コンパイラの最適化の話

ランタイムエラーでたまに出てくるIllegal instructionを意図的に出すにはどうすればいいかを知りたくなっていろいろみた。 その中で以下のコードが環境によってはIllegal instructionになるらしいと知り、いろいろ試してみた。

#include <stdio.h>

int main(void)
{
    int x = 1;
    int y = 0;

    printf("%d / %d = %d\n", x, y, x / y);
}

とりあえず単純にGCCとClangでコンパイル・実行してみた。

$ gcc main.c && ./a.out
zsh: floating point exception  ./a.out
$ clang main.c && ./a.out
zsh: floating point exception  ./a.out

オプションなしのコンパイルだとどちらでも浮動小数点例外が出る。 それぞれのアセンブリはこちら

GCC

	.file	"main.c"
	.text
	.section	.rodata
.LC0:
	.string	"%d / %d = %d\n"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	subq	$16, %rsp
	movl	$1, -4(%rbp)
	movl	$0, -8(%rbp)
	movl	-4(%rbp), %eax
	cltd
	idivl	-8(%rbp)
	movl	%eax, %ecx
	movl	-8(%rbp), %edx
	movl	-4(%rbp), %eax
	movl	%eax, %esi
	leaq	.LC0(%rip), %rdi
	movl	$0, %eax
	call	printf@PLT
	movl	$0, %eax
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Debian 10.3.0-9) 10.3.0"
	.section	.note.GNU-stack,"",@progbits

Clang

	.text
	.file	"main.c"
	.globl	main                            # -- Begin function main
	.p2align	4, 0x90
	.type	main,@function
main:                                   # @main
	.cfi_startproc
# %bb.0:
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset %rbp, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register %rbp
	subq	$16, %rsp
	movl	$1, -4(%rbp)
	movl	$0, -8(%rbp)
	movl	-4(%rbp), %esi
	movl	-8(%rbp), %edx
	movl	-4(%rbp), %eax
	movl	%edx, -12(%rbp)                 # 4-byte Spill
	cltd
	idivl	-8(%rbp)
	movabsq	$.L.str, %rdi
	movl	-12(%rbp), %ecx                 # 4-byte Reload
	movl	%ecx, %edx
	movl	%eax, %ecx
	movb	$0, %al
	callq	printf
	xorl	%ecx, %ecx
	movl	%eax, -16(%rbp)                 # 4-byte Spill
	movl	%ecx, %eax
	addq	$16, %rsp
	popq	%rbp
	.cfi_def_cfa %rsp, 8
	retq
.Lfunc_end0:
	.size	main, .Lfunc_end0-main
	.cfi_endproc
                                        # -- End function
	.type	.L.str,@object                  # @.str
	.section	.rodata.str1.1,"aMS",@progbits,1
.L.str:
	.asciz	"%d / %d = %d\n"
	.size	.L.str, 14

	.ident	"Debian clang version 11.0.1-2"
	.section	".note.GNU-stack","",@progbits
	.addrsig
	.addrsig_sym printf

どちらもidivlで例外が投げられているらしい。

idivl	-8(%rbp)

次に最適化オプションをつけてコンパイルしてみた。 まずはGCCでやってみる。

$ gcc -O2 main.c && ./a.out
zsh: illegal hardware instruction  ./a.ou

目的のIllegal instructionが出てきた。

次にClangでもやってみる。

$ clang -O2 main.c && ./a.out
1 / 0 = 1223354136

Illegal instructionが出ないどころか0徐算も出なくなってしまった。 何度か実行してみると、

$ clang -O2 main.c && ./a.out
1 / 0 = -1048254696

$ ./a.out
1 / 0 = 1310734104

$ ./a.out
1 / 0 = -39168232

$ ./a.out
1 / 0 = 752305944

毎回結果が変わってしまう。 というわけで、最適化されたアセンブリをGCCとClangで見比べてみる。

GCC

	.file	"main.c"
	.text
	.section	.text.startup,"ax",@progbits
	.p2align 4
	.globl	main
	.type	main, @function
main:
.LFB11:
	.cfi_startproc
	ud2
	.cfi_endproc
.LFE11:
	.size	main, .-main
	.ident	"GCC: (Debian 10.3.0-9) 10.3.0"
	.section	.note.GNU-stack,"",@progbits

GCCではコンパイル時に0徐算であることがわかりud2命令に置き換える最適化が走っているように見える。

Clang

	.text
	.file	"main.c"
	.globl	main                            # -- Begin function main
	.p2align	4, 0x90
	.type	main,@function
main:                                   # @main
	.cfi_startproc
# %bb.0:
	pushq	%rax
	.cfi_def_cfa_offset 16
	movl	$.L.str, %edi
	movl	$1, %esi
	xorl	%edx, %edx
	xorl	%eax, %eax
	callq	printf
	xorl	%eax, %eax
	popq	%rcx
	.cfi_def_cfa_offset 8
	retq
.Lfunc_end0:
	.size	main, .Lfunc_end0-main
	.cfi_endproc
                                        # -- End function
	.type	.L.str,@object                  # @.str
	.section	.rodata.str1.1,"aMS",@progbits,1
.L.str:
	.asciz	"%d / %d = %d\n"
	.size	.L.str, 14

	.ident	"Debian clang version 11.0.1-2"
	.section	".note.GNU-stack","",@progbits
	.addrsig

printfの引数に明示的に渡しているのはedi, esi, edxの3つ。 それぞれフォーマット文字列、10である。 x / yが渡されていない。 ただ、フォーマット文は3つの指定子(esi, edx, ecx)を持つため、printf呼び出し時のecxの値を使うことになる。 そのため、実行するたびに出力される値が変わってしまう。

コンパイラの最適化オプションはパフォーマンス面で優れる反面、今回のようなエラーを隠してしまうことにも繋がる。 開発時は最適化あり・なしでいろいろ検証しながら進めるのがいいなと思った。