Программирование RustUnsafe и памятьИнженер по системному программированию на Rust

Что должен учитывать Rust код при передаче аргументов в C variadic функцию, чтобы тип, прочитанный через va...

Что должен учитывать Rust-код при передаче аргументов в C variadic-функцию, чтобы тип, прочитанный через va_arg, совпал с фактически переданным?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Rust-код обязан учитывать стандартные продвижения типов C в variadic-вызовах: float передаётся как double, а целые типы уже́ int обычно преобразуются в int или unsigned int. Читающий код C должен использовать именно фактически переданный тип; несовпадение с va_arg приводит к неопределённому поведению.

Кроме того, нужно соблюдать ABI функции, её форматную строку или иной контракт интерпретации аргументов. Сам факт совпадения размера типов не делает variadic-вызов безопасным.

Исторический контекст

Variadic-функции появились в C для API с переменным числом аргументов, например форматированного вывода. У вызывающей стороны нет обычной сигнатуры для каждого аргумента, поэтому соглашение о представлении и продвижении типов стало частью ABI и контракта функции.

Rust сохраняет возможность вызывать такие функции через C ABI, но не может проверить соответствие переменных аргументов коду, который извлекает их из va_list. Поэтому эта часть корректности остаётся обязанностью unsafe-кода и документации C API.

Постановка проблемы

Тип, указанный в исходном выражении Rust, не всегда совпадает с типом, реально поступившим в C variadic-функцию. Например, передача f32 приводит к передаче double, а передача небольшого целого типа может привести к передаче int.

Если C-код извлекает такой аргумент как исходный float или как другой несовместимый целый тип, он нарушает контракт va_arg. Последствия не ограничиваются неверным значением: возможны повреждение чтения последующих аргументов, неверное использование регистров или стека и неопределённое поведение.

Подробное решение

В фиксированной части объявления Rust нужно точно описать параметры, а для переменной части — передавать значения с учётом правил C. На практике это означает, что значение f32 передают как f64, а небольшие целые значения явно приводят к ожидаемому продвинутому типу, обычно i32 или u32 в зависимости от контракта.

Минимальная схема выглядит так:

use std::os::raw::c_char; unsafe extern "C" { fn log_values(format: *const c_char, ...); } fn call_log(format: *const c_char) { unsafe { log_values(format, 1.25_f64, 7_i32); } }

Здесь вызывающий код передаёт значение с плавающей точкой как f64, а целое — как i32. Это корректно только при условии, что контракт log_values действительно извлекает аргументы как double и int; Rust не проверяет это автоматически.

Нельзя полагаться на одно лишь числовое совпадение размеров. Важны тип категории, правила продвижения, ABI и способ, которым принимающая функция интерпретирует последовательность аргументов. Безопасная Rust-обёртка должна ограничить допустимые варианты аргументов или вообще заменить variadic API типизированными функциями.

Ситуация из практики

Rust-модуль вызывает стороннюю C-библиотеку журналирования. В документации сказано, что спецификатор %f извлекает double, а %dint. Разработчик передаёт f32 и i16, считая, что библиотека сама использует исходные типы.

Рассматривались два варианта. Первый — оставить прямой variadic-вызов: он прост, но создаёт риск несовпадения типов при каждом новом месте вызова. Второй — перед вызовом явно преобразовывать значения к f64 и i32, а формат и набор аргументов скрыть в узкой обёртке; этот вариант требует больше кода, зато централизует проверку контракта.

Выбран второй вариант. В результате значения извлекаются C-кодом в тех типах, которые предусмотрены ABI и документацией, а потенциально опасная логика остаётся в одном проверенном месте.

Что кандидаты часто упускают

  1. Достаточно ли передать f32, если C-функция ожидает значение, занимающее те же четыре байта?

    Нет. В variadic-вызове C значение float подвергается продвижению и передаётся как double. Читающий код должен извлечь double; попытка извлечь float нарушает контракт независимо от исходного размера f32.

  2. Можно ли считать variadic-вызов безопасным, если форматная строка хранится в Rust и не изменяется?

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

  3. Почему безопасная обёртка не может просто принимать произвольный список Rust-значений?

    Потому что обычная проверка типов Rust не связывает список variadic-аргументов с форматом или логикой va_arg внутри C-функции. Обёртка должна заранее ограничить поддерживаемые типы и самостоятельно привести их к ожидаемым ABI-типам либо предоставить отдельные типизированные операции. Иначе вызывающий код всё равно сможет сформировать несовместимую последовательность аргументов.