• 由gomonkey引发的一些思考


    1、背景

    gomonkey是golang的一个单元测试的打桩框架,目标是让用户在单元测试中低成本的完成打桩,从而将精力聚焦于业务功能的开发。

    2、起因

    冠冕堂皇的起因: 在接触它之前我使用的是Java,Java中得益于Java虚拟机的字节码增强技术,可在运行期增加修改字节码文件,并由虚拟机进行动态加载,使得打桩显得尤其简单。
    那么golang是直接将代码编译成机器码,没有“中间人”该如何进行插桩呢?

    实际上: 项目中使用的gomonkey版本是v2.0.2,公司发的Mac m1,使用go arm64 运行 gomonkey提示undefined buildJmpDirective,替换成gomonkey v2.8.0就可以正常运行了

    3、gomonkey是如何实现打桩的

    在遇到上述的问题点时,相信大家已经大概能猜到gomonkey它是操作了我的系统内存,并使用了不兼容的内存操作指令。其实它是用的技术叫做Monkey Patching,译为热补丁,学C/C++的同学可能比较了解。

    先来看一段golang代码

    package main
    
    func a() int { return 1 }
    
    func main() {
    	f := a
    	f()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    反汇编看下编译器将执行f()翻译成了如下指令

    f函数地址加载到rdx寄存器 →对rdx寄存器的内容进行寻址取值,赋值到rbx寄存器,此时rbx存的就是a的函数指针执行→rbx寄存器的内容。
    我们可以发现,f实际上是一个指向a函数指针的指针,在goruntime.go有对f内存结构的描述。main函数在调用f时去取a的指针传给寄存器,然后call 寄存器的数据。显然我们可以模仿这个行为,在执行a函数时,再取b的函数指针存到寄存器,然后call b所在的寄存器数据。


    gomonkey热补丁的核心逻辑归纳出来其实就三点

    1. 获取原函数A和Mock函数B的内存地址
    2. 生成函数B的跳转指令
    3. 将原函数A的机器码修改为跳转指令的机器码

    一个简单的gomonkey使用示例

    func MethodA() string {
    	return "haha"
    }
    
    func TestA(t *testing.T) {
    	gomonkey.ApplyFunc(MethodA, func() string {
    		return "mock method"
    	})
    	fmt.Println(MethodA())	// 输出: mock method
    
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    可以看到MethodA被替换成了一个匿名的mock函数


    gomonkey.ApplyFunc执行流程


    接着来扒一下源码细节
    这里笔者以gomonkey v2.8.0, AMD64位CPU,Darwin操作系统运行为例

    patch.go

    是所有可调用函数的入口

    // 向外暴露的函数入口, 将target函数的实现逻辑改为double函数
    func ApplyFunc(target, double interface{}) *Patches {
       return create().ApplyFunc(target, double)
    }
    
    //------------------------------------------------------------------------------------------------------------------------
    
    // 生成一个补丁实例,它初始化了三个map
    // originals: key是原函数。,value是原函数的内存地址,用byte[]表示。用于后续撤销热补丁后,恢复原函数功能。
    // values: key是原变量,value是原变量值的地址。用于后续撤销热补丁后,恢复变量值。
    // valueHolders: key是mock函数,value依然是mock函数。它对执行逻辑没有任何的作用,仅仅是为了维持一个强引用,防止被GC回收。
    func create() *Patches {
       return &Patches{originals: make(map[reflect.Value][]byte), values: make(map[reflect.Value]reflect.Value), valueHolders: make(map[reflect.Value]reflect.Value)}
    }
    
    // 
    func (this *Patches) ApplyFunc(target, double interface{}) *Patches {
       t := reflect.ValueOf(target)
       d := reflect.ValueOf(double)
       return this.ApplyCore(t, d)
    }
    //------------------------------------------------------------------------------------------------------------------------
    // 核心流程函数
    func (this *Patches) ApplyCore(target, double reflect.Value) *Patches {
       // 检查原函数和mock函数的类型是否正确
       this.check(target, double)
       // 如果原函数已被mock,panic
       if _, ok := this.originals[target]; ok {
       	panic("patch has been existed")
       }
       // 防止mock函数被gc回收
       this.valueHolders[double] = double
       // 用mock函数替代原函数,并返回原函数地址
       original := replace(*(*uintptr)(getPointer(target)), uintptr(getPointer(double)))
       // 保存原函数地址
       this.originals[target] = original
       return this
    }
    
    
    func getPointer(v reflect.Value) unsafe.Pointer {
       // 对reflect.Value解引用,取得函数指针
       return (*funcValue)(unsafe.Pointer(&v)).p
    }
    //------------------------------------------------------------------------------------------------------------------------
    
    // 检查原函数和mock函数类型
    func (this *Patches) check(target, double reflect.Value) {
       // 原函数类型需为函数
       if target.Kind() != reflect.Func {
       	panic("target is not a func")
       }
       // mock函数类型需为函数
       if double.Kind() != reflect.Func {
       	panic("double is not a func")
       }
       // 原函数和mock函数的函数签名需一致
       if target.Type() != double.Type() {
       	panic(fmt.Sprintf("target type(%s) and double type(%s) are different", target.Type(), double.Type()))
       }
    }
    
    // 使用mock函数替换原函数
    func replace(target, double uintptr) []byte {
       // 返回跳转mock函数的指令
       code := buildJmpDirective(double)
       // 用于后续删除mock函数恢复现场
       bytes := entryAddress(target, len(code))
       original := make([]byte, len(bytes))
       copy(original, bytes)
       // 开始用生成的汇编指令替换原函数
       modifyBinary(target, code)
       return original
    }
    
    func entryAddress(p uintptr, l int) []byte {
       // 返回切片指针指向的实际数据指针
       return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{Data: p, Len: l, Cap: l}))
    }
    //------------------------------------------------------------------------------------------------------------------------
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81

    jmp_amd64.go

    只有一个函数buildJmpDirective(double uintptr),生成内存地址跳转指令

    func buildJmpDirective(double uintptr) []byte {
        d0 := byte(double)
        d1 := byte(double >> 8)
        d2 := byte(double >> 16)
        d3 := byte(double >> 24)
        d4 := byte(double >> 32)
        d5 := byte(double >> 40)
        d6 := byte(double >> 48)
        d7 := byte(double >> 56)
    
        return []byte{
            0x48, 0xBA, d0, d1, d2, d3, d4, d5, d6, d7, // MOVABS rdx, double
            0xFF, 0x22,     // JMP [rdx]
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    modify_binary_linux.go

    它只有一个函数modifyBinary(target uintptr, bytes []byte),用汇编跳转指令替换原函数

    func modifyBinary(target uintptr, bytes []byte) {
    	// 取出target函数的指令地址
        function := entryAddress(target, len(bytes))
        
        // 获取target函数指令所在页的数据
        page := entryAddress(pageStart(target), syscall.Getpagesize())
        // 设置页保护权限为可写
        err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)
        if err != nil {
            panic(err)
        }
        // 将跳转指令替代目标函数内容
        copy(function, bytes)
        // 设置页保护权限为不可写
        err = syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_EXEC)
        if err != nil {
            panic(err)
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    4、源码与实践中得出的问题与思考

    1、GoLand Debug可以正确mock函数,而Run却mock失败了?

    打开Run/Debug时的控制台,展开顶部被折叠的编译命令,让我们看看GoLand做了什么(由于文件夹名字太长,这里就不截图了,用文本形式贴出来)
    Run

    GOROOT=/usr/local/go #gosetup
    GOPATH=/Users/chenhuajian/go #gosetup
    /usr/local/go/bin/go test -c -o /private/var/folders/dr/xxx.test xxx #gosetup
    /usr/local/go/bin/go tool test2json -t /xxxxx/dlvLauncher.sh /Applications/GoLand.app/Contents/plugins/go/lib/dlv/macarm/dlv --listen=127.0.0.1:63761 --headless=true --api-version=2 --check-go-version=false --only-same-user=false exec /private/var/folders/dr/xxx.test -- -test.v -test.paniconexit0 -test.run ^\QTestA\E$
    === RUN   TestA
    mock method
    --- PASS: TestA (0.00s)
    PASS
    
    Debugger finished with the exit code 0
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    Debug

    GOROOT=/usr/local/go #gosetup
    GOPATH=/Users/chenhuajian/go #gosetup
    /usr/local/go/bin/go test -c -o /private/var/folders/dr/xxx.test -gcflags all=-N -l xxx #gosetup
    /usr/local/go/bin/go tool test2json -t /xxxxx/dlvLauncher.sh /Applications/GoLand.app/Contents/plugins/go/lib/dlv/macarm/dlv --listen=127.0.0.1:63761 --headless=true --api-version=2 --check-go-version=false --only-same-user=false exec /private/var/folders/dr/xxx.test -- -test.v -test.paniconexit0 -test.run ^\QTestA\E$
    === RUN   TestA
    haha
    --- PASS: TestA (0.00s)
    PASS
    
    Debugger finished with the exit code 0
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    区别在与Debug在第三行执行go test 命令时加了个参数-gcflags all=-N -l ,禁用了golang的内联优化。内联优化简单来说就是,golang为了减少因函数调用带来的时间开销,在编译期将函数体直接嵌入到调用方的代码里。
    例如文章开头的例子用golang代码的视角来看会被内联优化成这样:

    func MethodA() string {
    	return "haha"
    }
    
    func TestA(t *testing.T) {
    	gomonkey.ApplyFunc(MethodA, func() string {
    		return "mock method"
    	})
    	fmt.Println("haha")	
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    显然,无论对MethodA做什么手脚,程序都只能输出"haha"。正因此,这种热补丁技术也不建议用在生产环境的业务逻辑上,会对性能造成一定影响。

    PS: 这种技术不建议用作压测时Mock下游服务,因为它

    • 对代码有侵入性
    • 对内存数据直接操作,可能会产生无法预知且难以排查的BUG
    • 前置条件时是编译期禁用内联优化,会直接降低服务性能

    2、unsafe.Pointer和uintptr的区别

    unsafe.Pointer和uintptr都可以唯一表示一个内存地址,它们之间也可以互相转换。

    区别unsafe.Pointeruintptr
    原始类型*intint
    含义通用指针,可以与任意类型的指针进行相互转换一个整数,是内存地址的整数表现形式
    应用场景对值内容进行运算对内存地址进行运算
    注意点由于类型未知,不能通过*直接取值不能当作指针来使用,因为它只是一个整数,未持有对象的指针,而内存地址随时有可能被GC回收或移动

    3、如何理解getPointer()函数

    我们的最终目的是取得target函数实际执行逻辑的地址并替换,先记住如下的一张图

    这个函数的目的是为了通过反射实例取得函数的指针地址,让我们来简单回顾一下原文中的用法

    func MethodA() string {
    	return "haha"
    }
    
    
    func main(){
    	target:=reflect.valueOf(MethodA)
    	funcAddr:=*(*uintptr)(getPointer(target))
    }
    
    
    func getPointer(v reflect.Value) unsafe.Pointer {
    	// 对reflect.Value解引用,取得函数指针
    	return (*funcValue)(unsafe.Pointer(&v)).p
    }
    
    type funcValue struct {
    	_ uintptr
    	p unsafe.Pointer
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    其实他等价于

    func MethodA() string {
    	return "haha"
    }
    
    func main(){
    	f:=MethodA
    	funcAddr:=**(**uintptr)(unsafe.Pointer(&f))
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    4、跳转指令为什么这么写?为何replace(target, double uintptr)函数传参时给的参数不一样?

    先来复习一遍作者是怎么使用的

    func ApplyCore(..){
    	// ...
       target := reflect.ValueOf(MethodA)
       double := reflect.ValueOf(MockMethodB)
       original := replace(*(*uintptr)(getPointer(target)), uintptr(getPointer(double)))
    	// ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    反射实例转来转去不好理解,这里同样转成易于理解的写法

    func ApplyCore(...){
    	// ...
    	originals := replace(**(**uintptr)(unsafe.Pointer(&MethodA)), *(*uintptr)(unsafe.Pointer(&MockMethodB)))
    	// ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    通过转换后的写法,我们可以直观的看出
    replace方法的第一个入参是target函数实际执行逻辑的地址
    第二个入参是指向mock函数实际地址的指针


    那这里的第二个入参为什么不是传mock函数的实际地址呢?
    这里就需要解读一下replace函数是如何生成汇编指令了
    以amd64 系统为例

    func buildJmpDirective(double uintptr) []byte {
    	d0 := byte(double)
    	d1 := byte(double >> 8)
    	d2 := byte(double >> 16)
    	d3 := byte(double >> 24)
    	d4 := byte(double >> 32)
    	d5 := byte(double >> 40)
    	d6 := byte(double >> 48)
    	d7 := byte(double >> 56)
    
    	return []byte{
    		0x48, 0xBA, d0, d1, d2, d3, d4, d5, d6, d7, // MOVABS rdx, double
    		0xFF, 0x22, // JMP [rdx]
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    0X48 是X86-64系统需要加的前缀,表示接下来的指令是要在64位系统运行,寄存器的位宽是64位,后面的指令源立即数也就需要8个字节
    0XBAMOVABS的机器码,将8个字节的立即数放入rdx寄存器
    0XFF, 0x22 表示JMP [rdx],跳转到寄存器存储的地址,而[rdx]就是对rdx存储的地址进行取值,类似go中的*,所以mock函数自然是需要传指针了

    在线汇编/反汇编查询


    函数入参类型一致,意义却不一样,参数名也无法看出区别。为了更好理解,其代码也可以按如下方式实现,效果一样。

    func ApplyCore(...){
    	// ...
    	originals := replace(**(**uintptr)(unsafe.Pointer(&MethodA)), **(**uintptr)(unsafe.Pointer(&MockMethodB)))
    	// ...
    }
    
    func buildJmpDirective(double uintptr) []byte {
    	// ...
    	return []byte{
    		0x48, 0xBA, d0, d1, d2, d3, d4, d5, d6, d7, // MOVABS rdx, double
    		0xFF, 0xE2, // JMP rdx
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    JMP [rdx] 改成了JMP rdx,由间接寻址变为直接寻址,因为replace函数传入的已经是实际执行逻辑所在地址了。
    至于为什么是**(**uintptr)(unsafe.Pointer(&MockMethodB)),需要两次解引用呢?可以参看问题3的图,对于f:=MethondA这样的临时变量,需要两次解引用


    5、entryAddress的目的是啥?

    可以先参看这篇文章的go slice存储结构
    既然需要对内存数据做修改,那么就需要找到内存数据存储的真实位置,这里的内存数据指的是target函数的指令集机器码,以byte[]表示。

    func entryAddress(p uintptr, l int) []byte {
    	return *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{Data: p, Len: l, Cap: l}))
    }
    
    • 1
    • 2
    • 3

    我们拥有数据指针和数据大小,那么就可以构造一个三元组对象reflect.SliceHeader,来构造byte[]的底层对象,并通过对reflect.SliceHeader取址和用unsafe.Pointer强转成byte[]对象。


    6、为何mac m1运行gomonkey 2.0.2报的错是找不到buildJmpDirective函数?

    这是因为go build时会根据操作系统和CPU架构来对文件进行条件编译。
    而编译程序的判断依据有两个

    • 文件内容头部的编译标签
    • 文件名后缀

    gomonkey是通过文件名后缀的方式来进行条件编译,golang会从go env读取$GOOS$GOARCH对文件名进行匹配,同时使用时$GOOS需在$GOARCH前面。
    目前golang支持的goos和goarch可以看这个链接
    这里可以看看syscall这个系统调用的库的例子

    笔者电脑安装的是arm64的go,$GOOS=darwin,$GOARCH=arm64,如下是gomonkey v2.0.2的目录结构,jmp_amd64.go自然不会被编译。

    PS: 部分机智的小伙伴可能会说:那我把jmp_amd64.go改成jmp_arm64.go或者是其他的文件名,是不是就可以运行了?
    答案显然是不可以,程序会换了一个你更看不懂的错误报给你,因为jmp_amd64.gobuildJmpDirective函数构建的是amd64架构的指令,不兼容arm64架构。最新的gomonkey已经支持arm64架构,直接升级你的gomonkey版本吧


    7、为何修改页内存权限,如何修改

    因为内存数据默认是没有写入权限的,且修改权限只能以为最小单位。因此这里通过syscall.Mprotect这个系统调用来修改target函数的写入权限,看看gomonkey的用法。

    
    
    func modifyBinary(target uintptr, bytes []byte) {
    	// ...
        // 获取target函数指令所在页的数据
        page := entryAddress(pageStart(target), syscall.Getpagesize())
        // 设置页保护权限为可写
        err := syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)
        // ...
    }
    
    func pageStart(ptr uintptr) uintptr {
    	return ptr & ^(uintptr(syscall.Getpagesize() - 1))
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    syscall.Mprotect需要把一整页的byte[]作为入参,获取的方式可以分为以下两步

    1. 获取target函数所在页页首地址,即上述的pageStart函数。这里是通过位运算来抹去target的偏移量以获取页首地址。下面举个例子演算一遍更好理解这个函数
    1. 通过页首地址和页大小,用entryAddress获取完整的页数据

    5、参考资料

    https://bou.ke/blog/monkey-patching-in-go
    https://github.com/agiledragon/gomonkey
    https://stackoverflow.com/questions/59084717/what-is-the-difference-between-uintptr-and-uintptr

    如果本文对您有帮助,欢迎点个赞再走~

  • 相关阅读:
    MySQL数据库
    【scikit-learn基础】--『预处理』之 离散化
    2023年7月京东平板电脑行业品牌销售排行榜(京东销售数据分析)
    基于Matlab求解高教社杯全国大学生数学建模竞赛(CUMCM2003B题)-露天矿生产的车辆安排(附上源码+数据+题目)
    Leetcode1545-找出第 N 个二进制字符串中的第 K 位
    leetcode92 反转链表II
    如何编写一个 Pulsar Broker Interceptor 插件
    react事件与原生事件的区别
    python数据分析-ZET财务数据分析
    Wing Loss 论文阅读笔记
  • 原文地址:https://blog.csdn.net/qq_41960425/article/details/124968546