Posts 深入理解linux c
Post
Cancel

深入理解linux c

C 语言中的三种未完全定义行为

C 语言为了在“性能、实现自由度和可移植性”之间取得平衡,并没有对所有程序行为做出完全严格的规定,而是引入了三种不同层级的行为:Implementation-defined、Unspecified 和 Undefined。它们看起来相似,但在工程实践中的含义和风险完全不同,理解它们的差异,是写出可靠 C 代码的基础。


Implementation-defined behavior(实现定义行为)

这类行为的特点是:标准没有规定具体细节,但要求编译器必须选择一种实现方式,并且在文档中明确说明。一旦选定,这种行为在该编译器中就是稳定的、可预测的

例如:

  • int 的大小
  • 一个字节的位数
  • 有符号整数右移的方式

对开发者来说,这意味着代码在当前平台通常是安全的,但一旦更换编译器或体系结构,就可能出现差异。因此,这类行为的本质问题不是“会不会错”,而是“能不能移植”

工程上通常通过以下方式约束:

  • 使用固定宽度类型(如 int32_t
  • 使用编译期断言(static_assert

Unspecified behavior(未指定行为)

与前者不同,这类行为并不是“实现选择一种并固定下来”,而是:

标准允许存在多种合法结果,但不要求编译器说明使用哪一种

甚至在不同执行中也可以做出不同选择。

典型例子是函数参数的求值顺序:

1
printf("%d %d\n", i++, i++);

输出可能是:

1
1 2

也可能是:

1
2 1

这里的问题不在于结果“错”,而在于结果“不稳定、不可依赖”。一旦代码依赖某一种具体结果,就可能在不同编译器、不同优化级别甚至同一次运行中出现变化。

因此,这类行为的核心风险是:

不可预测

工程上的解决方式很直接:

  • 拆分表达式
  • 显式控制执行顺序
  • 避免在一个表达式中多次修改同一变量

Undefined behavior(未定义行为)

Undefined behavior 是三者中最需要警惕的一类。

标准对其完全不做任何规定,一旦触发,结果可以是任意的

包括:

  • 看似正常
  • 明显错误
  • 被编译器优化掉

常见例子包括:

  • 有符号整数溢出
  • 数组越界访问
  • 解引用非法指针
  • 表达式中无序修改同一变量

很多人误以为这是标准的缺陷,实际上这是刻意设计:一方面避免为极端情况强行定义语义而限制优化,另一方面允许编译器在假设“代码没有错误”的前提下进行激进优化。


为什么会出现 “O0 正常,O2 出错”?

这是 Undefined Behavior 最典型的表现。

代码在 -O0 下正常,但在 -O2 下出错,本质是未定义行为被优化放大

-O0

  • 编译器基本按顺序翻译代码
  • 很少做优化
  • UB 往往只是“碰巧工作”

-O2

编译器会基于一个关键前提进行推理:

程序不会触发未定义行为

一旦这个前提被破坏,优化就可能:

  • 重排指令
  • 消除分支
  • 删除代码

从而改变程序原有语义。


示例:整数溢出

1
2
3
4
5
6
7
int foo(int x) {
    if (x + 1 > x) {
        return 1;
    } else {
        return 0;
    }
}

由于有符号整数溢出属于未定义行为,编译器可以假设:

1
x + 1 > x // 永远成立

于是优化为:

1
return 1;

结果:

  • -O0:逻辑看似正常
  • -O2:分支被优化掉

Undefined Behavior 的真正危险

未定义行为的危险不仅在于“结果不对”,更在于:

它会破坏程序的可推理性

一旦触发,编译器的任何优化都是合法的,错误往往表现为:

  • 不稳定
  • 难复现
  • 难定位

这也是为什么在工程实践中,Undefined Behavior 是:

必须彻底避免的行为


总结

从整体上看,这三类行为可以理解为三个层级:

  • Implementation-defined:有规则,但因平台而异(影响可移植性)
  • Unspecified:有多种可能,但不保证选择(影响确定性)
  • Undefined:完全没有规则(破坏正确性)

写 C 程序的过程,本质上就是在这些边界之内进行控制:

接受必要的实现差异,避免不稳定行为,杜绝未定义行为

Scope

作用域(Scope) 作用域决定了程序中哪些地方可以直接访问这个名字。

  • 局部变量:仅函数内部可访问。
  • 局部 static 变量:函数内部可访问,但保留值,不随函数调用销毁。
  • 全局变量:文件内及其他文件可访问(如果不是 static)。
  • 全局 static 变量 / static 函数:仅在当前文件可访问,隐藏实现细节。

链接属性(Linkage) 链接属性决定了变量或函数是否可以被其他翻译单元访问。

  • External linkage(外部链接):可跨文件访问(普通全局变量、非 static 函数)。
  • Internal linkage(内部链接):只能在本文件访问(全局 static 变量、static 函数)。
  • No linkage(无链接):局部变量或局部 static 变量,名字不参与链接。

生命周期(Lifetime) 生命周期决定了变量在内存中存在的时间。

  • 自动存储期(auto):局部变量,函数调用时创建,函数返回销毁。
  • 静态存储期(static):全局变量、static 变量,程序整个运行期间一直存在。

在 C 语言中,跨文件使用变量和函数时,必须同时理解作用域(scope)和链接属性(linkage):作用域决定“名字在当前代码里能不能被访问”,而链接属性决定“不同文件中的这个名字是不是同一个对象”。普通的全局变量(如 int g;)具有外部链接(external linkage),可以被其他文件通过 extern 引用;而 extern int g; 本身只是声明,不分配内存,它告诉编译器“这个变量在别的文件中定义”。与之相对,使用 static 修饰的全局变量或函数具有内部链接(internal linkage),只能在当前 .c 文件中使用,对外不可见,本质上相当于“文件私有”。在多文件工程中,一个核心原则是:头文件只放声明(extern 和函数声明),定义必须且只能出现在一个 .c 文件中,否则就会出现 multiple definition 的链接错误。另外需要特别注意的是,如果在头文件中使用 static 定义变量,那么每个包含该头文件的 .c 文件都会生成一份独立副本,它们彼此不共享,这往往会导致“看起来是同一个变量,实际却不同步”的隐蔽问题。总结来说:extern 用于跨文件引用,static 用于限制在本文件,不加修饰的全局变量默认可被多个文件共享;写代码时应严格控制定义位置和可见范围,否则问题往往出现在链接阶段而不是编译阶段。

Storage Duration

这一章的核心在于区分作用域(scope)和存储期(storage duration)这两个容易混淆的概念:作用域决定“名字在哪里可见”,而存储期决定“对象在什么时候存在”。最有价值的理解是,C 语言中的对象可以同时具备“局部可见性”和“全局生命周期”,典型例子就是 static 局部变量——它的作用域仍然局限在函数内部,但生命周期贯穿整个程序执行过程,因此可以看作是“隐藏起来的全局变量”。这种设计允许我们在不暴露全局状态的前提下,保留跨函数调用的状态,是一种兼顾封装性和持久性的手段。从本质上讲,C 程序就是在“通过受作用域限制的名字,访问具有不同生命周期的内存对象”,而很多错误(尤其是悬空指针)都来源于访问了已经结束生命周期的对象。

Alignment

在 C 中,对齐(alignment)是类型对内存访问地址的约束,而不是内存本身的属性:每种类型除了大小(size)外,还隐含一个对齐要求(通常为 2 的幂),访问该类型对象时,地址必须满足这个对齐,否则就是未定义行为(UB)。编译器通常会自动为对象和 malloc 返回的内存提供正确对齐,但当你使用 char 缓冲区或进行类型转换(cast)时,风险就出现了——例如把 unsigned char* 强制转换为 struct S*,如果地址不满足 struct S 的对齐要求,就可能导致性能下降、错误甚至崩溃,这属于典型的 UB。相比之下,memcpy 是安全的,因为它只是按字节拷贝,不涉及类型化访问。C11 提供了 _Alignas 来显式指定对齐,从而避免这类问题。总结来说:内存本质是无类型的字节序列,类型决定“如何访问”,而一旦访问方式(类型)与地址对齐不匹配,就会触发未定义行为,这一点在类型转换中尤其隐蔽且危险。

1
2
3
4
5
6
7
8
9
10
11
struct S {
  int i; double d; char c;
};

int main(void) {
  unsigned char bad_buff[sizeof(struct S)];
  _Alignas(struct S) unsigned char good_buff[sizeof(struct S)];

  struct S *bad_s_ptr = (struct S *)bad_buff;   // wrong pointer alignment
  struct S *good_s_ptr = (struct S *)good_buff; // correct pointer alignment
}

Object Types

一、核心视角:对象类型 = “能存什么值 + 怎么表示”

这一节讲的是 C 里最基础的“对象类型”(object types),也就是变量里到底能存什么数据。你可以把它理解为:在“内存解释模型”之上,再加一层——允许的值域 + 表示方式。不同类型不仅决定占多少字节,还决定值是否合法、如何参与运算,以及潜在的溢出/转换风险。


二、布尔类型(_Bool / bool):本质是受限整数

_Bool 本质是一个只能存 0 或 1 的整数类型,任何非 0 赋值都会变成 1。<stdbool.h> 只是提供了 bool / true / false 的语法糖(宏)。需要注意的是,它不是“独立逻辑类型”,而是一个强约束的整数


三、字符类型(char / signed char / unsigned char):三种不同类型

C 里有三个字符类型:charsigned charunsigned char。 关键点:

  • char 不是 signed/unsigned 的别名,而是独立类型
  • 实现决定 char 是有符号还是无符号
  • char 主要用于“字符/字节数据”,不是小整数(这一点很多人用错)

👉 一个重要底层点: 只有 char 类型可以安全地逐字节访问任意内存(raw memory),这也是它常被用于 buffer 的原因。


四、宽字符(wchar_t):为大字符集服务

普通 char 不够表示多语言字符(比如中文),所以引入了 wchar_t

  • 通常是 16 或 32 位
  • 用于表示更大的字符集(如 Unicode)

本质:

👉 更宽的整数,用来装“更大的字符编码”


五、整数类型(Integer Types):分层 + 平台相关

整数类型分两大类:

  • 有符号:signed char / short / int / long / long long
  • 无符号:对应的 unsigned 版本

关键点:

  1. 大小是平台相关的(比如 int 可能是 32 位)
  2. 有“宽度层级保证”:

    1
    
    long long ≥ long ≥ int ≥ short ≥ char
    
  3. 无符号类型:

    • 只能表示非负数
    • 溢出是“模运算”(不会 UB)

👉 建议:

  • 精确控制大小 → 用 <stdint.h>(如 uint32_t
  • 不要假设 int 一定是 32 位

六、枚举(enum):带名字的整数

1
enum day { sun, mon, tue, ... };

本质:

👉 一组带名字的整数常量

特点:

  • 默认从 0 递增
  • 可以手动赋值
  • 类型本质还是整数(通常是 int)

⚠️ 注意:

  • 枚举值可以重复
  • 不要把它当“强类型”(C 里不是)

七、浮点类型(float / double / long double):近似实数

浮点数用于表示小数,本质是:

👉 有限精度的近似值(不是精确实数)

特点:

  • 通常基于 IEEE 754
  • 有精度误差
  • 运算不满足严格数学规则(比如结合律)

👉 核心认知:

浮点数问题不是 bug,是设计本质


八、void 类型:没有值,但很关键

void 表示“没有值”:

  • 函数返回 void → 不返回数据
  • 参数写 void → 无参数

但:

1
void *p;

👉 是完全不同的东西:

👉 “无类型指针”,可以指向任意对象


九、关键风险点(结合你前面那一章)

这一节虽然在讲“能存什么”,但真正危险的点在这里:

👉 类型不仅限制值,还影响转换和访问行为

典型坑:

  1. 有符号 / 无符号混用

    • 可能出现意外比较结果
  2. 整数溢出

    • 有符号溢出 → UB
    • 无符号溢出 → 合法但容易出 bug
  3. char 符号性不确定

    • 跨平台行为不同
  4. void* 强转

    • 如果后续访问不匹配真实对象 → UB(结合上一章)

十、一句话打通这一章

👉 对象类型定义“能存什么值”,而一旦参与访问或转换,它就和内存模型一起决定程序行为;类型用错,不只是值错,可能直接进入未定义行为(UB)。

Function Types

函数类型由“返回值类型 + 参数类型列表”共同决定,本质上定义了函数的调用方式;函数声明通过声明符描述这一类型,而带完整参数列表的声明(函数原型)可以让编译器在调用时进行类型检查。需要特别注意:在 C 中 f() 表示参数未指定(可接收任意参数),而不是无参数函数,正确写法应为 f(void);否则编译器无法校验参数,容易导致调用约定不匹配,进而产生未定义行为(UB)。函数定义是在声明基础上的具体实现,且函数返回值不能是数组类型,类型信息一旦不一致(如参数或返回值错误),本质上就是“调用方式错了”,可能直接破坏程序运行。

Derived Types

一、核心模型:类型 = 内存解释 + 访问规则

C 的类型系统本质不是“数据长什么样”,而是“如何解释一段内存以及如何访问它”。内存本身只是无类型的字节序列,类型决定了访问时的大小、对齐要求以及读取方式。一旦访问方式(类型)与实际内存状态不匹配,就会产生未定义行为(UB)。


二、指针(Pointer):带类型的访问入口

指针不仅是地址,更是“按某种类型访问内存的方式”。解引用(*)时,要求指针指向合法对象且满足对齐,否则就是 UB。&* 在语义上互为逆操作(如 ip = &*ip),但前提是指针本身有效。


三、数组(Array):连续内存 + 指针退化

数组是一段连续内存,元素类型相同。数组名在表达式中会自动退化为指向首元素的指针,a[i] 等价于 *(a+i)。数组越界访问不会报错,但属于 UB,是 C 中最常见的隐患之一。


四、多维数组:嵌套数组而非指针数组

多维数组本质是“数组的数组”,例如 int arr[3][5] 是 3 个 int[5] 的集合。arr[i] 是一整行(int[5]),再取 [j] 才是元素。它与 int** 完全不同,内存布局也不同。


五、typedef:类型别名而非新类型

typedef 只是为已有类型起别名,不会创建新类型,也不会改变类型的大小、对齐或行为。其主要作用是提升代码可读性,但不影响底层语义。


六、结构体(struct):带对齐的组合内存

结构体将不同类型的数据按顺序组合在一起,编译器可能插入 padding 以满足对齐要求。成员访问本质是“按类型规则 + 偏移访问内存”。错误的类型转换或未对齐访问 struct 也会导致 UB。


七、联合体(union):同一内存的多种解释

union 的所有成员共享同一块内存,同一时刻只能安全使用一个成员。写入一个成员后再以另一种类型读取,本质是改变“解释方式”,可能导致未定义或实现相关行为,是典型的底层风险点。


八、Tag 与类型名:C 的类型命名机制

struct/union/enum 的 tag(如 struct s 中的 s)不是类型名,必须带关键字使用。typedef 常用于为其创建别名以简化使用。tag 和普通标识符属于不同命名空间。


九、未定义行为(UB)的核心来源(重点)

本章所有风险可以归结为一点:访问方式(类型)与内存实际状态不匹配。常见情况包括:指针类型转换导致未对齐或类型不符、数组越界访问、错误使用 union、解引用非法指针等。C 编译器不会帮你检查这些问题,一旦发生 UB,程序行为不可预测。


十、一句话总结

C 中一切问题的根源在于:内存是无类型的,而类型决定访问方式;只要访问方式错了,就进入未定义行为。

Type Qualifiers

一、限定符的本质:约束“如何访问对象”

类型限定符(const / volatile / restrict)不会改变数据本身的布局,而是约束访问行为,本质仍然是“规定这块内存可以怎么被读写”。


二、const:只读约束(但可以被绕过 → UB)

const 表示对象不可修改,但只是“语义约束”。如果对象本身就是 const,却通过强转去写(如 (int*)&i),属于未定义行为(UB),因为编译器可能把它放在只读内存。 关键点:能不能改,取决于“对象是否本来就是 const”,不是指针类型


三、volatile:禁止优化(每次都必须真的读写)

volatile 告诉编译器:这个值可能“在程序之外变化”(如硬件寄存器、时钟)。因此每次访问都必须实际发生,不能缓存或优化掉。 本质:约束编译器行为,而不是提供线程安全


四、restrict:无别名承诺(换性能,但有风险)

restrict 用在指针上,表示“这块内存只通过这个指针访问”。编译器可以据此大胆优化(比如重排、寄存器缓存)。 但前提是:指针之间不能指向同一块内存,否则就是 UB


五、统一理解(重点)

三者可以统一为:

  • const:不能写
  • volatile:必须真的读写
  • restrict:只能通过我访问

👉 一旦违反这些“访问承诺”,本质就是: 用错误规则访问内存 → 未定义行为(UB)


六、一句话总结

👉 类型限定符不改变数据,只改变访问规则;一旦你用“欺骗编译器”的方式破坏这些规则,结果就是 UB。

3 ARITHMETIC TYPES

【整数(Integer Types)】 C 中的整数是有限位宽的二进制编码而不是数学整数。unsigned 表示模 2^N 的环(溢出会回绕,行为定义良好),signed 通常采用补码表示,但溢出是未定义行为(UB)。因此 unsigned 溢出是“可预测的”,而 signed 溢出是“不可预测的”。

【浮点数(Floating-Point Types)】 浮点数是有限精度的近似表示(符号+指数+尾数),很多值(如 0.1)无法精确表示,不满足结合律等数学性质。运算存在舍入误差,并有 NaN、∞ 等特殊值,不适合做精确比较或循环控制。

【整数 vs 浮点】 整数是离散且精确(范围有限),浮点是连续近似(但不精确)。两者混用时会发生隐式转换(通常转为浮点),可能导致精度丢失。

【类型转换(Conversions)】 C 有显式转换(cast)和隐式转换,真正危险的是隐式转换。编译器会自动调整类型来完成运算,这一过程由整数提升和常规算术转换规则决定。

【整数提升(Integer Promotion)】 char/short 等小整数在运算前会提升为 int(或 unsigned int),过程中可能发生符号扩展(如 -1 被扩展为 0xFFFFFFFF),影响结果。

【常规算术转换(Usual Arithmetic Conversions)】 不同类型参与运算时会统一类型:浮点优先,其次整数;整数中 unsigned 优先级较高。一旦混入 unsigned,signed 值可能被转为 unsigned,导致负数变成大正数。

【signed / unsigned 混用】 signed 与 unsigned 一起运算时,通常会把 signed 转为 unsigned,从而改变数值语义(如 -1 变成很大的正数)。这是最常见且隐蔽的 bug 来源之一。

【溢出与未定义行为(UB)】 unsigned 溢出:定义良好(按模回绕) signed 溢出:未定义行为(UB) 超范围转换(如 float→int 超界):可能是 UB UB 意味着编译器可以假设“不会发生”,从而做出不可预测的优化。

【常见转换风险】 类型转换可能导致截断(大转小)、符号变化(signed↔unsigned)、精度丢失(int↔float)。隐式转换尤其危险,因为不会报错但会改变语义。

【工程实践原则】 避免混用 signed/unsigned;溢出要先判断再计算;不要依赖 signed 溢出;浮点避免精确比较;关键转换尽量显式且确保安全。

【一句话总结】 C 的数值系统本质是“有限位宽编码 + 隐式转换规则”:unsigned 是模运算,signed 溢出是 UB,而最危险的是隐式类型转换(尤其是 signed 与 unsigned 混用),会在不报错的情况下改变程序语义。

4 EXPRESSIONS AND OPERATORS

C Operators & Expressions(重点笔记)

1. 赋值表达式本质

赋值在 C 中是表达式而不是语句,它有返回值(赋值后的值),且类型等于左值类型。链式赋值会逐层发生类型转换,中间结果可能被截断,因此 (c = i) 的结果已经是截断后的值,再参与外层计算。

2. lvalue vs rvalue(核心语义)

lvalue 本质是“能定位到内存对象的表达式”,rvalue 是纯值。*(p+4) 是 lvalue,而 i+1 不是。lvalue 在参与运算时会自动转换为 rvalue。

3. 表达式 = 值计算 + 副作用

表达式不仅计算值,还可能产生副作用(写内存、I/O、函数调用等)。例如 j = a[i] + f() 同时包含读取、计算和写入,是“读 + 改环境”的组合。

4. 求值顺序不确定

C 对大多数表达式求值顺序不做保证(如函数参数)。编译器可自由重排,因此 f()g() 谁先执行不确定,结果可能不同。

5. Undefined Behavior 触发条件

同一变量在一个表达式中被多次修改,或“修改 + 读取”,且没有顺序保证,就会产生未定义行为。例如 i++ * i++

6. 前缀 vs 后缀自增

i++ 返回旧值再递增,++i 先递增再返回新值。区别在返回值语义,而不是执行顺序。

7. 运算符优先级关键点

优先级只决定分组,不保证执行顺序。典型陷阱:*p++ == *(p++)++*p == ++(*p);比较表达式返回 0/1,可继续参与计算。

8. 逻辑运算符的特殊性

&&|| 具有:

  • 左到右求值顺序
  • 短路特性 可避免无效计算,但可能导致副作用不执行。

9. 取模运算规则

余数符号跟被除数一致:-3 % 2 = -1。因此判断奇数应使用 n % 2 != 0,而不是 == 1

10. 位运算与移位风险

移位位数若为负或 ≥ 类型位宽 → 未定义行为;对负数右移 → 实现定义。建议位运算使用 unsigned 类型。

11. 逻辑运算 vs 位运算

&& || 是逻辑运算(有短路),& | ^ 是位运算(无短路)。语义完全不同,不能混用。

12. 逗号运算符

逗号运算符保证左表达式先执行,再执行右表达式,并返回右值。本质是“强制顺序 + 返回最后值”。

13. 指针运算语义

指针加减按元素大小缩放(不是字节)。p+1 实际移动 sizeof(*p)。指针相减得到元素间距(ptrdiff_t)。

14. one-past-the-end 规则

允许指针指向数组末尾的下一个位置(&arr[n]),可用于比较,但不能解引用。

15. sizeof 特性

sizeof 通常在编译期计算,不会执行表达式,返回对象或类型的字节大小(size_t)。


核心总结

C 表达式本质是: “值计算 + 副作用 + 不确定顺序”的执行模型

写表达式必须同时考虑:

  1. 值如何产生
  2. 是否修改状态
  3. 顺序是否被保证

否则极易产生未定义行为。

5 CONTROL FLOW

1. 表达式语句的本质(易忽略)

表达式语句执行后其“值会被丢弃”,真正有意义的是副作用(变量修改、函数调用等)。写 C 时应关注“改变了什么”,而不是表达式结果。

1
2
3
4
5
int a, b, c;

a = 5;           // 不关心表达式的“值”,只关心a变成了5
b = (a = 10);    // 关心!因为(a=10)的“值”10被赋给了b
c = a++;         // 关心!因为a++的“值”(自增前的a)被赋给了c

2. {} 的真实作用(高频坑)

if / while / for 默认只控制一条语句,缩进没有语义意义,只有 {} 才决定作用范围。缺少大括号是最常见逻辑错误来源之一。


3. if 的判断机制(反直觉点)

C 中条件判断本质是:

  • != 0 → true
  • == 0 → false 不是严格布尔值,因此 if(-1)if(100) 都成立。

4. if-else 执行模型(重点)

if-else 链是“从上到下第一个命中即停止”的互斥结构,而不是多个独立 if。顺序非常关键。


5. switch 的本质(核心难点)

switch 是“跳转到某个 case 标签后顺序执行”,而不是逐个判断:

  • case 只是标签
  • 默认会继续执行(fall-through)
  • 必须显式 break 才会停止

6. switch 常见坑

  • ❗忘写 break → 意外贯穿(fall-through)
  • ❗漏写 case(特别是 enum 扩展)→ 逻辑缺失
  • ❗default 的取舍:

    • 有 default → 更安全
    • 无 default → 编译器更容易警告遗漏

7. while vs do-while(易混点)

  • while:先判断,可能执行 0 次
  • do-while:先执行,至少 1 次 本质区别是“判断时机”

8. for 的本质(关键理解)

for 本质等价于:

init; while (cond) { body; step; }

👉 最大坑:step 在循环体之后执行


9. for 循环隐蔽 bug(高危)

在循环体中释放对象(如 free)后,step 仍可能访问该对象(Use After Free),属于未定义行为。


10. continue 的真实行为

continue 会:

  • while:跳到条件判断
  • for:先执行 step,再判断条件 不是简单“回到循环开头”

11. break 的作用范围

break 只会跳出“最近一层”循环或 switch,不会影响外层结构。


12. goto 的正确理解(非主流重点)

goto 本质是无条件跳转,不推荐乱用,但在“资源清理(失败回滚)”场景中非常实用(统一出口,避免重复代码)。


13. return 的致命坑

非 void 函数如果存在路径没有 return 值 → 未定义行为。必须保证“所有路径都有返回”。


🔥 核心总结(必须掌握)

C 控制流的本质就是三件事:

  1. 选择执行(if / switch)
  2. 重复执行(while / for)
  3. 跳转执行(break / continue / goto / return)

⚠️ 最容易出问题的 4 个点

  1. if/for 没加 {} 导致逻辑错误
  2. switch 忘写 break(fall-through)
  3. for/continue 导致执行顺序误判
  4. 函数缺少 return 或使用已释放内存

🚀 一句话记忆

写控制流时,必须想清楚:

👉 哪些代码会执行?执行几次?什么时候跳出?

6 DYNAMICALLY ALLOCATED MEMORY

动态分配内存是在运行时从堆(heap)中分配的存储,其生命周期从分配开始到释放结束。与静态(static)或自动(automatic)存储不同,动态内存大小通常在编译时未知,因此适用于运行时确定大小的数组、链表、哈希表、二叉树等数据结构。常用的内存管理函数包括 malloccallocaligned_allocreallocfree,使用时需注意避免常见错误,如内存泄漏、重复释放(double-free)和访问已释放内存(dangling pointer)。为防止这些问题,可以在 free 后将指针置 NULL,并尽量在同一模块中分配和释放内存。

调试动态内存问题时,可使用 Gray Watson 的 dmalloc 库。该库替换标准内存管理函数,并在运行时提供检测工具,可报告内存分配错误的文件和行号,如重复释放或访问未初始化的内存。使用方法包括安装库、设置日志文件和检查间隔,然后在编译时加 -DDMALLOC 并链接 -ldmalloc,即可捕捉双重释放等问题,从而提高内存管理的安全性和可靠性。

7 CHARACTERS AND STRINGS

Characters

1. char 的本质

  • char 是整数类型,占 1 字节(8 位)。
  • 可以是 signedunsigned,取决于实现。
  • 用于存储字符编码(ASCII / Extended ASCII / UTF-8 的 code unit)。
  • 字符常量 'a' 类型是 int,历史遗留原因。

示例:

1
2
3
char c = 'A';
int i = 'A';  // 类型是 int
printf("%d %d\n", c, i); // 输出:65 65

2. char 与 int 的关系

  • char 参与算术运算或函数调用时,会自动提升为 int(integer promotion)。
  • 注意 signed char 可能被符号扩展,导致高位为 1。

示例:

1
2
signed char c = 200; // 可能是负数
int sum = c + 10;    // 自动提升到 int

3. EOF(End of File)

  • EOFint 类型,通常值为 -1。
  • 用于标记文件/流结尾。
  • 不能用 char 存储 EOF,否则可能与有效字符值冲突。

示例:

1
2
3
4
5
int ch;
while ((ch = fgetc(fp)) != EOF) {
    char c = (char)ch;
    putchar(c);
}

4. 字符处理函数注意事项

  • <ctype.h> 函数(如 isdigit, isalpha)接受 int 参数。
  • 必须先 (unsigned char) 转换,否则可能出现 undefined behavior。

示例:

1
2
3
4
char c = 'ÿ';
if (isdigit((unsigned char)c)) {
    puts("c is a digit");
}

5. 字符编码

  • char 可表示 ASCII、Extended ASCII、Latin-1、UTF-8 的 code unit。
  • UTF-8 中一个字符可能由多个 char 组成。
  • char 不适合存储完整 Unicode code point,需要 wchar_t / char16_t / char32_t

6. wchar_t / char16_t / char32_t

  • wchar_t:处理宽字符(16 或 32 位),实现依赖平台。
  • char16_t / char32_t(C11):用于 UTF-16 / UTF-32。
  • 转换函数:

    • mbrtoc16, c16rtomb
    • mbrtoc32, c32rtomb

7. 字符常量

前缀类型
int
Lwchar_t 对应无符号类型
uchar16_t
Uchar32_t
  • 注意:字符常量 'a' 的类型是 int
  • 多字符常量(如 'ab')是实现定义。
  • 无法保证 source 中的字符一定能表示为执行字符集的单个 code unit。

8. 转义序列

  • 特殊字符需转义:单引号 '', 反斜杠 \\\
  • 常用转义:

    • \n 换行, \t 水平制表, \r 回车, \b 退格, \f 换页
    • 八进制:\0..7 up to 3 digits
    • 十六进制:\xHH

示例:

1
2
3
char newline = '\n';
char backslash = '\\';
char bell = '\a';

9. 文件/输入输出实践

  • 文件读取:

    • int 接收 fgetc/fgetc-like 函数返回值
    • 检查 EOF
    • 再转 char 处理
  • ctype 函数:先转换 (unsigned char) 再调用

示例:

1
2
3
4
int ch;
while ((ch = getchar()) != EOF) {
    if (isalpha((unsigned char)ch)) putchar(ch);
}

10. 总结表

概念类型范围注意事项
charinteger8 位signed/unsigned,存储 code unit
‘a’int97历史原因,字符常量为 int
intinteger32 位参与算术、函数返回
EOFint-1不可存入 char
fgetcint0~255 / EOF循环读取用 int,转 char 使用
ctype 函数int0~255 / EOF参数必须 (unsigned char)

Strings

1. C语言字符串的本质

  • C不提供原生字符串类型,字符串只是 字符数组(char[])宽字符数组(wchar_t[])
  • 窄字符串(narrow string):char数组,最后有\0结束。
  • 宽字符串(wide string):wchar_t数组,也有终止宽字符\0,用于表示Unicode字符。
  • 概念区别

    • size:数组分配的空间大小(字节数)。
    • length:有效字符数(第一个空字符前的代码单元数量)。

理解这点很关键:在C里,数组大小和字符串长度不一定相等,尤其在动态分配内存或者初始化数组时。


2. 字符串字面量

  • 字符串常量用双引号 "ABC" 表示。
  • 前缀决定字符类型:

    • L"ABC" → wchar_t
    • u8"ABC" → UTF-8
    • u"ABC" → char16_t
    • U"ABC" → char32_t
  • 不可修改,修改会导致未定义行为。
  • 初始化数组时的坑

    1
    2
    
    #define S_INIT "abcd"
    const char s[4] = S_INIT; // 最后没空间存 '\0'
    
    • 建议使用:

      1
      
      const char s[] = S_INIT; // 自动分配足够空间
      
  • 使用 sizeof 获取数组分配大小,strlen 获取字符串长度。

这个差别在维护大型代码库时非常重要,否则增加字符可能意外去掉终止符。


3. 字符串处理函数

  • 标准库函数

    • <string.h>:窄字符串函数
    • <wchar.h>:宽字符串函数
  • 常用函数:strcpy, strncpy, strcat, strlen, strcmp,宽字符串对应函数在前加 w
  • 核心点

    • 这些函数不检查数组边界 → 容易产生 缓冲区溢出
    • strlen/wcslen 只返回 代码单元数,不是字节数或字符数。

4. 动态内存与字符串复制

  • 动态分配字符串内存时,必须加1来容纳终止符:

    1
    2
    
    char *str2 = malloc(strlen(str1) + 1);
    wchar_t *wstr2 = malloc((wcslen(wstr1)+1) * sizeof(wchar_t));
    
  • strcpy 可以复制字符串,但不检查长度 → 调用者必须保证安全。
  • memcpy 拷贝原始内存,用于非空终止符数据。

5. 输入函数的安全问题

  • gets 函数极不安全:

    • 不检查数组边界,容易溢出。
    • 已弃用(C99)/删除(C11)。
  • 安全替代gets_s(Annex K)

    • 需要指定数组大小。
    • 会触发运行时约束处理器(abort/ignore)。

6. Annex K 安全函数

  • 例:strcpy_s, strcat_s, strncpy_s, strncat_s
  • 设计原则:

    • 强制检查目标缓冲区大小。
    • 遇到越界或非法参数 → 调用 运行时约束处理器
  • 优点:

    • 避免缓冲区溢出。
    • 更安全,但可能比标准函数慢。
  • Microsoft 和 Visual C++ 提供部分实现,但不完全符合C11标准。

7. POSIX安全字符串函数

  • strdup, strndup

    • 动态分配内存来存储副本。
    • 防止缓冲区溢出,但需要手动 free
  • 典型用法:

    1
    2
    
    char *tmpvar = strdup(getenv("TMP"));
    free(tmpvar);
    

8. 核心理念总结

  1. C字符串 = 数组 + 终止符
  2. 区分 size 与 length,避免数组越界。
  3. 字符串函数安全性:标准函数高效但易错,Annex K/strdup等安全函数更稳健。
  4. 动态内存分配:始终考虑终止符和宽字符大小。
  5. 输入函数必须安全,避免使用 gets
  6. 运行时约束处理器:安全函数提供机制来处理非法操作。

9. char 与 wchar_t 区别

char 是窄字符类型,通常占 1 字节,用于存储 ASCII 或多字节编码的字符(如 UTF-8),每个 char 只能表示单个字节;适合英文、数字、符号等基本字符。wchar_t 是宽字符类型,大小依赖平台(Windows 16 位,Linux 32 位),用于存储 Unicode 字符,如中文、日文或 Emoji,每个 wchar_t 表示一个完整的宽字符(在 Windows 对 BMP 外字符使用代理对表示);适合多语言文本或国际化应用。选择 char 还是 wchar_t,取决于所需字符集和平台兼容性。

8 INPUT/OUTPUT

Standard I/O Streams

好的,我来帮你把这一章 “I/O (输入/输出)” 做一个完整、清晰的总结,兼顾重点和易理解性,同时保留中文笔记风格,方便快速复习:


1. I/O 的本质

  • I/O = 程序与外界交互的方式
  • 数据从程序输入(stdin)或输出(stdout/stderr)
  • 设备类型:文件、终端、打印机、socket 等

2. 标准 I/O 流(Standard I/O Streams)

  • 流(Stream) = 数据管道
  • FILE 表示,每个文件指针 FILE * 代表一个流
  • 保存信息:位置、缓冲状态、错误标志、EOF

预定义流

用途默认缓冲
stdin标准输入(键盘)全缓冲/行缓冲
stdout标准输出(屏幕)全缓冲
stderr标准错误无缓冲
  • 输出/输入可通过重定向改变
  • 流可以用管道连接不同程序的输出和输入

3. 流缓冲(Buffering)

  • 提高 I/O 性能
  • 三种缓冲模式

    1. 无缓冲:立即传输,适合日志、错误
    2. 全缓冲:积累到缓冲区满再传输,适合文件
    3. 行缓冲:遇换行符才传输,适合终端

4. 流方向(Orientation)

  • 窄字符流(char)
  • 宽字符流(wchar_t)
  • 默认新流无方向
  • 第一次用宽字符 I/O → 流变宽字符
  • 第一次用窄字符 I/O → 流变窄字符
  • 不要混用不同类型在同一文件

5. 文本流 vs 二进制流

类型特点适用
文本流按行组织,有换行符文本文件
二进制流原始字节,读写一致图像、音频、结构化数据
  • 文本流跨平台可能出现换行符显示异常(Unix: \n,Windows: \r\n
  • 二进制流更可靠,但不适合普通文本文件互操作

6. 中文或多字节字符处理

  • 文件含中文时:

    • 宽字符流(wchar_t)更安全,每个字符完整表示
    • 窄字符流 + UTF-8 也可,但要小心多字节字符切分

7. 总结要点

  1. 流 = 数据管道,FILE * = 文件指针
  2. 三个预定义流:stdin / stdout / stderr
  3. 缓冲机制优化性能,选择合适的缓冲模式
  4. 流方向决定能读写窄字符还是宽字符
  5. 文本流适合文本,二进制流适合原始数据
  6. 中文/多字节字符要注意选择宽字符流或 UTF-8

Opening and Creating Files

1. 打开文件与流的关联

  • 打开或创建文件时,会生成一个 流(stream)
  • C 标准库用 FILE * 操作流
  • POSIX 系统用 文件描述符(file descriptor, int) 操作文件

2. fopen 函数(C 标准库)

1
FILE *fopen(const char *filename, const char *mode);

常用模式

模式描述
r只读,文件必须存在
w写入,文件不存在则创建,存在则清空
a追加,写入数据从文件末尾开始,文件不存在则创建
r+读写,文件必须存在
w+读写,文件不存在则创建,存在则清空
a+读写,追加到文件末尾
rb, wb, ab二进制模式,对应 r, w, a
r+b, w+b, a+b二进制读写模式

C11 新增独占模式

模式描述
wx, wbX独占写入,文件已存在则失败
w+x, w+bx独占读写,文件已存在则失败

⚠️ 注意:永远不要按值拷贝 FILE 对象,否则可能出现未定义行为(程序崩溃)。


3. POSIX open 函数

1
int open(const char *path, int oflag, ...);
  • 返回 文件描述符(int)
  • 文件描述符用于标识进程中的打开文件
  • oflag 参数:指定文件访问模式 + 状态标志

文件访问模式(必须指定一个)

标志描述
O_RDONLY只读
O_WRONLY只写
O_RDWR读写
O_EXEC执行(非目录文件)
O_SEARCH搜索目录

文件状态标志(可组合)

标志描述
O_APPEND写入追加到文件末尾
O_TRUNC打开时清空文件内容
O_CREAT文件不存在则创建
O_EXCL与 O_CREAT 配合,文件存在则失败

文件权限(mode_t)

  • 创建新文件时,指定权限:

    • S_IRUSR:所有者可读
    • S_IWUSR:所有者可写
    • S_IRGRP:同组可读
    • S_IROTH:其他用户可读

示例:按所有者写入文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <fcntl.h>
#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int fd;
    mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
    const char *pathname = "/tmp/file";

    if ((fd = open(pathname, O_WRONLY | O_CREAT | O_TRUNC, mode)) == -1) {
        fprintf(stderr, "Can't open %s.\n", pathname);
        exit(1);
    }
    // 文件已成功打开
    return 0;
}
  • O_WRONLY | O_CREAT | O_TRUNC → 写入、创建新文件、清空已存在文件
  • 返回非负整数为文件描述符,否则返回 -1 并设置 errno

4. POSIX 其他常用函数

函数用途
fileno(FILE *stream)获取已有流对应的文件描述符
fdopen(int fd, const char *mode)根据文件描述符创建新的流
  • POSIX 文件描述符接口可以访问:

    • 目录
    • 文件权限
    • 符号链接 / 硬链接

核心总结

  1. C 标准库用 FILE *,适合跨平台基本文件 I/O
  2. POSIX 用文件描述符 int,可访问更多底层文件系统特性
  3. fopen + 模式字符串 → 轻量文件操作
  4. open + oflag + mode → 精细控制文件权限、状态和底层特性
  5. 永远不要按值拷贝 FILE 对象,否则未定义行为

Closing Files

1. 为什么要关闭文件

  • 打开文件会分配资源(文件描述符 / 句柄)
  • 如果不停打开文件而不关闭,最终会耗尽系统资源,导致无法再打开文件
  • 所以 使用完文件后必须关闭

2. C 标准库 fclose

```c id=”jqeyv0” int fclose(FILE *stream);

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
#### 功能

* 关闭与流关联的文件
* 将缓冲区中未写入的数据写入文件
* 丢弃缓冲区中未读的数据

#### 注意事项

* `fclose` 可能失败:

  * 例如磁盘已满或使用网络文件系统(NFS)时
* 关闭失败时,常见做法:

  * 终止程序
  * 截断文件,保证内容在下次读取时有意义
* 关闭后,`FILE *` 指针的值 **不确定**
* 可以在同一程序或其他程序中重新打开文件

#### 自动关闭

* 程序结束时,`main` 返回或调用 `exit`:

  * 所有打开的文件都会被关闭
  * 所有输出缓冲都会被刷新
* 调用 `abort` 等非正常退出可能不会关闭文件,未写入的数据可能丢失

---

### 3. POSIX `close`

```c id="ckl6f2"
int close(int fildes);

功能

  • 释放文件描述符资源
  • 文件描述符关闭后,不再指向任何文件

注意事项

  • 如果在关闭过程中发生 I/O 错误:

    • close 返回 -1
    • errno 被设置为 EIO
    • 文件描述符状态不确定,不能再读写或再次关闭
  • POSIX 规则:

    • fopen → 必须 fclose
    • open → 必须 close
    • 如果 open 得到的文件描述符被 fdopen 创建了流,则用 fclose 关闭

核心总结

  1. 文件使用完必须关闭,否则可能耗尽系统资源
  2. fclose 刷新缓冲并关闭流;close 释放文件描述符
  3. 关闭文件后,相关指针或描述符不再有效
  4. 正常程序结束时,系统会自动关闭文件并刷新缓冲

一些杂项

C 标准库提供了多种函数用于从文件或标准输入/输出读取和写入字符或整行文本,包括 fgetcfgetsfputcfputsprintfscanf 等。它们既有窄字符(char)版本,也有宽字符(wchar_t)版本,不过宽字符函数用得比较少,通常推荐直接使用 UTF-8 和窄字符函数以减少错误和安全问题。流操作时需要注意缓冲,写入后可用 fflush 确保数据真正写入文件。

对于二进制文件,C 提供 fwritefread 直接按内存字节读写结构体或数组,速度快且精确保存数据。使用二进制流时,文件指针位置可用 fseekftellfsetpos / fgetpos 调整和查询。注意跨平台读取二进制文件可能受 字节序(endianness) 影响,需要统一存储顺序或标记字节序,以确保数据正确解析。

9 PREPROCESSOR

1. 概述

C 的 预处理器 是编译器在翻译源代码前执行的阶段。它负责将一个文件的内容(通常是头文件)插入到另一个文件(通常是源文件)中,同时支持宏替换:用指定的代码段替换标识符。通过预处理器,可以实现:

  • 文件包含
  • 对象和函数式宏定义
  • 根据实现特性条件性编译代码

2. 编译流程中的位置

编译过程通常分为 8 个翻译阶段(translation phases)。预处理器运行在源代码被翻译成目标代码之前。预处理器只理解基本元素(tokens):头文件名、标识符、字面量以及运算符和标点符号。它不理解函数、变量或类型。

预处理指令

# 开头,例如:

  • #include
  • #define
  • #if

可以包含空格缩进,指令以换行符结束。预处理器执行指令可能改变最终传递给编译器的代码,这段输出通常以 .i 为后缀。

3. 文件包含(#include)

头文件

使用 #include 可以将一个文件的内容插入到另一个文件。常见做法是用头文件共享函数和对象声明。

1
2
3
4
5
6
7
8
// bar.h
int func(void);

// foo.c
#include "bar.h"
int main(void) {
  return func();
}

引号 vs 尖括号

  • #include "file.h":通常在项目路径查找
  • #include <file.h>:通常在系统路径查找

条件包含

使用 #if / #elif / #else / #endif 可以根据宏定义选择性编译:

1
2
3
4
5
#if defined(_WIN32)
#include <Windows.h>
#elif defined(__ANDROID__)
#include <android/log.h>
#endif

生成错误

使用 #error 可以在条件不满足时生成诊断信息:

1
2
3
#else
#error Neither <threads.h> nor <pthread.h> is available
#endif

头文件保护(Header Guards)

防止同一文件被重复包含:

1
2
3
4
#ifndef BAR_H
#define BAR_H
int func(void) { return 1; }
#endif /* BAR_H */

4. 宏定义(#define)

对象宏与函数宏

  • 对象宏:简单标识符替换
  • 函数宏:带参数,可像函数一样调用
1
2
3
4
#define FOO (1 + 1)
#define BAR(x) (1 + (x))
int i = FOO; // int i = (1 + 1);
int j = BAR(10); // int j = (1 + (10));

多行宏

使用 \ 连接多行定义:

1
2
3
4
5
#define cbrt(X) _Generic((X), \
  long double: cbrtl(X), \
  default: cbrt(X), \
  float: cbrtf(X) \
)

宏作用域与取消定义

  • 宏作用域直到 #undef 或翻译单元结束
  • 重新定义前应先 #undef
1
2
#undef NAME
#define NAME(X) X

宏替换细节

  • 参数前 # → 字符串化(stringize)
  • ## → 拼接 token(token pasting)
  • 注意副作用和逗号在宏参数中的处理
1
2
3
4
5
#define STRINGIZE(x) #x
const char *str = STRINGIZE(12); // "12"

#define PASTE(x, y) x ## _ ## y
int PASTE(foo, bar) = 12; // int foo_bar = 12;

5. 类型泛型宏(_Generic)

C 不支持函数重载,但可使用 _Generic 根据参数类型选择函数:

1
2
3
4
5
#define sin(X) _Generic((X), \
  float: sinf, \
  double: sin, \
  long double: sinl \
)(X)

调用 sin(1.5708f) 会映射到 sinf,调用 sin(3.14159) 会映射到 sin

6. 预定义宏

编译器自动定义一些宏:

作用
__DATE__编译日期
__TIME__编译时间
__FILE__当前文件名
__LINE__当前行号
__STDC__是否符合 C 标准
__STDC_VERSION__标准版本
__STDC_NO_THREADS__是否支持线程
其他环境信息

10 PROGRAM STRUCTURE


概述

任何现实世界的系统都由多个组件组成,例如源文件、头文件和库文件。很多系统还包含资源文件,如图像、音频和配置文件。将程序拆分为较小的逻辑组件是一种良好的软件工程实践,因为这些组件比单个大型文件更易于管理。

本章将介绍如何将程序结构化为多个单元,包括源文件和头文件,并学习如何将多个目标文件链接生成库和可执行文件。


1. 多翻译单元(Multiple Translation Units)

概念

  • 每个源文件(.c 文件)经过预处理和编译后生成一个目标文件(.o 文件)。
  • 每个目标文件是一个翻译单元(Translation Unit)。
  • 多个目标文件可以通过链接器(Linker)组合生成最终的可执行文件。

示例

假设有两个源文件 foo.cbar.c,每个文件包含一个函数:

foo.c

1
2
3
4
5
#include "bar.h"

int main(void) {
    return func();
}

bar.c

1
2
3
int func(void) {
    return 42;
}

编译与链接命令:

1
2
3
gcc -c foo.c -o foo.o
gcc -c bar.c -o bar.o
gcc foo.o bar.o -o my_program

注意事项

  • 每个目标文件可以独立编译。
  • 链接时需要确保所有符号都已定义。

2. Header 文件和接口设计

Header 文件作用

  • 声明函数、宏、类型和常量。
  • 提供模块间的接口。
  • 通过 #include 指令包含。

Header Guard

防止重复包含:

1
2
3
4
5
6
#ifndef BAR_H
#define BAR_H

int func(void);

#endif /* BAR_H */

使用示例

bar.h

1
2
3
4
#ifndef BAR_H
#define BAR_H
int func(void);
#endif

foo.c

1
2
#include "bar.h"
int main(void) { return func(); }

3. 库的创建与使用

静态库(Static Library)

  • 将多个目标文件打包成一个 .a 文件。
  • 编译时链接静态库,生成可执行文件。

生成静态库命令:

1
ar rcs libmylib.a foo.o bar.o

使用静态库:

1
gcc main.c -L. -lmylib -o my_program

动态库(Shared Library / DLL)

  • 将目标文件编译成共享库 .so.dll
  • 可在运行时被多个程序共享。

1️⃣ 编译阶段(告诉编译器函数在哪)

假设你有源文件:

1
2
3
4
5
6
7
8
9
10
// foo.c
#include <stdio.h>
void foo() { printf("foo\n"); }

// main.c
#include "foo.h"
int main() { foo(); return 0; }

// foo.h
void foo(void);
步骤
  1. 生成动态库
1
gcc -fPIC -shared foo.c -o libfoo.so

解释:

  • -fPIC → 生成位置无关代码(Position Independent Code),动态库必须的。
  • -shared → 生成 .so 动态库而不是可执行文件。
  1. 编译主程序并链接动态库
1
gcc main.c -L. -lfoo -o main

解释:

  • -L. → 链接器搜索路径,告诉它 libfoo.so 在当前目录。
  • -lfoo → 链接 libfoo.solib 前缀 + .so 后缀自动加上)。
  • 此时生成的 main 可执行文件并不包含库,只是记录了对动态库的依赖。

2️⃣ 运行阶段(程序如何找到动态库)

生成的 main 运行时会去系统查找 libfoo.so,顺序通常:

  1. 环境变量 LD_LIBRARY_PATH(Linux):
1
2
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./main
  1. 系统默认路径/lib, /usr/lib, /usr/local/lib

  2. rpath(嵌入搜索路径)

1
gcc main.c -L. -lfoo -Wl,-rpath=. -o main

-Wl,-rpath=. 把动态库路径嵌入到可执行文件里,这样运行时不用再设置 LD_LIBRARY_PATH


🔹 核心区别

阶段作用命令示例注意点
编译告诉编译器函数声明gcc main.c -L. -lfoo需要头文件 .h
生成动态库生成共享代码文件gcc -fPIC -shared foo.c -o libfoo.so需要 -fPIC
运行程序找到动态库并加载LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH ./main 或 rpath动态库必须存在于搜索路径中

💡 总结

  • -l-L 只在 编译/链接时使用,让编译器知道函数在哪里。
  • 动态库本身不打包到可执行文件里,程序运行时必须找到它。
  • rpathLD_LIBRARY_PATH 决定运行时动态库搜索路径。

4. 编译与链接流程

  1. 预处理:处理 #include#define、条件编译等。
  2. 编译:源文件 .c -> 汇编 .s
  3. 汇编:汇编 .s -> 目标文件 .o
  4. 链接:目标文件 .o + 库 -> 可执行文件

注意

  • 编译器只处理一个目标文件,链接器处理多个目标文件。
  • 链接器负责符号解析与地址分配。

5. 模块化设计原则

  1. 每个模块有清晰的接口和实现。
  2. 避免模块间直接依赖内部实现。
  3. 使用头文件声明接口,源文件实现。
  4. 使用库封装常用模块,方便复用。

总结

本章介绍了如何将程序结构化为多个源文件和头文件,并通过目标文件和库文件进行编译与链接。你学会了:

  • 理解多翻译单元的概念
  • 使用 header 文件声明接口
  • 使用 header guard 防止重复包含
  • 创建和使用静态/动态库
  • 理解编译与链接流程
  • 遵循模块化设计原则

这些方法将帮助你构建可维护、可扩展的 C 程序。

11 DEBUGGING, TESTING, AND ANALYSIS

🧠 总体概述

本章讲的是如何通过一整套工程手段(断言、调试、测试、分析)来保证 C 程序的正确性与健壮性。核心思想不是依赖某一个工具,而是在不同阶段用不同手段逐层兜底


⚠️ Assertions(断言)

断言用于验证程序中的假设,本质是一个布尔表达式。例如:

1
assert(x > 0);

如果条件为假,程序直接中止。

👉 关键点:

  • 不是处理错误,而是发现程序员错误
  • 用于验证“本不应该发生”的情况

🧱 Static Assertions(静态断言)

静态断言在编译期执行:

1
static_assert(sizeof(int) == 4, "unexpected int size");

👉 如果条件不满足,编译直接失败


🔍 示例1:结构体 padding 检查

1
2
3
4
5
6
7
8
9
struct packed {
  unsigned int i;
  char *p;
};

static_assert(
  sizeof(struct packed) == sizeof(unsigned int) + sizeof(char *),
  "struct packed must not have padding"
);

👉 作用:

  • 防止编译器插入 padding
  • 保证结构体内存布局符合预期(比如做网络传输/序列化)

🔍 示例2:平台假设验证

1
static_assert(UCHAR_MAX < UINT_MAX, "FIO34-C violation");

👉 背景:

1
2
int c = getchar();
while (c != EOF) { ... }

👉 问题:

  • 如果 unsigned charint 范围一样
  • 就可能无法区分字符和 EOF

👉 static_assert 的作用:

  • 保证这种“隐含前提”成立

🔍 示例3:数组越界预防

1
2
3
4
static const char prefix[] = "Error No: ";
char str[14];

static_assert(sizeof(str) > sizeof(prefix), "overflow risk");

👉 防止:

  • 后续改 prefix 或数组大小 → 引发 strcpy 溢出

⭐ 核心理解

👉 static_assert = 把运行时 bug 提前到编译期炸掉


🔥 Runtime Assertions(运行时断言)

基本用法:

1
assert(ptr != NULL);

失败时会:

  • 打印表达式、文件、行号
  • 调用 abort()

🔍 示例

1
2
3
4
void *dup_string(size_t size, char *str) {
  assert(size <= LIMIT);
  assert(str != NULL);
}

👉 表达的含义:

  • 调用者必须保证参数合法

🔍 带调试信息

1
assert(str != NULL && "str must not be NULL");

👉 技巧:

  • 利用 "xxx" 永远为真
  • 失败时能输出额外信息

⚠️ Release 版本

1
2
#define NDEBUG
#include <assert.h>

👉 assert 会变成:

1
((void)0)

👉 结论:

  • assert 不会存在于生产环境

❗ 关键原则

❌ 不要这样用:

1
assert(file != NULL);  // ❌ IO错误

✅ 应该这样:

1
2
3
if (file == NULL) {
  // 正常错误处理
}

⚙️ Compiler Flags(编译选项)

编译器行为靠 flag 控制,比如:

1
-Wall -Wextra -Werror

👉 含义:

  • 开启警告
  • 把警告当错误(强制修复)

🔍 优化等级

1
2
3
4
-O0  // 无优化(调试)
-Og  // 适合调试
-O2  // 生产推荐
-O3  // 更激进

👉 注意:

  • 优化越高 → 越难调试

🔍 调试信息

1
-g3

👉 提供:

  • 变量信息
  • 宏信息(g3 特有)

🔍 安全增强

1
-D_FORTIFY_SOURCE=2

👉 能检测:

  • strcpy / memcpy 溢出

🐞 Debugging(调试)

核心不是技巧,而是思维过程


🔍 示例问题(print_error)

1
2
3
4
5
if ((msg != NULL) && (strerror_s(...) != 0)) {
  fputs(msg, stderr);
} else {
  fputs("unknown error", stderr);
}

👉 实际输出:

1
unknown error

🧠 调试过程

  1. 判断执行路径 → 进入了 else
  2. 怀疑条件错误
  3. 拆分变量:
1
errno_t status = strerror_s(...);
  1. 发现:
1
status == 0

👉 bug:

1
if (status != 0)  // ❌ 写反了

🔧 修复

1
if (status == 0)

🔍 第二个 bug

1
rsize_t size = strerrorlen_s(errnum);

👉 问题:

  • 没给 ‘\0’

👉 修复:

1
rsize_t size = strerrorlen_s(errnum) + 1;

⭐ 本质

👉 Debug = 验证假设 + 缩小范围


🧪 Unit Testing(单元测试)

使用测试验证函数行为:

1
EXPECT_STREQ(get_error(ENOMEM), "Not enough space");

🔍 关键点

  • EXPECT → 失败继续执行
  • ASSERT → 失败直接终止

⚠️ 实际坑

1
get_error(ENOTSOCK)

👉 在不同系统返回不同:

  • Windows: “Not a socket”
  • Linux: “Unknown error”

👉 说明:

  • 单测依赖环境

🔍 Static Analysis(静态分析)

不运行代码,分析代码:

👉 能发现:

  • 类型错误
  • 潜在越界
  • 未初始化变量

⚠️ 局限

  • false positive(误报)
  • false negative(漏报)

👉 原因:

  • 停机问题 → 不可完全分析

⚡ Dynamic Analysis(动态分析)

运行程序 + 检测问题

👉 典型方式:

  • 插桩(instrumentation)

🔥 AddressSanitizer(ASan)

编译时加:

1
-fsanitize=address

🔍 示例 bug

1
2
char *msg = malloc(size);
// 没有 free

👉 ASan 输出:

1
memory leak

🔧 修复

1
free(msg);

🔍 能检测的问题

  • use-after-free
  • buffer overflow
  • memory leak

🧠 全章核心总结

这一章最重要的不是某个技术,而是这个模型:

1
2
3
4
5
6
7
8
9
编译期:static_assert + 编译器 warning
        ↓
开发期:assert + Debugger
        ↓
测试期:Unit Test
        ↓
分析:Static / Dynamic Analysis
        ↓
上线

⭐ 一句话精华

👉 C 程序的正确性 = 多阶段、多工具的组合防御,而不是单点保证

This post is licensed under CC BY 4.0 by the author.

Contents

Trending Tags