上一篇把数据模型和 ZAP 生成讲清楚了,这一篇就顺着那条线往下走:一个 Matter 灯泡样例跑起来之后,从 main() 到板上 LED 亮起来,中间到底经过哪些环节。
样例是 NCS v3.3.0 的 nrf/samples/matter/light_bulb。建议边看文章边把工程打开,对着源码走一遍,比干读强得多。
先看工程长什么样
目录结构不长:
light_bulb/
├── prj.conf / prj_release.conf / sysbuild.conf
├── CMakeLists.txt
├── pm_static_nrf54l15dk_nrf54l15_cpuapp.yml ← 分区表
├── sample.yaml ← 支持的板
└── src/
├── main.cpp ← 入口
├── app_task.h/.cpp ← 应用任务
├── zcl_callbacks.cpp ← 集群回调(核心)
├── chip_project_config.h ← 项目配置
└── default_zap/
├── light_bulb.zap ← ZAP 配置
├── light_bulb.matter ← 生成的 IDL
└── zap-generated/ ← 生成的代码
上一篇讲过的 default_zap/ 就在这里,zcl_callbacks.cpp 是本篇的主角。

启动:main() 只是个引子
src/main.cpp 干的事少得可疑:
int main()
{
// … 硬件初始化 …
return AppTask::Instance().StartApp(); // 进入应用任务
}
真正的内容在 AppTask::StartApp() 里,它调了三个 NCS 的封装函数:
Nrf::Matter::PrepareServer(); // 初始化 Matter 栈
Nrf::Matter::RegisterEventHandler(); // 注册事件处理器
Nrf::Matter::StartServer(); // 启动 Server(开始 BLE 广告)
这三行就是 Nordic 给 Matter 应用定下的启动范式。PrepareServer 负责把协议栈和平台抽象层拉起来,StartServer 之后设备就开始 BLE 广播,等着被配网(第 5 篇展开配网细节)。
到这里为止,还没有任何"灯"的逻辑。灯是在集群回调里点亮的。
集群回调:框架跟你的唯一接口
src/zcl_callbacks.cpp 里的 MatterPostAttributeChangeCallback 是整个样例的灵魂,第 9.1 节五种接入方式里最常用的一种:
void MatterPostAttributeChangeCallback(
const chip::app::ConcreteAttributePath &attributePath,
uint8_t type, uint16_t size, uint8_t *value)
{
ClusterId clusterId = attributePath.mClusterId;
AttributeId attributeId = attributePath.mAttributeId;
if (clusterId == OnOff::Id && attributeId == OnOff::Attributes::OnOff::Id)
{
// OnOff 属性变更 → 控制 LED
Nrf::GetBoard().GetLED(Nrf::DeviceLeds::LED2).Set(*value);
}
else if (clusterId == LevelControl::Id &&
attributeId == LevelControl::Attributes::CurrentLevel::Id)
{
// 亮度变更 → PWM
}
}
整个函数就一个模式:拿到 clusterId 和 attributeId,判断是不是自己关心的那个属性,是就干活。
上一篇说过,控制器发来的 Invoke 命令最终会落到 src/app/clusters/ 里的集群实现,OnOff 集群实现改完 OnOff 属性后,就调用这个回调通知应用。所以你的应用代码不需要碰协议栈,只需要在回调里按"哪个集群的哪个属性变了"来分发——这跟 Zephyr 里 bt_conn_cb 那套回调思路是相通的。
一个容易忽略的细节:value 指向的数据在回调返回后就不保证有效了。要留着用就先拷出来,别只存指针。样例里 *value 直接取了布尔值,正好没这个问题。
集群初始化回调:断电重启后灯还是那个状态
同一个文件往下看,还有个 emberAfOnOffClusterInitCallback:
void emberAfOnOffClusterInitCallback(EndpointId endpoint)
{
bool storedValue;
if (Attributes::OnOff::Get(endpoint, &storedValue) == Status::Success)
{
// 从持久化存储恢复 LED 状态
Nrf::GetBoard().GetLED(Nrf::DeviceLeds::LED2).Set(storedValue);
}
AppTask::Instance().UpdateClusterState();
}
集群初始化时被调(通过 GENERATED_FUNCTION_ARRAYS 注册),干的事是从持久化存储里把上次保存的 OnOff 状态读回来,直接刷到 LED 上。也就是说你关灯再上电,灯还是关的,不会"复活"。
这两个回调分工很清楚:Init 回调管"开机恢复",PostAttributeChange 回调管"运行中响应"。写自己的应用时把这两件事分开想,结构自然就对了。

应用主动读写属性:Accessor 函数
回调是框架找你,Accessor 是你找框架。生成的代码里每个属性都带 Get/Set:
// 写
Clusters::OnOff::Attributes::OnOff::Set(kLightEndpointId, value);
Clusters::LevelControl::Attributes::CurrentLevel::Set(kLightEndpointId, level);
// 读
bool storedValue;
Clusters::OnOff::Attributes::OnOff::Get(endpoint, &storedValue);
uint8_t minLevel;
Clusters::LevelControl::Attributes::MinLevel::Get(kLightEndpointId, &minLevel);
什么时候用 Set?比如设备上有物理按键想直接控灯:按键回调里调 Set(),框架会更新属性值、通知订阅方,还会触发 MatterPostAttributeChangeCallback——你自己的 LED 控制逻辑写在这一处就够了,遥控和本地按键走同一条路,不会出现"本地按了灯但 App 不同步"的裂缝。
属性持久化:Deferred 的 5 秒哲学
调亮度是个高频操作,用户按住滑块一路拖,CurrentLevel 属性每几十毫秒写一次。每次写都立刻刷 Flash 的话,几个回合就把 Flash 磨出问题。样例的做法:
// app_task.cpp
DefaultAttributePersistenceProvider gSimpleAttributePersistence;
DeferredAttributePersistenceProvider gDeferredAttributePersister(
gSimpleAttributePersistence,
Span<DeferredAttribute>(&gCurrentLevelPersister, 1),
System::Clock::Milliseconds32(5000)); // 延迟 5 秒持久化
// Init()
app::SetAttributePersistenceProvider(&gDeferredAttributePersister);
gSimpleAttributePersistence.Init(Nrf::Matter::GetPersistentStorageDelegate());
DeferredAttributePersistenceProvider 的逻辑是:CurrentLevel 变更先攒着,5 秒内没有新变更才真正落盘。一路拖滑块就一路推迟,停下来 5 秒后写一次。
代价要想清楚:这 5 秒窗口里断电,最后一次亮度就丢了。对灯来说丢一次亮度无伤大雅,所以这里是个教科书式的"按业务选持久化策略"的例子——如果换成门锁状态这种属性,你大概率不敢用 Deferred。

prj.conf 里的关键配置
样例的 prj.conf 值得逐行看,挑几条跟行为直接相关的:
CONFIG_CHIP=y
CONFIG_CHIP_PROJECT_CONFIG="src/chip_project_config.h"
CONFIG_CHIP_DEVICE_PRODUCT_NAME="Matter Light Bulb"
CONFIG_NCS_SAMPLE_MATTER_ZAP_FILE_PATH="${APPLICATION_CONFIG_DIR}/src/default_zap/light_bulb.zap"
CONFIG_CHIP_DEVICE_PRODUCT_ID=32773 # 0x8005
CONFIG_CHIP_ENABLE_PAIRING_AUTOSTART=y # 启动即配网
CONFIG_CHIP_BLE_EXT_ADVERTISING=y
CONFIG_CHIP_BLE_ADVERTISING_DURATION=60 # 60 分钟
CONFIG_BT_DEVICE_NAME="MatterLight"
CONFIG_CHIP_LIB_SHELL=y
CONFIG_CHIP_FACTORY_DATA=y
CONFIG_CHIP_FACTORY_DATA_BUILD=y
几条各有含义。ZAP_FILE_PATH 指定用哪个 .zap 文件做代码生成,也就是上一篇说的构建入口;PAIRING_AUTOSTART 让设备启动就进入可配网状态并开始 BLE 广告;ADVERTISING_DURATION=60 设定广播时长 60 分钟,超时就不再可发现,想再配网得手动触发;PRODUCT_ID=32773(0x8005)要在 Matter 官方的 VID/PID 数据库里登记过才能顺利过认证生态,自用测试无所谓;FACTORY_DATA 两项是出厂数据构建开关,第 5 篇讲证书和 SPAKE2+ 时会回头细看这一块。
CHIP_LIB_SHELL 打开的就是 Matter Shell,下一篇的主角,这里先按下不表。

接入方式不止一种
MatterPostAttributeChangeCallback 和 ClusterInit 回调覆盖了标准集群的常规需求,但 Matter 框架一共给了五种接入方式(第 9.1 节),后两种面向自定义集群:
① MatterPostAttributeChangeCallback——属性变更总回调,最常用 ② emberAf<Cluster>ClusterInitCallback——集群初始化,恢复状态 ③ AttributeAccessInterface——拦截自定义集群的属性读写 ④ CommandHandlerInterface——处理自定义集群命令 ⑤ Nordic 的 NRF_MATTER_CLUSTER_INIT 宏——v3.3.0 的 code-driven 注册,用 STRUCT_SECTION_ITERABLE 把初始化回调放进链接器 section,跟 BT_CONN_CB_DEFINE 一个机制,nrf_matter_cluster_init_run_all() 遍历执行
③④⑤ 第 7 篇自定义集群时会实际用到,本篇先把①②用熟。

小结
一条链路串起来:main() 进 StartApp(),三行封装拉起协议栈并开始 BLE 广告;控制器(或本地按键)改属性,集群实现回调 MatterPostAttributeChangeCallback,你在里面把属性值刷到 LED;Init 回调负责开机恢复;Accessor 让应用主动读写;Deferred 把高频属性写合并成 5 秒一次的落盘。
到这里,"点一盏 Matter 灯"的全貌已经有了。下一篇换个视角——站在控制器那一侧,用 chip-tool 和 Matter Shell 把这盏灯当靶子打,看看调试环境怎么搭。
写过其他协议栈的读者可以对比下:这套"回调 + Accessor"的分层,跟你用过的哪些框架神似?评论区聊聊。
说明:本文基于 nRF Connect SDK v3.3.0 源码与《Matter 协议栈开发指南》整理。文中回调函数签名、prj.conf 配置项均以 v3.3.0 实际源码为准;代码片段为讲解需要做了删减,完整实现以仓库为准。广告时长与 Product ID 的生态约束属于我的理解,如与官方文档出入,以官方为准。
网硕互联帮助中心



评论前必须登录!
注册