Раздел 32 · Системное программирование: Zig, ассемблер, Verilog

inline-ассемблер и голое железо

senior~80 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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);
}

Читай выражение по частям.

  1. Шаблон "syscall" это буквальный текст ассемблера. Здесь одна инструкция без операндов: всё, что ей нужно, уже разложено по регистрам входными ограничениями.
  2. Выходные ограничения, после первого двоеточия. Запись [ret] "={rax}" (-> usize) говорит: результат типа usize берётся из регистра %rax, знак = означает «только запись», а -> usize это форма, когда всё выражение asm само становится значением. Имя [ret] можно использовать в шаблоне как %[ret], но здесь оно не нужно.
  3. Входные ограничения, после второго двоеточия. Каждая строка привязывает Zig-значение к конкретному регистру: "{rax}" (@as(usize, 1)) кладёт единицу, номер write, в %rax; "{rdi}" (fd) кладёт дескриптор в %rdi, и так далее. Фигурные скобки вокруг имени регистра означают «именно этот регистр», без них компилятор выбрал бы регистр сам.
  4. Затираемые регистры, после третьего двоеточия. В 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;
}

Разбор трёх мест.

  1. sysWrite тот же, только fd теперь i32: дескриптор это знаковое число, и его надо привести к usize перед укладкой в регистр.
  2. Возврат интерпретируется дважды. Как usize минус 9 выглядит как гигантское число около двух в шестьдесят четвёртой, поэтому мы делаем @bitCast в isize и смотрим знак. Отрицательное или ноль означает «дальше писать нельзя», и цикл выходит, а не крутится вечно.
  3. 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));
}

Три детали.

  1. В шаблоне register пишется как %%rsp, с удвоенным процентом. Одиночный % в шаблоне Zig зарезервирован под подстановку операндов вроде %[entry], поэтому буквальный регистр экранируется вторым процентом. Это единственное место, где синтаксис шаблона отличается от привычного AT&T.
  2. movq %%rsp, %%rdi копирует указатель стека в первый регистр аргумента, потому что на вершине стека лежит argc, а сразу за ним массив argv. andq $-16, %%rsp сбрасывает младшие четыре бита указателя, то есть выравнивает его на 16 байтов, как требует ABI перед вызовом. jmp *%[entry] прыгает на адрес функции start, который мы передали входным операндом.
  3. 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 прямо в память и вызывая их как функцию. Всё, что ты разбирал по инструкции, станет тем, что ты порождаешь по инструкции.

домашка

Домашка