x86-64のメモリアドレッシングとスタックフレーム
実効アドレス、アラインメント、スタックの成長方向、関数フレームを追跡する
x86-64のメモリアドレッシングとスタックフレーム
本ブログの記事は講義を目的として作成しています。 例はIntel構文を使い、正確なスタック配置はABI、コンパイラ、最適化、デバッグ情報によって異なります。
アセンブリではplayer.healthが[rcx+12]に、values[i]が[rdi+rsi*4]になる。ローカル変数も固定された箱ではなく、その時点のrspまたはframe pointerを基準に見つける領域である。
実効アドレス
[base + index * scale + displacement]
scaleは1、2、4、8のいずれかで、他の項は省略できる。
mov eax, [rcx]
mov eax, [rcx + 12]
mov eax, [rcx + rdx*4]
mov eax, [rcx + rdx*4 + 12]
大括弧内をアドレスとして計算し、指定幅のメモリへアクセスする。leaだけは計算結果そのものを返す。
lea rax, [rcx + 8] ; アドレス
mov rax, [rcx + 8] ; その場所の値
レジスタのビットには「ポインタ」という型タグがないため、解析時はアドレスか値かを明示する。
配列と構造体
4バイトのint配列なら:
mov eax, dword ptr [rcx + rdx*4]
12バイト構造体はscaleだけで表せないため追加計算が入る。
lea rax, [rdx + rdx*2]
mov ecx, [rcx + rax*4 + 4]
構造体のoffsetはバイナリ契約であり、フィールド名ではない。
static_assert(offsetof(Player, health) == 12);
static_assert(sizeof(Player) == 16);
フィールド順、packing、継承、ABIが変わればハードコードしたoffsetの意味も変わる。
RIP-relativeとリトルエンディアン
lea rdi, [rip + message]
mov eax, dword ptr [rip + counter]
絶対アドレスを埋め込まず命令位置から対象を探す。動的リンクではELFのGOT/PLTやPEのimport tableによる間接参照が加わることがある。
0x1122334455667788はx86-64メモリで次の順序になる。
アドレス +0 +1 +2 +3 +4 +5 +6 +7
バイト 88 77 66 55 44 33 22 11
エンディアンは複数バイト値のメモリ表現順であり、算術の順序を反転するものではない。
アラインメント
x86-64の多くのload/storeはunalignedを許すが、常に正しく無料という意味ではない。
- cache lineやpage境界をまたぐと高コストになり得る。
- SIMD命令には整列を要求する形式がある。
- ABIは呼び出し境界のスタック整列を定める。
- C/C++で不正に整列したtyped pointerの参照は未定義動作になり得る。
- atomicityは命令、幅、整列、境界に依存する。
スタックは低いアドレスへ成長する
push rbx ; rsp -= 8; [rsp] = rbx
pop rbx ; rbx = [rsp]; rsp += 8
callは復帰アドレスを保存し、retはそれを取り出す。
call直前 RSP = S
callee入口 RSP = S - 8
ret後 RSP = S
引数の多くがレジスタでも、復帰アドレスはスタックに置かれる。
スタックフレーム
push rbp
mov rbp, rsp
sub rsp, 32
; body
add rsp, 32
pop rbp
ret
呼び出し元のrbpを保存し、固定基準点を作り、32バイトを確保して逆順に戻す。ただしframe pointerは必須ではない。最適化コードはrbpを汎用に使い、ローカル値をすべてレジスタに置くこともある。
フレームにはローカル配列、spill、非volatileレジスタ、stack引数、Windowsのshadow space、padding、canary、動的領域などが置かれる。したがって[rsp+40]を根拠なく「5番目のローカル」と決めてはいけない。
leaf関数とABI差
add_three:
lea eax, [rdi + rsi]
add eax, edx
ret
他関数を呼ばないleafはスタックを使わないことがある。Windows x64のcallerは32バイトshadow spaceを予約する。System Vにはshadow spaceはないが、ユーザー空間leafが条件付きで使える128バイトred zoneがある。Windowsにはこのred zoneはない。
バッファループを契約として読む
xor eax, eax
xor r8d, r8d
loop_begin:
cmp r8, rdx
jae loop_end
movsxd r9, dword ptr [rcx + r8*4]
add rax, r9
inc r8
jmp loop_begin
loop_end:
ret
[rcx+r8*4]はvalues[r8]の候補アドレス、movsxdはsigned 32ビット要素の拡張である。count > 0ならcallerは少なくともcount*4バイトの読み取り可能領域を渡す必要がある。ASMはポインタだけから割り当てサイズを知ることができない。
デバッグ手順
| 時点 | rsp | [rsp]の意味 | 保存状態 |
|---|---|---|---|
| 入口 | 基準 | 復帰アドレス | まだなし |
| prologue後 | 基準 - N | local/save領域 | 使用分を記録 |
nested call前 | ABI整列を確認 | outgoing領域 | live値を保護 |
| epilogue後 | 入口値 | 復帰アドレス | 全て復元 |
スタック破壊は、無関係な場所へのret、呼び出し元の非volatile値の変化、例外時だけ壊れるstack walk、整列SIMD命令でのfaultとして現れる。
まとめ
x86-64は[base + index*scale + displacement]で実効アドレスを作る。scaleは要素サイズ、displacementはfield offsetの手掛かりになる。callは復帰アドレスを積み、関数は必要なlocal、spill、保存、呼び出し領域だけを確保する。万能なフレーム図を暗記せず、rspの変化を命令ごとに追跡する。