アセンブラ

組み込み開発においてインラインアセンブラを挿入する際の2つの落とし穴

More than 3 years have passed since last update.

■インラインアセンブラを利用しなければならない理由ってなんだろう?

通常CやC++のプログラムを書く上ではアセンブラを意識することはないとおもいます。
でも、ハードウェア固有の専用命令を用いて、アルゴリズムを高速に動作させたい場合や、
プロセッサーに組み込まれたカウンタを用いてアクセスレイテンシの測定をする場合には、
いやだなぁと思いながらもプロセッサに内蔵された特殊なレジスタや、コンパイラでは活用できない命令を活用する必要があるのです。

このような場合、C言語では所望の機能を実現出来ないので、より低級な言語である
アセンブラを、インラインアセンブラを用いて記入する方法が一般的です

■インラインアセンブラの書き方事例(誤った例)

例えばプロセッサに組み込みのカウンタを用いて、
プロセッサが外部メモリにアクセスするレイテンシを測定したい場合、
下記のようにインラインアセンブラを記述します。

main.c
//0x40008000番地へのメモリアクセスレイテンシをカウンタで測定し、
//0x40008000番地へ結果を格納するプログラム
int main (void) 
{
  // 疑似アセンブラ
  // mv  :データの値の移動
  // slli:シフトレフト命令
  // or  :論理or
  // wfs :特殊レジスタからの値のコピー
  // ld  :メモリの値のロード
  // memw:直前までのメモリアクセスが完了するまでストール
  // sub :減算
  // st  :メモリへ値のストア
  asm volatile(
  "mv r0,0x4000;\
   slli r0,16;\
   mv r1,0x8000;\
   or r0, r0, r1;\  …①
   wfs r2, counter;\…②
   ld r1, r0, 0;\
   memw;\           …③
   wfs r3, counter;\…④
   sub r1, r3, r2;\ …⑤
   st r1, r0, 0;"   …⑥
  );

  return 0;
}

asm volatile();
で囲まれた箇所がインラインアセンブラです。
asmで書きだし、括弧内に機械語を記入していきます。
なお、volatile修飾子をつけると、コンパイラの最適化の影響をうけず、
ユーザが指定した通りのアセンブラが出力されます。

インラインアセンブラでは下記のようなことをしています。
①レジスタr0に0x40008000番地を代入
②メモリ測定前にプロセッサのカウンタの値を読み込み
③0x40008000番地を読み込み、読み込み結果が返ってくるまでストール
④メモリ測定後のプロセッサのカウンタの値を読み込み
⑤メモリ測定後からメモリ測定前のプロセッサのカウンタを減算し、メモリアクセスにかかったプロセッササイクル数を取得
⑥0x40008000番地に測定サイクル数を格納

ただし、実際に上記のプログラムを実行しようとすると

1.メモリアクセス時間にばらつきがある
意図通りのサイクル数となることもあれば、意図したよりも長いサイクル数となってしまうこともある
2.プログラムが正常終了せず、ハングしたり、想定外の番地へのメモリアクセスをする

といった問題が発生します。なぜなのでしょう。

■落とし穴1. 命令キャッシュミスによる想定外の遅延
通常プロセッサには命令キャッシュが備わっていて、
複数の命令を読み込み後はしばらくキャッシュヒット状態となります。

ところが、下記コードの合間で命令キャッシュミスが発生したらどうなるでしょう。

wfs r2, counter;\…②
ld r1, r0, 0;\
memw;\ …③
wfs r3, counter;\…④

命令は連続的に実行される前提で書かれたコードですので、キャッシュミスにより、
プロセッサは次の命令実行までストールしてしまい、本来計測したいメモリへのロードのサイクル数以外の時間が計上されてしまいます。

そこで、上記のコードの合間に命令キャッシュミスが発生しないようコードを修正すべきです。

■落とし穴2.スタックポインタレジスタやリターンアドレスレジスタの破壊
C言語のコンパイルを行った際には、コンパイラは関数の戻り番地や、
変数を保存するメモリの番地を記憶しているスタックポインタをレジスタに割り当てていることがあります。

自分のプロセッサが例えば32本のレジスタを持っていたとしても
各プロセッサのABI次第では全レジスタが利用できないことがあります。

インラインアセンブラを不用意に用いると、
本来破壊してはいけないレジスタを破壊し、
プログラムの異常動作を招くことがあります。

■正確な書き方の事例

2つの落とし穴にはまらないようにするために、下記のように記述を行えばよいでしょう。

main.c
__attribute__((aligned(256))…①
void test_access(void)
{
  unsigned int *addr = (unsigned int *) 0x40008000;
  unsigned int wfs0;
  unsigned int wfs1;
  unsigned int val;
  asm volatile(
  "wfs %0, counter;\
   ld %2, %3, 0;\
   memw;\           
   wfs %1, counter;\
   sub %2, %1, %0;\ 
   st %2, %0, 0;"   
  :"=r"(wfs0), "=r"(wfs1), "=r"(val)  …②
  :"r"(addr)                          …③
  );

  return;
}

int main (void) 
{
  test_access();
  return 0;
}

①キャッシュミスが発生しないための工夫
main関数からtest_access関数に飛びますが、test_access関数にattributeをつけました。
__attribute__((aligned(256))を関数の先頭につけることで、
該当の関数が256Bアライン(関数の最初の命令が必ず256Bで割り切れる番地となる)された状態となります。

256Bというのはキャッシュミス時に発生するメモリアクセスのサイズを表しておりますが、
ここはプロセッサにより柔軟に変更してかまいません。

このような記述を追記することで、レイテンシ測定時に命令キャッシュミスが起きることを回避できます。

②インラインアセンブラの出力変数の設定
③インラインアセンブラへの入力変数の設定
インラインアセンブラの関数には引数を取ることが出来ます。

実際には
asm("..." : [出力変数の引数] : [入力変数の引数] : [破壊される物理レジスタ]);
の3つの引数をとることが出来ます。省略をすることも可能です。

:で区切られた最初の領域はインラインアセンブラから出力される変数を指定する領域です。
今回のケースでは、"=r"(wfs0) などと記入しています。
"=r"の部分は制約、(wfs0)の部分は実際に対応させる変数となっています。

制約部はプロセッサの種類、コンパイラによって異なるため確認が必要ですが、一般に=やrはよく利用されています。
最初の"="は、出力変数に対する制約を表しており、=は、インラインアセンブラから見て書き込み専用であることを示しています。
続く"r"は、ジェネラルパーパスレジスタを表しています。

:で区切られた2番目の領域はインラインアセンブラへ入力する変数を指定する領域です。
今回のケースでは、"r"(addr)と記入しています。
"r"はジェネラルパーパスレジスタを表し、(wfs0)の部分は実際に対応させる変数を表しています。

今回利用はしていませんが、:で区切られた3番目の領域は破壊される物理レジスタを明示的に指定を行うことが出来ます。

インラインアセンブラやABIは日本語のドキュメントが充実していない領域なので、
参考になる情報がありましたら教えていただければ幸いです。