Golang 汇编入门知识总结

作者:ivansli,腾讯 IEG 运营开发工程师

在深入学习 Golang 的 runtime 和标准库实现的时候发现,如果对 Golang 汇编没有一定了解的话,很难深入了解其底层实现机制。在这里整理总结了一份基础的 Golang 汇编入门知识,通过学习之后能够对其底层实现有一定的认识。

0. 为什么写本文

平时业务中一直使用 PHP 编写代码,但是一直对 Golang 比较感兴趣,闲暇、周末之余会看一些 Go 底层源码。

近日在分析 go 的某些特性底层功能实现时发现:有些又跟 runtime 运行时有关,而要掌握这一部分的话,有一道坎是绕不过去的,那就是 Go 汇编。索性就查阅了很多大佬们写的资料,在阅读之余整理总结了一下,并在这里分享给大家。

本文使用 Go 版本为 go1.14.1

1. 为什么需要汇编

众所周知,在计算机的世界里,只有 2 种类型。那就是:0 和 1。

计算机工作是由一系列的机器指令进行驱动的,这些指令又是一组二进制数字,其对应计算机的高低电平。而这些机器指令的集合就是机器语言,这些机器语言在最底层是与硬件一一对应的。

显而易见,这样的机器指令有一个致命的缺点:可阅读性太差(恐怕也只有天才和疯子才有能力把控得了)。

为了解决可读性的问题以及代码编辑的需求,于是就诞生了最接近机器的语言:汇编语言(在我看来,汇编语言更像一种助记符,这些人们容易记住的每一条助记符都映射着一条不容易记住的由 0、1 组成的机器指令。你觉得像不像域名与 IP 地址的关系呢?)。

1.1 程序的编译过程

以 C 语言为例来说,从 hello.c 的源码文件到 hello 可执行文件,经过编译器处理,大致分为几个阶段:

编译器在不同的阶段会做不同的事情,但是有一步是可以确定的,那就是:源码会被编译成汇编,最后才是二进制。

2. 程序与进程

源码经过编译之后,得到一个二进制的可执行文件文件这两个字也就表明,目前得到的这个文件跟其他文件对比,除了是具有一定的格式(Linux 中是 ELF 格式,即:可运行可链接。executable linkable formate)的二进制组成,并没什么区别。

在 Linux 中文件类型大致分为 7 种:

b: 块设备文件
c:字符设备文件
d:目录
-:普通文件
l:链接
s:socket
p:管道

通过上面可以看到,可执行文件 main 与源码文件 main.go,都是同一种类型,属于普通文件。(当然了,在 Unix 中有一句很经典的话:一切皆文件)。

那么,问题来了:

  1. 什么是程序?

  2. 什么是进程?

2.1 程序

维基百科告诉我们:程序是指一组指示计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。

从某个层面来看,可以把程序分为静态程序、动态程序:静态程序:单纯的指具有一定格式的可执行二进制文件。动态程序:则是静态可执行程序文件被加载到内存之后的一种运行时模型(又称为进程)。

2.2 进程

首先,要知道的是,进程是分配系统资源的最小单位,线程(带有时间片的函数)是系统调度的最小单位。进程包含线程,线程所属于进程。

创建进程一般使用 fork 方法(通常会有个拉起程序,先 fork 自身生成一个子进程。然后,在该子进程中通过 exec 函数把对应程序加载进来,进而启动目标进程。当然,实际上要复杂得多),而创建线程则是使用 pthread 线程库。

以 32 位 Linux 操作系统为例,进程经典的虚拟内存结构模型如下图所示:

其中,有两处结构是静态程序所不具有的,那就是运行时堆(heap)运行时栈(stack)

运行时堆从低地址向高地址增长,申请的内存空间需要程序员自己或者由 GC 释放。运行时栈从高地址向低地址增长,内存空间在当前栈桢调用结束之后自动释放(并不是清除其所占用内存中数据,而是通过栈顶指针 SP 的移动,来标识哪些内存是正在使用的)。

3. Go 汇编

对于 Go 编译器而言,其输出的结果是一种抽象可移植的汇编代码,这种汇编(Go 的汇编是基于 Plan9 的汇编)并不对应某种真实的硬件架构。Go 的汇编器会使用这种伪汇编,再为目标硬件生成具体的机器指令。

伪汇编这一个额外层可以带来很多好处,最主要的一点是方便将 Go 移植到新的架构上。

相关的信息可以参考 Rob PikeThe Design of the Go Assembler

要了解 Go 的汇编器最重要的是要知道 Go 的汇编器不是对底层机器的直接表示,即 Go 的汇编器没有直接使用目标机器的汇编指令。Go 汇编器所用的指令,一部分与目标机器的指令一一对应,而另外一部分则不是。这是因为编译器套件不需要汇编器直接参与常规的编译过程。

相反,编译器使用了一种半抽象的指令集,并且部分指令是在代码生成后才被选择的。汇编器基于这种半抽象的形式工作,所以虽然你看到的是一条 MOV 指令,但是工具链针对对这条指令实际生成可能完全不是一个移动指令,也许会是清除或者加载。也有可能精确的对应目标平台上同名的指令。概括来说,特定于机器的指令会以他们的本尊出现, 然而对于一些通用的操作,如内存的移动以及子程序的调用以及返回通常都做了抽象。细节因架构不同而不一样,我们对这样的不精确性表示歉意,情况并不明确。

汇编器程序的工作是对这样半抽象指令集进行解析并将其转变为可以输入到链接器的指令。

The most important thing to know about Go’s assembler is that it is not a direct representation of the underlying machine. Some of the details map precisely to the machine, but some do not. This is because the compiler suite needs no assembler pass in the usual pipeline. Instead, the compiler operates on a kind of semi-abstract instruction set, and instruction selection occurs partly after code generation. The assembler works on the semi-abstract form, so when you see an instruction like MOV what the toolchain actually generates for that operation might not be a move instruction at all, perhaps a clear or load.

Or it might correspond exactly to the machine instruction with that name. In general, machine-specific operations tend to appear as themselves, while more general concepts like memory move and subroutine call and return are more abstract. The details vary with architecture, and we apologize for the imprecision; the situation is not well-defined.

The assembler program is a way to parse a description of that semi-abstract instruction set and turn it into instructions to be input to the linker.

Go 汇编使用的是caller-save模式,被调用函数的入参参数、返回值都由调用者维护、准备。因此,当需要调用一个函数时,需要先将这些工作准备好,才调用下一个函数,另外这些都需要进行内存对齐,对齐的大小是 sizeof(uintptr)。

3.1 几个概念

在深入了解 Go 汇编之前,需要知道的几个概念:

  • 栈:进程、线程、goroutine 都有自己的调用栈,先进后出(FILO)

  • 栈帧:可以理解是函数调用时,在栈上为函数所分配的内存区域

  • 调用者:caller,比如:A 函数调用了 B 函数,那么 A 就是调用者

  • <
  • 3
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值