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

В структуре с repr packed получение адреса поля через обычную ссылку уже может привести к неопределённому п...

В структуре с repr(packed) получение адреса поля через обычную ссылку уже может привести к неопределённому поведению — какой инвариант нарушается?

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

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

Обычная ссылка Rust обязана быть корректно выровненной для своего типа. В repr(packed) поле может находиться по адресу, не кратному его выравниванию, поэтому даже кратковременное создание ссылки на такое поле нарушает инвариант выравнивания и может вызвать неопределённое поведение.

Для получения адреса нужно использовать raw pointer без промежуточной ссылки, например через addr_of!, а чтение или запись выполнять с read_unaligned либо write_unaligned.

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

repr(packed) появился как средство описывать компактные структуры с фиксированным расположением полей, в том числе совместимые с бинарными форматами и C-структурами без padding. Это экономит память и позволяет точно соответствовать внешнему ABI.

Однако обычные Rust-ссылки проектировались для типов, размещённых с соблюдением их естественного выравнивания. Поэтому компактная упаковка layout не отменяет требований модели памяти Rust: поле может быть доступно по байтам, но не обязательно через обычную ссылку.

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

Рассмотрим упакованную структуру, где после однобайтного поля следует u32. Адрес u32 может быть смещён на один байт и потому не соответствовать требуемому выравниванию.

Проблема возникает не только при фактическом чтении значения. Операция, которая неявно создаёт &u32, уже обещает компилятору, что адрес выровнен. Если это обещание ложно, поведение программы не определено; на некоторых архитектурах последующее аппаратное обращение дополнительно может завершиться ошибкой.

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

Нужно разделять две операции: вычисление адреса и доступ к значению. Raw pointer может быть невыравненным, тогда как ссылка &T и &mut T — нет.

#[repr(C, packed)] struct Header { tag: u8, length: u32, } fn read_length(header: *const Header) -> u32 { let field = std::ptr::addr_of!((*header).length); unsafe { field.read_unaligned() } }

addr_of! формирует raw pointer непосредственно к полю и не создаёт промежуточную ссылку. read_unaligned специально допускает отсутствие выравнивания и возвращает копию значения.

Нельзя исправить проблему простым приведением raw pointer к ссылке: такое преобразование снова требует корректного выравнивания. Также опасно передавать упакованное поле в функцию, принимающую ссылку, вызывать на нём методы через авторазыменование или брать &mut для записи.

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

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

Библиотека получает из C заголовок сетевого пакета, объявленный с упаковкой. Разработчик пытался вернуть длину заголовка как ссылку на поле, чтобы избежать копирования. Такой вариант нарушает требования к выравниванию и оставляет вызывающему коду ссылку, которая изначально некорректна.

Рассматривались три решения. Удалить упаковку нельзя: layout перестанет совпадать с форматом пакета. Скопировать весь заголовок в выровненную Rust-структуру безопасно, но добавляет копирование и требует явно описать преобразование. Читать поле через raw pointer и read_unaligned дешевле и сохраняет исходный layout, но требует локального unsafe и проверки действительности указателя.

Выбран третий вариант: FFI-обёртка проверяет, что указатель не нулевой и указывает на доступную область нужного размера, затем выполняет read_unaligned и возвращает обычное значение u32, а не ссылку. В результате невыравненный доступ ограничен одним местом, а остальной Rust-код работает с уже скопированным выровненным значением.

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

  1. Достаточно ли использовать raw pointer вместо ссылки, чтобы любое чтение стало безопасным?

Нет. Raw pointer снимает требование выравнивания ссылки, но не проверяет действительность адреса, размер доступной памяти, корректность типа или отсутствие состояния, при котором чтение запрещено. Для упакованного поля дополнительно нужен именно read_unaligned; обычное разыменование raw pointer всё ещё предполагает выравнивание.

  1. Почему нельзя сначала получить ссылку, а затем вызвать на ней read_unaligned?

Потому что нарушение уже произошло в момент создания ссылки. read_unaligned работает с raw pointer и не может сделать ранее созданную некорректную ссылку допустимой. Правильная последовательность — вычислить raw pointer без промежуточного &T, а затем выполнить невыравненное чтение.

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

Только если это выравнивание гарантировано для всех допустимых экземпляров и сохраняется на всём времени жизни ссылки. Сам факт, что один адрес оказался кратен выравниванию, не создаёт общего контракта для типа repr(packed). На практике безопаснее возвращать значение-копию или ссылку на отдельно скопированное выровненное хранилище.