Содержание статьи
Вспомним сигнатуру функции 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
— нижние два байта, %r10b
— нижний байт.
Что входит в соглашения ABI?
SystemV ABI, которой в большей или меньшей степени следуют почти все UNIX-подобные системы, состоит из двух частей. Первая часть, общая для всех систем, описывает формат исполняемых файлов ELF. Ее можно найти на сайте SCO.
К общей части прилагаются архитектурно зависимые дополнения. Они описывают:
- соглашение о системных вызовах;
- соглашение о вызовах функций;
- организацию памяти процессов;
- загрузку и динамическое связывание программ.
Формат ELF
Знать формат ELF в деталях, особенно его двоичную реализацию, нужно только авторам ассемблеров и компоновщиков. Эти задачи мы в статье не рассматриваем. Тем не менее пользователю следует понимать организацию формата.
Файлы ELF состоят из нескольких секций. Компиляторы принимают решение о размещении данных по секциям автоматически, но ассемблеры оставляют это на человека или компилятор. Полный список можно найти в разделе Special Sections. Вот самые распространенные:
.text
— основной исполняемый код программы;.rodata
— данные только для чтения (константы);.data
— данные для чтения и записи (инициализированные переменные);.bss
— неинициализированные переменные известного размера.
Соглашения о вызовах
Соглашение о вызовах — важная часть ABI, которая позволяет пользовательским программам взаимодействовать с ядром, а программам и библиотекам — друг с другом. В соглашении указывается, как передаются аргументы (в регистрах или на стеке), какие именно регистры используются и где хранится результат. Кроме того, оговаривается, какие регистры вызываемая функция обязуется сохранить нетронутыми (callee-saved), а какие может свободно перезаписать (caller-saved).
Продолжение доступно только участникам
Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте
Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее
Вариант 2. Открой один материал
Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.
Я уже участник «Xakep.ru»