Go 汇编

前言:栈指针(SP)和基址指针(BP) #

栈指针(SP)和基址指针(BP)是计算机体系结构和程序执行中的两个关键概念,尤其在函数调用和栈管理中扮演重要角色。

栈指针(Stack Pointer, SP) #

  • 功能:SP 指向当前栈帧的顶部(通常是栈的低地址端)。
  • 作用:
    • 跟踪栈的当前位置
    • 用于分配新的栈空间(如局部变量)
    • 在函数调用时用于传递参数
  • 特点:
    • 随着数据的压栈和出栈而动态变化
    • 在函数调用时会被自动调整

基址指针(Base Pointer, BP) #

  • 也称为帧指针(Frame Pointer)
  • 功能:BP 通常指向当前栈帧的底部(在栈增长方向的反方向)
  • 作用:
    • 提供一个固定的参考点来访问局部变量和函数参数
    • 便于栈回溯和调试
  • 特点:
    • 在函数开始时设置,函数执行期间保持不变
    • 可以用来重建调用栈,对于异常处理和调试很有用

SP 和 BP 的关系 #

  • 在函数调用时,通常会先保存旧的 BP,然后将 SP 的值赋给 BP,建立新的栈帧。
  • BP 提供了一个稳定的引用点,而 SP 则随着局部变量的分配和释放而变化。
高地址
|  ......   |
|  参数 2    |
|  参数 1    |
|  返回地址   |
|  旧的 BP   | <-- BP 指向这里
|  局部变量 1 |
|  局部变量 2 |
|  ......    | <-- SP 指向这里
低地址

1.伪寄存器 #

伪寄存器常用的一般是下面的四个:

FP: Frame pointer: arguments and locals.
PC: Program counter: jumps and branches.
SB: Static base pointer: global symbols.
SP: Stack pointer: top of stack.

下面我们来翻译一下 官网1的对他们的解释,然后做一个总结,方便理解。

1.1.FP #

FP伪寄存器是一个用于引用函数参数的虚拟帧指针。编译器维护一个虚拟帧指针,并将堆栈上的参数引用为该伪寄存器的偏移量。因此0(FP)是函数的第一个参数,8(FP)是第二个参数(在64位机器上),以此类推。但是,当以这种方式引用一个函数参数时,有必要将名称放在开头,如first_arg+0(FP)和second_arg+8(FP)。(偏移量的含义(从帧指针出发的偏移量)与它在SB中的使用不同,在SB中,它是从符号出发的偏移量。) 汇编器强制执行这个约定,拒绝普通的0(FP)和8(FP)。实际的名称在语义上是不相关的,但应该用来记录参数的名称。值得强调的是,FP始终是一个伪寄存器,而不是硬件寄存器,即使在具有硬件帧指针的架构上也是如此。

The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP). (The meaning of the offset—offset from the frame pointer—distinct from its use with SB, where it is an offset from the symbol.) The assembler enforces this convention, rejecting plain 0(FP) and 8(FP). The actual name is semantically irrelevant but should be used to document the argument’s name. It is worth stressing that FP is always a pseudo-register, not a hardware register, even on architectures with a hardware frame pointer.

对于带有Go原型的汇编函数,go vet会检查参数名和偏移量是否匹配。在32位系统上,64位值的低位和高位32位是通过在名称中添加一个_lo或_hi后缀来区分的,如arg_lo+0(FP)或arg_hi+4(FP)。如果一个Go原型没有给它的结果命名,那么预期的汇编名是ret。

For assembly functions with Go prototypes, go vet will check that the argument names and offsets match. On 32-bit systems, the low and high 32 bits of a 64-bit value are distinguished by adding a _lo or _hi suffix to the name, as in arg_lo+0(FP) or arg_hi+4(FP). If a Go prototype does not name its result, the expected assembly name is ret.

1.2.SP #

SP伪寄存器是一个虚拟栈指针,用于引用帧本地变量和为函数调用准备的参数。它指向本地栈帧的顶部,所以引用时应使用负偏移量,范围为[-framesize,0):x-8(SP),y-4(SP),以此类推。

The SP pseudo-register is a virtual stack pointer used to refer to frame-local variables and the arguments being prepared for function calls. It points to the top of the local stack frame, so references should use negative offsets in the range [−framesize, 0): x-8(SP), y-4(SP), and so on.

在具有名为SP的硬件寄存器的架构上,名称前缀可以区分对虚拟栈指针的引用和对架构SP寄存器的引用,即x-8(SP),y-4(SP),以此类推。也就是说,x-8(SP)-8(SP)是不同的内存位置:第一个是指虚拟栈指针伪寄存器,而第二个是指硬件的SP寄存器。

On architectures with a hardware register named SP, the name prefix distinguishes references to the virtual stack pointer from references to the architectural SP register. That is, x-8(SP) and -8(SP) are different memory locations: the first refers to the virtual stack pointer pseudo-register, while the second refers to the hardware’s SP register.

  • 如何理解伪寄存器FP和SP呢?其实伪寄存器FP和SP相当于plan9伪汇编中的一个助记符,他们是根据当前函数栈空间计算出来的一个相对于物理寄存器SP的一个偏移量坐标。
  • 伪SP和FP的相对位置是会变的,所以不应该尝试用伪SP寄存器去找那些本用FP+offset来引用的值,例如函数的入参和返回值。
  • 官方文档中说的伪SP指向栈的top,是有问题的。其指向的局部变量位置实际上是整个栈的栈底(除caller BP 之外),所以说bottom更合适一些。
  • MOVQ 0(SP), AX,这种前面没有flags的,它相当于实际的寄存器的值,不是伪寄存器了。

2.Go类汇编指令 #

2.1.寻址模式 #

  • (DI)(BX2): The location at address DI plus BX2.
  • 64(DI)(BX2): The location at address DI plus BX2 plus 64. These modes accept only 1, 2, 4, and 8 as scale factors.

2.2.结构体+寄存器 #

类似这种:

// (m_morebuf+gobuf_pc)(REGISTER)
  MOVQ	8(SP), AX	# f's caller's PC
  MOVQ	AX, (m_morebuf+gobuf_pc)(BX)
type m struct {
  g0      *g     // goroutine with scheduling stack
  morebuf gobuf  // gobuf arg to morestack   //-----------morebuf-------------//
  divmod  uint32 // div/mod denominator for arm - known to liblink
  //...
}

type gobuf struct {
  // The offsets of sp, pc, and g are known to (hard-coded in) libmach.
  //
  // ctxt is unusual with respect to GC: it may be a
  // heap-allocated funcval, so GC needs to track it, but it
  // needs to be set and cleared from assembly, where it's
  // difficult to have write barriers. However, ctxt is really a
  // saved, live register, and we only ever exchange it between
  // the real register and the gobuf. Hence, we treat it as a
  // root during stack scanning, which means assembly that saves
  // and restores it doesn't need write barriers. It's still
  // typed as a pointer so that any other writes from Go get
  // write barriers.
  sp   uintptr
  pc   uintptr   // <<<--- 
  g    guintptr
  ctxt unsafe.Pointer
  ret  sys.Uintreg
  lr   uintptr
  bp   uintptr // for GOEXPERIMENT=framepointer
}

我们从这个m_morebuf+gobuf_pc就知道指的是这个m结构体中的morebuf结构体字段中的pc值。

3.函数调用栈分析 #

函数调用栈的知识(为方便起见,在函数A1中调用函数A2,我们称A1是caller,A2是callee); 栈指每个进程/线程/goroutine都有自己的调用栈,参数和返回值的传递,函数的局部变量存储通常是通过栈来完成的。和数据结构中的栈一样,内存栈也是后进先出的,地址从高地址开始增长到低地址。栈帧也称为帧,每一帧对应一个尚未返回的函数调用,帧本身以栈的形式存储数据。栈由许多帧组成,它描述函数之间的调用关系.

如下图所示:内存中的栈从高地址空间向低地址空间增长,栈顶小于栈底,分配栈空间对应sp值减小。

20220622230646

其中caller与callee的关系在go1.17版本以下是下图所示,go1.17+以上返回参数已使用 寄存器方式传递

20220622204818

3.1.示例 #

package main

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

//go:noinline
func Sub(a , b int) int {
    d := a - b
    return d
}

生成的汇编结果如下:

这里需要注意一点的是,上面都是在代码空间的,所以左边都是代码空间的地址,当我们分析栈空间的时候,需要查找栈空间地址的内容

下面来一步一步来看下调用的过程:

20220622193033


3.2.伪寄存器的位置 #

  • 下面来做下实验。
    • 确认伪FP, SP相对于真实存在的寄存器的位置点
      • 我们伪FP应该在caller’s next pc + 8byte
      • 伪SP应该在caller’s BP

main.go

package main

func test_FP_SP(a, b int64)(first uintptr, second uintptr)

func main(){
	first, second := test_FP_SP(1, 2)
	first -= second
	_ = first
}

test_FP_SP.s

// func test_FP_SP(a, b int64)(first uintptr, second uintptr)
TEXT ·test_FP_SP(SB),$1040-16    // 这里的16是为了存caller调用call指令的时候,把它下一个pc地圵放入栈中与caller's BP,所以就减16
        LEAQ x-0(SP), DI         // 
        MOVQ DI, first+16(FP)    // 将原伪寄存器SP偏移量存入返回值first

        MOVQ    SP, BP           // 存储物理SP偏移量到BP寄存器
        ADDQ    $512, SP        // 将物理SP偏移增加 0.5K

        LEAQ x-0(SP), SI         // 在上面中只改变了一个值就是SP这个寄存器,然后再次一模一样的把x-0(SP)给到了SI.

        /* 第一个 MOVQ    BP, SP */
        MOVQ    BP, SP           // 恢复物理SP,因为修改物理SP后,伪寄存器FP/SP随之改变,
                                 // 为了正确访问FP,先恢复物理SP
        MOVQ SI, second+24(FP)   // 将偏移后的伪寄存器SP偏移量存入返回值second

        /* 第二个 MOVQ    BP, SP */
        //MOVQ    BP, SP         

        RET                      // 从输出的second-first来看,正好相差0.5K

编译一下源代码:


# linux
go build -gcflags "-N -l" -o test .
# or 
go build -gcflags "all=-N -l" -o test .

# xos:
go build -gcflags "all=-N -l" -ldflags=-compressdwarf=false   -o test .

# result
[root@iZf8z14idfp0rwhiicngwqZ FP_SP]# tree .
.
├── main.go
├── test
└── test_FP_SP.s

我们用到的gdb命令:

gdb ./test
list
b 6
display /25i $pc-8
si
si
si

从上面的图中可以看出,go assemble中的x-0(SP)first+16(FP),其实都是与SP寄存器关联的,其中SP,伪FP,与伪SP的位置,在下图中已经标识出来了;

                 +-------------------+                                  
                 |       second      |                                  
                 |--------------------                                  
                 |       first       |                                  
                 |-------------------|                                  
                 |       b           |                                  
                 |-------------------|                                  
                 |       a           |                                  
            伪FP +-------------------+                       
                 |   caller's pc     |                                  
                 +-------------------+                                  
                 |   caller's BP     |                                  
伪SP|callee's BP +-------------------+ 
                 |      ...          |                                  
真实寄存器SP等于   +-------------------+
     caller's SP - caller's next CP(8) - callee's stack size
     上图已标识

当我们把/* 第一个 MOVQ BP, SP */下面的注释掉,执行的话会panic,是因为PC寄存器读取错误,而不是注释掉的下一行导致的。

可以实验下:我们把/* 第二个 MOVQ BP, SP */取消注释,它就正常执行,只是返回值不对而已。

3.3.添加汇编 #

go编译器在函数头添加额外汇编,判断当前Goroutine栈是否将越界,如将越界,需加空间

  • go version go1.13.8 linux/amd64
   0x0000000000459240 <main.test_FP_SP+0>:	mov    %fs:0xfffffffffffffff8,%rcx
   0x0000000000459249 <main.test_FP_SP+9>:	lea    -0x398(%rsp),%rax
   0x0000000000459251 <main.test_FP_SP+17>:	cmp    0x10(%rcx),%rax
   0x0000000000459255 <main.test_FP_SP+21>:	jbe    0x4592ab <main.test_FP_SP+107>
   // ...
   0x00000000004592ab <main.test_FP_SP+107>:	callq  0x450c70 <runtime.morestack_noctxt>
   0x00000000004592b0 <main.test_FP_SP+112>:	jmp    0x459240 <main.test_FP_SP>
  • go version go1.15
=> 0x459240 <main.test_FP_SP>:	mov    %fs:0xfffffffffffffff8,%rcx
   0x459249 <main.test_FP_SP+9>:	mov    0x10(%rcx),%rsi
   0x45924d <main.test_FP_SP+13>:	cmp    $0xfffffffffffffade,%rsi
   0x459254 <main.test_FP_SP+20>:	je     0x4592bd <main.test_FP_SP+125>
   //...
   0x4592bd <main.test_FP_SP+125>:	callq  0x450c70 <runtime.morestack_noctxt>
   0x4592c2 <main.test_FP_SP+130>:	jmpq   0x459240 <main.test_FP_SP>
   0x4592c7:	add    %al,(%rax)
   0x4592c9:	add    %al,(%rax)

可以观察到是在头和尾部加上了跳转代码

  • 0x10(%rcx)
  • $0xfffffffffffffade
type stack struct {
	lo uintptr
	hi uintptr
}

type g struct {
	// Stack parameters.
	// stack describes the actual stack memory: [stack.lo, stack.hi).
	// stackguard0 is the stack pointer compared in the Go stack growth prologue.
	// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
	// stackguard1 is the stack pointer compared in the C stack growth prologue.
	// It is stack.lo+StackGuard on g0 and gsignal stacks.
	// It is ~0 on other goroutine stacks, to trigger a call to morestackc (and crash).
	stack       stack   // offset known to runtime/cgo
	stackguard0 uintptr // offset known to liblink
	stackguard1 uintptr // offset known to liblink
	//...

20220626164439

其中rcx是g的地址,所以0x10(%rcx)是就是g.stackguard0的值,当被设置需要抢占的时候,其值是: StackPreempt = -1314 // 也就是 0xfff...fade

当设置需要抢占的时候,程序会会跳转到函数尾部的runtime.morestack_noctxt函数

4.Go类汇编函数 #

dropg函数 #

dropg()函数:解除g和m之间连接关系,其实就是设置g->m = nil, m->currg = nil.

func dropg() {
  _g_ := getg()

  setMNoWB(&_g_.m.curg.m, nil)
  setGNoWB(&_g_.m.curg, nil)
}

几个退出收尾函数 #

  • 非main goroutine运行结束: goexit0

  • 主动调度:gosched_m

    • 剥夺调度(运行太久):gopreempt_m
      • 其中 gopreempt_m与gosched_m内部都是调用的goschedImpl函数,所以功能都是一样。
  • 被动调度: park_m

TLS相关函数 #

TLS概念 #

定义:TLS 主要用于存储每个线程需要独立维护的数据,如线程特定的变量、错误代码、缓存等。这种机制让多线程程序可以在不使用锁的情况下维护线程安全。

特点:普通的全局变量,一个线程对其进行了修改,所有线程都可以看到这个修改;线程私有全局变量不同,每个线程都有自己的一份副本,某个线程对其所做的修改不会影响到其它线程的副本。

访问:在x86-64架构中,线程局部存储(TLS)通常通过段寄存器%fs或%gs来实现:

  • 段寄存器 %fs 和 %gs,可以用来存储 TLS 的基地址,每个线程在启动时,这些寄存器会被设置为指向该线程的TLS区域。
  • 访问 TLS 数据。通过段寄存器,程序可以使用一个固定的偏移量来访问 TLS 中的变量。其访问方式与访问全局变量不同,因为它始终是基于当前线程的基地址
    • 例如,要访问 TLS 中某个变量的值,可以使用如下汇编指令:movq %fs:offset, %rax // 从 %fs 段中的偏移量 offset 处加载数据到 rax 寄存器
    • 如果有一个TLS变量x,其编译时被分配到偏移量0x10,则可以通过以下方式在汇编中访问x:movq %fs:0x10, %rax // 将偏移量 0x10 处的数据加载到 rax

重要性:

  • 性能:使用段寄存器访问 TLS 是一种快速高效的方法,因为它避免了每次访问都需要查找基地址的开销。
  • 隔离:每个线程有自己的段寄存器设置,确保了线程之间的数据隔离,减少了数据竞争的可能性。
  • 简化并发编程:TLS 提供了一种简化的方式来管理线程特定的数据,使得并发编程更加直观。

go汇编的TLS #

TLS代表的是伪寄存器,存储线程本地存储的基地址(基地址加上偏移地址能得到完整的一个地址), 语义上

MOVQ TLS, reg
off(reg)(TLS*1) // (TLS*1)说明是从线程本地存储的基地址上进行索引

等同于

off(TLS)

Go 汇编中的线程局部存储(TLS):TLS通过特定的伪寄存器和指令格式来进行操作。以下是这些操作的关键点:

  • 加载 TLS 基地址:TLS 伪寄存器用于访问线程局部存储的基地址。要使用此地址,首先需要将其加载到一个普通寄存器中。例如:MOVQ TLS, AX
  • 通过偏移量访问:可以使用off(reg)(TLS*1)的格式来表示从 TLS 基地址的偏移量。虽然看起来像off(reg),但(TLS*1)注释表明这是一个基于 TLS 的索引。这种格式会触发重定位,使链接器在需要时调整偏移量。例如:
MOVQ TLS, AX
MOVQ 0(AX)(TLS*1), CX  // 将 g 加载到 CX
  • 直接 TLS 内存引用:在支持直接 TLS 内存访问的系统上,可以使用更简单的指令:MOVQ 0(TLS), CX // 直接将 g 加载到 CX
  • 在共享库中的使用
    • 指令转换:当构建为共享库时,类似MOV off(CX)(TLS*1), AX的指令会被转换为:mov %fs:off(%rcx), %rax
    • 这种转换假设正确的 TLS 偏移量已经被加载到 %rcx 中。目前,这种假设是合理的,因为只有一个 TLS 变量 g。在不构建为共享库的情况下,这种转换是不需要的。

runtime·settls&get_tls(r)&g(r) #

TEXT runtime·rt0_go(SB),NOSPLIT,$0
    //...
    LEAQ	runtime·m0+m_tls(SB), DI  // DI = &m0.tls,取m0的tls成员的地址到DI寄存器
    CALL	runtime·settls(SB)        // 调用settls设置线程本地存储,其中settls的入参为DI寄存器

    get_tls(BX)                       // 把TLS地址放入BX寄存器,测试刚刚那个绑定是否成功。
    MOVQ	$0x123, g(BX)             // 0x123设置到了m0.tls[0]
    MOVQ	runtime·m0+m_tls(SB), AX
    CMPQ	AX, $0x123                // 比较确认前面设置是否成功
    JEQ 2(PC)
    CALL	runtime·abort(SB)

  TEXT runtime·settls(SB),NOSPLIT,$32
      ADDQ $8, DI      // ELF wants to use -8(FS) // https://akkadia.org/drepper/tls.pdf
      MOVQ DI, SI      // SI是arch_prctl的第二个参数addr
      MOVQ $0x1002, DI // DI是arch_prctl的第一个参数code ($0x1002代表ARCH_SET_FS)
      MOVQ $SYS_arch_prctl, AX
      SYSCALL
      CMPQ AX, $0xfffffffffffff001
      JLS 2(PC)
      MOVL $0xf1, 0xf1
      RET

  #ifdef GOARCH_amd64
  #define	get_tls(r)	MOVQ TLS, r
  #define	g(r)	0(r)(TLS*1)
  #endif
  • settls函数(设置线程本地存储的段基寄存器)
    • $SYS_arch_prctl为linux系统API arch_prctl,有两个入参,分别为code,addr
      • int arch_prctl(int code, unsigned long addr);
        • code指定操作类型。 ARCH_SET_FS 1代表将FS寄存器的64位基数设置为addr指定的参数操作,它定义的操作类型代号为0x1002
    • DI 将作为arch_prctl系统调用的第一个参数(code)
    • SI 将作为arch_prctl系统调用的第二个参数(addr)

上面的步骤是把段基寄存器 设置为 m0.tls[0]所在的地址.

type m struct {
  //...
  tls [6]uintptr // TLS(thread-local storage)
  //...
}
  • get_tls(r)与g(r)是宏定义
    • #define get_tls(r) MOVQ TLS, r
    • #define g(r) 0(r)(TLS*1)

故get_tls(BX)与g(BX)翻译过来就是:

  • get_tls(BX) –> MOVQ TLS, BX
  • g(BX) —> 0(BX)(TLS*1) —> 0(TLS)

所以上面就是把0x123设置到了m0.tls[0], 然后通过CMPQ AX, $0x123确认设置是否成功。

getg函数 #

getg()单独返回当前的g, 如果是想得到当前用户g,最好使用getg().m.curg,因在系统或信号栈上执行时,这将分别返回当前M的 “g0 “或 “gsignal”。这通常不是你想要的。 想确定你是在用户栈还是系统栈上运行,可以使用getg()是否等于getg().m.curg

https://github.com/golang/go/blob/master/src/runtime/HACKING.md#getg-and-getgmcurg

func getg() *g
get_tls(CX)     // CX=TLS;TLS为当前线程对应的M结构体对象tls[0]的地址
MOVQ g(CX), BX; // BX=0(TLS);tls[0]存储的就是某个G结构体对象(g0还是g,需要根据设置时的设置)。
// /usr/local/go/src/cmd/compile/internal/amd64/ssa.go:175
func getgFromTLS(s *ssagen.State, r int16) {
	// See the comments in cmd/internal/obj/x86/obj6.go
	// near CanUse1InsnTLS for a detailed explanation of these instructions.
	if x86.CanUse1InsnTLS(base.Ctxt) {
		// MOVQ (TLS), r
		// MOVQ (r)(TLS*1), r
    //...
	} else {
		// MOVQ TLS, r
		// MOVQ (r)(TLS*1), r
    //...
	}
}

systemstack函数 #

func systemstack(f func(){/*...*/}){
  //...
}

systemstack:它实现了从普通goroutine栈切换到系统栈(g0 栈)并执行指定函数,然后再切换回原 goroutine 栈的过程。

  • 1.保存当前状态:
    • 保存当前 goroutine 的状态到 g.sched 结构中。
  • 2.切换到 g0(系统栈):
    • 更新线程本地存储(TLS)为g0地址。
    • 从 g0 的 sched 结构中加载栈指针(SP)和基址指针(BP),并更新当前的 SP 和 BP 寄存器。
  • 3.调用目标函数。
  • 4.切换回原 goroutine:
    • 从g0.m字段获取M结构对象,继而获取原goroutine(m.curg)的地址。
    • 更新线程本地存储(TLS)为原goroutine地址。
    • 从原goroutine的sched结构中恢复栈指针(SP)和基址指针(BP)。

附录 #