【双MCU项目复盘与优化】06 - 打造配置管理模块


1. V3 配置管理模块复盘 mdl_cfg

1.1 为了解决什么问题

  • 目前的配置参数,基本都是一些 uint8_t 变量,值通常是 0~3 之间,然后使用宏定义来进行具体参数定义,而不是使用枚举
  • 在早期开发的时候,我是把这些配置变量分别按照功能来封装成一个个结构体,同时使用一个联合体把这些结构体都封装起来
  • 在修改配置时,UI 或者语音识别会基于当前内部备份配置的基础上,把修改的内容打包成一个联合体类型的变量,通过队列传入一个叫管理者的任务,通过这个应用拿锁放锁来修改全局配置,修改之后再把新配置也打包成一个联合体通过队列传入串口发送任务, 该任务会把收到的配置发送给STM32
  • 简单描述一下配置修改在 ESP32-S3 需要经过的数据链路:
UI/语音识别 -> 队列 -> 管理者任务(拿锁 -> 放锁) -> 队列 -> 串口发送任务
  • 这样虽然最后成功运行了,但是实际使用起来,发现配置修改有 3~4s 的延迟,这样的延迟十分破坏使用体验,需要修改
  • 造成延迟的问题有二:
    • 配置的修改链路过长
    • 管理者读取、修改配置和 UI 内部同步备份配置时,都会对全局配置进行拿锁、放锁操作,而拿锁需要常常阻塞等待,哪怕设置了阻塞超时也是需要等待到超时时间

1.2 如何解决

1.2.1 方案推导

  • 首先,我们知道配置变量都是一些 uint8_t 的类型变量
  • 而 uint8_t 在 ESP-S3 的内部简单读写操作都是原子的,而在修改、读取这些变量的场景都是读和写,再加上这些变量的值都是使用有特殊含义的宏定义,所以不可能出现像“+=”这样的“读改写操作”
  • 再加上配置的使用都是需要最新有效值,每次读取的时候都需要是最新的。而在项目中,只有 UI 和语音控制会修改配置,其它的任务都是读取配置,而 UI 操作和语音控制一般不会同时进行,不需要担心有配置冲突
  • 所以,我们可以利用配置的 uin8_t 特性,让 UI 和语音控制直接修改配置,然后自动通知串口发送任务进行配置同步
  • 最好直接把这些操作进行封装,成为一个功能模块提供调用
  • 如果应用了这个方案,则配置修改链路则会变成如下:
             通知
UI/语音识别 --------> 串口发送任务
  • 将配置修改链路缩短 60%(5 步缩至 2 步),实时性更高,同时取消了一个互斥锁、两个队列,退役了管理者应用,使得整个项目更轻量
  • 任务通知无需分配消息内存、无需复制数据、无需排队等待,直接唤醒目标任务,在实时性和内存占用上都优于队列方案

1.2.2 具体实现

  • 封装一个 mdl_cfg 配置管理模块,管理配置的修改+通知、读取、数据持久化
  • 以对灯光模式配置修改、读取为例子,如下:
static volatile uint8_t _light_mode = CFG_LIGHT_MODE_OFF; // 定义并设置默认值

// 设置配置
void Mdl_Cfg_SetLightMode(uint8_t mode)
{
    if (_light_mode == mode)
    	return;
    
    _light_mode = mode;
    
    if(_configTaskHandle == NULL)
    	return;

    xTaskNotify(_configTaskHandle, CFG_LIGHT_CHANGE, eSetBits);
}

// 读取配置
uint8_t Mdl_Cfg_GetLightMode(void)
{
    return _light_mode;
}
  • 相应的,还需要提供通知任务注册函数、配置初始化函数,配置的类型定义、宏定义也可以放在 mdl_cfg.h 里面,数据持久化也需要有
  • 头文件如下:
#ifndef __MDL_CFG_H
#define __MDL_CFG_H

// 定义每个配置类型的位掩码
#define CFG_LIGHT_CHANGE (1 << 0)
// 其它配置类型位掩码 ....

// 配置参数定义
#define CFG_LIGHT_MODE_OFF 0   // 灯光:关闭模式
#define CFG_LIGHT_MODE_ON 1    // 灯光:开启模式
#define CFG_LIGHT_MODE_HUMAN 2 // 灯光:人体检测模式


// 供初始化配置的结构体类型定义
typedef struct
{
    uint8_t light_mode;
	
	// 其它配置变量 ....
	
} Cfg_Init_t;

void Mdl_Cfg_Init(Cfg_Init_t *init_cfg);
uint8_t Mdl_Cfg_CheckChange(TickType_t timeout_ms);

void Mdl_Cfg_LoadAll(void);
void Mdl_Cfg_BackupLight(void);
// 其它配置备份函数 ....

void Mdl_Cfg_SetLightMode(uint8_t mode);
uint8_t Mdl_Cfg_GetLightMode(void);
// 其它配置 API ....

#endif
  • 源文件如下:
static TaskHandle_t _configTaskHandle = NULL;

// 初始化函数
void Mdl_Cfg_Init(Cfg_Init_t *init_cfg)
{
    if (!init_cfg)
        return;

    Ret_t ret = bsp_nvs_init();  // 初始化 nvs
  
    if (ret != RET_OK)
    {
        LOGE("MDL_CFG", "bsp_nvs_init FAIL, config will NOT persist after reboot!");
    }
  
    _light_mode = init_cfg->light_mode;
    // 其它配置变量赋值 ....
}

// 注册函数
void Mdl_Cfg_RegChangeHandleTask(TaskHandle_t cfg_change_handle_task)
{
    if (cfg_change_handle_task != NULL)
    {
        _configTaskHandle = cfg_change_handle_task;
    }
}

// 检查是哪个配置发生了改变,供串口发送任务使用
uint8_t Mdl_Cfg_CheckChange(TickType_t timeout_ms)
{
    uint32_t ulNotifiedValue;
  
    if (xTaskNotifyWait(0, ULONG_MAX, &ulNotifiedValue, timeout_ms) == pdTRUE)
    {
        return (uint8_t)(ulNotifiedValue & 0x07); // 当前只使用了低3位,预留高位给未来扩展
    }
  
    return CFG_NONE_CHANGE;
}

// 从 NVS 中加载全部配置
void Mdl_Cfg_LoadAll(void)
{
    vTaskDelay(pdMS_TO_TICKS(200));  // 等待 NVS 就绪

    bsp_nvs_load_u8(_KEY_LIGHT_MODE, &_light_mode, _light_mode);
	// 加载其它配置 ....

    if (_configTaskHandle != NULL)
    {
        xTaskNotify(_configTaskHandle, CFG_LIGHT_CHANGE, eSetBits);
		// 其它通知 ...
    }
}

// 将当前配置备份到 NVS 中
void Mdl_Cfg_BackupLight(void)
{
    bsp_nvs_save_u8(_KEY_LIGHT_MODE, _light_mode);
    bsp_nvs_save_u8(_KEY_LIGHT_TIMEOUT, _light_tout);
    bsp_nvs_save_u8(_KEY_LIGHT_VALUE, _light_value);
}

2. 配置管理模块的 V4 升级

2.1 V3 带来的新问题

2.1.1 具体是什么问题

  • ESP32-S3 是双核 XTENSA,没有硬件缓存一致性
  • 虽然 uint8_t 读写是单指令原子操作,但是没有内存屏障时,一个核心的写入新值, 可能在几个 tick 内另一核心读到旧值,导致短暂的不一致,理论上另一个核心甚至可能会永远看不见这个新值
  • 当然也可以把调用 mdl_cfg 的任务都绑定到同一个核心上来解决这个问题,在 V3 的代码里面也确实是这样,但是为了使得代码更加健壮,我们还是应该在 mdl_cfg 内部直接解决这个问题

2.1.2 具体解决

  • 由于每次只修改或读取一个 uint8_t 变量,直接使用 C11 标准的原子库,更优雅、高效,它能保证多核同时读写而不出错
    • 相比于自旋锁,原子操作开销更小,且编译器保证内存屏障,适合单变量读/写场景
  • 例如:
#include <stdatomic.h>

atomic_uint8_t _light_mode = CFG_LIGHT_MODE_OFF;  // C11 支持原子类型的静态常量初始化

// 设置配置
void Mdl_Cfg_SetLightMode(uint8_t mode)
{
    if (atomic_load(&_light_mode) == mode)      return;
    
    atomic_store(&_light_mode,mode); // 原子写入
    
    if(_configTaskHandle == NULL) return;
    xTaskNotify(_configTaskHandle, CFG_LIGHT_CHANGE, eSetBits);
}

// 读取配置
uint8_t Mdl_Cfg_GetLightMode(void)
{
    return atomic_load(&_light_mode);
}
posted @ 2026-06-21 23:01  临祁  阅读(8)  评论(0)    收藏  举报