Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
От Zig к машинному коду
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
От Zig к машинному коду
До этого урока компилятор был чёрным ящиком: ты писал Zig, получал бинарник, он работал. Дальше ящик открыт и больше не закроется. Всё, что ты пишешь, компилятор превращает в конечный набор инструкций над шестнадцатью регистрами, и этот набор можно прочитать глазами. Сегодня учимся доставать листинг, узнавать в нём регистры и ширины, отличать код от разметки и понимать, чем отличаются три режима сборки, если смотреть не на флаги, а на байты.
Цели урока
- Получать ассемблерный листинг своей программы двумя способами: от компилятора через
-femit-asmи от дизассемблера черезobjdump -d, и понимать, чем эти два листинга отличаются. - Собирать код под
x86_64-linuxс любой машины, включая Apple Silicon, и знать, почему для чтения листинга это не мешает. - Знать шестнадцать регистров общего назначения, их имена во всех четырёх ширинах и роль каждого по System V.
- Читать суффиксы
b,w,l,qи по ним восстанавливать тип операнда в исходнике. - Отличать в листинге компилятора инструкции от директив, меток и отладочной разметки.
- Объяснять, что именно исчезает из кода при переходе от
DebugкReleaseSafeи дальше кReleaseFast. - Разбирать префикс REX по битам и понимать, откуда у инструкции берётся четвёртый бит номера регистра.
- Начать свой дизассемблер:
zt disasmчитает секцию.textобъектника и печатает однобайтные инструкции ровно так же, какobjdump.
Идея: между языком и процессором нет ничего, кроме таблицы
Машинный код это не другой язык. Это тот же самый код, записанный в формате, который умеет читать процессор. Компилятор берёт твой a * b, находит в системе команд инструкцию, которая умножает, и выписывает её байты. Никакой магии между этими двумя шагами нет: есть таблица инструкций на несколько тысяч строк и правила, по которым компилятор выбирает из неё.
Из этого следует главное свойство, ради которого стоит читать листинги: всё поведение программы видно в машинном коде и только там. Оптимизатор что-то выбросил, проверка переполнения куда-то делась, вызов исчез вместе с функцией, аргумент приехал не в том регистре: пока ты смотришь на исходник, это догадки, а листинг это факт. Дальше в разделе мы будем этим фактом пользоваться постоянно.
Второе свойство: направление обратимо. Раз машинный код это байты по правилам, значит по байтам правила можно применить назад и получить обратно инструкции. Программа, которая это делает, называется дизассемблером, и к концу урока у тебя будет свой.
Тридцать лет совместимости за одну страницу
Систему команд x86-64 невозможно понять как чей-то замысел. Её надо понимать как наслоение: каждое поколение процессоров добавляло своё, не ломая старое.
- 1978, Intel 8086. Шестнадцать бит, восемь регистров с именами
ax,cx,dx,bx,sp,bp,si,di. Именно в этом порядке, не в алфавитном: он и сегодня определяет номера регистров в машинном коде. Байт0x50тогда означалpush ax. - 1985, i386. Тридцать два бита, плоская модель памяти вместо сегментов. Регистры выросли и получили букву
eв начале:eax,ecx. Старые шестнадцатибитные имена остались как младшие половины. - 1997 до 2000. Расширения для мультимедиа: MMX, потом SSE и SSE2 со своими регистрами
xmm. Вычисления с плавающей точкой постепенно уезжают из старого стека сопроцессора в эти регистры, чем мы воспользуемся в уроке 17. - 2003, AMD Opteron. Шестьдесят четыре бита. Это сделала AMD, а не Intel: архитектура AMD64 добавила восемь новых регистров
r8доr15, расширила старые до имён с буквойrи придумала префикс REX, чтобы в старую кодировку влезли новые номера. Intel принял её через год. - 2011 до 2016. AVX, AVX2, AVX-512: регистры шириной 256 и 512 бит и новые способы кодировать инструкции.
Байт 0x50 при этом всё ещё значит push. Только теперь он кладёт на стек восемь байт из rax, а не два из ax. Ровно эта совместимость объясняет, почему кодировка инструкций выглядит странно: новое приходилось втискивать в промежутки, оставшиеся от старого.
Нам из всей этой истории нужен один срез: x86-64 в 64-битном режиме, синтаксис AT&T, ABI System V. То, что запускается в песочнице курса и на любом обычном Linux-сервере.
Как достать листинг
Способа два, и они дают разный текст.
Первый: спросить у компилятора. Флаг -femit-asm просит выдать ассемблер вместо (или вместе с) объектника.
zig build-obj mstore.zig -O ReleaseSafe -target x86_64-linux \
-femit-asm=mstore.s -fno-emit-bin
Второй: спросить у дизассемблера. Собираем объектник и разбираем его байты обратно.
zig build-obj mstore.zig -O ReleaseSafe -target x86_64-linux -femit-bin=mstore.o
objdump -d mstore.o
Оба листинга описывают один и тот же код, но смотрят с разных сторон. У компилятора есть имена, метки и отладочная разметка, но нет байтов: он ещё не решил, сколько места займёт каждая инструкция. У дизассемблера есть байты и адреса, но нет имён: он видит готовый объектник, где от исходника осталась только таблица символов.
Про objdump есть тонкость. Своя подкоманда zig objdump в версии 0.16 существует, но пока ничего не делает: она честно печатает TODO dump elf file. Так что берём внешний: на macOS в системе лежит /usr/bin/objdump из LLVM, на Linux ставится llvm-objdump или обычный GNU objdump. Оба по умолчанию печатают AT&T, как в этом курсе и в книге.
Кстати, дизассемблер, которого не хватает, это отличный повод написать свой. Именно этим мы и займёмся во второй половине урока.
Кросс-компиляция это не проблема
Если у тебя Apple Silicon, ты не можешь запустить x86-64, но прекрасно можешь его собрать и прочитать. Флаг -target x86_64-linux заставляет Zig сгенерировать код целевой архитектуры, и дизассемблер разберёт его на любой машине: разбор байтов это чистые вычисления, процессору всё равно, чью систему команд ты изучаешь.
Запускать полученное надо там, где оно работает: в песочнице раннера курса, в виртуалке или через docker run --platform linux/amd64. Но девяносто процентов работы в блоке это чтение, а не запуск.
Одна функция в трёх режимах
Возьмём самый маленький пример, на котором видно всё сразу.
export fn mult2(a: i64, b: i64) i64 {
return a * b;
}
export fn multstore(x: i64, y: i64, dest: *i64) void {
dest.* = mult2(x, y);
}
Соберём три раза и посмотрим на multstore.
Debug, режим по умолчанию:
0000000000000000 <multstore>:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 83 ec 30 subq $0x30, %rsp
8: 48 89 3c 24 movq %rdi, (%rsp)
c: 48 89 74 24 08 movq %rsi, 0x8(%rsp)
11: 48 89 54 24 10 movq %rdx, 0x10(%rsp)
16: 48 89 54 24 18 movq %rdx, 0x18(%rsp)
1b: 48 89 74 24 20 movq %rsi, 0x20(%rsp)
20: 48 89 7c 24 28 movq %rdi, 0x28(%rsp)
25: 48 8b 7c 24 28 movq 0x28(%rsp), %rdi
2a: 48 8b 74 24 20 movq 0x20(%rsp), %rsi
2f: e8 00 00 00 00 callq 0x34 <multstore+0x34>
34: 48 8b 54 24 18 movq 0x18(%rsp), %rdx
39: 48 89 02 movq %rax, (%rdx)
3c: 48 89 ec movq %rbp, %rsp
3f: 5d popq %rbp
40: c3 retq
Шестьдесят пять байт на одно умножение. Компилятор ничего не соптимизировал сознательно: каждый аргумент положен в свою ячейку на стеке, потом оттуда прочитан, вызов mult2 остался настоящим вызовом. Так сделано ради отладчика: в любой точке функции каждая переменная лежит по своему адресу, и её можно посмотреть и поменять.
ReleaseSafe:
0000000000000000 <multstore>:
0: 48 0f af fe imulq %rsi, %rdi
4: 70 04 jo 0xa <multstore+0xa>
6: 48 89 3a movq %rdi, (%rdx)
9: c3 retq
a: 55 pushq %rbp
b: 48 89 e5 movq %rsp, %rbp
e: e8 00 00 00 00 callq 0x13 <multstore+0x13>
Десять байт на быстром пути. Вызов mult2 исчез, умножение встроилось прямо сюда, стековый кадр не понадобился. Зато появились две новые строки: jo и то, куда она прыгает. Это проверка переполнения. Инструкция imul выставляет флаг переполнения, jo на него смотрит, и в плохом случае управление уходит в хвост функции, где строится кадр и зовётся обработчик паники. Кадр там нужен, чтобы паника напечатала трассу стека.
ReleaseFast:
0000000000000000 <multstore>:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 0f af fe imulq %rsi, %rdi
8: 48 89 3a movq %rdi, (%rdx)
b: 5d popq %rbp
c: c3 retq
Проверки нет, хвоста нет, тринадцать байт. Осталась пара push %rbp и pop %rbp: Zig по умолчанию сохраняет указатель кадра даже в быстром режиме, чтобы профилировщик и трассы стека продолжали работать. Если сказать -fomit-frame-pointer, останется восемь байт и ровно три инструкции:
0000000000000000 <multstore>:
0: 48 0f af fe imulq %rsi, %rdi
4: 48 89 3a movq %rdi, (%rdx)
7: c3 retq
Вот это и есть dest.* = x * y без единого лишнего байта. Держи эту тройку в голове как эталон: шестьдесят пять байт в Debug против восьми это цена удобства отладки, а два байта разницы между ReleaseSafe и ReleaseFast это цена проверки, которая ловит переполнение.
Правило, которое стоит принять сразу: читать листинги надо в ReleaseSafe или ReleaseFast. В Debug компилятор пишет не то, что делает программа, а то, что удобно отладчику, и за этим шумом не видно логики. Все листинги дальше в разделе сняты в ReleaseSafe, кроме случаев, где явно сказано иначе.
Четыре ширины
В листинге у каждой инструкции есть суффикс ширины. Он говорит, сколько байт трогает операция, и по нему восстанавливается тип из исходника.
| Суффикс AT&T | Байт | Название в Intel | Типы Zig |
|---|---|---|---|
b | 1 | byte | u8, i8, bool |
w | 2 | word | u16, i16 |
l | 4 | double word | u32, i32, f32 в скалярных SSE |
q | 8 | quad word | u64, i64, *T, usize, f64 |
Слово word тут значит два байта, а не машинное слово: это наследство 8086, где регистр и был шириной шестнадцать бит. Отсюда же «двойное слово» для четырёх байт и «четверное» для восьми, хотя на 64-битной машине естественнее было бы называть словом восемь байт. Привыкай: во всех документах Intel и во всех дизассемблерах word это два байта.
Заметь, чего в таблице нет: знака. Инструкция addq складывает восемь байт и не знает, знаковые они или нет. Дополнительный код устроен так, что для сложения, вычитания и умножения в младшей половине разницы между знаковым и беззнаковым нет вовсе. Знак появляется только там, где он реально что-то меняет: в делении, в сдвиге вправо, в расширении узкого значения до широкого и в условных переходах. Это ровно то же наблюдение, что и в уроке про биты, только теперь ты видишь его в системе команд.
Шестнадцать регистров
Регистр общего назначения это ячейка внутри процессора шириной восемь байт. Их шестнадцать, и у каждого четыре имени по ширине обращения.
| 64 бита | 32 | 16 | 8 | Роль по System V |
|---|---|---|---|---|
%rax | %eax | %ax | %al | возвращаемое значение |
%rbx | %ebx | %bx | %bl | callee-saved |
%rcx | %ecx | %cx | %cl | четвёртый аргумент, счётчик сдвига |
%rdx | %edx | %dx | %dl | третий аргумент, старшая половина произведения |
%rsi | %esi | %si | %sil | второй аргумент |
%rdi | %edi | %di | %dil | первый аргумент |
%rbp | %ebp | %bp | %bpl | callee-saved, обычно дно кадра |
%rsp | %esp | %sp | %spl | вершина стека |
%r8 | %r8d | %r8w | %r8b | пятый аргумент |
%r9 | %r9d | %r9w | %r9b | шестой аргумент |
%r10 | %r10d | %r10w | %r10b | caller-saved |
%r11 | %r11d | %r11w | %r11b | caller-saved |
%r12 до %r15 | %r12d … | %r12w … | %r12b … | callee-saved |
Три вещи, которые из этой таблицы надо унести.
Порядок номеров исторический. В машинном коде регистр это число от 0 до 15, и нумерация идёт в порядке первой колонки: rax это 0, rcx это 1, rdx это 2, rbx это 3, rsp это 4, rbp это 5, rsi это 6, rdi это 7, дальше r8 до r15 по порядку. Ни одна буква тут не помогает, эти восемь надо просто помнить. Проверить себя легко: байт 0x53 это pushq %rbx, потому что 0x53 минус 0x50 даёт три.
Роли это соглашение, а не свойство железа. Процессору всё равно, что лежит в %rdi. Договорённость о том, что там первый аргумент, называется System V AMD64 ABI, и её соблюдают компиляторы, чтобы код на Zig, C и Rust мог вызывать друг друга. Подробно разберём в уроке 14, а пока достаточно знать: если функция берёт три целых аргумента, они приедут в %rdi, %rsi, %rdx, и в листинге multstore выше ты это уже видел.
Восьмибитные имена не такие, как кажется. Для номеров с 4 по 7 есть два разных набора: %spl, %bpl, %sil, %dil это младшие байты соответствующих регистров, а %ah, %ch, %dh, %bh это старшие байты первых четырёх регистров, оставшиеся с 8086. Какой набор имеется в виду, решает наличие префикса REX в инструкции. Это единственное место, где присутствие префикса меняет смысл поля, и наш дизассемблер это учтёт.
Читаем настоящий листинг
Ниже тот же multstore в трёх видах: как его печатает objdump в AT&T, как в Intel и как выглядит листинг самого компилятора. Кликай по строкам: у каждой есть разбор.
objdump -d mstore-rs.o · объектник отzig build-obj mstore.zig -O ReleaseSafe -target x86_64-linux
objdump -d --x86-asm-syntax=intel mstore-rs.o · те же байты, другая запись
zig build-obj mstore.zig -O ReleaseSafe -target x86_64-linux -femit-asm=mstore.s -fno-emit-bin · сокращённый фрагмент, директивы отладочной информации вырезаны
Что стоит заметить, пока крутишь виджет.
AT&T против Intel. Один и тот же код, одни и те же байты, разный порядок операндов. AT&T пишет источник слева, приёмник справа (imulq %rsi, %rdi значит rdi = rdi * rsi), регистры с процентом, константы с долларом, ширину суффиксом мнемоники. Intel пишет приёмник слева и ширину словом перед адресом (mov qword ptr [rdx], rdi). Книга и курс используют AT&T, godbolt по умолчанию Intel, а листинг Zig 0.16 выдаёт Intel, о чём честно сообщает первой строкой файла: .intel_syntax noprefix. Путаница тут стоит дорого, поэтому первым делом всегда смотри, в каком синтаксисе перед тобой текст: если у регистров есть проценты, это AT&T.
Дырка вместо адреса. У инструкции callq четыре байта после опкода нулевые, а objdump рисует цель как адрес следующей инструкции. Это не код, это заготовка: в объектнике адрес обработчика паники ещё не известен, и рядом лежит запись о том, что сюда надо будет вписать настоящее значение. Заполнит её компоновщик, до которого мы дойдём в блоке S6.
Хвост из nop. Последние десять и три байта функции это выравнивание: следующая функция должна начаться с адреса, кратного шестнадцати. Ассемблер не оставляет там нули, а кладёт длинные варианты nop, чтобы процессор ничего не сломал, если вдруг туда попадёт. Такие хвосты не надо пытаться понять как логику, их надо научиться пропускать глазами.
Что в листинге компилятора не является кодом
Если открыть mstore.s, окажется, что настоящих инструкций там штук пять, а строк несколько сотен тысяч. Дело в том, что -femit-asm печатает весь модуль, включая всю задетую стандартную библиотеку и всю отладочную информацию. Ориентироваться там надо по имени функции. Вот кусок про multstore целиком:
.text
.p2align 4
.type mstore.multstore,@function
mstore.multstore:
.Lfunc_begin0:
.cfi_startproc
.file 72 "…/scratch" "mstore.zig"
.loc 72 2 14 prologue_end
imul rdi, rsi
.Ltmp0:
jo .LBB0_2
.Ltmp1:
.loc 72 6 19
mov qword ptr [rdx], rdi
ret
.Ltmp2:
.LBB0_2:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
.Ltmp3:
.loc 72 2 14 discriminator 2
call "debug.FullPanic((function 'defaultPanic')).integerOverflow"
.Ltmp4:
.Lfunc_end0:
.size mstore.multstore, .Lfunc_end0-mstore.multstore
.cfi_endproc
Инструкций здесь ровно шесть. Всё остальное делится на три вида строк.
Директивы начинаются с точки и адресованы ассемблеру. .text говорит, в какую секцию класть дальнейшее, .p2align 4 выравнивает адрес по шестнадцати байтам, .type и .size заполняют таблицу символов, .globl делает имя видимым снаружи. Ни одна из них не превращается в байты кода.
Метки это имена адресов, они кончаются двоеточием. Имена с точкой в начале локальные: .LBB0_2 это Local Basic Block от LLVM, .Ltmp0 временная метка, .Lfunc_end0 конец функции. В готовый объектник они не попадают, они нужны только внутри этого файла, чтобы ассемблер посчитал расстояния и размеры. Обрати внимание на последнюю директиву .size: размер функции записан как разность двух меток, и ассемблер умеет такое считать.
Отладочная разметка это .loc и семейство .cfi_*. Первая связывает инструкцию со строкой и столбцом исходника, из этих записей собирается таблица, по которой отладчик показывает нужную строку, а паника печатает номер. Второе описывает, как из текущей точки вернуться к вызывающему: по этим записям строится раскрутка стека. Обе живут в отдельных секциях файла и на исполнение не влияют.
И одна деталь про имена. Внутреннее имя функции это mstore.multstore, с префиксом модуля, а наружное multstore появляется в самом конце файла отдельной парой строк: .globl multstore и multstore = mstore.multstore. Так работает export: он делает символ видимым снаружи и даёт ему имя без префикса, оставляя внутреннее для отладочной информации.
Байты: префикс REX
Прежде чем писать дизассемблер, разберём одну кодировку до конца. Возьми любую строку с восемью байтами операнда, и первым байтом почти всегда окажется что-то из диапазона 0x40 до 0x4F. Это и есть префикс REX.
Его биты:
0 1 0 0 W R X B
│ │ │ └── четвёртый бит номера регистра в поле r/m,
│ │ │ в поле base байта SIB или прямо в опкоде
│ │ └──── четвёртый бит номера индексного регистра
│ └────── четвёртый бит номера регистра в поле reg
└──────── операнд шириной 64 бита
Старшая половина байта всегда 0100, иначе это не REX. Младшие четыре бита это флаги, и каждый решает свою задачу.
Бит W отвечает за ширину. Без него операнд по умолчанию четырёхбайтовый, с ним восьмибайтовый. Отсюда и берётся байт 0x48 в начале почти каждой строки листинга выше: 0x48 это 0100 1000, то есть REX только с битом W. А байт 0x41 это 0100 0001, REX только с битом B.
Биты R, X и B решают задачу совместимости. В старой кодировке под номер регистра отведено три бита, а регистров стало шестнадцать. Четвёртый бит взять неоткуда, поэтому его вынесли в префикс: каждое из трёх мест, где может стоять номер регистра, получило свой бит расширения. Отсюда следствие, которое видно невооружённым глазом: инструкция с регистрами из старшей восьмёрки на байт длиннее такой же инструкции с регистрами из младшей. Сравни 55 (pushq %rbp) и 41 57 (pushq %r15).
И ещё одно, уже упомянутое: сам факт наличия REX меняет смысл байтовых имён. Без префикса номер 4 в байтовом операнде значит %ah, с префиксом %spl. Поэтому декодер обязан помнить не только биты префикса, но и то, был ли он вообще.
Проверить понимание проще всего на инструкциях, где номер регистра сидит прямо в опкоде. Байты 0x50 до 0x57 это push регистров с номерами от 0 до 7, байты 0x58 до 0x5F это pop. Прибавь бит B, и номер вырастет на восемь:
| Байты | Инструкция | Почему |
|---|---|---|
50 | pushq %rax | 0x50 минус 0x50 даёт 0, это rax |
54 | pushq %rsp | номер 4 |
41 54 | pushq %r12 | REX.B прибавил восемь к номеру 4 |
5c | popq %rsp | 0x5c минус 0x58 даёт 4 |
41 5f | popq %r15 | номер 7 плюс восемь |
Ровно это ты сейчас и запрограммируешь.
Практика
Задача просит написать три чистые функции: разбор байта REX, имя регистра по номеру и ширине, декодер одной инструкции из потока байтов. Это фундамент дизассемблера в миниатюре, и ловушек в нём ровно столько, сколько в настоящем: диапазон префикса, порядок исторических имён, номер, собранный из трёх бит опкода и одного бита префикса, длина вместе с префиксом.
Одна тонкость отдельно. Байт 0x90 сам по себе это nop, но с префиксом REX тот же байт означает уже другую инструкцию, поэтому пару 41 90 декодировать как nop нельзя. Такие случаи в x86-64 не редкость, и лучше встретить первый из них сейчас, на трёх функциях, чем через два урока внутри большой таблицы.
Шаг проекта: zt учится читать код
Дальше начинается сквозной проект zt, твои собственные binutils. В прошлых уроках ты сделал zt hexdump, который печатает байты файла. Теперь байты станут инструкциями.
Цель шага скромная и проверяемая: zt disasm находит в объектнике секцию .text, идёт по её байтам и печатает строки в точности так, как их печатает objdump -d, вплоть до колонок и табуляций. Инструкции, до которых декодер ещё не дорос, печатаются как (bad) и занимают один байт, чтобы разбор продолжился со следующего. Именно так ведёт себя настоящий objdump, и это удобно: diff покажет, где твой декодер отстал.
Раскладка шага:
src/x86/registers.zig имена регистров во всех ширинах
src/x86/instruction.zig разобранная инструкция как данные
src/x86/decoder.zig префиксы, таблица опкодов, декодирование
src/x86/formatter.zig печать инструкции в синтаксисе AT&T
src/disasm.zig строка дизассемблера с колонками objdump
src/elf.zig поиск секции .text в объектнике
tests/step_09.zig тесты шага
fixtures/step_09.o объектник, на котором всё это проверяется
Главное решение шага структурное: декодер не печатает текст, а форматтер не читает байты. Между ними стоит структура Instruction. Такое разделение стоит одного лишнего типа, но окупается сразу: обе половины проверяются тестами по отдельности, а когда через два урока понадобится печатать в Intel-синтаксисе, менять придётся только форматтер.
Регистры: одна таблица на весь проект
Номер регистра это четыре бита, ширина это отдельное свойство инструкции, а имя получается из их пары.
//! Имена регистров x86-64 во всех ширинах.
const std = @import("std");
/// Ширина операнда. `xmm` стоит в одном ряду с целыми ширинами,
/// потому что в машинном коде номер регистра кодируется одинаково.
pub const Size = enum {
byte,
word,
dword,
qword,
xmm,
/// Суффикс мнемоники в синтаксисе AT&T: `movb`, `movw`, `movl`, `movq`.
pub fn suffix(size: Size) ?u8 {
return switch (size) {
.byte => 'b',
.word => 'w',
.dword => 'l',
.qword => 'q',
.xmm => null,
};
}
};
const names_qword = [16][]const u8{
"rax", "rcx", "rdx", "rbx", "rsp", "rbp", "rsi", "rdi",
"r8", "r9", "r10", "r11", "r12", "r13", "r14", "r15",
};
const names_dword = [16][]const u8{
"eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi",
"r8d", "r9d", "r10d", "r11d", "r12d", "r13d", "r14d", "r15d",
};
const names_word = [16][]const u8{
"ax", "cx", "dx", "bx", "sp", "bp", "si", "di",
"r8w", "r9w", "r10w", "r11w", "r12w", "r13w", "r14w", "r15w",
};
/// Байтовые регистры при наличии префикса REX: номера с 4 по 7 это spl, bpl, sil, dil.
const names_byte_rex = [16][]const u8{
"al", "cl", "dl", "bl", "spl", "bpl", "sil", "dil",
"r8b", "r9b", "r10b", "r11b", "r12b", "r13b", "r14b", "r15b",
};
/// Байтовые регистры без префикса REX: номера с 4 по 7 это старшие половины
/// ah, ch, dh, bh. Одно и то же поле значит разное в зависимости от REX.
const names_byte_legacy = [8][]const u8{
"al", "cl", "dl", "bl", "ah", "ch", "dh", "bh",
};
/// Регистр как операнд: номер плюс ширина. Флаг `rex_present` нужен только
/// байтовым регистрам и хранится в самом операнде, потому что имя без него
/// восстановить нельзя.
pub const Register = struct {
index: u4,
size: Size,
rex_present: bool = false,
pub fn name(register: Register) []const u8 {
return switch (register.size) {
.qword => names_qword[register.index],
.dword => names_dword[register.index],
.word => names_word[register.index],
.byte => if (register.rex_present)
names_byte_rex[register.index]
else
names_byte_legacy[@as(u3, @truncate(register.index))],
.xmm => unreachable, // появится в уроке 17
};
}
};
Обрати внимание на rex_present внутри самого операнда. Соблазн держать этот флаг в декодере и не тащить в структуру велик, но имя байтового регистра без него восстановить невозможно, а форматтер до декодера уже не дотянется. Такие места стоит замечать: они показывают, где именно проходит граница между «свойством инструкции» и «свойством операнда».
Инструкция как данные
//! Что декодер выдаёт наружу: разобранная инструкция как данные.
const registers = @import("registers.zig");
pub const Register = registers.Register;
pub const Size = registers.Size;
/// Больше пятнадцати байтов длиной инструкция x86-64 не бывает: это предел
/// самой архитектуры, а не нашего декодера.
pub const max_length = 15;
pub const max_operands = 3;
/// Операнд в памяти в форме AT&T: `disp(base,index,scale)`.
/// Заполнять его начнём со следующего урока, а поле заводим сейчас,
/// чтобы потом не переписывать половину проекта.
pub const Memory = struct {
base: ?Register = null,
index: ?Register = null,
scale: u8 = 1,
displacement: i64 = 0,
segment: ?[]const u8 = null,
rip_target: ?u64 = null,
};
pub const Operand = union(enum) {
register: Register,
memory: Memory,
immediate: i64,
/// Цель перехода: смещение из инструкции уже сложено с адресом
/// следующей инструкции, так что здесь абсолютный адрес.
target: u64,
};
pub const Instruction = struct {
/// Байты инструкции: срез входного буфера длиной от 1 до `max_length`.
bytes: []const u8,
/// Адрес первого байта.
address: u64,
/// Мнемоника без суффикса ширины: `push`, `mov`, `ret`.
mnemonic: []const u8,
/// Суффикс ширины операнда в стиле AT&T, если он у мнемоники есть.
suffix: ?u8 = null,
operands: [max_operands]Operand = undefined,
operand_count: u8 = 0,
indirect: bool = false,
/// Байт не нашёлся в таблице. Такая инструкция занимает ровно один байт,
/// чтобы разбор продолжился со следующего.
bad: bool = false,
pub fn length(instruction: Instruction) usize {
return instruction.bytes.len;
}
pub fn operandSlice(instruction: *const Instruction) []const Operand {
return instruction.operands[0..instruction.operand_count];
}
};
Поле bytes это срез входного буфера, а не копия: декодер ничего не выделяет и не владеет памятью. Отсюда и length() через длину среза, а не отдельный счётчик. Приятное следствие: невозможно ошибиться и вернуть длину, не совпадающую с тем, сколько байтов реально прочитано.
Курсор, префиксы, таблица
Декодер это цикл «прочитать префиксы, прочитать опкод, посмотреть в таблицу». Состояние разбора живёт в структуре Cursor, которая существует ровно один вызов.
const std = @import("std");
const instruction = @import("instruction.zig");
pub const Instruction = instruction.Instruction;
pub const Operand = instruction.Operand;
pub const Register = instruction.Register;
pub const Size = instruction.Size;
/// Префиксы, которые встретились перед опкодом.
pub const Prefixes = struct {
/// Байт REX целиком, или null, если его не было. Наличие важно само по
/// себе: без REX номера с 4 по 7 в байтовых операндах значат ah, ch, dh, bh.
rex: ?u8 = null,
/// 0x66: смена ширины операнда на 16 бит.
operand_size: bool = false,
pub fn rexW(prefixes: Prefixes) bool {
return prefixes.rex != null and prefixes.rex.? & 0b1000 != 0;
}
/// REX.B расширяет номер регистра, записанный прямо в опкоде.
pub fn rexB(prefixes: Prefixes) u4 {
return if (prefixes.rex != null and prefixes.rex.? & 0b0001 != 0) 8 else 0;
}
/// Ширина целого операнда по умолчанию: REX.W даёт 64 бита,
/// префикс 0x66 даёт 16, иначе 32.
pub fn operandWidth(prefixes: Prefixes) Size {
if (prefixes.rexW()) return .qword;
if (prefixes.operand_size) return .word;
return .dword;
}
};
/// Состояние разбора одной инструкции. Живёт ровно один вызов `decode`.
pub const Cursor = struct {
bytes: []const u8,
address: u64,
pos: usize = 0,
prefixes: Prefixes = .{},
pub fn next(cursor: *Cursor) ?u8 {
if (cursor.pos >= cursor.bytes.len) return null;
const byte = cursor.bytes[cursor.pos];
cursor.pos += 1;
return byte;
}
pub fn peek(cursor: Cursor) ?u8 {
if (cursor.pos >= cursor.bytes.len) return null;
return cursor.bytes[cursor.pos];
}
/// Префиксы идут перед опкодом в любом порядке, но REX обязан стоять
/// последним: сразу за ним читается опкод.
pub fn readPrefixes(cursor: *Cursor) void {
while (cursor.peek()) |byte| {
switch (byte) {
0x66 => cursor.prefixes.operand_size = true,
0x40...0x4f => {
cursor.prefixes.rex = byte;
cursor.pos += 1;
return;
},
else => return,
}
cursor.pos += 1;
}
}
pub fn register(cursor: Cursor, index: u4, size: Size) Register {
return .{ .index = index, .size = size, .rex_present = cursor.prefixes.rex != null };
}
pub fn width(cursor: Cursor) Size {
return cursor.prefixes.operandWidth();
}
/// Инструкция, у которой всё получилось: длина берётся из позиции курсора.
pub fn done(cursor: Cursor, mnemonic: []const u8, suffix: ?u8) Instruction {
return .{
.bytes = cursor.bytes[0..cursor.pos],
.address = cursor.address,
.mnemonic = mnemonic,
.suffix = suffix,
};
}
/// То же самое, но с одним операндом.
pub fn one(cursor: Cursor, mnemonic: []const u8, suffix: ?u8, operand: Operand) Instruction {
var result = cursor.done(mnemonic, suffix);
result.operands[0] = operand;
result.operand_count = 1;
return result;
}
};
Приём с done() стоит отметить отдельно. Длина инструкции не считается и не передаётся: она берётся из позиции курсора в момент, когда разбор закончен. Пока каждое чтение байта идёт через next(), длина не может разойтись с действительностью, и целый класс ошибок просто не возникает. Дальше, когда появятся смещения и непосредственные значения, этот приём будет спасать регулярно.
Сам разбор:
/// Разбирает одну инструкцию с начала среза. `address` это адрес первого
/// байта: он понадобится переходам, чья цель считается от конца инструкции.
pub fn decode(bytes: []const u8, address: u64) Instruction {
if (bytes.len == 0) return bad(bytes, address);
var cursor: Cursor = .{ .bytes = bytes, .address = address };
cursor.readPrefixes();
const opcode = cursor.next() orelse return bad(bytes, address);
return decodeOpcode(&cursor, opcode) orelse bad(bytes, address);
}
/// Инструкция длиной один байт, которую декодер не узнал.
fn bad(bytes: []const u8, address: u64) Instruction {
return .{
.bytes = bytes[0..@min(1, bytes.len)],
.address = address,
.mnemonic = "(bad)",
.bad = true,
};
}
/// Таблица однобайтных опкодов. `null` означает «не наш байт»:
/// вызывающий превратит это в `(bad)`.
fn decodeOpcode(cursor: *Cursor, opcode: u8) ?Instruction {
return switch (opcode) {
0x50...0x57 => pushPop(cursor, "push", opcode - 0x50),
0x58...0x5f => pushPop(cursor, "pop", opcode - 0x58),
0x90 => cursor.done("nop", null),
// Расширение аккумулятора со знаком. Мнемоника целиком зависит
// от ширины, суффикса у неё нет.
0x98 => cursor.done(widthName(cursor.*, "cbtw", "cwtl", "cltq"), null),
0x99 => cursor.done(widthName(cursor.*, "cwtd", "cltd", "cqto"), null),
0xc3 => cursor.done("ret", 'q'),
0xc9 => cursor.done("leave", null),
0xf4 => cursor.done("hlt", null),
0x0f => decodeTwoByte(cursor),
else => null,
};
}
/// Опкоды с ведущим байтом 0x0F.
fn decodeTwoByte(cursor: *Cursor) ?Instruction {
const opcode = cursor.next() orelse return null;
return switch (opcode) {
// ud2 компилятор ставит в конец функции, из которой нет выхода.
0x0b => cursor.done("ud2", null),
else => null,
};
}
/// push и pop: три бита номера регистра лежат в самом опкоде,
/// четвёртый приходит из бита B префикса REX.
fn pushPop(cursor: *Cursor, mnemonic: []const u8, low: u8) Instruction {
const index: u4 = @as(u4, @truncate(low)) | cursor.prefixes.rexB();
return cursor.one(mnemonic, 'q', .{ .register = cursor.register(index, .qword) });
}
/// Выбор мнемоники по ширине операнда для инструкций, у которых суффикса нет.
fn widthName(cursor: Cursor, word: []const u8, dword: []const u8, qword: []const u8) []const u8 {
return switch (cursor.width()) {
.word => word,
.qword => qword,
else => dword,
};
}
Четыре мнемоники cbtw, cwtl, cltq, cqto это одна и та же операция «расширить аккумулятор со знаком вдвое», у которой имя целиком зависит от ширины. Байт 0x98 без префиксов это cwtl (расширить %ax в %eax), с REX.W это cltq (расширить %eax в %rax), с префиксом 0x66 это cbtw. Мы встретили их так рано именно потому, что они однобайтные, а понадобятся они в уроке 11, где cqto готовит делимое для idiv.
Печать в AT&T
Форматтер не знает про байты вовсе. Он берёт Instruction и печатает её по правилам синтаксиса.
/// Печатает мнемонику и операнды, разделяя их табуляцией. Ровно так же
/// расставляет табуляции objdump, поэтому строки совпадают посимвольно.
pub fn format(out: *std.Io.Writer, inst: Instruction) !void {
try out.writeAll(inst.mnemonic);
if (inst.suffix) |suffix| try out.writeByte(suffix);
const operands = inst.operandSlice();
if (operands.len == 0) return;
try out.writeByte('\t');
for (operands, 0..) |operand, position| {
if (position > 0) try out.writeAll(", ");
// Звёздочка отличает косвенный переход от перехода по адресу:
// `jmp *%rax` идёт по значению регистра, `jmp 0x40` по метке.
if (inst.indirect and position == 0) try out.writeByte('*');
try formatOperand(out, operand);
}
}
fn formatOperand(out: *std.Io.Writer, operand: Operand) !void {
switch (operand) {
.register => |register| try out.print("%{s}", .{register.name()}),
.immediate => |value| try formatImmediate(out, value),
.target => |address| try out.print("0x{x}", .{address}),
.memory => unreachable, // появится в уроке 10
}
}
/// Непосредственное значение печатается со знаком и в шестнадцатеричном
/// виде: `$0x10`, `$-0x1`.
fn formatImmediate(out: *std.Io.Writer, value: i64) !void {
if (value < 0) {
try out.print("$-0x{x}", .{@abs(value)});
} else {
try out.print("$0x{x}", .{value});
}
}
И строка целиком, с колонками как у objdump:
/// Ширина колонки байтов: десять байтов по три знака минус последний пробел.
/// Столько же отводит objdump, поэтому строки совпадают посимвольно.
const bytes_column = 29;
/// Разбирает и печатает весь кусок кода. `base` это адрес первого байта:
/// для секции `.text` объектника ноль, для сырого файла то, что попросили.
pub fn disassemble(out: *std.Io.Writer, code: []const u8, base: u64) !void {
var offset: usize = 0;
while (offset < code.len) {
const instruction = decoder.decode(code[offset..], base + offset);
try printLine(out, instruction);
offset += instruction.length();
}
}
pub fn printLine(out: *std.Io.Writer, instruction: Instruction) !void {
try out.print("{x: >8}: ", .{instruction.address});
var width: usize = 0;
for (instruction.bytes, 0..) |byte, position| {
if (position > 0) {
try out.writeByte(' ');
width += 1;
}
try out.print("{x:0>2}", .{byte});
width += 2;
}
if (width < bytes_column) try out.splatByteAll(' ', bytes_column - width);
try out.writeByte('\t');
try formatter.format(out, instruction);
try out.writeByte('\n');
}
Число 29 подобрано под objdump: он отводит под байты место для десяти штук, каждый по два знака плюс пробел, а последний пробел не считается. Совпадение колонок нужно не ради красоты, а ради diff: наша главная проверка это сравнение с эталоном строка в строку.
Где взять байты: минимальный ELF
Дизассемблеру нужно найти в объектнике секцию с кодом. Полноценный читатель ELF появится в блоке S6, а сейчас достаточно одного: пройти по таблице секций и найти ту, чьё имя .text.
/// Первые четыре байта любого ELF: 0x7F и буквы E, L, F.
const magic = "\x7fELF";
const header_size = 64;
const section_entry_size = 64;
/// Смещения полей заголовка ELF64, которые нам нужны.
const e_shoff = 0x28;
const e_shentsize = 0x3a;
const e_shnum = 0x3c;
const e_shstrndx = 0x3e;
pub const Section = struct {
name: []const u8,
/// Адрес, по которому секция окажется в памяти. У объектника до
/// компоновки он нулевой, и адреса дизассемблера начинаются с нуля.
address: u64,
data: []const u8,
};
/// Ищет секцию по имени и отдаёт её байты как срез входного файла.
pub fn findSection(file: []const u8, name: []const u8) Error!Section {
if (file.len < header_size) return error.Truncated;
if (!std.mem.eql(u8, file[0..4], magic)) return error.NotElf;
// e_ident[EI_CLASS] == 2 значит 64 бита, e_ident[EI_DATA] == 1 значит little endian.
if (file[4] != 2) return error.NotElf64;
if (file[5] != 1) return error.NotLittleEndian;
const table_offset = try readU64(file, e_shoff);
const entry_size = try readU16(file, e_shentsize);
const count = try readU16(file, e_shnum);
const names_index = try readU16(file, e_shstrndx);
if (entry_size < section_entry_size) return error.Truncated;
// Имена секций лежат в отдельной секции строк, её номер стоит в заголовке.
const names_header = try sectionHeader(file, table_offset, entry_size, names_index, count);
const names = try sectionBytes(file, names_header);
var index: u16 = 0;
while (index < count) : (index += 1) {
const header = try sectionHeader(file, table_offset, entry_size, index, count);
const name_offset = try readU32(file, header + sh_name);
const section_name = stringAt(names, name_offset) orelse continue;
if (!std.mem.eql(u8, section_name, name)) continue;
return .{
.name = section_name,
.address = try readU64(file, header + sh_addr),
.data = try sectionBytes(file, header),
};
}
return error.SectionNotFound;
}
Заголовок ELF64 это шестьдесят четыре байта, из которых нам нужны четыре поля: смещение таблицы секций, размер одной записи, число записей и номер той секции, в которой лежат имена всех остальных. Имена секций хранятся отдельно именно поэтому: в заголовке секции лежит не строка, а смещение внутри секции строк. Читаем без всяких структур, просто по смещениям, через std.mem.readInt с явным порядком байтов: файл little endian независимо от того, на какой машине мы его разбираем.
Всё, что возвращает findSection, это срезы входного файла. Копий нет, владения нет, аллокатор не нужен.
Фикстура и тесты
Проверять декодер на настоящем скомпилированном коде на этом шаге не выйдет: компилятор не выдаёт подряд десяток инструкций без операндов. Поэтому фикстуру напишем руками, через asm volatile внутри функции с соглашением naked, у которой нет ни пролога, ни эпилога.
/// push и pop всех шестнадцати регистров: старшая восьмёрка требует REX.B.
export fn ztPushPop() callconv(.naked) void {
asm volatile (
\\pushq %rax
\\pushq %rcx
\\pushq %rdx
\\pushq %rbx
\\pushq %rsp
\\pushq %rbp
\\pushq %rsi
\\pushq %rdi
\\pushq %r8
\\pushq %r12
\\pushq %r15
\\popq %r15
\\popq %r12
\\popq %r8
\\popq %rdi
\\popq %rsi
\\popq %rbp
\\popq %rsp
\\popq %rbx
\\popq %rdx
\\popq %rcx
\\popq %rax
\\nop
\\leave
\\hlt
\\cwtl
\\cltq
\\cltd
\\cqto
\\ud2
\\ret
);
}
Собираем её и снимаем эталон один раз:
zig build-obj fixtures/step_09.zig -O ReleaseFast \
-target x86_64-linux-musl -femit-bin=fixtures/step_09.o
objdump -d fixtures/step_09.o > fixtures/step_09.objdump.txt
Оба файла кладём рядом и вшиваем в тест через @embedFile. Так тесты не зависят ни от рабочего каталога, ни от того, установлен ли objdump на машине.
Главных проверок шага две. Первая: ни один байт фикстуры не остался неразобранным. Вторая: наш вывод совпадает с эталонным построчно, если убрать из эталона заголовки файла и подписи символов, которых zt пока не печатает.
test "фикстура шага разбирается целиком" {
try support.expectNoBadInstructions(fixtures.step_09);
}
test "вывод совпадает с objdump построчно" {
try support.expectMatchesObjdump(fixtures.step_09);
}
test "push и pop берут три бита из опкода и четвёртый из REX.B" {
try expectText(&.{0x50}, " 0: 50 \tpushq\t%rax\n");
try expectText(&.{0x5c}, " 0: 5c \tpopq\t%rsp\n");
try expectText(&.{ 0x41, 0x57 }, " 0: 41 57 \tpushq\t%r15\n");
try expectText(&.{ 0x41, 0x5c }, " 0: 41 5c \tpopq\t%r12\n");
}
test "REX.W меняет мнемонику расширения аккумулятора" {
try expectText(&.{0x98}, " 0: 98 \tcwtl\n");
try expectText(&.{ 0x48, 0x98 }, " 0: 48 98 \tcltq\n");
}
test "неизвестный байт печатается как bad и не съедает следующий" {
try expectText(
&.{ 0x06, 0xc3 },
" 0: 06 \t(bad)\n" ++
" 1: c3 \tretq\n",
);
}
Последний тест важнее, чем кажется. Инструкция x86-64 переменной длины, поэтому один неверно понятый байт сдвигает разбор и превращает весь дальнейший вывод в мусор. Правило «не понял, значит один байт и дальше» это то, что позволяет декодеру самому вернуться на верный путь, и то, что делает diff с эталоном полезным даже на середине пути.
Прогон
zig build test -Dstep=9
zig build run -- disasm fixtures/step_09.o
Вывод второй команды совпадает с objdump -d fixtures/step_09.o строка в строку, если из эталона убрать первые строки с именем файла и подпись <ztPushPop>:. Заголовки и имена функций требуют таблицы символов, до которой zt дорастёт в блоке S6.
С этого места и до конца блока zt disasm растёт каждый урок: в следующем он научится читать адреса и всё семейство mov, потом арифметику, потом переходы. К уроку 19 он будет читать почти всё, что генерирует компилятор, и мы перевернём задачу и начнём машинный код писать.
Упражнения
Итоги
- Машинный код это байты по правилам, а не другой язык. Раз правила есть, их можно применить назад: это и есть дизассемблер.
- Листинг достаётся двумя способами:
-femit-asmдаёт текст от компилятора с именами и разметкой,objdump -dдаёт байты и адреса от дизассемблера. Своя подкомандаzig objdumpв 0.16 ещё заглушка. - Кросс-компиляция снимает вопрос про Apple Silicon:
-target x86_64-linuxгенерирует нужный код, читать его можно на любой машине. - Читать листинги надо в
ReleaseSafeилиReleaseFast. ВDebugкомпилятор пишет для отладчика, а не для процессора, и логики за этим не видно. - Разница между режимами измерима в байтах: у
multstoreэто 65 байт вDebug, 10 на быстром путиReleaseSafe, 13 вReleaseFastи 8 с-fomit-frame-pointer. - Суффиксы
b,w,l,qэто 1, 2, 4 и 8 байт. Слово это два байта, наследство 8086. - Знака у ширины нет. Инструкция складывает и умножает одинаково для знаковых и беззнаковых; знак важен только в делении, сдвиге вправо, расширении и условных переходах.
- Шестнадцать регистров пронумерованы в историческом порядке
rax,rcx,rdx,rbx,rsp,rbp,rsi,rdi, дальшеr8доr15. Роли (%rdiпервый аргумент,%raxрезультат) это соглашение System V, а не свойство железа. - Байтовые имена номеров с 4 по 7 зависят от наличия REX: с ним
%splи компания, без него%ahи компания. - Префикс REX это байт
0100WRXB. БитWвключает 64-битный операнд, битыR,X,Bдобавляют четвёртый разряд к трём номерам регистров. Инструкция со старшими регистрами на байт длиннее. - В листинге компилятора инструкций меньшинство. Директивы с точкой в начале адресованы ассемблеру, метки с точкой локальные,
.locи.cfi_*это отладочная информация, которая живёт в отдельных секциях. - Декодер, который не узнал байт, обязан вернуть длину один и идти дальше. Иначе одна ошибка портит весь остаток вывода.
Дальше
Ты умеешь доставать листинг, узнавать в нём регистры и ширины, отличать код от разметки и разбирать префикс по битам. Не хватает главного: инструкция должна как-то назвать данные, с которыми работает, а мы пока видели только регистры и один адрес в скобках.
Следующий урок закрывает эту дыру целиком. Одиннадцать форм операнда, которые сводятся к одной формуле адреса; семейство mov во всех обличьях и почему у него нет формы «из памяти в память»; правило про запись в 32-битный регистр, обнуляющую старшую половину; push и pop как пара обычных инструкций. И байты: как форма адреса упаковывается в ModRM и SIB, и почему (%rbp) кодируется длиннее, чем (%rax).
домашка