黄先森

西二旗民工

分享一些与编程、分布式系统、区块链技术相关的内容


欢迎访问个人github

golang实现运行时替换函数体及其原理

golang替换运行时函数体及其原理

Note. 本文主要翻译自Monkey Patching in Go一文,在那篇文章了作者介绍了如何替换golang里的两个简单函数func a() int { return 1 }func b() int { return 2 },本文在此基础上补充了如何替换任意两个函数体内容。

1. 什么是golang替换运行时函数体?

所谓golang替换运行时函数体就是改变golang在运行时的函数体内容,python、ruby等动态语言能够很容易实现这个特性。而golang是静态语言,变量类型和函数体在编译的时候就会确定下来。比如,定义了两个具有相同签名的函数func a() int { return 1 }func b() int { return 2 },能够在调用val := a() // val返回2时候,执行函数b()的方法体。虽然golang在语言级别上不支持这种动态替换,但是计算机毕竟只是运行程序的机器,我们依然能够通过代码让机器完成这件事。

2. golang替换运行时函数体有什么用?

上述这种做法有什么意义呢?为什么不直接在实现函数a()的时候,就让结果返回2呢?这种做法在做单元测试的时候非常有用,因为我们希望在做单元测试的时候,既不能改动a()的代码,但是仍然能够让函数a()在单元测试的时候运行mock代码,而在生产环境中运行实际代码。

3. 如何替换运行时函数体?

本节会详细介绍如何替换golang运行时的两个特定函数体,在下一章会进一步扩展到如何替换两个任意函数体。

在开始介绍替换原理前,我们先探索下golang里的函数。这篇文章会涉及到一些Intel汇编命令,读者阅读的时候可参考官方文档

让我们看看下面的代码在反汇编的时候是怎样的:

package main

func a() int { return 1 }

func main() {
  print(a())
}

当编译以上代码和使用Hopper反汇编工具查看时,上面代码会生成以下汇编代码:

图 1 上述代码对应的汇编代码

左侧是每一条汇编指令的地址,右侧是具体的汇编命令和参数。

我们的程序从main.main开始执行,指令0x2010-0x2026在设置函数的栈信息。我们后续的描述中都会忽略掉这些代码。

0x202a这一行是调用main.a函数,而main.a函数的地址为0x2000,而函数体只是简单的把0x01移动到栈中并返回。0x202f-0x2037这几行是把值传到函数runtime.printint里。

对应的汇编代码还算易懂,接下来我们看下golang里的函数值是怎么实现的。

3.1 golang里的函数值是怎么运作的

再看下面的代码

package main

import (
  "fmt"
  "unsafe"
)

func a() int { return 1 }

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

第11行把a赋值给f,这意味着调用f()将调用a()。上面使用了unsafe包直接读取f在内存中的值。如果我们之前有过C语言的使用经历,会认为f是一个指向函数a的函数指针,因为上面代码应该输出0x2000(图1中main.a的地址)。当运行代码的时候,结果输出0x102c38,这个地址貌似跟我们的代码没有关系。当进行反汇编的时候,第11行生成的汇编代码如下:

图 2 f对应的汇编代码

这会引用到一个main.a.f的信息,当我们在汇编代码里面搜索102c38的时候,我们会看到下面的结果:

图 3 地址为0x102c38对应的汇编代码

原来main.a.f的地址就在0x102c38,并且包含0x2000(main.a的地址)。看起来f不是一个指向函数的指针,而是一个指向函数指针的指针。让我们再修改下代码,验证下我们的猜想。

代码如下:

package main
 
import (
  "fmt"
  "unsafe"
)
 
func a() int { return 1 }
 
func main() {
  f := a
  fmt.Printf("0x%x\n", **(**uintptr)(unsafe.Pointer(&f))) // 通过unsafe包把f转为一个指针的指针,并打印这个指针的值
}

这会输出0x2000,跟我们猜想一致。golang源码runtime/runtime2.go,也揭示了这个信息。

我们继续看下调用一个函数值是怎么实现的。我们修改下代码,在f赋值完毕之后再调用它。

package main

func a() int { return 1 }

func main() {
	f := a
	f()
}

对应的汇编代码如下:

图 4 调用f时的汇编代码

golang会先把main.a.f的地址加载到寄存器rdx中,然后不管rdx里面存储的是什么,接着会加载到寄存器rbx,然后执行rbx的内容。

接下来用我们刚获取到的新知识来实现运行时函数体的替换。

3.2 运行时替换函数体

我们希望实现replace这么一个函数,来交换a()、b()两个函数,然后在调用a()的时候能够执行b()的代码。

package main

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

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

现在我们怎么实现replace函数呢?我们需要修改函数a,使得a能够跳转到函数b那而不是执行自己的方法体。本质上,我们需要把函数b的地址加载到寄存器rdx中,并跳转到rdx里面的内容所指向的地址。(注意,是跳转到rdx里面的内容所指向的地址,所以是带有中括号的jmp [rdx],而不是jmp rdx)对应的汇编代码如下:

 mov rdx, main.b.f ; 48 C7 C2 ?? ?? ?? ??
 jmp [rdx] ; FF 22

mov rdx, main.b.f 汇编代码对应的机器码是\x48 \xc7 \xc2 … …,而jmp [rdx]对应机器码是\xff \x22。我们可以通过在线汇编器来转换得到,如下图

图 5 在线汇编器

那么编写一个能够生成这样的代码的函数就很直接了,如下:

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]
  }
}

万事俱备,只欠东风,只要组合一下就可以替换掉函数a的函数体,让执行a的时候跳转到函数b的函数体。下面的代码试着把机器码拷贝到函数体所在位置。

package main

import (
	"syscall"
	"unsafe"
)

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

func rawMemoryAccess(b uintptr) []byte {  // 获取b地址的内存块
	return (*(*[0xFF]byte)(unsafe.Pointer(b)))[:]  // 获取b对应的内存块,以便改写内容,从而达到替换目的
}

func assembleJump(f func() int) []byte { // 生成跳转到f函数所在位置的机器码
	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]
	}
}

func replace(orig, replacement func() int) {
	bytes := assembleJump(replacement)
	functionLocation := **(**uintptr)(unsafe.Pointer(&orig))
	window := rawMemoryAccess(functionLocation)
	
	copy(window, bytes)
}

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

然而运行这段代码却不起作用,会导致segmentation fault错误。这是因为内存块默认是不可写的。我们可以用mprotect函数调用来关闭内存块的读写保护特性,最终版的代码如下,最后函数会输出2

package main

import (
	"syscall"
	"unsafe"
)

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

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

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

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
	}
}

func replace(orig, replacement func() int) {
	bytes := assembleJump(replacement)
	functionLocation := **(**uintptr)(unsafe.Pointer(&orig))
	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)
	print(a())
}

4. 如何替换运行时任意函数体?

上面的replace函数只能够替换签名为func() int的函数,而我们单元测试需要mock的函数是不固定的,所以需要进一步扩展成函数func replaceWithReflect(from, to interface{})。反射能帮忙完成这件任务。

同样地,我们需要获取from、to两个函数的地址,reflect.ValueOf(from).Pointer()reflect.ValueOf(to).Pointer()。接着我们需要修改生成跳转函数体机器码函数,因为现在已经获取到to的地址,所以对应的汇编命令需要改成jmp rdx而不是jmp [rdx],而jmp rdx对应的机器码为\xff\xe2。修改后的函数如下:

func JumpAssemblyDataWithReflect(p uintptr) []byte {
	return []byte{
		0x48, 0xC7, 0xC2,
		byte(p),
		byte(p >> 8),
		byte(p >> 16),
		byte(p >> 24),  // mov rdx,p
		0xFF, 0xe2, // jmp rdx而不是jmp [rdx]
	}
}

最后完整的代码如下,代码将输出func f()、3.14和return by h(),可以看到一个更general的replaceWithReflect已经实现:

package main

import (
	"unsafe"
	"syscall"
	"reflect"
	"fmt"
)

func e() float64 {
	fmt.Println("func e")
	return 0
}

func f() float64 {
	fmt.Println("func f")
	return 3.14
}

func g() string {
	return "return by g()"
}

func h() string {
	return "return by h()"
}

func JumpAssemblyDataWithReflect(p uintptr) []byte {
	return []byte{
		0x48, 0xC7, 0xC2,
		byte(p),
		byte(p >> 8),
		byte(p >> 16),
		byte(p >> 24),  // mov rdx,p
		0xFF, 0xe2, // jmp rdx而不是jmp [rdx]
	}
}

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

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

func replaceWithReflect(from, to interface{}) {

	jumpAndExecToAssemblyData := JumpAssemblyDataWithReflect(reflect.ValueOf(to).Pointer())
	funcLocation := reflect.ValueOf(from).Pointer()
	window := RawMemoryAccessWithReflect(funcLocation)

	page := getPageWithReflect(funcLocation)
	syscall.Mprotect(page, syscall.PROT_READ|syscall.PROT_WRITE|syscall.PROT_EXEC)

	copy(window, jumpAndExecToAssemblyData)
}

func main() {
	replaceWithReflect(e, f)
	fmt.Println(e())
	replaceWithReflect(g, h)
	fmt.Println(g())
}

5. 总结

办法总比问题多,所以还是可以在静态语言运行过程中来修改函数的,这能够使我们mock任意函数来实现单元测试,保证我们代码质量和高效迭代。

最近的文章

数据结构之Log Structured Merge Trees

Log Structured Merge Trees介绍Note. 本文主要翻译自Log Structured Merge Trees一文,在那篇文章里作者详细介绍了LSM这种写密集型高效存储数据结构,本文在此基础上补充了一些性能分析如时间复杂度、空间复杂度等内容和提出一些疑惑。希望能给各位读者抛砖引玉,本人水平有限请多指教。 1. B+树和Append Logs 2. LSM树 3. 具有层级压缩的LSM 4. 一些实现细节 ...…

数据结构 LSM 数据存储 BloomFilter继续阅读
更早的文章

ZooKeeper介绍以及应用

ZooKeeper介绍Note. 本文主要介绍ZooKeeper的基本概念,以及基于ZooKeeper的分布式配置平台demo和分布式锁,记录学习过程中的体会与总结。 1. 一个分布式配置平台原型 2. ZooKeeper背景介绍 3. ZooKeeper基础概念 4. ZAB协议 5. 基于ZooKeeper的分布式锁 1. 一个分布式配置平台原型在介绍ZooKeeper之前,先介绍一个基于ZooKeeper的分布式配置...…

ZooKeeper 分布式配置 分布式锁 zab继续阅读