作者:ivansli,腾讯 IEG 运营开发工程师
在深入学习 Golang 的 runtime 和标准库实现的时候发现,如果对 Golang 汇编没有一定了解的话,很难深入了解其底层实现机制。在这里整理总结了一份基础的 Golang 汇编入门知识,通过学习之后能够对其底层实现有一定的认识。
平时业务中一直使用 PHP 编写代码,但是一直对 Golang 比较感兴趣,闲暇、周末之余会看一些 Go 底层源码。
近日在分析 go 的某些特性底层功能实现时发现:有些又跟 runtime 运行时有关,而要掌握这一部分的话,有一道坎是绕不过去的,那就是 Go 汇编。索性就查阅了很多大佬们写的资料,在阅读之余整理总结了一下,并在这里分享给大家。
众所周知,在计算机的世界里,只有 2 种类型。那就是:0 和 1。
计算机工作是由一系列的机器指令进行驱动的,这些指令又是一组二进制数字,其对应计算机的高低电平。而这些机器指令的集合就是机器语言,这些机器语言在最底层是与硬件一一对应的。
显而易见,这样的机器指令有一个致命的缺点:可阅读性太差
(恐怕也只有天才和疯子才有能力把控得了)。
为了解决可读性的问题以及代码编辑的需求,于是就诞生了最接近机器的语言:汇编语言(在我看来,汇编语言更像一种助记符,这些人们容易记住的每一条助记符都映射着一条不容易记住的由 0、1 组成的机器指令。你觉得像不像域名与 IP 地址的关系呢?)。
1.1 程序的编译过程
以 C 语言为例来说,从 hello.c 的源码文件到 hello 可执行文件,经过编译器处理,大致分为几个阶段:
编译器在不同的阶段会做不同的事情,但是有一步是可以确定的,那就是:源码会被编译成汇编,最后才是二进制。
源码经过编译之后,得到一个二进制的可执行文件
。文件
这两个字也就表明,目前得到的这个文件跟其他文件对比,除了是具有一定的格式(Linux 中是 ELF 格式,即:可运行可链接。executable linkable formate)的二进制组成,并没什么区别。
在 Linux 中文件类型大致分为 7 种:
通过上面可以看到,可执行文件 main 与源码文件 main.go,都是同一种类型,属于普通文件。(当然了,在 Unix 中有一句很经典的话:一切皆文件
)。
维基百科告诉我们:程序
是指一组指示计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。
从某个层面来看,可以把程序分为静态程序、动态程序:静态程序:单纯的指具有一定格式的可执行二进制文件。动态程序:则是静态可执行程序文件被加载到内存之后的一种运行时模型(又称为进程)。
首先,要知道的是,进程
是分配系统资源的最小单位,线程
(带有时间片的函数)是系统调度的最小单位。进程包含线程,线程所属于进程。
创建进程一般使用 fork 方法(通常会有个拉起程序,先 fork 自身生成一个子进程。然后,在该子进程中通过 exec 函数把对应程序加载进来,进而启动目标进程。当然,实际上要复杂得多),而创建线程则是使用 pthread 线程库。
以 32 位 Linux 操作系统为例,进程经典的虚拟内存结构模型如下图所示:
其中,有两处结构是静态程序所不具有的,那就是运行时堆(heap)
与运行时栈(stack)
。
运行时堆
从低地址向高地址增长,申请的内存空间需要程序员自己或者由 GC 释放。运行时栈
从高地址向低地址增长,内存空间在当前栈桢调用结束之后自动释放(并不是清除其所占用内存中数据,而是通过栈顶指针 SP 的移动,来标识哪些内存是正在使用的)。
对于 Go 编译器而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编(Go 的汇编是基于 Plan9 的汇编)并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。
伪汇编
这一个额外层可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上。
要了解 Go 的汇编器最重要的是要知道 Go 的汇编器不是对底层机器的直接表示,即 Go 的汇编器没有直接使用目标机器的汇编指令。Go 汇编器所用的指令,一部分与目标机器的指令一一对应,而另外一部分则不是。这是因为编译器套件不需要汇编器直接参与常规的编译过程。
相反,编译器使用了一种半抽象的指令集,并且部分指令是在代码生成后才被选择的。汇编器基于这种半抽象的形式工作,所以虽然你看到的是一条 MOV 指令,但是工具链针对对这条指令实际生成可能完全不是一个移动指令,也许会是清除或者加载。也有可能精确的对应目标平台上同名的指令。概括来说,特定于机器的指令会以他们的本尊出现, 然而对于一些通用的操作,如内存的移动以及子程序的调用以及返回通常都做了抽象。细节因架构不同而不一样,我们对这样的不精确性表示歉意,情况并不明确。
汇编器程序的工作是对这样半抽象指令集进行解析并将其转变为可以输入到链接器的指令。
Go 汇编使用的是caller-save
模式,被调用函数的入参参数、返回值都由调用者维护、准备。因此,当需要调用一个函数时,需要先将这些工作准备好,才调用下一个函数,另外这些都需要进行内存对齐,对齐的大小是 sizeof(uintptr)。
在深入了解 Go 汇编之前,需要知道的几个概念:
go 汇编中有 4 个核心的伪寄存器,这 4 个寄存器是编译器用来维护上下文、特殊标识等作用的:
务必注意
:对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件 SP 寄存器,无论是否带 symbol(这一点非常具有迷惑性,需要慢慢理解。往往在分析编译输出的汇编时,看到的就是硬件 SP 寄存器)。
下图描述了栈桢与各个寄存器的内存关系模型,值得注意的是要站在 callee 的角度来看
有一点需要注意的是,return addr 也是在 caller 的栈上的,不过往栈上插 return addr 的过程是由 CALL 指令完成的(在分析汇编时,是看不到关于 addr 相关空间信息的。在分配栈空间时,addr 所占用空间大小不包含在栈帧大小内)。
在 AMD64 环境,伪 PC 寄存器其实是 IP 指令计数器寄存器的别名。伪 FP 寄存器对应的是 caller 函数的帧指针,一般用来访问 callee 函数的入参参数和返回值。伪 SP 栈指针对应的是当前 callee 函数栈帧的底部(不包括参数和返回值部分),一般用于定位局部变量。伪 SP 是一个比较特殊的寄存器,因为还存在一个同名的 SP 真寄存器,真 SP 寄存器对应的是栈的顶部。
在编写 Go 汇编时,当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真 SP 寄存器,而 a(SP)、b+8(SP)有标识符为前缀表示伪寄存器。
我们这里对容易混淆的几点简单进行说明:
在 plan9 汇编里还可以直接使用的 amd64 的通用寄存器,应用代码层面会用到的通用寄存器主要是: rax, rbx, rcx, rdx, rdi, rsi, r8~r15 这些寄存器,虽然 rbp 和 rsp 也可以用,不过 bp 和 sp 会被用来管理栈顶和栈底,最好不要拿来进行运算。
下面是通用通用寄存器的名字在 IA64 和 plan9 中的对应关系:
下面列出了常用的几个汇编指令(指令后缀Q
说明是 64 位上的汇编指令)
对于写好的 go 源码,生成对应的 Go 汇编,大概有下面几种
编译 go 源代码,输出汇编
这里列举了一个简单的 int 类型加法
示例,实际开发中会遇到各种参数类型,要复杂的多,这里只是抛砖引玉 :)
针对 4.2 输出汇编,对重要核心代码进行分析。
(SB)
SB 是一个虚拟的伪寄存器,保存静态基地址(static-base) 指针,即我们程序地址空间的开始地址。"".add(SB)
表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。
NOSPLIT:
向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令。在我们 add 函数的这种情况下,编译器自己帮我们插入了这个标记: 它足够聪明地意识到,由于 add 没有任何局部变量且没有它自己的栈帧,所以一定不会超出当前的栈。不然,每次调用函数时,在这里执行栈检查就是完全浪费 CPU 时间了。
24 指定了调用方传入的参数+返回值大小(24 字节=入参 a、b 大小8字节*2
+返回值8字节
)
SUBQ $16, SP
SP 为栈顶指针,该语句等价于 SP-=16(由于栈空间是向下增长的,所以开辟栈空间时为减操作),表示生成 16 字节大小的栈空间。
MOVQ $0, "".~r2+40(SP)
此时的 SP 为 add 函数栈的栈顶指针,40(SP)的位置则是 add 返回值的位置,该位置位于 main 函数栈空间内。该语句设置返回值类型的 0 值,即初始化返回值,防止得到脏数据(返回值类型为 int,int 的 0 值为 0)。
还记得前面提到的,Go 汇编使用的是caller-save
模式,被调用函数的参数、返回值、栈位置都需要由调用者维护、准备吗?
在函数栈桢结构中可以看到,add()函数的入参以及返回值都由调用者 main()函数维护。也正是因为如此,GO 有了其他语言不具有的,支持多个返回值的特性。
这里重点讲一下函数声明、变量声明。
来看一个典型的 Go 汇编函数定义
Go 汇编实现为什么是 TEXT
开头?仔细观察上面的进程内存布局图就会发现,我们的代码在是存储在.text 段中的,这里也就是一种约定俗成的起名方式。实际上在 plan9 中 TEXT 是一个指令,用来定义一个函数。
定义中的 pkgname 是可以省略的,(非想写也可以写上,不过写上 pkgname 的话,在重命名 package 之后还需要改代码,默认为""
) 编译器会在链接期自动加上所属的包名称。
中点 ·
比较特殊,是一个 unicode 的中点,该点在 mac 下的输入方法是 option+shift+9。在程序被链接之后,所有的中点·
都会被替换为句号.
,比如你的方法是runtime·main
,在编译之后的程序里的符号则是runtime.main
。
简单总结一下, Go 汇编实现函数声明,格式为:
"".add(SB)
表明我们的符号位于某个固定的相对地址空间起始处的偏移位置 (最终是由链接器计算得到的)。换句话来讲,它有一个直接的绝对地址: 是一个全局的函数符号。
汇编里的全局变量,一般是存储在.rodata
或者.data
段中。对应到 Go 代码,就是已初始化过的全局的 const、var 变量/常量。
大多数参数都是字面意思,不过这个 offset 需要注意:其含义是该值相对于符号 symbol 的偏移,而不是相对于全局某个地址的偏移。
GLOBL 汇编指令用于定义名为 symbol 的全局变量,变量对应的内存宽度为 width,内存宽度部分必须用常量初始化。
下面是定义了多个变量的例子:
大部分都比较好理解,不过这里引入了新的标记<>
,这个跟在符号名之后,表示该全局变量只在当前文件中生效,类似于 C 语言中的 static。如果在另外文件中引用该变量的话,会报 relocation target not found 的错误。
在 Go 源码中会看到一些汇编写的代码,这些代码跟其他 go 代码一起组成了整个 go 的底层功能实现。下面,我们通过一个简单的 Go 汇编代码示例来实现两数相加功能。
Go 源码中 add()函数只有函数签名,没有具体的实现(使用 GO 汇编实现)
把 Go 源码与 Go 汇编编译到一起(我这里,这两个文件在同一个目录)
我这里目录为 demo1,所以得到可执行程序 demo1,运行得到结果:5
对 5.1 中得到的可执行程序 demo1 使用 objdump 进行反编译,获取汇编代码
这里推荐 2 个 Go 代码调试工具。
常用的 gdb 调试命令
效果如下图所示,分两个窗口:上面显示源代码,下面是具体的命令行调试界面(跟 gdb 一样):
带图形化界面的 dlv 项目地址
dlv 的安装使用,这里不再做过多讲解,感兴趣的可以尝试一下。
对于 Go 汇编基础大致需要熟悉下面几个方面:
通过上面的例子相信已经让你对 Go 的汇编有了一定的理解。当然,对于大部分业务开发人员来说,只要看的懂即可。如果想进一步的了解,可以阅读相关的资料或者书籍。
最后想说的是:鉴于个人能力有限,在阅读过程中你可能会发现存在的一些问题或者缺陷,欢迎各位大佬指正。如果感兴趣的话,也可以一起私下交流。
asm调用test会输出乱码,为什么会这样呢?
使用的调用子程序则不会 将call_asm的a1参数改为整型, 调用时传递指向文本数据的指针就不会乱码,为什么会这样呢? 在我目前的理解中 , a1参数为文本型时, 易语言调用call_asm的时候实际传递的不也是指向文本数据的指针吗?那为何会乱码呢?
回答提醒:如果本帖被关闭无法回复,您有更好的答案帮助楼主解决,请发表至 可获得加分喔。 |