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

Процессы: fork, execve, waitpid

senior~190 мин

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

Процессы: fork, execve, waitpid

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

Цели урока

  • Объяснять процесс как две иллюзии: свой процессор (логический поток управления) и своя память (изолированное адресное пространство), и знать, чем каждая обеспечена.
  • Понимать переключение контекста: что сохраняется, когда оно случается, сколько стоит на железе 2026 года.
  • Писать fork на Zig 0.16, где в std.posix его нет, и читать программу с fork как граф процессов: какие выводы возможны, какие нет.
  • Не попадаться в две ловушки вывода: непустой буфер, который fork удваивает, и позиционная запись, при которой родитель затирает строку ребёнка.
  • Утилизировать детей через waitpid, разбирать слово состояния по битам и знать, откуда берутся зомби и куда деваются сироты.
  • Заменять образ процесса через execve, понимать, что переживает замену, и почему fork и execve разделены.
  • Знать, что делает за тебя std.process.spawn и когда его мало.
  • Написать zbox run: запуск чужой программы с отчётом о коде возврата, сигнале, времени процессора и пике памяти.
  • Сохранить контекст процесса на Y86 руками: PCB, два образа в памяти и ядро на ассемблере, которое переключает их по yield.

Идея: две иллюзии

Запусти на своей машине ps ax | wc -l. У меня выходит 1020 строк на ноутбуке с шестнадцатью ядрами. Тысяча программ, каждая написана так, будто она одна: никто не проверяет, свободен ли процессор, никто не договаривается с соседями, по каким адресам класть свои данные. Это работает, потому что операционная система выдаёт каждой программе процесс: экземпляр программы в исполнении вместе с его контекстом. Контекст это всё состояние, без которого выполнение не продолжить: код и данные в памяти, стек, содержимое регистров, счётчик команд, переменные окружения, таблица открытых файлов.

Процесс даёт программе две иллюзии.

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

Своя память. Кажется, что все адреса от нуля до верхней границы принадлежат одной программе. У каждого процесса своё адресное пространство, и байт по адресу 0x7ffc1000 в твоём процессе не имеет никакого отношения к байту по тому же адресу у соседа. Прочитать или испортить чужую память обычной инструкцией нельзя: до неё просто нет адреса. Раскладка у всех одинаковая, ты видел её в уроке про загрузку: внизу код и данные из исполняемого файла, выше куча, посередине разделяемые библиотеки, наверху стек, а самая верхняя часть отдана ядру и процессу недоступна. Как именно железо превращает одинаковые адреса разных процессов в разные физические байты, разберём через пять уроков, в блоке про виртуальную память. Сегодня достаточно факта: пространства изолированы.

Конкурентные потоки

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

Возьми три процесса с такими временами жизни:

ПроцессНачалоКонец
A02
B14
C35

A и B конкурентны: B начался в момент 1, когда A ещё работал. B и C тоже. А вот A и C нет: A закончился в момент 2, C начался в момент 3, общего времени у них не было. Зачем программисту это различие? Затем, что для любой пары конкурентных потоков ты обязан считать, что их инструкции могут перемешаться в любом порядке. Вся вторая половина урока про графы процессов стоит на этом допущении, а в уроке про сигналы оно же станет источником настоящих гонок.

Отрезок времени, который процесс работает без перерыва, называют квантом. В Linux с версии 6.6 планировщик называется EEVDF, и базовый квант у него 3 мс на машине с восемью ядрами и больше (файл /sys/kernel/debug/sched/base_slice_ns, число 3000000; на одном ядре 0,75 мс). Это верхняя оценка: процесс, который ушёл ждать диск или сеть, отдаёт процессор раньше.

Переключение контекста

Ядро держит для каждого процесса его контекст и умеет делать одну операцию, на которой стоит вся многозадачность: переключение контекста. Шагов три: сохранить контекст текущего процесса, восстановить контекст того, кого решили запустить, передать ему управление. Решение принимает планировщик, часть ядра.

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

  • Системный вызов, который не может завершиться сразу. Процесс попросил read с диска. Ждать данные миллисекунды, за это время можно выполнить десять миллионов чужих инструкций. Ядро усыпляет просителя и запускает другого.
  • Системный вызов, который прямо просит об этом. sleep, sched_yield, waitpid на живом ребёнке.
  • Прерывание таймера. Процесс ни о чём не просил, но его квант истёк. Это вытесняющая многозадачность: процессор отбирают, не спрашивая.

Вот как выглядит read с диска, если смотреть на физический процессор:

время
  |   процесс A, режим пользователя
  |   A зовёт read: trap, вход в ядро
  |   ядро от имени A: запрос ушёл на диск, A засыпает
  |   ---- переключение контекста A -> B ----
  |   процесс B, режим пользователя
  |   ...
  |   прерывание от диска: данные приехали
  |   ядро от имени B: помечает A готовым, решает вернуть ему процессор
  |   ---- переключение контекста B -> A ----
  |   ядро от имени A: копирует данные в буфер, возврат из read
  v   процесс A, режим пользователя

Обрати внимание на фразу “ядро от имени A”. Ядро это не отдельный процесс, который где-то работает сам по себе. Это код, который выполняется в контексте того процесса, который в него вошёл. У каждого процесса в Linux есть свой стек ядра (16 КБ на x86-64), и когда A засыпает посреди read, его недовыполненный системный вызов остаётся лежать на этом стеке до пробуждения.

Сколько это стоит? Само переключение на x86-64 это сохранение полутора десятков регистров общего назначения, около 2,5 КБ векторного состояния через XSAVE (если включён AVX-512), смена регистра CR3 с корнем таблицы страниц и работа планировщика. Прямая цена на современном ядре это единицы микросекунд: столько показывает perf bench sched pipe, который гоняет байт между двумя процессами через пайп и на каждый обмен платит двумя переключениями и двумя системными вызовами. Косвенная цена больше: новый процесс приходит на холодные кэши и холодный TLB, и первые тысячи его обращений к памяти идут по медленному пути из блока про иерархию памяти. Поэтому квант измеряется миллисекундами, а не микросекундами: переключаться чаще значит тратить заметную долю времени на прогрев.

Запомни три слова, которыми описывают состояние процесса с точки зрения программиста. Выполняется: либо прямо сейчас на процессоре, либо ждёт своей очереди и будет запущен. Остановлен: приостановлен сигналом SIGSTOP или SIGTSTP и не будет запущен, пока не придёт SIGCONT. Завершён: закончился навсегда. Завершиться можно тремя способами: вернуться из main, позвать exit, получить сигнал, действие которого по умолчанию это смерть.

Номер процесса и fork

У каждого процесса есть положительный номер, pid. getpid возвращает свой номер, getppid номер родителя. Родитель есть у всех, кроме самого первого процесса с номером 1, которого ядро создаёт само при загрузке.

Новый процесс создаёт системный вызов fork. Он не принимает аргументов и не запускает никакой программы. Он делает копию того процесса, который его позвал. У копии тот же код, те же значения всех переменных, тот же стек, те же открытые файлы, тот же счётчик команд: оба процесса продолжают с одной и той же точки, с возврата из fork. Отличаются они двумя вещами: номером процесса и тем, что fork им вернул. Родителю он возвращает pid ребёнка, ребёнку ноль. fork это функция, которую зовут один раз, а возвращается она дважды, в двух разных процессах.

Где fork в Zig 0.16

Тут первая неожиданность. В std.posix версии 0.16 нет ни fork, ни execve, ни waitpid: при переезде на std.Io их оттуда убрали, оставив порождение процессов за std.process.spawn. Голые вызовы живут в двух местах.

ОткудаЧто даётЦена
std.cfork, execve, waitpid, wait4, _exit, getpid из libc, с привычными сигнатурами и errnoпрограмма собирается с -lc; работает на Linux и на macOS
std.os.linuxсырые системные вызовы: fork() возвращает usize, ошибка закодирована как минус errnoтолько Linux; зато без libc, статический бинарник без зависимостей

В уроке и в zbox мы берём std.c: одна и та же программа работает и на Linux, и на macOS. В задаче из раздела “Практика” наоборот, std.os.linux: там libc нет, и ты увидишь сырой интерфейс ядра из прошлого урока. Чего нет даже в std.c, например execvp, объявляется одной строкой extern "c" fn.

Первая программа

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

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

pub fn main() void {
    var x: i32 = 1;

    const pid = c.fork();
    if (pid < 0) {
        say("fork не удался\n", .{});
        return;
    }
    if (pid == 0) {
        // Ребёнок: fork вернул ноль.
        x += 1;
        say("ребёнок:  pid={d} ppid={d} x={d} по адресу {*}\n", .{ c.getpid(), c.getppid(), x, &x });
        c._exit(0);
    }

    // Родитель: fork вернул pid ребёнка.
    x -= 1;
    say("родитель: pid={d} ребёнок={d} x={d} по адресу {*}\n", .{ c.getpid(), pid, x, &x });
    _ = c.waitpid(pid, null, 0);
}

Функция say пишет прямо в дескриптор 1 через write, без буфера. Почему так, а не через привычный std.Io.Writer, станет ясно через два раздела. Собери и запусти:

$ zig build-exe fork_x.zig -lc
$ ./fork_x
родитель: pid=257 ребёнок=258 x=0 по адресу i32@7ffffffca8a8
ребёнок:  pid=258 ppid=257 x=2 по адресу i32@7ffffffca8a8

Этот и остальные выводы урока сняты на Linux 7.0 (x86-64 под эмулятором Rosetta в Docker на машине с Apple Silicon), если рядом не сказано другое.

В этих двух строках весь fork.

  • Позвали раз, вернулся дважды. Одна строка от родителя, одна от ребёнка. В родителе pid равен 258, в ребёнке нулю.
  • Процессы конкурентны. Сегодня первым успел родитель. Завтра может успеть ребёнок: ядро вправе запустить любого. Программа, которая полагается на порядок, сломана, даже если тысячу раз подряд ей везло.
  • Адресные пространства одинаковые, но раздельные. Обе строки показывают один и тот же адрес переменной x, до последней цифры. Но родитель видит там 0, а ребёнок 2. Один виртуальный адрес, два разных байта в физической памяти. Каждый менял свою копию, и сосед об этом не узнал. Копировать всю память при fork было бы дорого, и ядро этого не делает: страницы копируются по одной, в момент первой записи. Механизм называется copy-on-write, он ждёт тебя в уроке про отображение памяти.
  • Открытые файлы общие. Ребёнок не открывал stdout, он его унаследовал. Обе строки попали в один терминал. У этого наследования есть тонкость с позицией в файле, до неё тоже дойдём.

Ребёнок выходит через _exit, а не возвращается из main. Это привычка, которую стоит завести сразу: код после if (pid == 0) принадлежит родителю, и ребёнок, который туда провалился, начинает выполнять чужую работу. В этой программе он позвал бы waitpid и получил ошибку, в программе посложнее он запустил бы второй веб-сервер на том же порту.

Графы процессов

Программу с одним fork легко удержать в голове. С двумя уже труднее, а с fork в цикле и waitpid вперемешку голова отказывает. Помогает картинка: граф процессов. Рисуется он так.

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

Рёбра задают частичный порядок: что обязано случиться раньше чего. Вдоль одной линии порядок жёсткий. Между линиями порядка нет, кроме рёбер fork и waitpid. Допустимый вывод программы это любая топологическая сортировка графа: выпиши все вершины в ряд так, чтобы каждое ребро шло слева направо. Если для какого-то вывода это удаётся, он возможен. Если хоть одно ребро приходится развернуть, такого вывода не бывает.

Возьмём программу, где есть всё сразу.

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

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

pub fn main() void {
    say("Hello\n");
    const pid = c.fork();
    if (pid == 0) {
        say("1\n");
    } else {
        say("0\n");
        _ = c.waitpid(pid, null, 0);
        say("2\n");
    }
    say("Bye\n");
}

Сначала Hello, потом fork. Ребёнок печатает 1 и Bye. Родитель печатает 0, ждёт ребёнка, печатает 2 и Bye. Ограничений три: Hello раньше всех; в ребёнке 1 раньше его Bye; в родителе 0, потом 2, потом Bye, причём 2 только после Bye ребёнка. Открой этот граф в виджете, он уже выбран. Сверху линии процессов, под ними все различимые выводы. В поле внизу можно ввести свой вариант через пробел и узнать, возможен ли он.

Проверь себя: вывод Hello 1 0 Bye 2 Bye допустим, а Hello 0 2 1 Bye Bye нет, потому что 2 обогнала Bye ребёнка, а между ними ребро waitpid.

Теперь сверим модель с машиной. Я прогнал программу 300 раз и сосчитал разные выводы:

$ for i in $(seq 1 300); do ./fork_status | tr '\n' ' '; echo; done | sort | uniq -c
    294 Hello 0 1 Bye 2 Bye
      6 Hello 1 Bye 0 2 Bye

Оба вывода есть в списке виджета. Но виджет показывает три допустимых вывода, а выпали два, причём один в 98 процентах случаев. Третий, Hello 1 0 Bye 2 Bye, требует, чтобы ребёнка прервали ровно между двумя его write, а это окно в несколько микросекунд. Отсюда главный практический вывод раздела: редкий порядок не значит невозможный. Тесты его не поймают, нагрузка в продакшене поймает. Рассуждать о конкурентных программах надо по графу, а не по прогонам.

Переключи в виджете программу на “два fork подряд” и на “fork в цикле”. Каждый fork удваивает число процессов, потому что его выполняют все, кто до него дожил: после двух вызовов процессов четыре, после трёх в цикле восемь. Запусти fork в цикле без условия выхода, и получится форк-бомба: число процессов растёт как степень двойки, пока не упрётся в лимит RLIMIT_NPROC или в pids.max группы cgroups. Раннер этого курса запускает твои решения с --pids-limit, и теперь ты знаешь зачем.

Ловушка: fork и буфер вывода

До сих пор мы печатали через голый write. Вернём привычный буферизованный Writer из бойлерплейта раздела и сделаем одну ошибку.

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

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &w.interface;

    try out.writeAll("привет до fork\n");
    // Ошибка: строка ещё лежит в buf, а buf это обычная память процесса.

    const pid = c.fork();
    if (pid == 0) {
        try out.writeAll("ребёнок\n");
        try out.flush();
        c._exit(0);
    }
    _ = c.waitpid(pid, null, 0);
    try out.writeAll("родитель\n");
    try out.flush();
}
$ ./fork_buffer
привет до fork
ребёнок
привет до fork
родитель

Строка, которую программа печатает один раз и до fork, вышла дважды. Причина в том, что writeAll ничего не вывел: он положил байты в массив buf. Массив это обычная память процесса. fork скопировал память, и у ребёнка оказался свой buf с той же непечатанной строкой. Потом каждый сделал flush.

В C эта ошибка коварнее, потому что буфер спрятан внутри printf, а его режим зависит от того, куда смотрит stdout. На терминале stdio сбрасывает буфер на каждом переводе строки, и программа ведёт себя правильно. Стоит перенаправить вывод в файл или в пайп, и буфер начинает копиться до 4 КБ: ./prog печатает строку один раз, а ./prog | cat дважды. Люди находят это через месяц, когда программу впервые запускают из cron. В Zig буфер твой и на виду, поэтому удвоение воспроизводится всегда, и это хорошо.

Правил два.

  1. Перед fork буферы пусты. Либо flush непосредственно перед вызовом, либо до fork в буфер вообще ничего не пишется. zbox пойдёт вторым путём.
  2. Ребёнок после неудачи выходит через _exit. Обычный exit из libc сбрасывает буферы stdio и выполняет обработчики atexit, которые ребёнок унаследовал от родителя: временные файлы удалятся дважды, в лог допишется чужой хвост. std.process.exit в программе, собранной с libc, зовёт именно такой exit. _exit это голый системный вызов, он просто завершает процесс.

Вторая ловушка: общая позиция в файле

В демо выше стоит writerStreaming, а не writer, и это не случайность. Замени одно на другое и перенаправь вывод в файл:

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

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;

    const pid = c.fork();
    if (pid == 0) {
        try out.writeAll("ребёнок написал эту строку\n");
        try out.flush();
        c._exit(0);
    }
    _ = c.waitpid(pid, null, 0);
    try out.writeAll("родитель\n");
    try out.flush();
}
$ ./fork_offset | cat
ребёнок написал эту строку
родитель
$ ./fork_offset > out.txt
$ cat out.txt
родитель
аписал эту строку

В пайп всё пришло целиком, а в файле родитель затёр начало строки ребёнка. Разберёмся, что тут общее, а что нет. После fork дескриптор 1 в обоих процессах указывает на одну и ту же запись в таблице открытых файлов ядра, а позиция в файле хранится именно в этой записи. Поэтому обычный write у родителя и ребёнка дописывает по очереди, ничего не теряя: ребёнок сдвинул общую позицию, родитель пишет с неё. Но File.writer в Zig 0.16 для обычного файла работает в позиционном режиме: он зовёт pwrite и сам ведёт счёт смещению, начиная с нуля, в своей памяти. Память после fork у каждого своя. Ребёнок записал строку по смещению 0 и сдвинул свой счётчик. Счётчик родителя остался нулём, и его pwrite лёг поверх. Пайп позиционную запись не поддерживает, Writer это замечает и переходит на обычный write, поэтому через | cat ошибки не видно.

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

Утилизация: зомби, сироты и waitpid

Когда процесс завершается, ядро не стирает его сразу. Память и файлы освобождаются, но строка в таблице процессов остаётся: в ней лежит код возврата и счётчики ресурсов, и кто-то должен их забрать. Забрать может только родитель, вызовом waitpid. Это называется утилизацией (reaping). Процесс, который уже завершился, но ещё не утилизирован, называют зомби.

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

pub fn main() void {
    const zombie = c.fork();
    if (zombie == 0) c._exit(0); // ребёнок выходит сразу

    // Родитель не зовёт waitpid. Дадим ребёнку секунду, чтобы умереть.
    const second: c.timespec = .{ .sec = 1, .nsec = 0 };
    _ = c.nanosleep(&second, null);

    // Строку с pid готовим до fork: после fork аллокатор не трогаем.
    var buf: [16]u8 = undefined;
    const pid_text = std.fmt.bufPrintZ(&buf, "{d}", .{zombie}) catch return;
    const argv = [_:null]?[*:0]const u8{ "ps", "-o", "pid,ppid,stat,comm", "-p", pid_text };

    const ps = c.fork();
    if (ps == 0) {
        _ = c.execve("/bin/ps", &argv, c.environ);
        c._exit(127);
    }
    _ = c.waitpid(ps, null, 0);

    // Утилизация: после waitpid запись о ребёнке исчезает из таблицы процессов.
    _ = c.waitpid(zombie, null, 0);
}
$ ./zombie
    PID    PPID STAT COMMAND
    192     191 Z    zombie

Буква Z в столбце STAT и есть зомби. Убить его нельзя, он уже мёртв: kill -9 на зомби ничего не меняет. Вреда от одного зомби нет, он не ест ни процессор, ни память. Вред в накоплении: сервер, который порождает ребёнка на каждый запрос и не утилизирует, за сутки займёт все pid, и следующий fork вернёт EAGAIN. Предел на Linux лежит в /proc/sys/kernel/pid_max, в современных дистрибутивах там 4194304.

А если родитель умер раньше ребёнка? Ребёнок становится сиротой, и ядро назначает ему нового родителя: процесс с номером 1, то есть init или systemd, либо ближайшего предка, который объявил себя усыновителем через prctl(PR_SET_CHILD_SUBREAPER). Первый процесс обязан крутить цикл waitpid и утилизировать всех, кто ему достался. Поэтому зомби, чей родитель завершился, исчезает сам. Отсюда же известная беда контейнеров: процесс с номером 1 внутри контейнера это твоя программа, усыновителем становится она, а цикла waitpid в ней нет. Для этого у Docker есть флаг --init, который ставит первым процессом крошечный усыновитель.

waitpid в деталях

pid_t waitpid(pid_t pid, int *status, int options);

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

Как ждать задаёт options. Ноль это поведение по умолчанию: уснуть, пока кто-нибудь из ожидаемых детей не завершится; если такой уже есть, вернуться сразу. Флаги меняют это так:

ФлагЧто меняет
WNOHANGне спать: если никто ещё не завершился, сразу вернуть 0
WUNTRACEDвернуться и тогда, когда ребёнок остановлен, а не только завершён
WCONTINUEDвернуться и тогда, когда остановленного ребёнка продолжили сигналом SIGCONT

Что случилось лежит в status. Это не код возврата, а упакованное слово, и на Linux оно устроено так:

бит   15 ............ 8   7    6 ............ 0
     +------------------+----+------------------+
     |    код выхода    |дамп|  номер сигнала   |
     +------------------+----+------------------+
  • Младшие семь битов равны нулю: процесс вышел сам, код выхода в битах с 8 по 15. Отсюда правило, что код возврата это число от 0 до 255: exit(256) превращается в ноль.
  • Младшие семь битов не ноль и не 0x7f: процесс убит сигналом с этим номером. Бит 7 говорит, что ядро записало дамп памяти.
  • Младший байт равен 0x7f: процесс остановлен, номер сигнала в битах с 8 по 15. Такое слово приходит только с WUNTRACED.

Руками сдвигать не принято: раскладка формально зависит от системы. В C есть макросы WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG, WIFSTOPPED, WSTOPSIG, в Zig они живут в std.c.W и std.os.linux.W.

Ошибки. Минус единица и ECHILD в errno: детей нет, ждать некого. Минус единица и EINTR: ожидание прервал сигнал, вызов надо повторить. Вторую ошибку забывают чаще всего, и программа с обработчиком сигнала начинает терять детей.

Соберём всё в одной программе: пять детей, один из них погибает от сигнала, родитель утилизирует всех.

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

const children = 5;

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

pub fn main() void {
    var pids: [children]c.pid_t = undefined;
    for (&pids, 0..) |*pid, i| {
        pid.* = c.fork();
        if (pid.* == 0) {
            if (i == 3) c.abort(); // этот ребёнок погибает от SIGABRT
            c._exit(@intCast(100 + i));
        }
    }

    // pid = -1: любой ребёнок. Порядок выбирает ядро, а не мы.
    while (true) {
        var status: c_int = 0;
        const pid = c.waitpid(-1, &status, 0);
        if (pid < 0) break;
        const word: u32 = @bitCast(status);
        if (c.W.IFEXITED(word)) {
            say("ребёнок {d} вышел сам, код {d}\n", .{ pid, c.W.EXITSTATUS(word) });
        } else if (c.W.IFSIGNALED(word)) {
            say("ребёнок {d} убит сигналом {d}\n", .{ pid, @intFromEnum(c.W.TERMSIG(word)) });
        }
    }
    // Детей не осталось: waitpid вернул -1, в errno лежит ECHILD.
    say("waitpid: {t}\n", .{std.posix.errno(@as(c_int, -1))});
}
$ ./reap
ребёнок 264 вышел сам, код 100
ребёнок 265 вышел сам, код 101
ребёнок 266 вышел сам, код 102
ребёнок 267 убит сигналом 6
ребёнок 268 вышел сам, код 104
waitpid: CHILD

Здесь дети пришли по порядку, но это совпадение. Вот та же программа на macOS минутой раньше:

ребёнок 63077 вышел сам, код 104
ребёнок 63076 убит сигналом 6
ребёнок 63072 вышел сам, код 102
ребёнок 63070 вышел сам, код 101
ребёнок 63068 вышел сам, код 100
waitpid: CHILD

waitpid(-1, ...) отдаёт детей в том порядке, в каком ядру удобно. Нужен определённый порядок, жди по номерам: сохрани pid в массив и зови waitpid(pids[i], ...) по очереди. Программа при этом не станет медленнее, она лишь утилизирует ранних детей чуть позже.

У waitpid есть старший брат wait4: те же аргументы плюс указатель на struct rusage, куда ядро кладёт счётчики ресурсов ребёнка: время процессора в режиме пользователя и ядра, пик резидентной памяти, число сбоев страниц и переключений контекста. На нём мы построим zbox run.

execve: другая программа в том же процессе

fork размножает процессы, но все они выполняют одну и ту же программу. Запустить другую умеет execve:

int execve(const char *path, char *const argv[], char *const envp[]);

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

Два массива это всё, что новая программа получает от старой. argv это аргументы командной строки, по соглашению argv[0] это имя программы. envp это окружение, строки вида ИМЯ=значение. Оба массива кончаются нулевым указателем. Ядро копирует их на вершину нового стека, и раскладка там такая, сверху вниз: сами строки, потом массив указателей envp с нулём в конце, массив argv с нулём в конце, и в самом низу число argc. На это слово и смотрит %rsp в первой инструкции _start. Оттуда startup-код libc или Zig достаёт аргументы и зовёт твой main. Как ядро раскладывает по адресному пространству сегменты ELF, ты видел в уроке про загрузку; что при этом происходит с таблицей страниц, увидишь в уроке 56.

Напишем подопытную программу, которая печатает всё, что получила.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &w.interface;

    const args = try init.minimal.args.toSlice(init.arena.allocator());
    try out.writeAll("Аргументы командной строки:\n");
    for (args, 0..) |arg, i| try out.print("    argv[{d:>2}]: {s}\n", .{ i, arg });

    try out.writeAll("Переменные окружения:\n");
    for (init.minimal.environ.block.view().slice, 0..) |entry, i| {
        try out.print("    envp[{d:>2}]: {s}\n", .{ i, entry });
    }
    try out.flush();
}

И программу, которая превращается в неё.

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

pub fn main() void {
    // Оба массива кончаются нулевым указателем: так их ждёт ядро.
    const argv = [_:null]?[*:0]const u8{ "какое-угодно-имя", "раз", "два три" };
    const envp = [_:null]?[*:0]const u8{ "LANG=ru_RU.UTF-8", "COURSE=batschool" };

    const before = "pid до execve тот же, что у myecho\n";
    _ = c.write(1, before.ptr, before.len);

    _ = c.execve("./myecho", &argv, &envp);

    // Сюда попадаем, только если execve не удался.
    const failed = "execve вернулся, значит не сработал\n";
    _ = c.write(2, failed.ptr, failed.len);
    c._exit(127);
}
$ zig build-exe myecho.zig
$ zig build-exe exec_demo.zig -lc
$ ./exec_demo
pid до execve тот же, что у myecho
Аргументы командной строки:
    argv[ 0]: какое-угодно-имя
    argv[ 1]: раз
    argv[ 2]: два три
Переменные окружения:
    envp[ 0]: LANG=ru_RU.UTF-8
    envp[ 1]: COURSE=batschool

Три наблюдения. argv[0] это просто строка, которую передал вызывающий, ядро её не проверяет. На этом держится BusyBox: один бинарник, сотня символических ссылок, и программа решает, кто она сегодня, по argv[0]. Окружение не наследуется само собой: мы передали две переменные, и myecho увидела ровно две, без PATH и HOME. Оболочка передаёт своё окружение целиком, поэтому кажется, что оно “глобальное”. И “два три” приехало одним аргументом с пробелом внутри: на слова строку режет оболочка, а не ядро. Ядру всё равно, что лежит в строках.

Что переживает execve

Память не переживает. Но процесс это не только память, и остальное остаётся.

ОстаётсяСбрасывается
pid, родитель, группа процессовкод, данные, куча, стек, отображения mmap
открытые дескрипторы без флага O_CLOEXECдескрипторы с флагом O_CLOEXEC закрываются
текущий каталог, umask, лимиты setrlimitобработчики сигналов: их функции исчезли вместе с кодом, действие возвращается к умолчанию
маска заблокированных сигналов и сигналы, которые игнорируютсяпотоки, кроме вызвавшего
счётчики rusage, которые накопил процесс

Левый столбец это и есть ответ на вопрос, зачем fork и execve разделены. Между ними есть промежуток, в котором ребёнок уже отдельный процесс, но в нём ещё работает твой код. В этом промежутке ребёнка настраивают: перенаправляют дескрипторы 0, 1 и 2 в файл или пайп, меняют каталог, ставят лимиты, переводят в другую группу процессов, сбрасывают привилегии. Потом execve, и новая программа просыпается в уже подготовленном мире, ничего о подготовке не зная. ls > out.txt работает именно так: ls не умеет писать в файлы, она пишет в дескриптор 1, а файл туда подложила оболочка между fork и execve. Весь zbox будет расти в этом промежутке: лимит памяти, захват вывода, изоляция.

У семейства exec в libc шесть обёрток над одним системным вызовом. Нам пригодится execvp: буква p значит, что имя без косой черты ищется по каталогам из PATH, как это делает оболочка. С ним zbox run sleep 1 работает без полного пути.

std.process: обёртка, которой обычно хватает

В прикладной программе голый fork нужен редко. Стандартная библиотека 0.16 даёт три функции в std.process: spawn порождает ребёнка и возвращает Child, Child.wait дожидается его и отдаёт Term, а run делает всё сразу и заодно собирает stdout и stderr ребёнка в память.

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &w.interface;

    // spawn это fork плюс execve, wait это wait4 плюс разбор слова состояния.
    var child = try std.process.spawn(init.io, .{
        .argv = &.{ "sh", "-c", "echo из ребёнка; exit 3" },
        .request_resource_usage_statistics = true,
    });
    const term = try child.wait(init.io);
    switch (term) {
        .exited => |code| try out.print("вышел сам, код {d}\n", .{code}),
        .signal => |sig| try out.print("убит сигналом {t}\n", .{sig}),
        .stopped, .unknown => try out.writeAll("остановлен или непонятно что\n"),
    }
    if (child.resource_usage_statistics.getMaxRss()) |bytes| {
        try out.print("пик памяти {d} КБ\n", .{bytes / 1024});
    }

    // run дополнительно забирает stdout и stderr через пайпы.
    const result = try std.process.run(init.gpa, init.io, .{ .argv = &.{ "uname", "-s" } });
    defer init.gpa.free(result.stdout);
    defer init.gpa.free(result.stderr);
    try out.print("uname сказал: {s}", .{result.stdout});
    try out.flush();
}
$ ./child_demo
из ребёнка
вышел сам, код 3
пик памяти 4332 КБ
uname сказал: Linux

Term это тот же разбор слова состояния, только типом: .exited с кодом, .signal и .stopped с сигналом. switch по нему компилятор проверяет на полноту, и забыть про смерть от сигнала не получится. С флагом request_resource_usage_statistics внутри работает wait4, и getMaxRss уже приводит единицы: в байты на любой системе.

Загляни в реализацию, processSpawnPosix в std/Io/Threaded.zig. Там ровно те три вызова, что в этом уроке, и три приёма, которые стоит запомнить.

  • Вся память выделяется до fork. Массивы argv и envp с нулями на конце собираются заранее. Причина та же, что с буферами: в многопоточной программе fork копирует только вызвавший поток, и если другой поток в этот миг держал замок аллокатора, в ребёнке замок останется запертым навсегда. Между fork и execve безопасны только функции из списка async-signal-safe, подробно о нём в уроке про сигналы.

  • Пайп для ошибок с флагом CLOEXEC. Как родителю узнать, что execve в ребёнке не удался? По коду 127, как делает оболочка, не отличить “программы нет” от “программа вернула 127”. spawn перед fork открывает пайп с O_CLOEXEC. Если execve удался, ядро само закрывает пишущий конец, и родитель читает конец файла. Если нет, ребёнок пишет в пайп код ошибки и выходит. Поэтому spawn несуществующей программы честно возвращает ошибку:

    spawn: FileNotFound
  • wait повторяет вызов при EINTR. Та самая ошибка, которую забывают.

Когда обёртки мало? Когда нужен тот самый промежуток между fork и execve. SpawnOptions покрывает частые случаи: каталог, окружение, перенаправление трёх стандартных дескрипторов, uid, gid, pgid. Но поставить setrlimit, вписать ребёнка в группу cgroups или сменить пространство имён через него нельзя, хука для своего кода в ребёнке нет. Песочница вся состоит из таких действий, поэтому zbox пишем на голых вызовах.

На macOS. Всё в этом уроке, кроме задачи в “Практике”, собирается и работает на macOS напрямую: std.c там это libSystem, флаг -lc можно не писать, он подразумевается. Отличия мелкие, но на них наступают. Номера процессов у соседних fork идут не подряд, поэтому в выводе reap числа с дырами. ps показывает зомби как <defunct> в столбце COMM, буква Z та же. ru_maxrss в rusage приходит в байтах, а не в килобайтах, как на Linux: это единственная системная разница, которую zbox обязан знать уже сегодня. Раскладка слова состояния совпадает с линуксовой, но пользуйся макросами W. Усыновителем сирот служит launchd с номером 1, prctl и pids.max нет. Сырые вызовы std.os.linux на macOS не работают, поэтому задачу mysystem решай прямо в редакторе на сайте или в Docker: локально на macOS тесты с процессами пропустят себя сами, проверится только разбор слова состояния. И ещё одно: Apple не любит fork без execve в программах с графическим интерфейсом и потоками. Многие системные библиотеки после fork в ребёнке намеренно падают с сообщением про fork() без exec(). Наши консольные демо этого не задевают.

Проект zbox: команда run

С этого урока в разделе появляется третий сквозной проект. zbox это своя песочница: программа, которая запускает чужую программу под присмотром и отчитывается, чем всё кончилось. Ровно этим занимается bs-runner, который проверяет твои задачи: он зовёт docker run с десятком флагов, и каждый флаг это один или два системных вызова. Мы будем добавлять их по одному, по мере того как курс до них доходит: лимит времени в уроке про сигналы, лимит памяти в уроке 54, захват вывода в уроке 61. Сегодня фундамент:

$ zbox run sh -c 'echo привет; exit 7'
привет
{"exit_code":7,"signal":null,"cpu_user_ms":16,"cpu_sys_ms":1,"max_rss_kb":5088,"wall_ms":19}

zbox run запускает команду, ждёт её и печатает одну строку JSON: код возврата или номер сигнала, время процессора в двух режимах, пик резидентной памяти, время по настенным часам. Вывод самой программы пока идёт напрямую в тот же терминал: ребёнок унаследовал дескрипторы. Сам zbox возвращает 0, если до запуска дошло, и 2, если его позвали неправильно. Код программы лежит в JSON, а не в коде возврата zbox: так вызывающий отличает “программа упала” от “песочница упала”.

Раскладка проекта:

our-runner/
  build.zig             граф сборки: модуль zbox, программа zbox, шаг test по шагам проекта
  src/root.zig          корень модуля zbox, отсюда тесты берут части песочницы
  src/main.zig          командная строка и печать ответа
  src/box/args.zig      разбор `run <cmd> [args...]`, чистая функция
  src/box/report.zig    итог запуска, единицы `ru_maxrss`, печать JSON
  src/box/run.zig       `fork`, `execvp`, `wait4`, разбор слова состояния
  tests/support.zig     запуск настоящего zbox из теста и разбор его ответа
  tests/step_48.zig     тесты этого урока

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

Сборка

const std = @import("std");

/// Номера уроков курса, на которых проект вырос. Каждому шагу
/// соответствует файл `tests/step_NN.zig`, и все они остаются зелёными.
const project_steps = [_]u8{48};

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    // Библиотечный код: подключается и программой, и тестами шагов.
    // fork, execvp и wait4 берём из libc: в std.posix обёрток для них нет.
    const zbox = b.addModule("zbox", .{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    });

    const exe = b.addExecutable(.{
        .name = "zbox",
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/main.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{.{ .name = "zbox", .module = zbox }},
        }),
    });
    b.installArtifact(exe);

    const run_cmd = b.addRunArtifact(exe);
    run_cmd.step.dependOn(b.getInstallStep());
    if (b.args) |args| run_cmd.addArgs(args);
    const run_step = b.step("run", "Собрать и запустить zbox");
    run_step.dependOn(&run_cmd.step);

    // Интеграционные тесты запускают настоящий zbox. Путь к собранной
    // программе попадает в них константой, и тесты зависят от её сборки.
    const options = b.addOptions();
    options.addOptionPath("zbox_exe", exe.getEmittedBin());

    // zig build test прогоняет все шаги, zig build test -Dstep=48 только один.
    const only_step = b.option(u8, "step", "Прогнать тесты одного шага, например -Dstep=48");
    const test_step = b.step("test", "Прогнать тесты всех шагов проекта");

    inline for (project_steps) |number| {
        if (only_step == null or only_step.? == number) {
            const path = std.fmt.comptimePrint("tests/step_{d:0>2}.zig", .{number});
            const step_tests = b.addTest(.{
                .root_module = b.createModule(.{
                    .root_source_file = b.path(path),
                    .target = target,
                    .optimize = optimize,
                    .imports = &.{
                        .{ .name = "zbox", .module = zbox },
                        .{ .name = "build_options", .module = options.createModule() },
                    },
                }),
            });
            test_step.dependOn(&b.addRunArtifact(step_tests).step);
        }
    }
}

link_libc = true у модуля и есть наш -lc. Интересное место это addOptions: тестам нужен путь к собранному zbox, и граф сборки передаёт его константой build_options.zbox_exe. Заодно тесты начинают зависеть от сборки программы, и zig build test всегда гоняет свежий бинарник.

//! Корень модуля zbox. Программа и тесты берут части песочницы отсюда.

pub const args = @import("box/args.zig");
pub const report = @import("box/report.zig");
pub const run = @import("box/run.zig");

Командная строка

//! Разбор командной строки `zbox run`. Чистая функция: ни процессов,
//! ни вывода, поэтому проверяется обычными тестами.

const std = @import("std");

pub const Command = struct {
    /// Программа и её аргументы: то, что уйдёт в `execvp`.
    argv: []const []const u8,
};

pub const Error = error{BadUsage};

/// `args` это командная строка без имени самого zbox: `run <cmd> [args...]`.
pub fn parse(args: []const []const u8) Error!Command {
    if (args.len < 2) return error.BadUsage;
    if (!std.mem.eql(u8, args[0], "run")) return error.BadUsage;
    // Всё после `run` принадлежит программе: `zbox run ls -l` передаст `-l` в ls.
    return .{ .argv = args[1..] };
}

Флагов у run пока нет, поэтому разбор умещается в три строки. Отдельный файл он получил авансом: уже в следующем уроке здесь появится --time-ms.

Отчёт

//! Итог запуска и его печать одной строкой JSON. Тоже чистый код:
//! от операционной системы здесь только знание о единицах `ru_maxrss`.

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

pub const Outcome = struct {
    /// Код возврата, если программа завершилась сама (`exit` или `return` из `main`).
    exit_code: ?u8 = null,
    /// Номер сигнала, если программу убил сигнал. Ровно одно из двух полей не null.
    signal: ?u32 = null,
    /// Время процессора в режиме пользователя и в режиме ядра.
    cpu_user_ms: u64 = 0,
    cpu_sys_ms: u64 = 0,
    /// Пиковый размер резидентной памяти, килобайты.
    max_rss_kb: u64 = 0,
    /// Время по настенным часам от `fork` до конца `wait4`.
    wall_ms: u64 = 0,
};

/// `ru_maxrss` на Linux приходит в килобайтах, а на macOS в байтах.
/// man-страница `getrusage(2)` у каждой системы своя, и они расходятся.
pub fn maxRssKb(ru_maxrss: u64, os: std.Target.Os.Tag) u64 {
    return switch (os) {
        .macos => ru_maxrss / 1024,
        else => ru_maxrss,
    };
}

pub fn maxRssKbNative(ru_maxrss: u64) u64 {
    return maxRssKb(ru_maxrss, builtin.os.tag);
}

/// Секунды и микросекунды из `timeval` в целые миллисекунды.
pub fn timevalMs(sec: i64, usec: i64) u64 {
    return @intCast(sec * 1000 + @divTrunc(usec, 1000));
}

/// Поля только числовые, экранировать нечего,
/// поэтому JSON собираем обычным `print`.
pub fn writeJson(out: *std.Io.Writer, outcome: Outcome) std.Io.Writer.Error!void {
    try out.writeAll("{\"exit_code\":");
    try writeOptional(out, outcome.exit_code);
    try out.writeAll(",\"signal\":");
    try writeOptional(out, outcome.signal);
    try out.print(",\"cpu_user_ms\":{d},\"cpu_sys_ms\":{d},\"max_rss_kb\":{d},\"wall_ms\":{d}}}\n", .{
        outcome.cpu_user_ms,
        outcome.cpu_sys_ms,
        outcome.max_rss_kb,
        outcome.wall_ms,
    });
}

fn writeOptional(out: *std.Io.Writer, value: anytype) std.Io.Writer.Error!void {
    if (value) |number| {
        try out.print("{d}", .{number});
    } else {
        try out.writeAll("null");
    }
}

exit_code и signal сделаны необязательными полями, и ровно одно из них не null. Это честнее, чем принятое в оболочке 128 + номер сигнала: код 137 может значить и SIGKILL, и программу, которая сама вернула 137. В задаче “Практики” ты напишешь оболочечный вариант и почувствуешь разницу. maxRssKb принимает систему аргументом, а не читает builtin.os.tag внутри, чтобы обе ветки проверялись тестом на любой машине.

Запуск

//! Запуск чужой программы: `fork`, `execvp`, `wait4`.

const std = @import("std");
const c = std.c;
const report = @import("report.zig");

// В std.c есть execve, но нет execvp. Нам нужен именно он: пусть libc
// сама найдёт `sleep` или `sh` по PATH, как это делает оболочка.
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;

pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory };

/// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
/// 127 значит, что команда не найдена.
pub const exec_failed_code = 127;

pub fn run(arena: std.mem.Allocator, argv: []const []const u8) Error!report.Outcome {
    // Массив для execvp собираем до fork. После fork в ребёнке безопасны
    // только async-signal-safe функции, а аллокатор к ним не относится.
    const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
    for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);

    const started_ms = nowMs();

    const pid = c.fork();
    if (pid < 0) return error.ForkFailed;
    if (pid == 0) {
        // Мы в ребёнке. Успешный execvp не возвращается: образ процесса
        // заменён, этого кода в памяти больше нет.
        _ = execvp(c_argv[0].?, c_argv.ptr);
        childFail();
    }

    // Мы в родителе. wait4 это waitpid, который заодно отдаёт rusage
    // ребёнка: время процессора и пик памяти ядро считало всё это время.
    var status: c_int = 0;
    var usage: c.rusage = undefined;
    while (true) {
        const reaped = c.wait4(pid, &status, 0, &usage);
        if (reaped == pid) break;
        // Сигнал мог прервать ожидание, тогда просто ждём дальше.
        if (std.posix.errno(reaped) == .INTR) continue;
        return error.WaitFailed;
    }

    var outcome = decodeStatus(@bitCast(status));
    outcome.cpu_user_ms = report.timevalMs(usage.utime.sec, usage.utime.usec);
    outcome.cpu_sys_ms = report.timevalMs(usage.stime.sec, usage.stime.usec);
    outcome.max_rss_kb = report.maxRssKbNative(@intCast(usage.maxrss));
    outcome.wall_ms = nowMs() - started_ms;
    return outcome;
}

/// Слово состояния из wait4 упаковано по-разному на разных системах,
/// поэтому разбираем его макросами W*, а не сдвигами руками.
fn decodeStatus(status: u32) report.Outcome {
    if (c.W.IFEXITED(status)) return .{ .exit_code = c.W.EXITSTATUS(status) };
    if (c.W.IFSIGNALED(status)) return .{ .signal = @intFromEnum(c.W.TERMSIG(status)) };
    // Без WUNTRACED остановленного ребёнка wait4 не вернёт.
    unreachable;
}

/// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
fn childFail() noreturn {
    const message = "zbox: не удалось запустить программу\n";
    _ = c.write(2, message.ptr, message.len);
    // Именно _exit, а не exit: обычный exit сбросил бы буферы stdio
    // и выполнил atexit-обработчики, унаследованные от родителя.
    c._exit(exec_failed_code);
}

fn nowMs() u64 {
    var now: c.timespec = undefined;
    _ = c.clock_gettime(.MONOTONIC, &now);
    return @intCast(now.sec * 1000 + @divTrunc(now.nsec, std.time.ns_per_ms));
}

Пройдись по функции run и найди в ней весь урок.

  • Массив c_argv с нулём на конце собирается до fork, из арены. После fork ребёнок не зовёт ничего, кроме execvp, write и _exit.
  • Ребёнок не возвращается из функции ни при каком исходе: либо execvp удался и этого кода больше нет, либо childFail с noreturn. Компилятор это проверяет: убери childFail(), и ветка if (pid == 0) провалится в родительский код, а с типом noreturn такая ошибка не соберётся незамеченной.
  • wait4 стоит в цикле с проверкой EINTR.
  • Слово состояния разбирается макросами W. Ветка unreachable законна: мы не передавали WUNTRACED, значит остановленного ребёнка wait4 не вернёт.
  • Код 127 при неудачном execvp это соглашение оболочки. Приём с пайпом и CLOEXEC из std.process был бы точнее, но у песочницы другой приоритет: её ответ должен выглядеть так же, как ответ sh.

Время процессора ядро считало само, всё время жизни ребёнка: на каждом входе в ядро и на каждом тике таймера оно знает, в каком режиме был процесс, и прибавляет к нужному счётчику. wait4 просто отдаёт накопленное.

Точка входа

//! Точка входа `zbox`. Здесь только командная строка и печать ответа:
//! вся работа живёт в `src/box`, чтобы её можно было проверить тестами.

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

const usage =
    \\zbox, своя песочница
    \\
    \\Использование:
    \\  zbox run <программа> [аргументы...]
    \\
    \\Ответ: одна строка JSON на stdout. Код программы лежит в поле exit_code,
    \\сам zbox при этом возвращает 0.
    \\
;

pub fn main(init: std.process.Init) !void {
    const arena = init.arena.allocator();
    const args = try init.minimal.args.toSlice(arena);

    var buf: [4096]u8 = undefined;
    var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &stdout.interface;

    const command = zbox.args.parse(args[1..]) catch {
        try out.writeAll(usage);
        try out.flush();
        std.process.exit(2);
    };

    // До fork в буфер stdout ничего не пишем: непустой буфер достался бы
    // и ребёнку, и один и тот же текст мог бы выйти дважды.
    const outcome = try zbox.run.run(arena, command.argv);
    try zbox.report.writeJson(out, outcome);
    try out.flush();
}

Здесь обе ловушки вывода из первой половины урока. writerStreaming, потому что zbox пишет в тот же дескриптор, что и его ребёнок, и позицию должен вести один счётчик, в ядре. И комментарий про пустой буфер до fork: пока run не вернётся, в out не попадает ни байта.

Тесты шага

Тесты двух сортов. Чистые функции проверяются напрямую. А run проверяется по-настоящему: тест запускает собранный zbox через std.process.run (вот где обёртка на своём месте), забирает его stdout и разбирает последнюю строку как JSON.

//! Общее для интеграционных тестов: запустить настоящий zbox и разобрать ответ.

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

/// Поля ответа, которые проверяют тесты. Лишние поля JSON пропускаем:
/// поздние шаги добавят свои, а ранние тесты обязаны остаться зелёными.
pub const Reply = struct {
    exit_code: ?u8,
    signal: ?u32,
    cpu_user_ms: u64,
    cpu_sys_ms: u64,
    max_rss_kb: u64,
    wall_ms: u64,
};

pub const Run = struct {
    reply: Reply,
    /// Всё, что попало на stdout до строки с JSON: это вывод самой программы.
    program_stdout: []const u8,
};

/// `zbox_args` это командная строка после имени zbox.
pub fn zbox(arena: std.mem.Allocator, zbox_args: []const []const u8) !Run {
    const argv = try std.mem.concat(arena, []const u8, &.{ &.{build_options.zbox_exe}, zbox_args });
    const result = try std.process.run(arena, std.testing.io, .{ .argv = argv });
    try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);

    // Ответ zbox это последняя строка stdout.
    const trimmed = std.mem.trimEnd(u8, result.stdout, "\n");
    const json_start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |newline| newline + 1 else 0;
    const reply = try std.json.parseFromSliceLeaky(Reply, arena, trimmed[json_start..], .{
        .ignore_unknown_fields = true,
    });
    return .{ .reply = reply, .program_stdout = trimmed[0..json_start] };
}

ignore_unknown_fields стоит с прицелом на будущее: поздние шаги добавят в ответ свои поля, а тесты сегодняшнего шага обязаны остаться зелёными.

//! Шаг 48: `zbox run <cmd>`, код возврата, сигнал, rusage, ответ в JSON.

const std = @import("std");
const zbox = @import("zbox");
const support = @import("support.zig");

const testing = std.testing;

test "parse: run и программа с аргументами" {
    const command = try zbox.args.parse(&.{ "run", "sh", "-c", "exit 7" });
    try testing.expectEqual(3, command.argv.len);
    try testing.expectEqualStrings("sh", command.argv[0]);
    try testing.expectEqualStrings("exit 7", command.argv[2]);
}

test "parse: без подкоманды или без программы это ошибка" {
    try testing.expectError(error.BadUsage, zbox.args.parse(&.{}));
    try testing.expectError(error.BadUsage, zbox.args.parse(&.{"run"}));
    try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "exec", "true" }));
}

test "maxRssKb: на macOS байты, на Linux килобайты" {
    try testing.expectEqual(2048, zbox.report.maxRssKb(2048 * 1024, .macos));
    try testing.expectEqual(2048, zbox.report.maxRssKb(2048, .linux));
}

test "timevalMs складывает секунды и микросекунды" {
    try testing.expectEqual(1250, zbox.report.timevalMs(1, 250_999));
    try testing.expectEqual(0, zbox.report.timevalMs(0, 999));
}

test "writeJson: завершилась сама" {
    var buf: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try zbox.report.writeJson(&out, .{
        .exit_code = 7,
        .cpu_user_ms = 12,
        .cpu_sys_ms = 3,
        .max_rss_kb = 2048,
        .wall_ms = 40,
    });
    const line = out.buffered();
    try testing.expect(std.mem.startsWith(u8, line, "{\"exit_code\":7,\"signal\":null,"));
    try testing.expect(std.mem.indexOf(u8, line, "\"cpu_user_ms\":12,\"cpu_sys_ms\":3,\"max_rss_kb\":2048,\"wall_ms\":40") != null);
    try testing.expect(std.mem.endsWith(u8, line, "}\n"));
}

test "writeJson: убита сигналом" {
    var buf: [1024]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    try zbox.report.writeJson(&out, .{ .signal = 9 });
    try testing.expect(std.mem.startsWith(u8, out.buffered(), "{\"exit_code\":null,\"signal\":9,"));
}

test "true возвращает 0, false возвращает 1" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const ok = try support.zbox(arena, &.{ "run", "true" });
    try testing.expectEqual(0, ok.reply.exit_code);
    try testing.expectEqual(null, ok.reply.signal);

    const failed = try support.zbox(arena, &.{ "run", "false" });
    try testing.expectEqual(1, failed.reply.exit_code);
}

test "код возврата доезжает как есть, вывод программы идёт перед JSON" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", "echo привет; exit 7" });
    try testing.expectEqual(7, run.reply.exit_code);
    try testing.expectEqualStrings("привет\n", run.program_stdout);
}

test "сигнал: exit_code пуст, в signal номер" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", "kill -TERM $$" });
    try testing.expectEqual(null, run.reply.exit_code);
    try testing.expectEqual(15, run.reply.signal);
}

test "несуществующая программа даёт 127" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    const run = try support.zbox(arena_state.allocator(), &.{ "run", "zbox-no-such-program" });
    try testing.expectEqual(zbox.run.exec_failed_code, run.reply.exit_code);
}

test "rusage: память и время процессора не нулевые у программы, которая считает" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();

    // Цикл в sh жжёт процессор в режиме пользователя. Порог щедрый:
    // важно, что время вообще дошло до отчёта, а не сколько его.
    const script = "i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done";
    const run = try support.zbox(arena_state.allocator(), &.{ "run", "sh", "-c", script });
    try testing.expectEqual(0, run.reply.exit_code);
    try testing.expect(run.reply.cpu_user_ms + run.reply.cpu_sys_ms > 0);
    try testing.expect(run.reply.max_rss_kb > 100);
}
$ zig build test --summary all
Build Summary: 5/5 steps succeeded; 11/11 tests passed

Тест про сигнал стоит разобрать. sh -c 'kill -TERM $$': оболочка подставляет вместо $$ свой pid и убивает сама себя. Для zbox это выглядит как ребёнок, погибший от сигнала 15:

$ zbox run sh -c 'kill -TERM $$'
{"exit_code":null,"signal":15,"cpu_user_ms":18,"cpu_sys_ms":2,"max_rss_kb":5088,"wall_ms":20}
$ zbox run zbox-no-such-program
zbox: не удалось запустить программу
{"exit_code":127,"signal":null,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":5216,"wall_ms":2}
$ zbox run sleep 1
{"exit_code":0,"signal":null,"cpu_user_ms":14,"cpu_sys_ms":2,"max_rss_kb":5088,"wall_ms":1017}

Последняя строка хорошо показывает разницу между двумя временами. sleep 1 прожил секунду по настенным часам и потратил 16 мс процессора: остальное время он был вытеснен и ждал таймера. Те же команды на macOS (Apple Silicon, без эмулятора) дают cpu_user_ms от 0 до 2 и max_rss_kb около 2000: 14 мс на Linux это цена эмуляции x86-64 через Rosetta, а не цена sleep.

У сегодняшнего zbox есть дыра размером с ворота: zbox run sleep 1000000 будет ждать миллион секунд. Закрыть её нечем, пока мы не умеем будить родителя по часам и убивать ребёнка. Это сигналы, следующий урок.

Проект Y86: контекст процесса и yield

В начале урока было сказано: ядро сохраняет контекст одного процесса и восстанавливает контекст другого. В Linux за этой фразой стоят struct task_struct, макрос switch_to и тысячи строк. На своей машине ту же фразу можно прочитать целиком, в полутора сотнях строк ассемблера. В прошлом уроке Y86 получил всё нужное железо: бит привилегий, инструкции trap и iret, таблицу исключений по адресу 0xfc0, слово ksp по адресу 0xfe0 и кадр исключения из трёх слов. Симулятор сегодня не меняется вообще. Весь шаг это программы: новое ядро yield.ys и три процесса для него.

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

PCB

Контекст процесса, пока тот не на процессоре, лежит в PCB, блоке управления процессом. У нас это 19 слов:

смещение  содержимое
  0x00    %r14  \
  0x08    %r13   |
   ...           |  четырнадцать регистров,
  0x60    %rcx   |  в порядке, обратном pushq
  0x68    %rax  /
  0x70    PC               \
  0x78    слово состояния   |  кадр исключения, его кладёт железо
  0x80    %rsp             /
  0x88    жив ли процесс
  0x90    адрес следующего PCB по кругу

Главная хитрость в том, куда смотрит ksp. Он всегда указывает на смещение 0x88 в PCB текущего процесса. Тогда при входе в ядро железо кладёт кадр из трёх слов прямо в поля 0x70, 0x78, 0x80, а %rsp оказывается на 0x70. Ядру остаётся сделать четырнадцать pushq, и регистры лягут в поля с 0x68 по 0x00. Контекст сохранён без единого копирования. Восстановление зеркально: поставить %rsp на начало PCB, четырнадцать popq, iret. Переключить процесс значит всего лишь поставить %rsp на начало другого PCB перед этими popq.

Ядро

# Ядро с двумя процессами и кооперативным переключением.
#
# Контекст процесса лежит в PCB: четырнадцать регистров, потом кадр
# исключения (PC, слово состояния, %rsp), потом два поля ядра.
#
#   0x00 %r14 ... 0x60 %rcx, 0x68 %rax     регистры, в порядке обратном pushq
#   0x70 PC    0x78 состояние    0x80 %rsp  кадр, его кладёт железо
#   0x88 жив ли процесс          0x90 следующий PCB по кругу
#
# Слово ksp в таблице исключений всегда смотрит на смещение 0x88 текущего
# PCB. Поэтому кадр железо кладёт прямо в PCB, а ядро дописывает под него
# регистры обычными pushq. Копировать ничего не надо.

        .pos 0
boot:   jmp resume              # PCB заполнены статически, остаётся запустить первый

# Входы из таблицы. Причину кладём в %rax, прежний %rax уже в PCB.
on_trap:
        pushq %rax
        irmovq $0, %rax
        jmp save
on_fault:
        pushq %rax
        irmovq $1, %rax

save:   pushq %rcx
        pushq %rdx
        pushq %rbx
        pushq %rbp
        pushq %rsi
        pushq %rdi
        pushq %r8
        pushq %r9
        pushq %r10
        pushq %r11
        pushq %r12
        pushq %r13
        pushq %r14
        irmovq kstack, %rsp     # контекст сохранён, дальше свой стек
        irmovq current, %rbx
        mrmovq (%rbx), %rbx     # %rbx это PCB текущего процесса
        andq %rax, %rax
        jne kill                # сбой: процесс снимаем

# Системный вызов: номер и аргументы читаем из сохранённого контекста.
        mrmovq 0x68(%rbx), %rax
        iaddq $-1, %rax
        je sys_write            # 1
        iaddq $-23, %rax
        je schedule             # 24, yield
        iaddq $-36, %rax
        je kill                 # 60, exit
        irmovq $-1, %rax
        rmmovq %rax, 0x68(%rbx) # такого вызова нет
        jmp resume

# write(%rdi = адрес, %rsi = длина)
sys_write:
        mrmovq 0x38(%rbx), %rdi
        mrmovq 0x40(%rbx), %rsi
wloop:  andq %rsi, %rsi
        je wdone
        mrmovq (%rdi), %rcx
        out %rcx
        iaddq $1, %rdi
        iaddq $-1, %rsi
        jmp wloop
wdone:  mrmovq 0x40(%rbx), %rax
        rmmovq %rax, 0x68(%rbx) # процесс увидит длину в %rax
        jmp resume

# Процесс закончился или упал. Если живых не осталось, машина встаёт.
kill:   irmovq $0, %rax
        rmmovq %rax, 0x88(%rbx)
        irmovq alive, %rcx
        mrmovq (%rcx), %rax
        iaddq $-1, %rax
        rmmovq %rax, (%rcx)
        jne schedule
        halt

# Следующий живой процесс по кругу становится текущим.
schedule:
        mrmovq 0x90(%rbx), %rbx
        mrmovq 0x88(%rbx), %rax
        andq %rax, %rax
        je schedule
        irmovq current, %rcx
        rmmovq %rbx, (%rcx)

# Вернуть текущий процесс на процессор.
resume: irmovq current, %rbx
        mrmovq (%rbx), %rsp     # %rsp на начало PCB
        rrmovq %rsp, %rax
        iaddq $0x88, %rax
        irmovq ksp, %rcx
        rmmovq %rax, (%rcx)     # следующий кадр ляжет в этот же PCB
        popq %r14
        popq %r13
        popq %r12
        popq %r11
        popq %r10
        popq %r9
        popq %r8
        popq %rdi
        popq %rsi
        popq %rbp
        popq %rbx
        popq %rdx
        popq %rcx
        popq %rax
        iret

# Данные ядра. Код обязан кончиться раньше: ассемблер наложение не ловит.
        .pos 0x280
current:
        .quad pcb_a
alive:  .quad 2

# Процесс A: образ с 0x400, стек под 0x800.
        .pos 0x2a0
pcb_a:
        .pos 0x310
        .quad 0x400             # PC
        .quad 0x19              # пользовательский режим, прерывания разрешены, ZF = 1
        .quad 0x800             # %rsp
        .quad 1                 # жив
        .quad pcb_b

# Процесс B: образ с 0x800, стек под 0xc00.
        .pos 0x340
pcb_b:
        .pos 0x3b0
        .quad 0x800
        .quad 0x19
        .quad 0xc00
        .quad 1
        .quad pcb_a

# Стек ядра растёт вниз от таблицы исключений, в свободную память.
        .pos 0xfc0
kstack:
        .quad on_trap
        .quad on_fault
        .quad on_fault
        .quad 0
ksp:    .quad 0

Прочитай его как три части.

Вход (on_trap, on_fault, save). Два входа из таблицы различаются только числом в %rax: 0 для системного вызова, 1 для сбоя. Чтобы положить туда причину, %rax сначала сохраняется, и это как раз первый pushq из четырнадцати. После save ядро пересаживается на свой стек kstack: PCB заполнен, дальше писать в него через pushq нельзя.

Решение (системные вызовы, kill, schedule). Номер вызова ядро читает не из регистра, а из сохранённого контекста, из поля 0x68: живой %rax уже занят причиной. Номера как в Linux: write 1, sched_yield 24, exit 60. Сравнение сделано цепочкой вычитаний, потому что у Y86 нет cmp с константой: минус 1 дал ноль, значит write; ещё минус 23, значит 24; ещё минус 36, значит 60. yield это просто переход на schedule: текущим становится следующий живой по кругу. kill помечает процесс мёртвым, уменьшает счётчик живых и либо идёт в планировщик, либо останавливает машину. Результат write ядро тоже кладёт не в регистр, а в поле 0x68 PCB: в %rax он попадёт при восстановлении.

Выход (resume). %rsp на начало PCB текущего процесса, ksp на его поле 0x88, четырнадцать popq, iret. После popq %rax указатель стека стоит на поле 0x70, и iret снимает оттуда кадр.

Загрузка машины это тот же resume. PCB обоих процессов заполнены статически, прямо директивами .quad: счётчик команд 0x400 или 0x800, слово состояния 0x19, стек под концом своего куска памяти. Регистры нулевые. С точки зрения ядра процесс, который ещё ни разу не запускался, ничем не отличается от процесса, которого когда-то прервали на его первой инструкции. 0x19 это три бита слова состояния: режим пользователя (16), разрешение прерываний (8, пригодится в уроке 50) и флаг ZF (1), как после сброса.

Процессы

# Процесс ping: три раза печатает своё слово и каждый раз уступает процессор.

        .pos 0x400
        irmovq $3, %rbx
loop:   irmovq $1, %rax         # write(word, 5)
        irmovq word, %rdi
        irmovq $5, %rsi
        trap
        irmovq $24, %rax        # yield
        trap
        iaddq $-1, %rbx
        jne loop
        irmovq $60, %rax        # exit
        trap

        .align 8
word:   .quad 0x0a676e6970        # "ping\n", младший байт первым
# Процесс pong: три раза печатает своё слово и каждый раз уступает процессор.

        .pos 0x800
        irmovq $3, %rbx
loop:   irmovq $1, %rax         # write(word, 5)
        irmovq word, %rdi
        irmovq $5, %rsi
        trap
        irmovq $24, %rax        # yield
        trap
        iaddq $-1, %rbx
        jne loop
        irmovq $60, %rax        # exit
        trap

        .align 8
word:   .quad 0x0a676e6f70        # "pong\n", младший байт первым

Программы одинаковые, отличаются адресом загрузки и словом. Адрес зашит при сборке директивой .pos: виртуальной памяти у машины пока нет, и два процесса обязаны договориться, кто где живёт. Каждый исходник собирается твоим ассемблером отдельно, а образы накладываются в одну память: load из прошлого урока копирует только ненулевые байты. Счётчик цикла лежит в %rbx, и между итерациями процесс дважды уходит в ядро и один раз теряет процессор. Если ядро перепутает хоть один регистр при сохранении, слов будет не три.

Третий процесс проверяет ветку сбоя.

# Процесс, который лезет за границу памяти. Ядро снимает его, сосед живёт дальше.

        .pos 0x800
        irmovq $0xfff, %rax
        mrmovq (%rax), %rbx     # слово не помещается в память: сбой ADR
        jmp 0x800               # сюда управление не вернётся

Программы регистрируются в двух местах. В build.zig список файлов ядра, которые попадают в модуль как анонимные импорты:

const kernel_programs = [_][]const u8{ "hello", "yield", "ping", "pong", "crash" };

И в src/programs.zig имена для тестов:

pub const kernel = struct {
    /// Один процесс, сисколл `write` и сбой INS.
    pub const hello = @embedFile("hello.ys");
    /// Два процесса, переключение по `yield`.
    pub const yield = @embedFile("yield.ys");
    pub const ping = @embedFile("ping.ys");
    pub const pong = @embedFile("pong.ys");
    pub const crash = @embedFile("crash.ys");
};

Прогон

$ zig build run -- kernel programs/kernel/yield.ys programs/kernel/ping.ys programs/kernel/pong.ys --console
ping
pong
ping
pong
ping
pong

Два логических потока на одном процессоре. Ключ --events оставляет от трассы только входы в ядро и байты консоли:

exc trap 0x429 -> 0x009      ping: write
out 70
out 69
out 6e
out 67
out 0a
exc trap 0x434 -> 0x009      ping: yield
exc trap 0x829 -> 0x009      pong: write
out 70
out 6f
...
exc trap 0x452 -> 0x009      ping: exit
exc trap 0x852 -> 0x009      pong: exit
halt cycles=1014

Подписи справа мои, машина печатает только левую часть. Адрес в строке exc это адрес возврата, следующая инструкция после trap: 0x429 и 0x434 лежат в образе ping, 0x829 в образе pong. После yield из ping следующий вход в ядро приходит уже из pong. А вот одно переключение в полной трассе, от trap в одном процессе до первой инструкции другого:

U 0x433 d0 AOK               ping: trap с номером 24
exc trap 0x434 -> 0x009
K 0x009 a0 AOK               on_trap: pushq %rax
K 0x00b 30 AOK
K 0x015 70 AOK               jmp save
K 0x02a a0 AOK               тринадцать pushq
...
K 0x042 a0 AOK
K 0x044 30 AOK               свой стек, current, разбор номера
...
K 0x178 50 AOK               schedule: следующий PCB, жив ли он
...
K 0x1ab 30 AOK               resume: %rsp на PCB, ksp на его 0x88
...
K 0x1df b0 AOK               четырнадцать popq
...
K 0x1fb d1 AOK               iret
U 0x800 30 AOK               pong: первая инструкция

Первая буква строки это режим. Между U 0x433 и U 0x800 ровно 53 инструкции в режиме ядра, из них 28 это pushq и popq. Весь прогон занимает 1014 тактов, полезной работы процессов в нём 54 такта, остальные 960 это ядро: в основном побайтный цикл write и сохранение с восстановлением. У настоящего процессора пропорция другая, но порядок рассуждения тот же: переключение это чистые накладные расходы, и его цена определяется размером контекста.

Теперь сбой:

$ zig build run -- kernel programs/kernel/yield.ys programs/kernel/ping.ys programs/kernel/crash.ys --events
exc trap 0x429 -> 0x009
exc trap 0x434 -> 0x009
exc adr 0x80a -> 0x01e
exc trap 0x429 -> 0x009
...
halt cycles=569

После первого yield управление получает crash, читает слово по адресу 0xfff и получает сбой ADR. Железо входит в ядро через вторую строку таблицы, on_fault кладёт в %rax единицу, kill снимает процесс. Адрес возврата 0x80a это сама сбойная инструкция, но возвращаться туда никто не собирается. ping спокойно допечатывает свои три слова: ошибка в одном процессе не задела другой. Наполовину это заслуга ядра, наполовину везение: памяти друг от друга наши процессы не защищены, и crash, который вместо чтения за границей записал бы мусор в 0x2a0, убил бы ядро. Настоящая изоляция придёт с MMU в уроке 56.

Тесты шага

//! Шаг 48: контекст процесса в PCB, два образа в памяти, переключение по `yield`.

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

const testing = std.testing;
const computer = y86.computer;
const kernel = y86.programs.kernel;

/// Собирает каждый исходник отдельно, как это делает `y86 kernel`.
fn run(sources: []const []const u8, config: computer.Config) !computer.Output {
    var images: [4][]u8 = undefined;
    var count: usize = 0;
    defer for (images[0..count]) |image| testing.allocator.free(image);
    for (sources) |source| {
        var result = try y86.assembler.assemble(testing.allocator, source, null);
        defer result.deinit(testing.allocator);
        images[count] = try testing.allocator.dupe(u8, result.image);
        count += 1;
    }
    return computer.runImages(testing.allocator, images[0..count], config);
}

test "два процесса по очереди печатают свои слова" {
    var out = try run(&.{ kernel.yield, kernel.ping, kernel.pong }, .{});
    defer out.deinit(testing.allocator);
    try testing.expectEqualStrings("ping\npong\nping\npong\nping\npong\n", out.console);
    try testing.expectEqual(y86.Stat.hlt, out.summary.stat);
}

test "контекст переживает переключение" {
    // Счётчик цикла каждого процесса живёт в %rbx. Если бы ядро путало
    // регистры, слов было бы не по три.
    var out = try run(&.{ kernel.yield, kernel.ping, kernel.pong }, .{});
    defer out.deinit(testing.allocator);
    try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "ping\n"));
    try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "pong\n"));
}

test "образы накладываются: ядро и процессы собраны по отдельности" {
    var result = try y86.assembler.assemble(testing.allocator, kernel.ping, null);
    defer result.deinit(testing.allocator);

    var machine: computer.Computer = .init(.{});
    machine.st.mem[0] = 0x10;
    machine.load(result.image);
    // Нулевые байты образа чужую память не затирают.
    try testing.expectEqual(@as(u8, 0x10), machine.st.mem[0]);
    try testing.expectEqual(@as(u8, 0x30), machine.st.mem[0x400]);
}

test "упавший процесс снимается, сосед доживает до конца" {
    var out = try run(&.{ kernel.yield, kernel.ping, kernel.crash }, .{});
    defer out.deinit(testing.allocator);
    try testing.expectEqualStrings("ping\nping\nping\n", out.console);
    try testing.expect(std.mem.indexOf(u8, out.trace, "U 0x80a 50 ADR\nexc adr 0x80a -> ") != null);
    try testing.expectEqual(y86.Stat.hlt, out.summary.stat);
}
$ zig build test -Dstep=48 --summary all
Build Summary: 5/5 steps succeeded; 45/45 tests passed

(41 тест это модульные тесты самого симулятора, они идут с каждым шагом. Тестов шага четыре.)

Наше ядро кооперативное: процесс, который не зовёт yield, не отдаст процессор никогда. Замени в ping.ys вызов yield на пустой цикл, и pong не напечатает ни слова. Лекарство то же, что в Linux: таймер. До него два урока.

Практика

Библиотечная system(3) это fork, execve и waitpid в одной функции. Напиши её на сырых вызовах std.os.linux, без libc: fork там возвращает usize, и отличать ребёнка от родителя и от ошибки придётся через @bitCast в isize. Вторая функция, exitCode, разбирает слово состояния по правилам оболочки.

Упражнения

Итоги

  • Процесс это программа в исполнении плюс контекст. Он даёт две иллюзии: непрерывный логический поток управления и собственное адресное пространство. Первая держится на переключении контекста, вторая на виртуальной памяти.
  • Потоки конкурентны, если перекрываются во времени, независимо от числа ядер. Для конкурентных потоков любой порядок инструкций надо считать возможным.
  • Переключение контекста случается только в ядре: по системному вызову, который не может завершиться сразу, или по прерыванию таймера. Прямая цена это микросекунды, косвенная это холодные кэши и TLB, поэтому квант измеряется миллисекундами (3 мс у EEVDF на восьми ядрах).
  • fork зовут раз, возвращается он дважды: ноль ребёнку, pid родителю. Память копируется (лениво), открытые файлы разделяются. В Zig 0.16 fork берут из std.c (с libc) или из std.os.linux (сырой вызов).
  • Программу с fork читают как граф процессов, допустимый вывод это топологическая сортировка. Редкий вывод не значит невозможный.
  • Перед fork буферы вывода пусты, ребёнок после неудачи выходит через _exit. В унаследованный дескриптор пишут потоково (writerStreaming), иначе родитель и ребёнок затрут друг друга.
  • Завершённый процесс остаётся зомби, пока родитель не утилизирует его через waitpid. Сирот усыновляет процесс 1. Слово состояния разбирают макросами W: вышел сам с кодом, убит сигналом, остановлен. EINTR значит повторить.
  • execve заменяет программу в том же процессе и не возвращается. Переживают его pid, открытые дескрипторы без CLOEXEC, каталог, лимиты, маска сигналов. В промежутке между fork и execve ребёнка настраивают, на этом стоят оболочки и песочницы.
  • std.process.spawn, wait и run покрывают обычные случаи и делают всё правильно: память до fork, пайп с CLOEXEC для ошибки execve, повтор при EINTR. Свой код в ребёнке они выполнить не дают.
  • zbox run это fork, execvp, wait4 и печать rusage в JSON. ru_maxrss на Linux в килобайтах, на macOS в байтах.
  • На Y86 контекст процесса это четырнадцать регистров плюс кадр исключения, всё лежит в PCB. ksp смотрит внутрь PCB текущего процесса, поэтому сохранение это 14 pushq, восстановление это 14 popq и iret, а переключение это смена одного указателя. Одно переключение стоит 53 инструкции.

Дальше

У нас остались два долга. zbox run sleep 1000000 висит вечно, потому что родитель умеет только спать в wait4. А родитель из восьмого упражнения вынужден выбирать между сном и опросом. Оба долга закрывает один механизм: ядро умеет постучаться в процесс снаружи, в произвольный момент, и заставить его выполнить функцию. В следующем уроке разберём сигналы: как их посылают, как ловят, почему обработчик это самое опасное место в системной программе, и поставим в zbox лимит времени.

домашка

Домашка