今回の目標はインラインアセンブリで “Lチカ”
このネタは他のブログでもたまに紹介されてますが、忘れないように自分でも、まとめときます。
今回の記事の最終目標は、下のようなLチカコードをインラインアセンブリで書くことです。#13のGPIOをつかって、LEDを点滅させるもの、まぁよくあるサンプルですね。
int LED = 13;
void setup(){
pinMode(LED, OUTPUT);
}
void loop(){
digitalWrite(LED,HIGH);
delay(1000);
digitalWrite(LED,LOW);
}
Arduinoの回路図を確認する
Lチカするにはunoのコネクタ番号#13がCPUの何番ピンにつながってるか知っとく必要があります。
下図だとコネクタの#13はCPUの#19(PB5)につながってました。これはadruino forumで見つけたカラフルな回路図です。もとの回路図よりかなり見やすいです。
メモリーマップを確認する。
ArduinoはメモリーマップドIO(MMIO)を採用してます。
メモリマップドIOとは、CPUがアクセスする一部のメモリスペースに、周辺機器を制御するためのレジスタが割り当てられている仕組みのことです。
つまり、自分の制御したいピン(ここではPB5)の制御用レジスタがメモリの何番地にあるかを確認する必要があるってことです。
ATMega328のデータシートをみれば、それが分かります。前回の記事でもリンクを張りましたが、再度、念のため。
ATMega328では、フラッシュメモリというプログラムを保存するメモリと、データメモリというデータを一時的に蓄えておくメモリの二種類がありますとデータシートは言っています。
これがフラッシュメモリの内訳。
そして、次がデータメモリの内訳。データメモリで重要なのは、レジスタファイルとI/Oレジスタ。レジスタファイルは前回の記事で説明した汎用レジスタです。I/Oレジスタとは、MMIOの周辺機器制御用のレジスタになります。PB5制御用のレジスタはこんなかにあるはずです。
PB5のようなデジタル入出力のピンは、POPRTAとかBとかでグループ分けされてます。PBはPORTBの略なので、それをデータシートにあるI/Oレジスタのリストの中から探します。
あった。PORTBはアドレスが0x25 ( 標準IOアドレスの先頭番地 0x20から0x05分オフセットした場所)に割り当てられていることがわかりました。
このPORTB内のレジスタはPORTB0~7までそれぞれのbit毎に対応するピンが決まっていて、各ビットで0ならLOW、1ならHIGHとそれぞれ、出力されます。下は仕様書の抜粋。要はLEDを光らせたいなら、下のBit5をSet(True)にすればいいわけです。
デジタル入出力に関係するレジスタがもう一個ありました。それが、ポート方向レジスタと言って、デジタル入出力のピンを入力にするか、出力にするかを設定するレジスタです。LEDを光らしたいので、Bit5をSetして、出力の設定にすればよいです。
インラインアセンブリで、PORTBのBit5をSetしようとすると、コードはこんな感じです。
asm(
"sbi %0, %1 \n"
: : "I"(_SFR_IO_ADDR(PORTB)),"I"(PORTB5)
);
一行目のsbiはレジスタ内の指定のビット番号をSetする(1にする)オペコードになります。二行目の入力オペランドの一番目がPORTBのメモリアドレス、二番目がビット番号です。
_SFR_IO_ADDRはAVRのライブラリ(AVRlibc)のsfr_defs.h内で定義されてるマクロです。レジスタ名を引数で渡すと、そのレジスタのアドレスが得られます。
イメージ図でも書かれているように、sbiの出力オペランドは、汎用レジスタではなく、IOレジスタになる訳です。こうすることで、オペコードで直接、デジタル入出力ポートを制御できます。
スケッチのdigitalWrite(13,HIGH)に相当するコードを書くと。
asm
(
"sbi %0, %2 \n"
"sbi %1, %3 \n"
: : "I"(_SFR_IO_ADDR(DDRB)),"I"(_SFR_IO_ADDR(PORTB)),"I"(DDB5),"I"(PORTB5)
);
これで実際にシールドコネクタ#13とLEDを繋げれば、実際に点灯します。
同じようにして、今度はBitをクリアして、LEDを消してみる。cbiはsbiの反対で指定したビットをクリアします。
asm
(
"cbi %0, %1 \n"
: : "I"(_SFR_IO_ADDR(PORTB)),"I"(PORTB5)
);
おー消えた。次に、たとえば、PORTB5だけじゃなくて、PORTBの複数のデジタル出力を一括で設定したいよという場合は、outというオペコードを使います。outは、I/Oレジスタに指定した汎用レジスタの値を書き込みます。
#define PB_PIN_DIR 0xFF
#define PB_PIN_OUT 0xFF
asm(
"out %0, %1 \n"
: "I"(_SFR_IO_ADDR(DDRB)),"r"(PB_PIN_DIR)
);
asm(
"out %0, %1 \n"
: : "I"(_SFR_IO_ADDR(PORTB)),"r"(PB_PIN_OUT)
);
この例だと、PORTBのすべてのデジタル入出力のピンをHIGHにセットしてます。
Delay関数をつくる
LEDが点灯・消灯できるようになったので、その間に一定時間待機する関数ができればLチカは完成です。
コードは以下のとおり。これまで出てこなかった要素がジャンプ、サブルーチン、SREG、ブランチとか諸々ふえました…
#include <Arduino.h>
#define CLOCK_MHZ 16UL
#define DELAY_LENGTH_MS 1000UL
#define TICKS_1COUNT 5
uint32_t volatile DELAY_TICK =CLOCK_MHZ*1000*DELAY_LENGTH_MS/TICKS_1COUNT;
void setup()
{
asm volatile
(
"sbi %0, 5 \n"
: : "I"(_SFR_IO_ADDR(DDRB))
);
asm volatile(
"rst: \n"
"sbi %0, 5 \n"
"rcall delay \n"
"cbi %0, 5 \n"
"rcall delay \n"
"jmp rst \n" //ニューカマー JUMP
"delay: \n" //ニューカマー サブルーチン
"lds r18, %1 \n" //
"lds r19, %1+1 \n" //
"lds r20, %1+2 \n" //
"0 : \n"
"subi r18, 1 \n" //
"sbci r19, 0 \n" //ニューカマー SREG
"sbci r20, 0 \n" //
"brcc 0b \n" //ニューカマー ブランチ
"ret \n"
::"I"(_SFR_IO_ADDR(PORTB)),"p"(&DELAY_TICK)
);
}
Jmpで無限ループ
jmp というオペコードを使用すると、jmpのあとにつづくラベルにジャンプできます。下の例は、jmp rstなので、”rst: \n”と定義された箇所に処理がジャンプします。なので、@@@@の処理を永遠と繰り返す無限ループができます。
asm volatile(
"rst: \n"
@@@@@@ループ@@@@@@
"jmp rst \n"
);
サブルーチン
Cコードの関数みたいなものですね。rcall につづけて関数名で、関数名を定義した箇所に処理がジャンプします。関数の処理が終わると最後にretで戻ります。Lチカコードではdelayという名前のサブルーチンを定義しました。
asm volatile(
"rcall delay \n"
"delay: \n"
@@@@サブルーチン@@@@
"ret
);
SREGとはなんだ?
ステータスレジスタのことで、汎用レジスタでの計算結果の追加情報的なものを表示したり、します。例えば、一般的なのがキャリーフラグとか。下のように処理結果がオーバーフローしたらキャリーフラグがセットされます。
で、なんでそのキャリーフラグが、Lチカで必要なのかというと、delay関数のとこで使ってるからです
"delay: \n" //delay関数
"lds r18, %1 \n" //
"lds r19, %1+1 \n" //
"lds r20, %1+2 \n" //
"0 : \n"
"subi r18, 1 \n" // R18から1を引いてく
"sbci r19, 0 \n" // R19から0とキャリーフラグを引く
"sbci r20, 0 \n" // R20から0とキャリーフラグを引く
"brcc 0b \n" // キャリフラグが0になったら0に飛ぶ bは後方という意味
"ret
subiはレジスタから即値をひく、sbciはレジスタから即値+キャリフラグをひくオペコードです。delay関数はレジスタに設定した値をsubiをつかって、カウントダウンしてきます。
例だと、r18~r20までタイマの待ち時間(CPUクロックのtick数, 1 tick = 1 /クロック周波数)が設定されてます。CPUのクロックは激早なので、Lチカで1sec毎に点滅させるとなると、レジスタ一個じゃ不足です。ここでは3バイト確保しました。r18がLSBで、r20がMSBになります。
そうすると処理自体は下のようになります。
r18の値が1ずつ減っていき、0以下になるとキャリフラグが立ち、上位のr19がそのキャリーフラグ分で、1減算して、キャリーフラグがリセットされる。これを繰り返し、3バイトのタイマ分0になります。この時、キャリーフラグは0にリセットされなくなるので、brccで分岐処理が実行されなくなり、ループを抜けるというわけです。
ATMega328のクロックは16MHzなので、クロック1tick分が0.0625usecになります。1secのdelay関数を定義しようとすると、16,000,000 ticks必要で、これをタイマの初期値にします。といいたいとこですが、実際は、サブルーチンの呼び出しや、subi、suciなどの実行にクロックを消費するため、その影響を考慮してやる必要があります。下のTICKS_1COUNTはその微修正です。
uint32_t volatile DELAY_TICK =CLOCK_MHZ*1000*DELAY_LENGTH_MS/TICKS_1COUNT;
以上です。自分の環境ではLチカするのを確認できました。動画は地味すぎて、UPしませんが、オシロでの結果だけは一応のせときます。ちゃんと1sec間隔でLO-HIGHしてます。