
做嵌入式时间长了你会发现一个特别有意思的现象很多刚入行的朋友对JSON有一种莫名的敬畏总觉得它是Web世界的玩意单片机这种小身板玩不起。但等你真在STM32上跑过一次jansson估计也会和我一样感叹一句——这东西真该早点用。我最早接触STM32和JSON是为了一个网关项目MCU要把温湿度、开关状态、设备配置统统上报给云平台同时接收云端下发的控制指令。刚开始用纯C结构体拼协议字段一多调试时对着十六进制报文看得两眼发黑后来就转成了JSON格式。选型时在cJSON和jansson之间纠结了一阵最后选了jansson。倒不是说cJSON不好而是jansson的API设计更顺我手尤其是引用计数和Unicode处理在我们那种需要长期维护的代码里特别加分。这篇文章就围绕“STM32使用jansson处理Json格式字符”这个主题展开。我会完整梳理为什么选它、怎么把它弄进STM32工程、解析和构建JSON的核心API怎么用、以及几个真实项目中踩过的坑。无论你是用CubeIDE还是Keil无论是F103还是H743这套思路都能直接套用。1. 为什么在STM32上要用JSON和jansson1.1 从自定义协议到JSON核心需求拆解很多老工程师习惯用结构体指针直接强转收发缓冲区结构体发出去另一端结构体接收速度快、代码量小。但项目一旦复杂起来问题就来了。第一个问题是字段对齐。你辛辛苦苦定了#pragma pack(1)换个编译器或者开个优化选项内存布局可能就不一样了设备之间通信直接踩坑。第二个问题是版本兼容。结构体里加一个字段接收方没同步升级整个解析就乱了。第三个问题是调试。抓包抓回来一串十六进制你得拿着协议文档挨个字节对效率非常低。JSON的好处在于自描述。接收方不需要知道完整的结构定义直接从文本里取自己关心的字段即可云端下发配置时可以动态增加数据项老版本设备忽略新字段就行兼容性天然好。同时JSON是文本格式串口助手、网络调试工具能直接看到内容写日志也更方便。在STM32这种资源有限的平台上只要数据量不大、频率不高JSON完全撑得住。而在所有C语言的JSON库里jansson是设计得比较完整的一个。1.2 jansson和cJSON的选型对比在嵌入式领域cJSON可能是名气最大的JSON库很多人的第一个JSON解析器就是它。但我在实际项目中更偏好jansson主要原因在于API设计。对比维度janssoncJSON文件结构多个源文件功能模块化单文件集成极简单内存管理可自定义分配器支持引用计数手动分配/释放需要自己管理引用计数核心机制增删节点安全无原生概念Unicode支持原生UTF-8处理转码方便只做字节流不关心编码构建JSONjson_pack/json_vpack格式串简洁逐节点操作代码较长解析嵌套错误返回错误行号列号只返回失败定位靠运气许可证MITMIT单文件集成确实是cJSON的绝对优势复制一个.c和.h进工程就能用。但如果你的项目要做比较复杂的文档生成、嵌套操作、长期多节点复用jansson的引用计机会省很多事。我见过太多cJSON项目内存泄漏本质是忘记删除子节点或父节点。jansson用json_decref统一管理思想更接近现代语言的GC模式虽然还是要人肉调用但容错率高不少。还有一个关键点jansson对Unicode转义的处理更规范。做设备接入云平台时设备名称、固件版本经常带中文或者特殊字符cJSON直接按原始字节输出有时候会和平台的UTF-8校验冲突jansson默认就能处理这类编码问题。2. STM32工程集成与最小化裁剪2.1 获取源码和目录整理官方源站在GitHub上有稳定版本直接把tar.gz包下载下来解压后重点拿src目录。里面文件不少但真正必需的只有核心几个value.c所有json_t类型定义和基础操作hashtable.cJSON Object的哈希表实现strbuffer.c字符串缓冲strconv.c整数/浮点数和字符串转换utf.cUTF-8编码处理load.c解析器主体dump.c序列化输出error.c错误信息封装memory.c内存分配封装version.c库版本信息pack_unpack.cjson_pack/json_unpack实现我习惯把这几个文件统一放到工程的Middlewares\Third_Party\jansson\src目录下和HAL库并列项目结构看起来干净。同时把jansson.h所在目录加入头文件搜索路径。注意jansson_config.h、jansson_private_config.h也会引用系统库的头文件在MDK里要确认C99模式已经打开否则stdint.h这类标准头文件可能报错。有个小技巧如果你用的是STM32CubeIDE直接右键工程添加现有文件编译器会自动处理依赖如果用的是Keil MDK记得把.c文件全部加到工程分组里并且把源文件目录添加到C/C的Include Paths里。2.2 在Keil和CubeIDE里过编译的配置细节第一次把jansson移植到MDK时我踩过一个典型的坑编译报错说找不到sys/types.h又或者报ssize_t未定义。这就涉及jansson对POSIX API的依赖。在桌面Linux上这些都不是问题但在MDK默认的微库环境里缺失的头文件会导致编译中断。解决思路有两种。第一种是在编译宏里加上JANSSON_WITHOUT_ATOMIC之类的宏绕过jansson对原子操作的依赖不同版本宏名略有差异需要看jansson_private_config.h里的#ifdef条件。第二种更底层就是建一个config.h根据你的芯片平台定义缺失的类型。比如#ifndef JANSSON_CONFIG_H #define JANSSON_CONFIG_H #define HAVE_STDINT_H 1 #define HAVE_SYS_TYPES_H 1 #define HAVE_STDLIB_H 1 #define HAVE_STRING_H 1 #define HAVE_STDARG_H 1 #endif然后把JANSSON_CONFIG_H放进全局编译器宏替代jansson默认的jansson_private_config.h省去一大部分环境适配问题。不过这里要提醒一下HAVE_UNISTD_H这类宏不要乱定义如果你的平台没有unistd.h编译器反而会多出更多错误。最好的做法是打开jansson源码里那几个#ifdef条件逐个看它到底需要什么缺什么补什么不贪多。2.3 自定义内存分配函数STM32上跑JSON解析最怕的就是堆不够和内存碎片。jansson默认用malloc/free在裸机或RTOS环境里我们通常有自己的一套内存管理策略比如FreeRTOS的pvPortMalloc或者自己实现的静态内存池。这时候就要用到jansson的分配器重定向功能。#include jansson.h void* json_malloc_impl(size_t size) { return pvPortMalloc(size); } void json_free_impl(void* ptr) { vPortFree(ptr); } void json_init_custom_allocator(void) { json_set_alloc_funcs(json_malloc_impl, json_free_impl); }在系统初始化时调用一次json_init_custom_allocator之后所有jansson内存操作都走你的自定义分配器。使用FreeRTOS的朋友要注意pvPortMalloc需要你在FreeRTOSConfig.h里开启configUSE_PORT_MALLOC相关配置默认工程一般已经支持。如果你用裸机开发又没有额外内存池直接沿用C库的malloc也没问题但建议养成定期json_decref的习惯不然解析一次泄漏几十字节跑几个小时系统就废了。3. 核心API实战从解析到构建3.1 解析字符串json_loads和错误定位STM32最常见的场景是从串口、网口或无线模块收到一段JSON字符串然后解析出字段。jansson解析字符串的核心函数是json_loads原型非常直观json_t *json_loads(const char *input, size_t flags, json_error_t *error);第二个参数flags是行为开关我通常用JSON_DECODE_ANY | JSON_DISABLE_EOF_CHECK。JSON_DECODE_ANY很关键它允许顶层不是对象或数组比如一个裸的整数、字符串都能解析成功JSON_DISABLE_EOF_CHECK表示输入字符串尾部可以有额外的空白或数据适合从UART缓冲区里直接解析不用精确到字符串结尾。json_error_t结构体是排查问题的神器里面包含source、line、column、text四个字段。解析失败时把这几项打出来能精确知道JSON到底错在第几行第几列比cJSON只返回一个NULL强太多。下面是一段典型的UART数据解析// 假设 rx_buffer 是全局接收缓冲数组 void parse_uart_json(char *rx_buffer) { json_error_t err; json_t *root json_loads(rx_buffer, JSON_DECODE_ANY | JSON_DISABLE_EOF_CHECK, err); if (!root) { printf([JSON] parse error: line %d, column %d: %s\r\n, err.line, err.column, err.text); return; } // 解析成功后处理... json_decref(root); // 一定记得释放 }括号配对错误、引号未闭合、多了逗号这三个是UART调试里最高频的错误。有了行列号和提示文本定位速度能提升一个量级。3.2 从JSON对象里提取字段解析完的顶层通常是一个json_t *对象接下来就是从里面抠数据。jansson提供了三类APIjson_object_get(obj, key)取出键对应的值没有这个键就返回NULLjson_object_get_int(obj, key)直接拿到整数类型不对或缺失时返回0json_object_get_real(obj, key)获取浮点数实际项目中我偏向先取json_t *节点再用json_is_*系列函数做类型判断最后再转成具体值。直接调json_integer_value虽然代码短但类型不匹配时返回的默认值容易掩盖问题。json_t *root json_loads(rx_buf, flags, err); json_t *temp_node json_object_get(root, temperature); if (json_is_real(temp_node)) { float temperature json_real_value(temp_node); printf([JSON] temperature: %.2f\r\n, temperature); }嵌套对象就一层层json_object_get取下去。比如{device:{sn:ABC123}}需要先取device节点再取sn节点不要指望一步到位。数组的遍历用的是json_array_size配合json_array_getjson_t *list json_object_get(root, sensors); int count json_array_size(list); for (int i 0; i count; i) { json_t *item json_array_get(list, i); // 对 item 进行处理 }3.3 构建JSONjson_pack是效率神器设备端要上报数据时拼JSON字符串最容易犯的错误是手写sprintf格式字符串转义、逗号、引号一不小心就写错。jansson的json_pack函数解决了这个问题用类似Python格式化字符串的语法一行构造出整个JSON对象。json_t *obj json_pack({s: s, s: i, s: f}, device_id, stm32-01, channel, 4, voltage, 12.6); if (!obj) { // 构造失败 return; } char *out json_dumps(obj, JSON_COMPACT); printf([JSON] send: %s\r\n, out); json_decref(obj); free(out);json_pack的格式串核心语法s字符串参数i整数参数f浮点参数b布尔参数o嵌入一个json_t对象所有权转移给父对象{}、[]构造对象或数组这个函数极大减少了代码量也让拼接逻辑一眼就能看懂。注意o格式符意味着参数节点的所有权会被父节点接管所以如果这个节点之前还被其他变量引用记得先json_incref一下不然后续访问就是悬垂指针。3.4 引用计数jansson最容易出死坑的地方引用计数是jansson的核心设计也是新手最容易翻车的点。每个json_t *节点都有一个引用计数创建时是1。json_object_set_new(obj, key, value)会把value的引用计数加1json_decref(value)会把引用计数减1减到0就自动释放内存。所以用json_object_set_new把一个节点塞进父对象后如果不再需要这个独立变量可以不再手动释放。如果用json_object_set不带new它只拷贝指针不转移所有权等父对象销毁时你会得到一个悬垂指针。如果需要把一个节点同时塞到多个对象里必须json_incref一次保证引用数正确。我早期调试时经常忘记释放导致内存越用越少。后来定了个规矩凡是函数里通过json_loads、json_object、json_pack创建的json_t在不再使用时要严格配对json_decref凡是需要长期保存的节点例如存到全局变量用json_deep_copy拷贝一份再存原始节点立刻释放。4. 实操案例温湿度传感器上报加上位机配置下发4.1 场景设计这里用一个真实做过的完整闭环当作样例STM32F407连接一个串口温湿度传感器通过ESP8266模块走MQTT和云端通信。上行报文是设备状态上报下行报文是云端下发配置。上行数据格式长这样{ device_id: sensor-001, temp: 26.5, hum: 62.4, ts: 1700000000, report: 5 }下行配置格式{ cmd: set_threshold, temp_high: 30.0, temp_low: 10.0, report_interval: 10 }这个场景很有代表性MCU要能快速构造结构化数据也要能可靠解析外部输入并提取配置。4.2 上报数据构建的完整代码上报不能在中断里做推荐放在主循环或者RTOS的任务里。下面这段代码直接构造JSON并拿到底层字符串用于发送char* build_report_json(float temp, float hum, uint32_t ts, int report_interval) { json_t *root json_pack({s:s, s:f, s:f, s:I, s:i}, device_id, sensor-001, temp, temp, hum, hum, ts, ts, report, report_interval); if (!root) { return NULL; } char *payload json_dumps(root, JSON_COMPACT); json_decref(root); return payload; // 使用方负责 free() }JSON_COMPACT标志会去掉所有多余空白得到最短的传输字符串省流量。发送完成后调用free(payload)释放。这里浮点数的序列化格式由jansson内部处理默认保留足够多的有效位云端解析完全没问题如果你要控制小数位数可以先把浮点数转成固定小数位字符串再塞进去。4.3 解析云端下发的配置MQTT回调收到字符串后为了避免长时间阻塞网络回调我习惯只把原始数据拷贝到缓冲区然后在主循环任务里调用解析函数typedef struct { float temp_high; float temp_low; int report_interval; bool has_config; } device_config_t; device_config_t g_config {0}; void parse_config_from_json(const char* payload) { json_error_t err; json_t *root json_loads(payload, 0, err); if (!root) { printf([JSON] invalid config: %s\r\n, err.text); return; } json_t *high json_object_get(root, temp_high); json_t *low json_object_get(root, temp_low); json_t *interval json_object_get(root, report_interval); if (json_is_real(high)) { g_config.temp_high json_real_value(high); } if (json_is_real(low)) { g_config.temp_low json_real_value(low); } if (json_is_integer(interval)) { g_config.report_interval json_integer_value(interval); } g_config.has_config true; json_decref(root); }注意json_loads的flags参数这里我用了0要求输入必须是一个完整且格式严格的JSON值不能让多余字符混进来。MQTT回调的数据一般来说边界清晰这种严格模式反而能帮你挡住很多异常报文。4.4 UART加DMA接收JSON时如何定界串口接收不定长的JSON比网络更麻烦因为UART没有天然的报文边界。有人按固定大小接收有人靠超时判断实际操作中我试过最稳的办法是用DMA加空闲中断一帧收完认为是一次完整数据然后送到解析器。收到一帧之后不要急着解析。很多传感器会把配置和应答拼成多段发送一次DMA中断拿到的可能只是半截JSON。我的处理方式是先把数据存进环形缓冲区然后尝试json_loads解析如果返回NULL并且错误原因是“unexpected end of data”或者文件未结束就说明数据不完整继续等下一包。等拼接完整后再解析。这个逻辑在串口透传WiFi模块时经常用到如果没有这个熔断机制调试半年你都不知道为什么JSON老是多一半。5. 常见问题与排查技巧实录5.1 解析嵌套深的JSON导致栈溢出jansson的解析器是递归下降的。默认递归深度上限是2048层理论很深但MCU的栈通常只有几KB递归个几十层栈就可能越界。有一回我在F103上解析一个云端返回的物联网平台响应结构嵌套确实比较深结果程序跑一会儿HardFault。排查方法就是在HardFault_Handler里查看栈指针最后定位到json_loads内部的递归调用。解决办法三个思路一是给任务或主循环的栈加大到8KB以上二是解析前先扫一遍字符数超过阈值直接拒绝三是在json_loads的flags里使用JSON_DECODE_ANY加上自定义深度限制jansson 2.9之后提供了带depth参数的json_loads重载吗其实不是重载而是用json_loadb加JSON_PARSE_REJECT_DUPLICATES这些真正控制深度的是在json_loads内部有宏。自己在外面加限制配合一下就可以。我这里推荐最省心的办法直接把主任务栈调大同时限制JSON报文长度不超过2KB。正常情况下设备间通信的JSON报文根本不会超过这个数嵌套深度也不会超过20层。5.2 中文和特殊符号被转义成\uXXXX有一次设备上报的固件版本里带中文上位机解析后发现字符串变成了一串\uXXXX。这是因为json_dumps默认不保证输出可读的UTF-8而是会把不可打印字符和部分Unicode字符转义。想要输出原始中文使用JSON_ENSURE_ASCII的反向JSON_PRESERVE_ORDER不行得在json_dumps时加上JSON_ENSURE_ASCII吗这里不要记反了JSON_ENSURE_ASCII强制所有非ASCII字符转成\uXXXX适合网络传输保证字符集一致。不加该标志则中文直接以UTF-8字节输出更适合本机调试显示串口助手可以看到中文。所以我调试的时候不加JSON_ENSURE_ASCII上线发云平台时反而经常加上避免不同端字符集判断差异导致设备名称乱码。这个细节不看源码还真容易踩。5.3 长时间运行后malloc失败设备连续跑了几天突然不上报了抓日志发现malloc返回了NULL。我排查了很久最终确定是内存碎片累积导致的。频繁解析释放JSON小对象散布在堆里碎片化严重。jansson本身没有碎片整理能力唯一能做的是两点。第一在RTOS里给jansson单独划一块内存池所有JSON内存从池里分配池的大小按报文上限估算比如2KB报文解析时峰值内存大约4KB上报构建时大约1KB预留50%余量。第二代码结构上尽量降低峰值占用解析完成后立刻释放不需要的中间节点不要长时间持有大对象。实测下来用内存池后连续运行一个月不会出现分配失败。5.4 编译报错与头文件兼容很多人在MDK里把jansson加进去后编译报__STDC_VERSION__未定义或者inline关键字不识别。这多半是因为MDK默认C89模式而jansson源码用了较多的C99特性。在Options对话框里C/C - Language选择C99基本能解决大部分问题。如果还报缺头文件比如找不到sys/stat.h那就按2.2节的方法做一个精简的config.h把HAVE_*宏都显式定义好然后全局包含。记住不要动jansson源码别在源码里加上#include stm32f4xx.h这类平台头文件以后换芯片会遇到一堆烂摊子。5.5 排查问题速查表现象常见原因处理方式json_loads返回NULL报文不完整、引号未闭合打印error.line/column检查报文尾float字段解析成0云端发的是字符串类型用json_is_string分支转换程序运行几分钟后内存暴涨忘记json_decref逐函数检查用内存统计宏定位HardFault栈溢出或野指针加大栈、检查引用计数json_dumps输出乱码中文字符集未统一传输时加JSON_ENSURE_ASCII编译报inline错误没开C99MDK里切换到C99模式6. 体积优化与性能预估6.1 裁剪不需要的功能jansson默认编译进STM32的Flash占用大概在15KB到25KB之间取决于编译优化等级和裁剪了多少模块。如果你Flash紧张有几个地方可以省。第一个是pack_unpack.c。如果项目中只用json_object和json_loads完全不用json_pack和json_unpack可以把这个文件从工程里移出去能省下2-4KB。但要确认代码里没有调用这两个函数否则链接阶段报错。第二个是strconv.c里对long double的支持如果MCU的编译器没开对应选项这块代码本身就是死的不用管。第三个是dump.c里的JSON_INDENT排版功能它只影响带缩进格式化输出。代码里不加缩进标志这块代码也会被编译器优化掉一部分。用-Os编译比-O2平均能再省15%的Flash代价是执行速度略慢。在STM32上如果JSON报文不大我更推荐-Os毕竟Flash寸土寸金。6.2 性能量级参考MCU上JSON解析耗时主要取决于报文长度和结构复杂度。以Cortex-M4 168MHz为例解析一个200字节左右的JSON对象时间大约在200到400微秒序列化一个同样大小的对象大约在100到200微秒。这个量级对秒级上报、分钟级配置下发完全够用。如果你的业务对实时性要求苛刻比如毫秒级控制链路那JSON本身就不合适该考虑二进制协议。要提醒的是不要在中断服务函数里调用json_loads或json_dumps一个包解析几百微秒对实时系统来说是不可接受的。标准做法是中断里放环形队列主循环或低优先级任务里做解析和序列化。6.3 什么时候不该用JSONJSON对STM32最大的代价是解析时间和内存碎片。如果你的通信频率高、帧结构固定、数据量大那干脆用二进制结构体配合CRC性能最优。JSON适合的是设备接入云平台、WiFi模块配置、调试命令交互、配置管理这类低频、小包、需要人工可读的场景。jansson在STM32上的定位是“好用但要有节制”它的价值在于让设备和上位机之间的接口更清晰代码更容易维护而不是追求极致的吞吐量。最后分享一点个人习惯做STM32的JSON解析我逐渐养成了几个小习惯。第一所有JSON相关的函数统一命名前缀比如app_json_这样看代码扫一眼就知道哪里涉及JSON。第二关键的解析操作全部封装成独立的函数输入缓冲区和输出结构体分开方便写单元测试。第三在每个json_decref的地方都注释一下这个节点是从哪里创建出来的后续排查引用计数问题能节省很多时间。如果是从零开始搞我建议先在PC上用最小工程把jansson跑通打印一些JSON样例然后再往STM32上搬。PC上调试报错信息清楚环境变量也简单能帮你快速理解这个库的行为习惯。等你把json_pack、json_loads、引用计数玩熟了再放到MCU上就是水到渠成的事。