ArduinoでInline Assemblyをやってみる<入門編>

アセンブリ言語は機械語に近い言語で、C言語とかの高級言語に比べて、やたら、とっつきにくいですが、ハードウェアに限りなく近い言語なので、勉強するとCPUがどうやって、プログラムを実行するのかを理解できるので、トライしてもいいかもしれません。

この記事では、Inline Assembly というC言語内に直接アセンブリを追加する手法を使って、アセンブリ言語の基本的な使用方法を簡単にまとめてみました。

アセンブリ言語のメリット

  1.  C言語とかに比べ、処理が速く、メモリサイズも節約できる
  2.  コンパイラが対応していないCPUの機能をフルに使うことができる
  3.  ハードウェアの仕組みが理解できる

概要を知るのに下の動画は勉強になります(英語だけど)

13. Arduino Assembly Language Commands

An overview of assembly language programming on the Arduino Lecture 13 of 17 from EE 260 Klipsch School of Electrical and Computer Engineering New Mexico Sta…

インラインアセンブリとは

インラインアセンブリとは、C言語などで作成したプログラム内にアセンブリコードを直接記述していく方法です。

上の動画のドウェインジョンソン似のおっちゃんは ”インラインアセンブリはエレガントでない!嫌いだ!”といってましたが…。まぁ確かに勉強してみると、クセがある感じはいなめません。

ただ、アセンブリコードを一から作ると、大変なので、自分でつくったプログラムに追加していく方法が、個人的には一番リーズナブルな勉強方法かと思います。

今回は、以前の記事でAS7を導入したので、その環境を利用して、シミュレーション機能で動作を確認しながら、インラインアセンブリの基礎的な所をまとめてみます。

ArduinoにATMEL Studioを導入する方法

最終的にはアセンブリだけで、Lチカとスイッチ入力による割り込み処理を実装する所ぐらいまではいきたいと思ってます。

一番簡単なインラインアセンブリコード

asm("nop \n");
__asm__("nop \n");

インラインアセンブリはプログラム内で asmか__asm__のキーワードで( )内にアセンブリコードを書いていきます。何行かにまたがっていてもOKです。例えば、

asm(
    "nop \n"
    "nop \n"
    "nop \n"
);

でも、問題ない。あと別にasmを何回呼び出してもOKです。

asm("nop \n");
asm("nop \n");
asm("nop \n");

これも問題なし。命令コードは ” ”の中に書いて、コードが複数行にわたるときは改行コードをいれます、従って、最後の行は必ずしも改行はいりません。あってもいいですが。

asm内の命令コードに対しては、コンパイラの最適化処理をいれたくないよって場合は、asm の直後にvolatile をいれて、

asm volatile ("  hogehoge   \n");

と書けます。

nop はCPUへの指示で、No operationの略らしい。つまり何もしないでという命令です。このように3~5文字ぐらいで、CPUの処理を指示するコードがきまっていて、これをオペコードといい、記号自体をニーモニックといいます。ニーモニックはCPUのデータシートをみれば、これがどんな処理しますみたいな内容を確認できます。

Arduino UNOはATMega328Pというマイコンを使用してるので、そのデータシートを見ると

NOP-無操作としっかり書かれています。

一見、NOPは、無駄そうなコードだけど、役にたつこともあります。たとえば、delay関数などをつくるときなどです。NOPの1回の呼び出しでCPUの1クロック分を消費します。ATMega328pのクロック数は16MHzなので、0.0625usの待ち時間が発生する訳です。

これを繰り返し呼びにいけば、なんとなく指定の時間だけCPUを待機させるみたいな機能を実現できそうな感じです。

汎用レジスターってなんなのよ?

次に新たなニーモニック登場させると

asm("ldi r26, 42  \n" );

今度は、オペコードの後にr26と42の数値が入ってます。この二つをオペランドっていいます。大体の場合、オペコードの後にはオペランドが必要になります。関数の引数と戻り値みたいなもんです。オペコードとオペランドがセットになって一つの処理を実現します。r付のオペランドはCPUの持っている汎用レジスタを表します。

汎用レジスタは何なんだかっていうと、CPUが色々計算するときに使う超高速でアクセスできるメモリのことです。

CPUは何かの処理をするときに、一端、メモリ(SRAM)から値を汎用レジスタにコピーして、計算をして、またメモリ(SRAM)に値を渡すようなことをしています。つまりCPUの作業スペースみたいな感じですかね。

ATmega328Pの汎用レジスタは名前がついていて、r0~r31で、全部で32個あります。ちなみに1個のレジスタに対して、8bitの信号を保存することができます。

例に戻るとldi はデータシートでは、

即値の取得ってあります。要するに数値をレジスタに格納しますってことです。r26のレジスタに42っていう値をいれますという指示になる訳ですね。

ここで、AS7の出番です。前回のスケッチを変換したプロジェクトでもなんでも持ってきて、上のコードをsetup()内にコピペして、シミュレーションを実行してみましょう。

シミュレーション実行方法も前の記事をみてね。

シミュレーションが始まったら、メニュー → デバッグ → ウィンドウ → Processor Statusをクリックします。

すると、こんな画面が出てきます。下の箇所のR01~R31(画像は見切れてる)が汎用レジスタです。その他にも、PC(プログラムカウンタ)やスタックポインタ、ステータスレジスタなどのCPUの基本的な処理情報がでていますが、これらは後で一部説明します。

CPUは実際には0.01us以下のオーダでこの汎用レジスタ内の値を超高速に演算したり、メモリからコピーしたりして、プログラムが動いてるって訳です。超クレイジーですね。

以下がシミュレーション実行時の画面です。アッセンブリコードで汎用レジスタに指定した即値がきちんと格納されてるのがわかります。この方法で色々なコードを試してみると、理解しやすいです。

ニーモニックのリスト(命令要約)

ニーモニックの説明はATMega328のデータシートの中の命令要約というとこにあります。下にデータシートのリンクをのせておきました。

https://avr.jp/user/DS/PDF/mega328P.pdf

データシートから抜粋すると、ニーモニックのリストは以下のようになります。

色々あります。とりあえずは、そんなんあるんだなぁレベルでいいとおもいます。

リストの見方を少し説明します。

オペランドの列にある Rd, Rr は汎用レジスタを指します。RdはDestination Register, RrはSource Registerとそれぞれ呼ばれます。Rdの方は実際に値を書き換えられるレジスタで、Rrの方は、オペコードで使用されるレジスタになります。

たとえば、

asm("add R24, R25 \n")

だと、R24にR24+R25の結果が代入される、てな具合です。

他のオペランドとして、Kは既に出てきた即値です。また、bはビット、kはメモリアドレスを指します。こんな感じで、使いたいニーモニックがあったら、オペランドの型を確認し、それに合わせたデータを渡してやる必要があります。

簡単なサンプル

  1. 指定のレジスタの値を変数に代入する
asm("sts (hoge), r26    \n");

実際にプログラムを以下のように実装してみます。r26に42をいれ、変数にr26の値を渡してます。

// sample #1
void setup(){
Serial.begin(9600);

asm("ldi r26, 42  \n"
    "sts (hoge), r26 \n"
    );
Serial.print("hoge=");
Serial.println(hoge);
}
void loop(){}

実行して、Teratermを使って、結果を表示してみます。

普通にできてます。

2. 変数を指定のレジスタに代入する。

即値ではないので、ldiは使えません、代わりにSRAM上の変数を拾えるldsを使います。

// sample #2
hoge=42;
asm(
     "lds r26, (hoge)  \n"
     "sts (hoge2), r26 \n"
    );

input/output オペランドの制約

上のサンプルコード #1は、実は次のように書くこともできます。

// sample #1 別バージョン
#define num 42
asm(
	"ldi %0, %1  \n"
	:"=r"(hoge) :"M"(num)
);

なぞの記号が命令文の後についてきました。Inline Assemblyの一般的な形は、

asm ( " オペコード 出力オペランド、入力オペランド "
   :出力オペランドリスト:入力オペランドリスト:クラバー );

みたいに実は、なっています。これまでの例では入出力のリストとクラバーを特に設定していなかったので、省略していました。まず最初に目につく%0と%1ですが、これは後で定義してますよ的な記号です。

下のへたくそな図にイメージを書いてみました。%0と%1は:で挟んだ、それぞれのオペランドのリスト内でその内容が定義されています。

次に定義の仕方ですが、まず ” ” 内にはオペランドに対する制約条件(constraints)と修飾子(modifier)を記述します。(最適な日本語訳がわからなかったので、誤訳かも)

例だと即値を指定したレジスタに代入する処理をおこないますが、拘束条件のrはr0~r31までどのレジスタでも使用してよいという条件になり、修飾子の=は、その任意のレジスタに書き込みを許可するという意味です。

入力側は拘束条件がMとなっています。これは入力値はuint8の定数にするという条件です。

その後につづく()内はCコード内の変数になります。

下の絵の方にイメージを書き足してるんですが、()内の変数はCコード視点で、Inline Assemblyに受け渡しする変数を定義します、次に ” “内では、最初の方で説明したニーモニックが指定しているオペランドの型を拘束条件とします。ldiは入力が即値で、出力がレジスタでした。なので、拘束が、それぞれ”M”と”r”となる訳です。

各ニーモニックに対する拘束条件のリストはAVR libcのサイトで確認できます。

https://cega.jp/avr-libc-jp/

また、Constraintsの各記号の意味は以下に貼っておきます。

簡単なサンプルをさらに

a=10 とb=20を入れ替えて、a=20 b=10にするプログラムのサンプルをつくってみます。

これが通常のコードです。

#include <Arduino.h>

volatile int8_t a=10;
volatile int8_t b=20;
volatile int8_t temp;

void setup()
{
Serial.begin(9600);

temp=a;
a = b;
b = temp;

Serial.print("a=");
Serial.println(a);

Serial.print("b=");
Serial.println(b);  

inline assemblyで実装すると、

#include <Arduino.h>

volatile int8_t a=10;
volatile int8_t b=20;

void setup()
{
Serial.begin(9600);

asm(
    "lds r24,   (a) \n"
    "lds r25,   (b) \n"
    "sts (b),   r24 \n"
    "sts (a),   r25 \n"
);

Serial.print("a=");
Serial.println(a);

Serial.print("b=");
Serial.println(b);
}
void loop(){}

movというニーモニックを利用するともっと効率的にコードを書けます。ちなみにmovの処理は下のような感じです。

asm(
     "mov %0, %3 \n"
     "mov %1, %2 \n"
     :"=r"(a),"=r"(b) : "r"(a),"r"(b)
);

前のコードだとstsでわざわざレジスタから変数に出力する命令を書いてましが、出力オペランドリストのなかで変数を指定することで、その命令が省略できます。

オペランドの指定方法は[hoge]を使えば、%0, %1とかでなくて、自由にオペランド名を定義することができます。

asm(
   "mov %[out1], %[in2] \n"
   "mov %[out2], %[in1] \n"
   :[out1]"=r"(a),[out2]"=r"(b) :[in1] "r"(a),[in2]"r"(b)
);

32bitのデータを取り扱う

汎用レジスタは一つあたり8bitでした。実際の取り扱う信号が8bit以上の場合は、特別に処理の仕方を考える必要があります。例題としては、さっきやった値のスワップをuint32のデータでやってみます。

volatile uint32_t a=0x12345678; //元の信号
volatile uint32_t b=0xABCD5678;

asm(
     "mov %A0, %A3 \n"
     "mov %B0, %B3 \n"
     "mov %C0, %C3 \n"
     "mov %D0, %D3 \n"
     "mov %A1, %A2 \n"
     "mov %B1, %B2 \n"
     "mov %C1, %C2 \n"
     "mov %D1, %D2 \n"
     :"=r"(a),"=r"(b) : "r"(a),"r"(b)
);

さっきのコードからの変化点は%0,%1,%2,%3の数字の先頭にA,B,C,Dの文字がついている点です。4バイト信号を保持する場合には、レジスタが4つ必要なので、例えば、”r”(a)でレジスタに値を読んだ場合、

a=0x1234 5678なので、%A2=0x12, %B2=0x34, %C2=0x56, %D2=0x78としてそれぞれ割り当てられます。ATMega328はリトルエンディアンなので、ビッグエンディアンを採用しているCPUを扱う際はこの逆になるので、注意が必要です。

下のようなマトリックにするとすっきり整理できます。%2と%3が入力で%0と%1が出力です。

下が実行結果。地味ですが、ちゃんとスワップされるのが確認されました。

変数をポインタで渡す

タイトルの通り、変数をポインタで渡すこともできます。例えば、

uint32_t volatile test = 0x27101;

asm(
   "lds r18, %0 \n"
   "lds r19, %0+1 \n"
   "lds r20, %0+2 \n"
   ::"p"(&test)
);

上の例では32bitのうち24bit分をレジスタに格納しています。%0がLSBでここでは0x01, %0+1が0x71、%0+2が0x02になります。

XYZレジスターって何?

XYZレジスターというのがあって、Xレジスターがr26とr27, Yレジスタがr28とr29, Zレジスタがr30とr31のレジスタをそれぞれペアで使用します。この16bitは実際の数値データではなくて、メモリアドレスを格納します。

上のポインタの例では、ldsというオペコードを使いましたが、XYZレジスタを利用する場合は、ldを使います。

uint32_t volatile test = 0x27101;

asm(
	"ld r18, X+ \n"
	"ld r19, X+ \n"
	"ld r20, X+ \n"
	::"x"(&test)
);

クラバーって何なの?

inline assembly内で汎用レジスタをこれまで色々と書き換えました。Cコード側でも当然同じように汎用レジスタを書き換えていきますので、レジスタがバッティングしてしまう可能性が十分にあります。この時にinline assemblyで使用しているレジスタ名をコンパイラに明示しておくことをクラバーといいます。

設定は簡単でこれまで隠れていた三番目の” : ” の後にレジスタ名を記述するだけです。例えば、

asm("ldi r26, 42  \n"
    "sts (hoge), r26 \n"
    : : : "r26");

シミュレーションしていて、意図しない瞬間にレジスタが書き換わるなどが発生すれば、このクラバーが適切に設定されているかを確認する必要があります。とはいっても、これまでの説明の範囲内では、そのようなシチュエーションはないので、詳細な説明は今後、またの別の機会でと言い訳しておいて、今回はここまでです。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です