如果你用 nRF52 跑过 SDK 里的 BLE_HeartRate(心率)示例,应该能感觉到它的"完整"其实带着一点空:手机连上、心率数字跳起来,但板子上的 LED 基本只在广播和连接时按固定逻辑亮一下,和心率数据本身没关系。这篇文章要做的,就是把这个看似简单的"用 LED 配合 BLE_HeartRate"需求拆开讲,从硬件接法、GPIO 配置、心率回调里的非阻塞控制,到不同呈现效果的代码落地,覆盖从"能亮"到"亮得有意义"的完整过程。如果你正在做的心率监测手环、运动配件或者基于 BLE 通知的桌面小设备也需要一个状态灯,这篇内容可以直接参考。
1. BLE_HeartRate 示例在教什么:先搞清楚 LED 该放在哪一层
1.1 从示例工程到真实需求
BLE_HeartRate(在 Nordic SDK 里对应 ble_app_hrs 这个 peripheral 示例)是很多人的第一个 BLE 项目,它做的事情一句话就能说清楚:把心率值通过 Heart Rate Service(0x180D)的 Heart Rate Measurement 特征(0x2A37)发给手机。
但这个示例能跑通之后,真正进入产品阶段就发现差得远。心率数据是有了,可用户拿在手上根本不知道设备现在处于什么状态:是在广播、已连接、还是在报错。更细的需求也很常见:很多心率胸带、运动手环会用 LED 在每次检测到心跳的时候闪一下,作为"传感器工作正常"的直观反馈。
这个需求听起来很傻:LED 亮一下而已,GPIO 拉高拉低不就完了。但放在 BLE 工程里真没那么简单。LED 控制的时机、延时方式、与协议栈的配合,甚至 LED 的亮度,都会影响整套嵌入式系统的行为。我在帮客户做心率臂带样机时,第一版就是直接把 LED 写成delay亮灭,结果手机端心率曲线直接断掉,因为协议栈事件被卡住了。
1.2 LED 在心率项目中的角色划分
按我自己的经验,LED 在心率项目里至少有三种独立职责,不建议混在一起:
- 生命周期指示:广播中、已连接、断开连接,对应不同闪烁节奏。这个通常用 BSP 或自定义状态机做。
- 事件指示:每次收到心率测量事件时 LED 闪一下或呼吸一下,代表"数据在流动"。
- 摄像头或传感器补光:如果是光学心率传感器(比如 MAX30102),LED 是传感器工作的一部分,不归我们控制。
本文只讲前两种,尤其是第二种。因为第一种很多 SDK 自带,第二种才是这里的关键。如果你用的是自带 PPG 传感器的模组,还要注意传感器内部的红外 LED 和外部指示灯不要共用电源,否则模拟前端会被拉偏,导致心率波形异常。
1.3 硬件与软件的边界
LED 看起来是硬件,但在 BLE 工程里它往往是最能反映软件结构的东西。你如果把 LED 闪烁逻辑直接写在 busy wait 里,大概率影响协议栈;你如果让一个 GPIO 输出和某个外设共用引脚,那 LED 大概率不亮。
所以动手之前先想清楚三件事:LED 在哪个 GPIO 上、由哪个模块控制、离心率数据多少层。想明白这三件事,后面写代码就是填坑。具体来说,我一般会先画一个简单的调用链:
心率传感器 -> 心率算法模块 -> BLE 服务(发送通知) -> LED 驱动模块(事件反馈)这个调用链越干净,后面调试越省心。不要把 LED 的逻辑写在 BLE 服务模块里,也不要在心率算法模块里去操作 GPIO,否则任何一个模块的改动都会牵动其他模块。
2. 硬件连接与选型:把 LED 接到 nRF52 的最小原则
2.1 引脚选择与板载 LED 的资源占用
如果你用的是 nRF52 DK,板子上已经有 4 个 LED。它们通常定义在 board.h(比如 pca10040.h / pca10056.h)里的BSP_LED_0到BSP_LED_3。
我先建议一个很实用的做法:把BSP_LED_0留给 SDK 的默认状态指示,BSP_LED_1留给我们自己的心率脉冲灯。这样两个功能互相不干扰,调试起来也直观。板上 LED 的驱动能力有限,但作为指示灯完全够用。
如果你要外接 LED,选引脚时避开这些大概率被占用的资源:
- nRF52832 / nRF52840 的 UART 默认引脚
- SPI 的 SCK、MOSI、MISO
- 板载外部 Flash、调试打印口
- 已经分配给其他传感器中断的引脚
一个简单的原则:优先选BSP_LED_2/BSP_LED_3,或者在 datasheet 里标记为普通 GPIO 的 P0/P1 引脚。选好之后在代码里用宏定义写清楚,不要在任意位置散落裸的数字。我见过有人在 main.c 里直接写nrf_gpio_cfg_output(19),三天后再看代码完全不知道那个 19 是哪颗引脚。
2.2 限流电阻计算:先算再焊
绝大多数用 LED 直接接在 GPIO 上的翻车现场,都是因为没算限流电阻。nRF52 的 GPIO 并不是大电流输出接口,实测单引脚做到 5mA 级别的电流就差不多了,保守一点按 2 到 3mA 设计。
公式很简单:
R = (VDD - V_F) / I_F以 nRF52 的 VDD=3.3V 为例:
- 红色 LED 压降 V_F 约 1.8V,取 I_F=3mA,R=(3.3-1.8)/0.003=500Ω,实际使用 470Ω 或 510Ω;
- 绿色 LED 的 V_F 约 2.0V,R=(3.3-2.0)/0.003约等于 433Ω,同样选 470Ω;
- 蓝色 LED 的 V_F 约 2.8V,R=(3.3-2.8)/0.003约等于 166Ω,可选 220Ω。
有人觉得 1kΩ 更省电,也行,LED 亮度会低一点,但在室内做状态指示完全够用。关键是不同颜色的 LED 不能直接用同一个电阻,蓝色灯用 470Ω 会明显偏暗。如果你用的是白色 LED,V_F 通常更高,接近 3.0V 或者 3.2V,这时候 3.3V 供电下电阻余量很小,建议换成低 V_F 的灯,或者改由外部 5V 经过三极管驱动,避免 GPIO 直接驱动。
2.3 电平极性:高电平点亮还是低电平点亮
这是新手最容易踩的第一个实际坑。nRF52 DK 板载 LED 的接法是 VDD 经过限流电阻接到 LED 阳极,LED 阴极接到 GPIO。也就是说,GPIO 输出低电平时 LED 才亮。而外接 LED 更常见的接法是把 GPIO 当作高电平输出源,LED 阳极接 GPIO,阴极经过电阻接地。
同一个nrf_gpio_pin_set()在这两种接法下效果完全相反。所以我建议在代码里做一个统一的开关宏:
#define LED_HEART_PIN BSP_LED_1 #if defined(LED_ACTIVE_HIGH) && (LED_ACTIVE_HIGH == 1) #define LED_HEART_ON() nrf_gpio_pin_set(LED_HEART_PIN) #define LED_HEART_OFF() nrf_gpio_pin_clear(LED_HEART_PIN) #else #define LED_HEART_ON() nrf_gpio_pin_clear(LED_HEART_PIN) #define LED_HEART_OFF() nrf_gpio_pin_set(LED_HEART_PIN) #endif实测下来这个抽象能省掉很多板间移植的痛苦。同一套代码在 DK 上调试、在自己画的板子上量产时,只需要改宏,不需要去每个调用点排查。你可以在板级头文件里定义LED_ACTIVE_HIGH的值,一个平台一个配置,代码主体完全不用动。
3. 代码改造:从 ble_app_hrs 工程到"LED 随心率跳"
3.1 定位心率测量通知的发送点
你首先要在示例工程里找到心率数据是从哪里发给手机端的。在 Nordic SDK 的 ble_app_hrs 示例中,入口在 main.c,核心的发送调用是:
ble_hrs_heart_rate_measurement_send(&m_hrs, hr_value);有的版本还会带 RR 间隔数组。不管具体形式怎样,我们要找的就是这个函数被调用的位置。因为每次调用,说明一次心率测量值已经准备好并且发出了。
我建议不要修改 BLE 心率服务模块内部代码,而是在调用这个 API 的地方加入 LED 触发逻辑。这样把"协议栈业务"和"外设呈现"解耦,后面想换 LED 驱动逻辑也不影响心率功能。
如果你用的是真实传感器(比如 MAX30102),发送点在传感器的采样完成回调里;如果你用的是模拟心率,发送点在 app_timer 周期回调里。两种情况下思路一样:找到那行发送函数,在它附近插入 LED 控制逻辑。
3.2 用 app_timer 做一个干净的非阻塞点灯延时
很多刚上手的人会直接在发送心率值之后写:
nrf_gpio_pin_clear(LED_HEART_PIN); // 点亮 nrf_delay_ms(200); // 等 200ms nrf_gpio_pin_set(LED_HEART_PIN); // 熄灭这个代码在 standalone 的 LED 测试里没问题,放进 BLE 工程里就是事故。因为nrf_delay_ms会让 CPU 死等 200ms,而 softdevice(BLE 协议栈)中断可能被长期阻塞,导致连接断开、通知超时、掉包。
正确的姿势是开一个单次定时器:点亮 LED 后启动定时器,定时器到了再熄灭。LED 亮灭逻辑不占用主循环。
#include "app_timer.h" #define LED_HEART_PULSE_MS 120 APP_TIMER_DEF(m_led_heart_off_timer); static void led_heart_off_handler(void * p_context) { LED_HEART_OFF(); } static void led_heart_init(void) { nrf_gpio_cfg_output(LED_HEART_PIN); LED_HEART_OFF(); APP_TIMER_CREATE(&m_led_heart_off_timer, APP_TIMER_MODE_SINGLE_SHOT, led_heart_off_handler); } static void led_heart_pulse(uint16_t duration_ms) { LED_HEART_ON(); ret_code_t err_code = app_timer_start(&m_led_heart_off_timer, APP_TIMER_TICKS(duration_ms), NULL); APP_ERROR_CHECK(err_code); }注意APP_TIMER_CREATE要在 app_timer 初始化之后才能调用,一般放在 main() 里 BSP 初始化之后。如果你的工程里有多个 app_timer 实例,还要留意 sdk_config.h 里APP_TIMER_MAX_TIMERS是否足够,默认值不够时会导致创建返回值异常。
3.3 把 LED 状态和 BSP 默认状态机错开
不少使用 Nordic SDK 的工程都会在 main.c 里调用bsp_init(),BSP 默认会占板载 LED。如果你把心率 LED 也放在BSP_LED_0上,很容易出现你这边刚点亮、BSP 那边又把它改了,最后闪烁状态无法预测。
我的习惯是初始化时做取舍。如果只需要 BSP 管理按键、不需要它管理 LED,可以用:
bsp_init(BSP_INIT_BUTTON, bsp_evt_handler);或者仍然让 BSP 管 LED,但把我们的自定义 LED 分到别的引脚。比如用BSP_LED_0做广播和连接状态指示,BSP_LED_1做心率脉冲。二者互不冲突,这是最省心的方案。
如果你确实想"独占"所有 LED,那可以在bsp_init里不传BSP_INIT_LED,然后在自己的状态机里控制全部板载 LED。这样自由度最高,但广播和连接的指示也要自己实现,工作量会大一些。对于新手,我更推荐两个 LED 分工的方案。
3.4 完整参考:心率定时器回调里加一行
以 ble_app_hrs 里常见的心率模拟逻辑为例,它的定时器回调每隔一个 RR 周期产生一次心率值。加入 LED 之后的写法如下:
static void heart_rate_timer_handler(void * p_context) { uint16_t hr = get_current_heart_rate(); ret_code_t err_code = ble_hrs_heart_rate_measurement_send(&m_hrs, hr); if (err_code != NRF_SUCCESS) { return; } led_heart_pulse(LED_HEART_PULSE_MS); }这样每次手机端收到一条心率通知,LED 就亮 120ms 后自动熄灭。视觉上就是每到一个心跳周期闪一下,实际上它闪的是"通知事件",不是"原始脉搏"。如果你的心率模拟器每 100ms 就发一次数据,LED 看起来会像高频闪烁,那不是代码写错了,是模拟数据太快,后面会专门讲这个问题。
4. 三种 LED 呈现方案:从"亮灭"到"呼吸"的演进
4.1 普通脉冲模式:每心跳亮 120ms
上一章的方案就是普通脉冲模式,最简单也最可靠。对于大多数验证性项目,做到这一步已经满足需求。
不过有一点要