简介:思岚A1激光雷达C#测试程序是一份面向机器人导航与传感器开发者的示例工程,帮助开发者在C#环境中快速接入A1雷达、完成串口数据收发与扫描可视化。压缩包共37个文件,约89KB,包含13个C#源码文件、解决方案文件、工程配置、可执行程序与调试符号等,源码覆盖串口波特率与DTR信号控制、扫描帧解码、噪声过滤、极坐标雷达图绘制等关键环节,从底层通信到上层显示形成完整链路,适合直接编译运行或二次开发。目前已有406人学习。借助该程序,开发者可理解激光雷达的测距与角度映射原理,学会将原始数据转换为极坐标点云并实时渲染,以半径为距离、角度为方向展现环境轮廓,为室内建图、障碍物检测等应用提供可复用的代码基底,也适合作为课程设计、毕业设计或机器人入门项目的参考。
1. 为什么非要自己写 C# 测试程序:思岚 A1 不只是转一圈出数据
思岚A1激光雷达是很多机器人从业者上手的第一台量产雷达。厂家给的 SDK 是 C++ 写的,编译链一动,Windows 上的硬件驱动依赖就直接劝退新手。而我见过最多的情况是:雷达转起来了,开发者却拿不出一个能立刻复现“转一圈出了一个圆环”的测试界面。与其被 SDK 绑定,不如用 C# 写一个测试程序,把串口收到的一帧帧二进制流变成屏幕上的点云。这篇文章面向要用 C# 做上位机集成、又对 A1 协议一知半解的人,目标是让你从接线到出图不超过一个晚上。A1 的串口协议并不复杂,真正的工程量不在协议本身,而在 C# 的串口读法、拆包逻辑和测量点解析。
2. 认识思岚 A1 的串口协议:先看懂 A1 吐出来的字节,再谈解析
A1 用的是 5V 串口 TTL,默认波特率 115200,数据格式 8N1。和大部分传感器一样,它按“请求—响应”工作:你给它一条命令,它回一段状态信息;你让它进入扫描模式,它就开始源源不断吐测量数据。想要用 C# 写好测试程序,先得像读仪表一样把这一串字节读懂。
2.1 快捷指令与扫描模式的差异:为什么测试程序要优先用 SCAN 模式
A1 的指令不多,测试程序里常用到的有四条:重置、停止、扫描、强制扫描。我习惯把它们放在一个静态类里:
public static class RplidarCommands { public static readonly byte[] Stop = { 0xA5, 0x25 }; public static readonly byte[] Reset = { 0xA5, 0x40 }; public static readonly byte[] Scan = { 0xA5, 0x20 }; public static readonly byte[] ForceScan = { 0xA5, 0x21 }; }这段代码本身没有技巧,重点是你会不会用对。Stop让雷达停止连续输出并回到空闲状态;Reset重启雷达的 DSP,让残留状态清零;Scan是标准扫描模式,要求雷达先建立稳定的测距信号才输出数据;ForceScan则是强制扫描,即使信号质量很差也输出测量值。
测试程序里我一般先发Reset,再发Stop,最后才发Scan。原因是雷达如果上次异常断电,重新上电后可能卡在一个未完成的响应状态,直接发Scan有概率没有任何输出。Stop能把这个不确定状态清掉。而ForceScan只在需要验证“雷达硬件是否还活着”的时候用:比如室内光线很强、被测物体是黑色哑光,Scan模式下曲线缺了一大块,这时切成ForceScan还能看到稀疏的点,说明雷达没坏,只是环境反射太差。
2.2 数据帧格式:起始字节、校验位和角度距离的拆包规则
进入扫描模式后,A1 回传的是一串连续测量点,不是一行行带换行符的文本。每个测量点是一个固定长度的二进制结构,常见定义如下:一个字节的同步/质量字段,两个字节的角度值,两个字节的距离值,总共五个字节。角度和距离都是小端序。角度是定点数,除以 64 才得到角度值;距离也是定点数,除以 4 得到毫米值。同步/质量字段的最高位标记“这一圈扫描的第一个点”,低 7 位是信号质量。
为了不让这些魔数散落在解析代码里,我会把测量点封装成一个类:
public class RplidarPacket { public byte SyncQuality { get; set; } public byte AngleLow { get; set; } public byte AngleHigh { get; set; } public byte DistanceLow { get; set; } public byte DistanceHigh { get; set; } public bool IsStartFlag => (SyncQuality & 0x80) != 0; public int Quality => SyncQuality & 0x7F; public float AngleDegrees { get { int raw = (AngleHigh << 8) | AngleLow; return raw / 64.0f; } } public float DistanceMillimeters { get { int raw = (DistanceHigh << 8) | DistanceLow; return raw / 4.0f; } } }这个类不是完整协议,但它是测试程序的“翻译层”。后面所有显示和统计都基于AngleDegrees和DistanceMillimeters这两个属性,不直接和字节打交道。需要特别注意:不同批次 A1 固件版本对角度、距离的定点位数大概率一致,但这也是最容易出问题的地方。如果你的程序显示角度永远在 0 到 5 度之间反复横跳,先回来核对这个类里的除数和字节顺序。
2.3 协议里的坑:从 A1 到 A2 的兼容性,别把包头写死
很多网上抄来的解析代码会把第一个字节当成固定包头处理,比如看到0xFA就认为是一个新测量点。A1 和 A2 是同一个家族,本质协议相近,但响应描述符的细节有差异。如果你只照着 A2 的格式写死了包头,插上 A1 之后轻则解析错位,重则直接丢数据。
我给测试程序定的规矩是:不在解析层依赖某一个魔数来“对齐”数据包。而是通过同步/质量字段中的起始标志来找到边界,再用角度值是否在 0 到 360 度之间、距离值是否在 0.15 到 12 米范围内来判定这个测量点是不是合法的。比如:
private bool LooksLikeValidPacket(RplidarPacket p) { if (p.IsStartFlag && p.DistanceMillimeters > 12000) return false; if (p.Quality == 0 && p.DistanceMillimeters > 12000) return false; return p.AngleDegrees >= 0 && p.AngleDegrees < 360; }这样写谈不上严谨的校验,但能把明显不是 A1 测量结果的垃圾帧挡在绘图之外。A1 标称测量范围就在 0.15 米到 12 米附近,超过这个范围的测量值多数是异常点,测试程序里宁可丢掉也不要画出来。真正的协议兼容问题还要靠下一章的状态机解决,这里先记住一个原则:测距传感器的测试程序,宁可多丢点,也不要把坏点当真。
3. 搭建 C# 测试程序骨架:SerialPort 参数、状态机与数据缓存
协议看懂了,接下来就是把 C# 上位机的骨架搭起来。A1 本质上就是一个串口设备,所以这章的内容同样适用于很多串口激光雷达和类似传感器。核心三件事:串口参数、指令发送顺序、以及一个不会拆错包的读缓冲。
3.1 SerialPort 还是自己控制 COM 口:测试程序的取舍
C# 里最省事的串口类是System.IO.Ports.SerialPort。它自带DataReceived事件和内部接收缓冲,但对这类高速连续数据流也有个麻烦:事件触发时机由操作系统驱动决定,一次事件可能带来 3 个字节,也可能带来 3000 个字节。应用层必须自己维护一个缓冲,把不连续的字节拼成完整的测量点。
标准初始化参数如下:
var serial = new SerialPort("COM3", 115200, Parity.None, 8, StopBits.One) { Handshake = Handshake.None, ReadBufferSize = 4096, WriteBufferSize = 1024, ReadTimeout = 1000, WriteTimeout = 1000 }; serial.DataReceived += OnDataReceived; serial.Open();A1 默认波特率就是 115200,不需要校验位,也不需要流控。ReadBufferSize我调到 4096,因为 A1 一圈扫描的数据量对 C# 的驱动缓冲来说不算小,如果缓冲太小,系统可能在忙时丢字节。手头如果有 CH340 这类 USB 转串口模块,最好把ReadBufferSize再调大一点,后面避坑章节会解释为什么。
3.2 写入请求指令的细节:什么时候该发 STOP,什么时候发 SCAN
打开串口后的指令顺序不能乱,我的顺序是:Reset,等 200ms;Stop,等 100ms;清空接收缓冲;最后发Scan。写成异步代码:
serial.Write(RplidarCommands.Reset, 0, RplidarCommands.Reset.Length); await Task.Delay(200); serial.Write(RplidarCommands.Stop, 0, RplidarCommands.Stop.Length); await Task.Delay(100); serial.DiscardInBuffer(); serial.Write(RplidarCommands.Scan, 0, RplidarCommands.Scan.Length); await Task.Delay(20); serial.DiscardInBuffer();DiscardInBuffer在这里是故意的。A1 收到Scan后会先回一个响应描述符,这个描述符不是测量点,如果放在正式解析流程里处理,很容易被当成坏数据。测试程序里更省事的做法是:发完Scan后等 20ms,把描述符和它后面的零碎字节全部清掉,再从干净的测量点流开始解析。代价是会丢掉扫描开始的一小段数据,对测试和硬件验证完全无感。如果你想做产品级代码,还是应该按官方 SDK 把描述符长度解析出来,而不是在串口层一刀切。
3.3 用环形缓存拼帧:应对拆包和粘包
串口读数据最常见的两个词是“拆包”和“粘包”。DataReceived一次拿到的数据可能只有半帧,也可能包含好几帧。测试程序要做的不是祈祷每次读到的长度正好是 5 的倍数,而是把所有收到的字节先推进一个缓存,再不断尝试从缓存里切出完整测量点。
private readonly List<byte> _buffer = new List<byte>(); private readonly object _bufferLock = new object(); private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { var serial = (SerialPort)sender; int available = serial.BytesToRead; if (available <= 0) return; byte[] chunk = new byte[available]; serial.Read(chunk, 0, available); lock (_bufferLock) { _buffer.AddRange(chunk); } TryParsePackets(); } private void TryParsePackets() { lock (_bufferLock) { while (_buffer.Count >= 5) { byte[] head = _buffer.GetRange(0, 5).ToArray(); var packet = DecodeMeasurement(head); _buffer.RemoveRange(0, 5); if (LooksLikeValidPacket(packet)) OnPacketReady(packet); } } }List<byte>的RemoveRange是内存拷贝,性能上不算最优,但 A1 一秒钟输出几千个点,测试程序完全扛得住。真正要注意的是_bufferLock不能省。DataReceived在后台线程触发,TryParsePackets又可能被 UI 定时器间接调用,不加锁会出现竞态。DecodeMeasurement就是把刚才那五个字节转成RplidarPacket的过程,拆包逻辑只允许在TryParsePackets这一处调,避免别的地方再写一套而搞乱状态。
4. 把字节变成可视化的测试面板:极坐标转直角坐标与实时刷新
测距雷达测试和普通串口调试不一样:你不能只看十六进制字符串,必须把它画出来。测试程序最终要回答几个问题:雷达转没转、一圈数据齐不齐、某个方向的测距线是不是断了。把测量点转成可视化面板,是最直接的验证手段。
4.1 角度-距离数组的组织:用一张表缓存当前帧
A1 输出的一圈测量点个数并不固定,电机转速、环境反射都会影响点数。为了让绘图逻辑简单,我会用固定的数组缓存当前帧,按角度槽位存放。角度分辨率取 0.1 度,那就是 3600 个槽位:
private readonly float[] _angleCache = new float[3600]; private readonly float[] _distCache = new float[3600]; private void CachePacket(RplidarPacket p) { int index = (int)MathF.Round(p.AngleDegrees * 10) % 3600; if (index < 0) index += 3600; _distCache[index] = p.DistanceMillimeters; _angleCache[index] = p.AngleDegrees; }缓存数组相当于一张查表,绘图时从 0 到 360 度遍历槽位即可,不需要关心 A1 这一圈数据是从哪个角度开始输出的。index < 0的兜底必须写,因为角度值在 359.9 度到 0 度之间跳变时,浮点数取整可能得到负值。不处理这个负索引,最后画出来的图会在某个固定方向缺一块。
4.2 绘制极坐标点云:剔除无效点与毛刺
C# 里画图最简单的是 GDI+,在测试面板上直接Graphics.DrawEllipse点就已经足够直观。绘图时要转直角坐标,同时把无效点和明显毛刺剔除:
private void DrawFrame(Graphics g, float scale) { g.Clear(Color.Black); float centerX = panel.Width / 2f; float centerY = panel.Height / 2f; for (int i = 0; i < 3600; i++) { float dist = _distCache[i]; if (dist < 150 || dist > 12000) continue; float angleDeg = _angleCache[i]; float rad = MathF.PI / 180f * angleDeg; float x = centerX + dist * scale * MathF.Cos(rad); float y = centerY + dist * scale * MathF.Sin(rad); g.DrawEllipse(Pens.Lime, x - 1, y - 1, 2, 2); } }scale是毫米到像素的换算比例,我一般根据面板宽度和最大测距范围动态计算。dist < 150 || dist > 12000直接跳过,正好对应 A1 的 0.15 米到 12 米有效量程。面板上的距离溢出点不画,避免一个假点把整个坐标范围撑爆。
4.3 测试面板上的有效信息:转速、信号强度与异常计数
一个只画点的测试面板还不够,至少要显示三个数字:当前扫描转速、信号质量、无效点比例。转速可以通过两圈起始标记之间的时间差算出来:
private DateTime _lastStartTime = default; private void OnPacketReady(RplidarPacket p) { if (!p.IsStartFlag) return; if (_lastStartTime != default) { double seconds = (DateTime.UtcNow - _lastStartTime).TotalSeconds; if (seconds > 0) { double rpm = 60.0 / seconds; labelRpm.Text = rpm.ToString("0.00"); } } _lastStartTime = DateTime.UtcNow; }这个转速只是粗略估计,因为 A1 没有独立的测速反馈,电机转速也随负载波动。测试程序里够用。更重要的是“无效点比例”和“低质量点占比”。我会在OnPacketReady里统计距离无效的测量点,每 100ms 刷新一次标签。如果无效点比例超过 10%,先别急着在 ROS 里做 SLAM 建图,回头检查测试环境远比调后端参数有效。
5. C# 测试程序避坑指南:A1 常见的翻车现场与排查流程
写这个测试程序的人,十个有九个会在同一个地方折腾一晚上。我这里把最常见的几个翻车现场整理成排查记录,每条都按“现象、原因、解决”来写,方便你直接对照。
5.1 现象:打开串口后发 SCAN 没有回应
现象:程序提示串口已打开,串口助手也能看到数据,但雷达就是不出数据,什么反应都没有。
原因:A1 电机供电不足。A1 的电机和串口逻辑共用一条排线,5V 电压没问题,但电流不够时电机一转就欠压复位,DSP 根本没进入扫描状态。很多 USB 转 TTL 模块只能提供几十毫安电流,根本带不动 A1 的电机。
解决:给 A1 单独供 5V/1A 电源,USB 转 TTL 只接 TX、RX、GND,并且与外部电源共地。程序里先发Reset再发Stop再发Scan。如果你的串口线和雷达之间还串了电平转换芯片,先确认雷达 VCC 确实是 5V,而不是被模块拉到了 3.3V。
5.2 现象:数据断帧/乱码,解析出异常角度
现象:画出的点云忽前忽后,角度值一会 300 度一会 50 度,距离值明显偏大偏小。
原因:拆包位置不对。最常见的是把Scan后的响应描述符当成了测量点,导致后续所有测量点整体偏移 5 个或者 7 个字节。另一个原因是波特率被改成了 9600,A1 收到的命令解析错误,返回的字节流自然不稳定。
解决:先确认程序里 SerialPort 初始化确实用了 115200,然后检查是否在发完Scan之后调用了DiscardInBuffer清掉描述符。还可以在LooksLikeValidPacket里增加一个统计,把连续 5 万个无效包打印出来,看无效包是随机出现还是有规律地每 N 个包出现一次。有规律出现几乎是拆包偏位,随机出现一般是电平干扰。
5.3 现象:程序卡死或 UI 假死
现象:面板开始刷新后,程序窗口变成“未响应”,鼠标转圈,但雷达还在转。
原因:在DataReceived事件里直接操作 UI 控件,或者直接调用Control.BeginInvoke去画点。A1 的数据量大,事件触发频率极高,UI 线程被画点请求淹没,消息队列根本处理不过来。
解决:拆包线程只负责写入缓存数组,不碰任何控件。画图用一个独立Timer,每 100ms 触发一次panel.Invalidate(),在Paint事件里再读取缓存绘制。这等于把数据生产和渲染解耦了,UI 再也不会因为雷达而卡死。
5.4 现象:USB 转串口芯片导致的数据延迟
现象:点云刷新速度正常,但雷达实际位置已经转了半圈,屏幕上的图还在上一个位置。转速显示也不稳定,一下 6 一下 8。
原因:某些 USB 转串口驱动会在应用层把数据包积压到一定数量后才一次性回调DataReceived。这种延迟不是 SerialPort 的错,而是驱动策略造成的。尤其是部分 PL2303 芯片的老驱动,数据稍微一多就开始延迟。
解决:优先换 FT232 或 CP2102 的 USB 转 TTL 模块。如果暂时不能换,就把ReadBufferSize调大,并在OnDataReceived里一次性Read完BytesToRead,不要一次只读 1 字节。A1 的串口速度不低,转接芯片驱动是测试程序里最容易忽略的“硬件玄学”。
5.5 现象:测试面板上某个角度区间永远是空的
现象:其他方向都能画出墙的轮廓,但某个固定角度范围始终空白,无论怎么转雷达都是同一个位置缺。
原因:雷达周围有遮挡物,或者被测物体是透明玻璃、黑色哑光材质。A1 是单点红外三角测距,透明玻璃会穿透或反射混乱,黑色物体吸光,太阳光直射也会让信号质量急剧下降。另一个可能是我在 4.1 节里提到的负索引问题,index < 0没有做兜底,导致某个角度方向的数据写入了不存在的槽位。
解决:先把雷达放在一个空旷房间重新扫描,确认缺口是否还在。如果消失,就说明是环境问题。如果还在,检查CachePacket中取模后是否又把负索引加了回来。这个模运算看似简单,但几乎每个忙起来的人都会在临界角度上写错。
6. 进阶:把测试程序扩展成简易点云调试工具
测试程序可以不只是“看一眼能不能出数”。我把这个 C# 程序慢慢扩展成了一个面向点云调试的小工具,用来在上 ROS 或激光雷达 SLAM 建图之前先把数据质量验证一遍。
6.1 用 C# 写一个极坐标网格背景
默认黑色背景上的绿点不够直观。我会在Paint事件里先画一组同心圆,半径对应 1 米、2 米、3 米,再画 30 度间隔的放射线。这样一眼就能看出发射点在哪一档距离,也能看出墙角有没有变形。画同心圆用DrawEllipse加Pen就行,不需要引入额外控件。
6.2 帧率与线程优先级设置
UI 刷新放在Timer里,间隔 100ms 比较合理,再低就浪费 CPU。拆包线程保持默认Normal优先级即可,不要为了流畅把进程优先级设成High。A1 的测量点流没有实时抢占诉求,真正会被高优先级拖垮的是 USB 串口驱动的系统中断处理。这个优先级上的克制,是我跑了很长时间测试之后才学到的。
6.3 验证数据的一贯做法
我最后的习惯是在测试程序里加一个“单圈抓包”按钮:按下后,把从下一个起始标志开始到再下一个起始标志之前的所有原始字节保存成 hex 文件。这个文件看起来没用,但只要之后在 ROS 仿真、激光雷达 SLAM 建图里发现地图偏斜,我都会回头打开这个十六进制文件,人工检查角度是否连续递增、距离是否平滑。很多后端问题最终都回到原始数据这一步,有抓手就不需要靠猜。
测试程序能做到这步,已经不只是“验证雷达好坏”了,它变成了我调试所有测距传感器时的通用工作台。希望这篇思岚 A1 激光雷达 C# 测试程序的记录,能帮你在做上位机和点云调试时少走弯路。希望帮到你。
本文还有配套的精品资源,点击获取