Программирование RustUnsafe и памятьРазработчик системного ПО на Rust

При чтении поля C объединения через Rust union какой инвариант обязан доказать unsafe код?

При чтении поля C-объединения через Rust union какой инвариант обязан доказать unsafe-код?

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

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

unsafe-код обязан доказать, что память объединения инициализирована, выровнена, а её текущая битовая последовательность является допустимым значением выбранного поля Rust. Обычно это обеспечивается контрактом C-кода и проверкой дискриминатора перед чтением.

Сам факт совпадения размеров или наличия нужного поля не доказывает корректность чтения. Rust не отслеживает активный член объединения во время выполнения.

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

Объединения C позволяют нескольким полям занимать одну область памяти. Это удобно для представления вариантов данных, но сам тип обычно не хранит информацию о том, какое поле было записано последним.

В Rust такая модель нужна прежде всего для совместимости с C ABI и низкоуровневыми структурами. Поскольку компилятор не может вывести активный член и корректность его представления, чтение поля union относится к unsafe-операциям.

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

Например, C-структура может содержать числовой дискриминатор и объединение с несколькими вариантами полезной нагрузки. Если Rust прочитает поле, которое не соответствует значению дискриминатора, он может интерпретировать байты как другой тип.

Последствия зависят от типа поля. Для u32 любая битовая последовательность является допустимым значением, но это всё равно может быть логически неверный вариант. Для ссылок, bool, некоторых enum и других типов существуют недопустимые представления; их чтение может привести к неопределённому поведению уже на уровне модели памяти Rust.

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

Нужно доказать следующие условия:

  • область памяти действительно инициализирована;
  • адрес соответствует выравниванию типа поля;
  • выбранное поле соответствует контракту источника данных, обычно активному члену C-объединения;
  • битовое представление памяти допустимо для типа Rust, который читается;
  • время жизни памяти и доступ к ней остаются корректными на момент чтения.

#[repr(C)] обеспечивает совместимое размещение объединения с C, но не добавляет дискриминатор и не проверяет активный член. Rust union также не запоминает, какое поле было записано, поэтому проверка должна находиться в окружающем коде или в безопасной обёртке.

Безопасная обёртка обычно хранит или получает отдельно дискриминатор, проверяет его и только затем выполняет чтение в узком unsafe-блоке. Комментарий безопасности должен объяснять не просто, что значение пришло из C, а конкретно почему выбранное поле было инициализировано и имеет допустимое представление.

#[repr(C)] union Payload { count: u32, code: i32, } #[repr(C)] struct Message { tag: u32, payload: Payload, } fn read_count(message: &Message) -> Option<u32> { if message.tag != 1 { return None; } // Контракт источника: tag == 1 означает инициализированное поле count. Some(unsafe { message.payload.count }) } fn main() { let message = Message { tag: 1, payload: Payload { count: 42 } }; assert_eq!(read_count(&message), Some(42)); }

В примере значение tag == 1 является частью внешнего контракта. Сам Rust не проверяет, что C действительно записал count; если C нарушит этот контракт, безопасная обёртка окажется построенной на ложном предположении.

Альтернатива — представлять варианты не через union, а через Rust enum или явную безопасную структуру. Это даёт проверяемый дискриминатор, но может изменить ABI, размер или формат данных, поэтому для существующего C-интерфейса такой вариант не всегда применим.

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

C-библиотека возвращает сообщение с полем kind и объединением: для одного значения kind активен счётчик, для другого — код ошибки. Первый вариант реализации Rust безусловно читал count, потому что оба поля занимали одинаковые четыре байта. При сообщениях об ошибках программа получала правдоподобные, но неверные числа.

Рассматривались три решения. Безусловное чтение было самым простым, но не имело доказательства корректности. Копирование объединения в массив байт устраняло риск неверного типа, однако переносило декодирование и проверку формата на вызывающий код. Обёртка с проверкой kind сохранила ABI и централизовала unsafe-код.

Выбрали третье решение: для каждого допустимого значения дискриминатора сделали отдельный проверяемый путь чтения, а неизвестные значения стали ошибкой. В результате неверные сообщения перестали интерпретироваться как другой вариант, а контракт с C оказался явно зафиксирован в коде.

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

  1. Достаточно ли проверить только дискриминатор?

Нет. Дискриминатор доказывает соответствие выбранного варианта протоколу только в том случае, если доверенный источник действительно поддерживает этот контракт. Дополнительно нужны корректная инициализация, выравнивание и допустимость битового представления выбранного Rust-типа.

Если данные пришли из недоверенного или потенциально повреждённого источника, сначала нужно валидировать сам формат. Нельзя превращать произвольные байты в ссылку, bool или enum только на основании значения тега.

  1. Можно ли читать другое поле, если оба поля имеют одинаковый размер?

Размер не определяет семантическую и типовую корректность. Для некоторых простых числовых типов чтение может быть допустимо с точки зрения представления, но результат всё равно не будет означать значение другого активного варианта C.

Для типов с ограниченным набором допустимых битовых представлений такая операция может создать недопустимое значение и привести к неопределённому поведению. Кроме того, правила C для активного члена объединения и правила Rust о допустимости значения не сводятся к проверке размера.

  1. Достаточно ли безопасно записать одно поле Rust, чтобы затем читать любое поле объединения?

Нет. Запись одного поля устанавливает корректное значение именно для этого варианта. Чтение другого поля требует отдельного доказательства: его представление должно быть допустимым, а такая интерпретация должна соответствовать контракту конкретного ABI и протокола данных.

Если требуется побайтное представление, безопаснее явно копировать байты и декодировать их с проверкой. Нельзя автоматически считать, что запись u32 делает корректным чтение ссылки, enum или другого типа, даже если размеры совпадают.