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

Виртуальная адресация и страницы

senior~110 мин

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

Виртуальная адресация и страницы

Пять уроков подряд, от иерархии памяти до горы памяти, мы говорили про адрес так, будто это номер байта в микросхеме DRAM. Это была удобная неправда. Ни один адрес, который ты печатал в этом разделе, не был номером байта в памяти: &local, указатель из аллокатора, адрес main в дизассемблере. Все они виртуальные. Между программой и памятью стоит переводчик, и у него своя таблица на каждый процесс. Сегодня мы начинаем блок про виртуальную память с самого основания: что такое виртуальный адрес, как он режется на номер страницы и смещение, что лежит в таблице страниц и что происходит, когда нужной страницы в памяти нет. Главная мысль урока уже знакома тебе по кэшам: оперативная память это кэш для диска. Только промах в этом кэше стоит не двести тактов, а сотни тысяч, и поэтому он устроен совсем иначе, чем L1.

Цели урока

  • Отличать физическую адресацию от виртуальной и понимать, где стоит MMU и что она делает с каждым адресом.
  • Считать размеры адресных пространств по числу бит и читать адреса своей программы: где код, где данные, где куча, где стек.
  • Резать виртуальный адрес на номер страницы и смещение руками и кодом для любого размера страницы, узнавать размер страницы своей системы через std.heap.pageSize().
  • Знать три состояния виртуальной страницы (не размещена, размещена на диске, лежит в памяти) и что хранит запись таблицы страниц.
  • Проследить по шагам попадание и сбой страницы и объяснить, почему после сбоя инструкция выполняется заново.
  • Увидеть размещение по требованию своими глазами: mmap на 64 МБ не стоит ни одного сбоя, а каждое первое касание страницы стоит ровно один.
  • Сравнить оперативную память как кэш диска с кэшами процессора: штрафы 2026 года, полная ассоциативность, программное замещение, и понять, откуда берётся пробуксовка.

Зачем вообще виртуальная память

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

Кому какая память. Две программы скомпонованы так, что их код начинается с одного адреса. Кто-то должен уступить, а значит, компоновщик обязан знать, какие ещё программы будут запущены рядом. Это невозможно.

Кто что портит. Ошибка в указателе одной программы молча переписывает данные другой, или таблицу прерываний, или само ядро. В мире без защиты один buf[i] с плохим i роняет всю машину.

Сколько памяти. Программе нужно 8 ГБ, а в машине 4 ГБ. Или нужно 100 МБ, но сорок программ по 100 МБ запущены одновременно. Без посредника каждая программа должна сама решать, что держать в памяти, а что на диске.

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

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

Физическая и виртуальная адресация

Оперативная память компьютера это массив из M байт, у каждого свой физический адрес: 0, 1, 2 и так до M − 1. При физической адресации инструкция movq (%rdi), %rax с числом 4 в %rdi читает восемь байт, начиная с физического байта 4.

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

        ядро процессора                        оперативная память
   ┌────────────────────┐  VA   ┌───────┐  PA   ┌──────────────────┐
   │ movq (%rdi), %rax  │──────▶│  MMU  │──────▶│ 0:               │
   │ %rdi = 4           │   4   │       │ 4100  │ ...              │
   └────────────────────┘       └───┬───┘       │ 4100: 8 байт ────┼──▶ в %rax
                                    │           │ ...              │
                          таблица страниц       │ M − 1:           │
                          процесса, её ведёт ОС └──────────────────┘

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

Одно следствие стоит проговорить сразу. Указатель в Zig, адрес в отладчике, число в %rsp, адрес символа в выводе nm: всё это виртуальные адреса. Физический адрес своей переменной программа пользовательского режима узнать не может (на Linux есть лазейка через /proc/self/pagemap, но с 2015 года она открыта только суперпользователю, после атаки Rowhammer). Весь раздел до сих пор мы прожили в виртуальных адресах и не заметили. Это и есть признак хорошей абстракции.

Адресные пространства

Адресное пространство это просто множество адресов. Процессор с n-битными адресами порождает виртуальное адресное пространство из N = 2^n адресов: от 0 до N − 1. У машины есть и физическое адресное пространство из M байт; M не обязано быть степенью двойки (в машину можно поставить 24 ГБ), но для арифметики мы будем считать M = 2^m.

Главная идея здесь в том, что у одного и того же байта данных теперь может быть несколько имён. Один физический байт может иметь один адрес в физическом пространстве и по адресу в виртуальном пространстве каждого процесса, который его видит. Или ни одного виртуального адреса, если страница сейчас никому не принадлежит. Как только ты отделил имя объекта от самого объекта, с именами можно делать что угодно: давать двум процессам одно имя для разных байтов (у обоих код начинается с 0x1000000), давать двум процессам разные имена для одного байта (общая библиотека), давать имя байту, которого ещё нет в памяти.

Арифметика степеней двойки нужна будет весь блок, поэтому таблица на память:

n, битN = 2^nНаибольший адресГде встречается
1664 К0xFFFF8-битные микроконтроллеры, Y86 в наших тестах
324 Г0xFFFF_FFFFx86 до 2003 года, wasm32
39512 Г0x7F_FFFF_FFFFaarch64 Linux с тремя уровнями таблиц
47128 Т0x7FFF_FFFF_FFFFпользовательская половина на x86-64 и на Apple Silicon
48256 Т0xFFFF_FFFF_FFFFx86-64 с четырьмя уровнями таблиц, обе половины
57128 Пx86-64 с пятью уровнями (Ice Lake и новее, включается по запросу)
6416 Э0xFFFF_FFFF_FFFF_FFFFширина указателя, целиком не реализована нигде

К это 2^10, М это 2^20, Г это 2^30, Т это 2^40, П это 2^50, Э это 2^60. Указатель на 64-битной машине занимает 64 бита, но настоящих бит адреса в нём 48 (или 57): остальные старшие обязаны повторять старший значащий бит, иначе процессор бросит исключение. Такие адреса называются каноническими. Нижняя половина канонических адресов, от нуля до 0x7FFF_FFFF_FFFF, отдана процессу, верхняя, с 0xFFFF_8000_0000_0000, отдана ядру. Именно поэтому тегированные указатели в zl прячут тег в младших битах, а не в старших: старшие заняты контрактом с процессором.

Адреса своей программы

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

const std = @import("std");

const greeting = "привет, страницы";
var counter: u64 = 42;
var scratch: [1 << 20]u8 = undefined;

fn show(out: *std.Io.Writer, name: []const u8, addr: usize) !void {
    try out.print("{s:<16} 0x{x:0>12}\n", .{ name, addr });
}

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

    var local: u64 = 7;
    const boxed = try init.gpa.create(u64);
    defer init.gpa.destroy(boxed);
    const mapped = try std.posix.mmap(
        null,
        1 << 20,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
    defer std.posix.munmap(mapped);

    try show(out, "code   main", @intFromPtr(&main));
    try show(out, "rodata greeting", @intFromPtr(greeting.ptr));
    try show(out, "data   counter", @intFromPtr(&counter));
    try show(out, "bss    scratch", @intFromPtr(&scratch));
    try show(out, "heap   boxed", @intFromPtr(boxed));
    try show(out, "mmap   mapped", @intFromPtr(mapped.ptr));
    try show(out, "stack  local", @intFromPtr(&local));
    local += counter;
    try out.flush();
}

std.posix.mmap с флагами PRIVATE и ANONYMOUS просит у ядра кусок адресного пространства, за которым не стоит никакой файл; к нему мы вернёмся через два раздела. local += counter в конце нужен, чтобы компилятор не выкинул обе переменные.

Вывод на Linux (ядро 7.0, aarch64, виртуальная машина на Apple M4 Max, Zig 0.16.0, статический бинарник без libc), два запуска подряд:

code   main      0x000001150d9c
rodata greeting  0x0000010141ea
data   counter   0x0000011b4868
bss    scratch   0x0000011b7000
heap   boxed     0xffff8cda0010
mmap   mapped    0xffff8cc40000
stack  local     0xffffff027648
code   main      0x000001150d9c
rodata greeting  0x0000010141ea
data   counter   0x0000011b4868
bss    scratch   0x0000011b7000
heap   boxed     0xffffa6920010
mmap   mapped    0xffffa67c0000
stack  local     0xffffc1207408

Прочитаем это как карту.

Низ: образ программы. Константы, код, данные и .bss лежат рядом, около 0x1000000, в том порядке, в котором их разложил компоновщик: сначала то, что только читается, потом код, потом то, что пишется. Эти адреса записаны в заголовках ELF (урок про динамическую компоновку разбирал, как) и между запусками не меняются: бинарник статический и позиционно-зависимый. scratch стоит ровно на границе страницы, 0x11b7000: компоновщик выровнял начало .bss.

Верх: стек. Локальная переменная живёт у самого потолка пользовательской половины. На aarch64 Linux с 48-битными адресами потолок это 0xFFFF_FFFF_FFFF, на x86-64 это 0x7FFF_FFFF_FFFF, и стек там начинается с 0x7FFC... или 0x7FFE....

Под стеком: всё, что выдано через mmap. Наша область в мегабайт и блок из кучи стоят рядом, и это не совпадение. init.gpa в отладочной сборке это DebugAllocator поверх page_allocator, а тот берёт память у ядра тем же mmap. Никакой отдельной “кучи” над .bss, как на рисунках из учебников по C, у этой программы нет: классическая куча растёт вызовом brk, и ею пользуется malloc из libc, а не аллокаторы Zig. Кстати, boxed кончается на 0x010: первые 16 байт страницы занял заголовок аллокатора. В уроке про устройство malloc ты будешь писать такие заголовки сам.

Между запусками. Образ стоит на месте, а стек и области mmap каждый раз в новом месте. Это ASLR, защита из урока про переполнение буфера. Заметь, как она устроена: младшие 12 бит адреса mapped оба раза нулевые (...40000, ...c0000). Ядро рандомизирует номер страницы, но не может сдвинуть область на полстраницы. Почему, станет ясно через раздел.

И главное наблюдение. Между .bss на 0x11b7000 и областью mmap на 0xffff8cc40000 лежит дыра в 256 терабайт без малого. Адресное пространство процесса почти целиком пустое. Настоящая машина с 64 ГБ памяти спокойно даёт каждому из сотен процессов по 256 ТБ адресов, потому что адрес сам по себе ничего не стоит. Стоит только страница, к которой обратились.

Страницы

Переводить адреса по одному невозможно: таблица “виртуальный байт в физический байт” была бы больше самой памяти. Поэтому, как и в кэшах, данные ходят блоками фиксированного размера. Виртуальное адресное пространство режется на виртуальные страницы (VP) размером P = 2^p байт. Физическая память режется на физические страницы (PP) того же размера, их ещё называют кадрами. Любая виртуальная страница может лечь в любой кадр.

В каждый момент виртуальные страницы процесса делятся на три непересекающихся множества:

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

Размещение (allocate) и кэширование это разные события, и весь урок стоит на этой разнице. mmap размещает страницы. В память они попадут позже, по одной, когда к ним обратятся.

Размер страницы

Размер страницы это свойство пары из процессора и ядра. x86-64 умеет страницы по 4 КБ, 2 МБ и 1 ГБ, и Linux по умолчанию берёт 4 КБ. ARM умеет 4, 16 и 64 КБ: Apple выбрала 16 КБ для всех своих систем на Apple Silicon, Linux на серверных ARM обычно собирают с 4 КБ, реже с 64 КБ. Программа не должна зашивать число 4096, и стандартная библиотека Zig это учитывает: в std.heap есть две константы времени компиляции, page_size_min и page_size_max, и функция pageSize(). Если минимум и максимум для целевой платформы совпадают, pageSize() отдаёт константу; если нет, спрашивает систему при первом вызове (на Linux без libc читает AT_PAGESZ из вспомогательного вектора, который ядро кладёт на стек при execve, на macOS зовёт task_info) и запоминает ответ.

const std = @import("std");

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

    const page = std.heap.pageSize();
    const p: u6 = @intCast(std.math.log2_int(usize, page));
    try out.print("page_size_min  {d}\n", .{std.heap.page_size_min});
    try out.print("page_size_max  {d}\n", .{std.heap.page_size_max});
    try out.print("pageSize()     {d} = 2^{d}\n", .{ page, p });

    var local: u32 = 0;
    const va = @intFromPtr(&local);
    local +%= 1;
    try out.print("VA   0x{x}\n", .{va});
    try out.print("VPN  0x{x}\n", .{va >> p});
    try out.print("VPO  0x{x}\n", .{va & (page - 1)});
    try out.flush();
}

На Linux aarch64:

page_size_min  4096
page_size_max  65536
pageSize()     4096 = 2^12
VA   0xffffd10d898c
VPN  0xffffd10d8
VPO  0x98c

Один и тот же бинарник для aarch64 Linux обязан работать на ядре с любым из трёх размеров страницы, поэтому минимум и максимум разные, а настоящий размер известен только во время выполнения. Именно поэтому std.posix.mmap возвращает срез с выравниванием page_size_min, а не “по размеру страницы”: тип обязан быть известен при компиляции, и гарантировать можно только минимум.

Номер страницы и смещение

Последние три строки вывода это главная операция всего блока. Раз страница занимает 2^p байт, младшие p бит адреса это смещение внутри страницы (VPO), а все остальные, старшие n − p бит, это номер виртуальной страницы (VPN). Никакого деления: сдвиг вправо на p даёт номер, маска page − 1 даёт смещение.

  47                                    12 11            0
 ┌────────────────────────────────────────┬───────────────┐
 │        VPN = 0xffffd10d8               │  VPO = 0x98c  │
 └────────────────────────────────────────┴───────────────┘
   VA = 0xffffd10d898c, страница 4 КБ, p = 12

При p = 12 граница проходит ровно по шестнадцатеричной цифре: три младшие цифры адреса это смещение, остальное это номер. Поэтому адреса в Linux так удобно читать глазами, и поэтому всё, что выровнено на страницу, кончается на 000. При p = 14, как на macOS, граница режет цифру пополам, и глазами уже не получится.

Перевод адреса меняет только номер. MMU находит по VPN номер кадра, PPN, и приклеивает к нему то же самое смещение: PA = PPN × P + VPO. Смещение в физической странице всегда равно смещению в виртуальной, потому что страницы одного размера. Теперь понятно, почему ASLR не трогает младшие биты: ядро выбирает, в какие страницы положить область, а положение байта внутри страницы от него не зависит.

Таблица страниц

Осталось понять, где MMU берёт номер кадра. В оперативной памяти лежит структура данных, которую ведёт ядро: таблица страниц. В простейшем виде это массив, в котором столько записей, сколько виртуальных страниц в адресном пространстве, 2^(n − p), и VPN служит индексом. Одна запись называется PTE. В модели книги, которой нам сегодня хватит, в PTE два поля: бит действительности (valid) и адрес.

validадресчто это значит
1номер кадрастраница кэширована: лежит в этом кадре DRAM
0адрес на дискестраница размещена, но не кэширована
0пустостраница не размещена

Три строки таблицы это те самые три множества страниц. Заметь, что MMU различает только первую строку и две остальные: для аппаратуры бит valid равен либо единице, либо нулю. Разницу между второй и третьей строкой знает только ядро, и она определяет, чем кончится обращение: подкачкой страницы или смертью процесса.

У каждого процесса своя таблица страниц, и регистр процессора (CR3 на x86-64, TTBR0_EL1 на ARM) хранит физический адрес таблицы текущего процесса. Переключение процесса из урока про процессы это, помимо сохранения регистров, одна запись в этот регистр. С этого момента те же виртуальные адреса ведут к другим кадрам.

В настоящем процессоре PTE занимает 8 байт и несёт кроме номера кадра права доступа (чтение, запись, исполнение, доступ из пользовательского режима) и два бита, которые выставляет сама аппаратура: к странице обращались, в страницу писали. Права это тема следующего урока. Бит записи понадобится нам уже сегодня.

Цена такой таблицы легко считается. 32-битное пространство со страницами по 4 КБ это 2^20 записей; при записи в 4 байта выходит 4 МБ на процесс, терпимо. 48-битное пространство с теми же страницами это 2^36 записей по 8 байт: 512 ГБ на каждый процесс, включая тот, что занимает четыре страницы. Плоская таблица для настоящих систем не годится, и в уроке про трансляцию адресов она станет деревом, которое хранит только непустые ветки. Модель от этого не меняется: дерево отвечает на тот же вопрос, что и массив. Поэтому сегодня таблица плоская.

Таблица страниц в руках

Виджет повторяет рисунок из книги: 8 виртуальных страниц, 4 кадра физической памяти. В памяти лежат VP 1, VP 2, VP 7 и VP 4, на диске VP 3 и VP 6, страницы VP 0 и VP 5 не размещены. Адрес можно ввести руками или взять из готовых.

Пройди по порядку.

  1. Нажми “читать” для адреса 0x2abc. Старшие три бита дают VPN 2, у PTE 2 бит valid равен единице, страница лежит в кадре PP 1. Физический адрес это 1 × 4096 + 0xabc = 0x1abc. Это попадание, и в настоящей машине его целиком выполняет аппаратура.
  2. Возьми готовое обращение “запись в VP 7”. Тоже попадание, но у PTE 7 встал бит dirty. Запомни его.
  3. Теперь “VP 3 на диске”. Бит valid равен нулю: сбой страницы. Свободных кадров нет, и жертвой по LRU становится VP 4 (её не трогали дольше всех). Она не менялась, поэтому просто забывается. VP 3 читается с диска в освободившийся кадр PP 3, и обращение повторяется, уже с попаданием.
  4. “VP 6 на диске”. Снова сбой. Кто жертва на этот раз? VP 1: к ней не обращались с самого начала.
  5. Введи 0x1000 и прочитай: VP 1 только что выгрузили, поэтому снова сбой, жертва VP 2. Теперь 0x4000: сбой, и жертвой становится VP 7, в которую ты писал. Счётчик “записей на диск” вырос на единицу: грязную страницу нельзя просто забыть, её свежее содержимое есть только в DRAM. Четыре страницы в четырёх кадрах, пять страниц в обороте, и почти каждое обращение стало сбоем. Запомни это ощущение до раздела про пробуксовку.
  6. “VP 0 не размещена”. Ни кадра, ни места на диске. Ядру нечего подкачивать, процесс получает ошибку сегментации. Нажми “разместить” в строке VP 0 и повтори: теперь это обычный сбой страницы. Кнопка “разместить” это mmap в миниатюре.
  7. Переключи размер страницы на 1 КБ. Бит в адресе столько же, но граница между VPN и VPO сдвинулась: страниц стало 32, и таблица выросла вчетверо. Кадров по-прежнему четыре, но теперь это 4 КБ физической памяти, а не 16.

Попадание и сбой страницы

Разберём оба исхода так, как их видит машина.

Попадание. Процессор выполняет movq (%rdi), %rax. Ядро процессора отдаёт MMU виртуальный адрес. MMU берёт VPN, находит PTE, видит valid = 1, склеивает PPN со смещением, и на шину памяти уходит физический адрес. Операционная система не участвует. Сколько это стоит, зависит от того, где MMU нашла PTE; это тема урока про TLB. Пока считаем, что перевод бесплатный.

Сбой страницы. Сбой страницы это промах кэша DRAM, и дальше работает механизм исключений в точности как в уроке про них:

  1. MMU видит valid = 0 и возбуждает исключение. На x86-64 это вектор 14, #PF. Процессор кладёт виновный виртуальный адрес в регистр CR2, код причины на стек ядра и переходит в обработчик из таблицы исключений.
  2. Обработчик ядра смотрит, есть ли у процесса область, которой принадлежит этот адрес. Если нет, это третья строка таблицы: процессу уходит сигнал SIGSEGV, и на этом всё.
  3. Если область есть, ядро выбирает кадр. Свободный, а если свободных нет, кадр страницы-жертвы. Если жертва менялась (бит dirty), её содержимое сначала записывается на диск. PTE жертвы получает valid = 0.
  4. Ядро читает нужную страницу с диска в кадр (или заполняет его нулями, если за страницей нет файла и она ещё ни разу не использовалась), записывает в PTE номер кадра и valid = 1.
  5. Обработчик возвращается. Сбой, в отличие от ловушки, возвращает управление не на следующую инструкцию, а на ту же самую. movq выполняется заново, отдаёт MMU тот же виртуальный адрес, и теперь это попадание.

Пятый шаг самый красивый. Программа не знает, что её инструкция выполнялась дважды и между двумя попытками прошло сто микросекунд работы ядра и накопителя. Это возможно только потому, что архитектура гарантирует: инструкция, вызвавшая сбой, не оставила видимых следов. Для movq это очевидно, а вот для rep movsb, которая копирует мегабайт и может споткнуться на любом байте, процессору приходится аккуратно сохранять в регистрах, сколько уже скопировано. Требование перезапускаемости инструкций это одна из причин, почему в конвейере исключение обрабатывается только на этапе записи, когда все предыдущие инструкции уже завершены, а все следующие ещё ничего не изменили.

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

Размещение по требованию

Теперь эксперимент, ради которого всё это стоило рассказывать. Если размещение и кэширование разные события, их можно увидеть по отдельности. Ядро считает сбои страниц каждого процесса и отдаёт счётчики через getrusage: поле minflt это лёгкие сбои, обслуженные без обращения к диску, majflt это тяжёлые, ради которых пришлось читать с накопителя. В std.posix 0.16 обёртка простая: getrusage(who) возвращает структуру rusage по значению, без ошибок, потому что ошибиться там нечему.

Программа размещает область в 4096 страниц, а потом трогает её частями и после каждой части смотрит, на сколько вырос счётчик.

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

const Faults = struct { minor: isize, major: isize };

fn faults() Faults {
    const usage = posix.getrusage(posix.rusage.SELF);
    return .{ .minor = usage.minflt, .major = usage.majflt };
}

fn report(out: *std.Io.Writer, what: []const u8, before: Faults, after: Faults) !void {
    const minor: usize = @intCast(after.minor - before.minor);
    const major: usize = @intCast(after.major - before.major);
    try out.print("{s:<22} minflt +{d:<6} majflt +{d}\n", .{ what, minor, major });
}

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

    const page = std.heap.pageSize();
    const pages = 4096;
    try out.print("page {d} bytes, region {d} pages = {d} MB\n", .{ page, pages, pages * page >> 20 });

    var before = faults();
    const region = try posix.mmap(
        null,
        pages * page,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
    defer posix.munmap(region);
    var after = faults();
    try report(out, "mmap", before, after);

    before = after;
    var i: usize = 0;
    while (i < pages / 4) : (i += 1) region[i * page] = 1;
    after = faults();
    try report(out, "touch first quarter", before, after);

    before = after;
    i = 0;
    while (i < pages / 4) : (i += 1) region[i * page] +%= 1;
    after = faults();
    try report(out, "touch it again", before, after);

    before = after;
    i = pages / 4;
    while (i < pages) : (i += 1) region[i * page] = 1;
    after = faults();
    try report(out, "touch the rest", before, after);

    before = after;
    var sum: usize = 0;
    for (region) |byte| sum += byte;
    after = faults();
    try report(out, "read every byte", before, after);
    try out.print("sum {d}\n", .{sum});
    try out.flush();
}

Запуск на macOS (Apple M4 Max, страница 16 КБ, область 64 МБ):

page 16384 bytes, region 4096 pages = 64 MB
mmap                   minflt +0      majflt +0
touch first quarter    minflt +1024   majflt +0
touch it again         minflt +0      majflt +0
touch the rest         minflt +3072   majflt +0
read every byte        minflt +0      majflt +0
sum 5120

Читаем по строкам.

mmap: ноль сбоев. Ядро выдало 64 МБ адресного пространства и не потратило ни одного кадра. Оно записало у себя: “у процесса есть область такого-то размера с правами на чтение и запись, за ней нет файла”. Таблица страниц не изменилась, все 4096 PTE по-прежнему недействительны. Поэтому mmap на гигабайт занимает те же микросекунды, что mmap на страницу, и поэтому программа может разместить больше памяти, чем есть в машине.

Первое касание: ровно 1024 сбоя на 1024 страницы. Каждая запись region[i * page] = 1 попала в страницу с valid = 0. Сбой, обработчик, чистый кадр, заполненный нулями, исправленная PTE, перезапуск инструкции. Сбои лёгкие: диска здесь нет, за анонимной страницей не стоит никакой файл, её начальное содержимое это нули, и ядро делает их на месте.

Второе касание: ноль. Страницы кэшированы, MMU справляется сама.

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

Чтение всех 64 МБ: ноль. Все страницы уже в памяти. Сумма 5120 это 1024 страницы со значением 2 и 3072 со значением 1: проверка, что мы читали ту же память, в которую писали.

Вот что такое размещение по требованию: ядро обещает память в момент mmap и выполняет обещание постранично, в обработчике сбоя, и только для тех страниц, которые действительно понадобились. Разреженный массив на терабайт, в котором заполнена сотня страниц, стоит сотню кадров. Стек потока в 8 МБ, из которых использовано 20 КБ, стоит два кадра по 16 КБ.

Тот же эксперимент на Linux, и сюрприз

Тот же исходник, собранный под aarch64-linux, на ядре 7.0 со страницей 4 КБ:

page 4096 bytes, region 4096 pages = 16 MB
mmap                   minflt +0      majflt +0
touch first quarter    minflt +256    majflt +0
touch it again         minflt +0      majflt +0
touch the rest         minflt +768    majflt +0
read every byte        minflt +0      majflt +0
sum 5120

Тронули 1024 страницы, получили 256 сбоев. Ровно вчетверо меньше. Теория сломалась?

Нет, это ядро оказалось умнее модели. Начиная с версии 6.8 Linux умеет обслуживать сбой на анонимной памяти не одной страницей, а сразу группой соседних: 16, 32, 64 КБ и дальше до привычных огромных страниц в 2 МБ. Механизм называется многоразмерными прозрачными огромными страницами (mTHP), и на этой машине он включён для размера 16 КБ:

$ cat /sys/kernel/mm/transparent_hugepage/hugepages-16kB/enabled
[always] inherit madvise never

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

Проверить объяснение легко: попросим ядро так не делать. Одна строка после mmap, совет MADV_NOHUGEPAGE (число 15 в заголовках Linux):

try posix.madvise(region.ptr, region.len, 15);
touch first quarter    minflt +1024   majflt +0
touch it again         minflt +0      majflt +0
touch the rest         minflt +3072   majflt +0

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

Сколько стоит лёгкий сбой

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

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

fn sweep(io: std.Io, region: []u8, page: usize) u64 {
    const started = std.Io.Clock.awake.now(io);
    var i: usize = 0;
    while (i < region.len) : (i += page) region[i] +%= 1;
    const elapsed = started.durationTo(std.Io.Clock.awake.now(io));
    return @intCast(elapsed.toNanoseconds());
}

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

    const page = std.heap.pageSize();
    const pages = 16384;
    const region = try posix.mmap(
        null,
        pages * page,
        .{ .READ = true, .WRITE = true },
        .{ .TYPE = .PRIVATE, .ANONYMOUS = true },
        -1,
        0,
    );
    defer posix.munmap(region);

    const cold = sweep(init.io, region, page);
    const warm = sweep(init.io, region, page);
    try out.print("pages {d}, page {d} bytes\n", .{ pages, page });
    try out.print("first touch   {d:>6} ns per page\n", .{cold / pages});
    try out.print("second touch  {d:>6} ns per page\n", .{warm / pages});
    try out.flush();
}

Linux aarch64, страница 4 КБ, сборка -O ReleaseFast, медиана из пяти запусков на нагруженной машине:

pages 16384, page 4096 bytes
first touch      936 ns per page
second touch      10 ns per page

Первое касание страницы стоит около 0.9 мкс, второе около 10 нс (это промах кэша и TLB на каждом шаге, страницы далеко друг от друга). Разница в девяносто раз, и это с учётом того, что ядро обслуживает четыре страницы одним сбоем: сам сбой вместе с обнулением 16 КБ стоит около 3.7 мкс. С MADV_NOHUGEPAGE, когда сбой приходится на каждую страницу, первое касание дорожает до 1250 нс на страницу: группировка экономит четверть времени, потому что вход в ядро и выход из него дороже, чем обнулить лишние 12 КБ.

Отсюда практическое следствие для программ, чувствительных к задержке. Свежая память от аллокатора медленная при первом проходе, и это не промахи кэша, а сбои страниц. Сервер, который на каждый запрос размещает и освобождает большой буфер через mmap, платит микросекунды на каждые 4 КБ. Лекарства два: переиспользовать память (арена из урока про аллокаторы, которую сбрасывают, а не возвращают системе) или прогреть её заранее (флаг MAP_POPULATE в Linux просит ядро разместить все кадры прямо в mmap). И наоборот: бенчмарк, который замеряет первый проход по только что выделенному массиву, замеряет ядро, а не твой код. В горе памяти мы именно поэтому делали разогрев перед замером: первый проход выбрасывался.

Оперативная память как кэш диска

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

Кэши процессораВиртуальная память
блок, линиястраница
строка кэшакадр, физическая страница
попаданиепопадание
промахсбой страницы
тег плюс бит валидностиPTE с битом valid
вытеснениевыгрузка страницы-жертвы
размер блока 64 или 128 байтразмер страницы 4 или 16 КБ, до 2 МБ и 1 ГБ
замещением управляет железозамещением управляет ядро
write-back или write-throughтолько отложенная запись

Вся разница в устройстве следует из одного числа: штрафа за промах. Сравним на железе 2026 года, числа те же, что в уроке про иерархию.

ПромахКуда идёмШтрафВ тактах при 4 ГГцВо сколько раз медленнее попадания
L1 в L2SRAM на кристаллеоколо 3 нс123
L3 в DRAMDDR580 нс32080 против L1
лёгкий сбой страницыядро, диск не нужен1 до 4 мкс (наш замер)4 000 до 16 00010 до 50 против DRAM
тяжёлый сбой, NVMe PCIe 5.0флеш60 мкс накопитель плюс 5 до 10 мкс ядро280 000около 900 против DRAM
тяжёлый сбой, жёсткий дисквращающиеся пластины8 мс32 000 000100 000 против DRAM

Книга писалась в эпоху последней строки: DRAM в десять раз медленнее SRAM, а диск в сто тысяч раз медленнее DRAM, причём первый байт сектора стоит в сто тысяч раз дороже следующих. SSD сократили пропасть на два порядка, но и три порядка это не промах кэша, который процессор пережидает, пока конвейер стоит. За время одного тяжёлого сбоя на NVMe ядро успевает выполнить четверть миллиона тактов, и поэтому на время сбоя процесс снимается с процессора, а его место занимает другой. Сбой страницы это не задержка, это переключение контекста.

Посмотрим, что такая цена делает со средним временем обращения. Формула та же, что для кэша: время попадания плюс доля промахов на штраф.

const std = @import("std");

const dram_ns = 80.0;
const ssd_ns = 60_000.0;
const hdd_ns = 8_000_000.0;

/// Среднее время обращения к памяти при доле сбоев страниц `fault_rate`.
fn effectiveNs(fault_rate: f64, penalty_ns: f64) f64 {
    return dram_ns + fault_rate * penalty_ns;
}

test "один сбой на тысячу обращений уже заметен" {
    try std.testing.expectApproxEqAbs(140.0, effectiveNs(0.001, ssd_ns), 0.001);
}

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

    try out.print("{s:>12} {s:>12} {s:>8} {s:>12} {s:>8}\n", .{ "fault rate", "SSD, ns", "slower", "HDD, ns", "slower" });
    for ([_]f64{ 0.0, 0.000001, 0.00001, 0.0001, 0.001, 0.01 }) |rate| {
        const ssd = effectiveNs(rate, ssd_ns);
        const hdd = effectiveNs(rate, hdd_ns);
        try out.print("{d:>12.6} {d:>12.1} {d:>7.2}x {d:>12.1} {d:>7.1}x\n", .{
            rate, ssd, ssd / dram_ns, hdd, hdd / dram_ns,
        });
    }
    try out.flush();
}
  fault rate      SSD, ns   slower      HDD, ns   slower
    0.000000         80.0    1.00x         80.0     1.0x
    0.000001         80.1    1.00x         88.0     1.1x
    0.000010         80.6    1.01x        160.0     2.0x
    0.000100         86.0    1.08x        880.0    11.0x
    0.001000        140.0    1.75x       8080.0   101.0x
    0.010000        680.0    8.50x      80080.0  1001.0x

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

Большие страницы. Раз уж заплатили за обращение к накопителю, надо принести побольше: стоимость первого байта огромна, следующих почти нулевая. Отсюда страницы в 4 до 16 КБ против линий в 64 байта, и отсюда же чтение с упреждением: на тяжёлом сбое по отображённому файлу ядро читает не одну страницу, а окно в 128 КБ и больше.

Полная ассоциативность. В кэше процессора блок может лечь только в свой набор, потому что искать по всему кэшу аппаратуре некогда. Расплата это конфликтные промахи. Здесь конфликтный промах стоил бы десятки микросекунд, и позволить его себе нельзя: любая виртуальная страница может лечь в любой кадр. Но как тогда искать страницу среди миллионов кадров за время одного обращения? А искать не нужно. Кэш процессора ищет по содержимому: сравнивает тег со всеми строками набора. Таблица страниц ищет по индексу: PTE с номером VPN прямо говорит, в каком кадре страница. Таблица страниц и есть та структура, которая превращает полностью ассоциативный поиск в одно чтение массива. Цена: таблица занимает память и сама требует обращения к памяти. Что с этим делать, расскажет урок про TLB.

Программное замещение. У кэша L1 на выбор жертвы есть доли наносекунды, и он обходится приближённым LRU на несколько бит. У ядра на фоне шестидесяти микросекунд ожидания накопителя есть время на любой алгоритм, и ошибка в выборе жертвы стоит так дорого, что умный алгоритм окупается. Настоящие ядра не ведут точный LRU, как наш виджет (обновлять метку времени на каждом обращении к памяти аппаратура не станет), а приближают его: аппаратура выставляет в PTE бит “к странице обращались”, ядро периодически обходит страницы, сбрасывает бит и смотрит, у кого он встал снова. В Linux это два списка, активный и неактивный, а с версии 6.1 ещё и многопоколенный LRU. Страницы, в которые не писали, выгодные жертвы: их можно просто забыть. Страницы кода и отображённых файлов ещё выгоднее: они вообще не идут в файл подкачки, их оригинал и так лежит на диске.

Только отложенная запись. В уроке про кэш мы сравнивали write-through и write-back и видели, что сквозная запись иногда выигрывает. Здесь она не выигрывает никогда: писать страницу на диск при каждом изменении байта немыслимо. Запись всегда откладывается до выгрузки, и для этого нужен бит dirty в PTE, который выставляет сама MMU при первой записи в страницу. Ты видел его в виджете.

Локальность, рабочее множество и пробуксовка

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

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

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

const std = @import("std");

pub const Outcome = enum { hit, fault, segfault };

pub const Pte = struct {
    allocated: bool = false,
    valid: bool = false,
    frame: usize = 0,
    last_used: u64 = 0,
};

pub fn Pager(comptime pages: usize, comptime frames: usize) type {
    return struct {
        const Self = @This();

        table: [pages]Pte = [_]Pte{.{}} ** pages,
        used_frames: usize = 0,
        clock: u64 = 0,
        faults: u64 = 0,
        evictions: u64 = 0,

        /// Размещение: страница получает место на диске, в память не попадает.
        pub fn allocate(self: *Self, vpn: usize) void {
            self.table[vpn].allocated = true;
        }

        /// Одно обращение к странице.
        pub fn touch(self: *Self, vpn: usize) Outcome {
            self.clock += 1;
            const pte = &self.table[vpn];
            if (!pte.allocated) return .segfault;
            if (pte.valid) {
                pte.last_used = self.clock;
                return .hit;
            }
            self.faults += 1;
            const frame = self.claimFrame();
            pte.* = .{ .allocated = true, .valid = true, .frame = frame, .last_used = self.clock };
            return .fault;
        }

        /// Свободный кадр, а если их нет, кадр страницы, которую не трогали дольше всех.
        fn claimFrame(self: *Self) usize {
            if (self.used_frames < frames) {
                self.used_frames += 1;
                return self.used_frames - 1;
            }
            var victim: ?*Pte = null;
            for (&self.table) |*pte| {
                if (!pte.valid) continue;
                if (victim == null or pte.last_used < victim.?.last_used) victim = pte;
            }
            self.evictions += 1;
            victim.?.valid = false;
            return victim.?.frame;
        }
    };
}

test "размещение по требованию: первый раз сбой, второй раз попадание" {
    var pager: Pager(8, 4) = .{};
    try std.testing.expectEqual(Outcome.segfault, pager.touch(3));
    pager.allocate(3);
    try std.testing.expectEqual(Outcome.fault, pager.touch(3));
    try std.testing.expectEqual(Outcome.hit, pager.touch(3));
    try std.testing.expectEqual(@as(u64, 1), pager.faults);
}

test "жертва это страница, которую не трогали дольше всех" {
    var pager: Pager(8, 2) = .{};
    for (0..3) |vpn| pager.allocate(vpn);
    _ = pager.touch(0);
    _ = pager.touch(1);
    _ = pager.touch(0);
    try std.testing.expectEqual(Outcome.fault, pager.touch(2));
    try std.testing.expectEqual(Outcome.hit, pager.touch(0));
    try std.testing.expectEqual(Outcome.fault, pager.touch(1));
    try std.testing.expectEqual(@as(u64, 2), pager.evictions);
}

fn faultsFor(comptime frames: usize, working_set: usize, rounds: usize) u64 {
    var pager: Pager(16, frames) = .{};
    for (0..16) |vpn| pager.allocate(vpn);
    for (0..rounds) |_| {
        for (0..working_set) |vpn| _ = pager.touch(vpn);
    }
    return pager.faults;
}

test "рабочее множество помещается: сбои только холодные" {
    try std.testing.expectEqual(@as(u64, 6), faultsFor(8, 6, 100));
    try std.testing.expectEqual(@as(u64, 6), faultsFor(6, 6, 100));
}

test "рабочее множество на страницу больше памяти: пробуксовка" {
    try std.testing.expectEqual(@as(u64, 600), faultsFor(5, 6, 100));
}

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

    try out.print("6 pages in a loop, 100 rounds, 600 accesses\n", .{});
    inline for (.{ 8, 7, 6, 5, 4, 3 }) |frames| {
        const faults = faultsFor(frames, 6, 100);
        try out.print("frames {d}  faults {d:>3}  fault rate {d:>5.1}%\n", .{
            frames,
            faults,
            @as(f64, @floatFromInt(faults)) / 6.0,
        });
    }
    try out.flush();
}

Обрати внимание на две строки в touch: сначала const frame = self.claimFrame();, потом присваивание в pte.*. Написать вызов прямо внутри инициализатора структуры нельзя, и это настоящая ловушка Zig. Инициализатор pte.* = .{ ... } пишет поля прямо в pte.* по порядку, без временной копии. Поле valid = true оказалось бы записано до вызова claimFrame, и та честно выбрала бы жертвой страницу, которую мы как раз подкачиваем: у неё самая старая метка времени. Я наступил на это, пока писал урок: тесты показали 105 сбоев там, где обязаны быть 600.

$ zig test pager.zig
All 4 tests passed.
$ zig run pager.zig
6 pages in a loop, 100 rounds, 600 accesses
frames 8  faults   6  fault rate   1.0%
frames 7  faults   6  fault rate   1.0%
frames 6  faults   6  fault rate   1.0%
frames 5  faults 600  fault rate 100.0%
frames 4  faults 600  fault rate 100.0%
frames 3  faults 600  fault rate 100.0%

Шесть страниц по кругу. Пока кадров шесть и больше, сбоев ровно шесть, все холодные. Убираем один кадр, и сбоем становится каждое обращение: LRU выгружает страницу, которую не трогали дольше всех, а при обходе по кругу это ровно та страница, которая понадобится следующей. Это худший случай для LRU, настоящие программы ведут себя мягче, но форма кривой та же: ступенька, а не склон.

Как это выглядит на живой машине. В Linux счётчики сбоев процесса видны в ps -o min_flt,maj_flt, в /usr/bin/time -v и в том самом getrusage. Общесистемная картина это vmstat 1: колонки si и so показывают, сколько килобайт в секунду подкачивается и выгружается. Если обе стабильно не нулевые, а процессор при этом простаивает в ожидании ввода-вывода, машина буксует, и лечится это только памятью или уменьшением рабочего множества. В 2026 году у пробуксовки есть и второй финал: если файла подкачки нет (в контейнерах и на большинстве облачных серверов его нет), выгружать анонимные страницы некуда, и вместо тормозов приходит OOM killer и убивает самый толстый процесс. Лимит памяти в zbox, который появится в следующем уроке, опирается именно на это поведение.

На macOS. Все программы урока собираются и запускаются на macOS напрямую. Отличий три. Первое: страница на Apple Silicon это 16 КБ, и std.heap.page_size_min с page_size_max для цели aarch64-macos совпадают, поэтому pageSize() там константа времени компиляции и системный вызов не делается вовсе. Процессы x86-64 под Rosetta видят страницу 4 КБ, хотя ядро под ними работает с 16 КБ. Второе: карта адресов. where.zig на macOS 26 печатает код и данные около 0x100b69a24 (образ начинается с 4 ГБ: нижние четыре гигабайта заняты сегментом __PAGEZERO без единого права, чтобы любой усечённый до 32 бит указатель давал сбой), кучу и mmap чуть выше, около 0x1057e00c8, а стек на 0x16f3900c8. Все исполняемые файлы на Apple Silicon позиционно-независимые, поэтому между запусками сдвигается и образ тоже, а не только стек. Третье: подкачка. Прежде чем писать страницу на диск, macOS пытается её сжать и оставить в памяти (строка “Сжатая память” в Мониторинге системы); распаковка стоит единицы микросекунд вместо десятков, и тяжёлым сбоем не считается. Linux умеет то же самое через zram и zswap, но по умолчанию это включено не везде. Счётчики minflt и majflt в getrusage на macOS работают, вывод demand.zig выше снят именно там. touchcost.zig на той же машине под macOS даёт 800 до 1000 нс на первое касание страницы в 16 КБ. Linux без группировки тратит на те же 16 КБ четыре сбоя по 1250 нс, то есть впятеро больше: вход в ядро стоит дорого, а обнулить лишние 12 КБ дёшево. Вот зачем Apple большие страницы, и вот зачем Linux понадобились mTHP. Файлов /proc и mTHP на macOS нет; аналог vmstat называется vm_stat, колонки pageins и pageouts.

Практика

Задача урока это арифметика страниц, на которой стоит весь блок. Система задаётся структурой Params с двумя числами: n бит в виртуальном адресе и p бит в смещении. Нужно написать пять чистых функций: split режет адрес на VPN и VPO, join собирает адрес из номера страницы и смещения (так MMU строит физический адрес), pageCount считает страницы в адресном пространстве, pagesFor считает, сколько страниц займёт область заданного размера, tableBytes даёт размер плоской таблицы страниц.

Скрытые тесты проверяют 16-битную систему из виджета, учебную систему из книги с 14-битным адресом и страницами по 64 байта (она станет главным героем урока про трансляцию), один 48-битный адрес при четырёх размерах страницы от 4 КБ до 1 ГБ, обратимость split и join на всех адресах маленькой системы и размеры таблиц из этого урока: 4 МБ для 32 бит и 512 ГБ для 48. Крайние случаи: p = 0, p = n и n = 64. Последний ловит сдвиг на 64, который в Zig либо не компилируется, либо паникует; как и в задаче про разбор адреса кэша, выручает std.math.shr. И не пиши в pagesFor привычное (bytes + page − 1) / page: для области у самого края адресного пространства сумма переполняется.

Упражнения

Итоги

  • Программа работает только с виртуальными адресами. MMU на кристалле процессора переводит каждый из них в физический по таблице, которую ведёт ядро; аппаратура делает перевод, ядро заполняет таблицы и обрабатывает случаи, когда перевод не удался.
  • Адресное пространство из n бит это 2^n адресов. На x86-64 значащих бит 48 (или 57), адреса обязаны быть каноническими, нижняя половина отдана процессу, верхняя ядру. Адресное пространство процесса почти целиком пустое: адрес ничего не стоит, стоит только страница, к которой обратились.
  • Память размещается, защищается и переносится страницами по P = 2^p байт: 4 КБ в Linux на x86-64, 16 КБ на Apple Silicon. Размер страницы узнают через std.heap.pageSize(), а не зашивают числом. Виртуальный адрес режется на VPN (старшие n − p бит) и VPO (младшие p бит); перевод меняет только номер, смещение переезжает в физический адрес как есть.
  • Страница бывает не размещена, размещена и кэширована в кадре DRAM, размещена и не кэширована. PTE хранит бит valid и номер кадра либо место на диске. У каждого процесса своя таблица, её адрес лежит в регистре процессора (CR3).
  • Сбой страницы это исключение класса сбой: MMU видит valid = 0, ядро находит кадр (при нужде выгружает жертву, грязную сначала пишет на диск), подкачивает страницу, правит PTE, и инструкция выполняется заново. Обращение к неразмещённой странице кончается SIGSEGV.
  • Размещение по требованию: mmap только обещает память и не стоит ни одного сбоя; кадр выдаётся в обработчике сбоя при первом касании каждой страницы. Это видно по minflt из getrusage. Лёгкий сбой стоит 1 до 4 мкс, поэтому первый проход по свежей памяти в десятки раз медленнее второго. Linux с mTHP обслуживает одним сбоем несколько соседних страниц.
  • DRAM это кэш диска со штрафом в тысячу (NVMe) или сто тысяч (жёсткий диск) попаданий. Отсюда большие страницы, полная ассоциативность (таблица страниц заменяет поиск по тегу чтением по индексу), замещение силами ядра и только отложенная запись с битом dirty.
  • Быстро это работает благодаря локальности: пока рабочее множество помещается в память, сбоев после разогрева нет. Когда перестаёт помещаться, начинается пробуксовка, и это обрыв, а не склон. Без файла подкачки вместо пробуксовки приходит OOM killer.

Дальше

Сегодня виртуальная память была кэшем: способом сделать вид, что памяти больше, чем есть. Но у того же механизма есть две другие роли, и для программиста они важнее. В следующем уроке таблица страниц станет инструментом управления: почему у всех процессов одинаковая карта адресов, как одна копия libc обслуживает сотню процессов и почему загрузка программы это не копирование файла в память, а несколько записей в таблицах. Затем инструментом защиты: биты прав в PTE, mprotect, и что на самом деле происходит, когда программа разыменовывает нулевой указатель. Там же ты прочитаешь /proc/self/maps своего процесса, научишь zt команде pmap, а zbox получит лимит памяти для чужой программы. А вопрос, который мы сегодня отложили дважды, сколько стоит сам перевод адреса и как его удешевить, достанется уроку про трансляцию и TLB.

домашка

Домашка