Ассемблер для программиста: регистры, стек, соглашения о вызовах
Почти никому сегодня не нужно писать на ассемблере. Компилятор распределяет регистры лучше человека, знает задержки инструкций на трёх поколениях микроархитектур и не устаёт к вечеру. Но читать ассемблер нужно многим — и по причинам, которые не имеют отношения к микрооптимизации.
Ассемблер — это единственное место, где видно, что компилятор на самом деле понял под вашим кодом. Пока вы смотрите на C, вы смотрите на свои намерения. Когда if (p == NULL) исчезает из бинаря, когда цикл превращается в четыре инструкции без цикла, когда strcpy затирает адрес возврата, а bt в gdb показывает мусор, — намерения кончаются и начинается машина. В C, где абстракции тонкие и дырявые, расстояние между «что я написал» и «что исполняется» иногда становится причиной бага. Эта статья — про то, как это расстояние измерить.
Мы разберём: модель машины (регистры, флаги, память), синтаксис и как получить листинг, кадр стека и соглашение о вызовах System V AMD64, конвенцию системных вызовов, инлайн-ассемблер, чем отличается AArch64, и как ассемблер объясняет неопределённое поведение. Предполагается, что вы прошли https://courses.digitable.life/post/systems-programming/02-memory-and-pointers/ и https://courses.digitable.life/post/systems-programming/05-build-and-linking/: понятие адреса и объектного файла здесь используется без повторного объяснения. Как устроен процессор на уровне тактов и конвейера — в статье про работу CPU в треке компьютерных наук.
Когда программисту действительно нужен ассемблер
Спускаться на этот уровень стоит не всегда. Полезность резко падает, если вы делаете это «чтобы было быстрее», и резко растёт, если вы делаете это, чтобы проверить гипотезу.
Пять сценариев, ради которых умение окупается: профиль показал горячую строку, но непонятно почему — в ассемблере видно деление, промах кеша, вызов вместо инлайна (https://courses.digitable.life/post/performance/03-cpu-profiling/); проверка компилятора — инлайнил? векторизовал? вынес инвариант из цикла?; последствия UB — удалённую проверку на NULL видно только в листинге (https://courses.digitable.life/post/systems-programming/11-undefined-behavior/); отладка без исходников — чужая библиотека, прошивка, core dump с прода (https://courses.digitable.life/post/systems-programming/06-debugging/); код, где ассемблер неизбежен — стартовый код, обработчики прерываний, переключение контекста (https://courses.digitable.life/post/embedded/02-c-for-embedded/), и защита от переполнений буфера (https://courses.digitable.life/post/security/14-secure-coding/).
Модель машины: регистры, флаги, память
Процессор не умеет работать «с переменными». Он умеет три вещи: перекладывать байты между регистрами и памятью, считать над содержимым регистров, менять счётчик команд. Регистр — это несколько десятков триггеров прямо в ядре: доступ за такт, тогда как обращение к L1-кешу стоит около 4 тактов, а к DRAM — сотни (подробно про иерархию — в https://courses.digitable.life/post/performance/05-cache-and-locality/). Поэтому регистров мало: они дороги физически и, главное, каждый занимает биты в кодировке инструкции.
На x86-64 доступно 16 регистров общего назначения по 64 бита плюс 16 (или 32 с AVX-512) векторных. Историческая особенность: каждый регистр имеет несколько имён для разных ширин.
Из этой схемы сразу следуют две вещи, которые постоянно встречаются в листингах:
xor eax, eaxвместоmov rax, 0— короче кодируется (2 байта против 7) и разрывает зависимость по данным, а запись в 32-битное имя обнуляет старшую половину.movzx eax, alпослеsetcc— потому чтоsetccпишет только в младший байт, а старшие 56 бит остаются мусором от прошлого значения.
Регистр флагов RFLAGS хранит результат последней арифметической операции. Четыре бита определяют почти все переходы:
| Флаг | Смысл | Ставится когда |
|---|---|---|
ZF |
результат нулевой | sub, cmp, test, and, add… |
SF |
старший бит результата = 1 | там же |
CF |
перенос/заём за пределы беззнакового диапазона | там же |
OF |
переполнение знакового диапазона | там же |
Отсюда важнейший для C факт: у машины нет типов, но есть две арифметики сравнения. cmp a, b — это sub, результат которого выбрасывается, а флаги остаются. А дальше компилятор выбирает переход исходя из типа в исходнике:
int lt_signed(int a, int b) { return a < b; } // → setl (SF != OF)
int lt_unsigned(unsigned a, unsigned b) { return a < b; } // → setb (CF == 1)
Одна и та же машинная операция cmp edi, esi, разные переходы. Когда в C случайно смешиваются int и size_t, ошибка ровно здесь — и в ассемблере она видна мгновенно: вместо jb стоит jl.
Карта инструкций: их не сотни, а десятки
Набор x86-64 огромен, но 90% сгенерированного компилятором кода — это два десятка инструкций.
Отдельно про lea (load effective address). Она не обращается к памяти: вычисляет адресное выражение base + index*scale + disp и кладёт результат в регистр. Компилятор использует её как трёхоперандный сумматор с умножением на 2, 4, 8:
lea eax, [rdi+rsi*4+8] ; eax = rdi + rsi*4 + 8, одна инструкция, флаги не трогает
Принять lea за чтение из памяти — ошибка новичка номер один: квадратные скобки здесь означают «адрес», а не «значение по адресу».
Как получить листинг
Три способа увидеть машинный уровень, у каждого своя роль.
# 1. Компилятор -> текст ассемблера. Лучший вариант для изучения.
gcc -O2 -S -masm=intel -fverbose-asm \
-fno-asynchronous-unwind-tables -fno-stack-protector -o - hot.c
# 2. Дизассемблировать то, что реально в объектнике/бинаре.
gcc -O2 -c hot.c && objdump -d -M intel --no-show-raw-insn hot.o
objdump -dr -M intel hot.o # -r: релокации — куда линкер впишет адреса
objdump --disassemble=parse_line -M intel app
gcc -O2 -g -c hot.c && objdump -S -M intel hot.o # вперемешку с исходником, привязка приблизительная
# 3. Что компилятор думает об оптимизациях
gcc -O3 -fopt-info-vec -fopt-info-vec-missed -c hot.c
-fno-asynchronous-unwind-tables убирает директивы .cfi_*, нужные для разворачивания стека, но при чтении только мешающие. -fverbose-asm дописывает к инструкциям имена переменных из исходника — бесценно на первых порах. Онлайн-вариант без установки — Compiler Explorer (https://godbolt.org/), где подсветка связывает строки C и строки ассемблера. В gdb то же самое доступно вживую: disassemble /s parse_line, x/8i $pc (первое, что делают при SIGILL/SIGSEGV), info registers, p $rdi, layout asm.
Два синтаксиса: AT&T и Intel
Один и тот же байт-код записывается двумя способами. GCC и objdump по умолчанию используют AT&T, документация Intel и большинство книг — Intel-синтаксис.
| Что | AT&T (по умолчанию в GCC) | Intel (-masm=intel) |
|---|---|---|
| Порядок операндов | mov src, dst |
mov dst, src |
| Регистры и константы | movq $1, %rax |
mov rax, 1 |
| Размер операнда | суффикс: movb/movw/movl/movq |
из регистра или BYTE PTR |
| Адресация | movl -8(%rbp,%rcx,4), %eax |
mov eax, DWORD PTR [rbp+rcx*4-8] |
| Косвенный вызов | call *%rax |
call rax |
Дальше в статье — Intel-синтаксис, потому что порядок «куда, откуда» совпадает с присваиванием в C. Но помните: objdump без флага выдаст AT&T, и перепутанный порядок операндов — вторая по частоте ошибка чтения. Ещё половина текста в .s — это вообще не инструкции, а директивы ассемблера: .globl, .type, .size, .section .rodata, .p2align 4, .LC0:. Они не исполняются, а управляют тем, что и в какую секцию положить; их разбор — в https://courses.digitable.life/post/systems-programming/05-build-and-linking/.
Первый разбор: от -O0 к -O2
// sum3.c
int sum3(int a, int b, int c) { return a + b + c; }
Без оптимизаций (gcc -O0 -S -masm=intel) компилятор буквально следует модели «каждая переменная живёт в памяти»:
sum3:
push rbp ; сохранить кадр вызывающего
mov rbp, rsp ; установить свой указатель кадра
mov DWORD PTR [rbp-4], edi ; a -> стек
mov DWORD PTR [rbp-8], esi ; b -> стек
mov DWORD PTR [rbp-12], edx ; c -> стек
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add edx, eax
mov eax, DWORD PTR [rbp-12]
add eax, edx ; результат — в eax
pop rbp
ret
С -O2 то же самое становится тремя инструкциями:
sum3:
add edi, esi ; a += b
lea eax, [rdi+rdx] ; eax = (a+b) + c
ret ; кадра стека нет вообще
Отсюда три вывода, определяющие всю дальнейшую работу. -O0-листинг — не ваша программа: он полезен ровно один раз, чтобы увидеть соответствие «переменная ↔ слот на стеке», а выводы о поведении и скорости делают по флагам прода. Переменных в оптимизированном коде нет — есть регистры, в которых по очереди живут разные значения; именно поэтому gdb на -O2 пишет <optimized out>. Кадр стека — не обязательная часть функции: листовая функция без локальных массивов вообще не трогает rsp.
Стек и кадр вызова
Стек — область памяти, растущая вниз (в сторону меньших адресов), а rsp всегда указывает на её вершину. Аппаратура знает про стек ровно четыре вещи: push (уменьшить rsp на 8 и записать), pop (прочитать и увеличить), call (положить адрес следующей инструкции и прыгнуть), ret (снять адрес и прыгнуть на него). Всё остальное — соглашение.
Отдельно стоит запомнить хвостовой вызов: -O2 превращает return inner(x + 1); в add edi, 1; jmp inner — кадр разбирается до перехода, стек не растёт (это позволяет писать бесконечную взаимную рекурсию), но в backtrace промежуточный кадр отсутствует. Типичный источник недоумения при отладке.
Полная картина того, что лежит на стеке в момент передачи управления:
Соглашение о вызовах System V AMD64
Соглашение (calling convention) — контракт между вызывающим и вызываемым, который делает возможной раздельную компиляцию: функция из одного .o может звать функцию из другого, потому что оба компилятора следуют одному документу. Для Linux/macOS/BSD это System V AMD64 psABI, и его ключевые правила таковы:
- Целые и указатели:
rdi, rsi, rdx, rcx, r8, r9, дальше — стек, справа налево. - Вещественные:
xmm0–xmm7, считаются отдельной очередью от целых. - Возврат:
rax(для 128-бит —rdx:rax),xmm0/xmm1для плавающей точки. - Callee-saved (вызванная функция обязана вернуть как было):
rbx,rbp,rsp,r12–r15. Всё остальное — caller-saved: если значение нужно после вызова, сохраните сами. - Выравнивание:
rspкратен 16 в момент выполненияcall. Значит, при входе в функциюrsp + 8кратно 16. Нарушение выравнивания не «чуть медленнее» — этоSIGSEGVна первой жеmovapsв вызванной функции. - Красная зона: 128 байт ниже
rspпринадлежат листовой функции, ядро их не портит при доставке сигнала. Поэтому в коде ядра всегда-mno-red-zone. - Variadic-функции: в
alкладётся количество использованныхxmm-регистров. Именно поэтому передcall printfвы видитеxor eax, eaxилиmov eax, 2.
Протокол целиком, включая то, кто и что сохраняет:
Строчка «аргумент 7 по [rbp+16]» стоит отдельного пояснения: после push rbp по адресу [rbp] лежит сохранённый rbp, по [rbp+8] — адрес возврата, значит первый стековый аргумент начинается с [rbp+16]. Это арифметика, которую в дизассемблере приходится делать в уме постоянно.
Структуры: почему struct иногда бесплатна
ABI классифицирует агрегаты по восьмибайтовым кускам. Правило упрощённо: агрегат размером до 16 байт включительно разбирается на два «eightbyte», каждый получает класс INTEGER или SSE и едет в своём регистре. Больше 16 байт — класс MEMORY, то есть копия на стеке.
struct pair { long a, b; }; // 2 x INTEGER → rdi, rsi
struct point { double x, y; }; // 2 x SSE → xmm0, xmm1
struct mixed { long id; double w; }; // INTEGER+SSE → rdi, xmm0
struct big { long v[4]; }; // 32 байта → MEMORY: копируется на стек
struct pair make_pair(void); // возврат в rdx:rax — копирования нет
struct big make_big(void); // скрытый указатель в rdi, он же вернётся в rax
Практический вывод: возврат маленькой структуры по значению в C бесплатен — это два регистра, а не memcpy. Возврат структуры из четырёх long уже стоит копии в память. Раскладка полей и выравнивание — тема https://courses.digitable.life/post/systems-programming/03-arrays-strings-structs/, и она напрямую влияет на то, во что превратится ваш struct на границе вызова.
Другие ABI, с которыми вы столкнётесь
| System V AMD64 (Linux, macOS) | Microsoft x64 (Windows) | |
|---|---|---|
| Целые аргументы | rdi, rsi, rdx, rcx, r8, r9 |
rcx, rdx, r8, r9 — всего 4 |
| Вещественные | xmm0–xmm7, отдельная очередь |
xmm0–xmm3, общая позиция с целыми |
| Место на стеке | не резервируется | 32 байта shadow space обязательно |
| Callee-saved | rbx, rbp, r12–r15 |
плюс rdi, rsi, xmm6–xmm15 |
| Красная зона | 128 байт | нет |
Это главная причина, по которой ассемблерные вставки непереносимы между платформами, а #ifdef _WIN32 в низкоуровневых библиотеках занимает столько места. На 32-битном i386 всё было ещё иначе — аргументы целиком через стек (cdecl), поэтому старые учебники сбивают с толку.
Конвенция системных вызовов
Переход в ядро — это отдельный ABI, и он специально сделан не совпадающим с функциональным.
| Вызов функции | syscall на x86-64 Linux |
|
|---|---|---|
| Номер/цель | адрес в call |
номер в rax |
| Аргументы | rdi, rsi, rdx, rcx, r8, r9 |
rdi, rsi, rdx, r10, r8, r9 |
| Возврат | rax |
rax; ошибка — как -errno в диапазоне от −4095 до −1 |
| Портит | caller-saved | rcx (туда уходит адрес возврата) и r11 (туда уходят флаги) |
rcx заменён на r10 не по прихоти: инструкция syscall аппаратно кладёт адрес возврата в rcx, поэтому передавать в нём аргумент невозможно. Отсюда же ответ на вопрос «почему write() из libc — это обёртка, а не сам вызов»: libc перекладывает rcx → r10, превращает отрицательный rax в -1 плюс errno и обрабатывает EINTR-политику. Подробно про эту границу — https://courses.digitable.life/post/systems-programming/07-syscalls-and-io/ и https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/.
// Прямой системный вызов без libc: в продакшене так делать не нужно,
// но полезно один раз увидеть, из чего состоит write().
static long sys_write(int fd, const void *buf, size_t n)
{
long ret;
__asm__ __volatile__ (
"syscall"
: "=a" (ret) // выход: результат в rax
: "a" (1L), "D" (fd), "S" (buf), "d" (n) // вход: rax=1 (write), rdi, rsi, rdx
: "rcx", "r11", "memory" // syscall портит rcx и r11; memory — барьер для оптимизатора
);
return ret;
}
Разбор синтаксиса extended asm GCC (https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html): четыре секции через двоеточие — шаблон, выходы, входы, clobber-список. Ограничения (constraints) сообщают компилятору, в каком регистре должно оказаться значение: a = rax, D = rdi, S = rsi, d = rdx, r = любой, m = память. Строка "memory" в clobber-списке запрещает компилятору переносить обращения к памяти через эту вставку.
Три правила про инлайн-ассемблер, нарушение которых даёт баги, которые невозможно найти:
- Перечислите всё, что портите. Забытый регистр в clobber-списке — компилятор считает его целым и читает мусор. Проявится через полгода после смены версии компилятора.
volatileобязателен, если у вставки есть эффект помимо вычисления выхода. Без него вставку без используемых выходов просто удалят, а вставку в цикле вынесут наружу.- Сначала ищите intrinsic.
__rdtsc(),__builtin_popcount(),_mm_add_ps(),__atomic_fetch_add()дают то же самое, но остаются видимыми для оптимизатора и переносимы между компиляторами. Ручная вставка — это чёрный ящик, вокруг которого оптимизация останавливается.
Что ассемблер говорит про неопределённое поведение
Здесь машинный уровень перестаёт быть любопытством и становится инструментом. Компилятор оптимизирует, предполагая, что UB не происходит — и результат этого предположения виден только в листинге.
// ub.c
int deref_then_check(int *p) {
int x = *p; // разыменование — значит, компилятор вправе считать p != NULL
if (p == NULL) return -1; // ...значит, эта ветка недостижима
return x;
}
int signed_overflow(int x) { return x + 1 > x; }
unsigned unsigned_wrap(unsigned x) { return x + 1 > x; }
gcc -O2 -S -masm=intel:
deref_then_check:
mov eax, DWORD PTR [rdi] ; проверки на NULL просто НЕТ
ret
signed_overflow:
mov eax, 1 ; знаковое переполнение — UB, значит x+1 > x всегда истинно
ret
unsigned_wrap:
cmp edi, -1 ; беззнаковое — определено (обёртка по модулю 2^32),
setne al ; поэтому проверка осталась: ложь только при x == UINT_MAX
movzx eax, al
ret
Три инструкции, которые объясняют больше, чем страница текста. Проверка if (p == NULL) исчезла из бинаря — не потому что компилятор злой, а потому что он вправе рассуждать так: «разыменование состоялось, значит указатель валиден, значит сравнение с нулём ложно». Именно этот механизм в 2009 году породил CVE-2009-1897 в ядре Linux, где удалённая компилятором проверка на NULL превратилась в эксплуатируемую уязвимость.
Отсюда — почему «работает на моей машине» в C особенно опасно. Различие между -O0 и -O2 не мистика: на -O0 компилятор ничего не предполагает и код случайно работает; на -O2 он использует своё право. Смена версии GCC, добавление inline, изменение порядка функций — любое из этого меняет, какое именно предположение сработает. Практический вывод: листинг — это способ подтвердить, что UB состоялось, а не способ его найти. Ищут его санитайзеры (-fsanitize=undefined,address), см. https://courses.digitable.life/post/systems-programming/06-debugging/; систематический разбор — в https://courses.digitable.life/post/systems-programming/11-undefined-behavior/ и у Джона Регера, A Guide to Undefined Behavior in C and C++.
Как читать незнакомую функцию: рецепт
или disassemble /s в gdb"] --> B{"Есть пролог
push rbp; mov rbp,rsp?"} B -- да --> C["Аргументы 7+ по [rbp+16],
локальные по [rbp-N]"] B -- нет --> D["Листовая функция или -fomit-frame-pointer:
всё адресуется от rsp"] C --> E["sub rsp, N — размер локальных;
push rbx/r12–r15 — что сохранено"] D --> E E --> F["Аргументы: чтения rdi, rsi, rdx, rcx,
r8, r9 ДО первой записи в них"] F --> G["Поток управления: метки .L,
обратные переходы = циклы"] G --> H{"Есть call?"} H -- да --> I["Символ или релокация;
call ...@PLT = внешняя функция"] H -- нет --> J["Листовая: может пользоваться
красной зоной"] I --> L J --> L["Эпилог: leave/pop + ret.
Что в rax — то и вернётся"] L --> M{"Сходится с прототипом на C?"} M -- нет --> N["Проверить: хвостовой вызов (jmp),
инлайн, векторизация, PLT-заглушка"] N --> G M -- да --> O["Функция понята"]
Разберём по рецепту цикл — самый частый объект чтения:
long sum(const long *a, size_t n) {
long s = 0;
for (size_t i = 0; i < n; i++) s += a[i];
return s;
}
gcc -O1 -S -masm=intel:
sum:
test rsi, rsi ; n == 0 ? (test = and без записи результата)
je .Lempty
lea rdx, [rdi+rsi*8] ; rdx = a + n — адрес ЗА последним элементом
xor eax, eax ; s = 0
.Lloop:
add rax, QWORD PTR [rdi] ; s += *a
add rdi, 8 ; ++a (шаг = sizeof(long))
cmp rdi, rdx ; дошли до конца?
jne .Lloop ; обратный переход = тело цикла
ret
.Lempty:
xor eax, eax
ret
Обратите внимание: индекса i в машинном коде нет. Компилятор применил strength reduction — заменил a[i] с умножением на инкремент указателя, а условие i < n — на сравнение указателей. Ровно то, что в C писали руками в 1980-е и что сегодня писать руками не нужно. На -O3 та же функция превращается в векторный цикл: vpaddq ymm0, ymm0, [rdi] обрабатывает четыре long за инструкцию, плюс скалярный «хвост» для остатка и пролог для выравнивания. Именно поэтому -O3-листинг втрое длиннее — и именно поэтому -fopt-info-vec-missed (объясняющий, почему цикл не векторизовался: возможный алиасинг, зависимость по данным, неизвестное число итераций) полезнее, чем чтение результата.
Защитные механизмы глазами ассемблера
Всё, что делает переполнение буфера трудноэксплуатируемым, живёт в прологе и эпилоге. Посмотрим на функцию с очевидной ошибкой:
void greet(const char *name) {
char buf[32];
strcpy(buf, name); // переполнение, если name длиннее 31 байта
puts(buf);
}
gcc -O2 -fstack-protector-strong -fcf-protection -S -masm=intel:
greet:
endbr64 ; CET: сюда разрешён косвенный переход
sub rsp, 56
mov rsi, rdi
mov rax, QWORD PTR fs:0x28 ; канарейка из TLS (сегмент fs)
mov QWORD PTR [rsp+40], rax ; положить её между buf и адресом возврата
xor eax, eax
mov rdi, rsp
call strcpy
mov rdi, rsp
call puts
mov rax, QWORD PTR [rsp+40]
sub rax, QWORD PTR fs:0x28 ; канарейка цела?
jne .Lsmashed
add rsp, 56
ret
.Lsmashed:
call __stack_chk_fail ; «stack smashing detected» и abort()
Что видно:
- Stack canary (
-fstack-protector-strong) кладётся выше буфера. Линейное переполнение черезstrcpyзатрёт её раньше, чем доберётся до адреса возврата, и__stack_chk_failубьёт процесс. Против точечной записи по вычисленному индексу защиты нет. endbr64(-fcf-protection, включено по умолчанию в Fedora/Ubuntu) — метка «сюда можно прыгать косвенно» для Intel CET. Прыжок в середину функции аппаратно запрещён, что ломает классический ROP.- PIE и RIP-относительная адресация: строковые константы адресуются как
lea rax, [rip+.LC0], а не по абсолютному адресу. Это то, что делает возможной ASLR — см. https://courses.digitable.life/post/systems-programming/05-build-and-linking/. call strcpy@PLTв динамически слинкованном коде — переход через таблицу связывания, разрешаемый при первом вызове.
Ни один из этих механизмов не чинит ошибку. Они превращают «тихую компрометацию» в «громкое падение» — что для инженера огромная разница, но не заменяет корректный код.
AArch64: то же самое, но иначе
Если вы работаете с Apple Silicon, ARM-серверами или микроконтроллерами, вторая архитектура становится обязательной. Хорошая новость: понятия те же — регистры, кадр, ABI (AAPCS64).
| x86-64 | AArch64 | |
|---|---|---|
| Регистров общего назначения | 16 | 31 (x0–x30), xN = 64 бита, wN = младшие 32 |
| Аргументы | rdi, rsi, rdx, rcx, r8, r9 |
x0–x7 |
| Возврат | rax |
x0 |
| Адрес возврата | на стеке (кладёт call) |
в регистре x30 (LR), кладёт bl |
| Арифметика с памятью | add rax, [rdi] можно |
нельзя: только load/store |
| Указатель кадра | rbp |
x29 (FP) |
| Callee-saved | rbx, rbp, r12–r15 |
x19–x28, x29, x30 |
| Длина инструкции | 1–15 байт | ровно 4 байта |
sum3: // AArch64, gcc -O2
add w0, w0, w1 // аргументы уже в w0, w1, w2
add w0, w0, w2 // результат — тоже w0
ret // возврат по адресу в x30, без обращения к памяти
foo: stp x29, x30, [sp, #-32]! // пролог с кадром: сохранить FP и LR и сдвинуть sp одной инструкцией
mov x29, sp
ldp x29, x30, [sp], #32 // эпилог: восстановить и вернуть sp обратно
ret
Два наблюдения. Адрес возврата в регистре, а не на стеке, — листовые функции вообще не касаются памяти, зато нелистовые обязаны сохранить x30 в прологе, иначе вложенный bl его затрёт. И архитектура load/store: нет инструкции, которая читает память и считает одновременно, поэтому листинги длиннее, но однороднее. Фиксированная длина инструкции в 4 байта означает, что дизассемблировать AArch64 можно с любого адреса — на x86-64 сдвиг на байт даёт совершенно другой (и валидный!) поток инструкций, чем пользуются обфускаторы и эксплойты.
Типичные ошибки при чтении
leaпринимают за загрузку из памяти. Скобки вlea— арифметика, а не разыменование. И путают порядок операндов, переключаясь междуobjdump(AT&T) и документацией (Intel).- Делают выводы по
-O0. Это отдельная программа, не ваша. - Ищут переменные из исходника. Их нет: одно значение живёт в трёх регистрах по очереди, а три переменные — в одном.
- Считают, что строка C = инструкция. Инлайн, переупорядочивание и векторизация уничтожают соответствие;
-gдаёт лишь приблизительную привязку, поэтомуperf annotateиногда «обвиняет» соседнюю строку (плюс skid — задержка выборки семпла). - Пропускают хвостовой вызов.
jmpв конце функции — это вызов, просто без кадра. Так же незаметно проскакиваетcall memcpy@PLT: это не memcpy, а заглушка, разрешаемая динамическим компоновщиком. - Не различают
jlиjb. Первое — знаковое сравнение, второе — беззнаковое; в C эта разница определяется типом операндов и порождает классические баги на границеint/size_t.
Практика: что с этим делать на работе
Проверять гипотезы одной командой. «Точно ли эта маленькая функция инлайнится?» — objdump --disassemble=caller app | grep call: если вызова нет, инлайнилась. Смотреть x/8i $pc при падении: разница между «читаем по нулевому указателю» и «прыгнули по мусорному адресу» видна сразу и вдвое сужает поиск. Сравнивать варианты на godbolt — 30 секунд и спор в code review закрыт без бенчмарка.
Читать горячий цикл после профилирования, а не до. perf record ./app && perf annotate --stdio покажет проценты рядом с инструкциями. Ищите: div/idiv (десятки тактов), скалярные инструкции там, где ожидалась векторизация, обращения к памяти в глубине цикла, lock-префиксы (см. https://courses.digitable.life/post/systems-programming/09-threads-and-sync/).
Не писать ассемблер, если есть intrinsic. Ручная вставка выключает оптимизатор вокруг себя и ломается при смене ABI. Исключения честные, но узкие: стартовый код, переключение контекста, доступ к системным регистрам, setjmp-подобные трюки.
Смежные темы на портале: генерация машинного кода и распределение регистров как раскраска графа — в треке компиляторов и в статье про оптимизации; двоичное представление чисел — в компьютерных науках; контекст процесса и переключение — в операционных системах; стартовый код и работа с регистрами периферии — в треке embedded.
Итог
- Ассемблер читают, а не пишут. Ценность — в проверке гипотез о том, что компилятор сделал с вашим кодом.
- Регистров 16, имена вложенные. Запись в 32-битное имя обнуляет старшую половину, в 8- и 16-битное — нет; отсюда
xor eax, eaxиmovzxв каждом втором листинге. - Флаги — общий механизм для всех сравнений, а знаковость живёт не в данных, а в выборе перехода:
jlпротивjb. Иleaне обращается к памяти — это трёхоперандный сумматор. - Кадр стека — соглашение, а не аппаратура. Процессор знает только
push/pop/call/ret; всё остальное — ABI. - System V AMD64:
rdi, rsi, rdx, rcx, r8, r9для целых,xmm0–xmm7для вещественных,raxдля возврата,rbx/rbp/r12–r15callee-saved,rspкратен 16 передcall. - Системный вызов — отдельный ABI: номер в
rax,r10вместоrcx, ошибка как отрицательныйrax. - Ассемблер — доказательство UB. Исчезнувшая проверка на
NULLиmov eax, 1вместо сравнения — это последствия права компилятора считать, что UB не бывает. - Защиты (канарейка,
endbr64, PIE) видны в прологе и эпилоге и превращают тихую компрометацию в громкое падение, но не заменяют корректный код. - AArch64 — те же понятия: адрес возврата в
x30, архитектура load/store, инструкции по 4 байта.
Источники
- System V AMD64 psABI (единственный нормативный документ по соглашению о вызовах): https://gitlab.com/x86-psABIs/x86-64-ABI
- Arm AAPCS64 — процедурное соглашение для AArch64: https://github.com/ARM-software/abi-aa
- Intel 64 and IA-32 Architectures Software Developer Manuals: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- Bryant, O’Hallaron. Computer Systems: A Programmer’s Perspective, глава 3 «Machine-Level Representation of Programs»: https://csapp.cs.cmu.edu/
- Agner Fog. Optimizing subroutines in assembly language и таблицы задержек инструкций: https://www.agner.org/optimize/
- GCC Extended Asm — синтаксис вставок, constraints, clobbers: https://gcc.gnu.org/onlinedocs/gcc/Extended-Asm.html; Compiler Explorer: https://godbolt.org/
man 2 syscall(таблица регистров для всех архитектур Linux) иman 7 vdso; J. Regehr. A Guide to Undefined Behavior in C and C++: https://blog.regehr.org/archives/213
Что дальше
Мы увидели, во что компилятор превращает C, и главное — увидели, как он вырезает код, опираясь на предположение, что неопределённого поведения не бывает. Дальше разберём это предположение целиком: какие конструкции в C неопределены, почему стандарт написан именно так, как UB выглядит в реальных уязвимостях и какая инженерная дисциплина позволяет ошибаться реже.
Неопределённое поведение и безопасность памяти: как ломается и как не ломать