简介cJson库是专为嵌入式系统打造的轻量级JSON解析与生成工具面向STM32等单片机开发者解决资源受限环境下JSON数据交互的难题。资源包内含2个文件分别为cJSON.h头文件与cJSON.c实现文件压缩包仅9KB集成时只需将这两个文件加入工程即可无需额外依赖。目前已有790人学习下载适合需要处理网络协议、配置更新或与服务器通信的嵌入式项目。内容围绕cJson核心函数展开包括cJSON_Parse解析、cJSON_Print生成、cJSON_CreateObject创建对象及cJSON_Delete释放内存并给出在STM32上解析与构造JSON的完整示例代码帮助读者快速掌握键值对添加、遍历修改与资源释放等操作。对于追求高效、小巧JSON方案的C语言单片机开发者这份资源提供了可直接复用的实现与清晰的API使用思路。1. 串口屏项目里为什么我最后把 JSON 解析换成了 cJSON去年做一个工业串口屏的固件主控是一颗 64KB Flash、8KB RAM 的 Cortex-M0屏幕那边要显示十来个传感器通道的实时值还要接收上位机下发的配置。最开始我用的是自己手写的一个简易键值对解析keyvalue;keyvalue;简单、省内存、跑得也快。问题出在配置项变复杂之后——有的通道要带量程、有的要带报警阈值、有的要带单位键值对开始出现嵌套需求手写解析器越写越像在造一个残缺的 JSON 解析器还全是 bug。后来换成 cJSON代码量直接砍掉一大半。cJSON 是一个用纯 C 写的超轻量 JSON 解析与生成库核心就一个.c加一个.h不依赖任何第三方库不依赖操作系统甚至不依赖标准库里的malloc——你可以把内存分配钩子换成自己的静态内存池。对单片机来说这才是真正的福音它把「结构化数据交换」这件事从你手里接走了你只需要关心业务字段。这篇笔记就按我实际落地的顺序把 cJSON 在单片机上怎么移植、怎么配内存、怎么解析、怎么生成、坑在哪一次讲透。适合正在做串口屏、Modbus 网关、物联网节点、上位机通信协议的嵌入式开发者。2. cJSON 的移植与内存模型先搞懂它怎么吃内存2.1 为什么单片机场景优先选 cJSON 而不是别的 JSON 库选型这件事在单片机上和在 PC 上完全是两套逻辑。PC 上你关心的是解析速度、API 好不好用、支不支持 JSON5单片机上你首先关心的是「它会不会把我的 RAM 吃光」。市面上常见的几个 C 语言 JSON 库大致分两类一类是 DOM 式解析完在内存里建一棵完整的树比如 cJSON、jsmn 的封装版一类是流式/SAX 式边解析边回调不建树比如 jsmn 原生用法。cJSON 属于 DOM 式。这意味着解析一个 200 字节的 JSON 报文它会在堆上建出对应的节点树每个节点是一个cJSON结构体。这个结构体在 32 位平台上的大小我实测过大概是 40 字节左右含next、prev、child、type、valuestring、valuedouble、string这些成员。所以一个带 10 个字段的扁平对象解析完大概占 400 到 600 字节堆空间加上字符串本身的拷贝1KB 以内能打住。对 8KB RAM 的芯片来说这是可以接受的。那为什么不选流式因为流式虽然省内存但用起来极其反人类。你要为每个字段写一个回调分支嵌套结构处理起来回调套回调代码可读性直接崩掉。而且流式解析对「我要取第 3 个数组元素的第 2 个字段」这种需求几乎没法优雅实现。cJSON 的 DOM 模型让你可以cJSON_GetObjectItem(root, temp)一把取出来开发效率完全不是一个量级。代价就是那几百字节的堆开销在 8KB RAM 的芯片上只要你别同时解析多个大报文完全扛得住。提示如果你的芯片 RAM 小于 4KB或者要解析的 JSON 超过 2KB建议先评估流式方案否则 cJSON 的 DOM 模型是性价比最高的选择。2.2 把 cJSON 塞进工程文件、宏和三个必须改的配置cJSON 的源码结构极简核心就两个文件cJSON.c和cJSON.h。把它俩拷进你的工程目录在 IDE 里把.c加入编译#include cJSON.h就能用。但直接这么用在单片机上大概率会翻车因为默认配置是给 PC 准备的。下面是我一般会改的三个地方。第一处是内存分配钩子。cJSON 默认用malloc和free但很多单片机的标准库malloc要么没实现要么碎片化严重。cJSON 在cJSON.c顶部提供了两个宏/* cJSON.c 顶部默认定义 */ #ifndef cJSON_malloc #define cJSON_malloc malloc #endif #ifndef cJSON_free #define cJSON_free free #endif我一般会在cJSON.h被包含之前用编译选项或者在自己的头文件里重定义这两个宏指向自己的静态内存池。比如/* my_mem.h —— 在包含 cJSON.h 之前定义 */ #include stddef.h void *my_pool_alloc(size_t size); void my_pool_free(void *ptr); #define cJSON_malloc(sz) my_pool_alloc(sz) #define cJSON_free(p) my_pool_free(p) #include cJSON.h这里的关键是cJSON_malloc和cJSON_free必须成对实现而且my_pool_free要能正确处理NULL。我见过有人只重定义了malloc没重定义free结果解析正常、释放时直接 HardFault查了一整天才发现是释放用了标准库的free去释放内存池的指针。这种玄学问题血泪经验就是两个宏必须一起改。第二处是浮点支持。cJSON 默认开启ENABLE_DOUBLE会用double存数字。如果你的芯片没有硬件 FPU或者你根本不需要小数可以在cJSON.h里把相关宏关掉改用整数存储能省不少代码空间和运算时间。具体宏名在不同版本里略有差异常见的是CJSON_ENABLE_DOUBLE或ENABLE_DOUBLE改之前先看一眼头文件里的条件编译段。第三处是CJSON_NESTING_LIMIT默认是 1000。这个值决定了 JSON 最大嵌套深度每层嵌套在解析时会占用栈空间。单片机栈通常只有 1KB 到 2KB1000 层嵌套直接爆栈。我一般改成 10 到 20够用且安全。2.3 内存池怎么配给 cJSON 划一块专属堆在单片机上用 cJSON最稳的做法是给它划一块独立的静态内存池而不是和系统堆混用。这样做的原因有两个一是避免和 RTOS 的堆管理打架二是内存池可以做成固定块大小彻底消除碎片。下面是一个最简的固定块内存池实现块大小设成 64 字节块数按你的最大报文估算/* cjson_pool.c —— 给 cJSON 用的固定块内存池 */ #include stdint.h #include string.h #define POOL_BLOCK_SIZE 64 #define POOL_BLOCK_COUNT 64 /* 64 * 64 4KB按需调整 */ typedef union { uint8_t data[POOL_BLOCK_SIZE]; union block *next; /* 空闲时存下一块地址 */ } pool_block_t; static pool_block_t g_pool[POOL_BLOCK_COUNT]; static pool_block_t *g_free_list NULL; void pool_init(void) { for (int i 0; i POOL_BLOCK_COUNT - 1; i) { g_pool[i].next g_pool[i 1]; } g_pool[POOL_BLOCK_COUNT - 1].next NULL; g_free_list g_pool[0]; } void *my_pool_alloc(size_t size) { if (size POOL_BLOCK_SIZE || g_free_list NULL) { return NULL; /* 超块大小或池耗尽返回 NULL */ } pool_block_t *blk g_free_list; g_free_list g_free_list-next; return blk-data; } void my_pool_free(void *ptr) { if (ptr NULL) return; pool_block_t *blk (pool_block_t *)((uint8_t *)ptr - offsetof(pool_block_t, data)); blk-next g_free_list; g_free_list blk; }这段代码的逻辑说明pool_block_t用union让每个块在空闲时存链表指针、被占用时存数据不浪费空间。pool_init把所有块串成空闲链表。my_pool_alloc从链表头摘一块my_pool_free把块还回链表头。参数上POOL_BLOCK_SIZE要大于等于 cJSON 单个节点的大小32 位平台约 40 字节所以 64 是安全值POOL_BLOCK_COUNT决定池总量4KB 的池大概能支撑解析一个 1KB 左右的 JSON 报文。注意这个池不支持任意大小分配超过 64 字节的请求会返回 NULL。cJSON 在解析长字符串时会一次性申请字符串长度加一的空间所以如果你的 JSON 里有超过 63 字节的字符串值要么调大块大小要么改用带大小类的内存池。这是固定块池最常见的翻车点。3. 解析实战从串口收到一帧 JSON 到取出字段3.1 最小解析流程Parse、取字段、释放三步不能少cJSON 的解析 API 就一个核心函数cJSON_Parse。它接收一个以\0结尾的字符串返回根节点指针失败返回NULL。拿到根节点后用cJSON_GetObjectItem按 key 取子节点用cJSON_GetStringValue、cJSON_GetNumberValue之类的辅助函数取值。最后必须调用cJSON_Delete释放整棵树。下面是一个完整的解析示例模拟从串口缓冲区拿到一帧传感器数据/* parse_demo.c —— 解析一帧传感器 JSON */ #include cJSON.h #include stdio.h #include string.h /* 假设串口收到的原始数据已去掉帧头帧尾 */ static const char *rx_frame {\dev\:\node01\,\temp\:25.6,\hum\:60, \alarm\:false,\ch\:[1,2,3]}; void handle_frame(const char *json_str) { cJSON *root cJSON_Parse(json_str); if (root NULL) { /* 解析失败打印出错位置方便定位 */ const char *err cJSON_GetErrorPtr(); printf(json parse error near: %s\r\n, err ? err : unknown); return; } /* 取字符串字段 */ cJSON *dev cJSON_GetObjectItem(root, dev); if (cJSON_IsString(dev)) { printf(device %s\r\n, dev-valuestring); } /* 取浮点字段 */ cJSON *temp cJSON_GetObjectItem(root, temp); if (cJSON_IsNumber(temp)) { printf(temp %.1f\r\n, temp-valuedouble); } /* 取整数字段 */ cJSON *hum cJSON_GetObjectItem(root, hum); if (cJSON_IsNumber(hum)) { printf(hum %d\r\n, hum-valueint); } /* 取布尔字段 */ cJSON *alarm cJSON_GetObjectItem(root, alarm); if (cJSON_IsBool(alarm)) { printf(alarm %d\r\n, cJSON_IsTrue(alarm)); } /* 遍历数组 */ cJSON *ch cJSON_GetObjectItem(root, ch); if (cJSON_IsArray(ch)) { cJSON *item NULL; cJSON_ArrayForEach(item, ch) { printf(ch %d\r\n, item-valueint); } } cJSON_Delete(root); /* 必须释放否则内存池很快耗尽 */ }逻辑说明cJSON_Parse内部会先扫描一遍字符串确定结构再逐个建节点。cJSON_GetObjectItem是线性查找字段越多越慢但十几个字段的量级完全无感。每个取值前都用cJSON_IsXxx做类型检查这是必须的——如果字段不存在cJSON_GetObjectItem返回NULL直接访问-valuestring就是空指针解引用在单片机上直接 HardFault。cJSON_ArrayForEach是一个宏展开后就是沿着next指针遍历链表。参数说明cJSON_Parse要求输入字符串以\0结尾。如果你从串口收到的是带长度信息的缓冲区必须先拷贝到一个多一字节的临时数组并补\0否则解析会越界读。这个坑我踩过现象是偶尔解析成功偶尔失败查了半天才发现是缓冲区末尾没有终止符。3.2 串口收包与 JSON 解析怎么衔接环形缓冲加帧同步实际项目里JSON 报文不会整整齐齐地一次性到达而是被串口中断切成一个个字节。常见做法是用环形缓冲区收字节在主循环里检测帧边界凑齐一帧再交给 cJSON。帧边界可以靠约定比如用\n结尾或者用长度字段。下面是一个简化的环形缓冲加帧提取逻辑/* uart_frame.c —— 环形缓冲收字节按换行符切帧 */ #include stdint.h #include string.h #define RB_SIZE 256 static uint8_t g_rb[RB_SIZE]; static volatile uint16_t g_head 0, g_tail 0; /* 串口中断里调用 */ void uart_rx_isr(uint8_t byte) { uint16_t next (g_head 1) % RB_SIZE; if (next ! g_tail) { /* 缓冲未满 */ g_rb[g_head] byte; g_head next; } /* 满了就丢弃避免覆盖未处理数据 */ } /* 主循环里调用返回一帧长度0 表示无完整帧 */ uint16_t frame_poll(char *out, uint16_t out_size) { uint16_t len 0; while (g_tail ! g_head) { uint8_t b g_rb[g_tail]; g_tail (g_tail 1) % RB_SIZE; if (b \n) { /* 换行符作为帧结束 */ if (len out_size) { out[len] \0; return len; } len 0; /* 超长帧丢弃重新计数 */ continue; } if (len out_size - 1) { out[len] (char)b; } } return 0; }逻辑说明中断只负责把字节塞进环形缓冲不做任何解析保证中断短小。主循环调用frame_poll逐字节取出遇到\n就认为一帧结束把累积的字符写入out并补\0。参数上RB_SIZE要大于单帧最大长度加余量out_size要大于单帧最大长度。这样 cJSON 拿到的永远是完整且带终止符的字符串。提示如果你的协议里 JSON 本身可能包含\n比如格式化过的报文就不能用换行符做帧边界改用长度前缀或特定帧头帧尾。这是协议设计阶段就要定好的事后期改起来很痛。3.3 解析失败怎么排查用好 cJSON_GetErrorPtrcJSON 解析失败时返回NULL但光知道失败没用得知道错在哪。cJSON_GetErrorPtr返回一个指向出错位置附近的指针把它打印出来基本能定位问题。常见的失败原因有三类括号不匹配、字符串没闭合、数字格式非法。我一般会在解析失败时把原始报文和错误位置一起打印方便对照cJSON *root cJSON_Parse(json_str); if (root NULL) { const char *err cJSON_GetErrorPtr(); printf(raw: %s\r\n, json_str); printf(err near: %.20s\r\n, err ? err : (null)); return; }这里%.20s只打印错误位置往后 20 个字符避免打印越界。如果err指向的是报文中间某个位置往前看几个字符通常就能发现是少了逗号还是多了引号。这个技巧在调试上位机下发的配置报文时特别有用因为上位机生成的 JSON 偶尔会因为转义问题出错。4. 生成实战单片机怎么拼出上位机认的 JSON4.1 用 cJSON 建树再打印适合字段固定的场景解析是收生成是发。单片机往上位机回数据或者往云端推数据都需要生成 JSON。cJSON 生成的路子是先用cJSON_CreateObject建根再用cJSON_AddXxxToObject往里塞字段最后用cJSON_PrintUnformatted转成字符串用完记得cJSON_Delete和释放打印出来的字符串。/* build_demo.c —— 生成一帧上报 JSON */ #include cJSON.h #include stdio.h char *build_report(const char *dev, float temp, int hum, int alarm) { cJSON *root cJSON_CreateObject(); if (root NULL) return NULL; cJSON_AddStringToObject(root, dev, dev); cJSON_AddNumberToObject(root, temp, temp); cJSON_AddNumberToObject(root, hum, hum); cJSON_AddBoolToObject(root, alarm, alarm); /* 加一个数组 */ cJSON *ch cJSON_AddArrayToObject(root, ch); for (int i 1; i 3; i) { cJSON_AddItemToArray(ch, cJSON_CreateNumber(i)); } char *out cJSON_PrintUnformatted(root); cJSON_Delete(root); /* 树用完就删out 是独立内存 */ return out; /* 调用者负责 cJSON_free(out) */ }逻辑说明cJSON_CreateObject在内存池里建一个对象节点。cJSON_AddStringToObject会拷贝字符串所以传入的dev可以是栈上的临时变量。cJSON_AddArrayToObject建一个数组节点并挂到根上然后循环往里加数字。cJSON_PrintUnformatted会遍历整棵树计算出总长度申请一块内存把 JSON 字符串写进去。这块内存和树是独立的所以cJSON_Delete(root)之后out依然有效但调用者必须用cJSON_free释放out否则内存池会漏。参数说明cJSON_PrintUnformatted生成的是紧凑格式没有空格和换行适合串口传输。如果你要打印给人看用cJSON_Print它会带缩进但会多占不少内存。在单片机上我一般只用Unformatted调试时在上位机侧格式化。4.2 不用建树的轻量生成字段少时直接拼字符串建树再打印虽然通用但每个字段都要建节点、最后还要遍历打印对 RAM 和 CPU 都是开销。如果字段很少且格式固定直接snprintf拼字符串反而更快更省。我一般在这两种做法之间按字段数取舍字段少于 5 个且没有嵌套直接拼字段多或有嵌套用 cJSON。/* light_build.c —— 字段少时直接拼 JSON */ #include stdio.h int build_light(char *buf, int buf_size, float temp, int hum) { int n snprintf(buf, buf_size, {\temp\:%.1f,\hum\:%d}, temp, hum); if (n 0 || n buf_size) { return -1; /* 缓冲区不够生成失败 */ } return n; }逻辑说明snprintf返回写入的字符数不含终止符如果大于等于buf_size说明被截断了返回 -1 让调用者知道。参数上buf_size要留足余量浮点格式化后长度可能比预期长。这种做法的风险是字段名和格式散落在代码里改协议时容易漏改所以只适合极简场景。注意直接拼字符串时如果字符串值来自外部输入必须做转义处理否则引号或反斜杠会破坏 JSON 结构。cJSON 的AddStringToObject会自动转义这是它相对手拼的一个隐性优势。4.3 生成时的内存释放谁申请谁释放别搞混cJSON 生成涉及两块内存树节点的内存和打印字符串的内存。树节点由cJSON_Delete释放打印字符串由cJSON_free释放。这两块必须分别处理混了就是内存泄漏或者重复释放。我见过最常见的错误是这样写的char *out cJSON_PrintUnformatted(root); cJSON_Delete(root); /* ... 使用 out ... */ free(out); /* 错误应该用 cJSON_free */如果cJSON_malloc被重定义成了内存池分配而这里用标准库free去释放行为未定义轻则内存池链表错乱重则 HardFault。正确做法是统一用cJSON_free因为 cJSON 内部就是用它配对的。这个坑的隐蔽性在于如果没重定义内存钩子用free也能跑一旦换了内存池就炸所以从一开始就养成用cJSON_free的习惯。5. 避坑与排查cJSON 在单片机上最容易翻车的 5 个点5.1 解析成功但取值全是 NULL字段名大小写和类型检查现象cJSON_Parse返回非 NULL但cJSON_GetObjectItem(root, Temp)取不到值返回 NULL。原因通常是字段名大小写不匹配JSON 是大小写敏感的temp和Temp是两个不同的 key。另一个原因是取值前没做类型检查字段存在但类型不对比如用cJSON_IsString去检查一个数字字段自然不通过。解决打印根节点的所有 key 对照或者用cJSON_Print把整棵树打出来看结构。取值前一律先cJSON_IsXxx再访问成员。5.2 解析到一半 HardFault栈溢出或空指针解引用现象解析较大的嵌套 JSON 时进入 HardFault。原因有两个一是CJSON_NESTING_LIMIT太大递归解析把栈打爆二是取值时没判空cJSON_GetObjectItem返回 NULL 后直接访问成员。解决把嵌套限制改到 10 到 20取值前判空。排查时可以先把栈加大一倍如果问题消失基本就是栈溢出。另外如果用了 RTOS注意解析任务的任务栈要留够cJSON 解析时的递归深度和嵌套层数相关。5.3 内存池很快耗尽忘记 cJSON_Delete 或字符串没释放现象跑一段时间后cJSON_Parse开始返回 NULL或者cJSON_CreateObject失败。原因几乎都是内存泄漏解析完没调cJSON_Delete或者cJSON_PrintUnformatted出来的字符串没调cJSON_free。解决把cJSON_Delete和cJSON_free当成配对操作每个Parse和Print都要有对应的释放。可以在内存池的 alloc/free 里加计数器跑一段时间看计数是否归零不归零就是漏了。5.4 浮点数解析精度不对double 和 float 的取舍现象JSON 里写25.6解析出来valuedouble是25.6000004之类。原因是double本身是二进制浮点十进制小数无法精确表示这是浮点数的固有特性不是 cJSON 的 bug。解决如果业务不需要高精度显示时用%.1f格式化即可如果涉及金额等需要精确的场景改用整数存最小单位比如分别用浮点。另外如果芯片没有 FPUdouble运算会调用软件浮点库代码体积和耗时都会上去可以考虑关掉 double 支持改用整数。5.5 串口收到的 JSON 带不可见字符帧头帧尾和空白处理现象上位机发的 JSON 看起来正常但单片机解析失败错误位置指向报文开头。原因通常是报文里混入了不可见字符比如\r、\0、或者 BOM 头。解决在交给cJSON_Parse之前先跳过开头的空白字符并确保字符串以\0结尾。如果协议是\r\n结尾切帧时要把\r也去掉。我一般会在frame_poll里把\r和\n都当帧结束符处理避免\r混进 JSON。6. 进阶技巧让 cJSON 在资源受限芯片上跑得更稳前面讲的都是「能用」这一章讲「用得省、用得稳」。在 8KB RAM 的芯片上cJSON 的默认行为还有优化空间下面几个技巧是我在实际项目里验证过的。第一个技巧是复用根节点。如果你的设备周期性上报字段结构固定没必要每次都CreateObject再Delete。可以建一棵模板树每次上报只更新字段值打印完不删树。这样省掉了反复建树和释放的开销也避免了内存池碎片。具体做法是初始化时建好树上报时用cJSON_ReplaceItemInObject更新值或者直接改节点的valuedouble和valuestring。注意改valuestring时要先释放旧字符串再cJSON_strdup新的否则会漏。第二个技巧是给打印字符串用独立缓冲区。cJSON_PrintUnformatted内部会申请一块和输出等长的内存如果你的内存池块大小是 64 字节而输出有 200 字节就会申请失败。解决办法是用cJSON_PrintPreallocated它允许你传入一块预先分配好的缓冲区cJSON 直接往里写不额外申请内存。这个函数在较新版本的 cJSON 里都有签名是cJSON_bool cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format)。用的时候传一个静态数组进去长度给足就能彻底避开打印阶段的内存申请。/* prealloc_print.c —— 用预分配缓冲区打印避免动态申请 */ static char g_print_buf[256]; void report_with_prealloc(cJSON *root) { if (cJSON_PrintPreallocated(root, g_print_buf, sizeof(g_print_buf), 0)) { uart_send(g_print_buf); /* 直接发送无需释放 */ } else { /* 缓冲区不够需要调大 g_print_buf */ } }逻辑说明第四个参数format传 0 表示紧凑格式传 1 表示带缩进。返回值是cJSON_bool成功为 1失败为 0通常是缓冲区不够。这个做法的好处是打印阶段零动态分配内存池只承担解析和建树的开销压力小很多。参数上g_print_buf的大小要按最大可能输出估算宁可大一点因为它是静态的不占堆。第三个技巧是解析时用cJSON_ParseWithLength。如果你的串口缓冲区不是以\0结尾而是带长度信息用cJSON_ParseWithLength(buf, len)比先拷贝再补终止符更省一次内存拷贝。这个函数在较新版本里提供老版本没有的话就老老实实拷贝补\0。最后一个习惯也是我踩坑最多的地方每次改完协议字段先在上位机侧用同样的 JSON 字符串跑一遍解析确认字段名和类型对得上再烧到板子上。因为单片机调试手段有限一个字段名拼错可能要花半小时定位而在 PC 上就是一眼的事。我现在的流程是协议定稿后先在 PC 上写个最小解析程序验证通过了再移植到单片机。这个习惯帮我省下了大量在串口打印里大海捞针的时间。希望帮到你。本文还有配套的精品资源点击获取