gccが呼び出し規約に従わないコードを出力する例

アセンブリプログラムを書いていたら、遭遇したのでメモです。

さすがにプログラム全てをアセンブリ言語で書くのは大変なので、性能上重要ではないところはコンパイラのコード生成に任せて、性能上重要なところだけアセンブリプログラムで書くということはあると思います。 また、一からアセンブリ言語で書いたり全部イントリンシックで書くのも大変なので、コンパイラが出したコードをベースに改造することもあると思います。 この時、コンパイラが出すコードは呼び出し規約に沿っているので、性能上重要な関数を呼び出し規約に沿う範囲で自由に書き換えられると素朴に思っていたのですが、どうもそうではないということがわかりました。

問題を引き起こすコード

__attribute__((noinline)) char* id( char* x ) { return x; }

int main( int argc, char* argv[] ) {
    for( int i = 0; i < argc; ++i ) {
        argv[i] = id(argv[i]);
    }
}

gcc -O2コンパイルした場合、以下のコードになります(Compiler Explorerで確認したところ、gcc5~gcc13でどれもほぼ同じコードになりました)。

id:
        mov     rax, rdi
        ret
main:
        test    edi, edi
        jle     .L4
        movsx   rdi, edi
        lea     rdx, [rsi+rdi*8]
.L5:
        mov     rdi, QWORD PTR [rsi]
        add     rsi, 8
        call    id
        mov     QWORD PTR [rsi-8], rax
        cmp     rsi, rdx
        jne     .L5
.L4:
        xor     eax, eax
        ret

どこが問題か

このコードは、id関数を呼び出してもrsirdxが変わらないことを前提としています。 rsirdxは呼び出し側保存レジスタであり、id関数の中で書き換えても良いレジスタです。 つまり、このコードは呼び出し規約に沿っていないことになります。 おそらく、gccid関数でrsirdxが変わらないことを見抜くことで、このような積極的な最適化*1を行っているのだと思います。

こういうことをされたので、アセンブリファイルを勝手に書き換えたときにおかしくなったという話でした。

*1:ちゃんと呼び出し規約に従おうとすると、呼び出し先保存レジスタに値を入れる必要があって、その呼び出し先保存レジスタの確保のためにスピルが発生します。