{ "index": 28, "slug": "editorial-2027-03-field-d-lessons", "title": "D и C ABI: как поймать ошибку до FFI-вызова", "excerpt": "Разбираем сбой на границе D и C: layout структуры, порядок байтов, ownership и код возврата. В статье — воспроизводимый протокол проверки, пример wrapper и условия, при которых вызов нужно остановить.", "contentHtml": "

Сбой на границе D и C редко выглядит как ошибка FFI-вызова. Сервис получает неверную длину, декодер читает поле за концом буфера, а процесс падает уже в доменной логике. На одной архитектуре тест проходит, на другой меняется значение поля. Причина часто находится не в алгоритме, а в физическом контракте: размере структуры, отступе, порядке байтов, calling convention или владении памятью.

Разберём один технический вопрос: как доказать, что пакет можно передать из D в C и безопасно обработать результат. Для этого нужны не только одинаковые объявления. Нужны снимок C-header, измерения sizeof и offsetof, проверка входных байтов, правило lifetime и отрицательные тесты. Если хотя бы одно условие неизвестно, wrapper должен вернуть ошибку до передачи данных следующему слою.

Граница состоит из двух контрактов

У FFI есть контракт вызова и контракт данных. Первый описывает имя символа, calling convention, типы аргументов и способ возврата результата. В D объявление без linkage по умолчанию использует D-соглашение, поэтому для C-функции нужна явная форма extern(C). Официальная спецификация D связывает это соглашение с C ABI компилятора на целевой платформе, но сама запись extern(C) не проверяет правильность прототипа.

Второй контракт описывает байты. Для структуры нужно знать порядок полей, padding, alignment и итоговый размер. Для указателя нужны адрес, длина и lifetime. Для числа в буфере нужен byte order. Для результата нужны код возврата и правило, какие поля output разрешено читать при ошибке. Имя PacketHeader не заменяет ни одного из этих фактов.

\"Проверка
Безопасная граница сначала принимает решение о допустимости пакета, затем вызывает C и только после успешного кода возврата создаёт доменный результат.

Сначала сравните layout, а не ощущения

Обычный C-header может выглядеть так:

#include <stddef.h>\n#include <stdint.h>\n\nstruct packet_header {\n    uint16_t version;\n    uint16_t flags;\n    uint32_t payload_len;\n};\n\n_Static_assert(sizeof(struct packet_header) == 8, \"packet_header size\");\n_Static_assert(offsetof(struct packet_header, payload_len) == 4,\n               \"packet_header payload offset\");

Для этого конкретного объявления мы фиксируем два свойства: размер структуры равен 8 байтам, а payload_len начинается с offset 4. Это не универсальная цифра для любой структуры. Добавление указателя, изменение packing, другого поля или compiler option меняет доказательство. Поэтому значения надо получать из того header и тех flags, с которыми собирается библиотека.

На стороне D сопоставление должно быть явным:

extern(C) struct PacketHeader\n{\n    ushort version;\n    ushort flags;\n    uint payload_len;\n}\n\nstatic assert(PacketHeader.sizeof == 8);\nstatic assert(PacketHeader.payload_len.offsetof == 4);\n\nextern(C) int decode_packet(\n    const(ubyte)* data,\n    size_t length,\n    PacketHeader* header,\n);

Структура с extern(C) использует C layout, но это не освобождает от проверки. Если C собирается с #pragma pack или с опцией изменения alignment, соответствующее правило нужно отразить в D и подтвердить измерением. Если C использует bit field, его нельзя механически переписать как обычное поле D: спецификация интерфейса требует отдельной модели со сдвигами и масками.

Контракт FFI: наблюдаемый факт, проверка и безопасное решение
Часть контрактаЧто может разойтисьКак проверитьЧто делать при расхождении
Layout структурыРазмер, padding, offset, alignmentСобрать C helper с sizeof, offsetof, _Alignof и сравнить со static assert DИсправить типы и align или передавать поля явно
Wire-байтыEndianness, длина, версия, reserved-поляПрогнать golden bytes и проверить границы до чтения каждого поляДекодировать буфер явно; не копировать его в структуру вслепую
ВызовLinkage, прототип, callback и calling conventionСверить header, D-декларацию, символ и ABI targetОставить FFI в маленьком wrapper и не экспортировать D-типы случайно
ПамятьКто выделяет, кто освобождает и сколько живут данныеЗаписать allocator, owner, length и момент освобожденияСкопировать данные на границе или вызвать парный deallocator библиотеки
РезультатЧастично заполненный output и неоднозначный код ошибкиПроверить return code до чтения output, включая отрицательные тестыВернуть typed error и запретить создание доменного объекта

Структура в памяти не равна wire-формату

Самая опасная подмена — считать, что массив байтов можно всегда привести к указателю на D-структуру. Даже при совпадении C и D layout это доказывает только представление в памяти на конкретном target. Wire-формат может задать little-endian, фиксированные размеры, checksum и padding, который нельзя читать как значение. На big-endian target те же четыре байта будут интерпретированы иначе.

Разделяйте два случая. Если C API принимает native struct, проверяйте ABI и передавайте указатель на объект с согласованным alignment. Если API принимает wire-пакет, сначала проверьте длину и версию, затем извлеките поля с явным byte order и диапазонами. Нельзя использовать успешный тест на x86 как доказательство переносимости сетевого или файлового формата.

Для golden bytes возьмите пакет с известной расшифровкой, например версией 1, flags 2 и длиной payload 16. Зафиксируйте массив байтов, ожидаемые значения, endian и версию протокола. Добавьте обрезанный пакет и пакет с длиной, превышающей остаток входа. Тест должен показывать не только результат, но и точку отказа: header, поле длины или checksum.

Wrapper должен владеть порядком проверки

Wrapper — это узкая граница, в которой собраны инварианты вызова. Он не должен превращаться в место для бизнес-логики. До C проверяются минимум ненулевой размер, допустимый адрес, верхняя граница длины и согласованность версии. После C сначала читается код возврата. Только при успехе проверяются output и семантические диапазоны.

int decode(scope const(ubyte)[] bytes, out PacketHeader header) @trusted\n{\n    header = PacketHeader.init;\n\n    if (bytes.length < PacketHeader.sizeof)\n        return -1;\n\n    auto rc = decode_packet(bytes.ptr, bytes.length, &header);\n    if (rc != 0)\n        return rc;\n\n    if (header.version != 1)\n        return -2;\n\n    if (header.payload_len > bytes.length - PacketHeader.sizeof)\n        return -3;\n\n    return 0;\n}

Фрагмент показывает порядок, а не готовую библиотеку. Он предполагает, что C-функция принимает указатель на байты, длину и заполняет native PacketHeader. Коды -1, -2 и -3 условны. В реальном проекте их нужно заменить типизированными ошибками конкретного API и проверить, может ли C писать в output при ненулевом коде возврата.

Атрибут @trusted здесь обозначает место, где автор D ручается за небезопасную операцию. Он не исправляет неверный prototype и не проверяет lifetime автоматически. Держите trusted-код коротким, не возвращайте наружу чужой указатель без правила владения и не смешивайте вызов C с созданием долгоживущего объекта.

Ownership и lifetime нельзя угадывать

Буфер, созданный в D, не становится автоматически безопасным для C на любой срок. Если C сохраняет указатель после возврата из wrapper, временный массив или память, которую может переместить сборщик, нельзя считать достаточной гарантией. В контракте должна быть одна из явных моделей: C читает данные только во время вызова; wrapper делает копию в памяти, которую C ожидает; либо C возвращает данные со своим deallocator, который вызывается парной функцией.

Нельзя освобождать память функцией, принадлежащей другому allocator. Вызов free из C runtime не является универсальной заменой функции освобождения, предоставленной библиотекой. Аналогично, GC.free относится к памяти, полученной от D GC, а не к произвольному адресу из C. Если библиотека документирует callback для освобождения или отдельный destroy_packet, этот шаг должен быть частью того же контракта.

Особое ограничение появляется у потоков. Документация D предупреждает, что GC не знает о потоках, созданных напрямую через OS/C runtime, если они не подключены к D runtime. Нельзя хранить там ссылки на GC-память без отдельной стратегии регистрации или копирования. Для FFI это практическое правило: если C вызывает callback из собственного потока, заранее определите, какие данные callback может видеть и кто удерживает их lifetime.

Отрицательные тесты доказывают границу

Положительный тест показывает, что один пакет однажды прочитан. Он не показывает, что wrapper остановит опасный вход. Нужна матрица отрицательных случаев: пустой буфер, длина меньше header, длина ровно header, payload на границе, payload на один байт больше, неизвестная версия, повреждённый порядок байтов, null output и код ошибки от C.

Для каждого случая фиксируйте ожидаемый эффект. Обрезанный пакет не должен приводить к чтению за границей. Неизвестная версия не должна превращаться в объект версии 1. Код ошибки не должен оставлять старое содержимое output видимым вызывающему коду. Если C может вернуть частичный output, wrapper обнуляет или закрывает его до передачи результата дальше.

  1. Сохранить точный C-header, версию библиотеки, target architecture, compiler flags, packing directives и calling convention.
  2. Собрать маленький C helper, который печатает sizeof, offsetof и alignment каждого поля; сопоставить эти значения с D static assert.
  3. Разделить native struct и wire-формат. Для wire-формата зафиксировать golden bytes, endian, версию, длину и допустимые диапазоны.
  4. Проверить ownership: allocator, deallocator, момент освобождения и возможность C сохранить указатель после вызова.
  5. Оставить один короткий wrapper, который проверяет границы до FFI, код возврата до output и версию до доменной логики.
  6. Прогнать положительные и отрицательные случаи на каждом поддерживаемом target, а в отчёт записать байты, ABI-метаданные и точную причину отказа.

Когда вызов нужно остановить

Остановите вызов, если размер или offset не совпал с C helper, неизвестен packing, не определён byte order, длина не согласована с указателем, lifetime заканчивается раньше C-операции или deallocator не назван. Не пытайтесь продолжить с «похожим» layout. Несколько совпавших полей не доказывают совместимость всей структуры.

Иногда безопаснее отказаться от передачи структуры. Явная сериализация полей в буфер добавляет операции копирования, зато убирает зависимость от padding, указателей и target alignment. Передача native struct оправдана, когда API стабилен, ABI закреплён и для него есть контрактные тесты. Выбор зависит от стоимости копирования и стоимости несовместимого обновления, а не от желания сделать wrapper короче.

Ограничения применимости

Пример не является готовой привязкой к конкретной библиотеке. В нём не описаны C++ name mangling, variadic functions, callbacks с несколькими calling convention, bit fields, packed structures, thread safety, checksum и динамическая загрузка символов. Для этих случаев нужны отдельные правила исходного API и тесты на реальном target.

Спецификация D описывает общие правила ABI, но не знает compiler flags, vendor header, версию сторонней библиотеки и её ownership. Даже совпавшие измерения на одной машине не доказывают совместимость всех платформ. Если библиотека обновляет header, повторите helper и пересмотрите golden bytes. Не переносите пример в production, пока не определены конкретные коды ошибок и deallocator.

Критерий готовности

Граница готова, когда C и D собраны с согласованными настройками, размеры и offsets проверяются автоматически, wire-байты отделены от native layout, ownership записан в контракте, а wrapper имеет отрицательные тесты. При неизвестной версии, неверной длине, ошибке C или неясном lifetime он возвращает отказ, а не частичный объект. Такой процесс связывает падение с измеримым нарушением контракта и оставляет следующий шаг для диагностики.

Проверяемые источники

" }