golang的汇编是基于Plan9汇编烟花而来。

GOARCh = amd64, GOOS = linux

快速入门

实现和声明

Go汇编语言并不是一个独立的语言,主要原因是因为Go汇编程序无法独立使用。Go汇编代码必须以Go包的方式被组织,同时包中至少要有一个Go语言文件。如果Go汇编代码中定义的变量和函数要被其它Go语言代码引用,还需要通过Go语言代码将汇编中定义的符号声明出来。用于变量的定义和函数的定义Go汇编文件类似于C语言中的.c文件。而用于导出汇编中定义符号的Go源文件类似于C语言的.h文件。

定义整数变量

为了简单,我们先用Go语言定义并赋值一个整数变量,然后查看生成的汇编代码。

pkg.go
package pkg

var Id = 9527

然后用以下命令查看的Go语言程序对应的伪汇编代码:

go tool compile -S pkg.gogo build -gcflags -S pkg.go
> go tool compile -S pkg.go

go.cuinfo.packagename. SDWARFINFO dupok size=0
        0x0000 70 6b 67                                         pkg
"".Id SNOPTRDATA size=8
        0x0000 37 25 00 00 00 00 00 00                          7%......
Id37 25 00 00 00 00 00 000x25379527
"".Id SNOPTRDATA size=8
        0x0000 37 25 00 00 00 00 00 00 

以上的内容只是目标文件对应的汇编,和Go汇编语言虽然相似但并不完全等价。Go语言官网自带了一个Go汇编语言的入门教程,地址在:https://golang.org/doc/asm。

接下来,我们换一种写法,使用Go代码来声明变量,使用GO汇编来初始化其值。

Go汇编语言提供了DATA命令用于初始化变量,DATA命令的语法如下:

DATA symbol+offset(SB)/width, value
·symbol·

采用以下命令可以给Id变量初始化为十六进制的0x2537,对应十进制的9527,常量需要以美元符号$开头表示:

DATA ·Id+0(SB)/1,$0x37
DATA ·Id+1(SB)/1,$0x25

变量定义好之后需要导出以供其它代码引用。Go汇编语言提供了GLOBL命令用于将符号导出:

GLOBL symbol(SB), width
·Id
GLOBL ·Id, $8

为了便于其它包使用该Id变量,我们还需要在Go代码中声明该变量,同时也给变量指定一个合适的类型。修改pkg.go的内容如下:

package pkg

var Id int
pkg_amd64.s
GLOBL ·Id(SB),$8

DATA ·Id+0(SB)/1,$0x37
DATA ·Id+1(SB)/1,$0x25
DATA ·Id+2(SB)/1,$0x00
DATA ·Id+3(SB)/1,$0x00
DATA ·Id+4(SB)/1,$0x00
DATA ·Id+5(SB)/1,$0x00
DATA ·Id+6(SB)/1,$0x00
DATA ·Id+7(SB)/1,$0x00

虽然pkg包改用汇编实现,但是用法和之前完全一样:

package main

import "demo5/pkg"

func main() {
	println(pkg.Id) // 9527
}

定义字符串变量

虽然从Go语言角度看,定义字符串和整数变量的写法基本相同,但是字符串底层却有着比单个整数更复杂的数据结构。

pkg/str.go
package pkg

var Name = "gopher"
> go tool compile -S pkg/str.go

go.cuinfo.packagename. SDWARFINFO dupok size=0
        0x0000 70 6b 67                                         pkg
go.string."gopher" SRODATA dupok size=6
        0x0000 67 6f 70 68 65 72                                gopher
"".Name SDATA size=16
        0x0000 00 00 00 00 00 00 00 00 06 00 00 00 00 00 00 00  ................
        rel 0+8 t=1 go.string."gopher"+0
go.string."gopher"go.string."gopher"

而真正的Go字符串变量Name对应的大小却只有16个字节了。其实Name变量并没有直接对应“gopher”字符串,而是对应reflect.StringHeader结构体:

type reflect.StringHeader struct {
	Data uintptr
	Len  int
}

从汇编角度看,Name变量其实对应的是reflect.StringHeader结构体类型。前8个字节对应底层真实字符串数据的指针,也就是符号go.string."gopher"对应的地址。后8个字节对应底层真实字符串数据的有效长度,这里是6个字节。

pkg/str_amd64.s
GLOBL ·NameData(SB),$8
DATA  ·NameData(SB)/8,$"gopher"

GLOBL ·Name(SB),$16
DATA  ·Name+0(SB)/8,$·NameData(SB)
DATA  ·Name+8(SB)/8,$6

因为在Go汇编语言中,go.string."gopher"不是一个合法的符号,我们无法手工创建(这是给编译器保留的部分特权,因为手工创建类似符号可能打破编译器输出代码的某些规则)。因此我们新创建了一个·NameData符号表示底层的字符串数据。

然后定义·Name符号为两个16字节,其中前8个字节用·NameData符号对应的地址初始化,后8个字节为常量6表示字符串长度。

pkg/str.go
package pkg

var Name string
demo5/pkg.NameData: missing Go type information for global symbol: size 8

提示汇编中定义的NameData符号没有类型信息。其实Go汇编语言中定义的数据并没有所谓的类型,每个符号只不过是对应一个内存而且。出现这种错误的原因是,Go语言的垃圾回收器在扫描NameData变量的时候,无法知晓该变量内部是否包含指针。因此,真正错误的原因并不是NameData没有类型,二是NameData变量没有标注是否会含有指针信息。

通过给NameData变量增加一个标志,表示其中不会包含指针数据可以修复该错误:

#include "textflag.h"

GLOBL ·NameData(SB),NOPTR,$8
DATA  ·NameData(SB)/8,$"gopher"

GLOBL ·Name(SB),$16
DATA  ·Name+0(SB)/8,$·NameData(SB)
DATA  ·Name+8(SB)/8,$6

通过给·NameData增加NOPTR,表示其中不含指针数据。那么垃圾回收器在遇到该变量的时候就会停止内部数据的扫描。

定义main函数

先创建main.go文件

package main

var helloworld = "你好, 世界"

func main()

然后创建main_amd64.s文件,里面对应main函数的实现:

TEXT ·main(SB), $16-0
	MOVQ ·helloworld+0(SB), AX; MOVQ AX, 0(SP)
	MOVQ ·helloworld+8(SB), BX; MOVQ BX, 8(SP)
	CALL runtime·printstring(SB)
	CALL runtime·printnl(SB)
	RET
	
TEXT ·main(SB), $16-0main$16-0mainruntime·printstring0mainmainruntime·printstring(SB)runtime·printnl

Go语言函数在函数调用时,完全通过栈传递调用参数和返回值。先通过MOVQ指令,将helloworld对应的字符串头部结构体的16个字节复制到栈指针SP对应的16字节的空间,然后通过CALL指令调用对应函数。最后使用RET指令表示当前函数返回。

特殊字符

Go语言函数或方法符号在编译为目标文件后,目标文件中的每个符号均包含对应包的绝对导入路径。因此目标文件的符号可能非常复杂,比如“path/to/pkg.(*SomeType).SomeMethod”或“go.string.“abc””。目标文件的符号名中不仅仅包含普通的字母,还可能包含诸多特殊字符。而Go语言的汇编器是从plan9移植过来的二把刀,并不能处理这些特殊的字符,导致了用Go汇编语言手工实现Go诸多特性时遇到种种限制。

·/U+00B7U+2215·math/rand·Intmath/rand.Int
·/
·~·

没有分号

Go汇编语言中分号可以用于分隔同一行内的多个语句。下面是用分号混乱排版的汇编代码:

TEXT ·main(SB), $16-0; MOVQ ·helloworld+0(SB), AX; MOVQ ·helloworld+8(SB), BX;
MOVQ AX, 0(SP);MOVQ BX, 8(SP);CALL runtime·printstring(SB);
CALL runtime·printnl(SB);
RET;

和Go语言一样,也可以省略行尾的分号。当遇到末尾时,汇编器会自动插入分号。下面是省略分号后的代码:

TEXT ·main(SB), $16-0
	MOVQ ·helloworld+0(SB), AX; MOVQ AX, 0(SP)
	MOVQ ·helloworld+8(SB), BX; MOVQ BX, 8(SP)
	CALL runtime·printstring(SB)
	CALL runtime·printnl(SB)
	RET

和Go语言一样,语句之间多个连续的空白字符和一个空格是等价的。

预定义的虚拟寄存器

FPPCSBSP

SB伪寄存器可以想象成内存的地址,所以符号foo(SB)是一个由foo这个名字代表的内存地址。这种形式一般用来命名全局函数和数据。给名字增加一个**<>符号,就像foo<>(SB),会让这个名字只有在当前文件可见,就像在C文件中预定义的static**。

FP伪寄存器是一个虚拟的帧指针,用来指向函数的参数。编译器维护了一个虚拟的栈指针,使用对伪寄存器的offsets操作的形式,指向栈上的函数参数。 于是,0(FP)就是第一个参数,8(FP)就是第二个(64位机器),以此类推。 当用这种方式引用函数参数时,可以很方便的在符号前面加上一个名称,就像first_arg+0(FP)second_arg+8(FP)。有些汇编程序强制使用这种约定,禁止单一的0(FP)8(FP)。在使用Go标准定义的汇编函数中,go vet会检查参数的名字和它们的匹配范围。 在32位系统上,一个64位值的高32和低32位表示为增加**_lo_hi这个两个后缀到一个名称,就像arg_lo+0(FP)或者arg_hi+4(FP)**。如果一个Go原型函数没有命名它的结果,期待的名字将会被返回。

SP伪寄存器是一个虚拟的栈指针,用来指向栈帧本地的变量为函数调用准备参数。它指向本地栈帧的顶部,所以一个对栈帧的引用必须是一个负值且范围在**[-framesize:0]之间,例如: x-8(SP)y-4(SP),以此类推。在CPU架构中,存在一个真实的寄存器SP**,虚拟的栈寄存器和真实的SP寄存器的区别在于名字的前缀上。就是说,x-8(SP)-8(SP)是不同的内存地址:前者是引用伪栈指针寄存器,但后者是硬件中真实存在的SP寄存器。

其他

参考文档

https://go.dev/doc/asm

https://studygolang.com/articles/2917