C语言1000问:基础语法、指针与未定义行为

本篇迁移首批 10 张已写有答案的 Flomo 卡片。范围包括声明/定义、整数位宽、符号转换、浮点数、指针、存储类、数组退化和未定义行为。示例按 C 标准语义解释;具体 ABI、类型宽度和编译器诊断仍需结合目标平台。

1. 声明和定义有什么区别?

答案: 声明(declaration)向编译器说明一个标识符的名称和类型;定义(definition)则在适用情况下创建对象或提供函数实体。对象定义通常分配存储,函数定义提供函数体。

  • 声明示例:extern int g_count;、void Update(void);
  • 定义示例:int g_count = 0;、void Update(void) { … }
  • 头文件通常放类型、宏、函数原型和 extern 声明;一个 .c 文件负责全局对象的唯一定义和函数实现。

为什么重要: C 采用分离编译。多个 .c 文件分别编译,最后由链接器解析外部符号。把变量定义放进被多个源文件包含的头文件,容易造成 multiple definition。

flowchart TD
  H[头文件:类型 / 宏 / 声明] --> A[a.c 编译]
  H --> B[b.c 编译]
  A --> L[链接器]
  B --> L
  D[某一个 .c:实体定义] --> L
  L --> E[可执行文件 / 固件]

2. int 在不同平台的位宽不同,如何写可移植代码?

答案: C 标准不保证 int 固定为 32 位,只保证其最小范围。协议字段、寄存器映射、持久化数据和跨平台二进制格式应使用 stdint.h 中的固定宽度类型,如 uint8_t、int16_t、uint32_t、int64_t。

  • 普通计数、循环变量可使用 int,前提是不依赖其精确位宽。
  • 序列化、CAN 信号解码、Flash 数据布局和位运算优先使用固定宽度类型。
  • 数组长度和 sizeof 结果应使用 size_t。
  • 打印固定宽度整数时,优先使用 inttypes.h 中的 PRIu32、PRId32 等格式宏。

易错点: char 的符号性由实现决定;处理原始字节时使用 unsigned char 或 uint8_t 更稳妥。

3. signed 与 unsigned 混合运算为什么容易出错?

答案: 整型运算会先执行整数提升,再按通常算术转换规则寻找公共类型。若同等级的 signed 和 unsigned 类型参与运算,signed 操作数可能转换为 unsigned,负数会变成很大的正数。

示例(假设 32 位 unsigned int):

int a = -1;
unsigned int b = 1;
 
if (a < b) {
    /* 这里通常不会进入:a 转为 unsigned 后是 UINT_MAX */
}

规避方法:

  • 比较前统一类型,并确认转换不会丢失信息。
  • 反向循环不要使用无符号变量与 i >= 0 这种永真条件。
  • 打开 -Wsign-compare 等编译器警告。
  • 对长度、偏移和协议字段建立清晰的有符号性约定。

4. float / double 如何存储?为什么不宜直接用 == 比较?

答案: 常见 IEEE 754 binary32 的结构为符号位 1 位、指数 8 位、尾数 23 位;binary64 为符号位 1 位、指数 11 位、尾数 52 位。许多十进制小数无法用有限二进制位精确表示,例如 0.1。

因此浮点运算结果可能带有舍入误差。若业务允许近似比较,可使用按量纲和误差预算选择的容差:

#include <math.h>
if (fabs(a - b) < epsilon) {
    /* 在给定容差内视为相等 */
}

注意: epsilon 不能机械地固定为 1e-6。应结合数值量级、算法误差、传感器精度和安全需求设定;对精确计数或货币等数据,应考虑整数或定点表示。

5. 指针是什么?地址与地址中的内容有什么区别?

答案: 指针是保存对象地址的变量。指针变量自身的值是地址;对有效指针解引用得到的是该地址处对象的值。

int value = 5;
int *p = &value;
 
/* p 保存 value 的地址 */
/* *p 访问该地址对应的 int 对象 */
  • 修改 p:改变指针指向的位置。
  • 修改 *p:修改指针当前指向的对象。
  • 指针类型告诉编译器解引用时按什么类型访问,以及指针算术的步长。

安全边界: 不能把任意整数当成有效对象地址;解引用空指针、悬空指针或不满足对齐/有效类型要求的地址都可能产生未定义行为。

6. p + 1 为什么不是地址加 1 字节?

答案: 对指向完整对象类型 T 的指针,p + 1 前进一个 T 对象,地址偏移量为 sizeof(T) 字节。数组下标表达式 arr[i] 等价于 *(arr + i)。

  • int * 的步长是 sizeof(int)。
  • uint8_t * 的步长是 1 字节。
  • 结构体指针的步长是 sizeof(struct 类型)。

边界: 指针算术只允许在同一数组对象及其尾后位置范围内进行;不能通过越界指针访问对象。标准 C 不允许对 void * 做标准指针算术,因为 void 没有对象大小。

7. auto、register、static、extern 分别控制什么?

答案: 需要区分存储期、作用域和链接属性,不能把它们简单理解成“变量放在哪个物理存储器”。

  • auto:块作用域对象通常默认具有自动存储期。
  • register:历史上表示建议使用寄存器;现代优化器自行决定,C23 已移除该说明符(较早标准中仍可见)。
  • static:块作用域对象具有静态存储期;文件作用域对象使用 static 时具有内部链接。
  • extern:声明具有外部链接的对象或函数,通常引用其他翻译单元中的定义。

工程用途: 用 static 限制模块私有函数/变量的可见范围;用 extern 声明跨文件共享接口;尽量避免无约束的全局变量。

8. 数组名和指针是一回事吗?为什么 sizeof 不同?

答案: 数组不是指针。数组表达式在多数表达式上下文会转换为指向首元素的指针,但 sizeof、取地址等上下文保留数组类型。

  • sizeof(array):整个数组的字节数。
  • sizeof(pointer):指针对象自身的字节数。
  • 函数形参中的数组声明会调整为指针形参,因此函数内部无法通过 sizeof 获得调用方数组长度,长度需要单独传入。

9. 什么是未定义行为(Undefined Behavior, UB)?

答案: UB 指 C 标准不对某种执行行为施加要求。它不是“随机返回一个结果”;编译器可以基于“程序不会触发 UB”的假设进行优化,最终表现可能是错误结果、崩溃或与调试版本不同。

常见风险包括:

  • 有符号整数溢出。
  • 数组越界、无效指针解引用。
  • 使用未初始化的自动变量。
  • 违反对象有效类型/严格别名规则。
  • 移位量超出类型宽度。
  • 除数为零。
  • 生命周期结束后继续访问对象。
  • 不满足标准规定的求值顺序约束。

注意区分: 未指定行为允许若干结果而不要求实现文档化具体选择;实现定义行为要求实现选择并记录;UB 则没有标准保证。

flowchart TD
  A[C 程序表达式 / 内存访问] --> B{符合标准约束?}
  B -->|是| C[行为受标准约束]
  B -->|否:实现定义| D[查编译器 / ABI 文档]
  B -->|否:未指定| E[标准允许多个结果]
  B -->|触发 UB| F[标准不保证任何结果]
  F --> G[优化器可能基于 UB 不发生的假设优化]
  G --> H[Debug / Release / 平台表现不同]

10. 序列点与求值顺序是什么?为什么 i = i++ + ++i 是 UB?

答案: 关键不是机械记忆“序列点”,而是判断对同一标量对象的读写之间是否存在标准要求的先后关系。C11 起使用 sequenced-before、unsequenced 等术语描述求值顺序。

表达式 i = i++ + ++i 中,对 i 的多次修改和读取之间没有满足要求的先后关系,因此行为未定义。不能依赖某个编译器当前输出的数值。

规避方法: 把副作用拆成独立完整表达式;多个函数实参中也不要依赖副作用的求值先后。

i++;
++i;
/* 两次修改分别位于独立完整表达式中 */

编译与验证清单

  • 使用 -Wall -Wextra -Wconversion -Wsign-conversion 等警告选项(按项目编译器能力调整)。
  • 在主机单元测试中使用 AddressSanitizer 和 UndefinedBehaviorSanitizer。
  • 对嵌入式目标结合静态分析、MISRA C 检查和编译器告警。
  • 对协议数据、寄存器、Flash 结构明确类型宽度、字节序、对齐和生命周期。
  • 关键代码在目标编译器、优化等级和真实 MCU 上做验证,不能只依赖 PC Debug 结果。

Flomo 原始来源