• Go运行时hacking


    § x01 与其他语言相比

    Go是为数不多可以自举(bootstrap、自编译)的语言,即其编译器是用自身语言编写。你可以通过环境变量GOROOT_BOOTSTRAP使用低版本的Go环境编译一个高版本的Go。

    编译器中知名的GCC编译器也可以自举编译,但是编译比较麻烦,没有Go这么易用。而其他流行的语言编译器(解释器)一般都是用其他更高效的语言编写的。如Python是用的C语言,Java虽然编译器是Java写的,但还有一个复杂的运行时虚拟机是用C++写的。

    而Go的明显优势是,运行时、编译器全是用自己写的。这样对普通使用者来说,没有什么区别,这也是语言设计者所期望的。对于喜欢深层技术人来说,就很非常心动。主要有以下原因:

    1. 研究语言的编译细节,不需要额外学习另一种语言。
    2. 研究运行时机制,不需要掌握另一种语言。

    也即,学习成本低了一些。很多通用的技术细节,编译器和运行时中都能找到实例。

    § 0x02 运行时的细节

    Go的运行时提供了语言的核心特性,如chan、goroutine、reflection、map、slice、GC等。运行时一方面提供上述特性,使语言更易用、上手;同时,运行时要对语言本身负责,保证其运行的正确性和高性能。所以Go的运行时也比较复杂。出于性能和不同平台的需要,代码中还包含了相当大量的汇编

    2.1 不同的两种栈

    runtime本身相当于资源的协调者,拥有全局的视角。所以协调者本身不能和用户的程序(协程)混淆。所以就有了两种栈。user stacksystem stack。用户的代码运行在user stack上,调度、GC等运行时的处理大多都在system stack下进行。

    system stack与普通的user stack相比有如下特点:

    1. 容量受限。
    2. 不能扩容。
    3. 不可GC。

    可理解为C语言中的栈。

    运行时中能看到一个经常出现的函数systemstack。这个函数是用来切换不同的栈。与这个函数等价的一个注解是//go:systemstack,后者用来指定一个函数在system stack上运行。

    system stack是不可扩容的,反之并不成立,普通的user stack也可以不扩容。可通过//go:nosplit来指定函数的栈不需要扩容。一般情况下,编码人员不要指定栈不扩容;这个特性使用时要非常小心,除非你对性能要求特别严苛。因为一旦开启,但栈又需要扩容时,就会出现栈溢出异常。

    带来的问题:

    栈回溯变得很复杂,需要区分用户栈和系统栈。

    2.2 栈扩容和协程调度的时机

    为什么这两个事情一起说呢?是因为其实现机制上有一定的重叠。

    1. 栈扩容
    2. 调度
    2.2.1 栈扩容

    编译时,编译器为每个没有标记//go:split//go:systemstack的函数,开头和结尾插入runtime.morestack的调用。

       0x00000000004b31e0 <+0>:     cmp    0x10(%r14),%rsp
       0x00000000004b31e4 <+4>:     jbe    0x4b3296 
       ...
       0x00000000004b3296 <+182>:   call   0x45c8a0 
       0x00000000004b329b <+187>:   nopl   0x0(%rax,%rax,1)
       0x00000000004b32a0 <+192>:   jmp    0x4b31e0 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    而morestack正是栈扩容的入口函数。但其职责不止栈扩容。

    r14是Go中用来保存当前运行协程的指针,0x10(%r14)表示栈的guard值,第1行比较用来判断是否需要扩容。64位系统中,类型为32bit。这个判断还两个特殊用途。

    1. guard被设置为0xfffffeed时,表示要强制扩容。不需要扩容时,也会触发栈拷贝。这是运行时的一种调试机制。
    2. guard被调度器设置为0xfffffade时,表示要被抢占调度(让出CPU执行)了。
    2.2.2 抢占调度

    安全点:指协程在某指令处中断执行,然后恢复到其他M上执行时,不会影响计算结果的正确性。

    AMD64中,非安全点的指令只有一类:

    1. TLS相关的MOV指令。 协程的一部分信息会保存在TLS中,一旦在保存时切换了,新的线程中TLS内容将不适用于新的协程,引发协程执行异常。

    抢占类型:

    1. 同步抢占是通过前述的guard设置为特殊的0xfffffade值,在函数调用时主动让出调度。在go1.15之前唯一的一种方式。
    2. 1.15及之后版本支持基于信号机制的异步抢占。Linux下是使用SIGURG信号。

    设置抢占的时机:

    1. 进入系统调用。
    2. 退出系统调用。
    3. 收到异步抢占信号,在处理时判断:能抢占直接抢占;不能抢占时,设置guard值,让协程在合适的时机让出调度。 所以异步抢占是不完全保证成功的。

    2.3 栈特点

    AMD64的ABI规则为:在近跳转调用函数时,将CALL的下一条指令压栈,转去执行被调用函数。函数内可以通过SP的操作选择分配具体的栈空间。

    Go函数调用时的用户栈特点如下:

    1. CALL执行时,与AMD64一样,压栈下一条PC后,转到目标函数执行。
    2. 目标函数中,先分配栈空间,之后,将调用者的SP保存在栈上。
    3. 除汇编代码外,函数内部很少直接使用POP和PUSH指令。

    查看反汇编时,可以根据特点2,分割栈上保存的栈帧。栈的设计与编译和运行时都强相关,感兴趣的可以看下参考链接1中rsc给出的设计分析。

    § 0x03 更底层的手段

    3.1 修改机器码

    在二进制攻防中常用的技术。
    目的:通过修改机器码,在没有源代码情况下,修改程序的执行逻辑。

    Go里面用这项技术的原因是,编译器生成的汇编我们无法控制。

    如下修改是为了保存+145行中保存到rdx寄存器中的内容。>:之后到汇编指令中间的就是机器码。

       0x00000000004b3264 <+132>:   48 8b 54 24 08  mov    0x8(%rsp),%rdx
       0x00000000004b3269 <+137>:   48 8b 02        mov    (%rdx),%rax
       <= 原位置
       0x00000000004b326c <+140>:   c6 44 24 07 00  movb   $0x0,0x7(%rsp)
       0x00000000004b3271 <+145>:   48 8b 54 24 30  mov    0x30(%rsp),%rdx
       0x00000000004b3276 <+150>:   ff d0   call   *%rax <= 新位置
       0x00000000004b3278 <+152>:   48 8b 02        mov    (%rdx),%rax
       0x00000000004b327b <+155>:   ff d0   call   *%rax
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    要求:

    1. 修改之后与之前的指令总长度不变。
    2. 这种前后顺序调整的,要求调整的指令中不能有引用指令偏移量的指令。如这种跳转指令
      jne 0x4b330c

    工具:可以使用hexedit或者使用Python写个脚本。

    3.2 修改编译过程插入自已定义的函数

    原因同上。以go1.17中想要在dwrap函数中添加一个函数调用为例。

    1. 修改go/cmd/compile下 src/cmd/compile/internal/typecheck/builtin/runtime.go,添加自己的函数声明。
    2. 切换到src/cmd/compile/internal/typecheck下,执行mkbuiltin.go,生成新的builtin.go包。建议使用其他版本的mkbuiltin.go,直接build出二进制拷贝到目标目录下执行。
    3. 修改src/cmd/compile/internal/walk/order.go,在1767行左右,修改添加如下代码:
    1767     setDX := typecheck.LookupRuntime("SaveDX") // 新增的函数名。
    1768     setdx := ir.NewCallExpr(base.Pos, ir.OCALL, setDX, nil) // 生成ir.Node,无参数
    1769     typecheck.Stmt(setdx) // 进行类型检查。
    1771     fn.Body = []ir.Node{setdx, newcall} // 修改原的Body,newcall前加入setdx的Node。
    
    • 1
    • 2
    • 3
    • 4
    1. src/runtime中添加函数的定义。
    2. 使用GOROOT_BOOTSTRAP方式,使用其他版本的Go编译当前的源码。切换到src目录下,执行GOROOT_BOOTSTRAP=goroot bash make.bash,编译完成后,使用新的go去build自己的Go代码,可以通过反汇编看到修改已生效。
    Dump of assembler code for function main.(*TaskRunner).Schedule.func1·dwrap·1:
       0x00000000004b32c0 <+0>:     cmp    0x10(%r14),%rsp
       0x00000000004b32c4 <+4>:     jbe    0x4b3330 
       0x00000000004b32c6 <+6>:     sub    $0x28,%rsp
       0x00000000004b32ca <+10>:    mov    %rbp,0x20(%rsp)
       0x00000000004b32cf <+15>:    lea    0x20(%rsp),%rbp
       0x00000000004b32d4 <+20>:    mov    0x20(%r14),%r12
       0x00000000004b32d8 <+24>:    test   %r12,%r12
       0x00000000004b32db <+27>:    jne    0x4b3337 
       0x00000000004b32dd <+29>:    mov    0x8(%rdx),%rax
       0x00000000004b32e1 <+33>:    mov    %rax,0x18(%rsp)
       0x00000000004b32e6 <+38>:    call   0x460b20 
       0x00000000004b32eb <+43>:    lea    0xa7ce(%rip),%rax        # 0x4bdac0
       0x00000000004b32f2 <+50>:    call   0x40c880 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    0x04 总结与参考

    Go自举机制为研究其运行时的实现提供了极大的便利性。通过这些底层的技术细节,可以深入理解Go的实现原理,这在排查与运行时相关的问题时非常方便。

    Go运行时同样也比较复杂,语言本身也是程序,所以不可能完全没有问题。高手也会出错,上述技术研究的起源就是1.17中regabi特性引入的一个问题https://github.com/golang/go/issues/54247

    这个总结也是备忘,后面大概率还有类似的问题出现需要排查。

    1. 栈回溯相关的设计 by rsc
  • 相关阅读:
    LeetCode 2369. 检查数组是否存在有效划分 动态规划
    线程(中):线程安全
    C++ 9:友元,范围for,静态成员
    Winform中使用System.Windows.Forms.Timer多次启动停止计时器时绑定事件会重复多次执行
    mp4文件怎样提取mp3音频文件
    使用HttpClients发送Get请求和Post请求【含原理分析】
    Spring系列文章3:基于注解方式依赖注入
    Cy3 PEG N-羟基琥珀酰亚胺,荧光染料CY3标记聚乙二醇修饰N-羟基琥珀酰亚胺,Cy3-PEG-N-Hydroxy succinimide
    用Roslyn玩转代码之一: 解析与执行字符串表达式
    景联文科技:深度探究自动驾驶重要方向——车路协同
  • 原文地址:https://blog.csdn.net/u012520854/article/details/126482563