Раздел 32 · Системное программирование: Zig, ассемблер, Verilog
Нелокальные переходы и ошибки в Zig
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Нелокальные переходы и ошибки в Zig
Весь блок мы смотрели, как управление уходит не туда, куда ведёт следующая инструкция: исключение уводит процессор в ядро, переключение контекста в другой процесс, сигнал в обработчик. Остался последний этаж: сама программа, без ядра, бросает текущую цепочку вызовов и оказывается в функции десятью кадрами выше. В C это делают
setjmpиlongjmp, и на них десятилетиями строили самодельные исключения. Сегодня мы разберём их до регистра: что лежит вjmp_buf, почемуsetjmpвозвращается дважды, как прыжок теряет файловые дескрипторы и портит локальные переменные, зачем нуженsigsetjmp. А потом посмотрим, почему в Zig этих функций нет вовсе и чем он их заменил: ошибка как значение,errdefer, паника со своим обработчиком и две разные трассы стека. В конце положим рядом машинный кодtryи машинный код исключения C++ и увидим, кто за что платит.
Цели урока
- Понимать
setjmpиlongjmpна уровне регистров: что сохраняется, что нет, и почему вызов возвращается дважды. - Воспроизвести три опасности нелокального перехода: утечку ресурса из пропущенного кадра, испорченную локальную переменную под оптимизатором и прыжок в кадр, которого уже нет.
- Знать, чем
sigsetjmpотличается отsetjmp, увидеть разницу на Linux и на macOS и помнить, что после прыжка из обработчика сигнала правила безопасных функций продолжают действовать. - Переписать тот же сценарий на Zig через ошибки как значения и
errdeferи объяснить, почему там утечка невозможна по построению. - Отличать ошибку от паники, ставить свой обработчик паники через
std.debug.FullPanicи печатать трассу стека и трассу возврата ошибки руками. - Читать в дизассемблере цену
try(проверка и переход) и цену исключения C++ (таблицы раскрутки, площадки приземления, двухфазный поиск). - Объяснить, почему звать libc-шный
setjmpиз Zig нельзя.
Идея: выйти из десяти функций разом
Обычный вызов и возврат живут по стековой дисциплине из урока про процедуры: функция возвращается только тому, кто её вызвал. Если ошибка случилась на дне глубокой цепочки, каждый кадр на пути наверх обязан её заметить и передать дальше. В C это десять одинаковых if (rc < 0) return rc;, и программисты всегда искали способ их не писать.
Нелокальный переход это и есть такой способ. setjmp(env) запоминает в буфере env место, куда можно будет вернуться, и возвращает 0. Позже, из любой глубины, longjmp(env, code) восстанавливает запомненное, и программа оказывается снова в точке setjmp, только теперь setjmp возвращает code. Получается пара с очень странным поведением: setjmp вызывают один раз, а возвращается он дважды или больше; longjmp вызывают один раз, а он не возвращается никогда. Вспомни fork: он тоже возвращается дважды, но в двух разных процессах. Здесь оба возврата происходят в одном.
Книга показывает два применения. Первое: мгновенный выход из глубокой вложенности при ошибке. Второе: обработчик сигнала, который не возвращается в прерванную инструкцию, а прыгает в заранее выбранное место, например в начало главного цикла. Оба мы сейчас соберём, запустим и сломаем.
Что лежит в jmp_buf
Никакой магии в setjmp нет, это два десятка инструкций на ассемблере. Вот реализация из musl для x86-64 целиком, она лежит прямо в поставке Zig (lib/libc/musl/src/setjmp/x86_64/):
setjmp:
mov %rbx,(%rdi) /* rdi is jmp_buf, move registers onto it */
mov %rbp,8(%rdi)
mov %r12,16(%rdi)
mov %r13,24(%rdi)
mov %r14,32(%rdi)
mov %r15,40(%rdi)
lea 8(%rsp),%rdx /* this is our rsp WITHOUT current ret addr */
mov %rdx,48(%rdi)
mov (%rsp),%rdx /* save return addr ptr for new rip */
mov %rdx,56(%rdi)
xor %eax,%eax /* always return 0 */
ret
Восемь слов: шесть регистров, которые по соглашению System V обязан сохранять вызываемый (%rbx, %rbp, %r12 до %r15), указатель стека, каким он будет после возврата из setjmp, и адрес возврата, то есть адрес инструкции сразу за call setjmp. Больше ничего. Регистры, которые сохраняет вызывающий, в буфер не попадают: компилятор и так считает, что после любого call они испорчены.
longjmp:
xor %eax,%eax
cmp $1,%esi /* CF = val ? 0 : 1 */
adc %esi,%eax /* eax = val + !val */
mov (%rdi),%rbx /* rdi is the jmp_buf, restore regs from it */
mov 8(%rdi),%rbp
mov 16(%rdi),%r12
mov 24(%rdi),%r13
mov 32(%rdi),%r14
mov 40(%rdi),%r15
mov 48(%rdi),%rsp
jmp *56(%rdi) /* goto saved address without altering rsp */
longjmp кладёт в %eax второй аргумент (а если передали 0, подменяет на 1, иначе setjmp нельзя было бы отличить от первого возврата), восстанавливает шесть регистров, переставляет %rsp и делает косвенный jmp на сохранённый адрес. С точки зрения кода после call setjmp всё выглядит так, будто setjmp только что вернулся со значением в %eax. Кадры, которые лежали ниже по стеку, никто не разбирает: %rsp просто переехал выше, и они стали мусором под вершиной стека.
Из этих двадцати строк следуют все свойства пары, и все её беды:
- память не откатывается: всё, что успели записать в кучу, в глобальные переменные и в сам стек, остаётся записанным;
- пропущенные кадры не получают управления, значит ничего за собой не убирают;
- прыгать можно только вверх, в кадр, который ещё жив: если функция, вызвавшая
setjmp, уже вернулась, сохранённый%rspуказывает в чужие данные; - маски сигналов в этих восьми словах нет.
В glibc jmp_buf занимает 200 байт: те же 64 байта регистров, флаг и 128 байт под маску сигналов, которую заполняет только sigsetjmp. Ещё glibc шифрует сохранённые %rsp, %rbp и адрес возврата секретом процесса: буфер лежит в записываемой памяти, и без этого переполнение буфера рядом с jmp_buf дарило бы атакующему готовый переход по любому адресу.
Демонстрация: ошибка со дна стека
Разбор номера порта: main зовёт load, тот parse, тот digit на каждый символ. Ошибка может случиться на двух уровнях, и оба сообщают о ней прыжком в main.
// Нелокальный переход как самодельное исключение: ошибка на дне стека
// возвращает управление сразу в main, минуя parse и load.
#include <setjmp.h>
#include <stdio.h>
static jmp_buf on_error;
enum { BAD_DIGIT = 1, TOO_BIG = 2 };
static int digit(char c) {
if (c < '0' || c > '9') longjmp(on_error, BAD_DIGIT);
return c - '0';
}
static int parse(const char *s) {
int n = 0;
for (; *s; s++) {
n = n * 10 + digit(*s);
if (n > 65535) longjmp(on_error, TOO_BIG);
}
return n;
}
static int load(const char *s) {
printf(" load: разбираю \"%s\"\n", s);
int port = parse(s);
printf(" load: порт %d\n", port);
return port;
}
int main(void) {
const char *inputs[] = {"8080", "80a0", "99999"};
for (int i = 0; i < 3; i++) {
int code = setjmp(on_error);
if (code == 0) {
load(inputs[i]);
} else {
printf("main: setjmp вернул %d, разбор \"%s\" прерван\n", code, inputs[i]);
}
}
return 0;
}
Собираем тем же zig cc, что и в первом уроке, и запускаем:
$ zig cc -o jump jump.c && ./jump
load: разбираю "8080"
load: порт 8080
load: разбираю "80a0"
main: setjmp вернул 1, разбор "80a0" прерван
load: разбираю "99999"
main: setjmp вернул 2, разбор "99999" прерван
На втором и третьем входе строки load: порт нет: load и parse не вернулись, их кадры просто перестали существовать. Это и подкупает: в parse и load нет ни одной строки про ошибки. Узнаёшь конструкцию? setjmp с веткой else это catch, longjmp это throw. Книга так и говорит: исключения C++ и Java это структурированная версия той же пары. Ниже мы увидим, насколько сильно слово “структурированная” меняет дело.
Заметь шаблон вызова: setjmp стоит прямо в условии или присваивается и сразу проверяется. Стандарт C разрешает его только в таких простых контекстах, потому что значение “возвращается” в выражение, которое компилятор уже наполовину вычислил.
Три способа выстрелить себе в ногу
Кадр выброшен вместе с ресурсом
Добавим в середину цепочки функцию, которая что-то держит. Дескриптор файла подойдёт лучше всего: его номер виден глазами.
// Прыжок через кадр, который держит ресурс: close не выполняется никогда.
#include <fcntl.h>
#include <setjmp.h>
#include <stdio.h>
#include <unistd.h>
static jmp_buf on_error;
static void check(int fd, int fail) {
(void)fd;
if (fail) longjmp(on_error, 1);
}
static void with_file(int fail) {
int fd = open("/dev/null", O_RDONLY);
printf(" открыл дескриптор %d\n", fd);
check(fd, fail);
close(fd);
printf(" закрыл дескриптор %d\n", fd);
}
int main(void) {
for (int i = 0; i < 4; i++) {
if (setjmp(on_error) == 0) {
with_file(i > 0);
} else {
printf("main: ошибка, кадр with_file выброшен вместе с дескриптором\n");
}
}
return 0;
}
$ zig cc -o leak leak.c && ./leak
открыл дескриптор 3
закрыл дескриптор 3
открыл дескриптор 3
main: ошибка, кадр with_file выброшен вместе с дескриптором
открыл дескриптор 4
main: ошибка, кадр with_file выброшен вместе с дескриптором
открыл дескриптор 5
main: ошибка, кадр with_file выброшен вместе с дескриптором
Первый проход честный: открыл тройку, закрыл тройку. Дальше каждая ошибка оставляет дескриптор открытым, и ядро выдаёт следующий номер: 3, 4, 5. Переменная fd исчезла вместе с кадром, закрыть файл больше некому. Сервер с таким кодом проживёт до лимита из ulimit -n и начнёт получать EMFILE на каждый open. То же самое с malloc, с захваченным мьютексом, с временным файлом, с полузаписанной структурой данных.
Автор with_file не сделал ничего неправильного. Ошибку внёс автор check, в другом файле, может быть, через год. В этом главная беда longjmp: чтобы им пользоваться безопасно, про него должен знать каждый кадр между setjmp и longjmp. Программы, которые живут на этой паре всерьёз (интерпретатор Lua, PostgreSQL, libpng), строят вокруг неё целую дисциплину: свои списки ресурсов, привязанные к точке возврата, и правило “внутри такого блока обычный malloc запрещён”.
Локальная переменная под оптимизатором
Вторая ловушка тоньше. setjmp сохраняет регистры на момент вызова. Если компилятор держит локальную переменную в регистре и ты меняешь её между setjmp и longjmp, прыжок вернёт в регистр старое значение.
// Локальная переменная, изменённая между setjmp и longjmp.
#include <setjmp.h>
#include <stdio.h>
static jmp_buf env;
__attribute__((noinline)) static void fail(void) { longjmp(env, 1); }
int main(void) {
int plain = 0;
volatile int safe = 0;
if (setjmp(env) == 0) {
plain = 42;
safe = 42;
fail();
}
printf("plain = %d, safe = %d\n", plain, safe);
return 0;
}
$ zig cc -O0 -o clobber clobber.c && ./clobber
plain = 42, safe = 42
$ zig cc -O2 -o clobber clobber.c && ./clobber
plain = 0, safe = 42
Один исходник, два ответа. Без оптимизации обе переменные живут в стеке, а стек longjmp не трогает. С -O2 компилятор рассуждает так: fail не возвращается, значит до printf можно дойти только по пути, где plain ещё равна нулю. Запись 42 он выбрасывает, а в printf передаёт константу: в дизассемблере main на месте второго аргумента стоит xorl %esi, %esi. Будь plain нужна и дальше, она жила бы в регистре вроде %rbx, и прыжок вернул бы в него старое значение из буфера. Стандарт говорит об этом прямо: значения локальных переменных без volatile, изменённых между setjmp и longjmp, после прыжка не определены. volatile знаком тебе по уроку про сигналы: он заставляет компилятор каждый раз ходить в память, и стоит здесь по той же причине. Управление приходит в функцию путём, которого компилятор не видит.
Прыжок в мёртвый кадр
Третья ловушка: jmp_buf пережил функцию, которая его заполнила. Функция вернулась, её кадр занят другими вызовами, а longjmp восстанавливает %rsp на старое место и прыгает в середину кода, который считает, что его локальные переменные лежат там, где лежали. Это неопределённое поведение без всякой диагностики. Отсюда правило: longjmp летит только вверх по живому стеку. Вниз, вбок и в прошлое нельзя.
sigsetjmp: прыжок из обработчика сигнала
Второе применение из книги: программа, которую Ctrl+C не убивает, а возвращает в начало. Обработчик SIGINT не возвращается в прерванную инструкцию, он прыгает в main.
Здесь всплывает четвёртое свойство из списка выше. Пока работает обработчик, ядро держит его сигнал заблокированным, и снимает блокировку при нормальном возврате из обработчика. Если обработчик ушёл через longjmp, возврата не было, и маску никто не восстановил. Поэтому у пары есть сигнальный вариант: sigsetjmp(env, 1) сохраняет в буфер ещё и маску сигналов, а siglongjmp её восстанавливает.
// Мягкий перезапуск по Ctrl+C: обработчик SIGINT прыгает в начало main.
#include <setjmp.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
static sigjmp_buf restart_point;
static void say(const char *s) { write(STDOUT_FILENO, s, strlen(s)); }
static void on_sigint(int sig) {
(void)sig;
siglongjmp(restart_point, 1);
}
int main(void) {
if (sigsetjmp(restart_point, 1) == 0) {
struct sigaction sa = {0};
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
sigaction(SIGINT, &sa, NULL);
say("запуск\n");
} else {
say("перезапуск\n");
}
for (;;) {
sleep(1);
say("работаю...\n");
}
}
Запускаем и дважды посылаем SIGINT: с клавиатуры это Ctrl+C, здесь его слал соседний скрипт через kill -INT, на третьей секунде и ещё через полторы.
$ zig cc -o restart restart.c && ./restart
запуск
работаю...
работаю...
перезапуск
работаю...
перезапуск
работаю...
Две детали здесь не случайны, обе из книги.
Обработчик ставится после sigsetjmp, а не до. Иначе возможна гонка: сигнал приходит между sigaction и sigsetjmp, обработчик прыгает по пустому буферу, и программа падает. Это та же порода ошибок, что гонка с регистрацией задания из прошлого урока.
Печать идёт через write, а не через printf. Сигнал мог прервать printf на середине, с захваченной блокировкой потока вывода. Обычный обработчик вернулся бы, и printf доделал бы работу. Мы же выпрыгнули, блокировка осталась захваченной навсегда, и следующий printf повиснет. signal-safety(7) формулирует это так: если сигнал прервал небезопасную функцию и обработчик вышел через siglongjmp, то дальше программа вправе звать только async-signal-safe функции. То есть шесть правил безопасного обработчика после прыжка распространяются на весь код, в который ты прыгнул.
Теперь эксперимент: заменим sigsetjmp и siglongjmp на обычные setjmp и longjmp и запустим на Linux.
$ zig cc -target x86_64-linux-gnu -o restart_plain restart_plain.c
$ ./restart_plain # x86-64 Linux, glibc
запуск
работаю...
работаю...
перезапуск
работаю...
работаю...
работаю...
Сигналы посланы в те же моменты. Первый сработал, второй пропал без следа, и так будет с каждым следующим. longjmp вынес нас из обработчика, SIGINT остался в маске заблокированных, и теперь сигнал вечно висит в ожидающих. Программу больше нельзя ни перезапустить, ни прервать с клавиатуры, только kill -9.
На macOS. Все программы этого урока собираются и запускаются на macOS напрямую, песочница не нужна. Но последний эксперимент там не воспроизводится: на macOS, как во всех наследниках BSD, обычный
setjmpсам сохраняет маску сигналов, аlongjmpеё восстанавливает, иrestart_plainперезапускается сколько угодно раз. Цена этого удобства: системный вызовsigprocmaskна каждыйsetjmp. Поведение Linux на macOS дают варианты с подчёркиванием,_setjmpи_longjmp: они маску не трогают, и с ними второй Ctrl+C пропадает точно так же. Вывод для переносимого кода простой: если прыгаешь из обработчика, пишиsigsetjmpиsiglongjmp, у них поведение одинаково везде. Сам буфер тоже другой: на Apple Siliconjmp_bufэто 192 байта (48 слов по 4 байта: сохраняемые регистры общего назначения,fp,lr,sp, восемь регистров с плавающей точкой d8 до d15 и два поля под сигналы), против 200 байт в glibc на x86-64. Объектники для последней части урока собирай кросс-компиляцией (-target x86_64-linux-gnu) и читайllvm-readelf; в родном Mach-O те же данные лежат в секциях__eh_frame,__compact_unwindи__gcc_except_tab.
Как Zig обходится без прыжков
В std Zig нет ни setjmp, ни longjmp, и в std.c, где лежат объявления функций libc, их нет тоже. Это решение, а не недоделка. Вспомни модель из урока про ошибки: ошибка это значение, функция возвращает E!T, а try это сокращение для “если пришла ошибка, верни её выше”. Управление идёт наверх обычными возвратами, кадр за кадром, и каждый кадр получает шанс убрать за собой через defer и errdefer.
Вот сценарий с портом и дескриптором на Zig. Функция open открывает файл, разбирает порт и отдаёт наружу оба; если разбор упал, файл надо закрыть.
//! Тот же сценарий без прыжков: ошибка это значение, и каждый кадр на её
//! пути получает управление и возвращает свои ресурсы.
const std = @import("std");
const Io = std.Io;
const ParseError = error{ BadDigit, TooBig };
fn digit(c: u8) ParseError!u32 {
if (c < '0' or c > '9') return error.BadDigit;
return c - '0';
}
fn parse(text: []const u8) ParseError!u16 {
var n: u32 = 0;
for (text) |c| {
n = n * 10 + try digit(c);
if (n > 65535) return error.TooBig;
}
return @intCast(n);
}
/// Открытый файл и разобранный порт уходят наружу вместе.
const Listener = struct { log: Io.File, port: u16 };
fn open(io: Io, out: *Io.Writer, text: []const u8) !Listener {
const log = try Io.Dir.openFileAbsolute(io, "/dev/null", .{});
errdefer {
log.close(io);
out.print(" errdefer: закрыл дескриптор {d}\n", .{log.handle}) catch {};
}
try out.print(" открыл дескриптор {d}\n", .{log.handle});
const port = try parse(text);
return .{ .log = log, .port = port };
}
pub fn main(init: std.process.Init) !void {
var buf: [1024]u8 = undefined;
var w = Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
defer out.flush() catch {};
for ([_][]const u8{ "8080", "80a0", "99999", "443" }) |text| {
const listener = open(init.io, out, text) catch |err| {
try out.print("main: {s}, разбор \"{s}\" прерван\n", .{ @errorName(err), text });
continue;
};
defer listener.log.close(init.io);
try out.print("main: порт {d}, дескриптор {d}\n", .{ listener.port, listener.log.handle });
}
}
$ zig run port.zig
открыл дескриптор 3
main: порт 8080, дескриптор 3
открыл дескриптор 3
errdefer: закрыл дескриптор 3
main: BadDigit, разбор "80a0" прерван
открыл дескриптор 3
errdefer: закрыл дескриптор 3
main: TooBig, разбор "99999" прерван
открыл дескриптор 3
main: порт 443, дескриптор 3
Номер дескриптора всегда 3. Прежде чем разбирать почему, пройди оба варианта по шагам. Слева стек программы на C, справа на Zig, сценарий один: ошибка на дне, у средних кадров есть что закрыть и освободить. Кнопка двигает обе колонки разом; следи, какие кадры C перепрыгнуты вместе со своей очисткой и в какой момент справа срабатывает errdefer.
C · setjmp и longjmp
код
jmp_buf env;
void validate(char *buf) {
if (bad(buf)) longjmp(env, 1);
}
void load(int fd) {
char *buf = malloc(4096);
validate(buf);
free(buf);
}
void parse(const char *path) {
int fd = open(path, O_RDONLY);
load(fd);
close(fd);
}
int main(void) {
if (setjmp(env) != 0) return 1;
parse("app.conf");
}
- mainenv: сохранены %rsp, %rbp, адрес возврата
- parsefd = 3
close(fd) · не выполнено · выполняется - loadbuf = malloc(4096)
free(buf) · не выполнено · выполняется - validateнашла ошибку
setjmp запоминает в env указатель стека и адрес, куда вернуться, и отдаёт 0.
parse открывает файл. Закрыть его она собирается после возврата из load.
load берёт буфер в куче. free стоит строкой ниже вызова validate.
validate видит плохой конфиг и готовится прыгать.
longjmp восстанавливает %rsp из env. Три кадра исчезают разом, их код после вызова не выполнится никогда.
setjmp возвращается второй раз, теперь с 1. Утекли дескриптор 3 и буфер на 4096 байт: close и free остались в перепрыгнутых кадрах.
main выходит с кодом 1. В долгоживущем сервере такая утечка копилась бы с каждым плохим запросом.
Zig · ошибка и errdefer
код
fn validate(buf: []const u8) !void {
if (bad(buf)) return error.BadConfig;
}
fn load(gpa: Allocator, fd: i32) !Config {
const buf = try gpa.alloc(u8, 4096);
errdefer gpa.free(buf);
try validate(buf);
return .{ .fd = fd, .buf = buf };
}
fn parse(gpa: Allocator, path: []const u8) !Config {
const fd = try open(path);
errdefer close(fd);
return load(gpa, fd);
}
fn run(gpa: Allocator) u8 {
_ = parse(gpa, "app.conf") catch return 1;
return 0;
}
- runждёт результат parse
- parsefd = 3
errdefer close(fd) · не выполнено · выполняется - loadbuf = gpa.alloc
errdefer gpa.free(buf) · не выполнено · выполняется - validateнашла ошибку
run зовёт parse и заранее говорит, что сделает с ошибкой: catch.
parse открывает файл и тут же, следующей строкой, записывает, как его закрыть при ошибке.
load берёт буфер и так же сразу объявляет errdefer с free.
validate видит плохой конфиг и готовится вернуть error.BadConfig.
Ошибка это обычное возвращаемое значение. validate вернулась, try в load видит ошибку и перед выходом выполняет errdefer: буфер освобождён.
Кадр load снят. Ошибка дошла до parse, её errdefer закрывает дескриптор 3.
Кадр parse снят, catch в run получил ошибку. Ни один кадр не перепрыгнут, ничего не утекло.
Теперь сравни с версией на C по трём пунктам.
Путь ошибки виден в коде. Каждое место, где функция может выйти раньше времени, помечено словом try или return error.X. В leak.c функция with_file могла оборваться на вызове check, и по её тексту этого не узнать. Здесь сигнатура ParseError!u16 говорит, что функция умеет падать, и компилятор не даст этот результат проигнорировать.
Откат стоит рядом с захватом. errdefer написан сразу под openFileAbsolute. Тот, кто через год добавит в parse новую ошибку, ничего не должен знать про файл: try parse(text) вернёт управление в open, и errdefer отработает.
Нечего пропускать. Нет механизма, которым кадр можно обойти. Утечка из leak.c здесь не запрещена правилом, она невыразима.
А перезапуск по Ctrl+C? Прыгать из обработчика некуда и нечем, поэтому делаем так, как прошлый урок советовал делать всегда: обработчик поднимает флаг, а главный цикл проверяет его в точке, где перезапуск безопасен.
//! Перезапуск по Ctrl+C без прыжка: обработчик только поднимает флаг,
//! а цикл сам решает, когда безопасно начать заново.
const std = @import("std");
const posix = std.posix;
var restart_requested = std.atomic.Value(bool).init(false);
fn onSigint(_: posix.SIG) callconv(.c) void {
restart_requested.store(true, .release);
}
pub fn main(init: std.process.Init) !void {
var buf: [256]u8 = undefined;
var w = std.Io.File.stdout().writer(init.io, &buf);
const out = &w.interface;
posix.sigaction(.INT, &.{
.handler = .{ .handler = onSigint },
.mask = posix.sigemptyset(),
.flags = 0,
}, null);
try out.writeAll("запуск\n");
while (true) {
try out.flush();
init.io.sleep(.fromSeconds(1), .awake) catch {};
if (restart_requested.swap(false, .acquire)) {
try out.writeAll("перезапуск\n");
continue;
}
try out.writeAll("работаю...\n");
}
}
$ zig build-exe restart.zig && ./restart
запуск
работаю...
работаю...
перезапуск
перезапуск
работаю...
Обработчик делает одну атомарную запись, маска сигналов восстанавливается обычным возвратом из него, буферизованный вывод не рвётся посередине, потому что перезапуск происходит только между итерациями. Сигнал заодно прерывает sleep, поэтому реакция мгновенная. Платим мы тем, что долгую операцию внутри итерации Ctrl+C не оборвёт: ей придётся самой поглядывать на флаг. Это честная цена, и в проекте оболочки мы будем платить именно её.
Паника: когда продолжать нельзя
Ошибки как значения закрывают то, что может пойти не так снаружи: плохой ввод, нет файла, кончилась память. Есть второй класс: нарушен инвариант самой программы. Индекс за границей среза, переполнение, unreachable, до которого дошли, пустой список, который по построению не бывает пустым. Вернуть тут нечего и некому, потому что программа уже не та, какой её считает автор. Для этого в Zig есть паника: @panic("сообщение") и все проверки безопасности зовут обработчик паники, а тот обязан не вернуться.
Поймать панику нельзя, и это принципиально: нет catch для паники, нет раскрутки стека, defer при панике не выполняются. Зато обработчик можно заменить. Как именно, написано в std/builtin.zig: компилятор ищет в корневом файле программы публичное объявление panic, и если его нет, берёт std.debug.FullPanic(std.debug.defaultPanic). FullPanic это функция времени компиляции: она принимает одну функцию с сигнатурой fn ([]const u8, ?usize) noreturn и строит пространство имён, в котором каждая проверка безопасности (outOfBounds, unwrapNull, integerOverflow и остальные) форматирует своё сообщение и зовёт твою функцию.
//! Свой обработчик паники: короткое сообщение, трасса, свой код выхода.
const std = @import("std");
pub const panic = std.debug.FullPanic(onPanic);
fn onPanic(msg: []const u8, first_trace_addr: ?usize) noreturn {
std.debug.print("zt: внутренняя ошибка: {s}\n", .{msg});
std.debug.dumpCurrentStackTrace(.{ .first_address = first_trace_addr orelse @returnAddress() });
std.process.exit(70);
}
fn pick(table: []const u16, index: usize) u16 {
return table[index];
}
fn checkInvariant(free_blocks: usize) void {
if (free_blocks == 0) @panic("список свободных блоков пуст");
}
pub fn main(init: std.process.Init) !void {
var args = init.minimal.args.iterate();
_ = args.next();
const mode = args.next() orelse "panic";
if (std.mem.eql(u8, mode, "index")) {
const table = [_]u16{ 80, 443, 8080 };
var index: usize = 2;
index += table.len;
std.debug.print("{d}\n", .{pick(&table, index)});
} else {
checkInvariant(0);
}
}
Вывод двух запусков (путь к каталогу с исходником и к std сокращён):
$ ./panic; echo "код выхода $?"
zt: внутренняя ошибка: список свободных блоков пуст
panic.zig:17:27: 0x100c5cdef in checkInvariant (panic)
if (free_blocks == 0) @panic("список свободных блоков пуст");
^
panic.zig:31:23: 0x100c58b0f in main (panic)
checkInvariant(0);
^
std/start.zig:737:30: 0x100c58fcf in callMain (panic)
return wrapMain(root.main(.{
^
???:?:?: 0x182f444e3 in start (/usr/lib/dyld)
код выхода 70
$ ./panic index; echo "код выхода $?"
zt: внутренняя ошибка: index out of bounds: index 5, len 3
panic.zig:13:17: 0x1023f0f5f in pick (panic)
return table[index];
^
panic.zig:29:40: 0x1023ecb3f in main (panic)
std.debug.print("{d}\n", .{pick(&table, index)});
^
std/start.zig:737:30: 0x1023ecfcf in callMain (panic)
return wrapMain(root.main(.{
^
???:?:?: 0x182f444e3 in start (/usr/lib/dyld)
код выхода 70
Явный @panic и проверка границ среза пришли в одну и ту же функцию, с готовым текстом. Второй аргумент обработчика это адрес, с которого стоит начинать трассу: без него первыми строками шли бы внутренности самого механизма паники. Мы передаём его в dumpCurrentStackTrace полем first_address, и трасса начинается с виноватой строки.
Зачем это в жизни. Встроенной системе без stderr нужен обработчик, который пишет в UART и перезагружает плату. Сервису нужен код выхода, по которому супервизор отличит падение от штатной остановки. Для совсем маленьких бинарников в std.debug лежат два готовых пространства имён: simple_panic (без форматирования сообщений) и no_panic (каждая паника это @trap()); подключаются они так же, одной строкой pub const panic = std.debug.no_panic;.
Граница между ошибкой и паникой это вопрос “кто виноват”. Пользователь ввёл 80a0: это error.BadDigit, штатная ситуация, у неё есть обработчик. Аллокатор нашёл в своём списке блок с битым заголовком: это паника, потому что любое продолжение опаснее остановки. catch unreachable превращает первое во второе, когда ты берёшься доказать, что ошибки быть не может.
Две трассы: где мы и как сюда пришла ошибка
У longjmp есть ещё один тихий недостаток: после прыжка не осталось следов. Кадры, через которые летели, стёрты, и отладчик покажет только main. В Zig следов два вида, и оба можно напечатать руками.
//! Две трассы: где мы сейчас и каким путём пришла ошибка.
const std = @import("std");
fn digit(c: u8) !u32 {
if (c < '0' or c > '9') return error.BadDigit;
return c - '0';
}
fn parse(text: []const u8) !u32 {
var n: u32 = 0;
for (text) |c| n = n * 10 + try digit(c);
return n;
}
fn load(text: []const u8) !u32 {
std.debug.print("-- стек в момент вызова load:\n", .{});
std.debug.dumpCurrentStackTrace(.{});
return try parse(text);
}
pub fn main() void {
const port = load("80a0") catch |err| {
std.debug.print("-- путь ошибки {s}:\n", .{@errorName(err)});
if (@errorReturnTrace()) |trace| std.debug.dumpErrorReturnTrace(trace);
return;
};
std.debug.print("порт {d}\n", .{port});
}
$ zig build-exe trace.zig && ./trace
-- стек в момент вызова load:
trace.zig:17:36: 0x1041c5f3b in load (trace)
std.debug.dumpCurrentStackTrace(.{});
^
trace.zig:22:22: 0x1041c6053 in main (trace)
const port = load("80a0") catch |err| {
^
std/start.zig:698:59: 0x1041c5bb3 in callMain (trace)
if (fn_info.params.len == 0) return wrapMain(root.main());
^
???:?:?: 0x182f444e3 in start (/usr/lib/dyld)
-- путь ошибки BadDigit:
trace.zig:5:29: 0x1041c5c97 in digit (trace)
if (c < '0' or c > '9') return error.BadDigit;
^
trace.zig:11:33: 0x1041c5e93 in parse (trace)
for (text) |c| n = n * 10 + try digit(c);
^
trace.zig:18:12: 0x1041c5fc7 in load (trace)
return try parse(text);
^
Первая трасса это трасса стека: снимок того, кто кого вызвал, чтобы мы оказались в этой строке. dumpCurrentStackTrace идёт по кадрам вверх (по цепочке %rbp, а где её нет, по таблицам раскрутки, о них ниже), а отладочная информация превращает адреса в имена и строки. Читается снизу вверх: start, callMain, main, load.
Вторая это трасса возврата ошибки из урока про ошибки, только тогда её печатал рантайм, когда ошибка вылетала из main, а сейчас мы достали её сами в точке catch через @errorReturnTrace(). Она отвечает на другой вопрос: где ошибка родилась и через какие try прошла. В момент, когда мы её печатаем, кадров digit и parse уже нет, обычная трасса стека их не покажет. А путь ошибки сохранился: в безопасных режимах каждый return error.X и каждый try на пути ошибки дописывают свой адрес в небольшой буфер, который живёт в кадре функции, начавшей цепочку.
Обе трассы зависят от режима сборки. В ReleaseFast та же программа печатает вот что:
$ zig build-exe -O ReleaseFast trace.zig && ./trace
-- стек в момент вызова load:
???:?:?: 0x182f444e3 in start (/usr/lib/dyld)
-- путь ошибки BadDigit:
load, parse и digit встроены в main, кадров нет, трассу возврата компилятор не ведёт, и @errorReturnTrace() возвращает null. Для сервиса, который должен внятно падать в проде, разумный выбор это ReleaseSafe: проверки и трассы остаются, оптимизации тоже.
Что видно в ассемблере: try против throw
Осталось посмотреть, сколько стоят оба подхода на уровне инструкций. Возьмём функцию из двух try и запретим её встраивать, чтобы было что читать.
const E = error{BadDigit};
noinline fn digit(c: u8) E!u32 {
if (c < '0' or c > '9') return error.BadDigit;
return c - '0';
}
noinline fn twoDigits(a: u8, b: u8) E!u32 {
const hi = try digit(a);
const lo = try digit(b);
return hi * 10 + lo;
}
export fn entry(a: u8, b: u8) i32 {
const n = twoDigits(a, b) catch return -1;
return @intCast(n);
}
$ zig build-obj -target x86_64-linux -O ReleaseFast tryasm.zig
$ objdump -d --no-show-raw-insn tryasm.o
0000000000000030 <tryasm.twoDigits>:
30: pushq %rbp
31: movq %rsp, %rbp
34: pushq %r15
36: pushq %r14
38: pushq %rbx
39: subq $0x18, %rsp
3d: movl %edx, %r14d
40: movq %rdi, %rbx
43: leaq -0x20(%rbp), %rdi
47: callq 0xa0 <tryasm.digit>
4c: movzwl -0x1c(%rbp), %eax
50: testw %ax, %ax
53: jne 0x8b <tryasm.twoDigits+0x5b>
55: movl -0x20(%rbp), %r15d
59: movzbl %r14b, %esi
5d: leaq -0x28(%rbp), %rdi
61: callq 0xa0 <tryasm.digit>
66: movzwl -0x24(%rbp), %eax
6a: testw %ax, %ax
6d: jne 0x8b <tryasm.twoDigits+0x5b>
6f: leal (%r15,%r15,4), %eax
73: addl %eax, %eax
75: addl -0x28(%rbp), %eax
78: movw $0x0, 0x4(%rbx)
7e: movl %eax, (%rbx)
80: addq $0x18, %rsp
84: popq %rbx
85: popq %r14
87: popq %r15
89: popq %rbp
8a: retq
8b: movw %ax, 0x4(%rbx)
8f: jmp 0x80 <tryasm.twoDigits+0x50>
Значение E!u32 это восемь байт в памяти: четыре байта результата и два байта кода ошибки, ноль означает “ошибки нет”. Вызывающий передаёт в %rdi адрес, куда их положить. Каждый try превратился в три инструкции: загрузить код ошибки (movzwl), проверить на ноль (testw), перейти, если не ноль (jne). Путь ошибки по адресу 8b записывает код в результат и уходит в общий эпилог. Всё. Ошибка стоит ровно столько же, сколько обычный возврат, а успех платит за каждую проверку одну предсказанную развилку, почти бесплатную по меркам урока про конвейер.
Теперь то же самое на C++ с исключением. Guard это объект с деструктором, аналог нашего errdefer.
struct BadDigit {};
struct Guard {
~Guard();
};
unsigned digit(unsigned char c);
unsigned twoDigits(unsigned char a, unsigned char b) {
Guard guard;
unsigned hi = digit(a);
unsigned lo = digit(b);
return hi * 10 + lo;
}
int entry(unsigned char a, unsigned char b) {
try {
return (int)twoDigits(a, b);
} catch (const BadDigit &) {
return -1;
}
}
$ zig c++ -target x86_64-linux-gnu -O2 -c tryasm.cpp -o tryasm_cpp.o
$ objdump -d -r -C --no-show-raw-insn tryasm_cpp.o
0000000000000000 <twoDigits(unsigned char, unsigned char)>:
0: pushq %rbp
1: movq %rsp, %rbp
4: pushq %r14
6: pushq %rbx
7: subq $0x10, %rsp
b: movl %esi, %r14d
e: callq 0x13 <twoDigits(unsigned char, unsigned char)+0x13>
000000000000000f: R_X86_64_PLT32 digit(unsigned char)-0x4
13: movl %eax, %ebx
15: movzbl %r14b, %edi
19: callq 0x1e <twoDigits(unsigned char, unsigned char)+0x1e>
000000000000001a: R_X86_64_PLT32 digit(unsigned char)-0x4
1e: leal (%rbx,%rbx,4), %ecx
21: leal (%rax,%rcx,2), %ebx
24: leaq -0x11(%rbp), %rdi
28: callq 0x2d <twoDigits(unsigned char, unsigned char)+0x2d>
0000000000000029: R_X86_64_PLT32 Guard::~Guard()-0x4
2d: movl %ebx, %eax
2f: addq $0x10, %rsp
33: popq %rbx
34: popq %r14
36: popq %rbp
37: retq
38: jmp 0x3a <twoDigits(unsigned char, unsigned char)+0x3a>
3a: movq %rax, %rbx
3d: leaq -0x11(%rbp), %rdi
41: callq 0x46 <twoDigits(unsigned char, unsigned char)+0x46>
0000000000000042: R_X86_64_PLT32 Guard::~Guard()-0x4
46: movq %rbx, %rdi
49: callq 0x4e <twoDigits(unsigned char, unsigned char)+0x4e>
000000000000004a: R_X86_64_PLT32 _Unwind_Resume-0x4
4e: nop
Успешный путь от 0 до 37 чище, чем у Zig: после call digit нет ни проверки, ни перехода, результат сразу идёт в арифметику. Отсюда название zero-cost exceptions: пока никто не бросает, исключения не стоят ни одной инструкции. А после retq лежит код, в который не ведёт ни один переход. Это площадка приземления: она зовёт деструктор Guard и _Unwind_Resume. Как процессор туда попадает, если перехода нет? Через таблицы.
$ readelf -S -W tryasm_cpp.o | grep -E "Name|text|except|eh_frame"
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 1] .text PROGBITS 0000000000000000 000040 0000b8 00 AX 0 0 16
[ 2] .rela.text RELA 0000000000000000 0001d0 000120 18 I 28 1 8
[ 3] .gcc_except_table PROGBITS 0000000000000000 0000f8 00002c 00 A 0 0 4
[ 4] .rela.gcc_except_table RELA 0000000000000000 0002f0 000018 18 I 28 3 8
[22] .eh_frame X86_64_UNWIND 0000000000000000 000158 000078 00 A 0 0 8
[23] .rela.eh_frame RELA 0000000000000000 000e68 000078 18 I 28 22 8
$ nm -C tryasm_cpp.o | grep -E " U |personality"
0000000000000000 V DW.ref.__gxx_personality_v0
U _Unwind_Resume
U digit(unsigned char)
U Guard::~Guard()
U vtable for __cxxabiv1::__class_type_info
U __cxa_begin_catch
U __cxa_end_catch
U __gxx_personality_v0
На 184 байта кода приходится 120 байт .eh_frame и 44 байта .gcc_except_table, плюс перемещения к ним и пять внешних символов рантайма. .eh_frame описывает для каждого адреса в функции, как по текущему кадру найти предыдущий: где лежит адрес возврата, где сохранённые регистры. .gcc_except_table хранит для каждого диапазона адресов с вызовами адрес площадки приземления и список типов, которые здесь ловят. __gxx_personality_v0 это функция, которая умеет эту таблицу читать.
Бросок работает в две фазы. throw выделяет объект исключения в куче и зовёт раскрутчик. В первой фазе, поиске, раскрутчик идёт по кадрам вверх по .eh_frame, ничего не меняя, и в каждом кадре спрашивает у personality-функции: есть ли тут catch подходящего типа? Если не нашёлся никто, вызывается std::terminate, и стек остаётся нетронутым для отладчика. Если нашёлся, начинается вторая фаза, очистка: раскрутчик снова идёт от точки броска, восстанавливает регистры каждого кадра по .eh_frame и прыгает на его площадку приземления; та зовёт деструкторы и через _Unwind_Resume возвращает управление раскрутчику, пока дело не дойдёт до кадра с catch.
Узнаёшь? Это longjmp, которому дали карту. Он так же восстанавливает регистры и прыгает вверх через кадры, только не вслепую: таблицы говорят ему, какие кадры лежат на пути и что в каждом нужно убрать. Деструкторы закрывают дыру с утечкой. Цена: таблицы в бинарнике, рантайм раскрутки в зависимостях и бросок стоимостью в тысячи инструкций, на три порядка дороже возврата. Поэтому в C++ и говорят “исключения только для исключительного”, а ядра, игровые движки и встроенные системы собираются с -fno-exceptions.
Честное уточнение про Zig. В его объектнике .eh_frame тоже есть:
$ zig build-obj -target x86_64-linux -O ReleaseFast -fstrip tryasm.zig
$ readelf -S -W tryasm.o | grep -E "Name|text|except|eh_frame"
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 1] .text PROGBITS 0000000000000000 000040 0000cc 00 AX 0 0 16
[ 2] .eh_frame X86_64_UNWIND 0000000000000000 000110 000090 00 A 0 0 8
[ 3] .rela.eh_frame RELA 0000000000000000 0001a0 000048 18 I 5 2 8
$ zig build-obj -target x86_64-linux -O ReleaseFast -fstrip -fno-unwind-tables tryasm.zig
$ readelf -S -W tryasm.o | grep -E "text|except|eh_frame"
[ 1] .text PROGBITS 0000000000000000 000040 0000cc 00 AX 0 0 16
Но роль у неё другая. Эти 144 байта нужны отладчику, профилировщику и dumpCurrentStackTrace, чтобы пройти по стеку там, где нет цепочки %rbp. Поток управления программы от них не зависит: с -fno-unwind-tables секция исчезает, .text не меняется ни на байт, и ошибки работают как работали. .gcc_except_table, площадок приземления и personality-функции в Zig нет в принципе. В C++ убрать таблицы нельзя, не убрав сами исключения: компилятор встречает try при -fno-exceptions словами cannot use 'try' with exceptions disabled.
Сведём три механизма в одну таблицу.
setjmp и longjmp | исключения C++ | ошибки Zig | |
|---|---|---|---|
| путь без ошибки | вызов setjmp: десяток записей в память (на macOS ещё сисколл) | ноль инструкций | проверка и переход на каждый try |
| путь с ошибкой | десяток чтений и jmp | поиск по таблицам, две фазы, тысячи инструкций | обычный возврат |
| промежуточные кадры | выброшены молча | деструкторы через площадки приземления | defer и errdefer обычным кодом |
| видно ли в сигнатуре | нет | нет (кроме noexcept) | да, E!T |
| нужен рантайм | три функции libc | раскрутчик, personality, __cxa_* | нет |
| данные в бинарнике | нет | .eh_frame и .gcc_except_table | нет (.eh_frame только для трасс) |
| след после ошибки | никакого | стек на момент terminate, если не поймали | трасса возврата ошибки |
Почему не позвать setjmp из Zig
Zig умеет звать любую функцию C, так что объявить extern fn setjmp(env: *anyopaque) c_int; технически можно. Делать этого нельзя, и причин три.
Первая: компилятор не знает, что функция возвращается дважды. В C setjmp особенный: clang и gcc узнают его по имени, помечают вызов атрибутом returns_twice и отключают вокруг него оптимизации, которые исходят из “вызов возвращается один раз”. Помнишь plain = 0 при -O2? Это было с компилятором, который про setjmp знает и ведёт себя по стандарту. У Zig для extern fn такого атрибута нет. Для LLVM это обычный вызов, и он вправе держать локальные переменные в регистрах, переставлять записи и выбрасывать “мёртвый” код вокруг второго возврата. Получится не “значение не определено у одной переменной”, а неопределённое поведение всей функции.
Вторая: longjmp через кадры Zig пропустит их defer и errdefer. Вся гарантия из раздела про errdefer держится на том, что из функции нельзя выйти мимо компилятора. Один прыжок, и DebugAllocator показывает утечки там, где по коду их быть не может, а трасса возврата ошибки хранит адреса кадров, которых нет.
Третья: буфер. jmp_buf это непрозрачный массив, размер и раскладка которого зависят от libc и архитектуры (мы видели 200 и 192 байта), а в glibc setjmp к тому же макрос над _setjmp. Объявление руками придётся поддерживать под каждую цель отдельно.
Если C-библиотека сообщает об ошибках через longjmp (так делают libpng и libjpeg), прыжок должен начаться и закончиться в коде на C. Пишется тонкая прослойка на C: она ставит setjmp, зовёт библиотеку и возвращает наружу обычный код ошибки. Zig зовёт прослойку и превращает код в error.X. Прыжок остаётся внутри одного языка и не пересекает ни одного кадра Zig; как подключить такой файл к сборке, ты знаешь из урока про build.zig и C.
Упражнения
Итоги
setjmpсохраняет вjmp_bufшесть регистров вызываемого,%rspи адрес возврата;longjmpих восстанавливает и делаетjmp. Поэтомуsetjmpвозвращается дважды (сначала 0, потом код изlongjmp), аlongjmpне возвращается. Память, куча и пропущенные кадры не откатываются.- Три опасности: кадр с ресурсом выбрасывается молча (дескрипторы 3, 4, 5); локальная переменная без
volatile, изменённая междуsetjmpиlongjmp, под оптимизатором не определена (plain = 0при-O2); прыжок в кадр вернувшейся функции это неопределённое поведение. - Из обработчика сигнала прыгают только через
sigsetjmp(env, 1)иsiglongjmp: они сохраняют и восстанавливают маску сигналов. С обычнымsetjmpна Linux сигнал остаётся заблокированным навсегда; на macOSsetjmpмаску сохраняет сам, а поведение Linux дают_setjmpи_longjmp. После прыжка из обработчика программа вправе звать только async-signal-safe функции, если сигнал прервал небезопасную. - Обработчик ставится после
sigsetjmp, иначе сигнал может прийти раньше, чем заполнен буфер. - В Zig нелокальных переходов нет: ошибка это значение в
E!T,tryвозвращает её обычнымret, каждый кадр на пути выполняет своиdeferиerrdefer. Перезапуск по сигналу делается флагом, который проверяет главный цикл. - Паника это остановка при нарушенном инварианте; её нельзя поймать, но обработчик можно заменить:
pub const panic = std.debug.FullPanic(myFn);в корневом файле, гдеmyFnимеет типfn ([]const u8, ?usize) noreturn. Готовые варианты:std.debug.simple_panicиstd.debug.no_panic. std.debug.dumpCurrentStackTrace(.{})печатает, кто кого вызвал;@errorReturnTrace()плюсstd.debug.dumpErrorReturnTraceпечатают, где ошибка родилась и через какиеtryпрошла. ВReleaseFastнет ни того, ни другого.tryв машинном коде это загрузка кода ошибки,testиjne. Исключение C++ на успешном пути не стоит ничего, но требует.eh_frame,.gcc_except_table, площадок приземления и двухфазной раскрутки; бросок на три порядка дороже возврата..eh_frameу Zig есть только ради трасс и убирается флагом без последствий для программы.- Звать
setjmpиз Zig нельзя: компилятор не знает про второй возврат, прыжок обходитdefer, раскладкаjmp_bufзависит от libc. Библиотеки наlongjmpоборачивают прослойкой на C.
Дальше
Теперь у тебя есть весь набор: исключения и системные вызовы, процессы, сигналы с их гонками и масками, и понимание, почему управление в Zig всегда идёт по видимым путям. Пора собрать это в одну программу. В следующем уроке мы напишем оболочку с управлением заданиями: разбор командной строки, fork и execve, группы процессов и терминал, SIGCHLD, SIGINT и SIGTSTP, встроенные jobs, bg и fg. Там не будет ни одного longjmp: главный цикл с флагами, блокировка сигналов вокруг таблицы заданий и errdefer на каждый открытый дескриптор.
домашка