1. 从一束不可见光说起905 nm 测距模块到底在解决什么问题如果你玩过扫地机器人、做过无人机定高、或者给机械臂加过避障大概率绕不开一个东西激光测距模块。市面上常见的方案有超声波、红外三角测距、ToF 摄像头还有一类就是基于 905 nm 激光的飞行时间Time of Flight测距。905 nm 这个波长属于近红外人眼看不见但在硅基探测器上响应很好成本也压得下来所以大量工业级和消费级测距模块都选它。我手上这块模块的典型参数是量程 0.05 m 到 100 m 以上取决于反射率单次测量精度在 ±1 m 以内远距离近距离能到厘米级刷新率可以做到 1 Hz 到 20 Hz 可调。它对外只给一个UART 串口波特率默认 1152008N1输出的是二进制帧或者 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帧头高字节0xAA1帧头低字节0x552功能码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 i1; 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 这种多串口板子。我用的是 MegaSerial1接模块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 KERNELttyUSB*, ATTRS{idVendor}2341, ATTRS{idProduct}0042, SYMLINKlaser这样设备名固定为/dev/laser代码里写死这个路径就行。idVendor和idProduct用lsusb查。4.2 Python 读取线程与数据校验树莓派端我用 Python 写一个读取线程持续从串口读行解析D:开头的行转成整数。这里要注意串口读取要设置超时否则readline()会一直阻塞程序没法优雅退出。import serial import threading class LaserReader: def __init__(self, port/dev/laser, baud115200): self.ser serial.Serial(port, baud, timeout0.1) self.distance None self.lock threading.Lock() self.running True self.thread threading.Thread(targetself._loop, daemonTrue) self.thread.start() def _loop(self): while self.running: try: line self.ser.readline().decode(ascii, errorsignore).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()拿最新值。timeout0.1保证readline()不会永久阻塞daemonTrue保证主程序退出时线程自动结束。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 话题。消息定义关键字段字段类型说明headerstd_msgs/Header时间戳和坐标系radiation_typeuint80超声波1红外2激光field_of_viewfloat32视场角弧度min_rangefloat32最小量程米max_rangefloat32最大量程米rangefloat32当前距离米905 nm 激光对应radiation_type2。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里ULTRASOUND0、INFRARED1、LASER2。有些版本 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_timefalse并确保系统时间通过 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 Frameros2 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被 CtrlC 中断后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 端加异常值剔除每一层都能独立优化。这种分层架构的好处就是哪里不满意改哪里不用推倒重来。