1. 项目缘起与整体设计思路
N10 这款激光雷达在创客圈和机器人入门玩家手里出镜率很高,价格便宜、体积小、串口直接出数据,拿来练手 SLAM 再合适不过。但很多人拿到手之后卡在第一步:数据怎么读出来?读出来之后怎么存?存完了怎么喂给 ROS2 去建图?这三个问题环环相扣,任何一个环节断了,后面都跑不起来。
我这次做的事情,说白了就是把这条链路完整打通:N10 激光雷达通过串口输出原始字节流,我用 Python 解析成结构化数据,一路写进 SQLite 数据库做持久化记录,另一路转成 ROS2 的 LaserScan 消息,交给 cartographer 做单雷达建图。听起来不复杂,但中间涉及串口协议解析、数据库表设计、ROS2 节点编写、cartographer 配置调参这几个完全不同层面的活儿,每个层面都有各自的坑。
这套方案适合谁?如果你手上有 N10 或者类似的串口激光雷达,想学 ROS2 但不想一上来就搞多传感器融合,想先把单雷达建图跑通,同时还想把原始数据存下来方便回放和分析,那这个记录就是写给你的。不需要你精通 ROS2,但至少要能装好系统、会敲终端命令、看得懂 Python 基础语法。
为什么选择 SQLite 而不是直接存文本文件或者用 ROS2 的 bag 包?这里有几个实际考量。文本文件存点云数据体积膨胀快,查询和筛选基本靠 grep,效率很低;ROS2 bag 虽然专业,但它是 ROS2 生态内的格式,脱离 ROS2 环境就不太好读,而且想按时间范围、按角度筛选数据很不方便。SQLite 的优势在于:单文件、零配置、SQL 查询、跨平台,一个 .db 文件拷到 Windows 上用 DB Browser 就能打开看,做数据分析和可视化非常顺手。对于单雷达这种数据量(每秒几千个点),SQLite 的写入性能完全扛得住。
整体架构我分成三个独立模块来设计,这样调试的时候可以逐个击破,不会一锅粥:
- 串口采集与解析模块:负责打开串口、读取原始字节、按照 N10 的协议解析出每个点的角度和距离。
- 数据持久化模块:把解析后的点数据批量写入 SQLite,同时记录帧号和时间戳,方便后续按帧回放。
- ROS2 发布与建图模块:把每帧数据打包成 LaserScan 消息发布到 ROS2 话题上,cartographer 订阅后完成建图。
三个模块之间用队列解耦,采集归采集、存储归存储、发布归发布,任何一个环节慢了不会把其他环节拖死。这个设计思路在后面调参和排查问题时帮了大忙。
2. N10 激光雷达串口协议与数据解析细节
2.1 先搞清楚 N10 到底吐出来什么
N10 激光雷达的串口参数是固定的:波特率 230400,8 位数据位,1 位停止位,无校验。这个波特率不算低,普通 USB 转串口模块如果芯片太差(比如某些 CH340 的老版本),可能会出现丢包。我实测下来,FT232 芯片的转接板最稳,CP2102 也可以,CH340 建议用新版的。
数据包的结构是 N10 的私有协议,一帧完整的包长 47 字节,帧头是0x54 0x2C。帧头后面跟着的是 40 个字节的点数据,每个点占 2 个字节,一共 20 个点。每个点的 2 个字节里,第一个字节的低 6 位是距离的低位,第二个字节是距离的高位,角度信息则通过帧内的起始角度和角度增量来推算。
具体来说,一帧数据的布局是这样的:
| 字节偏移 | 内容 | 说明 |
|---|---|---|
| 0-1 | 帧头 0x54 0x2C | 固定值,用于帧同步 |
| 2 | 转速低字节 | 雷达旋转速度 |
| 3 | 转速高字节 | 单位是度/秒 |
| 4-5 | 起始角度 | 本帧第一个点的角度,单位 0.01 度 |
| 6-45 | 点数据 | 20 个点,每点 2 字节 |
| 46 | 校验位 | 前面所有字节的累加和取低 8 位 |
每个点的距离计算方式是:距离 = (高字节 << 8 | 低字节) / 4.0,单位是毫米。角度则是从起始角度开始,每个点递增一个固定的角度增量。N10 一圈是 360 度,一帧 20 个点,所以角度增量大约是 18 度,但实际增量是根据转速动态变化的,需要用(结束角度 - 起始角度) / 19来算,这样更准确。
2.2 解析代码的关键实现
解析这块我用 Python 写,核心就是一个状态机:不断从串口缓冲区读字节,找到帧头之后往后取 47 个字节,校验通过就解析,校验失败就丢弃并重新找帧头。这里有个细节很多人会踩坑:不要每次只读一个字节然后判断,那样效率太低,230400 波特率下每秒有 23040 字节,逐字节读会跟不上。我的做法是一次读一大块(比如 512 字节),然后在内存缓冲区里做帧同步。
import serial import struct FRAME_HEADER = b'\x54\x2c' FRAME_LEN = 47 POINTS_PER_FRAME = 20 def parse_frame(frame): if len(frame) != FRAME_LEN: return None if frame[0:2] != FRAME_HEADER: return None # 校验和 checksum = sum(frame[0:46]) & 0xFF if checksum != frame[46]: return None start_angle = struct.unpack('<H', frame[4:6])[0] / 100.0 # 结束角度需要从最后一个点反推,这里简化处理 points = [] for i in range(POINTS_PER_FRAME): offset = 6 + i * 2 raw = struct.unpack('<H', frame[offset:offset+2])[0] distance = raw / 4.0 # 毫米 angle = (start_angle + i * 18.0) % 360.0 if distance > 0: points.append((angle, distance)) return points上面这段代码是简化版,实际用的时候角度增量最好动态计算。我一开始用固定 18 度,建图出来的墙是歪的,后来改成根据帧内角度差动态算,墙就直了。这个细节在 N10 的官方文档里写得比较含糊,得自己试出来。
注意:N10 在转速不稳定的时候,帧与帧之间的角度会有跳变。如果你的雷达供电不足(比如直接用 USB 口供电),转速会掉,角度就会乱。建议单独给雷达供 5V 2A 以上的电,别跟其他大功率设备共用。
2.3 串口读取的缓冲策略
串口读取我用的是pyserial库,设置timeout=0.05,每次read(512)。为什么要设超时?因为如果串口没数据,read会一直阻塞,程序就卡死了。设个短超时,读不到就返回空,循环继续,这样程序始终是活的。
缓冲区管理上,我维护一个bytearray,每次读到新数据就 append 进去,然后循环查找帧头。找到帧头后判断缓冲区长度是否够 47 字节,够就切出来解析,不够就等下次数据。解析完把用掉的字节从缓冲区删掉。这个逻辑看起来简单,但边界条件很多,比如帧头刚好在缓冲区末尾、校验失败后帧头位置怎么回退等等,写的时候要仔细。
实测下来,这套解析逻辑在树莓派 4B 和普通 x86 笔记本上都能稳定跑,CPU 占用不到 5%。如果你用更弱的设备(比如树莓派 Zero),建议把解析逻辑用 C 重写,Python 可能会成为瓶颈。
3. SQLite 数据库设计与高效写入实践
3.1 表结构怎么设计才合理
存激光雷达数据,表结构设计直接影响后续查询效率。我一开始想得很简单,一张表存所有点,字段就是时间戳、帧号、角度、距离。但实际用起来发现,按帧查询的时候很慢,因为每帧 20 个点,一秒 10 帧就是 200 行,跑十分钟就十几万行,查询要扫全表。
后来我改成两张表:一张frames表存帧级别的元信息(帧号、时间戳、起始角度、点数),一张points表存点数据(帧号、角度、距离),两张表通过帧号关联。这样查帧信息很快,查点数据的时候用帧号索引也很快。
CREATE TABLE frames ( frame_id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, start_angle REAL, point_count INTEGER ); CREATE TABLE points ( id INTEGER PRIMARY KEY AUTOINCREMENT, frame_id INTEGER NOT NULL, angle REAL NOT NULL, distance REAL NOT NULL, FOREIGN KEY (frame_id) REFERENCES frames(frame_id) ); CREATE INDEX idx_points_frame ON points(frame_id); CREATE INDEX idx_frames_ts ON frames(timestamp);frame_id用AUTOINCREMENT是为了保证帧号单调递增,方便按时间顺序回放。timestamp用 REAL 存 Unix 时间戳,精度到毫秒足够。索引是必须加的,不然查询会慢到怀疑人生。
3.2 批量写入与事务优化
SQLite 默认每条 INSERT 都是一个独立事务,每次都要写磁盘,速度极慢。我实测过,逐条插入的话,一秒只能写几百条,根本跟不上雷达出数据的速度。解决办法是用事务批量提交:攒够一定数量的点(比如 200 个点或者 10 帧),一次性executemany提交。
import sqlite3 import time conn = sqlite3.connect('lidar_data.db') conn.execute('PRAGMA journal_mode=WAL') # 开启 WAL 模式,读写并发更好 conn.execute('PRAGMA synchronous=NORMAL') # 降低同步级别,提升写入速度 def batch_insert(frames_data, points_data): conn.execute('BEGIN') conn.executemany( 'INSERT INTO frames (timestamp, start_angle, point_count) VALUES (?, ?, ?)', frames_data ) conn.executemany( 'INSERT INTO points (frame_id, angle, distance) VALUES (?, ?, ?)', points_data ) conn.commit()PRAGMA journal_mode=WAL这个设置很关键,它让 SQLite 支持读写并发,你在写入的同时可以用 DB Browser 打开看数据,不会锁死。synchronous=NORMAL是牺牲一点安全性换速度,如果突然断电可能丢最后几条数据,但对激光雷达这种场景完全可以接受。
批量大小我试过 50、100、200、500,最后定在 200 左右。太小了事务开销大,太大了内存占用高而且丢数据风险大。200 个点大概对应 10 帧,一秒提交一次,写入速度能到每秒几万条,完全够用。
3.3 数据文件管理与清理策略
激光雷达数据增长很快,一小时大概能产生几百 MB 的数据。如果一直往一个文件里写,文件会越来越大,查询也会变慢。我的做法是按天分文件,每天一个.db文件,文件名带上日期,比如lidar_20250115.db。这样既方便管理,也方便按天查询。
清理策略上,我设置了一个保留期限,比如保留最近 7 天的数据,超期的自动删除。这个逻辑可以写个定时任务,每天凌晨跑一次。如果你只是做实验,数据量不大,也可以手动清理,用 DB Browser 打开文件,直接删表或者删文件都行。
提示:SQLite 单文件在 Windows 上用 DB Browser for SQLite 或者 SQLiteStudio 打开非常方便,不需要装任何服务端。我经常把树莓派上跑出来的 .db 文件拷到 Windows 上用 DB Browser 看,直接就能画角度-距离的散点图,排查数据问题很直观。
4. ROS2 节点编写与 LaserScan 消息发布
4.1 ROS2 环境准备与工作空间搭建
ROS2 的安装这里不展开,网上教程很多,Ubuntu 22.04 对应的是 Humble,Ubuntu 24.04 对应的是 Jazzy。我这次用的是 Humble,因为 cartographer 在 Humble 上的支持最成熟。装完之后记得source /opt/ros/humble/setup.bash,或者把它加到.bashrc里。
工作空间我建在~/ros2_ws,标准的src目录结构。功能包用ament_python类型,因为我们的节点是 Python 写的。创建命令是:
ros2 pkg create --build-type ament_python n10_lidar_pkg --dependencies rclpy sensor_msgs这个命令会自动生成package.xml、setup.py和包目录。我们需要在setup.py里注册节点入口点,这样ros2 run才能找到它。
4.2 LaserScan 消息的字段填充
ROS2 的sensor_msgs/msg/LaserScan消息有一堆字段,每个都有讲究。我一开始随便填,结果 cartographer 建图要么不动,要么乱飘。后来仔细研究了每个字段的含义,才把图建对。
关键字段说明:
| 字段 | 含义 | 我的设置 |
|---|---|---|
| angle_min | 起始角度(弧度) | -π |
| angle_max | 结束角度(弧度) | π |
| angle_increment | 角度增量 | 2π / 点数 |
| range_min | 最小有效距离 | 0.1 米 |
| range_max | 最大有效距离 | 12.0 米 |
| ranges | 距离数组 | 按角度顺序填充 |
| scan_time | 扫描周期 | 0.1 秒 |
| time_increment | 点间隔时间 | scan_time / 点数 |
ranges数组的长度决定了角分辨率。N10 一圈大概 360 个点(取决于转速),所以数组长度设 360,每个元素对应 1 度。如果某个角度没有数据,填inf或者range_max,cartographer 会忽略这些点。
from sensor_msgs.msg import LaserScan import math def build_scan_msg(points, frame_id='laser'): msg = LaserScan() msg.header.frame_id = frame_id msg.header.stamp = self.get_clock().now().to_msg() msg.angle_min = -math.pi msg.angle_max = math.pi msg.angle_increment = 2 * math.pi / 360 msg.range_min = 0.1 msg.range_max = 12.0 msg.scan_time = 0.1 msg.time_increment = 0.1 / 360 ranges = [float('inf')] * 360 for angle, distance in points: idx = int(angle / 360.0 * 360) % 360 ranges[idx] = distance / 1000.0 # 毫米转米 msg.ranges = ranges return msg这里有个坑:N10 输出的距离单位是毫米,LaserScan 要的是米,必须除以 1000。我一开始忘了转,cartographer 建出来的图缩小了 1000 倍,看起来就像个点。
4.3 节点结构与话题配置
节点我设计成单节点多线程:一个线程读串口解析数据,一个线程写数据库,一个线程发布 ROS2 消息。线程之间用queue.Queue传递数据,队列设个最大长度(比如 100),防止内存无限增长。
发布的话题名我定为/scan,这是 cartographer 默认订阅的话题名。如果你改了话题名,cartographer 的配置里也要对应改。frame_id 设为laser,这个要和 cartographer 的tracking_frame配置对应上。
import rclpy from rclpy.node import Node from sensor_msgs.msg import LaserScan import threading import queue class N10LidarNode(Node): def __init__(self): super().__init__('n10_lidar_node') self.publisher = self.create_publisher(LaserScan, '/scan', 10) self.data_queue = queue.Queue(maxsize=100) self.serial_thread = threading.Thread(target=self.serial_loop, daemon=True) self.serial_thread.start() self.timer = self.create_timer(0.01, self.publish_loop) def publish_loop(self): try: points = self.data_queue.get_nowait() msg = build_scan_msg(points) self.publisher.publish(msg) except queue.Empty: passQoS 配置这里要注意,cartographer 默认用的是best_effort还是reliable取决于版本。Humble 上 cartographer 默认订阅是reliable,所以发布端也用默认的reliable就行。如果你发现 cartographer 收不到数据,先用ros2 topic hz /scan看看有没有数据,再用ros2 topic info /scan --verbose看 QoS 是否匹配。
5. cartographer 单雷达建图配置与调参实录
5.1 cartographer 安装与配置文件结构
cartographer 在 ROS2 上的安装,Humble 版本可以直接sudo apt install ros-humble-cartographer ros-humble-cartographer-ros。装完之后,配置文件在/opt/ros/humble/share/cartographer_ros/configuration_files/下面,有个backpack_2d.lua是单雷达 2D 建图的模板,我们基于它改。
我建议把配置文件拷到自己的工作空间里改,不要直接改系统目录的。拷出来之后,主要改这几个地方:tracking_frame、published_frame、num_laser_scans、use_odometry。单雷达建图的话,num_laser_scans = 1,use_odometry = false,因为我们没有里程计。
include "map_builder.lua" include "trajectory_builder.lua" options = { map_builder = MAP_BUILDER, trajectory_builder = TRAJECTORY_BUILDER, map_frame = "map", tracking_frame = "laser", published_frame = "laser", odom_frame = "odom", provide_odom_frame = true, publish_frame_projected_to_2d = true, use_odometry = false, use_nav_sat = false, use_landmarks = false, num_laser_scans = 1, num_multi_echo_laser_scans = 0, num_subdivisions_per_laser_scan = 1, num_point_clouds = 0, lookup_transform_timeout_sec = 0.2, submap_publish_period_sec = 0.3, pose_publish_period_sec = 5e-3, trajectory_publish_period_sec = 30e-3, rangefinder_sampling_ratio = 1., odometry_sampling_ratio = 1., fixed_frame_pose_sampling_ratio = 1., imu_sampling_ratio = 1., landmarks_sampling_ratio = 1., } TRAJECTORY_BUILDER_2D.min_range = 0.1 TRAJECTORY_BUILDER_2D.max_range = 12.0 TRAJECTORY_BUILDER_2D.missing_data_ray_length = 5.0 TRAJECTORY_BUILDER_2D.use_imu_data = false TRAJECTORY_BUILDER_2D.use_online_correlative_scan_matching = true TRAJECTORY_BUILDER_2D.motion_filter.max_angle_radians = math.rad(0.1) MAP_BUILDER.use_trajectory_builder_2d = true TRAJECTORY_BUILDER_2D.submaps.num_range_data = 90 POSE_GRAPH.optimize_every_n_nodes = 90 return options5.2 关键参数调优与建图飘的排查
建图飘是新手最常遇到的问题,原因通常有几个:时间戳不对、frame_id 不对、角度增量不对、雷达安装不稳。我逐个说。
时间戳问题最隐蔽。LaserScan 的header.stamp必须是数据采集的时刻,不能是发布时刻。如果你在发布线程里用now()取时间,而数据在队列里排了一会儿,时间戳就会偏晚,cartographer 会以为雷达在动,图就飘了。我的做法是在解析数据的时候就打时间戳,跟着数据一起进队列,发布的时候直接用。
frame_id 问题也很常见。tracking_frame和 LaserScan 的frame_id必须一致,否则 cartographer 找不到坐标变换,直接报错或者不建图。我统一用laser,简单省事。
角度增量问题前面提过,N10 的帧内角度增量不是固定的 18 度,得动态算。如果你用固定值,建出来的图会有规律性的扭曲,墙是波浪形的。
雷达安装不稳的话,建图会重影。N10 很轻,但线缆会带着它动,建议用支架固定好,线缆留点余量别绷太紧。
注意:cartographer 的
missing_data_ray_length参数控制的是没有数据时射线延伸的长度。设太小了,空旷区域建不出来;设太大了,噪声会被放大。我试过 3.0、5.0、8.0,最后定在 5.0,对室内环境比较合适。
5.3 建图效果验证与数据回放
建图跑起来之后,用rviz2看效果。rviz2里添加LaserScan显示,话题选/scan,再添加Map显示,话题选/map。如果能看到点云和地图,说明链路通了。
数据回放是我这套方案的一个亮点。因为数据都存 SQLite 了,我可以写个回放脚本,从数据库里按帧读数据,重新发布到/scan话题上,cartographer 照样能建图。这样调参的时候不用每次都推着雷达跑,坐在电脑前就能反复试。回放脚本的核心就是查数据库、按时间戳排序、逐帧发布,注意发布频率要和原始采集频率一致,不然 cartographer 的时间同步会乱。
def replay_from_db(db_path, publisher, rate=10): conn = sqlite3.connect(db_path) frames = conn.execute('SELECT frame_id, timestamp FROM frames ORDER BY timestamp').fetchall() for frame_id, ts in frames: points = conn.execute( 'SELECT angle, distance FROM points WHERE frame_id=?', (frame_id,) ).fetchall() msg = build_scan_msg(points) msg.header.stamp = rclpy.time.Time(seconds=ts).to_msg() publisher.publish(msg) time.sleep(1.0 / rate)回放的时候有个细节:时间戳要用数据库里存的原始时间戳,不能用当前时间,否则 cartographer 会以为你在瞬移。这个坑我踩过,回放出来的图完全对不上。
6. 常见问题排查与实操避坑指南
6.1 串口读不到数据怎么办
串口读不到数据,按这个顺序排查:先确认设备节点对不对,ls /dev/ttyUSB*看看有没有;再确认权限,普通用户默认没有串口读写权限,要么sudo跑,要么把用户加到dialout组;然后确认波特率,N10 是 230400,设错了读到的是乱码;最后确认线序,TX 接 RX、RX 接 TX,接反了没数据。
如果这些都对了还是没数据,用cat /dev/ttyUSB0 | xxd看看有没有字节流出来。有字节流但解析不出来,那就是协议解析的问题,检查帧头对不对、校验和算法对不对。
6.2 SQLite 写入报 database is locked
这个错误通常是多个进程同时写同一个数据库文件导致的。解决办法是开启 WAL 模式,并且确保只有一个写入进程。如果你在写入的同时用 DB Browser 打开了文件,DB Browser 默认是只读模式,一般不会锁,但如果你在 DB Browser 里执行了写操作,就会锁。
另一个原因是事务没提交。如果你开了事务但忘了commit,其他连接就会一直等。我的建议是每次批量写入后立即commit,不要攒着。
6.3 cartographer 建图不动或者报 TF 错误
TF 错误一般是 frame_id 不匹配。用ros2 run tf2_tools view_frames生成 TF 树看看,确认laser到map的变换链是完整的。如果缺odom到laser的变换,检查provide_odom_frame是不是设成了true。
建图不动的话,先看/scan话题有没有数据,ros2 topic hz /scan看频率。有数据但图不动,检查use_online_correlative_scan_matching是不是开了,这个参数对单雷达建图很关键,不开的话扫描匹配很弱,图基本不动。
6.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 串口无数据 | 权限/波特率/线序 | 加 dialout 组,确认 230400,检查 TX/RX |
| 数据乱码 | 波特率错误 | 改为 230400 |
| 建图飘 | 时间戳不对 | 采集时打时间戳,随数据入队 |
| 建图重影 | 雷达安装不稳 | 加固支架,线缆留余量 |
| 墙是波浪形 | 角度增量固定 | 改为动态计算角度增量 |
| database is locked | 多进程写入 | 开 WAL,单写入进程 |
| TF 报错 | frame_id 不匹配 | 统一用 laser,检查 TF 树 |
| 图不动 | 扫描匹配未开 | 开 use_online_correlative_scan_matching |
6.5 几个我踩过的坑
第一个坑是串口缓冲区溢出。我一开始用readline()读串口,结果 N10 的数据里没有换行符,readline一直阻塞,程序卡死。后来改成read(512)才正常。
第二个坑是 SQLite 的AUTOINCREMENT和INTEGER PRIMARY KEY的区别。INTEGER PRIMARY KEY会自动复用删除的 ID,AUTOINCREMENT不会。我需要帧号单调递增,所以用了AUTOINCREMENT。这个细节不注意的话,回放的时候帧号会乱。
第三个坑是 cartographer 的num_subdivisions_per_laser_scan参数。默认是 1,意思是每帧数据作为一个整体处理。如果你的雷达转速快、每帧点数少,可以设成 2 或 4,把一帧拆成多份,建图会更平滑。我试过设成 2,效果有提升,但 CPU 占用也上去了。
第四个坑是 ROS2 的 QoS。cartographer 订阅/scan用的 QoS 深度是 1,如果你的发布频率太高,消息会丢。我的做法是发布频率控制在 10Hz 左右,和雷达转速匹配,不要盲目提高。
这套方案跑通之后,我陆陆续续跑了几十次建图实验,数据都存在 SQLite 里,随时可以回放对比不同参数的效果。后来我又在这套基础上加了八叉树地图生成和简单的导航测试,但那是后话了。单雷达建图这个基础打牢了,后面加传感器、加导航都是水到渠成的事。