
不少刚开始准备嵌入式岗位笔试的同学都会有同一种感觉C 语言语法好像都见过但一拿到笔试题目要么不知道从哪下手要么写出来漏洞百出。嵌入式笔试和纯后端笔试不一样它更关注底层内存布局、指针操作、寄存器编程、资源受限环境下的代码风格。面试官不会问你“会不会用 Spring”但很可能让你手写一个链表反转或者分析一段 volatile 修饰的代码到底想表达什么。这篇文章围绕嵌入式笔试最常出现的 20 段 C 代码展开覆盖宏定义、位操作、指针、结构体对齐、字符串算法、链表、状态机、环形缓冲区以及一个贴近真实项目的串口报文解析综合题。每一段都会给出代码、执行结果或运行思路、易错点和笔试扩展问法。适合正在准备嵌入式软件工程师岗位、嵌入式 Linux 岗位笔试面试的同学也适合想系统巩固嵌入式 C 语言基础的在职人员。1. 为什么嵌入式笔试绕不开 C 语言代码题1.1 嵌入式开发的核心语言仍然是 C嵌入式设备的硬件资源有限MCU 的 Flash 和 RAM 往往只有几十 KB 到几 MB。C 语言编译效率高、可以直接操作寄存器地址、能精细控制内存布局因此嵌入式固件、驱动、RTOS 内核、底层 BSP 基本都是 C 语言写的。虽然在部分场景下 C 和 Rust 也在逐步进入嵌入式领域但笔试和面试考察主流仍然是 C 语言。嵌入式系统工程师日常写代码时经常要做的几类事情包括操作寄存器读写外设控制寄存器、状态寄存器。管理内存全局变量、栈、堆嵌入式环境里内存泄漏和越界问题更致命。设计数据结构通信协议栈里的队列、缓存池、链表。解决并发和中断问题volatile、临界区、原子操作。所以笔试题目不会只考 printf 输出结果而是围绕这些实际开发场景设计题目。理解这一点刷题时才会知道每一段代码对应什么工程场景。1.2 笔试考察的四种核心能力第一是语法准确度。写宏缺括号、指针运算方向搞反、结构体对齐计算错误这些都会被面试官放大成工程思维问题。第二是内存意识。数组越界、栈溢出、悬空指针在嵌入式环境里经常导致设备死机或者不可预期的重启。第三是硬件思维。大小端、寄存器位操作、volatile 与硬件状态这些是嵌入式特有的考点。第四是代码健壮性。嵌入式代码跑在无人值守的现场崩溃一次可能造成整个产线停机所以笔试中函数入口参数校验、返回值判断也很重要。理解了这四点再看下面这 20 段代码就不会觉得它们是孤立的知识点而是一套完整的嵌入式 C 工程能力拼图。2. 环境准备与编译验证方式2.1 推荐使用的实验环境笔试刷题阶段不一定需要开发板用 PC 上的编译环境就能完成大部分代码验证。比较常见的方案有两类Linux 环境Ubuntu 20.04 或 22.04安装 gcc 和 make。Windows 环境安装 MinGW-w64或者直接使用 Keil MDK、IAR EWARM 这类嵌入式 IDE也可以使用 VSCode 配置 C/C 扩展后调用 gcc 编译。本文的示例代码都基于标准 C 编写优先在主机端用 gcc 验证。比如在 Ubuntu 下写一个文件// 文件名demo.c #include stdio.h int main(void) { printf(embedded C\n); return 0; }编译命令gcc demo.c -o demo ./demo预期输出embedded C如果是在 VSCode 下练习需要先安装 C/C 扩展再配置 tasks.json 调用编译器。版本方面没有特殊要求gcc 9、gcc 11 或者更新的版本都能编译本文代码。Keil MDK 5 使用 ARMCC 或 AC6主要差别是某些编译器扩展关键字标准 C 语法部分可以通用。2.2 笔试答题时的注意事项笔试答题和平时写业务代码不同不要在细节上追求完美但要保证核心逻辑正确。建议按以下顺序写代码先写函数原型和整体骨架确保结构清晰。再补参数检查、边界条件。最后补注释和关键设计说明。每写完一个函数在心里用三个测试用例走一遍正常输入、边界输入、错误输入。下面进入代码正题建议每段代码都亲手敲一遍而不是只看不练。3. 宏与预处理笔试第一道送分题宏定义是嵌入式 C 笔试的高频考点因为嵌入式项目里大量使用宏来配置硬件参数、定义寄存器地址和开关调试信息。3.1 代码 1带参宏定义与括号陷阱先看一段很经典的代码// 代码 1带参宏定义与参数括号 #include stdio.h #define SQUARE(x) x * x #define SQUARE_SAFE(x) ((x) * (x)) int main(void) { int a 5; printf(SQUARE(a 1) %d\n, SQUARE(a 1)); printf(SQUARE_SAFE(a 1) %d\n, SQUARE_SAFE(a 1)); printf(SQUARE_SAFE(a) %d\n, SQUARE_SAFE(a)); return 0; }如果按数学直觉SQUARE(a 1) 应该是 36 吧实际运行结果是SQUARE(a 1) 11 SQUARE_SAFE(a 1) 36 SQUARE_SAFE(a) 42原因在于宏是文本替换不是函数调用。SQUARE(a 1) 展开后是 a 1 * a 1按照优先级先算乘法结果是 5 5 1 11。而 SQUARE_SAFE 把参数和整体都用括号包住展开后是 ((a 1) * (a 1))结果就是 36。后面那个 SQUARE_SAFE(a) 展开是 ((a) * (a))行为是未定义的不同编译器结果可能不同。所以带参宏不仅要给参数加括号还要避免在宏参数中传带有 、-- 或函数调用的表达式。这一点面试时经常被追问如果让你设计一个求最大值的宏怎么避免参数副作用正确写法#define MAX(a, b) ((a) (b) ? (a) : (b))但这个宏仍然无法解决 a 和 b 都带副作用的问题比如 MAX(i, j)所以需要提醒使用者不要传入带副作用的表达式。3.2 代码 2# 和 ## 操作符的应用预处理运算符 # 用于把参数转换为字符串## 用于连接两个 Token在嵌入式驱动代码里常用它生成寄存器读写函数。// 代码 2# 与 ## 的用法 #include stdio.h #define STR(x) #x #define CAT(a, b) a##b int global_counter 100; int main(void) { int sys_clock 200; printf(STR(hello) %s\n, STR(hello)); printf(CAT(sys_, clock) %d\n, CAT(sys_, clock)); return 0; }预期输出STR(hello) hello CAT(sys_, clock) 200CAT(sys_, clock) 展开后变成 sys_clock相当于定义了一个变量名。这种技巧常被用于批量生成寄存器访问函数例如#define REG_READ(reg_name) read_##reg_name()在笔试中## 不一定要求你写出复杂应用但至少要知道它的含义并能识别出“把两个标识符粘在一起”的作用。3.3 代码 3条件编译与头文件守卫头文件守卫几乎是所有嵌入式工程的头文件标配笔试中可能要求你写一个头文件的标准结构。// 代码 3头文件守卫与条件编译 #ifndef _BSP_UART_H_ #define _BSP_UART_H_ #ifdef __cplusplus extern C { #endif #define UART_BAUDRATE 115200 void bsp_uart_init(void); int bsp_uart_send(const char *data, unsigned int len); #ifdef __cplusplus } #endif #endif /* _BSP_UART_H_ */这段代码解决两个问题第一防止头文件被重复包含否则可能出现重定义错误第二如果这个头文件要被 C 代码引用extern C 能保证 C 编译器按照 C 语言的方式处理符号避免链接时找不到函数。在嵌入式项目中条件编译还常用于调试开关和硬件平台适配例如#if defined(STM32F103) #define FLASH_PAGE_SIZE 2048 #elif defined(STM32F407) #define FLASH_PAGE_SIZE 16384 #else #error Unsupported MCU! #endif笔试中看到 #if 和 #ifdef 要能区分前者判断一个常量表达式的值后者判断一个宏是否被定义。4. 位操作寄存器编程的基本功嵌入式开发不可能离开位操作。一个 32 位寄存器里可能同时存放使能位、状态位、数据位只能通过位运算去修改特定比特不影响其他位。4.1 代码 4置位、清零、翻转与提取先看一组最常用的寄存器操作宏// 代码 4常用位操作宏 #include stdio.h #define BIT(n) (1UL (n)) #define BIT_SET(reg, n) ((reg) | BIT(n)) #define BIT_CLEAR(reg, n) ((reg) ~BIT(n)) #define BIT_TOGGLE(reg, n) ((reg) ^ BIT(n)) #define BIT_READ(reg, n) (((reg) (n)) 0x01) int main(void) { unsigned int reg 0x00; BIT_SET(reg, 3); printf(after set bit3: 0x%x\n, reg); BIT_CLEAR(reg, 3); printf(after clear bit3: 0x%x\n, reg); BIT_TOGGLE(reg, 5); printf(after toggle bit5: 0x%x\n, reg); printf(bit5 value: %d\n, BIT_READ(reg, 5)); return 0; }预期输出after set bit3: 0x8 after clear bit3: 0x0 after toggle bit5: 0x20 bit5 value: 1这些宏在笔试中经常被要求现场写看起来简单但容易出错的地方在于清位操作一定要先取反再与不能写成 reg ~(1 n) 但中间漏掉括号置位使用 |清位使用 翻转使用 ^判断某一位使用 后比较是否为 0。嵌入式笔试还经常考“把一个寄存器第 m 位到第 n 位写成指定值”这类操作要多做一步先清位段再写入。#define BITMASK(start, end) \ (((~0UL) (start)) (~0UL (31 - (end))))不过上面这个宏默认寄存器是 32 位移植到不同位宽平台时需要调整笔试中写清楚思路即可。4.2 代码 5判断大小端大小端是嵌入式笔试的常客因为 ARM、RISC-V、DSP 等不同内核可能使用不同字节序串口和网络协议通常又规定为大端所以设备间通信经常要做字节序转换。最经典的写法是利用联合体// 代码 5联合体判断大小端 #include stdio.h typedef union { unsigned int word; unsigned char bytes[4]; } endian_test_t; int is_little_endian(void) { endian_test_t t; t.word 0x12345678; return (t.bytes[0] 0x78); } int main(void) { if (is_little_endian()) { printf(little endian\n); } else { printf(big endian\n); } return 0; }在 x86 和常见的 ARM Cortex-M 平台上输出是 little endian。因为 0x12345678 的低字节 0x78 会被存放在内存的低地址联合体第一个成员 bytes[0] 访问的就是低地址字节。同样也可以用指针方式判断unsigned int a 0x12345678; unsigned char *p (unsigned char *)a;用联合体写更直观而且不需要强制类型转换的语法解释笔试推荐优先写联合体版本。面试官还可能追问为什么要在多个平台之间关心大小端因为如果设备 A 是小端设备 B 是大端直接通过串口发送结构体双方解析同一段二进制数据时就会得到错误的字段值。所以通信协议要么规定统一字节序要么在收发时做转换。5. 指针、数组与内存易错题重灾区指针是 C 语言的灵魂也是嵌入式笔试丢分的重灾区。很多同学能写出代码但一旦考到指针类型、指针步长、函数指针就会犹豫。5.1 代码 6指针数组与数组指针辨析这两个概念名称非常像含义却完全不同。// 代码 6指针数组与数组指针 #include stdio.h int main(void) { int a 1; int b 2; int c 3; int *arr[3] {a, b, c}; // 指针数组数组的每个元素是 int* int (*ptr)[3]; // 数组指针指向一个含有 3 个 int 元素的数组 int matrix[2][3] { {10, 20, 30}, {40, 50, 60} }; printf(arr[1] %d\n, *arr[1]); ptr matrix; printf((*ptr)[1] %d\n, (*ptr)[1]); printf(*(*(ptr 1) 2) %d\n, *(*(ptr 1) 2)); return 0; }预期输出arr[1] 2 (*ptr)[1] 20 *(*(ptr 1) 2) 60指针数组的标识是int *arr[3][] 优先级高于所以 arr 先和 [3] 结合是一个数组数组元素是 int。数组指针的标识是int (*ptr)[3]ptr 先和 * 结合是一个指针指向一个长度为 3 的 int 数组。笔试中判断这类声明有一个技巧从变量名开始先看它和谁结合。和 [] 结合就是数组和 * 结合就是指针。5.2 代码 7指针运算与数组访问指针加 1 移动的字节数取决于指针指向的类型这个知识点非常基础但非常容易错。// 代码 7指针步长 #include stdio.h int main(void) { int arr[4] {0, 10, 20, 30}; int *p arr; printf(p %p\n, (void *)p); printf(p 1 %p\n, (void *)(p 1)); printf(*(p 3) %d\n, *(p 3)); return 0; }在 32 位平台上int 占 4 字节所以 p 和 p 1 的地址值相差 4。*(p 3) 等价于 arr[3]输出 30。在嵌入式笔试中还经常出现这种题目定义一个结构体指针让它指向一个寄存器地址然后用指针加偏移访问寄存器。这背后的逻辑就是指针步长。比如typedef struct { unsigned int CR; unsigned int SR; unsigned int DR; } uart_reg_t; #define UART_REG ((uart_reg_t *)0x40004400) UART_REG-SR 0x20;这里结构体指针加 1在内存里实际偏移的是整个结构体大小而不是 1 字节。理解指针步长后才能理解寄存器映射和驱动代码。5.3 代码 8函数指针与回调机制函数指针在嵌入式里最常见的应用是中断回调、协议解析回调、菜单结构和 RTOS 任务表。// 代码 8函数指针实现回调 #include stdio.h typedef int (*calc_func_t)(int, int); int add(int a, int b) { return a b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int calc(int a, int b, calc_func_t func) { return func(a, b); } int main(void) { printf(add: %d\n, calc(10, 5, add)); printf(sub: %d\n, calc(10, 5, sub)); printf(mul: %d\n, calc(10, 5, mul)); return 0; }预期输出add: 15 sub: 5 mul: 50这里的 calc 函数接收一个函数指针作为参数调用者决定传入哪个函数从而实现类似策略模式的效果。在 UART 驱动中收到一帧数据后可以通过回调把数据交给上层处理在按键驱动中不同按键事件可以注册不同回调函数。笔试写函数指针时关键是定义别名的语法不要写错typedef int (*calc_func_t)(int, int);注意 * 要和函数名一起被括号包住。如果不加括号int *func(int, int)就变成了一个返回 int* 的函数声明。5.4 代码 9const 与 volatile 的修饰对象这两个关键字单独考很简单放在一起就容易懵。// 代码 9const 和 volatile 的结合使用 #include stdio.h int main(void) { int x 10; int y 20; const int *p1 x; // 指向 const int 的指针不能通过 p1 改 *p1 int * const p2 x; // const 指针p2 本身不能改指向 p1 y; // 合法p1 可以指向其他变量 // *p1 30; // 不合法不能通过 p1 修改指向的值 // p2 y; // 不合法p2 是 const 指针 *p2 30; // 合法可以通过 p2 修改所指变量的值 printf(%d %d\n, x, *p1); return 0; }笔试中更常考的是 volatile 的真实使用场景。volatile 告诉编译器这个变量的值可能在当前程序流程之外被改变不要对它做优化缓存。典型场景有三个硬件寄存器映射比如状态寄存器。中断服务程序里修改的全局变量。RTOS 中多个任务共享的变量。例如volatile unsigned int g_tick_count 0; void SysTick_Handler(void) { g_tick_count; }如果 g_tick_count 没有加 volatile编译器在 main 函数里读取它时可能直接使用寄存器里的缓存值导致任务调度或延时判断不准确。const 和 volatile 可以同时使用比如只读硬件状态寄存器可以声明为const volatile unsigned int *p_status_reg (volatile unsigned int *)0x40001000;从软件层面不允许写这个寄存器从硬件层面它的值可能随时变化两个关键字并不冲突。5.5 代码 10字符串安全的拷贝实现嵌入式笔试经常让手写字符串拷贝并且会追问如果目标缓冲区不够大你的代码会不会造成缓冲区溢出// 代码 10安全和带长度的字符串拷贝 #include stdio.h char *safe_strcpy(char *dest, const char *src, unsigned int dest_size) { unsigned int i 0; if (dest NULL || src NULL || dest_size 0) { return NULL; } for (i 0; i dest_size - 1 src[i] ! \0; i) { dest[i] src[i]; } dest[i] \0; return dest; } int main(void) { char buf[8]; const char *msg embedded system; safe_strcpy(buf, msg, sizeof(buf)); printf(buf %s\n, buf); return 0; }预期输出buf embedde这段代码的优点是不会造成越界。它最多往 dest 里写入 dest_size - 1 个字符并保证最后补上字符串结束符。这在实际嵌入式通信解析中很重要比如从串口缓冲区拷贝一帧数据到用户缓冲区不能用不带大小的 strcpy否则一旦数据长度大于缓冲区就会出现内存破坏。笔试优化点可以不需要 i 和 目标长度两步判断写成更简洁的形式但面试官更看重的是你有没有检查 src、dest 是否为空以及有没有处理缓冲区边界。6. 结构体、内存对齐与联合体6.1 代码 11结构体大小计算与字节对齐结构体对齐是嵌入式笔试必考题因为它直接关系底层通信协议解析和内存占用。// 代码 11结构体对齐默认情况 #include stdio.h typedef struct { char a; int b; char c; } msg_t; typedef struct { char a; char c; int b; } msg_packed_t; int main(void) { printf(sizeof(msg_t) %lu\n, sizeof(msg_t)); printf(sizeof(msg_packed_t) %lu\n, sizeof(msg_packed_t)); return 0; }在 32 位平台上int 默认按 4 字节对齐char 占 1 字节。msg_t 的成员顺序是 char、int、char编译器会在第一个 char 之后填充 3 个字节让 b 对齐到 4 字节地址最后再补 3 个字节使结构体总大小是最大对齐数的整数倍所以结果是 12。而 msg_packed_t 把两个 char 放前面int 放最后总大小是 8。对于嵌入式通信协议来说如果上位机和下位机使用结构体直接收发数据必须保证双方的内存布局一致。通常做法有两种使用#pragma pack(1)取消对齐。在结构体成员之间手动填充。#pragma pack(1) typedef struct { char head; unsigned char len; unsigned short crc; } frame_header_t; #pragma pack()但注意取消对齐后结构体成员的访问可能变慢在某些不支持非对齐访问的 MCU 上甚至触发硬件异常所以通信协议解析也不一定非要让结构体对齐为 1关键是通信双方约定一致。6.2 代码 12联合体实现数据拆分联合体和结构体的区别在于结构体成员各自占用独立内存联合体成员共享同一块内存。嵌入式里经常用联合体实现“一个变量多种解读方式”。// 代码 12联合体拆分 4 字节数据 #include stdio.h typedef union { unsigned int value; unsigned char bytes[4]; } data32_t; int main(void) { data32_t d; d.value 0x12345678; printf(value 0x%08x\n, d.value); printf(bytes[0] 0x%02x\n, d.bytes[0]); printf(bytes[1] 0x%02x\n, d.bytes[1]); printf(bytes[2] 0x%02x\n, d.bytes[2]); printf(bytes[3] 0x%02x\n, d.bytes[3]); return 0; }在小端平台上输出value 0x12345678 bytes[0] 0x78 bytes[1] 0x56 bytes[2] 0x34 bytes[3] 0x12这种写法在解析通信协议时非常常见。比如收到 4 字节的 int 型传感器数据可以用联合体直接取出低字节和高字节进行拼接、显示或打包发送。联合体笔试还有一个高频考点计算联合体大小。联合体的大小等于最大成员的大小且同样遵循对齐规则。7. 基础算法笔试代码题常驻嘉宾嵌入式笔试的算法题不像互联网大厂那么难一般集中在字符串处理、排序、查找、链表操作。但这些题目必须写得又快又对。7.1 代码 13字符串逆序字符串逆序在很多笔试平台出现过比如 PTA 上的“字符串逆序输出 C”题目本质都是同一个问题。// 代码 13字符串原地逆序 #include stdio.h #include string.h void reverse_str(char *s) { int left 0; int right 0; char tmp; if (s NULL) { return; } right (int)strlen(s) - 1; while (left right) { tmp s[left]; s[left] s[right]; s[right] tmp; left; right--; } } int main(void) { char str[] hello; reverse_str(str); printf(%s\n, str); return 0; }预期输出olleh笔试时要注意不能把 main 里的字符串写成char *str hello再传给 reverse_str因为字符串常量存放在只读区修改它会导致段错误。应该写成字符数组char str[] hello让它申请可写的栈内存。这个问题面试官经常故意埋坑。7.2 代码 14判断回文串回文判断可以不用额外空间直接双指针从两端往中间扫描。// 代码 14判断回文串 #include stdio.h #include string.h int is_palindrome(const char *s) { int left 0; int right 0; if (s NULL) { return 0; } right (int)strlen(s) - 1; while (left right) { if (s[left] ! s[right]) { return 0; } left; right--; } return 1; } int main(void) { printf(abba: %d\n, is_palindrome(abba)); printf(abc: %d\n, is_palindrome(abc)); return 0; }预期输出abba: 1 abc: 0这个代码的复杂度是 O(n)空间复杂度 O(1)。嵌入式笔试中如果面试官让优化一般就是往这个方向引导。7.3 代码 15二分查找二分查找适合有序数组嵌入式里常用于查找配置表、参数表、密钥表。// 代码 15有序数组二分查找 #include stdio.h int binary_search(int *arr, int n, int target) { int left 0; int right n - 1; int mid 0; if (arr NULL || n 0) { return -1; } while (left right) { mid left (right - left) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { left mid 1; } else { right mid - 1; } } return -1; } int main(void) { int arr[] {1, 3, 5, 7, 9, 11}; int index binary_search(arr, 6, 7); printf(target index %d\n, index); return 0; }预期输出target index 3这里有两个细节值得注意mid 的计算使用left (right - left) / 2避免 left right 直接溢出虽然是嵌入式笔试但写出这种细节会加分循环条件是left right如果写成会漏掉区间只剩一个元素的场景。7.4 代码 16冒泡排序冒泡排序虽然效率不高但嵌入式笔试可能要求现场实现并且要能说明优化点。// 代码 16带优化标志的冒泡排序 #include stdio.h void bubble_sort(int *arr, int n) { int i 0; int j 0; int tmp; int swapped; if (arr NULL || n 0) { return; } for (i 0; i n - 1; i) { swapped 0; for (j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { tmp arr[j]; arr[j] arr[j 1]; arr[j 1] tmp; swapped 1; } } if (swapped 0) { break; } } } int main(void) { int arr[] {5, 2, 9, 1, 5, 6}; int i; bubble_sort(arr, 6); for (i 0; i 6; i) { printf(%d , arr[i]); } printf(\n); return 0; }预期输出1 2 5 5 6 9优化点在于 swapped 标志如果某一轮没有任何交换说明数组已经有序直接跳出循环。在资源受限的嵌入式设备中这种提前结束能减少不必要的 CPU 开销。8. 链表手写代码题的分水岭链表在嵌入式笔试中的出现频率非常高因为 MCU 上常用链表管理动态内存块、任务控制块和消息队列。笔试通常要求写单链表的创建、遍历、插入、删除。8.1 代码 17单链表节点创建与遍历先定义一个简单节点结构// 代码 17单链表的创建与遍历 #include stdio.h #include stdlib.h typedef struct node { int data; struct node *next; } node_t; node_t *create_node(int data) { node_t *n (node_t *)malloc(sizeof(node_t)); if (n NULL) { return NULL; } n-data data; n-next NULL; return n; } void print_list(node_t *head) { node_t *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); } int main(void) { node_t *head NULL; node_t *n1 create_node(10); node_t *n2 create_node(20); node_t *n3 create_node(30); head n1; n1-next n2; n2-next n3; print_list(head); return 0; }预期输出10 - 20 - 30 - NULL笔试中要注意 create_node 之后是否检查 malloc 返回值。尤其嵌入式设备内存本来就小malloc 失败是真实存在的情况不检查会导致空指针解引用设备死机。8.2 代码 18链表头插法与删除指定节点头插法常用来实现链表的逆序构建删除节点则需要处理“删除的是头节点”和“删除的是中间节点”两种情况。// 代码 18头插法插入与删除指定节点 #include stdio.h #include stdlib.h typedef struct node { int data; struct node *next; } node_t; node_t *push_front(node_t *head, int data) { node_t *n (node_t *)malloc(sizeof(node_t)); if (n NULL) { return head; } n-data data; n-next head; return n; } node_t *delete_node(node_t *head, int target) { node_t *cur head; node_t *prev NULL; while (cur ! NULL) { if (cur-data target) { if (prev NULL) { head cur-next; } else { prev-next cur-next; } free(cur); return head; } prev cur; cur cur-next; } return head; } void print_list(node_t *head) { node_t *cur head; while (cur ! NULL) { printf(%d - , cur-data); cur cur-next; } printf(NULL\n); } int main(void) { node_t *head NULL; head push_front(head, 10); head push_front(head, 20); head push_front(head, 30); printf(after push front:\n); print_list(head); head delete_node(head, 20); printf(after delete 20:\n); print_list(head); return 0; }预期输出after push front: 30 - 20 - 10 - NULL after delete 20: 30 - 10 - NULL删除节点的核心是记录 prev。笔试中如果要求 O(1) 时间删除“只知道当前节点指针”的节点可以使用“狸猫换太子”的思路把下一个节点的值拷贝到当前节点然后删掉下一个节点。不过在嵌入式笔试中更常见的还是这种带头或不带头的双指针遍历写法。9. 嵌入式特色代码状态机、环形缓冲区与综合题9.1 代码 19按键状态机按键消抖、短按、长按、双击都可以用状态机实现。状态机非常适合嵌入式因为它把复杂的时序逻辑拆成“状态 事件 动作”代码可读性和可维护性都很好。下面是一个简单按键状态机// 代码 19按键扫描状态机 #include stdio.h typedef enum { KEY_STATE_IDLE 0, KEY_STATE_PRESSED, KEY_STATE_RELEASED, } key_state_t; typedef enum { KEY_EVENT_NONE 0, KEY_EVENT_PRESS, KEY_EVENT_RELEASE, } key_event_t; int key_read_hw(void) { static int cnt 0; cnt; /* 前 3 次模拟按下之后模拟释放 */ return (cnt 3) ? 1 : 0; } key_event_t key_scan(key_state_t *state) { key_event_t event KEY_EVENT_NONE; int level key_read_hw(); switch (*state) { case KEY_STATE_IDLE: if (level 1) { *state KEY_STATE_PRESSED; event KEY_EVENT_PRESS; } break; case KEY_STATE_PRESSED: if (level 0) { *state KEY_STATE_RELEASED; event KEY_EVENT_RELEASE; } break; case KEY_STATE_RELEASED: if (level 1) { *state KEY_STATE_PRESSED; event KEY_EVENT_PRESS; } else { *state KEY_STATE_IDLE; } break; default: *state KEY_STATE_IDLE; break; } return event; } int main(void) { key_state_t state KEY_STATE_IDLE; int i; for (i 0; i 10; i) { key_event_t ev key_scan(state); if (ev KEY_EVENT_PRESS) { printf(event: press\n); } else if (ev KEY_EVENT_RELEASE) { printf(event: release\n); } } return 0; }预期输出event: press event: release event: press event: release真实项目中key_read_hw 会读取 GPIO 引脚并做滤波状态还应考虑消抖时间但笔试阶段写出这个结构已经能证明你理解状态机思想。状态机的另一个典型应用是通信协议解析比如 UART 收包时根据当前状态决定是解析帧头、长度、数据还是校验这比用一堆 if 嵌套更清晰也更不容易漏状态。9.2 代码 20环形缓冲区FIFO环形缓冲区在嵌入式里太常见了。串口接收中断把数据放入 FIFO主循环可以从 FIFO 取出完整报文读写双方不需要全局数组的互斥锁只要读写位置控制得当。// 代码 20环形缓冲区实现 #include stdio.h #include string.h #define RING_BUF_SIZE 8 typedef struct { unsigned char buf[RING_BUF_SIZE]; unsigned int head; unsigned int tail; } ring_buf_t; void ring_init(ring_buf_t *rb) { memset(rb, 0, sizeof(ring_buf_t)); } int ring_is_empty(ring_buf_t *rb) { return (rb-head rb-tail); } int ring_is_full(ring_buf_t *rb) { return ((rb-head 1) % RING_BUF_SIZE rb-tail); } int ring_write(ring_buf_t *rb, unsigned char data) { if (ring_is_full(rb)) { return -1; } rb-buf[rb-head] data; rb-head (rb-head 1) % RING_BUF_SIZE; return 0; } int ring_read(ring_buf_t *rb, unsigned char *data) { if (ring_is_empty(rb)) { return -1; } *data rb-buf[rb-tail]; rb-tail (rb-tail 1) % RING_BUF_SIZE; return 0; } int main(void) { ring_buf_t rb; unsigned char data; int i; ring_init(rb); for (i 0; i 6; i) { if (ring_write(rb, A i) 0) { printf(write %c\n, A i); } } while (ring_read(rb, data) 0) { printf(read %c\n, data); } return 0; }预期输出write A write B write C write D write E write F read A read B read C read D read E read F这个实现把环形缓冲区容量定义为 RING_BUF_SIZE实际最多存放 RING_BUF_SIZE - 1 个字节因为需要一个位置来区分“空”和“满”head tail 表示空head 1 tail 表示满。笔试中如果被问到“如何让缓冲区存满”常见方案是增加一个 count 字段或者用一个标志位记录最后一个操作是读还是写。这个扩展点在项目里也很有价值。9.3 综合题串口报文解析最后用一个串口报文解析综合题把前面这些知识点串起来。假设协议格式为帧头 0xAA、长度 len、数据体、校验和从长度到数据体最后一个字节的累加和。// 代码 21串口报文接收状态机 #include stdio.h #include string.h #define FRAME_HEAD 0xAA #define MAX_FRAME_LEN 16 typedef enum { PARSE_WAIT_HEAD 0, PARSE_WAIT_LEN, PARSE_WAIT_DATA, PARSE_WAIT_CRC, } parse_state_t; typedef struct { unsigned char buf[MAX_FRAME_LEN]; unsigned char len; unsigned char count; unsigned char sum; parse_state_t state; } parser_t; void parser_init(parser_t *p) { memset(p, 0, sizeof(parser_t)); p-state PARSE_WAIT_HEAD; } int parser_push(parser_t *p, unsigned char byte) { int frame_done 0; switch (p-state) { case PARSE_WAIT_HEAD: if (byte FRAME_HEAD) { p-state PARSE_WAIT_LEN; p-sum 0; p-count 0; } break; case PARSE_WAIT_LEN: if (byte MAX_FRAME_LEN) { p-state PARSE_WAIT_HEAD; } else { p-len byte; p-sum byte; p-state PARSE_WAIT_DATA; } break; case PARSE_WAIT_DATA: p-buf[p-count] byte; p-sum byte; if (p-count p-len) { p-state PARSE_WAIT_CRC; } break; case PARSE_WAIT_CRC: if (p-sum byte) { frame_done 1; printf(frame ok, len%d, payload[0]0x%02x\n, (int)p-len, (unsigned int)p-buf[0]); } else { printf(crc error, expect 0x%02x, actual 0x%02x\n, (unsigned int)p-sum, (unsigned int)byte); } p-state PARSE_WAIT_HEAD; break; default: p-state PARSE_WAIT_HEAD; break; } return frame_done; } int main(void) { parser_t parser; unsigned char frame[] { 0xAA, /* 帧头 */ 0x02, /* 长度表示数据体有 2 个字节 */ 0x11, 0x22, /* 数据体 */ (0x02 0x11 0x22) /* 校验和 */ }; int i; parser_init(parser); for (i 0; i (int)sizeof(frame); i) { parser_push(parser, frame[i]); } return 0; }预期输出frame ok, len2, payload[0]0x11这段综合代码用到了枚举状态、结构体、数组、指针、位运算和函数模块化思想几乎覆盖了嵌入式笔试最核心的内容面。实际项目里parser_push 应该由串口接收中断的数据逐字节调用收到完整帧后置标志位主循环再处理 frame 数据。10. 常见错误与排查思路笔试和实际调试中下面这些问题出现的频率最高。问题现象常见原因解决思路程序运行时报段错误空指针解引用、数组越界、字符串常量被修改先检查指针是否为空再检查数组边界使用 gdb 或地址回溯定位宏计算结果和预期不符宏参数没有加括号优先级错误给宏的每个参数和整体都加括号传参数时避免 、--结构体大小和通信协议不一致字节对齐导致成员之间产生填充字节使用 #pragma pack(1) 或定义明确的二进制协议volatile 变量读取值不变编译器优化寄存器缓存了变量给共享变量加 volatile必要时使用内存屏障链表删除后程序崩溃删除节点后没有更新前驱节点的 next画链表结构图确认 prev、cur、head 的更新关系串口接收数据偶尔丢字节缓冲区太小或读取不及时使用环形缓冲区适当增大缓冲区合理设计中断和主循环处理节奏malloc 之后程序不稳定嵌入式环境堆内存不足或内存碎片尽量使用静态内存池避免频繁 malloc/free排查顺序建议是先看复现条件再检查输入数据和边界条件最后分析内存布局和编译器优化。笔试阶段如果遇到代码运行结果和预期不一致也可以按这个顺序重新读一遍自己的代码。11. 嵌入式 C 代码的最佳实践11.1 命名与可读性嵌入式代码往往需要多人长期维护命名清晰比代码简洁更重要。全局变量使用 g_ 前缀静态变量使用 s_ 前缀宏定义使用全大写类型定义以 _t 结尾。这些规范不是强制标准但能让代码在笔试和工程评审中都更容易被理解。11.2 内存安全和边界防护任何从外部输入的函数都要检查参数有效性。处理缓冲区时优先使用带长度参数的函数数组下标在使用前确认范围malloc 之后必须检查返回值free 之后将指针置为 NULL避免野指针。11.3 防御式编程函数入口先做参数合法性检查返回值统一规范例如 0 表示成功负数表示错误码。在调试阶段可以使用断言#define ASSERT(expr) \ do { \ if (!(expr)) { \ printf(assert failed: %s %d\n, __FILE__, __LINE__); \ while (1); \ } \ } while (0)在产品发布版本中可以关掉断言但要保证核心逻辑仍然有运行时错误处理。11.4 硬件相关代码要隔离寄存器操作、中断服务函数、启动代码尽量放在独立模块中不要让业务代码直接散落各种寄存器地址。这样既方便移植到不同芯片也方便笔试中展示自己的工程分层能力。12. 笔试前如何高效利用这 20 段代码下面结合本文的 20 段代码给出一份“笔试前一至两周”的练习路径。第一轮搭建环境把 1 到 5 的代码全部在本地编译运行。重点关注宏展开、位操作、大小端判断。第二轮集中练习指针和内存相关代码也就是第 6 到 10 段。可以在纸上写出每一个表达式的类型和值再上机验证。第三轮刷链表和算法题第 15 到 18 段要能默写建议限定时间比如 15 分钟内独立写出链表的创建、遍历和删除。第四轮重点掌握状态机和环形缓冲区因为这是嵌入式项目和笔试之间的桥梁理解了这两段很多项目题会变得不再陌生。最后一到两天可以围绕第 21 段串口解析综合题复盘整个代码结构想清楚如果面试官要求增加一个帧头、增加 CRC16、或者改成 DMA 接收自己应该怎么改。这样面试时就不只是背代码而是展示真正的嵌入式思维。如需原文链接或配套工程文件可以收藏本文后按章节逐步练习。预祝准备嵌入式岗位笔试的朋友顺利拿到心仪 offer。