これは?

前回までに TinyRemoCon を作り、データを定義するために必要な情報を公開しました。これで TinyRemoCon を使うには充分なはずなのですが、今回は中身に興味がある人向けの記事です。主にタイマ/カウンタを利用したタイミングの取り方についての内容です。

まずは改めてコードを添付します(この記事公開時に更新していますので、再取得お願いします)。

ソースコードと HEXファイル
  • src/main.asm … 各種定数定義やメインループがあります
  • src/button.asm … キーボード入力の処理です
  • src/ir.asm … 今回の解説のターゲットである赤外線送信処理です
  • src/data_def.asm … データ定義のためのマクロ定義などです
  • src/data_inc.asm … データ定義を読み込むためのソースです
  • src/data_test.asm … テストデータ定義です
  • src/data_empty.asm … 空ボタンの定義例です
  • TinyRemoCon.hex … テストデータ定義を組み込んでアセンブルした HEXファイルです

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

src/button.asm にある多ボタン読み取りの処理については、以下の記事で詳しく書いています。

リモコンに必要な多ボタンの実現についての記事

コードはアセンブリ言語で書かれていますが、AVR の機械語命令についてはこの記事では詳しく説明しません。インストラクションマニュアルをご確認ください。

ATtiny10 のタイマ/カウンタ

タイマ/カウンタ概要及び周期と duty比の設定

ATtiny10 には 16bit タイマ/カウンタが 1ch 内蔵されています。これはシステムクロックを淡々と数え、指定したカウントになったら割り込みを発生させたり、ピンの出力を変化させたり、カウント値を 0 にリセットしてカウントを継続したりします。プログラムの実行とは独立してクロックを数えてくれるので、プログラムの方では時間を忘れて処理を行うことができます。必要なタイミングになったらタイマーが教えてくれるのです。キッチンタイマーなどを思い浮かべると分かりやすいでしょうか。

タイマ/カウンタには複数の動作モードがありますが、今回はカウントの周期やピン変化の閾値をシンプルに設定できる「高速PWMモード」を使用することにします。このモードでは次のように動作します。

タイマ/カウンタ 高速PWMモード

CLOCK に合わせて TCNT0(Timer/Counter0)が自動的にインクリメントされていきます。この値が ICR0(Timer/Counter0 Input Capture Register)に設定した値(ここでは 99)に到達すると、次のクロックで 0 になります。この時 TOV0(Timer/Counter0 Overflow Flag)が立ち、これで TOV0割り込みを掛けることができます。TOV0 は割り込み処理実行時に自動的に下ろされるか、フラグ操作によって下ろさない限りは立ったままです。

カウント値が OCR0A(Timer/Counter0 Output Compare Register A)や OCR0B(Timer/Counter0 Output Compare Register B)に設定した値(ここでは 50)に到達した時には、次のクロックで OCF0A(Timer/Counter0 Output Compare A Match Flag)や OCF0B(Timer/Counter0 Output Compare B Match Flag)が立ち、これで OCF0A割り込みや OCF0B割り込みを掛けることができます。これらフラグは割り込み処理実行時に自動的に下ろされるか、フラグ操作によって下ろさない限りは立ったままです。

OC0A出力や OC0B出力はカウント値に応じて変化します。カウント値が OCR0A や OCR0B 以下の場合は High、超える場合は Low になります(イコール時点では閾値を超えていない扱いのようです)。そして TCCR0Aレジスタの設定によって OC0A出力は出力ピン PB0 に、OC0B出力は出力ピン PB1 に任意に出力することができます。この A とか B とかは、同じような比較器が 2つあって独立に使えるということです(比較元となる TCNT0 は 1つだけです)。

OC0A/OC0B の出力

要約すると、ICR0 で周期(周期 – 1)を、OCR0A や OCR0B で周期中で出力が反転する閾値、つまりは duty比を設定できます。なおこれらレジスタ幅は 16bit で、8bitレジスタ 2つで構成されています。上位が xxxH、下位が xxxL とネーミングされていて、TCNT0 で言えば TCNT0H と TCNT0L という具合です。

タイマ/カウンタに関連するレジスタは他にも多数ありますし、高速PWMモード以外の動作モードもありますから、詳細はデータシートでご確認ください。

プリスケーラ

もうひとつ、プリスケーラについても説明しておきます。
今回はタイマ/カウンタの入力クロックとしてシステムクロックを使うのですが、システムクロックを次の比率で分周して使うことができます。

  • 1/1(分周なし)
  • 1/8
  • 1/64
  • 1/256
  • 1/1024

計りたい時間に対してシステムクロックが高すぎる場合は、プリスケーラで分周して遅いクロックにして入力します。
この設定は TCCR0B(Timer/Counter0 Control Register B) の CS2~0 です。

TinyRemoCon におけるタイマ/カウンタの利用

赤外線リモコンでは、次の周期やタイミングを扱うのでした。

  • サブキャリア周波数 38kHz、1/3 duty
  • 変調単位 T
  • フレーム間隔

TinyRemoCon では、タイマ/カウンタをこの中で周期が一番短いサブキャリアの発振に利用することにします。リモコンのデータ処理と並行して正確な短周期パルスをプログラムで生成するのは困難だからです。

タイマー/カウンタで生成される周期は、システムクロック周波数とタイマ/カウンタのプリスケーラの分周比、そして ICR0 の設定値で決まります(周期は ICR0 + 1 クロックになることに留意)。OC0A出力の duty比も OCR0A の設定値で決まります。これら組み合わせの候補は以下の通りです。

No.システムクロック
周波数
プリスケーラ
分周比
ICR0
設定値
OCR0A
設定値
OC0A
周波数
OC0A
duty比
18MHz1/120913938.10kHz33.3%
28MHz1/8251638.46kHz34.6%
34MHz1/11046938.10kHz33.3%
44MHz1/812838.46kHz30.8%
52MHz1/1513338.46kHz34.6%
61MHz1/1251638.46kHz34.6%
7500kHz1/112838.46kHz30.8%
8250kHz1/16435.71kHz28.6%
9250kHz1/15341.67kHz33.3%
クロックと分周比の候補

上記候補の中では No.1 と No.3 が理想的です。しかしシステムクロック 8MHz は、ATtiny10 の電源電圧 2.7V 以上でのみ保障されます。

電圧\システムクロック0~4MHz~8MHz~12MHz
1.8~××
2.7V~×
4.5~5.5V
電源電圧とシステムクロック

今回乾電池による 3V 駆動を想定していますので条件はクリアしていますが、電池は減っていくものですので 2.7V までしか使えないというのは心もとないですし、システムクロック 4MHz 以下であれば 1.8V まで使えます。なのここは No.3 を第1候補とします。

ところでシステムクロックの最大周波数が電源電圧に依存するということは、できるだけ低クロックで動かす方がより低電圧まで安定して動くんじゃないかと思えてきます(データシートで確認できるわけではないのですが)。なのでもっと低いクロックの候補も検討します。すると No.6 が第2候補、No.7 が第3候補とります。これら候補はサブキャリア周波数が少し高めですが、SONYリモコンの 40kHz などの存在を考えるとむしろ好都合かもしれません。

と御託を並べましたが、独断で第3候補のシステムクロック 500kHz で行きます。低クロックを意識するなら、許容範囲内で一番低いクロックを選択しなければ中途半端というものです。
しかしこの選択は、以降の節で明らかにしますが使用するプログラミング技術がかなり違うものになります。むしろそれが面白くて選択したという側面もあります。

このように生成したサブキャリアを、TCCR0Aレジスタの設定を切り替えて PB0 に出したり止めたりして赤外線出力の ON/OFF とします。

赤外線ON
赤外線OFF

時間を計る

タイマ/カウンタはサブキャリアの発振に使うことにしたので、他のタイミングはサブキャリアのパルスを数えることによって作り出します。変調単位 T はサブキャリアパルス何個分、のような感じです。ではこれをどうやって数えたらよいでしょうか。500kHz という低システムクロックを選択することでタイマ/カウンタの 1周期は 13システムクロックしか無く、これが大きな制約になります。

タイマ割り込みじゃだめ

タイマ/カウンタは、1周期ごとに割り込みを掛けることができます。TOV0割り込みです。周期が 38kHz 相当なら、割り込みも 38kHz で発生します。この割り込み理ルーチンで何らかのカウンタをインクリメントするなどすれば、サブキャリアパルス数を数えることができます。便利ですね。

ただし、今回の周期は 13システムクロックです。実は割り込みは結構クロックを必要とする処理です。
次の図をご覧ください。

タイマ割り込み処理

OPn は任意の機械語命令です。左端の数字は TCNT0 の値ですが、これがオーバーフローして 0 になった時に割り込み状態になります。この時点で実行しかけていた OP1 の処理が完了したら割り込みによって割り込みベクタに飛びます。これには OP1 完了後に 3クロックかかります。通常割り込みベクタから割り込み処理へ rjmp するので、このための 2クロックも必要です。割り込み処理としてはとりあえず inc CNT で何かしらの数を数えてみます。これが 1クロック。そして割り込み処理を終えて元の処理に復帰するための reti は 4クロックかかります。
1周期 13クロックのうち、10クロックが割り込み処理のために費やされます。しかもその 10クロックのうちの 9クロックは実質的な処理を行えない税金のような時間です。割り込み処理としては inc CNT だけを行ってみましたが、実際には数えた結果何かをするわけで、そのための条件判断や処理のことを考えるととてもクロックが足りません。税金高過ぎです。

というわけで、13システムクロック周期では割り込み処理でのカウントは現実的ではありません
タイマが 2ch あれば、ひとつはサブキャリア、もう一つは変調単位 T にすることができるんですけどね。

2022/05/12 追記
次の記事に書きましたが、割り込み処理でもやれました。

WDT も使えない

実は ATtiny10 には、タイマー的なものがもう一つ内蔵されています。WDT(Watch Dog Timer) と言いますが、暴走監視などに使われます。メインループなど定期的に繰り返されるはずの場所で WDT をクリアして使います。もし何かあってフリーズした場合にはクリアされなくなり、そのうちタイムアウトして割り込みやリセットがかかります。定期的にエサをあげないと犬が騒ぎ出す感じです。

ATtiny10 の WDT は内部クロック約128kHz を 2k ~ 1024k 分周した周期で動作します。最も短周期では 16ms ということになっています。変調周期 T は 0.5ms オーダーですから、残念ながら適用できなそうですね。また WDT がこのような用途に向いていないのは周期の問題だけではなく、クロックの精度が悪いことです。データシートのグラフによれば、温度や電源電圧によって 100kHz~109kHz の幅があるようです(全然 128kHz じゃないじゃん!)。

と言っても WDT はパワーダウン状態からでも目覚められる貴重なイベントなので、とても活躍しがいのある機能です。今回は使えないというだけで、WDT を否定してるわけではないですよ。

実行クロック数を数える

ATtiny10 の命令表を見ると各命令の実行クロック数が分かります。これを見れば、ある処理が全部で何クロックで実行されるのか数えることができます。なのであるタイミングで処理を行う必要がある場合、そこに到達するまでの実行クロック数がぴったりになるようにプログラムを書けばよいのです。例えば 100クロック後に行いたいことがあったならこうです。

    処理群(100クロック)
    行いたいこと

そりゃそうだろうと思いますね。でも都合よく 100クロックぴったりの処理なんてなかなか書けませんので、こうします。

    処理群(40クロック)

    ; 60クロック待ち
    ldi LP, 20 
WAIT:
    dec LP
    brne WAIT

    行いたいこと

あくまで一例ですが、ループで時間待ちをするのです。このループの中身は、ループする場合で 3クロック、最後にループをすり抜ける際は 2クロックです。ループ前の ldi が 1クロックですから、結局 LP の初期値 x 3クロックの待ち時間になります。半端が出るなら nop を足すなどして調整します。下の通り、nop 1つで 1クロック、rjmp で 2クロック潰すことができます。

    ; 1クロック待ち例
    nop

    ; 2クロック待ち例
    rjmp loc1
loc1:

    ; 8クロック待ち例
    rcall 適当な ret へ

しかし実際のところ、プログラムは条件分岐をしながらいろいろな経路を通るので、ある区間のクロック数は条件によって一定しません。こうなりますよね。

一定しない実行クロック

処理A~F の内側は場合によって 10、22、18、21クロックになります。
それならこうします。

実行クロック数を揃える

これはどの経路を通っても 22クロックです。このように経路ごとの実行クロック数さえ揃えてしまえば、後はしかるべきタイミングまでのウェイトを入れればタイミング通りに処理を行うことができます。

実際この作り込みはなかなか大変なのですが、割り込みでのカウントができない以上このように実行クロック数を調整してタイミングを測っていくしかありません。これはアセンブラコーディングだからこそやれる技と言ってもよいので、むしろこのパズルを楽しむべきです。

TinyRemoCon での実装

と言っても全ての実行経路でクロック数を合わせるのは大変な作業です。例えばループに初めて入るときと 2回目以降では違うでしょうし、遅い処理に合わせて逐一ウェイトを入れていったら結局処理時間が足りなくなったり。これをいくらか軽減するために、タイマ/カウンタを使います。割り込みは使えなくても、時間経過で変化するフラグを利用することはできるのです。
こんなフラグがありました。

  • TOV0 … TCNT0 が 1周したときに SET
  • OCF0A … TCNT0 が OCR0A に一致した次のクロックで SET
  • OCF0B … TCNT0 が OCR0B に一致した次のクロックで SET

プログラムの実行がある地点に来るまでにクロックがばらついている時、上記フラグを頼りにウェイトするクロック数を調整するようにします。例えば TOV0 が SET されるまで待つ、などです。そうするとばらついたクロックもタイマに同期させることができます。
TinyRemoCon ではこのために、ir.asm で次のような SYNC_TIMER というマクロを定義しています。これはプログラムの処理タイミングをタイマのオーバーフローに同期するためのマクロです。

.MACRO SYNC_TIMER
    ; 0, 4, 8, 12   => 3
    ; 1, 5, 9       => 0
    ; 2, 6, 10      => 1
    ; 3, 7, 11      => 2
    ; 13~          => +4
SYNC_TIMER_LP:
    in TMP, TIFR0
    andi TMP, TIFR0_FLAG
    breq SYNC_TIMER_LP
    out TIFR0, TMP      ; 割り込み許可フラグクリア
.ENDM

TIFR0 からは TOV0、OCF0A、OCF0B の状態が読めます。TIFR0_FLAG は OCF0B の bit位置が 1 になっています。つまり TIFR0 の OCF0B が SET されるまでループし、SET されたらループをやめてフラグをクリアします。OCF0B が SET されるタイミングは OCR0B で定義しますが、これは TCNT0 がオーバーフローする 4クロック手前になるようにしています(OCF0B は OCR0B になった次のクロックで SET)。

この 4クロック手前というのは SYNC_TIMER を素通りした場合のクロック数で、OCR0B が SETされた瞬間に SYNC_TIMER を行った場合に TCNT0 がぴったり 0 で SYNC_TIMER を完了するタイミングです。OVF0 を処理遅延分先取りするために OCF0B を使っています。ただし内部のループ 1回も 4クロックなのでタイミング合わせは 4クロック単位の精度となり、SYNC_TIMER 直後の TCNT0 は 0~3 になります。すなわち SYNC_TIMER の目的は TCNT0 が 0~3 の範囲に同期することです。下図の★の位置が SYNC_TIMER を行った位置、▼が SYNC_TIMER を終える位置です。

SYNC_TIMERの動作


上図の通り TCNT0 が 12以下で SYNC_TIMER を実行した場合はその目的(TCNT0 が 0~3 に同期)を達することができます。しかしそれを超える場合は 2回の SYNC_TIMER が必要で、下図の通り1回目の SYNC_TIMER を 周期17相当以下で行わなければなりません。それを超えた場合は OCF0B を喪失してしまい、狂いが生じます。1度目の SYNC_TIMER を制限内に行えた場合、2度目の SYNC_TIMER を周期周期25相当以下で実行すれば同期が完了します。

OCF0B の喪失

実行経路が分岐し始め実行クロック数にバラツキが出始めたら、SYNC_TIMER によってタイマ/カウンタに同期を図ることでタイミングが取りやすくなります。ただし SYNC_TIMER はマクロであり、ひとつ置くごとにメモリを 4 words = 8 bytes を消費することに留意する必要があります。

コード詳説

フローチャート

赤外線送信処理のフローチャートです。ソースコードは src/ir.asm です。

赤外線送信フローチャート

よく見れは処理は単純です。コマンドを処理して赤外線の出力をしているだけです。ただタイミングを取るためにクロック数を厳密に数え上げ、またコード長も短くなるようにコーディングしてあります。

コマンド送信初期化

    ; ==============================================================================
    ; IR送信処理
    ; ARG: ボタン番号(0~)
    ; ==============================================================================
SUB_SEND_IR:
    ldi CONST_PULSE_FLAG, TIFR0_FLAG

    ; データアドレス取得 X
    ldi YL, LOW(ADDR(DATA_PTR))
    ldi YH, HIGH(ADDR(DATA_PTR))
    add ARG, ARG
    add YL, ARG
    adc YH, ZERO
    ld XL, Y+
    ld XH, Y

ARG に入っているボタン番号に応じて、DATA_PTR から始まるテーブルからボタン定義のアドレスを取得します。Yレジスタに DATA_PTR + ARG * 2 を求めて、そこからの 2 bytes を Xレジスタに代入しています。以降、Xレジスタが定義データを読み出すポインタになります。


    ; パルス定義取得
    ld TICK_WIDTH, X+
    ldi INITIAL_LED, TCCR0A_LED_ON
    sbrc TICK_WIDTH, 7
    ldi INITIAL_LED, TCCR0A_LED_OFF ; 先行 OFF
    andi TICK_WIDTH, 0x7f
    dec TICK_WIDTH          ; 処理時間分予め1パルス減らしておく
    ld TICKS_BIT0, X+
    ld TICKS_BIT1, X+

Xレジスタで指示される定義データから、ヘッダ部分の読み取りを行う部分です。ヘッダ部分の構造を引用しておきます。多少シンボルに不一致が見られますがご愛敬です。

ヘッダ構造

上記 Iフラグは Bit値の出力に際して赤外線を ON から始めるか OFF から始めるかの定義ですが、それに従って TCCR0A_LED_ON または TCCR0A_LED_OFF を INITIAL_LED に展開しています。dec TICK_WIDTH(TICKLEN) は、新しいティックが始まる際の同期処理で絶対に必要となるパルス数を予めカウントから差し引いています。その時にデクリメントすることで発生するクロックのロスを減らすためです。


CMD_REPEAT_ENTRY:
    ; 赤外線送信クロック
    ldi TMP, CLKPSR_CLK_SEND
    out CCP, CONST_CCPID
    out CLKPSR, TMP

    cli
    ; タイマ ON
    ldi TMP, PRR_TIM_ON
    out PRR, TMP
    ;out TCNT0H, ZERO   ; 内部一時レジスタは 0 のはずなので使いまわす
    out TCNT0L, ZERO

赤外線送信のための初期化ですが、ボタンを押し続けた場合のリピートでもここに再突入してきます。システムクロックを 500kHz に切り替えています。普段はもっとゆっくり動いていて、ここで高速に切り替えます。その他タイマ/カウンタを有効化しています。TCNT0 も 0 にクリアするのですが、本来これは 16bit レジスタなので 2度に分けての設定が必要です。通常は上位バイト、下位バイトの順で設定しますが、その手順で起こることは以下のようなことです。

  • 上位バイトを設定すると、この値は内部一時レジスタに設定される
  • 下位バイトを設定すると、この値は内部一時レジスタと同時に 16bit レジスタに一度に設定される

内部一時レジスタには前回設定した値が残り続け、これは全体の初期化で設定した 0 であることが分かっています。なので上位バイトの設定を省略しています。コード長削減のためです。


    ; リピート用にデータ先頭アドレス記録
    sts SRAM_REPEAT_ADDR_L, XL      ; 0
    sts SRAM_REPEAT_ADDR_H, XH      ; 1

    ; パルスタイマクリア(初回はすぐタイムアウトするように、できるだけ少なく)
    ldi PULSE_TIMER, 5              ; 2
                                    ; 3 PULSE_TIMER が充分であれば初回周期は多少ずれてても同期される

リピート処理のため、Xレジスタの内容を SRAM に保存します。実際のリピートコマンド処理時には、この SRAM から Xレジスタを復元したりします。それによって、ここで今送ろうとしているデータをもう一度送ることができます。
PULSE_TIMER は、次のティックを出力するまでの残りパルス数で、38kHz サブキャリアのカウントです。これをデクリメントしていって、0 になったら次のティックです。ここではまだ次のティックの状態が決まっていませんが、それが決まるまでに 5パルス分程度の時間が必要なので、その値を設定しています。

ところで各行のコメントに数字が書かれています。これはタイマ/カウンタの値です。以後周期と呼びます。この行ではこの値になっているはずというものです。ソースを組み立てる時にクロックを数えた痕跡です。

コマンド分岐

    ; コマンド処理ループ
CMD_LP:                             ; 3 LEADER(8) BYTE(9) BIT(7)
    ; コマンド取り出し
    ld LP1, X+  ; CMD               ; 7-9

    ; クロック同期
    dec PULSE_TIMER                 ; 9-11
    TIMER_SUB12                     ; 10-12 (周期 9以降であること)

ここがコマンド処理ループの先頭です。Xレジスタが指し示す定義データからコマンドを 1つ読み取って実行します。
冒頭のコメントにはループしてきた時の経路ごとの周期です。このようにクロックがバラついているので SYNC_TIMER で揃えておきたいところですが、ここでは別の方法で同期を図っています。TIMER_SUB12 がそれなのですが、これは SYNC_TIMER よりも少ないコード長でもう少しラフな同期を図るものです。周期9 以降でのみ実行実行できるのですが、単純に周期を 12 引いたように振舞います。PULSE_TIMER のデクリメントと合わせて、パルスを一つ消費した動作となります。

    ; 周期 9以降でのみ実行可
    ; 周期を -12 する
.MACRO TIMER_SUB12
    out TIFR0, CONST_PULSE_FLAG
.ENDM

SET されいるはずの OCF0B をクリアしているだけです。なので SET されているはずのタイミングで実行しないといけません。


    sbrc LP1, 7                     ; -2-0
    rjmp CMD_LEADER                 ; -1-1

    ;****************
    ;* データ出力
    ;****************
CMD_DATA:

LP1 にはコマンドが入っています。bit7 が 0 ではない = 1なら CMD_LEADER に分岐します。CMD_LEADER はリーダーの出力を意図していますが、ON/OFFコマンドの処理です。bit7 が 0 なら CMD_DATA です。

ON/OFFコマンド

CMD_DATA:
    sbrc LP1, 6                     ; 0-2
    rjmp CMD_DATA_BIT               ; 1-3

    ;****************
    ;* バイトデータ出力
    ;****************
CMD_DATA_BYTE:

コマンドの bit6 が 0 なら CMD_DATA_BYTE で BYTEコマンド処理を行います。それ以外はひとまずCMD_DATA_BIT に分岐します。

BYTEコマンド

ソースコードの順序的には次は CMD_DATA_BYTE なのですが、とりあえずコマンド分岐を網羅してしまいます。

    ;****************
    ;* ビットデータ出力
    ;****************
CMD_DATA_BIT:
    sbrc LP1, 5                     ; 3-5
    rjmp CMD_LP_BREAK               ; 4-6
    ; ビット数指定送信

コマンドの bit5 が 1 なら CMD_LP_BREAK に分岐します。これはコマンドループを一旦抜ける処理ですが、ボタン押下が続いていたらリピートコマンドを処理します。bit5 が 0 なら BITコマンドです。

THRU/BACKコマンド
BITコマンド

コマンド処理

    ;****************
    ;* バイトデータ出力
    ;****************
CMD_DATA_BYTE:
    ; バイト数指定送信
    andi LP1, 0x3f                  ; 2-4
    breq CMD_LP_BREAK               ; 3-

CMD_DATA_BYTE_LP1:
    ; 8bit送信
    ldi LP2, 8 ; 8bit               ; 4-6
    rcall SUB_SEND_BITS             ; 5-7,8 残パルス 5以上、周期 13以内
    dec LP1                         ; 5
    brne CMD_DATA_BYTE_LP1          ; 6
    rjmp CMD_LP                     ; 7
                                    ; 9

BYTEコマンド処理です。ただしバイト長が 0 の場合は ENDコマンドで、CMD_LP_BREAK に飛んでコマンドループを抜けます。後は SUB_SEND_BITS による 8bit送信を指定バイト長繰り返します。


    ; ビット数指定送信
    andi LP1, 0x07                  ; 5-7,7
    mov LP2, LP1                    ; 6-8
    rcall SUB_SEND_BITS             ; 7-9 残パルス 5以上、周期 13以内
    rjmp CMD_LP                     ; 5

BITコマンド処理です。コマンドに指定された bit長を取り出し、SUB_SEND_BITS によって指定ビット送信します。


    ;****************
    ;* リーダー送信
    ;****************
CMD_LEADER:
    ldi ARG, TCCR0A_LED_OFF         ; 1-3
    sbrc LP1, 6                     ; 2-
    ldi ARG, TCCR0A_LED_ON          ; 3-
    andi LP1, 0x3f                  ; 4-
    ; 指定長のティック出力
CMD_LEADER_LP:
    rcall SUB_NEXT_TICK             ; 5-7,7 残パルス 2以上、周期 13以内
    dec LP1                         ; 4
    brne CMD_LEADER_LP              ; 5
    rjmp CMD_LP                     ; 6
                                    ; 8

ON/OFFコマンド処理です。とりあえず出力として OFF を選び、コマンドの内容によって ON を選び直してます。この方法はコードも短くクロック数も一定になります。そして TICKS で指定されたティック分その出力を継続します。SUB_NEXT_TICK は、次のティックで ARG で指定する OF/OFF 状態に切り替えます。

ON/OFFコマンド

ビット列送信処理

    ; ==============================================================================
    ; データを取得して送信
    ; ※呼び出し元は周期 13 以内で rcall すること
    ; ※PULSE_TIMER は 5以上残っていること
    ; ※呼び出し元に戻った時点で次ティックを 2パルス消費済み、timer=5 になってる
    ; X: 送信データアドレス
    ; LP2: ビット数(1~8)
    ; 破壊: TMP ARG LP2 R16-R17
    ; ==============================================================================
SUB_SEND_BITS:
    .DEF CUR_DATA = R16
    .DEF CUR_TICKS = R17

    ; ループ先頭と合わせて2パルス分同期
    SYNC_TIMER                      ; (14) 周期 17(4) 以内に実行すること
    subi PULSE_TIMER, 2             ; (18) ループ先頭の同期分も引いておく

    ; 送信データ取得        
    ld CUR_DATA, X+                 ; (19)
SEND_BITS_LP1:
    ; パルス同期
    SYNC_TIMER                      ; (21) 初回は周期 25(12) 以内に実行すること

Xレジスタが指し示すアドレスから 1バイト取り出し、この中の LP2 bit 送信します。そのループ冒頭のタイミング合わせ処理です。

このサブルーチン内ではタイミングを整えるために SYNC_TIMER しますが、SYNC_TIMER は通常周期12以内で実行する必要があります。そして rcall 自体が 4クロック消費することを踏まえると呼び出し元では周期8以内で呼び出す必要があります。この制限は少し厳しく、呼び出し元でも SYNC_TIMER しておかないと満たしづらい条件です。しかしこの呼び出し元は BYTE処理コマンドと BIT処理コマンドの 2箇所にあるので、その両方に SYNC_TIMER を置くと 16 bytes も必要になります。1024 bytes のメモリしか無い中で 16byte は大きく、これは削減したいところです。
なので、その呼び出し元で行う SYNC_TIMER をこのサブルーチン内に置けば、SYNC_TIMER を一つ削減できます。そして内部ループ行う SYNC_TIMER と合わせて、初回に 2回 SYNC_TIMER が実行されるようにすれば、1度目の SYNC_TIMER は周期17までに呼び出せばよいことになります。rcall のクロックを差し引くと呼び出し元で周期13以内で呼び出せばよく、ゆとりが生まれます。
周期8以内か 周期13以内かの差は、小さいように見えて大きいのです。実際 BITコマンドでの rcall は周期9 での呼び出しになっていて、周期8 では間に合いません。


    ; LSB の値によってティック値選択
    mov CUR_TICKS, TICKS_BIT0       ; 0-3
    sbrc CUR_DATA, 0                ; 1
    mov CUR_TICKS, TICKS_BIT1       ; 2
    dec CUR_TICKS                   ; 3 ループ条件に ZF ではなく CF(HF) を使うため、とりあえず前半を 1 減らしておく

bit列の送信は LSB first で行われますので、送信データ CUR_DATA の LSB を確認します。これが 0 であれば TICKS_BIT0 を、1 であれば TICKS_BIT1 を CUR_TICKS に設定します。これらのデータは、下位 4ビットが bit値出力前半のティック数、上位 4ビットが bit値出力後半のティック数になっている、ヘッダで定義された値です。

ヘッダ定義

ところでここに謎の dec CURCUR_TICKSTICKS があります。CUR_TICKS には繰り返し回数が入っているわけですから、通常はループごとにデクリメントして 0 になったら終了するような処理にするかと思います。ここでは下位 4bit値についての処理ですので、例えばこんな感じです。

    ; よくあるループ例
    mov LP3, CUR_TICKS
    andi LP3, 0x0f
LP:
    :
    dec LP3
    brne LP

でもこの方法では LP3 というレジスタが必要ですし、ループ条件に Zフラグを使いますから LP3 の上位 4bit はクリアしておく必要があります。当然のような気がしますが、ちょっと煩雑ですね。
一方でこうしたらどうでしょう。

    ; ループ TIPS
    dec CUR_TICKS
LP:
    :
    subi CUR_TICKS, 1
    brhc LP

予め CUR_TICKS を dec しているので、例えば 0x23 のような値だったら 0x22 になります。ループ内で subi で 1 引くと 0x21、次には 0x20、次には 0x1f となります。この時、上位4bit から繰り下がりが発生しました。この時Hフラグ(ハーフキャリーフラグ)が立ち、これで分岐することができます。ループ回数は期待通りの 3回です。
余計なレジスタも不要でコード長も短いのですから、活用しましょう。

しかもおあつらえ向きなことに、次に待ち受ける上位4bit でのループも非常にうまく書けます。

    ; ループ TIPS2
LP2:
    :
    subi CUR_TICKS, 0x10
    brcc LP2

お分かりでしょうか。
同じ理屈ですが、引く値を 0x10 にして上位4bit を直接デクリメントしています。なので利用するフラグは Hフラグではなく Cフラグです。そして事前に必要であったはずのデクリメントは、下位4bit のループが終了した時点ですでに完了しているのです!


    ; ティック前半
    mov ARG, INITIAL_LED            ; 4
SEND_BITS_LP2:
    rcall SUB_NEXT_TICK             ; 5-8,7 残パルス 2以上、周期 13以内になること
    subi CUR_TICKS, 1               ; 4
    brhc SEND_BITS_LP2              ; 5 前4bit のカウント終了をハーフキャリーで判定、後4bit は 1減らされている

SUB_NEXT_TICK は、現在のティックが終わったら次のティックの赤外線出力を ARG で指定した通りにします。ARG には INITIAL_LED を設定しますが、これは TCCR0A_LED_ON か TCCR0A_LED_OFF の値になっています。コマンド定義のヘッダで指定された、bit値出力前半の赤外線状態です。この出力を前半ティック数続けます。


    ; ティック後半
    ldi TMP, TCCR0A_LED_ON ^ TCCR0A_LED_OFF ; 6
    eor ARG, TMP                    ; 7
SEND_BITS_LP3:
    rcall SUB_NEXT_TICK             ; 8,7   残パルス 2以上、周期 13以内になること
    subi CUR_TICKS, 0x10            ; (1:4)
    brcc SEND_BITS_LP3              ; (1:5)

ARG には TCCR0A_LED_ON か TCCR0A_LED_OFF が入っていましたが、それを反転させるのに eor を使っています。もし ARG が TCCR0A_LED_ON だった場合、それに対して (TCCR0A_LED_ON ^ TCCR0A_LED_OFF) を eor すると TCCR0A_LED_OFF になります。元が TCCR0A_LED_OFF であれば TCCR0A_LED_ON になります。二種類の値をトグルするような操作には eor(XOR) がとても便利です(にもかかわらず eori に相当する命令が無いのが残念過ぎます)。
新たな ARG を設定できたら、あとは後半出力のためのループです。


    ; 次のビット
    lsr CUR_DATA                    ; (1:6)
    dec PULSE_TIMER                 ; (1:7) ループ先頭またはループ脱出後の SYNC_TIMER分 
    dec LP2                         ; (1:8)
    brne SEND_BITS_LP1              ; (1:9)

    SYNC_TIMER                      ; (1:10)
    ret                             ; (2:1)
                                    ; (2:5)
    .UNDEF CUR_DATA
    .UNDEF CUR_TICKS

LSB の出力終わったので、CUR_DATA を右シフトして次のビットが LSB に来るようにします。またこの後残りのビットのためにループをしても、あるいは全てのビット送信を終えてループを抜けても、SYNC_TIMER がありパルス1つ分を消費するので、PULSE_TIMER をあらかじめここでデクリメントしておきます。2つの経路のためのコード(デクリメント)を共有する形で、コード長の削減をしています。


    ; ==============================================================================
    ; 現在のティックの完了を待って次のティックを開始する
    ; ※呼び出し元は周期 13 以内で rcall すること
    ; ※PULSE_TIMER は 2以上残っていること
    ; ※呼び出し元に戻った時点で次ティックを 1パルス消費済み、timer=4 になってる
    ; ARG: 次のティックの出力ピン状態
    ; 破壊: TMP
    ; ==============================================================================
SUB_NEXT_TICK:
NEXT_TICK_WAIT:
    ; 現在のパルス終了待ち
    SYNC_TIMER                      ; 初回は周期 17(4) 以内 = ループして 2度目が周期 25(12) 以内になること
    ; 現在のティックの終了待ち
    dec PULSE_TIMER                 ; +0
    brne NEXT_TICK_WAIT             ; +1

    ; 次のティックの始まり
    out TCCR0A, ARG                 ; +2


SYNC_TIMER によってサブキャリアパルスと同期を取りながら PULSE_TIMER をデクリメントして、現在のティックが終わるのをカウントします。
現在のティックが終わったら、速やかに次の状態を出力します。


    ; タイマ端数クロックの調整
    in TMP, TCNT0L  ; (3-6)         ; +3
    cpi TMP, 5                      ; +4
    breq SYNC_TIMER_LOC1 ; TMP=5    ; +5
    brcc SYNC_TIMER_LOC2 ; TMP=6    ; +6
    cpi TMP, 4                      ; +7
    breq SYNC_TIMER_LOC2 ; TMP=4    ; +8
SYNC_TIMER_LOC1:                                
    rjmp SYNC_TIMER_LOC2            ;  +9  -- +7 --
SYNC_TIMER_LOC2:
                                    ; +11 +10 +9 +8
    ; === ここで必ず 1パルス目 11clock になる ===
    mov PULSE_TIMER, TICK_WIDTH     ; (0:11)
    ; === もうすぐ 2パルス目 ===
    out TIFR0, CONST_PULSE_FLAG     ; (0:12)

    ret                             ; (0:0)

ここは周期を厳密に合わせています。この処理の先頭では周期3~6 の幅がありますが、TCNT0 の値によって処理時間の異なる経路に流し分け、最終的に必ず特定の周期になるようにしています。周期を厳密に特定することによって、呼び出し元でのクロックカウントをしやすくしています。戻り先では必ず周期4 になっています。
この処理のために 1パルス分の時間を消費しますが、これは PULSE_TIMER に代入している TICK_WIDTH がヘッダ読み取り時に既にデクリメント済みなので、ここでのデクリメントは不要です。

コマンドの終了とリピート

    ;****************
    ;* コマンド終了
    ;* LP1 は最後のコマンド 0 またはリピート
    ;****************
CMD_LP_BREAK:                       ; 4-6 5-7
    ; 次のティックで出力OFF
    ldi ARG, TCCR0A_LED_OFF         ; 5-8
    rcall SUB_NEXT_TICK             ; 6-9 残パルス 2以上、周期 13以内
                                    ; 4

ソースコード飛びます。コマンド処理ループから分岐してくる処理です。
LP1 は最後に読んだ ENDコマンドまたは THRU/BACKコマンドです。まずは SUB_NEXT_TICK によって現在のティックが終わったら赤外線出力を OFF にします。


    ; 終了コマンドまたはボタンが離されてたら終了
    cpse LP1, ZERO                  ; 5
    sbic PINB, PNO_BTNIN            ; 6
    ret                             ; 7

LP1 が 0(ENDコマンド)なら ret します。そうでなければ THRU/BACKコマンドなので、ボタンの押下状態を確認します。BTNIN が Low なら押下中なので処理続行、そうじゃなければ ret です。


    ; リピート処理
    sbrc LP1, 4                     ; 8 bit4 0:REPEAT 1:BACK
    rjmp CMD_REPEAT                 ; 9
CMD_REPEAT_BACK:
    ; バックポイント復元
    lds XL, SRAM_REPEAT_ADDR_L      ; 10
    lds XH, SRAM_REPEAT_ADDR_H      ; 11

CMD_REPEAT:

コマンドの bit4 を確認し、BACKコマンドであれば Xレジスタを SRAM に保存しておいたリピートポイントに戻します。THRUコマンドであれば、Xレジスタは次のコマンドを指したままです。

THRU/BACKコマンド

CMD_REPEAT:
    ; 低速クロック 1/16
    ldi TMP, CLKPSR_CLK_SLOW        ; 11-12
    out CCP, CONST_CCPID            ; 12
    out CLKPSR, TMP                 ; 13

    ;andi LP1, 0x0f ; CF ではなく HF を使うので不要
CMD_REPEAT_WAIT:                    ; 14-15/10
    ; ボタンが離されるまで時間待ち
    rcall SUB_NEXT_TICK             ; クロックが 1/16 なので 16ティック分待つ

    sbic PINB, PNO_BTNIN            ; 5
    ret ; ボタンが離された

    subi LP1, 1                     ; 7
    brhc CMD_REPEAT_WAIT            ; 8

    rjmp CMD_REPEAT_ENTRY

リピートの時間待ちです。システムクロックを 1/16 にしてティックを待つことで、通常の 16倍の待ち時間になります。当然サブキャリア周波数も 1/16 になりますが、赤外線出力は OFF の状態なので問題ありません。時間待ちループ内ではボタンの押下状態を確認して、ボタンが離されたらリピートを中断して ret します。
ここに到達するまでに 500kHz クロックの状態でサブキャリア 1パルス分前後の時間が過ぎていて、それを調整しないまま低速クロックに切り替えていますから、正味の待ち時間はサブキャリア 15パルス分程度短くなりますが、許容することにします。

またループするこの部分、

    subi LP1, 1             ; 7
    brcc CMD_REPEAT_WAIT    ; 8

待ち時間は LP1 の下位4bit に入っていますから、デクリメント時の繰り下がり判定に Cフラグの代わりに Hフラグを使えます。これによってループ冒頭の andi LP1, 0x0f がいらなくなり、2 bytes 削減できます。

割り込みベクタ流用

今回は割り込み処理はありませんから、普通は割り込みベクタはこんな感じになるものです。

.ORG 0
    rjmp    RESET   ; 1 RESET
    reti            ; 2 INT0 
    reti            ; 3 PCINT0
    reti            ; 4 TIM0_CAPT
    reti            ; 5 TIM0_OVF
    reti            ; 6 TIM0_COMPA
    reti            ; 7 TIM0_COMPB
    reti            ; 8 ANA_COMP
    reti            ; 9 WDT
    reti            ; 10 LVM
    reti            ; 11 ADC

でもこれら割り込みが発生すらしないようになっているのであれば、src/main.asm にあるように別のコードを置くことができます。

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

    ; ==============================================================================
    ; 未使用割り込みベクタ使いまわし
    ; ボタンが離されるまで待つ
    ; sei されてること
    ; 破壊: TMP ARG
    ; ==============================================================================
SUB_WAIT_BTN_RELEASE:
    // ボタンが離されてたら戻る
    sbic PINB, PNO_BTNIN        ; 3 PCINT0
    ret                         ; 4 TIM0_CAPT
    ldi ARG, WDTCSR_64ms        ; 5 TIM0_OVF
    rcall SUB_WAIT_WDT          ; 6 TIM0_COMPA
    rjmp SUB_WAIT_BTN_RELEASE   ; 7 TIM0_COMPB

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

今回発生する割り込みは INT0、WDT、ADC ですので、それを避けて置ける程度の小さなサブルーチンを置いています。これで 5 words = 10 bytes の節約になります。

後書き

説明は雑だったと思いますが、何か参考になったところはありましたか。
今時のマイコンはなかなか高性能です。ATtiny10 でも内蔵クロックだけで 8MHz もの速度で動かせますから、割り込み処理で様々なことをするのに十分なポテンシャルがあります。なので今回のようにわざわざ低クロック縛りで割り込み不使用で実行クロック数でタイミングを取るようなコーディングは珍しかったのではないでしょうか。500kHz の選択は処理を組み上げるパズルを楽しむためにしたと言っても過言ではないのですが、だからと言って高クロックでは必要のない手法というわけでもありません。同じことを 8MHz にも適用すればより面白いことができる可能性がありますので、非常に有意義な試みだったと思います。

ちなみにクロックを数える作業では、シミュレータが大活躍しました。タイマ/カウンタの値やフラグも逐一確認できますし、正確なパルス幅が出ているかも PC上で検証することができます。シミュレータ上で使える Stimuliファイルというのを使うと、クロックごとのピン出力をログ出力できたりします。

$log PINB
$startlog PINB_LED.log
#100000

こんな内容を LED_log.stim というファイルに書いて、デバッグ時にこれを指定して実行すると、PINB の出力変化を 100000クロックに渡って、こんな感じでログ出力してくれます。

#135
PINB = 0x01
#4
PINB = 0x00
#9
PINB = 0x01
#4
PINB = 0x00
#9
 :

135クロック経過後に PINB が 0x02 になり、その 4クロック後に 0x00 になり、という具合です。これをちょっとしたスクリプトでクロックごとの PB0 の状態に成形したり、

0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0 0 0 0 1 1 1 1 ...

もう少し見やすくしてこんな感じにしたりすると、

□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
□□□□□□□□□■■■■
 :

タイミングの検証が見た目にも非常に行いやすいというわけです。サブキャリアが間違いなく 13クロック周期で後 4クロックが ON になってることが分かります。もっと長周期で見れば、ティックの切り替わりが正しいかも検証できます。こういうのが無ければ相当厳しい検証作業になったはずですから、本当にシミュレータ様様です。ぜひ活用してください。

ともかく、赤外線リモコンは低クロックで攻めれば 500kHz でできることは分かりました。ではコード長の短さで攻めるとどうなったでしょうか。今回はあちこちに同期を取るためのコードがありましたから、コード長の面では不利だったはずです。しかし、もしシステムクロック 1MHz であれば割り込み周期は 26クロックになりますから、割り込み処理での実装の余地が出てきます。割り込み処理でシンプルな実装ができればコードも短くなるはずですから、試してみたいですね。回路の変更はいらないはずなので、あなたもソースコードをいじって試してみてはいかがですか?

といったところで、今回のシリーズはいったんおしまいです。

2022/05/12 追記
やってみたら割り込み処理でもやれました。しかもシステムクロック 500kHz のままです。処理クロックの関係で最小ティック幅が 7パルスから 10パルスに延びてしまったのと、実装もちょっと特殊なので適用可能な場面は限定的ですが、コードもシンプルになり 440 bytes から 378 bytes になりました。これは次の記事で紹介します。
500kHz でやれるのですから、1MHz ならそれほど特殊なことをしなくても余裕でやれますね。

このシリーズ

この記事です

関連記事

リモコンに必要な多ボタンの実現についての記事