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

Разберите обращение к элементу за пределами выделенного массива: какой инвариант нарушается уже при вычисле...

Разберите обращение к элементу за пределами выделенного массива: какой инвариант нарушается уже при вычислении q?

fn main() {
    let data = Box::new([10u8, 20, 30]);
    let p = data.as_ptr();
    let q = unsafe { p.add(4) };
    println!("{}", unsafe { *q });
}
Проходите собеседования с ИИ помощником Hintsage

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

Вызов p.add(4) уже является неопределённым поведением: результат add должен указывать внутрь той же выделенной области либо ровно на позицию сразу за её концом. Для массива из трёх элементов допустимы p.add(0), p.add(1), p.add(2) и p.add(3), но не p.add(4).

unsafe не расширяет границы объекта и не отключает требования к арифметике указателей. Разыменование полученного указателя дополнительно было бы некорректным, но ошибка возникает раньше — при вычислении q.

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

Raw pointers нужны Rust для низкоуровневого кода, реализации контейнеров, взаимодействия с C и работы с памятью без автоматического создания ссылок. Такой код невозможно полностью проверить обычными правилами заимствования, поэтому разработчик вручную поддерживает инварианты безопасности.

Ограничения на pointer arithmetic позволяют компилятору рассуждать о границах объектов и оптимизировать код. Наличие адреса, численно похожего на нужный адрес, само по себе не означает, что этот указатель можно получить или разыменовать в рамках модели памяти Rust.

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

data содержит ровно три элемента u8. Указатель p указывает на первый элемент этой конкретной аллокации, поэтому смещение на четыре элемента выходит за её пределы более чем на разрешённую позицию one-past-the-end.

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

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

Для add смещение должно оставаться в пределах той же аллокации; разрешена также позиция сразу после последнего элемента. Эта позиция полезна для обозначения конца диапазона, но её нельзя разыменовывать. Кроме того, арифметика не должна приводить к переполнению ограничения размера isize.

Индекс нужно проверить до выполнения арифметики указателя:

fn read_at(data: &[u8], index: usize) -> Option<u8> { if index < data.len() { let p = data.as_ptr(); Some(unsafe { *p.add(index) }) } else { None } }

В этом примере проверка гарантирует, что add(index) указывает на существующий элемент, а не на one-past-позицию. В обычном коде предпочтительнее data.get(index): он сохраняет проверку границ в безопасном API и не требует unsafe.

wrapping_add не является способом разрешить выход за границы. Он позволяет вычислить указатель с оборачивающейся адресной арифметикой без немедленного требования in-bounds, но полученный указатель всё равно нельзя разыменовать, пока не доказана его принадлежность допустимому объекту. Это может быть уместно для отдельных алгоритмов сравнения или сканирования адресов, но не заменяет проверку диапазона.

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

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

В собственной реализации кольцевого буфера разработчик использовал ptr.add(head + count), предполагая, что указатель автоматически перейдёт к началу массива после его конца. При достижении конца буфера операция выходила за аллокацию, хотя последующее логическое значение индекса должно было быть корректным.

Рассматривались три варианта:

  • Использовать wrapping_add. Это не гарантирует корректность разыменования и скрывает ошибку в проверке границ.
  • Каждый раз вычислять указатель от начала после нормализации индекса. Это корректно, но требует явно поддерживать индекс в диапазоне и усложняет код.
  • Хранить логический индекс отдельно, сначала выполнять операцию по модулю длины буфера, затем вызывать add с нормализованным индексом. Этот вариант выбрали, потому что он сохраняет понятный инвариант: аргумент add всегда находится в диапазоне существующих элементов.

После этого чтение и запись выполнялись только при доказанном индексе index < capacity. Ошибки исчезли не из-за unsafe-блока, а благодаря явно поддержанному инварианту границ.

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

  1. Допустимо ли вычислить p.add(len) для массива длины len?

    Да, если указатель действительно относится к этому массиву и соблюдены остальные требования арифметики. Результат указывает на one-past-the-end и может использоваться как конечная граница диапазона. Однако чтение или запись через этот указатель недопустимы: за ним нет элемента этого объекта.

  2. Исправляет ли wrapping_add выход за границы?

    Нет. Он меняет правила самого вычисления указателя, но не создаёт объект по новому адресу и не даёт права на разыменование. Для безопасного чтения всё равно нужно доказать, что итоговый указатель указывает на подходящий живой объект и корректно выровнен.

  3. Можно ли вызвать offset_from для указателей разных аллокаций, если их адреса близки?

    Нет. offset_from требует, чтобы указатели относились к одной аллокации, причём допустимы указатели на элементы этой области или её one-past-позицию. Близость числовых адресов не заменяет принадлежность одному объекту; нарушение этого условия приводит к неопределённому поведению.