使用方式
题型范围:名词解释、30 秒简答、概念辨析、原理说明、工程判断和常见追问。回答优先准备 30 秒版本,再按追问展开到标准语义、内存模型、复杂度或验证方法。
本题库只保留可迁移的技术内容,不保留个人、教师、学校、地点、联系方式或具体材料信息。代码题建议按“输入约束 - 核心语义 - 边界条件 - 复杂度 - 验证方式”的顺序回答。
一、语言基础与控制流
1. C 语言的主要优点和局限是什么?
问题意图:检查能否从语言特性和工程代价两方面评价 C,而不是只背优缺点清单。
30 秒回答:C 语法和运行时开销较小,能直接表达内存和位操作,适合嵌入式、操作系统和高性能底层代码;同时它把很多安全责任交给程序员,例如边界检查、生命周期和类型转换。C 的可移植性依赖标准写法、编译器和平台约定,不能把“能操作地址”理解成代码天然安全或跨平台。
展开逻辑:C 提供接近硬件的控制力、成熟的工具链和较高的可移植性,但缺少自动内存管理、运行时边界检查和强制封装。工程中应通过接口约束、静态分析、编译器警告、测试和代码审查弥补这些风险。
常见追问:为什么 C 仍广泛用于资源受限系统?
易错点:把 C 的“高效”说成任何 C 代码都比其他语言快,或把可移植性说成不受平台影响。
2. void 的用途有哪些?void * 又有什么不同?
问题意图:区分无返回值、无参数和通用对象指针三种语义。
30 秒回答:函数返回类型写 void 表示不返回值,参数列表写 表示明确没有参数;void * 是可以保存任意对象地址的指针,但解引用前必须转换或隐式转换为合适的对象指针,而且不能直接做指针算术。void 本身不是一个可以存放普通对象的完整对象类型。
展开逻辑:在 C 中 void * 与对象指针之间可以转换,常用于通用容器和内存分配接口;它不携带对象大小和类型信息,调用方必须维护类型契约。函数指针与 void * 不是标准保证可互换的类型,不能混用。
常见追问:为什么 malloc 返回 void *,而 C++ 中通常需要显式转换?
易错点:把 写成“没有参数也可以省略”,或认为 void * 能直接解引用和按字节递增。
3. 如何评价一个算法是否适合工程使用?
问题意图:检查是否同时考虑正确性、资源约束和维护成本。
30 秒回答:至少看正确性、时间复杂度、空间复杂度、可读性、健壮性和可测试性。正确性保证合法输入得到预期结果;健壮性要求对空输入、越界和非法数据有明确处理;复杂度要结合数据规模和实时约束判断,不能只比较理论阶数。
展开逻辑:还要考虑最坏情况、平均情况、常数开销、内存局部性、并发约束、可移植性和失败时的行为。面试中应说明不变量或证明思路,并给出边界测试,而不是只说“运行很快”。
常见追问:为什么不能只看时间复杂度?
易错点:把空间复杂度说成已经不重要,或只用一组正常样例证明算法正确。
4. 全局变量和局部变量如何区分?可以重名吗?
问题意图:检查作用域、链接属性和名字隐藏是否分得清。
30 秒回答:局部变量通常在函数或代码块内声明,作用域只覆盖相应区域;文件作用域变量在文件级可见,是否能被其他翻译单元访问还取决于 static、extern 和链接属性。内层代码块可以声明同名变量,此时内层名字会隐藏外层名字,但两者是不同对象。
展开逻辑:应区分作用域、存储期和链接属性:作用域决定名字在哪可见,存储期决定对象存在多久,链接属性决定不同翻译单元能否指向同一实体。需要访问被隐藏的外层变量时,应重命名或调整设计,而不是依赖编译器扩展。
常见追问:文件作用域的 static 和函数内的 static 分别解决什么问题?
易错点:认为所有全局变量都能被其他文件访问,或把变量重名说成“覆盖了同一个内存对象”。
5. 全局变量和局部变量的存储方式有什么区别?
问题意图:纠正“全局在静态区、局部在栈上”的过度简化。
30 秒回答:具有静态存储期的文件作用域变量和函数内 static 变量在整个程序运行期间存在,并在程序启动阶段或首次使用前完成初始化;普通局部变量通常是自动存储期,进入代码块创建、离开代码块结束。标准只规定存储期和生命周期,不保证普通局部变量一定位于名为“栈”的区域。
展开逻辑:动态分配对象由 malloc 等接口获得,生命周期由程序显式控制,常见实现使用堆,但标准不要求具体分配区域。嵌入式系统还可能有寄存器、静态 RAM、内存池等实现,回答时应把语言规则与平台布局分开。
常见追问:函数返回局部变量地址为什么危险?
易错点:说局部变量“函数结束才释放”而忽略嵌套代码块,或把 static 局部变量误认为每次调用都会重新初始化。
6. 复合语句中途可以定义变量吗?作用域如何确定?
问题意图:检查对块作用域和 C 标准版本的理解。
30 秒回答:可以。由花括号构成的复合语句形成一个块作用域,变量从声明点开始可见,到该块结束为止。C99 允许声明和语句交错;如果使用较老的 C 标准或严格编译选项,则要遵守对应约束。内层变量可隐藏外层同名变量,但离开内层块后它就不再可访问。
展开逻辑:声明位置还影响初始化时机和可读性。变量应尽量靠近首次使用,但不能为了“短”而扩大生命周期或制造重名。编译器应启用标准版本选项,避免团队成员对语言版本产生不同假设。
常见追问:块内变量和函数开头声明的变量在生命周期上有什么区别?
易错点:认为 C 语言永远要求所有变量写在函数开头,或认为离开作用域后对象内存一定被清零。
7. if 和 switch 如何选择?break、case、default 分别做什么?
问题意图:检查分支语义、贯穿执行和输入覆盖意识。
30 秒回答:if 适合范围判断、复合条件和不规则分支;switch 适合一个整数或枚举表达式对应多个离散取值。case 是跳转标签,不是自动结束点;执行到 break 才离开 switch,没有 break 会继续贯穿后续分支。default 处理未匹配值,通常应明确记录、报错或采取安全默认行为。
展开逻辑:有意贯穿时应写注释并让代码结构清晰。枚举分支可用编译器警告检查遗漏;switch 的表达式类型和 case 常量要满足标准约束。不要为了追求所谓速度把所有多分支都改成 switch,可读性和验证性更重要。
常见追问:如何安全处理一个可能新增枚举值的状态机?
易错点:忘记 break 导致误贯穿,或认为 case 本身定义了分支结束位置。
8. 如何提高循环代码的效率和可靠性?
问题意图:检查是否能从复杂度、内存访问和测量结果出发优化。
30 秒回答:先确认算法复杂度和循环边界正确,再减少不必要的工作、改善数据局部性和分支行为。可以把循环不变计算移到循环外,避免重复调用昂贵操作,并在多重循环中选择更适合数据布局的访问顺序。是否真的更快要用基准测试和分析工具验证,不能凭 for、while 或三目运算符的形式判断。
展开逻辑:优化前应建立基线并保证编译器优化选项一致。数组遍历通常要检查边界、整数溢出和缓存访问;过度展开、改变求值顺序或删除看似无用的访问可能破坏 volatile、同步或错误处理语义。
常见追问:为什么把最长循环放内层不一定总是最优?
易错点:声称三目运算符一定比 if 快,或为了减少循环次数牺牲可读性和边界检查。
二、数组、字符串与指针
9. 数组是什么?它与数学中的数列有什么区别?
问题意图:检查数组的类型、元素数量和连续存储特征。
30 秒回答:数组是同一元素类型、固定元素个数的对象,元素按下标访问,通常连续存储。它的“有序”指存储顺序,不表示元素值必须满足数学规律;数学数列强调数值规律,C 数组只是一种数据组织方式。数组边界由声明或运行时约束决定,访问超出范围会产生未定义行为。
展开逻辑:多维数组按行主序连续布局,元素类型决定步长。数组对象本身不能通过赋值运算符整体复制,函数参数中的数组声明通常调整为指针,调用方需要额外传递长度。
常见追问:二维数组传参时为什么通常要写出后续维度?
易错点:把数组当成可自动扩容的容器,或把数组名、数组首元素地址和所有表达式场景下的指针混为一谈。
10. 数组和指针有什么区别?sizeof 为什么结果不同?
问题意图:检查数组类型、指针对象和数组参数调整规则。
30 秒回答:数组是包含若干元素的对象,指针是保存地址的对象;两者不是同一种类型。在多数表达式中数组会转换为指向首元素的指针,但作为 sizeof、_Alignof 或取地址运算对象时不会转换。对真正的数组使用 sizeof 得到整个数组字节数,对指针使用则只得到指针对象本身的大小;函数参数中的数组声明会调整为指针,因此函数内不能靠 sizeof 推出元素个数。
展开逻辑:数组大小应通过宏、结构体字段或显式长度传递。指针指向的内存容量不由指针值携带,必须依赖接口契约。不同平台的指针大小可能不同,不能写死为 2、4 或 8 字节。
常见追问:为什么 sizeof(a) / sizeof(a[0]) 传入函数后会失效?
易错点:说“数组名就是指针”,或认为 sizeof(pointer) 能得到所指数组的容量。
11. 指针运算的合法边界是什么?
问题意图:检查是否理解对象边界、步长和一位越界指针规则。
30 秒回答:指针加减的单位是所指元素大小,只能在同一数组对象内或指向数组末尾后一位的位置进行有限运算;只有指向同一数组对象的指针才可以相减或比较大小。末尾后一位指针可以用于循环终止和比较,但不能解引用。对无关对象、整数地址或已释放对象做运算都不可靠。
展开逻辑:指针递增并不等于按一个字节移动;需要字节访问时应使用 unsigned char *,仍要遵守对象生命周期和别名规则。指针差值类型应使用 ptrdiff_t,长度和索引通常使用 size_t,并防止有符号与无符号混算。
常见追问:为什么 可以作为结束位置,但 不能访问?
易错点:把任意整数加一当成地址遍历,或先越界再减回来并认为因此是安全的。
12. const int *、int *const 和 const int *const 有什么区别?
问题意图:检查是否能区分“不能改对象”和“不能改指针”。
30 秒回答:const int *p 表示不能通过 p 修改所指 int,但 p 可以改指向;int *const p 表示 p 初始化后不能改指向,但可以通过它修改对象;两者同时加 const 则两边都不能通过该名字修改。const 是访问约束,不等于对象一定位于只读存储区。
展开逻辑:把非 const 指针转换为指向 const 通常是安全的反向约束;去掉 const 再写入只有在原对象本来可修改时才可能合法,不能把字符串字面量强转后修改。接口参数加 const 可表达只读契约并帮助审查。
常见追问:为什么 const 不能替代线程同步?
易错点:认为 const 对象在任何情况下都不可变,或把指针常量和常量指针读反。
13. 字符串字面量、字符数组和字符指针有什么区别?
问题意图:检查 C 字符串的存储、终止符和写权限。
30 秒回答:C 字符串是以 \0 结尾的字符序列。char a[] = "abc" 创建可修改数组并包含终止符;const char *p = "abc" 让指针指向字符串字面量,字面量内容不能修改;数组和指针的生命周期、sizeof 结果和可写性都不同。复制字符串时必须保证目标空间足够。
展开逻辑:strlen 不包含终止符,sizeof(a) 对数组包含终止符。字符串 API 普遍依赖调用方保证有效终止和容量,工程中应显式传递长度并检查截断、编码和空指针情况。
常见追问:为什么 char *p = "abc"; p[0] = 'A'; 不能作为可移植代码?
易错点:把字符串字面量当成普通可写数组,或为字符串只分配 strlen(s) 个字节而忘记终止符。
14. 空指针、野指针和悬空指针分别是什么?
问题意图:检查指针状态和生命周期风险。
30 秒回答:空指针是明确表示“不指向对象”的指针;C 中的 NULL 通常是实现提供的空指针宏,C++ 的 nullptr 是专用空指针常量,不能把二者混为 C 语法关键字。野指针通常指未初始化或保存了不确定地址的指针;悬空指针指向的对象已经结束生命周期,例如释放后仍保存的地址。只有在确认非空、对象仍存在且类型和边界正确时才能解引用。
展开逻辑:free(p) 后把 p 置为 NULL 能降低重复释放风险,但不能修复其他别名指针。初始化、所有权约定、单一释放责任和工具检查比“到处判空”更重要。空指针判断不能代替长度、权限和并发检查。
常见追问:为什么 free 后置空仍不能解决 use-after-free?
易错点:认为未初始化指针默认是空指针,或认为只要地址非零就一定可访问。
三、函数、模块与数据组织
15. 形参和实参如何传递?C 有引用传递吗?
问题意图:检查参数传递与可修改对象的表达方式。
30 秒回答:C 的函数调用按值传递,形参得到实参值的副本;传入指针时,复制的是地址值,因此函数可以通过该地址修改调用方对象,但不能直接改变调用方的指针变量,除非传入指针的地址。数组参数声明会调整为指针,长度不会自动传递。
展开逻辑:输出参数应明确所有权、是否允许为空和长度约束。结构体按值传递会复制对象,过大对象可考虑传指针加 const。同一表达式中对同一标量多次修改且无明确求值顺序时,应避免依赖结果。
常见追问:如何实现一个既返回错误码又输出结果的函数?
易错点:把传指针说成 C 的引用传递,或忘记检查指针和长度的匹配关系。
16. 函数原型声明有什么作用?为什么不能依赖旧式隐式声明?
问题意图:检查类型检查、翻译单元和现代 C 编译规则。
30 秒回答:函数原型让编译器在调用点知道返回类型和参数类型,可以检查参数数量、类型转换并正确处理返回值。现代 C 标准不允许依赖未声明函数的隐式声明;函数定义在调用前或通过头文件提供原型即可。头文件应成为接口的唯一声明来源,避免声明与定义不一致。
展开逻辑:返回指针或窄整数的函数若缺少正确原型,可能造成调用约定或截断错误。编译时应开启严格标准和警告,把警告视为需要处理的问题,而不是静默忽略。
常见追问:头文件中为什么只放声明而不直接放普通函数定义?
易错点:认为返回 int 的旧式隐式声明仍安全,或手写多个不一致的函数声明。
17. 什么是递归?递归函数必须具备哪些条件?
问题意图:检查递归出口、规模收缩和栈资源意识。
30 秒回答:递归是函数直接或间接调用自身。可靠递归必须有可达的基例,并保证每次调用都向基例收敛;否则会无限调用并耗尽调用栈。递归写法适合树、分治等天然递归结构,但要比较栈深度、重复计算和迭代实现的成本。
展开逻辑:证明递归正确性通常包括基例和递推关系;工程中还要考虑输入恶化时的最大深度、尾递归是否被编译器优化以及错误传播。备忘录或显式栈可减少重复计算或栈风险。
常见追问:为什么斐波那契的朴素递归复杂度很高?
易错点:只写“函数调用自己”而不说明出口,或认为所有编译器都会自动消除尾递归。
18. static 在文件作用域和函数内部分别有什么意义?
问题意图:检查链接属性与静态存储期的双重语义。
30 秒回答:文件作用域的 static 限制符号只在当前翻译单元可见,属于内部链接,可用于隐藏模块实现;函数内的 static 变量具有静态存储期,多次调用之间保留上一次的值,但作用域仍只在该函数或块内。两者都不是“只读”或“线程安全”的意思。
展开逻辑:模块接口应在头文件暴露必要的非 static 声明,私有状态和辅助函数可设为文件级 static。函数内静态状态会引入调用顺序依赖、重入和并发问题,使用前要说明初始化和同步策略。
常见追问:为什么把函数改成文件级 static 有助于链接期检查?
易错点:把所有 static 都解释成“全局变量只初始化一次”,或认为 static 自动保证线程安全。
19. 结构体、联合体和枚举如何选择?
问题意图:检查数据表示、内存布局和不变式设计。
30 秒回答:结构体把多个成员同时组织在一个对象中,各成员有独立存储;联合体让多个成员共享同一段存储,任一时刻通常只按当前有效成员解释;枚举用于表达有限的命名整数状态。选择时要说明占用空间、对齐、有效成员和序列化约束,不能只看语法形式。
展开逻辑:结构体可能有填充字节,不能直接假定内存布局等于网络协议布局;联合体类型穿透和别名规则要遵守标准与编译器文档;枚举底层表示和取值范围需结合实现,外部输入应做范围校验。
常见追问:如何设计一个带类型标签的联合体?
易错点:把联合体所有成员都当成同时有效,或直接把结构体内存强转成通信报文发送。
20. typedef 是新类型吗?
问题意图:检查类型别名与类型定义的区别。
30 秒回答:typedef 只创建现有类型的别名,不会产生新的独立类型,也不会改变表示、生命周期或类型检查规则。它能提高复杂声明和接口的可读性,但应避免隐藏指针、数组或 const 语义。结构体若使用 typedef,仍要明确标签、成员和初始化方式。
展开逻辑:例如 typedef int *IntPtr 中 const 修饰的位置容易被误读,阅读时应先还原别名。公共接口的别名应表达稳定概念,不能用大量别名掩盖底层所有权和单位。
常见追问:为什么 typedef 后的指针声明容易让人误判?
易错点:说 typedef 会定义新类,或认为别名可以绕过不兼容类型赋值。
21. 函数指针和回调有什么用途?
问题意图:检查间接调用、接口解耦和生命周期管理。
30 秒回答:函数指针保存函数入口的可调用地址,可用于回调、策略选择、驱动接口和状态机分派。声明必须与被调用函数的参数和返回类型兼容;调用前要确认指针非空、目标函数仍有效,并明确回调上下文和线程约束。函数指针和对象指针不是一类指针。
展开逻辑:回调接口通常同时传入 void *context,让调用方保存状态。注册回调时要规定注销时机,避免异步任务仍调用已经失效的函数或上下文。复杂声明可使用 typedef,但不要隐藏调用约定。
常见追问:如何设计一个可取消的异步回调接口?
易错点:把函数指针强转成 void *,或回调注销后仍让后台线程持有旧上下文。
四、动态内存与对象生命周期
22. malloc、calloc、realloc 和 free 如何配合使用?
问题意图:检查动态内存的初始化、扩容和失败路径。
30 秒回答:malloc 分配指定字节数但内容未初始化,calloc 分配数组并把字节清零,realloc 尝试调整已有块大小,free 释放由分配函数得到的块。每次分配都要检查失败、记录容量和所有权;realloc 应先接收临时指针,成功后再覆盖原指针,避免失败时丢失原内存。
展开逻辑:计算 count * size 前要检查乘法溢出;扩容后旧指针可能失效,所有别名都需重新审视。只释放一次且必须使用匹配的分配/释放接口。清零不等于把对象初始化成业务上有效的值。
常见追问:realloc(p, 0) 是否等价于可靠地 free(p)?
易错点:直接写 p = realloc(p, n),或把 malloc 得到的内存当成已初始化的结构体对象使用。
23. 内存泄漏、重复释放和 use-after-free 如何避免?
问题意图:检查资源所有权和异常路径设计。
30 秒回答:内存泄漏是对象失去最后一个可释放引用却没有释放;重复释放是同一块内存释放多次;use-after-free 是对象生命周期结束后继续访问。应在接口中明确谁申请、谁释放、失败路径如何回滚,并用单一所有权、集中清理和 Sanitizer 等工具检查。
展开逻辑:free 后置空只能保护当前变量,不能修复其他别名。复杂函数可采用统一 cleanup 路径,资源获取按逆序释放。对异步和跨线程对象,要用引用计数、消息所有权或同步协议保证生命周期。
常见追问:为什么 RAII 不属于 C 语言内建机制,C 项目如何模拟?
易错点:把“没有崩溃”当作没有内存错误,或只在正常返回路径释放资源。
24. memcpy 和 memmove 有什么区别?
问题意图:检查重叠内存操作和长度责任。
30 秒回答:memcpy 要求源和目标区域不能重叠,重叠时行为未定义;memmove 专门保证重叠区域复制正确。两者都只按字节复制指定长度,不理解字符串终止符和对象类型,长度必须由调用方保证有效。
展开逻辑:结构体或对象复制还要考虑内部指针是浅拷贝,不能把复制字节当成深拷贝。跨类型访问要遵守有效类型和对齐要求;字符串应使用明确的字符串接口或带容量的封装。
常见追问:数组内部左移为什么常用 memmove?
易错点:认为两个函数只是速度差异,或用 sizeof(pointer) 作为复制长度。
25. 如何安全处理 strcpy、strlen 等 C 字符串函数?
问题意图:检查终止符、容量和输入校验。
30 秒回答:这些函数都假设传入的是以 \0 结尾的有效字符串,且目标空间足够;否则会读越界或写越界。工程上应显式维护缓冲区容量,检查长度加终止符是否溢出,必要时使用带长度的接口,并明确截断后的业务语义。
展开逻辑:strncpy 不一定补终止符,也可能无谓填充;snprintf 返回值要用于判断是否发生截断。处理外部输入还要考虑编码、嵌入零字节和整数长度转换,不能只把 strlen 结果强转成有符号类型。
常见追问:为什么 strncpy 不是简单的“安全版 strcpy”?
易错点:为字符串只分配可见字符数,或假设 strncpy 一定产生终止字符串。
五、类型、位操作与未定义行为
26. 有符号和无符号整数混算有哪些风险?
问题意图:检查整数提升、转换和边界条件。
30 秒回答:混算时会发生整数提升和通常算术转换,负数可能被转换成很大的无符号值,比较结果因此反直觉。无符号运算按模运算,但有符号溢出通常是未定义行为。循环索引、长度和错误码要选择匹配的类型,并避免无意义的强制转换。
展开逻辑:size_t 是无符号类型,用它倒序循环要特别处理边界;比较前可统一类型或显式检查范围。序列化和协议字段要使用固定宽度整数,并明确字节序,不要依赖 int 的大小。
常见追问:为什么 for (size_t i = n - 1; i >= 0; --i) 可能死循环?
易错点:认为所有整数溢出都按二进制回绕,或看到无符号就认为一定更安全。
27. 位运算和移位如何用于寄存器或标志位操作?
问题意图:检查掩码、类型宽度和移位边界。
30 秒回答:按位与常用于取字段或清零,按位或用于置位,异或可翻转,按位取反要配合掩码。移位前应使用无符号类型并保证移位量小于位宽,设置寄存器位时要避免读改写覆盖其他位。位域和硬件寄存器还要考虑 volatile、访问宽度和平台定义。
展开逻辑:宏中的移位常量应带合适的无符号后缀并加括号;字段更新可写成先清掩码再合并新值。对有符号负数右移、左移溢出等行为不要依赖编译器习惯。
常见追问:如何原子地更新一个共享寄存器的单个位?
易错点:移位 32 位整数 32 位,或直接对寄存器按位取反而误改保留位。
28. volatile 能解决什么问题,不能解决什么问题?
问题意图:区分可观察副作用与并发同步。
30 秒回答:volatile 告诉编译器每次访问都可能产生或观察外部可见变化,适用于内存映射寄存器、信号处理器修改的对象等场景。它不保证操作原子性、线程间顺序、缓存一致性或复合表达式不可打断,因此不能替代互斥锁、原子类型或内存屏障。
展开逻辑:嵌入式寄存器要按硬件规定的宽度访问,并检查读写副作用。多线程共享计数器应使用 C11 原子或平台同步原语;只加 volatile 可能导致竞态仍存在。
常见追问:为什么编译器不能把 while (!flag) {} 中的普通 flag 访问优化掉?
易错点:把 volatile 说成“禁止所有优化”或“保证线程安全”。
29. 什么是未定义行为、未指定行为和实现定义行为?
问题意图:检查对 C 标准边界和可移植性的认识。
30 秒回答:未定义行为表示标准不规定结果,编译器可以做任意假设,例如越界、use-after-free 和有符号溢出;未指定行为允许实现从多个结果中选择,不必记录选择;实现定义行为要求实现选择并给出说明,例如某些整数表示。可移植代码应避免依赖前两类,并明确记录第三类假设。
展开逻辑:优化级别变化可能暴露原本隐藏的未定义行为,不能用一次运行“看起来正常”证明安全。编译器警告、静态分析、Sanitizer 和针对边界的测试可以帮助发现问题,但工具通过不等于证明所有路径符合标准。
常见追问:为什么未定义行为不能简单理解成“通常会报错”?
易错点:把实现定义和未定义混为一谈,或用某个编译器的结果当作 C 标准保证。
30. 什么是未求值顺序问题?为什么 i++ + ++i 不应使用?
问题意图:检查副作用、求值顺序和可读性。
30 秒回答:一个完整表达式中,如果同一标量对象被多次修改,且修改之间没有标准规定的顺序,就可能产生未定义行为。i++ + ++i 同时修改 i,不能依赖任何特定结果。应把修改拆成多个语句,保证每一步只有一个清晰副作用。
展开逻辑:函数参数求值顺序、&&、||、逗号运算符和条件运算符的求值规则不同,不能凭从左到右的阅读顺序推断。拆分语句不仅修复标准问题,也便于调试和代码审查。
常见追问:i++ && i++ 是否与 i++ + i++ 等价?
易错点:把“编译器没有报错”当作表达式有定义,或只按某次运行结果回答。
31. sizeof、整数提升和整除在表达式中有哪些陷阱?
问题意图:检查类型推导和隐式转换。
30 秒回答:sizeof 的结果类型是 size_t,对非变长类型通常在编译期求值,且不会执行其操作数;小整数参与运算前会发生整数提升,混入无符号类型后可能改变比较结果;整数除法会截断小数。需要浮点结果时应显式转换,并检查中间表达式是否已经发生溢出或截断。
展开逻辑:sizeof(char) 标准规定为 1,但一个 char 不一定是 8 位;字节数与位数不要混淆。宏和表达式应加括号,避免优先级和类型转换造成隐藏错误。
常见追问:为什么 sizeof(a + b) 的结果不等于 sizeof(a) + sizeof(b)?
易错点:以为 sizeof 总会执行函数调用,或认为整数除法会自动保留小数。
六、工程实践、算法与调试
32. 如何在 C 中表达字节序和跨平台数据?
问题意图:检查是否能区分内存表示与协议表示。
30 秒回答:字节序是多字节整数在内存或传输中的排列方式,常见有大端和小端。跨平台数据不能直接把结构体内存写入文件或网络,应使用固定宽度整数、显式编码/解码、长度校验和字节序转换,并处理对齐、填充和版本兼容。
展开逻辑:字符数组和单字节数据通常不受整数字节序影响;浮点、指针和结构体布局更不适合作为稳定协议格式。调试时可用十六进制抓包和已知值测试验证编码结果。
常见追问:为什么结构体直接 fwrite 不能作为通用序列化方案?
易错点:把字节序当成位序,或认为同一编译器生成的结构体布局在所有平台都一致。
33. alignof、填充和未对齐访问为什么值得关注?
问题意图:检查对象布局、性能和硬件异常风险。
30 秒回答:不同类型有不同对齐要求,结构体成员之间可能插入填充字节,使 sizeof(struct) 大于成员大小之和。某些平台的未对齐访问会变慢甚至触发异常,因此不能把任意字节地址强转成大类型指针直接读取。需要紧凑协议时,应通过逐字节解码或 memcpy,并明确性能取舍。
展开逻辑:offsetof 可查询标准布局结构体成员偏移;#pragma pack 属于实现扩展,可能降低访问效率。对齐不仅影响大小,也会影响数组步长、缓存和原子访问要求。
常见追问:为什么改变结构体成员顺序可能减少内存占用?
易错点:把 packed 结构体当作任何平台都可直接解引用,或假设没有填充字节。
34. const、volatile、原子类型和互斥锁如何区分?
问题意图:检查并发语义是否被错误简化。
30 秒回答:const 表达通过某个访问路径不修改对象,volatile 表达访问具有外部可观察副作用;原子类型保证特定读改写操作的原子性和内存序;互斥锁用于保护多个操作组成的不变式和临界区。四者解决的问题不同,不能用一个替代另一个。
展开逻辑:共享状态设计要先定义所有权和不变式,再选择原子或锁。C11 _Atomic 需要结合内存序理解;信号处理器与普通线程也不是同一种同步场景。没有明确并发模型时,增加关键字并不能自动修复竞态。
常见追问:单个原子计数器为什么仍可能不能保证一组状态的一致性?
易错点:把 volatile 当成原子,把原子变量当成完整事务,或忽略锁的死锁和优先级反转风险。
35. 如何用编译器和工具定位 C 程序中的内存错误?
问题意图:检查是否具备可复现、可验证的调试流程。
30 秒回答:先用严格标准、较高警告级别和静态分析发现类型、控制流和未使用值问题;再用调试器、最小复现和断点确认运行路径;对越界、use-after-free、泄漏和未定义行为可使用 AddressSanitizer、UndefinedBehaviorSanitizer 等工具。修复后要补充回归测试,不能只依赖一次手工运行。
展开逻辑:调试步骤应固定输入、编译选项和平台,区分编译期、运行期和并发时序问题。优化构建与调试构建结果可能不同,需在接近生产的配置上复测。工具报告应回溯到所有权、边界和生命周期设计,而不是只改一行触发点。
常见追问:为什么开启优化后才暴露的崩溃通常值得优先怀疑未定义行为?
易错点:只在崩溃点加空指针判断,或把 Sanitizer 未报错当作不存在所有内存问题。
36. 如何设计一个可测试、可维护的 C 模块?
问题意图:把语言知识迁移到模块化工程设计。
30 秒回答:将公共接口放在头文件,隐藏实现细节和私有符号;函数输入、输出、所有权、线程约束和错误码要写清楚;避免隐式全局状态,让核心逻辑可用纯输入直接测试。对边界和失败路径建立单元测试,并用编译器警告、静态分析和持续集成保持质量。
展开逻辑:模块应有明确不变式和资源释放责任,接口尽量传长度而不是依赖哨兵;需要替换硬件或时间源时使用依赖注入或回调。版本演进要考虑结构体大小、枚举新增值和 ABI 兼容,不能只保证当前调用方能编译。
常见追问:为什么少用全局变量能提高可测试性和可移植性?
易错点:把“拆成很多函数”当作模块化,或只测试正常输入而不测试资源失败和边界条件。
44. 宏、普通函数和 static inline 函数如何选择?
问题意图:检查预处理替换、类型安全和性能优化的边界。
30 秒回答:宏在预处理阶段做文本替换,没有类型检查,参数可能被重复求值;普通函数有明确类型和调用边界,便于调试和维护;static inline 函数仍按函数语义检查参数,编译器可根据上下文决定是否内联。只有在需要条件编译、字符串化或拼接等预处理能力时才使用宏。
展开逻辑:带参数宏要用括号包住整体和每个参数,并避免副作用;内联只是优化建议,不保证消除调用开销。公共头文件中的 static inline 具有内部链接,避免多重定义但会增加各翻译单元的代码体积。
常见追问:为什么 MAX(i++, j++) 可能产生意外结果?
易错点:认为 inline 一定能提高速度,或把宏当作没有类型的函数直接替代。
45. restrict 关键字向编译器承诺了什么?
问题意图:检查指针别名、优化前提和未定义行为风险。
30 秒回答:restrict 表示在一个作用域内,通过该指针访问的对象主要不会同时通过不相关的别名指针访问。满足承诺时编译器可以更积极地重排和向量化;如果调用者传入实际别名的指针并发生冲突,程序可能触发未定义行为,因此不能把它当成普通的性能标记随意添加。
展开逻辑:典型场景是内存拷贝或数组运算接口,函数文档要明确输入输出是否允许重叠;若允许重叠应使用不带该承诺的实现或选择 memmove。优化前后要用别名和边界测试验证结果一致。
常见追问:为什么 memcpy 要求源和目的区域不能重叠,而 memmove 可以?
易错点:把 restrict 理解成自动复制数据,或只在函数声明中添加而不检查调用者契约。
46. C 语言位域适合表示硬件寄存器吗?有哪些可移植性风险?
问题意图:检查位域布局、端序和硬件寄存器访问的工程边界。
30 秒回答:位域可以用接近字段的语法表达若干位,但字段从低位还是高位分配、跨字节布局、对齐和带符号行为由实现决定,不能直接假设与芯片手册一致。对硬件寄存器更稳妥的方式通常是固定宽度整数配合掩码和移位,并使用 volatile 访问。
展开逻辑:位域适合编译器和目标 ABI 已明确约定的本地数据结构;跨平台协议、文件格式和 MMIO 接口要显式规定位序、读写宽度和保留位处理。读改写寄存器时还要考虑写一清零、只读位和并发中断。
常见追问:为什么把一个 uint32_t 寄存器直接映射成位域结构可能在换编译器后失效?
易错点:把位域声明顺序当成物理位编号顺序,或忘记硬件寄存器需要 volatile。
47. 声明、定义、外部链接和内部链接有什么区别?
问题意图:检查多文件 C 工程的符号组织和一次定义规则。
30 秒回答:声明告诉编译器某个对象或函数的类型和存在性,定义则分配存储或提供函数体。默认情况下,文件作用域的非 static 函数和对象具有外部链接,可被其他翻译单元引用;加 static 后是内部链接,只在当前翻译单元可见。头文件通常放声明,定义放一个源文件,避免重复定义。
展开逻辑:extern 声明不分配对象存储,extern 定义可在一个源文件中提供实体;同一对象的类型和 const 限定必须一致。构建时还要区分编译通过、链接符号解析和静态库对象被提取的条件。
常见追问:为什么头文件中直接定义一个非 static 全局变量会导致多重定义?
易错点:把声明和定义当成同义词,或认为加 extern 就会自动生成对象。
48. C 程序如何统一处理错误、errno 和资源清理?
问题意图:检查接口契约、失败传播和异常路径的工程质量。
30 秒回答:函数应通过返回值、输出参数或约定的错误码报告失败,调用者立即检查并保留必要上下文;标准库可能设置 errno,但只有在函数明确说明失败时才读取,不能把它当成全局可靠状态。打开的文件、锁和堆内存应沿单一清理路径释放,避免遗漏或重复释放。
展开逻辑:错误处理要区分可恢复、重试、降级和不可恢复错误,并保留原始错误码;多资源获取可采用逆序清理标签或封装对象。跨线程环境中要注意 errno 的线程局部语义,以及日志记录不能掩盖返回错误。
常见追问:为什么调用一个成功的库函数后再读取 errno 没有意义?
易错点:不检查返回值只看 errno,或错误路径提前返回导致资源泄漏。
49. 动态内存碎片是怎样产生的?如何降低实时系统中的风险?
问题意图:检查堆分配的长期行为、确定性和系统可靠性。
30 秒回答:频繁申请和释放不同大小的块会留下许多无法满足大块请求的空闲小洞,即外部碎片;块内部的对齐和分配粒度造成的浪费属于内部碎片。实时系统可采用静态数组、对象池、固定大小内存块或启动阶段一次性分配,以获得可预测的时间和空间上界。
展开逻辑:通用堆分配器可能合并相邻空闲块,但不能保证长期零碎片和固定时延。设计时统计峰值占用、最坏分配路径和失败恢复,并避免在中断或硬实时任务中调用不可预测的 malloc。
常见追问:为什么“总空闲内存足够”仍可能申请不到一个大块?
易错点:把所有内存浪费都归为外部碎片,或认为频繁 free 就能自动消除碎片。
50. setjmp/longjmp 能否替代 C 语言的常规错误处理?
问题意图:检查非局部跳转对栈、资源和可维护性的影响。
30 秒回答:setjmp 保存执行环境,longjmp 可以跳回保存点,适合少数需要跨多层退出的场景,但它不会自动释放中间栈帧中的资源,也会让局部变量和控制流变得难以推断。常规库接口应优先使用返回值和统一清理路径,非局部跳转只能在边界清晰、资源已登记的代码中谨慎使用。
展开逻辑:跳转后,保存点函数中未声明为 volatile 且在 setjmp 后被修改的自动变量,其值可能是不确定的;不能跳入已经返回的函数栈帧。若用于解析器或协程,要明确生命周期、信号处理和线程限制,并覆盖所有跳转路径。
常见追问:为什么 longjmp 不能安全地跳过持有锁或已分配内存的函数?
易错点:把 longjmp 当作 C++ 异常,或忘记它不会执行中间函数的清理代码。
51. 指针生命周期和悬空指针如何避免?
问题意图:检查对象生命周期与所有权。
30 秒回答:指针本身只是地址,不延长所指对象的生命周期。返回局部变量地址、释放后继续使用或保存临时缓冲区地址都会产生悬空指针;接口应明确所有权、有效期和释放责任,释放后立即置空或离开作用域。
展开逻辑:区分自动、静态和动态存储期,必要时用复制、引用计数或拥有者结构管理共享数据。工具只能发现部分路径,设计契约更重要。
常见追问:为什么把局部数组地址作为返回值通常错误?
易错点:把指针置空当成恢复已释放对象,或假设栈内存会一直保持原值。
52. 深拷贝和浅拷贝在 C 结构体中有什么区别?
问题意图:考查指针成员和资源所有权。
30 秒回答:浅拷贝只复制字段和指针值,两个结构体可能共享同一资源;深拷贝还要为指针成员分配新空间并复制内容。若资源有唯一所有者,复制、移动和销毁操作必须成对设计。
展开逻辑:写清字符串长度、失败回滚和嵌套成员处理;不能用 memcpy 代替含指针资源的深拷贝。接口还要规定源和目的重叠是否允许。
常见追问:为什么浅拷贝后分别 free 两个对象会崩溃?
易错点:只复制结构体大小,或遗漏容量、终止符和别名关系。
54. 位运算中的移位和溢出有哪些边界?
问题意图:考查整数类型和未定义行为。
30 秒回答:移位量必须小于左操作数的位宽;无符号左移按模运算,带符号溢出和对负数的某些移位可能是未定义或实现相关。位掩码应使用无符号固定宽度类型并显式检查范围。
展开逻辑:先确认整数提升后的实际类型,再决定左移、右移和符号扩展方式。协议解析还要考虑端序、对齐和输入长度。
常见追问:为什么把 1 左移 31 位直接写成 int 常量可能有风险?
易错点:移位等于类型宽度、对负数右移作标准保证,或忽略整数提升。
55. 字符串处理为什么容易发生越界?
问题意图:检查 C 字符串的哨兵和容量管理。
30 秒回答:C 字符串以零字节结尾,缓冲区容量必须包含终止符;strcpy、sprintf 等接口不检查容量,输入过长会覆盖相邻对象。应传递长度、使用有界接口并明确截断策略。
展开逻辑:strlen 也要求输入已有终止符,不能对任意字节缓冲区调用;二进制数据应使用指针加长度而不是字符串函数。边界和非 ASCII 编码要单独测试。
常见追问:为什么 strncpy 也不能简单视为安全字符串复制?
易错点:忘记终止符、把字节数当字符数,或把截断后的数据当完整字符串使用。
56. 文件和网络 I/O 的部分读写如何处理?
问题意图:考查系统调用返回值和协议边界。
30 秒回答:read、write、send、recv 可能只处理部分字节,返回值为零或负值还分别表示 EOF、暂时不可用或错误。应循环处理剩余长度,区分 EINTR、非阻塞重试和真正失败,并设置消息长度或帧边界。
展开逻辑:网络 TCP 是字节流,不保证一次 recv 对应一次消息;UDP 要考虑报文边界和截断。超时、取消和关闭连接都要纳入状态机。
常见追问:为什么 write 返回成功不一定代表数据已经到达对端应用?
易错点:把一次 send 当作完整发送,或忽略返回值为零导致的循环不退出。
57. 多线程程序中的数据竞争如何识别?
问题意图:检查 C 内存模型和同步。
30 秒回答:两个线程并发访问同一非原子对象且至少一个写入、又没有建立 happens-before 关系,就可能产生数据竞争,C 标准下属于未定义行为。应使用互斥锁、原子类型、条件变量或线程安全队列建立同步。
展开逻辑:volatile 只能影响访问优化,不能提供原子性和顺序;锁保护的是不变式而非单个变量。用 ThreadSanitizer、最小复现和压力测试定位时序问题。
常见追问:为什么把共享变量声明为 volatile 仍不能解决竞态?
易错点:把原子读写当成复合操作原子,或锁的加解锁范围不覆盖完整不变式。
58. 资源泄漏和双重释放如何系统检查?
问题意图:考查错误路径和所有权审计。
30 秒回答:为每个资源标记创建点、拥有者、转移点和释放点,沿成功、失败、提前返回和并发取消路径检查恰好释放一次。可用 Sanitizer、静态分析和代码审查辅助,但不能替代清晰的所有权契约。
展开逻辑:多资源函数宜采用逆序清理标签或封装对象,释放后禁止继续访问。文件描述符、锁、映射和线程句柄也属于需要配对管理的资源。
常见追问:为什么在错误路径中直接 return 容易造成泄漏?
易错点:只检查正常路径,或用统一 cleanup 标签却没有区分哪些资源已经成功获取。
59. 编译器优化为什么不能替代算法优化?
问题意图:检查性能分析和复杂度意识。
30 秒回答:编译器可做常量折叠、内联和向量化,但不能改变错误的算法复杂度和不合理的数据访问。应先保证算法和内存布局正确,再用基准、性能计数器和剖析工具定位热点,最后验证优化没有破坏语义。
展开逻辑:优化结果依赖编译选项、目标 CPU、输入规模和缓存状态;避免凭直觉比较语法形式。未定义行为会让优化后结果失去可靠性。
常见追问:为什么 O(n log n) 算法在小数据上可能不如 O(n²)?
易错点:只看理论阶数或单次计时,忽略常数、缓存和编译器优化。
60. 如何设计 C 接口的版本兼容?
问题意图:考查长期维护和 ABI 稳定性。
30 秒回答:公共接口要明确类型宽度、结构体大小、枚举新增值、错误码和所有权;可用版本字段、size 参数和能力查询兼容扩展,避免直接暴露可变内部布局。变更后需同时更新头文件、库和测试。
展开逻辑:区分源兼容、二进制兼容和行为兼容,跨编译器还要考虑调用约定和对齐。弃用接口应保留迁移窗口并记录线程、超时和错误语义。
常见追问:为什么公共头文件中的结构体直接加字段可能破坏 ABI?
易错点:只保证能重新编译,忽略旧二进制、结构体填充和调用方对枚举范围的假设。
回答要点
- 先区分语言标准语义、编译器实现和具体平台假设,再给出结论。
- 指针和内存题要同时交代指向对象、生命周期、边界和释放责任。
- 算法题除了正确性,还要比较时间复杂度、空间复杂度、可读性、健壮性和可测试性。
常见追问
- 如果输入规模、编译选项或运行平台改变,结论还成立吗?
- 如何用编译器警告、调试器、测试或 Sanitizer 验证这个判断?
常见误区
- 把“数组名是指针”“局部变量一定在栈上”等实现习惯当成 C 标准规定。
- 只说明正常路径,不说明空指针、越界、整数溢出和资源释放等异常路径。