关于c:深入理解计算机系统读书笔记-第三章-程序的机器级表示

8次阅读

共计 14837 个字符,预计需要花费 38 分钟才能阅读完成。

本章次要介绍了计算机中的机器代码——汇编语言。当咱们应用高级语言(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 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 8
float 单精度 s 4
double 双精度 1 8

访问信息

操作数批示符

整数寄存器

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

三种类型的操作数

  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 bytes
movb (%rdi. %rcx),%al     Memory--Register  1 bytes
movb $-17,(%rsp)          Immediate--Memory 1 bytes
movq %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 pointer
movq %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 实现乘法操作。

一元和二元操作
地址
0x100 0xFF
0x108 0xAB
0x110 0x13
0x118 0x11
寄存器
%rax 0x100
%rcx 0x1
%rdx 0x3

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

指令 目标 解释
addq %rcx,(%rax) 0x100 0x100 将 rcx 寄存器的值(0x1)加到 %rax 地址处(0xFF)
subq %rdx,8(%rax) 0x108 0xA8 从 8(%rax)地址处取值(0XAB)并减去 %rdx 的值(0x3)
imulq $16,(%rax,%rdx,8) 0x118 0x110 (0x100+0x3 * 8)= 118. 从 118 的地址取值并乘以 10(16)后果为 0x110
incq 16(%rax) 0x110 0x14 %rax + 16 = 0x100+10 = 0x110。从 0x110 取值得 0x13,后果 + 1 为 0x14。
decq %rcx %rcx 0x0 0x1-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,%rdi
setl %al
movzbl %al,%eax
ret

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 .L3
rep;ret

  反汇编

0:48 89 f8      mov %rdi,%raxrdi, 
3:eb 03         jmp 8 <loop+0x8>
5:48 d1 f8      sar %rax
8:48 85 c0      test %rax %rax
b: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
A 1 12 ${X_A}$ ${X_A}+i$
B 8 64 ${X_B}$ ${X_B}+8i$
C 4 24 ${X_C}$ ${X_C}+4i$
D 8 40 ${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 3i
leaq (%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-27
leaq  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 学习材料。

正文完
 0