4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Computer SocietyAdvent Calendar 2024

Day 7

未定義動作に初めて当たった話

Last updated at Posted at 2024-12-07

この記事と私について

 この記事はKCS Advent Calender 2024の7日目の記事です。
 お初にお目にかかります、きんべんと申します。最近はFlutterを用いたAndroidアプリ開発を行っています。今回はそれとは全く関係なく、あるCの課題にて直面した現象について話していきたいと思います。何か知っていることや、間違っていることがあればコメントしていただけると幸いです。

コンパイル対象のコードについて

 悩みの発端となった課題は、以下のコードをO0でコンパイルした場合とO2でコンパイルした場合の結果を比べてみて、その違いの原因を考察するというものでした。

pra3.c
#include <stdio.h>

int a = 10;
int b = 20;
int c = 30;

int main(){
    int *p = &b;
    *(p-1) = 11;
    *(p+1) = 33;
    printf("a = %d, b = %d, c = %d\n",a,b,c);
    return 0;
}

 仮に、このコードがそのまま実行されたとすると、グローバル変数のacはそれぞれメモリ上で変数bの4バイト上、4バイト下に存在するので、acの値はそれぞれ代入された値が出力されることになります。実際にこのコードをO0、O2でコンパイルした場合の実行結果はそれぞれこうなりました。

a = 11, b = 20, c = 33 //-O0
a = 10, b = 20, c = 30 //-O2

 なんと、O2ではacの変化前の値が出力されました。この原因をどうにかして探りたかったのですが、どうやらgccにはアセンブリを取得するオプションがあるそうです。それぞれのアセンブリのmain以下を示します。

O0

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	#,
# pra3.c:8:     int *p = &b;
	movq	$b, -8(%rbp)	#, p
# pra3.c:9:     *(p-1) = 11;
	movq	-8(%rbp), %rax	# p, tmp94
	subq	$4, %rax	#, _1
# pra3.c:9:     *(p-1) = 11;
	movl	$11, (%rax)	#, *_1
# pra3.c:10:     *(p+1) = 33;
	movq	-8(%rbp), %rax	# p, tmp95
	addq	$4, %rax	#, _2
# pra3.c:10:     *(p+1) = 33;
	movl	$33, (%rax)	#, *_2
# pra3.c:11:     printf("a = %d, b = %d, c = %d\n",a,b,c);
	movl	c(%rip), %ecx	# c, c.0_3
	movl	b(%rip), %edx	# b, b.1_4
	movl	a(%rip), %eax	# a, a.2_5
	movl	%eax, %esi	# a.2_5,
	movl	$.LC0, %edi	#,
	movl	$0, %eax	#,
	call	printf	#
# pra3.c:12:     return 0;
	movl	$0, %eax	#, _11
# pra3.c:13: }
	leave	
	.cfi_def_cfa 7, 8
	ret	
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (GNU) 8.5.0 20210514 (Red Hat 8.5.0-22)"
	.section	.note.GNU-stack,"",@progbits

O2

main:
.LFB11:
	.cfi_startproc
	subq	$8, %rsp	#,
	.cfi_def_cfa_offset 16
# pra3.c:11:     printf("a = %d, b = %d, c = %d\n",a,b,c);
	movl	c(%rip), %ecx	# c,
	movl	$.LC0, %edi	#,
	xorl	%eax, %eax	#
	movl	b(%rip), %edx	# b,
	movl	a(%rip), %esi	# a,
# pra3.c:9:     *(p-1) = 11;
	movl	$11, b-4(%rip)	#, MEM[(int *)&b + -4B]
# pra3.c:10:     *(p+1) = 33;
	movl	$33, b+4(%rip)	#, MEM[(int *)&b + 4B]
# pra3.c:11:     printf("a = %d, b = %d, c = %d\n",a,b,c);
	call	printf	#
# pra3.c:13: }
	xorl	%eax, %eax	#
	addq	$8, %rsp	#,
	.cfi_def_cfa_offset 8
	ret	
	.cfi_endproc
.LFE11:
	.size	main, .-main
	.globl	c
	.data
	.align 4
	.type	c, @object
	.size	c, 4
c:
	.long	30
	.globl	b
	.align 4
	.type	b, @object
	.size	b, 4
b:
	.long	20
	.globl	a
	.align 4
	.type	a, @object
	.size	a, 4
a:
	.long	10
	.ident	"GCC: (GNU) 8.5.0 20210514 (Red Hat 8.5.0-22)"
	.section	.note.GNU-stack,"",@progbits

 アセンブリを一行ずつ解説することはしませんが、最も重要なのはO2のアセンブリにおいてprintfの実行が二つのアセンブリコード群に分かれていることです。後半はprintf関数を呼ぶだけなのでいいとして、前半は何をやっているのかというとレジスタにメモリ上の変数の値を移動させています。これにより、標準出力にて値を表示できるということですね(実際はprintf関数の命令を見ていないので、どのようにレジスタの値がI/Oに繋がれているかはわからない)。このレジスタへの値の移動のタイミングがメモリへの値の書き込みより前に行われた結果、出力される値が異なっているんですね。
 課題への回答としてはこれで十分なのですが、私にとってはここからが本番でした。なぜ、レジスタへの値の書き込みをprintfの呼び出しより先に行うのだろう?

変なコードの正体

 さて、ここまでを読んで、既にご存知の方にとっては当然のことだと思うのですが、C言語には 未定義動作 というものが存在します。この未定義動作とは、規格書によって定められていない動作のことで、これにあたる部分をコードに見つけると、コンパイラは結果の保証をしません。それでは、今回はどの部分が未定義動作になっているかというと、以下の二行、特にその左辺があたります。

*(p-1) = 11;
*(p+1) = 33;

今回のメモリ番地としては問題がないように思えますが、このようなアクセスをする場合、p-1p+1は少なくとも配列の要素として存在している必要があります1。ところが、pbのアドレスを指していてそもそも配列になっておらず、これらは範囲外を参照していることになります。これが原因で、最適化の結果printfより後回しにされたのだと思います。

おわりに

 以上、実行結果が異なる原因について調べたところ、その原因は未定義動作だった、ということでした。未定義動作というものにあたるのは私としては初めてで、友人に聞くまではそもそもこの単語のことを知りませんでした。C言語は適当に書いてもそのまま実行してくれるものだと思っていたので、こういったケースには気を付けたいと思います。
 ある問題に対して深く考えることで、アセンブリの取得からそのちょっとした解析までできたのは非常にワクワクして楽しかったので、自らの良い経験としてここに記しておきたいと思います。未定義動作について教えてくれたlemolaくんをはじめ、初めて記事を書くにあたり協力してくれた複数の友人に感謝したいと思います。

  1. https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf 6.5.6 paragraph 8

4
0
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?