简介STM32工程示例面向需要在STM32平台上解析和生成JSON格式数据的嵌入式开发者。资源基于cjson开源库实现了JSON字符串的解析与打包工程已经过验证可在实际项目中直接移植使用帮助读者绕过环境配置与底层适配的坑缩短开发周期。压缩包共133个文件约1.76MB以31个C源文件与31个头文件为主体另含Keil工程配置uvprojx/uvoptx、链接脚本sct、编译中间文件o/d/crf等以及readme、license等说明文档目录结构清晰便于按需筛选代码与工程模板。已有3249人学习下载属于实用型参考资源。通过阅读源码与运行示例可以掌握cjson库在STM32上的移植方法、JSON对象创建与解析的典型调用流程并能直接借用工程模板完成自己的数据交换需求。1. 拿到压缩包之后先搞清楚 STM32 为什么需要 cJSON当你不小心把“单片机能不能解析 JSON”这个问题抛给搜索引擎大概率会先看到一堆在电脑上跑通的示例再看到几个移植到 STM32 上就翻车的帖子。标题里的“stm32 使用 cjson 解析 json 字符串.zip”其实已经点明了这个任务的真实面貌不是把 JSON 解析库下载下来就能用而是在 Cortex-M 这种内存按 KB 计算、没有操作系统兜底的环境里把解析这个动作稳稳当当地跑起来。你必须自己管理内存、控制栈深度、留意编译器选项还得对库本身的行为有足够清晰的预期。这篇文章想解决的问题很具体cJSON 在 STM32 上到底怎么挂进工程怎么在 MDK 里编译通过解析一段字符串之后怎么安全地取出每一个字段以及那些频繁出现的 HardFault、内存泄漏、浮点打印错误到底是怎么回事。适合正在做物联网设备、串口通信协议对接、或者需要在单片机里解析上位机配置参数的开发者。如果你已经在 Keil 里点过几次编译按钮这篇文章能帮你少踩一半的坑。2. cJSON 的编译与内存挂钩为什么移植第一步先改这两个地方2.1 为什么嵌入式场景选择 cJSON 而不是其他解析器嵌入式里可选的 JSON 解析器其实不少常见的有 cJSON、jsmn、json-c、以及一些针对特定平台的轻量封装。放在 STM32 场景下cJSON 之所以被用得最多因为它就两个源文件纯 C 实现不依赖操作系统、不依赖标准库以外的任何东西而且 API 非常简单。相比之下jsmn 虽然更省内存但它是“token 流式”解析只告诉你 JSON 字符串里每个字段的起始位置不帮你生成对象树取嵌套值的时候要自己维护状态代码复杂度明显更高。json-c 功能更全但那套引用计数和容器结构在资源受限的单片机上运行负担偏重很多开发者最后都放弃了。cJSON 的原理一句话就能讲清把 JSON 字符串解析成一棵由 cJSON 结构体组成的树每个节点代表一个键值对、数组、对象或原始值。你在代码里通过节点指针访问数据用完之后调用 cJSON_Delete 释放整棵树。这套模型对 STM32 来说非常匹配因为它的内存占用是显式的解析多少内容、分配多少内存开发者心里有数。另一个优势是 cJSON 不自己做内存池全部通过 malloc/free 完成这就意味着你可以在移植时把它挂到自己的内存管理方案上。2.2 在 Keil/MDK 里把 cJSON 加进工程的最小步骤把压缩包里的 cJSON.c 和 cJSON.h 放进工程其实只是第一步。最常被忽略的是编译器选项。cJSON 的源码里用了 C99 的特性比如在 for 循环里声明变量、stdint.h 里的固定宽度类型。如果你的 Keil 工程还在用默认的 C90 模式编译时会出现一堆 syntax error 或者“unknown type name ‘int32_t’”之类的报错。解决办法是在 Options for Target 的 C/C 标签页里勾选 C99 Mode同时把 cJSON.h 所在的目录加进 Include Paths。我一般会把 cJSON.c 单独放进一个 Group方便以后升级库文件也不用担心和其他文件重名。下面是一个典型的工程添加流程以 MDK 为例。bash伪代码在MDK工程中添加cJSON的步骤实际操作为IDE点击将 cJSON.c 和 cJSON.h 复制到项目目录下的 Middlewares/cJSON/在Keil中新建分组 Middlewares/cJSON右键分组 - Add Existing Files - 选择 cJSON.cOptions for Target - C/C - 勾选 C99 ModeOptions for Target - C/C - Include Paths - 添加 Middlewares/cJSON这里的关键参数是 C99 Mode 和 Include Paths。前者决定了 cJSON.c 能不能在当前编译器版本下通过语法检查后者决定了你的业务代码里能不能直接写#include cJSON.h。这两个配置缺一个后面编译都会报错。如果你用的是 STM32CubeIDE同样在项目属性的 C/C Build 里设置语言标准和头文件路径道理是一样的。实际编译后另一个常见的报错是cJSON.c里某个函数使用了snprintf而你在标准库设置里选择了精简版 microLib。这个我们放在避坑章节里细说。2.3 用 cJSON_InitHooks 替换默认内存分配器把内存掌握在自己手里cJSON 默认使用 C 标准库的 malloc 和 free。但在 STM32 上很多项目的内存管理已经交给了 FreeRTOS 的 heap 方案或者自己写的静态内存池。如果直接沿用默认 malloc可能出现两个问题一是穿过了 RTOS 的裁剪导致任务栈或堆管理之外的内存碎片难以追踪二是某些场景下中断里解析 JSONmalloc 不可重入造成不可预知的行为。这个时候就需要用到 cJSON 提供的内存钩子接口。cJSON_InitHooks 是一个很简单的全局配置函数你只需要在初始化阶段调用一次把自定义的分配和释放函数指针传进去。它的结构体定义大致如下c #include cJSON.h #include my_mem.h /* 假设你自己实现了 my_malloc / my_free */void cJSON_Memory_Init(void) { cJSON_Hooks hooks;hooks.malloc_fn my_malloc; /* 自定义分配函数 */ hooks.free_fn my_free; /* 自定义释放函数 */ cJSON_InitHooks(hooks); /* 全局生效之后解析的节点全部走这里 */}调用 cJSON_InitHooks 之后cJSON 内部所有节点、字符串副本、数组容器的内存分配都会走你提供的函数。这里有个容易忽略的点一旦你设置了自定义 hooks所有通过 cJSON_Parse 分配的树节点最终释放时也必须是 cJSON 内部的 free 逻辑也就是你传入的 my_free。所以不要在别的地方用标准 free 去释放 cJSON 返回的指针否则轻则内存泄漏重则触发断言。参数层面的建议是如果使用 FreeRTOS直接传 pvPortMalloc 和 vPortFree 即可这是最简单的方案也天然保证了线程安全。如果是裸机环境可以基于静态缓冲区实现一个简单的分配器或者直接保留默认 malloc前提是确认微库的堆大小够用。之所以强调这个步骤是因为 STM32 上“解析半天结果死机”的排错到最后多半会指向内存分配。3. 从字符串到结构体STM32 解析 JSON 的最小可运行代码3.1 解析入口cJSON_Parse 返回值不是 NULL 就代表语法正确先说结论cJSON_Parse 返回非 NULL 只说明这段字符串在语法上是合法 JSON并不代表它符合你的业务结构。很多刚上手的开发者在这时候就开始读字段读不到就怀疑库有问题其实是对 API 语义理解有偏差。cJSON_Parse 做的事情很简单输入一个以 \0 结尾的字符串尝试构建整棵 JSON 树。解析失败时返回 NULL并且会把错误原因放到 cJSON_GetErrorPtr() 指向的位置。日常开发中我习惯在解析后立刻做两层检查第一层看返回值是否为空第二层检查根节点是不是对象或数组。因为一个合法的 JSON 顶层可以是数字、字符串、对象或数组而业务上你通常只期望对象或数组。下面这段代码是标准的入口模板。c #include cJSON.h #include stdio.h #include string.hvoid parse_json_demo(const char *json_str) { cJSON *root NULL;/* 检查输入 */ if (json_str NULL) { printf(input json string is null\r\n); return; } /* 第一层语法解析 */ root cJSON_Parse(json_str); if (root NULL) { const char *err_pos cJSON_GetErrorPtr(); printf(json parse failed near: %s\r\n, err_pos ? err_pos : unknown); return; } /* 第二层根节点类型检查 */ if (!cJSON_IsObject(root)) { printf(root is not object\r\n); cJSON_Delete(root); return; } /* 业务解析... */ cJSON_Delete(root); /* 整棵树释放 */}参数层面的几个关键点cJSON_Parse 内部会复制输入字符串所以调用方可以立刻释放或复用原始 buffer错误位置的指针只在解析失败时有效不应该长期保存cJSON_Delete 是必须的哪怕你只取了一个字段也要记住 release 整个 root。很多人在这一步图省事把 cJSON_Delete 漏了跑一次两次没问题长时间运行后就看着内存一点点涨上去。3.2 稳妥地读取对象成员并校验类型解析树构建好之后读取对象成员的标准做法是用 cJSON_GetObjectItem它返回一个指向成员节点的指针。注意返回值可能是 NULL表示该键不存在即使键存在值的类型也可能和你预期不同。所以最稳妥的方式是先校验类型再取实际值。cJSON 提供了一组类型判断函数比如 cJSON_IsString、cJSON_IsNumber、cJSON_IsBool底层其实是对 item-type 的位掩码判断直接用这些宏会比手动比较 type 更清晰。下面是一个典型的读取场景假设设备需要从 JSON 里获取开关状态、设备名称和超时时间。c void handle_device_config(const char *json_str) { cJSON *root NULL; cJSON *item NULL;root cJSON_Parse(json_str); if (root NULL) { printf(parse error\r\n); return; } /* 读取字符串字段 */ item cJSON_GetObjectItem(root, device_name); if (cJSON_IsString(item) (item-valuestring ! NULL)) { printf(device_name %s\r\n, item-valuestring); } else { printf(device_name missing or not string\r\n); } /* 读取布尔字段 */ item cJSON_GetObjectItem(root, switch_on); if (cJSON_IsBool(item)) { printf(switch_on %d\r\n, item-valueint); } /* 读取数字字段 */ item cJSON_GetObjectItem(root, timeout_ms); if (cJSON_IsNumber(item)) { printf(timeout_ms %d\r\n, item-valueint); }}这段代码里最核心的习惯是对每个 item 先判断类型再使用。cJSON 的数字类型同时保存了 valueint 和 valuedouble 两份值整数型 JSON 数字用 valueint 读取没问题但如果你从云端收到的是 3.14那 valueint 会被截断成 3。所以数字字段建议先确认业务语义再选择合适的字段读取。字符串字段也同理valuestring 可能在异常数据下为 NULL取值前做一次空指针校验几乎零成本。 ### 3.3 数组和嵌套对象的遍历套路 对象之外数组是 JSON 里第二常见的结构。cJSON 的数组本质是一个双向链表通过 cJSON_GetArraySize 获取元素个数用 cJSON_GetArrayItem 按下标访问节点这一点和大多数高级语言不一样你得习惯“手动下标”的感觉。下面是遍历一个对象数组并解析每个元素的示例。 c /* JSON示例: [ {name:sensor1,value:11}, {name:sensor2,value:22} ] */ void parse_sensor_array(const char *json_str) { cJSON *root NULL; cJSON *item NULL; int size 0; int i 0; root cJSON_Parse(json_str); if (root NULL) { return; } if (!cJSON_IsArray(root)) { printf(expected array\r\n); cJSON_Delete(root); return; } size cJSON_GetArraySize(root); for (i 0; i size; i) { cJSON *obj cJSON_GetArrayItem(root, i); cJSON *name_item cJSON_GetObjectItem(obj, name); cJSON *value_item cJSON_GetObjectItem(obj, value); if (cJSON_IsString(name_item)) { printf([%d] name %s\r\n, i, name_item-valuestring); } if (cJSON_IsNumber(value_item)) { printf([%d] value %d\r\n, i, value_item-valueint); } } cJSON_Delete(root); }嵌套对象的访问方式和普通对象一样只是需要逐层拿到中间节点。比如{info:{addr:xx}}你得先用 cJSON_GetObjectItem(root, info) 拿到 info 节点再用 cJSON_GetObjectItem(info, addr) 拿最终值。这里有个常见误用有人拿中间的 NULL 指针继续调用 cJSON_GetObjectItem导致崩溃。标准做法是每一层都做空指针检查或者封装一个小函数把取路径封装起来。4. 输出与序列化让 STM32 把 JSON 发回去的细节4.1 PrintUnformatted 与 Print串口、网络、文件场景里的选择解析之外设备上报数据时往往也需要生成 JSON 字符串。cJSON 提供了两个输出接口cJSON_Print 输出带格式化的字符串有换行和缩进适合人眼调试cJSON_PrintUnformatted 输出紧凑字符串没有多余空白适合网络传输和串口传输。两者的本质区别只在于是否格式化内部都返回一个 malloc 出来的 char* 缓冲区。在单片机上格式化的换行符和空格会占掉相当一部分 RAM所以只要不是调试需求一律用 PrintUnformatted。生成 JSON 的标准套路是自底向上构建树先创建叶子节点再创建数组或对象最后用 cJSON_PrintUnformatted 输出。需要注意的是每个创建函数都可能返回 NULL在内存紧张的单片机上尤其要检查。下面是一个完整的示例。c void build_and_send_report(void) { cJSON *root NULL; cJSON *data_array NULL; char *out NULL;root cJSON_CreateObject(); if (root NULL) { return; } /* 添加普通字段 */ cJSON_AddStringToObject(root, device_id, stm32-001); cJSON_AddNumberToObject(root, temperature, 26.5); /* 添加数组 */ data_array cJSON_CreateArray(); cJSON_AddItemToObject(root, data, data_array); cJSON *item cJSON_CreateObject(); cJSON_AddNumberToObject(item, chn, 1); cJSON_AddNumberToObject(item, value, 12); cJSON_AddItemToArray(data_array, item); /* 输出为紧凑字符串 */ out cJSON_PrintUnformatted(root); if (out ! NULL) { printf(json: %s\r\n, out); cJSON_free(out); /* 必须用cJSON_free不能用标准free */ } /* 释放整棵树 */ cJSON_Delete(root);}这个例子里最容易忽略的是 out 的释放方式。cJSON_PrintUnformatted 返回的缓冲区是通过 cJSON 内部的内存分配函数申请的如果你在前面调用了 cJSON_InitHooks 替换了 malloc那么这里也必须调用 cJSON_free 来释放否则跨内存分配器释放会导致不可预知的错误。即使你没替换 hookscJSON_free 定义成宏也等价于标准 free统一用它总没错。 ### 4.2 输出缓冲区的生命周期与重复打印问题 另一个常见场景是循环上报设备每隔一段时间生成一次 JSON 并发送。很多人图省事把 cJSON_Parse 和 cJSON_Delete 的调用混在一起结果一个周期里反复申请释放内存碎片越积越多。规范化做法是在一个上报函数里完成“创建 - 打印 - 释放”保证每次进入函数时内存状态是一致的。不要在中断回调里调用 cJSON_PrintUnformatted因为它内部会执行 malloc中断里做动态内存分配风险很高可能阻塞。 如果你要在多个地方复用同一个 root 节点千万别在中间 cJSON_Delete(root) 之后再继续使用之前的 item 指针。cJSON_Delete 是深度释放整棵树都会失效。还想继续构建就得重新创建。 ### 4.3 常见的错误写法与正确写法对照 很多刚上手的朋友会把 cJSON_free 写成 free有些例程为了简化也这么做。短小 demo 能跑通但一旦你换了内存分配策略或引入 RTOS就会踩到“释放了不归自己管的内存”这种最隐蔽的坑。另一个常见错误是忘掉 cJSON_Delete任务函数不停地解析、返回却从不释放树最后堆耗尽。别忘了你虽然只打印了一个字段但整棵树都被解析到内存里了。 还有个值得注意的误用在创建对象时用 cJSON_AddItemToObject 添加了一个 item 之后如果后面又把这个 item 手动释放了会导致树里出现野指针。正确的做法是全部交给树来管理cJSON_Delete 会递归释放所有子节点。 ## 5. STM32 使用 cJSON 的避坑排查清单4 个高频翻车点 ### 5.1 现象JSON 解析成功但用 cJSON_GetObjectItem 取到的值是 NULL 这是最常见的问题。代码能打印出解析后的字符串说明 cJSON_Parse 返回了非 NULL但业务字段就是取不到。原因一般有三个键名拼写不一致比如 JSON 里是 deviceName 而代码里写 device_name键存在但你取它的对象层级不对比如从 root 直接取嵌套子对象里的字段第三是取到的 item 本身类型不对你拿的是数组节点却当对象使。 解决方式也很直接先打印 cJSON_PrintUnformatted(root) 出来核对实际键名再用 cJSON_GetObjectItem 一层一层往下取。不要怕麻烦层级多就一步一步拿中间节点并打印它的 valuestring 和 type基本一眼就能定位问题。 ### 5.2 现象解析稍复杂的 JSON 字符串后程序直接进入 HardFault HardFault 在 STM32 上出现绝大多数跟内存和栈有关。cJSON_Parse 是递归下降解析嵌套很深的 JSON 会在解析过程中占用大量栈空间而默认的启动文件里栈大小通常只有 1KB 左右超出即发生栈溢出。另一个原因是某个节点在创建时内存分配失败返回 NULL后续代码没做判空就直接解引用。 解决思路分两步。第一把启动文件里的 Stack_Size 改大到至少 2KB 或 4KB如果任务里用到了 FreeRTOS则应为解析任务单独分配更大的任务栈建议 2KB 起步。第二遍历创建节点时每一个 cJSON_CreateXxx 的返回值都要检查。宁可多写几行判空也别让一次 OOM 把你送进 HardFault。另外注意不要在中断回调里做解析哪怕栈改大了中断环境依然不稳定。 ### 5.3 现象程序运行越久内存占用越高最终死机 这种“缓慢泄漏”问题几乎都出在 cJSON_Delete 没有被正确调用。常见漏网之鱼包括cJSON_Parse 成功但提前 return 的路径上没有 DeletecJSON_PrintUnformatted 的返回值没有释放还有用 cJSON_DetachItemFromObject 把某个子节点摘下来之后忘了处理摘下来的节点。最坑的是很多库里把 Detach 和 Delete 混为一谈摘下来的节点如果不手动 Delete就永远游离在树外。 排查手段其实很简单。在连续解析 100 次、1000 次之后打印当前剩余堆大小看是否有单调下降趋势。定位到具体函数后检查所有提前 return 的分支是否都补了释放操作。一个经验是把所有 cJSON_Parse 和 cJSON_Delete 成对写中间只允许通过 goto 或统一出口来 return这样可以最大限度避免漏删。 ### 5.4 现象JSON 里的数字带小数解析之后用 printf 打印变成 0.00 或者乱码 这个问题的根往往不在 cJSON而在你使用了微库 microLib 时默认关闭了浮点 printf 支持。如果工程勾选了 Use MicroLIBprintf 里遇到 %f 经常输出不了正确内容。处理方式有三一是在 Keil 中取消勾选 Use MicroLIB但会额外占用几十 KB 的代码空间二是保留 MicroLIB但用 %d 和 %d.%02d 自己拆分整数和小数部分三是直接通过 cJSON 的 valuedouble 字段拿到原始浮点值然后用编译器支持的定点打印方式输出。 如果你确实需要浮点打印建议在开发早期就在一个小测试里验证 printf(%f, 1.34) 是否能正常输出别等联调时才抓狂。cJSON 本身对浮点数的存储用的是 double取值时用 item-valuedouble 就不会丢失精度问题基本都出在输出环节。 ## 6. 让 cJSON 解析更稳的一个习惯把“类型校验”做成统一的入口 用 cJSON 时间长了会发现90% 的解析崩溃都发生在“取了一个值为 NULL 的 item然后直接访问它的 valuestring”这一行。写一个统一的安全取值助手能让代码健壮很多。我一般会在工程里维护一组小函数专门做“取字符串、取整数、取浮点”的通用入口内部自动判空和类型校验。 c /* safer_get.c */ #include cJSON.h #include string.h int json_get_string(const cJSON *parent, const char *key, char *out, int max_len) { const cJSON *item NULL; if (parent NULL || key NULL || out NULL) { return -1; } item cJSON_GetObjectItem(parent, key); if (!cJSON_IsString(item) || item-valuestring NULL) { return -1; } strncpy(out, item-valuestring, max_len - 1); out[max_len - 1] \0; return 0; } int json_get_int(const cJSON *parent, const char *key, int *out) { const cJSON *item NULL; if (parent NULL || key NULL || out NULL) { return -1; } item cJSON_GetObjectItem(parent, key); if (!cJSON_IsNumber(item)) { return -1; } *out item-valueint; return 0; }调用这些封装函数后业务代码只关心返回值是不是 0而不用每次都写一遍“判空 判类型 取值”。在此基础上我还会强制要求解析函数只允许有一个出口所有 cJSON_Parse 和 cJSON_Delete 在同一个作用域内成对出现。这算是一个成本很低但收益极大的编码规范。回想我自己调试过的一个通信协议解析模块几十个字段的 JSON 结构最初总在个别字段缺失时死机后来全部切换到这种统一读取入口后问题彻底消失。希望你也能在动手之前先把这些规矩立起来祝你的 STM32 解析之路少踩几个坑一次跑通。本文还有配套的精品资源点击获取