本系列文章为MIT6.S081的学习笔记,包含了参考手册、课程、实验三部分的内容,前面的系列文章链接如下
操作系统MIT6.S081:[xv6参考手册第1章]->操作系统接口
操作系统MIT6.S081:P1->Introduction and examples
操作系统MIT6.S081:Lab1->Unix utilities
操作系统MIT6.S081:[xv6参考手册第2章]->操作系统组织结构
操作系统MIT6.S081:P2->OS organization and system calls
操作系统MIT6.S081:Lab2->System calls
操作系统MIT6.S081:[xv6参考手册第3章]->页表
操作系统MIT6.S081:P3->Page tables
操作系统MIT6.S081:Lab3->Page tables
一、C程序到汇编程序的转换
课程内容
今天的课程的主要内容如下所示。对于页表来说这些内容不太重要,但是对于后续的traps实验来说,这些内容至关重要,因为在该实验中将会频繁用到trapframe(xv6中用来实现trap的一个内存page,lecture 6有详细内容)和栈。
①回顾syscall lab
②C到汇编的转换过程
③RISC-V与X86
④寄存器
⑤栈+调用约定(calling convention)
⑥结构体在内存中的布局
C语言到汇编程序的转换
通常来说,C语言程序会有一个main函数,假设在这个函数中打印了一些东西然后退出了。
处理器并不能理解C语言,处理器能够理解的是汇编语言。或者更具体的说,处理器能够理解的是二进制编码后的汇编代码。
RISC-V处理器
处理器的厂家非常多,本课程主要讨论RISC-V处理器,如下图所示(一家基于RISC-V处理器的厂商SiFive主板上的RISC-V处理器)。
ISA
当我们说到一个RISC-V处理器时,意味着这个处理器能够理解RISC-V的指令集。所以任何一个处理器都有一个关联的ISA(Instruction Sets Architecture),ISA就是处理器能够理解的指令集。每一条指令都有一个对应的二进制编码或者一个操作码Opcode。当处理器运行时,如果看见了这些编码,处理器就知道该做什么样的操作。上图中的处理器正好能理解RISC-V汇编语言。
.o文件
汇编由C代码编译而来。所以通常来说,要让C语言能够运行在你的处理器上,我们首先要写出C程序,之后这个C程序需要被编译成汇编代码。这个过程中有一些链接和其他的步骤,但是因为这门课不是一个编译器的课程,所以我们忽略这些步骤。之后汇编语言会被翻译成二进制文件,也就是.obj或者.o文件。如果你在运行make qemu后注意过实验目录中的内容,你会看到一些.o文件散落在各处,这些就是处理器能够理解的目标文件。
.asm文件
你们可以在目录中看到一些.asm文件。虽然你还没有写任何汇编程序,但是如果你回想syscall、usys.pl文件被编译城usys.S,.S就是汇编文件,所以你肯定已经得到了一些RISC-V汇编。
汇编语言特点
----汇编语言看起来比C语言的结构化程度要低得多,在汇编语言中你只能看到一行行的指令,比如add,mult等。
----汇编语言中没有很好的控制流程,没有循环语句(注,但是有基于lable的跳转)。
----虽然有函数但是与C语言函数不太一样,汇编语言中的函数是以标签label的形式存在而不是真正的函数定义。
----汇编语言是一门非常底层的语言,许多其他语言(如C++)都会编译成汇编语言。运行任何编译型语言之前都需要先生成相同的汇编语言。
以上就是让计算机能够理解我们的C代码的基本流程。
二、RISC-V vs x86
汇编语言
汇编语言有很多种(因为不同的处理器指令集不一样,而汇编语言中都是一条条指令,所以不同处理器对应的汇编语言必然不一样)。如果你使用RISC-V处理器,你不太能将Linux运行在上面。相应的,大多数现代计算机都运行在x86和x86-64处理器上,通常你们的个人电脑上运行的处理器是x86,Intel和AMD的CPU都实现了x86。x86拥有一套不同的指令集,看起来与RISC-V非常相似。
RISC-V vs x86
RISC-V中的RISC是精简指令集(Reduced Instruction Set Computer)。
x86通常被称为CISC,复杂指令集(Complex Instruction Set Computer)。
关键的区别:
①指令的数量
创造RISC-V的一个非常大的初衷就是因为Intel手册中指令数量太多了。
----x86-64指令介绍由3个文档组成,并且新的指令以每个月3条的速度在增加。因为x86-64是在1970年代发布的,所以现在有多于15000条指令。
----RISC-V指令介绍由两个文档组成。这两个文档包含了RISC-V的指令集的所有信息,分别是240页和135页,相比x86的指令集文档要小得多的多。这是有关RISC-V比较好的一个方面。所以在RISC-V中,我们有更少的指令数量。
②指令复杂度
----在x86-64中,很多指令都做了不止一件事情,它们会执行一系列复杂的操作并返回结果。
----RISC-V的指令趋向于完成更简单的工作,相应的也消耗更少的CPU执行时间。
这其实是设计人员的在底层设计时的权衡,并没有一些非常确定的原因说RISC比CISC更好,它们各自有各自的使用场景。
③开源程度
相比x86来说,RISC是市场上唯一的一款开源指令集,这意味着任何人都可以为RISC-V开发主板。RISC-V来自于UC-Berkly的一个研究项目,之后被大量的公司选中并做了支持。
④应用广泛度
在你们的日常生活中,你们可能已经在完全不知情的情况下使用了精简指令集。比如说ARM也是一个精简指令集,高通的Snapdragon(骁龙)处理器就是基于ARM。如果你使用一个Android手机,那么大概率你的手机运行在精简指令集上。如果你使用IOS,苹果公司也实现某种版本的ARM处理器,这些处理器运行在iPad、iPhone和大多数苹果移动设备上(甚至包括Mac)。苹果公司也在尝试向ARM做迁移(如刚刚发布的Macbook)。所以精简指令集出现在各种各样的地方。如果你想在现实世界中找到RISC-V处理器,除了QEMU,你可以在一些嵌入式设备中找到。所以RISC-V也是有应用的,当然它可能没有x86那么广泛。
⑤向后兼容性
在最近几年,由于Intel的指令集是在是太大了,精简指令集的使用越来越多。Intel的指令集之所以这么大,是因为Intel对于向后兼容非常看重。Intel并没有下线任何指令,所以一个现代的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。例如,一个处理器可以选择支持单精度浮点型支持,就可以包含相应的扩展指令集。这种模式使得RISC-V更容易支持向后兼容,每一个RISC-V处理器可以声明支持了哪些扩展指令集,然后编译器可以根据支持的指令集来编译代码。
问答
学生提问:如果x86有15000条指令,那几乎不能有效地使用它们,为什么需要这么多?
Frans教授:我们需要许多指令来实现向后兼容,向后兼容是否重要因人而异。另一方面,我认为这里许多指令都是用来完成一些特殊的操作。我从来没有见过一个Intel的汇编代码使用了所有的15000个指令。大多数这些指令都是为了向后兼容和特定场景的需求创建。
学生提问:看起来使用x86而不是RISC-V的唯一优势就是能得到性能的提升,但是这里的性能是以巨大的成本、复杂度和潜在的安全性为代价的。我的问题是为什么我们还在使用x86,而不是使用RISC-V处理器?
Frans教授:我并没有一个很好的答案来回答。现在整个世界都运行在x86上,如果你突然将处理器转变成RISC-V,那么你就会失去很多重要的软件支持。同时,Intel在它的处理器里面做了一些有意思的事情。例如安全相关的enclave,这是Intel最近加到处理器中来提升安全性的功能。此外,Intel还实现了一些非常具体的指令,这些指令可以非常高效的进行一些特定的运算。所以Intel有非常多的指令,通常来说对于一个场景都会有一个完美的指令,它的执行效率要高于RISC-V中的同等指令。但是这个问题更实际的答案是,RISC-V相对来说更新一些,目前还没有人基于RISC-V来制造个人计算机,SiFive也就是最近才成为第一批将RISC-V应用到个人计算机的公司。所以,从实际的角度来说,因为不能在RISC-V上运行所有为Intel设计的软件,是我对这个问题的最好的答案。
三、gdb和汇编代码执行
汇编代码示例
我们来看一些真实的汇编代码。
----上半部分的注释是对应的C代码。这是个简单的函数,它累加了从1到n的所有数字,并返回结果。
----下半部分是可以编译出的最简单的汇编代码。如果你在你自己的计算机编写同样的C代码并编译,你得到的极有可能是差别较大的汇编代码。这里有很多原因,有一些原因我们之后会讲,有一些原因是因为编译器。当将C代码编译成汇编代码时,现代的编译器会执行各种各样的优化,所以你们自己编译得到的汇编代码可能看起来是不一样的。例如,当你在gdb中做debug的时候,有时候你会看到gdb提示你说某些变量被优化掉了,这意味着编译器决定不再需要那个变量,因此这个变量会从程序中消失。
----上图中的代码都很直观。将寄存器a0
中的值移动到寄存器t0
中、将寄存器a0
设置为0。在每个循环中:将t0
中的数据加到a0
中、t0
减去1、直到t0变成0结束循环。
问答
学生提问:这里面
.secion
、.global
、.text
分别是什么意思?
Frans教授:global表示你可以在其他文件中调用这个函数。我们先去看看defs.h,它包含了内核中可能使用的所有函数。在这里我们可以看到,已经包含了这些函数的定义。这样,.global保证这些函数可以从其他地方调用。
text表明这里的是代码,如果你还记得xv6中的图3.4。
每个进程的页表中有一个区域是text,用于保存代码。汇编代码中的text表明这部分是代码,并且位于页表的text区域中。如果你对内核比较感兴趣,在编译完之后,你可以查看kernel.asm文件,这就是xv6完整的内核汇编版本。
kernel.asm文件中每一行左边的数字是一个标签,表明这条指令会在内存中的哪个位置,这个信息非常有用。使用实际的汇编代码,你可以看到函数对应的标签以及它们是在哪里定义的。这些信息在我们调试代码的时候可能会非常有用,稍后会展示这部分。
学生提问:.asm
文件和.s
文件有什么区别?
Frans教授:我并不是百分百确定。这两类文件都是汇编代码,.asm
文件中包含大量额外的注释,而.s
文件中没有。所以通常来说当你编译你的C代码,你得到的是.s
文件。如果你好奇我们是如何得到.asm
文件,makefile里面包含了具体的步骤。
汇编代码示例
现在回到函数sum_to,我们看一下如何在gdb中检查这个函数。首先启动QEMU。
在另一个窗口打开gdb
gdb中输入tui enable
可以打开源代码展示窗口。现在是空的,但是在你调试时会很有用。
sum_to
的代码现在都位于内核中,我在sum_to
中设置一个断点。然后继续代码的执行,代码在断点处停住。左边的数字代表地址。
gdb窗口的左上角是程序计数器,我们可以看到当前的值是0x800065e2。如果我们去kernel.asm中查找这个地址,可以看到这个地址就是sum_to函数的起始地址。因此,如果代码出现了问题,在gdb中看到的地址可以直接在kernel.asm找到具体的行,分析问题的原因,然后再向相应的地址设置断点。
现在tui窗口顶部是源码窗口,在gdb中输入layout asm
,可以在tui窗口看到所有的汇编指令。
再输入layout reg
可以看到所有的寄存器信息。
现在我们有三个窗口,如果我想查看所有的寄存器,输入focus reg
。现在我们就聚焦在reg窗口了,可以上下键在窗口中滚动了。在寄存器窗口,可以看到t0
,a0
寄存器的值。在执行完一条汇编指令之后,t0
寄存器拥有了a0
寄存器的内容,也就是5。在寄存器窗口,更新了的寄存器会被高亮出来。
之后持续的单步执行代码,直到函数返回。如果你关心你设置了哪些断点,或着你跟踪代码的时候迷糊了忘记自己在做什么,你可以在gdb中输入info break
或info breakpoints
,你可以看到所有设置了的断点。你甚至可以看到这个断点已经被命中了几次。类似的,你也可以通过输入info reg
查看寄存器的信息。
问答
学生提问:你是怎么打开多个terminal窗口的?
Frans教授:我是通过tmux打开的。
学生提问:为什么这里展示的是汇编代码而不是C代码行号?
Frans教授:从最初的代码可以看出,这里的程序完全是汇编代码实现的,所以自然也没有关联的C程序行号。输入delete
可以删除所有断点。
如果我b demo-1
,这是一个C断点,然后继续运行。
如果我将断点设置在C代码中,在命中断点之后输入layout split或者layout source,就可以看到相应的C代码了。
layout split会同时展现C代码和汇编,而layout source只会展示C代码。
学生提问:在C代码中,断点设置在某一行,如果这一行有多个语句的话,断点会设置在哪个语句?
Frans教授:断点会设置在第一个语句。
gdb和tmux有上百个快捷指令,可以通过google去查找,对于gdb,也可以使用gdb的内置手册apropos指令查看帮助。
四、RISC-V寄存器
RISC-V寄存器
本节课程的参考书籍上有RISC-V的寄存器介绍,如下图所示。
寄存器是CPU或处理器周围预设很小的位置,可以用来存储数据。
寄存器非常重要的原因:
①汇编代码并不是在内存上执行,而是在寄存器上执行。也就是说,当我们在做add
、sub
等操作时,是对寄存器进行操作。所以你们通常看到的汇编代码中使用寄存器的模式是:
----将数据加载进寄存器中(这里的数据可以来自内存,也可以来自另一个寄存器)
----在寄存器上执行一些操作。
----如果对操作的结果关心的话,将操作的结果存储在某个地方。可能是内存中的某个地址,也可能是另一个寄存器。
②寄存器是用来进行任何运算和数据读取的最快的方式。因此,我们更喜欢使用寄存器而不是内存。
ABI
通常我们在谈到寄存器的时候,我们会用它们的ABI
名字来代替他们。不仅减少了混淆,这也是一种标准,因为在写汇编代码的时候使用的也是ABI名字。实际上第一列中的寄存器名字并不是非常重要,它唯一重要的场景是在RISC-V指令的压缩版本(Compressed Instruction)中。
压缩指令
RISC-V中通常的指令是64bit,但是在压缩指令是16bit。在压缩指令中我们使用更少的寄存器,也就是x8
-x15
寄存器。我猜你们可能会有疑问,为什么s1
(x9
)寄存器和其他的s
寄存器是分开的。这是因为s1
对压缩指令是有效的,而s2
-s11
却是无效的,所以我们认为s1
是个压缩指令寄存器。除了压缩指令,寄存器都是通过它们的ABI名字来引用。
函数参数寄存器
a0
到a7
寄存器是用来作为函数的参数。如果一个函数有超过8个参数,我们就需要用内存了。从这里也可以看出,当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它。
Saver
Saver有两个可能的值:Caller调用者,Callee被调用者。
Caller Saved寄存器在函数调用的时候不会保存。
Callee Saved寄存器在函数调用的时候会保存。
这里的意思是:一个Caller Saved寄存器可能被其他函数重写。假设我们在函数a中调用函数b,任何被函数a使用的并且是Caller Saved寄存器,调用函数b可能重写这些寄存器。例如Return address寄存器(保存的是函数返回的地址)是Caller Saved,因为每个函数都需要使用返回地址 ,因此它导致了当函数a调用函数b的时侯,b会重写Return address。
总结:对于任何一个Caller Saved寄存器,作为调用方的函数要小心数据的变化。对于任何一个Callee Saved寄存器,作为被调用方的函数要小心寄存器的值不会相应的变化。
数据改造
所有的寄存器都是64bit,各种各样的数据类型都会被改造使得可以放进这64bit中。比如说我们有一个32bit的整数,取决于整数是不是有符号的,会通过在前面补32个0或者1来使得这个整数变成64bit并存在这些寄存器中。
问答
学生提问:返回值可以放在a1寄存器吗?
Frans教授:我认为理论上是可以的。如果一个函数的返回值是long long型,也就是128bit,我们可以把它放到一对寄存器中。这也同样适用于函数的参数。所以,如果返回值超过了一个寄存器的长度,也就是64bit,我们可以将返回值保存在a0和a1。但是如果你只将返回值放在a1寄存器,我认为会出错。
学生提问:为什么寄存器不是连续的?比如为什么s1与其他的s寄存器是分开的?
Frans教授:我之前提到过,但是也只是我的猜想,我并不十分确定。因为s1寄存器在RISC-V的压缩指令是可用的,所以它才被分开。
学生提问:除了Stack Pointer和Frame Pointer,我不知道为甚什么我们需要更多的Callee Saved寄存器,但我们确实有这么多寄存器。
Frans教授:s0
-s11
都是Callee寄存器,我认为它们是提供给编译器而不是程序员使用。在一些特定的场景下,你会想要确保一些数据在函数调用之后仍然能够保存,这个时候编译器可以选择使用s
寄存器。
五、栈
栈
栈之所以很重要的原因是,它使得我们的函数变得有组织,且能够正常返回。
下面是一个非常简单的栈的布局图,其中每一个区域都是一个栈帧(Stack Frame),每执行一次函数调用就会产生一个Stack Frame。
栈的工作流程:
----每一次我们调用一个函数,函数都会为自己创建一个Stack Frame,并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。对于Stack来说,是从高地址开始向低地址使用,所以栈总是向下扩展。当我们想要创建一个新的Stack Frame的时候,总是对当前的Stack Pointer做减法。
----一个函数的Stack Frame包含了保存的寄存器、局部变量。由于如果函数的参数多于8个,额外的参数会出现在Stack中。此外,不同的函数有不同数量的局部变量、不同的寄存器等。所以Stack Frame大小并不总是一样,即使在这个图里面看起来是一样大的。
有关Stack Frame有两件事情是确定的:
①Return address总是会出现在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从函数中退出。
分别运行sum_to和sum_then_double,得到结果15和30。
问答
Frans教授:如果我们删除掉函数序言和函数尾声,然后只剩下函数主体会发生什么?
学生回答:sum_then_double
将不知道它应该返回的Return address。所以调用sum_to的时候,Return address被覆盖了,最终sum_to函数不能返回到它原本的调用位置。
Frans教授:是的,完全正确,我们可以看一下具体会发生什么。先在修改过的sum_then_double设置断点,然后执行sum_then_double。
我们可以看到现在的ra寄存器是0x80006392,它指向demo2函数,也就是sum_then_double的调用函数。之后我们单步调试si
执行代码,调用了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对应的值,现在我们就会进入到一个无限循环中。我认为这是一个很好 的例子用来展示为什么跟踪Caller和Callee寄存器是重要的。
学生提问:为什在最开始要对sp寄存器减16?
Frans教授:是为了Stack Frame创建空间。减16相当于内存地址向前移16,这样对于我们自己的Stack Frame就有了空间,我们可以在那个空间存数据。我们并不想覆盖原来在Stack Pointer位置的数据。
学生提问:为什么不减4呢?
Frans教授:我认为我们不需要减16那么多,但是4个也太少了,你至少需要减8,因为接下来要存的ra寄存器是64bit(8字节)。这里的习惯是用16字节,因为我们要存Return address和指向上一个Stack Frame的地址,只不过我们这里没有存指向上一个Stack Frame的地址。如果你看kernel.asm,你可以发现16个字节通常就是编译器的给的值。
C代码示例
接下来我们来看一些C代码。
demo4函数里面调用了dummymain函数。我们在dummymain函数中设置一个断点。
现在我们在dummymain函数中。如果我们在gdb中输入i frame
(或者info frame
),可以看到有关当前Stack Frame许多有用的信息。
Stack level 0
:表明这是调用栈的最底层
pc
:当前的程序计数器。
saved pc
:地址为0x800064b6
,表明当前函数要返回的位置。我们去kernel.asm查找这个地址,发现正是demo4的位置。
source language c
:表明这是C代码
Arglist at
:表明参数的起始地址。当前的参数都在寄存器中,可以看到argc=3,argv是一个地址。
深入了解:
输入info args
,就会出现参数的相关信息。
可以输入backtrace
(简写bt
),可以看到从当前调用栈开始的所有Stack Frame。
如果对某一个Stack Frame感兴趣,可以通过frame 数字
定位到那个frame再输入info frame。现在我们查看syscall调用时的Stack Frame。
在这个Stack Frame中有更多的信息,这些信息对于调试代码来说至关重要。
①有一堆的Saved Registers,如ra
、s2
等。
②有一些本地变量等。
③有程序计数器应该跳回到哪里(saved pc
)。
④这个函数没有任何参数。
现在我们输入frame 0
就会返回到刚才的位置。前面我们只得到了argv的地址,现在想看看地址里面是什么,可以使用p *argv
查看。可以看到,我们得到了该数组的第一个元素。
如果我们想多看到一些内容,可以通过p *argv@数字
指定索引长度。这里我们查看两个字符串。
问答:
学生提问:为什么有的时候编译器会优化掉argc或者argv?这个以前发生过。
Frans教授:这意味着编译器发现了一种更有效的方法,不使用这些变量,而是通过寄存器来完成所有的操作。如果一个变量不是百分百必要的话,这种优化还是很有常见的。我们并没有给你编译器的控制能力,但是在你们的日常使用中,你可以尝试设置编译器的optimization flag为0。不过就算这样,编译器也会做某些程度的优化。
学生提问:foo
、bar
之前的地址是它们的地址吗?但是argv的地址又和它们不一样。
Frans教授:这些地址是它们的位置。可以看到这些地址以8000开头,说明在内核中,这是因为这些都是字符串常量。另外,argv保存的都是这些字符串的地址,即argv是一个指针数组。
学生提问:为什么这些地址都是保存的相同长度的字符串,但是他们的长度不同(一个是38结尾,一个是40结尾,一个是48结尾)。
Frans教授:是为了对齐数据,用十六进制表示他们是对齐的。
六、结构体
struct
struct在内存中是一段连续的内存区域。假设现在如果我们有一个struct,并且有f1,f2,f3三个字段。
当我们创建这样一个struct时,内存中相应的字段会彼此相邻。你可以认为struct像是一个数组,但是里面的不同字段的类型可以不一样。这里有一个名字是Person的struct,它有两个字段。我将这个struct作为参数传递给printPerson并打印相关的信息。
我们在printPerson中设置一个断点,当程序运行到函数内部时打印当前的Stack Frame。
我们可以看到当前函数有一个参数p。打印p可以看到这是struct Person的指针,打印p的解引用可以看到struct的具体内容。
问答
学生提问:是谁创建了编译器来将C代码转换成各种各样的汇编代码,是不同的指令集创建者,还是第三方?
Frans教授:我认为不是指令集的创建者,通常是第三方创建的。你们常见的两大编译器,一个是gcc,这是由GNU基金会维护的。一个是Clang llvm,这个是开源的,你可以查到相应的代码。当一个新的指令集(例如RISC-V)发布之后,我认为会指令集的创建者和编译器的设计者之间会有一些高度合作。简单来说我认为是第三方配合指令集的创建者完成的编译器。RISC-V或许是个例外,因为它是来自于一个研究项目,相应的团队或许自己写了编译器,但是我不认为Intel对于gcc或者llvm有任何投入。