简介:这份资源是面向嵌入式开发者与单片机学习者的轻量级JSON解析库cJson,专为资源受限环境设计,可解决STM32等平台与服务器、上位机之间JSON数据交互的解析与生成问题。压缩包内共2个文件,包含1个cJSON.h头文件与1个cJSON.c实现文件,整体仅约9KB,集成时无需额外依赖,占用存储与内存极小。内容围绕cJson的核心能力展开,涵盖JSON基础概念、库的轻量高效与简洁API特点、cJSON_Parse、cJSON_Print、cJSON_CreateObject、cJSON_Delete等核心函数的用法,以及创建对象、添加键值对、解析字符串、遍历修改、生成输出到释放资源的完整流程,并给出在STM32上处理网络协议JSON数据的应用思路与示例代码。目前已有790人学习下载,适合希望快速掌握嵌入式JSON处理、提升数据交互开发效率的读者参考。
1. 单片机 JSON 解析的痛,cJSON 到底是不是福音
做嵌入式的人都有过这种经历:设备要跟云端对接,协议里塞了一串 JSON,MCU 上跑着裸机或者 RTOS,RAM 掐着指头算,Flash 也就那么点。你打开 Keil 或者 IAR,想找个 JSON 解析库,结果发现大部分库要么依赖 malloc 和文件系统,要么代码体积大到放不下,要么移植文档写得云里雾里。这时候有人跟你说“用 cJSON 吧,单片机的福音”,你第一反应大概是:真的假的?
cJSON 是一个用纯 C 写的 JSON 解析与生成库,整个实现就一个 .c 加一个 .h,不依赖操作系统、不依赖标准库以外的任何东西,核心逻辑围绕链表和递归下降解析展开。它最大的特点是把 JSON 的每个节点抽象成一个cJSON结构体,用双向链表串起来,解析时按需分配内存,生成时按树形结构拼接字符串。对单片机来说,这意味着你可以用几百字节的 RAM 和几 KB 的 Flash 完成一次完整的 JSON 收发,而不需要引入臃肿的框架。
这篇文章面向的是正在做物联网终端、工控板卡、串口屏协议对接的嵌入式开发者。我会从 cJSON 在单片机上的移植、内存配置、解析与生成的实际代码、常见翻车场景几个角度,把“福音”这个词拆开来看——它到底福音在哪,又在哪些情况下会变成坑。读完你至少能判断:自己的项目该不该上 cJSON,上了之后怎么配、怎么写、怎么排错。
2. 把 cJSON 塞进单片机:移植、裁剪与内存策略
2.1 为什么 cJSON 比“自己手写解析”更值得
很多工程师第一反应是:JSON 不就是字符串吗,我自己用strstr和sscanf抠出来不就行了。小规模、固定格式的报文确实可以这么干,但只要协议稍微复杂一点,比如嵌套对象、数组、转义字符、数字科学计数法,手写解析的代码量会迅速膨胀,而且极容易在边界条件上翻车。我见过一个项目,协议里有个字段是可选数组,手写解析时没处理空数组的情况,设备在现场跑了三个月后因为一条空数组报文直接死机。
cJSON 的价值在于它把 JSON 的语法规则固化成了状态机,你不需要关心引号怎么配对、括号怎么嵌套、转义怎么处理,只需要调用cJSON_Parse拿到树,然后按 key 取值。它的解析器是递归下降的,代码结构清晰,移植时只需要保证malloc和free可用,或者替换成自己的内存池。对于单片机来说,这一点非常关键:你可以完全控制内存来源,避免标准库 malloc 带来的碎片问题。
另一个容易被忽略的点是 cJSON 的代码体积。在 ARM Cortex-M3 上,开启-Os优化,只保留解析和生成功能,编译出来大约 6~8KB Flash,RAM 占用取决于你的 JSON 报文大小和节点数量。对比一些带 DOM 和 SAX 双模式的库,这个数字已经相当克制了。如果你的 Flash 只有 64KB,跑 RTOS 加网络协议栈之后还剩 20KB,cJSON 是能塞进去的。
2.2 移植步骤:从源码到第一个可运行工程
cJSON 的源码结构极简,核心就是cJSON.c和cJSON.h。移植时不要直接复制整个仓库里的测试文件和 CMake 脚本,只取这两个文件即可。下面以常见的 ARM GCC 工程为例,说明移植过程。
第一步,把cJSON.c和cJSON.h加入你的工程源文件列表。如果你用的是 Keil 或 IAR,直接在 IDE 里添加现有文件。注意cJSON.c里默认会#include <stdlib.h>、<string.h>、<math.h>,确保你的工具链有这些头文件。对于资源极紧的场合,可以重定义cJSON_malloc和cJSON_free宏,指向自己的内存池。
/* 在 cJSON.h 或编译选项中重定义内存函数 */ #define cJSON_malloc(sz) my_pool_alloc(sz) #define cJSON_free(ptr) my_pool_free(ptr) /* 一个简单的静态内存池示例,放在你的板级支持包里 */ static uint8_t json_pool[2048]; static size_t json_pool_offset = 0; void *my_pool_alloc(size_t sz) { void *p = NULL; if (json_pool_offset + sz <= sizeof(json_pool)) { p = &json_pool[json_pool_offset]; json_pool_offset += sz; } return p; } void my_pool_free(void *ptr) { /* 简单池不回收,整包解析完统一重置 offset */ (void)ptr; } void my_pool_reset(void) { json_pool_offset = 0; }这段代码的逻辑是:用一个静态数组模拟堆,每次分配时移动偏移量,释放时不做实际回收,而是在一整包 JSON 处理完之后调用my_pool_reset把偏移量归零。这样做的好处是完全没有碎片,分配速度是 O(1),缺点是池子大小必须大于单次解析所需的最大内存。参数json_pool的大小需要根据你的报文长度估算,一般建议取最大报文长度的 3~5 倍,因为 cJSON 每个节点都有结构体开销。
第二步,在工程里定义一个宏CJSON_HIDE_SYMBOLS或者直接修改cJSON.c,把不需要的功能裁掉。cJSON 支持条件编译,比如你不需要cJSON_Print的格式化输出,可以定义CJSON_NESTING_LIMIT来限制嵌套深度,防止恶意报文导致栈溢出。常见裁剪选项如下表。
| 宏名称 | 作用 | 单片机建议 |
|---|---|---|
| CJSON_NESTING_LIMIT | 限制 JSON 嵌套层数 | 设为 10~16,防止递归过深 |
| CJSON_USE_STACK | 是否使用栈缓冲 | 默认即可,栈小的设 0 |
| CJSON_HIDE_SYMBOLS | 隐藏内部符号 | 设为 1 减小符号表 |
| CJSON_CDECL | 调用约定 | 按编译器默认 |
第三步,写一个最小测试函数,验证解析和生成是否正常。不要一上来就接串口和网络,先在 main 里用字符串测。
#include "cJSON.h" #include <stdio.h> void test_cjson_basic(void) { const char *text = "{\"dev\":\"sensor01\",\"temp\":25.6,\"ok\":true}"; cJSON *root = cJSON_Parse(text); if (root == NULL) { printf("parse failed\n"); return; } cJSON *dev = cJSON_GetObjectItem(root, "dev"); cJSON *temp = cJSON_GetObjectItem(root, "temp"); if (cJSON_IsString(dev)) { printf("dev=%s\n", dev->valuestring); } if (cJSON_IsNumber(temp)) { printf("temp=%.1f\n", temp->valuedouble); } cJSON_Delete(root); }逻辑说明:cJSON_Parse返回根节点,失败返回 NULL。cJSON_GetObjectItem按 key 查找子节点,返回的指针不需要单独释放,因为cJSON_Delete会递归释放整棵树。参数方面,temp->valuedouble是双精度浮点,单片机如果没有 FPU,浮点运算会拉长代码,可以考虑用valueint存整数,或者把温度乘以 10 用整数传输。
2.3 内存配置:别让 malloc 在半夜把你叫醒
cJSON 默认用malloc和free,这在裸机或 RTOS 上都是隐患。标准库的 malloc 在长时间运行后会产生碎片,尤其是 JSON 报文长度变化大、解析频率高的场景。我一般会强制替换成内存池,或者至少用heap_4这类带合并的分配器。如果你用的是 FreeRTOS,可以直接把cJSON_malloc指向pvPortMalloc,但要注意pvPortMalloc在中断里不可用,而 cJSON 的解析通常不在中断里做,所以问题不大。
另一个参数是CJSON_NESTING_LIMIT。默认值是 1000,对单片机来说太大了。递归下降解析器每层嵌套都会消耗栈空间,如果报文里塞了 100 层嵌套,栈直接爆掉。我一般设成 10 到 16,足够覆盖正常协议。设置方法是在编译选项里加-DCJSON_NESTING_LIMIT=16,或者直接在cJSON.h里改。
还有一点:cJSON 解析数字时用strtod,这个函数在有些嵌入式 libc 里实现得很重,会拉进来一大堆浮点格式化代码。如果你的 JSON 里数字都是整数,可以在cJSON.c里把strtod替换成strtol,或者自己写一个简单的整数解析。这个改动能省下几 KB Flash,对小容量芯片很划算。
3. 解析与生成实战:从串口报文到云端应答
3.1 解析嵌套数组与对象:一个传感器上报的例子
实际项目里的 JSON 往往不是扁平的。比如一个网关设备上报多个传感器的数据,报文长这样:
{ "gw": "GW-01", "ts": 1710000000, "nodes": [ {"id": 1, "t": 25.6, "h": 60}, {"id": 2, "t": 26.1, "h": 58} ] }用 cJSON 解析这种结构,需要遍历数组。下面是对应的代码。
void parse_gateway_report(const char *text) { cJSON *root = cJSON_Parse(text); if (!root) return; cJSON *gw = cJSON_GetObjectItem(root, "gw"); cJSON *ts = cJSON_GetObjectItem(root, "ts"); cJSON *nodes = cJSON_GetObjectItem(root, "nodes"); if (cJSON_IsString(gw)) { printf("gateway: %s\n", gw->valuestring); } if (cJSON_IsNumber(ts)) { printf("timestamp: %d\n", ts->valueint); } if (cJSON_IsArray(nodes)) { int n = cJSON_GetArraySize(nodes); for (int i = 0; i < n; i++) { cJSON *node = cJSON_GetArrayItem(nodes, i); cJSON *id = cJSON_GetObjectItem(node, "id"); cJSON *t = cJSON_GetObjectItem(node, "t"); cJSON *h = cJSON_GetObjectItem(node, "h"); if (cJSON_IsNumber(id) && cJSON_IsNumber(t) && cJSON_IsNumber(h)) { printf("node %d: t=%.1f h=%d\n", id->valueint, t->valuedouble, h->valueint); } } } cJSON_Delete(root); }逻辑说明:cJSON_GetArraySize返回数组元素个数,cJSON_GetArrayItem按索引取元素。每个数组元素本身又是一个对象,继续用cJSON_GetObjectItem取字段。参数上,ts->valueint在 32 位平台上可能溢出,如果时间戳是 64 位,需要用valuedouble或者手动拼。t->valuedouble是浮点,打印时用%.1f会拉入浮点格式化,如果串口输出压力大,可以转成整数再打印。
这里有个细节:cJSON_GetObjectItem是大小写敏感的,JSON 的 key 必须完全匹配。有些云端下发的字段名带下划线或者驼峰,解析前最好确认一遍。另外,如果某个字段可能不存在,cJSON_GetObjectItem返回 NULL,直接访问->valuestring会硬件异常。所以每个取值前都要判空,这是血泪经验。
3.2 生成 JSON 应答:控制内存与转义
设备收到指令后要回一个应答,比如:
{"code":0,"msg":"ok","data":{"led":1}}用 cJSON 生成这个结构,代码比解析还简单,但要注意内存释放和转义。
char *build_ack(int code, const char *msg, int led) { cJSON *root = cJSON_CreateObject(); if (!root) return NULL; cJSON_AddNumberToObject(root, "code", code); cJSON_AddStringToObject(root, "msg", msg); cJSON *data = cJSON_CreateObject(); if (data) { cJSON_AddNumberToObject(data, "led", led); cJSON_AddItemToObject(root, "data", data); } char *out = cJSON_PrintUnformatted(root); cJSON_Delete(root); return out; /* 调用者负责 free */ }逻辑说明:cJSON_CreateObject创建一个空对象,cJSON_AddNumberToObject和cJSON_AddStringToObject是便捷函数,内部会创建节点并挂到父节点上。cJSON_PrintUnformatted输出紧凑格式,不带空格和换行,适合串口和网络传输。返回的字符串是用malloc分配的,调用者用完必须free,否则内存泄漏。参数上,msg如果包含双引号或反斜杠,cJSON 会自动转义,不需要手动处理。
如果你用的是内存池,cJSON_PrintUnformatted返回的指针也来自池子,那么“free”就要换成池子的重置逻辑。这里容易翻车:有人把返回的字符串直接传给printf之后就不管了,跑几百次之后池子耗尽,设备死机。所以生成和解析最好成对出现,一包处理完就重置池子。
3.3 在 RTOS 任务里安全使用 cJSON
如果你的单片机跑 FreeRTOS 或 RT-Thread,cJSON 本身不是线程安全的。多个任务同时调用cJSON_Parse或cJSON_Print,如果它们共享同一个内存池,就会互相踩踏。常见做法是给每个任务分配独立的内存池,或者用互斥锁保护共享池。我一般倾向于前者,因为锁会引入优先级反转和死锁风险,而独立池的代价只是多占几百字节 RAM。
下面是一个 FreeRTOS 下的任务示例,每个任务用自己的池子。
static uint8_t task1_pool[1024]; static size_t task1_off = 0; void *task1_alloc(size_t sz) { void *p = NULL; if (task1_off + sz <= sizeof(task1_pool)) { p = &task1_pool[task1_off]; task1_off += sz; } return p; } void task1_reset(void) { task1_off = 0; } void vTaskJsonHandler(void *pv) { for (;;) { char *raw = uart_read_packet(); /* 阻塞读串口 */ if (raw) { cJSON *root = cJSON_Parse(raw); if (root) { /* 处理业务 */ cJSON_Delete(root); } task1_reset(); } vTaskDelay(pdMS_TO_TICKS(10)); } }逻辑说明:每个任务维护自己的alloc和reset,cJSON_Parse之前不需要加锁,因为池子是任务私有的。uart_read_packet是伪代码,实际项目中可能是环形缓冲区加信号量。参数上,池子大小 1024 字节适合 200 字节以内的报文,如果报文更大,按比例增加。注意cJSON_Delete之后才重置池子,顺序不能反,否则树还没释放就把内存标记为可用,会导致数据错乱。
4. 避坑与排查:cJSON 在单片机上的五个翻车现场
4.1 解析成功但取值全是 NULL
现象:cJSON_Parse返回非 NULL,但cJSON_GetObjectItem取任何字段都是 NULL。原因通常是 JSON 字符串里有不可见字符,比如串口接收时带了\r\n或者 BOM 头,或者 key 的大小写不匹配。解决方法是打印原始报文,用十六进制看前几个字节,确认没有0xEF 0xBB 0xBF这类 BOM。另外检查cJSON_GetObjectItem的 key 是否和报文完全一致,包括空格。
4.2 跑一段时间后死机,栈溢出
现象:设备运行几分钟到几小时不等,突然硬件异常,看门狗复位。原因多半是递归解析时嵌套层数太深,或者cJSON_Print在栈上分配了大数组。解决方法是设置CJSON_NESTING_LIMIT为 10~16,并且检查cJSON.c里print_number等函数是否用了大局部变量。如果栈本身只有 1KB,建议把CJSON_USE_STACK关掉,强制走堆。
4.3 内存池耗尽,解析返回 NULL
现象:前几次解析正常,后面cJSON_Parse一直返回 NULL。原因是内存池没有重置,或者cJSON_Delete没有调用。解决方法是确保每次解析后都调用cJSON_Delete,并且在整包处理完后重置池子偏移量。如果用的是标准 malloc,用free检查工具或者自己加计数器,看分配和释放是否配对。
4.4 浮点数解析精度丢失
现象:报文里是25.6,解析出来变成25.599999。原因是strtod的精度限制,以及float和double的转换。解决方法是如果不需要小数,用整数传输;如果需要,接受这个误差,或者在比较时用范围判断。另外,cJSON_IsNumber对true和false返回 false,别把布尔值当数字取。
4.5 生成的 JSON 字符串带乱码
现象:cJSON_PrintUnformatted返回的字符串打印出来有乱码,或者长度不对。原因是内存池分配失败返回 NULL,但代码没判空直接用了。解决方法是每次cJSON_Print之后判空,并且检查池子大小是否足够。另外,如果msg字段包含中文,确保你的工程编码是 UTF-8,cJSON 本身不处理编码转换,它只按字节转义。
5. 进阶技巧:用 cJSON 做协议网关的配置热加载
前面讲的都是单包解析和生成。实际项目中,cJSON 还能用来做配置文件的解析,实现参数热加载。比如设备里存一个config.json,上电时读进 RAM,解析成 cJSON 树,然后业务代码从树里取参数。需要改参数时,通过串口或网络下发新的 JSON,解析后替换树,再写回 Flash。这样不用重新编译固件就能调整阈值、上报周期、设备 ID 等。
具体做法是:定义一个全局的cJSON *g_config,上电时从 Flash 读取字符串并解析。业务代码不直接读全局变量,而是通过封装函数config_get_int("report_interval")从树里取。更新时,先解析新报文,验证关键字段存在且类型正确,然后用cJSON_ReplaceItemInObject替换旧节点,最后调用cJSON_PrintUnformatted序列化并写入 Flash。注意 Flash 写入前要擦除扇区,并且做好掉电保护,否则配置丢了设备就变砖。
验证方法很简单:在串口终端里发一条{"cmd":"set","key":"interval","val":30},设备解析后更新配置树,回一条{"code":0},然后观察上报周期是否变成 30 秒。如果没变,先检查cJSON_ReplaceItemInObject的返回值,再检查 Flash 写入是否成功。我一般会在写 Flash 之前先把新配置打印出来,确认无误再擦写,这个习惯帮我省过好几次后悔药。
还有一个技巧是限制配置树的大小。不要什么参数都往 JSON 里塞,只放需要热加载的。静态参数直接编译进固件,减少解析开销和内存占用。另外,配置树的节点数量最好有个上限,比如 32 个,超过就拒绝更新,防止恶意报文撑爆内存。
最后说一个我自己的教训:早期做项目时,我把 cJSON 的解析放在中断服务函数里,觉得报文短、速度快。结果有一次现场报文突然变长,中断里解析时间超过 1ms,导致串口丢数据,整个通信协议崩了。后来改成中断里只收数据,解析放到任务里,问题消失。所以 cJSON 虽好,别在中断里用,这是底线。希望帮到你。
本文还有配套的精品资源,点击获取