引言
在 Go 语言中,string 和 []byte 之间的转换是非常常见的操作。但你是否注意到,当将一个 3 字节的字符串转为 []byte 时,得到的切片容量却是 32?这背后隐藏着 Go 运行时的内存分配策略。本文将通过汇编代码分析,深入探究这个有趣的现象,帮助你理解 Go 在底层是如何处理类型转换和内存分配的。
核心问题:
- 为什么
[]byte("abc")的容量是 32 而不是 3? - string 转 []byte 的底层实现是什么?
- Go 运行时如何进行内存分配?
问题发现
最近在 code review 时发现,将 string 转 []byte 时,得到的 []byte 的容量会发生变化。比如下面这段代码的输出是 32:
func main() {
s1 := "abc"
fmt.Println(cap([]byte(s1))) // 输出:32
}
为什么长度为 3 的字符串转换后,容量却是 32?
汇编代码分析
要理解这个问题,我们需要深入到汇编层面。首先编译代码并查看汇编输出:
-N 禁止优化 -S 输出汇编代码 -l 禁止内联
go build -gcflags="-N -l -S" go.go
得到以下代码
"".main STEXT size=293 args=0x0 locals=0xc0
0x0000 00000 (main.go:5) TEXT "".main(SB), ABIInternal, $192-0
0x0000 00000 (main.go:5) MOVQ (TLS), CX
0x0009 00009 (main.go:5) LEAQ -64(SP), AX
0x000e 00014 (main.go:5) CMPQ AX, 16(CX)
0x0012 00018 (main.go:5) JLS 283
0x0018 00024 (main.go:5) SUBQ $192, SP
0x001f 00031 (main.go:5) MOVQ BP, 184(SP)
0x0027 00039 (main.go:5) LEAQ 184(SP), BP
0x002f 00047 (main.go:5) FUNCDATA $0, gclocals·7d2d5fca80364273fb07d5820a76fef4(SB)
0x002f 00047 (main.go:5) FUNCDATA $1, gclocals·4dc9e0f0c3406fbbbbd2ec99068e8282(SB)
0x002f 00047 (main.go:5) FUNCDATA $2, gclocals·8dcadbff7c52509cfe2d26e4d7d24689(SB)
0x002f 00047 (main.go:5) FUNCDATA $3, "".main.stkobj(SB)
0x002f 00047 (main.go:6) PCDATA $0, $1
0x002f 00047 (main.go:6) PCDATA $1, $0
0x002f 00047 (main.go:6) LEAQ go.string."abc"(SB), AX
0x0036 00054 (main.go:6) MOVQ AX, "".s1+104(SP)
0x003b 00059 (main.go:6) MOVQ $3, "".s1+112(SP)
0x0044 00068 (main.go:7) PCDATA $0, $2
0x0044 00068 (main.go:7) LEAQ ""..autotmp_4+56(SP), CX
0x0049 00073 (main.go:7) PCDATA $0, $1
0x0049 00073 (main.go:7) MOVQ CX, (SP)
0x004d 00077 (main.go:7) PCDATA $0, $0
0x004d 00077 (main.go:7) MOVQ AX, 8(SP)
0x0052 00082 (main.go:7) MOVQ $3, 16(SP)
0x005b 00091 (main.go:7) CALL runtime.stringtoslicebyte(SB)
0x0060 00096 (main.go:7) MOVQ 40(SP), AX
0x0065 00101 (main.go:7) MOVQ 32(SP), CX
0x006a 00106 (main.go:7) PCDATA $0, $3
0x006a 00106 (main.go:7) MOVQ 24(SP), DX
0x006f 00111 (main.go:7) PCDATA $0, $0
0x006f 00111 (main.go:7) MOVQ DX, ""..autotmp_2+160(SP)
0x0077 00119 (main.go:7) MOVQ CX, ""..autotmp_2+168(SP)
0x007f 00127 (main.go:7) MOVQ AX, ""..autotmp_2+176(SP)
0x0087 00135 (main.go:7) MOVQ AX, ""..autotmp_3+48(SP)
0x008c 00140 (main.go:7) MOVQ AX, (SP)
0x0090 00144 (main.go:7) CALL runtime.convT64(SB)
0x0095 00149 (main.go:7) PCDATA $0, $1
0x0095 00149 (main.go:7) MOVQ 8(SP), AX
0x009a 00154 (main.go:7) PCDATA $0, $0
0x009a 00154 (main.go:7) PCDATA $1, $1
0x009a 00154 (main.go:7) MOVQ AX, ""..autotmp_5+96(SP)
0x009f 00159 (main.go:7) PCDATA $1, $2
0x009f 00159 (main.go:7) XORPS X0, X0
0x00a2 00162 (main.go:7) MOVUPS X0, ""..autotmp_1+120(SP)
0x00a7 00167 (main.go:7) PCDATA $0, $1
0x00a7 00167 (main.go:7) PCDATA $1, $1
0x00a7 00167 (main.go:7) LEAQ ""..autotmp_1+120(SP), AX
0x00ac 00172 (main.go:7) MOVQ AX, ""..autotmp_7+88(SP)
0x00b1 00177 (main.go:7) TESTB AL, (AX)
0x00b3 00179 (main.go:7) PCDATA $0, $2
0x00b3 00179 (main.go:7) PCDATA $1, $0
0x00b3 00179 (main.go:7) MOVQ ""..autotmp_5+96(SP), CX
0x00b8 00184 (main.go:7) PCDATA $0, $4
0x00b8 00184 (main.go:7) LEAQ type.int(SB), DX
0x00bf 00191 (main.go:7) PCDATA $0, $2
0x00bf 00191 (main.go:7) MOVQ DX, ""..autotmp_1+120(SP)
0x00c4 00196 (main.go:7) PCDATA $0, $1
0x00c4 00196 (main.go:7) MOVQ CX, ""..autotmp_1+128(SP)
0x00cc 00204 (main.go:7) TESTB AL, (AX)
0x00ce 00206 (main.go:7) JMP 208
0x00d0 00208 (main.go:7) MOVQ AX, ""..autotmp_6+136(SP)
0x00d8 00216 (main.go:7) MOVQ $1, ""..autotmp_6+144(SP)
0x00e4 00228 (main.go:7) MOVQ $1, ""..autotmp_6+152(SP)
0x00f0 00240 (main.go:7) PCDATA $0, $0
0x00f0 00240 (main.go:7) MOVQ AX, (SP)
0x00f4 00244 (main.go:7) MOVQ $1, 8(SP)
0x00fd 00253 (main.go:7) MOVQ $1, 16(SP)
0x0106 00262 (main.go:7) CALL fmt.Println(SB)
0x010b 00267 (main.go:9) MOVQ 184(SP), BP
0x0113 00275 (main.go:9) ADDQ $192, SP
0x011a 00282 (main.go:9) RET
0x011b 00283 (main.go:9) NOP
0x011b 00283 (main.go:5) PCDATA $1, $-1
0x011b 00283 (main.go:5) PCDATA $0, $-1
0x011b 00283 (main.go:5) CALL runtime.morestack_noctxt(SB)
0x0120 00288 (main.go:5) JMP 0
声明部分
"".main STEXT size=293 args=0x0 locals=0xc0
0x0000 00000 (main.go:5) TEXT "".main(SB), ABIInternal, $192-0
0x0000 00000 (main.go:5) MOVQ (TLS), CX
0x0009 00009 (main.go:5) LEAQ -64(SP), AX
0x000e 00014 (main.go:5) CMPQ AX, 16(CX)
0x0012 00018 (main.go:5) JLS 283
0x0018 00024 (main.go:5) SUBQ $192, SP
0x001f 00031 (main.go:5) MOVQ BP, 184(SP)
0x0027 00039 (main.go:5) LEAQ 184(SP), BP
main函数声明
0x0000 00000 (main.go:3) TEXT "".main(SB), ABIInternal, $192-0
- “”.main(被链接之后名字会变成main.main) 是一个全局的函数符号,存储在.text` 段中,该函数的地址是相对于整个地址空间起始位置的一个固定的偏移量。
- $192-0 它分配了 192 字节的栈帧,且不接收参数,不返回值。$192-0 中的 192 代表局部变量字节数总和,-0 代表在 192 的地址基础上空出0的长度作为传入和返回对象, 即没有参数和返回值
0x0000 00000 (main.go:3) MOVQ (TLS), CX
- TLS 是一个由 runtime 维护的虚拟寄存器,保存了指向当前 g 的指针,这个 g 的数据结构会跟踪 goroutine 运行时的所有状态值
- 将当前 *g 保存到 CX
0x0009 00009 (main.go:3) CMPQ SP, 16(CX)
看一看 runtime 源代码中对于 g 的定义:
type g struct {
stack stack // 16 bytes
// stackguard0 is the stack pointer compared in the Go stack growth prologue.
// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
stackguard0 uintptr
stackguard1 uintptr
我们可以看到 16(CX) 对应的是 g.stackguard0,是 runtime 维护的一个阈值,该值会被拿来与栈指针(stack-pointer)进行比较以判断一个 goroutine 是否马上要用完当前的栈空间。
0x0012 00018 (main.go:5) JLS 283
jumps to 283 if SP <= g.stackguard0
0x0018 00024 (main.go:5) SUBQ $192, SP
main 作为调用者,通过对虚拟栈指针(stack-pointer)寄存器做减法,将其栈帧大小增加了 192 个字节(回忆一下栈是向低地址方向增长,所以这里的 SUBQ 指令是将栈帧的大小调整得更大了)。
0x001f 00031 (main.go:5) MOVQ BP, 184(SP)
8 个字节(183(SP)-192(SP)) 用来存储当前帧指针 BP (这是一个实际存在的寄存器)的值,以支持栈的展开和方便调试
0x0027 00039 (main.go:5) LEAQ 184(SP), BP
跟着栈的增长,LEAQ 指令计算出帧指针的新地址,并将其存储到 BP 寄存器中。
看下字符串处理部分
0x002f 00047 (main.go:6) LEAQ go.string."abc"(SB), AX
0x0036 00054 (main.go:6) MOVQ AX, "".s1+104(SP)
0x003b 00059 (main.go:6) MOVQ $3, "".s1+112(SP)
0x0044 00068 (main.go:7) PCDATA $0, $2
0x0044 00068 (main.go:7) LEAQ ""..autotmp_4+56(SP), CX
0x0049 00073 (main.go:7) PCDATA $0, $1
0x0049 00073 (main.go:7) MOVQ CX, (SP)
0x004d 00077 (main.go:7) PCDATA $0, $0
0x004d 00077 (main.go:7) MOVQ AX, 8(SP)
0x0052 00082 (main.go:7) MOVQ $3, 16(SP)
0x005b 00091 (main.go:7) CALL runtime.stringtoslicebyte(SB)
0x002f 00047 (main.go:6) LEAQ go.string."abc"(SB), AX
取字面值的地址,(字面值的数据在.data区域分配)
0x0036 00054 (main.go:6) MOVQ AX, "".s1+104(SP)
将数据地址移动到栈指针104字节位置
0x003b 00059 (main.go:6) MOVQ $3, "".s1+112(SP)
把字符串长度(3)移动到112字节位置
0x005b 00091 (main.go:7) CALL runtime.stringtoslicebyte(SB)
这个函数调用就是关键!runtime.stringtoslicebyte 负责执行 string 到 []byte 的转换,它会分配内存并复制数据。
为什么是 32
转换的内存是 stringtoslicebyte 里分配的,而 Go 的分配器对小对象并不会精确给 3 字节,而是按预定义的 size class 取整。Go 运行时定义了一串内存块大小等级(8、16、32、48、64……),分配 3 字节时会落到最接近且大于等于 3 的那一档,也就是 32 bytes。这就是 cap 返回 32 而不是 3 的原因——不是 bug,是分配器本来就这么干的。
