C против Rust: что выбрать для программирования аппаратных абстракций

Перевод | Автор оригинала: Dan Pittman

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

Иллюстрация инструментов

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

LanguageOriginOfficial descriptionOverview
C1972C - это язык программирования общего назначения, который отличается экономичностью выражения, современным потоком управления и структурами данных, а также богатым набором операторов. (Источник: Основы CS)C - это императивный язык, разработанный для относительно простой компиляции, обеспечивающей низкоуровневый доступ к памяти. (Источник: W3schools.in)
Rust2010Язык, позволяющий каждому создавать надежное и эффективное программное обеспечение (Источник: веб-сайт Rust)Rust - это многопарадигмальный системный язык программирования, ориентированный на безопасность, особенно на безопасный параллелизм. (Источник: Википедия)

Побитовая операция над значениями регистров в C

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

Например, представьте себе 8-битный регистр с тремя полями:

+----------+------+-----------+---------+
| (unused) | Kind | Interrupt | Enabled |
+----------+------+-----------+---------+
   5-7       2-4        1          0

Число под именем поля указывает биты, используемые этим полем в регистре. Чтобы включить этот регистр, вы должны записать значение 1, представленное в двоичном формате как 0000_0001, чтобы установить бит разрешенного поля. Однако часто у вас также есть существующая конфигурация в реестре, которую вы не хотите беспокоить. Допустим, вы хотите разрешить прерывания на устройстве, но при этом хотите, чтобы устройство оставалось включенным. Для этого вы должны объединить значение поля Interrupt со значением поля Enabled. Вы бы сделали это с помощью побитовых операций:

1 | (1 << 1)

Это дает вам двоичное значение 0000_0011 путем или-ввода 1 с 2, которое вы получаете, сдвигая 1 влево на 1. Вы можете записать это в свой регистр, оставив его включенным, но также разрешив прерывания.

Об этом много нужно держать в голове, особенно когда вы имеете дело с потенциально сотнями регистров для полной системы. На практике вы делаете это с помощью мнемоники, которая отслеживает положение поля в регистре и его ширину, т. Е. Какова его верхняя граница?

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

#define REG_ENABLED_FIELD(x) (x << 0) & 1
#define REG_INTERRUPT_FIELD(x) (x << 1) & 2
#define REG_KIND_FIELD(x) (x << 2) & (7 << 2)

Затем вы могли бы использовать их, чтобы абстрагироваться от вывода значения регистра, например:

void set_reg_val(reg* u8, val u8);
fn enable_reg_with_interrupt(reg* u8) {
    set_reg_val(reg, REG_ENABLED_FIELD(1) | REG_INTERRUPT_FIELD(1));
}

Это состояние искусства. Фактически, именно так в ядре Linux появляется основная масса драйверов.

Есть ли способ лучше? Считайте благом для безопасности и выразительности, если бы система типов была основана на исследованиях современных языков программирования. То есть, что вы могли бы сделать с более богатой и выразительной системой шрифтов, чтобы сделать этот процесс более безопасным и разумным?

Побитовые операции над значениями регистров в Rust

Продолжая регистр выше в качестве примера:

+----------+------+-----------+---------+
| (unused) | Kind | Interrupt | Enabled |
+----------+------+-----------+---------+
   5-7       2-4        1          0

Как вы могли бы выразить такую вещь в типах Rust?

Вы начнете аналогичным образом, определив константы для смещения каждого поля, т. Е. Насколько далеко он от младшего значащего бита, и его маски. Маска - это значение, двоичное представление которого может использоваться для обновления или чтения поля из регистра:

const ENABLED_MASK: u8 = 1;
const ENABLED_OFFSET: u8 = 0;

const INTERRUPT_MASK: u8 = 2;
const INTERRUPT_OFFSET: u8 = 1;

const KIND_MASK: u8 = 7 << 2;
const KIND_OFFSET: u8 = 2;

Затем вы объявите тип поля и выполните свои операции для преобразования заданного значения в его релевантное для позиции значение для использования внутри регистра:

struct Field {
    value: u8,
}

impl Field {
    fn new(mask: u8, offset: u8, val: u8) -> Self {
        Field {
            value: (val << offset) & mask,
        }
    }
}

Наконец, вы будете использовать тип Register, который охватывает числовой тип, соответствующий ширине вашего регистра. Регистр имеет функцию обновления, которая обновляет регистр с указанным полем:

struct Register(u8);

impl Register {
    fn update(&mut self, val: Field) {
        self.0 = self.0 | field.value;
    }
}

fn enable_register(&mut reg) {
    reg.update(Field::new(ENABLED_MASK, ENABLED_OFFSET, 1));
}

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

Реализация Rust для простоты использования

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

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

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

#[macro_use] extern crate typenum;

use core::marker::PhantomData;

use typenum::*;

// Now we'll add Mask and Offset to Field's type
struct Field<Mask: Unsigned, Offset: Unsigned> {
    value: u8,
    _mask: PhantomData<Mask>,
    _offset: PhantomData<Offset>,
}

// We can use type aliases to give meaningful names to
// our fields (and not have to remember their offsets and masks).
type RegEnabled = Field<U1, U0>;
type RegInterrupt = Field<U2, U1>;
type RegKind = Field<op!(U7 << U2), U2>;

Теперь, при повторном посещении конструктора Field, вы можете опустить параметры маски и смещения, потому что тип содержит эту информацию:

impl<Mask: Unsigned, Offset: Unsigned> Field<Mask, Offset> {
    fn new(val: u8) -> Self {
        Field {
            value: (val << Offset::U8) & Mask::U8,
            _mask: PhantomData,
            _offset: PhantomData,
        }
    }
}

// And to enable our register...
fn enable_register(&mut reg) {
    reg.update(RegEnabled::new(1));
}

Выглядит неплохо, но ... что произойдет, если вы сделаете ошибку относительно того, поместится ли данное значение в поле? Рассмотрим простую опечатку, в которой вы указываете 10 вместо 1:

fn enable_register(&mut reg) {
    reg.update(RegEnabled::new(10));
}

Каков ожидаемый результат в приведенном выше коде? Что ж, код установит этот бит включения в 0, потому что 10 & 1 = 0. Это прискорбно; было бы неплохо узнать, подходит ли значение, которое вы пытаетесь записать в поле, в это поле, прежде чем пытаться выполнить запись. На самом деле, я бы подумал об отключении старших битов ошибочного значения поля неопределенного поведения (вздох).

Использование Rust с учетом безопасности

Как в общем случае проверить, соответствует ли значение поля заданному положению? Больше цифр на уровне типов!

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

struct Field<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> {
    value: u8,
    _mask: PhantomData<Mask>,
    _offset: PhantomData<Offset>,
    _width: PhantomData<Width>,
}

type RegEnabled = Field<U1,U1, U0>;
type RegInterrupt = Field<U1, U2, U1>;
type RegKind = Field<U3, op!(U7 << U2), U2>;

impl<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> Field<Width, Mask, Offset> {
    fn new(val: u8) -> Option<Self> {
        if val <= (1 << Width::U8) - 1 {
            Some(Field {
                value: (val << Offset::U8) & Mask::U8,
                _mask: PhantomData,
                _offset: PhantomData,
                _width: PhantomData,
            })
        } else {
            None
        }
    }
}

Теперь вы можете создать поле, только если заданное значение подходит! В противном случае у вас есть None, что сигнализирует о том, что произошла ошибка, вместо того, чтобы отсекать старшие биты значения и молча записывать неожиданное значение.

Учтите, однако, что это вызовет ошибку во время выполнения. Однако мы знали заранее, какое значение хотим написать, помните? Учитывая это, мы можем научить компилятор полностью отклонять программу с недопустимым значением поля - нам не нужно ждать, пока мы ее запустим!

На этот раз вы добавите границу трейта (предложение where) к новой реализации new, называемой new_checked, которая запрашивает, чтобы входящее значение было меньше или равным максимально возможному значению, которое может содержать поле с заданной шириной:

struct Field<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> {
    value: u8,
    _mask: PhantomData<Mask>,
    _offset: PhantomData<Offset>,
    _width: PhantomData<Width>,
}

type RegEnabled = Field<U1, U1, U0>;
type RegInterrupt = Field<U1, U2, U1>;
type RegKind = Field<U3, op!(U7 << U2), U2>;

impl<Width: Unsigned, Mask: Unsigned, Offset: Unsigned> Field<Width, Mask, Offset> {
    const fn new_checked<V: Unsigned>() -> Self
    where
        V: IsLessOrEqual<op!((U1 << Width) - U1), Output = True>,
    {
        Field {
            value: (V::U8 << Offset::U8) & Mask::U8,
            _mask: PhantomData,
            _offset: PhantomData,
            _width: PhantomData,
        }
    }
}

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

fn enable_register(&mut reg) {
    reg.update(RegEnabled::new_checked::<U10>());
}
12 |     reg.update(RegEnabled::new_checked::<U10>());
   |                           ^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1`
   |
   = note: expected type `typenum::B0`
           found type `typenum::B1`

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

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

В самый раз с Rust: и безопасно, и доступно

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

Наша команда хотела что-то вроде регистров TockOS mmio, но такой, который бы генерировал безопасные реализации с наименьшим возможным объемом ручной транскрипции. В результате мы получили макрос, который генерирует необходимый шаблон для получения Tock-подобного API с проверкой границ на основе типов. Чтобы использовать его, запишите некоторую информацию о регистре, его полях, их ширине и смещениях, а также необязательных значениях, подобных перечислению (если вы хотите придать «смысл» возможным значениям, которые может иметь поле):

register! {
    // The register's name
    Status,
    // The type which represents the whole register.
    u8,
    // The register's mode, ReadOnly, ReadWrite, or WriteOnly.
    RW,
    // And the fields in this register.
    Fields [
        On    WIDTH(U1) OFFSET(U0),
        Dead  WIDTH(U1) OFFSET(U1),
        Color WIDTH(U3) OFFSET(U2) [
            Red    = U1,
            Blue   = U2,
            Green  = U3,
            Yellow = U4
        ]
    ]
}

Отсюда вы можете сгенерировать типы регистров и полей, как в предыдущем примере, где индексы - ширина, маска и смещение - являются производными от значений, вводимых в разделах WIDTH и OFFSET определения поля. Также обратите внимание, что все эти числа являются типичными; они собираются войти прямо в определения вашего поля!

Сгенерированный код предоставляет пространства имен для регистров и связанных с ними полей через имя, данное регистру и полям. Это полный рот; вот как это выглядит:

mod Status {
    struct Register(u8);
    mod On {
        struct Field; // There is of course more to this definition
    }
    mod Dead {
        struct Field;
    }
    mod Color {
        struct Field;
        pub const Red: Field = Field::<U1>new();
        // &c.
    }
}

Сгенерированный API содержит номинально ожидаемые примитивы чтения и записи для получения необработанного значения регистра, но у него также есть способы получить значение отдельного поля, выполнить коллективные действия и выяснить, является ли какой-либо (или весь) набор битов набор. Вы можете прочитать документацию по полностью сгенерированному API.

Удар по шинам

Как выглядит использование этих определений для реального устройства? Будет ли код завален параметрами типа, скрывающими из виду реальную логику?

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

Вот пример блока регистров UART. Я пропущу объявление самих регистров, так как это было бы слишком много, чтобы включать его здесь. Вместо этого он начинается с «блока» регистров, а затем помогает компилятору узнать, как искать в регистрах указатель на заголовок блока. Мы делаем это, реализуя Deref и DerefMut:

#[repr(C)]
pub struct UartBlock {
    rx: UartRX::Register,
    _padding1: [u32; 15],
    tx: UartTX::Register,
    _padding2: [u32; 15],
    control1: UartControl1::Register,
}

pub struct Regs {
    addr: usize,
}

impl Deref for Regs {
    type Target = UartBlock;

    fn deref(&self) -> &UartBlock {
        unsafe { &*(self.addr as *const UartBlock) }
    }
}

impl DerefMut for Regs {
    fn deref_mut(&mut self) -> &mut UartBlock {
        unsafe { &mut *(self.addr as *mut UartBlock) }
    }
}

Как только это будет сделано, использовать эти регистры так же просто, как read() и modify():

fn main() {
    // A pretend register block.
    let mut x = [0_u32; 33];

    let mut regs = Regs {
        // Some shenanigans to get at `x` as though it were a
        // pointer. Normally you'd be given some address like
        // `0xDEADBEEF` over which you'd instantiate a `Regs`.
        addr: &mut x as *mut [u32; 33] as usize,
    };

    assert_eq!(regs.rx.read(), 0);

    regs.control1
        .modify(UartControl1::Enable::Set + UartControl1::RecvReadyInterrupt::Set);

    // The first bit and the 10th bit should be set.
    assert_eq!(regs.control1.read(), 0b_10_0000_0001);
}

Когда мы работаем со значениями времени выполнения, мы используем Option, как мы видели ранее. Здесь я использую unwrap, но в реальной программе с неизвестными входами вы, вероятно, захотите проверить, что вы получили Some от этого нового вызова: 1,2

fn main() {
    // A pretend register block.
    let mut x = [0_u32; 33];

    let mut regs = Regs {
        // Some shenanigans to get at `x` as though it were a
        // pointer. Normally you'd be given some address like
        // `0xDEADBEEF` over which you'd instantiate a `Regs`.
        addr: &mut x as *mut [u32; 33] as usize,
    };

    let input = regs.rx.get_field(UartRX::Data::Field::Read).unwrap();
    regs.tx.modify(UartTX::Data::Field::new(input).unwrap());
}

Условия сбоя декодирования

В зависимости от вашего личного болевого порога вы могли заметить, что ошибки почти неразборчивы. Взгляните на не очень тонкое напоминание о том, о чем я говорю:

error[E0271]: type mismatch resolving `<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>, typenum::B0> as typenum::IsLessOrEqual<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UInt<typenum::UTerm, typenum::B1>, typenum::B0>, typenum::B1>, typenum::B0>>>::Output == typenum::B1`
  --> src/main.rs:12:5
   |
12 |     less_than_ten::<U20>();
   |     ^^^^^^^^^^^^^^^^^^^^ expected struct `typenum::B0`, found struct `typenum::B1`
   |
   = note: expected type `typenum::B0`
       found type `typenum::B1`

Ожидаемая часть typenum::B0, найденная в части typenum::B1, имеет смысл, но что за ерунда typenum::UInt <typenum::UInt, typenum::UInt…? Ну, typenum представляет числа как двоичные cons-ячейки! Подобные ошибки затрудняют, особенно когда у вас есть несколько из этих чисел уровня типа, ограниченных узкими кругами, чтобы узнать, о каком числе идет речь. Если, конечно, вы не привыкли переводить барочные двоичные представления в десятичные.

После того, как U100-й раз попытался расшифровать какой-либо смысл этого беспорядка, товарищ по команде сошел с ума и больше не собирался его выносить и сделал небольшую утилиту tnfilt, чтобы разобрать смысл из страданий, которые представляют собой двоичные cons-ячейки с пространством имен . tnfilt берет обозначение в стиле cons-ячейки и заменяет его разумными десятичными числами. Мы думаем, что другие столкнутся с аналогичными трудностями, поэтому поделились tnfilt. Вы можете использовать это так:

$ cargo build 2>&1 | tnfilt

Он преобразует вывод выше во что-то вроде этого:

error[E0271]: type mismatch resolving `<U20 as typenum::IsLessOrEqual<U10>>::Output == typenum::B1`

Теперь это имеет смысл!

В заключении

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


  1. Технически чтение из поля регистра, по определению, даст значение только в установленных пределах, но никто из нас не живет в чистом мире, и вы никогда не знаете, что произойдет, когда в игру вступят внешние системы. Вы здесь по велению богов оборудования, поэтому вместо того, чтобы принуждать вас к ситуации «возможной паники», это дает вам возможность справиться с делом «Этого никогда не должно случиться».

  2. get_field выглядит немного странно. В частности, я смотрю на часть Field::Read. Поле - это тип, и вам нужен экземпляр этого типа для передачи в get_field. Более чистый API может выглядеть примерно так:

regs.rx.get_field::<UartRx::Data::Field>();

Но помните, что поле - это синоним типа, который имеет фиксированные индексы для ширины, смещения и т.д. Чтобы иметь возможность параметризовать get_field таким образом, вам потребуются типы более высокого порядка.