Совместное использование данных через глобальные переменные
ПРИМЕЧАНИЕ Этот материал частично взят (с разрешения автора) из статьи Interrupts Is Threads Джеймса Манса, в которой эта тема обсуждается подробнее.
Как я упоминал ранее, при прерывании нам не передают никаких аргументов и мы не можем вернуть никакого результата. Из-за этого программе трудно взаимодействовать с периферией и с состоянием основной программы. Прежде чем разбираться с этой проблемой «голого железа», стоит, вероятно, вспомнить про потоки в «std»-Rust.
Rust «std»: совместное использование данных с потоком
В «std»-Rust тоже нужно думать о совместном использовании данных, когда, например, создаём поток.
Если вы хотите отдать что-то потоку, можно передать это в замыкание с передачей владения.
#![allow(unused)]
fn main() {
// Создаём строку в текущем потоке
let data = String::from("hello");
// Теперь создаём новый поток и ОТДАЁМ ему владение строкой,
// которую только что создали
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(1000));
println!("{data}");
});
}
Если же вы хотите поделиться чем-то и сохранить доступ к этому в исходном потоке, обычно нельзя просто передать ссылку на него. Если сделать так:
use std::{thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = String::from("hello");
// Создаём ссылку, которую передадим дальше
let data_ref = &data;
// Теперь создаём новый поток и ОТДАЁМ ему владение строкой,
// которую только что создали
spawn(|| {
sleep(Duration::from_millis(1000));
println!("{data_ref}");
});
println!("{data_ref}");
}
вы получите ошибку примерно такого вида:
error[E0597]: `data` does not live long enough
--> src/main.rs:6:20
|
3 | let data = String::from("hello");
| ---- binding `data` declared here
...
6 | let data_ref = &data;
| ^^^^^ borrowed value does not live long enough
...
10 | / spawn(|| {
11 | | sleep(Duration::from_millis(1000));
12 | | println!("{data_ref}");
13 | | });
| |______- argument requires that `data` is borrowed for `'static`
...
16 | }
| - `data` dropped here while still borrowed
Нужно убедиться, что данные живут достаточно долго и для текущего потока, и для нового потока, который вы создаёте. Этого можно добиться, поместив данные в Arc (Atomically Reference Counted heap allocation, выделение в куче с атомарным подсчётом ссылок), например так:
use std::{sync::Arc, thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = Arc::new(String::from("hello"));
let handle = spawn({
// Делаем копию дескриптора, которую ОТДАЁМ новому потоку.
// `data` и `new_thread_data` указывают
// на одну и ту же строку!
let new_thread_data = data.clone();
move || {
sleep(Duration::from_millis(1000));
println!("{new_thread_data}");
}
});
println!("{data}");
// ждём, пока поток завершится
let _ = handle.join();
}
Это здорово! Теперь вы можете сколько угодно обращаться к данным и в основном потоке, и в новом. Но что, если хочется изменять данные в обоих местах?
Для этого обычно нужна какая-то форма «внутренней изменяемости» (inner mutability) — тип, который не требует &mut для изменения. На десктопе обычно берут такой тип, как Mutex, и вызывают lock(), чтобы получить изменяемый доступ к данным.
Это может выглядеть примерно так:
use std::{sync::{Arc, Mutex}, thread::{sleep, spawn}, time::Duration};
fn main() {
// Создаём строку в текущем потоке
let data = Arc::new(Mutex::new(String::from("hello")));
// блокируем её из исходного потока
{
let guard = data.lock().unwrap();
println!("{guard}");
// guard освобождается здесь, в конце области видимости!
}
let handle = spawn({
// Делаем копию дескриптора, которую вы ОТДАЁТЕ новому потоку.
// `data` и `new_thread_data` указывают
// на один и тот же `Mutex<String>`!
let new_thread_data = data.clone();
move || {
sleep(Duration::from_millis(1000));
{
let mut guard = new_thread_data.lock().unwrap();
// теперь мы можем менять данные!
guard.push_str(" | thread was here! |");
// guard освобождается здесь, в конце области видимости!
}
}
});
// ждём, пока поток завершится
let _ = handle.join();
{
let guard = data.lock().unwrap();
println!("{guard}");
// guard освобождается здесь, в конце области видимости!
}
}
Если запустить этот код, вы увидите:
hello
hello | thread was here! |
Почему «std»-Rust заставляет нас так делать? Rust помогает, заставляя задуматься о двух вещах:
- Данные живут достаточно долго (потенциально «вечно»!)
- Только один фрагмент кода может изменять данные в каждый момент времени
Если бы Rust позволял обращаться к данным, которые могут не прожить достаточно долго, например к данным, заимствованным из одного потока в другой, могли бы возникнуть проблемы. Мы могли бы получить повреждённые данные, если исходный поток завершится или упадёт в панику, а второй поток попытается обратиться к уже недействительным данным. Если бы Rust позволял двум фрагментам кода одновременно пытаться изменить одни и те же данные, возникла бы гонка данных (data race) или данные могли бы оказаться повреждены.
Embedded Rust: совместное использование данных с ISR
В embedded Rust нас волнует то же самое, когда речь идёт о совместном использовании данных с обработчиками прерываний! Как и потоки, прерывания могут случиться в любой момент, примерно как если бы поток просыпался и обращался к каким-то общим данным. Это значит, что данные, которые мы разделяем с прерыванием, должны жить достаточно долго, и мы должны следить, чтобы основной код не работал с какими-то данными, общими с ISR, в тот момент, когда эта ISR запускается и тоже пытается с ними работать!
Фактически в embedded Rust мы моделируем прерывания так же, как потоки в Rust: действуют те же правила по тем же причинам. Однако в embedded Rust есть некоторые важные отличия:
-
Прерывания работают не совсем как потоки: мы настраиваем их заранее, и они ждут, пока не случится какое-то событие (например, нажата кнопка или истёк таймер). В этот момент они выполняются, но без доступа к каким-либо переданным контекстам.
-
Прерывания могут срабатывать многократно: по одному разу на каждое наступление события.
Поскольку мы не можем передавать контекст прерываниям как аргументы функции, нужно найти другое место для хранения этих данных. В «голом» (bare metal) embedded Rust у нас нет доступа к выделению памяти в куче: поэтому Arc и подобные типы нам не подходят.
Без возможности передавать значения и без кучи для хранения данных у нас остаётся одно место для общих данных, к которым может обратиться наша ISR: глобальные переменные static.
Совместное использование данных с ISR в Embedded Rust: «стандартный способ»
Глобальные переменные в Rust — граждане второго сорта, с гораздо большим количеством ограничений, чем локальные переменные. Глобальную переменную состояния можно объявить так:
#![allow(unused)]
fn main() {
static COUNTER: usize = 0;
}
Конечно, это не слишком полезно: вам нужно менять COUNTER. Можно написать
#![allow(unused)]
fn main() {
static mut COUNTER: usize = 0;
}
но тогда все обращения к ней будут unsafe.
#![allow(unused)]
fn main() {
unsafe { COUNTER += 1 };
}
Небезопасность здесь не просто так: представьте, что посреди обновления COUNTER запускается обработчик прерывания и тоже пытается обновить COUNTER. Начнётся обычный хаос. Очевидно, что нужна какая-то блокировка.
Крейт critical-section предоставляет что-то вроде типа Mutex, но с необычным API и необычными операциями. Если посмотреть на Cargo.toml этой главы, вы увидите, что для крейта cortex-m включена функция critical-section-single-core. Она утверждает, что в системе только одно ядро процессора и что поэтому синхронизацию можно выполнить простым отключением прерываний на время критической секции. Если код не в прерывании, это гарантирует, что только основная программа имеет доступ к глобальной переменной. Если код в прерывании, это гарантирует, что основная программа не может обращаться к глобальной переменной (управление у обработчика прерывания) и что никакое другое прерывание с более высоким приоритетом не сработает.
critical_section::Mutex немного странный: он обеспечивает взаимное исключение, но сам по себе не даёт изменяемости. Чтобы данные можно было менять, нужно защитить мьютексом тип с внутренней изменяемостью — обычно RefCell. Этот Mutex также странен тем, что его не .lock()-ают. Вместо этого вы открываете критическую секцию с помощью замыкания, которое получает «токен критической секции», подтверждающий, что выполнение другой программы предотвращено. Этот токен можно передать методу borrow() у Mutex, чтобы получить доступ.
Собрав всё вместе, получаем возможность разделять состояние между ISR и основной программой (examples/count-once.rs):
#![no_main]
#![no_std]
use core::cell::RefCell;
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section::Mutex;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: Mutex<RefCell<usize>> = Mutex::new(RefCell::new(0));
/// Эта «функция» будет вызвана при получении прерывания. Пока что просто
/// выводим сообщение и паникуем.
#[interrupt]
fn GPIOTE() {
critical_section::with(|cs| {
let mut count = COUNTER.borrow(cs).borrow_mut();
*count += 1;
rprintln!("count: {}", count);
});
panic!();
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
}
}
Вы по-прежнему не можете безопасно вернуть управление из ISR, но теперь можно что-то с этим сделать: передать GPIOTE в ISR, чтобы она могла сбросить прерывание.
Совместное использование периферии (и не только) через глобальные переменные
Остаётся решить ещё одну проблему: глобальные переменные Rust должны инициализироваться статически, то есть до запуска программы. Для счётчика это было легко: просто инициализировать нулём. Но если хочется разделить периферию GPIOTE, это не сработает. Периферию нужно взять из структуры Board и настроить уже после запуска программы: для неё нет const-инициализатора (да и не может быть разумного).
Перепишем счётчик кнопки немного. Сначала переведём сам счётчик в AtomicUsize. Это более естественный тип для такой глобальной переменной. Затем добавим глобальную переменную GPIOTE_PERIPHERAL с типом LockMut из крейта critical-section-lock-mut. Этот крейт — удобная обёртка для шаблона из предыдущего раздела.
Теперь, когда основная программа может настроить периферию GPIOTE и передать её обработчику прерывания, можно перестать паниковать и позволить счётчику увеличиваться при каждом нажатии кнопки. Перенесём вывод счётчика в основной цикл, чтобы показать, что счётчик разделяется между обработчиком прерывания и остальной программой.
Запустите этот пример (examples/count.rs) и обратите внимание, что счётчик увеличивается на 1 при каждом нажатии кнопки A на MB2.
#![no_main]
#![no_std]
use core::sync::atomic::{AtomicUsize, Ordering::AcqRel};
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
#[interrupt]
fn GPIOTE() {
let count = COUNTER.fetch_add(1, AcqRel);
rprintln!("ouch {}", count + 1);
GPIOTE_PERIPHERAL.with_lock(|gpiote| {
gpiote.channel0().reset_events();
});
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
GPIOTE_PERIPHERAL.init(gpiote);
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
}
}
ПРИМЕЧАНИЕ Всегда хорошая идея собирать примеры с обработкой прерываний с
--release. Долгие обработчики прерываний могут привести к большой путанице.
Впрочем, вызов rprintln!() в обработчике прерывания — плохая практика: пока обработчик выполняет вывод, ничто другое не может продвинуться вперёд. Перенесём вывод в основной цикл, сразу после wfi(), «ожидания прерывания». Тогда счётчик будет выводиться каждый раз, когда обработчик прерывания завершится (examples/count-bounce.rs).
#![no_main]
#![no_std]
use core::sync::atomic::{
AtomicUsize,
Ordering::{AcqRel, Acquire},
};
use cortex_m::asm;
use cortex_m_rt::entry;
use critical_section_lock_mut::LockMut;
use panic_rtt_target as _;
use rtt_target::{rprintln, rtt_init_print};
use microbit::{
hal::{
gpiote,
pac::{self, interrupt},
},
Board,
};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
static GPIOTE_PERIPHERAL: LockMut<gpiote::Gpiote> = LockMut::new();
#[interrupt]
fn GPIOTE() {
let _ = COUNTER.fetch_add(1, AcqRel);
GPIOTE_PERIPHERAL.with_lock(|gpiote| {
gpiote.channel0().reset_events();
});
}
#[entry]
fn main() -> ! {
rtt_init_print!();
let board = Board::take().unwrap();
let button_a = board.buttons.button_a.into_floating_input();
// Настраиваем GPIOTE так, чтобы он генерировал прерывание при нажатии Button A (GPIO
// линия переходит в низкий уровень).
let gpiote = gpiote::Gpiote::new(board.GPIOTE);
let channel = gpiote.channel0();
channel
.input_pin(&button_a.degrade())
.hi_to_lo()
.enable_interrupt();
channel.reset_events();
GPIOTE_PERIPHERAL.init(gpiote);
// Настраиваем NVIC для обработки прерываний GPIO.
unsafe { pac::NVIC::unmask(pac::Interrupt::GPIOTE) };
pac::NVIC::unpend(pac::Interrupt::GPIOTE);
loop {
// «wait for interrupt»: процессор засыпает до прихода прерывания.
asm::wfi();
let count = COUNTER.load(Acquire);
rprintln!("ouch {}", count);
}
}
В этом примере счётчик увеличивается на 1 при каждом нажатии кнопки A на MB2. Наверное. Особенно если ваш MB2 старый (!), одно нажатие может увеличить счётчик сразу на несколько. Это не программная ошибка. В основном. В следующем разделе я расскажу, что может происходить и как с этим бороться.