嵌入式多产品统一架构重构:从分支同步到单仓库多产品构建

一次真实的 SDK 架构重构之旅,把多分支同步开发变成单仓库编译时切换


一、痛点:多分支同步代码,是一种怎样的体验?

手头有一条 IoT 产品线,旗下多款产品共用同一套 WiFi SoC 程序。产品之间的差异不大——主要是蓝牙广播名称、硬件/软件版本号、射频参数、部分功能裁剪不同。有些产品还有衍生型号,差异进一步细化。

以前的开发方式:每个产品一个 Git 分支,改了一个产品的代码后,用对比软件逐个文件夹比对,把改动手动同步到其他分支。

效率低、易漏改、心智负担重——每次改一个 bug,都要在多个分支间来回横跳。更要命的是,衍生型号越多,同步工作量呈指数级增长。

终于有一天,我决定不再忍受这种开发方式。


二、架构设计:单仓库 + 产品配置层 + 统一数据模型

核心思路:一份代码,N 个产品配置文件,编译时通过 PRODUCT 和 VARIANT 变量动态切换。产品差异全部收敛到配置层,不改任何公共源码。

设计原则:三条红线

原则一:公共代码零分支。 任何产品差异都不允许通过 #ifdef PRODUCT_A 散落在源码里。所有差异必须通过配置层注入。

原则二:老设备零数据迁移。 产品升级后,Flash 中已存的数据不能丢。存储 key 的映射必须在适配层完成,对上层透明。

原则三:一改全改。 改 bug 只改一处,所有产品自动生效。编译时切产品,不需要改任何源码。

架构分层设计

整个架构分为四层,自下而上依次为:

层级职责关键文件
SDK 层 芯片 SDK 原始代码(不修改或最小修改) beken378/ 目录
适配层 产品差异注入点:射频参数、存储 key 映射 beken378/func/ 中的条件编译
配置层 产品所有差异的集中定义 product/<name>/product_config.h
应用层 产品无关的公共业务代码 demos/bbq/src/*.c

这四层的设计哲学是:差异向上收敛,公共代码向下隔离。应用层完全不知道有几个产品、有什么差异;SDK 层只通过适配层感知产品差异,不直接读配置文件。

第一层:product_config.h — 产品差异的单一真相源

每个产品一个配置文件,集中管理所有产品差异。这是整个架构的核心——产品有什么不同,看这一个文件就知道。

/* product/product_a/product_config.h */
#ifndef VARIANT
#define VARIANT 4  // 默认衍生型号
#endif

/* ---- 蓝牙广播名称后缀 ---- */
#if   (VARIANT == 1) || (VARIANT == 4)
  #define BLE_NAME_SUFFIX   'A'
#elif (VARIANT == 2) || (VARIANT == 5)
  #define BLE_NAME_SUFFIX   'B'
#endif

/* ---- 射频功率表选择编号(供 SDK 层使用)---- */
#define RF_PRODUCT_ID   VARIANT

/* ---- 硬件/软件版本号 ---- */
#define HARDWARE_VERSION   "1.6.0"
#define SOFTWARE_VERSION   "2.3.7"

/* ---- WiFi 配网超时(产品 A: 50s, 产品 B: 55s)---- */
#define WIFI_CONNECT_TIMEOUT_PERIOD   50*1000

/* ---- 采集通道数量 ---- */
#define PROBE_MAX_NUM   1  // 产品 A: 单通道; 产品 C: 2

设计要点: 配置文件里只有 #define 和 #if,没有函数实现。衍生型号的差异通过 VARIANT 条件编译处理——同一产品的所有衍生型号共用一个 product_config.h,通过 VARIANT 值区分。加新产品只需要写一个新文件,不动任何公共代码。

第二层:统一数据模型 — 一份代码兼容不同通道数

这是改造量最大的一层。原来每个产品的参数变量是标量(单通道设计),改成数组后,一份代码同时兼容单通道和多通道产品:

/* 改造前:标量,只支持单通道 */
sys_param_t g_sys_param;

/* 改造后:数组,通过 PROBE_MAX_NUM 控制通道数 */
probe_param_t g_probe_param[PROBE_MAX_NUM];

产品 A 设 PROBE_MAX_NUM=1,产品 C 设 PROBE_MAX_NUM=2,上层代码用 g_probe_param[i] 统一访问,不需要 #ifdef 区分。消除了约 800 行产品专属代码。

为什么这一层最重要? 因为它消灭了代码级的产品差异。之前单通道产品和双通道产品的业务逻辑走不同的代码路径,改一个 bug 要改两处。统一成数组后,所有产品走同一段逻辑,bug 只需改一次。

第三层:存储适配层 — 零数据迁移的关键

这是整个架构中最巧妙的设计。不同产品在 Flash 中存数据用的 key 不同,如果统一后直接改 key,老设备升级会丢数据(读不到原来的 key),引发售后问题。

解决方案:引入 probe_key() 适配函数,把产品特定的 key 映射逻辑收敛到一处:

/* product_storage.c — 适配层 */
uint32_t probe_key(uint8_t index) {
#if PRODUCT_IS_A
    return KEY_A_BASE + index;  // 产品 A 的原始 key
#elif PRODUCT_IS_C
    return KEY_C_BASE + index;  // 产品 C 的原始 key
#endif
}

上层代码只调 probe_key(i) 获取 key,完全不知道产品差异。老设备升级后仍读原来的 key,零数据迁移,零售后问题。

设计精髓: 这里的核心思路是"映射而非统一"。不要求所有产品用同一个 key,而是让每个产品继续用自己的 key,只在适配层做一次映射。上层代码看到的永远是统一的 probe_key(i) 接口,底下实际读哪个 key,由配置层决定。这样既统一了代码,又保护了存量数据。

第四层:Makefile 产品选择 — 编译时切换

通过 PRODUCT 和 VARIANT 变量动态选择产品配置,并注入 RF_PRODUCT_ID 供 SDK 层使用:

# application.mk
PRODUCT ?= product_a
VARIANT ?= 2

# 计算射频选择编号
RF_ID = $(shell expr $(VARIANT) + $(PRODUCT_BASE))

# 注入编译宏
CFLAGS += -D$(PRODUCT_FLAG) -DVARIANT=$(VARIANT) -DRF_PRODUCT_ID=$(RF_ID)

# 包含产品特定头文件目录
INCLUDES += -I./demos/bbq/product/$(PRODUCT)

编译时只需指定变量,不需要改任何源码:

make bk7238 PRODUCT=product_a VARIANT=4
make bk7238 PRODUCT=product_b VARIANT=2
make bk7238 PRODUCT=product_c VARIANT=1

射频功率表统一:SDK 层的条件编译

射频参数是产品差异中最敏感的部分。不同产品的发射功率不同,直接改 SDK 源码会污染原始代码。用 RF_PRODUCT_ID 条件编译,在 SDK 层实现差异化:

/* SDK 射频功率表(只改这一处)*/
#if (RF_PRODUCT_ID == 1 || RF_PRODUCT_ID == 2)
    INIT_TXPWR_VALUE(74, TXPWR_ELEM_INUSED),  // 产品 A 系列
#elif (RF_PRODUCT_ID == 12)
    INIT_TXPWR_VALUE(88, TXPWR_ELEM_INUSED),  // 产品 B
#endif

这里的 RF_PRODUCT_ID 由 Makefile 从 product_config.h 透传进来,SDK 层不需要 #include 任何产品配置文件,只依赖一个宏值。这样 SDK 的修改面被压到最小——只改功率表,不动其他逻辑。

架构设计总结:三层分离

把整个架构的精髓浓缩成一句话:

把"产品有什么不同"和"怎么实现差异"分离。 配置层只回答前者,适配层只处理后者,公共代码层完全不知道产品差异的存在。

具体来说:

关注点由谁负责设计原则
产品有什么不同 配置层(product_config.h) 只写 #define,不写逻辑
怎么实现差异 适配层(条件编译 + 映射函数) 差异注入点集中、可控
公共业务逻辑 应用层 产品无关,一份代码跑所有产品

这三层分离做好后,加一个新产品只需要写一个 product_config.h,不需要动任何公共代码。


三、构建迁移:踩过的坑

架构设计完成后,创建新工作目录时踩了四个坑,这里精简记录,供同样做 SDK 迁移的同行参考。

坑 1:漏复制构建工具目录。 新目录只有源码,缺了 tools/(构建脚本)、build/(链接脚本)、release/(版本头文件)。generate_sys_config.sh 找不到,直接报错。解决方案:这三个目录必须一起复制。

坑 2:库输出目录不存在。 编译成功但归档失败——Makefile 有 mkdir -p 自动创建对象文件目录,但库文件输出目录 beken378/lib/ 没有这个处理。解决方案:mkdir -p beken378/lib/。

坑 3:纯预编译库缺失。 libairkiss.a 没有源码构建规则,只在链接时引用。新目录只有 .c 和 .h,没有 .a。解决方案:从原始 SDK 复制所有 .a 文件。

坑 4:平台预编译库全缺。 SRC_RF_TEST_C、SRC_UART_DEBUG_C 等列表为空,意味着这些库完全使用预编译版本(由 pick_beken_libs.sh 脚本从 lib*_bk7238.a 复制为通用名)。新目录的 beken378/lib/ 是空的,脚本无处可复制。解决方案:从原始 SDK 复制全部预编译库(47 个文件),且**不要运行 make clean**(会删掉预编译库)。

核心教训: SDK 的库分三种——源码编译库(有 SRC 列表)、平台预编译库(pick 脚本选择)、纯预编译库(无源码)。迁移时三种必须都带齐,缺一种就链接不过。


四、写在最后

这次重构的最终效果是:一份代码支持多个产品线(每条产品线还有多种衍生型号),编译时只需指定 PRODUCT 和 VARIANT:

make bk7238 PRODUCT=product_a VARIANT=4
make bk7238 PRODUCT=product_b VARIANT=2
make bk7238 PRODUCT=product_c VARIANT=1

改 bug 只改一次,所有产品自动生效。产品差异全部收敛到 product_config.h 一个文件里,清清楚楚。

架构设计花了一天,踩坑又花了大半天。但这是一次性投入——以后再也不会在分支间来回横跳了。

架构设计的核心思路: 把"产品有什么不同"和"怎么实现差异"分离。配置层只回答前者,适配层只处理后者,公共代码层完全不知道产品差异的存在。这三层分离做好后,加一个新产品只需要写一个 product_config.h,不需要动任何公共代码。