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 rspjump 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 — аргументы и результат, всё остальное реже.
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 стоит десятки тактов, сдвиг — один; поэтому делить на
константу дёшево, а на переменную — нет.
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 Итоги
- Аргументы:
rdi,rsi,rdx,rcx,r8,r9, дальше стек; результатrax; сохранятьrbx,rbp,r12–r15; стек на 16. -O0: кадр и переменные в стеке (9 команд);-O2:lea+ret.- Умножение и деление на константу —
leaи сдвиги;switch— таблица. gcc -S -masm=intel,objdump -d -M intel.- Встроенный ассемблер: выходы, входы, портимое;
volatile,"memory","cc". - Без libc:
_start,syscall1 и 60, 8896 байт.
Полные исходники
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 как среда разработки.