この記事を読んでいて、不思議に感じたのでメモ。
というのも、 低レイヤを知りたい人のためのCコンパイラ作成入門 を読みながら for 文を作っていたときにループごとにスタックフレームを新しく積むことはなかったので、別の変数に再代入しただけで結果が変わることに違和感を覚えた。もしかして C と Go で扱いが違うのかもと思って、ループ変数とループ内で宣言した変数のアドレスを書き出してみた。
C の場合はループ変数もループ内で宣言した変数もアドレスは毎回一致していた。ということは、毎回同じスタック領域をスタックフレームを使っていることになる。( for の初期化文などと同じフレームかまではわからないけど、 for 文のあとで i
を参照することができないことを考えると、 for 文に入ったところで新しく積んでいるのかもしれない)
$ cat <<"EOF" >main.c #include <stdio.h> int main(int argc, char **argv) { for (int i = 0; i < 4; i++) { int j = i; printf("&i = %p, &j = %p\n", &i, &j); } return 0; } EOF $ gcc main.c && ./a.out &i = 0x7ffeec76353c, &j = 0x7ffeec763538 &i = 0x7ffeec76353c, &j = 0x7ffeec763538 &i = 0x7ffeec76353c, &j = 0x7ffeec763538 &i = 0x7ffeec76353c, &j = 0x7ffeec763538
Go の場合はループ変数のアドレスは同じだけど、ループ内で宣言した変数のアドレスは毎回変わっていた。ということは、ループ変数とは別に、ループの実行ごとにスタックフレームを追加で積んでいる…?
$ cat <<"EOF" > main.go package main import "fmt" func main() { for i := 0; i < 4; i++ { j := i fmt.Printf("&i = %p, &j = %p\n", &i, &j) } } EOF $ go run main.go &i = 0xc00012a008, &j = 0xc00012a010 &i = 0xc00012a008, &j = 0xc00012a020 &i = 0xc00012a008, &j = 0xc00012a028 &i = 0xc00012a008, &j = 0xc00012a030
言語仕様や吐き出されたアセンブリなどを読めば厳密なことがわかりそうだけど、とりあえずここまで。
追記 2020-06-14
アセンブリを読んでみた。結論から書くと、
- C の場合、ループごとにスタックフレームを確保する(rbpを積む)ことはなく、同じスタックを使っている
- Go の場合もループの実行ごとにスタックフレームを追加で積んでいることはない。その代わり、変数の宣言のたびにメモリ領域を確保しているような雰囲気。
スタックフレームの積み方については、そうでなきゃループ前に宣言されたローカル変数が読めなくなっちゃうので当然か。
C
直接アセンブリを書き出す。
$ gcc -S -masm=intel -O0 main.c
結果はこの gist に貼った。 https://gist.github.com/a2ikm/04299c9d37c5b45edd1801825eec3041
メタデータっぽいものを削った main.s をみると、rbp を操作しているのは最初の1回だけなのでスタックフレームがループごとに積まれているということもない。
また、 printf の引数をみると &i
に相当する -4[rbp]
と &j
に相当する -8[rbp]
が渡されていることから、毎回同じ変数を参照していることがわかる。
main: .LFB0: push rbp mov rbp, rsp sub rsp, 32 mov DWORD PTR -20[rbp], edi mov QWORD PTR -32[rbp], rsi mov DWORD PTR -4[rbp], 0 jmp .L2 .L3: mov eax, DWORD PTR -4[rbp] mov DWORD PTR -8[rbp], eax lea rdx, -8[rbp] lea rax, -4[rbp] mov rsi, rax lea rdi, .LC0[rip] mov eax, 0 call printf@PLT mov eax, DWORD PTR -4[rbp] add eax, 1 mov DWORD PTR -4[rbp], eax .L2: mov eax, DWORD PTR -4[rbp] cmp eax, 3 jle .L3 mov eax, 0 leave ret
Go
逆アセンブルした。
$ go build -gcflags '-N -l' main.go $ objdump -M intel -d main > main.s
-N
と -l
はそれぞれ最適化とインライン化を行わないための go tool compile
コマンドのオプション。また、 go tool compile -N -l-S main.go
でも直接アセンブリを書き出すことができるようだけど、見慣れない AT&T syntax で読みづらかったのでこうした。
結果はこの gist に貼った。実行ファイル全体なのでデカい。 https://gist.github.com/a2ikm/bdd977b0381d0874bed89c313cd6ee1d
{パッケージ名}.{関数名}
がそのまま記録されているので main 関数の位置はすぐわかる。
0000000000492fe0 <main.main>:
ここでも rbp の操作をしているのは最初の1回だけなので、ループごとにスタックフレームを追加していることは無い。
493007: 48 8d ac 24 a0 00 00 lea rbp,[rsp+0xa0]
変数 i
を定義しているのはこの辺。cmp
で4と比較しているあたりから目星がつけられる。
49300f: 48 8d 05 ca e3 00 00 lea rax,[rip+0xe3ca] # 4a13e0 <type.*+0xd3e0> 493016: 48 89 04 24 mov QWORD PTR [rsp],rax 49301a: e8 91 8b f7 ff call 40bbb0 <runtime.newobject> 49301f: 48 8b 44 24 08 mov rax,QWORD PTR [rsp+0x8] 493024: 48 89 44 24 60 mov QWORD PTR [rsp+0x60],rax 493029: 48 c7 00 00 00 00 00 mov QWORD PTR [rax],0x0 493030: eb 00 jmp 493032 <main.main+0x52> 493032: 48 8b 44 24 60 mov rax,QWORD PTR [rsp+0x60] 493037: 48 83 38 04 cmp QWORD PTR [rax],0x4 49303b: 7c 05 jl 493042 <main.main+0x62> 49303d: e9 07 01 00 00 jmp 493149 <main.main+0x169>
名前からして怪しそうな runtime.newobject を覗いてみる。
000000000040bbb0 <runtime.newobject>: 40bbb0: 64 48 8b 0c 25 f8 ff mov rcx,QWORD PTR fs:0xfffffffffffffff8 40bbb7: ff ff 40bbb9: 48 3b 61 10 cmp rsp,QWORD PTR [rcx+0x10] 40bbbd: 76 3d jbe 40bbfc <runtime.newobject+0x4c> 40bbbf: 48 83 ec 28 sub rsp,0x28 40bbc3: 48 89 6c 24 20 mov QWORD PTR [rsp+0x20],rbp 40bbc8: 48 8d 6c 24 20 lea rbp,[rsp+0x20] 40bbcd: 48 8b 44 24 30 mov rax,QWORD PTR [rsp+0x30] 40bbd2: 48 8b 08 mov rcx,QWORD PTR [rax] 40bbd5: 48 89 0c 24 mov QWORD PTR [rsp],rcx 40bbd9: 48 89 44 24 08 mov QWORD PTR [rsp+0x8],rax 40bbde: c6 44 24 10 01 mov BYTE PTR [rsp+0x10],0x1 40bbe3: e8 78 f4 ff ff call 40b060 <runtime.mallocgc> 40bbe8: 48 8b 44 24 18 mov rax,QWORD PTR [rsp+0x18] 40bbed: 48 89 44 24 38 mov QWORD PTR [rsp+0x38],rax 40bbf2: 48 8b 6c 24 20 mov rbp,QWORD PTR [rsp+0x20] 40bbf7: 48 83 c4 28 add rsp,0x28 40bbfb: c3 ret 40bbfc: e8 6f e1 04 00 call 459d70 <runtime.morestack_noctxt> 40bc01: eb ad jmp 40bbb0 <runtime.newobject>
この中で呼ばれている runtime.mallocgc はまさに変数領域を確保するための関数。したがって、スタックが積まれるとかではなくて、 j := i
で変数が宣言されるたびに領域が確保されているために毎回アドレスが変わっているのではないかと。
この関数名でググった結果、 https://medium.com/a-journey-with-go/go-memory-management-and-allocation-a7396d430f44 などが詳しそう。