CS:APP学习笔记之程序的机器级表示(三)
本文记录《深入理解计算机系统》第3版中第3章程序的机器级表示的一些知识点。
第3章 程序的机器级表示
程序编码
两种抽象
对于机器级编程来说,其中两种抽象尤为重要。
第一种是由指令集体系结构或指令集架构(Instruction Set Architecture, ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA,包括x86-64,将程序的行为描述成好像每条指令都是按顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行的行为完全一致。
第二种抽象是,机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
处理器状态
x86-64 的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:
- 程序计数器(通常称为PC,在x86-64中用
%rip
表示)给出将要执行的下一条指令在内存中的地址。 - 整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
- 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现
if
和while
语句。 - 一组向量寄存器可以存放一个或多个整数或浮点数值。
编译和调试命令
1 | # 编译c文件,编译选项-Og高速编译器使用会生成符合源代码结构的机器代码的优化等级 |
数据格式

如图所示,大多数GCC生成的汇编代码指令都有一个字符的后缀,表明操作数的大小。例如,数据传送指令有四个变种:
movb(传送字节)、movw(传送字)、 movl(传送双字)和
movq(传送四字)。后缀l
用来表示双字,因为32位数被看成是“长字
(long word) ”。注意,汇编代码也使用后缀l
来表示4字节整数和8字节双精度浮点数。这不会产生歧义,因为浮点数使用的是一组完全不同的指令和寄存器。
访问信息
整数寄存器

操作数指示符
大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。 x86-64支持多种操作数格式。源数据值可以以常数形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数的可能性被分为三种类型。
第一种类型是立即数 (immediate) ,用来表示常数值。
第二种类型是寄存器( register ),它表示某个寄存器的内容。
第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。

数据传送指令
源操作数指定的值是一个立即数,存储在寄存器中或者内存中。目的操作数指定一个位置,一个寄存器或者是一个内存地址。 x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。

图3-5和图3-6记录的是两类数据移动指令,在将较小的源值复制到较大的目的时使用。所有这些指令都把数据从源(在寄存器或内存中)复制到目的寄存器。 MOVZ类中的指令把目的中剩余的字节填充为0,而MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。可以观察到,每条指令名字的最后两个字符都是大小指示符:第一个字符指定源的大小,而第二个指明目的的大小。

压入和弹出栈数据

在x86-64中,程序栈存放在内存中某个区域。如下图所示,栈向下增长,这样一来,栈顶元素的地址是所有栈中元素地址中最低的。栈指针%rsp
保存着栈顶元素的地址。

算术和逻辑操作
x86-64的一些整数和逻辑操作可分为四组:加载有效地址、一元操作、二元操作和移位。

加载有效地址
leaq
是 x86-64
汇编中一个非常重要且”独特”的指令。它的全称是 Load Effective
Address to Register(将有效地址加载到寄存器)。
核心概念:它不访问内存!
这是理解 leaq
最关键的一点:leaq
计算一个内存地址,但它并不去读取这个地址里的数据!它只是将计算出的地址值本身存入寄存器。
基本语法
1 | leaq SRC, DEST |
- SRC:一个内存操作数(寻址模式)
- DEST:一个寄存器
- 效果:
DEST = address_of(SRC)
特性 | leaq |
mov (内存到寄存器) |
---|---|---|
主要功能 | 计算地址 | 从内存加载数据 |
是否访问内存 | ❌ 否 | ✅ 是 |
实际用途 | 1. 地址计算 2. 整数运算 3. 指针运算 |
数据传输 |
简单记忆:leaq
是”披着内存操作外衣的算术指令”——它穿着内存寻址的”衣服”(语法),但干的是地址计算或普通算术的”活儿”。
这个指令的巧妙之处在于硬件设计者提供了一个强大的算术单元(用于地址计算),而软件开发者发现可以”借用”这个单元来做通用的数学运算,从而获得了性能优势。
一元和二元操作
一元操作只有一个操作数,既是源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。比如说,指令incq (%rsp)
会使栈顶的8字节元素加
1 。这种语法让人想起 C 语言中的加1运算符(++)和减1运算符(–)。
二元操作中第二个操作数既是源又是目的。这种语法让人想起C语言中的赋值运算符,例如
x -=y
。例如,指令subq %rax, %rdx
使寄存器%rdx
的值减去%rax
中的值。(将指令解读成从
%rdx
中减去打 %rax
会有所帮助。)第一个操作数可以是立即数、寄存器或是内存位置。第二个操作数可以是寄存器或是内存位置。注意,当第二个操作数为内存地址时,处理器必须从内存读出值,执行操作,再把结果写回内存。
移位操作
移位操作先给出移位量,然后第二项给出的是要移位的数。可以进行算术和逻辑右移。移位量可以是一个立即数,或者放在单字节寄存器%cl
中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数)
控制
条件码寄存器
除了整数寄存器, CPU还维护着一组单个位的条件码 (condition code) 寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:
- CF :进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
- ZF:零标志。最近的操作得出的结果为0。
- SF :符号标志。最近的操作得到的结果为负数。
- OF :溢出标志。


跳转指令

条件控制的汇编
C语言中if-else
语句的通用形式模板如下:
1 | if(test-expr) |
对应的汇编实现通常会使用下面的形式,这里用C语法来描述控制流:
1 | t = test-expr; |
条件传送的汇编
条件传送一般只用于分支语句比较简单的情况。
为了理解如何通过条件数据传输来实现条件操作,考虑下面的条件表达式和赋值的通用形式:
v = test-expr ? then-expr : else-expr;
用条件控制转移的标准方法来编译这个表达式会得到如下形式:
1 | if(!test-expr) |
这段代码包含两个代码序列:一个对then-expr
求值,另一个对else-expr
求值。
基于条件传送的代码,会对then-expr
和else-expr
都求值,最终值的选择基于对test-expr
的求值。可以用下面的抽象代码描述:
1 | vt = then-expr; |
这个序列中的最后一条语句是用条件传送实现的–只有当测试条件t
满足时,vt
的值才会被复制到v
中。
do-while的汇编
do-while
语句的通用形式如下:
1 | do |
这种通用形式可以被翻译成如下所示的条件和goto
语句:
1 | loop: |
while的汇编
while
语句的通用形式如下:
1 | while(test-expr) |
GCC在代码翻译中使用两种方法,跳转到中间(jump to middle)和 guarded-do。
jump to middle
用以下模板表示这种方法:
1 | goto test; |
guarded-do
首先用条件分支,如果初始条件不成立就过循环,把代码变换为
do-while
循环。当使用较高优化等级编译时,例如使用命令行O1
, GCC
会采用这种策略。可以用如下模板来表达这种方法,把通用的
while
循环翻译成do-while
循环:
1 | t = test-expr; |
翻译成goto
代码如下:
1 | t = test-expr; |
for循环的汇编
for
循环的通用形式如下:
1 | for(init-expr; test-expr; update-expr) |
可将其转换为while
循环:
1 | init-expr; |
GCC为for
循环翻译的汇编代码是while
循环的两种翻译之一,这取决于优化的等级。
过程
什么是过程
不同编程语言中,过程的形式多样:函数、方法、子例程、处理函数等,但是它们都有一些共有特性。
要提供对过程的机器级支持,必须处理不同属性。为讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
传递控制。在进入过程Q时,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
运行时栈
在x86 - 64架构的System V ABI规范下,对于超过6个的函数参数,压栈时是先压后面的参数,局部变量也是先压后面的参数。
在机器级程序中将控制与数据结合起来
对抗缓冲区溢出攻击的方法
栈随机化
栈随机化的思想使得栈的位置在程序每次运行时都有变化。
在 Linux 系统中,栈随机化已经变成了标准行为。它是更大的一类技术中的一种,这类技术称为地址空间布局随机化 (Address-Space Layout Randomization) ,或者简称 ASLR。采用 ASLR ,每次运行时程序的不同部分,包括程序代码、库代码、栈、全局变量和堆数据,都会被加载到内存的不同区域。这就意味着在一台机器上运行一个程序,与在其他机器上运行同样的程序,它们的地址映射大相径庭。这样才能够对抗一些形式的攻击。
栈破坏检测
最近的 GCC 版本在产生的代码中加入了一种栈保护者( stack protector) 机制,来检测缓冲区越界。其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值(canary)。这个金丝雀值,也称为哨兵值(guard value) ,是在程序每次运行时随机产生的,因此,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者该函数调用的某个函数的某个操作改变了。如果是的,那么程序异常中止。
栈保护很好地防止了缓冲区溢出攻击破坏存储在程序栈上的状态。它只会带来很小的能损失,特别是因为 GCC 只在函数中有局部 char 类型缓冲区的时候才插人这样的代码。
限制可执行代码区域
限制哪些内存区域能够存放可执行代码。在典型的程序中,只有保存编译器产生的代码的那部分内存才需要是可执行的。其他部分可以被限制为只允许读和写。