1. 从一束不可见光说起:905 nm 测距模块到底在解决什么问题
如果你玩过扫地机器人、做过无人机定高、或者给机械臂加过避障,大概率绕不开一个东西:激光测距模块。市面上常见的方案有超声波、红外三角测距、ToF 摄像头,还有一类就是基于 905 nm 激光的飞行时间(Time of Flight)测距。905 nm 这个波长属于近红外,人眼看不见,但在硅基探测器上响应很好,成本也压得下来,所以大量工业级和消费级测距模块都选它。
我手上这块模块的典型参数是:量程 0.05 m 到 100 m 以上(取决于反射率),单次测量精度在 ±1 m 以内(远距离),近距离能到厘米级,刷新率可以做到 1 Hz 到 20 Hz 可调。它对外只给一个UART 串口,波特率默认 115200,8N1,输出的是二进制帧或者 ASCII 帧,取决于固件版本。这就意味着:任何带串口的控制器都能读它——Arduino、树莓派、Jetson、甚至一块带 UART 的 STM32 都行。
问题在于,很多人拿到模块之后卡在三个地方:第一,串口读出来的数据是乱码,因为没搞清帧格式和校验;第二,Arduino 读到了,但不知道怎么把数据稳定地传给树莓派;第三,树莓派读到了,但不知道怎么把它变成 ROS 2 里的一个标准话题,让导航栈或者避障节点能用。这三个问题其实是同一条链路上的三个环节,我把它拆成"底层驱动—中间转发—上层集成"来走,每一步都给出可复现的代码和踩坑记录。
这篇文章适合谁看?如果你已经有一块 905 nm 测距模块,想把它接进自己的机器人系统,或者你正在选型阶段,想搞清楚 UART 方案和 I2C、SPI 方案的区别,再或者你只是想在 ROS 2 里加一个距离传感器但不想买昂贵的雷达,那这篇内容可以直接抄作业。我会用 Arduino 做底层采集,树莓派做协议转换和网络转发,最后在 ROS 2 里发布一个sensor_msgs/Range话题。整条链路我实测跑过,下面把每个环节的细节和坑都摊开讲。
2. 先别急着写代码:905 nm 模块的 UART 帧格式与电气特性
2.1 为什么你的串口读出来是乱码
拿到模块第一件事不是插线写代码,而是确认它的帧格式。我见过太多人直接用Serial.read()读字符然后打印,结果屏幕上全是ÿØÿà这种鬼东西。原因很简单:模块输出的是二进制帧,不是 ASCII 文本。一个典型的 905 nm 测距模块帧结构长这样(不同厂家略有差异,但逻辑类似):
| 字节位置 | 含义 | 典型值 |
|---|---|---|
| 0 | 帧头高字节 | 0xAA |
| 1 | 帧头低字节 | 0x55 |
| 2 | 功能码 | 0x01 表示距离 |
| 3 | 数据高字节 | 距离高位 |
| 4 | 数据低字节 | 距离低位 |
| 5 | 校验和 | 前 5 字节异或 |
| 6 | 帧尾 | 0x0D |
距离值的计算方式是distance = (high << 8) | low,单位通常是厘米或毫米,具体看手册。校验和一般是前几字节的异或和,用来判断这一帧是否有效。如果你不做校验直接取值,遇到干扰或者丢包时就会读到离谱的数字,比如突然蹦出一个 65535。
提示:拿到模块后先用 USB 转 TTL 工具接到电脑,用串口助手以十六进制模式看原始数据,确认帧头和帧尾,再动手写解析代码。这一步能省掉后面至少两个小时的调试。
2.2 电气连接:3.3 V 还是 5 V,这是个问题
905 nm 模块的 UART 电平通常是 3.3 V TTL,但有些模块标称兼容 5 V。这里有个坑:Arduino Uno 的 RX 引脚是 5 V 逻辑,直接接 3.3 V 模块的 TX 一般能识别(因为 3.3 V 高于 Uno 的高电平阈值 3.0 V),但 Uno 的 TX 输出 5 V 直接进模块的 RX 就可能烧掉模块的接收端。稳妥做法是加一个电平转换模块,或者用电阻分压(1 kΩ 和 2 kΩ 串联,从中间取 3.3 V)。
树莓派的 GPIO 是 3.3 V 逻辑,和模块直连没问题,但要注意树莓派的 UART 默认被蓝牙占用(在 Pi 3 和 Pi 4 上),需要在config.txt里关掉蓝牙或者改用/dev/ttyAMA0。我一般直接在raspi-config里把 serial port 打开、console 关掉,然后确认/dev/serial0指向正确的硬件串口。
供电方面,模块峰值电流可能到 100 mA 以上,不要从 Arduino 的 3.3 V 引脚取电,那个引脚输出能力有限。单独给模块供 3.3 V 或者 5 V(看模块规格),共地就行。
2.3 波特率与刷新率的取舍
模块默认 115200 波特率,这个速率下传一帧 7 字节大概需要 0.6 ms,理论上可以做到很高的刷新率。但实际刷新率受限于模块内部的测量周期,通常 1 Hz 到 10 Hz。如果你在代码里读得太快,会读到重复帧或者半帧。我的做法是:在解析函数里做帧同步,只有收到完整且校验通过的帧才更新距离值,主循环里用millis()控制读取节奏,不要用delay()阻塞。
另外,有些模块支持通过串口发送指令修改刷新率和量程模式(比如短距离高精度模式、长距离低精度模式)。这些指令也是二进制帧,格式和输出帧类似,但功能码不同。如果你需要动态切换模式,建议把指令封装成函数,发送后等待模块返回确认帧再继续。
3. Arduino 端:把二进制帧变成可信的距离值
3.1 状态机解析法,而不是"读一个字节判断一次"
很多人写串口解析喜欢用if (Serial.available())然后一个字节一个字节判断,代码写成一坨 if-else。这种写法在帧头出现在数据段里的时候会误判。我推荐用有限状态机:定义WAIT_HEADER_H、WAIT_HEADER_L、READ_DATA、CHECK_SUM几个状态,每来一个字节推进一次状态,只有完整走完一轮才认为收到一帧。
enum ParseState { WAIT_H, WAIT_L, READ_BODY, CHECK }; ParseState state = WAIT_H; uint8_t buf[7]; uint8_t idx = 0; void parseByte(uint8_t b) { switch (state) { case WAIT_H: if (b == 0xAA) { buf[0] = b; state = WAIT_L; } break; case WAIT_L: if (b == 0x55) { buf[1] = b; state = READ_BODY; idx = 2; } else state = WAIT_H; break; case READ_BODY: buf[idx++] = b; if (idx >= 6) state = CHECK; break; case CHECK: buf[6] = b; if (verifyChecksum(buf)) { uint16_t dist = (buf[3] << 8) | buf[4]; lastDistance = dist; newData = true; } state = WAIT_H; break; } }这段代码的好处是:即使数据段里出现了 0xAA 0x55,只要不是从帧头开始,状态机不会误触发。校验函数就是简单异或:
bool verifyChecksum(uint8_t *f) { uint8_t sum = 0; for (int i = 0; i < 5; i++) sum ^= f[i]; return sum == f[5]; }3.2 数据平滑:为什么我不建议直接用原始值
905 nm 模块在远距离或者低反射率目标上,单次测量会有跳动。比如对着 50 m 外的墙面,读数可能在 48 m 到 52 m 之间跳。如果你直接把这个值发给 ROS 2,导航栈会疯掉。我的做法是在 Arduino 端做一层滑动平均或者中值滤波。滑动平均窗口取 5 到 10 个样本,中值滤波取 5 个样本排序取中间值。中值滤波对脉冲噪声更有效,滑动平均对高斯噪声更平滑。我一般两个都用:先中值去脉冲,再滑动平均平滑。
const int MED_WIN = 5; uint16_t medBuf[MED_WIN]; int medIdx = 0; uint16_t medianFilter(uint16_t newVal) { medBuf[medIdx++ % MED_WIN] = newVal; uint16_t tmp[MED_WIN]; memcpy(tmp, medBuf, sizeof(tmp)); // 简单冒泡排序 for (int i = 0; i < MED_WIN-1; i++) for (int j = i+1; j < MED_WIN; j++) if (tmp[i] > tmp[j]) { uint16_t t = tmp[i]; tmp[i] = tmp[j]; tmp[j] = t; } return tmp[MED_WIN/2]; }注意:滤波会引入延迟。5 个样本在 10 Hz 刷新率下就是 0.5 s 的滞后。如果你的应用对实时性要求高(比如高速避障),窗口要缩小到 3 甚至不用滤波,靠上层做异常值剔除。
3.3 把数据打包成更简单的格式发给树莓派
Arduino 和树莓派之间可以用 UART 直连,也可以用 USB 串口。我倾向用 USB 串口,因为树莓派上/dev/ttyUSB0比 GPIO 串口更稳定,而且 Arduino 可以通过 USB 供电,少接两根线。Arduino 端把解析和滤波后的距离值重新打包成一个简单的 ASCII 行,比如D:1234\n,这样树莓派端解析起来极其简单,不用再处理二进制帧。
void loop() { while (Serial.available()) parseByte(Serial.read()); if (newData) { newData = false; uint16_t filtered = medianFilter(lastDistance); Serial.print("D:"); Serial.println(filtered); } }这里有个细节:Arduino 的硬件串口Serial同时用于和电脑通信以及和树莓派通信。如果你用 Uno,只有一个硬件串口,接树莓派之后就没法同时看调试信息了。解决办法是用SoftwareSerial接模块,硬件串口留给树莓派;或者用 Mega、Leonardo 这种多串口板子。我用的是 Mega,Serial1接模块,Serial接树莓派,调试和转发两不误。
4. 树莓派端:从串口读取到网络转发的完整链路
4.1 串口权限与 udev 规则,别每次都 sudo
树莓派上默认用户没有/dev/ttyUSB0的读写权限,很多人图省事直接sudo python3 xxx.py,但这样跑 ROS 2 节点会带来环境变量和权限的一堆麻烦。正确做法是把用户加入dialout组:
sudo usermod -a -G dialout $USER然后注销重新登录。如果还不行,加一条 udev 规则固定设备名,避免插拔后变成ttyUSB1:
# /etc/udev/rules.d/99-laser.rules KERNEL=="ttyUSB*", ATTRS{idVendor}=="2341", ATTRS{idProduct}=="0042", SYMLINK+="laser"这样设备名固定为/dev/laser,代码里写死这个路径就行。idVendor和idProduct用lsusb查。
4.2 Python 读取线程与数据校验
树莓派端我用 Python 写一个读取线程,持续从串口读行,解析D:开头的行,转成整数。这里要注意:串口读取要设置超时,否则readline()会一直阻塞,程序没法优雅退出。
import serial import threading class LaserReader: def __init__(self, port='/dev/laser', baud=115200): self.ser = serial.Serial(port, baud, timeout=0.1) self.distance = None self.lock = threading.Lock() self.running = True self.thread = threading.Thread(target=self._loop, daemon=True) self.thread.start() def _loop(self): while self.running: try: line = self.ser.readline().decode('ascii', errors='ignore').strip() if line.startswith('D:'): val = int(line[2:]) with self.lock: self.distance = val except Exception as e: print('parse error', e) def get(self): with self.lock: return self.distance这个类启动一个守护线程,主线程随时get()拿最新值。timeout=0.1保证readline()不会永久阻塞,daemon=True保证主程序退出时线程自动结束。
4.3 为什么我不直接在树莓派上解析二进制帧
有人会问:既然 Arduino 已经解析了一遍,为什么不让 Arduino 只做透传,树莓派直接读原始二进制?答案是分工。Arduino 擅长硬实时、低延迟的字节处理,树莓派擅长网络和复杂逻辑。让 Arduino 做帧同步和滤波,树莓派只读简单 ASCII,整条链路的调试难度大幅降低。如果哪天模块换了帧格式,只需要改 Arduino 代码重新烧录,树莓派端完全不用动。这种"边缘预处理 + 中心集成"的思路在机器人系统里很常见。
5. ROS 2 集成:把距离值变成标准话题
5.1 为什么选 sensor_msgs/Range 而不是自定义消息
ROS 2 里表示距离传感器,标准消息是sensor_msgs/msg/Range。它包含radiation_type(超声波、红外、激光)、field_of_view、min_range、max_range、range等字段。用标准消息的好处是:导航栈、避障节点、可视化工具都能直接识别,不用写额外的适配层。比如rviz2里可以直接加一个Range显示,nav2的代价地图也能配置成订阅 Range 话题。
消息定义关键字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| header | std_msgs/Header | 时间戳和坐标系 |
| radiation_type | uint8 | 0=超声波,1=红外,2=激光 |
| field_of_view | float32 | 视场角,弧度 |
| min_range | float32 | 最小量程,米 |
| max_range | float32 | 最大量程,米 |
| range | float32 | 当前距离,米 |
905 nm 激光对应radiation_type=2。field_of_view对单点测距模块来说很小,填 0.01 弧度即可。min_range和max_range按模块手册填,比如 0.05 和 100.0。
5.2 节点实现:定时器发布与坐标系
ROS 2 节点用rclpy写,核心是一个定时器,每 100 ms 读一次最新距离,填充消息并发布。坐标系frame_id要填对,通常是laser_link或者base_link,取决于你的 TF 树。如果只是测试,填laser也行,但要在rviz2里把 Fixed Frame 设成同一个。
import rclpy from rclpy.node import Node from sensor_msgs.msg import Range from .laser_reader import LaserReader class LaserRangeNode(Node): def __init__(self): super().__init__('laser_range_node') self.pub = self.create_publisher(Range, 'laser_range', 10) self.reader = LaserReader() self.timer = self.create_timer(0.1, self.tick) self.get_logger().info('laser range node started') def tick(self): d = self.reader.get() if d is None: return msg = Range() msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'laser_link' msg.radiation_type = Range.INFRARED # 或 LASER,看常量定义 msg.field_of_view = 0.01 msg.min_range = 0.05 msg.max_range = 100.0 msg.range = d / 100.0 # 假设 Arduino 传的是厘米 self.pub.publish(msg) def main(): rclpy.init() node = LaserRangeNode() rclpy.spin(node) node.destroy_node() rclpy.shutdown()注意radiation_type的常量:在sensor_msgs里ULTRASOUND=0、INFRARED=1、LASER=2。有些版本 Python 绑定里没有LASER常量,直接填 2 也行。
5.3 实测中的时间同步与丢帧处理
跑起来之后我用ros2 topic hz /laser_range看频率,稳定在 10 Hz 左右。但偶尔会掉到 8 Hz,原因是串口读取线程和 ROS 定时器不同步,有时候定时器触发时get()返回的还是上一帧的值。这不算丢帧,只是重复发布。如果要严格去重,可以在LaserReader里加一个序号,每次新数据序号加一,节点记录上次序号,相同就跳过。
另一个坑是时间戳。self.get_clock().now()用的是系统时间,如果树莓派没有 RTC 且没联网,时间会从 1970 年开始,导致rviz2里显示异常。解决办法是启动时用ros2 param设置use_sim_time=false,并确保系统时间通过 NTP 同步。没有网络的环境下,可以在 Arduino 端也发一个毫秒时间戳,树莓派收到后做相对时间处理,但这就复杂了,一般没必要。
6. 整条链路的联调与常见故障排查
6.1 分层验证:从串口助手到 rviz2
联调时不要一上来就跑完整系统,按层验证:
- 模块层:USB 转 TTL 接电脑,串口助手十六进制模式确认帧头和帧尾。
- Arduino 层:Arduino IDE 串口监视器看
D:输出,数值是否合理,用手挡镜头看变化。 - 树莓派串口层:
cat /dev/laser看是否有D:行,python3 -c "import serial; ..."测试读取。 - ROS 2 层:
ros2 topic echo /laser_range看消息内容,ros2 topic hz看频率。 - 可视化层:
rviz2加 Range 显示,Fixed Frame 设对,看扇形是否随距离变化。
每一层单独验证通过再往上走,出问题时能快速定位是哪一层。
6.2 常见故障对照表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 串口无数据 | TX/RX 接反、波特率不对、模块未供电 | 万用表测电压,换线序,确认波特率 |
| 数据乱码 | 帧格式不对、电平不匹配 | 十六进制模式看原始字节,加电平转换 |
| 距离值跳变 | 目标反射率低、环境光干扰 | 换目标,加遮光罩,开启滤波 |
| ROS 2 话题无数据 | 节点未启动、串口权限不足 | ros2 node list,检查 dialout 组 |
| rviz2 不显示 | frame_id 不匹配、时间戳异常 | 检查 Fixed Frame,ros2 topic echo看 header |
| 频率不稳定 | 串口阻塞、定时器冲突 | 加超时,用独立线程读取 |
6.3 我踩过的三个坑
第一个坑:Arduino 的Serial.print和Serial.write混用。print把整数转成 ASCII 字符串,write直接发字节。我一开始用Serial.write(distance),结果树莓派收到的是二进制,readline()读到换行符才停,但二进制里可能没有换行符,导致一直阻塞。后来统一用print加println,问题解决。
第二个坑:树莓派串口被 console 占用。/dev/serial0默认是 login console,你往里读会读到 shell 提示符。必须在raspi-config里关掉 serial console,只保留 hardware serial port。这个坑我查了半天才定位到。
第三个坑:ROS 2 节点退出时串口没释放。rclpy.spin被 Ctrl+C 中断后,LaserReader的线程还在跑,串口没关,下次启动报Device or resource busy。解决办法是在节点destroy_node时调用reader.stop(),里面self.running = False并self.ser.close()。
7. 性能边界与扩展思路
7.1 这套方案能跑多快
整条链路的延迟主要来自三部分:模块测量周期(约 100 ms)、Arduino 滤波窗口(5 样本约 50 ms)、ROS 定时器周期(100 ms)。端到端延迟大概 200 到 250 ms。对于低速机器人(< 0.5 m/s)足够用,高速场景需要把滤波窗口降到 3、定时器周期降到 50 ms,延迟能压到 150 ms 左右。再快就要换 SPI 接口的模块或者用 FPGA 做硬件时间戳了。
7.2 多模块扩展:I2C 多路复用还是多串口
如果需要多个测距模块(比如前、左、右各一个),Arduino Mega 有 4 个硬件串口,可以接 4 个模块。树莓派端可以用一个 USB Hub 接多个 Arduino,或者用一块多串口扩展板。ROS 2 端每个模块发布独立话题,比如/laser_front、/laser_left,然后在 TF 树里配置不同的frame_id。如果模块支持 I2C,也可以用 I2C 多路复用器(TCA9548A)挂 8 个模块,但 I2C 的时钟拉伸问题在长线上比较麻烦,我倾向串口方案。
7.3 从 Range 到 PointCloud:单点测距的局限与弥补
单点测距只能给一个方向的距离,信息量有限。如果想做二维扫描,可以把模块装在一个舵机上,Arduino 控制舵机旋转,每转一个角度读一次距离,树莓派端把角度和距离转成sensor_msgs/PointCloud2或者LaserScan。这样就能在 rviz2 里看到一圈点云。舵机扫描的精度受舵机回程差影响,建议用数字舵机,并在代码里做角度校准。这个扩展我做过一版,10 度一步、180 度范围,扫一圈 2 秒,效果能看,但和真正的激光雷达比还是差得远。不过成本摆在那里,适合预算有限的入门项目。
整套方案从模块到 ROS 2 话题,硬件成本不到两百块,代码量也不大,关键是每一层都透明可控。你可以在 Arduino 端加温度补偿,在树莓派端加卡尔曼滤波,在 ROS 2 端加异常值剔除,每一层都能独立优化。这种分层架构的好处就是:哪里不满意改哪里,不用推倒重来。