LINUX.ORG.RU

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

 , ,


3

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 ★★★★★
()
Для того чтобы оставить комментарий войдите или зарегистрируйтесь.