C语言代码->机器代码的过程
GCC 以汇编代码的形式产出,后GCC调用汇编器和链接器,产生可执行的机器代码
汇编代码:机器代码的文本表示,给出程序中的每一条指令。
历史观点
没有过多的内容就是一些处理器的发展
程序编码
gcc -Og -o p pl.c p2.c // 生成比较好阅读的汇编代码
C语言将源代码转化成可执行代码的过程
- C预处理器扩展C语言源代码,将#include指定的文件插入,并扩展所有用#define声明的宏
- 编译器产生源文件汇编代码
- 汇编器将汇编代码转换成二进制的目标代码文件(目标代码是机器代码的一种形式,但没有全局值的地址)
- 链接器将目标文件代码与实现的库函数代码进行合并产生最终的可执行代码(可执行代码是机器代码的第二种形式,就是处理器执行代码格式)
机器级代码
机器级编程的两种抽象
- ISA定义机器级程序的格式和行为(ISA定义了处理器状态、指令的格式,以及每条指令对状态的影响)
- 机器级程序使用的内存地址是虚拟地址
常见寄存器
- 程序计数器(通称为PC,x86表示为%rip) 给出将要执行下一条指令在内存中的地址
- 整数寄存器 存储地址或整数数据,有些寄存器用来记录某些重要的程序状态,其他的寄存器用来保存临时数据
- 条件码寄存器 保存最近执行的算数或逻辑指令的状态信息
- 向量寄存器 保存一个或多个整数或浮点值
程序内存的组成
- 可执行的机器代码
- 操作系统需要的一些信息
- 运行时的栈以及管理过程调用的栈
- 用户分配的内存块(例如malloc分配到heap上的)
代码示例
gcc -Og -S mstore.c// gcc编译该代码 产生汇编代码
gcc -Og -c mstore.c// gcc汇编该代码 汇编器进行汇编
objdump -d mstore.o// 查看机器代码内容
格式的注解
以 . 开头的命令都是指导汇编器和链接器的工作的伪命令,阅读时可以忽略。
数据格式
| C声明 | Intel数据类型 | 汇编代码后缀 | 大小(字节) |
|---|---|---|---|
| char | 字节 | b | 1 |
| short | 字 | w | 2 |
| int | 双字 | l | 4 |
| long | 四字 | q | 8 |
| char * | 四字 | q | 8 |
| float | 单精度 | s | 4 |
| double | 双精度 | l | 8 |
访问信息
一个 x86-64的CPU 包含一组16个存储64位的通用目的寄存器
操作数指示符
大多数指令含有多个操作数(operand)
定义:指示出执行一个操作中要使用的源数据值以及放置结果的目的位置
| 类型 | 表示(在ATT格式下) |
|---|---|
| 立即数 | $0x1F |
| 寄存器 | 将寄存器看成一个数组,通过索引获取值 |
| 内存引用 | 通过计算出的地址访问内存的某个位置(有多种寻址方式) |
内存引用的多种寻址方式
tips
x86-64内存引用的寄存器必须是4个字
数据传送指令
| 指令 | 操作数据大小 |
|---|---|
| movb | 传送字节 |
| movw | 传送字 |
| movl | 传送双字 |
| movq | 传送四字 |
| movabsq | 传送绝对四字 |
根据源和目的分为五类(这种没有大小转换的一定要大小匹配)
- immediate - register(通过两个都可以推出)
- register - register(通过两个都可以推出)
- memory - register(size通过register推出)
- immediate - memory(size通过immediate推出)
- register - memory (size通过register推出)
tips
- 注意没有 以immediate作为destination的指令
- memory - memory 需要两条指令 1.将数据从内存加载到寄存器 2. 将寄存器中值写入destination
- movabsq 以任意64位immediate为源操作数 destination只能为寄存器
- 三种类型都有扩展到64位的符号扩展,只有两种较小的源类型有零扩展
将较小值复制到较大的destination
- MOVZ (目的剩余字节填充为0)
- MOVS (目的剩余字节复制源的最高位)
将较大值复制到较小的destination
- 直接截断即可
数据传送示例
练习 3-4
- 低 -> 高 先转换 再存 要根据源是否为有符号数字来看是movz 还是 movs
- 高 -> 低 先全读 再截取
压入和弹出栈数据
- 栈指针保存着栈顶元素的地址
- 栈指针保存在%rsp中
- 栈的扩展在x86-64是自顶向下的
pushq %rbp(将数据压入栈)
等价于:
1.subq $8,%rsp (将栈指针减去8)
2.pushq %rbp,(%rsp)(将数据压入栈中)
popq %rdx(从栈中弹出元素)
等价于:
1.movq(%rsp),%rax;(从栈中取出数据)
2.addq $8,%rsp;(将栈指针加8)
算术和逻辑操作
操作分为四组
- 加载有效地址
- 一元操作
- 二元操作
- 移位
加载有效地址
tips:
- 第一个操作数看似是内存引用,但该指令实际上是将有效地址写入目的操作数
- leap能执行加法和有限形式的乘法,在编译某些简单的算术表达式很有用(如下)
一元操作
只有一个操作数,既是源又是目的.这个操作数可以是一个寄存器也可以是一个内存位置
immediate通常存的都是一个const值所以不用在一元操作中
二元操作
- 第二个操作数既是源又是目的
- 第一个操作数可以是立即数,寄存器,内存位置
- 当第二个操作数是内存位置时,需要先读,再操作,最后写回内存
移位操作
- 第一个是移位量,第二位是移位的数
- 移位量可以是一个立即数,或者放在单字节寄存器%cl中(only该寄存器)
- 移位操作对 w 位长的数据值进行操作,移位量是由 %cl 寄存器的低 m 位决定的,这里 。高位会被忽略。所以,例如当 寄存器 %cl 的十六进制值为 时,指令 salb 会移 7 位, salw 会移 15 位, sall 会移31 位,而 salq 会移 63 位。
- SAR右移填上符号位(有符号运算) SHR逻辑右移(填上0)
讨论
- 只有右移操作要求区分是否有符号
特殊的算术操作
主要是关于128位的一些操作
- 两个64位值的全128位乘积 提供mulq(无符号) imulq(补码乘法)
- 对于两个操作第一个参数必须在寄存器%rax中,另一个作为指令的源操作数给出,乘积存放在寄存器%rdx( 高 64 位)和 % rax(低 64 位)中
- 无符号除法使用divq指令.通常,寄存器%rdx会事先设置为零
- 有符号除法指令idivl 将寄存器%rdx( 高 64 位) 和 %rax(低 64 位 ) 中的 128 位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器%rax 中,将余数存储在寄存器%rdx 中。
- 对千大多数 64 位除法应用来说,除数也常常是一个 64 位的值。这个值应该存放在% rax 中, %rdx 的位应该设置为全 0( 无符号运算)或者%rax 的符号位(有符号运算)
两个有符号的64位的数相乘
两个无符号的64位相乘
无符号除法使用divq要事先将寄存器设置位0
代码和上面区别不大,主要是将符号为扩展换成高8位设置位0
3.6控制
条件码
- CPU维护一组单个位的条件码,它们描述了最近的算术或逻辑操作的属性
- 上一小节的算术和逻辑操作除了leap以外都会设置条件码
- 有两类指令只设置条件码而不改变寄存器
访问条件码
tips :
- movl会将空余位置清理
- 条件码一般不会直接使用,但有如下的使用方法
使用方法
- 根据条件码的某种组合将一个字节设置为0或者1 (该类指令称之为SET指令)
- 跳转到程序的某个位置
- 有条件的传输数据
跳转指令
跳转指令编码
- PC-relative 它们会将目标指令 的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为 1 、 2 或 4 个字节
- 第二种编码方法是给出“绝对“地址,用 4 个字节直接指定目标。 汇编器和链接器会选择适当的跳转目的编码。
- rep 与 repz 是同义名没有实际的含义只会让代码在AMD运行更快,阅读时可以忽略
- 指令编码很简洁只需要两个字节
用条件控制来实现条件分支
本小节案例比较多,主要是学if-else 和 goto 的 汇编代码形式
用条件传送来实现条件分支
- 用条件传送实现条件分支在现代的机器上可能没那么高效,引出了用条件传送来实现分支
- 并不是每个条件控制都能用条件传送来进行替换
- 第一个操作数表示源(内存,寄存器) 第二个操作数表示目的(寄存器)
- 源和目的的值可以是16位,32位,64位,没有单字节的条件传送
- 汇编器从目标寄存器的名字推断由条件传送指令的操作数长度(不用显示的给出)
理解为什么基于条件传送的代码效率高于基于条件控制的代码
- 处理器通过使用流水线 (pipelining)来获得高性能,这种方法通过重叠连续指令的步骤来获得高性能
- 当机器遇到条件跳转(也称为“分支")时,只 有当分支条件求值完成之后,才能决定分支往哪边走。处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行
- 错误预测一个跳转, 要求处理器丢掉它为该跳转指令后所有指令己做的工作,然后再开始用从正确位置处起始 的指令去填充流水线。正如我们会看到的,这样一个错误预测会招致很严重的惩罚,浪费大约 15~30 个时钟周期,导致程序性能严重下降。
循环
do-while 循环
while循环
- 中间策略
- guarded-do策略
for循环
直接翻译成while循环的两种方式
switch语句
- 提高了代码的可读性,通过使用跳转表这种数据结构使程序更加高效
- 使用跳转表优点是执行开关语句的时间与开关的情况数无关
过程
过程是软件中一种重要的抽象,形式多种多样有函数(Function)方法 (method) 子例程(subroutine)处理函数(handler)
过程P调用过程Q,Q执行完返回P需要如下机制
- 传递控制:当进入Q时PC设置为Q的起始地址,返回时,设置为调用Q后面那条指令的地址
- 传递数据:P能向Q传递数个数据,Q必须能返回P一个数据
- 分配和释放内存:在开始时为Q分配局部变量空间,在返回前释放这些空间
运行时的栈
栈帧:
- x86-64过程需要的存储空间超过寄存器能够存放的大小就会在栈上分配空间,这个部分称之为过程的栈帧。
转移控制
- call指令 当P调用Q时会返回地址A压入栈中,并将PC设置为Q的起始地址
- ret指令会从栈栈中弹出返回地址A并将PC设置为A
数据传送
- x86-64最多可以用寄存器传送6个整型参数,寄存器的使用顺序和大小是有规定的
- 超过6个的参数通过栈传递
栈上的局部存储
大部分过程都不需超过寄存器大小的本地存储区域,有些时候必须存放在内存中
- 寄存器不足够存放说有本地数据
- 对一个局部变量使用'&' ,因此必须为它产生一个地址
- 某些局部变量时数组或者结构体,因此必须通过引用来访问到
寄存器中的局部存储空间
寄存器时唯一被所有过程共享的资源,因此我们必须保证一个过程调用另一个过程时,被调用者不会覆盖调用调用者稍后会使用的寄存器值
- 寄存器%rbx % rbq %r12-%r15被称为被调用者寄存器,当P调用Q时,Q必须保存这些寄存器值,保证他们的值在Q返回P时与Q被调用时一样
- 所有其他寄存器除了栈指针%rsp都分类为调用者保存寄存器
递归过程
每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不会相互影响
数组分配和访问
变长数组
历史上C语言只支持在编译时期就确定多为数组,不得不使用malloc和calloc来分配空间,而且得显示编码 C99引入了一种功能,允许数组维度是表达式,在数组分配的时候才计算出来 动态的版本对i的伸缩变换只能用乘法而不能用移位之类的操作
异质的数据结构
联合
一个union的大小等于最大的字段的大小,应用的前提是结构中两个字段是互斥的,那么使用union会减少分配的空间总量
数据对齐
tips:所有元素的大小是2的整次幂的时候将元素的大小降幂排序能大大节省空间
无论数据是否对齐,x86-64硬件都能正确工作,Intel建议要对齐数据来提高内存系统的性能,对齐原则是任何K字节的基本对象的地址必须是K的倍数
在机器级程序中将控制与数据结合起来
理解指针
使用GDB调试器
没有什么特别多的内容,都是关于使用GDB的一些api
内存越界引用和缓冲区溢出
- buffer overflow :通常在栈中分配某个字符数组来保存一个字符串,但是字符串长度超过了为数组分配的空间,一个更严重的致命使用是让程序执行它本来不愿意执行的函数
- 对数组的越界访问可能会破坏栈中存储的状态信息
对抗缓冲区溢出攻击
1.栈随机化
栈随机化思想使得栈的位置在程序每次运行时都有变化。实现的方法是在程序开始时,在栈上分配一段0-n字节之间的随即大小空间。程序不使用这段空间,但时它会导致每次执行时后续的栈位置发生了变化
2.栈破坏检测
其思想是在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值,这个值是在程序每次运行时随机产生的,攻击者没有简单的办法能够知道它是什么。在恢复寄存器状态和从函数返回之前,函数检查该值是否被改变,如果改变,立即终止程序
3.限制可执行代码区域
主要思想是限制那些内存区域能够存放可执行代码
支持变长栈帧
使用寄存器%rbp,先把rbp的值保存到栈中,然后在整个函数的执行过程中,都使得%rbp指向那个时刻栈的位置,然后用固定长度的局部变量相对于%rbp的偏移量来引用他们
浮点代码
这一章主要都是关于浮点数的操作(阅读起来难度不大,都是一些比较具体的东西)