搞定 ESP32-S3 彩色 LCD 串流了,TCP 有瓶颈,还是 UDP 更适合,无线小副屏走起

前言

之前做了一个 ESP32-S3 + 12864 OLED 的桌面串流小电视(见桌面小电视新思路,ESP32-S3 + 12864 OLED 串流视频),思路是电脑端处理好数据,直接推帧到 ESP32-S3,设备只负责显示。后来又给单色 OLED 加上了抖动算法模拟灰度(见让串流小电视更实用,通过抖动实现 4 阶灰度图像显示),靠 Floyd-Steinberg 误差扩散把单色屏的显示效果拉了一截。

但单色屏终究是单色屏,一直有个念头:要是换成彩色 LCD,推流彩色画面,能跑到多少 FPS?

恰好前段时间刚系统测了几款 JPEG 解码库在 ESP32-S3 上的性能(见ESP32-S3 JPEG 实测:从解码库对比到 DMA 优化,最终到达 SPI 上限),最终用 ESP_NEW_JPEG + Block DMA 方案在 SPI 80MHz 下跑到了 77 FPS(见ESP32-S3 JPEG解码测试续篇:SPI换上80MHz,FPS飙到77)。渲染这头基本到头了,是时候把解码能力和串流方案合起来,真正做一个彩色 LCD 的桌面串流屏了。

最终折腾效果还可以,有点小坑也解决了:

  • 渲染侧性能基本符合预期,传输侧才是瓶颈

  • 使用 WebSocket 在 15 FPS 就开始慢慢掉帧,TCP 实测带宽只有约 5Mbps;换成自定义 UDP 协议之后,带宽跑到 20Mbps

  • 最终整体稳定在 18 FPS 左右,网页放个视频没什么问题


演示视频


方案设计

esp32s3-lcd-streaming-2

硬件配置

  • ESP32-S3 N16R8 开发板,双核 240MHz,开启 PSRAM

  • 2.0 寸彩色 LCD(ST7789,320×240,SPI 接口)

  • SPI 使用原生引脚,时钟频率 80MHz

整体串流思路

跟 OLED 那版思路一样,把计算压力放到电脑端:

电脑端(macOS):

  1. 调用 ScreenCaptureKit 框架截取桌面画面(macOS 12.3 引入的原生截屏 API,性能比旧方案好不少)

  2. 缩放到 320×240

  3. 编码为 JPEG,质量 60%,压缩后单帧大小根据画面内容在 8~15KB 之间浮动

  4. 通过网络推帧到 ESP32-S3

ESP32-S3 端:

  1. 接收数据帧

  2. 调用 ESP_NEW_JPEG 解码 JPEG

  3. Block + DMA 方式边解码边上屏

跟 OLED 版最大的区别就是,每一帧的数据量大了很多——320×240 的 RGB565 原始数据是 153,600 字节,JPEG 压缩后虽然会小不少,但传输带宽的需求比单色 OLED 高了两个数量级。


传输瓶颈:WebSocket 先撑不住了

esp32s3-lcd-streaming-3

最开始直接复用 OLED 版的 WebSocket 方案,毕竟写起来方便,不用额外设计协议,一条消息就是一帧数据。

结果发现,15 FPS 就开始陆续掉帧,帧率越往上越不稳定。直觉上是带宽不够,但具体瓶颈在哪还不清楚,于是先写了个测速固件,实测一下 ESP32-S3 的 TCP 吞吐量。

TCP 实测带宽

测速结果如下:

[REPORT] Total: 3681904 bytes | Last 5.0s: 3259720 bytes | Speed: 636.66 KB/s (5.22 Mbps)[REPORT] Total: 7045016 bytes | Last 5.0s: 3363112 bytes | Speed: 656.86 KB/s (5.38 Mbps)[REPORT] Total: 10419616 bytes | Last 5.0s: 3374600 bytes | Speed: 659.10 KB/s (5.40 Mbps)[REPORT] Total: 13656360 bytes | Last 5.0s: 3236744 bytes | Speed: 632.18 KB/s (5.18 Mbps)

TCP 吞吐量稳定在 5.2~5.4 Mbps 左右,很难再往上走了。

算一下理论需求:JPEG 质量 60% 下,单帧大小约 8~15KB,20 FPS 需要约 1.3~2.4 Mbps,理论上 TCP 的 5Mbps 是够的。但实际跑起来并不是这么简单:

  • TCP 拥塞控制:TCP 会根据网络状况动态调整发送窗口,遇到轻微抖动就会主动降速

  • 背压问题:串流场景下帧率是固定节奏的,一旦某一帧因为网络抖动没能及时传输完,下一帧已经在推了,前帧的数据还堵在缓冲区里,越积越多,帧率就开始掉,而且很难自己恢复

  • WebSocket 额外开销:帧头、掩码、心跳包,在高帧率下也是不小的负担

所以掉帧的根本原因不是带宽不够,而是 TCP 的可靠传输机制在这个实时性优先的场景里反而成了累赘。

TCP 小结

ESP32-S3 的 TCP 吞吐天花板约 5Mbps,WebSocket 在这个带宽下支撑不了 20 FPS 的彩色 LCD 串流。


换 UDP:带宽直接飙到 20Mbps

既然 TCP 到顶了,试试 UDP。UDP 没有拥塞控制、没有重传,在局域网这种低丢包的环境里,理论上可以跑满无线带宽。

换成 UDP 之后同样测速:

[UDP ] Total: 16499000 B  | Last 5.0s: 12516000 B | Speed: 2444.53 KB/s (20.03 Mbps) | Loss: 98.3%[UDP ] Total: 28163800 B  | Last 5.0s: 11664800 B | Speed: 2278.28 KB/s (18.66 Mbps) | Loss: 98.3%[UDP ] Total: 40479600 B  | Last 5.0s: 12315800 B | Speed: 2405.43 KB/s (19.71 Mbps) | Loss: 98.3%[UDP ] Total: 52760400 B  | Last 5.0s: 12280800 B | Speed: 2398.59 KB/s (19.65 Mbps) | Loss: 98.3%

UDP 吞吐稳定在 ~20Mbps,是 TCP 的 4 倍。

注意这里的 Loss 显示 98.3%,是测速工具的发包速率远超接收侧处理速度导致的,不代表正常使用下也会丢这么多包——实际串流时,发包频率跟帧率绑定,每帧发完等下一帧,并不会出现这么夸张的丢包。

自定义 UDP 协议

esp32s3-lcd-streaming-4

UDP 本身没有消息边界,直接发大包也踩到了坑:虽然 UDP 理论上支持发送大于 MTU 的包,底层会自动分片重组,但实测发现单个 UDP 包超过 1470 字节时,ESP32-S3 端就直接不响应了,不管是固件 bug 还是缓冲区限制,总之大包行不通。

所以最终设计了一个简单的分片协议,每个 UDP 包的结构如下:

  • total_chunks:这一帧 JPEG 数据总共分了几片

  • chunk_index:当前包是第几片(从 0 开始)

  • payload:JPEG 数据分片,每片固定 1400 字节(留出余量,稳稳低于 1470 的边界)

ESP32-S3 端收齐所有分片后拼合成完整 JPEG,再交给渲染线程解码上屏。协议很轻量,但刚好够用。

换成自定义 UDP 协议之后,串流稳定多了。实测性能数据如下:

[STAT] fps=18.6 avgSize=8.0KB avgRender=17.8ms avgRecv=2.2ms avgGap=48.7ms dropped=0[STAT] fps=18.3 avgSize=8.0KB avgRender=17.8ms avgRecv=3.5ms avgGap=48.1ms dropped=0[STAT] fps=17.7 avgSize=7.9KB avgRender=17.8ms avgRecv=2.7ms avgGap=55.6ms dropped=0[STAT] fps=18.2 avgSize=8.0KB avgRender=17.8ms avgRecv=2.8ms avgGap=51.0ms dropped=0[STAT] fps=18.8 avgSize=7.7KB avgRender=17.7ms avgRecv=2.8ms avgGap=46.4ms dropped=0

几个关键数字:

  • FPS:17.7~18.8,稳定跑在 18 FPS 区间

  • 平均帧大小 7.7~8.0KB:JPEG 质量 60% 下压缩效果不错

  • 接收耗时 2.2~3.5ms:网络传输本身很快

  • 渲染耗时 17.8ms:解码 + DMA 上屏的总时间

渲染耗时 17.8ms 对应约 56 FPS 的渲染上限,目前跑在 18 FPS 留了很大余量,网络侧和渲染侧都不是瓶颈。

UDP 小结

换 UDP 后完全没有丢帧,FPS 稳定在 17.7~18.8。渲染耗时 17.8ms,理论上限约 56 FPS,当前帧率还有很大的提升空间。


渲染侧:跟预期一致

渲染这边直接复用了之前测试验证过的方案:

  • 解码库:ESP_NEW_JPEG

  • 上屏方式:Block + DMA,每输出一个 Block 立刻 DMA 传输,解码与传输重叠

  • Strip 合并:多个 Block 合并为 320×16 整行再统一发,减少 SPI 启动次数

在 80MHz SPI 下,单帧 320×240 RGB565 绘制耗时约 15.36ms,对应约 65 FPS 的渲染上限。串流实际跑在 18 FPS,渲染侧完全不是瓶颈,留了很大余量。


实际效果

整体跑通之后,用网页放了个视频测试,18 FPS 在 320×240 的小屏上观感还不错,帧率稳定,没有明显卡顿。

彩色画面比单色 OLED 直观多了,之前靠抖动算法模拟的灰度感觉已经是另一个世界的东西了 😂。

更实用的玩法是直接把它当成电脑副屏用——全屏跑个 CPU 占用监控、网络流量图、任务状态之类的,放在桌上一眼就能扫到,挺有感觉的。


总结

esp32s3-lcd-streaming-5

捋一下整个系列走过来的路:

  1. ESP32-S3 + 12864 OLED,单色串流跑通,WebSocket 一帧一个消息

  2. 加入抖动算法,单色屏模拟 4 阶灰度,显示效果提升不少

  3. 系统测了 JPEG 解码库,找到 ESP_NEW_JPEG,加上 Block DMA 优化

  4. 换原生 SPI 引脚跑 80MHz,渲染上限推到 77 FPS

  5. 这次:彩色 LCD 串流,传输从 WebSocket/TCP 换到自定义 UDP,稳定跑在 18 FPS 🔥

现在整个链路是通的:桌面截图 → JPEG 编码 → UDP 推帧 → 解码 → DMA 上屏。后续可以继续优化的方向还有不少,比如动态调整 JPEG 质量来平衡画质和帧率,或者做成真正的副屏可以显示系统状态,再或者把 320×240 升级到更大尺寸的屏幕看看极限在哪。

慢慢折腾吧 😃。


参考资料

发表评论