聊聊Go程序是如何运行的

写在文章开头

Go语言是一门编译语言,其工作过程即直接通过编译生成各大操作系统的机器码即可直接执行,所以这篇文章笔者就从底层汇编码的角度聊一聊Go语言是如何运行的。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili

因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。

go语言代码执行详解

最终执行代码

我们首先在goland上创建一个名为main.go的文件,代码格式指明未main包下的main方法,当go语言完成编译并启动后,就会执行到这段代码:

go 复制代码
package main

import (
 "fmt"
)

func main() {
 fmt.Println("hello Go")
}

入口跳转

go语言是跨平台的语言,所以底层对各大平台的启动都做了特定的封装,以笔者的windows系统为例,其执行入口为rt0_windows_amd64.s,同理Linux系统则是rt0_linux_amd64.s,可以看到在任何平台它们都会通过汇编指令JMP跳转到_rt0_amd64方法:

scss 复制代码
//windows的入口代码_rt0_amd64_windows
TEXT _rt0_amd64_windows(SB),NOSPLIT,$-8
 JMP _rt0_amd64(SB)
 
//Linux入口代码_rt0_amd64_linux
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
 JMP _rt0_amd64(SB)

拷贝参数,启动协程

通过全局搜索看到这个方法的实现,这段代码会通过MOVQ将参数的数量argc拷贝到目标寄存器DI上,然后再通过LEAQ计算所有参数argv的偏移量地址并存储到SI上,然后再次跳转到runtime·rt0_go方法准备利用上述寄存的参数完成g0协程初始化。

scss 复制代码
TEXT _rt0_amd64(SB),NOSPLIT,$-8
 MOVQ 0(SP), DI // argc
 LEAQ 8(SP), SI // argv
 JMP runtime·rt0_go(SB)

启动g0运行main方法

因为runtime·rt0_go也是汇编方法,所以全局搜索后我们再次定位到该方法的实现,在这里笔者给出几个核心步骤:

  1. 将参数入栈,用于后续的各种初始化和启动操作所用。
  2. 调用check进行程序启动前必要的检查操作。
  3. 拷贝上述入栈的参数进行系统参数初始化。
  4. 初始化全局调度器。
  5. 创建协程g0等待线程绑定并运行main方法。
  6. 启动线程即M绑定协程g0,执行main方法。

对应核心代码如下:

scss 复制代码
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
 // 参数入栈
 MOVQ DI, AX  // argc
 MOVQ SI, BX  // argv
 SUBQ $(5*8), SP  // 3args 2auto
 ANDQ $~15, SP
 MOVQ AX, 24(SP)
 MOVQ BX, 32(SP)
 
 //......

 // 初始化协程g0栈等信息
 MOVQ $runtime·g0(SB), DI
 LEAQ (-64*1024+104)(SP), BX
 MOVQ BX, g_stackguard0(DI)
 MOVQ BX, g_stackguard1(DI)
 MOVQ BX, (g_stack+stack_lo)(DI)
 MOVQ SP, (g_stack+stack_hi)(DI)

 //......

 //调用check进行运行时检查
 CALL runtime·check(SB)

 //......

 //拷贝argc和argv
 MOVL 24(SP), AX  // copy argc
 MOVL AX, 0(SP)
 MOVQ 32(SP), AX  // copy argv
 MOVQ AX, 8(SP)
 CALL runtime·args(SB)
 //完成系统参数初始化,例如系统字长,CPU核心数等信息初始化
 CALL runtime·osinit(SB)
 //初始化调度器
 CALL runtime·schedinit(SB)

 // runtime·mainPC代表我们程序执行的main函数的地址值,下述方法会通过创建一个协程g0来调用这个方法
 MOVQ $runtime·mainPC(SB), AX  // entry
 PUSHQ AX
 CALL runtime·newproc(SB)
 POPQ AX

 // 启动一个线程绑定上述的协程,自此调度器开始工作直接执行main方法
 CALL runtime·mstart(SB)

 CALL runtime·abort(SB) // mstart should never return
 RET

我们先来看看运行时检查的步骤,这段代码在runtime1.go上,从笔者贴出的代码不难看出,这个方法会在程序运行进行类型长度、CAS、指针、原子类的进行正确性的检查操作。

scss 复制代码
func check() {
 //......
 if unsafe.Sizeof(a) != 1 {
  throw("bad a")
 }
 
 //......
 if timediv(12345*1000000000+54321, 1000000000, &e) != 12345 || e != 54321 {
  throw("bad timediv")
 }

 //......
 if !atomic.Cas(&z, 1, 2) {
  throw("cas1")
 }
 //......

 m = [4]byte{1, 1, 1, 1}
 atomic.Or8(&m[1], 0xf0)
 if m[0] != 1 || m[1] != 0xf1 || m[2] != 1 || m[3] != 1 {
  throw("atomicor8")
 }

 //......

 *(*uint64)(unsafe.Pointer(&j)) = ^uint64(0)
 if j == j {
  throw("float64nan")
 }
 if !(j != j) {
  throw("float64nan1")
 }

//......

 if _FixedStack != round2(_FixedStack) {
  throw("FixedStack is not power-of-2")
 }

 if !checkASM() {
  throw("assembly checks failed")
 }
}

检查之后就是osinit,它会获取当前操作系统核心数、系统字长等基本信息:

scss 复制代码
func osinit() {
 asmstdcallAddr = unsafe.Pointer(abi.FuncPCABI0(asmstdcall))

 setBadSignalMsg()

 loadOptionalSyscalls()

 disableWER()

 initExceptionHandler()

 initHighResTimer()
 timeBeginPeriodRetValue = osRelax(false)

 initLongPathSupport()

 ncpu = getproccount()

 physPageSize = getPageSize()

 // Windows dynamic priority boosting assumes that a process has different types
 // of dedicated threads -- GUI, IO, computational, etc. Go processes use
 // equivalent threads that all do a mix of GUI, IO, computations, etc.
 // In such context dynamic priority boosting does nothing but harm, so we turn it off.
 stdcall2(_SetProcessPriorityBoost, currentProcess, 1)
}

完成检查后就调用schedinit进行调度器初始化,从各个函数的语义即可知晓它会进行一次STW然后进行堆栈、cpu、环境变量、垃圾回收器等各个信息的初始化:

scss 复制代码
func schedinit() {
 
 // The world starts stopped.
 worldStopped()

 moduledataverify()
 stackinit()
 mallocinit()
 godebug := getGodebugEarly()
 initPageTrace(godebug) // must run after mallocinit but before anything allocates
 cpuinit(godebug)       // must run before alginit


 goargs()
 goenvs()
 secure()
 parsedebugvars()
 gcinit()


 //......
}

最终就是创建一个线程绑定协程g0,调用$runtime·mainPC(SB)从而拿到g0协程的main方法,最终定位到我们实现的main包下的main方法main_main,这一点我们可以定位main_main的注释知晓(go:linkname main_main main.main),这个main_main指向的是被链接的main包下的main方法,也就是我们编写入口代码:

go 复制代码
// The main goroutine.
func main() {
 //我们实现的main包下的main方法
 fn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtime
 fn()

}

以终为始印证观点

基于文章开头给出的代码断点,通过堆栈调用的信息可以看到,g0的main方法确实通过main_main定位我们编写的main方法并完成执行:

小结

碍于篇幅等原因,笔者对go程序的运行仅做了简单的介绍,总体来说go程序运行大体分为以下几个步骤:

  1. 参考拷贝并入栈
  2. 类型检查
  3. 系统信息初始化
  4. 主调度器初始化
  5. 创建协程g0分配main方法的调用
  6. 创建线程绑定g0
  7. 通过协程g0的main方法调用我们的main方法,程序启动并运行

我是 sharkchiliCSDN Java 领域博客专家开源项目---JavaGuide contributor ,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。 因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。

相关推荐
devlei3 小时前
从源码泄露看AI Agent未来:深度对比Claude Code原生实现与OpenClaw开源方案
android·前端·后端
努力的小郑5 小时前
Canal 不难,难的是用好:从接入到治理
后端·mysql·性能优化
Victor3565 小时前
MongoDB(87)如何使用GridFS?
后端
Victor3565 小时前
MongoDB(88)如何进行数据迁移?
后端
小红的布丁6 小时前
单线程 Redis 的高性能之道
redis·后端
GetcharZp6 小时前
Go 语言只能写后端?这款 2D 游戏引擎刷新你的认知!
后端
宁瑶琴7 小时前
COBOL语言的云计算
开发语言·后端·golang
普通网友8 小时前
阿里云国际版服务器,真的是学生党的性价比之选吗?
后端·python·阿里云·flask·云计算
IT_陈寒8 小时前
Vue的这个响应式问题,坑了我整整两小时
前端·人工智能·后端
Soofjan9 小时前
Go 内存回收-GC 源码1-触发与阶段
后端