2.8 コンピュータ・ハードウエア内での手続きのサポート
関数の呼び出し
まずは用語の確認
caller 関数の呼び元(親側) callee 関数の呼び出され側(子供側) プログラムカウンタ 現在実行中の命令がある場所(address) |
関数呼び出しの手続きは以下の流れで行われる。
パラメータのセット ($a0-$a3レジスタを使用) 手続きに処理を移す 手続きに必要なメモリを確保 手続きの実行 結果のセット ($v0-$v1レジスタを使用) 制御を元に戻す ($raにreturn addressが保存されてる) |
関数呼び出しは、アセンブラ的にはjal(jamp and link)命令を使用する
この命令で$raにリターンアドレス(プログラムカウンタに4[byte]を加えた値)をセットしてくれる
呼び出された側で呼び元にreturnする時は、”jr $ra”命令を実行する
$a0-$a3に値をセットするのは、もちろんcallerの仕事
スタックの利用
引数として必要な情報が5つ以上ある場合や、戻りの情報が3つ以上ある時は、は$a0-$a3/$v0-$v1に収まらないので、スタック領域を使用してやり取りする。
スタックポインタは$spレジスタで管理しており、大きな番地のアドレスから小さい方に伸びていく。
例題:
以下の関数をアセンブルせよ(g,h,i,jはそれぞれ$a0-$a3とする)
int func( int g, int h, int i, int j ) { int f; f = ( g + h ) - ( i + j ); return f; } |
回答例
func: addi $sp, $sp, -12 # 3つ分のスタックエリアを確保(低い番地に伸びるので負数になる) sw $t1, 8($sp) # 各レジスタ値をスタック領域にpushする sw $t2, 4($sp) sw $s0, 0($sp) add $t0, $a0, $a1 # t0 = g + h; add $t1, $a2, $a3 # t1 = i + j; add $s0, $t0, $t1 # f = t0 - t1; add $v0, $s0, $zero # v0 = s0; lw $t1, 8($sp) # 退避したレジスタ値をpopする lw $t2, 4($sp) # lw $s0, 0($sp) # jr $ra # 呼び元にreturnする |
自分が使うレジスタの退避と、スタックポインタのずらし作業はcalleeの仕事となる。
関数呼び出しを行うと、上記例のようにレジスタの退避(spilling)が手間になる。MIPSでは、以下のレジスタ以外は関数呼び出しによって破壊しても良い。
ワークで使う$t?レジスタとかまでは、毎回復元しない。
$s0-$s7 $gp $sp $fp $ra 自分および、自分より親側の関数が保存したスタック情報 |
上記のレジスタを元に戻すのはハードの仕事ではなく、呼び出された関数の仕事
2段以上の関数呼び出し
上記の説明には一部うそがある。関数の最後に”jr $ra”で呼び元に戻っているが、関数呼び出しが3段にネストすると子関数->孫関数の呼び出し時に親関数のリターンアドレスが失われてしまう。
なので、本当は$raもスタックに逃がしておく必要がある。($a0-$a3レジスタも同様)
例題:
以下のコードをアセンブルせよ(階乗の再帰処理)
nは$a0とする
int fact( int n ) { if ( n < 1 ) { return( 1 ); } else { return( n * fact( n-1 ) ); } } |
回答例
fact: addi $sp, $sp, -8 # まずは raとa0レジスタを退避(t0は退避する必要なし) sw $ra, 4($sp) # a0レジスタを退避するのは、自分が後で使うから sw $a0, 0($sp) slti $t0, $a0, 1 # 引数が1未満? beq $t0, $zero, L1 # false(1以上)だったらL1にジャンプ # n < 1の処理 addi $v0, $zero, 1 # v0 = 1; addi $sp, $sp, 8 # スタックの破棄(ra,a0を書き換えてないのでlwは不要) jr $ra # n >= 1の処理 L1 addi $a0, $a0, -1 # n -= 1 jal fact # fact( n ); ※戻り値は$v0に入る lw $ra, 4($sp) # レジスタの復元 lw $a0, 0($sp) addi $sp, $sp, 8 # スタックの破棄 mul $v0, $v0, $a0 # n * fact( n-1) jr $ra # 呼び元に戻る |
レジスタに定数をセットするには、以下の命令を使用する
$zeroの使いどころをまた1つ見つけた。
addi $v0, $zero, 1 |
L1の処理で、a0レジスタを書き換えているので、掛け算しても (n-1) * fact(n-1) になるのでは???
と思ったけど、掛け算する前にlwで元に戻すからOKという仕組みだった。
レジスタの復元は常にjrの直前にしたほうが分かりやすいように感じるんだけど,なぜそうしないんだろう??
使用レジスタを節約したいからぐらいしか思いつかない。
十分な理由にはなるけど、復元処理を途中に挟み込むとコンパイラの実装が大変そう…
Cの変数をCPUで管理する時、型と記憶クラスの解釈が重要になる。
型はその変数が占めるメモリサイズに関係する。
また、記憶クラスはstatic変数とauto変数(=ローカル変数)がある。
staticはプログラムの実行中、ずっと生きてる必要があるけど、その辺の管理を楽にする為に$gp(grobal pointer)が存在している。
再度まとめると、関数呼び出しが行われた後、保持されるレジスタは以下の通り
$s0-$s7 $gp $sp $fp $ra 自分および、自分より親側の関数が保存したスタック情報 |
$a0-$a3は、保存されない事に注意(呼び出し先側で破壊してもかまわない)
ここで”保持される”というのは、CPUが自動的に保持してくれるという意味ではなく、”呼び出された関数側(callee)で同じ状態に戻しておく義務がある。”という意味なので注意。
配列や構造体の受け渡し
配列や構造体を引数として渡す場合は、レジスタに入りきらないので、これらもスタックに積む必要がある。これらの情報の事をスタックフレーム(stack frame)と呼ぶ(またはactivation record, activation frame,procedure frameとも呼ばれる)。
関数のコールスタックは複数の手続きフレームで構成されていて、退避した$a0-$a3,$raレジスタなどの上に積まれる。そうすると、ある関数をコールした時に、追加されるスタックの大きさは可変になる。スタックの高さは記録されていないので、スタックの底の情報を取りたいときに$spの情報だけだとアクセスできない(offsetが分からないので)。
なので、そのような場合に備えて、スタックの底のアドレスを$fp(frame pointer)レジスタに保存する”ものがある”。 ここで、”ものがある”というのは、前述までの例のように、関数自身で使用するスタックが固定なら、$fpを使わなくても定数値でspの加減算すればやり繰りすることも出来る。
※たしか,javaのバイトコードはメソッド呼び出しの際に、使用するスタックの高さは固定だったはず。
※MIPS向けCコンパイラでも、実装によって$fpの使用有無は異なる。
また、関数の引数が5つ以上ある時は$a0-$a3に収まらないので、この場合はスタックフレームを積む前にスタックに載せ,$fp側からアクセスする。
メモリ空間のセグメント分け
メモリ空間は、その使用目的ごとにセグメント分けされている
text segment プログラムのコードが入っている領域 static data segment static変数(プログラムの実行中、ずっと有効なグローバル情報) 定数 ヒープ情報 スタック情報 |
2.9 人との情報交換
文字を表現するにはascii,unicodeなどのコード体系を使う。ascii文字を処理するときはword(32bit)単位ではなく、byte(8bit)単位で処理した方が都合が良い。また、unicodeの場合はhalf word(16bit)単位にアクセスできると便利。
なので、この為のロード命令がある.
lb (load byte) lbu (load byte unsigned) sb (store byte) lh (load half) lhu (load half unsigned) sh (store half) |
使い方はlw,swと同様。
例題:
strcpy関数を自作せよ
strcpy関数は、以下の実装とする。
(dest=$a0, src=$a1, i=$s0)
void strcpy( char *dest, char *src ) { int i = 0; while ( ( dest[i] = src[i] ) != 0x00 ) { i++; } } |
回答例:
strcpy: addi $sp, $sp, -4 # s0を退避 sw $s0, 0($sp) addi $s0, $zero, 0 # i = 0; L1: addi $t1, $a1, $s0 # t2 = src[i]; lbu $t2, 0($t1) addi $t3, $a0, $s0 # t3 = &dest[i]; sb $t2, 0($t3) # *t3 = t2; beq $t2, 0, L2: # if ( t2 == 0x00 ) goto L2; addi $s0, $s0, 1 # i++; j L1: # goto L1; L2: lw $s0, 0($sp) # s0を復元 addi $sp, $sp, -4 jr $ra # return |
strcpyは1byteづつ処理を行う。
strcpyは、ここからコールされる子関数がないので、リーフ処理になる。
この場合レジスタの退避・復元の省略が出来る。
また、MIPSではスタックの値は4byte境界でアライメントが整えられる。
なので1byteのcharをパラメータとして渡す場合でも4byte消費する。
コンピュータの構成と設計(上)
関連記事
コメントを残す