
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 激光雷达的串口参数是固定的波特率 2304008 位数据位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:offset2])[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库设置timeout0.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_modeWAL) # 开启 WAL 模式读写并发更好 conn.execute(PRAGMA synchronousNORMAL) # 降低同步级别提升写入速度 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_modeWAL这个设置很关键它让 SQLite 支持读写并发你在写入的同时可以用 DB Browser 打开看数据不会锁死。synchronousNORMAL是牺牲一点安全性换速度如果突然断电可能丢最后几条数据但对激光雷达这种场景完全可以接受。批量大小我试过 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 对应的是 HumbleUbuntu 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_maxcartographer 会忽略这些点。from sensor_msgs.msg import LaserScan import math def build_scan_msg(points, frame_idlaser): 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(maxsize100) self.serial_thread threading.Thread(targetself.serial_loop, daemonTrue) 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 1use_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, rate10): 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(secondsts).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_matching6.5 几个我踩过的坑第一个坑是串口缓冲区溢出。我一开始用readline()读串口结果 N10 的数据里没有换行符readline一直阻塞程序卡死。后来改成read(512)才正常。第二个坑是 SQLite 的AUTOINCREMENT和INTEGER PRIMARY KEY的区别。INTEGER PRIMARY KEY会自动复用删除的 IDAUTOINCREMENT不会。我需要帧号单调递增所以用了AUTOINCREMENT。这个细节不注意的话回放的时候帧号会乱。第三个坑是 cartographer 的num_subdivisions_per_laser_scan参数。默认是 1意思是每帧数据作为一个整体处理。如果你的雷达转速快、每帧点数少可以设成 2 或 4把一帧拆成多份建图会更平滑。我试过设成 2效果有提升但 CPU 占用也上去了。第四个坑是 ROS2 的 QoS。cartographer 订阅/scan用的 QoS 深度是 1如果你的发布频率太高消息会丢。我的做法是发布频率控制在 10Hz 左右和雷达转速匹配不要盲目提高。这套方案跑通之后我陆陆续续跑了几十次建图实验数据都存在 SQLite 里随时可以回放对比不同参数的效果。后来我又在这套基础上加了八叉树地图生成和简单的导航测试但那是后话了。单雷达建图这个基础打牢了后面加传感器、加导航都是水到渠成的事。