做室内定位这件事,我最早是从一个尴尬的现场开始的:设备明明在工位正前方,蓝牙网关却报出它在隔壁会议室。后来用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 R3 | ATmega328P | 2 | 作为锚点主控,读取RSSI |
| BLE模块 | CC2541(AT指令版) | 3 | 两个做锚点广播端,一个做信标 |
| USB转TTL模块 | CP2102 / CH340 | 1 | 调试用,配置CC2541模块参数 |
| 面包板与杜邦线 | 公对母若干 | 若干 | 临时接线 |
| 电源 | 5V USB供电 | 2 | 给两块Arduino供电 |
接线方式需要注意一个细节:CC2541模块的TXD要接Arduino的RX(0号引脚),RXD接Arduino的TX(1号引脚),两者共地。我在第一次接线时直接把模块的TXD接到了Arduino的TXD,结果串口什么都读不到——这是新手最容易踩的坑,UART通信必须交叉连接。
配置CC2541的参数时,需要先用USB转TTL模块连接模块与电脑,打开串口助手,发送AT指令。关键是要把模块的MAC地址记录清楚,锚点端根据MAC区分不同的信标信号。我实测用“AT+ADDR”指令读取MAC,然后用“AT+ROLE1”设置为主设备模式,让CC2541作为广播接收端,另一边信标用“AT+ROLE0”设置为从设备模式。
幕后的核心逻辑是: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、n=2套进去,得到的结果往往非常离谱,测两米距离算出5米。原因很简单:这个公式是在理想自由空间中推导的,而真实环境有墙壁、桌椅、人体,每个环境的A和n都不一样。
我这次的标定过程是这样的:把信标放在离锚点1米处,连续读50个RSSI值,取均值作为A;然后把信标放在3米处,再读50个RSSI值,利用公式反推n。实测下来,我办公室环境的A约为 -61 dBm,n约为 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[j+1]) { int t = tmp[j]; tmp[j] = tmp[j+1]; tmp[j+1] = 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 的 TXD,TX 接 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("AT+IMME1"); // 立即进入低功耗模式 delay(100); bleSerial.println("AT+ROLE1"); // 设置为主设备(扫描方) delay(100); bleSerial.println("AT+SCAN1"); // 开启连续扫描 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("AT+SCAN1"); 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[j+1]) { int t = tmp[j]; tmp[j] = tmp[j+1]; tmp[j+1] = 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”,有些版本可能是“OK+CONN”之类,你需要先用串口助手摸清格式再改代码。第二,AT+SCAN1这个指令不要连续高频发送,实测间隔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, rcond=None)[0] return result[0], result[1] except: return None def read_serial(ser): if ser.in_waiting > 0: line = ser.readline().decode('utf-8', errors='ignore').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.12 | 0.28 | 0.08 |
| 区域中央(1.5, 2.0) | 0.23 | 0.51 | 0.15 |
| 锚点2附近(2.8, 0.8) | 0.31 | 0.62 | 0.19 |
| 对角线远端(2.5, 3.5) | 0.46 | 0.89 | 0.27 |
可以明显看出,离锚点较近的位置误差小,因为RSSI在近距离时的灵敏度高,1dB的变化只对应几厘米的距离变化。而在区域远端,信号被环境中的人和设备反射干扰,误差迅速增大。标准差0.27米意味着定位点会在真实位置附近来回漂移。
这里说一句大实话:如果只是课堂作业或者个人项目展示,这个精度已经足够了。但要做资产追踪级别的应用,建议加惯性传感器(IMU)做航迹推算融合,或者换成蓝牙5.1方向定位方案。
5.2 常见问题与排查表:串口乱码、坐标跳变、卡死
我这里整理了一个问题排查表,都是实测中踩过、且最终解决的坑:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 串口读到乱码 | 波特率不匹配(CC2541用9600但Arduino开115200接收) | 统一软串口波特率,CC2541配置AT+BAUD4设为9600 |
| 坐标频繁跳变到角落 | RSSI偶发尖峰噪声未滤净 | 增加中值窗口长度,或在上位机加卡尔曼滤波R=4.0 |
| 一个锚点长时间无数据 | 蓝牙模块死机或串口线松动 | 在Arduino端加看门狗,超过5秒无数据自动重启软串口 |
| 两个锚点数据互相干扰 | MAC地址匹配错误 | 用AT+ADDR单独打印每个模块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数据之后再来谈精度优化,那个时候你对滤波、加权、标定的理解就不是靠背公式能得到的了。最后再分享一个小技巧:调试时把三个锚点放在包围定位区域的三面墙壁上,不要都放在一侧。锚点间的几何结构一旦稀疏了,再好的滤波算法也救不回来。