Embedded HandbookПриложения
Все разделы › XVII. Системное программирование на C
· уровень средний · 12 мин чтения
Открыть в интерактивном справочнике

144. Ассемблер x86-64 глазами C: регистры, соглашение вызовов, gcc -S, inline asm, nasm

Ассемблер Cortex-M разобран в разделе 7б: на микроконтроллере в него заглядывают при каждой отладке. Процессор в компьютере — другой: x86-64 с шестнадцатью регистрами и CISC-командами, которые внутри превращаются в микрооперации. Писать на нём программы не нужно; нужно читать то, что породил компилятор, — чтобы понять, почему код медленный, что случилось на месте падения и куда делся цикл после -O2. Все листинги — вывод gcc -S -masm=intel и objdump из stm-sys:1 на funcs.c, а программа без libc и функция на nasm собраны и запущены (_verify/sys144/).

Как это называется в документации

Оригинал Перевод
general-purpose register регистр общего назначения
flags register (RFLAGS) регистр флагов: ZF, CF, SF, OF
calling convention (System V ABI) соглашение вызовов
caller-saved, callee-saved регистры, которые сохраняет вызывающий / вызываемый
prologue, epilogue пролог, эпилог функции
red zone 128 байт под rsp, которые листовая функция использует без sub rsp
jump table, lookup table таблица переходов, таблица значений
inline assembly встроенный ассемблер
constraint ("r", "=a", "memory") ограничение операнда во встроенном ассемблере
system call системный вызов
AT&T vs Intel syntax два синтаксиса записи команд

144.1 Регистры

Шестнадцать 64-битных регистров общего назначения: rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp и r8–r15. У каждого есть младшие половины: eax — 32 бита, ax — 16, al — 8; запись в eax обнуляет старшую половину rax, и поэтому в листингах много mov edi, edi — это обнуление верхних 32 бит. Запись в ax или al старшие биты оставляет как были, поэтому байт в регистр загружают командой movzx eax, byte ptr …: она расширяет нулями до 32 бит, а значит, и до 64. Отдельно — указатель команд rip, флаги RFLAGS и регистры xmm0–xmm15 для вещественных чисел и векторов.

Флаги ставит почти каждая арифметическая команда: ZF — результат ноль, CF — перенос из старшего разряда (беззнаковое переполнение), SF — знак, OF — знаковое переполнение. Условные переходы читают флаги: je (ZF), ja (беззнаковое больше: CF = 0 и ZF = 0), jg (знаковое больше). Так if на C становится парой «cmp + jcc».

Частота регистров в листинге funcs.c при -O2: rdi 14 раз, rdx 8, eax 8, rax 7, rsp 5 — аргументы и результат, всё остальное реже.

Соглашение вызовов System V: шесть аргументов в регистрах, остальные в стеке, результат в rax


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

В Linux на x86-64 действует одно соглашение для всего кода на C:

Что Где
целые аргументы 1–6 rdi, rsi, rdx, rcx, r8, r9
седьмой и далее в стеке, первый из них — по [rsp + 8] после call
вещественные аргументы xmm0–xmm7
результат rax (и rdx для 128 бит), вещественный — xmm0
портить можно rax, rcx, rdx, rsi, rdi, r8–r11, все xmm
сохранять обязан вызываемый rbx, rbp, r12–r15
стек перед call выровнен на 16 байт

Функция с двумя аргументами при -O2:

int add(int a, int b) { return a + b; }                       /* аргументы в edi, esi; результат в eax */
add:
    lea eax, [rdi+rsi]
    ret

Одна команда: lea считает адрес rdi + rsi, но кладёт его в eax как число — сложение без флагов. С семью аргументами шестой ещё в r9, седьмой — из стека:

sum7:
    add rdi, rsi
    add rdi, rdx
    add rdi, rcx
    add rdi, r8
    lea rax, [rdi+r9]
    add rax, QWORD PTR 8[rsp]
    ret

Функция на nasm обязана соблюдать то же соглашение — и тогда C зовёт её как свою:

asm_add3:
        mov     rax, rdi            ; a
        add     rax, rsi            ; + b
        add     rax, rdx            ; + c
        ret
nasm: asm_add3(1, 2, 3) = 6

В Windows соглашение другое (rcx, rdx, r8, r9 и 32 байта «тени» в стеке) — поэтому ассемблерная функция для Linux не заработает под Windows без переписывания.


144.3 Пролог, эпилог и что делает -O2

Та же add при -O0:

add:
    push    rbp
    mov rbp, rsp
    mov DWORD PTR -4[rbp], edi
    mov DWORD PTR -8[rbp], esi
    mov edx, DWORD PTR -4[rbp]
    mov eax, DWORD PTR -8[rbp]
    add eax, edx
    pop rbp
    ret

Пролог push rbp; mov rbp, rsp строит кадр, аргументы кладутся в стек и читаются обратно — так отладчик видит каждую переменную. При -O2 кадра нет, переменных в памяти нет, девять команд стали двумя. Цикл loop_sum — 22 строки при -O0 против 11 при -O2.

Вызов другой функции: место под массив buf[4] берётся вычитанием из rsp, адрес передаётся в rdi, размер — в esi, call, освобождение:

calls_other:
    movd    xmm1, edi
    sub rsp, 24
    mov esi, 4
    pshufd  xmm0, xmm1, 0
    paddd   xmm0, XMMWORD PTR .LC0[rip]
    mov rdi, rsp
    movaps  XMMWORD PTR [rsp], xmm0
    call    loop_sum
    add rsp, 24
    ret

Четыре элемента {x, x+1, x+2, x+3} компилятор заполнил одной векторной командой paddd — 16 байт за раз. sub rsp, 24 — не 16: после call в стеке лежит ещё адрес возврата, и 24 + 8 = 32 держит выравнивание на 16.


144.4 Арифметика, switch и таблицы

Умножение на 10 и деление на 4 без mul и div:

mul_div:
    lea rdx, [rdi+rdi*4]
    add rdx, rdx
    lea rax, 3[rdx]
    cmovns  rax, rdx
    sar rax, 2
    ret

x·5 через lea с масштабом 4, ·2 через сложение, деление на 4 — арифметический сдвиг вправо, а lea rax, 3[rdx] и cmovns — поправка для отрицательных: сдвиг округляет к минус бесконечности, а C требует к нулю. div стоит десятки тактов, сдвиг — один; поэтому делить на константу дёшево, а на переменную — нет.

Деление на 4 сдвигом для положительных и отрицательных чисел и поправка, которую добавляет компилятор

switch по шести значениям стал не цепочкой сравнений, а таблицей значений в .rodata:

by_switch:
    mov eax, -1
    cmp edi, 5
    ja  .L10
    mov edi, edi
    lea rax, CSWTCH.5[rip]
    mov eax, DWORD PTR [rax+rdi*4]
.L10:
    ret

Одно сравнение ja и чтение из массива. ja сравнивает 32-битный edi как беззнаковое число: −1 для неё — 0xFFFFFFFF, так что условие «больше 5» отсекает и отрицательные. Следующая mov edi, edi обнуляет старшую половину rdi — индекс в адресе [rax+rdi*4] должен быть 64-битным. Когда ветви не сводятся к значению, компилятор строит таблицу переходов — массив адресов и jmp [rax + rdi*8]. Так switch на десятках плотно идущих case обходится одним сравнением и одним чтением. Для разреженных значений компилятор строит дерево сравнений: log₂N проверок вместо N.


144.5 Читать машинный код: objdump

gcc -S показывает ассемблер до компоновки; objdump -d разбирает готовый объектный файл или программу вместе с байтами команд:

0000000000000000 <add>:
   0:   8d 04 37                lea    eax,[rdi+rsi*1]
   3:   c3                      ret

lea — три байта, ret — один; команды x86 переменной длины, от 1 до 15 байт. Ключ -M intel даёт синтаксис Intel («приёмник, источник»); без него — AT&T с % перед регистрами и обратным порядком. objdump -d -S (у objdump ключ -S означает «с исходником», не то, что у gcc) при сборке с -g перемешивает машинный код со строками C. Код шести функций в funcs.o занимает 395 байт (size, столбец text).


144.6 Встроенный ассемблер

Иногда нужна команда, которой нет в C: счётчик времени, идентификация процессора, флаг переноса, системный вызов без libc.

static inline uint64_t rdtsc(void)
{
    uint32_t lo, hi;
    __asm__ volatile ("rdtsc" : "=a"(lo), "=d"(hi));           /* выходы: eax → lo, edx → hi */
    return ((uint64_t)hi << 32) | lo;
}

Форма: asm(текст : выходы : входы : что портится). "=a"(lo) — выход в eax, "=d" — в edx, "r" — любой регистр, "m" — память, "+r" — вход и выход сразу. Список после третьего двоеточия говорит компилятору, что команда меняет: "cc" — флаги, "memory" — память (барьер для перестановок), имена регистров — конкретные регистры. volatile запрещает выбрасывать команду как «бесполезную».

    __asm__ ("add %[b], %[a]\n\t"
             "setc %[c]"                                        /* флаг переноса → байт */
             : [a] "+r"(a), [c] "=r"(carry)
             : [b] "r"(b)
             : "cc");                                           /* меняем флаги */

Системный вызов напрямую: номер в rax, аргументы в rdi, rsi, rdx, команда syscall, результат в rax; ядро портит rcx и r11:

    __asm__ volatile ("syscall"                                 /* номер 1 = write; аргументы rdi, rsi, rdx */
                      : "=a"(ret)
                      : "a"(1), "D"(fd), "S"(buf), "d"(n)
                      : "rcx", "r11", "memory");                /* syscall портит rcx и r11 */
cpuid: GenuineIntel, старший лист 28
rdtsc: 1000 итераций — 4528 тиков TSC
add 0xFFFF…FFFF + 2 = 1, перенос 1
syscall write без libc
syscall вернул 26

add дал 1 с переносом — беззнаковое переполнение, которое C делает незаметным, а флаг CF показывает. На терминале строки идут в порядке программы: printf сбрасывает буфер на каждом переводе строки. Если же вывод направить в канал (./inline_asm | cat), строка «syscall write без libc» выйдет первой, раньше cpuid: printf тогда копит всё в буфере до выхода, а write уходит в ядро сразу.

rdtsc возвращает не такты ядра, а тики счётчика TSC. У современных x86 он идёт с постоянной, номинальной частотой процессора и не замечает ни турбо-режима, ни сниженной частоты — там тики и такты расходятся. К тому же команда не упорядочена: процессор может выполнить её раньше или позже соседних, поэтому короткий замер обрамляют lfence или берут rdtscp, которая дожидается предыдущих команд. Такты ядра честно считает только счётчик производительности: perf stat -e cycles.


144.7 Программа без libc

_start:
        mov     rax, 1              ; write
        mov     rdi, 1              ; stdout
        mov     rsi, msg
        mov     rdx, len
        syscall
        mov     rax, 60             ; exit
        mov     rdi, 7              ; код возврата 7
        syscall

nasm -f elf64 hello.asm && ld -o hello hello.o — 8896 байт, ldd говорит «not a dynamic executable»: ни libc, ни динамического компоновщика. Точка входа — _start, а не main: main вызывает стартовый код libc, которого здесь нет. Программа печатает строку и завершается с кодом 7 — exit тоже системный вызов, номер 60. Это ровно то, что делает Reset_Handler в прошивке до main (раздел 11), только на другом процессоре.


144.8 Грабли

Симптом Причина
в отладчике «переменная оптимизирована» -O2 держит её в регистре; смотреть -O0 или -Og
функция на ассемблере портит данные C тронут rbx/rbp/r12–r15 без сохранения
падение внутри call из ассемблера стек не выровнен на 16 перед call
ассемблерная функция работает в Linux и падает в Windows другое соглашение: rcx, rdx, r8, r9
asm «исчезает» при -O2 нет volatile и выходов, которые используются
результат asm неверный при -O2 не указаны "memory" или "cc", компилятор переставил
syscall вернул мусор в других регистрах rcx, r11 не в списке портимых
ассемблер nasm не собирается с gcc синтаксис Intel против AT&T; -masm=intel только для чтения
ld: warning: missing .note.GNU-stack добавить секцию .note.GNU-stack в файл nasm
деление медленное div на переменную — десятки тактов; на константу компилятор заменит сдвигом
rdtsc не сходится с тактами TSC идёт с постоянной частотой, команда не упорядочена: lfence, rdtscp; такты — perf stat -e cycles

144.9 Итоги

Полные исходники

inline_asm.c — встроенный ассемблер GCC: rdtsc, cpuid, перенос, write без libc.

/* Раздел 144: встроенный ассемблер GCC — счётчик тактов rdtsc, cpuid, add с флагом переноса,
   системный вызов write без libc, функция из файла на nasm. */
#include <stdio.h>
#include <stdint.h>
#include <string.h>

long asm_add3(long a, long b, long c);                          /* написана на nasm (add3.asm) */

static inline uint64_t rdtsc(void)
{
    uint32_t lo, hi;
    __asm__ volatile ("rdtsc" : "=a"(lo), "=d"(hi));           /* выходы: eax → lo, edx → hi */
    return ((uint64_t)hi << 32) | lo;
}

static int add_carry(uint64_t a, uint64_t b, uint64_t *sum)
{
    uint8_t carry;
    __asm__ ("add %[b], %[a]\n\t"
             "setc %[c]"                                        /* флаг переноса → байт */
             : [a] "+r"(a), [c] "=r"(carry)
             : [b] "r"(b)
             : "cc");                                           /* меняем флаги */
    *sum = a;
    return carry;
}

static long raw_write(int fd, const void *buf, size_t n)
{
    long ret;
    __asm__ volatile ("syscall"                                 /* номер 1 = write; аргументы rdi, rsi, rdx */
                      : "=a"(ret)
                      : "a"(1), "D"(fd), "S"(buf), "d"(n)
                      : "rcx", "r11", "memory");                /* syscall портит rcx и r11 */
    return ret;
}

int main(void)
{
    char vendor[13] = {0};
    uint32_t eax = 0, ebx, ecx, edx;
    __asm__ ("cpuid" : "+a"(eax), "=b"(ebx), "=c"(ecx), "=d"(edx));
    memcpy(vendor, &ebx, 4); memcpy(vendor + 4, &edx, 4); memcpy(vendor + 8, &ecx, 4);
    printf("cpuid: %s, старший лист %u\n", vendor, eax);
    uint64_t t0 = rdtsc(); volatile int x = 0; for (int i = 0; i < 1000; i++) x += i; uint64_t t1 = rdtsc();
    printf("rdtsc: 1000 итераций — %llu тиков TSC\n", (unsigned long long)(t1 - t0));
    uint64_t s; int c = add_carry(UINT64_MAX, 2, &s);
    printf("add 0xFFFF…FFFF + 2 = %llu, перенос %d\n", (unsigned long long)s, c);
    const char msg[] = "syscall write без libc\n";
    long n = raw_write(1, msg, sizeof msg - 1);
    printf("syscall вернул %ld\n", n);
    printf("nasm: asm_add3(1, 2, 3) = %ld\n", asm_add3(1, 2, 3));
    return 0;
}

Дальше: раздел 145 — UNIX как среда разработки.

Открыть в интерактивном справочнике