この記事と私について
この記事はKCS Advent Calender 2024の7日目の記事です。
お初にお目にかかります、きんべんと申します。最近はFlutterを用いたAndroidアプリ開発を行っています。今回はそれとは全く関係なく、あるCの課題にて直面した現象について話していきたいと思います。何か知っていることや、間違っていることがあればコメントしていただけると幸いです。
コンパイル対象のコードについて
悩みの発端となった課題は、以下のコードをO0でコンパイルした場合とO2でコンパイルした場合の結果を比べてみて、その違いの原因を考察するというものでした。
#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;
}
仮に、このコードがそのまま実行されたとすると、グローバル変数のa
とc
はそれぞれメモリ上で変数b
の4バイト上、4バイト下に存在するので、a
とc
の値はそれぞれ代入された値が出力されることになります。実際にこのコードをO0、O2でコンパイルした場合の実行結果はそれぞれこうなりました。
a = 11, b = 20, c = 33 //-O0
a = 10, b = 20, c = 30 //-O2
なんと、O2ではa
とc
の変化前の値が出力されました。この原因をどうにかして探りたかったのですが、どうやら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-1
とp+1
は少なくとも配列の要素として存在している必要があります1。ところが、p
はb
のアドレスを指していてそもそも配列になっておらず、これらは範囲外を参照していることになります。これが原因で、最適化の結果printf
より後回しにされたのだと思います。
おわりに
以上、実行結果が異なる原因について調べたところ、その原因は未定義動作だった、ということでした。未定義動作というものにあたるのは私としては初めてで、友人に聞くまではそもそもこの単語のことを知りませんでした。C言語は適当に書いてもそのまま実行してくれるものだと思っていたので、こういったケースには気を付けたいと思います。
ある問題に対して深く考えることで、アセンブリの取得からそのちょっとした解析までできたのは非常にワクワクして楽しかったので、自らの良い経験としてここに記しておきたいと思います。未定義動作について教えてくれたlemolaくんをはじめ、初めて記事を書くにあたり協力してくれた複数の友人に感謝したいと思います。
-
https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf 6.5.6 paragraph 8 ↩