Ch1 程序执行

原目录:系统级编程(CSAPP)

查看语雀原文

以Linux下hello.c为例

//hello.c
#include <stdio.h>
int main()
 {
	printf("hello, world\n");
}

编译系统

1.png

  1. 预处理(preprocess)阶段:cpp.h头文件直接插入程序文本,得到hello.i文件,并且移除注释(comments)
  2. 编译(compile)阶段:ccl把hello.i翻译成hello.s,其中包含汇编程序
  3. 汇编(assemble)阶段:as把hello.s翻译成机器指令打包成目标文件hello.o
  4. 链接(link)阶段:ld把需要的外部函数所在.o文件合并进当前hello.o,得到可执行文件,能加载到内存

中间文件顺序:ciso


程序执行

在linux下完成编译后用户输入./hello可执行程序,实际上有下列三步

  1. 接收用户输入: 从键盘接收输入后shell程序把字符读入寄存器,再放入主存,当敲下回车,shell判断输入完毕,执行指令加载可执行文件hello

2.png

  1. 加载hello到主存: Shell指令把磁盘中的hello复制到主存,其中包含要输出的”hello,world\n”

3.png

  1. 输入至显示器:当程序加载到主存后,CPU开始执行机器指令,将”hello,world\n”对应字节复制到寄存器,再从寄存器输出至显示器

4.png



选择题知识点

  1. VC++Project中包含Files
  2. VC++win32 console程序是:VC++可生成的最简单程序
  3. 计算的抽象级别排序: C++ code > C code > 机器码 > 逻辑门
  4. 以下代码
unsigned int x; int y; 
cin >> x >> y; 
cout << x + y;

x,y都是正数,打印结果不一定为正数,unsigned int和int运算无显式说明时进行无符号加法,得到无符号数

举例:
x=1,y=-4,此时执行无符号加法,结果fffffffd全被当作数据位,得到4294967293

1.png

2.png

x定义为int

3.png

此时执行补码加法,结果fffffffd的补码00000003,符号位f,答案-3


  1. IDE使得很难混合和匹配来自不同来源的工具:不好,因为没有一个供应商可能是所有最好工具源
  2. C代码和机器码可以描述相同的算法
  3. 关于debugger(调试程序)
    作用:①编译compiler生成的机器指令②中断程序的执行,但debugger本身不能发现error
    使用:可能需要重复执行多次代码才能定位错误behavior




Ch2 信息表示与处理

原目录:系统级编程(CSAPP)

查看语雀原文

化二进制

  • 整数:2取余(自下而上,直到商为0)
  • 小数:2取整(自上到下,直到小数部分为0)



C位运算

  • 反码ONES’ COMPLEMENT ( ~ ),
  • 位移SHIFT ( << and >> )
  • 左移时丢弃高位,低位补0
  • 右移有符号数,算术右移,丢弃低位,高位补符号位
  • 右移无符号数,逻辑右移,丢弃低位,高位补0
  • 与AND ( & ),
  • 或OR ( | ),
  • 异或EXCLUSIVE OR ( ^ ):同假异真
  • 注意!为逻辑非,C中对除0以为任何数使用!都得到0


CSAPP datalab⭐

参考链接: 实验二 datalab-handout

实现按位&,|,^操作

/* 
 * bitAnd - x&y using only ~ and | 
 *   Example: bitAnd(6, 5) = 4
 *   Legal ops: ~ |
 */
int bitAnd(int x, int y) {
  // ~(~A∪~B)=A∩B
  return ~(~x|~y);
}

/*

  • bitOr - x|y using only ~ and &
  • Example: bitOr(6, 5) = 7
  • Legal ops: ~ & */ int bitOr(int x, int y) { // ~(~A∩~B)=A∪B return ~(~x&~y);

}

/*

  • bitXor - x^y using only ~ and &
  • Example: bitXor(4, 5) = 1
  • Legal ops: ~ & */ int bitXor(int x, int y) { return (x & ~y) | (~x & y); }

求-1

1表示为32bits二进制000...001,数据位补码111...111,加符号位后转换为16进制,0xffffffff

/* 
 * minusOne - return a value of -1 
 *   Legal ops: ! ~ & ^ | + << >>
 *   Max ops: 2
 */
int minusOne(void) {
  return ~0;
}

求2s补码下最大整数

正常int的最大整数为0111.....1111,对齐取反1000...0000,那么通过0000...0001左移即可

/* 
 * TMax - return maximum two's complement integer 
 *   Legal ops: ! ~ & ^ | + << >>
 *   Max ops: 4
 */
int tmax(void) {
  return ~(1 << 31);
}

字节提取

/* 
 * getByte - Extract byte n from word x
 *   Bytes numbered from 0 (LSB) to 3 (MSB)
 *   Examples: getByte(0x12345678,1) = 0x56
 *   Legal ops: ! ~ & ^ | + << >>
 */
int getByte(int x, int n) {
  return (x >> (n << 3)) & 0x000000ff;
}

求相反数

即求2s补码

/* 
 * negate - return -x 
 *   Example: negate(1) = -1.
 *   Legal ops: ! ~ & ^ | + << >>
 */
int negate(int x) {
  return (~x)+1;
}

判断相等/等于0

/* 
 * isEqual - return 1 if x == y, and 0 otherwise 
 *   Examples: isEqual(5,5) = 1, isEqual(4,5) = 0
 *   Legal ops: ! ~ & ^ | + << >>
 */
int isEqual(int x, int y) {
  return !(x^y);
}

/*

  • isZero - returns 1 if x == 0, and 0 otherwise
  • Examples: isZero(5) = 0, isZero(0) = 1
  • Legal ops: ! ~ & ^ | + << >> */ int isZero(int x) { return ! (0^x); }

判断为正数,判断非负数

  • 为正数:符号位为0,且非0
  • 非负数:符号位为0,或为0
/* 
 * isPositive - return 1 if x > 0, return 0 otherwise 
 *   Example: isPositive(-1) = 0.
 *   Legal ops: ! ~ & ^ | + << >>
 */
int isPositive(int x) {
    return !((x >> 31) | (!x));
}

/* * isNonNegative - return 1 if x >= 0, return 0 otherwise

  • Example: isNonNegative(-1) = 0. isNonNegative(0) = 1.
  • Legal ops: ! ~ & ^ | + << >> */ int isNonNegative(int x) { return !(x >> 31); }



浮点数

IEEE Standard 754浮点数格式

1.png

结构

符号位sign: 01

阶码 Exponent: 决定小数点的位置: Exp(阶码) = E (真值)+Bias(偏移量)

分数 Fraction:指示[1 , 2]之间的数: Frac = M - 1

规格

单精度: 8 Exp, 23 Frac, 1 sign = 32

双精度: 11 Exp, 52 Frac, 1 sign =64

举例

float F  = 15213.0化为单精度浮点数Exp:

15213 = 11101101101101 = 1.1101101101101 X 213

得到E=13,偏移量27-1=127,13+127=140化为二进制10001100作为Exp

Frac:

M = 1.1101101101101,Frac = 11011011011010000000000(补齐23bits)

最终:0,10001100, 11011011011010000000000

逆运算根据IEEE表达求出原数(-1)S * 2 exp - 127 * (1 + frac * 2-23)

\((-1)^{0}*2^{13}*(1+(2^{22}+2^{21}+2^{22}+2^{19}+2^{18}+2^{16}+2^{15}+2^{13}+2^{12}+2^{10})*2^{-23})=15213\)


浮点数加法

浮点数加法需要经过"对阶",容易丢失精度:十进制下10.375 + 6.34375 = 16.71875

\(\\\frac{\begin{align} 1.0100110×2^3\\ +\quad0.1100110×2^3 \end{align}}{10.0001100×2^3}\Longrightarrow16.75\quad precision\ loss\)



选择题知识点

  1. 在4byte为一个word的计算机中,下列哪一个表达式测试指针ptr是否包含某个word的地址
(ptr & 3) == 0
(ptr | 3) == 0
(ptr % 4) == 0

选Ⅰ和Ⅲ(指针不可以做%或&运算,需要强转)

  • Ⅰ计算机4bytes为一个word,见指针,每个字节有一个地址,所以每个word的地址都以4对齐,下图为VS调试时内存窗口4字节显示结果,明显看到16进制表示时最后字节以0,4,8,c变化,对应0000 0000, 0000 01000000 1000, 0000 1100

image.png

最后2bit始终为00与0011按位与的结果永远是0

  • Ⅲ把最后字节化为int类型:0,4,8,12对4取余恒为0


  1. 关于溢出
  • C程序对整型加法中的溢出不会产生提醒,会得到错误结果;
  • 浮点数溢出,即阶码部分超过其能表示的最大值时报错#INF:infinity


  1. 以下结果是false的是:0x00 ^ 0x00
#include <stdio.h>
#include <stdbool.h>
void main()
{
	bool a = !(0);
	bool b = ~(0xFF);
	bool c = ~(0x00);
	bool d = 0x00 ^ 0x00;
}

image.png

在bd中犹豫,实际上C语言没有bool,所有bool都根据int截取,b按位取反:0x000000ff->0xffffff00,结果为负数,求二进制下31bits数据位原码,取反加一(100)2即-256,在C中只要非0的数都为true,因此b是true





Ch3 指令

原目录:系统级编程(CSAPP)

查看语雀原文

要理解指令和汇编部分,必须先掌握C语言内存分配和X86寄存器与栈帧知识

C语言内存分配

参考链接: C语言的内存分配

  1. stack-栈: 由编译器自动分配释放
  2. heap-堆: 一般由程序员分配释放,若程序员不释放 - 程序结束时可能由OS回收
  3. 全局区(静态区):全局变量静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域未初始化的全局变量和未初始化的静态变量在相邻的另一块区 - 程序结束释放。
  4. 另外还有一个专门放常量的地方 - 程序结束释放

X86寄存器和栈帧

Stack Frame

参考链接: X86-64寄存器和栈帧

C语言属于面向过程语言,他最大特点就是把一个程序分解成若干过程(函数),比如:入口函数是main,然后调用各个子函数。在对应机器语言中,GCC把过程转化成栈帧(frame),简单的说,每个栈帧对应一个过程



左图中:

  • 自下到上地址变大
  • 栈帧的生长方向自上到下


X86典型栈帧结构中

  • %ebp指向栈帧开始,%esp指向栈顶

esp是堆栈指针,无法暂借使用,所以一般使用ebp来存取堆栈

寄存器

  • x86-32CPU包含8个存储32bits的通用寄存器,从%eax到%esp(见左图👈,由于发展原因其命名历经变化),主要功能为管理栈、传递参数、保存返回值、存储局部和临时数据

  • 当16bits使用时从%ax到%sp,高8位以H标识%ah,%bh...低8位以L标识%al,%bl...


  • x86-64(x64)的CPU包含16个存储64bits的通用寄存器,都以%r开头(见CSAPP中文第三版3.4)

指令

参考链接: 16位汇编程序设计

指令格式

Snipaste_2019-12-20_12-05-12.png

  • 操作符:表示CPU支持的操作
  • 操作数/操作数地址:访问操作数的模式

不同的指令长度\可携带操作数个数\操作数访问模式都可能不同

指令循环

每一条指令的执行都经过"取址-译码-执行"的指令循环

示例代码分析

#include <stdio.h>
int x;
void main()
{
	int y;
	int z = 0;
	unsigned char* cptr;  	
	x = 5;
}

VS下以x86-32调试(x64下采用%r开头的寄存器)反汇编结果与逐行分析

参考链接: 函数参数压栈,栈帧ebp,esp怎样移动的?

--- D:\ProjectSet\CProject\slp\slp.cpp -----------------------------------------
#include <stdio.h>
int x;
void main()
{
55                   push        ebp	          //保存当前栈帧 
8B EC                mov         ebp,esp        //改变栈帧,以后访问参数通过ebp([ebp±k])
																								,访问局部变量通过esp
81 EC E4 00 00 00    sub         esp,0E4h       //跳过0E4大小地址块用于当前栈帧保存局部变量
56                   push        esi  
57                   push        edi            //将ebx,esi,edi压入栈
8D BD 1C FF FF FF    lea         edi,[ebp+FFFFFF1Ch]  
                                                //加载有效地址,把[ebp+FFFFFF1Ch]
																						    读取到edi作为参数1,根据补码运算即为[ebp-0E4]
                                           	    即当前帧局部变量保存区域基址   
B9 39 00 00 00       mov         ecx,39h        //令ecx等于0x39,作为下面rep指令重复次数  
B8 CC CC CC CC       mov         eax,0CCCCCCCCh //令eax等于CCCCCCCC
F3 AB                rep stos    dword ptr es:[edi] 
																								//es为辅助段寄存器,es:[edi]即辅助段基址+偏移
    																						//循环使用eax中的值CCCCCCCC初始化es:[edi]指
																								向空间(该串表示未初始化),步长为一个双字指针大
																								小8字节(2*32b),恰好为CCCCCCCC长度,循环0x39
																								(57)次----总结:循环使用cc初始化辅助段

B9 03 C0 7A 00 mov ecx,7AC003h //将7AC003存入ecx
E8 E0 FA FF FF call 007A1208 //调用地址007A1208处子程序,这里为debugger 相关的程序,无需深究 int y; int z = 0; C7 45 EC 00 00 00 00 mov dword ptr [ebp-14h],0 //将0放入到双字指针ptr指向的位置, 即地址ebp-14的内存空间
unsigned char* cptr; x = 5; C7 05 38 A1 7A 00 05 00 00 00 mov dword ptr ds:[007AA138h],5
//x是全局变量,位于整个程序数据端,非栈内数据 区域,寄存器ds存放数据段基址,偏移量为007AA138, 把imm 5放入007AA138指向的内存空间 }

  • movdword ptr ds:[007AA138h],5直接对应C7 0538 A1 7A 0005 00 00 00
  • ⭐寄存器再认识:通过上述分析,可以看出来寄存器本身可存放一个32/64bits数,即一个地址,所以寄存器就是指针
  • ⭐内存分配再认识:分析中可以看出

int z = 0对应movdword ptr [ebp-14h],0局部变量通过ebp±k访问,调试验证

image.png

x = 5对应movdword ptr ds:[007AA138h],5全局变量通过程序数据段基址+偏移访问

image.png


对于变量num=2

对于寄存器ax,bx

  • mov ax,num表示把2放入ax
  • mov ax,[num]表示把&num放入ax
  • lea ax,num表示把%num放入ax
  • lea ax,[num]表示把2放入ax
  • mov bx,[ax]表示取地址:把as指向数据放入bx
  • mov bx,ax表示取ax自身值放入bx
  • lea bx,[ax]表示取ax自身值放入bx
  • lea bx,ax表示取地址:把as指向数据放入bx



operand寻址模式

(Operand-addressing Mode)操作数寻址模式决定了指令循环中会被取到哪个数据项


Ch3 指令思维导图
Ch3 指令思维导图


寻址模式

指令

地址生成

说明

寄存器

AX存储BX的值

操作数都在通用reg

立即

BX存储0464H

直接给出操作数

直接

AX存储地址位0464H的空间中数据

给出操作数地址





间接

DS*10H+BX作为地址,将对应空间数据存入AX

给出一个操作数地址所在reg

基址

+
变址

DS*10H+BS+SI作为地址,将对应空间数据存入AX

给出操作数地址所在reg和存放变址的reg

寄存器相对

DS*10H+BX+4H作为地址,取其中数据放入AX

给出操作数地址所在reg和一个偏移量



选择题知识点

  1. program counter程序计数器:存储下条指令地址

instruction register指令寄存器:存储下条指令

  1. 为CPU设计少量fast memory(cache)的目的①使得指令更短②使得指令更快,与compiler无关
  2. debugger停止的原因:不确定,可能由于断点,可能由于异常
  3. 指令集:sparc:RISC,x86: CISC
  4. 如何再断点调试时查看变量值①鼠标指针放在变量上②添加监视,不能使用printf
  5. 当程序执行完一个非分支\跳转语句后,PC指向下条指令
  6. CPU 寄存器:是1word大小的CPU memory,可显式地加载/卸载编译器生成的指令
  7. 当一组连续的内存位置包含整数0xC605CD623A8365000000时,该区域可能有

①整数0xC605CD623A8365000000②字符串③一条指令

  1. 分支语句:把PC设为两个可能值中一个

跳转语句:无条件地把PC设为其操作数

  1. 机器码并没有保存源码所有信息
  2. 经过compiler对优化后的机器码①更短,运行更快②更难以调试,不能说更clearer
  3. 汇编程序不可能同时使用AX和AL:如图,AL会影响AX

未命名文件.png




Ch4 结构化数据表示

原目录:系统级编程(CSAPP)

查看语雀原文

指针&指针变量

指针

计算机中所有的数据都必须放在内存中,不同类型的数据占用的字节数不一样,例如 int 占用 4 个字节,char 占用 1 个字节。为了正确地访问这些数据,必须为每个字节都编上号码,就像门牌号、身份证号一样,每个字节的编号是唯一的,根据编号可以准确地找到某个字节

我们将内存中字节的编号称为地址(Address) / 指针(Pointer)

地址从 0 开始依次增加,对于 32 位环境,程序能够使用的内存(可寻址空间)为 232Bytes=4GB,最小的地址为 0,最大的地址为 0XFFFFFFFF

指针变量

定义

用于存放地址的变量

声明

含义:intptr可以保存一个int变量的地址

大小

1word (32bits机器即为4bytes,64bits机器即为8bytes)

操作

  • &:取地址(address-of)操作符,获取操作数地址
  • *:解引用(dereference)操作符,对指针解引用,获取当前指针指向地址区域中的值

运算

对指针的+,-,++,--,+=等运算都是针对内存中地址的运算,ptr+n即表示地址+n*元素长度(取决于ptr指向类型)

程序分析1--取地址和解引用

#include <stdio.h>
void main()
{
	int x = 0, y = 1;
	int* ptr = &y;
	*ptr = x;
	printf_s("x = %d, y = %d\n", x, y);
	// 结果x = 0, y = 0
}

反汇编中重要部分分析

  int* ptr = &y;
 lea         eax,[y]  						//lea eax,[y],把&y写入eax中
 mov         dword ptr [ptr],eax  //把eax值放在ptr指向空间,即写入&y
	*ptr = x;
 mov         eax,dword ptr [ptr]  //把ptr指向空间的数据写入eax,即写入&y
 mov         ecx,dword ptr [x]  	//把&x指向空间的数据写入ecx,即写入x
 mov         dword ptr [eax],ecx  //把ecx中数据写入对eax取址指向的地址空间,即y=[%y]=ecx=x

程序分析2--指针类型转换

#include <stdio.h>
void main()
{
	int x = 0x8041;
	char* ptr = (char*)&x;
	printf_s("%c", *ptr);
	// 结果A(A的ASCII编码65即41h)
}

char的大小为1byte但char*依旧为指针,大小4byte,用ptr保存&x

对ptr解引用,即改变&x指向空间的值,但经过强转后不再是对int*解引用而是对char*,所以结果只会看到&x指向空间的第一个字节,8041中的41

image.png

程序分析3--指针运算

#include <stdio.h>
void main()
{
	int x = 0x8041;
	char* ptr = (char*)&x;
	int* intptr = &x;
}

调试

image.png

  • char*类型ptr+1,ptr中地址增加了1byte(char元素长度)
  • int*类型intptr+1,intptr中地址增加4bytes(int元素长度)




变化sizeof(指向类型)*k个bytes,
留出空间给指向类型的k个元素

函数指针(变量)

#include <stdio.h>
int add(int a, int b) {
	return a + b;
}

void main() { int x = 0, y = 1; int (*func)(int,int) = &add; //或者int (*func)(int,int) = add; printf_s("%d", func(x, y)); //或者printf_s("%d", (*func)(x, y)); }


声明

: int (*func)(int,int):

意义

指针func保存了add()的起始地址

要点

  • 函数指针"*"需要和指针名称用()包围
  • 调用时可直接func()也可显式说明(*fun)()



数组

C中数组名和指向数组首元素指针是同义的,当ptr指向数组后:

  • 元素地址:  ptr == a    ptr + k == &a[k]
  • 元素值:  *(ptr+k) ==*(a+k) == a[k]  
#include <stdio.h>
void main()
{
	int a[5] = {0,1,2,3,4};
	int* ptr = a;
	printf_s("ptr = %p\n", ptr);
	printf_s("a = %p\n", a);
	printf_s("ptr + 2 = %p\n", ptr + 2);
	printf_s("&a[2] = %p\n", &a[2]);
	printf_s("*(ptr+2) = %d\n", *(ptr + 2));
	printf_s("*(a+2) = %d\n", *(a + 2));
	printf_s("*(ptr+2) = %d\n", *(ptr+2));
	printf_s("*(a+2) = %d\n", *(a+2));
	printf_s("a[2] = %d\n", a[2]);
}

结果:

image.png


二维数组

参考链接: C语言二维数组指针

示例代码

#include <stdio.h>
void main()
{
	//为了方便统一使用&p打印,使用16进制数
	int a[2][3] = {0x00,0x01,0x02,
				   0x10,0x11,0x12};
int(*ptr)[3]  = a;
printf_s(&quot;a = %p\n&quot;, a);
printf_s(&quot;*a = %p\n&quot;, *a);
printf_s(&quot;**a = %p\n\n&quot;, **a);

printf_s(&quot;a+1 = %p\n&quot;, a + 1);
printf_s(&quot;*(a+1) = %p\n&quot;, *(a + 1));
printf_s(&quot;**(a+1) = %p\n&quot;, **(a + 1));
printf_s(&quot;*(*a+1) = %p\n\n&quot;, *(*a + 1));

printf_s(&quot;a[1] = %p\n&quot;, a[1]);
printf_s(&quot;*a[1] = %p\n&quot;, *a[1]);
printf_s(&quot;a[1][1] = %p\n&quot;, a[1][1]);
printf_s(&quot;*(*(a+1)+1) = %p\n&quot;, *(*(a + 1) + 1));
printf_s(&quot;*(a[1] + 1) = %p\n&quot;, *(a[1] + 1));

}

结果:

image.png


  • 观察内存,得到结论:二维数组在概念上是二维的,但在内存中所有元素都是连续排列

image.png


  • 数组名a是int[2][3]起始地址
  • 一次解引用*a:依旧为地址,概念上第一个int[3]起始地址
  • 二次解引用**a:取到第一个int[3]起始元素值,可监视验证


image.png


  • ⭐二维数组a[2][3]获取元素值的方法
  • ①两次解引用**
  • ②int[j][k]
  • ③*(a[j]+k)
  • ④*((int*)a+4*j+k):如果没有int*强转,直接运算,变化以int[3]大小为单位


个人理解

  • 数组b[3]={0,1,2},*b可得到0,但二维数组a[2][3]必须两次解引用,可以看作二维数组对一维数组额外做了一层封装从而*a=b,**a=*b,可通过监视验证,

image.png

  • 结合👆指针运算,a+1使地址+sizeof(int[3])即12(0xA)字节,而*a+1使地址+sizeof(int)即4(0x04)字节

image.png


数组指针&指针数组

参考链接: C语言数组指针和指针数组

#include <stdio.h>
void main()
{
	int a[2][3] = { 0x00,0x01,0x02,0x10,0x11,0x12 };
	int(*aptr)[3] = a;
	int b[3] = { 0,1,2 };
	int* bptr = b;
}

如上,可以创建指向数组的指针对数组访问,对于二维数组同样适用,但极易和指针数组搞混

声明: int* ptr[3]

含义: 运算优先级[]>*,因此声明构成一个数组定义

  • 数组名ptr
  • 大小为3
  • 元素类型为int*

声明: int(*aptr)[3]

含义: 运算优先级()>[],因此声明构成一个指针定义

  • 指针名称aptr
  • 指向一个包含3个int元素的数组



字符串

C中没有string这一基本类型,实际上是一个以"\0"结尾的char[],并且char[]每个元素声明后会初始化为'\0'(0x00)

image.png



结构体&联合体

Structure

参考链接: C 结构体

定义:C 数组允许定义可存储相同类型数据项的变量,结构是 C 编程中另一种用户自定义的可用的数据类型

声明:结构体声明有三种形式,其中使用typedef可以创建自定义的新类型,并通过该类型声明新的结构体变量,见👇

存储空间:结构的大小取决于其对齐的结果

Union

参考链接: C 共用体

定义:union是一种特殊的数据类型,允许在相同的内存位置存储不同的数据类型,但是任何时候只能有一个成员带有值

声明:见示例

存储空间:Union的大小只需满足最大成员即可

示例(vs默认8字节对齐,开头主动声明为4字节)

#include <stdio.h>
#pragma pack(4)
typedef struct
{
    int a[7];
    char b;
    double c;
} Stru;

union Data { int i; float f; char str[20]; } data;

void main() { Stru s; Data u; printf_s("%d\n", sizeof(s)); printf_s("%d\n", sizeof(u)); }

结果为40,20,structure分析见👇,union的最大成员为char[20],故为20字节


结构成员对齐

参考链接: C语言内存对齐和结构补齐

为了优化CPU从内存中取数据的效率,compiler会对结构体对齐

对齐原则

  1. 第一个成员的首地址为0
  2. 每个成员的首地址是size的整数倍,当size大于基准时,按照基准对齐
  3. 最后以结构总体对齐:结构体结束地址要是基准的整数倍
#pragma pack(4)  //以4字节对齐
typedef struct
{
    int a[7];
    char b;
    double c;
} Stru;
  1. int[]: 从0开始,占据7*4=28bytes
  2. bsizeof(char)=1,任何位置都是1的倍数,则直接放入
  3. sizeof(double)=8>4,按4字节对齐,当前地址29不是4的整数倍,则29+3=32,补3字节
  4. 结束地址28+1+3+8=40,满足4的整数倍
  5. 最终结构的size为40bytes

image.png

32位机以双字(dword)进行传输,一次读8bytes(2*32bits),经过对齐,40bytes内容需要CPU从内存中读取5次



选择题知识点

  1. ⭐给定代码
#include <stdio.h>
void main()
{
    char a[100] = { "Hello World!" };
    a[99] = *((char*)(((int)&a[0]) + 4));
int b[100] = { 1,2,3,4,5,6,7 };
b[99] = *((int*)(((int)&amp;b[0]) + 4));

}

①a[99]的值等于?a[4]

(int)&a[0]获取&a[0],转化为数字,直接+4后地址前进4字节,即为&a[4],对其解引用,获取a[4]

image.png

②b[99]等于?b[1]

(int)&b[0]获取&b[0],转化为数字,直接+4后地址前进4字节,即为&b[1],解引用,获取b[1]

image.png

结论:地址化为int类型后+1表示地址前进1字节

  1. 不是所有现代处理器都需要对齐
  2. ⭐计算机中地址A,A+1,A+2,A+3保存int 256,令int* aptr=A在另一台设备上B,B+1,B+2,B+3保存int 256,令int* bptr = B,不同设备int都是4字节长度,则


①A+1中数据和B+1相同
🚫

②A+1中数据和B+2相同🚫

③*aptr == *bptr          ✅

关于①②,设计到数据格式中大小端问题256=0x0100,同时为有符号数,所以实际表示为4字节:0x00000100,将其入栈保存

  • 大端下栈帧向高地址生长,A:00,A+1:01,A+2:00,A+3:00
  • (X86默认)小端下栈帧向低地址生长,A:00,A+1:00,A+2:01,A+3:00

此时①②错误,③:对int* ptr解引用,无论地址格式,都获取其中int值,即256

  1. VS中的内存窗口:以多种方式显示内存情况,但是不出现变量名称(见👆结构对齐配图)
  2. 给定代码
int a[10];
int *ptr = a;

则语句①*(ary+3)=10②pary[4]=10③pary++④ary++中,错误的是ary++,数组名(起始地址)不可直接运算


6.给定代码,结果为10 5,sizeof计算分配空间的长度,strlen只计算字符串有效长度(无\0)

#include <stdio.h>
#include <string.h>

void main() { char s[10] = "hello"; printf("%d %d", sizeof(s), strlen(s)); }

  1. 给定代码
#include <stdio.h>
#include <string.h>

void main() { char str1[] = "abc"; const char* str2 = "abc"; const char* str3 = "abc"; int i = (str2 == str3); }

则调试结果如下

  • 对比6,当没有指定char[]大小时,sizeof计算"字符+\0"空间
  • 由于char在C中存放在专门的段中,对于相同字符串,不同指针实际指向一个地址

image.png

  1. C语言在数组越界访问时不会报错       



Ch5 函数调用与返回

原目录:系统级编程(CSAPP)

查看语雀原文

参数

C语言函数调用中参数分为形参(formal parameter)和实参(actual parameter)

  • 形参:定义函数时候使用的参数,用来接收调用该函数时传入的参数
  • 实参:调用者调用时传递给函数的参数

传参

C中主要有三种传参方式值传递地址传递引用传递,其中引用传递再C++中引入

值传递

传递的为实参的拷贝,二者值相同,但在内存中地址不同

地址传递

传递的为实参地址的拷贝,一定要清楚:本质依旧是值传递,只不过实参是地址

引用传递

传递的为实参地址,与地址传递区分,引用传递的实参就是变量本身,非其地址,但在函数中的操作都针对原始实参

对比Java

C中值传递和地址传递可对应Java中的基本数据类型传参对象引用传参,Java本身按值传参:

  • 对于int,float等基本类型传递值即可
  • 每个对象引用都是一个指针/地址,指向堆上同一个对象,传递引用即传递地址

示例代码

#include <stdio.h>
int first;
void callee(int value_first, int* addr_first, int& refe_first) {
    value_first = 2;
    *addr_first = 2;
    refe_first = 3;
}

void main() { first = 1; callee(first, &first, first); }

分析

image.png

  • value_first=first但是&value_first≠&first =>值传递
  • addr_first=&first且&addr_first单独存在 =>地址传递,且说明该地址本身作为拷贝又存储在别的区域(int**)
  • refe_first=first且&refe_first=&first => 引用传递



活动记录


定义:
在Ch3中了解过栈帧,即活动记录(Activation Record)

C语言作为面向过程的语言,当每个函数被调用时,都会产生一个过程记录,就是程序执行过程中函数"运行时栈"上的内容变化,一个函数被调用,反映在栈上的与之相关的内容被称为一帧,其中包含了参数、返回地址、旧ebp值、局部变量以及esp和ebp

示例代码

#include <stdio.h>
int first;
int callee(int value_first, int* addr_first) {
    value_first = 2;
    *addr_first = 2;
    return value_first;
}

void main() { first = 1; callee(first, &first); }

函数call过程

当发生函数调用时,编译器和硬件(寄存器)会进行下列动作:

  1. 将参数入栈
  2. 返回地址入栈
  3. 进入callee,旧的帧指针入栈保存(push ebp)
  4. 让帧指针等于当前栈顶指针(mov ebp,esp),成为新帧指针
  5. 帧指针偏移一定数值,预留用于保存局部变量的地址空间(sub ebp xxxxh)

部分反汇编结果

参考链接: 汇编语言OFFSET运算符

//-------------1. 函数调用部分----------
    callee(first, &first);
 push        offset first (020A17Ch)  		//全局变量first位于数据段偏移020A17Ch处,&first入栈
 mov         eax,dword ptr [first (020A17Ch)] //取&first处数据放入eax
 push        eax  												//eax入栈
 																					//对应步骤1,&first和first先后入栈
 call        callee (02013BBh)  					//call地址02013BBh处指令,当前地址入栈,
 																					对应步骤2,进入子函数
 add         esp,8
 }

//————-2. 子函数部分———- void callee(int value_first, int* addr_first) { push ebp //对应步骤3,保存上一栈帧 mov ebp,esp //对应步骤4,移动当前帧到栈顶,为子函数开辟新帧 sub esp,0C0h //对应步骤5,预留局部变量地址空间 push ebx
push esi
push edi //保存原来的寄存器值 lea edi,[ebp-0C0h]
mov ecx,30h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi] //初始化辅助段,见Ch3汇编分析

函数ret过程

函数返回时

  1. 如果有返回值,先保存返回值到eax
  2. esp add,释放预留给局部变量的空间
  3. 帧指针ebp等于栈指针esp,从栈中弹出上一个帧指针
  4. 从栈中弹出返回地址,使用eip保存
  5. 回到caller,esp add,释放参数占据的空间
//------------3.从callee return----------
return value_first;
 mov         eax,dword ptr [value_first]  //步骤1,返回值保存在eax,mov中对数值操作数加[]还是数值
}
 pop         edi  												
 pop         esi  
 pop         ebx  												//弹出保存的寄存器,恢复之前的状态
 add         esp,0C0h  										//步骤2,esp加上之前的偏移,释放预留给局部变量的空间
 cmp         ebp,esp  										
 call        __RTC_CheckEsp (0241212h)    //debugger相关,可忽略
 mov         esp,ebp  										//步骤3,令帧指针等于栈指针
 pop         ebp  												//步骤3,弹出旧的帧指针给ebp
 ret   																		//步骤4,栈中弹出返回地址,EIP接收

//————4.回到caller———- call callee (02413C0h)
add esp,8 //步骤5,栈指针递增,释放被参数占用空间 }



选择题知识点

  1. 活动记录什么时候产生:①程序开始执行(可理解为main()的栈帧)②调用子函数
  2. 全局变量,静态变量,函数的地址在编译时被compiler确定,但函数内局部变量地址无法被compiler获得
  3. 执行函数callee()后,帧指针的值是①caller()帧的top②callee()帧的底部,可以对比活动记录图示
  4. 递归函数,深入n次即产生n个活动记录,递归返回时最终要从栈中弹出n个活动记录,如下当调用factorial(4),最终弹出4个活动记录
int factorial(int n) {
    if (n == 1) return n; 
    return n * factorial(n - 1);
}



Ch6 内存布局和分配

原目录:系统级编程(CSAPP)

查看语雀原文

内存布局

程序的内存布局分为不同段,从低地址到高地址为:

  • :存放的是程序的全部代码(机器指令),来源于二进制.exe中的代码部分
  • :包含了已初始化全局变量和已初始化的static局部变量
  • :包含未初始化全局变量和未初始化static局部变量,C规定变量初始值都为0,因此bss中存放的都为0
  • :记录自动变量以及每次函数调用时所需信息(存放活动记录),属于动态/静态分配
  • :通常在堆中进行动态分配,由于历史上形成的惯例,堆位于非初始化数据段顶和栈底间



静态分配

参考链接: C语言中内存分配

定义: compiler在处理程序源代码时分配内存空间,程序执行之前进行,效率比较高

stack分配

下图代码中int n=1

  • 变量a在编译阶段已分配在stack中[ebp-8]处空间,执行时将1放入即可

image.png

**stack有静态和动态分配两种方式;而heap只有动态分配

静态变量

  • 局部静态变量: 作用域不变,生命周期延长
  • 全局静态变量: 作用域缩小,生命周期不变
Ch6 内存布局和分配思维导图
Ch6 内存布局和分配思维导图

字符串常量

#include <stdio.h> 
void main(void) {
    char s[] = "cat";
    const char *p = "rat";
    printf("%s chases %s\n", s, p);
s[0] = 'C';
char* sp = s;
*(sp + 1) = 'A';
printf(&quot;%s chases %s\n&quot;, s, p);
//最终输出:
//cat chases rat
//CAt chases rat

}

C中对于字符串初始化通常有两种方式(不考虑string.h)

  • char s[] = "..."
  • char *p = "..."(C11后规定只能const char*s = "...")

image.png

前者易理解,对于后者:相当于将一个字符内容为 "rat\0" 的字符数组首地址赋值给字符指针p,不允许使用字符指针修改字符串中数值即*p = 'R'不被允许的,因此C11后强制要求使用const char *初始化字符串,表示指针指向数据对指针来说是不可变的(const char* p等价于char const *p,区别于char * const p)


静态分配的缺点

  1. 命名可能产生冲突
  2. 不灵活,因为是在编译前就分配了空间,程序运行中无法调整空间大小
  3. 生命周期直到程序结束,因此对于暂时使用的变量产生空间浪费
  4. 无法实现递归,因为递归本身是动态过程,随着递归深入,当前帧必须进行"入栈-出栈"

动态分配

定义:程序在执行时进行内存分配,heap只能动态分配,stack也能进行动态分配

stack分配

  • 变长数组即为栈上动态分配的代表,使用alloca向stack申请空间

heap分配

  • 堆是由malloc()函数(C++ new)分配的内存块(chunk),内存释放由程序员手动控制,在C语言为free函数完成(C++ delete)

对比

stack分配

heap分配

管理方式

全自动,由compiler按需分配/清除

程序员手动使用malloc()/free()
没能free分配的heap空间

空间大小

栈向低地址连续生长,栈顶已经被限制,最大容量有限,故一般栈空间较小

堆向高地址生长,可不连续,可获得空间更大

碎片产生

栈时连续内存空间,没有碎片

堆不连续,容易产生碎片

增长方向

低地址

高地址

分配效率

高,栈是机器系统提供的数据结构,计算机底层硬件支持

低,堆需要依靠算法进行管理



选择题知识点

  1. 当某个compiler静态存储所有的变量,返回地址,寄存器等,则理论上①局部变量②函数调用③递归中的①②可以继续实现:在数据段保存变量,地址即可
  2. ⭐阻碍C语言自动释放heap空间的原因是(即自动free)

①指针不一定都被初始化:未考虑该问题的编译器可能为未初始化指针随机分配地址,一旦随机到被占用的地址,随意free会产生问题

②强转(casting)使得指针身份无法确定:free的时候,依靠的只有malloc时的地址和分配内存的大小-->当指针被强转其他类型,对应区域将无法被free

  1. 要在堆上分配100个long的空间是,语句是 long* a = (long*)malloc(100*sizeof(long))
  2. void* malloc()返回值
  • 分配成功:返回分配空间的地址/指针
  • 分配失败; 返回空指针NULL



Ch7-8 堆内存管理

原目录:系统级编程(CSAPP)

查看语雀原文

参考链接: melloc堆区的动态内存分配(该部分对应CSAPP第九章)

空闲块管理

堆被分成许多不同的blocks,只有记录空闲块位置才能进行空间分配

分配器原则

  • 处理任意请求序列
  • 立即响应请求
  • 只使用heap
  • 对齐块
  • 不修改已分配块

隐式空闲链表

块格式

链表

说明

"隐式"指空闲块是通过头部的大小字段隐含的连接起来,分配器访问空闲块必须经过已分配块,因此:隐式空闲链表任何操作开销都和和(空闲块+分配块)的大小成线性关系

显式空闲链表

块格式

显式空闲链表即构造了"双向链表",空闲块中标记了前后空闲块地址,链表结构和隐式基本一致

说明

显式空闲链表任何操作开销都只和空闲块的大小成线性关系



放置策略

当需要把数据放入free block时,需要考虑选择哪一块,即放置策略(placement policy)

最先适配(First Fit)

从链表head开始,选择最先满足需求的块

  • 优点:把较大空闲块留在了链表后
  • 缺点:容易在链表起始处产生碎片,使得速度速度降低

下次适配(Next FIt)

和FF类似,但每次从上次结束处开始搜索

  • 优点:比FF速度快
  • 缺点:空间利用率低

最佳适配(Best Fit)

从链表head开始,选择满足且最接近需求大小的块

  • 优点:空间利用率高于FF和NF
  • 缺点:需要彻底对heap进行搜索

最坏适配(Worst Fit)

从链表head开始,选择满足且最大的块

分离适配(Segregated Fit)

分离适配下分配器维护着空闲链表数组,每条链表维护大小不同的块

  • 优先进行first fit
  • 适配后,对当前块分割,提高利用率
  • 未适配,则考虑合并(coalesce)空闲块,如果依旧不满足,则请求额外空间,从数组中更大一级的链表中适配

分离适配:

32位机中,向heap请求3个word(12bytes),最先适配到32字节的空闲块,分割成两个16字节的块



垃圾收集器

定义:垃圾收集器是一种动态内存分配器,自动释放程序不再需要的分配块,即垃圾

思想:

  • 垃圾收集器把内存视为可达图,图中可分为root node和heap node,堆节点都位于堆中的已分配块,而根节点都不在堆中,需要无论何时都可达,可以是寄存器,栈中变量,全局变量等.p->q表示p中某个位置指向q中某处,此时q可达,而不可达节点即为垃圾,表示程序不会再去访问它
  • 垃圾收集器需要维护一张可达图,回收不可达节点,返回块给空闲链表

image.png

实际:

  • Java等对指针使用严格的语言可精确维护可达图,进行垃圾回收
  • C/C++等对指针使用更灵活的语言无法精确维护(Ch6选择第2题),保险起见,只能认为某些"垃圾"也是可达的--保守垃圾收集器

常用算法⭐

参考链接: 常见GC算法介绍

Reference counting-引用计数算法

思路:给每个对象一个引用计数器,每当对象被引用,counter就会加1;当引用失效时,counter的值就会减1.任何时刻计数器的值为0的对象就是不再被使用的,可被清除

优点:

  • 回收空间随时进行,回收时不会引发中断挂起程序
  • 简单高效,可利用全部heap空间

缺点:

  • counter增加任务繁重
  • 实现复杂
  • 循环结构无法回收:即两个对象相互引用,计数永远是1,因此被JVM放弃


Mark and Sweep-标记清除算法

思路:分为标记和清除两阶段.

  • 标记:从根结点出发遍历对象,对访问过的对象打上标记,表示该对象可达
  • 清除:标记完成后对那些没有标记的对象进行回收(不可达对象)

缺点:

  • 效率低:两阶段效率都不高
  • 堆空间碎片化:算法结束后会产生大量不连续的堆空间碎片
  • 周期性中断:用于回收空间


Copying-复制算法

思路:将内存空间按容量分成两块,当一块内存用完的时候,就将还存活着的对象复制到另外一块上,然后把已经使用过的一块一次性全部清除.这样使得每次都是对半块内存进行内存回收,分配时就不存在内存碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,通常简单高效

优点:

  • 分配时只要移动堆顶的指针即可,通常更高效

缺点:

  • 堆使用效率低下
  • 周期性中断:用于回收空间


小结

算法\特点

利用所有heap空间

发生周期性中断

处理循环结构

标记清除

引用计数

复制



内存相关Bug

间接引用坏指针

某些指针指向内存空洞或只读区域,无意中引用/写入会产生后果

如下代码,如果写成错误形式,scanf把val的值视为内存地址写入数据,结果难料

//正确
scanf("%d",&val);
//错误
scanf("%d",val);

未初始化指针

函数中未初始化的指针值是未定义(0xcccccccc),实测如下:

image.png

⭐读取未初始化的存储器

bss存储器位置总是被加载器初始化为0,但堆存储器不是这样,此时可以:

  • 显式的初始化新得到区域为0
  • 使用void* calloc()代替void* malloc(),malloc()不初始化分配的内存,calloc()初始化已分配的内存为0

栈溢出

栈大小优先,如果不检查串的大小就写入栈中的目标缓冲区可能会有缓冲区溢出错误

内存泄漏

未free()heap上申请的空间

错误的引用指针,而非对象

long a[10];  
ptr = a + 5;  
*ptr++ = x; //实际效果a[5]=x; ptr=ptr+1

一元运算符++和*优先级相同,则从右向左结合,即*(ptr++),则指针值+1,同时后缀++的特性表示先使用当前值进行下一步计算,即a[5] = x,然后再进行ptr++(极易错写为a[6]=x)

重复free()

#include <stdio.h> 
#include <malloc.h>
void main(void) {
    int* p = (int*)malloc(100);
    int* q = p;
    free(p);
}

实际上free(p)和free(q)结果一样,因为二者保存的地址是相同的(再次说明free及其依赖malloc时的地址),free后二者指向相同的被freed heap空间

image.png

  • 当重复free(q)时,会报关于无效heap指针的错误

image.png



选择题知识点

  1. 当C中变量被声明为static是①变量被静态分配空间②变量只对当前文件内函数可见(无论全局,局部),static声明不代表变量值不经常改变
  2. 静态变量都会自动初始化为0,见bss区
  3. ⭐关于结构体free():当free()一个使用malloc()动态分配空间创建的结构对象时,只会释放结构指针指向的堆空间,当结构体内也还有指针时,该内部指针指向空间不会被释放,可见free()释放struct结构体,尤其对于结构体中含有char* ptr时应当注意
  4. 避免重复free()的办法:为没一块设置free flag,free()前检查标记
  5. 垃圾收集器回收无法通过解引用指针访问的空间
  6. 提高内存池性能的方法是"一次性free()池中所有block"
  7. 垃圾回收器的"引用计数": 指向当前block的指针个数
  8. 对于常用数据类型,为了提高malloc()/free()效率,可以维护一条对应数据类型大小的空闲块链表
  9. 内部碎片:被分配出去(能明确指出属于哪个进程)却不能被利用的内存空间,比如被malloc后却从未free的空间;

外部碎片:没有被分配出去(不属于任何进程),但由于太小无法分配给新进程的内存空闲区域,比如标记清除后的块

  1. 关于分配器的说法,错误的为B,分离空闲链表优先FF

A. 最理想情况,带标记边界的合并使用常数时间

B. 分离空闲链表优先BF

C. 负载必须和边界对齐

D. 显式空闲链表更快





Ch9 性能度量

原目录:系统级编程(CSAPP)

查看语雀原文

法则

80/20 Rule

即Pareto Principle, 80%的CPU时间被20%的程序占用,要想提高性能需要关注这20%

Amdahl's Law

阿姆达尔法则:系统优化某部件获得的提升率取决于该部件使用的频率(占总执行时间的比例)

\(总加速比=\frac{1}{(1-升级比例)+\frac{升级比例}{升级加速比}}\\ \scriptsize\color{Red}*升级加速比=\frac{原用时}{现用时}>1\)

举例:

如左图,并行处理的5核心CPU中一个核心处理速度提高了10%,求整体加速比例.

解:

升级加速比=20/18=10/9

1/[80%+20%/(10/9)]=1/0.98≈102%,整体提高了2%



计时器

衡量性能的两种常用方法:timer(计时器) & profiler(探查器) tools

Time in CS

CS中的时间分为两种: wall time和cpu time


Ch9 性能度量思维导图
Ch9 性能度量思维导图


wall time

程序执行的总持续(duration)时间
(包括等待用户IO等)

用户时间

在user process中执行相关指令的时间

系统时间

在kernel中代表user process执行指令的时间

其他时间

执行其余和user process无关指令的时间

CPU time

程序执行指令花费的时间

用户CPU时间

CPU直接执行代码的时间

系统CPU时间

OS代表程序使用CPU执行指令的用时


Timer

要使用time,需要借助timer

定义

timer是CS(Computer System)中的组件,可作为硬件/软件,可在某种程度上衡量时间

硬件中

x86有专门的时间戳计数器(TSC, time stamp counter),可通过特殊指令使用

OS中

  • Linux
  • 内部实现:始于启动时读取RTC(时钟芯片),进行转换; 1970.1.1 0:00 AM
  • 系统调用: times(), gettimeofday()
  • 时间片: OS Scheduler
  • Windows
  • 内部实现:始于启动时读取RTC(时钟芯片),进行转换; 1970.1.1 12:00 PM
  • 系统调用: GetLocalTime(),GetSystemTime()
  • 时间片: OS Scheduler

C/C++中

数据类型: clock_t, time_t

宏: CLOCKS_PER_SEC

函数:<time.h>中的 Clock(), Time()



性能探查器

定义

代码的执行进行基准测试(benchmark)的程序,帮助用户了解代码执行花费的时间

提供信息

  • 源码运行时间
  • 范围分析
  • (函数)执行记录
  • 执行次数

意义

  • 找到程序的瓶颈
  • 找到最高频执行的代码

统计抽样

Statistical Sampling(统计抽样)是profiler的一种工作模式:通过在程序运行过程中暂停程序,记录堆栈中的信息,然后恢复程序来分析程序.暂停、记录、恢复的速度是非常迅速的,相对于程序的执行时间可以忽略不计

  • 优点:①代码可自动执行; ②性能探测的影响可被最小化



选择题知识点

  1. 阿姆达尔定律用于程序优化意味着:连续(successive)的优化带来的回报是递减的(diminishing)
  2. 可以有效探查程序性能的方法:

①使用C/C++中的stopwatch

Statistical Sampling(统计抽样)

③使用系统监控工具(System Monitors)

  1. 进行优化的最合理阶段是:函数written&debug结束
  2. 优化的第一步是:找到Hotspots
  3. 80/20原则程序运行中指:80%的运行时间被20%的代码占用
  4. 关于探查器,下列正确说法是: 全部

①GPROF是Linux下的探查器②探查器可估计程序花费时间③探查器可获得不同部分执行时间




Ch10 程序性能优化

原目录:系统级编程(CSAPP)

查看语雀原文

时间复杂度

时间复杂度(Asymptotic Complexity)被用来估算程序运行的开销,常见复杂度如下

笔记配图


常见复杂度和场景

描述增长的数量级说明举例

常数

O(n)普通语句两数相加
对数O(logN)二分策略二分查找

线性

O(N)

循环找出最大值
线性对数O(NlogN)分治归并排序
平方O()双层循环检查所有元素对
立方O(N³)三层循环检查所有三元组

指数

O(2N)

穷举查找检查所有子集



优化编译器的局限

内存别名

程序中两个不同名指针变量可指向相同地址,被称为内存别名(memory aliasing),此时某些优化会带来不同的结果

  • (GCC为例)编译器在优化时会假设存在内存别名的情况

示例程序

//优化前,需4读2写
void fun() {
    int a = 1;
    int* p = &a;
    int* q = &a;
    *p += *q;
    *p += *q;
    //结果a = 4
}
//优化后,需2读1写
void fun() {
    int a = 1;
    int* p = &a;
    int* q = &a;
    *p += 2 * *q;
    //结果a = 3
}

上述程序中当p和q同时指向a的空间,此时合并运算会产生完全不同的结果,编译器必须假设存在这种情况

函数副作用

某些时候调用函数会产生一些意料之外的效果

  • 编译器需假设每次调用函数都会有副作用(side-effects)

示例程序

//全局变量
int counter = 0;
int f(int x){
    return counter++;
}

//优化前 int func1(x){ return f(x) + f(x) + f(x) + f(x); } //优化后 int func2(x){ return 4*f(x); }

func1若被优化为func2,看似合理,但如果每次调用f()会改变一次全局变量,则优化后产生副作用


CPE

定义

每元素的周期数(Cycles Per Element, CPE),用于度量具有"在一组元素上迭代的循环"程序的性能

周期

CPU活动由时钟控制,时钟提供规律信号,即"时钟周期",单位一般为千兆赫兹(GHz),即每秒钟十亿(109)周期.而每周期时间即频率的倒数,如4GHz处理器的周期时间为0.25纳秒

元素

迭代循环中的一类数据中的对象,如数组元素

举例,下列为一个向量计算程序,psum1()每次计算一个元素,psum2()每次计算两个元素

拟合效果如下


psum1=>368+9.0n

psum2=>368+6.0n

分析:

  • 起始值368:说明函数的准备,初始化,完成阶段需368周期
  • 斜率:即CPE

psum1()计算每个元素周期开销为9

psum2()计算每个元素周期开销为6


常用优化

代码移动(code motion)

把把循环中每次都计算但值都不变的操作移出循环

减少过程调用

比如caller直接使用数组元素,而非通过callee获取元素值后返回给caller

减少不必要引用

在某些循环中,把当前结果保存在临时变量中最后写入,比每次都更新目标内存要高效

循环展开

循环展开(Loop Unrolling),通过增加每次迭代计算的元素数量减少总共所需的循环次数.

如上psum2()使用了2×1循环展开

提高并行性, 常量折叠 ... ...


选择题知识点

  1. 对于大部分时候都在比较字符串的程序,使用什么方法可以最大提高性能?

对每个字符串使用独立的指针,这样直接使用指针比较即可

  1. 下列函数可进行"减少过程调用"优化:使用temp变量保存s[i],而非每次访问数组
void lower1(char *s) { 
int i;
 for (i = 0; i < strlen(s); i++) 
   if (s[i] >= 'A' && s[i] <= 'Z') 
      s[i] -= ('A' - 'a'); 

}(

  1. 从程序运行速度上优化程序可以:使用更快的算法,与占用空间和指针无关
  2. 为了优化程序,需要①找到hot spot②理解程序运行时的处理器特性,无需了解所有系统调用
  3. 下列有关C程序优化的说法,正确的是: 全错

①只需要配置优化模式即可②无需理解CPU特性③无需关注汇编代码


Ch11 存储与性能

原目录:系统级编程(CSAPP)

查看语雀原文

存储技术

Ch11 存储与性能思维导图
Ch11 存储与性能思维导图

随机访问存储器

SRAM

DRAM

构成

6晶体管/cell

1电容+1晶体管/cell

tran/bit

6

1

持久性

是,只要通电

否,需定期刷新

敏感度

否,抗干扰

速度

成本

昂贵

较SRAM更便宜

应用

cache

main memory

非易失存储器

非易失存储器(Nonvolatile Memories),即read-only memory, ROM

存储访问

数据流通过总线在CPU和DRAM主存之间来回

  • 总线:一组并行导线,可携带地址,数据,控制信号,多个设备可共享总线

下方图示说明指令mov A,%rax的内存读事务

image.png

硬盘

组成结构

  • 由盘片(platters)组成,每个盘片由两面(surfaces)
  • 每一个面上有数条磁道(trace)
  • 每个磁道被间隙(gap)划分为扇区(sectors)



容量 = 磁盘数*盘片数*2*磁道数*扇区数

性能参数

寻道时间

磁头定位到某trace的用时

  • 通常为9ms

旋转延迟

抵达trace后,目标sector旋转到磁头下的时间

传输时间

数据读写用时

  • 解释:
  • 1/RPM:每分钟转数的倒数,即1转/1磁道的用时
  • 1/(sectors/track):即track/sectors,每个磁道平均扇区数
  • 60secs/1min: 单位转换,化为每秒钟

总存取时间

Taccess = 寻道时间+旋转延迟+传输时间

举例

转速7,200 RPM,平均寻道时间9 ms,扇区/磁道 = 400,则



存储器层次结构

层次

⭐管理者

register<->cache

compiler

L1<->L2

cache中的硬件

cache<->RAM

OS

RAM<->disk

OS

disk<->remote

程序

cache

高速缓存原理:对于k,每个位于k层的存储设备(小,快)作为k+1层(大,慢)的cache

image.png

  • 缓存命中(cache hit):当程序需要k+1层数据d时,它刚好被缓存在第k层中
  • 缓存不命中(cache miss):
  • 冷缓存/强制不命中/冷不命中(cold cache/compulsory miss/cold miss):第k层是空时导致
  • 冲突不命中(conflict miss):由于算法/放置策略导致
  • 容量不满足(capacity miss):当程序work set大于缓存大小



局部性原理

时间局部性

引用过的存储器位置在未来可能会被多次引用(通常在循环中)

  • 重复引用会带来时间局部性

空间局部性

一个数据对象附近的块更可能会被使用

  • k-步长引用会带来空间局部性,当步长越小,空间局部性越高

对于循环:更小的循环体和更多的循环迭代次数会产生更好的时间+空间局部性


分析以下伪代码的局部性

for (i = 0; i < M; i=i+1) { 
    for (j = 0 ; j < N; j=j+1) { 
        sum += data[i][j];
    } 
}
  • sum具有时间局部性,每次循环都会被访问一次;作为标量没有空间局部性
  • 对于循环,data[][]有较好的空间局部性,内层循环步长为1;但时间局部性较差,每个元素在函数中只会访问一次

存储器山

读吞吐量(read throughput):程序从存储系统中读数据的速率,当在s秒内读取n字节,则读吞吐量为n/s(B/s),单位通常为MB/s

存储器山:编写程序,使用循环发出一系列读请求测试读吞吐量,程序中size表示工作集大小,stride为步长,以不同的size和stride测试读吞吐量,得到吞吐量与时间和空间局部性的二维函数--存储器山

  • ⭐size越小,工作集越小,时间局部性越高
  • stride越小,跨度越小,空间局部性越高

下图为奔腾处理器的存储器山

image.png

解读:

  1. 山脊(ridges):

image.png

  • 图中三条山脊,实际上可见凸起为两条,对应size=16k和512k附近,表示工作集完全在L1和L2缓存中的吞吐量
  • 当吞吐量恰好和L1或L2吻合,达到最大吞吐量
  • >512k时,工作集开始存储在主存中,此时吞吐性能大幅下降
  1. 斜坡(slopes):

image.png

  • 随着步长的增加,不同山脊的吞吐量下降,体现了空间局部性变差的影响
  • 注意到即便在主存山脊中,吞吐量最高点也是最低点数倍,说明在时间局部性很差时,空间局部性也很重要



选择题知识点

  1. 关于引用局部性,下列正确的是②

①在compiler帮助下可精确预测未来的引用位置②是典型的程序特性③有数学证明

  1. 在存储器分层中,对应传输最大和最小数据块的层次为:

最大块:主存<->磁盘 最小块:CPU寄存器<->cache

  1. 未来的存储器分层发展趋势为:不会消失
  2. 给定代码
a = b;
c = d;
if (e == 1) return;

无论变量a,b,c,d,e的位置,都体现了引用(时间)局部性(重复引用)

  1. 管理cache<->主存数据传输:OS

    管理register<->cache:compiler

  1. 存储分级:利用了SRAM的快速和disk的大容量

Ch12 Cache深入

原目录:系统级编程(CSAPP)

查看语雀原文

之前在计组和操作系统都学过Cache映射问题,每本书的定义都不太一样,这里统一总结为CSAPP的描述

场景

指令指示CPU从主存地址A处读取内存字w,此时CPU把A发送给cache:

  • 当L1中有w的缓存副本,cache hit
  • 当L1无w的缓存副本,cache miss
  • L1向main memory请求w的副本,放入自己的cache line后提取出w返回给CPU


关注的点:

cache如何确定请求是否命中,然后提取请求的字--分为三步"组(set)选择,行(block)匹配,字(word)抽取",在了解过程之前需要认识cache的组织结构


cache结构和分类

结构

对一个计算机系统来说:

  • 主存和cache都被分成等大的block
  • 当其RAM地址共有m位时,可以产生2m个有效地址
  • cache被组织成一系列缓存组(cache set),共有S=2s个set,每个set中有E个缓存行(cache line)
  • 每一行包含一个块(block),每一块的数据大小B=2b字节,行内还包含一个有效位(valid),指明是否包含有意义信息;一个tag标记,唯一标识当前line中的block
  • 综合一个cache的结构可用四元组(S,E,B,m)来描述,其总大小解为S×E×B(组数*块数*每块大小)


⭐行,组,块辨析

  • 块:固定大小信息包,在cache和主存中直接传递
  • 行:cache中的一个容器,存储块+有效位+tag等
  • 组:一个或多个行的

分类

根据set和line的不同组织方式,cache和main memory之间的映射有三种方式:

  • 直接映射:S组,每组一行,E=1
  • 组关联映射:E路组关联,分为S组.每组E行
  • 全关联映射:1组,S=1,包含所有行

直接映射


概念

cache的每set只有1行,即只能存放一个block,而主存中的一个块只能映射到Cache的某一特定块中去

极易冲突,效率低下:假设共N块cache,对应RAM中的位置X,映射到cache中Y=XmodN,如右图示意:

地址A

  • 总长m:由RAM大小确定
  • set:决定哪一个组--组索引
  • tag:决定哪一行(块)--行匹配
  • offset: 决定块内字位置--字提取

举例

在一台32位机器上,假设每字是4字节,已知RAM大小214bits,cache有16块,求cache的地址

总长14bits,16=24,4位set,由已知每块有8个words,则3位offset,14-4-3=7,7位用于标记块(tag)

cache hit

组索引

下图当地址的set为0001时,映射到cache的set1中

行匹配

索引到指定set后需要确定组i中是否有一行包含请求的字w的副本,此时

  1. 检测有效位是否为1
  2. 核对tag位,确定block

字提取

满足1,2后通过offset确定块内字,如👆offset=100,即偏移4字节,即第二个word

cache miss

行替换

  • 当不满足1,此时发生冷不命中-->重新取一块填补空行
  • 当不满足2,此时发生冲突不命中-->替换当前行



组关联映射


概念

对比直接映射:

直接映射高冲突率是因为每个set只有一行,允许每个set有多行,此时多行可同时存在cache中,可明显降低冲突率


E路组关联,即分为S组.每组E行,如右图位2路组关联映射

地址A

  • 总长m:由RAM大小确定
  • set:决定哪一个组--组索引
  • tag:决定哪一行(块)--行匹配
  • offset: 决定块内字位置--字提取

举例

RAM大小214bits,16cache,每块8个字,求2路组关联映射地址组成

总长14bits,16/2=23,3位set,由已知每块有8个words,则3位offset,14-4-3=7,7位用于标记块(tag)

cache hit

组索引

下图当地址的set为0001时,映射到cache的set1中

行匹配

索引到指定set后需要确定组i中是否有一行包含请求的字w的副本,此时

  1. 检测有效位是否为1
  2. 核对tag位,确定block

字提取

满足1,2后通过offset确定块内字,如👆offset=100,即偏移4字节,即第二个word

cache miss

行替换

  • 当不满足1,此时发生冷不命中-->重新取一块填补空行
  • 当不满足2,此时发生冲突不命中-->通常LRU替换组内行



全关联映射


概念

只有1个set,当前set包含所有的行

  • 全关联允许主存中数据块放到cache中任何位置,但是访问时需要将所有cache遍历一次,需要硬件支持

地址A

  • 总长m:由RAM大小确定
  • tag:决定哪一行(块)--行匹配
  • offset: 决定块内字位置--字提取

举例

RAM大小214bits,16cache,每块8个字,求全关联映射地址组成

总长14bits,3位offset,14-3=11,11位用于标记块(tag)

cache hit

组索引

全关联没有set位,默认set=0

行匹配

索引到指定set后需要确定组i中是否有一行包含请求的字w的副本,此时

  1. 检测有效位是否为1
  2. 核对tag位,确定block

字提取

满足1,2后通过offset确定块内字,如👆offset=100,即偏移4字节,即第二个word

cache miss

行替换

  • 当不满足1,此时发生冷不命中-->重新取一块填补空行
  • 当不满足2,此时发生冲突不命中-->通常LRU替换组内行



写策略

当cache中的字w被更新后需要保证主存中的对应块也被更新,有两种策略:write back和write through

write through

即当cache更新后,立即把block写回到下一级的cache或主存中

write back

当cache更新后,推迟下一级block更新,直到当前block需要被换出时才写回


选择题知识点

  1. 编写cache友好程序使得命中率提升,可以减少wall time
  2. ⭐给定以下代码,当使用32字节cache line的全关联映射时,数组a需要从主存中取多少字节数据
int a[100];
for (i = 0; i < 17; sum += a[i], i++);

最多96次,首先未说明cache是否为空,假设最坏情况,初始为空,每次循环时取一行,即32字节,其中可包含8个int,循环需要读取数组a[0]~a[16],此时未命中情况为a[0],a[8],a[16]总共读取了a[0]~a[23],对应4*24=96字节

  1. LRU是最有效的cache替换策略,是因为考虑了"引用局部性"
  2. ⭐给定代码
int data[1 << 20]; 
void callee(int x) { 
    int i, result;
    for (i = 0; i < (1 << 20); i += x) { 
        result += data[i]; 
    } 
}

通过以上函数可确定的是:cache line大小

需要明确,循环执行时间长短由数组的内存访问次数决定,通过给定不同的步长x进行时间统计,当x大于cache line时,每次循环都miss,此时时间会明显变长,x即为cache line大小

  1. 当需要行替换时,最实际的实现是:随机替换(LRU实现困难)
  2. 计算机中cache级别各不相同,且不一定有data cache和instruction cache
  3. 一个code+data<256k字节的程序在cache空间512k字节的全关联映射下:无法确定fetch情况,信息太少
  4. ⭐在32位机上,当cache有128行32字节cache line时,给定下方代码,假设a[],b[]起始地址位0x800000和0x801000,则执行结束时cache需要从主存中取多少字节
int b[1024]; 
int a[1024];
for (i = 0; i < 17; sum += a[i] + b[i], i++);

1088字节,由已知,内存地址32位,128=27,中7位作为set,每个block8个字,需3位offset,则tag有22位.补全ab起始地址,为0x00800000和0x00801000

开始循环时a[0]和b[0]的set都相同,占据同一行,a[0]发生冷不命中,b[0]发生冲突不命中

a[1]对应0x00800001,b[1]对应0x00801001,还是占据开始的set,a[1],b[1]都发生冲突不命中

...以此类推,17次循环每次都发生两次cache miss,每次都读取2*32字节的数据,总共读取2*32*17=1088字节数据


Ch13 链接

原目录:系统级编程(CSAPP)

查看语雀原文

概述

链接(Linking)是把各种代码和数据片段组合成一个单一文件的过程,此文件可被加载到内存中执行,链接可发送于各种时刻:

  • 编译时(compile time):源码被翻译为机器码时
  • 加载时(load time):程序被loader加载到内存时
  • 运行时(run time):由程序来执行(动态链接)



静态链接

示例

以下两个.c文件说明链接的过程

image.png

在linux下使用gcc -Og -o prog main.c sum,c命令,调用GCC驱动程序在适当时候使用预处理器,编译器等对源码进行编译,过程如下


image.png

最后一步中,链接器(linker,ld)把main.o和sum.o以及一些必要的系统文件组可起来创建可执行目标文件prog

静态链接:以一组可重定位目标文件和命令行参数位输入,生成一个完全链接的,可加载和运行的可执行目标文件作为输出的过程

⭐链接器在其中的主要任务:符号解析(symbol resolution) & 合并同类型模块 & 重定位(relocate)


Ch13 链接思维导图
Ch13 链接思维导图



目标文件

目标文件有三种形式:

  • 可重定位目标文件(Linux下.o,Win下.obj):二进制代码和数据,编译时可于其他可重定位目标文件组合
  • 可执行目标文件(.exe):二进制代码和数据,可直接被加载到内存执行
  • 共享目标文件:特殊可重定位目标文件

可重定位目标文件

右侧位一个ELF可重定位目标文件格式,分为多个部分,介绍重点section:

  • .text:已编译程序的机器代码
  • .data:已初始化的全局和static变量
  • .bss:未初始化的全局和static变量(包括被初始化为0的全局和static变量)
  • .symtab:符号表
  • .debug:调试符号表



符号解析

目标文件定义和引用了符号,其中每个符号对应函数/全局变量/静态变量,符号解析就是把引用和定义关联,具体实现依靠把引用和可重定位目标文件符号表中确定的符号定义关联


符号&符号表

可重定位模块m都有符号表,其中包含m定义和引用的符号信息,符号有以下三种:

  • 由m定义可被其他模块引用的全局符号:非static函数和全局变量
  • 由其他模块定义但被m引用的全局符号:称为外部(external)符号,对应其他模块的非static函数和全局变量
  • 只在m中定义和引用的局部符号:对应m中的static函数和static全局变量


ELF符号表格式
下图为一个Linux下ELF符号表的格式:

image.png

属性

描述

value

符号地址

  • 对可重定位文件:表示距定义目标的节的起始位置偏移
  • 对可执行文件:绝对运行地址

size

目标字节数

type

目标类型,函数或者数据

binding

表示符号为本地或全局

section

指示符号被分配到目标文件的哪一节

伪节

在section中出现,包含3类:

  • ABS:不该被重定位的符号
  • UNDEF:未定义的符号,即本模块中引用但在别处定义
  • COMMON:未分配位置的未初始化数据目标(代码中的未初始化全局变量)
  • *现代GCC有如下规则:
  • COMMON:未初始化全局变量
  • .bss:未初始化静态变量,以及初始化为0的全局或静态变量

示例

以下为一个main.o符号表的最后三条

main条目:Ndx=1说明为.text节,是.text中偏移量为0的24字节全局函数

array条目:Ndx=3说明为.data节,是.data中偏移量为0的8字节全局变量

sum条目:Ndx=UND说明未在本模块定义,是对外部符号sum的引用


符号表生成

给定以下两个.c文件,填表说明swap.o符号表情况

image.png


符号

是否为symtab条目

符号类型

何处定义

buf

是(外部符号)

External

m.o

.data(m.o中)

bufp0

是(全局变量)

Global

swap.o

.data

bufp1

是(未初始全局变量)

Global

swap.o

COMMON

swap

是(非static函数)

Global

swap.o

.text

temp

否(局部变量)




*注意定义和声明不同swap()虽然声明在m.o但定义在swap.o


符号解析

有了符号表后,linker可以把每个引用和其定义关联起来,对于局部静态变量的关联很简单,但全局符号的引用较复杂,linker需要在所有输入模块中找到其定义,如果定义不存在或定义不明确都会报错

编译时,compiler向as输出所有全局符号,其中强符号:函数和已初始化全局变量;弱符号:未初始化全局变量

  1. 不允许有多个同名强符号
  2. 一个强符号和多个弱符号同名,选择强符号
  3. 多个弱符号同名,从中任选一个

使用gcc foo1.c bar1.c,报错,违反规则1:

两个main(),同名强符号

报错,违反规则1:

两个int x=15213,同名强符号

不会报错,应用规则2:

强符号int x =15213和弱符号int x中选择强符号,右侧f()中改变的为foo3.c中定义的全局x

输出x=15212

不会报错,应用规则3:

两个弱符号选择一个,无论是哪一个都被f()改变

输出x=15212



合并&重定位

完成符号解析后,ld得到目标模块中.text和.data的大小,此时可以开始重定位,将合并输入模块,并为每个符号分配运行时地址,分为两步:

  1. 重定位节和符号定义:ld把所有相同类型的节合并,如把所有模块的.data合并成为输出exe的.data节,之后ld把运行时内存地址赋给新的节/输入模块节/输入模块符号==>最终程序每条指令和全局变量都有唯一运行时地址
  2. 重定位节中符号引用:ld修改.text和.data中每个符号的引用,使其指向正确的运行时地址,依赖于可重定位目标文件的rel节(重定位条目)


image.png

重定位条目

as生成.o/.object模块时,不知道数据或代码最终在内存的什么位置,as在遇到任何一个最终位置未知的目标引用时就会生成一个重定位条目,告诉ld在合并生成exe时如何修改,对代码和数据的重定位条目在.rel.text和.rel.data中


ELF重定位条目格式

image.png

属性

描述

offset

需要被修改的引用的节偏移

symbol

标识被修改引用应该指向的符号

type

重定位类型,告知ld如可修改新引用

addend

有符号常数,用于偏移调整

两类最基本重定位类型

  • EFL-R_X86_64_32/PE-DIR32:重定位一个使用32bits绝对地址的引用.

通过绝对寻址,指令中编码的32btis值=有效地址,无需修改

  • EFL-R_X86_64_PC32/PE-REL32:重定位一个使用32bitsPC相对地址的引用.

PC相对地址是距离PC的当前运行时值的偏移量.

通过PC相对寻址指令,指令中编码的32bits值+PC当前运行时值=得到有效地址

算法如下:

image.png


⭐举例:给定以下main.o的反汇编代码(已注明重定位条目)

已知节地址,符号绝对地址

重定位PC相对引用(call sum())

重定位绝对引用(array)

  1. 通过f: R_X86_64_PC32 sum-0x4可知offset=0xf, type=REL32, symbol=sum, addend=0x(-4)
  2. 由👆算法:

引用的运行时地址/当前地址=节地址+节内偏移

refaddr=ADDR(S)+r.offset

=0x4004d0+0xf

=0x4004df

  1. 修改引用地址/求相对地址=

符号绝对地址-当前地址+调整量

*refptr=ADDR(r.symbol)+r.addend-refaddr

=0x4004e8-0x4-0x4004df

=0x5

  1. 最终生成的exe文件中.text节中,call指令更新

call指令存在4004de处,CPU执行时,PC的值为0x4004e3,为了执行指令

  • CPU把PC压入栈
  • PC<-PC+0x5=0x4004e3+0x5=0x4004e8,即sum例程的入口地址
  1. 通过a:R_X86_64_32 array可知offset=0xa, type=DIR32, symbol=array, addend=0x0
  2. 由👆算法,直接修改引用地址

*refptr=ADDR(r.symbol)+r.addend

=0x601018+0=0x601018

  1. exe文件.data节中,有如下重定位结果



可执行文件

右图为典型ELF可执行文件,结构类似可重定位目标文件

  • .text/.rodata/.data都已经被重定位到最终的运行内存地址外
  • .init:定义函数_init()用于初始化代码
  • 因为不再需要链接,没有.rel节
  • ELF可执行文件可以被加载到内存,能够被映射到连续的内存段



加载

loader把可执行文件中代码和数据从磁盘放入内存中,然后跳转到程序的入口执行,此过程为加载

右侧为程序的内存映像(image)

  • loader运行时,创建image
  • loader把可执行文件的片(chunk)复制到image的代码段和数据段,然后跳转到入口点
  • 每个程序都有自己的内存映像,即活动记录



选择题知识点

  1. loader可以完成:从磁盘中加载&映射exe到内存中
  2. linker可以完成:符号解析(resolution),重定位(relocation),合并同类型section
  3. 说明可重定位目标文件使用大端/小端的域为ELF header
  4. link可发生在compile,load,run time
  5. 解析有关的section:只有symtab
  6. 重定位有关的section:只有.rel.text和.rel.data
  7. 当没有特别强调时,所有未初始化全局/静态变量都在.bss中,不考虑COMMON伪节
  8. 可执行目标文件的格式有:PE,COFF,ELF,a.out



Ch14 异常与线程/进程

原目录:系统级编程(CSAPP)

查看语雀原文

异常

控制流

从给处理器加电开始到断电,PC假设一个值序列a0,a1...an-1,其中每个ai就是指令Ii的地址,每次从ai到ai+1的过渡即为控制转移(control transfer),控制转移序列即为处理器的控制流(control flow),而异常就是控制流中的突变,用来响应处理器状态中的某些变化,其基本思想如下

image.png

四类异常

类别

原因

异步/同步

返回行为

interrupt-中断

来自I/O设备的信号

异步

总是返回到下一条指令

trap-陷阱

有意的异常

同步

总是返回到下一条指令

fault-故障

潜在的可恢复错误

同步

可能返回到当前指令

abort-终止

不可恢复的错误

同步

不会返回


异常处理

异常处理是软硬件一起协作的过程:

  • 操作系统内存中分配并初始化异常表
  • 运行时CPU检测到事件,明确异常号k,使用其索引异常表,找到适当的异常处理程序地址
  • 开始处理异常

处理器

内存



线程与进程

image.png

进程(process)具有两大特点:

  • 资源所有权: 进程包含存放进程映像的虚拟地址空间
  • 调度/执行: 进程具有执行状态和优先级,是可被系统调度与分派的实体

通常把调度/执行的基本单位称为线程,拥有资源所有权的基本单位称为进程

多线程

操作系统在单个进程中支持多个并发执行路径的能力叫做多线程(multithreading)

线程与进程的区别

进程

线程

关联属性

  • 存放映像的虚拟地址空间
  • 对处理器,其他进程,文件和IO资源的受保护访问
  • 线程执行状态
  • 未运行线程的上下文
  • 执行栈
  • 存放局部变量的静态存储空间
  • 与其他进程内线程共享的内存和资源访问

进程管理

  • 进程只有一个相关的控制块和地址空间
  • 进程运行时,CPU寄存器被进程控制
  • 未运行时,保存寄存器中内容

  • 每个线程有单独的栈和控制块,其中包含寄存器值,优先级等线程相关属性
  • 进程中所有线程共享进程的状态和资源



选择题知识点

  1. 异常是①需要硬件&软件协作处理③依赖硬件:event的发现需要timer一类的硬件
  2. 对于异常处理程序(exception handle),它可能不会返回,也可能返回到异常发生处
  3. X86中system call的实现需要:trap;

demand paging的实现需要:fault;

hard disk interrupt的实现需要interrupt

  1. ⭐进程是资源所有权的单位;线程是调度/执行的单位
  2. ⭐以下关于进程的说法正确的是:all

①通过进程,可使程序独占CPU和memory

进程是一个运行的程序

③进程可由异常实现

  1. MessageLoop的相关函数有GetMessage(),TranslateMessage(),DispatchMessage()
  2. WIndows程序的起点都是WinMain()
  3. WIndows系统内核态线程同步的实现依靠:信号量(semaphore)
  4. 优先级倒置(Priority inversion)指高优先级线程间接等待低优先级线程
  5. 在时间共享操作系统中,轮询(polling)是不可取的,因为会导致时间浪费
  6. 下列适合线程的情况有:all

背景进程:数据比较,拼写检查;

页面呈现:在整个web page到达前;

预先加载:在用户还未浏览到下方界面时

  1. 可重入函数(reentrant function)可并行的被不止一个线程调用
  2. 锁(lock)指限制临界区(critical section)的访问
  3. win下线程①由内核级线程实现②有多种同步机制,而linux下是用户级线程
  4. 一个进程的多个线程共享一个虚拟地址空间