Learn C in Linux

琐碎知识点

printf 分成多行写, 用 \:

1
2
printf("Hello \
World");

第二章 常量, 变量和表达式

2.2 常量

转换说明 (Conversion Specification) 又被称为占位符 (placeholder).

2.3 变量

变量是计算机存储器中的一块命名的空间, 可以在里面存储一个值, 存储的值是可以随时变化的, 所以才叫做变量.

声明 (declaration) 和定义 (definition) 的区别:

  • 定义是声明的子集, 如果一个声明要求编译器为它分配存储空间, 那么也可以叫做定义.

函数名, 宏定义, 结构体成员等, 在 C 语言中这些都被统称为标识符.

关键词 (keyword) 和保留字 (reserved word) 是相同的含义.

一般来说应避免使用以下划线开头的标识符.

2.4 赋值

变量声明中的类型表明这个变量代表多大的存储空间.

初始化是一种特殊的声明, 而不是一种赋值语句.

2.5 表达式

任何表达式都有值和类型两个基本属性.

如果一个操作数的左右两侧各有一个相同优先级的运算符, 这个操作符和左边的运算符结合还是和右边的运算符结合取决于运算符的结合性 (Associativity), 相同优先级的运算符具有相同的结合性.

向下取整的运算称 Floor, 向上取整的运算称 Ceiling.

char 型本质上就是整数, 只不过取值范围比 int 小.

字符也可以用 ASCII 码转义序列表示, 这种转义序列由 \ 加上 13 个八进制数字组成, 或者由 \x\X 加上 12 个十六进制数字组成, 如 \11\x9 表示 Tab 字符.

第3章 简单函数

函数调用运算符 () 是一种后缀运算符.

头文件一般位于 /usr/include 目录下.

数学函数位于 libm.so 库文件中 (这些库文件通常位于 /lib) 目录下. gcc 的 -lm 选项告诉编译器, 我们程序中用到的数学函数要在这个库文件里面找.

很多库函数如 printf, 都位于 libc.so 库文件中, 使用 libc.so 中的库函数在编译时不需要加 -lc 选项, 这个选项为 gcc 的默认选项.

C 标准库和 glibc

见书.

glibc 提供一组头文件和一组库文件, 几乎所有 C 程序都依赖于 libc.so, 也有很多 C 程序依赖于其他库文件.

3.2 自定义函数

main 函数的特殊之处在于执行程序时它自动被操作系统调用, 意思就是, 我们不需要单独再写一个 main() 来调用这个函数.

main 函数的返回值是给操作系统看的, 因为 main 函数是被操作系统调用的, 通常程序执行成功就返回 0 , 在执行过程中出错就返回一个非零值.

main 函数最标准的形式是 int main(int argc, char* argv[])

语法上规定没有返回值的函数调用表达式是 void 类型, 有一个 void 类型的值.

语义上规定 void 类型不能参与运算.

这种 void threeline(void); 声明了一个函数的名称, 参数类型和个数, 返回值类型的称为函数原型. 只有带函数体的声明才叫做定义.

函数原型会为编译器提供有用的信息, 编译器在翻译代码的过程中, 只有见到函数原型之后才知道这个函数的名称, 参数类型和返回值, 这样在函数调用时才知道如何生成指令.

没有函数声明时, 会有函数的隐式声明 (implicit declaration), 隐式声明的函数返回值类型都是 int.

C 语言规定了一种特殊的参数列表格式:

1
int printf(const char *format, ...)

... 表示 0 个或任意多个参数, 这些参数的类型也可以是不确定的, 这被称作可变参数 (variable argument).

有时候我们把函数叫做接口 (interface), 调用函数就是使用这个接口, 使用接口的前提是必须和接口保持一致.

注意区分用户命令和系统管理命令, 用户命令通常位于 /bin/usr/bin 目录下, 系统管理命令通常位于 /sbin/usr/sbin 目录下. 一般用户可以执行用户命令, 而执行系统管理命令一般需要 root 权限.

3.4 全局变量, 局部变量和作用域

局部变量 (Local Variable) 在每次函数调用时分配存储空间, 在每次函数返回时释放存储空间.

全局变量 (Globle Variable), 定义在所有函数体之外, 它们在程序开始运行时分配存储空间, 在程序结束时释放存储空间.

要注意, 局部变量可以用类型相符的任意表达式来初始化, 而全局变量只能用常量表达式 (Constant Expression) 来初始化.

程序在开始运行时要用适当的值来初始化全局变量, 所以初始值必须保存在编译生成的可执行文件中, 因此初始值在编译时就要计算出来.

全局变量在定义时不被初始化则其初始值是 0, 如果局部变量在定义时不初始化则其初始值是不确定的.

写非定义的函数声明时参数可以只写类型而不起名, 如 void print_time(int, int) 只要告诉编译器参数类型是什么, 编译器就能为 print_time(23, 59) 函数调用生成正确的指令.

在一个函数中可以声明另一个函数, 但不能定义另一个函数.

第4章 分支语句

4.2 if/else 语句

在 C 语言中, 任何允许出现语句的地方, 既可以是由分号结尾的一条语句, 也可以是由 {} 扩起来的若干条语句或声明组成的语句块 (Statement Block).

每一个 Statement Block 中都是一个局部作用域, 如:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main(void)
{
{
int i = 10;
}
printf("%d", i);
return 0;
}

这里就会报错.

C 语言规定, % 运算符的两个操作数必须是整型.

% 运算符的结果总是与被除数同号, 即 a%b 中的 a.

浮点值的精度有限, 不适合用 == 运算符做精确比较.

4.3 布尔代数

逻辑运算的数学体系称为布尔代数 (Boolean Algebra). 在编程语言中表示真和假的数据类型叫布尔类型, 在 C 语言中通常用 int 型表示.

4.4 switch 语句

注意几点:

  • case 后面的表达式必须是常量表达式, 这个值和全局变量一样必须在编译时计算出来
  • 浮点型不适合做精确比较, 因此 case 后面必须为整型常量表达式.
  • 进入 case 如果没有遇到 break 语句就会一直往下执行.

有时候编译器会对 switch 语句进行整体的优化, 使它比等价的 if/else 语句生成的指令效率更高.

第5章 深入理解函数

5.1 return 语句

在没有返回值的函数中也能使用 return 语句.

在有返回值的函数中, returm 语句的作用是提供整个函数的返回值, 结束当前函数并返回到调用它的地方.

返回布尔值的函数是一类非常有用的函数, 在程序中充当控制表达式, 函数名通常带有 if 或 is 等表示判断的词, 这类函数也叫做谓词 (Predicate).

函数的返回值应该这样理解: 函数返回一个值相当于定义一个与返回值类型相同的临时变量并用 return 后面的表达式来初始化.

虽然函数的返回值可以看作临时变量, 但我们只是读一下它的值, 读完值就释放它.

5.3 递归

如果定义一个概念需要用到这个概念本身, 我们称它的定义是递归的 (Recursive).

最关键的是需要定义一个基础条件 (Base Case).

堆栈或栈 (stack) 中, 随着函数调用和返回不断变化的这一端称为栈顶, 每个函数调用的参数和局部变量的存储空间称为一个栈帧 (Stack Frame). 操作系统为程序的运行预留了一块栈空间, 函数调用时就在这个栈空间内分配栈帧, 函数返回时就释放栈帧.

用数学归纳法 (Mathematical Induction) 来证明只需要证明两点: Base Case 正确和递推关系正确.

操作系统为程序预留的栈空间耗尽导致程序崩溃即 段错误.

第6章 循环语句

C 语言规定, 如果控制表达式 2 为空, 则认为控制表达式 2 的值为真:

1
2
3
4
5
控制表达式 1;
while (控制表达式 2) {
语句
控制表达式 3;
}

i++++i 的区别在于返回值不同.

编译的过程分为词法解析和语法解析两个阶段, 在词法解析阶段, 编译器总是从前到后找到最长的合法 token.

C99 规定一种新的 for 循环语法:

1
for(int i = 1; i < 10; i++ )

编译时 gcc 需要加上 -std=c99 选项.

事实上, defaultcase 语句都是特殊的标号.

第7章 结构体

类型定义也是一种声明, 声明都要以 ; 结尾. 如:

1
2
3
struct complex_struct {
double x, y;
};

这里的 comlex_struct 为 tag.

若在定义结构体类型的同时定义了变量, 也可以不用写 tag:

1
2
3
struct {
double x, y;
} z1, z2;

这两个成员的存储空间是相邻的.

结构体变量也可以在定义时初始化:

1
struct complex_struct z = { 3.0, 4.0 };

Initializer 中数据不够则初始化为 0.

{} 这种语法不能用于结构体的赋值, 一下语句为错误:

1
2
struct complex_struct z1;
z1 = { 3.0, 4.0 }

Designated Initializer 的语法:

1
struct complex_struct z1 = { .y = 4.0 };

结构体类型在表达式中有很多限制, 不像基本类型那么自由.

结构体变量之间可以相互赋值和初始化, 也可以当作函数的参数和返回值来传递.

7.3 数据类型标志

1
enum coordinate_type = { RECTANGULAR, POLAR };

这里 enum 把 coordinate_type 声明为一个 tag, 其为枚举 (Enumeration) 类型, 默认从 0 开始分配.

1
enum coordinate_type = { RECTANGULAR = 1, POLAR };

这样就从 1 开始分配.

结构体的成员名和变量名不在同一命名空间, 枚举的成员名和变量名在同一命名空间中.

第8章 数组

数组中元素的存储空间也是相邻的.

1
2
3
struct complex_struct {
double x, y;
} a[4];

C99 的新特性允许在数组长度表达式中使用变量, 称为变长数组 (Variable Length Array), VLA 只能定义局部变量而不能是全局变量.

在 C 语言中, 后缀运算符的优先级最高, 单目运算符的优先级其次, 比其他运算符的优先级都高.

应避免数组越界问题.

数组也可以像结构体那样初始化, 未赋初值的元素也是用 0 来初始化.

如果定义数组时就将其初始化, 可以不用指定长度:

1
int count[] = { 3, 2, 1 };

利用 C99 的新特性也可以做 Memberwise Initialization:

1
int count[4] = { [2] = 3 };

数组不能相互赋值或初始化.

对于数组类型有一条特殊规则: 数组类型做右值使用时, 自动转换成指向数组首元素的指针.

因此, 如:

1
2
3
int a[5] = {4, 3, 2, 1 };
int b[5];
b = a;

这样写是错误的, 右边的 a 此时是指针类型, 而左边的 b 为数组类型.

8.2 数组应用实例: 统计随机数

C 标准库中生成伪随机数的是 rand 函数, 需包含 stdlib.h 头文件, 其没有参数, 返回值是一个介于 0 和 RAND_MAX 之间的接近均匀分布的整数. RAND_MAX 是该头文件中定义的一个常量.

实际上编译器的工作分为两个阶段, 先是预处理 (preprocess) 阶段, 然后才是编译阶段, 用 gcc 的 -E 选项可以看到预处理之后, 编译之前的程序. 使用 cpp (C Preprocessor) 也可以达到同样的效果, 只做预处理不编译.

虽然 include 和 define 在预处理指示中有特殊含义, 但它们并不是 C 语言的关键词.

8.3 数组应用实例: 直方图

C 标准库允许我们指定一个初值, 然后在此基础上生成伪随机数, 这个初值称为 Seed, 可以用 srand 函数指定 seed, 通常调用 time 函数得到当前系统时间距 1970-1-1-00:00:00 的秒数, 然后传给 srand:

1
srand(time(NULL));

调用 time 需要包含 time.h 头文件, NULL 是空指针.

8.4 字符串

字符串字面值也可以像数组名一样使用, 可以加下标访问其中的字符:

1
char c = "Hello World\n"[0];

通过下标修改其字符是不允许的. 但是传给数组后还是可以修改数组的内容的.

字符串字面值做右值时也会自动转换成指向首元素的指针.

printf("Hello World") 其实就是传一个指针给 printf 函数.

如果要用一个字符串字面值准确地初始化一个字符数组, 最好的办法是不指定数组的长度, 让编译器自己计算:

1
char str[] = "Hello World.\n";

8.5 多维数组

从物理模型看, 多维数组中的元素在存储器中仍然是连续存储的.

多维数组中, 除了第一维的长度可以由编译器自动计算而不需要指定, 其余各维都必须明确指定长度.

第9章 编码风格

9.2 注释

注释的使用场合:

  • 整个源文件的顶部注释, 说明次模块的相关信息, 例如文件名, 作者和版本历史等
  • 函数注释, 说明此函数的功能, 参数, 返回值, 错误码等, 写在函数定义上侧且不留空行.
  • 相对独立的语句组注释.
  • 代码行右侧的简短注释, 一个源文件中所有右侧代码注释最好能上下对齐.
  • 复杂的结构体定义.
  • 复杂的宏定义和变量声明.

9.5 indent 工具

这是一个命令行工具, 用来调整缩进, 其会直接修改源文件:

1
$ indent -kr -i8 main.c

-kr 选项是 K&R 风格, -i8 表示缩进八个空格长度.

第十章 gdb

-g 选项的作用是在可执行文件中加入源代码的信息.

help command, the output is the main type of a class of commands.

list command 一次只列10行. 可以用函数名。

按回车键表示重复上一条命令。

在调试时也需要源文件。

#0, #1 是栈帧编号。

未初始化的变量具有不确定的值。

$1 保存着gdb中的中间结果。

可以在 gdb 中修改变量的值 set.

基本命令

  • backtrace(bt)
  • finish
  • frame(f)
  • info(i) locals
  • list(l)
  • list 行号
  • list 函数名
  • next(n)
  • print(p)
  • quit(q)
  • set var
  • start
  • step(s)

10.2 断点

字符型的 ‘2’ 要减去 ‘0’ 的 ASCII 码才能转换成整数值 2.

display 命令可以跟踪显示. undisplay 取消跟踪显示.

设置断点的命令 break 和 continue 结合起来用, continue 命令就是连续运行, 知道遇到一个断点.

使用 info 命令可以查看已经设置的断点:

1
info breakpoints

delete 命令加上编号可以删除某个断点.

可以用 disable 命令禁用断点而不是删除. enable 开始启用.

可以设置断点在满足某个条件时才激活:

1
break 9 if sum != 0

使用 run 命令从程序开头连续运行.

字符串的长度是不包括 ‘\0’ 的.

10.3 观察点

x 命令打印指定存储单元的内容:

1
(gdb) x/7b input

这里的 7b 是打印格式, 7 指打印 7 组, b 表示每个字节一组, 这里即从 input 数组的第一个字节开始连续打印 7 个字节. 打印的是十六进制的 ASCII 码.

断点是当程序执行到某一行代码时中断, 而观察点 (Watchpoint) 是当程序访问某个存储单元时中断.

用 watch 命令来设置观察点:

1
(gdb) watch input[5]

可以用 info 命令查看:

1
(gdb) info watchpoints

10.4 段错误

如果程序运行时出现段错误, 用 gdb 可以很容易定位到究竟是哪一行引发的段错误.

在 gdb 中运行, 遇到段错误会自动停下来, 这时可以用命令查看当前执行到哪一行代码了, 可以使用 bt (backtrace) 命令.

如果某个函数的局部变量发生访问越界, 有可能并不立即产生段错误, 而是在函数返回时产生段错误.

第11章 排序与查找

11.1 算法的概念

算法 (Algorithm) 是将一组输入转化成一组输出的一系列计算步骤, 其中每个步骤必须能在有限时间内完成.

算法使用来解决一类问题的.

推荐书籍:

  • << The Art of Computer Programming >>
  • << Introduction to Algorithms >>

11.2 插入排序

假如某个判断条件满足以下三条准则, 就将其称为 Loop Invariant:

  • 第一次执行循环体之前该判断为真
  • 如果 “第 N-1 次循环之后 (或者说 N 次循环之前) 该判断条件为真” 这个前提可以成立, 那么就有办法证明第 N 次循环之后该判断条件仍为真
  • 如果在所有循环结束后该判断条件为真, 那么就有办法证明该算法正确地解决了问题

11.3 算法的时间复杂度分析

在分析算法的时间复杂度时, 我们更关心最坏情况而不是最好情况.

我们用一种跟粗略的方式表示算法的时间复杂度, 把系数和低次幂都省去, 线性函数记作 O(n), 二次函数记作 $O(n^2)$.

11.4 归并排序

第12章 栈与队列

12.1 数据结构的概念

数据结构 (Data Structure) 是数据的组织方式.

数据的组织方式包含了存储方式和访问方式这两层意思, 二者是紧密联系的.

一个问题中数据的存储方式和访问方式决定了解决问题可以采用什么样的算法, 要设计一个算法就要同时设计相应的数据结构来支持这种算法.

算法 + 数据结构 = 程序

12.2 堆栈

深度优先搜索

12.4 队列与广度优先搜索

队列也是一组元素的集合, 它提供两种基本操作:

  • Enqueue (入队), 将元素添加到队尾
  • Dequeue (出队), 从队头取出元素并返回

FIFO, First In First Out.

第13章 计算机中数的表示

任何复杂的加减乘除运算都可以分解成简单的逻辑运算.

异或 (XOR, eXclusive OR) 运算, 即两个操作数相同则结果为 0, 两个操作数不同则结果为 1.

最高位 (Most Significant Bit, MSB).

最低位 (Least Significant Bit, LSB).

除二反序取余法, 用于将十进制转换为二进制.

13.3 整数的加减运算

判断一个数的正负都是看最高位.

表示法的不同只是计算规则不同.

13.3.1 Sign and Magnitude 表示法

把最高位规定为符号位 (Sign Bit), 0 表示正 1 表示负.

计算机做加法运算需要处理的逻辑:

  • 如果两数符号位相同, 就把他们的低七位相加
  • 如果两数符号位不同, 首先比较它们的低七位谁大, 然后用大数减小数, 结果的符号位和大数相同

13.3.2 1’s Complement 表示法

负数用 1 的补码 (1’s Complement) 表示, 减法转换成加法, 计算结果的最高位如果有进位则要加回到最低位上去.

取 1 的补码就是把每个位取反, 所以 1 的补码也被称为反码.

13.3.3 2’s Complement 表示法

2’s Complement 表示法规定: 正数不变, 负数先取反码再加 1.

意思是, 如果我想要表示 -4, 那我就要把 0100 拿出来, 取 0100 的 2 的补码, 即 1111 - 0100 + 1 = 10000 - 0100. 即从 $2^4$ 里减去 4.

其计算规则为: 减法转换成加法, 忽略计算结果最高位的进位, 不必加回到最低位上去.

这里的精髓就是要舍弃最高位的进位.

13.3.4 有符号数和无符号数

计算机做加法时并不区分操作数是有符号数还是无符号数, 计算过程都一样.

注意进位标志和溢出标志.

13.4 浮点数

浮点数在计算机中的表示是基于科学计数法 (Scientific Notation).

32767 这个数用科学计数法可以写成 $3.2767x10^4$, 3.2767 称为尾数 (Mantissa, 或着叫 Significand), 4 称为指数 (Exponent).

浮点数分为三个部分:

  • 符号位
  • 指数部分
  • 尾数部分, 这里要将所有有效位数都移到小数点后

指数部分的表示采用 偏移的指数 (Biased Exponent), 规定一个偏移值, 如 16, 实际的指数就要加上这个偏移值再写入到指数部分, 这样, 比 16 小就说明指数为负数, 比 16大就说明指数为正数.

为了解决每个浮点数的表示不唯一的问题, 规定尾数部分的最高位必须是一. 这个 1 不必保存在尾数中, 因为最高位都是 1.

做浮点运算时要注意精度损失问题. 有时计算顺序不同也会导致结果不同.

整数运算会产生溢出, 浮点运算也会产生溢出, 浮点运算的溢出也分上溢和下溢, 但和整数运算的定义不同.

第14章 数据类型详解

C 语言与平台和编译器是密不可分的.

ILP32 这个缩写的意思是 int (I), long (L), 和指针 (P) 类型都占 32位.

指针类型的长度总是和计算机的位数一直.

在 x86 平台上 int 和 long 的取值范围相同.

14.2 浮点型

浮点数的实现在各种平台上差异很大.

14.3 类型转换

14.3.1 Integer Promotion

如果原始类型的取值范围都能用 int 型表示, 则其类型被提升为 int, 如果原始类型的取值范围用 int 型表示不了, 则提升为 unsigned int 型, 这被称作 Integer Promotion.

14.3.2 Usual Arithmetic Conversion

两个算数类型的操作数做算数运算, 比如 a + b, 如果两边操作数的类型不同, 编译器会自动做类型转换, 使两边类型相同之后再做运算, 这被称为 Usual Arithmetic Conversion.

有符号数和无符号数混用会很麻烦.

14.3.3 由赋值产生的类型转换

14.3.4 强制类型转换

14.3.5 编译器如何处理类型转换

第15章 运算符详解

15.1 位运算

15.1.1 按位与, 或, 异或, 取反运算

&, |, ^ 运算符都是要做 Usual Arithmetic Conversion 的 (其中有一步是 Integer Promotion), ~ 运算符也要做 Integer Promotion, 所以在 C 语言中其实并不存在 8 位整数的位运算, 操作数在做位运算之前都至少被提升为 int 型了.

15.1.2 移位运算

在一定的取值范围内, 将一个整数左移 1 位相当于乘以 2.

由于计算机做移位比乘法快得多, 编译器可以利用这一点进行优化.

当操作数是有符号数时, 右移运算的规则比较复杂.

建议只对无符号数做位运算, 以减少出错的可能性.

15.1.3 掩码

用于对一个整数中的某些位进行操作.

15.1.4 异或运算的一些特性

  • 一个数和自己做异或的结果是 0

15.2 其他运算符

a += 1a = a + 1, 前者对表达式 a 只求值一次, 而后者求值两次.

++i 相当于 i += 1, 只求值一次.

15.2.4 sizeof 运算符与 typedef 类型声明

sizeof 表达式 中的子表达式不求值, 如 sizeof(i++)i 也不会递增.

sizeof 运算符的结果是 size_t 类型, 这个类型定义在 stddef.h 头文件中.

标准 C 规定 size_t 是一种无符号整型:

1
typedef unsigned long size_t;

typedef 的例子:

1
2
typedef char array_t[10];
array_t a;

这相当于声明 char a[10].

第16章 计算机体系机构基础

Von Neumann 体系结构的主要特点: CPU (Central Processsing Unit, 中央处理器, 或简称处理器 Processor) 和内存 (Memory) 是计算机的两个主要组成部分, 内存中保存着数据和指令, CPU 从内存中取指令 (Fetch) 执行, 其中有些指令让 CPU 做运算, 有些指令让 CPU 读写内存中的数据.

一个地址所对应的内存单元只能存一个字节.

16.2 CPU

CPU 最核心的功能单元包括以下几部分:

  • 寄存器 (Register)
  • 程序计数器 (Program Counte), 是一种特殊的寄存器, 保存着 CPU 取下一条指令的地址.
  • 指令译码器 (Intruction Decoder)
  • 算数逻辑单元 (Arithmetic and Logic, ALU)
  • 地址和数据总线 (Bus)

地址线, 数据线和 CPU 寄存器的位数通常是一致的.

内总线经过 MMU 和总线接口的转换之后引出到芯片引脚才是外总线, 外地址线和外数据线的位数都有可能和内总线不同.

16.3 设备

正因为地址线和数据线上可以挂多个设备和内存芯片所以才叫 “总线”. 但不同设备和内存芯片应该占用不同的地址范围.

设备中可供读写访问的单元通常称为设备寄存器. 操作设备的过程就是读写这些设备寄存器的过程.

x86 对于设备有独立的端口地址空间, CPU 核需要引出额外的地址线来连接片内设备 (和访问内存所用的地址线不同).

从 CPU 的角度来看, 访问设备只有内存映射 I/O 和端口 I/O 两种.

一个操作系统为了支持广泛的设备就需要有大量的设备驱动程序, 事实上 Linux 内核源代码中绝大部分是设备驱动程序, 设备驱动程序通常是内核里的一组函数, 通过读写设备寄存器实现对设备的初始化, 读, 写等操作, 有些设备还要提供一个中断处理函数供 ISR 调用.

16.4 MMU

第17章 x86汇编程序基础

生成了目标文件之后还是无法执行, 虽然目标文件已经是二进制文件了.

链接主要有两个作用:

  • 修改目标文件中的信息, 对地址做重定位
  • 把多个目标文件合并成一个可执行文件

# 表示单行注释.

1
.section .data

汇编程序中以 . 开头的名称并不是指令的助记符, 不会被翻译成机器指令, 而是给汇编器一些特殊指示, 称为汇编指示 (Assembler Directive) 或伪操作 (Pseudo-operation), 由于它不是真正的指令所以加个 “伪” 字.

.section 指示把代码划分成若干个段 (Section). 程序被操作系统加载时, 每个段被加载到不同的地址, 操作系统对不同的页面设置不同的读, 写, 执行权限. 感觉像是用来声明一个段.

.data 段保存程序的数据, 是可读可写的, 相当于 C 程序的全局变量, 这里没有定义数据, 所以 .data 段是空的.

1
.section .text

.text 段保存代码, 是只读和可执行的, 后面那些指令都是属于 .text 段.

1
.globl _start

_start 是一个符号 (Symble), 符号在汇编程序中代表一个地址.

在 C 语言中我们通过变量名访问一个变量, 其实就是是读写从某个地址开始的内存单元, 我们通过函数名调用一个函数, 其实就是跳转到该函数第一条指令所在的地址, 所以变量名和函数名都是符号, 本质上是代表内存地址的.

.globl 告诉汇编器, _start 这个符号要被链接器用到, 所以要在目标文件的符号表中标记它是一个全局符号.

_start 就像 C 程序的 main 函数一样特殊, 是整个程序的入口, 这里只能是 _start, 不能写成其他的, 连接器在链接时会查找目标文件中 _start 符号代表的地址, 把它设置为整个程序的入口地址, 所以每个汇编程序都要提供一个 _start 符号并且用 .globl 声明, 如果一个符号没有用 .global 声明, 就表示这个符号不会被链接器用到.

_start:

这里定义了 _start 符号, 汇编器在翻译汇编程序时会计算每个数据对象和每条指令的地址, 当看到这样一个符号定义时, 就把他后面一条指令的地址作为这个符号所代表的地址.

movl $1, %eax

这条指令不要求 CPU 读内存, 1 这个数是在 CPU 内部产生的, 称为立即数 (Immediate).

在汇编程序中, 立即数前面要加 $, 寄存器名前面要加 %.

Linux 的各种系统调用都是由 int $0x80 指令引发的.

int 指令称为软中断指令, 可以用这条指令故意产生一个异常, 异常的处理和中断类似, CPU 从用户模式切换为内核模式, 然后跳转到内和代码中执行异常处理程序.

int 指令中的立即数 0X80 是一个参数, 在异常处理程序中要根据这个参数决定如何处理, 在 Linux 内核中 int $0x80 这种异常称为系统调用 (System Call).

eaxebx 是传递给系统调用的两个参数, eax 是系统调用号, _exit 的系统调用号是 1, ebx 的值是传递给 _exit 的参数.

UNIX 平台的汇编器一直使用 AT&T 语法.

参考书籍 Linux Assembly HOWTO

17.3 第二个汇编程序

常用的数据声明:

  • .long 指示声明一组数, 每个数占 32 位
  • .byte 指示声明一组数, 每个数占 8 位
  • ascii 指示声明一组数, 每个数的取值为对应的 ASCII 码

cmpl 把两数相减来做比较, 不保留结果, 改变 eflags 寄存器中的标志位.

je (jump if equal) 检查 eflags 中的 ZF 位来决定是否跳转.

jle (jump if less than or equal).

17.4 寻址方式

内存寻址在指令中可以表示成如下通用格式:

1
ADDRESS_OR_OFFSET(%BASE_OR_OFFSET,%INDEX,MULTIPLIER)

它所表示的地址可以这样计算出来:

1
FINAL ADDRESS = ADDRESS_OR_OFFSET + BASE_OR_OFFSET + MULTIPLIER z INDEX

其中 ADDRESS_OR_OFFSET 和 MULTIPLIER 必须是常数, BASE_OR_OFFSET 和 INDEX 必须是寄存器. 省略的项相当与是 0.

寻址方式见书.

17.5 ELF 文件

Excutable and Linkable Format.

各种 UNIX 系统的可执行文件都采用 ELF 格式, 它有以下三种不同的类型:

  • 可重定向的目标文件 (Relocatable 或 Object File)
  • 可执行文件 (Excutable)
  • 共享库 (Shared Object, 或者 Shared Library)

链接器把 ELF 文件看成是 Section 的集合, 而加载器把 ELF 文件看成是 Segment 的集合.

Section Header Table 中保存了所有 Section 的描述信息.

Program Header Table 保存了所有 Segment 的描述信息.

Section Header Table 和 Program Header Table 并不一定位于文件的开头和结尾, 其位置由 ELF Header 指出.

目标文件需要链接器做进一步处理, 所以一定有 Section Header Table, 可执行文件需要家在进行, 所以一定有 Program Header Table, 共享库既要加载运行, 又要在加载时做动态链接, 所以既有 Section Header Table 又有 Program Header Table.

17.5.1 目标文件

可以用 readelf 工具读出目标文件的 ELF Header 和 Section Header Table.

1
$ readelf -a max.o

用 hexdump 命令打印目标文件的全部字节:

1
$ hexdump -C max.o

.bss 段中存放的是 C 语言中没有初始化的全局变量. 其在 ELF 文件中不占用空间.

在汇编程序中用 .globl 指示声明过的符号会成为全局符号, 否则成为局部符号.

objdump 工具可以把程序中的机器指令反汇编 (Disassemble):

1
$ objdump -d max.o

17.5.2 可执行文件

.rel.text 用于链接过程, 做完链接之后会被删掉.

x86 平台上后面的 PhysAddr 列是没有意义的, 并不代表实际的物理地址.

MMU 的权限保护机制是以页为单位, 一个页面只能设置一种权限.

为了简化链接器和加载器的实现, 还规定每个 Segment 在文件的页面中偏移多少加载到内存页面中也要偏移多少.

strip 命令可以去除可执行文件中的符号信息, 这样可以有效减小文件的尺寸而不影响运行:

1
$ strip max

不要对目标文件和共享库使用 strip 命令, 因为链接器需要利用目标文件和共享库中的符号信息来做链接.

第18章 汇编与 C 之间的关系

gcc 还提供了一种扩展语法可以在 C 程序中内嵌汇编指令.

18.1 函数调用

如果在编译时加上 -g 选项, 那么用 objdump 反汇编时可以把 C 代码和汇编代码穿插起来显示.

1
2
$ gcc main.c -g
$ objdump -dS a.out

要查看编译后的汇编代码, 可以用 gcc -S main.c 这样只生成汇编代码 main.s, 而不生成二进制的目标文件.

在用 gdb 调试时, 可以用 disassemble 命令反汇编. 如果 disassemble 命令后面跟函数名或地址则反汇编指定的函数.

si 命令可以逐条指令 (应该是汇编指令) 进行单步调试. info registers 可以显示所有寄存器的当前值. 在 gdb 中表示寄存器名时前面要加 $, 如 (gdb) p $esp. (gdb) x/20 $esp 命令可以查看内存中从 0xbff1c3f4 地址开始的 20 个 32 位数.

在执行程序时, 操作系统为进程分配一块栈空间来保存函数栈帧, esp (Stack Pointer) 寄存器总是指向栈顶, 在 x86 平台上这个栈是从高地址向低地址增长的.

Linux 内核为每个新进程指定的栈空间的起始地址都会有些不同, 所以每次运行这个程序得到的地址都不一样.

参数是从右向左依次压栈的.

这里栈指针每次减四是因为数据为 int 类型, 占四个字节.

在每个函数的栈帧中, ebp (Base Pointer) 指向栈底, esp 指向栈顶. 函数的参数和局部变量都是通过 ebp 的值加上一个偏移量来访问.

在 gdb 中可以用 bt 命令和 frame 命令查看每层栈帧上的参数和局部变量.

bar 函数有一个 int 型的返回值, 这个返回值是通过 eax 寄存器传递的.

18.2 main 函数, 启动例程和退出状态

1
gcc main.c -o main

这条命令实际可以分为三条命令来做:

1
2
3
$ gcc -S main.c 
$ gcc -c main.s
$ gcc main.o

使用 -v 选项可以了解详细的编译过程:

1
$ gcc -v main.c -o main

gcc 只是一个外壳而不是真正的编译器, 真正的 C 编译器是 /usr/lib/gcc/x86_64-pc-linux-gnu/12.2.0/cc1 (每个 Linux 版本的位置会有些许出入), gcc 调用 C 编译器, 汇编器和链接器完成 C 代码的编译链接工作.

查看编译器提供的目标文件里都有什么 (这里查看符号表):

1
$ nm /usr/lib/crt1.o

或:

1
$ readelf -s crt1.o

符号表的每一行由地址, 符号类型和符号名组成.

符号类型用一个字母表示, 大写字母是全局符号, 小写字母是局部符号, 具体含义可查 man 1 nm.

T 表示 Text, U 表示 Undefined.

C 程序的入口 表示 Undefined.

C 程序的入口其实是 crt1.o 提供的 _start, 它首先做一些初始化工作, 然后调用我们编写的 main 函数.

用反汇编来查看一下 _start 的定义:

1
$ objdump -d /usr/lib/crt1.o

一个目标文件中引用了某个符号, 链接器在另一个目标文件中找到这个符号的定义并确定它的地址, 这个过程叫做 符号解析 (Symble Resolution)

符号解析和重定位都是通过修改指令中的地址实现的,

链接器也是一种编辑器, 其编辑的是目标文件, 所以链接器叫做 Link Editor.

就是把其他目标文件链接到 crt1.o 中.

最后一个压栈的参数 push $0x80483c4 正是 main 函数的地址, __libc_start_main 在做完初始化工作之后会根据这个参数调用 main 函数.

由于 main 函数是被启动例程 (startup Route, 也就是 _start) 调用的, 所以从 main 函数 return 时就返回到启动例程中, main 函数的返回值就被启动例程得到, 如果将启动例程表示成等价的 C 代码 (实际上启动例程一般是直接用汇编语言编写的), 则它调用 main 函数的形式是:

1
exit(main(argc, argv));

也就是说, 启动例程得到 main 函数的返回值后, 会立即用它做参数调用 exit 函数.

使用 exit 函数要包含 stdlib.h 头文件.

18.3 变量的存储布局

字符串字面值是只读, 相当于在全局作用域定义了一个 const 数组.

const 变量在定义时必须初始化, 因为只有在初始化时才有机会给它一个值.

链接器不会对局部符号做符号解析.

一个函数定义前面也可以用 static 修饰, 表示这个函数名是局部符号.

虽然栈是从高地址向低地址增长的, 但数组总是从低地址向高地址排列.

register 关键词的作用就是, 指示编译器尽可能分配一个寄存器来保存这个变量.

C 语言中作用域的分类, 见书.

命名空间的分类 (部分):

  • struct, enum, union 的 Tag 属于一个命名空间
  • struct 和 union 的成员名属于一个命名空间
  • 其他所有标识符, 如变量名, 函数名, 宏定义, typedef 定义的类型名, enum 成员等都属于一个命名空间.

链接属性.

存储类修饰符 (Storage Class Specifier):

  • auto, 用它修饰的变量在函数调用时自动在栈上分配存储空间, 函数返回时自动释放, auto 可以省略不写, 且不能修饰文件作用域的变量.
  • register, 也不能修饰文件作用域的变量.
  • typedef, 其声明的看法如下, 先去掉 typedef 把它看成变量声明, 看这个变量是什么类型的, 则 typedef 就给什么类型起一个类型名

typedef 的例子:

1
typedef char buffer[100];

先去掉 typedef, 就变成:

1
char buffer[100];

buffer 的类型是 char [100] 即长度为 100 的字符数组, 那么 buffer 就是长度为 100 的字符数组的别名.

变量生存期 (Storage Duration, 或者 Lifetime) 分为:

  • 静态生存期. 在程序开始执行时分配内存和初始化, 此后便一直存在直到程序结束, 通常位于 .rodata, .data, 或 .bss 段.
  • 自动生存期
  • 动态生存期

18.4 结构体和联合体

结构体的各成员并不是一个紧挨着一个排列的, 中间有空隙, 称为填充 (Padding).

大多数计算机体系结构对于访问内存的指令是有限制的, 在 32 位平台上, 如果一条指令访问 4 个字节, 起始内存地址应该是 4 的整数倍, 如果一条指令访问两个字节, 起始内存地址应该是 2 的整数倍, 这被称为对齐 (Alignment), 访问一个字节的指令没有对齐要求.

没有对齐可能会导致无法访问内存或效率更低.

要注意最后一个元素的填充. 其和第一个元素的需求相关.

合理设计结构体各成员的排列顺序可以节省存储空间.

一个联合体的各个成员占用相同的内存空间, 联合体的长度等于其中最长成员的长度.

联合体如果用 Initializer 初始化, 则只初始化它的第一个成员. 如

1
u = {{ 1, 5, 513, 17, 129, 0x81 }} ;

使用 C99 的 Memberwise 初始化语法, 则可以初始化联合体的任意一个成员:

1
demo_type = { .byte = {0x1b, 0x60, 0x24, 0x10, 0x81, 0, 0, 0} };

ABI (Application Binary Interface), 应用程序二进制接口规范.

如果两个平台具有相同的体系结构, 并且遵循相同的 ABI, 就可以保证一个平台上的二进制程序直接复制到另一个平台就能运行.

18.5 C 内联汇编

gcc 提供了一种扩展语法可以在 C 代码中使用内联汇编 (Inline Assembly). 最简单的格式:

1
__asm__("assembly code")

这里的 __ 是两个下划线.

1
__asm__("nop");

这里 nop 让 CPU 空转一个指令执行周期.

执行多条汇编指令, 应该用 \n\t 将各条指令分割开:

1
2
3
__asm__("movl $1, %eax\n\t"
"movl $4, %ebx\n\t"
"int $0x80");

通常内联汇编需要和 C 代码中的变量建立关联, 要用到完整的内联汇编格式:

1
2
3
4
5
__asm__(assembler template
: output operands /* option */
: input operands /* option */
: list of clobbered registers /* option */
);

第一部分是汇编指令.

第二部分和第三部分是约束条件, 第二部分告诉编译器汇编指令的运算结果要输出到哪些 C 语言操作数中, 这些操作数应该是左值表达式. 第三部分告诉编译器汇编指令需要从哪些 C 语言操作数中获得输入.

第四部分是在汇编指令中被修改的寄存器列表. 告诉编译器哪些寄存器的值在执行这条 __asm__ 语句时会改变. 可选部分未填写就只写冒号.

gcc 扩展语法可以参考 <<GCC online documentation>>

18.6 volatile 限定符

编译器在优化时, 可能把不该优化的部分给优化了, 为了避免这种情况, 用 volatile 限定符修饰变量, 就是告诉编译器, 即使在编译时指定了优化选项, 每次读取这个变量时仍然要从内存读取, 每次写这个变量也仍要写会内存.

在为调试而编译时不要指定优化选项.

通常, 有 Cache 的平台都有办法对某一段地址范围禁用 Cache.

MMU 不仅要做地址转换和访问权限检查, 也要配合 Cache 工作.

除了设备寄存器需要用 volatile 限定之外, 当一个全局变量被同一进程中的多个控制流程访问时也要用 volatile 限定.

第19章 链接详解

书中内容: putchar 是 libc 的库函数, 在可执行文件 main 中仍然是未定义的, 要在程序运行时做动态链接.

实际上链接过程是由一个链接脚本 (Linker Script) 控制的, 链接脚本决定了给每个段分配什么地址, 如何对齐, 哪个段在前, 哪个段在后, 哪些段合并到同一个 Segment. 另外链接脚本还要把一些特殊地址定义成符号.

如果用 ld 进行链接时没有通过 -T 选项指定链接脚本, 则使用 ld 的默认链接脚本, 默认链接脚本可以用 ld --verbose 命令查看.

脚本的具体解析见书. 推荐参考为 <<GNU Binutils Documentation>>.

在 gdb 命令中指定某个 .c 文件中的某一行或某个函数, 可以用 “文件名:行号” 或 “文件名:函数名” 的语法:

1
(gdb) b stack.c:10

19.2 定义和声明

19.2.1 extern 和 static 关键字

凡是被多次声明的变量或函数, 必须有且只有一个声明是定义, 如果有多个定义, 或者一个定义都没有, 链接器就无法完成链接.

变量声明和函数声明有一点不同, 函数声明的 extern 关键词可以省略, 而变量声明如果不写 extern 则含义不同.

static 关键词可以实现对模块内部的保护, 也是一种封装.

19.2.2 头文件

系统的头文件目录的顺序查找, 在 archlinux 上为 /usr/local/include, /usr/lib/gcc/x86_64-pc-linux-gnu/12.2.0/include/, /usr/include.

-I 选项告诉 gcc 头文件要到哪里去找.

1
2
3
#ifndef STACK_H
#define STACK_H
#endif

这种保护头文件的写法称为 Header Guard.

重复包含头文件的问题:

  • 预处理和编译的速度变慢
  • 可能陷入死循环
  • 头文件里有些代码不允许重复出现, 如用 typedef 定义一个类型名, 在一个编译单元中只允许定义一次.

.c 文件和头文件时一般来说应遵循以下规则:

  • .c 文件中可以有变量或函数定义, 而 .h 文件中应该只有变量或函数声明而没有定义.
  • 不要把一个 .c 文件包含到另一个 .c 文件中

19.2.3 定义和声明的详细规则

在块作用域中不允许用 static 关键字声明函数.

Tentative Definition 是指, 一个变量声明具有文件作用域, 没有初始化, 也没有用 Storage Class 关键字修饰, 或者用 static 关键字修饰, 那么编译器认为这个变量是在该编译单元中定义的, 但初始值特定, 然后继续编译下面的代码, 到整个单元编译结束时如果没有遇到这个变量带初始化的定义, 就将其初始化为 0.

C99 对 Tentative Definition 的处理是在编译一个单元时进行的, 而 gcc 是推迟到链接时才做.

19.3 静态库

1
2
$ ar rs libstack.a stack.o push.o pop.o is_empty.o
ar: creating libstak.a

库文件名都是以 lib 开头的, 静态库以 .a 作为后缀, 表示 Archive.

ar 命令类似于 tar 命令, 也是用来打包的, 但是把目标文件打包成静态库的格式只能用 ar 命令而不能用 tar 命令.

r 选项 (replacement) 表示将后面的目标文件列表添加到文件包 libstack.a 中, 如果 libstack.a 不存在就创建它, 如果 libstack.a 中已有同名的目标文件就替换成新的.

s 选项表示为静态库创建索引, 这个索引被链接器使用, ranlib 命令也可以为静态库创建索引, 以下等价:

1
2
$ ar r libstack.a stack.o push.o pop.o is_empty.o
$ ranlib libstack.a

将 libstack.a 和 main.c 编译链接在一起:

1
gcc main.c -L. -lstack -Istack -o main

-L 选项告诉编译器去哪里找需要的库文件. -I 选项告诉编译器去哪里照头文件.

可以用 -print-search-dirs 选项来查看编译器默认查找的目录.

gcc 在链接时优先考虑共享库, 其次才是静态库. 如果希望 gcc 之考虑静态库, 可以指定 -static 选项.

链接共享库和链接静态库的区别:

  • 链接共享库时, 链接器只是确认了可执行文件中应用的某些符号是否有定义, 并没有最终确定这些符号的地址, 这些符号在可执行文件中仍然是未定义符号, 要在运行时做动态链接
  • 链接静态库时, 链接器会把静态库中的目标文件取出来和可执行文件真正链接在一起.

使用静态库的一个好处: 链接器从静态库中只取出需要的目标文件来做链接, 不需要的目标文件可以不链接.

另一个好处是: 只需写一个库文件名, 而不需要写一长串目标文件名.

19.4 共享库

19.4.1 编译, 链接. 运行

组成共享库的目标文件和一般的目标文件有所不同, 在编译时要加 -fPIC 选项:

1
$ gcc -c -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c

-f 后面跟一些编译选项, PIC 是其中一种, 表示生成位置无关代码 (Position Independent Code).

反汇编语句中一般会有 sub $0x4,%esp 这一句, 这是在开辟栈空间, 这里的 $0x4 表示有用了 4 字节的局部变量, 即, 移动栈顶指针 esp, 给被调用函数开辟栈帧. 后面的一句 mov 0x8(%ebp),%eax 是把传进来的参数值赋给 eax.

编译生成共享库:

1
$ gcc -shared -o libstack.so stack.o push.o pop.o is_empty.o 

使用 -shared 选项, 并用 -o 指定文件名.

共享库中各段的加载地址并没有定死, 可以加载到任意位置, 因为指令中的地址都是相对于 ebx的.

lea 指令算出第一个操作书所代表的地址, 但不访问内存, 而是直接把这个地址传给第二个操作数.

1
lea 0x1(%eax),%edx

程序编译时寻找共享库的路径和运行时的寻找路径不同.

利用 ldd 命令可以查看可执行文件依赖于哪些共享库. ldd 命令会模拟运行一边程序, 在运行过程中做动态链接, 从而得知这个程序依赖于哪些共享库以及这些共享库都在什么路径下.

gcc 调用 ld 做链接时用 -dynamic-linker /lib/ld-linux.so.2 选项指定动态链接器的路径, 动态链接器也像其他共享库一样加载到进程的地址空间中.

共享库的路径需要在运行时由动态链接器 /lib/ld-linux.so.2 去查找.

从 ld.so(8) 中可以查到共享库路径的搜索顺序. 具体见书.

环境变量 (Environment Variable) 是进程运行时保存在内存中的一组字符串, 每个字符串都是 “key=value” 的形式, key 是变量名, value 是变量的值.

在创建进程是可以传递环境变量, 如:

1
LD_LIBRARY_PATH=/home/akaedu/testdir ./main

配置文件 /etc/ld.so.conf 保存共享库路径, 添加后需要执行 ldconfig 命令:

1
$ sudo ldconfig -v

19.4.2 函数的动态链接过程

.plt 段, PLT 是 Procedure Linkage Table 的缩写. .plt 段里保存的也是指令, 和 .text 一起合并到 Text Segment.

动态链接器利用 Globle Offset Table 的表项保存共享库中符号的绝对地址, 链接完成后, 通过 Globle Offset Table 的表项间接寻址即可访问共享库中的符号.

19.4.3 共享库的命名惯例

每个共享库有三个文件名:

  • real name
  • soname
  • linker name

真正的库文件的名字是 real name, 包含完整的共享库版本号, 如: libcap.so.1.10

soname 是符号链接的名字, 只包含共享库的主版本号. 主版本号一致即可保证库函数的接口一致, 可执行文件的 .dynamic 段只记录共享库的 soname, 动态链接器只要找到 soname 一致的共享库就可以加载它做动态链接. 如: libcap.so.1

使用共享库可以很方便地升级库文件而不需要重新编译程序.

libc 的版本编号有一点特殊, libc-2.8.90.so 的主版本号是 6 而不是 2 或 2.8

linker name 仅在编译链接时使用, gcc 的 -L 选项应该指定 linker name 所在的目录. 有的 linker name 是库文件的一个符号链接, 有的 linker name 是一段链接脚本.

编译器只认 linker name.

19.5 虚拟内存管理

/proc 目录下的文件并不是真正的磁盘文件, 而是内核虚拟出来的, 当前每个运行的进程在 /proc 目录下都有一个子目录, 目录名就是进程的 id. /proc/[id]/maps 文件是进程地址空间的信息.

x86 平台的虚拟地址空间是 0x00000000~0xffffffff, 大致上前 3GB (0x00000000~0xbfffffff) 是用户空间, 后 1GB (0xc0000000~0xffffffff) 是内核空间.

0x09283000~0x09497000 不是从磁盘文件加载到内存的, 这段空间称为 (Heap), 从 0x7ca8000 开始是共享库的映射空间, 每个共享库也分成几个 Segment, 每个 Segment 有不同的访问权限.

堆空间的地址上限 (0x9479000) 称为 Break, 堆空间要向高地址增长就要抬高 Break, 映射新的虚拟内存页面到物理内存, 这是通过系统调用 brk 实现的, malloc 函数也是调用 brk 向内核请求分配内存.

0xbfac5000~0bfada000 是栈空间, 其中高地址部分保存这进程的环境变量和命令行参数, 低地址部分是栈空间.

操作系统的虚拟内存管理机制的作用 (具体见书):

  • 控制物理内存的访问权限, 物理内存本身是不限制访问的.
  • 使每个进程有独立的地址空间, 不同进程中相同的 VA (Virtual Address) 被 MMU 映射到不同的 PA (Physic Address).
  • VA 到 PA 的映射会给分配和释放内存带来方便, 物理地址不连续的几块内存可以映射成虚拟地址连续的一块内存.
  • 一个系统如果同时运行着很多进程, 为各进程分配的内存之和可能会大于实际可用的物理内存, 虚拟内存管理机制使这种情况下各进程仍然能够正常运行.

每个进程都有自己的一套 VA 到 PA 的映射表, 在一个进程中通过 VA 只能访问到属于自己的物理页面.

两个进程的共享库加载地址并不相同, 共享库加载地址是在动态链接时确定的. 使用共享库可以节省物理内存, 比如 libc, 系统中几乎所有的进程都将 libc 映射到自己的进程空间, 而 libc 的只读部分在物理内存中只需要存在一份就可以被所有进程共享, 这就是 “共享库” 这个名称的由来.

在磁盘上开一个分区或者建一个文件专门用于临时保存虚拟内存页面的数据, 这被称为交换设备 (Swap Device).

第20章 预处理

20.1 预处理的步骤

预处理器把每个逻辑代码划分成 Token 和空白字符, 这时 Token 称为预处理 Token. 在划分 Token 时要遵循最长匹配原则.

在 Token 中识别出宏定义和预处理指示, 如果遇到宏定义则做宏展开, 遇到预处理指示则作出相应的与处理动作.

此处之记录了部分步骤.

找出字符常量或字符串字面值中的转移序列, 用相应的字节来替换它, 比如把 \n 替换成 0x0a.

把相邻的字符串字面值连接起来.

把一个预处理指示写成多行要用 \ 续行, 因为根据定义, 一条预处理指示只能由一个逻辑代码行组成, 而把 C 代码写成多行则不必用 \ 续行, 因为换行在 C 代码中只不过是一种空白字符.

20.2 宏定义

20.2.1 函数式宏定义

变量式宏定义 (Object-like Macro), 如:

  • #define N 20
  • #define STR "hello world"

函数式宏定义 (Function-like Macro), 如:

1
#define MAX(a, b) ((a)>(b)?(a):(b))

用 gcc 的 -E 选项或者用 cpp 处理.

函数式宏定义和真正的函数调用区别:

  • 函数式定义宏的参数没有类型, 预处理器只进行形式上的替换, 而不做参数类型检查
  • 调用真正的函数和调用函数式定义宏的代码编译生成的指令不同. 使用函数式定义宏编译生成的目标文件会比较大.

一般来说, 简短的, 被频繁调用的函数适合用函数式宏定义来代替实现.

函数式定义经常写成以下形式 (内核代码 include/linux/pm.h):

1
2
3
4
5
#define device_init_wekeup(dev,val) \
do { \
device_can_wekeup(dev) = !!(var); \
device_set_wekeup_enable(dev,val); \
} while(0)

20.2.2 内联函数

C99 引入的新关键字 inline, 用于定义内联函数 (Inline Function).

如:

1
2
3
4
5
6
7
static inline void down_read(struct rw_semaphore *sem)
{
might_sleep();
rwsemtrace(sem,"Entering down_read");
__down_read(sem);
rwsemtrace(sem,"Leaving down_read");
}

inline 关键字告诉编译器, 这个函数的调用要尽可能快, 可以当普通的函数调用实现, 也可以用宏展开的方法实现.

20.2.3 #, ## 运算符和可变参数

有:

1
2
#define PSQR(X) printf("The square of X is %d.\n", ((X)*(X)));
PSQR(8)

这样的输出结果为:

1
The square of X is 64.

如果使用 #:

1
2
#define PSQR(X) printf("The square of #X is %d.\n", ((X)*(X)));
PSQR(8)

则输出结果为:

1
The square of 8 is 64.

将形参对应的参数替换为了相应的字符串.

## 用于把前后两个 Token 连接成一个预处理 Token.

如:

1
2
#define CONCAT(a, b) a##b
CONCAT(con, cat)

的结果就是 concat.

函数式宏定义也可以带可变参数, 同样是在参数列表中用 ... 表示可变参数.

如:

1
#define showlist(...) printf(#__VA_ARGS__)

宏定义中可变参数部分用 __VA_ARGS__ 表示, 在宏展开时和 ... 对应的几个实参可以看成一个实参来替换掉 __VA_ARGS__.

调用函数式宏定义允许传空参数.

在定义时带三个参数, 在调用时也必须给它传三个参数, 空参数的位置可以空着, 但必须给足三个参数:

1
2
#define FOO(a, b, c) a##b##c
FOO(,,3)

20.2.4 #undef 预处理指示

如果在一个编译单元中重复定义一个宏, C 语言规定这些重复的宏定义必须一模一样.

可以用 #undef 取消原来定义的宏.

20.2.5 宏展开的步骤

20.3 条件预处理指示

条件预处理指示也常用于源代码的配置管理:

1
2
3
4
5
6
7
#if MACHINE == 68000
int x;
#elif MACHINE == 8086
long x;
#else
#error UNKOWN TARGET MACHINE
#endif

#ifdef#if 可以嵌套使用, 但预处理指示通常都顶头写不缩进.

gcc 的 -D 选项可以定义宏.

20.4 其他预处理特性

#pragma 提供的扩展特性是由编译器自己规定的.

C 标准规定了几个特殊的宏, 不需要定义即可使用:

  • __FILE__, 展开成当前源文件的文件名, 是一个字符串
  • __LINE__, 展开成当前代码行的行号, 是一个整数

C 标准规定, assert 应该实现成函数式宏定义而不是一个真正的函数, 并且 assert(test) 这个表达式应该是 void 类型.

((void)0) 表示一个 void 类型的值.

第21章 Makefile 基础

对于 Makefile 中每个以 Tab 开头的命令, make 会启动一个 Shell 进程去执行它.

在命令前面加 @- 的效果:

  • @ (At Sign), 不显示命令本身而只显示它的输出结果
  • - (Hyphen), 即使命令出错, make 也会继续执行后续任务

通常 rmmkdir 命令前面要加上 - 字符, 因为 rm 要删除的文件可能不存在, mkdir 要创建的文件可能已经存在.

.PHONY 声明一个伪目标, 避免和文件名重名.

1
2
3
clean:
-rm main *.o
.PHONY: clean

写在后面也有效.

21.2 隐含规则和模式规则

可以把一个目标拆开写成多条规则, 其中只有一条规则允许有文件列表.

如果一个目标在 Makefile 中的所有规则都没有命令列表, make 会尝试在内建的隐含规则 (Implicit Rule) 数据库中查找适用的规则.

make 的隐含规则数据库可以用 make -p 命令打印出来.

Makefile 变量就像环境变量或者 C 语言的宏定义一样, 代表一串字符 (或者空字符串), 并且按照惯例通常用大写字母加下划线命名.

变量名加上括号和 $ (Dollar Sign) 表示将变量值展开:

1
$(VAR)

% 在 Makefile 语法中表示 0 个或多个匹配项.

$@$< 这两个特殊变量:

  • $@, 取值是当前规则中的目标
  • $<, 取值是当前规则中的第一个条件

%.o: %.c 是模式规则 (Pattern Rule).

多目标:

1
2
target1 target2: prerequisitel1 prerequisitel2
command $< -o $@

相当于:

1
2
3
4
5
6
target1: prerequisitel1 prerequisitel2
command prerequisitel1 -o target1

target2: prerequisitel1 prerequisitel2
command prerequisitel1 -o target2

make 处理 Makefile 的过程分为两个阶段:

  • 根据规则建立以来关系图
  • 根据依赖关系图选择适当的规则执行

通常把 CFLAGS 定义成一些编译选项.

通常把 CPPFLAGS 定义成一些预处理选项.

:= 操作符表示, 前面使用定义的变量不能使用这个变量, 防止死循环.

造空格:

1
space := $(nullstring) # end of the line

空格为 # 前面那一个.

?= 操作符.

常见的自动变量:

  • $@, 表示规则中的目标
  • $<, 表示规则中的第一个条件
  • $?, 表示规则中所有比目标新的条件, 组成一个列表, 用空格分隔
  • $<, 表示规则中的所有条件, 组成一个列表, 以空格分隔, 如果这个列表中有重复的项则将其消除

: 之前的是目标, 之后的是条件.

变量的命名规则见书.

21.4 自动处理头文件的依赖

gcc 的 -M 选项自动分析目标文件和源文件的依赖关系, 以 Makefile 规则的格式输出:

1
$ gcc -M main.c

-MM 选项只会输出自己定义的头文件.

GNU make 官方手册.

include $(sources:.c=.d)

21.5 常用的 make 命令行选项

-n, 只打印要执行的命令, 而不会真的执行命令.

-C, 切换到另一个目录下执行那个目录下的 Makefile, 编译完成后仍退回先前的目录.

可以使用 =:= 定义变量.

-e, 用环境变量覆盖 Makefile 中定义的变量. 但是 make 命令行选项中定义的变量优先级最高.

第22章 指针

22.1 指针的基本概念

i 的地址来初始化一个指针并没有错, 因为 i 的地址是在编译链接时确定的, 不需要到运行时才知道, &i 是常量表达式.

指针变量的大小和平台有关, 若是 32 位的虚拟地址, 则指针的大小是 32 位, 若是 64 位, 则指针为 64 位.

如果把多个指针变量放在一起声明, 每个变量名前面都要有 *:

1
int *a, *b;

比较好的写法是将 * 和变量名写在一起.

指针有时称为变量的引用 (Reference), 所以根据指针找到变量称为 Dereference.

用一个指针给另一个指针赋值时, 两个指针必须是同一类型.

int *pichar *pi 的区别在与: 在 Dereference 时可以访问的字节大小, 前者为 4 个字节, 后者为 1 个字节.

为了避免出现野指针, 在定义指针变量时就应该明确地给它赋值, 或把它初始化为 NULL.

NULL 在 C 标准库的头文件 stddef.h 中定义:

1
#define NULL ((void *)0)

指针也是一种标量类型, 可以用 () 运算符做强制类型转换, 其他标量类型可以转换成指针类型, 指针类型也可以转换成其他标量类型.

((void *)0) 这个指针指向 0 地址, 称为空指针. 也就是说把 0 这个标量转换为 void * 类型, 其值就被当作是地址.

操作系统不会把任何数据保存在地址 0 及其附近, 也不会把 0~0xfff 的页面映射到物理内存. 所以任何对 0 地址的访问一定会引发段错误. 也就是说对 NULL 指针的引用也会报错. 试了一下, 在编译时不会报错, 在运行时会有 Segmentation fault (core dumped).

void * 类型是一种通用指针, ANSI 在把 C 语言标准化时引入的 void * 类型, 规定 void * 指针与其他类型的指针之间可以隐式转换, 而不必用 () 运算符强制转换.

void * 指针不能直接 Dereference, 必须要先转换成别的类型的指针再做 Dereference. 根据前面可以知道, 这里的 void 用于判断在 Dereference 时可以访问的内存大小, 但是 void 大小未知, 所以不能直接 Dereference.

22.2 指针类型的参数和返回值

22.3 指针与数组

后缀运算符的优先级高于单目运算符, 所以 &a[0] 是取 a[0] 的地址而不是取 a 的地址.

事实上, E1[E2] 这种写法和 (*((E1) + (E2))) 是等价的.

其实对于 *[] 运算符来说, 数组类型和指针是统一的.

C 语言规定, 只有指向同一个数组中的元素的指针之间下昂胡比较才有意义, 否则都是 Undefined.

指针相减的结果是 ptrdiff_t 类型, 这个类型名在 stddef.h 中定义, 是一种有符号整型.

数组类型做左值时表示整个数组的存储空间. 也就是说 a[1] 此时不是 *(a + 1) (这里的 * 表示这是一个指针类型), 而是 *(*(a + 1)), 即在这个地址上存的值.

函数原型中参数可以写成:

1
2
3
4
void func(int a[10])
{
...
}

等价于:

1
2
3
4
void func(int *a)
{
...
}

或:

1
2
3
4
void func(int a[])
{
...
}

第一个方括号中的数字可以不写.

22.4 指针与 const 限定符

1
2
const int *a;
int const *a;

两者等价, 表示 a 是一个指向 const int 型的指针.

1
int * const a;

表示 a 是一个指向 intconst 指针.

良好的编程习惯应该尽可能多使用 const 限定符.

gcc 把字符串字面值分配在 .rodata 段, 在运行时改写会出现段错误, 所以字符串字面值做右值时最好理解成 const char* 型.

22.5 指针与结构体

(*p).c(*p).num 可以写成 p->cp->num.

22.6 指向指针的指针与指针数组

int *a[10] 这种就是 指针数组 , 后缀运算符的优先级更高, 所以 a 首先是一个数组.

char *argv[] 指向的命令行参数后一个指针是 NULL.

如果给程序建立符号链接, 然后通过符号链接运行这个程序, 就可以得到不同的 argv[0].

关于 busybox, 这是一个可执行文件, 安装时会将 busybox 程序复制到嵌入式系统的 /bin 目录下, 同时在 /bin, /sbin, /usr/bin, /usr/sbin 等目录下创建很多指向 /bin/busybox 的符号链接, 命名为 cp, ls, mv, ifconfig 等, 不管执行哪个命令其实最终都在执行 /bin/busybox, 它会根据 argv[0] 来扮演不同的命令.

在 gdb 调试时加入命令行参数, 可以在 run 或 start 命令之后加入, 也可以用 set args 命令设置命令行参数之后再调用 run 或 start 运行程序.

指针的分析从右往左.

22.7 指向数组的指针与多维数组

指向数组的指针:

1
int (*a)[10] = NULL;

22.8 函数类型和函数指针

在 C 语言中, 函数也是一种类型, 可以定义指向函数的指针.

函数指针的内存单元里存放的是函数的入口地址 (位于 .text 段).

分析 void (*f)(const char *), f 首先跟 * 结合在一起, 因此是一个指针. (*f) 外面是一个函数原型的格式, 参数是 const char *, 返回值是 void.

函数类型和数组类型相似, 做右值使用时自动转换成函数指针类型.

数组取下标运算符 [] 要求操作数是指针类型. a[1] 等价于 *(a+1), 如果 a 是数组类型则要自动转换成指向首元素的指针类型.

要区分函数类型和函数指针类型.

以下为定义函数类型:

1
typedef int fn(void);

以下为定义函数指针:

1
typedef int (*fp)(void);

22.9 不完全类型和复杂声明

C 语言的类型分为函数类型, 对象类型和不完全类型三大类. 对象类型又分为标量类型和非标量类型. 不完全类型是暂时没有完全定义好的类型, 编译器不知道这种类型该占几个字节的存储空间. 如:

1
2
3
struct s;
union u;
char str[];

在分析复杂声明时, 要借助 typedef 把复杂声明分解成几种基本形式.

  • T *p; p 是指向 T 类型的指针
  • T a[]; a 是由 T 类型的元素组成的数组, 但有一个例外, 如果 a 是函数的形参, 则相当于 T *a
  • T1 f(T2, T3...); f 是一个函数, 参数类型是 T2, T3 等, 返回值类型是 T1

书中例子: int (*(*fp)(void *))[10];.

我们首先找到 name, 也就是 fp, 这里它前面有一个 *, 即 *fp, 说明这一长串最终肯定是一个指针, 但我们看到返回值极其复杂不便分析, 由此暂定为 T1, 因此可以简化为:

1
2
typedef int (*T1(void *))[10];
T1 *fp;

相当于 *fp 被替换为了 T1.

接着又找 name, 也就是 T1, 其先和 (void *) 结合 (后缀运算符), 因此 T1 是一个函数类型, 仍然要掩盖返回值的复杂, 因此定为 T2:

1
2
3
typedef int (*T2)[10];
typedef T2 T1(void *);
T1 *fp;

又看这里第一个 typedef, 这里 T2 先与 * 结合, 说明其为指针, 指向 T3 类型:

1
2
3
4
typedef int T3[10]
typedef T3 *T2; // T2 表示指向 T3 类型的指针
typedef T2 T1(void *)
T1 *fp;

综合一下就是, fp 是一个指向一个函数类型(参数为 void * 返回值是指向含有 10 个元素的数组的指针)的指针.

总结经验, 找 name 很重要, 通过优先级找到符合基本类型的条件后开始用 typedef 替换.

实例练习, char (*(*x(void))[3])(void)

首先 name 为 x, 其和 (void) 结合, 表明其为函数类型, 返回值为 T1:

1
2
typedef char (*(*T1)[3])(void);
T1 x(void);

这里 T1* 结合, 表明其为一个指针, 指向类型 T2:

1
2
3
typedef char (*T2[3])(void);
typedef T2 *T1;
T1 x(void)

T2[3] 先结合, 表明其为含有 3 个元素的数组, 再和前面的 * 结合, 表明是一个指针数组, 指向类型为 T3:

1
2
3
4
typedef char T3(void);
typedef T3 *T2[3];
typedef T2 *T1;
T1 x(void);

最后可以看出 T3 是一个函数类型, 参数为 void 类型, 返回值是 char 类型.

递归下降法解析复杂声明.

以下记录自 K&R 一书 5.12 节:

声明符 dcl 就是前面可能带多个 *direct-ctl.

direct-ctl 可以是 name, 由一对圆括号扩起来的 dcl, 后面跟有一对圆括号的 direct-dcl, 后面跟有用方括号括起来表示可选长度的 direct-dcl.

两个函数相互递归调用.

第23章 函数接口

函数接口的描述, 函数名, 参数, 返回值.

23.1.1 strcpy 与 strncpy

书中建议多查阅 man page.

确保 dest 以 '\0' 结尾:

1
2
3
char buf[10];
strncpy(buf, "hello word", sizeof(buf));
buf[sizeof(buf)-1] = '\0';

函数的 Man Page 都有一部分专门讲返回值.

CONFORMING TO 部分描述了这个函数是遵照那些标准实现的.

NOTES 部分给出一些提示信息.

BUGS 部分说明了使用这些函数可能引起的 Bug.

缓冲区溢出问题可能被利用, 推荐资料 “Smashing The Stack For Fun And Profit”.

23.1.2 malloc 与 free

位于 stdlib 中.

每个进程都有一个堆空间, C 标准库函数 malloc 可以在堆空间动态分配内存, 它的底层通过 brk 系统调用向操作系统申请内存.

malloc 函数保证它返回的指针指向的地址满足系统的对其要求.

动态分配的内存用玩之后可以调用 free 函数释放掉.

编写程序要规范, malloc 之后应该判断是否成功.

free(p); 之后, p 所指的内存空间是归还了, 但是 p 的值并没有变, 为了避免出现野指针, 因该在 free(p); 之后, 手动设置 p = NULL.

malloc(0) 是合法的, 返回一个非 NULL 指针.

23.2 传入参数与传出参数

把指针所指向的数据传给函数使用被称为传入参数.

由函数填充指针所指的内存空间, 传回给调用者使用, 被称为传出参数.

既是传入参数又是传出参数的被称为 Value-result 参数.

很多系统函数对于指针参数是 NULL 的情况有特殊规定, 如果传入参数是 NULL 表示取默认值, 也可能表示不做特别处理. 传出参数是 NULL 表示调用者不需要传出值.

23.3 两层指针的参数

23.4 返回值是指针的情况

23.5 回调函数

如果参数是一个函数指针, 调用者可以传递一个函数的地址给实现者, 即调用者提供一个函数但自己不去调用它, 而是让实现者去调用它, 这被称为回调函数 (Callback Function).

这里有使用 void * 指针.

回调函数的一个典型应用就是实现类似 C++ 的泛型算法 (Generics Algorithm).

异步调用也是会掉函数的一种典型用法, 调用者首先将回调函数传给实现者, 实现者记住这个函数, 这称为 注册 一个回调函数, 然后当某个事件发生时实现者再调用先前注册过的函数.

操作函数的函数称为高阶函数 (High-order Function).

23.6 可变参数

要处理可变参数, 需要用到 C 标准库的 va_list 类型和 va_start, va_arg, va_end 宏, 都定义在 stdarg.h 头文件中.

第24章 C 标准库

Linux 平台提供的 C 标准库包括:

  • 一组头文件, 定义了很多类型和宏, 声明了很多库函数和全局变量
  • 一组库文件, 提供了库函数和全局变量的定义

24.1 字符串操作函数

程序按功能划分可分为数值计算, 符号处理和 I/O 操作三类. 符号处理程序占相当大的比例.

24.1.1 给字符串赋初值

1
2
3
#include <string.h>

void *memset(void *s, int c, size_t n);

通常调用 memset 时传给 c 的值是 0, 把一块内存区清零.

C 标准说 memset 函数要把参数 c 转换成 unsigned char 型再填充到每个字节中.

ctype.h 中声明的字符处理函数 (如判断是否为数字之类). C 标准规定传给这些函数的参数可以是 EOF, EOF 是一个特殊的值, 它的类型是 int 而不是 char, 如果转换成 char 型会丢失信息.

ctype

24.1.2 取字符串的长度

strlen.

返回的长度不包括 '\0' 字符.

24.1.2 拷贝字符串

strcpy 和 strncpy 函数, 拷贝以 NULL 结尾的字符串.

memcpy 和 memmove 函数, 拷贝固定的字节数.

以 str 开头的函数操作以 Null 结尾的字符串, 而以 mem 开头的函数则不关心 '\0' 字符, 或者说这些函数只是把参数看做是 n 个字节, 并不看做字符串, 因此参数的指针类型是 void * 而非 char *.

memmove 能够拷贝重叠区域.


Learn C in Linux
http://example.com/2022/09/12/CinLinux/
作者
Jie
发布于
2022年9月12日
许可协议