本系列下篇点击这里跳转
很多同学在学习 C 语言时,往往只停留在基础语法层面,一到项目实战就踩坑不断。这些坑大多不是语法错误,而是嵌入式环境下特有的细节问题 —— 比如结构体对齐导致的内存越界、函数指针定义错误导致的死机、野指针引发的偶现 bug 等。本文就结合我自己踩过的坑,整理了嵌入式 C 语言中新手最容易踩雷的几个知识点,帮你提前避坑,少走弯路
一、指针数组与数组指针
1.1 指针数组
定义形式:int *p[5];
- 结合顺序:由于
[]的优先级高于*,标识符p首先与[5]结合,表明p是一个包含 5 个元素的数组。 - 元素类型:数组的每个元素类型为
int *,即指向整型的指针。
指针数组和数组指针是 C 语言中最容易混淆的概念之一,也是嵌入式开发中最常用的指针用法。区分二者的核心原则是:根据运算符优先级判断标识符的结合顺序。
因此,指针数组本质上是一个存储指针的数组。
典型应用:字符串表
在嵌入式系统中,经常需要存储大量的字符串信息,如错误提示、菜单选项、日志信息等。使用指针数组可以高效地管理这些字符串:
const char *error_msg[] = { "SUCCESS", // 0: 操作成功 "PARAM_ERROR", // 1: 参数错误 "TIMEOUT_ERROR", // 2: 超时错误 "MEMORY_ERROR", // 3: 内存错误 "DEVICE_ERROR" // 4: 设备错误 }; // 通过错误码索引获取并打印错误信息 void print_error(uint8_t error_code) { uint8_t msg_count = sizeof(error_msg) / sizeof(error_msg[0]); if (error_code < msg_count) { printf("Error: %s\r\n", error_msg[error_code]); } else { printf("Unknown error code: %d\r\n", error_code); } }这种实现方式的优势在于:
1:代码结构清晰,易于扩展,新增错误信息只需在数组中添加一行
2:使用const关键字将字符串存储在 Flash 中,不占用宝贵的 RAM 资源
3:通过索引直接访问,时间复杂度为 O (1)
1.2 数组指针
定义形式:int (*p)[5];
- 结合顺序:由于括号的优先级最高,标识符
p首先与*结合,表明p是一个指针。 - 指向类型:指针指向的类型为
int [5],即一个包含 5 个整型元素的数组。
因此,数组指针本质上是一个指向数组的指针,其步长为整个数组的大小。
典型应用:二维数组传参
在 C 语言中,二维数组不能直接作为函数参数传递,必须使用数组指针来接收:
void print_2d_array(int (*arr)[4], int rows) { for (int i = 0; i < rows; i++) { for (int j = 0; j < 4; j++) { printf("%d ", arr[i][j]); } printf("\r\n"); } } int main(void) { // 3行4列的传感器数据数组 int sensor_data[3][4] = { {12, 34, 56, 78}, {23, 45, 67, 89}, {34, 56, 78, 90} }; // 二维数组名本质上是指向第一行数组的指针 print_2d_array(sensor_data, 3); return 0; }1.3 关键区别:步长不同
int arr[5] = {1, 2, 3, 4, 5}; int *p1 = arr; // 指向int的指针 int (*p2)[5] = &arr; // 指向包含5个int元素数组的指针 printf("p1+1 = %p\r\n", (void *)(p1 + 1)); // 地址增加4字节(一个int的大小) printf("p2+1 = %p\r\n", (void *)(p2 + 1)); // 地址增加20字节(整个数组的大小)1.4 小总结:
很多人学 C 语言卡在这里,其实一句话就能分清:指针数组是装指针的数组,数组指针是指向数组的指针。前者int *arr[5],方括号优先级高,先定义数组,每个元素存一个 int 型指针;后者int (*p)[5],括号把星号和 p 绑在一起,先定义指针,指向一个长度为 5 的 int 数组。
实际开发中,指针数组用得更多:存多个字符串、命令行参数argv、函数跳转表、状态机的处理函数数组都靠它。数组指针主要用来优雅地操作二维数组,或者把大块连续内存当作数组来遍历,避免越界,在驱动里处理寄存器组或者缓冲区时特别好用。
二 、指针函数与函数指针
上一章我们搞定了指针数组和数组指针这对 “双胞胎”,很多人说看完终于分清谁是谁了。这一章我们来讲指针进阶的第二道坎,也是真正能让你代码水平上一个台阶的东西 ——指针函数和函数指针。
说实话,我当时学这俩的时候,比指针数组还懵。书上只告诉你 “指针函数是返回指针的函数,函数指针是指向函数的指针”,看完我就一个问题:那这玩意儿到底有啥用?我写个普通函数不行吗?
直到后来我看了 HAL 库的源码,又自己写了几个稍微大点的项目,才恍然大悟:原来函数指针才是嵌入式里实现代码解耦、写出优雅代码的核心。没有它,你写的代码永远是一堆堆的if-else和switch-case,改一个功能要动半个工程。
今天我就不讲那些干巴巴的语法定义了,直接从 “为什么要用” 讲起,结合嵌入式里最常见的几个场景,让你看完就能用到自己的项目里。
2.1 :指针函数,先搞懂最基础的
这个其实最简单,很多人搞混纯粹是名字起得太像了。
指针函数,本质上就是一个普通函数,只不过它的返回值是一个指针。
就这么简单。它的定义格式是:
返回值类型 *函数名(参数列表);2.1.1 嵌入式里指针函数的常见用法
指针函数在嵌入式里用得非常多,最常见的就是这几种:
- 返回字符串常量:(比如上面的错误信息、菜单文本)
- 返回结构体指针:(比如从链表中查找节点)
- 返回动态分配的内存地址:(如果你的系统有堆的话)
2.1.2 新手必踩的致命坑
这里有一个我踩过的坑,绝对绝对不要返回局部变量的地址!
// 错误示例!千万不要这么写! char* get_temp_str(void) { char buf[20]; // 局部变量,存储在栈上 sprintf(buf, "temp: %d", temp_value); return buf; // 函数退出后,buf的内存被回收,返回的指针变成野指针 }这个代码编译的时候可能只会给个警告,但运行起来绝对会出问题。你调用这个函数,大概率能拿到正确的字符串,但过一会儿再看,数据就乱了,甚至直接死机。
原因很简单:局部变量存在栈上,函数执行完,栈就被回收了,那块内存随时会被其他函数覆盖。
正确的做法:要么把buf定义成static静态变量(存在静态存储区,不会被回收),要么让调用者自己传一个缓冲区进来。
// 正确写法1:使用static静态变量 char* get_temp_str(void) { static char buf[20]; // 静态变量,程序运行期间一直存在 sprintf(buf, "temp: %d", temp_value); return buf; } // 正确写法2:调用者传入缓冲区 void get_temp_str(char *buf, uint8_t buf_len) { snprintf(buf, buf_len, "temp: %d", temp_value); }2.2 重点来了:函数指针
好了,现在到了本章的核心 —— 函数指针。
很多人觉得函数指针很抽象,其实你换个角度想就明白了:
普通指针,存的是变量的内存地址
函数指针,存的是函数的内存地址
没错,函数也是存在内存里的,它也有自己的地址。函数指针,就是一个专门用来存这个地址的变量。
2.2.1 函数指针的定义
函数指针的定义格式是:
返回值类型 (*指针名)(参数列表);看到那个括号了吗?这个括号绝对不能少!少了它,就变成指针函数了。这是新手最容易写错的地方。
举个例子:
// 定义一个函数指针,指向“无参数、无返回值”的函数 void (*func_ptr)(void); // 定义一个函数指针,指向“参数是int、返回值是int”的函数 int (*calc_ptr)(int a, int b);这样写是不是有点长?实际项目里我们一般都会用typedef给它起个别名,这样用起来就方便
// 用typedef定义一个函数指针类型 typedef void (*CallbackFunc)(void); // 现在就可以像普通类型一样用它来定义变量了 CallbackFunc g_button_callback = NULL;2.2.2 函数指针的赋值和调用
// 一个普通函数 void led_on(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); } int main(void) { // 定义函数指针并赋值 void (*led_func)(void) = led_on; // 调用函数指针,和调用普通函数一模一样 led_func(); // 等价于led_on(); return 0; }看到了吗?调用函数指针和调用普通函数没有任何区别。你甚至可以把led_func当成led_on来用
2.3 函数指针的灵魂:回调函数
2.3.1 到底什么是回调函数?用大白话讲清
很多教程对回调函数的解释都太学术了:“回调函数就是一个通过函数指针调用的函数。你把函数的地址作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。”
看完是不是更懵了?
我给你打个比方:你去快递站取快递,但是快递还没到。这时候你有两个选择:
- 每隔 10 分钟去快递站问一次:“我的快递到了吗?”(这叫轮询)
- 你给快递员留个你的手机号,告诉他:“快递到了给我打电话。” 然后你该干嘛干嘛,等电话响了再去取。
这个 “你留给快递员的手机号”,就是回调函数。“快递到了给你打电话” 这个动作,就是回调。
说白了,回调函数就是:你提前写好一个函数,把它的地址告诉别人,当某个事件发生的时候,别人会自动调用这个函数来通知你。
2.3.2 回调函数经典场景:按键处理
// 新手版按键处理 while(1) { if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { HAL_Delay(20); // 消抖 if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) { // 按键1按下,执行操作1 led_on(); } while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待松开 } if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) { HAL_Delay(20); if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET) { // 按键2按下,执行操作2 led_off(); } while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET); } // 按键3、按键4、按键5... }这个写法能跑,但有个致命的问题:按键驱动和业务逻辑强耦合在一起了。
如果哪天我想改一下按键 1 按下的功能,我就得去改主循环里的代码。如果我有 10 个按键,主循环里就会有 10 个大if,代码又臭又长。
用回调函数怎么写呢?非常优雅:
第一步,我们写一个独立的按键驱动模块,它只负责检测按键是否按下,不关心按下之后要做什么:
// button.h typedef void (*ButtonCallback)(void); typedef struct { GPIO_TypeDef *port; uint16_t pin; ButtonCallback callback; // 按键按下的回调函数 } Button_Typedef; void button_init(Button_Typedef *button, GPIO_TypeDef *port, uint16_t pin, ButtonCallback callback); void button_scan(Button_Typedef *button);// button.c #include "button.h" void button_init(Button_Typedef *button, GPIO_TypeDef *port, uint16_t pin, ButtonCallback callback) { button->port = port; button->pin = pin; button->callback = callback; } void button_scan(Button_Typedef *button) { if(HAL_GPIO_ReadPin(button->port, button->pin) == GPIO_PIN_RESET) { HAL_Delay(20); if(HAL_GPIO_ReadPin(button->port, button->pin) == GPIO_PIN_RESET) { // 按键按下了,调用注册的回调函数 if(button->callback != NULL) { button->callback(); } while(HAL_GPIO_ReadPin(button->port, button->pin) == GPIO_PIN_RESET); } } }第二步,在主函数里,我们只需要给每个按键注册对应的回调函数就行:
// 业务逻辑函数 void led_on_callback(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); } void led_off_callback(void) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); } int main(void) { Button_Typedef key1, key2; // 初始化按键,并注册回调函数 button_init(&key1, GPIOA, GPIO_PIN_0, led_on_callback); button_init(&key2, GPIOA, GPIO_PIN_1, led_off_callback); while(1) { // 扫描按键 button_scan(&key1); button_scan(&key2); // 主循环做其他事情 // ... } }这样写的好处太明显了:
- 代码结构无比清晰,每个指令的处理函数都是独立的。
- 新增指令的时候,只需要写一个处理函数,然后在指令表里加一行就行,完全不用动
parse_protocol函数。 - 代码的可读性和可维护性直接上了一个档次。
2.4 使用函数指针和回调函数的坑
坑 1:函数指针定义少了括号
这个前面说过了,再强调一遍:
void (*func_ptr)(void); // 正确:函数指针 void *func_ptr(void); // 错误:这是一个返回void*的函数
坑 2:赋值的时候加了括号
func_ptr = led_on; // 正确:直接赋值函数名(地址) func_ptr = led_on(); // 错误:这是把函数的返回值赋给了指针坑 3:调用前没有判空
如果你的回调函数还没被注册,它的值就是NULL,这时候调用它会直接触发 HardFault 死机。
2.4:小总结
核心其实就两点:
- 指针函数是返回指针的函数,注意不要返回局部变量的地址。
- 函数指针是指向函数的指针,它最大的价值是实现回调函数,让代码解耦。
三 、结构体与枚举
前两章我们把指针相关的核心难点都啃完了,很多人说看完终于能看懂大佬写的那些满是*的代码了。但光会指针还不够,在实际项目里,90% 的代码都是在和数据打交道。怎么把零散的数据组织得清晰、好维护、不容易出错?这就是本章要讲的核心 ——结构体和枚举的工程化用法。我见过太多人写的代码,变量满天飞,一个功能里定义十几个单独的变量,比如temp1、temp2、hum1、hum2,光看变量名都不知道是什么意思。更要命的是,当你需要新增一个传感器的时候,得把所有用到这些变量的地方都改一遍,改到最后自己都晕了。结构体和枚举就是用来解决这个问题的。它们不是什么高深的语法,但用好了能让你的代码结构瞬间清晰一个档次。
3.1 结构体对齐:不止是 “省内存”
很多人还是只知道 “加__attribute__((packed))”,不知道为什么要对齐,也不知道什么时候该用什么时候不该用。今天我就把这个事儿彻底讲透。
3.1.1 为什么会有结构体对齐?
很多人以为结构体对齐是编译器闲的没事干,故意给你填充字节浪费内存。其实恰恰相反,对齐是为了提高 CPU 访问内存的效率。
举个最简单的例子:32 位的 CPU,一次能从内存里读取 4 个字节。如果一个int类型的变量(4 字节)存在地址0x00000000的位置,CPU 一次就能读完。但如果它存在0x00000001的位置,CPU 就得先读0x00000000-0x00000003,再读0x00000004-0x00000007,然后把两次读到的数据拼起来才能得到这个int的值,效率直接降了一半。
所以编译器会自动把结构体的成员对齐到合适的地址上,中间填充一些没用的字节。这就是结构体对齐的由来。
3.1.2 新手容易踩的坑:通信协议解析
结构体对齐最大的坑,就是在解析通信数据的时候。比如你和上位机约定了一个 12 字节的协议帧:
- 1 字节帧头
- 2 字节数据长度
- 8 字节数据
- 1 字节校验和
新手很自然地会写出这样的结构体:
typedef struct { uint8_t head; uint16_t len; uint8_t data[8]; uint8_t check; } ProtocolFrame;然后信心满满地用memcpy把收到的串口数据直接拷贝到这个结构体里,结果发现解析出来的数据全是错的。为什么?因为编译器会自动在head和len之间填充 1 个字节,导致这个结构体的实际大小变成了 13 字节,和你约定的 12 字节对不上
正确的做法:对于通信协议、寄存器映射这种需要严格按字节对齐的场景,必须手动取消结构体对齐:
// GCC编译器写法 typedef struct __attribute__((packed)) { uint8_t head; uint16_t len; uint8_t data[8]; uint8_t check; } ProtocolFrame; // Keil MDK编译器写法 typedef struct { uint8_t head; uint16_t len; uint8_t data[8]; uint8_t check; } __packed ProtocolFrame;加上这个关键字之后,编译器就不会再填充任何字节了,结构体的大小正好是 1+2+8+1=12 字节,和协议完全一致。
3.1.3 实践:什么时候该对齐,什么时候不该对齐?
- 必须取消对齐:通信协议帧解析、寄存器结构体定义、Flash 参数保存
- 保持默认对齐:普通的内部数据结构体,默认对齐能提高 CPU 访问速度,不要为了省几个字节强行取消对齐
另外,还有一个小技巧:合理安排结构体成员的顺序,把大类型的成员放在前面,小类型的放在后面,能有效减少填充字节,节省内存:
// 不好的写法:会填充3个字节,总大小12字节 typedef struct { uint8_t a; // 1字节 uint32_t b; // 4字节 uint8_t c; // 1字节 uint16_t d; // 2字节 } bad_struct; // 好的写法:只填充1个字节,总大小8字节 typedef struct { uint32_t b; // 4字节 uint16_t d; // 2字节 uint8_t a; // 1字节 uint8_t c; // 1字节 } good_struct;同样的成员,只是换了个顺序,就省了 4 个字节的内存。
3.2 结构体数组:批量管理数据
很多只会定义单个的结构体变量,比如:
sht30_dev_t sensor1; sht30_dev_t sensor2; sht30_dev_t sensor3;然后写代码的时候,每个传感器都要写一遍重复的逻辑:
sht30_read(&sensor1); sht30_read(&sensor2); sht30_read(&sensor3); process_temp(&sensor1); process_temp(&sensor2); process_temp(&sensor3);当你有 10 个传感器的时候,你就得写 10 遍几乎一模一样的代码。这不仅累,还容易出错,万一漏了一个,找 bug 都找不到。
这时候,结构体数组就派上用场了。
结构体数组,顾名思义,就是一个数组,里面的每个元素都是一个结构体。用它来批量管理同类型的设备,简直不要太爽。
3.2.1 实战场景:多温湿度传感器管理
比如我们做一个机房环境监测项目,有 3 个 SHT30 温湿度传感器,分别装在机房的不同位置。我们可以先定义一个传感器的结构体:
typedef struct { I2C_HandleTypeDef *hi2c; // 绑定的I2C总线 uint8_t addr; // 传感器I2C地址 float temp; // 当前温度 float hum; // 当前湿度 uint8_t err_cnt; // 连续错误次数 } sht30_dev_t; // 通用数组长度宏,项目里都会定义这个 #define ARRAY_SIZE(arr) (sizeof(arr)/sizeof(arr[0]))然后定义一个结构体数组,把所有的传感器都放进去:
// 3个SHT30传感器,分别在机房入口、机柜1、机柜2 sht30_dev_t sht30_list[] = { {&hi2c1, 0x44, 0.0, 0.0, 0}, // 入口传感器 {&hi2c1, 0x45, 0.0, 0.0, 0}, // 机柜1传感器 {&hi2c1, 0x46, 0.0, 0.0, 0}, // 机柜2传感器 };接下来,读取所有传感器的数据,只需要一个 for 循环就行:
void sht30_read_all(void) { for(int i=0; i<ARRAY_SIZE(sht30_list); i++) { if(sht30_single_read(&sht30_list[i]) != 0) { sht30_list[i].err_cnt++; printf("SHT30-%d 读取失败,连续错误%d次\r\n", i+1, sht30_list[i].err_cnt); // 连续3次错误,标记传感器故障 if(sht30_list[i].err_cnt >= 3) { printf("SHT30-%d 故障!\r\n", i+1); } } else { sht30_list[i].err_cnt = 0; printf("SHT30-%d: 温度%.1f℃ 湿度%.1f%%\r\n", i+1, sht30_list[i].temp, sht30_list[i].hum); } } }看到了吗?不管你有 3 个传感器还是 30 个传感器,代码都是这几行。以后要新增传感器,只需要在sht30_list数组里加一行初始化代码就行,其他地方完全不用改。
这就是结构体数组的威力:一次编写,批量处理。
3.2.2 进阶:结构体数组 + 函数指针
如果我们的项目不只有温湿度传感器,还有气压传感器、光照传感器呢?这时候,我们可以把函数指针也放进结构体里,实现一个通用的设备管理框架:
// 通用设备操作函数类型 typedef int (*dev_init_func)(void *dev); typedef int (*dev_read_func)(void *dev); // 通用设备节点结构体 typedef struct { const char *name; // 设备名称,打印日志用 dev_init_func init; // 设备初始化函数 dev_read_func read; // 设备数据读取函数 void *private; // 设备私有数据指针 } dev_node_t;然后,我们可以把所有不同类型的设备都放进同一个设备列表里:
// 各个传感器的私有数据 sht30_dev_t indoor_sht30 = {&hi2c1, 0x44, 0.0, 0.0, 0}; bmp280_dev_t outdoor_bmp280 = {&hi2c1, 0x76, 0.0, 0.0, 0}; bh1750_dev_t light_sensor = {&hi2c1, 0x23, 0.0, 0}; // 全局设备列表,新增设备只需要在这里加一行 dev_node_t dev_list[] = { {"室内温湿度", sht30_init, sht30_read, &indoor_sht30}, {"室外气压", bmp280_init, bmp280_read, &outdoor_bmp280}, {"光照强度", bh1750_init, bh1750_read, &light_sensor}, };现在,初始化和读取所有设备,只需要统一的接口:
void all_dev_init(void) { printf("开始初始化所有设备...\r\n"); for(int i=0; i<ARRAY_SIZE(dev_list); i++) { if(dev_list[i].init(dev_list[i].private) != 0) { printf("[ERROR] %s 初始化失败\r\n", dev_list[i].name); } else { printf("[OK] %s 初始化成功\r\n", dev_list[i].name); } } printf("所有设备初始化完成\r\n\r\n"); } void all_dev_read(void) { printf("===== 设备数据采集 =====\r\n"); for(int i=0; i<ARRAY_SIZE(dev_list); i++) { if(dev_list[i].read(dev_list[i].private) != 0) { printf("%s: 读取失败\r\n", dev_list[i].name); } } printf("========================\r\n\r\n"); }这个写法是不是特别优雅?不管你新增什么类型的设备,只要实现对应的init和read函数,然后加到设备列表里就行,上层代码完全不用动。这就是面向对象思想在 C 语言里的体现。
3.3 枚举加结构体嵌套:不使用代码中的魔法数字
不知道你有没有见过这样的代码:这些0、1、2就是传说中的 “魔法数字”。写的时候你可能知道是什么意思,但过一个月再看,你绝对会忘了2代表什么。更要命的是,如果哪天你想改一下状态的顺序,得把所有用到这些数字的地方都找出来改一遍,漏一个就是一个大 bug。解决这个问题的最佳方案,就是枚举。
if(dev_state == 0) { // 设备空闲 } else if(dev_state == 1) { // 设备运行中 } else if(dev_state == 2) { // 设备故障 }3.3.1 用枚举定义状态和命令
枚举就是把一组相关的常量定义成一个集合,用有意义的名字代替数字。比如上面的设备状态,我们可以定义成一个枚举:
typedef enum { DEV_STATE_IDLE, // 0:设备空闲 DEV_STATE_RUNNING, // 1:设备运行中 DEV_STATE_ERROR // 2:设备故障 } dev_state_t;dev_state_t dev_state = DEV_STATE_IDLE; if(dev_state == DEV_STATE_IDLE) { // 设备空闲,等待启动命令 } else if(dev_state == DEV_STATE_RUNNING) { // 设备运行中,执行采集任务 } else if(dev_state == DEV_STATE_ERROR) { // 设备故障,执行故障处理 }是不是瞬间清晰多了?就算过半年再看,你也能一眼看懂每个条件判断是什么意思。而且以后要新增状态,只需要在枚举里加一行就行,不用改任何业务逻辑。
3.3.2 枚举与结构体的完美结合
枚举和结构体是天生的一对,把它们结合起来,能让你的代码可读性达到极致。
比如我们要实现一个简单的 LED 状态机,控制运行指示灯和故障指示灯。我们可以先定义 LED 的状态枚举:
typedef enum { LED_STATE_OFF, // 常灭 LED_STATE_ON, // 常亮 LED_STATE_BLINK // 闪烁 } led_state_t;然后定义一个 LED 的结构体,把状态和相关参数都放进去:
typedef struct { GPIO_TypeDef *port; uint16_t pin; led_state_t state; // 当前状态 uint32_t blink_interval; // 闪烁间隔(ms) uint32_t last_tick; // 上次翻转时间 } led_dev_t;最后,写一个统一的 LED 处理函数
// 定义两个LED:运行灯和故障灯 led_dev_t run_led = {GPIOB, GPIO_PIN_5, LED_STATE_BLINK, 500, 0}; led_dev_t err_led = {GPIOB, GPIO_PIN_6, LED_STATE_OFF, 200, 0}; void led_process(led_dev_t *led) { switch(led->state) { case LED_STATE_OFF: HAL_GPIO_WritePin(led->port, led->pin, GPIO_PIN_RESET); break; case LED_STATE_ON: HAL_GPIO_WritePin(led->port, led->pin, GPIO_PIN_SET); break; case LED_STATE_BLINK: if(HAL_GetTick() - led->last_tick >= led->blink_interval) { HAL_GPIO_TogglePin(led->port, led->pin); led->last_tick = HAL_GetTick(); } break; } } // 主循环里只需要这样调用 while(1) { led_process(&run_led); led_process(&err_led); // 其他业务逻辑 // ... }3.4 位域:写寄存器配置
我们来讲一个嵌入式独有的技巧 ——位域。
在嵌入式开发中,我们每天都在和寄存器打交道。新手写寄存器配置,一般都是这样的:
// 配置PB5为推挽输出模式 GPIOB->MODER &= ~(3 << (5*2)); // 清除原来的模式 GPIOB->MODER |= (1 << (5*2)); // 设置为输出模式 // 配置为推挽输出 GPIOB->OTYPER &= ~(1 << 5); // 配置为高速模式 GPIOB->OSPEEDR &= ~(3 << (5*2)); GPIOB->OSPEEDR |= (3 << (5*2));这样写虽然能跑,但可读性非常差。你得对着芯片手册,一个位一个位地数,才能知道每一行代码在干什么。而且很容易写错移位的位数,一旦写错,调试起来特别麻烦。
用位域结构体来定义寄存器,就能完美解决这个问题。
3.4.1 用位域定义寄存器
位域允许我们指定结构体中每个成员占用的位数。比如 GPIO 的 MODER 寄存器,每个引脚占 2 位(00 输入 01 输出 10 复用 11 模拟),我们可以这样定义:
typedef struct { uint32_t moder0 : 2; // 引脚0模式 uint32_t moder1 : 2; // 引脚1模式 uint32_t moder2 : 2; // 引脚2模式 uint32_t moder3 : 2; // 引脚3模式 uint32_t moder4 : 2; // 引脚4模式 uint32_t moder5 : 2; // 引脚5模式 // ... 其他引脚 } gpio_moder_t;然后,我们就可以直接通过成员名来访问寄存器的每一位了:
gpio_moder_t *gpiob_moder = (gpio_moder_t *)&GPIOB->MODER; // 配置PB5为输出模式,不用再数移位了! gpiob_moder->moder5 = 1;是不是比之前的移位写法清晰太多了?你不用再数移位了多少位,直接看成员名就知道在配置哪个引脚的什么功能。
3.5 小总结:
核心其实就一个:用合适的数据结构来组织你的代码,而不是用一堆零散的变量。
四, 内存管理
4.1 先搞懂:单片机的内存到底是怎么分布的?
很多人学了好几年嵌入式,都不知道单片机的内存是怎么划分的。你写的代码、定义的变量、函数调用时的参数,到底存在哪里?为什么有的变量占 Flash,有的占 RAM?
其实很简单,一个 STM32 程序运行起来之后,内存主要分成这 4 个区域:
| 区域 | 存储内容 | 特点 |
| 代码区(Flash) | 程序代码、const 常量 | 只读,掉电不丢失 |
| 静态存储区(RAM) | 全局变量、static 变量 | 程序运行期间一直存在,掉电丢失 |
| 栈(Stack) | 局部变量、函数参数、返回地址 | 自动分配自动释放,先进后出 |
| 堆(Heap) | malloc 动态分配的内存 | 手动分配手动释放,需要自己管理 |
4.2 栈溢出:嵌入式最常见的死机原因
栈这个东西,是嵌入式里最脆弱也最容易出问题的地方。很多新手根本不知道栈有多大,随便在函数里定义一个大数组,直接就栈溢出了。
4.2.1 栈到底有多大?
STM32 默认的栈大小是多少?
答案是:1024 字节。没错,只有 1KB。
你没看错,就是这么小。在 Keil 里,栈大小是在启动文件里定义的:
; 启动文件 startup_stm32f103xb.s Stack_Size EQU 0x00000400 ; 就是这里,0x400 = 1024字节 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp也就是说,你所有函数里的局部变量、函数调用时的参数、返回地址,加起来不能超过 1KB。超过了,就会发生栈溢出。
4.2.2 栈溢出的 3 种常见情况
我见过的栈溢出,99% 都是这三种情况:
1. 函数内定义大尺寸局部数组
这是最容易犯的错误,没有之一。
// 错误示例!直接栈溢出 void uart_process(void) { uint8_t rx_buf[1024]; // 栈总共才1KB,你一个数组就占满了 HAL_UART_Receive(&huart1, rx_buf, 1024, 100); // ... 处理数据 }这个代码编译的时候不会有任何错误,甚至运行起来前几次可能都正常。但只要这个函数被调用,栈就直接溢出了,后面的内存会被覆盖,接下来会发生什么完全是随机的 —— 可能数据错乱,可能死机,可能某个函数返回的时候直接跳飞了。
正确的做法:大数组绝对不要定义成局部变量,要么定义成全局变量,要么加 static 关键字:
// 正确写法1:全局变量,存在静态存储区 uint8_t uart_rx_buf[1024]; void uart_process(void) { HAL_UART_Receive(&huart1, uart_rx_buf, 1024, 100); // ... } // 正确写法2:static局部变量,也存在静态存储区 void uart_process(void) { static uint8_t rx_buf[1024]; // 只会初始化一次 HAL_UART_Receive(&huart1, rx_buf, 1024, 100); // ... }2. 递归调用过深
递归这个东西,在嵌入式里能不用就不用。因为每递归调用一次,就会在栈上压入一层函数的参数和返回地址。递归深度稍微深一点,栈就爆了。
// 正确写法:用循环代替递归 int factorial(int n) { int result = 1; for(int i=2; i<=n; i++) { result *= i; } return result; }3. 中断服务函数里定义大局部变量
中断的栈和主程序的栈是同一个,而且中断是优先级最高的,一旦中断里发生栈溢出,整个系统直接就挂了。
// 错误示例:中断里定义大数组 void TIM2_IRQHandler(void) { uint8_t temp_buf[256]; // 中断里绝对不要这么写 // ... 处理中断 }中断服务函数里,只做最必要的事情,不要定义任何超过几个字节的局部变量,不要调用任何复杂的函数,不要加 HAL_Delay ()。
如果你遇到了以下情况,90% 是栈溢出了:
- 程序运行到某个函数的时候,突然毫无征兆地死机
- 局部变量的值莫名其妙地被修改
- 函数执行完之后,没有返回到正确的地方
- 单步调试的时候,一进入某个函数就直接跑飞了
4.3 堆:能不用就尽量不用
单片机里的堆有三个致命的问题:
1. 堆空间非常小
STM32 默认的堆大小只有 512 字节,比栈还小。你想分配一个 1KB 的内存,直接就失败了。
uint8_t *buf = malloc(1024); if(buf == NULL) { // 这里大概率会进来,因为堆只有512字节 printf("malloc failed!\r\n"); }2. 内存碎片问题
这是堆最致命的问题。你不断地 malloc 和 free,堆空间会被分割成很多小块,最后明明还有很多空闲内存,却没有一块连续的内存可以分配。
// 模拟内存碎片 uint8_t *p1 = malloc(100); uint8_t *p2 = malloc(100); uint8_t *p3 = malloc(100); free(p2); // 释放中间的100字节 uint8_t *p4 = malloc(200); // 分配失败!虽然总共有200字节空闲,但没有连续的这个问题在 PC 上有操作系统帮你解决,但在单片机里,没有垃圾回收机制,内存碎片只会越来越严重,最后系统必然会崩溃。
3. 分配失败没有处理
很多人写 malloc 的时候,根本不检查返回值是否为 NULL。一旦分配失败,指针就是野指针,后面的操作直接导致死机。
4.3.2 什么时候可以用堆?
程序启动的时候一次性分配一大块内存,然后自己管理,永远不释放。
比如你需要一个动态大小的缓冲区,在程序启动的时候根据配置分配一次,然后整个程序运行期间一直使用,直到关机。这种情况下不会产生内存碎片,是安全的。
其他任何情况,都不要用 malloc/free。
4.4 静态存储区:最安全也最容易被忽略
静态存储区是单片机里最稳定的内存区域,全局变量和 static 变量都存在这里。程序启动的时候初始化,运行期间一直存在,不会自动释放。
4.4.1 全局变量的优缺点
很多人说 “不要用全局变量”,这句话在 PC 端可能有道理,但在嵌入式里,全局变量是必不可少的。
优点:
- 不会栈溢出
- 可以在多个函数之间共享数据
- 生命周期长,程序运行期间一直存在
缺点:
- 占用 RAM 空间,直到程序结束才会释放
- 多任务环境下会有线程安全问题
- 代码耦合度高,不好维护
我的建议是:合理使用全局变量,不要滥用。那些整个系统都需要用到的状态、配置参数、大缓冲区,用全局变量完全没问题。但不要什么变量都定义成全局的,局部变量能解决的就用局部变量。
4.4.2 static 关键字的妙用
static 关键字在 C 语言里有两个作用:
- 修饰局部变量:改变变量的存储位置,从栈变成静态存储区,生命周期延长到整个程序运行期间
- 修饰全局变量 / 函数:限制作用域,只能在当前文件内访问
第一个作用我们前面已经讲过了,用来定义大的局部数组,避免栈溢出。
第二个作用非常重要,很多人都忽略了。如果你写的函数或者全局变量只在当前文件里使用,一定要加上 static 关键字。这样可以避免命名冲突,也能防止其他文件意外修改你的变量。
// 只在当前文件内使用的函数,加static static void led_toggle(void) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5); } // 只在当前文件内使用的全局变量,加static static uint32_t led_tick = 0;4.4.3 const:把数据放到 Flash 里,节省 RAM
这是嵌入式里一个非常重要的技巧,很多人不知道。
如果你定义的是一个不会被修改的常量,一定要加上 const 关键字。这样编译器会把这个常量放到 Flash 里,而不是 RAM 里,能节省大量的 RAM 空间。
// 错误写法:存在RAM里,占用128字节RAM char error_msg[][16] = { "SUCCESS", "PARAM_ERROR", "TIMEOUT_ERROR", // ... 共8个字符串 }; // 正确写法:存在Flash里,不占用RAM const char *error_msg[] = { "SUCCESS", "PARAM_ERROR", "TIMEOUT_ERROR", // ... };对于 STM32F103 这种只有 20KB RAM 的单片机来说,这个技巧能帮你省出好几 KB 的 RAM,非常有用。
4.6:小总结
- 大数组、大结构体绝对不要定义成局部变量,要么用全局,要么加 static
- 禁止使用递归,所有递归都用循环代替
- 中断服务函数里不要定义任何大的局部变量,不要做复杂操作
- 尽量不要用 malloc/free,除非你知道自己在干什么
- 所有不会被修改的常量都加 const,放到 Flash 里节省 RAM
- 只在当前文件内使用的函数和变量都加 static,避免命名冲突
- 指针使用前必须判空,绝对不要操作野指针
- 绝对不要返回局部变量的地址,这个前面已经强调过无数次了