HAL как набор трейтов
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
HAL как набор трейтов
В прошлом уроке периферия появилась в виде регистров по адресам. Работать с ней так, дёргая байты по
0xFF00, можно, но больно: код прошивки утонет в магических смещениях, и его не перенести на другое железо. Поднимемся на уровень выше. Опишем, что умеет ножка, таймер, порт, набором трейтов, а как именно это сделано, спрячем под них. Это и есть HAL.
Зачем нужен слой абстракции
HAL отвечает на вопрос «что умеет устройство», умалчивая про «как оно сделано». Выходная ножка умеет подняться и опуститься. Входная умеет сообщить уровень. Порт умеет отправить и принять байт. Опишем это маленькими трейтами; за образец берём настоящий embedded-hal, экосистемный стандарт драйверов в Rust.
/// Выходная ножка: её можно поднять, опустить или задать уровнем.
pub trait OutputPin {
fn set_high(&mut self);
fn set_low(&mut self);
fn set_state(&mut self, high: bool) {
if high { self.set_high(); } else { self.set_low(); }
}
}
/// Входная ножка: с неё можно прочитать уровень.
pub trait InputPin {
fn is_high(&self) -> bool;
fn is_low(&self) -> bool { !self.is_high() }
}
/// Задержка по тактам модельного времени.
pub trait Delay {
fn delay_ticks(&mut self, ticks: u32);
}
/// Последовательный порт: побайтовый ввод-вывод.
pub trait Serial {
fn write_byte(&mut self, byte: u8);
fn read_byte(&mut self) -> Option<u8>;
/// Отправить строку побайтно. Метод по умолчанию: реализатору достаточно
/// `write_byte`, а удобство строки идёт бесплатно поверх него.
fn write_str(&mut self, text: &str) {
for byte in text.bytes() {
self.write_byte(byte);
}
}
}
Настоящий embedded-hal делает эти методы возвращающими Result, чтобы ловить сбои шины. Мы упростили до версий, которые не могут вернуть ошибку: суть абстракции та же, а сигнатуры короче. Это сознательное упрощение. Главный приём здесь это write_str: трейт даёт обязательный минимум (write_byte, read_byte) и методы поверх него с телом по умолчанию, как read16 у шины и set_state у ножки. Реализатор пишет два метода, а пользуется всеми.
Реализация поверх эмулируемого железа
Трейт это контракт; нужна реализация. Свяжем OutputPin с конкретной ножкой нашего эмулируемого порта GPIO. Ножка держит разделяемый порт (через Rc<RefCell<...>>, потому что и драйвер, и шина смотрят в один порт) и номер.
/// Одна ножка порта GPIO как выход.
pub struct GpioOutput {
port: Rc<RefCell<GpioPort>>,
pin: u8,
}
impl GpioOutput {
pub fn new(port: Rc<RefCell<GpioPort>>, pin: u8) -> Self {
GpioOutput { port, pin }
}
fn mask(&self) -> u8 {
1 << (self.pin & 0x7)
}
}
impl OutputPin for GpioOutput {
fn set_high(&mut self) {
let mut port = self.port.borrow_mut();
let value = port.read(0x0) | self.mask(); // выставить бит ножки
port.write(0x0, value);
}
fn set_low(&mut self) {
let mut port = self.port.borrow_mut();
let value = port.read(0x0) & !self.mask(); // сбросить бит ножки
port.write(0x0, value);
}
}
Заметь: вся возня с битовой маской и регистром порта спрятана здесь, в реализации трейта. Снаружи останется чистое set_high(). GpioPort это устройство из урока 39: регистр 0x0 это уровни на выходных ножках, бит на ножку. Rc<RefCell<GpioPort>> нужен, потому что в один порт смотрят двое: наша ножка-драйвер и шина процессора. Rc даёт разделяемое владение, RefCell разрешает менять содержимое через общую ссылку.
Входная ножка зеркальна выходной: она только читает регистр 0x1 (уровни на входах) и проверяет свой бит.
/// Одна ножка порта GPIO как вход.
pub struct GpioInput {
port: Rc<RefCell<GpioPort>>,
pin: u8,
}
impl GpioInput {
pub fn new(port: Rc<RefCell<GpioPort>>, pin: u8) -> Self {
GpioInput { port, pin }
}
}
impl InputPin for GpioInput {
fn is_high(&self) -> bool {
let mut port = self.port.borrow_mut();
port.read(0x1) & (1 << (self.pin & 0x7)) != 0
}
}
Последовательный порт поверх UART из урока 39 устроен так же: write_byte пишет в регистр данных 0x0, read_byte сначала смотрит в STATUS (0x1), есть ли принятый байт (бит RX_READY), и только тогда забирает его.
/// Последовательный порт поверх эмулируемого UART.
pub struct UartSerial {
uart: Rc<RefCell<Uart>>,
}
impl UartSerial {
pub fn new(uart: Rc<RefCell<Uart>>) -> Self {
UartSerial { uart }
}
}
impl Serial for UartSerial {
fn write_byte(&mut self, byte: u8) {
self.uart.borrow_mut().write(0x0, byte);
}
fn read_byte(&mut self) -> Option<u8> {
let mut uart = self.uart.borrow_mut();
// Бит1 STATUS это RX_READY.
if uart.read(0x1) & 0b10 != 0 {
Some(uart.read(0x0))
} else {
None
}
}
}
Остаётся задержка. Настоящий HAL крутил бы аппаратный таймер; нам для примеров и тестов хватит счётчика, который просто копит протиканные такты. write_str мы получили бесплатно от трейта, а CountingDelay это весь нужный нам Delay.
/// Простая задержка: копит число протиканных тактов модельного времени.
#[derive(Default)]
pub struct CountingDelay {
elapsed: u64,
}
impl CountingDelay {
pub fn new() -> Self {
CountingDelay::default()
}
/// Сколько тактов всего отсчитано.
pub fn elapsed(&self) -> u64 {
self.elapsed
}
}
impl Delay for CountingDelay {
fn delay_ticks(&mut self, ticks: u32) {
self.elapsed += ticks as u64;
}
}
Драйвер не знает, что под ним
Теперь самое важное. Драйвер мигалки обобщён по трейтам: он принимает что угодно, что реализует OutputPin и Delay. Он знает, в каком порядке дёргать ножку, но не знает, что это за ножка.
/// Мигалка: гоняет светодиод включить-подождать-выключить-подождать.
pub struct Blinker<P, D> {
led: P,
delay: D,
}
impl<P: OutputPin, D: Delay> Blinker<P, D> {
pub fn new(led: P, delay: D) -> Self {
Blinker { led, delay }
}
/// Мигнуть times раз, держа каждое состояние ticks тактов.
pub fn blink(&mut self, times: u32, ticks: u32) {
for _ in 0..times {
self.led.set_high();
self.delay.delay_ticks(ticks);
self.led.set_low();
self.delay.delay_ticks(ticks);
}
}
/// Вернуть HAL-ресурсы обратно (паттерн «освобождение ресурса»).
pub fn release(self) -> (P, D) {
(self.led, self.delay)
}
}
release забирает self по значению и отдаёт ножку и задержку наружу. Так драйвер не держит ресурсы вечно: попользовался и вернул, например чтобы заглянуть в CountingDelay::elapsed или передать ножку другому драйверу. Это типичный для Rust приём «драйвер владеет, пока работает, и отпускает на выходе».
Blinker<P, D> это дженерик: компилятор породит отдельную версию Blinker под каждую конкретную пару типов. Один и тот же драйвер скомпилируется и для эмулируемой ножки, и для тестовой заглушки, и для реального чипа, каждый раз превращаясь в прямой код без накладных расходов.
Та же политика, сменный механизм
Чтобы убедиться, что драйвер и правда не привязан к железу, дадим ему ножку-заглушку: она реализует тот же OutputPin, но вместо регистра порта просто записывает каждый уровень в список. Драйвер не заметит разницы.
/// Тестовая ножка: тот же трейт, но пишет уровни в список вместо железа.
struct RecordingPin {
seq: Vec<bool>,
}
impl OutputPin for RecordingPin {
fn set_high(&mut self) { self.seq.push(true); }
fn set_low(&mut self) { self.seq.push(false); }
}
В виджете ниже Blinker гоняет именно такую заглушку прямо в браузере. Покрути число миганий и длительность, запусти драйвер и проиграй записанную последовательность уровней. Это настоящий драйвер из кода выше, просто его ножка пишет в список, а не в порт.
Тот же blink(times, ticks) без единой правки дал бы ту же последовательность поверх эмулируемого GPIO (там уровни ушли бы в регистр порта и зажгли бы ножку) и поверх реального чипа (там они дёрнули бы физический вывод). Меняется механизм, политика остаётся.
Ещё два драйвера на тех же трейтах
Blinker это драйвер на одном выходе. Но драйверу не обязательно быть структурой: чаще это просто обобщённая функция. Зеркало повторяет на светодиоде уровень кнопки, то есть работает сразу с входом и выходом. Логгер шлёт строку в порт. Оба ничего не знают про конкретное железо, они говорят только трейтами.
/// Зеркало: светодиод повторяет состояние кнопки. Драйвер сразу на входе и выходе.
pub fn mirror_button<I: InputPin, O: OutputPin>(button: &I, led: &mut O) {
led.set_state(button.is_high());
}
/// Логгер строки в последовательный порт с переводом строки.
pub fn log_line<S: Serial>(serial: &mut S, line: &str) {
serial.write_str(line);
serial.write_byte(b'\n');
}
mirror_button читает кнопку (is_high) и задаёт уровень светодиода (set_state), не зная, эмулируемые это ножки, заглушки или реальные выводы. log_line пользуется методом по умолчанию write_str и добавляет перевод строки. Это и есть обещание HAL: драйвер пишется один раз и едет на любой реализации трейтов.
Развилка дизайна и её цена
Мысль урока: HAL разделяет механизм и политику, и граница между ними проходит по трейту.
Политика это драйвер: он решает «когда и в каком порядке» (мигнуть пять раз, держать столько-то). Механизм это реализация трейта: она решает «как именно» (выставить бит в регистре, дёрнуть вывод чипа, записать в список). Между ними стоит трейт OutputPin. Драйвер зависит от контракта, реализация его выполняет, и менять одно, не трогая другое, можно свободно.
Цена у обобщённости есть. Дженерики раздувают код: под каждую пару типов компилятор печатает свою копию (это мономорфизация). Границы трейтов в сигнатурах многословнее, чем прямой вызов. Взамен ты получаешь драйвер, который пишется один раз и едет с эмулятора на железо и в тест без изменений, и тестируется заглушкой без всякого железа вообще. Для библиотеки драйверов это ровно тот обмен, ради которого существует embedded-hal. Это та же развилка «механизм против интерфейса», что тянется через весь блок, в её самой явной форме. В последнем уроке соберём инструменты вокруг эмулятора: дизассемблер, отладчик, трейсы.