No title


C 语言与嵌入式基础面试手册

适用:嵌入式软件、MCU、RTOS、驱动和应用方案实习面试。

阅读方法:S 级必须能直接回答并写代码,A 级理解常见追问,B 级知道用途即可。

每题先背“直接回答”。“补充”只在面试官追问时展开。


1. 复习顺序

优先级 内容
S volatilestaticconst、指针和数组、内存分区、结构体对齐、宏、函数指针、编译链接
S 中断、临界区、互斥锁、信号量、FreeRTOS 调度、环形缓冲区
A 整数转换、动态内存、位域、大小端、C++ 对象模型、RAII
B 格式化 I/O、变参、文件接口、Linux 进程和 I/O 多路复用

回答顺序统一为:定义 -> 作用 -> 不能保证什么 -> 嵌入式场景


2. 数据类型、运算符与控制流

2.1 int 一定是 32 位吗? [S]

直接回答:

不一定。C 标准只规定类型的最小范围和大小关系,具体位宽由平台决定。协议字段、寄存器和文件格式需要固定宽度时,我会使用 <stdint.h> 中的 uint8_tint16_tuint32_t;数组长度和对象大小使用 size_t

1
2
3
4
5
6
7
#include <stddef.h>
#include <stdint.h>

uint16_t adc_value;
int16_t pcm_sample;
uint32_t timestamp_ms;
size_t buffer_size;

补充:

  • sizeof(char) 一定等于 1,但一个字节的位数由 CHAR_BIT 决定;常见平台为 8 位。
  • 普通 char 是否有符号由编译器决定。二进制数据优先用 uint8_t
  • 不要用 %u 猜测 uint32_t 的底层类型;严格写法使用 <inttypes.h>PRIu32

2.2 整数提升是什么? [A]

直接回答:

charshort 等较小整数参与运算时,通常先提升为 intunsigned int。不同类型继续运算时还会转换成共同类型。最容易出错的是有符号数和无符号数混用,负数可能被转换成很大的无符号数。

1
2
3
4
5
6
7
int error = -1;
unsigned int count = 1U;

/* error 会被转换为 unsigned int,结果通常不符合直觉。 */
if (error < count) {
/* ... */
}

工程做法: 长度使用无符号类型,错误码使用有符号类型;比较前确保两边的类型和值域一致。

2.3 有符号和无符号溢出有什么区别? [S]

直接回答:

无符号整数溢出按模运算回绕,是标准规定的;有符号整数溢出是未定义行为,不能依赖补码回绕。计算长度、容量和乘法分配大小时必须提前检查。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <limits.h>
#include <stdbool.h>

bool add_int(int a, int b, int *out)
{
if (out == NULL ||
(b > 0 && a > INT_MAX - b) ||
(b < 0 && a < INT_MIN - b)) {
return false;
}
*out = a + b;
return true;
}

2.4 运算符优先级要掌握哪些? [S]

从高到低记住这几组即可:

1
2
3
4
5
6
7
8
9
10
11
12
后缀: () [] . -> x++ x--
一元: ! ~ ++x --x + - * & sizeof
乘除: * / %
加减: + -
移位: << >>
关系: < <= > >=
相等: == !=
按位: & ^ |
逻辑: && ||
条件: ?:
赋值: = += -= ...
逗号: ,

高频陷阱:

1
2
3
4
5
6
if ((flags & MASK) == 0U) { } /* 必须给位运算加括号 */

*p++; /* 等价于 *(p++),移动指针 */
(*p)++; /* 修改指向的值 */

if (a == b) { } /* 比较,不要误写成 a = b */

2.5 什么是短路求值? [S]

直接回答:

&& 左边为假时不再计算右边;|| 左边为真时不再计算右边。这个顺序由语言保证,常用于空指针和边界保护。

1
2
3
if (buffer != NULL && index < length && buffer[index] == target) {
/* 访问安全 */
}

2.6 ++aa++ 有什么区别? [S]

直接回答:

两者都会让 a 加一。++a 表达式得到加一后的值,a++ 得到加一前的值。对内建整数,如果表达式结果没有被使用,通常没有性能差异。

1
2
3
int a = 3;
int x = ++a; /* a=4, x=4 */
int y = a++; /* y=4, a=5 */

不要写 i++ + ++i。同一表达式中对同一对象进行没有正确顺序关系的多次修改,会产生未定义行为。

2.7 什么是左值和右值? [A]

直接回答:

左值表示一个有身份、可定位的对象。可修改左值可以放在赋值号左边;const 对象也是左值,但不能通过它修改。a + b 这样的临时计算结果通常不是可修改左值。

1
2
3
4
int value = 1;       /* value 是可修改左值 */
const int fixed = 2; /* fixed 是不可修改左值 */
int *p = &value;
*p = 3; /* *p 是可修改左值 */

C++ 的 lvalue、prvalue、xvalue 一般在移动语义问题中再展开。

2.8 指针运算有哪些限制? [A]

直接回答:

指针只能在同一个数组对象及其尾后位置范围内做有意义的加减。两个指针可以在同一数组内相减,结果类型是 ptrdiff_t;指针不能相加;尾后指针可以形成但不能解引用。

移位还要注意:移位数不能为负,也不能大于等于类型位宽;协议位操作应使用无符号数,避免负有符号数右移的实现差异。

2.9 continuebreakreturn [S]

直接回答:

  • continue:结束本轮循环,进入下一轮。
  • break:退出最内层循环或 switch
  • return:结束当前函数并返回结果。

不能返回局部自动变量的地址,因为函数返回后它的生命周期已经结束。

2.10 goto 能不能用? [A]

直接回答:

普通流程不建议使用 goto,但 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
int device_start(void)
{
int rc = clock_enable();
if (rc != 0) {
return rc;
}

rc = bus_init();
if (rc != 0) {
goto disable_clock;
}

rc = sensor_init();
if (rc != 0) {
goto deinit_bus;
}
return 0;

deinit_bus:
bus_deinit();
disable_clock:
clock_disable();
return rc;
}

2.11 switch 有哪些注意点? [S]

直接回答:

case 必须是整型常量表达式;没有 break 会继续执行下一个分支;应处理非法状态。故意贯穿时要明确注释,防止被误认为漏写 break

1
2
3
4
5
6
7
8
9
10
11
switch (state) {
case STATE_IDLE:
start_transfer();
break;
case STATE_BUSY:
poll_transfer();
break;
default:
enter_safe_state();
break;
}

2.12 do whilewhile 的区别 [A]

do while 先执行循环体再判断,因此至少执行一次;while 先判断,可能一次都不执行。多语句宏使用 do { ... } while (0),是为了让宏在 if/else 中表现得像一条普通语句。

2.13 C 可以写二进制整数常量吗? [B]

C23 已加入 0b1010 二进制字面量。GCC、Clang 等工具链更早就提供了扩展,但 C11/C17 的严格可移植代码不能依赖它。因此回答时要说明语言标准和编译器版本,不能简单说“能”或“不能”。

2.14 如何判断一个整数类型有无符号? [B]

程序设计时应从类型声明直接确定,不应等到运行时猜测。面试中若要求写表达式,可以把该类型的 -1 与 0 比较:无符号类型转换后会变成最大值。

1
2
3
4
#define TYPE_IS_SIGNED(type_) (((type_)-1) < (type_)0)

_Static_assert(TYPE_IS_SIGNED(int), "int should be signed");
_Static_assert(!TYPE_IS_SIGNED(unsigned int), "unsigned expected");

这个宏接收的是类型,不是带副作用的变量表达式;实际工程应优先使用明确类型和编译器类型检查。


3. sizeof、存储期和内存分区

3.1 sizeofstrlen 的区别 [S]

直接回答:

sizeof 是运算符,返回对象或类型占用的字节数,结果类型是 size_tstrlen 是库函数,运行时查找字符串的第一个 \0,返回它前面的字符数,不包含终止符。

1
2
3
4
char text[] = "abc";

sizeof text; /* 4,包含结尾 '\0' */
strlen(text); /* 3 */

高频追问:

1
2
3
4
5
6
char s[] = "\0";
sizeof s; /* 2:显式 '\0' + 自动终止符 */
strlen(s); /* 0 */

int a = 1;
sizeof(a++); /* 非 VLA 情况下 a 不会增加 */

函数参数 void f(char a[32]) 中的 a 实际会调整为 char *,所以 sizeof(a) 是指针大小,不是 32。

标准 C 不允许 sizeof(void);GCC 把它当作扩展时可能给出 1,不能当作标准答案。

3.2 不同变量通常存在哪里? [S]

直接回答:

对象 常见位置 生命周期
函数代码 .text,MCU 中常在 Flash 程序全程
字符串字面量、只读常量 .rodata,常在 Flash 程序全程
已初始化全局/static .data,运行时在 RAM 程序全程
零初始化或未显式初始化全局/static .bss,RAM 程序全程
普通局部变量 栈、寄存器或被优化掉 进入到离开作用域
动态分配对象 分配到释放

这是典型链接布局,不是 C 标准强制。最终要看链接脚本、map 文件和编译优化。

3.3 const 变量存在哪里?和 static 一样吗? [S]

直接回答:

不一定。const 只说明不能通过该访问路径修改对象,不决定存储区域;static 主要决定存储期,文件作用域下还会限制链接范围。

  • 局部 const:可能在栈、寄存器中,也可能被直接优化成常量。
  • 文件作用域 static const:常放 .rodata 或 Flash。
  • static int x;:通常在 .bss
  • static int x = 1;:通常在 .data

因此 conststatic 不是同一维度,不能回答“它们都在静态区”。

3.4 栈和堆有什么区别? [S]

直接回答:

栈通常由编译器和调用过程自动管理,分配快、行为较确定,但容量有限;堆由程序通过分配器管理,生命周期灵活,但可能分配失败、泄漏和产生碎片。RTOS 中每个任务通常有自己的任务栈,而堆往往由系统共享。

补充:

  • 栈用于保存部分调用现场、自动变量等,但参数是否压栈、压栈顺序和栈增长方向由 ABI 决定,不是 C 语言规则。
  • 栈最大值没有统一答案,要看链接脚本、操作系统限制或任务栈配置。

3.5 MCU 上为什么慎用动态内存? [S]

直接回答:

主要原因不是“malloc 一定很慢”,而是它的时间和成功率不容易做最坏情况分析,长期运行还可能产生碎片。硬实时路径优先静态分配或固定块内存池;确实需要动态内存时,可在启动阶段集中申请,并明确失败处理和所有权。

3.6 malloccallocrealloc 的区别 [S]

直接回答:

  • malloc(n):申请 n 字节,内容未初始化。
  • calloc(count, size):申请数组并把所有字节清零。
  • realloc(p, n):调整原内存块大小,可能搬到新地址。
  • free(p):释放动态内存;free(NULL) 安全。

realloc 不能直接覆盖原指针,因为失败时会返回 NULL,而旧内存仍然有效:

1
2
3
4
5
6
7
void *tmp = realloc(buffer, new_size);
if (tmp == NULL && new_size != 0U) {
/* buffer 仍然有效 */
handle_error();
} else {
buffer = tmp;
}

分配数组前检查乘法溢出:

1
2
3
4
if (count > SIZE_MAX / sizeof(*array)) {
return NULL;
}
array = malloc(count * sizeof(*array));

补充: calloc 保证的是所有字节为零,不应把它泛化成所有类型都得到语言意义上的零值。工程上也应避免依赖 realloc(p, 0) 的版本差异,直接 free 更清楚。

3.7 什么是内存泄漏和内存碎片? [S]

直接回答:

内存泄漏是已经失去指针、无法再释放已申请内存。外部碎片是空闲总量够,但被分成很多小块,无法满足大块申请;内部碎片是分配块大于实际需求造成块内浪费。

规避方法:明确所有权、统一错误清理、减少不同尺寸的高频分配、使用固定块池、做长时间压力测试。主机侧可以用 AddressSanitizer、LeakSanitizer 或 Valgrind,目标侧看堆统计和完整性检查。

3.8 free 后为什么常把指针置 NULL? [S]

直接回答:

置 NULL 可以减少同一个指针变量被再次使用或重复释放的风险,但不能消除所有悬空指针,因为其他指向同一内存的别名仍然有效值、无效对象。

1
2
3
4
uint8_t *alias = buffer;
free(buffer);
buffer = NULL;
/* alias 仍是悬空指针 */

真正重要的是明确所有权并保证释放后不再访问。

3.9 1 GB 内存能否 malloc(1.2 GB)[B]

不能只看物理内存。还取决于虚拟地址空间、进程限制、交换空间、连续虚拟地址以及 Linux overcommit。启用 overcommit 时,malloc 可能先成功,真正访问页面时才失败。无 MMU 的 MCU 则主要取决于实际堆大小和连续空闲块。


4. volatileconststaticextern

4.1 volatile 有什么作用? [S]

直接回答:

volatile 告诉编译器,这个对象可能被当前代码之外的因素改变。因此代码读写它时,编译器不能把需要发生的访问随意删除、合并或一直使用寄存器中的旧值。嵌入式中主要用于内存映射寄存器,以及被中断异步修改的简单标志。

它只约束编译器访问,不保证复合操作的原子性,也不提供互斥和线程同步,不能替代锁、原子操作、内存屏障或 Cache 维护。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdbool.h>

static volatile bool data_ready;

void timer_isr(void)
{
data_ready = true;
}

void main_loop(void)
{
if (data_ready) {
data_ready = false;
process_data();
}
}

追问:为什么中断标志必须加?

编译器在普通程序流程中看不到 ISR 对变量的修改,可能只读取一次并一直使用缓存值。加 volatile 后会保留对该对象的实际访问。

边界:

  • volatile uint32_t count; count++; 仍可能是“读、加、写”三步,不是原子操作。
  • 标准 C 信号处理函数与主程序共享简单标志时,应使用 volatile sig_atomic_t
  • DMA 缓冲区还要处理所有权、内存顺序和非一致 Cache,不能只加 volatile。

4.2 const 有什么作用?真的不能改吗? [S]

直接回答:

const 表示不能通过这个受限的访问路径修改对象,主要用于表达只读接口和加强类型检查。它不是硬件写保护,也不直接决定变量存在哪里。

1
2
3
4
5
int value = 10;
const int *view = &value;

value = 20; /* 合法,底层对象本来不是 const */
/* *view = 30; 不能通过 view 修改 */

如果对象本身就是 const,强制去掉 const 后写入是未定义行为,可能因位于只读区而直接异常。

4.3 const 和指针怎样读? [S]

从变量名向外读:

1
2
3
4
const int *p1;                /* 可改指向,不能通过 p1 改目标 */
int const *p2; /* 与 p1 相同 */
int *const p3 = &value; /* 不可改指向,可以改目标 */
const int *const p4 = &value; /* 指向和目标访问都受限 */

函数只读输入缓冲区时写 const uint8_t *data,能直接表达接口不会修改数据。

4.4 static 有哪些作用? [S]

直接回答:

  • 局部 static:作用域仍在函数块内,但生命周期贯穿整个程序,只初始化一次。
  • 文件作用域 static 变量:只在当前 .c 文件可见,生命周期贯穿程序。
  • static 函数:只在当前翻译单元可见,适合模块私有函数。
  • C++ 类静态数据成员:所有对象共享一份,不计入普通对象大小。
  • C++ 静态成员函数:没有 this,只能直接访问类的静态成员。
1
2
3
4
5
6
7
static unsigned module_errors;

static unsigned next_id(void)
{
static unsigned id;
return ++id;
}

局部 static 会被多次调用共享,因此函数可能不再可重入。C++11 保证函数局部静态对象初始化的线程安全,但对象初始化完成后的普通访问仍不自动线程安全。

4.5 extern 有什么作用? [S]

直接回答:

extern 用来声明一个变量或函数在其他位置定义。编译器用声明做类型检查,链接器最后查找对应定义。

1
2
3
4
5
/* counter.h */
extern unsigned g_counter;

/* counter.c */
unsigned g_counter = 0U;

extern int x = 1; 带初始化器,仍然是定义。不要说 extern 只在链接阶段才起作用。

4.6 registerauto [B]

auto 在 C 中表示自动存储期,局部变量默认就是,因此几乎不用;不要和 C++ 的类型推导 auto 混淆。register 是旧式寄存器优化提示,现代编译器自行决定;C 中不能对 register 对象取地址。它们主要是历史知识。

4.7 局部变量能和全局变量重名吗? [A]

能。局部名字会遮蔽全局名字,但容易读错,工程中应避免并开启 -Wshadow 一类告警。


5. 结构体、联合体、枚举和大小端

5.1 结构体为什么要内存对齐? [S]

直接回答:

处理器通常对满足自然对齐的访问更高效,有的平台甚至不允许未对齐访问。编译器会在成员之间和结构体末尾加入填充:每个成员放在满足自身对齐的位置,结构体总大小再补齐为最大对齐要求的整数倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdint.h>

struct LayoutA {
uint8_t tag;
uint32_t value;
uint16_t code;
}; /* 常见 ABI 下为 12 字节 */

struct LayoutB {
uint32_t value;
uint16_t code;
uint8_t tag;
}; /* 常见 ABI 下为 8 字节 */

面试计算前要确认平台的类型大小和对齐规则。字段按对齐要求从大到小排列通常能减少填充,但不能破坏外部 ABI。

5.2 为什么协议不能直接发送结构体? [S]

因为结构体可能有填充字节,主机还存在大小端、类型宽度和编译器布局差异。网络协议或 Flash 格式应逐字段序列化,并明确字节序和版本。

packed 能减少填充,但可能造成未对齐访问、性能下降甚至硬件异常,也不能解决大小端和版本问题。

5.3 C 和 C++ 的 struct 有什么差别? [A]

直接回答:

C 中 struct 主要用于组合数据,使用时通常要写 struct Device,除非 typedef。C++ 的 struct 可以有构造函数、成员函数、继承和访问控制;它与 class 的主要区别是 struct 默认 public,class 默认 private,默认继承权限也不同。

5.4 structunion 的区别 [S]

直接回答:

结构体的每个成员都有独立存储,大小需要容纳全部成员和填充;联合体的成员共享同一块存储,大小足以容纳最大成员。同一时刻通常只应按当前有效成员解释联合体。

联合体适合表示互斥的数据类型,但应配合标签:

1
2
3
4
5
6
7
8
9
enum value_kind { VALUE_INT, VALUE_FLOAT };

struct tagged_value {
enum value_kind kind;
union {
int integer;
float real;
} value;
};

5.5 如何判断大小端? [S]

直接回答:

大端把高位字节放在低地址,小端把低位字节放在低地址。可以通过 unsigned char * 查看一个多字节整数的对象表示。

1
2
3
4
5
6
7
8
#include <stdbool.h>
#include <stdint.h>

bool is_little_endian(void)
{
uint16_t value = UINT16_C(0x0102);
return *(const unsigned char *)&value == 0x02U;
}

协议处理不要依赖主机端序,直接按字节编码:

1
2
3
4
uint16_t read_be16(const uint8_t p[2])
{
return (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
}

5.6 位域是什么?为什么不直接解析协议? [A]

直接回答:

位域可以用成员语法表示若干位,常见于受指定编译器和 ABI 约束的寄存器定义。但位域从哪一位开始、是否跨字节、如何填充都可能由实现决定,因此不适合直接映射可移植网络协议。

1
2
3
4
5
#define ACK_MASK  (UINT8_C(1) << 0)
#define MODE_MASK UINT8_C(0x1C)
#define MODE_SHIFT 2U

unsigned mode = (header & MODE_MASK) >> MODE_SHIFT;

协议使用掩码和移位更可靠。

5.7 enum 有什么作用?大小固定吗? [A]

枚举为一组相关整数常量命名,适合状态机。C 中枚举底层兼容整数类型和大小由实现选择,不能默认固定为 4 字节。协议和持久化数据应使用定宽整数保存,在代码边界转换成枚举。

5.8 typedef#define 的区别 [S]

直接回答:

typedef 是编译器处理的类型别名,有作用域和类型检查;#define 是预处理文本替换。定义指针时区别很明显:

1
2
3
4
5
typedef char *char_ptr;
#define CHAR_PTR char *

char_ptr a, b; /* a、b 都是 char * */
CHAR_PTR c, d; /* 展开后只有 c 是 char *,d 是 char */

函数指针推荐 typedef:

1
typedef void (*event_callback_t)(int event, void *context);

typedef 只是别名,不会创建与原类型不兼容的新类型。


6. 指针、数组和字符串

6.1 什么是指针? [S]

直接回答:

指针是保存地址的对象,指针类型决定解引用时按什么类型访问、指针加一跨过多少字节。要区分指针变量本身、保存的地址和它指向对象的生命周期。

1
2
3
uint32_t value = 10U;
uint32_t *p = &value;
*p = 20U;

sizeof(p) 是指针本身大小,sizeof(*p) 是所指类型大小。指针大小由架构和 ABI 决定,不由所指类型决定。

6.2 数组和指针的区别 [S]

直接回答:

数组是一段包含固定数量元素的连续存储,指针是保存地址的独立变量。数组名在大多数表达式中会转换成首元素指针,但数组本身不是指针。

1
2
3
4
5
6
7
8
int array[4] = {1, 2, 3, 4};
int *p = array;

sizeof(array); /* 整个数组大小 */
sizeof(p); /* 指针大小 */

p++; /* 合法 */
/* array++; 非法 */

6.3 array&array 有什么区别? [S]

假设 int array[4]

  • array 在多数表达式中转换为 int *,指向首元素。
  • &array 类型是 int (*)[4],指向整个数组。
  • array + 1 跨过一个 int。
  • &array + 1 跨过整个四元素数组。

它们起始地址通常相同,但类型和步长不同。

6.4 指针数组和数组指针 [S]

1
2
int *pointer_array[4];   /* 4 个 int 指针组成的数组 */
int (*array_pointer)[4]; /* 指向 int[4] 的指针 */

看变量名附近的运算符:[] 优先级高于 *,括号让 * 先和变量名结合。

6.5 二维数组如何传参? [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stddef.h>

#define COLS 4U

int matrix_sum(size_t rows, const int matrix[][COLS])
{
int sum = 0;
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < COLS; ++c) {
sum += matrix[r][c];
}
}
return sum;
}

参数实际调整为“指向一行数组的指针”,编译器必须知道列数才能计算下一行地址。int ** 不是连续二维数组 int[R][C] 的等价类型。

6.6 什么是二级指针? [A]

二级指针保存一级指针的地址,常用于让函数修改调用者的指针、处理指针数组或动态二维结构。

1
2
3
4
5
6
7
8
bool allocate_buffer(size_t size, uint8_t **out)
{
if (out == NULL) {
return false;
}
*out = malloc(size);
return *out != NULL;
}

6.7 指针和 C++ 引用有什么区别? [A]

引用通常必须初始化、不能改绑,语法上直接作为对象别名;指针可以为空并改变指向。需要表达“可选对象”时用指针,表达“必须存在且只是别名”时可用引用。C++ 用 T *& 可以让函数修改调用者的指针。

6.8 野指针、空指针和悬空指针 [S]

  • 空指针:明确不指向有效对象;C 用 NULL,C++ 优先 nullptr
  • 野指针:未初始化或值已损坏。
  • 悬空指针:曾经指向有效对象,但对象生命周期已经结束。
1
2
3
4
5
int *bad(void)
{
int local = 1;
return &local; /* 返回后 local 已失效 */
}

避免方法:声明时初始化、检查生命周期和边界、不要返回局部地址、释放后停止使用、明确所有权。

6.9 字符串字面量和字符数组 [S]

1
2
3
4
const char *s1 = "hello"; /* 指向字面量,不得修改 */
char s2[] = "hello"; /* 可修改的数组副本 */

s2[0] = 'H';

修改字符串字面量是未定义行为。C 接口也应使用 const char * 表达只读字符串。

6.10 数组下标可以是负数吗? [A]

语法允许,因为 p[i] 等价于 *(p + i)。只要结果仍在同一个数组对象内就是合法的:

1
2
3
int a[4] = {10, 20, 30, 40};
int *p = &a[2];
int value = p[-1]; /* 等于 a[1] */

越出数组范围就是未定义行为,业务代码通常不使用负下标。

6.11 函数指针和回调 [S]

直接回答:

函数指针保存函数入口,可实现回调、状态机动作表和驱动适配。回调接口应明确参数、执行上下文和生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef void (*rx_callback_t)(const uint8_t *data,
size_t length,
void *context);

struct receiver {
rx_callback_t callback;
void *context;
};

void dispatch(struct receiver *r, const uint8_t *data, size_t length)
{
if (r != NULL && r->callback != NULL) {
r->callback(data, length, r->context);
}
}

若回调在 ISR 中执行,就不能阻塞、打印或做耗时算法;通常只通知任务。

“函数指针”是指向函数的指针;“指针函数”是返回指针的函数,两者不要混淆。

6.12 void * 在 C 和 C++ 中的区别 [A]

C 中对象指针可与 void * 隐式转换;C++ 从 void * 转回具体指针需要显式转换。void * 不保存长度和类型信息,使用者必须通过接口约定恢复正确类型。标准不保证函数指针能可移植地转为 void *


7. 预处理、宏和编译链接

7.1 #include <>#include "" [S]

通常尖括号从系统 include 路径搜索,双引号先搜索项目或当前文件附近,再搜索系统路径;准确顺序由工具链定义。标准库头文件用尖括号,项目头文件用双引号。

7.2 头文件保护有什么用? [S]

防止同一个头文件在一个翻译单元中重复展开。

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

int sensor_init(void);

#endif

#pragma once 被主流编译器支持,但不是标准 C。include guard 的名字应带项目和模块前缀。

7.3 为什么头文件不能定义普通全局变量? [S]

头文件会被多个源文件包含,直接定义变量可能在链接时产生多重定义。正确做法是在头文件用 extern 声明,在一个源文件中定义。

也不要在头文件随意定义可写的 static 变量,因为每个翻译单元会得到一份独立副本,可能造成状态不一致。

7.4 const#defineenum 怎么选? [S]

直接回答:

  • const:有类型、作用域和编译检查,适合普通只读常量。
  • #define:文本替换,适合条件编译和必须在预处理阶段使用的值。
  • enum:适合一组相关整数常量,也能在 C 中提供整型常量表达式。
1
2
3
#define FEATURE_AUDIO 1
static const uint32_t sample_rate_hz = UINT32_C(16000);
enum { FRAME_SAMPLES = 960 };

C 中块作用域 const int n = 10 不一定能用于 case 标签等要求整型常量表达式的地方;C++ 规则不同。

7.5 宏有哪些常见坑? [S]

四类:缺括号、参数被多次求值、宏内部名字冲突、多语句宏破坏 if/else

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

/* SQUARE(i++) 仍会让 i 求值两次,不能这样调用。 */

多语句宏:

1
2
3
4
#define SET_FLAG(obj_, mask_) do { \
(obj_)->flags |= (mask_); \
notify_change(obj_); \
} while (0)

7.6 怎样写 MIN[A]

1
#define MIN(a_, b_) (((a_) < (b_)) ? (a_) : (b_))

这个写法解决了优先级问题,但参数仍可能执行两次,不能传 i++、函数调用或硬件寄存器读取。标准 C 宏很难同时做到泛型、只求值一次和类型安全;常用类型优先写 static inline 函数。

7.7 ### 有什么作用? [A]

# 把宏参数转换为字符串,## 拼接 token。

1
2
3
4
5
6
7
#define STRINGIFY_INNER(x) #x
#define STRINGIFY(x) STRINGIFY_INNER(x)
#define DECLARE_HANDLER(name_) void handler_##name_(void)

#define FW_MAJOR 2
const char *version = STRINGIFY(FW_MAJOR); /* "2" */
DECLARE_HANDLER(uart);

7.8 #error 和编译期断言 [A]

#error 在预处理阶段主动终止不支持的构建配置;_Static_assert 在编译期检查类型大小或常量条件。

1
2
3
4
5
#if !defined(TARGET_MCU) && !defined(HOST_TEST)
#error "Select a target"
#endif

_Static_assert(sizeof(uint32_t) == 4, "32-bit type required");

C++ 使用 static_assert

7.9 如何获取结构体成员偏移? [A]

使用标准头文件 <stddef.h>offsetof(type, member)。不要自己用空指针成员访问实现,因为标准宏可能由编译器内建处理,自写版本容易引入未定义行为。

7.10 宏函数、inline 和普通函数的区别 [S]

直接回答:

宏是预处理文本替换,没有普通函数的类型检查,参数还可能求值多次。inline 是真正的函数,参数只求值一次,有类型检查;但 inline 只是允许内联,编译器不一定展开。普通函数即使没写 inline,优化器也可能展开。

头文件中的小型 C 函数常写:

1
2
3
4
static inline uint32_t bit_mask(unsigned bit)
{
return UINT32_C(1) << bit;
}

7.11 编译四个阶段是什么? [S]

直接回答:

  1. 预处理:展开 include、宏和条件编译。
  2. 编译:语法语义检查、优化并生成汇编。
  3. 汇编:把汇编转为目标文件,生成符号和重定位信息。
  4. 链接:合并目标文件和库,解析符号、完成重定位并生成可执行映像。

嵌入式还要由链接脚本决定各段放入 Flash/RAM 的地址,最后生成 bin/hex 等烧录文件。

7.12 volatileextern 分别在哪个阶段起作用? [A]

volatile 是类型限定符,主要影响编译器生成访问代码;extern 的声明先由编译器用于类型检查,符号是否有匹配定义最后由链接器解析。不能简单说 volatile 只属于优化阶段、extern 只属于链接阶段。

7.13 定义一年秒数的宏 [B]

1
2
#define SECONDS_PER_YEAR \
(UINT32_C(365) * UINT32_C(24) * UINT32_C(60) * UINT32_C(60))

结果为 31,536,000。加括号和明确的无符号宽度,避免文本替换优先级和较小整数平台上的溢出问题;题目已说明忽略闰年。


8. 函数、字符串和格式化 I/O

8.1 C 是值传递还是引用传递? [S]

C 只有值传递。传指针时复制的也是地址值;通过地址可以修改所指对象,但给形参重新赋值不会改变调用者的指针。要修改调用者指针,需要传二级指针。

8.2 什么是可重入函数? [A]

直接回答:

可重入函数可以在上一次调用尚未结束时再次安全调用,例如被中断或另一任务调用。它不能依赖未保护的共享可写状态、返回共享静态缓冲区,也不能调用不可重入函数。

把状态交给调用者通常更容易实现可重入:

1
2
3
4
5
int filter_step(struct filter_state *state, int sample)
{
state->sum += sample;
return state->sum;
}

8.3 为什么嵌入式谨慎使用递归? [A]

递归深度可能难以确定,最坏栈占用不容易分析,栈溢出后会破坏其他内存。硬实时 MCU 优先用迭代或固定容量显式栈。若深度有严格上限并经过栈分析,并不是绝对不能用。

8.4 C 变参函数怎样实现? [B]

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdarg.h>

int sum_ints(unsigned count, ...)
{
va_list ap;
va_start(ap, count);
int sum = 0;
for (unsigned i = 0; i < count; ++i) {
sum += va_arg(ap, int);
}
va_end(ap);
return sum;
}

变参没有完整类型信息,调用者和函数必须约定参数类型。float 会提升为 double,小整数会提升为 intunsigned intva_arg 用错误类型读取可能产生未定义行为。

8.5 getsscanf 有什么区别? [S]

不要使用 gets。它无法接收缓冲区容量,C11 已删除该函数。scanf("%s", buf) 遇空白停止,如果不限制宽度也会越界。可靠做法是 fgets 读取一行,再使用 strtol 等函数解析并检查错误。

1
2
3
4
char line[64];
if (fgets(line, sizeof line, stdin) != NULL) {
/* 继续解析 */
}

8.6 printf 的返回值 [A]

成功时返回实际输出的字符数,不包含结尾 \0;失败返回负数。snprintf 返回缓冲区足够时本应写出的字符数,因此可以判断是否截断:

1
2
3
4
5
6
7
char text[32];
int n = snprintf(text, sizeof text, "id=%u", id);
if (n < 0) {
handle_error();
} else if ((size_t)n >= sizeof text) {
handle_truncation();
}

8.7 scanf 的返回值 [A]

返回成功赋值的输入项数。匹配失败可能返回 0;到达输入结束或发生读取错误且尚未赋值时返回 EOF。必须检查返回值,格式说明符必须与目标指针类型匹配。

1
2
3
4
int value;
if (scanf("%d", &value) != 1) {
handle_invalid_input();
}

8.8 printfscanf 中的 * [B]

printf* 表示从参数读取字段宽度或精度:

1
printf("%*.*f", width, precision, value);

scanf* 表示读取但不赋值,该项不计入成功赋值数:

1
sscanf("12:34", "%*d:%d", &value); /* value 为 34 */

8.9 常见字符和文件位置函数 [B]

  • fgetc/getchar 返回 int,才能同时表示全部字节值和 EOF
  • fgets 最多读取 size - 1 个字符并补 \0,读到换行时通常保留换行。
  • fseek 改变文件位置,ftell 获取位置,rewind 回到开头并清除 EOF/错误状态。
  • POSIX 大文件通常使用 fseeko/ftellooff_t

8.10 哪些字符串函数可能越界? [A]

  • strcpy 不知道目标容量,可能写越界。
  • strcat 不知道剩余容量,也可能写越界。
  • strncat 的第三个参数是最多追加的源字符数,不是目标总容量,仍容易误用。
  • strcmp 不写内存,但输入若没有正确 \0 结尾会越界读。

不要简单说 strncpy 一定安全,它可能不补终止符,还会填充剩余空间。接口应显式携带容量,并报告截断。


9. 嵌入式 C++

9.1 嵌入式为什么常用 C,不用 C++? [S]

直接回答:

C 的运行时依赖小、ABI 简单稳定、寄存器和内存操作直接,而且芯片厂商 SDK、启动代码和驱动普遍提供 C 接口,所以底层嵌入式长期以 C 为主。

但 C++ 不是不能用于嵌入式。RAII、模板、强类型和 constexpr 可以在很少运行时开销下改善资源管理和代码结构。项目真正需要限制的是异常、RTTI、不可控动态分配、全局对象初始化和部分 STL 容器,而不是笼统禁止 C++。

9.2 C 和 C++ 的主要区别 [S]

直接回答:

C 主要以过程和函数组织程序;C++ 在此基础上增加了类、构造析构、函数重载、引用、模板、继承和多态等机制。C++ 的类型检查更严格,并可用 RAII 自动管理资源。

嵌入式中常见组合是:底层驱动保持稳定的 C 接口,上层业务使用受约束的 C++。不能把 C++ 简单理解为“带 class 的 C”,两者有不同的类型和对象生命周期规则。

9.3 C++ 的 structclass 区别 [S]

两者能力基本相同,都可以有数据成员、成员函数、构造析构和继承。主要区别是:struct 默认成员和默认继承是 public;class 默认是 private。通常用 struct 表示简单数据,用 class 表示需要维护不变量和封装行为的对象。

9.4 构造函数有哪些? [A]

常见有默认构造、带参数构造、复制构造、移动构造和委托构造。现代 C++ 还常讨论六个特殊成员:默认构造、析构、复制构造、复制赋值、移动构造和移动赋值。

它们不是任何情况下都会自动可用。某个成员不可复制时,包含它的类复制操作可能被删除;用户声明析构函数会影响隐式移动操作的生成。工程上优先遵守 Rule of Zero:让标准 RAII 成员管理资源,类本身不手写特殊成员。

1
2
3
4
5
6
7
8
9
10
class Buffer {
public:
explicit Buffer(std::size_t size);
~Buffer();

Buffer(const Buffer &) = delete;
Buffer &operator=(const Buffer &) = delete;
Buffer(Buffer &&) noexcept;
Buffer &operator=(Buffer &&) noexcept;
};

9.5 对象的初始化和析构顺序 [A]

直接回答:

构造顺序是:虚基类、直接基类、数据成员、构造函数体。基类按继承声明顺序,成员按类中的声明顺序,而不是初始化列表书写顺序。析构顺序与构造顺序相反。

应开启 -Wreorder,避免初始化列表顺序造成误解。

9.6 析构函数为什么有时要写成虚函数? [S]

直接回答:

如果一个类作为多态基类,并且对象可能通过基类指针被删除,那么基类析构函数必须是 virtual,否则通过基类指针 delete 派生对象会产生未定义行为。

1
2
3
4
struct Driver {
virtual ~Driver() = default;
virtual void start() = 0;
};

不是所有析构函数都必须为虚函数。普通值类型和不会通过基类指针删除的类不需要承担虚函数机制的成本。C++ 默认析构不为 virtual,是“不使用就不付成本”的设计。

9.7 虚函数怎样实现多态? [A]

直接回答:

C++ 标准只规定行为,不强制具体实现。主流编译器通常在多态对象中保存一个隐藏的 vptr,指向该动态类型的虚函数表。通过基类指针调用虚函数时,会经表找到派生类最终覆盖的函数。

主要成本是对象中的 vptr、类的虚表和一次间接调用,还可能影响内联和最坏执行时间分析。具体占多少字节由 ABI 决定。

构造和析构期间的虚调用不会派发到尚未构造或已经析构的派生部分。

9.8 静态函数和虚函数有什么区别? [A]

类静态成员函数没有 this,不依赖具体对象,不能声明为 virtual;虚函数依赖对象的动态类型和 this 进行运行时派发。

文件作用域 static 函数表示内部链接,只在当前源文件可见,和类静态成员函数不是同一个概念。

9.9 重载、重写和隐藏 [S]

  • 重载:同一作用域中函数同名、参数列表不同,编译期选择;仅返回类型不同不能重载。
  • 重写:派生类提供与基类虚函数匹配的实现,实现运行时多态。
  • 隐藏:派生类的同名成员可能把基类全部同名重载隐藏。
1
2
3
4
5
6
7
struct Base {
virtual void run(int value) = 0;
};

struct Derived final : Base {
void run(int value) override;
};

使用 override 让编译器检查签名。

9.10 什么是 RAII? [S]

直接回答:

RAII 把资源生命周期绑定到对象生命周期:构造时获取资源,析构时自动释放。资源可以是内存、互斥锁、文件、总线句柄或临界区。它能让正常返回和异常路径都执行清理,减少泄漏和忘记解锁。

1
2
3
4
5
6
7
8
9
10
11
class LockGuard {
public:
explicit LockGuard(Mutex &mutex) : mutex_(mutex) { mutex_.lock(); }
~LockGuard() { mutex_.unlock(); }

LockGuard(const LockGuard &) = delete;
LockGuard &operator=(const LockGuard &) = delete;

private:
Mutex &mutex_;
};

9.11 智能指针有什么区别? [A]

  • std::unique_ptr:唯一所有权,优先使用,额外成本很小。
  • std::shared_ptr:共享引用计数,需要控制块,创建、复制和释放的成本更高。
  • std::weak_ptr:不拥有对象,用于观察 shared_ptr 对象并打破循环引用。

智能指针不能消除所有泄漏。shared_ptr 循环引用、容器永久持有对象或错误的自定义删除器仍会出问题。它解决的是所有权表达,不是万能内存管理。

9.12 new/deletemalloc/free 的区别 [S]

直接回答:

malloc 按字节申请原始内存,不调用构造函数,失败返回 NULL;new 申请内存并构造对象,失败默认抛异常,也可以使用 std::nothrowdelete 会先调用析构函数再释放,free 只释放内存。

必须正确配对:

  • malloc / free
  • new / delete
  • new[] / delete[]

混用会产生未定义行为。

9.13 placement new 是什么? [B]

placement new 在调用者提供且满足对齐要求的存储上构造对象,本身不申请内存。适合固定内存池,但必须手动管理对象生命周期并显式调用析构函数。

1
2
3
4
5
6
#include <cstddef>
#include <new>

alignas(Device) std::byte storage[sizeof(Device)];
Device *device = new (storage) Device();
device->~Device();

9.14 C++ 如何处理函数返回对象? [A]

直接回答:

概念上函数按值返回一个对象。实现可能使用调用者提供的隐藏返回存储,也可能调用复制或移动构造。现代 C++ 会进行返回值优化;C++17 对部分临时对象返回保证复制消除,命名局部对象的 NRVO 仍属于允许优化。

1
2
3
4
5
Buffer make_buffer()
{
Buffer result(128);
return result; /* 通常执行 NRVO,不要写 std::move(result) */
}

显式 std::move(result) 可能阻止 NRVO,不应为了“优化”随意添加。

9.15 类静态成员怎样定义? [A]

类静态数据成员由所有对象共享,不占每个普通对象的成员空间。传统写法通常在类外提供一个定义;C++17 可使用 inline static 在类内定义并初始化,constexpr 还有相应规则。因此“静态成员绝对不能在类内初始化”已经过时。

1
2
3
4
struct Config {
inline static std::uint32_t instances = 0; /* C++17 */
static constexpr std::uint32_t rate = 16000;
};

9.16 C 和 C++ 接口怎样兼容? [A]

C++ 会进行名字修饰。供 C 和 C++ 共同使用的 C 接口头文件应加语言链接保护:

1
2
3
4
5
6
7
8
9
#ifdef __cplusplus
extern "C" {
#endif

int device_init(void);

#ifdef __cplusplus
}
#endif

extern "C" 只改变语言链接,不会把任意 C++ 类或异常接口变成 C 接口。

9.17 const 对象和 const 成员函数 [A]

const 对象只能调用 const 成员函数。成员函数末尾的 const 表示该函数不能通过 this 修改普通数据成员,也不能调用非 const 成员函数;mutable 成员除外。

1
2
3
4
5
6
7
class Sensor {
public:
int value() const { return value_; }

private:
int value_{};
};

const 成员函数和非 const 成员函数可以构成重载。这里的 const 约束对象接口,不等于对象所在内存一定只读,也不自动保证线程安全。


10. 操作系统、进程和线程

10.1 进程和线程的区别 [S]

直接回答:

进程是资源分配和隔离的基本单位,通常拥有独立虚拟地址空间、文件描述符表等资源;线程是 CPU 调度的执行单元,同一进程内的线程共享地址空间和大部分进程资源,但各自有栈、寄存器上下文和调度状态。

进程隔离强、创建和通信成本通常更高;线程通信方便、切换通常更轻,但共享内存容易产生数据竞争。具体调度和资源实现由操作系统决定。

10.2 进程通信方式有哪些? [A]

  • 管道和命名管道:字节流,接口简单。
  • 消息队列:保留消息边界,可带优先级。
  • 共享内存或 mmap:适合大数据,速度快,但同步要自己设计。
  • Unix/TCP/UDP socket:可本机或跨主机通信。
  • 信号:适合简单通知,不适合传复杂数据。
  • eventfd 等 Linux 机制:适合事件计数和唤醒。

线程共享同一地址空间,常通过共享变量配合互斥锁、读写锁、条件变量、信号量、原子变量、队列进行通信和同步。

10.3 mmap 怎样实现进程通信? [A]

直接回答:

两个进程把同一个共享内存对象或文件以 MAP_SHARED 映射到各自虚拟地址空间。虽然虚拟地址可以不同,但页表最终指向同一组物理页或页缓存,所以一方写入后另一方能够读到。

典型过程:

  1. 使用 shm_open 或文件创建共享对象。
  2. ftruncate 设置大小。
  3. 双方用 mmap(..., MAP_SHARED, ...) 映射。
  4. 使用进程共享 mutex、信号量或事件机制同步读写。
  5. 结束后 munmapclose,最后由约定方 shm_unlink

mmap 只提供共享存储,不提供消息边界和互斥;MAP_PRIVATE 是写时复制,不适合共享修改。

10.4 forkwaitexec [B]

fork 创建子进程,父进程得到子 PID,子进程得到 0;Linux 通常使用写时复制。exec 不创建新进程,而是用新程序替换当前进程映像,成功时不会返回。wait/waitpid 获取子进程退出状态并回收资源,避免僵尸进程。

1
2
3
4
5
6
7
8
pid_t pid = fork();
if (pid == 0) {
execlp("echo", "echo", "child", (char *)NULL);
_exit(127); /* 只有 exec 失败才执行 */
} else if (pid > 0) {
int status;
(void)waitpid(pid, &status, 0);
}

10.5 Linux 信号怎样处理? [B]

优先使用 sigaction。信号处理函数中只能调用 async-signal-safe 函数,不能随意 printf、malloc 或加普通互斥锁。简单退出标志使用 volatile sig_atomic_t

1
2
3
4
5
6
7
8
9
#include <signal.h>

static volatile sig_atomic_t stop_requested;

static void handle_signal(int signal_number)
{
(void)signal_number;
stop_requested = 1;
}

10.6 selectpollepoll 的区别 [B]

接口 特点 主要限制
select 使用 fd 位图,兼容性好 fd 数量受限制;每次重建并线性扫描
poll 使用pollfd 数组 每次传入全部数组,通常线性扫描
epoll Linux 内核维护关注集合,只返回就绪事件 Linux 专用;必须正确处理 LT/ET 和 fd 生命周期

少量 fd 时不必强调性能差异;大量连接且活跃事件较少时,epoll 通常更合适。边沿触发 ET 一般要配合非阻塞 I/O,并一直处理到 EAGAIN


11. 多线程、中断和同步

11.1 多线程会出现哪些问题? [S]

直接回答:

主要有数据竞争和丢失更新、死锁、活锁、饥饿、优先级反转、可见性和生命周期问题。解决方式不是统一“加锁”,而是根据共享数据和执行上下文选择互斥锁、信号量、队列、原子变量或临界区。

普通 C/C++ 对象发生数据竞争属于未定义行为。

11.2 volatile 为什么不能保证线程安全? [S]

volatile 只约束编译器对单次访问的优化,不让“读、改、写”变成原子操作,也不建立线程间同步顺序。

1
2
3
4
5
6
static volatile unsigned count;

void increment(void)
{
++count; /* 两个任务并发执行可能丢失更新 */
}

简单计数可使用原子操作;多个字段必须保持一致时使用互斥锁或临界区。

11.3 互斥锁、信号量和队列怎样选? [S]

互斥锁: 保护共享资源和不变量,有明确所有者,通常由加锁者解锁。RTOS mutex 常支持优先级继承。

例:两个任务共享 I2C 总线,一次“写寄存器地址 + 读数据”必须作为完整事务执行,用 mutex 保护整个事务。

二值信号量或任务通知: 表示事件发生,没有所有者。

例:采样 ISR 完成后通知处理任务。ISR give,任务 take;不能用 mutex 代替这种中断通知。

计数信号量: 表示多个同类资源的数量。

例:系统有 4 个 DMA 缓冲块,取一个时计数减一,归还时加一。

队列: 不仅通知事件,还传输数据或所有权,适合任务间解耦。

11.4 为什么 ISR 要使用 FromISR API? [S]

普通任务 API 可能阻塞,并操作只适用于任务上下文的调度数据。ISR 不能睡眠,也没有可以阻塞的任务上下文。FromISR 版本保证中断可调用,并通过类似 higher_priority_task_woken 的结果告诉系统退出中断时是否需要切换任务。

11.5 临界区怎样实现?为什么不能太长? [S]

单核 MCU 常通过关闭或屏蔽一定优先级的中断保护极短代码。多核系统还需要自旋锁或原子机制,因为关闭本核中断挡不住另一个核。

临界区太长会增加中断延迟和实时抖动,可能丢外设数据。临界区内不要打印、阻塞或运行复杂算法,只更新少量共享状态。

11.6 单线程会出现数据不一致吗? [S]

会。没有多个普通线程,不代表没有 ISR、DMA、信号或硬件并发。

  • ISR 可能在 CPU 读取多字节变量中途修改它,产生撕裂读取。
  • DMA 写缓冲区时 CPU 同时解析,可能读到一半新、一半旧的数据。
  • 函数失败时只更新结构体一部分,也会破坏逻辑一致性。

对应方法包括临界区、双缓冲和所有权状态,以及“临时对象构造完成后再提交”。

11.7 中断处理过程 [S]

直接回答:

  1. 外设产生中断请求。
  2. 中断控制器根据使能、屏蔽和优先级仲裁。
  3. CPU 保存架构规定的现场,切换到中断入口。
  4. ISR 判断中断源并按芯片要求清除标志。
  5. 快速读取必要数据或记录事件,通知任务继续处理。
  6. 恢复现场并退出;如果高优先级任务已就绪,RTOS 可能进行任务切换。

具体保存哪些寄存器、能否嵌套、怎样清标志必须看处理器架构和芯片手册。ISR 中不做耗时算法、不阻塞,通常也不 printf。

11.8 死锁怎么产生,怎样避免? [A]

最典型是任务 A 持有锁 1 等锁 2,任务 B 持有锁 2 等锁 1,形成循环等待。

避免方法:规定全局加锁顺序、缩小锁范围、避免持锁阻塞、使用超时和错误恢复、能通过队列转移所有权时减少共享状态。

11.9 优先级反转是什么? [A]

低优先级任务持有锁,高优先级任务等待该锁;中优先级任务又不断抢占低优先级任务,导致高优先级任务被间接长期阻塞。

优先级继承让持锁的低优先级任务临时提升优先级,尽快释放锁。优先级天花板在获取资源时把任务提升到预设上限,便于做更严格的阻塞上界分析。它们不能解决死锁和超长临界区。


12. FreeRTOS、实时系统与 uC/OS-II

12.1 什么是实时系统?和普通 Linux 有什么区别? [S]

直接回答:

实时系统关注的不只是平均速度,而是任务能否在规定截止时间内完成。硬实时系统一旦错过截止时间就可能造成系统失效;软实时系统偶尔超时会降低体验,但不一定失败。

普通 Linux 主要追求吞吐、公平性和通用功能,最坏调度延迟不容易严格保证;RTOS 功能更精简,调度和中断延迟更可控。使用 PREEMPT_RT 等实时补丁的 Linux 可以提高确定性,但系统设计和最坏情况验证仍然必要。

12.2 实时系统怎样保证强实时性? [A]

直接回答:

靠的是整个系统的最坏情况设计,而不只是“任务优先级高”:

  • 使用确定的优先级调度,并做最坏执行时间和可调度性分析。
  • 中断和临界区保持短小,限制优先级反转。
  • 避免不可预测的动态分配、缺页、阻塞 I/O 和无界循环。
  • 预先分配资源,限制队列和递归深度。
  • 使用硬件定时器、看门狗和超时恢复。
  • 测量最坏中断延迟、调度延迟和任务完成时间。

强实时不是 RTOS 自动提供的属性,而是架构、代码、硬件和验证共同保证的。

12.3 FreeRTOS 怎样调度任务? [S]

直接回答:

调度器从就绪任务中选择最高优先级任务运行。抢占开启时,更高优先级任务一旦就绪就可以抢占当前任务;同优先级任务在启用时间片时可随 tick 轮转,也会因阻塞或主动 yield 切换。

configUSE_TIME_SLICING 控制同优先级就绪任务是否在 tick 时轮转,不会让低优先级任务自动得到时间片。

12.4 任务切换保存什么?保存在哪里? [A]

CPU 和 RTOS 端口共同保存任务继续运行所需的寄存器、程序状态、栈指针,以及必要时的浮点上下文。上下文通常保存在当前任务栈中,任务的栈指针等管理信息保存在 TCB。精确寄存器集合由处理器架构和 FreeRTOS 端口决定。

12.5 PendSV 是做什么的?为什么最低优先级? [S]

直接回答:

在 Cortex-M 上,FreeRTOS 通常用 PendSV 完成上下文切换。PendSV 可以由软件挂起并设为最低异常优先级,让紧急硬件中断先处理完,再在没有更高优先级中断时统一切换任务。这样避免在高优先级 ISR 中执行完整调度和上下文保存。

12.6 任务栈大小怎样确定? [A]

根据最坏调用链、局部变量、库函数栈占用、中断嵌套和编译配置估算,再通过栈水位、栈溢出检测和压力测试验证。设小会覆盖相邻内存、触发异常或产生随机故障;设太大会浪费 RAM。

FreeRTOS 不同 API/端口的栈深单位可能是 StackType_t 数量而不是字节,必须查当前版本文档。

12.7 空闲任务有什么作用? [A]

空闲任务保证没有其他任务就绪时仍有任务可运行,常负责回收已删除任务的资源,并提供 idle hook 或低功耗入口。它优先级最低,不能被长时间阻塞,否则资源回收和空闲处理可能无法进行。

12.8 软件定时器和硬件定时器 [A]

硬件定时器由外设计数并产生中断,精度和抖动更好,但回调受 ISR 限制。FreeRTOS 软件定时器由 timer service task 执行,回调处于任务上下文,但多个定时器共享该任务,回调仍不能长时间阻塞或做耗时工作。

12.9 uC/OS-II 怎样调度任务? [A]

直接回答:

uC/OS-II 是基于优先级的抢占式实时内核,经典版本通常要求每个任务具有唯一优先级。内核维护任务就绪表,调度时通过位图快速找到最高优先级就绪任务。更高优先级任务一旦就绪,在允许调度时会抢占当前任务。

任务因等待信号量、邮箱、消息队列或延时而阻塞;事件到达或延时结束后重新进入就绪态。中断退出时,如果唤醒了更高优先级任务,内核进行一次中断级任务切换。

回答具体数据结构时要说明 uC/OS-II 版本和移植,因为 OSRdyGrpOSRdyTbl 等细节可能随配置和版本变化。


13. 环形缓冲区与高频手写代码

13.1 环形缓冲区怎样判空和判满? [S]

三种常见方案:

  1. 留一个空槽:head == tail 为空,next(head) == tail 为满,容量损失一个元素。
  2. 增加 count:count 为 0 表示空,等于容量表示满,可以使用全部槽。
  3. 增加 full 标志:head 等于 tail 时由 full 区分空和满。

面试手写先选择一种并主动说明,不要边写边换方案。

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
33
34
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define QUEUE_CAPACITY 16U

struct byte_queue {
uint8_t data[QUEUE_CAPACITY];
size_t head;
size_t tail;
size_t count;
};

bool queue_push(struct byte_queue *q, uint8_t value)
{
if (q == NULL || q->count == QUEUE_CAPACITY) {
return false;
}
q->data[q->head] = value;
q->head = (q->head + 1U) % QUEUE_CAPACITY;
++q->count;
return true;
}

bool queue_pop(struct byte_queue *q, uint8_t *out)
{
if (q == NULL || out == NULL || q->count == 0U) {
return false;
}
*out = q->data[q->tail];
q->tail = (q->tail + 1U) % QUEUE_CAPACITY;
--q->count;
return true;
}

13.2 ISR 写、任务读的环形缓冲区安全吗? [S]

不能看到一份普通代码就直接说安全。至少要确认:单生产者单消费者、各自只修改自己的索引、索引访问在目标平台原子、数据先写完再发布 head、内存顺序正确、满时策略明确。

volatile 不能自动提供多核发布/获取语义。ESP32 多核场景优先使用 FreeRTOS queue/stream buffer,或用平台原子和临界区证明正确性。ISR 不能等待普通 mutex。

13.3 实现 strlen [S]

1
2
3
4
5
6
7
8
size_t my_strlen(const char *text)
{
const char *p = text;
while (*p != '\0') {
++p;
}
return (size_t)(p - text);
}

标准 strlen 不接受 NULL。题目若要求 NULL 安全,要先明确自定义接口语义。

13.4 实现 memmove [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void *my_memmove(void *dst, const void *src, size_t length)
{
unsigned char *d = dst;
const unsigned char *s = src;

if (d == s || length == 0U) {
return dst;
}

if (d < s) {
for (size_t i = 0; i < length; ++i) {
d[i] = s[i];
}
} else {
for (size_t i = length; i > 0U; --i) {
d[i - 1U] = s[i - 1U];
}
}
return dst;
}

面试重点是:目标在源后方且区域重叠时从后往前复制,否则从前往后。严格标准对无关对象指针的关系比较有限制;标准库实现可以依赖目标平台规则。普通业务代码应直接使用库函数。

13.5 无溢出二分查找 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
bool binary_search(const int *array, size_t length,
int target, size_t *index)
{
size_t left = 0U;
size_t right = length; /* [left, right) */

while (left < right) {
size_t middle = left + (right - left) / 2U;
if (array[middle] < target) {
left = middle + 1U;
} else {
right = middle;
}
}

if (left < length && array[left] == target) {
if (index != NULL) {
*index = left;
}
return true;
}
return false;
}

13.6 反转单链表 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct node {
int value;
struct node *next;
};

struct node *reverse_list(struct node *head)
{
struct node *previous = NULL;
while (head != NULL) {
struct node *next = head->next;
head->next = previous;
previous = head;
head = next;
}
return previous;
}

13.7 常用位操作 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
uint32_t set_bit(uint32_t value, unsigned bit)
{
return value | (UINT32_C(1) << bit);
}

uint32_t clear_bit(uint32_t value, unsigned bit)
{
return value & ~(UINT32_C(1) << bit);
}

uint32_t toggle_bit(uint32_t value, unsigned bit)
{
return value ^ (UINT32_C(1) << bit);
}

bool test_bit(uint32_t value, unsigned bit)
{
return (value & (UINT32_C(1) << bit)) != 0U;
}

调用前保证 bit 小于类型位宽。硬件寄存器可能是写 1 清零、只写或包含保留位,不能对所有寄存器机械做读改写。

13.8 统计 1 的数量 [A]

1
2
3
4
5
6
7
8
9
unsigned popcount32(uint32_t value)
{
unsigned count = 0U;
while (value != 0U) {
value &= value - 1U;
++count;
}
return count;
}

每轮清除最低位的一个 1,时间复杂度与 1 的数量有关。

13.9 有界字符串复制 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool string_copy(char *dst, size_t capacity, const char *src)
{
if (dst == NULL || src == NULL || capacity == 0U) {
return false;
}

size_t i = 0U;
while (i + 1U < capacity && src[i] != '\0') {
dst[i] = src[i];
++i;
}
dst[i] = '\0';
return src[i] == '\0'; /* true 表示没有截断 */
}

14. 面试高频纠错

以下说法都不完整或错误:

错误说法 正确表述
volatile 保证线程安全 只约束编译器访问,不保证原子性和线程同步
const 变量都放只读区 const 不决定存储位置,要看存储期、优化和链接脚本
static 变量都在.bss 零初始化常在.bss,非零初始化常在 .data,const 还可能在 .rodata
C 参数从右向左压栈 参数传递和栈布局由 ABI 决定,很多架构先用寄存器
sizeof(void) == 1 标准 C 中非法,GCC 的结果属于扩展
C 不能写二进制常量 C23 已支持,旧标准可能依赖编译器扩展
位域最适合解析协议 位域布局由实现决定,协议应显式掩码和移位
所有析构函数都必须 virtual 只有用于多态删除的基类需要虚析构
类静态成员不能类内初始化 C++17 有 inline static,constexpr 也有对应规则
free 后置 NULL 就没有悬空指针 其他别名仍然悬空
strncpy 一定安全 可能不补 NUL,还会填充,必须理解容量语义
malloc 成功就一定有物理内存 Linux overcommit 下可能到触页时才失败
单线程没有数据一致性问题 ISR、DMA、信号和失败半更新都可能造成不一致
epoll 永远比 select 快 取决于 fd 数量、活跃比例和实现方式
RTOS 自动保证强实时 还需要 WCET、调度、临界区和最坏延迟分析

15. 与你的 ESP32-P4 项目怎样结合

只讲已经做过的事实,不编性能数据:

  • 中断与任务: 从 I2S、I2C、BLE 或显示驱动的回调上下文讲“快速处理还是通知任务”,但先确认具体回调是否真的运行在 ISR。
  • 音频缓冲: 用 PCM 帧、I2S 和 OPUS 集成链路解释生产者消费者、固定缓冲和数据所有权。没有延迟测试记录就说明测试方案,不报虚构数字。
  • BLE 路线协议: 用 BEGIN/DATA/COMMIT、offset 和 CRC32 解释分包、状态机和逐字段序列化。
  • 传感器融合: 用 IMU、磁力计和 Mahony 数据流解释采样、状态对象、浮点误差与任务周期。
  • PCB 和驱动: 用自绘 PCB 解释如何把原理图、芯片引脚、I2C/I2S 配置和逻辑分析仪波形对应起来。
  • 第三方边界: OPUS、LVGL 和 ESP-IDF 是集成使用;只把自己实现的胶水层、状态机、驱动适配和业务逻辑说成自己的工作。

项目专项材料:ESP32-P4 骑行终端项目深挖面试准备


16. 七天复习计划

天数 内容 当天必须完成
1 类型、运算符、sizeof 口述 15 题,做 10 道看代码题
2 volatile、static、const、内存 画内存分区,手写动态内存检查
3 指针、数组、结构体、宏 手算结构体,手写回调和二维数组接口
4 C++ 基础 讲清 RAII、虚析构、虚表和特殊成员
5 线程、中断、FreeRTOS 画 ISR 到任务数据流,解释 PendSV
6 手写代码 环形队列、链表、二分、位操作、字符串
7 项目模拟面试 一分钟介绍、两分钟项目、一个真实故障复盘

算法路线:嵌入式算法刷题路线。刷题时先定义接口和边界,再写代码,最后检查空输入、最大值和复杂度。


17. 技术资料

面经用于收集题目,不用于证明答案。遇到标准、ABI、芯片和 RTOS 细节时优先查:

  • C/C++ 标准资料与 cppreference;
  • GCC、Clang 文档和编译器告警;
  • SEI CERT C Coding Standard;
  • C++ Core Guidelines;
  • FreeRTOS 官方文档和对应架构端口源码;
  • ESP-IDF 编程指南、ESP32-P4 技术参考手册和芯片数据手册;
  • Linux man-pages。

面试中遇到“总是、一定、固定大小、固定顺序”时,先判断它究竟是语言标准、ABI、编译器扩展、芯片规则,还是当前项目约定。

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
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
# C 语言与嵌入式基础完整面试手册

> 适用岗位:嵌入式软件、MCU、RTOS、驱动、应用方案、信号处理算法移植。
>
> 使用方式:先背每题的“30 秒回答”,再理解代码和边界。面试时不要一次倾倒所有知识;先给结论,等追问再展开。
>
> 本文依据你提供的目录重新编写。目录只作为知识点索引,其中不准确、过时或依赖平台的说法已在正文和“高频纠错”中修正。

---

## 0. 学习优先级与目录覆盖

### 0.1 优先级

| 级别 | 面试前掌握程度 | 内容 |
|---|---|---|
| S | 能脱稿回答并手写代码 | 指针、数组、`volatile/static/const`、内存分区、结构体对齐、宏、编译链接、中断并发 |
| A | 能解释原理和主要边界 | 动态内存、函数指针、整数转换、union、大小端、C++ 对象模型、RTOS 同步 |
| B | 知道用途,能回答典型追问 | 变参、文件 API、信号、`fork/exec`、`select/poll/epoll` |

### 0.2 你给出的目录在本文中的位置

| 原目录主题 | 本文位置 |
|---|---|
| 运算符、优先级、短路、前后置 `++`、左值右值 | 第 2 章 |
| `continue/break/return/goto/switch/do while/sizeof` | 第 3 章 |
| `volatile/const/static/extern/register/auto` | 第 4 章 |
| `struct/union/enum/typedef`、对齐、大小端、位域 | 第 5 章 |
| 内存区域、栈堆、泄漏、`malloc/calloc/realloc` | 第 6 章 |
| 指针、数组、二维数组、函数指针、引用、智能指针 | 第 7 章 |
| 宏、头文件、`#error`、`#`、`##`、`offsetof`、inline | 第 8 章 |
| 函数、变参、可重入、回调 | 第 9 章 |
| `printf/scanf`、字符和文件定位函数 | 第 10 章 |
| C++ 类、构造析构、虚函数、重载重写、RAII | 第 11 章 |
| `fork/wait/exec`、信号、`select/poll/epoll` | 第 12 章 |
| 中断、线程安全、RTOS、环形缓冲区 | 第 13 章 |
| 高频手撕和错误说法 | 第 14、15 章 |

---

## 1. 类型、表示与转换

### 1.1 C 的基本类型大小固定吗? `[S]`

**30 秒回答:**

C 标准规定的是最小取值范围和类型之间的大小关系,并不保证 `int` 一定为 32 位、`long` 一定为 64 位。协议字段、寄存器和持久化格式应优先用 `<stdint.h>` 的定宽类型;对象大小和数组长度使用 `size_t`。

```c
#include <stdint.h>
#include <stddef.h>

uint8_t status;
int16_t pcm_sample;
uint32_t timestamp_ms;
size_t buffer_length;

必须记住:

  • sizeof(char) == 1 永远成立,但一个字节不一定严格等于 8 位,位数看 CHAR_BIT
  • charsigned charunsigned char 是三个不同类型;普通 char 的符号性由实现决定;
  • uint32_t 只有在平台存在恰好 32 位无填充的无符号整数类型时才会定义;
  • 打印定宽整数优先使用 <inttypes.h> 中的 PRIu32 等宏。
1
2
3
4
5
6
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

uint32_t value = UINT32_C(123);
printf("value=%" PRIu32 "\n", value);

1.2 整数提升和通常算术转换 [S]

charshort 和位域参与运算时,通常先提升为 intunsigned int。不同整数类型混算时,再按规则转换成共同类型。有符号与无符号混用尤其危险。

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

int main(void)
{
int error = -1;
unsigned int count = 1U;

/* error 可能转成一个很大的 unsigned int,因此条件通常为假。 */
printf("%s\n", error < count ? "less" : "not less");
return 0;
}

工程做法:

  • 长度比较前先确保值域和类型一致;
  • 不要用无符号类型表达“可能为 -1 的错误码”;
  • 窄化前检查范围;
  • 移位使用无符号类型,如 UINT32_C(1) << bit

1.3 有符号和无符号溢出 [S]

无符号整数按模 2^N 回绕,这是语言定义的行为。有符号整数溢出是未定义行为,编译器可假定它不发生,不能依靠“补码自然回绕”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <limits.h>
#include <stdbool.h>

bool add_int_checked(int a, int b, int *out)
{
if (out == NULL) {
return false;
}
if ((b > 0 && a > INT_MAX - b) ||
(b < 0 && a < INT_MIN - b)) {
return false;
}
*out = a + b;
return true;
}

1.4 浮点数为什么不宜直接比较相等? [A]

二进制浮点无法精确表示很多十进制小数,计算中还会舍入。比较测量值通常使用绝对误差与相对误差结合。

1
2
3
4
5
6
7
8
9
#include <math.h>
#include <stdbool.h>

bool nearly_equal(float a, float b, float abs_tol, float rel_tol)
{
float diff = fabsf(a - b);
float scale = fmaxf(fabsf(a), fabsf(b));
return diff <= fmaxf(abs_tol, rel_tol * scale);
}

这不是说浮点永远不能用 ==。判断自己刚赋入的哨兵值、检查 x == 0.0f 的特定算法条件可能合理,要结合语义。

1.5 整数常量有哪些进制? [A]

  • 十进制:42
  • 八进制:052
  • 十六进制:0x2A
  • 二进制:C23 标准加入 0b101010;GCC/Clang 很早就支持扩展,但旧 C 标准不保证。

因此“C 语言不能用二进制表示整常数”只对旧标准的可移植代码成立,面试时要说明标准版本和工具链。


2. 运算符、表达式与求值顺序

2.1 高频优先级表 [S]

无需机械背完整表,但下面几层必须熟悉,从高到低:

层级 运算符 结合性
后缀 () [] . -> x++ x-- 左到右
一元 ! ~ ++x --x + - * & sizeof 右到左
乘除 * / % 左到右
加减 + - 左到右
移位 << >> 左到右
关系 < <= > >= 左到右
相等 == != 左到右
按位与异或或 &^、` `
逻辑与或 &&、`
条件 ?: 右到左
赋值 = += -= ... 右到左
逗号 , 左到右

最常见陷阱:

1
2
3
4
5
6
7
8
if (flags & MASK == 0U) { }       /* 错:解析成 flags & (MASK == 0U) */
if ((flags & MASK) == 0U) { } /* 对 */

*p++; /* 等价于 *(p++),移动指针 */
(*p)++; /* 增加所指对象 */

if (a = b) { } /* 赋值,通常是笔误 */
if (a == b) { } /* 比较 */

工程原则是:不确定就加括号,位操作和宏尤其如此。

2.2 短路求值 [S]

&& 左侧为假时不求右侧;|| 左侧为真时不求右侧。求值顺序由语言保证,可用于保护后续访问。

1
2
3
if (buffer != NULL && index < length && buffer[index] == target) {
/* 只有前面的条件满足,才读取 buffer[index]。 */
}

不要把有重要副作用的代码藏在复杂逻辑表达式中,可读性和可测试性都会变差。

2.3 ++aa++ [S]

两者最终都把 a 加一;表达式的值不同:前置返回修改后的值,后置产生修改前的值。对内建整数,结果未被使用时优化后通常没有性能差异。

1
2
3
int a = 3;
int x = ++a; /* a=4, x=4 */
int y = a++; /* y=4, a=5 */

不要写依赖多个未排序副作用的表达式:

1
2
/* C 中行为未定义,不要猜输出。 */
int result = i++ + ++i;

函数实参的求值顺序也不要凭“从右向左压栈”推断。参数如何传递和压栈属于 ABI,C 语言本身不保证这种顺序。

2.4 左值和右值 [A]

简化回答:左值表示一个有身份、可定位的对象;可修改左值还能出现在赋值号左边。表达式值如 a + b 通常不是可修改左值。

1
2
3
4
int x = 1;       /* x 是可修改左值 */
const int c = 2; /* c 是左值,但不是可修改左值 */
int *p = &x;
*p = 3; /* *p 是可修改左值 */

C++ 进一步区分 lvalue、prvalue、xvalue,通常在移动语义问题里再展开。

2.5 指针和移位运算边界 [A]

  • 两指针相减只在同一数组对象(或尾后位置)内有定义,结果类型是 ptrdiff_t
  • 指针可以加减整数,不能把两个指针相加;
  • 数组允许形成尾后指针,但不能解引用;
  • 左移位数不能为负或大于等于类型位宽;
  • 右移负有符号数的结果由实现定义,协议位操作使用无符号数。
1
2
3
4
5
6
#include <stdint.h>

uint32_t field_get(uint32_t reg, unsigned shift, uint32_t mask)
{
return (reg >> shift) & mask;
}

3. 控制流与常用关键字

3.1 continuebreakreturn [S]

  • continue:结束本轮循环,进入下一轮;在 for 中先执行迭代表达式,在 while/do while 中回到条件判断。
  • break:退出最内层循环或 switch,不会自动退出多层循环。
  • return:结束当前函数,可返回一个与声明兼容的值;返回局部自动变量的地址是错误的。
1
2
3
4
5
6
7
8
9
10
11
12
13
int find_first_positive(const int *a, size_t n)
{
if (a == NULL) {
return -1;
}
for (size_t i = 0; i < n; ++i) {
if (a[i] <= 0) {
continue;
}
return (int)i;
}
return -1;
}

3.2 goto 一定不能用吗? [A]

任意跳转会破坏结构化控制流,但 C 中的单出口资源清理是合理用法,Linux 内核和许多驱动都采用这种模式。规则是只向后跳到清理标签,不跨越复杂初始化,不用它实现普通循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int device_open(struct device *dev)
{
int rc = clock_enable(dev);
if (rc != 0) {
return rc;
}

rc = bus_init(dev);
if (rc != 0) {
goto disable_clock;
}

rc = sensor_probe(dev);
if (rc != 0) {
goto deinit_bus;
}
return 0;

deinit_bus:
bus_deinit(dev);
disable_clock:
clock_disable(dev);
return rc;
}

3.3 switch case 的规则 [S]

  • 控制表达式是整数或枚举类型;
  • case 标签必须是整型常量表达式且不能重复;
  • 没有 break 会继续落入后续分支;有意贯穿应写注释或用工具链认可的标记;
  • 定义局部变量时最好用花括号形成清晰作用域;
  • 建议保留 default 处理非法状态;对要求编译器检查穷举的枚举,有时会有意识省略 default,需团队约定。
1
2
3
4
5
6
7
8
9
10
11
switch (state) {
case STATE_IDLE:
start_transfer();
break;
case STATE_RX:
handle_rx();
break;
default:
enter_safe_state();
break;
}

3.4 do whilewhile [A]

do while 至少执行一次,while 可能一次也不执行。多语句宏常用 do { ... } while (0),使宏在语法上像一条语句。

3.5 sizeof 的本质和陷阱 [S]

sizeof 是运算符,结果类型为 size_t,给出对象表示占用的字节数。除变长数组 VLA 外,操作数通常不求值。

1
2
3
4
5
6
int x = 1;
size_t n = sizeof(x++); /* 非 VLA 场景下 x 仍是 1 */

char s[] = "\0";
/* s 中有显式 '\0' 和字符串自动添加的结尾 '\0'。 */
/* sizeof s == 2,strlen(s) == 0。 */

sizeofstrlen

项目 sizeof strlen
性质 运算符 库函数
结果 对象/类型的字节数 首个\0 前的字符数
是否含字符串终止符 数组整体大小会包含 不包含
对指针 指针本身大小 遍历指向的字符串
越界风险 通常无运行时读取 若无\0 会越界读取
1
2
3
4
void f(char a[32])
{
/* 参数声明中的 a 实际调整为 char *,sizeof(a) 是指针大小。 */
}

标准 C 中 sizeof(void) 非法。GCC 可能把它当扩展并给出 1,面试时不能把扩展当标准。

所谓“不用 sizeof 求类型大小”的宏:

1
2
3
#define MY_SIZEOF_OBJECT(obj) \
((size_t)((const unsigned char *)(&(obj) + 1) - \
(const unsigned char *)&(obj)))

它只能用于对象表达式,内部仍依赖指针算术语义,实际工程没有替代 sizeof 的价值,不建议使用。


4. 限定符、存储期、作用域与链接

4.1 volatile 必须用在哪些场景? [S]

30 秒回答:

volatile 要求抽象机中需要发生的 volatile 访问不能被编译器任意删除或合并。典型场景是内存映射寄存器、被中断或信号处理函数异步修改的标志,以及工具链指定的特殊内存。它不保证原子性、互斥、线程间内存顺序,也不能自动解决 Cache/DMA 一致性。

1
2
3
4
5
6
7
8
9
10
11
#include <signal.h>
#include <stdint.h>

#define UART_STATUS (*(volatile uint32_t *)UINT32_C(0x40001000))

static volatile sig_atomic_t tick_pending;

void timer_isr(void)
{
tick_pending = 1;
}

为什么中断标志不加可能出错:编译器在普通控制流里看不到变量被修改,可能只读取一次并把值保存在寄存器中,导致主循环永远看不到 ISR 的写入。

counter++ 即使对象是 volatile,仍然是“读、加、写”三步,可能被打断。传递事件优先使用 RTOS 的 FromISR 通知/队列;共享复合状态使用临界区或平台原子操作。

4.2 static 的全部作用 [S]

位置 作用域 存储期 链接/效果
函数内局部变量 块作用域 静态存储期 无链接,只初始化一次,程序结束前一直存在
文件作用域变量 文件作用域 静态存储期 内部链接,仅当前翻译单元可见
文件作用域函数 文件作用域 不适用对象存储期 内部链接,限制模块可见性
C++ 类静态数据成员 类作用域 静态存储期 所有对象共享,不计入普通对象大小
C++ 静态成员函数 类作用域 函数 没有this,只能直接访问静态成员
1
2
3
4
5
6
7
8
static unsigned module_errors; /* 模块私有,全程序生命周期 */

static void record_error(void) /* 模块私有函数 */
{
static unsigned calls; /* 块作用域、静态存储期 */
++calls;
++module_errors;
}

局部 static 的共享状态可能使函数不可重入,也可能引发多任务竞态。C++11 起,函数局部静态对象的初始化由语言保证线程安全,但有些裸机工具链会关闭该支持;对象后续访问仍不自动线程安全。

4.3 const 的作用与“真的不能改吗” [S]

const 表示通过该受限左值不能修改对象,可表达只读接口、帮助类型检查和优化。它不是存储区名称,也不是硬件写保护。

1
2
3
4
int value = 10;
const int *view = &value;
value = 20; /* 合法:底层对象本来不是 const */
/* *view = 30; */ /* 非法:不能通过 view 修改 */

若对象本身定义为 const,再强转去 const 并写入,行为未定义:

1
2
3
const int fixed = 10;
int *bad = (int *)&fixed;
/* *bad = 20; 未定义行为,可能写只读区直接异常。 */

指针组合从变量名向外读:

1
2
3
4
const int *p1;              /* 可改指向,不能通过 p1 改目标 */
int const *p2; /* 与 p1 相同 */
int *const p3 = &value; /* 不可改指向,可以改目标 */
const int *const p4 = &value; /* 指向和目标访问都受限 */

4.4 const 变量存在哪里?和 static 一样吗? [S]

不一定一样。要把三个概念分开:

  1. const 是类型限定符,描述能否通过某表达式修改;
  2. static 影响存储期,文件作用域时还影响链接;
  3. .text/.rodata/.data/.bss/栈/堆 是具体实现和链接脚本的布局。

典型情况:

声明 存储期 常见位置(不是语言强制)
int local; 自动 栈或寄存器
const int local = 1; 自动 栈、寄存器,或被常量折叠
static int x; 静态 .bss
static int x = 1; 静态 .data,初值可能存 Flash
static const int x = 1; 静态 常见.rodata/Flash
int *p = malloc(n); 指向的块 动态 堆;指针变量本身另看声明位置

MCU 启动时通常把 .data 初值从 Flash 复制到 RAM,并清零 .bss.rodata 可能直接映射在 Flash,也可能因架构和访问要求搬到 RAM。最终以 map 文件和链接脚本为准。

4.5 extern 在哪个阶段起作用? [S]

extern 是语言关键字,编译器先据此完成声明和类型检查;引用的符号最终是否有唯一匹配定义,通常由链接器在链接阶段解决。不能简单说它“只在链接阶段起作用”。

1
2
3
4
5
/* counter.h */
extern unsigned g_counter; /* 声明 */

/* counter.c */
unsigned g_counter = 0U; /* 唯一定义 */

extern int x = 1; 带初始化器,仍是定义。

4.6 registerauto [B]

  • auto 在 C 中显式表示自动存储期,局部变量默认就是它,所以几乎不用;不要和现代 C++ 的类型推导 auto 混淆。
  • register 是旧式优化提示,现代编译器自行分配寄存器;C 中不能对 register 对象使用 &。C23 已移除该关键字,面试回答要说明它主要是历史知识。

4.7 局部变量能和全局变量重名吗? [A]

能。内层作用域的名字会遮蔽外层名字,但可读性差,编译器常提供 shadow warning。

1
2
3
4
5
6
7
int status;

void f(void)
{
int status = 1; /* 遮蔽全局 status */
(void)status;
}

5. structunionenumtypedef

5.1 结构体内存对齐怎么计算? [S]

30 秒回答:

每个成员从满足自身对齐要求的偏移开始,编译器可能在成员之间加入填充;结构体整体大小还要补齐为其最大对齐要求的整数倍,从而保证数组中每个元素都对齐。具体对齐受 ABI、编译器选项和成员属性影响,面试计算前要先确认平台规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stddef.h>
#include <stdint.h>

struct BadLayout {
uint8_t tag; /* offset 0 */
uint32_t value; /* 常见 offset 4 */
uint16_t code; /* 常见 offset 8 */
}; /* 常见 sizeof 12 */

struct BetterLayout {
uint32_t value; /* offset 0 */
uint16_t code; /* offset 4 */
uint8_t tag; /* offset 6 */
}; /* 常见 sizeof 8 */

_Static_assert(offsetof(struct BetterLayout, code) >= sizeof(uint32_t),
"unexpected layout");

重排字段可节省空间,但要考虑接口稳定性和可读性。不要直接把本机结构体 memcpy 上网或写 Flash:填充、大小端、类型大小和版本都可能变化,应逐字段序列化。

5.2 struct 在 C 和 C++ 中有什么差别? [A]

  • C 中声明后通常要写 struct Device d;,除非另做 typedef;C++ 可直接写 Device d;
  • C++ 的 struct 可有成员函数、构造析构、继承和访问控制;
  • C++ 中 struct 默认成员访问和默认继承是 publicclass 默认是 private;除此之外能力基本相同。
1
2
3
4
5
6
7
8
9
10
11
/* C */
typedef struct Device {
int state;
} Device;

/* C++ */
struct Point {
int x{};
int y{};
int norm2() const { return x * x + y * y; }
};

5.3 结构体和联合体区别 [S]

结构体的每个成员拥有各自存储,大小至少能容纳全部成员及填充。联合体的成员共享同一块存储,大小足以容纳最大成员并满足最严格对齐;同一时刻通常只应把它解释为当前有效成员。

1
2
3
4
union SampleValue {
int32_t raw;
float calibrated;
};

嵌入式常见用途:节省互斥状态的数据存储、描述硬件寄存器视图、实现带标签联合体。稳妥设计要额外保存标签:

1
2
3
4
5
6
7
8
9
enum ValueKind { VALUE_RAW, VALUE_FLOAT };

struct TaggedValue {
enum ValueKind kind;
union {
int32_t raw;
float calibrated;
} value;
};

5.4 用 union 判断大小端可靠吗? [A]

许多 C 编译器支持读取 union 的另一成员来观察对象表示,常见面试代码如下:

1
2
3
4
5
6
7
8
#include <stdbool.h>
#include <stdint.h>

bool is_little_endian(void)
{
const uint16_t x = UINT16_C(0x0102);
return *(const unsigned char *)&x == UINT8_C(0x02);
}

通过 unsigned char * 检查对象表示更直接。协议代码不要依赖主机端序,应显式移位编码:

1
2
3
4
5
6
7
8
9
10
uint16_t read_be16(const uint8_t p[2])
{
return (uint16_t)(((uint16_t)p[0] << 8) | p[1]);
}

void write_be16(uint8_t p[2], uint16_t v)
{
p[0] = (uint8_t)(v >> 8);
p[1] = (uint8_t)v;
}

5.5 enum 的用途和大小 [A]

枚举为一组相关整数常量建立命名,适合状态机。C 中底层兼容整数类型和大小由实现选择,不能默认总是 4 字节;协议和 Flash 格式应使用固定宽度字段,在边界处转换。

1
2
3
4
5
6
enum LinkState {
LINK_DOWN,
LINK_CONNECTING,
LINK_UP,
LINK_ERROR
};

enum 相比宏有语言级名字和调试信息。C++ 的 enum class 作用域和类型安全更强。

5.6 typedef#define 区别 [S]

typedef 由编译器处理,为已有类型创建别名,有作用域并接受类型语法检查;宏是预处理文本替换。

1
2
3
4
5
typedef char *char_ptr;
#define CHAR_PTR char *

char_ptr a, b; /* a、b 都是 char * */
CHAR_PTR c, d; /* 展开为 char *c, d;d 是 char */

函数指针别名:

1
typedef void (*event_callback_t)(int event, void *context);

注意 typedef 不会创建与原类型完全不同的新类型;typedef unsigned int counter_t;unsigned int 仍兼容。

5.7 位域是什么,协议解析为什么要谨慎? [A]

位域允许用成员语法访问若干位,适合编译器/ABI 已固定的寄存器描述。但位域分配顺序、是否跨存储单元、基础类型接受范围、填充和对齐都可能由实现决定。因此可移植网络协议和磁盘格式不应直接映射位域。

1
2
3
4
5
6
7
8
9
10
11
12
/* 可移植协议解析:显式掩码和移位。 */
enum {
FLAG_ACK = 1U << 0,
FLAG_ERR = 1U << 1,
MODE_SHIFT = 2,
MODE_MASK = 0x7U
};

unsigned mode_from_header(uint8_t header)
{
return (header >> MODE_SHIFT) & MODE_MASK;
}

若芯片厂商头文件使用位域,要依赖其指定编译器与 ABI,并核对生成汇编或寄存器访问结果。


6. 内存模型与动态内存

6.1 不同变量通常存在哪里? [S]

对象 典型区域 初始化/生命周期
函数代码 .text,常在 Flash 程序映像
字符串字面量、只读常量 .rodata,常在 Flash 程序全程
已初始化可写全局/static .data,运行时在 RAM 启动复制初值,程序全程
零初始化或未显式初始化全局/static .bss,RAM 启动清零,程序全程
普通局部变量、返回地址、部分上下文 栈或寄存器 进入函数到离开作用域/调用
malloc/new 获得的对象 分配到释放

这是典型实现,不是 C 标准强制。优化器可能让局部变量只存在寄存器中,甚至完全消除;嵌入式最终看链接脚本、map 文件和反汇编。

6.2 栈和堆的区别 [S]

通常按调用自动管理 由分配器和程序显式管理
分配释放快、行为较确定 时间可能不确定,会碎片化
容量通常较小,每任务可能独立 通常共享一片或多片内存
易出现栈溢出、大局部数组风险 易出现泄漏、重复释放、悬空指针

栈承担函数调用上下文、自动变量和部分参数等,但“所有参数都按固定方向压栈”并非 C 规则。现代 ABI 常先用寄存器传参,栈方向和布局由架构与 ABI 决定。

栈最大空间没有统一答案:取决于链接脚本、操作系统限制、RTOS 任务配置和可用 RAM。FreeRTOS 中每个任务有独立任务栈,应结合 high-water mark、静态分析和最坏调用链估算。

6.3 MCU 上为什么倾向静态分配? [S]

静态分配的容量、生命周期和失败模式更容易分析,不会产生长期运行后的外部碎片,适合硬实时路径。动态分配并非绝对不能用,可在启动阶段集中分配、使用固定块内存池,或对非实时模块设置严格所有权和失败处理。

面试中不要答“malloc 一定很慢”或“嵌入式绝对不能 malloc”。关键是确定性、碎片、失败处理和所有权。

6.4 malloc/calloc/realloc/free [S]

1
2
3
4
5
6
7
8
9
10
11
#include <stdint.h>
#include <stdlib.h>
#include <stdint.h>

uint16_t *allocate_samples(size_t count)
{
if (count > SIZE_MAX / sizeof(uint16_t)) {
return NULL; /* 防 count * sizeof 溢出 */
}
return calloc(count, sizeof(uint16_t));
}
  • malloc(n):申请至少 n 字节,内容未初始化;
  • calloc(count, size):申请数组并将所有字节置零,同时实现通常会检查乘法溢出;全零字节不应被泛化为所有类型的语义零;
  • realloc(p, n):可能原地扩缩,也可能搬迁;成功后旧指针失效,失败时旧块仍有效;
  • free(NULL) 安全;释放非动态块、重复释放或释放后继续访问均为未定义行为。

正确的 realloc 写法:

1
2
3
4
5
6
7
void *tmp = realloc(buffer, new_size);
if (tmp == NULL && new_size != 0U) {
/* buffer 仍有效,不能丢失。 */
handle_error();
} else {
buffer = tmp;
}

realloc(p, 0) 的可移植语义容易因标准版本和实现差异产生歧义,工程上避免依赖它,显式 free(p); p = NULL;

6.5 内存碎片和内存泄漏 [S]

  • 内存泄漏:已无法再访问某块已分配内存,因此无法释放;
  • 外部碎片:空闲总量够,但分散成许多小块,无法满足大块申请;
  • 内部碎片:分配块或对齐粒度大于实际需要,块内有浪费。

规避方法:

  • 明确所有权,谁申请谁释放,接口文档说明转移规则;
  • 统一错误路径清理;
  • 启动阶段一次性分配;
  • 固定块池、对象池、区域分配器;
  • 避免高频不同尺寸分配;
  • 压测长时间运行和最坏内存占用;
  • 主机侧用 AddressSanitizer、LeakSanitizer、Valgrind,目标侧用堆完整性检查和分配统计。

6.6 free 后为什么建议置 NULL? [S]

置 NULL 能降低同一变量误用和重复释放风险,因为 free(NULL) 安全。但它不是万能方案:其他别名仍然悬空,也可能掩盖所有权混乱。

1
2
3
4
uint8_t *alias = buffer;
free(buffer);
buffer = NULL;
/* alias 仍然是悬空指针,不能访问。 */

更重要的是缩小作用域、单一所有者、释放后不再使用,以及必要时使用 RAII 或句柄。

6.7 1 GB 内存能否 malloc(1.2 GB)[B]

不能仅凭物理内存回答。还取决于虚拟地址空间、进程限制、交换空间、内核 overcommit、连续虚拟地址是否足够以及分配器策略。在允许 overcommit 的 Linux 上,malloc 可能先成功,实际触页时才失败或被 OOM killer 处理;32 位进程还受地址空间限制。MCU 无 MMU 时则更直接受实际堆区和连续空闲块限制。

6.8 strcpy/strcat/strcmp/strncat 哪些可能越界? [A]

  • strcpy 不知道目标容量,源串过长会写越界;
  • strcat 不知道目标剩余容量且要扫描终止符;
  • strncat(dst, src, n) 的 n 是最多追加的源字符数,不是目标总容量,误用仍会越界;
  • strcmp 不写内存,但输入若不是有效 NUL 结尾字符串会越界读。

更稳妥的做法是接口显式携带容量,并检查 snprintf 返回值;二进制数据使用长度而非字符串函数。


7. 指针、数组与字符串

7.1 指针包含哪几个概念? [S]

面试时可拆成五件事:指针变量本身、指针类型、所指类型、保存的地址值、指针变量自身的存储位置。指针大小主要由目标架构/ABI 决定,并不等于所指对象大小。

1
2
3
uint32_t value = 42U;
uint32_t *p = &value;
/* sizeof p:指针大小;sizeof *p:uint32_t 大小。 */

void * 是通用对象指针。C 中可与对象指针隐式转换,C++ 中从 void * 转回具体指针需显式转换。函数指针不属于可移植的 void * 对象指针转换范围。

7.2 数组与指针的区别 [S]

数组是一组连续对象,数组类型包含元素类型和数量;指针是保存地址的独立对象。数组名只是在大多数表达式中转换为首元素指针,以下场景不会按普通退化处理:sizeof arr&arr、字符串初始化数组等。

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

/* sizeof arr == 4 * sizeof(int) */
/* sizeof p == 当前平台指针大小 */
/* arr = p; 非法,数组不可赋值 */
p = &arr[1]; /* 合法 */

7.3 arr&arrarr + 1&arr + 1 [S]

int arr[4]

  • 表达式 arr 通常转换为 int *,指向 arr[0]
  • &arr 类型为 int (*)[4],指向整个数组;
  • arr + 1 跨过一个 int
  • &arr + 1 跨过整个 int[4]
  • 地址数值起点通常相同,类型和步长不同。

7.4 指针数组与数组指针 [S]

1
2
int *pointer_array[4];  /* 4 个元素,每个元素是 int * */
int (*array_pointer)[4];/* 指向 int[4] 数组的指针 */

口诀只作辅助:先看变量名,[]* 结合得紧;括号改变结合。

7.5 二维数组如何传参? [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stddef.h>

enum { COLS = 4 };

int matrix_sum(size_t rows, const int matrix[][COLS])
{
int sum = 0;
for (size_t r = 0; r < rows; ++r) {
for (size_t c = 0; c < COLS; ++c) {
sum += matrix[r][c];
}
}
return sum;
}

参数会调整为 const int (*)[COLS],编译器必须知道列数才能计算 matrix[r][c] 的步长。int ** 不是二维连续数组 int[R][C] 的等价类型。

7.6 二级指针和指针引用有什么用? [A]

C 中用二级指针让函数修改调用者的指针,或表示指针数组:

1
2
3
4
5
6
7
8
9
10
11
#include <stdbool.h>
#include <stdlib.h>

bool make_buffer(size_t size, unsigned char **out)
{
if (out == NULL) {
return false;
}
*out = malloc(size);
return *out != NULL;
}

C++ 可用引用表达不能为空的别名,用指针引用修改指针:

1
bool make_buffer(std::size_t size, unsigned char *&out);

引用通常必须初始化、不能改绑且语法更简洁;指针可为空、可改指向,适合表达“可选对象”。底层实现是否使用地址属于 ABI,不是语言语义。

7.7 野指针、空指针、悬空指针 [S]

  • 空指针:明确不指向有效对象,C 用 NULL,现代 C++ 优先 nullptr
  • 野指针:未初始化或被破坏,值不确定;
  • 悬空指针:曾指向有效对象,但对象生命周期已经结束。
1
2
3
4
5
int *bad_return(void)
{
int local = 1;
return &local; /* 返回后 local 生命周期结束,结果悬空。 */
}

避免方式:声明即初始化、检查边界与返回值、明确生命周期和所有权、不要返回局部地址、释放后停止使用、C++ 中优先 RAII。

7.8 字符串字面量与字符数组 [S]

1
2
3
4
5
const char *s1 = "hello"; /* 指向字符串字面量,不得修改 */
char s2[] = "hello"; /* 创建可写数组副本 */

/* s1[0] = 'H'; 未定义行为 */
s2[0] = 'H'; /* 合法 */

C 中字符串字面量类型历史上是 char[N],但修改仍为未定义行为,因此接口应使用 const char *。C++ 中字面量类型是 const char[N]

7.9 数组下标可以是负数吗? [A]

语法允许,因为 p[i] 定义为 *(p + i)。是否合法取决于结果是否仍落在同一数组对象内。

1
2
3
int a[4] = {10, 20, 30, 40};
int *p = &a[2];
int x = p[-1]; /* 合法,等于 a[1] */

若越出数组边界则行为未定义。业务代码一般不使用负下标,除非算法边界极其清楚。

7.10 函数指针、指针函数与回调 [S]

  • 函数指针:指向函数;
  • 指针函数:返回值是指针的函数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef void (*rx_callback_t)(const uint8_t *data,
size_t length,
void *context);

struct receiver {
rx_callback_t callback;
void *context;
};

void receiver_dispatch(struct receiver *r,
const uint8_t *data,
size_t length)
{
if (r != NULL && r->callback != NULL) {
r->callback(data, length, r->context);
}
}

回调在哪个上下文执行必须写清楚。若由 ISR 调用,回调不能阻塞或做耗时工作;常见做法是 ISR 只通知任务,任务再执行上层回调。


8. 预处理器、宏与内联函数

8.1 #include <...>#include "..." [S]

两者的区别主要是头文件搜索顺序,由实现定义具体路径。通常:

  • <header.h> 从系统/工具链配置的 include 路径搜索;
  • "header.h" 先从当前源文件附近或项目路径搜索,失败后再按系统路径搜索。

标准库头文件用尖括号,项目私有头文件用双引号。不要依赖复杂的相对路径层级。

8.2 头文件保护 [S]

1
2
3
4
5
6
7
8
9
#ifndef PROJECT_SENSOR_H
#define PROJECT_SENSOR_H

#include <stddef.h>
#include <stdint.h>

int sensor_read(int16_t *samples, size_t capacity);

#endif /* PROJECT_SENSOR_H */

include guard 防止同一翻译单元重复包含造成重复声明/定义。#pragma once 更简洁并被主流工具链支持,但不是 C 标准的一部分。保护宏应带项目/模块前缀,避免不同头文件撞名。

8.3 为什么头文件通常不能定义普通变量? [S]

头文件会被多个 .c/.cpp 展开。若写 int g_state;,每个翻译单元可能产生一个定义,链接时报多重定义,或者因旧编译器 common symbol 规则暂时没报错但语义脆弱。

正确方式是在头文件 extern 声明,在一个源文件定义。头文件也不要随意写 static 可写变量,因为每个翻译单元会各有一份副本,既浪费内存又容易出现状态不一致。

可在头文件定义的典型内容:类型、函数声明、宏、枚举、static inline 小函数、C++17 inline 变量等,但要理解各自链接规则。

8.4 #defineconst [S]

1
2
3
#define SAMPLE_RATE_HZ 16000U
static const uint32_t sample_rate_hz = UINT32_C(16000);
enum { FRAME_SAMPLES = 960 };
#define const
预处理文本替换 有类型的语言对象
无类型检查 编译器检查类型
无普通语言作用域 遵守作用域和链接规则
不能正常取地址 可以取地址,可能分配存储
适合条件编译和生成语法 适合有类型常量

C 中 const int n = 10; 通常不是整数常量表达式,不能在所有需要编译期常量的场景使用,例如标准 C 的文件作用域数组长度和 case 标签。此时可用 enum 或宏。C++ 的 const 常量规则不同。

8.5 宏的四类陷阱 [S]

1
2
3
4
5
#define BAD_SQUARE(x) x * x
#define SQUARE(x) ((x) * (x))

int a = BAD_SQUARE(1 + 2); /* 展开为 1 + 2 * 1 + 2,错误 */
int b = SQUARE(1 + 2); /* 9 */

即使加全括号,SQUARE(i++) 仍会修改 i 两次,行为可能未定义。因此优先内联函数:

1
2
3
4
static inline int square_int(int x)
{
return x * x;
}

多语句宏:

1
2
3
4
#define SET_ERROR_AND_RETURN(ctx_, code_) do { \
(ctx_)->error = (code_); \
return (code_); \
} while (0)

四类坑是:参数/整体缺括号、参数多次求值、变量名捕获、多语句宏与 if/else 结合出错。

8.6 写一个 MIN[A]

标准可移植 C 宏无法同时做到泛型、只求值一次和完整类型安全。

1
#define MIN(a_, b_) (((a_) < (b_)) ? (a_) : (b_))

它有括号,但参数可能求值两次,不能传 i++、函数调用或寄存器读取。工程中为常用类型写 static inline,或在 C11 用 _Generic 选择类型函数。GCC statement expression 能做到只求值一次,但属于扩展。

8.7 ### [A]

  • # 把宏实参字符串化;
  • ## 拼接 token。
1
2
3
4
5
6
7
8
#define STRINGIFY_INNER(x) #x
#define STRINGIFY(x) STRINGIFY_INNER(x)
#define REGISTER_HANDLER(name) void handler_##name(void)

#define FW_MAJOR 2
const char *version = STRINGIFY(FW_MAJOR); /* "2" */

REGISTER_HANDLER(uart) { /* 生成 handler_uart */ }

需要两层 STRINGIFY,才能先展开参数再字符串化。

8.8 #error、条件编译与编译期断言 [A]

1
2
3
4
5
#if !defined(TARGET_ESP32P4) && !defined(HOST_TEST)
#error "Select exactly one supported target"
#endif

_Static_assert(sizeof(uint32_t) == 4, "requires 32-bit uint32_t");

#error 在预处理阶段主动终止构建,适合不支持的平台组合。_Static_assert 是 C11 语言机制,检查类型大小、字段范围等编译期条件;C++ 使用 static_assert

8.9 offsetof 和结构体偏移 [A]

标准方式是 <stddef.h>offsetof(type, member)。不要在业务代码自己用空指针成员访问模拟,因为标准宏可由编译器内建处理,自己写可能引入未定义行为或告警。

1
2
3
4
5
6
7
8
9
#include <stddef.h>

struct Packet {
uint8_t type;
uint32_t sequence;
};

_Static_assert(offsetof(struct Packet, sequence) >= 1,
"invalid member offset");

8.10 inline、宏和普通函数 [S]

inline 函数有参数类型检查、参数只求值一次、普通作用域和调试语义。inline 只是允许内联,编译器可拒绝;没写 inline 的函数也可能被优化器内联。宏没有函数调用语义,但能参与条件编译或生成语法片段。

嵌入式头文件常写:

1
2
3
4
static inline uint32_t bit_mask(unsigned bit)
{
return UINT32_C(1) << bit;
}

static 避免 C 的外部 inline 定义规则造成链接问题。不要把很大的函数强制内联,否则会增大代码体积和 I-Cache 压力。


9. 函数、参数与健壮接口

9.1 C 函数参数是值传递 [S]

包括指针也是按值传递:函数得到地址值的副本,通过它可修改所指对象,但给形参重新赋值不会改变调用者的指针。

1
2
3
4
5
6
7
8
9
10
11
void set_value(int *p)
{
if (p != NULL) {
*p = 42; /* 修改调用者对象 */
}
}

void clear_local(int *p)
{
p = NULL; /* 只改形参副本 */
}

要修改调用者指针,传 T **;C++ 还可传 T *&

9.2 函数原型为什么重要? [S]

原型让编译器检查参数数量、类型和返回值,并完成正确调用约定。现代 C 不允许隐式函数声明。声明和定义不一致会造成编译错误或跨翻译单元 ABI 问题。

头文件中声明公开接口,源文件包含自己的头文件再定义,可让编译器检查一致性。

9.3 输入参数为什么加 const[S]

1
2
int parse_frame(const uint8_t *data, size_t length,
struct frame *out);

const 表达函数不会通过该指针修改输入,允许传入只读数据并帮助审查副作用。它不承诺底层对象永远不被其他代码修改,也不自动实现线程安全。

9.4 什么是可重入函数? [A]

可重入函数可在上次调用尚未完成时再次安全调用,例如被中断重入或多个任务并发调用。它不能依赖未保护的共享可写状态、返回静态缓冲区或调用不可重入函数。

1
2
3
4
5
6
/* 可重入:状态由调用者提供。 */
int filter_step(struct filter_state *state, int sample)
{
state->sum += sample;
return state->sum;
}

线程安全与可重入相关但不等价:使用互斥锁的函数可能线程安全,却不适合在信号/中断中重入;纯函数通常天然可重入。

9.5 嵌入式为什么谨慎递归? [A]

递归的最坏栈深可能难以静态界定,栈溢出后常破坏任务控制块或邻接内存。硬实时 MCU 优先迭代或显式固定容量栈。若算法深度有严格小上界且经过栈分析,也不是绝对不能用。

9.6 C 可变参数如何实现,有什么风险? [B]

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdarg.h>

int sum_ints(unsigned count, ...)
{
va_list ap;
va_start(ap, count);
int sum = 0;
for (unsigned i = 0; i < count; ++i) {
sum += va_arg(ap, int);
}
va_end(ap);
return sum;
}

调用者与函数必须通过格式或 count 协议约定类型;编译器通常无法完全检查。默认参数提升会把 float 提升成 double,小整数提升成 int/unsigned int。传入类型和 va_arg 读取类型不匹配可能是未定义行为。嵌入式应控制格式串、栈占用和代码体积。

9.7 返回值、错误码和输出参数怎么设计? [A]

常见接口让返回值表达成功/错误,输出参数承载数据,并显式传容量:

1
2
3
4
5
6
7
8
enum parse_result {
PARSE_OK = 0,
PARSE_INVALID = -1,
PARSE_TOO_SHORT = -2
};

int parse_header(const uint8_t *data, size_t length,
struct header *out);

约定应明确:是否允许 NULL、失败时 out 是否保持不变、函数是否阻塞、是否可从 ISR 调用、对象所有权是否转移。


10. 字符串、格式化 I/O 与文件接口

10.1 getsscanf 的区别 [S]

不要使用 gets。它无法知道目标缓冲区容量,C11 已从标准库删除。读取整行使用 fgets,再解析:

1
2
3
4
5
6
#include <stdio.h>

char line[64];
if (fgets(line, sizeof line, stdin) != NULL) {
/* 用 strtol/sscanf 等继续解析,并检查结果。 */
}

scanf("%s", buf) 以空白为分隔且若不写宽度也会溢出。即使写 %63s,宽度还必须和真实数组容量匹配。复杂输入优先“fgets 读一行 + strtol 等显式解析”。

10.2 printf 的返回值 [A]

成功时返回实际输出的字符数,不含结尾 NUL;发生输出/编码错误返回负值。snprintf 返回“如果容量足够本应写出的字符数”,不含 NUL,因此可判断截断:

1
2
3
4
5
6
7
char buffer[32];
int n = snprintf(buffer, sizeof buffer, "id=%u", id);
if (n < 0) {
handle_format_error();
} else if ((size_t)n >= sizeof buffer) {
handle_truncation();
}

在 ISR 中通常不调用 printf:它耗时不可控,可能持锁、分配内存、访问不可重入的驱动,浮点格式还会显著增加代码体积。ISR 记录事件或写无锁/临界区保护的日志缓冲区,任务异步输出。

10.3 printf 格式与类型必须匹配 [S]

变参没有普通参数那样完整的类型信息,格式不匹配可能导致未定义行为。

1
2
3
size_t length = 10;
printf("%zu\n", length); /* size_t */
printf("%p\n", (void *)ptr); /* %p 要求 void * */

常见格式:%d/int%u/unsigned int%ld/long%zu/size_t%f/double。在 printf 变参中 float 经默认提升为 double。

10.4 scanf 的返回值和安全检查 [A]

scanf 返回成功赋值的输入项数;匹配失败可能返回 0,到达输入结尾或读取错误且尚未赋值时返回 EOF。必须检查返回值。

1
2
3
4
int value;
if (scanf("%d", &value) != 1) {
/* 输入不是合法整数。 */
}

scanf 参数通常是地址;格式与目标指针类型必须匹配。%s%[ 必须限制宽度,宽度不包含最终 NUL 所占位置。

10.5 printf/scanf* 的含义 [B]

printf* 从参数读取字段宽度或精度:

1
printf("%*.*f\n", width, precision, value);

scanf* 表示读取但不赋值,该项不需要对应输出指针,也不计入成功赋值数:

1
2
int value;
sscanf("12:34", "%*d:%d", &value); /* value = 34 */

10.6 常见字符输入函数 [B]

  • fgetc/getchar:返回 int,才能同时表达所有 unsigned char 值和 EOF;不要先存进 char 再和 EOF 比;
  • fgets:最多读取 size - 1 个字符并补 NUL,若读到换行通常保留换行;
  • ungetc:把一个字符推回流,保证能力有限;
  • 二进制数据用 fread,不要用字符串 API。

10.7 fseek/ftell/rewind [B]

1
2
3
4
5
6
7
8
if (fseek(fp, 0L, SEEK_END) != 0) {
handle_error();
}
long length = ftell(fp);
if (length < 0L) {
handle_error();
}
rewind(fp); /* 回到开头并清除 EOF/错误状态 */

文本流的可移植定位能力有限;大文件在 POSIX 上常用 fseeko/ftellooff_t。文件长度也不应简单假设为可一次分配和读取的安全大小。


11. 嵌入式 C++ 高频基础

11.1 嵌入式为什么常用 C,而不是 C++? [S]

30 秒回答:

C 的 ABI 稳定、运行时依赖小、硬件寄存器映射直接、工具链和芯片 SDK 支持普遍,因此底层驱动和启动代码常用 C。现代 C++ 不是天然不适合嵌入式:RAII、模板、强类型和 constexpr 能在很少运行时成本下改善资源管理与抽象。真正要按平台约束选择或禁用的是异常、RTTI、不可控动态分配、全局构造和某些容器,而不是笼统拒绝 C++。

11.2 C 与 C++ 的主要差别 [S]

  • C 主要是过程式语言;C++ 支持类、泛型、RAII、继承和运行时多态;
  • C++ 类型检查更严格,如 void * 转具体指针需显式转换;
  • C++ 有函数重载、引用、模板、构造析构、命名空间;
  • C 没有语言级资源自动释放,常用 goto cleanup;C++ 可用析构函数表达所有权;
  • 两者存在许多同名但规则不同的语法,不能把 C++ 简化为“带类的 C”。

11.3 构造函数有哪些?默认生成哪些特殊成员? [A]

常见构造:默认构造、带参数构造、复制构造、移动构造、委托构造。现代 C++ 常讨论六个特殊成员:

  1. 默认构造函数;
  2. 析构函数;
  3. 复制构造函数;
  4. 复制赋值运算符;
  5. 移动构造函数;
  6. 移动赋值运算符。

是否隐式声明、定义或删除受用户声明成员和成员类型影响,不能死背“永远自动生成六个”。例如用户声明析构函数会抑制隐式移动操作的生成;不可复制成员会使包含类的复制操作被删除。

1
2
3
4
5
6
7
8
9
10
class Buffer {
public:
explicit Buffer(std::size_t size);
~Buffer();

Buffer(const Buffer &) = delete;
Buffer &operator=(const Buffer &) = delete;
Buffer(Buffer &&) noexcept;
Buffer &operator=(Buffer &&) noexcept;
};

优先 Rule of Zero:让标准 RAII 成员管理资源,类本身不手写特殊成员。

11.4 对象初始化和析构顺序 [A]

构造顺序与初始化列表书写顺序无关:

  1. 虚基类(最派生类负责);
  2. 直接基类,按声明顺序;
  3. 数据成员,按类中声明顺序;
  4. 构造函数体。

析构顺序相反。多重继承同样按基类声明顺序,不按初始化列表顺序。成员若依赖另一个成员,应按声明顺序排列,开启 -Wreorder

11.5 析构函数为什么有时必须为虚函数? [S]

只有当类要作为多态基类,并可能通过基类指针删除派生对象时,基类析构函数才需要 virtual,否则行为未定义或资源未正确释放。

1
2
3
4
struct Driver {
virtual ~Driver() = default;
virtual void start() = 0;
};

不是“所有析构函数都必须虚”。不用于多态删除的类无需承担 vptr 和虚调用设计;C++ Core Guidelines 常概括为基类析构应当 public virtual,或 protected non-virtual。

默认析构不为虚,是因为 C++ 遵循“不使用就不付成本”,语言无法替程序员判断该类是否设计成多态基类。

11.6 虚函数表如何实现多态? [A]

标准只规定动态派发语义,不强制 vtable 实现。主流 ABI 中,多态对象通常含隐藏 vptr,指向对应动态类型的虚函数表;通过基类指针调用虚函数时,程序经 vptr 查表得到最终覆盖函数。

成本通常包括:每个对象一个 vptr、每个多态类一张表、一次间接调用,以及对内联和最坏执行时间分析的影响。不要给出“虚函数一定占几个字节”的绝对答案,要说明平台 ABI。

构造和析构期间,虚调用不会派发到尚未构造或已析构的更派生部分,这也是常见追问。

11.7 静态函数和虚函数区别 [A]

类静态成员函数没有 this,属于类作用域的普通函数式接口,不能是 virtual;虚函数依赖具体对象的动态类型和 this 完成运行时派发。文件作用域 static 函数则是 C/C++ 的内部链接概念,和类静态成员不是同一件事。

11.8 重载、重写和隐藏 [S]

  • 重载 overloading:同一作用域、同名、参数列表不同,编译期选择;仅返回类型不同不能重载;
  • 重写 overriding:派生类提供与基类虚函数匹配的实现,运行时多态;
  • 隐藏 hiding:派生类同名成员可能隐藏基类的全部同名重载。
1
2
3
4
5
6
7
8
struct Base {
virtual void run(int value) = 0;
void log(int value);
};

struct Derived final : Base {
void run(int value) override;
};

始终使用 override 让编译器检查签名。

11.9 RAII 和智能指针 [S]

RAII 把资源生命周期绑定到对象生命周期:构造获取资源,析构释放。资源不仅是堆内存,还包括互斥锁、文件、GPIO、总线句柄和临界区。

1
2
3
4
5
6
7
8
9
class LockGuard {
public:
explicit LockGuard(Mutex &m) : mutex_(m) { mutex_.lock(); }
~LockGuard() { mutex_.unlock(); }
LockGuard(const LockGuard &) = delete;
LockGuard &operator=(const LockGuard &) = delete;
private:
Mutex &mutex_;
};

智能指针:

  • std::unique_ptr:唯一所有权,体积和普通指针接近,嵌入式优先;
  • std::shared_ptr:共享引用计数,控制块、原子计数和不可预测析构点有额外成本;
  • std::weak_ptr:不拥有对象,用于打破 shared_ptr 环。

智能指针仍可能出现逻辑泄漏,例如 shared_ptr 循环引用或容器永久持有对象。它解决的是所有权管理,不自动解决所有资源问题。

11.10 new/deletemalloc/free [S]

malloc 按字节申请原始存储,不调用构造函数,返回 void *new 分配存储并构造对象,失败默认抛 std::bad_alloc,也可用 std::nothrowdelete 调析构并释放,free 只释放分配块。

必须成对匹配:new/deletenew[]/delete[]malloc/free,不能混用。

placement new 在调用者提供的已对齐存储上构造对象,不分配存储:

1
2
3
4
5
6
#include <new>
#include <cstddef>

alignas(Device) std::byte storage[sizeof(Device)];
Device *dev = new (storage) Device(args);
dev->~Device();

适合固定内存池,但必须管理对齐、生命周期和显式析构。

11.11 C++ 类静态成员的现代规则 [A]

普通非 inline 静态数据成员通常在类外提供一个定义。C++17 inline static 成员可以在类内定义并初始化;整型 const staticconstexpr 还有标准版本相关规则。因此“静态成员绝对不能类内初始化”是过时答案。

1
2
3
4
struct Config {
inline static std::uint32_t instances = 0; /* C++17 */
static constexpr std::uint32_t rate = 16000;
};

11.12 C/C++ 混合编程 [A]

C++ 会进行名字修饰以支持重载,C 接口头文件需用 extern "C"

1
2
3
4
5
6
7
8
9
#ifdef __cplusplus
extern "C" {
#endif

int device_init(void);

#ifdef __cplusplus
}
#endif

extern "C" 改变的是语言链接,不会把 C++ 函数变成 C 语法,也不能让 C 调用需要 C++ 异常/对象 ABI 的任意接口。


12. Linux/POSIX 补充

韵音岗位更偏 MCU 和算法落板,本章优先级低于前面的 C、处理器和 RTOS。但简历写了 Linux,就可能被追问。

12.1 fork/wait/exec [B]

fork 创建子进程。成功后父进程得到子 PID,子进程得到 0;两者从 fork 后继续执行。现代 Linux 通过写时复制避免立即复制全部物理页,但逻辑地址空间彼此独立。

exec 族不创建新进程,而是用新程序映像替换当前进程;成功时不返回。wait/waitpid 回收子进程退出状态,避免僵尸进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdlib.h>

pid_t pid = fork();
if (pid == 0) {
execlp("echo", "echo", "child", (char *)NULL);
_exit(127); /* exec 失败才到这里,避免刷新父进程 stdio 缓冲 */
} else if (pid > 0) {
int status;
(void)waitpid(pid, &status, 0);
} else {
/* fork 失败 */
}

多线程进程 fork 后,子进程只保留调用 fork 的线程;到 exec 前能安全调用的函数受到 async-signal-safe 限制,这是高级追问。

12.2 信号处理 [B]

使用 sigaction,不优先使用语义较弱且历史行为差异大的 signal。信号处理函数中只能做 async-signal-safe 操作,不能随意 printfmalloc 或加普通互斥锁。

1
2
3
4
5
6
7
8
9
#include <signal.h>

static volatile sig_atomic_t stop_requested;

static void on_signal(int signo)
{
(void)signo;
stop_requested = 1;
}

volatile sig_atomic_t 适合简单标志,不表示任意复杂共享对象都安全。Linux 事件循环还可用 self-pipe 或 signalfd 把信号转成普通 I/O 事件。

12.3 select/poll/epoll 的区别 [B]

接口 核心特点 主要局限/适用场景
select 位图 fd 集合,兼容性广 FD_SETSIZE 限制;每次重建并线性扫描;会修改集合和 timeout
poll pollfd 数组,无固定小 fd 位图限制 每次传整个数组,通常线性扫描
epoll Linux 内核维护关注集合,返回就绪事件 Linux 专用;需正确处理 LT/ET、非阻塞和 fd 生命周期

“epoll 一定比 select 快”过于绝对。少量 fd 时差异不重要;epoll 的优势通常在大量连接和事件稀疏场景。

边沿触发 ET 只在状态变化时通知,通常必须把非阻塞 fd 一直读/写到 EAGAIN;水平触发 LT 只要条件仍满足就会继续通知,更容易写对。

12.4 mmap 进程通信过程 [B]

典型共享文件映射流程:

  1. 一方用 shm_open 或普通文件创建共享对象;
  2. ftruncate 设置大小;
  3. 双方 mmap(..., MAP_SHARED, ...) 映射同一对象;
  4. 页表把各进程不同虚拟地址映射到同一组物理页/页缓存;
  5. 一方写入后,另一方可从共享映射读取;
  6. 用进程共享 mutex、信号量、futex 或事件机制同步“数据何时有效”;
  7. munmapclose,最后由约定的一方 shm_unlink
1
2
3
4
5
struct SharedBlock {
/* 实际代码还需进程共享同步原语。 */
uint32_t sequence;
uint8_t payload[256];
};

mmap 只共享内存,不自动提供互斥、消息边界或内存顺序。发布者不能先更新“ready”再填数据;必须用正确同步原语建立 happens-before。MAP_PRIVATE 是写时复制,不适合双方共享修改。

12.5 常见进程通信方式 [B]

  • 匿名/命名管道:字节流,简单父子或本机进程通信;
  • 消息队列:带消息边界和优先级;
  • 共享内存/mmap:大数据吞吐高,但同步需自行设计;
  • Unix/TCP/UDP socket:可本机也可跨主机,接口统一;
  • 信号:只适合通知,不适合承载复杂数据;
  • eventfd、futex 等 Linux 机制:适合事件计数和底层同步。

13. 中断、多线程与 RTOS

13.1 多线程会出现哪些问题? [S]

  • 数据竞争:至少两个执行流并发访问同一对象,至少一个写且无同步;在 C/C++ 内存模型中普通数据竞争是未定义行为;
  • 丢失更新:两个任务同时执行读改写;
  • 可见性/重排序:一个核更新数据,另一个核未按同步关系观察;
  • 死锁:循环等待资源;
  • 活锁和饥饿:一直运行却无进展,或某任务长期拿不到资源;
  • 优先级反转:高优先级任务等待低优先级持有的锁;
  • 伪共享、Cache 一致性开销;
  • 生命周期问题:任务仍使用已释放对象。

解决手段要匹配问题:互斥锁保护共享不变量,信号量计数资源/通知事件,队列传递所有权,原子变量处理简单状态,临界区保护极短 ISR 共享操作,固定锁顺序避免死锁。

13.2 互斥锁和信号量的两个典型例子 [S]

互斥锁:保护同一 I2C 总线事务。 两个任务都要完成“写寄存器地址 + 读数据”的完整事务,若交叉执行会串包。锁住整个事务,持有者就是释放者,RTOS mutex 可提供优先级继承。

计数信号量:表示 N 个可用 DMA 缓冲块。 初值为 N,每个生产者取得一个块时 take,消费者处理并归还后 give。信号量表示资源数量,不保护块内复杂不变量。

二值信号量/任务通知:ISR 通知任务。 ISR 完成采样后 give,任务被唤醒处理。它没有“所有者”,这正是它与 mutex 的重要区别。

13.3 普通信号量 API 和 FromISR 为什么不同? [S]

普通任务 API 可能阻塞、操作调度器任务列表、进入不适用于 ISR 的临界区。ISR 不能睡眠,也没有可阻塞的任务上下文。FromISR 版本保证中断可用,通常只做有限操作,并通过 higher_priority_task_woken 告知退出中断前是否应触发一次上下文切换。

13.4 临界区怎么实现,为什么不能关中断太久? [S]

单核 MCU 常通过提升中断屏蔽级别或关中断保护极短代码;多核还需要自旋锁/原子机制,单纯关本核中断挡不住另一核。临界区过长会增加中断延迟和实时抖动,可能丢外设数据、影响时钟节拍和高优先级响应。

规则:临界区内不阻塞、不打印、不做复杂算法,只复制少量状态或更新索引。

13.5 volatile 为什么不能替代锁? [S]

1
2
3
4
5
6
static volatile unsigned count;

void increment(void)
{
++count; /* 仍是读-改-写,两个任务可丢更新。 */
}

volatile 约束编译器对特定访问的处理,不提供原子读改写,不建立跨线程同步关系。使用 C11 _Atomic/平台原子、mutex 或临界区。

13.6 单线程也会数据不一致吗? [S]

会。“单线程”只表示没有多个普通线程,不代表没有中断、DMA、信号处理、硬件并发或重入。

例 1:主循环读取一个 32 位计数器,8/16 位 MCU 的 ISR 在两次总线访问之间更新它,主循环读到撕裂值。

例 2:DMA 正在写缓冲区,CPU 同时解析,前半帧是新数据、后半帧是旧数据。应使用双缓冲和所有权状态,必要时做 Cache 维护。

例 3:即使完全无异步执行,函数失败后只更新结构体一半,也会留下逻辑不变量破坏。可先在临时对象中校验和构造,成功后一次提交。

13.7 ISR 中断处理过程 [S]

以常见 MCU 为例:

  1. 外设产生中断请求;
  2. 中断控制器根据使能、屏蔽和优先级仲裁;
  3. CPU 完成当前指令边界,保存规定的现场,切换特权级/栈并取中断向量;
  4. ISR 读取状态,确认中断源,按芯片要求清标志;
  5. 快速搬取必要数据或记录事件,唤醒任务;
  6. 退出时恢复现场;若有更高优先级任务就绪,RTOS 可能进行调度;
  7. 返回被打断代码。

保存哪些寄存器、自动压栈多少、能否嵌套由处理器架构和 ABI 决定。清中断标志的时序也必须看芯片手册,有的是写 1 清除,不能机械做读改写。

13.8 FreeRTOS 调度和 PendSV [S]

FreeRTOS 通常始终选择最高优先级的就绪任务。抢占开启时,更高优先级任务就绪可抢占当前任务;同优先级任务在启用时间片且 tick 到来时轮转,也会因阻塞/yield 切换。configUSE_TIME_SLICING 主要控制同优先级就绪任务的 tick 时间片轮转,不会让低优先级任务轮到 CPU。

Cortex-M 把上下文切换放在 PendSV,原因是 PendSV 可软件挂起并配置为最低优先级:让更紧急的硬件 ISR 先完成,再在所有中断退出后统一切换任务,避免在高优先级 ISR 中做完整调度和上下文切换。

典型切换保存:CPU 自动堆叠部分寄存器,RTOS 端口保存剩余调用者/被调用者保存寄存器、栈指针和必要的浮点上下文到当前任务栈/TCB,再载入下一任务上下文。精确集合看架构端口实现。

13.9 优先级反转 [A]

低优先级 L 持有锁,高优先级 H 等待该锁;中优先级 M 不需要锁却持续抢占 L,导致 H 间接被 M 阻塞,这就是无界优先级反转。

优先级继承让 L 临时提升到等待者的最高优先级,尽快释放锁,再恢复。优先级天花板在获取资源时把任务提升到该资源预设上限,可提供更强的阻塞上界分析,但配置和使用更严格。继承不能修复死锁或超长临界区。

13.10 空闲任务和软件定时器 [A]

空闲任务保证在没有其他任务就绪时仍有可运行任务,常负责回收已删除任务资源,并提供 idle hook/低功耗入口,所以优先级最低且不能长期阻塞。

软件定时器由 timer service/daemon task 执行回调,不是硬件 ISR。多个回调通常共享一个任务上下文,回调阻塞或耗时会拖延其他定时器;应只做快速工作或通知业务任务。硬件定时器直接由外设计数和中断驱动,精度/抖动更好但 ISR 约束更严格。

13.11 SPSC 环形缓冲区在 ISR 写、任务读时安全吗? [S]

只有在明确前提下安全:单生产者、单消费者;生产者只写 head,消费者只写 tail;元素先写完再发布 head;索引宽度访问原子;编译器和 CPU 内存顺序正确;溢出策略明确。volatile 只能抑制部分编译优化,不能自动提供发布/获取语义,多核尤其需要原子操作。

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
33
34
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define RB_CAPACITY 8U

struct ring_buffer {
uint8_t data[RB_CAPACITY];
volatile size_t head; /* ISR 唯一写者 */
volatile size_t tail; /* task 唯一写者 */
};

bool rb_push_isr(struct ring_buffer *rb, uint8_t value)
{
size_t head = rb->head;
size_t next = (head + 1U) % RB_CAPACITY;
if (next == rb->tail) {
return false; /* 留一个空槽判满 */
}
rb->data[head] = value;
rb->head = next; /* 单核需确保先数据、后发布索引 */
return true;
}

bool rb_pop_task(struct ring_buffer *rb, uint8_t *out)
{
size_t tail = rb->tail;
if (tail == rb->head || out == NULL) {
return false;
}
*out = rb->data[tail];
rb->tail = (tail + 1U) % RB_CAPACITY;
return true;
}

面试中应主动说:这只是展示结构,具体项目优先用 RTOS stream buffer/queue,或用目标平台原子与临界区证明同步;ESP32 多核场景不能只凭 volatile 判断安全。


14. 高频手撕代码

14.1 实现 strlen [S]

1
2
3
4
5
6
7
8
9
10
#include <stddef.h>

size_t my_strlen(const char *s)
{
const char *p = s;
while (*p != '\0') {
++p;
}
return (size_t)(p - s);
}

标准 strlen 不接受 NULL;若面试题要求健壮接口,可另行约定 NULL 行为,但不要声称它等价于标准函数。

14.2 实现 memmove [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stddef.h>

void *my_memmove(void *dst, const void *src, size_t n)
{
unsigned char *d = dst;
const unsigned char *s = src;

if (d == s || n == 0U) {
return dst;
}

if (d < s) {
for (size_t i = 0; i < n; ++i) {
d[i] = s[i];
}
} else {
for (size_t i = n; i > 0U; --i) {
d[i - 1U] = s[i - 1U];
}
}
return dst;
}

严格可移植地比较无关对象指针的关系有语言细节;工业库实现可用编译器/平台保证。面试重点是根据重叠方向选择前拷或后拷,memcpy 不允许重叠。

14.3 无溢出二分查找 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stddef.h>
#include <stdbool.h>

bool binary_search(const int *a, size_t n, int target, size_t *index)
{
size_t lo = 0U;
size_t hi = n; /* 半开区间 [lo, hi) */

while (lo < hi) {
size_t mid = lo + (hi - lo) / 2U;
if (a[mid] < target) {
lo = mid + 1U;
} else {
hi = mid;
}
}
if (lo < n && a[lo] == target) {
if (index != NULL) {
*index = lo;
}
return true;
}
return false;
}

14.4 反转单链表 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct node {
int value;
struct node *next;
};

struct node *list_reverse(struct node *head)
{
struct node *previous = NULL;
while (head != NULL) {
struct node *next = head->next;
head->next = previous;
previous = head;
head = next;
}
return previous;
}

14.5 位操作 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdbool.h>
#include <stdint.h>

uint32_t bit_set(uint32_t value, unsigned bit)
{
return value | (UINT32_C(1) << bit);
}

uint32_t bit_clear(uint32_t value, unsigned bit)
{
return value & ~(UINT32_C(1) << bit);
}

uint32_t bit_toggle(uint32_t value, unsigned bit)
{
return value ^ (UINT32_C(1) << bit);
}

bool bit_test(uint32_t value, unsigned bit)
{
return (value & (UINT32_C(1) << bit)) != 0U;
}

调用前必须保证 bit < 32。硬件寄存器可能具有写 1 清零、只写、保留位等特殊语义,不能对所有寄存器机械套读改写。

14.6 统计 32 位中 1 的个数 [A]

1
2
3
4
5
6
7
8
9
10
11
#include <stdint.h>

unsigned popcount32(uint32_t value)
{
unsigned count = 0U;
while (value != 0U) {
value &= value - 1U; /* 每次清除最低位的 1 */
++count;
}
return count;
}

14.7 有界字符串复制 [S]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdbool.h>
#include <stddef.h>

bool string_copy(char *dst, size_t capacity, const char *src)
{
if (dst == NULL || src == NULL || capacity == 0U) {
return false;
}

size_t i = 0U;
while (i + 1U < capacity && src[i] != '\0') {
dst[i] = src[i];
++i;
}
dst[i] = '\0';
return src[i] == '\0'; /* true 表示完整复制,false 表示截断 */
}

接口明确保证 NUL 终止,并通过返回值报告截断;这比误用 strncpy 更容易解释。

14.8 固定容量环形缓冲区(普通单线程) [S]

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
33
34
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

#define QUEUE_CAPACITY 16U

struct byte_queue {
uint8_t data[QUEUE_CAPACITY];
size_t head;
size_t tail;
size_t count;
};

bool queue_push(struct byte_queue *q, uint8_t value)
{
if (q == NULL || q->count == QUEUE_CAPACITY) {
return false;
}
q->data[q->head] = value;
q->head = (q->head + 1U) % QUEUE_CAPACITY;
++q->count;
return true;
}

bool queue_pop(struct byte_queue *q, uint8_t *out)
{
if (q == NULL || out == NULL || q->count == 0U) {
return false;
}
*out = q->data[q->tail];
q->tail = (q->tail + 1U) % QUEUE_CAPACITY;
--q->count;
return true;
}

判空/满有三种常见方案:留一个空槽、额外 count、额外 full 标志。count 方案能使用全部槽,但并发时 head/tail/count 多个共享字段更难同步。先写对单线程版,再根据 SPSC/MPSC/多核等具体模型设计并发版。

14.9 协议解析状态机 [A]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
enum parser_state { WAIT_MAGIC, WAIT_LENGTH, READ_PAYLOAD, WAIT_CHECKSUM };

struct parser {
enum parser_state state;
uint8_t payload[64];
size_t expected;
size_t used;
uint8_t checksum;
};

void parser_reset(struct parser *p)
{
p->state = WAIT_MAGIC;
p->expected = 0U;
p->used = 0U;
p->checksum = 0U;
}

继续实现时先验证 length 不超过数组,再写 payload;遇到错误应明确是立即复位、丢到下一个 magic,还是保留错误统计。面试官更看边界和错误恢复,不只看 switch 能否写出来。


15. 高频纠错与面试追问

15.1 十五句不能直接背的错误答案

  1. “volatile 保证线程安全。” 错;不保证原子性和同步顺序。
  2. “const 变量都放只读区。” 错;位置取决于存储期、用途、优化和链接脚本。
  3. “static 变量都放 .bss。” 错;非零初始化常在 .data,const 还可能在 .rodata
  4. “C 函数参数从右向左压栈。” 不是语言规则;寄存器传参和栈布局由 ABI 决定。
  5. sizeof(void) == 1。” 标准 C 非法,GCC 只是扩展。
  6. “C 不能写二进制字面量。” C23 已支持,旧标准及工具链需分别说明。
  7. “用位域解析协议更省事,所以最合适。” 布局由实现决定,不可移植。
  8. “析构函数必须是虚函数。” 只有多态基类通过基类指针删除时需要。
  9. “C++ 静态成员不能在类内初始化。” 现代 C++ 有 inline staticconstexpr 等规则。
  10. “free 后置 NULL 就不会有悬空指针。” 其他别名仍然悬空。
  11. strncpy 一定比 strcpy 安全。” 可能不补 NUL,并会填充剩余空间;仍需正确容量语义。
  12. calloc 把所有对象初始化成语言意义的零。” 保证字节清零,不应泛化到所有表示。
  13. “malloc 返回成功就表示这些物理内存一定可用。” Linux overcommit 下不一定。
  14. “单线程没有数据一致性问题。” ISR、DMA、信号和失败半更新仍可造成不一致。
  15. “epoll 永远比 select 快。” 性能取决于 fd 数量、活跃比例和实现方式。

15.2 面试官可能沿每章怎么追问

起始问题 常见追问
volatile 为什么不能原子?DMA 缓冲是否只加 volatile?多核怎么办?
static 局部 static 是否线程安全?初始化何时发生?放哪个段?
const const int *int *const;去 const 修改何时 UB?
结构体对齐 指定 ABI 算大小;重排;packed 风险;协议序列化
malloc 碎片;realloc 失败;分配溢出;MCU 上替代方案
数组和指针 &a + 1;数组传参;二维数组为什么不能传 int **
MIN(i++, j++);多语句宏;#/##;inline 是否一定展开
函数指针 typedef 写法;回调上下文;ISR 回调限制;生命周期
虚函数 vptr/vtable 成本;纯虚;构造期间派发;虚析构
环形缓冲区 判满判空;覆盖策略;SPSC;内存序;ISR 能否加 mutex
mmap MAP_SHARED/PRIVATE;同步;崩溃恢复;进程共享 mutex
FreeRTOS FromISR;PendSV;优先级反转;软件定时器回调限制

15.3 回答模板

每道基础题用以下顺序,通常能控制在 30 到 60 秒:

  1. 定义: 它是什么;
  2. 保证: 它能解决什么;
  3. 边界: 它不能解决什么;
  4. 场景: 在 MCU/RTOS/项目哪里用;
  5. 追问: 给一个错误代码或正确替代方案。

例如 volatile

volatile 要求编译器保留对该对象必要的实际读写,常用于硬件寄存器和被 ISR 异步修改的简单标志。不加时编译器可能把循环读取缓存到寄存器,主循环看不到变化。但它不保证 ++ 原子,也不建立线程同步关系。事件传递我会优先用 FreeRTOS 的 FromISR 通知或队列;少量共享字段再根据单核、多核和访问宽度选择临界区或原子操作。


16. 与你的 ESP32-P4 项目怎样关联

只使用已经确认的事实,不补造性能数据:

  • volatile/中断: 可以从 I2S、I2C、BLE 或显示驱动的中断/回调上下文讲,重点是回调是否在 ISR、是否只通知任务;不要声称所有回调都是硬中断。
  • 任务和缓冲区: 用音频采集、PCM 帧、OPUS 集成链路解释固定容量缓冲、生产者消费者和所有权;没有测试记录时只说设计和验证方法,不编延迟数字。
  • 结构体与协议: 用 BLE 路线协议的 BEGIN/DATA/COMMIT、offset 和 CRC32 解释为什么逐字段编码,不直接发送内存结构体。
  • 定宽整数: 路线分包长度、偏移、CRC、PCM 样本和传感器原始值都要选明确位宽并检查转换。
  • 静态分配: 结合 ESP32-P4/FreeRTOS 的任务栈、音频帧和显示缓冲说明确定性;实际分配位置以代码、map 文件和芯片内存能力为准。
  • 函数指针: 可讲驱动回调和 LVGL/ESP-IDF 注册式接口,但要区分“自己实现的回调逻辑”和“第三方框架提供的机制”。
  • C++: 如果项目主体是 C,不要假装使用过 RAII/智能指针;可以回答“理解其适用性和成本,当前 ESP-IDF 项目按现有 C 组件风格开发”。

项目题进一步复习:ESP32-P4 骑行终端项目深挖面试准备


17. 七天使用计划

学习内容 必须产出
1 第 1 到 4 章:类型、表达式、关键字、限定符 脱稿回答 volatile/static/const;做 10 道看代码题
2 第 5、6 章:结构体与内存 手算 5 个结构体;画 MCU 内存分区;手写 realloc 安全用法
3 第 7、8 章:指针、数组、宏 手写数组指针、回调、MIN 陷阱、头文件接口
4 第 9、10 章:函数与 I/O 写有界解析接口;练习 printf/scanf 返回值
5 第 11 章:C++ 讲清 RAII、虚析构、重载重写、特殊成员
6 第 12、13 章:Linux 和并发 画 mmap 流程、FreeRTOS 切换、ISR 到任务数据流
7 第 14 到 16 章:手撕与项目关联 限时写环形队列、链表、二分、memmove;完整模拟一轮

算法刷题不要只刷数量。每题按“接口和边界 -> 复杂度 -> 正确代码 -> 空输入/极值测试 -> 并发是否在题目范围”完成。配套路线见:嵌入式算法刷题路线


18. 资料核对建议

技术结论优先查以下一手资料,不直接相信面经附带答案:

  • C 标准草案与 cppreference C language 页面:语法、未定义行为、标准版本差异;
  • GCC/Clang 文档:volatile、扩展、告警与优化行为;
  • SEI CERT C Coding Standard:整数、内存、字符串和未定义行为;
  • C++ Core Guidelines 与 cppreference:RAII、特殊成员、虚函数和所有权;
  • FreeRTOS 官方文档与所用架构 portable layer:调度、FromISR、PendSV、任务栈;
  • ESP-IDF 编程指南和具体芯片 TRM:中断、Cache、DMA、内存能力和驱动上下文;
  • Linux man-pages:fork(2)execve(2)mmap(2)select(2)poll(2)epoll(7)signal-safety(7)

面经的价值是帮助发现“会问什么”,不是证明“答案一定对”。凡是涉及“总是、一定、固定顺序、固定大小”的说法,都要追问:这是语言标准、ABI、编译器扩展、芯片手册,还是当前项目约定?

1