1. 项目缘起为什么要在8位MCU上折腾JSON在嵌入式开发领域尤其是资源极其受限的8位微控制器MCU世界比如Microchip的PIC系列或Atmel/Microchip的AVR系列开发者们长期以来的信条是“能省则省”。一个字节的RAM、几十个字节的Flash都弥足珍贵。在这种背景下谈论JSONJavaScript Object Notation这种源自Web世界的、带冗余字符的数据交换格式听起来就像是在螺蛳壳里做道场甚至有点“杀鸡用牛刀”的荒谬感。然而现实的需求往往比技术教条更有说服力。我最近接手的一个智能农业传感器节点项目就让我不得不重新审视这个问题。节点使用一颗ATmega328P没错就是Arduino Uno那颗芯片作为主控需要将采集到的温度、湿度、土壤电导率等数据通过一个低功耗的2.4GHz射频模块发送到网关。最初我们采用了自定义的二进制协议第一个字节是包头第二个字节是传感器ID后面跟着几个字节的整型数据。这套方案在初期跑得飞快RAM和Flash占用几乎可以忽略不计。问题出在扩展性和调试上。当我们需要增加一个新的传感器类型或者调整数据精度比如从整型改成浮点固件和网关的解析代码必须同步修改任何一方的版本不匹配都会导致数据乱码。更头疼的是现场调试通过串口打印出来的是一串十六进制码除非手边有协议文档否则根本看不懂数据是什么。网关端的同事也抱怨每次对接新节点或新数据都要写一堆位操作和字节序转换的代码容易出错。这时JSON的优势就凸显出来了。它自描述、可读性强、结构灵活几乎是现代API通信的事实标准。如果传感器节点能直接输出{temp: 25.6, humi: 65.2, ec: 1.8}这样的字符串那么网关端可以直接用现成的、健壮的JSON库如Python的json模块、JavaScript的JSON.parse()进行解析前后端开发彻底解耦。调试时串口助手上看到的就是一目了然的明文数据。这个诱惑太大了。于是挑战变成了如何在仅有2KB RAM、32KB Flash的ATmega328P上实现一个能解析和生成简单JSON数据的轻量级解码器这就是本次项目的核心目标——为PIC/AVR这类8位MCU打造一个真正可用的、资源消耗极低的JSON处理核心。2. 核心设计哲学放弃“全能”追求“够用”在开始动手写代码之前首先要明确设计边界。在资源受限环境下试图实现一个完全符合RFC 8259标准的、功能齐全的JSON解析器是自杀行为。那样的库如cJSON动辄需要十几KB的RAM和Flash对于8位MCU来说是不可承受之重。因此我们的设计必须围绕“场景驱动”和“资源最小化”两个原则展开限定数据结构不支持完整的JSON标准。通常只支持对象Object和数组Array的嵌套层级不超过2-3层。键Key必须是简短的字符串常量最好在编译时确定。值Value类型限定为整数int、浮点数float、布尔值true/false、字符串String和空值null。复杂的转义字符、Unicode可能不予支持或仅支持有限集合如\n,\t,\。流式解析与生成放弃DOM文档对象模型式解析。DOM解析需要先将整个JSON字符串读入内存然后在内存中构建一棵完整的树形结构这非常耗内存。我们必须采用流式Streaming或事件驱动SAX风格的解析方式。解析器逐个字符地读取输入在遇到特定结构如一个键值对结束时调用用户预先注册的回调函数并立即丢弃已处理过的字符。这样只需要几十字节的缓冲区来存储当前正在解析的令牌Token即可。零动态内存分配严禁使用malloc或new。所有内存都在编译时静态分配或在栈上分配。这意味着解析器内部的工作缓冲区、状态机变量都必须是固定大小的全局数组或局部变量。编译器友好大量使用const、static关键字将常量数据放入Flash程序存储器而非RAM。对于AVR要使用PROGMEM宏对于PIC可能需要使用const far等编译器扩展。尽可能利用编译时计算如sizeof、strlen对常量字符串的计算减少运行时开销。基于以上原则我决定实现一个基于状态机的零拷贝令牌解析器。它的核心工作不是构建一个数据对象而是像分词器一样告诉用户“嗨我找到了一个键temp它的值是一个数字数字的字符范围是从输入字符串的第8个字节到第11个字节”。至于如何将这个字符范围转换成整数或浮点数由用户根据自身需求决定。这实现了最大的灵活性并将计算密集型操作数字转换的主动权交给了用户。3. 实现详解手搓一个微型JSON令牌解析器接下来我们深入到代码层面。我将以C语言为例展示核心解析器的实现。为了极致轻量我们甚至不依赖标准库的ctype.h而是自己实现字符判断。3.1 定义核心数据结构与状态首先定义解析器状态和令牌类型。// json_parser.h #ifndef JSON_PARSER_H #define JSON_PARSER_H #include stdint.h #include stdbool.h // 令牌类型 typedef enum { TOKEN_UNDEFINED, TOKEN_OBJECT_START, // { TOKEN_OBJECT_END, // } TOKEN_ARRAY_START, // [ TOKEN_ARRAY_END, // ] TOKEN_STRING, // string TOKEN_NUMBER, // 123, -45.67 TOKEN_BOOLEAN, // true, false TOKEN_NULL, // null TOKEN_COLON, // : TOKEN_COMMA, // , } json_token_type_t; // 一个令牌描述了解析到的一段内容 typedef struct { json_token_type_t type; const char* start; // 令牌在输入字符串中的起始位置 uint16_t length; // 令牌的长度字符数 } json_token_t; // 解析器状态机状态简化版 typedef enum { STATE_START, STATE_IN_OBJECT, STATE_IN_ARRAY, STATE_AFTER_KEY, STATE_AFTER_VALUE, STATE_ERROR } json_parser_state_t; // 解析器主结构 typedef struct { const char* json; // 指向输入JSON字符串的指针 uint16_t pos; // 当前解析位置 uint16_t length; // 输入字符串总长 json_parser_state_t state; json_token_t current_token; bool has_error; } json_parser_t; // 回调函数类型定义 // 当解析器识别出一个完整的令牌时会调用此函数 typedef void (*json_token_callback)(json_token_t* token, void* user_data); // 初始化解析器 void json_parser_init(json_parser_t* parser, const char* json_str, uint16_t len); // 执行一步解析返回false表示解析结束或出错 bool json_parser_step(json_parser_t* parser, json_token_callback callback, void* user_data); // 判断解析是否完成且无错误 bool json_parser_is_done(json_parser_t* parser); #endif // JSON_PARSER_H3.2 核心状态机解析逻辑解析器的核心是一个巨大的switch-case状态机在json_parser_step函数中实现。它逐字符查看输入根据当前字符和解析器状态决定下一步动作。// json_parser.c (部分核心代码) static void skip_whitespace(json_parser_t* parser) { while (parser-pos parser-length) { char c parser-json[parser-pos]; if (c || c \t || c \n || c \r) { parser-pos; } else { break; } } } bool json_parser_step(json_parser_t* parser, json_token_callback callback, void* user_data) { if (parser-pos parser-length || parser-has_error) { return false; } skip_whitespace(parser); if (parser-pos parser-length) { return false; // 全是空白解析结束 } char current_char parser-json[parser-pos]; json_token_t token {TOKEN_UNDEFINED, NULL, 0}; switch (current_char) { case {: token.type TOKEN_OBJECT_START; token.start parser-json[parser-pos]; token.length 1; parser-pos; parser-state STATE_IN_OBJECT; break; case }: token.type TOKEN_OBJECT_END; token.start parser-json[parser-pos]; token.length 1; parser-pos; // 状态转移逻辑如果之前在对象内可能回到上一层状态这里简化处理 break; case [: // ... 类似处理数组开始 break; case ]: // ... 类似处理数组结束 break; case :: token.type TOKEN_COLON; token.start parser-json[parser-pos]; token.length 1; parser-pos; parser-state STATE_AFTER_KEY; // 期待一个值 break; case ,: token.type TOKEN_COMMA; token.start parser-json[parser-pos]; token.length 1; parser-pos; // 在对象或数组中逗号之后期待下一个键或值 break; case \: { // 字符串开始 uint16_t start_pos parser-pos; parser-pos; // 跳过开头的引号 uint16_t str_start parser-pos; bool escaped false; while (parser-pos parser-length) { char c parser-json[parser-pos]; if (!escaped c \\) { escaped true; parser-pos; continue; } if (!escaped c \) { // 找到字符串结尾 token.type TOKEN_STRING; token.start parser-json[str_start]; token.length parser-pos - str_start; parser-pos; // 跳过结尾的引号 break; } if (escaped) { // 处理转义字符这里简化只跳过下一个字符 escaped false; } parser-pos; } if (token.type TOKEN_UNDEFINED) { parser-has_error true; // 没有找到匹配的结尾引号 return false; } break; } case t: // 可能是 true if (parser-pos 3 parser-length parser-json[parser-pos1] r parser-json[parser-pos2] u parser-json[parser-pos3] e) { token.type TOKEN_BOOLEAN; token.start parser-json[parser-pos]; token.length 4; parser-pos 4; } else { parser-has_error true; } break; case f: // 可能是 false // ... 类似判断 false break; case n: // 可能是 null // ... 类似判断 null break; default: // 处理数字或错误 if ((current_char 0 current_char 9) || current_char -) { token.type TOKEN_NUMBER; token.start parser-json[parser-pos]; // 循环直到数字结束 while (parser-pos parser-length) { char c parser-json[parser-pos]; if ((c 0 c 9) || c . || c - || c || c e || c E) { parser-pos; } else { break; } } token.length parser-pos - (token.start - parser-json); } else { // 非法字符 parser-has_error true; return false; } break; } // 如果成功识别了一个令牌调用回调函数 if (token.type ! TOKEN_UNDEFINED callback ! NULL) { callback(token, user_data); } parser-current_token token; return true; }这个解析器的精妙之处在于“懒惰”。它识别出TOKEN_NUMBER后只是标记了数字字符串的起止位置并不进行实际的atoi或atof转换。转换工作留给回调函数用户可以根据自己的需求决定是将“123.45”转换成int、long还是float甚至可以直接当作字符串使用。这避免了引入浮点库在部分8位MCU上浮点运算软件库很大和大的转换缓冲区。3.3 在应用层使用解析器假设我们的传感器数据JSON是{t:25.6,h:65,l:true}。下面是如何使用这个解析器来提取数据的示例#include json_parser.h typedef struct { float temperature; int humidity; bool led_status; } sensor_data_t; sensor_data_t my_data; char current_key[5] {0}; // 假设键最长4个字符\0 bool key_parsed false; void my_token_callback(json_token_t* token, void* user_data) { sensor_data_t* data (sensor_data_t*)user_data; switch(token-type) { case TOKEN_STRING: // 这个字符串可能是一个键 if (token-length sizeof(current_key)) { strncpy(current_key, token-start, token-length); current_key[token-length] \0; key_parsed true; } break; case TOKEN_NUMBER: if (key_parsed) { // 根据当前的键将数字字符串转换为具体值 char num_str[16]; strncpy(num_str, token-start, token-length); num_str[token-length] \0; if (strcmp(current_key, t) 0) { // 使用简单的atof实现或自己写的转换函数 >特性本文所述微型解析器某嵌入式JSON库 (精简配置)说明Flash占用 (代码)~1.2 KB~8.5 KB我们的解析器仅实现状态机和令牌识别不包含任何数据转换或动态构造功能。RAM占用 (全局)~20 字节~150 字节 (不含动态分配)我们的解析器只有几个状态变量和一个小结构体。库通常有全局缓存和配置结构。栈峰值占用~50 字节~200 字节我们的解析器函数调用层次浅局部变量少。库的递归解析或内部缓冲区会消耗更多栈空间。解析速度极快中等流式解析无内存分配无复杂树形操作单次线性扫描。功能完整性低高我们只支持有限语法、有限转义、无Unicode。库通常支持完整标准。易用性低高需要用户自己写回调和处理逻辑。库提供友好的get/setAPI。从对比可以看出我们的方案用功能和易用性换取了极致的空间效率。对于只需要解析固定格式、简单JSON的8位MCU应用这个交换是值得的。几个关键的优化技巧将字符串常量放入Flash解析器内部的错误信息字符串、状态名称等必须用PROGMEMAVR或类似机制存储避免占用宝贵的RAM。const char error_msg[] PROGMEM JSON syntax error;避免使用标准库的ctype.h函数如isspace()、isdigit()可能不是体积最小的实现。自己用简单的字符比较来代替编译器能更好地优化。#define IS_WHITESPACE(c) ((c) || (c) \t || (c) \n || (c) \r) #define IS_DIGIT(c) ((c) 0 (c) 9)使用更小的整数类型在保证足够索引范围的前提下解析器内部的pos、length可以使用uint16_t甚至uint8_t而不是int或size_t。内联关键函数对于skip_whitespace这类非常小且频繁调用的函数可以声明为static inline减少函数调用的开销。5. 避坑指南8位MCU上处理JSON的常见陷阱在实际项目中踩过不少坑这里总结几点希望能帮你绕开陷阱一栈溢出是隐形杀手在回调函数my_token_callback中我使用了strncpy和strcmp。这些函数在标准库实现中可能不是重入的并且会使用内部缓冲区。在中断服务程序ISR中调用解析器或者递归调用解析器回调时极易导致栈混乱。更安全的做法是避免在回调中使用标准库字符串函数而是直接比较令牌的start指针和长度。// 更安全的键比较避免使用strcmp if (token-type TOKEN_STRING token-length 1 *(token-start) t) { // 键是“t” }陷阱二数字转换的精度与性能自己实现simple_atof时要格外小心。一个健壮的、支持科学计数法的浮点转换函数体积可能很大。如果MCU没有硬件浮点单元8位MCU基本都没有软件浮点运算会非常慢。评估你的数据范围如果温度值范围是-40.0到85.0精度只需0.1那么完全可以将其乘以10作为整数-400到850来传输和解析彻底避免浮点数。这就是所谓的“定点数”思想。陷阱三输入缓冲区与内存碎片即使解析器本身是零拷贝的你的JSON输入字符串也需要一个来源。通常来自串口接收缓冲区或射频模块的接收缓冲区。务必确保这个缓冲区是连续、完整的。在异步通信中要处理好数据包边界防止半个JSON字符串被送入解析器。另外避免使用动态增长的缓冲区应使用固定大小的环形缓冲区Ring Buffer。陷阱四错误恢复能力几乎为零这个轻量级解析器一旦遇到错误如缺少引号、括号不匹配通常就设置一个错误标志并停止。它没有复杂错误恢复如跳过错误部分继续解析的能力。因此保证输入JSON的语法绝对正确是上层应用的责任。在发送端如果可控应使用经过充分测试的JSON生成代码。在接收端可以考虑添加一个最基础的校验比如检查首尾字符是否是{和}或者简单计算括号是否匹配在解析前就过滤掉明显错误的数据包。6. 进阶思考生成与更复杂的场景我们主要讨论了解析Decoding。那么生成Encoding呢思路是类似的但更简单。我们可以实现一个类似printf的系列函数但专门用于生成JSON键值对。void json_begin_object(char* buffer, uint16_t* pos); void json_end_object(char* buffer, uint16_t* pos); void json_add_int(char* buffer, uint16_t* pos, const char* key, int value); void json_add_float(char* buffer, uint16_t* pos, const char* key, float value); void json_add_bool(char* buffer, uint16_t* pos, const char* key, bool value); // ... 其他类型 // 使用示例 char tx_buffer[128]; uint16_t pos 0; json_begin_object(tx_buffer, pos); json_add_float(tx_buffer, pos, t, 25.6); json_add_int(tx_buffer, pos, h, 65); json_add_bool(tx_buffer, pos, l, true); json_end_object(tx_buffer, pos); tx_buffer[pos] \0; // 添加字符串结束符 // 现在 tx_buffer 里就是 {t:25.6,h:65,l:true}生成器的核心是安全的缓冲区操作和数字到字符串的转换。同样数字转换是性能和体积的关键需要根据实际情况选择整数或定点数输出。对于更复杂的场景比如需要查询嵌套对象{sensor:{temp:25.6}}中的temp值我们的简易解析器就需要升级。可以在状态机中增加一个“路径匹配”逻辑。在回调函数中不仅记录当前键还维护一个简单的路径栈例如用一个字符串数组记录当前所在的键名当路径匹配“sensor.temp”时才触发值的处理。这会增加一些复杂度和RAM消耗但对于有限的嵌套深度仍然是可行的。最后别忘了测试。编写单元测试覆盖各种边界情况空对象{}、空数组[]、数字边界值、字符串转义字符、错误的输入如多余的逗号、缺失的引号。在PC上使用GCC或Clang测试通过后再交叉编译到目标MCU进行验证。资源受限环境的开发谨慎和充分的测试是保证稳定性的不二法门。这个项目让我深刻体会到在嵌入式开发中没有“银弹”只有针对特定场景的“最优解”。这个轻量级JSON解码器就是我们在有限资源与开发现代化需求之间找到的一个精巧平衡点。它不完美但足够解决实际问题并且让你对数据解析的本质有了更透彻的理解。当你下次看到那些功能庞大的库时或许会多一份审视我的项目真的需要它们全部吗