uchan note

プログラミングや電子工作の話題を書きます

x86-64 モードのプログラミングではスタックのアライメントに気を付けよう

x86-64 モードというのは x86 系 CPU の動作モードの一つです.64 ビットモードとかロングモードと呼ばれることもあります. x86-64 モードではページングが必須だったり,セグメント CS や DS に設定するベースアドレスやリミットが無視されるなど,CPU 自体の制約事項もあります. それ以外に,x86-64 モードでよく使われる ABI による制約もあり,ハマりやすい部分でもあるので本記事で説明します.

ABI

ABI = Application Binary Interface とはバイナリレベルで守るべきインターフェースのことです. 関数の引数や戻り値の渡し方(呼び出し規約),シンボル名のマングリングの規則,データ型のメモリ上での表現などを規定します (参照:Application binary interface). 本記事では,ABI の中でも呼び出し規約を主な話題としています.

x86 向けの呼び出し規約 としては cdecl や stdcall が有名です. これらはレジスタ数が少ない x86 向けに,引数の受け渡しをスタックで行うようになっています. 「30 日でできる!OS 自作入門」で採用されている呼び出し規約は cdecl です.(それ以外の ABI が存在するという話はこの本には登場しませんが.)

x86-64 向けの呼び出し規約もあります. 今のところ Microsoft x64 呼び出し規約と System V ABI 呼び出し規約の 2 つが有名ですね. これら 2 つの呼び出し規約は,引数をスタックに積む x86 用の呼び出し規約とは違い,引数のうち最初のいくつかはレジスタ経由で渡すことになっています. x86-64 ではレジスタの数が増えたのでそういうことができるようになったのです. 引数が数個の関数であればスタック操作が不要のため,関数呼び出しのコストを抑えることができます.

スタックのアラインメント

x86-64 モード用の呼び出し規約では,浮動小数点数の受け渡しは XMM0 などのレジスタを用います. メモリと XMM0 系のレジスタ間で値を転送する命令(MOVAPS や MOVAPD など)は,メモリ上の値が 16 バイト境界に配置されていることを要求します.

コンパイラは,関数呼び出し時のスタックポインタが 16 バイト整列されていることを前提に,MOVAPS や MOVPAD 命令を発行します. また,他の関数を呼び出すときには必ずスタックポインタが 16 バイト整列するように調整する責任があります.

スタックのアライメントの実例

関数 funコンパイルした結果を見るとこのようになっていたとします.

func:
    push rbp
    mov  rbp, rsp
    push rbx
    sub  rsp, 0x18
    call ...

この関数が呼び出される直前,スタックは次に示すように 16 バイト境界に整列していると仮定します(コンパイラが,そのように調整する責任を負っています).

000FFFF8H  ----------
          |          |
00100000H  ----------   <-- RSP

図のメモリアドレスは下 1 桁以外は適当です.

ここで,他の関数の中で call foo されたとします.関数 foo が呼び出された直後のスタックは次のようになります.

000FFFF0H  ----------
          |          |
000FFFF8H  ----------   <-- RSP
          | ret addr |
00100000H  ----------

"ret addr" は戻り先アドレスです.この状態で 2 つの push が実行されると,次のような状態になります.

000FFFE0H  ----------
          |          |
000FFFE8H  ----------   <-- RSP
          |   rbx    |
000FFFF0H  ----------
          |   rbp    |
000FFFF8H  ----------
          | ret addr |
00100000H  ----------

この状態で sub rsp, 0x18 を実行すれば RSP はちょうど 16 バイト境界に整列しますね.

ちなみに sub rsp, 0x18 はローカル変数確保のためにコンパイラが良く使う命令です. ちょうど 24 バイトのローカル変数が必要だったのか,16 バイトしか要らないけれどアライメント調整のために 8 を余分に引いたのかは分かりません. コンパイラRSP から減ずる値を調整することで,RSP が 16-aligned になるように調整するのです.

割り込みハンドラ

ここで,割り込みハンドラのことを考えてみます.

割り込みは任意の時点で発生するため,もしかしたら関数 foopush rbx 実行直後に割り込みが発生するかもしれません. そうすると,割り込みハンドラは RSP が 16 バイト境界に無い 状態で呼び出されることになりそうですよね.

実はその心配はないのです. IA32e モード(Intel CPU における x86-64 モードのこと)では,割り込みハンドラを呼び出す前に,CPU が自動でスタックを 16 バイト境界に整列してくれるのです.

Intel SDM Vol.3, 6.14.2 64-Bit Mode Stack Frame に次のように書いてあります:

In legacy mode, the stack pointer may be at any alignment when an interrupt or exception causes a stack frame to be pushed. This causes the stack frame and succeeding pushes done by an interrupt handler to be at arbitrary alignments. In IA-32e mode, the RSP is aligned to a 16-byte boundary before pushing the stack frame. The stack frame itself is aligned on a 16-byte boundary when the interrupt handler is called. The processor can arbitrarily realign the new RSP on interrupts because the previous (possibly unaligned) RSP is unconditionally saved on the newly aligned stack. The previous RSP will be automatically restored by a subsequent IRET.

レガシーモードでは,割り込みハンドラ呼び出し時にスタックが自動で整列されるようなことは起きないが, IA32e モードでは CPU が自動で RSP を 16 バイト境界に整列させると書いてあります. 古い RSP の値はスタックフレームに保存されるので,割り込みハンドラ終了時にはもともとの値に RSP を戻すことができる,ということです.

アセンブラで書いた関数

さて,コンパイラが 16 バイト境界にスタックを整列させ,さらに CPU が割り込み時の境界調整を頑張れば,世界は平和に保たれるでしょうか?

応えは NO です. 世界にはアセンブラで書いた関数が存在するのです.

コンパイラの管理外で関数を書くのであれば,その関数内でスタックの整列を保つのは関数作者の責務です. コンパイラがするのと同じような努力を人間がする必要があります. 筆者は次のようなアセンブラ関数を書いて,まんまと罠にはまった経験があります.

asm_inthandler40:
    call inthandler40
    iretq

これは「30 日でできる!OS 自作入門」ではおなじみの関数の書き方です. この本では,割り込みハンドラはすべてアセンブラ関数から C 言語関数を呼び出すようになっています. なぜなら,C 言語で iret 系命令を呼び出す方法は C 言語の標準規格には存在しないからです1

この方法は x86 の世界では全く問題ありませんでした.

しかし,x86-64 モードで同じ感覚でプログラムを作ると,スタックのアライメント制約にやられます. スタックのアライメント制約が崩れていると,プログラムがとても不可解な動きになり,デバッグが非常に困難です. 例えば筆者が作っている OS では,いきなり画面が一色で塗りつぶされる(しかも,色は再起動するたびに違う色になる),という症状が出ました. 皆さん,気を付けてください.


  1. GCC や Clang では __attribute__ ((interrupt)) を使うことで C 言語だけで割り込みハンドラを記述できます.