前言
前六天主要是在补 C 语言基础和嵌入式常用数据处理能力:位运算、字符串、链表、数组、环形缓冲区、UART 帧解析。今天继续往前走,不再停留在单个函数练习,而是开始练真实项目里更常见的写法:把一组相关数据和操作封装成一个.h/.c模块。
今天的练习目录是:
workplace/exercises/day07_sensor_packet_module这次练的是一个传感器数据模块SensorPacket。它对应我自己的项目链路:
STM32 ADC 数据 -> SensorPacket -> [SENSOR] JSON 字符串 -> UART/DTU 发送 -> PC 网关解析 -> Web/MQTT 输出也就是说,今天不是单纯写一个“能跑的函数”,而是尝试把项目里的传感器数据格式集中管理起来。
一、今天的完成结果
今天完成了sensor_packet.c里的 4 个核心函数:
SensorPacketInitSensorPacketValidateSensorPacketToJsonSensorPacketFromLine
运行脚本:
& "D:\BaiduNetdiskDownload\精通嵌入式C语言\workplace\exercises\day07_sensor_packet_module\run_day07.ps1"实际输出:
Building day07 sensor packet module Running test_sensor_packet.exe test_sensor_packet passed All day07 sensor packet module exercises passed这说明今天的模块初始化、字段范围检查、JSON 打包、串口文本行解析都已经通过当前测试。
二、为什么今天要学.h/.c模块化
前几天的练习大多是单文件:一个.c文件里写函数、写测试、直接编译运行。这样适合训练基础,但真实项目不会一直这样写。
在 STM32 项目里,如果所有 ADC 处理、UART 打包、命令解析、OLED 显示、舵机控制都塞进一个文件,后面会很难维护。稍微改一个字段,就可能影响多个地方。
模块化的基本思路是:
.h 文件:告诉别人这个模块能用什么 .c 文件:实现这个模块具体怎么做今天的文件分工是:
sensor_packet.h 对外接口 sensor_packet.c 具体实现 test_sensor_packet.c 测试用例 run_day07.ps1 编译和运行脚本sensor_packet.h里放的是结构体、错误码和函数声明;sensor_packet.c里放的是函数实现和内部辅助函数。
三、SensorPacket数据结构
今天定义的核心结构体是:
typedef struct { int temp; int light; int mode; int servo; } SensorPacket;这四个字段正好对应项目里经常需要上传的数据:
temp:温度light:光照mode:工作模式servo:舵机 PWM 脉宽
用结构体集中描述这些字段,比在多个函数里散落四个独立变量更清晰。以后如果上报格式要加字段,比如设备 ID、电机状态、报警标志,也可以围绕这个结构体继续扩展。
四、错误码比直接打印更适合模块接口
今天没有让函数直接printf报错,而是统一返回错误码:
#define SENSOR_PACKET_OK 0 #define SENSOR_PACKET_ERR_NULL -1 #define SENSOR_PACKET_ERR_RANGE -2 #define SENSOR_PACKET_ERR_BUF_SMALL -3 #define SENSOR_PACKET_ERR_FORMAT -4这样做的好处是:模块本身只负责判断结果,不决定上层怎么处理。
比如:
- STM32 端可以在错误时点亮 LED 或丢弃本次上报。
- PC 网关端可以打印日志或返回 API 错误。
- 测试程序可以直接判断返回值是否符合预期。
这比在底层函数里直接打印一句“error”更可控。
五、初始化函数:给结构体一个确定状态
SensorPacketInit的作用是把结构体设置成默认值:
int SensorPacketInit(SensorPacket *packet) { if (packet == NULL) { return SENSOR_PACKET_ERR_NULL; } packet->temp = 0; packet->light = 0; packet->mode = 0; packet->servo = 1500; return SENSOR_PACKET_OK; }这里最重要的是两点:
- 先判断空指针。
- 不让结构体处于未初始化状态。
在嵌入式里,未初始化数据很危险。尤其是控制类字段,比如舵机脉宽,如果随机值被拿去输出 PWM,就可能导致错误动作。
六、范围检查:项目数据不能只看格式
今天写了一个内部辅助函数:
static int IsRange(int value, int min_value, int max_value) { return value >= min_value && value <= max_value; }它前面的static很重要,表示这个函数只在sensor_packet.c内部可见。外部模块不需要知道IsRange的存在,只需要调用SensorPacketValidate。
范围检查规则是:
temp : -40 到 125 light : 0 到 100 mode : 0 到 2 servo : 500 到 2500对应代码:
int SensorPacketValidate(const SensorPacket *packet) { if (packet == NULL) { return SENSOR_PACKET_ERR_NULL; } if (!IsRange(packet->temp, -40, 125)) { return SENSOR_PACKET_ERR_RANGE; } if (!IsRange(packet->light, 0, 100)) { return SENSOR_PACKET_ERR_RANGE; } if (!IsRange(packet->mode, 0, 2)) { return SENSOR_PACKET_ERR_RANGE; } if (!IsRange(packet->servo, 500, 2500)) { return SENSOR_PACKET_ERR_RANGE; } return SENSOR_PACKET_OK; }项目里不能只判断“有没有数据”,还要判断“数据是否合理”。例如光照百分比不应该超过 100,舵机脉宽也不应该超过安全范围。
七、JSON 打包:必须检查缓冲区大小
今天的输出格式是:
[SENSOR] {"light":74,"temp":27,"mode":0,"servo":1500}实现时使用snprintf:
written = snprintf(buf, buf_size, "[SENSOR] {\"light\":%d,\"temp\":%d,\"mode\":%d,\"servo\":%d}", packet->light, packet->temp, packet->mode, packet->servo);这里不能用不受限制的sprintf。因为在 MCU 里,输出缓冲区通常是固定大小。如果没有检查长度,就可能写越界。
判断方式是:
if ((size_t)written >= buf_size) { return SENSOR_PACKET_ERR_BUF_SMALL; }snprintf的返回值是“本来需要写入的字符数”,不包含结尾的\0。如果返回值大于等于缓冲区大小,就说明放不下完整字符串。
这个细节很适合面试时讲,因为它能体现内存边界意识。
八、从[SENSOR]行解析回结构体
PC 网关端收到的是一行文本,例如:
[SENSOR] {"light":74,"temp":27,"mode":0,"servo":1500}今天先用sscanf解析固定格式:
fields = sscanf(marker, "[SENSOR] {\"light\":%d,\"temp\":%d,\"mode\":%d,\"servo\":%d}", &parsed.light, &parsed.temp, &parsed.mode, &parsed.servo);然后判断是否解析出 4 个字段:
if (fields != 4) { return SENSOR_PACKET_ERR_FORMAT; }最后再调用SensorPacketValidate。这一步不能省,因为格式正确不代表数据合理。
今天这个解析器不是完整 JSON 解析器,它只适合当前固定格式练习。但对 Day7 来说重点不是造一个通用 JSON 库,而是理解模块接口、错误码、输入检查和项目数据流。
九、和自己项目的关系
这个模块可以直接对应到我的两个项目方向。
STM32 端可以这样理解:
ADC 采样值 -> 换算成 temp/light -> 填入 SensorPacket -> SensorPacketToJson -> UART/DTU 发送PC 网关端可以这样理解:
串口读到一行 [SENSOR] 文本 -> SensorPacketFromLine -> SensorPacketValidate -> 存入历史数据 -> Web API / MQTT 发布这样做的好处是数据格式集中。以后字段变化时,不需要在 STM32 端、PC 网关端、测试代码里到处找散落的字符串拼接逻辑。
十、今天必须能口述的问题
1..h和.c分别放什么?
.h文件放对外可见的内容,比如结构体、宏、错误码、函数声明。.c文件放具体实现和内部细节。这样其他文件只需要包含头文件,就知道怎么调用模块,而不需要关心内部怎么实现。
2. 为什么内部辅助函数要用static?
static修饰的函数只在当前.c文件内可见,可以隐藏实现细节,减少命名冲突,也避免外部模块绕过正式接口直接调用内部函数。
3. 为什么要返回错误码?
返回错误码可以让调用者决定怎么处理错误。底层模块不应该随便打印或直接退出,因为 STM32 端、PC 端、测试程序对错误的处理方式可能完全不同。
4. 为什么 JSON 打包要检查buf_size?
嵌入式系统常用固定长度缓冲区,如果不检查大小就写字符串,可能造成越界写入,破坏栈、全局变量或其他数据。snprintf能限制写入长度,但仍然要检查返回值,确认字符串是否完整写入。
总结
Day7 完成了一个新的 C 语言模块化练习:把传感器数据封装成SensorPacket,并实现初始化、范围校验、JSON 打包和[SENSOR]行解析。脚本已经通过:
test_sensor_packet passed All day07 sensor packet module exercises passed今天的重点不是多写几个函数,而是从“函数能跑”推进到“模块能被项目使用”。后续继续围绕 C 语言硬功和自己的 STM32/PC 网关项目展开,下一步可以学习函数指针和回调,把按键事件、串口接收事件、控制策略切换这些项目场景串起来。