Go的编译器先将源代码编译成Plan9风格的汇编指令,之后再通过汇编器和链接器生成不同平台的可执行程序。再深入研究源代码执行表现时,经常需要观察汇编指令,了解基本的Plan9汇编语法不可或缺。

CPU寄存器

32位x86架构的CPU有8个32位的通用寄存器(EAX,ECX,EDX,EBX,ESP,EBP,ESI,EDI),在汇编语言中可以通过名称直接引用这8个寄存器。

image-20240409153719050

有些通用寄存器是有特殊用途的:

(1)EAX寄存器会被乘法和除法指令自动使用,通常称为扩展累加寄存器。

(2)ECX被LOOP系列指令用作循环计数器,但是多数上层语言不会使用LOOP指令,一般通过条件跳转系列指令实现。

(3)ESP用来寻址栈上的数据,很少用于普通算数或数据传输,通常称为扩展栈指针寄存器。

(4)ESI和EDI被高速内存传输指令分别用来指向源地址和目的地址,被称为扩展源索引寄存器和扩展目标索引寄存器。

(5)EBP在高级语言中被用来引用栈上的函数参数和局部变量,一般不用于普通算数或数据传输,称为扩展帧指针寄存器。

除了这些通用寄存器之外,还有一个标志寄存器EFLAGS比较重要。汇编语言中用于比较的CMP和TEST会修改标志寄存器里的相关标志,再结合条件跳转系列指令,就能实现上层语言中的大部分流程控制语句。最后还有一个很重要而且很特殊的寄存器,即指令指针寄存器EIP。指令指针寄存器中存储的是下一条将要被执行的指令的地址,而且汇编语言中不能通过名称直接引用EIP,只能通过跳转、CALL和RET等指令间接地修改EIP的值。

64位架构把通用寄存器的个数扩展到16个,之前的8个通用寄存器也被扩展成了64位,每个寄存器的低8位、16位、32位都可以单独使用。

64位架构下16个通用寄存器的结构设计

image-20240409152426444

指令指针EIP被扩展为64位的RIP,但依然不能在代码中直接引用。标志寄存器EFLAGS被扩展为64位的RFLAGS,里面的标志位保持向前兼容。内存地址也扩展到了64位,实际上目前的硬件只使用了低48位,这已经能物理寻址256TB内存,很难有服务器有这么多物理内存。

Plan9的寄存器约定

不同体系结构的 CPU,其内部寄存器的数量、种类以及名称可能大不相同,这里我们只介绍 AMD64 的寄存器。AMD64 有 20 多个可以直接在汇编代码中使用的寄存器,其中有几个寄存器在操作系统代码中才会见到,而应用层代码一般只会用到如下三类寄存器。

image-20240409165421175

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
指令       基础作用           Plan9中名称
rax       保存临时结果        AX
rbx       保存临时结果        BX
rcx       保存第四参数        CX
rdx       保存第三参数        DX
rsi       保存第二参数        SI
rdi       保存第一参数        DI
rbp       保存栈帧基址        BP
rsp       保存栈顶地址        SP
r8        保存第五参数        R8
r9        保存第六参数        R9
r10       保留               R10
r11       保留               R11
r12       保留               R12
r13       保留               R13
r14       保留               R14
r15       保留               R15
rip       保存指令地址        PC
rflags    标识寄存器          FLAGS

上述这些寄存器除了段寄存器是 16 位的,其它都是 64 位的,也就是 8 个字节,其中的 16 个通用寄存器还可以作为 32/16/8 位寄存器使用。

Plan9中寄存器的一般用途:

image-20240409165603930

伪寄存器是 plan9 伪汇编中的一个助记符, 也是 Plan9 比较有个性的语法之一。常见伪寄存器如下表所示:

image-20240411150601680

  • SB:指向全局符号表。相对于寄存器,SB 更像是一个声明标识,用于标识全局变量、函数等。通过 symbol(SB) 方式使用,symbol<>(SB)表示 symbol 只在当前文件可见,跟 C 中的 static 效果类似。此外可以在引用上加偏移量,如 symbol+4(SB) 表示 symbol+4bytes 的地址。

  • PC:程序计数器(Program Counter),指向下一条要执行的指令的地址,在 AMD64 对应 rip 寄存器。个人觉得,把他归为伪寄存器有点令人费解,可能是因为每个平台对应的物理寄存器名字不一样。

  • SP:SP 寄存器比较特殊,既可以当做物理寄存器也可以当做伪寄存器使用,不过这两种用法的使用语法不同。其中,伪寄存器使用语法是 symbol+offset(SP),此场景下 SP 指向局部变量的起始位置(高地址处);x-8(SP) 表示函数的第一个本地变量;物理 SP(硬件SP) 的使用语法则是 +offset(SP),此场景下 SP 指向真实栈顶地址(栈帧最低地址处)。

  • FP:用于标识函数参数、返回值。被调用者(callee)的 FP 实际上是调用者(caller)的栈顶,即 callee.SP(物理SP) == caller.FP;x+0(FP) 表示第一个请求参数(参数返回值从右到左入栈)。使用如 symbol+offset(FP)的方式,引用 callee 函数的入参参数。例如 arg0+0(FP),arg1+8(FP),使用 FP 必须加 symbol ,否则无法通过编译(从汇编层面来看,symbol 没有什么用,加 symbol 主要是为了提升代码可读性)。另外,需要注意的是:往往在编写 go 汇编代码时,要站在 callee 的角度来看(FP),在 callee 看来,(FP)指向的是 caller 调用 callee 时传递的第一个参数的位置。假如当前的 callee 函数是 add,在 add 的代码中引用 FP,该 FP 指向的位置不在 callee 的 stack frame 之内。而是在 caller 的 stack frame 上,指向调用 add 函数时传递的第一个参数的位置,经常在 callee 中用symbol+offset(FP)来获取入参的参数值。

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

Plan9基本知识

Go语言使用的汇编代码风格跟最常见的Intel风格和AT&T风格都不太相同,根据官方文档的说法,是基于Plan 9汇编器的风格做了一些调整。

1.操作数的宽度

在Go汇编中通过指令的后缀来判断操作数的宽度,后缀B代表8位,W代表16位,D或L代表32位,Q代表64位,不像Intel汇编有AX、EAX、RAX不同的寄存器名称。

# Intel
INC EAX
INC RCX

# Go汇编
INCL AX
INCQ CX

2.操作数的顺序

对于常见的有两个操作数的指令,Go汇编中操作数的顺序与Intel汇编中操作数的顺序是相反的,源操作数在前而目的操作数在后。

# Intel
MOV EAX, ECX

# Go汇编
MOVL CX, AX

3.地址的表示

如果要用ESP作为基址寄存器,EBX作为索引寄存器,比例系数取2,位移为16,则可以分别给出两种风格的代码。

# Intel
[ESP + EBX*2 + 16]

# Go汇编
16(SP)(BX*2)

4.立即数格式

Go汇编中的立即数类似于AT&T风格的立即数,需要加上$前缀。

# Intel
MOV EAX, 1234

# Go汇编
MOVL $1234, AX

Go汇编

常见指令如下表:

image-20240411134724155

例如:

MOVB $1, DI     // 1 byte; 将 DI 的第一个 Byte 的值设置为 1
MOVW $0x10, BX  // 2bytes
MOVD $1, DX     // 4 bytes
MOVQ $-10, AX   // 8 bytes
SUBQ $0x18, SP  // 对SP做减法,扩栈
ADDQ $0x18, SP  // 对SP做加法,缩栈
ADDQ AX, BX     // BX += AX
SUBQ AX, BX     // BX -= AX
IMULQ AX, BX    // BX *= AX
JMP addr        // 跳转到地址,地址可为代码中的地址,不过实际上手写一般不会出现
JMP label       // 跳转到标签,可以跳转到同一函数内的标签位置
JMP 2(PC)       // 向前转2行
JMP -2(PC)      // 向后跳转2行
JNZ target      // 如果zero flag被set过,则跳转

函数声明如下:

image-20240410163335256

函数调用示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
告诉汇编器该数据放到TEXT区
  ^                        静态基地址指针(告诉汇编器这是基于静态地址的数据)
  |                                ^    
  |                                |   标签   函数入参+返回值占用空间大小(包含在调用此函数的函数栈帧中)
  |                                |    ^      ^
  |                                |    |      |
TEXT pkgname·funcname<ABIInternal>(SB),TAG,$16-24
     ^         ^        ^                   ^
     |         |        |                   |
函数所属包名   函数名  表示ABI类型           函数栈帧大小(局部变量、调用其它函数时参数和返回值等占用的空间)
  • 这里函数栈帧大小,除了本函数内部用到的局部变量,还包含了调用其它函数时,其它函数入参和返回值等需要占用的空间。
  • Go新版编译器将函数返回值放入寄存器当中,进一步压缩函数栈帧大小,提高运行速度。根据底层CPU差异有所不同,X86-64大概有(AX,BX,DI,SI,R8,R9,R10,R11,R12)等9个寄存器可以用,再多就需要额外的栈帧空间存放了。

汇编函数中用到的一些特殊命令(伪指令)

  • GO_RESULTS_INITIALIZED: 如果 Go 汇编函数返回值含指针,则该指针信息必须由 Go 源文件中的函数的 Go 原型提供,即使对于未直接从 Go 调用的汇编函数也是如此。如果返回值将在调用指令期间保存实时指针,则该函数中应首先将结果归零, 然后执行伪指令 GO_RESULTS_INITIALIZED。表明该堆栈位置应该执行进行 GC 扫描,避免其指向的内存地址呗 GC 意外回收。
  • NO_LOCAL_POINTERS: 就是字面意思,表示函数没有指针类型的局部变量。
  • PCDATA: Go 语言生成的汇编,利用此伪指令表明汇编所在的原始 Go 源码的位置(file&line&func),用于生成 PC 表格。runtime.FuncForPC 函数就是通过 PC 表格得到结果的。一般由编译器自动插入,手动维护并不现实。
  • FUNCDATA: 和 PCDATA 的格式类似,用于生成 FUNC 表格。FUNC 表格用于记录函数的参数、局部变量的指针信息,GC 依据它来跟踪栈中指针指向内存的生命周期,同时栈扩缩容的时候也是依据它来确认是否需要调整栈指针的值(如果指向的地址在需要扩缩容的栈中,则需要同步修改)。
1
2
3
指令       格式                 作用
PCDATA    PCDATA $N, $A       序号为N的全局常量和PC地址A绑定
FUNCDATA  FUNCDATA $N, $A     序号为N的函数常量和PC地址A绑定

Go函数调用栈帧

image-20240412152223154

参考阅读:

https://blog.csdn.net/ByteDanceTech/article/details/130212194

https://baijiahao.baidu.com/s?id=1677139791909759032

https://zhuanlan.zhihu.com/p/509151846

(完)