1. 项目缘起:为什么要在Aurix上折腾JSON库?
最近在做一个基于英飞凌Aurix TC234的网关项目,需要处理来自多个传感器的配置数据和状态上报。数据格式最初用的是自定义的二进制协议,虽然效率高,但每次增减字段或者调整结构,都得同步更新上位机配置工具和下位机解析代码,维护起来非常头疼。团队里有人提议:“要不试试JSON?现在很多云端接口和配置都用这个,通用性好。”这个想法立刻引起了我的兴趣,但也带来了一个核心疑问:在Aurix这种资源受限的汽车级MCU上,跑JSON解析和生成,到底现不现实?性能开销有多大?会不会把宝贵的CPU时间和内存给榨干了?
带着这些疑问,我决定动手做个实验。目标很明确:在Aurix TC234开发板上,实测两个在嵌入式领域比较有代表性的C语言JSON库——cJSON和JSMN。我不只是想看看它们能不能“跑起来”,更想摸清楚它们的“脾气”:谁解析更快?谁更省内存?在只有几百KB RAM的MCU上,处理一个嵌套了几层、带数组的典型设备状态JSON报文,会不会导致堆栈溢出?这些实战数据,对于后续是否在车规项目里引入JSON,以及如何选型,至关重要。
2. 实验环境搭建与核心考量
工欲善其事,必先利其器。实验的第一步是把环境搭好,并且明确测试的边界条件。这不是在PC上跑个Benchmark,所有选择都必须贴合Aurix TC234的实际应用场景。
2.1 硬件与工具链选择
我手头用的是英飞凌的AURIX™ TC234 Lite Kit开发板。这颗芯片是典型的Aurix TriCore架构,主频200MHz,片上集成512KB的Data SRAM和2.5MB的Program Flash。对于汽车电子控制单元来说,这个资源不算少,但和Linux服务器动辄几个G的内存比起来,还是得精打细算。
编译器用的是Tasking for TriCore v6.3r2,优化等级设置为-O2,这是一个在代码大小和运行速度之间比较平衡的选项。调试器是劳特巴赫的Trace32,用它来抓取精确的函数执行时间周期数(CPU Cycles)比用软件定时器更准确。所有测试代码都放在内部RAM中执行,以避免Flash访问延迟带来的性能干扰。
2.2 测试用例设计:模拟真实场景
测试用的JSON字符串不能太简单,也不能太学术化。我设计了一个模拟智能电池管理单元上报的JSON报文,它包含了我在实际项目中常见的几种数据结构:
{ "device_id": "BMS_Unit_001", "timestamp": 1685432100, "status": "normal", "voltages": [3.65, 3.67, 3.66, 3.64, 3.68, 3.63], "temperatures": {"sensor_a": 25.5, "sensor_b": 26.1, "sensor_c": 24.8}, "cell_balance": [true, false, true, false, false, true], "config": { "sample_rate": 100, "alarm_threshold": {"over_voltage": 4.2, "under_voltage": 2.8, "over_temp": 60} } }这个报文大约有350个字符。它包含了字符串、整数、浮点数、布尔值、数组(数值数组和布尔数组)、嵌套对象等JSON基本元素。解析它需要库能够完整地处理这些类型,并构建出对应的内存树或标记。
2.3 两个候选库的简要介绍与集成
cJSON:这是一个非常流行的、单文件C语言JSON解析器。它的特点是功能完整,API丰富,使用起来像操作一个链表树。你调用cJSON_Parse()后,会得到一个cJSON*的根节点指针,然后可以通过cJSON_GetObjectItem()、cJSON_GetArrayItem()等函数像遍历链表一样访问任何数据。它的缺点是,为了这种便利性,它会在堆上动态分配大量的小内存块来构建整个树形结构,每个键、值、对象、数组都是一个独立的cJSON结构体。
JSMN:它的全称是“JSON Scanner Minimalistic”,顾名思义,它追求极致的简洁和最小开销。JSMN本身只是一个“词法分析器”或“标记器”。它不会帮你分配任何内存,也不会构建树形结构。它的工作是把JSON字符串从头到尾扫描一遍,告诉你哪里是对象开始、哪里是键、哪里是值、值的类型是什么。这些信息以一个“标记”数组的形式返回,每个标记只包含类型、起始位置和长度。后续的数据提取工作,需要你根据这些标记信息,自己到原始的JSON字符串里去“抠”出来。
集成过程本身不复杂。对于cJSON,就是把cJSON.c和cJSON.h加入工程,注意其内部使用了标准库的malloc和free。在Aurix上,我重写了cJSON_malloc和cJSON_free这两个宏,将其指向我工程里经过验证的、线程安全的内存池接口,这是嵌入式开发的一个好习惯。对于JSMN,就更简单了,只有一个头文件jsmn.h,将其包含即可。
3. 内存使用深度剖析:静态与动态的博弈
在资源受限的MCU上,内存往往是比CPU速度更紧张的资源。因此,我把内存占用分析放在了性能测试之前。
3.1 cJSON:便利性背后的动态内存代价
cJSON的工作方式决定了它的内存使用模式是“解析时集中分配,使用后逐个释放”。当我调用cJSON_Parse()解析上面那个350字节的JSON字符串时,背后发生了一系列的malloc调用。
我通过重写的内存分配函数打了日志,统计结果如下:为了完整构建这个JSON的内存树,cJSON总共发起了28次内存分配请求。这包括:
- 1个根对象
- 7个顶层键值对(如
device_id,timestamp等) - 2个数组对象(
voltages和cell_balance) - 6个浮点数节点(
voltages数组内的元素) - 6个布尔值节点(
cell_balance数组内的元素) - 1个嵌套的
temperatures对象及其3个子节点 - 1个嵌套的
config对象及其子节点(sample_rate和嵌套的alarm_threshold对象)
每次分配的大小并不大,主要是cJSON结构体本身(在Tasking编译器下约为48字节)以及为字符串值分配的额外空间。但积少成多,所有分配的总内存开销达到了约1.8KB。这还只是解析一个报文!如果系统需要同时缓存多个报文,或者报文更大更复杂,内存压力会急剧上升。
注意:这里的1.8KB是峰值堆内存占用。这意味着在调用
cJSON_Delete()释放之前,这部分内存一直被占用。在内存只有几百KB的系统中,频繁地解析和释放不同报文,会导致堆内存碎片化,长期运行可能有内存分配失败的风险。
3.2 JSMN:极简主义与零拷贝哲学
JSMN采取了完全不同的策略。它的核心函数jsmn_parse()只需要两个外部资源:一个jsmntok_t类型的标记数组,和原始的JSON字符串。它不分配任何堆内存。
- 标记数组:这是一个需要用户预先定义好的静态数组,比如
jsmntok_t tokens[64];。每个标记只包含三个整数:类型(如对象、字符串、数字)、起始位置在字符串中的索引、长度。在我们的测试用例中,JSMN总共产生了42个标记。每个jsmntok_t结构体占12字节,因此整个标记数组的内存开销是固定的42 * 12 = 504字节,并且是栈上或静态存储区的开销,没有堆碎片问题。 - 零拷贝:JSMN不复制任何字符串。当它告诉你某个值的类型是字符串,起始位置是120,长度是5时,你需要自己从
json_string[120]开始,手动拷贝或处理这5个字符。这意味着,原始的JSON字符串必须在整个使用周期内保持有效且不被修改。这通常要求你将接收到的JSON报文存放在一个固定的缓冲区中。
内存占用对比总结
| 特性 | cJSON | JSMN |
|---|---|---|
| 内存分配方式 | 动态 (malloc),大量小对象 | 静态(用户预分配标记数组) |
| 峰值堆内存 | ~1.8 KB (解析测试用例) | 0 KB |
| 额外存储开销 | 每个节点约48字节结构体 | 每个标记12字节 |
| 数据访问方式 | 通过结构体指针直接访问已解析的值 | 需根据标记索引回原字符串提取 |
| 原始字符串 | 解析后可以释放 | 必须保持有效 |
| 适用场景 | 需要频繁、随机访问JSON树;结构复杂且多变 | 内存极度受限;仅需提取少数字段;流式解析 |
这个对比非常鲜明。cJSON用空间换取了时间和编程的便利性,而JSMN用更复杂的编程接口换取了极致的空间效率。如果你的应用内存充裕,且JSON结构复杂需要频繁遍历,cJSON是更舒适的选择。反之,如果你的内存以KB计,或者只需要从一个大JSON里提取一两个字段,JSMN几乎是唯一的选择。
4. 解析性能实测:CPU周期下的真相
内存占用是静态成本,而解析性能则关系到系统的实时响应能力。我使用Trace32在指令级精确测量了从调用解析函数开始,到完成解析(对于cJSON是得到树根,对于JSMN是得到所有标记)所消耗的CPU周期数。为了结果稳定,每个测试都循环执行了1000次取平均值。
4.1 cJSON性能表现
解析我们设计的测试JSON,cJSON平均耗时约 21,800 个CPU周期。在主频200MHz的TC234上,这大约是0.109毫秒。这个速度对于很多非实时性应用来说是完全可以接受的。
cJSON的解析过程可以粗略分为两步:1)词法分析(分词),2)语法分析与树构建。它的性能开销主要来自第二部分——为每一个识别出的元素创建cJSON节点、分配内存、拷贝字符串(对于字符串类型的值)。我们的JSON中有多个字符串键和值,还有浮点数(需要调用strtod进行字符串到浮点的转换),这些操作都比较耗时。
4.2 JSMN性能表现
JSMN的解析耗时仅为约 9,500 个CPU周期,换算成时间是0.0475毫秒。比cJSON快了一倍多。
这个结果在意料之中。JSMN只做纯粹的词法扫描,它像一台高速扫描仪,只识别JSON的语法结构(哪里是{,哪里是"key",哪里是:,value是什么类型),并记录下位置。它不做内存分配,不做字符串拷贝,也不进行数值转换。所有“重量级”的操作都被推迟到了用户按需提取数据的时候。
4.3 综合性能考量:解析 vs 访问
然而,性能比较不能只看解析时间。我们必须考虑“访问数据”的成本。
- 使用cJSON:解析完成后,获取
device_id的值只需要一行代码:char *id = cJSON_GetObjectItem(root, "device_id")->valuestring;。这个操作是O(1)的,因为它只是在已经构建好的链表中查找。 - 使用JSMN:解析完成后,你得到的只是一个标记数组。要获取
device_id,你需要:1)遍历标记数组,找到键为"device_id"的字符串标记;2)根据下一个标记(即值标记)的位置和长度,从原始字符串中手动截取出子字符串;3)如果需要字符串拷贝,还得自己malloc或strcpy。这个过程是O(n)的,并且包含了额外的处理逻辑。
因此,一个更公平的性能公式是:总耗时 = 解析耗时 + (访问次数 * 单次访问耗时)。
如果你的应用需要在解析后,反复、随机地访问JSON中的大量字段,那么cJSON的总体验效可能更高。如果你的应用模式是“解析一次,提取几个固定字段,然后就不再使用”,那么JSMN的“快速解析 + 按需慢速提取”模式总耗时可能更低。
5. 实际编码体验与避坑指南
纸上得来终觉浅,绝知此事要躬行。把这两个库集成到Aurix的工程里,并写出健壮的业务代码,过程中遇到了几个值得分享的坑。
5.1 使用cJSON的注意事项
- 内存分配器必须重定向:这是最重要的一步。默认的
malloc/free在无操作系统的嵌入式环境中可能不可用,或者不是线程安全的。务必在包含cJSON.h之前,定义CJSON_CDECL和CJSON_STDCALL(如果不需要可忽略),并重写cJSON_malloc和cJSON_free。我将其指向了一个带锁的内存池,确保了在多核Aurix(TC234有2个核)任务间调用的安全性。// 示例:重定向到内存池 #define cJSON_malloc(size) my_mempool_alloc(size) #define cJSON_free(ptr) my_mempool_free(ptr) - 检查每一个返回值:
cJSON_Parse()在失败时返回NULL。cJSON_GetObjectItem()在找不到项时也返回NULL。任何直接对返回指针进行解引用(如->valuestring)的操作,都必须在前一步进行非空判断,否则硬件错误(HardFault)随时可能发生。 - 字符串值的生命周期:通过
cJSON_GetObjectItem(item, "key")->valuestring获取的字符串指针,指向的是cJSON内部分配的内存。当你调用cJSON_Delete()释放整棵树时,这些字符串内存也会被一并释放。如果你需要长期持有这个字符串,必须立即用strdup或类似函数将其拷贝到自己的缓冲区中。 - 浮点数精度:cJSON内部使用C标准库的
strtod进行转换。在嵌入式环境中,如果启用了FPU,这没问题。但要注意,字符串形式的浮点数可能会带来精度损失和性能开销。对于固定精度的数值(如电压值3.65),有时用整数(3650,表示3.650V)在JSON中传递,在代码中再除以1000,是更高效、更精确的做法。
5.2 使用JSMN的实战技巧
- 预先分配足够的标记:
jsmn_parse()的第三个参数是标记数组的大小。如果JSON太复杂,标记数量超过这个大小,函数会返回JSMN_ERROR_NOMEM。一个保守的估计方法是:标记数 ≈ JSON中所有大括号、中括号、冒号、逗号、键、值的总数。对于不确定大小的JSON,可以采取两遍解析:第一遍用jsmn_parse()传入NULL标记数组,它会返回需要的标记数量;然后分配足够大的数组,再进行第二遍正式解析。 - 理解标记数组的结构:JSMN的标记是平铺在数组里的,但它通过
parent和size等字段隐含了层级关系。编写一个递归函数来打印或遍历标记结构,是上手JSMN的最佳方式。你需要自己处理对象、数组的嵌套关系。 - 手动提取数据:这是最繁琐但也最核心的部分。例如,要提取整数
sample_rate:
你需要自己写// 假设 tokens 是标记数组,json 是原始字符串 for (int i = 0; i < num_tokens; i++) { if (tokens[i].type == JSMN_STRING) { // 比较键名是否为 "sample_rate" if (jsoneq(json, &tokens[i], "sample_rate") == 0) { // 下一个标记就是值 jsmntok_t *v = &tokens[i+1]; char value_str[32]; memcpy(value_str, &json[v->start], v->end - v->start); value_str[v->end - v->start] = '\0'; int sample_rate = atoi(value_str); break; } } }jsoneq这样的辅助函数来比较字符串,自己处理数字转换。代码量会显著增加。 - 原始缓冲区管理:由于JSMN是零拷贝的,你必须确保存放原始JSON字符串的缓冲区生命周期足够长,并且在解析和访问阶段不会被其他任务修改。通常需要设计一个环形缓冲区或静态缓冲区池来管理这些报文。
6. 选型决策框架:没有银弹,只有权衡
经过上面的实验和分析,可以清楚地看到,cJSON和JSMN代表了嵌入式JSON处理的两种不同哲学。选择哪一个,不是一个技术优劣问题,而是一个工程权衡问题。我总结了一个简单的决策框架,可以根据你的项目需求来对号入座。
优先选择 cJSON 的情况:
- 项目内存相对充裕:有几十KB甚至上百KB的RAM可以用于动态内存分配,且对内存碎片化有监控和管理手段(如使用内存池)。
- JSON结构复杂且访问模式随机:需要频繁地、以任意顺序访问JSON中的多个字段,甚至需要修改JSON树并重新序列化成字符串。
- 开发效率优先:项目时间紧,希望使用成熟、API友好的库来快速实现功能,减少底层字符串处理带来的编码错误和调试时间。
- 需要修改或生成JSON:cJSON提供了完整的创建和修改JSON树的API,这在需要动态组装上报报文时非常方便。
优先选择 JSMN 的情况:
- 内存极度受限:RAM资源以KB计,必须杜绝任何不可预测的堆内存分配。静态分配是唯一选择。
- 仅需提取少数字段:协议固定,每次只需要从一个大JSON报文中提取出固定的几个字段(如状态码、时间戳),其他数据可以忽略。JSMN“快速扫描,按需提取”的模式效率最高。
- 处理流式JSON或超大JSON:JSON数据是分块到达的,或者整个JSON太大无法一次性装入内存。JSMN可以配置为增量解析模式,这是cJSON不具备的特性。
- 追求极致的解析速度和确定性:对实时性要求极高,需要确保解析阶段的时间开销是稳定且可预测的。JSMN的纯扫描操作非常稳定。
在Aurix TriCore上的特别建议:
对于典型的汽车电子应用,如车身控制器、电池管理系统等,我个人的倾向是:在通信协议层面,优先考虑更紧凑的二进制格式。但如果因为上下游生态(如与云端JSON接口对接)必须使用JSON,那么:
- 对于简单的配置下发(例如,通过UDS或DoIP下发的参数配置JSON),报文小,解析后立即使用并丢弃,JSMN是更安全、更节省资源的选择。它的静态特性避免了动态内存管理在长期运行中的潜在风险。
- 对于复杂的状态上报或数据聚合(例如,需要收集多个传感器数据,组装成一个复杂的JSON上报给网关),可以考虑使用cJSON。因为“生成”JSON是cJSON的强项,而且在上报前,数据已经在内存中以结构体形式存在,组装成JSON树的开销相对可控。关键是要使用定制的内存分配器,并将其生命周期限制在单个上报周期内,用完即删。
最后,无论选择哪个库,都强烈建议编写完善的单元测试,覆盖各种畸形的JSON输入(键值缺失、括号不匹配、非法字符等),确保解析器的鲁棒性不会成为系统的单点故障。在Aurix这样的安全关键系统中,数据的可靠解析是功能安全的基石之一。