ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32上使用cJSON实现JSON通信实战指南

STM32上使用cJSON实现JSON通信实战指南 1. 项目概述为什么要在STM32上折腾JSON通信直接说结论在STM32这类资源受限的MCU上cJSON是处理结构化数据通信最省心的方案之一。我最早接触这个需求是在做一个环境监测节点传感器数据、设备状态、上位机指令全都要通过串口和Wi-Fi模块上报云端。一开始图省事自己定义了一堆帧头长度数据体校验的二进制协议结果后期加字段、改类型、对接测试每次都要两边同时改代码那个酸爽至今记忆犹新。后来换了JSON做应用层协议上位机和云端用现成的解析库固件这边用cJSON开发效率直接翻倍。cJSON是一个纯C语言实现的JSON解析库全部源码加起来也就两个文件核心代码不到2000行。它做的事情很简单把JSON文本解析成一棵可操作的对象树或者反向把C语言里的结构体数据序列化成JSON字符串。在STM32F103这种主频72MHz、RAM只有20KB的芯片上跑起来毫无压力一套基础解析流程内存开销在几百个字节到几KB之间完全在可接受范围内。这篇文章会从零开始把这个库移植到STM32工程里拆解核心API的用法然后给一个完整的串口JSON通信实战案例最后把我实际开发中踩过的坑和排查方法也一并交代清楚。适合正在做物联网网关、设备接入、协议转换、上位机联调这些工作的嵌入式开发者也适合刚接触JSON的小白照着抄作业。先说好抄作业可以但每段代码你都得知道它在干什么。2. 核心难点拆解手动拼包有多痛cJSON就帮你补多少2.1 手动字符串拼接的坑你没踩过是真的幸运单片机上的传统通信格式大多是自定义的字节流类似这样uint8_t frame[64]; frame[0] 0xAA; frame[1] 0x55; frame[2] 0x01; // 命令字 frame[3] (uint8_t)(temp 8); frame[4] (uint8_t)(temp 0xFF); // ... 后面还得算校验和这种方案有三个致命问题。字段顺序绑死协议版本。你要加一个字段上位机那边也得跟着改偏移量。如果设备已经部署到现场新旧版本固件混跑上位机兼容代码会写得越来越乱。类型表达能力弱。温湿度这种浮点数据你得自己定标、转换、反转换一个小数点错位就是几度的偏差。调试不直观。串口抓回来的数据是一堆十六进制字节对着协议表格人工解析一次两次还行数据多了谁看谁崩溃。JSON就是来收拾这个烂摊子的。它本身就是字符串工程师能直接读懂它自带类型系统整数、浮点、字符串、布尔值不用你操心它的字段是名值对加字段不会影响老字段的解析。唯一的问题是裸的C语言并没有内置JSON处理能力。你当然可以自己写字符串处理函数去抠字段但真写起来你会发现转义字符、嵌套对象、数组遍历、错误处理每一层都是工作量。这就是cJSON存在的意义。2.2 cJSON的设计理念与嵌入式适配性cJSON的设计哲学就四个字够用就行。它不追求完整的RFC 8259规范覆盖也不打算处理超大JSON文档但常规的解析、创建、查询、修改、格式化输出全都给你安排得明明白白。它的核心数据结构特别直白一个双向链表节点代表一个JSON值typedef struct cJSON { struct cJSON *next; struct cJSON *prev; struct cJSON *child; int type; // 类型标志对象、数组、字符串、数字等 char *valuestring; // 字符串类型的值 int valueint; // 整数类型的值 double valuedouble; // 浮点类型的值 char *string; // 字段名如果是对象成员 } cJSON;理解了这个结构体你就理解了整个库。一个JSON对象在内存里就是一棵树child指向第一个子节点next和prev把同级节点串成双向链表type告诉你当前节点存的是哪种类型的数据。解析就是把字符串变成这棵树创建就是反向把这棵树搭出来输出就是把这棵树的字符串形式拼回去。2.3 选型对比cJSON在STM32上的优势边界有人可能会问JSON库那么多RapidJSON、jsmn、parson为什么偏偏推荐cJSON我自己的选型标准是这样的候选库内存占用代码量易用性适合场景RapidJSON较大依赖C特性很大API复杂需模板功底服务端、高性能解析jsmn极小不到200行只做tokenize拿到的是一堆标记还得自己处理RAM极小的8位MCUparson较小中等接口友好但社区活跃度一般轻量级场合cJSON小不到2000行API直观资料多MIT协议STM32这类32位MCU首选从实际体验来说cJSON资料最丰富遇到问题搜索一下基本都有答案。而且它的API设计比较符合直觉cJSON_Parse解析字符串cJSON_GetObjectItem取字段cJSON_CreateObject建对象。只要会用链表上手就很自然。不过有一点要泼个冷水cJSON虽然轻量但它不是零拷贝解析会把整个JSON文档拆成一块一块的内存节点。一个包含几十个字段的JSON报文解析下来动态内存开销大概是原始文本大小的5到10倍。所以如果你的MCU是1KB RAM的8051那还是老老实实用jsmn或者自研协议。但在STM32上这根本不是事。3. 环境准备把cJSON搬进你的STM32工程3.1 源码获取与文件结构cJSON官方仓库在GitHub上这个名字直接搜就能找到。我们只需要两个文件cJSON.c cJSON.h在有些版本里还会看到cJSON_Utils.c和cJSON_Utils.h那是用来做JSON合并、修改、JSON Pointer查询的扩展工具基础通信场景用不上不用管它。把cJSON.c和cJSON.h直接拷贝到你的工程源码目录比如放在Middleware/cJSON下然后在Keil MDK或STM32CubeIDE里把这两个文件加进工程配置好头文件路径就行。3.2 Keil MDK下的添加步骤很多人卡在第一步其实不是代码问题是工程配置问题。以Keil MDK为例完整步骤如下在Groups里新建一个分组名字随意比如Middleware把cJSON.c添加进去。点击魔术棒打开Options for Target进入C/C页面。在Include Paths里加上cJSON.h所在的目录路径。如果你用的是默认配置C语言标准一般默认是C99cJSON完全兼容不需要额外调整。STM32CubeIDE操作也差不多因为它是基于Eclipse的在项目上右键选择Properties进入C/C General - Paths and Symbols在Includes里加目录就行。cJSON.c文件直接拷贝到Src目录下构建系统会自动编译。3.3 动态内存管理默认malloc能不能用该不该改cJSON内部动态分配全部走malloc和free。在Keil MDK 标准库的环境下默认堆大小是在startup_stm32f10x_hd.s启动文件里配置的一个Heap_Size的EQU宏默认值是0x200字节也就是512字节。这尺寸跑cJSON根本不够。我建议至少把堆配置到4KB或8KBHeap_Size EQU 0x2000 ; 8KB够处理几十个字段的JSON了修改完后重新编译、下载在调试器里看Heap统计值逐步微调。RAM特别紧张的芯片上也可以把malloc和free映射到你自己的内存池上去cJSON留了两个全局函数指针void *cJSON_malloc(size_t size); void cJSON_free(void *object);你可以在初始化阶段把这两个函数替换掉cJSON_malloc my_custom_malloc; cJSON_free my_custom_free;但说实话对于STM32F103RG之类RAM 64KB的芯片默认malloc就够用了非必要不上内存池毕竟代码越简单越不容易出bug。注意如果你的工程使用的是FreeRTOS务必要保证malloc/free是线程安全的。标准库的malloc在C99下一般具备基础保护但裸机主循环和中断同时调用cJSON时建议用critical section或互斥锁包住解析和释放过程。3.4 全局配置选项版本更新后要留意的宏开关cJSON源码顶部有好几个可用宏开关最常见的有宏定义作用CJSON_VERBOSE_DEBUG调试信息输出CJSON_NESTING_LIMIT嵌套深度的最大限制默认1000CJSON_DISABLE_NESTING_LIMIT禁用嵌套深度限制CJSON_NO_OVERFLOW_CHECK关闭溢出检测默认配置就够用但如果你在资源敏感的场合可以把嵌套深度调小一点比如设成50。这样恶意或异常的深层JSON不会把栈撑爆。4. 核心API逐层拆解你会用到的其实就这几个4.1 解析从字符串到对象树解析是JSON通信的入口。比如串口收到了一段数据char *json_str {\temp\:26.5,\humidity\:60,\alarm\:false}; cJSON *root cJSON_Parse(json_str); if (root NULL) { const char *err cJSON_GetErrorPtr(); printf(JSON解析失败错误位置: %s\r\n, err); return; }cJSON_Parse会返回一个cJSON*指针指向解析后的根节点。解析失败返回NULL用cJSON_GetErrorPtr()可以拿到出错的大致位置这是调试时最给力的函数千万要学会用它。拿到根节点后提取具体字段cJSON *temp_item cJSON_GetObjectItem(root, temp); if (cJSON_IsNumber(temp_item)) { float temp temp_item-valuedouble; printf(温度: %.2f\r\n, temp); }这里有个关键点每次解析出来的对象树用完之后必须释放否则必泄漏。释放只要是根节点指针cJSON_Delete(root);这个函数会递归释放整棵树包括所有子节点和字符串内存你不用管里面的细节。只要记住cJSON_Parse和cJSON_Delete永远成对出现就像malloc和free、fopen和fclose一样是铁律。4.2 创建从C数据到JSON对象发送方向的流程是反过来的。先创建一个根对象然后往里面塞字段cJSON *root cJSON_CreateObject(); if (root NULL) { return -1; } cJSON_AddNumberToObject(root, temp, 26.5); cJSON_AddNumberToObject(root, humidity, 60); cJSON_AddBoolToObject(root, alarm, 0); cJSON_AddStringToObject(root, device_id, sensor_01);这类辅助函数非常方便本质上就是cJSON_AddItemToObject的封装。更底层的方式是先建节点再挂载cJSON *temp_node cJSON_CreateNumber(26.5); cJSON_AddItemToObject(root, temp, temp_node);用辅助函数和手动挂载效果完全一样。但必须明白一件事cJSON_AddItemToObject这个操作会“接管”节点的所有权。也就是说节点一旦挂到对象树上释放时只需要cJSON_Delete(root)千万不要再单独去free那个子节点否则就是双重释放程序直接崩溃。如果你需要数组类型cJSON也提供了对应接口cJSON *arr cJSON_AddArrayToObject(root, sensors); cJSON_AddItemToArray(arr, cJSON_CreateString(temperature)); cJSON_AddItemToArray(arr, cJSON_CreateString(humidity));4.3 输出序列化成字符串发出去对象树搭好之后要变成字符串才能往串口、网口发。cJSON提供了两个输出函数char *out cJSON_Print(root); // 带换行缩进的格式化输出 char *out2 cJSON_PrintUnformatted(root); // 紧凑输出没有多余空白在STM32上推荐用cJSON_PrintUnformatted因为格式化输出会额外生成一堆\n和空格白白浪费带宽和内存。串口通信场景下紧凑格式才是常态。需要注意的是这两个函数返回的字符串是动态分配的用完之后必须用cJSON_free(out)释放或者直接用标准的free(out)因为cJSON底层就是走malloc。泄露一个几KB的字符串缓冲在长期跑的设备上很快就能把堆耗干。还有一个细节cJSON_PrintUnformatted默认输出的浮点数是固定格式。如果遇到26.500000这种带一串尾随零的输出你想精简可以在创建数字时直接用cJSON_AddNumberToObject然后自己用snprintf格式化后再作为字符串加入对象char num_buf[16]; snprintf(num_buf, sizeof(num_buf), %.2f, 26.5); cJSON_AddStringToObject(root, temp, num_buf);这样JSON里就是temp:26.50这个字符串形式了虽然类型变成了字符串但上位机处理起来问题不大。哪种合适看你的协议约定。4.4 修改与删除嵌入式场景用得不多但要懂如果同一棵对象树要多次使用比如循环上报可以复用这棵树而非反复解析创建cJSON_SetNumberValue(item, 26.8); // 改数值 cJSON_ReplaceItemInObject(root, temp, cJSON_CreateNumber(26.9)); // 整体替换节点 cJSON_DeleteItemFromObject(root, alarm); // 删除字段这几个API在动态配置场景里很实用比如设备收到上位机下发的新参数直接在已有的配置对象树上改改完再序列化保存。但STM32通信场景一般还是推荐“用完即焚”解析完处理完立刻Delete可以最大程度减少内存碎片。5. 项目实战STM32温湿度上报与指令解析5.1 需求背景与JSON协议设计这个项目例子的背景非常常见STM32F103通过DHT22读取温湿度系统每2秒通过串口向上位机上报一次数据同时串口接收上位机下发的控制指令比如设置上报周期、控制LED开关。协议设计得越简单越不容易出错。我定义了两种报文上报帧设备 - 上位机{type:report,device_id:dev_01,temp:25.6,humidity:62,ts:1700000000}指令帧上位机 - 设备{type:cmd,cmd:set_period,period:5,led:1}设计原则就一条字段名用固定大小写不要搞出Temp和temp混用的状况数值类型保持稳定约定temp永远是数字上位机和MCU就不要某天突然发字符串。5.2 创建并发送JSON上报数据上报逻辑写在定时器中断或主循环里都行重点是创建和发送这一段void send_report(float temp, float humidity, uint32_t timestamp) { cJSON *root NULL; char *payload NULL; char uart_buf[128]; root cJSON_CreateObject(); if (root NULL) return; cJSON_AddStringToObject(root, type, report); cJSON_AddStringToObject(root, device_id, dev_01); cJSON_AddNumberToObject(root, temp, temp); cJSON_AddNumberToObject(root, humidity, humidity); cJSON_AddNumberToObject(root, ts, timestamp); payload cJSON_PrintUnformatted(root); if (payload ! NULL) { // 通过UART发送注意自动追加 \r\n 作为帧结束符方便接收端解帧 snprintf(uart_buf, sizeof(uart_buf), %s\r\n, payload); uart_send_string(uart_buf); cJSON_free(payload); } cJSON_Delete(root); }这段代码里有三个很容易被新手忽略的点。payload和root是两段独立动态内存必须分别释放。snprintf的缓冲区一定要足够大如果JSON报文膨胀到超过uart_buf字符串会被截断接收端解析铁定失败。所以缓冲区大小我在实际项目里会用一个宏定义统一管理比如#define JSON_TX_MAX 256然后在snprintf里填sizeof(uart_buf)这样后期改长度只动一处。5.3 接收并解析串口指令串口接收建议用中断加环形缓冲区不能阻塞主循环。这里不展开环形缓冲区的写法假设你已经收到了一整行以\r\n结尾的字符串存到了rx_line里接下来就交给cJSONint handle_cmd(char *rx_line) { int ret 0; cJSON *root NULL; cJSON *type_item NULL; cJSON *cmd_item NULL; cJSON *period_item NULL; cJSON *led_item NULL; root cJSON_Parse(rx_line); if (root NULL) { printf({\error\:\parse failed\}\r\n); return -1; } type_item cJSON_GetObjectItem(root, type); cmd_item cJSON_GetObjectItem(root, cmd); if (!cJSON_IsString(type_item) || !cJSON_IsString(cmd_item)) { printf({\error\:\invalid msg\}\r\n); ret -2; goto cleanup; } if (strcmp(cmd_item-valuestring, set_period) 0) { period_item cJSON_GetObjectItem(root, period); if (cJSON_IsNumber(period_item)) { set_report_period(period_item-valuedouble); printf({\ack\:\set_period ok\}\r\n); } else { printf({\error\:\period invalid\}\r\n); ret -3; } } else if (strcmp(cmd_item-valuestring, set_led) 0) { led_item cJSON_GetObjectItem(root, led); if (cJSON_IsNumber(led_item)) { led_control((uint8_t)led_item-valuedouble); printf({\ack\:\set_led ok\}\r\n); } else { printf({\error\:\led invalid\}\r\n); ret -4; } } else { printf({\error\:\unknown cmd\}\r\n); ret -5; } cleanup: cJSON_Delete(root); return ret; }我特意写成了goto cleanup的结构就是为了确保任意分支退出前都会释放root。判断类型时也别嫌麻烦cJSON_IsNumber这类检查函数要养成习惯。如果你拿到的字段不是数字类型直接访问valuedouble可能是个垃圾值或0排查起来非常难受。5.4 主循环集成与看门狗配合主循环的写法和延时查询方式int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); printf(Device started, waiting for command...\r\n); while (1) { process_rx_buffer(); // 从环形缓冲区取出完整行并调用handle_cmd static uint32_t last_report 0; if (HAL_GetTick() - last_report report_period * 1000) { float temp dht22_read_temp(); float humi dht22_read_humidity(); send_report(temp, humi, HAL_GetTick() / 1000); last_report HAL_GetTick(); } HAL_Delay(10); } }这里有个实际经验想分享不要在中断里直接调用cJSON_Parse。解析操作耗时不定可能几十到几百微秒在中断里做这种动态分配的事轻则影响实时性重则引起可重入问题。先进缓冲区再由主循环或专门的协议任务去处理这才稳妥。6. 常见问题与排查技巧我替你们踩过的坑6.1 解析总是返回NULL到底哪里出问题这是出现频率最高的一个问题。排查思路我建议是有顺序的第一步检查缓冲区有没有截断。你如果用的是固定数组接收数组太小JSON句子后半截被丢了自然解析失败。解决办法是抓完整包再丢给cJSON_Parse确认字符串末尾是}或]才算完。第二步检查有没有不可见字符。串口助手发来的数据可能带\0、\n、\r或者尾随空格。cJSON解析是要求从第一个字符就是{开始的前面多个空格都可能失败。你可以加个调试打印把收到的字节逐个以十六进制打印出来看一眼。for (int i 0; i len; i) { printf(%02X , (uint8_t)buffer[i]); }如果看到头部或尾部有多余的0D 0A接收逻辑里就该去掉。第三步用cJSON_GetErrorPtr定位。解析失败后这个指针指向出错位置的字符。我遇到的典型案例是字符串里的双引号没有转义比如{name:ab}cJSON就直接卡住了。6.2 内存越用越少泄漏排查方法长期运行的设备发现RAM剩余越来越少十有八九是动态内存泄漏。排查方法分为两步。第一步在release模式下定期打印堆剩余量。MDK里可以用__heapstats更粗暴的方式是用一个全局计数在构建调试固件时把cJSON的malloc和free挂钩子函数包一层int my_malloc_count 0; void *cJSON_malloc_hook(size_t size) { my_malloc_count; return malloc(size); } void cJSON_free_hook(void *ptr) { free(ptr); my_malloc_count--; }跑完1000次收发流程之后如果my_malloc_count不回落到初始值你就知道肯定有地方漏了。检查清单就三条cJSON_Parse之后的cJSON_Delete有没有执行cJSON_PrintUnformatted返回的char*有没有free每个goto分支是不是都走到了清理位置。6.3 浮点数精度与传输格式问题STM32默认double是64位还是32位取决于编译器设置。在MDK默认情况下double会被映射成8字节%.2f的输出精度能保证。但在一些优化等级下浮点转字符串可能产生26.599999这种让人抓狂的输出。处理办法前面说过用snprintf限定好小数点位数或者干脆把温度传输放大100倍用整数传输。后者在工业场景更稳妥端到端零误差。6.4 Keil编译报错与兼容性问题常见报错有几种error: #20: identifier true is undefined那是因为你的代码用了C风格的true/false而C语言里cJSON用的是1和0。检查cJSON_CreateBool(true)改成cJSON_CreateBool(1)。error: #18: expected a )一般是头文件重复包含或者struct前少写了cJSON。检查包含路径有没有同时引入了两个不同版本的cJSON.h这种情况很隐蔽我遇到过项目里一个旧版本cJSON混进来了API都变了编译错误千奇百怪。链接时报cJSON_Parse不对齐或者找不到定义那就是编译器没把cJSON.c编译进去只放了头文件。MDK里进Manage Project Items确认下源文件列表。6.5 中断驱动串口通信的典型坑最后说一个跟cJSON无关但实际项目中经常一起出现的坑串口DMA接收空闲中断解帧时如果你在中断回调里把半包数据直接当作完整JSON去解析就会间歇性出现解析失败。因为文本流可能被拆成两段第一段结尾不是完整的}。我自己的习惯做法是在DMA接收缓冲里检测\r\n作为帧边界只有收到完整帧才交给上层处理。伪代码如下void HAL_UARTEx_RxEventCallback(...) { for (int i 0; i rx_len; i) { if (rx_buf[i] \n rx_buf[i-1] \r) { copy_to_frame_line(...); // 收齐一帧设置标志位 } } }简单可靠也不会让cJSON去猜字符串边界。6.6 嵌套过深导致栈溢出cJSON内部递归解析嵌套层数太深会不断消耗栈空间。STM32栈默认1KB你解析一个嵌套十层的JSON对象加上函数调用现场很容易栈溢出。如前所述把CJSON_NESTING_LIMIT调到50甚至20既能防恶意报文也能帮你早点发现协议设计上的不合理——通信协议设计得要扁平时就扁平不要为了套娃而套娃。7. 踩坑之后的一些体会这几年代码写下来我对cJSON的真实感受是它本身没什么高深的技术难点最大的风险永远在工程集成的细节里。比如动态内存生命周期管理、串口帧边界判定、堆区大小配置这些环节出了问题排查起来往往比写代码更费时间。建议刚开始接触的朋友先搭一个最小工程在串口助手里手动发几条JSON把解析和创建的每个API都摸熟再往业务代码里塞。别一上来就把协议设计得无比复杂先让两个设备能稳定互发字符串再考虑加字段、数组、嵌套这些高级特性。我在实际项目中的体会是一个通信模块里只要JSON报文的收发出口和入口都控制好字段命名保持稳定cJSON这套方案基本不会再出什么幺蛾子。如果哪天你发现上位机又解析不出数据了别急着怀疑库先用十六进制把这帧数据打出来看一看多半是格式、编码、边界的老问题。多准备一个串口监视器比什么都强。最后再分享一个小经验在STM32工程里给cJSON的解析和创建函数包一层自己的接口比如json_parse_root、json_send_report。这样以后就算要换解析库或者对内存管理做定制你只需要改这一层业务代码几乎不用动。模块化这件事在嵌入式里照样值得做。
返回列表