Go internal ABI specification
2021-04-19
最近在研究 ebpf trace golang 程序,在 ebpf 读取 golang function 的函数如参和返回值时,遇到一些关于 golang ABI 相关的问题, 所以有必要研读下 golang 的 ABI specification,这篇文章完全是 Go internal ABI specification 的一个翻译,大家可以自行查看原文,本文只是一个 ebpf 研究过程中帮助作者理解的一个过程文档记录。
Memory layout
built-in types
| Type | 64-bit | 32-bit | ||
|---|---|---|---|---|
| Size | Align | Size | Align | |
| bool, uint8, int8 | 1 | 1 | 1 | 1 | 
| uint16, int16 | 2 | 2 | 2 | 2 | 
| uint32, int32 | 4 | 4 | 4 | 4 | 
| uint64, int64 | 8 | 8 | 8 | 4 | 
| int, uint | 8 | 8 | 4 | 4 | 
| float32 | 4 | 4 | 4 | 4 | 
| float64 | 8 | 8 | 8 | 4 | 
| complex64 | 8 | 4 | 8 | 4 | 
| complex128 | 16 | 8 | 16 | 4 | 
| uintptr, *T, unsafe.Pointer | 8 | 8 | 4 | 4 | 
- byte和- rune分别是- uint8和- int32的别名
- map,- chan, 和- func等同于- *T类型
composite types
我们做如下定义,S 是由 N 个属性组成了,N 个属性类型分别为 t1,t2,…,tn。
offset(S, i) = 0  if i = 1
= align(offset(S, i-1) + sizeof(t_(i-1)), alignof(t_i))
alignof(S)   = 1  if N = 0
= max(alignof(t_i) | 1 <= i <= N)
sizeof(S)    = 0  if N = 0
= align(offset(S, N) + sizeof(t_N), alignof(S))
- [N]T是由- N个- T类型的属性组成.
- 切片 []T是由一个*[cap]T的指针, 一个int类型存储长度, 和一个int类型存储容量,总共 3 个属性组成.
- string类型是由一个- *[len]byte指针, 一个- int类型存储长度.
- 一个 struct { f1 t1; ...; fM tM }是由t1, …,tM,tP顺序排列组成, 其中tP:- Type byte if sizeof(tM) = 0and any ofsizeof(ti) ≠ 0.
- 否则为空 (size 0 and align 1) .
 
- Type byte if 
Function call argument and result passing
golang 函数之间的调用传递参数和结果是通过使用 stack 和 registers 的联合方式。
每个参数或者结果都可以完全使用 stack 或者 registers 的方式。
由于使用 registers 的方式比 stack 的方式性能更优,所有会优先使用 registers 的方式。
但是参数或者结果包含 non-trivial array 或者不适合存储在寄存器中,那么就只能通过 stack 的方式。
每种架构下都会定义一系列的 integer 和 floating-point 类型的寄存器。
总体上将,参数和结果集合会将负载类型拆解为基本类型,然后分配给上述两种寄存器种。
参数和结果集合可以共享寄存器,但是不能共享栈空间。
除了栈上传递参数和结果集外,caller 会为寄存器参数保留栈空间在溢出空间,但是不会初始化这个空间。
函数调用者,参数,结构集 和 函数 F 在栈,寄存器上的分配遵循下面的算法:
- 令 NI和NFP分别为integer和floating-point寄存器的总数,令I和FP为 0,它们表明下个integer和floating-pointer寄存器的下标, 令S是这个类型的栈贞,为空。
- 如果 F是函数方法,分配F的调用者。
- 分配 F的每一个参数A
- 添加指针对齐的属性到 S。该字段大小为 0,对其方式为uintptr类型。
- 设置 I和FP从 0 开始。
- 分配函数 F的每一个结果R
- 添加指针对齐的属性到 S。
- 对于函数 F的每一个寄存器分配的类型参数,令T类型添加到栈空间S中。并且这个参数属于栈溢出区,不会被调用者初始化。
- 添加指针对齐的属性到 S。
分配调用者,参数,或者结果集中的 T 类型结果 V,遵循下述流程:
- 记住 I和FP下标。
- 尝试在寄存器中分配 V。
- 如果第二部失败,设置 I和FP的值到第一步中的只,在栈空间添加T,并且分配V到栈空间的这个属性上。
寄存器上分配 V 遵循下述流程:
- 如果 T是一个boolean或者integral类型,适合integer寄存器,分配V到寄存器I并且I的下标增加 1。
- 如果 T是适合两个integer寄存器,那么分配V的最高/最低 有效位分配给I和I+1,并且I增加 2。
- 如果 T是适合floating-point类型的寄存器,分配V到寄存器FP并且FP的下标增加 1。
- 如果 T是complex类型,那么分别分配复数的实数和虚数部分
- 如果 T是pointermapchannelfunction类型,分配 V 到寄存器 I 并且 I 的下标增加 1。
- 如果 T是stringinterfaceslice类型,递归调用V的各个部分(strings和interfaces有两个属性组成,slices是 3 )。
- 如果 T是struct类型,递归分配V的各个属性。
- 如果 T是数组类型,并且长度为 0, 不需要做任何事情。
- 如果 T是数组类型,并且长度为 1, 递归分配该元素。
- 如果 T是数组类型,并且长度大于 1,分配失败。
- 如果 I>NI或者FP>NFP,分配失败。
- 如果任何上诉递归分配失败,则失败。
通过上面的算法分配好的 参数,结果集,在栈的最终结果大体上如下:
- stack-assigned receiver
- stack-assigned arguments
- pointer-alignment
- stack-assigned results
- pointer-alignment
- spill space for each register-assigned argument
- pointer-alignment
入下图所示,0 地址在最下端:
+------------------------------+
|             . . .            |
| 2nd reg argument spill space |
| 1st reg argument spill space |
| <pointer-sized alignment>    |
|             . . .            |
| 2nd stack-assigned result    |
| 1st stack-assigned result    |
| <pointer-sized alignment>    |
|             . . .            |
| 2nd stack-assigned argument  |
| 1st stack-assigned argument  |
| stack-assigned receiver      |
+------------------------------+ ↓ lower addresses
Example
假设在 64-bit architecture 架构上存在 R0–R9 寄存器,并且函数签名如下:
func f(a1 uint8, a2 [2]uintptr, a3 uint8) (r1 struct { x uintptr; y [2]uintptr }, r2 string)
在函数进入时, a1 分配到 R0, a3 分配到 R1 上,其他参数在栈上内存布局如下:
a2      [2]uintptr
r1.x    uintptr
r1.y    [2]uintptr
a1Spill uint8
a2Spill uint8
_       [6]uint8  // alignment padding
在栈上只有 a2 会在函数进入时初始化,剩下到栈都不会初始化。
在函数退出时,r2.base 会分配到 R0, r2.len 分配到 R1, r1.x 和 r1.y 是在栈上初始化.
在上面到列子中,我们需要注意以下几点:
- a2和- r1在栈上分配时由于包含了数组,其他参数分配在寄存器上.
- r2被分解为 2 个单独的属性,并且在寄存器上分配。
在栈上,基于栈分配的参数时出现在基于栈分配的返回值的低地址空间。
