第零讲 课前热身

数据量单位

位(bit): 是最小存储单位,每个位可以存一个二进制码值得 0 或 1 字节(byte): 通常是由八个位组成的一个存储单元,是计算机最小的可寻址单位 位(word):是处理器使用的自然数据单位,处理器单个指令可以操作的最大的内存块 一般为一个字节大小

汇编语言

汇编语言(Assembly Language)是一种低级编程语言,和 CPU 架构相关。

汇编语言使用助记符(Mnemonic)来表示每个低级的机器指令,不同的汇编指令可以使用不同的参数形式,以 mov 指令举例,有如下三种形式:

  • mov r/m, r
  • mov r, r/m
  • mov r/m, imm

指令参数中,r 表示 寄存器(register),m 表示内存(memory),imm 表示立即数(immediate)。 指令mov ebx 1的含义是将立即数 1 存到寄存器 ebx 中。 在x86指令集中,受限于 CPU 实现的复杂的,不存在将两个内存地址同时作为 src 和 dest 参数的指令。

指令mov ebx 1对应的机器码为 bb 01 00 00 00 机器码由 OpCode 和 Immediata Data 两部分组成,OpCode 占用一个字节,mov 对应的指令是 0xb8,寄存器对应阈值为 0x3, 组合在一起即为 0xbb;由于 mov 传送 32 位数,所以立即数单独占用 4 个字节 (使用小端序)

寄存器

寄存器可以简单理解为 CPU 提供的一组由位于芯片上的高速存储器硬件,拥有最快的数据访问速度和最低的延迟

寄存器通常分为如下几类:

  • 统一寄存器:一般存放程序运行过程中产生的临时数据
  • 状态寄存器:一般存放之间执行结果相关的状态信息,如指令是否引起进位等
  • 系统寄存器:一般由操作系统使用,存放中断、CPU模式等信息

x86-64 架构中定义了 16 个通用寄存器,每个寄存器可以存放4个指令字(每个指令字16字节)

在汇编代码中,可以使用每个寄存器的不同别名来访问对应的低 8 位,低 16 位,低 32 位,以及完整的 64 位数据

register_alias

需要注意的是当重写寄存器的低32位数据时,高32位数据会被置零,可以使用如下代码进行对比。

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(void) {
  register long num asm("rax") = 0x100000000;
  asm("movl $0x1, %eax");
  // asm("movw $0x1, %ax");
  printf("%ld\n", num);
  return 0;
}

第一讲 一个C程序的完整生命周期

C核心语法

实例程序如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

#include <stdlib.h> 
#include <stdio.h>
#include <stdint.h>
#include <assert.h>
#include <stdbool.h>

#define BOOL_TRUE 1  // 定义用到的宏常量与宏函数;
#define BOOL_FALSE 0
#define typename(x) _Generic((x), \
  unsigned short: "unsigned short int", \
  unsigned long: "unsigned long int", \
  default: "unknown")

typedef enum { Host, IP } IP_ADDR_TYPE;  // 定义枚举类型 IP_ADDR_TYPE,用于表示联合中生效的字段;
typedef struct {  // 定义结构 CONN;
  size_t id;
  uint16_t port;
  bool closed;
  IP_ADDR_TYPE addr_type;
  union {
    char host_name[256];
    char ip[24];
  };
} CONN;

inline static const char* findAddr(const CONN* pip) {  // 定义函数 findAddr,用于打印 CONN 对象的信息;
  assert(pip != NULL);  // 运行时断言,判断传入的 CONN 指针是否有效;
  return pip->addr_type == Host ? pip->host_name : pip->ip;
}

int main(int argc, char* argv[]) {  // 入口函数;
  static_assert(sizeof(CONN) <= 0x400, "the size of CONN object exceeds limit.");  // 静态断言,判断 CONN 对象的大小是否符合要求;
  const CONN conns[] = {  // 构造一个数组,包含三个 CONN 对象;
    [2] = { 1, 80, BOOL_TRUE, IP, { .ip = "127.0.0.1" } },
    [0] = { 2, 8080, BOOL_FALSE, IP, { .ip = "192.168.1.1" } },
    { 3, 8088, BOOL_FALSE, Host, { .host_name = "http://localhost/" } }
  };

  for (size_t i = 0; i < (sizeof(conns) / sizeof(CONN)); ++i) {  // 遍历上述 CONN 数组,并打印其中的内容;
    printf(
      "Port: %d\n"
      "Host/Addr: %s\n"
      "Internal type of `id` is: %s\n\n",
      conns[i].port,
      findAddr(&conns[i]),
      typename(conns[i].id)
    );
  }
  return EXIT_SUCCESS; 
}

入口函数

使用 main 函数作为入口函数,返回值为 0 表示成功。

数组

conns 即为数组

结果体和联合体

结果体中所有定义的字段对应内存连续排列 联合体中同时只有一个字段生效,分配的内存为最大字段占用内存

控制语句

指针

宏函数 typename 使用了 C11标准引入的 _Generic 关键字

断言

断言分为静态断言和动态断言,静态断言在代码编译时进行检查

函数内联

使用 inline 内联关键字,可以建议编译器将该函数的内部逻辑直接替换到函数的 调用位置处,以减少函数调用开销提升性能

C 语言编程范式

C 语言属于命令式编程(Imperative Programing),这种范式更关注计算机完成任务所执行的具体步骤;与之对的另外一种编程范式为声明式编程(Declararive Programing),这种范式更倾向于表达计算逻辑。区别实例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 命令式
#define ARR_LEN 5
int main(void) { 
  int arr[ARR_LEN] = { 1, 5, 10, 9, 0 };
  for (int i = 0; i < ARR_LEN; ++i) {
    if (arr[i] > 7) {
      // save this element somewhere else.
    }
  }
  return 0;
}


# 声明式
let arr = [1, 5, 10, 9, 0]
let result = arr.filter(n => n > 7)

编译和运行

compile

  1. 代码预处理:编译器会移除所有注释信息、处理宏指令。
  2. 编译优化:编译器优化源码,将代码编程成汇编格式。
  3. 汇编:编译器将汇编代码编译成操作系统使用的某种对象格式。
  4. 链接:编译器将依赖的对象文件进行整合,设置好程序中所有调用函数的正确地址,生成二进制执行文件。

第二讲 数据和量值

量值

量值可以粗略地分为变量(Variable)和常量(constant)

变量

C 语言变量类型占用的具体字节大小与程序运行所在的硬件体系结构紧密相关

常量

在 C 语言中,通过内联方式直接写到源码中的字面量值一般被称为常量。

使用 const 关键字修饰的变量更接近于只读变量,不具有”常量表达式“属性,因此无法用来表示定长数组大小或使用在 case 语句中。 常量表达式在编译时被求值,而只读变量在运行时才被确定

数据的存储形式

计算机内部使用 补码(Two’s-complement)存放有符号整数,使用对应的二进制来存放无符号整数,使用 IEEE-754 标准来存放浮点数。

补码

一个补码所表示的实际数值,由负权重位的值(最高位)和正权重为的值求和。 如 1101,是及对应的值为 -3(-8 + 5)。 由此也得出四位补码的最小值为 -8(1000),最大值为 7(0111)。

IEEE-754

数据的存储位置

data_store_position

应用程序被正常加载前,需要将应用程序代码机器相关依赖数据映射到内存的某个位置,这段内存称之为进程的VAS(Virtual Address Space 虚拟地址空间).

初始化的全局变量和静态变量和应用程序具有同样的生命周期,其值通常会被存放到进程VAS内的 .data 中。

局部变量存放于寄存器或者 VAS 的栈中

通过 malloc 创建的内存存放于 VAS 的堆中

未初始化的全局变量和静态变量存放于 VAS 的 .bss 中

常量会按照数据的大小和类型存放于 VAS的 .text (通常存放代码) 或 .rodata (通常存放只读数据) 中

const_data_store_position

第三讲 运算符

运算符(operator)、表达式(expression)和语句(statement)是组成 C 语言程序的三个基本的语法结构,且一般依次呈包含关系。

运算符分类

operator_class

算数、关系、为、赋值运算

这四类运算符经过编译后,可以直接对应到由目标平台上相应的机器指令组成的简单计算逻辑。

拿下图的 foo 函数举例

foo

DWORD PRT [rbp-8] 指 将寄存器 rbp 的值减去 8 得到的结果作为起始地址,然后操作一个 大小为 DWORD 的空间。intel体系中 WORD 表示 16 位,DWROD 表示 32 位,QWORD 表示 64 位。

int arithmetic = x + y;对应的汇编代码中,前两行代码为分别从栈内存中将变量 x 和 y 存入 寄存器 edx 和 eax,接着通过 add 指令计算两者之和,最后通过 mov 指令将寄存器 eax 的值移到 局部变量 arithmetic 对应的栈内存中。

FLAGS 寄存器是一组用于反映程序当前运行状态的标志寄存器,详情如下

flags_register

cmp 指令会在 CPU 内部对两个操作数进行隐式减法运算,然后设置 FLAGS寄存器状态。

逻辑运算符

以与运算符 && 举例,示例代码如下

and_operator

je 指令会判断 ZF 标志位是否为 0, 如果是则跳转到指定地址

与运算的高级编辑优化,编译器会采用 test、setne、movzx来实现, 这种方式减少了对栈内存即条件跳转指令的使用,是的程序减少了 访问内存时产生的延迟,以及由于分支预测失败导致的CPU周期延迟。

1
2
3
4
5
6
test    edi, edi  ; edi <- x.
setne   al
test    esi, esi  ; esi <- y.
setne   sil
movzx   esi, sil
and     esi, eax

成员访问运算符

address_operator

int* n_ptr = &n;对应的汇编指令中首先 lea 指令将寄存器 rbp 中的值减去 16 后,存放到 rax 寄存器,即将n在站上的地址存放到 rax 寄存器;然后将 rax 寄存器的值存到 变量 n_ptr对应的栈内存的存储位置。

int m = *n_ptr;对应的汇编指令中首先将 n_ptr的值传送到 寄存器 ra;随后将 rax 的值作为地址,将该地址上的值以 DWORD(即 int)形式传送到 eax 寄存器;最后将 eax 寄存器中的结果只传送到 变量 m 在栈内存的存储位置。

其他运算符

这里介绍 sizeof(type),示例如下:

other_operator

size_t n = sizeof(int) 的汇编指令可以看出直接将结果4存到了 n 对应的栈内存。

short f = (short) n;的汇编指令首先将变量 n 的值移到 rax 寄存器,然后将其中低 16位的数据(ax)地道 f 所在的内存趋于

第四讲 控制逻辑

表达式

表达式(expression)是由一系列运算符和操作数(operand)组成的一种语法结构。

对表达式的求值过程,实际上就是根据运算符的优先级和结合性,来对表达式和它所包含的子表达式进行递归求值的过程。

可以使用clang -Xclang -ast-dump -fsyntax-only main.c 命令得到 C 程序对应的 AST 结构

语句

语句(statement)是用来描述程序的基本构建块。

语句可以包含或者不含表达式;语句在执行时不返回任何结果;语句以分号结尾。

语句分为复合语句、表达式语句、选择语句、迭代语句、跳转语句。

1
2
3
4
5
6
7
int foo(int x, int y) {  // 复合语句;
 int sum = x + y; // 表达式语句;
 if (sum < 0) {  // 复合语句;
   sum = -sum;  // 表达式语句;
 }
 return sum;
}

选择语句

if...else 语句示例如下:

if_else_statement

switch...case语句示例如下:

switch_case_statement

迭代语句

迭代语句主要包含 do...whileforwhile 三种形式,u示例如下: do_while_statement

跳转语句

跳转语句主要包含 breakcontinuereturngoto 四种形式

第五讲 函数调用(上)

快速回顾

借用函数,可以将一个程序的实现过程拆分为多个子步骤,并以结构化的方式来构建程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <tgmath.h>
typedef struct {
int x;
int y;
} Point;
int foo(int x, int y, Point* p, int(handler)(int)) {
return handler(x + y + p->x + p->y);
}
int handler(int n) {
return sqrt(n);
}
int main(void) {
int x = 2;
int y = 3;
Point p = { .x = 10, .y = 10 };
printf("%d", foo(x, y, &p, handler));  // 5.
return 0;
} 

函数类型的参数默认以指针传递,可以省略表明指针类型的*符号

1
2
3
4

int foo(int x, int y, Point* p, int(*handler)(int)) {
  return (*handler)(x + y + p->x + p->y);
}

C 函数的调用约定

我们将编译器实现函数调用所遵循的一系列规则称之为函数的调用约定(Calling Convention)

调用约定并非 C 语言标准的一部分,不同平台有不同的标准, Unix系统使用 System V AMD64 ABI (简称 SysV)的调用约定

SysV 函数调用示例如下:

sysv_calling_convention

函数调用是通过 call 指令来完成,每个函数执行完毕后通过 ret 指令 来退出函数的执行,并转移代码执行流程到之前函数调用指令的下一条指令上

call_and_ret

参数约定

对于整形和指针类型的实参,需要分别使用寄存器 rdi、rsi、rdx、rcx、r8、r9,按函数定义是参数从左到右的顺序进行传值。如果参数超过 6 个, 则余下参数通过栈内存进行传送,多出来的参数从右到左入栈

对于浮点数,使用 xmm0 到 xmm7 共 8 个寄存器进行存储。 对于更宽的值,也可能使用 ymm 与 zmm 寄存器来代替 xmm 寄存器。

返回值传递

整数类型返回值,小于 64 位,使用 rax 寄存器(32 位的别名为 eax ),小于 128 位,使用 rax和 rdx 分别返回低 64 位和高 64 位

浮点数会使用 xmm、ymm、zmm 寄存器。

寄存器使用

对于 rbx、rbp、rsp 和 r12 ~ r15 寄存器,若函数需要使用它们, 需要使用前暂存,退出前恢复。

堆栈清理

每个函数在结束前,需要清理自身的堆栈,可以通过 leave 指令完成

其他约定

  • 函数在 call 调用前, 需要保证栈顶地址值 16 字节对齐
  • 从栈顶向上保留128 字节作为 “Red Zone”
  • 系统调用使用寄存器 rdi、rsi、rdx、r10、r8 和 r9 传递参数

Red Zone 是位于栈顶向上的一段固定长度的内存段,这块区域可以被调用函数栈中的 “叶子” 函数(即不再调取其他函数的函数)使用,这样在需要额外栈内存时,就能省去调整栈内存大小的过程。

保存函数调用信息的栈帧

我们将栈内存数据块称之为帧栈,帧栈存放有返回地址、实参 局部地址、返回值和暂存的寄存器值。

在进程内存中,栈内存是从高向低增长的,即栈底位于高地址处,栈顶位于 低地址处。

rsp 寄存器又称之为 Stack Pointer,其存放着栈顶地址,即其决定了 栈内存大小,通过减小其存储的值,就能扩大栈内存。

stack_memory

bar 函数第一行指令 push rbp 会将当前 rbp 寄存器的值存到栈中, rbp 寄存器 又称为 Frame Pointer, 通常来存储调用前的栈高度,即 rsp的旧值,以便进行帧栈寻址,并在退出前将栈中数据恢复到调用前的状态; 第二行指令mov rbp, rsp便是将 rsp 的值保存到 rbp 中; 第三行指令mov eax, 10 将结果存入 eax 寄存器; 第四行指令pop rbp 恢复 rbp 寄存器的值; 第五行指令ret 将程序的执行转移到函数调用前。

main 函数第 29 行指令sub rsp, 16减小 rsp 的值,将栈空间扩大 16 字节; 第 30、31 行指令分别将 1 和 2 存入 rbp-4 和 rbp-8 的位置; 第 34、35 行指令,借助 push, 将 8 和 7 存入 rbp-12 、 rbp-16的位置。

leave 指令会恢复 rsp的值来“清理”栈数据,并恢复 rbp的值。 等效于: mov rsp, rbp; pop rbp; 与之对应的 enter 指令等效于 push rbp; mov rpb, rsp

main_func_memory

第六讲 函数调用(下)

函数参数求值顺序

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

如上示例程序,C 函数参数的求值顺序并没有被明确规定,有的编译器按从左到右计算,有的从左到右计算。

为了程序健壮性,不要编写需要依赖特定函数参数求值顺序的代码。

尾递归调用优化(tail-call optimization)

factorial_function

函数调用过程所需数据是以帧栈的形式存到到进程的栈内存中,而栈内存的清理工作是在函数准备ret 返回前,通过 leave指令进行。 对于正常的递归函数,由于函数不断调用自己,产生的帧栈越来越多,可能导致内存溢出;另外函数调用会创建和销毁帧栈,这也是消耗性能的。

尾递归调用优化是指在一定条件下,编译器直接利用跳转指令代替函数调用指令,来模拟函数调用过程,这样便可省去函数调用帧栈的不断创建和销毁, 而且也只使用了有限的栈内存。

尾递归调用优化一个前提条件是:递归语句必须是在函数返回前的最后一条语句。在这种情况下,编译器才能确定函数的返回值没有被上一个帧栈所使用。

factorial_function2

上面的函数开启最高编译优化等级”-O3“后,会使用尾递归优化, 在执行 ret 前,会判断寄存器 edi 的值是否为0 (ZF=1),来决定调转

K&R 函数声明

K&R 函数声明可能不知道函数的参数列表,如下所示:

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int add();
int main(void) {
printf("%d", add(1));  // ?
return 0;
}
int add(int x, int y) {
return x + y;
}

这样导致参数是一个随机值,不建议使用。

第七讲 枚举、结构和联合是如何实现的?

枚举 Enumeration

enum

从汇编代码可以看出自定义枚举类型都是以 int 类型存储的,枚举值 Mon 在底层是由 0 表示。

C 标准直接将枚举值当做整数进行处理,这样导致foo 函数在调用时,实际上允许传入任何可以被隐式转换为 int 类型的值。

结构 Struct

结构和数组类型,都是使用连续的内存存放数据,不过结构可以存放不同的类型的数据。

struct

本质上,结构只是对内部所包含的各类数据的一个封装,因此只需要它分装的这些数据放在连续的内存装即可。

struct_stack_memory

如上图的栈内存所示,字符转指针 p 位于 [rbp-32] 处,占用 8 个字节,字符 c 位于 [rbp-24] 处,占用 1 个字节,长整形 x 位于 [rbp-16]处,占用 8 个字节。

内存数据对齐

自然对齐是指被操作数据所在的地址为该数据大小的整数倍,当内存中的数据 满足自然对齐时,CPU通常能够以最高的效率进行数据操作。

填充字节

某些情况即使结果对象各个数据成员都满足自然对齐的要求,额外的填充字节也会被添加,如下示例:

1
2
3
4
5
struct Foo {
char *p;  // 8 bytes.
char c;  // 1 bytes.
// (padding): 7 bytes.
};

之所以会这样,因为编译器想保证:当结构体被连续存放时,前一个对象结束的位置正好可以满足后一个对象的起始位置时自然对齐的要求。 这样也就要求结构对象本身的大小必须是内部最大成员大小的整数倍。

联合 Union

联合和结构语法类型,只要把 关键字从 struct 改为 union。 顾名思义,联合就是所有字段共同使用一个内存区域。

联合对象的大小与内部定义最大成员的大小相同。

对一个单独的联合对象来说,哪个字段在生效我们无从得知,所以需要一个标签字段来配合指明正在生效的字段,这样模式叫做“Tagged Union”.

union

第八讲 指针是如何灵活使用内存的

指针的基本使用

通过 类型说明符加 * 符号可以定义一个指向该数据类型的指针。 通过 const 关键字可以限制指针变量的行为

pointer_basic_usage

指针和数组

pointer_and_array

从上图看出,数据中的元素被分配在连续的栈内存中。

当 arr 作为实参传入函数 sum 后,实际传入的是一个指向 int 类型的指针,有关 arr 的大小和类型都全部丢失,这种情况称之为”数组的退化“。

指针的其他运算

算术运算

pointer_math_operation

当我们对指针进行加减运算时,编译器是以当前指针所指向值对应的某个固定 长度为单位,对指针中存放的地址值进行相应调整的。

关系运算

大多数情况,编译器会使用 cmp 和 setg 等指令来判断关系运算两侧操作数的大小

堆内存指针

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define N 5
int main(void) {
  int arr[] = { 1, 2, 3, 4, 5 };  
  // 分配用于存放 N 个整数的堆内存;
  int* p = (int*) malloc(sizeof(int) * N);
  // 将数组 arr 中的元素复制到分配的堆内存中;
  memcpy(p, arr, sizeof(int) * N);  
  for (int i = 0; i < N; ++i) {
    // 通过指针遍历堆空间中的数据;
    printf("%d\n", *(p + i));
  }
  // 释放先前分配的堆空间,让操作系统可以回收内存;
  free(p);  
  return 0;
}

在 VAS 中,堆内存位于栈内存的下方,堆内存是从低地址向高地址逐渐增长

heap_memory

堆内存可以动态创建,可以保持和程序相同的生命周期。 另外和全局变量、静态变量这种将值完全暴露给所有程序代码相比, 使用堆内存可以将数据的使用限制在所需的最小范围内,加强了程序对内存的精细化管理程度。

第九讲 预处理器

预处理流程

预处理流程如下:

  1. 删除代码注释
  2. 处理宏定义 #define,进行展开和替换
  3. 处理条件预编译 #if、#elif,仅保留符合条件的代码
  4. 处理文件包含预编译指令 #include,将被包含的文件内容插入到指令所在位置
  5. 处理其他可识别的预处理指令如 #program
  6. 添加其他具有辅助性功能的注释信息
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#pragma GCC warning "Just FYI!"
#include <stdbool.h>
#define PI 3.14
#define SQUARE(x) (x * x)
int main(void) {
#if defined PI
  // Some specific calculations.
  const double area = SQUARE(5) * PI;
  const bool isAreaGT100 = area > 100.0;
#endif
  return 0;
}

使用gcc -O0 -Wall -E exmaple.c -o example.I 得到预处理后的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1 "macro.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "macro.c"

# 1 "/usr/lib/gcc/x86_64-redhat-linux/8/include/stdbool.h" 1 3 4
# 3 "macro.c" 2



int main(void) {

  const double area = (5 * 5) * 3.14;
  const
# 8 "macro.c" 3 4
       _Bool
# 8 "macro.c"
            isAreaGT100 = area > 100.0;
  return 0;
}

宏函数常用技巧

为返回值添加括号

1
2
3
4
5
6
7
#include <stdio.h>
// #define FOO(x) 1 + x * x
define FOO(x) (1 + x * x)
int main(void) {
  printf("%d", 3 * FOO(2));
  return 0;
}

为参数添加括号

1
2
3
4
5
6
7
#include <stdio.h>
// #define FOO(x) (1 + x * x)
define FOO(x) (1 + (x) * (x))
int main(void) {
  printf("%d", 3 * FOO(2));
  return 0;
}

警惕多次副作用

1
2
3
4
5
6
7
#include <stdio.h>
#define FOO(x) (1 + (x) * (x))
int main(void) {
  int i = 1;
  printf("%d", FOO(++i));
  return 0;
}

定义完备的多语句宏函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

#include <stdio.h>
// #define SAY() printf("Hello, "); printf("world!")

#define SAY() do { printf("Hello, "); printf("world!")} while (0)
int main(void) {
  int input;
  scanf("%d", &input);  
  if (input > 0)
    SAY();
  return 0;
}

第十讲 字符、字符串与数学计算

c_std_library

字符和字符串

在 C 语言中,字符用单引号表示,字符串用双引号表示

字符

C 语言保证 char 类型只占用一个字节大小。

大多数情况编译器会选择 char 类型视为有符号整数类型。

字符串

字符串可以使用指针或者数组形式表示。

连续出现的字符串之间如果仅有空格分隔,则会将它们视为一个整体。

1
2
3
// read-only string.
const char strA[] = "Hello, geek!";  
const char* strB = "Hello" ", geek!";

上述字符串在内存的布局如下:

string_memory_layout

字符串被存放在连续的内存段上,且每个字符串最后都以一个空字符作为终止符。

使用数组和指针形式定义的字符串,其底层的数据引用方式会有所区别。 其中数组方式会将字符串从 .rodata 中拷贝到其他位置(比如栈内存),因此修改这些这些数据不会改变原始的 .rodata 中的副本;而使用指针形式时指针会直接引用位于 .rodata 中的字符串数据,因此通过指针修改字符串的值会影响相同指向的指针字符串。

字符库函数

统计字符串长度

1
2
3
4
5
6
#include <string.h>
#include <stdio.h>
int main(void) {
  const char str[10] = "Hi";
  printf("%zu\n", strlen(str));  // 2.
}

strlen 函数不会计入字符串多余的终止符。

拼接字符串

1
2
3
4
5
6
7
8
9
#include <string.h>
#include <stdio.h>
#define STRLEN 14
int main(void) {
  char strA[STRLEN] = "Hello,";
  char strB[] = " world!";
  strncat(strA, strB, STRLEN - strlen(strA) - 1);
  printf("%s\n", strA); 
}

strncat 可控制被拼接字符串的长度

拷贝字符串

1
2
3
4
5
6
7
#include <string.h>
#include <stdio.h>
int main(void) {
  char strA[] = "aaaaaa";
  char strB[] = "bbbbbbb";
  printf("%s\n", strncpy(strA, strB, strlen(strA)));  // "bbbbbb".
}

格式化字符串

1
2
3
4
5
6
7
8
#include <stdio.h>
#define LEN 128
int main(void) {
  char dest[LEN];
  const char strA[] = "Hello, ";
  sprintf(dest, "%sworld!", strA);
  printf("%s\n", dest);
}

sprintf 会将格式化后的字符串保存到第一个参数传入的数组中

字符的判断和转换

ctype.h 中包含众多用于字符的函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <ctype.h>
#include <stdio.h>
int main(void) {
  char c = 'a';
  printf("%d\n", isalnum(c));  // 1.
  printf("%d\n", isalpha(c));  // 1.
  printf("%d\n", isblank(c));  // 0.
  printf("%d\n", isdigit(c));  // 0.
  printf("%c\n", toupper(c));  // 'A'.
}

数学运算库函数

math.h 和 stdlib.h 包含了众多数学运算函数

thmath.h 里面提供了泛型办的数学运算函数,大体思路是通过宏来判断参数类型进而转换到正确类型的底层函数去,详情参考实现

第十一讲 IO 标准库

C 语言采用标准库 stdio 的方式,提供对 I/O 相关接口的支持

基本使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>
int main(void) {
  printf("Enter some characters:\n");
  FILE* fp = fopen("./temp.txt", "w+");
  if (fp) {
    char ch;
    while (scanf("%c", &ch)) {
      if (ch == 'z') break;
      putc(ch, fp);
    }
  } else {
    perror("File open failed.");
  }
  fclose(fp);
  return 0;
}
  • 通过 printf 将指定文本传送到标准输出流 stdout
  • 通过 fopen 打开指定的文件,并将其与一个特定的文件 IO 流关联
  • 通过 perror 将指定错误信息传送到标准错误流 stderr
  • 通过 scanf 从 标准输入流 stdin 读取输入的信息
  • 通过 putc 将 字符写入指定文件

接口级别

IO 接口一般分为两个级别:

  • 标准 IO:ISO C 标准定义的一些列接口,实现与具体操作系统无关
  • 低级 IO:使用具体操作系统相关的一系列底层接口来提供相应的 IO 能力

上面的代码使用低级 IO 接口实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <unistd.h>
#include <fcntl.h>
int main(void) {
  const char str[] = "Enter some characters:\n";
  write(STDOUT_FILENO, str, sizeof(str));
  const int fd = open("./temp.txt", O_RDWR | O_CREAT);
  if (fd > 0) {
    char ch;
    while (read(STDIN_FILENO, &ch, 1)) {
      if (ch == 'z') break;
      write(fd, &ch, sizeof(ch));    
    }
  } else {
    const char errMsg[] = "File open failed.";
    write(STDERR_FILENO, errMsg, sizeof(errMsg));
  }
  close(fd);
  return 0;
}

和低级 IO 相比,标准 IO 会为我们提供带缓冲的输入和输出操作。

低级 IO 背后的系统调用

低级 IO 接口通过系统调用来完成相应的 IO 操作,与调用用户函数不同使用 call 指令 不同,在 x86-64 平台,通过 syscall 指令来执行一个系统调用函数。

每个系统调用函数有一个唯一整形 ID, open 函数的 ID 为 2。 SyxV 调用约定使用寄存器 rdi、rsi、rdx、r10、r8、r9 来进行实参的传递,寄存器 rax 用于存放系统调用对应的 ID,并接收系统调用的结果。

上面的代码使用汇编改造如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <unistd.h>
#include <fcntl.h>
int main(void) {
  const char str[] = "Enter some characters:\n";
  write(STDOUT_FILENO, str, sizeof(str));
  const char* fileName = "./temp.txt";
  // Call to `open` starts:
  // const int fd = open("./temp.txt", O_RDWR | O_CREAT);
  volatile int fd;
  asm("mov $2, %%rax\n\t"
      "mov %0, %%rdi\n\t"
      "mov $66, %%rsi\n\t"  // 2 | 64 -> 66;
      "syscall\n\t"
      "mov %%rax, %1\n\t"
       : "=m" (fileName)
       : "m" (fd));
  // Call ended.
  if (fd > 0) {
    char ch;
    while (read(STDIN_FILENO, &ch, 1)) {
      if (ch == 'z') break;
      write(fd, &ch, sizeof(ch));
    }
  } else {
    const char errMsg[] = "File open failed.";
    write(STDERR_FILENO, errMsg, sizeof(errMsg));
  }
  close(fd);
  return 0;
}

上面的代码中,将 ID 2 存入 寄存器 rax, 将 fileName 首地址存入寄存器 rdi,将 文件操作模式 66 存入寄存器 rsi,调用结果 从 寄存器 rax 存入 局部变量 fd。

第十二讲 非本地跳转和可变参数实现原理

本地跳转

local_jump

本地跳转一般指由 goto 语句完成的程序执行流的转移过程

setjmp 和 longjmp 函数

long_jump

如上图所示,在调用 longjmp 之后,程序会调到 call setjmp 下一条指令, 这种跳转为我们提供了一种可以暂存函数调用状态并在未来某个时刻再恢复的能力。

运作原理

setjmp 函数在执行时,会将程序此刻的调用环境信息存储在由其第一个参数指定的 jmp_buf 类型的对象中,并同时将 0 作为结果返回,后续当程序执行到 longjmp 时, 该函数便回从同一个 jmp_buf 对象中再次恢复之前保存的函数调用上下文,通过这种方式,程序的执行流程得到了重置。

SysV 调用约定,属于 Callee-saved 类型的寄存器信息需要在 call 指令调用时,由被调用函数 caller 负责保存和恢复。

自定义实现

setjmp 汇编如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
.global setjmp  # 将 setjmp 暴露给链接器
.intel_syntax noprefix  # 使用汇编语法
setjmp:
  mov  QWORD PTR [rdi], rbx
  mov  QWORD PTR [rdi+0x8], rbp
  mov  QWORD PTR [rdi+0x10], r12
  mov  QWORD PTR [rdi+0x18], r13
  mov  QWORD PTR [rdi+0x20], r14
  mov  QWORD PTR [rdi+0x28], r15
  lea  rdx, [rsp+0x8]
  mov  QWORD PTR [rdi+0x30], rdx
  mov  rdx, QWORD PTR [rsp]
  mov  QWORD PTR [rdi+0x38], rdx
  xor  eax, eax
  ret

由 SysV 规定,寄存器 rdi 接收第一个实参,所以 rdi 保存着 jmp_buf 的首地址, jmp_buf 可以看做是一个具有足够大小的字节数组。

在 4~9 行我们将寄存器 rdx、rbp、r12、r13、r14、r15 的值进行保存, 在 10~11 行我们将 setjmp 调用之前的 rsp 寄存器进行了暂存, 在 12~13 行我们将 setjmp 调用后的地址进行暂存,这个地址将由 longjmp 使用

longjmp 汇编如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
.global longjmp
.intel_syntax noprefix
longjmp:
  xor  eax, eax
  cmp  esi, 0x1
  adc  eax, esi
  mov  rbx, QWORD PTR [rdi]
  mov  rbp, QWORD PTR [rdi+0x8]
  mov  r12, QWORD PTR [rdi+0x10]
  mov  r13, QWORD PTR [rdi+0x18]
  mov  r14, QWORD PTR [rdi+0x20]
  mov  r15, QWORD PTR [rdi+0x28]
  mov  rsp, QWORD PTR [rdi+0x30]
  jmp  QWORD PTR [rdi+0x38]

在 4~5 行我们将对 long_jmp 的第二个参数做处理,如果实参为 0,将其改为 1,这样做是为了能够通过寄存器 rax 中的值区分当前代码是在 setjmp 函数调用后首次执行的,还是 long_jmp 恢复后执行的

将汇编代码编译成对象文件

1
2
gcc -c setjmp.s -o setjmp.o
gcc -c longjmp.s -o longjmp.o

完整代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdnoreturn.h>
// 定义 jmp_buf 类型;
typedef long jmp_buf[8];
// 提供函数原型;
int setjmp(jmp_buf);
noreturn void longjmp(jmp_buf, int);
// 原始 C 示例程序代码;
jmp_buf jb;
noreturn void inspect(char val) {
  putchar(val);
  longjmp(jb, val);
}
int main(void) {
  volatile char count = 'A';
  if (setjmp(jb) < 'J')
    inspect(count++);
  return 0;
}

编译、测试

1
gcc main.c setjmp.o longjmp.o -o main && ./main

通常非本地跳转主要用来实现异常处理、协程等功能

使用非本地调整实现的 try … catch 可以参考longjump_try_trow_catch

可变参数

基本使用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <stdio.h>
#include <stdarg.h>
void print_sum(int count, ...) {
  int sum = 0;
  va_list ap;
  va_start(ap, count); 
  for (int i = 0; i < count; ++i)
    sum += va_arg(ap, int);
  va_end(ap);
  printf("%d\n", sum);
}
int main(void) {
  print_sum(4, 1, 2, 3, 4);
  return 0;
}

运作原理

SysV ABI 规定了可变参数列表的实现要求

首先实参将会按类型按顺序依次存入寄存器, 接着一块名为 Register Save Area 的 栈内存被构建,每个通过寄存器传入的实参值都会按照 rdi、rsi、rdx、rcx、r8、r9、xmm0~15 的寄存器先后顺序拷贝到 RSA 中, 接下来创建 结构体 va_list

1
2
3
4
5
6
typedef struct {
  unsigned int gp_offset;  // 下一个整型数据相较于 RSA 的偏移;
  unsigned int fp_offset;  // 下一个浮点数据相较于 RSA 的偏移;
  void *overflow_arg_area;  // 指向使用栈进行传递的数据;
  void *reg_save_area;  //  指向 RSA 的指针;
} va_list[1];

va_start(ap, count) 语句对 ap 进行了初始化,reg_save_area 指向了 RSA 起始位置,gp_offset 设置为 从 RSA 中读整数实参相对 reg_save_area 的偏移

va_arg(ap, int) 语句根据 va_list 及提取参数的类型从 RSA 中取出相应的数据值

va_end(ap) 释放了 ap

第十三讲 C 并发编程

C11 标准加入了 thread.h 和 stdatomic.h 标准库,提供了一套通用的并发编程接口

进程 vs 线程

默认情况,操作系统会为每一个运行的程序创建一个相应的进程,作为程序的运行实例。 进程中包含一系列运行时信息,比如 VAS、PID、 处理器上下文(如通用寄存器和指令寄存器的值),进程状态分配调度相关资源。这些信息被放在 进程控制块 PCB 数据结构中。

相比进程,线程提供了更细粒度的运行单元,线程在共享程序运行资源的情况下,负责程序某个子任务的具体执行过程。线程的状态信息被放在 线程控制块 TCB 数据结构中

thread1

线程的基本控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <threads.h>
#include <stdio.h>
int run(void *arg) {
  thrd_t id = thrd_current();  // 返回该函数运行所在线程的标识符;
  printf((const char*) arg, id);
  return thrd_success;
}
int main(void) {
#ifndef __STDC_NO_THREADS__
  thrd_t thread;
  int result;
  // 创建一个线程;
  thrd_create(&thread, run, "Hello C11 thread with id: %lu.\n");
  if (thrd_join(thread, &result) == thrd_success) {
    // 等待其他线程退出;
    printf("Thread returns %d at the end.\n", result);  
  }  
#endif
  return 0;
}

其他线程控制函数如下表:

thread2

数据竞争

数据竞争 Data Race 是指在一个多线程的环境中,有两个及以上的线程同一时间对同一块内存的数据进行了非原子操作,示例如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <threads.h>
#include <stdio.h>
#define THREAD_COUNT 20
#define THREAD_LOOP 100000000
long counter = 0;  // 全局变量,用来记录线程的累加值;
int run(void* data) {
  for (int i = 0; i < THREAD_LOOP; i++)
    counter++;  // 在线程中递增全局变量的值;
  printf("Thread %d terminates.\n", *((int*) data));
  return thrd_success;
}
int main(void) {
#ifndef __STDC_NO_THREADS__
  int ids[THREAD_COUNT];  // 用于存放线程序号的数组;
  thrd_t threads[THREAD_COUNT];  
  for (int i = 0; i < THREAD_COUNT; i++) {
    ids[i] = i + 1;
    thrd_create(&threads[i], run, ids + i);  // 创建 THREAD_COUNT 个线程;
  }
  for (int i = 0; i < THREAD_COUNT; i++)
    thrd_join(threads[i], NULL);  // 让当前线程等待其他线程执行完毕;
  printf("Counter value is: %ld.\n", counter);  // 输出 counter 变量最终结果;
#endif
  return 0; 
}  

counter++ 语句可能会编译为如下几条机器指令

1
2
3
mov eax, DWORD PTR counter[rip]
add eax, 1
mov DWORD PTR counter[rip], eax

竞态条件

竞态条件 Race Condition 是指由于程序中某些事件的发生时机和顺序不一致,从而影响正确性的一种缺陷

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <threads.h>
#include <stdio.h>
#include <stdatomic.h>
#include <stdlib.h>
#include <time.h>
#define THREAD_COUNT 10
atomic_int accountA = 100000000;  // 转出账户初始金额;
atomic_int accountB = 0;  // 转入账户初始金额;
int run(void* v) {
  int _amount = *((int*) v);  // 获得当前线程的转移金额;
  for(;;) {
    // 首先判断转出账户金额是否足够,不够则直接退出;
    if (accountA < _amount) return thrd_error;  
    atomic_fetch_add(&accountB, _amount);  // 将金额累加到转入账户;
    atomic_fetch_sub(&accountA, _amount);  // 将金额从转出账户中扣除;
  }
}
int main(void) {
#if !defined(__STDC_NO_THREADS__) && !defined(__STDC_NO_ATOMICS__)
  thrd_t threads[THREAD_COUNT];  
  srand(time(NULL));
  for (int i = 0; i < THREAD_COUNT; i++) {
    int amount = rand() % 50;  // 为每一个线程生成一个随机转移金额;
    thrd_create(&threads[i], run, &amount);
  }
  for (int i = 0; i < THREAD_COUNT; i++)
    thrd_join(threads[i], NULL);
  printf("A: %d\nB: %d", accountA, accountB);
#endif
  return 0; 
} 

由于加钱和减钱的操作是分开的,可能导致错误的结果

指令重排

现代编译器和处理器会采用指令重排来进一步提升程序的运行效率。 这种技术会在不影响程序可观测执行结果的情况下,对生成的机器指令或实际执行顺序进行适当的重排。

对于编译器来说,其表象是源码中的语句出现顺序和汇编代码实现的顺序不一致。

对于处理器来说,则是程序在真正执行时产生副作用的顺序(如变量赋值),与汇编代码指令出现顺序不一致

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <threads.h>
#include <stdio.h>
#include <stdatomic.h>
#if !defined(__STDC_NO_ATOMICS__)
atomic_int x = 0, y = 0;
#endif
int run(void* v) {
  x = 10;
  y = 20;  // !变量 y 的值可能被优先更新!
}
int observe(void* v) {
  while(y != 20) ;  // 忙等待;
  printf("%d", x);  // 只在 x 被更新后打印;
}
int main(void) {
#if !defined(__STDC_NO_THREADS__)
  thrd_t threadA, threadB;  
  thrd_create(&threadA, run, NULL);
  thrd_create(&threadB, observe, NULL);
  thrd_join(threadA, NULL);
  thrd_join(threadB, NULL);
#endif
  return 0; 
}

第十四讲 如何协调线程

互斥量

互斥量 mtx_t_init 初始化有三种模式

  • mtx_plain: 普通模式
  • mtx_recursive: 可重入模式
  • mtx_timed:有超时限制,超过后互斥失效

另外两个互斥相关函数

  • mtx_trylock: 加锁或者直接返回
  • call_once:仅调用一次

原子操作

stdaotmic.h 文件中 提供了一些原子操作

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <threads.h>
#include <stdio.h>
#include <stdatomic.h>
#define THREAD_COUNT 10
#define THREAD_LOOP 100000000
#if !defined(__STDC_NO_ATOMICS__)
_Atomic long counter = 0;  // 定义一个原子类型全局变量,用来记录线程的累加值;
#endif
int run(void* data) {
  for (int i = 0; i < THREAD_LOOP; i++)
    atomic_fetch_add_explicit(&counter, 1, memory_order_relaxed);  // 使用原子加法操作;
  printf("Thread %d terminates.\n", *((int*) data));
  return thrd_success;
}
int main(void) {
#if !defined(__STDC_NO_THREADS__) || !defined(__STDC_NO_ATOMICS__)
  int ids[THREAD_COUNT];
  thrd_t threads[THREAD_COUNT];  
  for (int i = 0; i < THREAD_COUNT; i++) {
    ids[i] = i + 1;
    thrd_create(&threads[i], run, ids + i);
  }
  for (int i = 0; i < THREAD_COUNT; i++)
    thrd_join(threads[i], NULL);
  printf("Counter value is: %ld.\n", counter);
#endif
  return 0; 
}

通过 _Atomic 声明一个院子类型变量,使用 atomic_fetch_add_explicit 来进行原子变量的加法操作,内存顺序有三种:

memory_order

更多原子操作相关函数如下:

atomic_functions

条件变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <threads.h>
#include <stdio.h>
mtx_t mutex;
cnd_t cond;  // 定义一个条件变量;
int done = 0;
int run(void* data) {
  mtx_lock(&mutex); 
  done = 1;
  cnd_signal(&cond);  // 通知等待中的线程;
  mtx_unlock(&mutex); 
  return thrd_success;
}
int main(void) {
#ifndef __STDC_NO_THREADS__
  mtx_init(&mutex, mtx_plain); 
  cnd_init(&cond);  // 初始化条件变量;
  thrd_t thread;  
  thrd_create(&thread, run, NULL);
  mtx_lock(&mutex); 
  while (done == 0) {
    cnd_wait(&cond, &mutex);  // 让当前线程进入等待队列;
  }
  mtx_unlock(&mutex); 
  printf("The value of done is: %d", done);
  mtx_destroy(&mutex);
  cnd_destroy(&cond);  // 销毁条件变量;
#endif
  return 0; 
}

条件变量提供了线程间通知能力,某个线程可以在完成了某件事后,通知并唤醒等待线程。

本地变量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <threads.h>
#include <stdatomic.h>
#define THREAD_COUNT 10
#define THREAD_LOOP 10000
_Thread_local int counter = 0;  // 定义线程本地变量;
int run(void *data) {
  for (int i = 0; i < THREAD_LOOP; ++i)
    counter += 1;  // 更新当前线程所属的 counter 变量值;
  return counter;
}
int main(int argc, char const *argv[]) {
  thrd_t threads[THREAD_COUNT];
  int sum = 0, result = 0;
  for (int i = 0; i < THREAD_COUNT; ++i)
    thrd_create(&threads[i], run, NULL);
  for (int i = 0; i < THREAD_COUNT; ++i) {
    thrd_join(threads[i], &result);
    sum += result;  // 累加每个线程的计算值;
  }
  printf("The value of count is %d.\n", sum);
  return 0;
}

线程本地变量的值仅能够在某个线程的生存期内可用,变量的实际存储空间会在线程开始时分配,线程结束时回收。

thread_local_variable

第十五讲 信号

什么是信号

信号实际上是一种可以用来传递特定消息的机制,操作系统将程序运行过程中发生的各类特殊情况转发给程序,并按照其指定的逻辑进行处理。

信号的产生是一个随机的过程,所以程序需要提前”告诉“操作系统,信号到来时,应该如何处理。这就是一种典型的异步事件处理方式。

信号与软中断

信号是一种软中断,当特定事件发生时,操作系统会将对应的信号值发送给相关程序,通常情况下,如果对应程序并未设置自定义的信号处理程序,则操作系统会执行默认信号处理程序。整个程序处理过程中,存在着 CPU 从用户态到信号处理程序的执行流程转移。

C 代码样例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
void sigHandler(int sig) {
  printf("Signal %d catched!\n", sig);
  exit(sig);
}
int main(void) {
  signal(SIGFPE, sigHandler);
  int x = 10;
  int y = 0;
  printf("%d", x / y);
}

C 标准库提供了 6 种不同类型的信号

signal_num

信号处理函数的原型为 void (*handler) (int),即接受一个整形的信号值,不返回任何内容。

除零异常的信号交互逻辑如下:

  1. CPU 执行触发指令 idiv
  2. 发现除零异常,CPU 暂停当前程序运行,并将控制权转交给操作系统
  3. 操作系统将信号 SIGFPE 发送给出错的程序
  4. 操作系统根据情况执行相应的信号处理程序
  5. 信号处理程序执行完毕后,如果程序未退出,则将程序执行恢复到之前的中断点,即 CPU 会重新执行 idiv 指令

可重入函数

当程序在执行函数 A 时收到了某个信号,信号处理函数中也对函数 A 发起了调用,这样可能会影响之前还未完成调用的函数 A 执行状态。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>
#include <signal.h>
#include <string.h>
#define BUF_SIZE 16  // 全局静态数组大小;
#define FORMAT_NUM_(N) " $"#N
#define FORMAT_NUM(N) FORMAT_NUM_(N)
#define RAISE_EXP_false_ASM()
// 调用 raise 函数向当前程序发送信号;
#define RAISE_EXP_true_ASM() \
  "movl    $4, %%edi\n\t" \
  "call    raise\n\t"
// 内联汇编实现;
#define INLINE_ASM(ID, HAS_EXP) \
  "mov     %0, %%r8\n\t" /* 复制传入的字符串数据到全局静态数组 */ \
  "testq   %%rsi, %%rsi\n\t" \
  "je      .L1" #ID "\n\t" \
  "xorl    %%eax, %%eax\n\t" \
  ".L3" #ID ":\n\t" \
  "movzbl  (%%rdi,%%rax), %%ecx\n\t" \
  "movb    %%cl, (%%r8,%%rax)\n\t" \
  "addq    $1, %%rax\n\t" \
  "cmpq    %%rsi, %%rax\n\t" \
  "jne     .L3" #ID "\n\t" \
  ".L1" #ID ":\n\t" \
  RAISE_EXP_##HAS_EXP##_ASM() /* 选择性调用 raise 函数 */ \
  "mov     $1, %%rax\n\t" \
  "mov     $1, %%rdi\n\t" \
  "mov     %0, %%rsi\n\t" \
  "mov" FORMAT_NUM(BUF_SIZE) ", %%rdx\n\t" \
  "syscall\n\t"  /* 触发系统调用,打印内容 */
  
static char buf[BUF_SIZE];  // 用于保存字符的全局静态数组;
void print_with_exp(const char* str, size_t len) {  // 会引起信号中断的版本;
  asm(INLINE_ASM(a, true) :: "g" (buf));
}
void print_normal(const char* str, size_t len) {  // 正常的版本;
  asm(INLINE_ASM(b, false) :: "g" (buf));
}
void sigHandler(int sig) {
  const char* str = "Hello";
  print_normal(str, strlen(str));
}
int main(void) {
  signal(SIGILL, sigHandler);
  const char* str = ", world!";
  print_with_exp(str, strlen(str));
  return 0;
}

reentrant_function

不受中断和重新调用影响的函数称之为可重入函数

多线程信号处理

C 语言没有对并发编程的信号处理做规范,所以多线程应用中使用 signal 和 raise 函数会产生未定义的行为

第十六讲 日期、时间与实用函数

日期与时间

日历时间

使用 time_t 类型表示,其代表从 1970-01-01: 00:00:00 到当前时间秒数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>
#include <time.h>

int main(void) {
    time_t currTime = time(NULL);
    if (currTime != (time_t) (-1)) {
        printf("the current timestamp is: %ld(s)\n", currTime);
        printf("local time is: %s\n", asctime(localtime(&currTime)));
        printf("UTC time is: %s\n", asctime(gmtime(&currTime)));
    }
    return 0;
}

处理器时间

处理器时间即 CPU 资源被调度以支持程序在某段时间内正常运作所花费的时间。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <time.h>
#include <stdio.h>
int main(void) {
  clock_t startTime = clock();    
  for(int i = 0; i < 10000000; i++) {}
  clock_t endTime = clock();
  printf("Consumed CPU time is:%fs\n", 
   (double)(endTime - startTime) / CLOCKS_PER_SEC); 
  return 0;
} 

字符串到数值的转换

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
int main(void) {
  // 一次性字符串到数值转换;
  const char* strA = "1.0";
  printf("%f\n", atof(strA));
  // 带溢出检查的转换函数,执行后会保存不能被转换部分的地址;
  const char* strB = "200000000000000000000000000000.0";
  char* end;
  double num = strtol(strB, &end, 10);
  if (errno == ERANGE) {  // 判断转换结果是否发生溢出;
    printf("Range error, got: ");
    errno = 0;
  }
  printf("%f\n", num);
  return 0;
}

生成随机数

1
2
3
4
5
6
7
8
9
#include <stdio.h> 
#include <stdlib.h> 
#include <time.h> 
int main (void) {
  srand(time(NULL));  // 初始化随机数种子;
  while (getchar() == '\n') 
    printf("%d", rand() % 10);  // 生成并打印 0-9 的随机数;
  return 0; 
}

动态内存管理

除了 malloc 和 free 函数之外,C 标准库也提供了另外的一些函数

alloc_function

进程控制

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>
#include <stdlib.h>
void exitHandler() {
  printf("%s\n", getenv("PATH"));
}
int main(void) {
  if (!atexit(exitHandler)) {
    exit(EXIT_SUCCESS);
  }
  return 0;
}

process_control

第十七讲 断言、错误处理和对齐

断言

断言分为静态断言和动态断言

一般我们在程序运行前使用静态断言,来检查它所需要满足的一系列要求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <assert.h>
double sqrt(double x) {
  // 检查函数使用时传入的参数;
  assert(x > 0.0);
  // ...
}
int main(void) {
  // 检查程序的编译要求;
  static_assert(sizeof(int) >= 4, 
    "Integer should have at least 4 bytes length.");
  // ...
  return 0;
}

定义 NDEBUG 宏可关闭断言功能

错误处理

在 C 语言中,名为 errno 的预处理宏会被展开为一个 init 类型的可修改全局左值

1
2
3
4
5
6
7
8
9
#include <tgmath.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
int main(void) {
  sqrt(-1);
  fprintf(stderr, "%s\n", strerror(errno));  // "Numerical argument out of domain".
  return 0;
}

可以通过 strerror 函数获取当前 errno 对应的可读文本

对齐

可以使用 _Alignas 来根据自身需要为数据指定特殊的对齐要求,stdalign.h 有 对应的宏 alignas

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>
#include <stdalign.h>
int main(void) {
#if __alignas_is_defined == 1 && __alignof_is_defined == 1
  alignas(1024) int n = 1;
  printf("The alignment of n is %zu\n", alignof(n));  // "The alignment of n is 1024".
  printf("The address of n is: %p\n", &n);  // "The address of n is: 0x7ffe80658c00".
#endif
  return 0; 
}

第十八讲 极致优化(上)

高速缓存

CPU 芯片上有 L1、L2、L3 三个不同级别的高速缓存

高速缓存之所以能提升性能,一个重要的前提在于局部性原理

  • 时间局部性:被引用过一次的内存位置接下来可能会被再次引用
  • 空间局部性:如果一个内存位置被引用了,那附近的内存也可能会被引用

内联

通过内联关键字 inline ,可以建议编译器,将某个方法的实现内联到它的实际调用处。

1
2
3
4
5
6
7
8
9
#include <stdio.h>
static inline int foo() {
  return 10;
}
int main(void) {
  int v = foo();
  printf("Output is: %d\n", v);
  return 0;
}

上述代码对应的汇编代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.LC0:
        .string "Output is: %d\n"
main:
        sub     rsp, 8
        mov     esi, 10
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        call    printf
        xor     eax, eax
        add     rsp, 8
        ret

通过内联,程序不再需要使用 call 指令来调用 foo 函数,好处在于节省 call 指令执行时需要进行的函数帧栈创建和销毁过程。坏处是导致可执行二进制文件增大

restrict关键字

restrict 关键字用于指针,表明该指针是访问对应数据的唯一方式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>
void foo(int* x, int* y, int* restrict z) {
  *x += *z;
  *y += *z;
}
int main(void) {
  int x = 10, y = 20, z = 30;
  foo(&x, &y, &z);
  printf("%d %d %d", x, y, z);
  return 0;
}

对应的汇编代码如下:

1
2
3
4
5
foo:
        mov     eax, DWORD PTR [rdx]
        add     DWORD PTR [rdi], eax
        add     DWORD PTR [rsi], eax
        ret

使用restrict 关键字后,在为指针 y 进行 值累加前,编译器不会再重复性地从内存中读取指针 z 对应的值

消除不需要的内存引用

原代码

1
2
3
4
5
6
7
8
#define LEN 1024
int data[LEN] = { ... };
int foo(int* dest) {
  *dest = 1;
  for (int i = 0; i < LEN; i++) {
    *dest = *dest * data[i];
  }
}

优化代码

1
2
3
4
5
6
7
8
9
#define LEN 3
int data[LEN] = { 1,2,4 };
int foo(int* dest) {
  register int acc = 1;
  for (int i = 0; i < LEN; i++) {
    acc = acc * data[i];
  }
  *dest = acc;
}

优化代码主要做了两件事情:

  1. 将用于存放临时积累值的 *dest 替换为局部变量
  2. 为局部变量添加 register 关键字,建议编译器将该值存放在寄存器中

第十九讲 极致优化(下)

循环展开(Loop Unrolling)

现在 CPU 为了进一步提升指令的执行效率,通常会将单一的机器指令再进行拆分,以达到并行的目的。对于一个基本的五级 RISC 流水线来说, CPU 会将指令的执行细分为指令提取 (IF)、指令译码(ID)、指令执行(EX)、内存访问(MEM)和寄存器写回(WB)。

在第一条机器指令经过了指令提取后,即使该指令没有完全执行完毕,CPU 也可以立即开始处理下一条机器指令。因此从宏观上来看,机器指令的执行由串行变成了并行。 当五个节点全部执行完毕后,CPU 会更新指令指针(PC)

instructions

示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# 原代码
#define LEN 4096
int data[LEN] = { ... };
int foo(void) {
  int acc = 1;
  for (int i = 0; i < LEN; ++i) {
    acc = acc * data[i];
  }
  return acc;
}


# 展开后的代码
#define LEN 4096
int data[LEN] = { ... };
int foo(void) {
  int limit = LEN - 1;
  int i;
  int acc0 = 1;
  int acc1 = 1;
  for (i = 0; i < limit; i += 2) {  // 2x2 loop unrolling.
    acc0 = acc0 * data[i];
    acc1 = acc1 * data[i + 1];
  }
  for (; i < LEN; ++i) {  // Finish any remaining elements.
    acc0 = acc0 * data[i];
  }
  return acc0 * acc1;
}

我们将 循环结果应用了 2 * 2 (步长为2,2个独立累积值变量)循环展开。

不过这样导致代码量增加和可读性下降,大部分情况下我们不需要手动改变代码来做循环展开。

优先使用条件传送指令

CPU 中存在着条件传送指令、条件分支指令等,CPU 执行条件分支指令会对分支进行预测,并提现执行分支指令,如果预测的跳转位置发生错误,就会将状态重置为发生跳转前分支所处的状态,并取出正确方向上的指令,开始重新处理。 条件传送指令没有分支预测,所以没有这部分损失。

示例代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define LEN 1024
void foo(int* x, int* y) {
  int i;
  for (i = 0; i < LEN; i++) {
    if (x[i] > y[i]) {
      int t = x[i];
      x[i] = y[i];
      y[i] = t;
    }
  }
}


#include <stdio.h>
#define LEN 16
void foo(int* x, int* y) {
  int i;
  for (i = 0; i < LEN; i++) {
    int min = x[i] < y[i] ? x[i] : y[i];
    int max = x[i] < y[i] ? y[i] : x[i];
    x[i] = min;
    y[i] = max;
  }
}

上面示例通过增加几次比较和复制操作,来避免分支预测失败代码的惩罚。

使用更高的编译优化等级

gcc 可以指定更高的优化等级来优化

gcc_options

尾递归优化

尾递归优化通过将函数的递归调用优化为循环结构,减少了 call 指令的调用次数,进而减少了帧栈的创建和销毁过程,提升了程序的执行性能。

第二十讲 编码规范

我们以 GNU 编码规范为基准

格式

  • 每行字符数量保持在79个以内
  • 函数定义是开始花括号“{”位于行首
  • 函数命中位于行首
  • 参数过多时,将超过限制的参数放到函数名下一行,并与第一个参数开头对齐
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int
lots_of_args (int an_integer, long a_long, short a_short,
              double a_double, float a_float)
{
  // ...
}
struct foo 
{
  int a, b;
}
struct bar { int x, y; }

更多规范参考 GNU 旗下的格式化工具 indent

注释

文件层面,main 函数所在文件应以描述程序的基本用途的注释作为开头,而其他源文件应以文件名和描述该源文件基本功能的注释作为开头

函数层面,需要添加用于描述函数基本功能、参数类型、参数用途、参数可能取值和返回值含义等内容注释信息

语法约定

  • 显示地为所有使用到的值标注类型,尤其是直接使用在表达式中的数字字面量值
  • 外部函数和后续才会使函数的声明,应该被放置在当前源文件处开头统一的地方,或放到单独的文件中
  • 将同类型的多个变量放在同一行声明
  • 使用嵌套的流程控制语句(如 if 语句)时,总是将内部嵌套的逻辑包裹在大括号中。
  • 尽量避免在 if 语句 条件判断处做赋值操作(while 可以这样做)

命名

变量名一般采用下划线命名法

尽量使用小写字母、下划线和数字来组成变量名,把大写字母留给宏常量和枚举常量。

国际化

可以使用 GNU gettext 库,将消息翻译成各种语言

第二十一讲 自动化测试

单元测试

单元测试就是对组成程序的基本单元(也可称为模块)进行功能正确性验证,它的目标是 隔离程序的每个部分,并单独验证这些部分能够按照预期正常工作。

对于 C 程序来说,这里的单元通常为函数

通常使用 Cunit 进行单元测试

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ...
int maxi(int x, int y) {  // 被测试函数;
  return (x > y) ? x : y;
}
void test_maxi(void) {  // 测试用例;
  CU_ASSERT(maxi(0, 2) == 2);
  CU_ASSERT(maxi(0, -2) == 0);
  CU_ASSERT(maxi(2, 2) == 2);
}
// ...

集成测试

检测不同单元或模块整合在一起时,他们是否也可以很好地协同工作,这类测试称之为集成测试 Integration Testing,这类测试通常需要使用数据库,网络连接等真实的外部资源。

功能测试

功能测试 Functional Testing 的目的和集成测试十分类似,但功能测试对测试结果的正确性要求可能会更加严格,需要满足业务需求中的相应规定。

性能测试

通常可以使用运行时间和内存使用率这两个指标来作为程序运行性能的度量单位

可以使用 perf 命令行工具来进行性能测试

第二十二讲 结构化编译

如何组织代码结构

对于小型项目,可以简单地将 .h 文件和 .c文件分别放在 include 目录 和 src 目录,当项目变大时,可以将源文件按功能进行更细分的划分。

两种参考目录结构如下图

project_layouts

如何组织编译流程

一个 简单的 C 项目如下图 project_example

可以用如下命令进行编译, 使用-I选项指定查找头文件需要搜索的目录,使用-l指定 链接运行时依赖的库

1
gcc src/main.c src/mod.c -I./include -lm -o bin/main

使用 Makefile 进行结构化编译

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 用于控制编译细节的自定义宏;
CC = gcc
CFLAGS = -I./include
LDFLAGS = -lm
TARGET_FILE = bin/main

# 描述各个目标的详细编译步骤;
$(TARGET_FILE): $(patsubst src/%.c,src/%.o,$(wildcard src/*.c))
  $(CC) $^ $(LDFLAGS) -o $@

src/%.o: src/%.c include/%.h
  $(CC) $< $(CFLAGS) -c -o $@

使用 CMake 进行跨平台自动化构建

CMake(Cross-platform Make)会根据所在的平台,生成相应的“平台本地构建项目”,比如在 Unix 系统上,会生成相应的 Makefie 文件,在 Windows 会生成对应的 Visula Studio 工程文件。

CMake 的配置信息存在 CMakeLists.txt 文件中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cmake_minimum_required(VERSION 3.10)
# 设置项目名称;
project(Test)
# 设置二进制目标文件名称;
set(TARGET_FILE "main")
# 添加源文件目录;
aux_source_directory(./src DIR_SRCS)
# 设置二进制目标文件的依赖;
add_executable(${TARGET_FILE} ${DIR_SRCS})
# 设置头文件查找目录;
target_include_directories(${TARGET_FILE} PUBLIC "${PROJECT_SOURCE_DIR}/include")
# 设置需要链接的库;
target_link_libraries(${TARGET_FILE} PUBLIC m)

第二十三讲 高性能 HTTP Server 实战(上)

我们将会创建一个叫 FivSev 的服务,接收路径 “/?num={pos}”的 GET 请求,返回斐波那契数列对应位置的数值。

大体流程如下图

tcp_server

部分优化措施

  • 使用线程池利用多核CPU
  • 使用尾递归调用优化
  • 使用条件变量避免忙等待

第二十四讲 高性能 HTTP Server 实战(下)

项目基本结构

项目仓库地址: https://github.com/Becavalier/tiny-http-echo-server/tree/geektime

project_layout

处理用户参数

1
2
3
4
// libs/structs.h#L9-L11
typedef struct {
  int threadCount;
} serverSettings;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// libs/helpers.h#L67-L86
void setupServerSettings(int argc, const char** argv, serverSettings* ss) {
  while (argc-- > 1) {
    // process key.
    const char* keyHead = argv[argc];
    const char* keyPos = strchr(keyHead, '=');
    const size_t keyLen = keyPos - keyHead + 1;
    char key[keyLen];
    wrapStrFromPTR(key, keyLen, keyHead, keyPos);
    // process value.
    const char* valHead = keyHead + keyLen;
    const char* valPos = strchr(valHead, '\0');
    const size_t valLen = valPos - valHead + 1;
    char val[valLen];
    for (size_t i = 0; valHead <= valPos; valHead++)
      val[i++] = *valHead;
    if (strcmp(key, "thread_count") == 0) {
      ss->threadCount = atoi(val);
    }
  }
}

TCP Server

监听请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// src/main.c#L70-95
int serverFd; 
sockaddr_in address;
int addrLen = sizeof(address);

// establish a socket.
if ((serverFd = socket(AF_INET, SOCK_STREAM, 0)) == 0) { ... }

bzero(&address, addrLen); 
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;  // -> 0.0.0.0.
address.sin_port = htons(PORT);
  
// assigns specified address to the socket.
if (bind(serverFd, (sockaddr*) &address, sizeof(address)) < 0) { ... }

// mark the socket as a passive socket.
if (listen(serverFd, MAX_LISTEN_CONN) < 0) { ... }

解析请求

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// libs/helpers.c#L37-L65
int retrieveGETQueryIntValByKey(char* req, const char* key) {
  int result = 0;

  // extract uri;
  const char* uriHead = strchr(req, ' ') + 1;
  const char* uriTail = strchr(uriHead, ' ');
  size_t uriLen = uriTail - uriHead + 1;
  char strUri[uriLen];
  wrapStrFromPTR(strUri, uriLen, uriHead, uriTail);

  // parse uri;
  UriUriA uri;
  UriQueryListA* queryList;
  int itemCount;
  const char* errorPos;
  if (uriParseSingleUriA(&uri, strUri, &errorPos) == URI_SUCCESS) {
    if (uriDissectQueryMallocA(&queryList, &itemCount, uri.query.first, uri.query.afterLast) == URI_SUCCESS) {
      while (itemCount--) {
        if (strcmp(queryList->key, key) == 0) {
          result = atoi(queryList->value);
          break;
        }
        queryList = queryList->next;
      }
      uriFreeQueryListA(queryList);
    }
  }
  return result;
}

计算斐波那契数列

有递归和尾递归优化版本

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// libs/helpers.c#L8-L20
int __calcFibTCO(int n, int x, int y) {
  if (n == 0)
    return x;
  if (n == 1)
    return y;
  return __calcFibTCO(n - 1, y, x + y);
}

int __calcFibRecursion(int n) {
  if (n <= 1)
    return n;
  return __calcFibRecursion(n - 1) + __calcFibRecursion(n - 2);
}

int calcFibonacci(int n) {
  // return __calcFibTCO(n, 0, 1);  // TCO version. 
  return __calcFibRecursion(n);  // recursion version.
}

第二十五讲 可执行二进制文件

ELF 文件格式

Unix 最常使用的是ELF(executabke and linkable format)文件格式

通常可执行文件的组织方式分为3部分:header、section、segment

1
2
3
4
5
6
7
// elf.c
#include <stdio.h>
int main (void) {
  const char* str = "Hello, world!";
  printf("%s", str);
  return 0;
}

通过 file 命令查看文件格式信息

file_info

ELF 头

通过readelf -h可以查看文件头部内容

elf_head

Section 头

Section 用于存放可执行文件中按照功能分类好的数据,各个 Section 相关信息存放在对应的 Section 头部中,众多连续的 Section 头便组成了 Section 头表

通过 ELF 头信息可以得到总共有 30 个 Section 头,第一个头位于文件开始偏移的 15512 字节

通过readelf -S 查看 Section 头部信息

section_head

.text 主要存放程序对应的机器代码,.rodata 存放只读常量,.data 存放已经初始化的全局变量或者静态变量,.bss 中coffee初始值为 0 的全局或者静态变量。

使用 objdump -s 查看 某个 Section 的完整内容

section_rodata

Program 头

除了由 Section 组成的静态视图外,众多的 Segment 组成了可执行文件的动态图。

Segment 指定了应用程序在实际运行时,应该如何在进程的 VAS 内部组织数据。

使用readelf -l 查看 Segment 情况

section_segment

Segment 对应的头部称之为 Program 头

其中 “LOAD” 类型的 Segment 会在运行时载入到进程的 VAS 中, 而其余的 Segment 主要用于辅助程序的正常运行(如动态链接)。

通常 Segment 和 Section 之间有一定的对应关系,上图中 第一个 LOAD 类型的 Segment 便包含 .text 和 .rodata 等 Section, 第二个 LOAD Segment 包含 .data Section。这样印证了 两个 LOAD 对应的 FLags 分别 为 RE 和 RW。

另外第一个 LOAD 的 Offset 为 0 ,这意味着执行时会除了将对应的 Sections 加载到内存外,文件 ELF 头也会加载到内存中。

head_view

ELF 编程

可以使用 elf.h 库对 ELF 格式的应用编程

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <elf.h>

void print_elf_type(uint16_t type_enum) {
  switch (type_enum) {
    case ET_REL: printf("A relocatable file."); break;
    case ET_DYN: printf("A shared object file."); break;
    case ET_NONE: printf("An unknown type."); break;
    case ET_EXEC: printf("An executable file."); break;
    case ET_CORE: printf("A core file."); break;
  }
}

int main (void) {
  Elf64_Ehdr elf_header;
  FILE* fp = fopen("./elf", "r");
  fread(&elf_header, sizeof(Elf64_Ehdr), 1, fp);
  print_elf_type(elf_header.e_type);  // "An executable file."
  fclose(fp);
  return 0;
}

ELF 文件类型

elf_file_type

第二十六讲 进程如何使用操作系统内存

计算机内部缓存系统

缓存由 L1、L2 等高速缓存和内存组成

虚拟内存机制

虚拟内存 Virtual Memory 是操作系统在物理内存的基础上进行了一层抽象,以帮助运行于其上的应用程序合理分配内存

retrieve_data

CPU 会借助内存管理单元 MMU 将虚拟地址翻译成物理地址,然后再进行实际数据的获取。

每一个应用进程都有独立的虚拟地址空间 VAS,从进程的角度看,它可以独享整个计算机上的内存。在64 位 Linux 系统中,与应用代码相关的 Segment 会从 VAS 的固定地址 0x400000 处开始加载,而Section 内容将在满足一定对齐要求的条件下按顺序加载到高地址方向的虚拟内存中。

0x0 到 0x400000 (4K) 的虚拟地址不会使用,这样空指针可以触发异常

可以通过cat /proc/<pid>/maps 查看某个运行中的进程的 VAS 布局情况。

vas_map

VAS 数据布局

vas_detail

VAS 数据按地址有低到高,可以分为如下几个部分:

  • LOAD Segments: 包括与代码相关的 Text Segment(.text, .rodata) 位于最低地址处,紧接着为包含已初始化和未初始化数据的Data Segment(.data, .bss)
  • 堆: 堆内存,向高地址方向增长
  • 共享库数据:包含各类 .so 共享库相关的数据,程序会在运行时通过动态连接器来完成对它们的加载和处理
  • 栈:栈内存,向低地址方向增长
  • 用于系统调用加速的内核数据:该部分数据主要提供了用户进程可以直接和内核交互的接口,其中 [vvar] 包含只读的内核数据,另外 [vdso] 和 [vsyscall] 则包含用于辅助操作系统加速用户进程执行某些系统调用过程的信息。
  • 其他内核数据

页表

每个进程有一个独立的页表结构来维护 VAS 中的虚拟页在对应物理内存中的映射状态。

页表本身维护在物理内存中,其内部由多个 PTE (Page Table Entry) 组成,每个虚拟页对应一个 PTE 。

CPU、MMU、页表、物理内存和磁盘五者之间的协作关系如下图:

PTE

多级页表

multi_level_page_table

多级页表节省空间的最重要的两个因素是:

  1. 当一级页表某个PTE 没有实际映射时,其对应的二级页表便不会被创建
  2. 只有一级页表才需要常驻内存,二级页表仅在需要时创建或者从磁盘调入

TLB

TLB (Translation Lookaside Buffer) 是 MMU 的一部分,用来加快虚拟地址查 PTE 的过程。可以简单将 TLB 理解为一个具有 N 行 M 列的矩阵,MMU 会从 虚拟地址中提取用于查询表项的索引和标记,这两个值可以定位到具体单元格,如果单元格有值,则可以与虚拟地址中的其他信息一起组成最终的物理地址;否则仍需要通过主机查询页表的方式获取物理地址。

第二十七讲 静态链接

示例代码如下

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// main.c
#define LEN 2
extern int sharedArr[LEN];
extern int sum(int *arr, int n);
int* array = sharedArr;
int main(void) {
  int val = sum(array, LEN);
  return val;
}

// sum.c
#define LEN 2
int sharedArr[LEN] = { 1, 2 };
int sum(int *arr, int n) {
  int i, s = 0;
  for (i = 0; i < n; i++) {
    s += arr[i];
  }
  return s;
}

使用 gcc 将代码分别编译成二进制可执行文件 main 和目标文件 main.o、sum.o

1
2
3
gcc main.c sum.c -o main  # 生成可执行文件 main;
gcc -c main.c -o main.o  # 生成目标文件 main.o;
gcc -c sum.c -o sum.o  # 生成目标文件 sum.o;

目标文件

目标文件是一种被称为”可重定位文件“的 ELF 文件类型。但它不包含 与动视图相关的的多种 Segment 和 Program 头部, 因此不同类型的 Section 便成了用于描述它所有特征的基本结构。

object_file

可以使用nm 命令查看目标文件中的符号表,符号表包含了所有全局变量和函数信息 nm_result

nm 会返回三列数据

  • 第一列代表符号在对应 Section 中的偏移量
  • 第二列代表符号的类型,D 代表已初始化的全局数据,位于 .data Section; T 代表函数,位于 .text Section; U 代表符号未定义
  • 第三列代表名称

符号解析

编译器编译源码时,会为无法在当前编译单元内找到定义的符号生成一个特定符号表条目。 链接器在随后进行符号解析时会在全局符号表中进行搜索。

如果链接器找到了多个同名定义, 会按照一定规则进行解析。符号有强弱之分,通常函数和已初始化的全局变量为强符号,而未初始化的全局变量为弱符号。

  • 如果有一个强符号和多个弱符号,选强符号
  • 如果有多个弱符号,任意选一个(通常选择占用空间最大的)
  • 如果有多个强符号,则报错

通过为函数显示添加 attribute((weak)) 标记可以设置其为弱符号。

重定位

符号解析之后,链接器开始将多个目标文件相同类型的Section 进行合并,同时为这些 Section 及 符号指定运行时 VAS 地址。

链接器通过重定位步骤来修改 .data 和 .text 两个 Section 中对每个符号的引用信息,使得它们可以指向正确的运行时地址。

重定位依赖特殊的 Section: .rela.data 和 rela.text, 通常这两个 Section 也称之为重定位表。

readelf

上图中每列数据含义:

  • Offest: 指目标在 Section 中的偏移
  • Info:
  • Type: 指重定位类型
  • Sym. Value: 当前值
  • Sym. Name + Addend: 符号名称和修正量

relocation_type

重定位类型如上图,计算方式中 S 表示实际地址,A 表示 Addend,P 表示被修改的具体位置,L 表示在 PLT 中该符号的入口地址。

通过 objdump 可以找到重定向目标的位置

以 R_x86_64_pc32 类型 arrary 举例计算, 验证 “S+A-P” 的计算方式

S 实际地址 可以通过nm 查看, 结果为 0x601020

nm

A 修正量为 -4

P 修改位置可以通过objdump -M inter -d main查看,40053e未改行机器指令起始位置, 在此基础上加 3个字节(操作指令+寄存器占用?)即可得到重定向的修改位置: 4000541

objdump

0x601020 - 0x4 - 0x400541 结果为 0x200adb,对应大端地址即为 db 01 20 00

第二十八讲 动态链接

共享库

能够被动态链接加载的库称之为共享库 Shared Library,在 Linux 中这类库文件以 ”.so“ 后缀结尾

使用示例

已上一讲中 sum.c 和 main.c 两个文件为例,将 sum.c 编译成 动态库,并让 main.c 对应的应用程序使用。

  1. 使用命令gcc sum.c -shared -fPIC -o libsum.o 编译动态库文件
  2. 使用命令gcc -o main main.c -lsum -L.编译应用程序
  3. 使用命令export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH 来设置动态链接器查找动态库文件的目录
  4. 执行./main

位置无关代码

位置无关代码(Position Independent Code, PIC)是一类特殊的机器代码,这些代码在使用时,可以被放置在每个进程 VAS 中任意位置,而无需链接器对它内部引用的地址进行重定位。

通常可以将模块之间的数据引用分为四种方式:

  • 模块内部函数调用
  • 模块内部数据访问
  • 模块之间函数调用
  • 模块之间数据访问

模块内部函数调用一般以 PC 寄存器寻址方式进行,不依赖 VAS 内的绝对地址。

模块内部数据访问时由于.data 和 .text 是相对固定的,数据访问也可以使用相对稳定的地址进行。

全局偏移表

全局偏移表(Global Offset Table, GOT)是位于每个模块 Data Segment起始位置处的一个特殊结构,其内部的每个表项都存放一个地址信息,这些地址对应这被当前 模块引用的外部函数或者变量在 VAS 中的实际地址。

当程序被加载进内存时,动态连接器根据实际情况,通过修正 GOT 中的值,来修正代码对应符号的实际引用地址

GOT

过程链接表

过程链接表 (Procedure Linkage Table, PLT) 是位于 Text Segment 中的一个表结构。其中 第一表项 PT[0] 内部存放的代码专门用于调用动态连链接器,而其他表项则依次存放着用于完成用户函数调用过程相关的代码,这些表项的地址将被 call 指令直接使用。

加载时链接

动态连接器进行的符号重定位发生在程序代码被真正执行之前

运行时链接

程序可以自动选择想要加载的共享库模块,并在不使用时卸载。动态链接主要使用 dlopen、dlsym、、dlerror、dlclose 四个接口实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
typedef double (*cos_t)(double);
int main(void) {
  cos_t cosine;
  char *error;
  void* handle = dlopen("libm.so.6", RTLD_LAZY);
  if (!handle) {
    fprintf(stderr, "%s\n", dlerror());
    exit(EXIT_FAILURE);
  }
  dlerror();
  cosine = (cos_t) dlsym(handle, "cos");
  error = dlerror();
  if (error != NULL) {
    fprintf(stderr, "%s\n", error);
    exit(EXIT_FAILURE);
  }
  printf("%f\n", (*cosine)(2.0));
  dlclose(handle);
  return 0;
}

第二十九讲 C 程序入口

真正的入口函数

1
2
3
4
// main.c
int main(void) {
  return 0;
}

objdump

可以从图中看到,程序首先从 _start 开始执行

_start

_start 是链接器在生成目标可执行文件时,默认使用的一个符号名称。链接器在链接时会在全局符号表中找到该符号,并将虚拟地址拷贝到 ELF 头的 e_entry 字段中。

可以通过命令ld --verbose查看

ld_verbose

_start 符号具体定义来自 crt1.o 文件

编译时带”-v“ 参数可是看到相关信息

gcc_verbose

_start 作用

crt1.o 是 C 运行时库 (C Runtime Library CRT) 提供的一个用于辅助应用程序正常运行的特殊文件,汇编代码见文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <sysdep.h>

ENTRY (_start)
  cfi_undefined (rip)
  xorl %ebp, %ebp  /* 复位 ebp */
  mov %RDX_LP, %R9_LP   /* 保存 FINI 函数的地址到 r9 */
#ifdef __ILP32__
  /* 模拟 ILP32 模型下的栈操作,将位于栈顶的 argc 放入 rsi */
  mov (%rsp), %esi  
  add $4, %esp  /* 同时让栈顶向高地址移动 4 字节 */
#else
  popq %rsi  /* 将位于栈顶的 argc 放入 rsi */
#endif
  mov %RSP_LP, %RDX_LP  /* 将 argv 放入 rdx */
  and $~15, %RSP_LP  /* 对齐栈到 16 字节 */
  pushq %rax  /* 将 rax 的值存入栈中,以用于在函数调用前保持对齐状态 */
  pushq %rsp  /* 将当前栈顶地址存入栈中 */

  xorl %r8d, %r8d  /* 复位 r8 */
  xorl %ecx, %ecx  /* 复位 ecx */
#ifdef PIC
  /* 将 GOT 表项中的 main 函数地址存放到 rdi */
  mov main@GOTPCREL(%rip), %RDI_LP  
#else
  mov $main, %RDI_LP  /* 将 main 函数的绝对地址存放到 rdi */
#endif
  /* 调用 __libc_start_main 函数 */
  call *__libc_start_main@GOTPCREL(%rip)
  hlt  
END (_start)
  .data
  .globl __data_start
__data_start:
  .long 0
  .weak data_start
  data_start = __data_start

_libc_start_main 函数原型

1
2
3
4
5
6
7
int __libc_start_main(int (*main) (int, char**, char**), 
                      int argc, 
                      char **argv, 
                      void (*init) (void), 
                      void (*fini) (void), 
                      void (*rtld_fini) (void), 
                      void *stack_end);

_libc_start_main 会为用户代码的执行做一些前期准备工作

  • 执行对用户ID的必要安全性检查
  • 初始化线程子系统
  • 注册 rtld_fini 函数,以便在动态共享对象退出是释放资源
  • 注册 fini 处理程序,以便在程序退出时执行
  • 调用初始化函数 init
  • 调用 main 函数
  • 用 main 的返回值调用 exit 函数

CRT

CRT(C Runtime Library CRT) 为应用程序提供了对启动与退出、C 标准库函数、IO、堆、C 语言特殊实现、调试等多方面功能的实现和支持。

  • crt1.o 提供了 _start 符号的具体实现,仅参与可执行文件的编译过程
  • crti.o 和 crtn.o 两者通过协作,为共享对象提供了可以使用”构造函数“和”析构函数“的能力
  • crtbegin.o 和 crtend.o 分别提供了 ”构造函数“和”析构函数“的实现

第三十讲 ABI 和 API

API

API(Application Programming Interface)侧重点在于编程,通过遵循 API 规范,可以在相应的编程语言代码中使用这些接口。 对于 C 语言来说,标准库头文件中的函数原型便是一种 API 的具体表现, glibc 和 musl 是其的具体实现。

API 的重要特征是提供相应功能的同时隐藏实现细节,让使用者可以按照较为统一和稳定的方式来使用系统的能力。

ABI

ABI(Application Bianry Interface)侧重点在于机器指令层面的具体格式, ABI 将程序、操作系统、硬件平台之间协作需要遵守的特定规则暴露出来。这些规则指定这个体系运行的二进制应用程序应该如何在机器代码层面进行数据访问或函数调用。

API 规范通常涵盖如下内容:

  • 函数调用规范
  • 处理器可以访问的数据类型的大小和对齐方式
  • 进程初始化细节(如栈和寄存器的状态变化)
  • 对象文件(如 .o)的基本结构
  • 程序载入和动态链接的细节

第三十一讲 程序和操作系统交互

什么是系统调用

系统调用是由操作系统内核封装过的一些可供上层应用程序使用的接口

以 getpid 函数具体

1
2
3
4
5
#include <unistd.h>
#include "syscall.h"
pid_t getpid(void) {
  return __syscall(SYS_getpid);
}

__syscall 对应的汇编实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
.global __syscall
.type __syscall,@function
__syscall:
  movq %rdi, %rax  // 将函数对应的ID 存放到 rax 
  movq %rsi, %rdi  // 将函数参数存放对寄存器
  movq %rdx, %rsi
  movq %rcx, %rdx
  movq %r8, %r10
  movq %r9, %r8
  movq 8(%rsp), %r9
  syscall  // 执行指令
  ret

系统调用和用户函数

两者最大区在在于 系统调用执行的代码位于操作系统底层的内核环境中,而用户代码则位于内核之上的应用环境中。

在 x86 架构中, 特权级别分为四个层次

protection_rings

不同特权级别,CPU 能被允许执行的机器指令和使用的起存器不同

系统调用实现

可以使用 int 实现系统调用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
extern sub
global _start
section .text
_start:
  and   rsp,0xfffffffffffffff0  // 16位对齐
  mov   esi, 2
  mov   edi, 1
  call  sub
  # use "int" to invoke a system call.
  mov   ebx, eax  
  mov   eax, 1
  int   0x80

上述代码执行过程如图

syscall