Следующая новость
Предыдущая новость

Внутри x86-64 SystemV ABI. Как говорить с ядром Linux на его языке

17.05.2019 13:34
Внутри x86-64 SystemV ABI. Как говорить с ядром Linux на его языке

Содержание статьи

  • Демонстрационная задача
  • Среда разработки
  • Очень краткая справка
  • Что входит в соглашения ABI?
  • Формат ELF
  • Соглашения о вызовах
  • Соглашение о системных вызовах
  • Соглашение о вызовах функций
  • Пишем стандартную библиотеку
  • Где лежат аргументы командной строки?
  • Финальная программа
  • Заключение

Абстракция — основа программирования. Многие вещи мы используем, не задумываясь об их внутреннем устройстве, и они отлично работают. Всем известно, что пользовательские программы взаимодействуют с ядром через системные вызовы, но задумывался ли ты, как это происходит на твоей машине?

Вспомним сигнатуру функции main в C:

int main(int argc, char** argv) 

Откуда берутся число аргументов (argc) и массив указателей на их строки (argv)? Как возвращаемое значение main становится кодом возврата самой программы?

Краткий ответ: зависит от архитектуры процессора. К сожалению, доступных для начинающих материалов по самой распространенной сейчас архитектуре x86-64 не так много, и интересующиеся новички вынуждены сначала обращаться к старой литературе по 32-битным x86, которая следует другим соглашениям.

Попробуем исправить этот пробел и продемонстрировать прямое взаимодействие с машиной и ядром Linux сразу в 64-битном режиме.

Демонстрационная задача

Для демонстрации мы напишем расширенную версию hello world, которая может приветствовать любое количество объектов или людей, чьи имена передаются в аргументах команды.

$ ./hello Dennis Brian Ken Hello Dennis! Hello Brian! Hello Ken! 

Среда разработки

Для демонстрации мы будем использовать Linux и GNU toolchain (GCC и binutils), как самые распространенные ОС и среда разработки. Писать мы будем на языке ассемблера, потому что продемонстрировать низкоуровневое взаимодействие с ОС из языка сколько-нибудь высокого уровня невозможно.

Очень краткая справка

Чтобы упростить чтение статьи тем, кто вообще никогда не сталкивался с ассемблером x86, я использовал только самые простые инструкции и постарался аннотировать их псевдокодом везде, где возможно. Я использую синтаксис AT&T, который все инструменты GNU используют по умолчанию. Нужно помнить, что регистры пишутся с префиксом % (например, %rax), а константы — c префиксом $. Например, $255, $0xFF, $foo — значение символа foo.

Синтаксис указателей: смещение(база, индекс, множитель). Очень краткая справка:

  • mov <источник>, <приемник> — копирует значение из источника (регистра или адреса) в приемник;
  • push <источник> — добавляет значение из источника на стек;
  • pop <приемник> — удаляет значение из стека и копирует в приемник;
  • call <указатель на функцию> — вызывает функцию по указанному адресу;
  • ret — возврат из функции;
  • jmp <адрес> — безусловный переход по адресу (метке);
  • inc и dec — инкремент и декремент;
  • cmp <значение1> <значение2> — сравнение и установка флагов (например, равенство);
  • je <метка> — переход на метку в случае, если аргументы cmp оказались равными.

Условные переходы и циклы реализуются через инструкции сравнения и условные переходы. Инструкции сравнения устанавливают определенные разряды в регистре флагов, команда условного перехода проверяет их и принимает решение, переходить или нет. Например, следующий цикл увеличивает значение регистра %rax, пока оно не станет равным 10, а затем копирует его в %rbx.

Для изучения ассемблера x86 я могу посоветовать книгу Programming From the Ground Up — к сожалению, ориентированную на 32-битную архитектуру, но очень хорошо написанную и подходящую новичкам.

mov $0, %rax # rax = 0  my_loop:   inc %rax   cmp $10, %rax   jne my_loop # Jump if Not Equal  mov %rax, %rbx # %rbx = 10 

О регистрах: в x86-64 их куда больше. Кроме традиционных, добавлены регистры от %r8 до %r15, всего шестнадцать 64-битных регистров. Чтобы обратиться к нижним байтам новых регистров, нужно использовать суффиксы d, w, или b. То есть %r10d — нижние четыре байта, %r10w — нижние два байта, %10b — нижний байт.

Что входит в соглашения ABI?

SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.

К общей части прилагаются архитектурно зависимые дополнения. Они описывают:

  • соглашение о системных вызовах;
  • соглашение о вызовах функций;
  • организацию памяти процессов;
  • загрузку и динамическое связывание программ.

Формат ELF

Знать формат ELF в деталях, особенно его двоичную реализацию, нужно только авторам ассемблеров и компоновщиков. Эти задачи мы в статье не рассматриваем. Тем не менее пользователю следует понимать организацию формата.

Файлы ELF состоят из нескольких секций. Компиляторы принимают решение о размещении данных по секциям автоматически, но ассемблеры оставляют это на человека или компилятор. Полный список можно найти в разделе Special Sections. Вот самые распространенные:

  • .text — основной исполняемый код программы;
  • .rodata — данные только для чтения (константы);
  • .data — данные для чтения и записи (инициализированные переменные);
  • .bss — неинициализированные переменные известного размера.

Соглашения о вызовах

Соглашение о вызовах — важная часть ABI, которая позволяет пользовательским программам взаимодействовать с ядром, а программам и библиотекам — друг с другом. В соглашении указывается, как передаются аргументы (в регистрах или на стеке), какие именно регистры используются и где хранится результат. Кроме того, оговаривается, какие регистры вызываемая функция обязуется сохранить нетронутыми (callee-saved), а какие может свободно перезаписать (caller-saved).

Соглашение о системных вызовах

Системные вызовы выполняются с помощью инструкции процессора syscall.

INFO

На старых 32-разрядных x86 использовалось программное прерывание 0x80 и поныне используется в 32-разрядном коде. Инструкция syscall из x86-64 передает управление напрямую в точку входа в пространстве ядра, без накладных расходов на вызов обработчика прерывания.

Через регистры в ядро передается номер системного вызова и его аргументы. Соглашение для Linux описано в параграфе A.2.1.

  • Номер вызова передается в регистре %rax.
  • Можно передавать до шести аргументов в регистрах %rdi, %rsi, %rdx, %r10, %r9, %r8.
  • Результат возвращается в регистре %rax.
  • Отрицательный результат означает, что это номер ошибки (errno).
  • Регистры %rcx и %r11 должны быть сохранены пользователем.

Номера системных вызовов зависят от ОС и архитектуры. В Linux номера вызовов для x86-64 можно найти в заголовках ядра. На установленной системе он обычно находится в /usr/include/asm/unistd_64.h. Для наших целей потребуются всего два системных вызова: write (номер 1) и exit (номер 60).

Напишем простейшую программу, которая корректно завершается с кодом возврата 0 — аналог /bin/true.

.file "true.s" .section .text  _start:     # syscall(number=60/exit, arg0=0)     mov $60, %rax     mov $0, %rdi     syscall .global _start 

Код программы мы помещаем в секцию .text, как говорит директива .section .text. Метка _start — соглашение компоновщика ld, именно там он ожидает найти точку входа программы. Директива .global _start делает символ _start видимым для компоновщика.

Соберем и запустим программу:

$ as -o true.o ./true.s  && ld -nostdlib -o true ./true.o $ ./true && echo Success Success 

Соглашение о вызовах функций

Соглашение о вызовах функций похоже на соглашение о системных вызовах. Детали можно найти в разделе 3.2. Мы будем работать только с целыми числами и указателями, поэтому наши значения можно отнести к классу INTEGER.

К нашему случаю относятся следующие соглашения:

  • до шести аргументов можно передать в регистрах %rdi, %rsi, %rdx, %rcx, %r8, %r9;
  • возвращаемое значение нужно поместить в регистр %rax;
  • вызываемая функция обязана сохранить значения регистров %rbx, %rbp, %r12–15.

Пишем стандартную библиотеку

Пользуясь этими знаниями, мы можем написать небольшую стандартную библиотеку. Прежде всего нам понадобится функция puts, чтобы выводить строки на стандартный вывод. Системный вызов write сделает за нас почти всю работу. Единственная сложность в том, что он требует длину строки в качестве аргумента. Его условная сигнатура — write(file_descriptor, string_pointer, string_length). Поэтому нам потребуется функция strlen.

Сначала приведем код библиотеки, а потом разберем ее функции.

.file "stdlib.s"  .section .text  .macro save_registers     push %rbx     push %rbp     push %r12     push %r13     push %r14     push %r15 .endm  .macro restore_registers     pop %r15     pop %r14     pop %r13     pop %r12     pop %rbp     pop %rbx .endm  .macro write filedescr bufptr length     mov $1, %rax     mov filedescr, %rdi     mov bufptr, %rsi     mov length, %rdx     syscall .endm  ## strlen(char* buf) asm_strlen:     save_registers      # r12 — индекс символа в строке     mov $0, %r12 # index = 0      strlen_loop:         # r13b = buf[r12]         mov (%rdi, %r12, 1), %r13b          # if(r13b == 0) goto strlen_return         cmp $0, %r13b         je strlen_return          inc %r12 # index++         jmp strlen_loop      strlen_return:         # return index         mov %r12, %rax         restore_registers         ret .type asm_strlen, @function .global asm_strlen  ## puts(int filedescr, char* buf) asm_puts:     save_registers      mov %rdi, %r12 # r12 = filedescr     mov %rsi, %r13 # r13 = buf      # r13 = strlen(buf)     mov %r13, %rdi     call asm_strlen      mov %rax, %r14 # r14 = asm_strlen(buf)      write %r12 %r13 %r14      restore_registers     ret .type asm_puts, @function .global asm_puts 

Макросы save_registers и restore_registers просто автоматизируют сохранение регистров callee-saved. Первый добавляет все регистры на стек, а второй удаляет их значения из стека и возвращает обратно в регистры. Макрос write — более удобная обертка к системному вызову.

Функция strlen использует тот факт, что строки следуют соглашению языка С, — нулевой байт выступает в качестве признака конца строки. На каждом шаге цикла strlen_loop следующий байт строки сравнивается с нулем, и, пока он не равен нулю, значение индекса элемента в регистре %r12 увеличивается на единицу. Если он равен нулю, производится условный переход на метку strlen_return.

Внутри x86-64 SystemV ABI. Как говорить с ядром Linux на его языке

INFO

Семейство команд условных переходов в x86 довольно обширно и включает в себя команду jz — jump if zero. Я намеренно использовал команды, которые мне кажутся наиболее наглядными для читателей, не сталкивавшихся с языком ассемблера до этой статьи. Возможно, более правильно было бы для индекса элемента строки использовать регистр %r11, который зарезервирован как scratch register и не обязан сохраняться вызываемой функцией.

Попробуем использовать нашу библиотеку из программы на C. Сигнатура функции asm_puts с точки зрения C будет asm_puts(int filedescr, char* string). Выводить будем на stdout, его дескриптор всегда равен 1.

Сохраним следующий код в hello.c:

#define STDOUT 1  int main(void) {     asm_puts(STDOUT, "hello worldn"); } 

Теперь соберем из этого всего программу:

$ gcc -Wno-implicit-function-declaration -c -o hello.o ./hello.c $ as -o stdlib.o ./stdlib.s $ gcc -o hello ./hello.o ./stdlib.o $ ./hello hello world 

Как видим, вызов нашей функции из C сработал. Увы, main в исполнении GCC зависит от инициализаций из libc, поэтому финальную программу тоже придется писать на языке ассемблера, если мы не хотим эмулировать работу GCC.

Где лежат аргументы командной строки?

Ответ на это можно найти в разделе 3.4.1, и он проще, чем можно было ожидать: на стеке процесса. При запуске процесса регистр %rbp указывает на выделенный для него кадр стека, и первое значение на стеке — количество аргументов (argc). За ним следуют указатели на сами аргументы.

Таким образом, все, что нам нужно, — это несложный цикл, который извлекает значения со стека по одному и передает их нашей функции asm_puts.

Финальная программа

## Константы .section .rodata  hello_begin:     .ascii "Hello "  hello_end:     .ascii "!n"  ## Код программы .section .text  _start:     # argc — первое значение на стеке, сохраним его в %r12     pop %r12 # r12 = argc      # Следующее значение — *argv[0], это имя программы, и оно нам не нужно     pop %r13 # r13 = argv[0]     dec %r12 # argc--      # Сохраним первый нужный аргумент в %r13 перед входом в цикл main_loop     pop %r13 # r13 = argv[1]      main_loop:         # if(argc == 0) goto exit         cmp $0, %r12         je exit          # asm_puts(STDOUT, hello_begin)         mov $1, %rdi # rdi = STDOUT         mov $hello_begin, %rsi         call asm_puts          # asm_puts(STDOUT, argv[r12])         mov $1, %rdi         mov %r13, %rsi         call asm_puts          # asm_puts(STDOUT, hello_end)         mov $1, %rdi         mov $hello_end, %rsi         call asm_puts          pop %r13 # Извлекаем следующий аргумент         dec %r12 # argc--         jmp main_loop  exit:     # syscall(number=60/exit, arg0/exit_code=0)     mov $60, %rax     mov $0, %rdi     syscall  .global _start 

Соберем программу и проверим ее в работе:

$ as -o hello.o ./hello.s $ as -o stdlib.o ./stdlib.s $ ld -nostdlib -o hello ./hello.o ./stdlib.o $ ./hello Dennis Brian Ken Hello Dennis! Hello Brian! Hello Ken! 

Логика достаточно проста. Число аргументов, которое находится на вершине стека, мы сохраняем в регистре %r12, а затем извлекаем указатели на аргументы из стека и уменьшаем значение в %r12 на единицу, пока оно не достигнет нуля. Основной цикл программы организован через те же команды сравнения и условного перехода, которые мы уже видели в asm_strlen.

Поскольку форматированный вывод нам недоступен, его отсутствие приходится компенсировать отдельным выводом сначала строки Hello, затем аргумента и только затем восклицательного знака.

Заключение

Мы успешно поговорили с ядром Linux без посредников на его собственном языке. Такие упражнения несут мало практического смысла, но приближают нас к пониманию того, как userspace работает с ядром.

Источник

Последние новости