先抛出问题:你的STM32和上位机之间,现在是用什么格式传数据的?我在两年前接了一个环境监测项目,方案组一开始拍板用结构体直接打包发送,结果两个人联调三天没睡好:MCU端结构体一改,上位机端解析就要同步改,改完还要处理字节序、对齐、版本兼容。后来我们全部换成JSON文本通信,虽然码率没那么理想,但端到端联调、设备上云的效率直接翻了倍。
这篇文章就是那次实践沉淀下来的:从cJSON库的原理、STM32移植,到真实的打包、解析代码,再到串口帧协议和性能实测,一口气讲完。不管你是在写物联网节点、数据采集器,还是做设备屏幕显示配置项,只要MCU还有几十KB Flash和几KB RAM,这套方案都能直接参考。代码部分我会尽量给完整,争取你复制出去加进工程就能跑通。
1. 为什么MCU要用JSON?先看没有JSON时我们怎么传数据
1.1 结构体直传为什么总在项目中期翻车
把C结构体指针直接丢进串口,上位机再按同一个结构体解包,这是很多工程师的第一反应。原理上没有任何问题,但一到项目中期就麻烦起来:32位编译器的对齐规则、大小端、字段顺序,任何一边有改动,另一端就要同步升级。最头疼的是旧版本固件在客户现场还没升级,新版本上位机已经发布了,新老结构体互相不兼容,一旦报错你根本说不清是“解析失败”还是“内容错位”。
而且结构体里的字符串、动态数组处理起来更难受。你总不能把一个指针直接发过去,上位机那边拿到的地址毫无意义。最后只能手写序列化代码,一个字段一个字段地编码,做着做着你会发现,这不就是自己写了个半残的序列化框架吗。
1.2 自定义二进制协议到底败给了什么
二进制协议本身没有错,在强实时、高频数据链路里它依然是首选。我自己做电机控制的项目时,通讯帧还是用纯二进制,因为一帧数据就几个字节,延迟和带宽都有硬指标。但问题在于,每加一个字段就要改协议文档、改编解码函数、改测试用例,版本管理稍乱一点就崩。
尤其是设备状态上报这类字段只增不减的场景,二进制协议对字段的可选、缺省、嵌套集合支持得再好,也需要额外设计。而JSON天生就是这种模型:字段不存在就是不存在,新字段对旧解析器只是“多了一个不认识的名字”,完全不影响其他字段。
还有一点很现实——云平台、网关、上位机工具几乎默认JSON格式。Node-RED、Postman、腾讯云IOT、阿里云IOT,全都是JSON。设备端如果坚持用二进制,最后也得在上位机前加一层转换。既然逃不掉,不如直接在MCU端生成JSON。
1.3 JSON入局后,协议演进变得简单了
换成JSON之后最直观的感受是:MCU端想加一个传感器字段,只要在打包函数里多写一行cJSON_AddNumberToObject;上位机端即使没同步升级,也能自动忽略这个新字段。上位机想新增一个控制指令,只需要发一条新cmd的JSON,老固件对未知命令选择忽略就行,不会崩。
JSON自带的另一个大优势是可读性。联调阶段抓包,用串口助手直接看文本就能定位问题,不需要再写一个十六进制解析脚本。团队里就算换了个不熟悉协议的新人,给他看一条JSON样例,他马上就知道该怎么扩展。
不同的轻量级方案我也简单对比过,供你选型:
| 方案 | 可读性 | 跨语言 | 字段扩展 | 资源成本 | 适用场景 |
|---|---|---|---|---|---|
| 结构体直传 | 低 | 一般 | 低 | 极低 | 同构平台、高频数据流 |
| 自定义二进制 | 低 | 较低 | 中 | 低 | 强实时、长连接 |
| CSV/分隔文本 | 中 | 高 | 低 | 低 | 简单配置与日志 |
| JSON | 高 | 高 | 高 | 中 | 状态上报、指令下发、云平台对接 |
当然,JSON也不是银弹。对带宽极度敏感、单帧数据量只有几个字节的场景,还是老老实实走二进制。但如果你做的是带屏设备、物联网网关、数据采集终端,JSON这套方案基本能覆盖大部分需求。
2. cJSON能在MCU上跑,靠的就是这个结构体
2.1 先认识 cJSON 的核心结构体
cJSON整个库最核心的就是那一个结构体,理解了它,整个库的用法就理解了一半:
typedef struct cJSON { struct cJSON *next; /* 链表:指向下一个兄弟节点 */ struct cJSON *prev; /* 链表:指向上一个兄弟节点 */ struct cJSON *child; /* 子节点:对象/数组的第一个元素 */ int type; /* 节点类型 */ char *valuestring; /* 字符串值 */ int valueint; /* 整数值 */ double valuedouble; /* 浮点值 */ char *string; /* 字段名(键) */ } cJSON;next和prev把同一层的字段串成一个双向链表,child负责指向下一层。比如{"a":1,"list":[true,null]},根对象的child指向字段a,a的next指向list;list的child指向数组第一个元素true,true的next指向null。整个JSON在内存里就是一棵多叉树,树的每个节点就是这样一个结构体。
type字段则是用一系列宏来标记类型:cJSON_Object、cJSON_Array、cJSON_String、cJSON_Number、cJSON_True、cJSON_False、cJSON_NULL。解析和打印全靠它来判断当前节点该怎么处理。
2.2 一棵 JSON 树是如何被“挂”起来的
cJSON_Parse负责把文本解析成上面说的这棵树,返回根节点指针。cJSON_Delete则从根节点开始递归释放整棵树。所以只要拿到根节点,析构一整个JSON对象只需要一次调用。
这里有个特别容易搞混的“所有权转移”规则:cJSON_AddItemToObject(root, "sensor", sensor)之后,sensor节点就归root所有了,你不需要再单独释放它;同时你仍然可以继续通过sensor指针往它下面挂子节点。如果之后你想删掉这个字段,不能直接cJSON_Delete(sensor)——那会让整棵树丢孩子,应该直接删根,或者用cJSON_DetachItemFromObject先把sensor摘下来再释放。
2.3 内存钩子:cJSON 怎么接入你的RTOS
cJSON默认使用标准库的malloc和free。在STM32裸机环境下这没什么问题,只要把Keil/CubeIDE里的堆空间配够就行。但在FreeRTOS这类RTOS下,我更建议把分配器换成内核的pvPortMalloc和vPortFree,统一走RTOS堆,内存碎片和剩余空间都方便观测。
#include "cJSON.h" #include "FreeRTOS.h" static void *cjson_malloc(size_t size) { return pvPortMalloc(size); } static void cjson_free(void *ptr) { vPortFree(ptr); } void cjson_init_hooks(void) { cJSON_Hooks hooks; hooks.malloc_fn = cjson_malloc; hooks.free_fn = cjson_free; cJSON_InitHooks(&hooks); }cJSON_InitHooks必须在调用任何cJSON API之前执行,提前到main函数初始化的位置就行。要特别留意的是:如果你在启动文件里把Heap_Size配得很小,标准库的malloc可能频繁失败,这时候用RTOS heap配合xPortGetFreeHeapSize()观察剩余内存会直观得多。
3. 在STM32工程里把cJSON跑起来:配置、移植、第一个Demo
3.1 引入源码与工程配置
cJSON是一个纯C库,源码就cJSON.c和cJSON.h两个文件,依赖很少,从GitHub release页拿到对应版本后,放进工程即可。
在Keil MDK里,我把这两个文件放在Middlewares/cJSON目录下,然后在工程中添加cJSON.c,在Options的C/C++页里把include路径加到Middlewares/cJSON。如果是CubeIDE或CMake工程,直接加源文件、加头文件路径就行。
有一点容易被坑:Keil的AC5编译器默认不是C99标准。cJSON源码用了一些C99的注释风格,部分版本在C90模式下会报警。在Options的C/C++页勾选C99之后再编译,才是最稳的。
3.2 编译裁剪宏
cJSON提供了两个裁剪宏:CJSON_NO_PRINT和CJSON_NO_PARSE。如果你的设备只上报数据、不接收指令,可以在编译时定义CJSON_NO_PRINT,直接把打印相关代码裁掉,能省下不少Flash。反过来,如果只接收指令、不上报,就定义CJSON_NO_PARSE。这个对资源紧张的MCU很实用。
在Keil里,在C/C++页的Define栏加入CJSON_NO_PRINT即可;CMake工程里则是target_compile_definitions(... PRIVATE CJSON_NO_PRINT)。
3.3 跑通第一个Demo并看到JSON输出
移植完成后,先跑一个最小Demo验证环境:
#include "cJSON.h" void demo_json(void) { cJSON *root = cJSON_CreateObject(); if (!root) return; cJSON_AddStringToObject(root, "greet", "hello stm32"); cJSON_AddNumberToObject(root, "magic", 42); cJSON_AddBoolToObject(root, "ok", 1); char *out = cJSON_PrintUnformatted(root); if (out) { printf("out = %s\r\n", out); cJSON_free(out); /* 注意:Print返回的字符串要用cJSON_free释放 */ } cJSON_Delete(root); /* 释放整棵树 */ }如果printf重定向正常,串口里能看到:
out = {"greet":"hello stm32","magic":42,"ok":true}这里最容易犯的错是:只free(out)不cJSON_Delete(root),或者反过来。cJSON_PrintUnformatted返回的字符串是动态分配的,必须用cJSON_free释放;root这棵树也必须通过cJSON_Delete释放。两个是独立的内存资源,谁都不能漏。
4. 传感器上报的实现:一次完整的cJSON打包与串口发送
4.1 上报帧格式设计:不能只图能跑
我习惯在写代码前,先把JSON样例手写出来,确认字段名和嵌套层级。比如我们要构造这样一条上报帧:
{ "device_id": "env_01", "timestamp": 1700000000, "sensor": { "temperature": 28.5, "humidity": 65.3, "adc_value": 2048 }, "alarm": false, "battery": 86, "ext": [1, 2, 3] }设计时有几个原则供你参考:字段能平铺就平铺,嵌套层级越浅越好;字段名尽量短但别牺牲可读性,比如device_id比did好维护;数字字段保持数字类型,别在数字和字符串之间来回横跳;如果是走LoRa这种窄带链路,字段名再短一点,甚至直接上数组。
4.2 完整打包代码:对象、嵌套对象、数组
下面这个函数比较完整地演示了创建对象、嵌套对象、数组、布尔值和字符串的全过程:
static uint32_t timestamp_now(void) { return HAL_GetTick() / 1000U; /* 演示用,实际项目请替换为UTC秒 */ } static int build_sensor_report(char *out, size_t outsz) { cJSON *root = cJSON_CreateObject(); if (!root) return -1; cJSON_AddStringToObject(root, "device_id", "env_01"); cJSON_AddNumberToObject(root, "timestamp", (double)timestamp_now()); /* 嵌套对象 */ cJSON *sensor = cJSON_CreateObject(); if (!sensor) { cJSON_Delete(root); return -1; } cJSON_AddItemToObject(root, "sensor", sensor); /* 所有权转移给root */ cJSON_AddNumberToObject(sensor, "temperature", 28.5); cJSON_AddNumberToObject(sensor, "humidity", 65.3); cJSON_AddNumberToObject(sensor, "adc_value", 2048); cJSON_AddBoolToObject(root, "alarm", 0); cJSON_AddNumberToObject(root, "battery", 86); /* 数组 */ cJSON *ext = cJSON_CreateArray(); if (!ext) { cJSON_Delete(root); return -1; } cJSON_AddItemToObject(root, "ext", ext); for (int i = 0; i < 3; i++) { cJSON *num = cJSON_CreateNumber(i + 1); if (num) { cJSON_AddItemToArray(ext, num); /* 所有权转移给数组 */ } } char *json = cJSON_PrintUnformatted(root); if (!json) { cJSON_Delete(root); return -2; } size_t len = strlen(json); if (out && len < outsz) { memcpy(out, json, len + 1); } cJSON_free(json); cJSON_Delete(root); return (int)len; }调用方式很简单:
char txbuf[256]; int len = build_sensor_report(txbuf, sizeof(txbuf)); if (len > 0) { HAL_UART_Transmit(&huart1, (uint8_t *)txbuf, len, 1000); }实际输出长这样:
{"device_id":"env_01","timestamp":1700000000,"sensor":{"temperature":28.5,"humidity":65.3,"adc_value":2048},"alarm":false,"battery":86,"ext":[1,2,3]}注意"alarm":false,cJSON对布尔类型的输出就是true和false,不是1和0。这个坑我见过好几次,上位机如果按0/1去判断,条件判断会直接失效。
4.3 输出与发送:PrintUnformatted和PrintBuffered怎么选
cJSON有三种打印方式,很多人不知道选哪个:
cJSON_Print:带格式化的输出,有换行和缩进,适合调试看。cJSON_PrintUnformatted:紧凑输出,没有多余空白,适合通信链路。cJSON_PrintBuffered(root, prebuffer, fmt):预先分配指定大小的缓冲区,减少内部realloc次数,对内存碎片敏感的环境更友好。
我在数据上报场景里优先用cJSON_PrintUnformatted。如果你每分钟上报上千次,或者走LoRa这种慢链路,建议换cJSON_PrintBuffered(root, 256, 0),预分配256字节能明显减少malloc次数,内存碎片问题会缓解不少。
4.4 所有权边界:谁创建谁释放
这是cJSON最容易让人翻车的地方,我再强调一遍:
cJSON_AddItemToObject挂载成功后,item的所有权归父节点,不需要也不能单独释放它。cJSON_PrintUnformatted返回的char*由你负责,用cJSON_free释放。cJSON_GetObjectItemCaseSensitive返回的指针指向树内部节点,绝对不能cJSON_Delete它。- 创建子对象后如果还没来得及挂载就出错,必须自己
cJSON_Delete释放,否则就是内存泄漏。
我见过有人写了这样一段代码,把整个树给毁了:
cJSON *child = cJSON_GetObjectItemCaseSensitive(root, "sensor"); cJSON_Delete(child); /* 错误示范:相当于在树上挖掉一块,树结构损坏 */正确做法永远只有一个:要么cJSON_Delete(root)整棵释放,要么先用cJSON_DetachItemFromObject摘下来再删除。
5. 指令下发的实现:防御式JSON解析与动作执行
5.1 先定义指令协议
数据上报解决的是“上行”,指令下发则是“下行”。我在项目里一般把控制指令设计成下面这种:
{"cmd":"ctrl","target":"led","action":1} {"cmd":"query","target":"status"}cmd是路由字段,target是操作对象,action是参数。结构简单,MCU端就一个大的if-else或者switch就能处理。
5.2 防御式解析:每一次取值都要“验明正身”
上位机发过来的数据是不可信的,缺字段、类型错误、空值都可能出现。嵌入式代码里哪怕一次空指针解引用,就是HardFault。所以解析函数我习惯写成下面这种防御式风格:
int parse_control_msg(const char *msg) { cJSON *root = cJSON_Parse(msg); if (!root) return -1; cJSON *cmd = cJSON_GetObjectItemCaseSensitive(root, "cmd"); if (!cJSON_IsString(cmd) || cmd->valuestring == NULL) { cJSON_Delete(root); return -2; } if (strcmp(cmd->valuestring, "ctrl") == 0) { cJSON *target = cJSON_GetObjectItemCaseSensitive(root, "target"); cJSON *action = cJSON_GetObjectItemCaseSensitive(root, "action"); if (cJSON_IsString(target) && cJSON_IsNumber(action)) { if (strcmp(target->valuestring, "led") == 0) { led_set(action->valueint); } } } else if (strcmp(cmd->valuestring, "query") == 0) { do_query_status(); } cJSON_Delete(root); return 0; }注意几个要点:
cJSON_Parse失败会返回NULL,第一时间处理。- 取字段用
cJSON_GetObjectItemCaseSensitive,推荐大小写敏感版本。默认的cJSON_GetObjectItem是大小写不敏感的,你的协议如果写了"Cmd"和"cmd",它都会匹配,这在严谨的协议设计里不是什么好事。 cJSON_IsString检查类型,valuestring判空,然后再去访问内容。- 所有可能的错误路径里都要
cJSON_Delete(root),不能提前return泄漏内存。
5.3 数组和嵌套对象怎么安全地取
指令里带数组也很常见,比如一次性下发多个参数:
{"cmd":"set_multi","values":[10,20,30]}解析数组的标准姿势是先拿长度,再逐个取:
cJSON *values = cJSON_GetObjectItemCaseSensitive(root, "values"); if (!cJSON_IsArray(values)) { cJSON_Delete(root); return -3; } int n = cJSON_GetArraySize(values); for (int i = 0; i < n; i++) { cJSON *item = cJSON_GetArrayItem(values, i); if (cJSON_IsNumber(item)) { params[i] = item->valueint; } }cJSON_GetArrayItem在越界时返回NULL,所以cJSON_IsNumber检查必须做,不能默认每个元素都存在且类型正确。
5.4 解析失败时怎么定位问题
cJSON_Parse失败后,可以用cJSON_GetErrorPtr拿到错误位置附近的字符串:
cJSON *root = cJSON_Parse(msg); if (!root) { const char *err = cJSON_GetErrorPtr(); printf("json parse error near: %s\r\n", err ? err : "unknown"); return -1; }但这里有个潜在大坑:cJSON_GetErrorPtr返回的指针指向的是外部传入的msg字符串内部,并且该错误信息是全局变量保存的。如果你的程序是多线程的,另一个线程在这之后又调用了一次cJSON_Parse并失败,这个指针可能会指向新的错误位置,旧信息就被覆盖了。所以在单线程主循环里用它定位问题没问题,在RTOS多任务环境里不要依赖它做跨线程的错误判断。
另外,如果收到的JSON被故意弄成很深层的嵌套,cJSON_Parse会递归解析,栈空间可能溢出。我通常会在调cJSON_Parse之前做一个深度预检:
static int json_depth_ok(const char *s, int max_depth) { int depth = 0; for (; *s; s++) { if (*s == '{' || *s == '[') { if (++depth > max_depth) return 0; } else if (*s == '}' || *s == ']') { depth--; } } return 1; }这个预检函数没法处理字符串中包含花括号的边界情况,但作为一个粗筛已经够用。真正严谨的方案是直接在协议层限制最大报文长度,从源头上控制风险。
6. 串口链路协议与状态机:把JSON放到可靠的帧里
6.1 为什么不能直接在中断里调用cJSON_Parse
很多人拿到一段JSON就往串口中断里塞,在HAL_UART_RxCpltCallback里直接调用cJSON_Parse,这是最容易埋雷的位置。cJSON_Parse要调用malloc做动态分配,还要做一整套状态机解析,耗时随报文长度线性增长。在72MHz的F103上解析300字节的JSON可能要几百微秒,中断里背着这么重的负载,实时性会变得很难看。
更要命的是,如果你在FreeRTOS下把malloc替换成了pvPortMalloc,那函数本身不是ISR-safe,你在中断里调用,系统说挂就挂。正确姿势永远是:中断只负责把字节塞进环形缓冲区,主循环里再做帧解析和JSON解析。
#define RINGBUF_SIZE 256 typedef struct { uint8_t buf[RINGBUF_SIZE]; uint16_t head; uint16_t tail; } ringbuf_t; static ringbuf_t rx_rb; static uint8_t rx_byte; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rx_rb.buf[rx_rb.head] = rx_byte; rx_rb.head = (rx_rb.head + 1) % RINGBUF_SIZE; HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } } /* 返回1表示成功取出一个字节 */ static int ringbuf_pop(ringbuf_t *rb, uint8_t *byte) { if (rb->head == rb->tail) return 0; *byte = rb->buf[rb->tail]; rb->tail = (rb->tail + 1) % RINGBUF_SIZE; return 1; }初始化时调用HAL_UART_Receive_IT(&huart1, &rx_byte, 1);开启单字节中断接收,主循环里再慢慢消费环形缓冲区。
6.2 一个简单的帧协议和接收状态机
JSON文本本身没有结束边界,串口收到的流可能粘包、半包。所以我在实际项目里用了一个简单的帧协议:帧头2字节AA 55,2字节小端长度,后面是JSON负载,最后1字节校验(负载逐字节累加和低8位)。
帧结构:
AA 55 LL LL [JSON负载] 校验接收状态机代码:
typedef struct { uint8_t state; uint8_t buf[256]; uint16_t len; uint16_t target_len; uint8_t sum; } rx_frame_t; #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 static void frame_rx_reset(rx_frame_t *fr) { fr->state = 0; fr->len = 0; fr->target_len = 0; fr->sum = 0; } /* 返回值:1表示收完一帧,负数表示帧错误,0表示还在接收中 */ static int frame_rx_push(rx_frame_t *fr, uint8_t b) { switch (fr->state) { case 0: if (b == FRAME_HEAD1) fr->state = 1; break; case 1: if (b == FRAME_HEAD2) { fr->state = 2; fr->len = 0; fr->sum = 0; } else { fr->state = (b == FRAME_HEAD1) ? 1 : 0; } break; case 2: fr->target_len = b; /* 长度低字节 */ fr->state = 3; break; case 3: fr->target_len |= (uint16_t)b << 8; /* 长度高字节 */ if (fr->target_len > sizeof(fr->buf)) { frame_rx_reset(fr); return -1; } fr->state = 4; break; case 4: fr->buf[fr->len++] = b; fr->sum += b; if (fr->len >= fr->target_len) { fr->state = 5; } break; case 5: frame_rx_reset(fr); if (b != fr->sum) return -2; return 1; /* 完整且校验通过 */ } return 0; }主循环里这样用:
rx_frame_t frame; frame_rx_reset(&frame); uint8_t b; while (1) { if (ringbuf_pop(&rx_rb, &b)) { int r = frame_rx_push(&frame, b); if (r == 1) { parse_control_msg((char *)frame.buf); frame_rx_reset(&frame); } else if (r < 0) { frame_rx_reset(&frame); } } }6.3 粘包、半包实测与问题判断
测试帧协议时,用串口助手发下面这帧数据,负载是{"cmd":"query"},累计校验是0x24:
AA 55 0F 00 7B 22 63 6D 64 22 3A 22 71 75 65 72 79 22 7D 24半包测试:先发AA 55 0F 00,停顿几百毫秒,再发后面的负载和校验。预期效果是状态机不会乱,收到后半段后仍能完整解出一帧并执行查询。
粘包测试:连续发两次完整帧。预期效果是主循环解出两帧,执行两次查询。
实际项目中还要给状态机加超时清理,否则如果只收到帧头帧尾就断了,设备会一直卡在等长度或等负载的状态。可以在主循环里记录最近一次收到字节的时间,超过200ms没新数据就frame_rx_reset。
7. 资源账本:Flash占用、解析耗时与内存优化的实测数据
7.1 测试环境与实测数据
我的测试平台是STM32F103C8T6,主频72MHz,Keil MDK 5.x,AC5编译器,-O2优化,cJSON版本1.7.18。测试报文是上面的sensor上报帧,大约150字节。
| 项目 | 参考数值 |
|---|---|
| 全功能cJSON编译后.text | 约9~12 KB |
| 关闭CJSON_NO_PRINT后 | 约可减少3~5 KB |
| 解析150B报文耗时 | 约0.2~0.5 ms |
| 打印150B报文耗时 | 约0.5~1.0 ms |
| 单棵JSON树峰值堆占用 | 约1.5~2.5 KB |
这些数字在不同编译器、不同优化等级下会有明显差异。AC6的-O2通常比AC5优化得更狠,cJSON裁剪后体积还能再压。但无论如何,对F103这种级别的芯片来说,这套成本完全能接受。
7.2 用DWT精确测量Parse和Print的耗时
我实测时用ARM内核的DWT计数器,精度比HAL_GetTick高得多:
#include "core_cm3.h" static void dwt_enable(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } /* 使用示例 */ dwt_enable(); DWT->CYCCNT = 0; cJSON *root = cJSON_Parse((const char *)frame.buf); uint32_t ticks = DWT->CYCCNT; float us = (float)ticks / (SystemCoreClock / 1000000.0f); printf("parse %lu ticks, %.2f us\r\n", ticks, us);有了这个工具,优化前后效果一测便知,比拍脑袋猜强太多。
7.3 资源优化三板斧
第一板斧是裁剪功能。只上报不解析就定义CJSON_NO_PRINT,只接收指令就定义CJSON_NO_PARSE,能省不少Flash。第二板斧是减少中间拷贝。cJSON_PrintUnformatted拿到字符串后直接串口发送,不要再用snprintf拼一层壳,每多一次拷贝就多一次堆操作。
第三板斧是控制报文长度。字段名能缩多短缩多短,数组能代替对象就尽量允许上层协议用数组索引。走LoRa这类窄带链路时,哪怕一个字节都很值钱。
8. 高频踩坑复盘:空指针、浮点精度、转义与内存碎片
8.1 空指针检查不彻底,越界数据直接HardFault
我在联调时遇到过一个很经典的场景:上位机同事随手发了一条{"cmd":"ctrl"},代码里没有判空就执行了GetObjectItemCaseSensitive(root, "target")->valueint,F103当场HardFault。从那之后我定了个规矩:所有从树里取出的指针,使用前必须做类型检查,访问字符串前必须判valuestring非空。这不是小心过度,而是嵌入式解析外部输入的底线。
8.2 cJSON_Delete只认根节点,删除子节点就是埋雷
再次强调,cJSON的树是一个整体。想删除某个字段,标准做法是cJSON_DetachItemFromObject先摘除,再决定是删除这一个节点还是留着复用。直接cJSON_Delete子节点会让整棵树的内部链表断掉,后续打印出来的JSON会莫名其妙少字段,甚至内存访问异常。
8.3 浮点数的“魔幻精度”与转义陷阱
cJSON内部用double存数字,打印时对整数做了优化,但小数部分仍受二进制浮点表示影响。某些编译器组合下,28.5可能被打印成28.4999999。项目里对精度敏感的值,我一般放大成整数存:比如温度扩大100倍,用2850表示28.50度。或者干脆格式化成字符串:
char temp_str[16]; snprintf(temp_str, sizeof(temp_str), "%.2f", temperature); cJSON_AddStringToObject(root, "temperature", temp_str);转义问题同样隐蔽。手工拼JSON时,如果设备名称里带了引号、反斜杠或换行,拼出来的字符串就是非法JSON。用cJSON_AddStringToObject会自动处理转义,这也是我坚持不让团队手工拼JSON的原因。
8.4 内存碎片:长期运行的隐性杀手
低端MCU上跑cJSON,最怕的不是解析慢,而是内存碎片。设备连续跑几个小时甚至几天后,堆上散落着各种大小不一的空闲块,新的大块内存申请就可能失败。现象就是上报突然断了,查日志发现cJSON_Parse返回NULL,而报文本身没有问题。
规避手段我按优先级排列:把malloc换成FreeRTOS的heap_4,至少能观察剩余堆;用cJSON_PrintBuffered预分配减少realloc次数;控制上报频率,不要无意义地频繁创建删除大对象;如果实在没法彻底解决,加一个定时健康检查,堆剩余低到阈值就主动重启。
我个人的体会是,cJSON带来的开发效率提升是实打实的,但代价是必须把内存管理的意识提上去。拿上面这套代码跑一遍,从打包到解析再到串口帧协议,全链路打通之后,你会感受到“加个字段像改配置一样简单”有多舒服。如果你手里正好有一块STM32开发板,别犹豫,直接开跑。