gitbook/手把手带你写一门编程语言/docs/416043.md

211 lines
18 KiB
Markdown
Raw Normal View History

2022-09-03 22:05:03 +08:00
# 14汇编代码学习熟悉CPU架构和指令集
你好,我是宫文学。
经过了上一节课的学习,你已经对物理机的运行期机制有了一定的了解。其中最重要的知识点就是,为了让一个程序运行起来,硬件架构、操作系统和计算机语言分别起到了什么作用?对这些知识的深入理解,是让你进入高手行列的关键。
接下来,就让我们把程序编译成汇编代码,从而生成在物理机上运行的可执行程序吧!
慢着不要太着急。为了让你打下更好的基础我决定再拿出一节课来带你了解一下CPU架构和指令集特别是ARM和X86这两种使用最广泛的CPU架构为你学习汇编语言打下良好的基础。
首先我们讨论一下什么是CPU架构以及它对学习汇编语言的作用。
## 掌握汇编语言的关键是了解CPU架构
提到汇编语言,很多同学都会觉得很高深、很难学。其实这是个误解,汇编语言并不难掌握。
为什么这么说呢其实前面在实现虚拟机的时候我们已经接触了栈机的字节码。你觉得它难吗JVM的字节码理论上不会超过128条而我们通过前面几节课已经了解了其中的好几十条指令并且已经让他们顺利地运转起来了。
而且汇编代码作为物理机的指令也不可能有多么复杂。因为CPU的设计就是要去快速地执行一条条简单的指令所以这些指令不可能像高级语言那样充满复杂的语义。
我们可以把学习汇编语言跟学习高级语言做一下类比。如果你接触过多门计算机语言,很快就能找到这些计算机语言的相似性,比如都有基础数据类型、都支持加减乘除等各种运算、都支持各种表达式和语句等等。抓住这些基本规律以后,学习一门新语言的速度会很快。
汇编语言也是一样的。不同的CPU有不同的指令集但它们的指令的格式有一些共性比如都包含操作码的助记符和操作数而操作数通常也都包含立即数、寄存器和内存地址这三个类别。它们也都包含数据处理、内存读写、跳转、子过程调用等几种大类的指令。所以你也可以很快掌握这些规律或模式做到触类旁通游刃有余。
**说了那么多,那么学习汇编语言的关键是什么呢?**
由于汇编语言是跟具体的CPU打交道的所以不同的CPU架构它们的汇编语言就会有所差别。如果你能够深刻了解某款CPU的架构自然也就会为它编写汇编代码了。
这里提到了一个词,**架构**。所谓架构是指一个处理器的功能规范定义了CPU在什么情况下会产生什么行为。你也可以把它理解成软件和硬件之间的一个桥梁规定了硬件如何提供功能被软件所调用。所以如果你要搞编译技术就必须要了解目标CPU的架构。
我把CPU架构里的内容整合在这张表里你可以保存起来
![图片](https://static001.geekbang.org/resource/image/7f/87/7f506095932158702ed85eaef6ef8387.jpg?wh=1920x1080)
看上去要深入了解一个CPU架构涉及的知识还蛮多的。不过**作为初学者,我们最重要的是关注两个方面就行了:指令集和寄存器集,因为它们跟汇编代码的关系最密切。**
那么如何了解一个CPU的架构呢正如我在[第12节课](https://time.geekbang.org/column/article/414397)说的那样其实最重要的方式就是阅读CPU的手册。
那么这节课我们就分析两种主流的CPU架构看看能获得哪些知识点。首先我们就来看一下ARM架构的CPU的特点它是目前大多数智能手机所采用的CPU。
## 了解ARM架构
ARM处理器是ARM公司推出的一系列处理器的名称。ARMv8是它比较新的架构当前大多数高端智能手机都是采用这个架构。了解这个架构的方法呢当然是下载[ARMv8的手册](https://developer.arm.com/documentation/ddi0487/latest)。
不过这本手册比较厚有8000多页。为了加速你的理解我挑其中最有用、跟编写汇编代码最相关的几个知识点跟你聊聊。
首先ARMv8支持32位和64位运行模式分别叫做AArch32和AArch64。在64位模式下它的指令集叫做A64。
接着我们看看ARMv8的寄存器。在AArch64架构下它的寄存器有下面这几个参见手册的B1.2.1部分)。
* R0-R30是31个通用寄存器。当它们被用于64位计算或32位计算的时候分别被叫做X0-X30X表示64位以及W0-W30W是Word的意思表示32位。这31个处理器是我们用做数据处理的主力。
![图片](https://static001.geekbang.org/resource/image/05/c5/05bbyy215e11822ca76947a6d86159c5.png?wh=1312x242)
* SP寄存器64位的栈指针寄存器用于指向栈顶的地址。
* PC寄存器64位的程序代码寄存器PC是Program Code的意思。这个寄存器记录了内存中当前指令的地址CPU会从这个地址读取指令并执行。当程序执行跳转指令、进入异常或退出异常的时候这个寄存器的值会被自动修改。
* 另外,还有一组寄存器是用于处理浮点数运算和矢量计算的,你可以去官方手册看看。
这些就是我们的汇编代码中会涉及到的寄存器,还有一些寄存器是系统级的寄存器,我们平常的应用代码用不上,就先不管了。
谈到寄存器我插个话题。我注意到你如果在网上搜索某个CPU架构的文章往往得到的是模棱两可的、甚至是错误的信息你千万要注意不要被它们误导了。
比如我看到有的文章说在某个架构的CPU中哪些寄存器是用来放返回值的哪些寄存器是用来传参数的而哪些寄存器又分别是由调用者或被调用者保护的等等还配了图做说明。
但如果你对调用约定或ABI的概念有所了解的话马上就会知道这些其实都是软件层面上的一些约定不是CPU架构层面上的规定。如果你还想了解得更具体一些可以参考涉及到ARM架构的一些[ABI规范文档](https://github.com/ARM-software/abi-aa/releases)特别是其中的“Procedure Call Standard for the Arm® 64-bit”这篇文档就规定了如何使用这些通用寄存器等信息。但作为语言的作者你其实可以设计自己的ABI你拥有更大的自由度。
与寄存器相关的一个概念叫做Process State或者PSTATE它是CPU在执行指令时形成的一些状态信息这些状态信息在物理层面是保存在一些特殊目的寄存器里。
PSTATE有什么用呢比如它的用途之一是辅助跳转指令的运行。当我们执行一个条件跳转指令之前会先执行一个比较指令这个比较指令就会设置某个状态信息而后续的跳转指令就可以基于这个状态信息进行正确的跳转了。
此外PSTATE还可以用于判断算术运算是否溢出、是否需要进位等等。
最后我们终于谈到CPU架构中的主角**指令集**了。你先看一下A64指令集中的一些常见的指令
![图片](https://static001.geekbang.org/resource/image/41/b7/411c8fde9d8948d4ac17cc9088f67bb7.jpg?wh=1594x1305)
你看这些指令是不是跟前面学过的Java字节码的指令集有很多相似之处我们来比较一下看上面的指令大概可以分为四组。
**第一组指令,是加减乘除等算术运算的指令。**
回忆一下,在我们之前学栈机的时候,是不是也有这些运算指令?但栈机和寄存器机的指令有一些差别。栈机的运算指令,是不需要带操作数的,因为操作数已经在操作数栈里。这几个指令会从栈顶取出两个操作数,做完加减乘除运算后,再放回栈顶。这样,下一条指令就可以把这个值作为操作数,继续进行计算。
但寄存器机上没有操作数栈,典型的寄存器机,所有运算都发生在寄存器里。我们来看看下面这个示例程序:
```plain
int foo(int a){
return a + 10;
}
```
你可以用下面这个命令生成ARM64指令集的汇编代码
```plain
clang -arch arm64 -S  foo.c -o foo_arm64.s -O2
```
汇编代码的主要部分是下面这几行:
```plain
_foo: ; @foo
add w0, w0, #10 ; =10
ret
```
其中“add w0, w0, #10”的意思是把w0寄存器的值加上10结果仍然放到w0。
在CPU指令里我们把常量一般叫做立即数immediate。在这条代码里#10就是个立即数。根据当前的调用约定当我们调用foo函数的时候参数a是传入到w0寄存器的。然后这个寄存器的值又加上了10返回值也放在w0里就可以了函数调用者会从w0里获取这个返回值。
**第二组指令只有一个mov指令**,它能把数据从一个寄存器拷贝到另一个寄存器,也可以把一个立即数设置到目标寄存器。
**第三组指令,是内存读写的指令。**
在ARM指令集中算术运算性的指令只能基于寄存器但是原始数据通常来自于内存计算结果最后也通常保存回内存所以我们还需要一组指令把数据从内存加载到寄存器以及从寄存器再写回内存这就是load和store指令。
这两个指令我们也很熟悉因为它们在栈机的指令里也出现过。在栈机里我们是用load命令把本地变量和常量加载到操作数栈用store命令操作数栈中的数据写回到本地变量。而在寄存器机里操作数栈变成了寄存器。
比如“str w0, \[sp, #12\]”的意思是把w0的值保存到一个内存地址这个内存地址是sp寄存器的值加12。sp是寄存器指向栈顶的指针操作系统会根据这个寄存器的值自动为栈分配内存。
**第四组指令,是各种跳转指令。**
跳转指令的基本原理是修改程序计数器的值让CPU去执行另一个地方的代码。
高级语言中的if语句、for循环语句等翻译成汇编代码后就会生成跳转指令。在跳转之前一般要做一个数值的比较依据比较结果再做跳转做比较的指令是CMP。比较完之后就可以根据比较结果做跳转了比如jeq表示两边相等就可以跳转了。
而函数调用、方法调用,我们都把它们统称为子过程调用,本质上也是做指令的跳转。只不过做子过程调用的时候,要建立新的栈帧并保证原来的栈帧不被破坏,一些寄存器的值也要保护起来,并且还要记下返回地址,所以我们通常要花费多条指令才能完成子过程调用的工作。
上面四组指令是各类不同CPU的指令集都具备的。当然我当前给你选的都是一些常用的也是一看就容易明白的指令。明白了这些之后你可以进一步研究更多的指令了。
在初步研究了ARM架构以后我们再接再厉了解一下X86架构。因为我们的台式机和服务器目前大多采用的还都是X86架构的CPU。对于64位的CPU这个系列的架构也叫做X64架构或者是AMD64架构。并且你还可以把它跟ARM架构做对比了解它们的相同点和不同点对比着学习你可以有更多的收获。
## 了解X86架构
X86架构的CPU有着很长的历史贯穿了整个PC的发展史。要了解X86架构当然也是看手册就行了手册名称叫做《[Intel® 64 and IA-32 Architectures Software Developers Manual](https://software.intel.com/content/www/us/en/develop/download/intel-64-and-ia-32-architectures-software-developers-manual-volume-1-basic-architecture.html)》。对于初学者,只需要看第一卷就行了,这一卷不是太厚,但已经足够你获得丰富的信息了。
**首先,我们仍然看看寄存器方面的信息**X86的架构下的寄存器可以分为下面几种。
* 通用寄存器随着X86架构在不断地演化它的可用的寄存器的数量也在不断增加。在64位模式下一共有16个通用寄存器可以使用这些寄存器可以用8位、16位、32位和64位的模式访问分别拥有不同的名称但实际上对应的是同一个物理寄存器。
![图片](https://static001.geekbang.org/resource/image/97/5b/97a4d4f82ffa3263ce4388f122b2585b.jpg?wh=1920x668)
* 段寄存器在32位模式下CPU使用多个段寄存器分别指向内存中不同段的起始地址比如代码段、数据段和栈来帮助计算代码、数据和栈操作的最后的地址但在64位模式下段寄存器就没有用了。
* 指令指针EIP/RIP在32位模式下EIP寄存器保存着下一条要执行的指令在代码段中的偏移量。**换句话代码段的段寄存器的地址加上EIP的地址就是指令的逻辑地址。**当然在64位模式下段寄存器没有用RIP里的地址就是下一条要执行的指令的地址它就相当于ARM中的PC寄存器。
* EFLAGS寄存器这个寄存器有点像ARM中的PSTATE也是保存了指令执行过程中产生的状态信息用于执行跳转指令等场景。
这里我补充一下我前面费这么大的劲介绍段寄存器和指令指针就是为了让你看出来虽然不同的CPU架构都能够知道下一条指令的地址但它的实现机制却是不同的。
**接着我们再看看X86的指令集。**
X86架构的CPU采用的是复杂指令集CISC而前面介绍的ARM和RISC-V指令集都是精简指令集RISC。这两者的差别体现在多个方面但这些差别不是我们这节课的重点我建议你自己去查阅一下资料这里我们主要研究一下指令上的差别。
我在下面这张表里列出了X86指令集中的一些常见的指令这些指令基本上从字面上也一看就懂。
![](https://static001.geekbang.org/resource/image/01/81/01f26fe85868d99afecba5caf7213181.jpg?wh=1265x1688)
你要注意的是这些指令存在8位、16位、32位和64位的模式你在汇编指令中使用时要带上不同的后缀
![图片](https://static001.geekbang.org/resource/image/b8/9b/b85f562e1680595c0692465bbd7b0a9b.jpg?wh=1900x774)
接下来我们再分析一下X86指令的特点。
**首先大多数指令的源和目标都支持使用内存地址而不像精简指令集那样一般只用load和store指令来做内存的读写。**在计算机发展的早期寄存器的数量是很少的这样的指令使用起来更方便。但是现在的手机芯片都支持比较多的通用寄存器比如16个甚至更高可以尽量减少内存读写操作所以RISC指令的设计方式就更有优势了。
**第二mov指令的不同。**在ARM的指令中mov只支持对寄存器的操作。而X86的mov指令由于支持内存地址作为操作数所以实际上支持把数据从内存加载到寄存器从寄存器保存到内存实现了RISC指令集中的load和store指令的功能。
**第三对于栈的维护和子过程的调用X86也有一些特别的指令方便我们编程。**
比如通过push指令我们可以把一个寄存器的值压到栈里同时修改栈顶指针的值。所以一条push %rbx指令相当于下面两条指令但花费的时间更少。
```plain
subq $8, %rsp        #把%rsp的值减8也就是栈增长8个字节从高地址向低地址增长
movq %rbp, (%rsp)    #把%rbp的值写到当前栈顶指示的内存位置
```
pop指令则是push指令的反向操作。
类似的还有一对指令call和ret。前者是先把返回地址压到栈里然后跳转到被调用的子过程的地址。比如“callq \_foo”其实相当于下面两条指令
```plain
pushq %rip  # 保存下一条指令的地址,用于函数返回继续执行
jmp _foo   # 跳转到函数_fun1
```
ret指令则是一个反向的操作。从栈里取出返回地址并设置到rip中让程序跳回来。
关于X86架构及其指令集我们就先了解到这里。其实我们还有不少细节没有讲到不过因为下一节课我们就要为X86架构的CPU生成汇编代码了我们会继续介绍更多的知识点。
## 课程小结
今天的内容就是这些了,我希望你在这一节课记住这些重要的知识点。
首先汇编语言是针对具体的CPU架构的而每种架构的CPU都有不同的设计所以它们的汇编语言也都是不同的。CPU架构相当于针对软件开发所提供的一个接口规范包括了寄存器、指令集、异常处理、内存模式等内容。学习CPU架构相关的知识最重要的就是学会下载和阅读手册。
我们这一节课初步研究了ARM架构和X86架构的CPU你会发现它们有一些共性。比如在寄存器方面都提供了一些通用寄存器也都有用于计算代码地址的寄存器并都能够提供一种机制用于保存指令执行过程中产生的一些标志信息或状态信息用于指令跳转等目的。在指令集方面它们也都有用于数据处理、数据拷贝mov、跳转等类别的指令。
当然它们也有一些差别比如X86的数据处理类的指令也可以直接把内存地址作为操作数等等。
我建议你可以再多看几个不同的指令集对比着来分析。学习不同的指令集能避免你的思维被限制在一种CPU设计模式里这样也可以加深你对各种不同架构的理解。在这里我特别建议你关注**RISC-V指令集**。由于它开源开放的特点未来在我国会拥有巨大的发展潜力。运行你的程序的下一个设备采用的可能就是RISC-V指令集。
好了今天的要点就是这些。下一节课我们将开始为X86架构生成汇编代码继续在底层机制上深钻。
## 思考题
我们今天学到了指令集的概念其实你当前使用的CPU可能会同时支持多个指令集。那你知道怎么来查询你的电脑的CPU支持的指令集吗你可以把查询方法和这些指令集分享出来并且查一查这些指令集都有什么用途欢迎分享在留言区。
感谢你和我一起学习,也欢迎你把这节课分享给更多对汇编代码感兴趣的朋友。我是宫文学,我们下节课见。
## 资源链接
1.[Arm Architecture Reference Manual - Armv8, for A profile Architecture](https://developer.arm.com/documentation/ddi0487/latest)