了解底层才能理解程序的运作方式,哪怕只懂一点点汇编,也便于更好地理解计算机底层原理。

基础

程序的编译阶段

从源码到最终可执行文件的整体流程如下:

源程序->编译预处理->编译->汇编->链接->可执行文件。

编译器

编译器即编译这一步干活的程序。

编译器粗略分为

词法分析,语法分析,类型检查,中间代码生成,代码优化,目标代码生成,目标代码优化。

以 中间代码生成 区分为前端/后端

指令集

目前市面上存在两种指令集架构类型:

  1. Reduced Instruction Set Computing (RISC) 精简指令集,比如ARM,MIPS等
  2. Complex Instruction Set Computing (CISC) 复杂指令集,比如Intel的X86及优化指令集SSE/AVX等

linux 可以 cat /proc/cpuinfo查看支持指令集

macos 可以 sysctl machdep.cpu查看支持指令集

go也可以通过cpu.X86查看X86当前支持的指令集

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
package main

import (
"fmt"
_ "unsafe"
)

type CacheLinePad struct{ _ [64]byte }

type Set struct {
_ CacheLinePad
HasAES bool
HasADX bool
HasAVX bool
HasAVX2 bool
HasBMI1 bool
HasBMI2 bool
HasERMS bool
HasFMA bool
HasOSXSAVE bool
HasPCLMULQDQ bool
HasPOPCNT bool
HasSSE2 bool
HasSSE3 bool
HasSSSE3 bool
HasSSE41 bool
HasSSE42 bool
_ CacheLinePad
}

//go:linkname X86 internal/cpu.X86
var X86 Set

func main() {
fmt.Println(X86)
}

Go汇编语言并没有支持全部的CPU指令。
完整的X86指令定义看这里
同时Go汇编还正对一些指令定义了别名,具体可以参考这里

寄存器

通用寄存器

rax(al,ah,ax,eax),rbx,rcx,rdx,rdi,rsi,r8-r15

Go汇编精简为

AX,BX,CX…..

指针寄存器

bp:指向栈底

sp:指向栈顶

段寄存器

cs——代码段寄存器(Code Segment Register),其值为代码段的段值

ds——数据段寄存器(Data Segment Register),其值为数据段的段值

es——附加段寄存器(Extra Segment Register),其值为附加数据段的段值

ss——堆栈段寄存器(Stack Segment Register),其值为堆栈段的段值

fs——附加段寄存器(Extra Segment Register),其值为附加数据段的段值

gs——附加段寄存器(Extra Segment Register),其值为附加数据段的段值

指令指针寄存器

rip: instruction pointer指令指针寄存器

cs:ip 一般也称为PC(programme counter程序计数器),指向下一条要执行的指令

伪寄存器

Go汇编为了简化汇编代码的编写,工具链维持出来了FP、PC、SB、SP四个伪寄存器

  1. FP: frame pointer, 帧指针,参数和局部变量
  2. PC: program counter: 程序计数器,跳转和分支
  3. SB: static base pointer: 静态基址指针,全局符号
  4. SP: stack pointer: 栈指针,栈的顶端

FP:寄存器指向函数参数。0(FP)是第一个参数,8(FP)是第二个参数(64-bit machine). first_arg+0(FP)表示把第一个参数地址绑定到符号 first_arg, 这个与SB的含义不同。

PC:程序计数器,指下一步要执行的程序,伪寄存器PC和硬件寄存器PC作用差不多。

SB:寄存器表示全局内存起点,foo(SB) 表示 符号foo作为内存地址使用。这种形式用于命名 全局函数,数据。<>限制符号只能在当前源文件使用,类似 C 中的 static。foo+4(SB)表示foo 往后 4字节的地址。

SP:寄存器表示栈指针,指向当前栈顶, 所以 offset 都是负数,范围在 [ -framesize, 0 ), 例如 x-8(SP). 对于硬件寄存器名称为SP的架构,x-8(SP) 表示虚拟栈指针寄存器(实际上是当前栈帧的 BP 位置), -8(SP) 表示硬件 SP 寄存器(与虚拟SP相隔当前栈帧大小).如果使用SP时有一个临时标识符前缀就是伪SP,否则就是真SP寄存器,所有生成的汇编代码都是真实SP寄存器,与手写汇编不一致

下方给个参考图示,1.17后会有区别见下方调用规约相关。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
高地址位
┼───────────┼
│ 返回值2 │
┼───────────┼
│ 返回值1 │
┼───────────┼
│ 参数值2 │
┼───────────┼
│ 参数值1 │
┼───────────┼ <-- 伪FP
│ 函数返回地址│
┼───────────┼
│ CALLER BP │
┼───────────┼ <-- 伪SP(BP)
│ 变量值m │
┼───────────┼ <-- m-8(SP)
│ 变量值n │
┼───────────┼ <-- 硬件SP <-- n-16(SP)
低地址位

其他

其他寄存器,比如cf,xmm等可以自己查看相关文献

汇编风格比较

Intel AT&T Go
mov eax, 1 movl $1, %eax MOVQ $1, AX
mov rbx, 0ffh movl $0xff, %rbx MOVQ $0xff, BX
mov ecx, [ebx+3] movl 3(%ebx), %ecx MOVQ 2(BX), CX

整体风格与AT&T类似

  1. 以$符号表示常量,但是寄存器并没有%前缀
  2. 以命令来区分操作长度比如 MOVB MOVW MOVL MOVQ

Go汇编命令绝大大多与AT&T一致 op src dst,比如 MOVQ $1, AX 表示把1赋值给AX,也有一部分是不一样的,比如CMP相关

程序段(section)

程序编译以后会分为不同的段,一般来说会分为

  1. .text 代码段 存放程序代码,通常是只读的
  2. .data 数据段 存放初始化了的全局变量和初始化了的静态变量
  3. .bss 全局变量段 存放未初始化的全局变量和未初始化的静态变量

名字都是历史定的,也没啥特殊硬记就完事。主要就是把数据按照不同的分类放在一起。也就是说可以自定义段信息来区分,能找到对应的数据就行。

Go汇编基础语法

symbol为变量在汇编语言中对应的标识符,当前包中Go语言定义的符号symbol,在汇编代码中对应·symbol,在符号后的<>作用是限制数据在当前文件使用。

offset是符号开始地址的偏移量

width是要初始化内存的宽度大小

value是要初始化的值

(SB)表示符号相对于SB伪寄存器的偏移量,二者组合在一起最终是绝对地址

DATA:用于初始化包变量

语法为: DATA symbol+offset(SB)/width, value

GLOBL:用于将符号导出

语法为: GLOBL symbol(SB), width

TEXT:用于定义符号(一般就是函数)

语法为: TEXT symbol(SB), [flags,] $framesize[-argsize]

其中函数名(symbol)中当前包的路径可以省略

flags用于指示函数的一些特殊行为,标志在textlags.h文件中定义,常见的NOSPLIT主要用于标记函数不进行栈分裂。

framesize表示栈帧大小,其中包含局部变量+调用其它函数时准备调用参数的隐式栈空间。

argsize参数及返回值大小,之所以可以省略是因为编译器可以从Go语言的函数声明中推导出函数参数的大小。

*PCDATA和FUNCDATA

Go里有一个函数叫做runtime.Caller,可以获取当前的PC寄存器值,以及文件和行号。然后根据PC寄存器表示的指令位置,通过runtime.FuncForPC函数获取函数的基本信息,这种功能是如何实现的?

Go语言作为一门静态编译型语言,在执行时每个函数的地址都是固定的,函数的每条指令也是固定的。如果针对每个函数和函数的每个指令生成一个地址表格(也叫PC表格),那么在运行时我们就可以根据PC寄存器的值轻松查询到指令当时对应的函数和位置信息。而Go语言也是采用类似的策略,只不过地址表格经过裁剪,舍弃了不必要的信息。因为要在运行时获取任意一个地址的位置,必然是要有一个函数调用,因此我们只需要为函数的开始和结束位置,以及每个函数调用位置生成地址表格就可以了。同时地址是有大小顺序的,在排序后可以通过只记录增量来减少数据的大小;在查询时可以通过二分法加快查找的速度。

这两命令就是标记地址表格和函数表格的指令

两者语法也一致,语法为: PCDATA/FUNCDATA tableid, tableoffset

tableid: 表格的类型

​ PCDATA有两种类型PCDATA_StackMapIndex,PCDATA_InlTreeIndex,两种类型类似,后者为内联函数的表格

​ FUNCDATA有3种类型

 1. `FUNCDATA_ArgsPointerMaps`: 函数参数的指针信息表
 2. `FUNCDATA_LocalsPointerMaps`: 局部指针信息表
 3. `FUNCDATA_InlTree`: 被内联展开的指针信息表

tableoffset: 表格的地址

通过FUNC表格,Go语言的垃圾回收器可以跟踪全部指针的生命周期,同时根据指针指向的地址是否在被移动的栈范围来确定是否要进行指针移动。

一般来说都是由编译器自动生成,手动实现不太现实

手写汇编

一般来说,就看下汇编生成就行了,手写汇编意义不大,当然,真想写也不是不行。。。

简单计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//add.s
TEXT ·Add(SB), $0-24
MOVQ a+0(FP), AX
MOVQ b+8(FP), BX
ADDQ BX,AX
MOVQ AX,ret+16(FP)
RET

TEXT ·Neg(SB), $0
MOVQ x+0(FP), AX
NEGQ AX
MOVQ AX, ret+8(FP)
RET
//add.go
package add

func Add(a int64, b int64) int64

func Neg(x uint64) int64

变量定义

1
2
3
4
5
6
7
8
9
10
11
12
13
//a.s
GLOBL str(SB),RODATA,$17
DATA str+0x00(SB)/8, $"aaaaaaaa"
DATA str+0x08(SB)/8, $"bbbbcccc"
DATA str+0x10(SB)/1, $0x0a

GLOBL ·foo(SB), RODATA, $16
DATA ·foo+0(SB)/8,$str(SB)
DATA ·foo+8(SB)/8,$24

//a.go
package a
var foo string

获取goid

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//goid.s
#include "textflag.h"

TEXT ·GetGoID(SB),NOSPLIT,$0-8
MOVQ (TLS),AX
MOVQ ·offset(SB),BX
MOVQ (AX)(BX*1),CX
MOVQ CX,g+0(FP)
RET

//goid.go
package goid

var offset = 152 //go1.16
func GetGoID() int

系统调用

1
2
3
4
5
6
7
8
9
10
11
//a.s
TEXT ·helloWorld(SB), NOSPLIT, $0
MOVL $(0x2000000+4), AX // syscall write
MOVQ $1, DI // arg 1 fd
LEAQ str(SB), SI // arg 2 buf
MOVL $24, DX // arg 3 count
SYSCALL
RET
//a.go
package hello
func helloWorld()

函数调用规约

传统的调用规约中,一般会区分为

  1. caller saved registers 调用方保存现场
  2. callee saved registers 被调方保存现场

1.16及以前

要求所有参数和返回值都通过从右至左压栈来传递

看一个示例

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
func add(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11 int) (r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11 int) {
return a11, a10, a9, a8, a7, a6, a5, a4, a3, a2, a1
}
/*
"".add STEXT nosplit size=132 args=0xb0 locals=0x0 funcid=0x0
0x0000 00000 (test.go:4) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-176
0x0000 00000 (test.go:4) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test.go:4) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test.go:5) MOVQ "".a11+88(SP), AX
0x0005 00005 (test.go:5) MOVQ AX, "".r1+96(SP)
0x000a 00010 (test.go:5) MOVQ "".a10+80(SP), AX
0x000f 00015 (test.go:5) MOVQ AX, "".r2+104(SP)
0x0014 00020 (test.go:5) MOVQ "".a9+72(SP), AX
0x0019 00025 (test.go:5) MOVQ AX, "".r3+112(SP)
0x001e 00030 (test.go:5) MOVQ "".a8+64(SP), AX
0x0023 00035 (test.go:5) MOVQ AX, "".r4+120(SP)
0x0028 00040 (test.go:5) MOVQ "".a7+56(SP), AX
0x002d 00045 (test.go:5) MOVQ AX, "".r5+128(SP)
0x0035 00053 (test.go:5) MOVQ "".a6+48(SP), AX
0x003a 00058 (test.go:5) MOVQ AX, "".r6+136(SP)
0x0042 00066 (test.go:5) MOVQ "".a5+40(SP), AX
0x0047 00071 (test.go:5) MOVQ AX, "".r7+144(SP)
0x004f 00079 (test.go:5) MOVQ "".a4+32(SP), AX
0x0054 00084 (test.go:5) MOVQ AX, "".r8+152(SP)
0x005c 00092 (test.go:5) MOVQ "".a3+24(SP), AX
0x0061 00097 (test.go:5) MOVQ AX, "".r9+160(SP)
0x0069 00105 (test.go:5) MOVQ "".a2+16(SP), AX
0x006e 00110 (test.go:5) MOVQ AX, "".r10+168(SP)
0x0076 00118 (test.go:5) MOVQ "".a1+8(SP), AX
0x007b 00123 (test.go:5) MOVQ AX, "".r11+176(SP)
0x0083 00131 (test.go:5) RET
0x0000 48 8b 44 24 58 48 89 44 24 60 48 8b 44 24 50 48 H.D$XH.D$`H.D$PH
0x0010 89 44 24 68 48 8b 44 24 48 48 89 44 24 70 48 8b .D$hH.D$HH.D$pH.
0x0020 44 24 40 48 89 44 24 78 48 8b 44 24 38 48 89 84 D$@H.D$xH.D$8H..
0x0030 24 80 00 00 00 48 8b 44 24 30 48 89 84 24 88 00 $....H.D$0H..$..
0x0040 00 00 48 8b 44 24 28 48 89 84 24 90 00 00 00 48 ..H.D$(H..$....H
0x0050 8b 44 24 20 48 89 84 24 98 00 00 00 48 8b 44 24 .D$ H..$....H.D$
0x0060 18 48 89 84 24 a0 00 00 00 48 8b 44 24 10 48 89 .H..$....H.D$.H.
0x0070 84 24 a8 00 00 00 48 8b 44 24 08 48 89 84 24 b0 .$....H.D$.H..$.
0x0080 00 00 00 c3 ....
*/

可以简单的看出来就是把a11-a1移动到了r1-r11上

1.17+

改成caller saved registers,通过寄存器来调用,但是与c不一样的是,使用9个寄存器来进行交互,依次是 AX,BX,CX,DI,SI,R8,R9,R10,R11,超出部分还是通过栈来传递

所以1.17在性能及内存使用上会提升很多

同样看示例

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
func add(a1, a2, a3, a4, a5, a6, a7, a8, a9, a10, a11 int) (r1, r2, r3, r4, r5, r6, r7, r8, r9, r10, r11 int) {
return a11, a10, a9, a8, a7, a6, a5, a4, a3, a2, a1
}
/*
"".add STEXT nosplit size=48 args=0x68 locals=0x0 funcid=0x0
0x0000 00000 (test.go:4) TEXT "".add(SB), NOSPLIT|ABIInternal, $0-104
0x0000 00000 (test.go:4) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test.go:4) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (test.go:4) FUNCDATA $5, "".add.arginfo1(SB)
0x0000 00000 (test.go:5) MOVQ BX, "".r10+24(SP)
0x0005 00005 (test.go:5) MOVQ AX, "".r11+32(SP)
0x000a 00010 (test.go:5) MOVQ "".a11+16(SP), AX
0x000f 00015 (test.go:5) MOVQ "".a10+8(SP), BX
0x0014 00020 (test.go:5) MOVQ CX, DX
0x0017 00023 (test.go:5) MOVQ R11, CX
0x001a 00026 (test.go:5) MOVQ DI, R12
0x001d 00029 (test.go:5) MOVQ R10, DI
0x0020 00032 (test.go:5) MOVQ SI, R13
0x0023 00035 (test.go:5) MOVQ R9, SI
0x0026 00038 (test.go:5) MOVQ R13, R9
0x0029 00041 (test.go:5) MOVQ R12, R10
0x002c 00044 (test.go:5) MOVQ DX, R11
0x002f 00047 (test.go:5) RET
0x0000 48 89 5c 24 18 48 89 44 24 20 48 8b 44 24 10 48 H.\$.H.D$ H.D$.H
0x0010 8b 5c 24 08 48 89 ca 4c 89 d9 49 89 fc 4c 89 d7 .\$.H..L..I..L..
0x0020 49 89 f5 4c 89 ce 4d 89 e9 4d 89 e2 49 89 d3 c3 I..L..M..M..I...
*/

可以看到先将超出9个的BX(a2),AX(a1)移动到r10,r11上,然后交换对应的寄存器的值最后返回

参考

asm.pdf (9p.io)

MacOS系统调用

Go 语言设计与实现

GO汇编变量