Featured image of post eBPF: Attach Go(Part1): Go ABI SPEC

eBPF: Attach Go(Part1): Go ABI SPEC

本文是使用eBPF挂钩Go程序的第二篇,主要介绍了Go语言的ABI规范。

    在前一篇eBPF: Attach Go(Part0): Go 语言基础和特性中介绍了Go语言的基础类型和接口的实现。而接下来本文将围绕Go语言的ABI规范展开介绍,简单来说有如下两个结论:

  • 在 Go1.17 之前,Go函数调用约定为通过栈传递参数(stack-based)

  • 从 Go1.17 开始,Go将函数调用的方式从栈传递参数切换成寄存器传递参数(register-based),当寄存器无法满足存储参数时,则剩余的在堆栈上传递。

0.Call Convention

    为什么要了解函数调用约定(Call Convention)呢 ? 当我们在使用libbpf attach函数时,通常会使用如PT_REGS_PARM1这样的宏来访问函数参数,查看源码可以发现其实最终是访问了对应的寄存器,比如展开可能是AX,BX,DI……

1
2
3
// 比如PT_REGS_PARM1(ctx) 获取函数第一个参数实际范围的就是di寄存器.
#define __PT_PARM1_REG di
#define PT_REGS_PARM1(x) (__PT_REGS_CAST(x)->__PT_PARM1_REG)

当然,这些宏实际上是libbpf根据C语言的ABI为C设置的。当我们使用eBPF去获取Go的函数参数、返回值时,我们就需要知道这些值是谁存放的?如何存放的?又存放在哪里?从那里取?即调用约定指的是函数调用时参数传递、返回值处理和堆栈管理的规定和约定。比如在C/C++中有以下调用约定:

关键字堆栈清理参数传递
__cdecl调用方在堆栈上按相反顺序推送参数(从右到左)
__clrcall不适用按顺序将参数加载到 CLR 表达式堆栈上(从左到右)。
__stdcall被调用方在堆栈上按相反顺序推送参数(从右到左)
__fastcall被调用方存储在寄存器中,然后在堆栈上推送
__thiscall被调用方在堆栈上推送;存储在 ECX 中的 this 指针
__vectorcall被调用方存储在寄存器中,然后按相反顺序在堆栈上推送(从右到左)

表 - Visual C/C++ 编译器支持的调用约定

tips: 函数调用约定如果展开讲的话,还是有很多东西需要深入学习的。

1.Issue#40724

Proposal: Register-based Go calling convention

Proposal: Create an undefined internal calling convention

Issue#40724: switch to a register-based calling convention for Go functions

    接下来,我们看一下Go中的ABI,首先先了解一下#40724这个issue都讨论了些什么。  

    Go 自发布以来一直使用的都是基于堆栈式的Plan 9 ABI 函数调用约定(显然,Plan9和Rob Pike有关系)。这种在堆栈上通过值传递的方式优势是:调用规则简单,并且这种规则是建立在现有的结构布局规则之上的;且在所有平台上都可以使用相同的约定从而保持可移植的编译器和运行时程序…..而且由于旧的ABI没有用到寄存器,这也简化了在panic时对垃圾回收、堆栈增长和堆栈展开的跟踪。

    虽然基于堆栈的函数调用约定简单,但是在性能上也存在许多问题。毕竟访问寄存器总是要比访问堆栈要快得多(约40%),即使现在CPU已经尽力在对堆栈进行了优化。

    因而,issue#40724 建议将Go ABI 切换至基于寄存器的函数调用约定

  • 不使用操作系统平台的调用约定,虽然它在不同语言间的调用上更有效。但Go最核心的特性就是协程,而协程是由Go运行时调度的而非操作系统内核调度(非线程),且协程的堆栈在运行时是可动态调整的。

  • 大多数现有的ABI都是基于C语言的,当然这也很语言特性有关。比如Go的切片{data, len, cap}在现有的x86_64/arm64/RISC-V等上会强制使用堆栈传递而不是寄存器。同样问题在返回值上,Go的函数返回值通常有多个(通常会有error),而C基本上是一个,这导致Go的返回值会被放在堆栈上而不是寄存器。因此,平台abi并不适合Go语言

2.Stack-based(ABI0)

    基于堆栈式的函数调用约定。ABI0参数完全通过堆栈传递,参数列表从右向左依次压栈(栈的生长方向右高地址向低地址),返回值亦在栈上传递。从高地址到低地址,栈的布局是:局部变量–返回值—参数列表。函数参数和返回值的大小以及对齐问题和结构体的大小和成员对齐问题是一致的,函数的第一个参数和第一个返回值会分别进行一次地址对齐。(足以见得ABI0方式的简单)

3.Register-based(ABIInternal)

     基于寄存器的函数调用约定使用堆栈和寄存器的组合来传递参数和结果。每个参数或结果要么完全在寄存器中传递,要么完全在堆栈中传递。因为访问寄存器通常比访问堆栈快,所以参数和结果优先在寄存器中传递。但是,任何包含非平凡数组或不完全装入剩余可用寄存器的参数或结果都会在队栈上传递。

    参数和结果可以共享相同的寄存器,但不能共享相同的堆栈空间。除了在堆栈上传递的参数和结果之外,调用方还在堆栈上为所有基于寄存器的参数保留溢出空间(但不填充该空间)。

分配一个底层类型为T的接收者、参数或结果V的工作方式如下:

  • T是适合整型寄存器的布尔或整型类型,则将V分配给寄存器I并自增;

  • T是适合两个整数寄存器的整型,则将V的最低有效位和最高有效位分别分配给寄存器I和I+1,并将I增加2;

  • T是浮点类型并且可以在浮点寄存器中不损失精度地表示,则将V分配给寄存器FP,并递增FP;

  • T是一个复数类型,则递归地寄存器分配它的实部和虚部;

  • T是指针类型、映射类型、通道类型或函数类型,则将V分配给寄存器I并自增;

  • T是字符串类型、接口类型或切片类型,则递归地对V的组件分配寄存器(2个用于字符串和接口,3个用于切片);

  • T是结构体类型,则递归地为V的每个字段分配寄存器;

  • T是长度为0的数组类型,则什么也不做;

  • T是长度为1的数组类型,则递归地为它的每一个元素分配寄存器;

  • T是长度大于1的数组类型,则分配失败;

  • 如果上述任何递归赋值失败,则失败。

    最终的堆栈序列看起来像:堆栈分配的接收器、堆栈分配的参数、指针对齐、堆栈分配的结果、指针对齐、每个寄存器分配的参数的溢出空间、指针对齐。下图显示了这个堆栈帧在堆栈上的样子,使用了地址0位于底部的典型约定:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
+------------------------------+
|             . . .            |
| 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

3.1amd64 architecture

    在amd64架构下,Go ABIInternal使用以下9个寄存器来存储整型参数和结果(不过在Go的反汇编中RAX等是不带R的,直接使用AX表示),使用X0 - X14存储浮点参数和结果:

1
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

对于栈增长和反射调用等一些特定操作需要借助专用的暂存寄存器,以便在不破坏参数或结果的情况下操作调用帧。专用寄存器如下:

RegisterCall meaningReturn meaningBody meaning
RSPStack pointerSameSame
RBPFrame pointerSameSame
RDXClosure context pointerScratchScratch
R12ScratchScratchScratch
R13ScratchScratchScratch
R14Current goroutineSameSame
R15GOT reference temporary if dynlinkSameSame
X15Zero value (*)SameScratch

表来源: https://github.com/golang/go/blob/master/src/cmd/compile/abi-internal.md

Tips: 可以直接被拿来用的寄存器叫scratch register, 需要保护才能使用的寄存器叫volatile register;相对而言scrach register也叫non-volatile register, volatile register也叫preserved register。

  • 其中,R14被指定为固定寄存器,用于存储当前goroutine指针

  • 另外,X15被指定为固定零寄存器,因为函数通常必须将其栈帧批量归零,而使用指定的零寄存器更高效。

在堆栈布局中,堆栈指针RSP向下增长,并始终对齐为8字节,amd64体系结构不使用链接寄存器,一个函数的栈帧布局如下:

1
2
3
4
5
6
+------------------------------+
| return PC                    |
| RBP on entry                 |
| ... locals ...               |
| ... outgoing arguments ...   |
+------------------------------+ ↓ lower addresses
  • return PC作为标准amd64 CALL的一部分被压入堆栈;

  • 进入函数时,从RSP中减去RBP,打开它的堆栈帧,并将RBP的值保存在return PC的下方。

3.2arm64 architecture

    在arm64架构下,Go ABIInternal使用R0 - R15存储整型参数和结果,使用F0 - F15存储浮点参数和结果。专用寄存器如下:

RegisterCall meaningReturn meaningBody meaning
RSPStack pointerSameSame
R30Link registerSameScratch (non-leaf functions)
R29Frame pointerSameSame
R28Current goroutineSameSame
R27ScratchScratchScratch
R26Closure context pointerScratchScratch
R18Reserved (not used)SameSame
ZRZero valueSameSame

在堆栈布局中,堆栈指针RSP向下增长,并始终对齐为16字节(arm64架构要求堆栈指针以16字节对齐),一个函数的栈帧布局如下:

1
2
3
4
5
6
+------------------------------+
| ... locals ...               |
| ... outgoing arguments ...   |
| return PC                    | ← RSP points to
| frame pointer on entry       |
+------------------------------+ ↓ lower addresses
  • 作为arm64 CALL操作的一部分,return PC被加载到R30寄存器中;

  • 进入函数时,从RSP中减去RSP打开它的堆栈帧,并将R30和R29的值保存在帧的底部;其中RSP更新后,R30保存在0(RSP), R29保存在-8(RSP)。

关于riscv64等其他架构可查阅/src/cmd/compile/abi-internal.md原文。

4.Example: ABI0 Vs ABIInternal

我们先编写一个简单函数来看一下两种ABI的函数调用方式,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
package main

func main() {
    var v1, v2, v3, v4 int64 = 2, 3, 4, 5
    foo(v1, v2, v3, v4)
}

func foo(a1, a2, a3, a4 int64) int64 {
    var b1, b2 int64 = 10, 20
    return a1*a3 + a2*a4 + b1*b2
}

我们在Go1.16.1下执行GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go反汇编得如下:

再看一下func foo(a1, a2, a3, a4 int64) int64函数的反汇编代码:

通过ABI0的定义和上述汇编代码,我们可以得到main函数调用foo时的堆栈切换:

CALL指令执行时,会压栈caller的PC,上图即main.PC导致SP+8;

  • 在x86架构下使用eBPF attach uprobe时,即会在函数入口处插入INT3中断汇编指令,即对应截图中设置.foo堆栈前(即上图中间的堆栈示例),那么,此时通过SP获取函数的入参即:

    • a1 即 (SP+8);

    • a2 即 (SP+16);

    • a3 即 (SP+24);

    • a4 即 (SP+32);

    • ~r4返回值即(SP+40).

接下来切换至Go1.20.7看看ABIInternal的函数调用方式,同样执行GOOS=linux GOARCH=amd64 go tool compile -S -N -l main.go反汇编得如下:

可以得到如下的堆栈变化图:

5.eBPF: Howto Get Go parameters ?

上面的例子从汇编代码的角度演示了两种ABI的函数调用过程,那么根据Go的ABI规范我们可以在eBPF程序中按照约定获取函数的参数和返回值:

1
2
3
4
5
6
7
8
9
# ABIInternal:by regs.
+-----------------------------------+ +----------+
|AX |BX |CX |DI |SI |R8 |R9 |R10|R11| | by stack |
+-----------------------------------+ +----------+
0                                   72
# ABI0: by stack.
+------------------------------------------------+
|    stack....                                   |
+------------------------------------------------+

可以把上面寄存器排列看成一个数组regs,对于堆栈,其本身就类似数组,那么,我们有:

1
2
3
4
// 假设有函数:
func bar(a1, a2, a3, a4 int64, b []byte) (int, error) {
    // ...
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 寄存器数组
struct go_regabi_regs_t {
    __u64 regs[9];
};

struct {
    __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
    __type(key, __u32);
    __type(value, struct go_regabi_regs_t);
    __uint(max_entries, 1);
} go_regabi_regs_map SEC(".maps");


static __always_inline __u64* go_regabi_regs(const struct pt_regs* ctx) {
    __u32 zero = 0;
    struct go_regabi_regs_t* regs_heap = bpf_map_lookup_elem(&go_regabi_regs_map, &zero);
    if (regs_heap == NULL) {
        return NULL;
    }

    regs_heap->regs[0] = ctx->rax;
    regs_heap->regs[1] = ctx->rbx;
    regs_heap->regs[2] = ctx->rcx;
    regs_heap->regs[3] = ctx->rdi;
    regs_heap->regs[4] = ctx->rsi;
    regs_heap->regs[5] = ctx->r8;
    regs_heap->regs[6] = ctx->r9;
    regs_heap->regs[7] = ctx->r10;
    regs_heap->regs[8] = ctx->r11;

    return regs_heap->regs;
}

static __always_inline void assign_go_arg_bystack(void* arg, size_t arg_size, uint32_t offset, const void* sp) {
    bpf_probe_read(arg, arg_size, sp + offset);
}

static __always_inline void assign_go_arg_byregs(void* arg, size_t arg_size, uint32_t offset, 
                            __u64* regs, const void* sp) {
    const uint32_t kRegs_size = 72;

    if (offset >= 0) {
        if((arg_size + offset) <= kRegs_size) // 寄存器能满足传递.
            bpf_probe_read(arg, arg_size, (char*)regs + offset);
        else{
            // 寄存器没有足够的空间存储, 通过栈传递
        }
    }
}

// 对于基于寄存器
// assign_go_arg_byregs(a1, sizeof(a1), 0, regs, sp);
// assign_go_arg_byregs(a2, sizeof(a2), 8, regs, sp);
// b => {data, len, cap}, data is uintptr
// assign_go_arg_byregs(b.data, sizeof(uintptr), 40, regs, sp);

// 对于基于堆栈
// assign_go_arg_bystack(a1, sizeof(a1), 8, sp);
// assign_go_arg_bystack(a2, sizeof(a2), 16, sp);
// assign_go_arg_bystack(b.data, sizeof(uintptr), 48, sp);

6.Conclusion

    在Go 1.17 Release Notes#Compiler小节中有提到:Go的函数调用方式从基于栈传递参数切换至基于寄存器传递参数的方式后,获得了约 5% 的性能和 2% 的二进制文件缩小。

    Go从1.17.1版本,开始支持多ABI,主要是两个ABI:ABI0和ABIInternal。值得注意的是:ABIInternal似乎是不稳定的ABI。

    对于ABI0,我们很容易就能通过SP获取到参数和返回值,需要注意的是eBPF的uprobe放置在函数入口处,这是CALL指令已经将SP+8,所以获取第一个参数为SP+8

    对于ABIInternal要稍微复杂一些,我们将9个寄存器看作一个char数组,通过参数的offset来访问。只需要记住当寄存器能满足参数或返回值的存储时,第i个参数或第i个返回值的,可通过regs+offset,比如第一参数为int时为regs+0对应AX寄存器。特别需要注意的是,当参数或返回值过多或寄存器大小无法满足时,ABIInternal会使用堆栈来传递参数或返回值。就可能会出现一个参数或返回值恰好同时跨越寄存器和堆栈,但这种情况时不会存在的。比如[]byte的结构是{data,len,cap},不会出现data和len通过寄存器传递而cap通过堆栈传递,如果出现同时跨越的情况,那么将该参数将会全部通过堆栈传递,即{data,len,cap}都通过堆栈传递。

    另外,在传递参数和返回值时,还需特别注意考虑内存对齐问题。

Proposal: Register-based Go calling convention

Proposal: Create an undefined internal calling convention

Issue#40724: switch to a register-based calling convention for Go functions

深入分析Go1.17函数调用栈参数传递-腾讯云开发者社区-腾讯云 (tencent.com)

Go 函数调用约定与栈 - 简书 (jianshu.com)

哦吼是一首歌。
Built with Hugo
Theme Stack designed by Jimmy