Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Unix I/O и короткие счёты
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Unix I/O и короткие счёты
Прошлый блок закончился на сборке мусора и ошибках с памятью: вся память процесса лежала перед нами как на ладони. Теперь выходим за её пределы. Всё, что программа знает о внешнем мире, приходит к ней через пять системных вызовов:
open,read,write,lseek,close. Файл на диске, терминал, пайп, сетевое соединение для ядра выглядят одинаково: номер дескриптора и поток байтов. Сегодня мы возьмём эти вызовы голыми руками, безstd.Io, и наткнёмся на правило, о которое спотыкается каждый второй сетевой сервис:readимеет право вернуть меньше, чем ты просил, и это не ошибка. Мы воспроизведём такой короткий счёт на живом пайпе, поймаемEINTR, напишемreadnиwriten, посмотрим, что поверх этого строитstd.Io.File. А в конце дадим машине Y86 консоль и диск, который тоже отдаёт байты не все сразу.
Цели урока
- Понимать модель Unix I/O: файл это последовательность байтов, открытый файл это дескриптор плюс позиция, устройство это тоже файл.
- Вызывать
open,read,write,lseek,closeиз Zig 0.16 напрямую: черезstd.cс libc и черезstd.os.linuxбез неё, и знать, чего из этого вstd.posixуже нет. - Помнить правило наименьшего свободного дескриптора и уметь предсказать номер, который вернёт следующий
open. - Отличать три исхода
read: данные (возможно, меньше заказа), ноль как конец файла, минус один с кодом ошибки. Знать, откуда берётся короткий счёт: конец файла, терминал, пайп, сокет, сигнал. - Воспроизвести короткий счёт на чтении и на записи, а также
EINTR, на своей машине. - Написать
readnиwritenи объяснить каждую ветку цикла. - Понимать, что
std.Io.Fileдобавляет поверх сырых вызовов и чего не добавляет. - Дать Y86 устройства с отображением в память: консоль и диск с короткими счётами, драйвер в ядре и процесс, который дочитывает циклом.
Идея: всё есть последовательность байтов
У Unix одна абстракция на весь ввод и вывод. Файл это просто байты по порядку. У ядра нет понятия строки, записи или кодировки. Что лежит внутри, решает программа.
Вторая половина идеи смелее: устройства тоже файлы. Диск, терминал, сетевая карта, генератор случайных чисел видны программе как имена в файловой системе или как дескрипторы, и разговаривают с ней теми же вызовами. Поэтому cat умеет печатать и файл, и то, что ты набираешь на клавиатуре, и то, что пришло из сети через пайп, и ни разу не спрашивает, с чем имеет дело.
Модель работы одинакова для всего:
- Открыть. Программа просит ядро открыть файл по имени. Ядро возвращает маленькое неотрицательное целое, дескриптор. Всё остальное про открытый файл ядро держит у себя, программа хранит только номер.
- Позиция. У каждого открытого файла ядро ведёт текущую позицию k, изначально ноль. Это смещение от начала файла, с которого пойдёт следующее чтение или запись.
lseekставит её явно. - Читать и писать. Чтение n байтов копирует байты с k по k+n-1 в память программы и сдвигает k на n. Если k уже равно размеру файла, чтение возвращает ноль: это и есть конец файла. Никакого особого символа “конец файла” в самом файле нет, это просто состояние “позиция упёрлась в размер”. Запись симметрична: байты из памяти ложатся в файл с позиции k.
- Закрыть. Программа говорит ядру, что файл ей больше не нужен. Ядро освобождает свои структуры и возвращает номер в пул свободных. При завершении процесса ядро закрывает всё, что осталось открытым, само.
Каждый процесс рождается с тремя открытыми дескрипторами: 0 это стандартный ввод, 1 это стандартный вывод, 2 это стандартный вывод ошибок. Их открыл не он: их оставила ему в наследство оболочка, и как именно это наследство устроено, мы разберём в уроке про таблицы дескрипторов.
Ты уже стоял рядом с этим интерфейсом. В уроке про inline asm ты звал write с номером 1 через голую инструкцию syscall, а в уроке про исключения смотрел, как эта инструкция уводит процессор в ядро. В уроке про нелокальные переходы смотрел, как longjmp теряет открытый дескриптор. Сегодня разбираем сам интерфейс, целиком.
Где это в Zig 0.16
Сразу честно про инструмент. В Zig 0.16 весь ввод и вывод переехал в std.Io, и модуль std.posix, в котором раньше жили тонкие обёртки над системными вызовами, сильно похудел. Если открыть lib/std/posix.zig установленной версии, из нашей пятёрки там остались только read и openat. Ни write, ни close, ни lseek, ни pipe там больше нет: авторы языка подталкивают к std.Io.File.
Нам сегодня нужен именно сырой слой, поэтому берём его одним из двух способов:
| Путь | Что это | Как собирать | Где работает |
|---|---|---|---|
std.c | объявления функций libc: open, read, write, lseek, close, pipe, fork | zig run -lc prog.zig | Linux, macOS, BSD |
std.os.linux | сами системные вызовы, без libc: инструкция syscall внутри | zig run prog.zig | только Linux |
Основные листинги урока идут через std.c: так они запускаются и на Linux, и на macOS. Вариант без libc покажем отдельно, он хорошо проявляет, как на самом деле возвращаются ошибки.
Дескрипторы и правило наименьшего свободного
Начнём с open. Он принимает путь, набор флагов и, если файл создаётся, права доступа. Флаги в Zig это не целое с битовыми масками, как в C, а упакованная структура std.c.O: режим доступа лежит в поле ACCMODE (.RDONLY, .WRONLY, .RDWR), остальное это булевы поля CREAT, TRUNC, APPEND и так далее. В память она ложится тем же самым целым, которое ждёт ядро: ты видел такие структуры в уроке про раскладку.
Главное, что надо знать про результат: ядро всегда возвращает наименьший дескриптор, который сейчас не занят. Не следующий по счёту, не случайный, а наименьший свободный. Проверим.
const std = @import("std");
const c = std.c;
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
const a = c.open("/etc/hosts", .{ .ACCMODE = .RDONLY });
const b = c.open("/etc/hosts", .{ .ACCMODE = .RDONLY });
try out.print("a = {d}, b = {d}\n", .{ a, b });
_ = c.close(a);
const d = c.open("/etc/passwd", .{ .ACCMODE = .RDONLY });
try out.print("закрыли {d}, следующий open дал {d}\n", .{ a, d });
_ = c.close(0);
const e = c.open("/etc/hosts", .{ .ACCMODE = .RDONLY });
try out.print("закрыли 0, следующий open дал {d}\n", .{e});
const bad = c.open("/no/such/file", .{ .ACCMODE = .RDONLY });
try out.print("нет файла: open вернул {d}, errno = {t}\n", .{ bad, std.posix.errno(bad) });
try out.flush();
}
$ zig run -lc fds.zig
a = 3, b = 4
закрыли 3, следующий open дал 3
закрыли 0, следующий open дал 0
нет файла: open вернул -1, errno = NOENT
Первые два open получили 3 и 4: номера 0, 1 и 2 заняты стандартными потоками. Закрыли 3, и следующий open вернул снова 3, хотя 5 тоже был свободен. Закрыли стандартный ввод, и открытый файл сел на номер 0. С этой секунды любой код, который читает “стандартный ввод”, читает /etc/hosts. Это не курьёз, а механизм: именно так оболочка делает перенаправление < file, и в уроке 62 мы соберём его сами.
Последняя строка показывает, как libc сообщает об ошибке: возвращает -1, а причину кладёт в errno. std.posix.errno(rc) достаёт её оттуда и превращает в значение перечисления std.posix.E, которое {t} печатает по имени.
Из правила следуют две практические вещи. Первая: номер дескриптора после close ничего не значит и может мгновенно достаться другому файлу. Закрыл и продолжил пользоваться старым номером, значит пишешь в чужой файл, и ядро тебе не возразит. Это та же ошибка, что использование памяти после free из прошлого урока, только с файлами. Вторая: дескрипторы это ограниченный ресурс. ulimit -n показывает потолок на процесс: на типичном Linux 2026 года мягкий предел 1024, на macOS 256 в оболочке по умолчанию. Сервер, который забывает close на каждом соединении, упирается в него за минуты, и open с accept начинают возвращать EMFILE.
Пять вызовов на одном файле
Теперь вся пятёрка разом. Создадим файл, запишем строку, посмотрим на позицию, прочитаем с середины и заглянем за конец.
const std = @import("std");
const c = std.c;
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
// O_CREAT требует третий аргумент: права нового файла.
const fd = c.open("note.txt", .{ .ACCMODE = .RDWR, .CREAT = true, .TRUNC = true }, @as(c_uint, 0o644));
if (fd < 0) return error.OpenFailed;
defer _ = c.close(fd);
const text = "hello, unix io\n";
const wrote = c.write(fd, text.ptr, text.len);
try out.print("write вернул {d}, позиция {d}\n", .{ wrote, c.lseek(fd, 0, std.posix.SEEK.CUR) });
// Позиция стоит в конце: читать нечего.
var in: [64]u8 = undefined;
try out.print("read с конца вернул {d}\n", .{c.read(fd, &in, in.len)});
// Перематываем на седьмой байт и просим 64, хотя осталось восемь.
_ = c.lseek(fd, 7, std.posix.SEEK.SET);
const got = c.read(fd, &in, in.len);
try out.print("read с позиции 7 вернул {d}: {s}", .{ got, in[0..@intCast(got)] });
try out.print("ещё один read вернул {d}\n", .{c.read(fd, &in, in.len)});
// За конец файла перематывать можно. Запись там оставляет дыру из нулей.
_ = c.lseek(fd, 100, std.posix.SEEK.SET);
_ = c.write(fd, "!", 1);
try out.print("размер после записи по смещению 100: {d}\n", .{c.lseek(fd, 0, std.posix.SEEK.END)});
try out.flush();
}
$ zig run -lc rawio.zig
write вернул 15, позиция 15
read с конца вернул 0
read с позиции 7 вернул 8: unix io
ещё один read вернул 0
размер после записи по смещению 100: 101
Разберём по строкам вывода.
write вернул 15, позиция 15. Запись легла с позиции 0 и сдвинула её на длину записанного. lseek(fd, 0, SEEK.CUR) это идиома “ничего не двигай, просто скажи, где мы”: сдвиг на ноль от текущей позиции возвращает саму позицию.
read с конца вернул 0. Позиция одна на чтение и на запись. После записи она стоит в конце, и читать оттуда нечего. Ноль это не ошибка и не “пока нет данных”, а именно конец файла. Новички часто ждут, что read после write вернёт только что записанное: нет, сначала перемотай.
read с позиции 7 вернул 8. Просили 64 байта, в файле от седьмого до конца их восемь. Это первый короткий счёт в уроке, самый безобидный: файл кончился раньше заказа. Следующий вызов честно отдаёт ноль.
Размер 101. lseek разрешает поставить позицию за конец файла. Сам по себе он ничего не меняет, но запись оттуда растягивает файл, а пропущенный участок читается нулями:
$ xxd note.txt | head -3
00000000: 6865 6c6c 6f2c 2075 6e69 7820 696f 0a00 hello, unix io..
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................
Это дыра. На ext4, XFS, APFS блоки под неё не выделяются: файл с дырой на гигабайт занимает на диске килобайты. Так устроены образы виртуальных машин и файлы баз данных.
Обрати внимание на третий аргумент open: @as(c_uint, 0o644). В C у open переменное число аргументов, и права нового файла передаются только вместе с O_CREAT. Zig объявляет функцию так же, с ..., а в вариативный вызов нельзя передать comptime_int: нужен конкретный тип. Забудешь права при CREAT, и ядро возьмёт их из того, что случайно лежало в регистре третьего аргумента, и файл получится со случайным набором прав. Итоговые права это 0o644 минус биты из umask процесса, как и в оболочке.
Ещё одна вещь, которую сырые вызовы не прощают. Каждый из них может вернуть -1, и в листинге выше мы проверили только open. В учебном примере на локальном файле это сходит с рук. В настоящем коде непроверенный write означает молча потерянные данные на полном диске, а непроверенный close пропускает ошибку отложенной записи на сетевой файловой системе. Книга ради этого заводит обёртки с заглавной буквы (Open, Read), которые при ошибке завершают программу. В Zig ту же роль играют ошибки как значения, и до них мы дойдём в разделе про std.Io.File.
Без libc: куда девается errno
На Linux libc для этих вызовов почти ничего не делает: кладёт аргументы в регистры, выполняет syscall, разбирает результат. Zig умеет то же самое сам, через std.os.linux. Посмотри, как при этом выглядит ошибка.
const std = @import("std");
const linux = std.os.linux;
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 rc = linux.open("/no/such/file", .{ .ACCMODE = .RDONLY }, 0);
try out.print("open вернул 0x{x}, как знаковое {d}, errno = {t}\n", .{ rc, @as(isize, @bitCast(rc)), linux.errno(rc) });
const fd = linux.open("/etc/hostname", .{ .ACCMODE = .RDONLY }, 0);
var in: [64]u8 = undefined;
const n = linux.read(@intCast(fd), &in, in.len);
try out.print("fd = {d}, read = {d}\n", .{ fd, n });
_ = linux.close(@intCast(fd));
const again = linux.read(@intCast(fd), &in, in.len);
try out.print("read из закрытого: errno = {t}\n", .{linux.errno(again)});
try out.flush();
}
$ zig build-exe -target x86_64-linux nolibc.zig && ./nolibc # Debian в docker, linux/amd64
open вернул 0xfffffffffffffffe, как знаковое -2, errno = NOENT
fd = 3, read = 13
read из закрытого: errno = BADF
Никакого errno у ядра нет. Системный вызов возвращает одно число в %rax, и ошибка закодирована в нём самом: значения от -4095 до -1 это код ошибки с минусом. ENOENT равен 2, поэтому несуществующий файл даёт -2, то есть 0xfffffffffffffffe в беззнаковом виде. Переменную errno придумала libc: её обёртка видит отрицательное число из этого диапазона, кладёт его модуль в errno (он свой у каждого потока) и возвращает -1. linux.errno(rc) делает первую половину этой работы без второй.
Отсюда же растёт забавное ограничение: за один read на Linux нельзя передать больше 0x7ffff000 байтов, чуть меньше двух гибибайт. Верхние 4096 значений заняты кодами ошибок, остальное срезано до границы страницы. Исходники std.posix.read учитывают это явно: заказ зажимается до этого потолка перед вызовом. Вот тебе ещё один законный источник короткого счёта: попросил три гибибайта, получил меньше двух, даже на обычном файле.
Бинарник вышел статическим (ldd отвечает not a dynamic executable): libc в нём нет вообще.
На macOS. Всё, что идёт через
std.c, собирается и запускается на macOS напрямую: libc там часть системы, и Zig линкуется с ней всегда, флаг-lcможно не писать. А вот путиstd.os.linuxна macOS нет и быть не может: Apple не обещает стабильных номеров системных вызовов, единственный поддерживаемый вход в ядро этоlibSystem. Поэтомуnolibc.zigсобирай кросс-компиляцией (-target x86_64-linux) и запускай в контейнере:docker run --rm --platform linux/amd64 -v "$PWD":/w debian:stable-slim /w/nolibc. Числа тоже местами другие: буфер пайпа на macOS начинается с 16 КиБ и вырастает до 64 КиБ,PIPE_BUFравен 512 против 4096 на Linux, предел дескрипторов в оболочке 256. Выводы листингов этого урока сняты на macOS 26 (Apple Silicon), если в подписи не сказано иное; на Linux те же программы дают те же числа, кроме размера короткой записи в опыте сwrite.
Короткие счёты
Вот сигнатура, ради которой написан этот урок:
read(fd, buf, n) -> n просили n, получили n
k < n получили меньше: короткий счёт
0 конец файла
-1 ошибка, причина в errno
Короткий счёт это второй случай. Он не ошибка: ядро сделало ровно то, что обещает документация, а обещает она “до n байтов”. Беда в том, что на обычном файле с локального диска короткий счёт встречается только у конца файла, и программа, написанная в предположении “сколько просил, столько и дали”, годами проходит все тесты. А потом её вход перенаправляют из пайпа или сокета.
Откуда берутся короткие счёты:
| Источник | Что происходит | Лечится повтором? |
|---|---|---|
| Конец файла | просили 64, до конца осталось 8: вернётся 8, следующий вызов вернёт 0 | нет, данных больше не будет |
| Терминал | в обычном режиме ядро отдаёт по строке: read возвращается, когда нажат Enter, с тем, что в строке набрано | да |
| Пайп | read отдаёт то, что лежит в буфере пайпа прямо сейчас, и не ждёт, пока писатель допишет остальное | да |
| Сокет | данные приходят пакетами, задержки сети режут поток где попало; размер куска не связан с тем, как нарезал отправитель | да |
| Сигнал посреди передачи | вызов, который уже передал часть байтов и был прерван сигналом, возвращает то, что успел | да |
| Сигнал до передачи | вызов ничего не успел: возвращает -1 с EINTR. Формально это ошибка, по сути просьба повторить | да |
| Потолок ядра | больше 0x7ffff000 байтов за вызов Linux не передаёт | да |
У записи свой список, он короче: write на обычный файл и в блокирующий пайп пишет всё или засыпает, пока не допишет. Короткая запись случается, когда место кончилось на середине (диск, квота), когда дескриптор неблокирующий и буфер заполнился, и когда спящий write прервали сигналом после того, как часть данных уже ушла. Последний случай мы сейчас воспроизведём.
Сначала посмотри глазами на самый простой инструмент. Программа читает стандартный ввод блоками по 4096 и печатает в stderr счёт каждого вызова:
const std = @import("std");
const c = std.c;
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stderr().writerStreaming(init.io, &buf);
const out = &w.interface;
var in: [4096]u8 = undefined;
while (true) {
const n = c.read(0, &in, in.len);
try out.print("read(0, buf, {d}) = {d}\n", .{ in.len, n });
try out.flush();
if (n <= 0) break;
}
}
Три запуска на трёх источниках:
$ ./counts < /usr/share/dict/words 2>&1 | sort | uniq -c
608 read(0, buf, 4096) = 4096
1 read(0, buf, 4096) = 3517
1 read(0, buf, 4096) = 0
$ (echo ab; sleep 0.3; echo cdef; sleep 0.3; printf xyz) | ./counts
read(0, buf, 4096) = 3
read(0, buf, 4096) = 5
read(0, buf, 4096) = 3
read(0, buf, 4096) = 0
$ ./counts # с клавиатуры: набираем hello, Enter, потом Ctrl+D
hello
read(0, buf, 4096) = 6
read(0, buf, 4096) = 0
Файл размером 2 493 885 байтов даёт 608 полных блоков, один хвост на 3517 (608 на 4096 плюс 3517 это как раз размер файла) и ноль. Короткий счёт ровно один, у конца файла. Пайп с медленным писателем даёт короткий счёт на каждом вызове: read просыпается, как только в буфере появилось хоть что-то, забирает это и возвращается. Он не знает и не может знать, что писатель собирался дописать ещё. С терминала приходит по строке: шесть байтов это hello и перевод строки. Ctrl+D на пустой строке не посылает никакого символа: драйвер терминала просто завершает read с тем, что накоплено, то есть с нулём, и программа видит конец файла.
Заметь, что counts пишет отчёт через writerStreaming в stderr и делает flush после каждой строки: иначе при перенаправлении порядок строк относительно эха терминала был бы другим.
Живой опыт: пайп с медленным писателем
Теперь без оболочки, целиком в одной программе. Родитель создаёт пайп и порождает потомка. Потомок пишет 40 байтов четырьмя порциями с паузами по 50 миллисекунд. Родитель каждый раз просит все 40.
const std = @import("std");
const c = std.c;
fn pause(ms: u32) void {
const ts: c.timespec = .{ .sec = 0, .nsec = @as(c_long, ms) * 1_000_000 };
_ = c.nanosleep(&ts, null);
}
/// Медленный писатель: 40 байтов четырьмя порциями с паузами.
fn writer(fd: c.fd_t) noreturn {
const parts = [_][]const u8{ "pack my ", "box with five", " dozen liquor", " jugs\n" };
for (parts) |part| {
_ = c.write(fd, part.ptr, part.len);
pause(50);
}
c._exit(0);
}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
const pid = c.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
_ = c.close(fds[0]);
writer(fds[1]);
}
// Свой пишущий конец закрываем, иначе конца файла не дождаться.
_ = c.close(fds[1]);
var in: [40]u8 = undefined;
while (true) {
const n = c.read(fds[0], &in, in.len);
try out.print("read(fd, buf, {d}) = {d}\n", .{ in.len, n });
if (n <= 0) break;
}
_ = c.waitpid(pid, null, 0);
try out.flush();
}
$ zig run -lc shortpipe.zig
read(fd, buf, 40) = 8
read(fd, buf, 40) = 13
read(fd, buf, 40) = 13
read(fd, buf, 40) = 6
read(fd, buf, 40) = 0
Четыре коротких счёта подряд, и каждый совпал с порцией писателя. Это совпадение случайное и держится только на паузах. Убери pause(50), и писатель успеет положить в пайп все четыре порции до того, как читатель проснётся: у меня восемь запусков из восьми дали 40, 0. Четыре вызова write слились в один read. На загруженной машине планировщик может вклиниться между порциями, и тогда разрез пройдёт в любом месте. Пайп это поток байтов, а не очередь сообщений. Границ между вызовами write в нём нет, и читатель не вправе на них рассчитывать.
Про механику. fork и waitpid ты знаешь из урока про процессы; pipe подробно разберём в уроке 62, пока хватит того, что он возвращает два дескриптора: из fds[0] читают то, что записали в fds[1]. Важная строка в родителе: close(fds[1]). После fork пишущий конец открыт в обоих процессах. read вернёт ноль только тогда, когда закрыты все пишущие концы. Забудь эту строку, и родитель после четвёртой порции уснёт навсегда: ждёт данных от писателя, которым является он сам.
Буфер stdout в момент fork пуст, а потомок выходит через _exit, который буферов не сбрасывает. Иначе всё, что лежало в буфере на момент fork, напечаталось бы дважды: эту ловушку ты видел в уроке про процессы.
EINTR: вызов, который ничего не успел
Остался сигнал. Процесс спит в read, данных нет, приходит сигнал, у которого есть обработчик. Ядро обязано обработчик запустить, а для этого надо вернуться в режим пользователя, то есть прервать системный вызов. Что будет после обработчика, зависит от флага SA_RESTART, с которым обработчик ставили: либо ядро тихо запускает вызов заново, либо вызов возвращает -1 с errno, равным EINTR.
const std = @import("std");
const c = std.c;
const posix = std.posix;
fn onAlarm(_: posix.SIG) callconv(.c) void {
_ = c.write(2, " (сигнал)\n", 17);
}
fn install(flags: c_uint) void {
const action: posix.Sigaction = .{
.handler = .{ .handler = onAlarm },
.mask = posix.sigemptyset(),
.flags = flags,
};
posix.sigaction(.ALRM, &action, null);
}
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;
// Пайп, в который никто не пишет: read уснёт надолго.
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
var in: [16]u8 = undefined;
install(0);
_ = c.alarm(1);
const n = c.read(fds[0], &in, in.len);
try out.print("без SA_RESTART: read = {d}, errno = {t}\n", .{ n, posix.errno(n) });
try out.flush();
// Теперь с перезапуском. Через секунду придёт сигнал, через две в пайпе
// появятся данные: потомок пишет их и выходит.
install(posix.SA.RESTART);
const pid = c.fork();
if (pid == 0) {
const ts: c.timespec = .{ .sec = 2, .nsec = 0 };
_ = c.nanosleep(&ts, null);
_ = c.write(fds[1], "done", 4);
c._exit(0);
}
_ = c.alarm(1);
const m = c.read(fds[0], &in, in.len);
try out.print("с SA_RESTART: read = {d}\n", .{m});
try out.flush();
_ = c.waitpid(pid, null, 0);
}
$ zig run -lc eintr.zig
(сигнал)
без SA_RESTART: read = -1, errno = INTR
(сигнал)
с SA_RESTART: read = 4
Первый read прерван: минус один и EINTR. Данные не потеряны, их просто не было, позиция не сдвинулась, вызов можно и нужно повторить. Второй read пережил тот же сигнал незаметно для программы: обработчик отработал, ядро перезапустило вызов, и через секунду он вернул четыре байта от потомка.
Отсюда не следует, что достаточно всегда ставить SA_RESTART. Во-первых, обработчики ставишь не только ты: любая библиотека в процессе может поставить свой без этого флага. Во-вторых, перезапускаются не все вызовы: man 7 signal перечисляет те, что возвращают EINTR при любом флаге (например, poll, nanosleep, чтение из сокета с таймаутом). В-третьих, оболочке из урока про управление заданиями EINTR нужен намеренно: это способ выдернуть главный цикл из read и сообщить о завершившемся фоновом задании. Поэтому переносимый код проверяет EINTR после каждого медленного вызова, всегда.
Короткая запись
И обещанный write. Родитель пишет в пайп 300 000 байтов одним вызовом. Потомок две секунды не читает вовсе. Через секунду родителю приходит SIGALRM, обработчик без SA_RESTART.
const std = @import("std");
const c = std.c;
const posix = std.posix;
fn onAlarm(_: posix.SIG) callconv(.c) void {}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
const pid = c.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
// Ленивый читатель: две секунды спит, потом вычитывает всё до конца.
_ = c.close(fds[1]);
const ts: c.timespec = .{ .sec = 2, .nsec = 0 };
_ = c.nanosleep(&ts, null);
var sink: [65536]u8 = undefined;
while (c.read(fds[0], &sink, sink.len) > 0) {}
c._exit(0);
}
_ = c.close(fds[0]);
const action: posix.Sigaction = .{
.handler = .{ .handler = onAlarm },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(.ALRM, &action, null);
const data: [300_000]u8 = @splat('x');
var rest: []const u8 = &data;
_ = c.alarm(1);
while (rest.len > 0) {
const n = c.write(fds[1], rest.ptr, rest.len);
try out.print("write(fd, buf, {d}) = {d}", .{ rest.len, n });
if (n < 0) {
try out.print(", errno = {t}\n", .{posix.errno(n)});
continue;
}
try out.writeAll("\n");
rest = rest[@intCast(n)..];
}
_ = c.close(fds[1]);
_ = c.waitpid(pid, null, 0);
try out.flush();
}
$ zig run -lc shortwrite.zig
write(fd, buf, 300000) = 65536
write(fd, buf, 234464) = 234464
Первый write заполнил буфер пайпа, 65 536 байтов, и уснул в ожидании места. Сигнал его разбудил. Вернуть EINTR ядро уже не может: 65 536 байтов реально ушли, и сказать “ничего не вышло” было бы ложью. Поэтому write возвращает то, что успел: короткий счёт. Программа, которая не смотрит на результат write, потеряла бы здесь 234 464 байта, и ни одна ошибка нигде бы не всплыла. Второй вызов дописывает остаток: потомок к тому времени проснулся и вычитывает пайп.
Размер буфера это свойство системы: на Linux по умолчанию те же 65 536 байтов (16 страниц, man 7 pipe), и его можно поменять через fcntl с F_SETPIPE_SZ. На macOS пайп начинается с 16 384 и вырастает до 65 536 под нагрузкой, что мы и увидели.
Теперь потрогай всё это руками. Виджет ниже моделирует дескриптор, за которым стоит один из четырёх источников, и программу в двух вариантах: наивную, с одним read, и с циклом.
Что посмотреть. Оставь “пайп, медленный писатель”, включи “один read” и нажми “вызвать read”: в буфере оказалось несколько байтов из 33, остальные ячейки так и остались вопросами, а программа уверена, что прочитала всё. Не меняя зерна, переключись на “цикл readn” и пройди по шагам: каждый следующий заказ это остаток, а не исходные 33. На “сокете и сигналах” в журнале появятся красные строки -1 EINTR: набранное при этом не растёт и не теряется. На “файле короче заказа” цикл кончается строкой 0 EOF, и это единственный случай, когда readn законно возвращает меньше заказа.
readn и writen: цикл, который дочитывает
Лекарство одно на все источники: звать read в цикле, пока не наберётся заказанное. Книга называет эту пару rio_readn и rio_writen (RIO это её пакет надёжного ввода и вывода). У неё две половины: функции без буфера, которые мы пишем сейчас, и буферизованные, которые достанутся следующему уроку.
const std = @import("std");
const c = std.c;
const posix = std.posix;
pub const Error = error{ ReadFailed, WriteFailed };
/// Читает ровно `buf.len` байтов. Меньше вернёт только на конце файла.
pub fn readn(fd: c.fd_t, buf: []u8) Error!usize {
var got: usize = 0;
while (got < buf.len) {
const n = c.read(fd, buf[got..].ptr, buf.len - got);
if (n < 0) {
if (posix.errno(n) == .INTR) continue; // сигнал: ничего не прочитано, повторяем
return error.ReadFailed;
}
if (n == 0) break; // конец файла
got += @intCast(n);
}
return got;
}
/// Пишет весь срез. Короткого счёта у неё не бывает: либо всё, либо ошибка.
pub fn writen(fd: c.fd_t, bytes: []const u8) Error!void {
var rest = bytes;
while (rest.len > 0) {
const n = c.write(fd, rest.ptr, rest.len);
if (n < 0) {
if (posix.errno(n) == .INTR) continue;
return error.WriteFailed;
}
rest = rest[@intCast(n)..];
}
}
fn pause(ms: u32) void {
const ts: c.timespec = .{ .sec = 0, .nsec = @as(c_long, ms) * 1_000_000 };
_ = c.nanosleep(&ts, null);
}
fn writer(fd: c.fd_t) noreturn {
const parts = [_][]const u8{ "pack my ", "box with five", " dozen liquor", " jugs\n" };
for (parts) |part| {
writen(fd, part) catch c._exit(1);
pause(50);
}
c._exit(0);
}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
const pid = c.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
_ = c.close(fds[0]);
writer(fds[1]);
}
_ = c.close(fds[1]);
var in: [40]u8 = undefined;
const first = try readn(fds[0], &in);
try out.print("readn(fd, buf, 40) = {d}: {s}", .{ first, in[0..first] });
const second = try readn(fds[0], &in);
try out.print("readn(fd, buf, 40) = {d}\n", .{second});
_ = c.waitpid(pid, null, 0);
try out.flush();
}
$ zig run -lc rio.zig
readn(fd, buf, 40) = 40: pack my box with five dozen liquor jugs
readn(fd, buf, 40) = 0
Тот же медленный писатель, те же четыре порции, но программа видит один вызов и сорок байтов. Второй readn возвращает ноль: писатель вышел, пишущих концов не осталось.
В readn четыре ветки, и каждая отвечает на свой исход read:
n < 0иEINTR. Сигнал пришёл раньше данных. Ничего не прочитано,gotне трогаем, повторяем тот же заказ.n < 0, другая ошибка. Настоящая беда: плохой дескриптор, ошибка устройства. Наверх. То, что уже лежит в буфере, вызывающему не достанется, и это осознанное решение: после ошибки состояние потока неизвестно.n == 0. Конец файла. Выходим из цикла и возвращаем, сколько набрали. Это единственный путь, на которомreadnвозвращает меньше заказа, и потому вызывающий вправе трактовать недобор однозначно: данных больше нет.n > 0. Сдвигаемgot. Следующий заказ этоbuf.len - got, а пишем по адресуbuf[got..].ptr. Перепутаешь одно из двух, и либо затрёшь прочитанное, либо вылезешь за буфер.
Срезы Zig здесь просто удобнее указателей C: buf[got..] несёт и адрес, и остаток длины разом, и в Debug выход за границу ловится проверкой, а не порчей стека.
У writen веток меньше. Нуля на записи практически не бывает, конца файла нет, поэтому результат у неё не счёт, а void: либо записано всё, либо ошибка. Это хорошее свойство интерфейса. Функции, у которой не бывает короткого счёта, нельзя забыть его проверить.
Почему такие функции не лежат в ядре? Потому что ядро не знает, чего ты хочешь. Интерактивной программе нужен именно короткий счёт: cat с терминала должен отдать строку сразу, как её набрали, а не ждать, пока наберётся 4096 байтов. Сервер, который ждёт заголовок фиксированной длины, хочет ровно N байтов. read даёт механизм, политику выбирает программа. readn это политика “мне нужно ровно столько”.
Там же лежит её ограничение: readn годится, только когда ты заранее знаешь, сколько байтов ждёшь. Строку текста так не прочитать: её длина выясняется по ходу дела. Читать по байту можно, но это системный вызов на каждый символ. Правильный ответ это буфер внутри программы, и про него весь урок 61.
std.Io.File: что даёт обёртка
Теперь посмотрим, как то же самое выглядит в штатном Zig 0.16. std.Io.File это структура из двух полей: handle (тот самый дескриптор) и flags с одним битом nonblocking. Все операции принимают io: через него вызов уходит в ту реализацию ввода и вывода, которую программа выбрала в main (потоки, цикл событий), так же как память уходит в выбранный аллокатор.
const std = @import("std");
const c = std.c;
const Io = std.Io;
fn pause(ms: u32) void {
const ts: c.timespec = .{ .sec = 0, .nsec = @as(c_long, ms) * 1_000_000 };
_ = c.nanosleep(&ts, null);
}
fn writer(fd: c.fd_t) noreturn {
const parts = [_][]const u8{ "pack my ", "box with five", " dozen liquor", " jugs\n" };
for (parts) |part| {
_ = c.write(fd, part.ptr, part.len);
pause(50);
}
c._exit(0);
}
pub fn main(init: std.process.Init) !void {
const io = init.io;
var buf: [4096]u8 = undefined;
var w = Io.File.stdout().writer(io, &buf);
const out = &w.interface;
// Обычный файл: open, write, read и close за именами std.Io.
const file = try Io.Dir.cwd().createFile(io, "note2.txt", .{ .read = true });
defer file.close(io);
try file.writeStreamingAll(io, "hello, std.Io\n");
var in: [40]u8 = undefined;
const n = try file.readPositional(io, &.{&in}, 7);
try out.print("handle = {d}, readPositional с 7 вернул {d}: {s}", .{ file.handle, n, in[0..n] });
// Пайп с медленным писателем: обёртка короткие счёты не прячет.
var fds: [2]c.fd_t = undefined;
if (c.pipe(&fds) != 0) return error.PipeFailed;
const pid = c.fork();
if (pid < 0) return error.ForkFailed;
if (pid == 0) {
_ = c.close(fds[0]);
writer(fds[1]);
}
_ = c.close(fds[1]);
const pipe_end: Io.File = .{ .handle = fds[0], .flags = .{ .nonblocking = false } };
defer pipe_end.close(io);
while (true) {
const got = pipe_end.readStreaming(io, &.{&in}) catch |err| {
try out.print("readStreaming: error.{t}\n", .{err});
break;
};
try out.print("readStreaming = {d}\n", .{got});
}
_ = c.waitpid(pid, null, 0);
try out.flush();
}
$ zig run -lc iofile.zig
handle = 3, readPositional с 7 вернул 7: std.Io
readStreaming = 8
readStreaming = 13
readStreaming = 13
readStreaming = 6
readStreaming: error.EndOfStream
Первое, что видно: handle = 3. Никакой магии, под капотом тот же наименьший свободный дескриптор. Дальше по пунктам, что обёртка добавляет.
Ошибки как значения. Вместо -1 и errno каждая операция возвращает набор ошибок с именами: error.FileNotFound, error.AccessDenied, error.NoSpaceLeft, error.BrokenPipe. Забыть проверку нельзя: компилятор не даст проигнорировать результат с ошибкой. Это закрывает всю тему обёрток с заглавной буквы из книги.
EINTR спрятан. Открой std.posix.read в исходниках: ветка .INTR => continue стоит прямо в цикле вокруг системного вызова. Реализации std.Io делают то же самое. Программе на Zig EINTR не виден вообще, если она не ходит в std.c сама, как мы сегодня.
Конец файла это ошибка, а не ноль. readStreaming на конце потока возвращает error.EndOfStream. Ноль у него тоже возможен, но значит другое: в переданных буферах не было места. У readPositional наоборот, конец файла это ноль. Сверяйся с комментарием над функцией, а не с памятью.
Позиционные операции. readPositional и writePositional принимают смещение явно и не трогают позицию в ядре: под ними pread и pwrite. Это безопасно для потоков (два потока не дерутся за одну позицию) и потому выбрано по умолчанию: file.reader(io, &buf) сначала пробует позиционный режим и откатывается на потоковый, если файл не умеет lseek (пайп, терминал, сокет). Отсюда ловушка, про которую ты читаешь в бойлерплейте каждого урока: stdout().writer при перенаправлении в файл с >> пишет позиционно с нуля и затирает начало файла. Для вывода, который должен дописываться, бери writerStreaming.
Векторы. Буфер у readStreaming это срез срезов, &.{&in}: под ним readv, один системный вызов наполняет несколько буферов подряд. Нам хватило одного.
Отмена. В наборах ошибок есть error.Canceled: задачу, спящую в read, можно отменить через io. У сырого вызова такой возможности нет, кроме сигнала.
А вот чего обёртка не делает: она не прячет короткие счёты. Четыре строки readStreaming = 8, 13, 13, 6 те же самые, что у сырого read. И это правильно, по той же причине, по которой readn не живёт в ядре. Циклы, которые дочитывают, в std лежат этажом выше. readPositionalAll и writeStreamingAll это буквально наши readn и writen, только в позиционном и потоковом вариантах: открой File.zig, там по пять строк на каждую. А в потоковом чтении ту же роль играет std.Io.Reader со своим буфером: его readSliceAll крутит цикл за тебя. Это уже территория следующего урока.
Заодно посмотри на createFile: опция .read = true превращает O_WRONLY в O_RDWR, без неё readPositional вернул бы error.NotOpenForReading. Флаги никуда не делись, у них просто появились имена.
Шаг проекта: консоль и диск для Y86
Пора дать нашей машине внешний мир. После ядра с двумя процессами и страничной памяти у Y86 есть почти всё, что делает компьютер компьютером, кроме устройств. Единственный выход наружу до сих пор это инструкция out, которую мы когда-то добавили в ISA специально ради консоли. Настоящие процессоры так почти не делают. Сегодня мы уберём её из ядра и подключим два устройства тем способом, каким их подключает большинство архитектур.
Устройство это адреса в памяти
Ввод и вывод с отображением в память устроен просто: у устройства есть несколько регистров, и каждому назначен физический адрес. Запись по такому адресу попадает не в оперативную память, а в устройство, и оно что-то делает: печатает символ, начинает читать сектор. Чтение возвращает состояние или данные. Процессору не нужны ни новые инструкции, ни новые провода в тракте данных: rmmovq и mrmovq уже умеют всё нужное. Так работает почти всё на ARM и RISC-V, так работает PCIe на x86-64. Отдельное адресное пространство портов с инструкциями in и out у x86 осталось как наследие.
Наша карта устройств, четыре слова у самой вершины физической памяти:
| Адрес | Имя | Направление | Смысл |
|---|---|---|---|
0xf80 | console | запись | младший байт слова уходит на консоль |
0xf88 | disk_req | запись | сколько байтов ядро просит у диска |
0xf90 | disk_len | чтение | сколько байтов диск отдаёт на этот раз, 0 это конец файла |
0xf98 | disk_data | чтение | очередной байт; каждое чтение сдвигает диск дальше |
Диск у нас с характером. Он “медленный” и отдаёт, сколько успел: на заказ в 32 байта может выдать 8. Сколько именно, решает генератор случайных чисел с зерном, заданным при запуске, так что один и тот же прогон режет данные одинаково и тесты воспроизводимы. Нулевое зерно даёт исправный диск, который отдаёт всё запрошенное. Протокол в три шага: ядро пишет заказ в disk_req, читает из disk_len, сколько дадут на самом деле, и забирает столько байтов по одному из disk_data.
Узнаёшь? Это read с короткими счётами, только уровнем ниже: между драйвером и железом. Короткий счёт рождается в устройстве, проходит сквозь ядро, не меняясь, и достаётся процессу. Дочитывать будет процесс.
devices.zig: два устройства и карта регистров
Новый модуль целиком. Он чистый: про процессор не знает ничего, получает срез памяти машины и физический адрес обращения.
//! Устройства с отображением в память: консоль и диск.
//!
//! У устройства нет своих инструкций. Его регистры занимают адреса в физической
//! памяти, и ядро разговаривает с ним обычными `rmmovq` и `mrmovq`:
//!
//! 0xf80 console запись: младший байт слова уходит на консоль
//! 0xf88 disk_req запись: сколько байтов ядро просит у диска
//! 0xf90 disk_len чтение: сколько байтов диск отдаёт в этот раз, 0 это конец
//! 0xf98 disk_data чтение: очередной байт, каждое чтение сдвигает диск дальше
//!
//! Диск медленный и отдаёт, сколько успел: за одно обращение меньше, чем
//! просили. Сколько именно, решает генератор случайных чисел с заданным
//! зерном, поэтому короткие счёты одни и те же от прогона к прогону. Нулевое
//! зерно даёт исправный диск, который отдаёт всё запрошенное.
//!
//! Значения регистров лежат прямо в памяти машины. Устройство смотрит, по
//! какому физическому адресу прошла инструкция, и после неё обновляет свои
//! слова. Реагирует оно только на режим ядра: под страничной памятью процессу
//! до этих адресов и так не добраться, а без неё это единственная защита.
const std = @import("std");
pub const console_addr = 0xf80;
pub const disk_req_addr = 0xf88;
pub const disk_len_addr = 0xf90;
pub const disk_data_addr = 0xf98;
/// Что устройство сделало в ответ на обращение. Нужно трассе.
pub const Event = union(enum) {
/// Байт ушёл на консоль.
console: u8,
/// Диску заказали `asked` байтов, он отдаёт `given`.
disk: struct { asked: u64, given: u64 },
};
pub const Disk = struct {
data: []const u8 = "",
pos: usize = 0,
/// Сколько байтов осталось от текущей выдачи.
left: u64 = 0,
/// Состояние генератора xorshift64. Ноль: коротких счётов нет.
rng: u64 = 0,
fn random(self: *Disk) u64 {
var x = self.rng;
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
self.rng = x;
return x;
}
/// Сколько байтов диск отдаст на просьбу об `asked`: не больше, чем
/// осталось в файле, и от одного до этого числа, если диск «медленный».
pub fn grant(self: *Disk, asked: u64) u64 {
const most = @min(asked, self.data.len - self.pos);
const given = if (self.rng == 0 or most < 2) most else 1 + self.random() % most;
self.left = given;
return given;
}
/// Очередной байт выдачи. За её концом диск отдаёт нули.
pub fn next(self: *Disk) u8 {
if (self.left == 0) return 0;
self.left -= 1;
self.pos += 1;
return self.data[self.pos - 1];
}
fn peek(self: *const Disk) u8 {
return if (self.left == 0) 0 else self.data[self.pos];
}
};
pub const Devices = struct {
disk: Disk = .{},
pub fn init(data: []const u8, seed: u64) Devices {
return .{ .disk = .{ .data = data, .rng = seed } };
}
/// Инструкция ядра обратилась к физическому адресу `pa`. Если это регистр
/// устройства, оно срабатывает и обновляет свои слова в памяти.
pub fn access(self: *Devices, mem: []u8, pa: u64, write: bool) ?Event {
switch (pa) {
console_addr => if (write) return .{ .console = mem[console_addr] },
disk_req_addr => if (write) {
const asked = std.mem.readInt(u64, mem[disk_req_addr..][0..8], .little);
const given = self.disk.grant(asked);
std.mem.writeInt(u64, mem[disk_len_addr..][0..8], given, .little);
std.mem.writeInt(u64, mem[disk_data_addr..][0..8], self.disk.peek(), .little);
return .{ .disk = .{ .asked = asked, .given = given } };
},
disk_data_addr => if (!write) {
_ = self.disk.next();
std.mem.writeInt(u64, mem[disk_data_addr..][0..8], self.disk.peek(), .little);
},
else => {},
}
return null;
}
};
const testing = std.testing;
test "исправный диск отдаёт всё, медленный меньше, конец файла это ноль" {
var good: Disk = .{ .data = "0123456789" };
try testing.expectEqual(@as(u64, 4), good.grant(4));
try testing.expectEqual(@as(u8, '0'), good.next());
var slow: Disk = .{ .data = "0123456789", .rng = 7 };
var total: u64 = 0;
var short = false;
while (true) {
const given = slow.grant(10);
if (given == 0) break;
if (given < 10 - total) short = true;
for (0..given) |_| _ = slow.next();
total += given;
}
try testing.expect(short);
try testing.expectEqual(@as(u64, 10), total);
}
test "регистры диска: заказ, длина выдачи, байты по одному" {
var mem: [4096]u8 = @splat(0);
var dev: Devices = .init("hi", 0);
std.mem.writeInt(u64, mem[disk_req_addr..][0..8], 8, .little);
const event = dev.access(&mem, disk_req_addr, true).?;
try testing.expectEqual(@as(u64, 2), event.disk.given);
try testing.expectEqual(@as(u8, 2), mem[disk_len_addr]);
try testing.expectEqual(@as(u8, 'h'), mem[disk_data_addr]);
_ = dev.access(&mem, disk_data_addr, false);
try testing.expectEqual(@as(u8, 'i'), mem[disk_data_addr]);
_ = dev.access(&mem, disk_data_addr, false);
// Файл кончился: следующая выдача пустая.
_ = dev.access(&mem, disk_req_addr, true);
try testing.expectEqual(@as(u8, 0), mem[disk_len_addr]);
}
На что смотреть.
Значения регистров лежат в памяти машины. У устройства нет своего хранилища для disk_len и disk_data: оно пишет их прямо в mem по своим адресам, и mrmovq читает их оттуда как обычные слова. Устройство срабатывает после инструкции: access вызывается, когда такт уже завершён, смотрит, по какому адресу прошло обращение, и обновляет слова. Поэтому запись в disk_req сначала ложится в память как обычная запись, а потом диск читает заказ из памяти же.
Чтение с побочным эффектом. disk_data это регистр, чтение которого меняет состояние устройства: каждый mrmovq оттуда продвигает диск на байт, и в слове уже лежит следующий. Для оперативной памяти такое немыслимо, для регистров устройств обычное дело. Именно поэтому компиляторам нельзя оптимизировать обращения к таким адресам, и в C их помечают volatile, а в Zig читают через *volatile указатель. У нас ассемблер, оптимизировать некому.
grant это и есть источник коротких счётов. 1 + random() % most даёт от одного до most байтов, где most это меньшее из заказа и остатка файла. Ноль диск отдаёт только в одном случае: файл кончился. Ровно та же тройка исходов, что у read: полный счёт, короткий, ноль. Генератор xorshift64 занимает три строки, и его состояние не может стать нулём из ненулевого, поэтому ноль можно занять под “коротких счётов нет”.
Изменённые места симулятора
Устройству нужен физический адрес обращения, а процессор его наружу не отдавал. В cpu.zig три добавки: поле в сигналах такта, два поля в записи о завершённой инструкции и одна строка в стадии памяти, сразу после трансляции.
// src/sim/cpu.zig, в Signals
/// Физический адрес состоявшегося обращения. По нему устройства узнают,
/// что обратились к ним.
mem_pa: ?u64 = null,
// там же, в Retired
/// Физический адрес обращения к памяти и было ли оно записью.
mem_pa: ?u64 = null,
mem_write: bool = false,
// в конце memory(): resolve уже перевела адрес через MMU
var pas: [8]u64 = undefined;
if (!resolve(st, s, s.mem_addr, &pas, 0, if (s.mem_write) .write else .read)) return;
s.mem_pa = pas[0];
// в step(), где собирается Retired
.mem_pa = s.mem_pa,
.mem_write = s.mem_write,
Адрес берётся после resolve, то есть уже физический. Это принципиально: устройства живут в физическом адресном пространстве, виртуальных адресов они не видят. Если обращение кончилось сбоем страницы, memory выходит раньше, mem_pa остаётся null, и устройство ничего не замечает.
В computer.zig появляются три поля конфигурации, само устройство и метод poke, который зовётся после каждого такта:
// src/sim/computer.zig, в Config
/// Включены ли устройства с отображением в память. Старым ядрам их
/// показывать нельзя: у них по этим адресам стек.
devices: bool = false,
/// Содержимое диска и зерно коротких счётов. Нулевое зерно: диск исправен.
disk: []const u8 = "",
disk_seed: u64 = 0,
// в Computer
devices: devices.Devices = .{},
// в init
self.devices = .init(config.disk, config.disk_seed);
// в step, сразу после record
try self.poke(r);
/// Дать устройствам посмотреть на обращение к памяти. Слушают они только ядро.
fn poke(self: *Computer, r: cpu.Retired) Writer.Error!void {
if (!self.config.devices or r.user) return;
const event = self.devices.access(&self.st.mem, r.mem_pa orelse return, r.mem_write) orelse return;
switch (event) {
.console => |byte| {
if (self.console) |c| try c.writeByte(byte);
if (self.trace_out) |w| try w.print("out {x:0>2}\n", .{byte});
},
.disk => |d| if (self.trace_out) |w| try w.print("disk {d} -> {d}\n", .{ d.asked, d.given }),
}
}
Два решения здесь стоит проговорить.
Устройства слушают только режим ядра (r.user отсекает остальное). Под страничной памятью процессу до 0xf80 и так не добраться: в его таблице страниц такого отображения нет. Но трансляцию включает само ядро, и пока она выключена, бит привилегий это единственная защита. Это та же мысль, что в настоящих системах: пользовательский код не трогает железо, он просит ядро. Иначе любой процесс мог бы читать чужие файлы прямо с диска, минуя все права.
Устройства включаются флагом. Ядра из прошлых уроков ничего про них не знают, и у них по адресу 0xf80 вершина стека. Включи мы устройства всегда, первый же pushq в старом ядре напечатал бы мусор на консоль. Поэтому старые тесты идут с devices = false, и все шаги проекта остаются зелёными.
Байт, ушедший на консоль, попадает в трассу той же строкой out 61, что раньше печатала инструкция out: для читающего трассу неважно, каким путём символ дошёл. У диска своя строка: disk 32 -> 8.
В main.zig у подкоманды kernel появились два ключа. Любой из них включает устройства:
// src/main.zig, в разборе аргументов kernel
} else if (std.mem.eql(u8, arg, "--disk") or std.mem.eql(u8, arg, "--seed")) {
i += 1;
if (i == args.len) {
try cli.err.print("у ключа {s} нет значения\n", .{arg});
return error.BadUsage;
}
config.devices = true;
if (std.mem.eql(u8, arg, "--disk")) {
if (disk) |old| cli.gpa.free(old);
disk = try readSource(cli, args[i]);
config.disk = disk.?;
} else {
config.disk_seed = std.fmt.parseInt(u64, args[i], 0) catch {
try cli.err.print("зерно это число, а не {s}\n", .{args[i]});
return error.BadUsage;
};
}
Выше по функции объявлены var disk: ?[]u8 = null; и defer if (disk) |bytes| cli.gpa.free(bytes);. Остаётся регистрация: строка pub const devices = @import("sim/devices.zig"); в root.zig, три программы в programs.zig и в списке kernel_programs из build.zig, шаг "60" в списке шагов.
// src/programs.zig, в kernel
/// Ядро с консолью и диском в памяти и системным вызовом `read`.
pub const io = @embedFile("io.ys");
/// Дочитывает с диска циклом, сколько бы тот ни отдавал за раз.
pub const readn = @embedFile("readn.ys");
/// Читает один раз и на медленном диске остаётся с обрывком.
pub const readonce = @embedFile("readonce.ys");
io.ys: драйвер в ядре
Ядро io.ys это vm.ys из урока 56 с тремя изменениями. Вот они отдельно, а полный файл ниже.
Первое: в разборе номера системного вызова появился read с номером 0, как в Linux. write остаётся номером 1.
syscall:
mrmovq 0x68(%rbx), %rax
andq %rax, %rax
je sys_read # 0
iaddq $-1, %rax
je sys_write # 1
Второе: write больше не пользуется инструкцией out. Байт уходит на консоль обычной записью в память:
mrmovq (%rax), %rcx
irmovq console, %rdx
rmmovq %rcx, (%rdx) # консоль берёт младший байт слова
Третье: драйвер диска, он же обработчик read.
sys_read:
mrmovq 0x38(%rbx), %rdi
mrmovq 0x40(%rbx), %rsi
irmovq disk_req, %rcx
rmmovq %rsi, (%rcx) # заказ
irmovq disk_len, %rcx
mrmovq (%rcx), %r9 # а вот сколько диск отдаёт
rmmovq %r9, 0x68(%rbx)
irmovq $7, %r8 # valid, write и user: в буфер будем писать
rloop: andq %r9, %r9
je resume
call uaddr
andq %rax, %rax
je efault
irmovq disk_data, %rcx
mrmovq (%rcx), %rcx # очередной байт, диск сдвинулся
# Записи байта в Y86-64 нет: читаем слово, меняем младший байт, пишем.
irmovq $-256, %rdx
mrmovq (%rax), %rsi
andq %rdx, %rsi
addq %rcx, %rsi
rmmovq %rsi, (%rax)
iaddq $1, %rdi
iaddq $-1, %r9
jmp rloop
Пройдём по нему. Аргументы вызова ядро берёт из сохранённого контекста процесса: %rdi лежит в блоке управления процессом по смещению 0x38, %rsi по 0x40, а 0x68 это место %rax, куда кладётся результат. Заказ уходит в disk_req, ответ читается из disk_len и сразу записывается процессу как возвращаемое значение. Ядро не пытается дозаказать недостающее: сколько диск дал, столько read и вернёт. Ровно так ведёт себя настоящее ядро с пайпом.
Дальше цикл по байтам. uaddr это программная трансляция из урока 56: ядро работает без MMU и само переводит адрес в буфере процесса в физический, проверяя по таблице страниц биты из %r8. Для write хватало valid и user (5), для read нужен ещё write (7): ядро будет писать в буфер процесса от его имени, и если страница процессу на запись закрыта, он получит -1, а не дыру в защите. Это EFAULT. Трансляция делается на каждый байт, потому что буфер вправе пересечь границу страницы.
И последняя тонкость: в Y86-64 нет записи одного байта, rmmovq пишет слово. Поэтому драйвер читает восемь байтов по адресу назначения, маской $-256 (это 0xffffffffffffff00) обнуляет младший, прибавляет байт с диска и пишет слово обратно. Семь соседних байтов перезаписываются своими же значениями. Порядок байтов little-endian тут работает на нас: младший байт слова лежит по младшему адресу, то есть ровно там, куда указывает %rax.
Регистры устройств объявлены метками в самом конце, над таблицей исключений:
.pos 0xf80
kstack:
console:
.quad 0 # запись сюда печатает символ
disk_req:
.quad 0 # сколько байтов просим
disk_len:
.quad 0 # сколько диск отдаёт, ноль это конец файла
disk_data:
.quad 0 # очередной байт
Метка kstack стоит на том же адресе, что и console, и это не конфликт: стек растёт вниз, pushq сначала уменьшает %rsp и пишет по 0xf78. Вершина стека и первый регистр устройства соседи, но не пересекаются.
Файл целиком:
# Ядро с устройствами: то же, что vm.ys, плюс консоль и диск с отображением
# в память и системный вызов read.
#
# Регистры устройств это слова физической памяти с 0xf80. Инструкции out
# больше нет: write кладёт байт в слово console. Диск отдаёт, сколько успел:
# ядро пишет заказ в disk_req, читает из disk_len, сколько байтов будет на
# самом деле, и забирает их по одному из disk_data. Столько read и вернёт,
# дочитывать остальное это забота процесса.
#
# Оба процесса собраны с одного виртуального адреса 0x400, а в физической
# памяти лежат в разных кадрах. Страница 256 байт, адрес 12 бит, в таблице
# 16 строк по слову. Строка таблицы (PTE):
#
# адрес кадра | 1 valid | 2 write | 4 user | 8 demand
#
# Бит demand железо не читает, он для ядра: страницы пока нет, но процессу
# она положена, и при первом обращении ядро её выдаст. Нулевая строка значит,
# что по этому адресу у процесса ничего нет.
#
# Ядро работает без трансляции, по физическим адресам. Регистр базы ptbr
# железо читает только в пользовательском режиме, поэтому менять его ядро
# может в любой момент. Адрес таблицы лежит в PCB по смещению 0x98.
#
# Физическая память:
# 0x000 код ядра 0x600 данные и PCB 0x780 и 0x800 таблицы страниц
# 0x900 образ A 0xa00 образ B 0xb00 до 0xeff свободные кадры
# 0xf80 вершина стека ядра и регистры устройств над ней
# 0xfc0 таблица исключений
.pos 0
boot: jmp resume # PCB заполнены статически, остаётся запустить первый
# Входы из таблицы. Причину кладём в %rax, прежний %rax уже в PCB.
on_trap:
pushq %rax
irmovq $0, %rax
jmp save
on_adr: pushq %rax
irmovq $1, %rax
jmp save
on_ins: pushq %rax
irmovq $2, %rax
jmp save
on_timer:
pushq %rax
irmovq $3, %rax
save: pushq %rcx
pushq %rdx
pushq %rbx
pushq %rbp
pushq %rsi
pushq %rdi
pushq %r8
pushq %r9
pushq %r10
pushq %r11
pushq %r12
pushq %r13
pushq %r14
irmovq kstack, %rsp # контекст сохранён, дальше свой стек
irmovq current, %rbx
mrmovq (%rbx), %rbx # %rbx это PCB текущего процесса
andq %rax, %rax
je syscall
iaddq $-1, %rax
je pagefault
iaddq $-2, %rax
je schedule # таймер: квант кончился, очередь следующего
jmp kill # недопустимая инструкция: процесс снимаем
# Сбой трансляции. Железо оставило адрес PTE сбойной страницы в fault_pte.
pagefault:
irmovq fault_pte, %rcx
mrmovq (%rcx), %rcx
andq %rcx, %rcx
je kill # адрес вне адресного пространства
mrmovq (%rcx), %rax
call alloc # страницу по требованию, если она положена
je kill
jmp resume # iret вернётся на сбойную инструкцию
# alloc(%rcx = адрес PTE, %rax = PTE): выдать кадр под страницу demand.
# На выходе %rax новая PTE, а при отказе ноль и ZF = 1. Отказов три: страница
# уже есть (значит, не хватило прав), она процессу не положена, кадры кончились.
# Кадры обратно не возвращаются: снятый процесс уносит свои с собой.
# Портит только %rax, %rdx, %r10 и %r11: аргументы вызова в %rdi и %rsi целы.
alloc: irmovq $1, %rdx
andq %rax, %rdx
jne nope
irmovq $8, %rdx
andq %rax, %rdx
je nope
irmovq next_frame, %rdx
mrmovq (%rdx), %r10
irmovq $0xf00, %r11
subq %r10, %r11
je nope
irmovq $6, %r11
andq %r11, %rax # права остаются как были
addq %r10, %rax
iaddq $1, %rax # valid
rmmovq %rax, (%rcx)
iaddq $0x100, %r10
rmmovq %r10, (%rdx)
andq %rax, %rax # ZF = 0
ret
nope: xorq %rax, %rax
ret
# uaddr(%rdi = адрес в процессе, %r8 = нужные биты PTE): физический адрес
# в %rax или ноль, если процессу туда нельзя. Сдвигов в Y86-64 нет, поэтому
# номер страницы считаем вычитанием: шаг по адресу 0x100, шаг по таблице 8.
uaddr: rrmovq %rdi, %rax
andq %rax, %rax
jl ubad
irmovq $0x1000, %rdx
rrmovq %rax, %rsi
subq %rdx, %rsi
jge ubad # за таблицей чужая память
mrmovq 0x98(%rbx), %rcx
irmovq $0x100, %rdx
uwalk: rrmovq %rax, %rsi
subq %rdx, %rsi
jl ufound
rrmovq %rsi, %rax
iaddq $8, %rcx
jmp uwalk
ufound: pushq %rax # смещение внутри страницы
mrmovq (%rcx), %rax
irmovq $1, %rdx
andq %rax, %rdx
jne uhave
call alloc # буфер, которого процесс ещё не касался
je upop
uhave: rrmovq %rax, %rdx
andq %r8, %rdx
subq %r8, %rdx
jne upop
irmovq $-256, %rdx
andq %rdx, %rax # адрес кадра
popq %rdx
addq %rdx, %rax
ret
upop: popq %rdx
ubad: xorq %rax, %rax
ret
# Системный вызов: номер и аргументы читаем из сохранённого контекста.
syscall:
mrmovq 0x68(%rbx), %rax
andq %rax, %rax
je sys_read # 0
iaddq $-1, %rax
je sys_write # 1
iaddq $-23, %rax
je schedule # 24, yield
iaddq $-36, %rax
je kill # 60, exit
irmovq $-1, %rax
rmmovq %rax, 0x68(%rbx) # такого вызова нет
jmp resume
# write(%rdi = адрес, %rsi = длина)
sys_write:
mrmovq 0x38(%rbx), %rdi
mrmovq 0x40(%rbx), %rsi
rrmovq %rsi, %r9
irmovq $5, %r8 # valid и user: читать процессу можно
wloop: andq %r9, %r9
je wdone
call uaddr # каждый байт заново: буфер вправе пересечь страницу
andq %rax, %rax
je efault
mrmovq (%rax), %rcx
irmovq console, %rdx
rmmovq %rcx, (%rdx) # консоль берёт младший байт слова
iaddq $1, %rdi
iaddq $-1, %r9
jmp wloop
wdone: mrmovq 0x40(%rbx), %rax
rmmovq %rax, 0x68(%rbx) # процесс увидит длину в %rax
jmp resume
# read(%rdi = адрес, %rsi = сколько): драйвер диска. Возвращает, сколько
# байтов диск отдал на этот раз: от одного до заказанного, ноль в конце файла.
sys_read:
mrmovq 0x38(%rbx), %rdi
mrmovq 0x40(%rbx), %rsi
irmovq disk_req, %rcx
rmmovq %rsi, (%rcx) # заказ
irmovq disk_len, %rcx
mrmovq (%rcx), %r9 # а вот сколько диск отдаёт
rmmovq %r9, 0x68(%rbx)
irmovq $7, %r8 # valid, write и user: в буфер будем писать
rloop: andq %r9, %r9
je resume
call uaddr
andq %rax, %rax
je efault
irmovq disk_data, %rcx
mrmovq (%rcx), %rcx # очередной байт, диск сдвинулся
# Записи байта в Y86-64 нет: читаем слово, меняем младший байт, пишем.
irmovq $-256, %rdx
mrmovq (%rax), %rsi
andq %rdx, %rsi
addq %rcx, %rsi
rmmovq %rsi, (%rax)
iaddq $1, %rdi
iaddq $-1, %r9
jmp rloop
efault: irmovq $-1, %rax
rmmovq %rax, 0x68(%rbx) # плохой адрес буфера
jmp resume
# Процесс закончился или упал. Если живых не осталось, машина встаёт.
kill: irmovq $0, %rax
rmmovq %rax, 0x88(%rbx)
irmovq alive, %rcx
mrmovq (%rcx), %rax
iaddq $-1, %rax
rmmovq %rax, (%rcx)
jne schedule
halt
# Следующий живой процесс по кругу становится текущим.
schedule:
mrmovq 0x90(%rbx), %rbx
mrmovq 0x88(%rbx), %rax
andq %rax, %rax
je schedule
irmovq current, %rcx
rmmovq %rbx, (%rcx)
# Вернуть текущий процесс на процессор.
resume: irmovq current, %rbx
mrmovq (%rbx), %rsp # %rsp на начало PCB
rrmovq %rsp, %rax
iaddq $0x88, %rax
irmovq ksp, %rcx
rmmovq %rax, (%rcx) # следующий кадр ляжет в этот же PCB
mrmovq 0x98(%rsp), %rax
irmovq ptbr, %rcx
rmmovq %rax, (%rcx) # таблица этого процесса; TLB железо сбросит само
popq %r14
popq %r13
popq %r12
popq %r11
popq %r10
popq %r9
popq %r8
popq %rdi
popq %rsi
popq %rbp
popq %rbx
popq %rdx
popq %rcx
popq %rax
iret
# Данные ядра. Код обязан кончиться раньше: ассемблер наложение не ловит.
.pos 0x600
current:
.quad pcb_a
alive: .quad 2
next_frame:
.quad 0xb00 # первый свободный кадр, последний 0xe00
# Оба процесса начинают с виртуального адреса 0x400, стек под 0x800.
.pos 0x620
pcb_a:
.pos 0x690
.quad 0x400 # PC
.quad 0x19 # пользовательский режим, прерывания разрешены, ZF = 1
.quad 0x800 # %rsp
.quad 1 # жив
.quad pcb_b
.quad pt_a
.pos 0x6c0
pcb_b:
.pos 0x730
.quad 0x400
.quad 0x19
.quad 0x800
.quad 1
.quad pcb_a
.quad pt_b
# Таблицы страниц. Страница 4 это код, только чтение. Страница 5 под данные
# и страница 7 под стек появятся при первом обращении. Остальное пусто:
# ни ядра, ни соседа в адресном пространстве процесса нет.
.pos 0x780
pt_a:
.pos 0x7a0
.quad 0x905 # кадр 0x900, valid и user
.quad 0x00e # demand, write, user
.pos 0x7b8
.quad 0x00e
.pos 0x800
pt_b:
.pos 0x820
.quad 0xa05
.quad 0x00e
.pos 0x838
.quad 0x00e
# Стек ядра растёт вниз от 0xf80. За таблицей исключений слова, через
# которые ядро разговаривает с MMU.
.pos 0xf80
kstack:
console:
.quad 0 # запись сюда печатает символ
disk_req:
.quad 0 # сколько байтов просим
disk_len:
.quad 0 # сколько диск отдаёт, ноль это конец файла
disk_data:
.quad 0 # очередной байт
.pos 0xfc0
.quad on_trap
.quad on_adr
.quad on_ins
.quad on_timer
ksp: .quad 0
ptbr: .quad 0 # база таблицы страниц, ноль выключает трансляцию
fault_va:
.quad 0 # сбойный адрес, его пишет железо
fault_pte:
.quad 0 # адрес PTE сбойной страницы, тоже от железа
Два процесса: с циклом и без
Пользовательских программ две, и это те же два режима, что ты щёлкал в виджете. Первая верит read на слово:
# Процесс readonce: зовёт read один раз и верит, что получил все 32 байта.
# На исправном диске это сходит с рук, на медленном печатается обрывок
# и мусор из нулей за ним.
.pos 0x400
irmovq $0, %rax # read(0x500, 32)
irmovq $0x500, %rdi
irmovq $32, %rsi
trap
irmovq $1, %rax # write(0x500, 32): длину никто не проверил
irmovq $0x500, %rdi
irmovq $32, %rsi
trap
irmovq $60, %rax # exit
trap
Вторая это readn на ассемблере Y86:
# Процесс readn: просит у диска 32 байта и дочитывает циклом, пока не
# наберёт все или пока файл не кончится. Потом печатает, что набрал.
# Диск вправе отдать за раз меньше заказанного, и это не ошибка.
.pos 0x400
irmovq $0x500, %rbx # куда класть следующую порцию
irmovq $32, %rbp # сколько ещё осталось
again: andq %rbp, %rbp
je done
irmovq $0, %rax # read(%rbx, %rbp)
rrmovq %rbx, %rdi
rrmovq %rbp, %rsi
trap
andq %rax, %rax
jle done # 0 это конец файла, минус это ошибка
addq %rax, %rbx
subq %rax, %rbp
jmp again
done: irmovq $0x500, %rdi # write(0x500, сколько набрали)
rrmovq %rbx, %rsi
subq %rdi, %rsi
irmovq $1, %rax
trap
irmovq $60, %rax # exit
trap
Сравни с readn на Zig строка в строку. %rbx это buf[got..].ptr, %rbp это buf.len - got. andq %rax, %rax с jle done закрывает сразу две ветки: ноль (конец файла) и минус (ошибка). Ветки EINTR нет, потому что в нашем ядре системный вызов идёт с замаскированными прерываниями и прерваться не может. После цикла длина набранного считается как разность указателей: %rbx - 0x500.
Прогон
Кладём на диск строку ровно в 32 байта, столько просит readn.ys, и запускаем рядом с ним второй процесс beta, чтобы видеть, что переключение по-прежнему живо:
$ printf 'pack my box with five dozen jug\n' > text.txt
$ K=programs/kernel
$ zig build run -- kernel $K/io.ys $K/readn.ys@0x900 $K/beta.ys@0xa00 --disk text.txt --seed 7 --console
pack my box with five dozen jug
beta
beta
beta
$ zig build run -- kernel $K/io.ys $K/readn.ys@0x900 $K/beta.ys@0xa00 --disk text.txt --seed 7 --events | grep '^disk'
disk 32 -> 8
disk 24 -> 13
disk 11 -> 4
disk 7 -> 6
disk 1 -> 1
Пять системных вызовов вместо одного, каждый заказ это остаток от предыдущего, а на консоли строка целая. Нарезка зависит только от зерна:
| Зерно | Строки disk | Тактов |
|---|---|---|
| 0 | 32 → 32 | 6264 |
| 1 | 32 → 2, 30 → 6, 24 → 10, 14 → 14 | 6471 |
| 7 | 32 → 8, 24 → 13, 11 → 4, 7 → 6, 1 → 1 | 6540 |
| 2026 | 32 → 6, 26 → 8, 18 → 16, 2 → 1, 1 → 1 | 6540 |
Вот цена коротких счётов в тактах: пять заходов в ядро вместо одного стоят 276 лишних тактов из шести с половиной тысяч, около 4 процентов. Каждый заход это исключение, сохранение контекста, разбор номера, восстановление. На настоящем железе пропорция та же по смыслу: системный вызов стоит сотни наносекунд, и программа, которая читает по байту, платит их за каждый байт. Запомни это число до следующего урока.
В полной трассе видно место, где рождается короткий счёт:
K 0x36b 40 AOK
disk 32 -> 8
K 0x375 30 AOK
K 0x37f 50 AOK
Инструкция с кодом 40 это rmmovq заказа в disk_req, в режиме ядра (K). Сразу за ней сработало устройство. Следующие 30 и 50 это irmovq disk_len, %rcx и mrmovq (%rcx), %r9: ядро читает ответ.
Теперь наивная программа на том же диске с тем же зерном:
$ zig build run -- kernel $K/io.ys $K/readonce.ys@0x900 $K/beta.ys@0xa00 --disk text.txt --seed 7 --console | xxd
00000000: 7061 636b 206d 7920 0000 0000 0000 0000 pack my ........
00000010: 0000 0000 0000 0000 0000 0000 0000 0000 ................
00000020: 6265 7461 0a62 6574 610a 6265 7461 0a beta.beta.beta.
Восемь байтов с диска и двадцать четыре нуля из свежей страницы. Процесс не упал, ядро не возразило, код возврата нулевой. Просто данные не те. С зерном 0 та же программа печатает строку целиком: на исправном диске ошибка невидима.
И конец файла. На диске шесть байтов, процесс просит 32:
$ printf 'short\n' > short.txt
$ zig build run -- kernel $K/io.ys $K/readn.ys@0x900 $K/beta.ys@0xa00 --disk short.txt --seed 7 --events | grep '^disk'
disk 32 -> 4
disk 28 -> 1
disk 27 -> 1
disk 26 -> 0
Последний заказ диск встретил нулём, jle done сработал, на консоль ушло short и три beta.
Тесты шага
//! Шаг 60: устройства с отображением в память. Консоль, диск с короткими
//! счётами, системный вызов `read` и цикл, который дочитывает до конца.
const std = @import("std");
const y86 = @import("y86");
const testing = std.testing;
const computer = y86.computer;
const kernel = y86.programs.kernel;
/// Ровно 32 байта: столько просит `readn.ys`.
const text = "pack my box with five dozen jug\n";
/// Ядро накладывается как есть, программы ложатся в кадры 0x900 и 0xa00.
fn run(sources: []const []const u8, config: computer.Config) !computer.Output {
const frames = [_]?usize{ null, 0x900, 0xa00 };
var placed: [3]computer.Placed = undefined;
var count: usize = 0;
defer for (placed[0..count]) |p| testing.allocator.free(p.image);
for (sources, frames[0..sources.len]) |source, at| {
var result = try y86.assembler.assemble(testing.allocator, source, null);
defer result.deinit(testing.allocator);
placed[count] = .{ .image = try testing.allocator.dupe(u8, result.image), .at = at };
count += 1;
}
return computer.runPlaced(testing.allocator, placed[0..count], config);
}
fn disk(data: []const u8, seed: u64) computer.Config {
return .{ .devices = true, .disk = data, .disk_seed = seed };
}
const reader = [_][]const u8{ kernel.io, kernel.readn, kernel.beta };
test "консоль это адрес в памяти: write обходится без инструкции out" {
var out = try run(&.{ kernel.io, kernel.alpha, kernel.beta }, disk("", 0));
defer out.deinit(testing.allocator);
try testing.expectEqualStrings("alpha\nbeta\nalpha\nbeta\nalpha\nbeta\n", out.console);
// Байт ушёл на консоль после записи слова по адресу 0xf80...
try testing.expect(std.mem.indexOf(u8, out.trace, " 40 AOK\nout 61\n") != null);
// ...а инструкции out (код e0) в трассе нет ни одной.
try testing.expect(std.mem.indexOf(u8, out.trace, " e0 AOK\n") == null);
}
test "устройства слушают только ядро" {
// irmovq $0x41, %rax; irmovq $0xf80, %rbx; rmmovq %rax, (%rbx)
const code = [_]u8{
0x30, 0xf0, 0x41, 0, 0, 0, 0, 0, 0, 0,
0x30, 0xf3, 0x80, 0x0f, 0, 0, 0, 0, 0, 0,
0x40, 0x03, 0, 0, 0, 0, 0, 0, 0, 0,
};
var console_buf: std.Io.Writer.Allocating = .init(testing.allocator);
defer console_buf.deinit();
var machine: computer.Computer = .init(disk("", 0));
machine.console = &console_buf.writer;
machine.load(&code);
machine.st.user = true;
for (0..3) |_| try machine.step();
// Без страничной памяти процесс до адреса дотянулся, но консоль промолчала.
try testing.expectEqual(@as(u8, 0x41), machine.st.mem[0xf80]);
try testing.expectEqualStrings("", console_buf.written());
machine.st.user = false;
machine.st.pc = 0;
for (0..3) |_| try machine.step();
try testing.expectEqualStrings("A", console_buf.written());
}
test "исправный диск отдаёт всё за один вызов" {
var out = try run(&reader, disk(text, 0));
defer out.deinit(testing.allocator);
try testing.expectEqualStrings(text ++ "beta\nbeta\nbeta\n", out.console);
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, out.trace, "\ndisk "));
try testing.expect(std.mem.indexOf(u8, out.trace, "disk 32 -> 32\n") != null);
}
test "короткие счёты: зерно задаёт их раз и навсегда" {
var out = try run(&reader, disk(text, 7));
defer out.deinit(testing.allocator);
// Пять вызовов read вместо одного, каждый заказ это остаток.
var lines = std.mem.tokenizeScalar(u8, out.trace, '\n');
var seen: usize = 0;
const expected = [_][]const u8{ "disk 32 -> 8", "disk 24 -> 13", "disk 11 -> 4", "disk 7 -> 6", "disk 1 -> 1" };
while (lines.next()) |line| {
if (!std.mem.startsWith(u8, line, "disk ")) continue;
try testing.expectEqualStrings(expected[seen], line);
seen += 1;
}
try testing.expectEqual(expected.len, seen);
// Второй прогон с тем же зерном даёт ту же трассу байт в байт.
var again = try run(&reader, disk(text, 7));
defer again.deinit(testing.allocator);
try testing.expectEqualStrings(out.trace, again.trace);
// Другое зерно режет иначе.
var other = try run(&reader, disk(text, 1));
defer other.deinit(testing.allocator);
try testing.expect(std.mem.indexOf(u8, other.trace, "disk 32 -> 2\n") != null);
}
test "цикл дочитывает всё, как бы диск ни резал" {
for ([_]u64{ 0, 1, 7, 2026 }) |seed| {
var out = try run(&reader, disk(text, seed));
defer out.deinit(testing.allocator);
try testing.expectEqualStrings(text ++ "beta\nbeta\nbeta\n", out.console);
try testing.expectEqual(y86.Stat.hlt, out.summary.stat);
}
}
test "один вызов read на медленном диске оставляет обрывок" {
var out = try run(&.{ kernel.io, kernel.readonce, kernel.beta }, disk(text, 7));
defer out.deinit(testing.allocator);
// Диск отдал восемь байтов, остальные двадцать четыре в буфере нулевые.
try testing.expectEqualStrings("pack my " ++ "\x00" ** 24 ++ "beta\nbeta\nbeta\n", out.console);
}
test "конец файла: read возвращает ноль, цикл останавливается" {
var out = try run(&reader, disk("short\n", 7));
defer out.deinit(testing.allocator);
try testing.expectEqualStrings("short\nbeta\nbeta\nbeta\n", out.console);
// Последний заказ диск встретил пустой выдачей.
const last = std.mem.lastIndexOf(u8, out.trace, "\ndisk ").?;
try testing.expect(std.mem.startsWith(u8, out.trace[last..], "\ndisk 26 -> 0\n"));
// Пустой диск это конец файла с первого же вызова.
var empty = try run(&reader, disk("", 0));
defer empty.deinit(testing.allocator);
try testing.expectEqualStrings("beta\nbeta\nbeta\n", empty.console);
}
Семь тестов, запуск zig build test -Dstep=60. Первый проверяет, что старые процессы alpha и beta печатают то же самое под новым ядром и что инструкции out (байт e0) в трассе больше нет ни одной. Второй собирает три инструкции руками и доказывает, что запись по 0xf80 из режима пользователя в память попадает, а на консоль нет. Четвёртый самый важный для воспроизводимости: трасса с зерном 7 сверяется построчно и второй прогон совпадает с первым байт в байт. Короткие счёты в жизни случайны, в тестах они обязаны быть детерминированными, иначе упавший тест нельзя повторить.
Чего у этой машины по-прежнему нет. Диск отвечает мгновенно: ядро не ждёт готовности и не засыпает, поэтому у нас нет ни опроса, ни прерывания от устройства (это одно из домашних заданий). Диск только читается. Verilog-модель про устройства не знает, как и про trap: вся ОС-часть живёт в симуляторе на Zig.
Практика
Напиши readn сам. Настоящих файлов и пайпов в песочнице нет, поэтому источник данных лежит в самой задаче: структура Source с методом read, который по сценарию отдаёт короткие счёты, прерывается и заканчивается. Заготовка зовёт read один раз, и первый тест у неё зелёный: на исправном источнике ошибка не видна, как и в жизни.
Упражнения
Итоги
- Файл в Unix это последовательность байтов, и устройства тоже файлы. Весь интерфейс это пять вызовов:
open,read,write,lseek,close. - Открытый файл это дескриптор (маленькое целое) плюс позиция, которую ведёт ядро.
openвсегда возвращает наименьший свободный номер. Дескрипторы 0, 1, 2 достаются процессу по наследству. - В Zig 0.16 сырой слой достаётся через
std.c(с libc, переносимо) илиstd.os.linux(без libc, только Linux). Вstd.posixиз нашей пятёрки осталисьreadиopenat. - У ядра нет
errno: системный вызов Linux возвращает ошибку отрицательным числом от -4095 до -1 в том же регистре, что и результат.errnoи возврат -1 это работа обёртки libc. readвозвращает одно из трёх: положительный счёт (возможно, меньше заказа), ноль на конце файла, -1 при ошибке. Конец файла это не символ, а состояние “позиция равна размеру”.- Короткий счёт не ошибка. Его дают конец файла, терминал (по строке), пайп и сокет (что успело прийти), сигнал посреди передачи, потолок
0x7ffff000байтов на вызов. Пайп не хранит границ между вызовамиwrite. - Сигнал, пришедший до передачи данных, даёт -1 с
EINTR, если обработчик поставлен безSA_RESTART. Сигнал, пришедший после частичной передачи, даёт короткий счёт. И то и другое лечится повтором. readnэто цикл с четырьмя ветками:EINTRповторить, ошибку наверх, ноль закончить, счёт прибавить. Недобор он возвращает только на конце файла.writenвозвращаетvoid: у неё короткого счёта нет по построению.readnживёт не в ядре, потому что это политика: интерактивной программе нужен как раз короткий счёт. И она годится, только когда длина известна заранее. Для строк нужен буфер, это следующий урок.std.Io.Fileэто дескриптор плюс бит. Он даёт именованные ошибки, прячетEINTR, добавляет позиционные операции поверхpreadиpwrite, векторы и отмену. Короткие счёты он не прячет: для этого естьreadPositionalAll,writeStreamingAllиstd.Io.Reader.- У Y86 теперь есть устройства с отображением в память: четыре слова с
0xf80, с которыми ядро говорит обычнымиrmmovqиmrmovq. Устройства видят физические адреса и слушают только режим ядра. Диск отдаёт короткие счёты по зерну, ядро передаёт их процессу как есть, процесс дочитывает циклом. Пять заходов в ядро вместо одного стоят около 4 процентов тактов.
Дальше
readn решает задачу “дай ровно N байтов”, но большинство протоколов и форматов устроены иначе: строка до перевода строки, заголовок до пустой строки, число до пробела. Длина заранее неизвестна, а читать по байту значит платить системным вызовом за каждый символ; сколько это стоит, ты только что видел на Y86 в тактах. В следующем уроке мы поставим между программой и ядром буфер: напишем readline, разберём, почему буферизованное и небуферизованное чтение нельзя смешивать на одном дескрипторе, и посмотрим, как ту же идею воплощает std.Io.Reader. Заодно научимся спрашивать у ядра метаданные файла и обходить каталоги. А в уроке 62 откроем три таблицы ядра, которые стоят за словом “дескриптор”, и соберём из dup2 и pipe перенаправление и конвейер оболочки.
домашка