nRF52实战:为BLE心率示例添加LED指示 📅 发布时间:2026/8/31 1:51:36 👁 浏览次数: 如果你用 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 Service0x180D的 Heart Rate Measurement 特征0x2A37发给手机。但这个示例能跑通之后真正进入产品阶段就发现差得远。心率数据是有了可用户拿在手上根本不知道设备现在处于什么状态是在广播、已连接、还是在报错。更细的需求也很常见很多心率胸带、运动手环会用 LED 在每次检测到心跳的时候闪一下作为传感器工作正常的直观反馈。这个需求听起来很傻LED 亮一下而已GPIO 拉高拉低不就完了。但放在 BLE 工程里真没那么简单。LED 控制的时机、延时方式、与协议栈的配合甚至 LED 的亮度都会影响整套嵌入式系统的行为。我在帮客户做心率臂带样机时第一版就是直接把 LED 写成delay亮灭结果手机端心率曲线直接断掉因为协议栈事件被卡住了。1.2 LED 在心率项目中的角色划分按我自己的经验LED 在心率项目里至少有三种独立职责不建议混在一起生命周期指示广播中、已连接、断开连接对应不同闪烁节奏。这个通常用 BSP 或自定义状态机做。事件指示每次收到心率测量事件时 LED 闪一下或呼吸一下代表数据在流动。摄像头或传感器补光如果是光学心率传感器比如 MAX30102LED 是传感器工作的一部分不归我们控制。本文只讲前两种尤其是第二种。因为第一种很多 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 的 VDD3.3V 为例红色 LED 压降 V_F 约 1.8V取 I_F3mAR(3.3-1.8)/0.003500Ω实际使用 470Ω 或 510Ω绿色 LED 的 V_F 约 2.0VR(3.3-2.0)/0.003约等于 433Ω同样选 470Ω蓝色 LED 的 V_F 约 2.8VR(3.3-2.8)/0.003约等于 166Ω可选 220Ω。有人觉得 1kΩ 更省电也行LED 亮度会低一点但在室内做状态指示完全够用。关键是不同颜色的 LED 不能直接用同一个电阻蓝色灯用 470Ω 会明显偏暗。如果你用的是白色 LEDV_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而 softdeviceBLE 协议栈中断可能被长期阻塞导致连接断开、通知超时、掉包。正确的姿势是开一个单次定时器点亮 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上一章的方案就是普通脉冲模式最简单也最可靠。对于大多数验证性项目做到这一步已经满足需求。不过有一点要