嵌入式笔试的 C 语言题,翻来覆去就是那几类:宏定义、关键字语义、指针、内存对齐、位操作、链表、状态机、通信协议。很多同学不是不会写代码,而是没把“面试官真正要考的点”吃透。明明一段代码看着眼熟,一追问就卡住,分数自然就丢在基础题上。
这篇文章直接把 20 段高频 C 代码整理成一条学习主线,每段都给出了可运行的代码、考点拆解和面试官追问方向。重点覆盖嵌入式 Linux、单片机、驱动开发岗位笔试中最常见的考察面,适合正在找嵌入式软件工程师、嵌入式开发岗位的应届生和转行者。你不需要背八股,只需要把下面这些代码在本地敲一遍、跑一遍、改一遍,笔试丢分点就能少掉一大部分。
1. 核心能力速览
| 能力项 | 说明 |
|---|---|
| 内容定位 | 嵌入式 C 笔试高频考点代码解析 |
| 代码数量 | 20 段,每段都可单独编译验证 |
| 覆盖范围 | 宏、const/static/volatile、指针、数组、结构体对齐、位操作、大小端、链表、环形缓冲区、状态机、CRC、硬件寄存器映射 |
| 运行环境 | 标准 C 环境,Linux 用 gcc,Windows 用 MinGW 或 Keil MDK |
| 硬件要求 | 无,纯软件环境即可测试 |
| 编译方式 | gcc -Wall -std=c99 命令行编译 |
| 适合岗位 | 嵌入式软件工程师、单片机开发、嵌入式 Linux 驱动、物联网开发 |
| 学习方式 | 自己敲代码 + 跑代码 + 回答追问 |
从知识结构看,这 20 段代码基本覆盖了嵌入式笔试 70% 以上的 C 语言考察点。接下来先从底层环境开始,再逐段拆解。
2. 嵌入式 C 笔试考点地图
嵌入式笔试的 C 语言题,很少考“这个算法怎么写”,更多考“这段代码能不能跑、为什么能跑、为什么不能跑”。真正拉开差距的考点集中在三类:
第一类是语言基础。宏展开的副作用、const 修饰对象、static 生命周期、volatile 编译优化、typedef 和 #define 的区别。这些问题单独拿出来都不难,但组合在选择题和程序分析题里,错误率非常高。
第二类是内存模型。数组和指针的退化、sizeof的语义、结构体对齐、动态内存分配、返回局部变量的陷阱。嵌入式开发直接面对栈、堆、只读区、数据段,面试官默认你清楚这些区域的划分。
第三类是硬件交互。位操作、大小端、位域寄存器、环形缓冲区、状态机、CRC 校验、外设寄存器地址映射。这类题在嵌入式笔试中的比例明显高于互联网后端笔试。
下面按这三大类把 20 段代码拆开讲。
3. 本地编译环境准备
在开始看代码前,先把编译环境准备好。这 20 段代码全部是标准 C,理论上任何 C 编译器都能编译。
3.1 Linux 或 WSL 环境
直接在终端里写代码测试:
# 安装 gcc(通常系统自带) sudo apt install gcc # 编译单文件程序 gcc -Wall -std=c99 -o demo demo.c # 运行 ./demo-Wall会打开所有常见警告,笔试刷题时建议一直开着。很多代码陷阱在编译阶段就会爆出警告,比如类型不匹配、变量未使用、指针加减越界风险。
3.2 Windows 环境
Windows 下最简单的方案是装 MinGW-w64,或者在 VSCode 里配置 C/C++ 编译环境。如果已经在用 Keil MDK 做单片机开发,也可以把代码丢进 Keil 的仿真工程里跑,Keil 对嵌入式 C 语法检查更严格,反而更容易发现笔试代码里的坑。
VSCode 里写 C 代码时,装好 C/C++ 插件后,把编译器路径指到 gcc,就可以直接在终端里编译:
gcc -Wall -std=c99 -o demo.exe demo.c .\demo.exe3.3 建议的刷题姿势
不要直接复制下面的大段代码。先把考点自己写一遍,写不出来再对照代码。跑通之后,再尝试修改参数、改变边界条件,比如把字符串逆序函数改成逆序打印、把链表反转改成递归实现。这个步骤做扎实,笔试手写代码基本不会慌。
4. 第一类代码:关键字与宏定义高频题
这段是嵌入式笔试选择题的重灾区。宏定义展开、const 修饰、static 生命周期、volatile 编译优化,每一类都能单独出道题。
4.1 01 宏定义 MAX/MIN:副作用是面试官第一刀
#define MAX(a, b) ((a) > (b) ? (a) : (b))考点在于宏展开是文本替换,不是函数调用。MAX(i++, j++)会被展开成:
((i++) > (j++) ? (i++) : (j++))i 和 j 会被自增两次,结果完全不符合预期。这就是宏的副作用问题。嵌入式笔试经常把这种代码放进选择题,问执行后 i、j 的值是多少。
追问方向:
- 为什么宏参数外面要加括号?
- GCC 下有没有更安全的写法?可以用
__typeof__或内联函数。 - 宏和内联函数的区别是什么?
推荐回答思路:先说清楚括号是防止优先级陷阱,再指出参数副作用是宏的天然问题,最后说明在嵌入式工程里更常用内联函数或带类型检查的写法来替代复杂宏。
4.2 02 位操作宏:寄存器操作的看家本领
#define SET_BIT(x, n) ((x) |= (1U << (n))) #define CLR_BIT(x, n) ((x) &= ~(1U << (n))) #define TOGGLE_BIT(x, n) ((x) ^= (1U << (n))) #define GET_BIT(x, n) (((x) >> (n)) & 1U)位操作是嵌入式笔试必考,没有例外。这四个宏分别对应置位、清零、翻转、读取。笔试题目通常会换一层皮来考,比如“请写出将寄存器第 7 位清零、同时保留其他位的语句”。
需要特别注意两点:1U是无符号整数,避免有符号右移的未定义行为;n 的取值不能超过类型位宽,否则是未定义行为。
追问方向:
- 如何一次置位多个 bit?
- 如何将某个 bit 取反后写回?
- GET_BIT 返回的值是什么类型?
推荐回答:
x |= (1U << 3) | (1U << 7); x &= ~((1U << 2) | (1U << 5));4.3 03 const 修饰:到底谁不能变?
const int a = 10; int b = 20; const int *p = &b; // p 指向 const int,不能通过 p 修改 b int * const q = &b; // q 是 const 指针,不能修改 q 本身 const int * const r = &b; // 两者都不能改笔试最常见的考法是给出几段声明,问你哪一行能通过编译。记忆口诀是“const 修饰谁,谁就不能变”。
const int *p:const 修饰*p,所以通过 p 不能改值。int * const q:const 修饰 q,所以指针变量本身不能改。const int * const r:两者都固定。
追问方向:
- 函数参数用
const char *s有什么好处? - 嵌入式里 const 变量放在什么内存区域?
参考答案:const 修饰的函数参数可以防止函数内部误修改外部数据;嵌入式里 const 变量通常放在只读数据区或 Flash,节省 RAM 空间。
4.4 04 static 三大作用:局部变量、文件作用域、生命周期
#include <stdio.h> int counter(void) { static int count = 0; return ++count; } int main(void) { printf("%d ", counter()); printf("%d ", counter()); return 0; }输出结果是1 2。count 虽然定义在函数内部,但用 static 修饰后,生命周期延长到整个程序运行结束,并且只初始化一次。普通局部变量每次调用都会在栈上重新创建。
static 还有另外两个作用:修饰全局变量时限制变量只能在当前文件内访问,修饰函数时限制函数只在当前文件内链接。这在多文件嵌入式工程中非常重要。
追问方向:
- static 局部变量存储在哪个内存区域?
- static 全局变量和普通全局变量的区别是什么?
- 中断函数里能不能调用带 static 局部变量的函数?
回答要点:static 局部变量存储在数据段或 BSS 段,不在栈上;static 全局变量的链接属性是内部链接,普通全局变量是外部链接。
4.5 05 volatile:嵌入式最容易丢分的编译优化坑
volatile unsigned int *reg = (volatile unsigned int *)0x40000000; *reg = 0x01;volatile 告诉编译器:这个变量的值可能在当前代码上下文之外被改变,不要优化到 CPU 寄存器里,每次使用都必须从内存重新读取。
嵌入式里三类场景必须加 volatile:
- 硬件外设寄存器映射地址。
- 中断服务程序和主循环共享的全局变量。
- 多线程或 RTOS 任务间共享的变量。
笔试经典题是:
int flag = 0; while (flag == 0) { // 等待中断置位 flag }如果 flag 没有 volatile,编译器可能把flag == 0优化成死循环,因为编译器认为循环体里没有修改 flag,直接从寄存器取值,永远不会重新读内存。
追问方向:
- const 和 volatile 能不能同时修饰一个变量?
- volatile 能解决并发安全吗?
参考答案:const 和 volatile 可以同时使用,典型场景是只读硬件状态寄存器;volatile 只保证“每次从内存读”,不解决多线程原子性,所以不能替代互斥锁。
4.6 06 typedef 与 #define:看似一样,实际差很多
#define INT_PTR int* INT_PTR a, b; // 等价于 int *a, b; b 是 int,不是 int* typedef char *CHAR_PTR; CHAR_PTR c, d; // c 和 d 都是 char*#define 是预处理阶段文本替换,typedef 是编译阶段类型声明。笔试选择题非常喜欢用INT_PTR a, b;来考,很多人会因为惯性以为 a 和 b 都是指针。
typedef 在嵌入式工程里的常见用法是给结构体、函数指针、寄存器映射结构体起别名,让代码简洁。但要注意不要滥用 typedef 隐藏原本类型,降低代码可读性。
追问方向:
- typedef struct 和 struct 直接定义的区别在哪里?
- 在 C 语言中
typedef void (*callback_t)(int);是什么意思?
参考答案:typedef void (*callback_t)(int);定义了一个函数指针类型,指向返回值是 void、参数是 int 的函数。这在事件驱动框架和回调注册里很常用。
5. 第二类代码:指针、数组与字符串
指针是嵌入式 C 笔试的绝对核心,几乎每一份笔试卷都会出现。这里挑了三段最高频的代码。
5.1 07 数组与指针:等价但不完全相同
#include <stdio.h> int main(void) { int arr[5] = {1, 2, 3, 4, 5}; 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("sizeof(arr) = %zu\n", sizeof(arr)); printf("sizeof(p) = %zu\n", sizeof(p)); return 0; }三种访问方式等价,输出都是3。但sizeof(arr)是整个数组的大小,sizeof(p)是指针变量的大小。在 32 位系统上 p 的大小通常是 4 字节,在 64 位系统上是 8 字节。
数组名在绝大多数表达式中会退化成首元素地址,但放在sizeof操作符里不会退化。这是笔试里最高频的 sizeof 考点之一。
追问方向:
&arr + 1指向什么位置?- 数组作为函数参数时,为什么
sizeof(arr)会变成指针大小?
回答要点:&arr + 1的步长是整个数组,指向数组末尾之后;数组作为函数参数时,编译器会把参数声明调整为指针,所以sizeof退化成指针大小。
5.2 08 函数指针与回调:事件驱动的基础
#include <stdio.h> int add(int a, int b) { return a + b; } int calc(int (*op)(int, int), int a, int b) { return op(a, b); } int main(void) { printf("add via func ptr: %d\n", calc(add, 3, 5)); return 0; }函数指针是嵌入式驱动开发中非常常用的语法。定时器回调、按键矩阵扫描、串口命令表、任务调度器,底层都依赖函数指针。
笔试里手写函数指针声明的正确率高,很多人一碰到就卡壳。推荐先把int (*op)(int, int)这个声明读法练熟:op是一个指针,指向返回值是 int、参数是两个 int 的函数。
追问方向:
- 函数指针数组怎么声明?
- 中断回调函数里能直接调用阻塞函数吗?
参考答案:函数指针数组声明是int (*ops[4])(int, int);,可以用于命令表驱动;中断回调里不能做耗时或阻塞操作,要快速进快速出,否则会破坏实时性。
5.3 09 字符串逆序:笔试手写题第一梯队
#include <stdio.h> #include <string.h> void str_reverse(char *s) { if (!s) return; char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char tmp = *left; *left = *right; *right = tmp; left++; right--; } } int main(void) { char buf[] = "embedded"; str_reverse(buf); printf("%s\n", buf); return 0; }输出结果是deddembe。这个函数用双指针从两端向中间遍历,原地交换字符。注意传入的必须是可写字符数组,不能直接传字符串常量,这个坑下面还会讲。
笔试手写时最容易犯三个错误:忘记判断空指针、strlen(s)对空串返回 0 后 right 变成s - 1、最后没有写\0。其实原地交换不会改变字符串长度,所以不需要重新补\0。
追问方向:
- 不允许修改原字符串时,怎么逆序输出?
- 递归版本怎么写?
- 中文字符串逆序和 ASCII 字符串逆序有什么区别?
回答要点:不允许修改原串时,可以新开一块缓冲区倒序拷贝;递归写法简单但嵌入式里链表过长时可能导致栈溢出;中文字符串在 UTF-8 下是可变长编码,按字节逆序会把字符拆碎,需要按字符边界处理。
6. 第三类代码:内存、对齐与结构体
内存问题是嵌入式笔试区分度最高的部分。结构体对齐、动态内存、返回局部变量,三个题一旦出现,错误率普遍偏高。
6.1 10 字符串长度:sizeof 与 strlen 的区别
#include <stdio.h> #include <string.h> int main(void) { char arr[] = "hello"; char *p = arr; printf("strlen(arr) = %zu\n", strlen(arr)); printf("sizeof(arr) = %zu\n", sizeof(arr)); printf("sizeof(p) = %zu\n", sizeof(p)); return 0; }标准答案:strlen(arr)是 5,sizeof(arr)是 6(包含结尾的\0),sizeof(p)在 32 位系统是 4,在 64 位系统是 8。
笔试选择题经常把sizeof(p)和sizeof(arr)混在一起考。很多人在函数参数里传数组名,以为sizeof能算出数组大小,结果算出的是指针大小。
追问方向:
- 手写一个
strlen函数。 sizeof是在编译期还是运行期计算的?
手写strlen的参考实现:
size_t my_strlen(const char *s) { const char *p = s; while (*p) { p++; } return (size_t)(p - s); }6.2 11 结构体内存对齐:笔试送分题还是送命题
#include <stdio.h> typedef struct { char a; int b; char c; } S1; typedef struct { char a; char c; int b; } S2; int main(void) { printf("sizeof(S1) = %zu\n", sizeof(S1)); printf("sizeof(S2) = %zu\n", sizeof(S2)); return 0; }在 32 位系统上,输出通常是sizeof(S1) = 12、sizeof(S2) = 8。两个结构体成员完全一样,只是顺序不同,大小就差了 4 字节。
对齐规则是:每个成员按照自身对齐数对齐,结构体的总大小必须是最大对齐数的整数倍。int要对齐到 4 字节,所以char a后面会填充 3 个字节,char c后面再填充 3 个字节。
嵌入式里结构体经常用于协议数据包、寄存器映射,把成员顺序排好可以省 RAM,还能避免一些平台上的非对齐访问错误。如果需要紧凑布局,可以用:
#pragma pack(1) typedef struct { char a; int b; char c; } PackedS1; #pragma pack()追问方向:
- 为什么要内存对齐?
- pack(1) 的代价是什么?
- 如何判断结构体实际占用大小?
回答要点:对齐是为了 CPU 访问效率,某些 ARM 架构非对齐访问会直接触发硬件异常;pack(1) 节省空间但可能降低访问速度,且可移植性变差。
6.3 12 返回局部变量:栈指针的典型陷阱
char *get_string(void) { char buf[32]; snprintf(buf, sizeof(buf), "hello"); return buf; // 错误:返回栈地址 }函数返回后,栈帧被释放,buf 指向的内存内容不确定,属于未定义行为。笔试机试里很多人机试跑不出结果,就是因为这个。
正确写法有三种:
// 方法一:调用方传入输出缓冲区 void get_string(char *out, size_t size) { snprintf(out, size, "hello"); } // 方法二:返回静态缓冲区,注意线程不友好 char *get_string(void) { static char buf[32]; snprintf(buf, sizeof(buf), "hello"); return buf; } // 方法三:malloc 动态分配,调用方负责 free char *get_string(void) { char *buf = malloc(32); if (buf) { snprintf(buf, 32, "hello"); } return buf; }追问方向:
- 返回局部数组名和返回局部结构体有什么区别?
- 栈空间一般多大、堆空间一般多大?
回答要点:返回结构体通常按值拷贝,不是返回地址,所以可以;栈空间在单片机工程里可能只有几 KB,堆空间受链接脚本和内存池配置限制,动态内存使用要格外小心。
7. 第四类代码:位操作、大小端与寄存器映射
嵌入式笔试和硬件交互最紧密的部分,就是这一块。不懂位操作和字节序,很难写出能跑在真实板子上的驱动代码。
7.1 13 大小端判断:一个函数解决问题
#include <stdio.h> #include <stdint.h> int is_little_endian(void) { uint32_t x = 1; uint8_t *p = (uint8_t *)&x; return *p == 1; } int main(void) { if (is_little_endian()) { printf("little-endian\n"); } else { printf("big-endian\n"); } return 0; }小端模式:低字节保存在低地址。x86 和大多数 ARM Cortex-M 默认是小端。大端模式:高字节保存在低地址,网络字节序就是大端。
这个函数利用指针类型转换,把uint32_t的首字节取出来。如果首字节是 1,说明低字节在低地址,是小端。
笔试还可能用联合体来写:
typedef union { uint32_t word; uint8_t bytes[4]; } EndianTest; int is_little_endian(void) { EndianTest t; t.word = 1; return t.bytes[0] == 1; }追问方向:
- 大小端对通信协议有什么影响?
- 如何把结构体数据写入字节流?
回答要点:设计通信帧时要把数值都转成网络字节序,再按字节发送;接收端再转回主机字节序,避免不同平台解析错位。
7.2 14 字节序转换:手动实现 htons/htonl
#include <stdint.h> uint16_t swap_bytes16(uint16_t x) { return (uint16_t)((x << 8) | (x >> 8)); } uint32_t swap_bytes32(uint32_t x) { return ((x & 0xFF000000U) >> 24) | ((x & 0x00FF0000U) >> 8) | ((x & 0x0000FF00U) << 8) | ((x & 0x000000FFU) << 24); }swap_bytes16(0x1234)结果是0x3412,swap_bytes32(0x12345678)结果是0x78563412。
嵌入式网络通信里,协议栈通常提供htons、htonl、ntohs、ntohl,底层原理就是这种字节交换。笔试不要求背库函数,但要求你理解怎么通过移位和掩码重新组合字节。
追问方向:
- 从串口收到 4 字节怎么拼成一个
uint32_t? - 为什么不能用强制类型转换直接转?
回答要点:拼接时可以(uint32_t)d[0] << 24 | (uint32_t)d[1] << 16 | ...按协议字节序拼接;直接强转会受本机大小端影响,可移植性差。
7.3 15 位域定义寄存器:看起来方便,坑也不少
#include <stdint.h> typedef struct { uint32_t b0 : 1; uint32_t b1 : 1; uint32_t rsvd : 2; uint32_t data : 12; uint32_t crc : 16; } RegBits; volatile RegBits *reg = (volatile RegBits *)0x40000000;位域能直接用成员名操作寄存器里的 bit 段,可读性很好。但位域的分配方向由编译器决定,标准没有规定 bit 是从低到高还是从高到低排列,所以跨平台移植时容易出问题。
嵌入式 Linux 和单片机驱动里,更常见的做法是宏定义偏移量和掩码,配合 readl/writel 或直接指针访问,显式控制位布局。
追问方向:
- 位域和移位宏相比,哪个可移植性更好?
- ARM Cortex-M 上位域布局默认是按小端分配吗?
回答要点:移位宏的可移植性通常更好,因为布局完全由代码控制;位域依赖编译器的内存布局规则,使用前要查对应编译器手册。
7.4 20 硬件寄存器地址映射:结构体指针访问外设
#include <stdint.h> #define UART_BASE 0x40004000U typedef struct { volatile uint32_t SR; // 状态寄存器 volatile uint32_t DR; // 数据寄存器 } UART_TypeDef; volatile UART_TypeDef *UART = (volatile UART_TypeDef *)UART_BASE; void uart_send_char(char c) { UART->DR = (uint32_t)c; }这是嵌入式驱动里非常典型的写法:把外设基地址强制转换成结构体指针,然后用成员访问寄存器。volatile保证每次读写都直接访问物理地址,而不是使用缓存值。
笔试遇到这种题,重点看你会不会写结构体定义、会不会用volatile、会不会区分外设基地址和偏移量。
追问方向:
- 结构体成员顺序必须和硬件寄存器偏移一致吗?
- 为什么寄存器地址要加 volatile?
回答要点:外设寄存器的地址偏移是硬件决定的,结构体成员顺序必须按硬件手册排列;加 volatile 是为了防止编译器优化掉对寄存器的读写操作。
8. 第五类代码:链表、环形缓冲区、状态机与通信协议
最后这几段是嵌入式笔试里的大题高频区。链表、环形缓冲区、状态机、CRC,每一个都是实际工程里天天在用的东西。
8.1 16 链表反转:笔试高频手写题
typedef struct Node { int data; struct Node *next; } Node; Node *reverse_list(Node *head) { Node *prev = NULL; Node *cur = head; while (cur) { Node *next = cur->next; cur->next = prev; prev = cur; cur = next; } return prev; }核心是三个指针依次往后走。先保存cur->next,再把cur->next指向前一个节点,最后整体后移。如果漏掉保存 next 这一步,链表会断掉。
笔试手写链表反转,最容易出错的是循环结束后返回的节点。循环结束时cur为 NULL,prev指向原来的最后一个节点,也就是新链表的头节点,所以返回prev。
追问方向:
- 如何判断链表是否有环?
- 递归反转链表有什么坑?
参考答案:快慢指针可以判断环,慢指针每次走一步,快指针每次走两步,如果相遇则有环;递归反转在链表很长时容易栈溢出,嵌入式里更推荐迭代写法。
8.2 17 环形缓冲区:串口和通信队列的标配数据结构
#include <stdint.h> #define RBUF_SIZE 64 typedef struct { uint8_t buf[RBUF_SIZE]; uint16_t head; uint16_t tail; } RingBuffer; int rb_write(RingBuffer *rb, uint8_t data) { uint16_t next = (rb->head + 1) % RBUF_SIZE; if (next == rb->tail) { return -1; // 缓冲区满 } rb->buf[rb->head] = data; rb->head = next; return 0; } int rb_read(RingBuffer *rb, uint8_t *data) { if (rb->head == rb->tail) { return -1; // 缓冲区空 } *data = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % RBUF_SIZE; return 0; }环形缓冲区用两个指针 head 和 tail 管理读写位置。最常见的设计是预留一个空位来区分“满”和“空”,否则 head == tail 既可能是空也可能是满。
嵌入式 UART 接收、DMA 双缓冲、网络协议栈接收队列,底层都有环形缓冲区的身影。笔试喜欢考察满判断、空判断和连续读写的位置关系。
追问方向:
- 生产者和消费者都操作同一个环形缓冲区时怎么保证安全?
- 环形缓冲区大小为什么常用 2 的幂?
回答要点:单生产者单消费者场景,可以用关中断或在临界区保护读写下标;大小用 2 的幂时,取模操作可以优化成& (size - 1),效率更高。
8.3 18 状态机框架:按键、协议解析、菜单系统都用它
typedef enum { STATE_IDLE, STATE_RUNNING, STATE_ERROR } State_t; State_t state = STATE_IDLE; void state_machine_run(uint8_t start_event, uint8_t error_event, uint8_t reset_event) { switch (state) { case STATE_IDLE: if (start_event) { state = STATE_RUNNING; } break; case STATE_RUNNING: if (error_event) { state = STATE_ERROR; } break; case STATE_ERROR: if (reset_event) { state = STATE_IDLE; } break; default: state = STATE_IDLE; break; } }状态机是嵌入式软件架构的核心思想,按键消抖、通信协议解析、充电管理、菜单切换,都是状态机。笔试经常要求补全状态转移条件,或者问你某事件发生后状态变成什么。
嵌入式笔试里的状态机题,要重点关注“事件是否会被吞掉”和“状态机是否有默认分支”。很多题会在 default 分支上挖坑,如果没有 default,非法状态会导致程序跑飞。
追问方向:
- 状态机里能不能用阻塞延时?
- 状态机的事件怎么产生?
回答要点:状态机里尽量不用阻塞延时,用 tick 轮询或事件标志判断超时;事件可以由中断、定时器扫描、通信解析产生,状态机代码本身只处理“当前状态 + 输入事件 -> 下一状态”。
8.4 19 CRC 校验:通信协议里的数据完整性保障
#include <stdint.h> uint8_t crc8(uint8_t *data, uint16_t len) { uint8_t crc = 0; while (len--) { crc ^= *data++; for (int i = 0; i < 8; i++) { if (crc & 0x80) { crc = (uint8_t)((crc << 1) ^ 0x07); } else { crc = (uint8_t)(crc << 1); } } } return crc; }这段代码实现的是多项式0x07的 CRC-8 逐位计算。每个字节先异或到 crc,再按位处理,遇到最高位为 1 就与多项式异或。
笔试通常不会让你背出完整的 CRC 计算表,但会考你理解什么叫“按位异或 + 多项式反馈”。如果题目给出查表法代码,要能看懂表和渐次计算的关系。
追问方向:
- CRC 和累加和有什么区别?
- 为什么通信协议里常用 CRC 而不是简单累加和?
回答要点:CRC 的检错能力更强,尤其是对突发错误和连续 bit 翻转;累加和实现简单但碰撞概率高,可靠性不如 CRC。
9. 编译运行与自测方法
这 20 段代码不要只“看明白”,要真去编译运行。这里给一个统一的自测模板,把可运行的几段代码拼在一个 main 函数里。
#include <stdio.h> #include <string.h> #include <stdint.h> #define MAX(a, b) ((a) > (b) ? (a) : (b)) int is_little_endian(void) { uint32_t x = 1; uint8_t *p = (uint8_t *)&x; return *p == 1; } uint16_t swap_bytes16(uint16_t x) { return (uint16_t)((x << 8) | (x >> 8)); } void str_reverse(char *s) { if (!s) return; char *left = s; char *right = s + strlen(s) - 1; while (left < right) { char tmp = *left; *left = *right; *right = tmp; left++; right--; } } int main(void) { char buf[] = "embedded"; str_reverse(buf); printf("reverse: %s\n", buf); printf("little_endian: %d\n", is_little_endian()); printf("swap16(0x1234): 0x%04X\n", swap_bytes16(0x1234)); int x = 10, y = 20; printf("MAX(10, 20): %d\n", MAX(x, y)); return 0; }编译命令:
gcc -Wall -std=c99 -o test_demo test_demo.c ./test_demo预期输出:
reverse: deddembe little_endian: 1 swap16(0x1234): 0x3412 MAX(10, 20): 20如果输出和预期一致,说明基础环境没问题。链表、环形缓冲区、状态机、CRC 这几段代码可以单独建一个文件,封装成测试函数,再在 main 里加assert判断结果。
一个推荐的自测思路是:把每段代码的“输入 -> 预期输出 -> 实际输出”写成注释,运行后逐项比对。这样做笔试前的查漏补缺效率很高,也能在准备“嵌入式八股文”时积累真实运行结论。
10. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 宏展开后结果不对 | 宏参数没有加括号或参数带副作用 | 展开宏后再看表达式 | 参数全部加括号,改用内联函数 |
| 字符串逆序程序崩溃 | 传入的是字符串常量,写入只读区 | 检查传入参数类型 | 改用可写字符数组,如char buf[] = "hello" |
| sizeof(数组) 结果变成 4/8 | 数组传入函数后退化成指针 | 打印 sizeof 检查位置 | 在函数外计算大小,或额外传入长度参数 |
| 结构体大小和预期不一致 | 内存对齐导致填充字节 | 用 sizeof 打印实际大小 | 调整成员顺序,必要时使用 #pragma pack(1) |
| 链表反转死循环或断链 | 没有保存 cur->next | 手动画出链表节点推导 | 先保存 next,再改 cur->next,再移动指针 |
| 大小端判断结果相反 | 对大小端概念理解反了 | 打印每个字节的地址和值 | 记住“低字节在低地址是小端” |
| 位域寄存器位布局错乱 | 位域分配顺序依赖编译器 | 查阅编译器手册或对比反汇编 | 改用移位宏 + 掩码 |
| 状态机跑到未知状态 | switch 缺少 default 分支 | 检查状态枚举范围 | 增加 default 分支恢复安全状态 |
| volatile 声明漏掉 | 编译器优化掉寄存器读写 | 查看反汇编或观察行为异常 | 硬件寄存器指针统一加 volatile |
| CRC 结果和上位机不一致 | 多项式或初始值不匹配 | 对比通信协议文档参数 | 统一多项式初值、输入反射、输出异或参数 |
这里最值得提前关注的两个坑是:字符串常量不能原地修改,以及结构体对齐导致协议帧长度错误。这两个问题在笔试机试和项目开发中都非常常见。
11. 嵌入式 C 笔试学习建议
第一,上面每段代码都先自己手写一遍,再对照答案改。手写和看是两码事。很多同学概念都知道,一上机写struct Node *next都会漏掉struct,这种基础错误在笔试里很致命。
第二,把代码跑起来后再自问“如果改一个条件会怎样”。比如数组变成 10 个元素,结构体成员换个顺序,宏参数改成i++,状态机事件连续来两次,结果怎么变。这类变式题在笔试里出现频率极高,平时不多练,考场上很吃亏。
第三,结合真实工程代码来记忆。比如在串口中断里写环形缓冲区,在协议栈里看字节序转换,在驱动里找 volatile 寄存器访问,在按键驱动里看状态机。看得多了,笔试题目会变得非常直白。
第四,不要只看“嵌入式八股文”,要能解释为什么。八股给的是答案模板,但面试官通常会追问原理,比如“为什么加 volatile”“为什么对齐会有填充字节”“为什么返回局部数组会出错”。先把原理讲清楚,再背模板也不迟。
12. 总结
这 20 段 C 代码基本覆盖了嵌入式笔试里最高频的语言基础、内存模型、硬件交互知识点。宏定义的副作用、const/static/volatile 语义、数组和指针的区别、结构体对齐、链表反转、环形缓冲区、状态机、CRC、大小端转换,每一段都值得反复练到能直接默写。
如果时间有限,优先吃透这几段:宏定义、volatile、数组和 sizeof、结构体对齐、字符串逆序、链表反转、状态机框架。这几段是最常出现在选择题和手写题里的内容。建议把文章里的代码复制到本地工程里逐一编译运行,再对照追问方向自测一遍。代码跑通了,笔试题里很多看起来很绕的选项,一眼就能识别出真正的考点。