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

Процедуры и стековые кадры

senior~170 мин

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

Процедуры и стековые кадры

Функция это не строчка языка, а протокол между двумя кусками кода. Одна инструкция кладёт адрес возврата на стек и прыгает, другая снимает его и прыгает обратно. Аргументы едут по строгому списку регистров, результат возвращается в оговорённом месте, а часть регистров вызываемый обязан вернуть нетронутыми. В этом уроке ты снимешь ассемблер обычных вызовов, вызовов с восемью аргументами, рекурсии и передачи структур по значению, и увидишь, что за каждым из них стоит один и тот же контракт.

Цели урока

  • Понять, что делают call и ret: одна кладёт адрес возврата на стек, другая снимает и прыгает по нему.
  • Прочитать по ассемблеру стековый кадр: где сохранённый %rbp, где адрес возврата, где локальные переменные.
  • Запомнить шесть целочисленных регистров аргументов System V и увидеть, как седьмой и восьмой аргументы уходят на стек.
  • Отличать регистры, которые вызываемый обязан сохранить, от тех, что он волен затирать, и находить пары push и pop в прологе и эпилоге.
  • Объяснить, зачем %rsp выравнивают на 16 перед call и как компилятор этого добивается.
  • Увидеть, что рекурсия это обычные кадры: десять вызовов дают десять одинаковых кадров, а оптимизатор иногда сворачивает хвост в цикл.
  • Разобрать передачу и возврат структур по значению: маленькая едет в регистрах, большая через скрытый указатель.
  • Восстановить рекурсивную функцию по её дизассемблеру.

Идея: вызов это контракт

Ты уже видел стек в уроке про операнды и mov: push уменьшает %rsp и пишет значение на вершину, pop читает и увеличивает %rsp. Стек растёт вниз, к младшим адресам. Теперь добавим к нему две инструкции, вокруг которых крутится весь механизм вызова.

Когда одна функция зовёт другую, им нужно договориться о четырёх вещах: куда положить аргументы, куда положить результат, куда вернуться после вызова и какие регистры переживут вызов, а какие нет. Договорённость называется соглашением о вызовах. На Linux x86-64 оно называется System V AMD64 ABI, и его соблюдает компилятор Zig ровно так же, как компилятор C: именно поэтому Zig-функцию с пометкой export можно позвать из C, как ты делал в уроке про comptime и C.

Ассемблер в этом уроке снят с реального Zig-кода командой zig build-obj file.zig -O ReleaseFast -target x86_64-linux -femit-bin=file.o и напечатан через objdump -d file.o. Синтаксис AT&T, как в книге: приёмник справа, источник слева, суффикс размера (q это 8 байт, l это 4), регистры с %, непосредственные значения с $. Ты уже читал такой в уроке про машинный код в Rust.

call и ret: два конца одной нити

Инструкция call target делает ровно две вещи: кладёт на стек адрес следующей за собой инструкции (это и есть адрес возврата) и прыгает на target. Инструкция ret снимает адрес возврата с вершины стека и прыгает по нему. Всё. call это push адреса плюс jmp, а ret это pop во внутренний счётчик команд.

Из этой пары вырастает дисциплина стека. Пока каждый call находит свой ret, а каждый push свой pop, стек после возврата функции выглядит ровно так же, как до вызова. Функция работает на своём участке стека, который называется стековым кадром, а %rbp часто указывает на его начало.

Виджет ниже прогоняет вызов square(5) по шагам. Жми «шаг» и следи за тремя вещами: как call кладёт адрес возврата на стек, как пролог сохраняет %rbp вызывающего и заводит новый кадр, как эпилог всё это снимает. Адреса на стеке убывают сверху вниз, вершина всегда на младшем адресе.

Обрати внимание на две пары. Первая: call кладёт адрес возврата, ret его снимает. Вторая: push %rbp в прологе сохраняет базу кадра вызывающего, pop %rbp в эпилоге её возвращает. Между ними mov %rsp, %rbp заводит новую базу, а sub $N, %rsp выделяет место под локальные. Кадр square не пересекается с кадром main, и именно поэтому вложенные вызовы, включая рекурсию, не мешают друг другу.

Шесть регистров аргументов и rax для результата

Первые шесть целочисленных аргументов (числа и указатели) едут в регистрах, в строгом порядке: %rdi, %rsi, %rdx, %rcx, %r8, %r9. Результат возвращается в %rax. Это надо помнить наизусть, потому что ты будешь узнавать этот порядок в каждом листинге.

Что происходит с седьмым и восьмым аргументом? Регистры кончились, и они едут на стек. Возьмём функцию, которая складывает восемь чисел, и посмотрим, откуда она их берёт.

export fn sum8(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64, h: u64) u64 {
    return a +% b +% c +% d +% e +% f +% g +% h;
}
# objdump -d args8.o  (собрано -O ReleaseFast -target x86_64-linux)
sum8:
  pushq %rbp
  movq  %rsp, %rbp
  addq  %rdi, %rsi          # a + b
  leaq  (%rdx,%rcx), %rax   # c + d, lea складывает без флагов
  addq  %rsi, %rax          # + (a + b)
  addq  %r8, %rax           # + e
  addq  %r9, %rax           # + f
  addq  0x10(%rbp), %rax    # + g, седьмой аргумент со стека
  addq  0x18(%rbp), %rax    # + h, восьмой аргумент со стека
  popq  %rbp
  retq

Шесть аргументов пришли в %rdi, %rsi, %rdx, %rcx, %r8, %r9, ровно по списку. А g и h функция читает по адресам 0x10(%rbp) и 0x18(%rbp). Почему такие смещения? После pushq %rbp и movq %rsp, %rbp внутри кадра сложилась известная раскладка: по адресу (%rbp) лежит сохранённый %rbp вызывающего, по 0x8(%rbp) адрес возврата, который положил call, а дальше вверх идут аргументы, что не поместились в регистры: 0x10(%rbp) это g, 0x18(%rbp) это h. Стек их туда положил вызывающий.

Теперь взгляд с другой стороны. Вот функция, которая зовёт sum8 с восемью константами, и её ассемблер показывает, как аргументы раскладываются перед вызовом.

extern fn sum8(a: u64, b: u64, c: u64, d: u64, e: u64, f: u64, g: u64, h: u64) u64;

export fn caller() u64 {
    return sum8(1, 2, 3, 4, 5, 6, 7, 8);
}
# objdump -d caller.o
caller:
  pushq %rbp
  movq  %rsp, %rbp
  movl  $0x1, %edi          # a в rdi (запись в edi обнуляет старшие 32 бита rdi)
  movl  $0x2, %esi          # b в rsi
  movl  $0x3, %edx          # c в rdx
  movl  $0x4, %ecx          # d в rcx
  movl  $0x5, %r8d          # e в r8
  movl  $0x6, %r9d          # f в r9
  pushq $0x8                # h на стек
  pushq $0x7                # g на стек, ниже по адресу, чем h
  callq sum8
  addq  $0x10, %rsp         # снять два аргумента со стека
  popq  %rbp
  retq

Шесть аргументов легли в те же шесть регистров. Седьмой и восьмой вызывающий положил на стек через pushq, причём в обратном порядке: сначала h, потом g, так что g оказался на младшем адресе, ближе к вершине. После call эти два слова видны вызываемому как 0x10(%rbp) и 0x18(%rbp). Заметь addq $0x10, %rsp сразу после возврата: вызывающий сам убирает со стека то, что туда положил, шестнадцать байт под два аргумента. Это часть контракта System V: аргументы на стеке чистит вызывающий.

Ещё одна мелочь, которая пугает новичков. Компилятор пишет movl $0x1, %edi, а не movq $0x1, %rdi, хотя аргумент восьмибайтовый. Причина в том, что любая запись в 32-битную половину регистра (%edi) обнуляет старшие 32 бита полного %rdi. Запись короче на байты, а результат тот же. Ты уже видел это правило в уроке про операнды.

Виджет ниже раскладывает произвольный список аргументов по регистрам и стеку. Добавляй целые и числа с плавающей точкой, меняй тип возврата, попробуй готовые примеры. Главное, что стоит покрутить: смешать целые и float и увидеть, что у них две отдельные очереди регистров.

Целые и указатели заполняют очередь из шести регистров, а числа с плавающей точкой отдельную очередь из восьми (%xmm0 до %xmm7). Очереди двигаются независимо: функция f(a: u64, b: f64, c: u64, d: f64) положит a в %rdi, c в %rsi, b в %xmm0, d в %xmm1. Это не выдумка виджета, это реальная раскладка, к ней вернёмся в разделе про структуры.

Кто хранит регистры: callee-saved против caller-saved

Регистров мало, а функций в цепочке вызовов много, и все хотят ими пользоваться. Чтобы не затирать чужое, System V делит регистры на две группы.

callee-saved регистры (%rbx, %rbp, %r12, %r13, %r14, %r15) вызываемая функция обязана вернуть нетронутыми. Хочешь ими пользоваться, сначала сохрани на стек, перед выходом верни. Их ещё называют сохраняемыми вызываемым.

caller-saved регистры (регистры аргументов плюс %rax, %r10, %r11) вызываемая функция вправе затирать как угодно. Если их значение понадобится после вызова, сохранять их обязан вызывающий, до call.

Правило видно в ассемблере невооружённым глазом. Возьмём функцию, которой нужно удержать один из аргументов через чужой вызов.

extern fn helper(x: u64) u64;

export fn keepAcross(a: u64, b: u64) u64 {
    const h = helper(a);
    return h +% b;
}

Аргумент b пришёл в %rsi. Но %rsi это caller-saved регистр, и call helper вправе его затереть. Значит, b надо где-то спрятать на время вызова. Смотри, куда компилятор его убрал.

# objdump -d keep.o
keepAcross:
  pushq %rbp
  movq  %rsp, %rbp
  pushq %rbx               # сохранить callee-saved rbx: мы будем им пользоваться
  pushq %rax               # выровнять стек на 16 перед call (см. ниже)
  movq  %rsi, %rbx         # b переехал в callee-saved rbx, он переживёт вызов
  callq helper             # a уже в rdi; helper вернёт результат в rax
  addq  %rbx, %rax         # h (в rax) + b (в rbx)
  addq  $0x8, %rsp         # отменить выравнивающий push
  popq  %rbx               # вернуть rbx вызывающему в целости
  popq  %rbp
  retq

Разбери по шагам. Компилятор перекладывает b из caller-saved %rsi в callee-saved %rbx, потому что %rbx по контракту переживёт call helper, а %rsi нет. Но взять %rbx просто так нельзя: он принадлежит вызывающему. Поэтому в прологе стоит pushq %rbx, а в эпилоге popq %rbx, эта пара честно возвращает регистр владельцу. Аргумент a остался в %rdi и поехал в helper без пересадки. Результат helper пришёл в %rax, а сложение %rbx с %rax дало h + b.

Выравнивание стека на 16

В том же листинге есть на первый взгляд бессмысленная инструкция: pushq %rax, значение которой никто не читает, и парная ей addq $0x8, %rsp. Это выравнивание стека.

System V требует: в момент выполнения call значение %rsp должно быть кратно 16. Выравнивание на 16 нужно, чтобы вызываемая функция могла класть на стек векторные значения SSE, которым нужен адрес, кратный 16, и вообще чтобы раскладка её кадра была предсказуемой.

Посчитаем %rsp по шагам. При входе в keepAcross сам call из вызывающего уже положил восьмибайтовый адрес возврата, поэтому %rsp кратен 16 минус 8. Дальше pushq %rbp вычитает 8, и %rsp становится кратен 16. pushq %rbx снова вычитает 8, %rsp кратен 16 минус 8. Если бы теперь пошёл call helper, контракт был бы нарушен. Поэтому компилятор добавляет ещё один pushq (значение неважно, взяли %rax), возвращая %rsp к кратному 16. После вызова addq $0x8, %rsp убирает этот фиктивный push. Пары нечётного числа push компилятор добивает до чётного именно так.

Это же правило объясняет subq $N, %rsp в прологах, где N всегда подобрано так, чтобы удержать выравнивание. Забудешь про него в рукописном ассемблере, и первый же call в функцию со movaps упадёт.

Локальные переменные: в регистре или на стеке

Не каждая локальная переменная попадает на стек. Если компилятору хватает регистров и у переменной никогда не берут адрес, она живёт в регистре и на стеке её нет вовсе. На стек переменная переезжает по одной из трёх причин: у неё берут адрес (&x), она слишком большая для регистра (массив, большая структура), или регистры закончились и что-то надо временно вытеснить в память. Последнее называют сбросом в память.

Ты уже видел это в листинге keepAcross: значение b жило в регистре %rbx, на стек не попало ни на миг. А в рекурсии ниже увидишь обратное: аргумент придётся сбросить на стек, потому что рекурсивный call затрёт регистр, в котором он лежал.

Практический вывод. Когда в Zig ты пишешь var x: u32 = 5; и нигде не берёшь &x, скорее всего никакого «места в памяти» под x не будет: оптимизатор оставит её в регистре. Как только появляется &x или ты кладёшь адрес в срез, переменная обязана иметь адрес, и компилятор выделяет ей ячейку кадра. Это прямая цена операции взятия адреса, о которой полезно помнить.

Рекурсия это обычный кадр

Про рекурсию любят говорить как про что-то особенное. На уровне машины ничего особенного в ней нет: рекурсивный вызов это такой же call, а каждый заход в функцию получает свой кадр. Десять вложенных вызовов это десять кадров, наставленных друг на друга на стеке, и они не мешают друг другу ровно потому, что у каждого свой участок.

Возьмём факториал. Чтобы кадр и вызов были видны, соберём его в отладочном режиме, где компилятор не разворачивает и не сворачивает код.

export fn fact(n: u64) u64 {
    if (n <= 1) return 1;
    return n *% fact(n -% 1);
}
# objdump -d fact.o  (собрано без -O, отладочная раскладка)
fact:
  pushq %rbp
  movq  %rsp, %rbp
  subq  $0x20, %rsp        # завести кадр
  movq  %rdi, (%rsp)       # сбросить n в кадр: rdi не переживёт call
  cmpq  $0x1, %rdi
  ja    .Lrec              # if (n > 1) в рекурсию
  movl  $0x1, %eax         # база: return 1
  jmp   .Lepi
.Lrec:
  movq  %rdi, %rax
  subq  $0x1, %rax         # n - 1
  movq  %rax, 0x8(%rsp)
  movq  %rdi, 0x10(%rsp)   # сохранить n на время вызова
  movq  0x8(%rsp), %rdi    # аргумент рекурсии = n - 1
  callq fact               # тот же call, что и любой другой
  imulq 0x10(%rsp), %rax   # n * fact(n - 1), n взяли из кадра
.Lepi:
  movq  %rbp, %rsp         # эпилог: снять кадр
  popq  %rbp
  retq

Ничего нового. Пролог заводит кадр (subq $0x20, %rsp), тело проверяет базу (cmpq $0x1), а рекурсивный вызов это callq fact, буквально та же инструкция, которой caller звал sum8. Один момент стоит внимания: перед call компилятор сбрасывает n в кадр по адресу 0x10(%rsp), а после вызова читает его оттуда для imulq. Почему? Потому что n лежал в %rdi, а %rdi это caller-saved регистр аргументов, и callq fact его затрёт (внутренний вызов положит туда свой n - 1). Значение, которое нужно после вызова, обязано пережить его либо в callee-saved регистре, либо в кадре. Здесь компилятор выбрал кадр.

Отсюда и цена рекурсии: каждый незавершённый вызов держит свой кадр на стеке, и стек не бесконечен. Тысячи вложенных вызовов упрутся в его предел, и программа получит переполнение стека. Это не абстракция, а тот же счётчик %rsp, который уезжает вниз на каждый пролог.

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

export fn rfun(x: u64) u64 {
    if (x == 0) return 0;
    return x +% rfun(x >> 2);
}
# objdump -d rfun.o  (собрано -O ReleaseSafe)
rfun:
  pushq %rbp
  movq  %rsp, %rbp
  xorl  %eax, %eax         # acc = 0
  testq %rdi, %rdi
  je    .Ldone             # if (x == 0)
  movq  %rdi, %rcx         # затравка: rcx = x
  nop
.Lloop:
  shrq  $0x2, %rcx         # rcx >>= 2
  addq  %rdi, %rax         # acc += x
  cmpq  $0x4, %rdi
  movq  %rcx, %rdi         # x = rcx
  jae   .Lloop             # петля вместо call
.Ldone:
  popq  %rbp
  retq

Ни одного call. Оптимизатор заметил, что сложение можно накапливать в %rax и переписал рекурсию циклом. Это законно, пока видимое поведение то же, и связано с тем, о чём был урок про циклы и switch: цикл и хвостовая рекурсия это две записи одного и того же графа переходов. Отладочная сборка честно рекурсивна, оптимизированная свёрнута в петлю, а результат одинаковый.

Реверс: восстановить функцию по её кадру

Настоящее умение читать ассемблер это восстановить исходник по листингу, как в упражнениях книги с 3.5 по 3.35. Возьмём дизассемблер рекурсивной функции и разберём его построчно, а потом ты повторишь тот же приём в код-задаче.

# objdump -d mystery.o  (отладочная раскладка)
mystery:
  pushq %rbp
  movq  %rsp, %rbp
  subq  $0x20, %rsp
  movq  %rdi, (%rsp)       # сохранить аргумент x
  cmpq  $0x1, %rdi
  ja    .Lrec             # if (x > 1) в рекурсию
  movq  %rdi, %rax         # база: return x
  jmp   .Lepi
.Lrec:
  movq  %rdi, %rax
  andq  $0x1, %rax         # x & 1, посчитали до вызова
  shrq  %rdi               # x >> 1 (сдвиг на один бит)
  movq  %rax, 0x8(%rsp)    # сохранить (x & 1) на время вызова
  movq  %rdi, 0x10(%rsp)
  movq  0x10(%rsp), %rdi   # аргумент рекурсии = x >> 1
  callq mystery
  addq  %rax, 0x8(%rsp)    # (x & 1) + mystery(x >> 1)
  movq  0x8(%rsp), %rax
.Lepi:
  movq  %rbp, %rsp
  popq  %rbp
  retq

Читаем сверху вниз. Функция сохраняет x, сравнивает с единицей: при x <= 1 возвращает сам x (ветка сразу после cmpq). Иначе берёт младший бит x (andq $0x1), сдвигает x вправо на бит (shrq %rdi это сдвиг на один), зовёт себя от сдвинутого значения, а результат вызова складывает с сохранённым младшим битом. То есть функция считает число единичных битов в x: младший бит плюс число единиц в остатке. На Zig это

fn mystery(x: u64) u64 {
    if (x <= 1) return x;
    return (x & 1) +% mystery(x >> 1);
}

Приём всегда один: найди базу (условие выхода без рекурсии), найди аргумент рекурсивного вызова (что лежит в %rdi перед call), найди, как результат вызова комбинируется с сохранённым значением после возврата. Три вопроса, три строчки исходника.

Структуры по значению: регистры или скрытый указатель

Аргумент не обязан быть одним числом. Что происходит, когда в функцию передают структуру целиком, по значению? Ответ System V зависит от размера, и граница проходит по 16 байтам.

Чтобы у структуры была предсказуемая раскладка в памяти, помечаем её extern struct: тогда поля лежат подряд, в порядке объявления, как в C. Обычный struct в Zig компилятор волен переставлять и не гарантирует раскладку, поэтому через границу C-совместимого вызова его передавать нельзя (компилятор так и скажет: у структуры с автоматической раскладкой нет гарантированного представления в памяти). Возьмём две структуры: одну на 16 байт, другую на 24.

const Small = extern struct { a: u64, b: u64 };
const Big = extern struct { a: u64, b: u64, c: u64 };

export fn small_sum(s: Small) u64 {
    return s.a +% s.b;
}
export fn big_sum(s: Big) u64 {
    return s.a +% s.b +% s.c;
}
# objdump -d structs.o
small_sum:
  pushq %rbp
  movq  %rsp, %rbp
  leaq  (%rdi,%rsi), %rax   # s.a в rdi, s.b в rsi: два поля в двух регистрах
  popq  %rbp
  retq

big_sum:
  pushq %rbp
  movq  %rsp, %rbp
  movq  0x18(%rbp), %rax    # s.b
  addq  0x10(%rbp), %rax    # + s.a
  addq  0x20(%rbp), %rax    # + s.c
  popq  %rbp
  retq

Small это 16 байт, и функция получила её в двух регистрах: s.a в %rdi, s.b в %rsi. Никакой памяти, структуру разложили по регистрам как два отдельных аргумента. Big это 24 байта, больше 16, и такую структуру System V передаёт через стек: вызывающий копирует её на стек, а big_sum читает поля по 0x10(%rbp), 0x18(%rbp), 0x20(%rbp), ровно там, где были бы аргументы, не влезшие в регистры.

С возвратом та же граница, но большую структуру возвращают иначе: через скрытый указатель.

export fn make_small(x: u64) Small {
    return .{ .a = x, .b = x +% 1 };
}
export fn make_big(x: u64) Big {
    return .{ .a = x, .b = x +% 1, .c = x +% 2 };
}
# objdump -d structs.o
make_small:
  pushq %rbp
  movq  %rsp, %rbp
  movq  %rdi, %rax          # вернуть a в rax
  leaq  0x1(%rdi), %rdx     # вернуть b в rdx
  popq  %rbp
  retq

make_big:
  pushq %rbp
  movq  %rsp, %rbp
  movq  %rdi, %rax          # rdi это адрес места под результат (скрытый первый аргумент)
  movq  %rsi, (%rdi)        # a  (настоящий x приехал в rsi, сдвинулся на один регистр)
  leaq  0x1(%rsi), %rcx
  movq  %rcx, 0x8(%rdi)     # b
  addq  $0x2, %rsi
  movq  %rsi, 0x10(%rdi)    # c
  popq  %rbp
  retq

make_small возвращает 16 байт в паре регистров %rax и %rdx. А make_big возвращает 24 байта через скрытый указатель: вызывающий заранее выделяет место под результат и кладёт его адрес в %rdi как невидимый нулевой аргумент. Из-за этого настоящий x сдвинулся с %rdi на %rsi, а функция пишет три поля по этому адресу и возвращает сам адрес в %rax. Это домашнее задание книги про структуры по значению: увидеть, где проходит граница между регистрами и памятью, и что возврат большой структуры это лишний указатель и лишнее копирование.

Правило целиком: аргумент или возврат до 16 байт едет в регистрах (целые поля в целых регистрах, поля с плавающей точкой в %xmm), больше 16 байт аргумент едет на стеке, а возврат через скрытый указатель в %rdi. Отсюда практический совет системщика: маленькие структуры можно спокойно передавать по значению, они бесплатны, а большие лучше передавать по указателю осознанно, чтобы не платить за копию.

Шаг проекта: zt учится вызовам и именам

Весь этот урок держится на одной инструкции, call, а твой дизассемблер её до сих пор не знает. Косвенный callq *%rax он читает с прошлого урока, но обычный вызов по смещению, байт e8, ему незнаком. Вот функция ztSumSquares из фикстуры этого шага (две квадратичные суммы через вспомогательную square) в выводе zt после урока 13:

$ ./zig-out/bin/zt disasm fixtures/step_14.o | sed -n '/ 4f:/,/ 71:/p'
      4f: 55                           	pushq	%rbp
      50: 48 89 e5                     	movq	%rsp, %rbp
      53: 41 56                        	pushq	%r14
      55: 53                           	pushq	%rbx
      56: 48 89 f3                     	movq	%rsi, %rbx
      59: e8                           	(bad)
      5a: 13 00                        	adcl	(%rax), %eax
      5c: 00 00                        	addb	%al, (%rax)
      5e: 49 89 c6                     	movq	%rax, %r14
      61: 48 89 df                     	movq	%rbx, %rdi
      64: e8                           	(bad)
      65: 08 00                        	orb	%al, (%rax)
      67: 00 00                        	addb	%al, (%rax)
      69: 4c 01 f0                     	addq	%r14, %rax
      6c: 5b                           	popq	%rbx
      6d: 41 5e                        	popq	%r14
      6f: 5d                           	popq	%rbp
      70: c3                           	retq
      71: 55                           	pushq	%rbp

Картина знакомая по прошлым шагам: неизвестный байт, за ним смещение, прочитанное как арифметика. Но здесь видна и вторая беда, и её одним новым опкодом не вылечить. Даже когда call разберётся, строка выйдет callq 0x71, и из неё непонятно, кого зовут. objdump печатает callq 0x71 <ztSumSquares+0x22>, и с этой подписью листинг читается как текст. Шаг поэтому из двух частей: новый опкод и подписи адресов.

call по смещению

Кодирование call ты уже знаешь, не зная об этом. Это e8 и четыре байта смещения со знаком, отсчитанного от конца инструкции, ровно как у jmp с опкодом e9. Разница только в том, что процессор перед прыжком кладёт на стек адрес следующей инструкции, но дизассемблеру до этого дела нет. В src/x86/ops_branch.zig рядом с jump:

/// Вызов по смещению, опкод 0xE8. Цель считается так же, как у `jmp`,
/// а адрес следующей инструкции процессор кладёт на стек как адрес возврата.
pub fn call(cursor: *Cursor) ?Instruction {
    const displacement = cursor.readSigned(4) orelse return null;
    return cursor.one("call", 'q', .{ .target = cursor.relativeTarget(displacement) });
}

И строка таблицы в src/x86/decoder.zig, перед безусловными переходами:

        // Вызов и безусловные переходы по смещению.
        0xe8 => branch.call(cursor),
        0xe9 => branch.jump(cursor, 4),
        0xeb => branch.jump(cursor, 1),

Суффикс q у вызова стоит по той же причине, что у push: на стек уходит восемь байтов. Короткой формы с однобайтным смещением у call нет, и это разумно: вызываемая функция почти никогда не лежит в пределах 127 байтов. А ret твой декодер знает с урока 9: c3 без операндов, снимает адрес со стека.

Откуда взять имена

Имя функции в машинном коде не записано. Оно лежит в таблице символов объектника, отдельно от кода, и zt читать её пока не умеет: разбор ELF у него сводится к поиску секции .text по имени. Полноценный читатель ELF появится в уроке про объектные файлы. До тех пор сделаем честный обходной путь: таблицу прочитает nm, а zt возьмёт его вывод готовым.

$ nm fixtures/step_14.o
000000000000001f T ztFib
                 U ztLog
0000000000000000 T ztLogTwice
000000000000004f T ztSumSquares

Каждая строка это адрес, буква и имя. Большая T значит «код, видный снаружи», маленькая t значит «код, только для этого файла», U значит «имя нужно, но определено где-то ещё», и адреса у такого имени нет. Функции square в списке нет вовсе. Фикстура собрана с -O ReleaseSmall, и в этом режиме Zig вычищает имена, которые не видны снаружи файла. Для отладчика и дизассемблера square стала безымянным куском ztSumSquares, и её адрес objdump подписывает от ближайшего имени слева: 0x71 это ztSumSquares плюс 0x22.

Отсюда правило подписи. Для адреса цели ищем имя с наибольшим адресом, который не больше цели. Совпал точно, пишем <имя>. Не совпал, пишем <имя+0xсмещение>.

Модуль подписей

Всё, что касается имён, живёт в новом файле src/labels.zig:

//! Подписи для дизассемблера: какому адресу какое имя соответствует.
//!
//! Таблицу символов дизассемблер пока не читает сам: её передают снаружи
//! готовым выводом `nm`. Подпись цели это ближайшее имя не правее адреса,
//! плюс расстояние до него: `<ztSumSquares+0x22>`.

const std = @import("std");

pub const Label = struct {
    /// Номер секции с кодом. Пока секция одна, и номер всегда 0.
    section: u32,
    address: u64,
    name: []const u8,
    /// Чем больше, тем охотнее берём имя, когда на одном адресе их несколько:
    /// глобальное важнее локального.
    rank: u8,

    fn lessThan(_: void, a: Label, b: Label) bool {
        if (a.section != b.section) return a.section < b.section;
        if (a.address != b.address) return a.address < b.address;
        return a.rank > b.rank;
    }
};

pub const Found = struct { name: []const u8, offset: u64 };

/// Имя функции и её адрес, как их печатает `nm`: `000000000000001f T ztFib`.
pub const Symbol = struct {
    name: []const u8,
    address: u64,
    /// Большая буква у `nm`: имя видно из других файлов.
    global: bool,
};

/// Разбирает вывод `nm`. Берёт только код, буквы `T` и `t`: у неопределённых
/// имён адреса нет, а данные дизассемблеру не нужны. Строки указывают в `text`.
pub fn parseNm(gpa: std.mem.Allocator, text: []const u8) ![]Symbol {
    var list: std.ArrayList(Symbol) = .empty;
    errdefer list.deinit(gpa);

    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        // У неопределённого имени колонка адреса пустая, и полей будет два.
        var fields = std.mem.tokenizeScalar(u8, line, ' ');
        const address = fields.next() orelse continue;
        const letter = fields.next() orelse continue;
        const name = fields.next() orelse continue;
        if (letter.len != 1 or std.ascii.toLower(letter[0]) != 't') continue;
        try list.append(gpa, .{
            .name = name,
            .address = try std.fmt.parseInt(u64, address, 16),
            .global = letter[0] == 'T',
        });
    }
    return list.toOwnedSlice(gpa);
}

pub const Labels = struct {
    arena: std.heap.ArenaAllocator,
    /// Отсортированы по секции, адресу и убыванию ранга.
    code: []const Label,

    /// Подписи из готового списка. Весь код считается одной секцией с
    /// номером 0: у куска байтов других секций и нет.
    pub fn fromSymbols(gpa: std.mem.Allocator, symbols: []const Symbol) !Labels {
        var arena: std.heap.ArenaAllocator = .init(gpa);
        errdefer arena.deinit();

        const list = try arena.allocator().alloc(Label, symbols.len);
        for (symbols, list) |symbol, *label| label.* = .{
            .section = 0,
            .address = symbol.address,
            .name = symbol.name,
            .rank = if (symbol.global) 2 else 1,
        };
        std.mem.sort(Label, list, {}, Label.lessThan);
        return .{ .arena = arena, .code = list };
    }

    pub fn deinit(labels: *Labels) void {
        labels.arena.deinit();
    }

    /// Имя, стоящее ровно на этом адресе: с него начинается функция.
    pub fn exact(labels: Labels, section: u32, address: u64) ?[]const u8 {
        for (labels.code) |label| {
            if (label.section == section and label.address == address) return label.name;
        }
        return null;
    }

    /// Адрес следующей подписи в секции после данного адреса.
    pub fn nextAfter(labels: Labels, section: u32, address: u64) ?u64 {
        for (labels.code) |label| {
            if (label.section == section and label.address > address) return label.address;
        }
        return null;
    }

    /// Ближайшее имя не правее адреса и расстояние до него.
    pub fn nearest(labels: Labels, section: u32, address: u64) ?Found {
        var best: ?Found = null;
        for (labels.code) |label| {
            if (label.section != section or label.address > address) continue;
            // При равных адресах первым в списке стоит имя с большим рангом.
            if (best != null and best.?.offset == address - label.address) continue;
            best = .{ .name = label.name, .offset = address - label.address };
        }
        return best;
    }
};

test "из вывода nm берутся только функции" {
    const gpa = std.testing.allocator;
    const symbols = try parseNm(gpa,
        \\000000000000001f T ztFib
        \\                 U ztLog
        \\0000000000000000 t helper
        \\0000000000000010 D table
        \\
    );
    defer gpa.free(symbols);

    try std.testing.expectEqual(@as(usize, 2), symbols.len);
    try std.testing.expectEqualStrings("ztFib", symbols[0].name);
    try std.testing.expectEqual(@as(u64, 0x1f), symbols[0].address);
    try std.testing.expect(symbols[0].global);
    try std.testing.expect(!symbols[1].global);
}

test "цель без своего имени подписывается от ближайшего слева" {
    var labels = try Labels.fromSymbols(std.testing.allocator, &.{
        .{ .name = "b", .address = 0x40, .global = true },
        .{ .name = "a", .address = 0x0, .global = true },
    });
    defer labels.deinit();

    const inside = labels.nearest(0, 0x5a).?;
    try std.testing.expectEqualStrings("b", inside.name);
    try std.testing.expectEqual(@as(u64, 0x1a), inside.offset);
    try std.testing.expectEqualStrings("a", labels.exact(0, 0).?);
}

Несколько решений в этом файле сделаны на вырост, и о них стоит сказать сразу.

  1. Строки имён не копируются. parseNm отдаёт срезы исходного текста, поэтому текст должен жить не меньше списка. В main.zig так и выходит: файл читается один раз и не освобождается до конца программы.
  2. Поле section сейчас всегда ноль. Оно здесь потому, что в объектнике бывает несколько секций с кодом, и у каждой адреса начинаются с нуля: адрес 0x10 в одной и 0x10 в другой это разные места. Когда zt научится читать ELF сам, номер секции станет настоящим, и менять придётся только то место, где подписи собираются.
  3. Ранг решает спор двух имён на одном адресе. В сборке -O ReleaseFast Zig оставляет у экспортированной функции два имени, локальное step_14.ztFib и глобальное ztFib, и печатать хочется то, по которому функцию зовут снаружи. Сортировка ставит на одном адресе имя с большим рангом первым, а nearest при равном расстоянии оставляет первое найденное.
  4. Поиск линейный. Имён в объектнике десятки, строк в листинге сотни, и сортированный массив с двоичным поиском здесь ничего не даст, кроме лишних строк. Если когда-нибудь понадобится дизассемблировать мегабайты кода, это первое место, которое стоит переписать.

Строка с подписью

В src/disasm.zig строка дизассемблера получает подписи. У printLine два новых параметра: подписи, которых может и не быть, и номер секции.

/// Одна строка дизассемблера. `section` это номер секции, в которой лежит
/// инструкция: цель перехода ищем в той же секции.
pub fn printLine(
    out: *std.Io.Writer,
    instruction: Instruction,
    labels: ?*const Labels,
    section: u32,
) !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);

    if (labels) |known| try printTargetLabel(out, instruction, known, section);

    if (formatter.hasComment(instruction)) {
        try out.writeAll("  ");
        try formatter.formatComment(out, instruction);
    }
    try out.writeByte('\n');
}

/// Подпись цели перехода или вызова: `<ztFib>`, `<ztSumSquares+0x22>`.
fn printTargetLabel(out: *std.Io.Writer, instruction: Instruction, labels: *const Labels, section: u32) !void {
    for (instruction.operandSlice()) |operand| {
        const target = switch (operand) {
            .target => |address| address,
            else => continue,
        };
        const found = labels.nearest(section, target) orelse return;
        if (found.offset == 0) {
            try out.print(" <{s}>", .{found.name});
        } else {
            try out.print(" <{s}+0x{x}>", .{ found.name, found.offset });
        }
    }
}

Подпись получают только операнды вида .target, то есть цели jmp, jcc и call, у которых адрес посчитан декодером. У jmpq *%rax цели в инструкции нет, и подписывать нечего.

Старая disassemble передаёт printLine пустые подписи: try printLine(out, instruction, null, 0);. А рядом появляются две новые функции:

const labels_mod = @import("labels.zig");

pub const Labels = labels_mod.Labels;

/// Разбирает кусок кода и подписывает его именами из готового списка,
/// например из вывода `nm`. Весь кусок считается одной секцией.
pub fn disassembleNamed(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    code: []const u8,
    base: u64,
    symbols: []const labels_mod.Symbol,
) !void {
    var labels = try Labels.fromSymbols(gpa, symbols);
    defer labels.deinit();
    try disassembleSection(out, code, base, &labels, 0);
}

/// Одна секция с подписями: заголовок перед каждой функцией, имя у каждой
/// цели перехода и вызова.
fn disassembleSection(
    out: *std.Io.Writer,
    code: []const u8,
    base: u64,
    labels: *const Labels,
    section: u32,
) !void {
    var offset: usize = 0;
    while (offset < code.len) {
        // Инструкция не может перешагнуть начало следующей функции. Если
        // перед функцией лежит мусор или добивка, разбор сбился бы и съел
        // её первые байты, поэтому декодеру показываем байты только до
        // ближайшей подписи. Так же поступает objdump.
        const address = base + offset;
        const limit = if (labels.nextAfter(section, address)) |next| @min(code.len, next - base) else code.len;
        const instruction = decoder.decode(code[offset..@intCast(limit)], address);
        if (labels.exact(section, instruction.address)) |name| {
            try out.print("\n{x:0>16} <{s}>:\n", .{ instruction.address, name });
        }
        try printLine(out, instruction, labels, section);
        offset += instruction.length();
    }
}

Перед первой инструкцией каждой функции печатается заголовок в формате objdump: пустая строка, адрес на шестнадцать цифр, имя в угловых скобках. А переменная limit отвечает на вопрос, который без имён даже не возникал. Между функциями лежит добивка до выравнивания, и если в ней окажется байт, похожий на начало длинной инструкции, декодер прочитает эту «инструкцию» через границу и съест пролог следующей функции. Начало функции это единственное место в секции, где граница инструкции известна точно, поэтому дальше неё декодеру байтов не показываем.

Флаг --syms

Осталось передать таблицу из командной строки. В cmdDisasm из src/main.zig новый флаг и новая ветка перед обычным разбором:

        } else if (std.mem.startsWith(u8, arg, "--syms=")) {
            syms_path = arg["--syms=".len..];
    // Таблица символов снаружи: вывод nm, снятый с того же файла.
    if (syms_path) |syms| {
        const text = try std.Io.Dir.cwd().readFileAlloc(init.io, syms, gpa, file_limit);
        const symbols = try zt.labels.parseNm(gpa, text);
        const code: zt.elf.Section = if (raw)
            .{ .name = "", .address = base, .data = bytes }
        else
            try zt.elf.findSection(bytes, ".text");
        try zt.disasm.disassembleNamed(gpa, out, code.data, code.address, symbols);
        return;
    }

Флаг работает и с --raw: для сырого куска кода nm не поможет, но файл с подписями можно написать руками, в том же формате. В строку usage добавь [--syms=файл] и пояснение к флагу, а в src/root.zig строку pub const labels = @import("labels.zig");, чтобы тесты дотянулись до модуля.

Фикстура и тесты шага

Три функции фикстуры покрывают три вида подписей: вызов безымянной функции, рекурсию и вызов функции из другого файла.

//! Фикстура шага 14: call, ret и подписи целей по таблице символов.
//!
//! Собирается так:
//!   zig build-obj fixtures/step_14.zig -O ReleaseSmall \
//!       -target x86_64-linux-musl -femit-bin=fixtures/step_14.o
//!   objdump -d fixtures/step_14.o > fixtures/step_14.objdump.txt
//!   nm fixtures/step_14.o > fixtures/step_14.nm.txt
//!
//! ReleaseSmall выбран нарочно: в этом режиме Zig вычищает имена функций,
//! которые не видны снаружи, и цель вызова `square` подписывается как
//! смещение от ближайшего имени, `<ztSumSquares+0x22>`.

/// Функция, которую нельзя встроить: иначе от вызова ничего не останется.
noinline fn square(x: u64) u64 {
    return x *% x;
}

/// Два вызова одной функции. Значение, которое нужно после вызова, живёт
/// в регистре, который вызываемый обязан сохранить.
export fn ztSumSquares(a: u64, b: u64) u64 {
    return square(a) +% square(b);
}

/// Число Фибоначчи в лоб. Один из двух рекурсивных вызовов оптимизатор
/// превращает в цикл, второй остаётся настоящим `call` на начало функции.
export fn ztFib(n: u64) u64 {
    if (n < 2) return n;
    return ztFib(n - 1) +% ztFib(n - 2);
}

/// Функция из другого объектника. Её адрес узнает только компоновщик,
/// поэтому на месте смещения в `call` пока нули.
extern fn ztLog(value: u64) void;

export fn ztLogTwice(value: u64) void {
    ztLog(value);
    ztLog(value +% 1);
}

У этого шага три файла эталона вместо двух, и fixtures/root.zig получает вывод nm отдельной константой:

/// У шага 14 рядом лежит ещё вывод `nm`: это таблица символов, которую
/// дизассемблер получает снаружи, пока не умеет читать её из ELF сам.
pub const step_14 = Fixture{
    .object = @embedFile("step_14.o"),
    .objdump = @embedFile("step_14.objdump.txt"),
};
pub const step_14_nm = @embedFile("step_14.nm.txt");

Сверка с подписями строже прежней, поэтому в tests/support.zig появляется вторая проверка. Старая expectMatchesObjdump отрезает у эталона всё после < и #, новая сравнивает строки целиком, вместе с заголовками функций, и убирает только комментарии:

/// Проверка шага 14 и дальше: вывод с подписями, которые взяты из вывода
/// `nm`, совпадает с objdump вместе с заголовками функций и именами целей.
/// Убираем шапку эталона до названия секции и комментарии после решётки.
pub fn expectMatchesObjdumpNamed(fixture: Fixture, nm: []const u8) !void {
    const gpa = std.testing.allocator;

    const symbols = try zt.labels.parseNm(gpa, nm);
    defer gpa.free(symbols);
    const text = try zt.elf.findSection(fixture.object, ".text");

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try zt.disasm.disassembleNamed(gpa, &out.writer, text.data, text.address, symbols);

    const header = "Disassembly of section .text:\n";
    const body_start = (std.mem.indexOf(u8, fixture.objdump, header) orelse return error.NoText) + header.len;
    const expected = try withoutComments(gpa, fixture.objdump[body_start..]);
    defer gpa.free(expected);
    const actual = try withoutComments(gpa, out.written());
    defer gpa.free(actual);
    try std.testing.expectEqualStrings(expected, actual);
}

/// Текст без комментариев после решётки и без хвостовых пустых строк.
pub fn withoutComments(gpa: std.mem.Allocator, text: []const u8) ![]u8 {
    var out: std.Io.Writer.Allocating = .init(gpa);
    errdefer out.deinit();
    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        const end = std.mem.indexOfScalar(u8, line, '#') orelse line.len;
        try out.writer.writeAll(std.mem.trimEnd(u8, line[0..end], " \t"));
        try out.writer.writeByte('\n');
    }
    // Хвостовые пустые строки у нас и у objdump разные, на суть они не влияют.
    const trimmed = std.mem.trimEnd(u8, out.written(), "\n").len;
    out.shrinkRetainingCapacity(trimmed);
    return out.toOwnedSlice();
}

Тесты в tests/step_14.zig:

//! Шаг 14: `call`, `ret` и подписи целей по таблице символов.
//!
//! Цель вызова записана так же, как цель `jmp`: смещением от конца
//! инструкции. Новое в шаге то, что голый адрес получает имя. Таблицу
//! символов дизассемблер пока не читает сам, её передают снаружи выводом
//! `nm`, а подпись ищется как ближайшее имя не правее адреса.

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

test "вывод совпадает с objdump построчно" {
    try support.expectMatchesObjdump(fixtures.step_14);
}

test "с таблицей из nm совпадают и подписи" {
    try support.expectMatchesObjdumpNamed(fixtures.step_14, fixtures.step_14_nm);
}

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

test "цель call считается от конца инструкции, как у jmp" {
    // e8 e4 ff ff ff по адресу 0x36: конец 0x3b, смещение -0x1c, цель 0x1f.
    const back = decode(&.{ 0xe8, 0xe4, 0xff, 0xff, 0xff }, 0x36);
    try std.testing.expectEqual(@as(u64, 0x1f), back.operandSlice()[0].target);
    try std.testing.expectEqualStrings("call", back.mnemonic);
    try std.testing.expectEqual(@as(?u8, 'q'), back.suffix);

    // Нули на месте смещения: цель это следующая инструкция. Так в
    // объектнике выглядит вызов функции, которую найдёт компоновщик.
    const external = decode(&.{ 0xe8, 0x00, 0x00, 0x00, 0x00 }, 0x9);
    try std.testing.expectEqual(@as(u64, 0xe), external.operandSlice()[0].target);
}

test "ret снимает адрес возврата и операндов не имеет" {
    const ret = decode(&.{0xc3}, 0);
    try std.testing.expectEqualStrings("ret", ret.mnemonic);
    try std.testing.expectEqual(@as(usize, 0), ret.operandSlice().len);
}

test "подпись: точное имя или ближайшее слева плюс смещение" {
    const gpa = std.testing.allocator;
    const symbols = try zt.labels.parseNm(gpa, fixtures.step_14_nm);
    defer gpa.free(symbols);

    var labels = try zt.labels.Labels.fromSymbols(gpa, symbols);
    defer labels.deinit();

    // Рекурсивный вызов попадает ровно в начало функции.
    try std.testing.expectEqualStrings("ztFib", labels.exact(0, 0x1f).?);
    // У square имени нет: ReleaseSmall его вычистил. Подпись берётся от
    // ближайшего имени слева, и адрес 0x71 это ztSumSquares плюс 0x22.
    const helper = labels.nearest(0, 0x71).?;
    try std.testing.expectEqualStrings("ztSumSquares", helper.name);
    try std.testing.expectEqual(@as(u64, 0x22), helper.offset);
}

test "строка с подписью цели" {
    const gpa = std.testing.allocator;
    var labels = try zt.labels.Labels.fromSymbols(gpa, &.{
        .{ .name = "ztFib", .address = 0x1f, .global = true },
    });
    defer labels.deinit();

    var buffer: [128]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    const call = decode(&.{ 0xe8, 0xe4, 0xff, 0xff, 0xff }, 0x36);
    try zt.disasm.printLine(&out, call, &labels, 0);
    try std.testing.expectEqualStrings(
        "      36: e8 e4 ff ff ff               \tcallq\t0x1f <ztFib>\n",
        out.buffered(),
    );
}

И 14 в project_steps в build.zig.

Прогон

$ zig build test -Dstep=14 --summary all
Build Summary: 3/3 steps succeeded; 8/8 tests passed
test success
+- run test 8 pass (8 total) 17ms MaxRSS:3M

А теперь главное: вывод с подписями против objdump. Первые четыре строки эталона это имя файла и название секции, их отрезаем:

$ nm fixtures/step_14.o > step_14.nm
$ ./zig-out/bin/zt disasm --syms=step_14.nm fixtures/step_14.o > zt14.txt
$ objdump -d fixtures/step_14.o | tail -n +5 > od14.txt
$ diff od14.txt zt14.txt && echo совпало
совпало

Впервые за блок diff пуст целиком, вместе с заголовками функций и подписями. Вот что вышло:

$ ./zig-out/bin/zt disasm --syms=step_14.nm fixtures/step_14.o

0000000000000000 <ztLogTwice>:
       0: 55                           	pushq	%rbp
       1: 48 89 e5                     	movq	%rsp, %rbp
       4: 53                           	pushq	%rbx
       5: 50                           	pushq	%rax
       6: 48 89 fb                     	movq	%rdi, %rbx
       9: e8 00 00 00 00               	callq	0xe <ztLogTwice+0xe>
       e: 48 ff c3                     	incq	%rbx
      11: 48 89 df                     	movq	%rbx, %rdi
      14: 48 83 c4 08                  	addq	$0x8, %rsp
      18: 5b                           	popq	%rbx
      19: 5d                           	popq	%rbp
      1a: e9 00 00 00 00               	jmp	0x1f <ztFib>

000000000000001f <ztFib>:
      1f: 55                           	pushq	%rbp
      20: 48 89 e5                     	movq	%rsp, %rbp
      23: 41 56                        	pushq	%r14
      25: 53                           	pushq	%rbx
      26: 48 89 fb                     	movq	%rdi, %rbx
      29: 45 31 f6                     	xorl	%r14d, %r14d
      2c: 48 83 fb 01                  	cmpq	$0x1, %rbx
      30: 76 12                        	jbe	0x44 <ztFib+0x25>
      32: 48 8d 7b ff                  	leaq	-0x1(%rbx), %rdi
      36: e8 e4 ff ff ff               	callq	0x1f <ztFib>
      3b: 49 01 c6                     	addq	%rax, %r14
      3e: 48 83 c3 fe                  	addq	$-0x2, %rbx
      42: eb e8                        	jmp	0x2c <ztFib+0xd>
      44: 4c 01 f3                     	addq	%r14, %rbx
      47: 48 89 d8                     	movq	%rbx, %rax
      4a: 5b                           	popq	%rbx
      4b: 41 5e                        	popq	%r14
      4d: 5d                           	popq	%rbp
      4e: c3                           	retq

000000000000004f <ztSumSquares>:
      4f: 55                           	pushq	%rbp
      50: 48 89 e5                     	movq	%rsp, %rbp
      53: 41 56                        	pushq	%r14
      55: 53                           	pushq	%rbx
      56: 48 89 f3                     	movq	%rsi, %rbx
      59: e8 13 00 00 00               	callq	0x71 <ztSumSquares+0x22>
      5e: 49 89 c6                     	movq	%rax, %r14
      61: 48 89 df                     	movq	%rbx, %rdi
      64: e8 08 00 00 00               	callq	0x71 <ztSumSquares+0x22>
      69: 4c 01 f0                     	addq	%r14, %rax
      6c: 5b                           	popq	%rbx
      6d: 41 5e                        	popq	%r14
      6f: 5d                           	popq	%rbp
      70: c3                           	retq
      71: 55                           	pushq	%rbp
      72: 48 89 e5                     	movq	%rsp, %rbp
      75: 48 89 f8                     	movq	%rdi, %rax
      78: 48 0f af c7                  	imulq	%rdi, %rax
      7c: 5d                           	popq	%rbp
      7d: c3                           	retq

Прочитай его, держа в голове правила урока, и в каждой функции найдётся своё.

ztSumSquares держит b через вызов в %rbx, а результат первого вызова в %r14. Оба регистра callee-saved: square обязана их не портить, поэтому значения переживут вызов. Но для своего вызывающего ztSumSquares тоже вызываемая, и те же два регистра она сама сохраняет в прологе парой push и восстанавливает в эпилоге. Сама square видна сразу за retq на 0x71: имени нет, но по подписи ясно, что это отдельная функция со своим прологом, а не хвост ztSumSquares.

ztFib показывает, что оптимизатор сделал с рекурсией. Вызов fib(n - 1) остался настоящим callq 0x1f <ztFib>, на самого себя. А fib(n - 2) превратился в цикл: %r14 копит сумму, %rbx уменьшается на два, и jmp на 0x2c возвращает к проверке. Та же история, что с rfun выше: после второго вызова оставалось только сложение, и компилятор свернул его в петлю с накопителем.

ztLogTwice самая поучительная, потому что две её подписи врут. callq 0xe <ztLogTwice+0xe> не зовёт середину самой себя, а jmp 0x1f <ztFib> не прыгает в ztFib. У обеих инструкций смещение нулевое, потому что ztLog живёт в другом файле и её адрес неизвестен, а нулевое смещение указывает на следующую инструкцию. objdump печатает ту же неправду, и zt повторяет её честно. Правду знают перемещения:

$ objdump -dr fixtures/step_14.o | grep -A1 -E '^ +(9|1a):'
       9: e8 00 00 00 00               	callq	0xe <ztLogTwice+0xe>
		000000000000000a:  R_X86_64_PLT32	ztLog-0x4
--
      1a: e9 00 00 00 00               	jmp	0x1f <ztFib>
		000000000000001b:  R_X86_64_PLT32	ztLog-0x4

Компоновщик впишет в четыре байта по адресу 0xa значение ztLog - 4 - 0xa. Минус четыре появляется потому, что смещение считается от конца инструкции, а заплатка стоит за четыре байта до конца. Читать перемещения zt научится в уроке про объектные файлы. А второй вызов ztLog стал jmp, а не call: это вызов в хвостовой позиции, после него ztLogTwice уже нечего делать. Функция снимает свой кадр и прыгает в ztLog, и та вернётся прямо к тому, кто звал ztLogTwice, сэкономив один ret.

В уроке про объектные файлы zt научится читать таблицу символов сам, флаг --syms станет не нужен, а модуль подписей получит ещё два источника имён. Всё, что написано сегодня, там пригодится без изменений.

Шаг проекта: zl говорит на языке C

В прошлом уроке байткод-машина научилась звать примитивы через primitives.call, обычную функцию Zig. Как именно Zig передаёт ей *Vm, Value и ошибку, знает только компилятор текущей версии. Собственное соглашение Zig (.auto) нигде не записано и вправе меняться от версии к версии, а объединение ошибки со значением вообще не имеет смысла за пределами Zig. Пока все вызовы делает код на Zig, это неважно. Но через несколько уроков мы начнём порождать машинный код сами, в уроке про JIT, и этот код должен уметь позвать примитив. Машинный код знает одно соглашение: то самое System V, которое ты весь урок читал по листингам.

Значит, у каждого примитива должен появиться вход с известным контрактом: машина в %rdi, список аргументов дальше, результат в %rax. Это шаг проекта. К нему два вопроса, на которые урок уже ответил: что можно передать через такую границу и что должно пережить вызов. И третий, про наш собственный вызов: как устроен кадр замыкания в байткод-машине.

Граница C не пропускает тегированное объединение

Первая попытка очевидна: объявить вход с callconv(.c) и ту же подпись.

pub const Native = *const fn (vm: *Vm, args: Value) callconv(.c) Value;

fn nativeCar(vm: *Vm, args: Value) callconv(.c) Value {
    return primCar(vm, args) catch .nil;
}
$ zig build -Dtarget=x86_64-linux
src/primitives.zig:27:23: error: parameter of type 'value.Value' not allowed in function with calling convention 'x86_64_sysv'
fn nativeCar(vm: *Vm, args: Value) callconv(.c) Value {
                      ^~~~~~~~~~~
src/primitives.zig:27:23: note: union with automatic layout has no guaranteed in-memory representation
src/value.zig:36:19: note: union declared here
pub const Value = union(enum) {
                  ^~~~~

Та же причина, по которой Small в разделе про структуры пришлось объявить extern struct. У тегированного объединения Zig раскладка автоматическая: где лежит тег, сколько байт он занимает и в каком порядке с нагрузкой, решает компилятор. Соглашение C раскладывает аргумент по регистрам по его байтам, а байты у такого типа не обещаны. В шапке value.zig с самого начала записано, что представление значения поменяется дважды, сначала на C-совместимое, потом на тегированный указатель в одно слово. Первая смена случается сейчас.

value.zig: значение из двух слов

Value становится extern struct из вида и полезной нагрузки. Перечень Tag получает явный тип u8, без него перечень тоже нельзя положить в extern struct:

pub const Tag = enum(u8) { nil, fixnum, symbol, cons, closure, primitive };
/// Значение, которое можно передать через границу C: `extern struct` из двух
/// слов, поля лежат ровно в порядке объявления. Первое слово это вид, второе
/// полезная нагрузка: число, номер символа или примитива, адрес ячейки.
/// Тегированное объединение Zig через эту границу не пройдёт: у него нет
/// обещанной раскладки, и компилятор откажется ставить его в сигнатуру
/// функции с соглашением C.
pub const Value = extern struct {
    kind: Tag,
    bits: u64,

    pub const nil: Value = .{ .kind = .nil, .bits = 0 };

    pub fn tag(self: Value) Tag {
        return self.kind;
    }

    pub fn fromFixnum(n: i64) Value {
        return .{ .kind = .fixnum, .bits = @bitCast(n) };
    }

    pub fn fromSymbol(id: SymbolId) Value {
        return .{ .kind = .symbol, .bits = id };
    }

    pub fn fromCell(cell: *Cell) Value {
        return .{ .kind = .cons, .bits = @intFromPtr(cell) };
    }

    pub fn fromClosure(cl: *Closure) Value {
        return .{ .kind = .closure, .bits = @intFromPtr(cl) };
    }

    pub fn fromPrimitive(index: PrimIndex) Value {
        return .{ .kind = .primitive, .bits = index };
    }

    pub fn isNil(self: Value) bool {
        return self.tag() == .nil;
    }

    pub fn isCons(self: Value) bool {
        return self.tag() == .cons;
    }

    /// Атом это всё, что не пара. nil тоже атом, как у Маккарти.
    pub fn isAtom(self: Value) bool {
        return self.tag() != .cons;
    }

    pub fn asFixnum(self: Value) i64 {
        std.debug.assert(self.kind == .fixnum);
        return @bitCast(self.bits);
    }

    pub fn asSymbol(self: Value) SymbolId {
        std.debug.assert(self.kind == .symbol);
        return @intCast(self.bits);
    }

    pub fn asCell(self: Value) *Cell {
        std.debug.assert(self.kind == .cons);
        return @ptrFromInt(self.bits);
    }

    pub fn asClosure(self: Value) *Closure {
        std.debug.assert(self.kind == .closure);
        return @ptrFromInt(self.bits);
    }

    pub fn asPrimitive(self: Value) PrimIndex {
        std.debug.assert(self.kind == .primitive);
        return @intCast(self.bits);
    }

    /// Всё, что можно поставить в голову формы.
    pub fn isCallable(self: Value) bool {
        return switch (self.tag()) {
            .closure, .primitive => true,
            else => false,
        };
    }

    /// Тождество в смысле `eq`: числа и символы сравниваются по значению,
    /// пары и замыкания по адресу. В этом представлении всё это одно и то же
    /// сравнение двух слов: у nil нагрузка всегда нулевая.
    pub fn eql(a: Value, b: Value) bool {
        return a.kind == b.kind and a.bits == b.bits;
    }
};

Раскладка теперь обещана: байт вида по смещению 0, семь байт выравнивания, восемь байт нагрузки по смещению 8, всего 16. Размер тот же, что был у объединения, и тест размер значения на этом шаге в value.zig не меняется (поправь в нём только комментарий: теперь это вид плюс нагрузка).

Подписи методов не изменились ни одной, и поэтому не изменился ни один файл, кроме этого. Ради такого момента в value.zig с первого дня действует правило: остальной код ходит к значению только через методы и не разбирает его сам. Методы as* получили std.debug.assert: у объединения чтение чужого поля в Debug ловил сам язык, у двух слов эту проверку приходится писать руками. А eql упростился до сравнения двух слов, потому что у каждого вида в bits лежит ровно то, по чему eq сравнивает: число, номер или адрес.

Что это значит для регистров, ты уже знаешь из раздела про структуры. Value на 16 байт это два восьмибайтовых слова целого класса, и System V раскладывает их по двум целочисленным регистрам подряд, как Small. Вход fn (vm: *Vm, args: Value) callconv(.c) Value получает машину в %rdi, вид списка аргументов в %rsi (в младшем байте, %sil), адрес его первой ячейки в %rdx. Результат на 16 байт возвращается парой %rax и %rdx, как у make_small.

primitives.zig: вход по соглашению C

Писать каждый примитив сразу с callconv(.c) неудобно: внутри примитива хочется try, а ошибки Zig через границу C не проходят. Поэтому примитивы остаются Zig-функциями с ошибками, а входы для них печатает компилятор. Fn становится типом функции, а не указателя (так его можно передать как comptime-параметр), рядом появляется Native:

/// Сигнатура примитива на Zig: машина и список уже вычисленных аргументов.
pub const Fn = fn (vm: *Vm, args: Value) Error!Value;

/// Тот же примитив по соглашению C. Машина едет в `%rdi`, список аргументов
/// в паре `%rsi` и `%rdx` (значение это два слова), результат возвращается
/// в паре `%rax` и `%rdx`. Ошибки Zig через эту границу не проходят, поэтому
/// ошибка остаётся в машине, а наружу уходит nil.
pub const Native = *const fn (vm: *Vm, args: Value) callconv(.c) Value;

Старый call с inline for уступает место таблице адресов:

/// Вход по соглашению C для примитива на Zig. Компилятор печатает по одному
/// такому входу на каждую строку таблицы.
fn native(comptime f: Fn) Native {
    return struct {
        fn entry(vm: *Vm, args: Value) callconv(.c) Value {
            return f(vm, args) catch |err| vm.fail(err);
        }
    }.entry;
}

/// Адреса входов в порядке таблицы: номер примитива это индекс в массиве.
pub const natives: [count]Native = blk: {
    var out: [count]Native = undefined;
    for (&out, 0..) |*slot, i| slot.* = native(table[i][1]);
    break :blk out;
};

/// Вызвать примитив по номеру. Вызов идёт через вход по соглашению C, той же
/// дорогой, какой его позовёт машинный код, а ошибку забираем из машины.
pub fn call(vm: *Vm, index: Index, args: Value) Error!Value {
    const result = natives[index](vm, args);
    if (vm.takeFailure()) |err| return err;
    return result;
}

native это функция, которая возвращает функцию. Каждый вызов с новым f порождает новую анонимную структуру, а в ней новую функцию entry с callconv(.c): так в Zig пишут замыкание времени компиляции. natives собирается в блоке, который целиком вычисляется при компиляции, и в бинарник ложится готовым массивом адресов. Шапка файла тоже меняется: вызов больше не разворачивается inline for, это чтение адреса из массива и косвенный call.

vm.zig: где ждёт ошибка

Соглашение C не знает ошибок, поэтому ошибка едет в обход регистров: вход кладёт её в машину и возвращает nil, а вызывающий забирает. Это приём errno из libc, только поле своё. В vm.zig импортируется errors.zig, в Vm появляется поле и два метода:

    /// Ошибка примитива, позванного по соглашению C. Вернуть ошибку Zig такой
    /// вызов не может, поэтому она ждёт здесь, пока вызывающий её не заберёт.
    failure: ?errors.Error = null,
    /// Запомнить ошибку и вернуть nil: его отдаст наружу вход по соглашению C.
    pub fn fail(self: *Vm, err: errors.Error) Value {
        self.failure = err;
        return .nil;
    }

    /// Забрать ошибку последнего вызова, если она была.
    pub fn takeFailure(self: *Vm) ?errors.Error {
        defer self.failure = null;
        return self.failure;
    }

eval, bytecode.zig и сами примитивы не меняются: все они и так звали primitives.call.

Что видно в ассемблере

Соберём программу под x86_64-linux в режиме оптимизации и найдём входы:

$ zig build asm -Dtarget=x86_64-linux -Doptimize=ReleaseFast
$ nm zig-out/bin/zl-llvm | grep primitives.native | sort | tail -3
0000000001074a30 t primitives.native__struct_27676.entry
0000000001074ac0 t primitives.native__struct_27668.entry
0000000001074b00 t primitives.native__struct_27659.entry

Имена говорят, откуда функции взялись: entry внутри анонимной структуры, которую вернул native. Самая младшая структура, 27659, это car, первая строка таблицы:

<primitives.native__struct_27659.entry>:
    pushq   %rbp
    movq    %rsp, %rbp
    movw    $0x20, %ax                # код ошибки ArityMismatch наготове
    cmpb    $0x3, %sil                # args.kind == cons? вид списка в младшем байте rsi
    jne     <...+0x2a>
    cmpb    $0x0, 0x10(%rdx)          # rdx = args.bits, адрес ячейки; cdr.kind == nil?
    jne     <...+0x2a>                # аргумент не один: ArityMismatch
    movw    $0x1f, %ax                # теперь наготове NotAPair
    cmpb    $0x3, (%rdx)              # car.kind: сам аргумент пара?
    jne     <...+0x2a>
    movq    0x8(%rdx), %rcx           # адрес этой пары
    movq    (%rcx), %rax              # её car: вид в rax
    movq    0x8(%rcx), %rdx           #         нагрузка в rdx
    popq    %rbp
    retq
    movw    %ax, 0xbc(%rdi)           # vm.failure = код ошибки, rdi это машина
    xorl    %eax, %eax                # вернуть nil: вид 0
    xorl    %edx, %edx                #              нагрузка 0
    popq    %rbp
    retq

Весь контракт на одном экране. Машина пришла в %rdi и понадобилась только на пути ошибки. Список аргументов пришёл двумя регистрами: вид в %sil, адрес ячейки в %rdx. Раскладка ячейки видна по смещениям: car занимает байты с 0 по 15 (вид по 0, нагрузка по 8), cdr с 16 (вид по 0x10). Результат ушёл парой %rax и %rdx. Ошибка ушла записью двух байт в 0xbc(%rdi): это поле failure, опциональная ошибка Zig, у которой ноль означает «ошибки нет». Весь try из primCar и takeOne сложился в три сравнения.

Теперь с другой стороны, у вызывающего. Вот конец bytecode.Machine.call в ветке примитива:

<bytecode.Machine.call>:
    pushq   %rbp
    movq    %rsp, %rbp
    pushq   %r15                      # пять callee-saved регистров:
    pushq   %r14                      # функция будет ими пользоваться
    pushq   %r13                      # и обязана вернуть их нетронутыми
    pushq   %r12
    pushq   %rbx
    subq    $0x58, %rsp
    movl    %esi, %ecx
    movq    %rdi, %rbx                # rbx = self, машина байткода
    ...
    movq    (%rbx), %r15              # r15 = self.vm
    movq    %r15, %rdi                # vm в rdi
    movq    %rax, %rdx                # args.bits в rdx (args.kind уже в esi)
    callq   *0x1013db8(,%r8,8)        # natives[index]: косвенный call по таблице
    movzwl  0xbc(%r15), %esi          # vm.failure: r15 пережил вызов
    movw    $0x0, 0xbc(%r15)          # takeFailure гасит поле
    testw   %si, %si
    jne     <bytecode.Machine.call+0x1c0>   # была ошибка: наружу
    movq    %r12, 0x28(%rbx)          # stack.items.len = base: rbx и r12 тоже пережили
    movq    0x20(%rbx), %rcx          # stack.items.ptr
    movq    %rax, (%rcx,%r14)         # результат на место функции: вид
    movq    %rdx, 0x8(%rcx,%r14)      #                            и нагрузка

Здесь ответ на вопрос «что должно пережить вызов». После callq машине нужны четыре вещи: сама байткод-машина, её Vm, номер base и смещение слота функции. Компилятор заранее разложил их по %rbx, %r15, %r12 и %r14, то есть только по callee-saved регистрам, и заплатил за это пятью парами push и pop в прологе и эпилоге, ровно как keepAcross платил за один %rbx. Вход примитива вправе испортить %rdi, %rsi, %rdx, %rcx, %r8, и никто не пострадает. callq *0x1013db8(,%r8,8) это тот же косвенный переход по таблице, что и jmpq в switch, только с возвратом: natives лежит в .rodata, номер примитива служит индексом.

Когда JIT начнёт звать примитивы, ему придётся выполнить ту же половину контракта, что здесь выполнил компилятор Zig: положить машину в %rdi, значение в предписанные регистры, выровнять стек на 16 и после вызова не рассчитывать ни на один caller-saved регистр.

Кадр замыкания

Примитив зовётся по чужому соглашению, а замыкание по нашему собственному, и оно устроено по тому же образцу, что машинный кадр. Вот как выглядят стек значений и кадры в момент, когда (fib 20) из прошлого урока только что позвала себя с n = 19:

стек значений                          кадры
  [0]  #<closure>   fib                  { chunk: тело fib, pc: 16, base: 1 }
  [1]  20           n вызывающего
  [2]  #<+>         сложение ждёт
  [3]  #<closure>   fib                  { chunk: тело fib, pc: 0,  base: 4 }
  [4]  19           n вызванного

Нижний кадр когда-то принадлежал форме (fib 20): её tail_call положил fib в слот 0 на место заглушки и занял её запись. pc: 16 у вызывающего это команда сразу за call 1 из листинга fib, туда машина вернётся. 19 в слоте 4 лёг на место функции -, которую перед этим позвали и сняли.

Параллель прямая.

Машинный кадр x86-64Кадр байткод-машины
call кладёт адрес возвратаpc вызывающего остаётся в его записи Frame, уже сдвинутым на следующую команду
%rbp указывает на кадрbase указывает на первый аргумент
аргументы в регистрах и над адресом возвратааргументы в слотах base, base + 1 и дальше
результат в %raxрезультат в слоте base - 1, на месте функции
callee-saved регистры возвращаются нетронутымивызванный не трогает ничего ниже своего base - 1, поэтому слоты вызывающего переживают вызов сами
хвостовой вызов это jmp вместо calltail_call переписывает текущую запись вместо новой

И то же самое глазами компилятора: ветка замыкания в bytecode.Machine.call собирает запись Frame в памяти и зовёт pushFrame:

    movq    -0x80(%rbp), %rax
    movq    %rax, -0x70(%rbp)         # frame.chunk: тело, которое вернул functionOf
    movl    $0x0, -0x68(%rbp)         # frame.pc = 0
    movl    %r12d, -0x64(%rbp)        # frame.base = base, всё ещё в r12
    leaq    -0x70(%rbp), %rsi
    movq    %rbx, %rdi
    callq   <bytecode.Machine.pushFrame>

Запись на 16 байт (указатель и два u32) передаётся в pushFrame адресом, хотя по размеру прошла бы и в регистрах: pushFrame не callconv(.c), и соглашение для неё выбирает Zig. Это ещё одна иллюстрация, почему для машинного кода нужна граница с обещанным контрактом.

Тесты шага

В build.zig номер шага: const steps = [_][]const u8{ "05", "06", "13", "14" };.

//! Шаг 14: соглашение о вызовах. Примитивы видны снаружи как функции
//! `fn (*Vm, Value) callconv(.c) Value`, значение проходит через границу C
//! по значению, ошибка примитива переезжает через машину.

const std = @import("std");

const zl = @import("zl");

const bytecode = zl.bytecode;
const primitives = zl.primitives;
const printer = zl.printer;
const reader = zl.reader;
const Value = zl.Value;
const Vm = zl.Vm;

const testing = std.testing;

fn expectPrinted(vm: *Vm, v: Value, expected: []const u8) !void {
    const text = try printer.toOwned(testing.allocator, vm, v);
    defer testing.allocator.free(text);
    try testing.expectEqualStrings(expected, text);
}

test "вход примитива объявлен с соглашением C" {
    const info = @typeInfo(@typeInfo(primitives.Native).pointer.child).@"fn";
    try testing.expect(info.calling_convention.eql(std.builtin.CallingConvention.c));
    try testing.expectEqual(primitives.count, primitives.natives.len);
}

test "примитив зовётся через адрес из таблицы, как его позовёт машинный код" {
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();

    const plus = primitives.natives[primitives.indexOf("+")];
    const args = try reader.readOne(&vm, "(40 2)");
    try testing.expectEqual(@as(i64, 42), plus(&vm, args).asFixnum());
    try testing.expectEqual(@as(?zl.errors.Error, null), vm.takeFailure());

    const cons = primitives.natives[primitives.indexOf("cons")];
    try expectPrinted(&vm, cons(&vm, try reader.readOne(&vm, "(1 (2 3))")), "(1 2 3)");
}

test "ошибка примитива переходит границу через машину" {
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();

    const car = primitives.natives[primitives.indexOf("car")];
    const result = car(&vm, try reader.readOne(&vm, "(5)"));
    // Наружу ушёл nil, а сама ошибка ждёт в машине.
    try testing.expect(result.isNil());
    try testing.expectEqual(@as(?zl.errors.Error, error.NotAPair), vm.takeFailure());
    try testing.expectEqual(@as(?zl.errors.Error, null), vm.takeFailure());

    // `primitives.call` забирает ошибку сам и возвращает её как ошибку Zig.
    const idx = primitives.indexOf("+");
    try testing.expectError(error.NotANumber, primitives.call(&vm, idx, try reader.readOne(&vm, "(a 1)")));
    try testing.expectEqual(@as(?zl.errors.Error, null), vm.failure);
}

/// Функция с соглашением C, которая возвращает свой аргумент. Если значение
/// переживает дорогу через регистры туда и обратно, раскладка у вызывающего
/// и у вызываемого одна и та же.
fn echo(vm: *Vm, v: Value) callconv(.c) Value {
    _ = vm;
    return v;
}

test "значение любого вида проходит через границу C по значению" {
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();

    const pair = try reader.readOne(&vm, "(1 . 2)");
    const values = [_]Value{
        .nil,
        .fromFixnum(-5),
        .fromFixnum(std.math.maxInt(i32)),
        try vm.symbolValue("hello"),
        pair,
        .fromPrimitive(primitives.indexOf("car")),
    };
    const through: *const fn (*Vm, Value) callconv(.c) Value = &echo;
    for (values) |v| {
        const back = through(&vm, v);
        try testing.expect(back.eql(v));
        try testing.expectEqual(v.tag(), back.tag());
    }
    try testing.expectEqual(pair.asCell(), through(&vm, pair).asCell());
}

test "обход дерева и байткод зовут примитив через один вход и видят одну ошибку" {
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();
    var m: bytecode.Machine = .init(&vm);
    defer m.deinit();

    try expectPrinted(&vm, try zl.eval.evalSource(&vm, "(+ 1 2)"), "3");
    try expectPrinted(&vm, try m.runSource("(+ 1 2)", .loop), "3");
    try testing.expectError(error.NotAPair, m.runSource("(cdr 7)", .labeled));
    try testing.expectError(error.NotAPair, zl.eval.evalSource(&vm, "(cdr 7)"));
    try testing.expectEqual(@as(?zl.errors.Error, null), vm.failure);
}

test "кадр замыкания: функция под аргументами, результат на её месте" {
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();
    var m: bytecode.Machine = .init(&vm);
    defer m.deinit();

    // Три аргумента, два из них выражения: всё считается в слотах стека.
    try expectPrinted(&vm, try m.runSource(
        \\(define f (lambda (a b c) (list c b a)))
        \\(f 1 (+ 1 1) (f 'x 'y 'z))
    , .loop), "((z y x) 2 1)");
    // Кадры сняты, стек пуст: каждый результат лёг на место своей функции.
    try testing.expectEqual(@as(usize, 0), m.stack.items.len);
    try testing.expectEqual(@as(usize, 2), m.peak_frames);
}

Тесты нарочно не проверяют размер значения. Они проверяют то, что обязано остаться верным при любом представлении: вход объявлен с соглашением C, значение любого вида переживает дорогу через регистры туда и обратно, ошибка доходит до вызывающего, а кадр замыкания оставляет стек чистым. Следующий шаг сожмёт значение в одно слово, и все эти тесты должны пройти без правки.

Прогон

$ zig build test --summary all
Build Summary: 11/11 steps succeeded; 40/40 tests passed
test success
+- run test 7 pass (7 total) 320ms MaxRSS:2M      тесты внутри модулей
+- run test 12 pass (12 total) 608ms MaxRSS:3M    шаг 05
+- run test 10 pass (10 total) 1s MaxRSS:10M      шаг 06
+- run test 5 pass (5 total) 2s MaxRSS:29M        шаг 13
+- run test 6 pass (6 total) 1s MaxRSS:3M         шаг 14

Прогон на Apple M4 проверяет тот же контракт в соглашении AArch64, где значение едет в x1 и x2. Чтобы увидеть именно System V, прогони те же тесты под amd64, как у zt и в JIT:

$ docker run --rm --platform linux/amd64 -v "$PWD":/work -w /work \
    ghcr.io/bondiano/runner-zig:dev-amd64 \
    zig build test --summary all --cache-dir /work/.zc --global-cache-dir /work/.zg
Build Summary: 11/11 steps succeeded; 40/40 tests passed

Практика

Пора самому восстановить функцию по её кадру. В задаче лежит дизассемблер рекурсивной rfun, снятый с реального Zig-кода: она сохраняет аргумент, на нуле возвращает ноль, иначе сдвигает аргумент вправо на два бита, зовёт себя и складывает результат с исходным значением. Твоя работа написать pub fn rfun(x: u64) u64 с тем же поведением. Тесты сверяют твою функцию с независимой петлёй на широком диапазоне, включая большие числа, где сумма оборачивается. Читай листинг по трём вопросам из разбора выше: где база, что уходит в рекурсию, как складывается результат.

Упражнения

Итоги

  • call target кладёт адрес возврата на стек и прыгает; ret снимает адрес и прыгает по нему. Это push плюс jmp и pop в счётчик команд.
  • Стековый кадр это участок стека одного вызова: сохранённый %rbp, адрес возврата по 0x8(%rbp), локальные ниже, аргументы вызываемых функций ещё ниже. Пролог его заводит, эпилог снимает.
  • Первые шесть целочисленных аргументов едут в %rdi, %rsi, %rdx, %rcx, %r8, %r9, результат в %rax. Седьмой и дальше ложатся на стек, их читают по 0x10(%rbp), 0x18(%rbp) и так далее.
  • Числа с плавающей точкой едут своей очередью в %xmm0 до %xmm7, независимо от целочисленной очереди.
  • callee-saved регистры (%rbx, %rbp, %r12 до %r15) вызываемый обязан вернуть нетронутыми, сохраняя их на стек парой push и pop. caller-saved (регистры аргументов, %rax, %r10, %r11) он вправе затирать.
  • Значение, которое нужно после вызова, обязано пережить его в callee-saved регистре или в кадре, потому что регистры аргументов после call в общем случае мусор.
  • Перед call значение %rsp обязано делиться на 16. Компилятор добивает нечётное число push фиктивным push и подбирает sub в прологе под это правило.
  • Локальная переменная живёт в регистре, пока у неё не берут адрес и она влезает в регистр. &x, большой размер или нехватка регистров отправляют её на стек.
  • Рекурсия это обычные кадры: каждый незавершённый вызов держит свой кадр, а бесконечная рекурсия упирается в предел стека. Оптимизатор иногда сворачивает хвостовую рекурсию в цикл.
  • Структура до 16 байт передаётся и возвращается в регистрах, больше 16 байт передаётся на стеке, а возвращается через скрытый указатель в %rdi. Через C-совместимую границу передаётся только extern struct с гарантированной раскладкой.
  • Реверс функции по ассемблеру сводится к трём вопросам: где база, что уходит аргументом в рекурсию, как результат вызова комбинируется с сохранённым значением.
  • В шаге проекта zt disasm научился прямому call и подписям: имена берутся из вывода nm, цель подписывается ближайшим именем не правее адреса, <ztSumSquares+0x22>, а нулевое смещение в объектнике это ещё не вписанный компоновщиком адрес.
  • В шаге проекта значение zl стало extern struct из двух слов, а у каждого примитива появился вход fn (*Vm, Value) callconv(.c) Value: машина в %rdi, значение в %rsi и %rdx, результат в %rax и %rdx, ошибка в поле машины. Кадр замыкания в байткод-машине устроен как машинный: функция под аргументами, base вместо %rbp, результат на месте функции.

Дальше

Ты научился читать вызов: регистры аргументов, стековый кадр, сохранённые регистры, выравнивание и возврат. Дальше в разделе мы посмотрим, как в кадре лежат составные данные: массивы и структуры, адресная арифметика по индексу, выравнивание полей и дырки между ними. А ещё через урок вернёмся к кадру с неприятной стороны: что происходит, когда запись в локальный массив выходит за его границы и затирает тот самый адрес возврата, который кладёт call.

домашка

Домашка