JSON 详解与解析方法对比报告
基于 Clock 项目中
weather.c的实际代码,从零开始讲清楚 JSON 是什么、为什么用它、怎么解析它,以及项目用的”手搓解析”和专业库的对比。
第一部分:JSON 是什么?为什么要用它?
1.1 JSON 的本质——一种”文字格式的数据包”
想象你要从网络上请求天气数据,服务器需要把”城市名、天气状况、温度、天气代码”这几个数据发给你。服务器和你的单片机是两台完全不同的机器,如何才能让双方都看懂这些数据?
最简单的方式是:约定一种文本格式,双方都按照这个格式来读写数据。JSON(JavaScript Object Notation)就是目前互联网上最流行的这种”通用数据格式”。
1.2 JSON 长什么样
本项目中,向心知天气 API 发请求后,服务器会返回这样一段文字:
{
"results": [
{
"location": {
"id": "WTEMH46Z5N09",
"name": "Hefei",
"country": "CN",
"path": "Hefei,Hefei,Anhui,China",
"timezone": "Asia/Shanghai",
"timezone_offset": "+08:00"
},
"now": {
"text": "Cloudy",
"code": "4",
"temperature": "32"
},
"last_update": "2025-07-26T16:30:00+08:00"
}
]
}这就是服务器发过来的原始字符串,里面包含了我们需要的:城市名(name)、天气描述(text)、天气代码(code)、温度(temperature)。
1.3 JSON 的语法规则(非常简单)
| 语法元素 | 写法 | 含义 |
|---|---|---|
| 对象 | { "key": value } | 用花括号包裹,键值对集合 |
| 数组 | [ item1, item2 ] | 用方括号包裹,有顺序的列表 |
| 字符串 | "Hello" | 必须用双引号 |
| 数字 | 32 或 3.14 | 不加引号 |
| 布尔 | true / false | 小写 |
| 嵌套 | 对象里面可以再套对象或数组 | 形成树状结构 |
1.4 为什么选 JSON,而不是其他格式?
| 格式 | 优点 | 缺点 |
|---|---|---|
| JSON | 人类可读、格式简洁、各语言都支持 | 有冗余的引号和括号 |
| XML | 有标准、自描述性强 | 标签太啰嗦,体积大 |
| 纯文本(CSV) | 极简 | 没有层级结构,复杂数据无法表示 |
| 二进制协议 | 体积小、速度快 | 人类不可读,调试困难 |
结论: JSON 是”易读性”和”简洁性”的最佳平衡点,成为互联网 API 的默认标准。
第二部分:解析 JSON 的两种方法对比
方法 A:使用专业 JSON 库(cJSON)——标准做法
在 PC 或手机开发中,几乎都会使用专门的 JSON 解析库。嵌入式领域最流行的是 cJSON(轻量级,专为 MCU 设计)。
cJSON 解析同一个天气 JSON 的代码:
#include "cJSON.h"
bool parse_weather_with_cjson(const char *response, weather_info_t *info)
{
// 第一步:把 JSON 字符串解析成一棵"树"(内存中的结构)
cJSON *root = cJSON_Parse(response);
if (root == NULL) return false; // 解析失败(格式不对)
// 第二步:像查字典一样,按键名逐级找到要的数据
// 找 results 数组
cJSON *results = cJSON_GetObjectItem(root, "results");
if (!cJSON_IsArray(results)) goto cleanup;
// 取数组第0个元素
cJSON *first = cJSON_GetArrayItem(results, 0);
if (first == NULL) goto cleanup;
// 进入 location 对象
cJSON *location = cJSON_GetObjectItem(first, "location");
cJSON *name = cJSON_GetObjectItem(location, "name");
if (cJSON_IsString(name)) {
strncpy(info->city, name->valuestring, 31);
}
// 进入 now 对象
cJSON *now = cJSON_GetObjectItem(first, "now");
cJSON *text = cJSON_GetObjectItem(now, "text");
cJSON *code = cJSON_GetObjectItem(now, "code");
cJSON *temp = cJSON_GetObjectItem(now, "temperature");
if (cJSON_IsString(text)) strncpy(info->weather, text->valuestring, 15);
if (cJSON_IsString(code)) info->weather_code = atoi(code->valuestring);
if (cJSON_IsString(temp)) info->temperature = atof(temp->valuestring);
// 第三步:释放内存(cJSON 在解析时动态分配了很多内存)
cleanup:
cJSON_Delete(root); // 必须手动释放,否则内存泄漏!
return true;
}cJSON 工作原理:
原始字符串 → cJSON_Parse() → 内存中的树形结构
root (对象)
│
"results" (数组)
│
[0] (对象)
┌────┴────┐
"location" "now"
(对象) (对象)
┌──┘ ┌──┤──┐
"name" "text" "code" "temperature"
"Hefei" "Cloudy" "4" "32"
cJSON 把整个 JSON 字符串变成一棵树,然后你用键名在树上查找节点,取出值。
方法 B:项目中的”手搓解析”——strstr + sscanf
项目中没有用 cJSON,而是用两个标准 C 函数自己解析:
实际代码(weather.c):
bool parse_seniverse_response(const char *response, weather_info_t *info)
{
// strstr(大字符串, 要找的子串) → 返回子串在大字符串中的位置(指针)
response = strstr(response, "\"results\":");
if (response == NULL) return false;
const char *location_response = strstr(response, "\"location\":");
if (location_response == NULL) return false;
// 找到 "name": 出现的位置,然后用 sscanf 提取引号里的内容
const char *loaction_name_response = strstr(location_response, "\"name\":");
if (loaction_name_response) {
// 格式字符串解读:
// "name": "%31[^\"]"
// ↑找到"name":后,跳过空格,匹配一个最多31字符且不包含"的字符串
sscanf(loaction_name_response, "\"name\": \"%31[^\"]\"", info->city);
}
const char *now_response = strstr(response, "\"now\":");
if (now_response == NULL) return false;
const char *now_text_response = strstr(now_response, "\"text\":");
if (now_text_response) {
sscanf(now_text_response, "\"text\": \"%15[^\"]\"", info->weather);
}
const char *now_code_response = strstr(now_response, "\"code\":");
if (now_code_response) {
sscanf(now_code_response, "\"code\": \"%d\"", &info->weather_code);
}
char temperature_str[16] = { 0 };
const char *now_temperature_response = strstr(now_response, "\"temperature\":");
if (now_temperature_response) {
if (sscanf(now_temperature_response, "\"temperature\": \"%15[^\"]\"", temperature_str) == 1)
info->temperature = atof(temperature_str);
}
return true;
}第三部分:核心函数逐行解释
3.1 strstr ——在字符串中”搜索”
const char *strstr(const char *str, const char *target);- 功能:在
str里搜索target第一次出现的位置 - 返回:指向找到位置的指针;找不到返回 NULL
图解:
原始字符串:
{ "results": [{ "location": { "name": "Hefei" ...
↑
strstr(response, "\"name\":") 返回这里的指针
此时指针 loaction_name_response 指向:
"name": "Hefei", "country": "CN" ...
↑ 从这里开始
为什么要逐层往里找? 因为 JSON 中同一个键名可能出现多次。 比如 “name” 在整个响应里可能出现很多次, 先定位到 “location”: 的位置,再在 location 这一段里找 “name”, 确保找到的是城市名,而不是其他地方的 “name”。
3.2 sscanf ——从字符串中”提取数据”
int sscanf(const char *str, const char *format, ...);- 功能:按照格式模板,从字符串中提取数据(和 printf 相反)
- 返回:成功提取的参数个数
格式模板详解(最难懂的部分):
sscanf(loaction_name_response, "\"name\": \"%31[^\"]\"", info->city);把格式字符串拆解来看(去掉转义符后):
模板: "name": "%31[^\"]"
匹配: "name": "Hefei"
↑↑↑↑↑
提取这里
%31[^\"] 的含义:
%:开始一个格式说明符31:最多读取 31 个字符(防止缓冲区溢出)[^]:字符集合,^表示”取反/排除”\":要排除的字符 = 双引号"- 完整含义:读取最多31个字符,遇到双引号就停下
所以这个模式的效果:从 "name": " 开始匹配,跳过固定文本,把引号里的内容(直到下一个引号)提取出来。
更多 sscanf 格式示例:
// 提取整数
sscanf("\"code\": \"4\"", "\"code\": \"%d\"", &weather_code);
// → weather_code = 4
// 提取多个值(Wi-Fi解析 esp_at.c中)
sscanf("+CWJAP:\"WiFi名\",\"aa:bb:cc\",6,-50",
"+CWJAP:\"%63[^\"]\",\"%17[^\"]\",%d,%d",
ssid, bssid, &channel, &rssi);
// → ssid="WiFi名", bssid="aa:bb:cc", channel=6, rssi=-50第四部分:两种方法全面对比
| 对比维度 | 手搓法(strstr+sscanf) | 专业库(cJSON) |
|---|---|---|
| 代码量 | 少,weather.c 只有 60 行 | 需要引入 cJSON.c/cJSON.h(约2000行) |
| 内存占用 | 极低,不动态分配内存,直接在原字符串上操作 | 需要大量动态内存(malloc),在内存中建树 |
| 执行速度 | 快,线性扫描一遍 | 较慢,先完整解析再查询 |
| 健壮性 | 弱,JSON 格式稍有变化(多一个空格)就可能失败 | 强,严格按照 JSON 标准解析,格式有一定容错 |
| 可维护性 | 差,每个字段都要写一段查找代码,容易出错 | 好,代码清晰,增加新字段很方便 |
| 适用场景 | 嵌入式、内存极小的 MCU、已知API不会变 | PC、Linux、资源充足的环境 |
| 调试难度 | 难,格式字符串一旦写错很难发现 | 易,解析出错有明确的错误信息 |
第五部分:为什么这个项目选择手搓法?
原因 1:内存极度受限
STM32F4 的 RAM 约 192KB,FreeRTOSConfig.h 中配置了堆大小仅 92KB:
#define configTOTAL_HEAP_SIZE (92 * 1024)cJSON 解析一个 JSON 时会大量调用 malloc,在内存里建立整棵树。
这个天气响应大约 300 字节,cJSON 解析后需要的内存远超原始大小(每个节点都要分配结构体)。
手搓法 直接在原始字符串上操作,不分配任何额外内存,零内存开销。
原因 2:API 格式固定不变
心知天气的响应格式由官方文档定义,项目作者知道它不会随意变化。 既然格式固定,用 strstr 精确定位就够了,完全不需要通用的完整 JSON 解析器。
原因 3:只需要少数几个字段
这个 JSON 里有十几个字段,但我们只关心 4 个(name, text, code, temperature)。 没有必要把整个 JSON 解析成树再去查,直接”点对点”找到目标字段即可。
第六部分:手搓法的隐患与改进建议
隐患 1:对空格敏感
// 当前代码期望格式:
sscanf(ptr, "\"name\": \"%31[^\"]\"", ...);
// ↑ 一个空格
// 如果API返回的是(没有空格):
// "name":"Hefei" ← 没有: 后面的空格,sscanf 就匹配失败!改进方案:格式模板中用空格匹配”任意数量空白字符”:
sscanf(ptr, "\"name\": %31[^\"]", ...); // 去掉外层引号,空格会自动跳过空白隐患 2:strstr 可能找错位置
如果JSON结构变化,比如某个地方新增了 "now_name": "xxx" 字段,
strstr(response, "\"name\":") 就可能找到错误的位置。
隐患 3:没有类型检查
cJSON 提供 cJSON_IsString()、cJSON_IsNumber() 等检查,
手搓法直接信任格式,如果服务器返回了意外的格式,程序可能提取到垃圾数据。
第七部分:如果要引入 cJSON 该怎么做(参考)
cJSON 是一个单文件库,只需要两个文件:cJSON.c 和 cJSON.h。
在 MCU 上使用时需要告诉它用哪个 malloc/free:
// 初始化时告诉 cJSON 用 FreeRTOS 的内存分配
cJSON_Hooks hooks = {
.malloc_fn = pvPortMalloc, // FreeRTOS 的 malloc
.free_fn = vPortFree // FreeRTOS 的 free
};
cJSON_InitHooks(&hooks);之后就可以正常使用 cJSON_Parse() 等函数了。
总结
服务器返回的原始字符串(约300字节):
+HTTPCLIENT:261,{"results":[{"location":{"name":"Hefei",...},"now":{"text":"Cloudy","code":"4","temperature":"32"},...}]}
OK
↓ 两种解析思路
手搓法(项目采用) 专业库 cJSON
strstr 定位到 "name": cJSON_Parse 完整建树
sscanf 提取引号内的值 cJSON_GetObjectItem 查节点
→ 直接写入结构体 → 从节点取值写入结构体
优势:零内存分配,极快 优势:健壮、易维护、扩展性强
适合:资源极少的 MCU 适合:资源充足的环境
对于你的学习建议:
- 先彻底搞懂
strstr和sscanf的用法(C标准库基础) - 理解这两个函数组合可以解析任何格式固定的文本
- 如果将来做复杂项目,引入 cJSON 是更好的选择
- 这两种方法各有适用场景,都是工程师必须掌握的技能