Files
progcode/editorial/agent-rewrites/028.json
T

8 lines
22 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"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 &lt;stddef.h&gt;\n#include &lt;stdint.h&gt;\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 &lt; PacketHeader.sizeof)\n return -1;\n\n auto rc = decode_packet(bytes.ptr, bytes.length, &amp;header);\n if (rc != 0)\n return rc;\n\n if (header.version != 1)\n return -2;\n\n if (header.payload_len &gt; 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>"
}