
1. 项目缘起为什么要在Matter设备里“夹带私货”如果你正在开发一个基于Matter协议的智能家居设备比如一个智能灯泡或者门锁你可能会遇到一个挺有意思的需求除了标准的Matter over Wi-Fi或Thread通信我还想让我这个设备能通过蓝牙干点别的事。这个“别的事”可能五花八门——比如在设备初次配网Commissioning时除了用Matter的BLE配网我还想用蓝牙广播一些自定义的设备状态信息方便手机App在靠近时就能直接读取实现“近场感知”。又或者你想在设备上保留一个私有的蓝牙服务Service用于设备出厂时的产线测试、固件升级OTA的后备通道或者实现一些Matter标准尚未涵盖、但又对用户体验很重要的功能比如通过蓝牙播放提示音。这个需求非常真实。Matter协议栈本身已经高度集成和标准化它通过蓝牙低功耗BLE主要完成一件事安全配网。芯片原厂提供的SDK比如Nordic的nRF Connect SDK已经把Matter的BLE配网流程封装得严严实实。你作为开发者直接调用API设备就能广播Matter的特定服务UUID如0xFFF6等待手机端Matter App来发现和连接完成后续的Wi-Fi/Thread凭证分发。整个过程是“黑盒”的你很难插手。但现实项目往往没那么单纯。我最近就遇到一个案例我们要做一个带屏的Matter温控器。标准Matter功能一切正常但产品经理提了个需求——希望工程人员用专用的调试App靠近设备时屏幕能自动点亮并显示一个调试菜单而普通用户手机靠近则无感。这个功能用Matter协议实现非常别扭也不符合标准。最自然的想法就是让设备在广播Matter信息的同时再广播一个我们自己定义的Manufacturer Specific Data厂商特定数据里面包含设备序列号或型号代码。调试App扫描到这个特定数据后再通过一个自定义的蓝牙服务去发送“点亮屏幕”的指令。听起来简单做起来却处处是坑。Matter协议栈对蓝牙控制器Controller和主机Host栈有严格的管理你自己额外初始化的蓝牙操作很容易和Matter栈冲突导致广播失败、服务无法注册甚至整个协议栈崩溃。网上关于“Matter 自定义蓝牙”的中文资料几乎为零英文社区也多是只言片语。经过一番折腾我终于在nRF Connect SDK的环境下成功实现了在Matter设备上稳定共存自定义蓝牙广播与服务。这篇文章我就把完整的实现思路、关键代码、以及踩过的那些坑毫无保留地分享给你。2. 理解Matter的蓝牙栈它已经占好了“车道”在动手之前我们必须先搞清楚Matter协议栈是如何管理蓝牙的。以nRF Connect SDKNCS为例它是目前最主流的Matter开发平台之一。当你创建一个Matter项目时SDK会通过Kconfig配置系统自动引入一系列蓝牙相关的模块其中最关键的是CHIP模块对Zephyr RTOS原生蓝牙栈的封装。Matter使用蓝牙主要做两件事广播设备上电后在未配网状态下以BLE广播的形式宣告自己是一个可配网的Matter设备。这个广播包里包含了Matter的服务UUID、设备识别信息Discriminator等。GATT服务当手机App连接后设备会提供一个GATT通用属性配置文件服务用于安全地交换配网所需的证书、临时密钥等信息。在NCS的框架下这些功能主要由src/platform/Zephyr/BLEManagerImpl.cpp等底层文件实现。关键点在于Matter会初始化并独占一个蓝牙连接实例。它通过bt_enable初始化蓝牙控制器通过bt_le_adv_start启动广播并通过bt_gatt_service_register注册自己的GATT服务。这就引出了第一个大坑蓝牙栈的单例模式。在Zephyr中bt_enable()通常只能调用一次。如果你在自己的代码里又调用了一次系统会返回-EALREADY错误。但如果你不调用直接去操作广播或服务又会因为协议栈未初始化而失败。更棘手的是广播和服务资源冲突一个蓝牙设备在某一时刻只能有一套广播参数Advertising Set和一组GATT服务。Matter已经注册了一套你再注册自己的很可能把它的覆盖掉导致Matter配网功能直接失效。所以我们的核心目标不是“另起炉灶”而是“和平共处、资源共享”。我们需要找到一种方式在Matter已经建立好的蓝牙环境里“插入”我们自己的数据和服务并且确保两者互不干扰稳定运行。3. 方案选型三种路径的利弊权衡面对这个挑战我调研并尝试了三种主流方案最终选择了最稳定的一种。3.1 方案一直接操作Matter的广播数据最理想但最难理论上最优雅的方式是扩展Matter协议栈本身的广播数据。Matter的广播数据包结构是固定的但其中包含一个“厂商特定数据”Manufacturer Specific Data字段理论上可以用于携带自定义信息。我们需要修改Matter的底层代码如BLEManagerImpl.cpp在它组装的广播数据包中加入我们自己的数据。优点效率最高功耗最低只发一个广播包。缺点侵入性极强。你需要深度理解Matter协议栈的蓝牙模块修改其内部数据结构。这会给未来的SDK升级带来巨大的合并冲突风险几乎不可维护。除非你是芯片原厂的系统工程师否则不推荐。3.2 方案二创建第二个广播实例看似简单实则多坑Zephyr BLE支持多个广播实例Advertising Set。思路是让Matter跑它的第一个广播实例我们用自己的参数创建第二个广播实例两者同时广播。优点逻辑隔离清晰互不影响。缺点硬件与驱动支持并非所有BLE芯片都稳定支持多广播实例。即使支持同时广播也会增加射频功耗。地址冲突每个广播实例需要一个广播地址。如果处理不好两个实例可能使用相同的随机地址造成混乱。扫描响应数据自定义数据可能需要放在扫描响应Scan Response中这需要更精细的控制。与Matter栈的时序竞争你需要确保在Matter蓝牙栈完全初始化之后再去创建第二个广播实例否则可能初始化失败。我在nRF52840上测试此方案时遇到了广播时断时续、手机端扫描不到自定义数据的问题稳定性不佳。3.3 方案三利用Matter广播并注册自定义GATT服务推荐方案这是最终被我验证稳定可靠的方案。其核心思想是广播不创建第二个广播实例而是修改Matter广播包中的数据但通过一个非侵入式的“钩子”函数来实现。我们利用NCS提供的回调机制在Matter即将启动广播前合法地修改其广播数据包插入我们的自定义数据。服务在Matter注册完它的GATT服务后我们再注册自己的自定义GATT服务。它们会共存于同一个GATT服务器中客户端手机可以同时发现并访问Matter的服务和我们的服务。优点非侵入式无需修改Matter核心源码主要通过配置和外部回调实现维护性好。资源统一共用同一个蓝牙连接和广播实例功耗最优。稳定性高遵循了Zephyr和Matter栈的最佳实践避免了资源竞争。缺点需要仔细处理回调函数的执行时机和数据格式。自定义GATT服务需要妥善处理UUID冲突不能与Matter的UUID重复。接下来我将详细拆解方案三的实现步骤这也是本文的重点。4. 实战修改广播数据插入厂商自定义数据我们的目标是在Matter的广播数据中加入一段自定义的厂商数据Manufacturer Specific Data。在BLE中这是一个标准的数据类型AD Type。第一步在Matter配置中启用广播数据回调首先需要确保Matter的配置允许我们修改广播数据。在NCS中这通常通过prj.conf文件进行配置。你需要添加或确认以下配置# 启用CHIP对自定义蓝牙广播数据的支持 CONFIG_CHIP_ENABLE_ADDITIONAL_DATA_ADVERTISINGy CONFIG_CHIP_ENABLE_ROTATING_DEVICE_IDyCONFIG_CHIP_ENABLE_ADDITIONAL_DATA_ADVERTISING这个配置是关键它开启了在广播数据中添加额外数据的钩子。第二步实现AdditionalDataPayloadGenerator在Matter中额外广播数据的生成是通过一个名为AdditionalDataPayloadGenerator的类来管理的。我们需要提供一个自己的实现。在你的应用代码目录如src/下创建一个文件例如custom_additional_data_provider.cpp。// custom_additional_data_provider.cpp #include lib/support/BufferWriter.h #include platform/AdditionalDataPayloadGenerator.h using namespace chip; namespace { // 自定义的厂商ID使用蓝牙SIG分配的16位ID或者使用0xFFFF进行测试注意生产环境需使用合法ID constexpr uint16_t kCustomManufacturerId 0xFFFF; // 自定义数据例如设备类型代码 (1字节) 状态码 (1字节) constexpr uint8_t kCustomData[] {0x01, 0xAA}; } // namespace CHIP_ERROR CustomAdditionalDataGenerator::GenerateAdditionalDataBlock( MutableByteSpan buffer, const chip::PayloadContents payload) { // 这个方法会在Matter生成其旋转设备ID等额外数据后被调用。 // 我们需要把自定义数据填入提供的buffer中。 if (buffer.size() sizeof(kCustomData) 3) // 2字节厂商ID 1字节长度 N字节数据 { return CHIP_ERROR_BUFFER_TOO_SMALL; } Encoding::LittleEndian::BufferWriter writer(buffer); // 写入厂商特定数据的AD Header: [长度][类型0xFF] // 长度 厂商ID(2字节) 数据长度(N字节) 1 (类型字节本身不计入长度) uint8_t adv_data_len sizeof(kCustomManufacturerId) sizeof(kCustomData) 1; writer.Put(adv_data_len); // 长度字段 writer.Put(0xFF); // 类型 0xFF 代表 Manufacturer Specific Data // 写入厂商ID (小端格式) writer.Put16(kCustomManufacturerId); // 写入自定义数据 writer.Put(kCustomData, sizeof(kCustomData)); if (writer.Fit()) { buffer.reduce_size(writer.Needed()); return CHIP_NO_ERROR; } return CHIP_ERROR_NO_MEMORY; } // 提供一个获取生成器实例的函数此函数需要在别处被调用以注册我们的生成器。 AdditionalDataPayloadGenerator GetCustomAdditionalDataGenerator() { static CustomAdditionalDataGenerator sGenerator; return sGenerator; }第三步注册自定义数据生成器这是最关键的一步需要找到Matter初始化过程中设置额外数据生成器的地方。在NCS中通常位于src/platform/Zephyr/PlatformManagerImpl.cpp的InitChipStack()函数附近。你需要添加代码用我们的生成器替换默认的。// 在 PlatformManagerImpl.cpp 的 InitChipStack() 函数中找到初始化蓝牙相关部分后添加 extern AdditionalDataPayloadGenerator GetCustomAdditionalDataGenerator(); CHIP_ERROR PlatformManagerImpl::_InitChipStack() { // ... 其他初始化代码 ... // 在蓝牙广告初始化前设置我们的自定义数据生成器 { chip::DeviceLayer::PlatformMgr().LockChipStack(); // 替换默认的生成器。具体函数名可能因SDK版本而异例如 SetAdditionalDataPayloadGenerator chip::DeviceLayer::SetAdditionalDataPayloadGenerator(GetCustomAdditionalDataGenerator()); chip::DeviceLayer::PlatformMgr().UnlockChipStack(); } // ... 后续初始化代码 ... return CHIP_NO_ERROR; }注意SetAdditionalDataPayloadGenerator这个函数名可能需要根据你使用的NCS/Matter版本进行查找它可能位于src/include/platform/AdditionalDataPayloadGenerator.h中。如果找不到可以搜索AdditionalDataPayloadGenerator在代码库中的使用方式。第四步验证广播数据编译并烧录固件后使用手机端的蓝牙调试工具如nRF Connect扫描设备。找到你的Matter设备通常以“MAT-”或“Matter-”开头查看其广播数据包Advertising Data。你应该能看到一个类型为Manufacturer Specific Data (0xFF)的数据段内容包含你定义的厂商ID如FFFF和自定义数据如01 AA。踩坑记录数据长度计算最容易出错的是广播数据中“长度字段”的计算。这个长度是指本段AD Structure类型数据的总字节数。例如类型0xFF占1字节厂商ID占2字节自定义数据占N字节那么长度 2 N 1不对长度字段本身表示的是类型字节和数据字节的总和。所以正确的计算是长度 2(厂商ID) N(自定义数据) 1(类型0xFF)等等类型0xFF是数据的一部分。所以应该是长度 2 N还是不对。实际上AD Structure的格式是[长度][类型][数据]。长度类型(1字节)数据(X字节)的总和。所以如果数据是厂商ID(2字节) 自定义数据(N字节)那么长度 1(类型) 2 N。我最初在这里卡了很久导致广播数据格式错误手机端解析不出来。务必对照蓝牙核心规范反复核对。时机问题确保注册自定义生成器的代码在Matter蓝牙广播初始化之前执行否则你的数据可能不会被包含进去。5. 实战添加自定义GATT服务广播数据是单向的只能被动被扫描。要实现双向通信如接收调试指令就需要添加GATT服务。我们要在Matter的GATT服务器上“加挂”我们自己的服务。第一步定义自定义服务的UUID选择一个不会与Matter标准服务UUID通常以0xFFF4-0xFFF9范围冲突的UUID。可以使用128位UUID以确保唯一性。// custom_ble_service.h #define BT_UUID_CUSTOM_SERVICE_VAL \ BT_UUID_128_ENCODE(0x12345678, 0x1234, 0x5678, 0x1234, 0x56789abcdef0) #define BT_UUID_CUSTOM_CHARACTERISTIC_VAL \ BT_UUID_128_ENCODE(0xabcdef12, 0x3456, 0x7890, 0xabcd, 0xef1234567890) static struct bt_uuid_128 custom_service_uuid BT_UUID_INIT_128(BT_UUID_CUSTOM_SERVICE_VAL); static struct bt_uuid_128 custom_characteristic_uuid BT_UUID_INIT_128(BT_UUID_CUSTOM_CHARACTERISTIC_VAL);第二步定义特征值Characteristic及其回调我们创建一个可读、可写、可通知Notify的特征值用于接收指令和上报状态。// custom_ble_service.c #include bluetooth/bluetooth.h #include bluetooth/uuid.h #include bluetooth/gatt.h #include zephyr/kernel.h static uint8_t custom_value[10] {0}; // 特征值数据 static bool notify_enabled false; // 处理客户端写入请求 static ssize_t write_custom(struct bt_conn *conn, const struct bt_gatt_attr *attr, const void *buf, uint16_t len, uint16_t offset, uint8_t flags) { // 安全检查确保写入长度不超过缓冲区且偏移为0简单示例 if (offset len sizeof(custom_value)) { return BT_GATT_ERR(BT_ATT_ERR_INVALID_OFFSET); } memcpy(custom_value offset, buf, len); printk(Custom BLE Service: Received data, len%d\n, len); // 这里可以触发业务逻辑例如解析指令 return len; } // 处理客户端读取请求 static ssize_t read_custom(struct bt_conn *conn, const struct bt_gatt_attr *attr, void *buf, uint16_t len, uint16_t offset) { const uint8_t *value attr-user_data; return bt_gatt_attr_read(conn, attr, buf, len, offset, value, sizeof(custom_value)); } // CCCD客户端特征配置描述符写入回调用于启用/禁用通知 static void cccd_changed(const struct bt_gatt_attr *attr, uint16_t value) { notify_enabled (value BT_GATT_CCC_NOTIFY); printk(Custom BLE Service: Notify %s\n, notify_enabled ? enabled : disabled); } // 定义特征值的属性数组 BT_GATT_SERVICE_DEFINE(custom_svc, BT_GATT_PRIMARY_SERVICE(custom_service_uuid), BT_GATT_CHARACTERISTIC(custom_characteristic_uuid.uuid, BT_GATT_CHRC_READ | BT_GATT_CHRC_WRITE | BT_GATT_CHRC_NOTIFY, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE, read_custom, write_custom, custom_value), BT_GATT_CCC(cccd_changed, BT_GATT_PERM_READ | BT_GATT_PERM_WRITE), );这个宏BT_GATT_SERVICE_DEFINE会在编译时静态地将我们的服务注册到GATT数据库中。第三步在应用初始化中确保蓝牙已就绪我们的服务是静态定义的但需要确保在Matter初始化蓝牙之后这个服务才被有效地包含进去。实际上只要我们的源文件被编译链接服务就注册了。关键在于初始化的顺序。我们需要将包含此服务的模块初始化放在Matter蓝牙初始化完成之后。通常在main.c或你的应用初始化函数中在调用chip::Platform::Matter::Init()之后再执行任何与自定义蓝牙服务相关的初始化例如启动一个用于管理该服务的任务。// main.c #include custom_ble_service.h void main(void) { // 初始化硬件、日志等 ... // 初始化Matter协议栈这会调用bt_enable()等 chip::Platform::Matter::Init(); // 此时蓝牙栈已由Matter初始化完毕。 // 我们的自定义服务 custom_svc 已通过宏静态注册。 // 可以在这里启动一个任务来更新自定义特征值的数值并发送通知。 k_thread_start(my_ble_service_thread); // 启动Matter的主事件循环 chip::Platform::Matter::RunEventLoop(); }第四步从自定义服务发送通知Notify当设备端状态变化需要主动通知手机客户端时可以调用bt_gatt_notify。void update_and_notify_custom_value(uint8_t new_data[], uint8_t len) { if (len sizeof(custom_value)) { return; } memcpy(custom_value, new_data, len); if (notify_enabled) { // 需要获取当前活跃的连接。Matter通常只管理一个BLE连接。 // 这里简化处理获取第一个连接。生产环境需要更健壮的管理。 struct bt_conn *conn bt_conn_lookup_state_le(BT_ID_DEFAULT, NULL, BT_CONN_CONNECTED); if (conn) { bt_gatt_notify(conn, custom_svc.attrs[1], custom_value, len); // 注意索引[1]通常是特征值属性 bt_conn_unref(conn); } } }第五步手机端测试使用nRF Connect App连接你的设备。在GATT服务列表中除了标准的Matter服务如FFF6你应该能看到一个以你定义的128位UUID命名的自定义服务。点击进入可以看到你定义的可读、可写、可通知的特征值。你可以尝试写入数据并在设备日志中查看接收情况也可以启用通知然后从设备端触发update_and_notify_custom_value在App上观察收到的通知数据。踩坑记录连接对象管理bt_gatt_notify需要一个有效的连接对象。在Matter场景下这个连接是由Matter协议栈管理的。直接使用bt_conn_lookup_state_le可能在某些情况下返回NULL例如连接正在建立或断开。更安全的方式是订阅Matter层的连接事件回调或者确保在通知前连接状态是稳定的。属性索引bt_gatt_notify的第二个参数需要指向特征值的属性Attribute。BT_GATT_SERVICE_DEFINE宏生成的属性数组顺序是固定的但直接使用索引如custom_svc.attrs[1]很脆弱。更好的做法是使用bt_gatt_find_by_uuid函数来动态查找特征值的属性句柄。内存与并发GATT回调函数如write_custom运行在蓝牙线程上下文中应尽快返回避免执行耗时操作。如果需要复杂处理应将数据拷贝到队列中由应用线程处理。6. 调试技巧与稳定性保障在集成过程中调试是最大的挑战。蓝牙行为是异步且底层的这里分享几个关键技巧。1. 使用RTT日志与Segger Ozone由于蓝牙协议栈和Matter任务运行在实时操作系统上普通的串口打印可能会因为中断或任务调度导致信息丢失或时序错乱。J-Link配合RTTReal Time Transfer日志是必备工具。它通过调试接口输出日志几乎不影响系统实时性。再结合Segger Ozone调试器可以设置断点、查看变量、单步跟踪蓝牙栈和Matter代码的执行流对于定位初始化顺序、回调时机等问题至关重要。2. 验证广播数据格式使用手机端的nRF Connect App是最直观的方式。关注广播数据包的原始字节。对照蓝牙核心规范检查你的自定义数据段长度字段是否正确类型0xFF是否正确厂商ID和数据内容是否符合预期 如果格式错误整个数据段可能被手机系统忽略。3. 压力测试与并发场景连接/断开风暴让手机App反复连接、断开设备观察自定义服务是否依然稳定可用Matter配网功能是否受影响。并行操作在Matter配网过程进行到一半时尝试通过自定义服务读写数据。观察两者是否会产生冲突例如GATT数据库忙错误。长时运行让设备持续广播并保持连接数小时监控内存泄漏。可以使用Zephyr的heap和stack分析工具。4. 功耗影响评估添加自定义广播数据和服务会增加功耗但增量通常很小。你需要使用功率分析仪如Nordic的Power Profiler Kit II进行实测测量仅Matter广播时的平均电流。测量添加自定义数据后的平均电流。如果自定义服务需要频繁通知测量通知间隔对电流峰值和平均值的影响。 确保总功耗仍在产品设计的电池续航要求范围内。7. 进阶思考生产环境下的优化与安全当功能跑通后我们需要考虑如何将它打磨成一个适合量产的功能。1. 动态数据与广播间隔我们的示例中广播数据是静态的。实际应用中数据可能需要动态更新如设备内部温度、电量。你需要设计一个机制在数据变化时重启广播以更新数据包。注意频繁重启广播会增加功耗。可以设置一个合理的广播间隔和更新阈值。2. 安全考虑自定义服务认证你的自定义GATT服务可能涉及设备控制或敏感信息。绝不能是开放的。最简单的实现是在特征值读写权限上增加BT_GATT_PERM_READ_AUTHEN和BT_GATT_PERM_WRITE_AUTHEN要求连接已配对加密。更复杂一点可以在服务内实现一个简单的基于预共享密钥的挑战-响应认证。数据加密即使连接已加密对于敏感指令也可以考虑在应用层对特征值的数据进行二次加密。厂商ID生产环境中务必使用公司向蓝牙SIG申请的合法16位厂商ID而不是测试用的0xFFFF。3. 与Matter配网的协同最复杂的场景是当手机通过Matter配网流程正在连接设备时你的调试App也试图通过自定义服务连接。虽然BLE理论上支持多客户端但很多低功耗芯片只支持一个连接。Matter协议栈通常会独占连接。你需要定义清晰的行为逻辑例如当Matter配网激活时暂时禁用自定义服务的连接或者设计一个优先级机制。这需要在产品需求阶段就明确。4. 代码架构建议为了保持代码的整洁和可维护性建议将自定义蓝牙功能封装成一个独立的模块custom_ble_advertiser.h/cpp负责管理自定义广播数据的生成与更新。custom_ble_service.h/cpp负责自定义GATT服务的定义、注册和事件处理。custom_ble_manager.h/cpp一个高层管理器协调广播和服务的生命周期并提供简单的API给上层应用如StartCustomBle()SendNotification(data)。这个模块通过清晰的接口与Matter主应用交互避免业务代码直接调用底层的蓝牙API。这样未来如果Matter SDK或蓝牙栈API发生变化你只需要修改这个模块内部而不必到处搜索散落的蓝牙代码。实现Matter与自定义蓝牙的共存就像在一条已经繁忙运行的流水线上安全地加入一个你自己的工位。它要求你对Matter和Zephyr BLE栈都有深入的理解并小心翼翼地处理资源与时序。这个过程充满挑战但一旦打通将为你的智能设备带来巨大的灵活性和附加值。希望这篇详尽的指南能帮你避开我踩过的那些坑顺利实现你的功能。如果在实践中遇到新的问题不妨从协议栈初始化的顺序、回调函数的上下文、以及资源冲突这几个角度去排查祝你好运。