ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32移植cJSON解析JSON字符串实战:内存配置、解析避坑与压测验证

STM32移植cJSON解析JSON字符串实战:内存配置、解析避坑与压测验证

简介:这份资源面向使用STM32进行嵌入式开发的工程师与学习者,聚焦于在单片机环境中解析和生成JSON格式数据这一常见需求。包内提供经过工程验证的cJSON移植与调用示例,可直接嵌入实际项目,帮助读者解决设备与上位机、云平台之间轻量级数据交换的问题。压缩包共133个文件,约1.76MB,以31个c源文件和31个h头文件为核心,涵盖STM32标准外设库与cJSON实现;同时包含o、d、crf等编译中间文件,s启动文件、uvprojx与uvoptx工程配置,以及map、lst、axf等构建产物,并附readme说明,便于快速导入Keil环境复现。目前已有3250人学习下载,说明该方案在社区中具备一定参考价值。读者可从中获得cJSON在STM32上的集成方式、内存配置与序列化/反序列化调用思路,适合作为项目开发中的基础组件与排错参照。

1. 串口收到一包 JSON,STM32 该怎么把它拆开

调试一块 STM32 板子时,串口或无线模块吐上来一包{"temp":25.6,"hum":60,"ok":1},很多人第一反应是strstr找冒号、atoi转数字,字段一多、嵌套一深,代码立刻变成一坨没法维护的字符串手术。这份stm32使用cjson解析json字符串.zip就是冲着这个场景来的:把 cJSON 这套轻量 JSON 解析库移植进 STM32 工程,让单片机像上位机一样按 key 取值、按数组下标遍历,而不是靠手写偏移量硬啃字符串。它适合正在做物联网节点、设备上报、上位机协议对接的嵌入式开发者,尤其是 RAM 只有几十 KB、又不想自己造解析轮子的那批人。下面按「库怎么进工程 → 怎么解析 → 怎么避坑 → 怎么验证」走一遍,能直接抄。

2. 把 cJSON 塞进 Keil 工程:文件、内存与编译选项

cJSON 的全部实现就两个文件,cJSON.c和cJSON.h,纯 C89 风格,不依赖操作系统,也不依赖标准库里的浮点格式化之外的东西。这意味着它在 STM32 上落地的主要工作量不在「能不能编译」,而在「内存从哪来、编译选项怎么配、浮点会不会把 Flash 撑爆」。先把这三件事定下来,后面解析代码才有意义。

2.1 源码文件清单与目录组织

拿到压缩包后,先别急着全丢进工程。cJSON 官方仓库里还有 test、fuzzing 之类的目录,那些跟单片机无关,进工程只会拖慢编译、增加误包含风险。我一般只保留核心两个文件,按下面结构放:

YourProject/ ├── Core/ │ └── Src/ │ └── main.c ├── Drivers/ ├── Middlewares/ │ └── Third_Party/ │ └── cJSON/ │ ├── cJSON.c │ └── cJSON.h └── MDK-ARM/ └── YourProject.uvprojx

放好之后在 Keil 里做两件事:一是把cJSON.c加入对应分组(比如新建一个Middlewares/cJSON组),二是在Options for Target → C/C++ → Include Paths里把Middlewares/Third_Party/cJSON加进去。头文件包含路径这一步漏了,编译会报cannot open source input file "cJSON.h",这是最高频的低级翻车点,跟库本身没关系。

提示:不要图省事把cJSON.c直接拖进main.c所在目录再#include "cJSON.c"。这种写法在单文件 demo 里能跑,一旦工程里有两个 .c 都包含它,链接期就是一堆重复符号。

2.2 内存钩子:让 cJSON 用你自己的堆

cJSON 默认用malloc/free分配节点。STM32 上如果启用了标准库的malloc,它背后是_sbrk那套,堆大小由启动文件里的Heap_Size决定,默认往往只有 0x200 到 0x400 字节。解析一个稍大的 JSON,节点一多就返回 NULL,现象是「解析函数不报错但取不到值」,非常隐蔽。

稳妥做法是给 cJSON 挂自定义内存钩子,把它指到自己的内存池或者 RTOS 的堆上。cJSON 提供了cJSON_InitHooks:

#include "cJSON.h" /* 假设工程里已有一个静态内存池,或 RTOS 的 pvPortMalloc */ extern void *my_pool_malloc(size_t size); extern void my_pool_free(void *ptr); static cJSON_Hooks g_json_hooks = { .malloc_fn = my_pool_malloc, .free_fn = my_pool_free, }; void json_lib_init(void) { /* 必须在任何 cJSON_Parse 之前调用,且只调一次 */ cJSON_InitHooks(&g_json_hooks); }

malloc_fn的签名是void *(size_t),free_fn是void (void *),和标准库一致,所以裸机下直接传malloc/free也能编过,但那就失去了控制堆的意义。参数上唯一要注意的是:钩子必须在第一次解析前设置,中途改钩子会导致「这块内存是用 A 分配的、却用 B 释放」,轻则内存池错乱,重则 HardFault。我一般把json_lib_init()放在main里外设初始化之后、进主循环之前,位置固定,不给自己留后悔药。

2.3 编译选项:浮点、栈与-lm

cJSON 解析带小数的字段时会走strtod,格式化输出时会走sprintf的浮点分支。如果你的 JSON 里全是整数和字符串,可以把CJSON_NESTING_LIMIT之外的浮点相关裁剪掉;但只要有一个"temp":25.6,就得保证工具链的浮点支持是开的。

Keil ARMCC/ARMCLANG 下常见配置:

选项建议值原因
Floating Point Hardware按芯片选 Single Precision有 FPU 的 F4/F7 打开,解析浮点更快
C99 Mode勾选cJSON 里用了//注释和部分 C99 写法
Optimization-O1 或 -O2-O0 下 cJSON 代码体积偏大,但调试期可先用 -O0
Use MicroLIB视情况MicroLIB 的malloc更省,但和自定义钩子二选一

如果链接阶段报undefined symbol strtod或浮点相关符号,多半是没把数学库带上。GCC 工具链加-lm,Keil 一般在Options → Target → Floating Point Hardware配好即可。还有一个容易忽略的点:cJSON 解析是递归下降,嵌套层数受栈深度限制。默认启动文件里Stack_Size常常是 0x400,解析三四层嵌套没问题,但如果你的协议里有深层数组套对象,建议把栈加到 0x800 以上,否则现象是「解析到一半跑飞」,用调试器看调用栈才发现是栈溢出。

3. 解析实战:从cJSON_Parse到按 key 取值

库进工程只是第一步,真正决定代码好不好维护的是解析流程的组织方式。cJSON 的 API 是「解析成树 → 在树上找节点 → 取值 → 释放整棵树」,这个顺序不能乱,尤其是释放,漏一次就是内存泄漏,跑几百次解析后堆就空了。下面按一个典型的设备上报报文,把完整链路走一遍。

3.1 解析入口与根节点校验

假设串口收到这样一包数据(已去掉帧头帧尾,只留 JSON 正文):

{"dev":"node01","temp":25.6,"hum":60,"alarm":{"code":3,"msg":"overheat"},"ch":[1,2,3]}

解析代码:

#include "cJSON.h" #include <string.h> /* 返回值:0 成功,-1 失败;调用方负责在合适时机释放 root */ int parse_report(const char *json_str, float *temp, int *hum) { cJSON *root = cJSON_Parse(json_str); if (root == NULL) { /* 拿错误位置,定位是哪个字符开始不合法 */ const char *err = cJSON_GetErrorPtr(); if (err != NULL) { /* 实际项目里可把 err 前后若干字节打印出来 */ printf("json parse error near: %.20s\r\n", err); } return -1; } cJSON *item_temp = cJSON_GetObjectItemCaseSensitive(root, "temp"); cJSON *item_hum = cJSON_GetObjectItemCaseSensitive(root, "hum"); if (cJSON_IsNumber(item_temp)) { *temp = (float)item_temp->valuedouble; } if (cJSON_IsNumber(item_hum)) { *hum = item_hum->valueint; } cJSON_Delete(root); /* 整棵树一次性释放 */ return 0; }

逻辑上分四步:cJSON_Parse把字符串建成树,失败时用cJSON_GetErrorPtr拿到出错位置;cJSON_GetObjectItemCaseSensitive按 key 精确匹配(大小写敏感,协议里 key 大小写不一致时用它,别用不区分大小写的版本,否则Temp和temp会被当成同一个);取值前必须用cJSON_IsNumber之类的类型判断,直接读valueint而不判类型,遇到字符串字段会读到 0,属于「不崩但错」的典型;最后cJSON_Delete(root)释放整棵树,子节点不用单独释放。

参数上要留意valuedouble和valueint的区别:cJSON 内部所有数字都存成 double,valueint是解析时顺手截断的 int。取小数用valuedouble,取整数用valueint更省事,但前提是确认字段确实是整数,否则25.6用valueint取出来是 25,这种精度丢失在温度、电压场景里会直接导致业务判断出错。

3.2 嵌套对象与数组的遍历

上面那包数据里alarm是嵌套对象,ch是数组。嵌套对象用cJSON_GetObjectItemCaseSensitive逐层往下取,数组则用cJSON_GetArraySize配cJSON_GetArrayItem,或者用 cJSON 提供的遍历宏:

/* 取嵌套对象字段 */ cJSON *alarm = cJSON_GetObjectItemCaseSensitive(root, "alarm"); if (cJSON_IsObject(alarm)) { cJSON *code = cJSON_GetObjectItemCaseSensitive(alarm, "code"); cJSON *msg = cJSON_GetObjectItemCaseSensitive(alarm, "msg"); if (cJSON_IsNumber(code)) { printf("alarm code=%d\r\n", code->valueint); } if (cJSON_IsString(msg) && msg->valuestring != NULL) { printf("alarm msg=%s\r\n", msg->valuestring); } } /* 遍历数组 */ cJSON *ch = cJSON_GetObjectItemCaseSensitive(root, "ch"); if (cJSON_IsArray(ch)) { int n = cJSON_GetArraySize(ch); for (int i = 0; i < n; i++) { cJSON *elem = cJSON_GetArrayItem(ch, i); if (cJSON_IsNumber(elem)) { printf("ch[%d]=%d\r\n", i, elem->valueint); } } }

这里的关键判断是cJSON_IsObject和cJSON_IsArray。很多翻车案例是「协议里alarm有时是对象、有时是 null」,代码不判类型直接往下取,cJSON_GetObjectItemCaseSensitive(NULL, ...)返回 NULL,再对 NULL 取valueint就是空指针解引用。cJSON 对 NULL 入参有一定容错(多数 Get 系列函数遇到 NULL 返回 NULL),但->valueint这种直接解引用没有保护,必须自己判。

数组遍历还有一个边界:cJSON_GetArraySize返回的是当前节点数,遍历过程中不要对数组做增删,否则下标会错位。解析场景一般只读不写,问题不大,但如果你的代码在同一棵树上既解析又修改,建议先取值到本地结构体,再统一处理,别在树上边遍历边改。

3.3 构造 JSON:设备上报的另一半

解析是收,构造是发。设备上报时经常要把本地结构体拼成 JSON 字符串再走串口或无线发出去,cJSON 的构造 API 是cJSON_CreateObject起头,cJSON_AddXxxToObject挂字段,最后cJSON_PrintUnformatted序列化:

char *build_report(float temp, int hum) { cJSON *root = cJSON_CreateObject(); if (root == NULL) { return NULL; } cJSON_AddStringToObject(root, "dev", "node01"); cJSON_AddNumberToObject(root, "temp", temp); cJSON_AddNumberToObject(root, "hum", hum); cJSON *alarm = cJSON_AddObjectToObject(root, "alarm"); if (alarm != NULL) { cJSON_AddNumberToObject(alarm, "code", 0); cJSON_AddStringToObject(alarm, "msg", "ok"); } /* PrintUnformatted 不带缩进和换行,适合串口/无线传输 */ char *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); /* 树释放后 out 仍然有效,需调用方 free */ return out; }

两个参数细节值得说。第一,cJSON_PrintUnformatted返回的字符串是 cJSON 用钩子里的malloc_fn分配的,调用方用完必须free(或对应的my_pool_free),否则每发一包泄漏一次。第二,cJSON_AddNumberToObject对浮点的默认格式化位数有限,温度25.6没问题,但如果你的业务要求两位小数25.60,默认输出可能是25.6,需要自己用sprintf拼字符串字段,或者接受这个精度。我一般对精度敏感的字段直接cJSON_AddStringToObject传格式化后的字符串,接收端再转,省得两边对小数位数的预期不一致。

4. 避坑清单:五个让解析「不崩但错」的现场

cJSON 本身不复杂,真正耗时间的是那些不报错、但结果不对的场景。下面五条是我在 STM32 上实际踩过的,按「现象 → 原因 → 解决」记,遇到同类问题可以直接对号。

4.1 解析成功但取值为 0

现象:cJSON_Parse返回非 NULL,cJSON_GetObjectItemCaseSensitive也能拿到节点,但valueint读出来是 0,而报文里明明是"hum":60。

原因:key 大小写或前后有空格不匹配,函数返回了 NULL,代码没判 NULL 就直接读->valueint,对 NULL 解引用在部分编译器下读到 0 而不崩。另一种是字段类型是字符串"60",用cJSON_IsNumber判断为假,代码走了默认分支留下 0。

解决:所有GetObjectItem的返回值先判 NULL,再判类型,取值前打印一次 key 和类型,确认匹配上了再读。协议对接初期我会在解析函数里加一行printf("key=%s type=%d\r\n", key, item ? item->type : -1),跑通再删。

4.2 跑几十次后cJSON_Parse开始返回 NULL

现象:上电初期解析正常,设备跑几分钟或解析几十包后,cJSON_Parse突然一直返回 NULL,复位后恢复。

原因:某条错误分支提前return,漏了cJSON_Delete(root),每包泄漏几十到几百字节,堆耗尽后malloc返回 NULL,cJSON 建树失败。

解决:解析函数统一用「单入口单出口」结构,所有分支汇到末尾统一cJSON_Delete;或者用goto cleanup模式。更稳的做法是把解析封装成一个函数,函数内保证「只要 Parse 成功就一定 Delete」,调用方只拿值不碰树。

4.3 浮点字段解析出来是 0 或乱值

现象:整数字段正常,"temp":25.6解析后valuedouble是 0 或者一个极大值。

原因:工具链没开浮点支持,strtod链接到了精简版实现,或者printf浮点格式化被裁掉导致调试输出误导。也有可能是报文里小数点用了中文全角字符,strtod解析到非法字符就停。

解决:确认编译选项里浮点硬件和库都开了;用十六进制打印valuedouble的内存表示,排除是显示问题还是解析问题;抓原始字节确认小数点、负号是 ASCII。串口收到的数据先做一次字符集检查,全角字符在嵌入式协议里是纯灾难。

4.4 嵌套深了直接 HardFault

现象:解析扁平 JSON 正常,遇到三四层嵌套的报文,程序跑飞进 HardFault。

原因:cJSON 递归下降解析,每层嵌套消耗栈帧,默认Stack_Size太小,栈溢出踩到了其他内存区。

解决:把启动文件里的Stack_Size从 0x400 加到 0x800 或 0x1000,同时在链接脚本里确认栈区没和堆区重叠。更根本的做法是协议设计阶段就限制嵌套层数,设备间通信没必要套五层,扁平化对双方都友好。

4.5cJSON_Print返回的字符串用free释放报错

现象:构造 JSON 后调用cJSON_PrintUnformatted,用完free(out),偶尔进 HardFault 或堆断言。

原因:设置了自定义malloc_fn/free_fn,但释放时用了标准库free,分配器和释放器不匹配。

解决:谁分配谁释放。用了钩子就用钩子对应的释放函数,裸机下如果钩子就是标准malloc/free,那free没问题;一旦换成内存池,释放也必须走内存池。这条和 2.2 节是同一个坑的两面,写代码时把「分配器」当成一个全局约定,别在某个文件里图方便混用。

5. 验证与压测:怎么确认这套解析在板子上真的稳

代码编过、单包解析对,不等于能在设备上长期跑。JSON 解析的稳定性问题大多是「跑久了才暴露」,所以验证环节要专门设计,不能只靠手动发几条报文看看串口打印。下面这套流程是我现在每次移植第三方解析库都会走一遍的,成本不高,但能提前把内存和边界问题逼出来。

5.1 用固定用例做回归

先准备一组覆盖边界的 JSON 字符串,放在一个数组里,上电后自动跑一遍,比对预期值:

typedef struct { const char *json; int expect_ret; /* 0 成功,-1 失败 */ float expect_temp; } json_case_t; static const json_case_t g_cases[] = { {"{\"temp\":25.6,\"hum\":60}", 0, 25.6f}, {"{\"temp\":-10.5,\"hum\":0}", 0, -10.5f}, {"{\"temp\":25.6}", 0, 25.6f}, {"{\"temp\":\"25.6\"}", 0, 0.0f}, /* 类型不符,取默认 */ {"{\"temp\":25.6,}", 0, 25.6f}, /* 尾逗号,看库容错 */ {"{\"temp\":25.6", -1, 0.0f}, /* 缺右括号 */ {"not a json", -1, 0.0f}, {"", -1, 0.0f}, }; void json_regression_run(void) { for (int i = 0; i < (int)(sizeof(g_cases)/sizeof(g_cases[0])); i++) { float temp = 0.0f; int hum = 0; int ret = parse_report(g_cases[i].json, &temp, &hum); if (ret != g_cases[i].expect_ret) { printf("case %d ret mismatch: got %d\r\n", i, ret); } if (ret == 0 && temp != g_cases[i].expect_temp) { printf("case %d temp mismatch: got %.2f\r\n", i, temp); } } }

这套用例的价值在于把「我以为库会怎么处理」变成可执行的断言。比如尾逗号那条,不同版本的 cJSON 容错行为可能不同,跑一遍就知道你手上这份是宽松还是严格,协议对接时心里有数。类型不符那条用来确认你的解析函数在字段类型变化时是走默认值还是报错,这直接决定上层业务逻辑要不要额外兜底。

5.2 长时间循环压测看堆水位

单包正确之后,做一轮循环压测:把同一包报文连续解析一万次,每次解析后记录当前堆剩余(用xPortGetFreeHeapSize或自己内存池的剩余量),观察曲线是否单调下降。正常情况应该是锯齿状、整体持平;如果持续下降,就是 4.2 那条泄漏。

void json_stress_test(const char *json, int times) { for (int i = 0; i < times; i++) { float temp; int hum; parse_report(json, &temp, &hum); if ((i % 1000) == 0) { printf("iter=%d free_heap=%u\r\n", i, (unsigned)get_free_heap()); } } }

参数上,times取一万起步,堆小的板子取五千也行,关键是看趋势而不是绝对值。压测期间别开太多其他动态分配的任务,否则堆水位波动会掩盖泄漏信号。我一般会在压测前后各打印一次堆总量,差值超过单包节点大小的量级就值得查。

5.3 边界与异常输入的取舍

最后一层验证是异常输入:超长字符串、深层嵌套、非法 UTF-8、数字溢出。cJSON 对超长字符串没有硬上限,取决于你的堆;深层嵌套受栈限制;数字溢出会走strtod的饱和行为。这些不需要全部在设备上处理,但协议层要定好规矩:单包最大长度、最大嵌套层数、字段类型白名单。设备端解析函数在入口处先做长度检查,超过阈值直接丢弃并回错误码,比让 cJSON 去啃一个畸形报文安全得多。

从那以后我每次往 STM32 工程里塞第三方解析库,都强制先跑一遍回归用例加一万次循环压测,堆水位曲线不平就不往下写业务代码。这套习惯帮我省掉了好几次「现场跑三天才复现」的排查。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进