让配置“活”起来:一个嵌入式工程师的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); // 关键!否则内存泄漏
}

几个关键点值得强调

  1. 一定要调用 cJSON_Delete() ,否则每次解析都会吃掉几百字节RAM;
  2. 使用 CaseSensitive 版本避免大小写歧义;
  3. 所有访问前都用 cJSON_IsXXX() 做类型检查,防止野指针崩溃;
  4. 错误位置可通过 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)来做更高效的本地存储,下次有机会再聊。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐