简介:这份资源面向使用STM32进行嵌入式开发的工程师与学习者,聚焦于在单片机工程中集成cJSON库,解决JSON格式数据的解析与生成问题。压缩包共133个文件,约1.76MB,以31个c源文件与31个h头文件为核心,涵盖STM32标准外设库驱动与cJSON实现,另含Keil工程配置、编译中间文件及readme说明,工程已实际验证,可直接嵌入项目使用。已有3250人学习下载,说明其在嵌入式JSON通信场景中具有较高参考价值。读者可从中获得一套可运行的STM32工程模板,理解cJSON在资源受限环境下的内存管理与接口调用方式,并借助现成的外设驱动与工程结构快速完成串口、网络等场景下的JSON数据收发调试,减少从零搭建与移植的时间成本。
1. 串口收到一包 JSON,STM32 上怎么把它拆干净
调试串口时收到一包{"temp":25.6,"hum":60,"ok":true},用sscanf硬抠字段,字段一多、嵌套一层就崩,改一次协议改一次解析代码,这是很多嵌入式项目里真实发生过的翻车现场。这份资源围绕 cjson 在 STM32 上的落地使用,解决的就是「单片机端如何稳定解析 JSON 字符串」这件事:把 cJSON 源码移植进工程,用cJSON_Parse建树、用cJSON_GetObjectItem取值、用完cJSON_Delete释放,形成一套可复用的解析流程。适合正在做设备上报、上位机下发配置、模块间用 JSON 通信的嵌入式开发者,尤其是 RAM 紧张、又不想自己手写状态机解析器的场景。下面按移植、解析、内存管理、避坑的顺序拆开讲。
2. 把 cJSON 塞进 STM32 工程:源码裁剪与堆配置
cJSON 本身是纯 C 的单文件实现,核心就cJSON.c和cJSON.h两个文件,不依赖操作系统,也不依赖标准库里的文件 IO,这一点对裸机环境很友好。但「能编译过」和「能稳定跑」是两回事,移植阶段真正要处理的是内存来源和编译选项。
2.1 源码获取与工程目录组织
拿到资源包后,先确认里面 cJSON 的版本形态。常见做法是把cJSON.c、cJSON.h放到工程的Middlewares/cJSON/目录下,然后在 IDE 的 include 路径里加上这个目录。不要直接把文件丢进Src/根目录,后面换芯片或换工程时不好剥离。
目录建议这样组织:
Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ └── json_task.c ├── Middlewares/ │ └── cJSON/ │ ├── cJSON.c │ └── cJSON.h └── Drivers/json_task.c是自己写的业务封装层,把 cJSON 的调用包起来,业务代码不直接碰 cJSON 的 API。这样以后换解析库,改动面只在一个文件里。
2.2 内存钩子:让 cJSON 走自己的堆
cJSON 默认用malloc/free。在 STM32 上,如果你用的是标准库的malloc,它背后是_sbrk管理的堆,和 FreeRTOS 的 heap 是两套东西,混用容易出问题。更稳的做法是给 cJSON 挂内存钩子,让它走你指定的内存池。
cJSON 提供了cJSON_InitHooks,在初始化阶段调用一次即可:
#include "cJSON.h" /* 自定义分配函数,内部走 FreeRTOS 的 pvPortMalloc */ static void *json_malloc(size_t size) { return pvPortMalloc(size); } static void json_free(void *ptr) { vPortFree(ptr); } void json_module_init(void) { cJSON_Hooks hooks; hooks.malloc_fn = json_malloc; hooks.free_fn = json_free; cJSON_InitHooks(&hooks); /* 全局生效,只需调用一次 */ }逻辑说明:cJSON_InitHooks把 cJSON 内部所有malloc/free替换成你传入的函数指针,之后每次cJSON_Parse、cJSON_CreateObject分配节点都走pvPortMalloc。参数上要注意,malloc_fn的签名必须是void *(size_t),free_fn是void (void *),签名对不上编译会报错。裸机没有 RTOS 的话,把这两个函数换成自己实现的静态内存池分配器即可,思路一样。
提示:
cJSON_InitHooks是全局设置,多任务环境下要在创建任何 JSON 对象之前调用,且只调用一次。放在main里外设初始化之后、任务启动之前最稳妥。
2.3 编译选项与栈、堆的余量估算
cJSON 解析是递归下降,嵌套层级越深,栈消耗越大。一个对象节点在 32 位平台上大约占几十字节,加上字符串拷贝,解析一条中等复杂度的报文,堆上临时开销在几百字节到 1KB 量级。工程里要把 heap 调够,FreeRTOS 的configTOTAL_HEAP_SIZE建议至少留 4KB 给 JSON 相关操作,具体看报文大小。
编译选项上,cJSON 默认开启了一些可选特性,比如cJSON_AS4CPP、ENABLE_LOCALES。嵌入式场景一般把 locale 相关关掉,减少代码体积:
/* 在 cJSON.h 顶部或工程全局宏里定义 */ #define ENABLE_LOCALES 0另外注意浮点支持。如果报文里有小数,比如"temp":25.6,cJSON 会用strtod解析,这会拉进浮点库。芯片没有 FPU 时,strtod是软件模拟,速度慢但能用;如果确定不需要小数,可以在解析后统一按整数处理,省掉这部分开销。
3. 解析流程落地:从字符串到结构体字段
移植通了只是第一步,真正决定代码质量的是解析流程怎么组织。直接在主循环里cJSON_Parse然后一层层cJSON_GetObjectItem,代码会迅速变成意大利面。合理的做法是分三层:报文接收、JSON 解析、业务映射。
3.1 报文接收与完整性判断
串口收 JSON 最常见的坑是「收到一半就开始解析」。JSON 是变长的,没有固定长度头,必须靠结束符判断。简单协议可以用\n或\r\n作为帧尾,接收中断里攒到结束符再置标志位。
#define JSON_BUF_SIZE 256 static char json_rx_buf[JSON_BUF_SIZE]; static volatile uint16_t json_rx_len = 0; static volatile uint8_t json_frame_ready = 0; /* 串口接收中断回调,逐字节调用 */ void json_uart_rx_byte(uint8_t byte) { if (json_frame_ready) { return; /* 上一帧还没处理完,丢弃新数据 */ } if (byte == '\n') { /* 帧尾 */ json_rx_buf[json_rx_len] = '\0'; json_frame_ready = 1; return; } if (json_rx_len < JSON_BUF_SIZE - 1) { json_rx_buf[json_rx_len++] = byte; } else { json_rx_len = 0; /* 溢出,整帧丢弃,重新同步 */ } }逻辑说明:json_frame_ready是单生产者单消费者的标志位,中断里只写、主循环只读,不加锁也安全。JSON_BUF_SIZE要按最大报文长度留余量,超长直接丢弃整帧而不是截断,截断的 JSON 解析必然失败,还会浪费一次解析开销。参数上,帧尾字符要和上位机约定一致,有的协议用\r\n,那就判断\n即可,\r会在解析时被 cJSON 当作空白跳过。
3.2 用 cJSON_Parse 建树并逐字段取值
主循环检测到json_frame_ready后,把缓冲区交给 cJSON。取值时每一步都要判空,因为字段缺失、类型不对都会返回NULL,不判空直接->valuedouble就是空指针解引用。
typedef struct { float temperature; uint8_t humidity; uint8_t valid; } sensor_data_t; int parse_sensor_json(const char *text, sensor_data_t *out) { cJSON *root = cJSON_Parse(text); if (root == NULL) { const char *err = cJSON_GetErrorPtr(); /* 出错位置,调试用 */ (void)err; return -1; } cJSON *item_temp = cJSON_GetObjectItemCaseSensitive(root, "temp"); cJSON *item_hum = cJSON_GetObjectItemCaseSensitive(root, "hum"); cJSON *item_ok = cJSON_GetObjectItemCaseSensitive(root, "ok"); if (!cJSON_IsNumber(item_temp) || !cJSON_IsNumber(item_hum)) { cJSON_Delete(root); return -2; /* 字段缺失或类型不符 */ } out->temperature = (float)item_temp->valuedouble; out->humidity = (uint8_t)item_hum->valueint; out->valid = cJSON_IsTrue(item_ok) ? 1 : 0; cJSON_Delete(root); /* 必须释放,否则每帧泄漏 */ return 0; }逻辑说明:cJSON_Parse返回的是整棵树的根,内部所有节点都从堆上分配,解析成功后必须cJSON_Delete(root)一次性释放整棵树,子节点不用单独删。cJSON_GetObjectItemCaseSensitive区分大小写,协议字段名大小写固定时用它更严格;如果上位机字段名大小写不统一,换成cJSON_GetObjectItem。cJSON_IsNumber这类类型判断函数比直接读type字段可读性好,也避免漏判。valuedouble和valueint是 cJSON 节点里同时维护的两个值,取整数字段用valueint更直接。
注意:
cJSON_GetErrorPtr()返回的是解析失败位置附近的指针,指向原始输入缓冲区。如果缓冲区是复用的,打印错误信息要在解析失败后立刻做,别等缓冲区被下一帧覆盖。
3.3 嵌套对象与数组的遍历写法
实际协议很少是平铺的,常见的是{"dev":{"id":1,"cfg":[1,2,3]}}这种结构。嵌套对象用cJSON_GetObjectItem逐层下钻,数组用cJSON_GetArraySize配cJSON_GetArrayItem遍历。
/* 解析 {"dev":{"id":1,"cfg":[10,20,30]}} */ int parse_nested(const char *text) { cJSON *root = cJSON_Parse(text); if (!root) return -1; cJSON *dev = cJSON_GetObjectItemCaseSensitive(root, "dev"); if (!cJSON_IsObject(dev)) { cJSON_Delete(root); return -2; } cJSON *id = cJSON_GetObjectItemCaseSensitive(dev, "id"); if (cJSON_IsNumber(id)) { /* id->valueint 可用 */ } cJSON *cfg = cJSON_GetObjectItemCaseSensitive(dev, "cfg"); if (cJSON_IsArray(cfg)) { int n = cJSON_GetArraySize(cfg); for (int i = 0; i < n; i++) { cJSON *elem = cJSON_GetArrayItem(cfg, i); if (cJSON_IsNumber(elem)) { /* elem->valueint 可用 */ } } } cJSON_Delete(root); return 0; }逻辑说明:cJSON_IsObject和cJSON_IsArray是进入下一层前的必要检查,类型不对就提前返回,避免对非对象节点调GetObjectItem拿到NULL后继续解引用。数组遍历用cJSON_GetArraySize拿长度,注意这个函数对非数组节点返回 0,所以前面的类型判断不能省。嵌套层级建议控制在 3 层以内,再深就该考虑拆协议了,递归解析的栈开销和代码可读性都会变差。
4. 内存与性能:STM32 上跑 JSON 的真实边界
cJSON 好用,但它在嵌入式上的代价主要在两处:堆碎片和解析耗时。这一章把这两个边界讲清楚,方便你判断自己的项目能不能扛。
4.1 频繁解析下的堆碎片问题
每解析一帧就malloc一批节点、再free掉,如果报文大小不固定,堆上会出现大小不一的内存块,跑久了碎片化,最后pvPortMalloc返回NULL,表现为「偶尔解析失败,重启就好」。这是 cJSON 在 STM32 上最典型的血泪经验。
缓解手段有三条,按侵入性从低到高:
| 手段 | 做法 | 适用场景 |
|---|---|---|
| 固定报文长度 | 协议约定定长或补齐到定长 | 字段少、可协商协议 |
| 内存池 | 用静态数组实现固定块分配器,挂到 hooks | 报文种类固定 |
| 复用解析缓冲 | 解析完立即 Delete,不在中断里解析 | 所有场景 |
最省事的是第三条:绝不在中断里调cJSON_Parse,中断只负责收字节和置标志,解析放主循环或低优先级任务,解析完立刻cJSON_Delete,让堆块尽快归还。如果报文种类固定,直接上内存池,把json_malloc换成从静态块数组里取,彻底消除碎片。
4.2 解析耗时实测与优化方向
一条 100 字节左右、两三层嵌套的报文,在 72MHz 的 Cortex-M3 上解析加取值,量级在几十微秒到一百多微秒,具体取决于浮点字段数量和编译器优化等级。这个耗时对大多数应用无所谓,但如果你的串口是 115200 波特率、每秒几十帧,就要留意主循环别被解析卡住。
优化方向:一是关掉不需要的特性,比如前面说的 locale;二是浮点字段能不用就不用,strtod是耗时大头;三是把cJSON_Parse换成cJSON_ParseWithLength,传入已知长度,省掉内部strlen扫描。最后这条在报文带长度头的协议里很好用:
/* 已知报文长度时,避免内部 strlen */ cJSON *root = cJSON_ParseWithLength(json_rx_buf, json_rx_len);参数说明:第二个参数是字节数,不含结尾\0也行,cJSON 按长度截取。注意长度必须准确,传大了会把缓冲区后面的脏数据当报文内容,解析结果不可预期。
5. 避坑清单:五个真实踩过的坑
5.1 解析成功但取值全是 NULL
现象:cJSON_Parse返回非空,cJSON_GetObjectItem却拿不到字段。原因多半是字段名大小写不一致,或者报文里带了 BOM 头、前后有不可见字符。解决:先用cJSON_PrintUnformatted(root)把解析后的树重新打印出来对比,确认字段名拼写;接收缓冲区在解析前打印十六进制,检查帧头是否有EF BB BF之类的多余字节。
5.2 跑一段时间后 malloc 返回 NULL
现象:设备运行几小时后 JSON 解析开始失败,重启恢复。原因是有分支漏了cJSON_Delete,比如类型判断失败提前return时没释放 root。解决:把所有return路径过一遍,确保cJSON_Parse成功后的每条退出路径都调用cJSON_Delete;更稳的写法是用goto cleanup统一收口。
5.3 中断里解析导致偶发死机
现象:高频率收报文时随机 HardFault。原因是中断里调cJSON_Parse,内部malloc在中断上下文操作堆,和主循环的堆操作冲突。解决:中断只收字节,解析全部挪到主循环;如果必须用 RTOS,解析放独立任务,用队列传递缓冲区指针。
5.4 浮点字段解析出乱值
现象:"temp":25.6解析出来是 25.599998 之类。原因是float精度和double转换的正常现象,不是 bug。解决:业务层按精度四舍五入,比如乘 10 取整再除;或者协议直接传整数(放大 10 倍),彻底绕开浮点。
5.5 报文超长被截断后解析失败
现象:字段多的报文偶尔解析失败,短报文正常。原因是接收缓冲区不够,超长部分被丢弃,JSON 结构不完整。解决:按最大可能报文长度设JSON_BUF_SIZE,并在接收逻辑里对溢出做整帧丢弃加计数,方便定位;协议层面也可以约定分片或压缩字段名。
6. 进阶:把解析层做成可复用模块的几个技巧
做到能解析只是及格线,真正省心的是把 JSON 处理封装成和业务解耦的模块。我一般会在json_task.c里做三件事:统一入口、统一错误码、统一日志。
统一入口是指所有报文都走一个json_dispatch(const char *text),内部先取"cmd"字段判断报文类型,再分发给对应的解析函数。这样新增报文类型只加一个分支,不用改接收逻辑。统一错误码是把-1解析失败、-2字段缺失、-3类型不符这类约定固定下来,上层根据错误码决定是丢弃、重试还是上报。统一日志是在解析失败时打印原始报文的前若干字节和cJSON_GetErrorPtr的位置偏移,现场排查时这一条信息往往比什么都管用。
还有一个容易被忽略的点:发送方向。设备上报时用cJSON_CreateObject组包、cJSON_PrintUnformatted序列化,记得序列化出来的字符串也是堆上的,发完要cJSON_free(不是free,因为可能走了自定义 hooks)。组包和解析用同一套 hooks,内存来源才一致。
验证方法上,我会在 PC 上先用同样的 cJSON 源码写个小程序,把设备实际收到的报文喂进去跑一遍,确认字段名、类型、嵌套结构都对,再烧到板子上。PC 端调试成本低,能快速排除协议层面的问题,剩下的才是嵌入式特有的内存和时序问题。
从那以后我每次移植 cJSON 到新芯片,都强制先跑一遍「连续解析一万帧不重启」的压力测试,堆余量和碎片情况一目了然,比事后现场排查省太多事。希望帮到你。
本文还有配套的精品资源,点击获取