简介:cJson库是专为嵌入式系统打造的轻量级JSON解析与生成工具,面向STM32等单片机开发者,解决资源受限环境下JSON数据交互的难题。压缩包内共2个文件,包含1个cJSON.h头文件与1个cJSON.c实现文件,整体仅9KB,集成时只需将这两个文件加入工程即可,无需额外依赖。目前已有790人学习下载,适合需要处理网络协议、配置更新等JSON通信场景的嵌入式工程师。资源围绕cJson核心能力展开,涵盖JSON基础概念、库的轻量高效与内存管理特点、cJSON_Parse、cJSON_Print、cJSON_CreateObject、cJSON_Delete等核心函数用法,并给出从导入库、创建对象、添加键值对到解析、遍历、生成字符串及释放资源的完整流程,同时结合STM32平台说明实际应用方式,附有可直接参考的示例代码,帮助读者快速掌握在单片机项目中读写JSON数据的思路与方法。
1. 串口屏配置数据总丢?我把 cJSON 塞进 STM32 的完整记录
前阵子调一块 STM32 板子,上位机通过串口下发一组 JSON 格式的配置参数,MCU 这边解析完再回传状态。一开始我图省事,拿字符串一顿strstr加sscanf硬抠字段,结果字段顺序一变、嵌套一层对象、或者数值带个负号,解析就翻车。后来换成 cJSON,整个流程才稳下来。cJSON 是一个用纯 C 写的超轻量 JSON 解析与生成库,核心就一个.c加一个.h,不依赖任何第三方,堆内存占用可控,天生适合 STM32、GD32 这类资源紧张的单片机场景。它解决的就是「在裸机或 RTOS 上可靠地读写 JSON」这件事。如果你正在做串口屏、物联网模组、上位机通信协议,或者单纯想让 MCU 的配置管理不那么玄学,这份资源值得你花半小时跟一遍。
2. 为什么单片机项目里我最终选了 cJSON:选型对比与内存模型
2.1 裸机解析 JSON 的三种常见做法
在 MCU 上处理 JSON,我见过也试过三种路子。第一种是纯字符串匹配,strstr找 key,再往后抠 value。优点是零依赖、零额外内存,缺点是遇到嵌套、转义、数组就基本歇菜,维护成本极高。第二种是手写状态机,逐字符扫描,性能最好,但开发周期长,改一次协议就要重写一遍,属于「写的时候爽,改的时候想哭」。第三种就是引入现成的解析库,cJSON 是这里面最省心的一个。
cJSON 的定位很明确:不追求极致性能,追求「够小、够简单、够可靠」。整个库编译出来在 ARM Cortex-M 上大概几 KB 的 Flash,RAM 开销取决于你解析的 JSON 大小,因为它用的是动态内存分配。它支持标准的 JSON 语法,包括对象、数组、字符串、数字、布尔和 null,解析和生成两个方向都覆盖。对于绝大多数单片机通信场景,这个能力边界完全够用。
2.2 cJSON 的内存模型:为什么它能在单片机上跑
cJSON 的核心数据结构是一个双向链表节点cJSON,每个节点代表 JSON 里的一个值。解析时,它用malloc为每个节点分配内存,字符串值再单独分配一块。这意味着两件事:第一,你的堆空间必须够;第二,用完必须调cJSON_Delete释放,否则就是内存泄漏。
很多人担心malloc在单片机上不安全。实际项目中,只要你的堆不是小得可怜(一般建议至少留 2KB 以上给 JSON 解析),并且解析的 JSON 不会无限大,cJSON 是稳的。我一般会在FreeRTOS里给它单独划一块 heap,或者用heap_4这种带碎片合并的方案。裸机的话,确保_Min_Heap_Size在启动文件里设够。
提示:cJSON 默认用
malloc/free,如果你有自己的内存池,可以在cJSON.c开头重定义cJSON_malloc和cJSON_free宏,指向你的池分配函数。
2.3 移植到 STM32 工程:文件添加与堆配置
移植本身没有难度,把cJSON.c和cJSON.h拷进工程,加入编译,然后确认堆大小。以 Keil MDK 为例,在启动文件startup_stm32xxxx.s里找到Heap_Size,默认可能是0x200(512 字节),这个值解析稍大一点的 JSON 就会失败。我一般改成0x800到0x1000。
/* startup_stm32xxxx.s 片段:调整堆大小 */ Heap_Size EQU 0x00001000 ; 4KB 堆,给 cJSON 留足空间改完堆之后,在需要用的地方包含头文件即可:
#include "cJSON.h"逻辑说明:Heap_Size决定了malloc能用的最大连续内存。cJSON 解析一个包含 10 个字段的对象,大约需要几百字节到 1KB 出头,具体取决于字符串长度。参数上,0x1000是 4KB,对大多数配置类 JSON 足够;如果你要解析上百个字段的报文,继续往上加。
3. 解析与生成实战:从串口收到一帧 JSON 到回传状态
3.1 解析:把串口缓冲区变成 cJSON 对象
假设串口收到这样一帧数据,存在rx_buf里:
{"dev":"sensor01","temp":25.6,"hum":60,"alarm":false,"mode":[1,2,3]}解析代码:
#include "cJSON.h" #include <string.h> void parse_rx_frame(char *rx_buf) { cJSON *root = cJSON_Parse(rx_buf); /* 解析整帧 */ if (root == NULL) { const char *err = cJSON_GetErrorPtr(); /* 拿到出错位置 */ printf("json parse error near: %s\r\n", err); return; } /* 取字符串字段 */ cJSON *dev = cJSON_GetObjectItem(root, "dev"); if (cJSON_IsString(dev)) { printf("dev = %s\r\n", dev->valuestring); } /* 取数值字段 */ cJSON *temp = cJSON_GetObjectItem(root, "temp"); if (cJSON_IsNumber(temp)) { printf("temp = %.1f\r\n", temp->valuedouble); } /* 取布尔字段 */ cJSON *alarm = cJSON_GetObjectItem(root, "alarm"); if (cJSON_IsBool(alarm)) { printf("alarm = %d\r\n", cJSON_IsTrue(alarm)); } /* 取数组字段 */ cJSON *mode = cJSON_GetObjectItem(root, "mode"); if (cJSON_IsArray(mode)) { int n = cJSON_GetArraySize(mode); for (int i = 0; i < n; i++) { cJSON *item = cJSON_GetArrayItem(mode, i); printf("mode[%d] = %d\r\n", i, item->valueint); } } cJSON_Delete(root); /* 必须释放,否则内存泄漏 */ }逻辑说明:cJSON_Parse会扫描整个字符串并构建链表树,返回根节点。失败时返回NULL,用cJSON_GetErrorPtr能定位到出错的大致位置,调试时很有用。取值时不要直接访问valuestring或valueint,先用cJSON_IsString、cJSON_IsNumber这类宏判断类型,否则类型不匹配会读到脏数据。数组用cJSON_GetArraySize配合cJSON_GetArrayItem遍历。最后cJSON_Delete是必须的,它递归释放整棵树。
参数说明:cJSON_GetObjectItem对大小写敏感,key 必须完全匹配。valueint是整数截断值,valuedouble是浮点原值,取浮点一定用valuedouble。
3.2 生成:把 MCU 状态打包成 JSON 回传
回传状态时,用 cJSON 的构建接口从零拼一个对象:
char *build_status_json(void) { cJSON *root = cJSON_CreateObject(); /* 创建根对象 */ cJSON_AddStringToObject(root, "dev", "sensor01"); cJSON_AddNumberToObject(root, "temp", 25.6); cJSON_AddBoolToObject(root, "alarm", 0); cJSON *mode = cJSON_AddArrayToObject(root, "mode"); /* 创建数组 */ cJSON_AddItemToArray(mode, cJSON_CreateNumber(1)); cJSON_AddItemToArray(mode, cJSON_CreateNumber(2)); cJSON_AddItemToArray(mode, cJSON_CreateNumber(3)); char *out = cJSON_PrintUnformatted(root); /* 无空格紧凑输出 */ cJSON_Delete(root); /* 释放树,out 独立存在 */ return out; /* 用完记得 free(out) */ }逻辑说明:cJSON_CreateObject创建空对象,cJSON_AddStringToObject等接口直接往对象里挂键值对。数组用cJSON_AddArrayToObject创建后,再用cJSON_AddItemToArray逐个添加元素。输出用cJSON_PrintUnformatted得到不带换行和空格的紧凑字符串,适合串口传输;cJSON_Print会带缩进,适合调试打印。
参数说明:cJSON_PrintUnformatted返回的字符串是malloc出来的,发完串口后必须free(out),否则泄漏。cJSON_Delete(root)只释放树本身,不影响已经打印出来的字符串。
3.3 在 FreeRTOS 任务里安全使用 cJSON
如果跑 RTOS,多个任务同时调 cJSON 的malloc/free会有线程安全问题。常见做法是给 cJSON 加一把互斥锁,或者干脆把 JSON 解析集中到一个任务里,其他任务通过队列把缓冲区传进来。
/* 在 cJSON.c 中重定义分配函数,指向线程安全的堆 */ #define cJSON_malloc(sz) pvPortMalloc(sz) #define cJSON_free(p) vPortFree(p)逻辑说明:FreeRTOS 的pvPortMalloc在heap_4及以上是线程安全的(内部有挂起调度器保护)。把 cJSON 的分配器替换掉,就不用额外加锁。注意这要求你在包含cJSON.h之前先包含FreeRTOS.h。
参数说明:heap_4带碎片合并,适合频繁分配释放的场景;heap_1不支持 free,不能用。堆总大小在configTOTAL_HEAP_SIZE里配置,给 cJSON 留够余量。
4. 避坑与排查:cJSON 在单片机上最容易翻车的五个点
4.1 解析返回 NULL,但串口数据看着没问题
现象:cJSON_Parse返回NULL,用cJSON_GetErrorPtr打印出错位置,发现停在一个看似正常的字符上。
原因:最常见的是串口接收缓冲区没有加字符串结束符\0。cJSON 靠\0判断字符串结尾,如果缓冲区里是裸数据,它会一直往后读,读到非法字符就报错。
解决:收到数据后先手动补\0,再传给cJSON_Parse。另外确认缓冲区长度够,别让 JSON 被截断。
rx_buf[len] = '\0'; /* 补结束符 */ cJSON *root = cJSON_Parse(rx_buf);4.2 解析几次之后程序跑飞或死机
现象:单次解析正常,循环解析几十次后malloc返回NULL,或者直接进 HardFault。
原因:忘记调cJSON_Delete,每次解析都泄漏一棵树,堆很快耗尽。
解决:cJSON_Parse和cJSON_Delete必须成对出现。用cJSON_Print系列生成的字符串也要free。我一般会在解析函数出口统一释放,避免中途return漏掉。
4.3 浮点数取出来是 0 或者整数
现象:JSON 里写的是25.6,用valueint取出来是 25,用valuedouble取出来是 0。
原因:valueint是整数截断,浮点必须用valuedouble。如果valuedouble也是 0,检查是不是在cJSON.h里把CJSON_DISABLE_FLOAT打开了,或者编译器没开浮点支持。
解决:取浮点统一用valuedouble,并确认没有定义禁用浮点的宏。
4.4 中文或特殊字符解析后乱码
现象:JSON 里的中文字段解析出来是乱码。
原因:cJSON 默认按 UTF-8 处理字符串,如果你的串口数据是 GBK 编码,解析出来自然不对。
解决:统一用 UTF-8 编码传输。如果上位机发的是 GBK,要么在上位机转码,要么在 MCU 侧做一次编码转换再解析。
4.5 堆够大但解析大 JSON 仍然失败
现象:Heap_Size已经调到 8KB,解析一个稍大的 JSON 还是返回NULL。
原因:堆是够的,但内存碎片导致没有连续大块可用。频繁解析不同大小的 JSON 会加剧碎片。
解决:换用带碎片合并的堆管理(如 FreeRTOSheap_4),或者给 cJSON 单独划一块静态内存池,重定义cJSON_malloc/free指向池分配。另一个思路是限制单帧 JSON 的最大长度,从源头控制内存需求。
5. 进阶技巧:用 cJSON 做配置持久化与版本兼容
5.1 把配置存进 Flash 再读回来
cJSON 不只能解析串口数据,拿来做配置持久化也很顺手。思路是:上电时从 Flash 读出一段 JSON 字符串,解析成对象,把参数加载到全局变量;参数修改后,重新生成 JSON 字符串,擦写 Flash 存回去。
/* 从 Flash 读取配置并解析 */ void load_config_from_flash(void) { char buf[512]; flash_read(CFG_ADDR, (uint8_t *)buf, sizeof(buf)); buf[sizeof(buf) - 1] = '\0'; cJSON *root = cJSON_Parse(buf); if (root == NULL) { /* 首次上电或数据损坏,加载默认配置 */ load_default_config(); return; } cJSON *baud = cJSON_GetObjectItem(root, "baud"); if (cJSON_IsNumber(baud)) { g_config.baud = baud->valueint; } /* ... 其他字段 ... */ cJSON_Delete(root); }逻辑说明:Flash 里存的是上一次cJSON_PrintUnformatted生成的紧凑字符串。读取后补\0再解析。解析失败说明 Flash 数据无效(首次上电或擦写中断),走默认配置分支。参数上,buf大小要大于你最大配置 JSON 的长度,留一点余量。
5.2 用版本字段做配置兼容
产品迭代时配置结构会变,老版本 Flash 里的 JSON 可能缺字段或多字段。我的习惯是在 JSON 根对象里放一个"ver"字段,加载时先读版本号,再决定按哪套逻辑解析。
cJSON *ver = cJSON_GetObjectItem(root, "ver"); int cfg_ver = cJSON_IsNumber(ver) ? ver->valueint : 0; if (cfg_ver < 2) { /* 老版本配置,做字段迁移 */ migrate_config_v1_to_v2(root); }逻辑说明:ver缺失时默认按版本 0 处理,保证向前兼容。迁移函数负责把老字段映射到新结构,或者补上新增字段的默认值。这样升级固件后,老设备不会因为配置格式变化而丢参数。
参数说明:版本号建议用整数,每次结构变更递增。迁移逻辑要幂等,重复执行不出错。
5.3 一个我常用的调试习惯
cJSON 出错时,cJSON_GetErrorPtr返回的是出错位置附近的指针,但它指向的是原始缓冲区内部。我一般会打印出错位置前后各 16 个字符,这样能直观看到是哪个字段附近出的问题。
const char *err = cJSON_GetErrorPtr(); if (err) { int offset = err - rx_buf; printf("error at offset %d: %.32s\r\n", offset, rx_buf + (offset > 16 ? offset - 16 : 0)); }从那以后我每次集成 cJSON 到新工程,都强制先跑一遍「解析失败路径」——故意发一帧残缺 JSON,确认错误定位和内存释放都正常,再跑正常流程。这个习惯帮我省了不少现场排查的时间。希望帮到你。
本文还有配套的精品资源,点击获取