【嵌入式面试】C 语言高频面试题
【嵌入式面试】C 语言高频面试题
嵌入式岗位的 C 语言面试,考的不是语法冷知识,而是指针、内存、编译链接这些直接影响固件正确性与可靠性的功底。本文整理了面试中反复出现的高频考题与考点,覆盖指针与数组、关键字、宏与内联、结构体对齐与大小端、内存管理等方向,每题附精炼参考答案,供复习与自测。
指针与数组
int a[5]; int *p = a;,p 和 a 有什么区别?a[i] 和 *(a+i) 等价吗?
数组名 a 在大多数表达式中会退化为指向首元素的指针,但 a 本身不是变量:sizeof(a) 得到整个数组的字节数(20),而 sizeof(p) 只是指针大小(32 位下为 4);a 不能自增自减或重新赋值,p 可以。a[i] 与 *(a+i) 在语义上完全等价,编译器都按"首地址 + i×元素大小"的指针运算处理,因此 p[i] 同样合法。注意 &a 的类型是 int (*)[5](指向整个数组),与 &a[0](int*)不同。
const int *p; 与 int *const p; 有什么区别?哪个能改指向、哪个能改值?
const int *p 中 const 修饰的是 *p 所指对象,称为指向常量的指针(坊间也叫"常量指针",术语易混),不能通过 p 修改所指的值,但 p 可以改指向别的地址。int *const p 中 const 修饰 p 本身,是指针常量,p 初始化后不能再指向别处,但可以通过 p 修改所指的值。还有 const int *const p 二者皆不可变。答题时先说明"const 修饰的是指针还是所指对象",再下结论,就不会被绕晕。
数组指针和指针数组怎么区分?int (*p)[5] 和 int *p[5] 分别是什么?
判断口诀:先看标识符与谁结合。int (*p)[5] 中 p 先与 * 结合,是数组指针——指向"含 5 个 int 的数组"的指针,常作为二维数组的行指针,p+1 跳过一整行(20 字节)。int *p[5] 中 p 先与 [5] 结合,是指针数组——一个数组,元素是 5 个 int*。两者类型完全不同,混用会导致编译告警或越界访问。
sizeof 和 strlen 的区别?char s[] = "abc" 和 char *p = "abc" 的 sizeof 分别是多少?
sizeof 是运算符,编译期求值,返回对象或类型占用的字节数,包含字符串末尾的 '\0';strlen 是库函数,运行时从首地址扫描到 '\0' 为止,不含 '\0'。所以 char s[] = "abc" 的 sizeof(s) 为 4、strlen(s) 为 3;而 char *p = "abc" 的 sizeof(p) 是指针大小(32 位下 4),strlen(p) 仍是 3——对指针用 sizeof 永远拿不到字符串长度,这是高频陷阱。
函数指针怎么定义和调用?嵌入式里一般用在哪些场景?
定义形如 int (*fp)(int, int) = add;,调用 fp(1, 2) 或 (*fp)(1, 2) 均可;用 typedef 可简化:typedef int (*Op)(int, int);。典型场景:回调函数(如定时器/串口收发完成回调)、中断向量表、状态机跳转表(用数组下标代替 switch-case)、RTOS 中的任务入口。注意函数名在表达式中会退化为函数指针,&add 与 add 等价。
关键字:volatile / static / const
volatile 关键字的作用是什么?什么情况下必须用?
volatile 告诉编译器该变量的值可能被程序自身之外的因素改变,要求每次访问都从内存重新读取,禁止编译器把变量缓存在寄存器里或优化掉看似冗余的读写。典型场景:硬件寄存器(MMIO 映射的寄存器必须 volatile,否则循环等待标志位可能被优化成死循环)、中断服务程序与主循环共享的全局变量、RTOS/多线程共享变量。注意:volatile 保证"每次都读",但不保证原子性,也不能替代临界区保护。
static 有哪些作用?请说出三种。
三种典型作用:① 修饰局部变量——延长生命周期至程序运行期,存储于静态存储区,只初始化一次,未显式初始化时默认值为 0;② 修饰全局变量——把作用域限制在本文件内,避免多文件同名冲突(内部链接);③ 修饰函数——函数只在本文件可见(内部链接),是 C 语言实现模块封装、隐藏实现细节的主要手段。
const 有哪些用途?修饰变量和修饰指针分别代表什么?
① 定义只读变量,防止被意外修改,嵌入式里编译器常把 const 全局数据放入只读段(Flash),从而节省 RAM;② 修饰指针,区分"指向常量的指针"与"指针常量";③ 修饰函数参数(如 const char *s),声明函数不修改实参指向的内容,既能防止误改,也便于编译器优化;④ 修饰函数返回值,表示返回的指针所指内容只读。注意 const 只是编译期约束,不代表变量一定位于只读存储器。
宏与内联函数
宏和内联函数有什么区别?嵌入式为什么大量用宏?
宏在预处理阶段做纯文本替换,没有类型检查,且存在参数多次求值、优先级等副作用陷阱;内联函数(inline)在编译期展开,有类型检查、作用域和调试信息。inline 只是对编译器的建议,是否真正内联由编译器决定。嵌入式大量用宏是因为:宏在预处理阶段就已替换,不产生函数调用开销,能精确控制代码体积,且适合定义常量、位操作封装等;涉及参数的简单逻辑更推荐 static inline。
static inline 是什么?一般用在什么场景?
static inline 是"文件内静态 + 内联展开"的函数:static 限定它只在当前文件可见(内部链接),避免多个文件包含同一头文件时产生重复定义;inline 提示编译器在调用处直接展开,省去函数调用开销。最典型场景是在头文件中定义小函数,如寄存器读写封装(REG32(x) 的 getter/setter)、字节交换、位操作封装,既高效又不会引起链接冲突。
#define SQUARE(x) x*x 有什么问题?怎么改?
直接替换会导致优先级错误:SQUARE(a+b) 展开为 a+b*a+b,结果与预期不符;参数带副作用时还会被多次求值,如 SQUARE(i++) 展开成 i++*i++,行为未定义。正确写法是给参数和整体都加括号:#define SQUARE(x) ((x)*(x))。宏没有类型检查,复杂逻辑应改用函数或 static inline。
头文件为什么要防重复包含?有哪几种方式?
头文件被多个 .c 文件包含、或经其他头文件间接重复包含时,会导致类型/宏重复定义的编译错误,所以需要包含保护。常用两种:宏守卫 #ifndef XXX_H / #define XXX_H / #endif,可移植性好,是标准做法;#pragma once 写法简洁、编译更快,但它是编译器扩展(主流编译器均支持)。两种方式在嵌入式代码中都很常见。
编译与链接
从源码到可执行文件要经过哪些步骤?链接错误常见原因有哪些?
依次为预处理(展开宏、包含头文件、处理条件编译)→编译(C 源码转汇编/目标文件)→汇编(目标文件转机器码)→链接(合并各目标文件的段、解析符号引用、生成可执行文件或固件)。常见链接错误:undefined reference——声明了但没定义,或漏链接了库/目标文件;重复定义——多个文件定义了同名全局符号,可用 static 限制作用域或统一用 extern 声明。区分声明(不分配内存)与定义(分配内存)是排查这类问题的关键。
extern 的作用?多文件共享全局变量怎么用?
extern 声明变量或函数定义在其他文件,用于多文件共享。正确做法:在某个 .c 文件中定义全局变量(分配内存),其他文件用 extern 声明后使用;更规范的是把 extern 声明写在头文件里,各 .c 包含即可。注意 extern int x; 只是声明不是定义,不能初始化;定义只能有一处,否则链接报重复定义。
结构体、位域与位操作
结构体为什么要字节对齐?对齐规则是什么?#pragma pack 是干什么的?
对齐是为了让 CPU 一次总线事务就能取到数据,避免跨边界访问导致性能下降,甚至在某些平台上触发硬件异常。规则:每个成员按其对齐值(通常等于自身类型大小,受编译器默认对齐限制)对齐存放,结构体总大小必须是最大成员对齐值的整数倍;成员排列顺序会影响 sizeof,如 struct { char a; int b; char c; } 在 4 字节对齐下大小为 12。#pragma pack(n) 强制按 n 字节对齐(pack(1) 即紧凑排列、无填充),常用于通信协议报文、Flash/EEPROM 存储结构、寄存器映射,但紧凑排列可能降低访问效率,跨平台时还需考虑大小端。
什么是大小端?怎么用代码判断当前平台是大端还是小端?
大端:高位字节存在低地址;小端:低位字节存在低地址。x86、ARM 默认多为小端,而网络字节序是大端,所以数据发往网络前要用 htonl/htons 转换。判断方法:int x = 1; if (*(char *)&x == 1) 即为小端——(char*)&x 取到的是低地址字节;或用 union:union { int i; char c; } u; u.i = 1; 若 u.c == 1 为小端。跨平台解析协议数据时务必按字节序转换,不能直接强转。
位域有什么用途和陷阱?
位域把若干 bit 打包进一个字节或字,节省内存,常用于硬件寄存器位定义或协议标志位。陷阱:① 位域成员从低位还是高位开始分配由编译器决定(实现相关),不同平台/编译器布局可能不同,跨平台不可移植;② 不能对位域成员取地址 &;③ 位域类型建议用 unsigned,避免符号位扩展问题。嵌入式驱动里更常用位操作宏(置位/清位)直接操作寄存器,可移植性更好。
请写出:置位、清位、翻转某一位,以及判断某位是否为 1、取出最低位的 1。
设 unsigned int x;,第 n 位(从 0 开始):置位 x |= (1U << n);;清位 x &= ~(1U << n);;翻转 x ^= (1U << n);;判断 if ((x >> n) & 1U)。取最低位的 1:x & (-x)(如 x = 0b1100 得 0b0100)。补充:判断是否为 2 的幂:x > 0 && (x & (x - 1)) == 0。务必使用无符号类型,避免右移时的符号位扩展,且移位位数不能超过类型位宽。
内存管理
栈溢出和内存泄漏分别是什么?在嵌入式里怎么排查?
栈溢出是局部变量过大(大数组)、递归过深或中断嵌套过深,导致栈指针越过栈区,可能静默破坏相邻内存、产生随机崩溃或触发 HardFault;内存泄漏是动态内存只分配不释放,导致堆可用空间持续减少,最终 malloc 失败。排查栈溢出:查看 map 文件的栈用量、用栈哨兵填充在运行时检测越界、控制递归深度;排查泄漏:用内存池统计分配/释放计数、结合代码审查,裸机下减少动态分配是从源头规避。
嵌入式环境为什么慎用 malloc/free?有什么替代方案?
嵌入式(尤其裸机和 RTOS)慎用动态内存的原因:① 堆碎片化——频繁分配释放大小不一的块,长期运行后堆耗尽而系统不一定报错;② 执行时间不确定,破坏实时性;③ 标准 malloc 不可重入/非线程安全,RTOS 中需加锁保护;④ 中断服务程序中禁止调用 malloc。替代方案:启动时一次性静态分配、内存池/固定大小块分配、环形缓冲区等专用结构。若必须用,务必检查返回值,free 后置 NULL 防止悬垂指针。
memcpy 和 memset 有哪些常见坑?
① memcpy 的源与目标重叠时行为未定义,应改用 memmove(它会正确处理重叠);② memset(p, 0, sizeof(p)) 当 p 是指针时只清指针本身的大小,应传 sizeof(*p) 或结构体名——这是最高频的陷阱;③ 第三个参数单位是字节,别把元素个数当成字节数;④ memset 第二参数虽为 int,实际按 unsigned char 截断(memset(p, 0x1234, n) 等效填 0x34);⑤ 缓冲区大小不匹配是缓冲区溢出的常见根源,接收数据前先核对长度。

