STM32H723VGT6实时视频流实战:DCMI采集+lwIP推流MJPEG

发布时间:2026/8/30 12:11:37
STM32H723VGT6实时视频流实战:DCMI采集+lwIP推流MJPEG 1. 项目定位为什么在 STM32H723VGT6 上做实时视频流Live camera streaming using STM32H723VGT6这标题对应的事其实很具体用一片主频 550MHz 的 Cortex-M7 单片机把摄像头画面实时送到电脑浏览器。很多人一听“单片机推视频流”就觉得不靠谱但实际上 STM32H723VGT6 这颗料干这个活是够格的——它有 DCMI 接口、DMA、以太网 MAC跑 lwIP 后推 MJPEG 流非常顺。这个项目适合谁两种人最需要一是想给嵌入式设备加“远程视觉”能力但又不想上 Linux 方案的人二是正在学习 STM32 图像采集和网络协议栈想找一条真实可落地的完整链路的人。项目本身不需要跑系统直接在裸机上用 HAL lwIP 就能完成逻辑链路很清晰。1.1 需求拆解与方案选型先把“实时视频流”拆开看它其实包含三个核心问题图像从哪来、图像怎么存、图像怎么送出去。图像采集必须选带 DCMI数字摄像头接口的 MCU这样可以直连 CMOS 摄像头并行数据总线。图像编码如果是 640x480 RGB565 原始数据一帧就要 614KB网络根本送不动。所以得让摄像头直接输出 JPEG 压缩后的数据MCU 只做搬运和分发。数据传输STM32H723VGT6 内置以太网 MAC配合 PHY 芯片走 RMII再挂 lwIP用 TCP 把 JPEG 流推给浏览器是非常合理的路径。这套方案最聪明的点在于没有在 MCU 里做软件压缩。ARM Cortex-M7 虽然有 550MHz但逐像素做 JPEG 编码仍然会吃掉大量 CPU帧率上不去。OV2640 这类摄像头内部自带 JPEG 编码器输出端直接给压缩好的码流MCU 捡现成的就行。1.2 器件分工与数据通路我的样机选型是STM32H723VGT6 主控 OV2640 摄像头 LAN8720A PHY。整条数据通路可以概括成一句话摄像头并行输出 JPEG 码流 → DCMI 接口按像素时钟接收 → DMA 搬运到 SRAM 双缓冲 → CPU 扫描 JPEG 帧边界 → lwIP 打包成 TCP 数据 → 浏览器显示。这个链条中DCMI 负责“尺寸转换”把 8bit 并行数据变成连续内存DMA 负责“不占 CPU 的搬运”CPU 只负责在帧边界上做少量判断lwIP 负责网络分包。每一层都很简单但组合起来就是一套完整实时视频流。2. DCMI 摄像头采集核心细节DCMI 是 STM32 家族里比较“偏门”但很有用的外设。它本质上是一组并行数据捕获单元外部摄像头给 PCLK 像素时钟、HSYNC 行同步、VSYNC 帧同步DCMI 就按这些信号把 8bit 数据采进 FIFO再由 DMA 送入内存。2.1 摄像头寄存器配置与时钟OV2640 的配置走 SCCB时序上兼容 I2C。这颗传感器最麻烦的地方是它内部寄存器分 bank配置前必须写入0xFF来切换地址空间。比如OV2640_WriteReg(0xFF, 0x01); // 切到 sensor bank OV2640_WriteReg(0x12, 0x80); // 软复位 HAL_Delay(20); OV2640_WriteReg(0xFF, 0x00); // 切回 DSP bank // 然后继续按厂商初始化序列配置输出分辨率、JPEG 质量等新手容易踩的坑是“寄存器地址看起来一样但含义完全不同”。比如同样是0x12在 bank0 和 bank1 里代表的东西不一样。所以不要随便拿网上零散的初始化片段拼最好直接用开发板厂商或 OV2640 datasheet 里的完整初始化序列然后只微调输出尺寸和 JPEG 质量相关寄存器。时钟上我用 MCU 的 TIM 输出 24MHz 给 OV2640 的 XCLK。摄像头内部自己会倍频出 PCLKDCMI 只需要跟着 PCLK 走不需要知道具体频率。如果 XCLK 不给后面的配置全白搭所以上电后第一件事是量 XCLK。2.2 DCMI 接口初始化DCMI 初始化看似参数多其实只要关注同步极性和数据宽度。我用的典型配置DCMI_HandleTypeDef hdcmi; hdcmi.Instance DCMI; hdcmi.Init.SynchroMode DCMI_SYNCHRO_HARDWARE; // 硬件同步 hdcmi.Init.PCKPolarity DCMI_PCKPOLARITY_RISING; // 像素时钟上升沿采样 hdcmi.Init.VSPolarity DCMI_VSPOLARITY_HIGH; // VSYNC 高有效 hdcmi.Init.HSPolarity DCMI_HSPOLARITY_HIGH; // HSYNC 高有效 hdcmi.Init.CaptureRate DCMI_CR_ALL_FRAME; // 每帧都采集 hdcmi.Init.ExtendedDataMode DCMI_EXTEND_DATA_8B; // 8bit 数据宽度 HAL_DCMI_Init(hdcmi);极性不对的典型表现是画面错位、全是横条纹或者一帧都采不到。调试时如果确认摄像头有输出可以先试着把 VSPolarity 和 HSPolarity 反过来。OV2640 的手册里对极性有说明但不同模组可能已经改了硬件电路所以实际以波形为准。2.3 DMA 双缓冲与帧边界管理DCMI 采集是持续不断的如果只有一个缓冲区CPU 还没读完数据DMA 就把新帧写进来了画面必花。所以我设置了 4 块独立缓冲区每次 DMA 写满一块就切到下一块CPU 在处理当前帧时绝不会被覆盖。缓冲区大小按 JPEG 最大数据量留余量。640x480 分辨率下OV2640 默认质量 JPEG 帧大约 30KB~60KB我每块给了 128KB足够。DCMI 启动 DMA 的 HAL 接口很直接HAL_DCMI_Start_DMA(hdcmi, DCMI_MODE_CONTINUOUS, (uint32_t)camera_buf[active_buf], 128 * 1024);但这里有个非常容易忽略的问题DCMI 的 DMA 传输完成中断并不等于“一帧完整 JPEG 数据”。OV2640 输出的 JPEG 流会被填充字节打断而且帧结束时不一定刚好对齐 DMA 长度。所以我不是直接拿整块缓冲作为一帧而是在缓冲里扫描 JPEG 的 SOI 标记0xFFD8和 EOI 标记0xFFD9扫到完整区间后才交给网络层。uint8_t *jpeg_start find_pattern(buf, buf_len, 0xFF, 0xD8); uint8_t *jpeg_end find_pattern(buf, buf_len, 0xFF, 0xD9); if (jpeg_start jpeg_end jpeg_end jpeg_start) { frame_len jpeg_end 1 - jpeg_start; network_send_frame(jpeg_start, frame_len); }扫描 128KB 缓冲区在 550MHz 主频下很轻松也就几百微秒。关键是不要在 DMA 中断里做完整扫描和网络发送否则中断占用时间太长下一帧的 DMA 配置可能被破坏。我习惯的做法是DMA 中断只置标志位主循环检测到标志后再做 JPEG 边界扫描。2.4 缓存一致性处理STM32H7 系列默认开了 D-Cache而 DMA 写内存不会自动通知 Cache。如果不处理CPU 从 Cache 里读到的可能是旧数据表现出来就是画面残缺、颜色错乱、偶发花屏。解决思路有两条。第一条是直接用 MPU 把摄像头缓冲区设成 non-cacheableHAL_MPU_Disable(); MPU_Region_InitTypeDef MPU_InitStruct {0}; MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress (uint32_t)camera_buf; MPU_InitStruct.Size MPU_REGION_SIZE_512KB; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable MPU_REGION_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable MPU_REGION_NOT_CACHEABLE; MPU_InitStruct.Number MPU_REGION_NUMBER0; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);第二条是保持缓冲区 cacheable在每次 DMA 写完一帧后调用SCB_InvalidateDCache_by_Addr清除对应地址的 Cache。两条都能用我推荐第一条因为它让整个缓冲区的行为更可预测不需要每次传输后都想着维护 Cache。3. LWIP 网络推流从裸数据到浏览器摄像头这边把 JPEG 帧准备好之后剩下的事情就是怎么把它送出去。我用的传输层是 lwIP 的 TCP应用层协议直接做 HTTP MJPEG 流浏览器里放一个img标签就能看到画面不需要写客户端。3.1 网络硬件与 lwIP 集成STM32H723VGT6 内部有以太网 MAC外部再接一颗 LAN8720A PHY走 RMII 接口即可。RMII 比 MII 少一半引脚信号线只有 TXD0/TXD1、RXD0/RXD1、TX_EN、CLK对板级布线很友好。lwIP 的移植主要分三块ethernetif.c负责底层收发包low_level_init里配置 MAC 地址和 PHY 地址sys_arch.c提供信号量、互斥锁和时间戳lwipopts.h决定内存池大小和协议功能开关。如果不跑 RTOS最简单的方式是把 lwIP 放在主循环里轮询while (1) { ethernetif_input(gnetif); sys_check_timeouts(); if (frame_ready) { http_stream_send_frame(); } }ethernetif_input每轮调用都能从网卡 DMA 环形描述符里取出收到的数据包交给协议栈处理TCP 的重传和 ACK 会在sys_check_timeouts里被驱动。帧率要求不高时这种轮询模式足够稳定。3.2 MJPEG over HTTP 协议实现MJPEG 流的经典实现是用multipart/x-mixed-replace原理很直白TCP 连接不关闭HTTP 头先宣告“我要连续发多个 JPEG 帧”然后用固定 boundary 分隔每一帧。浏览器解析到这种响应后会不断用最新一张图刷新显示区域。服务端响应头示例static const char http_header[] HTTP/1.1 200 OK\r\n Connection: keep-alive\r\n Cache-Control: no-store, no-cache\r\n Pragma: no-cache\r\n Content-Type: multipart/x-mixed-replace; boundaryframe\r\n\r\n; static const char frame_head[] --frame\r\n Content-Type: image/jpeg\r\n Content-Length: %d\r\n\r\n;建立连接后先发送http_header之后每来一帧 JPEG 就发送tcp_write(pcb, frame_head, snprintf(...), TCP_WRITE_FLAG_MORE); tcp_write(pcb, jpeg_ptr, frame_len, TCP_WRITE_FLAG_MORE);这里有个容易犯的误区Content-Length必须是真实 JPEG 数据长度不能写整个缓冲区大小否则浏览器会一直等后半部分数据画面卡住。我在调试时就是在这里吃了大亏前 20 分钟一直看到第一帧。3.3 丢帧队列与 TCP 发送节奏实时视频流最忌讳“追旧帧”。如果网络一时跟不上新帧就不停排队延迟越来越高画面越来越老。正确做法是只保留最新帧发送不过来就丢。我实现了一个 3 帧环形队列DCMI 侧把解析好的帧推入队尾网络侧从队首取帧发送。队列满时新帧直接把最旧的覆盖掉。这里的关键是保证摄像头采集和生产帧永不阻塞网络发送即使失败也不会拖慢下一帧的采集。typedef struct { uint8_t *buf; uint32_t len; } frame_info_t; // 入队时如果满了直接覆盖最旧帧保证 always latest if (queue_is_full()) { queue_pop(); } queue_push(jpeg_ptr, frame_len);TCP 本身有拥塞控制和 ACK 重传如果客户端断网或接收慢tcp_sndbuf会逐渐变小。此时tcp_write可能返回ERR_MEM或ERR_WOULDBLOCK我的策略很简单如果发送失败直接丢弃这一帧但 TCP 连接不关。这样恢复后画面会从当前帧继续用户最多看到一次跳变不会永久卡死。4. 调优实录与性能表现理论跑通和实际流畅之间还有一段距离这部分我记录的是调试过程中真正影响结果的关键参数和实际测试数据。4.1 主循环与 DMA 中断分工一开始我把帧扫描放在 DMA 中断里结果帧率不稳定。原因是扫描 128KB 缓冲区要几百微秒这段时间里 DMA 下一帧可能已经产生其他事件中断嵌套和调度直接把时序搞乱。后来我改成DMA 中断只做两件事——切换 active buffer并置位frame_flag。主循环看到frame_flag后再扫描、发送。这样中断服务时间压缩到几十微秒大部分 CPU 时间交给主循环。700MHz? 不对H723 是 550MHz但主循环里扫描和tcp_write已经够快了。运行频率确认外部晶振 25MHz经过 PLL 得到 550MHz 系统时钟DCMI 和 ETH 各自分频工作。实际测试中主循环一次完整“扫描帧 发送帧”大约 1ms~2ms远小于一帧的间隔。4.2 内存参数调整lwIP 是一个内存敏感的协议栈默认参数往往是为小内存 MCU 设计的跑视频流必须调大。我最终使用的关键宏如下#define MEM_SIZE (128 * 1024) #define TCP_SND_BUF (32 * 1024) #define TCP_WND (16 * 1024) #define PBUF_POOL_SIZE 32这里尤其要提TCP_SND_BUF。如果它太小一帧 60KB 的 JPEG 需要分好几次才能写完网络吞吐自然上不去。我调到 32KB 后配合tcp_nagle_disable(pcb)关闭 Nagle 算法小包不会等合并帧发送延迟明显降低。tcp_nagle_disable(conn-pcb);Nagle 算法对 MJPEG 这种连续大数据流其实是负优化。视频帧本身就是大块数据不需要合并小包关了它以后 TCP 切包更及时延迟降低 30ms 左右。4.3 实测帧率与延迟数据我以 640x480 OV2640 JPEG 模式为基准实际测试结果如下分辨率帧率平均单帧大小估算带宽表现320x24030fps15KB3.6Mbps非常稳浏览器无卡顿640x48015fps45KB5.4Mbps稳定偶尔轻微延迟800x6008~10fps90KB7.2Mbps可看但延迟偏高这里的帧率瓶颈不在以太网而在摄像头 JPEG 编码速度和 DMA 配置。OV2640 在 800x600 下内部编码时间明显变长MCU 侧几乎没有压力。延迟方面从摄像头采集到浏览器显示大概 100ms~150ms大部分来自 TCP 缓冲和浏览器渲染缓冲对监控类场景完全够用。5. 常见问题与排查技巧这一节全是实际操作中踩过的坑。我把它们整理成速查表每一条背后都是一个真实调试故事。5.1 DCMI 一帧都采不到最常见原因是摄像头初始化没成功。排查顺序是量 XCLK确认 MCU 确实输出了 24MHz。读 OV2640 的设备 ID确认 SCCB 通信正常。用示波器看 PCLK/VSYNC/HSYNC确认摄像头真的在输出。检查 DCMI 引脚复用H723 的 DCMI 引脚和部分复用功能容易冲突。如果 PCLK 有输出但 DCMI 没采到大概率是极性配置反了。把VSPolarity从HIGH改成LOW再试通常能解决。5.2 画面花屏或者 JPEG 解析失败花屏分成两种。第一种是颜色错乱、有条纹这是电池或布线导致的信号质量差或者 DCMI 采样沿选错不是软件问题。第二种是整个画面完全马赛克多半是 JPEG 帧片段不完整我在扫描边界时一开始只找 SOI 没找 EOI导致把半帧数据送出去。另外OV2640 输出 JPEG 时可能包含填充数据字符串搜0xFFD8时要注意不要在 32bit 内存中直接比较否则可能漏掉跨字节边界的标记。我最终用一个简单的逐字节状态机做标记搜索比memcmp批量搜索更可靠。5.3 网络推流卡顿CPU 占用不低一个隐蔽原因是 lwIP 的tcp_write默认要拷贝数据到协议栈内部 pbuf如果一帧 60KB 就多了一次 60KB 的 memcpy主循环时间翻倍。解决办法有两个方向用TCP_WRITE_FLAG_COPY之外的零拷贝方式传 pbuf 指针而不是裸指针在可接受范围内降低分辨率减少单帧拷贝量。我项目里直接用拷贝方式因为 H723 有 550MHz 和充足 RAMmemcpy 60KB 也就几十微秒不影响整体帧率。如果换低主频 MCU就必须考虑零拷贝。5.4 调试小贴士先跑静态图再跑视频流把“摄像头 网络推流”放在一起调试问题会很难定位。我的习惯是先做两个独立验证摄像头把采集到的 JPEG 帧写到串口或者 SD 卡先在 PC 上确认图片完整网络用一个固定图片数组模拟摄像头帧通过 HTTP 推出去确认浏览器能显示。两边都通过后再合并成实况推流。这样一旦出现问题范围小很多。6. 心得与可扩展方向这套项目做下来我对 STM32H723VGT6 的能力边界有了更真实的判断。它做 640x480 级别的 MJPEG 推流是绰绰有余的CPU 占用大概只有 20% 左右剩余资源还能跑一些简单的图像处理或者控制逻辑。但如果你想上 720p 甚至 1080pMCU 方案就比较吃力了除非摄像头 JPEG 输出足够小、帧率要求足够低。如果后续要扩展有几个方向很实际移植 FreeRTOS把摄像头采集、JPEG 解析、网络发送分成独立任务逻辑更清晰增加本地 TF 卡存储实现“推流 录像”双功能加一个简单的 Web 控制页面通过 POST 请求切换分辨率和 JPEG 质量不用重新烧固件如果项目需要多路视频可以考虑摄像头切换或分时采集H723 的 DCMI 只有一个但外部门控电路可以轮询多路信号。最后再分享一个小技巧调试阶段我在 HTTP 服务器里加了一个/raw路径不包 multipart直接把最新一帧 JPEG 以image/jpeg返回。这样用浏览器或 Postman 反复刷新就能验证摄像头和网络链路不用一直盯播放页。这个习惯帮我省了不少时间尤其是我把分辨率从 VGA 调到 800x600 的时候能快速确认 JPEG 是否完整。设备侧做视频流核心不是把 MCU 榨干而是让每一帧在正确的时间出现在正确的位置。