There's an echo in my head

日々のメモ。

【追記】C と Go で for 文のループでのスタックフレームの積み方が違うという気付き → 変数の確保の方法が違うだけだった

この記事を読んでいて、不思議に感じたのでメモ。

blog.p1ass.com

というのも、 低レイヤを知りたい人のための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 などが詳しそう。

このブログに出てくるコードスニペッツは、引用あるいは断りがない限りMITライセンスです。