本章次要介绍了计算机中的机器代码——汇编语言。当咱们应用高级语言(C、Java等)编程时,代码会屏蔽机器级的细节,咱们无奈理解到机器级的代码实现。既然有了高级语言,咱们为什么还须要学习汇编语言呢?学习程序的机器级实现,能够帮忙咱们了解编译器的优化能力,能够让咱们理解程序是如何运行的,哪些局部是能够优化的;当程序受到攻打(破绽)时,都会波及到程序运行时管制信息的细节,很多程序都会利用零碎程序中的破绽信息重写程序,从而取得零碎的控制权(蠕虫病毒就是利用了gets函数的破绽)。特地是作为一名嵌入式软件开发的从业人员,会常常接触到底层的代码实现,比方Bootloader中的时钟初始化,重定位等都是用汇编语言实现的。尽管不要求咱们应用汇编语言写简单的程序,然而要求咱们要可能浏览和了解编译器产生的汇编代码。

@[toc]

程序编码

计算机的形象模型

  在之前的《深刻了解计算机系统》(CSAPP)读书笔记 —— 第一章 计算机系统漫游文章中提到过计算机的形象模型,计算机利用更简略的形象模型来暗藏实现的细节。对于机器级编程来说,其中两种形象尤为重要。第一种是由指令集体系结构或指令集架构( Instruction Set Architecture,ISA)来定义机器级程序的格局和行为,它定义了处理器状态指令的格局,以及每条指令对状态的影响。大多数ISA,包含x86-64,将程序的行为形容成如同每条指令都是按程序执行的,一条指令完结后,下一条再开始。处理器的硬件远比形容的精密简单,它们并发地执行许多指令,然而能够采取措施保障整体行为与ISA指定的程序执行的行为完全一致。第二种形象是,机器级程序应用的内存地址是虚拟地址,提供的内存模型看上去是一个十分大的字节数组。存储器零碎的理论实现是将多个硬件存储器和操作系统软件组合起来。

汇编代码中的寄存器

  程序计数器(通常称为“PC”,在x86-64中用号%rip示意)给出将要执行的下一条指令在内存中的地址。

  整数寄存器文件蕴含16个命名的地位,别离存储64位的值。这些寄存器能够存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其余的寄存器用来保留长期数据,例如过程的参数和局部变量,以及函数的返回值。

  条件码寄存器保留着最近执行的算术或逻辑指令的状态信息。它们用来实现管制或数据流中的条件变动,比如说用来实现if和 while语句

  一组向量寄存器能够寄存个或多个整数或浮点数值

  对于汇编中罕用的寄存器倡议看我整顿的嵌入式软件开发面试知识点中的ARM局部,外面具体介绍了Arm中罕用的寄存器和指令集。

机器代码示例

  如果咱们有一个main.c文件,应用 gcc -0g -S main.c能够产生一个汇编文件。接着应用gcc -0g -c main.c就能够产生指标代码文件main.o。通常,这个.o文件是二进制格局的,无奈间接查看,咱们关上编辑器能够调整为十六进制的格局,示例如下所示。

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

  这就是汇编指令对应的指标代码。从中失去一个重要信息,即机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码简直无所不知。

反汇编简介

  要查看机器代码文件的内容,有一类称为反汇编器( disassembler)的程序十分有用。这些程序依据机器代码产生一种相似于汇编代码的格局。在 Linux零碎中,应用命令 objdump -d main.o能够产生反汇编文件。示例如下图。

  在右边,咱们看到依照后面给出的字节顺序排列的14个十六进制字节值,它们分成了若干组,每组有1~5个字节。每组都是一条指令,左边是等价的汇编语言

  其中一些对于机器代码和它的反汇编示意的个性值得注意

  • x86-64的指令长度从1到15个字节不等。罕用的指令以及操作数较少的指令所需的字节数少,而那些不太罕用或操作数较多的指令所需字节数较多
  • 设计指令格局的形式是,从某个给定地位开始,能够将字节惟一地解码成机器指令。例如,只有指令 push%rbx是以字节值53结尾的
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不须要拜访该程序的源代码或汇编代码
  • 反汇编器应用的指令命名规定与GCC生成的汇编代码应用的有些轻微的差异。在咱们的示例中,它省略了很多指令结尾的‘q’。这些后缀是大小批示符,在大多数状况中能够省略。相同,反汇编器给ca11和ret指令增加了‘q’后缀,同样,省略这些后缀也没有问题。

数据格式

   Intel用术语“字(word)”示意16位数据类型。因而,称32位数为“双字( double words)”,称64位数为“四字( quad words)。下表给出了C语言根本数据类型对应的x86-64示意。

C申明Intel数据类型汇编代码后缀大小(字节)
char字节b1
shortw2
int双字l4
long四字q8
char*四字q8
float单精度s4
double双精度18

访问信息

操作数批示符

整数寄存器

  不同位的寄存器名字不同,应用的时候要留神。

三种类型的操作数

  1.立刻数,用来示意常数值,比方,$0x1f 。不同的指令容许的立刻数值范畴不同,汇编器会主动抉择最紧凑的形式进行数值编码。

  2.寄存器,它示意某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数别离对应于8位、16位、32位或64位。在图3-3中,咱们用符号${r_a}$来示意任意寄存器a,用援用$R[{r_a}]$来示意它的值,这是将寄存器汇合看成一个数组R,用寄存器标识符作为索引

  3.内存援用,它会依据计算出来的地址(通常称为无效地址)拜访某个内存地位。因为将内存看成一个很大的字节数组,咱们用符号${M_b}[Addr]$示意对存储在内存中从地址Addr开始的b个字节值的援用。为了简便,咱们通常省去下标b。

操作数的格局

  看汇编指令的时候,对照下图能够读懂大部分的汇编代码。

数据传送指令

  不同后缀的指令次要区别在于它们操作的数据大小不同。

  源操作数:寄存器,内存

  目标操作数:寄存器,内存。

留神:传送指令的两个操作数不能都指向内存地位。将一个值从一个内存地位复制到另一个内存地位须要两条指令—第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目标地位。

movl $0x4050,%eax         Immediate--Register,4 bytes p,1sp  move movw %bp,%sp              Register--Register, 2 bytesmovb (%rdi. %rcx),%al     Memory--Register  1 bytesmovb $-17,(%rsp)          Immediate--Memory 1 bytesmovq %rax,-12(%rpb)       Register--Memory, 8 bytes

  将较小的源值复制到较大的目标时应用如下指令。

举例

  过程参数xp和y别离存储在寄存器%rdi和%rsi中(参数通过寄存器传递给函数)。

  第二行:指令movq从内存中读出xp,把它寄存到寄存器%rax中(像x这样的局部变量通常是保留在寄存器中,而不是在内存中)。

  第三行:指令movq将y写入到寄存器%rdi中的xp指向的内存地位。

  第四行:指令ret用寄存器 %rax从这个函数返回一个值。

  总结:

  间接援用指针就是将该指针放在一个寄存器中,而后在内存援用中应用这个寄存器。

  像x这样的局部变量通常是保留在寄存器中,而不是内存中。拜访寄存器比拜访内存要快得多。

压入和弹出栈数据

  pushq指令的性能是把数据压入到栈上,而popq指令是弹出数据。这些指令都只有一个操作数——压入的数据源和弹出的数据目标。

pushq %rbp等价于以下两条指令:

subq $8,%rsp             Decrement stack pointermovq %rbp,(%rsp)       Store %rbp on stack

popq %rax等价于上面两条指令:

mova (%rsp), %rax        Read %rax from stack addq $8,%rsp             Increment stack pointer

算数和逻辑操作

加载无效地址

  IA32指令集中有这样一条加载无效地址指令leal,用法为leal S, D,成果是将S的地址存入D,是mov指令的变形。可是这条指令往往用在计算乘法上,GCC编译器特地喜爱应用这个指令,比方上面的例子

leal (%eax, %eax, 2), %eax

  实现的性能相当于%eax = %eax * 3。括号中是一种比例变址寻址,将第一个数加上第二个数和第三个数的乘积作为地址寻址,leal的成果使源操作数正好是寻址失去的地址,而后将其赋值给%eax寄存器。为什么用这种形式算乘法,而不是用乘法指令imul呢?

  这是因为Intel处理器有一个专门的地址运算单元,使得leal的执行不用通过ALU,而且只须要单个时钟周期。相比于imul来说要快得多。因而,对于大部分乘数为小常数的状况,编译器都会应用leal实现乘法操作。

一元和二元操作
地址
0x1000xFF
0x1080xAB
0x1100x13
0x1180x11
寄存器
%rax0x100
%rcx0x1
%rdx0x3

  看个例子应该就明确这些指令的含意了,不晓得指令意思的,能够看操作数的格局这一节中总结的常见汇编指令的格局。

指令目标解释
addq %rcx,(%rax)0x1000x100将rcx寄存器的值(0x1)加到%rax地址处(0xFF)
subq %rdx,8(%rax)0x1080xA8从8(%rax)地址处取值(0XAB)并减去%rdx的值(0x3)
imulq $16,(%rax,%rdx,8)0x1180x110(0x100+0x3 * 8) = 118.从118的地址取值并乘以10(16)后果为0x110
incq 16(%rax)0x1100x14%rax + 16 = 0x100+10 = 0x110。从0x110取值得0x13,后果+1为0x14。
decq %rcx%rcx0x00x1-1
移位操作

  左移指令:SAL,SHL

  算术右移指令:SAR(填上符号位)

  逻辑右移指令:SHR(填上0)

  移位操作的目标操作数是一个寄存器或是一个内存地位。169

  C语言对应的汇编代码

管制

条件码

条件码的定义

  形容了最近的算术或逻辑操作的属性。能够检测这些寄存器来执行条件分支指令

罕用的条件码

  CF:进位标记。最近的操作使最高位产生了进位。可用来查看无符号操作的溢出。
  ZF:零标记。最近的操作得出的后果为0。
  SF:符号标记。最近的操作失去的后果为正数。
  OF:溢出标记。最近的操作导致一个补码溢出—正溢出或负溢出。

扭转条件码的指令

  cmp指令依据两个操作数之差来设置条件码,罕用来比拟两个数,然而不会扭转操作数。

  test指令用来测试这个数是负数还是正数,是零还是非零。两个操作数雷同

test %rax,%rax //查看%rax是正数、零、还是负数(%rax && %rax)

cmp %rax,%rdi //与sub指令相似,%rdi - %rax 。

  上表中除了leap指令,其余指令都会扭转条件码。

ⅩOR,进位标记和溢出标记会设置成0.对于移位操作,进位标记将设置为最初一个被移出的位,而溢出标记设置为0。INC和DEC指令会设置溢出和零标记。
拜访条件码

拜访条件码的三种形式

  1.能够依据条件码的某种组合,将一个字节设置为0或者1。

  2.能够条件跳转到程序的某个其余的局部。

  3.能够有条件地传送数据。

  对于第一种状况,常应用set指令来设置,set指令如下图所示。

/*计算a<b的汇编代码int comp(data_t a,data_t b)a in %rdi,b in %rsi*/comp:cmpq %rsi,%rdisetl %almovzbl %al,%eaxret

setl %al 当a<b,设置%eax的低位为0或者1。

跳转指令

  上表中的有些指令是带有后缀的,示意条件跳转,上面解释下这些后缀,有助于记忆。

  e == equal,ne == not equal,s == signed,ns == not signed,g == greater,ge == greater or equal,l == less,le == less or eauql,a == ahead,ae == ahead or equal,b == below,be == below or equal

  间接跳转

jmp .L1 //间接给出标号,跳转到标号处

  间接跳转

jmp *%rax  //用寄存器%rax中的值作为跳转指标jmp *(%rax) //以%rax中的值作为读地址,从内存中读出跳转指标
跳转指令的编码

  通过看跳转指令的编码格局了解下程序计数器PC是如何实现跳转的。

  汇编

movq %rdi, %rax jmp .L2.L3:sarq %rax .L2:testq %rax, %rax jg .L3rep;ret

  反汇编

0:48 89 f8      mov %rdi,%raxrdi, 3:eb 03         jmp 8 <loop+0x8>5:48 d1 f8      sar %rax8:48 85 c0      test %rax %raxb:71 f8         jg 5<loop+0x5>d: f3 C3        repz rete

  左边反汇编器产生的正文中,第2行中跳转指令的跳转指标指明为0x8,第5行中跳转指令的跳转指标是0x5(反汇编器以十六进制格局给出所有的数字)。不过,察看指令的宇节编码,会看到第一条跳转指令的指标编码(在第二个字节中)为0x03.把它加上0×5,也就是下一条指令的地址,就失去跳转指标地址0x8,也就是第4行指令的地址。

  相似,第二个跳转指令的指标用单字节、补码示意编码为0xf8(十进制-8)。将这个数加上0xa(十进制13),即第6行指令的地址,咱们失去0x5,即第3行指令的地址。

  这些例子阐明,当执行PC绝对寻址时,程序计数器的值是跳转指令前面的那条指令的地址,而不是跳转指令自身的地址

条件管制实现条件分支

  上图别离给出了C语言,goto示意,汇编语言的三种模式。这里应用goto语句,是为了结构形容汇编代码程序控制流的C程序。

  汇编代码的实现(图3-16c)首先比拟了两个操作数(第2行),设置条件码。如果比拟的结果表明x大于或者等于y,那么它就会跳转到第8行,减少全局变量 ge_cnt,计算x-y作为返回值并返回。由此咱们能够看到 absdiff_se对应汇编代码的控制流十分相似于gotodiff_ se的goto代码。

  C语言中的if-else通用模版如下:

  对应的汇编代码如下:

条件传送实现条件分支

  GCC为该函数产生的汇编代码如图3-17c所示,它与图3-17b中所示的C函数cmovdiff有类似的模式。钻研这个C版本,咱们能够看到它既计算了y-x,也计算了x-y,别离命名为rval和eval。而后它再测试x是否大于等于y,如果是,就在函数返回rval前,将eval复制到rval中。图3-17c中的汇编代码有雷同的逻辑。要害就在于汇编代码的那条 cmovge指令(第7行)实现了 cmovdiff的条件赋值(第8行)。只有当第6行的cmpq指令表明一个值大于等于另一个值(正如后缀ge表明的那样)时,才会把数据源寄存器传送到目标

  条件管制的汇编模版如下:

  实际上,基于条件数据传送的代码会比基于条件管制转移的代码性能要好。次要起因是处理器通过应用流水线来取得高性能,处理器采纳十分精细的分支预测逻辑来猜想每条跳转指令是否会执行。只有它的猜想还比拟牢靠(古代微处理器设计试图达到90%以上的成功率),指令流水线中就会充斥着指令。另一方面,谬误预测一个跳转,要求处理器丢掉它为该跳转指令后所有指令已做的工作,而后再开始用从正确地位处起始的指令去填充流水线。这样一个谬误预测会导致很重大的惩办,节约大概15~30个时钟周期,导致程序性能重大降落

  应用条件传送也不总是会进步代码的效率。例如,如果 then expr或者 else expr的求值须要大量的计算,那么当绝对应的条件不满足时,这些工作就徒劳了。编译器必须思考节约的计算和因为分支预测谬误所造成的性能处罚之间的绝对性能。说实话,编译器井不具备足够的信息来做出牢靠的决定;例如,它们不晓得分支会多好地遵循可预测的模式。咱们对GCC的试验表明,只有当两个表达式都很容易计算时,例如表达式别离都只是条加法指令,它才会应用条件传送。依据咱们的教训,即便许多分支预测谬误的开销会超过更简单的计算,GCC还是会应用条件管制转移。

  所以,总的来说,条件数据传送提供了一种用条件管制转移来实现条件操作的代替策略。它们只能用于十分受限制的状况,然而这些状况还是相当常见的,而且与古代处理器的运行形式更符合。

循环

  将循环翻译成汇编次要有两种办法,第一种咱们称为跳转到两头,它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试。第二种办法叫guarded-do,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-whie循环。当应用较髙优化等级编译时,例如应用命令行选项-O1,GCC会采纳这种策略。

跳转到两头

  如下图所示为while循环写的计算阶乘的代码。能够看到编译器应用了跳转到两头的翻译办法,在第3行用jmp跳转到以标号L5开始的测试,如果n满足要求就执行循环,否则就退出。

guarded-do

  下图为应用第二种办法编译的汇编代码,编译时是用的是-O1,GCC就会采纳这种形式编译循环。

  下面介绍的是while循环和do-while循环的两种编译模式,依据GCC不同的优化后果会失去不同的汇编代码。实际上,for循环产生的汇编代码也是以上两种汇编代码中的一种。for循环的通用模式如下所示。

  抉择跳转到两头策略会失去如下goto代码:

  guarded-do策略会失去如下goto代码:

suitch语句

  switch语句能够依据一个整数索引值进行多重分支。它们不仅进步了C代码的可读性而且通过应用跳转表这种数据结构使得实现更加高效。跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作。

  程序代码用开关索引值来执行一个跳转表内的数组援用,确定跳转指令的指标。和应用组很长的if-else语句相比,应用跳转表的长处是执行开关语句的工夫与开关状况的数量无关。GCC依据开关状况的数量和开关状况值的稠密水平来翻译开关语句。当开关状况数量比拟多(例如4个以上),并且值的范畴跨度比拟小时,就会应用跳转表。

  原始的C代码有针对值100、102104和106的状况,然而开关变量n能够是任意整数。编译器首先将n减去100,把取值范畴移到0和6之间,创立一个新的程序变量,在咱们的C版本中称为 index。补码示意的正数会映射成无符号示意的大负数,利用这一事实,将 index看作无符号值,从而进一步简化了分支的可能性。因而能够通过测试 index是否大于6来断定index是否在0~6的范畴之外。在C和汇编代码中,依据 index的值,有五个不同的跳转地位:loc_A(.L3),loc_B(.L5),loc_C(.L6),loc_D(.L7)和 loc_def(.L8),最初一个是默认的目标地址。每个标号都标识一个实现某个状况分支的代码块。在C和汇编代码中,程序都是将 index和6做比拟,如果大于6就跳转到默认的代码处

  执行 switch语句的关键步骤是通过跳转表来拜访代码地位。在C代码中是第16行一条goto语句援用了跳转表jt。GCC反对计算goto,是对C语言的扩大。在咱们的汇编代码版本中,相似的操作是在第5行,jmp指令的操作数有前缀‘ * ’,表明这是一个间接跳转,操作数指定一个内存地位,索引由寄存器%rsi给出,这个寄存器保留着 index的值。

  C代码将跳转表申明为一个有7个元素的数组,每个元素都是一个指向代码地位的指针。这些元素逾越 index的值0 ~ 6,对应于n的值100~106。能够察看到,跳转表对反复状况的解决就是简略地对表项4和6用同样的代码标号(loc_D),而对于缺失的状况的解决就是对表项1和5应用默认状况的标号(loc_def)

  在汇编代码中,跳转表申明为如下模式

  (.rodata段的具体解释在我总结的嵌入式软件开发口试面试知识点中有具体介绍)

已知switch汇编代码,如何利用汇编语言和跳转表的构造推断出switch的C语言构造?

  对于C语言的switch语句,须要重点确定的有跳转表的大小,跳转范畴,那些case是缺失的,那些是反复的。上面咱们一 一确定。

  这些表申明中,从图3-23的汇编第1行能够晓得,n的起始计数为100。由第二行能够晓得,变量和6进行比拟,阐明跳转表索引偏移范畴为0 ~ 6,对应为100 ~106。从.quad .L3开始,由上到下,顺次编号为0,1,2,3,4,5,6。其中由图3-23的ja .L8可知,大于6时就跳转到.L8,那么跳转表中编号为1和5的都是跳转的默认地位。因而,编号为1和5的为缺失的状况,即没有101和105的选项。而编号为4和6的都跳转到了.L7,阐明两者是对应于100+4=104,100+6=106。剩下的状况0,2,3顺次编号为100,102,103。至此咱们就得出了switch的编号状况,一共有6项,100,102,103,104,106,default。剩下的对于每种case的C语言内容就能够依据汇编代码写进去了。

过程

运行时栈

  C语言过程调用机制的一个要害个性(大多数其余语言也是如此)在于应用了栈数据结构提供的后进先出的内存治理准则。如果在过程P调用过程Q时,能够看到当Q在执行时,P以及所有在向上追溯到P的调用链中的过程,都是临时被挂起的。当Q运行时,它只须要为局部变量调配新的存储空间,或者设置到另一个过程的调用。另一方面,当Q返回时,任何它所调配的部分存储空间都能够被开释。因而,程序能够用栈来治理它的过程所须要的存储空间,栈和程序寄存器寄存着传递管制和数据、分配内存所须要的信息。当P调用Q时,管制和数据信息增加到栈尾。当P返回时,这些信息会开释掉。

  x86-64的栈向低地址方向增长,而栈指针号%rsp指向栈顶元素。能够用 pushq和popq指令将数据存人栈中或是从栈中取出。将栈指针减小一个适当的量能够为没有指定初始值的数据在栈上调配空间。相似地,能够通过减少栈指针来开释空间。

  过程P能够传递最多6个整数值(也就是指针和整数),然而如果Q须要更多的参数,P能够在调用Q之前在本人的栈帧(也就是内存)里存储好这些参数。

转移管制

  将管制从函数转移到函数Q只须要简略地把程序计数器(PC)设置为Q的代码的起始地位。不过,当稍后从Q返回的时候,处理器必须记录好它须要持续P的执行的代码地位。在x86-64机器中,这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令前面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。

  上面看个例子

  main调用top(100),而后top调用leaf(95)。函数leaf向top返回97,而后top向main返回194.后面三列形容了被执行的指令,包含指令标号、地址和指令类型。前面四列给出了在该指令执行前程序的状态,包含寄存器%rdi、%rax和%rsp的内容,以及位于栈顶的值。

  leaf的指令L1将%rax设置为97,也就是要返回的值。而后指令L2返回,它从栈中弹出0×400054e。通过将PC设置为这个弹出的值,管制转移回top的T3指令。程序胜利实现对leaf的调用,返回到top。

  指令T3将%rax设置为194,也就是要从top返回的值。而后指令T4返回,它从栈中弹出0×4000560,因而将PC设置为main的M2指令。程序胜利实现对top的调用,返回到main。能够看到,此时栈指针也复原成了0x7fffffffe820,即调用top之前的值。

 这种把返回地址压入栈的简略的机制可能让函数在稍后返回到程序中正确的点。C语言规范的调用/返回机制刚好与栈提供的后进先出的内存治理办法吻合。

数据传送

  X86-64中,能够通过寄存器来传递最多6个参数。寄存器的应用是有非凡程序的,如下表所示,会依据参数的程序为其调配寄存器。

  当传递参数超过6个时,会把大于6个的局部放在栈上。

  如下图所示的局部,红框内的参数就是存储在栈上的。

栈上的部分存储

  通常来说,不须要超出寄存器大小的本地存储区域。不过有些时候,部分数据必须寄存在内存中,常见的状况包含:1.寄存器不足够寄存所有的本地数据。
2.对一个局部变量应用地址运算符‘&‘,因而必须可能为它产生一个地址。3.某些局部变量是数组或构造,因而必须可能通过数组或构造援用被拜访到。

  上面看一个例子。

  第二行的subq指令将栈指针减去32,实际上就是调配了32个字节的内存空间。在栈指针的根底上,别离+24,+20,+18,+17,用来寄存1,2,3,4的值。在第7行中,应用leaq生成到17(%rsp)的指针并赋值给%rax。接着在栈指针根底上+8和+16的地位寄存参数7和参数8。而参数1-参数6别离放在6个寄存器中。栈帧的构造如下图所示。

  上述汇编中第2-15行都是在为调用proc做筹备(为局部变量和函数建设栈帧,将函数加载到寄存器)。当筹备工作实现后,就会开始执行proc的代码。当程序返回call_proc时,代码会取出4个局部变量(第17~20行),并执行最终的计算。在程序完结前,把栈指针加32,开释这个栈帧。

寄存器中的部分存储

  寄存器组是惟一被所有过程共享的资源。因而,在某些调用过程中,咱们要不同过程调用的寄存器不能相互影响。

  依据常规,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保留寄存器。当过程P调用过程Q时,Q必须保留这些寄存器的值,保障它们的值在Q返回到P时与Q被调用时是一样的。过程Q保留一个寄存器的值不变,要么就是基本不去扭转它,要么就是把原始值压入栈中。有了这条常规,P的代码就能平安地把值存在被调用者保留寄存器中(当然,要先把之前的值保留到栈上),调用Q,而后持续应用寄存器中的值。

  上面看个例子。

  能够看到GCC生成的代码应用了两个被调用者保留寄存器:%rbp保留x和%rbx保留计算出来的Q(y)的值。在函数的结尾,把这两个寄存器的值保留到栈中(第2~3行)。在第一次调用Q之前,把参数ⅹ复制到%rbp(第5行)。在第二次调用Q之前,把这次调用的后果复制到%rbx (第8行)。在函数的结尾,(第13~14行),把它们从栈中弹出,复原这两个被调用者保留寄器的值。留神它们的招抚入程序,阐明了栈的后进先出规定。

递归过程

  依据之前的内容能够晓得,多个过程调用在栈中都有本人的公有空间,多个未实现调用的局部变量不会相互影响,递归实质上也是多个过程的互相调用。如下所示为一个计算阶乘的递归调用。

  上图给出了递归的阶乘函数的C代码和生成的汇编代码。能够看到汇编代码应用寄存器%rbx来保留参数n,先把已有的值保留在栈上(第2行),随后在返回前复原该值(第11行)。依据栈的应用个性和寄存器保留规定,能够保障当递归调用 refact(n-1)返回时(第9行),(1)该次调用的后果会保留在寄存器号%rax中,(2)参数n的值依然在寄存器各%rbx中。把这两个值相乘就能失去冀望的后果。

数组调配和拜访

根本准则

  在机器代码级是没有数组这一更高级的概念的,只是你将其视为字节的汇合,这些字节的汇合是在间断地位上存储的,构造也是如此,它就是作为字节汇合来调配的,而后,C 编译器的工作就是生成适当的代码来调配该内存,从而当你去援用构造或数组的某个元素时,去获取正确的值。

  数据类型T和整型常数N,申明一个数组T A[N]。起始地位示意为${X_A}$.这个申明有两个成果。首先,它在内存中调配一个$L \bullet N$字节的间断区域,这里L是数据类型T的大小(单位为字节)。其次,它引入了标识符A,能够用来作A为指向数组结尾的指针,这个指针的值就是${X_A}$。能够用0~N-1的整数索引来拜访该数组元素。数组元素i会被寄存在地址为${X_A} + L \bullet i$的中央。

char A[12];

char *B[8];

char C[6];

char *D[5];

数组元素大小总的大小起始地址元素i
A112${X_A}$${X_A}+i$
B864${X_B}$${X_B}+8i$
C424${X_C}$${X_C}+4i$
D840${X_D}$${X_D}+8i$
  指针运算

  假如整型数组E的起始地址和整数索引i别离寄存在寄存器是%rdx和%rcx中。上面是一些与E无关的表达式。咱们还给出了每个表达式的汇编代码实现,后果寄存在寄存器号%eax(如果是数据)或寄存器号%rax(如果是指针)中。

二维数组

  对于一个申明为T D[R] [C]的二维数组来说,数组D[i] [j]的内存地址为${X_D} + L(C \bullet i + j)$。

  这里,L是数据类型T以字节为单位的大小。假如${X_A}$、i和j别离在寄存器%rdi、%rsi和%rdx中。而后,能够用上面的代码将数组元素A[i] [j]复制到寄存器%eax中:

/*A in %rdi, i in %rsi, and j in %rdx*/ leaq (%rsi,%rsi,2), %rax //Compute 3ileaq (%rdi,%rax,4),%rax //Compute XA+ 12i movl (7rax, rdx, 4), %eax //Read from M[XA+ 12i+4j]

异质的数据结构

构造体

  C语言的 struct申明创立一个数据类型,将可能不同类型的对象聚合到一个对象中。构造的所有组成部分都寄存在内存中一段间断的区域内,而指向构造的指针就是构造第个字节的地址。编译器保护对于每个构造类型的信息,批示每个字段( field)的字节偏移。它以这些偏移作为内存援用指令中的位移,从而产生对构造元素的援用。

  构造体在内存中是以偏移的形式存储的,具体能够看这个文章。Linux内核中container_of宏的具体解释。

struct rec {    int i;    int j;    int a[2];    int *p;};

  这个构造包含4个字段:两个4字节int、一个由两个类型为int的元素组成的数组和一个8字节整型指针,总共是24个字节。

  看汇编代码也能够看出,构造体成员的拜访是基地址加上偏移地址的形式。例如,假如 struct rec*类型的变量r放在寄存器%rdi中。那么上面的代码将元素r->i复制到元素r->j:

/*Registers:r in %rdi,i %rsi */movl (%rdi), %eax //Get r->i movl %eax, 4(%rdi) //Store in r-27leaq  8(%rdi,%rsi,4),//%rax 失去一个指针,8+4*%rsi,&(r->a[i])
数据对齐

  对于字节对齐的相干内容见我整顿的《嵌入式软件口试面试知识点总结》外面具体介绍了字节对齐的相干内容。

在机器级程序中将管制和程序联合起来

了解指针

  对于指针的几点阐明:

  1.每个指针都对应一个类型

int *ip;//ip为一个指向int类型对象的指针char **cpp;//cpp为指向指针的指针,即cpp指向的自身就是一个指向char类型对象的指针void *p;//p为通用指针,malloc的返回值为通用指针,通过强制类型转换能够转换成咱们须要的指针类型

  2.每个指针都有一个值。这个值能够是某个指定类型的对象的地址,也能够是一个非凡的NULL(0)。

  3.指针用&运算符创立。在汇编代码中,用leaq指令计算内存援用的地址。

int i = 0;int *p = &i;//取i的地址赋值给p指针

  4.* 操作符用于间接援用指针。援用的后果是一个具体的数值,它的类型与该指针的类型统一。

  5.数组与指针紧密联系,然而又有所区别。

int a[10] ={0};

一个数组的名字能够像一个指针变量一样援用(然而不能批改)。数组援用(例如a[5]与指针运算和间接援用(例如*(a+5))有一样的成果。

数组援用和指针运算都须要用对象大小对偏移量进行伸缩。当咱们写表达式a+i,这里指针p的值为a,失去的地址计算为a+L * i,这里L是与a相关联的数据类型的大小。

数组名对应的是一块内存地址,不能批改。指针指向的是任意一块内存,其值能够随便批改。

  6.将指针从一种类型强制转換成另一种类型,只扭转它的类型,而不扭转它的值。强制类型转换的一个成果是扭转指针运算的伸缩。例如,如果a是一个char 类型的指针,它的值为a,a+7后果为a+7 1,而表达式(int )p+7后果为p+4 7。

内存越界援用

  C对于数组援用不进行任何边界查看,而且局部变量和状态信息(例如保留的寄存器值和返回地址)都寄存在栈中。这两种状况联合到一起就能导致重大的程序谬误,对越界的数组元素的写操作会毁坏存储在栈中的状态信息。当程序应用这个被毁坏的状态,就会呈现很重大的谬误,一种特地常见的状态毁坏称为缓冲区溢出( buffer overflow)。

  上述C代码,buf只调配了8个字节的大小,任何超过7字节的都会使的数组越界。

  输出不同数量的字符串会产生不同的谬误,具体能够参考下图。

  echo函数的栈散布如下图所示。

  字符串到23个字符之前都没有重大的结果,然而超过当前,返回指针的值以及更多可能的保留状态会被毁坏。如果存储的返回地址的值被毁坏了,那么ret指令(第8行)会导致程序跳转到一个齐全意想不到的地位。如果只看C代码,基本就不可能看出会有下面这些行为。只有通过钻研机器代码级别旳程序能力了解像gets这样的函数进行的内存越界写的影响。

浮点代码

  计算机中的浮点数能够说是"另类"的存在,每次提到数据相干的内容时,浮点数总是会被独自拿出来说。同样,在汇编中浮点数也是和其余类型的数据有所差异的,咱们须要思考以下几个方面:1.如何存储和拜访浮点数值。通常是通过某种寄存器形式来实现2.对浮点数据操作的指令3.向函数传递浮点数参数和从函数返回浮点数后果的规定。4.函数调用过程中保留寄存器的规定—例如,一些寄存器被指定为调用者保留,而其余的被指定为被调用者保留。

  X86-64浮点数是基于SSE或AVX的,包含传递过程参数和返回值的规定。在这里,咱们解说的是基于AVX2。在利用GCC进行编译时,加上-mavx2,GCC会生成AVX2代码。

  如下图所示,AVX浮点体系结构容许数据存储在16个YMM寄存器中,它们的名字为%ymm0~%ymm15。每个YMM寄存器都是256位(32字节)。当对标量数据操作时,这些寄存器只保留浮点数,而且只应用低32位(对于float)或64位(对于 double)。汇编代码用寄存器的 SSE XMM寄存器名字%xmm0~%xmm15来援用它们,每个XMM寄存器都是对应的YMM寄存器的低128位(16字节)。

   其实浮点数的汇编指令和整数的指令都是差不多的,不须要都记住,用到的时候再查问就能够了。

数据传送指令

双操作数浮点转换指令

三操作数浮点转换指令

标量浮点算术运算

浮点数的位级操作

比拟浮点数值的指令


  在本章中,咱们理解了C语言提供的形象层上面的货色。通过让编译器产生机器级程序的汇编代码示意,咱们理解了编译器和它的优化能力,以及机器、数据类型和指令集。本章要求咱们要能浏览和了解编译器产生的机器级代码,机器指令并不需要都记住,在须要的时候查就能够了。Arm的指令集和X86指令集大同小异,做嵌入式软件开发把握罕用的Arm指令集就能够。嵌入式软件开发知识点具体介绍了罕用的Arm指令集及其含意,有须要的能够关注我的公众号支付。

  养成习惯,先赞后看!如果感觉写的不错,欢送关注,点赞,转发,谢谢!

如遇到排版错乱的问题,能够通过以下链接拜访我的CSDN。

CSDN:CSDN搜寻“嵌入式与Linux那些事”

欢送欢送关注我的公众号:嵌入式与Linux那些事,支付秋招口试面试大礼包(华为小米等大厂面经,嵌入式知识点总结,口试题目,简历模版等)和2000G学习材料。