中科蓝讯 BT897X SDK 二次开发:先弄懂这六件事
拿到一套蓝牙音频 SDK,最花时间的往往不是写功能,而是搞清楚"这个功能该写在哪"。中科蓝讯(Bluetrum)的 BT897X 是一套 RV32 架构的蓝牙音频 SDK,二次开发时如果位置找错,逻辑再对也会四处漏风。这篇按六个地方讲:分层、模式循环、消息机制、双轨配置、段属性与内存布局、构建链路。
一、先认清分层,别在错误的一层动刀
SDK 大致分四层:
projects/<chip>/:芯片与产品移植层,放编译期配置、链接脚本、板级 port(按键、LED、功放静音)与外设驱动;- functions/ + projects/message/ + projects/display/:功能模式三件套,模式逻辑、消息响应、显示各一份;
- modules/:可复用子系统——蓝牙、音频、音效、充电、升级、GUI、按键扫描等,头文件由
modules/modules.h一次性汇总; libs/<chip>/liba/:原厂预编译静态库加唯一的公开接口头(api_*.h)。
原厂库要当黑盒接口对待:libplatform.a、libbtstack.a、libcodecs.a 这类 .a 是外部依赖,不要试图反编译。需要改原厂行为时,SDK 一般留了弱符号覆盖点(strong_*.c 一族),在覆盖点里重写符号,比改库内部干净得多,也活得久。
还有一条贯穿全 SDK 的惯例:几乎每个 .c 都以 #include "include.h" 开头。include/include.h 把 global.h、xcfg.h、config.h、api.h、modules.h、load_code.h 依次带进来,而各个 .h 自己并不自包含。
这条约定直接影响开发体验:clangd 之类的索引器必须给整个工程加 -include include.h,否则头文件里的 u8 / u16 全报未知类型,跳转也失效。很多人以为"SDK 太老、IDE 支持差",其实只是漏了这一条。

图 1 · 四层结构,以及每层该用什么态度改
二、一个模式,就是一层内循环
模式枚举在 functions/func.h:FUNC_MUSIC、FUNC_BT、FUNC_IDLE、FUNC_CHARGE……分派逻辑本身是个死循环,每轮取 func_cb.sta,找到对应的 func_X() 执行。而每个 func_X() 内部又是一个循环,骨架大致是这样(已简化):
void func_bt(void)
{
func_bt_enter();
while (func_cb.sta == FUNC_BT) {
func_bt_process(); // 轮询:状态、充电、计时
func_bt_message(msg); // 消息:按键、蓝牙事件
func_bt_display(); // 显示:LED / LCD / 数码管
}
func_bt_exit();
}
模式切换不靠返回值,而是谁都能改 func_cb.sta:按键、蓝牙断连、入仓、关机,改掉之后内循环条件不成立,函数退出,回到分派循环,再按顺序表 func_sort_table[] 与设备在线状态挑下一个模式。
由此得出一条很实用的习惯:改某个模式的行为,一定按三处一组去找——functions/func_X.c(进入/退出/轮询)、projects/message/msg_X.c(消息响应)、projects/display/display_X.c(显示)。只改其中一处,最容易出现"蓝牙切歌正常但显示不动"“入仓后 LED 不灭"这类半截问题,而且两边看代码都对。
另外注意 sfunc_* 这种子功能:它们没有对应的 FUNC_XXX 枚举,不进顶层分派,只是被某个模式在内部调用(通话链路、OTA 流程、来电铃声)。找代码时别在分派 switch 里翻。

图 2 · 分派循环 + 内循环,以及"三处一组”
三、消息是 16 位整数,按键也混在同一个队列
所有异步事件——按键、蓝牙状态、充电、定时——都汇进同一个 16 位消息队列(msg_enqueue / msg_dequeue),由各模式的消息函数消费。
按键消息不是单独一套机制:在按键扫描模块里,低位是按键 id,事件类型占几个 bit(短按、长按、长按抬起、双击……),再用 MSG_KU / MSG_KL / MSG_KD 这类宏合成消息号。按键到消息的映射是每个模式一张表,同一个物理按键在音乐模式和通话模式下可以对应完全不同的动作。
好处是事件处理入口单一,日志好追;代价是消息号属于全局命名空间,自己加业务消息要挑一段没人用的编号,否则可能在别的模式里被误消费——这类问题在日志上表现为"莫名其妙进了某个分支",很难一眼看出。
四、配置分两条轨道:编译期 config.h 与运行期 xcfg
这是整套 SDK 里最值得先看明白的设计。
config.h 是编译期开关总表:FUNC_*_EN 决定某个模式是否编进固件,BT_*_EN 决定蓝牙特性是否开启(这类开关上百个)。它不只门控 C 代码——链接脚本 ram.ld 会 #include "config.h",同一批宏也决定段的取舍。所以"关掉一个功能"既省代码也省 RAM,这是优点;反过来说,改开关前要意识到它同时动了代码和内存布局。
xcfg.h 是运行期配置结构(带位域,字段注释里写着每个参数的取值范围)。文件头明确标注由工具自动生成、不要手改:它由上位机配置工具产出,打包进固件里一个独立的配置区。好处是提示音语言、自动休眠时间、低电阈值这类参数,烧录后不用重编固件就能改。
同一类的还有资源地址头(res.h / res2.h):模板提示音、UI 素材在 flash 里的绝对地址映射,宏直接就是 0x11xxxxxx 这样的地址常量。资源清单和这些头文件必须由工具同步生成,手改必然对不上。
一条能省很多时间的经验:新参数先问"能不能走 xcfg",能走就别开编译开关——少一次重编,也少一轮回归。
五、AT() 段属性 + overlay 链接脚本:最容易被低估的地方
SDK 里几乎每个函数和变量都带段属性:
#define AT(x) __attribute__((section(STR(x))))
函数写 AT(.text.func)、数据写 AT(.data_effect)。链接脚本把这些段叠放在同一块 RAM 上——不同模式用不同段,运行时按需把代码从 flash 搬进 RAM(load_code 按 LMA→VMA 搬运,lock_code 把一段代码锁进 cache)。
这带来两个必须记住的约束:
- 不要随意增删或搬动
AT(...)。 段名和链接脚本是一份契约。对不上时编译照样通过,运行期踩内存,问题会以"偶尔死机"“切模式后声音异常"的形式出现,极难定位。 - 新增功能尽量不要增加搬运次数和搬运量。 每次搬运都是额外的 flash 读与功耗。对靠小电池撑续航的耳机产品,这是硬指标,不是优化建议。
至于内存布局本身:链接脚本动辄上千行、几十个 overlay 段,看着吓人,但排查时只需要问三个问题——这段代码在哪个段、这个段和谁叠放、谁在什么时候把它搬进来。三个问题回答完,多数"玄学重启"都能落到具体代码上。
六、构建链路:产物必须同源
这类 SDK 的构建通常是 IDE 工程文件加一套脚本引擎:工程文件(如 .cbp)是唯一的源文件与编译选项清单,脚本解析它并复刻 IDE 的行为,大致四步:
- prebuild:用资源工具把资源脚本打包成
res.bin/xcfg.bin(提示音、UI 资源、配置区); - 链接脚本预处理:
ram.ld先过一遍 C 预处理器,所以脚本里能写config.h的宏和#if; - compile + link:按工程选项逐文件编译,链接原厂
.a,输出 map; - postbuild:objcopy 出
app.bin,再由资源工具打包成最终烧录 / 升级文件。
两个工程习惯值得强调。一是改完工程文件的源文件清单或 include 路径后,索引数据库必须重新生成,否则 IDE 里"跳转失效"会被误判成代码问题。二是排查问题时,log、map、elf、烧录的 bin 必须来自同一次构建——这类 SDK 大多没有单元测试框架,编译通过只是底线,真正的验证是上机实测:蓝牙、通话、充电、升级、功耗、传感器,一项都省不掉。

图 3 · 四步构建,以及"产物同源"这条纪律
一张检查清单
动第一行代码之前,先过一遍:
- 要改的东西属于哪一层?能不能只在自己的业务层解决?
- 涉及模式行为吗?
func_X.c/msg_X.c/display_X.c三处都看了吗? - 新加的消息号会不会和其他模式的段撞?
- 这个参数该走编译期
config.h,还是运行期xcfg?能不能不重编? - 动到
AT()或新增代码了吗?链接脚本对得上吗?搬运量增加了吗? - 要改原厂行为,走弱符号覆盖点了吗?
- 出问题时,手上的 log / map / elf / bin 是同一次构建的吗?
把这七条养成习惯,SDK 二次开发里最难查的一类问题——编译通过、上机偶发——会少很多。