# C 语言基础

Source: https://codewiki.com/zh/cpp/c-basics/

> - **what**: C 程序由带类型的对象、表达式、控制流和函数组成；实现先翻译源文件，再把所需定义链接成程序。
> - **trap**: C 的类型规则不会自动保护边界和生命周期；未初始化读取、有符号溢出或格式说明符错误都可能产生未定义行为。
> - **fix**: 明确类型与范围，检查库函数的结果，并用严格警告和 sanitizer 验证生成的代码。

## 是什么，为什么存在

C 是一门静态类型的编译型语言。程序用声明描述对象和函数的类型，用表达式计算值，再用语句决定执行顺序。它只提供少量语言机制，却允许代码直接表达内存布局、位级运算和外部接口，因此常见于操作系统、嵌入式软件、运行库和跨语言接口。

“接近硬件”不等于“每条语句都对应一条机器指令”。C 标准描述的是抽象机及其可观察行为，具体实现再把合法程序映射到目标平台。编译器可以重排或删除不影响可观察结果的工作，但前提是程序没有触发未定义行为。

基础 C 代码主要回答四个问题：数据是什么类型，表达式按什么规则求值，下一条执行哪条语句，以及数据在哪个作用域内可见。指针、数组、动态内存和文件 I/O 都建立在这些规则之上，本主题只介绍理解后续主题所需的边界。

你会在 `.c` 源文件、头文件声明、构建日志和编译器诊断中遇到这些概念。一个能运行的短程序通常至少包含头文件引入、`main` 函数、若干声明与表达式，以及表示成功或失败的返回值。

| 需求 | C 中的机制 | 要自己维护的契约 |
|---|---|---|
| 表示数据 | 对象与类型 | 范围、初始化状态 |
| 计算结果 | 运算符与表达式 | 转换、溢出、求值顺序 |
| 选择执行路径 | `if`、`switch`、循环 | 边界、终止条件 |
| 复用行为 | 函数与声明 | 参数含义、返回值、错误约定 |

## 工作原理

### 从源文件到程序

一个源文件经过预处理后形成翻译单元（translation unit）。预处理处理 `#include`、条件编译和宏替换；编译器随后分析声明、类型与语句，并生成目标代码。链接器把目标文件及所需库定义组合成可执行程序。具体命令和中间文件属于工具链，而不是 C 语言本身的固定要求。

声明告诉编译器一个名称表示什么。定义还会提供函数体，或为对象保留存储。头文件通常放共享声明，源文件放定义；若多个翻译单元需要同一个函数，它们看到的声明必须兼容，而且整个程序要能找到所需定义。

以 GCC 为例，`gcc -std=c2x -Wall -Wextra -Wconversion -Wpedantic program.c -o program` 会请求接近 C23 的语言模式并启用一组有用警告。GCC 13 把该模式命名为 `c2x`，所以本文用这个拼写验证示例。警告选项不是语言语义的一部分，不同编译器的名称也可能不同。

常见的构建路径可以分成四步：

1. 预处理展开头文件、宏和条件分支。
2. 编译检查语法与类型，并生成目标代码所需的信息。
3. 汇编产生包含机器码和重定位信息的目标文件。
4. 链接解析跨文件名称及库引用，生成最终程序。

### 对象、类型与初始化

对象是一块在执行期间保存值的数据存储。声明 `int retries = 3;` 创建一个名为 `retries` 的 `int` 对象并初始化它；之后的赋值会改变其值，但不会改变它的类型。`const` 限定符表示不能通过该左值修改对象，并不意味着编译器必须把它放进只读硬件存储。

C 规定类型的能力和最小范围，但许多精确宽度由实现决定。`sizeof(char)` 永远是 `1`，这个单位叫字节；一个 C 字节至少有 8 位，却不必恰好是 8 位。不要在可移植代码中假定 `short`、`int`、`long` 或指针的固定大小，应使用 `sizeof`、`limits.h` 或明确宽度的整数类型表达真实需求。

基本算术类型包括整数类型和浮点类型。整数类型又有有符号与无符号版本，普通 `char` 的有符号性由实现选择。`void` 表示没有值或不完整的对象类型；数组、指针、函数、结构体、联合体和枚举在各自主题中继续展开。

对象在第一次读取前必须有适合其类型的值。具有自动存储期的普通局部整数若没有初始化，其值不适合直接读取；文件作用域对象和 `static` 局部对象则在程序启动阶段进行零初始化。最可靠的习惯是在声明处给局部对象提供有意义的初值。

| 写法 | 含义 | 需要留意 |
|---|---|---|
| `int count = 0;` | 创建并初始化可修改整数 | 范围由实现的 `int` 决定 |
| `const double rate = 0.2;` | 通过 `rate` 不可修改 | 浮点表示通常不是十进制精确值 |
| `unsigned mask = 1u;` | 创建无符号整数 | 与有符号值混算会触发转换 |
| `int pending;` | 创建但不显式初始化 | 赋值前不要读取 |

### 表达式与转换

表达式由操作数和运算符构成，并产生值或副作用。乘除和取模的优先级高于加减，比较运算又高于逻辑与、逻辑或。优先级只决定如何分组，不决定所有子表达式的求值先后；当顺序影响结果时，应拆成多条语句。

两个整数相除仍得到整数，小数部分向零截断。因此 `7 / 2` 得到 `3`，而 `7 / 2.0` 会先把整数转换为 `double`，结果为 `3.5`。强制转换可以明确请求转换，却不能恢复已经丢失的精度，也不能让超出目标类型范围的值自动变安全。

较窄的整数参与多数算术运算前会发生整数提升（integer promotion）。二元算术的两个操作数随后按通常算术转换取得共同类型。尤其要小心有符号整数和无符号整数混合：负值可能先转换成很大的无符号值，再参与比较或运算。

有符号整数溢出属于未定义行为。无符号整数按其类型可表示值的数量取模回绕，这是定义好的语义，但未必符合业务意图。做计数、大小和金额运算时，先确定允许范围，再在运算前检查边界。

条件把标量零解释为假，把非零解释为真。`&&` 和 `||` 从左向右求值并短路，因此常用来先验证前置条件，再执行可能不安全的操作。不过，短路不能修复左侧本身已经触发的越界、无效读取或溢出。

### 控制流、函数与作用域

`if` 适合按布尔条件选择路径，`switch` 适合按一个整数或枚举值选择离散分支。`switch` 从匹配的 `case` 开始执行，除非遇到 `break`、`return` 或其他跳转，否则会继续进入后续分支。需要贯穿执行时应明确注释；不需要时就写出 `break`。

`for` 把初始化、继续条件和每轮更新放在一起，适合有清晰迭代变量的循环。`while` 在每轮之前检查条件，`do while` 则至少执行一次。循环边界通常写成半开区间，例如从 `0` 开始并在 `index < count` 时继续，这与元素数量的表达方式更容易配合。

函数声明给出名称、返回类型和参数类型，函数定义再提供函数体。写成 `int main(void)` 明确表示 `main` 不接收参数；普通无参函数也应写出 `void` 参数列表。调用函数时，C 总是按值传递参数；即使参数是指针，被复制的仍然是指针值。

块内声明的名称从声明点到块结束可见，内层块可以遮蔽外层同名名称。文件作用域名称可以被同一翻译单元中的后续声明引用，链接则决定名称能否跨翻译单元对应到同一实体。作用域描述名称在哪里可见，存储期描述对象的存储存在多久，两者不要混为一谈。

### 程序入口与退出状态

在宿主环境中，程序从 `main` 开始执行。`int main(void)` 适合不读取命令行参数的程序，`int main(int argc, char *argv[])` 则接收参数数量和参数字符串。到达 `main` 的右花括号等价于返回 `0`，但显式的 `return 0;` 能让示例的成功路径更直观。

返回给宿主环境的零值表示成功，非零值表示某种失败；`<stdlib.h>` 还提供 `EXIT_SUCCESS` 和 `EXIT_FAILURE`。具体非零值的含义属于程序与调用方之间的接口契约，不要假定所有平台都会原样保留任意大的返回值。

## 示例

下面四个程序依次加入类型与算术、函数与分支、循环，以及 `switch`。它们都是独立文件；每个输出都来自本地 GCC 13.3.0 以 `-std=c2x -Wall -Wextra -Wconversion -Wpedantic -Werror` 编译后的实际运行结果。

### 带单位的金额计算

金额先以整数分保存，最后才转换为欧元。这样能让数量和单位清楚，也能避免在中间步骤反复进行二进制浮点舍入。

<!-- quick -->

```c
// file: order_total.c
#include <stdio.h>

int main(void) {
    const int unit_price_cents = 725;
    const int quantity = 3;
    const int subtotal_cents = unit_price_cents * quantity;
    const double tax_rate = 0.20;
    const double tax_euros = subtotal_cents * tax_rate / 100.0;
    const double total_euros = subtotal_cents / 100.0 + tax_euros;

    printf("items: %d\n", quantity);
    printf("subtotal: %.2f EUR\n", subtotal_cents / 100.0);
    printf("tax: %.2f EUR\n", tax_euros);
    printf("total: %.2f EUR\n", total_euros);
    return 0;
}
```

```text
items: 3
subtotal: 21.75 EUR
tax: 4.35 EUR
total: 26.10 EUR
```

<!-- /quick -->

`100.0` 让除法在浮点类型中进行。如果写成 `subtotal_cents / 100`，整数除法会先得到 `21`，之后再赋给 `double` 也无法找回丢失的 75 分。

### 用函数封装分支

运费函数接收克数并返回分数；负数输入用 `-1` 表示错误。调用方必须先检查这个约定，再把结果当作价格使用。

```c
// file: shipping.c
#include <stdio.h>

static int shipping_cents(int weight_grams) {
    if (weight_grams <= 0) {
        return -1;
    }
    if (weight_grams <= 500) {
        return 499;
    }
    if (weight_grams <= 2000) {
        return 799;
    }
    return 1299;
}

int main(void) {
    const int weight_grams = 1200;
    const int price = shipping_cents(weight_grams);

    if (price < 0) {
        puts("invalid weight");
        return 1;
    }

    printf("weight: %d g\n", weight_grams);
    printf("shipping: %.2f EUR\n", price / 100.0);
    return 0;
}
```

```text
weight: 1200 g
shipping: 7.99 EUR
```

`static` 放在文件作用域函数声明上，会把该函数的链接限制在当前翻译单元。两个连续的 `if` 已经覆盖较轻区间，后面的条件只处理尚未返回的输入，因此分支边界没有重叠。

### 用循环聚合一段范围

循环使用闭区间 `[first, last]`，所以条件写成 `current <= last`。函数先处理反向范围，避免把无效边界带入循环。

```c
// file: range_total.c
#include <stdio.h>

static long long range_total(int first, int last) {
    long long total = 0;

    if (first > last) {
        return 0;
    }

    for (int current = first; current <= last; ++current) {
        total += current;
    }
    return total;
}

int main(void) {
    for (int end = 3; end <= 6; ++end) {
        printf("1..%d = %lld\n", end, range_total(1, end));
    }

    printf("empty range = %lld\n", range_total(5, 2));
    return 0;
}
```

```text
1..3 = 6
1..4 = 10
1..5 = 15
1..6 = 21
empty range = 0
```

示例输入很小，因此 `long long` 足以容纳总和；函数本身并没有为任意 `int` 边界证明不会溢出。真实接口还应限制范围，或在加法前检查 `LLONG_MAX - current`。

### 用 `switch` 处理离散状态

`switch` 让状态码与处理路径并列显示。每个已知分支都以 `break` 结束，`default` 则保留对未知值的防御。

```c
// file: job_state.c
#include <stdio.h>

static void print_state(int state) {
    printf("state %d: ", state);

    switch (state) {
        case 0:
            puts("queued");
            break;
        case 1:
            puts("running");
            break;
        case 2:
            puts("complete");
            break;
        default:
            puts("unknown");
            break;
    }
}

int main(void) {
    print_state(0);
    print_state(2);
    print_state(9);
    return 0;
}
```

```text
state 0: queued
state 2: complete
state 9: unknown
```

若多个 `case` 确实共享处理逻辑，可以让它们连续出现，只在共享代码后写一个 `break`。不要删掉 `default` 只为消除“不可达”分支；外部输入和未来新增状态都可能到达它。

## 陷阱

### 读取未初始化的自动对象

> **陷阱:** `int result;` 只声明对象，不保证它取得零值。若控制流在赋值前读取 `result`，程序可能触发未定义行为。

**修复方法：** 在声明处初始化，或证明所有到达读取点的路径都完成了赋值。启用 `-Wuninitialized`，并测试错误分支、空输入和提前返回路径；不要把某次调试运行碰巧看到的零当作保证。

### 假定整数大小和符号

> **陷阱:** 把 `int` 写进固定四字节协议，或假定普通 `char` 一定有符号，会把当前平台特征误当作 C 契约。

**修复方法：** 协议字段使用 `<stdint.h>` 中满足需求的类型，并在格式化时使用 `<inttypes.h>` 的宏。普通计数可继续用 `int` 或 `size_t`，但边界必须根据类型的真实范围设计，而不是根据熟悉的平台猜测。

### 混合有符号与无符号值

> **陷阱:** 把 `-1` 错误哨兵与 `size_t` 比较时，负数可能先转换成很大的无符号值，使看似明显的大小关系反转。

**修复方法：** 同一计算域使用兼容类型。若 API 返回有符号错误码，就先检查错误，再转换有效值；若必须转换，先验证源值非负且目标类型能表示它，并让 `-Wconversion` 帮助定位隐式转换。

### 误用 `printf` 格式说明符

> **陷阱:** 可变参数不会携带足够信息让 `printf` 修正类型。用 `%d` 读取 `long`、用 `%f` 读取 `int`，或忘记 `%zu` 对应 `size_t`，都可能产生未定义行为。

**修复方法：** 让每个说明符与实参提升后的类型精确匹配，并保留 `-Wformat=2`。打印固定宽度整数时使用 `<inttypes.h>` 的 `PRI...` 宏，不要根据字节数猜测 `%ld` 或 `%lld`。

### 在一个表达式里多次修改对象

> **陷阱:** `values[index] = index++;` 让 `index` 的读取与修改彼此没有规定的先后关系。优先级能解释表达式如何分组，却不能为这些副作用建立顺序。

**修复方法：** 每条语句只做一次可见状态修改：先写入元素，再单独增加索引。审查包含 `++`、`--`、赋值和函数调用的复合表达式，尤其不要要求模型预测一个本来就没有 C 语义保证的结果。

<!-- deep -->

## C 抽象机与可移植边界

### 四类不完全相同的行为

C 的可移植性依赖对规范边界的准确描述。语言会把一部分结果完全规定下来，把另一部分选择留给实现或某次执行；只有未定义行为意味着标准不再对结果施加要求。把这些类别全叫“随机”会掩盖哪些选择能够查文档、测试或约束。

| 类别 | 含义 | 应对方式 |
|---|---|---|
| 已定义行为 | 标准给出必须满足的结果 | 可直接依赖 |
| 实现定义行为 | 实现选择并记录一种行为 | 查编译器和目标平台文档 |
| 未指定行为 | 实现可从允许集合中选择，无须记录 | 不让业务正确性依赖具体选择 |
| 未定义行为（undefined behavior） | 标准不对结果提出要求 | 通过设计、诊断和测试排除 |

普通 `char` 的有符号性是实现选择的例子，函数实参的求值次序常属于未指定选择，越界访问和有符号溢出则会进入未定义行为。它们的后果不同：前两者仍有语言规定的候选范围，最后一种没有“崩溃”或“继续运行”的保证。

优化器可以假设执行中的程序没有未定义行为。例如，若有符号加法溢出不属于合法执行，优化器就可以基于“不溢出”推导条件。sanitizer 和警告能发现许多问题，却不能证明任意程序不存在未定义行为；接口约束和代码审查仍是第一层防线。

### 作用域、存储期与链接

作用域控制名称在源代码中的可见范围。块作用域适用于函数体和嵌套块中的名称，文件作用域适用于所有函数之外声明的名称，函数原型中的参数名称只在该原型内有意义。内层声明遮蔽外层名称时，两个对象可以同时存在，只是外层名称暂时不可见。

存储期控制对象存储的最短存续时间。普通局部对象通常具有自动存储期，每次进入对应块时创建，并在离开时结束；文件作用域对象和 `static` 局部对象具有静态存储期，贯穿程序执行。动态分配的存储由分配与释放函数控制，具体所有权规则属于内存管理主题。

链接回答不同声明是否指向跨作用域或跨翻译单元的同一实体。文件作用域的 `static` 名称具有内部链接，只在当前翻译单元对应；普通外部函数定义通常具有外部链接。局部变量一般没有链接。把这三个维度分开，才能准确解释“看得见”“还活着”和“是不是同一个定义”。

### 求值顺序与副作用

运算符优先级和结合性在翻译时决定语法分组。例如，`a + b * c` 分组为 `a + (b * c)`。它们通常不规定运行时先求哪个操作数，函数 `consume(first(), second())` 也不能假定 `first()` 总在 `second()` 前执行。

语言用先序关系约束部分求值。`&&`、`||`、逗号运算符以及条件运算符被选择路径中的关键点会建立顺序，但函数实参之间不因此自动排序。如果同一标量对象上的副作用彼此无序，或者与读取该对象的值无序且读取不是为了确定要写入的值，行为可能未定义。

最稳妥的代码把有状态调用和增量操作拆开。临时变量不会天然降低性能；优化器通常能消除只用于表达顺序的中间存储，而审查者可以清楚看到每次读取和修改发生在哪条完整表达式中。

### 整数模型与边界检查

整数转换先考虑转换等级、符号和可表示范围，而不是只比较源码里类型名称的“大小”。如果一个类型能表示另一个类型的全部值，就可以保留数值；否则规则可能把双方带到无符号类型。这个过程解释了为什么 `-1 < sizeof object` 可能为假：右侧是无符号 `size_t`，左侧可能被转换。

边界检查必须发生在可能溢出的运算之前。验证 `a + b <= LIMIT` 本身可能已经计算了会溢出的 `a + b`；对已知非负有符号整数，应改查 `a <= LIMIT - b`，并先保证减法有效。乘法检查还要分别处理零、符号和最小负值，复杂场景应使用经过审查的辅助函数或编译器溢出内建函数。

固定宽度类型如 `uint32_t` 只在实现确实提供恰好 32 位且无填充位的整数类型时才存在。需要“至少 32 位”时，`uint_least32_t` 表达的约束更宽；需要对象大小和索引时，标准库 API 通常使用 `size_t`。类型选择应来自数据契约，不应只是为了让警告消失。

### 诊断与动态检查

严格警告适合捕获可疑转换、遗漏原型、不可达分支和格式字符串问题。把警告当错误能防止新诊断被日志淹没，但第三方头文件或不同编译器可能需要分层配置。抑制某条警告时，应记录已证明的前置条件，而不是用强制转换盖住它。

AddressSanitizer 主要帮助发现越界、释放后使用等内存错误；UndefinedBehaviorSanitizer 能检测部分溢出、无效移位和对齐问题。它们只检查实际执行到的路径，并会改变程序布局与时序，因此不能代替测试设计，也不能证明没有缺陷。

一次可靠的基础验证至少包含严格警告构建、带 sanitizer 的测试构建，以及接近发布优化级别的构建。对依赖实现选择的代码，还应记录编译器、版本、目标架构和相关选项，使下一次验证能够判断环境是否发生变化。

<!-- /deep -->

[检查点: cpp/c-basics](https://codewiki.com/zh/cpp/c-basics/#checkpoint)

## 延伸阅读

先用 GCC 文档核对工具链诊断，再用语言参考核对类型、转换和表达式规则。

- [GCC 警告选项](https://gcc.gnu.org/onlinedocs/gcc/Warning-Options.html)
- [GCC 程序插桩选项](https://gcc.gnu.org/onlinedocs/gcc/Instrumentation-Options.html)
- [cppreference：C 语言](https://en.cppreference.com/w/c/language)
- [cppreference：隐式转换](https://en.cppreference.com/w/c/language/conversion)
- [cppreference：运算符优先级](https://en.cppreference.com/w/c/language/operator_precedence)
