使用 cgo 让 Go 跟 C 一起工作已经不是啥稀奇的了。有大量的第三方包直接对 C 的库做了封装,提供给 Go 使用。从 Go 项目本身的代码中可以看到,不但有 C 代码,还有汇编代码存在。那么在自己的项目中是否能跟汇编结合呢?这篇文章完整并清晰的解说了如何让 Go 和汇编协同工作。真得性能敏感?上汇编吧!!
————翻译分隔线————
Go 和汇编
关于 Go,我最喜欢的部分之一就是它那坚定不移的实用主义线路。有时我们过于强调语言的设计,而忘记了编程所包含的其他内容。例如:
- Go 的编译器很快
- Go 有着强大的标准库
- Go 可以工作在多种平台下
- Go 有着可以通过命令行/本地 Web 服务/ Internet 访问的完整文档
- 所有 Go 的代码是静态编译的,因此部署的问题微不足道
- 全部 Go 的代码都以良好的格式发布,可以在线阅读(就像这个)
- Go 有着良好定义(和文档)的语法。(不像 C++ 或 Ruby)
- Go 自带包管理工具。
go get X
(例如go get code.google.com/p/go.net/websocket
) - 跟其他语言一样,Go 有编码样式指引,有一些是编译器强制的(例如大写和小写),而其他一些仅仅是约定,不过它还是提供了整理代码的工具:
gofmt name_of_file.go
。 - 还有工具
go fix
可以将 Go 代码从早版本自动迁移到新的版本 - Go 带有测试工具来测试包:
go test /path/to/package
。它还可以进行性能评估。 - 可以调试 和评估 Go 程序。
- 你知道有个游乐场可以在线尝试 Go 吗?
- 通过 cgo Go 可以整合 C 的库。
这些都已经有一些例子了,不过这里我想聚焦在一个不怎么为人所知的话题:Go 可以无缝调用汇编编写的函数。
如何在 Go 中使用汇编
假设我们需要编写一个汇编版本的 sum
函数。首先创建一个叫做 sum.go
的文件,内容如下:
package sum func Sum(xs []int64) int64 { var n int64 for _, v := range xs { n += v } return n }
这个函数将一个整型的 slice 相加,并返回结果。为了测试这个函数,创建一个叫做 sum_test.go
的文件,内容如下:
package sum import ( "testing" ) type ( testCase struct { n int64 xs []int64 } ) var ( cases = []testCase{ { 0, []int64{} }, { 15, []int64{1,2,3,4,5} }, } ) func TestSum(t *testing.T) { for _, tc := range cases { n := Sum(tc.xs) if tc.n != n { t.Error("Expected", tc.n, "got", n, "for", tc.xs) } } }
为你的代码编写测试是个不错的主意,不但可以检验库的代码(只要不是 |译注:package main 中的方法也是可以使用 package main
go test
进行测试的),还是一个用于试验的好方法。在命令行输入 go test
就可以运行这个测试。
现在让我们用汇编来代替这个函数。我们可以来看看 Go 编译器到底生成了什么。用命令 go tool 6g -S sum.go
来代替 go test
或者 go build
(对于 64 位来说)。你会得到下面的内容:
--- prog list "Sum" --- 0000 (sum.go:3) TEXT Sum+0(SB),$16-24 0001 (sum.go:4) MOVQ $0,SI 0002 (sum.go:5) MOVQ xs+0(FP),BX 0003 (sum.go:5) MOVQ BX,autotmp_0000+-16(SP) 0004 (sum.go:5) MOVL xs+8(FP),BX 0005 (sum.go:5) MOVL BX,autotmp_0000+-8(SP) 0006 (sum.go:5) MOVL xs+12(FP),BX 0007 (sum.go:5) MOVL BX,autotmp_0000+-4(SP) 0008 (sum.go:5) MOVL $0,AX 0009 (sum.go:5) MOVL autotmp_0000+-8(SP),DI 0010 (sum.go:5) LEAQ autotmp_0000+-16(SP),BX 0011 (sum.go:5) MOVQ (BX),CX 0012 (sum.go:5) JMP ,14 0013 (sum.go:5) INCL ,AX 0014 (sum.go:5) CMPL AX,DI 0015 (sum.go:5) JGE ,20 0016 (sum.go:5) MOVQ (CX),BP 0017 (sum.go:5) ADDQ $8,CX 0018 (sum.go:6) ADDQ BP,SI 0019 (sum.go:5) JMP ,13 0020 (sum.go:8) MOVQ SI,.noname+16(FP) 0021 (sum.go:8) RET , sum.go:3: Sum xs does not escape
汇编是相当难理解的,一会我们会详细了解一下这个部分……不过,首先用这个作为模板接着往下做。在 sum.go
同一目录创建一个叫做 sum_amd64.s
的文件,内容如下:
// func Sum(xs []int64) int64 TEXT ·Sum(SB),$0 MOVQ $0,SI MOVQ xs+0(FP),BX MOVQ BX,autotmp_0000+-16(SP) MOVL xs+8(FP),BX MOVL BX,autotmp_0000+-8(SP) MOVL xs+12(FP),BX MOVL BX,autotmp_0000+-4(SP) MOVL $0,AX MOVL autotmp_0000+-8(SP),DI LEAQ autotmp_0000+-16(SP),BX MOVQ (BX),CX JMP L2 L1: INCL AX L2: CMPL AX,DI JGE L3 MOVQ (CX),BP ADDQ $8,CX ADDQ BP,SI JMP L1 L3: MOVQ SI,.noname+16(FP) RET
基本上,我所做的所有处理就是将硬编码的用于跳转(JMP,JGE)的行号替换为标签,并且在函数名前增加了中点符(·)。(确保文件保存为 UTF-8 编码)接下来,从 sum.go
中移除我们的函数定义:
package sum func Sum(xs []int64) int64
现在,应当可以用 go test
运行测试,它将使用自定义的汇编版本的函数。
工作原理
这里对汇编做一些更为详细的说明。我将简短的说明一下它做了什么。
MOVQ $0,SI
首先,将 0 放入 SI(源变址)寄存器,它表示执行的指令的位置。Q 表示四个字,8 比特,下面还会看到 L 表示 4 比特。参数的顺序是(源,目标)。
MOVQ xs+0(FP),BX MOVQ BX,autotmp_0000+-16(SP) MOVL xs+8(FP),BX MOVL BX,autotmp_0000+-8(SP) MOVL xs+12(FP),BX MOVL BX,autotmp_0000+-4(SP)
接下来接收传入的参数,并将其值保存在栈上。一个 Go 的 slice 有三个部分:指向其所在的内存的指针、长度和容量。指针是 8 比特,长度和容量都是 4 比特。因此这段代码从 BX 寄存器复制了这些值出来。(参阅这里了解更多关于 slice 的细节)
MOVL $0,AX MOVL autotmp_0000+-8(SP),DI LEAQ autotmp_0000+-16(SP),BX MOVQ (BX),CX
接下来,将 0 放入 AX,用于循环变量。将 slice 的长度放入 DI,并且加载指向 xs 元素的指针到 CX。
JMP L2 L1: INCL AX L2: CMPL AX,DI JGE L3
现在到达代码的主体。首先跳转到 L2 比较 AX 和 DI。如果相等,说明已经计算了 slice 中的所有元素,因此跳到 L3。(也就是 i == len(xs)
)。
MOVQ (CX),BP ADDQ $8,CX ADDQ BP,SI JMP L1
这里进行了求和。首先从 CX 中获取值保存到 BP。然后将 CX 向前移动 8 字节。最后将 BP 加到 SI 并跳转到 L1。L1 增加 AX 并且再次开始循环。
L3: MOVQ SI,.noname+16(FP) RET
结束求和后,将结果保存在传递到函数的所有的参数之后(由于一个 slice 是 16 字节,所以这里是 16 字节)。这时就返回了。
重写
这里我重写了代码:
// func Sum(xs []int64) int64 TEXT ·Sum2(SB),7,$0 MOVQ $0, SI // n MOVQ xs+0(FP), BX // BX = &xs[0] MOVL xs+8(FP), CX // len(xs) MOVLQSX CX, CX // len as int64 INCQ CX // CX++ start: DECQ CX // CX-- JZ done // jump if CX = 0 ADDQ (BX), SI // n += *BX ADDQ $8, BX // BX += 8 JMP start done: MOVQ SI, .noname+16(FP) // return n RET
希望这会更容易理解一些。
忠告
可以这么做当然很酷,但是不要忽视了这些忠告:
- 汇编很难编写,特别是很难写好。通常编译器会比你写出更快的代码(从前文来看,Go 编译器会做得更好)。
- 汇编仅能运行在一个平台上。在这个例子中,代码仅能运行在 amd64 上。这个问题有一个解决方案是给 Go 对于 x86 和 arm 不同版本的代码(像这样)。
- 汇编让你和底层绑定在一起,而标准的 Go 不会。例如,slice 的长度当前是 32 位整数。但是也不是不可能为长整型。当发生这些变化时,这些代码就被破坏了(也可能是编译器无法检测到的更恶心的途径来破坏)
- 当前 Go 编译器不能将汇编编译为函数的内联,但是对于小的 Go 函数是可以的。因此使用汇编可能意味着让你的程序更慢。
对于下面的两个原因,这还是很有用的:
- 由于它非常容易实践,所以这绝对是个学习汇编的好途径。
有疑问加站长微信联系(非本文作者)