Skip to main content
A cup of beer
  1. Posts/

计算机系统原理2022年总结

·1126 words·6 mins
Table of Contents

3-存储程序 #

  • 程序一旦运行,不需要人工干预即可自动执行

3-冯诺依曼机器特点 #

  • 采用存储程序的方式
  • 计算机由5大部分构成
  • 数据和指令在存储器中,以二进制表示,且没有区别,但计算机能区分
    • 指令由操作码和地址码构成
  • 控制器能自动执行指令,运算器能进行算术和逻辑运算
  • 操作人员使用IO操作计算机

4-通用寄存器组(GPRs) #

  • General Propose Registers.用于临时存放CPU从主存取来的数据或运算中间结果

6-时钟周期 #

  • 时钟信号的宽度称为时钟周期

7-程序语言和自然语言的区别 #

  • 程序语言不存在二义性,且具有严格的执行顺序

7-机器语言 #

  • 即0-1序列
  • 汇编语言必须转换成机器语言才能执行

8-翻译程序 #

  • 翻译程序分为汇编程序assembler、解释程序interpreter、编译程序compiler三个大类。
  • 汇编程序把汇编语言转换为机器语言
  • 解释程序把源程序按执行顺序翻译成机器指令并立即执行
  • 编译程序把高级语言源程序翻译成汇编或机器语言

8-源程序和目标程序,源语言和目标语言 #

  • 前者是源代码写的程序,后者是翻译生成的程序。“语言”即所使用的语言。

9-mips指令集 #

  • mips是一种risc指令集。即microcomputer without interlocked pipeline stages.

10-gcc编译过程 #

hello.c为例

  • (高级语言源程序)预处理:对“#”定义的宏进行预先处理,包括把头文件嵌入等操作,生成hello.i。它仍是高级语言源程序。

  • (汇编语言源程序)编译:对hello.i进行编译,生成汇编语言源程序hello.s

  • (可重定位目标程序)汇编:对hello.s进行汇编,生成机器语言的可重定位目标程序(relocatable object file) hello.o

  • (可执行目标程序)链接:把

    hello.o
    
    
      和其他文件例如
    
    
      printf.o
    
    
      进行合并,生成可执行文件
    
    
      hello.out
    
    
      - 可执行文件又称可执行目标程序。可执行目标程序和可重定位目标程序的区别是前者是可执行的,而后者需要经过链接。
    
    

11-读入键盘字符、输出字符到屏幕 #

  • 键盘字符送到了CPU的寄存器中,随后送到主存中(输入缓冲区)

  • 数据在送上总线之前,都要

    先缓存

    在存储部件(不是存储器)中。

    • 因此,在端口中,一般都有数据缓冲。

12-南桥北桥 #

  • 北桥芯片负责处理高速部件的通信,例如RAM
  • 南桥芯片负责处理IO总线通信,例如外部设备、硬盘等

12-端口 #

  • 数据缓冲寄存器、命令字寄存器、状态字寄存器
  • 端口可以进行独立编址,也可进行存储器映射编址。前者是自己一套IO地址空间,后者是把内存地址的一部分拿出来给IO编址。

13-操作系统 #

  • 操作系统是对计算机底层结构和硬件的一种抽象

13-计算机体系结构(指令集体系结构,ISA) #

  • Instruction Set Architecture
  • 即计算机硬件和软件之间的交界面,软硬件接口
  • 是软件对计算机硬件的感知方式
  • ISA规定了一套指令集,即计算机机器语言的一套设计规范。例如Intel x86 ISA下有很多款CPU,但是由于使用了相同的ISA,所以在i9-9900k上跑的程序在上也能在AMD 3900X上跑。
  • **ISA是整个计算机系统的核心。**ISA集中体现了硬件的特性,软件在ISA上跑。

14-微体系架构(微架构) #

  • 一套ISA的具体实现就是这个ISA的微体系架构。
  • 例如加法器可以使用串行进位,也可使用超前进位。

14-高级语言翻译程序的前端和后端 #

  • 高级语言程序的编译可以理解为语法分析、语义分析、中间代码生成、中间代码优化、目标代码生成、目标代码优化。
  • 把中间代码生成及其之前的操作称为前端,之后的操作称为后端。
  • 可以理解为:前端是检查ASCII写的程序并转化为汇编,后端是优化汇编并汇编成为目标程序。

14-未定义行为和未确定行为 #

  • undefined behavior:例如C90规定:在printf中使用错误的类型描述符就是未定义行为。它会导致每次执行的结果都不一样,或者在不同的微架构下执行结果都不一样。(即玄学行为)
    • 未定义行为即官方明确表示“我们不保证这么写他能跑”。
    • 原因:gcc明确给出了警告:这么写,我们也不保证他能跑出什么结果来。
  • unspecified behavior:例如C对char到底是unsigned还是signed没有作出要求,所以在不同微架构下的gcc可能会对这个char给出不一样的解释,导致在不同微架构下,其执行结果不同。
    • 未确定行为即未明确给出规定的,模棱两可的。
    • 原因:gcc没有强制规定char属于什么类型,于是不同gcc的实现对他的处理有一定差异。

15-ABI接口和API接口 #

  • ABI接口可以类比API接口,但前者的层次非常低,包含了和硬件相关的接口,但是后者只是高层次的库之间的接口而已。
  • ABI接口是在给定的ISA、给定的OS下,规定的及其级别的目标代码层接口。
    • ABI规定了诸如OS的系统调用、可执行文件格式、寄存器使用约定、内存地址划分等规范。因此,它不仅取决于ISA,还取决于OS。
    • 可以想象,如果在某ABI下编译好的程序,拿到另一台有相同ABI的电脑上跑,是可以正常跑的。

16-计算机软件分类 #

  • 系统软件
    • 包括OS、DBMS、Compiler、实用程序(例如KDE Plasma)
    • OS对硬件进行管理监视,对软件提供环境,对用户提供界面
    • 语言处理程序负责提供编程环境、装入运行环境等

16-计算机用户分类 #

  • 最终用户、系统管理员、应用程序员、系统程序员

17-什么是透明 #

  • 一种客观存在的东西,在某个角度来看好像不存在一样

17-几种透明虚拟机 #

  • 机器语言程序员看到的机器是ISA以上的部分,以下的部分透明了,称为机器语言虚拟机。
  • 系统程序员看到的机器是装备了OS的机器,看到的是OS以上的部分,以下的部分透明了,称为操作系统虚拟机。
  • 以此类推,还与汇编语言虚拟机、高级语言虚拟机。

18-衡量计算机系统性能的定义 #

  • 吞吐率:单位时间完成的工作数量
  • 响应时间:从作业提交到给出相应(或作业完成)经过的时间
  • CPI:一条指令执行所需的时钟周期数,一般采用平均CPI。对于一段给定的程序,可以求针对这段程序的平均CPI,即该程序的综合CPI(程序总指令数除以程序花费的总时钟周期数)。

19-用户视角下的CPU时间(如何衡量计算机的好坏) #

  • 分为系统CPU时间(操作系统各操作占用的时间)、用户CPU时间(用户程序所占用的时间,是用户体验的部分)、其他CPU时间(例如等待IO时间、其他用户查程序占用的时间)。

  • 衡量计算机性能好坏,往往通过用户CPU时间来衡量。计算机的性能可以看做是用户CPU时间的倒数,也就是执行这一个用户程序需要多少用户CPU时间,该时间越短说明程序执行速度越快。

  • 用户CPU时间=

    CPI*这段程序的指令数*时钟周期宽度
    
    
      - 可见,计算机的速度由CPI、指令条数、主频共同决定。他们互相制约,共同影响计算机的性能。
    
    

21-MIPS法、Gibson混合法、FLOPS法 #

  • Gibson混合法(对一条指令所需执行时间进行加权平均):第i种指令的占比是wi,其所用时间是ti,则sigma(wi*ti)就是计算机

    一条指令

    的平均执行时间。

    • 如果时间单位是节拍,那么这就是CPI。
    • 如果时间单位是s,那么对他取倒数就是MIPS。
  • 峰值MIPS,相对MIPS(P21)

22-benchmark程序(基准程序) #

  • 基准程序是一种小程序,在不同电脑上运行,比较其执行时间,以对比出电脑性能的差异。
  • 不排除厂商对基准程序中的瓶颈指令编写特定的编译器进行优化,以得出离谱的跑分的情况。(根据阿姆达尔 Amdahl 定律)。

23-阿姆达尔定律 Amdahl’s Law #

  • 对计算机系统的硬件或软件部分进行改进带来的性能提升程度取决于该部分被使用的频率或占总执行时间的比例
  • 定律的两种公式(P23)和例题
  • 极限加速比:如果仅对某关键部分进行改进,而不去考虑其他部分,那么这种改进带来的性能提升是有上限的,例如P24例子,你怎么改进都不可能让性能提升5倍。
  • 如果仅对使用较少的部分进行改进,带来的性能提升可能趋近于0.

30-离散化 #

  • 必须是数字化的信息,计算机才能处理,所以输入设备都有着“离散化,然后编码”的功能。

31-计算机为什么采用二进制 #

  • 找到能稳定保持两种状态的物理器件比较简单。
  • (便于算术运算)二进制的运算比较简单。
  • (便于逻辑运算)二进制对应真、假。便与实现数字逻辑。

31-机器指令所能处理的数据类型 #

  • 分为数值型和非数值型数据。
    • 非数值型包括编码字符、逻辑数据
    • 数值型包括二进制数、二进制编码的十进制数。
      • 二进制数分为整数(有符号定点数、无符号定点数)和实数(浮点数)。

35-进制转换 #

  • 进制转换的原则是:整数部分和小数部分分别来做。
  • R进制转10进制,以8转10为例:
    • 整数部分:按权展开。注意个位是8的0次方。
    • 小数部分:按权展开。注意第一个小数位是8的-1次方。
  • 10进制转R进制,以10转8为例:
    • 整数部分:每次除以8,拿到余数作为从右往左罗列的新位,然后继续对商进行上述操作,直到商0.
    • 小数部分:每次乘以8,拿到整数部分作为小数点后从左向右罗列的新位,然后继续对小数部分进行上述操作,直到没有小数部分。
      • 如果永远都有小数部分,那么就近似处理。例如(0.63)D=(0.1010…)B。

36-定点数和浮点数 #

  • 计算机无法表示小数点。因此要表示小数,只能通过约定小数点所在位置的方式实现。我们约定整数的小数点位置在最低位之后,定点小数的小数点在第一位后,浮点小数的小数点可浮动。

  • 定点整数和定点小数统称为“定点数”。

  • 浮点数三要素:尾数(原码表示),基数,指数(移码表示)

    • 指数的表示范围直接决定了该浮点数的表示范围。指数的值决定了小数点的位置

    • 尾数的表示范围决定了浮点数的精度。尾数越长,代表其有效位越多,也就是精度越大。

    • $$ 2^{-n}\times2^{-(2^m-1)}\le|M|\le(1-2^{-n})\times2^{2^m-1} $$

      设尾数小数点后n位,指数除了符号位有m位,则该浮点数的绝对值的范围是.

      • 对于尾数,其是有符号定点小数,所以绝对值最小的数就是其小数点后最后一位是1,因为小数点后1位是2^-1,所以第n位就是2^-n。相反,如果要最大的这种小数,直接用1减,就能得到小数点后全1,显然最大。
        • 尾数我们要求绝对值最大和最小。因为上面那个式子我们看的是绝对值。
      • 对于指数,其是有符号定点整数,所以最小数就是负的全1,最大数就是正的全1.
        • 指数我们要求带符号的最大和最小。因为其符号不影响浮点数的符号。
    • 如果要拼凑绝对值最小的浮点数,只需要尾数绝对值最小,且指数带符号最小;拼最大的则反过来。

    • 可以看到,由于指数的加入,整个浮点数的表示范围远超了它带的这个定点小数的范围。

    • 区分“范围”和“精度”:范围是数字本身的大小,精度是有多少位有效数字。即一个是数值的大小,一个是有效位的多少。

37-机器数和真值 #

  • 机器数即数字在机器内的编码表示,例如原、反、补、移。真值是人对数字的表示

  • 原码表示的问题是0的表示不唯一,且运算不便(需要判断正负)

  • 补码的新理解

    • 对于整数,补码就是他本身;对于负数,补码=模减去该负数的绝对值(实际上就是对该负数取模)(但是注意,这里的运算是以无符号数的方式进行,也就是最高位也参与减法)。
    • 假设当前包括符号位是n位,模的就是2的n次方(于是就可以做到这个模实际上是1后面n个0.),那么让待表示的负数取这个模可想而知,实际上就是让这个模减去这个负数的绝对值(注意是无符号数的运算方法。)
    • 于是就得到了奇妙的性质:
      • 对于一个补码表示的数,如果它最高位是1,那么它一定是负数,因为只有负数才是通过与模做差得来的,而做差会导致最高位1的出现。
      • 如果把补码表示的这个数字整体看作无符号数,那么随着它的增大,它的真值也是单调递增的(除了经过0的时候是一个跳跃,即从正数大的数跳到负数最小的(绝对值最大)那个数)。因为对于负数来说是做减法,减法以后剩下的1越多,代表原来1越少,也就是绝对值越小,也就是真值越大。
      • 即:0->1->+max->-max->-1.(-max是指负数里面的绝对值最大的)
    • 同一真值,编码位数不同,模不同,从而导致表示出来的补码不同。(例2.12)
    • 因为$$2^{n-1}$$ 的补码是100….0,是个负数,且-2^(n-1)的补码也是100…0(因为一个是2^n+2^(n-1),一个是2^n-2^(n-1)),所以他俩就冲突了,而且显然对于正的那个2^(n-1)来说不合适。所以补码的表示范围的那个等于号最终还是花落-2^(n-1)家。
    • 因为0的补码是2^n+0=2^n-0=0,所以不存在+0和-0的区别了,因此100…0给到-2^(n-1)那里也是正确的。
  • 移码

    • 浮点数的指数往往需要对阶操作,所以需要一种表示方法,能在表示范围上完全单调递增(补码只能保证符号内单增)

    • 只需要给数加上一个偏移量(bias),这个偏移量一般取 $2^{n-1}$,也就是

      模右移一位
      
      
        。这样一来,就能在表示范围内实现单增了。
      
        - 注意,n是整个移码的长度,所以有效位一共有n-1位,而 $2^{n-1}$为1的那一位是最高位(不属于有效位),所以能达到这种效果。
        - 移码中,最高位也不是有效位,它类似于符号位,与符号有关。
      
      
    • 数的排列是这样的:-max->-1->-0->+0->1->+max.(-max是指负数里面的绝对值最大的)

43-C中有符号数和无符号数的转换 #

  • 在C中,这两种数之间可以进行强制转换,但是转换前后机器数不变,而只是解释这个机器数的方法变了。
  • 因此一个很小的负数如果转换成无符号数,会变成一个很大的正数。
  • 当无符号数和有符号数进行运算,有符号数会被强制换为无符号数,此时会如上一条所说,产生unexpected现象。例如例2.21

44-C中数值常量类型的确定 #

  • 对于一个常量数字,在C90下,其分水岭是 $2^{31}-1$、 $2^{32}-1$、 $2^{63}-1$、 $2^{64}-1$。分别对应int,unsigned int,long long,unsigned long long。因此如例2.21所述,如果一个数字是 $2^{32}$,那么它被解释成了unsigned int。这个时候如果再去和其他int做运算,就会出现问题。
  • 在C99下,取消了$2^{32}-1$这个分水岭。也就是取消了unsigned int这个判断,其空出来这块区域直接由long long代替。

45-阶码造成的上溢、下溢、机器零 #

  • 阶码没法再小了,导致比较小的数无法表示,这种称为下溢出。
  • 下溢可用机器零处理。因为当尾数=0,阶码变得没有意义。所以只要尾数是0,整个数就是0了,称为机器0.因为阶码没什么用,所以机器0的表示显然不唯一。
  • 上溢是真的溢出了。阶码超出了可表示的最大的绝对值范围。
  • 上、下溢出一般是由阶码引起的,而与尾数无关。因为阶码负责数字的表示范围。

45-尾数的规格化 #

  • 左规:尾数有效位前的0过多,影响精度,需要左规。
  • 右规:尾数的有效位跑到小数点左边了,不符合表示格式,需要右规。

46-IEEE 754标准 #

  • 目前几乎所有的计算机都使用IEEE 754标准。该标准规定了32位单精度浮点数、64位双精度浮点数类型。

  • 尾数采用带有一个隐藏位的

    原码
    
    
      表示,阶码采用偏置-1的
    
    
      移码
    
    
      表示。
    
      - 因此,只需要一个符号位,用于表示整个浮点数的符号。这个符号位实际上也是尾数的符号位,因为阶码用移码表示了,不需要符号位了。
      - 于是得出754的格式:符号|阶码|尾数。
    
    
  • 尾数隐藏位

    • 因为规格化以后尾数都是1,所以这个1就不必写了,所以例如32位的类型中,尾数有23位,但是实际上可以表示24位内容。扩大了精度。
    • 可以把隐藏位看做是在小数点之前。
  • 偏置-1

    • 和一般的移码不一样,例如有n位,那我偏置就是 $2^{n-1}$,但是这里偏置是$2^{n-1}-1$。这样扩大了表示范围。
    • 即全0就是最小负数。

46-IEEE 754的特殊值 #

  • 特殊值:阶码全0或全1.
  • 正常数字:阶码非全0,非全1.
阶码 尾数 含义
全0 全0 +0或-0(符号取决于符号位)
全0 非0 非规格化数
全1 全0 +inf或-inf(符号取决于符号位)
全1 非0 NaN(尾数可携带错误信息)
非全0,非全1 任意 正常数字

47-IEEE 754的非规格化数及其逐级下溢 #

  • 如果尾数隐藏位是0,且阶码是最小阶码(-126或-1022),那么这是一个IEEE 754非规格化数。
  • 逐级下溢是利用非规格化数实现的,用于保证当运算过程中,阶码超出了表示下界时,程序仍然能正常运行。
    • 以32位(单精度)浮点数为例,其阶码的最小值是-126.
      • 得出机器零的情况(发生下溢出的情况):例如 $2\times10^{-90}\times2\times10^{-90} = 1.04\times10^{-180} = 0.104\times10^{-179}=…=0.0$,即每次都右移动,当把有效位都移没了,但是仍然没有把阶码从下溢出的范围内移出来,就直接变成了机器零。
      • 避免下溢出的情况:例如 $2\times10^{-90}\times2\times10^{-37} = 1.04\times10^{-127} = 0.104\times10^{-126}$,此时通过一定次数的右移,把阶码从下溢出的范围内移出来了,顺利完成了运算,且显然得到了规格化的数。
  • 非规格化数和规格化数是没有冲突的。非规格化数的加入,利用了一块本来没法利用的数字区间。也就是区间 $[0.0…0\times2^{-126},0.0…1\times2^{-126}]$,因为规格化数的第一个有效位必须是1,所以显然这个区间被浪费掉了。而根据规格化数的定义,这里就给完美地用上了。

47-IEEE 754精度和阶码的关系 #

  • 类比:$[00000001\times10^2,99999999\times10^2]$所表示的区间长度要比$[00000001\times10^9,99999999\times10^9]$小得多,同时,有效数之间的间距也要小得多。也就是前者更加精确,但是范围小;后者范围大,但是丧失了精确度。
  • 对比 $[0.1…0\times2^{-126},0.1…1\times2^{-126}]$和 $[0.1…0\times2^{-125},0.1…1\times2^{-125}]$,也是一个道理。
  • 而且你会发现,非规格化数由于在 $[0.0…0\times2^{-126},0.0…1\times2^{-126}]$,所以它和区间 $[0.1…0\times2^{-126},0.1…1\times2^{-126}]$的长度、有效数字间隔都是一样的,就好像是复制过来了。

48-IEEE 754的NaN #

  • 最高有效位是1时,是不发信号的NaN,不会产生错误信息;否则是发信号的NaN,会产生错误信息。
  • 其余的位可以携带异常。
  • 当进行无数学解释的运算,会产生NaN。基本上都是那些类似洛必达法则的关于0和无穷的运算。

50-C中的强制类型转换 #

  • 一般情况下int是32位,float是32位,double是64位。
  • int转float会舍去一些有效位,因为float有效位不足32个。
  • int、float转double没有问题。double有52位尾数,也是比32位的int要多的。
  • double转float时,可能会溢出(对阶码)。且精确度降低(对尾数)。
  • float、double转int时,由于int没有小数部分,所以会向0方向截断。
  • 注意,在char转int时,可能出现问题。因为char是unsigned还是signed是未给出要求的,所以这种转换是未确定行为。例如0xff转换为int,x86认为这是一个signed char,由于采用补码表示,其在高位补0,也就是补1,所以最终得到了0xffffffff这个int。但是如果在RISC-V上跑,他认为是unsigned char,那么高位只需要补0,则得到了0x000000ff这个int。

50-C中的浮点数运算 #

  • 例如 $1.79\times10^{308}+1.0\times10^1$,那么就会出现问题,因为后者要向前者对阶(小对大),其有效位在右移的过程中会丢掉那唯一的1,导致变成0.于是运算就变成了 $1.79\times10^{308}+0$.

51-有权码和无权码 #

  • 以BCD为例,有权BCD一般是8421码,无权BCD一般是余3码、格雷码。

52-西文字符和ASCII #

  • 字符集:各种字母、符号构成的集合。
  • 码表:字符集每个字符和编码对应构成的表。
  • ASCII的规律
    • 用7位二进制数来编码(因为早期8位机下,可以留一位给校验位,如果需要的话)
    • 数字的高3位是011,低4位正好是其8421BCD码。
    • 字母的第5位如果是0,则是大写,否则是小写。这给大小写转换带来了便利。

54-汉字输入码(外码),国标码(国标交换码),机内码(汉字内码) #

  • 输入码即用键盘按键编码构成的序列,来确定一个汉字
  • 国标码是区位码+32.(由于信息传输限制)
    • GB2312属于国标码
    • 类似的编码还有CJK、微软的Unicode
  • 机内码是把国标码的两个字节的最高位都设为1.(由于避免与ASCII撞车)
    • 机内码是汉字编码在计算机内的实际存在形式

55-汉字的描述 #

  • 点阵描述——位图思想
  • 轮廓描述——矢量图思想

55-字长和字 #

  • 字长即CPU用于整数运算的数据通路的长度。

  • 字可以不等于字长。

    例如现在的64位机,其字仍然是16位,但是字长却是64位。

    • 因此,即便是在64位机中,我们仍然定义32位的数据类型为“双字”。
    • 字是信息处理的单位,只是用来规定数据类型的长度。
    • 字长是计算机处理能力的反映。例如64位机可以同时处理4个字,可以看出性能比较强大。
    • 因此,可以认为字节永远都是8bits,字永远都等于2个字节。

56-C中数据类型的宽度 #

C类型 32位机(单位:B) 64位机(单位:B)
char 1 1
short 2 2
int 4 4
long 4 8
char * 4 8
float 4 4
double 8 8
  • 可见,就算是同种数据类型,在不同的ABI下,宽度不一定相同。因此需要取决于具体的ABI。

57-LSB和MSB #

  • 即最低(高)有效位(字节)least/most significant bit/byte
    • 注意,有效最高、低位是指考虑了这段数据的含义在内后,给出的定义。例如一个数据如果倒着放,那么低地址实际上对应了MSB,这种情况下就是大端存储。
  • 产生原因:仅仅说“最左边的位”、“最右边的位”会产生歧义。因为数据可以小端、大端存储。
  • 使用字节是因为现代计算机一般按字节编址。

57-字节排序问题——大小端存储 #

大端存储是顺着放。

——你已经长大了,是时候能好好地放一个数据了。

(因为MSB是数据最高位,假设我们大端,那么最高位就是在较小的地址处,那么符合我们日常书写的习惯,即从左向右)

  • 计算机按字节编址,但是数据的长度往往不止一个字节,例如int是4个字节,这4个字节是正着放到连续内存中,还是倒着放在连续内存中,即字节排序问题。
  • 大端存储,即数据的地址是MSB所在地址;小端存储,即数据的地址是LSB所在地址。(我们认为数据地址是这几个字节的起始地址。)
  • x86架构采用的是小端方式,mips采用大端方式。
  • 大小端存储问题同样存在于软件领域,例如gif格式是小端,jpeg则是大端。
  • 采用不同字节排序方式的计算机(软件)之间不能进行直接通信。需要事先转换。

61-逻辑运算和按位运算 #

  • 逻辑运算的结果只有true和false;按位运算是一种数值的运算,是把操作数的各个二进制位分别进行逻辑运算从而得到一个新的二进制数,结果是新的二进制数。

61-C中的移位运算 #

  • 往往对于无符号数进行逻辑移位,对于有符号数进行算术移位。
  • 技巧:因为左移相当于乘2,所以有溢出的可能。只要移出去了1,就是溢出了;因为右移相当于除以2,所以涉及到不能整除的问题。只要移出去了1,就是不能正处2(因为最低位是1必然是奇数)。

62-C中的扩展与截断 #

  • C没有扩展、截断运算符,但是它在强制类型转换时会自动进行。

  • 扩展分为0扩展(高位全补0,用于无符号数)、符号扩展(高位全都用符号位填充,用于补码表示的有符号数。说成填充符号位没毛病,因为如果是负数,补1相当于补0,正数就不用说了。)

  • 截断发生在长数强制转换短数时,把高位删掉。例如把32768(0x00008000)这个int截断成short,则删去高2个字节,得到0x8000,即1000 0000 0000 0000,是-32768的补码(==这不是那个负0空出来的吗==)。

  • 截断属于

    未定义行为
    
    
      ,C语言不保证能输出合理的结果。因为截断非常容易溢出,长数的表示范围远大于短数。
    
      - 可以看出,特别是对于有符号数,只要你长数的有效位数大于短数总位数,只要截断必然出来个负数。
    
    

64-OF和ZF标志位 #

  • 如果结果所有位都是0,则ZF。
  • 如果两个加数的符号位相等,且结果符号位和他们不相等,则OF。

65-CF标志位 #

  • CF=CN和SUB的异或。
    • 当是ADD时,CF就表示进位。因此CF就是加法器的CN。
    • 当是SUB时,希望利用CF来表示借位。可知,当两个补码数相加,出现高位进位时才是正常情况,否则说明不够减。因此,CF是加法器的CN的取反。
    • 即ADD时是CN,SUB时是CN取反,所以得出CF的表达式。

66-无符号数的溢出 #

  • 对于无符号数,如果超过表示上限,或者得出负数,都属于溢出。溢出是非正常情况,属于错误。
  • 例如
int main(){
      unsigned int i=0;
      i-=1;
      cout<<(1<i)<<endl;
}

这里的输出是1(真)。因为i溢出了,变成了unsigned int可表示的最大值。

67-C中“<”、“>”、“=”的机器级表示 #

  • 对比较双方做减法。判断SF。

69-机器数乘除溢出判断 #

  • 对于两个n位数字相乘,它的结果是2n位的。而最终结果需要截断其低n位来存储。因此,只需要判断高n位中是否有有效1即可判断溢出。
    • 对于无符号数乘法(booth算法),MIPS会把高n位放在hi寄存器,低n位放在lo寄存器,只需查看hi寄存器是否为0,即可判断溢出。
    • 对于补码乘法(改进booth算法),MIPS会把高n位放在hi寄存器,低n位放在lo寄存器,只需查看hi寄存器的每一位是否都与lo寄存器中的最高位(即符号位)相等(因为当SF=0,hi中无效数字是0.当SF=1,hi中无效数字是1.),即可判断溢出。
  • 对于两个n位数字相除,其实没有判断的必要。因为结果不会发生溢出。
    • 因为参与运算的都是机器数,而机器数没有解释的话可以看做无符号整数。则其结果必然比被除数小。因为被除数一定没有溢出,所以结果也是。
    • 除法唯一可能产生溢出的情况,就是-2147483648/-1时。因为他们采用补码表示,而补码可以表示-2147483648,却不可以表示2147483648

73-IEEE 754下的浮点运算 #

  • 对阶还是注意小阶对大阶

  • 尾数规格化

    • 754下的尾数,要保证在计组中的规格化尾数的基础上多左移一位,即隐藏位位于小数点左边一位,其规格化尾数是形如+1.xxxxxx-1.xxxxxx的。

    • 因此,在左归的时候,多移动一位。在右归的时候,少移动一位。

    • 右归的时候要注意,低位可能会移丢。此时进行舍入:采用0舍1入(也就是保证舍入位都是0,因为是0就不变,是1就变成0,也就是保证结果都向就近的偶数舍入。),或向0舍入、或向+无穷、-无穷舍入4种方法。

      • 0.110101->0.11011->0.1110(结果)
      • 其中,在754中,右边多出来的两位是为了运算过程中的精度。因为运算时,结果不需要严格按照754字长(因为不需要保存到内存),所以可以在运算过程中多留下两位,提升精度。较高的那一位是保护位,用于保护754字长内的那个LSB;较低的那一位是舍入位,根据它进行舍入。
    • 直观体现舍入

      •   int main(){
                double a=123456.789e4;
                float b=a;
                printf("%f\n%f\n",a,b);
          }
        
          //output
          ➜  ccTest g++ ccTest.cpp -o ccTest
          ➜  ccTest ./ccTest                
          1234567890.000000
          1234567936.000000
        
        
        
      • 这里,b向最近的可表示数a进行了近似。因为a赋给b时,超过了b的可表示范围(double 采用754的双精度标准,float采用754的单精度标准。)

      • 为什么会差的那么多?因为阶码越高,两个可表示数之间的距离就越大。

    • 如果数字过小,导致左归时,阶码无法表示,例如计算$1.1B\times2^{-126}-1.0B\times2^{-126}$,那么得到$0.1B\times2^{-126}$,当前它并不是规格化的(因为IEEE 754要求形如+或-1.xxxxx的尾数才是规格化,也就是左归时要多移动一位),但是现在再左归一次,阶码要首先-1,但是这超过了该移码的表示范围。于是,选择什么也不干,而单纯把阶码置0.也就是说现在$0.1B\times2^{0}$就是正确的754表示结果了。

      • 因为在754中,如前所示,阶码全0代表着它是一个非规格化数字,且其比阶是-126的情况还要小。所以,这么表示是正确的。
    • 如果数字过大,导致右归时,阶码无法表示,那么就导致错误了。因为对于浮点数,阶码溢出就是真的溢出了。

      • 在有的机器中,会报错,而有的会输出inf(一种特殊的754数字)。

对于浮点数,阶码溢出才是溢出。因为尾数溢出只是为了提高精度,其可以通过规格化的方式解除溢出。

76-浮点运算存在的问题:大数吃小数 #

  • 浮点加减中的大数吃小数
    • 运算前要对阶,小阶要向大阶看齐,于是小的那个数的阶如果比大的那个数的小了过多,小阶数尾数右移就会丢失有效数字,丢的过多就成0了有可能。
    • 于是造成大数+小数转化为大数+0的情况。(因为尾数是0,则当机器0)
  • 浮点乘除中的大数吃小数
    • 考虑大乘大乘小的情况:
    • 如果先算前两个大的,则可能当场溢出,报错或置inf,而inf和小的相乘,当做inf处理输出。结果是inf;
    • 但是如果换一下顺序,先让大的和小的乘,如果小的足够小,能让大的那个严重缩减,然后再和第一个大的乘也不止于溢出,就能得到正确答案。

87-RTL(寄存器传送语言 Register Transfer Language) #

  • R[r]表示r寄存器中存的内容;M[addr]表示内存中地址addr存放的内容。
  • RTL由形如上述两种元素、数字和箭头构成,能够表示汇编语言实际进行的操作。

92-如何从gcc编译得到汇编代码,然后编译到可重定位目标文件并比较 #

  • gcc -E main.c -o main.i从源程序预处理得到预处理后的文本文件。例如原来的文件只有100行,但是经过这一步可以到到3600+行。其中远不止把宏定义给你处理了那么简单:它把头文件中引入的内容进行预处理,另外还有类似于 typedef unsigned int __u_int;这样的操作。可见,就连基本的数据类型也是通过这种方式,和实际机器上的数据类型联系起来。
  • gcc -S main.i -o main.s从预处理后的文件汇编出来一个汇编语言文件,是文本文件。里面采用AT&T格式的汇编代码,和汇编语言课程中不是一套体系(虽然在我的电脑上都是x86汇编)。
  • gcc -c main.s -o main.o从汇编语言得到可重定位目标文件。之后,可以使用objdump -d main.o反汇编它。可以看出,除了助记符发生了变化,其余和main.s的内容没有太大区别。
  • 然后,gcc main.o -o main即可链接这个文件,于是就得到了可执行文件。

可以看出,gcc还能当AT&T汇编器来用。

93-AT&T格式、Intel格式 #

  • 汇编语言课程中使用的是Intel格式,而系统原理课程采用AT&T格式。gcc编译器、objdump都使用的是AT&T格式。
  • 二者比较
项目 AT&T Intel
源、目的 源在前,目的在后,例如mov $0x01, %bx 源在后,目的在前,例如MOV bx, 01h
长度后缀 b:字节,w:字,l:32位(双字),q:64位(4字),且“l”可以简写不要,例如movb foo, %al word ptr,byte ptr,dword ptr等命令后缀,例如mov al, byte ptr foo。注意,32位机中,dword常被省略(也就是默认长度)
寄存器 必须以“%”开头 不需要“%”,除非加了长度后缀
立即数 必须以“$”开头 不需要“$”
寻址方式表达(内存操作数表达) 偏移量(基址寄存器,变址寄存器,倍数),相当于基址寄存器值+变址寄存器*倍数+基址寄存器 直接以算式表达,清晰明了
大小写 敏感 不敏感
16进制表示 使用0x前缀 使用h后缀
采用 Linux GNU gcc;GNU objdump Microsoft MASM

两种汇编格式,可以理解为这是两种汇编语言。就像高级语言有C和java一样。你不能用masm去编译AT&T汇编语言。

例如:

Intel Code AT&T Code
mov eax,1 movl $1,%eax
mov ebx,0ffh movl $0xff,%ebx
mov eax,[ecx] movl (%ecx),%eax
sub eax,[ebx+ecx*4h-20h] subl -0x20(%ebx,%ecx,0x4),%eax

94-将C和汇编结合来编写程序(内核一般都是这么写) #

  • 使用C和汇编同时开发能带来更高的执行效率和更强的灵活性。可以采用如下三种方法:
    • 编译C程序到可重定位目标文件截断,把汇编程序也编译到该阶段,然后链接他们;
    • 也可以使用内联汇编代码,即在C中以字符串的形式嵌入汇编代码,同时还能实现与C程序中通过寄存器、内存、栈等方式进行参数传递。香啊!
    • 也可以采用如下的笨办法:利用gcc把C程序编译到汇编语言阶段,然后直接在此基础上修改此汇编语言文件,然后继续编译。(显然存在代码无法维护的问题。)

94补充-gcc内联汇编 #

  • 在gcc下,内联汇编以格式

    asm("......")
    
    
      出现。这里的asm属于C关键字,不需要任何头文件的引入。
    
      - 其中的汇编代码采用字符串直接书写。
      - 每句汇编指令采用linux风格换行符结尾,并且加个tab,即`\n\t`。
    
    
  • 如果采用 asm volatile("......")volatile能保证这段代码被固定在此处执行,不会因为gcc编译器的优化而导致跑到别处去。**例如不会因为优化而被移出循环。**当然,这会对gcc代码优化造成影响,毕竟这一段汇编禁止被优化了。

  • 扩展内联汇编支持实现与C程序中通过寄存器、内存、栈等方式进行参数传递。并且能够声明可能要用到的寄存器,让编译器在进入这段汇编前可以保存相应的上下文。

asm ( assembler template
        : output operands                /* optional */
        : input operands                   /* optional */
        : list of clobbered registers   /* optional */
);

例如:

int a=10, b;
asm ( "movl %1, %%eax;
           movl %%eax, %0;"
          :"=r"(b)           /* output */
          :"r"(a)              /* input */
          :"%eax"         /* clobbered register */
);

上面代码实现的功能就是用汇编代码把a的值赋给b。值得注意的几点有:

  • “b”是输出操作数,用%0来访问,”a”是输入操作数,用%1来访问。
  • “r” 是一个constraint。这里我们只要记住这里”r”的意思就是让GCC自己去选择一个寄存器去存储变量a。输出部分constraint前必须要有个 ”=”修饰,用来说明是一个这是一个输出操作数,并且是只写(write only)的。
  • 你可能已经注意到,有的寄存器名字前面用了”%%”,这是用来让GCC区分操作数和寄存器的:操作数已经用了一个%作为前缀,寄存器只能用“%%”做前缀了。
  • 第三个冒号后面的clobbered register部分有个%eax,意思是内联汇编代码中会改变寄存器eax的内容,如此一来GCC在调用内联汇编前就不会依赖保存在寄存器eax中的内容了。

当这段代码执行结束后,变量”b”的值将会被改写,因为它是被指定作为输出操作数的。这里可以看出在“asm”内部对b的改动将影响到asm外了,正如之前所说的内联汇编起到桥梁作用。

95-AMD64的由来 #

  • 早在1985年,Intel就感觉到姓A的都不好对付了。
  • 一开始,Intel的32位体系结构称为x86-32,之后改名为IA-32(Intel Architecture-32),基于IA-32的第一款处理器即80386。
  • 1985年,AMD在IA-32的基础上加以扩展,提出了x86-64架构,并改名为AMD64架构。Intel称其为Intel64架构。但是很显然现在都是叫AMD64的。

96-“字”是16位的由来 #

  • 因为一开始是16位机,所以定义一个字是16位。但是后来发展了32位、64位,只是字长变成了这么长,但是一个字仍然为16位。这样才能实现更好的向后兼容(或者只是人们懒得改了:在计算机的世界,谁先提出来,就得听谁的,并且没人能改变它。)

96-C的基本数据类型 #

  • 指针或地址类型
数据类型 所述大类 对应汇编数据类型(IA-32)
ptr 指针或地址 双字(l)
  • 位串(即无符号整数)类型:在机器中以原码表示
数据类型 所述大类 对应汇编数据类型(IA-32) 对应汇编数据类型(AMD64)
unsigned char 位串 字节(b)
unsigned short (int) 位串 字(w)
unsigned int或unsigned long(int) 位串 双字(l)
unsigned long long(int)(C99引入) 位串 双字(l)(因为在32位机中被编译器截断成双字) 四字(q)
  • 带符号整数类型:在机器中以补码表示
数据类型 所述大类 对应汇编数据类型(IA-32) 对应汇编数据类型(AMD64)
char 带符号整数 字节(b)
short(int) 带符号整数 字(w)
int或long(int) 带符号整数 双字(l)
long long(int)(C99引入) 带符号整数 双字(l)(因为在32位机中被编译器截断成双字) 四字(q)
  • 浮点小数:在机器中以IEEE 754标准表示
数据类型 所述大类 对应汇编数据类型(IA-32和AMD64) 对应汇编数据类型(x87协处理器存在时)(没错,确实是x87,而不是x86)
float 浮点小数 双字(l),组织形式为IEEE 754单精度
double 浮点小数 四字(q),组织形式为IEEE 754双精度
long double (C99引入) 浮点小数 四字(q),组织形式为IEEE 754双精度(因为在32位机中被编译器截断成四字) 四字(q)+双字(l),其中只有80位有效,组织形式为IEEE 754扩展精度
  • long double只有80位有效,虽然采用双字+四字共96位来存储,会导致内碎片,但是这会提升存取效率:即每次先读个四字,再读个双字,就能拿到他。
  • AT&T的寻址方式中,比例因子正是为遍历这几种基本数据类型的长度的元素构成的数组提供了方便。例如如果这个数组是int类型的,那么就可以让比例因子=4.(注意,因为计算机是按字节编址,所以4字节也就是双字,32位,所以比例因子是4。)
  • 此外,C的复杂数据类型包含联合体、结构体、数组。他们又称构造类型。

97-IEEE 754扩展精度 #

  • 扩展精度长80位,其中尾数达到了63位,又加上一位显式表示(非扩展精度可都是为了节约空间,不写这一位的。)的隐藏位,所以共64位有效数字。
  • 还剩下16位,那么1位做符号,15位做阶码。

101-x86下的通用寄存器的前向兼容性 #

  • AX为例:在IA-32体系结构下,AX寄存器已经扩展到32位。为了兼容以前的程序,AX指的是AX寄存器的0-15位,AL是0-7,AH是8-15,而EAX是0-31.其中EAX的意思是Extended-AX

104-MOVS、MOVZ和MOV的区别 #

  • MOV就是直接移动。而S结尾的是将短的数据按照符号扩展后,再送给目标处;而Z是直接高位补0即可。显然,前者适用于有符号数(因为是补码存放),而后者适用于无符号数(原码存放)。

104-LEA指令和MOV的区别 #

  • 相当于C里面的“

    &
    
    
      ”运算符。LEA的拼写是Load Effect Address,加载有效地址。
    
      - 例如 `mov ax,[dx+10h]` 和 `lea ax,[dx+10h]`的区别是:前者ax中存放了`[dx+10h]`单元中存放的**内容**,而后者ax中存放的是数字:`dx+10h`。
      - 因此,可以把这种操作看作是取了地址。实际上只是对中括号中的运算进行了直传。对于AT&T格式,也是类似的。
    
    
  • 因此,这里有一个语法糖,就是LEA指令可以方便地实现多项式运算:例如计算x+4y的值,并且放入ax寄存器中,就可以是形如 lea ax,[x+4y]

104-数据传送指令、地址传送指令 #

  • 像MOV,LEA这种,实现的是传送某些东西,从源到目的。
  • 前者传送数据,所以是数据传送指令(所以,MOV并不是剪切-粘贴的操作,而是复制-粘贴。因为他是“传送”)。后者传送地址(但是实际上也是数据),称为地址传送指令。

105-如何实现C类型转换中的扩展与截断 #

  • 扩展:采用MOVZ、MOVC即可。该指令能形成先扩展,再传送的逻辑,所以即便操作数的长度不同,也不会报错。
  • 截断:源寄存器(如果直接暴力把一个长数字移动到一个短寄存器中,过不了编译。因此只能取相同长度的数字传送过去)使用形如AX、AL这样的即可。

106-栈帧、栈帧寄存器 #

  • 进程的栈段中,根据所属过程的不同可以进一步进行逻辑划分:即每个过程对应一个栈帧。栈帧越多,说明当前调用深度越深。
  • C程序进入主函数,当前只有一帧。每深入一层,就会增加一层栈帧。
    • 可以认为,一个栈帧就是一个逻辑上的栈。而进程的栈段是由若干这样的栈帧一个接着一个构成的。
    • 每次新的栈帧被创建,是向压栈的方向继续生长,所以不存在外碎片。
    • 但是有可能存在内碎片,因为IA-32架构要求栈帧的大小都是16个字节的整数倍。(这就有点像分页了)
  • 在当前函数(即过程)(注意,函数和过程没有区别。只是C里面叫函数,汇编里面叫过程)中,BP指向当前过程的栈帧的栈底,SP指向当前过程的栈帧的栈顶。
  • 在具体操作中,除了ESPESS外,还要引入一个和栈有关的寄存器:EBP、该寄存器可以理解为栈帧寄存器,提供了一种不以ESS为基址,而以EBP为基址对栈进行访问的方式。这利于我们实现栈帧。

栈帧是由谁来分配和实现的?——由编译器。

当gcc编译后,会在原有的代码逻辑上加上对栈帧进行操作的代码。

106-函数调用的机器级表示 #

注意:函数调用所有步骤都是编译器负责完成的。编译器在函数调用与返回处添加完成下面的步骤的汇编代码。

  • 简记:调用者(P)、被调用者(Q)

  • 函数调用前后,上下文的保护

    • P负责保存一部分上下文(一些寄存器的值。这些寄存器因为必然会在调用过程中被用到所以必须要保存),Q负责保存P剩下的一部分上下文(如果要用到的话就保存,否则没有必要保存,相当于那些不一定要保存的寄存器)。
  • 调用过程

    • P把需要调用者保存的寄存器先压入自己的栈帧。

    • P把入口参数按倒序(在程序里就是从右边的参数到左边的

      参数

      )压入自己的栈帧,然后压入CS和IP的值(

      代码返回地址

      ),并压入EBP的值(

      栈帧返回地址

      )。

      • 注意,会产生内碎片。因为不管是字节、字,还是双字、四字,栈帧内的数字所占空间都是以4字节,即双字为单位进行分配的,所以字节、字都会产生内碎片。
    • 新建栈帧(按16字节为单位分配),让EBP和ESP指向新栈帧的栈底。

    • CS和IP转到Q的代码处

    • Q的代码首先保存被调用者需要保护的调用者的现场,把这些压入自己的栈帧。

    • Q执行自己的功能。

      • 在这个过程中,Q可以通过EBP+8、EBP+12(即每次+4),来访问函数的参数。(注意因为栈的倒序性,这次访问参数的顺序实际上是相当于C源码里面从左往右读)(因为EBP和第一个参数之间隔了代码返回地址、栈帧返回地址两个地址,所以是EBP+8(因为是按4B为单位进行分配,所以4乘2=8))
      • 当需要创建非static的局部变量,就把他压入栈帧即可完成创建。
    • Q开始弹栈,准备进行返回。

      • 先把局部变量弹走,ESP就移动到了Q保存的P的部分现场那里

      • 于是继续弹栈,把P的现场恢复一部分。

      • 当把Q的栈帧弹空了,EBP=ESP。此时,再弹一下(

        popl ebp
        
        
          ),EBP就被自动恢复成老的EBP了。
        
          - 因为EBP并不是ESS,所以并不会出现栈越界错误。
          - 因为是`pop ebp`,所以这里存储的老EBP的值就被弹出并装入了EBP。
          - 这个时候,这两个指针的关系是:`EBP=ESP+2`。
        
        
      • 此时,EBP和ESP的关系可以看出,因为EBP恢复到了老的EBP了,而ESP因为最后一次弹栈也回来啦,所以现在的栈帧完美地恢复到了调用者的栈帧

    • Q继续执行ret指令,修改CS IP到P的,控制权交还,调用结束。

      • 因为P的栈帧现在的栈顶是代码返回地址,所以直接pop cs,pop ip就能完成返回(因为栈顶指针正好指向了我们所需的代码返回地址)。这也是ret的作用。
  • 总结:过程调用过程中,栈帧的情况

    • 从高地址到低地址,依次是:…(如果存在上一级调用),P需要保存的P的现场(即一些寄存器的值),参数表,代码返回地址,栈帧返回地址,之后进入Q的栈帧区域:接着是Q需要保存的P的现场,Q的非static局部变量,…(如果存在下一级调用)
    • 注意,栈是从高地址向低地址生长的。
      • 助记:因为堆是从低往高堆的,所以栈和他反着来。

128-函数递归调用和嵌套调用的栈帧描述 #

  • 显然,每次进入一层调用,就要进入下一层调用。也就是一直在创建新的栈帧,直到到达最里面一层。然后,在返回的时候,会依次弹出一层层的栈帧,直到第一层调用者的栈帧。
  • 嵌套调用也类似,只不过多创建的栈帧数是相对可控的。

128-栈溢出 #

  • 若OS默认为进程分配2MB大小的栈段空间,那么假设每层调用都创建最小的栈帧(即16B),那么只需要131072层调用就会超出栈空间的上限,也就是爆栈。

132-非static局部变量的空间创建 #

  • 注意,非static局部变量的空间分配的顺序是未定义行为
  • 因此,对于指针变量的除了“==”、“!=”两种比较运算外的任意比较运算和任意算术运算都是未定义行为
  • 例如下列程序:
void test(){
	int a, b, c;
    int* aPtr=&a,* bPtr=&b,* cPtr=&c;
}
  • 其中,

    a
    
      b
    
      c
    
    
      变量虽然是按如上述的顺序创建的,但是
    
    
      aPtr
    
      bPtr
    
      cPtr
    
    
      之间的大小关系
    
      是未定义的
    
      。
    
      - 编译器为了优化,可能会把其中的数据直接分配寄存器保存,而根本就不分配内存空间;(但是如果是复杂类型,肯定是分配内存空间的。)
      - 还有可能是见缝插针,有空间就存(为了减少内碎片),无法保证内存空间的连续性和相对顺序;
      - 另外就是编译器为了对齐,会让诸如char一类的无需进行对齐的数据先分配。(因为char只需要1个字节,可以理解为不需要对齐,因为随便放哪里都是齐的。)由此一来就打乱了顺序。
    
    
  • 因此,除非你在使用数组,否则aPtr+sizeof(int)等操作是危险的(所以不被允许)。没有人能保证它的结果等于bPtr

132-为什么for循环比递归性能要好很多 #

  • 因为for循环只需要在同一个过程中不断执行其重复的代码。而递归每次进行一层,就要额外分配栈帧,并且还要进行代码现场、栈帧现场的保存与恢复。前者导致了额外的空间开销,后者导致了额外的时间开销。

143-数组、结构体的机器表示 #

  • 数组、结构体都是严格连续存放其元素的。后者可以看做一个元素不等长的数组。
  • 以数组起始地址为基址,以数据长度为比例因子即可访问数组。结构体类似,但是是加变长偏移量,且是根据其中的元素进行累加。
  • 无论是数组还是结构体,其类型的指针都是指向其第一个元素处。

148-共用体的机器表示 #

  • 共用体开辟一块空间,但是这块空间是依据其中体积最大的那个数据类型去开的。
    • 例如共用体里面有char和long long,那么它会开64位的空间出来(对AMD64来说)

149-数据对齐 #

  • 数据对齐指的是数据的地址应当能够比较方便地利用基址+编址和比例因子的乘积访问得到。因为地址都是按字节,所以首先要要求地址至少必须都是和字节对齐的。

    • 如果不和字节对齐,那么就算是访问一个char类型,都要进行两次访存,效率直接减半
  • 此外,对于比较长的数据类型,比如int,希望能够一次访存就能取出,这就希望int也能与其大小对齐(即地址必须是4的整数倍)

    • 这个思想就是假设整个内存空间都存放了int类型的数组,能够方便地使用下标访问到任何一个元素。
  • 具体实现

    • Windows要求任何数据都要和它的长度对齐,例如int要4的倍数,long long和double要8的倍数(AMD64下)。
    • Linux下和Windows基本一致,区别在于最大也就是4的倍数,也就是说long long和double都是以4的倍数对齐的,其他和Windows保持一致
    • 如前所述,采用x87浮点运算器的架构下,long double是12字节大小,但是在Linux下仍然按照4的倍数对齐。
    • 实际上对齐只需要保证能正常访问到即可,例如long long在AMD64下虽然是8字节,但是其与4字节对齐,这没有关系。因为在64位宽(字长)的情况下,仍然能一次性取出。这丝毫不影响性能。
  • 特殊情况:结构体的对齐

    • 结构体在Linux下有三条对齐规则:

      • (对结构体进行平移)整个结构体必须按照其成员中最难对齐的那个变量的对齐方式进行对齐。

        • 所谓最难对齐,就是对齐的那个基数最大。例如要求4的倍数就比要求2的倍数最难对齐。
        • 这样一来,让整个结构体的起始地址对齐,是其中的数据能够对齐的基础;且这样也方便整个结构体被访问(特别是作为数组的时候)。
      • (中间插入空字节)其中的成员必须按其对其规则对齐。

        • 例如char、int、int的情况(记作结构体A),那么注意他们是顺序存储在结构体中的,如果不对齐,相对地址就是0,1,5,那么第二个、第三个int都没对齐。于是就应该变成0,4,8.
        • 但是如果换一下成员的声明顺序,即int,int,char(记作结构体B),那么全都天生就对齐了,其相对地址分别为0,4,8。
        • 可以看出,如果结构体中声明成员的顺序不一样,也会导致结构体的大小不一样。但是这不一定导致。因为还有下面的第三步对齐,对齐完发现他们是一样大的(纯属巧合)。
      • (尾部插入空字节)整个结构体

        的大小

        也必须根据他的对齐方式进行对齐。

        • 例如刚才这个结构体A现在是12,确实没有问题,已经对齐。但是结构体B是9的大小,则需要在其尾部补充3个空字节,凑成12,才能被4整除从而对齐。

可以看出,如果结构体中声明成员的顺序不一样,也会导致结构体的大小不一样。但是这不一定导致。因为还有第三步对齐。第三步对齐完发现他们是一样大的(纯属巧合)。

153-缓冲区溢出攻击及其防范 #

  • 在函数中定义变量,会临时放在其栈帧中。栈帧中,存放这些变量的这段空间称为缓冲区
  • 设想在一个函数中定义了一个数组,那么数组的元素根据其下标依次按照内存地址增大的方向存储。假设该数组离栈帧底部挺近的,那么该数组的下标最大的元素再加若干偏移量就能摸到当前所在栈帧的栈底,再往上摸就摸到了存放老EBX的地方和函数返回地址的地方。
  • 因为利用数组进行访问,无法实现下标越界检查,所以只要让他越界,并且越界到达了上面说的那个位置,然后把对应的那个存放函数返回地址的那个地方通过下标访问替换成恶意程序的代码起始地址,就会使得当前函数执行完毕时,返回到恶意代码处。
    • 这么做不会导致段错误,因为当前没有访问到除该程序外的非法空间。当且仅当访问到空洞空间,才会导致段错误。
    • 如果恶意程序能够装入一个shell,而且被攻击程序是一个root权限的程序,那么你就获得了一个具有root权限的shell,从而实现对被攻击服务器的完全控制。
    • 从恶意程序结束时,也会调用ret。但是由于进入恶意程序时,并没有保存返回地址,所以出现段错误。因为本该存放返回地址的单元不知道存放了什么。
  • 防范:
    • 栈所在地址随机化
      • 让程序的栈在一定范围内进行随机选址,这样每次程序运行,栈所在的位置都不一样。当前栈帧的位置不可预期,就难以攻击。
    • 栈破坏检测
      • 在缓冲区和老EBX、返回地址存放单元之间插入一随机值,在Windows下称为security cookie,在Linux下称为金丝雀(哨兵)。因为通过strcpy等方式去修改返回地址,必须要定义一个比较长的能够越界的数组,且其覆盖的区域都被修改了,也就势必会覆盖这个哨兵。
      • 当函数返回时,如果发现哨兵被修改,那就报段错误,于是杜绝了攻击的发生。

159-AMD64 架构和IA-32的区别 #

  • 更多的通用寄存器

    • AMD64多了8个通用寄存器,分别是R8,….,R15。因为是64位架构,所以他们都是64位的,但是又能当做32位、16位、8位寄存器使用,分别通过单独访问其低32位、低16位、低8位实现。

    • 寄存器又多又长,非常任性——这导致,如果函数的参数小于8个,一律采用寄存器传递参数。

    • 如果函数比较简单,都不需要保存被调用者的现场,

      那么这个函数不需要栈帧

      • 连栈帧都不需要了,显然比32位架构要快很多。
  • IA-32的老寄存器又被扩展了一次

    • 老的那些寄存器都是64位了。现在他们不以E为前缀了,而是以R为前缀。(也许是因为AMD家的消费级U都是R系列吧)
    • 例如:EAX变成RAX,IP变成RIP。
  • 数据类型的长度进行了扩展:主要是long long和指针类型的变化。

    • long long变成64位;指针类型也变成64位(因为逻辑地址扩展到了64位)。
  • 新的存储器访问只允许8字节或16字节一次性读出了。

    • 这就导致long double虽然仍然是80位,但是物理存储变成了16字节,因为原来的12字节存储形式不可能使用了。
    • 而且对齐方式也发生了改变:强制要求Windows式对齐,即必须是其长度的整数倍。
  • 栈的操作原来是以为单位,现在是以四字为单位!

  • 逻辑地址空间变成了64位。

    • 于是,最大虚拟内存支持到了256TB(而32位架构只有可怜的4GB,显然早就被淘汰了)

185-链接的过程 #

  • 符号解析
    • 全局静态变量名、函数名称为符号。其他的标识符都不是符号。符号是参与链接的范畴
    • 所谓解析符号,就是把多个需要被链接的文件中,其中声明为同一个符号的那些符号建立一个联系。例如把main.c中的int atest.c中的extern int a之间建立一个联系。表明他们是一个东西。

于是,各个模块就以这些符号为桥梁链接了起来。

  • 重定位

    • 在静态装入中,需要把指令中的地址改为待装入空间下的地址。

    •   .o
      
      
        阶段和可执行文件阶段的区别是:
      
        - 各个模块编译后,分别生成其`.o`文件。如果用`objdump`查看,他们的地址都是从0开始的。
        - 链接后,对于同一个函数,其地址发生了变化。变成了待装入的那个地址,例如`0x804800`.
      
      
  • 链接的好处

    • 模块化,易于维护;
    • 如果用到库,可以根据符号解析规则(见上所述)**只链接这个库里面用到的部分,而**不必把整个库都装进来。这样虽然是静态装入,但是仍能节约程序运行的内存空间。

186-ELF(Executable and Linkable Format)目标文件格式 #

  • 这个格式能解答可重定向目标文件和可执行文件虽然都是二进制文件,但是他们之间有什么区别。
  • ELF格式由Linux所采用。
    • 汇编后,生成链接视图的ELF文件;链接后,生成执行视图的ELF文件。这两种视图对应的ELF文件的格式是相同的,只是两种区域划分方法。而且程序头表、节头表对他们来说分别都是一个可选,则另一个必选。
  • 采用ELF格式,则他们可以说是同一种文件了,因为它是既linkable又executable的。其格式是:
    • ELF头
    • 程序头表(如果是链接视图,则可以不要)
      • 用于指导OS创建进程。
    • 节i或段i
      • 这就是主体部分。节是链接视图下的,段是执行视图下的。
      • 体现了节与段的映射关系。多个节可以映射到一个段。
    • 节头表(如果是执行视图,则可以不要)
      • 包含了对各节的描述信息。例如长度、名称。
      • 常见的节:代码节(.text),只读数据节(.rodata),已初始化的全局数据节(.data),未初始化的全局数据节(.bss)等。

240-指令流水线的设计 #

  • 所谓流水线,就是假设任何指令都需要相同数量的执行阶段;第i条指令在执行其第k个阶段时,第i-1条指令正在执行其第k+1个阶段。
    • 因为只有一套部件,所以保证每个阶段都只有一个指令在使用它;
    • 在下一阶段,通过寄存器进行上下文切换,这个部件又能紧接着处理下一条指令的同样的阶段。
  • MIPS中,最复杂的指令,其周期需要5个阶段才能完成(取指令,译码,取数,执行,存结果)。且最复杂的指令,其最长的阶段需要200ps完成。
  • 因此,取上述两种最坏情况,就构成了每个阶段是200ps,共5个阶段的指令流水线。
    • 除此之外,还需要在每个阶段执行完毕后,紧接着把结果打入寄存器,以实现流水线中每个涉及的部件的上下文切换(从上一条指令的执行过程中切换到下一条指令的执行过程),这也是需要时间的(一般为50ps)
    • 所以,如果分段过多,就会导致上下文切换过多,造成额外的切换时间;
    • 如果分段过少,就会导致其中最大段的时间长度过长,那么其他短的段就需要等待更长时间,造成额外的等待时间。
    • 因此,分段越均匀越好(保证每一段的等待时间都较少),且尽量在均匀的情况下保证段越少越好(减少上下文切换次数)。也就是采用了这里的MIPS五级流水线结构。
  • 得到如图的流水线结构:
Ifetch Decode + Register ALU Time Mem Time Write Back
取指(阶段1) 译码,取数到Reg(阶段2、3) 执行(阶段4)(实际上就是ALU时间) 访存(阶段5) 打入寄存器(用于流水线上下文切换)(不属于MIPS指令执行流程

241-MIPS为什么比IA-32更适合实现流水线 #

  • 指令长度一致,数据严格对齐——保证取指阶段花费的时间差不多(防止访问多次存储器)
  • 指令格式一致——保证译码阶段花费的时间差不多,而且还能实现提前读取(因为操作数都在指令中的相同的位置,不需要译码就可以提前读取)
  • 只有load、store型指令可以访存——避免某些指令因为间址而有非常长的执行周期,从而使得流水线难于进行高效的周期长度分配
    • load型指令的含义是:从内存取数据,放到寄存器中;store型指令的含义是:从寄存器存数据到内存中。
    • 可见,访存型指令都是寄存器和内存之间的数据传送。

246-流水线冒险 #

  • 结构冒险

    • 指的是多个指令同时访问同一个硬件
      • 如果不是存储器,那么可以设置限制,保证互斥访问;
      • 如果是寄存器,可以通过加Cache来解决。即区分L1缓存区分iCache和dCache,可以解决这个问题。因为访存冲突只可能是一个在取指阶段、一个在写回阶段才会发生。(不可能都在取指或写回,否则就不符合流水线的定义了)
  • 数据冒险

    • 无法实现指令的同步(指令B需要指令A的计算结果,但是他还没有得出结果。)

      • load-store数据冒险:发生在load指令和需要使用其结果的指令

        靠的太近的情况。

        • load型指令A把其load到的数据放到寄存器1,指令B要从寄存器1中取数据进行运算,但是指令A的WriteBack阶段在B的Reg阶段之后才发生,就导致指令B拿到的是过时数据。
        • 可以通过增加nop指令的方式解决,从而粗暴地把两条指令执行之间的时间差扩大。
      • 运算型数据冒险:发生在运算型指令的下一条就紧接着需要他的结果的指令

        靠的太近的情况。

        • 可以通过增加旁路的方式(即数据转发)解决:如果是一条运算指令,就把运算结果直接通过旁路(bypass)送到ALU输入端,那么下一条指令就不需要从寄存器取了。这样两条指令就可以靠的更近一些