ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32上的cJSON实战:从串口协议到物联网设备上云

STM32上的cJSON实战:从串口协议到物联网设备上云 先抛出问题你的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指向字段aa的next指向listlist的child指向数组第一个元素truetrue的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 怎么接入你的RTOScJSON默认使用标准库的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跑起来配置、移植、第一个Demo3.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:falsecJSON对布尔类型的输出就是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 552字节小端长度后面是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}累计校验是0x24AA 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主频72MHzKeil MDK 5.xAC5编译器-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)-valueintF103当场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开发板别犹豫直接开跑。
返回列表