ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32串口图像传输:自定义协议从封帧到上位机解析实践

STM32串口图像传输:自定义协议从封帧到上位机解析实践 简介面向STM32嵌入式开发与上位机通信学习者的完整工程包解决如何通过自定义串口协议将STM32采集的图像数据实时传输至Windows上位机显示的问题。包内包含Visual Studio 2019与Keil 5双平台工程源码涵盖C# WinForm上位机、STM32下位机C程序以及ILI93xx屏幕驱动等模块开发中需预先指定图像大小否则解析会失败。压缩包共138个文件约2.22MB以h/c源文件、o/d编译中间文件、crf/uvproj/sln工程配置为主另有少量exe可直接运行验证。目前已有824人学习下载。资源附带了VS上位机的鼠标滚轮图像缩放功能并包含keilkill.bat等辅助脚本适合希望完整跑通串口图像传输链路、理解自定义协议封装与解析细节的开发者参考。1. 自定义串口协议为什么比裸传图像更现实用 STM32 采集图像后通过串口发给上位机最直觉的做法是把像素数据一帧一帧直接往串口里丢。实际跑一次就会发现上位机收到的要么是断断续续的碎片要么是把两帧图像黏在一起甚至偶尔还会丢几个字节让整张图花掉。原因很简单串口是字节流协议没有消息边界而图像数据量大、传输时间长任何一端的定时偏差都会把同步彻底打乱。自定义串口通信协议的意义就在这里——通过固定帧头、长度、序号和校验让接收端能准确地把字节流切分成一帧帧图像还能在出错时恢复同步。对于做 STM32 上位机开发、调试图像采集模块的工程师来说这套思路不仅适用串口换到 CAN、以太网或者无线透传模块时协议层的设计模式同样可以直接迁移。2. STM32 端图像采集与协议封帧从 DMA 到 FIFO2.1 图像数据源与串口发送瓶颈STM32 采集图像通常来自 OV7670、OV2640 等摄像头模组DCMI 接口把像素数据以 BT.601/656 时序送入 DMA再由 DMA 搬运到内存缓冲。这里的关键瓶颈在于串口的发送速率远低于采集速率。假设一幅 320x240 的灰度图数据量大约是 76.8 KB如果串口配置为 921600 bps按 8N1 格式算有效速率约 92 KB/s传输一幅图像就需要接近 1 秒。这期间摄像头可能已经连续产生好几帧数据。常见做法是让摄像头工作在单帧触发模式或者干脆由 MCU 主动只采集一帧然后暂停 DCMI专心把数据从缓冲区发出去。我在实际项目里一般把 DCMI 配置为连续模式但只在收到上位机命令后才采集一帧。这样可以避免图像数据在内存里覆盖也方便协议层的帧序号管理。另外串口发送不要用阻塞式的逐字节等待发送完成寄存器最好用 DMA 发送。原因是阻塞发送会占用 CPU 全部时间图像数据还没发完摄像头数据已经又来了。2.2 自定义帧结构设计串口传输图像的帧结构不能做得太复杂否则封帧和解帧都会消耗过多 CPU。我的协议定义如下| 帧头1(0xAA) | 帧头2(0x55) | 帧类型(1B) | 图像宽度(2B) | 图像高度(2B) | 数据长度(4B) | 序号(1B) | 像素数据(N B) | CRC16(2B) |帧头固定为 0xAA 0x55让接收端能快速找到可能的数据起点。帧类型里约定 0x01 表示灰度图像0x02 表示 RGB565。宽度和高度使用小端格式这样上位机在 C# 里直接用 BitConverter 就能读。数据长度指像素数据的字节数因为图像大小可能因为格式不同而变化显式声明长度比让接收方猜更可靠。序号字段用于检测丢帧如果上位机发现序号不连续可以直接丢弃当前帧并请求重发。CRC16 对从帧类型到像素数据的所有字节计算接收端自己算一次两边一致就认为是完整帧。2.3 STM32 端代码实现下面是我在 Keil 5 里用的发送端代码基于 STM32 HAL 库使用串口 DMA 发送。typedef struct { uint8_t head[2]; uint8_t type; uint16_t width; uint16_t height; uint32_t data_len; uint8_t seq; uint8_t data[320 * 240]; // 最大灰度图 uint16_t crc; } __attribute__((packed)) ImageFrame;注意这里的__attribute__((packed))它告诉编译器不要对这个结构体做内存对齐填充确保结构体在内存中的布局和串口线上传输的字节序完全一致。如果去掉这个声明结构体里很可能会被填充两三个空洞字节上位机解析时就会完全错位。发送函数uint16_t calc_crc16(const uint8_t *buf, uint32_t len) { uint16_t crc 0xFFFF; for (uint32_t i 0; i len; i) { crc ^ buf[i]; for (int j 0; j 8; j) { if (crc 0x0001) crc (crc 1) ^ 0xA001; else crc 1; } } return crc; } void send_image_frame(ImageFrame *frame, uint8_t *pixels, uint32_t pixel_bytes) { frame-head[0] 0xAA; frame-head[1] 0x55; frame-type 0x01; // 灰度图 frame-width 320; frame-height 240; frame-data_len pixel_bytes; frame-seq get_next_seq(); memcpy(frame-data, pixels, pixel_bytes); frame-crc calc_crc16((uint8_t*)frame-type, 3 2 2 4 1 pixel_bytes); // 移动 frame 指针到 type 位置发送避免发送 head 之前的冗余字节 HAL_UART_Transmit_DMA(huart1, (uint8_t*)frame-type, 3 2 2 4 1 pixel_bytes 2); }这里我把帧头单独写在结构体里但发送时通过frame-type作为起始地址把帧头、类型、宽高、长度、序号、数据和 CRC 一起连续发出去。HAL_UART_Transmit_DMA是异步的函数返回后 DMA 控制器还在搬运数据因此必须确保这个发送缓冲区在发送完成前不被改写。常见做法是定义两个缓冲区交替使用DMA 发送完成中断里切换缓冲区。参数说明CRC 计算是从 type 字段开始直到 data 最后一个字节不包含帧头。为什么不算帧头因为帧头是固定值接收端做同步时已经依赖它了把帧头放进 CRC 反而会让同步逻辑和校验逻辑耦合。计算时传入的长度是 15 字节的字段区加上像素数据长度其中 15 字节包括 type、width、height、data_len、seq 共 12241 字节。3. C# WinForm 上位机解析SerialPort 与环形缓冲3.1 串口参数与事件驱动接收上位机使用 VS2019 下的 C# WinForm核心控件是 SerialPort 和 PictureBox。串口参数必须与 STM32 端一致否则解析永远是乱的。我这里常用的配置是 921600 波特率、8 个数据位、无校验位、1 个停止位。serialPort1.BaudRate 921600; serialPort1.DataBits 8; serialPort1.Parity Parity.None; serialPort1.StopBits StopBits.One; serialPort1.ReceivedBytesThreshold 1;接收方式使用事件驱动而不是定时轮询。SerialPort 的 DataReceived 事件在后台线程触发每次至少触发一次但实际收到的字节数不确定可能只有一个字节也可能有一整帧。如果在事件里直接解析很容易因为数据不全而失败。正确的做法是建立一个接收缓冲区把 DataReceived 里拿到的字节全部追加到缓冲区尾部然后从缓冲区头部尝试解析出完整帧。缓冲区我用的是Listbyte加读位置的索引简单且高效。private Listbyte _buffer new Listbyte(); private int _bufferPos 0; private void serialPort1_DataReceived(object sender, SerialDataReceivedEventArgs e) { int len serialPort1.BytesToRead; byte[] data new byte[len]; serialPort1.Read(data, 0, len); lock (_buffer) { _buffer.AddRange(data); } TryParseFrames(); }这里加锁的原因有两个DataReceived 事件运行在串口接收线程而界面刷新在 UI 线程两个线程会同时访问缓冲区。Listbyte不是线程安全的不加锁会出现索引越界或数据丢失。3.2 粘包拆包用状态机提取完整帧解析过程本质是一个状态机。我从字节流中不断搜索帧头找到帧头后读取固定长度的头部字段再根据数据长度判断当前缓冲区有没有足够数据。如果不够就等待下一次 DataReceived 继续补数据如果够了就提取整帧并校验 CRC。private void TryParseFrames() { byte[] frame; lock (_buffer) { while (true) { // 查找帧头 0xAA 0x55 if (_bufferPos 1 _buffer.Count) return; if (_buffer[_bufferPos] 0xAA _buffer[_bufferPos 1] 0x55) { // 头部字段type(1) width(2) height(2) datalen(4) seq(1) if (_bufferPos 10 _buffer.Count) return; int datalen BitConverter.ToInt32(_buffer.Skip(_bufferPos 6).Take(4).ToArray(), 0); int frameLen 10 datalen 2; // 头部 数据 CRC if (_bufferPos frameLen _buffer.Count) return; // 数据还未收全 byte[] frameBytes _buffer.GetRange(_bufferPos, frameLen).ToArray(); _buffer.RemoveRange(0, _bufferPos frameLen); _bufferPos 0; // 校验 CRC if (VerifyCrc(frameBytes)) { frame frameBytes; break; } else { // CRC 错误跳过当前帧头继续找下一个 _bufferPos 2; continue; } } else { _bufferPos; } } } if (frame ! null) ProcessFrame(frame); }TryParseFrames是一个循环直到缓冲区里没有完整帧才返回。注意_bufferPos在每轮循环里的推进方式找到帧头时它会根据数据长度判断数据不足时直接 return等下一批数据来再继续。如果 CRC 校验失败说明这一块内容不是真正的帧头或者数据被干扰了这时把_bufferPos前进两个字节继续在下一轮循环中搜下一次 0xAA 0x55。3.3 图像还原与显示解析出一帧后需要把像素字节还原成 Bitmap 显示。灰度图比较简单每像素一个字节直接生成 8 位灰度图像。但 WinForm 的 PictureBox 对 8 位灰度图支持不算好尤其是缩放时会失真所以我通常转成 24 位 RGBprivate void ProcessFrame(byte[] frame) { byte type frame[2]; ushort width BitConverter.ToUInt16(frame, 3); ushort height BitConverter.ToUInt16(frame, 5); byte seq frame[10]; // 数据区从 index 11 开始 int offset 11; Bitmap bmp new Bitmap(width, height, PixelFormat.Format24bppRgb); Rectangle rect new Rectangle(0, 0, width, height); BitmapData bmpData bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format24bppRgb); byte[] rgb new byte[width * height * 3]; for (int i 0; i width * height; i) { rgb[i * 3] frame[offset i]; // B rgb[i * 3 1] frame[offset i]; // G rgb[i * 3 2] frame[offset i]; // R } Marshal.Copy(rgb, 0, bmpData.Scan0, rgb.Length); bmp.UnlockBits(bmpData); // 更新界面 pictureBox1.Image bmp; }这里使用了LockBits直接写入像素数据比SetPixel逐点赋值快几个数量级。因为图像是灰度图所以每个像素的 B、G、R 三分量取相同的值。Marshal.Copy把托管数组复制到非托管内存完成 Bitmap 数据填充。4. 鼠标滚轮缩放与二次刷新图像显示性能优化4.1 PictureBox 缩放为何卡顿直接把图像赋给 PictureBox再用SizeMode Zoom让它自动缩放初次看没问题但用鼠标滚轮连续缩放时PictureBox 会反复重绘每帧都重新拉伸整个 Bitmap。图像分辨率一旦超过 320x240丢帧感会非常明显。更糟的是 Bitmap 默认没有启用双缓冲重绘时会出现闪烁。我的优化思路是滚轮缩放不直接改 PictureBox 的SizeMode而是维护一个缩放比例因子把原始 Bitmap 按比例预先缩放到一个新的 Bitmap再赋值给 PictureBox。这样每次缩放只做一次缩放运算并且只对当前可见区域做一次重绘性能好很多。4.2 预缩放 Bitmap 与双缓冲鼠标滚轮事件的代码大概是这样private float _zoom 1.0f; private Bitmap _originalBmp; // 最新一帧原始图像 private Bitmap _scaledBmp; // 缩放后的显示图像 private void pictureBox1_MouseWheel(object sender, MouseEventArgs e) { if (_originalBmp null) return; if (e.Delta 0) _zoom * 1.1f; else _zoom / 1.1f; _zoom Math.Max(0.1f, Math.Min(10.0f, _zoom)); int newWidth (int)(_originalBmp.Width * _zoom); int newHeight (int)(_originalBmp.Height * _zoom); _scaledBmp?.Dispose(); _scaledBmp new Bitmap(_originalBmp, newWidth, newHeight); pictureBox1.Image _scaledBmp; }这里要注意的是_scaledBmp?.Dispose()每次缩放都生成新 Bitmap旧的要及时释放否则内存会快速上涨。另外 PictureBox 需要设置DoubleBuffered属性为 true但该属性是 protected所以通常继承一个自定义 PictureBox 来暴露这个设置public class DoubleBufferedPictureBox : PictureBox { public DoubleBufferedPictureBox() { DoubleBuffered true; } }双缓冲让所有绘制先发生在内存画布上然后一次性提交到屏幕可以彻底消除图像闪烁。4.3 参数对吞吐的影响下面这张表总结了我常用的三组参数组合供不同场景参考波特率图像分辨率灰度/彩色帧传输耗时适用场景115200160x120灰度约 1.6s调试用USB 转串口线也稳定460800320x240灰度约 0.6s一般图像预览921600320x240灰度约 0.3s实时性要求较高需用质量好的线缆波特率超过 921600 后普通 USB 转串口芯片容易出丢字节问题而丢字节直接导致 CRC 失败、整帧丢弃实际吞吐反而下降。所以我一般不推荐使用 2M 以上波特率除非你用的是工业级 USB 隔离串口线并且做过误码率测试。串口发送的延时也需要考虑。STM32 的HAL_UART_Transmit_DMA是异步发送如果马上调用下一帧的发送函数而上一帧还没发完DMA 会返回 busy。正确做法是使用串口发送完成回调在回调里发起下一帧发送或者用一个简单的标志位等待。5. 图像大小与步长对齐解析失败的一个隐蔽原因很多人在移植这套代码时遇到“上位机显示一片花屏”或“根本不显示”第一反应是协议写错了其实大部分时候是图像大小和步长对齐的问题。项目摘要里特别提到“需要提前指定要发送的图像大小否则会出现解析失败的情况”这句话背后有两个技术点。第一STM32 的 DMA 传输如果配置的宽度是半字或字节而图像缓冲区的行宽不是偶数部分摄像头输出的 RGB565 每像素 2 字节行字节数可能不是 4 的倍数。DMA 在某些 MCU 上对非对齐访问会执行出错或者实际搬运的数据量和头像素数据长度不一致。解决方法是发送前对每行做对齐处理或者直接约定图像宽度必须是 2 的倍数。比如 RGB565 的 320x240 图像每行 640 字节天然对齐到 4 字节就没有问题但如果是 322x240每行 644 字节就会出现步长错位。上位机若按 322 宽度解析数据流长度正确但像素全部右移了一个字节图像看起来就是斜条纹。第二上位机解析时如果不验证图像数据的步长只是按width * height去取像素会因为摄像头输出的行对齐填充字节而算错长度。标准做法是在协议头里增加一个 stride 字段或者约定“一行像素的字节数 width * bytes_per_pixel”并且在 STM32 端把数据拷贝到连续缓冲区时逐行拷贝确保没有任何填充字节。具体来说如果使用 DCMI 直接到内存每行 DMA 的传输长度就是像素时钟计数值不会有填充但如果使用了帧缓冲的地址偏移就需要自己处理。下面是一个针对 RGB565 的校验和重同步技巧能帮你快速定位是协议问题还是数据问题private void QuickCheckFrame(byte[] frame) { int width BitConverter.ToUInt16(frame, 3); int height BitConverter.ToUInt16(frame, 5); int expectedLen width * height * 2; // RGB565 int actualLen BitConverter.ToInt32(frame, 7); if (expectedLen ! actualLen) { // 说明 STM32 发送端和上位机的图像参数不一致 Console.WriteLine($expected{expectedLen}, actual{actualLen}); } // 对第 2 行的第一个像素做一致性校验若 RGB565 的低 5 位都是 0 则大概率是步长错位 int rowBytes width * 2; int pixel0 BitConverter.ToUInt16(frame, 11 rowBytes); if ((pixel0 0x001F) 0) { Console.WriteLine(possible stride mismatch); } }这里BitConverter.ToInt32(frame, 7)对应协议里的 data_len 字段它在帧结构中的偏移是帧头 2 字节 type 1 字节 width 2 字节 height 2 字节 7 字节。如果你发现 expectedLen 和 actualLen 不一致先检查 STM32 端send_image_frame传入的pixel_bytes是不是直接用了width * height * 2还是把 DMA 缓冲的总大小传了进去。后者通常包含行对齐填充就是花屏的根源。另外一个容易忽略的坑是上位机的 SerialPort 接收缓冲区默认只有 4096 字节而一帧图像可能有几十 KB。如果不在 DataReceived 事件里及时读取串口内部缓冲区会满之后到达的数据会被丢弃。解决方法是把serialPort1.ReadBufferSize设为足够大例如 4 MB并且确保解析代码不在 UI 线程执行。你可以在ProcessFrame里使用Invoke或BeginInvoke更新界面同时保留原始图像的副本这样即使 UI 线程忙串口接收线程也不会被阻塞。如果你按照上面的步骤做仍然出现解析失败建议在 STM32 端只发送一帧固定大小、固定内容的测试图案比如全灰度渐变条。上位机如果能正确显示说明协议和解析逻辑没问题如果不能就检查串口连接和参数。这种从最小系统开始排查的方式比反复调试完整图像流程要快得多。本文还有配套的精品资源点击获取
返回列表