最終回の主役はスタックポインタレジスタてす。
1 今回のプログラム
今まではいつも1から100までの合計を求めてきましたが、今回はその最後の数を外から与えることにします。要するに引数付きの関数を書こうというわけです。
登場するレジスタはすべてハーフサイズ(一部はフルサイズ)ですから、400までの合計でも桁あふれは生じないでしょう。なお私のgccではunsigned intは4バイトで、ハーフサイズのレジスタと同じです。
main関数は次のようになりますね。
/*** function with argument (with_arg.c) ***/
#include <stdio.h>
extern unsigned int addsub(unsigned int);
int main(int argc, char **argv)
{ unsigned int answer;
answer = addsub(400);
printf("%d\n", answer);
return 0;
}
実は作業にかこつけて、ここでスタックの説明をしたかったのです。
2 スタックとは
スタックはよく棚に積まれたお皿にたとえられます。最後に積まれたお皿は最初に取り除くのが自然ですよね。お皿を積むのがpush、取り除くのがpopです。
プッシュ、ポップの語感からお皿自体が動くように誤解されそうですが、お皿の位置は不変で、スタックポインタレジスタがスタックを管理しています。名前が長いので今後はスタックポインタと呼ぶことにしましょう。
3 スタックポインタの動き
スタックポインタの動きを正確に説明します。その起算点は(デフォールトでなく)常にスタックセグメントSSです。ここではスタックポインタの初期値は0xC000だとしましょう。
memory
BFFA | |
BFFB | |
BFFC | |
BFFD | |
BFFE | |
BFFF | |
C000
ここでレジスタaxをプッシュします。axは2バイトですから、これがメモリに格納されて、スタックポインタの値はBFFEになります。なおプッシュは一種の代入命令で、作業後もaxの値は変わりません。
次にediをプッシュしましょう。これは4バイトですから格納後のスタックポインタの値はBFFAになります。
最後に積まれたお皿は最初に取り除くのでした。ediをポップするとediはスタックから4バイトを取得して元の値を復元し、スタックポインタの値はBFFEになります。更にaxをポップするとaxも元の値に戻り、スタックポインタの値は初期値のC000になります。(注1)
ついでですが、ポップの順番をまちがえたら?まずaxをポップするとaxには元のediの半分がはいり、スタックポインタの値はBFFCになります。次にediをポップするとediには元のediの残り半分と元のaxの値がはいります。どちらも使い物にならないデータですね
このようにスタックポインタの値が自動的に変化するというのが、ほかのレジスタにない特性です。変化幅は2か4(フルサイズのレジスタならば8)です。
説明が前後しましたが、起算点であるSS(スタックセグメントレジスタ)と(スタックポインタの初期値マイナス1)の範囲にあるメモリが、正式の「スタック」の定義です。なお正常にプッシュポップをしている限り、初期値の指しているメモリにデータを書くことはありません。
スタックポインタはadd命令などで値を外から変えることもできますが、これは厳禁です。そんな必要はほとんど考えられませんし、プログラムを実行してもきっとヘンな結果になるでしょう。
4 main関数への配慮
昔の本に「レジスタをローカル変数と思うな。みんなグローバル変数だ」と書いてありました。アセンブリ言語の関数を書いているときは、作業が終わればレジスタの値などはどこかに消えるものだと思ってしまいますが、実際にはリターン命令のとき、すべてのレジスタはそのままmain関数に返されます。
main関数に悪影響を及ぼさないよう、アセンブリ言語側で使うレジスタは最初にプッシュし、リターンの直前にポップして元の値に戻すのがmain関数への配慮というものでしょう。たとえば第1回の第1案は、正式には次のように書くべきでした。(注2)
asmadd:
push cx /* save cx */
push bx /* save bx */
movw $0, %ax /* initialize answer */
movw $100, %bx /* set limit (actually constant) */
movw $1, %cx /* initialize addend */
/* manipulate bx and cx */
/* ... */
pop bx /* restore bx */
pop cx /* restore cx */
ret
5 スタック・意外な利用者
CPUはメモリに並べられた機械語を読みながらmain関数の命令を実行して行きますが、関数を呼ぶ命令に対しては、「main関数の次の命令がはいっているメモリのアドレス」をスタックにプッシュしてから、呼ばれた関数の先頭にジャンプします。呼ばれた関数でリターン命令にぶつかると、CPUはさっきプッシュしておいたアドレスをポップして取り出し、main関数の実行を続けます。因みにアドレスのサイズは8バイトです。
こういう事情ですから、呼ばれた関数の中でプッシュしたレジスタは、その関数からリターンする前にすべてポップする必要があります。さもないとCPUがプッシュしたアドレスを再取得することができなくなってしまいます。
6 ベースポインタレジスタ
このメモで初登場のレジスタです。これも名前が長いのでベースポインタと呼ぶことにしましょう。起算点はスタックポインタと同じでスタックセグメントSSです。
名前のとおりポインタですから、メモリの内容を参照できるのは当然ですが、ベースポインタの得意技として、丸かっこの左側に正負の数値を書けば付近のメモリの内容も参照できます。これは使い方の全体像を示す方が早いでしょう。(注2-2)
push %rbp /* copy main's rbp in stack */
mov %rsp, %rbp /* rbp == rsp now */
mov 0x08(%rbp), rax /* get main's rbp */
マルチタスクのOSではスタックポインタは外から(おそらくユーザーごとに)与えられるもので、フルサイズの可能性があるのでrspにしました。そのコピー先であるrbpも同様です。2行目はコピー命令で、rbpの値がrspと同じになりました。
次の1行はサンプルですが、さっきプッシュしたrbpの値をraxにコピーしています。
ところでスタック全体の状況を眺めてみると、上の方(アドレスの大きい方)はプッシュされたrbpやCPUがプッシュしたアドレスなどが詰まっていますが、反対側は使われていません。ここに関数のローカル変数を格納することにしましょう。
7 アセンブリ言語の関数の完成
もうひとつ必要な情報がありました。引数の取得方法です。Linux x64 Calling Conventionによれば、引数は左からrdi、rsi、rdx、rcxの順番で渡されます。われわれの引数はunsigned intで4バイトですから、実引数はediにはいっている筈です。
これでアセンブリ言語による関数を書けますね。
/*** 'addsub' with argument (argument.s) ***/
/*-- note: argument in %edi --*/
/*-- note: %edi is unchanged (a constant) --*/
.globl addsub /* global .. called by 'main' */
.type addsub, @function
/* -0x04 answer (%eax) */
/* -0x08 addend (%ecx) */
/* -0x0c thelimit (== argument) */
addsub:
pushq %rbp /* copy %rbp in stack */
mov %rsp, %rbp /* %rbp == %rsp now */
mov %edi, -0x0c(%rbp) /* store argument */
mov $0x0000, %eax /* answer = 0 */
mov %eax, -0x04(%rbp) /* store answer */
mov $0x0001, %ecx /* addend = 1 */
mov %ecx, -0x08(%rbp) /* store addend */
asmadd_l:
mov -0x04(%rbp), %eax /* %eax = answer */
add -0x08(%rbp), %eax /* answer += addend */
mov %eax, -0x04(%rbp) /* store answer */
mov -0x08(%rbp), %ecx /* %ecx = addend */
inc %ecx /* addend++ */
mov %ecx, -0x08(%rbp) /* store addend */
mov -0x08(%rbp), %ecx /* %ecx = addend again */
mov -0x0c(%rbp), %edi /* %edi = thelimit */
cmp %ecx, %edi /* try (thelimit - addend) */
jnc asmadd_l /* jump if addend <= thelimit */
mov -0x04(%rbp), %eax /* (unnecessary ^_^) */
popq %rbp /* restore former %rbp */
ret
ローカル変数とレジスタとの間でやたらにデータをやり取りしていますが、もともと「ローカル変数の使い方」みたいなプログラムですから、このままにしておきましょう。
足し算の結果は「400 * (1+400) / 2」ですから80200の筈です。読者のマシンは正しい答えを表示しましたか。
8 引数の受け渡し
引数の受け渡しには昔から2種類のやり方があって、片方はgccのようにレジスタを使う方法、もうひとつは実引数を右側からプッシュしてから関数を呼ぶ方法です。
まあ昔話はさて措き、読者はすでにスタックを理解しておられますから、実引数をプッシュする方法にも対処できる筈です。スタックポインタの上の方(アドレスの大きい方)を見ると、いちばん近くに8バイトのrbpがあり、その上にCPUがプッシュした8バイトのアドレスがありますから、その更に上に4バイト(unsigned int)の実引数があります。
取り付きにくいアセンブリ言語の中でもスタックは特に取り付きにくい部分ですが、わかってしまえば簡単ですよね。
さて、読者のウォーミングアップもそろそろ終わりです。締めくくりとして次の一言を加えておきましょう。
9 アセンブリ言語の存在意義
アセンブリ言語を使えば、CPUの能力をすべて利用することができます。機械語の命令には、必ずそれに対応するアセンブリ言語が用意されています。アセンブリ言語で書けないような仕事は、そのCPUにはできません。
終わりに(雑談です)
私がTK-80を買ってから、50年近くが経過しました。今のマシンもあの機械と同様に、メモリに並べられた機械語を1バイトずつ読みながら命令を実行しているわけですが、メモリも補助記憶装置もサイズが飛躍的に増え(注3)、コンパイラも人間の書いたプログラムを添削するほど賢くなりました。
私はAIのことは何も知りませんが、AIにパソコン(あるいは大型機)の操作をすべてやらせる計画もきっと進んでいることでしょう。人間が口で指示すれば銀行システムの構築やバグの発見・修復などはみんなAIがやってくれる、そんな日が来ればプログラミング言語は要らなくなりそうです。ひょっとしてプログラマーも
しかし更に考えてみると、人間語だけでAIを操作するのは簡単ではなさそうです。たとえば私がAIに「1から100までの合計を求めよ」と命令したとしましょう。あるAIは第1回で作ったような機械語をさっとメモリに並べてくれるでしょうが、愚直なAIは「1以上100以下のすべての有理数・無理数の和を求めよ」と解釈し、必死になってアルゴリズムを考えるかもしれません。私はこの愚直なAIを責めることはできません。なぜなら私の命令は、そういう意味にも取れるからです。
人間語は人間としての常識に裏打ちされて、初めて意味が確定します。AIが人間の常識を完全に理解する日までには、まだ時間が掛かりそうです。その日まではプロの読者も、ますます商売繁盛でしょうね
(注1)
スタックポインタの正確な位置を理解しておくことは大切で、まちがえるとスタックのデータを参照するときにずれてしまいます。
本文の繰り返しになりますが、スタックポインタは積まれたデータの最も小さいアドレス(同じことですが次に積むべきデータの位置よりも1だけ大きいアドレス)を指しています。
(注2)
第1回のmain関数であるwith_asm.cを「gcc -S」で調べてみると、bxもcxも使っていませんから、実は本文のような配慮は不要でした。ただ一般論として、呼ばれた側の関数が自分で使うレジスタをプッシュ・ポップして、呼んだ側に迷惑が掛からないようにするのは、いい習慣だと思います。
追記
藤田さんのご指摘によれば、rbx(ebxとbxも同じ)はルールで変えてはいけないので、たとえmain関数でebxなどを使っていなくても、アセンブリ言語側でプッシュ・ポップして保護するのが正しいそうです。
(注2-2)
ここは藤田さんから間違いのご指摘を受けました。カッコ悪いですが間違いはそのまま残し、ご指摘が正しいことを確認済みです。
(注3)
TK-80のメモリは、忘れもしない256バイトでした。
追記 藤田さんから「メモリと書いているがramでは?」と指摘を受けました。全くそのとおりで、ramに訂正します。藤田さんによれば、ほかにromを768 バイト積んでいたそうです。
そのramのサイズですが、当初は256バイトで、増設キットを買ってきてやっと1024バイトにした記憶があるのですが、何分にも50年近く前のことで