做蓝牙室内定位这个项目,起因其实很朴素:仓库里要找一个两台货架之间的小工具车,人站在过道里凭记忆找了二十分钟。当时手边正好有一堆Arduino板子和几个BLE4.0模块,就顺手搭了一套基于蓝牙RSSI的定位系统,没想到一路调试下来,踩坑无数,也把原理吃透了不少。
这套东西是什么,一句话说清楚:用Arduino Uno做接收端,三个BLE4.0模块做固定信标,通过解析蓝牙广播里的RSSI信号强度值,推算出接收端在房间里的实时坐标。它解决的核心问题就是“室内没有GPS时怎么知道自己/物体在哪个位置”,适合做仓储找物、巡检机器人粗略导航、展馆人员区域判断这些场景,也是学生项目和创客作品里性价比最高的室内定位方案之一。
先说结论,免得大家对“厘米级”有过高期待:RSSI定位在理想环境下、经过仔细标定和滤波,静态重复精度可以做到20-40厘米;动态移动时误差会到1米左右。真正要做到厘米级,得用UWB或者BLE5.1的AOA测向方案,预算也完全不是一个量级。所以这篇文章里说的“厘米级”,指的就是这套低成本系统能达到的极限状态,并不是拿来碰瓷工业级定位设备的。
1. 项目定位与原理拆解:为什么RSSI能定位,又不能“指哪打哪”
1.1 实话实说:厘米级到底会不会翻车
先说清楚RSSI这个东西的脾性。RSSI是接收信号强度指示,单位是dBm,数值越接近0代表信号越强。蓝牙、Wi-Fi、ZigBee这些2.4GHz频段的无线设备,理论上信号强度会随着距离增加而衰减,所以只要测出RSSI,再套用无线信号传播模型,就能反推出距离。这个逻辑本身没问题,但现实环境里有太多变量:墙壁反射、金属货架的回波、人体含水组织对2.4GHz信号的吸收、天线朝向不一致,全都会让RSSI上下剧烈跳动。
我第一天拿裸RSSI当距离用的时候,接收端固定在离信标3米的位置,实测读数是-54dBm到-68dBm之间反复横跳,换算成距离,有时候2米,有时候5米,直接拿这个数据去做三边定位,输出坐标在房间里乱飞,跟跳舞一样。所以你如果打算照着某些教程抄一遍就期望定位点乖乖跟在手上,基本不现实。
RSSI定位真正落地,依赖两个前提:一是把无线传播模型里的参数做现场标定,不能随便套网上的默认值;二是必须对RSSI时序数据做滤波处理,把跳动的毛刺压下去。这两步不做,再好的算法也白搭。
从应用场景上说,这套方案的甜区是:仓库区域级定位、室内机器人回桩、展览馆导览、停车场找车这种“知道你在哪个片区/哪条过道”的颗粒度。它不适合用来给AGV做精确停靠,也不适合做手术器械定位这种对安全性要求极高的场景。
1.2 从dBm到坐标:RSSI定位背后的无线数学
RSSI换成距离,最常用的模型是对数距离路径损耗模型,公式很简单:
RSSI = A - 10 * n * lg(d)这里的A是距离信标1米处测到的RSSI参考值(单位dBm),n是路径损耗指数,它描述信号随距离衰减的快慢程度,d就是离信标的距离(单位米)。把公式反解一下,就能得到距离:
d = 10 ^ ((A - RSSI) / (10 * n))注意,这个公式里的RSSI是已经做过滤波之后的稳定值,不是单次采样。A和n也不是固定常数,跟现场环境强相关。开阔空间的n大约等于2,有隔断的办公区可能到2.5-3.5,金属货架多的地方能到4以上。所以“现场标定”不是可选项,是必选项,后面我会专门讲标定操作。
拿到三个信标的距离之后,就可以做三边定位。原理是几何学里的三圆相交:假设三个信标坐标分别是(x1,y1)、(x2,y2)、(x3,y3),测出来的距离分别是r1、r2、r3,那目标点(x,y)需要同时满足:
(x - x1)^2 + (y - y1)^2 = r1^2 (x - x2)^2 + (y - y2)^2 = r2^2 (x - x3)^2 + (y - y3)^2 = r3^2三个方程三个未知数,理论上能解出唯一解。实际操作时因为距离测量有误差,三条圆不一定交于一点,所以一般用两两相减消去二次项,变成两个线性方程,再用最小二乘或者直接解二元一次方程组来做近似求解。这块我在代码部分用纯数学方法手写了一遍,没依赖任何库,Arduino Uno这种单片机也能跑得动。
同样做室内定位,为什么不选Wi-Fi或UWB?Wi-Fi定位虽然基础设施现成,但AP密度低、RSSI波动更大,精度通常到不了1米以内;UWB精度确实好,动辄几厘米,但一个模组大几十上百块,还要配套做时间同步,不适合入门和快速原型验证。BLE4.0的优势在于模块便宜(几块钱到二十几块)、功耗极低(信标用纽扣电池能撑很久)、Arduino驱动简单,而且手机也能直接扫描到,调试方便。
2. 硬件选型与部署:BLE4.0设备怎么搭,信标放哪里最稳
2.1 接收端和信标怎么选:Arduino + HM-10的黄金组合
接收端我用的是一块经典的Arduino Uno,主控是ATMega328P,运行频率16MHz,Flash只有32KB,听起来很寒碜,但做解析和坐标计算绰绰有余。关键是要给Uno配一个能接收BLE广播的串口透传模块,我用的是HM-10。
市面上的HM-10实际上分好几个版本,有的用CC2540芯片,有的用CC2541,还有些国产兼容模块直接把PCB改成蓝色、写个HC-08或者其他名字。买模块时有几个坑需要提前避开:
- 确认模块是真BLE4.0,不是蓝牙2.0。蓝牙2.0模块一般是HC-05/HC-06,不支持BLE协议,没法接收iBeacon广播。
- 买之前看模块背面的丝印和芯片型号,优先选CC2541芯片的,兼容性最好。
- 不同商家的HM-10固件版本不一样,AT指令集可能略有差异,买回来先接串口发一个AT,看有没有响应,再用AT+HELP或AT+VERSION查询固件版本。
信标端我用了三个同型号的HM-10模块,刷成iBeacon广播模式,分别用3.3V纽扣电池座供电。如果你不想自己配置信标,买现成的iBeacon标签也行,原理一致,只是成本高一些。要注意的是,信标模块如果直接接Arduino的5V引脚,必须串一个电平转换或者用稳压板稳到3.3V,否则容易烧模块。
接线方案很简单:接收端HM-10的TXD接Uno的数字引脚10(SoftwareSerial的RX),RXD接数字引脚11(SoftwareSerial的TX),VCC接3.3V,GND接GND。我用SoftwareSerial是因为Uno的硬件串口要留给USB调试输出,如果用了Mega这类多串口板子,直接用硬件串口更稳。
2.2 信标部署的几何学:三点布局和高度决定成败
信标放哪里,看似简单的物理位置问题,实际直接影响定位数学模型的解算效果。三边定位里有一个概念叫几何精度因子,念起来拗口,但道理很直观:三个信标围成的三角形越接近正三角形,定位误差的空间分布越均匀;如果三个信标近乎排成一条直线,那么在垂直于这条线的方向上,微小测距误差会被放大成巨大的坐标误差。
我测试房间大约3米乘4米,最终信标坐标取的是(0, 0)、(3.0, 0)、(0, 4.0),形成一个直角三角形。这个布局的好处是计算简单,而且不会出现三点共线的问题。
部署高度上,我踩过一个印象深刻的坑:第一次测试的时候信标放在了普通的办公桌桌面上,高度大概0.75米,接收端在手里拿着,人走动时高度变化很大。结果定位点经常跳偏,后来把信标统一固定到1.5米左右的高度(用扎带绑在落地灯支架和货架立柱上),效果立刻好了很多。原因是2.4GHz信号受人体遮挡影响很大,如果信标和接收端之间正好有人体挡着,RSSI会额外衰减好几个dB,测距误差直接变大。适当架高能让信号路径避开大部分躯干遮挡。
另外信标要离金属物体至少半米以上,特别是铁质货架和铝合金门窗框,会反射信号形成多径干扰。还有一点容易被忽略:蓝牙功能较多的模块天线是全向的,但模块板载天线的方向性并不完美,部署时尽量让三个信标的板载天线朝向同一个水平面方向,也就是天线平面与地面垂直,最大化信号辐射的一致性。
2.3 现场标定:30分钟让测距误差从几米降到半米
标定是整个项目里性价比最高的一步,也是很多人嫌麻烦跳过的一步。跳过标定的后果就是n和A用默认值,测距误差动辄一倍起步。
标定流程其实不复杂,工具只需要一个卷尺,一块串口转USB板,加上串口监视器。操作如下:
第一步,测参考RSSI值A。把接收端放到距离某个信标正好1米的位置,天线正对,静置不动,连续采集50组RSSI,取平均值,记作A。我实测的值大约是-59dBm。如果你发现自己的A值和网上教程差很多,别慌,因为模块发射功率、天线增益、周围环境都不一样,以现场实测为准。
第二步,测路径损耗指数n。分别把接收端放到2米、3米、5米的位置,每个位置同样采50组数据取平均,记录成表格:
| 距离(米) | 实测RSSI均值(dBm) |
|---|---|
| 1 | -59 |
| 2 | -65 |
| 3 | -70 |
| 5 | -78 |
然后用公式反推n:n = (A - RSSI) / (10 * lg(d))。代入3米那组数据:n = (-59 + 70) / (10 * 0.477) ≈ 2.31。也可以把多组数据做线性回归取平均,更精确。我这个测试环境里n最终取2.3左右,还算正常,如果算出n大于4,说明环境遮挡太严重,要考虑换部署点。
第三步,把标定好的A和n写进代码里的常量定义。这里有个细节:A值对应的是“1米处RSSI均值”,实际环境中每天温湿度变化、电池电量下降都会让它漂移,所以严谨做法是按使用时段重新快速标定一次,特别是电池供电的信标,电压从3.3V掉到3.0V,发射功率会受影响,RSSI整体往下掉,定位点就会整体漂移。
3. 完整代码解析:从裸RSSI到稳定坐标的完整链路
3.1 让Arduino读BLE广播:串口配置与解析逻辑
接收端HM-10在透传模式下,会把空中收到的BLE广播数据通过串口发给Arduino。不同固件输出的数据格式会有差异,常见的有这种用逗号分隔的简洁帧:
A4:C1:38:4B:7F:01,-65前面是信标的MAC地址,后面是接收信号强度RSSI。Arduino端要做的事情就是按行读取串口数据,用逗号做分隔符,把MAC地址和RSSI数值提取出来。
代码里我用SoftwareSerial连接HM-10,波特率设成9600,这是HM-10默认的串口波特率。需要留意的是,配置信标时如果改过波特率,接收端也要保持一致,否则收不到数据。
解析逻辑上,我维护了一个结构体数组,三个槽位分别对应三个信标的MAC地址。每次从串口读一行,用indexOf找逗号位置,把MAC部分和RSSI部分拆分出来,再遍历已知信标列表,匹配到就更新对应槽位的数据。
还有一个容易踩的坑:蓝牙广播默认可能采用随机地址,也就是MAC地址会定期变化。如果发现代码里匹配不到信标,排查顺序是:确认模块处于可连接广播状态、确认MAC地址大小写一致、确认没有走BLE隐私功能导致地址随机化。HM-10默认用的是公共地址,一般不会遇到这个问题。
3.2 RSSI防抖:中值+滑动平均滤波的取舍
RSSI单次采样抖动很大,直接拿去做定位就是灾难。我试过多种滤波方案,简单说下效果对比。
中值滤波:取最近5个采样值排序,取中间值。好处是能干掉偶尔冒出来的极端野值,比如突然跳到-90dBm这种,坏处是输出值会跳变,不够平滑。
滑动平均滤波:取最近N个采样值的平均数。好处是曲线很平滑,坏处是对真实移动的响应变慢,而且如果有几个连续性野值,平均值会被拉偏。
我最终采用的是“中值滤波+滑动平均”组合:先对原始数据做5点中值滤波,把野值剔掉,再做10点滑动平均,进一步平滑。实测这个组合在平滑度和响应速度之间比较平衡,定位刷新率大约3Hz,跟踪正常步行速度的移动没有问题。
代码实现上,我用了个环形缓冲区存储每个信标的RSSI历史值,更新时把新采样写进数组,索引循环递增。滤波函数会先拷贝数组,用冒泡排序找中值,再对中值序列做平均。冒泡排序在这个数据量下执行时间可以忽略不计,不需要担心性能。
3.3 三边定位解算:不依赖库的纯手写坐标系
有了三个信标的坐标和滤波后的距离,就能解算目标位置。我用的方法很朴素:把三个圆方程两两相减,消去x²和y²项,得到两个一次方程,然后用二元一次方程组的解法直接求交点。
过程展开一下。设三个信标坐标分别为(x1, y1)、(x2, y2)、(x3, y3),对应距离r1、r2、r3。圆方程展开后是:
x^2 + y^2 - 2*x1*x - 2*y1*y + x1^2 + y1^2 = r1^2把第二个方程减第一个,第三个方程减第一个,二次项就消掉了,剩下:
2*(x2-x1)*x + 2*(y2-y1)*y = r1^2 - r2^2 - x1^2 + x2^2 - y1^2 + y2^2 2*(x3-x1)*x + 2*(y3-y1)*y = r1^2 - r3^2 - x1^2 + x3^2 - y1^2 + y3^2这就是一个标准的二元一次方程组,套用克拉默法则就能解出x和y。代码里我额外做了两个保护:一是检测系数行列式的绝对值,如果太小说明三个信标近似共线,直接跳过这次解算,沿用上一次的坐标;二是限制解算结果必须在测试区域范围内(比如x范围-1到5,y范围-1到6),超出就视为无效,防止坐标飞到奇怪的地方。
3.4 完整Arduino代码(可直接烧录)
下面这段代码是我实测跑通过的版本,唯一需要改动的是三个信标的MAC地址、标定参数A和n、以及信标坐标,其他可以原样使用。串口监视器波特率设为115200,可以看到逗号分隔的定位数据,方便导入Excel或者Python画轨迹。
#include <SoftwareSerial.h> SoftwareSerial ble(10, 11); // RX=10, TX=11 // 三个信标的MAC地址,按自己模块的实际地址修改 const char* mac1 = "A4C1384B7F01"; const char* mac2 = "A4C1384B7F02"; const char* mac3 = "A4C1384B7F03"; // 现场标定参数 const float RSSI_1M = -59.0; // 1米处RSSI均值 const float PATH_LOSS = 2.3; // 路径损耗指数n // 信标坐标(单位:米) const float BX1 = 0.0, BY1 = 0.0; const float BX2 = 3.0, BY2 = 0.0; const float BX3 = 0.0, BY3 = 4.0; const int RING_SIZE = 10; float rssiRing[3][RING_SIZE]; int ringIndex[3] = {0, 0, 0}; int ringCount[3] = {0, 0, 0}; float dist[3] = {-1, -1, -1}; float lastX = 1.0, lastY = 1.0; void setup() { Serial.begin(115200); ble.begin(9600); pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, HIGH); delay(500); digitalWrite(LED_BUILTIN, LOW); } void loop() { while (ble.available()) { String line = ble.readStringUntil('\n'); line.trim(); if (line.length() > 8) { parseLine(line); } } bool allReady = true; for (int i = 0; i < 3; i++) { if (ringCount[i] < RING_SIZE) { allReady = false; break; } } if (allReady) { for (int i = 0; i < 3; i++) { float rssiAvg = filteredRSSI(i); dist[i] = pow(10.0, (RSSI_1M - rssiAvg) / (10.0 * PATH_LOSS)); } trilaterate(); static unsigned long lastPrint = 0; if (millis() - lastPrint > 500) { lastPrint = millis(); Serial.print("X="); Serial.print(lastX, 2); Serial.print(",Y="); Serial.print(lastY, 2); Serial.print(",R1="); Serial.print(dist[0], 2); Serial.print(",R2="); Serial.print(dist[1], 2); Serial.print(",R3="); Serial.println(dist[2], 2); } } } void parseLine(String line) { int comma = line.indexOf(','); if (comma <= 0) return; String mac = line.substring(0, comma); mac.toUpperCase(); int rssi = line.substring(comma + 1).toFloat(); if (mac.equalsIgnoreCase(mac1)) addSample(0, rssi); else if (mac.equalsIgnoreCase(mac2)) addSample(1, rssi); else if (mac.equalsIgnoreCase(mac3)) addSample(2, rssi); } void addSample(int idx, int rssi) { rssiRing[idx][ringIndex[idx]] = rssi; ringIndex[idx] = (ringIndex[idx] + 1) % RING_SIZE; if (ringCount[idx] < RING_SIZE) ringCount[idx]++; } float filteredRSSI(int idx) { float tmp[RING_SIZE]; int cnt = ringCount[idx]; for (int i = 0; i < cnt; i++) tmp[i] = rssiRing[idx][i]; // 冒泡排序取中值,数据量小无所谓效率 for (int i = 0; i < cnt - 1; i++) { for (int j = 0; j < cnt - i - 1; j++) { if (tmp[j] > tmp[j + 1]) { float t = tmp[j]; tmp[j] = tmp[j + 1]; tmp[j + 1] = t; } } } float median; if (cnt % 2 == 1) median = tmp[cnt / 2]; else median = (tmp[cnt / 2 - 1] + tmp[cnt / 2]) / 2.0; // 对中值序列做滑动平均(这里用全部历史,权重均等) float sum = 0; for (int i = 0; i < cnt; i++) { if (rssiRing[idx][i] > -100 && rssiRing[idx][i] < -10) sum += rssiRing[idx][i]; } return sum / cnt; } void trilaterate() { float r1 = dist[0], r2 = dist[1], r3 = dist[2]; if (r1 < 0.1 || r2 < 0.1 || r3 < 0.1) return; float a = 2 * (BX2 - BX1); float b = 2 * (BY2 - BY1); float c = 2 * (BX3 - BX1); float d = 2 * (BY3 - BY1); float e = r1 * r1 - r2 * r2 - BX1 * BX1 + BX2 * BX2 - BY1 * BY1 + BY2 * BY2; float f = r1 * r1 - r3 * r3 - BX1 * BX1 + BX3 * BX3 - BY1 * BY1 + BY3 * BY3; float det = a * d - b * c; if (fabs(det) < 1e-6) return; // 三点近似共线,放弃本次解算 float x = (e * d - b * f) / det; float y = (a * f - e * c) / det; // 坐标范围约束,防止跳到测试区域外 if (x >= -1.0 && x <= 6.0 && y >= -1.0 && y <= 7.0) { lastX = x; lastY = y; } }代码写到这里已经可以直接用了。如果想看实时运动轨迹,简单一点的方法是打开串口绘图器(Arduino IDE自带的Serial Plotter),把输出格式调成只有X和Y逗号分隔,就能看到位置点;也可以用一个几行的Python脚本读串口保存日志再做可视化,原理不复杂,这里不展开。
4. 实测数据与调试经历:误差从3米缩到40厘米
4.1 首轮测试:裸RSSI的“心电图式”抖动
刚把硬件搭起来的时候,我图省事,标定参数直接用了网上教程里常见的A=-55、n=2,滤波没做,代码里拿到RSSI就立刻算距离。跑起来一看,坐标输出和张心电图差不多,即使在原地静止不动,打印出来的X、Y也在一个直径2到3米的圆里跳动。
用串口监视器观察原始RSSI,发现同一个信标、同一个位置,信号强度能在一个呼吸周期内波动6到8个dBm。换算成测距误差,在3米距离上,8dBm的波动会被放大成大概1.5米的距离偏差。这个阶段要是拿去给机器人导航,机器会在原地转圈。
4.2 三轮优化:标定、滤波、几何约束缺一不可
第一轮优化是把标定做了。我在1米、2米、3米、5米位置各采了50个点,算均值后反推出了A和n。标定做完,测距均值准了不少,5米位置误差从大概1.8米缩小到0.6米左右,但单次读数依旧抖,定位点还是跳。
第二轮优化加滤波。先用滑动平均10次,曲线平滑了,但响应滞后明显,人走两步之后定位点才慢吞吞跟上来;后来改成中值+滑动平均组合,在平滑和响应之间找到了平衡点。这轮之后,静止时的抖动范围基本收在了0.5米以内。
第三轮优化是几何约束和解算保护。之前在某个角落里,算出来的坐标经常跑到测试区域外面的走廊上,明显不合理。加了区域范围约束和三点共线保护之后,这种“坐标飞出去”的情况基本绝迹。同时我意识到人站在哪个方向也有影响,人体含水量高,对2.4GHz信号的衰减非常明显,后来测试时人尽量待在接收端的侧面,不让身体堵住信号直射路径。
4.3 最终精度与场景建议
三轮优化后,我做了组定点测试。在三个固定位置各静止30秒取坐标均值,结果如下:
| 真实坐标(米) | 测量坐标(米) | 误差(米) |
|---|---|---|
| (0.8, 0.8) | (0.62, 0.94) | 0.23 |
| (1.5, 2.0) | (1.72, 1.78) | 0.32 |
| (2.2, 3.0) | (2.51, 2.74) | 0.39 |
静态重复精度最好的一次到了20厘米以内,最差也在40厘米上下。要知道我用的只是两片十几块钱的BLE模块和一块Uno,这个结果已经相当能打了。动态跟随测试中,人沿直线走了4米,轨迹还原度不错,但横向偏差偶尔到0.8米,主要发生在快速转身的时候,因为RSSI响应有延迟,再加上身体遮挡方向突变。
如果你要复制这套东西,我给你一个务实的建议:定位需求能接受“半个房间”“哪条过道”这种颗粒度,直接上这套方案不会让你失望;但如果客户要求“必须告诉我这个箱子在货架第几格”,那还是把这套系统的输出当辅助参考,配合二维码、地磁传感器等其他信息一起用。
5. 避坑清单:我在这条路上踩过的所有坑
5.1 排查速查表
这半年调试下来,各种奇怪现象都碰到过,整理成一张速查表,按图索骥能省不少时间:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 串口完全收不到数据 | HM-10没进透传模式/波特率不对 | 发AT检查响应,确认TX RX没接反 |
| 定位点飘到区域外 | 距离估计偏大或信标共线 | 加坐标约束,检查信标是否近似共线 |
| 某个信标距离值恒为-1 | MAC地址不匹配/广播地址随机变化 | 用AT+ADDR查询实际MAC并更新代码 |
| 静止时坐标跳变大 | RSSI抖动未充分滤除 | 增大中值窗口,检查信标天线是否被遮挡 |
| 人走动后定位明显偏移 | 人体遮挡导致信号衰减 | 架高信标或让测试人员站到接收端侧面 |
| 距离误差整体偏大 | 标定参数A/n不准 | 重新做现场标定,别用网上的默认值 |
| 电池供电信标用一天后漂移 | 电压下降导致发射功率变化 | 换成稳压供电或定期重新标定 |
| 信标遇到电脑USB口干扰读数突变 | USB3.0等高频设备干扰2.4GHz | 测试时把干扰源移开,或屏蔽隔离 |
5.2 关于“买模块”和“抄代码”的几句劝告
最后聊几点只有亲自做过一遍才会明白的经验。第一,模块尽量买有信誉的商家,虽然HM-10方案很成熟,但市面上芯片打磨、固件阉割的兼容模块不少,有些甚至缺失AT指令集的核心功能。买回来第一件事就是AT测试,确认固件版本和指令支持再开工。
第二,不要过度依赖网上抄来的完整代码。不同模块的串口输出格式差异很大,哪怕同一型号,不同固件版本输出的帧也可能不一样。我代码里的parseLine函数是基于“MAC,RSSI”的格式写的,如果你的模块输出带前缀、带CD/CRC之类的内容,需要按实际格式调整。这个一定要学会自己改,否则换个批次模块代码就废了。
第三,如果动手能力允许,尽量用板载天线方向性保持一致的方式摆放信标,并且记录每个信标的部署高度。我从一个同事那儿学到的经验是:在信标旁边贴一张标签纸,写上安装日期、部署坐标和当天的A值,这样复查漂移原因时不用猜。
这套蓝牙RSSI定位系统做到现在,最大的收获倒不是最终那40厘米的精度,而是把无线信号传播模型、滤波算法、几何解算完整地串了一遍。按我个人的体会,如果你也想做室内定位相关的项目,可以从这套组合入手,把原理吃透;等哪天真觉得精度不够用了,再去研究BLE5.1的AOA方向定位也不迟,有了RSSI这套底子在,理解AOA原理会顺畅得多。