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

Отображение в память: mmap, fork, execve и копирование при записи

lead~270 мин

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

Отображение в память: mmap, fork, execve и копирование при записи

В прошлом уроке ты прошёл с адресом весь путь: VPN и VPO, TLB, таблица страниц, физический адрес, кэш, байт. Таблица страниц там была данностью: кто-то её заполнил, а мы по ней ходили. Сегодня разбираемся, кто и когда её заполняет. Ответ короткий: почти никто и почти никогда заранее. Ядро записывает намерения (эта область адресов связана с этим файлом, та заполнена нулями), а строки таблицы появляются по одной, в обработчике сбоя страницы. На этой лени держатся три вещи, без которых Unix не был бы Unix: fork, который копирует процесс на гигабайт за доли миллисекунды, execve, который запускает программу, не прочитав из неё ни байта, и mmap, который превращает файл в срез. Урок самый большой в блоке: после теории и четырёх программ на Zig мы закроем два долга. JIT нашего zl перестанет просить у ядра страницу с правами RWX, а у процессора Y86 появится настоящий блок трансляции адресов с TLB, сбоем страницы и своей таблицей у каждого процесса.

Цели урока

  • Объяснять, что такое отображение в память: область виртуальных адресов, связанная с объектом на диске или с нулями, и почему страницы приходят в память только по сбою.
  • Различать разделяемые и приватные объекты и предсказывать, кто увидит запись: другой процесс, файл на диске или никто.
  • Рассказать по шагам, что делает ядро при fork и при execve, и какие области создаёт execve для новой программы.
  • Увидеть копирование при записи своими глазами: по счётчику сбоев страниц после fork.
  • Вызывать mmap и munmap из Zig 0.16, копировать файл без read, править файл на месте без write, делить память между родителем и потомком.
  • Сделать исполняемую память JIT по правилу W^X: mmap с RW, запись, mprotect в RX, данные на отдельной странице.
  • Встроить MMU в симулятор Y86: трансляция, TLB, сбой страницы как исключение, страница по требованию, защита ядра от процессов и процессов друг от друга.

Идея: область адресов плюс объект

В уроке про управление памятью ты смотрел на /proc/self/maps и видел, что адресное пространство процесса это список областей: код, данные, куча, библиотеки, стек. У каждой области есть начало, конец, права и, в правой колонке, имя файла или пустота. Эта правая колонка и есть сегодняшняя тема.

Отображение в память это договор между процессом и ядром: вот диапазон виртуальных адресов, и его начальное содержимое лежит вот в этом объекте. Объектов два вида.

Обычный файл. Область связана с отрезком файла: смещение и длина. Отрезок мысленно режется на куски размером со страницу, и каждый кусок это начальное содержимое одной виртуальной страницы. Если область длиннее отрезка файла, хвост заполняется нулями.

Анонимный объект. Файла нет. Первое обращение к странице такой области даёт страницу из нулей: ядро находит свободный кадр (или выселяет жертву), обнуляет его и вписывает в таблицу страниц. С диска при этом ничего не читается, поэтому такие страницы в книге названы страницами, заполняемыми нулями по требованию. Куча, стек и .bss живут именно так.

Главное слово в обоих случаях: по требованию. В момент создания отображения ядро не читает файл и не выделяет кадры. Оно заводит у себя запись об области (в Linux это vm_area_struct, ты видел её в уроке 54) и возвращает управление. Таблица страниц при этом не меняется вовсе: для новых адресов в ней по-прежнему нет действительных строк. Первое же обращение к такому адресу даёт сбой страницы. Обработчик находит область, в которую попал адрес, видит, что адрес законный, достаёт содержимое из объекта, вписывает строку в таблицу и перезапускает инструкцию. Программа ничего не заметила, кроме задержки.

Сколько стоит такая лень? Я замерил на машине, где писался урок (Apple M4 Max, 2026 год): 256 МБ анонимной памяти, запись по байту в каждую страницу, медиана пяти прогонов. На macOS 26 первый проход стоит около 630 наносекунд на страницу в 16 КБ, то есть на один сбой: вход в ядро, поиск области, обнуление кадра, запись PTE, возврат. В контейнере Linux 7.0 на той же машине выходит около 190 наносекунд на страницу в 4 КБ, но один сбой там приносит сразу четыре страницы (почему, увидим ниже), так что сбой стоит те же 0,7 до 0,8 микросекунды. Второй проход по тем же страницам стоит 5 до 11 наносекунд: сбоев больше нет. Разница на два порядка, и это ещё без диска: сбой с походом на NVMe это десятки микросекунд, на три порядка дороже обычного обращения.

Есть и обратная связь с диском. Когда памяти не хватает, страницы приходится выселять. Страницу файлового отображения можно выбросить и потом перечитать из файла (а изменённую сначала записать обратно). Анонимной странице возвращаться некуда, поэтому её выселяют в область подкачки. Отсюда практическое правило: размер подкачки плюс физическая память ограничивают суммарный объём анонимных страниц всех процессов, а файловые отображения могут быть сколь угодно большими. Отобразить файл на 200 ГБ на машине с 16 ГБ памяти можно, и это обычный приём в базах данных.

Разделяемое и приватное

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

Разделяемое отображение (MAP_SHARED). Запись видят все, кто отобразил тот же объект, и она же меняет сам объект: байты рано или поздно окажутся в файле. Физически это одна копия страницы в памяти, на которую смотрят строки таблиц страниц нескольких процессов. Виртуальные адреса у процессов при этом могут быть разными: у одного файл лежит с 0x7f12..., у другого с 0x5566..., а кадр один.

Приватное отображение (MAP_PRIVATE). Запись видит только сам процесс, и до файла она не доходит. Наивная реализация копировала бы весь объект при отображении. Настоящая хитрее, и эта хитрость называется копированием при записи.

Копирование при записи

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

Дальше всё делает аппаратура, которую ты уже знаешь. Процесс пишет в страницу. MMU видит, что у PTE нет права записи, и поднимает сбой защиты. Обработчик смотрит на область: запись разрешена, область приватная, значит это не ошибка программы, а отложенная работа ядра. Он берёт свободный кадр, копирует в него страницу, переставляет PTE этого процесса на новый кадр, возвращает ей право записи и перезапускает инструкцию. Запись проходит, уже в личную копию. Остальные процессы по-прежнему смотрят на старый кадр и ничего не заметили.

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

У обработчика есть и короткая ветка. Чтобы знать, нужна ли копия, ядро держит у каждого кадра счётчик ссылок. Если к моменту сбоя на кадр смотрит только сам пишущий (остальные уже сделали свои копии или завершились), копировать незачем: ядро просто возвращает PTE право записи. Сбой при этом всё равно случился, и мы увидим его в счётчике.

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

Три наблюдения, которые стоит унести из виджета. Первое: после fork число кадров не выросло ни на один. Второе: сбоев защиты два на страницу (один у того, кто писал первым, с копией; один у второго, без копии), а копия одна. Третье: запись в код это не копирование при записи, а настоящая ошибка: область запись не разрешает, и обработчик шлёт процессу SIGSEGV. Одно и то же аппаратное событие, сбой защиты, ядро толкует по-разному, заглянув в свою запись об области.

fork через виртуальную память

Теперь fork из урока про управление процессами можно объяснить целиком: там он был чёрным ящиком, который возвращается дважды. Когда процесс зовёт fork, ядро:

  1. Заводит потомку новый PID и структуры ядра.
  2. Копирует родительские записи об областях и таблицы страниц. Не страницы, а только их описание: для процесса на гигабайт с обычными страницами по 4 КБ это 262 144 строки по восемь байт, то есть около 2 МБ таблиц вместо гигабайта данных.
  3. Каждую страницу приватных записываемых областей помечает только для чтения в обеих таблицах, у родителя тоже, а сами области помечает как копируемые при записи.

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

Обрати внимание на цену, которая осталась. Таблицы страниц копировать всё же надо, и это линейно по размеру адресного пространства. fork из процесса с 64 ГБ анонимной памяти это 16 миллионов PTE, и на практике десятки миллисекунд, в течение которых процесс стоит. Поэтому Redis, который делает fork ради снимка на диск, советует выключать прозрачные огромные страницы (иначе копия при записи идёт кусками по 2 МБ), а тяжёлые серверы запускают дочерние программы через posix_spawn или vfork, где таблицы не копируются вовсе.

execve через виртуальную память

execve делает обратное: вместо копии адресного пространства строит новое на месте старого. Пусть процесс зовёт execve("a.out", argv, envp). Загрузчик в ядре:

  1. Удаляет все пользовательские области текущего процесса. Старой программы больше нет.
  2. Отображает приватные области новой программы. Код и инициализированные данные это приватные файловые отображения сегментов a.out (тех самых PT_LOAD из заголовка программы, которые ты разбирал в zt). .bss это анонимная область нужной длины. Стек и куча тоже анонимные, начальной длины ноль.
  3. Отображает разделяемые библиотеки, если программа слинкована динамически: libc.so и остальные ложатся в адресное пространство как файловые отображения, а связывает их с программой динамический загрузчик (это было в уроке про PIC, GOT и PLT).
  4. Ставит счётчик команд на точку входа.

Сведём в таблицу, что откуда берётся:

ОбластьОбъектВидПрава
код (.text, .rodata)отрезок файла a.outприватная, файловаяr-x и r--
данные (.data)отрезок файла a.outприватная, файловаяrw-
.bssнулиприватная, анонимнаяrw-
кучанулиприватная, анонимная, растёт через brkrw-
библиотекифайлы .soприватные, файловыепо сегментам
стекнулиприватная, анонимная, растёт внизrw-

Две вещи в этой таблице стоят отдельного абзаца. Во-первых, .data приватная: программа меняет свои глобальные переменные, а файл a.out на диске остаётся прежним. Это то же копирование при записи, только копия снимается со страницы страничного кэша. Во-вторых, execve ничего не читает с диска, кроме заголовков. Код попадёт в память, когда на него впервые придёт счётчик команд, по сбою страницы; функции, которые ни разу не вызвали, в память не попадут никогда. А если ту же программу уже запустил кто-то другой, страницы её кода уже лежат в страничном кэше, и сбой закроется без диска. Сто экземпляров bash делят одну физическую копию кода bash и libc, и никто специально об этом не заботился: так работают приватные файловые отображения.

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

mmap и munmap в Zig 0.16

Всё, что ядро делает само при execve, доступно и программе. Вызов один:

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);

Просьба читается так: отобрази length байт объекта, заданного дескриптором fd, начиная со смещения offset, желательно по адресу addr. Адрес это только пожелание, почти всегда там NULL, и ядро выбирает место само. prot это права страниц новой области: биты PROT_READ, PROT_WRITE, PROT_EXEC или PROT_NONE. flags задают вид объекта: MAP_SHARED или MAP_PRIVATE, плюс MAP_ANONYMOUS, если файла нет (тогда fd равен -1). Смещение обязано быть кратно странице. munmap удаляет область, и следующее обращение к её адресам это ошибка сегментации.

В Zig 0.16 оба вызова лежат в std.posix, и подпись стоит прочитать в исходниках установленной std (zig env покажет std_dir, файл posix.zig):

pub fn mmap(
    ptr: ?[*]align(page_size_min) u8,
    length: usize,
    prot: PROT,
    flags: MAP,
    fd: fd_t,
    offset: u64,
) MMapError![]align(page_size_min) u8

pub fn munmap(memory: []align(page_size_min) const u8) void

Три отличия от C, и все в пользу Zig. Права и флаги это не целые с битовыми масками, а упакованные структуры: .{ .READ = true, .WRITE = true } и .{ .TYPE = .PRIVATE, .ANONYMOUS = true }. Перепутать prot с flags местами, как это бывает в C, компилятор не даст. Результат это не голый указатель с магическим MAP_FAILED, а срез с длиной и с выравниванием на страницу в типе, либо ошибка из набора MMapError. И munmap принимает тот же срез целиком и ничего не возвращает: в Zig освобождение ресурса не имеет права провалиться, поэтому вырезать дыру в середине отображения через эту обёртку нельзя (через голый std.os.linux.munmap можно).

Чего в std.posix 0.16 нет: mprotect, fork, waitpid. Их мы будем доставать этажом ниже, и на шаге проекта zl соберём для этого маленький sys.zig. Ещё в 0.16 появилась переносимая обёртка std.Io.File.MemoryMap (метод file.createMemoryMap(io, options)), которая работает и на Windows, а там, где отображений нет, честно подменяет их чтением в буфер. Она хороша для прикладного кода, но прячет ровно то, что мы сегодня изучаем, поэтому дальше идём через std.posix.mmap.

Копирование файла без read

Упражнение 9.5 из книги: программа mmapcopy получает имя файла и копирует его на стандартный вывод, не читая файл вызовом read. Весь фокус в том, что после mmap файл это срез, а срез можно отдать писателю целиком.

const std = @import("std");

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

    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len != 2) {
        std.debug.print("usage: mmapcopy FILE\n", .{});
        std.process.exit(2);
    }

    const file = try std.Io.Dir.cwd().openFile(io, args[1], .{});
    defer file.close(io);
    const len = try file.length(io);
    // Отображение нулевой длины ядро не создаёт: mmap вернул бы EINVAL.
    if (len == 0) return;

    const bytes = try std.posix.mmap(
        null,
        len,
        .{ .READ = true },
        .{ .TYPE = .PRIVATE },
        file.handle,
        0,
    );
    defer std.posix.munmap(bytes);

    try out.writeAll(bytes);
    try out.flush();
}
$ zig build-exe mmapcopy.zig
$ printf 'Hello, world!\n' > hello.txt
$ ./mmapcopy hello.txt
Hello, world!
$ ./mmapcopy mmapcopy.zig | shasum
23e8a7305c7f30cd3694c7c057c4b97b29d4b8c5  -
$ shasum mmapcopy.zig
23e8a7305c7f30cd3694c7c057c4b97b29d4b8c5  mmapcopy.zig

Разберём детали. Длину файла мы спрашиваем у file.length(io): отображение не знает, где кончается файл, и если попросить больше, хвост последней страницы придёт нулями, а обращение к целым страницам за концом файла кончится сигналом SIGBUS. Файл нулевой длины это отдельная ветка: mmap с length = 0 возвращает EINVAL, а обёртка Zig считает EINVAL недостижимым, так что без проверки программа упадёт в unreachable. Вид отображения PRIVATE, права только на чтение: писать мы не собираемся. Дескриптор берётся из file.handle. Стандартный вывод открыт через writerStreaming, потому что позиционный writer при перенаправлении с >> писал бы с нулевой позиции и затирал файл.

Куда делось копирование? У read данные идут с диска в страничный кэш, оттуда в буфер программы, оттуда вызовом write обратно в ядро. У mmap среднего звена нет: страницы страничного кэша сами стали страницами процесса, и write забирает байты прямо из них. Это не всегда быстрее (каждая страница приходит отдельным сбоем, а read большими кусками умеет упреждающее чтение), но для случайного доступа к большому файлу выигрыш заметный: ты платишь только за те страницы, которых коснулся.

Правка файла на месте

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

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    const io = init.io;
    const args = try init.minimal.args.toSlice(init.arena.allocator());
    if (args.len < 2 or args.len > 3) {
        std.debug.print("usage: upcase FILE [--private]\n", .{});
        std.process.exit(2);
    }
    const private = args.len == 3 and std.mem.eql(u8, args[2], "--private");

    // Отображение с правом записи требует файла, открытого на чтение и запись.
    const file = try std.Io.Dir.cwd().openFile(io, args[1], .{ .mode = .read_write });
    defer file.close(io);
    const len = try file.length(io);
    if (len == 0) return;

    const bytes = try std.posix.mmap(
        null,
        len,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = if (private) .PRIVATE else .SHARED },
        file.handle,
        0,
    );
    defer std.posix.munmap(bytes);

    // Ни одного read и ни одного write: файл это просто срез байтов.
    for (bytes) |*byte| byte.* = std.ascii.toUpper(byte.*);

    std.debug.print("в памяти: {s}", .{bytes[0..@min(len, 14)]});
}
$ zig build-exe upcase.zig
$ ./upcase hello.txt --private
в памяти: HELLO, WORLD!
$ cat hello.txt
Hello, world!
$ ./upcase hello.txt
в памяти: HELLO, WORLD!
$ cat hello.txt
HELLO, WORLD!

Оба запуска отработали одинаково и напечатали одно и то же: внутри процесса разницы нет. Разница снаружи. С --private первая же запись дала сбой защиты, ядро сняло для процесса копию страницы, цикл отработал над копией, а на munmap копия была выброшена. Файл цел. Без ключа запись шла в страницу страничного кэша, ядро пометило её грязной и позже сбросит на диск (когда именно, решает оно; поторопить можно вызовом msync). Команда cat видит изменение сразу, потому что читает из того же страничного кэша.

Отсюда же растёт требование к режиму открытия: разделяемое отображение с правом записи ядро даёт только на дескриптор, открытый на чтение и запись. Попробуй убрать .mode = .read_write, и mmap вернёт error.AccessDenied. Приватному отображению достаточно дескриптора на чтение даже при PROT_WRITE: до файла его записи всё равно не дойдут.

Чего через отображение сделать нельзя, так это изменить длину файла. Дописать в конец нельзя: за последним байтом кончается объект. Если файл надо удлинить, сначала file.setLength(io, new_len), потом новое отображение.

Общая память между родителем и потомком

Теперь анонимные отображения и fork. Заведём две страницы: одну MAP_SHARED, другую MAP_PRIVATE, в обе положим 100, сделаем fork, и пусть потомок увеличит оба счётчика.

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

const linux = std.os.linux;
const is_linux = builtin.os.tag == .linux;

/// В std.posix версии 0.16 нет fork и waitpid. На Linux зовём ядро напрямую,
/// на macOS идём через libc, которую Zig там подключает сам.
fn fork() i32 {
    if (is_linux) return @bitCast(@as(u32, @truncate(linux.fork())));
    return std.c.fork();
}

fn wait(pid: i32) void {
    if (is_linux) {
        var status: u32 = 0;
        _ = linux.waitpid(pid, &status, 0);
    } else {
        var status: c_int = 0;
        _ = std.c.waitpid(pid, &status, 0);
    }
}

fn counter(sharing: @FieldType(std.posix.MAP, "TYPE")) !*u64 {
    const page = try std.posix.mmap(
        null,
        @sizeOf(u64),
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = sharing, .ANONYMOUS = true },
        -1,
        0,
    );
    return @ptrCast(page.ptr);
}

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 shared = try counter(.SHARED);
    const private = try counter(.PRIVATE);
    shared.* = 100;
    private.* = 100;

    const pid = fork();
    if (pid < 0) return error.ForkFailed;
    if (pid == 0) {
        shared.* += 1;
        private.* += 1;
        try out.print("потомок:  shared={d} private={d} (адреса {x} и {x})\n", .{
            shared.*, private.*, @intFromPtr(shared), @intFromPtr(private),
        });
        try out.flush();
        std.process.exit(0);
    }
    wait(pid);
    try out.print("родитель: shared={d} private={d} (адреса {x} и {x})\n", .{
        shared.*, private.*, @intFromPtr(shared), @intFromPtr(private),
    });
    try out.flush();
}
$ zig build-exe shared.zig && ./shared
потомок:  shared=101 private=101 (адреса 1004ac000 и 1004b0000)
родитель: shared=101 private=100 (адреса 1004ac000 и 1004b0000)

Вывод снят на macOS 26, Apple M4 Max. В контейнере Linux (ядро 7.0, aarch64) строки те же, отличаются только адреса: ffff8b7d4000 и ffff8b7d3000.

Адреса у родителя и потомка совпадают: fork копирует адресное пространство как есть. Но за одинаковыми виртуальными адресами теперь стоит разное. Разделяемая страница по-прежнему одна на двоих, и родитель видит 101, записанное потомком. Приватная раздвоилась в момент записи потомка: у него своя копия со 101, у родителя оригинал со 100. Это самый дешёвый способ завести общую память между родственными процессами: ни имён, ни файлов, ни shm_open. Так устроены, например, общие счётчики и кольцевые буферы у пула воркеров, который размножается через fork. Про синхронизацию доступа к такой памяти поговорим в блоке про конкурентность: здесь нас спасает wait, родитель читает, когда потомок уже закончил.

Несколько слов про fork и wait в листинге. На Linux мы зовём ядро напрямую через std.os.linux, libc не нужна; linux.fork() возвращает usize, в котором лежит либо PID, либо отрицательный код ошибки, отсюда @truncate и @bitCast. На macOS прямых системных вызовов нет, всё идёт через libc, которую Zig на этой платформе подключает сам, поэтому там std.c.fork и std.c.waitpid.

Копирование при записи по счётчику сбоев

Виджет показал модель, теперь измерим настоящее ядро. Ядро считает сбои страниц каждого процесса, и getrusage отдаёт их в поле minflt: это сбои, закрытые без похода на диск. Программа отображает 64 МБ анонимной приватной памяти и пишет по байту в каждую страницу: в родителе дважды, потом в потомке после fork, потом снова в родителе.

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

const linux = std.os.linux;
const is_linux = builtin.os.tag == .linux;

fn fork() i32 {
    if (is_linux) return @bitCast(@as(u32, @truncate(linux.fork())));
    return std.c.fork();
}

fn wait(pid: i32) void {
    if (is_linux) {
        var status: u32 = 0;
        _ = linux.waitpid(pid, &status, 0);
    } else {
        var status: c_int = 0;
        _ = std.c.waitpid(pid, &status, 0);
    }
}

/// Сбои страниц, которые ядро закрыло без похода на диск (minor faults).
fn faults() i64 {
    return @intCast(std.posix.getrusage(std.posix.rusage.SELF).minflt);
}

/// Прочитать по байту с каждой страницы. volatile не даёт выкинуть чтение.
fn readAll(memory: []u8, page: usize) u64 {
    var sum: u64 = 0;
    var at: usize = 0;
    while (at < memory.len) : (at += page) {
        const byte: *volatile u8 = &memory[at];
        sum += byte.*;
    }
    return sum;
}

fn writeAll(memory: []u8, page: usize, value: u8) void {
    var at: usize = 0;
    while (at < memory.len) : (at += page) {
        const byte: *volatile u8 = &memory[at];
        byte.* = value;
    }
}

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 page = std.heap.pageSize();
    const size = 64 << 20;
    const memory = try std.posix.mmap(
        null,
        size,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
    defer std.posix.munmap(memory);
    try out.print("страница {d} байт, страниц в области {d}\n", .{ page, size / page });

    var before = faults();
    writeAll(memory, page, 1);
    try out.print("родитель, первая запись:  {d} сбоев\n", .{faults() - before});
    before = faults();
    writeAll(memory, page, 2);
    try out.print("родитель, вторая запись:  {d} сбоев\n", .{faults() - before});
    try out.flush();

    const pid = fork();
    if (pid < 0) return error.ForkFailed;
    if (pid == 0) {
        before = faults();
        const sum = readAll(memory, page);
        try out.print("потомок, чтение:          {d} сбоев (сумма {d})\n", .{ faults() - before, sum });
        before = faults();
        writeAll(memory, page, 3);
        try out.print("потомок, запись:          {d} сбоев\n", .{faults() - before});
        before = faults();
        writeAll(memory, page, 4);
        try out.print("потомок, вторая запись:   {d} сбоев\n", .{faults() - before});
        try out.flush();
        std.process.exit(0);
    }
    wait(pid);
    before = faults();
    writeAll(memory, page, 5);
    try out.print("родитель после потомка:   {d} сбоев, до записи там лежало 2\n", .{faults() - before});
    try out.flush();
}

macOS 26, Apple M4 Max, страница 16 КБ:

$ zig build-exe cowfaults.zig && ./cowfaults
страница 16384 байт, страниц в области 4096
родитель, первая запись:  4096 сбоев
родитель, вторая запись:  0 сбоев
потомок, чтение:          4097 сбоев (сумма 8192)
потомок, запись:          4096 сбоев
потомок, вторая запись:   0 сбоев
родитель после потомка:   4096 сбоев, до записи там лежало 2

Контейнер Linux на той же машине (ядро 7.0.14 от OrbStack, aarch64, страница 4 КБ):

страница 4096 байт, страниц в области 16384
родитель, первая запись:  4096 сбоев
родитель, вторая запись:  0 сбоев
потомок, чтение:          16384 сбоев (сумма 32768)
потомок, запись:          16384 сбоев
потомок, вторая запись:   0 сбоев
родитель после потомка:   16384 сбоев, до записи там лежало 2

Три прогона подряд на каждой системе дали одни и те же числа с точностью до единицы. Читаем по строкам.

Первая запись родителя. Страницы по требованию с обнулением в чистом виде: mmap ничего не выделил, каждая страница пришла своим сбоем. На macOS сбоев ровно столько, сколько страниц. На Linux их вчетверо меньше, чем страниц: 4096 на 16 384. Это не ошибка счётчика. В этом ядре включены большие фолио для анонимной памяти (файл /sys/kernel/mm/transparent_hugepage/hugepages-16kB/enabled показывает always), и один сбой вписывает в таблицу сразу четыре соседние страницы. На ядре без этой настройки ты увидишь 16 384.

Вторая запись. Ноль. Строки в таблице есть, права есть, ядро не участвует. Это обычное состояние работающей программы, и именно поэтому виртуальная память почти ничего не стоит.

Запись потомка. Сбоев снова по одному на страницу, причём на Linux уже ровно 16 384, без поблажки вчетверо: копия при записи снимается постранично. Это и есть копирование при записи, пойманное за руку: до fork те же записи в те же адреса шли без единого сбоя. Вторая запись потомка снова бесплатна: копии личные.

Родитель после потомка. Самая поучительная строка. Потомка уже нет, делить страницы не с кем, а родитель всё равно платит по сбою на страницу. Его PTE остались только для чтения с момента fork, и снять эту пометку ядро может лишь там, где о ней узнаёт: в обработчике сбоя. Там оно видит, что ссылка на кадр одна, и возвращает право записи без копии. Это короткая ветка из виджета. Значение 2 в памяти родителя подтверждает, что тройки и четвёрки потомка ушли в его копии.

Чтение потомка. Здесь системы честно расходятся с учебником, и это полезно знать. По книге fork копирует таблицы страниц, значит чтение в потомке сбоев давать не должно. В исходниках Linux так и написано: функция copy_page_range копирует строки приватных анонимных областей, а вот строки файловых отображений не копирует, потому что их дешевле восстановить по сбою. На моих замерах потомок получает сбой на первом чтении каждой страницы на обеих системах: и XNU, и это ядро Linux оставили таблицу потомка пустой и заполняли её лениво. На ответ программы это не влияет (второе чтение даёт ноль сбоев, сумма верная), влияет только на то, кто и когда платит. Запусти программу на своей машине: если увидишь у чтения число около нуля, твоё ядро скопировало строки при fork. Мораль шире одного счётчика: копирование при записи это договор о том, что увидит программа, а сколько работы сделать при fork и сколько отложить, каждое ядро решает само.

На macOS

Всё, что выше, работает на macOS без правок: mmap, munmap, MAP_SHARED, MAP_PRIVATE и MAP_ANONYMOUS это POSIX и BSD, выводы в уроке сняты в том числе там. Отличия такие.

  • Страница 16 КБ. На Apple Silicon std.heap.pageSize() возвращает 16 384. Всё, что должно быть кратно странице (смещение в mmap, длина в mprotect), считай от этого вызова, а не от константы 4096. std.heap.page_size_min это нижняя граница для типа, а не размер страницы.
  • Нет /proc. Карту областей показывает vmmap PID, сбои страниц считает getrusage, как у нас.
  • Прямых системных вызовов нет. fork, waitpid, mprotect идут через libc (std.c), Zig подключает её сам.
  • Нарушение прав это SIGBUS, а не SIGSEGV. Запись в страницу без права записи на Linux даёт SIGSEGV, на macOS SIGBUS. Переносимый тест проверяет только то, что процесс убит сигналом.
  • RWX одним вызовом не дают. На Apple Silicon mmap с PROT_WRITE и PROT_EXEC сразу проходит только с флагом MAP_JIT и правом в подписи приложения. Переход RW в RX через mprotect для анонимной памяти работает. Это отличный довод в пользу дисциплины, к которой мы сейчас перейдём.

Права страниц можно менять: mprotect и правило W^X

Права области задаются при mmap, но не навсегда. Вызов mprotect(addr, len, prot) меняет права целых страниц в диапазоне, и ядро при этом переписывает биты в PTE и сбрасывает соответствующие строки TLB. На этом вызове держится правило, которое за последние пятнадцать лет стало нормой для всех, кто порождает машинный код на лету.

W^X читается как запись исключающее или исполнение: страница в каждый момент либо записываемая, либо исполняемая. Зачем это нужно, ты знаешь из урока про переполнение буфера: если в процессе есть страница RWX, любая ошибка с записью за границу превращается в исполнение чужого кода. Стек и куча давно не исполняемые (бит NX в PTE, у Intel он называется XD). JIT это законный случай, когда процессу действительно нужно написать байты и потом исполнить их, и дисциплина тут простая: сначала RW, пишем; потом mprotect в RX, исполняем; надо поправить, возвращаем RW, правим, снова RX.

Посмотрим на это глазами ядра. Программа ниже отображает две страницы RW, кладёт в первую шесть байт кода (mov $42, %eax; ret), переводит первую страницу в RX и оба раза печатает строки /proc/self/maps, которые пересекаются с нашей областью.

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

const linux = std.os.linux;

/// mov $42, %eax; ret
const answer = [_]u8{ 0xB8, 0x2A, 0x00, 0x00, 0x00, 0xC3 };

/// Строки /proc/self/maps, которые пересекаются с областью.
fn showMaps(out: *std.Io.Writer, io: std.Io, start: usize, end: usize) !void {
    const file = try std.Io.Dir.openFileAbsolute(io, "/proc/self/maps", .{});
    defer file.close(io);
    var buf: [4096]u8 = undefined;
    var reader = file.readerStreaming(io, &buf);
    while (try reader.interface.takeDelimiter('\n')) |line| {
        const dash = std.mem.indexOfScalar(u8, line, '-') orelse continue;
        const space = std.mem.indexOfScalar(u8, line, ' ') orelse continue;
        const lo = try std.fmt.parseInt(usize, line[0..dash], 16);
        const hi = try std.fmt.parseInt(usize, line[dash + 1 .. space], 16);
        if (lo < end and hi > start) try out.print("  {s}\n", .{line});
    }
}

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

    const page = std.heap.pageSize();
    const memory = try std.posix.mmap(
        null,
        2 * page,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
    defer std.posix.munmap(memory);
    const start = @intFromPtr(memory.ptr);

    try out.writeAll("после mmap, обе страницы RW:\n");
    try showMaps(out, io, start, start + 2 * page);

    @memcpy(memory[0..answer.len], &answer);
    const rx: std.posix.PROT = .{ .READ = true, .EXEC = true };
    if (linux.errno(linux.mprotect(memory.ptr, page, rx)) != .SUCCESS) return error.ProtectFailed;

    try out.writeAll("после mprotect первой страницы в RX:\n");
    try showMaps(out, io, start, start + 2 * page);

    if (builtin.cpu.arch == .x86_64) {
        const f: *const fn () callconv(.c) u32 = @ptrCast(memory.ptr);
        try out.print("код со страницы RX вернул {d}\n", .{f()});
    }
    try out.flush();
}

Docker, linux/amd64 (эмуляция x86-64 на той же машине):

после mmap, обе страницы RW:
  7fffff7bc000-7fffff7ff000 rw-p 00000000 00:00 0 
после mprotect первой страницы в RX:
  7fffff7bc000-7fffff7bd000 r-xp 00000000 00:00 0 
  7fffff7bd000-7fffff7ff000 rw-p 00000000 00:00 0 
код со страницы RX вернул 42

Два наблюдения. Первое: до mprotect наших двух страниц в карте отдельной строкой нет. Ядро склеило новую анонимную область с соседней, у которой те же права и тот же вид: строка покрывает 268 КБ, а не 8. Области в ядре это не то, что ты просил у mmap, а максимальные отрезки с одинаковыми свойствами. Второе: после mprotect область раскололась. Права принадлежат страницам, а запись об области описывает отрезок с одинаковыми правами, поэтому одна запись стала двумя. Именно поэтому mprotect умеет вернуть ENOMEM: на новую запись об области может не хватить памяти ядра.

Шаг проекта zl: исполняемая память по-честному

В уроке про JIT мы просили у ядра страницу сразу с тремя правами и в комментарии к Code.map обещали исправиться в уроке про отображение памяти. Вот он. Что меняется:

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

Зачем коду и данным разные страницы, когда можно было бы положить таблицу сразу за последним ret? Причин две. Данные JIT меняются на ходу: таблицы адресов, счётчики вызовов, встроенные кэши. Лежи они на одной странице с кодом, каждая правка требовала бы двух mprotect, а между ними код был бы не исполним. И вторая, аппаратная: x86 следит за записью в страницы, с которых недавно исполнялся код, и при такой записи сбрасывает конвейер, подозревая самоизменяющийся код. На разных страницах код остаётся запечатанным, а данные правятся обычным mov, без единого системного вызова. Промышленные JIT приходят к тому же: изменяемые таблицы и встроенные кэши живут в обычной куче, а в коде лежат только их адреса.

sys.zig: чего нет в std.posix

Новый файл. mprotect, fork и waitpid в std.posix 0.16 отсутствуют, поэтому заводим тонкий слой: на Linux прямые системные вызовы из std.os.linux, на macOS libc.

//! Тонкий слой над системными вызовами, которых нет в `std.posix` версии 0.16.
//!
//! `mmap` и `munmap` в `std.posix` есть, а `mprotect`, `fork` и `waitpid` нет.
//! На Linux зовём ядро напрямую через `std.os.linux`, и libc для этого не
//! нужна. На macOS прямых системных вызовов не бывает, там всё идёт через
//! libc, которую Zig на этой платформе подключает сам.

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

const linux = std.os.linux;
const is_linux = builtin.os.tag == .linux;

pub const Page = []align(std.heap.page_size_min) u8;

pub const ProtectError = error{
    /// Ядро не разрешает такие права для этой области. Так отвечает, например,
    /// macOS с усиленной средой исполнения на просьбу о `PROT_EXEC`.
    AccessDenied,
    /// Смена прав расколола область на две, и ядру не хватило памяти на вторую.
    OutOfMemory,
    Unexpected,
};

/// Поменять права страниц. Длина среза обязана быть кратна странице:
/// права бывают только у целых страниц.
pub fn mprotect(memory: Page, prot: std.posix.PROT) ProtectError!void {
    const err = if (is_linux)
        linux.errno(linux.mprotect(memory.ptr, memory.len, prot))
    else
        std.c.errno(std.c.mprotect(@ptrCast(memory.ptr), memory.len, prot));
    return switch (err) {
        .SUCCESS => {},
        .ACCES, .PERM => error.AccessDenied,
        .NOMEM => error.OutOfMemory,
        else => error.Unexpected,
    };
}

extern "c" fn fork() c_int;
/// Чем кончил потомок, в котором исполнили опасное действие.
pub const Fate = union(enum) {
    /// Дожил до конца и вышел сам.
    exited: u8,
    /// Убит сигналом: номер сигнала.
    signaled: u32,
};

/// Исполнить `action(arg)` в потомке и рассказать, чем это для него кончилось.
///
/// Так проверяют то, что в своём процессе проверить нельзя: запись в страницу
/// без права на запись убивает процесс сигналом, и тест упал бы вместе с ним.
/// Потомок получает копию адресного пространства вместе с правами страниц,
/// поэтому его судьба честно говорит о наших страницах.
pub fn fateOf(comptime action: fn (usize) void, arg: usize) error{ForkFailed}!Fate {
    if (is_linux) {
        const pid = linux.fork();
        if (linux.errno(pid) != .SUCCESS) return error.ForkFailed;
        if (pid == 0) {
            dieQuietly();
            action(arg);
            linux.exit_group(0);
        }
        var status: u32 = 0;
        _ = linux.waitpid(@intCast(pid), &status, 0);
        return fate(linux.W, status);
    }
    const pid = fork();
    if (pid < 0) return error.ForkFailed;
    if (pid == 0) {
        dieQuietly();
        action(arg);
        std.c._exit(0);
    }
    var status: c_int = 0;
    _ = std.c.waitpid(pid, &status, 0);
    return fate(std.c.W, @bitCast(status));
}

/// Вернуть сигналам о сбое памяти поведение по умолчанию. Среда Zig ставит на
/// них свой обработчик с трассой стека, а потомку положено умереть молча.
fn dieQuietly() void {
    const default: std.posix.Sigaction = .{
        .handler = .{ .handler = std.posix.SIG.DFL },
        .mask = std.posix.sigemptyset(),
        .flags = 0,
    };
    for ([_]std.posix.SIG{ .SEGV, .BUS, .ILL }) |sig| std.posix.sigaction(sig, &default, null);
}

fn fate(comptime W: type, status: u32) Fate {
    if (W.IFSIGNALED(status)) return .{ .signaled = @intFromEnum(W.TERMSIG(status)) };
    return .{ .exited = W.EXITSTATUS(status) };
}

Главное здесь fateOf. Проверить, что запись в страницу RX запрещена, в своём процессе нельзя: процесс умрёт от сигнала, и тест вместе с ним. Поэтому опасное действие исполняет потомок. Он получает копию адресного пространства вместе с правами страниц, так что его судьба честно говорит о наших страницах, а родитель читает её из waitpid. Это то же копирование при записи, что в первой половине урока, только теперь оно работает на нас как песочница. dieQuietly нужен по прозаической причине: среда исполнения Zig ставит на SIGSEGV свой обработчик, который печатает трассу стека, и без сброса в SIG.DFL каждый такой тест заливал бы терминал.

execmem.zig: образ с двумя состояниями

//! Исполняемая память по правилу W^X.
//!
//! Страница либо записываемая, либо исполняемая, и никогда то и другое сразу.
//! Образ JIT это одно отображение `mmap`, в котором подряд лежат страницы кода
//! и страницы данных:
//!
//!   [ код: RW, пока пишем; RX, когда запечатан ] [ данные: всегда RW, без X ]
//!
//! Права в ядре назначаются страницам, а не отображениям, поэтому одно
//! отображение спокойно живёт с разными правами на разных страницах (в
//! `/proc/self/maps` оно после `mprotect` распадается на две строки).
//!
//! Зачем данным своя страница. Данные JIT меняются на ходу: таблицы адресов,
//! счётчики, кэши вызовов. Лежи они на одной странице с кодом, каждая правка
//! требовала бы снять с кода право на исполнение, а страница с кодом, в
//! которую пишут, заставляет x86 сбрасывать конвейер: процессор подозревает
//! самоизменяющийся код. На разных страницах код остаётся запечатанным
//! навсегда, а данные правятся без единого системного вызова.

const std = @import("std");

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

pub const Error = std.posix.MMapError || sys.ProtectError;

pub const WriteError = error{
    /// Код запечатан: писать можно только после `unseal` или через `patch`.
    Sealed,
    /// Запись не помещается в страницы кода.
    OutOfBounds,
};

pub const State = enum {
    /// Страницы кода RW: пишем байты, исполнять нельзя.
    writable,
    /// Страницы кода RX: исполняем, писать нельзя.
    executable,
};

const rw: std.posix.PROT = .{ .READ = true, .WRITE = true };
const rx: std.posix.PROT = .{ .READ = true, .EXEC = true };

pub const Image = struct {
    memory: sys.Page,
    /// Сколько байт с начала отображения отдано под код. Кратно странице.
    code_size: usize,
    state: State = .writable,

    /// Отобразить образ: `code_bytes` под код и `data_bytes` под данные, то и
    /// другое округляется вверх до целых страниц. Всё отображение рождается RW.
    pub fn map(code_bytes: usize, data_bytes: usize) Error!Image {
        const page = std.heap.pageSize();
        const code_size = std.mem.alignForward(usize, @max(code_bytes, 1), page);
        const data_size = std.mem.alignForward(usize, data_bytes, page);
        const memory = try std.posix.mmap(
            null,
            code_size + data_size,
            rw,
            .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
            -1,
            0,
        );
        return .{ .memory = memory, .code_size = code_size };
    }

    pub fn deinit(self: *Image) void {
        std.posix.munmap(self.memory);
        self.* = undefined;
    }

    /// Страницы кода только на чтение: читать можно в любом состоянии.
    pub fn code(self: *const Image) []const u8 {
        return self.memory[0..self.code_size];
    }

    /// Страницы данных. Они RW всю жизнь образа и не исполняются никогда.
    pub fn data(self: *const Image) sys.Page {
        return @alignCast(self.memory[self.code_size..]);
    }

    /// Положить байты в код. Работает только пока образ не запечатан:
    /// проверка стоит здесь, в API, чтобы ошибка была значением, а не сигналом
    /// от ядра.
    pub fn write(self: *Image, at: usize, bytes: []const u8) WriteError!void {
        if (self.state != .writable) return error.Sealed;
        if (at > self.code_size or bytes.len > self.code_size - at) return error.OutOfBounds;
        @memcpy(self.memory[at..][0..bytes.len], bytes);
    }

    /// RW в RX: после этого код можно звать и нельзя править.
    pub fn seal(self: *Image) sys.ProtectError!void {
        try sys.mprotect(self.codePages(), rx);
        self.state = .executable;
    }

    /// RX в RW: код снова можно править и нельзя звать.
    pub fn unseal(self: *Image) sys.ProtectError!void {
        try sys.mprotect(self.codePages(), rw);
        self.state = .writable;
    }

    /// Заплатка в запечатанном коде: распечатать, записать, запечатать снова.
    /// Между двумя `mprotect` код не исполним, поэтому второй поток в этот
    /// момент звать его не должен.
    pub fn patch(self: *Image, at: usize, bytes: []const u8) (WriteError || sys.ProtectError)!void {
        if (at > self.code_size or bytes.len > self.code_size - at) return error.OutOfBounds;
        try self.unseal();
        self.write(at, bytes) catch unreachable;
        try self.seal();
    }

    /// Адрес в коде как функция. Отдаётся только из запечатанного образа.
    pub fn entry(self: *const Image, comptime F: type, at: usize) error{NotSealed}!F {
        if (self.state != .executable) return error.NotSealed;
        return @ptrFromInt(@intFromPtr(self.memory.ptr) + at);
    }

    fn codePages(self: *const Image) sys.Page {
        return self.memory[0..self.code_size];
    }
};

const testing = std.testing;

test "код и данные лежат на разных страницах одного отображения" {
    var image: Image = try .map(100, 8);
    defer image.deinit();

    const page = std.heap.pageSize();
    try testing.expectEqual(page, image.code().len);
    try testing.expectEqual(page, image.data().len);
    try testing.expectEqual(@intFromPtr(image.code().ptr) + page, @intFromPtr(image.data().ptr));
}

test "запечатанный код не принимает запись, распечатанный не отдаёт точку входа" {
    var image: Image = try .map(1, 0);
    defer image.deinit();

    try testing.expectError(error.NotSealed, image.entry(*const fn () callconv(.c) void, 0));
    try image.write(0, &.{0xC3});
    try testing.expectError(error.OutOfBounds, image.write(image.code_size, &.{0xC3}));

    try image.seal();
    try testing.expectError(error.Sealed, image.write(0, &.{0x90}));
    _ = try image.entry(*const fn () callconv(.c) void, 0);

    try image.patch(0, &.{ 0x90, 0xC3 });
    try testing.expectEqual(State.executable, image.state);
    try testing.expectEqualSlices(u8, &.{ 0x90, 0xC3 }, image.code()[0..2]);
}

Image это маленький автомат: writable и executable. Метод write работает только в первом состоянии, entry только во втором, и обе проверки возвращают ошибку как значение (error.Sealed, error.NotSealed), а не сигнал от ядра. Ядро всё равно подстрахует, но ошибка из API дешевле в отладке, чем SIGSEGV без адреса. patch это распечатать, записать, запечатать. Между двумя mprotect код не исполним; для одного потока это незаметно, а при втором потоке, который в этот момент зовёт код, получится сбой. Взрослые JIT решают это двумя отображениями одной физической памяти: одно RW для компилятора, другое RX для исполнения. Сделать это можно через memfd_create и два mmap с MAP_SHARED, и это хорошее упражнение.

Про кэш инструкций. На x86-64 после записи кода сбрасывать ничего не нужно: процессор сам замечает запись в страницы, откуда шла выборка. На arm64 кэши команд и данных раздельные, и после записи кода обязателен вызов вроде sys_icache_invalidate или __clear_cache. Наш JIT порождает только x86-64, на arm64 его код не исполняется, поэтому эталон этого не делает.

jit.zig: Code поверх Image и косвенные вызовы

Компилятор выражений не изменился, и байты прямого режима остались теми же: тесты урока 19 проходят без правок. Изменений три. Первое: импорт нового модуля.

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

Второе: таблица Slots и ветка в генерации вызова.

/// Таблица адресов примитивов на странице данных образа.
///
/// В прямом режиме адрес примитива вшит в `movabs` и лежит в коде. В косвенном
/// в коде лежит адрес ячейки этой таблицы, а сам адрес примитива читается из
/// неё при каждом вызове. Таблица живёт на RW-странице, поэтому примитив можно
/// подменить на ходу, не снимая с кода право на исполнение.
pub const Slots = extern struct {
    pub const capacity = 31;

    len: usize = 0,
    prims: [capacity]Prim2 = undefined,

    /// Ячейка этого примитива: уже занятая или новая.
    pub fn slotFor(self: *Slots, prim: Prim2) *Prim2 {
        for (self.prims[0..self.len]) |*slot| {
            if (slot.* == prim) return slot;
        }
        std.debug.assert(self.len < capacity);
        self.prims[self.len] = prim;
        self.len += 1;
        return &self.prims[self.len - 1];
    }

    /// Подменить примитив во всех вызовах разом. Правда, если было что менять.
    pub fn rebind(self: *Slots, old: Prim2, new: Prim2) bool {
        for (self.prims[0..self.len]) |*slot| {
            if (slot.* == old) {
                slot.* = new;
                return true;
            }
        }
        return false;
    }
};

В Compiler появляется поле slots: ?*Slots = null, а в ветке .call вместо одной строки с movImm64 теперь выбор:

                if (self.slots) |slots| {
                    // Косвенно: в коде адрес ячейки, адрес примитива в данных.
                    x86.movImm64(buf, .rax, @intFromPtr(slots.slotFor(call.prim)));
                    x86.movLoad(buf, .rax, .rax, 0);
                } else {
                    x86.movImm64(buf, .rax, @intFromPtr(call.prim));
                }
                x86.callReg(buf, .rax);

В прямом режиме вызов это movabs $prim, %rax; call *%rax. В косвенном movabs $slot, %rax; mov 0(%rax), %rax; call *%rax: на четыре байта длиннее и на одно чтение памяти медленнее. Если ты делал урок про динамическую компоновку, ты эту картинку узнал: это GOT в миниатюре. Код неизменяем, а адреса, которые могут поменяться, вынесены в таблицу на странице данных.

Третье: compile разделилась на две точки входа, а Code переехала на Image.

/// Собрать машинный код тела `lambda` в буфер. Вызовы примитивов прямые.
pub fn compile(buf: *x86.Buf, expr: *const Expr) void {
    compileWith(buf, expr, null);
}

/// То же, но вызовы идут через таблицу `slots`.
pub fn compileIndirect(buf: *x86.Buf, expr: *const Expr, slots: *Slots) void {
    compileWith(buf, expr, slots);
}

fn compileWith(buf: *x86.Buf, expr: *const Expr, slots: ?*Slots) void {
    const frame = x86.frameSize(scratchDepth(expr));
    var compiler: Compiler = .{
        .buf = buf,
        .body = x86.prologue(buf, frame),
        .slots = slots,
    };
    compiler.gen(expr, 0);
    x86.epilogue(buf);
}

/// Ошибки, которые может вернуть отображение кода в память.
pub const MapError = execmem.Error || error{CodeTooLong};

/// Как скомпилированный код зовёт примитивы.
pub const Calls = enum {
    /// Адрес примитива вшит в код.
    direct,
    /// Адрес примитива читается из таблицы на странице данных.
    indirect,
};

/// Машинный код в запечатанном образе: его можно звать и нельзя править.
///
/// На шаге 19 мы просили у ядра страницу RWX и честно называли это
/// компромиссом. Теперь правило W^X соблюдено: образ рождается RW, байты
/// ложатся в него, `seal` меняет права на RX, и только после этого отдаётся
/// точка входа. Страница данных отдельная и остаётся RW.
pub const Code = struct {
    image: execmem.Image,
    len: usize,

    /// Отобразить готовые байты и запечатать.
    pub fn map(machine_code: []const u8) MapError!Code {
        var image: execmem.Image = try .map(machine_code.len, @sizeOf(Slots));
        errdefer image.deinit();
        image.write(0, machine_code) catch unreachable;
        try image.seal();
        return .{ .image = image, .len = machine_code.len };
    }

    /// Скомпилировать дерево прямо под этот образ. Порядок обратный `map`:
    /// сначала отображение, потом компиляция, потому что косвенному коду
    /// нужен адрес таблицы, а он известен только после `mmap`.
    pub fn compileFrom(expr: *const Expr, calls: Calls) MapError!Code {
        var image: execmem.Image = try .map(x86.Buf.capacity, @sizeOf(Slots));
        errdefer image.deinit();

        const table: *Slots = @ptrCast(image.data().ptr);
        table.* = .{};

        var buf: x86.Buf = .{};
        switch (calls) {
            .direct => compile(&buf, expr),
            .indirect => compileIndirect(&buf, expr, table),
        }
        image.write(0, buf.slice()) catch return error.CodeTooLong;
        try image.seal();
        return .{ .image = image, .len = buf.len };
    }

    /// Байты кода, как их видит процессор.
    pub fn bytes(self: *const Code) []const u8 {
        return self.image.code()[0..self.len];
    }

    /// Код как обычная функция с соглашением C.
    pub fn body(self: *const Code) Body {
        return self.image.entry(Body, 0) catch unreachable;
    }

    /// Таблица примитивов на странице данных.
    pub fn slots(self: *const Code) *Slots {
        return @ptrCast(self.image.data().ptr);
    }

    /// Заплатка в уже запечатанном коде: RX в RW, запись, RW в RX.
    pub fn patch(self: *Code, at: usize, new_bytes: []const u8) !void {
        if (at > self.len or new_bytes.len > self.len - at) return error.OutOfBounds;
        try self.image.patch(at, new_bytes);
    }

    pub fn deinit(self: *Code) void {
        self.image.deinit();
        self.* = undefined;
    }
};

/// Скомпилировать дерево и сразу отобразить его в исполняемую память.
pub fn jit(expr: *const Expr) MapError!Code {
    return Code.compileFrom(expr, .direct);
}

Обрати внимание на порядок в compileFrom: сначала mmap, потом компиляция. В уроке 19 было наоборот, но косвенному коду нужен адрес таблицы, а он известен только после отображения. Параметр называется machine_code, а не bytes, потому что в Zig 0.16 имя параметра не может совпадать с именем метода той же структуры. Остаётся зарегистрировать модули и шаг: в src/root.zig две строки, в build.zig номер шага в списке.

pub const execmem = @import("execmem.zig");
pub const sys = @import("sys.zig");
const steps = [_][]const u8{ "05", "06", "13", "14", "15", "19", "56" };

Тесты шага

Проверки сложены в три слоя. Первый проверяет автомат состояний и идёт везде. Второй спрашивает ядро через fateOf и идёт на любой POSIX-системе. Третий прыгает в скомпилированный код и вне x86-64 возвращает error.SkipZigTest.

//! Шаг 56: исполняемая память по правилу W^X.
//!
//! Три слоя проверок. Первый это автомат состояний образа: запись в
//! запечатанный код отбивается ошибкой `error.Sealed` ещё в API, до ядра.
//! Второй спрашивает само ядро: потомок после `fork` пишет в страницу RX и
//! погибает от сигнала, а тест смотрит на его судьбу со стороны и остаётся
//! жив. Оба слоя идут на любой POSIX-системе. Третий слой прыгает в
//! скомпилированный код, поэтому вне x86-64 возвращает `error.SkipZigTest`.

const std = @import("std");

const zl = @import("zl");

const execmem = zl.execmem;
const jit = zl.jit;
const sys = zl.sys;
const x86 = zl.x86;
const Vm = zl.Vm;

const Expr = jit.Expr;
const Value = jit.Value;

const testing = std.testing;

fn plus(vm: *Vm, a: Value, b: Value) callconv(.c) Value {
    _ = vm;
    return jit.fixnum(jit.fixnumOf(a) + jit.fixnumOf(b));
}

fn minus(vm: *Vm, a: Value, b: Value) callconv(.c) Value {
    _ = vm;
    return jit.fixnum(jit.fixnumOf(a) - jit.fixnumOf(b));
}

const param: Expr = .param;
const one: Expr = .{ .fixnum = 1 };
/// `(+ x 1)`.
const x_plus_one: Expr = .{ .call = .{ .prim = &plus, .a = &param, .b = &one } };

/// Где в коде константы лежат восемь байт её значения: пролог, затем два
/// байта `movabs` (REX.W и opcode), затем само число.
const imm_at = 19 + 2;

// --- Слой первый: автомат состояний ---

test "образ рождается записываемым, а после seal только исполняется" {
    var image: execmem.Image = try .map(64, 0);
    defer image.deinit();

    try testing.expectEqual(execmem.State.writable, image.state);
    try image.write(0, &.{ 0x90, 0xC3 });

    try image.seal();
    try testing.expectEqual(execmem.State.executable, image.state);
    try testing.expectError(error.Sealed, image.write(0, &.{0xCC}));
    // Читать запечатанный код можно: RX это чтение плюс исполнение.
    try testing.expectEqualSlices(u8, &.{ 0x90, 0xC3 }, image.code()[0..2]);
}

test "заплатка проходит через RW и возвращает образ в RX" {
    var image: execmem.Image = try .map(64, 0);
    defer image.deinit();

    try image.write(0, &.{ 0x90, 0x90, 0xC3 });
    try image.seal();
    try image.patch(1, &.{0xCC});

    try testing.expectEqual(execmem.State.executable, image.state);
    try testing.expectEqualSlices(u8, &.{ 0x90, 0xCC, 0xC3 }, image.code()[0..3]);
    try testing.expectError(error.OutOfBounds, image.patch(image.code_size, &.{0x90}));
}

test "точка входа отдаётся только из запечатанного образа" {
    var image: execmem.Image = try .map(64, 0);
    defer image.deinit();

    try testing.expectError(error.NotSealed, image.entry(jit.Body, 0));
    try image.seal();
    _ = try image.entry(jit.Body, 0);
    try image.unseal();
    try testing.expectError(error.NotSealed, image.entry(jit.Body, 0));
}

test "код и данные это соседние страницы с разными правами" {
    var code = try jit.Code.compileFrom(&x_plus_one, .indirect);
    defer code.deinit();

    const page = std.heap.pageSize();
    const code_at = @intFromPtr(code.image.code().ptr);
    const data_at = @intFromPtr(code.slots());
    try testing.expectEqual(@as(usize, 0), code_at % page);
    try testing.expectEqual(code_at + code.image.code_size, data_at);

    // Таблица на странице данных правится без единого mprotect.
    try testing.expectEqual(@as(usize, 1), code.slots().len);
    try testing.expect(code.slots().rebind(&plus, &minus));
    try testing.expect(!code.slots().rebind(&plus, &minus));
    try testing.expectEqual(execmem.State.executable, code.image.state);
}

// --- Слой второй: что на это говорит ядро ---

fn pokeByte(addr: usize) void {
    const target: *volatile u8 = @ptrFromInt(addr);
    target.* = 0xCC;
}

fn jumpTo(addr: usize) void {
    const target: *const fn () callconv(.c) void = @ptrFromInt(addr);
    target();
}

test "запись в страницу RX убивает процесс, запись в RW нет" {
    var image: execmem.Image = try .map(64, 8);
    defer image.deinit();
    const code_at = @intFromPtr(image.code().ptr);
    const data_at = @intFromPtr(image.data().ptr);

    // Пока образ не запечатан, потомок пишет в код и выходит сам.
    try testing.expectEqual(sys.Fate{ .exited = 0 }, try sys.fateOf(pokeByte, code_at));

    try image.seal();
    // Linux присылает SIGSEGV, macOS на нарушение прав отвечает SIGBUS.
    const fate = try sys.fateOf(pokeByte, code_at);
    try testing.expect(fate == .signaled);
    // Страница данных осталась RW, хотя лежит в том же отображении.
    try testing.expectEqual(sys.Fate{ .exited = 0 }, try sys.fateOf(pokeByte, data_at));
    // Запись потомка нашу страницу не тронула: у него своя копия при записи.
    try testing.expectEqual(@as(u8, 0), image.code()[0]);

    try image.unseal();
    try testing.expectEqual(sys.Fate{ .exited = 0 }, try sys.fateOf(pokeByte, code_at));
}

test "страница данных не исполняется" {
    var image: execmem.Image = try .map(64, 8);
    defer image.deinit();
    // `ret` на странице данных: байт верный, права нет.
    image.data()[0] = 0xC3;
    try image.seal();

    const fate = try sys.fateOf(jumpTo, @intFromPtr(image.data().ptr));
    try testing.expect(fate == .signaled);
}

// --- Байты косвенного вызова: проверяются на любой архитектуре ---

test "косвенный вызов длиннее прямого ровно на чтение из таблицы" {
    var direct: x86.Buf = .{};
    jit.compile(&direct, &x_plus_one);

    var table: jit.Slots = .{};
    var indirect: x86.Buf = .{};
    jit.compileIndirect(&indirect, &x_plus_one, &table);

    // mov 0(%rax), %rax это четыре байта.
    try testing.expectEqual(direct.len + 4, indirect.len);
    try testing.expectEqual(@as(usize, 1), table.len);

    // Прямо перед `call *%rax` стоит это чтение, а перед ним movabs с адресом
    // ячейки таблицы, а не примитива.
    const code = indirect.slice();
    const call_at = std.mem.indexOf(u8, code, &.{ 0x48, 0x8B, 0x40, 0x00, 0xFF, 0xD0 }).?;
    const slot_addr = std.mem.readInt(u64, code[call_at - 8 ..][0..8], .little);
    try testing.expectEqual(@intFromPtr(&table.prims[0]), slot_addr);
}

// --- Слой третий: прыгаем в код ---

test "скомпилированный код исполняется из запечатанной страницы" {
    if (!jit.canRun()) return error.SkipZigTest;

    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();

    var code = try jit.jit(&x_plus_one);
    defer code.deinit();
    try testing.expectEqual(execmem.State.executable, code.image.state);
    try testing.expectEqual(@as(i64, 42), jit.fixnumOf(code.body()(&vm, jit.fixnum(41))));
}

test "заплатка константы меняет ответ уже запечатанного кода" {
    var code = try jit.jit(&one);
    defer code.deinit();

    // movabs с тегированной единицей: байты проверяются везде.
    try testing.expectEqual(jit.fixnum(1), std.mem.readInt(u64, code.bytes()[imm_at..][0..8], .little));

    var imm: [8]u8 = undefined;
    std.mem.writeInt(u64, &imm, jit.fixnum(2), .little);
    try code.patch(imm_at, &imm);
    try testing.expectEqual(jit.fixnum(2), std.mem.readInt(u64, code.bytes()[imm_at..][0..8], .little));
    try testing.expectError(error.OutOfBounds, code.patch(code.len, &imm));

    if (!jit.canRun()) return error.SkipZigTest;
    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();
    try testing.expectEqual(@as(i64, 2), jit.fixnumOf(code.body()(&vm, jit.nil)));
}

test "подмена примитива в таблице данных не трогает права кода" {
    if (!jit.canRun()) return error.SkipZigTest;

    var vm: Vm = try .init(testing.allocator);
    defer vm.deinit();

    var code = try jit.Code.compileFrom(&x_plus_one, .indirect);
    defer code.deinit();

    try testing.expectEqual(@as(i64, 42), jit.fixnumOf(code.body()(&vm, jit.fixnum(41))));
    try testing.expect(code.slots().rebind(&plus, &minus));
    try testing.expectEqual(@as(i64, 40), jit.fixnumOf(code.body()(&vm, jit.fixnum(41))));
    try testing.expectEqual(execmem.State.executable, code.image.state);
}

macOS arm64 (три теста, которые прыгают в код x86-64, пропущены):

$ zig build test -Dstep=56 --summary all
Build Summary: 5/5 steps succeeded; 33/36 tests passed (3 skipped)
test success
+- run test 26 pass (26 total) 139ms MaxRSS:5M
|  +- compile test Debug native cached 383ms MaxRSS:35M
+- run test 7 pass, 3 skip (10 total) 125ms MaxRSS:2M
   +- compile test Debug native cached 363ms MaxRSS:35M

Строка 26 pass это тесты самого модуля, в том числе два из execmem.zig. В контейнере linux/amd64 с копией исходников те же десять тестов шага проходят без пропусков: под эмуляцией x86-64 права страниц соблюдаются, и потомок честно погибает от SIGSEGV.

Прочитай тест про запись в страницу RX ещё раз, в нём вся первая половина урока. Потомок пишет в незапечатанный код и выходит с нулём, а родитель после этого видит в своей странице ноль: запись потомка ушла в его копию. После seal та же запись убивает потомка сигналом. Страница данных в том же отображении принимает запись как ни в чём не бывало. Тест про заплатку меняет восемь байт константы внутри movabs (смещение 19 байт пролога плюс REX.W и код операции), и уже запечатанный код начинает отвечать 2 вместо 1. А последний тест подменяет plus на minus в таблице на странице данных, и 42 превращается в 40 без единого mprotect: состояние образа всё это время executable.

Шаг проекта Y86: MMU

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

  • виртуальный адрес делится на VPN и VPO, строка таблицы страниц лежит в физической памяти, базу таблицы железо берёт из регистра ptbr;
  • перед таблицей стоит TLB, наборно-ассоциативный, как в книге;
  • сбой трансляции это исключение: железо запоминает сбойный адрес и уходит в обработчик ядра, а после iret инструкция исполняется заново;
  • ядро выдаёт страницы по требованию: у процесса при старте есть только страница кода, данные и стек приходят по первому сбою;
  • у каждого процесса своя таблица, поэтому оба собраны с одного виртуального адреса 0x400 и друг друга не видят.

Решения, которые стоит проговорить

Два набора параметров. Сквозной пример книги это VA 14 бит, страница 64 байта, TLB на четыре набора по четыре строки. Модуль mmu.zig параметризован, и набор mmu.book повторяет книгу: первые тесты шага загружают TLB и таблицу с рисунка к разделу 9.6.4 и проверяют те самые адреса, которые ты переводил руками в уроке 55. Но сама машина работает на mmu.machine: VA 12 бит, страница 256 байт, TLB два набора по две строки. Причина в арифметике: у нас PTE это слово в восемь байт, с параметрами книги в таблице 256 строк, то есть 2048 байт на процесс при физической памяти в 4096. С большой страницей таблица это 16 строк и 128 байт, и две таблицы спокойно лежат в памяти ядра. Это тот самый размен из урока 55 в миниатюре: крупнее страница, меньше таблица, грубее защита.

Формат PTE. Слово: физический адрес кадра плюс биты в младших разрядах, которые у выровненного адреса всё равно нулевые. Так же устроена строка у x86-64. Бит 0 valid, бит 1 write, бит 2 user. Бит 3 demand железо не читает: это пометка ядра самому себе, что страницы пока нет, но она процессу положена. Бита исполнения нет, выборка требует valid и user.

В режиме ядра трансляции нет. Ядро ходит по физическим адресам и по ним же правит таблицы. Так было у ранних MIPS (сегмент kseg0) и так проще всего: иначе ядру пришлось бы отображать само себя, и мы получили бы курицу и яйцо при загрузке. Вдобавок ptbr == 0 выключает трансляцию и для процессов, поэтому ядра из уроков 47, 48 и 50 работают на новой машине без единой правки.

Сбой идёт через существующую причину adr. Отдельного вектора мы не заводим: у x86-64 сбой страницы и нарушение защиты тоже приходят одним исключением #PF, а различает их обработчик. Чтобы он мог это сделать, железо кладёт в память два слова: fault_va, сбойный адрес (аналог регистра CR2), и fault_pte, физический адрес PTE сбойной страницы. Второе слово это уступка набору команд: сдвигов в Y86-64 нет, и делить адрес на 256 вычитанием в цикле внутри обработчика сбоя было бы жестоко. У MIPS для той же цели есть регистр Context. Нулевой fault_pte значит, что адрес вне адресного пространства и строки для него не существует.

TLB сбрасывается сам при смене базы. MMU помнит, при какой ptbr наполнялся TLB, и видит подмену на первом же обращении. Это поведение x86 без PCID: запись в CR3 сбрасывает TLB. Вытеснение внутри набора по кругу.

mmu.zig: чистая трансляция

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

//! MMU: трансляция виртуального адреса в физический.
//!
//! Виртуальный адрес делится на номер страницы VPN и смещение VPO. По VPN
//! блок ищет строку таблицы страниц (PTE): сперва в TLB, при промахе в самой
//! таблице, которая лежит в физической памяти по адресу из регистра базы.
//!
//! PTE это одно слово: физический адрес начала страницы плюс биты в младших
//! разрядах, которые у выровненного адреса всё равно нулевые. Так же устроена
//! строка таблицы у x86-64.
//!
//!   бит 0   valid   страница в памяти
//!   бит 1   write   писать можно
//!   бит 2   user    доступна пользовательскому режиму
//!
//! Модуль чистый: на входе память и адрес, на выходе физический адрес или
//! сбой. Про процессор, режимы и исключения он не знает: транслировать ли
//! вообще, решает `cpu.zig`.

const std = @import("std");

pub const pte_valid: u64 = 1 << 0;
pub const pte_write: u64 = 1 << 1;
pub const pte_user: u64 = 1 << 2;

/// Сколько строк помещается в TLB при любой геометрии.
pub const max_tlb_entries = 16;

pub const Params = struct {
    /// Разрядность виртуального адреса.
    va_bits: u6 = 14,
    /// Разрядность смещения: страница занимает 2^page_bits байт.
    page_bits: u6 = 6,
    tlb_sets: u8 = 4,
    tlb_ways: u8 = 4,

    pub fn pageSize(self: Params) u64 {
        return @as(u64, 1) << self.page_bits;
    }

    pub fn offsetMask(self: Params) u64 {
        return self.pageSize() - 1;
    }

    /// Сколько страниц в виртуальном адресном пространстве, оно же число
    /// строк в таблице страниц.
    pub fn pageCount(self: Params) u64 {
        return @as(u64, 1) << (self.va_bits - self.page_bits);
    }
};

/// Система из сквозного примера книги: VA 14 бит, страница 64 байта,
/// TLB на четыре набора по четыре строки.
pub const book: Params = .{};

/// Машина с ядром: VA 12 бит, страница 256 байт, TLB два набора по две строки.
/// Таблица на 16 строк занимает 128 байт, и две таких помещаются в память
/// ядра. С параметрами книги таблица заняла бы 2048 байт из 4096.
pub const machine: Params = .{ .va_bits = 12, .page_bits = 8, .tlb_sets = 2, .tlb_ways = 2 };

pub const Access = enum { fetch, read, write };

pub const FaultKind = enum {
    /// Страницы нет в памяти: бит valid сброшен.
    page,
    /// Страница есть, но прав не хватает, либо адрес вне адресного пространства.
    prot,
};

pub const Fault = struct {
    kind: FaultKind,
    va: u64,
    /// Физический адрес PTE сбойной страницы. Ноль, если адрес лежит вне
    /// адресного пространства и строки для него нет.
    pte_addr: u64,
};

pub const Hit = struct {
    pa: u64,
    tlb_hit: bool,
};

pub const Result = union(enum) {
    ok: Hit,
    fault: Fault,
};

/// Что MMU сделал за такт. Нужно трассе.
pub const Event = union(enum) {
    /// База таблицы сменилась, TLB сброшен.
    flush,
    /// Промах TLB: строка взята из таблицы.
    miss: struct { va: u64, pa: u64 },
    fault: Fault,
};

pub const max_events = 8;

const Entry = struct {
    valid: bool = false,
    tag: u64 = 0,
    pte: u64 = 0,
};

pub const Mmu = struct {
    params: Params = machine,
    entries: [max_tlb_entries]Entry = @splat(.{}),
    /// Какую строку набора вытеснять следующей: очередь по кругу.
    next: [max_tlb_entries]u8 = @splat(0),
    /// База таблицы, при которой наполнялся TLB.
    base: u64 = 0,
    hits: usize = 0,
    misses: usize = 0,
    events: [max_events]Event = undefined,
    event_count: usize = 0,

    pub fn flush(self: *Mmu) void {
        self.entries = @splat(.{});
        self.next = @splat(0);
    }

    fn note(self: *Mmu, event: Event) void {
        if (self.event_count == max_events) return;
        self.events[self.event_count] = event;
        self.event_count += 1;
    }

    fn set(self: *Mmu, vpn: u64) []Entry {
        const index: usize = @intCast(vpn % self.params.tlb_sets);
        const ways: usize = self.params.tlb_ways;
        return self.entries[index * ways ..][0..ways];
    }

    fn lookup(self: *Mmu, vpn: u64) ?u64 {
        const tag = vpn / self.params.tlb_sets;
        for (self.set(vpn)) |entry| {
            if (entry.valid and entry.tag == tag) return entry.pte;
        }
        return null;
    }

    /// Кладёт строку в TLB: в свободное место, а если его нет, на место
    /// самой старой строки набора.
    pub fn insert(self: *Mmu, vpn: u64, pte: u64) void {
        const tag = vpn / self.params.tlb_sets;
        const index: usize = @intCast(vpn % self.params.tlb_sets);
        const entries = self.set(vpn);
        const slot = for (entries, 0..) |entry, i| {
            if (!entry.valid) break i;
        } else blk: {
            const victim = self.next[index];
            self.next[index] = (victim + 1) % self.params.tlb_ways;
            break :blk victim;
        };
        entries[slot] = .{ .valid = true, .tag = tag, .pte = pte };
    }

    /// Транслирует один адрес для пользовательского режима.
    /// `ptbr` это регистр базы: физический адрес таблицы страниц.
    pub fn translate(self: *Mmu, mem: []const u8, ptbr: u64, va: u64, access: Access) Result {
        const p = self.params;
        if (ptbr != self.base) {
            self.flush();
            self.base = ptbr;
            self.note(.flush);
        }

        if (va >> p.va_bits != 0) return self.fail(.prot, va, 0);
        const vpn = va >> p.page_bits;
        const pte_addr = ptbr + 8 * vpn;

        var tlb_hit = true;
        const pte = self.lookup(vpn) orelse blk: {
            tlb_hit = false;
            if (pte_addr + 8 > mem.len) return self.fail(.prot, va, 0);
            const at: usize = @intCast(pte_addr);
            break :blk std.mem.readInt(u64, mem[at..][0..8], .little);
        };

        if (pte & pte_valid == 0) return self.fail(.page, va, pte_addr);
        if (pte & pte_user == 0) return self.fail(.prot, va, pte_addr);
        if (access == .write and pte & pte_write == 0) return self.fail(.prot, va, pte_addr);

        const pa = (pte & ~p.offsetMask()) | (va & p.offsetMask());
        // Строка показывает мимо физической памяти: таблицу испортило ядро.
        if (pa >= mem.len) return self.fail(.prot, va, pte_addr);
        if (tlb_hit) {
            self.hits += 1;
        } else {
            // В TLB попадают только строки с valid: устаревать там нечему,
            // когда ядро подкачает страницу и перепишет PTE.
            self.misses += 1;
            self.insert(vpn, pte);
            self.note(.{ .miss = .{ .va = va, .pa = pa } });
        }
        return .{ .ok = .{ .pa = pa, .tlb_hit = tlb_hit } };
    }

    fn fail(self: *Mmu, kind: FaultKind, va: u64, pte_addr: u64) Result {
        const fault: Fault = .{ .kind = kind, .va = va, .pte_addr = pte_addr };
        self.note(.{ .fault = fault });
        return .{ .fault = fault };
    }
};

const testing = std.testing;

test "адрес делится на VPN и VPO, PTE хранит адрес страницы и биты" {
    try testing.expectEqual(@as(u64, 64), book.pageSize());
    try testing.expectEqual(@as(u64, 256), book.pageCount());
    try testing.expectEqual(@as(u64, 16), machine.pageCount());

    var mem: [4096]u8 = @splat(0);
    // Таблица с адреса 0x400, страница 7 лежит в кадре 0xb00.
    std.mem.writeInt(u64, mem[0x400 + 8 * 7 ..][0..8], 0xb00 | pte_valid | pte_write | pte_user, .little);

    var mmu: Mmu = .{};
    const first = mmu.translate(&mem, 0x400, 0x7f8, .write);
    try testing.expectEqual(@as(u64, 0xbf8), first.ok.pa);
    try testing.expect(!first.ok.tlb_hit);
    try testing.expect(mmu.translate(&mem, 0x400, 0x710, .read).ok.tlb_hit);
}

test "сбои: нет страницы, нет прав, адрес вне пространства" {
    var mem: [4096]u8 = @splat(0);
    std.mem.writeInt(u64, mem[0x400 + 8 * 4 ..][0..8], 0x700 | pte_valid | pte_user, .little);
    std.mem.writeInt(u64, mem[0x400 + 8 * 5 ..][0..8], 0x000 | pte_valid | pte_write, .little);

    var mmu: Mmu = .{};
    const absent = mmu.translate(&mem, 0x400, 0x7f8, .read).fault;
    try testing.expectEqual(FaultKind.page, absent.kind);
    try testing.expectEqual(@as(u64, 0x438), absent.pte_addr);

    try testing.expectEqual(FaultKind.prot, mmu.translate(&mem, 0x400, 0x410, .write).fault.kind);
    try testing.expectEqual(FaultKind.prot, mmu.translate(&mem, 0x400, 0x500, .read).fault.kind);
    const outside = mmu.translate(&mem, 0x400, 0x1000, .read).fault;
    try testing.expectEqual(FaultKind.prot, outside.kind);
    try testing.expectEqual(@as(u64, 0), outside.pte_addr);
}

Порядок проверок в translate такой же, как в кремнии: сначала есть ли страница (valid), потом права. В TLB попадают только строки с valid. Это избавляет от классической ошибки: если бы недействительная строка осела в TLB, то после того как ядро подкачало страницу и переписало PTE, TLB продолжал бы отвечать сбоем, и ядру понадобилась бы команда сброса одной строки (у x86 это invlpg). Событийный буфер events нужен только трассе.

Три слова для разговора с железом

В src/isa.zig рядом с таблицей исключений и ksp появляются три адреса. Регистров управления у Y86 нет, поэтому слова, которые читает и пишет железо, лежат в памяти ядра за таблицей исключений.

/// Регистр базы таблицы страниц: физический адрес таблицы текущего процесса.
/// Ноль выключает трансляцию, поэтому ядра без страничной памяти работают
/// как раньше. Это слово, как и `ksp`, железо читает из памяти.
pub const ptbr_addr = 0xfe8;

/// Сюда железо кладёт виртуальный адрес, на котором трансляция дала сбой.
pub const fault_va_addr = 0xff0;

/// А сюда физический адрес PTE сбойной страницы, чтобы обработчику не пришлось
/// делить адрес на размер страницы: сдвигов в Y86-64 нет. Ноль, если адрес
/// лежит вне адресного пространства.
pub const fault_pte_addr = 0xff8;

В src/sim/state.zig у состояния машины новое поле mmu: Mmu = .{} (с импортом const Mmu = @import("mmu.zig").Mmu;) и один метод:

    /// Включена ли трансляция для следующего обращения. В режиме ядра её нет:
    /// ядро лежит по физическим адресам и по ним же правит таблицы страниц.
    /// Нулевая база выключает её и для процессов.
    pub fn paging(self: *const State) bool {
        return self.user and self.readWord(isa.ptbr_addr).? != 0;
    }

cpu.zig: каждый байт через resolve

В Signals добавляется провод fault: ?mmu.Fault = null, в Retired копия событий MMU за такт (mmu_events, mmu_event_count и метод mmuEvents). Главная новая функция превращает виртуальный адрес обращения в список физических адресов его байтов:

/// Физические адреса байтов, идущих подряд с виртуального адреса `va`.
/// Байты до `from` уже разобраны. Без трансляции физический адрес равен
/// виртуальному. С ней MMU спрашивают на первом байте и на каждой границе
/// страницы: слово и инструкция вправе лежать на двух страницах сразу.
///
/// Сбой даёт ADR, а память к этому моменту ещё не тронута, поэтому
/// инструкцию можно исполнить заново.
fn resolve(st: *State, s: *Signals, va: u64, pas: []u64, from: usize, access: mmu.Access) bool {
    const paging = st.paging();
    const ptbr = st.readWord(isa.ptbr_addr).?;
    const mask = st.mmu.params.offsetMask();
    for (from..pas.len) |i| {
        const v = va +% i;
        const pa = if (!paging)
            v
        else if (i != 0 and v & mask != 0)
            pas[i - 1] + 1
        else switch (st.mmu.translate(&st.mem, ptbr, v, access)) {
            .ok => |hit| hit.pa,
            .fault => |fault| {
                s.fault = fault;
                s.stat = .adr;
                return false;
            },
        };
        if (pa >= isa.mem_size) {
            s.stat = .adr;
            return false;
        }
        pas[i] = pa;
    }
    return true;
}

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

pub fn fetch(st: *State, s: *Signals) void {
    // Длину инструкции знает её первый байт. Пока он не прочитан, трогать
    // следующие нельзя: они могут лежать на странице, которой нет.
    var pas: [10]u64 = undefined;
    var bytes: [10]u8 = undefined;
    if (!resolve(st, s, st.pc, pas[0..1], 0, .fetch)) return;
    bytes[0] = st.mem[@intCast(pas[0])];
    const len = isa.length(@enumFromInt(@as(u4, @truncate(bytes[0] >> 4))));
    if (!resolve(st, s, st.pc, pas[0..len], 1, .fetch)) return;
    for (pas[1..len], bytes[1..len]) |pa, *byte| byte.* = st.mem[@intCast(pa)];
    const decoded = encoder.decode(bytes[0..len]) catch unreachable;

Дальше fetch не изменился. Конец этапа memory вместо readWord и writeWord по одному адресу работает с восемью физическими адресами:

    var pas: [8]u64 = undefined;
    if (!resolve(st, s, s.mem_addr, &pas, 0, if (s.mem_write) .write else .read)) return;
    var word: [8]u8 = undefined;
    if (s.mem_write) {
        std.mem.writeInt(u64, &word, s.mem_data, .little);
        for (pas, word) |pa, byte| st.mem[@intCast(pa)] = byte;
    } else {
        for (pas, &word) |pa, *byte| byte.* = st.mem[@intCast(pa)];
        s.val_m = std.mem.readInt(u64, &word, .little);
    }

Здесь спрятано главное свойство, ради которого сбой страницы вообще возможен: перезапускаемость инструкции. resolve сначала переводит все восемь адресов и только потом трогает память. Если слово лежит на двух страницах и второй нет, первая половина не будет записана. Регистры к этому моменту тоже не тронуты: в SEQ запись в регистры идёт после этапа памяти, а при stat != aok не идёт вовсе. Поэтому после сбоя состояние машины такое, будто инструкция не начиналась, и iret может вернуть управление прямо на неё. У настоящих процессоров то же обещание называется точными исключениями, и стоит оно им заметно дороже, чем нам.

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

    // Сбой трансляции: обработчику нужны сбойный адрес и адрес его PTE.
    // Режим пока пользовательский, но пишет железо, и пишет по физическим адресам.
    if (s.fault) |fault| {
        _ = st.writeWord(isa.fault_va_addr, fault.va);
        _ = st.writeWord(isa.fault_pte_addr, fault.pte_addr);
    }

computer.zig и main.zig: загрузчик и трасса

Программы теперь собраны с одного виртуального адреса, а лежать должны в разных кадрах, поэтому загрузчик научился класть образ по физическому адресу. В Config появилось поле mmu: mmu.Params = mmu.machine (оно уходит в self.st.mmu.params в init), в Summary счётчики tlb_hits и tlb_misses, а рядом с load новые объявления:

/// Виртуальный адрес, с которого собраны пользовательские программы. Под
/// страничной памятью оба процесса собраны с него, а в физической памяти
/// лежат в разных местах.
pub const user_base = 0x400;

/// Образ и место для него. Без `at` образ накладывается как есть. С `at`
/// берётся его часть начиная с `user_base` и кладётся по этому физическому
/// адресу: так работает загрузчик, когда программа собрана под виртуальные адреса.
pub const Placed = struct {
    image: []const u8,
    at: ?usize = null,
};
    /// Кладёт программу, собранную с `user_base`, по физическому адресу `at`.
    /// Хвост из нулей не копируется, чтобы не затирать соседей.
    pub fn loadAt(self: *Computer, image: []const u8, at: usize) void {
        var end = @min(image.len, isa.mem_size);
        while (end > user_base and image[end - 1] == 0) end -= 1;
        if (end <= user_base) return;
        const body = image[user_base..end];
        const n = @min(body.len, isa.mem_size -| at);
        @memcpy(self.st.mem[at..][0..n], body[0..n]);
    }

    pub fn place(self: *Computer, placed: Placed) void {
        if (placed.at) |at| self.loadAt(placed.image, at) else self.load(placed.image);
    }

Функция runImages стала обёрткой над новой runPlaced, которая принимает срез Placed. В трассе между строкой инструкции и её exc встают события MMU, а в самом конце итог по TLB:

        for (r.mmuEvents()) |event| switch (event) {
            .flush => try w.writeAll("tlb flush\n"),
            .miss => |m| try w.print("tlb miss 0x{x:0>3} -> 0x{x:0>3}\n", .{ m.va, m.pa }),
            .fault => |f| try w.print("fault {s} 0x{x:0>3} pte 0x{x:0>3}\n", .{ @tagName(f.kind), f.va, f.pte_addr }),
        };

В src/main.zig команда kernel понимает запись prog.ys@0x900:

            // `prog.ys@0x900`: программа собрана с виртуального адреса 0x400,
            // а в физическую память ложится по адресу после собаки.
            const at = std.mem.indexOfScalar(u8, arg, '@');
            paths[count] = arg[0 .. at orelse arg.len];
            places[count] = if (at) |pos| std.fmt.parseInt(usize, arg[pos + 1 ..], 0) catch {
                try cli.err.print("после @ нужен физический адрес, а не {s}\n", .{arg[pos + 1 ..]});
                return error.BadUsage;
            } else null;
            count += 1;

Регистрация: в src/root.zig строка pub const mmu = @import("sim/mmu.zig");, в src/programs.zig шесть строк @embedFile для новых файлов в структуре kernel (vm, alpha, beta, peek, scribble, badbuf), в build.zig номер "56" в конце списка шагов.

vm.ys: ядро со страничной памятью

Ядро выросло из kernel.ys урока 50. Вход в исключение, сохранение контекста в PCB, планировщик и iret те же. Новое: обработчик pagefault, выдача кадров alloc, программная трансляция адреса буфера uaddr, запись ptbr при возврате в процесс и сами таблицы страниц в данных.

# Ядро со страничной памятью: то же, что kernel.ys, плюс своя таблица
# страниц у каждого процесса и подкачка страниц по требованию.
#
# Оба процесса собраны с одного виртуального адреса 0x400, а в физической
# памяти лежат в разных кадрах. Страница 256 байт, адрес 12 бит, в таблице
# 16 строк по слову. Строка таблицы (PTE):
#
#   адрес кадра | 1 valid | 2 write | 4 user | 8 demand
#
# Бит demand железо не читает, он для ядра: страницы пока нет, но процессу
# она положена, и при первом обращении ядро её выдаст. Нулевая строка значит,
# что по этому адресу у процесса ничего нет.
#
# Ядро работает без трансляции, по физическим адресам. Регистр базы ptbr
# железо читает только в пользовательском режиме, поэтому менять его ядро
# может в любой момент. Адрес таблицы лежит в PCB по смещению 0x98.
#
# Физическая память:
#   0x000 код ядра        0x600 данные и PCB     0x780 и 0x800 таблицы страниц
#   0x900 образ A         0xa00 образ B          0xb00 до 0xeff свободные кадры
#   0xf80 вершина стека ядра                     0xfc0 таблица исключений

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

# Входы из таблицы. Причину кладём в %rax, прежний %rax уже в PCB.
on_trap:
        pushq %rax
        irmovq $0, %rax
        jmp save
on_adr: pushq %rax
        irmovq $1, %rax
        jmp save
on_ins: pushq %rax
        irmovq $2, %rax
        jmp save
on_timer:
        pushq %rax
        irmovq $3, %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
        je syscall
        iaddq $-1, %rax
        je pagefault
        iaddq $-2, %rax
        je schedule             # таймер: квант кончился, очередь следующего
        jmp kill                # недопустимая инструкция: процесс снимаем

# Сбой трансляции. Железо оставило адрес PTE сбойной страницы в fault_pte.
pagefault:
        irmovq fault_pte, %rcx
        mrmovq (%rcx), %rcx
        andq %rcx, %rcx
        je kill                 # адрес вне адресного пространства
        mrmovq (%rcx), %rax
        call alloc              # страницу по требованию, если она положена
        je kill
        jmp resume              # iret вернётся на сбойную инструкцию

# alloc(%rcx = адрес PTE, %rax = PTE): выдать кадр под страницу demand.
# На выходе %rax новая PTE, а при отказе ноль и ZF = 1. Отказов три: страница
# уже есть (значит, не хватило прав), она процессу не положена, кадры кончились.
# Кадры обратно не возвращаются: снятый процесс уносит свои с собой.
# Портит только %rax, %rdx, %r10 и %r11: аргументы вызова в %rdi и %rsi целы.
alloc:  irmovq $1, %rdx
        andq %rax, %rdx
        jne nope
        irmovq $8, %rdx
        andq %rax, %rdx
        je nope
        irmovq next_frame, %rdx
        mrmovq (%rdx), %r10
        irmovq $0xf00, %r11
        subq %r10, %r11
        je nope
        irmovq $6, %r11
        andq %r11, %rax         # права остаются как были
        addq %r10, %rax
        iaddq $1, %rax          # valid
        rmmovq %rax, (%rcx)
        iaddq $0x100, %r10
        rmmovq %r10, (%rdx)
        andq %rax, %rax         # ZF = 0
        ret
nope:   xorq %rax, %rax
        ret

# uaddr(%rdi = адрес в процессе, %r8 = нужные биты PTE): физический адрес
# в %rax или ноль, если процессу туда нельзя. Сдвигов в Y86-64 нет, поэтому
# номер страницы считаем вычитанием: шаг по адресу 0x100, шаг по таблице 8.
uaddr:  rrmovq %rdi, %rax
        andq %rax, %rax
        jl ubad
        irmovq $0x1000, %rdx
        rrmovq %rax, %rsi
        subq %rdx, %rsi
        jge ubad                # за таблицей чужая память
        mrmovq 0x98(%rbx), %rcx
        irmovq $0x100, %rdx
uwalk:  rrmovq %rax, %rsi
        subq %rdx, %rsi
        jl ufound
        rrmovq %rsi, %rax
        iaddq $8, %rcx
        jmp uwalk
ufound: pushq %rax              # смещение внутри страницы
        mrmovq (%rcx), %rax
        irmovq $1, %rdx
        andq %rax, %rdx
        jne uhave
        call alloc              # буфер, которого процесс ещё не касался
        je upop
uhave:  rrmovq %rax, %rdx
        andq %r8, %rdx
        subq %r8, %rdx
        jne upop
        irmovq $-256, %rdx
        andq %rdx, %rax         # адрес кадра
        popq %rdx
        addq %rdx, %rax
        ret
upop:   popq %rdx
ubad:   xorq %rax, %rax
        ret

# Системный вызов: номер и аргументы читаем из сохранённого контекста.
syscall:
        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
        rrmovq %rsi, %r9
        irmovq $5, %r8          # valid и user: читать процессу можно
wloop:  andq %r9, %r9
        je wdone
        call uaddr              # каждый байт заново: буфер вправе пересечь страницу
        andq %rax, %rax
        je efault
        mrmovq (%rax), %rcx
        out %rcx
        iaddq $1, %rdi
        iaddq $-1, %r9
        jmp wloop
wdone:  mrmovq 0x40(%rbx), %rax
        rmmovq %rax, 0x68(%rbx) # процесс увидит длину в %rax
        jmp resume
efault: irmovq $-1, %rax
        rmmovq %rax, 0x68(%rbx) # плохой адрес буфера
        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
        mrmovq 0x98(%rsp), %rax
        irmovq ptbr, %rcx
        rmmovq %rax, (%rcx)     # таблица этого процесса; TLB железо сбросит само
        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 0x600
current:
        .quad pcb_a
alive:  .quad 2
next_frame:
        .quad 0xb00             # первый свободный кадр, последний 0xe00

# Оба процесса начинают с виртуального адреса 0x400, стек под 0x800.
        .pos 0x620
pcb_a:
        .pos 0x690
        .quad 0x400             # PC
        .quad 0x19              # пользовательский режим, прерывания разрешены, ZF = 1
        .quad 0x800             # %rsp
        .quad 1                 # жив
        .quad pcb_b
        .quad pt_a

        .pos 0x6c0
pcb_b:
        .pos 0x730
        .quad 0x400
        .quad 0x19
        .quad 0x800
        .quad 1
        .quad pcb_a
        .quad pt_b

# Таблицы страниц. Страница 4 это код, только чтение. Страница 5 под данные
# и страница 7 под стек появятся при первом обращении. Остальное пусто:
# ни ядра, ни соседа в адресном пространстве процесса нет.
        .pos 0x780
pt_a:
        .pos 0x7a0
        .quad 0x905             # кадр 0x900, valid и user
        .quad 0x00e             # demand, write, user
        .pos 0x7b8
        .quad 0x00e

        .pos 0x800
pt_b:
        .pos 0x820
        .quad 0xa05
        .quad 0x00e
        .pos 0x838
        .quad 0x00e

# Стек ядра растёт вниз от 0xf80. За таблицей исключений слова, через
# которые ядро разговаривает с MMU.
        .pos 0xf80
kstack:
        .pos 0xfc0
        .quad on_trap
        .quad on_adr
        .quad on_ins
        .quad on_timer
ksp:    .quad 0
ptbr:   .quad 0                 # база таблицы страниц, ноль выключает трансляцию
fault_va:
        .quad 0                 # сбойный адрес, его пишет железо
fault_pte:
        .quad 0                 # адрес PTE сбойной страницы, тоже от железа

Пройдём по новому.

pagefault занимает восемь инструкций. Адрес PTE уже посчитало железо. Ноль значит адрес вне пространства: процесс снимаем. Иначе читаем PTE и зовём alloc. Если alloc отказал, снимаем; если выдал кадр, уходим в resume, и iret вернёт процесс на ту самую инструкцию, которая дала сбой. Сравни с тем, что ты читал про Linux: найти область, проверить права, выдать страницу, перезапустить. Роль записи об области у нас играет бит demand прямо в PTE.

alloc различает три отказа по битам одной строки. Страница уже valid, а сбой всё равно пришёл: значит, не хватило прав, это нарушение защиты. Нет бита demand: страница процессу не положена. Счётчик next_frame дошёл до 0xf00: кадры кончились. При успехе от старой PTE остаются права (andq с шестёркой это биты write и user), добавляются адрес кадра и valid. Кадры обратно не возвращаются: свободных четыре, двум процессам ровно хватает на данные и стек. Честный аллокатор кадров со списком свободных это хорошее упражнение.

uaddr это ответ на вопрос, который в уроке 50 мы могли не задавать: что, если процесс передаст в write адрес памяти ядра? Ядро работает без трансляции, и адрес буфера из %rdi для него это просто число. Прочитай оно по нему как по физическому адресу, любой процесс печатал бы чужую память чужими руками. Поэтому ядро само проходит по таблице процесса: находит PTE (вычитанием, сдвигов нет), требует биты valid и user, берёт кадр и добавляет смещение. Делается это для каждого байта, потому что буфер вправе пересечь границу страницы. Если прав нет, системный вызов возвращает -1: это и есть EFAULT из настоящего Unix. Тонкость: буфер может лежать на странице по требованию, которой процесс ещё не касался, и тогда uaddr сам зовёт alloc. Отсюда требование к alloc не портить %rdi и %rsi: при работе над эталоном на этом была поймана настоящая ошибка, alloc затирал %rdi, и write печатал один байт вместо шести.

resume перед восстановлением регистров кладёт адрес таблицы процесса из PCB (смещение 0x98) в ptbr. Ядру это безразлично, оно в режиме ядра. А после iret железо увидит новую базу и сбросит TLB.

Таблицы. У каждого процесса заполнены три строки из шестнадцати. Страница 4 это код: кадр 0x900 у первого и 0xa00 у второго, биты valid и user, без write. Страницы 5 и 7 это 0x00e: demand, write, user, кадра нет. Всё остальное нули: ни ядра, ни соседа в адресном пространстве процесса просто нет.

Программы

Два одинаковых процесса, которые пишут по одному и тому же адресу 0x500:

# Процесс alpha: кладёт своё слово в страницу данных по адресу 0x500 и три
# раза печатает его оттуда, уступая процессор между строками. Сосед делает
# то же самое по тому же адресу, и слова не перемешиваются: страницы разные.

        .pos 0x400
        irmovq word, %rcx
        mrmovq (%rcx), %rcx
        irmovq $0x500, %rdx
        rmmovq %rcx, (%rdx)     # страницы данных ещё нет: сбой, ядро её выдаст
        irmovq $3, %rbx
loop:   call show               # первый call упрётся в стек, которого тоже нет
        irmovq $24, %rax        # yield
        trap
        iaddq $-1, %rbx
        jne loop
        irmovq $60, %rax        # exit
        trap

show:   irmovq $1, %rax         # write(0x500, 6)
        irmovq $0x500, %rdi
        irmovq $6, %rsi
        trap
        ret

        .align 8
word:   .quad 0x0a6168706c61        # "alpha\n", младший байт первым
# Процесс beta: кладёт своё слово в страницу данных по адресу 0x500 и три
# раза печатает его оттуда, уступая процессор между строками. Сосед делает
# то же самое по тому же адресу, и слова не перемешиваются: страницы разные.

        .pos 0x400
        irmovq word, %rcx
        mrmovq (%rcx), %rcx
        irmovq $0x500, %rdx
        rmmovq %rcx, (%rdx)     # страницы данных ещё нет: сбой, ядро её выдаст
        irmovq $3, %rbx
loop:   call show               # первый call упрётся в стек, которого тоже нет
        irmovq $24, %rax        # yield
        trap
        iaddq $-1, %rbx
        jne loop
        irmovq $60, %rax        # exit
        trap

show:   irmovq $1, %rax         # write(0x500, 5)
        irmovq $0x500, %rdi
        irmovq $5, %rsi
        trap
        ret

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

И три нарушителя. Первый читает адрес, по которому физически лежит PCB соседа. Второй пишет в собственный код. Третий просит ядро напечатать память ядра.

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

        .pos 0x400
        irmovq $0x6c0, %rax     # по этому физическому адресу лежит PCB соседа
        mrmovq (%rax), %rbx     # а виртуального адреса 0x6c0 у процесса нет
        jmp 0x400               # сюда управление не вернётся
# Процесс, который пишет в собственный код. Страница есть, но бита write
# у неё нет: это нарушение защиты, ядро снимает процесс.

        .pos 0x400
        irmovq $0x400, %rax
        rmmovq %rax, (%rax)     # запись в страницу только для чтения
        jmp 0x400               # сюда управление не вернётся
# Процесс, который просит ядро напечатать память ядра: write с адресом 0x6c0,
# где физически лежит PCB соседа. Ядро проверяет адрес по таблице страниц
# процесса и возвращает -1. Процесс печатает, что увидел.

        .pos 0x400
        irmovq $1, %rax         # write(0x6c0, 8)
        irmovq $0x6c0, %rdi
        irmovq $8, %rsi
        trap
        irmovq denied, %rdi
        iaddq $1, %rax          # -1 + 1 = 0: ядро отказало
        je say
        irmovq leaked, %rdi
say:    irmovq $1, %rax
        irmovq $7, %rsi
        trap
        irmovq $60, %rax        # exit
        trap

        .align 8
denied: .quad 0x0a6465696e6564    # "denied\n"
leaked: .quad 0x0a64656b61656c    # "leaked\n"

Прогоны

$ V="programs/kernel/vm.ys programs/kernel/alpha.ys@0x900 programs/kernel/beta.ys@0xa00"
$ zig build run -- kernel $V --console
alpha
beta
alpha
beta
alpha
beta

Оба процесса положили своё слово по адресу 0x500 и трижды напечатали его оттуда. Слова не перемешались. Посмотрим, как это выглядит в событиях:

$ zig build run -- kernel $V --events
tlb flush
tlb miss 0x400 -> 0x900
fault page 0x500 pte 0x7a8
exc adr 0x41e -> 0x01e
tlb miss 0x500 -> 0xb00
fault page 0x7f8 pte 0x7b8
exc adr 0x432 -> 0x01e
tlb miss 0x7f8 -> 0xcf8
exc trap 0x483 -> 0x009
out 61
out 6c
out 70
out 68
out 61
out 0a
exc trap 0x446 -> 0x009
tlb flush
tlb miss 0x400 -> 0xa00
fault page 0x500 pte 0x828
exc adr 0x41e -> 0x01e
tlb miss 0x500 -> 0xd00
fault page 0x7f8 pte 0x838
exc adr 0x432 -> 0x01e
tlb miss 0x7f8 -> 0xef8
...
halt cycles=3360
...
tlb hits=78 misses=16

Читаем сверху. Ядро записало ptbr, железо сбросило TLB. Первая выборка alpha: промах TLB, таблица дала 0x400 -> 0x900. Запись по 0x500: страницы нет, PTE по физическому адресу 0x7a8 (это pt_a плюс 5 строк по 8 байт), исключение adr с адресом возврата 0x41e, то есть на саму сбойную инструкцию. Ядро выдало кадр 0xb00, и повтор той же инструкции прошёл: tlb miss 0x500 -> 0xb00. Первый call так же получил страницу стека, кадр 0xc00. Потом write и yield, смена базы, снова tlb flush, и beta проходит тот же путь, только её 0x400 это 0xa00, а её 0x500 это 0xd00. Один виртуальный адрес, два разных кадра: это и есть отдельное адресное пространство у каждого процесса.

Тот же сбой в полной трассе, с инструкциями (U и K это режим):

U 0x41e 40 ADR
fault page 0x500 pte 0x7a8
exc adr 0x41e -> 0x01e
K 0x01e a0 AOK
...
U 0x41e 40 AOK
tlb miss 0x500 -> 0xb00

Инструкция rmmovq по адресу 0x41e исполнилась дважды: первый раз с кодом ADR, второй, после обработчика, с AOK. После прогона PTE по 0x7a8 равна 0xb07 (кадр 0xb00, биты valid, write, user), в физической памяти с 0xb00 лежит alpha\n, с 0xd00 лежит beta\n.

Счётчики TLB: 78 попаданий на 16 промахов, доля попаданий 83 процента. Для TLB это очень плохо (настоящие держат 99 и выше), и причина на виду: каждый yield сбрасывает TLB целиком, а процессы у нас уступают процессор после каждой строки. С таймером на 400 тактов переключений больше, и картина хуже: tlb hits=70 misses=24, 3858 тактов против 3360. Вот зачем x86-64 завёл PCID, а ARM завёл ASID: метку процесса в строке TLB, чтобы не сбрасывать его при каждом переключении.

Теперь нарушители. Каждый запущен вторым процессом рядом с alpha:

$ zig build run -- kernel programs/kernel/vm.ys programs/kernel/alpha.ys@0x900 programs/kernel/peek.ys@0xa00 --events
...
fault page 0x6c0 pte 0x830
exc adr 0x40a -> 0x01e
...
$ zig build run -- kernel programs/kernel/vm.ys programs/kernel/alpha.ys@0x900 programs/kernel/scribble.ys@0xa00 --events
...
fault prot 0x400 pte 0x820
exc adr 0x40a -> 0x01e
...
$ zig build run -- kernel programs/kernel/vm.ys programs/kernel/badbuf.ys@0x900 programs/kernel/beta.ys@0xa00 --console
denied
beta
beta
beta

peek читает 0x6c0. В физической памяти там PCB соседа, но у процесса это виртуальный адрес страницы 6, а её PTE (по 0x830) нулевая: сбой страницы, бита demand нет, ядро снимает процесс. На консоли в этом прогоне три строки alpha: сосед доработал. scribble пишет в свой код: страница есть, бита write нет, событие уже другое, fault prot, и alloc отказывает по первой ветке. Код соседа цел. badbuf получает от write минус единицу, печатает denied и мирно выходит: это не сбой, а возвращённая ошибка, потому что адрес проверяло ядро программно, а не железо.

Тесты шага

//! Шаг 56: MMU. Трансляция адреса, TLB, сбой страницы как исключение,
//! страница по требованию, своя таблица у каждого процесса, защита.
//!
//! Первая половина проверяет сам блок трансляции на данных сквозного примера
//! книги (раздел 9.6.4): те же 14 бит адреса, страницы по 64 байта и TLB
//! на четыре набора. Вторая половина гоняет машину с ядром `vm.ys`, у которой
//! страницы по 256 байт: таблица с параметрами книги в её память не влезла бы.

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

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

const all_rights = mmu.pte_valid | mmu.pte_write | mmu.pte_user;

/// Память и MMU из книги: таблица страниц (первые 16 строк) лежит с 0x800,
/// TLB заполнен так же, как на рисунке.
const Book = struct {
    mem: [4096]u8 = @splat(0),
    unit: mmu.Mmu = .{ .params = mmu.book },

    const ptbr = 0x800;

    fn init() Book {
        var self: Book = .{};
        // VPN и PPN действительных строк таблицы. Остальные строки нулевые.
        const table = [_][2]u64{
            .{ 0x00, 0x28 }, .{ 0x02, 0x33 }, .{ 0x03, 0x02 }, .{ 0x05, 0x16 }, .{ 0x08, 0x13 },
            .{ 0x09, 0x17 }, .{ 0x0a, 0x09 }, .{ 0x0d, 0x2d }, .{ 0x0e, 0x11 }, .{ 0x0f, 0x0d },
        };
        for (table) |row| {
            const pte = (row[1] << mmu.book.page_bits) | all_rights;
            std.mem.writeInt(u64, self.mem[ptbr + 8 * @as(usize, @intCast(row[0])) ..][0..8], pte, .little);
        }
        // Действительные строки TLB: набор, тег, PPN. VPN это тег и номер набора.
        const tlb = [_][3]u64{
            .{ 0, 0x09, 0x0d }, .{ 0, 0x07, 0x02 }, .{ 1, 0x03, 0x2d },
            .{ 3, 0x03, 0x0d }, .{ 3, 0x0a, 0x34 },
        };
        self.unit.base = ptbr;
        for (tlb) |row| {
            self.unit.insert(row[1] * 4 + row[0], (row[2] << mmu.book.page_bits) | all_rights);
        }
        // Блок кэша из примера: по физическому адресу 0x354 лежат 36 72 f0 1d.
        @memcpy(self.mem[0x354..0x358], &[_]u8{ 0x36, 0x72, 0xf0, 0x1d });
        return self;
    }

    fn translate(self: *Book, va: u64) mmu.Result {
        return self.unit.translate(&self.mem, ptbr, va, .read);
    }
};

test "сквозной пример книги: 0x03d4 и 0x03d7 попадают в TLB" {
    var book: Book = .init();

    // VPN 0x0f, набор 3, тег 0x03: строка в TLB есть, PPN 0x0d.
    const first = book.translate(0x03d4).ok;
    try testing.expect(first.tlb_hit);
    try testing.expectEqual(@as(u64, 0x354), first.pa);
    try testing.expectEqual(@as(u8, 0x36), book.mem[@intCast(first.pa)]);

    // Упражнение 9.4: та же страница, смещение на три байта дальше.
    const second = book.translate(0x03d7).ok;
    try testing.expect(second.tlb_hit);
    try testing.expectEqual(@as(u64, 0x357), second.pa);
    try testing.expectEqual(@as(u8, 0x1d), book.mem[@intCast(second.pa)]);
}

test "адреса из домашних задач: промах TLB, таблица, сбой страницы" {
    var book: Book = .init();

    // 0x027c: VPN 0x09, набор 1, тег 0x02. В TLB такой строки нет, в таблице PPN 0x17.
    const miss = book.translate(0x027c).ok;
    try testing.expect(!miss.tlb_hit);
    try testing.expectEqual(@as(u64, 0x5fc), miss.pa);
    // Строка осела в TLB: второе обращение к той же странице уже попадание.
    try testing.expect(book.translate(0x0240).ok.tlb_hit);

    // 0x03a9: VPN 0x0e, PPN 0x11.
    try testing.expectEqual(@as(u64, 0x469), book.translate(0x03a9).ok.pa);

    // 0x0040: VPN 0x01, бит valid сброшен.
    const fault = book.translate(0x0040).fault;
    try testing.expectEqual(mmu.FaultKind.page, fault.kind);
    try testing.expectEqual(@as(u64, Book.ptbr + 8), fault.pte_addr);

    try testing.expectEqual(@as(usize, 1), book.unit.hits);
    try testing.expectEqual(@as(usize, 2), book.unit.misses);
}

test "TLB: вытеснение по кругу и сброс при смене базы" {
    var mem: [4096]u8 = @splat(0);
    // Две одинаковые таблицы на 16 строк: страница n лежит в кадре n.
    for ([_]usize{ 0x100, 0x200 }) |base| {
        for (0..16) |vpn| {
            std.mem.writeInt(u64, mem[base + 8 * vpn ..][0..8], (vpn << 8) | all_rights, .little);
        }
    }

    // Два набора по две строки. Чётные страницы спорят за набор 0.
    var unit: mmu.Mmu = .{ .params = mmu.machine };
    for ([_]u64{ 0x000, 0x200, 0x400 }) |va| {
        try testing.expect(!unit.translate(&mem, 0x100, va, .read).ok.tlb_hit);
    }
    // Третья страница вытеснила первую, вторая на месте.
    try testing.expect(unit.translate(&mem, 0x100, 0x200, .read).ok.tlb_hit);
    try testing.expect(!unit.translate(&mem, 0x100, 0x000, .read).ok.tlb_hit);

    // Другая база это другой процесс: строки прежнего ему показывать нельзя.
    unit.event_count = 0;
    try testing.expect(!unit.translate(&mem, 0x200, 0x000, .read).ok.tlb_hit);
    try testing.expectEqual(mmu.Event.flush, unit.events[0]);
}

/// Собирает ядро и программы. Ядро накладывается как есть, программы
/// ложатся в физические кадры 0x900 и 0xa00.
fn boot(machine: *computer.Computer, sources: []const []const u8) !void {
    const frames = [_]?usize{ null, 0x900, 0xa00 };
    for (sources, frames[0..sources.len]) |source, at| {
        var result = try y86.assembler.assemble(testing.allocator, source, null);
        defer result.deinit(testing.allocator);
        machine.place(.{ .image = result.image, .at = at });
    }
}

const Run = struct {
    machine: computer.Computer,
    trace: []u8,
    console: []u8,

    fn deinit(self: *Run) void {
        testing.allocator.free(self.trace);
        testing.allocator.free(self.console);
    }
};

fn run(sources: []const []const u8, config: computer.Config) !Run {
    var trace_buf: std.Io.Writer.Allocating = .init(testing.allocator);
    defer trace_buf.deinit();
    var console_buf: std.Io.Writer.Allocating = .init(testing.allocator);
    defer console_buf.deinit();

    var machine: computer.Computer = .init(config);
    machine.trace_out = &trace_buf.writer;
    machine.console = &console_buf.writer;
    try boot(&machine, sources);
    _ = try machine.run();
    machine.trace_out = null;
    machine.console = null;

    const trace_text = try trace_buf.toOwnedSlice();
    errdefer testing.allocator.free(trace_text);
    return .{ .machine = machine, .trace = trace_text, .console = try console_buf.toOwnedSlice() };
}

const two = [_][]const u8{ kernel.vm, kernel.alpha, kernel.beta };

test "страница по требованию: сбой, кадр от ядра, та же инструкция заново" {
    var out = try run(&two, .{});
    defer out.deinit();

    // Запись в страницу данных упёрлась в PTE без valid и ушла в ядро...
    const fault = "U 0x41e 40 ADR\nfault page 0x500 pte 0x7a8\nexc adr 0x41e -> ";
    const at = std.mem.indexOf(u8, out.trace, fault).?;
    // ...а после iret исполнилась с того же адреса, уже через новый кадр.
    const retry = "U 0x41e 40 AOK\ntlb miss 0x500 -> 0xb00\n";
    try testing.expect(std.mem.indexOfPos(u8, out.trace, at, retry) != null);

    // Ядро дописало в таблицу кадр и бит valid, права остались прежними.
    try testing.expectEqual(@as(u64, 0xb07), out.machine.st.readWord(0x7a8).?);
    // Первый call точно так же получил страницу стека.
    try testing.expect(std.mem.indexOf(u8, out.trace, "fault page 0x7f8 pte 0x7b8\n") != null);
    try testing.expectEqual(y86.Stat.hlt, out.machine.st.stat);
}

test "два процесса пишут по одному виртуальному адресу и видят каждый своё" {
    var out = try run(&two, .{});
    defer out.deinit();
    try testing.expectEqualStrings("alpha\nbeta\nalpha\nbeta\nalpha\nbeta\n", out.console);

    // Виртуальный адрес 0x500 у обоих, физические кадры разные.
    const mem = &out.machine.st.mem;
    try testing.expectEqualStrings("alpha\n", mem[0xb00..0xb06]);
    try testing.expectEqualStrings("beta\n", mem[0xd00..0xd05]);
    // Каждое переключение меняет базу, и TLB начинает с нуля.
    try testing.expect(std.mem.count(u8, out.trace, "tlb flush\n") >= 6);
    try testing.expect(out.machine.st.mmu.hits > out.machine.st.mmu.misses);
}

test "чтение памяти ядра и запись в свой код: процесс снят, сосед доработал" {
    var peek = try run(&.{ kernel.vm, kernel.alpha, kernel.peek }, .{});
    defer peek.deinit();
    try testing.expectEqualStrings("alpha\nalpha\nalpha\n", peek.console);
    // Строки для этого адреса в таблице нет: PTE нулевая, подкачивать нечего.
    try testing.expect(std.mem.indexOf(u8, peek.trace, "fault page 0x6c0 pte 0x830\nexc adr 0x40a -> ") != null);
    try testing.expectEqual(@as(u64, 0), peek.machine.st.readWord(0x830).?);
    try testing.expectEqual(y86.Stat.hlt, peek.machine.st.stat);

    var scribble = try run(&.{ kernel.vm, kernel.alpha, kernel.scribble }, .{});
    defer scribble.deinit();
    try testing.expectEqualStrings("alpha\nalpha\nalpha\n", scribble.console);
    try testing.expect(std.mem.indexOf(u8, scribble.trace, "fault prot 0x400 pte 0x820\n") != null);
    // Код соседа цел: первая инструкция на месте.
    try testing.expectEqual(@as(u8, 0x30), scribble.machine.st.mem[0xa00]);
}

test "ядро проверяет адрес буфера по таблице процесса" {
    var out = try run(&.{ kernel.vm, kernel.badbuf, kernel.beta }, .{});
    defer out.deinit();
    try testing.expectEqualStrings("denied\nbeta\nbeta\nbeta\n", out.console);
}

test "трасса с таймером воспроизводится байт в байт" {
    const config: computer.Config = .{ .timer_period = 400, .events_only = true };
    var out = try run(&two, config);
    defer out.deinit();
    try testing.expectEqualStrings(@embedFile("vm_400.events"), out.trace);
    try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "alpha\n"));
    try testing.expectEqual(@as(usize, 3), std.mem.count(u8, out.console, "beta\n"));

    var again = try run(&two, config);
    defer again.deinit();
    try testing.expectEqualStrings(out.trace, again.trace);
}

test "в режиме ядра и без базы трансляции нет" {
    // Старое ядро про ptbr не знает, слово по 0xfe8 нулевое: MMU молчит.
    var old = try run(&.{ kernel.full, kernel.tick }, .{});
    defer old.deinit();
    try testing.expectEqual(@as(usize, 0), old.machine.st.mmu.hits + old.machine.st.mmu.misses);

    // Новое ядро ходит по физическим адресам: все промахи TLB пришли
    // из пользовательского режима, у инструкций ядра событий MMU нет.
    var out = try run(&two, .{});
    defer out.deinit();
    var lines = std.mem.tokenizeScalar(u8, out.trace, '\n');
    var mode: u8 = 'K';
    while (lines.next()) |line| {
        if (line[0] == 'U' or line[0] == 'K') mode = line[0];
        if (std.mem.startsWith(u8, line, "tlb ") and !std.mem.startsWith(u8, line, "tlb hits")) {
            try testing.expectEqual(@as(u8, 'U'), mode);
        }
    }
}

Файл tests/expected/vm_400.events это вывод zig build run -- kernel $V --timer 400 --events, записанный в файл (118 строк): симулятор детерминирован, и трасса воспроизводится байт в байт.

$ zig build test -Dstep=56 --summary all
...
+- run test 9 pass (9 total) 66ms MaxRSS:3M

Первая тройка тестов это мост к уроку 55. Структура Book загружает таблицу и TLB с рисунка книги, и 0x03d4 даёт попадание TLB, физический адрес 0x354 и байт 0x36, как в сквозном примере. Адреса из домашних задач книги ведут себя так же, как ты считал на бумаге: промах TLB с успешной трансляцией, попадание и сбой страницы. Если ты решал их в уроке 55, сверь свои таблицы с проверками в тесте.

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

Практика

В задаче две половины сегодняшнего шага Y86 собраны в две чистые функции на Zig, без TLB и без процессора вокруг. translate это железо: режим ядра и нулевая база отключают трансляцию, дальше проверка valid, потом прав, и вид сбоя с адресом PTE. demand это ядро: получив адрес PTE, решить, положена ли страница, выдать кадр и переписать строку. От задачи прошлого урока она отличается предметом: там были TLB и геометрия адреса из книги, здесь права, режимы и цепочка сбой, кадр, повтор.

Упражнения

Итоги

  • Отображение в память это связь области виртуальных адресов с объектом: отрезком файла или нулями. mmap ничего не читает и не выделяет, страницы приходят по одной в обработчике сбоя.
  • Разделяемое отображение: запись видят все и она доходит до файла. Приватное: запись видит только процесс, до файла она не доходит.
  • Копирование при записи: общие страницы приватной области помечены только для чтения, копия снимается в обработчике сбоя защиты, и только с той страницы, в которую пишут. Если владелец кадра один, сбой есть, а копии нет.
  • fork копирует записи об областях и таблицы страниц, а не страницы. execve выбрасывает старые области и отображает новые: код и данные из файла приватно, .bss, куча и стек анонимно. С диска при этом читаются только заголовки.
  • В Zig 0.16 std.posix.mmap возвращает выровненный срез, права и флаги это упакованные структуры. mprotect, fork и waitpid приходится брать из std.os.linux или std.c.
  • После fork каждая первая запись в страницу стоит сбоя и потомку, и родителю: мы видели это по minflt. Сколько работы ядро делает в самом fork, зависит от ядра.
  • W^X: страница либо записываемая, либо исполняемая. JIT пишет в RW, запечатывает в RX через mprotect, изменяемые данные держит на отдельной странице. Права принадлежат страницам, поэтому mprotect раскалывает область надвое.
  • В Y86 теперь есть MMU: PTE с битами valid, write, user, TLB со сбросом при смене базы, сбой страницы как перезапускаемое исключение, страница по требованию, проверка адресов буфера в системном вызове.

Дальше

Сегодня мы просили у ядра память страницами: mmap выдаёт не меньше 4 КБ, а на Apple Silicon не меньше 16. Программе же нужны 24 байта под узел списка и 300 байт под строку, тысячи раз в секунду. Между этими масштабами стоит аллокатор: библиотека, которая берёт у ядра большие куски (тем самым mmap или brk) и нарезает их на блоки. В следующем уроке разберём, как он устроен: заголовки блоков, выравнивание, поиск свободного блока, фрагментация и цена каждой стратегии.

домашка

Домашка