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

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

ПРИМЕЧАНИЕ Этот материал частично взят (с разрешения автора) из статьи 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 помогает, заставляя задуматься о двух вещах:

  1. Данные живут достаточно долго (потенциально «вечно»!)
  2. Только один фрагмент кода может изменять данные в каждый момент времени

Если бы 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 старый (!), одно нажатие может увеличить счётчик сразу на несколько. Это не программная ошибка. В основном. В следующем разделе я расскажу, что может происходить и как с этим бороться.