news 2026/9/28 7:38:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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指向字段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; }

注意几个要点:

  1. cJSON_Parse失败会返回NULL,第一时间处理。
  2. 取字段用cJSON_GetObjectItemCaseSensitive,推荐大小写敏感版本。默认的cJSON_GetObjectItem是大小写不敏感的,你的协议如果写了"Cmd"和"cmd",它都会匹配,这在严谨的协议设计里不是什么好事。
  3. cJSON_IsString检查类型,valuestring判空,然后再去访问内容。
  4. 所有可能的错误路径里都要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开发板,别犹豫,直接开跑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 7:38:16

架构治理与技术债务管理:给系统长期演进留足底牌

设计一套架构其实不难&#xff0c;难的是一年后它还像你设计的那样。干过几年架构的人应该都有这种体会&#xff1a;系统刚上线时边界清晰、依赖明确&#xff0c;可随着需求堆叠&#xff0c;订单服务开始读取用户表&#xff0c;营销系统直接连上了生产库&#xff0c;配置项散落…

作者头像 李华
网站建设 2026/9/28 7:38:15

包头网站开发实战:不会代码用免费工具也能搞定

包头网站开发实战:不会代码用免费工具也能搞定 很多包头老板想给公司做个官网,第一反应是找外包。一问报价,几万块起步,还要维护费。其实真没必要。只要你会用 免费工具 ,配合简单的配置,完全能自己搞定。…

作者头像 李华
网站建设 2026/9/28 7:37:43

MQSS-Selector:基于强化学习的MLIR编译流水线动态调度框架

1. 项目概述&#xff1a;这不是一个“选哪个优化更省事”的小工具&#xff0c;而是一次编译器决策逻辑的底层重构MQSS-Selector 这个名字乍看像某个内部代号&#xff0c;但拆开来看——MQSS 是“Multi-Query Selection Strategy”的缩写&#xff0c;RL-Guided 指明了它的核心驱…

作者头像 李华
网站建设 2026/9/28 7:37:37

wordpress导航怎么弄避坑指南

零代码从零搭建WordPress导航的报价与避坑指南 很多刚入行或者想自己搞网站的朋友,最大的痛点就是: 自己不会代码想做网站 。面对满屏的HTML、CSS和PHP代码,脑子是懵的。其实,利用WordPress这个强大的开源CMS,你完全可以 从零搭建…

作者头像 李华
网站建设 2026/9/28 7:37:20

网站开发的完整流程图与门户网站建设一般多少钱对比

告别备案焦虑,看懂网站开发完整流程图与源码下载 备案流程一头雾水,是不是让你盯着后台页面发呆?很多刚入行的前端新手,拿到源码下载包后,卡在域名解析和ICP备案这一步,感觉像隔着玻璃看世界,看得见却摸不着。别慌,这很正常。今天咱们不整虚的,直接把【网站开发的完整流程图】拆解给你看,从买域名到上线,一步…

作者头像 李华
网站建设 2026/9/28 7:37:19

著名的国外设计网站有哪些?搭建高转化官网到底多少钱

著名的国外设计网站有哪些?搭建高转化官网到底多少钱 别被那些花里胡哨的模板网站骗了,看着挺热闹,实则丑得掉渣,客户一眼就划走。 你花了几万块做的官网,打开速度像老牛拉车,手机上看全是乱码,这种“模板网站太丑不够用”的痛,谁做官网谁懂。 很多老板问我,想做个像国外那些顶级大厂一样的网站, 多少钱 ?…

作者头像 李华