C 语言基础

从翻译单元、类型与表达式开始,掌握 C23 的控制流、函数和错误边界,并避开整数转换与未定义行为陷阱。

难度 入门 时长 标准深度约 14分钟
版本 C23 (GCC 13.3.0)
what

C 程序由带类型的对象、表达式、控制流和函数组成;实现先翻译源文件,再把所需定义链接成程序。

trap

C 的类型规则不会自动保护边界和生命周期;未初始化读取、有符号溢出或格式说明符错误都可能产生未定义行为。

fix

明确类型与范围,检查库函数的结果,并用严格警告和 sanitizer 验证生成的代码。

是什么,为什么存在

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

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

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

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

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

工作原理

从源文件到程序

一个源文件经过预处理后形成 翻译单元(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; 创建一个名为 retriesint 对象并初始化它;之后的赋值会改变其值,但不会改变它的类型。const 限定符表示不能通过该左值修改对象,并不意味着编译器必须把它放进只读硬件存储。

C 规定类型的能力和最小范围,但许多精确宽度由实现决定。sizeof(char) 永远是 1,这个单位叫字节;一个 C 字节至少有 8 位,却不必恰好是 8 位。不要在可移植代码中假定 shortintlong 或指针的固定大小,应使用 sizeoflimits.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 开始执行,除非遇到 breakreturn 或其他跳转,否则会继续进入后续分支。需要贯穿执行时应明确注释;不需要时就写出 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_SUCCESSEXIT_FAILURE。具体非零值的含义属于程序与调用方之间的接口契约,不要假定所有平台都会原样保留任意大的返回值。

示例

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

带单位的金额计算

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

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;
}
items: 3
subtotal: 21.75 EUR
tax: 4.35 EUR
total: 26.10 EUR

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

用函数封装分支

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

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;
}
weight: 1200 g
shipping: 7.99 EUR

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

用循环聚合一段范围

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

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;
}
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 则保留对未知值的防御。

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;
}
state 0: queued
state 2: complete
state 9: unknown

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

陷阱

读取未初始化的自动对象

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

假定整数大小和符号

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

混合有符号与无符号值

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

误用 printf 格式说明符

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

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

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

深入 C 抽象机与可移植边界

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 的测试构建,以及接近发布优化级别的构建。对依赖实现选择的代码,还应记录编译器、版本、目标架构和相关选项,使下一次验证能够判断环境是否发生变化。

延伸阅读

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

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?