公開日:2019/03/17
title.png

4th Season


#4-8 タイマ割り込み

 今まで、Arduinoでプログラムを組んでいると、時々、「一定時間毎に処理したい」ケースに遭遇しました。タイマユニットを持っているのだから、「タイマの割り込みでできるんじゃないか?」とは思っていたのですが、標準ライブラリではサポートされていないようです。
 今回は、タイマ割り込みを設定して、定期的な処理を実現してみたいと思います。

※ここで解説する方法で直接コーディングしなくても、どなたかが作ったタイマ割り込み用のライブラリを使うと可能な様ですので、ライブラリマネージャから探してみてください。ライブラリを使うにしても、ちゃんと仕組みを知って使った方がモア・ベターなので、構わず続けます。

定期的に処理する(タイマ割り込みを使わない場合)

 今まで、定期的に処理を実行したい時に使っていたのは、millis()やmicros()などの値です。

  void loop (void)
  {
    uint32 msec;
    static uint32 msec_prev;
    msec=millis ();
    if (msec - msec_prev>200)
    {
      msec_prev=msec;
      // 定期的(200ms毎)に実行すべき処理
        :
    }
    // その他の処理
      :
  }


 これで十分、用は足りています。これを可能にしているのは、loop()関数1回分の実行時間が、定期的処理時間(上の例では200ms)よりも圧倒的に短い時間であることです。

 もし、loop()関数1回分の実行時間が160msだったとしましょう。すると、定期的処理の判定if文も、160ms毎にしか実行されないことになります。つまり、160msの解像度で、200ms毎の処理が実行されるので、定期的処理の時間間隔は200〜360[ms]の間でばらつくことになります。この時間間隔のばらつきが許容できる用途であれば、上のようなコーディングで十分、という訳です。

 タイマ割り込みを使った場合の利点は、例えば以下の様な用途にあります。

ATmega328の割り込み

 ここからは、Arduino UNOに搭載されたATmega328というMCUについて説明します。
 ATmega328には、MCUのステータスを示すステータスレジスタ(SREG)という8ビットレジスタが存在します。MCUの内部状態を命令実行毎に更新しているレジスタで、どう見てもMCUリソースなのですが、面白いことにI/Oレジスタとして、I/O空間(アドレス0x3f、メモリマップドI/Oでは0x5f)にマッピングされています。
 このSREGの第7ビットをIビットと呼び、ATmega328全体の割り込み許可(I=1)/禁止(I=0)を設定することができるビットとなっています。すべての割り込みが対象なので、I=0を設定すると、全ての割り込みが禁止されます(割り込みがかからなくなる)。
 プログラムで明示的に割り込みを許可/禁止することもでき、Cの標準ライブラリでは、interrupts()/noInterrupts()関数で可能です。アセンブラでは、sei/cli命令が相当します。

※メモリマップドI/Oとの合わせ技で、MCUのステータスレジスタであるSREGの値を、C言語から簡単に保存・回復できるのは、面白い仕様だと思います。普通なら、MCUリソースにアクセスするため、インラインアセンブラなどで記述が必要となります。

  // SREGを保存する
  uint8 sreg_bk=SREG;
  // 割り込みを禁止する。
  noInterrupts ();
  // 割り込み禁止中の処理
    :
  // SREGを回復する
  SREG=sreg_bk;


 ATmega328では基本的に、多重割り込みがかからないような構造になっており、一旦割り込み処理(ISR; Interrupt Service Routine)が始まると、ハードウェアでI=0が設定されて、割り込みが禁止されます。割り込み処理から復帰する専用のreti命令が実行されると、再びI=1に戻って、次の割り込みを受け付けられるようになります。

割り込み要因

 割り込みには、それを起こすための要因があります。例えば、タイマカウンタがオーバフロウした時や、AD変換が終了したとき、等です。
 16ビットタイマ(TC1)を例にとると、以下の要因が定義されています。

 このような、I/Oユニットによる割り込みでは、条件が一致したときに立つ「フラグ」と、フラグが立った時に割り込みを要求するかどうか?を指定する「割り込み許可ビット」が、各I/Oユニットの制御レジスタに備わっています。
 16ビットタイマ(TC1)を例にとると、割り込みフラグレジスタ(TIFR1)と、割り込みマスクレジスタ(TIMSK1)の各ビットが相当します。

 つまり、割り込みは以下の手順で発生します(TC1のアウトプットコンペアマッチA割り込みの例)。

割り込みベクタ

 割り込みが発生したときに、プログラムのどの部分(ISR)を実行すれば良いのか?を、割り込み要因毎に指定可能なテーブルを持っており、このテーブルのことを「割り込みベクタ」と呼びます。ATmega328では、割り込みベクタはプログラム空間(Flashメモリ)の先頭アドレス0x0000〜0x0033に配置されています。TC1の割り込みベクタは、0x0014〜0x001bの16バイトです。
 1要因当たりのベクタは、各々4バイトで構成され、以下の様なアセンブラ命令が並んでいます。

アドレス データ アセンブラ 割り込み要因
0x0014 0x0c94,<TIM1_CAPT> JMP TIM1_CAPT TC1インプットキャプチャ
0x0016 0x0c94,<TIM1_COMPA> JMP TIM1_COMPA TC1アウトプットコンペアマッチA
0x0018 0x0c94,<TIM1_COMPB> JMP TIM1_COMPB TC1アウトプットコンペアマッチB
0x001a 0x0c94,<TIM1_OVF> JMP TIM1_OVF TC1オーバフロウ

※jmp命令は4バイト命令で、0x0c94に続き、ジャンプ先のアドレスが2バイト続く(Little Endian)。
※補足:ATmega328のプログラム空間は、16ビットバスなので、アドレスは16ビットワードアドレスになっている(バイトアドレスにあらず)。

 割り込みベクタはFlashメモリ上に存在するので、プログラムの実行途中で書き換えることはできず、コンパイル時に割り込み先アドレスが指定されている必要があります。

ISR()マクロ

 Arduino開発環境では、コンパイラに対して割り込みサービスルーチン(ISR)であることを示し、割り込みベクタに登録させるためのマクロISR()が定義されています。以下の様に使います。

  ISR (TIMER1_COMPA_vect)
  {
    // OCI1A interrupt service routine
    // TC1アウトプットコンペアマッチA割り込みで実行すべき処理を記述する。
      :
    // clear interrupt flag
    // 割り込みフラグをクリアして、ISRを抜ける。
      :
  }


 ISRを抜けて、通常処理に復帰する前に、割り込み要因となったフラグをクリアしておかなければならない点に留意してください。フラグをクリアしておかないと、ISRから復帰した瞬間に、同じ割り込みが繰り返し発生して、通常処理がハングしてしまいます。

※ISRに飛んだ時点でフラグが自動クリアされるI/Oユニットもあるので、データシートで確認して使ってください。

 マクロISR()に渡す引数は、「割り込みベクタ名」となっており、以下のディレクトリに存在する、「iom328p.h (UNO等ATmega328機用)」や「iom32u4.h (Leonardo等ATmega32U4機用)」等のヘッダファイル内で定義されています。

 ATmega328データシートと突き合わせると、容易に対応が付く名前になっています。例えば、TC1の割り込みベクタ名は以下の様に定義されています(iom328p.h)。

  #define TIMER1_CAPT_vect _VECTOR(10)
  #define TIMER1_COMPA_vect _VECTOR(11)
  #define TIMER1_COMPB_vect _VECTOR(12)
  #define TIMER1_OVF_vect _VECTOR(13)


 割り込み要因はMCU毎に異なるので、ターゲットMCUに合わせてコーディングする必要があります。

タイマ割り込みを使う

 具体的にプログラムを組んでみましょう。

目標仕様

 目標仕様は以下の通りとします。

 目標仕様に注釈しておきましたが、I/Oユニットの設定を個別に変更して使用するので、同時に使用できなくなるライブラリなども出てきます。影響範囲を精査した上で使用しましょう。

タイマの設定

 タイマは、16ビットタイマ(TC1)を使います。所望の間隔で一定時間毎に割り込みを掛けたいので、タイマの動作モードをCTC(Clear Timer on Compare)モードに設定します。このモードは、タイマカウンタ(TCNT1)が、アウトプットコンペアマッチレジスタA(OCR1A)と一致した時に、TCNT1=0x0000にクリアして、カウントを続けるモードです。
 このモードでは、TCNT1=OCR1Aとなった時に、アウトプットコンペアマッチA割り込み(TIMER1_COMPA_vect)が発生するように設定することができます。TIMER1_COMPA_vect割り込みが、ちょうど1秒毎に起きるように設定しておくと、割り込みサービスルーチン内で、経過時間(秒)をカウントすることが可能となります。

 以下、1秒毎に割り込みを発生させるためのTC1のレジスタ設定値です。

レジスタ名 アドレス 設定値 説明
TCCR1A 0x0080 0x00 波形モード:Clear Timer on Compare (OCR1A) Mode
出力モード:Normal port operation.
TCCR1B 0x0081 0x0c 波形モード:Clear Timer on Compare (OCR1A) Mode
クロック選択:clkio/256
OCR1A 0x0088 0xf423 周期:1s (逆算すると、(0xf423+1)/(clkio/256)=1.000s)
TCNT1 0x0084 0x0000 割り込み設定したときに0x0000にクリア
TIFR1 0x0036 0x02 アウトプットコンペアマッチAフラグ(OCF1A)クリア
TIMSK1 0x006f 0x02 アウトプットコンペアマッチA割り込み有効(OCIE1A)

 OCR1A=0xffffを設定した時、割り込み間隔が最長となります。また、TCCR1Bレジスタのカウントクロック指定で、clkio/1024の設定が一番遅くなるので、この場合で4秒強が最長間隔となります。(1)設定したい割り込み間隔を実現できる最速のクロック設定を選択し、(2)所望の間隔に設定するためのOCR1A値を求める、と言う順番で設定値を決定します(赤文字部分)。

 割り込み間隔を短くする場合には、注意が必要です。間隔を短くし過ぎて、ISR内の処理時間より短い、または同程度の間隔を指定してしまうと、ほとんど通常処理を実行するCPU時間が確保できず、破綻することになります。

コーディング

 タイマを所望のモードに設定します。16ビットアクセスが必要なレジスタがあるので、noInterrupts()〜interrupts()で挟みます(理由については「#4-2 Logiscopeの拡張」で解説済みです)。

  // TC1 setting
  // ctc OCR1A mode 4, fcpu/256, OCI1A every 1s
  noInterrupts ();
  TCCR1A=0x00;
  TCCR1B=0x0c;
  OCR1A=0xf423;
  TCNT1=0x0000;
  TIFR1=0x02;
  TIMSK1=0x02;
  interrupts ();


 割り込みサービスルーチン(ISR)を宣言します。

  ISR (TIMER1_COMPA_vect)
  {
    // OCI1A interrupt service routine



 割り込みが発生した場合の動作を記述します。

    // count up second counter
    sec_count++;


 時刻の表示も、この位置で実行しています。完成版プログラム(test_interrupt.ino)でご確認ください。

 割り込みフラグをクリアして終了します。

    // clear interrupt flag
    // tc1.tifr1.ocf1a flag is automatically cleard by hardware
  }


 ただし、16ビットタイマのアウトプットコンペアマッチAフラグOCF1Aは、割り込みが発生したときにハードウェアで自動的にクリアされるので、ISR内では何もしなくてOKです。I/Oユニット毎に、フラグのクリア条件(方法)が異なるので、データシートで確認しながらコーディングする必要があります。

 変数sec_count (uint32)が、1秒毎にカウントアップされる秒カウンタとなっています。sec_countは、割り込みサービスルーチン内で非同期に更新されるので、loop()関数など、通常プログラム内からアクセスする時には、アクセスの途中でタイマ割り込みが発生しないように、配慮する必要があります。
 以下、seconds()関数は、秒カウンタを安全に読み出すための関数です。

  uint32 seconds (void)
  {
    // it returns second counter value
    // return: second counter value
    uint32 sec;

    // atomic access by disabling interrupt
    noInterrupts ();
    sec=sec_counter;
    interrupts ();

    return (sec);
  }


 完成版プログラム(test_interrupt.ino)では、seconds()関数をもう少し一般的に使えるように、SREGの割り込み許可(I)フラグを保存するようにしてあります。

※通常プログラムにおいて、sec_count値を変更する場合にも、同様に割り込みを禁止する必要があります。

動作確認

 完成版プログラムをリンクしておきます。

  完成版プログラム:test_interrupt.ino

 Arduino UNO (ATmega328)をターゲットにしていますが、Leonardo、Micro (ATmega32U4)等でも動作可能なはずです(筆者は動作未確認)。Arduino IDEでコンパイルして、UNOに転送し、シリアルモニタを開きます。

  実験サンプル:【 】内はシリアルモニタからの送信データ
  【set,10,59,30】
  OK: set clock=10:59:30

  【show,10】 …… 10秒毎に時刻が表示される
  OK: show clock every 10[s]
  CLK: 10:59:31
  CLK: 10:59:41
  CLK: 10:59:51
  CLK: 11:00:01
    :

  【show,0】
  OK: hide clock

 結果だけ見ると、今まで通りloop()関数内で時間を計測しながら表示した場合と、見た目は同じです。しかし、ちゃんと割り込みを使って処理できているのは、プログラムを見ていただければ明らかです。定期的に処理する必要のあるアプリケーションには、力を発揮するはずです。
 尚、一応「時計表示」ってことですが、3時間くらい放置しておくと、結構時間がずれます(さすがに1秒前後だったけれど……)。タイマの設定が間違えているのか??と、一応見直してみましたが、合っていそうなので、「ATmegaの16MHzクロック発振周波数精度のせいだ」と思い込むことにしました(1^^;)。


 これで、Arduinoを使って割り込みを受ける基礎は解説できたので、タイマ割り込み以外にも、応用が利くはずです。ただ、ATmega328のデータシートを見てみても、割り込みが力を発揮しそうなのは、タイマ割り込みくらいですね……。

 何かの参考になれば、幸いです。


btn_arduino.png

btn_prev.png btn_next.png

home