本文为MIT 6.S081课程第五节重点笔记整理。
不同的处理器指令集不一样,而汇编语言中都是一条条指令,所以不同处理器对应的汇编语言必然不一样。
如果你使用RISC-V,你不太能将Linux运行在上面。相应的,大多数现代计算机都运行在x86和x86-64处理器上。x86拥有一套不同的指令集,看起来与RISC-V非常相似。通常你们的个人电脑上运行的处理器是x86,Intel和AMD的CPU都实现了x86。
RISC-V和x86并没有它们第一眼看起来那么相似。RISC-V中的RISC是精简指令集(Reduced Instruction Set Computer)的意思,而x86通常被称为CISC,复杂指令集(Complex Instruction Set Computer)。这两者之间有一些关键的区别:
在日常生活中,可能已经在完全不知情的情况下使用了精简指令集。比如说ARM也是一个精简指令集,高通的Snapdragon处理器就是基于ARM。如果你使用一个Android手机,那么大概率你的手机运行在精简指令集上。如果你使用IOS,苹果公司也实现某种版本的ARM处理器,这些处理器运行在iPad,iPhone和大多数苹果移动设备上,甚至对于Mac,苹果公司也在尝试向ARM做迁移(注,刚刚发布的Macbook)。所以精简指令集出现在各种各样的地方。如果你想在现实世界中找到RISC-V处理器,你可以在一些嵌入式设备中找到。所以RISC-V也是有应用的,当然它可能没有x86那么流行。
在最近几年,由于Intel的指令集是在是太大了,精简指令集的使用越来越多。Intel的指令集之所以这么大,是因为Intel对于向后兼容非常看重。所以一个现代的Intel处理器还可以运行30/40年前的指令。Intel并没有下线任何指令。而RISC-V提出的更晚,所以不存在历史包袱的问题。
如果查看RISC-V的文档,可以发现RISC-V的特殊之处在于:它区分了Base Integer Instruction Set和Standard Extension Instruction Set。Base Integer Instruction Set包含了所有的常用指令,比如add,mult。除此之外,处理器还可以选择性的支持Standard Extension Instruction Set。例如,一个处理器可以选择支持Standard Extension for Single-Precision Float-Point。这种模式使得RISC-V更容易支持向后兼容。 每一个RISC-V处理器可以声明支持了哪些扩展指令集,然后编译器可以根据支持的指令集来编译代码。
看起来使用x86而不是RISC-V的唯一优势就是能得到性能的提升,但是这里的性能是以复杂度和潜在的安全为代价的,我的问题是为什么我们还在使用x86,而不是使用RISC-V处理器?
这个表里面是RISC-V寄存器。
寄存器是用来进行任何运算和数据读取的最快的方式,这就是为什么使用它们很重要,也是为什么我们更喜欢使用寄存器而不是内存。
当我们调用函数时,你可以看到这里有a0 - a7寄存器。通常我们在谈到寄存器的时候,我们会用它们的ABI名字。不仅是因为这样描述更清晰和标准,同时也因为在写汇编代码的时候使用的也是ABI名字。
a0到a7寄存器是用来作为函数的参数。如果一个函数有超过8个参数,我们就需要用内存了。从这里也可以看出,当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它。
表单中的第4列,Saver列,当我们在讨论寄存器的时候也非常重要。它有两个可能的值Caller,Callee。我经常混淆这两个值,因为它们只差一个字母。我发现最简单的记住它们的方法是:
这里的意思是,一个Caller Saved寄存器可能被其他函数重写。
如果你们还记得的话,所有的寄存器都是64bit,各种各样的数据类型都会被改造的可以放进这64bit中。比如说我们有一个32bit的整数,取决于整数是不是有符号的,会通过在前面补32个0或者1来使得这个整数变成64bit并存在这些寄存器中。
下面是一个非常简单的栈的结构图,其中每一个区域都是一个Stack Frame,每执行一次函数调用就会产生一个Stack Frame。
每一次我们调用一个函数,函数都会为自己创建一个Stack Frame,并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。
对于Stack来说,是从高地址开始向低地址使用。所以栈总是向下增长。当我们想要创建一个新的Stack Frame的时候,总是对当前的Stack Pointer做减法。一个函数的Stack Frame包含了保存的寄存器,本地变量,并且,如果函数的参数多于8个,额外的参数会出现在Stack中。所以Stack Frame大小并不总是一样,即使在这个图里面看起来是一样大的。不同的函数有不同数量的本地变量,不同的寄存器,所以Stack Frame的大小是不一样的。但是有关Stack Frame有两件事情是确定的:
有关Stack Frame中有两个重要的寄存器,第一个是SP(Stack Pointer),它指向Stack的底部并代表了当前Stack Frame的位置。第二个是FP(Frame Pointer),它指向当前Stack Frame的顶部。因为Return address和指向前一个Stack Frame的的指针都在当前Stack Frame的固定位置,所以可以通过当前的FP寄存器寻址到这两个数据。
我们保存前一个Stack Frame的指针的原因是为了让我们能跳转回去。所以当前函数返回时,我们可以将前一个Frame Pointer存储到FP寄存器中。所以我们使用Frame Pointer来操纵我们的Stack Frames,并确保我们总是指向正确的函数
Stack Frame必须要被汇编代码创建,所以是编译器生成了汇编代码,进而创建了Stack Frame。所以通常,在汇编代码中,函数的最开始你们可以看到Function prologue,之后是函数的本体,最后是Epollgue。这就是一个汇编函数通常的样子。
我们从汇编代码中来看一下这里的操作:
在我们上面的sum_to函数中,只有函数主体,并没有Stack Frame的内容。它这里能正常工作的原因是它足够简单,并且它是一个leaf函数。leaf函数是指不调用别的函数的函数,它的特别之处在于它不用担心保存自己的Return address或者任何其他的Caller Saved寄存器,因为它不会调用别的函数。
而另一个函数sum_then_double就不是一个leaf函数了,这里你可以看到它调用了sum_to。
所以在这个函数中,需要包含prologue。
这里我们对Stack Pointer减16,这样我们为新的Stack Frame创建了16字节的空间。之后我们将Return address保存在Stack Pointer位置。
之后就是调用sum_to并对结果乘以2。最后是Epllogue,
这里首先将Return address加载回ra寄存器,通过对Stack Pointer加16来删除刚刚创建的Stack Frame,最后ret从函数中退出。
如果我们删除掉Prologue和Epllogue,然后只剩下函数主体会发生什么?
我们可以看一下具体会发生什么。先在修改过的sum_then_double设置断点,然后执行sum_then_double:
我们可以看到现在的ra寄存器是0x80006392,它指向demo2函数,也就是sum_then_double的调用函数。之后我们执行代码,调用了sum_to:
我们可以看到ra寄存器的值被sum_to重写成了0x800065f4,指向sum_then_double,这也合理,符合我们的预期。我们在函数sum_then_double中调用了sum_to,那么sum_to就应该要返回到sum_then_double。
之后执行代码直到sum_then_double返回。因为没有恢复sum_then_double自己的Return address,现在的Return address仍然是sum_to对应的值,现在我们就会进入到一个无限循环中。
接下来我们来看一些C代码。
demo4函数里面调用了dummymain函数。我们在dummymain函数中设置一个断点,
现在我们在dummymain函数中。如果我们在gdb中输入info frame,可以看到有关当前Stack Frame许多有用的信息。
如果输入backtrace(简写bt)可以看到从当前调用栈开始的所有Stack Frame
如果对某一个Stack Frame感兴趣,可以先定位到那个frame再输入info frame,Stack Frame中有更多的信息,有一堆的Saved Registers,有一些本地变量等等。这些信息对于调试代码来说超级重要。
struct在内存中的结构是怎样?
当我们创建这样一个struct时,内存中相应的字段会彼此相邻。你可以认为struct像是一个数组,但是里面的不同字段的类型可以不一样。
我们可以将struct作为参数传递给函数。
这里有一个名字是Person的struct,它有两个字段。我将这个struct作为参数传递给printPerson并打印相关的信息。我们在printPerson中设置一个断点,当程序运行到函数内部时打印当前的Stack Frame。
我们可以看到当前函数有一个参数p。打印p可以看到这是struct Person的指针,打印p的反引用可以看到struct的具体内容。