Советы для разработчиков встраиваемого C
В этой главе собраны различные советы, которые могут пригодиться опытным разработчикам встраиваемого C, которые хотят начать писать на Rust. В особенности здесь подчёркивается, чем на Rust отличаются вещи, к которым вы, возможно, уже привыкли в C.
Препроцессор
Во встраиваемом C очень распространено использование препроцессора для самых разных целей, например:
- выбор блоков кода на этапе компиляции с помощью
#ifdef; - размеры массивов и вычисления на этапе компиляции;
- макросы для упрощения типовых конструкций (чтобы избежать накладных расходов на вызов функции).
В Rust препроцессора нет, поэтому многие из этих задач решаются иначе. В остальной части этого раздела мы рассмотрим различные альтернативы использованию препроцессора.
Выбор кода на этапе компиляции
Ближайший аналог #ifdef ... #endif в Rust — это feature-флаги Cargo (features). Они немного формальнее, чем препроцессор C: все возможные feature явно перечисляются для каждого крейта и могут быть только включены или выключены. Feature включаются, когда вы указываете крейт как зависимость, и они аддитивны: если какой-либо крейт в дереве зависимостей включает feature другого крейта, этот feature будет включён для всех пользователей этого крейта.
Например, у вас может быть крейт, который предоставляет библиотеку примитивов обработки сигналов. Каждый из них может требовать дополнительного времени на компиляцию или объявлять большую таблицу констант, которой вы хотели бы избежать. Вы можете объявить feature Cargo для каждого компонента в своём Cargo.toml:
[features]
FIR = []
IIR = []
Затем в коде используйте #[cfg(feature="FIR")], чтобы управлять тем, что включается.
#![allow(unused)]
fn main() {
/// В вашем верхнеуровневом lib.rs
#[cfg(feature="FIR")]
pub mod fir;
#[cfg(feature="IIR")]
pub mod iir;
}
Аналогичным образом можно включать блоки кода, только если feature не включён, или при любом сочетании включённых и выключенных feature.
Кроме того, Rust предоставляет ряд автоматически задаваемых условий, которые можно использовать, например target_arch, чтобы выбирать разный код в зависимости от архитектуры. Полные подробности о поддержке условной компиляции см. в главе условная компиляция справочника Rust.
Условная компиляция применяется только к следующему оператору или блоку. Если блок нельзя использовать в текущей области видимости, атрибут cfg придётся указывать несколько раз. Стоит отметить, что чаще всего лучше просто включить весь код и позволить компилятору удалить мёртвый код при оптимизации: это проще для вас и ваших пользователей, и, как правило, компилятор хорошо справляется с удалением неиспользуемого кода.
Размеры и вычисления на этапе компиляции
Rust поддерживает const fn — функции, которые гарантированно вычисляются на этапе компиляции и поэтому могут использоваться там, где требуются константы, например для размера массивов. Это можно сочетать с описанными выше feature, например:
#![allow(unused)]
fn main() {
const fn array_size() -> usize {
#[cfg(feature="use_more_ram")]
{ 1024 }
#[cfg(not(feature="use_more_ram"))]
{ 128 }
}
static BUF: [u32; array_size()] = [0u32; array_size()];
}
Эти возможности появились в стабильном Rust начиная с версии 1.31, поэтому документации по ним пока мало. Функциональность, доступная для const fn, на момент написания также очень ограничена; ожидается, что в будущих версиях Rust круг допустимого в const fn расширится.
Макросы
Rust предоставляет чрезвычайно мощную систему макросов. Препроцессор C работает почти непосредственно с текстом исходного кода, тогда как система макросов Rust работает на более высоком уровне. Существует два вида макросов Rust: макросы по примеру (macros by example) и процедурные макросы. Первые проще и распространённее: они выглядят как вызовы функций и могут раскрываться в целое выражение, оператор, элемент или шаблон. Процедурные макросы сложнее, но позволяют очень мощно расширять язык Rust: они могут преобразовывать произвольный синтаксис Rust в новый синтаксис Rust.
В общем случае там, где вы могли бы использовать макрос препроцессора C, стоит проверить, справится ли с задачей макрос по примеру. Такие макросы можно определить в своём крейте, легко использовать внутри собственного крейта или экспортировать для других пользователей. Имейте в виду, что, поскольку они должны раскрываться в целые выражения, операторы, элементы или шаблоны, некоторые сценарии использования макросов препроцессора C не будут работать; например, макрос, который раскрывается в часть имени переменной, или неполный набор элементов в списке.
Как и в случае с feature-флагами Cargo, стоит подумать, нужен ли вам вообще макрос. Во многих случаях обычная функция проще для понимания и будет встроена (inlined) в тот же код, что и макрос. Атрибуты #[inline] и #[inline(always)] дают дополнительный контроль над этим процессом, но и здесь нужна осторожность: компилятор автоматически встраивает функции из того же крейта там, где это уместно, поэтому принудительное встраивание не к месту может привести к снижению производительности.
Подробно объяснять всю систему макросов Rust выходит за рамки этой страницы с советами, поэтому мы рекомендуем обратиться к документации Rust за полными сведениями.
Система сборки
Большинство крейтов Rust собираются с помощью Cargo (хотя это не обязательно). Cargo решает многие сложные проблемы традиционных систем сборки. Однако вы можете захотеть настроить процесс сборки. Для этого Cargo предоставляет скрипты build.rs. Это скрипты на Rust, которые при необходимости могут взаимодействовать с системой сборки Cargo.
Типичные сценарии использования скриптов сборки:
- предоставление информации, известной на этапе сборки, например статическое встраивание даты сборки или хеша коммита Git в исполняемый файл;
- генерация скриптов компоновщика на этапе сборки в зависимости от выбранных feature-флагов или другой логики;
- изменение конфигурации сборки Cargo;
- добавление дополнительных статических библиотек для компоновки.
На данный момент постсборочные скрипты не поддерживаются — то есть те, которые традиционно использовались для таких задач, как автоматическое создание бинарных файлов из объектных файлов сборки или вывод информации о сборке.
Кросс-компиляция
Использование Cargo в качестве системы сборки также упрощает кросс-компиляцию. В большинстве случаев достаточно указать Cargo --target thumbv6m-none-eabi и найти подходящий исполняемый файл в target/thumbv6m-none-eabi/debug/myapp.
Для платформ, которые Rust не поддерживает нативно, вам самостоятельно понадобится собрать libcore для этой целевой платформы. Для таких платформ можно использовать Xargo как замену Cargo, которая автоматически собирает libcore за вас.
Итераторы вместо доступа по индексу
В C вы, вероятно, привыкли обращаться к массивам напрямую по индексу:
int16_t arr[16];
int i;
for(i=0; i<sizeof(arr)/sizeof(arr[0]); i++) {
process(arr[i]);
}
В Rust это антипаттерн: доступ по индексу может быть медленнее (так как требует проверки границ) и может помешать различным оптимизациям компилятора. Это важное различие, поэтому стоит повторить: Rust проверяет выход за границы при ручной индексации массива, чтобы гарантировать безопасность памяти, тогда как C спокойно позволит индексировать за пределами массива.
Вместо этого используйте итераторы:
let arr = [0u16; 16];
for element in arr.iter() {
process(*element);
}
Итераторы предоставляют мощный набор функциональности, который в C пришлось бы реализовывать вручную: цепочки, объединение (zip), нумерацию (enumerate), поиск минимума или максимума, суммирование и многое другое. Методы итераторов также можно объединять в цепочки, что даёт очень читаемый код обработки данных.
Подробнее см. Итераторы в книге и документацию по итераторам.
Ссылки и указатели
В Rust указатели (называемые сырыми указателями) существуют, но используются только в особых случаях, поскольку разыменование их всегда считается unsafe — Rust не может обеспечить свои обычные гарантии относительно того, что находится за указателем.
В большинстве случаев вместо них мы используем ссылки, обозначаемые символом &, или изменяемые ссылки, обозначаемые &mut. Ссылки ведут себя похоже на указатели в том смысле, что их можно разыменовать, чтобы получить доступ к базовым значениям, но они являются ключевой частью системы владения Rust: Rust строго следит за тем, что в любой момент времени для одного и того же значения может существовать только одна изменяемая ссылка или несколько неизменяемых ссылок.
На практике это означает, что нужно внимательнее относиться к тому, нужен ли вам изменяемый доступ к данным: тогда как в C по умолчанию всё изменяемо и нужно явно указывать const, в Rust верно обратное.
Одна из ситуаций, в которых вы всё же можете использовать сырые указатели, — прямое взаимодействие с аппаратурой (например, запись указателя на буфер в регистр периферии DMA). Кроме того, они используются «под капотом» всех крейтов доступа к периферии, чтобы позволить читать и записывать регистры, отображённые в память.
Летучий (volatile) доступ
В C отдельные переменные можно пометить как volatile, что указывает компилятору, что значение переменной может измениться между обращениями. Volatile-переменные обычно используются во встраиваемом контексте для регистров, отображённых в память.
В Rust вместо пометки переменной как volatile мы используем специальные методы для volatile-доступа: core::ptr::read_volatile и core::ptr::write_volatile. Эти методы принимают *const T или *mut T (сырые указатели, о которых говорилось выше) и выполняют volatile-чтение или запись.
Например, в C можно написать:
volatile bool signalled = false;
void ISR() {
// Сигнализируем, что прерывание произошло
signalled = true;
}
void driver() {
while(true) {
// Спим, пока не получим сигнал
while(!signalled) { WFI(); }
// Сбрасываем индикатор сигнала
signalled = false;
// Выполняем задачу, которая ждала прерывания
run_task();
}
}
Эквивалент на Rust использовал бы volatile-методы при каждом обращении:
static mut SIGNALLED: bool = false;
#[interrupt]
fn ISR() {
// Сигнализируем, что прерывание произошло
// (В реальном коде стоит рассмотреть примитив более высокого уровня,
// например атомарный тип.)
unsafe { core::ptr::write_volatile(&mut SIGNALLED, true) };
}
fn driver() {
loop {
// Спим, пока не получим сигнал
while unsafe { !core::ptr::read_volatile(&SIGNALLED) } {}
// Сбрасываем индикатор сигнала
unsafe { core::ptr::write_volatile(&mut SIGNALLED, false) };
// Выполняем задачу, которая ждала прерывания
run_task();
}
}
В этом примере кода стоит отметить несколько моментов:
- Мы можем передать
&mut SIGNALLEDв функцию, требующую*mut T, поскольку&mut Tавтоматически преобразуется в*mut T(то же самое верно и для*const T); - Нам нужны блоки
unsafeдля методовread_volatile/write_volatile, так как этоunsafe-функции. Программист несёт ответственность за безопасное использование: подробности см. в документации к этим методам.
Редко приходится вызывать эти функции напрямую в своём коде, так как обычно о них позаботятся библиотеки более высокого уровня. Для регистров, отображённых в память, крейты доступа к периферии реализуют volatile-доступ автоматически, а для примитивов конкурентности есть более подходящие абстракции (см. главу о конкурентности).
Упакованные и выровненные типы
Во встраиваемом C часто требуется сообщить компилятору, что переменная должна иметь определённое выравнивание или что структура должна быть упакованной (packed), а не выровненной, обычно чтобы соответствовать требованиям конкретной аппаратуры или протокола.
В Rust это управляется атрибутом repr для структуры или объединения (union). Представление по умолчанию не даёт никаких гарантий о раскладке, поэтому его не следует использовать для кода, который взаимодействует с аппаратурой или C. Компилятор может переупорядочивать поля структуры или вставлять дополнительные байты заполнения (padding), и поведение может измениться в будущих версиях Rust.
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7ffecb3511d0 0x7ffecb3511d4 0x7ffecb3511d2
// Обратите внимание, что порядок изменён на x, z, y для лучшей упаковки.
Чтобы гарантировать раскладку, совместимую с C, используйте repr(C):
#[repr(C)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
}
// 0x7fffd0d84c60 0x7fffd0d84c62 0x7fffd0d84c64
// Порядок сохранён, и раскладка не будет меняться со временем.
// `z` выровнен по двум байтам, поэтому между `y` и `z` есть один байт заполнения.
Чтобы гарантировать упакованное представление, используйте repr(packed):
#[repr(packed)]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
// Ссылки всегда должны быть выровненными, поэтому чтобы проверить адреса
// полей структуры, мы используем `std::ptr::addr_of!()`, чтобы получить сырой указатель,
// вместо того чтобы просто выводить `&v.x`.
let px = std::ptr::addr_of!(v.x);
let py = std::ptr::addr_of!(v.y);
let pz = std::ptr::addr_of!(v.z);
println!("{:p} {:p} {:p}", px, py, pz);
}
// 0x7ffd33598490 0x7ffd33598492 0x7ffd33598493
// Между `y` и `z` не вставлено заполнение, поэтому теперь `z` не выровнено.
Обратите внимание, что использование repr(packed) также устанавливает выравнивание типа равным 1.
Наконец, чтобы задать конкретное выравнивание, используйте repr(align(n)), где n — количество байтов, по которым нужно выравнивать (и оно должно быть степенью двойки):
#[repr(C)]
#[repr(align(4096))]
struct Foo {
x: u16,
y: u8,
z: u16,
}
fn main() {
let v = Foo { x: 0, y: 0, z: 0 };
let u = Foo { x: 0, y: 0, z: 0 };
println!("{:p} {:p} {:p}", &v.x, &v.y, &v.z);
println!("{:p} {:p} {:p}", &u.x, &u.y, &u.z);
}
// 0x7ffec909a000 0x7ffec909a002 0x7ffec909a004
// 0x7ffec909b000 0x7ffec909b002 0x7ffec909b004
// Два экземпляра `u` и `v` размещены на границах 4096 байт,
// о чём свидетельствуют `000` в конце их адресов.
Обратите внимание, что repr(C) можно комбинировать с repr(align(n)), чтобы получить выровненную раскладку, совместимую с C. Нельзя комбинировать repr(align(n)) с repr(packed), так как repr(packed) устанавливает выравнивание равным 1. Также repr(packed)-тип не может содержать тип с repr(align(n)).
Подробнее о раскладке типов см. главу type layout справочника Rust.