思澈面试笔记-嵌入式C
思澈面试笔记-嵌入式C
为巽1 | # 嵌入式 C 语言高频问点 |
面试答法:
int的位宽和平台有关,不能假设一定是 32 位。在嵌入式项目中,如果寄存器、协议字段或 Flash 数据格式要求固定宽度,我会使用uint8_t、uint16_t、uint32_t这类类型,避免平台差异导致问题。
高频问点:char 是否一定是有符号?
不一定。char 是 signed 还是 unsigned 由编译器决定。
如果需要明确范围,应该使用:
1 | signed char a; |
嵌入式场景:
- 处理二进制协议、传感器原始数据、Flash 字节流时,建议使用
uint8_t。 - 处理有符号温度、加速度等数据时,明确使用
int8_t、int16_t。
高频问点:有符号和无符号混用有什么风险?
C 语言中,表达式计算会发生整数提升和类型转换。无符号类型和有符号类型混用时,可能把负数转换成很大的无符号数。
示例:
1 | int a = -1; |
嵌入式场景:
- 长度、数组下标、寄存器值通常使用无符号。
- 错误码通常使用有符号,例如返回
-1表示失败。 - 比较长度和错误码时要小心类型转换。
面试答法:
有符号和无符号混用可能导致隐式转换,尤其是负数会变成很大的无符号值。嵌入式里我会尽量让协议长度、缓冲区大小、寄存器值使用明确的无符号类型,错误码使用明确的有符号类型,并避免直接混用比较。
3. 指针
高频问点:指针是什么?
指针是保存地址的变量。通过指针可以间接访问某块内存。
1 | int a = 10; |
嵌入式场景:
- 访问寄存器地址。
- 传递缓冲区。
- 实现驱动回调。
- 操作外设接收数据。
- 管理链表、队列等数据结构。
面试答法:
指针本质上保存的是地址。嵌入式中指针非常常见,例如通过固定地址访问外设寄存器,或者把缓冲区地址传给 UART、SPI、DMA 驱动。使用指针时要注意空指针、野指针、越界访问和生命周期。
高频问点:指针和数组有什么区别?
数组是一段连续内存,数组名在多数表达式中会退化为首元素地址。指针是一个变量,保存某个地址,可以重新赋值。
1 | int arr[4] = {1, 2, 3, 4}; |
区别:
sizeof(arr)得到整个数组大小。sizeof(p)得到指针变量大小。- 数组名不是普通变量,不能重新赋值。
- 函数参数中的数组会退化为指针。
示例:
1 | void foo(uint8_t buf[16]) |
面试答法:
数组代表一块连续内存,指针是保存地址的变量。数组名在大多数表达式中会退化成首元素指针,但
sizeof(arr)和&arr这类场景不会简单等同于普通指针。嵌入式里处理缓冲区时,函数参数最好同时传入指针和长度,避免数组退化后丢失长度信息。
高频问点:什么是野指针、空指针、悬空指针?
空指针:
1 | int *p = NULL; |
野指针:没有初始化的指针,里面是随机地址。
1 | int *p; |
悬空指针:指向的对象已经失效。
1 | int *foo(void) |
嵌入式风险:
- 访问非法地址可能 HardFault。
- 写错地址可能破坏寄存器或内存。
- DMA 使用已经失效的缓冲区会导致随机错误。
高频问点:const 和指针组合怎么理解?
看 const 修饰谁。
1 | const int *p1; // 指向 const int 的指针,不能通过 p1 修改值,但 p1 可以改指向 |
面试答法:
const靠近谁就限制谁。const int *p表示不能通过这个指针修改目标值;int * const p表示指针变量本身不能再指向别处。嵌入式里函数参数如果只读缓冲区,我会写成const uint8_t *buf,这样接口语义更清楚。
高频问点:函数指针有什么用?
函数指针保存函数入口地址,可以实现回调、驱动适配、状态机动作表。
1 | typedef void (*irq_callback_t)(void *arg); |
嵌入式场景:
- GPIO 中断回调。
- UART 接收完成回调。
- 定时器超时回调。
- 驱动层和应用层解耦。
面试答法:
函数指针常用于回调机制。驱动层不需要知道应用层具体要做什么,只需要在事件发生时调用注册的回调函数,这样能降低模块耦合。
4. 结构体、联合体、枚举
高频问点:结构体有什么用?
结构体把相关数据组织在一起。
1 | typedef struct { |
嵌入式场景:
- 保存传感器数据。
- 描述设备配置。
- 封装驱动对象。
- 映射寄存器布局。
示例:
1 | typedef struct { |
高频问点:结构体为什么会有内存对齐?
CPU 访问对齐地址通常更高效,有些架构访问未对齐地址还会异常。编译器会在结构体成员之间插入 padding。
1 | typedef struct { |
这个结构体大小可能不是 6,而是 12。
优化方法:
- 把大成员放前面,小成员放后面。
- 协议结构不要盲目直接强转。
- 如确实需要紧凑布局,可使用编译器 packed 属性,但要注意未对齐访问风险。
面试答法:
结构体会为了满足 CPU 对齐访问插入填充字节,所以
sizeof(struct)可能大于成员大小之和。在嵌入式协议解析和 Flash 存储中,不能随意假设结构体内存布局完全等于通信数据格式,必要时要逐字节解析或明确 packed。
高频问点:联合体有什么用?
联合体中所有成员共享同一块内存,大小等于最大成员大小。
1 | typedef union { |
用途:
- 节省内存。
- 同一数据的不同视图。
- 解析寄存器或协议字段。
风险:
- 大小端影响字节顺序。
- 可读性不如显式位运算。
高频问点:枚举有什么用?
枚举用于表示一组有限状态或类型。
1 | typedef enum { |
嵌入式场景:
- 状态机状态。
- 错误码。
- 事件类型。
- 设备工作模式。
5. 内存布局
高频问点:C 程序的内存区域有哪些?
常见区域:
.text:代码段,存放程序指令。.rodata:只读数据,例如字符串常量、const常量。.data:已初始化的全局变量和静态变量。.bss:未初始化或初始化为 0 的全局变量和静态变量。- heap:堆,动态分配内存。
- stack:栈,函数调用、局部变量、返回地址。
示例:
1 | const int a = 1; // 可能在 rodata |
面试答法:
嵌入式里 Flash 通常存放代码和只读数据,SRAM 存放
.data、.bss、堆和栈。启动代码会把.data从 Flash 复制到 RAM,并把.bss清零,然后再进入main。因此理解内存布局有助于分析 RAM 占用、栈溢出和启动过程。
高频问点:全局变量和局部变量有什么区别?
全局变量:
- 生命周期是整个程序运行期间。
- 默认初始化为 0。
- 存放在
.data或.bss。 - 多个函数都可能访问,容易引入耦合。
局部变量:
- 生命周期通常是函数调用期间。
- 默认不初始化,值不确定。
- 通常存放在栈上。
- 大数组放局部变量可能导致栈溢出。
面试答法:
嵌入式里我会避免在函数里定义很大的局部数组,因为栈空间有限。较大的缓冲区可以放到静态区,但要控制作用域,能用
static限制在当前文件就不要暴露为全局变量。
高频问点:为什么嵌入式里慎用 malloc?
原因:
- 小 MCU RAM 很有限。
- 频繁申请释放可能产生内存碎片。
- 分配失败需要处理。
- 实时系统中动态分配耗时不确定。
- 调试内存泄漏比较困难。
面试答法:
小型嵌入式项目中我会尽量避免频繁动态分配,尤其是在实时路径和中断中。常见做法是使用静态缓冲区、内存池或固定大小队列。如果必须使用动态分配,需要检查返回值,并明确释放时机。
6. volatile
高频问点:volatile 的作用是什么?
volatile 告诉编译器:这个变量可能被当前执行流之外的因素改变,每次访问都必须真实读写内存,不能假设它的值不变。
常见场景:
- 硬件寄存器。
- 中断服务函数和主循环共享变量。
- DMA 状态标志。
- RTOS 中某些简单状态标志。
示例:
1 | volatile uint32_t *GPIO_ODR = (volatile uint32_t *)0x40020014; |
中断共享变量:
1 | static volatile uint8_t uart_rx_done; |
高频追问:volatile 能保证线程安全吗?
不能。
volatile 只保证编译器不优化访问,不保证操作原子性,也不保证多任务互斥。
1 | volatile int count; |
如果 count++ 同时发生在中断和主循环中,仍然可能丢失更新。
面试答法:
volatile解决的是编译器优化导致的可见性问题,不解决并发安全问题。多个任务或中断同时读写共享变量时,还需要关中断、临界区、互斥锁或原子操作。
7. static、extern、const
高频问点:static 有哪些作用?
修饰局部变量:
1 | void foo(void) |
特点:
- 生命周期变成整个程序运行期间。
- 作用域仍在函数内部。
- 只初始化一次。
修饰全局变量或函数:
1 | static int module_state; |
特点:
- 只在当前
.c文件可见。 - 避免符号污染和命名冲突。
- 有利于模块封装。
面试答法:
static修饰局部变量时改变生命周期,修饰全局变量或函数时限制链接作用域。嵌入式模块里我会把不需要对外暴露的变量和函数声明为static,减少全局符号污染。
高频问点:extern 的作用是什么?
extern 表示声明一个在其他地方定义的变量或函数。
1 | // config.c |
注意:
- 声明不分配存储空间。
- 定义才分配存储空间。
- 不要在头文件里直接定义普通全局变量。
错误示例:
1 | // config.h |
正确示例:
1 | // config.h |
高频问点:const 在嵌入式中有什么意义?
const 表示对象不应该被修改。
嵌入式场景:
- 查表数据。
- 字符串常量。
- 设备固定配置。
- 函数只读参数。
1 | static const uint16_t crc_table[256] = { |
面试答法:
const能表达只读语义,也可能让数据放到只读存储区域,节省 RAM。函数参数中使用const可以告诉调用者这个函数不会修改传入缓冲区。
8. 位运算和寄存器操作
高频问点:如何置位、清零、翻转、判断某一位?
1 |
|
高频问点:如何设置多位字段?
例如要设置寄存器 bit[5:4] 为 0b10:
1 |
|
面试答法:
设置多位字段时,不能直接或上新值,否则旧值可能残留。正确做法是先用 mask 清掉目标位,再把新值移位后写进去。
高频问点:为什么寄存器要用 volatile?
硬件寄存器的值可能被硬件改变,例如状态寄存器、接收数据寄存器。编译器无法知道这些变化。如果不加 volatile,编译器可能把寄存器读取优化成一次,导致程序看不到状态变化。
1 |
|
9. 宏、条件编译和 inline
高频问点:宏有什么优缺点?
优点:
- 编译期展开。
- 适合定义常量、寄存器地址、bit mask。
- 可用于条件编译。
缺点:
- 没有类型检查。
- 参数可能被重复求值。
- 复杂宏可读性差。
错误示例:
1 |
|
改进:
1 |
但仍有重复求值问题:
1 | SQUARE(i++); // i++ 会执行两次 |
面试答法:
简单常量和 bit 操作用宏很方便,但复杂逻辑我更倾向使用
static inline函数,因为它有类型检查,也更容易调试。
高频问点:#ifdef 常用于什么?
用途:
- 按芯片型号选择代码。
- 打开或关闭调试日志。
- 配置功能模块。
- 适配不同编译器。
示例:
1 |
高频问点:头文件为什么要防止重复包含?
同一个头文件可能被多个文件间接包含,如果没有 include guard,可能导致重复定义或编译错误。
1 |
|
或:
1 |
10. 大小端、对齐和字节序
高频问点:什么是大小端?
多字节数据在内存中的存放顺序不同:
- 大端:高位字节放低地址。
- 小端:低位字节放低地址。
例如 0x12345678:
- 大端:
12 34 56 78 - 小端:
78 56 34 12
嵌入式场景:
- 通信协议解析。
- Flash 数据存储。
- 传感器寄存器高低字节合成。
示例:
1 | uint16_t value = ((uint16_t)buf[0] << 8) | buf[1]; // 大端解析 |
面试答法:
协议解析时不要直接把字节数组强转成结构体或整数指针,因为会受到大小端和对齐影响。更稳妥的做法是按协议规定显式移位组合。
高频问点:未对齐访问有什么问题?
有些 CPU 支持未对齐访问但效率较低,有些 MCU 访问未对齐地址会产生异常。
风险场景:
- 把
uint8_t *强转为uint32_t *。 - packed 结构体中直接访问多字节成员。
- 协议缓冲区地址不对齐。
安全做法:
- 使用
memcpy复制到对齐变量。 - 使用字节移位解析。
11. 中断与共享变量
高频问点:中断函数里为什么不能做太多事?
原因:
- 中断会打断正常任务。
- 中断执行时间过长会影响实时性。
- 可能阻塞其他中断。
- 不适合做 printf、动态内存分配、长循环、复杂协议解析。
推荐做法:
- 读取必要状态。
- 清除中断标志。
- 保存少量数据。
- 设置标志位。
- 释放信号量或发送简短消息给任务。
面试答法:
中断里应该快进快出,只做必要处理。耗时逻辑放到主循环或 RTOS 任务中执行。这样可以减少中断延迟,提高系统实时性。
高频问点:主循环和中断共享变量要注意什么?
注意:
- 共享变量加
volatile。 - 多字节变量读写可能不是原子操作。
- 读改写操作需要临界区。
- 中断标志要及时清除。
示例:
1 | static volatile uint32_t tick_count; |
12. 错误处理和健壮性
高频问点:嵌入式函数返回值怎么设计?
常见方式:
1 | typedef enum { |
函数示例:
1 | int uart_send(const uint8_t *buf, uint16_t len) |
面试答法:
驱动接口要尽量返回明确错误码,而不是只返回成功或失败。这样上层可以区分参数错误、超时、忙状态、硬件错误,并做对应处理。
高频问点:为什么要检查空指针和长度?
嵌入式系统没有操作系统保护时,空指针或越界访问可能直接 HardFault,甚至破坏内存。
接口设计建议:
- 指针参数先判空。
- 长度参数检查范围。
- 对外接口不要相信调用者一定正确。
- 对关键错误可以使用断言。
13. 代码组织
高频问点:.h 和 .c 分别放什么?
.h 文件:
- 类型定义。
- 宏定义。
- 对外函数声明。
- 对外常量声明。
.c 文件:
- 函数实现。
- 私有变量。
- 私有函数。
示例:
1 | // led.h |
1 | // led.c |
高频问点:如何设计一个简单驱动接口?
建议包含:
- 初始化函数。
- 配置函数。
- 读写函数。
- 状态查询函数。
- 错误返回值。
示例:
1 | typedef struct { |
面试答法:
我会把驱动接口设计成清晰的初始化、读写和状态查询函数,参数尽量明确,返回值能表达错误原因。模块内部变量用
static隐藏,避免上层直接操作底层状态。
14. 高频 Bug 场景
场景 1:程序偶尔 HardFault
可能原因:
- 空指针。
- 栈溢出。
- 数组越界。
- 未对齐访问。
- 函数指针错误。
- 中断访问已释放或失效的缓冲区。
排查方法:
- 查看 HardFault 寄存器。
- 打开 map 文件看栈和 RAM。
- 增大任务栈测试。
- 检查最近修改。
- 加断言和日志。
场景 2:优化等级一开程序就异常
可能原因:
- 应该加
volatile的变量没加。 - 代码依赖未定义行为。
- 越界访问。
- 未初始化变量。
- 延时循环被优化掉。
面试答法:
如果不开优化正常、开优化异常,我会重点怀疑
volatile、未初始化变量、越界访问和未定义行为。比如等待中断标志位的循环,如果标志变量没有volatile,编译器可能把它优化成死循环。
场景 3:局部数组过大导致异常
示例:
1 | void foo(void) |
解决:
- 改为静态缓冲区。
- 减小缓冲区。
- 使用分块处理。
- 检查任务栈大小。
15. 嵌入式 C 高频问答速背
volatile、const、static 的区别?
volatile:防止编译器优化访问,常用于寄存器和中断共享变量。const:表示只读语义,防止误修改,也可能放入只读区。static:修饰局部变量改变生命周期,修饰全局变量或函数限制文件作用域。
malloc 和静态分配怎么选?
小 MCU 和实时系统中优先静态分配或内存池。动态分配灵活,但有碎片、失败、耗时不确定和泄漏风险。
为什么中断共享变量要加 volatile?
因为变量可能在中断中被修改,主循环如果不加 volatile,编译器可能缓存变量值,导致主循环看不到变化。
volatile 为什么不等于原子操作?
因为 volatile 只约束编译器访问,不保证 count++ 这种读改写操作不可打断。
如何避免头文件重复包含?
使用 include guard 或 #pragma once。
什么情况下用函数指针?
回调、状态机、驱动适配、事件通知。它能降低模块之间的耦合。
为什么协议解析不建议直接强转结构体?
因为结构体可能有 padding,且大小端、对齐方式和编译器实现会影响布局。更稳妥的是按字节解析。
寄存器操作为什么常用位运算?
寄存器通常每一位或几位代表一个配置项。位运算可以只修改目标位,不影响其他位。
如何看待全局变量?
全局变量方便但容易导致耦合和并发问题。能限制作用域就用 static,能通过接口访问就不要直接暴露。
嵌入式 C 代码最重要的习惯是什么?
参数检查、边界检查、清晰的错误码、控制作用域、避免大栈对象、共享变量考虑并发、硬件相关变量使用 volatile。
1 |



