CSAPP

课程视频:https://www.bilibili.com/video/BV1iW411d7hd?share_source=copy_web

Lecture02:Bits,Bytes,and Integer

浮点数的二进制表示:100010.101=1*2^5+1*2^1+1*2^(-1)+1*2^(-3)

4个bit分为一组—>十六进制

x86-64上的数据类型:

  1. char 1字节—8位
  2. short 2字节—16位
  3. int 4字节—32位
  4. long 8字节—64位
  5. float 4字节—32位
  6. double 8字节—64位
  7. long double 10/16字节(只使用10个字节,当在64位机器上使用时,额外6字节被浪费—保证了一切以16字节对齐)

64位机器、32位机器:地址是64位或32位(即pointer的大小)

位移操作(shift)

左移(left shift):将bit-vector向左移动,将溢出位舍弃,在右边填0

右移(right shift):将bit-vector向右移动,将多余位舍弃

signed的逻辑右移(logic shift):在左边填0

signed的算术右移(Arithmetic shift):在左边复制符号位(为什么没有算术左移?左移相当于执行了一次可能会溢出的乘法)

注意:移动<0的数或大于等于数据字节长度的行为,都是UB

二进制到unsigned,signed的转换(T表示two’s complement,即二进制补码形式):

即最高位取负数,其他不变,若最高位为1,则一定是负数(最大为-1)

|TMin|=TMax+1

UMax=2*TMax+1

B2U,U2B(对同一个bit-vector,建立从signed到unsigned的映射)

注意:在C中,默认常量都是signed,如要使用unsigned需要添加后缀”U”,如0U,4294967259U

当signed,unsigned被混合运算时(如大小比较),signed总被转化成unsigned(bit-vector保持不变,仅仅是转换方式变化)

signed extension(如32位拓展到64位):signed在左边复制符号位(类似算术右移),即从符号位开始的一连串1,可等价于将这串1中,最低位作为符号位

Lecture03:Bits,Bytes,and Integers cont

Unsigned Addition :相加后无论”多出”的一位是什么,都舍弃掉,可理解为模运算,即

Two’s complement Addition:等价于将两个数都转化为unsigned计算后再转化为signed(即按位相加)

正溢出(positive):两个较大正数加在一起使符号位变成1,即得到负数

负溢出(positive):两个较小负数加在一起使符号位变成0,即得到正数

.png)

Unsigned Multiplication:相乘后多余位舍弃,可理解为模运算,即

Signed Multiplication:当作unsigned相乘后舍弃多余位,剩下的首位作为符号位,如55=25—>11001—>1001—>-7,若相乘后的数字可以用目前位数表示,则结果正常(注意,这里相乘后的数字指的是*signed相乘结果,而不是转化成unsigned后的相乘结果)

注意,负数算术右移时,结果会向负无穷舍入,而不是零(即左侧,事实上这就是二分中通常用>>1的原因),如果想要向零舍入,需要添加一个偏移量(添加偏移量的操作,编译器会在使用除法时自动添加,然后在实施右移)

??C语言对signed的右移是算术还是逻辑右移没有明确规定,绝大多数情况下是算术右移

移位操作消耗1计算周期,而乘法操作消耗3个计算周期(通过各种硬件优化),除法操作消耗30个计算周期

signed取相反数(x—>-x):将所有位取反,再+1,故Tmin=-Tmin


数据是如何存储的:在64位机器中,地址有64位,可以将内存系统理解为一个巨大的char数组,每一个char占据一字节,64位地址能表示的下标从0~2^64,故最多存储2^64字节数据(实际上目前能使用的最大内存地址是47位,对应的内存大小大约是128TB),实际上,只是程序认为它可以使用这么大的空间,当程序运行时,系统会规定其能访问的内存区域,如果程序试图访问其他区域,将报错(即segementation fault)

一般情况下,数据类型的地址取其占有的bytes的最低位地址,如64位地址占据了0000~0007这8个byte,这该地址是0000,类比0008~0015是0008;而32位地址同理,0000~0004是0000,0005~0007是0005(地址表示一般情况下使用十六进制)

字长(Word Size):没有明确规定,当我们提及64位机器时,指的是其pointer/地址大小是64位(尽管只使用了47位)

使用GCC编译时,可以指定它是64位还是32位,会生成两种不同类似的object code,即硬件本身不一定定义字节大小,是硬件和编译器一起决定(64位的代码也可兼容32位机器)

multi-byte word是如何在内存中排序的?

小端序(little endian):最低有效byte拥有最低位地址(x86,IOS,Windows,ARM processors running Android)

大端序(big endian):最低有效byte拥有最高位地址(PPC MAC,Internet)

如15213=3B6D(十六进制)

在小端序中:6D 3B 00 00

在大端序中:00 00 3B 6D

例外:无论是小端序还是大端序,char数组的存储顺序都是相同的,即都符合大端序,’/0’放在最高位

实际上,ARM的硬件配置为既能处理小端序也能处理大端序

Lecture04:Floating Point

小数/分数的二进制表示:(小数点左侧0~i,右侧-1~-j)

如果是分数—>依次减1/2,1/4,1/8判断该位是否为1

如果固定位数,将point左移,则能表示的最大值变小了,精度变大;将point右移,则精度变小,能表示的最大值变小了,故采用floating point,即浮点来表示小数


IEEE(电气电子工程师学会) floating point:

形式:

Sign bit 表示了该数是正是负;Significand/Mantissa表示一个介于[1.0,2.0)之间的值;而Exponent表示了该值要乘上2的多少次方

单精度浮点数(32位):s占一位,exp占8位,frac占23位;双精度浮点数(64位):s占一位,exp占11位,frac占52位;80位浮点数(Intel only):s占1位,exp占15位,frac占63位或64位(所以为什么不直接取64位呢?)


Normalized values:

exp域(阶码字段)决定了E,但不等于E,同时,exp域不能全为0或全为1.exp域视作一个unsigned number,exp域的值-bias(偏移量,值为)后才是E.(example:exp占8位,则bias为127,exp域本身的范围是1~254,加上偏移量后是-126~127).

注意,为何不用exp域不使用signed number的标准—>便于浮点数之间比较

significand域(尾数域/frac)决定了M,但不等于M,其采用隐含编码法.即假设M=1.xxxxxx始终成立,可以省去一位来表示小数点前的1,所以尾数域全0时尾数最小为1,尾数域全1时尾数最大,极其接近2.0


Denormalized values:

来源:当我们想表示0or非常接近0的数时,尾数域隐含的1限制了我们

解决措施:当exp全为0时,其值不是0-bias,而是1-bias.同时,尾数域隐含的1变为0,即0.xxxxxx

example:

  1. exp全为0,frac全为0,则此时值为0,但注意有(+0和-0的区别)
  2. exp全为0,frac不全为0,则此时值很接近0(0.xxxxx的-126次方)

Special values:

当exp全为1时:

  1. 若frac全为0,则表示一个,如,
  2. frac不为0,表示Not-a-Number(NaN),表示无法确定的值,如sqrt(-1),,

.png)

.png)

相关性质:比较浮点数的大小时,除了NaN,都可以将其视作unsigned number比较(即从高到低依次相比):正数总是比负数大;当exp不全为0时,exp域大的数总是比exp域小的数大(最高位是隐含的1);当exp全为0时,尾数域大的总是比尾数域小的数大


Floating Point Operations(addition,multiplication)

rounding(舍入):

  1. towards zero(向零舍入)
  2. round down(向负无穷舍入,即舍入后的值<=舍入前的值)
  3. round up(向正无穷舍入,即舍入后的值>=舍入前的值)
  4. default:nearest even(向最靠近的值舍入,偶数优先,如1.4-1,1.6-2,1.5-2,2.5-2,-1.5—2)

为什么采用IEEE采用nearest even作为默认舍入规则:当给定足够多数据时,这些数据有50%的概率增加或减少,故平均值保持不变(可能不够严谨,但至少能说明为什么该规则较优)

而应用到二进制数上:偶数即有效位的最低位是0,而中间值(即’0.5’)是10000….,其他都同理


Floating Point Multiplication:

符号位取异或,exp域相加,尾数域相乘

如果尾数域>=2,右移,增加exp

如果exp超出范围,overflow

对尾数域相乘结果进行舍入以对应尾数域精确度


Floating Point Addition:

首先对齐小数点(即exp域取二者中较大值),然后相加

若尾数域>=2,右移,增加exp

若尾数域<1,左移k位使其>=1,exp减少k

如果exp超出范围,overflow

对尾数域相加结果进行舍入以对应尾数域精确度


注意,浮点数的加法,乘法均不满足结合律,如(1e20+3.14)-1e20=0,(1e20-1e20)+3.14=3.14

(1e20*1e20)*1e-20=inf,1e20*(1e20*1e-20)=1e20

在”自然”条件中,值通常是连续的,即不会相差太大,浮点数的该性质通常不会产生影响.但是在非自然条件中,如金融行业中的计算,需要注意该性质对结果的影响.


C语言中的浮点数

当浮点数和int之间发生类型转化时,不能保持bit-vector不变而只改变翻译方式

double/float—>int:

  1. 截去小数(效果如同向0舍入)
  2. 当浮点数的值超出范围或者为NaN时,没有定义,通常情况下会设置为TMin

int—>double:

  1. 如果int的位数小于等于53(double的尾数域长度),值将不会改变

int—>float:

  1. 同理double,若尾数域长度小于int位数,将会发生舍入

Lecture05:Machine-Level Programing I:Basics

一般说machine program有两种含义,一是计算机实际运行的object code(是一系列代表着实际操作的字节);二是汇编代码(编译器的目标便是将代码转化成汇编代码).二者是一一对应的.

x86机器有两种汇编语言,一种是Intel和Microsoft使用的,一种是Linux使用的(本课程使用).二者参数顺序不同.

本课程采用的是英特尔64位processors(处理器)的指令集


英特尔processors的历史:

x86是对英特尔processer的一个口头称呼,因为芯片名字都含有86

x86的特性总是不断进化\重叠,其指令集或许不是最清晰易懂的,却是最uesful的

80年代的RISC(reduced instructions set computer,即精简指令集计算机)和CISC(complex instructions set computer,复杂指令计算机.实际上,CISC是RISC派给以前的处理器起的名,带有贬义色彩,乐)大战

英特尔采用CISC架构


8086:1978年推出,是第一个16位Intel processor,有1MB的内存空间,其一个变种是IBS和DOS的基础

386:1985年推出,也被称作IA32,因为其是第一个32位Intel processor.添加了”flat addressing”,能运行Unix或Lunix

Pentium 4E:2004年推出,也被称作x86-64,因为其是第一个64位Intel processor(其也可运行32位代码,避免了software从32位到64位的长久迭代)(到这一代为止,Intel processor的性能都在逐步提升,但面对着功耗过高的问题,引出了多核processor(可以理解为在一个芯片上塞多个处理器))

Core 2:2006年推出,是第一个multi-core(多核)Intel Processor

Core i7:2008年推出,其拥有4个core


Processors有Desktop Model(一般功率更高)和Server Model(Cores一般更多)两种

.png)

DDR:processor链接到主存储器的方式,即DRAM/动态RAM

PCI是与外部设备的连接

SATA是与不同类型盘的连接

Ethernet(以太网接口)连接到一个网路

这些逻辑单元和处理器一起组成了芯片


Advanced Micro Decvices:AMD(Intel的对手,和Intel之间吵过专利,结果是AMD被允许生产x86处理器)

AMD提出了64位英特尔扩展程序


2001年,Intel投入大量资金,试图从IA32转变到IA64,在市场上没有成功

而AMD将寄存器变大,只是将东西从32位变成64位,并获得了成功,于是Intel跟随其脚部继续推出x86


ARM(Acorn RISC Machine),一家公司推出的自家芯片,然后公司华丽丽的破产了,但是其指令集非常高效且简单,可以设置在芯片上,且可定制

所以ARM现在是一家位于英国剑桥的公司,向其他公司出售使用其设计的许可权利(其比x86机器功耗更低)


如前言所说,编译器的目标是将你的code转化成电脑操作的指令,但有些指令更简单,却需要更多硬件,有些指令很繁琐,但不需要什么硬件条件,而如何最好的实现指令集,是硬件研究者的工作


.png)

pc:Program counter,存储下一条指令的地址,在x86-64中被称作”RIP”

register:存储数据(有各自的名字,如%rbx,%rdx等等)

condition code:存储操作的状态信息,在branching(“分支”)中使用

Memory:可以视作一个巨大的字节数组,实际上是操作系统和硬件协调产生的一种”虚拟内存”,每个软件都有自己独立的字节数组,而在物理意义上,所有软件都共享这些字节数组(cache指的是多次访问同一位置的内存总是更快,但注意,在指令层面,你无法直接操作cache)


将C 转变成object code:

.png)

第一步是将C代码变成汇编代码(gcc -Og -S,-Og是对编译器进行什么优化的规范,O是指Optimize,如果不加,将不会进行任何优化,;-S意思是Stop,只做第一部分,即将C代码变成汇编代码)

第二步是通过汇编程序(Assembler)运行它,将文本形式的代码转化成字节形式

第三步是通过链接器(Linker),将不同的文件融合在一起

(你的各个代码文件和库代码)

实际上,当真正开始运行这个程序上时,有些库是在首次启动时动态导入


1.将C转化成Assembly(汇编代码)

.png)

pushq:将某个数据压入栈中

movq:将某个数据移动位置

call:调用某个procedure(操作序列)

ret:从一个特定的函数返回

汇编代码中的数据类型:

.png)

Integer类型:存储整数(不区分signed,unsigned,C中的pointer等数据类型也以该形式存储)

Floating point类型:以不同的方式处理,使用不同的寄存器组

Code:定义了一系列操作的字节序列

结构体等数据类型,都不存在于机器代码中,都由编译器来定义


2.将汇编代码变成object code

.png)

.png)

一些指令可能只有1byte,但也有些指令有15bytes(不同指令所代表byte长度不一定)

变量的所有名称,在汇编代码,机器代码级别完全丢失(变成了寄存器和内存中的某个位置)

.png)

disassembler/反汇编(顾名思义,将object code逆推成汇编代码)

.png)

gdb:强大的debugger,可以在存在source code的情况下进行调试,不存在source code时,也可进行调试,其也具有反汇编功能(disassemble xxxx)

x/14xb sumstore—显示从sumstore地址开始的14个十六进制byte


更多的汇编基础知识:

.png)

注意:如果你使用%r版本,使用的是64位register;如果你使用%e版本,使用的是32位register(实际上,%e版本只是使用了%r版本的低32位,同理,8位,16位都可取到)

rsp是特殊的寄存器,被称为栈指针(stack point)

左半部分寄存器(除rsp)的命名来自其原始用途,现在这些名字只是历史遗留问题,在大多数情况下,他们都可用于保存数据

.png)

注意,从IA32到64位处理器,寄存器数量增加了一倍


movq operation:

.png)

注意,不能将内存中的数据直接移动到内存中的另一个地方,需要两次操作(Mem-Reg,Reg-Mem)

.png)

(%xxx)表示对该寄存器所存储的地址解引用

D(%xxx),D是一个数字,表示对该寄存器所存储的地址加一个偏移量后解引用

.png)

swap函数示例

对地址解引用的更多操作:

.png)

.png)

leaq(load effective address):地址操作

有些乘法操作可以转化成leaq,如*12—>*3再*4

.png)

.png)

.png)

.png)

Lecture06:Machine-Level Programing II:Control

Condition codes:

.png)%rip:指令指针(instruction pointer,在IA32中中叫%eip),包含正在执行指令的地址,无法通过正常方式操作(可通过技巧获取它的值)

condition codes一共有8个,图中只展示了4个,都只有1bit

.png)

CF:在二进制加法时,表示是否进位(翻译为溢出更恰当?)

ZF:当计算结果为0时被置位

SF:计算结果最高位为1时(signed)被置位

OF:当signed计算溢出时被置位(包括加,乘?)

注意,lea不会操作这4个condition code

专门操作condition code的指令:

.png)

.png)

获取condition code的手段:

.png)

setX根据condition code的状态,将目标寄存器的低位,置1或置0,以此来知道最近的指令做了什么

sete,setne表示上次指令的结果是相等或为零

sets,setns表示上次指令的结果的正负性(没有考虑溢出)

setg,setge表示上次指令的结果是大于(大于或等于),这里用了~(SF^OF)表示大于或等于,即考虑了正溢出和负溢出,保证了比大小不会因为溢出出错

setl,setle同理

setb表示上次指令结果是否溢出(unsigned)

.png)

%xxl表示寄存器的最后一位(l—low)

setX示例:

.png)

PS:x86-64的小特性:当你操作32位寄存器时,会将寄存器的其余32位置为0.

而byte(如short)操作不动其他位(更低位也如此)


Conditional branches:

if和else

传统的实现方法:jump instruction

unconditional jump(无条件跳跃)

conditonal jump(有条件跳跃)

.png)

示例:

.png)

.XXX:label,在object code中并不存在(代表了指令的地址?)


优化:Conditional Moves(类似于先将if的结果和else的结果都算出来,根据条件值判断使用哪个)

先猜测条件结果(大部分情况下都能猜对),并直接开始计算

.png)

可以类比工厂的工作流水线,可以同一时间处理多条指令,而不是卡在条件指令,使之更有效率

示例:

.png)

注意,conditional move仅在分支计算都很简单的时候能提高效率,在某些情况下甚至有风险(如解引用NULL指针),有时计算结果影响条件判断

.png)


loops:

.png)

.png)

dowhile循环的实现


-Og级别的while:

.png)

-O1级别的:

.png)


for循环

.png)

类似while循环

.png)

采用O1优化时,会删除初始化之后的一个test(因为一定对)


switch:

.png)

switch会生成一个类似于数组的jump table,以快速定位提高效率

.png)

.png)

注意,从0到x的最大值均有其标签(如果出现负数,会添加一个bias使下标从0开始)

若是x的范围过大且相对稀疏(如x为0和1000000,会使用二分优化成if else)

.png)

break代码块:

.png)

注意,switch后即为return w,故将ret写在代码块内

.png)

.png)

只有case3中的指令需要w初始化,故将w初始化放在case3中.

case2fall-through到case3中,故在初始化后添加merge标签供case2跳转