LINUX.ORG.RU

Выделяй память как Линус: инструкция

 , ,


4

1

Предположим, у нас есть библиотека на Си. А в этой библиотеке - какая-то структура, детали реализации которой мы хотим скрыть и поэтому удалили из публичных заголовочных файлов. Как правильно выделить память под экземпляр этой структуры? Данная статья пытается ответить на этот вопрос.

Структура проекта

Будем рассматривать такой игрушечный пример:

// 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, и доказал там свою стабильность, работоспособность и совместимость с самым широким спектром архитектур. Рекомендуется к внедрению в ваших проектах, если таковые имеются.

★★★★★

Проверено: hobbit ()
Последнее исправление: hobbit (всего исправлений: 1)

Осталось засунуть

  alignas(FOO_ALIGN) char foo_buf[FOO_SIZE];
в публичный хедер в качестве описания структуры, и можно будет избавиться от лишнего кода и писать всё по-обычному.

firkax ★★★★★
()

Самый часто применяемый и самый неправильный способ - написать в библиотеке функцию foo_new, в которой будем выделять память с помощью malloc и прочего подобного.

Это делают не для рещения «проблемы приватных структур» а для упрощения кода - вместо гирлянд из malloc и инициализаций просто вызов функции-конструктора.

И там внутри не обязательно malloc, может быть какой-то кастомный аллокатор, оптимизированный конкретно под этот тип объектов и спрятанный от вызывающего кода.

firkax ★★★★★
()
Последнее исправление: firkax (всего исправлений: 2)
// foo.h
extern size_t foo_size;

// foo.c
size_t foo_size = sizeof(struct foo);

// main.c
struct foo *thing = __builtin_alloca(foo_size);

Да, align так не сделаешь.

shdown
()

-100 к читаемости кода. Может это имеет некоторый смысл, но панацеей не является.

ulyssesjj
()

Плохой подход. Мне он не нравится. Слишком замудрёно. Для ядра линукса может и уместно, а для проекта попроще я бы так делать не стал.

Самый простой подход, который я бы в 99% случаев и применял: просто не бороться с ветряными мельницами и не прятать объявления структуры.

vbr ★★★★★
()
Последнее исправление: vbr (всего исправлений: 1)
// foo-sizes.h
#pragma once

#define FOO_SIZE 40
#define FOO_ALIGN 8

И с этого момента нет ни какого смысла скрывать детали. Т.к. детали мы прибили гвоздями к внешнему API.

Весь смысл скрытия реализации за:

struct foo *a = foo_new();

в том, что в разных версиях ПО состав и РАЗМЕР struct foo может быть разным, в том числе и на одной платформе.

ВАЖНЫЙ МОМЕНТ: В ядре обозначенные тобой скрипты используются не для скрытия чего-то там, а для определения констант зависимых от архитектуры target-а.

Так что весь твой «Путь решения (оптимальный)» является крайне вредным советом.

Однако это все равно не позволяет разработчику писать оптимальный код. Потому что размер и выравнивание становятся ему известны только во время выполнения программы, но не во время компиляции. Ему придется выделять на стеке буфер какого-нибудь максимально возможного размера с максимально возможным выравниванием, использовать глобальные и/или статические переменные, и тому подобное. Надо предоставить компилятору больше информации, чтобы этого избежать.

Для появления оптимального итогового бинарного кода компилятор должен видеть максимальные детали происходящего. А не абстрактное выделение на стеке буфера.

Если небольшой объект планируется разместить на стеке функции. То с большей вероятностью, явное определение struct foo, плюс определение в заголовочном файле static inline ... foo_work(...) даст намного больше «эффективный» код.

Простой пример из ядра:

rg "static inline.*\(" --glob "*.h"  | wc
  55536  373114 5319142
AlexVR ★★★★★
()

Зачем в коде ядра использовать хедеры без полных определений? Ради чего это делать в опенсорсе?

Xintrea ★★★★★
()

как из мухи сделать слона и добавить костылей :-)

в сборке - дополнительный шаг, патч sed`ом ассемблера, это явный показатель что что-то не так и не то, решение/язык/команда/весь-проект

MKuznetsov ★★★★★
()
Ответ на: комментарий от vbr

Самый простой подход, который я бы в 99% случаев и применял: просто не бороться с ветряными мельницами и не прятать объявления структуры.

ну проблема в том что если не прятать поля, то ими обязательно начнут пользоваться. И сколько не пиши «это не стабильно», кто-нибудь обязательно ими воспользуется, а потом в новой версии пойдет писать репорты что все сломалось. Либо еще хуже, накрутит каких-нибудь хаков и воркэраундов, вызовет неопределенное поведение, и уже после этого пойдет писать репорты.

А так да, конечно проще засунуть все структуры в публичные хедеры. Да и не только структуры, сделать всё хедер онли.

Lrrr ★★★★★
() автор топика
Ответ на: комментарий от Lrrr

ну проблема в том что если не прятать поля, то ими обязательно начнут пользоваться.

Проблема для кого? Для линукса? Ну может быть и проблема. Хотя с их stable api is nonsense и Линусом, матерящим всех, как будто бы и не проблема. Напиши в комментарии, что это поле внутреннее, назови его через подчёркивание или через internal_, вариантов на уровне соглашения придумать можно.

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

И сколько не пиши «это не стабильно», кто-нибудь обязательно ими воспользуется, а потом в новой версии пойдет писать репорты что все сломалось. Либо еще хуже, накрутит каких-нибудь хаков и воркэраундов, вызовет неопределенное поведение, и уже после этого пойдет писать репорты.

Понимаешь, если ты поменяешь название поля (или его уберёшь), то у человека сломается код и может быть он подумает, что делает.

А если ты поле не выставляешь, то человек всё равно туда залезет. Через что-нибудь вроде (uint32_t)((char*)ptr + 12). Если уж ему приспичило. А бывает, что нужно, нельзя всё в API идеально продумать, а ждать новой версии можно долго. И вот такой код точно сломается очень неприятно.

Уж лучше залезать в кишки осознанно и типобезопасно.

сделать всё хедер онли.

Ну это уже чересчур, как минимум, компилироваться такое будет долго, если файлов много. Может я не прав, я всегда делаю как положено - хедерово в хедеры, исходниково в исходники. Ну кроме случаев, когда я точно хочу, чтобы функция встраивалась.

PS почитать было в любом случае интересно, спасибо, может и пригодится

vbr ★★★★★
()
Последнее исправление: vbr (всего исправлений: 1)

с такими ухищрениями, код читать почти невозможно и когда пишешь придётся держать в уме «нельзя чтобы породилось нечто что подхватится sed». Если случайно совпадёт, то будет эпично. Это гарантированная шиза для разработчиков.

все статические проверяйзеры С/С++ тоже сходят с ума и идут нах.. в ком из них может быть заложено что выхлоп С компилятора дополнительно патчится ?

безопасники и project-мены требуют утроения оплаты и расширения штата. С-шный то код почти не проверяется (точнее проверяется, но авто-средствами которые ушли нах), а тут надо вычитывать сгенерённый asm.

к тому шикарное место для злодействий - достаточно модифицировать единственный parse-asm.sh или сам sed, за которыми нет такого пригляда как за остальными сорцами и компиляторами.

PS/ с alloca всё это хитроплётство все равно не работает :-)

PPS/ традиционно, если содержание структуры надо прятать от юзера, и он не может обойтись просто указателями (нужно непосредственно помещать в стек или внутрь других структур), то делается opaque_public_struct c тем-же размером и аттрибутами. И в публичном интерфейсе только она и присутствует.

MKuznetsov ★★★★★
()
Последнее исправление: MKuznetsov (всего исправлений: 1)
struct foo *foo_new(void) {
  return malloc(sizeof(struct foo));
}

Почему бы не использовать calloc, который запишет структуру нулями? или на худой конец так:

struct foo *foo_new(void) {
  struct foo *new_ptr = malloc(sizeof(struct foo));
  *new_ptr = (struct foo){0}; // почти то же самое что и memset
  return new_ptr;
}

P.S. Юзайте typedef struct чтобы не писать struct каждый раз.

Skullnet ★★★★★
()
Последнее исправление: Skullnet (всего исправлений: 1)
Ответ на: комментарий от Skullnet

Почему бы не использовать calloc, который запишет структуру нулями?

хотя-бы потому что убьётся скорость аллокатора. Она станет O(N). Для современных прикладов где «всё в куче» это приговор даже при мизерном множителе.

MKuznetsov ★★★★★
()
Ответ на: комментарий от Lrrr

ну проблема в том что если не прятать поля, то ими обязательно начнут пользоваться.

Это не проблема

А в этой библиотеке - какая-то структура, детали реализации которой мы хотим скрыть и поэтому удалили из публичных заголовочных файлов

И не раскрыта главная причина почему решили скрыть?.

Вариант №1. Структура foo может меняться от версии к версии, и разрешается динамическая линковка.

Тогда «Путь решения (оптимальный)» из-за динамической линковки становится фигнёй на полочке. Размер-то то же может меняться.

Вариант №2. ….

Какой? Решили скрыть поля что бы криворукие пользователи библиотеки не ползли куда не надо? Си это не С++ или т.п.. И не надо тащить в него лишние абстракции.

Как дать расширять библиотеку заползая в её нутро?

Всё что сказано в «Явная передача аллокатора» и «Путь решения (оптимальный)» - это расширение библиотеки, а не её использование. Так что нормальный путь к этому в Си: разместить всё необходимое в foo-internal.h.

Про malloc

Не надо так писать. malloc вызывает системный аллокатор, который работает недетерминированное время, может тормозить в многопоточном окружении, может вызвать OOM, и в целом это очень большой и сложный кусок кода с, возможно, кучей особенностей, которые вам придется учитывать. Оно вам не надо.

malloc - это библиотечный вызов. И для небольшого числа объектов он и должен использоваться. И весь текст про вредность malloc - бред сивой кобылы.

Кроме того, каждому malloc должен обязательно соответствовать free. Это усложняет уже ваш код и служит бесконечным источником багов в нем. В Си даже ввели defer для того чтобы с этим бороться

Точно так же для любого foo_init нужен foo_destroy.

Требуется 100500 объектов foo?

Тогда вопрос, почему библиотека не представляет builder_foo?

AlexVR ★★★★★
()

Почему не пользоваться implicit rules…

utf8nowhere ★★★★
()

Хорошая статья, хоть я и не могу согласиться с ней или возразить в силу необразованности в этом вопросе, и интересное обсуждение. Побольше бы такого на лоре.

treflip
()

Немножко беспантовая статья - назвать исскуственную проблему, предложить очевидно фиговое решение, сказать что оно фиговое, назвать очевидно очевидное решение, сказать что оно хорошее. Ну хоть не «бесполезные украшательства linux», спасибо на этом

daniyal
()
Для того чтобы оставить комментарий войдите или зарегистрируйтесь.