13. 入门 go 语言汇编,看懂 GMP 源码

11,101 阅读20分钟

前言

近期在看 GMP 源码,涉及到了很多 Golang 汇编的代码,为了看懂 GMP,就得学习一下 Go 语言的汇编了。这几天通过对汇编的学习,了解到了寄存器、内存、函数调用栈以及函数调用过程等相关知识,不仅对操作系统底层有了更深一步的了解,还对上层的高级语言有了更深刻的认识,更为后续看懂 GMP 源码奠定了基础。这里把我的学习心得分享给大家,通过本文你可以了解到以下内容:

  • 什么是寄存器
  • 什么是内存
  • 什么是函数调用栈
  • 函数的调用过程
  • 如何把自己的 Golang 代码编译为汇编
  • 用汇编写自己的代码&并执行
  • 利用汇编知识进入 GMP 世界

Go 版本 1.20.7

主要参考文档:

https://Go.dev/doc/asm

https://9p.io/sys/doc/compiler.html

想一起学习 Go 语言进阶知识的同学可以点赞+关注+收藏哦!后续将继续更新 GMP 相关源码分享(第一次有粉丝主动评论想学习这块内容,那必须得给安排上,我也不是啥大佬,凑合看哈哈哈)。

通过读本文内容,希望各位读者最后都能够看懂 GMP 的源码。例如:runtime/asm_amd64.s

为了让大家看的更顺畅一点,我们先了解一下寄存器、内存的概念,然后进入正题:Go 汇编入门,结合简单案例的实战,让自己拥有看懂简单汇编代码的能力。最后,我们开启 GMP 之旅!

1.什么是寄存器

寄存器是 CPU 内部的存储单元,用于存放从内存读取而来的数据(包括指令)和 CPU 运算的中间结果,之所以要使用寄存器来临时存放数据而不是直接操作内存,主要有以下两点原因:

  1. CPU 的工作原理决定了有些操作运算只能在 CPU 内部进行;
  2. CPU 读写寄存器的速度比读写内存的速度快得多。

为了方便大家使用汇编语言进行编程,CPU 厂商为每个寄存器都取了一个名字,这样程序员就可以很方便的在汇编代码中使用寄存器的名字来进行编程。不同体系结构的CPU,其内部寄存器的数量、种类以及名称可能大不相同,应用最广泛的是 AMD64 这种体系结构的 CPU,这种 CPU 共有 20 多个可以直接在汇编代码中使用的寄存器,应用层一般只会用到 19 个(这些寄存器需要我们额外关心,其他寄存器一般由操作系统内部使用,我们不用关心),这 19 个寄存器它们大致分为三类:

分类位数解释举例说明
通用寄存器64位(8字节)通用寄存器共 16 个,一般没有被 CPU 规定特殊用途,由业务方自己定义和约定使用。通用寄存器:rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, r8, r9, r10, r11, r12, r13, r14, r15,其中 rbp 和 rsp 寄存器都跟函数调用栈有关,rbp 为**栈基址寄存器,rsp 为栈顶寄存器。**通用寄存器还可以作为 32/16/8 位寄存器使用,使用时需要换一个名字,例如:rax(64位) -> eax(32位) -> ax(16 位) -> al(低8位)/ah(高8位)。
程序计数寄存器64位(8字节)程序计数寄存器也被称为 PC 寄存器或 IP 寄存器,用 rip 表示,它用来存放 CPU 下一条即将执行的指令的地址,这个寄存器决定了程序的执行流程。修改 rip 寄存器的值是 CPU 自动控制的,不需要业务程序操纵。
段寄存器16位(2字节)段寄存器一般用来实现线程本地存储(TLS),由 fs 和 gs 表示。

2.什么是内存

内存是计算机系统的存储设备,其主要作用是协助 CPU 在执行程序时存储数据和指令。内存由大量内存单元组成,内存单元大小为 1 个字节,每一个内存单元都有一个地址,在汇编代码中想要读写内存,就必须在指令中指定内存地址,这样 CPU 才知道它要存取哪个或哪些内存单元。举一个简单的例子:在 Go 中 int64 类型的变量占用 8 个字节,任何大于一个字节的变量在内存中都存储在相邻连续的的几个内存单元之中,也就是占用 8 个连续的内存单元,要读写该变量,只需在汇编指令中指定这些内存单元的起始地址以及读写的字节数即可。

操作系统把磁盘上的可执行文件加载到内存运行之前,会做很多工作,其中很重要的一件事情就是把可执行文件中的代码,数据放在内存中合适的位置,并分配和初始化程序运行过程中所必须的堆栈,所有准备工作完成后操作系统才会调度程序起来运行。来看一下程序运行时在内存中的布局图:

栈内存-第 3 页.png

进程运行时在内存中的布局主要分为四部分:

  • 代码区: 包括能被CPU执行的机器代码(指令)和只读数据比如字符串常量,程序一旦加载完成代码区的大小就不会再变化了。
  • 数据区: 包括程序的全局变量和静态变量,与代码区一样,程序加载完毕后数据区的大小也不会发生改变。
  • 堆: 程序运行时动态分配的内存都位于堆中,这部分内存由内存分配器负责管理。
    • 堆区域的大小会随着程序的运行而变化:当我们向堆请求分配内存,但分配器发现堆中的内存不足时,它会向操作系统内核申请向高地址方向扩展堆的大小;当我们释放内存,把它归还给堆时,如果内存分配器发现剩余空闲内存太多,则又会向操作系统请求向低地址方向收缩堆的大小
    • 如果我们只向堆请求内存分配,而不释放内存时,就有可能发生内存泄露,传统的c/c++代码就必须小心处理内存的分配和释放,而 Go 由于有垃圾回收器帮助我们自动释放内存,因此不必担心这个问题。
  • 栈: 随函数调用、返回而增减的一片内存,用于为局部变量和函数调用链接信息分配存储空间。
    • 栈(stack)是一个动态增长和收缩的段,由栈帧(stack frame)组成。
    • 系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量、实参和返回值以及调用链接的信息。
    • 当发生函数调用时,因为调用者还没有执行完,其栈内存中保存的数据还有用,所以被调用函数不能覆盖调用者的栈帧,只能把被调用函数的栈帧“push”到栈上,等被调函数执行完成后再把其栈帧从栈上“pop”出去,这样,栈的大小就会随函数调用层级的增加而生长,随函数的返回而缩小。
    • 栈的生长和收缩都是自动的,由编译器插入的代码自动完成,因此位于栈内存中的函数局部变量所使用的内存随函数的调用而分配,随函数的返回而自动释放,因此不用主动释放栈内存,也不需要依赖 GC。

3.Go 汇编入门

关于 Go 的汇编器,最重要的一点是它不是底层机器的直接表示。有些细节与机器精确对应,但有些则不然。在 Go 中编译出来的伪汇编代码中,使用的是 GAS 汇编语法(Gnu ASsembler),采用 AT&T 汇编格式,使用的是 Plan9 汇编语法,在机器汇编层面,总是有一些自己特立独行的惯例规约,接下来我们就一起走入 Go 汇编的世界。

3.1 Go 的寄存器

Go 汇编中大部分寄存器与机器中的寄存器存在一一对应的关系,下面是寄存器的名字在 AMD64 和 Plan 9 中的对应关系:

AMD64raxrbxrcxrdxrsirdirbprspr8 ~ r15rip
Plan9AXBXCXDXSIDIBPSPR8 ~ R15PC

伪寄存器是 Plan9 伪汇编中的一个助记符, 也是 Plan9 比较有个性的语法之一。Go 汇编提供了 SB、PC、FP、SP 等常见的伪寄存器,接下来我们一一介绍一下。

SB(Static base pointer: global symbols.) 指向全局符号,保存静态基地址(static-base) 指针,即我们程序地址空间的开始地址,一般用来声明函数或全局变量。符号 foo(SB) 表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。具体使用方法:

exprmeaning
foo(SB)foo 这个符号的起始地址
foo<>(SB)foo 符号只在当前文件可见
foo+4(SB)从 foo 开始偏移4字节

PC(Program counter: jumps and branches.)程序计数器,指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。修改 rip 寄存器的值是 CPU 自动控制的,不需要业务程序操纵,因此我们一般也用不到。

FP(Frame pointer: arguments and locals.) 伪寄存器是一个虚拟帧指针,编译器维护一个虚拟帧指针,并将栈上的参数引用为该伪寄存器的偏移量。使用形如 symbol+offset(FP) 的方式,例如 arg0+0(FP),arg1+8(FP)

  • 使用 FP 不加 symbol 时,无法通过编译,在汇编层面来讲,symbol 并没有什么用,加 symbol 主要是为了提升代码可读性。
  • 此处的 offset(FP),指的是与帧指针 (FP) 的偏移量(而 SB 中的偏移量是与 symbol 的偏移量)。因此,0(FP) 是函数的第一个参数,8(FP) 是第二个参数(在 64 位机器上)。

举个具体点的例子:返回两个数之和的程序,代码执行完毕,结构存储在 R0 寄存器中。

TEXT    sum(SB), $0 // 声明 sum 函数(语法后边会学到),内部栈帧占用 0 字节
MOVL    arg1+0(FP), R0 // arg1+0(FP) 表示第一个参数(int32),R0 = arg1+0(FP)
ADDL    arg2+4(FP), R0 // arg2+4(FP) 从第一个参数偏移 4 字节,表示第二个参数, R0 = R0 + arg2+4(FP) 
RTS // 函数返回

SP(Stack pointer: top of stack.)伪寄存器是一个虚拟栈指针,指向本地栈帧内的最高地址,用于寻找栈本地变量和为函数调用准备的参数。SP 寄存器名字有点特殊,和物理寄存器 SP 同名,因此 Go 汇编使用两种语法进行区分:伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(最高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。

注意:有 symbol 的 SP 和没 symbol 的 SP 不是一个东西,手写的时候有 symbol 的 SP,即symbol+offset(SP) 为伪寄存器 SP;而直接使用的 SP,即 MOVQ offset(SP) 为物理寄存器 SP。

后面说到栈结构时会对 SP 和 FP 有图示讲解,但是这是在手写汇编时的说法,使用 Go tool compile 得到的汇编代码中,没有 伪 SP、FP 寄存器,生成真正可执行代码时,伪 SP、FP 会由物理 SP 寄存器加上偏移量替换。因此,SP、FP 伪寄存器只适用于我们看 Go 源码中的一些手写汇编代码。

另外还有 1 个比较特殊的伪寄存器:TLS:存储当前 Goroutine 的 g 结构体的指针。实际上,X86 和 AMD64 下的 TLS 是通过段寄存器 FS 或 GS 实现的线程本地存储基地址,而当前 g 的指针是线程本地存储的第一个变量。

3.2 Go 函数调用栈结构

下图描述了栈桢与SP、FP寄存器的内存关系模型,能够帮助你快速理解 SP 伪寄存器和 FP 伪寄存器指向的位置和使用方式。 栈内存-第 2 页.png

每个函数在栈中对应的片段叫做栈帧,也就是 stack frame。栈帧记录了函数调用需要的上下文信息。

栈帧有以下几部分组成:

  • caller BP:保存调用函数的栈基地址(BP),用于函数返回后获得调用函数的栈帧基地址。
  • local var:保存函数内部本地变量。
  • callee ret:保存被调用函数的返回值。
  • callee arg:保存被调用函数的入参参数。
  • return address:保存被调用函数返回后的程序地址,即本函数调用被调用函数的下一条指令地址,给 PC 赋值使用。

栈帧中有一点需要注意,return addr 也是在 caller 的栈上的,不过往栈上插 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的,在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。 在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部,一般用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 物理寄存器,SP 物理寄存器对应的是栈的顶部,如图所示。

3.3 Go 汇编语法

3.3.1 文件命名

使用到汇编时,即表明了所写的代码不能够跨平台使用,因此需要针对不同的平台使用不同的汇编代码。Go 编译器采用文件名中加入平台名后缀进行区分,例子: src/runtime/asan_amd64.s,详情可以参考 go/build

3.3.2 函数声明

关于函数声明,我看网上有人这样画,感觉还挺清晰的(拿来用一下)。

TEXT 指令用于定义函数
  ^                  静态基地址指针(告诉汇编器这是基于静态地址的数据)
  |                   ^    
  |                   |               标签    函数入参占用空间大小(+ 部分返回值大小)
  |                   |                ^        ^
  |                   |                |        |
TEXT pkgname·funcname(SB),ABIInternal,flag,$168-16
       ^         ^            ^              ^
       |         |            |              |
   函数所属包名  函数名      表示ABI类型        函数栈帧大小(本地变量占用空间大小)     

格式:TEXT pkgname·funcname(SB), ABI, flag, $framesize-argsize

表示意义:

TEXT: 定义函数标识;

pkgname: 表示包名(可以省略,最好省略,不然修改包名还要级联修改);

funcname: 声明的函数名;

SB: 伪寄存器,前边已经介绍了。

ABI(application binary interface): 应用程序二进制接口,规定了程序在机器层面的操作规范和调用规约,调用规约: calling convention, 所谓“调用规约”是调用方和被调用方对于函数调用的一个明确的约定,包括:函数参数与返回值的传递方式、传递顺序。只有双方都遵守同样的约定,函数才能被正确地调用和执行。如果不遵守这个约定,函数将无法正确执行。Go 从1.17.1版本开始支持多 ABI:1. 为了兼容性各平台保持通用性,保留历史版本 ABI,并更名为 ABI0。2. 为了更好的性能,增加新版本 ABI 取名 ABIInternal,该调用公约增加了用寄存器传递参数的约定,据官方测试,寄存器传参可以带来 5% 的性能提升。参考文档 Go internal ABI specification

flag: 标志位,如 NOSPLIT,因为 Go Runtime 会追踪每个 stack(栈)的使用情况,然后实现动态自增。而NOSPLIT 标志位禁止检查栈是否需要被分割,节省开销,但是写程序的人要保证这个函数是内存安全的,也就是你手写汇编的时候需要指定足够的栈内存 framesize。所有的 flag 定义需要通过 #include "textflag.h" 引入,参数如下:

flag含义
NOPROF = 1用于 TEXT 指令 不优化NOPROF标记的函数。这个标志已废弃。
DUPOK = 2允许同二进制中有多个相同的符号,链接器会选择其中之一
NOSPLIT = 4用于 TEXT 指令,标记不需要插入栈溢出检查
RODATA = 8用于 DATA 和 GLOBAL 指令,将数据放入只读段
NOPTR = 16用于 DATA 和 GLOBAL 指令,标记数据不包含指针,不需要 GC 扫描
WRAPPER = 32用于 TEXT 指令,标记该函数只是一个 wrap 不要禁用 recover
NEEDCTXT = 64用于 TEXT 指令,标记该函数为闭包需使用传入的上下文寄存器
LOCAL = 128此符号是动态共享对象的本地符号。
TLSBSS = 256用于 DATA 和 GLOBAL 指令,标记分配 TLS 存储单元并将其偏移量存储在变量中
NOFRAME = 512用于 TEXT 指令,标记不在函数中插入分配栈帧空间的指令,适用于零栈帧函数。
TOPFRAME = 2048用于 TEXT 指令, 函数在调用栈顶部,调用追踪在此处停止。

framesize: 本函数栈帧大小 = 局部变量(包括返回值,也属于局部变量,写代码的时候有时是会显示声明,有时候隐式声明;但不包含参数变量,该部分由函数调用方提供) + 调用其它函数参数空间的总大小。

argsize:调用方为调用该函数提供的参数空间(包括返回值空间),这个大小在不同 Go 版本有不同的约定

  • GO 1.7.1 之前 参数+返回值都存在栈帧中,此时 argsize = 调用参数大小 + 函数返回值大小
  • GO 1.7.1 更新后, 优先使用 9 个 通用寄存器传递参数与返回值,超出部分再存在栈中,并且寄存器中返回值会覆盖参数中的值。
    • 返回值 <= 9 个,argsize = 调用参数的大小
    • 返回值 > 9 个,argsize = 参数大小 + 返回值超出 9 个的部分大小

3.3.3 变量声明

全局数据通过一组 DATA 指令加上一个 GLOBAL 指令来定义。

DATA 指令的格式是 DATA symbol+offset(SB)/width, value 表示在符号 symbol 的指定偏移量 offset 处初始化一个大小为 width 初始值为 value 的内存段,多个 DATA 指令的 offset/width 必须是连续的。这个 offset 需要稍微注意,其含义是该值相对于符号 symbol 的偏移,而不是相对于 SB 的偏移。

GLOBAL 指令格式是 GLOBL symbol(SB), flag, $64用于声明全局符号,需要指定符号名,参数和大小。如果 DATA 指令没有初始化值,则 GLOBAL 会将其初始化为 0。在 GLOBL 中加入 <>, 如 GLOBL foo<>(SB), RODATA, $16 则表示这个全局变量只在本文件中生效,此处 RODATA 参数表示只读数据(Read Only)。

实际例子:

// src/runtime/asm_amd64.s, 这里声明的 argc,argv 是 Go 程序的入参
// NOPTR 这个表示不是指针,不需要垃圾回收扫描
DATA _rt0_amd64_lib_argc<>(SB)/8, $0
GLOBL _rt0_amd64_lib_argc<>(SB),NOPTR, $8
DATA _rt0_amd64_lib_argv<>(SB)/8, $0
GLOBL _rt0_amd64_lib_argv<>(SB),NOPTR, $8

局部变量:其在栈帧中,不需要声明,直接依靠 offset 取出使用。例如 0(FP) 代表函数第一个参数,arg0-8(SP) 代表函数中第一个局部变量。

3.3.4 常用指令

  • 栈调整

intel 或 AT&T 汇编提供了 push 和 pop 指令族,plan9 中虽然有 push 和 pop 指令,但一般生成的代码中是没有的,我们看到的栈的调整大多是通过对物理 SP 寄存器进行运算来实现的,例如:

SUBQ $0x18, SP // 对 SP 做减法,为函数分配函数栈帧 16+8=24
ADDQ $0x18, SP // 对 SP 做加法,清除函数栈帧
  • 数据搬运

MOV 指令表示数据搬运操作,关于该操作有以下常用参数:

  1. Go 汇编指令一般格式是:操作码 + 源操作数 + 目标操作数的形式。例如:MOVQ $10, AX;表示 AX = 10。
  2. Go 汇编会在指令后加上 B , W , D 或 Q , 分别表示操作数的大小为 1 个,2 个,4 个或 8 个字节,例如:MOVQ
  3. 立即操作数需要加上 $ 符号做前缀,比如:MOVB $1, DI 这条指令中第一个操作数不是寄存器,也不是内存地址,而是直接写在指令中的一个常数,这种操作数叫做立即操作数。这条指令表示把数值 1 放入 DI 寄存器中。常数可表示为 $num, 可以为负数,默认为十进制,可以使用 $0x18 表示 16 进制的 24;
  4. 寄存器间接寻址的格式为 offset(register) ,如果 offset 为 0,则可以略去偏移不写直接写成(register)。何为间接寻址呢?其实就是指令中的寄存器并不是真正的源操作数或目的操作数,寄存器的值是一个内存地址,这个地址对应的内存才是真正的源或目的操作数,比如:MOVQ (R1), R2 这条指令,第一个操作数 (R1) 用括号括起来,则表示间接寻址,R1 的值是一个内存地址,这条指令真实意思是:(R2 = *R1) 把 R1 寄存器的值(内存地址)对应的内存赋值给寄存器 R2 中的值;相对比,MOVQ R1, R2这条指令表示 R2 = R1
MOVB $1, DI      // 1 byte
MOVW $0x10, BX   // 2 bytes
MOVD $1, DX      // 4 bytes
MOVQ $-10, AX     // 8 bytes

MOVQ (R1), R2 // R2 = *R1
MOVQ R1, R2 // R2 = R1
MOVQ 8(R3), R4 // R4 = *(8 + R3)
MOVQ 16(R5)(R6*1), R7 // R7 = *(16 + R5 + R6*1)

MOVQ ·myvar(SB), R8 // R8 = *myvar
MOVQ $·myvar(SB), R8 // R8 = &myvar
  • 地址运算

LEA:将有效地址加载到指定的地址寄存器中。amd64 平台地址都是 8 个字节,所以直接就用 LEAQ 就好了。

LEAQ (BX)(AX*8), CX // CX = BX + (AX * 8)
// 上面代码中的 8 代表 scale
// scale 只能是 0、2、4、8
// 如果写成其它值:
// LEAQ (BX)(AX*3), CX
// ./a.s:6: bad scale: 3

// 用 LEAQ 的话,即使是两个寄存器值直接相加,也必须提供 scale
// 下面这样是不行的
// LEAQ (BX)(AX), CX
// asm: asmidx: bad address 0/2064/2067
// 正确的写法是
LEAQ (BX)(AX*1), CX


// 在寄存器运算的基础上,可以加上额外的 offset
LEAQ 16(BX)(AX*1), CX

// ret+24(FP) 这代表了第三个函数参数,是个地址
LEAQ    ret+24(FP), AX  // 把 ret+24(FP) 地址加载到 AX 寄存器中
  • 数据计算

数据计算就是我们常见的”加减乘除“,注释写的很明白!

ADDQ  AX, BX  // BX += AX
SUBQ  AX, BX  // BX -= AX
IMULQ AX, BX  // BX *= AX
SUB   R3, R4, R5 // R5 = R4 - R3
MUL   $7, R6  // R6 *= 7
  • 跳转

跳转包括条件跳转和无条件跳转两种模式:

// 无条件跳转
JMP addr   // 跳转到地址,地址可为代码中的地址,不过实际上手写不会出现这种东西
JMP label  // 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC)  // 以当前指令为基础,向前/后跳转 x 行
JMP -2(PC) // 同上

// 有条件跳转
JZ target // 如果 zero flag 被 set 过,则跳转
JLS num   // 如果上一行的比较结果,左边小于右边则执行跳到 num 地址处
  • 控制流

对于函数控制流的跳转,是用label来实现的,label 只在函数内可见。

next: // next 标签定义
  MOVW $0, R1 
  JMP  next // 跳转到标签,可以跳转到同一函数内的标签位置
  • DECQ/INCQ 自增和自减

用于减少或者增加寄存器内的数值,DECQ(Decrease), INCQ(Increase)。

INCQ CX // CX++
DECQ CX // CX--
  • CALL 与 RET 指令

CALL指令是用来调用函数的,在调用函数时除了要准备参数和返回值还需要保存 CALL 指令的下一条指令的地址(return addr),以便函数返回时能继续运行 caller 里的代码,这个保存动作就是 CALL 指令隐式去做的,它会把 PC 寄存器(储存的 CPU 下一条要运行的指令)的值(return addr)保存到栈里,因此栈结构那一小节会有这一块的内存。

// CALL 指令的使用方式
CALL main.sub(SB)

CALL相当于有三步操作

  1. SUBQ $8, SP // 栈向下增长 8 个字节(申请 8 个字节的内存)
  2. MOVQ PC, (SP) // 在这刚刚申请的 8 个字节上赋值 PC 当前值,其实就是 return address
  3. MOVQ 函数的第一条指令, PC // PC 指向 CALL 调用的函数的地址,这样 CPU 就可以执行被调用函数了。

RET指令是用来返回一个函数的,相当于 return 语句。RET 与 CALL 是相反的操作,RET 相当于两步操作

  1. MOVQ (SP), PC // PC 赋值为 SP 指向的地址的值,也就是刚刚 CALL 设置进来的 return address
  2. ADDQ $8, SP // 回收栈内存,CALL 申请的

3.4 手写 Go 汇编

Go 语言手写的汇编使用的 ABI 格式是 ABI0。对于手写汇编来说,所有参数通过栈来传递,通过伪寄存器 FP 偏移进行访问,函数的返回值跟随在输入参数后面,各个输入参数和返回值将以倒序的方式从高地址位分布于栈空间上,在下一小节会详细进行分析。

3.4.1 计算 argsize 和 framesize

Go 汇编使用的是 caller-save 模式,因此被调用函数的参数、返回值的栈内存都需要由调用者维护和准备,在 amd64 平台(指针大小为 8 字节)上不满足 8 字节倍数的内存需要进行对齐。

argsize = 参数大小求和 + 返回值大小求和 (最后再做内存对齐,8的倍数)

函数参数往往混合了多种类型,还需要考虑内存对齐问题,所以如果不确定自己的函数签名需要多大的 argsize,可以通过简单实现一个相同签名的空函数,然后 go tool objdump(该命令如何使用后边有讲) 来逆向查找应该分配多少空间。当然,我这里使用的是 Go 1.20.7 版本,argsize 只包含部分返回值的大小,其余 9 个返回值用寄存器传递,所以可以把返回值当做参数定义到函数里,自然就可以得到 ABI0 格式下的 argsize。

函数的 framesize(函数栈大小) 就稍微复杂一些了,手写代码的 framesize 不需要考虑由编译器插入的 caller BP(调用方的栈底,后边实例分析会讲到),需要考虑以下内容:

  1. 全部局部变量大小之和。
  2. 在函数中是否有对其它函数调,若有需要为其准备 argsize (虽然 return address(rip) 的值也是存储在 caller 的 stack frame 上的,但是这个过程是由 CALL 指令和 RET 指令完成 PC 寄存器的保存和恢复的,在手写汇编时,同样也是不需要考虑这个 PC 寄存器在栈上所需占用的 8 个字节的)。
  3. 原则上调用函数时只要不把局部变量覆盖掉就可以了,因此可以多分配一点 framesize,如果少分配了就会导致局部变量被覆盖,按理说,只要保证进入和退出汇编函数时的 caller 和 callee 能正确拿到返回值就可以。

3.4.2 案例一:求和

main.go

package main
var a = 999
func add(b int) int
func main() {
	println(add(1))
}

sum.s

#include "textflag.h"

TEXT ·add(SB), NOSPLIT, $0-16
    MOVQ ·a(SB), AX  // 共用全局变量 a
    ADDQ b+0(FP), AX  // 使用参数 b
    MOVQ AX, ret+8(FP) // 返回
    RET
// 最后一行的空行是必须的,否则可能报 unexpected EOF

案例一中使用 main,go 和 sum.s 文件共同构成求和函数,使用 go build 即可编译执行;在 .go 文件中声明 add 函数,在 .s 文件中进行函数实现,.s 文件中求和函数使用了 a 这个全局变量(.go 文件中定义的),是为了说明 .s 和 .go 文件的全局变量是可以互相使用的, ·a(SB) 表示该符号需要链接器来帮我们进行重定向(relocation)。

3.4.3 案例二:循环求和

求切片每个元素之和:使用了自减、跳转加控制流指令实现了循环求和。 main.go

package main

func sum([]int64) int64

func main() {
    println(sum([]int64{1, 2, 3, 4, 5}))
}

sum.s

#include "textflag.h"

// func sum(sl []int64) int64
TEXT ·sum(SB), NOSPLIT, $0-32
    MOVQ $0, SI       // 初始化 SI
    MOVQ sl+0(FP), BX // slice 的底层数组的指针
    MOVQ sl+8(FP), CX // slice 的长度 len

start:
    ADDQ (BX), SI // SI += *BX
    ADDQ $8, BX   // 指针移动
	DECQ CX       // CX--
    JZ   done     // CX = 0 时跳转
    JMP  start

done:
    // 返回地址是 24 是怎么得来的呢?
    // 可以通过 go tool compile -S main.go 得知
	// MOVQ    AX, main..autotmp_3+24(SP)
    // 在调用 sum 函数时,会传入三个值,分别为:
    // slice 的首地址、slice 的 len, slice 的 cap
    // 不过我们这里的求和只需要 len,但 cap 依然会占用参数的空间
    // 就是 16(FP)
    MOVQ SI, ret+24(FP)
    RET

4.剖析 Go 函数调用栈分配过程

本小节将使用一个简单的汇编代码,解答以下几个问题:

  1. CPU 是如何从调用者跳转到被调用函数执行的?
  2. 参数是如何从调用者传递给被调用函数的?
  3. 函数局部变量所占内存是怎么在栈上分配的?
  4. 返回值是如何从被调用函数返回给调用者的?
  5. 函数执行完成之后又需要做哪些清理工作?

4.1 Go 工具链

可以使用 Go 工具链的命令将 Go 代码编译为汇编代码。

  • -N 禁用编译优化
  • -l 禁止内联
  • -m 打印编译优化策略(包括逃逸情况和函数是否内联,以及变量分配在堆或栈)
  • -S 打印汇编
// 方式一
go build -gcflags="-S -l -N" main.go 2> main.s

// 方式二
GOOS=linux GOARCH=amd64 go tool compile -S -L -N main.go

// 方式三
go build -gcflags="-S -l -N" main.go // 先编译为二进制
go tool objdump -s main.main main // 反汇编具体函数
go tool objdump -s "main\." main // 反编译获取汇编

注意,这里顺带说一下,Go build -gcflags="-S -l -N" main.go 这个命令的输出是直接输出到标准错误的(FD = 2),所以如果需要重定向到文件需要将错误重定向到文件,也就是 go build -gcflags="-S -l -N" main.go 2> main.s

4.2 示例代码

示例很简单,主要目的是梳理一下函数调用过程。使用 go build -gcflags="-S -l -N" main.go 2> main.s 编译代码并输出汇编。

func main() {
	_ = run(10, 20)
}

func run(a, b int) (x int) {
	c := a + b
	d := sub(100, c)
	return d
}

func sub(x, y int) int {
	return x - y
}

4.3 汇编代码分析

在我的 Mac 机器上,amd64 的架构,汇编代码生成如下(输出结果中的 FUNCDATA 和 PCDATA 指令由编译器引入,包含 GC 用到的信息,我们额外不需要关注)。

main 函数汇编代码

main.main STEXT size=54 args=0x0 locals=0x18 funcid=0x0 align=0x0
# 声明 main 函数,函数栈帧 24 字节,格式 ABIInternal
# 24 = caller BP(8)+ 调用 run(16)
	0x0000 00000 (main.go:3)	TEXT	main.main(SB), ABIInternal, $24-0 
	# R14 寄存器就是当前协程 g,这里比较栈顶 SP 寄存器与 16(R14) 地址大小(也就是字段 stackguard0);如果小于说明栈空间不足
	0x0000 00000 (main.go:3)	CMPQ	SP, 16(R14) 
	0x0004 00004 (main.go:3)	PCDATA	$0, $-2
	0x0004 00004 (main.go:3)	JLS	47 # 栈空间不足,则跳转到地址 00047  CALL	runtime.morestack_noctxt(SB)
	0x0006 00006 (main.go:3)	PCDATA	$0, $-1
  # 生成 24 字节大小的栈空间
	0x0006 00006 (main.go:3)	SUBQ	$24, SP # 即把 SP 向低地址移动 24 字节作为 main 栈的栈顶
  # BP 是栈帧的栈底指针,当进行函数调用时需要把 caller 的 BP 中的值保存在 callee 的栈帧中
	0x000a 00010 (main.go:3)	MOVQ	BP, 16(SP) 
	0x000f 00015 (main.go:3)	LEAQ	16(SP), BP # 把当前(callee)栈帧的栈底地址赋值给 BP 寄存器,指向 callee 的栈底
	0x0014 00020 (main.go:3)	FUNCDATA	$0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0014 00020 (main.go:3)	FUNCDATA	$1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0014 00020 (main.go:4)	MOVL	$10, AX # AX 存储 10
	0x0019 00025 (main.go:4)	MOVL	$20, BX # BX 存储 20
	0x001e 00030 (main.go:4)	PCDATA	$1, $0
  # NOP(No Operation)指令是汇编语言中的一种指令,它表示不执行任何操作,仅作为一个占位符使用
	0x001e 00030 (main.go:4)	NOP
	0x0020 00032 (main.go:4)	CALL	main.run(SB) # 调用 main.run 函数
	0x0025 00037 (main.go:5)	MOVQ	16(SP), BP  # BP 重新指向 caller 的 BP
	0x002a 00042 (main.go:5)	ADDQ	$24, SP # 回收栈空间
	0x002e 00046 (main.go:5)	RET # 返回
	0x002f 00047 (main.go:5)	NOP
	0x002f 00047 (main.go:3)	PCDATA	$1, $-1
	0x002f 00047 (main.go:3)	PCDATA	$0, $-2
	0x002f 00047 (main.go:3)	CALL	runtime.morestack_noctxt(SB) // 执行扩容
	0x0034 00052 (main.go:3)	PCDATA	$0, $-1
	0x0034 00052 (main.go:3)	JMP	0 // 扩容后跳回 0 地址继续执行 main 第一条指令
  

一旦检查到栈溢出,就会 call runtime.morestack_noctxt(SB) 执行栈拷贝功能,这包含新栈分配+旧栈拷贝两个部分。

run 函数汇编代码

main.run STEXT size=104 args=0x10 locals=0x30 funcid=0x0 align=0x0
# 声明 main.run 函数,函数栈帧 48 字节 
# 48 = caller BP(8)+ 调用 sub(16)+ 局部变量(16) + 返回值局部变量(8)
	0x0000 00000 (main.go:7)	TEXT	main.run(SB), ABIInternal, $48-16 
	0x0000 00000 (main.go:7)	CMPQ	SP, 16(R14)
	0x0004 00004 (main.go:7)	PCDATA	$0, $-2
	0x0004 00004 (main.go:7)	JLS	77
	0x0006 00006 (main.go:7)	PCDATA	$0, $-1
	0x0006 00006 (main.go:7)	SUBQ	$48, SP # 分配栈空间
	0x000a 00010 (main.go:7)	MOVQ	BP, 40(SP) # 存储 caller 的 BP
	0x000f 00015 (main.go:7)	LEAQ	40(SP), BP # 指向 callee 的BP
	0x0014 00020 (main.go:7)	FUNCDATA	$0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0014 00020 (main.go:7)	FUNCDATA	$1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x0014 00020 (main.go:7)	FUNCDATA	$5, main.run.arginfo1(SB)
  # AX 为第一个参数 10,存储到 56(SP) 地址处,属于 caller 函数栈,看图更清晰
	0x0014 00020 (main.go:7)	MOVQ	AX, main.a+56(SP) 
	0x0019 00025 (main.go:7)	MOVQ	BX, main.b+64(SP) # 同 AX,BX 存储 20
	0x001e 00030 (main.go:7)	MOVQ	$0, main.x+16(SP) # x 返回值,在 callee 栈内
	0x0027 00039 (main.go:8)	ADDQ	AX, BX # BX 存储 10 + 20
	0x002a 00042 (main.go:8)	MOVQ	BX, main.c+32(SP) # c 局部变量存储结果 30
	0x002f 00047 (main.go:9)	MOVL	$100, AX # AX 用于下一个函数的传参 100
	0x0034 00052 (main.go:9)	PCDATA	$1, $0
	0x0034 00052 (main.go:9)	CALL	main.sub(SB) # 调用 main.sub
  # main.sub 函数的返回值用 AX 传递,存储在 caller 栈内存中
	0x0039 00057 (main.go:9)	MOVQ	AX, main.d+24(SP)  
	0x003e 00062 (main.go:10)	MOVQ	AX, main.x+16(SP) # 自己的返回值属于局部变量
	0x0043 00067 (main.go:10)	MOVQ	40(SP), BP # BP 重新指向 main 函数 BP
	0x0048 00072 (main.go:10)	ADDQ	$48, SP # 回收栈空间
	0x004c 00076 (main.go:10)	RET
	0x004d 00077 (main.go:10)	NOP
	0x004d 00077 (main.go:7)	PCDATA	$1, $-1
	0x004d 00077 (main.go:7)	PCDATA	$0, $-2
	0x004d 00077 (main.go:7)	MOVQ	AX, 8(SP) # 栈扩容前先将参数存储到 caller 栈内存中
	0x0052 00082 (main.go:7)	MOVQ	BX, 16(SP)
	0x0057 00087 (main.go:7)	CALL	runtime.morestack_noctxt(SB)
	0x005c 00092 (main.go:7)	MOVQ	8(SP), AX # 栈扩容完毕,再赋值到寄存器中
	0x0061 00097 (main.go:7)	MOVQ	16(SP), BX 
	0x0066 00102 (main.go:7)	PCDATA	$0, $-1
	0x0066 00102 (main.go:7)	JMP	0
  

sub 函数汇编代码

main.sub STEXT nosplit size=49 args=0x10 locals=0x10 funcid=0x0 align=0x0
# 声明 main.sub 函数,函数栈帧 16 字节 
# 16 = caller BP(8) + 返回值局部变量(8)  
# NOSPLIT 标志不用做栈溢出检查
	0x0000 00000 (main.go:13)	TEXT	main.sub(SB), NOSPLIT|ABIInternal, $16-16
	0x0000 00000 (main.go:13)	SUBQ	$16, SP # 分配栈空间
	0x0004 00004 (main.go:13)	MOVQ	BP, 8(SP) # 存储 caller BP
	0x0009 00009 (main.go:13)	LEAQ	8(SP), BP # 指向 caller BP
	0x000e 00014 (main.go:13)	FUNCDATA	$0, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x000e 00014 (main.go:13)	FUNCDATA	$1, gclocals·g2BeySu+wFnoycgXfElmcg==(SB)
	0x000e 00014 (main.go:13)	FUNCDATA	$5, main.sub.arginfo1(SB)
	0x000e 00014 (main.go:13)	MOVQ	AX, main.x+24(SP) # 参数传递 100
	0x0013 00019 (main.go:13)	MOVQ	BX, main.y+32(SP) # 参数传递 30
	0x0018 00024 (main.go:13)	MOVQ	$0, main.~r0(SP) # 返回值局部变量,未命名 r0
	0x0020 00032 (main.go:14)	SUBQ	BX, AX # 相减后存储在 AX
	0x0023 00035 (main.go:14)	MOVQ	AX, main.~r0(SP) # 局部返回值变量赋值 70
	0x0027 00039 (main.go:14)	MOVQ	8(SP), BP #  BP 重新指向 run 函数 BP
	0x002c 00044 (main.go:14)	ADDQ	$16, SP # 回收栈内存
	0x0030 00048 (main.go:14)	RET
  

整体函数栈内存图:(对照着上边的汇编看,应该很容易看明白) 栈内存.png

4.4 问题解答

CPU 是如何从调用者跳转到被调用函数执行的?

还记得 CALL 指令相当于以下三步操作吗?

  1. SUBQ $8, SP // 栈向下增长 8 个字节(申请 8 个字节的内存)
  2. MOVQ PC, (SP) // 在这刚刚申请的 8 个字节上赋值 PC 当前值,其实就是 return address
  3. MOVQ 函数的第一条指令, PC // PC 指向 CALL 调用的函数的地址,这样 CPU 就可以执行被调用函数了。

经过这三步操作后,PC 指向了函数的第一条指令,CPU 执行下一条指令时,就会从调用者跳转到被调用函数执行。

参数是如何从调用者传递给被调用函数的?

经过对汇编源码和函数栈内存的分析,我们可以得知调用者(caller)栈帧中存储了传递给被调用函数的参数和部分返回值,而参数传递时通过 AX 或 BX 寄存器实现的,传递方式和 Go 版本息息相关。

函数局部变量所占内存是怎么在栈上分配的?

函数内部的局部变量被分配到自己的栈帧中,随着函数调用结束而自动消亡,最早声明的局部变量被分配到低地址,向高地址扩散,可以看一下上一小节的栈内存分配图。

返回值是如何从被调用函数返回给调用者的?

在 Go 1.20.7 版本,前 9 个返回值是通过寄存器传递给调用者的,多于 9 个的部分通过存储在调用者函数栈帧中进行传递,我们可以看到 sub 函数返回到 run 函数的参数的传递方式正是使用了寄存器传递。

函数执行完成之后又需要做哪些清理工作?

首先执行 ADDQ $16, SP回收栈内存,随后使用 RET 指令返回调用者。 还记得 RET 指令相当于两步操作吗?

  1. MOVQ (SP), PC // PC 赋值为 SP 指向的地址的值,也就是刚刚 CALL 设置进来的 return address
  2. ADDQ $8, SP // 回收栈内存,CALL 申请的

将 PC 指向调用前的下一条指令地址,等 CPU 执行时,就顺利的从调用函数返回到了调用者。回收存储 return address 时的栈内存。

5.开始整 GMP 源码

5.1 安装 gdb 调试工具

主要步骤:

  1. 下载安装 brew install gdb
  2. 创建证书(mac 下版本 10.15.6 Catalina)
  3. 证书授权 gdb

细节挺多的,网上很多人也安装不成功,后边会单独出一篇文章,贴到这里! 写完了补充到这里 juejin.cn/post/731978…

5.2 寻找程序入口

main.go 文件源码:

package main

import "fmt"

func main() {
    fmt.Println("Hello World!")
}

编译源码

// 关闭函数内联和编译器的代码优化,使得编译可以不受其干扰更好地可以查看编译源码的展示
go build -gcflags=all="-N -l" -ldflags=-compressdwarf=false -o main main.go

执行 gdb main 进入程序调试,执行 info files 查看程序入口:

image.png

在程序入口设置断点进行调试 break *0x1068260

image.png

执行 run 运行程序,就找到对应的汇编文件了,接着就可以顺着汇编文件进行分析了。

image.png

这里我们先简单看一下这个文件:

image.png

JMP 直接跳转到了 _rt0_amd64(SB) 函数,使用全局搜索,找到定义的地方,就可以继续愉快的看源码了! image.png

入口源码如下:

// _rt0_amd64 is common startup code for most amd64 systems when using
// internal linking. This is the entry point for the program from the
// kernel for an ordinary -buildmode=exe program. The stack holds the
// number of arguments and the C-style argv.
TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

前两行指令把操作系统内核传递过来的参数 argc 和 argv 数组的地址分别放在 DI 和 SI 寄存器中,第三行指令跳转到 rt0_go 去执行。

rt0_go 函数完成了 go 程序启动时的所有初始化工作,因此这个函数比较长,也比较繁杂,这里我们能看到与调度器相关的一些初始化;GMP 就从这里开始了,详细内容下一节再分享吧!

总结

本文讲解了寄存器、内存、Go 汇编语法 以及函数调用栈结构等相关内容,能够快速入门 Go 汇编;随后以求和为例,实现了自己的手写汇编代码,然后深度剖析了 Go 函数调用栈分配过程;最后,进入我们的终极目标,开始研读 GMP 源码,讲述了如何开始阅读 GMP,后续会继续分享 GMP 源码的阅读过程,分析、总结 Go 调度器的相关知识,敬请期待!

以上就是本文的全部内容,如果觉得还不错的话欢迎点赞转发关注,感谢支持。