【以太网摄像头采集存储终端】HTTP MJPEG 推流
1. 引言
前面的文章已经分别完成了 OV5640 图像采集、LCD 显示、JPEG 硬件编解码,以及以太网、LwIP 和 HTTP 服务。这一篇把这些功能串起来:让 STM32H757 将摄像头画面编码成 JPEG,通过 HTTP 持续发送,在浏览器中直接观看。
本文基于现有工程展开,不再重复外设初始化和协议栈移植。需要的前置知识包括摄像头与 LCD、JPEG/MDMA、以太网与 LwIP、HTTP 与 OTA,以及双核启动和 FreeRTOS,相关内容可从文末博客导航查阅。
当前实现包含以下功能:
- 双端观看:支持两个观看连接,共用一个 JPEG 编码任务。
- 独立发送:各连接独立发送,较慢的连接会跳过旧帧。
- 业务协作:推流期间,HTTP 主任务仍可处理状态查询和 OTA 请求。
- 帧率显示:网页显示板端发送帧率。
2. 从摄像头到浏览器的整体链路
2.1 从打开页面到持续显示
先看整篇文章的主线:摄像头产生图像,CM7 将完整图像压缩成 JPEG,再通过 HTTP 发给浏览器。
图像处理与发送链路
下图按“采集 → 编码 → 共享 → 发送 → 显示”排列,每个方框只表达一个处理环节。
图像从上向下依次经过采集、颜色转换、JPEG 压缩和网络发送。LCD 与编码任务使用完整的 RGB565 帧,两个观看连接共享同一套 JPEG 编码结果,由各自的发送任务独立传输。慢连接在完成当前帧后获取最新帧,跳过期间产生的中间帧。
浏览器请求与连接移交
在这条图像通路开始工作前,浏览器还需要建立观看连接。请求过程单独看会更清楚:
上图的 /stream 与 /status 使用不同连接,状态查询可以在持续接收图像时进行。连接退出、缓冲引用和 OTA 停流的细节放到后文对应章节,开头先把主链路建立起来。
阅读后文时可以围绕三个问题展开:
- 图像从哪里来?
- 完整 JPEG 如何在任务之间共享?
- 浏览器如何从同一个响应里区分每一帧?
2.2 数据流与任务分工
沿着上面的图像链路,RGB565 先转换成按 JPEG MCU 排列的 YCbCr 数据,再由硬件压缩。MCU 是 JPEG 中的最小编码单元(Minimum Coded Unit),这里指数据排列方式。
- 摄像头任务:负责持续采集和显示。
Mjpeg_Thread():负责取得完整帧、调用编码接口并发布 JPEG。Mjpeg_SendThread():负责各自的 HTTP 长连接。
编码任务和发送任务首次使用时通过 xTaskCreateStatic() 创建,后续用任务通知唤醒,无人观看时编码任务回到等待状态。
主要代码集中在以下文件:
| 文件 | 职责 |
|---|---|
CM7/APP/NET/HTTP/app_http_server.c |
页面、路由、状态查询与连接移交 |
CM7/APP/NET/HTTP/app_http_mjpeg.c |
共享编码、帧缓冲管理与独立发送 |
CM7/APP/JPEG/app_hw_jpeg.c |
颜色转换、JPEG/MDMA 编码和缓存维护 |
CM7/BSP/CAMERA/ov5640_dcmi.c |
完整帧持有与释放 |
2.3 安全取帧与编码衔接
不能把某个采集缓冲地址直接交给编码器:如果 DMA 正在更新这块内存,读到的图像可能混合两帧内容。当前实现通过 ov5640_dcmi_acquire_frame() 取得完整显示帧,同时保护它不被回收为采集缓冲。
下面摘录编码任务的调用链,省略计时和结果发布部分:
/* 成功取到一帧 RGB 数据 */
if (ov5640_dcmi_acquire_frame(&rgb) == 0)
{
/* 若正在停止(s_stopping)则跳过编码,直接置 -1;
否则用硬件 JPEG 编码器把 rgb 编码到 index 对应的 MJPEG 缓冲区,
输出尺寸写入 size(成功返回 0) */
int result = s_stopping ? -1 :
jpeg_hw_encode(rgb, mjpeg_buffer((uint32_t)index),
MJPEG_CAPACITY, &size);
/* 编码完立即归还帧缓冲,与 acquire 配对 */
ov5640_dcmi_release_frame();
/* 后续根据 result 决定是否发布 JPEG:result==0 才发布。 */
}
这是直接持有完整帧的方式,没有额外复制一份 RGB 图像。取帧函数会分块失效对应 D-Cache,使 CPU 读取 DMA 已写入的内存;持有期间允许 LCD 切换到其他完整帧。
无 LCD 时,当前实现仍保留 LTDC 时序,供摄像头三缓冲通过消隐期重载完成帧切换,但会关闭图层的帧缓冲读取,避免无用的 SDRAM 访问影响编码性能。相关故障与修复见 6.5 节。
释放原则:编码调用返回后,无论成功与否都释放帧,避免长期占用采集资源。
jpeg_hw_encode() 当前使用固定配置:
info.ImageWidth = 800U; // 图像宽度 800 像素
info.ImageHeight = 480U; // 图像高度 480 像素(800x480 分辨率)
info.ColorSpace = JPEG_YCBCR_COLORSPACE; // 色彩空间:YCbCr(JPEG 常用)
info.ChromaSubsampling = JPEG_420_SUBSAMPLING; // 色度下采样 4:2:0,压缩率高、体积小
info.ImageQuality = 80U; // 编码质量 80(0~100,越高越清晰、文件越大)
RGB565 到 YCbCr420 MCU 数据的转换由 CPU 完成,JPEG 压缩由硬件执行。转换按 MCU 行分批进行,累计运行约 4 ms 后主动让出 CPU,兼顾相机和网络任务的运行机会。
相关内存区域如下:
| 数据或缓冲 | 大小 / 地址 |
|---|---|
| 一帧 RGB565 | 800 × 480 × 2 = 768000 字节 |
| YCbCr420 工作区 | 从 0xD0600000 开始 |
| 三块 JPEG 输出缓冲 | 0xD0700000..0xD087FFFF,每块 512 KiB |
采集/显示缓冲和 JPEG 输出缓冲是两套不同用途的缓冲,不能混用。
CPU 生成的 MCU 数据在 MDMA 读取前需要清理缓存,JPEG 输出在 CPU 读取前也要完成相应缓存维护。编码接口还会检查 JPEG 起止标记,去掉 EOI 后的填充字节,返回实际长度,供后续填写单帧 Content-Length。
3. 浏览器为什么能连续显示 JPEG
3.1 MJPEG 与 HTTP 长连接
MJPEG 可以理解为连续传输的一组独立 JPEG 图像。每帧都能单独解码,不需要依赖前后帧,因此很适合在已经具备 JPEG 编码能力的项目上实现实时预览。
浏览器请求 /stream 后,服务器保持响应打开,不断在响应体中追加新的 JPEG。这里不需要网页反复请求单张图片,也不需要编写 JavaScript JPEG 解码器。
代价是每一帧都要完整传输,没有利用相邻画面的重复信息。画面复杂度、编码质量、JPEG 大小和观看端数量,都会影响带宽与发送帧率。两个观看端共享编码结果可以减少重复计算,但网络数据仍然需要分别发送。
3.2 multipart 响应格式是什么
multipart 是一种把一个消息体组织成多个部分的格式。 普通图片响应的响应体只有一张 JPEG;multipart 响应的响应体则包含多个 part,每个 part 都可以带有自己的头部和数据,并通过约定的边界标记分隔。
在本项目中,一个 part 就是一帧 JPEG。浏览器只发起一次 /stream 请求,服务器返回一次 HTTP 响应,随后在这一个响应体里持续发送多个 part。每帧的 Content-Type、Content-Length 属于 part 的头部,不是重新发送一套 HTTP 状态行和响应头。
multipart 是总类别,具体用途由子类型决定。这里使用 multipart/x-mixed-replace,表达持续提供新内容、替换先前显示内容的语义。支持这种格式的浏览器把每个新 JPEG 显示在同一个图像位置,就形成了连续画面。它与上传表单常见的 multipart/form-data 使用场景不同,也不是 HTTP 的 chunked 传输编码。
可以把响应分成两层理解:
一个 HTTP 响应
├─ 外层响应头:声明 multipart 类型及 boundary
└─ 响应体:持续追加多个 part
├─ part 1:分隔行 + 单帧头 + 空行 + JPEG 1 + CRLF
├─ part 2:分隔行 + 单帧头 + 空行 + JPEG 2 + CRLF
└─ ……
下面是实际使用的报文结构示意,<JPEG 字节数> 和二进制数据需要替换为实际内容。头部和分隔行使用 \r\n 结束,空行也不能省略;JPEG 部分是原始二进制,不需要转成 Base64。
HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=frame
Cache-Control: no-store, no-cache, must-revalidate
Connection: close
--frame
Content-Type: image/jpeg
Content-Length: <第一帧 JPEG 字节数>
<第一帧 JPEG 二进制数据,随后是 CRLF>
--frame
Content-Type: image/jpeg
Content-Length: <第二帧 JPEG 字节数>
<第二帧 JPEG 二进制数据,随后是 CRLF>
……
各字段的作用如下:
| 字段或标记 | 含义 |
|---|---|
外层 Content-Type |
告诉浏览器按 multipart 流解析整个响应体 |
boundary=frame |
约定边界字符串为 frame,供浏览器识别各个 part |
--frame |
实际边界行,由 -- 前缀和约定的边界字符串组成 |
单帧 Content-Type: image/jpeg |
声明这一 part 的数据是一张 JPEG |
单帧 Content-Length |
当前 JPEG 的有效字节数,不包含帧头和帧尾 CRLF |
| 头部后的空行 | 表示头部结束,后面开始传输 JPEG 二进制数据 |
报文结束方式还需要注意三点:
- 总长度:整个流的长度事先未知,因此外层响应没有设置总
Content-Length。 - 连接关闭:
Connection: close表示响应结束后关闭连接,不代表发送完第一张图就立即关闭。 - 结束边界:规范中的 multipart 结束边界会在边界字符串后再加
--,即--frame--;当前实现持续发送,退出时直接关闭连接,没有额外发送这个结束标记。
单片机发送任务中的单帧头部如下,发送顺序是“帧头 → JPEG 数据 → \r\n”:
int length = snprintf(header, sizeof(header),
"--frame\r\nContent-Type: image/jpeg\r\n"
"Content-Length: %lu\r\n\r\n", (unsigned long)size);
/* \r\n\r\n:最后这一组连续的换行符,暗示 HTTP 头部到此结束,紧挨着后面的就是纯二进制数据 */
网页上只需把流地址交给图像标签,支持这种响应格式的浏览器就会持续更新画面:
<img src="/stream" alt="相机画面" style="max-width:100%;height:auto">
4. HTTP 推流的关键实现
4.1 路由与连接所有权
HTTP 服务提供三个与预览直接相关的入口:
| 路由 | 返回内容 |
|---|---|
/ |
包含图像标签和帧率轮询逻辑的页面 |
/stream |
持续输出 JPEG 的 multipart 响应 |
/status |
网络、运行槽位及发送帧率等 JSON 状态 |
普通请求处理完毕后,HTTP 主任务关闭并删除连接;推流需要持续发送,因此必须移交连接:
HTTP 主任务接收并解析请求
├─ / 或 /status → 返回响应 → 主任务关闭、删除连接
└─ /stream
├─ 准入失败 → 返回 503 → 主任务关闭、删除连接
└─ 准入成功 → 移交发送任务 → 主任务继续 accept
↓
发送结束后关闭、删除连接
路由处理通过 s_client_transferred 标记移交结果:
if (app_http_mjpeg_start(conn) == 0)
s_client_transferred = 1U;
else
http_reply_text(conn, 503, "Service Unavailable", "text/plain; charset=utf-8",
"stream busy or OTA active\r\n");
主任务发现连接已移交便跳过清理,避免浏览器刚收到流就被关闭,也避免重复释放。每个发送任务独占自己的 netconn,推流循环不会长期占住 HTTP 主任务。不过这不等于任意请求都完全并行,主任务仍按自身流程处理其他请求。
4.2 一次编码,两个观看端共享
如果为每个浏览器单独取帧编码,会重复消耗颜色转换和 JPEG 硬件资源。当前方案只编码一次,再让两个发送任务读取共享结果。
每块 JPEG 缓冲保存三个元数据:
typedef struct
{
uint32_t size;
uint32_t sequence;
uint8_t readers;
} mjpeg_frame_t;
| 状态字段 | 作用 |
|---|---|
size |
当前 JPEG 的有效字节数 |
sequence |
用于识别新帧 |
readers |
正在发送这块缓冲的连接数 |
s_latest |
指向最新发布缓冲的全局索引 |
选择可写缓冲
编码任务只选择“不是最新帧,并且没有发送者持有”的缓冲:
for (uint32_t i = 0U; i < MJPEG_BUFFERS; i++)
{
if ((int)i != s_latest && !s_frames[i].readers)
{
index = (int)i;
break;
}
}
发布编码结果
编码成功后,在临界区内一起更新序号、长度和最新索引。这样发送任务看到的新索引,一定对应已经完成编码的 JPEG:
taskENTER_CRITICAL();
if (++s_sequence == 0U) ++s_sequence;
s_frames[index].sequence = s_sequence;
s_frames[index].size = size;
s_latest = index;
taskEXIT_CRITICAL();
发送任务取得引用
发送任务也在临界区内取得最新帧并增加引用:
taskENTER_CRITICAL();
if (s_latest >= 0 && s_frames[s_latest].sequence != sequence)
{
index = s_latest;
s_frames[index].readers++;
sequence = s_frames[index].sequence;
size = s_frames[index].size;
}
taskEXIT_CRITICAL();
缓冲复用示例
以下是可能出现的一种状态,序号仅为机制示例:
缓冲 A:第 10 帧,readers=1,慢端仍在发送 → 不可覆盖
缓冲 B:第 11 帧,readers=0,latest 指向它 → 保留最新帧
缓冲 C:旧内容, readers=0 → 可编码第 12 帧
第 12 帧完成:latest 从 B 切换到 C
慢端发完 A:readers 减为 0,下次直接获取当时的最新帧
三缓冲的边界:三块缓冲为读写交错提供了空间,但不保证始终有空闲块。如果两个连接分别持有不同的旧帧,而第三块是最新帧,就暂时没有可写缓冲,编码任务会让出 CPU,下一轮重新检查。这里优先保证不覆盖正在发送的数据。
4.3 慢连接跳帧与发送退出
每个发送任务只记录自己已经发送的序号,没有维护逐帧发送队列。发送完当前帧后重新查看 s_latest,因此慢端会跳过期间产生的中间 JPEG,避免待发旧帧持续堆积。跳过发生在完整帧之间,不会把已经开始发送的 JPEG 中途替换。
JPEG 数据按最多 512 字节一段提交给发送函数。这个长度是应用层提交大小,并不等同于实际 TCP 包大小。底层使用 netconn_write_partly(),关键是根据实际写入量推进指针:
size_t written = 0U;
err_t error = netconn_write_partly(conn, bytes, length,
NETCONN_COPY, &written);
if (written > length) return -1;
bytes += written;
length -= written;
if (written) progress = HAL_GetTick();
这里的“写入成功”表示数据已交给协议栈处理,不等于浏览器已经显示。暂时不可写或超时可以重试,但若连续 3 秒没有写入进展,就结束发送;等待新帧也有 3 秒超时。
发送成功或失败后都要减少缓冲的 readers。连接退出时再执行 netconn_close()、netconn_delete(),清除客户端活动状态;静态发送任务保留,等待下次通知。断开后名额可能要等设备检测到发送失败才释放,立即重连时可能短暂收到 503。
最多两个观看连接;满员、OTA 忙或停流期间会拒绝新流。首次从无人观看恢复时还会清除最新帧索引,避免新会话先发出上次残留的图像。
5. 页面帧率与 OTA 配合
5.1 页面预览与帧率显示
网页中图像与状态查询分别使用不同连接。下面把固件中的 HTML 字符串整理为页面代码,保留核心逻辑:
<p>推流帧率:<span id="fps">--</span> FPS(板端发送)</p>
<img src="/stream" alt="相机画面"
style="max-width:100%;height:auto">
<script>
async function updateFps() {
const el = document.getElementById('fps');
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 3000);
try {
const r = await fetch('/status', {
cache: 'no-store', signal: ctrl.signal
});
if (!r.ok) throw new Error();
const s = await r.json();
el.textContent = (s.mjpeg_fps_x10 / 10).toFixed(1);
} catch (e) {
el.textContent = '--';
} finally {
clearTimeout(timer);
setTimeout(updateFps, 1000);
}
}
updateFps();
</script>
每次查询结束后等待 1 秒再发下一次,查询超过 3 秒则取消,失败时显示 --。
发送任务在完整 JPEG 及帧尾提交成功后计数,统计窗口至少为 1 秒,计算 fps_x10 = 帧数 × 10000 / 时间毫秒数。/status 从活动连接中选出最近 3 秒仍有发送、且统计帧率最高的一个。
帧率口径:页面显示的是最快活动连接的板端发送帧率,不累加两个观看端,也不分别代表每个观看端的速度,更不等同于浏览器解码、渲染后的 FPS。
初次连接尚未形成统计窗口时,数值可能暂时为 0。
5.2 升级前停流与升级后访问
OTA 擦写前,需要让相关业务退出资源使用。工程先取得存储 OTA 占用,再执行停流和摄像头暂停,之后才允许后续擦写。这里摘录与预览直接相关的顺序:
if (app_http_mjpeg_stop(5000U) != 0)
{
app_http_mjpeg_resume();
return -1;
}
if (app_ov5640_pause(3000U) != 0)
{
app_http_mjpeg_resume();
return -1;
}
app_http_mjpeg_stop() 先设置停止标志,再等待所有观看连接退出且编码不再进行。编码任务在临界区内也检查停止状态,避免停流已返回后,被抢占过的任务又重新进入编码硬件。
失败或取消后的恢复路径会恢复摄像头并解除停流限制。解除限制只允许建立新流,不会复活已经关闭的 TCP 连接;需要复位的升级流程还会中断设备网络服务。当前页面没有自动重连图像的逻辑,连接中断或升级完成后需要刷新页面。
6. 故障处理与恢复
推流涉及采集、编码和网络发送,画面停止更新并不一定是 HTTP 出了问题。本工程在不同层分别处理故障:
- 采集异常:尝试重启采集链路。
- 单次编码失败:丢弃失败结果。
- 发送异常:回收对应连接。
恢复范围:板端业务恢复与浏览器重新建立连接,是两件不同的事。
6.1 摄像头采集异常:停止后重新启动
ov5640_dcmi_stream_poll() 的主要检查项如下:
| 检查项 | 触发条件或说明 |
|---|---|
| 采集状态 | 采集错误标志置位,或存在 DCMI 错误 |
| DMA 状态 | 存在未被屏蔽的 DMA 错误;检查屏蔽了 HAL_DMA_ERROR_FE |
| 帧更新 | 距离最近一次帧更新超过 1000 ms |
| LTDC 地址重载 | 等待消隐期地址重载超过 100 ms,返回超时 |
因此,不能简单理解为任意 DMA 标志都会触发重启。
摄像头任务发现轮询失败后,先打印现场信息,再停止采集,将 streaming 清零,等待 250 ms 后重新进入启动流程。下面是该分支的关键代码:
ret = ov5640_dcmi_stream_poll();
if (ret != OV5640_DCMI_EOK)
{
ov5640_dcmi_stream_log_error();
LOG_ERROR("CAMERA", "CAMERA_STREAM_FAIL=%u DCMI=0x%lX DMA=0x%lX",
ret, (unsigned long)hdcmi.ErrorCode,
(unsigned long)hdcmi.DMA_Handle->ErrorCode);
ov5640_dcmi_stream_stop();
streaming = 0U;
retries++;
LOG_WARN("CAMERA", "CAMERA_RECOVER retry=%lu", (unsigned long)retries);
osDelay(250U);
continue;
}
停止函数会先拒绝新的取帧,再等待已持有的帧释放,之后调用 HAL_DCMI_Stop()。因此不能为了恢复速度,直接停止仍被编码任务使用的采集链路。
下一轮调用 ov5640_dcmi_stream_start(),通过日志区分恢复进度:
| 日志 | 含义与后续行为 |
|---|---|
CAMERA_RESTART_FAIL |
启动失败,停止后等待 250 ms 再试 |
CAMERA_RESTART_STARTED |
启动调用成功 |
CAMERA_STREAM_FRAME_PASS |
首次完成显示换帧,已有完整帧进入显示流程 |
启动调用成功不等于已经恢复出图,需要继续观察完整帧日志和实际画面。
这里重启的是采集流,不是重新执行整套 OV5640 初始化。运行中的采集故障会进入重试,但前面的相机参数配置如果直接失败返回,摄像头任务会打印 CAMERA_THREAD_FAIL 并退出,不能把这两类情况都描述成自动恢复。
6.2 取帧或编码失败:不发布无效 JPEG
取帧失败时,MJPEG 编码任务不会使用一个未经保护的地址凑合编码,而是让出 CPU,下一轮再尝试。没有可写 JPEG 缓冲时,也采用同样的重试方式,不覆盖正在发送的数据。
编码接口在硬件阶段最多等待约 2000 ms;这个时间只约束启动硬件编码后的等待,并不是整个颜色转换和编码调用的总超时。硬件错误、输出缓冲异常或 JPEG 起止标记检查失败,会进入失败清理路径,调用 HAL_JPEG_Abort(),释放内部占用并返回失败。
上层仍会释放持有的 RGB565 帧,但不会更新 s_latest,因此半成品 JPEG 不会被发送任务当作新帧读取。未处于停流状态时,会记录 MJPEG_ENCODE_FAIL;只要还有观看连接,编码任务后续仍会尝试编码。
如果迟迟没有新的 JPEG,发送任务在等待新帧超过约 3 秒后关闭连接。所以短暂故障可能在原连接上继续出图,较长故障即使板端后来恢复,也需要浏览器重新请求 /stream。
6.3 网络中断与连接回收
发送函数允许 ERR_WOULDBLOCK、ERR_TIMEOUT 等暂时性结果重试,但连续约 3 秒没有写入进展,或遇到其他发送错误,就退出当前连接。退出前释放已持有 JPEG 的引用,随后关闭、删除 netconn,清除客户端活动标志,打印 MJPEG_STREAM_STOP。
这条路径回收的是当前观看端的资源,不会主动停止另一个观看端。网络恢复后,需要新的 /stream 请求重新接入;原有 TCP 连接不会由固件自动重建。当前页面只会自动重试 /status 查询,图像流断开后仍需刷新页面。
另外,503 是准入失败,不一定代表摄像头或网络故障。以下情况都会使启动接口返回失败:
- 两路观看名额已占满。
- OTA 正忙,或停流限制未解除。
- 首次创建相关任务失败。
应先检查其他标签页和设备是否仍在观看,关闭多余连接后等待设备回收,再刷新;升级期间则等待业务恢复。
6.4 按现象定位与确认恢复
| 现象 | 优先查看 | 恢复判断与操作 |
|---|---|---|
| LCD 和网页画面都不再更新 | CAMERA_STREAM_FAIL、CAMERA_RECOVER、CAMERA_RESTART_FAIL |
先判断采集是否重启,并观察 CAMERA_STREAM_FRAME_PASS;若网页连接已超时,恢复后刷新 |
| LCD 正常,网页停止更新 | MJPEG_ENCODE_FAIL、MJPEG_STREAM_STOP,以及 /status 是否可访问 |
区分编码失败与发送退出;确认持续产生 JPEG 后重新建立流连接 |
| 拔掉 LCD 并重新上电后,网页仍能出图但帧率明显下降 | CAMERA_LTDC、ENCODE_PERF convert,以及无屏图层关闭路径 |
检查 LTDC 是否仍在读取 SDRAM;设置帧地址后也要保持无屏图层关闭,详见 6.5 节 |
新页面无法出图,流请求返回 503 |
活动观看端数量、OTA/停流状态、任务创建路径 | 释放观看名额或等待升级结束,再刷新;持续失败时继续排查任务创建与资源状态 |
页面 FPS 显示 -- |
/status 请求是否失败或超时 |
状态查询会自动重试;单凭 -- 不能判断图像流是否已断开 |
| 画面卡住,FPS 随后变为 0 | 是否还在发送、是否有新 JPEG、连接是否已被回收 | 无近期活动发送时统计值会归零,需要结合日志判断,不能把 0 直接等同于摄像头故障 |
| OTA 结束或取消后页面仍停在旧画面 | 摄像头是否恢复、停流限制是否解除 | 板端恢复后刷新页面;已关闭的流连接不会自动恢复 |
恢复验证要看完整链路:
- 采集重新启动后,是否产生完整帧。
- 新的观看连接是否出现
MJPEG_FIRST_FRAME。 - 画面是否继续变化,另一个观看端是否仍正常。
6.5 无 LCD 启动时的帧率下降
拔掉 LCD 排线后重新上电,LTDC 图层仍可能读取 SDRAM,与摄像头搬运和 JPEG 颜色转换争用带宽,导致网页能出图但帧率明显下降。
修复时,在 lcd_init() 未检测到面板的分支关闭图层;换帧调用 HAL_LTDC_SetAddress_NoReload() 后也要再次关闭,因为当前 HAL 会重新使能图层。保留 LTDC 时序,让现有消隐期换帧机制继续工作。
实机修复后,颜色转换日志样本由 114–127 ms 降至 47 ms,现场确认推流明显恢复、画面正常。这是短时验证结果,未进行长期稳定性和接屏显示回归。
7. 浏览器访问与效果验证
7.1 访问方法与效果展示
板卡与电脑处于可互通的网络中,确认设备实际 IP 后,可使用以下地址:
| 访问目的 | 地址 |
|---|---|
| 打开预览页面 | http://<设备IP>/ |
| 直接查看图像流 | http://<设备IP>/stream |
| 查看状态 JSON | http://<设备IP>/status |
http://<设备IP>/

http://<设备IP>/stream

http://<设备IP>/status

两个页面各自建立 /stream 连接即可验证双端观看,测试第三个连接前应确认已有两个流仍在工作。调试时注意其他浏览器标签页也可能占用观看名额。

7.2 验证项目与性能观察
可以结合网页和串口日志:
| 观察项 | 来源与含义 |
|---|---|
| 板端发送 FPS | 页面或 /status,按前述最快活动连接口径统计 |
MJPEG_PERF acquire |
取帧调用耗时,包含相应缓存处理 |
MJPEG_PERF encode |
编码调用及释放帧等后续操作的近似耗时,不含网络发送 |
ENCODE_PERF convert |
RGB565 到 YCbCr MCU 数据的转换耗时 |
ENCODE_PERF dma |
从启动硬件编码到结果检查阶段的耗时,包含等待和后处理 |
ENCODE_PERF bytes |
当前 JPEG 实际字节数,随画面内容变化 |
串口打印日志:
[I][MJPEG] MJPEG_STREAM_START
[I][MJPEG] MJPEG_FIRST_FRAME bytes=69349
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69821
[I][MJPEG] MJPEG_PERF acquire=0 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=40 dma=6 ms bytes=70162
[I][MJPEG] MJPEG_PERF acquire=1 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69828
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69350
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=70236
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69522
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=7 ms bytes=69099
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69772
[I][MJPEG] MJPEG_PERF acquire=0 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69924
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=7 ms bytes=70167
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][MJPEG] MJPEG_STREAM_STOP frames=315
这些性能日志每隔一定帧数输出一次,并不是连续采样平均值。比较单双端结果时,应尽量保持画面、质量参数和网络环境一致;所需图像负载可粗略按“平均 JPEG 字节数 × 每秒帧数 × 观看端数量”估算,实际链路还包含协议开销。
博客导航
本文来自博客园,作者:膝盖中箭卫兵,转载请注明原文链接:https://www.cnblogs.com/hai-feng-dev/p/23441895

浙公网安备 33010602011771号
https://orcid.org/0000-0001-5102-772X