简介:这份无人机地面站软件源码面向刚接触无人机地面站与C#桌面开发的入门者,基于C# WinForms和GDI+实现,可用于学习无人机状态显示、在线地图、航线航迹、飞行参数曲线以及航线跟踪飞行仿真等典型模块。工程将串口通信、GMap多地图接入(谷歌、高德、腾讯等)和自定义通信协议组织在桌面应用中,便于理解地面站从数据接收到可视化呈现的基本链路。压缩包共169个文件,包含30个cs源码、30个dll依赖、17个resources资源、16个png界面素材及9个resx、18个dat、11个exe等,整体约15.1MB,源码、资源、配置与可执行文件相对齐备。已有783人学习,适合作为课程设计、入门练习或二次开发底稿,通过阅读工程结构与界面实现,可较快掌握WinForms绘图、地图叠加和串口数据展示的排错思路。
1. 从串口到仪表盘:一套 C# WinForms 地面站源码能跑通什么
一份 UAV 无人机地面站软件源码摆在面前,多数人第一反应是双击 .sln 按 F5,然后看到一个空白窗体加几个灰色按钮,就没底了。这套 C# WinForm 地面站源码要解决的事其实很窄也很硬:数传电台吐进来的原始字节,怎么一步步变成姿态仪上的俯仰角、地图上的飞机图标、一条能下发回飞控的航线,以及一份事后能翻出来复盘的日志。适合正在写飞控上位机、做测试工装或课程设计的开发者,也适合手上有串口调试助手、但需要一个可视化壳子的团队。下面按数据流顺序拆,从打开串口一直讲到回放。
2. 遥测链路:从串口字节到一帧可用数据
这一段是整个软件重写率最高的地方。界面可以慢慢调,链路搭不稳,后面所有控件都是摆设。我见过不少版本把SerialPort.DataReceived里直接ReadLine()然后Split(','),自己定一套文本协议,测试阶段勉强能用,一上真机遇到二进制协议立刻崩。
2.1 串口参数:57600 8N1 之后的四个隐藏开关
波特率 57600 和 115200 是数传电台最常见的两档,8 数据位、无校验、1 停止位基本是默认,真正出问题的是那几个不写在文档首页的开关。DtrEnable和RtsEnable在一些半双工电台模块上决定了它愿不愿意往外吐数据,不置位就是“串口打开成功但一个字节都收不到”的典型玄学。ReadBufferSize默认 4096 对 50Hz 遥测够用,但如果你的链路上还混着航点、参数、日志流,建议抬到 8192。
还有一个坑来自接收方式本身。ReadExisting()返回的是字符串,内部按当前Encoding解码,二进制协议里 0x80 以上的字节会被替换成问号,帧直接废掉。正确姿势是BytesToRead拿长度、Read(byte[], int, int)拿原始字节。
private SerialPort _port; private readonly BlockingCollection<byte[]> _rxQueue = new BlockingCollection<byte[]>(512); private CancellationTokenSource _cts = new CancellationTokenSource(); private void OpenLink(string portName, int baud) { _port = new SerialPort(portName, baud, Parity.None, 8, StopBits.One) { ReadBufferSize = 8192, ReadTimeout = 500, WriteTimeout = 500, DtrEnable = true, // 部分数传模块靠 DTR 拉起发送使能 RtsEnable = true // 不开硬件流控时也给高,避免模块半双工挂死 }; _port.DataReceived += OnDataReceived; _port.Open(); // 端口被占用会抛 UnauthorizedAccessException Task.Factory.StartNew(ParseLoop, _cts.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); } private void OnDataReceived(object sender, SerialDataReceivedEventArgs e) { int n = _port.BytesToRead; // 先问有多少,再按量读 if (n <= 0) return; var buf = new byte[n]; int read = _port.Read(buf, 0, n); if (read > 0) { var copy = new byte[read]; Buffer.BlockCopy(buf, 0, copy, 0, read); // 必须拷贝,buf 不能跨线程持有 _rxQueue.Add(copy); // 队列满了会阻塞,等于给串口做背压 } }这段代码的核心不是语法,是线程边界。DataReceived在 ThreadPool 线程上触发,触发频率和驱动有关,通常是攒够十几个字节才回调一次,所以一次Read拿到的可能是半帧,也可能是三帧半。把字节丢进BlockingCollection之后立刻返回,串口驱动的缓冲区就不会因为你的解析逻辑慢而溢出。队列容量设 512 是经验值,正常遥测下永远填不满,真填满了说明解析线程卡死,这时候阻塞比丢数据更安全。
提示:USB 转串口线的“延迟计时器”默认常见是 16ms,短包协议下会明显拉高延迟,设备管理器里改成 1ms 会立竿见影,但改之前先确认线材驱动支持。
2.2 解帧:0xFD 只是开头,CRC_EXTRA 才是准入门槛
MAVLink v2 的帧头是0xFD,v1 是0xFE,很多人的解析器写成“找到 0xFD 就当帧头”,结果一遇到字节流里的脏数据就散架,解析出来的角度乱跳到几百弧度。帧结构本身不复杂:魔数 1 字节、负载长度 1 字节、不兼容标志 1 字节、兼容标志 1 字节、序号 1 字节、系统 ID 1 字节、组件 ID 1 字节、消息 ID 3 字节,然后负载,然后 2 字节校验。
关键在于校验不只是算帧头加负载,还要在末尾追加一个CRC_EXTRA,这个字节由消息定义文件生成,每个消息 ID 对应一个固定值,换协议方言就得重新生成。少了它,误码率高的无线链路上你会收到大量“看起来合法”的假帧。
// 消息 ID -> CRC_EXTRA,值由 common.xml 一类的定义文件生成,自定义方言要重新生成 private static readonly Dictionary<uint, byte> CrcExtra = new Dictionary<uint, byte> { { 0, 50 }, // HEARTBEAT { 30, 39 }, // ATTITUDE { 33, 104 }, // GLOBAL_POSITION_INT }; private const byte MagicV2 = 0xFD; private const byte MagicV1 = 0xFE; // 从 offset 处尝试解一帧,成功返回 true 并给出帧长 private bool TryParseOne(List<byte> buf, int offset, out int frameLen, out MavFrame frame) { frameLen = 0; frame = null; if (buf.Count - offset < 8) return false; byte magic = buf[offset]; if (magic != MagicV2 && magic != MagicV1) { frameLen = 0; return false; } bool v2 = magic == MagicV2; int headerLen = v2 ? 10 : 6; int payloadLen = buf[offset + 1]; int total = headerLen + payloadLen + 2; // 头 + 负载 + 校验 if (buf.Count - offset < total) return false; // 还没收全,等下一批 uint msgId = v2 ? (uint)(buf[offset + 7] | buf[offset + 8] << 8 | buf[offset + 9] << 16) : buf[offset + 5]; if (!CrcExtra.TryGetValue(msgId, out byte extra)) { frameLen = 0; return false; } ushort crc = 0xFFFF; for (int i = offset + 1; i < offset + headerLen + payloadLen; i++) // 从长度字节开始,跳过魔数 crc = Accumulate(crc, buf[i]); crc = Accumulate(crc, extra); // 追加 CRC_EXTRA 再走一步 ushort got = (ushort)(buf[offset + total - 2] | buf[offset + total - 1] << 8); if (crc != got) { frameLen = 0; return false; } // 校验失败只丢 1 字节,重新找帧头 var payload = new byte[payloadLen]; buf.CopyTo(offset + headerLen, payload, 0, payloadLen); frame = new MavFrame { MsgId = msgId, Payload = payload, Seq = buf[offset + 4] }; frameLen = total; return true; } private static ushort Accumulate(ushort crc, byte data) { byte tmp = (byte)(data ^ (byte)(crc & 0xFF)); tmp ^= (byte)(tmp << 4); return (ushort)((crc >> 8) ^ (tmp << 8) ^ (tmp << 3) ^ (tmp >> 4)); }解析循环里有个细节必须处理:校验失败时frameLen返回 0,调用方只把读指针往后挪一个字节,而不是整帧丢弃。因为在脏字节流里,真正的帧头很可能就藏在你以为的“负载”中间。另一个防护是缓冲区上限,长时间跑下来如果缓冲区超过 64KB 还没解出东西,说明链路已经完全错位,直接砍掉保留最后 4KB 重新同步,比让内存缓慢涨上去体面得多。
2.3 三级流水:收线程、解析线程、UI 定时器
链路跑通之后最常见的性能事故是界面卡死。姿态消息在 50Hz,GPS 10Hz,再加电池、遥控、状态文本,一秒几百帧不夸张。如果每一帧都BeginInvoke到 UI 线程去刷控件,UI 消息队列会被塞爆,表现是窗口拖动迟滞、按钮点不动,任务管理器里 CPU 未必高,但就是“卡”。
正确做法是把“最新值”和“消息流”分开。界面关心的从来不是每一帧,而是当前值。所以解析线程只负责解帧并写入一组无锁的“最新值槽位”,UI 用一个 100ms 的定时器去拉。
private Attitude _latestAttitude; // 引用类型,用 Interlocked 交换即可 private GlobalPos _latestPos; private void OnFrame(MavFrame f) { switch (f.MsgId) { case 30: Interlocked.Exchange(ref _latestAttitude, DecodeAttitude(f.Payload)); break; case 33: Interlocked.Exchange(ref _latestPos, DecodeGlobalPos(f.Payload)); break; default: _logWriter?.Write(f); // 其余消息直接落盘,不进界面 break; } } private void UiTimer_Tick(object sender, EventArgs e) { if (IsDisposed || !IsHandleCreated) return; var att = Interlocked.Exchange(ref _latestAttitude, null); // 取走即清空,避免重复绘制 if (att != null) { attitudeControl.SetAttitude(att.RollDeg, att.PitchDeg, att.YawDeg); lblAlt.Text = ((int)att.AltRel).ToString(); // 5Hz 量级的文本更新可以接受 } var pos = Interlocked.Exchange(ref _latestPos, null); if (pos != null) mapOverlay.UpdateAircraft(pos.Lat, pos.Lon, pos.Hdg); }Timer.Interval = 100意味着界面最多 10Hz 刷新,肉眼完全够用,而 GDI+ 的绘制开销直接降到原来的十分之一。Interlocked.Exchange取走再置空,是为了在飞控断链时界面停在最后一帧而不是跳回零,这个细节在排故时很有用——你能一眼看出是数据没了还是飞机真的掉下来了。落盘日志写原始帧而不是解析后的结构体,理由在最后一章会讲。
3. 飞控数据上屏:自绘控件与实时曲线
WinForms 自带的进度条和标签应付不了姿态仪,这类控件必须自己画。好消息是 GDI+ 画一个姿态球并不难,难的是画得稳、不闪、不占 CPU。
3.1 姿态仪:两次变换搞定滚转与俯仰
思路是把整个画布先平移到控件中心,再旋转负的滚转角,然后以中心为基准上下填两块矩形——上面是天、下面是地,两者的分界线按俯仰角偏移。最后把绘制区域裁剪成一个圆,超出圆的部分自然消失,看起来就是个球。
public class AttitudeIndicator : Control { private float _roll, _pitch; private const float PixelsPerDeg = 4f; // 每度俯仰对应多少像素,跟控件高度挂钩 public AttitudeIndicator() { // 双缓冲 + 不画背景,消除闪烁的三个关键样式 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); } public void SetAttitude(float rollDeg, float pitchDeg, float yawDeg) { // 死区判断:变化小于 0.3 度不重绘,静止停机时 CPU 直接归零 if (Math.Abs(rollDeg - _roll) < 0.3f && Math.Abs(pitchDeg - _pitch) < 0.3f) return; _roll = rollDeg; _pitch = pitchDeg; Invalidate(); } protected override void OnPaint(PaintEventArgs e) { var g = e.Graphics; g.SmoothingMode = SmoothingMode.AntiAlias; g.Clear(Parent?.BackColor ?? Color.Black); float cx = Width / 2f, cy = Height / 2f, r = Math.Min(cx, cy) - 4; var state = g.Save(); g.SetClip(new RectangleF(cx - r, cy - r, r * 2, r * 2)); // 先裁圆 g.TranslateTransform(cx, cy); g.RotateTransform(-_roll); // 飞机右倾,天地线左转 float offset = _pitch * PixelsPerDeg; g.FillRectangle(Brushes.SkyBlue, -r * 2, -r * 4 + offset, r * 4, r * 4); g.FillRectangle(Brushes.SaddleBrown, -r * 2, offset, r * 4, r * 4); // 每 10 度一条俯仰刻度线,超出可视圆的部分靠裁剪自动去掉 for (int deg = -90; deg <= 90; deg += 10) { float y = offset - deg * PixelsPerDeg; g.DrawLine(Pens.White, -r * 0.35f, y, r * 0.35f, y); } g.Restore(state); // 固定机翼符号,不参与旋转 g.DrawLine(new Pen(Color.Yellow, 2f), cx - r * 0.6f, cy, cx - r * 0.15f, cy); g.DrawLine(new Pen(Color.Yellow, 2f), cx + r * 0.15f, cy, cx + r * 0.6f, cy); g.DrawEllipse(Pens.Yellow, cx - 3, cy - 3, 6, 6); } }RotateTransform(-_roll)这个负号容易搞反,判断标准很简单:飞机右滚时,飞行员看到的天地线是向左倾斜的。SetClip放在变换之前是个技巧,这样裁剪区域用的是控件坐标,不用跟着旋转跑。俯仰刻度线画到 ±90 度、长度给到r * 0.35,超出圆的范围会被裁掉,不需要自己算交点,这是省事又不容易错的做法。
3.2 实时曲线:环形缓冲比 List 靠谱
高度、空速这类曲线,一般要保留最近 60 秒。用List<float>加RemoveAt(0)是最直观的写法,但每秒 50 次插入加一次头部删除,跑两小时之后 GC 会明显抬头。固定长度数组加头指针就够,写满一圈自然覆盖旧值。
private readonly float[] _altBuf = new float[600]; // 50Hz 采样,600 点约 12 秒 private int _head; private int _count; public void Add(float value) { _altBuf[_head] = value; _head = (_head + 1) % _altBuf.Length; _count = Math.Min(_count + 1, _altBuf.Length); } protected override void OnPaint(PaintEventArgs e) { if (_count < 2) return; // 复用点数组,避免每次 OnPaint 分配新内存 if (_points == null || _points.Length != _count) _points = new PointF[_count]; float min = float.MaxValue, max = float.MinValue; for (int i = 0; i < _count; i++) { var v = At(i); if (v < min) min = v; if (v > max) max = v; } if (max - min < 1f) { max = min + 1f; } // 平飞时避免除零和满屏抖动 float sx = (float)Width / (_count - 1), sy = Height - 8; for (int i = 0; i < _count; i++) { float norm = (At(i) - min) / (max - min); _points[i] = new PointF(i * sx, sy - norm * (Height - 16)); } e.Graphics.DrawLines(Pens.Lime, _points); } private float At(int i) => _altBuf[(_head - _count + i + _altBuf.Length * 2) % _altBuf.Length];At(i)里那个+ _altBuf.Length * 2是为了让负数取模不出问题,C# 的%对负数返回负值,少了这个补偿,曲线在最开始几秒会索引越界。纵向自适应用min/max而不是固定量程,好处是悬停时也能看清细节,坏处是飞机急升时曲线会“缩水”,我一般会在旁边用标签把当前量程写出来,避免误读。
3.3 刷新节流:GDI+ 的性能特点
控件Text属性的 setter 每次都会判断是否相等再触发重绘,看着无害,但字符串拼接本身每帧都在分配。姿态窗口如果还带十几个数字标签,一秒几百次ToString()加上格式串,GC 的 Gen0 回收会明显变密。经验做法是:角度、高度这类整数量化到 1 再比较,变化了才写;坐标保留 6 位小数,但只在 GPS 帧到达时才更新,不要跟着姿态帧走。
另一个细节是Invalidate()与Refresh()的区别。Invalidate只是标记区域,等消息循环空闲时统一重绘,多次调用会合并;Refresh是立即同步重绘,在定时器里连用会让整个界面变成同步阻塞。所有自定义控件内部统一用Invalidate,只有在需要立刻截图的场景(比如生成报告)才用Refresh。
注意:给窗体本身也开双缓冲,
DoubleBuffered = true只对 Form 生效,容器上的子控件还是会各自闪,所以每个自绘控件都要单独SetStyle。
4. 地图与航点:把飞机放对位置,把航线发对顺序
地图这块是“看起来最简单、实际最容易被打回”的模块。拖动缩放谁都会写,真正决定可用性的是断网时出不出图、图标偏不偏、航点发下去飞控认不认。
4.1 瓦片:断网现场怎么还有图
在线地图源在实验室里一切正常,到了外场手机热点一断,地图立刻变成一片灰格子。这不是控件的问题,是瓦片缓存没提前铺。常见做法是在出任务前,沿计划航线把缩放级别 10 到 17 的瓦片预先抓一遍,落到本地缓存目录,控件启动时先读缓存再走网络。缓存体积不小,一条 20 公里的航线铺到 17 级大概几百 MB,按需取舍。
// 初始化地图:先设缓存,再设图源和缩放范围 mapControl.CacheLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "MapCache"); mapControl.Manager.Mode = AccessMode.ServerAndCache; // 有网走服务端,没网自动回落到缓存 mapControl.MapProvider = mapProvider; // 图源可切换,方便换不同底图 mapControl.MinZoom = 3; mapControl.MaxZoom = 19; mapControl.Zoom = 15; mapControl.DragButton = MouseButtons.Left; // 左键拖图,右键留给航点菜单 var aircraft = new GMarkerGoogle(new PointLatLng(lat, lon), GMarkerGoogleType.red_drone); aircraft.Offset = new Point(-aircraft.Size.Width / 2, -aircraft.Size.Height / 2); // 锚点对准图标中心 mapControl.Overlays.Add(aircraft);Offset那一行是我踩过最久的坑。默认锚点在图标左上角,GPS 更新时图标会整体向右下“跳”一格,看起来像坐标漂移,实际是渲染锚点没对齐。另外AccessMode一定要设成ServerAndCache,不要设纯Server,否则断网时控件会不断地发请求、超时、重试,界面卡成幻灯片。
坐标系问题得单独说一句。如果你用的底图是经过偏移加密的国内图源,而飞控给的经纬度是原始 WGS84,两者叠加会整体错开几百米,飞机图标落在马路对面。判断方法很简单:找一个已知地标的已知坐标打点对比。解决方式要么换回无偏移的图源,要么在显示层做一次坐标转换,千万别在存储层改——日志里存的必须是原始坐标,不然回放全乱。
4.2 航点:屏幕坐标、经纬度和本地 NED 的三次换算
用户在地图上点一下,控件给的是屏幕像素;要变成航点,得先转成经纬度,再算相对起飞点的东北天偏移。反过来显示时再倒推一遍。经纬度到本地平面的近似换算在几公里范围内误差可以忽略,用等距圆柱近似就够,没必要上完整的墨卡托反算。
private const double MetersPerDegLat = 111320.0; // 经纬度 -> 相对 home 的东北偏移(米) private (double north, double east) ToLocal(double lat, double lon, double homeLat, double homeLon) { double north = (lat - homeLat) * MetersPerDegLat; double east = (lon - homeLon) * MetersPerDegLat * Math.Cos(homeLat * Math.PI / 180.0); return (north, east); // 注意经度方向要乘 cos(纬度),高纬度地区差得很多 } // 鼠标点击 -> 航点 private void OnMapMouseClick(object sender, MouseEventArgs e) { if (e.Button != MouseButtons.Right) return; var ll = mapControl.FromLocalToLatLng(e.X, e.Y); var (n, ea) = ToLocal(ll.Lat, ll.Lng, _homeLat, _homeLon); _mission.Add(new Waypoint { Seq = _mission.Count, Lat = ll.Lat, Lon = ll.Lng, AltRel = (float)numericAlt.Value, // 相对高度,别用绝对高度糊弄 North = n, East = ea }); RefreshRouteOverlay(); }cos(homeLat * PI / 180)这个因子漏掉的话,在北纬 40 度左右,东西方向会有约 23% 的系统性偏差,表现就是“航点看着在路口,飞机实际飞到了路口往东一百多米”。AltRel用相对高度是有意的,飞控侧一般也按相对起飞点解析,混用绝对高度在山区是事故级错误。
4.3 航点上传:一问一答,别一把梭
航点协议是严格的一问一答:地面站先发总数,飞控回一个“我要第 0 条”,地面站发第 0 条,飞控要第 1 条……全部发完,飞控回一个确认。很多人图省事,收到总数确认后连发一串,短链路上看着能成,链路一抖就出现“飞到第 5 个点又绕回第 2 个点”的怪事。
private async Task<bool> UploadMissionAsync(IList<Waypoint> wps, CancellationToken ct) { for (int attempt = 0; attempt < 3; attempt++) // 整体重试 3 轮 { SendMissionCount((ushort)wps.Count); int sent = 0; var deadline = DateTime.UtcNow.AddSeconds(15); while (sent < wps.Count && DateTime.UtcNow < deadline) { var req = await WaitRequestAsync(TimeSpan.FromSeconds(2), ct); // 等“要第几条” if (req == null) break; // 超时,交给外层重试 if (req.Value >= wps.Count) break; SendMissionItemInt(wps[req.Value], req.Value); // 一问一答,绝不预发 sent = req.Value + 1; } var ack = await WaitAckAsync(TimeSpan.FromSeconds(3), ct); if (ack == 0) return true; // 0 = ACCEPTED // 非 0 的 ack 是飞控明确拒绝,重试没意义,直接把错误码弹出来 if (ack > 0) throw new InvalidOperationException($"飞控拒绝航点,错误码 {ack}"); } return false; }序号从 0 开始,这点在MISSION_ITEM_INT和老的MISSION_ITEM之间是一致的,但如果你混用了不同版本的消息定义,很容易出现“总数 5 条,实际只飞 4 条”的错位。省事的验证办法是上传完成后立刻读回来一次,逐条比对经纬度和高度,差一条就报错。多花的这两秒,比飞机飞到一半停在那儿强。
5. 避坑与排查:五个真实翻车现场
下面这几条都是从能跑变成能用的过程中必然会撞上的,按现象、原因、解决三段写,方便对着排查。
5.1 链路层:串口与解帧的三个坑
现象:串口打开成功,前几秒数据正常,之后DataReceived再也不触发,程序像死了一样。原因是接收回调里用了ReadExisting()按文本读,或者Read之后没有把字节及时取走,驱动缓冲区溢出后驱动层直接停止上报。解决办法是把接收逻辑改成“问多少读多少、读完立刻入队”,并且给回调整体套try/catch,任何异常都要记日志并触发重连状态机,不能让异常逃到 ThreadPool 上把进程带走。
现象:姿态角在静止时也会周期性跳到几千度。原因是解帧只匹配了魔数 0xFD 就往下走,没有做 CRC 校验,链路里的随机噪声被当成帧头。解决是把全帧 CRC 加上CRC_EXTRA一起算,校验失败时只前进一个字节重新找头,而不是整段丢弃。
现象:拔插一次数传电台,程序直接闪退。原因是串口被物理拔出时Read会抛IOException,而回调运行在后台线程,未捕获异常会终止进程。解决是把串口生命周期做成状态机(关闭 → 打开 → 就绪 → 断开),断开时先-= DataReceived再Dispose,UI 上给一个明确的链路指示灯,别让用户靠猜。
5.2 UI 与地图层:卡顿与偏移的两个坑
现象:飞了二十分钟后界面越来越卡,内存曲线一路上扬。原因是每一帧都BeginInvoke到 UI 线程,而且每次重绘都new Bitmap做离屏缓冲。消息队列越积越长,位图对象又等着 GC。解决是把“每帧刷新”改成“定时拉最新值”,离屏位图在Resize时创建一次、反复复用。
现象:地图上飞机图标稳定偏在实际位置的斜前方几百米,重启也不恢复。原因有两个,一是图标锚点没用Offset居中,二是底图坐标系与飞控输出的 WGS84 不一致。解决是先打一个已知地标的静态点确认是哪种偏移,锚点问题改Offset一行就够,坐标系问题在显示层做转换,底层数据保持原样。
现象:航点上传十次里有一次失败,飞控报“航点数量不符”。原因是没等飞控逐条请求就连发,或者重试时没有把序号清零重新走总数握手。解决是严格一问一答,重试从MISSION_COUNT重来,并且在上传后读回校验。
6. 日志回放:用一份 bin 文件把那次飞行重演一遍
排故最有效的手段不是盯着现场,是回放。这套源码里我坚持只记原始字节,不记解析后的结构体,格式就三列:4 字节时间戳(毫秒,相对开始)、2 字节长度、N 字节原始帧。
| 字段 | 长度 | 说明 |
|---|---|---|
| TimestampMs | 4 字节 | 相对录制开始的毫秒数,小端 |
| FrameLen | 2 字节 | 后面跟的原始字节数 |
| Raw | FrameLen 字节 | 完整的一帧,含魔数和校验 |
回放时起一个虚拟链路,把同一份字节按时间戳喂给同一个解析器,界面代码一行都不用改。这一步的价值在于:现场出现的问题,你能在桌面上反复重放,改一行解帧逻辑再看一遍结果。如果当初存的是解析后的结构体,协议改一次、字段加一个,历史日志就全废了。
// 回放:按时间戳节奏把原始帧塞回解析队列,模拟真实链路 using (var fs = File.OpenRead("flight_2024.bin")) using (var br = new BinaryReader(fs)) { var sw = Stopwatch.StartNew(); while (fs.Position < fs.Length) { uint ts = br.ReadUInt32(); ushort len = br.ReadUInt16(); var raw = br.ReadBytes(len); while (sw.ElapsedMilliseconds < ts) Thread.Sleep(1); // 保持原始节奏 _rxQueue.Add(raw); // 走和实时一样的入口 } }调试时把Thread.Sleep那行去掉就能倍速跑完,验证解析器在高吞吐下会不会漏帧。我一般的习惯是每次外场回来第一件事就是把 bin 拖进回放器跑一遍,看有没有校验失败的帧、有没有消息断档,然后再去改代码。从那以后,凡是抓不到日志的飞行我一律不认,这条规矩救回来的时间比任何调试技巧都多。希望帮到你。
本文还有配套的精品资源,点击获取