Погружение в x86-64 SystemV ABI

x86-64 SystemV ABI

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

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

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

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

РЕКОМЕНДУЕМ:
Отладка MIPS с помощью GDB

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

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

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

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

Для примера мы будем использовать 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-битную архитектуру, но очень хорошо написанную и подходящую новичкам.

О регистрах: в 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.

На старых 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.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

РЕКОМЕНДУЕМ:
Программирование в консоли

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

Заключение

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

Понравилась статья? Поделиться с друзьями:
Добавить комментарий