
简介这份资源面向低功耗蓝牙开发初学者与物联网嵌入式工程师聚焦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决定是否断开重连连上不代表一劳永逸。设备可能移动信号可能变差。我一般会在连接状态下定期读一次RSSINRF51822可以用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才解决。所以参数这东西一定要对着自己的实际设备调别迷信任何一组“万能参数”。希望帮到你。本文还有配套的精品资源点击获取