Программа взаимодействует с компьютером на нескольких уровнях: вызывает функции библиотек, выполняет машинные инструкции и обращается к ядру операционной системы. Эти уровни связаны, но у каждого свои правила. Понимание границ между ними помогает читать ассемблерный код, разбирать ошибки и исследовать работу программы в Linux.
API (Application Programming Interface) описывает взаимодействие на уровне исходного кода: имена функций, параметры, возвращаемые значения и назначение операций. Например, описание write() можно найти в документации и заголовочных файлах.
ABI (Application Binary Interface) определяет правила взаимодействия уже скомпилированного кода: какие регистры содержат аргументы, где находится результат, какие регистры нужно сохранять, как устроен стек и как выполняется системный вызов. Совпадения имен функций недостаточно: если двоичные компоненты используют несовместимые ABI, они не смогут корректно обмениваться данными.
Процессор извлекает инструкции из памяти, декодирует и выполняет их. В x86-64 регистр RIP связан с адресом следующей выполняемой инструкции. Арифметико-логическое устройство выполняет вычисления; промежуточные значения удобно держать в регистрах, а долговременные данные — в памяти. Поток команд меняют, в частности, переходы, вызовы функций, исключения и прерывания.
Ассемблер — текстовое представление машинных инструкций конкретной архитектуры. Для x86-64 существуют разные варианты записи: в примерах ниже используется синтаксис AT&T, принятый в GNU assembler (as). В синтаксисе Intel меняются, в частности, оформление операндов и их порядок. Поэтому, читая пример, сначала определите архитектуру и синтаксис.
В AT&T регистр обозначается знаком %, непосредственное числовое значение — знаком $. Обычно операнды идут в порядке источник, назначение, а суффикс q обозначает 64-битную операцию. Скобки вокруг регистра означают обращение к памяти по адресу в этом регистре.
movq $25, %rax # записать число 25 в RAX
movq %rax, %rbx # скопировать RAX в RBX
movq (%rax), %rbx # прочитать 8 байт из памяти по адресу RAX
movq %rbx, (%rax) # записать 8 байт в память по адресу RAX
Записи movq $25, %rax и movq 25, %rax не эквивалентны: вторая читает данные из памяти по абсолютному адресу 25, а не помещает в регистр число 25. Аналогично $value обозначает использование значения символа как непосредственного операнда, а value — обращение к памяти по этому адресу. Обычная movq не копирует данные напрямую из памяти в память: нужен промежуточный регистр.
Основные регистры общего назначения: RAX, RBX, RCX, RDX, RSI, RDI, R8–R15. RSP указывает на вершину стека; RBP может использоваться как опорный регистр кадра функции. RIP хранит указатель команд, а RFLAGS — флаги состояния, в том числе используемые условными переходами. Реальная роль регистра зависит от инструкции и соглашения о вызовах.
addq $5, %rax # RAX = RAX + 5
subq %rax, %rbx # RBX = RBX - RAX
incq %rax # RAX = RAX + 1
decq %rbx # RBX = RBX - 1
При беззнаковом умножении mulq операнд перемножает операнд и RAX, помещая 128-битный результат в пару RDX:RAX (старшая и младшая половины соответственно). divq операнд делит 128-битное число RDX:RAX на операнд: частное попадает в RAX, остаток — в RDX. Перед делением необходимо правильно подготовить оба регистра; в частности, для простого беззнакового 64-битного делимого обычно обнуляют RDX. Деление на ноль или непомещающееся частное вызывает исключение.
Стек в обычном режиме x86-64 растет в сторону меньших адресов. pushq уменьшает RSP на 8 и записывает значение на вершину стека; popq считывает его и увеличивает RSP на 8. Чтение movq (%rsp), %rax получает верхний элемент без изменения указателя стека. popq не стирает байты физически — значение просто перестает считаться лежащим на вершине стека.
Пользовательские приложения в x86-64 обычно работают с уровнем привилегий ring 3, а ядро — с ring 0. Приложение не может напрямую выполнять операции, меняющие критическое состояние процессора или системы: для этого оно обращается к ядру. Попытка выполнить недопустимую привилегированную инструкцию может вызвать исключение защиты. При этом ограничения зависят от конкретной инструкции и ее операндов: нельзя считать любую инструкцию из перечня «системных» всегда привилегированной.
Прерывание меняет обычный поток выполнения и передает управление обработчику. Источником может быть внешнее устройство; ошибки при выполнении инструкции (например, деление на ноль) относятся к исключениям процессора; программный код также может инициировать специальный переход. Эти случаи полезно различать: внешнее прерывание обычно асинхронно по отношению к выполняемой инструкции, а исключение связано с ее выполнением.
Для корректного продолжения работы процессор и обработчик сохраняют необходимую часть состояния, а затем восстанавливают ее при возврате. Обработка прерывания не тождественна переключению процессов: планировщик может воспользоваться переходом в ядро и выбрать другой процесс, но это отдельное решение.
Инструкции cli и sti изменяют флаг разрешения маскируемых внешних прерываний и применяются в привилегированном коде; они не отключают исключения вроде деления на ноль. Возврат из обработчика прерывания на x86-64 выполняется соответствующей формой iret. Обычное пользовательское приложение не должно пытаться применять cli и sti для синхронизации своих потоков.
Системный вызов — предусмотренный интерфейсом ОС способ запросить у ядра операцию, недоступную приложению напрямую: например, работу с файлом или памятью процесса. Важно различать вызов библиотечной функции и переход в ядро. printf() — функция стандартной библиотеки C; для вывода она в конечном счете может инициировать системный вызов write(). Библиотека при этом может буферизовать вывод.
Для Linux x86-64 соглашение о непосредственном вызове ядра отличается от соглашения о вызове обычной функции:
| Что передается | Вызов функции (целочисленные аргументы) | Системный вызов |
|---|---|---|
| Аргументы 1–3 | RDI, RSI, RDX |
RDI, RSI, RDX |
| Аргумент 4 | RCX |
R10 |
| Аргументы 5–6 | R8, R9 |
R8, R9 |
| Номер операции | — | RAX |
| Результат | Обычно RAX |
RAX |
Инструкция syscall передает управление ядру. При возврате значения RCX и R11 следует считать испорченными. Непосредственный интерфейс системных вызовов допускает до шести аргументов; дополнительные аргументы не передаются через пользовательский стек как часть этого соглашения. Отрицательные значения от -4095 до -1 в RAX обозначают код ошибки -errno. Это правило относится к непосредственному интерфейсу ядра: библиотечная обертка обычно возвращает -1 и устанавливает errno.
Пример: записать 13 байт Hello, World! в стандартный вывод через write и завершить программу. Для Linux x86-64 номер write — 1, номер exit — 60; файловый дескриптор стандартного вывода — 1.
.section .rodata
message:
.ascii "Hello, World!"
.section .text
.global _start
_start:
movq $1, %rax # write
movq $1, %rdi # stdout
leaq message(%rip), %rsi # адрес данных
movq $13, %rdx # число байт
syscall
movq $60, %rax # exit
xorq %rdi, %rdi # код завершения 0
syscall
Сохраните код в hello.s; в среде Linux x86-64 его можно собрать командой gcc -nostdlib -no-pie -o hello hello.s и запустить как ./hello. Здесь leaq вычисляет адрес строки относительно RIP, а не читает символы строки в регистр. Пример использует Linux-специфичный ABI: перенос на другую архитектуру требует других инструкций и соглашения о системных вызовах.
strace показывает системные вызовы программы и их результаты. Например, strace -e trace=write ./hello позволяет увидеть вызов write(1, "Hello, World!", 13) = 13. Для программы на C трасса обычно также содержит вызовы загрузчика и библиотеки. Если нужен отдельный файл трассы, используйте strace -o trace.txt ./hello: обычное перенаправление > output.txt направляет в файл стандартный вывод самой программы, тогда как strace по умолчанию пишет трассу в стандартный поток ошибок.
В исходниках ядра Linux таблица номеров системных вызовов x86-64 находится в arch/x86/entry/syscalls/syscall_64.tbl, а реализацию write можно искать в fs/read_write.c. Конкретный путь выполнения и имена внутренних функций зависят от версии ядра и конфигурации; их лучше проверять в исходниках именно той версии, которую вы изучаете. strace помогает наблюдать границу «программа — ядро», а дизассемблер или Ghidra — смотреть, какие инструкции содержит скомпилированная программа. Это разные, дополняющие друг друга способы анализа.