C 语言面试题(30 题,含答案)

适用方向:嵌入式 C、驱动、固件和底层软件。以下答案以标准 C 为基础;涉及编译器扩展或硬件行为时会特别说明。

1. C 程序从源文件到可执行文件经历哪些阶段?

答案: 通常经历预处理、编译、汇编和链接。预处理展开宏、头文件和条件编译;编译把 C 翻译为汇编或中间代码并优化;汇编生成目标文件;链接解析符号和重定位,组合目标文件与库,输出 ELF/可执行映像。嵌入式还会由链接脚本安排 Flash/RAM 段地址,再从 ELF 生成 .bin.hex

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

答案: 声明告诉编译器标识符的类型和属性,定义则实际创建实体或提供函数实现。extern int g_count; 通常是变量声明,int g_count = 0; 是定义;函数原型是声明,带函数体的是定义。一个具有外部链接的对象通常只能有一个定义,但可以在多个文件中通过头文件重复声明。

3. static 关键字有哪些作用?

答案: 文件作用域的 static 使变量或函数具有内部链接,只在当前翻译单元可见;块作用域的 static 局部变量具有静态存储期,只初始化一次,在整个程序运行期存在,但作用域仍局限于该代码块。它不等于线程安全,也不等于数据一定存放在 RAM 的某个固定段,具体布局由工具链和链接脚本决定。

4. const 的含义是什么?const 变量一定在只读区吗?

答案: const 表示不能通过该左值修改对象,是类型系统约束。它不保证一定放在 ROM/只读段,存储位置取决于作用域、初始化方式、编译器和链接脚本。强制去掉 const 后修改一个本来定义为 const 的对象属于未定义行为。硬件寄存器有时需要 volatile const,表示软件只读但硬件可能改变。

5. volatile 有什么作用?它不能解决什么问题?

答案: volatile 告诉编译器每次按抽象机要求实际读取或写入对象,不得把访问随意缓存或消除,常用于内存映射寄存器、ISR 与主程序共享的简单标志等。它不保证复合操作原子性,不提供互斥,不自动建立跨核/线程内存顺序,也不能修复数据竞争。并发同步仍需原子操作、临界区或锁。

6. 解释 const int *pint * const pconst int * const p

答案: const int *p(或 int const *p)是指向只读 int 的指针,可改变 p,不能通过 p 改目标;int * const p 是常量指针,p 自身不能改,但目标可改;const int * const p 两者都不能通过该名字改。阅读复杂声明时可从标识符向外看,先确定 const 修饰指针还是指向类型。

7. 指针和数组有什么区别?

答案: 数组是连续排列的一组元素,数组名在多数表达式中会转换为指向首元素的指针,但数组本身不是指针。sizeof(array) 得到整个数组字节数,sizeof(pointer) 只得指针大小;数组对象不可被整体赋值,指针变量可以改变指向。作为函数形参的 int a[] 会调整为 int *a,因此函数内无法借 sizeof(a) 得知调用方数组长度。

8. sizeof 是函数吗?有哪些注意点?

答案: sizeof 是运算符,结果类型为 size_t,大多数情况下在编译期求值且操作数表达式不执行;变长数组(VLA)是例外,大小可能在运行时求值。sizeof(char) 永远为 1,但一个字节不一定是 8 位,可由 CHAR_BIT 查询。不要把 %d 用于打印 size_t,应使用 %zu(工具链支持 C99 时)。

9. 什么是结构体内存对齐?为什么会有填充字节?

答案: 不同类型通常要求地址满足一定对齐,编译器会在成员之间和结构体尾部插入填充,以提高访问效率并保证结构体数组中的每个元素对齐。例如成员顺序不同可能导致结构体大小不同。协议报文不应直接依赖本机结构体布局,应显式序列化;强制 packed 可能引发非对齐访问、性能下降甚至硬件异常。

10. union 有什么用途?如何可靠处理字节序?

答案: 联合体的所有成员共享同一段存储,大小至少能容纳最大成员并满足对齐,常用于节省互斥数据的空间或描述寄存器视图。用联合体跨成员读取做类型重解释在不同 C 标准/编译器下存在可移植性问题;更稳妥的是用 memcpy 或移位掩码。协议字节序应显式转换,不能依赖联合体成员顺序或主机端序。

11. 大端和小端是什么?如何解析网络字节流?

答案: 大端把多字节数的最高有效字节放在低地址,小端相反。字节序描述多字节对象的字节排列,单字节无所谓端序。解析协议时应按规范显式组合,例如大端 16 位值可写为 ((uint16_t)buf[0] << 8) | buf[1],或使用经过验证的转换函数;不要把未对齐的 uint8_t * 直接强转为 uint32_t * 解引用。

12. 宏和内联函数有什么区别?

答案: 宏在预处理阶段进行文本替换,没有类型检查,参数可能被重复求值,作用域和调试体验也较差;内联函数遵守类型、作用域和单次求值规则,编译器可决定是否真正内联。简单常量、条件编译等适合宏;可表达为函数的逻辑通常优先 static inline。宏参数和整体表达式应充分加括号。

13. 下面的宏有什么问题:#define SQUARE(x) x*x

答案: 缺少括号,SQUARE(a + b) 展开为 a + b*a + b,结果错误;即使改成 #define SQUARE(x) ((x) * (x)),调用 SQUARE(i++) 仍会使 i 被求值两次,并可能触发未定义行为。更好的实现是按类型提供 static inline 函数,或保证宏实参无副作用。

14. 什么是左值、右值和可修改左值?

答案: 左值是指代某个对象的表达式;可修改左值还要求对象类型允许修改且不是数组等受限情形。赋值运算符左侧通常需要可修改左值。右值是常用于计算的值表达式。数组名是左值但不可修改,在多数表达式中会转换为指向首元素的指针。

15. 什么是未定义行为、未指定行为和实现定义行为?

答案: 未定义行为(UB)是标准不规定任何结果,如有符号整数溢出、越界访问、解引用空指针;程序不能依赖其表现。未指定行为允许实现从若干结果中选择且无需说明,如某些求值顺序。实现定义行为由编译器选择并应文档化,如普通 char 是有符号还是无符号。嵌入式代码应结合编译器 ABI 文档,并用警告和静态分析发现风险。

16. i = i++ + 1; 有什么问题?

答案: 该表达式在 C 中对 i 的修改与其他访问/修改缺少所需的先后关系,属于未定义行为(具体标准版本的术语有所变化)。不能根据某次编译结果推断它“先加再赋”或“等于原值加一”。应拆成清晰的独立语句,避免在同一完整表达式中多次修改同一对象。

17. 有符号整数和无符号整数混合运算有什么陷阱?

答案: 经过通常算术转换后,有符号值可能被转换为无符号值。例如 -1 < 1U 往往为假,因为 -1 转为很大的无符号数。无符号溢出按模 2^N 定义,而有符号溢出是未定义行为。长度、下标和返回错误码混用时尤其危险,应统一类型、显式检查范围并避免用无符号值表达负错误。

18. malloccallocreallocfree 有何区别?

答案: malloc 分配未初始化的字节块;calloc 分配数组并把所有位清零,但全零位模式不应被笼统理解为所有类型的语义零;realloc 调整大小,可能原地扩展或搬迁;free 释放。realloc(p, n) 失败时原指针仍有效,所以应先接到临时指针。嵌入式还需考虑碎片、线程安全、确定性和分配失败。

19. 什么是内存泄漏、野指针和悬空指针?

答案: 已分配内存失去所有可用引用而未释放是内存泄漏;指向未知非法地址、未初始化的指针常称野指针;对象生命周期结束后仍指向原地址的是悬空指针。释放后将某一个指针设为 NULL 只能防止通过该别名误用,不能清除其他别名。应明确所有权、成对管理资源并使用动态分析或静态分析工具。

20. memcpymemmove 有什么区别?

答案: memcpy 要求源和目标内存区域不重叠,重叠时行为未定义;memmove 支持重叠,语义上像先复制到临时区域再写目标,实际实现通常按地址选择复制方向。两者都按字节复制,不理解对象内部指针,也不会追加字符串终止符。

21. strlensizeof 的区别是什么?

答案: strlen 在运行时从指针位置查找第一个 \0,返回其前面的字符数,不包括终止符;若没有可访问的终止符会越界,行为未定义。sizeof 获取类型或对象的存储大小,字符数组会包含初始化字符串的 \0,指针则只返回指针大小。二进制数据不能用 strlen 计算长度。

22. strcpysprintf 等函数有哪些风险?如何改进?

答案: 它们若目标容量不足会越界。应在接口中传递缓冲区容量,使用 snprintf 并检查返回值是否大于等于容量;字符串复制可先验证长度再复制,或使用项目统一的安全封装。strncpy 并非总是安全替代:源过长时可能不追加 \0,源很短时还会填充大量零。

23. 函数指针如何声明?有什么典型用途?

答案: int (*fp)(int, int); 声明一个指向“接收两个 int、返回 int”函数的指针。常用 typedef int (*operation_fn)(int, int); 改善可读性。典型用途包括回调、状态机动作表、中断向量、驱动抽象接口。调用前要保证签名完全兼容且指针非空,不能把数据指针和函数指针想当然地互转。

24. 什么是回调函数?使用时要注意什么?

答案: 回调是把函数指针注册给模块,由模块在特定事件发生时调用,实现控制反转和解耦。需明确调用上下文(任务还是 ISR)、是否可阻塞、重入性、参数对象生命周期、注册/注销并发和错误处理。嵌入式回调若在 ISR 中触发,应保持短小并把耗时工作转交任务。

25. 什么是可重入函数?它和线程安全有什么关系?

答案: 可重入函数在尚未完成时被再次调用也能正确工作,通常不依赖可变全局/静态状态,不返回共享静态缓冲区,不调用不可重入函数。线程安全只要求多线程并发调用正确,可以通过锁实现,因此线程安全函数不一定可重入(例如被信号/中断再次调用时锁可能死锁)。ISR 能否调用还要看执行时间和 API 限制。

26. 头文件应如何防止重复包含?

答案: 使用 include guard:#ifndef MODULE_H#define MODULE_H、文件内容、#endif#pragma once 更简洁且被广泛支持,但不是 ISO C 标准。头文件应尽量自包含,包含其声明所需的类型定义,避免在头文件定义具有外部链接的普通函数或变量。

27. extern 的正确用法是什么?

答案: 在某个 .c 文件中定义全局对象,例如 int g_state;;在头文件中写 extern int g_state;,需要它的源文件包含该头文件。不要在头文件中直接写 int g_state;,否则可能造成多重定义或依赖旧的 common 符号行为。更好的模块设计通常是把变量设为文件内 static,通过函数接口访问。

28. 位域适合直接映射硬件寄存器或通信协议吗?

答案: 通常不建议用于可移植的寄存器/协议布局,因为位域的分配顺序、对齐、跨存储单元方式和基础类型细节含有实现定义行为,编译器还可能生成不期望的读改写。可靠做法是使用固定宽度无符号整数配合掩码和移位,并阅读芯片手册。若特定工具链项目使用位域,必须依赖并验证其 ABI。

29. 固定宽度整数类型有什么价值?

答案: <stdint.h>uint8_tint16_tuint32_t 等在实现提供时具有明确位宽,适合寄存器、协议和存储格式。并非所有平台都必须提供所有精确宽度类型;还有至少宽度类型 uint_least32_t 和最快类型 uint_fast32_t。打印时可使用 <inttypes.h>PRIu32 等宏,避免假定 uint32_t 一定是 unsigned int

30. 嵌入式 C 中如何写出更可靠的代码?

答案: 开启高等级编译警告并视关键警告为错误,使用静态分析、代码审查和单元/集成测试;明确整数宽度、缓冲区容量、所有权、超时和错误路径;避免未定义行为、不可控递归和运行期频繁分配;寄存器访问使用厂商定义和必要的 volatile;对 ISR/任务共享数据采用正确同步。安全项目还可采用 MISRA C、CERT C 等规则,但应理解规则背后的风险,而不是机械消警告。


面试作答建议

C 语言题不要只回答“编译器一般会怎样”,要区分:标准保证、实现定义、工具链扩展和硬件事实。遇到有争议的表达式,先判断是否越界、溢出、违反生命周期或存在未定义行为,再讨论具体输出。