前言
David在15-213的课堂上分享了他在北京一家图书馆浏览这本书时被安利英文原版的趣事,我看的是中文版,将来会再看看英文版。David还提到,他和Randal希望对学生的人生产生真正积极的影响。这句话给我一丝触动,我希望自己可以带给身边人真正的积极影响,并且相信身边的人会带给我反馈,让我变成更好的人。事实上,我身边的人也是这么想的。
这本书里说到:“如果你全力投身学习本书中的概念,完全理解底层计算机系统以及它对应用程序的影响,那么你会步上成为为数不多的‘大牛’的道路。” 而最好的学习方式一定是做这本书的实验,去真正复现它们,这也是我正在做的事情。
注意看,那句书里的话说的很有“水平”哈哈,它说的是全力投身学习本书中的“概念”。有一个朋友说,这本书叫系统导论更适合,因为它讲的东西太多了,很多地方说的很浅。还有一位社区的朋友提到,这本书很多地方讲的非常空洞,只适合泛读。我现在看完这本书,很赞同他们说的,但觉得可以多补充一点,作为“导论”,它给读者提供了足够完备的思考方向,带着每一个新人真正的入门计算机。
如果是新人想要快速了解计算机的一些知识,推荐大家看一看这个计算机科学速成课:
写下来这篇总结,结合自己此前学过的知识和这学期做project过程里学到的知识点,分享一些内容。
第一章 计算机系统漫游
这一章主要是带着大家熟悉计算机领域常见的概念,我起初是希望轻松惬意的学习这些概念,一步步慢慢来的,但是这学期参与的项目让我还没沉淀好,就急着先用了起来,些许狼狈。当然,这会再回过头来理解也容易了很多。如果是第一次接触这些概念的朋友,不要被这么多新名词吓到,这其中并没有涉及很复杂的逻辑,我们是站在前人的肩膀上“看世界”,只需要花时间去熟悉它们就好。
信息就是位+上下文
首先从我们最常见的源代码文件出发,所有的源代码文件,无论什么语言(.java、.c、.cpp、.py......)实际上都是文本文件,而文本文件是只由ASCII字符组成的(ASCII是字符编码标准,类似的还有UTF-8、GBK、Big5等等)。这些文件可以被任何文本编辑器打开和编辑,我们常用的IDE(Intellj、Eclipse、Pycharm、Clion......)便是内置了编辑器模块用于编辑代码。除了文本文件,其它所有文件类型都被叫做二进制文件。我们日常生活里说的视频文件、图像文件、音频文件等等都是二进制文件,是根据不同用途给了不同的名字。
有一个基本思想:
系统中所有的信息——包括磁盘文件、内存中的程序、内存中存放的用户数据以及网络上传送的数据,都是由一串比特(0、1)表示的。区分不同数据对象的唯一方法是我们读到这些数据对象时的上下文。比如,在不同的上下文中,一个同样的字节序列可能表示一个整数、浮点数、字符串或者机器指令。
这里举几个例子:(不了解相关名词的朋友,可以选择性跳过)
1. 文本与数值
假设有一个字节数据 01100001。根据上下文,这个字节可以有不同的含义:
如果上下文是文本文件,这个字节可能表示字符 a,因为在ASCII编码中,01100001 对应的是字符 a。
如果上下文是一个整数值,这个字节可能表示数字 97,因为二进制数 01100001 转换为十进制就是 97。
2. 文件类型
如果这个文件的上下文是一个图像文件,那么这些比特将被解释为图像的像素数据。如果是音频文件的上下文,那么同样的一串比特将被解释为声音数据。
图像文件上下文:比特被解码成像素颜色值。
音频文件上下文:比特被解码成声音波形数据。
3. 程序与数据
在计算机内存中,同样的比特序列可以根据上下文被解释为代码或数据。
如果这些比特是被CPU执行的指令,那么它们的上下文就是程序代码。
如果这些比特是被程序使用的变量值,那么它们的上下文就是数据。
4. 网络协议
在网络通信中,不同协议的上下文决定了比特数据的解释方式。
如果上下文是HTTP协议,那么比特流可能表示网页数据。
如果上下文是FTP协议,那么比特流可能表示文件传输数据。
程序被其它程序翻译成不同的格式
从源程序(文本文件,hello.c)到目标程序要经历四个步骤:
1.源程序被预处理器处理得到修改了的源程序(文本文件,hello.i)(预处理器完成)
2.再由编译器处理得到汇编程序(文本文件,hello.s)(编译器完成)
3.汇编程序由汇编器处理得到可重定位目标程序(二进制文件,hello.o)(汇编器完成)
4.最后由链接器链接得到可执行目标程序(二进制文件,hello)(链接器完成)
执行这四个阶段的程序(预处理器、编译器、汇编器和链接器)一起构成了编译系统。
了解编译系统如何工作是大有益处的
- 优化程序性能
- 理解链接时出现的错误
- 避免安全漏洞
处理器读并解释存储在内存中的指令
shell是一个命令行解释器,它输出一个提示符,等待输入一个命令行,然后执行这个命令。
系统的硬件组成
主要包括总线、I/O设备、主存、处理器四个部分。
这部分会在计算机组成原理里学到:
总线是贯穿整个系统的一组电子管道,它携带信息字节并负责在各个部件间传递。
I/O(输入/输出)设备是系统与外部世界的联系通道。
主存是一个临时存储设备,在处理器执行程序时,用来存放程序和程序处理的数据。
最左边的是处理器,也就是常说的CPU。处理器就是在通电以后,一直不断地执行程序计数器指向的指令,再更新程序计数器,使其指向下一条指令。
运行hello程序
整个流程:读取文件字符到寄存器 -> 存储到主存 -> 执行指令 -> 加载 helloworld 到寄存器 -> 复制到显示器 -> 显示
高速缓存至关重要
从主存中读取一个字比从磁盘驱动器读大约1000万倍。
从寄存器文件读数据比从主存读大约快100倍,并且差距还在加大。
针对这种处理器与主存之间的差异,系统设计者采用了更小更快的存储设备,称为高速缓存存储器(cache memory,简称为cache或高速缓存),作为暂时的集结区域,存放处理器近期可能会需要的信息。
L1高速缓存位于处理器芯片上,访问速度几乎和访问寄存器文件一样快。
L2比L1慢5倍,但比访问内存快5-10倍。
通过让高速缓存里存放可能经常访问的数据,让大部分的内存操作都在高速缓存中完成。
存储设备形成层次结构
操作系统管理硬件
操作系统内核是应用程序和硬件之间的媒介,它有两个基本功能:
(1)防止硬件被失控的应用程序滥用
(2)向应用程序提供简单一致的机制来控制复杂而又通常大不相同的低级硬件设备。
操作系统通过几个基本的抽象概念(进程、虚拟内存和文件)来实现这两个功能。
文件是对I/O设备的抽象表示,虚拟内存是对内存和磁盘I/O设备的抽象表示,进程则是对处理器(CPU)、主存和I/O设备的抽象表示。
这里我多加一个线程的概念:线程是对处理器执行环境的抽象。在操作系统中,线程被设计为最小的执行单元,它抽象了处理器(CPU)上的程序执行流程。
系统之间利用网络进行通信
从一个单独的系统而言,网络可以视为一个 I/O 设备。 以在一个远端服务器运行程序为例,在本地输入,在远端执行,执行结果发送回本地输出。
重要主题
Amdahl 定律
Amdahl 定律的主要观点:要加速整个系统,必须提升全系统中相当大的部分。
其他
然后在这里分享一些重要的抽象概念吧:
-
并发性:一个处理器处理多个任务(就像杂耍球一样,一次处理一个任务,但可以在它们之间快速切换)。
-
并行性:多个处理器同时执行多个任务,注意并行其实是并发的一种(比如四只手去处理四个杂耍球)。
-
指令级并行:在较低的抽象层次上,现代处理器可以同时执行多条指令的属性称为指令级并行。
-
在最低层次上,许多现代处理器拥有特殊的硬件,允许一条指令产生多个可以并行执行的操作,这种方式称为单指令、多数据,即 SIMD 并行。
计算机系统中抽象的重要性
计算机系统提供的一些抽象。计算机系统中的一个重大主题就是提供不同层次的抽象表示,来隐藏实际实现的复杂性
可以看到,虚拟机是对整个计算机的抽象,包括操作系统、处理器和程序。
小结
第一章到这里就结束了,这一章的重点是在普及很多抽象的概念,有些读者可能会被这么多新名词吓到,但是不用太担心,后面随着这些名词的高频出现,自然而然就会熟悉起来,先有个大概印象就好。就像我最开始说的,这些更重要的是带给我们思考方向。
第二章 信息的表示和处理
信息存储
计算机一般使用字节作为最小的可寻址的内存单位,1字节等于8比特(1 byte = 8 bit)。
机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtual address space)。顾名思义,这个虚拟地址空间只是一个展现给机器级程序的概念性映像。实际的实现(见第 9 章)是将动态随机访问存储器(DRAM)、闪存、磁盘存储器、特殊硬件和操作系统软件结合起来,为程序提供一个看上去统一的字节数组。
C语言里指针的值,都是指向了某个存储块的第一个字节的虚拟地址。
我们可以看下面这个例子:
#include <stdio.h>
int main() {
int arr[5] = {10, 20, 30, 40, 50}; // 定义一个整数数组
int *p = arr; // 定义一个指针p,并初始化为数组arr的首地址
// 输出数组的地址和元素
printf("数组的地址: %p\n", (void *)arr);
printf("指针p的值: %p\n\n", (void *)p);
// 使用指针遍历数组
for (int i = 0; i < 5; i++) {
printf("arr[%d]的地址: %p, 通过指针访问的值: %d\n", i, (void *)&arr[i], *(p + i));
}
return 0;
}
我们首先定义了一个整数数组arr,然后创建了一个指针p并将其初始化为数组的地址。在C语言里,数组名arr在大多数情况下会被当作数组第一个元素的地址,因此p = arr;这行代码实际上是让指针p指向数组的第一个元素。
然后通过循环,使用指针p加上索引i(即p + i)来访问数组的每一个元素。这里p + i实际上是根据整数的大小(在这个例子中是int类型,通常占4个字节)计算出新的地址,也就是数组中第i个元素的地址。
这是输出结果:
十六进制表示法
这个比较简单,感兴趣的朋友可以做点十进制和十六进制数之间的转化练习
十六进制以 0x 开头。
A:10;C:12;F:15
字数据大小
每个计算机有对应的字长,虚拟地址用一个字来编码,所以字长决定了虚拟地址空间的大小(更准确地说,字长定义了寄存器的大小,而寄存器的大小决定了指针可以表示的地址范围)
PS: 寄存器是计算机中央处理器(CPU)内的一种非常高速的小容量存储设备。
32位字长机器的指针类型长度是4字节,64 位字长机器的指针类型长度为 8 字节(1字节等于8比特,也就是8位,4X8=32, 8X8=64)。
对 32 位和 64 位机器而言,char、short、int、long long 长度都是一样的,为 1,2,4,8。long 的长度不一样,32位上是4,64位上是8。
很多具体的规定我也不怎么记得,因为目前接触这些也比较少,所以想着先知道这些,等到需要的时候再去查,随着时间的推移会慢慢熟悉起来。
寻址和字节顺序
两种字节存储法:
- 小端法:数字的低位在前(前就是最小地址)
- 大端法:数字的高位在前
举个例子:
假设我们有一个16位的数字 0x1234。在内存中,这个数字需要两个字节存储(再重复一次,1字节等于8位,所以16位需要2个字节)。
小端法(Little-endian)
在小端法中,较低的字节(低位)存储在较小的地址上,较高的字节(高位)存储在较高的地址上。对于数字 0x1234:
0x34是低位字节,存储在较小的地址。0x12是高位字节,存储在较大的地址。
内存布局(假设从地址 0x00 开始):
地址 0x00: 34
地址 0x01: 12
大端法(Big-endian)
在大端法中,较高的字节(高位)存储在较小的地址上,较低的字节(低位)存储在较高的地址上。对于数字 0x1234:
0x12是高位字节,存储在较小的地址。0x34是低位字节,存储在较大的地址。
内存布局(假设从地址 0x00 开始):
地址 0x00: 12
地址 0x01: 34
表示字符串
C 语言字符串是以 null 字符结尾的字符数组,即 '\0'
ASCII 字符适合编码英文文档。
Unicode(UTF-8)使用 4 字节表示字符,一些常用的字符只需要 1 或 2 个字节。所有 ASCII 字符在 UTF-8 中是一样的。
JAVA 使用 UTF-8 来编码字符串。
表示代码
这里我们只需要知道,从机器的角度看,程序就是一个字节序列。不同机器类型、不同操作系统的编码方式和规则都是不同的,所以二进制代码是不兼容的。
二进制代码很少能在不同机器和操作系统组合之间移植
布尔代数简介
常见的就是与(AND)或(OR)非(NOT)
C语言中的位级运算
介绍下C语言中的基本位运算符:
- 与(AND)运算符
&: 只有当两个操作数的对应位都为1时,结果的那位才为1,否则为0。 - 或(OR)运算符
|: 只要两个操作数的对应位中有一个为1,结果的那位就为1。 - 异或(XOR)运算符
^: 当两个操作数的对应位不相同,结果的那位就为1;如果相同,那位就为0。 - 取反(NOT)运算符
~: 对操作数的每一位进行取反操作,即1变0,0变1。 - 左移运算符
<<: 将左边的操作数的位向左移动右边操作数指定的位数(右边空出的位用0填充)。 - 右移运算符
>>: 将左边的操作数的位向右移动右边操作数指定的位数。对于有符号整数,左边空出的位通常由符号位填充(这依赖于编译器和机器);对于无符号整数,用0填充。
可以复制下面这段代码,看它们具体的输出:
#include <cstdio>
int main() {
unsigned int a = 12; // 二进制表示为 1100
unsigned int b = 10; // 二进制表示为 1010
unsigned int result;
// AND运算
result = a & b; // 1100 & 1010 = 1000 (二进制)
printf("AND: %u\n", result);
// OR运算
result = a | b; // 1100 | 1010 = 1110 (二进制)
printf("OR: %u\n", result);
// XOR运算
result = a ^ b; // 1100 ^ 1010 = 0110 (二进制)
printf("XOR: %u\n", result);
// NOT运算
result = ~a; // ~1100 = 0011 (二进制,取决于操作数的位数)
printf("NOT: %u\n", result);
// 左移运算
result = a << 2; // 1100 << 2 = 110000 (二进制)
printf("左移: %u\n", result);
// 右移运算
result = a >> 2; // 1100 >> 2 = 0011 (二进制)
printf("右移: %u\n", result);
return 0;
}
逻辑运算
逻辑运算符 && 和 || 如果第一个参数就能确定结果,就不再计算第二个参数
移位运算
逻辑右移在左端补k个0,算数右移是在左端补k个最高有效位的值。C语言标准没有明确定义对于有符号数应该使用哪种类型的右移——算数右移或者逻辑右移都可以。
然而,实际上,几乎所有的编译器/机器组合都对有符号数使用算数右移,且许多程序员也都假设机器会使用这种右移。另一方面,对于无符号数,右移必须是逻辑的。
与C相比,Java对于如何进行右移有明确的定义。表达是x>>k会将x算术右移k个位置,而x>>>k会对x做逻辑右移。
加减法的优先级比移位运算要高。分享一个经常用到的移位运算:左移一位相当于乘以2,右移一位相当于除以2。
对于大多数C语言的实现,处理同样字长的有符号数和无符号数之间相互转换的一般规则是:数值可能会改变,但是位模式不变。强制类型转换的结果保持位值不变,只是改变了解释这些位的方式。
当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么C语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。
这种方法对于标准的算数运算来说并无多大差异,但是对于像<和>这样的关系运算符来说,它会导致非直观的结果。
举个例子:
#include <cstdio>
int main() {
int a = -1;
unsigned int b = 1;
if (a > b) {
printf("a is greater than b\n");
} else {
printf("b is greater than a\n");
}
return 0;
}
在这个例子中,a 是一个有符号整数,值为 -1,而 b 是一个无符号整数,值为 1。按理说,-1 应该小于 1,但是当这两个变量进行比较时,会发生意想不到的事情。
分析
- 当比较
a和b时,由于b是无符号整数,因此a会被隐式转换为无符号整数。 - 在C语言中,将负的有符号整数转换为无符号整数是通过加上最大的无符号整数加1来完成的(在32位系统中通常是 232232)。因此,
-1被转换为 232−1232−1,这是一个非常大的数。 - 结果是,转换后的
a(无符号)实际上远大于b
这是输出结果:
我们需要知道,有符号数和无符号数之间的转换,会遇到漏洞问题,C和C++都支持有符号(默认)和无符号数,Java只支持有符号数。
补充
在采用补码运算的32位机器上表达式-2147483647-1U < 2147483647 求值,得到结果是0(false)。这里-2147483647-1U 的部分:-2147483647 是一个有符号整数,1U 是一个无符号整数。在执行减法之前,-2147483647 被转换为无符号整数。因为无符号整数不能表示负数,所以这个值转换为一个非常大的正数(根据32位整数的转换规则,这个值会是 2147483649)。然后减去1,等于2147483648。
看下面这个函数getpeername的安全漏洞:
void *memcpy(void *dest,void *src, size_t n);
#define KSIZE 1024
char kbuf[KSIZE];
int copy_from_kernel(void * user_dest, int maxlen){
int len = KSIZE < maxlen? KSIZE:maxlen;
memcpy(user_dest,kbuf,len);
return len;
}
如果传入的maxlen是一个负数,那么int len = KSIZE < maxlen? KSIZE:maxlen;这里len获得的就是maxlen这个负数,这个负数被传到memcpy的函数里,但是变量n的类型是size_t,因此这个负数变成了一个很大的正数,然后程序会试图从内核区域复制这么多字节的数据到用户的缓冲区。
虽然复制这么多字节(至少2^31个)实际上不会完成,因为程序会遇到进程种非法地址的错误,但是程序还是能督导它没有被授权的内核内存区域。
然后这本书还讲解了一些基本运算的数学原理,我不是很感兴趣,佛系写了几个练习题就跳过去了,感兴趣的朋友可以之后再去看看书。
第三章 程序的机器级表示
这一章里讲了指令集体系结构或者说指令集架构的抽象概念,它是用来定义机器级程序的格式和行为,定义了处理器(CPU)状态、指令的格式,以及每条指令对状态的影响。
还有个抽象是,机器级程序使用的内存地址是虚拟地址,,提供的内存模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件结合起来,这后面第九章会讲到,这里我们对这个抽象概念有个印象就好。
然后我觉得比较有意义的地方是书里的代码例子,比如
gcc -Og -S main.c
这会生成一个汇编文件:main.s
gcc -Og -c main.c
这会产生目标代码文件main.o(一个二进制格式文件,所以无法直接查看)
另外有一类称为反汇编器的程序非常有用,可以查看机器代码文件的内容,比如Linux系统里,带-d命令行标志的程序OBJDUMP可以充当这个角色:
objdump -d main.o
这可以查看机器代码文件的内容:
然后这本书讲解了一些x86版本的汇编语言(我的学校学计组的时候用的是ARM版本的汇编,另外还有RISC-V、AVR、PowerPC等等版本),比如条件、循环、switch等等对应的汇编语言(汇编语言大多数人接触的少,它本身并不复杂,第一次看到的朋友可以不用对它感到畏惧)。
注意一个数据对齐的概念,感兴趣的朋友可以看看书,它可以提高内存系统的性能。
然后是内存越界引用和缓冲区溢出的概念,在使用如C和C++这类没有内建边界检查的语言时,缓冲区溢出很常见,比如下面这个例子:
#include <stdio.h>
#include <string.h>
int main() {
char buffer[10];
strcpy(buffer, "too_long_string_for_buffer");
return 0;
}
在这个例子里,buffer 被分配了10个字符的空间,但是字符串 "too_long_string_for_buffer" 明显超过了这个限制。这将导致超出buffer范围的字符覆盖相邻内存区域,可能破坏其他变量的值,影响程序的执行流程,或引起程序崩溃。
第四章 处理器体系结构
这一章是在讲CPU的设计原理,大家是可以在计算机组成原理这门课里学到的,从基本的逻辑门一步步搭建出来一个具备基本功能的CPU。当然,我对硬件的设计原理不感兴趣哈哈(更多关注硬件能够提供什么功能)。
第五章 优化程序性能
首先明确编写高效程序需要的条件:
- 选择适当的算法和数据结构
- 理解优化编译器的能力和局限性,编写容易优化的代码
- 任务并行化,多核并行计算
优化编译器的能力和局限性
这个我们直接看几个代码例子:
编译器可以做的优化:
#include <stdio.h>
int main() {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += 5;
}
printf("Sum is: %d\n", sum);
return 0;
}
在这段代码中,循环固定运行100次,每次将5加到sum变量上。编译器在看到这种模式时,特别是当优化开启(如使用-O2或-O3标志编译时),它会进行称为循环展开和常量折叠的优化。
PS:
-02和-03是两种常用的优化级别,没有优化的编译方式是这样的:
gcc -o example example.c
使用-O2优化:
gcc -O2 -o example example.c
使用-O3优化:
gcc -O3 -o example example.c
编译器优化后的可能行为:
-
常量折叠:
- 编译器会计算出循环中的常量表达式,这里
5 * 100 = 500,直接将sum初始化为500,完全消除循环。
- 编译器会计算出循环中的常量表达式,这里
-
循环展开:
- 如果循环不被完全消除,编译器可能会将循环展开几次以减少循环的迭代次数,从而减少循环控制的开销。
循环展开的概念后面还会用到,因为提到了编译器优化,所以我提前分享一下,它的本质就是通过减少循环控制的开销(如计数器增加、终止条件检查等):
假设有一个简单的循环,每次迭代只做一点工作,如下面的代码所示:
for (int i = 0; i < 4; i++) {
doWork(i);
}
在这个循环中,doWork(i) 被调用四次。如果我们展开这个循环,就可以消除一些循环迭代,将其重写为:
doWork(0);
doWork(1);
doWork(2);
doWork(3);
在实际应用中,完全展开可能并不总是可行或高效,特别是当循环次数非常大时。因此,编译器和程序员通常使用部分循环展开:
for (int i = 0; i < 4; i += 2) {
doWork(i);
doWork(i + 1);
}
循环展开的优点
- 减少循环开销:每次迭代减少了条件检查和迭代变量更新。
- 增加指令级并行性:现代处理器可以利用指令级并行(ILP)来同时执行多个独立的指令。
- 改善缓存利用:通过处理更多的数据可以减少对缓存的访问次数。
循环展开的缺点
- 代码膨胀:展开循环可能会增加执行文件的大小,因为代码量增加了。
- 可能降低缓存效率:如果展开的代码体积过大,可能不适合缓存,从而反而降低性能。
- 条件复杂时难以处理:当循环边界和步长复杂时,循环展开会变得更加困难。
编译器优化的局限性
假设我们有更复杂的数据依赖,如下面的代码:
#include <stdio.h>
int calculate(int x) {
return x * x + 2 * x + 1;
}
int main() {
int result = 0;
for (int i = 0; i < 100; i++) {
result += calculate(i);
}
printf("Result is: %d\n", result);
return 0;
}
在这个例子中,由于calculate函数对于每次循环迭代的输入i都可能不同,编译器不能简单地通过常量折叠来优化循环。即使可以通过内联calculate函数来减少函数调用的开销,但循环本身的计算逻辑和数据依赖性限制了进一步的优化。
程序例子
这里介绍一个有趣的语言小魔法
#include <stdio.h>
// 定义宏,可以在编译时通过 -D标志来重新定义这些值
#ifndef IDENT
#define IDENT 0 // 默认累加操作的初始值
#endif
#ifndef OP
#define OP(a, b) ((a) + (b)) // 默认操作为加法
#endif
#define SIZE 5
int combine(int vec[SIZE]) {
int result = IDENT;
for (int i = 0; i < SIZE; i++) {
result = OP(result, vec[i]);
}
return result;
}
int main() {
int myVector[SIZE] = {1, 2, 3, 4, 5};
int result = combine(myVector);
printf("The combined result is: %d\n", result);
return 0;
}
编译和运行
- 累加操作: 编译:
gcc -o example example.c运行:./example输出:The combined result is: 15(默认操作,累加所有元素) - 累乘操作: 编译:
gcc -o example example.c -DIDENT=1 -DOP(a,b)=(a*b)运行:./example输出:The combined result is: 120(1乘以所有元素)
消除循环的低效率
我们上面的提到过的循环展开就是一种方式,另外还有一些方式,比如下面这个:
优化前
#include <stdio.h>
#define SIZE 100000
// 一个模拟的计算函数,假设这个计算相对复杂且耗时
double complexCalculation(double x) {
return x * x * x - 0.5 * x + 1;
}
int main() {
double array[SIZE];
double value = 3.14; // 假设这是需要重复使用的计算结果
double result;
// 初始化数组
for (int i = 0; i < SIZE; i++) {
array[i] = i * 0.01;
}
// 计算并存储每个元素与value的复杂计算结果的和
for (int i = 0; i < SIZE; i++) {
result = complexCalculation(value); // 低效:在循环中重复计算
array[i] += result;
}
printf("Operation completed.\n");
return 0;
}
优化后:
#include <stdio.h>
#define SIZE 100000
double complexCalculation(double x) {
return x * x * x - 0.5 * x + 1;
}
int main() {
double array[SIZE];
double value = 3.14; // 这个计算结果需要被多次使用
double result;
// 初始化数组
for (int i = 0; i < SIZE; i++) {
array[i] = i * 0.01;
}
// 在循环外部计算value的结果
result = complexCalculation(value);
// 将计算结果添加到每个元素
for (int i = 0; i < SIZE; i++) {
array[i] += result;
}
printf("Operation completed.\n");
return 0;
}
这个例子很容易理解:在优化后的版本,complexCalculation(value)只计算了一次。
还有很多和编译器有关的性能优化方式,比如减少过程调用、消除不必要的内存引用等等。
第六章 存储器层次结构
计算机技术的成功很大程度上源自于存储技术的巨大进步。早期的计算机只有几千字节的随机访问存储器。最早的IBM PC甚至于没有硬盘。
执行指令时访问数据所需的周期数:
- CPU寄存器:0个周期
- L1 ~ L3高速缓存:4 ~ 75个周期
- 主存:上百个周期
- 磁盘:几千万个周期
本章里简单介绍了几种基本的存储技术——SRAM存储器、DRAM存储器、ROM存储器以及旋转的固态的硬盘,以及磁盘的构造、容量、操作等等。这部分在《操作性系统导论》里也看到过,不过我目前来说,更关注硬件能够提供的功能,不会去关注硬件的实现原理)。
访问主存来说:
读事务的三个步骤:
- CPU 将地址 A 放到内存总线上。
- 主存从总线读出 A,取出字 x,然后将 x 放到总线上。
- CPU 从总线读出字 x,并将它复制到相应寄存器中。
写事务的三个步骤:
- CPU 将地址 A 放到内存总线。主存读出这个地址,并等待数据字。
- CPU 将数据字 y 放到总线上。
- 主存从总线读数据字 y,并将它存储在地址 A。
访问磁盘来说:
假设磁盘控制器映射到端口 0xa0,读一个磁盘扇区的步骤如下:
- CPU 依次发送命令字、逻辑块号、目的内存地址到 0xa0,发起一个磁盘读。因为磁盘读的时间很长,所以此后 CPU 会转去执行其他工作。
- 磁盘收到读命令后,将逻辑块号翻译成一个扇区地址,读取该扇区的内容,并将内容直接传送到主存,不需要经过 CPU (这称为直接内存访问(DMA))。
- DMA 传送完成后,即磁盘扇区的内容安全地存储在主存中后,磁盘控制器给 CPU 发送一个中断信号来通知 CPU。
PS:这里分享自己在项目里常用到的知识点:我们日常的各种文件都是保存在电脑磁盘里的,比如xml、csv、parquet等等,当程序在读取这些文件数据的时候,都相当于是从磁盘里读数据,因此速度非常慢。设计好的缓存,可以有效的避免从磁盘读取数据的开销。因为有操作系统提供的虚拟地址,内存是可以完全映射磁盘的。因此有时候设计树的数据结构作为缓存,虽然增加了旋转、修正等开销,但相比较从磁盘读取数据,在很多场景下依旧是值得的。
实际上我们应该把注意力集中到高速缓存存储器上,它作为CPU和主存之间的缓存区域,对应用程序性能的影响最大。另外:
一个编写良好的计算机程序常常具有良好的局部性。也就是,它们倾向于引用邻近于其他最近引用过的数据项的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理,是一个持久的概念,对硬件和软件系统的设计和性能都有着极大的影响。
程序员应该理解局部性原理,因为一般而言,有良好局部性的程序比局部性差的程序运行得更快。
在我看来,局部性是一种计算机设计哲学的理念,我们可以看下面这个例子:
这是一个空间局部性很好的代码:
int sumarrayrows(int a[M][N]){
int i , j sum = 0;
for(int i = 0; i < M; i++){
for(int j = 0; j < N; j++){
sum += a[i][j];
}
}
return sum;
}
这是一个空间局部性很差的代码(因为它使用步长为N的引用模式来扫描):
int sumarraycols(int a[M][N]){
int i , j sum = 0;
for(int j = 0; j < N; i++){
for(int i = 0; i < M; j++){
sum += a[i][j];
}
}
return sum;
}
然后就来到了缓存:
一般而言,高速缓存(cache,读作“cash”)是一个小而快速的存储设备,它作为存储在更大、也更慢的设备中的数据对象的缓冲区域。使用高速缓存的过程称为缓存。
存储器层次结构的中心思想是,对于每个k,位于k层的更快更小的存储设备作为位于k+1层的更大更慢的存储设备的缓存。换句话说,层次结构中的每一层都缓存来自较低一层的数据对象。例如,本地磁盘作为通过网络从远程磁盘取出的文件(例如Web页面)的缓存,主存作为本地磁盘上数据的缓存,以此类推,直到最小的缓存——CPU寄存器组。
一些概念(知道就好了,没有必要精准描述出来,最快最好的学习方式是在项目里实际使用它们):
1、缓存命中
当需要 k+1 层的某个数据对象 d 时,如果 d 恰好缓存在 k 层中,就称为缓存命中。
2、缓存不命中
缓存不命中时,第 k 层的缓存从 第 k+1 层缓存中取出包含 d 的块。
如果第 k 层缓存已经满了,需要根据替换策略选择一个块进行覆盖 (替换),未满的话需要根据放置策略来选择一个块放置。
3、缓存不命中的种类
- 冷不命中:一个空的缓存称为冷缓存,冷缓存必然不命中,称为冷不命中。
- 冲突不命中:常用的放置策略是将 k+1 层的某个块限制放置在 k 层块的一个小的子集中。比如 k+1 层的块 1,5,9,13 映射到 k 层的块 0。这会带来冲突不命中。
- 容量不命中:当访问的工作集的大小超过缓存的大小时,会发生容量不命中。即缓存太小了,不能缓存整个工作集。
4、缓存管理
寄存器文件的缓存由编译器管理,L1,L2,L3 的缓存由内置在缓存中的硬件逻辑管理,DRAM 主存作为缓存由操作系统和 CPU 上的地址翻译硬件共同管理。
常见的缓存策略:最优替换策略、先入先出策略、随机、利用历史数据(LRU) 。同样缓存策略也可以在力扣里找到练习题,第146题的LRU和第460题的LFU,LRU要简单些。
第七章 链接
链接在以下三个阶段都可以执行:
- 编译时,即在源代码被翻译成机器代码时
- 加载时,即程序被加载器加载到内存并执行时
- 运行时,即由应用程序来执行
早期计算机系统中,链接是手动执行的。在现代系统中,链接是由链接器(一种程序)自动执行的。
编译器驱动程序可以使用户根据需要调用语言预处理器、编译器、汇编器和链接器。
通过静态链接,链接器将多个可重定位目标文件组合形成一个可执行目标文件。
我们来看一个代码例子:
int sum(int *a,int n);
int array[2] = {1,2};
int main(){
int val = sum(array,2);
return 0;
}
这里我们在main.cpp里声明了一个sum函数,但没有定义它,真正的定义是在sum.cpp文件里:
int sum(int *a, int n){
int i ,s = 0;
for(int i = 0; i < n; i++){
s += a[i];
}
return s;
}
会发现,它在预处理阶段、编译阶段和汇编阶段都不会出问题,但是链接阶段会报错:
(PS:这里的main.i、main.s、main.o、main_executable都是可以自己任意命名的)
然后可以根据预处理器、编译器、汇编器和链接器的职责,想一下为什么只有在链接阶段才会发现对于sum函数缺失定义的错误呢?
让我们进一步解释静态链接器:静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的、可以加载和运行的可执行目标文件作为输出。
在构造可执行文件的过程中,链接器主要完成两个任务:
- 符号解析(symbol resolution)。 目标文件定义和引用符号,每个符号对应于一个函数、一个全局变量或一个静态变量(即 C 语言中任何以 static 属性声明的变量)。符号解析的目的是将每个符号引用正好和一个符号定义关联起来。
- 重定位(relocation)。 编译器和汇编器生成从地址 0 开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,使得它们指向这个内存位置。链接器使用汇编器产生的重定位条目(relocation entry)的详细指令,不加甄别地执行这样的重定位。
接下来的内容都是为了详细解释这两个任务。
在你阅读的时候,要记住关于链接器的一些基本事实:目标文件纯粹是字节块的集合。这些块中,有些包含程序代码,有些包含程序数据,而其他的则包含引导链接器和加载器的数据结构。链接器将这些块连接起来,确定被连接块的运行时位置,并且修改代码和数据块中的各种位置。链接器对目标机器了解甚少。产生目标文件的编译器和汇编器已经完成了大部分工作。
目标文件
从技术上来说,一个目标模块(object module)就是一个字节序列,而一个目标文件(object file)就是一个以文件形式存放在磁盘中的目标模块。不过,我们会互换地使用这些术语。
目标文件有三种形式:
- 可重定位目标文件。 包含二进制代码和数据,其形式可以在编译时与其他可重定位目标文件合并起来,创建一个可执行目标文件。经过预处理、编译、汇编后生成的 .o 文件(比如上面的
mian.o)就是可重定位目标文件。 - 可执行目标文件。 包含二进制代码和数据,其形式可以被直接复制到内存并执行。编译器经过链接后生成的
.out文件以及无后缀名文件都是可执行文件。 - 共享目标文件。 一种特殊类型的可重定位目标文件,也是我们后面要说的动态链接库,可以在加载或者运行时被动态地加载进内存并链接。
可重定位目标文件
本书主要说的是Linux 中的 ELF 可重定位目标文件。
这些节分别代表着什么在书里也有介绍,这里我就不多说了,感兴趣的朋友可以直接去看书。
符号和符号表
重定位的核心就是对符号表进行符号解析
每个可重定位目标模块 m 都有一个符号表(即 .symtab 节),包含着 m 定义和引用的符号的信息。
有三种不同的符号:
- 由模块 m 定义并能被其他模块引用的全局符号。包括非静态的函数和全局变量
- 由其他模块定义并被 m 引用的全局符号,称之为外部符号。对应其他模块中定义的非静态函数和全局变量。
- 由模块 m 定义且只能被 m 引用的局部符号。包括带 static 属性的函数和全局变量。
符号解析
这一块书里也只是简单的提了一下,没有讲的很清楚,主要是解析局部符号和全局符号
重定位
重定位由两步组成:
- 重定位节和符号定义。 在这一步中,链接器将所有相同类型的节合并为同一类型的新的聚合节。例如,来自所有输入模块的. data 节被全部合并成一个节,这个节成为输出的可执行目标文件的. data 节。然后,链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成时,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
- 重定位节中的符号引用。 在这一步中,链接器修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中称为重定位条目(relocation entry)的数据结构,我们接下来将会描述这种数据结构。
可执行目标文件
动态链接共享库
静态库的缺点:
- 静态库需要定期维护和更新。如果想要使用一个更新后的静态库,必须显式地将程序与更新了的静态库重新链接。
- 调用的静态库中的函数在运行时会被复制到每个运行进程的文本段中。
共享库是为了解决静态库缺陷的产物。也就是说共享库的主要目的就是
- 共享库与可执行文件相独立,只要输出接口不变(即名称、参数、返回值类型和调用约定不变),共享库更新不会对可执行文件造成任何影响。
- 允许多个正在运行的进程共享内存中相同的库代码,从而节约宝贵的内存资源。
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和内存中的程序链接起来。
动态链接:在程序运行或加载时,动态链接器将共享库加载到内存中并和程序链接起来。
共享库在 Linux 中以 .so 后缀表示,在 Windows 中以 .dll 表示。Windows 操作系统中大量使用了共享库。
我们来看一个共享库的具体使用例子(注意,这个不是动态链接):
假设我们创建一个简单的数学库,提供基本的加法和减法功能:
// 定义两个简单的函数:加法和减法
int add(int x, int y) {
return x + y;
}
int subtract(int x, int y) {
return x - y;
}
接下来,使用以下命令将上述文件编译为共享库:
g++ -shared -fPIC math_functions.cpp -o libmathfunctions.so
现在我们需要一个主程序来使用这个共享库。主程序将调用库中的函数:
#include <iostream>
extern "C" int add(int x, int y);
extern "C" int subtract(int x, int y);
int main() {
int result1 = add(10, 5);
int result2 = subtract(10, 5);
std::cout << "10 + 5 = " << result1 << std::endl;
std::cout << "10 - 5 = " << result2 << std::endl;
return 0;
}
假设共享库和主程序位于同一目录下,用这个命令进行编译:
g++ main.cpp -L. -lmathfunctions -o main
我们可以用这个命令调整共享库的路径(替换.):
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
比如:
export LD_LIBRARY_PATH=/example/path:$LD_LIBRARY_PATH
然后更换我们的编译方式就好:
g++ main.cpp -L/example/path -lmathfunctions -o main
现在运行主程序:
./main
然后会输出:
10 + 5 = 15
10 - 5 = 5
接下来我们来看一个动态链接的例子:
动态链接的过程需要依次调用 dlopen, dlsym, dlclose, dlerror 函数。
首先创建动态库:
extern "C" int square(int x) {
return x * x;
}
然后编译:
g++ -shared -fPIC square.cpp -o libsquare.so
创建主程序:
#include <iostream>
#include <dlfcn.h>
typedef int (*FuncPtr)(int); // 定义函数指针类型
int main() {
void* handle = dlopen("./libsquare.so", RTLD_LAZY);
if (!handle) {
std::cerr << "Cannot load library: " << dlerror() << '\n';
return 1;
}
// 重置错误
dlerror();
// 加载符号
FuncPtr square = (FuncPtr) dlsym(handle, "square");
const char* dlsym_error = dlerror();
if (dlsym_error) {
std::cerr << "Cannot load symbol 'square': " << dlsym_error << '\n';
dlclose(handle);
return 1;
}
// 使用函数
std::cout << "Square of 5 is " << square(5) << std::endl;
// 关闭库
dlclose(handle);
return 0;
}
在这个主程序里,用 dlopen 来加载库,用 dlsym 获取 square 函数的地址。
编译这个程序,需要链接 dl 库:
g++ main.cpp -ldl -o main
PS:
dl库(动态加载库)是一个在 UNIX 和类 UNIX 系统中用于动态加载和链接共享库(如.so文件)的编程接口(API)。这个库提供了一组称为libdl的函数,使得程序能够在运行时加载共享库、获取库中符号的地址、以及卸载库,而无需在编译时静态链接这些库。主要函数包括dlopen、dlsym、dlclose和dlerror。
运行:
./main
输出: Square of 5 is 25。
如果 dlopen 无法找到或打开库文件,将输出类似:Cannot load library: [具体错误信息]
如果 dlsym 无法解析符号 square,将输出:Cannot load symbol 'square': [具体错误信息]
然后书浅浅提了一下打桩机制,感兴趣的朋友可以去看一看。
到这里,本书的链接部分差不多就结束了,有了具体的代码例子是不是挺好理解的?
第八章 异常控制流
从给处理器加电开始,到断电为止,程序计数器假设一个值的序列:a0, a1, a2, ..., an。其中每个 a(k) 都是某个相应的指令 I(k) 的地址。
每次从 a(k) 到 a(k+1) 的过渡称为控制转移。这样的控制转移序列叫做处理器的控制流(control flow)。
最简单的控制流是一个平滑的序列,其中每个 I(k) 和 I(k+1) 都是相邻的。而诸如跳转、调用、返回等指令则会造成平滑流的突变,这些突变是由程序内部变量带来的。
还有一种突变是由程序外部的原因造成的,比如磁盘返回数据,鼠标关闭程序等,这种突变就叫做异常控制流(Exceptional Control Flow, ECF)。
异常控制流 ECF 发生在计算机系统的各个层次:
- 硬件层,硬件中断
- 操作系统层,内核通过上下文切换将控制从一个进程转移到另一个进程
- 应用层,一个进程给另一个进程发送信号,信号接收者将控制转移到信号处理程序。
ECF 的应用:
- 操作系统内部。ECF 是操作系统用来实现 I/O、进程和虚拟内存的基本机制。
- 与操作系统交互。应用程序通过使用一个叫做系统调用(system call)的 ECF 形式,向操作系统请求服务。
- 编写应用程序。操作系统为应用程序提供了 ECF 机制,用来创建新进程、等待进程终止、通知其他进程系统中的异常事件、检测和响应这些事件。
- 并发。ECF 是计算机系统中实现并发的基本机制。并发的例子有:异常处理程序或信号处理程序中断应用程序的执行,时间上重叠执行的进程和线程。
- 软件异常处理。C++ 和 Java 通过 try、catch、throw 等语句来提供异常处理功能。异常处理允许程序进行非本地跳转(即违反通常的调用/返回栈规则的跳转)来响应错误情况。非本地跳转是一种应用层 ECF,在 C 中由 setjmp和 longjmp 函数提供。
理解异常控制流的关键在于认识到它是从程序计数器的控制流角度进行描述的。异常控制流发生时,程序计数器的正常执行路径由于外部因素的影响而产生突变。这些外部因素包括但不限于硬件中断、操作系统级的上下文切换、进程间通信以及其他异步事件。这种控制流的改变是由程序外部的事件触发的,与程序内部逻辑(如直接的函数调用或循环)无关。
不同的异常我们有着不同的处理方式,而后本书便介绍了一些常见的异常和相应处理方式。也是由此引出来了操作系统层面的内容。本书这部分内容讲的很浅,我在这里也主要是抛砖引玉,书里主要是在讲解一些常见的操作系统指令和进程状态。代码部分我就不放在这里了,和之前一样,很多地方只是接触的比较少,这些指令本身的作用不难以理解和学习。
第九章 虚拟内存
这部分以及后面的并发编程,都可以看之前写的这篇操作系统相关的总结,比这本书里讲的要完善一些:《操作系统导论》小结
第十章 系统级I/O
到这里变得具象很多,因为我们平时就经常接触。
Linux 将所有的文件组织成一个目录层次结构,由根目录 (/) 确定:
每个进程都有一个当前工作目录。
目录层次结构中的位置用路径名来指定,路径名有两种形式:
- 绝对路径名(absolute pathname)以一个斜杠开始,表示从根节点开始的路径。例如,在图 10-1 中,hello.c 的绝对路径名为 /home/droh/hello.c。
- 相对路径名(relative pathname)以文件名开始,表示从当前工作目录开始的路径。例如,在图 10-1 中,如果 /home/droh 是当前工作目录,那么 hello.c 的相对路径名就是 ./hello.c。反之,如果 /home/bryant 是当前工作目录,那么相对路径名就是 ../home/droh/hello.c。
然后本书介绍了很多操作系统层面,涉及文件操作的函数:
这些函数的基本作用也都很容易理解,感兴趣的朋友可以直接看看相关文档。
然后书里给出了三个基本指导原则:
- G1:只要有可能就使用标准 I/O。 对磁盘和终端设备 I/O 来说,标准 I/O 函数是首选方法。大多数 C 程序员在其整个职业生涯中只使用标准 I/O,从不受较低级的 UnixI/O 函数的困扰(可能 stat 除外,因为在标准 I/O 库中没有与它对应的函数)。只要可能,我们建议你也这样做。
- G2:不要使用 scanf 或 rio_readlineb 来读二进制文件。 像 scanf 或 rio_read-lineb 这样的函数是专门设普来读取文本文件的。学生通常会犯的一个错误就是用这些函数来读取二进制文件,这就使得他们的程序出现了诡异莫测的失败。比如,二进制文件可能散布着很多 Oxa 字节,而这些字节又与终止文本行无关。
- G3:对网络套接字的 I/O 使用 RIO 函数。 不幸的是,当我们试着将标准 I/O 用于网络的输入输出时,出现了一些令人讨厌的问题。如同我们将在 11.4 节所见,Linux 对网络的抽象是一种称为套接字的文件类型。就像所有的 Linux 文件一样,套接字由文件描述符来引用,在这种情况下称为套接字描述符。应用程序进程通过读写套接字描述符来与运行在其他计算机的进程实现通信。
标准 I/O 流,从某种意义上而言是全双工的,因为程序能够在同一个流上执行输入和输出。然而,对流的限制和对套接字的限制,有时候会互相冲突,而又极少有文档描述这些现象:
- 限制 1:跟在输出函数之后的输入函数。 如果中间没有插入对 fflush、fseek、fsetpos 或者 rewind 的调用,一个输入函数不能跟随在一个输出函数之后。fflush 函数清空与流相关的缓冲区。后三个函数使用 Unix I/O lseek 函数来重置当前的文件位置。
- 限制 2:跟在输入函数之后的输出函数。 如果中间没有插入对 fseek、fsetpos 或者 rewind 的调用,一个输出函数不能跟随在一个输入函数之后,除非该输入函数遇到了一个文件结束。
第十一章 网络编程
这部分属于计算机网络的知识,讲的比较浅,简单总结下书里的内容吧:
网络应用都是基于客户端-服务器模型的。采用此模型,一个应用是由一个服务器进程和一个或多个客户端进程组成的。
服务器管理某种资源,并通过操作这种资源来为它的客户端提供服务。
一个客户端—服务器事务由以下四步组成。
- 当一个客户端需要服务时,它向服务器发送一个请求,发起一个事务。例如,当 Web 浏览器需要一个文件时,它就发送一个请求给 Web 服务器。
- 服务器收到请求后,解释它,并以适当的方式操作它的资源。例如,当 Web 服务器收到浏览器发出的请求后,它就读一个磁盘文件。
- 服务器给客户端发送一个响应,并等待下一个请求。例如,Web 服务器将文件发送回客户端。
- 客户端收到响应并处理它。例如,当 Web 浏览器收到来自服务器的一页后,就在屏幕上显示此页。
注意客户端和服务器都是进程,而不是常提到的机器或者主机,两者可以在一台主机上也可以在不同的主机上。
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件资源来通信。
对主机而言,网络只是又一种 I/O 设备,是数据源和数据接收方,如图 11-2 所示:
我们日常生活里经常听到一些网络相关的概念,比如IP地址、域名、局域网、因特网等等,感兴趣的朋友可以搜索下相关的概念,这里就不再赘述了。
第十二章 并发编程
写在最后
到这里我的阅读小结就结束了,希望它可以给朋友们带来收获。
比起此前只想做自己感兴趣的事(如我此前所说,我希望余生都可以做自己喜欢的事),现在的我多出来了不一样的感觉,我把它叫做责任感。
这个学期我遇到了很多很好的人,我们一起做了一些很有意思的事(当然,可能在别人眼里是很无趣的哈哈)。有很多朋友给了我很高的评价:“如果能早点遇到你就好了。”事实上,我也想说一句感谢相遇,你们让我变成了更好的自己。