🦀

Rustで作るリアルタイムOS

2023/03/04に公開

はじめに

簡単なタスク管理機能と、タイマレジスタによる割り込み管理機能を持つ簡易なリアルタイムOSをRustで自作しました。
Aruduino Unoボードに搭載されるATmega328Pマイコン上で動かすことを想定していますが、https://github.com/Rahix/avr-hal でサポートされているAVRマイコンであれば、ほぼ同様の実装ができそうです。

本記事では環境構築については割愛し、実装の詳細について解説を行いたいと思います。

リアルタイムOSの設計

今回作成したリアルタイムOSは、技術書店13でサークル出展した書籍、cistLT Bookで作成したOS(https://github.com/yud0uhu/ChocottoOS)を、Rustで再実装したものになります。

書籍同様、ITRON4.0仕様に準拠するための五項目をOSの機能要件として定めました。

(a) タスクを生成できること.タスクは少なくとも,実行状態,実行可能状態,休止状態 の3つの状態を持つこと.
(b) μITRON4.0 仕様のスケジューリング規則に従ったタスクスケジューリング を行うこと.ただし,優先度毎のタスクを1つに制限することや,優先度を1段階に制限 することは許される.
(c) 割込みハンドラ(または割込みサービスルーチン)を登録できること.
(d) タスクおよび割込みハンドラ(または割込みサービスルーチン)から,タ スクを起動する(休止状態から実行できる状態にする)手段が用意されていること. (e) 自タスクを終了する(実行状態から休止状態にする)手段が用意されていること.
(e) 自タスクを終了する(実行状態から休止状態にする)手段が用意されていること.

タスク管理はタスクコントロールブロック(TCB)で行います。
タスクはRUNNING/READY/SUSPENDの3つの状態を持ちます。
実行しているときの状態をRUNNING、実行していないときの状態をそれぞれREADYSUSPENDとしています。
タスクは0から9の優先順位を持ち、最も数字の高いものから実行されます。優先順位が 同列の場合は、タスク関数の呼び出し順で実行されます。例えば、優先度1のタスクが実行状態(RUNNING)にあるとき、優先度2のタスクが実行可能状態(READY)となったら、タスク状態がスイッチされ、優先度1のタスクは休止状態(SUSPEND)に、優先度2のタスクが実行状態(RUNNING)になります。

また、割り込みハンドラにはタイマー割り込みを用います。ここでは、AVRのタイマレジスタを扱います。ATmega328Pは三種類のタイマーを内蔵していますが、今回はタイマー1による割り込み制御を行います。

タイマー bit
TIMER0 8
TIMER1 16
TIMER2 8

https://techbookfest.org/product/6TSJb6UBKpzbkHNuFkZPGy?productVariantID=pAbURDgvQQN7x8gatbmLkZ

タスクコントロールブロック(TCB)

タスクの状態は下記のenum(列挙体)で定義します。

#[derive(PartialEq)]
pub enum TaskState {
    RUNNING,
    READY,
    SUSPEND,
}

Rustでは、derive属性を使用することで、構造体や列挙型に共通の振る舞い(トレイト)を追加できます。
PartialEqトレイトにより、オブジェクト同士の等価性を比較することができます。これにより==!=演算子の使用が可能になります。
列挙体の場合、各列挙子は常に自身とのみ等価です。(例えば、TaskState.RUNNINGTaskState.READYは等価ではありません(assert_eq!(TaskState.RUNNING, TaskState.READY); // false)
https://doc.rust-jp.rs/book-ja/appendix-03-derivable-traits.html#等価比較のためのpartialeqとeq

タスクコントロールブロック(TCB)と、プロセスを監視するためのユーティリティ(タスクマネージャ)を下記の構造体で定義しています。

pub struct TaskManager<'a> {
    pub task_control_block: &'a TaskControlBlock,
    pub task_handler: (),
}
pub struct TaskControlBlock {
    pub task_id: u32,
    pub task_state: TaskState,
    pub task_priority: &'static usize,
    pub task_handler: (),
}

implブロックの中でupdateメソッドを定義します。
updateメソッドは、新規登録されたタスクをスタックから取得し、更新する際に呼び出します。

Rustの標準ライブラリ(std)には、VecStringといった、複数のオブジェクトを管理するためのデータ構造が用意されています。これらを総称してコレクションと呼びます。

一方、ベアメタルのno_std環境ではこれらのコレクションを使うことができません。
no_std環境でVecStringを使うには、グローバルメモリアロケータ(#[global_allocator]属性が指定されたアロケータのこと。GlobalAllocトレイトの実装を参照)が必要になります。

https://tomoyuki-nakabayashi.github.io/embedded-rust-techniques/03-bare-metal/allocator.html#グローバルアロケータ

heaplessクレートを使うことで、グローバルメモリアロケータなしでVecを使用することができます。

pub use heapless::Vec;

でheaplessクレートの仕様を宣言し、static変数上に可変長配列PRIORITY_STACKのコレクションを割り当てます。

pub static mut PRIORITY_STACK: Vec<&usize, 8> = Vec::new();

Rustでは、スレッド間でグローバル変数を共有することができません。グローバル変数へのアクセスや書き換えは、内部可変性パターン(Interior mutability)を使うか、unsafeで注釈されたブロックの中でのみ行うことができます。

https://doc.rust-jp.rs/book-ja/ch19-01-unsafe-rust.html

push_uncheckedは、インデックスアクセス時の配列の境界チェックを省略し、配列に対して要素の追加を行います。実行速度を高速化(O(1))できることが利点です。

impl TaskManager<'_> {
    pub fn update<W: uWrite<Error = void::Void>>(&mut self, serial: &mut W) {
        {
            let task_control_block: &TaskControlBlock = &self.task_control_block;
            let _serial: &mut W = serial;
            let _task_id: &u32 = &task_control_block.task_id;
            let task_priority = &task_control_block.task_priority;
            unsafe {
                PRIORITY_STACK.push_unchecked(task_priority);
            }
        };
    }
}

tcb.rsの全てのソースコードは下記のようになります。

tcb.rs
use panic_halt as _;

#[derive(PartialEq)]
pub enum TaskState {
    RUNNING,
    READY,
    SUSPEND,
}

pub static mut PRIORITY_STACK: Vec<&usize, 8> = Vec::new();

pub use heapless::Vec;

pub struct TaskManager<'a> {
    pub task_control_block: &'a TaskControlBlock,
    pub task_handler: (),
}
pub struct TaskControlBlock {
    pub task_id: u32,
    pub task_state: TaskState,
    pub task_priority: &'static usize,
    pub task_handler: (),
}

impl TaskManager<'_> {
    pub fn update<W: uWrite<Error = void::Void>>(&mut self, serial: &mut W) {
        {
            let task_control_block: &TaskControlBlock = &self.task_control_block;
            let _serial: &mut W = serial;
            let _task_id: &u32 = &task_control_block.task_id;
            let task_priority = &task_control_block.task_priority;
            unsafe {
                PRIORITY_STACK.push_unchecked(task_priority);
            }
        };
    }
}

割り込み制御

タイマー1(16bit)を使用して、0.48ミリ秒の周期で割り込みを生成します。
タイマー1では、TCCR1A・TCCR1B・OCR1A・TIMSK1などのレジスタを組み合わせてPWMの設定を行います。
波形出力の設定を行うレジスタはTCCR1AとTCCR1Bです。タイマー波形の選択を「WGMn」で、分周の設定を「CSn」で行います。

PWM出力の設定
モード CTC
分周(プリスケーラ) 256
割り込み オーバーフロー(TIMER1 OVF)
const ARDUINO_UNO_CLOCK_FREQUENCY_HZ: u32 = arduino_hal::DefaultClock::FREQ;

定数ARDUINO_UNO_CLOCK_FREQUENCY_HZはArduino Unoのクロック周波数です。
arduino_hal::DefaultClock で定義されています。

const CLOCK_SOURCE: CS1_A = CS1_A::PRESCALE_256;
tmr1.tccr1a.write(|w| w.wgm1().bits(0b00));
tmr1.tccr1b.write(|w| {
        w.cs1()
            .variant(CLOCK_SOURCE)
            .wgm1()
            .bits(0b01)
    });

で、WGM1[3:0]ビットを設定し、bits(0b01)でCTCモードを指定しています。
また、タイマー1の分周(プリスケーラ)を設定しています。

tmr1.ocr1a.write(|w| unsafe { w.bits(ticks) });
tmr1.timsk1.write(|w| w.ocie1a().set_bit());

で、0.48msのオーバーフロー割り込みを許可します。

os_timerの全てのソースコードは下記のようになります。

os_timer
use arduino_hal::prelude::*;
use avr_device::atmega328p::tc1::tccr1b::CS1_A;
use avr_device::atmega328p::TC1;
use core::cell;
use panic_halt as _;
use ufmt::{uWrite, uwriteln};

/**
 * 優先順位スタックから最も優先度が高く(1~9で最も数字の大きいもの)設定されているタスクID
 */
pub static HIGH_PRIORITY_TASK_ID: avr_device::interrupt::Mutex<cell::Cell<u32>> =
    avr_device::interrupt::Mutex::new(cell::Cell::new(0));

/**
 * 優先順位スタックから最も優先度が高く(1~9で最も数字の大きいもの)設定されているタスクIDを取得する関数
 */
pub fn high_priority_task_id() -> u32 {
    avr_device::interrupt::free(|cs| HIGH_PRIORITY_TASK_ID.borrow(cs).get())
}
/**
 * 0.48ms周期のタイマー割り込みをセットする関数。タスクを設定後に宣言して使う。タイマー1のインスタンス変数を第1引数に渡す
 */
pub fn timer_create<W: uWrite<Error = void::Void>>(tmr1: &TC1, serial: &mut W) {
    /*
     https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf
     section 15.11
    */
    use arduino_hal::clock::Clock;

    const ARDUINO_UNO_CLOCK_FREQUENCY_HZ: u32 = arduino_hal::DefaultClock::FREQ;
    const CLOCK_SOURCE: CS1_A = CS1_A::PRESCALE_256;
    let clock_divisor: u32 = match CLOCK_SOURCE {
        CS1_A::DIRECT => 1,
        CS1_A::PRESCALE_8 => 8,
        CS1_A::PRESCALE_64 => 64,
        CS1_A::PRESCALE_256 => 256,
        CS1_A::PRESCALE_1024 => 1024,
        CS1_A::NO_CLOCK | CS1_A::EXT_FALLING | CS1_A::EXT_RISING => {
            uwriteln!(serial, "uhoh, code tried to set the clock source to something other than a static prescaler {}", CLOCK_SOURCE as usize)
                .void_unwrap();
            1
        }
    };

    // 0.48ms周期の割り込みを設定
    let ticks = (ARDUINO_UNO_CLOCK_FREQUENCY_HZ / clock_divisor / 2083) as u16; // 2083 = (1/0.48) *10^6

    tmr1.tccr1a.write(|w| w.wgm1().bits(0b00));
    tmr1.tccr1b.write(|w| {
        w.cs1()
            .variant(CLOCK_SOURCE)
            .wgm1()
            .bits(0b01)
    });
    tmr1.ocr1a.write(|w| unsafe { w.bits(ticks) });
    // オーバーフロー割り込みを許可
    tmr1.timsk1.write(|w| w.ocie1a().set_bit());
}

コンテキストスイッチの作成

実行タスクの切り替えは関連関数os_start()で行います。context_switch()では、TCBスタックから次に実行されるタスク(優先順位が最も高いタスク)を取得し、実行待ちのタスクとして設定録します。実行状態にあるタスクがないときは、関連関数all_set_task()でタスクの再登録を行います。

pub static mut TASKS: Mutex<tcb::Vec<tcb::TaskControlBlock, 8>> = Mutex::new(tcb::Vec::new());

で、TCBを保持するための可変長配列TASKSを定義します。
可変長配列を用いることで、スレッドが増減しても動的にメモリを割り当てる必要がないため、効率的なスレッド管理を行うことができます。
ここでは、グローバル変数で動的に確保した配列をavr device::interrupt::Mutexでラッ
プしています。
Mutexは内部可変性パターン(Interior mutability)の一つです。Mutexで変数をラップすることで、複数のスレッドから同時に一つの変数にアクセスされるような可能性を排除することができます。

os.rs
extern crate avr_device as device;
use device::interrupt::Mutex;
use panic_halt as _;

use arduino_hal::{prelude::*, Delay};
use ufmt::{uWrite, uwriteln};

pub mod os_timer;
pub mod tcb;

pub static mut TASKS: Mutex<tcb::Vec<tcb::TaskControlBlock, 8>> = Mutex::new(tcb::Vec::new());

/**
 * TCBスタックから最も優先度が高く設定されているタスクIDを取得し、そのタスクの状態をRUNNING(実行可能)に、それ以外のタスクのステートをREADY(実行待ち)に設定する関数
 */
pub fn context_switch() {
    let running = tcb::TaskState::RUNNING;
    let ready = tcb::TaskState::READY;
    let top_priority = get_top_priority();

    for tcb_stack in unsafe { TASKS.get_mut() } {
        let task_id = tcb_stack.task_id;
        let mut _task_state = &tcb_stack.task_state;
        let task_priority = tcb_stack.task_priority;
        if task_priority == &top_priority {
            avr_device::interrupt::free(|cs| {
                os_timer::HIGH_PRIORITY_TASK_ID.borrow(cs).set(task_id);
            });
            _task_state = &running;
        } else {
            _task_state = &ready;
        }
    }
}

pub fn task_init<W: uWrite<Error = void::Void>>(serial: &mut W) {
    unsafe {
        if tcb::PRIORITY_STACK.is_empty() {
            all_set_task(serial);
        }
    }
}

static mut MAX_TACK_ID: usize = 3;
static mut COUNT: usize = 0;

/**
 * タスクの初期化(TCBスタックに登録)後、コンテキストスイッチを0.4ミリ秒周期で実行し、優先順位順に実行可能タスクを切り替える関数
 */
pub fn os_start<W: uWrite<Error = void::Void>>(serial: &mut W) {
    task_init(serial);
    while unsafe { COUNT < MAX_TACK_ID } {
        // 割り込みハンドラ

        context_switch();

        let mut _task_id = os_timer::high_priority_task_id() as usize;
        unsafe {
            let vec = TASKS.get_mut();

            if vec[_task_id - 1].task_state == tcb::TaskState::READY {
                uwriteln!(
                    serial,
                    "current high task priority task_id= {}",
                    vec[_task_id - 1].task_id
                )
                .void_unwrap();
                vec[_task_id - 1].task_handler;
            }
        }

        unsafe {
            COUNT += 1;
            if COUNT >= MAX_TACK_ID {
                break;
            }
        }
    }
}

pub fn os_delay(us: u16) {
    Delay::new().delay_us(us)
}

/**
 * PRIORITY_STACKから最大値を取得し、その値を返す関数
 * PRIORITY_STACKが空の場合にはNoneが返されるため、max = **nの行が実行されず、maxは初期値である1を返す
 * */
pub fn get_top_priority() -> usize {
    // match文の返り値を参照で取得する
    let max: usize;
    unsafe {
        // 最も優先順位の高いものを検索する
        match tcb::PRIORITY_STACK.iter().max() {
            Some(n) => max = **n,
            None => unreachable!(),
        };
        // 優先順位の低い順にソートする
        tcb::PRIORITY_STACK.sort_unstable();
        // 優先順位の最も高い要素のインデックスを取り除く
        tcb::PRIORITY_STACK.remove(tcb::PRIORITY_STACK.len() - 1);
        max
    }
}

/**
 * 新規登録されたタスクをスタックから取得し、更新するための関数
 */
fn all_set_task<W: uWrite<Error = void::Void>>(serial: &mut W) {
    // stackの所有権がtaskに移動しまわないように、参照を借用する
    for task in unsafe { TASKS.get_mut() } {
        let mut task_manager = tcb::TaskManager {
            task_control_block: &task,
            task_handler: task.task_handler,
        };
        task_manager.update(serial);
    }
}

OSの呼び出し

ここまでで作成したOSは、main.rsで下記のように呼び出すことができます。

main.rs
#![no_std]
#![no_main]
#![feature(abi_avr_interrupt)]

use arduino_hal::simple_pwm::{IntoPwmPin, Prescaler, Timer2Pwm};
use arduino_hal::{prelude::*};
use avr_device::atmega328p::TC1;
use panic_halt as _;
use ufmt::uwriteln;

use arduino_hal::port::mode::{Output};
use arduino_hal::port::Pin;

use arduino_hal::hal::port::{PD4};

mod os;

/**
 * タスクをセットする関数
 */
pub fn create_task(
    _task1: os::tcb::TaskControlBlock,
    _task2: os::tcb::TaskControlBlock,
    _task3: os::tcb::TaskControlBlock,
) {
    unsafe {
        let vec = os::TASKS.get_mut();
        vec.push_unchecked(_task1);
        vec.push_unchecked(_task2);
        vec.push_unchecked(_task3);
    }
}

trait Tasks {
    fn call(&mut self);
    fn u_call(&mut self) -> u8;
    fn init(&mut self);
}

// 各タスクの構造体を定義し、Tasksトレイトに実装する処理など
...

#[arduino_hal::entry]
fn main() -> ! {
    let dp = arduino_hal::Peripherals::take().unwrap();

    let pins = arduino_hal::pins!(dp);

    let mut serial = arduino_hal::default_serial!(dp, pins, 57600);
    
    // 各タスクのハンドラを定義する
    ...

    // 各タスクをグローバルなスレッド(context/TaskManager)にpushする
    let mut _task1 = os::tcb::TaskControlBlock {
        task_id: 1,
        task_state: os::tcb::TaskState::READY,
        task_priority: &9,
        // 任意のタスクをハンドラにセットする
        task_handler: task1_function.call(),
    };

    let mut _task2 = os::tcb::TaskControlBlock {
        task_id: 2,
        task_state: os::tcb::TaskState::READY,
        task_priority: &2,
        task_handler: task2_function.call(),
    };

    let mut _task3 = os::tcb::TaskControlBlock {
        task_id: 3,
        task_state: os::tcb::TaskState::READY,
        task_priority: &3,
        task_handler: task3_function.call(),
    };

    let tmr1: TC1 = dp.TC1;

    create_task(_task1, _task2, _task3);

    os::os_timer::timer_create(&tmr1, &mut serial);

    os::os_start(&mut serial);

    unsafe {
        avr_device::interrupt::enable();
    }

    loop {
        avr_device::asm::sleep();
    }
}

最後に

全ソースコードは下記になります。
https://github.com/yud0uhu/avr-rs-rtos/tree/ba7f366048845324fafee9f8e8457a48ad2f61d4

必要最小限の機能しかありませんが、ATTiny85のようなメモリサイズが小さなマイコン上で動かせるように改良してみても面白いかもしれません。

Discussion