什么是monkey patch

早前的一个python项目遇到性能瓶颈,试图用对标准库做monkey patch,在不改源码的情况下,用gevent让标准库用上非阻塞IO。 留下的印象是以为在python等动态语言里才有monkey patch

偶然看到bouk/monkey,才发现,go语言也可以实现。好奇之下,研究了它的原理。作者有个博客,讲得很好,但缺了很多细节和过程。于是,从问题源头出发,自己理一遍,收益良多:

  • 程序的编译、连接与执行
  • go工具compileobjdump
  • go函数值实现
  • plan9汇编
  • X86指令、寄存器

魔法

简单来说,通过monkey patch,以下代码将输出”2”而不是”1”:

package main

func a() int { return 1 }
func b() int { return 2 }

func main() {
  replace(a, b)
  println(a())
}

不难看出,魔法都藏在replace函数里:需要修改a函数,使其不执行自己的函数体,而是跳转到函数b

为了讲清楚如何实现,得先铺垫几点背景知识。

go与汇编

go项目里使用汇编是件很容易的事。go代码func.go里只声明函数:

package main

func a() int
func b() int

func main() {
  println(a())
}

汇编代码func.s里,实现两函数,这里,我们让a函数跳转到b函数:

#include "textflag.h"

TEXT ·a(SB), NOSPLIT, $0-8
  JMP ·b(SB)

TEXT ·b(SB), NOSPLIT, $0-8
  MOVQ $2, ret1+0(FP)
  RET

func.gofunc.s放到同一目录,然后执行: GO111MODULE="off"; go build -o func && ./func。这段代码定义了两个函数,a函数直接跳转到b函数,b返回整数2。至此,没什么大不了,手动实现跳转而已。

关于汇编的语法,不是本文的重点,可以参考文后链接。

go的函数值类型

手动跳转显然不够,replace(f, g)的职责就动态改变函数f的代码,分两步:

  • 取得函数g的地址
  • 重写f,使其跳转到g

需要指出的是,replace的入参fg很容易被误解为函数ab的指针,但其实它们是指针的指针。这点可以通过反编译来验证,保存下面代码到funcaddr.go

package main

import (
  "fmt"
  "unsafe"
)

func a() int { return 1 }

func main() {
  f := a
  fmt.Printf("0x%x\n", **(**uintptr)(unsafe.Pointer(&f)))
}

执行 go build funcaddr.go && ./funcaddr得到:0x109adc0

再执行go tool objdump -S funcaddr,搜索这个地址,发现确实是a函数的地址:

TEXT main.a(SB) funcaddr.go
func a() int { return 1 }
  0x109adc0             48c744240801000000      MOVQ $0x1, 0x8(SP)
  0x109adc9             c3                      RET

Ok,通过函数值f可以拿到函数a的地址了。为什么要拿到地址呢?

在运行时改写函数

函数体本质是一段字符串,知道开始地址后,从那里开始写入表示新逻辑的字符串即可实现覆盖。随之而来的问题是:

  • 新逻辑的字符串是什么?
  • 如何知道覆盖的范围?

因为新的字符串要能直接被机器运行,所以它必须是机器码。把汇编翻译成机器码,并不是件容易的事,同一段汇编在不同平台得到的机器不尽相同。如果想手动翻译,可参考文后链接。

我用了一个取巧的方式,反翻译上面的func: go tool objdump -S func,找到main.a的定义:

TEXT main.a(SB) func.s
  0x1054e70         e90b000000           JMP main.b(SB)
  //...

其中,e90b000000就是跳转到函数b的机器码。终于可以来实现replace函数了:

func rawMemoryAccess(b uintptr) []byte {
  return (*(*[0xFF]byte)(unsafe.Pointer(b)))[:]
}

func replace(f, g func() int) {
  bytes := []byte{0xe9, 0x0b, 0x00, 0x00, 0x00}
  funcLocation := **(**uintptr)(unsafe.Pointer(&f))
  window := rawMemoryAccess(funcLocation)
  copy(window, bytes)
}

逻辑实现了,但这段代码是无法运行的。因为加载的二进制文件默认是无法修改的,即copy这行将报错。我们用系统调用mprotect来关闭这一保护机制,得到可用的代码:

//go:noinline
package main

import (
  "syscall"
  "unsafe"
)

func a() int {return 1}
func b() int {return 2}

func rawMemoryAccess(b uintptr) []byte {
  return (*(*[0xFF]byte)(unsafe.Pointer(b)))[:]
}

func getPage(p uintptr) []byte {
  return (*(*[0xFFFFFF]byte)(unsafe.Pointer(p
& ^uintptr(syscall.Getpagesize()-1))))[:syscall.Getpagesize()]
}

func assembleJump(g func() int) []byte {
  return []byte{0xe9, 0x0b, 0x00, 0x00, 0x00}
}

func replace(f, g func() int) {
  bytes := assembleJump(g)
  functionLocation := **(**uintptr)(unsafe.Pointer(&f))
  window := rawMemoryAccess(functionLocation)

  page := getPage(functionLocation)
  syscall.Mprotect(page,
syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)
  
  copy(window, bytes)
}

func main() {
    replace(a, b)
    println(a())
}

现在可以直接执行go run func.go,因为func.s只是用于帮助理解,现在不再需要了。 注意//go:noinine这行,用于关闭函数内联,这样才能支持改写。

眼尖的读者可能发现了,这个replace不够通用,还是写死了跳转到函数b而不是指定的函数g,实际上g参数根本没用上! 为了通用,我们改造assembleJump,让跳转的机器码使用g所指向的地址:

func assembleJump(f func() int) []byte {
  funcVal := *(*uintptr)(unsafe.Pointer(&f))
  return []byte{
    0x48, 0xC7, 0xC2,
    byte(funcVal >> 0),
    byte(funcVal >> 8),
    byte(funcVal >> 16),
    byte(funcVal >> 24), // MOV rdx, funcVal
    0xFF, 0x22,          // JMP rdx
  }
}

这里不是直接jmp到函数g,而是先把g的地址存到寄存器rdx,再jmprdx

用这个通用的assembleJump替换上面返回固定值的assembleJump,大功造成。

monkey patch的应用

显然,这是一种hack,不能用于生产环境。随着go版本的迭代,没准不久的将来就失效了。如果仔细观察,会发现我们手写的汇编或者机器码,比go编译得到的少了些含有FUNCDATAPCDATA字眼的内容:

  0x0000 00000 (func.go:9) FUNCDATA        $0,
gclocals·33cdeccccebe80329f1fdbee7f5874cb(
SB)
  0x0000 00000 (func.go:9) FUNCDATA        $1,
gclocals·33cdeccccebe80329f1fdbee7f5874cb(
SB)
  0x0000 00000 (func.go:9) FUNCDATA        $2,
gclocals·33cdeccccebe80329f1fdbee7f5874cb(
SB)
  0x0000 00000 (func.go:9) PCDATA  $0, $0
  0x0000 00000 (func.go:9) PCDATA  $1, $0

PCDATA把程序计数器和代码行号对应起来,FUNCDATA则是为垃圾回收服务的,详见Object Files and Function Metadata。缺少它们,相应功能就有缺陷。

难道,只能用于装逼了?

不,有一个场合正是用武之地:测试。将它用于打桩,让用户在单元测试中低成本的完成mock。

go的各种mock工具都只能对interface类型做mock。虽然我们一直提倡依赖倒置,现实中,还是有很多代码直接依赖了具体实现,给mock带来不必要的麻烦。

正好,黑魔法般的monkey patch来搭救了。使用封装好的monkey,可以非常简单地实现对非interface依赖的mock。

举个例子,RpcClient是个struct,代表外部rpc调用:

package rpc

type RpcClient struct {
}

func (rpc *RpcClient) SayHello() string {
  // call remote endpoint
  return "hello world"
}

在测试时mock这个rpc调用,返回指定内容:

package rpc

import (
  "bou.ke/monkey"
  "github.com/stretchr/testify/assert"
  "reflect"
  "testing"
)

func TestSayHello(t *testing.T) {
    var client = &RpcClient{}

  fakeRpc := monkey.PatchInstanceMethod(reflect.TypeOf(client), "SayHello",
func(rpcClient *RpcClient) string {
    return "hi five"
  })
  defer fakeRpc.Unpatch()
  
  msg := client.SayHello()
  assert.Equal(t, "hi five", msg)
}

执行测试时,需要关闭内联:go test -gcflags=-l

参考链接