go初始化源码学习
1、入口
1.1 准备go程序
package main func test() int { return 1 } func main() { go test() }
编译 go build main.go
1.2 使用readelf 查找入口
root@xxx:/data# readelf -h main ELF 头: ... 入口点地址: 0x453860 程序头起点: 64 (bytes into file) ... root@xxx:/data# addr2line -e main -a 0x453860 0x0000000000453860 /usr/lib/go-1.18/src/runtime/rt0_linux_amd64.s:8
2、引导
2.1 查看引导代码
src/runtime/rt0_linux_amd64.s
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 JMP _rt0_amd64(SB)
查找_rt0_amd64
root@xxx:/data# readelf -s main | grep -P "_rt0_amd64\b" 912: 0000000000451ba0 14 FUNC GLOBAL DEFAULT 1 _rt0_amd64 root@xxx:/data# addr2line -e main -a 0451ba0 0x0000000000451ba0 /usr/lib/go-1.18/src/runtime/asm_amd64.s:16
src/runtime/asm_amd64.s
EXT _rt0_amd64(SB),NOSPLIT,$-8 MOVQ 0(SP), DI // argc LEAQ 8(SP), SI // argv JMP runtime·rt0_go(SB)
TEXT runtime·rt0_go(SB),NOSPLIT|NOFRAME|TOPFRAME,$0 //标志 NOSPLIT 表示函数不会引发栈分裂,NOFRAME 表示函数没有栈帧,TOPFRAME 表示这是栈上的顶层帧 // copy arguments forward on an even stack MOVQ DI, AX // argc 将寄存器 DI 中的值(argc,即参数计数)移动到寄存器 AX 中。 MOVQ SI, BX // argv 将寄存器 SI 中的值(argv,即参数数组)移动到寄存器 BX 中。 SUBQ $(5*8), SP // 3args 2auto 将栈指针 SP 减少 40 字节(5*8),为 3 个参数和 2 个自动变量留出空间。 ANDQ $~15, SP MOVQ AX, 24(SP) // 将参数计数 AX 存储到栈上的偏移 24 处 MOVQ BX, 32(SP) // 同理 // create istack out of the given (operating system) stack. // _cgo_init may update stackguard. MOVQ $runtime·g0(SB), DI // 将全局变量 runtime.g0 的地址加载到寄存器 DI 中 LEAQ (-64*1024)(SP), BX // 将栈指针 SP 减少 64KB,结果存储在寄存器 BX 中 MOVQ BX, g_stackguard0(DI) // 将 BX(新的栈指针值)存储到 g0 结构的 stackguard0 字段中 MOVQ BX, g_stackguard1(DI) MOVQ BX, (g_stack+stack_lo)(DI) // 将 BX(新的栈指针值)存储到 g0 结构的 stack.lo 字段中 MOVQ SP, (g_stack+stack_hi)(DI)
运行完上面这几行指令后g0与栈之间的关系如下图所示:
主线程与m0绑定
LEAQ runtime·m0+m_tls(SB), DI //DI = &m0.tls,取m0的tls成员的地址到DI寄存器(LEA:Load Effective Address, m0+m_tls m0偏移m_tls的位置) CALL runtime·settls(SB) //调用settls设置线程本地存储,settls函数的参数在DI寄存器中 // store through it, to make sure it works ,验证settls是否可以正常工作,如果有问题则abort退出程序 get_tls(BX) //上面两个汇编语句,将m0.tls 放到DI寄存器,然后call 过了settls。此处校验tls是否正确,获取settls后的tls 的地址到BX寄存器 MOVQ $0x123, g(BX) //把整型常量0x123拷贝到BX寄存器 MOVQ runtime·m0+m_tls(SB), AX //AX = m0.tls[0] 将m0.tls 的地址放入AX寄存器 CMPQ AX, $0x123 //检查m0.tls[0]的值是否是通过线程本地存储存入的0x123来验证tls功能是否正常 JEQ 2(PC) CALL runtime·abort(SB)
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) // 调用get_tls 将当前m.tls地址放入BX寄存器 BX = &m.tls
LEAQ runtime·g0(SB), CX // 获取g0的地址放入 CX = &g0
MOVQ CX, g(BX) // 将CX寄存器也就是g0的地址放入BX也就是当前线程的m.tls[0] m.tls[0] = BX = CX = &g0
LEAQ runtime·m0(SB), AX // AX = &m0
// save m->g0 = g0
MOVQ CX, m_g0(AX) // m.g0 = CX = g0
// save m0 to g0->m
MOVQ AX, g_m(CX) // 此处的CX还保存着g0,也就是g0.m = AX = &m0
CLD // convention is D is always left cleared
这段代码首先调用settls函数初始化主线程的线程本地存储(TLS),目的是把m0与主线程关联在一起
下面我们详细来详细看一下settls函数是如何实现线程私有全局变量的。
runtime/sys_linx_amd64.s : 606
// set tls base to DI TEXT runtime·settls(SB),NOSPLIT,$32 //...... //DI寄存器中存放的是m.tls[0]的地址,m的tls成员是一个数组,读者如果忘记了可以回头看一下m结构体的定义 //下面这一句代码把DI寄存器中的地址加8,为什么要+8呢,主要跟ELF可执行文件格式中的TLS实现的机制有关 //执行下面这句指令之后DI寄存器中的存放的就是m.tls[1]的地址了 ADDQ$8, DI// ELF wants to use -8(FS) //下面通过arch_prctl系统调用设置FS段基址 MOVQDI, SI //SI存放arch_prctl系统调用的第二个参数 MOVQ$0x1002, DI// ARCH_SET_FS //arch_prctl的第一个参数 MOVQ$SYS_arch_prctl, AX //系统调用编号 SYSCALL CMPQAX, $0xfffffffffffff001 JLS2(PC) MOVL$0xf1, 0xf1 // crash //系统调用失败直接crash RET
从代码可以看到,这里通过arch_prctl系统调用把m0.tls[1]的地址设置成了fs段的段基址。CPU中有个叫fs的段寄存器与之对应,而每个线程都有自己的一组CPU寄存器值,操作系统在把线程调离CPU运行时会帮我们把所有寄存器中的值保存在内存中,调度线程起来运行时又会从内存中把这些寄存器的值恢复到CPU,这样,在此之后,工作线程代码就可以通过fs寄存器来找到m.tls
下面继续分析rt0_go,
runtime/asm_amd64.s : 174
ok:
// set the per-goroutine and per-mach "registers"
get_tls(BX) // 调用get_tls 将当前m.tls地址放入BX寄存器 BX = &m.tls
LEAQ runtime·g0(SB), CX // 获取g0的地址放入 CX = &g0
MOVQ CX, g(BX) // 将CX寄存器也就是g0的地址放入BX也就是当前线程的m.tls[0] m.tls[0] = BX = CX = &g0
LEAQ runtime·m0(SB), AX // AX = &m0
// save m->g0 = g0
MOVQ CX, m_g0(AX) // m.g0 = CX = g0
// save m0 to g0->m
MOVQ AX, g_m(CX) // 此处的CX还保存着g0,也就是g0.m = AX = &m0
CLD // convention is D is always left cleared
上面的代码首先把g0的地址放入主线程的线程本地存储中,然后通过
m0.g0 = &g0
g0.m = &m0
把m0和g0绑定在一起,这样,之后在主线程中通过get_tls可以获取到g0,通过g0的m成员又可以找到m0,于是这里就实现了m0和g0与主线程之间的关联。从这里还可以看到,保存在主线程本地存储中的值是g0的地址,也就是说工作线程的私有全局变量其实是一个指向g的指针而不是指向m的指针,目前这个指针指向g0,表示代码正运行在g0栈。此时,主线程,m0,g0以及g0的栈之间的关系如下图所示:
初始化m0
下面代码开始处理命令行参数,这部分我们不关心,所以跳过。命令行参数处理完成后调用osinit函数获取CPU核的数量并保存在全局变量ncpu之中,调度器初始化时需要知道当前系统有多少个CPU核。
runtime/asm_amd64.s : 189
//准备调用args函数,前面四条指令把参数放在栈上 MOVL16(SP), AX// AX = argc MOVLAX, 0(SP) // argc放在栈顶 MOVQ24(SP), AX// AX = argv MOVQAX, 8(SP) // argv放在SP + 8的位置 CALLruntime·args(SB) //处理操作系统传递过来的参数和env,不需要关心 //对于linx来说,osinit唯一功能就是获取CPU的核数并放在global变量ncpu中, //调度器初始化时需要知道当前系统有多少CPU核 CALLruntime·osinit(SB) //执行的结果是全局变量 ncpu = CPU核数 CALLruntime·schedinit(SB) //调度系统初始化
// create a new goroutine to start program MOVQ $runtime·mainPC(SB), AX // entry 将 runtime·mainPC 的地址加载到寄存器 AX 中,是程序的入口点 PUSHQ AX // 将寄存器 AX 中的值推入栈中,即将入口点的地址压入栈顶 CALL runtime·newproc(SB) // 调用 runtime·newproc 函数,创建一个新的 goroutine 来执行程序的入口点 POPQ AX // start this M CALL runtime·mstart(SB) // 调用 runtime·mstart 函数,启动当前的 M(机器线程) CALL runtime·abort(SB) // mstart should never return 调用 runtime·abort 函数,此处是在程序中断或异常情况下的处理方式 RET
TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME|NOFRAME,$0 CALL runtime·mstart0(SB) // 启动runtime库的 mstart0
RET // not reached
2.2 关键代码
从上述汇编代码可以看到,ok标签下执行到 runtime库的 osinit -> schedinit -> newproc -> mstart -> mstart0 汇编引导部分就结束了,
其中
- osinit:src/runtime/os_linux.go:343 func osinit()
- schedinit:src/runtime/proc.go:750 func schedinit()
- newproc:src/runtime/proc.go:4874 func newproc(fn *funcval)
- mstart :src/runtime/asm_amd64.s:393 TEXT runtime·mstart(SB),NOSPLIT|TOPFRAME|NOFRAME,$0
- mstart0:src/runtime/proc.go:1660 func mstart0()
再到 mstart0 -> mstart1 -> schedule 调度器就正式运行起来了
- mstart1:src/runtime/proc.go:1702 func mstart1()
- schedule:src/runtime/proc.go:3839 func schedule()