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

Подмена функций и компоновка на практике

senior~170 мин

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

Подмена функций и компоновка на практике

Четыре урока мы разбирали компоновщик как механизм: таблицы имён, разрешение ссылок, перемещения, GOT и PLT. Сегодня мы этим механизмом воспользуемся. Раз имя malloc в программе это всего лишь строка, которую кто-то когда-то свяжет с адресом, то связать её можно не с той функцией, которую имел в виду автор. Мы трижды подсунем чужой программе свой malloc: на этапе компиляции, на этапе компоновки и на этапе загрузки, каждый раз посмотрим, что для этого нужно иметь на руках, и один раз красиво упадём в бесконечную рекурсию. Потом третий способ станет подкомандой zt ltrace. А во второй половине урока соберём практические знания про компоновку в Zig: сколько на самом деле весит статический бинарник на musl и на glibc, что делают -fstrip и --gc-sections, как собрать программу под чужую платформу одной командой, как отдать zig cc чужой C-проект и когда программе на Zig нужен -lc.

Цели урока

  • Понимать подмену библиотечной функции как следствие того, что вызов привязывается к коду по имени, и называть три момента, когда привязку можно перехватить.
  • Написать трассировщик malloc и free каждым из трёх способов: макросами препроцессора, ключом --wrap и библиотекой под LD_PRELOAD с dlsym(RTLD_NEXT, ...).
  • Знать, что каждому способу нужно (исходники, объектники или только бинарник) и чего он не видит.
  • Воспроизвести и объяснить бесконечную рекурсию printf внутри обёртки malloc и писать перехватчики, которые не трогают кучу.
  • Собрать zt ltrace: библиотеку-перехватчик с таблицей функций, защитой от рекурсии и фильтром по именам.
  • Ориентироваться в binutils: какой утилитой что смотреть и что из этого уже умеет твой zt.
  • Осознанно выбирать режим компоновки в Zig: статика на musl или динамика на glibc, -fstrip, --gc-sections, цель сборки, zig cc для C-проекта, -lc или без него. На каждый выбор у тебя будут реальные размеры.

Идея: вызов привязан к имени, а не к коду

Когда ты пишешь malloc(32), компилятор не знает, где живёт malloc. Он оставляет в объектнике неопределённое имя и запись о перемещении, а адрес появится позже. Для статической компоновки позже значит при сборке исполняемого файла. Для динамической ещё позже: при загрузке программы или даже при первом вызове, через ленивое связывание в PLT.

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

  • трассировка и профилирование: кто, сколько и каких размеров просит у кучи; сколько раз открывался файл; какие строки уходят в write;
  • поиск ошибок: обёртка над free ловит двойное освобождение, обёртка над malloc подкладывает сторожевые байты вокруг блока (так работают отладочные аллокаторы, до них мы дойдём в уроке про ошибки памяти);
  • замена реализации: jemalloc, tcmalloc и mimalloc подключаются к готовой программе ровно так, подменой malloc на этапе загрузки;
  • тесты: подменить time, rand или connect, чтобы программа жила в управляемом мире;
  • песочницы и совместимость: fakeroot, faketime, proxychains целиком построены на третьем уровне.

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

Три уровня подмены библиотечной функции
  1. исходник
  2. объектник .o
  3. исполняемый файл
  4. процесс

Время компиляции

нужно иметьисходники программы

Программу придётся перекомпилировать: подмена живёт в тексте.

          # C: макросы в заголовке, подложенном ключом -include
#define malloc(size) mymalloc(size)
zig cc -c mymalloc.c -o mymalloc.o
zig cc -include wrap/malloc.h -c main.c -o main.o
zig cc -o prog main.o mymalloc.o

# Zig: аллокатор и так параметр, подмена это обёртка над ним
var tracing: Tracing = .{ .child = gpa };
const text = try greeting(tracing.allocator(), "zt");
        
путь вызова
  1. main зовёт malloc(32)
  2. препроцессор переписал имя: в объектнике уже call mymalloc
  3. mymalloc печатает трассу и зовёт настоящую malloc из libc

В main.o имени malloc нет совсем: компоновщик про подмену ничего не знает.

Чем позже уровень, тем меньше нужно иметь на руках. Переключай кнопками или стрелками влево и вправо: на схеме подсвечен переход, в который вклинивается подмена.

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

Все эксперименты урока сняты в контейнере linux/amd64: Debian 12, gcc 12.2.0, GNU ld 2.40, glibc 2.36, Zig 0.16.0. Контейнер работал на Apple Silicon через Rosetta, поэтому адреса кучи у тебя будут другими, а всё остальное совпадёт.

Подопытная программа

Нам нужна программа, которая зовёт malloc и free и ничего не знает о наших планах. Пусть будет такая:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    char *name = malloc(32);
    strcpy(name, "zt");
    printf("hello, %s\n", name);
    free(name);
    return 0;
}

Один блок на 32 байта, одна строка, одно освобождение. Во всех трёх экспериментах main.c останется нетронутым: меняться будет только то, как мы его собираем и запускаем.

Уровень первый: компиляция

Самый ранний момент: имя ещё даже не попало в объектник. В C для этого есть препроцессор. Если перед компиляцией превратить каждое malloc(...) в mymalloc(...), компилятор увидит уже другой текст.

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

#include <stdio.h>
#include <stdlib.h>

void *mymalloc(size_t size) {
    void *ptr = malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
    return ptr;
}

void myfree(void *ptr) {
    free(ptr);
    fprintf(stderr, "free(%p)\n", ptr);
}

А переименование делает заголовок:

#include <stdlib.h>

#define malloc(size) mymalloc(size)
#define free(ptr) myfree(ptr)

void *mymalloc(size_t size);
void myfree(void *ptr);

Первая строка заголовка важна. stdlib.h должен подключиться раньше макросов: тогда его объявление void *malloc(size_t) пройдёт как есть, а когда main.c позже подключит stdlib.h сам, защита от повторного включения сделает этот #include пустым. Иначе макрос переписал бы и объявление внутри системного заголовка.

Осталось подложить заголовок программе, не редактируя её. Ключ -include делает вид, что первой строкой файла стоит #include "wrap/malloc.h":

$ gcc -c mymalloc.c -o mymalloc.o
$ gcc -include wrap/malloc.h -c main.c -o main1.o
$ gcc -o prog1 main1.o mymalloc.o
$ ./prog1
malloc(32) = 0x5555555592a0
hello, zt
free(0x5555555592a0)

Книга добивается того же иначе: кладёт рядом с программой свой файл malloc.h и ключом -I. ставит текущий каталог первым в списке поиска заголовков, так что #include <malloc.h> из программы находит подложенный файл раньше системного. Приём с -include удобнее тем, что не требует, чтобы программа вообще подключала какой-то определённый заголовок.

Посмотрим, что именно увидел компилятор. Ключ -E останавливает gcc после препроцессора:

$ gcc -E -include wrap/malloc.h main.c | tail -8
# 5 "main.c"
int main(void) {
    char *name = mymalloc(32);
    strcpy(name, "zt");
    printf("hello, %s\n", name);
    myfree(name);
    return 0;
}

И что попало в таблицу имён объектника:

$ nm main1.o
0000000000000000 T main
                 U myfree
                 U mymalloc
                 U printf

Имени malloc в main1.o нет вовсе. Компоновщик о подмене ничего не знает и знать не должен: для него это обычная программа, которая зовёт обычную функцию mymalloc. Те же три команды с zig cc вместо gcc дают тот же результат, zig cc принимает -include как любой clang.

У первого уровня два ограничения, и оба следуют из того, что он работает с текстом. Во-первых, нужны исходники всего, за чем ты хочешь следить: вызовы malloc из уже скомпилированной библиотеки макрос не тронет. Во-вторых, макрос ловит только прямые вызовы по имени. Строка void *(*alloc)(size_t) = malloc; берёт адрес функции без скобок после имени, функциональный макрос на неё не срабатывает, и через такой указатель программа ходит в настоящий malloc мимо трассы.

А как это выглядит в Zig

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

const std = @import("std");
const Allocator = std.mem.Allocator;
const Alignment = std.mem.Alignment;

/// Аллокатор-обёртка: каждое обращение печатает в stderr и уходит в `child`.
const Tracing = struct {
    child: Allocator,

    fn allocator(self: *Tracing) Allocator {
        return .{ .ptr = self, .vtable = &vtable };
    }

    const vtable: Allocator.VTable = .{
        .alloc = alloc,
        .resize = resize,
        .remap = remap,
        .free = free,
    };

    fn alloc(context: *anyopaque, len: usize, alignment: Alignment, ret_addr: usize) ?[*]u8 {
        const self: *Tracing = @ptrCast(@alignCast(context));
        const result = self.child.rawAlloc(len, alignment, ret_addr);
        std.debug.print("alloc({d}) = 0x{x}\n", .{ len, @intFromPtr(result) });
        return result;
    }

    fn resize(context: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) bool {
        const self: *Tracing = @ptrCast(@alignCast(context));
        const ok = self.child.rawResize(memory, alignment, new_len, ret_addr);
        std.debug.print("resize(0x{x}, {d}) = {}\n", .{ @intFromPtr(memory.ptr), new_len, ok });
        return ok;
    }

    fn remap(context: *anyopaque, memory: []u8, alignment: Alignment, new_len: usize, ret_addr: usize) ?[*]u8 {
        const self: *Tracing = @ptrCast(@alignCast(context));
        const result = self.child.rawRemap(memory, alignment, new_len, ret_addr);
        std.debug.print("remap(0x{x}, {d}) = 0x{x}\n", .{ @intFromPtr(memory.ptr), new_len, @intFromPtr(result) });
        return result;
    }

    fn free(context: *anyopaque, memory: []u8, alignment: Alignment, ret_addr: usize) void {
        const self: *Tracing = @ptrCast(@alignCast(context));
        std.debug.print("free(0x{x})\n", .{@intFromPtr(memory.ptr)});
        self.child.rawFree(memory, alignment, ret_addr);
    }
};

/// Функция про подмену ничего не знает: ей дали аллокатор, она им пользуется.
fn greeting(gpa: Allocator, name: []const u8) ![]u8 {
    return std.fmt.allocPrint(gpa, "hello, {s}", .{name});
}

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 tracing: Tracing = .{ .child = init.gpa };
    const gpa = tracing.allocator();

    const text = try greeting(gpa, "zt");
    defer gpa.free(text);
    try out.print("{s}\n", .{text});
    try out.flush();
}
$ zig run tracing.zig
alloc(10) = 0x7ffffd180040
remap(0x7ffffd180040, 9) = 0x7ffffd180040
hello, zt
free(0x7ffffd180040)

Функция greeting не изменилась ни на букву и не подключала никаких особых заголовков. Трасса заодно показывает, как устроен allocPrint внутри: он заводит буфер ёмкостью в длину строки формата (в "hello, {s}" десять символов), пишет туда результат и ужимает блок до точного размера через remap. Интерфейс std.mem.Allocator это четыре функции в таблице, и все четыре мы обернули: alloc, resize, remap, free.

Сравни с C. Там подмена на этапе компиляции это трюк с препроцессором, который нужно знать и который легко обойти. Здесь это штатное свойство интерфейса: std.testing.allocator, который ловит утечки в тестах, std.heap.ArenaAllocator и std.testing.FailingAllocator, который отказывает на N-й аллокации, сделаны тем же способом. У приёма то же ограничение, что и у макросов: он действует только на код, которому ты сам передаёшь аллокатор. До malloc внутри чужой C-библиотеки, с которой программа скомпонована, он не дотянется. Для этого нужны следующие два уровня.

Уровень второй: компоновка

Теперь исходников у нас нет, есть только main.o. В нём лежит неопределённое имя malloc, и разрешать его будет компоновщик. Вот ему мы и скажем разрешить иначе.

У GNU ld и у lld для этого есть ключ --wrap=имя. Он меняет правила разрешения для одного имени:

  • каждая ссылка на имя разрешается в __wrap_имя;
  • каждая ссылка на __real_имя разрешается в настоящее имя.

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

#include <stdio.h>

void *__real_malloc(size_t size);
void __real_free(void *ptr);

void *__wrap_malloc(size_t size) {
    void *ptr = __real_malloc(size);
    fprintf(stderr, "malloc(%zu) = %p\n", size, ptr);
    return ptr;
}

void __wrap_free(void *ptr) {
    __real_free(ptr);
    fprintf(stderr, "free(%p)\n", ptr);
}

Функции __real_malloc нигде нет и никогда не будет: это просто имя, которое компоновщик по договорённости свяжет с malloc из libc. Собираем. Драйверу gcc ключи компоновщика передаются через -Wl,:

$ gcc -c main.c -o main.o
$ gcc -c wrapmalloc.c -o wrapmalloc.o
$ gcc -Wl,--wrap=malloc -Wl,--wrap=free -o prog2 main.o wrapmalloc.o
$ ./prog2
malloc(32) = 0x5555555592a0
hello, zt
free(0x5555555592a0)

Убедимся, что main.o собран без всяких хитростей и что вся подмена произошла в компоновщике:

$ nm main.o
                 U free
0000000000000000 T main
                 U malloc
                 U printf
$ nm wrapmalloc.o
                 U __real_free
                 U __real_malloc
0000000000000045 T __wrap_free
0000000000000000 T __wrap_malloc
                 U fprintf
                 U stderr
$ nm prog2 | grep -i 'malloc\|free'
00000000000011ff T __wrap_free
00000000000011ba T __wrap_malloc
                 U free@GLIBC_2.2.5
                 U malloc@GLIBC_2.2.5

В main.o обычные U malloc и U free. В готовой программе имён __real_malloc и __real_free уже нет: они растворились в ссылках на настоящие malloc и free из libc.so.6, которые остались неопределёнными до загрузки, как и положено при динамической компоновке.

Когда ключа нет: то же самое руками

А теперь попробуем zig cc:

$ zig cc -Wl,--wrap=malloc -Wl,--wrap=free -o z_prog2 main.o wrapmalloc.o
error: unsupported linker arg: --wrap

Драйвер Zig 0.16 разбирает ключи компоновщика сам и --wrap среди них не знает. Это хороший повод убедиться, что в ключе нет никакой магии. Всё, что он делает, это два переименования в таблицах имён, а переименовывать имена в объектнике умеет objcopy:

$ objcopy --redefine-sym malloc=__wrap_malloc --redefine-sym free=__wrap_free main.o main_wrapped.o
$ objcopy --redefine-sym __real_malloc=malloc --redefine-sym __real_free=free wrapmalloc.o wrap_real.o
$ nm main_wrapped.o
                 U __wrap_free
                 U __wrap_malloc
0000000000000000 T main
                 U printf
$ nm wrap_real.o
0000000000000045 T __wrap_free
0000000000000000 T __wrap_malloc
                 U fprintf
                 U free
                 U malloc
                 U stderr
$ zig cc -o z_prog2 main_wrapped.o wrap_real.o
$ ./z_prog2
malloc(32) = 0x10042a0
hello, zt
free(0x10042a0)

Первая команда переписала в main.o ссылки на malloc и free. Вторая сделала обратное в объектнике с обёртками: __real_malloc стал обычным malloc. После этого компоновать можно чем угодно, хоть своим zt ld, если дать ему статическую libc. Ты уже читал таблицу имён своим кодом и понимаешь, что именно правит objcopy: поле st_name указывает на строку в .strtab, инструмент дописывает в таблицу строк новое имя и переставляет смещение.

Чего не видит второй уровень

Ключ --wrap работает с неопределёнными ссылками между объектниками. Отсюда три слепых пятна.

  • Вызов внутри одного объектника. Если malloc определён и вызывается в одном и том же .o, компилятор или ассемблер могли связать вызов с определением ещё до компоновщика, и ссылки, которую можно переименовать, в таблице уже нет.
  • Уже скомпонованные разделяемые библиотеки. libc.so.6 компоновалась без нашего ключа, и когда printf внутри неё зовёт malloc, этот вызов идёт мимо обёртки. Сравни с выводом третьего уровня ниже: там такой вызов будет виден.
  • Нужна повторная компоновка, то есть объектники. Программу, которая пришла готовым бинарником, так не возьмёшь.

В тестах на C второй уровень любят больше всего: собираешь те же объектники, что идут в релиз, добавляешь --wrap=connect и объектник с __wrap_connect, и тестируемый код ходит в подделку, не зная об этом. Исходники при этом не содержат ни одного #ifdef TEST.

Уровень третий: загрузка

Самый поздний момент и самый мощный. Программа уже собрана, скомпонована динамически, и имя malloc в ней разрешит динамический компоновщик ld.so при запуске. Как он ищет определение? Обходит загруженные объекты по порядку: сначала сам исполняемый файл, потом библиотеки в порядке загрузки, и берёт первое найденное определение. Переменная окружения LD_PRELOAD вставляет в этот порядок наши библиотеки сразу после исполняемого файла, перед libc. Если в такой библиотеке есть malloc, выиграет он.

Остаётся вопрос, как из своей malloc позвать настоящую. Написать внутри malloc(size) нельзя: это имя теперь разрешается в нас самих. Нужна функция dlsym из прошлого урока с особым первым аргументом. RTLD_NEXT означает: найди это имя в объектах, которые стоят в порядке поиска после меня. После нас стоит libc, там и найдётся настоящая функция.

Первая попытка и бесконечная рекурсия

Напишем обёртку в лоб, по образцу первых двух уровней:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>

void *malloc(size_t size) {
    void *(*real_malloc)(size_t) = dlsym(RTLD_NEXT, "malloc");
    void *ptr = real_malloc(size);
    printf("malloc(%zu) = %p\n", size, ptr);
    return ptr;
}
$ gcc -o prog main.c
$ gcc -shared -fPIC -o libnaive.so mymalloc_naive.c
$ LD_PRELOAD=./libnaive.so ./prog
Segmentation fault
$ echo $?
139

Ни одной строки трассы, сразу SIGSEGV. Код возврата 139 это 128 плюс 11, номер сигнала. Причину легко восстановить, если вспомнить, что printf пишет не в терминал, а в буфер потока stdout, и этот буфер glibc заводит лениво, при первом выводе. Заводит она его через malloc. А malloc теперь наш:

  1. main зовёт malloc(32), управление приходит в нашу обёртку;
  2. обёртка зовёт настоящий malloc, получает блок и зовёт printf;
  3. printf видит, что у stdout ещё нет буфера, и зовёт malloc, то есть снова нашу обёртку;
  4. обёртка зовёт настоящий malloc, получает блок и зовёт printf;
  5. у stdout буфера по-прежнему нет, потому что шаг 3 ещё не завершился, и всё повторяется.

Каждый виток оставляет на стеке два кадра, стек в восемь мегабайт кончается за доли секунды, и ядро присылает SIGSEGV за обращение ниже его границы. Первые два уровня эту ловушку не показали по простой причине: там мы подменяли только вызовы из main.o, а malloc внутри libc оставался настоящим. На третьем уровне подмена действует на весь процесс, включая саму libc, и любая функция, которую ты зовёшь из обёртки, может вернуться к тебе же.

Отсюда правило на весь оставшийся урок: внутри перехватчика malloc нельзя делать ничего, что может позвать malloc. Никаких printf, fopen, std::string, никаких аллокаторов. Строку собираем в буфере на стеке, наружу отдаём системным вызовом write. Это то же правило, что для обработчиков сигналов, и причина та же: мы вклиниваемся в середину чужой работы и не знаем, в каком состоянии её структуры.

Вторая попытка: без кучи

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <unistd.h>

static void *(*real_malloc)(size_t);
static void (*real_free)(void *);

void *malloc(size_t size) {
    if (!real_malloc) real_malloc = dlsym(RTLD_NEXT, "malloc");
    void *ptr = real_malloc(size);

    char line[64];
    int length = snprintf(line, sizeof line, "malloc(%zu) = %p\n", size, ptr);
    write(2, line, (size_t)length);
    return ptr;
}

void free(void *ptr) {
    if (!real_free) real_free = dlsym(RTLD_NEXT, "free");
    real_free(ptr);
    if (!ptr) return;

    char line[64];
    int length = snprintf(line, sizeof line, "free(%p)\n", ptr);
    write(2, line, (size_t)length);
}

snprintf пишет в готовый буфер и для простых форматов кучу не трогает, write это тонкая обёртка над системным вызовом. Указатели на настоящие функции ищем один раз и запоминаем: dlsym обходит таблицы имён всех библиотек, и платить за это на каждом malloc незачем.

$ gcc -shared -fPIC -o libtrace.so trace.c
$ LD_PRELOAD=./libtrace.so ./prog
malloc(32) = 0x5555555592a0
malloc(1024) = 0x5555555592d0
hello, zt
free(0x5555555592a0)

Программа prog собрана самым обычным gcc -o prog main.c, без единого намёка на трассировку. И в трассе появилась строка, которой не было на первых двух уровнях: malloc(1024). Это тот самый ленивый буфер stdout, из-за которого мы только что падали. Теперь мы видим вызовы malloc не только из программы, но и из самой libc.

Раз уж он виден, поставим опыт. Направим вывод программы не в терминал, а в трубу:

$ LD_PRELOAD=./libtrace.so ./prog | cat
malloc(32) = 0x5555555592a0
malloc(4096) = 0x5555555592d0
free(0x5555555592a0)
hello, zt

Буфер вырос с 1024 до 4096 байт, а hello, zt переехала в самый конец. glibc смотрит, куда ведёт дескриптор 1: для терминала она заводит буфер в килобайт и сбрасывает его на каждом переводе строки, для трубы или файла берёт буфер в размер блока и сбрасывает, только когда он заполнится или программа завершится. Трасса же идёт через write в дескриптор 2 без всякого буфера, поэтому обгоняет текст программы. Трассировщик на три десятка строк показал поведение стандартной библиотеки, о котором обычно узнают из документации. К буферам ввода и вывода мы вернёмся в уроке про буферизованный ввод и вывод. И заметь: блок на 4096 байт никто не освободил. libc не возвращает буфер stdout при выходе, это бессмысленная работа перед смертью процесса.

Как это видит загрузчик

Верить на слово, что ld.so связал имена именно так, не обязательно. У загрузчика glibc есть отладочная переменная LD_DEBUG. Значение bindings печатает каждую привязку имени:

$ LD_DEBUG=bindings LD_PRELOAD=./libtrace.so ./prog 2>&1 | grep 'malloc\|`free'
     10723:	binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to ./libtrace.so [0]: normal symbol `free' [GLIBC_2.2.5]
     10723:	binding file /lib/x86_64-linux-gnu/libc.so.6 [0] to ./libtrace.so [0]: normal symbol `malloc' [GLIBC_2.2.5]
     10723:	binding file ./prog [0] to ./libtrace.so [0]: normal symbol `free' [GLIBC_2.2.5]
     10723:	binding file ./prog [0] to ./libtrace.so [0]: normal symbol `malloc' [GLIBC_2.2.5]
     10723:	binding file ./libtrace.so [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol `malloc'

Читается это так. Первые две строки: ссылки на malloc и free из самой libc.so.6 привязаны к libtrace.so. Вот откуда в трассе буфер stdout. Следующие две: ссылки из prog привязаны туда же. Последняя строка это наш dlsym(RTLD_NEXT, "malloc"): имя из libtrace.so привязано к libc.so.6. Круг замкнулся ровно так, как мы задумали.

Любая чужая программа

Главная сила третьего уровня: программу не нужно ни собирать, ни даже иметь право читать её исходники.

$ LD_PRELOAD=./libtrace.so /bin/ls wrap 2>&1 | head -8
malloc(472) = 0x55555557a2a0
malloc(120) = 0x55555557a480
malloc(1024) = 0x55555557a500
free(0x55555557a480)
free(0x55555557a500)
free(0x55555557a2a0)
malloc(34) = 0x55555557a910
malloc(10) = 0x55555557a940
$ LD_PRELOAD=./libtrace.so /bin/ls / 2>&1 >/dev/null | wc -l
42

ls на корневом каталоге оставляет в трассе 42 строки. Первая тройка malloc с быстрым free это fopen, чтение и fclose какого-то файла при старте: 472 байта glibc берёт под структуру FILE с блокировкой, 1024 под буфер чтения.

Чего не видит третий уровень

  • Статически скомпонованные программы. В них нет ни ld.so, ни PLT, имя malloc превратилось в адрес ещё при сборке. LD_PRELOAD просто некому прочитать:

    $ gcc -static -o prog_static main.c
    $ LD_PRELOAD=./libtrace.so ./prog_static
    hello, zt

    Запомни этот молчаливый вывод. Программа на Zig по умолчанию именно такая, и мы к этому вернёмся.

  • Программы с повышенными правами. Для setuid-бинарников и программ с файловыми capabilities ядро выставляет во вспомогательном векторе флаг AT_SECURE, и ld.so переходит в режим безопасного исполнения: из LD_PRELOAD принимаются только библиотеки из системных каталогов, у которых тоже стоит бит setuid. Иначе любой пользователь мог бы подсунуть sudo свой getuid.

  • Вызовы, которые не проходят через динамическую привязку. Если библиотека собрана с -Bsymbolic или зовёт свою функцию под внутренним скрытым именем (glibc так делает для многих путей, например через __libc_malloc), вызов идёт напрямую. А прямой syscall из машинного кода не увидит вообще никакой перехватчик на уровне библиотек: для этого нужен ptrace, и им займётся zt strace в следующем уроке.

  • Функции, которые нужны самому механизму. На старых glibc dlsym при первом вызове зовёт calloc. Если ты перехватил calloc и внутри ищешь настоящий calloc через dlsym, получишь рекурсию ещё до первой строки трассы. Серьёзные перехватчики держат на этот случай маленький статический буфер и выдают память из него, пока настоящая функция не найдена.

Три уровня рядом

компиляциякомпоновказагрузка
что нужно иметьисходникиобъектники .oтолько бинарник
чем делается#define и -include, в Zig параметр-аллокатор--wrap=имя или objcopy --redefine-symLD_PRELOAD и dlsym(RTLD_NEXT, ...)
имя настоящей функцииmalloc (в файле без макроса)__real_mallocто, что вернул dlsym
видит вызовы из libcнетнетда
видит вызов через указатель на функциюнетдада
работает для статического бинарникададанет
цена в работающей программенольнольодин dlsym на функцию и лишний переход на каждый вызов
типичное применениеотладочные сборки, аллокаторы в Zigподделки в тестах на Cтрассировка, замена аллокатора, faketime

Практика: та же библиотека на Zig

Обёртку третьего уровня мы написали на C. Разделяемую библиотеку с теми же именами можно собрать и на Zig: нужны export fn с сигнатурой libc, extern "c" для dlsym и write и тот же запрет на кучу внутри. В задаче стенд сам собирает твою библиотеку, сам собирает подопытную программу с -lc, запускает её под LD_PRELOAD и сверяет трассу, заменив адреса метками. Три сборки подряд идут секунд двадцать.

Проект zt: подкоманда ltrace

В zt уже есть readelf, nm, ld, а с прошлого урока ldd и plt. Сегодня добавим инструмент другого рода: он не читает файл, а подсматривает за живым процессом. Настоящий ltrace делает это через ptrace и точки останова в PLT. Мы возьмём механизм проще и честнее по отношению к теме урока: LD_PRELOAD.

$ zt ltrace fixtures/dyn/hello_dyn
malloc(32) = 0x10042a0
malloc(1024) = 0x10042d0
hello, zt
free(0x10042a0)
bye
puts("bye") = 4

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

  • src/ltrace/hooks.zig: таблица перехватываемых функций и формат строки трассы. Чистый код: ни libc, ни Linux. Тесты идут везде, в том числе на macOS.
  • src/ltrace/preload.zig: библиотека libzt-ltrace.so. Экспортирует malloc, free, realloc, puts, ищет настоящие функции и зовёт форматирование из hooks.zig. Собирается только под Linux.
  • src/ltrace.zig и ветка в src/main.zig: сама подкоманда. Она выставляет LD_PRELOAD и заменяет себя программой. Всё.

Таблица перехватов

//! Чистая половина `zt ltrace`: таблица перехватываемых функций и формат
//! строки трассы. Ни libc, ни Linux здесь нет, поэтому эта часть проверяется
//! тестами на любой машине.
//!
//! Внутри перехватчика `malloc` нельзя звать `malloc`, а значит нельзя и
//! почти всё привычное: ни аллокаторов, ни `printf`. Поэтому строка трассы
//! собирается в буфере на стеке, а функции форматирования ничего не выделяют.

const std = @import("std");

/// Как печатать аргумент или результат.
pub const Value = enum {
    none,
    /// Размер в байтах, десятичное число.
    size,
    /// Адрес, шестнадцатеричное число. Нулевой печатается как `NULL`.
    pointer,
    /// Строка C. В регистре лежит адрес, читаем по нему до нулевого байта.
    string,
    /// Целое со знаком, как `int` в C.
    int,
};

pub const Hook = struct {
    name: []const u8,
    args: []const Value,
    result: Value,
};

/// Что `zt ltrace` умеет перехватывать. Новая функция это строка здесь
/// и обёртка в `preload.zig`.
pub const table = [_]Hook{
    .{ .name = "malloc", .args = &.{.size}, .result = .pointer },
    .{ .name = "free", .args = &.{.pointer}, .result = .none },
    .{ .name = "realloc", .args = &.{ .pointer, .size }, .result = .pointer },
    .{ .name = "puts", .args = &.{.string}, .result = .int },
};

pub fn find(name: []const u8) ?Hook {
    for (table) |hook| {
        if (std.mem.eql(u8, hook.name, name)) return hook;
    }
    return null;
}

/// Фильтр `--only=malloc,free`. Без фильтра трассируется всё.
pub fn enabled(filter: ?[]const u8, name: []const u8) bool {
    const list = filter orelse return true;
    var names = std.mem.tokenizeScalar(u8, list, ',');
    while (names.next()) |wanted| {
        if (std.mem.eql(u8, wanted, name)) return true;
    }
    return false;
}

/// Проверяет фильтр до запуска программы: опечатка в имени не должна
/// молча превращаться в пустую трассу.
pub fn unknownName(filter: []const u8) ?[]const u8 {
    var names = std.mem.tokenizeScalar(u8, filter, ',');
    while (names.next()) |wanted| {
        if (find(wanted) == null) return wanted;
    }
    return null;
}

/// Длиннее строки в трассе не показываем: хвост заменяется многоточием.
pub const string_limit = 32;

/// Собирает строку трассы в `buffer`: `malloc(32) = 0x55d0c2a0`.
/// Значения приходят как машинные слова, ровно как они лежали в регистрах.
pub fn formatCall(buffer: []u8, hook: Hook, args: []const usize, result: usize) []const u8 {
    var out: std.Io.Writer = .fixed(buffer);
    write(&out, hook, args, result) catch {
        // Буфер кончился: лучше обрезанная строка, чем никакой.
    };
    return out.buffered();
}

fn write(out: *std.Io.Writer, hook: Hook, args: []const usize, result: usize) !void {
    try out.print("{s}(", .{hook.name});
    for (hook.args, args, 0..) |kind, value, position| {
        if (position > 0) try out.writeAll(", ");
        try writeValue(out, kind, value);
    }
    try out.writeByte(')');
    if (hook.result != .none) {
        try out.writeAll(" = ");
        try writeValue(out, hook.result, result);
    }
    try out.writeByte('\n');
}

fn writeValue(out: *std.Io.Writer, kind: Value, value: usize) !void {
    switch (kind) {
        .none => {},
        .size => try out.print("{d}", .{value}),
        .pointer => if (value == 0) try out.writeAll("NULL") else try out.print("0x{x}", .{value}),
        .int => try out.print("{d}", .{@as(i32, @bitCast(@as(u32, @truncate(value))))}),
        .string => {
            if (value == 0) return out.writeAll("NULL");
            const text = std.mem.span(@as([*:0]const u8, @ptrFromInt(value)));
            if (text.len <= string_limit) {
                try out.print("\"{s}\"", .{text});
            } else {
                try out.print("\"{s}\"...", .{text[0..string_limit]});
            }
        },
    }
}

test "строка трассы malloc и free" {
    var buffer: [128]u8 = undefined;
    try std.testing.expectEqualStrings(
        "malloc(32) = 0x55d0c2a0\n",
        formatCall(&buffer, find("malloc").?, &.{32}, 0x55d0c2a0),
    );
    try std.testing.expectEqualStrings("free(0x55d0c2a0)\n", formatCall(&buffer, find("free").?, &.{0x55d0c2a0}, 0));
    try std.testing.expectEqualStrings("free(NULL)\n", formatCall(&buffer, find("free").?, &.{0}, 0));
}

test "строка C читается по адресу и обрезается" {
    var buffer: [128]u8 = undefined;
    const short: [*:0]const u8 = "bye";
    try std.testing.expectEqualStrings(
        "puts(\"bye\") = 4\n",
        formatCall(&buffer, find("puts").?, &.{@intFromPtr(short)}, 4),
    );
    const long: [*:0]const u8 = "0123456789" ** 5;
    try std.testing.expectEqualStrings(
        "puts(\"01234567890123456789012345678901\"...) = -1\n",
        formatCall(&buffer, find("puts").?, &.{@intFromPtr(long)}, 0xffff_ffff),
    );
}

test "тесный буфер даёт обрезанную строку, а не сбой" {
    var buffer: [8]u8 = undefined;
    try std.testing.expectEqualStrings("malloc(3", formatCall(&buffer, find("malloc").?, &.{32}, 1));
}

test "фильтр по именам" {
    try std.testing.expect(enabled(null, "puts"));
    try std.testing.expect(enabled("malloc,free", "free"));
    try std.testing.expect(!enabled("malloc,free", "puts"));
    try std.testing.expectEqual(@as(?[]const u8, null), unknownName("malloc,free"));
    try std.testing.expectEqualStrings("mallok", unknownName("free,mallok").?);
}

Разбор по частям.

Значения как машинные слова. formatCall получает аргументы срезом []const usize. Это сознательное упрощение: по соглашению о вызовах и размер, и указатель, и int приезжают в функцию в регистрах общего назначения, то есть как 64-битные слова. Как слово толковать, говорит Value из таблицы. Поэтому добавить функцию с целыми и указательными аргументами это одна строка таблицы. Для double приём не годится: такие аргументы едут в %xmm0, и таблице понадобился бы второй срез.

.int и знак. puts возвращает int, 32 бита со знаком, в %eax. В слове usize он лежит в младшей половине. Цепочка @truncate до u32, потом @bitCast в i32 достаёт его вместе со знаком: тест проверяет, что 0xffff_ffff печатается как -1, а не как четыре миллиарда.

std.Io.Writer.fixed. Писатель поверх готового буфера: ни аллокатора, ни системных вызовов. Когда буфер кончается, print возвращает ошибку, мы её глотаем и отдаём то, что поместилось. Обрезанная строка трассы лучше, чем паника внутри чужого malloc.

unknownName. Фильтр проверяется до запуска программы. Опечатка в --only=mallok иначе дала бы пустую трассу, и ты полчаса искал бы, почему программа не зовёт malloc.

Библиотека-перехватчик

//! Библиотека-перехватчик для `zt ltrace`. Собирается в `libzt-ltrace.so`
//! и подсовывается программе через `LD_PRELOAD`.
//!
//! Загрузчик ищет каждое имя по библиотекам в порядке загрузки, а
//! `LD_PRELOAD` ставит нашу библиотеку первой. Поэтому `malloc` из программы
//! и из libc попадает сюда. Настоящую функцию достаём через
//! `dlsym(RTLD_NEXT, ...)`: «найди это имя в библиотеках после меня».
//!
//! Только Linux с glibc или musl: на других системах этот файл не собирается.

const std = @import("std");

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

extern "c" fn dlsym(handle: ?*anyopaque, name: [*:0]const u8) ?*anyopaque;
extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;

/// Особое значение вместо описателя библиотеки: искать в следующих по порядку.
const rtld_next: ?*anyopaque = @ptrFromInt(@as(usize, @bitCast(@as(isize, -1))));

/// Мы уже внутри перехватчика. Пока флаг поднят, вложенные вызовы идут в
/// настоящую функцию молча: иначе трасса того, что делает сам трассировщик,
/// смешается с трассой программы, а неосторожный `malloc` внутри печати
/// уйдёт в бесконечную рекурсию.
// ponytail: один флаг на процесс. В многопоточной программе часть строк
// потеряется; нужен флаг на поток, и тогда TLS модели initial-exec, потому
// что обычный TLS в разделяемой библиотеке сам зовёт malloc при первом доступе.
var inside = false;

/// Настоящая функция с этим именем. Для каждой пары «тип, имя» компилятор
/// заводит свою копию `Cache`, так что `dlsym` зовётся один раз на функцию.
fn next(comptime Fn: type, comptime name: [:0]const u8) *const Fn {
    const Cache = struct {
        var pointer: ?*const Fn = null;
    };
    if (Cache.pointer == null) {
        const found = dlsym(rtld_next, name) orelse @panic("zt ltrace: dlsym не нашёл " ++ name);
        Cache.pointer = @ptrCast(@alignCast(found));
    }
    return Cache.pointer.?;
}

fn trace(comptime name: []const u8, args: []const usize, result: usize) void {
    if (inside) return;
    inside = true;
    defer inside = false;

    const filter: ?[]const u8 = if (getenv("ZT_LTRACE_ONLY")) |text| std.mem.span(text) else null;
    if (!hooks.enabled(filter, name)) return;

    // Буфер на стеке и системный вызов write напрямую, мимо libc:
    // ни одной функции, которую мы сами перехватываем.
    var buffer: [256]u8 = undefined;
    const line = hooks.formatCall(&buffer, comptime hooks.find(name).?, args, result);
    _ = std.os.linux.write(2, line.ptr, line.len);
}

export fn malloc(size: usize) ?*anyopaque {
    const result = next(fn (usize) callconv(.c) ?*anyopaque, "malloc")(size);
    trace("malloc", &.{size}, @intFromPtr(result));
    return result;
}

export fn free(pointer: ?*anyopaque) void {
    // Сначала строка, потом вызов: после free адрес уже ничей.
    trace("free", &.{@intFromPtr(pointer)}, 0);
    next(fn (?*anyopaque) callconv(.c) void, "free")(pointer);
}

export fn realloc(pointer: ?*anyopaque, size: usize) ?*anyopaque {
    const result = next(fn (?*anyopaque, usize) callconv(.c) ?*anyopaque, "realloc")(pointer, size);
    trace("realloc", &.{ @intFromPtr(pointer), size }, @intFromPtr(result));
    return result;
}

export fn puts(text: ?[*:0]const u8) c_int {
    // Пока puts работает, он может звать malloc под свой буфер. Поднятый
    // флаг прячет эти внутренние вызовы: в трассе остаётся то, что написал
    // программист.
    inside = true;
    const result = next(fn (?[*:0]const u8) callconv(.c) c_int, "puts")(text);
    inside = false;
    trace("puts", &.{@intFromPtr(text)}, @as(u32, @bitCast(result)));
    return result;
}

Здесь сошлось всё, что мы сегодня выяснили.

export fn malloc. Слово export кладёт имя в динамическую таблицу имён .dynsym с соглашением о вызовах C. Без него функция осталась бы внутренней, ld.so её не нашёл бы, и программа спокойно взяла бы malloc из libc. Это самая частая причина пустой трассы.

next и comptime. В C мы завели по статической переменной на каждую настоящую функцию. Здесь это делает компилятор. next принимает тип функции и имя параметрами времени компиляции, а значит, для каждой пары порождается своя копия функции со своей структурой Cache и своей переменной pointer внутри. Четыре перехвата, четыре кэша, ни одной строки повторяющегося кода. Строка паники тоже склеивается во время компиляции: оператор ++ над строками времени компиляции.

rtld_next. В заголовках glibc RTLD_NEXT это ((void *) -1l). Мы подключаем libc, но не её заголовки, поэтому собираем то же значение сами: минус единица как isize, те же биты как usize, из них указатель.

Флаг inside. Это защита от той самой рекурсии, но сделанная иначе, чем в trace.c. Там мы просто не звали ничего опасного. Здесь правило то же (буфер на стеке, std.os.linux.write это прямой системный вызов мимо libc), а флаг решает вторую задачу: прячет вызовы, которые делает не программа. Посмотри на puts: пока настоящий puts работает, флаг поднят, и его внутренний malloc под буфер stdout в трассу не попадает. В трассе остаётся то, что написал программист. А malloc(1024) в выводе выше пришёл из printf внутри libgreet.so: printf мы не перехватываем, флаг никто не поднял.

free: сначала строка, потом вызов. После free адрес уже ничей, и другой поток может получить его из malloc раньше, чем мы напечатаем строку. Трасса, в которой malloc вернул адрес до того, как его освободили, сведёт с ума кого угодно.

Чего здесь нет. Флаг один на процесс. В многопоточной программе поток А поднимет его, поток Б в это время позовёт malloc и останется без строки. Правильный флаг живёт в TLS, и тут новая ловушка: обычная переменная потока в разделяемой библиотеке при первом обращении выделяется через malloc. Нужна модель initial-exec, при которой место под переменную резервируется при загрузке. Комментарий в коде это честно фиксирует. И calloc в таблице нет намеренно, причину ты уже знаешь.

Подкоманда

//! `zt ltrace`: трасса библиотечных вызовов программы через `LD_PRELOAD`.
//!
//! Сама подкоманда почти ничего не делает: выставляет две переменные
//! окружения и заменяет себя программой. Всю работу выполняет библиотека
//! `libzt-ltrace.so` из `ltrace/preload.zig`, которую загрузчик поставит
//! в процессе первой.

const std = @import("std");

pub const hooks = @import("ltrace/hooks.zig");

pub const library_name = "libzt-ltrace.so";

/// Переменная, которой можно указать библиотеку явно.
pub const library_variable = "ZT_LTRACE_LIB";
/// Через неё фильтр `--only` доезжает до библиотеки внутри чужого процесса.
pub const filter_variable = "ZT_LTRACE_ONLY";

/// `zig build` кладёт программу в `zig-out/bin`, а библиотеку в `zig-out/lib`.
pub fn libraryPath(gpa: std.mem.Allocator, executable_directory: []const u8) ![]u8 {
    return std.fs.path.join(gpa, &.{ executable_directory, "..", "lib", library_name });
}

/// Готовит окружение для трассируемой программы.
pub fn prepareEnvironment(
    environment: *std.process.Environ.Map,
    library: []const u8,
    filter: ?[]const u8,
) !void {
    // ponytail: чужой LD_PRELOAD затираем. Понадобится совмещать: дописывай
    // свою библиотеку первой через двоеточие.
    try environment.put("LD_PRELOAD", library);
    if (filter) |list| try environment.put(filter_variable, list);
}

test "библиотека ищется рядом с программой" {
    const gpa = std.testing.allocator;
    const path = try libraryPath(gpa, "/opt/zt/bin");
    defer gpa.free(path);
    try std.testing.expectEqualStrings("/opt/zt/bin/../lib/libzt-ltrace.so", path);
}

test {
    _ = hooks;
}

Вся работа подкоманды это две переменные окружения. Фильтр --only уезжает в чужой процесс тем же путём, что и сама библиотека: через окружение. Другого канала у нас нет, библиотека оживёт уже после execve, в адресном пространстве, где от zt ничего не осталось.

В src/root.zig добавляется одна строка:

pub const ltrace = @import("ltrace.zig");

В src/main.zig добавляются строка в справку, ветка в разборе подкоманды и функция:

    \\  zt ltrace [--only=имя,имя] <программа> [аргументы...]
    \\Флаги ltrace (только Linux):
    \\  --only=a,b  трассировать только перечисленные функции
    \\
    } else if (std.mem.eql(u8, command, "ltrace")) {
        try cmdLtrace(init, gpa, out, rest);
fn cmdLtrace(
    init: std.process.Init,
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    args: []const []const u8,
) !void {
    // LD_PRELOAD это механизм загрузчика ld.so, на других системах его нет.
    if (builtin.os.tag != .linux) return fail(out, "zt ltrace: работает только на Linux\n");

    var filter: ?[]const u8 = null;
    var program = args;
    if (program.len > 0 and std.mem.startsWith(u8, program[0], "--only=")) {
        filter = program[0]["--only=".len..];
        program = program[1..];
        if (zt.ltrace.hooks.unknownName(filter.?)) |name| {
            try out.print("zt ltrace: функцию {s} перехватывать не умею\n", .{name});
            return error.BadUsage;
        }
    }
    if (program.len == 0) return fail(out, "zt ltrace: нужна программа для запуска\n");

    const library = init.environ_map.get(zt.ltrace.library_variable) orelse
        try zt.ltrace.libraryPath(gpa, try std.process.executableDirPathAlloc(init.io, gpa));
    std.Io.Dir.cwd().access(init.io, library, .{}) catch {
        try out.print("zt ltrace: нет библиотеки {s}, собери её через zig build\n", .{library});
        return error.BadUsage;
    };
    try zt.ltrace.prepareEnvironment(init.environ_map, library, filter);

    // Заменяем себя программой: её код возврата станет нашим, а её вывод
    // пойдёт в тот же терминал. При успехе replace не возвращается.
    try out.flush();
    const err = std.process.replace(init.io, .{ .argv = program, .environ_map = init.environ_map });
    try out.print("zt ltrace: не удалось запустить {s} ({t})\n", .{ program[0], err });
    return error.BadUsage;
}

std.process.replace это execve: процесс zt перестаёт существовать, на его месте с тем же номером запускается трассируемая программа. Никакого fork и ожидания, поэтому код возврата программы сам собой становится кодом возврата zt ltrace, а её вывод идёт в тот же терминал. Если replace вернулся, значит, запуск не удался, и возвращает он всегда ошибку. Как устроены fork и execve, разберём через два урока.

Перед replace стоит out.flush(). Буфер вывода живёт в памяти процесса, а execve эту память выбрасывает целиком: всё, что не сброшено, пропадёт без следа.

Сборка

В build.zig появляется вторая цель, разделяемая библиотека. Она идёт сразу после b.installArtifact(exe);:

    // Библиотека-перехватчик для `zt ltrace`. LD_PRELOAD есть только у
    // загрузчика Linux, поэтому на других системах её не собираем. Ей нужна
    // libc: настоящие malloc и free она достаёт через dlsym.
    if (target.result.os.tag == .linux) {
        const preload = b.addLibrary(.{
            .name = "zt-ltrace",
            .linkage = .dynamic,
            .root_module = b.createModule(.{
                .root_source_file = b.path("src/ltrace/preload.zig"),
                .target = target,
                .optimize = optimize,
                .link_libc = true,
            }),
        });
        b.installArtifact(preload);
    }

.linkage = .dynamic даёт .so, .link_libc = true это тот самый -lc: без него не будет ни dlsym, ни getenv. zig build кладёт программу в zig-out/bin, библиотеку в zig-out/lib, и именно эту раскладку знает libraryPath. С macOS библиотека собирается кросс-компиляцией: zig build -Dtarget=x86_64-linux-gnu.

Сквозному тесту нужно запустить настоящий zt на настоящей программе, а значит, знать два пути. Их передаёт модуль с константами, который build.zig порождает на лету:

    // Сквозной тест `zt ltrace` запускает настоящую программу, и ему нужны
    // пути к собранным `zt` и фикстуре. Передаём их модулем с константами.
    const paths = b.addOptions();
    paths.addOption([]const u8, "zt", b.getInstallPath(.bin, "zt"));
    paths.addOption([]const u8, "hello_dyn", b.pathFromRoot("fixtures/dyn/hello_dyn"));

В массив project_steps добавляется число 46, а внутри цикла по шагам тест шага 46 получает этот модуль и зависимость от установки:

            const run_tests = b.addRunArtifact(step_tests);
            if (number == 46) {
                step_tests.root_module.addOptions("paths", paths);
                // Программа и библиотека-перехватчик должны быть собраны до теста.
                run_tests.step.dependOn(b.getInstallStep());
            }
            test_step.dependOn(&run_tests.step);

Без dependOn(b.getInstallStep()) система сборки вправе запустить тест раньше, чем соберёт zt и библиотеку: порядок шагов определяют только зависимости.

Тесты шага

//! Шаг 46: `zt ltrace`, трасса библиотечных вызовов через `LD_PRELOAD`.
//!
//! Таблица перехватов и формат строки это чистый код, он проверяется везде.
//! Сам перехват живёт в загрузчике Linux, поэтому сквозной тест идёт только
//! на x86-64 Linux с glibc: под эту систему собрана фикстура `hello_dyn`.

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

const paths = @import("paths");
const zt = @import("zt");

const hooks = zt.ltrace.hooks;

test "в таблице есть malloc и free" {
    const malloc = hooks.find("malloc").?;
    try std.testing.expectEqualSlices(hooks.Value, &.{.size}, malloc.args);
    try std.testing.expectEqual(hooks.Value.pointer, malloc.result);

    const free = hooks.find("free").?;
    try std.testing.expectEqual(hooks.Value.none, free.result);
    try std.testing.expectEqual(@as(?hooks.Hook, null), hooks.find("fopen"));
}

test "строка трассы" {
    var buffer: [128]u8 = undefined;
    try std.testing.expectEqualStrings(
        "malloc(32) = 0x10042a0\n",
        hooks.formatCall(&buffer, hooks.find("malloc").?, &.{32}, 0x10042a0),
    );
    try std.testing.expectEqualStrings(
        "realloc(0x10042a0, 64) = 0x10042f0\n",
        hooks.formatCall(&buffer, hooks.find("realloc").?, &.{ 0x10042a0, 64 }, 0x10042f0),
    );
    // malloc не нашёл памяти: результат NULL, а не 0x0.
    try std.testing.expectEqualStrings(
        "malloc(1024) = NULL\n",
        hooks.formatCall(&buffer, hooks.find("malloc").?, &.{1024}, 0),
    );
}

test "форматирование ничего не выделяет и переживает тесный буфер" {
    var tiny: [10]u8 = undefined;
    try std.testing.expectEqualStrings("malloc(32)", hooks.formatCall(&tiny, hooks.find("malloc").?, &.{32}, 0x1000));
}

test "фильтр --only" {
    try std.testing.expect(hooks.enabled(null, "malloc"));
    try std.testing.expect(hooks.enabled("malloc,free", "malloc"));
    try std.testing.expect(!hooks.enabled("malloc,free", "puts"));
    try std.testing.expectEqualStrings("fopen", hooks.unknownName("malloc,fopen").?);
}

test "окружение трассируемой программы" {
    const gpa = std.testing.allocator;
    var environment: std.process.Environ.Map = .init(gpa);
    defer environment.deinit();
    try environment.put("LD_PRELOAD", "/чужая/библиотека.so");

    try zt.ltrace.prepareEnvironment(&environment, "/opt/zt/lib/libzt-ltrace.so", "malloc,free");
    try std.testing.expectEqualStrings("/opt/zt/lib/libzt-ltrace.so", environment.get("LD_PRELOAD").?);
    try std.testing.expectEqualStrings("malloc,free", environment.get(zt.ltrace.filter_variable).?);
}

/// Сквозной тест нужен загрузчик glibc для x86-64: без него фикстура не запустится.
fn canTrace() bool {
    if (builtin.os.tag != .linux or builtin.cpu.arch != .x86_64) return false;
    std.Io.Dir.cwd().access(std.testing.io, "/lib64/ld-linux-x86-64.so.2", .{}) catch return false;
    return true;
}

test "zt ltrace видит malloc и free программы" {
    if (!canTrace()) return error.SkipZigTest;
    const gpa = std.testing.allocator;

    const result = try std.process.run(gpa, std.testing.io, .{
        .argv = &.{ paths.zt, "ltrace", "--only=malloc,free", paths.hello_dyn },
    });
    defer gpa.free(result.stdout);
    defer gpa.free(result.stderr);

    // Программа отработала как обычно: тот же вывод, тот же код возврата.
    try std.testing.expectEqual(std.process.Child.Term{ .exited = 0 }, result.term);
    try std.testing.expectEqualStrings("hello, zt\nbye\n", result.stdout);

    // Трасса идёт в stderr. Адреса от запуска к запуску разные, поэтому
    // сверяем форму: malloc(32) вернул адрес, и этот же адрес ушёл в free.
    var lines = std.mem.splitScalar(u8, result.stderr, '\n');
    const first = lines.next().?;
    const prefix = "malloc(32) = ";
    try std.testing.expect(std.mem.startsWith(u8, first, prefix));
    const address = first[prefix.len..];
    try std.testing.expect(std.mem.startsWith(u8, address, "0x"));

    const expected_free = try std.fmt.allocPrint(gpa, "free({s})", .{address});
    defer gpa.free(expected_free);
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, expected_free) != null);
    // puts отфильтрован флагом --only.
    try std.testing.expect(std.mem.indexOf(u8, result.stderr, "puts(") == null);
}

Пять тестов из шести идут на любой машине. Шестой честно пропускает себя везде, кроме x86-64 Linux с glibc: error.SkipZigTest это штатный способ сказать, что тест здесь неприменим, в сводке он считается отдельно от упавших. Адреса кучи от запуска к запуску разные, поэтому тест сверяет форму: malloc(32) вернул что-то с 0x, и ровно это же ушло в free.

$ zig build test -Dstep=46 --summary all
Build Summary: 9/9 steps succeeded; 6/6 tests passed
test success
+- run test 6 pass (6 total) 49ms MaxRSS:10M
   +- compile test Debug native success 757ms MaxRSS:173M
   |  +- options success
   +- install success
      +- install zt success
      |  +- compile exe zt Debug native success 831ms MaxRSS:183M
      +- install zt-ltrace success
         +- compile lib zt-ltrace Debug native success 3s MaxRSS:325M

И проверка руками, на программе из начала урока и с ошибкой в фильтре:

$ zt ltrace ./prog
malloc(32) = 0x5555555592a0
malloc(1024) = 0x5555555592d0
hello, zt
free(0x5555555592a0)
$ zt ltrace --only=malloc /bin/ls wrap 2>&1 | tail -4
malloc(32816) = 0x55555557fc50
malloc(9) = 0x55555557fbc0
malloc(4096) = 0x55555557fc50
malloc.h
$ zt ltrace --only=mallok ./prog
zt ltrace: функцию mallok перехватывать не умею
$ zt ltrace ./prog_static
hello, zt

Последняя команда это молчаливый отказ на статическом бинарнике, который мы уже видели. Хорошее упражнение: научить zt ltrace замечать его заранее. У тебя есть zt ldd, он уже умеет отличать программу без PT_INTERP.

Экскурсия по binutils

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

утилитачто делаетгде встречаласьв zt
arсобирает и разбирает статические архивы .aурок 43zt nm и zt ld читают архивы
nmтаблица имён объектника, архива, программы; -D для динамическойуроки 42 до 46zt nm
readelfвсё про ELF: заголовок, секции, сегменты, имена, перемещения, -d для .dynamicуроки 42 до 45zt readelf -h -S -s -r
objdumpдизассемблер (-d), содержимое секций (-s), перемещения (-r)с урока 9zt disasm, zt plt
ldкомпоновщикуроки 43 и 44zt ld
lddкакие разделяемые библиотеки нужны программе и где они нашлисьурок 45zt ldd
ltraceтрасса библиотечных вызововсегодняzt ltrace
hexdump, xxdбайты как естьурок 6zt hexdump
sizeразмеры .text, .data, .bss одной строкойсегоднянет
stringsпечатные строки из любого файласегоднянет
stripвыбрасывает таблицу имён и отладочные секциисегоднянет
objcopyпереписывает объектник: переименовать имя, вырезать секцию, сменить форматсегоднянет
addr2lineадрес в файл и строку по DWARFпригодится, когда дойдём до профилировщиканет
c++filtрасшифровка имён C++нетнет

hexdump и ldd формально в binutils не входят (первый из util-linux, второй это сценарий из поставки glibc), но живут в той же коробке с инструментами.

Несколько команд на наших сегодняшних файлах. ar и size:

$ ar rcs libmy.a mymalloc.o wrapmalloc.o
$ ar t libmy.a
mymalloc.o
wrapmalloc.o
$ size prog prog_static libtrace.so
   text	   data	    bss	    dec	    hex	filename
   1514	    600	      8	   2122	    84a	prog
 650731	  23440	  22632	 696803	  aa1e3	prog_static
   1595	    552	     24	   2171	    87b	libtrace.so

size отвечает на вопрос, сколько программа займёт в памяти, а не на диске. У prog полтора килобайта кода, у prog_static шестьсот пятьдесят: это та часть glibc, которую потянул за собой один printf. Сколько всего объектников в статической glibc, тоже видно одной командой:

$ ar t /usr/lib/x86_64-linux-gnu/libc.a | wc -l
2070
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep -c ' [TW] '
2764

2070 участников архива, и из них компоновщик по алгоритму с множествами E, U и D выбрал несколько десятков. А разделяемая libc.so.6 отдаёт наружу 2764 функции, и каждую из них можно подменить через LD_PRELOAD.

strings хорош для первого знакомства с чужим бинарником. Он ничего не знает про ELF, просто ищет цепочки печатных байтов:

$ strings -n 8 prog | head -12
/lib64/ld-linux-x86-64.so.2
__libc_start_main
__cxa_finalize
libc.so.6
GLIBC_2.34
GLIBC_2.2.5
_ITM_deregisterTMCloneTable
__gmon_start__
_ITM_registerTMCloneTable
hello, %s
GCC: (Debian 12.2.0-14+deb12u1) 12.2.0
__abi_tag

За десять строк видно: загрузчик (это содержимое PT_INTERP), нужная библиотека, версии имён glibc, строка формата из main и компилятор, которым программу собрали (секция .comment).

Теперь strip, и тут есть тонкость, важная для сегодняшней темы:

$ cp prog prog_stripped && strip prog_stripped
$ ls -l prog prog_stripped
16064 prog
14496 prog_stripped
$ nm prog_stripped
nm: prog_stripped: no symbols
$ nm -D prog_stripped
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable
                 w __cxa_finalize@GLIBC_2.2.5
                 w __gmon_start__
                 U __libc_start_main@GLIBC_2.34
                 U free@GLIBC_2.2.5
                 U malloc@GLIBC_2.2.5
                 U printf@GLIBC_2.2.5

strip выбросил .symtab и .strtab, имя main пропало. Но .dynsym на месте, и malloc с free в ней по-прежнему числятся неопределёнными. Иначе и быть не может: эти имена нужны ld.so при каждом запуске. Поэтому очищенный от имён бинарник трассируется через LD_PRELOAD ничуть не хуже. Таблиц имён в ELF две, и вторую убрать нельзя, пока программа скомпонована динамически.

И последнее: куда на самом деле ведёт вызов malloc в prog. Это картина из прошлого урока, и теперь понятно, за какое место в ней цепляется третий уровень:

$ objdump -d --no-show-raw-insn prog | grep -B2 -A1 'call.*malloc'
    115d:	sub    $0x10,%rsp
    1161:	mov    $0x20,%edi
    1166:	call   1050 <malloc@plt>
    116b:	mov    %rax,-0x8(%rbp)
$ objdump -d --no-show-raw-insn prog | grep -A3 '<malloc@plt>:'
0000000000001050 <malloc@plt>:
    1050:	jmp    *0x2fba(%rip)        # 4010 <malloc@GLIBC_2.2.5>
    1056:	push   $0x2
    105b:	jmp    1020 <_init+0x20>
$ zt plt prog | grep 'malloc\|free'
0000000000004000  0000000000001036  0000000000001030  R_X86_64_JUMP_SLOT  free
0000000000004010  0000000000001056  0000000000001050  R_X86_64_JUMP_SLOT  malloc

main зовёт заглушку, заглушка прыгает по адресу из ячейки GOT 0x4010, а что туда записать, решает ld.so. LD_PRELOAD не трогает ни байта программы: он меняет только то, какой адрес загрузчик положит в эту ячейку.

Zig на практике: что выбрать при компоновке

Вторая половина урока про решения, которые ты принимаешь каждый раз, когда собираешь программу для кого-то, кроме себя. Будем мерить на двух программах. Первая на Zig, без единого обращения к libc:

const std = @import("std");

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    const out = &w.interface;
    try out.writeAll("hello, zt\n");
    try out.flush();
}

Вторая на C:

#include <stdio.h>

int main(void) {
    puts("hello, zt");
    return 0;
}

Все размеры ниже в байтах, цель x86_64-linux, Zig 0.16.0, gcc 12.2.0, glibc 2.36.

Статика или динамика, musl или glibc

Начнём с C, потому что там выбор нагляднее.

командакомпоновкаразмерпосле -s
gcc -O2 hello.cдинамика, glibc15 96014 480
gcc -O2 -static hello.cстатика, glibc762 424682 696
zig cc -O2 hello.cдинамика, glibc4 9843 600
zig cc -O2 -target x86_64-linux-musl hello.cстатика, musl875 0965 192
zig cc -O2 -target x86_64-linux-gnu -static hello.cстатика, glibcошибка

Строка за строкой.

Динамический hello от gcc весит 16 КБ, хотя кода в нём сотня байт: GNU ld выравнивает сегменты по границе страницы прямо в файле и добавляет стартовые объектники. zig cc компонует через LLD, тот пакует плотнее, выходит 5 КБ. Оба файла без libc.so.6 бесполезны.

Статика на glibc это 680 КБ даже после strip. Причину мы видели в size: glibc не рассчитывали на статическую компоновку, её puts тянет за собой локали, iconv, потоки и половину stdio. Хуже того, часть glibc статически не работает вовсе: getaddrinfo, getpwnam и всё, что ходит через NSS, даже в статическом бинарнике пытается загрузить разделяемые модули через dlopen, и компоновщик честно об этом предупреждает.

Статика на musl после -s занимает 5 192 байта. В сто тридцать раз меньше, чем на glibc, и меньше, чем динамический бинарник от gcc. Этот файл можно скопировать в пустой контейнер FROM scratch, на Alpine, на Debian десятилетней давности, и он запустится: ему нужно только ядро Linux. Без -s файл весит 875 КБ, потому что Zig собирает musl из исходников с отладочной информацией, и она вся едет в бинарник. Это первый урок про размеры: смотри, что именно ты меряешь. 870 КБ разницы это DWARF, в память он не загружается.

Последняя строка таблицы: zig cc отказывается компоновать glibc статически.

$ zig cc -O2 -target x86_64-linux-gnu -static hello.c
error: libc of the specified target requires dynamic linking

В поставке Zig нет статической glibc. Для неё Zig везёт только заголовки и списки имён с версиями, из которых на лету строит пустышку libc.so.6: для динамической компоновки компоновщику нужны только имена, мы видели это в прошлом уроке. А musl везёт исходниками и собирает под цель при первом обращении.

Что выбирать:

  • статика на musl, когда нужен один файл, который запускается везде: утилиты командной строки, контейнеры, всё, что раздаёшь людям. Плата: аллокатор в musl проще и под многопоточной нагрузкой заметно медленнее, чем в glibc; поиск имён в DNS устроен иначе; dlopen из статического бинарника не работает, а значит, не работают и подключаемые модули. И LD_PRELOAD, как мы сегодня выяснили, тоже.
  • динамика на glibc, когда программа живёт внутри дистрибутива: ей нужны системные библиотеки (OpenSSL, драйверы GPU, PAM), подключаемые модули, NSS. Плата: бинарник привязан к версии glibc не старше той, с которой его собирали.

Для привязки к версии у zig cc есть приём, которого нет у gcc: версию glibc можно указать прямо в цели.

$ gcc -O2 -o new hello.c && readelf -V new | grep -o 'GLIBC_[0-9.]*' | sort -u
GLIBC_2.2.5
GLIBC_2.34
$ zig cc -O2 -target x86_64-linux-gnu.2.17 -o old hello.c && readelf -V old | grep -o 'GLIBC_[0-9.]*' | sort -u
GLIBC_2.2.5

Программа от gcc требует GLIBC_2.34 (в этой версии __libc_start_main получил новую версию имени) и на CentOS 7 с его glibc 2.17 не запустится: version GLIBC_2.34 not found. Программа от zig cc с целью gnu.2.17 скомпонована против списка имён glibc 2.17 и пойдёт на любой системе новее. Обычно ради этого держат сборочную машину со старым дистрибутивом.

Режимы сборки и -fstrip

Теперь программа на Zig. По умолчанию она статическая и без libc вообще: стандартная библиотека Zig на Linux сама делает системные вызовы.

командаразмер
zig build-exe hello.zig (Debug)10 379 533
-O ReleaseSafe3 670 824
-O ReleaseFast3 766 168
-O ReleaseFast -fstrip237 576
-O ReleaseSmall144 600
-O ReleaseSmall -fstrip144 600
-O ReleaseSmall -fstrip -fsingle-threaded127 984
-O ReleaseSmall -fstrip -lc (динамика, glibc)148 152
-O ReleaseSmall -fstrip -lc -target x86_64-linux-musl (статика, musl)205 608

Десять мегабайт в Debug не должны пугать: почти всё это отладочная информация для std, благодаря ей паника печатает трассу стека с именами файлов и строками. Посмотри на пару ReleaseFast: 3,7 МБ против 237 КБ, разница целиком в -fstrip. Флаг делает две вещи. Компилятор перестаёт порождать DWARF, а компоновщик не кладёт в файл .symtab. И заодно из программы уходит код, который эти данные читал: разборщик DWARF для трасс стека сам занимает сотни килобайт, и без отладочной информации ему нечего делать. Поэтому -fstrip при сборке даёт меньший файл, чем strip после неё: утилита умеет выбросить секции, но не умеет выбросить ставший ненужным код.

ReleaseSmall включает -fstrip сам, поэтому две строки таблицы совпали до байта. -fsingle-threaded убирает ещё 16 КБ: атомарные операции и блокировки в std превращаются в обычные. Даже лучший результат, 128 КБ, в двадцать пять раз больше, чем hello.c на musl. Это цена std.Io: интерфейс ввода и вывода с таблицей функций, через которую компоновщик не может понять, какие реализации на самом деле не нужны. Для утилиты это не важно. Для прошивки на 64 КБ флеша пишут без std.Io, прямо на системных вызовах.

Две последние строки показывают, что -lc программе, которой libc не нужна, только вредит: динамический вариант выигрывает три килобайта и теряет независимость, а статический на musl становится больше на 60 КБ, потому что теперь в нём две стартовые обвязки и два набора обёрток над системными вызовами.

--gc-sections: мёртвый код

--gc-sections это ключ компоновщика: выбросить входные секции, на которые никто не ссылается. Тонкость в слове секции. Компоновщик не умеет резать секцию на части, он берёт её целиком или не берёт вовсе. Если компилятор сложил все функции файла в одну .text, выбрасывать нечего.

#include <stdio.h>

static const char table[64 * 1024] = {1};

int used(int x) { return x + 1; }

/* Никто не зовёт: кандидат на выброс. */
int unused(int x) { return table[x & 0xffff]; }

int main(void) {
    printf("%d\n", used(41));
    return 0;
}
$ gcc -O1 -Wl,--gc-sections -o dead dead.c && ls -l dead
81472 dead
$ gcc -O1 -ffunction-sections -fdata-sections -Wl,--gc-sections -o dead dead.c && ls -l dead
15856 dead
$ gcc -O1 -ffunction-sections -fdata-sections -Wl,--gc-sections -Wl,--print-gc-sections -o dead dead.c
/usr/bin/ld: removing unused section '.text.used' in file '/tmp/ccfOhjf6.o'
/usr/bin/ld: removing unused section '.text.unused' in file '/tmp/ccfOhjf6.o'
/usr/bin/ld: removing unused section '.rodata.table' in file '/tmp/ccfOhjf6.o'

Один --gc-sections почти ничего не дал: used, unused и main лежат в одной .text, таблица в общей .rodata, всё держится друг за друга. С -ffunction-sections -fdata-sections компилятор кладёт каждую функцию в свою секцию .text.имя и каждую переменную в свою .rodata.имя. Теперь у графа ссылок есть мелкие вершины, и 64 КБ таблицы вместе с unused уходят. Первая строка вывода, .text.used, тоже закономерна: при -O1 компилятор подставил used(41) прямо в main как константу 42, и отдельная копия функции стала не нужна.

Именно поэтому hello.c на musl весит 5 КБ: musl собрана по функции на секцию. Без сборки мусора тот же файл выходит таким:

$ zig cc -O2 -s -target x86_64-linux-musl -Wl,--no-gc-sections -o hello hello.c && ls -l hello
175744 hello

Zig порождает по секции на функцию всегда, а сборку мусора включает в режимах Release* и выключает в Debug. Проверим на программе, где мёртвый код выдан насильно, через export:

const std = @import("std");

const table = [_]u8{1} ** (64 * 1024);

/// export заставляет компилятор выдать функцию, даже если её никто не зовёт.
export fn unused(x: usize) u8 {
    return table[x & 0xffff];
}

pub fn main(init: std.process.Init) !void {
    var buf: [64]u8 = undefined;
    var w = std.Io.File.stdout().writer(init.io, &buf);
    try w.interface.writeAll("42\n");
    try w.interface.flush();
}
$ zig build-exe dead.zig -O ReleaseSmall --no-gc-sections && ls -l dead
337272 dead
$ zig build-exe dead.zig -O ReleaseSmall --gc-sections && ls -l dead
210200 dead
$ zig build-exe dead.zig -O ReleaseSmall && ls -l dead
210200 dead

Обычному коду на Zig этот ключ почти не нужен по другой причине. Компилятор ленив: функция, которую никто не вызывает, не анализируется и не попадает даже в объектник. --gc-sections имеет значение там, где эта лень не действует: для export fn, для объектников C и для статических библиотек, которые ты компонуешь со своей программой.

Кросс-компоновка

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

$ for t in x86_64-linux aarch64-linux riscv64-linux x86_64-windows aarch64-macos wasm32-wasi; do
>   zig build-exe hello.zig -O ReleaseSmall -target $t -femit-bin=hello-$t
> done
$ ls -l
141648 hello-aarch64-linux
165152 hello-aarch64-macos
130064 hello-riscv64-linux
 46595 hello-wasm32-wasi
145008 hello-x86_64-linux
433152 hello-x86_64-windows
$ file *
hello-aarch64-linux:  ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, stripped
hello-aarch64-macos:  Mach-O 64-bit arm64 executable, flags:<NOUNDEFS|DYLDLINK|TWOLEVEL|NO_REEXPORTED_DYLIBS|PIE|HAS_TLV_DESCRIPTORS>
hello-riscv64-linux:  ELF 64-bit LSB executable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), statically linked, stripped
hello-wasm32-wasi:    WebAssembly (wasm) binary module version 0x1 (MVP)
hello-x86_64-linux:   ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, stripped
hello-x86_64-windows: PE32+ executable (console) x86-64, for MS Windows, 7 sections

Шесть платформ, три формата объектных файлов (ELF, Mach-O, PE) плюс WebAssembly, одна машина, ноль установленных тулчейнов. Три линуксовых бинарника статические и от системы не зависят. Бинарник для macOS динамический (флаг DYLDLINK): на macOS статической компоновки с системной библиотекой нет, подробности во врезке ниже. Для него Zig, как и для glibc, везёт описание имён libSystem, а не саму библиотеку.

Цель записывается тройкой процессор-система-abi, и третья часть это как раз выбор libc: x86_64-linux-gnu, x86_64-linux-musl, x86_64-linux-gnu.2.17. Если её опустить, для Linux подразумевается musl, когда libc нужна, и отсутствие libc, когда не нужна.

zig cc для чужого C-проекта

zig cc это clang плюс всё перечисленное: свои libc, свой компоновщик, кросс-цели. Для проекта на C с обычным Makefile он подставляется через переменные CC и AR. Возьмём маленький проект: калькулятор в обратной польской записи, стек вынесен в статическую библиотеку.

#ifndef STACK_H
#define STACK_H

void push(long value);
long pop(void);

#endif
#include "stack.h"

static long items[64];
static int top;

void push(long value) { items[top++] = value; }
long pop(void) { return items[--top]; }
#include <stdio.h>
#include <stdlib.h>

#include "stack.h"

/* Обратная польская запись: ./rpn 2 3 + 4 x */
int main(int argc, char **argv) {
    for (int i = 1; i < argc; i++) {
        char op = argv[i][0];
        if (op == '+' || op == 'x') {
            long b = pop(), a = pop();
            push(op == '+' ? a + b : a * b);
        } else {
            push(strtol(argv[i], NULL, 10));
        }
    }
    printf("%ld\n", pop());
    return 0;
}
CC ?= cc
AR ?= ar
CFLAGS ?= -O2 -Wall

rpn: main.o libstack.a
	$(CC) $(LDFLAGS) -o $@ main.o -L. -lstack

libstack.a: stack.o
	$(AR) rcs $@ $^

clean:
	rm -f rpn *.o *.a

Makefile ничего не знает про Zig: он зовёт $(CC) и $(AR), как положено.

$ make
cc -O2 -Wall   -c -o main.o main.c
cc -O2 -Wall   -c -o stack.o stack.c
ar rcs libstack.a stack.o
cc  -o rpn main.o -L. -lstack
$ ./rpn 2 3 + 4 x
20
$ make clean && make CC="zig cc -target aarch64-linux-musl" AR="zig ar" LDFLAGS="-s -O2"
zig cc -target aarch64-linux-musl -O2 -Wall   -c -o main.o main.c
zig cc -target aarch64-linux-musl -O2 -Wall   -c -o stack.o stack.c
zig ar rcs libstack.a stack.o
zig cc -target aarch64-linux-musl -s -O2 -o rpn main.o -L. -lstack
$ file rpn
rpn: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), statically linked, stripped
$ ls -l rpn
26488 rpn

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

сборкаразмер rpn
make LDFLAGS=-s (gcc, динамика, glibc)14 488
make LDFLAGS="-s -static" (gcc, статика, glibc)682 696
make CC="zig cc" AR="zig ar" LDFLAGS="-s -O2" (динамика, glibc)4 184
make CC="zig cc -target x86_64-linux-musl" AR="zig ar" LDFLAGS="-s -O2" (статика, musl)19 400

rpn на musl вырос с пяти килобайт до девятнадцати против hello.c: printf с его разбором формата и печатью чисел с плавающей точкой весит куда больше, чем puts.

И ловушка, в которую я попал, пока снимал эти числа. Обрати внимание на -O2 в LDFLAGS. Без него получается вот что:

$ make clean && make CC="zig cc -target x86_64-linux-musl" AR="zig ar" LDFLAGS=-s
...
zig cc -target x86_64-linux-musl -s -o rpn main.o -L. -lstack
$ ls -l rpn
257144 rpn

В тринадцать раз больше при тех же объектниках. Makefile передаёт CFLAGS только на шаге компиляции, а шаг компоновки получает zig cc без -O. Для Zig это режим Debug, а в Debug он не включает --gc-sections, и в файл едет вся та часть musl, до которой дотянулись ссылки, без чистки. У gcc такой зависимости нет, поэтому в Makefile для него её никто не предусмотрел. Лечится любым из двух способов: -O2 в LDFLAGS или явный -Wl,--gc-sections, результат одинаковый, 19 400 байт. Правило общее: когда подставляешь zig cc в чужую сборку, проверь, что ключи оптимизации доезжают до команды компоновки.

Вторая частая неожиданность это error: unsupported linker arg, которую мы сегодня уже видели на --wrap. zig cc разбирает ключи -Wl, сам и не знает редких. Обычно ключ можно убрать или заменить, как мы заменили --wrap на objcopy.

-lc: когда нужен и когда без него

Ключ -lc (в build.zig это .link_libc = true) говорит: компонуй со стандартной библиотекой C цели. На Linux программа на Zig обходится без неё, и мы весь раздел так и делали. Что меняет ключ:

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

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;

    // С libc берём её malloc, без неё страницы напрямую у ядра.
    const gpa = if (builtin.link_libc) std.heap.c_allocator else std.heap.page_allocator;
    const text = try std.fmt.allocPrint(gpa, "link_libc = {}", .{builtin.link_libc});
    defer gpa.free(text);

    try out.print("{s}\n", .{text});
    try out.flush();
}
$ zig build-exe anyalloc.zig -femit-bin=any_nolibc && ./any_nolibc
link_libc = false
$ ldd any_nolibc
	not a dynamic executable
$ zig build-exe anyalloc.zig -lc -femit-bin=any_libc && ./any_libc
link_libc = true
$ ldd any_libc
	libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007fffff6e6000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fffff504000)
	/lib64/ld-linux-x86-64.so.2 (0x00007ffffffcb000)

Один исходник, две разные программы. Константа builtin.link_libc известна во время компиляции, и std сама ветвится по ней во многих местах: с libc она зовёт write из libc, без неё делает системный вызов напрямую. Мы этим воспользовались, чтобы выбрать аллокатор: std.heap.c_allocator это обёртка над malloc, без libc его нет.

А теперь соединим две половины урока. Натравим zt ltrace на обе программы:

$ zt ltrace ./any_nolibc
link_libc = false
$ zt ltrace ./any_libc
malloc(16) = 0x12a12a0
realloc(0x12a12a0, 159) = 0x12a12a0
link_libc = true
free(0x12a12a0)

Первая программа для LD_PRELOAD невидима: она статическая, malloc в ней нет вообще, страницы она берёт у ядра через mmap. Вторая ходит в кучу glibc, и трасса показывает ту же механику allocPrint, что и в начале урока: стартовый буфер в длину строки формата (16 символов), рост через realloc, освобождение. Если хочешь следить за кучей программы на Zig, которая собрана без libc, третий уровень не поможет. Поможет первый: аллокатор-обёртка.

Если обратиться к libc без ключа, компилятор объяснит, чего не хватает:

const std = @import("std");

pub fn main() void {
    const block = std.c.malloc(32);
    std.c.free(block);
}
$ zig build-exe needc.zig
/opt/zig/lib/std/c.zig:10789:12: error: dependency on libc must be explicitly specified in the build command
pub extern "c" fn malloc(usize) ?*anyopaque;
           ^~~
referenced by:
    main: needc.zig:4:24

Итого. -lc нужен:

  • когда зовёшь функции C: свои через extern "c", чужие библиотеки, std.c.*, std.heap.c_allocator;
  • когда компонуешь объектники или библиотеки на C, которые сами зовут libc (zig cc добавляет её без просьб, zig build-exe с файлами .c требует ключ);
  • когда пишешь разделяемую библиотеку под LD_PRELOAD: ей нужен dlsym;
  • когда нужны вещи, которые живут только в libc: NSS и настройки поиска имён системы, локали, dlopen подключаемых модулей;
  • всегда на macOS, на Windows и на BSD: там системные вызовы не считаются стабильным интерфейсом, стабильна только системная библиотека, и Zig компонует с ней, не спрашивая.

-lc не нужен, когда программа на чистом Zig под Linux: выйдет статический бинарник без зависимостей, с предсказуемым поведением и без сюрпризов от версии glibc. Именно так собран твой zt.

На macOS. Первый уровень работает как есть: -include понимает любой clang, а аллокатор-обёртка на Zig от системы не зависит. Второго уровня нет: у системного компоновщика ld от Apple ключа --wrap не существует (есть -alias, но он решает другую задачу), а objcopy в поставку не входит; Mach-O объектник можно переписать через llvm-objcopy --redefine-sym из Homebrew, помня, что имена C в Mach-O начинаются с подчёркивания. Третий уровень есть, но устроен иначе. Вместо LD_PRELOAD переменная DYLD_INSERT_LIBRARIES, а простого совпадения имён мало: в Mach-O действует двухуровневое пространство имён, каждая ссылка помнит, из какой библиотеки её брать, и malloc из вставленной библиотеки сам по себе ничего не перекрывает. Вместо этого библиотека кладёт в секцию __DATA,__interpose пары адресов (чем заменить, что заменить), и dyld переписывает привязки во всех остальных образах. dlsym(RTLD_NEXT, ...) при этом не нужен: внутри самой вставленной библиотеки имя malloc остаётся настоящим. На macOS 26 такая библиотека показывает три десятка вызовов malloc для нашего prog, большая часть приходит из инициализации libSystem. Системные программы так не возьмёшь: для бинарников под защитой SIP и для всего с hardened runtime dyld вычищает переменные DYLD_* из окружения, и DYLD_INSERT_LIBRARIES=./libtrace.dylib /bin/ls отработает без единой строки трассы. Статической компоновки на macOS нет вовсе: cc -static падает с ld: library 'crt0.o' not found, потому что Apple не поставляет статическую libSystem, а otool -L (здешний ldd) даже для hello.zig без -lc покажет /usr/lib/libSystem.B.dylib. Всё про ELF и zt ltrace гоняй в контейнере: docker run --platform linux/amd64 с копией исходников внутри. Библиотеку и zt можно собрать с хоста кросс-компиляцией (zig build -Dtarget=x86_64-linux-gnu), а запустить в контейнере. Одна странность эмулятора: статический бинарник Zig в режиме ReleaseSmall под Rosetta не стартует (rosetta error: bss_size overflow), хотя Debug, ReleaseSafe и ReleaseFast работают, а на настоящем Linux запускаются все четыре. Если упёрся, собери под aarch64-linux и запусти в родном для твоего процессора контейнере.

Упражнения

Итоги

  • Вызов привязан к функции по имени, и привязку можно перехватить в трёх местах: при компиляции (имя меняется в тексте), при компоновке (меняется правило разрешения имени) и при загрузке (меняется порядок поиска определения).
  • Уровень компиляции: #define malloc(size) mymalloc(size) в заголовке, подложенном через -include или -I.. Нужны исходники, в объектнике имени malloc нет совсем, вызовы через указатель и из готовых библиотек не видны. В Zig это штатная возможность: аллокатор передаётся параметром, обёртка над std.mem.Allocator это четыре функции.
  • Уровень компоновки: --wrap=f разрешает f в __wrap_f, а __real_f в настоящую f. Нужны объектники. zig cc 0.16 ключ не принимает, те же два переименования делает objcopy --redefine-sym. Вызовы внутри libc и внутри одного объектника не видны.
  • Уровень загрузки: LD_PRELOAD ставит библиотеку в начало порядка поиска, dlsym(RTLD_NEXT, "f") находит следующую f. Нужен только бинарник. Видны вызовы из всего процесса, включая libc. Не работает для статических программ и в режиме безопасного исполнения (setuid).
  • Внутри перехватчика malloc нельзя звать ничего, что зовёт malloc. printf заводит буфер stdout через malloc, обёртка с printf уходит в бесконечную рекурсию и падает с SIGSEGV. Строку собираем на стеке, отдаём через write.
  • zt ltrace это LD_PRELOAD плюс execve. Чистая половина (таблица перехватов, формат строки, фильтр) проверяется тестами везде; библиотека с export fn собирается только под Linux и с -lc. comptime-параметры дают каждой функции свой кэш dlsym, флаг inside прячет вложенные вызовы.
  • strip убирает .symtab, но не .dynsym: очищенную от имён динамическую программу всё равно можно трассировать и подменять в ней функции.
  • Статика: hello.c на musl это 5 КБ, на glibc 680 КБ, и часть glibc (NSS) статически не работает. zig cc статическую glibc не компонует. Версию glibc для динамики можно зафиксировать в цели: -target x86_64-linux-gnu.2.17.
  • -fstrip убирает DWARF, .symtab и код, который их читал (3,7 МБ против 237 КБ для ReleaseFast); ReleaseSmall включает его сам. --gc-sections работает, только когда функции лежат по отдельным секциям; Zig включает его в Release* и выключает в Debug.
  • Кросс-компоновка в Zig это ключ -target: libc и стартовые объектники целей едут в поставке. zig cc и zig ar подставляются в Makefile через CC и AR; проверь, что -O доезжает до команды компоновки, иначе сборка мусора выключена.
  • -lc нужен для функций C, библиотек C, dlsym, NSS и на всех системах, кроме Linux. Программа на чистом Zig под Linux обходится без libc, получается статической, и LD_PRELOAD её не видит.

Дальше

Блок про компоновку закрыт. Ты прошёл путь от байтов объектника до работающего процесса: читаешь ELF своим readelf, собираешь программу своим ld, понимаешь, что делает ld.so до первой инструкции main, и умеешь вклиниться в любую из этих стадий. Дальше мы спускаемся на уровень ниже библиотечных вызовов. Сегодняшний перехватчик видит только то, что проходит через динамическую привязку, а прямой syscall для него невидим. В следующем уроке разберём, что происходит, когда процессор бросает обычный поток инструкций: исключения, прерывания, таблица обработчиков, переход в ядро и обратно. Там же zt получит подкоманду strace, которая ловит системные вызовы чужого процесса через ptrace, и для неё уже не важно, статическая программа или динамическая.

домашка

Домашка