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

Исключения и системные вызовы

senior~330 мин

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

Исключения и системные вызовы

В прошлом уроке ты вклинился между программой и библиотекой: перехватчик под LD_PRELOAD видел каждый malloc, но только потому, что вызов шёл через таблицу динамической привязки. Прямая инструкция syscall для него невидима. Сегодня спускаемся туда, где невидимого не бывает: на границу между программой и ядром. Всё, что происходит на этой границе, называется одним словом, исключение. Прерывание от таймера, системный вызов, обращение к отсутствующей странице и деление на ноль это четыре лица одного механизма, и к концу урока ты будешь читать их как одну таблицу. Мы разберём, что процессор делает на входе в ядро и на выходе, выучим соглашение Linux о системных вызовах, позовём ядро тремя способами и посмотрим, что к голому вызову добавляют обёртки стандартной библиотеки. Потом два шага проектов: zt получит strace, который ловит чужие системные вызовы через ptrace, а машина Y86 получит бит привилегий, инструкцию trap и первое ядро на десять строк.

Цели урока

  • Определять исключение как передачу управления ядру по таблице и различать четыре класса: прерывание, ловушка, сбой, аварийное завершение. Для каждого знать, кто виноват и куда возвращается управление.
  • Понимать, что такое таблица исключений, вектор, стек ядра и режимы процессора, и почему процессу запрещены одни инструкции и разрешены другие.
  • Знать несколько исключений x86-64 по номерам и то, как Linux превращает их в сигналы процессу.
  • Выучить соглашение Linux о системных вызовах на x86-64 и aarch64: где номер, где аргументы, какая инструкция, как закодирована ошибка.
  • Сделать системный вызов через asm volatile, через std.os.linux и через std.posix, и объяснить, что каждый слой добавляет к предыдущему.
  • Читать вывод strace и знать, на чём он построен.
  • Написать zt strace: fork, PTRACE_TRACEME, цикл PTRACE_SYSCALL, регистры потомка, память потомка, итог со счётчиками.
  • Дать машине Y86 два режима, таблицу исключений, trap и iret, и запустить на ней первый процесс, который печатает через ядро.

Идея: поток, который ломает не программа

Всё, что ты делал с процессором до этого урока, укладывалось в одну картину: поток управления идёт от инструкции к инструкции, и каждый следующий адрес известен программе. jmp, call, ret и условные переходы меняют адрес, но по правилам, которые записаны в самой программе. Ни одна из этих инструкций не поможет, когда происходит что-то снаружи программы: пришли данные с диска, истёк квант времени, пользователь нажал Ctrl+C. И ни одна не поможет, когда программе нужна услуга, которую она не может оказать себе сама: прочитать файл, создать процесс, узнать время. Для этого у процессора есть второй механизм смены потока управления, и он всегда ведёт в одно место, в ядро.

Исключение это передача управления ядру в ответ на событие с номером. Четыре шага, всегда одни и те же.

  1. Событие. Процессор замечает, что произошло: пришёл сигнал от устройства, инструкция не может выполниться, программа выполнила специальную инструкцию.
  2. Номер. У события есть номер, и по этому номеру процессор берёт адрес обработчика из таблицы исключений. Начало таблицы лежит в регистре процессора, который ядро заполнило при загрузке.
  3. Режим ядра. Процессор сохраняет адрес возврата и слово состояния на стек ядра, взводит бит привилегий и прыгает по адресу из таблицы. С этого момента исполняется код ядра.
  4. Возврат. Обработчик заканчивается специальной инструкцией возврата, которая снимает сохранённое со стека и возвращает процессор в пользовательский режим. Куда именно, зависит от класса исключения: к следующей инструкции, к той же самой или никуда.

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

Четыре класса

Исключения делят на четыре класса по двум признакам: синхронное ли событие (вызвано текущей инструкцией) и куда возвращается управление.

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

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

Ловушка это исключение по заказу. Программа выполняет специальную инструкцию (syscall на x86-64, svc на ARM, trap на нашей Y86), и это единственный законный способ попросить ядро что-то сделать. Возврат всегда к следующей инструкции, ответ в регистре. Вся вторая половина урока про этот класс.

Сбой это ошибка с шансом на исправление. Классический пример это сбой страницы: инструкция обратилась к адресу, чья страница лежит на диске. Ядро подгружает страницу, правит таблицу и возвращает управление на ту же самую инструкцию: она выполняется заново и на этот раз проходит. Программа не узнаёт, что её прерывали. Если же адрес чужой, исправлять нечего, и ядро вместо возврата шлёт процессу сигнал. Деление на ноль по классу тоже сбой, но Linux даже не пытается его чинить.

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

Пощёлкай по виджету: пять настоящих событий x86-64, у каждого свой класс, свой номер в таблице и свой обработчик в ядре Linux. Кнопка «шаг» проводит событие по четырём шагам из предыдущего раздела, и на последнем видно, куда возвращается управление.

Обрати внимание на два случая. У syscall в таблице стоит номер 128, но это номер исторической инструкции int $0x80: современная syscall идёт в ядро короче, через адрес в регистре MSR_LSTAR, мимо таблицы. Смысл тот же, дверь другая. И у деления на ноль возврат «никуда», хотя по классу это сбой: Linux не чинит арифметику, а шлёт процессу SIGFPE.

Таблица, стек ядра и режимы

Разберём шаг три подробнее, потому что в нём вся защита операционной системы.

У процессора есть бит, который говорит, чей код сейчас исполняется. На x86-64 он называется CPL и лежит в младших битах регистра %cs: 0 для ядра, 3 для программы. Картинку с четырьмя кольцами защиты ты наверняка видел, но Linux использует два: ядро в нулевом, всё остальное в третьем. В пользовательском режиме нельзя:

  • выполнить привилегированную инструкцию: hlt, cli и sti (запрет и разрешение прерываний), lidt (сменить таблицу исключений), запись в %cr3 (сменить таблицу страниц), in и out к портам устройств;
  • обратиться к памяти ядра: верхняя половина адресного пространства помечена в таблице страниц как доступная только из режима ядра, и любое обращение туда это сбой;
  • переключиться в режим ядра иначе как через исключение.

Последний пункт и есть суть. Программа не может сама взвести бит привилегий. Единственная дорога вверх это исключение, а у исключения адрес назначения берётся из таблицы, которую заполнило ядро. Значит, поднявшись в режим ядра, процессор всегда оказывается в коде ядра, и никак иначе. Точка входа контролируется полностью.

Второе, что делает процессор на входе, это смена стека. Стеку программы верить нельзя: он может указывать куда угодно, в том числе на память ядра, и обработчик, который положил бы туда адрес возврата, стал бы дырой. Поэтому у каждого процесса есть отдельный стек ядра, и адрес его вершины процессор берёт из своей структуры, а не из %rsp программы. На этот стек ложатся адрес возврата, старый %rsp, флаги и сегменты, дальше обработчик сохраняет регистры общего назначения. Всё это вместе называют кадром исключения, и ровно такой кадр из трёх слов ты положишь на стек ядра Y86 в конце урока.

Возврат делает инструкция iretq: снимает кадр, восстанавливает флаги, %rsp и режим и прыгает по адресу возврата. Для системного вызова x86-64 есть ускоренная пара syscall и sysret, которая экономит обращения к памяти, но делает то же самое.

Исключения x86-64: номера и сигналы

Первые 32 номера в таблице исключений x86-64 зарезервированы архитектурой, их назначил Intel. Вот те, которые ты встретишь в жизни:

НомерИмяКлассЧто случилосьСигнал от Linux
0#DE, divide errorсбойцелочисленное деление на ноль или переполнение частногоSIGFPE
3#BP, breakpointловушкаинструкция int3, её ставит отладчикSIGTRAP
6#UD, invalid opcodeсбойбайты не образуют инструкцию, или инструкция недоступнаSIGILL
13#GP, general protectionсбойпривилегированная инструкция из программы, неканонический адрес, ошибка сегментаSIGSEGV
14#PF, page faultсбойобращение к странице, которой нет в таблице, или с недостаточными праваминичего, если ядро подгрузило страницу; иначе SIGSEGV или SIGBUS
18#MC, machine checkаварийное завершениеошибка железаSIGBUS или останов машины
32 до 255прерываниеназначает ядро: таймер, устройства, межпроцессорные

Номера с 32 по 255 раздаёт ядро. Например, у Linux вектор 236 это прерывание локального таймера, тот самый тик планировщика.

Заметь столбец с сигналами. Процессор не знает, что такое процесс, он знает только номер исключения. Ядро в обработчике решает, что делать: сбой страницы оно пытается починить, а деление на ноль превращает в сигнал процессу. Сигналы это программная надстройка над исключениями, и им посвящён урок 49. Пока достаточно знать, как их увидеть.

Программа, которая ломается тремя способами по выбору:

const std = @import("std");

/// Без этого Zig сам ловит SIGSEGV и SIGILL и печатает трассу стека.
/// Нам нужно увидеть, что делает ядро, когда ловить некому.
pub const std_options: std.Options = .{ .enable_segfault_handler = false };
const builtin = @import("builtin");

fn say(text: []const u8) void {
    _ = std.os.linux.write(1, text.ptr, text.len);
}

pub fn main(init: std.process.Init) !void {
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len < 2) return error.BadUsage;
    const which = args[1];

    if (std.mem.eql(u8, which, "divide")) {
        // Деление на ноль руками, чтобы компилятор не вставил свою проверку.
        say("делим на ноль\n");
        var zero: u64 = 0;
        _ = &zero;
        const q = switch (builtin.cpu.arch) {
            .x86_64 => asm volatile (
                \\movq $7, %%rax
                \\xorq %%rdx, %%rdx
                \\divq %[d]
                : [q] "={rax}" (-> u64),
                : [d] "r" (zero),
                : .{ .rdx = true }),
            .aarch64 => asm volatile ("udiv %[q], %[n], %[d]"
                : [q] "=r" (-> u64),
                : [n] "r" (@as(u64, 7)),
                  [d] "r" (zero)),
            else => unreachable,
        };
        var buf: [32]u8 = undefined;
        say(try std.fmt.bufPrint(&buf, "частное {d}\n", .{q}));
    } else if (std.mem.eql(u8, which, "segv")) {
        say("пишем по адресу 0\n");
        const p: *allowzero volatile u8 = @ptrFromInt(0);
        p.* = 1;
        say("сюда не дойдём\n");
    } else if (std.mem.eql(u8, which, "ill")) {
        say("исполняем мусорную инструкцию\n");
        switch (builtin.cpu.arch) {
            .x86_64 => asm volatile ("ud2"),
            .aarch64 => asm volatile ("udf #0"),
            else => unreachable,
        }
        say("сюда не дойдём\n");
    }
}
$ zig build-exe faults.zig -O ReleaseSafe
$ ./faults segv; echo "exit=$?"
пишем по адресу 0
Segmentation fault
exit=139
$ ./faults ill; echo "exit=$?"
исполняем мусорную инструкцию
Illegal instruction
exit=132

Выводы этого урока сняты в контейнере Debian bookworm на Linux 7.0, ядро aarch64 без эмуляции, машина с Apple M4 Max; где снято иначе, сказано рядом. Запись по нулевому адресу это сбой страницы: у нулевой страницы нет отображения, чинить нечего, процесс получает SIGSEGV с номером 11. Мусорная инструкция это #UD и SIGILL с номером 4. Слова «Segmentation fault» и «Illegal instruction» напечатала оболочка, а не программа: программа умерла посреди say. Код выхода 128 плюс номер сигнала это тоже соглашение оболочки, в следующем уроке ты увидишь, как оно раскладывается по битам слова состояния waitpid.

А деление на ноль показало разницу между архитектурами:

$ ./faults divide; echo "exit=$?"
делим на ноль
частное 0
exit=0

На ARM целочисленное деление на ноль не исключение вовсе: инструкция udiv по определению архитектуры возвращает ноль, и процессор идёт дальше. На x86-64 та же программа получила бы #DE, ядро отправило бы SIGFPE, оболочка написала бы «Floating point exception», код выхода был бы 136. Под эмулятором Rosetta на этой же машине x86-64 версия тоже прошла без исключения: эмулятор переводит divq в ARM-инструкции и теряет ловушку. Так что если тебе нужно посмотреть на SIGFPE живьём, нужен настоящий x86-64 или песочница курса.

Системный вызов: ловушка по договору

Системный вызов это ловушка с договором о том, что лежит в регистрах до неё и после. В уроке про встроенный ассемблер ты уже писал write руками. Повторим договор целиком, теперь с обеих сторон.

Linux x86-64.

  • Номер вызова в %rax. Таблица номеров лежит в исходниках ядра, файл arch/x86/entry/syscalls/syscall_64.tbl: read 0, write 1, open 2, close 3, getpid 39, execve 59, exit 60, exit_group 231, openat 257.
  • До шести аргументов, слева направо: %rdi, %rsi, %rdx, %r10, %r8, %r9. Четвёртый в %r10, а не в %rcx, как в обычном соглашении о вызовах: инструкция syscall сама затирает %rcx, туда она кладёт адрес возврата.
  • Инструкция syscall. Она же портит %r11: туда попадают флаги.
  • Результат в %rax. Успех это число от 0 и выше или адрес. Ошибка это отрицательное число от минус 4095 до минус 1, и это минус errno. Всё остальное между ними ядро не возвращает, поэтому по одному регистру можно понять, ошибка это или, скажем, адрес 0xffff...f000 от mmap.
  • Все остальные регистры сохраняются.

Linux aarch64. Тот же смысл, другие буквы: номер в x8, аргументы в x0 до x5, инструкция svc #0, результат в x0, ошибка закодирована так же. Регистры общего назначения svc не портит. Номера другие: write 64, openat 56, getpid 172, exit_group 94. У ARM нет исторического груза, там одна общая таблица для всех новых портов ядра, и старых вызовов без суффикса at вроде open в ней нет вовсе.

Виджет из урока 18 показывает раскладку по регистрам для обеих архитектур. Переключи на aarch64, выбери write и положи в дескриптор девятку: ответ станет минус девять, и виджет расшифрует его как EBADF.

Отрицательный errno в регистре это договор с ядром. Привычные тебе минус единица и глобальная переменная errno это уже libc: обёртка проверяет диапазон, кладёт код в errno и возвращает минус один. Zig без libc делает то же самое, но без глобальной переменной, и сейчас ты увидишь, как.

Способ первый: asm volatile

Голый вызов, ничего между тобой и ядром.

const std = @import("std");
const builtin = @import("builtin");

const arch = builtin.cpu.arch;

/// Номер вызова у каждой архитектуры свой. Таблица x86-64 историческая,
/// aarch64 пользуется общей таблицей новых портов ядра.
const Sys = struct {
    getpid: usize,
    write: usize,
};

const sys: Sys = switch (arch) {
    .x86_64 => .{ .getpid = 39, .write = 1 },
    .aarch64 => .{ .getpid = 172, .write = 64 },
    else => @compileError("нужен Linux на x86-64 или aarch64"),
};

/// Сисколл без аргументов. Возвращает регистр результата как есть.
fn syscall0(number: usize) usize {
    return switch (arch) {
        .x86_64 => asm volatile ("syscall"
            : [ret] "={rax}" (-> usize),
            : [number] "{rax}" (number),
            : .{ .rcx = true, .r11 = true, .memory = true }),
        .aarch64 => asm volatile ("svc #0"
            : [ret] "={x0}" (-> usize),
            : [number] "{x8}" (number),
            : .{ .memory = true }),
        else => unreachable,
    };
}

/// Сисколл с тремя аргументами: столько нужно write.
fn syscall3(number: usize, a1: usize, a2: usize, a3: usize) usize {
    return switch (arch) {
        .x86_64 => asm volatile ("syscall"
            : [ret] "={rax}" (-> usize),
            : [number] "{rax}" (number),
              [a1] "{rdi}" (a1),
              [a2] "{rsi}" (a2),
              [a3] "{rdx}" (a3),
            : .{ .rcx = true, .r11 = true, .memory = true }),
        .aarch64 => asm volatile ("svc #0"
            : [ret] "={x0}" (-> usize),
            : [number] "{x8}" (number),
              [a1] "{x0}" (a1),
              [a2] "{x1}" (a2),
              [a3] "{x2}" (a3),
            : .{ .memory = true }),
        else => unreachable,
    };
}

fn say(comptime fmt: []const u8, args: anytype) void {
    var buf: [128]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
    _ = syscall3(sys.write, 1, @intFromPtr(text.ptr), text.len);
}

pub fn main() void {
    const pid = syscall0(sys.getpid);
    say("getpid: {d}\n", .{pid});

    // Дескриптор 9 никто не открывал. Ядро отвечает не -1, а минус EBADF.
    const msg = "в закрытый дескриптор\n";
    const rc = syscall3(sys.write, 9, @intFromPtr(msg.ptr), msg.len);
    const signed: isize = @bitCast(rc);
    say("write(9, ...): как usize {d}, как isize {d}\n", .{ rc, signed });

    // Такого номера нет. Ядро отвечает минус ENOSYS.
    const nosys: isize = @bitCast(syscall0(100_000));
    say("syscall 100000: {d}\n", .{nosys});
}
$ zig build-exe raw_syscall.zig
$ ./raw_syscall
getpid: 41
write(9, ...): как usize 18446744073709551607, как isize -9
syscall 100000: -38

Та же программа на x86-64 (под Rosetta в Docker на той же машине) печатает то же самое, только с другим pid: switch по архитектуре вычисляется при компиляции, и в бинарник попадает одна ветка с нужными номерами. Второй вывод стоит запомнить в обоих видах. Регистр результата это usize, и минус девять в нём выглядит как 18446744073709551607. Знак появляется только после @bitCast в isize. Девятка это EBADF, «плохой дескриптор». Тридцать восемь это ENOSYS, «такого вызова нет»: в %rax его кладёт сам вход в ядро ещё до того, как посмотрит на номер, и если номер не найден в таблице, это значение так и возвращается. Заметь, что getpid вернул 41: в контейнере это сорок первый процесс с момента старта.

Способ второй: std.os.linux

Писать asm volatile на каждый вызов незачем: это уже сделано в std.os.linux. Загляни в lib/std/os/linux/x86_64.zig (путь к lib покажет zig env):

pub fn syscall1(number: SYS, arg1: u64) u64 {
    return asm volatile ("syscall"
        : [ret] "={rax}" (-> u64),
        : [number] "{rax}" (@intFromEnum(number)),
          [arg1] "{rdi}" (arg1),
        : .{ .rcx = true, .r11 = true, .memory = true });
}

Ровно твоя функция, только номер приезжает как enum, и таких функций семь, от syscall0 до syscall6. Над ними тонкие обёртки с именами вызовов, в lib/std/os/linux.zig:

pub fn write(fd: i32, buf: [*]const u8, count: usize) usize {
    return syscall3(.write, @bitCast(@as(isize, fd)), @intFromPtr(buf), count);
}

pub fn getpid() pid_t {
    // Casts result to a pid_t, safety-checking >= 0, because getpid() cannot fail
    return @intCast(@as(u32, @truncate(syscall0(.getpid))));
}

pub fn errno(r: usize) E {
    const signed_r: isize = @bitCast(r);
    const int = if (signed_r > -4096 and signed_r < 0) -signed_r else 0;
    return @enumFromInt(int);
}

Три вещи, которые этот слой делает и не делает.

  • Возвращает usize. Регистр как есть. Обёртка не решает за тебя, ошибка это или нет; она лишь привела типы аргументов: дескриптор стал i32, буфер указателем.
  • Даёт errno как функцию. linux.errno(rc) проверяет тот самый диапазон от минус 4095 до минус 1 и возвращает значение перечисления E: .SUCCESS, если ошибки нет, иначе .BADF, .NOENT, .INTR и так далее. Имена без префикса E, потому что префикс уже в имени типа.
  • Знает номера за тебя. Перечисления linux.syscalls.X64 и linux.syscalls.Arm64 это перепечатанные таблицы ядра, и через них .write превращается в 1 или в 64 в зависимости от цели сборки. На них же будет стоять zt strace.

Чего здесь нет: повтора при EINTR, проверки аргументов, переносимости. Всё это только Linux.

Способ третий: std.posix

Слой выше называется std.posix, и он уже не про Linux, а про любую систему с POSIX-вызовами: под ним лежит либо std.os.linux, либо libc. Вот read из lib/std/posix.zig, с сокращениями:

pub fn read(fd: fd_t, buf: []u8) ReadError!usize {
    if (buf.len == 0) return 0;
    // ...
    while (true) {
        const rc = system.read(fd, buf.ptr, @min(buf.len, max_count));
        switch (errno(rc)) {
            .SUCCESS => return @intCast(rc),
            .INTR => continue,
            .INVAL => unreachable,
            .FAULT => unreachable,
            .AGAIN => return error.WouldBlock,
            .CANCELED => return error.Canceled,
            .BADF => return error.Unexpected, // use after free
            .IO => return error.InputOutput,
            .ISDIR => return error.IsDir,
            .NOBUFS => return error.SystemResources,
            .NOMEM => return error.SystemResources,
            .NOTCONN => return error.SocketUnconnected,
            .CONNRESET => return error.ConnectionResetByPeer,
            .TIMEDOUT => return error.Unexpected,
            else => |err| return unexpectedErrno(err),
        }
    }
}

Здесь видно всё, что обёртка добавляет к голому вызову.

  • Ошибка стала значением из error set. Вместо числа в регистре ты получаешь error.IsDir, и компилятор заставит его обработать. Числа errno за пределами этой функции не существует.
  • Повтор при EINTR. Если системный вызов прервал сигнал, ядро возвращает EINTR, и вызов надо повторить. Обёртка делает это сама в while (true). В уроке про сигналы ты увидишь, что бывает с программой, которая про EINTR забыла.
  • Ошибки программиста это unreachable. EINVAL и EFAULT значат, что аргументы были заведомо плохими: невыровненный размер, указатель в никуда. Обёртка считает, что до неё такое не доходит, и в отладочной сборке падает с трассой стека. EBADF тоже сюда: закрытый дескриптор это ошибка в программе, поэтому он возвращается как error.Unexpected, а не как отдельная ошибка, которую кто-то стал бы ловить.
  • unexpectedErrno для всего остального. Код, которого в списке нет, превращается в error.Unexpected, а с включённым std.options.unexpected_error_tracing ещё и печатает номер и трассу стека. Это страховка от новой версии ядра, которая начала возвращать код, о котором обёртка не знала.

Три слоя рядом:

const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;

fn say(comptime fmt: []const u8, args: anytype) void {
    var buf: [128]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
    _ = linux.write(1, text.ptr, text.len);
}

pub fn main() void {
    // Слой std.os.linux: тонкая обёртка над регистрами. Число обратно.
    const rc = linux.write(9, "x", 1);
    say("linux.write(9): {d}, errno {t}\n", .{ @as(isize, @bitCast(rc)), linux.errno(rc) });

    if (linux.errno(rc) == .BADF) say("это EBADF, дескриптор закрыт\n", .{});

    // Слой std.posix: та же операция, но ошибка стала значением из error set.
    var buf: [8]u8 = undefined;
    const n = posix.read(9, &buf);
    if (n) |count| {
        say("posix.read прочитал {d}\n", .{count});
    } else |err| {
        say("posix.read: {t}\n", .{err});
    }

    // kill несуществующему процессу: ESRCH превращается в ProcessNotFound.
    posix.kill(2_000_000, .TERM) catch |err| say("posix.kill: {t}\n", .{err});
}
$ ./three_layers
linux.write(9): -9, errno BADF
это EBADF, дескриптор закрыт
posix.read: Unexpected
posix.kill: ProcessNotFound

В 0.16 набор std.posix заметно похудел: write, fork, execve, waitpid оттуда ушли, их место занял интерфейс std.Io, а голые вызовы остались в std.os.linux и std.c. Правило на блок вперёд: для переносимого кода std.posix и std.c, для Linux-специфики и для бинарников без libc std.os.linux, для учебных целей и там, где обёртки нет, asm volatile.

Сколько стоит сисколл

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

const std = @import("std");
const linux = std.os.linux;
const c = std.c;

const rounds = 1_000_000;

fn nowNs() u64 {
    var ts: linux.timespec = undefined;
    _ = linux.clock_gettime(.MONOTONIC, &ts);
    return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec));
}

fn say(comptime fmt: []const u8, args: anytype) void {
    var buf: [128]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
    _ = linux.write(1, text.ptr, text.len);
}

fn measure(name: []const u8, comptime body: fn () void) void {
    const start = nowNs();
    for (0..rounds) |_| body();
    const total = nowNs() - start;
    say("{s}: {d} нс за вызов\n", .{ name, total / rounds });
}

var sink: u64 = 0;

fn plainCall() void {
    sink +%= @intFromPtr(&sink);
}

fn rawGetpid() void {
    sink +%= linux.syscall0(.getpid);
}

fn rawClock() void {
    var ts: linux.timespec = undefined;
    sink +%= linux.syscall2(.clock_gettime, @intFromEnum(linux.CLOCK.MONOTONIC), @intFromPtr(&ts));
}

fn vdsoClock() void {
    var ts: c.timespec = undefined;
    _ = c.clock_gettime(.MONOTONIC, &ts);
    sink +%= @intCast(ts.nsec);
}

pub fn main() void {
    measure("обычный вызов функции", plainCall);
    measure("getpid через syscall", rawGetpid);
    measure("clock_gettime через syscall", rawClock);
    measure("clock_gettime через libc и vDSO", vdsoClock);
}
$ zig build-exe syscall_cost.zig -lc -O ReleaseFast
$ ./syscall_cost
обычный вызов функции: 0 нс за вызов
getpid через syscall: 94 нс за вызов
clock_gettime через syscall: 116 нс за вызов
clock_gettime через libc и vDSO: 11 нс за вызов

Apple M4 Max, Linux 7.0 в контейнере arm64. Самый дешёвый системный вызов, у которого внутри одна инструкция чтения поля структуры, стоит около ста наносекунд: это цена входа и выхода, а не работы. Для сравнения, на macOS на той же машине getpid через svc #0x80 стоит 70 нс, а c.getpid() из libSystem 1 нс, потому что libc там кэширует ответ.

Последняя строка интереснее. clock_gettime через libc в десять раз дешевле, чем тот же вызов через syscall, потому что в ядро он не ходит. Ядро отображает в каждый процесс крошечную библиотеку vDSO и рядом страницу с данными, куда само пишет текущее время. Функция из vDSO читает время из этой страницы обычными инструкциями. Это системный вызов без системного вызова: интерфейс тот же, ловушки нет. В уроке про карту памяти ты увидишь эти страницы в /proc/<pid>/maps под именами [vdso] и [vvar].

Отсюда практическое правило блока про ввод и вывод: не зови write на каждый байт. Буфер в 4 КБ превращает тысячи входов в ядро в один, и именно поэтому std.Io.Writer требует явный буфер и явный flush.

strace: граница под микроскопом

Всё, что программа делает с внешним миром, проходит через системные вызовы. Значит, если записать каждый вызов с аргументами и ответом, получится полный протокол её отношений с ядром: какие файлы она открыла, сколько байтов прочитала, кому послала сигнал и почему упала. Такой протокол пишет strace. Начнём с самой маленькой программы, какую можно придумать:

const std = @import("std");

pub fn main() void {
    const msg = "привет из статики\n";
    _ = std.os.linux.write(1, msg, msg.len);
}
$ zig build-exe hello.zig
$ strace ./hello
execve("./hello", ["./hello"], 0xffffdd3b5b80 /* 9 vars */) = 0
mmap(NULL, 262215, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xffffa1f03000
prlimit64(0, RLIMIT_STACK, NULL, {rlim_cur=8192*1024, rlim_max=RLIM64_INFINITY}) = 0
prlimit64(0, RLIMIT_STACK, {rlim_cur=16384*1024, rlim_max=RLIM64_INFINITY}, NULL) = 0
sigaltstack({ss_sp=0xffffa1f03040, ss_flags=0, ss_size=262144}, NULL) = 0
rt_sigaction(SIGSEGV, {sa_handler=0x114dfbc, sa_mask=[], sa_flags=SA_RESTORER|SA_ONSTACK|SA_RESTART|SA_RESETHAND|SA_SIGINFO, sa_restorer=0x1057338}, NULL, 8) = 0
rt_sigaction(SIGILL, {sa_handler=0x114dfbc, ...}, NULL, 8) = 0
rt_sigaction(SIGBUS, {sa_handler=0x114dfbc, ...}, NULL, 8) = 0
rt_sigaction(SIGFPE, {sa_handler=0x114dfbc, ...}, NULL, 8) = 0
write(1, "\320\277\321\200\320\270\320\262\320\265\321\202 \320\270\320\267 \321\201\321\202\320\260\321\202\320\270\320\272\320\270"..., 33) = 33
exit_group(0)                           = ?
привет из статики
+++ exited with 0 +++

Снято в том же контейнере, strace 6.1 из пакетов Debian; три длинные строки rt_sigaction я сократил многоточием. Одна строка write в исходнике, одиннадцать вызовов в трассе. Пройдём по ним сверху вниз, потому что каждый что-то рассказывает.

  • execve это последний вызов, который сделал ещё strace: он запустил программу, и трасса начинается с выхода из этого вызова. Правая часть = 0 значит, что образ заменён и дальше работает уже hello.
  • mmap на 256 КБ с хвостиком это стартовый код Zig. Он берёт у ядра кусок памяти, и в нём живёт запасной стек для обработчика сигналов: его адрес ты видишь строкой ниже, в sigaltstack.
  • Два prlimit64 это тоже старт Zig: спросить мягкий предел стека (8 МБ), поднять его до 16 МБ. Программе на Zig по умолчанию нужен стек побольше, чем даёт Linux.
  • Четыре rt_sigaction ставят обработчики на SIGSEGV, SIGILL, SIGBUS и SIGFPE. Это тот самый обработчик, который печатает трассу стека при падении. В программе faults выше мы его выключали через enable_segfault_handler = false, чтобы увидеть голое поведение ядра.
  • write и exit_group это уже наша программа. main вернулась, стартовый код позвал exit_group, и из этого вызова процесс не возвращается: отсюда знак вопроса вместо результата.

Строка «привет из статики» стоит после exit_group, а не рядом с write, только потому, что вывод снят из журнала контейнера: stdout программы и stderr трассы приходят туда разными потоками. В терминале строка встала бы внутрь строки write, как в следующем примере.

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

$ strace -e trace=openat,read,write cat /etc/hostname
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/aarch64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0\267\0\1\0\0\0000y\2\0\0\0\0\0"..., 832) = 832
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
read(3, "3ef8ed8f37c3\n", 131072)       = 13
write(1, "3ef8ed8f37c3\n", 133ef8ed8f37c3
)          = 13
read(3, "", 131072)                     = 0
+++ exited with 0 +++

Флаг -e trace= оставляет только перечисленные вызовы. Первые три строки это работа загрузчика ld.so ещё до main: он нашёл libc через кэш и прочитал 832 байта, заголовок ELF и таблицу заголовков программы (узнаёшь \177ELF из урока 42?). Дальше cat открыл файл, прочитал его буфером в 128 КБ, записал и прочитал ещё раз, получив ноль: это конец файла. Здесь вывод программы попал точно между половинами строки write: strace печатает левую половину до входа в вызов, а правую после выхода, и в промежутке cat успел напечатать своё.

Для обзора вместо протокола есть флаг -c: только счётчики.

$ strace -c ls / > /dev/null
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
  0.00    0.000000           0         2         2 ioctl
  0.00    0.000000           0         2         2 statfs
  0.00    0.000000           0         2         2 faccessat
  0.00    0.000000           0         6           openat
  0.00    0.000000           0         8           close
  0.00    0.000000           0         2           getdents64
  0.00    0.000000           0         5           read
  0.00    0.000000           0         1           write
  0.00    0.000000           0         7           newfstatat
  0.00    0.000000           0         1           set_tid_address
  0.00    0.000000           0         1           set_robust_list
  0.00    0.000000           0         3           brk
  0.00    0.000000           0         7           munmap
  0.00    0.000000           0         1           execve
  0.00    0.000000           0        14           mmap
  0.00    0.000000           0         8           mprotect
  0.00    0.000000           0         1           prlimit64
  0.00    0.000000           0         1           getrandom
  0.00    0.000000           0         1           statx
  0.00    0.000000           0         1           rseq
------ ----------- ----------- --------- --------- ----------------
100.00    0.000000           0        74         6 total

Семьдесят четыре вызова, чтобы вывести содержимое одного каталога, и из них собственно работа это openat на /, два getdents64 и один write. Всё остальное загрузчик и libc: mmap и mprotect раскладывают три библиотеки, ошибки ENOENT и ENOTTY ожидаемые (нет SELinux, вывод идёт не в терминал). Время по нулям, потому что вызовы короче разрешения таймера strace.

Последний полезный флаг, -f, следует за потомками. Оболочка запускает ls так, как ты разбирал в уроке про процессы:

$ strace -f -e trace=clone,execve,wait4,exit_group sh -c 'ls / > /dev/null; exit 3'
execve("/usr/bin/sh", ["sh", "-c", "ls / > /dev/null; exit 3"], 0xffffdf56fec8 /* 9 vars */) = 0
clone(child_stack=0xffffd3dc0c60, flags=CLONE_VM|CLONE_VFORK|SIGCHLDstrace: Process 509 attached
 <unfinished ...>
[pid   509] execve("/usr/bin/ls", ["ls", "/"], 0xaaaaec18d668 /* 9 vars */ <unfinished ...>
[pid   508] <... clone resumed>)        = 509
[pid   508] wait4(-1,  <unfinished ...>
[pid   509] <... execve resumed>)       = 0
[pid   509] exit_group(0)               = ?
[pid   509] +++ exited with 0 +++
<... wait4 resumed>[{WIFEXITED(s) && WEXITSTATUS(s) == 0}], 0, NULL) = 509
--- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=509, si_uid=0, si_status=0, si_utime=0, si_stime=0} ---
wait4(-1, 0xffffd3dc0b8c, WNOHANG, NULL) = -1 ECHILD (No child processes)
exit_group(3)                           = ?
+++ exited with 3 +++

fork в трассе нет: на aarch64 такого вызова не существует вовсе, его роль играет clone, а dash вдобавок просит CLONE_VFORK, то есть родитель спит, пока ребёнок не сделает execve. Строки unfinished и resumed появляются, когда вызов одного процесса начался, а закончиться не успел, потому что ядро переключилось на другой. Сигнал SIGCHLD пришёл оболочке после смерти ребёнка, и она на всякий случай спросила ещё раз с WNOHANG: вдруг умер кто-то ещё. Этот приём ты разберёшь в уроке про сигналы.

На чём это стоит

У strace нет доступа к исходникам и символам программы, и он ей не нужен. Всю работу делает ядро через системный вызов ptrace. Схема такая. Процесс-трассировщик делает fork, ребёнок объявляет «трассируйте меня» и делает execve. С этого момента ядро на каждом входе ребёнка в системный вызов и на каждом выходе из него останавливает ребёнка и будит родителя, который ждёт в waitpid. Родитель читает регистры ребёнка, печатает строку и говорит «беги до следующего вызова».

Получается, что каждый системный вызов под strace обходится в две лишние остановки, а каждая остановка это переключение на трассировщик и обратно. Замерим на find по трём каталогам (4850 вызовов, пять прогонов подряд, числа в миллисекундах):

без трассировки   2  2  2  2  2
strace -c        23 22 22 23 22
zt strace -c     21 20 20 20 20

Около четырёх микросекунд на вызов сверху, в сорок раз больше, чем стоит сам getpid. Поэтому strace хорош для вопроса «что программа делает», но не для замера того, «сколько это стоит»: под ним всё медленнее в десять раз, а шумные по системным вызовам места ещё сильнее. Третья строка это наш собственный инструмент, к нему и переходим.

Проект zt: подкоманда strace

В прошлом уроке zt ltrace подсаживал в процесс библиотеку через LD_PRELOAD и видел только вызовы, которые идут через PLT. Статическая программа или прямой syscall были для него невидимы. Сегодняшний инструмент стоит на ступеньку ниже: не между программой и библиотекой, а между программой и ядром. Ему всё равно, как собрана программа, на каком языке и есть ли в ней libc.

zt strace [-c] <программа> [аргументы...]

Строка трассы как у настоящего strace, только проще: аргументы-строки читаются из памяти потомка, флаги печатаются числами, структуры адресами. Трасса идёт в stderr, код выхода zt это код выхода программы.

Код разложен на две половины, как в ltrace. Чистая половина (таблицы вызовов, формат строки, разбор регистров и слова состояния, итог -c) проверяется тестами на любой машине, в том числе на macOS. Живая половина, tracer.zig, собирается только на Linux, x86-64 или aarch64.

src/strace.zig            подкоманда: поиск программы по PATH, argv для execve
src/strace/syscalls.zig   номер в имя, как печатать аргументы, errno в имя
src/strace/regs.zig       раскладка регистров x86-64 и aarch64, слово состояния waitpid
src/strace/summary.zig    итог -c
src/strace/tracer.zig     fork, PTRACE_TRACEME, цикл PTRACE_SYSCALL (только Linux)

Как ходит трассировщик

Прежде чем читать код, пройдём по времени, кто что делает.

zt (родитель)                               потомок
─────────────                               ───────
fork                                        ptrace(PTRACE_TRACEME)
                                            kill(getpid(), SIGSTOP)   встал
waitpid: остановка SIGSTOP
ptrace(PTRACE_SETOPTIONS)
ptrace(PTRACE_SYSCALL)       ─────────────▶ execve(путь, argv, envp)  вход, встал
waitpid: вход в вызов, читаем номер,
  аргументы и строку пути из его памяти
ptrace(PTRACE_SYSCALL)       ─────────────▶ ядро заменило образ, встал на событии EXEC
waitpid: событие, пропускаем
ptrace(PTRACE_SYSCALL)       ─────────────▶ выход из execve, встал
waitpid: выход, в регистре результат 0
ptrace(PTRACE_SYSCALL)       ─────────────▶ ... и так до exit_group
waitpid: процесс завершился

Три детали, без которых не работает.

Встреча через SIGSTOP. Родитель должен выставить опции до того, как потомок сделает execve, иначе первые вызовы проскочат без трассы. Поэтому потомок останавливает себя сам, а родитель ловит эту остановку в waitpid. Вызов kill в трассу не попадает: ядро начинает останавливать потомка на вызовах только после первого PTRACE_SYSCALL.

Вход и выход приходят одинаково. Остановка на входе и на выходе выглядят в waitpid одним и тем же словом состояния, различать их трассировщик обязан сам: они строго чередуются. Хуже того, на aarch64 регистр x0 на входе это первый аргумент, а на выходе уже результат. Поэтому аргументы запоминаются на входе, а на выходе из регистров берётся только ответ.

Сигналы потомка идут через трассировщика. Пока процесс трассируется, любой сигнал для него сначала останавливает его и будит родителя. Если родитель не передаст сигнал дальше аргументом следующего PTRACE_SYSCALL, сигнал пропадёт. Трассировщик, который забыл об этом, делает программу бессмертной для Ctrl+C.

src/strace/syscalls.zig

Номер вызова ничего не говорит человеку, поэтому первое, что нужно трассировщику, это таблица номеров. Перепечатывать её не придётся: в std.os.linux.syscalls она уже лежит в виде перечислений, по одному на архитектуру. Вторая таблица, как печатать аргументы, своя. Её ключ имя, а не номер, поэтому она общая для обеих архитектур.

//! Чистая половина `zt strace`: таблицы системных вызовов и формат строки
//! трассы. Здесь нет ни ptrace, ни Linux, поэтому эта часть проверяется
//! тестами на любой машине.
//!
//! Ядро видит системный вызов как семь чисел: номер и до шести аргументов.
//! Что эти числа значат, знают только таблицы. Их две. Первая переводит
//! номер в имя, и у каждой архитектуры она своя: `write` это 1 на x86-64 и
//! 64 на aarch64. Вторая по имени говорит, как читать аргументы: у `write`
//! второй аргумент это адрес буфера, а у `openat` адрес строки с путём.
//! Она от архитектуры не зависит.

const std = @import("std");
const builtin = @import("builtin");

const linux = std.os.linux;

pub const Arch = enum {
    x86_64,
    aarch64,

    /// Архитектура, под которую собран `zt`. Трассировать он умеет только
    /// программы своей архитектуры: раскладка регистров у ptrace своя у каждой.
    pub const native: ?Arch = switch (builtin.cpu.arch) {
        .x86_64 => .x86_64,
        .aarch64 => .aarch64,
        else => null,
    };
};

/// Имя вызова по номеру. Сами таблицы (`arch/x86/entry/syscalls/syscall_64.tbl`
/// и `include/uapi/asm-generic/unistd.h` из исходников ядра) уже лежат в std
/// в виде enum, по одному на архитектуру: перепечатывать четыреста строк незачем.
pub fn name(arch: Arch, number: u64) ?[]const u8 {
    return switch (arch) {
        .x86_64 => tagName(linux.syscalls.X64, number),
        .aarch64 => tagName(linux.syscalls.Arm64, number),
    };
}

fn tagName(comptime Table: type, number: u64) ?[]const u8 {
    const tag = std.enums.fromInt(Table, number) orelse return null;
    return @tagName(tag);
}

/// Как печатать аргумент.
pub const Arg = enum {
    /// Целое со знаком, десятичное.
    int,
    /// Флаги и маски, шестнадцатеричное.
    hex,
    /// Адрес в памяти трассируемого процесса. Нулевой печатается как `NULL`.
    pointer,
    /// Адрес строки C. Если строку удалось прочитать, печатаем её в кавычках.
    string,
    /// Адрес буфера, длина которого лежит в следующем аргументе (`write`).
    /// Прочитанные байты печатаем как строку.
    buffer,
    /// Файловый дескриптор. Особое значение -100 это `AT_FDCWD`.
    fd,
};

pub const Signature = struct {
    args: []const Arg,
    /// Вызов возвращает адрес (`mmap`, `brk`): результат печатаем в hex.
    returns_pointer: bool = false,
};

pub const max_args = 6;

/// Как читать аргументы вызова. Не все четыреста вызовов, а те, что
/// встречаются у обычной программы с libc. Остальные печатаются шестью
/// регистрами как есть.
pub fn signature(call: []const u8) ?Signature {
    return signatures.get(call);
}

const signatures: std.StaticStringMap(Signature) = .initComptime(.{
    .{ "read", Signature{ .args = &.{ .fd, .pointer, .int } } },
    .{ "write", Signature{ .args = &.{ .fd, .buffer, .int } } },
    .{ "open", Signature{ .args = &.{ .string, .hex, .hex } } },
    .{ "openat", Signature{ .args = &.{ .fd, .string, .hex, .hex } } },
    .{ "close", Signature{ .args = &.{.fd} } },
    .{ "stat", Signature{ .args = &.{ .string, .pointer } } },
    .{ "fstat", Signature{ .args = &.{ .fd, .pointer } } },
    .{ "newfstatat", Signature{ .args = &.{ .fd, .string, .pointer, .hex } } },
    // В таблице aarch64 тот же вызов записан под старым именем.
    .{ "fstatat64", Signature{ .args = &.{ .fd, .string, .pointer, .hex } } },
    .{ "statx", Signature{ .args = &.{ .fd, .string, .hex, .hex, .pointer } } },
    .{ "lseek", Signature{ .args = &.{ .fd, .int, .int } } },
    .{ "mmap", Signature{ .args = &.{ .pointer, .int, .hex, .hex, .fd, .hex }, .returns_pointer = true } },
    .{ "mprotect", Signature{ .args = &.{ .pointer, .int, .hex } } },
    .{ "munmap", Signature{ .args = &.{ .pointer, .int } } },
    .{ "brk", Signature{ .args = &.{.pointer}, .returns_pointer = true } },
    .{ "rt_sigaction", Signature{ .args = &.{ .int, .pointer, .pointer, .int } } },
    .{ "rt_sigprocmask", Signature{ .args = &.{ .int, .pointer, .pointer, .int } } },
    .{ "ioctl", Signature{ .args = &.{ .fd, .hex, .hex } } },
    .{ "pread64", Signature{ .args = &.{ .fd, .pointer, .int, .int } } },
    .{ "writev", Signature{ .args = &.{ .fd, .pointer, .int } } },
    .{ "access", Signature{ .args = &.{ .string, .hex } } },
    .{ "faccessat", Signature{ .args = &.{ .fd, .string, .hex } } },
    .{ "pipe", Signature{ .args = &.{.pointer} } },
    .{ "pipe2", Signature{ .args = &.{ .pointer, .hex } } },
    .{ "dup", Signature{ .args = &.{.fd} } },
    .{ "dup2", Signature{ .args = &.{ .fd, .fd } } },
    .{ "dup3", Signature{ .args = &.{ .fd, .fd, .hex } } },
    .{ "nanosleep", Signature{ .args = &.{ .pointer, .pointer } } },
    .{ "clock_nanosleep", Signature{ .args = &.{ .int, .hex, .pointer, .pointer } } },
    .{ "getpid", Signature{ .args = &.{} } },
    .{ "getppid", Signature{ .args = &.{} } },
    .{ "gettid", Signature{ .args = &.{} } },
    .{ "getuid", Signature{ .args = &.{} } },
    .{ "geteuid", Signature{ .args = &.{} } },
    .{ "getgid", Signature{ .args = &.{} } },
    .{ "getegid", Signature{ .args = &.{} } },
    .{ "clone", Signature{ .args = &.{ .hex, .pointer, .pointer, .pointer, .hex } } },
    .{ "fork", Signature{ .args = &.{} } },
    .{ "execve", Signature{ .args = &.{ .string, .pointer, .pointer } } },
    .{ "exit", Signature{ .args = &.{.int} } },
    .{ "exit_group", Signature{ .args = &.{.int} } },
    .{ "wait4", Signature{ .args = &.{ .int, .pointer, .hex, .pointer } } },
    .{ "kill", Signature{ .args = &.{ .int, .int } } },
    .{ "uname", Signature{ .args = &.{.pointer} } },
    .{ "fcntl", Signature{ .args = &.{ .fd, .int, .hex } } },
    .{ "getcwd", Signature{ .args = &.{ .pointer, .int } } },
    .{ "chdir", Signature{ .args = &.{.string} } },
    .{ "mkdir", Signature{ .args = &.{ .string, .hex } } },
    .{ "mkdirat", Signature{ .args = &.{ .fd, .string, .hex } } },
    .{ "unlink", Signature{ .args = &.{.string} } },
    .{ "unlinkat", Signature{ .args = &.{ .fd, .string, .hex } } },
    .{ "readlink", Signature{ .args = &.{ .string, .pointer, .int } } },
    .{ "readlinkat", Signature{ .args = &.{ .fd, .string, .pointer, .int } } },
    .{ "arch_prctl", Signature{ .args = &.{ .hex, .pointer } } },
    .{ "futex", Signature{ .args = &.{ .pointer, .int, .int, .pointer, .pointer, .int } } },
    .{ "getdents64", Signature{ .args = &.{ .fd, .pointer, .int } } },
    .{ "set_tid_address", Signature{ .args = &.{.pointer} } },
    .{ "clock_gettime", Signature{ .args = &.{ .int, .pointer } } },
    .{ "set_robust_list", Signature{ .args = &.{ .pointer, .int } } },
    .{ "prlimit64", Signature{ .args = &.{ .int, .int, .pointer, .pointer } } },
    .{ "getrandom", Signature{ .args = &.{ .pointer, .int, .hex } } },
    .{ "rseq", Signature{ .args = &.{ .pointer, .int, .hex, .hex } } },
});

/// Вызовы, из которых процесс не возвращается: строки выхода у них не будет.
pub fn neverReturns(call: []const u8) bool {
    return std.mem.eql(u8, call, "exit") or std.mem.eql(u8, call, "exit_group");
}

const at_fdcwd: i32 = -100;

/// Длиннее строки в трассе не показываем: хвост заменяется многоточием.
/// Трассировщик читает из памяти процесса на байт больше, и по этому
/// лишнему байту видно, что строку обрезали.
pub const string_limit = 32;

/// Левая половина строки трассы: `openat(AT_FDCWD, "/etc/passwd", 0x0, 0x0)`.
/// `texts[i]` это уже прочитанные из памяти процесса байты аргумента `i`
/// (строка или буфер) или `null`, если читать было нечего или не получилось.
pub fn writeEntry(
    out: *std.Io.Writer,
    arch: Arch,
    number: u64,
    args: [max_args]u64,
    texts: [max_args]?[]const u8,
) !void {
    const call = name(arch, number) orelse {
        // Номера нет в таблице ядра: номер и все шесть регистров как есть.
        try out.print("syscall_{d}", .{number});
        return writeRaw(out, args);
    };
    try out.writeAll(call);
    const known = signature(call) orelse return writeRaw(out, args);

    try out.writeByte('(');
    for (known.args, 0..) |kind, position| {
        if (position > 0) try out.writeAll(", ");
        try writeArg(out, kind, args[position], texts[position]);
    }
    try out.writeByte(')');
}

fn writeRaw(out: *std.Io.Writer, args: [max_args]u64) !void {
    try out.writeByte('(');
    for (args, 0..) |value, position| {
        if (position > 0) try out.writeAll(", ");
        try out.print("0x{x}", .{value});
    }
    try out.writeByte(')');
}

fn writeArg(out: *std.Io.Writer, kind: Arg, value: u64, text: ?[]const u8) !void {
    switch (kind) {
        .int => try out.print("{d}", .{@as(i64, @bitCast(value))}),
        .hex => try out.print("0x{x}", .{value}),
        .pointer => if (value == 0) try out.writeAll("NULL") else try out.print("0x{x}", .{value}),
        .fd => {
            // Дескриптор это int: ядро смотрит только на младшие 32 бита регистра.
            const fd: i32 = @bitCast(@as(u32, @truncate(value)));
            if (fd == at_fdcwd) try out.writeAll("AT_FDCWD") else try out.print("{d}", .{fd});
        },
        .string, .buffer => {
            const bytes = text orelse return writeArg(out, .pointer, value, null);
            try out.writeByte('"');
            try writeEscaped(out, bytes[0..@min(bytes.len, string_limit)]);
            try out.writeByte('"');
            if (bytes.len > string_limit) try out.writeAll("...");
        },
    }
}

/// Байты буфера не обязаны быть текстом: перевод строки и кавычки
/// экранируем, остальное непечатное показываем кодом.
fn writeEscaped(out: *std.Io.Writer, bytes: []const u8) !void {
    for (bytes) |byte| switch (byte) {
        '\n' => try out.writeAll("\\n"),
        '\t' => try out.writeAll("\\t"),
        '\r' => try out.writeAll("\\r"),
        '"' => try out.writeAll("\\\""),
        '\\' => try out.writeAll("\\\\"),
        // Байты от 0x80 не трогаем: так путь в UTF-8 остаётся читаемым.
        0...8, 11, 12, 14...31, 127 => try out.print("\\x{x:0>2}", .{byte}),
        else => try out.writeByte(byte),
    };
}

/// Правая половина: ` = 3` или ` = -1 ENOENT (No such file or directory)`.
///
/// Ядро сообщает об ошибке отрицательным числом в регистре результата:
/// значения от -4095 до -1 это код ошибки с минусом. Привычные `-1` и
/// `errno` делает уже libc.
pub fn writeReturn(out: *std.Io.Writer, arch: Arch, number: u64, value: u64) !void {
    if (errorCode(value)) |code| {
        if (errorName(code)) |known| {
            return out.print(" = -1 {s} ({s})", .{ known.name, known.text });
        }
        return out.print(" = -1 errno {d}", .{code});
    }

    const known = if (name(arch, number)) |call| signature(call) else null;
    const pointer = if (known) |s| s.returns_pointer else false;
    if (pointer) try out.print(" = 0x{x}", .{value}) else try out.print(" = {d}", .{@as(i64, @bitCast(value))});
}

/// Код ошибки из регистра результата или `null`, если вызов удался.
pub fn errorCode(value: u64) ?u16 {
    const signed: i64 = @bitCast(value);
    if (signed < 0 and signed >= -4095) return @intCast(-signed);
    return null;
}

pub const ErrorName = struct { name: []const u8, text: []const u8 };

/// Коды ошибок Linux: числа общие для x86-64 и aarch64, тексты из `strerror`.
pub fn errorName(code: u16) ?ErrorName {
    return switch (code) {
        1 => .{ .name = "EPERM", .text = "Operation not permitted" },
        2 => .{ .name = "ENOENT", .text = "No such file or directory" },
        3 => .{ .name = "ESRCH", .text = "No such process" },
        4 => .{ .name = "EINTR", .text = "Interrupted system call" },
        5 => .{ .name = "EIO", .text = "Input/output error" },
        9 => .{ .name = "EBADF", .text = "Bad file descriptor" },
        10 => .{ .name = "ECHILD", .text = "No child processes" },
        11 => .{ .name = "EAGAIN", .text = "Resource temporarily unavailable" },
        12 => .{ .name = "ENOMEM", .text = "Cannot allocate memory" },
        13 => .{ .name = "EACCES", .text = "Permission denied" },
        14 => .{ .name = "EFAULT", .text = "Bad address" },
        17 => .{ .name = "EEXIST", .text = "File exists" },
        20 => .{ .name = "ENOTDIR", .text = "Not a directory" },
        21 => .{ .name = "EISDIR", .text = "Is a directory" },
        22 => .{ .name = "EINVAL", .text = "Invalid argument" },
        25 => .{ .name = "ENOTTY", .text = "Inappropriate ioctl for device" },
        28 => .{ .name = "ENOSPC", .text = "No space left on device" },
        32 => .{ .name = "EPIPE", .text = "Broken pipe" },
        38 => .{ .name = "ENOSYS", .text = "Function not implemented" },
        else => null,
    };
}

/// Целая строка трассы для вызова, который вернулся.
pub fn formatLine(
    buffer: []u8,
    arch: Arch,
    number: u64,
    args: [max_args]u64,
    texts: [max_args]?[]const u8,
    result: u64,
) []const u8 {
    var out: std.Io.Writer = .fixed(buffer);
    writeEntry(&out, arch, number, args, texts) catch {};
    writeReturn(&out, arch, number, result) catch {};
    out.writeByte('\n') catch {};
    return out.buffered();
}

pub const no_texts = [_]?[]const u8{null} ** max_args;

test "один вызов, два номера" {
    try std.testing.expectEqualStrings("write", name(.x86_64, 1).?);
    try std.testing.expectEqualStrings("write", name(.aarch64, 64).?);
    // На aarch64 нет старых вызовов без суффикса at: open там нет вовсе.
    try std.testing.expectEqualStrings("open", name(.x86_64, 2).?);
    try std.testing.expectEqualStrings("openat", name(.aarch64, 56).?);
    try std.testing.expectEqual(@as(?[]const u8, null), name(.x86_64, 9999));
}

test "write: дескриптор, буфер, длина" {
    var buffer: [128]u8 = undefined;
    var texts = no_texts;
    texts[1] = "hello, zt\n";
    try std.testing.expectEqualStrings(
        "write(1, \"hello, zt\\n\", 10) = 10\n",
        formatLine(&buffer, .x86_64, 1, .{ 1, 0x7ffc1000, 10, 0, 0, 0 }, texts, 10),
    );
    // Буфер прочитать не удалось: остаётся адрес.
    try std.testing.expectEqualStrings(
        "write(1, 0x7ffc1000, 10) = 10\n",
        formatLine(&buffer, .aarch64, 64, .{ 1, 0x7ffc1000, 10, 0, 0, 0 }, no_texts, 10),
    );
}

test "openat: AT_FDCWD, строка и ошибка ENOENT" {
    var buffer: [160]u8 = undefined;
    var texts = no_texts;
    texts[1] = "/etc/нет";
    const at_fdcwd_raw: u64 = @bitCast(@as(i64, at_fdcwd));
    try std.testing.expectEqualStrings(
        "openat(AT_FDCWD, \"/etc/нет\", 0x80000, 0x0) = -1 ENOENT (No such file or directory)\n",
        formatLine(&buffer, .x86_64, 257, .{ at_fdcwd_raw, 0x1000, 0x80000, 0, 0, 0 }, texts, @bitCast(@as(i64, -2))),
    );
}

test "mmap возвращает адрес, а не число" {
    var buffer: [160]u8 = undefined;
    const minus_one: u64 = @bitCast(@as(i64, -1));
    try std.testing.expectEqualStrings(
        "mmap(NULL, 8192, 0x3, 0x22, -1, 0x0) = 0x7f0000001000\n",
        formatLine(&buffer, .x86_64, 9, .{ 0, 8192, 3, 0x22, minus_one, 0 }, no_texts, 0x7f0000001000),
    );
}

test "вызов без номера и вызов без сигнатуры печатаются шестью регистрами" {
    var buffer: [160]u8 = undefined;
    try std.testing.expectEqualStrings(
        "syscall_9999(0x1, 0x2, 0x3, 0x4, 0x5, 0x6) = 0\n",
        formatLine(&buffer, .x86_64, 9999, .{ 1, 2, 3, 4, 5, 6 }, no_texts, 0),
    );
    // 41 это socket: имя ядро знает, а как читать аргументы, мы не описали.
    try std.testing.expectEqualStrings(
        "socket(0x2, 0x1, 0x0, 0x0, 0x0, 0x0) = 3\n",
        formatLine(&buffer, .x86_64, 41, .{ 2, 1, 0, 0, 0, 0 }, no_texts, 3),
    );
}

test "длинная строка обрезается, непрочитанная печатается адресом" {
    var buffer: [160]u8 = undefined;
    var texts = no_texts;
    texts[0] = "/очень/длинный/путь/к/файлу/которого/нет";
    const line = formatLine(&buffer, .x86_64, 87, .{ 0x1000, 0, 0, 0, 0, 0 }, texts, 0);
    try std.testing.expect(std.mem.endsWith(u8, line, "\"...) = 0\n"));

    try std.testing.expectEqualStrings(
        "unlink(0x1000) = 0\n",
        formatLine(&buffer, .x86_64, 87, .{ 0x1000, 0, 0, 0, 0, 0 }, no_texts, 0),
    );
}

test "непечатные байты буфера" {
    var buffer: [160]u8 = undefined;
    var texts = no_texts;
    texts[1] = "a\"b\\\x00\x1b[0m";
    try std.testing.expectEqualStrings(
        "write(2, \"a\\\"b\\\\\\x00\\x1b[0m\", 9) = 9\n",
        formatLine(&buffer, .x86_64, 1, .{ 2, 0x1000, 9, 0, 0, 0 }, texts, 9),
    );
}

test "граница кодов ошибок: -4095 это ошибка, -4096 уже адрес" {
    var buffer: [64]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    try writeReturn(&out, .x86_64, 9, @bitCast(@as(i64, -4096)));
    try std.testing.expectEqualStrings(" = 0xfffffffffffff000", out.buffered());

    out = .fixed(&buffer);
    try writeReturn(&out, .x86_64, 9, @bitCast(@as(i64, -4095)));
    try std.testing.expectEqualStrings(" = -1 errno 4095", out.buffered());
}

Что стоит заметить.

  • name это две строки кода над std.enums.fromInt, которая превращает число в значение перечисления или в null, если такого номера нет. @tagName даёт имя значения. Вся таблица ядра приезжает бесплатно.
  • В таблице signatures описаны шестьдесят с небольшим вызовов, которые встречаются у обычной программы с libc. Вызов, которого там нет, печатается шестью регистрами как есть: имя известно, смысл аргументов нет. Так же поступает сам strace с экзотикой вроде нового вызова, который вышел после его релиза.
  • errorCode это ровно то правило из начала урока: значение от минус 4095 до минус 1 это ошибка, всё остальное ответ. Последний тест файла проверяет границу: -4096 уже адрес от mmap.
  • writeEscaped не трогает байты от 0x80, поэтому путь в UTF-8 остаётся читаемым. Настоящий strace осторожнее и печатает их восьмеричными кодами, как ты видел выше в строке write.
  • string_limit равен 32, как у strace по умолчанию. Трассировщик читает на байт больше, и по лишнему байту видно, что строку пришлось обрезать.

src/strace/regs.zig

Вторая чистая часть это то, что ядро отдаёт трассировщику: регистры остановленного процесса и слово состояния из waitpid.

//! Что ядро рассказывает трассировщику: регистры остановленного процесса и
//! слово состояния из `waitpid`. Разбор и того и другого это чистый код.
//!
//! Раскладка регистров у каждой архитектуры своя. Это `struct user_regs_struct`
//! из `<sys/user.h>`: ядро копирует её в наш буфер по запросу
//! `PTRACE_GETREGSET` с набором `NT_PRSTATUS`.

const std = @import("std");

const syscalls = @import("syscalls.zig");

pub const Arch = syscalls.Arch;

/// Системный вызов глазами ядра: номер и шесть аргументов.
pub const Call = struct {
    number: u64,
    args: [syscalls.max_args]u64,
};

/// x86-64: поля идут в том порядке, в каком ядро кладёт их на свой стек при
/// входе в системный вызов.
pub const X86_64 = extern struct {
    r15: u64,
    r14: u64,
    r13: u64,
    r12: u64,
    rbp: u64,
    rbx: u64,
    r11: u64,
    r10: u64,
    r9: u64,
    r8: u64,
    rax: u64,
    rcx: u64,
    rdx: u64,
    rsi: u64,
    rdi: u64,
    /// Номер вызова. В самом `rax` на входе уже лежит `-ENOSYS`: ядро
    /// заранее кладёт туда ответ на случай, если вызова с таким номером нет.
    orig_rax: u64,
    rip: u64,
    cs: u64,
    eflags: u64,
    rsp: u64,
    ss: u64,
    fs_base: u64,
    gs_base: u64,
    ds: u64,
    es: u64,
    fs: u64,
    gs: u64,

    /// Четвёртый аргумент в `r10`, а не в `rcx`, как у обычных функций:
    /// инструкция `syscall` сама затирает `rcx` адресом возврата.
    pub fn call(regs: X86_64) Call {
        return .{
            .number = regs.orig_rax,
            .args = .{ regs.rdi, regs.rsi, regs.rdx, regs.r10, regs.r8, regs.r9 },
        };
    }

    pub fn result(regs: X86_64) u64 {
        return regs.rax;
    }
};

/// aarch64: номер в `x8`, аргументы в `x0` до `x5`, результат снова в `x0`.
/// Поэтому аргументы надо запомнить на входе: на выходе первого уже нет.
pub const Aarch64 = extern struct {
    x: [31]u64,
    sp: u64,
    pc: u64,
    pstate: u64,

    pub fn call(regs: Aarch64) Call {
        return .{ .number = regs.x[8], .args = regs.x[0..6].* };
    }

    pub fn result(regs: Aarch64) u64 {
        return regs.x[0];
    }
};

comptime {
    std.debug.assert(@sizeOf(X86_64) == 27 * 8);
    std.debug.assert(@sizeOf(Aarch64) == 34 * 8);
}

pub fn Registers(comptime arch: Arch) type {
    return switch (arch) {
        .x86_64 => X86_64,
        .aarch64 => Aarch64,
    };
}

/// Почему `waitpid` вернул управление.
pub const Stop = union(enum) {
    /// Процесс завершился сам, внутри код возврата.
    exited: u8,
    /// Процесс убит сигналом.
    killed: u8,
    /// Остановка на входе в системный вызов или на выходе из него.
    syscall,
    /// Событие ptrace, например `PTRACE_EVENT_EXEC`. Сигнал процессу не нужен.
    event: u8,
    /// Процессу пришёл сигнал. Трассировщик обязан передать его дальше.
    signal: u8,
};

const sigtrap = 5;

/// Разбирает слово состояния. Младшие семь бит это сигнал, убивший процесс;
/// значение 0x7f в них значит «остановлен», и тогда сигнал остановки лежит
/// во втором байте, а номер события ptrace в третьем.
///
/// С опцией `PTRACE_O_TRACESYSGOOD` остановка на системном вызове приходит
/// как `SIGTRAP | 0x80`: так её не спутать с настоящим `SIGTRAP`.
pub fn decodeStatus(status: u32) Stop {
    const low: u8 = @truncate(status & 0x7f);
    if (low == 0) return .{ .exited = @truncate(status >> 8) };
    if (low != 0x7f) return .{ .killed = low };

    const stop_signal: u8 = @truncate(status >> 8);
    if (stop_signal == (sigtrap | 0x80)) return .syscall;
    const event: u8 = @truncate(status >> 16);
    if (event != 0) return .{ .event = event };
    return .{ .signal = stop_signal };
}

/// `SIGSEGV` по номеру. Номера сигналов у x86-64 и aarch64 общие.
pub fn signalName(buffer: []u8, number: u8) []const u8 {
    const names = [_][]const u8{
        "HUP",  "INT",    "QUIT", "ILL",   "TRAP", "ABRT", "BUS",  "FPE",
        "KILL", "USR1",   "SEGV", "USR2",  "PIPE", "ALRM", "TERM", "STKFLT",
        "CHLD", "CONT",   "STOP", "TSTP",  "TTIN", "TTOU", "URG",  "XCPU",
        "XFSZ", "VTALRM", "PROF", "WINCH", "IO",   "PWR",  "SYS",
    };
    if (number >= 1 and number <= names.len) {
        return std.fmt.bufPrint(buffer, "SIG{s}", .{names[number - 1]}) catch "SIG?";
    }
    return std.fmt.bufPrint(buffer, "SIG_{d}", .{number}) catch "SIG?";
}

test "x86-64: номер из orig_rax, четвёртый аргумент из r10" {
    var regs = std.mem.zeroes(X86_64);
    regs.orig_rax = 257;
    regs.rax = @bitCast(@as(i64, -38)); // -ENOSYS на входе
    regs.rdi = 1;
    regs.rsi = 2;
    regs.rdx = 3;
    regs.rcx = 0xdead; // адрес возврата, не аргумент
    regs.r10 = 4;
    regs.r8 = 5;
    regs.r9 = 6;
    const call = regs.call();
    try std.testing.expectEqual(257, call.number);
    try std.testing.expectEqualSlices(u64, &.{ 1, 2, 3, 4, 5, 6 }, &call.args);
    try std.testing.expectEqual(38, syscalls.errorCode(regs.result()).?);
}

test "aarch64: номер из x8, аргументы из x0..x5" {
    var regs = std.mem.zeroes(Aarch64);
    regs.x[8] = 64;
    for (0..6) |i| regs.x[i] = 10 + i;
    const call = regs.call();
    try std.testing.expectEqual(64, call.number);
    try std.testing.expectEqualSlices(u64, &.{ 10, 11, 12, 13, 14, 15 }, &call.args);
    try std.testing.expectEqual(10, regs.result());
}

test "слово состояния waitpid" {
    try std.testing.expectEqual(Stop{ .exited = 3 }, decodeStatus(0x0300));
    try std.testing.expectEqual(Stop{ .killed = 11 }, decodeStatus(0x000b));
    // Убит с дампом памяти: бит 0x80 рядом с номером сигнала.
    try std.testing.expectEqual(Stop{ .killed = 11 }, decodeStatus(0x008b));
    // Первая остановка после raise(SIGSTOP).
    try std.testing.expectEqual(Stop{ .signal = 19 }, decodeStatus(0x137f));
    try std.testing.expectEqual(Stop.syscall, decodeStatus(0x857f));
    // PTRACE_EVENT_EXEC это 4, сигнал при нём обычный SIGTRAP.
    try std.testing.expectEqual(Stop{ .event = 4 }, decodeStatus(0x4057f));
    try std.testing.expectEqual(Stop{ .signal = 5 }, decodeStatus(0x057f));
}

test "имя сигнала" {
    var buffer: [16]u8 = undefined;
    try std.testing.expectEqualStrings("SIGSEGV", signalName(&buffer, 11));
    try std.testing.expectEqualStrings("SIGPROF", signalName(&buffer, 27));
    try std.testing.expectEqualStrings("SIG_64", signalName(&buffer, 64));
}

X86_64 и Aarch64 это struct user_regs_struct из заголовков Linux, переписанная на Zig как extern struct, чтобы порядок и размер полей совпадали байт в байт. Проверка размеров в comptime стоит не для красоты: ошибись ты в одном поле, и все регистры после него сдвинутся на восемь байтов, а трасса покажет правдоподобную чушь.

Обрати внимание на orig_rax. На входе в системный вызов ядро x86-64 сразу кладёт в rax ответ -ENOSYS, на случай если номер окажется неизвестным. Сам номер переезжает в orig_rax. Это ровно то поведение, которое ты видел в raw_syscall на вызове с номером 100000: ответ -38 лежал в регистре ещё до того, как ядро посмотрело на номер.

decodeStatus разбирает то же слово состояния, что и waitpid в прошлом уроке, плюс остановки, которых обычный родитель не видит. Семь младших битов равны 0x7f: процесс остановлен, во втором байте сигнал остановки. С опцией PTRACE_O_TRACESYSGOOD остановка на системном вызове приходит как SIGTRAP | 0x80, и её не спутать с настоящим SIGTRAP от int3. В третьем байте номер события ptrace, у нас это только PTRACE_EVENT_EXEC.

src/strace/summary.zig

Итог -c это таблица «имя → счётчики» и сортировка.

//! Итог `zt strace -c`: сколько раз программа звала каждый системный вызов
//! и сколько из этих вызовов кончились ошибкой.

const std = @import("std");

pub const Row = struct {
    name: []const u8,
    calls: u64 = 0,
    errors: u64 = 0,

    /// Сначала самые частые, при равенстве по имени: так вывод не зависит
    /// от порядка, в котором вызовы встретились.
    fn before(_: void, a: Row, b: Row) bool {
        if (a.calls != b.calls) return a.calls > b.calls;
        return std.mem.order(u8, a.name, b.name) == .lt;
    }
};

pub const Summary = struct {
    rows: std.StringArrayHashMapUnmanaged(Row) = .empty,

    pub fn deinit(summary: *Summary, gpa: std.mem.Allocator) void {
        summary.rows.deinit(gpa);
    }

    /// `name` должно жить дольше таблицы: имена вызовов это константы.
    pub fn record(summary: *Summary, gpa: std.mem.Allocator, name: []const u8, failed: bool) !void {
        const entry = try summary.rows.getOrPutValue(gpa, name, .{ .name = name });
        entry.value_ptr.calls += 1;
        if (failed) entry.value_ptr.errors += 1;
    }

    pub fn print(summary: *Summary, out: *std.Io.Writer) !void {
        const rows = summary.rows.values();
        std.mem.sort(Row, rows, {}, Row.before);
        // После сортировки значений индекс таблицы не годится, но он больше
        // и не нужен: итог печатается один раз в самом конце.

        const rule = "---------- --------- ----------------\n";
        try out.writeAll("     calls    errors syscall\n" ++ rule);
        var calls: u64 = 0;
        var errors: u64 = 0;
        for (rows) |row| {
            calls += row.calls;
            errors += row.errors;
            try out.print("{d:>10} ", .{row.calls});
            if (row.errors > 0) try out.print("{d:>9}", .{row.errors}) else try out.splatByteAll(' ', 9);
            try out.print(" {s}\n", .{row.name});
        }
        try out.writeAll(rule);
        try out.print("{d:>10} ", .{calls});
        if (errors > 0) try out.print("{d:>9}", .{errors}) else try out.splatByteAll(' ', 9);
        try out.writeAll(" total\n");
    }
};

test "счётчики и порядок строк" {
    const gpa = std.testing.allocator;
    var summary: Summary = .{};
    defer summary.deinit(gpa);

    try summary.record(gpa, "openat", true);
    try summary.record(gpa, "write", false);
    try summary.record(gpa, "openat", false);
    try summary.record(gpa, "close", false);
    try summary.record(gpa, "write", false);
    try summary.record(gpa, "write", false);

    var buffer: [512]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    try summary.print(&out);
    try std.testing.expectEqualStrings(
        \\     calls    errors syscall
        \\---------- --------- ----------------
        \\         3           write
        \\         2         1 openat
        \\         1           close
        \\---------- --------- ----------------
        \\         6         1 total
        \\
    , out.buffered());
}

StringArrayHashMapUnmanaged хранит значения подряд в массиве, поэтому их можно отсортировать на месте через values(). После этого индекс таблицы испорчен, о чём честно предупреждает комментарий: печать последняя операция, искать по таблице больше никто не будет. Порядок строк задан полностью (сначала по числу вызовов, потом по имени), поэтому вывод не зависит от порядка вставки и тест может сравнить его целиком.

src/strace/tracer.zig

Теперь живая половина. Всё, что выше, превращало числа в текст, а здесь числа добываются.

//! Живая половина `zt strace`: `fork`, `PTRACE_TRACEME`, `execve` и цикл
//! `PTRACE_SYSCALL`. Только Linux на x86-64 или aarch64: на других системах
//! этот файл не собирается.
//!
//! Потомок просит ядро трассировать себя и останавливается. Дальше родитель
//! каждый раз говорит «беги до следующего системного вызова» и ждёт. Ядро
//! останавливает потомка дважды на каждый вызов: на входе, когда в регистрах
//! лежат номер и аргументы, и на выходе, когда там результат.

const std = @import("std");

const regs_mod = @import("regs.zig");
const summary_mod = @import("summary.zig");
const syscalls = @import("syscalls.zig");

const linux = std.os.linux;
const PTRACE = linux.PTRACE;

const arch = syscalls.Arch.native.?;
const Registers = regs_mod.Registers(arch);

/// Набор регистров общего назначения для `PTRACE_GETREGSET`, из `<elf.h>`.
const nt_prstatus = 1;

pub const Options = struct {
    /// Флаг `-c`: вместо строк трассы печатать итог со счётчиками.
    summary_only: bool = false,
};

pub const Error = error{ ForkFailed, PtraceFailed, WaitFailed, ForeignRegisters } || std.mem.Allocator.Error || std.Io.Writer.Error;

/// Запускает программу под трассировкой и пишет трассу в `out`.
/// Возвращает код, с которым `zt` должен выйти сам: код программы или
/// `128 + сигнал`, как это делает оболочка.
pub fn run(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    path: [*:0]const u8,
    argv: [*:null]const ?[*:0]const u8,
    envp: [*:null]const ?[*:0]const u8,
    options: Options,
) Error!u8 {
    const forked = linux.fork();
    if (linux.errno(forked) != .SUCCESS) return error.ForkFailed;
    if (forked == 0) child(path, argv, envp);
    const pid: linux.pid_t = @intCast(forked);

    // Первая остановка это SIGSTOP, который потомок послал себе сам.
    // Сигнал дальше не передаём: он был нужен только для встречи.
    var status: u32 = 0;
    if (linux.errno(linux.waitpid(pid, &status, 0)) != .SUCCESS) return error.WaitFailed;
    // EXITKILL: если zt умрёт, ядро убьёт и потомка, а не оставит его висеть.
    // TRACEEXEC: после execve придёт событие, а не голый SIGTRAP.
    const wanted = PTRACE.O.TRACESYSGOOD | PTRACE.O.TRACEEXEC | PTRACE.O.EXITKILL;
    try request(PTRACE.SETOPTIONS, pid, 0, wanted);

    var summary: summary_mod.Summary = .{};
    defer summary.deinit(gpa);

    // Вызов, в который потомок вошёл и из которого ещё не вышел.
    var entered: ?regs_mod.Call = null;
    var pending_signal: usize = 0;

    while (true) {
        try request(PTRACE.SYSCALL, pid, 0, pending_signal);
        pending_signal = 0;
        if (linux.errno(linux.waitpid(pid, &status, 0)) != .SUCCESS) return error.WaitFailed;

        switch (regs_mod.decodeStatus(status)) {
            .syscall => {
                const registers = try readRegisters(pid);
                if (entered) |call| {
                    entered = null;
                    try onExit(gpa, out, &summary, options, call, registers.result());
                } else {
                    entered = registers.call();
                    if (!options.summary_only) try writeEntry(out, pid, entered.?);
                }
            },
            .event => {},
            .signal => |number| {
                var buffer: [16]u8 = undefined;
                if (!options.summary_only) {
                    try out.print("--- {s} ---\n", .{regs_mod.signalName(&buffer, number)});
                    try out.flush();
                }
                pending_signal = number;
            },
            .exited => |code| {
                try onGone(gpa, out, &summary, options, entered);
                try out.print("+++ exited with {d} +++\n", .{code});
                return finish(out, &summary, options, code);
            },
            .killed => |number| {
                var buffer: [16]u8 = undefined;
                try onGone(gpa, out, &summary, options, entered);
                try out.print("+++ killed by {s} +++\n", .{regs_mod.signalName(&buffer, number)});
                return finish(out, &summary, options, 128 +| number);
            },
        }
    }
}

/// Код потомка между `fork` и `execve`. Здесь только системные вызовы
/// напрямую: после `fork` в копии процесса нельзя трогать ни аллокатор,
/// ни `std.Io`.
fn child(
    path: [*:0]const u8,
    argv: [*:null]const ?[*:0]const u8,
    envp: [*:null]const ?[*:0]const u8,
) noreturn {
    _ = linux.ptrace(PTRACE.TRACEME, 0, 0, 0, 0);
    // Останавливаемся и ждём, пока родитель выставит опции. Иначе execve
    // проскочит раньше, чем трассировщик будет готов.
    _ = linux.kill(linux.getpid(), .STOP);
    _ = linux.execve(path, argv, envp);
    // execve вернулся, значит, не получилось. Код 127, как у оболочки.
    linux.exit(127);
}

fn request(what: u32, pid: linux.pid_t, addr: usize, data: usize) Error!void {
    if (linux.errno(linux.ptrace(what, pid, addr, data, 0)) != .SUCCESS) return error.PtraceFailed;
}

fn readRegisters(pid: linux.pid_t) Error!Registers {
    // Буфер с запасом: ядро отдаёт не больше, чем просили, и записывает в
    // iov_len, сколько байт отдало на самом деле. По этому числу видно, чья
    // раскладка пришла. Попроси мы ровно свой размер, чужой набор побольше
    // молча обрезался бы до него.
    var raw: [64]u64 = undefined;
    var vector: std.posix.iovec = .{ .base = @ptrCast(&raw), .len = @sizeOf(@TypeOf(raw)) };
    try request(PTRACE.GETREGSET, pid, nt_prstatus, @intFromPtr(&vector));
    // Другой размер значит, что потомок живёт на другой архитектуре. Так
    // бывает с x86-64 под эмулятором Rosetta: для ядра это процесс aarch64,
    // и в регистрах лежит состояние эмулятора, а не программы.
    if (vector.len != @sizeOf(Registers)) return error.ForeignRegisters;
    return @as(*const Registers, @ptrCast(&raw)).*;
}

/// Читает память потомка словами через `PTRACE_PEEKDATA`, пока не заполнит
/// буфер или не упрётся в неотображённую страницу. Возвращает прочитанное.
///
/// Сырой системный вызов, в отличие от обёртки glibc, кладёт слово по адресу
/// из четвёртого аргумента, а не возвращает его: иначе слово `-1` было бы
/// не отличить от ошибки.
// ponytail: вызов ядра на каждые 8 байт. Для строк до 33 байт это пять вызовов;
// читать мегабайты так нельзя, для них есть process_vm_readv.
fn readMemory(pid: linux.pid_t, address: u64, buffer: []u8) []u8 {
    var done: usize = 0;
    while (done < buffer.len) {
        var word: usize = 0;
        const rc = linux.ptrace(PTRACE.PEEKDATA, pid, address + done, @intFromPtr(&word), 0);
        if (linux.errno(rc) != .SUCCESS) break;
        const bytes = std.mem.asBytes(&word);
        const take = @min(bytes.len, buffer.len - done);
        @memcpy(buffer[done..][0..take], bytes[0..take]);
        done += take;
    }
    return buffer[0..done];
}

/// Строка C: читаем с запасом и обрезаем по нулевому байту.
fn readString(pid: linux.pid_t, address: u64, buffer: []u8) ?[]const u8 {
    const bytes = readMemory(pid, address, buffer);
    if (bytes.len == 0) return null;
    return std.mem.sliceTo(bytes, 0);
}

fn writeEntry(out: *std.Io.Writer, pid: linux.pid_t, call: regs_mod.Call) Error!void {
    // На байт больше предела: по лишнему байту видно, что текст обрезан.
    var buffers: [syscalls.max_args][syscalls.string_limit + 1]u8 = undefined;
    var texts = syscalls.no_texts;

    const known = if (syscalls.name(arch, call.number)) |name| syscalls.signature(name) else null;
    if (known) |signature| {
        for (signature.args, 0..) |kind, position| switch (kind) {
            .string => texts[position] = readString(pid, call.args[position], &buffers[position]),
            .buffer => {
                // Длина буфера лежит в следующем аргументе.
                const length = @min(call.args[position + 1], buffers[position].len);
                const bytes = readMemory(pid, call.args[position], buffers[position][0..length]);
                if (bytes.len == length) texts[position] = bytes;
            },
            else => {},
        };
    }

    try syscalls.writeEntry(out, arch, call.number, call.args, texts);
    // Левую половину строки отдаём сразу: если вызов это write в терминал,
    // вывод программы встанет между половинами, как у настоящего strace.
    try out.flush();
}

fn onExit(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    summary: *summary_mod.Summary,
    options: Options,
    call: regs_mod.Call,
    result: u64,
) Error!void {
    if (options.summary_only) {
        try summary.record(gpa, displayName(call.number), syscalls.errorCode(result) != null);
        return;
    }
    try syscalls.writeReturn(out, arch, call.number, result);
    try out.writeByte('\n');
    try out.flush();
}

/// Потомок исчез посреди вызова: так кончаются `exit` и `exit_group`.
fn onGone(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    summary: *summary_mod.Summary,
    options: Options,
    entered: ?regs_mod.Call,
) Error!void {
    const call = entered orelse return;
    if (options.summary_only) return summary.record(gpa, displayName(call.number), false);
    try out.writeAll(" = ?\n");
}

fn displayName(number: u64) []const u8 {
    return syscalls.name(arch, number) orelse "syscall_?";
}

fn finish(out: *std.Io.Writer, summary: *summary_mod.Summary, options: Options, code: u8) Error!u8 {
    if (options.summary_only) try summary.print(out);
    try out.flush();
    return code;
}

Пройдём по функциям.

  • run это цикл со схемы выше. Опций три. TRACESYSGOOD помечает остановки на вызовах битом 0x80. TRACEEXEC превращает успешный execve в событие вместо голого SIGTRAP, который иначе пришлось бы отличать от настоящего. EXITKILL страхует от брошенных потомков: если zt упадёт, ядро убьёт и трассируемый процесс, а не оставит его навсегда остановленным.
  • child работает в копии процесса между fork и execve. Как ты помнишь из прошлого урока, в этой щели нельзя трогать аллокатор и буферы вывода, поэтому здесь только голые вызовы std.os.linux. Все строки для execve приготовлены заранее, ещё в родителе.
  • readRegisters просит у ядра набор NT_PRSTATUS через PTRACE_GETREGSET. Ядро записывает в iovec, сколько байтов отдало, и это число заодно проверяет, чья раскладка пришла. Под Rosetta программа x86-64 для ядра это процесс aarch64, регистры принадлежат эмулятору, и размер набора выдаёт подмену.
  • readMemory читает чужую память словами по восемь байтов через PTRACE_PEEKDATA. Сырой вызов кладёт слово по адресу из четвёртого аргумента, а не возвращает его: иначе прочитанное значение -1 было бы не отличить от ошибки. Комментарий ponytail честно называет потолок: для мегабайтов есть process_vm_readv, для строк в 33 байта пять вызовов сойдёт.
  • writeEntry печатает левую половину строки и сразу сбрасывает буфер. Поэтому, если вызов это write в тот же терминал, вывод программы встанет между половинами, как у настоящего strace.
  • onGone закрывает строку, если процесс исчез посреди вызова. Так кончается любая программа: вход в exit_group есть, выхода нет.

Код выхода zt повторяет правило оболочки: код программы, если она вышла сама, и 128 + номер сигнала, если её убили. Сложение с насыщением +| здесь для порядка: номер сигнала не больше 64, переполнения не будет, но u8 об этом не знает.

src/strace.zig

Подкоманда почти ничего не делает сама, как и ltrace: находит программу, готовит argv и отдаёт всё трассировщику.

//! `zt strace`: трасса системных вызовов программы через `ptrace`.
//!
//! Таблицы, разбор регистров и формат строки лежат в чистых модулях и
//! проверяются везде. Сам трассировщик (`strace/tracer.zig`) собирается
//! только под Linux на x86-64 или aarch64.

const std = @import("std");
const builtin = @import("builtin");

pub const syscalls = @import("strace/syscalls.zig");
pub const regs = @import("strace/regs.zig");
pub const summary = @import("strace/summary.zig");

pub const Arch = syscalls.Arch;

/// Можно ли трассировать на этой машине.
pub const supported = builtin.os.tag == .linux and Arch.native != null;

pub const tracer = if (supported) @import("strace/tracer.zig") else struct {};

/// Путь к программе, как его нашла бы оболочка. `execve` сам по `PATH` не
/// ищет, а искать в потомке после `fork` нельзя: там нет аллокатора.
/// Имя с косой чертой это уже путь. Память результата принадлежит `gpa`.
pub fn resolveProgram(
    gpa: std.mem.Allocator,
    io: std.Io,
    program: []const u8,
    search_path: []const u8,
) !?[:0]u8 {
    if (std.mem.indexOfScalar(u8, program, '/') != null) return try gpa.dupeZ(u8, program);

    var directories = std.mem.tokenizeScalar(u8, search_path, ':');
    while (directories.next()) |directory| {
        const candidate = try std.fs.path.joinZ(gpa, &.{ directory, program });
        if (std.Io.Dir.cwd().access(io, candidate, .{ .execute = true })) |_| {
            return candidate;
        } else |_| gpa.free(candidate);
    }
    return null;
}

/// Массив указателей с нулём в конце, каким его ждёт `execve`.
pub fn nullTerminated(gpa: std.mem.Allocator, args: []const []const u8) ![:null]?[*:0]const u8 {
    const result = try gpa.allocSentinel(?[*:0]const u8, args.len, null);
    for (args, result) |arg, *slot| slot.* = (try gpa.dupeZ(u8, arg)).ptr;
    return result;
}

test "имя с косой чертой остаётся как есть, остальное ищется по PATH" {
    const gpa = std.testing.allocator;
    const io = std.testing.io;

    const direct = (try resolveProgram(gpa, io, "./нет/такого", "/bin")).?;
    defer gpa.free(direct);
    try std.testing.expectEqualStrings("./нет/такого", direct);

    // /bin/sh есть на любой POSIX-системе.
    const shell = (try resolveProgram(gpa, io, "sh", "/нет-такого-каталога:/bin")).?;
    defer gpa.free(shell);
    try std.testing.expectEqualStrings("/bin/sh", shell);

    try std.testing.expectEqual(@as(?[:0]u8, null), try resolveProgram(gpa, io, "zt-нет-такой-программы", "/bin:/usr/bin"));
}

test {
    _ = syscalls;
    _ = regs;
    _ = summary;
}

execve в отличие от оболочки по PATH не ищет, ему нужен путь. Искать в потомке после fork нельзя (там нет аллокатора), значит, ищем заранее в родителе. tracer собирается условно: на macOS на его месте пустая структура, и все обращения к нему в main.zig компилятор выбросит вместе с веткой, где стоит проверка supported.

В src/root.zig добавляется одна строка:

pub const strace = @import("strace.zig");

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

    \\  zt strace [-c] <программа> [аргументы...]
    \\Флаги strace (только Linux, x86-64 или aarch64):
    \\  -c          вместо трассы итог: сколько раз звали каждый вызов
    \\
    } else if (std.mem.eql(u8, command, "strace")) {
        try cmdStrace(init, gpa, out, rest);
fn cmdStrace(
    init: std.process.Init,
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    args: []const []const u8,
) !void {
    if (!zt.strace.supported) return fail(out, "zt strace: работает только на Linux, x86-64 или aarch64\n");

    var options: zt.strace.tracer.Options = .{};
    var program = args;
    if (program.len > 0 and std.mem.eql(u8, program[0], "-c")) {
        options.summary_only = true;
        program = program[1..];
    }
    if (program.len == 0) return fail(out, "zt strace: нужна программа для запуска\n");

    const search_path = init.environ_map.get("PATH") orelse "/usr/local/bin:/usr/bin:/bin";
    const path = try zt.strace.resolveProgram(gpa, init.io, program[0], search_path) orelse {
        try out.print("zt strace: не нашёл программу {s}\n", .{program[0]});
        return error.BadUsage;
    };
    const argv = try zt.strace.nullTerminated(gpa, program);

    // Трасса идёт в stderr, как у настоящего strace: stdout остаётся программе.
    // Буфер сбрасывается после каждой половины строки, см. tracer.zig.
    try out.flush();
    var trace_buf: [4096]u8 = undefined;
    var stderr = std.Io.File.stderr().writerStreaming(init.io, &trace_buf);
    const code = zt.strace.tracer.run(
        gpa,
        &stderr.interface,
        path,
        argv.ptr,
        init.minimal.environ.block.slice.ptr,
        options,
    ) catch |err| {
        const hint = switch (err) {
            error.ForeignRegisters => "программа идёт под эмулятором (Rosetta, qemu), её регистров ядро не видит",
            else => "ptrace запрещён? В контейнере попробуй --cap-add SYS_PTRACE",
        };
        try out.print("zt strace: трассировка не удалась ({t}): {s}\n", .{ err, hint });
        return error.BadUsage;
    };
    // Выходим с кодом программы, чтобы zt strace можно было подставить в скрипт.
    if (code != 0) std.process.exit(code);
}

Окружение для execve берётся как есть, прямо из блока, который zt получил от ядра при старте: init.minimal.environ.block. Это уже массив указателей с нулём в конце, ровно такой, какой ждёт execve, копировать его незачем.

Сборка

В build.zig две правки: число 47 в массив project_steps, и тест шага 47 получает тот же модуль paths, что и тест шага 46, потому что тоже запускает собранный zt:

const project_steps = [_]u8{ 6, 7, 9, 10, 11, 12, 42, 43, 44, 45, 46, 47 };
            const run_tests = b.addRunArtifact(step_tests);
            // Шаги со сквозными тестами: они запускают собранный `zt`.
            if (number == 46 or number == 47) {
                step_tests.root_module.addOptions("paths", paths);
                // Программа и библиотека-перехватчик должны быть собраны до теста.
                run_tests.step.dependOn(b.getInstallStep());
            }
            test_step.dependOn(&run_tests.step);

Новой цели сборки нет: трассировщик живёт в самом zt.

Тесты шага

//! Шаг 47: `zt strace`, трасса системных вызовов через `ptrace`.
//!
//! Таблицы, разбор регистров, слово состояния и формат строки это чистый
//! код, он проверяется везде. Живой прогон идёт только на Linux, x86-64 или
//! aarch64, и только там, где ядро показывает регистры потомка: под
//! эмулятором Rosetta это не так, и тест пропускается.

const std = @import("std");

const paths = @import("paths");
const zt = @import("zt");

const strace = zt.strace;
const syscalls = strace.syscalls;
const regs = strace.regs;

test "номер в имя: у каждой архитектуры своя таблица" {
    try std.testing.expectEqualStrings("execve", syscalls.name(.x86_64, 59).?);
    try std.testing.expectEqualStrings("execve", syscalls.name(.aarch64, 221).?);
    try std.testing.expectEqualStrings("exit_group", syscalls.name(.x86_64, 231).?);
    try std.testing.expectEqualStrings("exit_group", syscalls.name(.aarch64, 94).?);
    try std.testing.expect(syscalls.neverReturns("exit_group"));
    try std.testing.expect(!syscalls.neverReturns("write"));
}

test "от регистров до строки трассы, x86-64" {
    // Остановка на входе в openat("/etc/passwd"): так регистры видит ptrace.
    var entry = std.mem.zeroes(regs.X86_64);
    entry.orig_rax = 257;
    entry.rax = @bitCast(@as(i64, -38));
    entry.rdi = @bitCast(@as(i64, -100));
    entry.rsi = 0x7ffd_0000_1000;
    entry.rdx = 0x80000;
    const call = entry.call();

    // Остановка на выходе: в rax дескриптор.
    var exit = entry;
    exit.rax = 3;

    var texts = syscalls.no_texts;
    texts[1] = "/etc/passwd";
    var buffer: [160]u8 = undefined;
    try std.testing.expectEqualStrings(
        "openat(AT_FDCWD, \"/etc/passwd\", 0x80000, 0x0) = 3\n",
        syscalls.formatLine(&buffer, .x86_64, call.number, call.args, texts, exit.result()),
    );
}

test "от регистров до строки трассы, aarch64" {
    var entry = std.mem.zeroes(regs.Aarch64);
    entry.x[8] = 56;
    entry.x[0] = @bitCast(@as(i64, -100));
    entry.x[1] = 0xffff_0000_1000;
    entry.x[2] = 0x80000;
    const call = entry.call();

    // На выходе x0 уже результат: аргументы берём из запомненного входа.
    var exit = entry;
    exit.x[0] = @bitCast(@as(i64, -13));

    var texts = syscalls.no_texts;
    texts[1] = "/etc/shadow";
    var buffer: [160]u8 = undefined;
    try std.testing.expectEqualStrings(
        "openat(AT_FDCWD, \"/etc/shadow\", 0x80000, 0x0) = -1 EACCES (Permission denied)\n",
        syscalls.formatLine(&buffer, .aarch64, call.number, call.args, texts, exit.result()),
    );
}

test "отрицательный результат это errno, но только до -4095" {
    try std.testing.expectEqual(2, syscalls.errorCode(@bitCast(@as(i64, -2))).?);
    try std.testing.expectEqual(4095, syscalls.errorCode(@bitCast(@as(i64, -4095))).?);
    try std.testing.expectEqual(@as(?u16, null), syscalls.errorCode(@bitCast(@as(i64, -4096))));
    try std.testing.expectEqual(@as(?u16, null), syscalls.errorCode(0));
    try std.testing.expectEqualStrings("ENOENT", syscalls.errorName(2).?.name);
}

test "слово состояния: вызов, сигнал, событие, конец" {
    try std.testing.expectEqual(regs.Stop.syscall, regs.decodeStatus(0x857f));
    try std.testing.expectEqual(regs.Stop{ .signal = 19 }, regs.decodeStatus(0x137f));
    try std.testing.expectEqual(regs.Stop{ .event = 4 }, regs.decodeStatus(0x4057f));
    try std.testing.expectEqual(regs.Stop{ .exited = 1 }, regs.decodeStatus(0x0100));
    try std.testing.expectEqual(regs.Stop{ .killed = 9 }, regs.decodeStatus(0x0009));
}

test "итог -c" {
    const gpa = std.testing.allocator;
    var summary: strace.summary.Summary = .{};
    defer summary.deinit(gpa);
    try summary.record(gpa, "write", false);
    try summary.record(gpa, "openat", true);
    try summary.record(gpa, "write", false);

    var buffer: [512]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buffer);
    try summary.print(&out);
    try std.testing.expect(std.mem.indexOf(u8, out.buffered(), "         2           write\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, out.buffered(), "         1         1 openat\n") != null);
    try std.testing.expect(std.mem.endsWith(u8, out.buffered(), "         3         1 total\n"));
}

/// Запускает `zt strace` и возвращает результат. Пропускает тест там, где
/// трассировать нельзя: не Linux, чужая архитектура или эмулятор.
fn trace(gpa: std.mem.Allocator, argv: []const []const u8) !std.process.RunResult {
    if (!strace.supported) return error.SkipZigTest;
    const result = try std.process.run(gpa, std.testing.io, .{ .argv = argv });
    if (std.mem.indexOf(u8, result.stderr, "ForeignRegisters") != null or
        std.mem.indexOf(u8, result.stdout, "ForeignRegisters") != null)
    {
        gpa.free(result.stdout);
        gpa.free(result.stderr);
        return error.SkipZigTest;
    }
    return result;
}

test "zt strace: трасса настоящей программы" {
    const gpa = std.testing.allocator;
    const result = try trace(gpa, &.{ paths.zt, "strace", "/bin/sh", "-c", "echo hi; exit 3" });
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);

    // Программа отработала как обычно, и её код возврата стал кодом zt.
    try std.testing.expectEqualStrings("hi\n", result.stdout);
    try std.testing.expectEqual(std.process.Child.Term{ .exited = 3 }, result.term);

    // Первая строка трассы это execve самой программы, путь прочитан из её памяти.
    try std.testing.expect(std.mem.startsWith(u8, result.stderr, "execve(\"/bin/sh\", 0x"));
    // echo встроен в оболочку, так что вывод это её собственный write.
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "write(1, \"hi\\n\", 3) = 3\n") != null);
    try std.testing.expect(std.mem.endsWith(u8, result.stderr, "exit_group(3) = ?\n+++ exited with 3 +++\n"));
}

test "zt strace: ошибка вызова расшифрована" {
    const gpa = std.testing.allocator;
    const result = try trace(gpa, &.{ paths.zt, "strace", "/bin/cat", "/zt/нет/такого" });
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);

    try std.testing.expectEqual(std.process.Child.Term{ .exited = 1 }, result.term);
    try std.testing.expect(std.mem.indexOf(
        u8,
        result.stderr,
        "openat(AT_FDCWD, \"/zt/нет/такого\", 0x0, 0x0) = -1 ENOENT (No such file or directory)\n",
    ) != null);
}

test "zt strace -c: счётчики вместо трассы" {
    const gpa = std.testing.allocator;
    const result = try trace(gpa, &.{ paths.zt, "strace", "-c", "/bin/cat", "/zt/нет/такого" });
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);

    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "openat(") == null);
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "     calls    errors syscall\n") != null);
    // execve и exit_group случаются ровно по разу у любой программы.
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "         1           execve\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "         1           exit_group\n") != null);
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, " total\n") != null);
}

Шесть тестов из девяти чистые: таблица номеров, путь от регистров до строки на обеих архитектурах, граница кодов ошибок, слово состояния, итог. Регистры x86-64 я живьём снять не могу (машина ARM, а под Rosetta, как мы выяснили, ptrace видит эмулятор), поэтому раскладка x86-64 покрыта только этими тестами. Три сквозных теста запускают настоящие /bin/sh и /bin/cat и сверяют форму трассы. Функция trace пропускает их там, где трассировать нельзя: не Linux, не та архитектура или регистры чужие.

На macOS:

$ zig build test -Dstep=47 --summary all
Build Summary: 7/7 steps succeeded; 6/9 tests passed (3 skipped)
test success
+- run test 6 pass, 3 skip (9 total) 8ms MaxRSS:2M
   +- compile test Debug native cached 42ms MaxRSS:35M
   |  +- options cached
   +- install cached
      +- install zt cached
         +- compile exe zt Debug native cached 42ms MaxRSS:35M
$ zt strace ls
zt strace: работает только на Linux, x86-64 или aarch64

В контейнере linux/arm64 проходят все девять (команду запуска даёт README эталона, cp нужен затем, что кэш сборки не пишется в смонтированную только на чтение папку):

$ docker run --rm --platform linux/arm64 -v "$PWD":/w:ro ghcr.io/bondiano/runner-zig:dev sh -c '
    mkdir /tmp/p && cd /w && cp -r build.zig src tests fixtures /tmp/p/ && cd /tmp/p &&
    zig build test -Dstep=47 --summary all --cache-dir /tmp/p/.zig-cache --global-cache-dir /tmp/g'
Build Summary: 13/13 steps succeeded; 9/9 tests passed
test success
+- run test 9 pass (9 total) 12ms MaxRSS:5M
   +- compile test Debug native success 1s MaxRSS:397M
   |  +- options success
   +- install success
      +- install zt success
      |  +- compile exe zt Debug native success 1s MaxRSS:475M
...

Живой прогон

Та же статическая программа, что в начале раздела, теперь под своим инструментом:

$ zt strace ./hello
execve("./hello", 0xffff95daf050, 0xffffdc84c648) = 0
mmap(NULL, 262215, 0x3, 0x22, -1, 0x0) = 0xffffa227b000
prlimit64(0, 3, NULL, 0xfffff8fda320) = 0
sigaltstack(0xfffff8fda468, 0x0, 0xfffff8fda468, 0x0, 0x0, 0xfffff8fda320) = 0
rt_sigaction(11, 0xfffff8fda300, NULL, 8) = 0
rt_sigaction(4, 0xfffff8fda300, NULL, 8) = 0
rt_sigaction(7, 0xfffff8fda300, NULL, 8) = 0
rt_sigaction(8, 0xfffff8fda300, NULL, 8) = 0
write(1, "привет из статики"..., 33)привет из статики
 = 33
exit_group(0) = ?
+++ exited with 0 +++

Три отличия от настоящего strace стоит разобрать, потому что каждое чему-то учит.

Флаги числами. 0x3 у mmap это PROT_READ|PROT_WRITE, 0x22 это MAP_PRIVATE|MAP_ANONYMOUS, а 3 у prlimit64 это RLIMIT_STACK. Расшифровка флагов это таблицы имён для битов, и она оставлена в домашнем задании.

sigaltstack шестью регистрами. Этого вызова нет в таблице сигнатур, поэтому zt печатает x0 до x5 как есть. У sigaltstack два аргумента, и остальные четыре числа это мусор, который остался в регистрах от прошлого кода. Ядро на них не смотрит, и это хорошая иллюстрация: у системного вызова нет понятия «сколько аргументов передали», есть только шесть регистров.

Один prlimit64 вместо двух. Это уже не упрощение, а настоящее различие. zt сам написан на Zig, и его стартовый код уже поднял себе предел стека до 16 МБ. Потомок наследует пределы через fork и execve, видит 16 МБ и второй вызов не делает. Проверка:

$ sh -c 'ulimit -s'
8192
$ zt strace -c sh -c 'ulimit -s' 2>/dev/null
16384

Трассировщик не невидим: у потомка его пределы, его окружение, его открытые дескрипторы. Настоящий strace написан на C и стек не трогает, поэтому и видит два вызова.

Динамическая программа выглядит так же, как у strace, с поправкой на формат (вывод ls в /dev/null, середина вырезана):

$ zt strace ls / > /dev/null
execve("/usr/bin/ls", 0xffff9cd3f068, 0xffffd6cabd10) = 0
brk(NULL) = 0xaaaac03d4000
mmap(NULL, 8192, 0x3, 0x22, -1, 0x0) = 0xffff9171d000
faccessat(AT_FDCWD, "/etc/ld.so.preload", 0x4) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", 0x80000, 0x0) = 3
fstatat64(3, "", 0xffffefbf9230, 0x1000) = 0
mmap(NULL, 4587, 0x1, 0x2, 3, 0x0) = 0xffff9171b000
close(3) = 0
openat(AT_FDCWD, "/lib/aarch64-linux-gnu/libselinu"..., 0x80000, 0x0) = 3
read(3, 0xffffefbf93e0, 832) = 832
...
openat(AT_FDCWD, "/", 0x84800, 0x0) = 3
fstatat64(3, "", 0xffffefbf9bc8, 0x1000) = 0
getdents64(3, 0xaaaac03d9640, 32768) = 592
getdents64(3, 0xaaaac03d9640, 32768) = 0
close(3) = 0
fstatat64(1, "", 0xffffefbf7a38, 0x1000) = 0
ioctl(1, 0x5401, 0xffffefbf7990) = -1 ENOTTY (Inappropriate ioctl for device)
write(1, "bin\nboot\ndev\netc\nhome\nlib\nmedia\n"..., 88) = 88
close(1) = 0
close(2) = 0
exit_group(0) = ?
+++ exited with 0 +++

Путь к libselinux.so.1 обрезан на 32 байтах, три точки после кавычки говорят об этом. Вместо newfstatat у нас fstatat64: номер 79 один и тот же, а имя взято из перечисления std.os.linux.syscalls.Arm64, где этот вызов записан под старым именем из общей таблицы ядра. Ради него в таблице сигнатур и стоят две строки. И итог:

$ zt strace -c ls / > /dev/null
+++ exited with 0 +++
     calls    errors syscall
---------- --------- ----------------
        14           mmap
         8           close
         8           mprotect
         7           fstatat64
         7           munmap
         6           openat
         5           read
         3           brk
         2         2 faccessat
         2           getdents64
         2         2 ioctl
         2         2 statfs
         1           execve
         1           exit_group
         1           getrandom
         1           prlimit64
         1           rseq
         1           set_robust_list
         1           set_tid_address
         1           statx
         1           write
---------- --------- ----------------
        75         6 total

Счётчики сошлись с strace -c построчно, кроме одного: мы считаем exit_group, у которого нет выхода, а strace в итог его не включает. Отсюда 75 против 74.

Теперь то, ради чего мы спустились ниже ltrace. Программа raw_syscall из начала урока зовёт ядро инструкцией svc напрямую, мимо любой библиотеки. ltrace её бы не увидел, zt strace видит (вывод программы убран в /dev/null):

$ zt strace ./raw_syscall > /dev/null
...
getpid() = 524
write(1, "getpid: 524\n", 12) = 12
write(9, "в закрытый дескри"..., 41) = -1 EBADF (Bad file descriptor)
write(1, "write(9, ...): как usize 1844"..., 66) = 66
syscall_100000(0xfffff647d440, 0x186a0, 0x42, 0xfffff647d148, 0x42, 0x2) = -1 ENOSYS (Function not implemented)
write(1, "syscall 100000: -38\n", 20) = 20
exit_group(0) = ?
+++ exited with 0 +++

Строка syscall_100000 показывает, что zt видит ровно то, что видит ядро: номера нет в таблице, значит, имени нет, а шесть регистров содержат то, что там случайно лежало. Настоящий strace пишет то же самое, только номер в шестнадцатеричном виде: syscall_0x186a0(...). Строка "в закрытый дескри"... обрезана ровно на 32 байтах: кириллица в UTF-8 занимает по два байта на букву, поэтому от сообщения осталось меньше половины.

Ошибка вызова и сигналы:

$ zt strace cat /zt/нет/такого 2>&1 | grep openat | tail -1
openat(AT_FDCWD, "/zt/нет/такого", 0x0, 0x0) = -1 ENOENT (No such file or directory)
$ zt strace ./segv
execve("./segv", 0xffff9003f050, 0xffffd42b9aa8) = 0
mmap(NULL, 262215, 0x3, 0x22, -1, 0x0) = 0xffff81e86000
prlimit64(0, 3, NULL, 0xffffd0e20830) = 0
sigaltstack(0xffffd0e20978, 0x0, 0xffffd0e20978, 0x0, 0x0, 0xffffd0e20830) = 0
write(1, "пишем по адресу 0\n", 31)пишем по адресу 0
 = 31
--- SIGSEGV ---
+++ killed by SIGSEGV +++
$ echo $?
139
$ zt strace sh -c 'kill -TERM $$' 2>&1 | tail -3
kill(527, 15) = 0
--- SIGTERM ---
+++ killed by SIGTERM +++

segv это программа из раздела про исключения, та, что пишет по нулевому адресу, с выключенным обработчиком сигналов Zig: поэтому в трассе нет четырёх rt_sigaction. Сбой страницы случился не в системном вызове, а в обычной инструкции записи. Ядро превратило его в SIGSEGV, и прежде чем доставить сигнал, остановило процесс и показало его трассировщику: строка --- SIGSEGV ---. zt передал сигнал дальше, процесс погиб, код выхода 128 + 11. Во втором примере оболочка послала сигнал сама себе, и путь тот же.

Проект Y86: trap и таблица исключений

Машина Y86 из урока 22 до сих пор знала одного хозяина. Программа могла всё: остановить машину, писать куда угодно, и любой сбой был концом света. Сегодня у машины появится ядро. Разделим мир на две половины ровно так, как это сделано в x86-64, только в миниатюре, чтобы весь механизм уместился на одном экране.

Что добавляем в железо:

  • Бит привилегий. Поле user в состоянии машины. После сброса машина в режиме ядра, поэтому двенадцать старых программ ничего не заметят: им по-прежнему всё разрешено.
  • Три инструкции. trap (байт d0) уводит процесс в ядро, это наш syscall. iret (d1) возвращает обратно, это наш iretq. out rA (e0, форма как у pushq) отправляет младший байт регистра на консоль: устройство ввода и вывода, до которого процессу дотрагиваться нельзя, как до in и out на x86-64.
  • Запреты. Процессу нельзя halt, iret и out: это сбой INS. Ядру нельзя trap: выше ядра звать некого.
  • Таблицу исключений. Четыре слова по адресу 0xfc0, по одному на причину: trap 0, adr 1, ins 2, timer 3. Строка для таймера заведена с самого начала, но стрелять в неё начнёт устройство из урока 50.
  • Слово ksp по адресу 0xfe0, сразу за таблицей: вершина стека ядра. На x86-64 это поле в структуре TSS, у нас слово в памяти.
  • Кадр исключения из трёх слов, который железо кладёт под ksp: адрес возврата, слово состояния (флаги, разрешение прерываний, режим), прежний %rsp.

Слово состояния это пять битов: ZF, SF, OF, бит ie (прерывания разрешены) и бит user. Три флага обязаны ехать в кадре, иначе процесс, которого прервали между subq и jne, после возврата прыгнул бы не туда. Бит ie сегодня ни на что не влияет: прерываний пока нет, и он просто ездит в кадр и обратно.

Сбой в режиме ядра ловить некому, он останавливает машину, как останавливал всегда. У настоящего процессора на это есть двойной сбой (#DF, номер 8) и тройной, после которого машина перезагружается; у нас проще.

src/isa.zig

В перечисление кодов операций добавляются два значения, 0xd и 0xe:

    /// Расширение из упражнения 4.3: сложение с непосредственным значением.
    iaddq = 0xc,
    /// Переход между режимами: `trap` при ifun = 0 уходит в ядро, `iret`
    /// при ifun = 1 возвращается из него. Семантика в `sim/trap.zig`.
    sys = 0xd,
    /// Порт вывода: младший байт регистра уходит на консоль. Только для ядра.
    out = 0xe,
    _,
};

trap и iret делят один код, как jmp и jne делят jxx: отличает их ifun. Сразу после перечисления кодов состояния Stat (перед needRegids) идёт всё, что нужно исключениям:

/// Причина исключения. Она же номер строки в таблице исключений.
pub const Cause = enum(u2) {
    /// Инструкция `trap`: системный вызов.
    trap = 0,
    /// Обращение за границу памяти.
    adr = 1,
    /// Недопустимая или привилегированная инструкция.
    ins = 2,
    /// Прерывание от таймера.
    timer = 3,

    pub fn name(self: Cause) []const u8 {
        return @tagName(self);
    }
};

/// Таблица исключений: четыре адреса обработчиков по восемь байт, строка
/// на причину. Нулевой адрес значит, что обработчика нет.
pub const evt_base = 0xfc0;

/// Слово сразу за таблицей: вершина стека, на который железо кладёт кадр
/// исключения, когда оно пришло из пользовательского режима.
pub const ksp_addr = 0xfe0;

/// Биты слова состояния, которое ложится в кадр исключения.
pub const status_zf = 1 << 0;
pub const status_sf = 1 << 1;
pub const status_of = 1 << 2;
/// Прерывания разрешены.
pub const status_ie = 1 << 3;
/// Пользовательский режим.
pub const status_user = 1 << 4;

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

/// Нужен ли инструкции байт с номерами регистров.
pub fn needRegids(icode: Icode) bool {
    return switch (icode) {
        .cmovxx, .irmovq, .rmmovq, .mrmovq, .opq, .pushq, .popq, .iaddq, .out => true,
        else => false,
    };
}
/// Допустим ли такой ifun у такого icode.
pub fn validIfun(icode: Icode, ifun: u4) bool {
    return switch (icode) {
        .halt, .nop, .irmovq, .rmmovq, .mrmovq, .call, .ret, .pushq, .popq, .iaddq => ifun == 0,
        .out => ifun == 0,
        .cmovxx, .jxx => ifun <= 6,
        .opq => ifun <= 3,
        .sys => ifun <= 1,
        _ => false,
    };
}
pub fn form(icode: Icode) Form {
    return switch (icode) {
        .cmovxx, .opq => .reg_reg,
        .irmovq, .iaddq => .imm_reg,
        .rmmovq => .reg_mem,
        .mrmovq => .mem_reg,
        .pushq, .popq, .out => .reg,
        .jxx, .call => .dest,
        else => .none,
    };
}

И три строки в конец таблицы мнемоник, по ней ассемблер находит имя, а дизассемблер печатает инструкцию:

    .{ .name = "trap", .icode = .sys, .ifun = 0 },
    .{ .name = "iret", .icode = .sys, .ifun = 1 },
    .{ .name = "out", .icode = .out, .ifun = 0 },

Ассемблер из урока 21 ничего больше не требует: он собирает инструкцию по форме, а форму знает isa.zig.

src/sim/state.zig

В состоянии машины два новых поля, после stat:

    stat: Stat = .aok,
    /// Бит привилегий: true в пользовательском режиме. После сброса машина
    /// в режиме ядра, поэтому программам без ядра ничего не запрещено.
    user: bool = false,
    /// Прерывания разрешены. После сброса запрещены: ядру сперва надо встать.
    /// Пока прерывать некому, бит только ездит в кадре исключения и обратно.
    ie: bool = false,

Начало комментария файла тоже меняется: «С ядром к ним добавились бит привилегий и разрешение прерываний».

src/sim/trap.zig

Новый файл, и в нём весь вход в ядро и весь выход. Это чистые функции: состояние машины на входе, состояние на выходе, никакого вывода и никаких устройств.

//! Вход в ядро и выход из него.
//!
//! Исключение любого вида (системный вызов, сбой, прерывание) железо
//! обрабатывает одинаково: кладёт на стек ядра кадр из трёх слов, переходит
//! в режим ядра и прыгает по адресу из таблицы исключений. `iret` делает
//! обратное: снимает кадр и возвращает машину туда, откуда её выдернули.
//!
//!   %rsp + 16   прежний %rsp
//!   %rsp +  8   слово состояния: ZF, SF, OF, разрешение прерываний, режим
//!   %rsp +  0   адрес возврата
//!
//! Обе функции чистые: состояние на входе, состояние на выходе, никаких
//! устройств и никакого вывода.

const std = @import("std");

const isa = @import("../isa.zig");
const state = @import("state.zig");

const Cause = isa.Cause;
const State = state.State;

/// Размер кадра исключения в байтах.
pub const frame_size = 24;

/// Что произошло при входе в ядро. Нужно трассе.
pub const Entry = struct {
    cause: Cause,
    /// Адрес возврата, он же лежит в кадре.
    epc: u64,
    /// Адрес обработчика из таблицы.
    vector: u64,
};

/// Флаги, разрешение прерываний и режим одним словом.
pub fn statusWord(st: *const State) u64 {
    var word: u64 = 0;
    if (st.zf) word |= isa.status_zf;
    if (st.sf) word |= isa.status_sf;
    if (st.of) word |= isa.status_of;
    if (st.ie) word |= isa.status_ie;
    if (st.user) word |= isa.status_user;
    return word;
}

pub fn setStatus(st: *State, word: u64) void {
    st.zf = word & isa.status_zf != 0;
    st.sf = word & isa.status_sf != 0;
    st.of = word & isa.status_of != 0;
    st.ie = word & isa.status_ie != 0;
    st.user = word & isa.status_user != 0;
}

/// Вход в обработчик. `return_pc` решает вызывающий: у `trap` это следующая
/// инструкция, у сбоя сама сбойная, у прерывания та, что не успела начаться.
///
/// Возвращает null, если обработчика в таблице нет или кадр не помещается
/// в память. Состояние в этом случае не тронуто.
pub fn handleTrap(st: *State, cause: Cause, return_pc: u64) ?Entry {
    const slot = isa.evt_base + 8 * @as(u64, @intFromEnum(cause));
    const vector = st.readWord(slot).?;
    if (vector == 0) return null;

    // Из пользовательского режима железо пересаживается на стек ядра: стеку
    // процесса верить нельзя. Исключение внутри ядра кладёт кадр на текущий.
    const old_rsp = st.get(.rsp);
    const top = if (st.user) st.readWord(isa.ksp_addr).? else old_rsp;
    if (top < frame_size or top > isa.mem_size) return null;

    const frame = top - frame_size;
    _ = st.writeWord(frame + 16, old_rsp);
    _ = st.writeWord(frame + 8, statusWord(st));
    _ = st.writeWord(frame, return_pc);

    st.set(.rsp, frame);
    st.user = false;
    // В обработчике прерывания запрещены: второй вход поверх первого
    // затёр бы кадр и регистры, которые ядро ещё не успело сохранить.
    st.ie = false;
    st.pc = vector;
    return .{ .cause = cause, .epc = return_pc, .vector = vector };
}

/// Возврат из обработчика: снять кадр и восстановить PC, слово состояния
/// и %rsp. Возвращает false, если кадр не читается; состояние тогда не тронуто.
pub fn iret(st: *State) bool {
    const frame = st.get(.rsp);
    if (frame > isa.mem_size - frame_size) return false;

    st.pc = st.readWord(frame).?;
    setStatus(st, st.readWord(frame + 8).?);
    st.set(.rsp, st.readWord(frame + 16).?);
    return true;
}

const testing = std.testing;

/// Машина с таблицей: обработчик `trap` на 0x100, стек ядра кончается на 0x300.
fn machine() State {
    var st: State = .init();
    _ = st.writeWord(isa.evt_base, 0x100);
    _ = st.writeWord(isa.ksp_addr, 0x300);
    return st;
}

test "вход из пользовательского режима: кадр на стеке ядра, режим ядра, маска" {
    var st = machine();
    st.user = true;
    st.ie = true;
    st.sf = true;
    st.zf = false;
    st.set(.rsp, 0x7f0);

    const entry = handleTrap(&st, .trap, 0x40a).?;
    try testing.expectEqual(@as(u64, 0x100), entry.vector);
    try testing.expectEqual(@as(u64, 0x100), st.pc);
    try testing.expect(!st.user and !st.ie);

    try testing.expectEqual(@as(u64, 0x2e8), st.get(.rsp));
    try testing.expectEqual(@as(u64, 0x40a), st.readWord(0x2e8).?);
    const status = isa.status_sf | isa.status_ie | isa.status_user;
    try testing.expectEqual(@as(u64, status), st.readWord(0x2f0).?);
    try testing.expectEqual(@as(u64, 0x7f0), st.readWord(0x2f8).?);
}

test "iret возвращает ровно то, что сохранил вход" {
    var st = machine();
    st.user = true;
    st.ie = true;
    st.of = true;
    st.set(.rsp, 0x7f0);
    const before = st;

    _ = handleTrap(&st, .trap, 0x40a).?;
    // Обработчик волен портить флаги: они вернутся из кадра.
    st.zf = false;
    st.of = false;
    try testing.expect(iret(&st));

    try testing.expectEqual(@as(u64, 0x40a), st.pc);
    try testing.expectEqual(before.get(.rsp), st.get(.rsp));
    try testing.expectEqual(statusWord(&before), statusWord(&st));
}

test "нет обработчика или некуда класть кадр: состояние не тронуто" {
    var st = machine();
    st.user = true;
    const before = st;
    // Строка ADR в таблице пустая.
    try testing.expectEqual(@as(?Entry, null), handleTrap(&st, .adr, 0x400));
    try testing.expectEqual(before.pc, st.pc);
    try testing.expect(st.user);

    // Вершина стека ядра смотрит мимо памяти.
    _ = st.writeWord(isa.ksp_addr, isa.mem_size + 8);
    try testing.expectEqual(@as(?Entry, null), handleTrap(&st, .trap, 0x400));
    try testing.expect(st.user);
}

test "исключение внутри ядра кладёт кадр на текущий стек" {
    var st = machine();
    st.ie = true;
    st.set(.rsp, 0x2e8);
    _ = st.writeWord(isa.evt_base + 8 * @as(u64, @intFromEnum(Cause.timer)), 0x180);

    _ = handleTrap(&st, .timer, 0x120).?;
    try testing.expectEqual(@as(u64, 0x2d0), st.get(.rsp));
    try testing.expectEqual(@as(u64, 0x2e8), st.readWord(0x2e0).?);
    // Режим ядра остался ядром, прерывания на входе запрещены.
    try testing.expect(!st.user and !st.ie);
}

handleTrap делает шаги 2 и 3 из начала урока. Номер причины это строка таблицы, адрес обработчика берётся из памяти по 0xfc0 + 8 * причина. Нулевой адрес значит, что обработчика нет. Потом смена стека: если исключение пришло из процесса, вершину берём из ksp, а не из %rsp, потому что стеку процесса верить нельзя. Если исключение случилось в самом ядре (такое у нас сейчас невозможно, но таймер из урока 50 это умеет), кадр ложится на текущий стек ядра. Под вершину три слова, режим ядра, прерывания запрещены, прыжок.

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

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

iret делает шаг 4: снимает кадр и восстанавливает три вещи, которые программа сама сохранить не может. Счётчик команд, слово состояния целиком (а значит, и режим: возврат в процесс это просто бит user из кадра) и %rsp.

src/sim/cpu.zig

Процессор узнаёт про режимы в четырёх местах. Первое это Signals и Retired: провод для байта консоли и три поля для трассы.

    val_m: u64 = 0,

    /// Байт, который `out` отправила на консоль.
    out: ?u8 = null,

    stat: Stat = .aok,
};
/// Что нужно знать трассе об исполненной инструкции.
pub const Retired = struct {
    /// Адрес самой инструкции, а не следующей.
    pc: u64,
    icode: Icode,
    ifun: u4,
    /// Код состояния самой инструкции. Если сбой ушёл в обработчик, здесь
    /// всё равно ADR или INS, а машина при этом осталась в AOK.
    stat: Stat,
    /// В каком режиме инструкция исполнялась.
    user: bool = false,
    /// Исключение, которым инструкция закончилась: `trap` или сбой.
    exc: ?trap.Entry = null,
    out: ?u8 = null,
};

Второе это выборка. Инструкция может быть законной и при этом запрещённой в текущем режиме. Проверку ставим туда же, где проверяется законность, и результат тот же, INS:

    if (!decoded.valid or !allowed(st.user, decoded.icode, decoded.ifun)) {
        s.stat = .ins;
        return;
    }
    if (decoded.icode == .halt) s.stat = .hlt;
}

/// Разрешена ли инструкция в этом режиме. Останавливать машину, возвращаться
/// из обработчика и писать в порт может только ядро. А `trap` из ядра это
/// ошибка самого ядра: выше него никого нет.
fn allowed(user: bool, icode: Icode, ifun: u4) bool {
    const is_trap = icode == .sys and ifun == 0;
    const privileged = icode == .halt or icode == .out or (icode == .sys and ifun == 1);
    return if (user) !privileged else !is_trap;
}

Третье это исполнение. out читает регистр на этапе декодирования (строка .out => s.ra в выборе src_a, рядом с pushq) и отдаёт младший байт на этапе исполнения:

        // Порт вывода берёт младший байт регистра.
        .out => s.out = @truncate(s.val_a),

А iret вместо обычного выбора следующего PC берёт всё из кадра:

/// Выбор следующего PC.
pub fn pcUpdate(st: *State, s: *Signals) void {
    if (s.stat != .aok) return;
    if (s.icode == .sys and s.ifun == 1) {
        // `iret` берёт новый PC из кадра, а заодно режим, флаги и %rsp.
        if (!trap.iret(st)) s.stat = .adr;
        return;
    }
    st.pc = switch (s.icode) {
        .jxx => if (s.cnd) s.val_c else s.val_p,
        .call => s.val_c,
        .ret => s.val_m,
        else => s.val_p,
    };
}

Четвёртое, главное: после шести этапов машина разбирается, чем кончилась инструкция.

/// Чем закончилась инструкция: `trap` и сбои пользовательского режима уходят
/// в обработчик, и машина остаётся в AOK. Сбой в режиме ядра ловить некому,
/// он останавливает машину, как останавливал всегда.
fn raise(st: *State, s: *Signals) ?trap.Entry {
    const is_trap = s.stat == .aok and s.icode == .sys and s.ifun == 0;
    const cause: isa.Cause = if (is_trap) .trap else switch (s.stat) {
        .adr => .adr,
        .ins => .ins,
        else => return null,
    };
    if (!st.user) return null;

    // К этому месту у `trap` PC уже указывает на следующую инструкцию,
    // а у сбойной инструкции остался на ней самой: этапы после сбоя молчали.
    const entry = trap.handleTrap(st, cause, st.pc) orelse {
        // Обработчика нет: системный вызов без ядра это недопустимая инструкция.
        if (is_trap) s.stat = .ins;
        return null;
    };
    s.stat = .aok;
    return entry;
}

/// Один такт: шесть этапов подряд над одним набором проводов, после них
/// машина разбирается с исключением.
pub fn step(st: *State) Retired {
    const pc = st.pc;
    const user = st.user;
    var s: Signals = .{};

    fetch(st, &s);
    decode(st, &s);
    execute(st, &s);
    memory(st, &s);
    writeback(st, &s);
    pcUpdate(st, &s);

    const stat = s.stat;
    const exc = raise(st, &s);
    st.stat = s.stat;
    return .{
        .pc = pc,
        .icode = s.icode,
        .ifun = s.ifun,
        .stat = if (exc != null) stat else s.stat,
        .user = user,
        .exc = exc,
        .out = s.out,
    };
}

raise собирает в одном месте то, что раньше решалось само собой. Инструкция trap без ошибок это ловушка. Код ADR или INS это сбой. Из режима ядра исключений не бывает: функция возвращает null, код состояния остаётся, и машина встаёт, как и раньше. Из процесса машина идёт в обработчик, а код состояния машины становится AOK: сбой обработан, работаем дальше.

Посмотри, откуда берётся адрес возврата. raise передаёт в handleTrap просто st.pc. Для trap этап выбора PC уже отработал, и st.pc указывает на следующую инструкцию. Для сбойной инструкции этапы после сбоя молчали (каждый начинается с if (s.stat != .aok) return;), и st.pc остался на ней самой. Правильный адрес возврата получается сам, без единой строки специального кода.

В Retired два кода состояния: stat инструкции (у сбойной это INS, даже если машина пошла дальше) и итог машины. Трассе нужен первый: в строке должно быть видно, что инструкция сбойная.

src/sim/computer.zig

Процессор теперь умеет исключения, но не знает, куда деваются байты из порта вывода. Нужна плата, на которой процессор соединён с консолью. Она же пишет трассу второй версии: к строке на инструкцию из урока 22 добавляется буква режима в начале и строки событий.

//! Компьютер: процессор и консоль на одной плате.
//!
//! `cpu.step` умеет исключения, но не знает, куда деваются байты из порта
//! вывода и кто читает трассу. Здесь к процессору подключена консоль, и здесь
//! же пишется трасса версии 2: та же строка на инструкцию, что в версии 1,
//! плюс режим в начале строки и строки событий.
//!
//!   U 0x400 30 AOK              режим, адрес, icode и ifun, код состояния инструкции
//!   out 68                      байт ушёл на консоль
//!   exc trap 0x415 -> 0x010     инструкция закончилась исключением
//!   halt cycles=120             дальше пятнадцать регистров, как в версии 1

const std = @import("std");

const cpu = @import("cpu.zig");
const isa = @import("../isa.zig");
const state = @import("state.zig");
const trace = @import("trace.zig");
const trap = @import("trap.zig");

const State = state.State;
const Writer = std.Io.Writer;

pub const Config = struct {
    /// Печатать только события, без строк инструкций.
    events_only: bool = false,
    max_steps: usize = 100_000,
};

pub const Summary = struct {
    cycles: usize,
    stat: isa.Stat,
    hit_limit: bool,
};

pub const Computer = struct {
    st: State = .init(),
    config: Config,
    /// Куда писать трассу и куда консоль. null: не писать.
    trace_out: ?*Writer = null,
    console: ?*Writer = null,
    cycles: usize = 0,

    pub fn init(config: Config) Computer {
        return .{ .config = config };
    }

    /// Накладывает образ на память: ненулевые байты образа ложатся поверх.
    /// Так в одну память попадают ядро и программы, собранные по отдельности.
    pub fn load(self: *Computer, image: []const u8) void {
        for (image, 0..) |byte, at| {
            if (byte != 0 and at < isa.mem_size) self.st.mem[at] = byte;
        }
    }

    pub fn step(self: *Computer) Writer.Error!void {
        const r = cpu.step(&self.st);
        self.cycles += 1;
        try self.record(r);
    }

    pub fn run(self: *Computer) Writer.Error!Summary {
        while (self.st.stat == .aok and self.cycles < self.config.max_steps) try self.step();
        if (self.trace_out) |w| {
            try w.print("halt cycles={d}\n", .{self.cycles});
            try trace.writeRegisters(w, &self.st);
        }
        return .{
            .cycles = self.cycles,
            .stat = self.st.stat,
            .hit_limit = self.st.stat == .aok,
        };
    }

    fn record(self: *Computer, r: cpu.Retired) Writer.Error!void {
        if (r.out) |byte| {
            if (self.console) |c| try c.writeByte(byte);
        }
        const w = self.trace_out orelse return;
        if (!self.config.events_only) {
            try w.print("{c} 0x{x:0>3} {x}{x} {s}\n", .{
                @as(u8, if (r.user) 'U' else 'K'),
                r.pc,
                @intFromEnum(r.icode),
                r.ifun,
                r.stat.name(),
            });
        }
        if (r.out) |byte| try w.print("out {x:0>2}\n", .{byte});
        if (r.exc) |e| try writeEntry(w, "exc", e);
    }
};

fn writeEntry(w: *Writer, kind: []const u8, e: trap.Entry) Writer.Error!void {
    try w.print("{s} {s} 0x{x:0>3} -> 0x{x:0>3}\n", .{ kind, e.cause.name(), e.epc, e.vector });
}

/// Результат прогона текстом: трасса и то, что появилось на консоли.
pub const Output = struct {
    trace: []u8,
    console: []u8,
    summary: Summary,

    pub fn deinit(self: *Output, gpa: std.mem.Allocator) void {
        gpa.free(self.trace);
        gpa.free(self.console);
        self.* = undefined;
    }
};

/// Накладывает образы, прогоняет машину и возвращает трассу с консолью.
pub fn runImages(gpa: std.mem.Allocator, images: []const []const u8, config: Config) std.mem.Allocator.Error!Output {
    var trace_buf: Writer.Allocating = .init(gpa);
    defer trace_buf.deinit();
    var console_buf: Writer.Allocating = .init(gpa);
    defer console_buf.deinit();

    var computer: Computer = .init(config);
    computer.trace_out = &trace_buf.writer;
    computer.console = &console_buf.writer;
    for (images) |image| computer.load(image);
    const summary = computer.run() catch return error.OutOfMemory;

    const trace_text = try trace_buf.toOwnedSlice();
    errdefer gpa.free(trace_text);
    return .{ .trace = trace_text, .console = try console_buf.toOwnedSlice(), .summary = summary };
}

load накладывает образ, а не копирует его: ненулевые байты ложатся поверх. Ядро и процесс можно собрать отдельными файлами и положить в одну память. Сегодня нам это не нужно, hello.ys собран одним куском, но в следующем уроке ядро и два процесса будут тремя файлами.

runImages это то, чем пользуются тесты: прогнать и получить трассу и консоль строками.

src/main.zig и сборка

В main.zig новая команда kernel. Значение в перечислении команд, строка в usage, ветка в switch и функция:

const Command = enum { @"asm", sim, trace, kernel };
        \\y86 kernel kernel.ys [prog.ys ...] [--events] [--console]
        .kernel => kernelCommand(cli, args),
/// Машина с ядром: все образы накладываются в одну память, дальше работает
/// `Computer`. Трасса идёт на стандартный вывод, с `--console` вместо неё
/// печатается то, что ядро отправило в порт вывода.
fn kernelCommand(cli: Cli, args: []const []const u8) !void {
    var config: computer.Config = .{};
    var console_only = false;
    var paths: [8][]const u8 = undefined;
    var count: usize = 0;

    for (args[2..]) |arg| {
        if (std.mem.eql(u8, arg, "--events")) {
            config.events_only = true;
        } else if (std.mem.eql(u8, arg, "--console")) {
            console_only = true;
        } else if (std.mem.startsWith(u8, arg, "--")) {
            try cli.err.print("неизвестный ключ: {s}\n", .{arg});
            return error.BadUsage;
        } else {
            if (count == paths.len) {
                try cli.err.writeAll("слишком много образов\n");
                return error.BadUsage;
            }
            paths[count] = arg;
            count += 1;
        }
    }

    var machine: computer.Computer = .init(config);
    const m = &machine;
    for (paths[0..count]) |path| {
        const image = try loadImage(cli, path);
        defer cli.gpa.free(image);
        m.load(image);
    }
    if (console_only) m.console = cli.out else m.trace_out = cli.out;

    const summary = try m.run();
    if (summary.hit_limit) {
        try cli.err.writeAll("машина не дошла до останова: такты кончились\n");
        return error.StepLimit;
    }
}

Наверху файла импорт const computer = @import("sim/computer.zig");. В src/root.zig две строки, чтобы тесты видели новые модули:

pub const trap = @import("sim/trap.zig");
pub const computer = @import("sim/computer.zig");

В src/programs.zig новое пространство имён для программ с ядром:

/// Ядра и программы для машины с ядром. Каждый файл собирается отдельно,
/// образы накладываются в одну память: ядро с адреса 0, процесс с 0x400.
pub const kernel = struct {
    /// Один процесс, сисколл `write` и сбой INS.
    pub const hello = @embedFile("hello.ys");
};

А в build.zig три правки: шаг "47" в конец массива steps, список программ ядра и цикл, который отдаёт их модулю под своими именами. Программы ядра лежат в отдельном каталоге programs/kernel/ и в общий список не входят: Verilog-модели из уроков 24 до 30 про trap, iret и out не знают, и сверять с ними трассу нечего.

/// Ядра и пользовательские программы для них. В общий список они не входят:
/// Verilog-модели инструкций `trap`, `iret` и `out` не знают.
const kernel_programs = [_][]const u8{"hello"};
    for (kernel_programs) |name| {
        y86.addAnonymousImport(b.fmt("{s}.ys", .{name}), .{
            .root_source_file = b.path(b.fmt("programs/kernel/{s}.ys", .{name})),
        });
    }

Первое ядро: programs/kernel/hello.ys

Ядро на три десятка инструкций и процесс на пять. Процесс печатает hello через системный вызов write, а потом пробует halt, который ему запрещён.

# Первое ядро: один процесс и один системный вызов.
#
# Машина стартует в режиме ядра с адреса 0. Ядро собирает кадр исключения
# руками и делает iret: так процесс попадает в пользовательский режим.
# Дальше процесс зовёт write через trap, а потом пробует halt, который ему
# запрещён: это сбой INS, и ядро останавливает машину само.

        .pos 0
boot:   irmovq kstack, %rsp
        irmovq ustack, %rax
        pushq %rax              # %rsp процесса
        irmovq $0x11, %rax      # слово состояния: пользовательский режим, ZF = 1
        pushq %rax
        irmovq user, %rax
        pushq %rax              # с какого адреса процесс начнёт
        iret

# Системный вызов. Номер в %rax, аргументы в %rdi и %rsi, как в Linux.
# Железо уже пересадило нас на стек ядра и положило туда кадр.
on_trap:
        pushq %rcx
        irmovq $1, %rcx
        subq %rcx, %rax         # write это номер 1
        jne bad
        rrmovq %rsi, %rax       # write вернёт длину
        call kwrite
        popq %rcx
        iret
bad:    irmovq $-1, %rax        # такого вызова нет
        popq %rcx
        iret

# Сбои процесса. Он у нас один, так что после сообщения машина встаёт.
on_adr: irmovq msg_adr, %rdi
        jmp die
on_ins: irmovq msg_ins, %rdi
die:    irmovq $4, %rsi
        call kwrite
        halt

# kwrite(%rdi = адрес, %rsi = длина): байты по одному уходят в порт вывода.
# Побайтного чтения в Y86-64 нет, поэтому читаем слово и отдаём младший байт.
kwrite: andq %rsi, %rsi
        je kdone
        mrmovq (%rdi), %rcx
        out %rcx
        iaddq $1, %rdi
        iaddq $-1, %rsi
        jmp kwrite
kdone:  ret

        .align 8
msg_adr:
        .quad 0x0a524441        # "ADR\n", младший байт первым
msg_ins:
        .quad 0x0a534e49        # "INS\n"

        .pos 0x300
kstack:

# Пользовательская программа.
        .pos 0x400
user:   irmovq $1, %rax         # write
        irmovq hello, %rdi
        irmovq $6, %rsi
        trap
        halt                    # привилегированная инструкция: сбой INS

        .align 8
hello:  .quad 0x0a6f6c6c6568    # "hello\n"

        .pos 0x800
ustack:

# Таблица исключений: trap, ADR, INS, таймер. За ней вершина стека ядра.
        .pos 0xfc0
        .quad on_trap
        .quad on_adr
        .quad on_ins
        .quad 0
        .quad kstack

Самое интересное здесь boot. Машина стартует в режиме ядра, а инструкции «перейти в пользовательский режим» у нас нет, и у x86-64 её тоже нет. Есть только iret, который снимает кадр. Значит, ядро собирает кадр руками: кладёт на свой стек прежний %rsp будущего процесса, слово состояния 0x11 (бит user и ZF) и адрес первой инструкции процесса, и делает iret. Процессор честно «возвращается» туда, где процесс никогда не был. Linux запускает первый процесс, init, ровно так же.

Системный вызов устроен по договору Linux: номер в %rax, аргументы в %rdi и %rsi, ответ в %rax, номер write тоже единица. Обработчик on_trap сохраняет %rcx, потому что kwrite его портит, а процесс вправе ожидать, что после вызова его регистры на месте (кроме %rax с ответом; заметь, что %rdi и %rsi наше ядро портит, это упрощение, настоящее ядро такого себе не позволяет). На неизвестный номер ядро отвечает минус единицей: наш маленький ENOSYS.

Сбои приходят в on_adr и on_ins. Процесс у нас один, так что после сообщения ядро останавливает машину своей инструкцией halt, которая ядру разрешена.

Побайтного чтения в Y86 нет, поэтому kwrite читает слово с адреса очередного байта и отдаёт в порт младший байт. Строки упакованы в .quad задом наперёд: младший байт первым, как и положено на little-endian.

Прогон

$ zig build run -- kernel programs/kernel/hello.ys --console
hello
INS

Процесс напечатал своё слово через ядро и умер от сбоя, ядро напечатало причину. Трасса целиком это 101 такт, начало и оба входа в ядро выглядят так:

$ zig build run -- kernel programs/kernel/hello.ys
K 0x000 30 AOK
K 0x00a 30 AOK
K 0x014 a0 AOK
K 0x016 30 AOK
K 0x020 a0 AOK
K 0x022 30 AOK
K 0x02c a0 AOK
K 0x02e d1 AOK
U 0x400 30 AOK
U 0x40a 30 AOK
U 0x414 30 AOK
U 0x41e d0 AOK
exc trap 0x41f -> 0x02f
K 0x02f a0 AOK
K 0x031 30 AOK
K 0x03b 61 AOK
K 0x03d 74 AOK
K 0x046 20 AOK
K 0x048 80 AOK
K 0x092 62 AOK
K 0x094 73 AOK
K 0x09d 50 AOK
K 0x0a7 e0 AOK
out 68
...
K 0x0c6 90 AOK
K 0x051 b0 AOK
K 0x053 d1 AOK
U 0x41f 00 INS
exc ins 0x41f -> 0x074
K 0x074 30 AOK
...
K 0x091 00 HLT
halt cycles=101

Семь инструкций загрузки в режиме K, потом iret на 0x02e, и следующая строка уже U 0x400: машина в пользовательском режиме, на первой инструкции процесса. На 0x41e стоит trap, и строка события говорит: адрес возврата 0x41f, обработчик 0x02f. Ядро печатает шесть байтов, по одной строке out на байт, возвращается через iret на 0x053, и процесс продолжает с 0x41f, где лежит halt. Эта инструкция в режиме U получает код INS, но машина не встаёт: exc ins 0x41f -> 0x074. Заметь, что адрес возврата у сбоя это сама сбойная инструкция 0x41f, а у trap был адрес за ней. Последняя строка это halt ядра, законный.

Ключ --events оставляет только события:

$ zig build run -- kernel programs/kernel/hello.ys --events
exc trap 0x41f -> 0x02f
out 68
out 65
out 6c
out 6c
out 6f
out 0a
exc ins 0x41f -> 0x074
out 49
out 4e
out 53
out 0a
halt cycles=101

Из 101 такта процесс исполнил пять инструкций. Остальное загрузка, два входа в ядро и побайтный вывод. Для урока это нормально, а в уроке 50 именно эта пропорция станет заметной, когда за процессор начнут бороться двое.

Тесты шага

//! Шаг 47: бит привилегий, `trap` и `iret`, таблица исключений, порт вывода.
//!
//! Проверяется четыре вещи. Новые инструкции собираются и разбираются
//! обратно. Машина без ядра ведёт себя как раньше: сбой её останавливает.
//! С ядром системный вызов доходит до консоли, а сбой процесса превращается
//! в исключение с обработчиком. И привилегированные инструкции процессу
//! недоступны.

const std = @import("std");
const y86 = @import("y86");

const testing = std.testing;
const computer = y86.computer;
const isa = y86.isa;

fn assemble(source: []const u8) ![]u8 {
    var result = try y86.assembler.assemble(testing.allocator, source, null);
    defer result.deinit(testing.allocator);
    return testing.allocator.dupe(u8, result.image);
}

test "trap, iret и out: мнемоники, байты, длина" {
    const image = try assemble(
        \\        trap
        \\        iret
        \\        out %rcx
        \\
    );
    defer testing.allocator.free(image);
    try testing.expectEqualSlices(u8, &[_]u8{ 0xd0, 0xd1, 0xe0, 0x1f }, image[0..4]);

    try testing.expectEqual(@as(u8, 1), isa.length(.sys));
    try testing.expectEqual(@as(u8, 2), isa.length(.out));
    try testing.expectEqualStrings("iret", isa.mnemonicOf(.sys, 1).?);
    // Функций у кода 0xd только две.
    try testing.expect(!(try y86.encoder.decode(&[_]u8{0xd2})).valid);
}

test "без ядра всё по-старому: сбой останавливает машину" {
    // Машина после сброса в режиме ядра, таблица пустая.
    var st: y86.State = .init();
    try testing.expect(!st.user);
    st.load(&[_]u8{ 0x50, 0x30, 0xff, 0x0f, 0, 0, 0, 0, 0, 0 });
    _ = y86.cpu.run(&st, 10);
    try testing.expectEqual(y86.Stat.adr, st.stat);

    // `trap` из режима ядра ловить некому: это недопустимая инструкция.
    var lonely: y86.State = .init();
    lonely.load(&[_]u8{0xd0});
    _ = y86.cpu.run(&lonely, 10);
    try testing.expectEqual(y86.Stat.ins, lonely.stat);
}

test "write доходит до консоли, halt из процесса это сбой INS" {
    const image = try assemble(y86.programs.kernel.hello);
    defer testing.allocator.free(image);

    var out = try computer.runImages(testing.allocator, &.{image}, .{});
    defer out.deinit(testing.allocator);

    try testing.expectEqualStrings("hello\nINS\n", out.console);
    try testing.expectEqual(y86.Stat.hlt, out.summary.stat);

    // Вход в ядро и возврат видны в трассе: процесс в режиме U, ядро в K.
    try testing.expect(std.mem.indexOf(u8, out.trace, "U 0x41e d0 AOK\nexc trap 0x41f -> ") != null);
    // Сбойная инструкция печатается со своим кодом, а машина остаётся в AOK.
    try testing.expect(std.mem.indexOf(u8, out.trace, "U 0x41f 00 INS\nexc ins 0x41f -> ") != null);
    // Адрес возврата у `trap` это следующая инструкция, у сбоя сама сбойная.
}

test "сбой ADR в процессе уходит в свой обработчик" {
    // То же ядро, но процесс читает слово, которое не помещается в память.
    const source = try std.mem.replaceOwned(
        u8,
        testing.allocator,
        y86.programs.kernel.hello,
        "        halt                    # привилегированная инструкция: сбой INS\n",
        "        mrmovq 0xfff(%rbx), %rcx\n",
    );
    defer testing.allocator.free(source);
    const image = try assemble(source);
    defer testing.allocator.free(image);

    var out = try computer.runImages(testing.allocator, &.{image}, .{});
    defer out.deinit(testing.allocator);
    try testing.expectEqualStrings("hello\nADR\n", out.console);
}

test "процессу нельзя ни iret, ни out" {
    for ([_][]const u8{ "        iret\n", "        out %rax\n" }) |line| {
        const source = try std.mem.replaceOwned(
            u8,
            testing.allocator,
            y86.programs.kernel.hello,
            "        halt                    # привилегированная инструкция: сбой INS\n",
            line,
        );
        defer testing.allocator.free(source);
        const image = try assemble(source);
        defer testing.allocator.free(image);

        var out = try computer.runImages(testing.allocator, &.{image}, .{});
        defer out.deinit(testing.allocator);
        try testing.expectEqualStrings("hello\nINS\n", out.console);
    }
}

test "системный вызов возвращает значение в %rax" {
    // Неизвестный номер: ядро кладёт в %rax минус единицу и возвращается.
    const source = try std.mem.replaceOwned(
        u8,
        testing.allocator,
        y86.programs.kernel.hello,
        "user:   irmovq $1, %rax         # write\n",
        "user:   irmovq $9, %rax\n",
    );
    defer testing.allocator.free(source);
    const image = try assemble(source);
    defer testing.allocator.free(image);

    var machine: computer.Computer = .init(.{});
    machine.load(image);
    // До первой инструкции после `trap`: там %rax уже с ответом ядра.
    while (!(machine.st.user and machine.st.pc == 0x41f)) try machine.step();
    try testing.expectEqual(@as(u64, @bitCast(@as(i64, -1))), machine.st.get(.rax));
    try testing.expect(machine.st.user);
}

Три теста подменяют в hello.ys одну строку через std.mem.replaceOwned и проверяют другие пути через ядро: процесс читает за границей памяти (сбой ADR уходит в свою строку таблицы), процесс пробует iret и out (оба запрещены), процесс зовёт вызов номер 9, которого нет (ядро отвечает минус единицей в %rax). Последний тест крутит машину по такту и останавливается на первой инструкции после trap, чтобы посмотреть регистр ровно в тот момент, когда процесс получает ответ.

$ zig build test -Dstep=47 --summary all
Build Summary: 5/5 steps succeeded; 42/42 tests passed
test success
+- run test 36 pass (36 total) 6ms MaxRSS:3M
|  +- compile test Debug native cached 39ms MaxRSS:35M
+- run test 6 pass (6 total) 14ms MaxRSS:3M
   +- compile test Debug native cached 39ms MaxRSS:35M

Тридцать шесть это модульные тесты самого симулятора, вместе с четырьмя новыми из trap.zig, они идут с каждым шагом. Тестов шага шесть. Старые шаги остаются зелёными, zig build test целиком даёт 94 теста из 94: машина после сброса в режиме ядра, и для программ без ядра не изменилось ничего.

На macOS

Всё, что касается Y86, работает на macOS как есть: машина наша, и ей безразлично, на чём её гонять. Чистая половина zt strace тоже. А вот живой трассировщик на macOS не собрать, и причин три.

Другое соглашение о вызовах. У ядра XNU на Apple Silicon номер вызова лежит в x16, а не в x8, ловушка это svc #0x80, номера свои (write 4, getpid 20), и ошибка кодируется иначе: не отрицательным числом, а флагом переноса C в слове NZCV, и тогда в x0 лежит положительный errno. Проверка на той же машине, macOS 26:

const std = @import("std");

/// Ответ ядра Darwin: регистр x0 и флаг переноса C из слова NZCV.
const Reply = struct { x0: usize, carry: bool };

/// write(fd, buf, len) напрямую: номер в x16, ловушка svc #0x80.
fn darwinWrite(fd: usize, buf: []const u8) Reply {
    var nzcv: usize = undefined;
    const x0 = asm volatile (
        \\svc #0x80
        \\mrs %[flags], nzcv
        : [ret] "={x0}" (-> usize),
          [flags] "=r" (nzcv),
        : [number] "{x16}" (@as(usize, 4)),
          [fd] "{x0}" (fd),
          [ptr] "{x1}" (@intFromPtr(buf.ptr)),
          [len] "{x2}" (buf.len),
        : .{ .memory = true, .x1 = true });
    return .{ .x0 = x0, .carry = nzcv & (1 << 29) != 0 };
}

pub fn main() void {
    const ok = darwinWrite(1, "в стандартный вывод\n");
    const bad = darwinWrite(9, "в закрытый дескриптор\n");
    var buf: [128]u8 = undefined;
    const text = std.fmt.bufPrint(&buf, "fd 1: x0 = {d}, C = {}\nfd 9: x0 = {d}, C = {}\n", .{ ok.x0, ok.carry, bad.x0, bad.carry }) catch unreachable;
    _ = darwinWrite(1, text);
}
$ zig build-exe darwin_syscall.zig && ./darwin_syscall
в стандартный вывод
fd 1: x0 = 37, C = false
fd 9: x0 = 9, C = true

Тридцать семь байтов записано, флаг чист. На закрытом дескрипторе в x0 девятка со знаком плюс, та же EBADF, а об ошибке говорит только флаг. Смотреть на одно число, как в Linux, здесь нельзя: девятка могла быть и числом записанных байтов.

А что будет, если запустить на macOS программу, которая зовёт ядро по-линуксовски? faults из начала урока печатает через std.os.linux.write, то есть svc #0 с номером в x8. Собирается она без единого предупреждения, потому что это просто ассемблер:

$ zig build-exe faults.zig -O ReleaseSafe
$ ./faults segv; echo "exit=$?"
Bad system call: 12
exit=140

До записи по нулевому адресу дело не дошло: первая же печать ушла в ядро Darwin с номером из x16, где лежало что попало, и ядро ответило сигналом SIGSYS (номер 12, отсюда код 140). С печатью через std.c.write та же программа ведёт себя как на Linux: Segmentation fault: 11 и код 139, Illegal instruction: 4 и 132, а деление на ноль и тут даёт ноль без всякого исключения, потому что процессор тот же.

Номера не обещаны. Apple не считает номера системных вызовов публичным интерфейсом и меняет их между версиями. Единственный поддерживаемый путь в ядро это libSystem: и std.c в Zig, и Go (с версии 1.11) на macOS зовут ядро только через неё. Поэтому в уроке про оболочку мы пойдём через libc, а не через голый svc.

Нет PTRACE_SYSCALL. ptrace на macOS есть, но урезанный: PT_TRACE_ME и PT_ATTACHEXC остались, остановок на системных вызовах и чтения регистров нет. Отладчики на macOS работают через порты исключений Mach и thread_get_state. Готовый аналог strace называется dtruss, он лежит в /usr/bin и построен на DTrace, но при включённой защите целостности системы (SIP, проверь csrutil status) трассировать почти ничего не может. Честная дорога на Mac это контейнер linux/arm64, как в этом уроке.

И последнее, про само железо. У ARM вместо колец уровни исключений: EL0 для программ, EL1 для ядра, EL2 для гипервизора. Таблица исключений это шестнадцать входов по 0x80 байт по адресу из регистра VBAR_EL1: четыре вида (синхронное, IRQ, FIQ, SError) на четыре источника. Причину синхронного исключения ядро читает из регистра ESR_EL1, где у svc свой код класса, а у сбоя страницы свой. Кадр в память железо не кладёт: адрес возврата уходит в ELR_EL1, слово состояния в SPSR_EL1, и возврат из обработчика это eret.

Практика

getpid самый простой системный вызов: аргументов нет, ошибок не бывает. В задаче ты напишешь syscall0 сам, для двух архитектур, и поверх него getpid и getppid (номер 110 на x86-64, 173 на aarch64). Скрытые тесты сверят ответы с std.os.linux, проверят, что на несуществующий номер приходит маленькое отрицательное число, и сделают fork, чтобы убедиться, что в ребёнке getpid новый, а getppid это номер родителя.

Упражнения

Итоги

  • Исключение это передача управления ядру по номеру события через таблицу исключений. Шагов всегда четыре: событие, номер, вход в режим ядра со сменой стека и кадром, возврат специальной инструкцией.
  • Класса четыре. Прерывание приходит от устройства и возвращает к следующей инструкции. Ловушка это просьба программы, возврат тоже к следующей. Сбой возвращает к той же инструкции, если ядро его исправило, иначе процесс получает сигнал. Аварийное завершение не возвращает никуда.
  • Процесс не может сам поднять привилегии: единственная дорога в режим ядра это исключение, а адрес назначения берётся из таблицы, которую заполнило ядро. На x86-64 режим это CPL в %cs, у ARM уровни EL0 и EL1.
  • Номера исключений x86-64 с 0 по 31 назначены архитектурой: 0 деление, 6 мусорная инструкция, 13 общая защита, 14 сбой страницы. Ядро превращает неисправимые в сигналы: SIGFPE, SIGILL, SIGSEGV, SIGBUS. На ARM целочисленное деление на ноль исключения не даёт вовсе.
  • Системный вызов Linux x86-64: номер в %rax, аргументы в %rdi %rsi %rdx %r10 %r8 %r9, инструкция syscall портит %rcx и %r11, ответ в %rax, ошибка это число от минус 4095 до минус 1. На aarch64 номер в x8, аргументы в x0 до x5, svc #0, номера свои.
  • std.os.linux это тонкие обёртки: usize на выходе, errno(rc) для разбора. std.posix превращает код в значение из error set, повторяет вызов при EINTR, ошибки программиста считает unreachable, а неизвестные коды отдаёт как error.Unexpected.
  • Вход в ядро стоит около ста наносекунд даже для пустого вызова. vDSO даёт clock_gettime без входа в ядро за десяток.
  • strace стоит на ptrace: ядро останавливает потомка на входе и выходе из каждого вызова, трассировщик читает регистры и память. Каждый вызов под трассировкой обходится в микросекунды, программа медленнее в разы.
  • zt strace это fork, PTRACE_TRACEME, встреча через SIGSTOP, опции TRACESYSGOOD, TRACEEXEC, EXITKILL, цикл PTRACE_SYSCALL и waitpid. Аргументы запоминаются на входе, строки читаются из чужой памяти через PTRACE_PEEKDATA, сигналы передаются дальше. Трассировщик не невидим: потомок наследует его пределы и окружение.
  • У Y86 теперь два режима, таблица из четырёх причин по 0xfc0, ksp по 0xfe0, кадр из трёх слов, инструкции trap, iret и out. Процесс в пользовательский режим попадает через iret по кадру, который ядро собрало руками. Адрес возврата у ловушки следующая инструкция, у сбоя сама сбойная, и в симуляторе это получается само.

Дальше

Сегодня управление впервые ушло из программы не по её воле: исключение увело процессор в ядро и вернуло обратно. Ядро Y86 умеет держать один процесс, а трассировщик zt уже пользуется парой fork и waitpid, которую мы пока взяли на веру. В следующем уроке разберём процессы по-настоящему: как fork возвращается дважды, почему execve не возвращается совсем, что такое зомби и как разобрать слово состояния из waitpid. zbox получит свою первую команду, а ядро Y86 научится держать два процесса и переключаться между ними.

домашка

Домашка