Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
inline-ассемблер и голое железо
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
inline-ассемблер и голое железо
Весь блок ты читал чужой ассемблер: брал вывод компилятора и восстанавливал по нему исходник. Теперь развернём стрелку. Ты вставишь ассемблерные инструкции прямо в Zig через
asm volatile, вызовешь ядро без единой строки стандартной библиотеки, соберёшь функцию целиком на отдельном.sи запустишь бинарник, в котором нет ни libc, ниstd, только твоя точка входа и два системных вызова. В конце сравним x86-64 с aarch64: идея одна, регистры и номера разные.
Цели урока
- Прочитать синтаксис
asm volatileв Zig 0.16: шаблон, выходные и входные ограничения, список затираемых регистров, зачем нужно словоvolatile. - Понять, как выглядит системный вызов на x86-64: номер в
%rax, аргументы в%rdi,%rsi,%rdx, инструкцияsyscall, затёртые%rcxи%r11. - Напечатать строку прямым сисколлом
write, безstd.Ioи без libc, и убедиться по запуску, что работает. - Собрать функцию, написанную целиком на ассемблере в файле
.s, черезzig ccи вызвать её из Zig какextern fn. - Написать
callconv(.naked)функцию без пролога, свою_start, и собрать минимальный бинарник безstd, измерив его размер. - Перенести те же идеи на aarch64: регистры
x0..x7, номер вx8, ловушкаsvc #0, другой номерwrite.
Идея: как программа просит ядро о работе
Пользовательская программа не умеет сама писать в файл или на экран. Всё, что выходит за пределы её памяти, делает ядро. Способ попросить называется системным вызовом. Библиотека std, которую ты звал всё это время через std.Io.File.stdout(), в конце концов упирается ровно в него: складывает номер и аргументы по регистрам и выполняет одну особую инструкцию, которая передаёт управление ядру.
В уроке про сборку и C ты видел, что zig cc тащит с собой musl, целую реализацию libc. Сегодня мы обойдёмся без неё. Не потому, что libc плоха, а потому, что без неё видно дно: под всеми обёртками лежат три вещи, номер операции, несколько регистров и инструкция-ловушка. Когда ты их увидишь, перестанет быть загадкой, что такое «системный вызов» и почему strace показывает именно такие строки.
Соглашение о системных вызовах на Linux x86-64 короткое:
- номер сисколла кладётся в
%rax; - аргументы, слева направо, в
%rdi,%rsi,%rdx,%r10,%r8,%r9; - выполняется инструкция
syscall; - результат возвращается в
%rax, при ошибке это отрицательное число, равное минусerrno; - инструкция
syscallпопутно затирает%rcxи%r11, ядро использует их для своих нужд.
Заметь: четвёртый аргумент идёт в %r10, а не в %rcx, хотя обычное соглашение о вызовах поставило бы туда %rcx. Причина ровно в том, что syscall затирает %rcx, поэтому для сисколлов ABI сдвинул четвёртый аргумент в %r10.
asm volatile: ассемблер внутри Zig
Чтобы выполнить syscall, нужно вставить эту инструкцию в тело Zig-функции. Для этого есть встроенное выражение asm. Полная форма делится на четыре части, разделённые двоеточиями: шаблон, выходные ограничения, входные ограничения и список затираемых регистров.
// Прямой сисколл write(2) без libc. Номер write это 1, аргументы в rdi/rsi/rdx.
fn sysWrite(fd: usize, buf: [*]const u8, len: usize) usize {
return asm volatile ("syscall"
: [ret] "={rax}" (-> usize),
: [number] "{rax}" (@as(usize, 1)),
[fd] "{rdi}" (fd),
[buf] "{rsi}" (buf),
[len] "{rdx}" (len),
: .{ .rcx = true, .r11 = true, .memory = true });
}
pub fn main() void {
const msg = "привет без libc\n";
_ = sysWrite(1, msg, msg.len);
}
Читай выражение по частям.
- Шаблон
"syscall"это буквальный текст ассемблера. Здесь одна инструкция без операндов: всё, что ей нужно, уже разложено по регистрам входными ограничениями. - Выходные ограничения, после первого двоеточия. Запись
[ret] "={rax}" (-> usize)говорит: результат типаusizeберётся из регистра%rax, знак=означает «только запись», а-> usizeэто форма, когда всё выражениеasmсамо становится значением. Имя[ret]можно использовать в шаблоне как%[ret], но здесь оно не нужно. - Входные ограничения, после второго двоеточия. Каждая строка привязывает Zig-значение к конкретному регистру:
"{rax}" (@as(usize, 1))кладёт единицу, номерwrite, в%rax;"{rdi}" (fd)кладёт дескриптор в%rdi, и так далее. Фигурные скобки вокруг имени регистра означают «именно этот регистр», без них компилятор выбрал бы регистр сам. - Затираемые регистры, после третьего двоеточия. В Zig 0.16 это clobbers, записанный структурным литералом
.{ .rcx = true, .r11 = true, .memory = true }. Мы честно объявляем, чтоsyscallзатирает%rcxи%r11, а.memory = trueговорит компилятору, что вызов мог поменять память, поэтому кэшированные в регистрах значения из памяти надо перечитать.
Слово volatile здесь обязательно. Без него компилятор считает asm-выражение чистым: раз у него есть выход и нет побочных эффектов, а результат sysWrite в main отбрасывается через _ =, оптимизатор имел бы полное право выкинуть весь вызов. volatile запрещает это: инструкция обязана выполниться ровно там, где написана, и ровно один раз.
Собери и запусти это в контейнере курса. Собственный x86-64 бэкенд Zig в Debug вполне справляется с inline-ассемблером:
$ zig build-exe write_basic.zig --name write_basic
$ ./write_basic
привет без libc
Строка напечатана, и в бинарнике нет ни std, ни libc: только _start, который дал Zig, и наш единственный syscall.
Что видит дизассемблер
Убедимся, что asm volatile не магия, а ровно одна инструкция. Соберём sysWrite как отдельную функцию с C-сигнатурой (чтобы оптимизатор её не встроил) и посмотрим байты через objdump:
$ objdump -d wb_export.o
0000000000000000 <doWrite>:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 48 b8 01 00 00 00 00 00 00 00 movabsq $0x1, %rax
e: 0f 05 syscall
10: 5d popq %rbp
11: c3 retq
Пролог push %rbp и mov %rsp, %rbp компилятор добавил сам, аргументы fd, buf и len уже приехали в %rdi, %rsi, %rdx по соглашению о вызовах, поэтому в теле осталось только положить номер 1 в %rax инструкцией movabs и выполнить syscall. Опкод syscall это два байта 0f 05, и это единственное, что дал наш шаблон. Всё остальное вокруг это обычный код функции.
Виджет: сисколл по регистрам
Соглашение проще увидеть, чем прочитать. Выбери сисколл из четырёх, впиши значения аргументов в регистры и нажми кнопку с инструкцией-ловушкой. Виджет покажет, что вернулось в регистр результата, и как прочитать отрицательное значение как минус errno. Переключатель сверху меняет архитектуру: имена регистров, номера сисколлов и сама ловушка syscall против svc #0 подменяются на лету. Обрати внимание на write с дескриптором 1 (успех, вернулось число байтов) против дескриптора 9 (закрыт, вернулось минус 9, это EBADF), и на openat с нулевым адресом имени (минус 14, EFAULT).
Главный вывод из кручения: код возврата и ошибка это одно и то же число в одном регистре. Ядро не бросает исключений и не заводит отдельный канал для ошибок. Оно кладёт в %rax либо полезный результат (число записанных байтов, новый дескриптор), либо отрицательное значение из узкого диапазона от минус 4095 до минус 1, которое означает минус errno. Обёртки libc как раз и превращают это отрицательное число в привычный -1 плюс глобальную переменную errno, а мы теперь видим сырую договорённость.
write, повторённый до конца
Один syscall write может записать меньше, чем ты попросил: ядро вправе принять только часть данных, особенно на трубах и сокетах. Поэтому надёжная запись это цикл: зовём write на остаток, пока не запишем всё или не упрёмся в ошибку.
fn sysWrite(fd: i32, buf: [*]const u8, len: usize) usize {
return asm volatile ("syscall"
: [ret] "={rax}" (-> usize),
: [number] "{rax}" (@as(usize, 1)),
[fd] "{rdi}" (@as(usize, @intCast(fd))),
[buf] "{rsi}" (buf),
[len] "{rdx}" (len),
: .{ .rcx = true, .r11 = true, .memory = true });
}
/// Пишет все bytes в fd, повторяя write до полной записи.
pub fn writeAll(fd: i32, bytes: []const u8) usize {
var written: usize = 0;
while (written < bytes.len) {
const n = sysWrite(fd, bytes.ptr + written, bytes.len - written);
// Ошибка приходит как минус errno: в usize это очень большое число,
// а как isize оно отрицательное. На нём цикл обязан остановиться.
const signed: isize = @bitCast(n);
if (signed <= 0) break;
written += @intCast(signed);
}
return written;
}
Разбор трёх мест.
sysWriteтот же, толькоfdтеперьi32: дескриптор это знаковое число, и его надо привести кusizeперед укладкой в регистр.- Возврат интерпретируется дважды. Как
usizeминус 9 выглядит как гигантское число около двух в шестьдесят четвёртой, поэтому мы делаем@bitCastвisizeи смотрим знак. Отрицательное или ноль означает «дальше писать нельзя», и цикл выходит, а не крутится вечно. bytes.ptr + writtenсдвигает указатель на уже записанное,bytes.len - writtenэто оставшийся хвост. Так следующий заход пишет ровно то, что не влезло в прошлый.
Эта функция и есть твоя задача в конце урока: пишешь writeAll, а скрытые тесты проверяют её на трубе, на пустом срезе и на закрытом дескрипторе.
Функция целиком на ассемблере: отдельный .s
Inline-ассемблер хорош для одной-двух инструкций внутри Zig-функции. Когда функция целиком написана на ассемблере, её кладут в отдельный файл .s, собирают ассемблером и линкуют как обычный объектник. zig cc умеет и это: он ассемблер, а не только компилятор C.
Простая функция add3, складывающая три аргумента. По соглашению о вызовах System V первые три целых аргумента приезжают в %rdi, %rsi, %rdx, результат уходит в %rax:
.text
.globl add3
add3:
movq %rdi, %rax # rax = a
addq %rsi, %rax # rax += b
addq %rdx, %rax # rax += c
ret
Директива .text кладёт код в секцию кода, .globl add3 делает символ add3 видимым снаружи (без неё линкер его не найдёт), метка add3: это адрес входа. Дальше три инструкции и ret.
Со стороны Zig объявляем функцию как extern: тело живёт в другом файле, здесь только сигнатура с C-соглашением.
const std = @import("std");
// Тело функции живёт в add3.s, здесь только объявление с C ABI.
extern fn add3(a: u64, b: u64, c: u64) callconv(.c) u64;
test "add3 складывает три аргумента" {
try std.testing.expectEqual(@as(u64, 6), add3(1, 2, 3));
try std.testing.expectEqual(@as(u64, 100), add3(30, 30, 40));
}
Собираем .s в объектник через zig cc -c, а потом передаём этот объектник в zig test как обычный позиционный аргумент:
$ zig cc -c -target x86_64-linux add3.s -o add3.o
$ zig test add3_main.zig add3.o
1/1 add3_main.test.add3 складывает три аргумента...OK
All 1 tests passed.
Проверим, что в объектнике лежит ровно то, что мы написали. objdump печатает и байты, и AT&T-мнемонику:
$ objdump -d add3.o
0000000000000000 <add3>:
0: 48 89 f8 movq %rdi, %rax
3: 48 01 f0 addq %rsi, %rax
6: 48 01 d0 addq %rdx, %rax
9: c3 retq
Три инструкции и ret, ни байта лишнего: 48 89 f8 это movq %rdi, %rax, 48 01 f0 это addq %rsi, %rax. Префикс 48 это REX.W, тот самый, что делает операцию 64-битной, ты разбирал его в уроке про путь от Zig к машинному коду. Функция на ассемблере и функция на Zig после компиляции неотличимы: обе это символ в секции кода с C-соглашением о вызовах.
Ту же add3 можно написать и как inline-ассемблер внутри Zig-функции, без отдельного файла. Тогда аргументы связываются с регистрами через ограничения, а результат возвращается через выходной операнд:
fn add3(a: u64, b: u64, c: u64) u64 {
return asm volatile (
\\ addq %[b], %[sum]
\\ addq %[c], %[sum]
: [sum] "=r" (-> u64),
: [a] "0" (a),
[b] "r" (b),
[c] "r" (c),
);
}
Здесь ограничение "=r" у выхода означает «любой свободный регистр общего назначения», а не конкретный. Вход "0" (a) это хитрость: цифра 0 привязывает вход к нулевому выходному операнду, то есть a попадает в тот же регистр, что и sum. Первая инструкция прибавляет к нему b, вторая c. Имена в шаблоне пишутся как %[sum], %[b], %[c]: компилятор подставит вместо них выбранные регистры. Этот вариант и есть вторая задача урока.
callconv(.naked): функция без пролога
У обычной функции компилятор ставит пролог и эпилог: push %rbp, настройку кадра, а в конце восстановление и ret. Ты видел их в дизассемблере doWrite выше. Для точки входа программы это лишнее и даже вредное: когда ядро запускает процесс, стек устроен по-особому, на вершине лежит argc, и любой пролог сдвинет указатель до того, как мы его прочитаем.
Для таких случаев есть callconv(.naked). Тело naked-функции это только ассемблер, без единой строки обычного Zig. Наша _start прочитает указатель стека, выровняет его и передаст управление обычной функции:
// Своя точка входа: ни пролога, ни аргументов, стек как его оставило ядро.
export fn _start() callconv(.naked) noreturn {
asm volatile (
\\ movq %%rsp, %%rdi
\\ andq $-16, %%rsp
\\ jmp *%[entry]
:
: [entry] "r" (&start),
);
}
fn start(sp: [*]const usize) callconv(.c) noreturn {
const argc = sp[0];
_ = sysWrite(1, "аргументов ровно ", "аргументов ровно ".len);
const digit = [1]u8{'0' + @as(u8, @intCast(argc))};
_ = sysWrite(1, &digit, 1);
_ = sysWrite(1, "\n", 1);
sysExit(@intCast(argc));
}
Три детали.
- В шаблоне register пишется как
%%rsp, с удвоенным процентом. Одиночный%в шаблоне Zig зарезервирован под подстановку операндов вроде%[entry], поэтому буквальный регистр экранируется вторым процентом. Это единственное место, где синтаксис шаблона отличается от привычного AT&T. movq %%rsp, %%rdiкопирует указатель стека в первый регистр аргумента, потому что на вершине стека лежитargc, а сразу за ним массивargv.andq $-16, %%rspсбрасывает младшие четыре бита указателя, то есть выравнивает его на 16 байтов, как требует ABI перед вызовом.jmp *%[entry]прыгает на адрес функцииstart, который мы передали входным операндом.startуже обычная функция с C-соглашением:sp[0]этоargc, дальше мы печатаем его цифрой и завершаем процесс сисколломexit(номер 60), передавargcкодом возврата.
Функция sysExit устроена как sysWrite, только номер другой и она noreturn:
fn sysExit(code: u8) noreturn {
asm volatile ("syscall"
:
: [number] "{rax}" (@as(usize, 60)),
[code] "{rdi}" (@as(usize, code)),
: .{ .rcx = true, .r11 = true, .memory = true });
unreachable;
}
У sysExit нет выходных ограничений, поэтому вторая часть пустая: сисколл exit не возвращает управление, и после него стоит unreachable.
Минимальный бинарник без std
Соберём это как настоящую программу. Ключ в том, чтобы не подключать std: как только в файле нет pub fn main и нет импорта std, а точку входа _start мы дали сами, компилятору незачем тащить стартовый код стандартной библиотеки. Цель x86_64-freestanding собирает ровно такой бинарник, без операционной системы в стартапе, с одной нашей _start:
$ zig build-exe freestanding.zig -target x86_64-freestanding --name mini
$ ./mini one two
аргументов ровно 3
код возврата: 3
Три аргумента это имя программы плюс one и two, поэтому argc равен 3. Программа сама прочитала его со стека, напечатала и вернула кодом возврата, и всё это без единой строки чужого кода.
Теперь измерим, во что обходится std. Соберём под -O ReleaseSmall наш минимальный бинарник и обычный hello world на std.Io:
$ zig build-exe freestanding.zig -target x86_64-freestanding -O ReleaseSmall --name mini
$ zig build-exe hello_std.zig -target x86_64-linux -O ReleaseSmall --name hello
$ ls -l mini hello
1112 mini
145024 hello
Разница больше, чем в сто раз: 1112 байт против примерно 142 килобайт. В hello вошёл весь стартовый код std, разбор аргументов, настройка буферизованного вывода, обработчик паники со стеком. Нашему mini из этого не нужно ничего: он делает ровно три сисколла. Числа сняты кросс-компиляцией под Linux на ноутбуке с Apple Silicon, размеры от архитектуры хоста не зависят.
Это и есть «голое железо» в мягком варианте: программа, которая говорит с ядром напрямую и не зависит ни от чего сверху. На настоящем железе без операционной системы _start выглядел бы так же, только вместо сисколлов ты писал бы в регистры устройств по фиксированным адресам. К этому вернёмся в разделе про ядро.
На aarch64: та же идея, другие регистры
У нас под рукой две архитектуры: контейнер курса это x86-64, а ноутбук на Apple Silicon это aarch64. Соглашение о сисколлах на aarch64 устроено так же по смыслу, но по буквам иначе:
- аргументы идут в
x0,x1,x2и дальше доx7; - номер сисколла кладётся в
x8, а не в регистр результата; - ловушка называется
svc #0, а неsyscall; - результат возвращается в
x0; - номера сисколлов другие:
writeэто 64,exitэто 93 (на x86-64 было 1 и 60).
Вот тот же write, написанный для Linux на aarch64:
// Linux aarch64: номер 64 в x8, аргументы x0/x1/x2, ловушка svc #0.
export fn doWrite(fd: usize, buf: [*]const u8, len: usize) usize {
return asm volatile ("svc #0"
: [ret] "={x0}" (-> usize),
: [number] "{x8}" (@as(usize, 64)),
[fd] "{x0}" (fd),
[buf] "{x1}" (buf),
[len] "{x2}" (len),
: .{ .memory = true });
}
Дизассемблер подтверждает, что ловушка одна инструкция, а номер кладётся отдельно:
$ objdump -d wa_export.o
0000000000000000 <doWrite>:
0: 52800808 mov w8, #0x40 // =64
4: d4000001 svc #0
8: d65f03c0 ret
mov w8, #0x40 кладёт 64 в младшую половину x8, svc #0 дёргает ядро, ret возвращает. Никаких %rcx и %r11 в clobbers: на aarch64 svc не портит регистры общего назначения так, как это делает syscall на x86-64, поэтому в списке затираемого осталась только память.
На самом Маке ядро не Linux, а Darwin, и у него своя таблица: номер write это 0x2000004, кладётся он в x16, а ловушка это svc #0x80. Вот версия, которая печатает строку нативно на macOS:
// Нативный aarch64 на macOS: соглашение Darwin. Номер write это 0x2000004,
// он ложится в x16, аргументы в x0/x1/x2, ловушка через svc #0x80.
fn sysWrite(fd: usize, buf: [*]const u8, len: usize) usize {
return asm volatile ("svc #0x80"
: [ret] "={x0}" (-> usize),
: [number] "{x16}" (@as(usize, 0x2000004)),
[fd] "{x0}" (fd),
[buf] "{x1}" (buf),
[len] "{x2}" (len),
: .{ .memory = true });
}
pub fn main() void {
const msg = "aarch64 write на macOS, без libc\n";
_ = sysWrite(1, msg, msg.len);
}
$ zig run write_macos.zig
aarch64 write на macOS, без libc
Три платформы, один и тот же приём: разложи номер и аргументы по оговорённым регистрам, дёрни инструкцию-ловушку, прочитай результат из оговорённого регистра. Меняются только имена и числа. Именно поэтому переносимый код прячет всё это за std: соглашение о сисколлах у каждой пары «архитектура плюс ядро» своё, а подпись функции write одна.
Шаг проекта: zt учится ловушкам
Последний шаг дизассемблера в этом блоке самый короткий, а инструкции в нём самые важные: без них программа не может ни попросить ядро о чём-нибудь, ни остановиться под отладчиком. Вот фикстура шага в выводе zt после урока 17:
$ nm fixtures/step_18.o > step_18.nm
$ ./zig-out/bin/zt disasm --syms=step_18.nm fixtures/step_18.o | sed -n '/<ztBreak>/,$p'
0000000000000010 <ztBreak>:
10: 55 pushq %rbp
11: 48 89 e5 movq %rsp, %rbp
14: cc (bad)
15: 48 8d 47 01 leaq 0x1(%rdi), %rax
19: 5d popq %rbp
1a: c3 retq
1b: 0f 1f 44 00 00 nopl (%rax,%rax)
0000000000000020 <ztExit>:
20: 55 pushq %rbp
21: 48 89 e5 movq %rsp, %rbp
24: 89 ff movl %edi, %edi
26: b8 3c 00 00 00 movl $0x3c, %eax
2b: 0f (bad)
2c: 05 (bad)
2d: 0f 1f 00 nopl (%rax)
0000000000000030 <ztWrite>:
30: 55 pushq %rbp
31: 48 89 e5 movq %rsp, %rbp
34: b8 01 00 00 00 movl $0x1, %eax
39: 0f (bad)
3a: 05 (bad)
3b: 5d popq %rbp
3c: c3 retq
ztWrite это doWrite из раздела про дизассемблер, только собранная с -O ReleaseFast: номер write компилятор положил короткой инструкцией movl $0x1, %eax вместо movabsq, запись в 32-битный регистр всё равно обнуляет старшую половину. А сам syscall рассыпался на два (bad). Байт 05 это addl с четырёхбайтным числом, и декодер попробовал его так прочитать, но до следующей функции осталось меньше четырёх байтов. Ограничение по подписям из урока 14 не дало ему съесть пролог соседа.
Две строки таблицы
syscall это 0f 05, без операндов. Всё, что ему нужно, уже разложено по регистрам, как ты видел в разделе про ограничения asm. В двухбайтной таблице src/x86/decoder.zig, первой строкой:
// Вход в ядро: номер вызова в rax, аргументы в регистрах.
0x05 => cursor.done("syscall", null),
int3 это один байт cc. В однобайтной таблице, рядом с leave:
// Ловушка отладчика. Компоновщик забивает ею промежутки между функциями.
0xcc => cursor.done("int3", null),
Суффикса нет ни у той, ни у другой: операндов нет, и ширину назвать не у чего.
Про int3 в уроке речи не было, а стоит. Это тоже ловушка, как syscall, только ведёт она не в обработчик системного вызова, а в обработчик исключения отладки. Ядро превращает его в сигнал SIGTRAP, и если к процессу подключён отладчик, управление получает он. Так устроены точки остановки: отладчик запоминает первый байт инструкции, на которой ты хочешь остановиться, и пишет на его место cc. Процессор доходит до ловушки, отладчик получает управление, возвращает исходный байт и откатывает счётчик команд на один байт назад. Всё это работает только потому, что int3 занимает ровно один байт: самую короткую инструкцию x86-64 можно подменить, не задев соседнюю. У той же ловушки есть длинная запись cd 03, int $0x3, но для точки остановки она не годится именно из-за длины.
В Zig int3 ставит встроенная функция @breakpoint(). Компоновщик забивает этим байтом промежутки между функциями в готовом файле: если программа по ошибке прыгнет в добивку, она остановится сразу, а не пойдёт выполнять мусор. Этот байт ты ещё встретишь в выводе zt на готовых бинарниках.
Фикстура и тесты шага
//! Фикстура шага 18: вход в ядро и ловушка отладчика.
//!
//! Собирается так:
//! zig build-obj fixtures/step_18.zig -O ReleaseFast \
//! -target x86_64-linux-musl -femit-bin=fixtures/step_18.o
/// write(2) напрямую, как в уроке: номер вызова в `%rax`, аргументы
/// приехали в `%rdi`, `%rsi`, `%rdx` по соглашению о вызовах.
export fn ztWrite(fd: usize, buf: [*]const u8, len: usize) usize {
return asm volatile ("syscall"
: [ret] "={rax}" (-> usize),
: [number] "{rax}" (@as(usize, 1)),
[fd] "{rdi}" (fd),
[buf] "{rsi}" (buf),
[len] "{rdx}" (len),
: .{ .rcx = true, .r11 = true, .memory = true });
}
/// exit(2) не возвращается, и после `syscall` компилятору ставить нечего.
export fn ztExit(code: u8) noreturn {
asm volatile ("syscall"
:
: [number] "{rax}" (@as(usize, 60)),
[code] "{rdi}" (@as(usize, code)),
: .{ .rcx = true, .r11 = true, .memory = true });
unreachable;
}
/// `@breakpoint()` становится одним байтом 0xCC: отладчик кладёт его
/// поверх первой инструкции, на которой хочет остановиться.
export fn ztBreak(x: u64) u64 {
@breakpoint();
return x +% 1;
}
/// Точка входа без пролога, как `_start` из урока.
export fn ztStart() callconv(.naked) noreturn {
asm volatile (
\\movq %rsp, %rdi
\\andq $-16, %rsp
\\callq *%rax
\\hlt
);
}
zig build-obj fixtures/step_18.zig -O ReleaseFast \
-target x86_64-linux-musl -femit-bin=fixtures/step_18.o
objdump -d fixtures/step_18.o > fixtures/step_18.objdump.txt
Запись в fixtures/root.zig:
pub const step_18 = Fixture{
.object = @embedFile("step_18.o"),
.objdump = @embedFile("step_18.objdump.txt"),
};
Тесты в tests/step_18.zig:
//! Шаг 18: вход в ядро и ловушка отладчика.
//!
//! `syscall` это два байта `0F 05` без операндов: номер вызова и аргументы
//! уже лежат в регистрах. `int3` это один байт `CC`: отладчик пишет его
//! поверх инструкции, на которой хочет остановить программу.
const std = @import("std");
const fixtures = @import("fixtures");
const zt = @import("zt");
const support = @import("support.zig");
const decode = zt.x86.decoder.decode;
test "фикстура шага разбирается целиком" {
try support.expectNoBadInstructions(fixtures.step_18);
}
test "вывод совпадает с objdump построчно" {
try support.expectMatchesObjdump(fixtures.step_18);
}
test "предыдущие шаги остались зелёными" {
try support.expectMatchesObjdump(fixtures.step_09);
try support.expectMatchesObjdump(fixtures.step_10);
try support.expectMatchesObjdump(fixtures.step_11);
try support.expectMatchesObjdump(fixtures.step_12);
try support.expectMatchesObjdump(fixtures.step_13);
try support.expectMatchesObjdumpNamed(fixtures.step_14, fixtures.step_14_nm);
try support.expectMatchesObjdump(fixtures.step_17);
}
test "syscall занимает два байта и операндов не имеет" {
const syscall = decode(&.{ 0x0f, 0x05 }, 0);
try std.testing.expectEqualStrings("syscall", syscall.mnemonic);
try std.testing.expectEqual(@as(usize, 2), syscall.length());
try std.testing.expectEqual(@as(usize, 0), syscall.operandSlice().len);
try std.testing.expectEqual(@as(?u8, null), syscall.suffix);
}
test "int3 занимает ровно один байт" {
// Иначе отладчик не смог бы поставить точку остановки на однобайтную
// инструкцию, не задев соседнюю.
const trap = decode(&.{ 0xcc, 0x48, 0x8d, 0x47, 0x01 }, 0x14);
try std.testing.expectEqualStrings("int3", trap.mnemonic);
try std.testing.expectEqual(@as(usize, 1), trap.length());
}
test "за syscall в exit ничего нет: дальше идёт добивка" {
// В ztExit после syscall компилятор не поставил ни ret, ни ud2:
// unreachable в ReleaseFast не превращается в код.
const text = try zt.elf.findSection(fixtures.step_18.object, ".text");
const after = decode(text.data[0x2d..], 0x2d);
try std.testing.expectEqualStrings("nop", after.mnemonic);
try std.testing.expectEqualStrings("syscall", decode(text.data[0x2b..], 0x2b).mnemonic);
}
Последний тест фиксирует то, что легко не заметить в листинге. У ztExit после syscall нет ни ret, ни ud2: функция объявлена noreturn, после сисколла стоит unreachable, и в режиме ReleaseFast это обещание компилятору, а не проверка. Если ядро всё-таки вернёт управление, процессор пойдёт выполнять добивку nopl и дальше то, что лежит за функцией. Собери ту же фикстуру с -O ReleaseSafe, и за syscall появится callq в обработчик паники reachedUnreachable: там unreachable это проверка, и программа упадёт с сообщением, а не пойдёт исполнять что попало.
И 18 в project_steps в build.zig.
Прогон
$ zig build test -Dstep=18 --summary all
Build Summary: 3/3 steps succeeded; 6/6 tests passed
test success
+- run test 6 pass (6 total) 18ms MaxRSS:3M
С подписями из nm вывод совпадает с objdump целиком:
$ ./zig-out/bin/zt disasm --syms=step_18.nm fixtures/step_18.o > zt18.txt
$ objdump -d fixtures/step_18.o | tail -n +5 > od18.txt
$ diff od18.txt zt18.txt && echo совпало
совпало
Целая программа
Теперь можно проверить zt на чём-то большем, чем фикстура. Возьми mini из раздела про бинарник без std, собранный с -O ReleaseSmall. Таблицы символов в нём нет вовсе, nm mini ответит no symbols, поэтому подписей не будет, но они и не нужны:
$ ./zig-out/bin/zt disasm mini
10011e4: b8 f2 11 00 01 movl $0x10011f2, %eax
10011e9: 48 89 e7 movq %rsp, %rdi
10011ec: 48 83 e4 f0 andq $-0x10, %rsp
10011f0: ff e0 jmpq *%rax
10011f2: 55 pushq %rbp
10011f3: 48 89 e5 movq %rsp, %rbp
10011f6: 4c 8b 07 movq (%rdi), %r8
10011f9: 48 bf 01 00 00 00 00 00 00 00 movabsq $0x1, %rdi
1001203: 48 ba 20 00 00 00 00 00 00 00 movabsq $0x20, %rdx
100120d: be 58 01 00 01 movl $0x1000158, %esi
1001212: 48 89 f8 movq %rdi, %rax
1001215: 0f 05 syscall
1001217: 41 8d 40 30 leal 0x30(%r8), %eax
100121b: 48 8d 75 ff leaq -0x1(%rbp), %rsi
100121f: 88 06 movb %al, (%rsi)
1001221: 48 89 f8 movq %rdi, %rax
1001224: 48 89 fa movq %rdi, %rdx
1001227: 0f 05 syscall
1001229: be 79 01 00 01 movl $0x1000179, %esi
100122e: 48 89 f8 movq %rdi, %rax
1001231: 0f 05 syscall
1001233: 48 b8 3c 00 00 00 00 00 00 00 movabsq $0x3c, %rax
100123d: 4c 89 c7 movq %r8, %rdi
1001240: 0f 05 syscall
Из 1112 байтов файла код занимает 94, это 24 инструкции, и ни одна не осталась (bad). Прочитай их сверху вниз, это и есть итог блока. Первые четыре строки это наша _start: адрес start в %eax, указатель стека в %rdi, выравнивание и косвенный переход jmpq *%rax из урока 13. Дальше start: argc читается из (%rdi), и три раза подряд номер вызова и аргументы раскладываются по регистрам перед syscall. Все три вызова sysWrite встроились, ни одного call не осталось. Цифра argc превращается в символ через leal 0x30(%r8), %eax из урока 11 и пишется в байт на стеке. Четвёртый syscall с $0x3c в %rax это exit, и после него в файле ничего нет.
Сверь сам с objdump -d mini: расхождение будет только в заголовке <.text>:, которым objdump подписывает секцию без символов, и в комментариях # imm = 0x10011F2, где он повторяет большое число ещё раз.
С этого места твой дизассемблер читает почти всё, что выдаёт компилятор Zig под x86_64 без AVX. В следующем уроке задача переворачивается: не байты в текст, а текст в байты, и машинный код начнёт писать сама программа.
Практика
Первая задача собирает вместе всё про write: ты пишешь writeAll(fd, bytes), которая через asm volatile зовёт сисколл write в цикле, пока не запишет весь срез. Скрытые тесты проверяют полную запись, пустой срез и закрытый дескриптор, на котором цикл обязан остановиться, а не зациклиться. Задача только про x86-64: тесты сами пропустят себя на другой архитектуре.
Вторая задача про inline-ассемблер как выражение: внутри Zig-функции add3(a, b, c) ты пишешь тело на asm, которое складывает три аргумента через регистры и возвращает сумму выходным операндом. Потом та же сумма второй раз, но с операндами, прибитыми к rax, rbx и rcx, чтобы почувствовать разницу между "r" и "{rax}". Последняя функция maxU64 обходится без единой ветки: cmpq выставляет флаги, cmovb переносит большее. Никаких сисколлов, чистая арифметика в регистрах, но с настоящими ограничениями и связыванием.
Упражнения
Итоги
- Пользовательский код не трогает железо: всё внешнее делает ядро, а просят его системным вызовом, номером операции плюс аргументами в оговорённых регистрах и одной инструкцией-ловушкой.
- На Linux x86-64 номер сисколла идёт в
%rax, аргументы в%rdi,%rsi,%rdx,%r10,%r8,%r9, ловушка этоsyscall, результат в%rax. Четвёртый аргумент в%r10, потому чтоsyscallзатирает%rcx. - Выражение
asmв Zig делится на четыре части: шаблон, выходные ограничения, входные ограничения, список затираемого. Регистр задаётся фигурными скобками"{rax}", любой регистр это"r", привязка входа к выходу это цифра операнда. - Слово
volatileобязательно там, где у инструкции есть побочный эффект: без него компилятор вправе выкинуть или переставитьasm, чей результат не используется. - Ошибка сисколла приходит тем же числом в том же регистре: отрицательное значение от минус 4095 до минус 1 это минус
errno. Обёртки libc превращают его в-1плюсerrno. - Функция целиком на ассемблере живёт в
.s, собирается черезzig cc -cи линкуется как объектник, а со стороны Zig объявляется какextern fn ... callconv(.c). callconv(.naked)даёт функцию без пролога и эпилога, её тело это чистый ассемблер. Такая нужна для_start, где стек надо прочитать до любых сдвигов.- Бинарник без
stdсобирается цельюx86_64-freestandingсо своей_start: подReleaseSmallон выходит около килобайта против примерно 142 килобайт у hello world наstd. - На aarch64 идея та же, буквы другие: аргументы в
x0..x7, номер вx8, ловушкаsvc #0, результат вx0, номерwriteэто 64. У Darwin своя таблица: номер вx16, ловушкаsvc #0x80. - В шаге проекта
zt disasmнаучилсяsyscallиint3и впервые прочитал целую программу без единого(bad):int3занимает один байт, поэтому отладчик может подменить им любую инструкцию.
Дальше
Ты научился говорить с ядром напрямую и собирать бинарник, в котором нет ничего лишнего. Это тот же навык, что понадобится, когда мы дойдём до устройства ядра, драйверов и настоящего голого железа: там write в регистр устройства выглядит ровно как сегодняшний сисколл, только адрес другой. А в следующем уроке мы в последний раз в этом блоке развернём стрелку до упора: перестанем читать машинный код и начнём его писать сами, генерируя байты x86-64 прямо в память и вызывая их как функцию. Всё, что ты разбирал по инструкции, станет тем, что ты порождаешь по инструкции.
домашка