STM32上使用cJSON实现轻量级JSON通信:从移植到稳定运行的完整指南

发布时间:2026/9/29 23:47:07
STM32上使用cJSON实现轻量级JSON通信:从移植到稳定运行的完整指南 去年做环境监测节点MCU端是STM32F407上位机用Python写中间还过了个4G模组。前期偷懒自定义了一套二进制协议——帧头、长度、类型、payload、CRC字段位置全靠文档约定。前两个月联调还算太平等需求迭代到第三版传感器从3种加到9种payload里又要塞GPS坐标和信号强度协议解析函数越写越长上位机那边也开始动不动就报长度错误CRC不匹配。最后整个协议层推倒重来改用JSONSTM32端上的cJSON库。我花了一个下午把通信模块重构完后续联调顺畅得意外。这篇记录一下我在STM32上使用cJSON库实现轻量级JSON通信的完整思路和可直接抄的代码。1. 在MCU上跑JSON这事到底值不值入行头几年我对JSON上MCU是抵触的。一个int占4个字节JSON里一个temp:25.6就要十几个字节串口115200波特率下多传几个字节肉眼可见地增加时延。直到被自定义协议坑过几次才慢慢明白一个道理绝大多数嵌入式项目瓶颈并不是那几十个字节的带宽而是联调阶段的沟通成本。1.1 自定义协议的沟通成本往往被低估自定义二进制协议最大的问题是隐式约束。结构体里字段A是u8还是u16字段B要不要字节对齐传感器不上报时是填0还是置FF这些信息只存在于协议文档里。文档更新滞后于代码是常态联调时两边对着不匹配的字段定义找半天的日子做过的都懂。JSON的好处是自描述。字段名就是键类型就是值本身的类型解析端拿到数据就能看懂结构。尤其对接第三方平台或App端时JSON几乎是事实标准。哪怕通信量多30%换取的是再也不用为了一个字段的字节序打电话对齐。1.2 为什么是cJSON不是别的库嵌入式领域JSON解析库不少我用下来cJSON是最省心的一个特性cJSON其他常见方案源码规模cJSON.c cJSON.h单文件共约2000行有些库动辄上万行C标准支持C89老编译器也能编译部分要求C99或更高依赖无任何外部依赖最多改一下malloc有的依赖底层OS接口许可证MIT商用无压力部分库是GPL商用要谨慎维护状态长期活跃社区广泛有的已停更多年还有一个隐性优势cJSON足够底层代码逻辑清晰出问题时可以直接从源码层面追。1.3 什么场景该用什么场景该绕开也不是所有STM32项目都适合上JSON。我自己的判断标准是这样适合的场景需要和PC上位机、手机App、云平台交互通信频率不高比如1秒1次心跳或事件上报MCU资源比较充裕至少三五十KB RAMF103级别以上其实也够还是那句话联调对象是人而不是固定库的时候不建议硬上的场景8KB RAM以下的超小资源MCU通信帧率极高比如音频流、高频传感采样纯粹的点对点内部通信双方都是嵌入式设备协议终身不会变提示如果只是传感器内部互通我用过MessagePack序列化后体积接近二进制又有JSON的灵活性。但话说回来除非你的包体真有几百字节以上的诉求否则JSON多出来的那点开销在工程上往往是可接受的。2. cJSON移植到STM32工程的三步走从GitHub拉源码到在STM32工程里能跑通cJSON不走弯路的话十分钟之内能搞定。2.1 源码获取与文件组织结构从GitHub拉取 cJSON仓库 核心只需要两个文件cJSON.ccJSON.h如果还要用JSON Patch或Merge Patch功能才需要额外加入cJSON_Utils.c和cJSON_Utils.h。绝大多数STM32场景用不到我不建议加能少一个依赖就少一个。在工程里新建一个ThirdParty/cJSON目录把这俩文件拷进去然后在Keil MDK的Manage Project Items里添加cJSON.c到对应的group头文件路径加到C/C Include Paths里。2.2 标准库/HAL库工程的配置差异我最早是用标准库后来转到HAL库cJSON的移植在这两种环境下没有任何区别。唯一需要确认的是编译器是否支持C99或以上标准。Keil MDK的话在Options for Target C/C里勾选C99 Mode。GCC环境下默认就行。如果你的工程用了microlib注意一点microlib下的malloc实现非常精简堆空间默认很小建议在启动文件里把Heap大小至少调到4KB以上或者直接用后面第2.3节的方法替换内存分配函数。2.3 内存钩子不用默认malloc也行cJSON默认使用malloc/free。但STM32工程里很多情况需要对内存做特殊处理用了FreeRTOS等RTOS希望内存统一从堆栈池分配裸机工程担心堆碎片化想静态分配默认malloc不够用或不确定够不够用cJSON提供了cJSON_InitHooks接口可以在初始化时替换内存函数#include cJSON.h #include my_memory_manager.h static void *cjson_malloc(size_t size) { return my_pool_malloc(size); } static void cjson_free(void *ptr) { my_pool_free(ptr); } void cjson_init(void) { cJSON_Hooks hooks { .malloc_fn cjson_malloc, .free_fn cjson_free }; cJSON_InitHooks(hooks); }注意要在程序启动阶段、任何cJSON API被调用之前就执行cJSON_InitHooks。换内存钩子不是线程安全的运行中途调用会出现意想不到的问题。我在一个F103C8T620KB RAM的项目里就是用自定义内存池管理cJSON的内存开销跑了一整年没有出现过内存问题。3. 序列化实战把传感器数据组装成JSON发出去组装JSON是使用频率最高的操作。下面从最简单的对象开始一步步到嵌套和数组。3.1 基础对象的创建与数据填充cJSON创建JSON的API命名很简单基本看一眼就能记住#include cJSON.h void build_sensor_json(void) { // 创建根对象 cJSON *root cJSON_CreateObject(); if (root NULL) return; // 添加字符串字段 cJSON_AddStringToObject(root, device_id, STM32_001); // 添加整数字段 cJSON_AddNumberToObject(root, battery, 87); // 添加浮点字段 cJSON_AddNumberToObject(root, temperature, 25.6); // 输出成无格式JSON字符串 char *json_str cJSON_PrintUnformatted(root); if (json_str ! NULL) { // 用完之后释放 cJSON_free(json_str); } // 释放根对象所有子对象一并释放 cJSON_Delete(root); }这段代码覆盖了最基本的使用流程创建对象 - 添加字段 - 打印字符串 - 释放字符串 - 释放对象树。注意cJSON_AddNumberToObject对整数和浮点都是同一个函数内部统一存成double类型。3.2 嵌套对象与数组一条消息带上完整状态实际项目中很少只发几个扁平的字段更多是需要嵌套结构和数组。比如上报一条设备状态cJSON *root cJSON_CreateObject(); // 基础信息 cJSON_AddStringToObject(root, device_id, GW-01); cJSON_AddNumberToObject(root, firmware_ver, 103); // 1.03 // 经纬度用一个子对象 cJSON *loc cJSON_CreateObject(); cJSON_AddNumberToObject(loc, lat, 31.2304); cJSON_AddNumberToObject(loc, lng, 121.4737); cJSON_AddItemToObject(root, location, loc); // 传感器数组比如三个温度传感器 cJSON *sensors cJSON_CreateArray(); for (int i 0; i 3; i) { cJSON *sensor cJSON_CreateObject(); cJSON_AddNumberToObject(sensor, ch, i); cJSON_AddNumberToObject(sensor, temp, 20.0 i * 0.5); cJSON_AddItemToArray(sensors, sensor); } cJSON_AddItemToObject(root, sensors, sensors); char *out cJSON_PrintUnformatted(root); printf(%s\r\n, out); cJSON_free(out); cJSON_Delete(root);输出{device_id:GW-01,firmware_ver:103,location:{lat:31.2304,lng:121.4737},sensors:[{ch:0,temp:20.0},{ch:1,temp:20.5},{ch:2,temp:21.0}]}嵌套用cJSON_AddItemToObject数组用cJSON_AddItemToArray逻辑非常直观。3.3 打印与内存释放PrintUnformatted和Print的区别cJSON提供了两个打印函数cJSON_Print带格式化的可读输出有换行和缩进体积大且打印速度慢cJSON_PrintUnformatted无空白压缩输出MCU上序列化几乎都用这个格式化的可读输出在PC调试时很有用在串口助手看也方便但生产环境别用。除此之外还有cJSON_PrintBuffered可以把输出写到指定缓冲区避免再用malloc分一块内存。一个重要的使用习惯打印返回的字符串用完必须用cJSON_free释放。我看到过不少初学者用free()释放两边内存钩子不一致时会直接崩溃。3.4 完整封装一个可复用的数据装配函数实际项目里我会把构建JSON - 打印 - 拷入发送缓冲区 - 释放封装成一个统一的函数/** * brief 组装传感器上报的JSON数据 * param buf 输出缓冲区 * param bufsize 缓冲区大小 * return 实际使用的字节数失败返回 -1 */ int build_sensor_report(char *buf, int bufsize, float temp, float humidity, int battery) { cJSON *root cJSON_CreateObject(); if (root NULL) return -1; cJSON *data cJSON_CreateObject(); if (data NULL) { cJSON_Delete(root); return -1; } cJSON_AddStringToObject(root, type, report); cJSON_AddNumberToObject(root, ts, get_timestamp()); cJSON_AddItemToObject(root, data, data); cJSON_AddNumberToObject(data, temp, temp); cJSON_AddNumberToObject(data, humidity, humidity); cJSON_AddNumberToObject(data, battery, battery); char *json_str cJSON_PrintUnformatted(root); cJSON_Delete(root); // 打印完就可以释放对象树 if (json_str NULL) return -1; int len strlen(json_str); if (len bufsize) { cJSON_free(json_str); return -1; // 缓冲区不够大调用方自己处理 } strcpy(buf, json_str); cJSON_free(json_str); return len; }这种封装的好处是上层只关心给我一个可发送的字符串cJSON的细节完全隔离在函数内部。4. 解析实战把下发的控制指令接进来如果说序列化是写那么解析就是读。解析比序列化更容易踩坑因为外部输入的JSON结构完全不可控任何字段缺失或类型不符都可能导致异常。4.1 cJSON_Parse的返回值与常见解析失败cJSON *root cJSON_Parse(json_str); if (root NULL) { // 解析失败 const char *err cJSON_GetErrorPtr(); // err 指向出错位置附近的内容可用来日志定位 return -1; }解析失败的最常见原因JSON语法错误比如多了个逗号、少了引号字符串截断串口收了一半就被拿去解析编码问题比如包含非UTF-8字节cJSON_GetErrorPtr()可以拿到错误位置附近的文本对排查很有帮助。但注意它只是附近的位置不是精确的行号列号。4.2 安全读取字段类型判断不能省这是我见到最多的问题。很多人写完cJSON_GetObjectItem后直接取valueint或valuestring发现解析出来的东西不对头或者干脆hardfault。cJSON *item cJSON_GetObjectItem(root, led); if (NULL item) { // 字段不存在做缺省处理 } else if (cJSON_IsNumber(item)) { // 字段存在且是数字类型才安全 int led_state item-valueint; }cJSON 1.7.x以后提供了cJSON_IsNumber、cJSON_IsString、cJSON_IsArray等一系列类型判断宏。凡是拿字段值之前先做存在性和类型判断这是血泪教训换来的经验。尤其对接第三方下发数据时对方哪天在字段里放了个字符串true而不是布尔true你没检查就可能做出错误动作。4.3 结构化解析一条完整指令的完整解析流程假设云端下发的控制指令长这样{ cmd: set_pwm, params: { freq: 1000, duty: 0.35, channel: 2 }, nonce: a1b2c3 }对应解析代码typedef struct { char cmd[16]; int channel; float freq; float duty; char nonce[16]; } PwmCommand; int parse_pwm_command(const char *json_str, PwmCommand *cmd) { if (NULL json_str || NULL cmd) return -1; cJSON *root cJSON_Parse(json_str); if (root NULL) return -1; // 解析cmd cJSON *item cJSON_GetObjectItem(root, cmd); if (cJSON_IsString(item)) { strncpy(cmd-cmd, item-valuestring, sizeof(cmd-cmd) - 1); cmd-cmd[sizeof(cmd-cmd) - 1] \0; } else { cJSON_Delete(root); return -1; } // 解析params子对象 cJSON *params cJSON_GetObjectItem(root, params); if (cJSON_IsObject(params)) { item cJSON_GetObjectItem(params, channel); if (cJSON_IsNumber(item)) { cmd-channel item-valueint; } item cJSON_GetObjectItem(params, freq); if (cJSON_IsNumber(item)) { cmd-freq (float)item-valuedouble; } item cJSON_GetObjectItem(params, duty); if (cJSON_IsNumber(item)) { float duty (float)item-valuedouble; // 范围检查 if (duty 0.0f duty 1.0f) { cmd-duty duty; } } } // 可选的nonce字段 item cJSON_GetObjectItem(root, nonce); if (cJSON_IsString(item)) { strncpy(cmd-nonce, item-valuestring, sizeof(cmd-nonce) - 1); cmd-nonce[sizeof(cmd-nonce) - 1] \0; } cJSON_Delete(root); return 0; }这里我的习惯是每个字段都独立做存在性判断缺哪个字段就返回错误或者使用默认值而不是在开头一刀切。因为IoT场景下云平台下发指令偶尔缺个非关键字段很常见容错能力很重要。4.4 解析完必须Delete以及cJSON_ArrayForEach遍历cJSON_Parse创建的对象树解析完成后必须用cJSON_Delete释放否则就是内存泄漏。如果涉及数组遍历cJSON提供了遍历宏cJSON *array cJSON_GetObjectItem(root, records); if (cJSON_IsArray(array)) { cJSON *record NULL; cJSON_ArrayForEach(record, array) { int idx cJSON_GetObjectItemCaseSensitive(record, index)-valueint; // 处理每条记录 } }cJSON_ArrayForEach是一个for循环宏遍历过程中不要修改数组结构否则行为未定义。关于大小写cJSON默认的cJSON_GetObjectItem对键名大小写不敏感但cJSON_GetObjectItemCaseSensitive是大小写敏感版本。机器对机器通信的场景推荐用CaseSensitive版本语义更明确还能避免一些隐晦的问题。5. 通信链路的配合串口、WiFi模块、MQTTJSON本身只解决数据怎么组织不解决数据怎么传输。实际项目中JSON通常跑在各种物理链路上。5.1 串口与JSON帧边界怎么划串口是流式的没有天然的消息边界。直接用串口发JSON时需要自己界定一条消息的起止。几种常见做法方案思路优缺点换行符结尾每条JSON以\r\n结尾最简单但JSON字符串内部如果含换行会冲突需要转义长度前缀帧头长度JSON格式最可靠接收方先收长度再收正文特殊帧头结尾0x7E开头0x7F结尾需要处理内容冲突我用得最多的是长度前缀方式typedef struct { uint8_t head; // 0x5A uint8_t type; // 0x01表示JSON uint16_t len; // 大端 // 后跟len字节的JSON数据 } FrameHeader;接收端状态机typedef enum { WAIT_HEAD, WAIT_TYPE, WAIT_LEN_H, WAIT_LEN_L, WAIT_BODY } RxState; void uart_rx_byte(uint8_t byte) { static RxState state WAIT_HEAD; static uint16_t body_len 0; static uint16_t body_cnt 0; static uint8_t json_buf[512]; switch (state) { case WAIT_HEAD: if (byte 0x5A) state WAIT_TYPE; break; case WAIT_TYPE: if (byte 0x01) state WAIT_LEN_H; else state WAIT_HEAD; break; case WAIT_LEN_H: body_len (uint16_t)byte 8; state WAIT_LEN_L; break; case WAIT_LEN_L: body_len | byte; if (body_len sizeof(json_buf)) { state WAIT_HEAD; // 超长丢弃 } else { body_cnt 0; state WAIT_BODY; } break; case WAIT_BODY: json_buf[body_cnt] byte; if (body_cnt body_len) { // 收完一帧交给解析函数 handle_json_message(json_buf, body_len); state WAIT_HEAD; } break; } }5.2 WiFi/4G模块场景AT指令里嵌套JSON用ESP8266、EC200这类模块时JSON往往嵌套在AT指令里比如ATMQTTPUBtopic/data,{\temp\:25.6,\humidity\:60},0,0这里有个容易忽略的问题JSON字符串里的引号在AT指令格式里会被解析器吞掉必须用反斜杠转义。我的做法是先组装JSON再手动在发送时处理转义// 构造JSON后将 替换为 \ void send_mqtt_json(const char *topic, const char *json_str) { char buf[600]; int pos 0; pos sprintf(buf pos, ATMQTTPUB\%s\,\, topic); for (const char *p json_str; *p ! \0; p) { if (*p || *p \\) { buf[pos] \\; } buf[pos] *p; } buf[pos] ; }这种细节在联调前最好提前考虑到否则AT指令解析会一直报错。5.3 MQTT场景JSON是事实标准接入云平台时JSON几乎是负载的默认选择。几乎所有主流IoT平台阿里云IoT、OneNET、腾讯云等的设备端数据模板都是JSON格式。这类平台的设备端SDK通常已经把MQTT和JSON模板封装好了但了解底层机制仍然有用——当平台下发的数据包结构变得复杂时你不能依赖黑盒还是要自己掌握cJSON的解析能力。6. 稳定性在MCU上长期跑cJSON这些坑必须填跑通demo容易稳定运行半年不重启才是真正考验。6.1 内存碎片与长时间运行cJSON频繁创建/释放对象默认的malloc在MCU这种小堆环境下容易产生碎片。典型症状刚上电一切正常运行几天后cJSON_Parse返回NULLcJSON_PrintUnformatted返回NULL系统循环重启。解决办法有两个方向方向一用更可靠的内存分配器。如果你用的FreeRTOS可以在cJSON_InitHooks里接入pvPortMalloc/vPortFree。FreeRTOS的heap4实现了简单的合并机制碎片问题比newlib的malloc好一些。方向二限制JSON报文长度和频率。即使不换分配器只要每次分配的块大小相近碎片化就会减缓。建议接收缓冲区固定大小比如1024字节封顶创建对象时复用同一个根对象而不是每次都从零创建发送完JSON立即释放不要保存指针6.2 栈大小与中断里调用的问题cJSON解析时递归遍历嵌套结构会消耗栈空间。在裸机环境下还好但用RTOS时如果任务栈开得比较小调用cJSON_Parse深层嵌套的JSON可能导致栈溢出表现就是跑飞或者HardFault。我的经验FreeRTOS任务栈至少给2KB以上默认512字节肯定不够绝不在中断服务函数里调用cJSON_Parse。中断栈通常很小而且锁中断会让整个系统的实时性崩掉。正确做法是中断里只收数据辅助标志位在任务上下文里做JSON解析。6.3 嵌套深度限制与大型JSONcJSON有默认的嵌套深度限制宏CJSON_NESTING_LIMIT默认值是1000。这个值对嵌入式来说太大了——意味着递归深度上限太大风险也大。我在项目里会把它改小// cJSON.h 中修改 #define CJSON_NESTING_LIMIT 3232层的嵌套深度对IoT设备数据包来说完全足够。这样即使收到恶意构造的深层嵌套JSON递归栈消耗也有限不会栈溢出。6.4 浮点精度、字符串转义、中文编码这几个属于边角问题但遇到了也很折腾。浮点精度cJSON默认用%.17g1.7.x版本格式化浮点保证能无损还原double。但输出长度可能比预期长。嵌入式场景里温度24.5这种精度我通常会自己格式化成字符串再添加char temp_str[16]; snprintf(temp_str, sizeof(temp_str), %.2f, temp); cJSON_AddStringToObject(root, temp, temp_str);这样输出的就是24.50而不是24.5xxxxxxxx。字符串转义JSON标准要求\n、、\\等字符必须转义。cJSON在打印时自动做了但如果你自己拼接字符串再塞进去要手动转义。中文编码cJSON要求输入是UTF-8编码。STM32的Keil默认编码可能是GB2312直接用中文字符串字面量会产生编码问题。我的习惯是设备端全部用英文键名和ASCII字符串中文只出现在UI层。6.5 在PC上先验证逻辑再移植最后分享一个提高效率的习惯cJSON是纯C库不依赖任何硬件接口所以完全可以在PC上用CMake或VS直接调试。# CMakeLists.txt 最小示例 cmake_minimum_required(VERSION 3.10) project(cjson_test) add_executable(test_cjson test_cjson.c ${CMAKE_CURRENT_SOURCE_DIR}/cJSON.c ) target_include_directories(test_cjson PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} )在PC上写好测试用例验证JSON拼装和解析逻辑都正确之后再交叉编译到STM32。这样能绕开嵌入式调试器效率低的问题把精力集中在逻辑上。最后再分享一个我在多个项目里用下来比较顺手的调试技巧在STM32的串口调试助手里接TTL转USB模块用printf把json_str直接打印出来看——但生产环境里要把printf关掉或改成日志模块否则字符串格式化本身会消耗不少CPU时间和栈空间。另外一个建议如果产品有OTA升级功能首次升级时尽量保留旧版本固件的远程回滚入口。JSON报文结构一旦定下来后续扩展只加字段、不删不改老版本和新版本就能共存。我见过因为更换了JSON字段名导致云端下发升级指令老版本解析失败而无法升级的惨案这个坑值得记一笔。