深入Go语言底层:一文学会Plan9汇编

什么是Go语言汇编

伪汇编

传统的汇编语言是和硬件架构是一一对应的,一种硬件架构对应一种汇编语言。而对Go 语言汇编而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。

语法不同

Go 语言汇编使用的是 GAS 汇编语法(Gnu ASsembler),与传统的汇编语言存在一些差异。

存在伪寄存器

Go 语言汇编提供了SB、FP、 PC、SP 4 个伪寄存器,其他汇编语言没有伪寄存器的概念, 详细介绍:会在寄存器章节会展开。

寄存器 官方说明
SB(Static base pointer) global symbols
FP(Frame pointer) arguments and locals
PC(Program counter) jumps and branches
SP(Stack pointer) top of stack

为什么学习Go语言汇编

读懂Go汇编源码

Go的源码中有很多.s为后缀的文件,这就是GO的汇编源码,如果不了解Go语言汇编很难读懂这些汇编源码,下面是atomic(原子操作)的目录截图:

实现高级编程功能

为了系统安全一般操作系统会限制高级语言的权限,比如说禁止直接操作内存(堆&栈)、操作寄存器、执行CPU指令等,而go支持的runtime调度,调度过程中涉及了栈和寄存器等操作,这里就必须借助汇编来实现。

Go语言的runtime机制里有很多使用了汇编,比如:GMP、defer、原子操作等等。

代码性能优化

大家都知道汇编语言是低级语言更靠近硬件,可以直接操作寄存器和内存,性能更高。以[]byte转string举例:

正常写法

Go 复制代码
func bytesToString(bs []byte) string {
    return string(bs)
}
              

unsafe写法

Go 复制代码
func bytesToString1(bs []byte) string {
    return *(*string)(unsafe.Pointer(&bs))
}
              

汇编写法:

Go 复制代码
// main.go
func bytesToString2(bs []byte) string
              
// main.s 汇编文件末尾必须有个空行
#include "textflag.h"

TEXT ·bytesToString2(SB), NOSPLIT, $0-40
    MOVQ    bs+0(FP), AX
    MOVQ    bs+8(FP), BX
    MOVQ    AX, r0+24(FP)
    MOVQ    BX, r0+32(FP)

    RET
    

通过基准测试可以看出,使用汇编写法的性能是最高的:

理解底层实现机制

同样以[]byte to string 为例:

Go 复制代码
func bytesToString(bs []byte) string {
    return string(bs)
}
            

其中string(bs) 不是函数调用,在go源码里也没有相关的声明,单纯看代码很难弄明白其中原理,如果我们把上面代码编译成汇编之后我们就可以发现其实底层是调用了runtime.slicebytetostring()函数。

Go 复制代码
0x0000 00000 (main.go:17)       TEXT    main.bytesToString(SB), ABIInternal, $48-24
0x0006 00006 (main.go:17)       SUBQ    $48, SP
0x000a 00010 (main.go:17)       MOVQ    BP, 40(SP)
0x000f 00015 (main.go:17)       LEAQ    40(SP), BP
0x0014 00020 (main.go:17)       MOVQ    AX, main.bs+56(SP)
0x0019 00025 (main.go:17)       MOVQ    BX, main.bs+64(SP)
0x001e 00030 (main.go:17)       MOVQ    CX, main.bs+72(SP)
0x0023 00035 (main.go:17)       MOVUPS  X15, main.~r0+24(SP)
0x0029 00041 (main.go:18)       MOVQ    main.bs+56(SP), BX
0x002e 00046 (main.go:18)       MOVQ    main.bs+64(SP), CX
0x0033 00051 (main.go:18)       XORL    AX, AX
0x0035 00053 (main.go:18)       CALL    runtime.slicebytetostring(SB)
0x003a 00058 (main.go:18)       MOVQ    AX, main.~r0+24(SP)
0x003f 00063 (main.go:18)       MOVQ    BX, main.~r0+32(SP)
0x0044 00068 (main.go:18)       MOVQ    40(SP), BP
0x0049 00073 (main.go:18)       ADDQ    $48, SP
0x004d 00077 (main.go:18)       RET

Go汇编的基础知识(P0

指令格式

Go汇编指令格式是:操作码 + 源操作数 + 目标操作数的形式。

Go 复制代码
操作码   源操作数   目标操作数
MOVQ    $10,       AX      // 含义是:将 10 赋值给 AX 寄存器   

Go 汇编会在指令后加上 B , W , L 或 Q , 分别表示操作数的大小为1个,2个,4个或8个字节。

Go 复制代码
MOVB $1, DI      // 1 byte
MOVW $0x10, BX   // 2 bytes
MOVL $1, DX      // 4 bytes
MOVQ $-10, AX    // 8 bytes           

常数在 Go汇编中用 <math xmlns="http://www.w3.org/1998/Math/MathML"> n u m 表示,可以为负数,默认情况下为十进制。用 num 表示,可以为负数,默认情况下为十进制。用 </math>num表示,可以为负数,默认情况下为十进制。用0x123 的形式来表示十六进制数。

常用操作指令

Go语言汇编支持几千个指令,作为入门掌握下面常见的指令就可以。

指令 指令种类 用途 示例
MOVQ 传送 数据传送 MOVQ $48, AX // 把 48 传送到 AX
LEAQ 传送 地址传送 LEAQ AX, BX // 把 AX 有效地址传送到 BX
PUSHQ 传送 栈压入 PUSHQ AX // 将 AX 内容送入栈顶位置
POPQ 传送 栈弹出 POPQ AX // 弹出栈顶数据后修改栈顶指针
ADDQ 运算 相加并赋值 ADDQ BX, AX // 等价于 AX+=BX
SUBQ 运算 相减并赋值 SUBQ BX, AX // 等价于 AX-=BX
CMPQ 运算 比较大小 CMPQ SI CX // 比较 SI 和 CX 的大小
CALL 转移 调用函数 CALL runtime.printnl(SB) // 发起调用
JMP 转移 无条件转移指令 JMP 0x0185 //无条件转至 0x0185 地址处
JLS 转移 条件转移指令 JLS 0x0185 //左边小于右边,则跳到 0x0185
JZ 转移 条件跳转指令

寄存器

伪寄存器

go 汇编中有SB、FP、 PC、SP 4 个伪寄存器:

寄存器 说明
SB(Static base pointer) global symbols
FP(Frame pointer) arguments and locals
PC(Program counter) jumps and branches
SP(Stack pointer) top of stack
  • SB寄存器: 全局静态基指针,一般用在声明函数、全局变量中。

  • FP寄存器: 在手写汇编代码时通常用来操作参数和返回值 , (FP)指向的是 caller 调用 callee 时传递的第一个参数的位置因此可以使用如 symbol+offset(FP)的方式来访问 callee 函数的参数与返回值。

Go 复制代码
// func add(a, b int) int

TEXT ·add(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX     // 参数 a
    MOVQ b+8(FP), BX     // 参数 b
    ADDQ BX, AX          // AX += BX
    MOVQ AX, ret+16(FP)  // 返回值赋值
    RET
         
  • PC寄存器: 指令寄存器,和 x86 平台的 ip 寄存器相对应,保存CPU即将运行的下一条指令。

  • SP****寄存器: 该寄存器也是最具有迷惑性的寄存器,因为会有伪 SP 寄存器和硬件 SP 寄存器之分。

通用寄存器

在 Go 汇编里还可以直接使用的X86_64架构的通用寄存器,常用的主要有: rax, rbx, rcx, rdx, rdi, rsi, rbp,rsp, r8~r15 这些寄存器,

下表是通用寄存器的名字在 X86_64 和 Go汇编(plan9)中的对应关系:

变量定义与声明

Go汇编中使用 DATA 和 GLOBL 来定义一个变量(常量)。

  • DATA 用来指定对应内存中的值;

  • GLOBL 用来声明一个变量对应的符号,以及变量对应的内存大小。

Go 复制代码
// DATA 汇编指令指定对应内存中的值; width 必须是 1、2、4、8 几个宽度之一
DATA symbol+offset(SB)/width, value // symbol+offset 偏移量,width 宽度, value 初始值

// GLOBL 指令声明一个变量对应的符号,以及变量对应的内存大小
GLOBL symbol(SB), flag, width  // 名为 symbol, 内存宽度为 width, flag可省略           

下面是声明变量的例子:

函数声明

函数声明的公式如下:

Go 复制代码
告诉汇编器该数据放到TEXT区
  ^                静态基地址指针(告诉汇编器这是基于静态地址的数据)
  |                    ^    
  |                    |   标签   函数入参+返回值占用空间大小
  |                    |    ^      ^
  |                    |    |      |
TEXT pkgname·funcname(SB),flag,$16-24
       ^       ^               ^
       |       |               |
 函数所属包名  函数名        函数栈帧大小(本地变量占用空间大小)  
格式 描述
TEXT 定义函数标识
pkgname 函数包名,可以不写,也可以""替代
· 在程序链接后会转换为 "." ,mac 电脑按 option + shift + 9 能打出这个点。
(SB) 让 SB 认识这个函数,即生成全局符号。
flag 表示函数某些特殊功能,多个标签可以通过|连接。定义和全局变量的 tag 一样
$16-24 16表示函数栈帧大小,24表示入参和返回大小

Go语言调用规约

所谓的调用规约就是约定了如何调用函数,包括函数参数的传递、返回值的处理以及调用栈的管理等。

基本概念

  • :进程、线程、goroutine 都有自己的调用栈。

  • 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域

  • 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者

  • 被调者:callee,比如:A 函数调用了 B 函数,那么 B 就是被调者

caller-save模式

Go语言的函数调用使用的是caller-save模式:在caller调用callee的时候,caller需要将callee的参数、返回值在栈上准备好,然后才能执行CALL callee 指令,参数和返回值具体在栈上的具体分布请参考Go内存模型章节。

CALL 与 RET指令

CALL指令是用来调用函数的,** RET指令**是用来返回一个函数的相当 return 语句。在调用函数时除了要准备参数和返回值还需要保存CALL指令的下一条指令的地址(return addr),以便函数返回时能继续运行caller里的代码,这个保存动作就是CALL指令隐式去做的,它会把 PC 寄存器(储存的 CPU 下一条要运行的指令)的值(return addr)保存到栈里,RET指令正好是个相反的操作,它会把 return addr 从栈里取出来赋值 PC 寄存器。

下面以 main() -> add() 举个例子:

Go 复制代码
// main 函数片段
0x0000 00000    TEXT    "".main(SB), ABIInternal, $32-0
......
0x002e 00046    CALL    "".add(SB)
0x0033 00051    MOVQ    24(SP), BP
......
Go 复制代码
// add 函数片段
0x0000 00000    TEXT    "".add(SB), NOSPLIT, $16-24
0x0000 00001    SUBQ    $16, SP  ;;生成add栈空间
...
0x003b 00059    RET

CALL相当于有三步操作

  1. SUBQ $8, SP
  2. MOVQ PC, (SP)
  3. MOVQ 函数的第一条指令, PC

RET相当于两步操作

  1. MOVQ (SP), PC

  2. ADDQ $8, SP

Go内存模型

通过内存模型能更好的理解caller-save模式,以及栈桢与各个寄存器的关系,在学习内存模型前我们先看一些问题。

带着问题学习

  • 栈的增长方向?

  • BP寄存器、硬件SP寄存器、伪SP寄存器、伪FP寄存器的指向?

  • callee参数和返回值的内存布局?

  • callee会如何访问参数和返回值?

  • go 支持多返回值的原理?

  • 栈帧都包含什么?

有一点需要注意的是,return addr 也是在 caller 的栈上的,不过往栈上插入 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的。在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。

Go语言汇编分析

如何生成Go语言汇编

方式一(推荐):

Go 复制代码
go build -gcflags "-N -l -S" main.go 2 > main.s           

方式二(推荐):

Go 复制代码
go tool compile -N -l -S main.go > main.s             

方式三:

Go 复制代码
go tool compile -N -l main.go
go tool objdump simpleMap > simpleMap.s

生成示例代码

Go 复制代码
package main

func add(a, b int) int {
        sum := 0 // 不设置该局部变量sum,add栈空间大小会是0
        sum = a+b
        return sum
}

func main(){
       add(1, 2)
}
              

编译 go 源代码,输出汇编,下面的代码是使用go1.14生成的,如果go是1.17以后版本,生成的汇编代码会有些不同。

Go 复制代码
go tool compile -N -l -S main.go          

截取主要汇编如下:

Go 复制代码
// add 函数
0x0000 00000 (main.go:3)        TEXT    "".add(SB), NOSPLIT, $16-24
0x0000 00000 (main.go:3)        SUBQ    $16, SP  ;;生成add栈空间
0x0004 00004 (main.go:3)        MOVQ    BP, 8(SP)
0x0009 00009 (main.go:3)        LEAQ    8(SP), BP
......
0x000e 00014 (main.go:3)        MOVQ    $0, "".~r2+40(SP) ;;初始化返回值
0x0017 00023 (main.go:4)        MOVQ    $0, "".sum(SP) ;;局部变量sum赋为0
0x001f 00031 (main.go:5)        MOVQ    "".a+24(SP), AX  ;;取参数a
0x0024 00036 (main.go:5)        ADDQ    "".b+32(SP), AX ;;等价于AX=a+b
0x0029 00041 (main.go:5)        MOVQ    AX, "".sum(SP)  ;;赋值局部变量sum
0x002d 00045 (main.go:6)        MOVQ    AX, "".~r2+40(SP) ;;设置返回值
0x0032 00050 (main.go:6)        MOVQ    8(SP), BP
0x0037 00055 (main.go:6)        ADDQ    $16, SP ;;清除add栈空间
0x003b 00059 (main.go:6)        RET
......


// main 函数
0x0000 00000 (main.go:7)    TEXT    "".main(SB), ABIInternal, $32-0
......
0x000f 00015 (main.go:7)    SUBQ    $32, SP
0x0013 00019 (main.go:7)    MOVQ    BP, 24(SP)
0x0018 00024 (main.go:7)    LEAQ    24(SP), BP
......
0x001d 00029 (main.go:8)    MOVQ    $1, (SP)
0x0025 00037 (main.go:8)    MOVQ    $2, 8(SP)
0x002e 00046 (main.go:8)    CALL    "".add(SB)
0x0033 00051 (main.go:9)    MOVQ    24(SP), BP
0x0038 00056 (main.go:9)    ADDQ    $32, SP
0x003c 00060 (main.go:9)    RET
......
              

Go 汇编代码解析

下面针对上一章节输出汇编代码进行分析。

add 函数汇编解析

指令 说明
TEXT "".add TEXT 指令声明了 "".add 是一个函数
NOSPLIT 向编译器表明不需要栈扩容检查。
$16-24 16: 说明add函数需要16字节的栈空间(caller BP 8字节 + 局部变量8字节) 24: 说明add函数的参数+返回值总大小为24字节(24 字节=入参 a、b 大小8字节*2+返回值8字节)
SUBQ $16, SP SP 为栈顶指针,该语句等价于 SP-=16(由于栈空间是向下增长的,所以开辟栈空间时为减操作),表示生成 16 字节大小的栈空间。
MOVQ BP, 8(SP) BP是栈帧的栈底指针,当进行函数调用时需要把caller的BP中的值保存在callee的栈帧中。
LEAQ 8(SP), BP 把当前(callee)栈帧的栈底地址赋值给BP寄存器。
MOVQ $0, "".~r2+40(SP) 此时的 SP 为 add 函数栈的栈顶指针,40(SP)的位置则是 add 返回值的位置,该位置位于 main 函数栈空间内。该语句设置返回值类型的 0 值,即初始化返回值,防止得到脏数据(返回值类型为 int,int 的 0 值为 0)
MOVQ "".a+24(SP), AX 从 main 函数栈空间获取入参 a 的值,存到寄存器 AX
ADDQ "".b+32(SP), AX 从 main 函数栈空间获取入参 b 的值,与寄存器 AX 中存储的 a 值相加,结果存到 AX。相当于 AX=a+b
MOVQ AX, "".~r2+40(SP) 把 a+b 的结果放到 main 函数栈中, add(a+b)返回值所在的位置
MOVQ 8(SP), BP 恢复BP指向caller的栈底
ADDQ $16, SP 归还 add 函数占用的栈空间
RET 返回add函数,并把return addr出栈并赋值给 PC 寄存器。

main 函数汇编解析

指令 说明
MOVQ $1, (SP) 参数1赋值
MOVQ $2, 8(SP) 参数2赋值
CALL "".add(SB) 调用add函数,将PC寄存器的值压入到栈中,并将函数的第一条指令赋值给PC寄存器。

函数栈桢结构模型

手写Go语言汇编

前置知识

  • 汇编文件是以 .s后缀结尾,汇编文件末尾需要有个空行

  • 将汇编文件和go文件放到一个目录下就能一起编译

  • 手写汇编代码时注意点:

两数求和

Go 复制代码
package main

import "fmt"

func add(a, b int) int // 汇编函数声明

func main() {
    fmt.Println(add(10, 11))
}
              
Go 复制代码
#include "textflag.h"

// func add(a, b int) int
TEXT ·add(SB), NOSPLIT, $0-24
    MOVQ a+0(FP), AX // 参数 a
    MOVQ b+8(FP), BX // 参数 b
    ADDQ BX, AX    // AX += BX
    MOVQ AX, ret+16(FP) // 返回
    RET
              

你能解释这里为什么是 $0-24 吗?

slice求和

Go 复制代码
package main

func sum([]int64) int64

func main() {
    println(sum([]int64{1, 2, 3, 4, 5}))
}
Go 复制代码
#include "textflag.h"

// func sum(sl []int64) int64
TEXT ·sum(SB), NOSPLIT, $0-32
    MOVQ $0, SI
    MOVQ sl+0(FP), BX // &sl[0], addr of the first elem
    MOVQ sl+8(FP), CX // len(sl)
    INCQ CX           // CX++, 因为要循环 len 次

start:
    DECQ CX       // CX--
    JZ   done     // 判断为零则跳转
    ADDQ (BX), SI // SI += *BX
    ADDQ $8, BX   // 指针移动
    JMP  start

done:
    MOVQ SI, ret+24(FP)
    RET
              

问题,返回值的 ret+24(FP),这里的 24 是怎么算出来的呢?

bytesToString

Go 复制代码
// main.go
package main

import (
    "fmt"
)

func main() {
    bs := []byte{'a', 'b', 'c'}
    fmt.Println(bytesToString(bs))
}

func bytesToString(bs []byte) string
              
Go 复制代码
#include "textflag.h"

TEXT ·bytesToString(SB), NOSPLIT, $0-40
    MOVQ    bs+0(FP), AX
    MOVQ    bs+8(FP), BX
    MOVQ    AX, r0+24(FP)
    MOVQ    BX, r0+32(FP)

    RET 
              

Go语言汇编的应用

定位runtime源码

以make函数为例,初始化不同类型的数据会调用不同的runtime函数

Go 复制代码
func makeRuntimeCode() {
    s := make([]int, 0, 3)
    m := make(map[string]string, 3)
    c := make(chan int, 1)
    fmt.Println(s, m, c)
}
              

编译成汇编:

Go 复制代码
TEXT    "".makeRuntimeCode(SB), ABIInternal, $200-0
       // ...
       CALL    runtime.makeslice(SB)
       // ...
       CALL    runtime.makemap_small(SB)
       // ...
       CALL    runtime.makechan(SB)
       // ...
       RET
       
              

通过查看汇编代码,可以确定初始化切片时的make对应的runtime.makeslice,初始化map时的对应runtime.makemap_small,初始化chan时的对应runtime.makechan。

调用硬件指令

下面是Cas的源码,阅读源码可知原子操作是通过调用硬件LOCK指令实现的:

Go 复制代码
// func Cas(ptr *int32, old, new int32) bool
// runtime/internal/atomic/asm_amd64.s
TEXT runtime∕internal∕atomic·Cas(SB),NOSPLIT,$0-17
    MOVQ    ptr+0(FP), BX
    MOVL    old+8(FP), AX
    MOVL    new+12(FP), CX
    LOCK
    CMPXCHGL    CX, 0(BX)
    SETEQ   ret+16(FP)
    RET
              

LOCK:是一个指令前缀,其后必须跟一条"读-改-写"性质的指令,它们可以是ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD, XCHG。该指令是一种锁定协议,用于封锁总线,禁止其他 CPU 对内存的操作来保证原子性。

其他

  1. 调用其他package的私有函数:可以参考:sitano.github.io/2016/04/28/...

  2. 优化获取行号性能,参考:golang文件行号探索

  3. 等等

参考

github.com/cch123/asms...

bytetech.info/articles/72...

github.com/cch123/gola...

segmentfault.com/a/119000003...

github.com/lxt1045/blo...

相关推荐
BlockChain8889 小时前
Solidity 实战【二】:手写一个「链上资金托管合约」
go·区块链
BlockChain88817 小时前
Solidity 实战【三】:重入攻击与防御(从 0 到 1 看懂 DAO 事件)
go·区块链
剩下了什么1 天前
Gf命令行工具下载
go
地球没有花1 天前
tw引发的对redis的深入了解
数据库·redis·缓存·go
BlockChain8881 天前
字符串最后一个单词的长度
算法·go
龙井茶Sky1 天前
通过higress AI统计插件学gjson表达式的分享
go·gjson·higress插件
宇宙帅猴3 天前
【Ubuntu踩坑及解决方案(一)】
linux·运维·ubuntu·go
SomeBottle3 天前
【小记】解决校园网中不同单播互通子网间 LocalSend 的发现问题
计算机网络·go·网络编程·学习笔记·计算机基础
且去填词4 天前
深入理解 GMP 模型:Go 高并发的基石
开发语言·后端·学习·算法·面试·golang·go
大厂技术总监下海4 天前
向量数据库“卷”向何方?从Milvus看“全功能、企业级”的未来
数据库·分布式·go·milvus·增强现实