1.3 CPU眼里的汇编语言
●汇编语言是必需的吗?
尽管本书,并不要求大家具备汇编语言的知识,但考虑到内容的完整性,最终还是增加了这个章节。也希望借此机会帮助大家消除一些对“汇编语言”不必要的敬畏,同时,分享一下自己对学习、使用汇编语言的一点点看法。
●代码分析
打开Compiler Explorer,写一个简单的自加函数,如图1-14所示。
图1-14
下边是CPU的初始状态,所有的寄存器初始值都是0x100。
其中寄存器rax,一般用来存放数值,有点类似C语言的普通变量;而寄存器rbp,rsp,一般用来存放内存地址,有点类似C语言的指针变量。
线程往往通过调用函数来运行,因此,必须要有一个“堆栈”,用来存储临时变量和函数返回地址,所以“堆栈”内存是必不可少的。而rbp、rsp寄存器,就是用来管理、读写“堆栈”内存的。我们还会在3.2节“CPU眼里的函数括号{}”中,详细分析“堆栈”。
首先看函数{对应的汇编指令:push,千万不要被这个熟悉、亲切的名字迷惑。这是典型的复杂指令,无数同学被它直接劝退,如图1-15所示。
图1-15
push对应了两个微操作:
(1)先将寄存器rbp的值,存放在“栈顶”寄存器rsp指向的内存下方。
(2)随着“栈顶”向低生长,也就是让rsp寄存器的值减8。
随后是一个简单的mov指令,把寄存器rsp的值赋给寄存器rbp,如图1-16所示。
图1-16
至此,函数的栈帧保护工作完成,更详细的栈帧工作原理请参看3.2节“CPU眼里的函数括号{}”。
接着,是一个比较复杂的mov指令,如图1-17所示。
但通过参考源代码,我们很容易猜出它是要把数值1,写入到变量a所在的内存。
图1-17
用于写入的mov指令和数值1都很容易找到,但变量a的内存地址,就显得颇为复杂。不过PTR关键字显然在提示我们:这是一个指针操作,再加上rbp本身就是类似指针变量的寄存器。
所以,它对应的C语言,是这样的: *(rbp - 8) = 1。
变量a的内存地址等于寄存器rbp的值减8;而中括号,就相当于指针变量的*操作;QWORD是指针类型,表明数值1将占用8字节长度。
你是不是也从中看到了C语言的影子?所以说C语言是最接近底层的高级语言,真的一点儿都不过分。同样,相比于精简指令集,复杂指令集对程序员而言,也更加接近C语言。
好了,如果此时,你还能跟上阿布的节奏,那么恭喜你!因为,这就是本书中,最难的汇编语言了。后面的学习将轻松不少。
让我们接着进行自加运算,如图1-18所示。
图1-18
这种带PTR和[]的add指令,也有两个微操作,它们对应的C语言是这样的:*(rbp–8)=*(rbp–8)+2。
- 首先,用指针的*读操作,获得变量a的值,并与2做加法运算;
- 其次,把加法运算的结果,通过指针的*写操作,写入变量a所在的内存。
随后的mov指令,同样是一个带PTR和[]的指令,分析的方法,跟上面的mov指令一致,如图1-19所示。
图1-19
它对应的C语言是这样的:rax=*(rbp–8)。
只是不同于上面的mov指令,是一个写内存的操作;这次则是把变量a的值从内存中读出来,并写入到寄存器rax里面。
或许,你会纳闷为什么普通变量操作,背后也弄得跟“指针”一样?在CPU眼里的,万物皆有地址,万物皆可指针。指针变量,跟普通变量并无本质区别,如果此时参看2.5节“CPU眼里的指针本质和风险”,你可能会有更多感悟。
最后,就是push的反向操作pop,如图1-20所示。
它也对应了两个微操作:
(1)把寄存器rsp指向的“栈顶”值0x100,写入寄存器rbp。
(2)随着“栈顶”的升高,rsp寄存器的值,也随之加8。
至此,整个代码基本走完,除了用于作返回值的寄存器rax;所有寄存器,都恢复到了刚开始的状况,就像test函数从未被调用一样。
图1-20
●思考
或许,本章是本书中最乏味的一章。因为,在没有结合编译器意图的情况下,单独讨论每条汇编指令,是非常乏味的!
不知道读者里面,有没有在工地干过的工友?很多宏伟、漂亮的房子,在真正施工的时候,不过是在重复搭钢筋、倒水泥;再搭钢筋、再倒水泥的过程。而CPU也是如此,我们不过是把数据,在寄存器和内存之间,搬来搬去。
或许,本节也是全书中,最具洞察力的一节。经过粗略的统计,我们发现为了做1次简单的+2运算,居然产生了(至少) 5次的内存读写,内存读写的占比高达83%!
虽然,经过编译器优化后,一些没有必要的内存读写指令,会被优化掉。但对于复杂程序,其内存的读写总量,仍然不容小觑!有些机构给出的结论显示:CPU的内存读写,占据了CPU 90%的工作负荷。
这也是为什么苹果的M系列CPU,在没有显著提高CPU核心频率的情况下,也能产生秒杀同类的炸裂性能,因为它着重优化了CPU读、写内存的效率。
●总结
(1)虽然完整的CPU寄存器和指令集比较庞大。但编译器只会用到很小的一部分,而且使用的套路也很单一。一旦克服恐惧心理,就很容易掌握。
(2)C/C++语言对应的汇编指令存在大量的类似“指针”的操作,我们也叫它寄存器间接寻址。夸张地说“指针”不仅是C语言的灵魂,也是汇编语言的灵魂。
(3)相比于精简指令集,复杂指令集对程序员而言,更加接近C语言。在那个只有汇编语言的年代,复杂指令集,十分有助于提高编程效率。
最后,作为普通程序员,我们直接使用汇编语言编程的可能性几乎为零。在今天,汇编语言,也不是大规模软件开发的首选。所以,很多时候,我们并不需要成为汇编语言的专家。
阿布认为,普通开发者学习汇编语言,最好要结合特定、必要的场景。例如,我们可以用CPU视角,解读出一个真实的程序运行过程;或帮助我们调试、解决一些无法在语言层面表现出来的bug。
●热点问题
Q1:寄存器eax和寄存器rax有什么区别?
A1:寄存器eax是32位的x86 CPU的寄存器,如今的x86 CPU多是64位的,其对应的寄存器是rax,eax只是rax的低32位而已。
Q2:不精通汇编语言,等于白学编程语言了?
A2:当然不是。对于学习C/C++这种相对接近底层的语言,它对应的汇编语言还是比较简单、易懂的,完全不需要你精通汇编语言。同时,一些新的编程语言,例如RUST、SWIFT,编译器对代码封装得比较厉害,就不容易通过对应的汇编指令,了解语言的实现细节了。
而且有些语言,例如Java、JavaScript对应的是字节码,并没有汇编指令可以参考,但这并不妨碍大家掌握它们。