Системное программирование на C Ассемблер для программиста: регистры, стек, соглашения о вызовах
0%

Ассемблер для программиста: регистры, стек, соглашения о вызовах

Ассемблер для программиста: регистры, стек, соглашения о вызовах

Почти никому сегодня не нужно писать на ассемблере. Компилятор распределяет регистры лучше человека, знает задержки инструкций на трёх поколениях микроархитектур и не устаёт к вечеру. Но читать ассемблер нужно многим — и по причинам, которые не имеют отношения к микрооптимизации.

Ассемблер — это единственное место, где видно, что компилятор на самом деле понял под вашим кодом. Пока вы смотрите на 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) векторных. Историческая особенность: каждый регистр имеет несколько имён для разных ширин.

Регистры x86-64: подрегистры rax/eax/ax/al и роли по System V ABI

Из этой схемы сразу следуют две вещи, которые постоянно встречаются в листингах:

  • 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: распределение аргументов по регистрам и стеку, кадр вызова и красная зона

Соглашение о вызовах System V AMD64

Соглашение (calling convention) — контракт между вызывающим и вызываемым, который делает возможной раздельную компиляцию: функция из одного .o может звать функцию из другого, потому что оба компилятора следуют одному документу. Для Linux/macOS/BSD это System V AMD64 psABI, и его ключевые правила таковы:

  • Целые и указатели: rdi, rsi, rdx, rcx, r8, r9, дальше — стек, справа налево.
  • Вещественные: xmm0xmm7, считаются отдельной очередью от целых.
  • Возврат: rax (для 128-бит — rdx:rax), xmm0/xmm1 для плавающей точки.
  • Callee-saved (вызванная функция обязана вернуть как было): rbx, rbp, rsp, r12r15. Всё остальное — 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
Вещественные xmm0xmm7, отдельная очередь xmm0xmm3, общая позиция с целыми
Место на стеке не резервируется 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-списке запрещает компилятору переносить обращения к памяти через эту вставку.

Три правила про инлайн-ассемблер, нарушение которых даёт баги, которые невозможно найти:

  1. Перечислите всё, что портите. Забытый регистр в clobber-списке — компилятор считает его целым и читает мусор. Проявится через полгода после смены версии компилятора.
  2. volatile обязателен, если у вставки есть эффект помимо вычисления выхода. Без него вставку без используемых выходов просто удалят, а вставку в цикле вынесут наружу.
  3. Сначала ищите 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++.

Как читать незнакомую функцию: рецепт

Разберём по рецепту цикл — самый частый объект чтения:

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 (x0x30), xN = 64 бита, wN = младшие 32
Аргументы rdi, rsi, rdx, rcx, r8, r9 x0x7
Возврат rax x0
Адрес возврата на стеке (кладёт call) в регистре x30 (LR), кладёт bl
Арифметика с памятью add rax, [rdi] можно нельзя: только load/store
Указатель кадра rbp x29 (FP)
Callee-saved rbx, rbp, r12–r15 x19x28, 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 для целых, xmm0xmm7 для вещественных, rax для возврата, rbx/rbp/r12–r15 callee-saved, rsp кратен 16 перед call.
  • Системный вызов — отдельный ABI: номер в rax, r10 вместо rcx, ошибка как отрицательный rax.
  • Ассемблер — доказательство UB. Исчезнувшая проверка на NULL и mov eax, 1 вместо сравнения — это последствия права компилятора считать, что UB не бывает.
  • Защиты (канарейка, endbr64, PIE) видны в прологе и эпилоге и превращают тихую компрометацию в громкое падение, но не заменяют корректный код.
  • AArch64 — те же понятия: адрес возврата в x30, архитектура load/store, инструкции по 4 байта.

Источники

Что дальше

Мы увидели, во что компилятор превращает C, и главное — увидели, как он вырезает код, опираясь на предположение, что неопределённого поведения не бывает. Дальше разберём это предположение целиком: какие конструкции в C неопределены, почему стандарт написан именно так, как UB выглядит в реальных уязвимостях и какая инженерная дисциплина позволяет ошибаться реже.

Неопределённое поведение и безопасность памяти: как ломается и как не ломать

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов