ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32移植cJSON解析JSON:从串口收包到结构体实战

STM32移植cJSON解析JSON:从串口收包到结构体实战

简介:这份资源面向在STM32平台上需要处理JSON数据的嵌入式开发者,提供基于cjson库的解析与生成示例工程,可直接用于项目中,解决单片机与上位机或云端进行轻量级数据交换时的格式处理问题。压缩包共133个文件,约1.76MB,以31个c源文件与31个h头文件为核心,涵盖STM32标准外设库与cjson实现,另含Keil工程文件、编译中间产物及readme说明,工程结构完整、层次清晰。已有3250人学习下载,说明其在嵌入式JSON应用场景中具有较高的参考价值。读者可从中获得一套经过验证的cjson移植与调用范例,理解JSON字符串的解析流程、内存配置方式及数据生成方法,并借助现成工程快速搭建自己的通信数据处理模块,减少从零调试库文件的时间成本。

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

做嵌入式联网项目,最常遇到的场景就是模组吐回来一串 JSON:{"temp":26.5,"hum":61,"ok":true}。字符串是拿到了,可接下来要取值、要判断、要存进结构体,靠strstr加sscanf硬抠,字段一多、嵌套一层,代码立刻变成一坨谁都不敢动的玄学。这份资源就是一个把 cJSON 移植进 STM32 工程、用来解析 JSON 字符串的完整示例包,适合正在做 4G 模组通信、MQTT 上报、上位机协议对接的嵌入式开发者。它解决的不是“JSON 是什么”,而是“在只有几十 KB RAM 的单片机上,怎么安全地把这包字符串变成能用的变量”。

2. 工程结构与移植思路:cJSON 是怎么塞进 STM32 的

拿到压缩包先别急着编译,先看清楚它凭什么能在单片机上跑。cJSON 本身是纯 C 写的,不依赖操作系统、不依赖标准库的重型部分,核心就两个文件,这正是它能被塞进 STM32 的根本原因。整个示例工程的目录组织大致如下,不同 IDE 建工程时文件名可能略有差异,但结构是一致的。

stm32_cjson_demo/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── cJSON.h // cJSON 头文件 │ │ └── usart.h │ └── Src/ │ ├── main.c // 解析入口,业务代码在这 │ ├── cJSON.c // cJSON 实现,约 3000 行 │ └── usart.c // 串口收发,负责把字节拼成字符串 ├── Drivers/ // HAL 库,CubeMX 生成 └── MDK-ARM/ 或 STM32CubeIDE/ // 工程文件

2.1 为什么选 cJSON 而不是自己写解析器

自己写解析器听起来很酷,实际做起来是血泪经验:转义字符、浮点指数、嵌套数组、空值,随便一个没处理对,现场就翻车。cJSON 的价值在于它把 JSON 抽象成一棵 DOM 树,解析完你拿到的是一串cJSON节点,取值就是按名字找孩子节点,逻辑清晰、可维护。它的内存模型是“解析时按需 malloc,用完统一 delete”,对 STM32 来说这意味着必须配好堆,否则解析到一半返回 NULL,你还以为是数据格式错了。

移植时只需要把cJSON.c和cJSON.h加进工程,确保cJSON.c里用到的malloc/free/strlen/memcpy这些函数在你的工程里可用。HAL 工程默认带标准 C 库,一般直接能编过。如果用的是精简的 newlib-nano,注意把printf浮点支持打开,否则打印浮点字段会输出异常。

2.2 堆空间配置:cJSON 的命门

cJSON 每解析一个对象、一个键值对都会申请内存。一个中等复杂度的报文,解析峰值可能吃掉几 KB。STM32 的默认堆(Heap Size)在启动文件startup_stm32xxxx.s里配置,CubeMX 生成的工程通常在sysmem.c或链接脚本里定义。常见做法是把 Heap 调到 0x1000(4KB)以上,具体看你的报文大小。

/* 在 main.c 里可以先做个堆可用性自检,避免上线才发现不够 */ extern int __io_putchar(int ch); void cjson_heap_check(void) { /* 连续申请再释放,粗略探测当前可用堆 */ void *p = malloc(2048); if (p == NULL) { printf("heap not enough for 2KB\r\n"); return; } free(p); printf("heap ok\r\n"); }

这段自检不是万能的,但能在调试阶段快速暴露“堆配小了”这个最常见的问题。参数 2048 按你实际报文规模调整,报文越大,这个探测值越要贴近真实峰值。注意malloc失败返回 NULL 时不要继续解析,直接丢弃这包数据重来,比解析出半棵树再崩掉强。

2.3 串口收包到字符串的衔接

JSON 解析的前提是拿到一个完整的、以\0结尾的字符串。串口是字节流,必须自己拼包。示例里通常用“收到换行符或固定长度”作为一帧结束标志。下面是一个中断收包加空闲判断的典型写法,思路是收到字节先塞缓冲区,主循环里检测到结束符再交给解析函数。

#define RX_BUF_SIZE 256 uint8_t rx_byte; // 单字节中断接收 char json_buf[RX_BUF_SIZE]; // 拼包缓冲 volatile uint16_t json_len = 0; volatile uint8_t json_ready = 0; /* 串口中断回调,HAL 库风格 */ void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { if (rx_byte == '\n' || json_len >= RX_BUF_SIZE - 1) { json_buf[json_len] = '\0'; // 补字符串结束符 json_ready = 1; // 通知主循环 json_len = 0; } else { json_buf[json_len++] = rx_byte; } HAL_UART_Receive_IT(huart, &rx_byte, 1); // 重新挂接收 } }

这里的关键参数是RX_BUF_SIZE,它必须大于你最长的一帧报文,否则会被截断,解析必然失败。json_buf[json_len] = '\0'这一句不能省,cJSON 靠它判断字符串结尾。json_ready用volatile修饰,因为它在中断和主循环之间共享,编译器优化时可能把它缓存进寄存器导致主循环永远看不到变化,这是新手最容易踩的坑之一。

3. 核心解析代码拆解:从字符串到结构体的完整链路

移植通了,接下来是真正干活的部分。cJSON 的 API 不多,但用错顺序就会内存泄漏或者拿到野指针。这一章按“解析 → 取值 → 释放”的顺序把链路走一遍,每段都能直接抄进你的工程。

3.1 解析入口与返回值判断

cJSON_Parse是整个流程的起点,它把字符串变成树,失败返回 NULL。很多人拿到 NULL 就懵,其实 cJSON 提供了cJSON_GetErrorPtr()告诉你解析到哪个位置出错,调试时非常有用。

#include "cJSON.h" void parse_json_frame(const char *frame) { cJSON *root = cJSON_Parse(frame); if (root == NULL) { /* 打印出错位置,定位是数据问题还是拼包问题 */ printf("json parse error near: %s\r\n", cJSON_GetErrorPtr()); return; } /* 后续取值逻辑 */ handle_json_root(root); cJSON_Delete(root); // 必须释放,否则每帧泄漏一棵树 }

cJSON_Parse内部会跳过空白、校验括号配对、处理转义。返回的root是整棵树的根,可能是对象也可能是数组。cJSON_GetErrorPtr()返回的是出错位置附近的指针,打印出来能直接看到是哪个字符让解析器卡住了,比盲猜高效得多。cJSON_Delete一定要在取值完成后调用,它递归释放所有子节点,漏掉就是慢性内存泄漏,跑几小时就 OOM。

3.2 按字段取值:类型判断不能省

拿到 root 之后,取值前必须先确认节点类型。JSON 里数字可能是整数也可能是浮点,布尔是true/false,字符串带引号,直接按错误类型取会得到 0 或者空指针。

void handle_json_root(cJSON *root) { cJSON *item; /* 取温度,浮点 */ item = cJSON_GetObjectItemCaseSensitive(root, "temp"); if (cJSON_IsNumber(item)) { float temp = (float)item->valuedouble; printf("temp=%.1f\r\n", temp); } /* 取湿度,整数 */ item = cJSON_GetObjectItemCaseSensitive(root, "hum"); if (cJSON_IsNumber(item)) { int hum = item->valueint; printf("hum=%d\r\n", hum); } /* 取状态,布尔 */ item = cJSON_GetObjectItemCaseSensitive(root, "ok"); if (cJSON_IsBool(item)) { printf("ok=%d\r\n", cJSON_IsTrue(item)); } /* 取字符串,注意用 valuestring */ item = cJSON_GetObjectItemCaseSensitive(root, "dev"); if (cJSON_IsString(item) && item->valuestring != NULL) { printf("dev=%s\r\n", item->valuestring); } }

cJSON_GetObjectItemCaseSensitive是大小写敏感的查找,如果你的协议字段大小写不固定,用cJSON_GetObjectItem。valuedouble和valueint是 cJSON 内部同时维护的两个值,浮点字段用前者,整数用后者。cJSON_IsBool判断后还要用cJSON_IsTrue取实际布尔值,不能直接读valueint,因为布尔节点的内部表示和数字不同。字符串字段一定要判valuestring != NULL,空字符串和缺失字段在 cJSON 里表现不同,不判空可能拿到野指针。

3.3 嵌套对象与数组的遍历

真实报文很少是平铺的,常见的是{"sensor":{"temp":26.5},"list":[1,2,3]}这种结构。嵌套对象就是再往下找一层,数组则要用cJSON_GetArraySize配合cJSON_GetArrayItem遍历。

void handle_nested(cJSON *root) { /* 嵌套对象:先取子对象,再在子对象里取字段 */ cJSON *sensor = cJSON_GetObjectItemCaseSensitive(root, "sensor"); if (cJSON_IsObject(sensor)) { cJSON *t = cJSON_GetObjectItemCaseSensitive(sensor, "temp"); if (cJSON_IsNumber(t)) { printf("nested temp=%.1f\r\n", t->valuedouble); } } /* 数组:先取长度,再按下标取 */ cJSON *list = cJSON_GetObjectItemCaseSensitive(root, "list"); if (cJSON_IsArray(list)) { int n = cJSON_GetArraySize(list); for (int i = 0; i < n; i++) { cJSON *elem = cJSON_GetArrayItem(list, i); if (cJSON_IsNumber(elem)) { printf("list[%d]=%d\r\n", i, elem->valueint); } } } }

cJSON_GetArraySize返回数组元素个数,cJSON_GetArrayItem按下标取,越界会返回 NULL,所以循环里仍然要判类型。嵌套对象的关键是每一层都先判cJSON_IsObject,因为如果上游字段缺失,sensor就是 NULL,直接往下取会崩。这套“逐层判空判类型”的写法看着啰嗦,但它是嵌入式环境里最稳的姿势,比事后查 hardfault 省事得多。

3.4 反向操作:用 cJSON 拼装上报报文

解析只是半条链路,很多项目还要把采集到的数据拼成 JSON 发出去。cJSON 的构造 API 是cJSON_CreateObject加cJSON_AddXxxToObject,用完同样要cJSON_Delete,但打印前要用cJSON_PrintUnformatted拿到紧凑字符串。

char *build_report(float temp, int hum) { cJSON *root = cJSON_CreateObject(); if (root == NULL) return NULL; cJSON_AddNumberToObject(root, "temp", temp); cJSON_AddNumberToObject(root, "hum", hum); cJSON_AddBoolToObject(root, "ok", 1); /* Unformatted 不带换行缩进,省流量,适合串口/模组发送 */ char *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); // 树释放了,out 还在,需要调用者 free return out; }

cJSON_PrintUnformatted返回的字符串是malloc出来的,调用者用完必须free(out),否则又是一处泄漏。如果报文不大、发送频率不高,也可以直接用cJSON_PrintPreallocated打印到你自己的静态缓冲区,彻底避开堆分配,这在 RAM 紧张的型号上是很实用的技巧。参数上注意cJSON_AddNumberToObject对浮点默认保留精度有限,需要更高精度时用cJSON_CreateNumber配合手动设置。

4. 避坑与排查:那些让解析莫名失败的细节

代码能编过不代表能跑对。下面这几条是实际项目里反复出现的翻车点,每条都按“现象 → 原因 → 解决”说清楚,遇到问题时对着查。

4.1 解析一直返回 NULL,但报文肉眼看着没问题

现象:串口打印出来的 JSON 字符串完全正确,cJSON_Parse却始终返回 NULL。原因多半是字符串里混进了不可见字符,比如\r、模组自带的+IPD前缀、或者拼包时残留的上一帧尾巴。解决:在解析前打印字符串的十六进制,逐字节确认;拼包逻辑里在帧头做同步,收到{才开始记录,遇到}再结束,而不是简单按换行切。

4.2 跑一段时间后死机或返回 NULL

现象:刚上电正常,跑几十分钟后解析开始失败,最终 hardfault。原因几乎都是内存泄漏——某条分支里cJSON_Parse成功后提前 return,没走到cJSON_Delete。解决:把解析和释放用统一出口管理,或者用goto cleanup模式保证任何路径都释放;同时把 Heap 调大并定期打印剩余堆做监控。

4.3 浮点字段取出来是 0 或者乱值

现象:temp字段明明是 26.5,取出来是 0。原因有两种:一是用了valueint去读浮点字段;二是工程链接了精简 C 库,strtod被裁掉导致 cJSON 解析浮点失败。解决:浮点统一用valuedouble;检查链接选项,确保浮点解析函数没被优化掉,必要时在 cJSON 配置里关掉不需要的特性减小依赖。

4.4 中断里调用解析导致卡死

现象:把cJSON_Parse直接写进串口中断回调,系统响应变慢甚至看门狗复位。原因:cJSON 内部有malloc和较长的循环,中断里做重活是大忌。解决:中断只负责收字节和置标志,解析放到主循环或独立任务里做,这是嵌入式的基本纪律,别图省事。

4.5 中文或特殊字符解析后乱码

现象:JSON 里带中文,取出来是乱码。原因:cJSON 按 UTF-8 处理字符串,如果你的源文件或串口数据是 GBK,字节层面就对不上。解决:统一全链路用 UTF-8,发送端和接收端编码一致;如果必须兼容 GBK,在解析后自己做一次编码转换,别指望 cJSON 帮你转。

5. 二次开发改造:把它变成你自己的协议解析层

示例能跑通只是起点,真正落地时要把它改造成适配自己协议的解析层。我的习惯是把“解析”和“业务”彻底分开:解析层只负责把 JSON 转成一个固定的结构体,业务层只认结构体,这样协议字段变了只改一处。

/* 定义业务结构体,和 JSON 字段解耦 */ typedef struct { float temp; int hum; bool ok; char dev[32]; } sensor_data_t; /* 解析层:JSON -> 结构体,返回 0 成功,-1 失败 */ int json_to_sensor(const char *frame, sensor_data_t *out) { cJSON *root = cJSON_Parse(frame); if (root == NULL) return -1; cJSON *t = cJSON_GetObjectItemCaseSensitive(root, "temp"); cJSON *h = cJSON_GetObjectItemCaseSensitive(root, "hum"); cJSON *o = cJSON_GetObjectItemCaseSensitive(root, "ok"); cJSON *d = cJSON_GetObjectItemCaseSensitive(root, "dev"); int ret = -1; if (cJSON_IsNumber(t) && cJSON_IsNumber(h) && cJSON_IsBool(o)) { out->temp = (float)t->valuedouble; out->hum = h->valueint; out->ok = cJSON_IsTrue(o); if (cJSON_IsString(d) && d->valuestring) { strncpy(out->dev, d->valuestring, sizeof(out->dev) - 1); out->dev[sizeof(out->dev) - 1] = '\0'; } ret = 0; } cJSON_Delete(root); return ret; }

这样改造的好处是:以后协议加字段,只动json_to_sensor;业务逻辑里全是sensor_data_t,测试时可以直接构造结构体,不用拼 JSON 字符串。strncpy后面手动补\0是必须的,防止源字符串刚好填满缓冲区导致没有结束符。

再进一步,可以把字段名做成表驱动,用一张{字段名, 类型, 偏移量}的表来批量解析,字段再多也不用写一长串 if。验证方法很简单:准备一组正常报文、一组缺字段报文、一组类型错误报文,跑一遍看返回值是否符合预期,尤其是缺字段时结构体里不能残留上一帧的旧值,解析前先memset清零。

还有一个实用技巧是给解析加超时和长度上限保护:cJSON_ParseWithLength可以限定最大解析长度,防止恶意或异常的超长报文把堆吃光。从那以后我每次移植 cJSON 到新板子,都强制先跑一遍堆自检和异常报文测试,再谈业务逻辑,这个习惯帮我省掉了太多现场排查的时间。希望帮到你。

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

返回列表