【Arduino】「I2Cを使ってるとdelay()が効かなくなったぞ?」となったら、それは二重割込みのせい
最近の業務は、Arduinoなどのマイコン用ファームウェア開発がメインになってます。
マイコンでは色々と低レベル*1なところに気を配らないといけないので大変です。
今回はそんな体験談を一つ。
顛末
delay()が効かない
数ヶ月前、ある案件(以下案件A)でパルス発信用のArduinoのスケッチ*2を制作していたときの話です。
そのスケッチは、単純に言えば「I2Cでスレーブとして待ち受け→マスターから命令を受け取ったらパルスを送信」といった感じのものでした。
実装が完了していざテスト動作!となったのですが、動作させてみると想定のとおり動かない。
オシロスコープでピンの出力を調べてみたところ、ある2つのパルスについて6msほど間隔が開いているべきところが、連続で発信されていることがわかりました。
パルス発信の間にはdelay関数を噛ませて6ms待たせているにも関わらずです。
その後delay関数をいじって10ms待たせたり1s待たせたりと試行錯誤してみたのですが、連続で発信される状況は変わりませんでした。
でもdelayMicroseconds()は効く
困り果ててダメ元でdelayMicroseconds関数で6000us*3待たせてみたところ、パルスの間隔が6ms開きました。
最初は「そんなバカな!?」と思ったものですが、まぁ動くならいいかと軽い気持ちでそのまま完成扱いに。
タイマ割込みも効かない
案件Aのあと、別の案件(以下案件B)の開発をしていたときにも上記と同様の問題が発生してしまいました。
このときは複数のプログラムを並行して開発していましたが、どれも単純に言えば「I2Cマスターからの命令を受け取ったらパルスを送信」といった感じのもの*4でした。
そのプログラムのうちdelay関数で実装した数箇所がテストでうまく動作しなかったので、案件Aと同様delayMicroseconds関数に差し替えたりしてました。
delayMicroseconds関数でごまかせていたうちはそれでよかったのですが、別のプログラム(以下プログラムB1)ではより致命的な問題が発生してしまいました。
プログラムB1ではそれまでのプログラムよりパルス出力の要求レベルが高く*5、タイマ割込みを使って時間を計測する必要がありました。
ところが実装してみたものの、テストするとパルスがまったく生成されない。
おかしいなぁと思ってタイマ割込み&パルス出力の部分をsetup関数内に持っていくと、期待していたパルスがちゃんと出力されるではありませんか!
関数を記述する箇所によってパルスが出力されたりされなかったりする?
そんなことホントにあるのか?
原因
ここで原因に思い当たりました。
パルスが出力されないのは、Wire.onReceive()で登録された関数から呼ばれていた箇所だったんです。
onRequest(), OnReceive()は割込み処理である
まず前提として、Wire.onReceive関数とWire.onRequest関数で登録した関数は、I2C割込み処理の内部で呼ばれます。
Wire.hの実装(というかArduinoコア全般の実装)については、下記リポジトリで確認できます。
github.com
追ってみると、onReceiveについては下記の順で登録した関数(ハンドラ)が呼ばれていることがわかるはずです*6。
- ISR(TWI_vect)関数
- twi_onSlaveReceive関数ポインタ = TwoWire::onRequestService関数
- TwoWire::user_onRequest関数ポインタ = ユーザ登録した関数
先頭のISR(TWI_vect)関数が、まさにI2C送受信が発生したときに呼び出される割込み処理です。
AVRマイコンは二重割込みを抑制する
そして、Arduinoでよく利用されるAVRマイコンでは、割込みが発生した段階で、割込み禁止命令が実行されます。
つまり二重割込みは(デフォルトで)禁止となっているわけです。
そう、onReceiveハンドラ内でタイマ割込みを利用しようとすると必然的に二重割込み禁止に引っかかるわけです。
となるとdelay関数が効かなかった件についても察しが付きます。
delay関数も内部でタイマ割込みに依存しているわけです。
delay関数のリファレンスには書いてありませんが、attachInterrupt関数のリファレンスにははっきりと「(割込み処理中は)delay関数は機能しません」と書かれています。
www.arduino.cc
delayMicrosecond()が効くのは割込みを利用していないから
見出しの通りですが、delay関数がタイマ割り込みに依存する一方で、delayMicroseconds関数はタイマ割込みに依存しません*7。
なのでonReceiveハンドラ内で利用しても正常に動作するわけです。
その他の内部でタイマ割込みを利用している処理
タイマ割り込みを利用する処理は他にもPWMなどがあります。
ソフトウェアシリアルももしかしたら内部でタイマ割り込み使ってるかも?
その辺りとI2Cを併用する際は、同様に注意が必要かと思います*8。
修正
原因がわかったら早速修正です。
onReceive()から抜け出して本処理をするようにする
「割込み処理内部で割込みをしようとするからいけないのであって、割込み処理を抜けてから割込みをすればよい」という発想。
私はこの方法で修正しました。
具体的には下記の通り。
- 命令キューを新規追加
- onReceiveハンドラ内では命令キューに命令をツッコむだけ
- loop関数で命令キューを常時監視し、命令を見つけ次第処理する
二重割込みを利用する形でも修正はできる
一方で「二重割込みの禁止を解除することで、本来の二重割込みによるロジックをそのまま利用する」という修正も可能です。
割込み処理内部でsei関数*9を実行することで、二重割込みが可能になります。
二重割込みを利用する場合は、割込みの優先順位に気を配る必要があります。
割込みにも順位が決まっており、順位が低い割込み処理中に順位の高い割込みを発生させることはできますが、逆はできません。
割込順位は「割込みベクタ番号の若いものほど順位が高い」です。
具体的には下記ページを参照してください。
AVR の 割り込み
I2C通信の割込み(TWI_vect)は順位が低いので、タイマ割り込みなどほとんどの割込みが二重割込みの形で利用可能です。
調べた範囲内では、こちらでも修正できるようには感じました。
ただonReceiveハンドラで重い処理をすると、以降のI2C通信を阻害してしまいかねず、不具合の原因になりそうだったので見送りました。
結論
onReceive()内で重い処理をやらない。
*1:「機械部分に近い」の意
*2:Arduinoではプログラムソースを「スケッチ」と呼ぶ
*3:6ms
*4:もちろん出力するパルスのタイミングなど細かい違いはありますが…
*5:「60us間隔でパルスを100回流し続ける」といった感じ。ステッピングモータの制御とかでやります
*6:onRequestについても概ね似たようなフローです
*7:余談ですが、delayMicroseconds関数の実装を見るとif関数など実行するあらゆる命令の消費クロックを考慮に入れた実装になっており「ちゃんとしてるなぁ」と思いました(素並感)
*8:私の方ではまだ検証できてないです。間違ってたら訂正します
*9:割込みを許可する関数