ARTICLE DETAIL

资讯详情

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

STM32上cJSON移植与实战:嵌入式JSON解析和内存管理指南

STM32上cJSON移植与实战:嵌入式JSON解析和内存管理指南 嵌入式开发这个圈子很多人一提起JSON就摇头觉得那是服务器、App端才玩得起的东西。毕竟一个JSON解析库动不动就要好几KB的RAM而STM32F103这种主流MCU总共也才20KB内存跑个协议栈都恨不得扣扣搜搜哪敢碰JSON这种奢侈品。但这个观念真得改改了。现在IoT设备跟云端通信基本默认JSON格式哪怕你走的是MQTT、CoAP或者自定义的TCP协议负载部分多半也是JSON。如果嵌入式端连JSON都不会解析那基本上跟现在主流的物联网平台没法对接。好在有cJSON这个库它天生就是为资源受限的MCU设计的整个库就两个文件代码量不到1千行运行时动态内存占用完全可控在STM32上跑起来毫无压力。这篇文章我就拿STM32F103C8T6也就是常说的蓝板为例从零开始讲清楚怎么把cJSON移植进工程、怎么用API构造和解析JSON、怎么跟串口配合实现一套完整的通信逻辑最后附上可以直接抄作业的完整代码。无论是做毕设、个人项目还是正式产品原型这套流程都通用。1. 为什么偏偏是cJSON轻量JSON方案的核心优势1.1 嵌入式 JSON 库的选型对比如果你去GitHub上搜跟cJSON类似的开源JSON库还有好几个比如jsmn、uJSON、parson等等。但真正在STM32社区里被广泛验证过的cJSON肯定排第一原因很简单它把功能完整度和资源占用这两个矛盾点平衡得非常好。我们可以直观对比一下主流方案库名代码体积动态内存依赖支持构造JSON支持解析JSON可移植性cJSON约30KB Flash依赖malloc/free完整支持完整支持极好纯Cjsmn约5KB Flash无动态内存不支持仅tokenize极好parson约20KB Flash依赖malloc/free完整支持完整支持好jsmn虽然小但它只做tokenize把JSON拆成标记不帮你构建树形结构想取一个字段还得自己按索引去找用起来很累。cJSON则帮你把整个JSON对象解析成一棵带类型的树取字段、加字段、改字段都是直接操作API符合大多数人的思维习惯。再说了cJSON源码极其干净没有依赖任何标准库之外的东西连操作系统的线程安全都由你自己控制这点在裸机环境中特别合适。所以我的建议很简单STM32上做JSON通信cJSON就是首选。1.2 cJSON的底层模型从JSON文本到内存对象这一步必须弄清楚否则后面代码写得再漂亮出了问题你也不知道从哪下手。cJSON核心就一个关键数据结构typedef struct cJSON { struct cJSON *next; // 指向下一个兄弟节点用于数组/对象的遍历 struct cJSON *prev; // 指向前一个兄弟节点 struct cJSON *child; // 指向第一个子节点用于对象或数组 int type; // 节点类型cJSON_Number/cJSON_String/cJSON_Array等 char *valuestring; // 字符串类型的值 int valueint; // 整数类型的值 double valuedouble; // 浮点类型的值 char *string; // 当前节点的键名 } cJSON;看到没有这其实是一棵多叉树。对象{ temp: 26.5 }的树形结构就是根节点是一个对象类型节点它有一个子节点子节点的string是tempvaluedouble是26.5。理解了这个结构你就能明白为什么读取一个字段要写成两步先用键名找到对应节点再根据type去拿对应的值。这也是后面所有API调用的底层逻辑。2. 工程准备在STM32工程中完整接入cJSON2.1 源码获取与文件添加cJSON的源码很好找GitHub上cJSON官方仓库直接下载就行只需要两个文件cJSON.c和cJSON.h。下载后放到你的工程目录下比如新建一个Middlewares/cJSON文件夹然后在Keil MDK里点Add Existing Files把cJSON.c加进去并在Include Path里加上对应头文件路径。这里有个小问题要提醒cJSON源码的cJSON.h里有几个_option 宏比如CJSON_VERSION_MAJOR这种都是为跨平台准备的在STM32工程里一般不用管它默认配置就够用。如果你用的IDE不是Keil而是IAR或STM32CubeIDE思路一模一样无非是文件添加和头文件路径设置的位置不同。2.2 堆空间规划cJSON能跑起来的关键前提cJSON是依赖动态内存分配malloc/free的这一点比什么都重要。很多朋友在移植后一调用 cJSON_Parse 就死机十有八九是堆空间太小malloc失败了。STM32默认的启动文件startup_stm32f103xb.s里一般定义了Heap_Size EQU 0x200 Stack_Size EQU 0x4000x200也就是512字节的堆对于cJSON来说完全不够用。举例来说你解析一段100字节的JSON文本cJSON内部至少要为每个节点分配一次内存每个节点结构体大概占几十字节再加上字符串副本详细信息见后面第6章。我个人的经验是使用cJSON的工程堆空间至少放到4KB以上保险起见8KB。改法有两个方向一个是直接改启动文件里的Heap_Size另一个是在链接脚本.sct文件里手动划分堆区。最简单的还是改启动文件Heap_Size EQU 0x2000 ; 8KB改完重新编译下载基础环境才算稳了。2.3 内存钩子让cJSON用上你自定义的内存策略如果你的工程对内存分配有严格管理比如使用了FreeRTOS的pvPortMalloc或者你自己写了一个内存池那cJSON还提供了钩子机制让你替换默认的malloc/free#include cJSON.h #include FreeRTOS.h static void *rtos_malloc(size_t size) { return pvPortMalloc(size); } static void rtos_free(void *ptr) { vPortFree(ptr); } void cjson_platform_init(void) { cJSON_Hooks hooks; hooks.malloc_fn rtos_malloc; hooks.free_fn rtos_free; cJSON_InitHooks(hooks); }用FreeRTOS的堆管理器替换标准库的malloc好处是线程安全且有明确的堆大小控制对于跑RTOS的STM32工程来说更稳健。裸机工程如果只是简单使用标准库不调用cJSON_InitHooks也行。3. 核心API盘点构造和解析的必备工具箱3.1 构造与序列化这几个函数用到烂熟cJSON构造类API我必须先列个表大家后面写代码时可以对照函数作用cJSON_CreateObject()创建一个JSON对象花括号{}cJSON_CreateArray()创建一个JSON数组方括号[]cJSON_AddItemToObject(node, key, item)向对象中添加一个子节点cJSON_AddItemToArray(arr, item)向数组中追加一个子节点cJSON_AddNumberToObject(node, key, num)快捷添加数字字段cJSON_AddStringToObject(node, key, str)快捷添加字符串字段cJSON_AddBoolToObject(node, key, bool)快捷添加布尔字段cJSON_Print(node)将对象序列化为格式化的JSON文本带换行缩进cJSON_PrintUnformatted(node)将对象序列化为紧凑JSON文本无多余空白注意cJSON_Print和cJSON_PrintUnformatted返回值是一个char *这是cJSON内部用malloc分配出来的字符串用完之后一定要调用cJSON_free(ptr)释放否则就是内存泄漏。嵌入式设备常年累月跑着几字节的泄漏都可能让系统慢慢死掉。3.2 解析与遍历从JSON文本中精准取数解析端也有一个核心API清单函数作用cJSON_Parse(json_str)解析JSON文本返回根节点指针失败返回NULLcJSON_GetObjectItem(node, key)从对象中通过键名取出子节点cJSON_GetArraySize(arr)获取数组元素个数cJSON_GetArrayItem(arr, index)按索引获取数组元素cJSON_IsString(item) / IsNumber / IsBool类型检查函数防止拿到错误类型cJSON_Delete(node)释放整棵树内存这里一定记住cJSON_Parse返回的根节点最终必须由cJSON_Delete释放。很多人解析完了忘记释放MCU跑一段时间后内存就爆了。关于类型检查我特别强调一下。有些新手习惯直接item-valueint拿值但根本不检查item是不是NULL、类型对不对这在调试阶段可能碰巧能跑一旦协议内容变一点就崩溃。规范写法应该是cJSON *led cJSON_GetObjectItem(root, led); if (cJSON_IsNumber(led)) { int value led-valueint; }只有穿过类型检查才去取值这样安全性和可维护性都会好很多。4. 实战一STM32上报传感器数据到服务器4.1 数据帧设计这个场景很典型STM32采集到温度和湿度打包成JSON格式通过串口或ESP8266 WiFi模块发给远端服务器。假设协议如下{ type: report, device_id: BLE_DEV_001, timestamp: 1723001234, sensors: { temp: 26.5, humidity: 62.3 } }这个JSON结构包含嵌套对象嵌套就是实际项目里最常见的形态所以我们用cJSON把它构造出来再序列化成字符串发送。4.2 构造JSON并序列化发送直接上完整代码#include cJSON.h #include usart.h #include string.h #include stdio.h /* 串口发送字符串封装HAL库示例 */ static void uart_send_string(UART_HandleTypeDef *huart, const char *str) { HAL_UART_Transmit(huart, (uint8_t *)str, strlen(str), 1000); } /* 构造传感器上报JSON并发送 */ void send_sensor_report(float temp, float hum, uint32_t timestamp) { char *json_out NULL; cJSON *root NULL; cJSON *sensors NULL; root cJSON_CreateObject(); if (root NULL) { goto exit; } cJSON_AddStringToObject(root, type, report); cJSON_AddStringToObject(root, device_id, BLE_DEV_001); cJSON_AddNumberToObject(root, timestamp, (double)timestamp); sensors cJSON_CreateObject(); cJSON_AddNumberToObject(sensors, temp, temp); cJSON_AddNumberToObject(sensors, humidity, hum); cJSON_AddItemToObject(root, sensors, sensors); /* 序列化为紧凑字符串 */ json_out cJSON_PrintUnformatted(root); if (json_out ! NULL) { uart_send_string(huart1, json_out); uart_send_string(huart1, \r\n); cJSON_free(json_out); } exit: cJSON_Delete(root); return; }看着很简单对吧但每个调用背后都有讲究先cJSON_CreateObject()创建根。如果失败NULL指针说明内存分配失败提前退出。cJSON_AddStringToObject和cJSON_AddNumberToObject是快捷函数它们内部会创建子节点并挂到对象上会复制字符串值到内部缓冲区。嵌套对象先单独Create再通过cJSON_AddItemToObject挂到父节点挂上去之后父节点接管了它的生命周期所以最后只需要cJSON_Delete(root)不用单独释放sensors。cJSON_PrintUnformatted生成紧凑文本比格式化版本更省RAM和带宽实际产品里基本都用这个。json_out是malloc出来的缓冲区发送完之后必须cJSON_free。4.3 发送结果与验证把这段代码烧进STM32在串口助手波特率115200下能看到输出{type:report,device_id:BLE_DEV_001,timestamp:1723001234,sensors:{temp:26.5,humidity:62.3}}跟设计目标完全一致。这里提一个细节cJSON序列化浮点数时用的是%g格式所以26.500000会输出为26.5简洁美观。但如果你的数据对精度有严格需求比如需要固定小数位那建议先自己在业务层转换成字符串再塞给cJSON否则会遇到26.10被输出成26.1这种失真问题。5. 实战二STM32解析云端下发的控制指令5.1 接收缓冲与消息边界识别串口接收JSON有个难点JSON文本是可变长的怎么知道一帧数据收完了常见做法有三种固定帧头帧尾比如{...}\n以换行符作为结束标志。使用串口IDLE空闲中断超过一定时间无新数据认为一帧结束。在帧头加上长度字段先收长度再收正文。最省事且粗糙的方式是第1种。我在演示代码里就用换行符\n作为一条JSON消息的结束。工程化一点推荐用IDLE空闲中断DMA这样CPU负担最小。这里给一个简化版的串口接收过程串口每收到一个字节都存入rx_buffer遇到\n就把缓冲区字符串交给parse_command处理。5.2 解析控制指令并执行假设云端下发的指令是{cmd:set_led,state:1}我们要解析出cmd字符串和state数值然后控制LED的亮灭。完整示例/* 解析JSON指令并执行对应的控制操作 */ void parse_command(const char *json_msg) { cJSON *root NULL; cJSON *cmd_item NULL; cJSON *state_item NULL; root cJSON_Parse(json_msg); if (root NULL) { /* 解析失败说明json_msg不是合法JSON */ uart_send_string(huart1, {\result\:\error\,\reason\:\invalid json\}\r\n); return; } cmd_item cJSON_GetObjectItem(root, cmd); if (cJSON_IsString(cmd_item) cmd_item-valuestring ! NULL) { if (strcmp(cmd_item-valuestring, set_led) 0) { state_item cJSON_GetObjectItem(root, state); if (cJSON_IsNumber(state_item)) { if (state_item-valueint 1) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); } } } } cJSON_Delete(root); }写这段代码时我有几个习惯大家可以直接抄每取一个字段都先判类型再取值不直接foo-valueint。每次解析完务必cJSON_Delete(root)否则根节点及其所有子节点全部泄漏。如果上层指令很多建议把cmd的值做成一个枚举或字符串表用查找表方式分发别写一长串if-else后期会很难维护。错误响应也返回JSON格式这样上位机或服务端解析逻辑统一。5.3 回调机制把解析和业务解耦项目变大之后一个文件里到处写cJSON解析会很乱。我的做法是定义统一的重型解析入口业务方注册回调typedef void (*cmd_handler_t)(const cJSON *params); typedef struct { const char *cmd_name; cmd_handler_t handler; } cmd_entry_t; void dispatch_command(const char *json_msg) { cJSON *root cJSON_Parse(json_msg); cJSON *cmd_item cJSON_GetObjectItem(root, cmd); if (!cJSON_IsString(cmd_item)) { cJSON_Delete(root); return; } const cmd_entry_t *entry find_handler(cmd_item-valuestring); if (entry) { entry-handler(root); // 把整个root传给具体处理函数 } cJSON_Delete(root); }这种写法的好处是新增一种命令时只需要新增一个handler函数和一条表项不用动分发逻辑。对于做完整项目的人来说这个结构可以让你的代码经得起迭代。我的很多项目从最初几个命令扩展到几十个命令分发部分一直没大改过。6. 嵌入式上的内存管理和性能优化6.1 内存占用到底是多少手把手估算很多朋友问cJSON到底吃多少内存这个问题没法一句话回答因为跟JSON内容的结构有关。但我们可以做一个估算每个节点结构体cJSON大约需要13 * 4 52字节在Cortex-M3上指针4字节double是8字节还要按8字节对齐实际上可能到56或64字节。每个字符串键和字符串值都会在解析时被复制出一份内存。树形节点本身还要为对象的每个子节点至少分配一个节点。举个例子解析{temp:26.5,humidity:62.3}这个JSON根节点1个temp子节点1个humidity子节点1个节点内存3 * 约56字节 168字节键名字符串 temp、humidity 约12字节加上cJSON内部malloc实现本身的开销所以一段总共不到40字符的JSON运行时至少占用300字节左右的堆内存。这不是cJSON笨重而是动态树形结构必须付出的代价。如果你要管理一个复杂的配置项几百个字段的那种那几KB甚至几十KB的堆都是正常的。6.2 malloc开销与碎片化裸机上的终极考验cJSON虽然好用但它高频调用malloc/free这会导致内存碎片化。尤其裸机工程长时间频繁构造和释放JSON对象后堆空间虽然没有泄漏但是被切成很多小碎片大块分配就会失败。我的实践经验是不要频繁构造、删除大JSON对象。如果每秒上报一次每次构造完释放问题不大但如果每10毫秒构造一次就该考虑内存池或复用缓冲了。把cJSON_Print分配的字符串复用一个静态缓冲区。比如发完立即释放并尽量让每次的JSON大小接近降低碎片程度。定期做内存自检。用FreeRTOS时可以调用xPortGetFreeHeapSize()监控剩余堆空间变化裸机用标准库malloc的话可以挂钩子函数统计分配总字节数。考虑用自己实现的轻量内存池。把相近大小的分配请求放到固定池中能极大降低碎片化。6.3 浮点数序列化精度问题cJSON内部把数字都用double保存。当你用cJSON_Print输出时默认用的格式是%.17g或%g在STM32这种单精度浮点运算为主的平台上你很有可能想存26.10输出却是26.1想存0.1输出却变成0.100000001490116。解决这个问题有两个思路如果业务只要求两位小数就先把 float 放大100倍转成int再传输比如temp_x100 (int)(temp * 100)接收端再除以100。这是嵌入式领域最常见的做法也最省内存。或者自己先格式化成字符串再用cJSON_AddStringToObject塞进去。例如char temp_str[16]; snprintf(temp_str, sizeof(temp_str), %.2f, temp); cJSON_AddStringToObject(sensors, temp, temp_str);这样输出就是temp:26.50代价是接收端要把字符串再转回数字多一道转换。但保证不会出现二进制浮点数带来的精度显示问题。7. 避坑指南与调试技巧7.1 千万别忘了释放否则必死我见过太多人把 cJSON 用得风生水起上线跑了一个月突然死机查到最后就是内存泄漏。嵌入式设备的内存本来就紧张每泄漏几十字节可能一两天看不出问题但一周、一个月下来整个堆就耗尽了。我的铁律是每写一次cJSON_Parse同一函数返回路径上必须写cJSON_Delete。cJSON_Print/cJSON_PrintUnformatted返回的字符串必须cJSON_free。使用goto或return时先检查是否已经释放。7.2 解析失败不打印排查效率低一半遇到cJSON_Parse返回NULL时很多人只知道解析失败却不知道JSON具体哪里写错了。cJSON提供了一个可选的错误定位接口const char *error_ptr NULL; cJSON *root cJSON_ParseWithOpts(json_msg, error_ptr, 0); if (root NULL error_ptr ! NULL) { // error_ptr 指向第一个出错字符位置 printf(JSON error at: %s\n, error_ptr); }用这个接口你秒定位是哪个字符导致了语法错误不用瞪着眼睛数括号。7.3 串口接收乱码与分包实际用串口收JSON时一条消息可能分好几包到达。如果你只是简单地把每次接收到的内容都拿去Parse大概率会失败。解决方案是建立缓冲-完整性判断-解析的三段式流程持续把串口收到的字节追加到rx_buffer。每次收到\n就把缓冲区内容当作一条完整JSON去解析。解析前如果rx_buffer已经接近上限强制清空并报错。代码示例void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart huart1) { if (rx_byte \n) { rx_buffer[rx_index] \0; parse_command(rx_buffer); rx_index 0; } else { if (rx_index sizeof(rx_buffer) - 1) { rx_buffer[rx_index] rx_byte; } else { rx_index 0; // 缓冲区溢出丢弃整帧 } } HAL_UART_Receive_IT(huart1, rx_byte, 1); } }注意这里用的是中断逐字节接收。工程上更推荐串口IDLEDMA方案但原理一样拼到完整JSON再解析。7.4 我踩过的两个坑专门列出来第一个坑是cJSON_AddNumberToObject传入float类型变量结果精度丢失。原因是cJSON内部统一用doublefloat转double时某些值会变成很长的尾数。解决方式在上面已经说过放大成int或者格式化字符串。第二个坑是Keil工程里忘了把cJSON.c添加到C99编译模式。cJSON源码里用了C99特性如果编译报一些奇怪的道义错建议在Keil的 C/C - Language 中把--c99加上。很多网上教程没提这点导致不少人卡在编译阶段。8. 扩展思路从串口到网络协议栈写到这里基础用法已经完整覆盖了。最后说下我实际做项目时更普遍用的方式在FreeRTOSLWIP或RT-Thread环境下把cJSON跟网络协议结合起来。思路依然是双重的构造段采集线程把数据填进结构化变量统一用一个发送模块调用cJSON构造并netconn_write/lwip_send。解析段TCP/MQTT收到完整报文后先进入帧缓冲再调用cJSON解析然后按消息类型分发给不同业务线程。有一个点经常被忽略在多线程环境里cJSON本身不是线程安全的多个线程同时构造或解析JSON时需要加互斥锁。我的习惯是给JSON收发模块单独设一把互斥锁所有cJSON操作都在锁内完成简单可靠。另外一个小技巧如果你的设备要跟各种云平台对接不同平台的字段命名差异很大你用cJSON解析后建议统一转换成内部的业务结构体。不要到业务代码里到处写cJSON_GetObjectItem否则将来适配第二个平台时你会改到怀疑人生。我在实际产品里就是这么做的入口统一把JSON翻译成结构体出去时再把结构体翻译成JSON。cJSON永远只是边界层的东西核心业务模块根本感知不到JSON的存在。这样不仅代码干净换协议、换平台时工作量也会小很多。如果你只是做毕设或者个人小项目先照着前面的代码跑通串口收发JSON就已经跨过了最难的坎。当我们把底层的序列化和反序列化逻辑都封装好之后剩下的就只是业务逻辑了——那才是你真正应该花时间思考的地方。
返回列表