Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Советы для разработчиков на embedded C

Эта глава собирает различные советы, которые могут быть полезны опытным разработчикам на embedded C, желающим начать писать на Rust. Особое внимание будет уделено тому, как вещи, к которым вы уже привыкли в C, отличаются в Rust.

Препроцессор

В embedded C очень распространено использование препроцессора для различных целей, таких как:

  • Выбор блоков кода во время компиляции с помощью #ifdef
  • Размеры массивов и вычисления во время компиляции
  • Макросы для упрощения общих шаблонов (чтобы избежать накладных расходов на вызов функций)

В Rust нет препроцессора, поэтому многие из этих случаев использования решаются по-другому. В остальной части этого раздела мы рассмотрим различные альтернативы использованию препроцессора.

Выбор кода во время компиляции

Наиболее близким аналогом #ifdef ... #endif в Rust являются функции Cargo. Они немного более формальны, чем препроцессор C: все возможные функции явно перечислены для каждого крейта и могут быть либо включены, либо выключены. Функции включаются при перечислении крейта как зависимости и являются аддитивными: если любой крейт в вашем дереве зависимостей включает функцию для другого крейта, эта функция будет включена для всех пользователей этого крейта.

Например, у вас может быть крейт, предоставляющий библиотеку примитивов обработки сигналов. Каждый из них может требовать дополнительного времени на компиляцию или объявлять большую таблицу констант, которую вы хотите избежать. Вы можете объявить функцию 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;
}

Аналогично вы можете включать блоки кода только если функция не включена или если любая комбинация функций включена или не включена.

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

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

Размеры и вычисления во время компиляции

Rust поддерживает const fn, функции, которые гарантированно вычисляемы во время компиляции и поэтому могут использоваться там, где требуются константы, например, в размере массивов. Это можно использовать вместе с функциями, упомянутыми выше, например:

#![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: макросы по примеру и процедурные макросы. Первые проще и наиболее распространены; они выглядят как вызовы функций и могут расширяться в полное выражение, утверждение, элемент или шаблон. Процедурные макросы более сложны, но позволяют чрезвычайно мощные дополнения к языку Rust: они могут преобразовывать произвольный синтаксис Rust в новый синтаксис Rust.

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

Как и с функциями Cargo, стоит подумать, нужен ли вам макрос вообще. Во многих случаях обычная функция проще для понимания и будет встроена в тот же код, что и макрос. Атрибуты #[inline] и #[inline(always)] attributes дают вам дополнительный контроль над этим процессом, хотя здесь тоже следует проявлять осторожность — компилятор автоматически встраивает функции из того же крейта, где это уместно, так что принуждение к этому неуместно может привести к снижению производительности.

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

Система сборки

Большинство крейтов Rust собираются с использованием Cargo (хотя это не обязательно). Это решает многие сложные проблемы традиционных систем сборки. Однако вы можете захотеть настроить процесс сборки. Cargo предоставляет скрипты build.rs для этой цели. Это скрипты на Rust, которые могут взаимодействовать с системой сборки Cargo по мере необходимости.

Общие случаи использования скриптов сборки включают:

  • предоставление информации во время сборки, например, статическое встраивание даты сборки или хэша коммита Git в исполняемый файл
  • генерация скриптов линковки во время сборки в зависимости от выбранных функций или другой логики
  • изменение конфигурации сборки 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++) {
    arr[i] = i;
}

В Rust предпочтительным подходом является использование итераторов, которые часто более безопасны и выразительны. Например:

#![allow(unused)]
fn main() {
let mut arr = [0i16; 16];
for (i, v) in arr.iter_mut().enumerate() {
    *v = i as i16;
}
}

Итераторы в Rust позволяют избежать ошибок, связанных с выходом за границы массива, и предоставляют мощные методы для обработки данных. Однако в контексте embedded, где производительность критична, иногда может потребоваться прямой доступ к массиву, как в C. В таких случаях вы можете использовать индексацию, но будьте осторожны с проверкой границ:

#![allow(unused)]
fn main() {
let mut arr = [0i16; 16];
for i in 0..arr.len() {
    arr[i] = i as i16;
}
}

Указатели

В C указатели — это фундаментальная часть языка, используемая для прямого доступа к памяти, особенно в embedded-разработке. В Rust указатели существуют, но их использование ограничено из-за системы владения и заимствования. В Rust есть два типа сырых указателей: *const T и *mut T. Они похожи на указатели в C в том смысле, что их можно разыменовать для доступа к базовым значениям, но они являются ключевой частью системы владения Rust: Rust строго обеспечивает, что у вас может быть только одна изменяемая ссылка или несколько неизменяемых ссылок на одно и то же значение в любой момент времени.

На практике это означает, что вы должны быть более осторожны с тем, нужен ли вам изменяемый доступ к данным: если в C по умолчанию все изменяемо и вы должны явно указывать const, в Rust наоборот.

Одна ситуация, когда вы все еще можете использовать сырые указатели, — это прямое взаимодействие с аппаратным обеспечением (например, запись указателя на буфер в регистр DMA), и они также используются под капотом во всех крейтах доступа к периферийным устройствам, чтобы позволить вам читать и записывать регистры с отображением в память.

Волатильный доступ

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

В Rust вместо пометки переменной как volatile мы используем специальные методы для выполнения волатильного доступа: core::ptr::read_volatile и core::ptr::write_volatile. Эти методы принимают *const T или *mut T (сырые указатели, как обсуждалось выше) и выполняют волатильное чтение или запись.

Например, в C вы могли бы написать:

volatile bool signalled = false;

void ISR() {
    // Сигнализируем, что прерывание произошло
    signalled = true;
}

void driver() {
    while(true) {
        // Спим до сигнала
        while(!signalled) { WFI(); }
        // Сбрасываем индикатор сигнала
        signalled = false;
        // Выполняем задачу, ожидающую прерывания
        run_task();
    }
}

Эквивалент в Rust использовал бы волатильные методы для каждого доступа:

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, поскольку это небезопасные функции. Ответственность за обеспечение безопасного использования лежит на программисте: подробности см. в документации методов.

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

Упакованные и выровненные типы

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

В Rust это контролируется атрибутом repr для структуры или объединения. Представление по умолчанию не предоставляет гарантий компоновки, поэтому его не следует использовать для кода, взаимодействующего с аппаратным обеспечением или C. Компилятор может переупорядочить члены структуры или вставить заполнение, и поведение может измениться в будущих версиях 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)).

Для дополнительной информации о компоновке типов обратитесь к главе компоновка типов справочника Rust.

Другие ресурсы