兄弟们,最近是不是又开始刷嵌入式笔试真题了?
很多读者跟我反馈,说嵌入式笔试题目看着不难,但一做就错,尤其是C语言相关的选择题、改错题和编程题,好像每个考点都见过,但每次踩坑的都是同一个地方。究其原因,还是对C语言在嵌入式场景中的“特殊用法”和“常见陷阱”缺少系统梳理。
这篇文章我整理了20 段笔试中出场率极高的 C 语言代码,覆盖数据类型、指针、结构体、位操作、内存管理、链表、状态机、中断等嵌入式笔试高频考点。每一段都会给出完整代码、运行结果、考点分析,以及面试官想听到的回答方向。
不管你是正在准备校招、跳槽,还是想扎实补一补嵌入式C语言基础,这篇文章都值得收藏后反复看。
1. 嵌入式笔试到底在考什么
先别急着看代码,我们先用 30 秒把方向理清楚。
嵌入式岗位的笔试题目,和纯后端、纯算法岗位有明显区别。嵌入式笔试更看重以下几类能力:
- 底层硬件意识:内存、寄存器、地址、位操作、大小端。
- C语言基本功:指针、数组、结构体、宏定义、类型转换。
- 资源受限思维:栈空间、堆空间、内存对齐、代码体积。
- 并发与中断意识:volatile、static、回调、重入。
- 调试能力:段错误、内存泄漏、栈溢出、野指针。
所以你会发现,嵌入式笔试中的 C 语言题目,往往不是单纯考语法,而是把语法放到“单片机、Linux、驱动、RTOS”这些场景里考。
接下来我们直接进入正题。我按照考点类型,把这 20 段代码分成了 8 组,方便你按需复习。
2. 环境准备:本地复现这些代码需要什么
在开始之前,先说说运行环境。这些代码主要是标准 C 语言,绝大部分不依赖特定硬件,可以直接在 PC 上编译运行。
推荐工具:
- Windows:安装 MinGW-w64 或 Visual Studio,或者直接用 VS Code 搭配 C/C++ 插件。
- Linux:系统自带 gcc,直接命令行编译。
- 在线编译器:如果只是快速验证,可以用在线 C 编译器。
编译命令示例:
gcc -Wall -O0 -o test test.c ./test注意:笔试复习阶段,强烈建议开-Wall编译选项,把所有警告都当成提示信息看一遍。很多嵌入式笔试题目,考察的就是“哪一行会产生警告/错误”。
3. 第一组:数据类型与溢出陷阱
3.1 无符号数与有符号数的隐式转换
#include <stdio.h> int main(void) { unsigned int a = 10; int b = -20; if (a + b > 0) { printf("a + b > 0, value = %u\n", a + b); } else { printf("a + b <= 0\n"); } printf("a + b = %u\n", a + b); return 0; }输出结果:
a + b > 0, value = 4294967286 a + b = 4294967286考点分析:
- 当有符号数和无符号数混合运算时,有符号数会被隐式转换为无符号数。
-20转换成无符号数是一个很大的正整数,所以a + b结果大于 0。- 实际公式:
-20对应无符号数为2^32 - 20 = 4294967276,再加10得到4294967286。
嵌入式场景:
- 在判断传感器数据、定时器计数值、通信协议字段时,如果无符号和有符号混用,非常容易出这种问题。例如:
unsigned char len = 0; if (len - 1 > 0) { // 错误示例:永远为真 // ... }因为len - 1会发生整型提升,0 - 1变成-1,然后转成无符号255。
3.2 数据溢出与截断
#include <stdio.h> int main(void) { unsigned char c = 255; c = c + 1; printf("c = %d\n", c); // 期望多少?实际多少? char d = 127; d = d + 1; printf("d = %d\n", d); // 有符号char溢出 return 0; }输出结果:
c = 0 d = -128考点分析:
- 无符号 char 溢出后回绕到 0。
- 有符号 char 溢出属于未定义行为,但绝大多数平台上是回绕到最小值。
- 嵌入式开发中,控制变量、循环变量、通信计数变量都可能发生溢出,需要根据数据范围选择合适的类型。
这类问题经常在笔试中以“输出结果题”出现,答题时一定要先确定变量类型和取值范围。
4. 第二组:运算符优先级与求值顺序
4.1 经典运算符优先级题
#include <stdio.h> int main(void) { int a = 1, b = 2, c = 3; int result = a++ + ++b * c--; printf("a = %d, b = %d, c = %d, result = %d\n", a, b, c, result); return 0; }输出结果:
a = 2, b = 3, c = 2, result = 10拆解过程:
- 后缀自增
a++:表达式用旧值 1,之后 a 变为 2。 - 前缀自增
++b:b 先变为 3,表达式值为 3。 - 后缀自减
c--:表达式用旧值 3,之后 c 变为 2。 - 先算乘法
3 * 3 = 9,再算加法1 + 9 = 10。
考点分析:
- 注意区分“表达式的值”和“变量最终的值”。
- 乘除优先于加减,自增自减优先级很高。
- 笔试中常扩展考察
a++ + ++a这类代码,实际上同一变量在一个表达式中被多次修改,属于未定义行为,不要这样写。
4.2 短路求值
#include <stdio.h> int main(void) { int x = 0; int y = 0; if (x++ && y++) { // 不会执行到这里 } printf("x = %d, y = %d\n", x, y); int m = 1; int n = 0; if (m++ || n++) { // 会执行到这里 } printf("m = %d, n = %d\n", m, n); return 0; }输出结果:
x = 1, y = 0 m = 2, n = 0考点分析:
&&左侧为假时,右侧不执行。||左侧为真时,右侧不执行。- 这就是短路求值。
- 嵌入式代码中的典型应用:先判断指针是否为 NULL,再解引用。例如:
if (p != NULL && p->len > 0) { // 安全访问 }如果先访问p->len再判断p,当p为 NULL 时就可能触发段错误。
5. 第三组:指针、数组与指针运算
5.1 指针与数组的关系
#include <stdio.h> int main(void) { int arr[5] = {10, 20, 30, 40, 50}; int *p = arr; printf("arr[2] = %d\n", arr[2]); printf("*(p + 2) = %d\n", *(p + 2)); printf("p[2] = %d\n", p[2]); printf("2[arr] = %d\n", 2[arr]); printf("sizeof(arr) = %d\n", (int)sizeof(arr)); printf("sizeof(p) = %d\n", (int)sizeof(p)); return 0; }输出结果:
arr[2] = 30 *(p + 2) = 30 p[2] = 30 2[arr] = 30 sizeof(arr) = 20 sizeof(p) = 8考点分析:
- 数组名在表达式中会退化为指向首元素的指针,但
sizeof是例外。 p[2]和2[arr]都能访问数组元素,因为a[i]本质上就是*(a + i)。sizeof(arr)是整个数组的字节数,sizeof(p)是指针的大小,在 64 位平台上通常是 8 字节。
嵌入式场景:
- 串口接收数组、ADC 采样数组、图像数据缓冲区,本质上都是指针操作的延伸。
- 笔试必问题:数组名和指针的区别?答案就是上面代码中
sizeof表现出的差异。
5.2 二维数组与指针访问
#include <stdio.h> int main(void) { int matrix[2][3] = { {1, 2, 3}, {4, 5, 6} }; printf("matrix[1][2] = %d\n", matrix[1][2]); printf("*(*(matrix + 1) + 2) = %d\n", *(*(matrix + 1) + 2)); int *p = &matrix[0][0]; printf("通过线性指针访问 matrix[1][2] = %d\n", *(p + 1 * 3 + 2)); return 0; }输出结果:
matrix[1][2] = 6 *(*(matrix + 1) + 2) = 6 通过线性指针访问 matrix[1][2] = 6考点分析:
- 二维数组名是“指向数组的指针”,
matrix + 1跳过一整行。 - 在嵌入式代码中,图像处理、矩阵运算、按键扫描表都常用二维数组。
- 使用线性指针访问时,需要自己计算偏移量。
这种方式在“把二维数组传给函数”的时候很实用,尤其是当行数和列数需要作为参数时。
6. 第四组:结构体、联合体与内存对齐
6.1 struct 内存对齐计算
#include <stdio.h> #pragma pack(push, 1) typedef struct { char a; int b; char c; } PackedStruct; #pragma pack(pop) typedef struct { char a; int b; char c; } NormalStruct; int main(void) { printf("NormalStruct size = %d\n", (int)sizeof(NormalStruct)); printf("PackedStruct size = %d\n", (int)sizeof(PackedStruct)); printf("NormalStruct a offset = %d\n", (int)offsetof(NormalStruct, a)); printf("NormalStruct b offset = %d\n", (int)offsetof(NormalStruct, b)); printf("NormalStruct c offset = %d\n", (int)offsetof(NormalStruct, c)); return 0; }输出结果:
NormalStruct size = 12 PackedStruct size = 6 NormalStruct a offset = 0 NormalStruct b offset = 4 NormalStruct c offset = 8考点分析:
- 默认对齐规则下,
int需要 4 字节对齐,所以char a后面会填充 3 个字节。 #pragma pack(1)可以取消对齐,但会导致非对齐访问,在部分 MCU 上会增加访问开销甚至触发硬件异常。- 嵌入式协议解析中,很多人喜欢用
#pragma pack(1)直接把结构体映射到协议帧,这对于 x86 上位机可行,但对于 ARM Cortex-M 某些场景要谨慎。
笔试经常反过来考:给定结构体定义,计算大小。做题时按“每个成员偏移量必须整除自身对齐值”来算。
6.2 union 的内存共享特性
#include <stdio.h> typedef union { unsigned int value; unsigned char bytes[4]; } DataUnion; int main(void) { DataUnion u; u.value = 0x12345678; printf("bytes[0] = 0x%02x\n", u.bytes[0]); printf("bytes[1] = 0x%02x\n", u.bytes[1]); printf("bytes[2] = 0x%02x\n", u.bytes[2]); printf("bytes[3] = 0x%02x\n", u.bytes[3]); printf("union size = %d\n", (int)sizeof(DataUnion)); return 0; }输出结果(小端平台):
bytes[0] = 0x78 bytes[1] = 0x56 bytes[2] = 0x34 bytes[3] = 0x12 union size = 4考点分析:
- 联合体所有成员共享同一块内存,大小取最大成员大小。
- 通过
union可以把一个 32 位数据拆成 4 个字节,这在通信协议解析中很常见。 - 输出结果依赖平台大小端,这也是嵌入式笔试常考点。
7. 第五组:位操作与寄存器操作
7.1 置位、清位、翻转、取值
#include <stdio.h> #define REG_ADDR_BASE (0x40000000UL) int main(void) { unsigned int reg = 0x00000000; // 置位 bit5 reg |= (1U << 5); printf("置位 bit5 后: 0x%08x\n", reg); // 清位 bit5 reg &= ~(1U << 5); printf("清位 bit5 后: 0x%08x\n", reg); // 翻转 bit3 reg ^= (1U << 3); printf("翻转 bit3 后: 0x%08x\n", reg); // 取值 bit3 unsigned int bit3 = (reg >> 3) & 0x01; printf("bit3 的值为: %u\n", bit3); // 批量置位 bit0~bit3 reg |= 0x0F; printf("批量置位低4位: 0x%08x\n", reg); return 0; }输出结果:
置位 bit5 后: 0x00000020 清位 bit5 后: 0x00000000 翻转 bit3 后: 0x00000008 bit3 的值为: 1 批量置位低4位: 0x0000000f考点分析:
- 置位用
|,清位用& ~,翻转用^,取值用&+ 右移。 - 单片机寄存器操作中,
1U << n的U后缀可以避免有符号整数移位时可能出现的符号扩展问题。 - 笔试中经常出现“用宏定义实现置位/清位”,需要能熟练写出下面这种宏:
#define SET_BIT(reg, bit) ((reg) |= (1U << (bit))) #define CLEAR_BIT(reg, bit) ((reg) &= ~(1U << (bit))) #define TOGGLE_BIT(reg, bit) ((reg) ^= (1U << (bit))) #define GET_BIT(reg, bit) (((reg) >> (bit)) & 0x01U)7.2 位域的使用与注意事项
#include <stdio.h> typedef struct { unsigned char mode : 2; unsigned char enable : 1; unsigned char reserved : 5; } ControlField; int main(void) { ControlField ctrl; unsigned char raw = 0x41; // 二进制 0100 0001 // 假设 raw 来自寄存器或协议帧 unsigned char *p = (unsigned char *)&ctrl; *p = raw; printf("mode = %u\n", ctrl.mode); printf("enable = %u\n", ctrl.enable); printf("sizeof(ControlField) = %d\n", (int)sizeof(ControlField)); return 0; }输出结果:
mode = 1 enable = 0 sizeof(ControlField) = 1考点分析:
- 位域可以按位定义结构体成员,节省内存,常用于寄存器映射和协议解析。
- 位域的内存布局依赖编译器和平台,可移植性较差,跨平台通信时建议使用显式位操作代替。
- 嵌入式笔试中,位域通常和大小端、内存布局结合考察。
8. 第六组:宏定义与预处理
8.1 宏定义常见错误
#include <stdio.h> #define SQUARE(x) x * x #define SQUARE_FIX(x) ((x) * (x)) #define MAX(a, b) ((a) > (b) ? (a) : (b)) int main(void) { int a = 5; printf("SQUARE(a + 1) = %d\n", SQUARE(a + 1)); printf("SQUARE_FIX(a + 1) = %d\n", SQUARE_FIX(a + 1)); int x = 3, y = 4; int max = MAX(++x, y); printf("max = %d, x = %d\n", max, x); return 0; }输出结果:
SQUARE(a + 1) = 11 SQUARE_FIX(a + 1) = 36 max = 5, x = 5考点分析:
SQUARE(a + 1)展开成a + 1 * a + 1,所以结果是5 + 5 + 1 = 11。- 带参宏必须给参数和整体都加括号。
MAX(++x, y)中参数被展开两次,导致 x 被自增两次,这是宏的副作用,面试官非常喜欢考。- 嵌入式代码中,宏常用于寄存器地址定义、位掩码定义、断言、调试日志开关,合理使用能提升可读性,但要注意副作用和优先级问题。
8.2 条件编译
#include <stdio.h> #define DEBUG_LEVEL 2 #if DEBUG_LEVEL >= 1 #define LOG_INFO(fmt, ...) printf("[INFO] " fmt "\n", ##__VA_ARGS__) #else #define LOG_INFO(fmt, ...) #endif #if DEBUG_LEVEL >= 2 #define LOG_DEBUG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__) #else #define LOG_DEBUG(fmt, ...) #endif int main(void) { int temp = 25; LOG_INFO("temp = %d", temp); LOG_DEBUG("temp = %d", temp); return 0; }输出结果:
[INFO] temp = 25 [DEBUG] temp = 25考点分析:
- 条件编译可以在编译阶段裁剪代码,适合嵌入式设备中根据调试等级打印日志。
##__VA_ARGS__是 GNU 扩展,用于处理可变参数为空的情况。部分编译器需要特殊处理。- 笔试中常见题型:给定宏定义,判断
#if、#ifdef、#if defined()的差异。
如果只是想在调试时开启日志、发布时彻底不编入代码,条件编译比运行时 if 更省资源。
9. 第七组:内存与字符串
9.1 字符串常量与字符数组
#include <stdio.h> #include <string.h> int main(void) { char *p1 = "hello"; char arr[] = "hello"; printf("sizeof(p1) = %d\n", (int)sizeof(p1)); printf("sizeof(arr) = %d\n", (int)sizeof(arr)); printf("strlen(p1) = %d\n", (int)strlen(p1)); printf("strlen(arr) = %d\n", (int)strlen(arr)); // arr[0] = 'H'; // 合法 // p1[0] = 'H'; // 未定义行为,字符串常量存储在只读区 return 0; }输出结果:
sizeof(p1) = 8 sizeof(arr) = 6 strlen(p1) = 5 strlen(arr) = 5考点分析:
char *p1指向字符串常量,存放在只读区,修改会导致段错误或未定义行为。char arr[]是数组,会在栈上拷贝一份字符串,可以修改。sizeof(arr)包含结尾的\0,strlen不包含。- 嵌入式笔试中,这个题经常作为“为什么程序运行一段时间后崩溃”的排查方向。
9.2 字符串逆序
字符串逆序在嵌入式笔试中出现率极高,很多公司直接让手写。
#include <stdio.h> #include <string.h> void reverse_string(char *s) { int left = 0; int right = (int)strlen(s) - 1; while (left < right) { char temp = s[left]; s[left] = s[right]; s[right] = temp; left++; right--; } } int main(void) { char str[] = "embedded"; printf("原始字符串: %s\n", str); reverse_string(str); printf("逆序字符串: %s\n", str); return 0; }输出结果:
原始字符串: embedded 逆序字符串: debeddme考点分析:
- 必须保证传入的是可修改的字符数组,不能是字符串常量。
- 双指针法时间复杂度 O(n),空间复杂度 O(1)。
- 面试官可能会追问:如果要求不修改原字符串,返回新字符串呢?或者如果要求逆序后继续反转单词呢?这些变体在面试中很常见。
10. 第八组:const、static、volatile
10.1 const 修饰指针的不同含义
#include <stdio.h> int main(void) { int a = 10; int b = 20; const int *p1 = &a; // 指向 const int 的指针,不能通过 p1 修改 a // *p1 = 30; // 错误 p1 = &b; // 允许:指针本身可以变 int *const p2 = &a; // 指针本身是 const,不能指向其他地址 // p2 = &b; // 错误 *p2 = 30; // 允许:可以通过 p2 修改 a const int *const p3 = &a; // 指针和指向的数据都不能变 printf("a = %d, b = %d\n", a, b); printf("*p1 = %d, *p2 = %d\n", *p1, *p2); return 0; }输出结果:
a = 30, b = 20 *p1 = 20, *p2 = 30考点分析:
- 面试官非常喜欢问“const 在星号左边和右边分别代表什么”。
- 记忆方法:
const修饰的是它左边最近的那个类型;如果左边没有,就修饰右边。 - 嵌入式开发中,
const常用于定义只读的查找表,这类表可以被编译器放到 Flash 中,节省 RAM。例如:
const int sin_table[256] = { /* 预计算的sin值 */ };10.2 volatile 的作用
#include <stdio.h> int main(void) { volatile unsigned int status_reg = 0; // 模拟中断或硬件修改 status_reg status_reg = 1; while (status_reg == 0) { // 等待硬件置位 // 如果 status_reg 不是 volatile,编译器可能优化成死循环 } printf("status_reg = %u\n", status_reg); return 0; }考点分析:
volatile告诉编译器,这个变量可能被当前线程之外的代码修改(硬件、中断、其他线程),不要优化掉对该变量的访问。- 嵌入式开发中,以下场景必须用 volatile:
- 硬件寄存器映射。
- 中断服务函数中修改的全局变量。
- RTOS 多任务间共享的全局变量。
- 笔试常考:
const和volatile能同时修饰一个变量吗?答案是可以。例如只读的硬件状态寄存器,程序不能修改它,但它的值会变。
const volatile unsigned int *status_reg = (const volatile unsigned int *)0x40001000UL;10.3 static 的三种作用
#include <stdio.h> static int global_counter = 0; // 文件作用域,限制在本文件内 static void increment(void) // 文件作用域,仅本文件可调用 { static int local_counter = 0; // 局部静态变量,生命周期为整个程序 local_counter++; global_counter++; printf("local_counter = %d, global_counter = %d\n", local_counter, global_counter); } int main(void) { increment(); increment(); increment(); return 0; }输出结果:
local_counter = 1, global_counter = 1 local_counter = 2, global_counter = 2 local_counter = 3, global_counter = 3考点分析:
static修饰局部变量:变量存储在静态区,只初始化一次,函数结束后不销毁。static修饰全局变量或函数:限制作用域在当前文件,避免与其他文件同名符号冲突。- 嵌入式 C 项目中,模块内部工具函数、模块内部全局状态,都推荐用 static 封装,这也是“信息隐藏”思想。
- 在 RTOS 任务函数、中断回调中,局部静态变量常用于保存上一次状态。
11. 手写代码重点题:链表、环形缓冲区、状态机
这一部分是嵌入式笔试编程题的重灾区,不止考语法,还考数据结构设计和代码组织能力。下面精选三组最常考的代码。
11.1 单链表的插入与删除
链表在嵌入式开发中非常常用,比如任务队列、内存池空闲块链表。
#include <stdio.h> #include <stdlib.h> typedef struct Node { int data; struct Node *next; } Node; // 在头部插入节点 int list_insert_head(Node **head, int value) { Node *new_node = (Node *)malloc(sizeof(Node)); if (new_node == NULL) { return -1; } new_node->data = value; new_node->next = *head; *head = new_node; return 0; } // 删除指定值的第一个节点 int list_delete(Node **head, int value) { if (head == NULL || *head == NULL) { return -1; } Node *cur = *head; Node *prev = NULL; while (cur != NULL && cur->data != value) { prev = cur; cur = cur->next; } if (cur == NULL) { return -1; // 不存在 } if (prev == NULL) { // 删除的是头节点 *head = cur->next; } else { prev->next = cur->next; } free(cur); return 0; } void list_print(Node *head) { while (head != NULL) { printf("%d -> ", head->data); head = head->next; } printf("NULL\n"); } int main(void) { Node *head = NULL; list_insert_head(&head, 10); list_insert_head(&head, 20); list_insert_head(&head, 30); list_print(head); list_delete(&head, 20); list_print(head); list_delete(&head, 30); list_print(head); return 0; }输出结果:
30 -> 20 -> 10 -> NULL 30 -> 10 -> NULL 10 -> NULL考点分析:
- 插入和删除都要注意修改头指针的场景,所以需要传二级指针。
- 删除节点后要
free,防止内存泄漏。 - 链表面试题经常会要求“原地反转单链表”,建议把反转、查找中间节点、判断是否有环作为扩展练习。
11.2 环形缓冲区
环形缓冲区在串口接收、DMA 数据传输、日志系统中非常常见,是嵌入式笔试的高频手写题。
#include <stdio.h> #include <stdint.h> #include <string.h> #define BUF_SIZE 8 typedef struct { uint8_t data[BUF_SIZE]; uint8_t head; // 写入位置 uint8_t tail; // 读取位置 uint8_t count; // 当前数据个数 } RingBuffer; void ring_buf_init(RingBuffer *rb) { memset(rb, 0, sizeof(RingBuffer)); } int ring_buf_write(RingBuffer *rb, uint8_t value) { if (rb->count >= BUF_SIZE) { return -1; // 缓冲区满 } rb->data[rb->head] = value; rb->head = (rb->head + 1) % BUF_SIZE; rb->count++; return 0; } int ring_buf_read(RingBuffer *rb, uint8_t *value) { if (rb->count == 0) { return -1; // 缓冲区空 } *value = rb->data[rb->tail]; rb->tail = (rb->tail + 1) % BUF_SIZE; rb->count--; return 0; } int main(void) { RingBuffer rb; ring_buf_init(&rb); for (uint8_t i = 0; i < 10; i++) { int ret = ring_buf_write(&rb, i + 1); if (ret == 0) { printf("写入: %d\n", i + 1); } else { printf("缓冲区满,写入失败: %d\n", i + 1); } } uint8_t val = 0; while (ring_buf_read(&rb, &val) == 0) { printf("读取: %d\n", val); } return 0; }输出结果:
写入: 1 写入: 2 写入: 3 写入: 4 写入: 5 写入: 6 写入: 7 写入: 8 缓冲区满,写入失败: 9 缓冲区满,写入失败: 10 读取: 1 读取: 2 读取: 3 读取: 4 读取: 5 读取: 6 读取: 7 读取: 8考点分析:
- 环形缓冲区核心是取模运算或位与运算(当容量为 2 的幂时)。
- 写入时先判断是否满,读取时先判断是否空。
- 在中断和主循环配合的场景中,还可能要求实现“单生产者单消费者”的无锁版本,此时
count可以通过head和tail推导。 - 笔试中常把容量定义为 2 的幂,这样可以写成
index & (BUF_SIZE - 1),比取模更高效。
11.3 按键状态机
很多嵌入式笔试第二道编程题会考状态机。这里用按键消抖状态机来演示。
#include <stdio.h> typedef enum { KEY_STATE_IDLE, KEY_STATE_PRESSED, KEY_STATE_CONFIRM } KeyState; int read_key_gpio(void) { // 模拟按键读取,实际项目中读取 GPIO 引脚 static int press_count = 0; static int key = 0; press_count++; if (press_count > 5) { key = 1; // 模拟按键按下 } if (press_count > 8) { key = 0; // 模拟按键释放 press_count = 0; } return key; } void key_state_machine_run(void) { static KeyState state = KEY_STATE_IDLE; static int confirm_count = 0; int key_value = read_key_gpio(); switch (state) { case KEY_STATE_IDLE: if (key_value == 1) { state = KEY_STATE_PRESSED; confirm_count = 0; } break; case KEY_STATE_PRESSED: if (key_value == 1) { confirm_count++; if (confirm_count >= 2) { state = KEY_STATE_CONFIRM; printf("按键按下确认\n"); } } else { state = KEY_STATE_IDLE; confirm_count = 0; } break; case KEY_STATE_CONFIRM: if (key_value == 0) { state = KEY_STATE_IDLE; confirm_count = 0; } break; default: state = KEY_STATE_IDLE; break; } } int main(void) { for (int i = 0; i < 10; i++) { key_state_machine_run(); } return 0; }输出结果:
按键按下确认考点分析:
- 状态机是嵌入式软件架构中最基础也最实用的思想,适合处理按键、通信协议解析、菜单逻辑、任务调度等。
- 状态机代码要点:状态枚举清晰、事件驱动、避免在多个地方修改状态。
- 笔试中如果只让写“简单按键消抖”,你可以用延时实现;但如果要求“不阻塞、可扩展”,状态机方案得分更高。
12. 综合代码阅读题:用 10 分钟看懂一段“工程代码”
笔试最后一类高频题型是“代码阅读题”。题目给出一个剪裁过的嵌入式 C 文件,要求找出问题或写出输出。
这里给一个综合例子,模拟笔试风格:
#include <stdio.h> #include <string.h> typedef struct { unsigned short length; unsigned char data[4]; unsigned char crc; } Frame; unsigned char calc_crc(const unsigned char *data, unsigned int len) { unsigned char crc = 0; for (unsigned int i = 0; i < len; i++) { crc ^= data[i]; } return crc; } void process_frame(const unsigned char *buffer) { Frame frame; memcpy(&frame, buffer, sizeof(frame)); if (frame.length > 4) { printf("长度错误\n"); return; } unsigned char crc = calc_crc(frame.data, frame.length); if (crc != frame.crc) { printf("CRC 校验失败\n"); return; } printf("数据有效: %d\n", frame.data[0]); } int main(void) { unsigned char buffer[8] = {0x04, 0x11, 0x22, 0x33, 0x44, 0x00, 0x00, 0x00}; // 计算 CRC buffer[6] = buffer[0] ^ buffer[1] ^ buffer[2] ^ buffer[3] ^ buffer[4]; process_frame(buffer); return 0; }这段代码有哪些问题?
- 结构体对齐问题:
Frame包含unsigned short和unsigned char,在默认对齐下可能有填充字节。如果协议帧是紧密排列的,使用memcpy(sizeof(frame))可能多拷贝填充字节。 - 缓冲区越界风险:
buffer[8]有 8 字节,sizeof(frame)在默认对齐下可能是 8 字节(2 + 4 + 1 + 1 padding)。如果结构体变大,memcpy可能越界。 - CRC 计算范围:
calc_crc(frame.data, frame.length)只对data的前length字节计算,但如果发送方和接收方对 CRC 计算范围理解不一致,会失败。 - magic number:直接使用
4表示最大长度,应该用宏或常量。
这种题考察的不只是“能不能看懂”,还包括有没有工程安全意识。
改进版本
#include <stdio.h> #include <string.h> #define FRAME_MAX_DATA_LEN 4 #pragma pack(push, 1) typedef struct { unsigned short length; unsigned char data[FRAME_MAX_DATA_LEN]; unsigned char crc; } Frame; #pragma pack(pop) unsigned char calc_crc(const unsigned char *data, unsigned int len) { unsigned char crc = 0; for (unsigned int i = 0; i < len; i++) { crc ^= data[i]; } return crc; } void process_frame(const unsigned char *buffer, unsigned int buffer_len) { if (buffer_len < sizeof(Frame)) { printf("缓冲区长度不足\n"); return; } Frame frame; memcpy(&frame, buffer, sizeof(Frame)); if (frame.length > FRAME_MAX_DATA_LEN) { printf("长度错误\n"); return; } unsigned char crc = calc_crc(frame.data, frame.length); if (crc != frame.crc) { printf("CRC 校验失败\n"); return; } printf("数据有效: %d\n", frame.data[0]); }这个改进版本做了三件事:
- 使用
#pragma pack(1)去除结构体填充,确保和协议帧一致。 - 增加缓冲区长度检查,避免越界。
- 用宏代替魔法数字。
13. 常见笔试错误与排查清单
13.1 高频错误对照表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 无符号变量相减结果异常 | 无符号数溢出/回绕 | 先判断大小再减,或改用有符号类型 |
| 数组越界但编译不报错 | C 语言不检查数组边界 | 使用时手动校验索引范围 |
| 字符串拷贝导致段错误 | 目标缓冲区过小 | 改用strncpy并检查长度 |
| 函数返回局部变量地址 | 栈内存函数结束即失效 | 改为返回值、传参或 static 变量 |
| 结构体大小和预期不符 | 内存对齐 | 使用offsetof检查,必要时 pack |
| 宏参数出现副作用 | 参数被重复展开 | 宏内参数都加括号,避免传入自增表达式 |
| 程序行为被编译器优化改变 | 缺少 volatile | 硬件相关变量加 volatile |
| 两个源文件同名全局变量冲突 | 未使用 static | 内部变量和函数加 static |
| 内存越写后程序随机崩溃 | 野指针/写越界 | 使用内存检测工具,开启编译警告 |
| 使用未初始化指针 | 局部指针默认值随机 | 初始化指针为 NULL,使用前判断 |
13.2 笔试答题时的通用排查步骤
看到一段 C 代码并让你“找问题”时,按这个顺序检查:
- 变量类型:有无符号、char 范围、int 溢出。
- 指针操作:是否解引用 NULL、是否越界、是否返回局部地址。
- 数组下标:边界条件是否可能等于数组长度。
- 运算符优先级:混合运算是否和预期一致。
- 宏定义:参数是否被重复展开,括号是否完整。
- 结构体布局:对齐、大小端、pack。
- 内存管理:malloc 是否判断返回值,free 后是否置 NULL。
- 生命周期:static、全局、局部、堆栈。
- 并发安全:中断/多线程环境下是否有竞争条件。
14. 最佳实践与复习建议
14.1 嵌入式 C 代码的工程建议
笔试是一回事,真正项目里还要更严格。结合工程经验给几条建议:
- 能不动态分配就不动态分配:MCU 上堆空间有限,动态分配容易产生碎片。使用静态内存池、环形缓冲区、变长数组时要有上限。
- 所有外部输入都要校验:协议长度、索引、CRC、范围,不能信任接收到的数据。
- 寄存器操作必须 volatile:使用指针访问寄存器地址时,类型必须是 volatile 限定,防止编译器优化。
- 中断服务函数越短越好:中断里只做标志位设置、数据搬移或简单的状态切换,不要在中断里执行耗时的打印和复杂算法。
- 使用断言和日志:在关键入口处使用断言,但量产版本要关闭;日志分级编译裁剪,避免串口阻塞。
- 结构体访问硬件寄存器时确认内存布局:不同编译器、不同对齐规则下结果可能不同,优先使用位操作 + 固定偏移。
14.2 笔试复习路线
如果你离笔试还有 2 到 3 周,建议按这个顺序复习:
- 第一周:把本文的 20 段代码全部手写一遍,运行并分析输出。
- 第二周:刷链表、环形缓冲区、状态机、字符串逆序这类高频手写题,每种至少写 3 遍。
- 考试前 3 天:整理错题,重点温习本文第 13 章的排查清单。
如果你已经工作了,正在准备面试,可以额外看这些方向:
- Linux 下的嵌入式 C 开发:static、volatile、const、指针数组与数组指针。
- RTOS 相关:任务间通信、互斥锁、中断与任务同步。
- 编译器与链接:
-Wall、内存段布局、链接脚本、栈大小。
14.3 20 段代码速查表
给读者们做个速查表,方便收藏后快速定位复习内容:
| 编号 | 考点 | 核心要点 | 优先级 |
|---|---|---|---|
| 1 | 无符号与有符号转换 | 混合运算会隐式转换 | 必考 |
| 2 | 数据类型溢出 | 无符号回绕,有符号未定义 | 必考 |
| 3 | 运算符优先级 | 先乘除后加减,注意自增自减值 | 必考 |
| 4 | 短路求值 | 左侧确定时不执行右侧 | 高频 |
| 5 | 数组名与指针 | sizeof 区别、下标本质 | 必考 |
| 6 | 二维数组指针 | 行指针与线性指针 | 高频 |
| 7 | 结构体内存对齐 | 对齐规则、offsetof | 必考 |
| 8 | union 大小端 | 内存共享、大小端取字节 | 高频 |
| 9 | 位操作 | 置位/清位/翻转/取值 | 必考 |
| 10 | 位域 | 节省内存,注意可移植性 | 高频 |
| 11 | 宏定义错误 | 参数括号、副作用 | 必考 |
| 12 | 条件编译 | 日志分级、代码裁剪 | 高频 |
| 13 | 字符串常量与数组 | 只读区与栈上拷贝 | 必考 |
| 14 | 字符串逆序 | 双指针法 | 高频 |
| 15 | const 修饰指针 | 星号左右区别 | 必考 |
| 16 | volatile | 防优化、硬件访问 | 必考 |
| 17 | static 三种用途 | 静态变量/文件作用域 | 必考 |
| 18 | 单链表插入删除 | 二级指针、free | 高频 |
| 19 | 环形缓冲区 | 取模/位运算、读写索引 | 高频 |
| 20 | 状态机 | 状态枚举、事件驱动 | 高频 |
15. 写在最后一些实在话
嵌入式笔试不是靠背题就能过的,但也不能不背。上面这 20 段代码,基本覆盖了嵌入式 C 语言笔试 70% 以上的知识点。每一段你都亲自敲一遍、运行一遍、出错一遍,比看十篇文章都管用。
尤其是链表、环形缓冲区和状态机这三类,不要只在 IDE 里写,最好拿张纸手写。笔试的时候是要在纸上写代码的,手写和机器上写完全两种感觉。
另外一个建议:平时编译代码一定开-Wall -Wextra,把警告当成错误来看。很多笔试找问题题,其实考的就是编译器警告里提示的那些点。
如果这篇文章对你有帮助,可以先收藏备用。复习过程中遇到具体问题,欢迎在评论区把代码贴出来一起讨论。祝兄弟们笔试顺利,拿下心仪 offer。