Предположим, у нас есть библиотека на Си. А в этой библиотеке - какая-то структура, детали реализации которой мы хотим скрыть и поэтому удалили из публичных заголовочных файлов. Как правильно выделить память под экземпляр этой структуры? Данная статья пытается ответить на этот вопрос.
Структура проекта
Будем рассматривать такой игрушечный пример:
// foo.h
#pragma once
struct foo;
// foo-internal.h
#pragma once
#include "foo.h"
#include <stdint.h>
struct foo {
int a;
int64_t b;
char c[20];
};
// foo.c
#include "foo.h"
#include "foo-internal.h"
// main.c
#include "foo.h"
#include <stdio.h>
int main(void) {
struct foo a;
}
Который компилируется с помощью make:
OBJECTS := foo.o
main: main.o $(OBJECTS)
$(CC) -o main $^
%.o: %.c
$(CC) -c -std=c23 $< $^
clean:
rm -rf *.o *.s
Здесь считаем что foo.h - публичный хедер, который поставляется с бинарниками библиотеки, а foo-internal.h - наш внутренний, который с ними не поставляется и на который не распространяются гарантии стабильности.
main.c - пример использования библиотеки без внутренних хедеров. В представленном примере компиляция естественно выдаст ошибку:
$ make
cc -c main.c main.c
main.c: In function 'main':
main.c:6:14: error: storage size of 'a' isn't known
6 | struct foo a;
| ^
потому что компилятор не может разместить на стеке то, размер чего он не знает. Далее попытаемся это исправить.
Пути решения (неоптимальные)
malloc под капотом
Самый часто применяемый и самый неправильный способ - написать в библиотеке функцию foo_new, в которой будем выделять память с помощью malloc и прочего подобного.
#include "foo-internal.h"
struct foo *foo_new(void) {
return malloc(sizeof(struct foo));
}
Не надо так писать. malloc вызывает системный аллокатор, который работает недетерминированное время, может тормозить в многопоточном окружении, может вызвать OOM, и в целом это очень большой и сложный кусок кода с, возможно, кучей особенностей, которые вам придется учитывать. Оно вам не надо. Кроме того, каждому malloc должен обязательно соответствовать free. Это усложняет уже ваш код и служит бесконечным источником багов в нем. В Си даже ввели defer для того чтобы с этим бороться:
#include <stddefer.h>
int main(void) {
struct foo *a = foo_new();
defer free(a);
}
(на данный момент работает с Clang 22 и флагами -std=c23 -fdefer-ts).
А товарищ Andrew Kelley даже сделал новый язык программирования Zig, в котором преподносит «no hidden allocations» как одну из главных фич. И попробуйте сказать, что он не прав.
Явная передача аллокатора
Подход, который активно применяется в Zig и который нам никто не запрещает применять в Си, заключается в явной передаче аллокатора:
struct foo *foo_new(void * (malloc_fn)(size_t, size_t)) {
return malloc_fn(alignof(struct foo), sizeof(struct foo));
}
// использование
struct foo *bar = foo_new(aligned_alloc);
Обратите внимание, что здесь в колбек передается не только размер, но и выравнивание. Эта информация может понадобиться разработчику, если он например захочет написать какую-то свою функцию, которая вместо malloc будет откусывать куски памяти со стека.
Этот подход гораздо лучше предыдущего, потому что позволяет использовать стек вместо кучи и в целом легко применять разные аллокаторы для разных типов данных.
Однако это все равно не позволяет разработчику писать оптимальный код. Потому что размер и выравнивание становятся ему известны только во время выполнения программы, но не во время компиляции. Ему придется выделять на стеке буфер какого-нибудь максимально возможного размера с максимально возможным выравниванием, использовать глобальные и/или статические переменные, и тому подобное. Надо предоставить компилятору больше информации, чтобы этого избежать.
Путь решения (оптимальный)
Данный способ активно применяется в ядре Linux и заключается в том, что мы определяем в хедере библиотеки дефайны с правильными размером и выравниванием:
// foo-sizes.h
#pragma once
#define FOO_SIZE 40
#define FOO_ALIGN 8
а логику инициализации внутренних полей выносим в отдельную функцию void foo_init(struct foo* a), которая принимает на вход уже готовый буфер и сама не занимается работой с памятью.
После чего разработчик сможет легко создать правильный буфер на стеке или где угодно:
// main.c
#include "foo.h"
#include "foo-sizes.h"
int main(void) {
alignas(FOO_ALIGN) char foo_buf[FOO_SIZE];
struct foo *a = (struct foo *)foo_buf;
foo_init(a);
}
Как сгенерировать такой хедер? Есть несколько подходов.
Можно сделать вспомогательный исполняемый файл, который будет просто печатать то что нам надо
printf("size = %zu, align = %zu\n", sizeof(struct foo), alignof(struct foo));
Но это не вариант в случае кросс-компиляции: тащить в систему всякие qemu для запуска этого исполняемого файла под разными архитектурами как-то не хочется. А сделать один хедер на всех - просто ошибка, потому что для разных архитектур размеры и/или выравнивания могут отличаться.
Можно поступить хитрее: записать нужные значения в какие-нибудь символы, (кросс)скомпилировать объектный файл, после чего вместо запуска просто распарсить его с помощью утилит вроде nm и strings. Это уже гораздо лучше, так как qemu здесь не нужен. Входящий в состав CMake модуль CheckTypeSize примерно так и делает.
Однако в ядре Linux поступают еще лучше. Там делают специальную ассемблерную вставку
// getsizes.c
#include "foo.h"
#include "foo-internal.h"
#define DEFINE(sym, val) \
asm volatile("\n->" #sym " %0 " #val : : "i" (val))
void main(void) {
DEFINE(FOO_SIZE, sizeof(struct foo));
DEFINE(FOO_ALIGN, alignof(struct foo));
}
генерируют не объектный, а просто ассемблерный файл:
$ gcc -S getsizes.c -o getsizes.s
после чего эту заранее определенную ассемблерную вставку можно просто загрепать:
$ cat getsizes.s | fgrep -- '->'
->FOO_SIZE $40 sizeof(struct foo)
->FOO_ALIGN $8 alignof(struct foo)
дальнейшее тривиально. Я правда не стал писать свой скрипт, а выдрал и причесал то что уже есть в исходниках ядра:
#!/bin/sh -e
# parse-asm.sh
INPUT=$1
OUTPUT=$2
exec > $OUTPUT
echo "#pragma once"
echo "/*"
echo " * DO NOT MODIFY."
echo " *"
echo " * This file was automatically generated."
echo " */"
echo ""
sed -n \
-e 's/^[[:space:]]*\.ascii[[:space:]]*"\(.*\)".*/\1/p' \
-e '/^->/{
s/->#\(.*\)/\/* \1 \*\//;
s/^->\([^ ]*\) [\$$#]*\([^ ]*\) \(.*\)/#define \1 \2/;
s/->//;
p;
}' "$INPUT"
echo ""
Использование:
$ ./parse-asm.sh ./getsizes.s ./foo-sizes.h
Выглядит несколько менее читаемо, чем могло бы быть, но судя по показаниям git blame, этот код не менялся в ядре последние 8 лет. Думаю, как-нибудь можно пережить.
Итоговый Makefile выглядит как-то так:
main: main.o foo.o
$(CC) -o main $^
main.o: foo-sizes.h main.c
$(CC) -c -fanalyzer -std=c23 -o main.o main.c
foo-sizes.h: getsizes.s
./parse-asm.sh ./getsizes.s ./foo-sizes.h
getsizes.s: getsizes.c
$(CC) -S -o $@ $^
%.o: %.c
$(CC) -c -fanalyzer -std=c23 -o $@ $^
clean:
rm -rf *.o *.s
Выводы
Мы научились правильно выделять память для структур со скрытыми деталями реализации. Это позволяет писать на Си код без аллокаций памяти, избавляясь от неизбежно сопутствующих им багов и тормозов при работе. Рассмотренный подход широко применяется на практике в разработке ядра Linux, и доказал там свою стабильность, работоспособность и совместимость с самым широким спектром архитектур. Рекомендуется к внедрению в ваших проектах, если таковые имеются.





