ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NRF51822主模式扫描连接实战:从扫描到连接的状态机与参数调优

NRF51822主模式扫描连接实战:从扫描到连接的状态机与参数调优

简介:这份资源面向低功耗蓝牙开发初学者与物联网嵌入式工程师,聚焦NRF51822芯片的主模式扫描与连接实现,解决主设备如何发现目标蓝牙模块、建立连接并完成主从数据透传的问题。压缩包共3个文件,包含2个C源文件与1个H头文件,整体约11KB,其中nrf_uart_at.c与nrf_uart_at.h封装了串口与BLE交互的接口,main.c则承载主流程逻辑,便于直接移植到Nordic开发环境。内容覆盖BLE初始化、扫描参数配置、广告数据解析、连接请求发起、连接参数协商以及自定义服务与特征值的数据读写,可帮助读者理解GATT服务下的透明传输机制。目前已有422人学习下载,适合需要快速搭建主从通信原型、构建远程控制或健康监测类物联网应用的开发者参考,也可作为蓝牙网络多跳通信的入门实践素材。

1. NRF51822主模式扫描连接:从被动等待到主动出击的那一步

很多刚接触低功耗蓝牙的开发者,第一次把NRF51822当从机跑通广播后,都会卡在同一个地方:两块板子面对面,却怎么也连不上。问题往往不在射频,而在于没人告诉你主模式扫描连接到底该怎么配。NRF51822主模式扫描连接,说白了就是让这颗芯片从被动广播的从机角色,切换到主动搜索、发起连接的主机角色。它解决的是设备间自主组网的问题,适合做数据采集网关、多节点传感器汇聚、手持终端主动读取设备信息这类场景。如果你只会从机广播,那设备永远只能等别人来连;学会主模式,你才能让设备自己去发现、去连接、去拿数据。这一章不堆概念,先把主模式扫描连接在NRF51822上到底意味着什么讲清楚,后面再一步步落到协议栈配置、参数设置和实际排错上。

2. 主模式扫描连接在协议栈里到底走了哪几步

2.1 扫描和连接是两个独立阶段,别混在一起调

NRF51822的蓝牙协议栈把主模式行为拆成了扫描和连接两个独立阶段。扫描阶段只负责监听空中广播包,把广播地址、RSSI、广播数据解析出来;连接阶段才是真正向目标设备发起连接请求,建立链路层连接。很多新手一上来就调连接参数,结果扫描都没扫到设备,自然连不上。常见做法是先用扫描把目标设备地址拿到,再拿这个地址去发起连接。扫描又分被动扫描和主动扫描:被动扫描只收广播包,主动扫描还会发扫描请求,让从机回扫描响应,能多拿一些数据。如果你只需要设备地址和RSSI,被动扫描就够了;如果要读扫描响应里的自定义数据,就得开主动扫描。

2.2 协议栈初始化时主模式相关的几个关键配置

NRF51822的协议栈初始化结构体里,跟主模式直接相关的是角色使能和外设数量配置。下面这段代码是常见的最小配置,只开中央角色,不开广播。

// 协议栈初始化:只使能中央角色,关闭广播 ble_gap_addr_t gap_addr; uint32_t err_code; // 设置本机蓝牙地址,主模式也需要一个合法地址 gap_addr.addr_type = BLE_GAP_ADDR_TYPE_RANDOM_STATIC; gap_addr.addr[0] = 0x01; gap_addr.addr[1] = 0x02; gap_addr.addr[2] = 0x03; gap_addr.addr[3] = 0x04; gap_addr.addr[4] = 0x05; gap_addr.addr[5] = 0xC0; err_code = sd_ble_gap_address_set(BLE_GAP_ADDR_CYCLE_MODE_NONE, &gap_addr); APP_ERROR_CHECK(err_code); // 协议栈使能参数:中央角色连接数设为1,外设角色连接数设为0 ble_enable_params_t ble_enable_params; memset(&ble_enable_params, 0, sizeof(ble_enable_params)); ble_enable_params.gap_enable_params.role = BLE_GAP_ROLE_CENTRAL; ble_enable_params.gap_enable_params.periph_conn_count = 0; ble_enable_params.gap_enable_params.central_conn_count = 1; ble_enable_params.common_enable_params.vs_uuid_count = 1; err_code = sd_ble_enable(&ble_enable_params); APP_ERROR_CHECK(err_code);

这段代码里,role设成BLE_GAP_ROLE_CENTRAL表示只做主机,periph_conn_count设0表示不接收别人连我,central_conn_count设1表示我最多连一个从机。如果你要做多节点汇聚,这个值可以往上调,但要注意NRF51822的RAM和连接数上限,一般建议不超过4个。地址类型用随机静态地址就行,不用纠结公共地址,主模式对地址类型不敏感。

2.3 扫描参数怎么设:间隔、窗口和超时

扫描参数直接决定你能不能扫到目标设备,以及扫到得快不快。下面是一个常用的扫描配置。

// 扫描参数配置:主动扫描,间隔160ms,窗口80ms,不超时 ble_gap_scan_params_t scan_params; memset(&scan_params, 0, sizeof(scan_params)); scan_params.active = 1; // 主动扫描,发扫描请求 scan_params.interval = MSEC_TO_UNITS(160, UNIT_0_625_MS); // 扫描间隔160ms scan_params.window = MSEC_TO_UNITS(80, UNIT_0_625_MS); // 扫描窗口80ms scan_params.timeout = 0; // 0表示不超时,一直扫 scan_params.selective = 0; // 不启用白名单过滤 err_code = sd_ble_gap_scan_start(&scan_params); APP_ERROR_CHECK(err_code);

interval是两次扫描窗口起始的时间差,window是每次扫描持续监听的时间。window必须小于等于interval,否则协议栈会报错。上面这组参数的意思是:每160ms里,有80ms在监听,占空比50%。占空比越高,扫到设备越快,但功耗越大。如果你做的是电池供电的网关,可以把window降到interval的20%左右,比如interval 160ms、window 32ms,牺牲一点扫描速度换功耗。timeout设0表示一直扫,设非0值则单位是10ms,比如设100就是1秒后自动停。实际项目里我一般先设0,扫到目标后再手动停,避免扫到一半超时了还得重来。

2.4 扫到设备后怎么发起连接

扫描回调里拿到目标设备地址后,就可以发起连接了。连接参数比扫描参数更敏感,设不好会连上就断。

// 扫描回调:拿到目标地址后发起连接 static void on_ble_adv_report(ble_gap_evt_adv_report_t const *p_adv_report) { // 判断广播地址是否为目标设备 if (memcmp(p_adv_report->peer_addr.addr, target_addr, 6) == 0) { // 先停止扫描,再发起连接 sd_ble_gap_scan_stop(); ble_gap_conn_params_t conn_params; memset(&conn_params, 0, sizeof(conn_params)); conn_params.min_conn_interval = MSEC_TO_UNITS(20, UNIT_1_25_MS); // 最小连接间隔20ms conn_params.max_conn_interval = MSEC_TO_UNITS(40, UNIT_1_25_MS); // 最大连接间隔40ms conn_params.slave_latency = 0; // 从机延迟0 conn_params.conn_sup_timeout = MSEC_TO_UNITS(4000, UNIT_10_MS); // 连接超时4s uint32_t err = sd_ble_gap_connect(&p_adv_report->peer_addr, &scan_params, &conn_params); APP_ERROR_CHECK(err); } }

连接参数里,min_conn_interval和max_conn_interval是连接间隔范围,单位1.25ms。上面设的是20ms到40ms,意思是双方协商一个在这个范围内的间隔。slave_latency允许从机跳过多少次连接事件不响应,设0表示每次都响应,设大了省电但响应慢。conn_sup_timeout是连接超时,单位10ms,设4000就是4秒。注意超时时间必须大于 (1 + slave_latency) × max_conn_interval × 2,否则协议栈会拒绝这组参数。我一般会留足余量,避免玄学断连。

3. 扫描连接过程中最容易翻车的几个地方

3.1 扫不到设备:先查地址类型和广播通道

现象是扫描回调一直不触发,或者只扫到别的设备就是扫不到目标。原因通常是目标设备用的是随机私有地址,每次上电地址都变,你拿旧地址去匹配自然匹配不上。解决方法是扫描时不要写死地址,而是根据广播数据里的厂商自定义字段或者设备名称来过滤。另一个常见原因是广播通道干扰,NRF51822默认在37、38、39三个通道轮流扫,如果周围2.4G设备太多,可以适当加大扫描窗口。还有个小概率情况是目标设备广播间隔太长,比如设了2秒,而你扫描窗口只有30ms,那就得等好几个周期才能撞上。

3.2 连上就断:连接参数超出从机接受范围

现象是连接建立后几百毫秒内就断开,错误码常见0x08或0x3B。原因是主机提的连接参数从机不接受,比如从机要求最小连接间隔不得低于100ms,你提了20ms,从机直接拒绝。解决方法是连接发起前先读从机的设备信息或者用通用连接参数,把min_conn_interval设大一点,比如100ms起步,连上后再通过连接参数更新流程往下降。NRF51822的协议栈支持连接后发起参数更新,不用一开始就卡死。

3.3 扫描和连接切换时协议栈返回忙

现象是调用sd_ble_gap_connect返回NRF_ERROR_BUSY。原因是扫描停止命令还没执行完,协议栈还在处理扫描停止事件,你就急着发起连接。解决方法是不要在扫描回调里直接调连接,而是设一个标志位,在主循环里等扫描停止事件BLE_GAP_EVT_SCAN_TIMEOUT或者BLE_GAP_EVT_ADV_REPORT处理完之后再发起连接。我一般会在扫描停止后延时10ms再调连接,给协议栈留出清理时间。

3.4 多设备环境下连错目标

现象是周围有好几个同型号设备,你连上了其中一个但不是你想要的那个。原因是只按地址过滤,而地址可能重复或者随机变化。解决方法是在广播数据里加一个自定义的厂商ID或者序列号字段,扫描时解析广播数据,匹配这个字段而不是只匹配地址。NRF51822的广播数据解析可以用ble_advdata_decode相关接口,把厂商自定义数据解出来比对。

3.5 连接成功后收不到从机数据

现象是连接建立成功,但GATT服务发现或者特征值读取一直失败。原因通常是连接间隔太短或者从机延迟设太大,导致数据交互窗口不够。解决方法是先把连接间隔放宽到50ms以上,slave_latency设0,确保每次连接事件双方都有机会收发。等链路稳定后再逐步收紧参数。另外注意NRF51822的协议栈事件回调里不要做耗时操作,否则会阻塞协议栈导致数据丢失。

4. 把主模式扫描连接做成可复用的状态机

4.1 用状态机管理扫描、连接、服务发现三个阶段

主模式流程不是一条直线,扫描可能失败要重扫,连接可能断开要重连,服务发现可能超时要重试。用状态机来管最稳。下面是一个简化状态机框架。

typedef enum { STATE_IDLE, // 空闲 STATE_SCANNING, // 扫描中 STATE_CONNECTING, // 连接中 STATE_DISCOVERING, // 服务发现中 STATE_CONNECTED, // 已连接可用 STATE_ERROR // 错误处理 } main_state_t; static main_state_t m_state = STATE_IDLE; // 主循环里根据状态推进 void main_loop(void) { switch (m_state) { case STATE_IDLE: start_scan(); // 启动扫描 m_state = STATE_SCANNING; break; case STATE_SCANNING: // 扫描回调里扫到目标后置标志,这里检查标志 if (scan_target_found) { stop_scan(); m_state = STATE_CONNECTING; } break; case STATE_CONNECTING: if (connect_success) { m_state = STATE_DISCOVERING; start_service_discovery(); } else if (connect_failed) { m_state = STATE_ERROR; } break; case STATE_DISCOVERING: if (discovery_done) { m_state = STATE_CONNECTED; } break; case STATE_CONNECTED: // 正常数据交互 break; case STATE_ERROR: // 错误处理:延时后重试 nrf_delay_ms(500); m_state = STATE_IDLE; break; } }

这个状态机的核心是把每个阶段的进入条件、退出条件和失败处理分开。扫描失败就回IDLE重扫,连接失败也回IDLE,服务发现失败可以重试一次再回IDLE。实际项目里我会给每个状态加一个超时计数器,比如扫描超过10秒没结果就强制回IDLE,避免卡死。

4.2 连接参数动态调整:先连上再优化

不要指望一次就把连接参数设到最优。我的习惯是分两步:第一步用保守参数连上,比如min_conn_interval 100ms、max_conn_interval 200ms、slave_latency 0、timeout 4s;第二步连上并完成服务发现后,再根据实际数据吞吐需求发起连接参数更新。NRF51822的协议栈里用sd_ble_gap_conn_param_update发起更新,从机可以接受也可以拒绝。如果从机拒绝,就保持保守参数继续用,别硬来。下面是一个参数更新的例子。

// 连接建立后,尝试更新到更快的连接参数 ble_gap_conn_params_t new_params; new_params.min_conn_interval = MSEC_TO_UNITS(30, UNIT_1_25_MS); new_params.max_conn_interval = MSEC_TO_UNITS(50, UNIT_1_25_MS); new_params.slave_latency = 0; new_params.conn_sup_timeout = MSEC_TO_UNITS(4000, UNIT_10_MS); uint32_t err = sd_ble_gap_conn_param_update(conn_handle, &new_params); if (err != NRF_SUCCESS) { // 更新失败就保持原参数,不影响使用 }

4.3 扫描过滤:用白名单和自定义数据减少无效连接

如果周围设备很多,每次扫描都遍历所有广播包效率很低。NRF51822支持白名单过滤,可以先把目标设备地址加到白名单里,扫描时只上报白名单内的设备。白名单用sd_ble_gap_whitelist_set设置,最多支持8个地址。但白名单对随机地址不友好,因为随机地址会变。更通用的做法是在扫描回调里解析广播数据,只处理包含特定厂商ID或服务UUID的广播包,其他直接忽略。这样即使周围有几十个设备,你的扫描回调也只会被目标设备触发。

5. 主模式扫描连接的一个实战技巧:用RSSI做连接决策

5.1 为什么RSSI比地址更值得关注

地址只能告诉你“这是不是目标设备”,RSSI能告诉你“这个设备离我多远、信号稳不稳”。在多节点场景里,同一个设备可能被多个网关扫到,这时候选RSSI最强的那个网关去连,链路质量最好。NRF51822的扫描回调里,p_adv_report->rssi就是当前广播包的信号强度,单位dBm,典型值在-30到-90之间。我一般会设一个阈值,比如-70dBm,低于这个值就不发起连接,避免连上就断。

5.2 用滑动平均过滤RSSI抖动

单次RSSI抖动很大,直接拿来决策容易误判。常见做法是维护一个最近N次RSSI的滑动平均,N取5到10。下面是一个简单的滑动平均实现。

#define RSSI_WINDOW_SIZE 8 static int8_t rssi_buf[RSSI_WINDOW_SIZE]; static uint8_t rssi_idx = 0; static uint8_t rssi_cnt = 0; int8_t rssi_filter(int8_t new_rssi) { rssi_buf[rssi_idx] = new_rssi; rssi_idx = (rssi_idx + 1) % RSSI_WINDOW_SIZE; if (rssi_cnt < RSSI_WINDOW_SIZE) rssi_cnt++; int16_t sum = 0; for (uint8_t i = 0; i < rssi_cnt; i++) { sum += rssi_buf[i]; } return (int8_t)(sum / rssi_cnt); }

这个函数每收到一个广播包就更新一次,返回平滑后的RSSI。用平滑值跟阈值比较,比单次值稳得多。注意RSSI是负值,比较的时候别搞反了,-60比-80强。

5.3 连接后持续监控RSSI决定是否断开重连

连上不代表一劳永逸。设备可能移动,信号可能变差。我一般会在连接状态下定期读一次RSSI,NRF51822可以用sd_ble_gap_rssi_get获取当前连接的RSSI。如果连续几次低于断开阈值,比如-85dBm,就主动断开,回到扫描状态重新找更强的节点。这个策略在移动场景里特别有用,避免抱着一个弱链路死扛。但要注意别频繁断开重连,加一个滞回区间,比如低于-85断开、高于-75才允许重连,中间区域保持现状。

5.4 一个容易忽略的细节:扫描期间的功耗控制

主模式扫描比从机广播更耗电,因为接收窗口占空比高。如果设备是电池供电,扫描参数要精打细算。我的经验是:扫描间隔设200ms、窗口设30ms,占空比15%,配合RSSI阈值过滤,大部分场景够用。如果目标设备广播间隔是100ms,那200ms的扫描间隔意味着平均2个广播周期能撞上1次,可以接受。真要追求低功耗,还可以用sd_ble_gap_scan_start的timeout参数做间歇扫描,扫5秒停5秒,进一步降功耗。这些参数没有标准答案,得根据你的目标设备广播行为和功耗预算去试。我踩过的坑是照搬别人的参数,结果自己的设备广播间隔是500ms,扫了半分钟没扫到,还以为是代码问题。后来把扫描窗口拉长到100ms才解决。所以参数这东西,一定要对着自己的实际设备调,别迷信任何一组“万能参数”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进