今回はいわゆるCALL命令をエクササイズしてみます。A64的にはBL命令ですが。またいままで見て見ぬフリ?をしてきていたスタックポインタも登場、だいたいA64でPUSH/POPするのはどしたらよいのでしょうか?32ビットのArmとはだいぶ違う感じ。ここを乗り越えるとA64のABIが見えてくる。ホントか?
※「ぐだぐだ低レベルプログラミング」投稿順indexはこちら
※実機確認には以下を使用しております。
-
- Raspberry Pi 4 model B、Cortex-A72コア(ARMv8-A)
- Raspberry Pi OS (64bit) bullseye
- gcc (Debian 10.2.1-6) 10.2.1 20210110
ARMv8もいろいろレベルがあり、Arm Cortex-A72はARMv8の中でもベーシックな(命令数の少ない)ARMv8p0です。
※A64の最新のマニュアルは以下でダウンロード可能です。
Arm Architecture Reference Manual for A-profile architecture
サブルーチンコールとリターン
x86などのCISCでは CALLと綴ることが多いサブルーチン呼び出し命令ですが、Armでは BL です。BL、Branch with Linkですぞ。CISCでは呼び出すついでに戻り番地をメモリにプッシュするところまでやってくれますが、Armは戻り番地をリンク・レジスタLR(x30レジスタ)に保存してくれるだけ。こういうところはRISC。
またリターン命令のニーモニックはRETです。リンク・レジスタLR(x30レジスタ)に格納されている番地に無条件ジャンプするだけの命令です。
リンク・レジスタの保存
上記のようにA64はRISCの伝統にのっとり、戻り番地は特定のレジスタ上に保存されるだけです。サブルーチンをネストした場合、何もしなければ戻り番地は失われてしまいます。CALLされた後スタック上にPUSHし、RET直前にPOPするのが定番。
32ビット時代のArmの場合、結構スタック上へのPUSH、POPが過激な命令だった思い出が。A64では過激な命令は無くなってます。でもA64ではちょっと異なる状況もありです。
スタックポインタの16バイトアライメント縛り
です。汎用のxレジスタなら8バイト幅なのですが、8バイト幅のレジスタをスタックポインタSPの指すアドレスに単純PUSHすると、ミスアライメント状態になってしまうっと。wレジスタならもっとだね。このため、スタックへのPUSH、POP(実際はプリ、ポストインデックス付のロードストア命令を使う)については考慮しないとならないことが多いです。調べた中では以下のArmコミュニティの記事が一番わかりやすいんでないかと。
Using the Stack in AArch64: Implementing Push and Pop
なお、通常Cで関数コールなどする場合、コンパイラが関数プロローグ、エピローグとしてスタックフレームを生成、消去し、その中でLRを保存してくれているので気にする必要はまったくない話です。
なお、A64のABIについては以下に説明があります(全部読んでないケド。)
Procedure Call Standard for the Arm® 64-bit Architecture (AArch64)
実験に使ったアセンブリ言語ソース
Cからコールされたアセンブリ関数の中でさらにコールしてます。無理やりLRを退避しないとならない状況を作ったと。いつもと違い「ミニマム」な関数エピローグ、プロローグ的なものもありっと。
.globl tst_callret .text .balign 4 tst_callret: str lr, [sp, #-16]! bl tst_target ldr lr, [sp], #16 ret tst_target: mov x0, 0x1234 ret
実験に使用したC言語ソース
上記のアセンブリ言語関数を呼び出すCソースが以下に。とりあえずダミーの引数を与えてx0レジスタをその値で書き換えておいたのち、呼び出した関数から呼び出される孫関数で戻り値を上書きするだけのもの。
#include <stdio.h> #include <stdint.h> extern uint64_t tst_callret(uint64_t); int main(void) { uint64_t resultX; resultX = tst_callret(0xa5a5a5a500000000); printf ("tst_callret(0xa5a5a5a500000000): %016lx\n", resultX); return 0; }
とりあえずビルドして実行
とりあえずコンパイルして実行したところが以下に。期待通りの動作をしているようですが、今回はここからが長いです。
GDB(TUI)を使ってアセンブラ関数内の動作を観察
gdb の -tui オプション、使うの久しぶりです。使い方、ほぼほぼ忘れてるますな。
$ gdb -tui ./a.out
tuiモードで起動後の画面が以下に。
gdbを直接起動すると下のコマンドウインドウ部分のみの操作となるのですが、-tuiを指定すれば、ソースウインドウが上部に現れます。起動直後なので、main()関数が頭から見えてます。
レジスタ一覧画面と、停止時に次に実行されるアセンブラ命令の逆アセンブル表示とプログラムカウンタの表示を指定したところが上です。ただし、まだRunしていないので、デバッガは被デバッグ・プログラムのレジスタイメージを取得しておらずUnavailableからの出発となります。
C言語ソースの10行目(アセンブリ関数の呼び出し行)にブレークポイントをつけてRunしてみます。こんな感じ。
C言語レベルでの関数引数のロード命令を指しているみたいです。
関数tst_callretにジャンプする直前までステップ実行で進めてみます。こんな感じ。
いよいよ、アセンブラ記述の関数に突入します。入った瞬間にソース表示はC言語ソースからアセンブラソースに切り替わります。
上記では、先頭のstr命令(PUSH LR相当)のところで止まってますな。念のため、この状態でのLR(x30)と、SPの値を確かめておきます。
いやあ、SPはちゃんと16バイトアライメントされてるし。当たり前か。
16バイト分、番地は「若い」「下の」方に移ってますな。今回は一番単純な、8バイト幅のレジスタを16バイトの領域に詰め込む(8バイトあまり)というズボラな方法とりましたが、普通はちゃんとスタックスレームを確保して他の保存すべき物どもと共に格納すべきです。
このサブルーチンの先頭で、C言語レベルへの戻り値をx0レジスタにロードしてます。ステップ進めるとこんな感じ。
最初のアセンブラ記述関数に戻ってきました。RET前のLR(x30)の内容と戻り先の番地(PC)をご覧あれ。
さて、POP LRに相当する、ldr命令を使ってスタックからLRの旧値(C言語レベルの戻り先をさしている筈)を回復したところが以下に。
RETすればCのソースに戻ってきます。よかったけれど、長かったな。
久しぶりにTUI使ってしまいました。