【Arduino工作〜発展編】AVR-RustでAtmega328pの割り込み処理を試そう!


2021/11/10
蛸壺の中の工作室|AVR-RustでAtmega328pの割り込み処理を試そう!

今回はAVR-RustでAVRマイコンの
「割り込み」をどう実現するか具体例を取り上げて説明していきます。

単純な機能を使うだけなら、メインルーチンだけでことが済むので、割り込み処理(サブルーチン)をさほど理解せずともなんとなく動作するプログラムが出来てしまします。

ただAVRマイコンのみならず、ある程度プログラムの規模が大きくなってきたときには、割り込み処理を使うことが必須になってきます。

マイコンの持つ能力を余すこと無く応用した完成品を作成したいなら、じっくりと割り込みの裏側の理解をしていきましょう。

なおこの記事は、
RustでArduinoライクに使えるものを作ってみることをテーマに複数回の記事に渡って連載しているところの第5回目です。

大体この記事もそろそろ応用的な内容に差し掛かってきまして、基礎的な内容から大分遠くなってきました。

途中、既知として前回までで利用したテクニックを使っている箇所が出てきますが、ここでは詳しい解説はしませんので、以下のリンクで知りたい内容があればそちらでお願いします。

AVRマイコン向けのRustプログラムのビルドや書き込み方法に関しては以下の記事をご覧ください。

AVRマイコンのクロック周波数の設定の話は、以下の記事をご覧ください。

またAVRマイコンのUSARTの設定の話は、以下の記事をご覧ください。

PWM出力とタイマーの使い方に関しては、以下の記事で説明しています。

では以降で、AVRマイコンの割り込みをRustでどう作り込んでいくか見ていきましょう。


AVRの割り込みと割り込みベクタ

まず初めに知っておくことは、AVRマイコンにおける処理の考え方の大前提として、同時に一つの処理しか実行されないということです。

指定した時間だけ待機するdelay関数も、マイコンが
何も処理しないままじっと待っているわけではなく、きっちりと決められたクロック回数分だけ何もしない処理を全力で回しています。

何でもかんでもメインルーチンにマイコンにやらせたい動作を定義して、そのタイミングを全てdelayで調整する...というやり方は不可能ではないですが、欲しい動作が増えたり複雑化していくほどdelayだけでは困難になります。

このため、ハードウェアからの特定のトリガーイベントを元に、予め定義しておいたサブルーチンを実行する仕組みを、
「割り込み」と呼んでいます。

割り込みのトリガーイベントの種類は特別なアドレス値で定義・管理されており、
「割り込みベクタ」として主に以下の表のように決められています。

ベクタ順序

ベクタ名※

トリガー元デバイス

備考

0

無し

電源などリセット全般

電源ON・WDT・BOD等の各種リセット動作

1

INT0_vect

INT0ピン

外部割り込み要求0

2

INT1_vect

INT1ピン

外部割り込み要求1

3

PCINT0_vect

PCINT0ピン

入力信号変化時割り込み要求0

4

PCINT1_vect

PCINT1ピン

入力信号変化時割り込み要求1

5

PCINT2_vect

PCINT2ピン

入力信号変化時割り込み要求2

6

WDT_vect

WDT

ウオッチドック完了

7

TIMER2_COMPA_vect

タイマー2/比較器A

タイマー2比較A一致

8

TIMER2_COMPB_vect

タイマー2/比較器B

タイマー2比較B一致

9

TIMER2_OVF_vect

タイマー2

タイマー2オーバフロー

10

TIMER1_CAPT_vect

タイマー1

タイマー1入力キャプチャ発生

11

TIMER1_COMPA_vect

タイマー1/比較器A

タイマー1比較A一致

12

TIMER1_COMPB_vect

タイマー1/比較器B

タイマー1比較B一致

13

TIMER1_OVF_vect

タイマー1

タイマー1オーバフロー

14

TIMER0_COMPA_vect

タイマー0/比較器A

タイマー0比較A一致

15

TIMER0_COMPB_vect

タイマー0/比較器B

タイマー0比較B一致

16

TIMER0_OVF_vect

タイマー0

タイマー0オーバフロー

17

SPI_STC_vect

SPI

SPIシリアル送信完了

18

USART_RX_vect

USART

USARTシリアル受信完了

19

USART_UDRE_vect

USART

USARTデータレジスタ空き

20

USART_TX_vect

USART

USARTシリアル送信完了

21

ADC_vect

ADC

AD変換完了

22

EE_READY_vect

EEPROM

EEPROM操作準備完了

23

ANALOG_COMP_vect

ANA_COMP

アナログ比較完了

24

TWI_vect

TWI

2線シリアル通信状態変化時

25

SPM_READY_vect

SPM

SPM命令準備完了

※Cライブラリ(io.h等)に収録されている定義名。なおRustでは利用しません。

一般的に、
割り込み処理とは次のようなプロセスで流れます。

            1. 割り込み機能を有する周辺回路から割り込み要求(IRQ; Interrupt Re-Quest)が発行
2. 実行予定にあるメモリアドレスを一旦スタックに格納
3. 現行で実行中の命令を完了
4. 割り込みルーチン(ISR; Interrupt Service Routine)へ制御が移行
5. 割り込みルーチンの処理が完了後、メインルーチンで項目2で格納していたプロセスに復帰
        
実際の割り込み処理は、多数の状態レジスタやIOレジスタなどが複雑に非同期動作する場合がほとんどで、言うほど単純なものではなく、複雑なプロセスが走っています。


タイマー2を使った割り込み

Atmega328pにはタイマーが3つあり、タイマーごとに多少、機能とレジスタ構造が違います。

以前の記事でタイマーモードやレジスタの概要は説明しましたが、今回はPWM波形の発生源としてタイマー1を使うため、タイマー2を割り込み制御に利用します。

ちなみにタイマー0を使っても同じですが、タイマーによって出力ピンが異なるのを留意しながら利用してください。

タイマー2のレジスタ操作

タイマー2の割り込みには、先程の割り込みベクタの表にまとめたように、比較A一致比較B一致カウンターオーバフローの3種類が利用できます。

この内今回は
比較A一致だけにフォーカスして説明します。

割り込み要求の使い方を一つ覚えれば後は同じ要領で、他の割り込みも使えるようになると思います。

TCCR2A・TCCR2Bレジスタ

比較A一致の割り込みをトリガーするためには、ここではタイマー2の比較器AをCTCモードに設定してみます。

CTCモードは高速PWMモードとは異なり、ここでの例ではタイマーから得られるノコギリ波(正しくは階段形状)のTOP値をOCR2Aで決めることができます。

このタイマー2の比較器Aの出力ピン(PB3ピン;OC2A)からPWM波形を得ることもできます。

合同会社タコスキングダム|蛸壺の中の工作室

CTCモードで生成されるPWM波形は、カウンターがOCR2Aの値と一致する際にゼロリセットされ、そのタイミングでHIGHとLOWがトグルされるような出力になります。

なお、このPWM波形をPB3ピンから外部に出力するためには、当該の方向レジスタDDRBのビットを出力(1)に切り替えて、TCCR2Aレジスタの
COM2A1-COM2A0ビットを01に設定することで取り出せるようになります。

今回はタイマー2を割り込み処理の発生元にするだけですので、このPWM波形は利用することはありません。

ちなみにCTCモードで得られるPWM波の周波数
fPWMCTCf_\mathrm{PWM}^\mathrm{CTC}は以下のような式で表します。

fPWMCTC=fCLK2N(1+OCR2A)f_\mathrm{PWM}^\mathrm{CTC} = \frac{f_\mathrm{CLK}}{2N (1 + \mathrm{OCR2A})}Eq. (1)

例えばシステムクロック数
fCLK=8 [MHz]f_\mathrm{CLK} = 8\ \mathrm{[MHz]}、分周数N=128N = 128OCR2A = 49だった場合、fPWMCTC=625 [Hz]f_\mathrm{PWM}^\mathrm{CTC} = 625\ \mathrm{[Hz]}のPWM波形になります。

なお高速PWMモードの存在するAtmegaシリーズのAVRマイコン以外ではこちらの手法が基本的なPWM波形生成方法がとなっています。

すこし話がPWMに逸れましたので、割り込みの話に戻します。

タイマーをCTCモードで利用すると、カウンタがTOP値に一致すると同時に、比較一致の割り込み要求をトリガーさせるようにできます。

この割り込み処理を許可するためには、まず
TCCR2Aレジスタを設定する必要があります。

            COM2A1-COM2A0-COM2B1-COM2B0-無し-無し-WGM21-WGM20

設定例:
    00(OC2Aポート切断)-00(OC2Bポート切断)-00-10(☆)
        
まずは、COM2A1-COM2A0COM2B1-COM2B0の各ビットですが先程も述べたようにPWM波形を外部に対応のポートから出力するかどうかを設定します。

出力しないばあいには
00、する場合には01にします。

また1-0ビット目に
WGM21-WGM20がありますが、これを理解するにはTCCR2Bレジスタも加味する必要が有ります。

TCCR2Bレジスタが以下のような構成になっています。

            FOC2A-FOC2B-無し-無し-WGM22-CS22-CS21-CS20

設定例:
    00-00-0(☆)-111
        
7-6ビット目のFOC2A-FOC2Bは非PWMモードの際に、このビットに1を書き込むと、強制的に即座に比較一致を行うことができます。この機能は使いどころがないので基本00とします。

分周数は
CS22-CS21-CS20で定義し、以下のようなビットで分周数を設定します。

            CS22-CS21-CS20:
    000...タイマー停止
    001...分周無し(=1)
    010...1/8
    011...1/32
    100...1/64
    101...1/128
    110...1/256
    111...1/1024
        
タイマーのモード設定はTCCR2A/Bの2つのレジスタにまたがって存在しているWGM22-WGM21-WGM20のビットに設定しなければなりません。

タイマー2のモード選択ビットの設定は以下の表の通りです。

WGM22-WGM21-WGM20

モード名

TOP値

OCR2更新タイミング

TOVタイミング

000

標準

0xFF

即時

0xFF

001

8bit位相標準PWM

0xFF

0xFF

0x00

010

CTC

OCR2A

即時

0xFF

011

8bit高速PWM

0xFF

0x00

0xFF

101

位相標準PWM

OCR2A

OCR2A

0x00

111

高速標準PWM

OCR2A

0x00

OCR2A

今回のようにCTCモードにしたい場合には、
WGM22-WGM21-WGM20010に設定するので、TCCR2Aレジスタの1-0ビット目を10に、TCCR2Bレジスタの3ビット目を0にします。

CTCモードの割り込み周期はOCR2Aに一致した直後にトリガーされるため、例えば主クロック周波数8MHz、分周数1024、OCR2Aを100とすると、割り込み間隔は
0.125us(@8MHz) * 1024 * 100 = 12.8msで計算できます。

TIMSK2レジスタ

そのままだと割り込みトリガー動作に許可がないので、割り込みルーチン上で何も起こりません。

今回の割り込みトリガー元であるタイマー2に割り込み処理の許可を与える場合には、
TIMSK2レジスタを設定する必要があります。

            無-無-無-無-無-OCIE2B-OCIE2A-TOIE2

設定例:
    00000-010(比較A一致割り込み有効)
        
2ビット目を1にすると、タイマー2の比較B一致の割り込み要求を許可します。

同様に1ビット目を1にすると、タイマー2の比較A一致の割り込み要求を許可します。

0ビット目を1にすると、タイマー2のカウンターオーバフロー割り込み要求を許可します。

今回は比較A一致割り込みだけを使うので、
OCIE2Aビットのみ1を設定します。


RustでISRマクロが使えない...サブルーチンどうする?

Rustでの割り込みルーチンを実装する前に、C言語との違いを考えておきましょう。

C言語組込で割り込み処理を作成する場合、ISRマクロで目的の割り込みを指定して、サブルーチンの処理を記述するやり方が一般的かと思います。

            ISR(<割り込みベクタの識別名>) {
    //...サブルーチン処理
}
        
何もしないとRustでこの割り込みルーチンにあたるマクロは未実装です。

そのままCのISRマクロにライブラリリンクしてくれる親切な仕組みがないので、割り込み処理が走りません。

Rustでも割り込み処理を行うようにするためには、C言語のISRマクロの中身を覗いて、裏でプリプロセッサがやっていることを理解する必要があります。

先程のパートで記載した割り込みベクタの識別名は、AVRのCライブラリ・avr-libcのAtmega328pの割り込みベクタの定義のように、マイコンの種類によって違いはありますがio.h等で定義されています。

例えば、タイマー2のオーバフロー割り込みである
TIMER2_OVF_vectは、以下のように定義されるものです。

            #define TIMER2_OVF_vect_num   9
#define TIMER2_OVF_vect   _VECTOR(9)   /* Timer/Counter2 Overflow */
        
そこで更に_VECTOR(N)へ定義が渡されています。

この
_VECTOR(N)sfr_defs.hに定義されていて、

            #define _VECTOR(N) __vector_ ## N
        
というような関数マクロです。

ということは、最終的にC言語で
ISR(TIMER2_OVF_vect)と書くと、プリプロセッサによって__vector_9とアッセンプラ展開することになるようです。

ということで、C言語では複数のヘッダファイルで回りくどく割り込みルーチンの定義がなされているわけですが、RustのFFIを利用して直接割り込みベクタのオリジナルのシンボルを叩きます。

            #[no_mangle]
pub unsafe extern "avr-interrupt" fn __vector_N() {
    //...サブルーチン処理
}
        
ここでのNにあたる割り込みベクタ順序は、先程の割り込みベクタ表での順番に割り振られている番号です。

AVRマイコンでも多少異なるかも知れないので、データシート等を良く確認して使う必要があります。

こちらでも議論されているように、extern "C"で__vector_Nを呼び出すと場合によっては別のアドレス値を読み込むときがあり、extern "avr-interrupt"できちんとライブラリ指定するのがより正しいとされています。


プログラムの実装

ここからは一定間隔(ここでは8秒間隔)ごとに、タイマー1から発生させたPWMのデューティー比を、タイマー2の割り込みから切り替えるためのプログラムを試します。

合同会社タコスキングダム|蛸壺の中の工作室

なお、マイコンの組込開発は通常のOS有りのアプリケーションプログラム開発と違い、コンソール出力を手軽に見ながら試行錯誤するようなやり方が出来ません。

前回解説していたように、先にUSART機能でマイコンと相互通信出来るように機能を作成しておくと、マイコンの実機内部で発生しているもバグも見つけやすくなります。

割り込みの許可/禁止

C言語でのプログラミングにおいても、割り込みをスタートさせる際には、メインルーチンでsei()関数で割り込み許可する必要がありました。

これはRustに関しても同じですが、Cの
sei()関数の代わりに、アセンブラ命令(ニーモニック)のSEIを以下のように直接叩きます。

            #![feature(llvm_asm)]
//...

pub extern "C" fn main() {
    //...
    //👇割り込み処理を許可
    unsafe { llvm_asm!("sei"); }
    //...
        
逆に割り込み処理を禁止に戻したいのであれば、llvm_asm!("cli");を使います。

メインルーチンとサブルーチン間のデータ共有

C言語であれば、メインルーチン外(main関数)のスコープの外で(volatileを付けた)変数を宣言することで、その変数を介してサブルーチンとのデータの受け渡しが出来ました。

            #include <avr/interrupt.h>

//ルーチン間で共有される変数
volatile unsigned char COUNT = 0;

ISR(HOGE_vect) {
    //サブルーチンで変数COUNTを操作...
}

int main(void) {
    sei();
    //メインルーチンでも変数COUNTを操作...
}
        
Rustでは変数拘束のライフタイム管理の概念がより重要ですので、単純にメインルーチン外に変数を出しただけではコンパイルエラーになります。

そこでグローバル変数としてデータを共有したい場合、
static mutで変数を拘束宣言しておき、この変数を使う場合にはunsafeブロック内で使用することでC言語との補完が可能です。

            //ルーチン間で共有される変数
static mut COUNT: u8 = 0;

#[no_mangle]
pub unsafe extern "avr-interrupt" fn __vector_N() {
    unsafe {
        //サブルーチンで変数COUNTを操作...
    }
}

#[no_mangle]
pub extern "C" fn main() {
    unsafe { llvm_asm!("sei"); }
    unsafe {
        //メインルーチンでも変数COUNTを操作...
    }
}
        

Rustでプログラミング

以上のポイントを踏まえて、今回の割り込み機能をテストするためのプログラムを以下のソースコードをビルドして生成します。

            #![feature(llvm_asm, lang_items, unwind_attributes)]
#![feature(start, abi_avr_interrupt)]
#![no_std]
#![no_main]

extern crate avr_delay;
extern crate avr_std_stub;
extern crate avrd;

use avr_delay::{delay};
use core::ptr::write_volatile;

use avrd::atmega328p::{DDRB, DDRC};
use avrd::atmega328p::{CLKPR};

//👇タイマー1制御レジスタ
use avrd::atmega328p::{TCCR1A, TCCR1B, OCR1A, ICR1};

//👇タイマー2制御レジスタ
use avrd::atmega328p::{TCCR2A, TCCR2B, OCR2A, TIMSK2};

//👇USART関連レジスタ
use avrd::atmega328p::{UCSR0A, UCSR0B, UCSR0C, UDR0, UBRR0};

static mut COUNT: u8 = 0;
static mut OLD_COUNT: u8 = 0;

#[no_mangle]
#[start]
pub extern "C" fn main() {
    clock_init();
    usart_init();
    pwm_init();
    interrupt_init();

    //👇PB1ピン(OC1A), PB3(OC2A)ピンを出力
    unsafe { write_volatile(DDRB, 0b00001010); }

    //👇割り込み処理を許可
    unsafe { llvm_asm!("sei"); }

    loop {
        delay(2000000); //👈1s@8Mhz
        unsafe {
            //👇一秒ごとにカウンターを回す
            cycle_shift(&mut COUNT);
        }
    }
}

#[no_mangle]
fn clock_init() {
    unsafe {
        write_volatile(CLKPR, 0b10000000);
        //👇主クロック周波数16MHzを2分周し、8MHz駆動にする
        write_volatile(CLKPR, 0b00000001);
    }
}

#[no_mangle]
fn pwm_init(){
    unsafe {
        write_volatile(TCCR1A, 0b10000010);
        write_volatile(TCCR1B, 0b00011011);
        write_volatile(ICR1, 65535);
        //👇Duty比: 0.5
        write_volatile(OCR1A, 32767);
    }
}

#[no_mangle]
fn interrupt_init(){
    unsafe {
        //👇タイマ2はCTCモードで、比較A一致で割り込みトリガー
        write_volatile(TCCR2A, 0b00000010);
        //👇1024分周
        write_volatile(TCCR2B, 0b00000111);
        //👇割込み間隔: 0.125us(@8MHz) * 1024 * 100 = 12.8ms
        write_volatile(OCR2A, 100);
        //👇比較A一致割り込み有効
        write_volatile(TIMSK2, 0b0000010);
    }
}

//👇割り込み処理の確認用のUSART設定
#[no_mangle]
fn usart_init() {
    unsafe {
        write_volatile(UBRR0, 51);//9600bps
        write_volatile(UCSR0B, 0b00011000);
        write_volatile(UCSR0C, 0b00000110);
    }
}

//👇タイマー2の比較A割り込みルーチン
#[no_mangle]
pub unsafe extern "avr-interrupt" fn __vector_7() {
    unsafe {
        if OLD_COUNT ^ COUNT != 0 {
            //👇デバッグ用にカウンターをUSARTのTxより送信して、シリアルでモニタリング可
            transmit(COUNT);
            //👇8秒おきにタイマー1のPWM波形のDuty比を変化
            if COUNT == 0 {
                //👇Duty比: 約0.92
                write_volatile(OCR1A, 60000);
            }
            if COUNT == 7 {
                //👇Duty比: 約0.08
                write_volatile(OCR1A, 5000);
            }
            OLD_COUNT = COUNT;
        }
    }
}

//👇循環整数(0~15)の生成
#[no_mangle]
fn cycle_shift(x: &mut u8) {
    *x += 1;
    *x &= (1<<4) - 1;
}

#[no_mangle]
fn transmit(byte: u8) {
    while !ready_to_transmit() {}
    unsafe {
        write_volatile(UDR0, byte);
    }
}

#[no_mangle]
fn ready_to_transmit() -> bool {
    unsafe {
        (*UCSR0A & (1 << 5)) > 0
    }
}
        

動作確認

先程のRustソースコードをビルドしたものをAtmega328pに書き込みます。

ブレッドボード上に部品を配置して、PB1ピン(OC1A)から出力されるタイマー1のPWM波のデューティー比が変化しているかを確認してみましょう。

合同会社タコスキングダム|蛸壺の中の工作室

PWMの周波数はそのまま、デューティー比を定間隔で変化させているので、LEDの点滅時間が短い状態と長い状態が一定時間ごとに入れ替わることが分かります。


まとめ

今回はAVRマイコンの割り込み機能全般をRustで使う方法を紹介していきました。

割り込み要求のタイミングには色々とバリエーションがあり、使いたい場合にはその都度適切なトリガー元モジュールのレジスタ周辺のビットを良く理解する必要があります。困った時はAVRデータシート等で確認しましょう。


参考

ATmega48A/PA/88A/PA/168A/PA/328/P データシート