Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Виртуальная память как менеджер и как защита
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Виртуальная память как менеджер и как защита
В прошлом уроке виртуальная память была кэшем: страницы лежат на диске, горячие подняты в физическую память, таблица страниц помнит, что где. Если бы дело было только в кэше, механизм давно бы выбросили: памяти в машинах 2026 года хватает, и своп на сервере чаще выключен, чем включён. Виртуальную память держат ради двух других работ. Первая: она раздаёт каждому процессу одинаковое, чистое, непрерывное адресное пространство и этим упрощает компоновщик, загрузчик и аллокатор. Вторая: в той же записи таблицы страниц лежат биты прав, и процессор проверяет их на каждом обращении. Сегодня мы посмотрим на обе работы снаружи и изнутри: прочитаем карту памяти своего процесса, отнимем у страницы право записи и поймаем нарушение, заглянем в структуры ядра Linux, которые эту карту хранят. А потом пустим знание в дело:
ztнаучится командеpmap, аzboxначнёт ограничивать память чужой программы, и мы увидим, почему из двух способов годится только один.
Цели урока
- Объяснить, чем одинаковое адресное пространство у всех процессов упрощает компоновку, загрузку, разделение кода и выделение памяти.
- Знать биты прав в записи таблицы страниц: учебные SUP, READ, WRITE, EXEC из книги и настоящие биты x86-64, и понимать, кто и когда их проверяет.
- Читать
/proc/self/maps: шесть полей строки, праваrwxp, откуда взялась каждая область. - Прочитать и разобрать карту памяти своего процесса на Zig.
- Снять право записи через
mprotect, поймать нарушение защиты обработчиком сигнала и объяснить, почему после возврата из обработчика запись проходит. - Понимать, как Linux хранит адресное пространство:
mm_struct,vm_area_struct, дерево областей (maple tree вместо списка из книги) и три вопроса обработчика сбоя страницы. - Написать
zt pmapи сверить его вывод с настоящимpmapбайт в байт. - Ограничить память программы в
zboxдвумя способами,RLIMIT_ASи cgroups v2, и на живых числах увидеть, почему раннер курса выбрал второй.
Идея: одна таблица, две работы
Вспомни, что делает запись таблицы страниц (PTE): сопоставляет номер виртуальной страницы с номером физической. В прошлом уроке нас интересовал один её бит, valid: страница в памяти или на диске. Теперь посмотрим на саму возможность сопоставлять что угодно с чем угодно.
У каждого процесса своя таблица страниц. Значит, у каждого процесса свой ответ на вопрос “что лежит по адресу 0x401000”. Операционная система может раздать всем одинаковые виртуальные адреса, а физические страницы под ними расставить как ей удобно. Из этого одного факта вырастают четыре упрощения.
Компоновка: все программы живут по одним адресам
Когда мы в уроке про перемещения и загрузку писали zt ld, компоновщик назначал секциям адреса, ничего не зная о машине, на которой программу запустят. Код с 0x400000 (у Zig с 0x1000000), за ним данные, стек где-то под вершиной пользовательской половины. Это возможно только потому, что адреса виртуальные. Компоновщику не нужно знать, сколько памяти в машине, какие программы уже запущены и какие физические кадры свободны. Формат исполняемого файла описывает одну и ту же картину для любого запуска.
Представь мир без этого. Две копии одной программы не могли бы работать одновременно: обе слинкованы под одни адреса. Так и жили ранние системы без MMU: либо одна программа в памяти, либо загрузчик правит все адреса в коде при каждой загрузке.
Загрузка: execve ничего не копирует
Загрузить программу значит создать области виртуальной памяти для её сегментов и пометить их страницы как “не в памяти, лежат в таком-то файле с такого-то смещения”. Ни одного байта кода execve из файла в память не копирует. Первая же инструкция вызывает сбой страницы, ядро подтягивает страницу из файла, инструкция перезапускается. Данные приходят по требованию, и та часть программы, до которой выполнение не дошло, так и останется на диске.
Механизм, который связывает кусок файла с диапазоном виртуальных адресов, называется отображением в память, и доступен он не только ядру: вызов mmap отдаёт его программам. Это тема урока 56, здесь нам хватит факта: область программы в памяти и сегмент в файле это одно и то же, увиденное с двух сторон.
Разделение: одна libc на всех
Обычно у процессов всё своё: код, данные, куча, стек. Таблицы страниц разных процессов ведут в непересекающиеся физические страницы. Но ничто не мешает двум таблицам указать на один кадр. Так и сделано для кода разделяемых библиотек: в машине сотни процессов, почти каждому нужна libc, и её код лежит в физической памяти в одном экземпляре. В уроке про динамическую компоновку мы ради этого делали код позиционно-независимым: у каждого процесса библиотека стоит по своему виртуальному адресу, а физические страницы общие, значит адреса в самих страницах зашивать нельзя.
Это видно числами. Ядро показывает про каждую область, сколько её страниц сейчас в физической памяти (Rss) и сколько из них приходится на долю этого процесса, если общие страницы честно поделить между всеми, кто их держит (Pss). Вот код libc одного из трёх процессов sleep 100, запущенных рядом (Debian 12, Linux 7.0, контейнер linux/amd64, сентябрь 2026):
7fffff60a000-7fffff760000 r-xp 00026000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
Size: 1368 kB
Rss: 788 kB
Pss: 141 kB
Из 1368 КБ кода в памяти 788: до остального никто не дошёл, и оно осталось на диске. А доля одного процесса всего 141 КБ, в пять с лишним раз меньше: эти же кадры видят два соседних sleep, оболочка и всё остальное в системе, что слинковано с той же libc. С кучей того же процесса картина обратная, Rss равен Pss: куча частная, делить её не с кем.
Выделение: непрерывное снаружи, разбросанное внутри
Программа просит у ядра ещё памяти, скажем, k страниц подряд. Ядру не нужно искать k соседних свободных кадров. Оно берёт любые k кадров, где бы они ни лежали, и расставляет их в таблице страниц подряд. Программа видит непрерывный мегабайт, физически он размазан по всей памяти. Без этого физическая память быстро превратилась бы в решето, в котором свободного места много, а подходящего куска нет. С этой бедой, фрагментацией, мы ещё встретимся в уроке про malloc, но уже внутри кучи одного процесса, а не в масштабе машины.
Защита: биты прав в записи таблицы страниц
Раз каждое обращение к памяти проходит через PTE, в неё удобно положить ещё несколько битов и заставить процессор проверять их заодно с трансляцией. Проверка бесплатная: запись всё равно уже прочитана (а чаще лежит в TLB, о нём следующий урок).
Книга рисует учебную PTE с тремя добавочными битами, мы добавим четвёртый, который есть у всех современных процессоров:
| Бит | Смысл |
|---|---|
| SUP | страница доступна только в режиме ядра |
| READ | читать можно |
| WRITE | писать можно |
| EXEC | исполнять можно |
Вот кусок таблицы страниц процесса в этих терминах:
| Страница | SUP | READ | WRITE | EXEC | Что в ней |
|---|---|---|---|---|---|
| VP 0 | нет | да | нет | да | код программы |
| VP 1 | нет | да | да | нет | данные и куча |
| VP 2 | да | да | да | нет | структуры ядра |
| VP 3 | нет | да | нет | нет | константы |
Запись в VP 0, переход на VP 1, любое обращение к VP 2 из пользовательского режима, запись в VP 3: во всех четырёх случаях процессор не выполняет инструкцию, а возбуждает исключение. Это сбой того же класса, что и отсутствие страницы, с тем же номером вектора 14 на x86-64; код ошибки на стеке говорит обработчику, что именно случилось: страницы нет или права не те, чтение это было, запись или выборка инструкции, из какого режима. Ядро Linux в ответ посылает процессу SIGSEGV, оболочка печатает Segmentation fault.
Как это выглядит на настоящем x86-64
Настоящая PTE x86-64 (Intel SDM, том 3, раздел 4.5) устроена чуть иначе, и разница полезная:
| Бит PTE | Имя | Смысл |
|---|---|---|
| 0 | P | страница присутствует, это бит valid из прошлого урока |
| 1 | R/W | 0: только чтение, 1: можно писать |
| 2 | U/S | 0: только режим ядра, 1: доступна пользователю. Это SUP наоборот |
| 5, 6 | A, D | процессор сам ставит их при обращении и при записи |
| 63 | XD | 1: исполнять нельзя (у AMD тот же бит зовётся NX) |
Бита READ нет вовсе: присутствующую страницу всегда можно читать. Поэтому права -w- или --x на x86-64 аппаратно невыразимы, и если попросить у ядра страницу “только для записи”, читать её всё равно получится. Бит запрета исполнения появился только в 2003 году, у AMD64; до него любая страница с данными была исполняемой, и переполнение буфера с кодом прямо на стеке работало без всяких ухищрений. С тех пор правило W^X (страница либо записываемая, либо исполняемая, но не то и другое разом) стало нормой, и JIT из урока 19 нам придётся под него переделать в уроке 56.
Два уточнения, чтобы картина была честной. Права проверяются на каждом уровне многоуровневой таблицы, и действует самое строгое: если запись верхнего уровня запрещает запись, нижняя её не разрешит. И у ядра есть обратная защита: биты SMEP и SMAP в регистре CR4 запрещают режиму ядра исполнять и без явного разрешения трогать пользовательские страницы. Это лекарство от целого класса атак, где ядро обманом заставляли перейти по адресу в памяти атакующего.
У ARM64 биты другие (есть отдельные запреты исполнения для ядра и для пользователя, права кодируются парой битов AP), но идея та же: права живут в записи таблицы страниц, проверяет их железо, нарушение это исключение.
Области: карта памяти процесса
Таблица страниц отвечает на вопрос про одну страницу. Но адресное пространство в 128 ТБ почти целиком пустое, и ядру нужен способ помнить, какие его куски вообще существуют, не перебирая страницы. Для этого есть понятие уровнем выше: область. Это непрерывный кусок уже существующей (выделенной) виртуальной памяти, страницы которого чем-то связаны: сегмент кода, сегмент данных, куча, стек, отображённая библиотека. Каждая существующая страница принадлежит какой-то области, а страница вне областей не существует, и обращаться к ней нельзя.
Linux показывает области любого процесса текстом, в файле /proc/<pid>/maps. Для своего процесса есть короткий путь /proc/self/maps. Одна строка это одна область:
ffffbc2bc000-ffffbc2c0000 r--p 0018c000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6
начало конец права смещение устр. inode путь
- Диапазон. Два шестнадцатеричных адреса через дефис. Конец в область не входит, так что размер это просто разность. Границы всегда кратны размеру страницы.
- Права. Четыре символа. Первые три это
r,w,xили прочерк. Четвёртый говорит, что будет с записями:pзначит частная область (private), запись снимает копию страницы, и оригинал не меняется;sзначит разделяемая (shared), записи видны всем, кто отобразил тот же файл. - Смещение. С какого байта файла начинается область. У анонимной памяти ноль.
- Устройство и inode. Какой именно файл стоит за областью: номер устройства парой старший:младший и номер индексного узла. У анонимной памяти
00:00и0. - Путь. Имя файла, псевдоимя в квадратных скобках (
[heap],[stack],[vdso]) или ничего, если память анонимная.
Читаем свою карту на Zig
Программа читает /proc/self/maps, разбирает каждую строку и печатает таблицу. Чтобы было на что опереться, она заранее берёт три адреса, про которые мы знаем, где они обязаны оказаться: адрес функции, адрес глобальной переменной и адрес локальной.
// Читаем карту памяти собственного процесса и печатаем её таблицей.
// Только Linux: на других системах каталога /proc нет.
const std = @import("std");
const Region = struct {
start: u64,
end: u64,
perms: []const u8,
path: []const u8,
};
fn parseLine(line: []const u8) ?Region {
// начало-конец права смещение устройство inode [путь]
var fields = std.mem.tokenizeScalar(u8, line, ' ');
const range = fields.next() orelse return null;
const perms = fields.next() orelse return null;
_ = fields.next() orelse return null; // смещение
_ = fields.next() orelse return null; // устройство
_ = fields.next() orelse return null; // inode
const dash = std.mem.indexOfScalar(u8, range, '-') orelse return null;
return .{
.start = std.fmt.parseInt(u64, range[0..dash], 16) catch return null,
.end = std.fmt.parseInt(u64, range[dash + 1 ..], 16) catch return null,
.perms = perms,
.path = std.mem.trim(u8, fields.rest(), " "),
};
}
var global_counter: u32 = 7;
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;
// Файлы procfs ядро собирает в момент чтения, stat показывает у них
// нулевой размер. Поэтому читаем потоком до конца, а не «весь файл по размеру».
var file = try std.Io.Dir.cwd().openFile(init.io, "/proc/self/maps", .{});
defer file.close(init.io);
var read_buf: [4096]u8 = undefined;
var reader = file.readerStreaming(init.io, &read_buf);
const text = try reader.interface.allocRemaining(init.arena.allocator(), .limited(1 << 20));
// Три адреса, про которые мы заранее знаем, где они обязаны лежать.
var local: u32 = 0;
const probes = [_]struct { name: []const u8, address: u64 }{
.{ .name = "функция main", .address = @intFromPtr(&main) },
.{ .name = "глобальная переменная", .address = @intFromPtr(&global_counter) },
.{ .name = "локальная переменная", .address = @intFromPtr(&local) },
};
local = global_counter;
var total_kb: u64 = 0;
var lines = std.mem.tokenizeScalar(u8, text, '\n');
while (lines.next()) |line| {
const region = parseLine(line) orelse continue;
const kb = (region.end - region.start) / 1024;
total_kb += kb;
try out.print("{x:0>12} {d:>8} КБ {s} {s}\n", .{ region.start, kb, region.perms, region.path });
for (probes) |probe| {
if (probe.address >= region.start and probe.address < region.end) {
try out.print(" ^ здесь {s}, адрес {x}\n", .{ probe.name, probe.address });
}
}
}
try out.print("всего адресов: {d} КБ\n", .{total_kb});
try out.flush();
}
Одна ловушка спрятана в чтении файла. Файлы в /proc не лежат на диске, ядро сочиняет их текст в момент чтения, и stat честно отвечает, что размер у них нулевой. std.Io.Dir.readFileAlloc в Zig 0.16 верит размеру и возвращает пустой срез без всякой ошибки. Поэтому мы открываем файл и читаем его потоком, readerStreaming плюс allocRemaining, пока ядро не скажет, что текст кончился. Запомни этот приём: он понадобится каждый раз, когда ты полезешь в /proc или /sys.
Запускаем на Linux 7.0, aarch64 (контейнер Docker на Mac с процессором Apple, без эмуляции), сборка отладочная:
$ zig build-exe maps.zig && ./maps
000001000000 292 КБ r--p /tmp/p/maps
000001058000 1264 КБ r-xp /tmp/p/maps
^ здесь функция main, адрес 11515f4
0000011a3000 4 КБ rw-p /tmp/p/maps
0000011b3000 24 КБ rw-p /tmp/p/maps
^ здесь глобальная переменная, адрес 11b8138
0000011b9000 16 КБ rw-p
ffffa733f000 644 КБ rw-p
ffffa73e9000 2780 КБ rw-p
ffffa76a5000 260 КБ rw-p
ffffa76e6000 16 КБ r--p [vvar]
ffffa76ea000 8 КБ r-xp [vdso]
ffffcf4d2000 132 КБ rw-p [stack]
^ здесь локальная переменная, адрес ffffcf4f17d4
всего адресов: 5440 КБ
Прочитаем сверху вниз.
Первые четыре строки это сам исполняемый файл, и они один в один повторяют сегменты PT_LOAD, которые ты видел через zt readelf: константы и заголовки (r--), код (r-x), данные (rw-). Функция main попала в область с x, глобальная переменная в область с w, и ни одна область не имеет w и x сразу. Адрес 0x1000000 это выбор компоновщика Zig; у программ, собранных gcc, ты привык видеть 0x400000 или случайный адрес вида 0x5555... у позиционно-независимых файлов.
Пятая строка, 16 КБ rw-p без имени вплотную за данными программы, это .bss: глобальные переменные без начального значения. В файле они места не занимают, поэтому файла за областью нет, а страницы при первом обращении приходят заполненными нулями.
Три безымянные области в районе ffffa7... это память, которую рантайм Zig взял у ядра через mmap: отладочная сборка держит там информацию для трасс стека, а наш allocRemaining положил туда прочитанный текст. Строки [heap] нет совсем, и это не ошибка: [heap] ядро подписывает область, выращенную вызовом brk, а стандартная библиотека Zig его не зовёт, вся её память идёт из mmap. У программы на C с glibc куча появится после первого же malloc.
[vdso] и [vvar] подложило ядро, программа о них не просила. Это крошечная разделяемая библиотека с быстрыми версиями clock_gettime и gettimeofday, которые читают время из страницы [vvar], вообще не переходя в ядро. Вспомни цену системного вызова из урока про исключения: для функции, которую зовут миллионы раз в секунду, это заметная экономия.
[stack] это стек главного потока, 132 КБ. Лимит стека обычно 8 МБ, но область под него ядро заводит маленькую и наращивает вниз по мере сбоев страниц под её нижней границей.
Последняя строка программы: всего 5,4 МБ адресов из 256 ТБ возможных на aarch64 с 48-битной адресацией. Всё остальное дыры. Между стеком и отображениями, между программой и отображениями нет ни одной страницы, и именно поэтому разыменование случайного числа почти всегда кончается сбоем, а не тихой порчей данных.
Та же программа под эмулятором
Курс опирается на x86-64, и Linux-часть раздела мы гоняем в контейнере linux/amd64. На Mac с процессором Apple такой контейнер исполняется через Rosetta, транслятор машинного кода x86-64 в ARM64. Для программы он прозрачен, а вот в карте памяти виден отлично:
000001000000 4 КБ r--p /tmp/p/maps
000001001000 140 КБ r--p /tmp/p/maps
000001024000 2212 КБ r-xp /tmp/p/maps
^ здесь функция main, адрес 11ea7c0
00000124d000 392 КБ rw-p /tmp/p/maps
^ здесь глобальная переменная, адрес 1293f00
0000012af000 4 КБ rw-p
7fffff43f000 644 КБ rw-p
7fffff4e9000 2780 КБ rw-p
7fffff7be000 260 КБ rw-p
7fffff7ff000 4 КБ ---p
7fffff800000 8192 КБ rw-p
^ здесь локальная переменная, адрес 7fffffffcfb0
800000000000 152 КБ r--p /mnt/rv/[rosetta]
800000026000 456 КБ r-xp /mnt/rv/[rosetta]
8000000a0000 1048 КБ rw-p /mnt/rv/[rosetta]
effff7dc2000 148 КБ rw-p
effff7de7000 4 КБ ---p
effff7de8000 16 КБ rw-p
effff7dec000 4 КБ ---p
effff7ded000 2092 КБ rw-p
effff7ff8000 131072 КБ rwxp
efffffff8000 529688 КБ rw-p
ffff9c4c4000 16 КБ r--p [vvar]
ffff9c4c8000 8 КБ r-xp [vdso]
ffffca70d000 132 КБ rw-p [stack]
всего адресов: 679468 КБ
Наша программа занимает первые десять строк, и стек, в котором лежит локальная переменная, здесь не [stack], а безымянные 8 МБ под адресом 0x800000000000 с сторожевой страницей ---p под ним: этот стек эмулятор сделал для гостевой программы сам. Настоящий [stack] внизу карты принадлежит эмулятору. Дальше идут три области файла [rosetta], то есть сам транслятор, и две огромные анонимные: 128 МБ с правами rwxp и 517 МБ rw-p. Первая это кэш оттранслированного кода (транслятор пишет туда машинный код и тут же его исполняет, вот тебе W и X вместе), вторая его рабочие данные. Итог: программа, которой нужно пять мегабайт, держит 664 МБ адресов.
Физической памяти под этими адресами почти нет: страницы, которых никто не касался, не существуют. Запомни число 664 МБ и то, что это только адреса. Во второй половине урока оно выстрелит.
Настоящей карты x86-64 без эмулятора у меня под рукой нет, хост курса это Mac. Если ты на Linux с процессором Intel или AMD, твоя карта будет похожа на первую, с aarch64, только адреса отображений начнутся с 7f..., а внизу добавится строка [vsyscall].
Виджет: откуда взялась каждая область
В виджете две готовые карты (статическая программа на Zig и та же программа, собранная с libc) и поле, куда можно вставить свою. Щёлкай по областям: справа написано, кто создал область (execve, загрузчик ld.so, brk, mmap или ядро), что за ней стоит и что значат её права. Сравни две карты и найди, что именно принесла с собой libc.
Обрати внимание на область библиотеки с правами r--p, которая стоит сразу за её кодом и называется в виджете RELRO. Загрузчик заполнил в ней таблицу адресов GOT, а потом снял право записи тем самым вызовом mprotect, до которого мы сейчас дойдём. Таблицу, через которую идут все вызовы библиотечных функций, после старта программы нельзя подменить даже при наличии в программе дыры с произвольной записью.
Что хранит ядро: mm_struct и vm_area_struct
Текст в /proc/<pid>/maps это распечатка структуры данных ядра. Книга рисует её так: у задачи (task_struct) есть указатель mm на mm_struct, которая описывает всё адресное пространство; в ней поле pgd с адресом таблицы страниц верхнего уровня и поле mmap с головой связного списка структур vm_area_struct, по одной на область.
Откроем актуальные исходники, include/linux/mm_types.h (ветка master, сентябрь 2026). Область выглядит так, я оставил главное:
struct vm_area_struct {
unsigned long vm_start; /* первый адрес области */
unsigned long vm_end; /* первый адрес за областью */
struct mm_struct *vm_mm; /* чьё это адресное пространство */
pgprot_t vm_page_prot; /* права, как их надо писать в PTE */
const vm_flags_t vm_flags; /* VM_READ, VM_WRITE, VM_EXEC, VM_SHARED и другие */
unsigned int vm_lock_seq; /* блокировка одной области */
struct anon_vma *anon_vma; /* анонимные страницы области */
const struct vm_operations_struct *vm_ops; /* в том числе обработчик сбоя */
unsigned long vm_pgoff; /* смещение в файле, в страницах */
struct file *vm_file; /* файл за областью или NULL */
/* ... */
};
Сопоставь с колонками maps: диапазон это vm_start и vm_end, права это vm_flags, смещение это vm_pgoff, умноженное на размер страницы, устройство, inode и путь достаются из vm_file. Ядро печатает ровно то, что хранит.
А вот адресное пространство:
struct mm_struct {
struct maple_tree mm_mt; /* области процесса */
pgd_t *pgd; /* таблица страниц верхнего уровня */
atomic_t mm_users; /* сколько потоков делят это пространство */
int map_count; /* число областей */
struct rw_semaphore mmap_lock;
unsigned long hiwater_rss; /* пик резидентной памяти */
unsigned long hiwater_vm; /* пик виртуальной памяти */
unsigned long total_vm; /* всего отображено страниц */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
/* ... */
};
pgd на месте: при переключении контекста ядро кладёт его в регистр CR3, и процессор начинает видеть другое адресное пространство. start_brk и brk это границы кучи, start_code и соседи приходят из сегментов ELF. На hiwater_rss и hiwater_vm поставь закладку: в /proc/<pid>/status они называются VmHWM и VmPeak, и через полчаса их будет читать твой zbox.
А поля mmap со списком областей нет. Вместо него mm_mt, maple tree. История такая. Связный список годится, чтобы перебрать области по порядку, но поиск области по адресу в нём линейный, а искать приходится на каждом сбое страницы, и у браузера или базы данных областей тысячи. Поэтому ядро много лет держало области сразу в двух структурах: в списке и в красно-чёрном дереве, плюс кэш последней найденной области. Три структуры надо было менять согласованно, под одной общей блокировкой mmap_lock, и на многопоточных программах она стала узким местом: потоки, которые всего лишь ловят сбои страниц, стояли в очереди за тем, кто зовёт mmap. В Linux 6.1 (декабрь 2022) и список, и красно-чёрное дерево, и кэш заменили одним maple tree: это B-дерево диапазонов с широкими узлами (до 16 диапазонов в листе, узел занимает 256 байт, ровно четыре линии кэша, привет уроку 40), которое можно читать без блокировки. На нём в 6.4 сделали следующий шаг, блокировки на отдельную область (поле vm_lock_seq выше): сбой страницы теперь блокирует одну область, а не всё адресное пространство.
Так что рисунок из книги со стрелочками vm_next устарел как реализация, но остался верным как модель: упорядоченный набор непересекающихся областей, по которому ищут адрес. Модель мы и будем использовать.
Три вопроса обработчика сбоя страницы
Теперь можно точно сказать, что происходит, когда MMU не смогла оттранслировать адрес A и управление попало в ядро.
- Адрес законен? Ядро ищет в дереве область, в которую попадает A. Не нашлось: процесс получает
SIGSEGVс кодомSEGV_MAPERR, “по этому адресу ничего не отображено”. - Доступ законен? Область нашлась, но программа писала, а в
vm_flagsнетVM_WRITE(или исполняла безVM_EXEC, или это пользовательский режим лезет в область ядра). СноваSIGSEGV, но код другой:SEGV_ACCERR, “область есть, прав нет”. - Иначе сбой честный. Адрес законен, доступ разрешён, просто страницы нет в памяти или PTE ещё не заполнена. Ядро находит свободный кадр (или выбирает жертву), читает страницу из файла либо заполняет нулями, правит PTE и возвращается. Инструкция перезапускается и проходит. Программа ничего не заметила.
Подавляющее большинство сбоев страниц третьего вида, и это штатная работа, а не авария. Два первых ответа это и есть защита памяти, увиденная со стороны ядра. Их различие ты запрограммируешь сам в задаче этого урока.
mprotect: меняем права на ходу
Права области не приговор. Вызов mprotect(addr, len, prot) меняет их у любого диапазона страниц своего процесса. Адрес обязан быть кратен размеру страницы, длина округляется вверх до целых страниц: защита бывает только постраничной, потому что биты прав живут в PTE, а PTE одна на страницу.
Поставим опыт. Возьмём страницу, запишем в неё, отнимем право записи, запишем ещё раз. Обычный исход это смерть от SIGSEGV. Но мы поставим обработчик, и не простой, а с флагом SA_SIGINFO из урока про сигналы: такой обработчик получает структуру siginfo_t, а в ней адрес, на котором программа споткнулась. Обработчик вернёт странице право записи и просто выйдет.
// Страница, у которой отняли право записи, и пойманное нарушение защиты.
// Сборка: zig build-exe protect.zig -lc
const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
const c = std.c;
var page: []align(std.heap.page_size_min) u8 = &.{};
var faults: std.atomic.Value(u32) = .init(0);
var fault_address: std.atomic.Value(usize) = .init(0);
var fault_signal: std.atomic.Value(u32) = .init(0);
/// Обработчик получает адрес, на котором споткнулась программа. Он возвращает
/// странице право записи и выходит. Ядро перезапускает ту же самую инструкцию,
/// и во второй раз она проходит. mprotect не числится в списке
/// async-signal-safe у POSIX, но на Linux и macOS это голый системный вызов.
fn onFault(sig: posix.SIG, info: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
const address: usize = switch (builtin.os.tag) {
.linux => @intFromPtr(info.fields.sigfault.addr),
else => @intFromPtr(info.addr),
};
fault_address.store(address, .seq_cst);
fault_signal.store(@intFromEnum(sig), .seq_cst);
// Сбой не на нашей странице чинить нечем: это настоящая ошибка.
const base = @intFromPtr(page.ptr);
if (address < base or address >= base + page.len) c._exit(70);
_ = faults.fetchAdd(1, .seq_cst);
_ = c.mprotect(@ptrCast(page.ptr), page.len, .{ .READ = true, .WRITE = true });
}
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_size = std.heap.pageSize();
page = try posix.mmap(null, page_size, .{ .READ = true, .WRITE = true }, .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0);
defer posix.munmap(page);
page[0] = 41;
try out.print("страница {x}, размер {d}, записали {d}\n", .{ @intFromPtr(page.ptr), page_size, page[0] });
if (c.mprotect(@ptrCast(page.ptr), page.len, .{ .READ = true }) != 0) return error.MprotectFailed;
try out.print("право записи снято, читать можно: {d}\n", .{page[0]});
// Linux шлёт за нарушение прав SIGSEGV, macOS шлёт SIGBUS. Ловим оба.
const action: posix.Sigaction = .{
.handler = .{ .sigaction = onFault },
.mask = posix.sigemptyset(),
.flags = posix.SA.SIGINFO,
};
posix.sigaction(.SEGV, &action, null);
posix.sigaction(.BUS, &action, null);
// Запись идёт через volatile-указатель, чтобы оптимизатор её не выбросил
// и не переставил: нам нужна ровно одна инструкция записи в этом месте.
const cell: *volatile u8 = &page[100];
cell.* = 42;
try out.print("запись прошла со второй попытки: {d}\n", .{cell.*});
const sig: posix.SIG = @enumFromInt(fault_signal.load(.seq_cst));
try out.print("сбоев: {d}, сигнал {t}, адрес сбоя {x}, смещение в странице {d}\n", .{
faults.load(.seq_cst),
sig,
fault_address.load(.seq_cst),
fault_address.load(.seq_cst) - @intFromPtr(page.ptr),
});
try out.flush();
}
В std.posix Zig 0.16 обёртки для mprotect нет, поэтому берём std.c.mprotect и собираем с -lc. Linux 7.0, x86-64 (контейнер linux/amd64):
$ zig build-exe protect.zig -lc && ./protect
страница 7fffff7c9000, размер 4096, записали 41
право записи снято, читать можно: 41
запись прошла со второй попытки: 42
сбоев: 1, сигнал SEGV, адрес сбоя 7fffff7c9064, смещение в странице 100
Проследи, что случилось на строке cell.* = 42. Процессор начал выполнять инструкцию записи, MMU нашла PTE, увидела сброшенный бит R/W и возбудила сбой страницы. Ядро нашло область (адрес законен), увидело, что VM_WRITE в ней нет (доступ незаконен), и доставило SIGSEGV с адресом 0x7fffff7c9064: это база страницы плюс 100 (в шестнадцатеричной записи 64), ровно тот байт, в который мы писали. Наш обработчик вернул право записи и вышел. А дальше сработало свойство сбоев, которое мы разбирали в уроке про исключения: сбой возвращает управление не на следующую инструкцию, а на ту же самую. Запись выполнилась второй раз, теперь успешно. Программа печатает 42 и один сбой.
Это не фокус ради фокуса. На связке “снять права, поймать сбой, сделать работу, вернуть права, перезапустить инструкцию” построена куча настоящих механизмов:
- Сторожевые страницы. Страница без прав под стеком потока превращает переполнение стека из тихой порчи чужой памяти в немедленный сбой. Так делают и ядро, и
pthread, и рантаймы языков. Ты видел такие строки---pв карте под Rosetta. - RELRO. Загрузчик заполняет GOT и снимает с неё право записи.
- W^X в JIT. Компилятор пишет машинный код в страницу
rw-, потом переключает её вr-x. Этим мы займёмся в уроке 56 на своёмzl. - Барьеры записи в сборщиках мусора. Сборщик снимает право записи со страниц старого поколения, и первая же запись в такую страницу сообщает ему через сбой, что страницу надо пересмотреть.
- Копирование при записи. Ядро само снимает право записи со страниц после
fork, а в обработчике сбоя снимает копию. Это тоже тема урока 56.
Одна оговорка про обработчик. mprotect нет в списке async-signal-safe функций POSIX, том самом списке из урока про сигналы. На Linux и macOS это тонкая обёртка над системным вызовом без внутреннего состояния, и звать её из обработчика на практике безопасно, так делают все рантаймы из списка выше. Но знать, что ты вышел за букву стандарта, надо.
И вторая: mprotect на кусок посреди области делит область. У структуры vm_area_struct права одни на всю область, поэтому ядро режет одну область на три (до, сам кусок, после), и в maps появляются две новые строки. Вернёшь права, соседи с одинаковыми свойствами сольются обратно. Это первое домашнее задание.
На macOS
Каталога /proc в macOS нет, и maps.zig там упадёт с FileNotFound на первой же строке. Ядро XNU хранит те же сведения (область у него называется vm_map_entry), но отдаёт их не текстом, а через вызовы Mach вроде mach_vm_region. Готовый инструмент поверх них называется vmmap:
$ vmmap $$
Virtual Memory Map of process 84082 (zsh)
VM page size: 16384 bytes
==== Non-writable regions for process 84082
REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD REGION DETAIL
__TEXT 104518000-1045a0000 [ 544K 512K 0K 0K] r-x/r-x SM=COW /bin/zsh
__DATA_CONST 1045a0000-1045a4000 [ 16K 16K 0K 0K] r--/rw- SM=COW /bin/zsh
MALLOC guard page 104adc000-104ae0000 [ 16K 0K 0K 0K] ---/rwx SM=COW
STACK GUARD 1678e8000-16b0ec000 [ 56.0M 0K 0K 0K] ---/rwx SM=NUL stack guard for thread 0
__TEXT 182e9c000-182eef000 [ 332K 332K 0K 0K] r-x/r-x SM=COW /usr/lib/libobjc.A.dylib
Те же области, те же права (через косую черту максимально допустимые: выше них mprotect права не поднимет), тот же режим разделения SM=COW вместо буквы p. Бросаются в глаза три отличия. Страница на Mac с процессором Apple 16 КБ, а не 4: protect.zig честно печатает там размер 16384. Сторожевые страницы подписаны, и сторож под стеком занимает 56 МБ адресов. И колонки резидентной и грязной памяти есть прямо в карте, на Linux за ними надо идти в smaps.
protect.zig на macOS работает, но сигнал другой:
страница 105328000, размер 16384, записали 41
право записи снято, читать можно: 41
запись прошла со второй попытки: 42
сбоев: 1, сигнал BUS, адрес сбоя 105328064, смещение в странице 100
За нарушение прав на существующей странице XNU шлёт SIGBUS, а SIGSEGV оставляет для адресов, по которым ничего не отображено. Переносимая программа ловит оба, поэтому в листинге два вызова sigaction.
Всё, что в этом уроке читает /proc или пишет в /sys/fs/cgroup, запускай в контейнере. Исходники копируем внутрь, потому что каталог, подключённый с хоста, отдаёт файлы с задержкой, и кэш Zig на нём ломается:
docker run --rm --platform linux/amd64 -v "$PWD":/w:ro debian:bookworm sh -c '
mkdir /tmp/p && cp -r /w/. /tmp/p/ && cd /tmp/p && ./maps'
Собирать под Linux можно прямо на Mac, Zig кросс-компилятор: zig build-exe maps.zig -target x86_64-linux. Для protect.zig с -lc цель пишется как x86_64-linux-gnu или x86_64-linux-musl.
Шаг проекта zt: команда pmap
В системе есть готовый инструмент, который читает /proc/<pid>/maps и печатает карту по-человечески: pmap из пакета procps. По традиции zt мы пишем его сами и сверяем вывод с настоящим байт в байт.
$ pmap 189
189: sleep 100
0000aaaab6780000 32K r-x-- sleep
0000aaaab679f000 4K r---- sleep
0000aaaab67a0000 4K rw--- sleep
0000aaaaf64b6000 132K rw--- [ anon ]
0000ffffbc120000 1584K r-x-- libc.so.6
...
0000ffffddf48000 132K rw--- [ stack ]
total 2240K
Формат нехитрый, но с характером. Адрес в 16 шестнадцатеричных цифр, размер в килобайтах в колонке шириной 6 с буквой K, права в пять символов, от файла только имя без каталогов. Пять символов прав вместо четырёх достались от Solaris: четвёртый это s у разделяемой области и прочерк у частной, пятый на Linux всегда прочерк. Стек подписан как [ stack ] с двумя пробелами впереди, а всё остальное без файла (куча, [vdso], [vvar], безымянный mmap) для pmap просто [ anon ].
src/pmap.zig
Модуль целиком чистый: текст на входе, текст на выходе, ни одного системного вызова. Поэтому он проверяется на любой машине по снятым заранее файлам, и на Mac тоже.
//! `zt pmap`: карта памяти живого процесса по `/proc/<pid>/maps`.
//!
//! Разбор текста и печать это чистый код, он проверяется на любой машине
//! по снятому заранее файлу. Сам `/proc` есть только в Linux, его читает
//! `main.zig`.
const std = @import("std");
/// Одна строка `/proc/<pid>/maps`, то есть одна область виртуальной памяти:
///
/// 7f1c2a600000-7f1c2a628000 r--p 00000000 08:01 1835 /usr/lib/libc.so.6
/// начало конец права смещение устр. inode путь
pub const Region = struct {
start: u64,
/// Первый адрес за областью.
end: u64,
/// Четыре символа: `r`, `w`, `x` и `p` (частная, копирование при записи)
/// либо `s` (разделяемая).
perms: [4]u8,
/// С какого байта файла начинается область. Для анонимной памяти ноль.
offset: u64,
inode: u64,
/// Путь к файлу, псевдоимя вроде `[stack]` или пустая строка у анонимной памяти.
/// Срез смотрит в исходный текст.
path: []const u8,
pub fn size(region: Region) u64 {
return region.end - region.start;
}
pub fn contains(region: Region, address: u64) bool {
return address >= region.start and address < region.end;
}
/// За областью стоит настоящий файл на диске.
pub fn isFile(region: Region) bool {
return region.path.len > 0 and region.path[0] == '/';
}
};
pub const ParseError = error{BadMapsLine};
pub fn parseLine(line: []const u8) ParseError!Region {
// Первые пять полей разделены пробелами. Путь берём как остаток строки:
// в нём самом пробелы законны, а ядро дописывает к удалённым файлам ` (deleted)`.
var rest = line;
const range = nextField(&rest) orelse return error.BadMapsLine;
const perms = nextField(&rest) orelse return error.BadMapsLine;
const offset = nextField(&rest) orelse return error.BadMapsLine;
_ = nextField(&rest) orelse return error.BadMapsLine; // устройство, старший:младший
const inode = nextField(&rest) orelse return error.BadMapsLine;
const dash = std.mem.indexOfScalar(u8, range, '-') orelse return error.BadMapsLine;
if (perms.len != 4) return error.BadMapsLine;
return .{
.start = std.fmt.parseInt(u64, range[0..dash], 16) catch return error.BadMapsLine,
.end = std.fmt.parseInt(u64, range[dash + 1 ..], 16) catch return error.BadMapsLine,
.perms = perms[0..4].*,
.offset = std.fmt.parseInt(u64, offset, 16) catch return error.BadMapsLine,
.inode = std.fmt.parseInt(u64, inode, 10) catch return error.BadMapsLine,
.path = std.mem.trim(u8, rest, " "),
};
}
fn nextField(rest: *[]const u8) ?[]const u8 {
const trimmed = std.mem.trimStart(u8, rest.*, " ");
if (trimmed.len == 0) return null;
const end = std.mem.indexOfScalar(u8, trimmed, ' ') orelse trimmed.len;
rest.* = trimmed[end..];
return trimmed[0..end];
}
/// Весь файл в список областей. Ядро отдаёт их по возрастанию адресов.
pub fn parse(gpa: std.mem.Allocator, text: []const u8) (ParseError || std.mem.Allocator.Error)![]Region {
var regions: std.ArrayList(Region) = .empty;
errdefer regions.deinit(gpa);
var lines = std.mem.tokenizeScalar(u8, text, '\n');
while (lines.next()) |line| try regions.append(gpa, try parseLine(line));
return regions.toOwnedSlice(gpa);
}
/// Вывод как у `pmap` из procps: адрес, размер в килобайтах, права,
/// отображение, в конце сумма. `command` это командная строка процесса.
pub fn print(out: *std.Io.Writer, pid: u32, command: []const u8, regions: []const Region) std.Io.Writer.Error!void {
try out.print("{d}: {s}\n", .{ pid, command });
var total_kb: u64 = 0;
for (regions) |region| {
const kb = region.size() / 1024;
total_kb += kb;
try out.print("{x:0>16} {d:>6}K {s} {s}\n", .{ region.start, kb, pmapPerms(region.perms), mappingName(region) });
}
try out.print(" total {d:>16}K\n", .{total_kb});
}
/// `r-xp` из maps в `r-x--` как у pmap: четвёртая колонка это `s` у разделяемой
/// области, пятая осталась от Solaris и на Linux всегда прочерк.
fn pmapPerms(perms: [4]u8) [5]u8 {
return .{ perms[0], perms[1], perms[2], if (perms[3] == 's') 's' else '-', '-' };
}
/// От файла pmap показывает только имя. Стек подписан, всё остальное
/// (куча, `[vdso]`, `[vvar]`, безымянный `mmap`) для него анонимная память.
fn mappingName(region: Region) []const u8 {
if (region.isFile()) return std.fs.path.basenamePosix(region.path);
if (std.mem.eql(u8, region.path, "[stack]")) return " [ stack ]";
return " [ anon ]";
}
/// Адрес в терминах файла: какой файл и какой его байт лежит по этому адресу.
pub const FileAddress = struct {
path: []const u8,
offset: u64,
};
/// Переводит адрес живого процесса в пару (файл, смещение в файле).
/// Это первая половина символизации адреса внутри разделяемой библиотеки:
/// библиотека каждый раз грузится по новому адресу, а смещение в файле
/// постоянно. Вторая половина уже дело читателя ELF: по сегментам `PT_LOAD`
/// перевести смещение в адрес из таблицы символов и найти символ через `nm`.
/// null значит, что адрес не отображён или за ним нет файла.
pub fn locate(regions: []const Region, address: u64) ?FileAddress {
for (regions) |region| {
if (!region.contains(address)) continue;
if (!region.isFile()) return null;
return .{ .path = region.path, .offset = region.offset + (address - region.start) };
}
return null;
}
test "строка анонимной области: путь пустой" {
const region = try parseLine("ffffbc2c2000-ffffbc2cf000 rw-p 00000000 00:00 0 ");
try std.testing.expectEqual(0xffffbc2c2000, region.start);
try std.testing.expectEqualStrings("", region.path);
try std.testing.expect(!region.isFile());
}
parseLine ты уже видел в уроке про сигналы, здесь она переиспользуется без изменений. По сравнению с упрощённой версией из maps.zig она строже: отдельная ошибка BadMapsLine вместо молчаливого пропуска, права ровно четыре символа, inode десятичный. Поля откусываются по одному функцией nextField, а не токенизатором, по одной причине: путь это не шестое поле, а весь остаток строки. В путях бывают пробелы, а к файлу, который удалили, пока он был отображён, ядро дописывает (deleted). Срез path смотрит прямо в исходный текст, ничего не копируется, поэтому текст maps должен жить, пока живут области.
В конце файла функция locate, и её ты уже знаешь: вместе с Region и parseLine она появилась в уроке про сигналы, когда профилировщику zt prof понадобилось называть адреса внутри libc. Напомню, зачем она. Адрес внутри libc, например 0xffffbc14a4f0, в таблице символов libc не найти: библиотека каждый раз грузится по новому адресу, а в её таблице символов адреса посчитаны от нуля. locate находит область, в которую попал адрес, и возвращает пару: путь к файлу и смещение внутри файла, то есть offset области плюс расстояние от её начала. Смещение в файле от запуска к запуску не меняется, а дальше symbolize.zig по сегментам PT_LOAD переводит его в адрес из таблицы символов урока 42. Тогда файлу хватало разбора строки и locate. Сегодня к ним добавились parse и print, и pmap.zig дорос до самостоятельной подкоманды.
Подключение в main.zig
В main.zig три правки. В текст usage добавляется строка zt pmap <pid>, в цепочку разбора подкоманд новая ветка:
} else if (std.mem.eql(u8, command, "pmap")) {
try cmdPmap(init, gpa, out, rest);
И две новые функции. В начале файла понадобится const builtin = @import("builtin");, если его там ещё нет.
fn cmdPmap(
init: std.process.Init,
gpa: std.mem.Allocator,
out: *std.Io.Writer,
args: []const []const u8,
) !void {
if (builtin.os.tag != .linux) return fail(out, "zt pmap: работает только на Linux, нужен /proc\n");
if (args.len != 1) return fail(out, "zt pmap: нужен ровно один pid\n");
const pid = std.fmt.parseInt(u32, args[0], 10) catch return fail(out, "zt pmap: pid это число\n");
var path_buf: [64]u8 = undefined;
const maps_path = try std.fmt.bufPrint(&path_buf, "/proc/{d}/maps", .{pid});
const maps = readProcFile(init.io, gpa, maps_path) catch |err| {
try out.print("zt pmap: не могу прочитать {s} ({t}): процесса нет или он чужой\n", .{ maps_path, err });
return error.BadUsage;
};
const cmdline_path = try std.fmt.bufPrint(&path_buf, "/proc/{d}/cmdline", .{pid});
const cmdline = try readProcFile(init.io, gpa, cmdline_path);
// Аргументы в cmdline разделены нулевыми байтами, последний тоже закрыт нулём.
std.mem.replaceScalar(u8, cmdline, 0, ' ');
try zt.pmap.print(out, pid, std.mem.trimEnd(u8, cmdline, " "), try zt.pmap.parse(gpa, maps));
}
/// Файлы procfs ядро собирает в момент чтения, и `stat` показывает у них
/// нулевой размер. `readFileAlloc` верит размеру и возвращает пустой срез,
/// поэтому читаем потоково, до конца файла.
fn readProcFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) ![]u8 {
var file = try std.Io.Dir.cwd().openFile(io, path, .{});
defer file.close(io);
var buf: [4096]u8 = undefined;
var reader = file.readerStreaming(io, &buf);
return reader.interface.allocRemaining(gpa, file_limit) catch |err| switch (err) {
error.ReadFailed => return reader.err.?,
else => |e| return e,
};
}
cmdPmap проверяет систему при компиляции: builtin.os.tag это константа, и на macOS всё тело после первой строки компилятор выбросит. Имя команды для шапки берём из /proc/<pid>/cmdline: аргументы там разделены нулевыми байтами, мы меняем их на пробелы. readProcFile это тот же потоковый приём, что в maps.zig, с той же причиной в комментарии.
В src/root.zig добавь pub const pmap = @import("pmap.zig");, в build.zig допиши 54 в список project_steps.
Тесты шага
Данные для тестов сняты с настоящих процессов sleep 100 в Debian 12: рядом лежат cat /proc/<pid>/maps и вывод настоящего pmap <pid> из procps-ng 4.0.2. Первая пара с aarch64, вторая с x86-64 под Rosetta, в ней видно области эмулятора. Положи все четыре файла в tests/pmap/. Сначала пара с aarch64.
aaaab6780000-aaaab6788000 r-xp 00000000 00:f3 12792165 /usr/bin/sleep
aaaab679f000-aaaab67a0000 r--p 0000f000 00:f3 12792165 /usr/bin/sleep
aaaab67a0000-aaaab67a1000 rw-p 00010000 00:f3 12792165 /usr/bin/sleep
aaaaf64b6000-aaaaf64d7000 rw-p 00000000 00:00 0 [heap]
ffffbc120000-ffffbc2ac000 r-xp 00000000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6
ffffbc2ac000-ffffbc2bc000 ---p 0018c000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6
ffffbc2bc000-ffffbc2c0000 r--p 0018c000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6
ffffbc2c0000-ffffbc2c2000 rw-p 00190000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6
ffffbc2c2000-ffffbc2cf000 rw-p 00000000 00:00 0
ffffbc2d9000-ffffbc300000 r-xp 00000000 00:f3 12792492 /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1
ffffbc30b000-ffffbc30d000 rw-p 00000000 00:00 0
ffffbc30f000-ffffbc311000 rw-p 00000000 00:00 0
ffffbc311000-ffffbc315000 r--p 00000000 00:00 0 [vvar]
ffffbc315000-ffffbc317000 r-xp 00000000 00:00 0 [vdso]
ffffbc317000-ffffbc319000 r--p 0002e000 00:f3 12792492 /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1
ffffbc319000-ffffbc31b000 rw-p 00030000 00:f3 12792492 /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1
ffffddf48000-ffffddf69000 rw-p 00000000 00:00 0 [stack]
189: sleep 100
0000aaaab6780000 32K r-x-- sleep
0000aaaab679f000 4K r---- sleep
0000aaaab67a0000 4K rw--- sleep
0000aaaaf64b6000 132K rw--- [ anon ]
0000ffffbc120000 1584K r-x-- libc.so.6
0000ffffbc2ac000 64K ----- libc.so.6
0000ffffbc2bc000 16K r---- libc.so.6
0000ffffbc2c0000 8K rw--- libc.so.6
0000ffffbc2c2000 52K rw--- [ anon ]
0000ffffbc2d9000 156K r-x-- ld-linux-aarch64.so.1
0000ffffbc30b000 8K rw--- [ anon ]
0000ffffbc30f000 8K rw--- [ anon ]
0000ffffbc311000 16K r---- [ anon ]
0000ffffbc315000 8K r-x-- [ anon ]
0000ffffbc317000 8K r---- ld-linux-aarch64.so.1
0000ffffbc319000 8K rw--- ld-linux-aarch64.so.1
0000ffffddf48000 132K rw--- [ stack ]
total 2240K
Вторая пара, x86-64 под Rosetta:
555555554000-555555556000 r--p 00000000 00:96 29191245 /usr/bin/sleep
555555556000-55555555b000 r-xp 00002000 00:96 29191245 /usr/bin/sleep
55555555b000-55555555d000 r--p 00007000 00:96 29191245 /usr/bin/sleep
55555555d000-55555555e000 r--p 00009000 00:96 29191245 /usr/bin/sleep
55555555e000-55555555f000 rw-p 0000a000 00:96 29191245 /usr/bin/sleep
55555555f000-555555580000 rw-p 00000000 00:00 0
7fffff5e4000-7fffff60a000 r--p 00000000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff60a000-7fffff760000 r-xp 00026000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff760000-7fffff7b3000 r--p 0017c000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7b3000-7fffff7b7000 r--p 001cf000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7b7000-7fffff7b9000 rw-p 001d3000 00:96 29191734 /usr/lib/x86_64-linux-gnu/libc.so.6
7fffff7b9000-7fffff7c8000 rw-p 00000000 00:00 0
7fffff7ca000-7fffff7cb000 ---p 00000000 00:00 0
7fffff7cb000-7ffffffcb000 rw-p 00000000 00:00 0
7ffffffcb000-7ffffffcc000 r--p 00000000 00:96 29191716 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7ffffffcc000-7fffffff2000 r-xp 00001000 00:96 29191716 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffff2000-7fffffffc000 r--p 00027000 00:96 29191716 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffffc000-7fffffffe000 r--p 00031000 00:96 29191716 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
7fffffffe000-800000000000 rw-p 00033000 00:96 29191716 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
800000000000-800000026000 r--p 00000000 00:25 2 /mnt/rv/[rosetta]
800000026000-800000098000 r-xp 00026000 00:25 2 /mnt/rv/[rosetta]
8000000a0000-8000001a6000 rw-p 000a0000 00:25 2 /mnt/rv/[rosetta]
effff7dcf000-effff7de7000 rw-p 00000000 00:00 0
effff7de7000-effff7de8000 ---p 00000000 00:00 0
effff7de8000-effff7dec000 rw-p 00000000 00:00 0
effff7dec000-effff7ded000 ---p 00000000 00:00 0
effff7ded000-effff7ff8000 rw-p 00000000 00:00 0
effff7ff8000-efffffff8000 rwxp 00000000 00:00 0
efffffff8000-f0002053e000 rw-p 00000000 00:00 0
ffffa5d23000-ffffa5d27000 r--p 00000000 00:00 0 [vvar]
ffffa5d27000-ffffa5d29000 r-xp 00000000 00:00 0 [vdso]
ffffc8bf4000-ffffc8c15000 rw-p 00000000 00:00 0 [stack]
189: /usr/bin/sleep sleep 100
0000555555554000 8K r---- sleep
0000555555556000 20K r-x-- sleep
000055555555b000 8K r---- sleep
000055555555d000 4K r---- sleep
000055555555e000 4K rw--- sleep
000055555555f000 132K rw--- [ anon ]
00007fffff5e4000 152K r---- libc.so.6
00007fffff60a000 1368K r-x-- libc.so.6
00007fffff760000 332K r---- libc.so.6
00007fffff7b3000 16K r---- libc.so.6
00007fffff7b7000 8K rw--- libc.so.6
00007fffff7b9000 60K rw--- [ anon ]
00007fffff7ca000 4K ----- [ anon ]
00007fffff7cb000 8192K rw--- [ anon ]
00007ffffffcb000 4K r---- ld-linux-x86-64.so.2
00007ffffffcc000 152K r-x-- ld-linux-x86-64.so.2
00007fffffff2000 40K r---- ld-linux-x86-64.so.2
00007fffffffc000 8K r---- ld-linux-x86-64.so.2
00007fffffffe000 8K rw--- ld-linux-x86-64.so.2
0000800000000000 152K r---- [rosetta]
0000800000026000 456K r-x-- [rosetta]
00008000000a0000 1048K rw--- [rosetta]
0000effff7dcf000 96K rw--- [ anon ]
0000effff7de7000 4K ----- [ anon ]
0000effff7de8000 16K rw--- [ anon ]
0000effff7dec000 4K ----- [ anon ]
0000effff7ded000 2092K rw--- [ anon ]
0000effff7ff8000 131072K rwx-- [ anon ]
0000efffffff8000 529688K rw--- [ anon ]
0000ffffa5d23000 16K r---- [ anon ]
0000ffffa5d27000 8K r-x-- [ anon ]
0000ffffc8bf4000 132K rw--- [ stack ]
total 675304K
Вернись к первой паре и посмотри на вторую строку libc, ---p на 64 КБ. Это не сторожевая страница, а дыра внутри библиотеки: сегменты кода и данных в файле выровнены на 64 КБ (на aarch64 страница бывает и такой), загрузчик отобразил файл одним куском, а потом снял все права с промежутка, чтобы случайный указатель туда не попал. Снова mprotect.
//! Шаг 54: `zt pmap`, карта памяти процесса по `/proc/<pid>/maps`.
//!
//! Тексты сняты с настоящих процессов `sleep 100` в Debian 12 (Linux 7.0):
//! `cat /proc/<pid>/maps` и рядом вывод `pmap <pid>` из procps-ng 4.0.2.
//! Наш вывод обязан совпасть с ним байт в байт. Первая пара с aarch64,
//! вторая с x86-64 под эмулятором Rosetta: в ней видно области самого эмулятора.
const std = @import("std");
const zt = @import("zt");
const pmap = zt.pmap;
const sleep_maps = @embedFile("pmap/sleep.maps.txt");
const sleep_pmap = @embedFile("pmap/sleep.pmap.txt");
const rosetta_maps = @embedFile("pmap/sleep_rosetta.maps.txt");
const rosetta_pmap = @embedFile("pmap/sleep_rosetta.pmap.txt");
test "строка с файлом" {
const region = try pmap.parseLine("ffffbc2bc000-ffffbc2c0000 r--p 0018c000 00:f3 12792523 /usr/lib/aarch64-linux-gnu/libc.so.6");
try std.testing.expectEqual(0xffffbc2bc000, region.start);
try std.testing.expectEqual(0xffffbc2c0000, region.end);
try std.testing.expectEqual(16 * 1024, region.size());
try std.testing.expectEqualStrings("r--p", ®ion.perms);
try std.testing.expectEqual(0x18c000, region.offset);
try std.testing.expectEqual(12792523, region.inode);
try std.testing.expectEqualStrings("/usr/lib/aarch64-linux-gnu/libc.so.6", region.path);
try std.testing.expect(region.isFile());
}
test "псевдоимя и путь с пробелами" {
const stack = try pmap.parseLine("ffffddf48000-ffffddf69000 rw-p 00000000 00:00 0 [stack]");
try std.testing.expectEqualStrings("[stack]", stack.path);
try std.testing.expect(!stack.isFile());
const deleted = try pmap.parseLine("7f00a000-7f00b000 r-xs 00001000 08:01 42 /tmp/my lib.so (deleted)");
try std.testing.expectEqualStrings("/tmp/my lib.so (deleted)", deleted.path);
try std.testing.expectEqual('s', deleted.perms[3]);
}
test "битые строки" {
try std.testing.expectError(error.BadMapsLine, pmap.parseLine(""));
try std.testing.expectError(error.BadMapsLine, pmap.parseLine("ffff-zzzz r--p 0 00:00 0"));
try std.testing.expectError(error.BadMapsLine, pmap.parseLine("1000-2000 r-- 0 00:00 0"));
try std.testing.expectError(error.BadMapsLine, pmap.parseLine("1000-2000 r--p 0"));
}
test "весь файл: 17 областей, по возрастанию и без наложений" {
const gpa = std.testing.allocator;
const regions = try pmap.parse(gpa, sleep_maps);
defer gpa.free(regions);
try std.testing.expectEqual(17, regions.len);
for (regions[0 .. regions.len - 1], regions[1..]) |left, right| {
try std.testing.expect(left.end <= right.start);
}
}
fn expectSameAsProcps(maps: []const u8, expected: []const u8) !void {
const gpa = std.testing.allocator;
const regions = try pmap.parse(gpa, maps);
defer gpa.free(regions);
// Первая строка эталона это `pid: команда`, берём оба значения из неё.
const header = expected[0..std.mem.indexOfScalar(u8, expected, '\n').?];
const colon = std.mem.indexOfScalar(u8, header, ':').?;
const pid = try std.fmt.parseInt(u32, header[0..colon], 10);
const command = std.mem.trimStart(u8, header[colon + 1 ..], " ");
var out: std.Io.Writer.Allocating = .init(gpa);
defer out.deinit();
try pmap.print(&out.writer, pid, command, regions);
try std.testing.expectEqualStrings(expected, out.written());
}
test "вывод совпадает с pmap из procps, aarch64" {
try expectSameAsProcps(sleep_maps, sleep_pmap);
}
test "вывод совпадает с pmap из procps, x86-64 под Rosetta" {
try expectSameAsProcps(rosetta_maps, rosetta_pmap);
}
test "locate: адрес внутри libc это смещение в её файле" {
const gpa = std.testing.allocator;
const regions = try pmap.parse(gpa, sleep_maps);
defer gpa.free(regions);
// Область кода libc начинается с нулевого смещения.
const code = pmap.locate(regions, 0xffffbc120000 + 0x2a4f0).?;
try std.testing.expectEqualStrings("/usr/lib/aarch64-linux-gnu/libc.so.6", code.path);
try std.testing.expectEqual(0x2a4f0, code.offset);
// Область данных отображена не с начала файла: смещение области прибавляется.
const data = pmap.locate(regions, 0xffffbc2c0000 + 0x10).?;
try std.testing.expectEqual(0x190000 + 0x10, data.offset);
}
test "locate: стек, куча, дыра между областями и край области" {
const gpa = std.testing.allocator;
const regions = try pmap.parse(gpa, sleep_maps);
defer gpa.free(regions);
try std.testing.expectEqual(null, pmap.locate(regions, 0xffffddf48000)); // [stack]
try std.testing.expectEqual(null, pmap.locate(regions, 0xaaaaf64b6000)); // [heap]
try std.testing.expectEqual(null, pmap.locate(regions, 0x1000)); // не отображено
// Конец области в неё уже не входит: aaaab6788000 это первый байт за кодом sleep.
try std.testing.expect(pmap.locate(regions, 0xaaaab6787fff) != null);
try std.testing.expectEqual(null, pmap.locate(regions, 0xaaaab6788000));
}
Главные два теста это expectSameAsProcps: наш вывод обязан совпасть с эталонным до байта, включая пробелы в [ anon ]. Тесты locate проверяют ровно ту арифметику, которую мы обсудили: у кода libc смещение области нулевое, у её данных 0x190000, и оно прибавляется; стек и куча файла не имеют; конец области в неё не входит.
$ zig build test -Dstep=54 --summary all
Build Summary: 3/3 steps succeeded; 8/8 tests passed
Весь набор zt после шага: на macOS 159 тестов прошли и 3 пропущены (им нужен Linux), в контейнере linux/amd64 162 из 162.
Живой прогон
В контейнере linux/amd64, рядом запущен sleep 100:
$ zt pmap 386
386: /usr/bin/sleep sleep 100
0000555555554000 8K r---- sleep
0000555555556000 20K r-x-- sleep
000055555555b000 8K r---- sleep
000055555555d000 4K r---- sleep
000055555555e000 4K rw--- sleep
000055555555f000 132K rw--- [ anon ]
00007fffff5e4000 152K r---- libc.so.6
00007fffff60a000 1368K r-x-- libc.so.6
... ещё двадцать строк: libc, стек гостя, ld-linux, [rosetta] ...
0000effff7ff8000 131072K rwx-- [ anon ]
0000efffffff8000 529688K rw--- [ anon ]
0000ffffaaa54000 16K r---- [ anon ]
0000ffffaaa58000 8K r-x-- [ anon ]
0000ffffd27aa000 132K rw--- [ stack ]
total 675304K
С настоящим pmap 386 вывод совпал до байта. Программа sleep, которой нужно два мегабайта (на aarch64 без эмулятора итог 2240K, смотри файл выше), под Rosetta держит 660 МБ адресов. Вот оно снова, это число.
Ошибки тоже проверь руками:
$ zt pmap 999999
zt pmap: не могу прочитать /proc/999999/maps (FileNotFound): процесса нет или он чужой
$ echo $?
2
На macOS команда отвечает zt pmap: работает только на Linux, нужен /proc с тем же кодом 2.
Команда, которой сняты данные (подставь свой образ с Zig или собери zt под Linux заранее, на Mac это zig build -Dtarget=x86_64-linux):
docker run --rm --platform linux/amd64 -v "$PWD":/w:ro debian:bookworm sh -c '
apt-get update -qq; apt-get install -y -qq procps >/dev/null
sleep 100 &
sleep 0.3
cat /proc/$!/maps > /tmp/maps.txt; pmap $! > /tmp/pmap.txt
/w/zig-out/bin/zt pmap $! | diff - /tmp/pmap.txt && echo совпало'
Шаг проекта zbox: лимит памяти
Напомню, где мы оставили песочницу. zbox run <программа> из урока про процессы делает fork, в ребёнке execvp, в родителе wait4, и печатает одну строку JSON: код возврата или номер сигнала, время процессора, пиковый RSS из rusage. В уроке про сигналы добавился сторож watchdog.zig: флаг --time-ms, будильник setitimer, SIGKILL всей группе процессов, ожидание через sigsuspend с заблокированными сигналами, чтобы не проспать SIGCHLD. Структура проекта: src/main.zig с командной строкой, модули в src/box/ (args.zig, run.zig, report.zig, watchdog.zig), src/root.zig, тесты по шагам в tests/.
Лимита времени мало. Программа, которая в цикле зовёт malloc и пишет в полученное, за секунду съест память машины, и пострадают все соседи. Раннеру нужен лимит памяти. Вопрос в том, что считать памятью, и после первой половины урока ты знаешь, что ответов минимум два:
- виртуальная память: сколько адресов заняли области процесса, сумма строк
pmap; - резидентная память: сколько физических кадров реально занято его страницами.
Под каждый ответ в Linux есть свой механизм.
setrlimit(RLIMIT_AS, ...) ограничивает размер адресного пространства. Лимит ставится на процесс, наследуется через fork и переживает execve, прав не требует. Когда mmap или brk попросят область, с которой сумма перевалит за лимит, вызов вернёт ENOMEM. Никто никого не убивает: программа получает ошибку и сама решает, что с ней делать.
cgroups v2 ограничивают физическую память группы процессов. Интерфейс файловый: каталог в /sys/fs/cgroup это группа, запись числа в файл memory.max это лимит, запись pid в cgroup.procs переносит процесс в группу. Считаются страницы, которых коснулись, причём у всей группы разом, со всеми потомками. Когда группа упирается в лимит, ядро сначала пытается освободить её страницы (сбросить файловый кэш, вытеснить в своп), а не вышло, зовёт OOM-убийцу, и тот убивает процесс группы сигналом SIGKILL. Факт убийства записывается в счётчик oom_kill файла memory.events. Нужны права на запись в каталог группы, то есть обычно root.
Мы сделаем оба и сравним.
Подопытная программа
Чтобы сравнение было честным, нужна программа, которая умеет занимать память двумя способами: только адреса и адреса вместе со страницами.
//! Подопытная программа для тестов лимита памяти.
//!
//! hog touch <МБ> занять память и записать в каждую страницу
//! hog reserve <МБ> только занять адреса, страницы не трогать
//!
//! Код возврата 3 значит, что система отказала в памяти.
const std = @import("std");
pub fn main(init: std.process.Init) !void {
const args = try init.minimal.args.toSlice(init.arena.allocator());
if (args.len != 3) std.process.exit(2);
const touch = std.mem.eql(u8, args[1], "touch");
const megabytes = try std.fmt.parseInt(usize, args[2], 10);
// page_allocator идёт прямо в mmap: один вызов, одна область. Берём
// rawAlloc, а не alloc: в отладочной сборке alloc заливает блок байтами
// 0xaa, то есть сам трогает каждую страницу, и резерв перестаёт быть резервом.
const len = megabytes * 1024 * 1024;
const ptr = std.heap.page_allocator.rawAlloc(len, .fromByteUnits(std.heap.pageSize()), @returnAddress()) orelse
std.process.exit(3);
const block = ptr[0..len];
if (touch) {
// Физическая страница появляется только при первой записи в неё.
var offset: usize = 0;
while (offset < block.len) : (offset += std.heap.pageSize()) block[offset] = 1;
}
// Даём сторожу zbox время снять показания из /proc.
try init.io.sleep(.fromMilliseconds(200), .awake);
var buf: [64]u8 = undefined;
var stdout = std.Io.File.stdout().writerStreaming(init.io, &buf);
try stdout.interface.print("{s} {d}\n", .{ args[1], megabytes });
try stdout.interface.flush();
}
hog reserve 512 просит у ядра область в полгигабайта и не трогает её. После первой половины урока ты знаешь, чего это стоит: одна vm_area_struct, одна строка в maps, ноль физических кадров. hog touch 512 пишет по байту в каждую страницу, каждая запись это сбой страницы третьего вида, и ядро выдаёт настоящий кадр.
Комментарий про rawAlloc оплачен отладкой. page_allocator.alloc в отладочной сборке заливает выданный блок байтами 0xaa, чтобы чтение неинициализированной памяти бросалось в глаза. Заливка трогает каждую страницу, и “резерв без касания” молча превращается в касание. rawAlloc заливки не делает.
src/box/memory.zig: чистая половина
Как и в прошлых шагах, всё, что можно проверить без системных вызовов, живёт отдельно: разбор текстов ядра, сборка путей, выбор причины завершения.
//! Чистая половина лимита памяти: разбор текстов, которые отдаёт ядро Linux,
//! и сборка путей внутри cgroups v2. Ни одного системного вызова, поэтому
//! тесты этого файла идут на любой машине.
const std = @import("std");
/// Две строки из `/proc/<pid>/status`, обе в килобайтах.
pub const Status = struct {
/// Пик виртуальной памяти: сколько адресов процесс занимал на максимуме.
/// Именно эту величину ограничивает `RLIMIT_AS`.
vm_peak_kb: ?u64 = null,
/// Пик резидентной памяти (high water mark): сколько страниц реально
/// лежало в физической памяти. Эту сторону ограничивает `memory.max`.
vm_hwm_kb: ?u64 = null,
};
/// Строки файла выглядят так: `VmPeak:\t 2460 kB`. У зомби и у потоков
/// ядра строк `Vm*` нет вовсе, тогда оба поля остаются null.
pub fn parseStatus(text: []const u8) Status {
var status: Status = .{};
var lines = std.mem.tokenizeScalar(u8, text, '\n');
while (lines.next()) |line| {
if (fieldKb(line, "VmPeak:")) |kb| status.vm_peak_kb = kb;
if (fieldKb(line, "VmHWM:")) |kb| status.vm_hwm_kb = kb;
}
return status;
}
fn fieldKb(line: []const u8, name: []const u8) ?u64 {
if (!std.mem.startsWith(u8, line, name)) return null;
var words = std.mem.tokenizeAny(u8, line[name.len..], " \t");
const number = words.next() orelse return null;
return std.fmt.parseInt(u64, number, 10) catch null;
}
/// Счётчики из `memory.events`. Каждая строка файла это `имя число`.
pub const Events = struct {
/// Сколько раз группа упиралась в `memory.max`.
max: u64 = 0,
/// Сколько раз ядро не смогло освободить память и позвало OOM-убийцу.
oom: u64 = 0,
/// Сколько процессов группы OOM-убийца действительно убил.
oom_kill: u64 = 0,
};
pub fn parseEvents(text: []const u8) Events {
var events: Events = .{};
var lines = std.mem.tokenizeScalar(u8, text, '\n');
while (lines.next()) |line| {
var words = std.mem.tokenizeScalar(u8, line, ' ');
const name = words.next() orelse continue;
const count = std.fmt.parseInt(u64, words.next() orelse continue, 10) catch continue;
// Сравниваем имя целиком: `oom` и `oom_kill` начинаются одинаково.
if (std.mem.eql(u8, name, "max")) events.max = count;
if (std.mem.eql(u8, name, "oom")) events.oom = count;
if (std.mem.eql(u8, name, "oom_kill")) events.oom_kill = count;
}
return events;
}
/// Файл с одним числом, например `memory.peak`: байты в килобайты.
pub fn parseBytesAsKb(text: []const u8) ?u64 {
const bytes = std.fmt.parseInt(u64, std.mem.trim(u8, text, " \n"), 10) catch return null;
return bytes / 1024;
}
/// Каталог группы одного запуска: `<root>/zbox-<pid>`. Pid самого zbox
/// уникален, пока zbox жив, так что параллельные запуски не столкнутся.
pub fn groupDir(buf: []u8, root: []const u8, zbox_pid: i32) error{NoSpaceLeft}![:0]u8 {
const trimmed = std.mem.trimEnd(u8, root, "/");
return std.fmt.bufPrintZ(buf, "{s}/zbox-{d}", .{ trimmed, zbox_pid });
}
/// Файл внутри каталога группы: `memory.max`, `cgroup.procs` и так далее.
pub fn groupFile(buf: []u8, dir: []const u8, name: []const u8) error{NoSpaceLeft}![:0]u8 {
return std.fmt.bufPrintZ(buf, "{s}/{s}", .{ dir, name });
}
/// Путь к состоянию процесса в procfs.
pub fn statusPath(buf: []u8, pid: i32) error{NoSpaceLeft}![:0]u8 {
return std.fmt.bufPrintZ(buf, "/proc/{d}/status", .{pid});
}
pub const Reason = enum { exited, signaled, time_limit, memory_limit };
/// Факты о запуске, по которым выбирается причина завершения.
pub const Verdict = struct {
signaled: bool = false,
timed_out: bool = false,
oom_kills: ?u64 = null,
};
/// Причина завершения одним словом. Порядок проверок это приоритет:
/// убийство по памяти достоверно (его записало ядро), поэтому оно первое.
pub fn reason(verdict: Verdict) Reason {
if ((verdict.oom_kills orelse 0) > 0) return .memory_limit;
if (verdict.timed_out) return .time_limit;
return if (verdict.signaled) .signaled else .exited;
}
VmPeak и VmHWM это те самые hiwater_vm и hiwater_rss из mm_struct, напечатанные в килобайтах. Первая величина про адреса, вторая про кадры: по ним видно обе стороны лимита.
В parseEvents имена сравниваются целиком, а не по началу: oom и oom_kill разные счётчики. Первый растёт, когда группа упёрлась в лимит и ядру нечего было освободить; второй, когда кого-то действительно убили.
Функция reason задаёт приоритет причин. Если ядро записало убийство по памяти, это достоверный факт, и он важнее всего остального: программа, убитая OOM-убийцей за миг до срабатывания будильника, превысила память, а не время.
src/box/memlimit.zig: системная половина
//! Системная половина лимита памяти: `setrlimit(RLIMIT_AS)`, группа
//! cgroups v2 и чтение `/proc/<pid>/status`. Разбор текстов лежит
//! в `memory.zig`, здесь только файлы и системные вызовы.
const std = @import("std");
const builtin = @import("builtin");
const c = std.c;
const memory = @import("memory.zig");
const is_linux = builtin.os.tag == .linux;
const bytes_per_mb = 1024 * 1024;
/// Зовётся в ребёнке между fork и execvp: `setrlimit` async-signal-safe.
/// Лимит переживает `execve` и наследуется всеми потомками программы.
/// Возвращает false, если ядро отказало.
pub fn limitAddressSpace(as_mb: u32) bool {
const bytes = @as(u64, as_mb) * bytes_per_mb;
// Мягкий и жёсткий пределы равны: программа не сможет поднять лимит сама.
const limit: c.rlimit = .{ .cur = bytes, .max = bytes };
return c.setrlimit(.AS, &limit) == 0;
}
/// Опрос `/proc/<pid>/status`. Строки `Vm*` исчезают, как только процесс
/// стал зомби, поэтому читать их после `wait4` поздно: сторож снимает
/// показания, пока программа жива, и помнит последние удачные.
pub const Sampler = struct {
pid: c.pid_t = 0,
last: memory.Status = .{},
pub fn sample(sampler: *Sampler) void {
if (!is_linux or sampler.pid <= 0) return;
var path_buf: [32]u8 = undefined;
const path = memory.statusPath(&path_buf, sampler.pid) catch return;
var text_buf: [4096]u8 = undefined;
const status = memory.parseStatus(readSmallFile(path, &text_buf) orelse return);
// Берём последнее значение, а не максимум. До `execve` в ребёнке
// живёт копия самого zbox, после него ядро заводит новое адресное
// пространство и считает пики заново: нам нужны пики программы.
if (status.vm_peak_kb != null) sampler.last = status;
}
};
pub const CgroupError = error{ CgroupUnsupported, CgroupUnavailable, PathTooLong };
/// Группа cgroups v2 на один запуск. Родитель создаёт каталог и пишет лимит
/// до fork, ребёнок записывает себя в `cgroup.procs` до `execvp`, родитель
/// после `wait4` читает счётчики и удаляет каталог.
pub const Cgroup = struct {
dir_buf: [dir_capacity]u8 = undefined,
dir_len: usize = 0,
procs_buf: [dir_capacity + 16]u8 = undefined,
procs_len: usize = 0,
const dir_capacity = 256;
pub fn create(group: *Cgroup, root: []const u8, mem_mb: u32) CgroupError!void {
if (!is_linux) return error.CgroupUnsupported;
const dir_path = memory.groupDir(&group.dir_buf, root, c.getpid()) catch return error.PathTooLong;
group.dir_len = dir_path.len;
const procs_path = memory.groupFile(&group.procs_buf, dir_path, "cgroup.procs") catch return error.PathTooLong;
group.procs_len = procs_path.len;
// Каталог в cgroupfs это и есть новая группа: файлы memory.* ядро
// создаёт в нём само, если у родительской группы включён контроллер.
if (c.mkdir(dir_path, 0o755) != 0) return error.CgroupUnavailable;
errdefer group.remove();
var number_buf: [24]u8 = undefined;
const bytes = std.fmt.bufPrint(&number_buf, "{d}", .{@as(u64, mem_mb) * bytes_per_mb}) catch unreachable;
if (!group.writeFile("memory.max", bytes)) return error.CgroupUnavailable;
// Без этой строки ядро сначала вытеснит страницы в своп, и программа
// вместо смерти начнёт ползти. Файла нет, если своп выключен: не беда.
_ = group.writeFile("memory.swap.max", "0");
}
fn dir(group: *const Cgroup) [:0]const u8 {
return group.dir_buf[0..group.dir_len :0];
}
/// Путь собран до fork: в ребёнке форматировать строки уже нельзя.
pub fn procsPath(group: *const Cgroup) [:0]const u8 {
return group.procs_buf[0..group.procs_len :0];
}
/// Зовётся в ребёнке. Ноль в `cgroup.procs` значит «тот, кто пишет».
/// open, write и close входят в список async-signal-safe.
pub fn joinSelf(procs_path: [:0]const u8) bool {
const fd = c.open(procs_path, .{ .ACCMODE = .WRONLY });
if (fd < 0) return false;
defer _ = c.close(fd);
return c.write(fd, "0", 1) == 1;
}
pub const Usage = struct {
events: memory.Events = .{},
/// `memory.peak` появился в Linux 5.19, на старых ядрах null.
peak_kb: ?u64 = null,
};
pub fn usage(group: *const Cgroup) Usage {
var text_buf: [512]u8 = undefined;
var result: Usage = .{};
if (group.readFile("memory.events", &text_buf)) |text| result.events = memory.parseEvents(text);
if (group.readFile("memory.peak", &text_buf)) |text| result.peak_kb = memory.parseBytesAsKb(text);
return result;
}
/// Группу с живыми процессами ядро удалить не даст (EBUSY). Убитые
/// внуки умирают не мгновенно, поэтому пробуем несколько раз.
pub fn remove(group: *const Cgroup) void {
for (0..200) |_| {
if (c.rmdir(group.dir()) == 0) return;
const pause: c.timespec = .{ .sec = 0, .nsec = std.time.ns_per_ms };
_ = c.nanosleep(&pause, null);
}
}
fn writeFile(group: *const Cgroup, name: []const u8, text: []const u8) bool {
var path_buf: [dir_capacity + 32]u8 = undefined;
const path = memory.groupFile(&path_buf, group.dir(), name) catch return false;
const fd = c.open(path, .{ .ACCMODE = .WRONLY });
if (fd < 0) return false;
defer _ = c.close(fd);
return c.write(fd, text.ptr, text.len) == @as(isize, @intCast(text.len));
}
fn readFile(group: *const Cgroup, name: []const u8, buf: []u8) ?[]const u8 {
var path_buf: [dir_capacity + 32]u8 = undefined;
const path = memory.groupFile(&path_buf, group.dir(), name) catch return null;
return readSmallFile(path, buf);
}
};
/// Файлы procfs и cgroupfs ядро собирает на лету. Маленький файл обычно
/// приходит одним `read`, но обещания такого нет: дочитываем циклом до нуля.
fn readSmallFile(path: [:0]const u8, buf: []u8) ?[]const u8 {
const fd = c.open(path, .{ .ACCMODE = .RDONLY });
if (fd < 0) return null;
defer _ = c.close(fd);
var len: usize = 0;
while (len < buf.len) {
const got = c.read(fd, buf[len..].ptr, buf.len - len);
if (got < 0) return null;
if (got == 0) break;
len += @intCast(got);
}
return buf[0..len];
}
Три части, у каждой своя тонкость.
limitAddressSpace зовётся в ребёнке между fork и execvp. Там действует правило из урока про сигналы: только async-signal-safe функции, потому что ребёнок многопоточной программы унаследовал чужие блокировки в неизвестном состоянии. setrlimit в списке есть. Мягкий и жёсткий пределы ставим равными, иначе программа подняла бы себе мягкий предел обратно.
Sampler читает /proc/<pid>/status ребёнка, и читать приходится, пока тот жив. Казалось бы, удобнее всего спросить пики после wait4. Но когда процесс завершился и стал зомби, ядро уже освободило его mm_struct, и строк Vm* в status просто нет. А после wait4 нет и самого каталога /proc/<pid>. Поэтому сторож будет просыпаться каждые 10 мс и снимать показания, и от программы, которая прожила меньше тика, останется null. Это приемлемо: точный пик резидентной памяти всё равно даёт rusage.
Вторая тонкость там же: берём последнее удачное значение, а не максимум. Между fork и execvp в ребёнке живёт копия самого zbox со своим адресным пространством. execve заводит новый mm_struct, и пики считаются с нуля. Максимум смешал бы пик zbox с пиком программы.
Cgroup делит работу между родителем и ребёнком. Родитель до fork создаёт каталог (сам mkdir и есть создание группы, файлы memory.* ядро кладёт в него само), пишет лимит в memory.max и ноль в memory.swap.max. Без второй записи ядро при нехватке начнёт выталкивать страницы группы в своп, и программа вместо быстрой смерти будет медленно ползти. Путь к cgroup.procs тоже собирается заранее: в ребёнке форматировать строки уже нельзя. Ребёнку остаётся open, write одного символа и close: ноль в cgroup.procs значит “перенеси того, кто пишет”. Делается это до execvp, чтобы программа с первой своей страницы считалась уже в группе.
После wait4 родитель читает memory.events и memory.peak и удаляет каталог. rmdir на группу с живыми процессами отвечает EBUSY, а внуки, убитые вместе с ребёнком, умирают не мгновенно, отсюда цикл с паузой в миллисекунду.
readSmallFile дочитывает циклом до нуля, даже когда файл заведомо меньше буфера. Обещания отдать маленький файл одним read ядро не даёт. О коротких счётах будет весь урок 60, здесь просто привыкай писать правильно.
src/box/watchdog.zig: будильник стал периодическим
В прошлой версии сторож заводил таймер один раз, на весь лимит времени, и SIGALRM значил “время вышло”. Теперь родителю нужно просыпаться каждые 10 мс ради Sampler, даже когда лимита времени нет вовсе. Поэтому таймер стал периодическим (interval равен value), крайний срок хранится в атомарной переменной deadline_ms, а обработчик на каждом тике сверяет часы со сроком через clock_gettime, который тоже async-signal-safe. Функция nowMs переехала сюда из run.zig, а waitForChild получила второй аргумент.
В шапке файла поправь описание порядка работы (будильник тикает раз в 10 мс, waitForChild на каждом тике снимает показания памяти) и добавь импорт const memlimit = @import("memlimit.zig");. Константы, общие переменные и обработчики теперь такие:
const ITIMER_REAL = 0;
/// Период будильника. Раз в тик обработчик сверяет часы с крайним сроком,
/// а основной цикл просыпается и читает `/proc/<pid>/status`.
const tick_ms = 10;
// Обработчик и основной код общаются только через эти переменные.
// Атомарная запись слова входит в список того, что обработчику можно.
var child_exited: std.atomic.Value(bool) = .init(false);
var alarm_fired: std.atomic.Value(bool) = .init(false);
var group: std.atomic.Value(c.pid_t) = .init(0);
/// Крайний срок по монотонным часам, миллисекунды. Ноль значит без лимита.
var deadline_ms: std.atomic.Value(u64) = .init(0);
fn onChild(_: posix.SIG) callconv(.c) void {
child_exited.store(true, .seq_cst);
}
fn onAlarm(_: posix.SIG) callconv(.c) void {
const deadline = deadline_ms.load(.seq_cst);
// clock_gettime тоже в списке async-signal-safe.
if (deadline == 0 or nowMs() < deadline) return;
alarm_fired.store(true, .seq_cst);
killGroup();
}
pub fn nowMs() u64 {
var now: c.timespec = undefined;
_ = c.clock_gettime(.MONOTONIC, &now);
return @intCast(now.sec * 1000 + @divTrunc(now.nsec, std.time.ns_per_ms));
}
В arm к трём сбросам добавился четвёртый, deadline_ms.store(0, .seq_cst);. startTimer и waitForChild целиком:
/// Зовётся в родителе сразу после fork.
pub fn startTimer(child: c.pid_t, time_ms: ?u32) void {
// Тот же setpgid, что и в ребёнке. Кто из двоих успеет первым, неизвестно,
// а группа нужна уже сейчас. Второй вызов безвреден. После execve ядро
// ответит родителю EACCES, но к этому моменту ребёнок всё сделал сам.
_ = c.setpgid(child, child);
group.store(child, .seq_cst);
if (time_ms) |ms| deadline_ms.store(nowMs() + ms, .seq_cst);
// Таймер периодический и заводится всегда: даже без лимита времени
// тики нужны, чтобы снимать показания памяти, пока программа жива.
const tick: c.timeval = .{ .sec = 0, .usec = tick_ms * std.time.us_per_ms };
const timer: Itimerval = .{ .interval = tick, .value = tick };
_ = setitimer(ITIMER_REAL, &timer, null);
}
/// Спит, пока не придёт SIGCHLD, и просыпается на каждом тике. sigsuspend
/// атомарно ставит старую маску и засыпает, а по возвращении возвращает
/// нашу. Между проверкой флага и сном нет окна, в которое сигнал мог бы
/// проскочить незамеченным.
pub fn waitForChild(old_mask: *const posix.sigset_t, sampler: *memlimit.Sampler) void {
while (!child_exited.load(.seq_cst)) {
_ = sigsuspend(old_mask);
// Здесь сигналы снова заблокированы, обработчик нас не перебьёт.
sampler.sample();
}
}
killGroup, childSetup и disarm остались как были.
Главное свойство прежней конструкции сохранилось: сигналы заблокированы везде, кроме сна внутри sigsuspend. sampler.sample() зовётся уже после пробуждения, с заблокированными сигналами, так что обработчик не вклинится посреди чтения файла.
args.zig, report.zig, run.zig
В разборе командной строки три новых флага и общая проверка на положительное число:
pub const Command = struct {
/// Лимит времени по настенным часам, миллисекунды. null значит без лимита.
time_ms: ?u32 = null,
/// Лимит адресного пространства через `setrlimit(RLIMIT_AS)`, мегабайты.
as_mb: ?u32 = null,
/// Лимит памяти через cgroups v2 (`memory.max`), мегабайты. Только Linux.
mem_mb: ?u32 = null,
/// Каталог cgroups v2, внутри которого zbox заводит свою группу.
cgroup_root: []const u8 = "/sys/fs/cgroup",
/// Программа и её аргументы: то, что уйдёт в `execvp`.
argv: []const []const u8,
};
Цикл по флагам и проверка числа:
// Флаги zbox стоят строго до программы. Всё после её имени принадлежит
// ей самой: `zbox run ls --time-ms` передаст `--time-ms` в ls.
while (rest.len > 0 and std.mem.startsWith(u8, rest[0], "--")) {
if (rest.len < 2) return error.BadUsage;
const flag = rest[0];
const value = rest[1];
if (std.mem.eql(u8, flag, "--time-ms")) {
command.time_ms = try positive(value);
} else if (std.mem.eql(u8, flag, "--as-mb")) {
command.as_mb = try positive(value);
} else if (std.mem.eql(u8, flag, "--mem-mb")) {
command.mem_mb = try positive(value);
} else if (std.mem.eql(u8, flag, "--cgroup-root")) {
if (value.len == 0) return error.BadUsage;
command.cgroup_root = value;
} else return error.BadUsage;
rest = rest[2..];
}
if (rest.len == 0) return error.BadUsage;
command.argv = rest;
return command;
}
/// Все числовые лимиты положительные. Нулевой лимит времени выключил бы
/// таймер, а нулевой лимит памяти не дал бы программе даже стартовать.
fn positive(text: []const u8) Error!u32 {
const number = std.fmt.parseInt(u32, text, 10) catch return error.BadUsage;
if (number == 0) return error.BadUsage;
return number;
}
В отчёте четыре новых поля, метод reason и хвост JSON:
В начале файла добавь const memory = @import("memory.zig");. Конец структуры Outcome:
/// Пики из `/proc/<pid>/status`, килобайты: виртуальная память и
/// резидентная. Только Linux, и только если сторож успел снять показания.
vm_peak_kb: ?u64 = null,
vm_hwm_kb: ?u64 = null,
/// Пик памяти всей группы из `memory.peak`. Только с `--mem-mb`.
cgroup_peak_kb: ?u64 = null,
/// Сколько процессов убил OOM-убийца группы. Только с `--mem-mb`.
oom_kills: ?u64 = null,
pub fn reason(outcome: Outcome) memory.Reason {
return memory.reason(.{
.signaled = outcome.signal != null,
.timed_out = outcome.timed_out,
.oom_kills = outcome.oom_kills,
});
}
};
И хвост writeJson, после печати wall_ms. Помощник writeOptional тот же, что был:
// Имя тега enum это латинское слово без кавычек внутри, экранировать нечего.
try out.print(",\"reason\":\"{t}\",\"vm_peak_kb\":", .{outcome.reason()});
try writeOptional(out, outcome.vm_peak_kb);
try out.writeAll(",\"vm_hwm_kb\":");
try writeOptional(out, outcome.vm_hwm_kb);
try out.writeAll(",\"cgroup_peak_kb\":");
try writeOptional(out, outcome.cgroup_peak_kb);
try out.writeAll(",\"oom_kills\":");
try writeOptional(out, outcome.oom_kills);
try out.writeAll("}\n");
}
И сам запуск. run теперь принимает всю Command, а не только argv с лимитом времени. Проследи порядок: группа создаётся до fork, ребёнок входит в неё и ставит RLIMIT_AS до execvp, родитель читает счётчики после wait4.
//! Запуск чужой программы: `fork`, `execvp`, `wait4`.
//! Сигналы и лимит времени живут рядом, в `watchdog.zig`.
const std = @import("std");
const c = std.c;
const args = @import("args.zig");
const memlimit = @import("memlimit.zig");
const report = @import("report.zig");
const watchdog = @import("watchdog.zig");
// В std.c есть execve, но нет execvp. Нам нужен именно он: пусть libc
// сама найдёт `sleep` или `sh` по PATH, как это делает оболочка.
extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
pub const Error = error{ ForkFailed, WaitFailed, OutOfMemory } || memlimit.CgroupError;
/// Код возврата ребёнка, если `execvp` не удался. Так же поступает оболочка:
/// 127 значит, что команда не найдена.
pub const exec_failed_code = 127;
/// Ребёнок не смог поставить себе лимит памяти. Запускать программу
/// без заказанного лимита нельзя, поэтому до `execvp` дело не доходит.
pub const limit_failed_code = 126;
pub fn run(arena: std.mem.Allocator, command: args.Command) Error!report.Outcome {
const argv = command.argv;
// Массив для execvp собираем до fork. После fork в ребёнке безопасны
// только async-signal-safe функции, а аллокатор к ним не относится.
const c_argv = try arena.allocSentinel(?[*:0]const u8, argv.len, null);
for (argv, c_argv) |arg, *slot| slot.* = try arena.dupeZ(u8, arg);
// Группу cgroups тоже готовим до fork: каталог, лимит и путь к
// `cgroup.procs`. Ребёнку останется один open и один write.
var cgroup: memlimit.Cgroup = .{};
if (command.mem_mb) |mem_mb| try cgroup.create(command.cgroup_root, mem_mb);
defer if (command.mem_mb != null) cgroup.remove();
const started_ms = watchdog.nowMs();
// Сигналы блокируем до fork. Иначе короткая программа вроде `true`
// завершится раньше, чем родитель приготовится ждать, SIGCHLD придёт
// в пустоту, и родитель уснёт навсегда.
const old_mask = watchdog.arm();
const pid = c.fork();
if (pid < 0) {
_ = watchdog.disarm(&old_mask);
return error.ForkFailed;
}
if (pid == 0) {
watchdog.childSetup(&old_mask);
// Сначала группа, потом execvp: память программы с первой страницы
// считается уже в группе с лимитом.
if (command.mem_mb != null and !memlimit.Cgroup.joinSelf(cgroup.procsPath())) childFail(limit_failed_code);
if (command.as_mb) |as_mb| {
if (!memlimit.limitAddressSpace(as_mb)) childFail(limit_failed_code);
}
// Мы в ребёнке. Успешный execvp не возвращается: образ процесса
// заменён, этого кода в памяти больше нет.
_ = execvp(c_argv[0].?, c_argv.ptr);
childFail(exec_failed_code);
}
// Мы в родителе.
var sampler: memlimit.Sampler = .{ .pid = pid };
watchdog.startTimer(pid, command.time_ms);
watchdog.waitForChild(&old_mask, &sampler);
const alarm_fired = watchdog.disarm(&old_mask);
// Ребёнок завершился, но мог оставить в группе фоновых внуков. Пока он
// зомби, его pid и pgid заняты, так что чужую группу мы не заденем.
watchdog.killGroup();
// wait4 это waitpid, который заодно отдаёт rusage ребёнка. Ждать уже
// не придётся: ребёнок мёртв, вызов только забирает зомби.
var status: c_int = 0;
var usage: c.rusage = undefined;
while (true) {
const reaped = c.wait4(pid, &status, 0, &usage);
if (reaped == pid) break;
// Сигнал мог прервать ожидание, тогда просто ждём дальше.
if (std.posix.errno(reaped) == .INTR) continue;
return error.WaitFailed;
}
var outcome = decodeStatus(@bitCast(status));
outcome.cpu_user_ms = report.timevalMs(usage.utime.sec, usage.utime.usec);
outcome.cpu_sys_ms = report.timevalMs(usage.stime.sec, usage.stime.usec);
outcome.max_rss_kb = report.maxRssKbNative(@intCast(usage.maxrss));
outcome.wall_ms = watchdog.nowMs() - started_ms;
outcome.vm_peak_kb = sampler.last.vm_peak_kb;
outcome.vm_hwm_kb = sampler.last.vm_hwm_kb;
if (command.mem_mb != null) {
// Счётчики читаем после wait4: к этому моменту ядро их уже обновило.
const usage_now = cgroup.usage();
outcome.cgroup_peak_kb = usage_now.peak_kb;
outcome.oom_kills = usage_now.events.oom_kill;
}
// Будильник мог прозвенеть в тот же миг, когда программа закончила сама.
// Лимит превышен, только если её действительно убил наш SIGKILL.
outcome.timed_out = alarm_fired and outcome.signal == @intFromEnum(std.posix.SIG.KILL);
return outcome;
}
/// Слово состояния из wait4 упаковано по-разному на разных системах,
/// поэтому разбираем его макросами W*, а не сдвигами руками.
fn decodeStatus(status: u32) report.Outcome {
if (c.W.IFEXITED(status)) return .{ .exit_code = c.W.EXITSTATUS(status) };
if (c.W.IFSIGNALED(status)) return .{ .signal = @intFromEnum(c.W.TERMSIG(status)) };
// Без WUNTRACED остановленного ребёнка wait4 не вернёт.
unreachable;
}
/// Выход из ребёнка с сообщением. Только async-signal-safe вызовы.
fn childFail(code: u8) noreturn {
const message: []const u8 = if (code == exec_failed_code)
"zbox: не удалось запустить программу\n"
else
"zbox: не удалось поставить лимит памяти\n";
_ = c.write(2, message.ptr, message.len);
// Именно _exit, а не exit: обычный exit сбросил бы буферы stdio
// и выполнил atexit-обработчики, унаследованные от родителя.
c._exit(code);
}
Новый код возврата 126: ребёнок не смог поставить заказанный лимит. Запускать чужую программу без лимита, о котором просили, нельзя, поэтому до execvp дело не доходит. Оболочка использует 126 для “файл найден, но не запускается”, по смыслу близко.
Порядок двух лимитов в ребёнке не случаен: сначала группа, потом RLIMIT_AS. Наоборот тоже сработало бы, но так ограничение адресов не может помешать самому входу в группу.
main.zig, root.zig, build.zig
В тексте usage описаны три новых флага, а вызов run теперь получает всю команду и умеет объяснить отказ:
// До fork в буфер stdout ничего не пишем: непустой буфер достался бы
// и ребёнку, и один и тот же текст мог бы выйти дважды.
const outcome = zbox.run.run(arena, command) catch |err| switch (err) {
error.CgroupUnsupported, error.CgroupUnavailable, error.PathTooLong => {
try out.print("zbox: группа cgroups недоступна ({t}): нужен Linux с cgroups v2, права на запись в каталог и контроллер memory в его cgroup.subtree_control\n", .{err});
try out.flush();
std.process.exit(3);
},
else => return err,
};
try zbox.report.writeJson(out, outcome);
try out.flush();
Недоступные cgroups это ошибка окружения, а не программы, поэтому zbox честно говорит, чего ему не хватает, и выходит с кодом 3, не напечатав JSON.
В src/root.zig два новых экспорта:
pub const memlimit = @import("box/memlimit.zig");
pub const memory = @import("box/memory.zig");
В build.zig в project_steps дописывается 54, а после строки с zbox_exe собирается hog и его путь уходит в тесты второй константой:
// Подопытная программа для тестов лимита памяти: занимает столько
// мегабайт, сколько попросили. Студенческого кода в ней нет.
const hog = b.addExecutable(.{
.name = "hog",
.root_module = b.createModule(.{
.root_source_file = b.path("tests/hog.zig"),
.target = target,
.optimize = optimize,
}),
});
options.addOptionPath("hog_exe", hog.getEmittedBin());
Тесты шага
Помощник для интеграционных тестов вырос: появился сырой запуск zboxRaw для тестов, которым важен код возврата самого zbox, и новые поля ответа.
//! Общее для интеграционных тестов: запустить настоящий zbox и разобрать ответ.
const std = @import("std");
const build_options = @import("build_options");
/// Поля ответа, которые проверяют тесты. Лишние поля JSON пропускаем:
/// поздние шаги добавляют свои, а ранние тесты обязаны остаться зелёными.
pub const Reply = struct {
exit_code: ?u8,
signal: ?u32,
cpu_user_ms: u64,
cpu_sys_ms: u64,
max_rss_kb: u64,
wall_ms: u64,
timed_out: bool = false,
reason: []const u8 = "",
vm_peak_kb: ?u64 = null,
vm_hwm_kb: ?u64 = null,
cgroup_peak_kb: ?u64 = null,
oom_kills: ?u64 = null,
};
pub const Run = struct {
reply: Reply,
/// Всё, что попало на stdout до строки с JSON: это вывод самой программы.
program_stdout: []const u8,
};
/// Путь к подопытной программе `hog`, которая занимает память по заказу.
pub const hog_exe = build_options.hog_exe;
/// Сырой запуск zbox: для тестов, которым важен код возврата самого zbox.
/// `zbox_args` это командная строка после имени zbox.
pub fn zboxRaw(arena: std.mem.Allocator, zbox_args: []const []const u8) !std.process.RunResult {
const argv = try std.mem.concat(arena, []const u8, &.{ &.{build_options.zbox_exe}, zbox_args });
return std.process.run(arena, std.testing.io, .{ .argv = argv });
}
pub fn zbox(arena: std.mem.Allocator, zbox_args: []const []const u8) !Run {
const result = try zboxRaw(arena, zbox_args);
try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
return parseReply(arena, result.stdout);
}
pub fn parseReply(arena: std.mem.Allocator, stdout: []const u8) !Run {
// Ответ zbox это последняя строка stdout.
const trimmed = std.mem.trimEnd(u8, stdout, "\n");
const json_start = if (std.mem.lastIndexOfScalar(u8, trimmed, '\n')) |newline| newline + 1 else 0;
const reply = try std.json.parseFromSliceLeaky(Reply, arena, trimmed[json_start..], .{
.ignore_unknown_fields = true,
});
return .{ .reply = reply, .program_stdout = trimmed[0..json_start] };
}
В тестах шагов 48 и 49 поправь одно место: буфер, в который writeJson пишет ответ, должен вырасти с 256 до 1024 байт, ответ стал длиннее.
Для теста parseStatus нужен настоящий текст /proc/self/status. В нём табуляции, а в многострочном литерале Zig символ табуляции запрещён, поэтому текст лежит файлом и подключается через @embedFile. Сохрани вывод cat /proc/self/status из контейнера в tests/fixtures/proc_status.txt и подставь в первый тест свои числа. Мой файл начинается так (между именем и значением табуляция):
Name: cat
Umask: 0022
State: R (running)
...
VmPeak: 2380 kB
VmSize: 2380 kB
VmLck: 0 kB
VmPin: 0 kB
VmHWM: 932 kB
VmRSS: 932 kB
//! Шаг 54: лимит памяти через RLIMIT_AS и через cgroups v2, пики из /proc.
const std = @import("std");
const builtin = @import("builtin");
const zbox = @import("zbox");
const support = @import("support.zig");
const testing = std.testing;
const memory = zbox.memory;
// Настоящий `cat /proc/self/status`, Linux 7.0, aarch64. В файле табуляции,
// а в многострочном литерале Zig они запрещены, поэтому текст лежит рядом.
const status_text = @embedFile("fixtures/proc_status.txt");
test "parseStatus достаёт VmPeak и VmHWM" {
const status = memory.parseStatus(status_text);
try testing.expectEqual(2380, status.vm_peak_kb);
try testing.expectEqual(932, status.vm_hwm_kb);
}
test "parseStatus: у зомби строк Vm нет" {
const status = memory.parseStatus("Name:\tsleep\nState:\tZ (zombie)\nPid:\t42\n");
try testing.expectEqual(null, status.vm_peak_kb);
try testing.expectEqual(null, status.vm_hwm_kb);
}
test "parseEvents: oom и oom_kill не путаются" {
const events = memory.parseEvents("low 0\nhigh 0\nmax 37\noom 2\noom_kill 1\noom_group_kill 0\n");
try testing.expectEqual(37, events.max);
try testing.expectEqual(2, events.oom);
try testing.expectEqual(1, events.oom_kill);
}
test "parseEvents: пустой и битый текст дают нули" {
try testing.expectEqual(memory.Events{}, memory.parseEvents(""));
try testing.expectEqual(memory.Events{}, memory.parseEvents("oom_kill\nmax много\n"));
}
test "parseBytesAsKb: memory.peak в килобайты" {
try testing.expectEqual(32768, memory.parseBytesAsKb("33554432\n"));
try testing.expectEqual(null, memory.parseBytesAsKb("max\n"));
}
test "пути группы и procfs" {
var dir_buf: [64]u8 = undefined;
const dir = try memory.groupDir(&dir_buf, "/sys/fs/cgroup/", 4242);
try testing.expectEqualStrings("/sys/fs/cgroup/zbox-4242", dir);
var file_buf: [64]u8 = undefined;
try testing.expectEqualStrings("/sys/fs/cgroup/zbox-4242/memory.max", try memory.groupFile(&file_buf, dir, "memory.max"));
var status_buf: [32]u8 = undefined;
try testing.expectEqualStrings("/proc/7/status", try memory.statusPath(&status_buf, 7));
var tiny: [8]u8 = undefined;
try testing.expectError(error.NoSpaceLeft, memory.groupDir(&tiny, "/sys/fs/cgroup", 1));
}
test "reason: память важнее времени, время важнее сигнала" {
try testing.expectEqual(.exited, memory.reason(.{}));
try testing.expectEqual(.exited, memory.reason(.{ .oom_kills = 0 }));
try testing.expectEqual(.signaled, memory.reason(.{ .signaled = true }));
try testing.expectEqual(.time_limit, memory.reason(.{ .signaled = true, .timed_out = true, .oom_kills = 0 }));
try testing.expectEqual(.memory_limit, memory.reason(.{ .signaled = true, .timed_out = true, .oom_kills = 1 }));
}
test "parse: флаги памяти" {
const command = try zbox.args.parse(&.{ "run", "--as-mb", "256", "--mem-mb", "64", "--cgroup-root", "/tmp/cg", "true" });
try testing.expectEqual(256, command.as_mb);
try testing.expectEqual(64, command.mem_mb);
try testing.expectEqualStrings("/tmp/cg", command.cgroup_root);
const plain = try zbox.args.parse(&.{ "run", "true" });
try testing.expectEqual(null, plain.as_mb);
try testing.expectEqual(null, plain.mem_mb);
try testing.expectEqualStrings("/sys/fs/cgroup", plain.cgroup_root);
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--mem-mb", "0", "true" }));
try testing.expectError(error.BadUsage, zbox.args.parse(&.{ "run", "--as-mb", "много", "true" }));
}
test "writeJson: причина и поля памяти" {
var buf: [512]u8 = undefined;
var out: std.Io.Writer = .fixed(&buf);
try zbox.report.writeJson(&out, .{ .signal = 9, .vm_peak_kb = 300000, .vm_hwm_kb = 65000, .cgroup_peak_kb = 65536, .oom_kills = 1 });
const fields = "\"reason\":\"memory_limit\",\"vm_peak_kb\":300000,\"vm_hwm_kb\":65000,\"cgroup_peak_kb\":65536,\"oom_kills\":1";
try testing.expect(std.mem.indexOf(u8, out.buffered(), fields) != null);
}
test "без лимитов причина exited, поля группы пустые" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", "true" });
try testing.expectEqualStrings("exited", run.reply.reason);
try testing.expectEqual(null, run.reply.oom_kills);
try testing.expectEqual(null, run.reply.cgroup_peak_kb);
}
test "linux: пики из /proc, виртуальный не меньше резидентного" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const run = try support.zbox(arena_state.allocator(), &.{ "run", support.hog_exe, "touch", "64" });
try testing.expectEqual(0, run.reply.exit_code);
// 64 МБ записаны постранично, значит резидентный пик не меньше 64 МБ.
try testing.expect(run.reply.vm_hwm_kb.? >= 64 * 1024);
try testing.expect(run.reply.vm_peak_kb.? >= run.reply.vm_hwm_kb.?);
}
test "linux: RLIMIT_AS отказывает в адресах, а не убивает" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Лимит щедрый, гигабайт: под эмуляцией x86-64 (Rosetta в Docker на Mac)
// даже `cat` держит 670 МБ адресов, и лимит поменьше не дал бы стартовать.
// mmap получает ENOMEM, hog сам выходит с кодом 3. Сигнала нет,
// и узнать о превышении лимита zbox может только со слов программы.
const denied = try support.zbox(arena, &.{ "run", "--as-mb", "1024", support.hog_exe, "touch", "2048" });
try testing.expectEqual(3, denied.reply.exit_code);
try testing.expectEqualStrings("exited", denied.reply.reason);
const fits = try support.zbox(arena, &.{ "run", "--as-mb", "1024", support.hog_exe, "touch", "16" });
try testing.expectEqual(0, fits.reply.exit_code);
// Главная беда RLIMIT_AS: страницы не тронуты, физической памяти
// не потрачено ни байта, а программе всё равно отказано.
const reserve = try support.zbox(arena, &.{ "run", "--as-mb", "1024", support.hog_exe, "reserve", "2048" });
try testing.expectEqual(3, reserve.reply.exit_code);
}
test "linux: cgroup убивает за настоящую память и прощает резерв адресов" {
if (builtin.os.tag != .linux) return error.SkipZigTest;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const raw = try support.zboxRaw(arena, &.{ "run", "--mem-mb", "32", support.hog_exe, "touch", "256" });
if (std.meta.eql(raw.term, std.process.Child.Term{ .exited = 3 })) {
std.debug.print("пропуск: cgroups v2 недоступны, нужен root и контроллер memory в cgroup.subtree_control\n", .{});
return error.SkipZigTest;
}
const killed = (try support.parseReply(arena, raw.stdout)).reply;
try testing.expectEqual(9, killed.signal);
try testing.expectEqualStrings("memory_limit", killed.reason);
try testing.expect(killed.oom_kills.? >= 1);
// Полгигабайта адресов без единой записи: группе это ничего не стоит.
const reserve = (try support.zbox(arena, &.{ "run", "--mem-mb", "32", support.hog_exe, "reserve", "512" })).reply;
try testing.expectEqual(0, reserve.exit_code);
try testing.expectEqualStrings("exited", reserve.reason);
try testing.expectEqual(0, reserve.oom_kills);
try testing.expect(reserve.vm_peak_kb.? >= 512 * 1024);
try testing.expect(reserve.cgroup_peak_kb.? < 32 * 1024);
// Группа удалена: после запуска в каталоге не осталось zbox-*.
var root = try std.Io.Dir.openDirAbsolute(testing.io, "/sys/fs/cgroup", .{ .iterate = true });
defer root.close(testing.io);
var entries = root.iterate();
while (try entries.next(testing.io)) |entry| {
try testing.expect(!std.mem.startsWith(u8, entry.name, "zbox-"));
}
}
Первая половина файла это чистые тесты, они идут везде. Три последних требуют Linux и на других системах пропускают себя сами, а тест cgroups пропускает себя и на Linux, если zbox ответил кодом 3.
macOS: 31 тест прошёл, 3 пропущены
Linux, root и --privileged: 34 из 34
Linux без привилегий: 33 прошли, тест cgroups пропущен с объяснением
Как запустить это в контейнере
Для cgroups контейнеру нужны привилегии, свой cgroup namespace и немного подготовки. У cgroups v2 есть правило “без внутренних процессов”: группа, которая раздаёт контроллеры детям через cgroup.subtree_control, не может сама содержать процессы. В свежем контейнере все процессы сидят в корневой группе, поэтому сначала переселяем их в новую группу init и только потом включаем контроллер памяти для детей корня:
docker run --rm --platform linux/amd64 --privileged --cgroupns private --user 0 \
-v "$PWD":/w:ro <образ с zig> sh -c '
mkdir /tmp/p && cd /w && cp -r build.zig src tests /tmp/p/ && cd /tmp/p
mkdir /sys/fs/cgroup/init
for p in $(cat /sys/fs/cgroup/cgroup.procs); do echo $p > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; done
echo +memory > /sys/fs/cgroup/cgroup.subtree_control
zig build test --summary all --cache-dir /tmp/p/.zig-cache --global-cache-dir /tmp/g'
Если в контейнере тест вдруг падает с синтаксической ошибкой на обрыве файла, перезапусти: подключённый с хоста каталог иногда отдаёт только что изменённый файл не целиком.
Прогон: два лимита, две истории
Linux 7.0, aarch64, контейнер с привилегиями. Вывод самой программы идёт перед строкой JSON.
Группа с лимитом 32 МБ и программа, которая резервирует полгигабайта адресов:
$ zbox run --mem-mb 32 /tmp/hog reserve 512
reserve 512
{"exit_code":0,"signal":null,"timed_out":false,"cpu_user_ms":0,"cpu_sys_ms":1,"max_rss_kb":3692,"wall_ms":207,"reason":"exited","vm_peak_kb":529848,"vm_hwm_kb":3632,"cgroup_peak_kb":2576,"oom_kills":0}
Жива и здорова. vm_peak_kb 529848, это 517 МБ адресов, а пик группы 2,5 МБ. Группе безразлично, сколько адресов ты занял.
Та же группа, программа трогает 256 МБ:
$ zbox run --mem-mb 32 /tmp/hog touch 256
{"exit_code":null,"signal":9,"timed_out":false,"cpu_user_ms":4,"cpu_sys_ms":6,"max_rss_kb":33888,"wall_ms":12,"reason":"memory_limit","vm_peak_kb":267704,"vm_hwm_kb":34004,"cgroup_peak_kb":32768,"oom_kills":1}
Убита сигналом 9 через 12 мс. cgroup_peak_kb равен 32768, ровно лимит, oom_kills единица, причина memory_limit. Это вердикт, которому можно верить: его вынесло ядро.
Теперь лимит адресов в гигабайт и резерв на два:
$ zbox run --as-mb 1024 /tmp/hog reserve 2048
{"exit_code":3,"signal":null,"timed_out":false,"cpu_user_ms":2,"cpu_sys_ms":1,"max_rss_kb":3560,"wall_ms":5,"reason":"exited","vm_peak_kb":null,"vm_hwm_kb":null,"cgroup_peak_kb":null,"oom_kills":null}
Посмотри внимательно. Сигнала нет. Причина exited. Код возврата 3, и это hog сам так решил, когда mmap вернул ему ENOMEM. Другая программа на его месте напечатала бы out of memory, третья упала бы на разыменовании нуля с SIGSEGV, четвёртая поймала бы ошибку и продолжила работать в урезанном режиме. Снаружи отличить “программе отказали в памяти” от “программа вышла с кодом 3 по своим причинам” невозможно в принципе. У RLIMIT_AS нет ни счётчика, ни события. Раннер, который обязан написать студенту “превышен лимит памяти”, из такого лимита этой фразы не получит. (Пиков здесь нет, null: программа прожила 5 мс, меньше тика сторожа.)
И вторая беда, хуже первой. Этот hog reserve не потратил ни одного кадра физической памяти. Его наказали за адреса.
История с Rosetta: 670 МБ на ровном месте
Те же команды в контейнере linux/amd64 на Mac, то есть под эмулятором:
$ zbox run --mem-mb 64 /tmp/hog touch 16
touch 16
{"exit_code":0,"signal":null,"timed_out":false,"cpu_user_ms":13,"cpu_sys_ms":4,"max_rss_kb":22436,"wall_ms":221,"reason":"exited","vm_peak_kb":704136,"vm_hwm_kb":22352,"cgroup_peak_kb":20088,"oom_kills":0}
Программа тронула 16 МБ, резидентный пик 22 МБ, группа насчитала 20 МБ. А vm_peak_kb равен 704136, это 688 МБ. Ты уже знаешь, откуда они: две анонимные области эмулятора, 128 МБ под оттранслированный код и 517 МБ под его данные, которые мы дважды видели в карте памяти. Около 670 МБ адресов держит под Rosetta любой процесс, даже cat.
Когда я писал тесты этого шага, первым делом поставил лимит --as-mb 256, разумное число для учебной задачи. Под эмулятором с таким лимитом не запустилось вообще ничего: транслятор не смог отобразить свои области и умер раньше первой инструкции программы. Поэтому в тесте стоит гигабайт, и комментарий в step_54.zig объясняет почему.
Rosetta здесь просто удобный пример под рукой. Так же ведут себя почти все большие рантаймы:
- JVM занимает адреса под всю максимальную кучу сразу при старте, плюс область под сжатые указатели классов;
- санитайзер адресов отображает теневую память на терабайты;
- glibc заводит по арене
mallocна 64 МБ адресов на каждый поток; - любой JIT держит запас под код.
Всё это адреса без страниц, они ничего не стоят, и авторы рантаймов тратят их не считая: в 64-битном адресном пространстве 128 ТБ. RLIMIT_AS придумали во времена, когда адресное пространство и память были примерно одним и тем же. С приходом 64 бит и ленивого выделения страниц они разошлись на порядки, и лимит на адреса перестал что-либо говорить о потреблении памяти.
Сведём:
RLIMIT_AS | cgroups v2, memory.max | |
|---|---|---|
| Что ограничивает | адреса: сумму размеров областей | физические страницы, которых коснулись |
| На кого действует | на один процесс (дети наследуют лимит, но считаются порознь) | на всю группу разом, с потомками |
| Что при превышении | mmap и brk возвращают ENOMEM | OOM-убийца шлёт SIGKILL |
| Узнаёт ли раннер | нет, только со слов программы | да, oom_kill в memory.events |
| Ложные срабатывания | рантаймы с большим резервом адресов не стартуют | нет |
| Нужны права | нет | запись в каталог группы |
| Где работает | Linux (на macOS константа есть, ядро её не соблюдает) | только Linux |
Поэтому раннер курса, bs-runner, ограничивает память через cgroups. Руками он с каталогами не возится: песочница это контейнер, и флаги docker run --memory 512m --memory-swap 512m (второй равен первому, то есть свопа ноль) делают ровно то, что ты сейчас написал: запись в memory.max и запрет свопа для группы контейнера. Теперь ты знаешь, что стоит за этими двумя флагами и почему их два. А --as-mb в zbox оставь: как дешёвая вторая линия обороны без прав root он годится, если лимит щедрый, а вердикту по нему никто не верит.
На macOS из этого шага работает только чистая половина. zbox run --mem-mb 32 true отвечает, что cgroups недоступны, и выходит с кодом 3. С --as-mb интереснее: константа RLIMIT_AS в заголовках macOS есть (это псевдоним RLIMIT_RSS), ядро её не соблюдает, но setrlimit со значением ниже текущего потребления возвращает ошибку, и zbox run --as-mb 64 sh -c 'echo ok' даёт exit_code 126. Ограничение памяти процесса на macOS делается совсем другими средствами, и в курсе мы их не касаемся.
Практика
Задача собирает первую половину урока в две чистые функции: разбор строки maps в структуру и вопрос, который ядро задаёт себе при сбое страницы. Три ответа check это три исхода из раздела про обработчик сбоя: адрес не отображён, прав не хватает, всё законно. Тесты берут карту настоящего sleep и дюжину битых строк.
Упражнения
Итоги
- Виртуальная память нужна не только как кэш диска. Отдельная таблица страниц на процесс даёт всем процессам одинаковое адресное пространство, и от этого проще всем: компоновщик назначает адреса, не зная о машине;
execveничего не копирует, а отображает файл; код библиотек лежит в физической памяти один раз на всех; непрерывный кусок виртуальной памяти собирается из любых свободных кадров. - Права доступа живут в той же записи таблицы страниц, и процессор проверяет их при каждой трансляции. Учебные биты SUP, READ, WRITE, EXEC на x86-64 превращаются в P, R/W, U/S и XD; отдельного бита чтения нет. Нарушение это сбой с тем же вектором, что и отсутствие страницы.
- Ядро описывает адресное пространство набором областей. В Linux это
vm_area_structвнутриmm_struct; с версии 6.1 области лежат в maple tree, а не в списке с красно-чёрным деревом, и с 6.4 блокируются поодиночке. Модель из книги, упорядоченный набор непересекающихся областей, осталась верной. /proc/<pid>/mapsэто распечатка этих структур: диапазон, праваrwxp, смещение, устройство, inode, путь. Файлы/procимеют нулевой размер, читать их надо потоком до конца.- Обработчик сбоя страницы задаёт три вопроса: адрес в какой-нибудь области? доступ разрешён её правами? Два “нет” дают
SIGSEGVс кодамиSEGV_MAPERRиSEGV_ACCERR, два “да” это обычная подкачка страницы, которой программа не замечает. mprotectменяет права постранично. Сбой возвращает управление на ту же инструкцию, поэтому обработчик, который починил права, даёт записи пройти со второй попытки. На этом стоят сторожевые страницы, RELRO, W^X, барьеры записи сборщиков мусора и копирование при записи. macOS шлёт за нарушение правSIGBUS, LinuxSIGSEGV.zt pmapпечатает карту процесса в формате procps, аpmap.locateпереводит адрес живого процесса в пару (файл, смещение): это первая половина символизации адресов внутри разделяемых библиотек.- Память процесса меряется двумя числами: адреса (
VmPeak) и кадры (VmHWM, RSS).RLIMIT_ASограничивает первое, молча отказывает вmmapи не оставляет раннеру никакого следа; рантаймы с большим резервом адресов под ним не стартуют, как любой процесс под Rosetta с его 670 МБ. cgroups v2 ограничивают второе у всей группы, убивают через OOM-убийцу и записывают это вmemory.events. Поэтому раннер курса ограничивает память через cgroups.
Дальше
Мы весь урок говорили “MMU транслирует адрес”, “PTE лежит в TLB”, “права проверяются на каждом уровне таблицы”, и ни разу не показали, как это происходит. В следующем уроке откроем коробку: пройдём трансляцию по шагам на учебной системе из книги, с TLB, таблицей страниц и кэшем, потом разберём настоящую четырёхуровневую таблицу Core i7 и посчитаем, почему 512 ГБ плоской таблицы превращаются в десяток страниц по 4 КБ. А карта памяти вернётся в уроке 56: там области начнут появляться по нашей команде через mmap, fork окажется почти бесплатным благодаря тому самому биту записи, а JIT нашего zl перейдёт на W^X.
домашка