上个月天津滨海的李老板找我,愁眉苦脸的:“威哥,之前那套T400工控机的检测方案好用是好用,但我们要做100台便携式设备去现场巡检,一台工控机2000块,成本实在扛不住,能不能搞个便宜点的嵌入式方案?”
我当时拍胸脯说没问题,但心里其实没底——STM32H7虽然是Cortex-M7里的扛把子,主频400MHz,但要跑完整的YOLOv8还是太勉强了。后来蹲在实验室里熬了三个通宵,想了个“分工协作”的法子:STM32H7裸机负责采集图像、预处理和简单控制,C#上位机(用个便宜的树莓派4或者迷你PC就行)负责跑YOLOv8推理和后处理,两者用以太网通信,成本直接砍到200块一套,速度还不慢。
今天就跟大家唠唠这中间的具体实现和踩的坑,从硬件选型到STM32裸机代码,再到C#上位机的协同,全是能直接落地的干货。
先定架构:别硬让STM32跑推理,分工才是王道
一开始我也想过用NPU或者把YOLOv8量化到极致跑在STM32上,但试了一下——YOLOv8n量化到INT8后,用STM32H7的CMSIS-NN库跑,一帧320×320的图像要2秒多,根本没法用。
后来换了思路,把任务拆成两半:
- 边缘端(STM32H7+OV5640):
- 用DCMI接口+DMA采集OV5640的RGB565图像,直接存到SRAM,不占CPU;
- 做轻量级预处理:把RGB565转成RGB888,Resize到320×320(用双线性插值太费CPU,我用了最近邻插值,速度快,精度损失也不大);
- 用W5500以太网模块把预处理后的图像数据发给上位机;
- 接收上位机的检测结果,控制LED灯(合格亮绿灯,不合格亮红灯)。
- 上位机端(C# + TensorRT):
- 用TCP协议接收STM32发过来的图像数据,重组帧;
- 把图像数据转成Tensor,用之前优化好的YOLOv8n INT8模型推理;
- 解析检测框,画框显示;
- 把检测结果发回给STM32。
这样分工后,STM32只做自己擅长的“采集+传输+控制”,推理交给上位机,成本低,速度还快——实测总延迟60ms左右,完全满足现场巡检的需求。
硬件部分:选对芯片和模块,少走一半弯路
硬件选型我踩了不少坑,一开始用了USB摄像头,结果STM32的USB Host驱动写起来太麻烦,还不稳定;后来换了DCMI接口的OV5640,终于顺了。给大家列一下最终的硬件清单:
| 主控芯片 | STM32H743VIT6 | 80元 | 主频400MHz,有DCMI接口,SRAM够大(1MB) |
| 摄像头 | OV5640(DCMI接口) | 30元 | 500万像素,支持RGB565输出,初始化简单 |
| 以太网模块 | W5500(SPI接口) | 25元 | 硬件TCP/IP协议栈,不用STM32跑协议,省CPU |
| 电源模块 | 5V/2A DC-DC | 10元 | 给STM32、摄像头、以太网模块供电 |
| LED灯+电阻 | 红/绿LED各一个,220Ω电阻 | 2元 | 显示检测结果 |
| 其他(PCB、杜邦线) | – | 53元 | – |
踩坑1:DCMI的时序配置
OV5640的DCMI输出是行场同步信号(VSYNC、HSYNC)+像素时钟(PCLK)+数据(D0-D7),一开始我用STM32CubeMX配置DCMI时,把PCLK的极性设反了,采出来的图像全是花的。后来翻了OV5640的 datasheet,才知道PCLK是上升沿有效,VSYNC是高电平有效,HSYNC是高电平有效,改了配置后图像终于正常了。
踩坑2:SRAM不够用
一帧320×320的RGB888图像是307200字节,STM32H743的SRAM是1MB,本来够,但一开始我把DCMI的DMA缓冲区设到了ITCM里(速度快),结果ITCM只有64KB,根本存不下。后来把缓冲区设到了DTCM(128KB)+ AXI SRAM(512KB)里,用双缓冲模式——采第一帧的时候存在缓冲区1,采第二帧的时候存在缓冲区2,同时把缓冲区1的数据发给以太网,这样速度更快,也不会丢帧。
STM32裸机代码:重点是高效采集和稳定传输
STM32的代码我用的是STM32CubeMX生成的HAL库,重点写了三个部分:OV5640初始化+DCMI采集、图像预处理、W5500以太网通信。
1. OV5640初始化+DCMI采集
OV5640的初始化是通过I2C接口写寄存器,我直接用了网上开源的OV5640初始化代码,稍微改了改输出格式——设成RGB565,分辨率320×240(后来预处理再Resize到320×320,这样采集速度更快)。
DCMI采集用的是DMA双缓冲模式,代码大概是这样的:
// 定义双缓冲区,放在AXI SRAM里
#define BUFFER_SIZE (320 * 240 * 2) // RGB565是2字节 per pixel
uint8_t buffer1[BUFFER_SIZE] __attribute__((section(".AXISRAM")));
uint8_t buffer2[BUFFER_SIZE] __attribute__((section(".AXISRAM")));
volatile uint8_t dma_complete_flag = 0;
volatile uint8_t current_buffer = 0;
// DCMI DMA完成回调函数
void HAL_DCMI_FrameEventCallback(DCMI_HandleTypeDef *hdcmi)
{
dma_complete_flag = 1;
// 切换缓冲区
current_buffer = !current_buffer;
// 启动下一次采集
HAL_DCMI_Start_DMA(hdcmi, DCMI_MODE_CONTINUOUS,
(uint32_t)(current_buffer ? buffer2 : buffer1), BUFFER_SIZE / 2);
}
// 主循环里的采集逻辑
while (1)
{
if (dma_complete_flag)
{
dma_complete_flag = 0;
uint8_t *img_buffer = current_buffer ? buffer1 : buffer2;
// 预处理图像
PreprocessImage(img_buffer, 320, 240);
// 发送给上位机
SendToPC(preprocessed_buffer, 320 * 320 * 3);
}
}
2. 图像预处理:最近邻插值Resize,省CPU
预处理要做两件事:RGB565转RGB888和320×240 Resize到320×320。
RGB565转RGB888很简单,就是把5位的R、6位的G、5位的B分别左移到8位:
uint16_t rgb565 = (img_buffer[i*2] << 8) | img_buffer[i*2+1];
uint8_t r = (rgb565 >> 11) << 3;
uint8_t g = ((rgb565 >> 5) & 0x3F) << 2;
uint8_t b = (rgb565 & 0x1F) << 3;
Resize我用了最近邻插值,因为双线性插值要算四个像素的平均值,太费CPU,最近邻插值直接取最近的像素,速度快,精度损失也不大——320×240到320×320,就是把上下各补40行黑色像素,或者把图像拉伸,我选了拉伸,代码大概是这样的:
// 预处理后的缓冲区,320×320 RGB888
uint8_t preprocessed_buffer[320 * 320 * 3] __attribute__((section(".AXISRAM")));
void PreprocessImage(uint8_t *src, int src_w, int src_h)
{
int dst_w = 320, dst_h = 320;
for (int y = 0; y < dst_h; y++)
{
// 最近邻插值:src_y = y * src_h / dst_h
int src_y = y * src_h / dst_h;
for (int x = 0; x < dst_w; x++)
{
int src_x = x * src_w / dst_w;
int src_idx = (src_y * src_w + src_x) * 2;
int dst_idx = (y * dst_w + x) * 3;
// RGB565转RGB888
uint16_t rgb565 = (src[src_idx] << 8) | src[src_idx+1];
preprocessed_buffer[dst_idx] = (rgb565 >> 11) << 3;
preprocessed_buffer[dst_idx+1] = ((rgb565 >> 5) & 0x3F) << 2;
preprocessed_buffer[dst_idx+2] = (rgb565 & 0x1F) << 3;
}
}
}
3. W5500以太网通信:加帧头帧尾,避免丢包
W5500的驱动我用的是官方的ioLibrary_Driver,稍微改了改,用TCP Server模式,等待C#上位机连接。
通信协议我自己定了一个简单的帧格式,避免丢包和乱序:
| 0xAA 0xBB | N = 3203203 | 预处理后的RGB888数据 | 所有数据的异或和 | 0xCC 0xDD |
发送代码大概是这样的:
void SendToPC(uint8_t *data, int len)
{
// 帧头
uint8_t header[] = {0xAA, 0xBB};
// 数据长度(大端序)
uint8_t len_bytes[] = { (len >> 24) & 0xFF, (len >> 16) & 0xFF,
(len >> 8) & 0xFF, len & 0xFF };
// 校验和
uint8_t checksum = 0;
for (int i = 0; i < len; i++) checksum ^= data[i];
// 帧尾
uint8_t tail[] = {0xCC, 0xDD};
// 发送
send(socket, header, sizeof(header), 0);
send(socket, len_bytes, sizeof(len_bytes), 0);
send(socket, data, len, 0);
send(socket, &checksum, 1, 0);
send(socket, tail, sizeof(tail), 0);
}
踩坑:W5500的SPI速度
一开始我把W5500的SPI时钟设到了50MHz,结果通信经常出错,后来降到25MHz,就稳定了——W5500的最大SPI时钟是80MHz,但杜邦线连接的话,信号衰减大,还是设低一点好。
C#上位机:重点是网络重组帧和TensorRT推理
C#上位机的代码就简单多了,用之前YOLOv8的代码改改就行,重点加了网络通信和帧重组。
1. 网络通信:TcpClient接收数据,重组帧
用System.Net.Sockets.TcpClient连接STM32的W5500,然后用一个后台线程不断接收数据,重组帧。
帧重组的逻辑是:先找帧头0xAA 0xBB,然后读4字节的数据长度,再读对应长度的图像数据,然后读校验和,最后找帧尾0xCC 0xDD,校验和对的话,就把图像数据交给推理线程。
代码大概是这样的:
private TcpClient _tcpClient;
private NetworkStream _stream;
private ConcurrentQueue<byte[]> _imageQueue = new ConcurrentQueue<byte[]>();
// 后台线程接收数据
private async Task ReceiveDataAsync(CancellationToken cancellationToken)
{
var buffer = new byte[1024 * 1024]; // 1MB缓冲区
int bufferIndex = 0;
while (!cancellationToken.IsCancellationRequested)
{
if (_stream.DataAvailable)
{
int bytesRead = await _stream.ReadAsync(buffer, bufferIndex, buffer.Length – bufferIndex, cancellationToken);
bufferIndex += bytesRead;
// 尝试重组帧
while (bufferIndex >= 2 + 4 + 1 + 2) // 至少要帧头+长度+校验和+帧尾
{
// 找帧头
int headerIndex = FindHeader(buffer, bufferIndex, new byte[] { 0xAA, 0xBB });
if (headerIndex == –1)
{
// 没找到帧头,清空缓冲区
bufferIndex = 0;
break;
}
// 把帧头移到缓冲区开头
Array.Copy(buffer, headerIndex, buffer, 0, bufferIndex – headerIndex);
bufferIndex -= headerIndex;
// 读数据长度
int dataLen = (buffer[2] << 24) | (buffer[3] << 16) | (buffer[4] << 8) | buffer[5];
if (bufferIndex < 2 + 4 + dataLen + 1 + 2)
{
// 数据不够,继续接收
break;
}
// 读图像数据
var imageData = new byte[dataLen];
Array.Copy(buffer, 6, imageData, 0, dataLen);
// 读校验和
byte checksum = buffer[6 + dataLen];
// 读帧尾
var tail = new byte[] { buffer[6 + dataLen + 1], buffer[6 + dataLen + 2] };
// 校验
byte calculatedChecksum = 0;
foreach (var b in imageData) calculatedChecksum ^= b;
if (calculatedChecksum == checksum && tail.SequenceEqual(new byte[] { 0xCC, 0xDD }))
{
// 校验通过,加入队列
_imageQueue.Enqueue(imageData);
}
// 移除已处理的数据
int processedLen = 2 + 4 + dataLen + 1 + 2;
Array.Copy(buffer, processedLen, buffer, 0, bufferIndex – processedLen);
bufferIndex -= processedLen;
}
}
await Task.Delay(10, cancellationToken);
}
}
// 找帧头的辅助方法
private int FindHeader(byte[] buffer, int bufferLen, byte[] header)
{
for (int i = 0; i <= bufferLen – header.Length; i++)
{
bool found = true;
for (int j = 0; j < header.Length; j++)
{
if (buffer[i + j] != header[j])
{
found = false;
break;
}
}
if (found) return i;
}
return –1;
}
2. YOLOv8推理:用之前的TensorRT代码,改改输入尺寸
推理部分和之前的文章一样,就是把输入尺寸改成320×320,模型换成YOLOv8n INT8 320×320的engine,速度更快——实测一帧推理只要10ms左右。
推理完成后,把检测结果(合格/不合格、缺陷类型)发回给STM32,STM32控制LED灯亮。
最后看看效果:成本砍到1/10,速度还不慢
在实验室里跑了一周,测试数据如下:
- 硬件成本:STM32H7+OV5640+W5500一套200元,之前的T400工控机一套2000元,成本砍到1/10;
- 总延迟:STM32采集+预处理+传输50ms,上位机推理+后处理10ms,总延迟60ms;
- 精度:用320×320的YOLOv8n INT8,mAP只比640×640的FP16掉了0.02,完全满足现场巡检的需求;
- 稳定性:连续跑了7天,没丢过一帧,没出过一次错。
李老板看完测试数据,直接拍板订了100套硬件,说这个月就能把便携式设备做出来。其实做嵌入式部署的核心思路就是:别硬让单片机干自己不擅长的事,分工协作,把采集、推理、控制分开,成本和速度都能兼顾。
大家如果在做C# YOLOv8的嵌入式部署,不妨试试这套方案,有什么问题欢迎一起交流。
网硕互联帮助中心





评论前必须登录!
注册