8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 28,
|
||
"slug": "editorial-2027-03-field-d-lessons",
|
||
"title": "D и C ABI: как поймать ошибку до FFI-вызова",
|
||
"excerpt": "Разбираем сбой на границе D и C: layout структуры, порядок байтов, ownership и код возврата. В статье — воспроизводимый протокол проверки, пример wrapper и условия, при которых вызов нужно остановить.",
|
||
"contentHtml": "<p>Сбой на границе D и C редко выглядит как ошибка FFI-вызова. Сервис получает неверную длину, декодер читает поле за концом буфера, а процесс падает уже в доменной логике. На одной архитектуре тест проходит, на другой меняется значение поля. Причина часто находится не в алгоритме, а в физическом контракте: размере структуры, отступе, порядке байтов, calling convention или владении памятью.</p><p>Разберём один технический вопрос: как доказать, что пакет можно передать из D в C и безопасно обработать результат. Для этого нужны не только одинаковые объявления. Нужны снимок C-header, измерения <code>sizeof</code> и <code>offsetof</code>, проверка входных байтов, правило lifetime и отрицательные тесты. Если хотя бы одно условие неизвестно, wrapper должен вернуть ошибку до передачи данных следующему слою.</p><h2>Граница состоит из двух контрактов</h2><p>У FFI есть контракт вызова и контракт данных. Первый описывает имя символа, calling convention, типы аргументов и способ возврата результата. В D объявление без linkage по умолчанию использует D-соглашение, поэтому для C-функции нужна явная форма <code>extern(C)</code>. Официальная спецификация D связывает это соглашение с C ABI компилятора на целевой платформе, но сама запись <code>extern(C)</code> не проверяет правильность прототипа.</p><p>Второй контракт описывает байты. Для структуры нужно знать порядок полей, padding, alignment и итоговый размер. Для указателя нужны адрес, длина и lifetime. Для числа в буфере нужен byte order. Для результата нужны код возврата и правило, какие поля output разрешено читать при ошибке. Имя <code>PacketHeader</code> не заменяет ни одного из этих фактов.</p><figure><img src=\"/assets/editorial/2027/d-lessons-2027-evidence-handoff-loop.svg\" alt=\"Проверка D и C ABI: сначала сравниваются layout и байты пакета, затем проверяются указатель и длина, после FFI проверяется код возврата и версия\" loading=\"lazy\" /><figcaption>Безопасная граница сначала принимает решение о допустимости пакета, затем вызывает C и только после успешного кода возврата создаёт доменный результат.</figcaption></figure><h2>Сначала сравните layout, а не ощущения</h2><p>Обычный C-header может выглядеть так:</p><pre><code>#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\");</code></pre><p>Для этого конкретного объявления мы фиксируем два свойства: размер структуры равен 8 байтам, а <code>payload_len</code> начинается с offset 4. Это не универсальная цифра для любой структуры. Добавление указателя, изменение packing, другого поля или compiler option меняет доказательство. Поэтому значения надо получать из того header и тех flags, с которыми собирается библиотека.</p><p>На стороне D сопоставление должно быть явным:</p><pre><code>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);</code></pre><p>Структура с <code>extern(C)</code> использует C layout, но это не освобождает от проверки. Если C собирается с <code>#pragma pack</code> или с опцией изменения alignment, соответствующее правило нужно отразить в D и подтвердить измерением. Если C использует bit field, его нельзя механически переписать как обычное поле D: спецификация интерфейса требует отдельной модели со сдвигами и масками.</p><div class=\"table-scroll\"><table><caption>Контракт FFI: наблюдаемый факт, проверка и безопасное решение</caption><thead><tr><th scope=\"col\">Часть контракта</th><th scope=\"col\">Что может разойтись</th><th scope=\"col\">Как проверить</th><th scope=\"col\">Что делать при расхождении</th></tr></thead><tbody><tr><td>Layout структуры</td><td>Размер, padding, offset, alignment</td><td>Собрать C helper с <code>sizeof</code>, <code>offsetof</code>, <code>_Alignof</code> и сравнить со static assert D</td><td>Исправить типы и align или передавать поля явно</td></tr><tr><td>Wire-байты</td><td>Endianness, длина, версия, reserved-поля</td><td>Прогнать golden bytes и проверить границы до чтения каждого поля</td><td>Декодировать буфер явно; не копировать его в структуру вслепую</td></tr><tr><td>Вызов</td><td>Linkage, прототип, callback и calling convention</td><td>Сверить header, D-декларацию, символ и ABI target</td><td>Оставить FFI в маленьком wrapper и не экспортировать D-типы случайно</td></tr><tr><td>Память</td><td>Кто выделяет, кто освобождает и сколько живут данные</td><td>Записать allocator, owner, length и момент освобождения</td><td>Скопировать данные на границе или вызвать парный deallocator библиотеки</td></tr><tr><td>Результат</td><td>Частично заполненный output и неоднозначный код ошибки</td><td>Проверить return code до чтения output, включая отрицательные тесты</td><td>Вернуть typed error и запретить создание доменного объекта</td></tr></tbody></table></div><h2>Структура в памяти не равна wire-формату</h2><p>Самая опасная подмена — считать, что массив байтов можно всегда привести к указателю на D-структуру. Даже при совпадении C и D layout это доказывает только представление в памяти на конкретном target. Wire-формат может задать little-endian, фиксированные размеры, checksum и padding, который нельзя читать как значение. На big-endian target те же четыре байта будут интерпретированы иначе.</p><p>Разделяйте два случая. Если C API принимает native struct, проверяйте ABI и передавайте указатель на объект с согласованным alignment. Если API принимает wire-пакет, сначала проверьте длину и версию, затем извлеките поля с явным byte order и диапазонами. Нельзя использовать успешный тест на x86 как доказательство переносимости сетевого или файлового формата.</p><p>Для golden bytes возьмите пакет с известной расшифровкой, например версией 1, flags 2 и длиной payload 16. Зафиксируйте массив байтов, ожидаемые значения, endian и версию протокола. Добавьте обрезанный пакет и пакет с длиной, превышающей остаток входа. Тест должен показывать не только результат, но и точку отказа: header, поле длины или checksum.</p><h2>Wrapper должен владеть порядком проверки</h2><p>Wrapper — это узкая граница, в которой собраны инварианты вызова. Он не должен превращаться в место для бизнес-логики. До C проверяются минимум ненулевой размер, допустимый адрес, верхняя граница длины и согласованность версии. После C сначала читается код возврата. Только при успехе проверяются output и семантические диапазоны.</p><pre><code>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}</code></pre><p>Фрагмент показывает порядок, а не готовую библиотеку. Он предполагает, что C-функция принимает указатель на байты, длину и заполняет native <code>PacketHeader</code>. Коды <code>-1</code>, <code>-2</code> и <code>-3</code> условны. В реальном проекте их нужно заменить типизированными ошибками конкретного API и проверить, может ли C писать в output при ненулевом коде возврата.</p><p>Атрибут <code>@trusted</code> здесь обозначает место, где автор D ручается за небезопасную операцию. Он не исправляет неверный prototype и не проверяет lifetime автоматически. Держите trusted-код коротким, не возвращайте наружу чужой указатель без правила владения и не смешивайте вызов C с созданием долгоживущего объекта.</p><h2>Ownership и lifetime нельзя угадывать</h2><p>Буфер, созданный в D, не становится автоматически безопасным для C на любой срок. Если C сохраняет указатель после возврата из wrapper, временный массив или память, которую может переместить сборщик, нельзя считать достаточной гарантией. В контракте должна быть одна из явных моделей: C читает данные только во время вызова; wrapper делает копию в памяти, которую C ожидает; либо C возвращает данные со своим deallocator, который вызывается парной функцией.</p><p>Нельзя освобождать память функцией, принадлежащей другому allocator. Вызов <code>free</code> из C runtime не является универсальной заменой функции освобождения, предоставленной библиотекой. Аналогично, <code>GC.free</code> относится к памяти, полученной от D GC, а не к произвольному адресу из C. Если библиотека документирует callback для освобождения или отдельный <code>destroy_packet</code>, этот шаг должен быть частью того же контракта.</p><p>Особое ограничение появляется у потоков. Документация D предупреждает, что GC не знает о потоках, созданных напрямую через OS/C runtime, если они не подключены к D runtime. Нельзя хранить там ссылки на GC-память без отдельной стратегии регистрации или копирования. Для FFI это практическое правило: если C вызывает callback из собственного потока, заранее определите, какие данные callback может видеть и кто удерживает их lifetime.</p><h2>Отрицательные тесты доказывают границу</h2><p>Положительный тест показывает, что один пакет однажды прочитан. Он не показывает, что wrapper остановит опасный вход. Нужна матрица отрицательных случаев: пустой буфер, длина меньше header, длина ровно header, payload на границе, payload на один байт больше, неизвестная версия, повреждённый порядок байтов, null output и код ошибки от C.</p><p>Для каждого случая фиксируйте ожидаемый эффект. Обрезанный пакет не должен приводить к чтению за границей. Неизвестная версия не должна превращаться в объект версии 1. Код ошибки не должен оставлять старое содержимое output видимым вызывающему коду. Если C может вернуть частичный output, wrapper обнуляет или закрывает его до передачи результата дальше.</p><ol><li>Сохранить точный C-header, версию библиотеки, target architecture, compiler flags, packing directives и calling convention.</li><li>Собрать маленький C helper, который печатает <code>sizeof</code>, <code>offsetof</code> и alignment каждого поля; сопоставить эти значения с D static assert.</li><li>Разделить native struct и wire-формат. Для wire-формата зафиксировать golden bytes, endian, версию, длину и допустимые диапазоны.</li><li>Проверить ownership: allocator, deallocator, момент освобождения и возможность C сохранить указатель после вызова.</li><li>Оставить один короткий wrapper, который проверяет границы до FFI, код возврата до output и версию до доменной логики.</li><li>Прогнать положительные и отрицательные случаи на каждом поддерживаемом target, а в отчёт записать байты, ABI-метаданные и точную причину отказа.</li></ol><h2>Когда вызов нужно остановить</h2><p>Остановите вызов, если размер или offset не совпал с C helper, неизвестен packing, не определён byte order, длина не согласована с указателем, lifetime заканчивается раньше C-операции или deallocator не назван. Не пытайтесь продолжить с «похожим» layout. Несколько совпавших полей не доказывают совместимость всей структуры.</p><p>Иногда безопаснее отказаться от передачи структуры. Явная сериализация полей в буфер добавляет операции копирования, зато убирает зависимость от padding, указателей и target alignment. Передача native struct оправдана, когда API стабилен, ABI закреплён и для него есть контрактные тесты. Выбор зависит от стоимости копирования и стоимости несовместимого обновления, а не от желания сделать wrapper короче.</p><h2>Ограничения применимости</h2><p>Пример не является готовой привязкой к конкретной библиотеке. В нём не описаны C++ name mangling, variadic functions, callbacks с несколькими calling convention, bit fields, packed structures, thread safety, checksum и динамическая загрузка символов. Для этих случаев нужны отдельные правила исходного API и тесты на реальном target.</p><p>Спецификация D описывает общие правила ABI, но не знает compiler flags, vendor header, версию сторонней библиотеки и её ownership. Даже совпавшие измерения на одной машине не доказывают совместимость всех платформ. Если библиотека обновляет header, повторите helper и пересмотрите golden bytes. Не переносите пример в production, пока не определены конкретные коды ошибок и deallocator.</p><h2>Критерий готовности</h2><p>Граница готова, когда C и D собраны с согласованными настройками, размеры и offsets проверяются автоматически, wire-байты отделены от native layout, ownership записан в контракте, а wrapper имеет отрицательные тесты. При неизвестной версии, неверной длине, ошибке C или неясном lifetime он возвращает отказ, а не частичный объект. Такой процесс связывает падение с измеримым нарушением контракта и оставляет следующий шаг для диагностики.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://dlang.org/spec/abi.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Application Binary Interface</a> — описывает C ABI, endianness, представление базовых типов, layout структур и calling convention. Конкретные compiler flags и header проекта нужно проверять отдельно.</li><li><a href=\"https://dlang.org/spec/interfaceToC.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Interfacing to C</a> — объясняет сопоставление C и D-типов, alignment, packing, bit fields и callbacks. Источник не подтверждает ownership конкретной библиотеки.</li><li><a href=\"https://dlang.org/spec/garbage.html\" target=\"_blank\" rel=\"noopener noreferrer\">D Language Specification: Automatic Memory Management</a> — описывает взаимодействие GC с памятью, переданной foreign code, и ограничения для потоков. Правило deallocator всё равно задаётся API.</li></ul>"
|
||
}
|