ESP32でC/C++からアセンブリ言語を使用する
はじめに
C/C++でマイコン向けのソフトウェアを書いていると、処理速度を高めるための工夫が必要な場面に遭遇する事があることと思います。…ありませんか?私はしょっちゅうあります。
コードの無駄を省きに省き、最適化を重ねて絞りに絞ったコード…しかしあと一歩足りない…。
そんな時、解決策の候補の一つとしてアセンブリ言語を加えてみるのはどうでしょうか。
ここではESP32を対象に、C/C++のソースからアセンブリ言語を使用する方法について説明します。
前提条件
-
XtensaコアのESP32シリーズ (ESP32/ESP32-S2/ESP32-S3) を使用していること。 -
ArduinoIDEまたはVSCode+PlatformIOでESP32用のプログラムを実行できること。 - C/C++をある程度読書きできること。
※ ESP32-C3などはXtensaコアではないため、本記事の対象から外れます。
まず公式情報を入手する
ESP32のコアはXtensa LX6、ESP32-S2とESP32-S3のコアはXtensa LX7です。
アセンブリ言語で使用できる命令の詳細を知るには Xtensa の公式資料を調べるのが確実です。
執筆時点でのURLはこちら⇒ Xtensa Instruction Set Architecture (ISA) Summary
早速C/C++からアセンブリ言語を使ってみる
ESP32で実行可能なアセンブリ言語を含んだC/C++コードの例を用意しました。
ソースコード
#include <Arduino.h>
void setup(void) {}
void loop(void)
{
delay(1000);
printf("loop:\n");
for (int32_t y = 0; y < 3; ++y) {
for (int32_t x = 0; x < 6; ++x) {
int32_t val;
__asm__ ( // インライン・アセンブラの記述開始
"add a15,%1, %2 \n" // アセンブリ言語の文字列
"mov %0, a15 \n" // アセンブリ言語の文字列
: "=r" ( val ) // output_list
: "r" ( x ), "r" ( y ) // input_list
: "a15" // clobber-list
); // インライン・アセンブラの記述終了
printf(" %d+%d=%d", x, y, val);
}
printf("\n");
}
}
実行結果
loop:
0+0=0 1+0=1 2+0=2 3+0=3 4+0=4 5+0=5
0+1=1 1+1=2 2+1=3 3+1=4 4+1=5 5+1=6
0+2=2 1+2=3 2+2=4 3+2=5 4+2=6 5+2=7
概要説明
__asm__ ( ); のカッコ内にアセンブリ言語のソースコードを埋め込む「インライン・アセンブラ」と呼ばれる記述方法になります。"ダブルクォートで囲んだ文字列の形でアセンブリ言語を記述し、その後ろに :コロンで区切って output-list、input-list、clobber-list と呼ばれる記述を続けます。
まず前提知識として「アドレスレジスタ」について説明します。その後で「output-list input-list clobber-list」の役割について説明し、最後にアセンブリ言語部分の説明をしていきます。
C/C++の変数とアセンブリ言語のアドレスレジスタ
C/C++の変数の役割をするものはアセンブリ言語では「汎用レジスタ(general register)」と呼ばれることがありますが、Xtensa ではこれを「アドレスレジスタ」と呼称しており、32bitの値を扱える a0 ~ a15 の合計16個が用意されています。
アドレスレジスタにはC/C++の変数の「型」に相当するものはありません。使用する命令によってuint32_tのようにも、int32_tのようにも、void* ポインタのようにも扱えます。
現時点では「32bitの変数のようなものが16個ある」と思って頂ければ良いでしょう。
他にも浮動小数値を扱うレジスタやbool値を扱うレジスタなど様々なレジスタが存在しますが、これらはいずれ別の機会に説明したいと思います。
output-list / input-list / clobber-list
レジスタと変数の取扱いに関する記述になります。リスト間の区切りは : コロンです。
リスト内は,カンマで区切って複数記述できます。使用しない場合は空欄にできます。
output-list アセンブリ言語からの出力。アドレスレジスタから値を受け取る変数を記述。
input-list アセンブリ言語への入力。アドレスレジスタに値を渡す変数を記述。
clobber-list 値を変更したレジスタをコンパイラに知らせる記述。
__asm__ (
// ~~ 省略 ~~
: // output-list // アセンブリ言語からC/C++への受渡し
"=r" ( val ) // %0 の値を変数 val に出力
: // input-list // C/C++からアセンブリ言語への受渡し
"r" ( x ), // %1 に変数 x の値を入力
"r" ( y ) // %2 に変数 y の値を入力
: // clobber-list // 値を書き換えたレジスタの申告
"a15" // a15を書き変えたことをコンパイラに知らせる
);
output-list / input-list
output-list input-listに記述された変数にはアドレスレジスタが割り当てられ、%0 から順に番号付けされ、アセンブリコード内に記述が可能になります。
output-list に "=r" ではなく "+r" で記述した変数は入出力の両方に使用できます。
今回の例は、val x y の順に記述されているので、
%0 = val
%1 = x
%2 = y
が対応します。
これらはコンパイル時に a0 ~ a15 の中からどれかに置き換えられて処理されます。どのアドレスレジスタが使用されるかはコンパイラ任せとなります。
アドレスレジスタの個数には限りがありますから、受渡しできる変数の個数にも上限があります。インライン・アセンブラの外側の処理の都合もあるため、なるべく使用個数は少なく抑えることが望ましいです。
clobber-list
アセンブリコード内で値を変えたレジスタがある場合に、そのことをコンパイラに知らせるための記述です。今回の例では、アドレスレジスタ a15 を使用したので、ここに記述しています。複数使用している場合は"a13","a14","a15"のようにカンマで区切って全て列挙します。
アドレスレジスタの選択の指針
アドレスレジスタを使う際、わかりやすく若い番号 a0 側から順に使おう…と考えてしまいそうですが、現時点では a15 側から逆順に使用した方が良い、と思ってください。
代替手段として、output-listにダミーの変数を追加して使用することで、どのアドレスレジスタを使用するかをコンパイラに任せる方法もあります。
アセンブリコード部分
今回の例では add と mov の行がアセンブリコードの記述になります。
C/C++ならint32_t val = x + y;と記述できる内容です。
__asm__ (
"add a15,%1, %2 \n" // a15 = %1 + %2;
"mov %0, a15 \n" // %0 = a15;
// ~~ 省略 ~~
);
add 命令には続けて3つのアドレスレジスタの記述が必要で、後ろ2つのアドレスレジスタの値を合算した値が、1つめのアドレスレジスタに代入されます。
今回の例では add a15,%1,%2 ですから、a15に%1 + %2の結果が代入されます。input-list の記述により、%1 は x 、 %2 は y ですから、a15 = x + y という動作になります。
mov 命令には続けて2つのアドレスレジスタの記述が必要で、2つめアドレスレジスタの値が、1つめのアドレスレジスタにそのまま代入されます。
今回の例では mov %0,a15 ですから、%0にa15の値が代入されます。output-list の記述により、%0 は val ですから、val = a15 という動作になります。
なお今回の例は clobber-list の説明のために敢えて2行に分けてa15を経由するようにしましたが、 add %0,%1,%2 と記述して1行にまとめ、valに直接x + yの結果を代入して、a15とmov命令を不使用にしても良いでしょう。
全体の流れを再確認
ここまでの説明内容をソースコードのコメントに反映してみます。
__asm__ (
"add a15,%1, %2 \n" // a15 = x + y;
"mov %0, a15 \n" // val = a15;
: // output-list // アセンブリ言語からC/C++への受渡し
"=r" ( val ) // %0 の値を変数 val に出力
: // input-list // C/C++からアセンブリ言語への受渡し
"r" ( x ), // %1 に変数 x の値を入力
"r" ( y ) // %2 に変数 y の値を入力
: // clobber-list // 値を書き換えたレジスタの申告
"a15" // a15を書き変えたことをコンパイラに知らせる
);
各行の意味はこのようになります。
C/C++のみで記述するより長くなってしまうのはやむを得ない点ですが、アセンブリ言語を使用すること自体は さほど難しくはない…と感じて頂けたら幸いです。
CPUの内部サイクルカウンタの値を取得してみる
アセンブリ言語が使用できるようになると、C/C++のみでは実現が難しい機能を簡単に利用できることもあります。
#include <Arduino.h>
void setup(void) {}
void loop(void)
{
delay(1000);
uint32_t c0, c1;
// 変数 c0 に CPUサイクルカウントを取得
__asm__ __volatile("rsr %0, ccount" : "=r"(c0) );
// ↓ここに、処理サイクルを計測したいコードを書く↓
for (int i = 0; i < 1000; ++i) {
__asm__ __volatile("nop"); // 何もしない命令
}
// ↑ここに、処理サイクルを計測したいコードを書く↑
// 変数 c1 に CPUサイクルカウントを取得
__asm__ __volatile("rsr %0, ccount" : "=r"(c1) );
// 処理前と処理後のCPUサイクルカウントの差 (実行にかかったサイクル数) を表示する
printf("cycle : %lu\n", c1 - c0);
}
このコードは、とても短い処理の正確な実行時間を計測する例になります。
Xtensaコア内部には特殊レジスタ(Special Register)が多数存在します。この例ではその中から ccountを読み出しています。Cycle Countの略で、CPU内部の1クロックごとに値が1増加する特殊レジスタになります。
rsr命令は Read Special Registerの略で、特殊レジスタを読み出す命令です。rsr命令に続けて、値を受け取るアドレスレジスタと、読み出したい特殊レジスタを記述します。
今回の例では rsr %0, ccount です。output-listの記述により%0に割り当てられた変数に ccountの値が代入されます。
今回の例はnop命令という、何も処理を行わない命令を forループで1000回実行した結果を表示します。コンパイラの最適化条件などで変化しますが、大体5000~9000が表示されると思います。1000回のループですから、結果を1000で割った値がループ1周に要したCPUサイクルということになります。
CPU内部サイクルカウントを取得すれば、millis()やmicros()のような、ミリ秒・マイクロ秒を取得する関数よりもはるかに正確に処理時間を計測できます。
まとめ
以上、XtensaコアのESP32シリーズを対象に、C/C++ソースからアセンブリ言語を使用する方法の説明でした。なおアセンブリ言語の細部の説明はXtensaに特化した内容になりましたが、__asm__の構文に関しては他のCPUにも応用が出来ます。
次回は具体的にアセンブリ言語の書き方にフォーカスしていきたいと思います。
Discussion