简介:围绕ZigBee无线定位中的RSSI测距环节,这套工程资料面向物联网、嵌入式开发学习者及CC2530/ZigBee入门开发者,重点解决串口读取接收信号强度并换算节点间距的实践问题。压缩包共637个文件,约12.38MB,包含大量r51、s51、lst等编译链接与列表输出,c、h源码及hex烧录文件,以及ewp、ewd、eww等IAR工程配置和map、d51等调试产物,便于直接打开工程、对照编译结果与烧录验证。内容涉及UART参数配置、协议栈中RSSI获取、自由空间与经验路径损耗模型换算,以及多径、遮挡等因素对测距稳定性的影响,可帮助读者把信号强度数据落到距离估算和后续多点定位中。已有1741人学习下载,适合作为课程实验、毕业设计或ZigBee定位原型的参考工程,从中可梳理源码结构、工程配置与串口调试排错思路。
1. 从一块定位板开始:RSSI 测距为什么值得单独拆一遍
去年帮人调一套室内定位的小系统,几块 ZigBee 节点摆在桌上,串口助手一开,屏幕上刷出一串十六进制。同一个位置不动,RSSI 在 -52 到 -61 之间来回跳,换成米数就是从 1.2 米飘到 3 米。那一刻我才意识到,ZigBee 定位这件事的难点根本不在「能不能读到 RSSI」,串口读取 RSSI 值并计算出距离,后半句才是真正要花时间的地方。这份资源做的事情很明确:把节点收发的 RSSI 从协议栈里取出来,经串口送到上位机,再用路径损耗模型换算成距离。适合手里已经有 ZigBee 模块、想做区域级定位或人员/物资粗定位的开发者,也适合刚接触无线测距、想搞清楚「信号强度到距离」这条链路到底有哪些参数要标的人。
2. 链路与选型:RSSI 从物理层到串口要经过哪几层
动手之前先把数据流向在脑子里过一遍,不然写到一半会发现不知道该在哪一层加打印。一套典型的 RSSI 测距链路是四段:无线收发 → 协议栈回调 → 应用层组帧 → 串口输出。任何一段取错位置,后面所有距离都是错的。
2.1 定位链路的物理组成与角色分工
最小组网通常是一个协调器加若干个终端节点。协调器接 USB 转串口挂在电脑上,负责收包并把 RSSI 吐出来;终端节点周期性地发一帧数据,充当「信号源」。这里有个容易搞混的点:RSSI 是接收方测出来的,不是发送方。所以量到的永远是「谁收到谁的帧,谁就测谁」的方向。如果一会儿参考节点测标签、一会儿标签测参考节点,两组数据的路径损耗基准不一样,混在一起算距离必然发散。
我一般会固定成单向:所有参考节点只收不发,标签节点每 200 ms 广播一帧心跳,协调器汇总各路 RSSI 上传。这样每一路 RSSI 的方向一致,标定出来的 A 值才能共用。
节点部署上还有两个细节值得提前注意。一是天线高度,终端节点别贴着桌面或金属放,2.4G 频段在金属附近衰减非常夸张,测出来的值跟实际距离完全对不上。二是发射功率要固定,很多模块默认开了自适应功率控制,节点离得远了自己加大功率,RSSI 看着没变化,但距离已经翻倍了。协议栈里通常有对应的编译选项或运行期 API 把功率锁死,这个开关必须先关掉再谈标定。
2.2 协议栈里取 RSSI 的三个位置与取舍
同一帧数据,在不同层能看到不同的信号质量字段,常用的有三个位置:
| 取数位置 | 典型字段 | 单位 | 特点 |
|---|---|---|---|
| MAC 层接收中断 | pMacData->rssi | dBm(有符号) | 最原始,不带协议栈处理延迟,但改动侵入性大 |
| AF 层应用回调 | pkt->rssi | dBm(有符号) | 改动最小,推荐入口,随消息一起送达 |
| 链路质量 LQI | pkt->linkQuality | 0~255 | 与 RSSI 相关但不是线性,做距离换算需要重新建模 |
做距离换算我优先用 AF 层的pkt->rssi。理由是它跟着业务消息一起到达,天然带上了源节点的短地址,不用自己去关联地址和信号值;而且这一层的改动只在应用文件里,不碰协议栈内核,二次开发时升级协议栈版本也不至于全部重来。
要留意的是字段类型。pkt->rssi在某些协议栈版本里声明成无符号 8 位,直接打印会出现 200 多的数值,其实是 -50 左右的负数被当成无符号解释了。取出来第一件事是强转成有符号:
// 协调器应用层接收回调:取 RSSI 并组帧从串口发出 void SampleApp_MessageMSGCB(afIncomingMSGPacket_t *pkt) { switch (pkt->clusterId) { case SAMPLEAPP_RSSI_CLUSTERID: { uint8 frame[10]; int8 rssi = (int8)pkt->rssi; // 关键:按有符号解释,否则 -50 会变成 206 uint16 shortAddr = pkt->srcAddr.addr.shortAddr; frame[0] = 0xAA; // 帧头 1 frame[1] = 0x55; // 帧头 2 frame[2] = 0x06; // 负载长度:类型到序号共 6 字节 frame[3] = 0x01; // 类型 0x01 = RSSI 上报 frame[4] = HI_UINT16(shortAddr); // 源节点短地址高字节 frame[5] = LO_UINT16(shortAddr); // 源节点短地址低字节 frame[6] = (uint8)rssi; // RSSI 原样按位存放 frame[7] = seq++; // 帧序号,丢帧能一眼看出来 uint8 sum = 0; for (uint8 i = 2; i <= 7; i++) sum += frame[i]; frame[8] = sum; // 累加和,覆盖长度到序号 HalUARTWrite(0, frame, 9); // UART0 输出,115200 8N1 } break; } }这段代码值得逐行看。frame[4]和frame[5]把 16 位短地址拆成两个字节,小端序在前,上位机拼回来时要反过来。frame[7]的序号是我特意加的,RSSI 丢帧和 RSSI 跳变在数据上表现很像,有了序号就能区分——序号连续但数值跳,那是信号本身不稳;序号断档,那是串口或空口丢了包。校验和只覆盖长度到序号,不覆盖帧头,这样上位机在找帧头的时候不用先算校验。
2.3 串口帧格式的取舍:二进制还是 ASCII
一开始我用的是 ASCII,直接printf("RSSI,%d,0x%04X\n", rssi, addr),好处是串口助手一开就能看,调试期非常舒服。但采样率一上去问题就来了:每帧十几个字节,115200 波特率下 200 Hz 采样会开始丢数据,而且协议栈自己的调试打印也走串口,两种文本混在一起,上位机解析直接翻车。
所以正式采集阶段换成上面的二进制帧。九个字节定长、带帧头、带长度、带校验,解析器写起来简单,抗干扰也好。ASCII 模式我保留成编译期可切换的调试开关,只在排查阶段打开。这个双模式的设计在实际调参时能省很多来回,新接手的人不用一上来就啃十六进制。
3. 串口读取链路:从字节流到可用数值的完整实现
上位机这一段看着简单,实际是最容易出问题的地方。串口给的是连续字节流,没有「一帧」的概念,读到哪算哪。
3.1 打开串口与参数对齐
串口参数三件事必须和下位机完全一致:波特率、数据位/停止位/校验、流控。默认用 115200 8N1 无流控。
import serial def open_port(port: str, baud: int = 115200) -> serial.Serial: ser = serial.Serial( port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, stopbits=serial.STOPBITS_ONE, timeout=0.05, # 读超时短一点,便于主循环做别的活 rtscts=False, # 不用硬件流控,下位机也没接 dsrdtr=False, ) ser.reset_input_buffer() # 丢掉打开瞬间的残留字节 return sertimeout设 0.05 秒是刻意的。设成 None 会一直阻塞,界面上想加个「停止采集」按钮都点不动;设成 0 又变成非阻塞,CPU 空转。50 ms 刚好卡在「能及时响应」和「不浪费」之间。reset_input_buffer()那行别省,串口打开的一瞬间缓冲区里经常已经躺着半帧残缺数据,不清掉第一帧必然校验失败。
3.2 帧解析:粘包、半包与校验
字节流处理的经典问题这里一个都不会少。我的做法是维护一个 bytearray 缓冲,每次读到的数据往里追加,然后循环找帧头、判长度、验校验,成功就切走一帧,失败就丢掉一个字节重新找。
SOF = b'\xAA\x55' def feed(buf: bytearray, chunk: bytes): """把新读到的字节塞进缓冲,产出所有完整且校验通过的帧""" buf.extend(chunk) frames = [] while True: idx = buf.find(SOF) if idx < 0: # 没找到帧头,只保留最后一个字节,防止帧头被切断在两次读取之间 if len(buf) > 1: del buf[:-1] break if idx > 0: del buf[:idx] # 丢掉帧头前的垃圾数据 if len(buf) < 9: break # 半包,等下一次读取 length = buf[2] if length != 0x06: # 长度不对,判为伪帧头 del buf[0] continue body = buf[2:8] if (sum(body) & 0xFF) != buf[8]: del buf[0] # 校验失败,跳过这个假帧头继续找 continue addr = (buf[4] << 8) | buf[5] rssi = buf[6] - 256 if buf[6] > 127 else buf[6] # 还原有符号 frames.append({'addr': addr, 'rssi': rssi, 'seq': buf[7]}) del buf[:9] # 切掉整帧 return frames三个细节:del buf[:-1]保留最后一个字节,是因为帧头AA 55有可能刚好被切在两次read之间,全清掉就会一直丢帧。校验失败时只del buf[0]而不是清空整个缓冲,因为后面可能还压着若干好帧,清空等于自断数据。rssi的还原就是第 2 章那个符号问题的上位机对应处理,buf[6] > 127就减 256,把 0~255 映射回 -128~127。
3.3 采集脚本落地:一边写文件一边看实时值
解析出来只是中间结果,下一步要落盘。用 CSV 最省事,Excel、pandas、MATLAB 都能直接读,标定阶段拿去做拟合很方便。
import csv, time, statistics from collections import defaultdict, deque def collect(ser, out_path: str, seconds: int = 60): buf = bytearray() win = defaultdict(lambda: deque(maxlen=20)) # 每个节点保留最近 20 帧 t0 = time.time() with open(out_path, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['ts', 'addr', 'rssi', 'rssi_med', 'seq']) while time.time() - t0 < seconds: chunk = ser.read(ser.in_waiting or 1) if not chunk: continue for fr in feed(buf, chunk): win[fr['addr']].append(fr['rssi']) med = statistics.median(win[fr['addr']]) # 中位数抗突发跳变 writer.writerow([f"{time.time()-t0:.3f}", hex(fr['addr']), fr['rssi'], med, fr['seq']]) f.flush()deque(maxlen=20)是个滑动窗口,每来一帧自动踢掉最老的。中位数取的是窗口内 20 个值的中位数,比均值稳得多——均值会被一两帧 -30 的异常值直接把整段拉高,中位数不会。CSV 里同时存原始值和滤波值,标定的时候两种都能试,不用重采。
采完的 CSV 长这样,第一列时间戳、第二列源地址、第三列原始 RSSI、第四列窗口中位数、第五列帧序号。序号列拿来算丢包率,(最大序号 - 行数) / 最大序号就是丢帧比例,超过 5% 就得先回头查空口环境或串口配置,别急着做距离换算。
4. 距离换算核心:对数路径损耗模型的标定与落地
拿到稳定的 RSSI 序列,才轮到公式。这一章是整个项目里最容易被糊弄过去、也最影响最终精度的部分。
4.1 模型公式与每个参数的物理含义
室内 2.4G 场景用得最多的是对数距离路径损耗模型:
RSSI(d) = A - 10 · n · lg(d / d0)反过来解距离:
d = d0 · 10 ^ ((A - RSSI) / (10 · n))d0是参考距离,统一取 1 米,这样公式里 lg(d/d0) 就是 lg(d),省掉一项。A是 1 米处的 RSSI 值,单位 dBm,反映发射功率和天线增益的总和,室内通常在 -40 到 -50 之间。n是路径损耗指数,自由空间是 2,室内因为有墙、家具、人体遮挡,实际落在 2.0 到 4.0,走廊这种长条空间甚至能到 4 以上。
参数的实际含义决定了标定方法:A只要把节点摆到 1 米处测一批就够,n必须要一整条距离曲线拟合出来。两个参数都跟你所用的模块、天线朝向、场地强相关,网上的默认值拿来当初始参考可以,直接投产一定翻车。
4.2 A 值和 n 值的实测标定流程
我的标定流程固定成五步:
- 选一段相对空旷、无遮挡的直线,地面画刻度,从 1 米到 10 米每米一个点。
- 每个点位采集 100 帧以上,取值的中位数作为该点的 RSSI 代表值,顺手记下标准差。
- 1 米点的中位数直接作为
A的初值。 - 用全部点做一元线性回归拟合
n,A也跟着回归重算一遍。 - 把拟合结果代回去算预测距离,比对真实距离,误差超过 30% 就检查是不是有遮挡点。
第 4 步的回归推导很简单:把公式改写成RSSI = A + n · (-10·lg d),令x = -10·lg d、y = RSSI,就成了一元线性回归,斜率是n,截距是A。
import numpy as np def fit_path_loss(distances, rssi_medians): """distances: 米;rssi_medians: 各点中位数;返回 (A, n)""" d = np.asarray(distances, dtype=float) y = np.asarray(rssi_medians, dtype=float) x = -10.0 * np.log10(d) # d0 = 1m,lg(d/d0) 即 lg(d) n, A = np.polyfit(x, y, 1) # polyfit 返回 [斜率, 截距] return float(A), float(n) def rssi_to_distance(rssi, A, n): if n <= 0: raise ValueError(f"路径损耗指数异常: n={n},检查标定数据") return 10 ** ((A - rssi) / (10.0 * n))np.polyfit(x, y, 1)的返回值顺序是[斜率, 截距],这里斜率对应n、截距对应A,顺序千万别接反,接反了算出来的距离会离谱到没法看。rssi_to_distance里加了一道n <= 0的防御,标定点太少或者距离跨度太小(比如全在 1~2 米之间)时,回归很容易给出负数斜率,这时候任何 RSSI 都算不出合理距离,必须提前拦住。
一个实测的参考结果:某次在办公楼走廊上标出来的A = -45.3、n = 2.86,1 米处预测误差不到 10 厘米,5 米处误差约 0.8 米,到 10 米误差接近 2 米。这个量级对于「判断人在哪个房间」够用,对于「精确到工位」就不够了——这是 RSSI 测距的物理上限,不是代码能解决的。
4.3 数据落表与误差评估
标定完不能只看拟合曲线好不好看,得拿独立数据验证。我的做法是另外采一组没有参与拟合的点位,计算预测距离和真实距离的偏差。
| 指标 | 计算方式 | 可接受范围(室内) |
|---|---|---|
| 平均绝对误差 MAE | mean(|d_pred - d_true|) | < 1.5 m |
| 均方根误差 RMSE | sqrt(mean((d_pred-d_true)^2)) | < 2.0 m |
| 平均相对误差 MAPE | mean(|d_pred-d_true|/d_true) | < 25% |
def evaluate(distances, rssi_list, A, n): errs = [] for d_true, r in zip(distances, rssi_list): d_pred = rssi_to_distance(r, A, n) errs.append((abs(d_pred - d_true), (d_pred - d_true) ** 2, abs(d_pred - d_true) / d_true)) arr = np.array(errs) return {'MAE': arr[:, 0].mean(), 'RMSE': np.sqrt(arr[:, 1].mean()), 'MAPE': arr[:, 2].mean()}相对误差和绝对误差要一起看。近处相对误差小但绝对误差可能大,远处反过来。如果 1 米处 MAPE 超过 30%,问题多半在A没标准;如果远处误差随距离线性放大得厉害,那说明n偏小,实际环境的损耗比拟合出来的更陡。
5. 排查与避坑:串口、跳变、漂移这几类问题怎么定位
下面五条都是我在调试现场真实遇到过的,现象、原因、解决按顺序对号入座,能省掉大半天。
5.1 串口助手能收到,Python 一读就是乱码
现象:串口助手用十六进制模式看数据完全正常,Python 脚本读上来的字节对不上帧头,偶尔还能读出 0x00 一堆。
原因:多数是串口打开时残留数据没清,或者波特率虽然写的一样但下位机 UART 时钟没校准。某些模块默认用内部 RC 振荡器做 UART 时钟源,115200 下误差就能到 3%,接收端采样点飘到边沿上,误码率一下子上来。
解决:先reset_input_buffer()清残留;再把下位机 UART 时钟源切到外部晶振,或者把波特率降到 57600 试一次。若降速就正常了,百分百是时钟精度问题,别再去怀疑代码。
5.2 RSSI 全是 200 以上的怪值
现象:打印出来一路 206、209、198,偶尔有个 45。
原因:pkt->rssi是无符号 8 位,负值被当成补码解释了。-50 放进无符号里就是 206,完全是同一个字节。
解决:取出来先(int8)强转,或者上位机按>127 则减 256还原。这个坑在协议栈例程里改过一个版本的字段类型,老代码搬过来最容易中招。
5.3 同一位置 RSSI 跳 8~10 dBm
现象:节点一动不动,RSSI 在 -52 和 -62 之间来回摆,算出来的距离在 1.2 米和 3 米之间晃。
原因:多径效应。2.4G 波长只有 12.5 cm,人站在旁边走一步,反射路径就变了,几个 dBm 的波动是物理规律。发射功率自适应也会放大这个波动。
解决:三层一起上——固定发射功率、窗口取中位数而不是均值、天线抬到 1.5 米以上离开桌面。采样率也可以提上来,200 ms 一次采 20 帧取中位数,比 1 秒采一次准得多。
5.4 距离算出负数或者无穷大
现象:rssi_to_distance返回负数或者OverflowError。
原因:标定出的n是负数或极小值,或者A - RSSI的绝对值异常大(比如 RSSI 是 -120 的无效值)。
解决:拟合后立刻检查n是否落在 1.5~5.0,超范围直接拒绝这组标定;采集端过滤掉小于 -100 的 RSSI,那是接收失败时的占位值,不是真实信号。函数里保留那道n <= 0的抛错,别为了「不报错」把异常吞掉。
5.5 采一晚上数据,早上发现丢了 40%
现象:CSV 行数远少于预期,帧序号中间大段断档。
原因:串口缓冲溢出。上位机每帧flush()一次磁盘,写盘慢的时候缓冲区积压;下位机又是 200 Hz 高频发送,115200 的带宽本来是够的,卡在写盘上。
解决:采集和写盘拆成两个线程,采到的数据先扔进queue.Queue,写盘线程批量落盘;或者把flush()改成每 100 行一次。下位机侧也留一手,序号连续丢失超过 10 帧时降速发送,先保住数据完整性。
6. 二次开发:从中位数滤波到三点定位的改造路径
原始版本只做到「一对多测距」,要变成真正的定位系统,还差三块:滤波、解算、可视化。
滤波层面,我一般把单节点中位数扩成「中位数 + 时间衰减」:新数据权重高、老数据权重低,节点移动时不会因为窗口里还压着旧位置的数据而滞后。代码上就是把deque换成带权重的列表,每次重算加权中位数,改动不到二十行。
解算层面,用一个已知坐标的参考节点以上就能做三点定位。以第一个参考节点为基准做差分,把双曲线方程拉成线性方程组,最小二乘解出坐标:
import numpy as np def trilaterate(anchors, distances): """anchors: [(x,y), ...] 至少 3 个已知点;distances: 对应测距值""" x0, y0 = anchors[0] xs = np.array([a[0] for a in anchors[1:]], dtype=float) ys = np.array([a[1] for a in anchors[1:]], dtype=float) d = np.asarray(distances, dtype=float) # 两圆方程相减,得到线性方程 A·[x,y]^T = b A = 2.0 * np.column_stack([xs - x0, ys - y0]) b = d[0] ** 2 - d[1:] ** 2 + xs ** 2 - x0 ** 2 + ys ** 2 - y0 ** 2 sol, *_ = np.linalg.lstsq(A, b, rcond=None) return float(sol[0]), float(sol[1])lstsq的好处是参考节点多于 3 个时自动退化成最小二乘,不怕冗余数据。rcond=None用默认截断,别传0,否则病态矩阵会抛异常。加测距值之前记得做一次欧氏距离一致性检查:解出来的点到各参考节点的距离,应该和测距值都在误差范围内,偏离最远的那个点大概率是 RSSI 突变,剔掉重算一次,定位结果能稳不少。
可视化层面,用 matplotlib 的scatter加FuncAnimation就能画实时点位,不用上 Qt。真要交付看板再考虑 web 端,把 CSV 换成 WebSocket 推过去就行,数据结构不用变。
验证方法我固定成一个流程:标签放在 5 个已知坐标点,每点采 60 秒,跑完整链路记录解算坐标,算 2D 误差的中位数。中位误差小于 1.5 米、最大误差小于 3 米就算可用,超过就先回第 4 章重标A和n,别在算法上找补。
最后说一条血泪经验:标定一定要在实际部署的场地做。我曾经图省事在走廊标的参数,搬到有隔断的办公区直接用,误差从 1 米飙到 4 米,查了两天才反应过来是n从 2.8 变成了 3.7。从那以后我每次部署新场地,第一件事就是铺刻度尺重新标一组A、n,宁可多花半小时。希望帮到你。
本文还有配套的精品资源,点击获取