赞
踩
多年以后,面对镜子前那个秃头的人,我仍会想起第一次拿起《CSAPP》的那个神奇的下午……
目录
平时写的C语言等其实都是一种较高的抽象,分支、循环、数组、结构、调用等功能在CPU中其实都由一些基本的功能实现的。这类功能只有:移动数据、数值计算、跳转控制,三者结合即可实现所需要的所有功能。在底层中,这些操作直接由2进制串表示,而这些二进制串可一一对应地翻译成机器级汇编代码。
本章所要学习的,这些具体指令在汇编代码中的使用,以及如何将其与C语言程序相互转换。
C声明 | 数据类型名称 | 汇编代码后缀 | 字节数 byte(s) |
char | 字节 | b (byte) | 1 |
short | 字 | w (word) | 2 |
int | 双字 | l (long word) | 4 |
long | 四字 | q (quad) | 8 |
type*(指针) | 四字 | q | 8 |
float | 单精度 | s (single) | 4 |
double | 双精度 | d (double) | 8 |
注:
1. int一般是32位(约2*10^10),但有时也会是16位/64位;long一般是64位(约10^20),但有时也会是32位(同一环境下long一定不短于int);long long一般是64位,有时也128位(约10^40)
2. 不建议用long double,可移植性差
规格:寄存器从小到大分别有1 byte, 2 bytes(word), 4 bytes(双字), 8 bytes(四字)等四种规格
名称记忆:‘r’表示“register”(寄存器),默认表示最大;'e'表示“extended”(拓展的),是32位相对于16位;而'l'表示“low”(低位)、'b'表示"below",是8位相对16位。事实上8-15位也存在有%ah等寄存器,其中'h'即表示“high”
注:
1. 这里都是整数寄存器,对于浮点数,又有另一套寄存器
2. a、b、c、d基本属于一组,si、di不可拆,bp、sp同样如此,之后是8-15,尾缀从大到小分别是 ‘无’、'd'、‘w’、‘b',要能判断一个写出的寄存器是否存在
大多数指令有一个或多个操作数,用来指示出需进行操作的原数据,以及得到结果后存放的位置。
操作数一般有以下3类
1. 立即数:用“$Imm”表示,即指这个数本身(不同指令允许的范围不同),表达式中记为“Imm”
2. 寄存器:用"%r"表示,即指取这个寄存器中的值,表达式中表示成“R[%r]”
3. 内存:用"…(…)"表示,即取通过计算后的地址值处的值,表达式中记为“M[……]”
内存地址的具体计算方法如下:
标准形式:Imm(%r1, %r2, s),其中s可以为1、2、4、8(s一定程度上是为了满足不同数据类型的数组,而再加上Imm则可能是由于结构体数组的内部偏置)
计算结果:M[ R[%r1] + s * R[%r2] + Imm ] //是先取寄存器中的值再与立即数相加
一般形式:其中几乎可以从标准形式去掉任意部分(注意单独的,没加'$'的Imm表示内存地址而非立即数)
1. MOV类:
模板:MOV Src, Dest //不同指令集中Src与Dest顺序可能不同,但最终的二进制文件是相同的
等位传送:
movb, movw, movl, movq分别对应1、2、4、8 byte(s) 的传送
movabsq(move absolute quad word——传送绝对的四字) Imm -> %r
注:movl会将目的寄存器的高位四字节(即高32位)全部置为0
拓展传送:
I. 零拓展(即将高位全部补零,z(zero)):
movzbw, movzbl, movzbq, movzwl, movzwq(由于movl会自动将高位字节置为0,故没有必要做movzlq)
II. 符号拓展(即将高位都补成符号位,s(signal))(S属性大爆发!):
movsbw, movsbl, movsbq, movswl, movswq, movslq
cltq(Convert Long to Quadword)特指将%eax中的值符号拓展到%rax中
使用限制:
1. 不能传送值给立即数,也不能从内存传到内存
2. 寄存器大小必须与指令相匹配,且寄存器先得存在
3. movabsq 只能将立即数传到寄存器
注:没有从大的寄存器移动到小寄存器的,因为没有必要,都是截断,直接用大寄存器的低位移动即可
2. 压入与弹出栈数据(pushq, popq)
模板:pushq Src vs popq Dest
实际效果:pushq Src == sub $8, %rsp + movq Src, %rsp
popq Dest == movq %rsp, Dest + add $8, %rsp
注:由于栈顶在高地址,所以入栈(栈向下增长)时要将专门存储栈指针的寄存器%rsp的值减8(地址都是四字的,这里8指8个字节bytes!),而出栈时加8
1. 加载有效地址 leaq (load effective address)
模板:leaq Src, Dest
本质上即是利用内存地址计算来得到对应的地址值,但与mov不同的是,leaq只是将地址值赋给Dest,而并没有取出该地址值所指示的具体数据。即mov的移动的是M[x],而leaq移动的则只是x(但x的计算方式相同)
注: 1. Dest则必须是64位的寄存器,因为地址都是这么长
2. Src并不一定非要是内存表示中的一种,也可以是$Imm与%r
2. 一、二元计算指令
一元:incq, decq, negq(取负), notq(取反)
二元:addq, subq, imulq, andq, orq, xorq, salq, sarq, shrq
模板:(一元)xxq Dest (二元)xxq Src, Dest
计算:(一元)op( Dest ) -> Dest (二元)Dest_val op Src_val -> Dest
注 1. 算数移用a,逻辑移用h,但左移都一样,故只用sar即可
2. 位移时位移的位数在前
3. 看清addq与andq
4. 基本都要加尾缀q
3. 特殊运算(二元但只有一个参数)
指令集:imulq, mulq, cqto, idivq, divq
模板:xxq Src
计算:imulq(mulq): R[%rax] * Src -> R[%rax]
cqto: 将R[%rax]进行符号拓展以后放入R[%rdx] : R[%rax](即由%rdx作高64位,%rax作低64位构成的128位数)
idivq(divq): R[%rdx] : R[%rax] mod Src -> R[%rdx] //%rdx放余数
R[%rdx] : R[%rax] / Src -> R[%rax] //%rax放商
注:前面有i的表示有符号运算,无i的表示符号号运算
1. 条件码
对应的寄存器都只有单个位,用来保存最近算术或逻辑操作的属性,用来实现分支控制。
常用: CF (Carry Flag) 进位/借位标志
ZF (Zero Flag) 零标志
SF (Signal Flag) 符号标志
OF (Overflow Flag) 溢出标志
置位规则:
CF: 首位1 + 1 => 1 / 0 - 1 = 0 OF: 首位 x + x => y / x - y => y
ZF: 各位全是0; SF: 首位是1
注:溢出 OF 只针对有符号数,但进位有无符号都有(只是一般是用于无符号的判断)
置位条件语句:
1. 一般的逻辑或数值运算,不包括 leaq !
2. cmp(b, w, l, q) S1, S2 => S2 - S1 //注意是后减前,而且不改变原来的值
3. test(b, w, l, q) S1, S2 => S1 & S2 //常用作S1 & S1 即检查S1与0的关系
条件码的作用(一般不会直接使用):
1. 根据条件码的某种组合,将某一位置为0 / 1
2. 根据条件码的某种组合,跳转到程序某一位置
3. 根据条件码,执行数据传送
2. 条件置位
都是单目运算符
指令 | 同义名 | 效果 | 设置条件 |
---|---|---|---|
sete (equal) | setz (zero) | ZF | 等于 / 0 |
setne (not equal) | setnz (not zero | ~ZF | 不等 / 非 0 |
sets (signal) | —— | SF | 负数 |
setns (not signal) | —— | ~SF | 非负 |
setg (greater) | setnle (之下的都是用not “翻转”不等号) | ~(SF^OF)&~ZF 相当于有无溢出的情况分别讨论 | > |
setge (greater or euqal) | setnl | ~(SF^OF) 无符号就包含0,所以不用 | ZF | >= |
setl (less) | setnge | (SF^OF)&~ZF | < |
setle (less or equal) | setng | (SF ^ OF) | ZF | <= |
seta (above) | setnbe | ~CF & ~ZF | > |
setae (above or equal) | setnb | ~CF | >= |
setb (below) | setnae | CF | < |
setbe (below or equal) | setna | CF | ZF | <= |
注:
1. 有符号数用OF,无符号数用CF
2. 其实一般来说,机器代码对有、无符号数都采取相同的指令(除有些特殊情况外)
3. 条件跳转
指令集:jmp, je, jne, js, jns, jg, jge, jl,jle, ja,jae, jb,jbe
模板:jxx Label (Label在反汇编文件中表现为 " .xxx")
注:jmp(无条件跳转) 既可以直接跳转,也可以间接跳转
直接:jmp Label; 间接:jmp *Operand (Operand即3种操作数指示符,表示跳转地址)
潜在问题:在流水线上造成低效
4. 条件传送
为避免条件跳转在流水线上低效而出现
思路:计算一个条件操作的两种结果,再根据条件对其赋值
不适用:
1. 计算结果的分支中有副作用
2. 计算结果的分支过于冗长,不便于计算,不如条件跳转进入流水线
3. 需要先对条件进行判断,才能执行计算(如空指针检查等)
指令集:CMOV (conditional move) (后缀与SET相同)
模板:cmov Src R (即目的一定是寄存器)
注:条件传送一般会先直接将一个值赋给目标,之后在条件传送另一种可能(经常体现else)
5. 分支 / 循环实现
I. if: 实现方法较多,也可用条件传送等(略)
II. do_while:
模板: .loop_label:
body_statement
renew test_val
test
jxx loop_label
III. while
模板: jmp test_label (即在do_while基础上先跳到检测单元,做一次检测再循环)
.loop_label:
body_statement
renew test_val
.test_label
test
jxx loop_label
IV. for(C语言中把初始化提出,变量迭代放到最后即相当于while,不过需要细节注意continue应该跳转到标量迭代前,而非直接下一次循环)
V. switch
作用:对一个整数值实现多重分支
实现思路:当开关值数量较多且密集时,使用跳转表;否则,即变成if-else
跳转表:其实是一个数组,存放在内存中一段连续的区域,数组中每个元素都是一条指令的地址,这些指令即是各个 case 及 default 的开始语句。由处理case值做索引,即可直接从跳转表达到对应的指令段而不需大量分支判断
模板及理解过程:
1. 现有对case值的预处理,因为跳转表的索引是从0开始,所以要先减去最小的case值。此时即可得每个case的基准值
2. default判断,有case之的情况有限,之后的区域全是default,通常用cmp + ja。此时即可得default的标签
3. switch中的具体内容 + 跳转表(两种方式)。此时可以确定每一个case值,以及case、default中的具体内容
a. 给Label顺序
从0开始(一定有),排除default后,每一个都除去最开始的偏置值(注意方向)
Label尾缀一般从小到大,但也不绝对,case存在负值时可能有大的在前
b. 给跳转表中的地址值
同理,从左到右,从上到下给每个地址编好case值,然后到指令序列中找即可
注:通过ret注意case、default之间有没有break
被误删了/哭
注:一个结构(struct)或联合(union)在内存中会按其中最长的数据类型的长度补齐
大小端
指的即是多字节数据在储存传输的过程中,字节序的顺序排列方式
大端:即高位字节排放在内存的低地址端(符合人阅读数字的顺序,借助数组a[0]-a[n]即内存中从低到高,按索引输出即是高位在左低位在右)
小端:即高位字节排放在地址的高地址端
注:这里的排序都是以字节作为最小单位的,即字节内部(8位)仍是高位在高地址依次存放的
1. 基本信息(基于AVX2):
包含16个向量寄存器,名称为%ymm0~15,每个长度为32字节(256位)。每一个可以储存8个四字节int或float,4个八字节的long或doubel。也可用%xmm0~15来使用其低16字节的部分。
2. 用途及指令
I. 实现浮点操作:
a. 每个寄存器作用与性质:所有向量寄存器都是调用者保存的,前8个(%ymm0~7)用于传参,超出的同样使用栈的参数构造区,而第一个还作为返回值的保存器。当参数中指针、整数和浮点数混合时,指针和整数用向量寄存器传递。
b. 传送与转换
指令 | 理解 | 作用 |
vmovss(d) | Vector Move Scaler(标量) Single(Double)-precision | 将内存中的浮点值转存到寄存器中或反过来(不包括内存到内存或寄存器到寄存器) |
vmovaps(d) | Vector Move Aligned Paked Single(Double)-precision | 在寄存器之间传送对齐的封装好的单(双)精度浮点数(对齐指读写内存时地址要满足16字节对齐——即其是可以在寄存器与内存之间操作,但寄存器之间传送一定是满足的,这样效率更高)(与之相反vmovups(d)) |
vcvttss(d)2si+(q) | Vector Convert Truncation(截断) Scaler Single(Double)-precision to Scaler Int (Int Quad) | 用截断的方法把单(双)精度数转换成整数(int) / 四字整数(long) (截断会丢弃小数部分,整数部分过大也会截) |
vcvtsi2ss(d)+(q) | Vector Convert Scaler Int (Quad) to Single(Double)-precision | 把整数/四字整数转换成单/双精度浮点数 |
c. 运算与比较操作
运算指令有一个或两个源和一个目的操作数,源可以是内存/寄存器,目的必须是寄存器
双操作数:v add / sub / mul / div / xorp / andp / max / min ss(d) 都是S2 op S1除了求最大最小值的max, min 以外
单操作数:vsqrtss(d)
比较指令:vucomiss(d) 即基于S2 - S1作比较(因为有inf与NaN,可能得到无序结果)
注:AVX浮点操作不能以立即数作为操作数,编译器必须为所有常量值分配空间,然后从内存读入
II. 实现SIMD:及对多个整数/浮点数同时进行加法等运算
缓冲区溢出(buffer overflow)是一种常见的转台破坏,由于C对数组引用不做边界检查,故在站上为某个字符数组分配空间后,输入的字符串又超过了这个空间,就会破坏原来栈中的其他变量乃至函数调用返回地址。这些漏洞往往是源于不控制输入长度的函数(gets, scanf, strcpy, strcat等)而黑客常利用这样的漏洞进行攻击
1. 栈溢出攻击
栈溢出攻击(stack smashing attack)指的即是,攻击者通过输入超出栈分配空间的字符串以修改正在运行的函数的返回地址,是之跳转到自己所希望运行的恶意代码处(常常也是在同一次输入中),而一般这段攻击代码最后仍会返回到本来正在运行的函数的上一级函数,从而使攻击不易被发现。
注:除此之外,在分配内存时首先就应该注意malloc的返回值是否为空,以及字符串要多分配一个'\0'的空间
2. 应对方法
I. 改进函数:即将原来有缺陷的函数进行改进,要求写入时输入一个写入长度来保证不会修改到栈中其他值
II. 栈随机化:即程序开始时,现在站上分配一段0~n字节的随即大小的空间,但程序不使用这段空间,它的作用就在于使每一次程序执行时后续栈的位置发生改变。n要足够大以得到足够多的随机地址变化,但又不会太大以至于浪费过多的栈空间。(32位Linux上大约是2^23,64位上大约是2^32)
III. 栈破坏检测:即在栈帧中,于返回值之前存放一个特殊的“金丝雀值”,该函数返回时会检查这个值是否被修改,如果被修改,则直接终止运行并报告(gcc -fstack-protector code.c 后编译器即会在每次函数调用中加入相关检测)
IV. 限制可执行的代码区域:即限制内存中只有代码区可以运行(及类似用rvx中的x标记),而栈中不能运行,故攻击者就不能通过向栈中输入攻击代码来执行了。(CPU中处理)
3. 栈溢出攻击变体(Return-to-libc)
背景:对于许多久远的代码,其中的函数并没有或无法更新,也难以重新编译以加上栈破坏检测,主要起保护的即是CPU的可执行区域限制,但是部分黑客仍可以通过拼接共享库函数来实现其目的。
具体操作:共享库函数中其实存在大量的常用函数,攻击者知道对应系统的libc版本及函数地址后便可以用来实现远程代码执行、权限提升等攻击。有时攻击者甚至不会直接调用整个函数,而是截取ret前的一条指令,通过缓冲区溢出修改大量的返回地址,使得这样的许多小的程序片段可以利用层级返回连在一起,共同实现攻击者的目的。
这一章的核心内容即是设计一个处理器(CPU),而处理器的主要功能即是执行指令集中的指令,进行算数、逻辑等操作。我们接下来将要设计的处理器,称为Y86-64(64位)
一个指令集体系结构包括定义各种状态单元、指令集及其编码,编程规范和异常事件处理等
1. 概念:Y86-64中每一条指令都会读取或修改,处理器状态的某个部分(否则这条指令就没意义),这些部分即是“程序员可见状态”(这里的“程序员”指使用汇编语言者 / 编译器)。
2. 成分:在Y86-64处理器中,“程序员可见状态”包括:寄存器、条件码、内存、程序状态、PC
寄存器(RF--Register File):为在不使指令集过于复杂,用一个寄存器文件管理寄存器,实际读取时想寄存器文件传入一个寄存器的编号,写的时候传入编号和写入内容即可。Y86-64中设计15个寄存器,为了能用4bit完成编号,同时可以有一位表示空寄存器(该寄存器在物理上存在,但不会参与正常指令的读/写,有时用它的编号表示位置空出,有时在条件转移中用于放置不满足条件的“废弃值”)
条件码(CC--Conditional Code):ZF,SF,OF
程序状态(Stat):即标识程序状态的一种特殊的寄存器,正常情况下为SAOK,异常情况有地址越界SADR,无效指令SINS,程序中断SHLT等状态
PC(Program Counter):即程序计数器,其实也是一个寄存器,存放到当前正在执行的指令的地址
这个指令集只是x86指令集的一个子集,寻址方式较少,操作也较少,具体包括:
指令名 | 字节数 | |||||
halt | 0 | 0 | - | - | - | 1 |
nop | 1 | 0 | - | - | - | 1 |
rrmovq | 2 | 0 | rA | rB | - | 2 |
irmovq | 3 | 0 | F | rB | V(立即数) | 10 |
rmmovq | 4 | 0 | rA | rB | D(内存偏移) | 10 |
mrmovq | 5 | 0 | rA | rB | D | 10 |
OPq | 6 | fn | rA | rB | - | 2 |
jXX | 7 | fn | - | - | Dest | 9 |
cmovXX | 2 | fn | rA | rB | D | 10 |
call | 8 | 0 | - | - | Dest | 9 |
ret | 9 | 0 | - | - | - | 1 |
pushq | a | 0 | rA | F | - | 2 |
popq | b | 0 | rA | F | - | 2 |
fn表示根据不同数值对应执行不同的的指令
//注:cmov本质仍是rrmov,所以在指令编号中与rrmov同“首位”,或者也可将rrmov视为cmov中特殊的一种,类似jmp是jxx中特殊的一种一样
CISC(Complex Instruction Set Computer) 即复杂指令集计算机,复杂指令即指计算机处理器中有实现各种功能的指令;而与之相对的RISC(Reduced Instruction Set Computer) 精简指令集计算机,则只包含了一个简单的指令集。
对比:
优势 | 劣势 | |
---|---|---|
CISC | 1. 指令集丰富,为微处理器编写程序容易 2. 将一些特殊指令集成后,相比于按一些简化指令分布执行,可以显著提高效率(e.g. SIMD,即将两串(64-512位)要做相同操作的数先拼在一起,直接操作后再拆开) | 指令的使用频率相差悬殊,而指令集的复杂导致CPU和控制单元也变大变复杂 |
RISC | 指令集简化,CPU可更加简洁,芯片小巧(适合于手机等移动端) | 编程者的任务更加复杂,即实现相同的命令,相比CISC,需要用到更多的指令 |
其实两个架构各有优点,并不是要相互取代的关系,现代的CPU常用CISC的外围,内部加入RISC的特性
1. 核心思想:“通常,处理一条指令包括很多操作。将他们组织成一个特殊的序列阶段,即使指令的动作差异很大,但所有指令也能遵循一个统一序列”
2. 阶段:
取址(fetch):从内存中读取指令字节,得到 icode(指令代码), ifun(指令功能), *valC(常数)
译码(decode):从寄存器文件中读取最多两个操作数(0 / 1 / 2)
执行(execute):进行计算/条件判断
访存(memory):可将数据写回内存 / 从内存中读出数据(读写不可同时进行)
写回(write back):将数据写回到寄存器中,最多两个
更新PC(PC update):将PC值设为下一条指令的地址
3. 各指令行为
阶段 | rrmovq rA, rB | irmovq V, rB | rmmovq rA, D(rB) | mrmovq D(rB), rA |
取址 | icode : ifun <= M[PC] rA: rB <= M[PC+1] valP <= PC + 2 | icode : ifun <= M[PC] rA : rB <= M[PC+1] //这里rA其实用不上 valC <= M[PC+2] valP <= PC + 10 | icode : ifun <= M[PC] rA: rB <= M[PC+1] valC <= M[PC+2] valP <= PC + 10 | icode : ifun <= M[PC] rA: rB <= M[PC+1] valC <= M{PC+2] valP <= PC + 10 |
译码 | valA <= R[rA] | - | valA <= R[rA] valB <= R[rB] | valB <= R[rB] |
执行 | valE <= valA + 0 | valE <= valC + 0 | valE <= valB + valC | valE <= valB + valC |
访存 | - | - | M[valE] <= valA | valM <= M[valE] |
写回 | R[rB] <= valE | R[rB] <= valE | - | R[rA] <= valM |
更新PC | PC <= valP | PC <= valP | PC <= valP | PC <= valP |
阶段 | pushq rA | popq rA | callq Dest | ret |
取址 | icode : ifun <= M[PC] rA: rB <= M[PC+1] //rB在这里是F valP <= PC + 2 | icode : ifun <= M[PC] rA: rB <= M[PC+1] //rB在这里是F valP <= PC + 2 | icode : ifun <= M[PC] valC <= M[PC+1] valP <= PC + 9 | icode : ifun <= M[PC] valP <= PC + 1 |
译码 | valA <= R[rA] valB <= R[%rsp] | valA <= R[%rsp] valB <= R[%rsp] | valB <= R[%rsp] //可能为了便于布线设计,统一用valB来实现栈指针寄存器的更新 | valA <= R[%rsp] valB <= R[%rsp] |
执行 | valE <= valB + (- 8) //常用加一个负数的形式,如0xfffffff8 | valE <= valB + 8 | valE <= valB + (-8) | valE <= valB + 8 |
访存 | M[valE] <= valA | valM <= M[valA] | M[valE] <= valP | valM <= M[valA] |
写回 | R[%rsp] <= valE | R[%rsp] <= valE | R[%rsp] <= valE | R[%rsp] <= valE |
更新PC | PC <= valP | PC <= valP | PC <= valC | PC <= valM |
阶段 | jXX Dest | cmovXX rA, rB | OPq rA, rB |
取址 | icode : ifun <= M[PC] valC <= M[PC+1] valP <= M[PC+9] | icode : ifun <= M[PC] rA: rB <= M[PC+1] valP <= M[PC+2] | icode : ifun <= M[PC] rA: rB <= M[PC+1] valP <= M[PC+2] |
译码 | - | valA <= R[rA] | valA <= R[rA] valB <= R[rB] |
执行 | Cnd <= Cond(CC, ifun) | valE <= valA + 0 Cnd <= Cond(CC, ifun) | valE <= valA OP valB set CC |
访存 | - | - | - |
写回 | - | if(Cnd) R[rB] <= valE //其实如果Cnd不成立的话是将值赋给F表示的空寄存器 | R[rB] <= valE |
更新PC | PC <= Cnd ? valC : valP | PC <= valP | PC <= valP |
//在布线设计上所有指令都必须经过执行阶段,输入原有数据并产生一个输出值。所以如果不希望原来值变化则加0,但数据的表示一定是输出的valE,而写回阶段不再有valA, valB, valC(访存/更新PC时仍可能存在)
1. 图示惯例
白色方框——时钟寄存器:包括程序计数器和条件码寄存器
浅蓝色框——硬件单元:相当于一个黑盒
灰色圆角矩形——控制逻辑块:从一组信号中进行选择,或计算布尔函数
白色圆圈——线路名称(不是具体的硬件)
2. 各阶段具体实现
取址:
译码与写回:
执行:
访存:
更新PC:
To be continued
1. 128位long long又如何操作?
2. 无符号数加到溢出怎么判断seta
3. Y86-64的条件码为什么不置CF(不涉及无符号数?)
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。