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

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

Тезис простой: FFI-вызов нельзя считать началом проверки. Сначала нужно подтвердить физический контракт пакета. Он включает размер, offsets, alignment, порядок байтов, набор обязательных полей, calling convention, ownership и код возврата. Только после этого пакет можно передавать в C. Имя структуры и успешная компиляция этого не доказывают.

Что именно ломается

ABI описывает представление типов и вызовов на машинной границе. В D и C совпадение названий полей не гарантирует совпадение layout. Между двумя полями может появиться padding. Указатель занимает разный размер на разных target-платформах. Директива packing меняет offsets. Сборка с другим compiler flag создаёт другой контракт, даже если исходный header не изменился.

Порядок байтов нужно проверять отдельно. Структура из памяти не является wire-форматом. Число 0x01020304 в little-endian и big-endian занимает те же четыре байта, но читается с разным значением. Если код копирует входной буфер в структуру без явного декодирования, ошибка будет похожа на неверный размер или повреждённый id.

Есть и семантическая часть. Поле payload может быть указателем, длиной или смещением внутри буфера. Ноль может означать пустой пакет, null или успешный результат. C-функция может частично заполнить output и вернуть ошибку. Поэтому проверка размера без проверки ownership и кода возврата создаёт ложное чувство безопасности.

\"Схема
Проверка отделяет физический контракт от вызова. Несовпадение возвращает пакет на границу и останавливает опасную операцию.
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Поле имеет неверное значение только на одной платформеРазный padding, alignment или размер указателяСравнить sizeof, offsets и alignment D/C на каждом targetЗафиксировать layout, выровнять типы или сериализовать поля явно
Число меняется после чтения буфераПерепутан порядок байтовПрогнать golden bytes с 0x01020304Декодировать wire-формат явно, не копировать структуру целиком
Редкий crash после успешного вызоваНеверная длина или истёкшее владение памятьюПроверить pointer, length, lifetime и правило освобожденияСузить wrapper, скопировать данные или вернуть ошибку до C
Ошибочный пакет выглядит успешнымOutput читается до проверки кода возвратаПроверить порядок обработки return code и outputСначала переводить ошибку, потом интерпретировать output
Тесты проходят, production-пакет не читаетсяТест использует другой header, target или версию протоколаСохранить hex-пакет, compiler flags и версию ABIДобавить контрактный тест для реального target matrix

Минимальный безопасный порядок

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

Затем составьте layout table. Для каждого поля запишите тип C, тип D, offset, размер, alignment, смысл, допустимый диапазон и владельца памяти. Если поле является указателем, рядом должна стоять длина и правило освобождения. Если их нельзя указать, wrapper не готов.

Только после этого сравните C header и D-объявление. Сверьте calling convention и compiler flags. Отдельно проверьте, не добавляет ли C-код packing или условную компиляцию. Сборка одного target не подтверждает остальные.

Учебный фрагмент wrapper

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

extern(C) int decode_packet(\\n    const(ubyte)* data,\\n    size_t length,\\n    uint* version,\\n);\\n\\n// Учебный пример: контракт библиотеки нужно подтвердить по её header.\\nint decode(scope const(ubyte)[] data, out uint version) @trusted\\n{\\n    if (data.length == 0)\\n        return -1;\\n\\n    return decode_packet(data.ptr, data.length, &version);\\n}

В примере wrapper не передаёт null вместе с ненулевой длиной. Но это только одна проверка. Реальный контракт может требовать null для пустого буфера, завершающий ноль, выравнивание адреса или отдельный allocator. Поэтому правило нельзя переносить на библиотеку без чтения её header и документации.

Атрибут @trusted не означает, что C-вызов проверен компилятором. Он означает, что автор wrapper берёт на себя доказательство инвариантов. Держите такую функцию короткой. Не смешивайте в ней разбор формата, бизнес-правила и освобождение памяти. Чем шире trusted-зона, тем труднее проверить её границу.

Проверка кода возврата

Обрабатывайте результат внешней функции в фиксированном порядке: сначала код возврата, затем размер и версию output, затем семантику полей. Не используйте частично заполненную структуру после ошибки. Если C API допускает частичный output, это должно быть явно записано в контракте и покрыто отдельным тестом.

Для числовых полей нужны golden bytes. Возьмите известное значение, закодируйте его в требуемом wire-формате и сравните результат на D-стороне. Такой тест показывает, где ошибка: в байтах, offsets или выборе типа. Для указателей добавьте нулевую длину, длину ровно до границы и длину на один байт больше.

Действия по порядку

  1. Зафиксировать C header, D-объявление, compiler flags, target architecture, packing directives и calling convention.
  2. Составить таблицу layout: размер, offset, alignment, тип, значение, длина и владелец каждого поля.
  3. Сохранить golden bytes для корректного пакета, неверного endianness, обрезанной длины и неизвестной версии.
  4. Вынести FFI в маленький wrapper и поставить проверки pointer, length, lifetime и кода возврата до передачи данных доменному коду.
  5. Проверить валидный и отрицательный пути на каждой поддерживаемой архитектуре, включая границы 0, capacity и capacity+1.
  6. Сохранить в отчёте hex-пакет, размер, target, версию ABI и точную ошибку. Это связывает симптом с физическим контрактом.

Когда проверка должна остановить вызов

Остановите вызов, если размер не совпал, обязательное поле отсутствует, версия неизвестна, pointer не согласован с length или правило ownership не имеет ответа. Не пытайтесь «продолжить с тем, что удалось прочитать». На FFI-границе частичный успех часто превращается в повреждённое состояние выше по стеку.

Если layout зависит от платформы, есть два пути. Можно описать отдельные контракты и тестировать каждый target. Можно отказаться от передачи структуры и использовать явную сериализацию полей в буфер. Второй путь иногда медленнее, но уменьшает зависимость от padding и размера указателя. Выбирайте его, когда переносимость важнее нулевой копии.

Ограничения

Учебный wrapper не моделирует все правила D, C и конкретной библиотеки. Он не проверяет compiler lowering, alignment адреса, aliasing, null termination, thread safety, освобождение памяти и совместимость версий. Таблица layout не заменяет сборку маленького C helper и тест на целевом ABI. Документация языка объясняет общие правила, но не подтверждает vendor header.

Не называйте проверку успешной только потому, что код компилируется и один тест возвращает ожидаемое поле. Готовность требует повторяемого отрицательного пути. Ошибочный размер должен остановить вызов. Ошибочный порядок байтов должен быть виден в golden test. Ошибка C не должна превращаться в валидный доменный объект.

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

Граница готова, если для каждого target есть зафиксированные размер и layout, есть golden bytes и тесты на отрицательные случаи, wrapper проверяет pointer, length, lifetime и код возврата, а неизвестная версия или несовпадение контракта останавливает вызов. Проверка должна оставлять диагностический пакет: hex, target, версию ABI и причину отказа. Тогда следующая ошибка возвращается к конкретному байту, а не к предположению о языке.

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

" }