うそついてました

ATtiny10 で赤外線リモコンを作る本シリーズの前回の記事、

ここの一節でこんなことを書きました。

というわけで、13システムクロック周期では割り込み処理でのカウントは現実的ではありません。

時間を計る – タイマ割り込みじゃだめ : 赤外線リモコンを作ろう (3)インサイド TinyRemoCon

システムクロックを 500kHz にすると割込み周期が 13クロック周期になって、そんな短周期では割り込み処理で何かをするのはとても無理と断言して、別の手段を採ったのです。

ですが、これはうそでした。
とりあえず最新のソースコードを置いておきますね。

ソースコードと HEXファイル

ソースコードは 4TAB です。

前バージョンでは割り込み処理を使わず 38kHz サブキャリアパルスの数を数えていましたが、今回は割り込み処理によってパルス数を数えています。ただ、そこそこ特殊な実装をしてこれを実現しています。

普通の割り込み処理

一般論として、割り込み処理でやることはこんな感じです。

  • 割り込みベクタから割り込み処理へジャンプ
  • 割り込まれた処理(主処理)へ影響を与えないためのこと

何らかのイベントが発生すると割込みが発生して、まず割り込みベクタに処理が来ます。割り込みベクタは ROM の先頭領域にあり、イベント毎に決められたアドレスに 1ワードずつ割り当てられています。ATtiny10 において各ベクタは 1ワードしかありませんから、通常は rjmp を置いて実際の割り込み処理へジャンプするか reti を置くのです。

.ORG 0
    rjmp RESET       ; ベクタ1 リセット
    rjmp INT_INT0    ; ベクタ2 INT0 割込
    rjmp INT_PCINT   ; ベクタ3 PCINT 割込
    rjmp INT_TIMCAPT ; ベクタ4 タイマキャプチャ割込
    rjmp INT_TIMOVF  ; ベクタ5 タイマオーバーフロー割込
    rjmp INT_TIMCMPA ; ベクタ6 タイマ比較A 割込
    rjmp INT_TIMCMPB ; ベクタ7 タイマ比較B 割込
    rjmp INT_ANACMP  ; ベクタ8 アナログ比較割込
    rjmp INT_WDT     ; ベクタ9 WDT 割込
    rjmp INT_LVM     ; ベクタ10 低電圧検出割込
    rjmp INT_ADC     ; ベクタ11 AD変換完了割込

そして重要なことですが、割り込み処理は主処理に意図しない影響を与えてはいけません。主にフラグになりますが、とりあえずコードで示すとこんな感じです。

INT_TIMOVF:
    in R25, SREG    ; 適当なレジスタにフラグを保存
    [処理]
    out SREG, R25   ; フラグ戻す
    reti

割り込み処理の中で何らかの演算を行うと各種フラグが変化してしまいます。主処理の方でもフラグを演算によって変化させ、それを条件判定に利用しているでしょう。なので割り込み処理では、自分が変化させてしまったフラグを元に戻しておかないと、主処理で正しいフラグ判定ができなくなってしまうのです。そのために、上記コードでは適当なレジスタ R25 にフラグ(SREG) を保存して、reti する前に元に戻しています。もちろん R25 はこのための専用レジスタとして確保しておき、他で使わないようにしておきます。またフラグ以外にも、壊してはいけない汎用レジスタも何らかの方法で保存が必要です。push/pop を使ったり SRAM に書いておいたりします。

と言ったわけで割り込み処理では本当にやりたいこと以外に行う手続きが多く、オーバーヘッドとして結構なクロック数を必要とします。上記の例で数えてみますと、割り込みベクタから割り込み処理への rjmp で 2クロック、フラグの保存/回復 で計 2クロック、reti で 4クロック、合計8クロックかかります。この他に割り込みベクタに飛んでくるまでの時間が 4クロックかかります。

今回は割込み周期が 13クロックですから、真面目にこんなことしてたらとても間に合わないのです。

TinyRemoCon の割り込み処理

そもそもやりたいことは、38kHz のサブキャリアを発振しつつティック単位で赤外線の ON/OFF を切り替えることです。1ティックはサブキャリアパルスの例えば 21個分の時間ですので、割り込み処理でこのパルス数を数えてタイミングを取りたいのです。普通に書くとこんな感じです。冒頭のベクタ5 の位置の rjmp が割り込み処理の始まりです。

     :
    rjmp INT_PULSE  ; ベクタ5 タイマオーバーフロー割込
     :

    ; パルス割り込み処理
INT_PULSE:
    in SAVE_SREG, SREG ; フラグ保存
    dec PULSE_TIMER
    brne INT_PULSE_RET ; まだ 1ティック経ってなかったら戻る

    ; 次のティック出力開始
    out TCCR0A, NEXT_LED_STATUS ; LED の出力状態更新
    mov PULSE_TIMER, TICK_WIDTH ; 新しいティックのためパルスカウンタ再設定

    ; 主処理への通知
    ldi NEXT_LED_STATUS, 0  ; ティック切り替え完了したことを主処理へ通知
                            ; 主処理では NEXT_LED_STATUS が 0 になることを見張ってティックを進める

INT_PULST_RET:
    out SREG, SAVE_SREG ; フラグ復元
    reti

PULSE_TIMER がパルス数を数えるカウンタで、デクリメントしていって 0 になったらタイミング到来です。TICK_WIDTH はその初期値で、1ティックが何パルスなのか、例えば 21 などが入っています。NEXT_LED_STATUS は主処理で決定しておきますが、次のティックの赤外線 ON/OFF の状態です。

見ての通りとても小さな処理ですが、PULSE_TIMER がタイムアップしないケースで 15クロック、タイムアップするケースでは 17クロックかかってしまい、やっぱり 13クロックの割込み周期には収まりません。

なので TinyRemoCon ではこんな風に書いてみました。main.asm にある割り込みベクタ部分です。次の節以降で細かく説明するので、ここでは眺めるだけでよいです。INT_PULSE のラベルを付けた、ベクタ6 が割り込み処理の始まりです。

.ORG 0
    rjmp    RESET                   ; 1 RESET
    reti                            ; 2 INT0 

    ; ==============================================================================
    ; パルス割り込み処理に ベクタ3~8 の未使用ベクタを流用
    ; ティックが切り替わったら ZF=1, そうじゃなかったら ZF=0
    ; タイマ割り込みが有効になっている間は ZF を使った通常の条件分岐はできない
    ; ティックが切り替わった瞬間の ARG の値が出力されるので、それまでに ARG を確定して
    ; ZF=1 になるまで待機すること
    ; ティック継続時は割り込み処理で 9clk、ティック切り替わり時は 12 消費
    ; ==============================================================================

    ; ==============================================================================
    ; 割り込みルーチン: 次のティックになった
    ; ==============================================================================
NEXT_TICK:
    out TCCR0A, ARG                 ; 3 PCINT0
    mov PULSE_TIMER, TICK_WIDTH     ; 4 TIM0_CAPT
    reti                            ; 5 TIM0_OVF

    ; ==============================================================================
    ; パルス割り込み
    ; OVF の代わりに COMPx を利用、タイミング先取を兼ねる
    ; ARG: 次のティックの出力ピン状態
    ; ==============================================================================
INT_PULSE:
    dec PULSE_TIMER                 ; 6 TIM0_COMPA
    breq NEXT_TICK                  ; 7 TIM0_COMPB
    reti                            ; 8 ANA_COMP

    reti                            ; 9 WDT
    reti                            ; 10 LVM
    reti                            ; 11 ADC

パルスタイマーがタイムアップするまで

INT_PULSE:
    dec PULSE_TIMER                 ; 6 TIM0_COMPA
    breq NEXT_TICK                  ; 7 TIM0_COMPB
    reti                            ; 8 ANA_COMP

今回の割り込みはタイマオーバーフローではなくタイマ比較A 割り込みを使い、ベクタ6 が割り込み処理の入り口になります。そして続くベクタ7~8 は未使用なので、rjmp せずにここに直接割り込み処理の一部を書いてしまいます。これで rjmp の 2クロックを削減できます。

ここに書いた処理は PULSE_TIMER をデクリメントして、まだ残り時間があったら(0 じゃなかったら) reti するまでです。このケースでは、割り込みが発生してから reti が完了するまでに 10クロックかかります。この10クロックのうちの 1クロックは、割り込み発生時に仕掛中だった主処理の処理です。そして 13クロック周期の残り 3クロックも主処理の実行時間です。つまりサブキャリア 1パルスの間の主処理の時間は 4クロック分だけということですから、これはなかなか大変です。

ところでここでは、PULSE_TIMER をデクリメントしてフラグ変化で条件判定しています。つまりフラグを変化させる=壊しているわけですが、それを元に戻していません。壊しっぱなしで reti しちゃってます。いいのでしょうか?いいのです。主処理の方ではここで壊れるフラグを使わないように処理を書いた=ここで壊れないフラグだけを使って処理を書いたからです。ここで壊れないフラグとは Cフラグと Hフラグというキャリー系のフラグなのですが、inc/dec は Zフラグなどは変化させるもののキャリー系のフラグには影響を与えません。なので主処理では条件判断に C/Hフラグなら使うことができ、逆にここで変化してしまう Zフラグなどは使えません。それを満たせるのであれば割り込み処理内でのフラグの復元は必須ではありません。

パルスタイマーがタイムアップした時

dec PULSE_TIMER が 0 になった場合は、それまでのティックが終了したということになります。そこで NEXT_TICK にジャンプします。

NEXT_TICK:
    out TCCR0A, ARG                 ; 3 PCINT0
    mov PULSE_TIMER, TICK_WIDTH     ; 4 TIM0_CAPT
    reti                            ; 5 TIM0_OVF

ここでは ARG を TCCR0A に out していますが、ARG には LED の出力ピン PB0 を Low にするかタイマの OC0A に繋ぐかのいずれかの値が主処理によって設定されています。

赤外線 OFF
赤外線 ON

なのでこの out だけで赤外線出力の切り替えは完了するのですが、この out の位置は割り込みが発生してから 8クロック目以降となります。もしオーバーフロー割込みを使っていた場合、下図の▼1のタイミングで割込みがかかり★1のタイミングで out が実行されることになります。

割り込み処理タイミング

出力を ON から OFF にしようとしている場合、この処理が 1クロック以上遅れると OFF が間に合わず OC0A が High になったことがピンに出てしまいます。このような処理の遅れは、割り込み発生時に主処理で実行していた命令の必要クロック数が 2クロック以上の場合に起こります。例えば 2クロックかかるジャンプ系命令や 4クロックかかる call/ret系命令などがありますが、これら命令の実行中に割り込みが掛かればその完了まで割り込みは遅延します。なのでオーバーフロー割り込みでは★1のタイミングが OC0A が High になって以降まで遅延することがあり、その場合にはピンにノイズが出るということになります。代わりに比較A割り込みを使用するのはこれを回避するためです。この場合は▼2で割込みがかかり★2で out を実行できますし、call/ret系命令で 3クロック遅延しても間に合います。言わば比較A割り込みをオーバーフロー割り込みの先取りとして使っているわけです。

以上のように、タイムアップによって出力を切り替えて reti する場合の処理クロック数は 13クロック、先ほどの説明の遅延分を考慮するとさらに数クロックかかりますから、サブキャリア周期 13クロック以内にわずかに収まっていません。しかしこの割り込み中に発生する次の割り込みは保留され、この割り込み処理から reti するとすぐに保留されていた分の割り込みがかかります(正確には主処理に戻って1命令が実行されてから)。その割り込み処理では PULSE_TIMER はタイムアウトせずクロック数にゆとりがあるので、ここではみ出した分のクロックのズレを吸収することができ、最終的に追いつきます。なおこの割り込み中の割り込み保留動作は1回分だけです。割り込み中に2回以上割り込みがあっても2回目以降は捨てられますので、保留されるケースでも速やかに reti しなければなりません。

ところでこの NEXT_TICK、なぜか割り込みベクタ3~5 の領域にありますが、これは単に未使用割り込みベクタを別のコードに使い回してサイズ削減してるだけです。見た目に非常にややこしいのですが、他所の場所に書けばコードサイズが 3 words = 6 bytes 大きくなるのでケチりたい時には活用できます。あともう一つ、オーバーフロー割込みを使わなかったのはクロック数のシビアさだけではなく、この場所に 3 words 確保するためでもあります。比較A割り込みを使うことによって NEXT_TICK の処理がここにピッタリはまっています。オーバーフロー割込みを使っていたら空きが 1 word 足りず、ここに置くことはできませんでした。クロック数とサイズを稼ぐ、一石二鳥の手法でした。

ティック切り替わりの通知

主処理では、次のティックの赤外線出力の状態を ARG に設定したらその値の出力が完了するまで待ちます。ですので割り込み処理で PULSE_TIMER がタイムアップして出力の切り替えが起こったことを、主処理は何らかの方法で検出する必要があります。そのために Zフラグを使います。

Zフラグは割り込み処理で壊れてしまうフラグだと説明しましたが、実際はやみくもに壊れるわけではありません。PULSE_TIMER がタイムアップしてなければ Zフラグは 0  に、PULSE_TIMER がタイムアップした時には Zフラグは 1 になるのです。ですのでこれを利用して、主処理では次のように待ちます。

    clz
WAIT_LP:
    brne WAIT_LP

最初に Zフラグを 0 にしたら、Zフラグが 0 の間無限ループします。このループの中で Zフラグに変化をもたらすのは割り込み処理だけですから、割り込み処理で Zフラグが 1 になった = PULSE_TIMER がタイムアップした = ARG が出力済みとなった ことが分かるのです。

この待ち合わせ処理は以下のマクロで定義しています。ir.asm です。

; 現ティック終了待ち
; ZF=0 であること
; マクロ直後の命令では既に ZF=0 になっている
.MACRO WAIT_TICK
WAIT_TICK_LP:
    brne WAIT_TICK_LP
.ENDM

マクロ中に clz は置いていませんので、必要に応じて外で clz する必要があります。しかし割り込み処理は 13クロック中 10クロック消費するので、主処理は最も長くても 4クロック置きに割り込みがかかることになります。なので主処理で Zフラグを 1 にするような操作があっても、WAIT_TICK するまでに 4クロック離れていれば少なくとも 1度は割り込みが入って、その時 Zフラグは 0 にされます(前回の Zフラグ変化から 4クロック命令離れていれば Zフラグは必ず 0 になっている)。このことを意識して命令の並び順を決めれば clz を省略できる場面は多いです。

ちなみに、一般的には割り込み待ちのために sleep を使うこともできます。sleepモードがスタンバイモードの場合、sleep するとプロセッサコアは停止しますがタイマ/カウンタなどは動き続け、割り込み発生時にプロセッサコアは sleep から目覚めます。これを利用する場合は現ティックの終了待ちは次のようになります。

WAIT_LP:
    sleep
    brne WAIT_LP

brne だけのループより 1命令増えてしまっていますが、sleep によるプロセッサコア停止で電力効率がよくなります。ですが今回は適用できません。割込み周期 13クロック中 sleep できる時間は 4クロック以下なので電力効率にあまり寄与しませんし、sleep してる最中からの割り込みは大きく遅延するからです。sleep してない状態では割り込みベクタに来る時点で 4クロック遅延ですが、sleep していた場合には 10クロック遅延になります。なので sleep は使えません。

Zフラグを当てにしない条件分岐手法

主処理にとって Zフラグは割り込み処理によっていつの間にか壊されてしまうので、通常の条件分岐には使えません。壊れないフラグは Cフラグと Hフラグだけですので、これだけで条件分岐するようにします。こんな感じです。

; Zフラグ使用

    ;****************
    ;* バイトデータ出力
    ;****************
CMD_DATA_BYTE:
    ; バイト数指定送信
    andi LP1, 0x3f
    breq CMD_LP_BREAK ; 0 だったら分岐

CMD_DATA_BYTE_LP1:
    ; 8bit送信
    ldi LP2, 8 ; 8bit
    rcall SUB_SEND_BITS
    dec LP1
    breq CMD_LP ; 0 になったら分岐
    rjmp CMD_DATA_BYTE_LP1
; Cフラグに置き換え

    ;****************
    ;* バイトデータ出力
    ;****************
CMD_DATA_BYTE:
    ; バイト数指定送信
    andi LP1, 0x3f
    subi LP1, 1
    brcs CMD_LP_BREAK ; 繰り下がりが起こったら分岐

CMD_DATA_BYTE_LP1:
    ; 8bit送信
    ldi LP2, 8-1 ; 8bit
    rcall SUB_SEND_BITS
    subi LP1, 1
    brcs CMD_LP ; 繰り下がりが起こったら分岐
    rjmp CMD_DATA_BYTE_LP1 

「0 になったら」ではなく「繰り下がりが起こったら」を条件にするようにします。dec reg は Cフラグに影響を与えないので、subi reg, 1 を使う必要があります。どっちも 1 word 1 クロック命令という点で同じですから、気兼ねなく使い分けられます。

続かない

前回の記事を書きながらシステムクロックを 1MHz に上げれば割り込み処理でやれそうだと思いましたが、500kHz のままでやれたのは正直驚きでした。使える割り込みや割り込みベクタの空き具合、いろいろなタイミングの全てがきっちりぴったりはまり込んでいて、ATtiny10 はこれを作るためにあったのかと錯覚させられる程です。工芸品のようだと言っても過言ではないでしょう。コードもシンプルにまとまり他の最適化と相まってサイズも前バージョン 440 bytes から 378 bytes へと 62 bytes もの削減に繋がりました。いよいよ TinyRemoCon でやり残したことも無くなり、やっと試作品じゃないリモコンを製造できそうです。既に別のものを作りたい気持ちでいっぱいですが。

といったところで、また。

このシリーズ

この記事です

関連記事