ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

室内定位实战:Arduino+BLE4.0实现RSSI测距与三边定位

室内定位实战:Arduino+BLE4.0实现RSSI测距与三边定位 做室内定位这件事我最早是从一个尴尬的现场开始的设备明明在工位正前方蓝牙网关却报出它在隔壁会议室。后来用RSSI信号强度指示做了一套基于Arduino和BLE4.0的定位系统虽然不是商品级UWB那种厘米级精度但在开阔环境下能把误差压到半米左右某些点位甚至真的做到了厘米级。这篇就完整复盘整个实战过程从原理、硬件、滤波到定位算法、完整代码一次讲透方便你直接抄作业。1. 项目整体设计与思路拆解1.1 为什么选Arduino BLE4.0这套组合上手这套方案之前我先说结论室内定位方案很多蓝牙RSSI只是其中性价比最高、门槛最低的一条路。WiFi指纹定位需要先做繁琐的离线指纹采集UWB超宽带的硬件价格对个人项目不友好而BLE4.0的nRF51822或者CC2541模块十几块钱就能买到Arduino Uno更是学生党和DIY玩家的老朋友两者配合能快速搭出第一版原型。选BLE4.0而不是BLE5.0主要是考虑兼容性和资料成熟度。BLE4.0就是常说的蓝牙4.0低功耗版本几乎所有手机和开发板都支持而且RSSI广播包格式简单用Arduino的串口直接读就行。BLE5.0虽然增加了广播扩展、传输速率更高但很多老模块并不支持做定位研究时反而容易踩坑。需要注意的是这个项目本质上是基于信号强度的距离估计系统而不是高精度测距系统。BLE4.0广播包里的RSSI值会随着环境温度、湿度、人体遮挡、多径效应剧烈跳动所以后续的滤波和算法才是决定精度的关键。换句话说硬件只提供了原始信号真正的功夫在数据处理。1.2 核心需求解析厘米级定位需要哪些前置条件标题里写了“厘米级”我必须先泼一盆冷水在真实室内环境下纯RSSI想稳定实现厘米级是不可能的。这个精度目标要成立至少得满足三个前置条件环境相对空旷没有大量金属反射面或密集人流走动定位目标与锚点固定基站之间的距离在1米以内信号强度衰减曲线非常陡峭部署至少三个锚点且锚点坐标精确已知形成良好的几何拓扑。我在项目里实际测出来的结果是在3米范围内经过中值滤波和三边定位计算后平均误差大约在0.3到0.5米在1米内、视线无遮挡时误差最小能到10厘米左右。之所以有人宣传厘米级通常是在严格控制的实验舱里做的那里几乎没有多径干扰信号传播非常接近自由空间模型。所以这篇博文的目标读者是想低成本做一套能跑通的室内定位原型、理解RSSI测距全链路、为后续上卡尔曼滤波或更高精度算法打基础的朋友。我会尽量把每个环节的真实表现讲透让你做完之后心里有数不会被不切实际的宣传误导。2. 硬件选型、组装与关键参数标定2.1 硬件清单、接线方式与注意事项我这次用的是两块Arduino Uno作为两个锚点基站一个CC2541 BLE模块作为信标节点被定位目标其实CC2541本身可以单独供电所以信标端不需要Arduino这也是BLE功耗低的优势——一颗CR2032纽扣电池能跑很久。硬件部件型号/规格数量作用Arduino Uno R3ATmega328P2作为锚点主控读取RSSIBLE模块CC2541AT指令版3两个做锚点广播端一个做信标USB转TTL模块CP2102 / CH3401调试用配置CC2541模块参数面包板与杜邦线公对母若干若干临时接线电源5V USB供电2给两块Arduino供电接线方式需要注意一个细节CC2541模块的TXD要接Arduino的RX0号引脚RXD接Arduino的TX1号引脚两者共地。我在第一次接线时直接把模块的TXD接到了Arduino的TXD结果串口什么都读不到——这是新手最容易踩的坑UART通信必须交叉连接。配置CC2541的参数时需要先用USB转TTL模块连接模块与电脑打开串口助手发送AT指令。关键是要把模块的MAC地址记录清楚锚点端根据MAC区分不同的信标信号。我实测用“ATADDR”指令读取MAC然后用“ATROLE1”设置为主设备模式让CC2541作为广播接收端另一边信标用“ATROLE0”设置为从设备模式。幕后的核心逻辑是Arduino通过串口向CC2541发送AT指令让模块开启被动扫描然后把收到的设备名、MAC地址、RSSI值原样返回给Arduino。CC2541工作在透传模式时数据帧的解析非常稳定这一点比HM-10模块更让我放心但HM-10也行两者指令集基本都是兼容的。2.2 为什么必须做RSSI标定而非直接套公式很多教程会直接给出一个RSSI测距公式距离 10^((A - RSSI) / (10 * n))其中A是1米处信号强度n是路径损耗指数。你直接把A -59、n2套进去得到的结果往往非常离谱测两米距离算出5米。原因很简单这个公式是在理想自由空间中推导的而真实环境有墙壁、桌椅、人体每个环境的A和n都不一样。我这次的标定过程是这样的把信标放在离锚点1米处连续读50个RSSI值取均值作为A然后把信标放在3米处再读50个RSSI值利用公式反推n。实测下来我办公室环境的A约为 -61 dBmn约为 1.8——注意这个n比自由空间的2还小说明环境里的反射波在一定距离内反而让信号衰减变慢了这也是室内定位的诡异之处。串口读取原生数据的时候Arduino端要加一个延时比如每次读取间隔50毫秒避免缓冲区溢出。我的做法是每收满10个RSSI值就做一次均值再把均值通过串口传给上位机Python处理而不是把原始数据全部堆积在Arduino内存里——Arduino Uno的SRAM只有2KB一旦数据量大了就会出现莫名其妙的复位重启。2.3 锚点位置设计避免几何奇异锚点不是随便放的三个点如果排成一条直线定位结果会在垂直方向出现巨大的不确定性。我在部署时选择的是直角三角形的三个顶点布局锚点1坐标(0,0)锚点2坐标(3,0)锚点3坐标(0,4)单位米。这样信标无论走到哪个位置至少有两个锚点的测距线是正交的三边交汇的几何条件就比较好。这里有个小技巧部署高度要一致。如果锚点一个放在桌面高度0.75米另一个挂在天花板高度2.8米纵向高度差会直接换算成水平距离误差。我统一把锚点放在三脚架上离地1.2米和信标的佩戴高度尽量一致。实测下来高度差每多20厘米水平定位误差会增加约10到15厘米大家注意这个细节。3. 数据滤波与预处理决定精度的隐形战场3.1 滑动窗口中值滤波实现RSSI原始数据我看过一次心都凉了信标静止不动两秒钟内读数从-52跳到-74窗口达22dB换算成距离大约是1米到4米的巨大抖动。如果把这堆垃圾数据直接丢给定位算法结果完全是随机数。滤波是整套系统的灵魂。我采用的第一个方案是滑动窗口去极值平均维护一个长度为5的窗口每次采样先去掉窗口内的最大值和最小值然后对剩余3个值取平均。这个方法比普通均值滤波好很多因为RSSI的噪声通常是脉冲性的极值对均值影响极大但把极值扔掉就稳多了。Arduino端实现代码如下// 滑动窗口中值滤波头文件 #ifndef RSSI_FILTER_H #define RSSI_FILTER_H class RssiFilter { private: int buffer[5] {0}; int index 0; int count 0; public: int addSample(int rssi) { buffer[index] rssi; index (index 1) % 5; if (count 5) count; if (count 3) return rssi; // 数据不够时不滤波 int tmp[5]; for (int i 0; i count; i) tmp[i] buffer[i]; // 冒泡排序 for (int i 0; i count - 1; i) { for (int j 0; j count - i - 1; j) { if (tmp[j] tmp[j1]) { int t tmp[j]; tmp[j] tmp[j1]; tmp[j1] t; } } } if (count 5) { return tmp[2]; // 中间值 } else { int sum 0; for (int i 0; i count; i) sum tmp[i]; return sum / count; } } void reset() { count 0; index 0; } }; #endif这段代码的核心理念是滑动窗口保证时效性中值保证鲁棒性。如果连续采样50个点做全局中值信标已经移动了反应太慢。5个点窗口的延时大约250毫秒刚好能满足人步行速度下的定位需求。3.2 高斯滤波与卡尔曼滤波的取舍滑动窗口中值滤波只能对付脉冲噪声但对高斯白噪声的抑制能力一般。如果你的环境比较安静可以升级为高斯滤波也就是对最近N个样本按正态分布概率权重做加权平均。思路是RSSI的真值附近分布密集离真值远的点概率低权重应该小。但我在实际项目里发现高斯滤波的提升没有想象中大反而计算开销让Arduino Uno有些吃力。后来我直接在上位机Python里用了卡尔曼滤波状态量只有一维RSSI值过程噪声协方差Q设为0.01测量噪声协方差R设为4.0。这一组参数是反复调出来的R设太大则滤波太迟钝设太小则噪声抑制不住4.0在我这个环境里效果刚好。对精度要求更高的场景更推荐使用扩展卡尔曼滤波EKF将位置坐标作为状态量、三个锚点的RSSI作为观测值直接进行融合定位而不是先滤波再测距再定位的链式方案。链式方案的误差会逐级放大EKF一步到位的效果好很多。但EKF的代码量复杂度确实高了不少适合已经跑通基础方案后再迭代优化。4. 定位算法与完整代码实现4.1 对数路径损耗模型与最小二乘三边定位滤波完成之后我拿到了三个锚点各自到信标的RSSI估计值。接下来第一步是把RSSI转换成距离采用上面说过的公式d 10 ^ ((A - RSSI) / (10 * n))其中A和n来自第2.2节的实地标定。但三边定位通常不会完美交汇于一个点因为测距误差的存在三个圆会交出一个区域而不是一个点。这时需要用最小二乘法求最优近似解。假设锚点坐标分别为(x1, y1)、(x2, y2)、(x3, y3)测得的距离为d1、d2、d3目标是求信标坐标(x, y)使得残差平方和最小。线性化处理的方式是用第二和第三个锚点的方程分别减去第一个锚点的方程消去二次项得到线性方程组2(x2 - x1) * x 2(y2 - y1) * y d1^2 - d2^2 - x1^2 x2^2 - y1^2 y2^2 2(x3 - x1) * x 2(y3 - y1) * y d1^2 - d3^2 - x1^2 x3^2 - y1^2 y3^2这就是经典的Ax b形式用矩阵求解x (A^T A)^(-1) A^T b即可。Python端用numpy的lstsq函数一行就能解出来Arduino端也可以手写高斯消元法但我不建议在Arduino上做因为它算力有限而且浮点运算慢定位解算放在上位机更合理。4.2 Arduino端完整代码扫描、滤波、串口上报我直接贴上锚点端Arduino的完整代码这份代码在Arduino IDE 1.8.19环境中编译通过注意需要安装SoftwareSerial库IDE自带。#include SoftwareSerial.h // 定义软串口RX 接 CC2541 的 TXDTX 接 CC2541 的 RXD SoftwareSerial bleSerial(10, 11); // RX, TX // 目标信标 MAC 地址根据实际配置修改 const String TARGET_MAC CD80:6A:90:12:34:56; // 滑动窗口参数 const int WINDOW_SIZE 5; int rssiBuffer[WINDOW_SIZE]; int bufferIndex 0; int sampleCount 0; // 锚点编号用于上位机识别 const int ANCHOR_ID 1; void setup() { Serial.begin(115200); bleSerial.begin(9600); pinMode(10, INPUT); pinMode(11, OUTPUT); // 清空缓冲区 memset(rssiBuffer, 0, sizeof(rssiBuffer)); // 配置 CC2541 模块 delay(500); bleSerial.println(AT); delay(100); bleSerial.println(ATIMME1); // 立即进入低功耗模式 delay(100); bleSerial.println(ATROLE1); // 设置为主设备扫描方 delay(100); bleSerial.println(ATSCAN1); // 开启连续扫描 delay(100); Serial.println(BLE RSSI Anchor Ready); } void loop() { if (bleSerial.available() 0) { String data bleSerial.readStringUntil(\n); if (data.indexOf(TARGET_MAC) 0) { int rssiIndex data.indexOf(RSSI:); if (rssiIndex 0) { String rssiStr data.substring(rssiIndex 5); rssiStr.trim(); int rawRssi rssiStr.toInt(); int filteredRssi addSample(rawRssi); // 输出格式: ANCHOR_ID,RSSI Serial.print(ANCHOR_); Serial.print(ANCHOR_ID); Serial.print(,); Serial.println(filteredRssi); } } } // 每 50ms 发送一次扫描指令保持活跃 static unsigned long lastScan 0; if (millis() - lastScan 50) { bleSerial.println(ATSCAN1); lastScan millis(); } } int addSample(int rssi) { rssiBuffer[bufferIndex] rssi; bufferIndex (bufferIndex 1) % WINDOW_SIZE; if (sampleCount WINDOW_SIZE) sampleCount; if (sampleCount 3) return rssi; // 复制并排序 int tmp[WINDOW_SIZE]; for (int i 0; i sampleCount; i) tmp[i] rssiBuffer[i]; for (int i 0; i sampleCount - 1; i) { for (int j 0; j sampleCount - i - 1; j) { if (tmp[j] tmp[j1]) { int t tmp[j]; tmp[j] tmp[j1]; tmp[j1] t; } } } if (sampleCount WINDOW_SIZE) { return tmp[2]; } else { int sum 0; for (int i 0; i sampleCount; i) sum tmp[i]; return sum / sampleCount; } }这里有两个关键点。第一CC2541模块的AT指令响应格式因固件版本而异我用的Z-duino固件返回格式是“SCAN:MAC,RSSI”有些版本可能是“OKCONN”之类你需要先用串口助手摸清格式再改代码。第二ATSCAN1这个指令不要连续高频发送实测间隔50ms已经足够太频繁会让模块死机或返回乱码。4.3 Python端定位解算与实时显示上位机我用的是Python pyserial numpy matplotlib从串口读取三个锚点上报的RSSI实时解算坐标并绘制轨迹。核心代码不长但整个逻辑闭环了。import serial import numpy as np import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation # 锚点坐标单位米 ANCHORS { ANCHOR_1: (0, 0), ANCHOR_2: (3, 0), ANCHOR_3: (0, 4) } # 标定参数 A -61.0 n 1.8 # 存储每个锚点的最新RSSI rssi_values {} def rssi_to_distance(rssi): return 10 ** ((A - rssi) / (10 * n)) def trilaterate(d1, d2, d3): # 锚点坐标 (x1, y1) ANCHORS[ANCHOR_1] (x2, y2) ANCHORS[ANCHOR_2] (x3, y3) ANCHORS[ANCHOR_3] # 线性化 A_mat np.array([ [2 * (x2 - x1), 2 * (y2 - y1)], [2 * (x3 - x1), 2 * (y3 - y1)] ]) b_mat np.array([ d1**2 - d2**2 - x1**2 x2**2 - y1**2 y2**2, d1**2 - d3**2 - x1**2 x3**2 - y1**2 y3**2 ]) try: result np.linalg.lstsq(A_mat, b_mat, rcondNone)[0] return result[0], result[1] except: return None def read_serial(ser): if ser.in_waiting 0: line ser.readline().decode(utf-8, errorsignore).strip() parts line.split(,) if len(parts) 2: anchor_id parts[0].strip() rssi int(parts[1].strip()) rssi_values[anchor_id] rssi串口部分需要注意三个锚点接电脑需要三个USB口在Windows上会分配不同COM口Linux上一般是ttyUSB0、ttyUSB1、ttyUSB2。我写了个简单循环读取三个串口放在一个线程里持续更新rssi_values。matplotlib的FuncAnimation负责每100ms刷新一次位置点。5. 实测效果、问题排查与精度优化实录5.1 实测结果汇总不同距离下的误差表现我把信标放在几个固定点上每个点测30秒记录定位结果与实际坐标的误差。表里统计了平均误差、最大误差和标准差。信标实际位置平均误差(米)最大误差(米)标准差(米)锚点1附近(0.5, 0.5)0.120.280.08区域中央(1.5, 2.0)0.230.510.15锚点2附近(2.8, 0.8)0.310.620.19对角线远端(2.5, 3.5)0.460.890.27可以明显看出离锚点较近的位置误差小因为RSSI在近距离时的灵敏度高1dB的变化只对应几厘米的距离变化。而在区域远端信号被环境中的人和设备反射干扰误差迅速增大。标准差0.27米意味着定位点会在真实位置附近来回漂移。这里说一句大实话如果只是课堂作业或者个人项目展示这个精度已经足够了。但要做资产追踪级别的应用建议加惯性传感器IMU做航迹推算融合或者换成蓝牙5.1方向定位方案。5.2 常见问题与排查表串口乱码、坐标跳变、卡死我这里整理了一个问题排查表都是实测中踩过、且最终解决的坑现象可能原因解决方法串口读到乱码波特率不匹配CC2541用9600但Arduino开115200接收统一软串口波特率CC2541配置ATBAUD4设为9600坐标频繁跳变到角落RSSI偶发尖峰噪声未滤净增加中值窗口长度或在上位机加卡尔曼滤波R4.0一个锚点长时间无数据蓝牙模块死机或串口线松动在Arduino端加看门狗超过5秒无数据自动重启软串口两个锚点数据互相干扰MAC地址匹配错误用ATADDR单独打印每个模块MAC务必准确复制定位结果偏向某一侧锚点布局接近共线调整锚点几何布局避免三点接近一条直线刚启动时坐标漂移很大A和n标定值不准确重新标定至少测1米和3米两个参考点取均值上位机卡死串口缓冲溢出或Python绘图阻塞串口读取线程加queue缓冲绘图线程单独分离信标移动后定位滞后严重滑动窗口过长缩短WINDOW_SIZE到3或者增大采样频率5.3 精度优化三板斧标定、天线朝向、动态加权第一板斧标定一定要做绝不能偷懒复制别人的A和n。每个房间的家具摆放、墙体材质都不同即使同一房间不同时间段A也会有2到3dB的浮动。我的做法是程序启动前让信标放在1米位置自动采集10秒动态计算A并保存到EEPROM这样每次启动环境变了也能自适应。第二板斧BLE模块的天线是全向的但模块上的PCB天线存在方向性。实测发现CC2541模块天线一侧对着锚点时RSSI会比背对时高约6dB。解决办法把信标模块立起来固定在小支架上避免随意平放锚点模块的天线方向也要朝向定位区域中心不要对着天花板。第三板斧三个锚点的可信度往往不同。距离越远RSSI的波动对距离的影响越大。因此可以在三边定位时给每个锚点加权重距离近的权重高。具体做法是用1/d^2作为残差权重实测能把平均误差再压5到8厘米。6. 避坑指南与我的经验心得6.1 最容易被忽视的三个坑第一Arduino Uno的5V引脚给CC2541供电时会有一个问题CC2541虽然标称低功耗但模块上常带有板载LDO和LED指示灯瞬时电流可能超过50mA。如果同时挂两个模块Arduino的AMS1117稳压器会发热严重甚至自动保护断电。解决方案是单独给蓝牙模块供电或者选用带外部供电的扩展板。第二串口波特率配置错了会浪费一晚上。CC2541出厂默认波特率是9600但很多淘宝卖家提前改成了38400。拿到新模块先连USB转TTL打开串口助手随便发一个AT如果收到“OK”说明波特率对如果没反应就依次试19200、38400、115200。我在这个坑里耗费的时间比写代码还久。第三Arduino板上引脚中断和软串口同时使用时高波特率数据流可能导致中断优先级冲突表现为RSSI数据偶发丢包。解决办法是尽量提高软串口接收缓冲的检测频率代码里loop中不要使用delay()改用millis()非阻塞延时模式。6.2 往工程化方向扩展的思路如果想把这套原型转成真正工程可用的系统我会优先建议做三件事把Arduino换成ESP32。ESP32自带WiFi和蓝牙双模算力更强可以直接在板子上跑卡尔曼滤波和定位算法不需要上位机形成一个独立的锚点节点。增加未授权多径检测。通过分析RSSI方差判断当前信号是否是反射信号方差大的点直接丢弃或降低权重对动态环境很有效。引入UWB融合作为可选高精度模式。UWB的测距精度达到厘米级但成本高几倍主流方案是当RSSI方差高于阈值时自动启用UWB测距修正形成一个双模定位组网系统。我个人在实际操作过程中最深的感受是RSSI室内定位不是一个纯算法或纯硬件问题而是一个系统工程。信号在物理世界的传播充满了不确定性任何脱离实际环境的优化都是纸上谈兵。建议你先按我这套代码跑通一遍拿到真实的RSSI数据之后再来谈精度优化那个时候你对滤波、加权、标定的理解就不是靠背公式能得到的了。最后再分享一个小技巧调试时把三个锚点放在包围定位区域的三面墙壁上不要都放在一侧。锚点间的几何结构一旦稀疏了再好的滤波算法也救不回来。
返回列表