思澈面试笔记-嵌入式C

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 嵌入式 C 语言高频问点

这份笔记面向嵌入式方向面试,重点不是单纯背 C 语言语法,而是把 C 语言和 MCU、寄存器、中断、内存、驱动代码联系起来讲。

## 1. 面试回答思路

回答嵌入式 C 问题时,尽量使用这个结构:

1. 先说概念:这个关键字、语法或机制是什么。
2. 再说嵌入式场景:它在 MCU、寄存器、外设、中断、RTOS 中怎么用。
3. 补充风险点:容易踩什么坑。
4. 给一个小例子:用代码或排查场景证明自己会用。

例如回答 `volatile`:

> `volatile` 告诉编译器变量可能被当前代码之外的因素改变,不能把读写随意优化掉。嵌入式里常用于硬件寄存器、中断共享变量、DMA 缓冲区状态标志。它解决的是编译器优化可见性问题,不等于线程安全;如果多个任务或中断同时修改变量,还需要临界区、互斥锁或原子操作保护。

## 2. 基本数据类型

### 高频问点:C 语言中 `int` 一定是 32 位吗?

不一定。C 标准只规定了类型之间的最小范围和大小关系,不保证 `int` 一定是 32 位。

嵌入式开发中,不同编译器、不同芯片架构下,`char`、`short`、`int`、`long` 的大小可能不同。为了代码可移植,推荐使用 `<stdint.h>` 中的固定宽度类型:

```c
#include <stdint.h>

uint8_t u8;
uint16_t u16;
uint32_t u32;
int32_t s32;

面试答法:

int 的位宽和平台有关,不能假设一定是 32 位。在嵌入式项目中,如果寄存器、协议字段或 Flash 数据格式要求固定宽度,我会使用 uint8_tuint16_tuint32_t 这类类型,避免平台差异导致问题。

高频问点:char 是否一定是有符号?

不一定。char 是 signed 还是 unsigned 由编译器决定。

如果需要明确范围,应该使用:

1
2
3
4
signed char a;
unsigned char b;
uint8_t c;
int8_t d;

嵌入式场景:

  • 处理二进制协议、传感器原始数据、Flash 字节流时,建议使用 uint8_t
  • 处理有符号温度、加速度等数据时,明确使用 int8_tint16_t

高频问点:有符号和无符号混用有什么风险?

C 语言中,表达式计算会发生整数提升和类型转换。无符号类型和有符号类型混用时,可能把负数转换成很大的无符号数。

示例:

1
2
3
4
5
6
int a = -1;
unsigned int b = 1;

if (a < b) {
// 这里不一定符合直觉,因为 a 可能被转换成 unsigned
}

嵌入式场景:

  • 长度、数组下标、寄存器值通常使用无符号。
  • 错误码通常使用有符号,例如返回 -1 表示失败。
  • 比较长度和错误码时要小心类型转换。

面试答法:

有符号和无符号混用可能导致隐式转换,尤其是负数会变成很大的无符号值。嵌入式里我会尽量让协议长度、缓冲区大小、寄存器值使用明确的无符号类型,错误码使用明确的有符号类型,并避免直接混用比较。

3. 指针

高频问点:指针是什么?

指针是保存地址的变量。通过指针可以间接访问某块内存。

1
2
3
int a = 10;
int *p = &a;
*p = 20;

嵌入式场景:

  • 访问寄存器地址。
  • 传递缓冲区。
  • 实现驱动回调。
  • 操作外设接收数据。
  • 管理链表、队列等数据结构。

面试答法:

指针本质上保存的是地址。嵌入式中指针非常常见,例如通过固定地址访问外设寄存器,或者把缓冲区地址传给 UART、SPI、DMA 驱动。使用指针时要注意空指针、野指针、越界访问和生命周期。

高频问点:指针和数组有什么区别?

数组是一段连续内存,数组名在多数表达式中会退化为首元素地址。指针是一个变量,保存某个地址,可以重新赋值。

1
2
3
4
5
int arr[4] = {1, 2, 3, 4};
int *p = arr;

p++; // 合法,指向下一个元素
// arr++; // 不合法,数组名不能被重新赋值

区别:

  • sizeof(arr) 得到整个数组大小。
  • sizeof(p) 得到指针变量大小。
  • 数组名不是普通变量,不能重新赋值。
  • 函数参数中的数组会退化为指针。

示例:

1
2
3
4
void foo(uint8_t buf[16])
{
// 这里 buf 实际上是 uint8_t *,sizeof(buf) 是指针大小
}

面试答法:

数组代表一块连续内存,指针是保存地址的变量。数组名在大多数表达式中会退化成首元素指针,但 sizeof(arr)&arr 这类场景不会简单等同于普通指针。嵌入式里处理缓冲区时,函数参数最好同时传入指针和长度,避免数组退化后丢失长度信息。

高频问点:什么是野指针、空指针、悬空指针?

空指针:

1
int *p = NULL;

野指针:没有初始化的指针,里面是随机地址。

1
2
int *p;
*p = 10; // 危险

悬空指针:指向的对象已经失效。

1
2
3
4
5
int *foo(void)
{
int x = 10;
return &x; // 错误,x 是局部变量,函数返回后失效
}

嵌入式风险:

  • 访问非法地址可能 HardFault。
  • 写错地址可能破坏寄存器或内存。
  • DMA 使用已经失效的缓冲区会导致随机错误。

高频问点:const 和指针组合怎么理解?

const 修饰谁。

1
2
3
4
const int *p1;       // 指向 const int 的指针,不能通过 p1 修改值,但 p1 可以改指向
int const *p2; // 同上
int * const p3 = &x; // const 指针,p3 不能改指向,但可以修改 *p3
const int * const p4 = &x; // 指针和指向内容都不能改

面试答法:

const 靠近谁就限制谁。const int *p 表示不能通过这个指针修改目标值;int * const p 表示指针变量本身不能再指向别处。嵌入式里函数参数如果只读缓冲区,我会写成 const uint8_t *buf,这样接口语义更清楚。

高频问点:函数指针有什么用?

函数指针保存函数入口地址,可以实现回调、驱动适配、状态机动作表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef void (*irq_callback_t)(void *arg);

static irq_callback_t cb;
static void *cb_arg;

void register_callback(irq_callback_t callback, void *arg)
{
cb = callback;
cb_arg = arg;
}

void irq_handler(void)
{
if (cb) {
cb(cb_arg);
}
}

嵌入式场景:

  • GPIO 中断回调。
  • UART 接收完成回调。
  • 定时器超时回调。
  • 驱动层和应用层解耦。

面试答法:

函数指针常用于回调机制。驱动层不需要知道应用层具体要做什么,只需要在事件发生时调用注册的回调函数,这样能降低模块耦合。

4. 结构体、联合体、枚举

高频问点:结构体有什么用?

结构体把相关数据组织在一起。

1
2
3
4
5
typedef struct {
uint8_t id;
uint16_t value;
uint32_t timestamp;
} sensor_data_t;

嵌入式场景:

  • 保存传感器数据。
  • 描述设备配置。
  • 封装驱动对象。
  • 映射寄存器布局。

示例:

1
2
3
4
5
6
7
8
typedef struct {
volatile uint32_t MODER;
volatile uint32_t OTYPER;
volatile uint32_t OSPEEDR;
volatile uint32_t PUPDR;
volatile uint32_t IDR;
volatile uint32_t ODR;
} GPIO_TypeDef;

高频问点:结构体为什么会有内存对齐?

CPU 访问对齐地址通常更高效,有些架构访问未对齐地址还会异常。编译器会在结构体成员之间插入 padding。

1
2
3
4
5
typedef struct {
uint8_t a;
uint32_t b;
uint8_t c;
} demo_t;

这个结构体大小可能不是 6,而是 12。

优化方法:

  • 把大成员放前面,小成员放后面。
  • 协议结构不要盲目直接强转。
  • 如确实需要紧凑布局,可使用编译器 packed 属性,但要注意未对齐访问风险。

面试答法:

结构体会为了满足 CPU 对齐访问插入填充字节,所以 sizeof(struct) 可能大于成员大小之和。在嵌入式协议解析和 Flash 存储中,不能随意假设结构体内存布局完全等于通信数据格式,必要时要逐字节解析或明确 packed。

高频问点:联合体有什么用?

联合体中所有成员共享同一块内存,大小等于最大成员大小。

1
2
3
4
typedef union {
uint32_t word;
uint8_t bytes[4];
} word_bytes_t;

用途:

  • 节省内存。
  • 同一数据的不同视图。
  • 解析寄存器或协议字段。

风险:

  • 大小端影响字节顺序。
  • 可读性不如显式位运算。

高频问点:枚举有什么用?

枚举用于表示一组有限状态或类型。

1
2
3
4
5
typedef enum {
LED_OFF = 0,
LED_ON,
LED_BLINK,
} led_state_t;

嵌入式场景:

  • 状态机状态。
  • 错误码。
  • 事件类型。
  • 设备工作模式。

5. 内存布局

高频问点:C 程序的内存区域有哪些?

常见区域:

  • .text:代码段,存放程序指令。
  • .rodata:只读数据,例如字符串常量、const 常量。
  • .data:已初始化的全局变量和静态变量。
  • .bss:未初始化或初始化为 0 的全局变量和静态变量。
  • heap:堆,动态分配内存。
  • stack:栈,函数调用、局部变量、返回地址。

示例:

1
2
3
4
5
6
7
8
9
const int a = 1;       // 可能在 rodata
int b = 2; // data
int c; // bss
static int d = 3; // data

void foo(void)
{
int e = 4; // stack
}

面试答法:

嵌入式里 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
2
3
4
5
6
7
8
9
10
11
12
static volatile uint8_t uart_rx_done;

void USART_IRQHandler(void)
{
uart_rx_done = 1;
}

int main(void)
{
while (!uart_rx_done) {
}
}

高频追问:volatile 能保证线程安全吗?

不能。

volatile 只保证编译器不优化访问,不保证操作原子性,也不保证多任务互斥。

1
2
3
volatile int count;

count++; // 可能包含读、加、写三个步骤,不是原子操作

如果 count++ 同时发生在中断和主循环中,仍然可能丢失更新。

面试答法:

volatile 解决的是编译器优化导致的可见性问题,不解决并发安全问题。多个任务或中断同时读写共享变量时,还需要关中断、临界区、互斥锁或原子操作。

7. staticexternconst

高频问点:static 有哪些作用?

修饰局部变量:

1
2
3
4
5
void foo(void)
{
static int count = 0;
count++;
}

特点:

  • 生命周期变成整个程序运行期间。
  • 作用域仍在函数内部。
  • 只初始化一次。

修饰全局变量或函数:

1
2
3
4
5
static int module_state;

static void helper(void)
{
}

特点:

  • 只在当前 .c 文件可见。
  • 避免符号污染和命名冲突。
  • 有利于模块封装。

面试答法:

static 修饰局部变量时改变生命周期,修饰全局变量或函数时限制链接作用域。嵌入式模块里我会把不需要对外暴露的变量和函数声明为 static,减少全局符号污染。

高频问点:extern 的作用是什么?

extern 表示声明一个在其他地方定义的变量或函数。

1
2
3
4
5
// config.c
int g_baudrate = 115200;

// main.c
extern int g_baudrate;

注意:

  • 声明不分配存储空间。
  • 定义才分配存储空间。
  • 不要在头文件里直接定义普通全局变量。

错误示例:

1
2
// config.h
int g_value; // 如果多个 .c 包含,会导致重复定义

正确示例:

1
2
3
4
5
// config.h
extern int g_value;

// config.c
int g_value;

高频问点:const 在嵌入式中有什么意义?

const 表示对象不应该被修改。

嵌入式场景:

  • 查表数据。
  • 字符串常量。
  • 设备固定配置。
  • 函数只读参数。
1
2
3
4
5
static const uint16_t crc_table[256] = {
// ...
};

void send_data(const uint8_t *buf, uint16_t len);

面试答法:

const 能表达只读语义,也可能让数据放到只读存储区域,节省 RAM。函数参数中使用 const 可以告诉调用者这个函数不会修改传入缓冲区。

8. 位运算和寄存器操作

高频问点:如何置位、清零、翻转、判断某一位?

1
2
3
4
5
6
7
8
#define BIT(n) (1U << (n))

reg |= BIT(3); // 置位 bit3
reg &= ~BIT(3); // 清零 bit3
reg ^= BIT(3); // 翻转 bit3

if (reg & BIT(3)) { // 判断 bit3 是否为 1
}

高频问点:如何设置多位字段?

例如要设置寄存器 bit[5:4] 为 0b10

1
2
3
4
#define FIELD_MASK   (0x3U << 4)
#define FIELD_VALUE (0x2U << 4)

reg = (reg & ~FIELD_MASK) | FIELD_VALUE;

面试答法:

设置多位字段时,不能直接或上新值,否则旧值可能残留。正确做法是先用 mask 清掉目标位,再把新值移位后写进去。

高频问点:为什么寄存器要用 volatile

硬件寄存器的值可能被硬件改变,例如状态寄存器、接收数据寄存器。编译器无法知道这些变化。如果不加 volatile,编译器可能把寄存器读取优化成一次,导致程序看不到状态变化。

1
2
3
4
#define UART_SR (*(volatile uint32_t *)0x40011000)

while ((UART_SR & BIT(5)) == 0) {
}

9. 宏、条件编译和 inline

高频问点:宏有什么优缺点?

优点:

  • 编译期展开。
  • 适合定义常量、寄存器地址、bit mask。
  • 可用于条件编译。

缺点:

  • 没有类型检查。
  • 参数可能被重复求值。
  • 复杂宏可读性差。

错误示例:

1
2
3
#define SQUARE(x) x * x

int y = SQUARE(1 + 2); // 展开为 1 + 2 * 1 + 2

改进:

1
#define SQUARE(x) ((x) * (x))

但仍有重复求值问题:

1
SQUARE(i++); // i++ 会执行两次

面试答法:

简单常量和 bit 操作用宏很方便,但复杂逻辑我更倾向使用 static inline 函数,因为它有类型检查,也更容易调试。

高频问点:#ifdef 常用于什么?

用途:

  • 按芯片型号选择代码。
  • 打开或关闭调试日志。
  • 配置功能模块。
  • 适配不同编译器。

示例:

1
2
3
4
5
#ifdef ENABLE_DEBUG_LOG
#define LOG(fmt, ...) printf(fmt, ##__VA_ARGS__)
#else
#define LOG(fmt, ...)
#endif

高频问点:头文件为什么要防止重复包含?

同一个头文件可能被多个文件间接包含,如果没有 include guard,可能导致重复定义或编译错误。

1
2
3
4
5
6
#ifndef DRIVER_UART_H
#define DRIVER_UART_H

void uart_init(void);

#endif

或:

1
#pragma once

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static volatile uint32_t tick_count;

void SysTick_Handler(void)
{
tick_count++;
}

uint32_t get_tick(void)
{
uint32_t t;

__disable_irq();
t = tick_count;
__enable_irq();

return t;
}

12. 错误处理和健壮性

高频问点:嵌入式函数返回值怎么设计?

常见方式:

1
2
3
4
5
6
typedef enum {
ERR_OK = 0,
ERR_TIMEOUT = -1,
ERR_INVALID_ARG = -2,
ERR_BUSY = -3,
} error_t;

函数示例:

1
2
3
4
5
6
7
8
9
int uart_send(const uint8_t *buf, uint16_t len)
{
if (buf == NULL || len == 0) {
return ERR_INVALID_ARG;
}

// send...
return ERR_OK;
}

面试答法:

驱动接口要尽量返回明确错误码,而不是只返回成功或失败。这样上层可以区分参数错误、超时、忙状态、硬件错误,并做对应处理。

高频问点:为什么要检查空指针和长度?

嵌入式系统没有操作系统保护时,空指针或越界访问可能直接 HardFault,甚至破坏内存。

接口设计建议:

  • 指针参数先判空。
  • 长度参数检查范围。
  • 对外接口不要相信调用者一定正确。
  • 对关键错误可以使用断言。

13. 代码组织

高频问点:.h.c 分别放什么?

.h 文件:

  • 类型定义。
  • 宏定义。
  • 对外函数声明。
  • 对外常量声明。

.c 文件:

  • 函数实现。
  • 私有变量。
  • 私有函数。

示例:

1
2
3
4
5
// led.h
#pragma once

void led_init(void);
void led_set(uint8_t on);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// led.c
#include "led.h"

static uint8_t led_state;

void led_init(void)
{
led_state = 0;
}

void led_set(uint8_t on)
{
led_state = on;
}

高频问点:如何设计一个简单驱动接口?

建议包含:

  • 初始化函数。
  • 配置函数。
  • 读写函数。
  • 状态查询函数。
  • 错误返回值。

示例:

1
2
3
4
5
6
7
8
9
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
uint8_t stop_bits;
} uart_config_t;

int uart_init(const uart_config_t *config);
int uart_write(const uint8_t *buf, uint16_t len);
int uart_read(uint8_t *buf, uint16_t len, uint32_t timeout_ms);

面试答法:

我会把驱动接口设计成清晰的初始化、读写和状态查询函数,参数尽量明确,返回值能表达错误原因。模块内部变量用 static 隐藏,避免上层直接操作底层状态。

14. 高频 Bug 场景

场景 1:程序偶尔 HardFault

可能原因:

  • 空指针。
  • 栈溢出。
  • 数组越界。
  • 未对齐访问。
  • 函数指针错误。
  • 中断访问已释放或失效的缓冲区。

排查方法:

  • 查看 HardFault 寄存器。
  • 打开 map 文件看栈和 RAM。
  • 增大任务栈测试。
  • 检查最近修改。
  • 加断言和日志。

场景 2:优化等级一开程序就异常

可能原因:

  • 应该加 volatile 的变量没加。
  • 代码依赖未定义行为。
  • 越界访问。
  • 未初始化变量。
  • 延时循环被优化掉。

面试答法:

如果不开优化正常、开优化异常,我会重点怀疑 volatile、未初始化变量、越界访问和未定义行为。比如等待中断标志位的循环,如果标志变量没有 volatile,编译器可能把它优化成死循环。

场景 3:局部数组过大导致异常

示例:

1
2
3
4
void foo(void)
{
uint8_t buf[8192]; // 小 MCU 上很危险
}

解决:

  • 改为静态缓冲区。
  • 减小缓冲区。
  • 使用分块处理。
  • 检查任务栈大小。

15. 嵌入式 C 高频问答速背

volatileconststatic 的区别?

  • volatile:防止编译器优化访问,常用于寄存器和中断共享变量。
  • const:表示只读语义,防止误修改,也可能放入只读区。
  • static:修饰局部变量改变生命周期,修饰全局变量或函数限制文件作用域。

malloc 和静态分配怎么选?

小 MCU 和实时系统中优先静态分配或内存池。动态分配灵活,但有碎片、失败、耗时不确定和泄漏风险。

为什么中断共享变量要加 volatile

因为变量可能在中断中被修改,主循环如果不加 volatile,编译器可能缓存变量值,导致主循环看不到变化。

volatile 为什么不等于原子操作?

因为 volatile 只约束编译器访问,不保证 count++ 这种读改写操作不可打断。

如何避免头文件重复包含?

使用 include guard 或 #pragma once

什么情况下用函数指针?

回调、状态机、驱动适配、事件通知。它能降低模块之间的耦合。

为什么协议解析不建议直接强转结构体?

因为结构体可能有 padding,且大小端、对齐方式和编译器实现会影响布局。更稳妥的是按字节解析。

寄存器操作为什么常用位运算?

寄存器通常每一位或几位代表一个配置项。位运算可以只修改目标位,不影响其他位。

如何看待全局变量?

全局变量方便但容易导致耦合和并发问题。能限制作用域就用 static,能通过接口访问就不要直接暴露。

嵌入式 C 代码最重要的习惯是什么?

参数检查、边界检查、清晰的错误码、控制作用域、避免大栈对象、共享变量考虑并发、硬件相关变量使用 volatile

1