JSON配置文件在嵌入式端的解析实战案例
通过实际案例讲解如何在嵌入式系统中高效解析JSON配置文件,提升配置管理灵活性与可维护性,适用于资源受限环境下的配置文件处理场景。
让配置“活”起来:一个嵌入式工程师的JSON实战手记
最近在调试一款基于STM32的工业传感器节点时,客户提出了这样一个需求:“能不能不改固件就能切换工作模式?”——这听起来简单,但背后却牵动了整个系统的架构设计。我们原本的参数都是靠宏定义和编译时决定的,换种配置就得重新烧录,现场维护成本极高。
于是,我和团队开始重新审视我们的配置管理方式。最终,我们选择了 JSON + cJSON + 静态内存池 的组合方案。今天我想以第一人称视角,把这段从“硬编码困局”到“灵活配置落地”的完整经历写下来,分享给正在面对类似挑战的你。
为什么我们放弃了#define?
先说背景:设备需要支持多种通信协议(Modbus、CAN、自定义串口)、不同采样频率、报警阈值、Wi-Fi连接信息等。早期做法是:
#define DEFAULT_BAUD_RATE 115200
#define ALARM_THRESHOLD 85
#define WIFI_SSID "FactoryNet"
结果呢?每来一个新项目,就要建一个分支,改一堆宏,测试、打包、烧写……一个月出三版固件成了常态。更糟的是,现场工程师根本不敢动任何参数,怕“改坏”。
直到有一次,客户临时要求将某台设备的上报周期从10秒改成30秒,而我们最近的一次OTA更新已经过去两个月。最后只能派人带下载器去现场重刷——那一刻我意识到: 配置必须脱离固件 。
JSON:不是时髦,而是刚需
我们考虑过几种替代方案:
- INI文件 :可读性尚可,但嵌套能力弱,解析器也得自己写;
- 二进制blob :效率高,但完全不可读,运维人员无法干预;
- XML :太重,光解析库就几万行代码,MCU上跑不动。
最终选了 JSON 。理由很实际:
- 文本格式,人类可读;
- 层级结构清晰,适合表达复杂配置;
- 工具链丰富,前端能生成,后端能校验;
- 最重要的是——有一个叫 cJSON 的小而美的C库。
为什么是cJSON?
市面上其实有不少JSON库,但我们测试了一圈后发现, cJSON几乎是为嵌入式量身定制的 。
它只有两个核心文件( cjson.c 和 cjson.h ),编译后占用Flash约 9KB (GCC -Os优化下),RAM峰值堆使用控制在 1.5KB以内 ,完全能在STM32F1这种老平台上跑起来。
而且它的API极其简洁:
cJSON *root = cJSON_Parse(json_string);
cJSON *item = cJSON_GetObjectItemCaseSensitive(root, "key");
// ... 处理数据
cJSON_Delete(root); // 别忘了释放!
短短三步,就把一串字符变成了可用的数据树。
实战案例:一次真实的WiFi配置解析
我们设备启动时要加载网络参数,原来的代码是这样的:
char ssid[] = "MyHome";
char passwd[] = "12345678";
uint8_t channel = 6;
现在换成JSON后,配置长这样:
{
"wifi": {
"ssid": "Office_5G",
"password": "secure@2024",
"channel": 36,
"dhcp": true
},
"sensor": {
"interval_sec": 5,
"calibration_offset": -0.3
}
}
对应的解析函数如下:
void load_configuration(const char *json_str) {
cJSON *root = NULL, *wifi = NULL, *sensor = NULL;
cJSON *ssid = NULL, *passwd = NULL, *chan = NULL;
cJSON *interval = NULL, *offset = NULL;
root = cJSON_Parse(json_str);
if (!root) {
LOG_ERROR("JSON parse failed near: %s", cJSON_GetErrorPtr());
return;
}
wifi = cJSON_GetObjectItemCaseSensitive(root, "wifi");
if (cJSON_IsObject(wifi)) {
ssid = cJSON_GetObjectItemCaseSensitive(wifi, "ssid");
passwd = cJSON_GetObjectItemCaseSensitive(wifi, "password");
chan = cJSON_GetObjectItemCaseSensitive(wifi, "channel");
if (cJSON_IsString(ssid) && ssid->valuestring) {
strncpy(g_cfg.wifi.ssid, ssid->valuestring, 32);
}
if (cJSON_IsString(passwd) && passwd->valuestring) {
strncpy(g_cfg.wifi.passwd, passwd->valuestring, 64);
}
if (cJSON_IsNumber(chan)) {
g_cfg.wifi.channel = chan->valueint;
}
}
sensor = cJSON_GetObjectItemCaseSensitive(root, "sensor");
if (cJSON_IsObject(sensor)) {
interval = cJSON_GetObjectItemCaseSensitive(sensor, "interval_sec");
offset = cJSON_GetObjectItemCaseSensitive(sensor, "calibration_offset");
if (cJSON_IsNumber(interval)) {
g_cfg.sensor.interval = interval->valueint;
}
if (cJSON_IsNumber(offset)) {
g_cfg.sensor.offset = offset->valuedouble;
}
}
cJSON_Delete(root); // 关键!否则内存泄漏
}
几个关键点值得强调 :
- 一定要调用
cJSON_Delete(),否则每次解析都会吃掉几百字节RAM; - 使用
CaseSensitive版本避免大小写歧义; - 所有访问前都用
cJSON_IsXXX()做类型检查,防止野指针崩溃; - 错误位置可通过
cJSON_GetErrorPtr()快速定位,极大提升调试效率。
内存问题来了:malloc能不用就不用
起初我们直接用了默认的 malloc/free ,但在连续解析几次大配置后,系统开始出现偶发性死机。查下来发现是 heap碎片化 导致后续分配失败。
嵌入式环境里,动态内存就像一把双刃剑:方便是真方便,危险也是真危险。
于是我们转向 静态内存池 方案。
自定义内存管理:把命运握在手里
cJSON允许我们替换内存函数:
#define cJSON__malloc json_pool_malloc
#define cJSON__free json_pool_free
然后实现自己的分配器:
static uint8_t json_memory_pool[512]; // 预留512字节
static size_t pool_used = 0;
void* json_pool_malloc(size_t size) {
void *ptr = NULL;
if (pool_used + size <= sizeof(json_memory_pool)) {
ptr = &json_memory_pool[pool_used];
pool_used += size;
} else {
LOG_WARN("JSON pool full! Requested: %u, Used: %u", size, pool_used);
}
return ptr;
}
void json_pool_free(void *ptr) {
// 简单场景下不做实际释放(一次性解析)
// 或者直接重置:pool_used = 0;
}
这样一来,内存行为变得完全可预测:最多用512字节,不会崩,也不会泄露。
⚠️ 提示:建议通过压力测试估算最大消耗。经验公式:每个JSON节点大约消耗 64~80字节 。如果你的配置有20个字段,预留1.5KB比较稳妥。
更进一步:大文件也能“边收边解”
有个项目要用LoRa接收远程配置,但整段JSON有近2KB,而设备只有4KB RAM,没法一次性缓存。
怎么办? 流式分片处理 上场了。
虽然 cJSON 本身不支持增量解析,但我们可以通过“环形缓冲 + 完整性检测”模拟实现:
#define RX_BUFFER_SIZE 256
static char rx_buffer[RX_BUFFER_SIZE];
static int buf_len = 0;
bool is_valid_json_fragment(const char *str, int len) {
// 临时解析,成功即返回true
cJSON *temp = cJSON_Parse(str);
if (temp) {
cJSON_Delete(temp);
return true;
}
return false;
}
void on_uart_byte_received(uint8_t byte) {
if (buf_len >= RX_BUFFER_SIZE - 1) {
buf_len = 0; // 溢出保护
return;
}
rx_buffer[buf_len++] = byte;
rx_buffer[buf_len] = '\0';
// 尝试解析当前内容是否构成完整JSON
if (is_valid_json_fragment(rx_buffer, buf_len)) {
load_configuration(rx_buffer);
buf_len = 0; // 成功则清空
}
}
这个方法的核心思想是: 不断尝试解析,直到收到完整的结构为止 。
优点很明显:
- 只需几百字节缓冲;
- 支持低速信道传输;
- 接收到即可处理,响应更快。
当然也有代价:频繁调用 cJSON_Parse 会增加CPU负担。所以我们在非关键任务中运行,并加了长度阈值(比如至少收到50字节才开始尝试解析)来优化性能。
我们解决了哪些实际问题?
场景一:多地区部署不再头疼
以前每个国家都要单独出固件。现在只需一份固件 + 多个JSON配置:
// config_cn.json
{
"region": "CN",
"wifi": { "country_code": "CN", "max_power_dbm": 20 },
"language": "zh"
}
// config_eu.json
{
"region": "EU",
"wifi": { "country_code": "DE", "max_power_dbm": 20 },
"language": "en"
}
设备上电时根据拨码开关或EEPROM标记自动加载对应配置, 真正实现“一固件走天下” 。
场景二:现场调试无需拆机
技术支持可以通过串口发送新的JSON配置:
send_config {"sensor":{"interval_sec":2,"alarm_high":90}}
设备收到后热更新参数并立即生效,省去了返厂或现场烧录的时间。
场景三:OTA失败也能自救
我们保留两份配置:Active 和 Backup。
每次新配置写入后先解析验证,成功再激活;若解析失败或设备重启后无法联网,则自动回滚到备份配置。 哪怕OTA出错,也不至于变砖 。
踩过的坑与避坑指南
坑1:忘记调用 cJSON_Delete()
后果:每次解析吃掉几百字节RAM,几次之后系统卡死。
✅ 解法:用 goto cleanup; 统一释放资源。
cleanup:
cJSON_Delete(root);
return;
坑2:字符串没有转义
用户编辑配置时输入了 "ssid": "My"Home" ,引号未转义,导致解析失败。
✅ 解法:下发前做JSON合法性校验;或在设备端提供友好的错误提示。
坑3:数值溢出
配置中写了 "brightness": 99999 ,程序用 uint8_t 存储,结果溢出成31。
✅ 解法:所有数值写入前做范围检查:
int val = brightness->valueint;
if (val >= 0 && val <= 100) {
led_set_brightness(val);
} else {
LOG_WARN("Invalid brightness: %d", val);
}
写在最后:配置自由才是真正的敏捷
回头看这一路,从“改个参数就要发版本”,到现在“远程推送一个文本文件就能完成调参”,变化的不只是技术,更是整个产品的交付逻辑。
JSON配置带来的不仅是灵活性,更是一种思维方式的转变 :
- 固件只负责能力,配置决定行为;
- 运维人员也能参与调整,降低技术门槛;
- 云端可以集中管理成千上万台设备的差异化设置。
如果你还在用 #define 管理参数,不妨试试引入 cJSON。哪怕只是把最常变动的那几个值抽出来做成JSON,也会让你在未来某次紧急修改中感激自己今天的决定。
如果你在实践中遇到解析性能、内存紧张或安全性方面的问题,欢迎留言交流。我也正在探索如何结合 CBOR(一种二进制JSON)来做更高效的本地存储,下次有机会再聊。
openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。
更多推荐



所有评论(0)