嵌入式多产品统一架构重构:从分支同步到单仓库多产品构建
一次真实的 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,不需要动任何公共代码。
浙公网安备 33010602011771号