news 2026/8/31 2:22:40

嵌入式C笔试高频考点:20段代码吃透指针、位操作与状态机

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
嵌入式C笔试高频考点:20段代码吃透指针、位操作与状态机

兄弟们,最近是不是又开始刷嵌入式笔试真题了?

很多读者跟我反馈,说嵌入式笔试题目看着不难,但一做就错,尤其是C语言相关的选择题、改错题和编程题,好像每个考点都见过,但每次踩坑的都是同一个地方。究其原因,还是对C语言在嵌入式场景中的“特殊用法”和“常见陷阱”缺少系统梳理。

这篇文章我整理了20 段笔试中出场率极高的 C 语言代码,覆盖数据类型、指针、结构体、位操作、内存管理、链表、状态机、中断等嵌入式笔试高频考点。每一段都会给出完整代码、运行结果、考点分析,以及面试官想听到的回答方向。

不管你是正在准备校招、跳槽,还是想扎实补一补嵌入式C语言基础,这篇文章都值得收藏后反复看。

1. 嵌入式笔试到底在考什么

先别急着看代码,我们先用 30 秒把方向理清楚。

嵌入式岗位的笔试题目,和纯后端、纯算法岗位有明显区别。嵌入式笔试更看重以下几类能力:

  1. 底层硬件意识:内存、寄存器、地址、位操作、大小端。
  2. C语言基本功:指针、数组、结构体、宏定义、类型转换。
  3. 资源受限思维:栈空间、堆空间、内存对齐、代码体积。
  4. 并发与中断意识:volatile、static、回调、重入。
  5. 调试能力:段错误、内存泄漏、栈溢出、野指针。

所以你会发现,嵌入式笔试中的 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 << nU后缀可以避免有符号整数移位时可能出现的符号扩展问题。
  • 笔试中经常出现“用宏定义实现置位/清位”,需要能熟练写出下面这种宏:
#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)包含结尾的\0strlen不包含。
  • 嵌入式笔试中,这个题经常作为“为什么程序运行一段时间后崩溃”的排查方向。

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 多任务间共享的全局变量。
  • 笔试常考:constvolatile能同时修饰一个变量吗?答案是可以。例如只读的硬件状态寄存器,程序不能修改它,但它的值会变。
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可以通过headtail推导。
  • 笔试中常把容量定义为 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; }

这段代码有哪些问题?

  1. 结构体对齐问题Frame包含unsigned shortunsigned char,在默认对齐下可能有填充字节。如果协议帧是紧密排列的,使用memcpy(sizeof(frame))可能多拷贝填充字节。
  2. 缓冲区越界风险buffer[8]有 8 字节,sizeof(frame)在默认对齐下可能是 8 字节(2 + 4 + 1 + 1 padding)。如果结构体变大,memcpy可能越界。
  3. CRC 计算范围calc_crc(frame.data, frame.length)只对data的前length字节计算,但如果发送方和接收方对 CRC 计算范围理解不一致,会失败。
  4. 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 代码并让你“找问题”时,按这个顺序检查:

  1. 变量类型:有无符号、char 范围、int 溢出。
  2. 指针操作:是否解引用 NULL、是否越界、是否返回局部地址。
  3. 数组下标:边界条件是否可能等于数组长度。
  4. 运算符优先级:混合运算是否和预期一致。
  5. 宏定义:参数是否被重复展开,括号是否完整。
  6. 结构体布局:对齐、大小端、pack。
  7. 内存管理:malloc 是否判断返回值,free 后是否置 NULL。
  8. 生命周期:static、全局、局部、堆栈。
  9. 并发安全:中断/多线程环境下是否有竞争条件。

14. 最佳实践与复习建议

14.1 嵌入式 C 代码的工程建议

笔试是一回事,真正项目里还要更严格。结合工程经验给几条建议:

  1. 能不动态分配就不动态分配:MCU 上堆空间有限,动态分配容易产生碎片。使用静态内存池、环形缓冲区、变长数组时要有上限。
  2. 所有外部输入都要校验:协议长度、索引、CRC、范围,不能信任接收到的数据。
  3. 寄存器操作必须 volatile:使用指针访问寄存器地址时,类型必须是 volatile 限定,防止编译器优化。
  4. 中断服务函数越短越好:中断里只做标志位设置、数据搬移或简单的状态切换,不要在中断里执行耗时的打印和复杂算法。
  5. 使用断言和日志:在关键入口处使用断言,但量产版本要关闭;日志分级编译裁剪,避免串口阻塞。
  6. 结构体访问硬件寄存器时确认内存布局:不同编译器、不同对齐规则下结果可能不同,优先使用位操作 + 固定偏移。

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必考
8union 大小端内存共享、大小端取字节高频
9位操作置位/清位/翻转/取值必考
10位域节省内存,注意可移植性高频
11宏定义错误参数括号、副作用必考
12条件编译日志分级、代码裁剪高频
13字符串常量与数组只读区与栈上拷贝必考
14字符串逆序双指针法高频
15const 修饰指针星号左右区别必考
16volatile防优化、硬件访问必考
17static 三种用途静态变量/文件作用域必考
18单链表插入删除二级指针、free高频
19环形缓冲区取模/位运算、读写索引高频
20状态机状态枚举、事件驱动高频

15. 写在最后一些实在话

嵌入式笔试不是靠背题就能过的,但也不能不背。上面这 20 段代码,基本覆盖了嵌入式 C 语言笔试 70% 以上的知识点。每一段你都亲自敲一遍、运行一遍、出错一遍,比看十篇文章都管用。

尤其是链表、环形缓冲区和状态机这三类,不要只在 IDE 里写,最好拿张纸手写。笔试的时候是要在纸上写代码的,手写和机器上写完全两种感觉。

另外一个建议:平时编译代码一定开-Wall -Wextra,把警告当成错误来看。很多笔试找问题题,其实考的就是编译器警告里提示的那些点。

如果这篇文章对你有帮助,可以先收藏备用。复习过程中遇到具体问题,欢迎在评论区把代码贴出来一起讨论。祝兄弟们笔试顺利,拿下心仪 offer。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 2:21:21

NUCLEO-C562RE boot0 pin问题排查:从引脚电平到选项字节

大约半个月前&#xff0c;有个朋友在群里发了张NUCLEO-C562RE的照片&#xff0c;配文是&#xff1a;“这板子的boot0 pin是不是出厂就是坏的&#xff1f;程序烧进去跑不起来&#xff0c;偶尔ST-LINK还连不上。”我当时的回复是&#xff1a;先别急着退换货&#xff0c;打开STM32…

作者头像 李华
网站建设 2026/8/31 2:21:12

搜狗校招测试岗笔试复盘:场景题与测试思维实战解析

搜狗2020校招测试岗笔试第二场&#xff0c;我是下午场考的。说实话&#xff0c;第一场考完心态有点崩&#xff0c;第二场本来不打算去了&#xff0c;后来想想反正简历也投了&#xff0c;多一次笔试多一次经验&#xff0c;硬着头皮上了。结果没想到第二场的题目风格和第一场差别…

作者头像 李华
网站建设 2026/8/31 2:19:09

残虹超还原背后:游戏线下活动如何打造角色传播高光

当“异环日本线下活动”的现场返图开始在国内社区流转&#xff0c;很多人第一眼注意到的不是舞台规模&#xff0c;也不是媒体通稿&#xff0c;而是那位把“残虹”还原得几乎像从立绘里走出来的Coser。这个画面本身不复杂&#xff0c;却成了一个很典型的行业切片&#xff1a;游戏…

作者头像 李华
网站建设 2026/8/31 2:17:19

用Cola架构划定MVP边界,再让Claude Code高效写代码

我见过太多人拿到 AI 编程工具后&#xff0c;第一反应是打开终端&#xff0c;敲下一句“帮我写一个某某系统”&#xff0c;然后等着奇迹发生。上次一个做后台系统的朋友用 Claude Code 搞了一下午&#xff0c;最后项目确实跑起来了&#xff0c;但代码结构完全失控&#xff1a;几…

作者头像 李华
网站建设 2026/8/31 2:16:33

零分配LINQ方案ZLinq:C#热路径性能优化实战

我们从一句经常出现在 C# 技术讨论里的话讲起&#xff1a;“能用 LINQ&#xff0c;但性能要求高的地方别用。” 很多写服务端、写游戏工具链、写实时数据处理管线的开发者&#xff0c;都有过这种纠结&#xff1a;Where、Select、OrderBy写起来确实爽&#xff0c;可一旦放进被频…

作者头像 李华
网站建设 2026/8/31 2:16:04

定时器更新ListBox的底层原理与性能优化

在 Windows 窗口程序里&#xff0c;定时器更新列表框&#xff0c;看起来是最普通的入门功能。你定义一个SetTimer&#xff0c;过一会儿往ListBox里AddString一行文字&#xff0c;界面就会自动出现新内容。但只要你真在项目里用它做过日志窗口、设备状态列表、消息通知区域&…

作者头像 李华