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任意函数来实现单元测试,保证我们代码质量和高效迭代。