C语言1000问:内存模型、可移植性与并发基础

本篇迁移 C 语言 1000 问第三批 10 张完整卡片,覆盖字节序、函数栈帧、内存池、位运算、头文件组织、文件 EOF、static 私有化、volatile、堆栈和 const 指针。

21. 大端和小端是什么?跨 MCU 传输数据为什么不能直接 memcpy 结构体?

答案: 大端把高位字节放在低地址,小端把低位字节放在低地址。数值 0x12345678 在两种机器上的内存字节排列不同。跨 MCU 通信、Flash 文件格式和网络协议必须明确字节序,并逐字段序列化/反序列化。

uint32_t value = 0x12345678U;
uint8_t bytes[4];
 
bytes[0] = (uint8_t)(value >> 24);
bytes[1] = (uint8_t)(value >> 16);
bytes[2] = (uint8_t)(value >> 8);
bytes[3] = (uint8_t)value;

直接 memcpy 结构体到报文还会引入填充字节、对齐、枚举宽度和端序问题。应使用明确的定宽类型和显式打包解包。

22. 一次函数调用在底层发生什么?栈帧里有什么?

答案: 调用方按 ABI 传参,执行调用指令;被调函数建立所需栈空间并保存需要保留的寄存器,执行函数体;返回时恢复状态并跳回调用点。具体参数寄存器、栈对齐和保存规则由目标平台 ABI 决定。

栈帧可能包含返回地址、保存的寄存器、局部变量、溢出参数、临时空间和对齐填充。优化后某些局部变量可能完全放在寄存器中,不一定真的出现在栈上。

flowchart TD
  A[调用方准备参数] --> B[Call / 跳转]
  B --> C[建立栈帧]
  C --> D[保存寄存器 / 局部变量]
  D --> E[执行函数体]
  E --> F[恢复栈帧与寄存器]
  F --> G[返回调用方]

23. 为什么嵌入式常用内存池,而不是频繁 malloc/free?

答案: 内存池预先保留一块内存并按固定块或尺寸等级管理,可减少运行时分配的不确定性和外部碎片,便于统计峰值和容量。适合资源受限、长期运行和实时性要求较高的系统。

代价是灵活性降低:池耗尽时必须有明确失败策略,块大小和数量需要按最坏负载设计。内存池也可能有内部碎片、并发保护和生命周期错误,不能简单视为“零风险”。

flowchart LR
  A[启动时预分配内存池] --> B[空闲块链表 / 位图]
  B --> C[申请固定块]
  C --> D[使用]
  D --> E[释放回池]
  E --> B

24. 如何用位运算判断一个数是不是 2 的幂?怎样统计 1 的个数?

答案: 对无符号整数 n,判断 2 的幂可用 n>0 且 (n & (n-1))==0。原因是 2 的幂二进制只有一个 1,减 1 后该位变 0、低位变 1。

统计置位数可用 Kernighan 算法:每次执行 n &= n-1 清除最低位的 1,循环次数等于 1 的个数。位移量必须小于类型宽度;位操作优先使用无符号类型,避免有符号移位和溢出问题。

25. .h 和 .c 文件分别应该放什么?

答案: 头文件通常放可重复包含的声明、类型定义、宏、接口原型和 extern 声明;源文件放对象定义、函数实现和模块私有的 static 函数/变量。头文件会被多个翻译单元展开,若在其中定义普通外部变量或非 inline 函数,可能造成链接时重复定义。

常见模块组织:一个公开头文件描述接口,一个或多个 .c 文件实现细节;私有符号用 static 限制内部链接。

26. 为什么 while (!feof(fp)) 是错误的文件读取循环?

答案: feof 只在上一次读操作因到达文件末尾而失败后才置位,它不能预测下一次读取是否到 EOF。正确方式是根据读取函数返回值控制循环:

size_t n;
while ((n = fread(buf, 1, sizeof buf, fp)) > 0) {
    process(buf, n);
}
if (ferror(fp)) {
    /* 处理 I/O 错误 */
}

文本行读取可用 while (fgets(line, sizeof line, fp) != NULL)。读完后用 ferror 判断错误,用 feof 识别正常 EOF 状态。

27. static 在多文件工程中怎样实现“私有”?

答案: 文件作用域的 static 对象和函数具有内部链接,只能在当前翻译单元内通过该名字访问。C 没有 class private 关键字,常用 static 隐藏模块实现细节,减少符号冲突并稳定对外接口。

  • 公开接口:头文件声明,源文件定义。
  • 私有实现:在 .c 中定义为 static。
  • 不要把需要跨文件共享的 static 变量放进头文件;每个包含它的 .c 都会得到独立副本。

28. volatile 是什么?它能保证 ISR/任务共享变量安全吗?

答案: volatile 告诉编译器对该对象的访问具有可观察副作用,不能像普通变量一样随意缓存或删除访问,常用于内存映射寄存器和异步更新状态。

但 volatile 不保证原子性、线程间同步或完整的内存顺序,也不能替代锁、原子操作和内存屏障。ISR 与任务共享变量时,应根据目标 MCU 的访问宽度、编译器、RTOS 和多核架构选择临界区、原子类型或平台同步原语。

29. 堆和栈有什么区别?嵌入式为什么常限制动态内存?

答案: 栈通常由调用约定和运行时自动管理,函数调用建立栈帧、返回时回收;堆由 malloc/calloc/realloc/free 等接口动态管理,生命周期由程序控制。

  • 栈:分配释放快、生命周期清晰,但容量通常有限,深递归和大局部数组可能导致溢出。
  • 堆:容量更灵活,但分配耗时和碎片受实现与运行历史影响,容易出现泄漏、重复释放和悬垂指针。
  • 嵌入式:对最坏执行时间和内存上界要求高时,常使用静态分配或内存池。

30. const 修饰变量和指针时分别意味着什么?

答案:

  • const int x:不能通过 x 这个标识符修改对象。
  • const int *p:不能通过 p 修改所指对象,但 p 可以改指向。
  • int *const p:p 本身不能改指向,但可通过 *p 修改对象。
  • const int *const p:指针和通过它访问的对象都不能通过该表达式修改。

const 是编译期类型约束,不是 MPU/内存保护;不能把它当成运行时安全隔离机制。

flowchart TD
  A[const int *p] --> B[内容只读]
  A --> C[指针可改指向]
  D[int *const p] --> E[指针只读]
  D --> F[内容可修改]
  G[const int *const p] --> H[指针与内容都只读]

Flomo 原始来源