
第一章 C:穿越时空的迷雾
1.1 C语言的史前阶段
C语言的产生源于 Multics 项目的失败。这是由通用电气、麻省理工和贝尔实验室在1969年共同开展的一个项目,目的是创建一个操作系统。
项目失败后,贝尔实验室的大佬们心灰意冷,在等活干的时候,Ken Thompson使用汇编语言编写了一个小巧的操作系统,并在1970年被命名为 UNIX 。这个时候 C语言 还没有出现。

但是使用汇编语言编写的UNIX系统存在很多问题,维护起来也不方便。为此Ken Thompson创建了 B语言 (大佬就是大佬),这是一种无类型语言。1970年,UNIX开发平台转移到了PDP-11,B语言开始显得不合时宜。为此, Dennis Ritchie 创建了 New B 语言也就是后来的C语言。所以,说 C语言就是New B 是有理有据的。
1.2 C语言的早期体验
C语言的早期用户大多是编译器的设计者,所以C语言有很多为了编译器设计者方便的特性:
数组下标从0开始
C语言的基本数据类型直接与底层硬件相对应
表达式中的数组名可以看作是指针
不过数组和指针并不是在任何情况下都等效,后面会再讲
float被自动扩展为double
早期是这样,ANSI C中不再如此了
不允许嵌套函数,即不允许在一个函数中定义另一个函数
1.3 标准IO库和C预处理器
70年代后期,Steve Bourne使用C预处理器为C语言的很多语法定义的不同的标记,这种C语言变型促成了国际C语言混乱代码大赛,比赛要求参赛的程序员尽可能地编写混乱的程序来压倒对手。
1.4 K&R C

1978年,The C Programming Language出版
1.5 今日之ANSI C
1983年,美国国家标准化组织(ANSI) 成立了 C语言小组,开始 C语言的标准化工作。
1989年,C语言标准草案最终被ANSI 委员会接纳。
1991年,美国国家标准和技术局发布了ANSI C。
1.6 它很棒,但它符合标准吗?
应该理解的几个术语:
不可移植的代码 unportable code
由编译器定义的 implementation-defined — 由编译器设计者决定采取何种行动,不同的编译器行为可能会不同,但是它们都是正确的。
例如:当整型数向右移位时,符号位要不要扩展
未确定的 unspecified — 在某些正确情况下的做法,但是标准并未规定该怎样做。
例如:参数的求值顺序
坏代码 bad code
未定义的 undefined — 在某些不正确情况下的做法,但是标准并未规定该怎样做。
例如:当一个有符号整数溢出时该采取什么行动。
约束条件 a constraint — 必须遵守的限制或要求。
可移植的代码 portable code
严格遵循标准的 strictly-conforming
遵循标准的 conforming
1.7 编译限制
1.8 ANSI C标准的结构
K&R C 和 ANSI C的区别:
- 原型 — ANSI C 把形参的类型作为函数声明的一部分。
- 新的关键字。
enum
const
volatile
signed
void
- 其它在现实中几乎遇不到的改变
原型 — 声明函数时,除了提供函数名和返回类型外,还必须提供形参的类型。原型也称为 函数签名 function signature
1.9 阅读ANSI C标准,寻找乐趣和裨益
1 | foo(const char **p){} |
编译这段代码,编译器会发出警告:
line 5: warning: argument is incompatible with prototype
为什么实参 char ** argv
与形参 const char ** p
不能相容呢?
首先,研究为什么 char * argv
和 const char * p
能够相容。
ANSI C标准中关于形参与实参相容的约束:
每个实参都应该具有自己的类型,这样它的值就可以赋值给与它所对应的形参类型的对象(该对象不能含有限定符)。
也就是说参数传递的过程类似于赋值
ANSI C标准中对赋值合法的约束条件之一:
两个操作数都是指向有限定符或无限定符的相容类型的指针,左边指针所指向的类型必须具有右边指针所指向类型的全部限定符。
分析
1 | char *p; |
cp
是指向 带有const
限定符的char
类型的指针,p
是指向 char
类型的指针
char
类型与 char
类型相容,左边指针 cp
具有右边指针 p
所指向类型的全部限定符加上自己的限定符 const
,所以赋值是合法的。
现在来看 char ** argv
和 const char ** p
首先需要明白的是,p
是一个没有限定符的指针类型,它指向 const char *
;argv
也是没有限定符的指针类型,它指向 char *
。
根据编译器警告, const char *
与 char *
并不相容,这是为什么呢?
因为 const char *
是指向 const char
类型的指针, char *
是指向 char
类型的指针,它们并不是同一种指针,也就是说它们的类型并不相同,自然不能相容。另外,与 char *
相容的带 const
限定符的类型应该是 char * const
为什么不能把 char 传给需要 char const 的函数?
1.10 “安静的改变”究竟有多安静
对无符号类型的建议
尽量不要在代码中使用无符号类型,以免增加不必要的复杂性。
第二章 这不是BUG,而是语言特性
2.1 这关语言特性何事,在 Fortran 里这就是BUG呀
无论在什么时候,如果遇见了这样一条语句 malloc(strlen(str))
,几乎可以断定它是错的,因为 malloc(strlen(str)+1)
才是正确的。要为字符串最后的 '\0'
分配空间!
2.2 多做之过
多做之过就是语言中存在某些不应该存在的特性。
2.2.1 由于存在 fall through ,switch 语句会带来麻烦
这个小标题就能说明一切了。慎重使用 switch 语句,记得 break;
2.2.2 粉笔也成了可用的硬件
ANSI C中引入了一个新特性,相邻的字符串常量将被自动合并成一个字符串:
1 | printf("one" |
将会打印 onetwothree
使程序第一次执行时与以后执行时的行为不同
1
2
3
4
5
6for(int i = 0; i < 10; ++i)
{
static char separator = ' ';
printf("%c %d",separator,i);
separator = ',';
}第一次打印空格,以后都打印 ‘,’
2.2.3 太多的缺省可见性
定义C函数时,默认情况下函数名字是全局可见的。如果要限制该函数的可见性,可以加个 static
关键字
2.3 误做之过
2.3.1 骆驼背上的重载
C语言中的符号重载:
符号 | 意义 |
---|---|
static |
在函数内部,表示变量的值在各个调用间一直保持延续性 |
在函数这一级,表示该函数只对本文件可见 | |
extern |
用于变量时,表示变量在别处定义 |
用于函数时,表示函数全局可见 |
2.3.2 有些运算符的优先级是错误的
优先级问题 | 表达式 | 意义 |
---|---|---|
. 的优先级高于 * |
*p.f | *(p.f) |
[] 的优先级高于 * |
int *p[] | (int*) p[] |
() 的优先级高于 * |
int *fp() | (int*) f() |
第三章 分析C语言的声明
3.1 只有编译器才会喜欢的语法
这一节吐槽了C语言复杂的声明方式,并且留下了一个问题:
声明 char * const *(*next)();
的确切意思是什么?
答:next
是一个函数指针,该函数的返回值是一个指向 char * const
的指针
3.2 声明是如何形成的
提供标识符与类型信息,用于声明一个标识符的语法被称作声明器。
关于声明器的讲解可以看下面链接的文章:
3.3 优先级规则
C语言声明的优先级规则:
- 读取声明的名字,然后按照优先级顺序读取
优先级顺序由高到低:
()
- 函数的
()
和 数组的[]
处于同一优先级 - 指针的
*
如果
const
和volatile
关键字的后面紧跟类型说明符 (int,long,double等),那么它作用于类型说明符。其它情况下,const
和volatile
关键字作用于它左边紧邻的指针星号
举个栗子
int *[4]
[]
优先级最高,所以是一个数组- 接下来是
*
,所以上一步中数组中的元素是 指针 - 最后是
int
,上一步的指针指向int
综合起来,这是一个长度为4的数组,数组元素都是指向 int
的指针
3.5 typedef 可以成为你的朋友
首先分析一下下面这个例子:
1 | void (*signal(int sig,void (*func)(int)))(int); |
signal
是一个函数,它的参数类型是 int
和 void (*)(int)
,返回值是一个函数指针,该指针指向的函数接受 int
类型的参数,返回值类型为 void
使用 typedef
可以使这个函数的声明大大简化
1 | typedef void (*ptr_to_func)(int); |
那么最开始的函数声明可以简化为
1 | ptr_to_func signal(int sig,ptr_to_func func); |
3.6 typedef int x[10] 和 #define x int[10] 的区别
这是在讨论 typedef
和 宏文本 替换的区别,主要体现在两个方面:
宏定义的类型可以使用其它类型说明符进行扩展,如
1
2
unsigned peach i; //OK, i is of type unsigned int而
typedef
定义的类型不能这样做在连续几个变量的声明中,
typedef
可以确保每一个变量都是同一类型,而宏替换不能保证,如1
2
3
4
5
int_ptr chalk,cheese; // chalk is of type int* , cheese is of type int
typedef char* char_ptr;
char_ptr chalk,cheese; // chalk and cheese are both of type char*
第四章 令人震惊的事实:数组和指针并不相同
4.1 数组并非指针
作者在本小节主要是想告诉我们,在C语言中,数组和指针是不同的。
4.3 什么是声明,什么是定义
C语言的对象只能有一个定义,但可以有多个 extern
声明。
只要记住下面的内容就可以区分定义和声明:
·声明相当于普通的声明:它所说明的并非自身,而是描述其他地方的创建的对象。
定义:它为对象分配内存。
4.3.1 数组和指针是如何访问的
1 | char a[9]="abcdefgh"; |
编译器符号表中有数组 a
的地址,当执行 a[i]
时,直接将 a
的地址加上 i
得到所需字符的地址,然后取地址里的内容得到所需字符
1 | char *p = "abcdefgh"; |
编译器符号表中有 指针 p
的地址,当执行 *p
时,首先获得 p
地址里的内容(也是一个地址),然后取该地址的内容得到所需字符。
定义为指针,但是以数组方式引用会发生什么?
1 | char *p = "abcdefgh"; |
编译器符号表中有 指针 p
的地址,当执行 p[i]
时,首先 获得 p
的地址里的内容,然后将这个地址 加上 i
获得所需字符的地址,再取该地址的内容得到所需字符
看下面这个例子
1 | //文件1 |
这段代码可以通过编译,但会引发段错误而无法执行
为什么呢?
因为在文件二中将 mango
声明为一个指针,所以在文件二后面对 mango
进行解引用时会首先获得 mango
地址的内容并将其当作一个地址看待,而实际上 mango
是一个数组,其地址里的内容是 int
。我们的代码相当于将 int
类型当作 地址对待,这肯定是不对的。
4.4 使声明与定义相匹配
将上一小节的代码修改如下:
1 | //文件1 |
就可以执行,没有段错误了
第五章 对链接的思考
5.1 函数库、链接和载入

5.2 动态链接的优点
- 动态链接可执行文件比功能相同的静态链接文件的体积小
- 所有动态链接到某个特定函数库的可执行文件可以共享该函数库的一个拷贝
静态库被称为 archive ,它们通过 ar 来创建和更新,约定使用 .a
扩展名
动态链接库由 链接编辑器 ld 创建。根据约定,动态库采用 .so
扩展名,表示 shared object
5.3 函数库链接的5个特殊秘密
动态库文件的拓展名是
.SO
,静态库文件的拓展名是.a
例如,通过
-lthread
选项,告诉编译链接到libthread.so
编译器期望在确定的目录找到库
观察头文件,确认所使用的函数库
与提取动态库中的符号相比,静态库中的符号提取的方法限制更严
在编译器命令行中各个静态链接库出现的顺序是非常重要的 ,包含未声明的文件应该放在前面。
始终将 -l
函数库选项放在编译器命令行的最右边。
例子 | Windows时间(ipopt耗时) | Windows总时间 | Linux时间(ipopt耗时) | Linux总时间 |
---|---|---|---|---|
Moonlander | 0.111 | 4479 | 0.201 | 231169 |
OrbitRaising | 0.186 | 7176 | 2.362 | 2386301 |
RocketLanding | 0.282 | 2833 | 0.597 | 609618 |
5.4 警惕 Interpositioning
Interpositioning 就是通过编写与库函数同名的函数来取代库函数的行为。
5.5 产生链接器报告文件
介绍了 ld
-m
-D
两个选项,它们对分析链接过程有帮助。
第六章 运动的诗章:运行时数据结构
6.1 a.out 及其传说
震惊!a.out
竟然不是为了方便而随便取的缺省名字,而是 assembler output 的缩写!且它不是汇编程序的输出,而是链接器的输出。
6.2 段
目标文件和可执行文件有几种不同的格式。SVr4中最常见的一种是 ELF (Executable and Linking Format) 格式,在 BSD UNIX 中 a.out
具有 a.out
格式。
在这些不同的格式中有一个相同的概念 段(segments),它们是二进制文件中的简单区域。一个段一般包含了几个 section
使用 nm
命令可以查看 .out
文件内各段的内容
6.3 操作系统在 a.out 文件里干了些什么
以段的形式组织 .out
文件的好处是 链接器可以方便地把段映射到载入的对象中。

文本段和数据段被链接器拷贝到进程的地址空间
链接器根据可执行文件中 BSS 段的内容在进程的地址空间中分配对应大小的内存块。
其次,我们还需要内存以存储局部变量、临时数据、传递到函数中的参数等等,这块内存被称为 堆栈段(stack segment)
6.4 C语言运行时系统在 a.out 里干了些什么
堆栈段的三个主要用途:
- 堆栈为函数内部声明的局部变量提供存储空间
- 进行函数调用时,堆栈存储与此相关的维护信息
- 可以被用作暂时存储区,存储临时需要存储的值
6.5 当函数被调用时发生了什么:过程活动记录

6.8 setjmp 和 longjmp
这两个函数用于程序执行流程的控制
使用这两个函数需要包含头文件
这两个函数会让程序难以理解,如果不是非用不可,最好避开它们
第七章 对内存的思考
7.2 Intel 80x86内存模型以及它的工作原理
7.3 虚拟内存
虚拟内存的思路是将硬盘作为内存的扩展,在任一时刻,需要的数据会被从硬盘载入到内存,而内存中一段时间不使用的数据会被转移到硬盘当中以为需要使用的数据腾出内存空间。

由上图可以看到,内存管理单元(MMU)负责和CPU通信,并负责虚拟地址和物理地址的转换
虚拟内存通过 页(page)的形式组织,它是操作系统在硬盘和内存之间移动数据的单位,一般为 几K 字节(我的raspbian 是 4K)
磁盘上有一个特殊的 交换区 ,用于保存从内存中换出的进程
进程只能操作位于真实物理内存中的页面,也就是说它不能直接操作位于硬盘上的页。当进程引用一个不存在于物理内存中的页面时,MMU会产生一个页错误。内核会响应此事件并判断该引用是否有效,
- 如果有效就把硬盘上相应的页换入内存中;
- 如果无效,内核向进程发出 segmentation violation 的信号
7.4 Cache 存储器
Cache包含一个地址的列表和它们的内容。随着CPU不断引用新的内存地址,Cache中的地址也在变。所有的内存读取和存储操作都要经过Cache。当CPU需要从一个特定的地址访问数据时,它首先查看Cache中是否有此地址,
- 如果有,就能立即得到需要的数据
- 如果没有,Cache把请求传递给MMU进行内存访问

7.5 数据段和堆
就像堆栈段可以自动增长一样,数据段也有一个对象能够实现这个功能,那就是堆(heap)
7.6 内存泄漏
如何检测内存泄漏?
分两个步骤:
使用
swap
命令观察还有多少可用的交换空间1
$ swapon -s
短时间内键入上述命令三到四次,看看可用的交换区是否在减少。如果发现不断有内存被分配且不被释放,很大可能就是发生了内存泄漏
确定可疑的进程
1
$ ps -lu ming
显示所有的进程大小,也是通过键入这个命令多次,观察是否有进程大小不断增长却不减小,如果有,它就有可能发生了内存泄漏
7.7 总线错误
两个常见的错误
bus error (core dumped) 总线错误(信息已转储)
segmentation fault (core dump) 段错误(信息已转储)
当硬件告诉操作系统一个有问题的内存引用时就会产生这两个错误。操作系统向发生错误的进程发送信号与之进行交流,一般情况下,进程在收到信号之后将进行信息转储并了结自己。
7.7.1 总线错误
总线错误几乎都是由于未对齐的读写引起的。
7.7.2 段错误
段错误由内存管理单元(MMU)的异常导致,而该异常通常是由于解除引用一个未初始化或非法值的指针引起的。
第八章 为什么程序员无法分清万圣节和圣诞节
如这一章的标题所说,为啥呢?
原来 10月30日 $\rightarrow$ Oct 30 $\rightarrow$ Dec 24 $\rightarrow$ 12月24日 圣诞节前夜
8.3 在等待时类型发生了变化

上图中的类型转换并不仅仅发生在涉及操作符和混合类型操作数的表达式。
需要注意的是,参数传递时也会发生隐式类型转换。在被调用的函数的内部,提升后的参数被裁减为原先声明的大小。
第九章 再论数组
第四章讲数组和指针的不同点,这一章作者介绍了数组和指针的相同点
9.1 什么时候数组与指针相同

解说上图:
将数组分为声明和使用两个情况解释,
声明分为三种情况
- 外部数组声明,这个时候必须使用数组,使用指针会出错,这个在第四章已经详细说明过了
- 数组定义,也不能写成指针的形式
- 数组作为函数参数声明时,可以任意选择,数组或者指针都可以,因为编译器最终都会把它们变成指针
使用的时候,指针还是数组形式随便选择,都可以
9.2 为什么会发生混淆
C语言标准对数组和指针何时相同做了如下说明:
规则1 表达式中的数组名被编译器当作一个指向该数组第一个元素的指针
规则2 下标总是与指针偏移量相同
规则3 在函数参数的声明中,数组名被编译器当作指向该数组第一个元素的指针
9.3 为什么C语言把数组形参当作指针
效率
9.6 C语言的多维数组
需要注意的是,C语言中多维数组是按行主序在内存中存储的
第十章 再论指针
10.1 多维数组的内存布局
C语言中多维数组是按行主序在内存中存储的,也就是说,多维数组在内存中的排列是线性的,如下图

pea[4][6]
在内存中的排列方式
10.7 使用指针创建和使用动态数组
记录书中提到的一个函数 realloc()
,它能够对一个现在的内存块大小进行重新分配,同时保证原来的数据不丢失