[翻译]Go 和汇编

使用 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 函数是可以的。因此使用汇编可能意味着让你的程序更慢。

对于下面的两个原因,这还是很有用的:

      有时需要汇编给你带来一些力量(不论是性能方面的原因,还是一些相当特殊的关于 CPU 的操作)。对于什么时候应该使用它,Go 源码包括了若干相当好的例子(可以看看 cryptomath)。
      由于它非常容易实践,所以这绝对是个学习汇编的好途径。

Join the Conversation

4 Comments

  1. 最近在学汇编,刚好看到这个,就爬过来了= =||
    话说汇编已经够反人类了,Intel还把格式搞成 opcode dest, src 这样的,于是转AT&T了= =||

  2. 好文!

    PS:
    前面有一段: “为你的代码编写测试是个不错的主意,不但可以检验库的代码(只要不是 package main),还是一个用于试验的好方法。” 有些不准确. 好像main也可以go test.

  3. 测试里面这一段看起来不是很简洁:
    type (
    testCase struct {
    n int64
    xs []int64
    }
    )

    var (
    cases = []testCase{
    { 0, []int64{} },
    { 15, []int64{1,2,3,4,5} },
    }
    )
    个人认为简洁的写法如下:
    var cases = []struct {
    n int64
    xs []int64
    }{
    { 0, []int64{} },
    { 15, []int64{1,2,3,4,5} },
    )

Leave a comment

Your email address will not be published. Required fields are marked *