说实话,第一次在ESP32-P4上把ST7703的RGB屏跑出24fps视频轮播效果时,我确实在屏幕前面愣了好几秒。之前我一直觉得“MCU做视频播放”就是玩个GIF轮播,真正上手P4这颗带MIPI-DSI控制器和H.264硬解的单片机之后,才意识到这套方案已经能撑起一个正经的视频轮播器:720×720的屏幕,24fps稳定刷新,多段视频循环切换,SD卡换素材即插即用。如果你正在纠结“用MCU做广告机/数字相框/展会演示屏到底行不行”,或者已经被SPI屏的刷新率折磨得想放弃,这篇文章就是给你写的。
我会把整个项目从0到1完整拆开:为什么选ESP32-P4和ST7703、硬件怎么接线、视频怎么预处理、上游怎么解码、下游怎么送显,以及最后怎么把帧率稳在24fps。代码部分不是截几个功能片段,而是把初始化、解码、显示、轮播的完整闭环给出来,你照着抄就能跑。
1. 项目先导:为什么这套组合能打
1.1 需求痛点:视频轮播器到底难在哪
视频轮播器最常见的使用场景是门店广告屏、展会演示、产品展厅、数字相框这一类。它们的共同要求是:能循环放视频、画面尽量清晰流畅、7×24小时稳定运行、换内容方便,最好还有一个相对合理的成本。过去要做这种事,大家的首选是安卓盒子或者树莓派,但这些方案的启动速度、系统稳定性、外设成本,以及每次换视频还要连鼠标键盘的维护方式,放在嵌入式场景里多少有点杀鸡用牛刀。反过来,如果你用传统MCU,比如ESP32-S3、STM32F4这类,走RGB8080并口或者SPI接口,刷一帧480×480的画面都够CPU喘半天,更别提24fps的视频流了。
问题拆开来看其实就三点:一是MCU算力够不够解码视频帧,二是屏幕数据通道能不能扛住高分辨率高帧率的带宽,三是整个系统能不能保证帧率稳定不撕裂不闪烁。传统MCU卡在第二和第三条上,而ESP32-P4这颗芯片的MIPI-DSI外设和硬件编解码器,刚好把这几条全打通了。
1.2 核心选型:ESP32-P4和ST7703分工明确
先聊聊为什么选ESP32-P4。它是乐鑫的旗舰级MCU,双核RISC-V跑到400MHz,内置了MIPI-DSI控制器、MIPI-CSI摄像头接口、H.264编码器和解码器,还有独立的JPEG编解码器。这些能力放到以前的MCU上根本不敢想。以前MCU做显示,最高效的也就是SPI_DMA刷屏,分辨率一高就得靠牺牲帧率来换,而P4有专门的硬件解码单元,CPU只需要把压缩流丢给硬件解码器,然后把解码出来的帧搬运到显存即可。搬运这件事也能用DMA完成,配上双buffer甚至三buffer,帧率自然就稳了。
再聊ST7703。很多朋友第一次看到ST7703屏幕会有点懵:这芯片到底算什么角色?简单说,它是一颗MIPI-DSI转RGB的驱动桥芯片,面板本身是RGB接口的LCD,ST7703负责接收MCU发来的MIPI-DSI信号,转换成RGB并行信号去驱动液晶玻璃。所以你买到的“ST7703屏幕模组”,其实是一个“MIPI-DSI输入,RGB输出到LCD”的完整模组。ST7703很常见的分辨率是480×480、720×720这类方形屏,色彩支持RGB565或者RGB888,刷新率可以做到30fps以上,厂商一般还会提供初始化序列(Init Code),你烧进去基本就能用。
ES32-P4负责“大脑”的部分,ST7703负责“显示驱动”的部分,两者通过MIPI-DSI这根高速管道连接。选这个组合而不是直接上Linux小板,最大的理由是:接口简单、启动快、代码可控、纯MCU方案在工业级稳定性上更让人放心。
1.3 24fps的可行性:带宽与算力都算过一遍
先说结论:720×720 RGB565、24fps,这套配置对ESP32-P4来说是绰绰有余的。我们来算一笔账。
一帧画面720×720像素,每个像素RGB565占2字节,那么一帧的数据量就是720×720×2 = 1,036,800 字节,约1MB。24fps的话,每秒需要传输约24.9MB的数据,折算成带宽就是24.9 × 8 ≈ 200Mbps。MIPI-DSI这边,ESP32-P4的D-PHY通常可以跑每lane 1Gbps左右,即使只开2 lane,理论带宽也有2Gbps,远高于200Mbps的实际需求。所以显示通道完全不是瓶颈。
解码这边,如果走MJPEG方案,JPEG硬件解码器能轻松处理720×720的帧;如果走H.264方案,P4内置的H.264解码器也能扛住这个分辨率。真正的瓶颈往往不在MCU本身,而在SD卡的读取速度和内存拷贝策略。这个问题我会在第4章仔细讲,但至少从芯片选型层面来说,24fps是完全可以达成的目标。
注意:很多ST7703模组标称支持60fps甚至更高,但那是面板的极限能力,实际工程里还要考虑解码、搬运、刷新同步的开销,定在24fps是一个兼顾流畅度和稳定性的务实选择,尤其适合视频广告机这类场景。
2. 硬件准备、接线与信号链路
2.1 物料清单
复制这套项目之前,先把东西备齐。以下是我实际使用的清单,全部列出来,你直接照着买就行。
| 物料 | 型号/规格 | 备注 |
|---|---|---|
| 主控开发板 | ESP32-P4 官方 DevKit / 任意带MIPI-DSI引出的开发板 | 注意确认DSI接口已经引到排针或FPC座 |
| LCD模组 | ST7703驱动,720×720 RGB屏 | 最好带RGB888/RGB565切换 |
| 存储介质 | TF/SD卡,Class 10或以上 | 建议16GB以上,放视频素材 |
| 电源 | 5V/2A及以上USB电源 | MIPI屏背光+MCU同时耗电,别用劣质充电头 |
| 杜邦线/FPC转接板 | 视开发板而定 | 验证阶段用杜邦线,成品建议打样FPC |
| 逻辑分析仪/示波器 | 可选 | 排查MIPI信号问题时有用 |
有一点要提醒,买ST7703屏幕的时候,不要只看店家给的“接口类型”描述。很多ST7703模组上面既有MIPI-DSI的FPC座,还有一排引脚,看起来像是8080并口,但那一排引脚实际是RGB信号并行输出,给屏体用的,不是给你接MCU的。所以你只需要关注模组的MIPI-DSI输入接口和背光、复位引脚。
2.2 接线要点:MIPI-DSI、背光、复位
MIPI-DSI看起来很高端,接线其实不算复杂,但每一条都有讲究。ESP32-P4开发板上的DSI接口一般引出以下信号:
- 时钟线:DSI_CLKP、DSI_CLKN,这是一对差分时钟
- 数据线:DSI_D0P、D0N、D1P、D1N,部分板子有4 lane,那还有D2P、D2N、D3P、D3N
- 复位线:LCD_RST
- 背光线:LCD_BL / LCD_PWM
- 电源:3.3V、GND
差分信号讲究的是等长、阻抗匹配。在开发板上,走线已经被Layout固定好了,你要做的是把它和屏幕排线连起来。如果是用Dupont线验证,注意把P/N对应好,不要交叉接反。我曾经有一块板子一直花屏,最后发现是D0P和D0N接反了,MIPI链路能Link上,但数据全乱。
快速接线参考(以2 lane为例):
| ESP32-P4开发板引脚 | ST7703模组引脚 | 说明 |
|---|---|---|
| 3V3 | VCC | 模组数字电源 |
| 5V / VBUS | LED+(背光正极) | 背光电流较大,建议从5V取 |
| GND | GND | 共地 |
| DSI_CLKP | CLKP | 差分时钟正 |
| DSI_CLKN | CLKN | 差分时钟负 |
| DSI_D0P | D0P | Lane0正 |
| DSI_D0N | D0N | Lane0负 |
| DSI_D1P | D1P | Lane1正 |
| DSI_D1N | D1N | Lane1负 |
| GPIO(如4) | RESET | 复位,低有效 |
| GPIO(如5) | BL_PWM | 背光PWM |
接线里最容易翻车的两个点,一是背光供电。如果模组上LED+和LED-直接接了5V电源,那么背光电流可能达到几百毫安,USB供电电流不足的话,屏幕一亮整个系统就重启。二是复位脚的时序。ST7703的复位要求低电平保持一段时间再拉高,如果上来直接用Chip Enable引脚代替,有的屏会初始化不彻底,直接黑屏。
2.3 电气细节:为什么不能随便乱接
MIPI-DSI是高速差分信号,理想的工作环境是PCB上做100Ω差分阻抗匹配。开发板上信号线已经布好,但当你要把板子和屏幕用杜邦线连起来时,这段线就成了阻抗不连续的“天线”。如果是低速的I2C、UART,乱接问题不大;MIPI-DSI跑在Gbps级别,信号完整性就很敏感。我的建议是验证阶段可以短距离用杜邦线,但线长控制在10cm以内,并且把P/N两根线绞在一起,能有效减少共模干扰。
另外,ST7703模组供电一般分两路:数字逻辑供电VCC是3.3V,背光LED供电需要单独接。我见过不少新手直接把背光LED接到3.3V,结果是屏幕能亮但非常暗,因为白光LED的正向导通压降和电流需求摆在那里。硬件层面先把这两个电源分开,能避免很多莫名其妙的症状。
3. 搭建开发环境与最小工程
3.1 ESP-IDF环境准备
ESP32-P4的上手方式和普通ESP32系列差不多,官方推荐用ESP-IDF v5.4及以上版本。装好IDF之后,首次编译P4的工程需要执行:
idf.py set-target esp32p4如果还没安装P4的支持包,先执行:
. $IDF_PATH/export.sh idf.py install esp32p4考虑到P4是RISC-V内核,工具链和之前Xtensa的ESP32不太一样,刚装完环境后第一次编译耗时比较长,耐心等就行。建议开发完一个可用的最小工程后,把编译缓存保留好,后续做增量编译很快。
3.2 创建工程并配置Board Support
最稳妥的方式是在IDF的组件库里直接找到MIPI-DSI的示例工程。我建的工程目录结构大概是这样的:
video_player/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ ├── main.c │ ├── display.c │ ├── display.h │ ├── video_decoder.c │ ├── video_decoder.h │ ├── player.c │ └── player.h └── sdkconfig.defaults在sdkconfig.defaults里需要额外开启几个关键选项:
CONFIG_ESP_LCD_MIPI_DSI=y CONFIG_ESP_LCD_ST7703=y CONFIG_SPIRAM=y CONFIG_SPIRAM_MODE_OCT=ySPIRAM(PSRAM)对视频播放至关重要。P4虽然有内置SRAM,但显示缓冲和视频解码帧缓冲动辄几MB,肯定得放在PSRAM里。如果不开PSRAM,程序能编译过,但跑到申请内存时就崩了。
3.3 点亮屏幕的最小工程验证
在写播放器代码之前,我强烈建议先跑一个纯色填充的小程序,确认MIPI链路和ST7703初始化正常。代码核心就是创建DSI Bus、创建Panel IO、创建ST7703屏幕实例,然后填充纯色给面板。
下面这段是迷你验证代码,你可以直接复制到main.c里测试:
#include <stdio.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_lcd_mipi_dsi.h" #include "esp_lcd_panel_st7703.h" #include "esp_lcd_panel_ops.h" void app_main(void) { // 1. 创建 MIPI DSI Bus esp_lcd_dsi_bus_config_t bus_config = { .bus_id = 0, .num_data_lanes = 2, .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = 1000, // 1Gbps per lane }; esp_lcd_dsi_bus_handle_t mipi_dsi_bus; ESP_ERROR_CHECK(esp_lcd_new_dsi_bus(&bus_config, &mipi_dsi_bus)); // 2. 创建 DSI Panel IO esp_lcd_dsi_io_config_t io_config = { .virtual_channel = 0, .lcd_cmd_bits = 8, .lcd_param_bits = 8, }; esp_lcd_panel_io_handle_t mipi_dsi_io; ESP_ERROR_CHECK(esp_lcd_new_panel_io_dsi(mipi_dsi_bus, &io_config, &mipi_dsi_io)); // 3. 创建并初始化 ST7703 面板 esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = 4, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, .bits_per_pixel = 16, // RGB565 }; esp_lcd_panel_handle_t panel; ESP_ERROR_CHECK(esp_lcd_new_panel_st7703(mipi_dsi_io, &panel_config, &panel)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel)); ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel, true)); // 4. 填充一个纯色帧并刷上去 uint16_t *frame = heap_caps_malloc(720 * 720 * 2, MALLOC_CAP_SPIRAM); for (int i = 0; i < 720 * 720; i++) { frame[i] = 0xF800; // 红色 } esp_lcd_panel_draw_bitmap(panel, 0, 0, 720, 720, frame); vTaskDelay(pdMS_TO_TICKS(5000)); // 绿色 for (int i = 0; i < 720 * 720; i++) { frame[i] = 0x07E0; } esp_lcd_panel_draw_bitmap(panel, 0, 0, 720, 720, frame); vTaskDelay(pdMS_TO_TICKS(5000)); }如果你看到屏幕先变红再变绿,说明MIPI链路、ST7703初始化、帧缓冲申请全部正常,接下来可以放心往播放器方向走。如果这里是黑屏,先别往下写播放器,先把硬件链路问题找出来,否则后面排错会很痛苦。
注意:如果你的屏幕是RGB888模组,
bits_per_pixel可以改成24,同时帧缓冲也要对应调整每个像素3字节。一般ST7703模组默认支持RGB565,RGB888要额外切换数据格式,最好看一眼店家的初始化文档。
4. 视频源准备:转码与播放列表设计
4.1 为什么不能直接播放电脑里的视频
很多人会问:我有一堆MP4,直接拷到SD卡不就行了?原则上可以,但你要考虑两件事。一是编码格式兼容性:虽然ESP32-P4有H.264硬解,但它支持的H.264是有限制的,Main Profile、High Profile复杂的参考帧、B帧都有可能让硬解性能大打折扣。二是码率和分辨率:720×720只是屏幕分辨率,视频文件本身如果是4K,硬解出来的画面还得缩放,很浪费算力。
所以我的建议是:不要直接扔原始文件,统一用ffmpeg预处理成最适合P4的格式。这一步一劳永逸,后面所有视频素材都能无缝播放。
4.2 两套预处理方案对比:MJPEG与H.264
我实际测试了两条路线,各有取舍:
| 方案 | 清晰度 | CPU消耗 | 硬解依赖 | 帧率稳定性 | 适合场景 |
|---|---|---|---|---|---|
| MJPEG | 中高,静止画面压缩比高 | 很低 | 不依赖H.264,仅JPEG解码 | 非常稳 | 画面变化不剧烈的内容,如展示图、产品视频 |
| H.264 | 高,压缩率更高,动态画面更优 | 中 | 必须依赖P4的H.264硬解 | 需要额外调优 | 动态画面多、期望更高清晰度时 |
我现在的默认方案是MJPEG,因为JPEG硬解码器比H.264更简单,丢帧概率更低,代码也好写。H.264则在后面可以平滑升级。
转MJPEG的命令是这样的:
ffmpeg -i input.mp4 -vf "scale=720:720:flags=lanczos" -r 24 -an -c:v mjpeg -q:v 3 output.mjpeg这里有几个关键参数解释一下:
-r 24:强制抽帧成24fps,避免视频原本是30fps导致播放时长和显示节奏不匹配-q:v 3:JPEG质量,1是最大质量,5以下观感都还行,3是兼顾文件大小和质量的选择-an:视频轮播器暂时不需要音频,直接用轮播逻辑或者外接背景音乐即可
转H.264的话,命令稍微改一下:
ffmpeg -i input.mp4 -vf "scale=720:720:flags=lanczos" -r 24 -an -c:v libx264 -profile:v baseline -level 3.0 -pix_fmt yuv420p -movflags +faststart output.mp4H.264转码时务必注意-profile:v baseline和-pix_fmt yuv420p,这直接关系到P4硬解是否支持。B帧多的High Profile文件在某些固件版本上解码到一半会卡死。
4.3 播放列表与SD卡结构
视频轮播器顾名思义要“轮播”,所以多个视频文件不能只是简单堆在根目录。我在SD卡里建了这样的目录结构:
/sd ├── playlist.txt ├── video/ │ ├── 01.mjpeg │ ├── 02.mjpeg │ └── 03.mjpeg └── logo.jpgplaylist.txt的内容很简单:
video/01.mjpeg 5 video/02.mjpeg 10 video/03.mjpeg 8第一列是路径,第二列是停留/播放秒数。有些素材是循环动画,但不需要播放完整视频,这时候在播放列表里指定秒数就行。程序启动后先读这个文件,把路径列表解析到内存里,然后逐个打开、解码、播放、切换。这样换素材就非常方便,直接改txt文件就行,不用重新编译固件。
5. 核心代码实现:播放器闭环
5.1 初始化Display的完整过程
前面最小工程里已经把MIPI链路过了一遍,真正做播放器时,要在初始化环节把帧率相关的VSync和显示时序一并配置好。这个display_init函数里包含了DSI Bus、Panel IO、ST7703以及DPI模式的完整配置:
static esp_lcd_panel_handle_t s_panel = NULL; static void display_init(void) { // MIPI DSI Bus 配置 esp_lcd_dsi_bus_config_t bus_config = { .bus_id = 0, .num_data_lanes = 2, .phy_clk_src = MIPI_DSI_PHY_CLK_SRC_DEFAULT, .lane_bit_rate_mbps = 1000, }; esp_lcd_dsi_bus_handle_t mipi_dsi_bus; ESP_ERROR_CHECK(esp_lcd_new_dsi_bus(&bus_config, &mipi_dsi_bus)); // Panel IO 配置 esp_lcd_dsi_io_config_t io_config = { .virtual_channel = 0, .lcd_cmd_bits = 8, .lcd_param_bits = 8, }; esp_lcd_panel_io_handle_t mipi_dsi_io; ESP_ERROR_CHECK(esp_lcd_new_panel_io_dsi(mipi_dsi_bus, &io_config, &mipi_dsi_io)); // ST7703 面板配置 esp_lcd_panel_dev_config_t panel_config = { .reset_gpio_num = 4, .rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB, .bits_per_pixel = 16, }; esp_lcd_panel_handle_t panel; ESP_ERROR_CHECK(esp_lcd_new_panel_st7703(mipi_dsi_io, &panel_config, &panel)); ESP_ERROR_CHECK(esp_lcd_panel_reset(panel)); ESP_ERROR_CHECK(esp_lcd_panel_init(panel)); // DPI (video mode) 配置 esp_lcd_dpi_panel_config_t dpi_config = { .pixel_clock = 24000000, // Pixel Clock 24MHz,对应 24fps .h_res = 720, .v_res = 720, .hsync_pulse_width = 16, .hsync_back_porch = 16, .hsync_front_porch = 16, .vsync_pulse_width = 4, .vsync_back_porch = 4, .vsync_front_porch = 4, }; ESP_ERROR_CHECK(esp_lcd_dpi_panel_config(panel, &dpi_config)); ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel, true)); s_panel = panel; }注意DPI模式下,MCU不直接控制每一个像素的刷新时序,而是通过MIPI-DSI的视频模式把像素流持续送给ST7703,ST7703再自己控制LCD的行场扫描。这种方式非常省CPU,MCU只需要往frame buffer里写内容,屏幕就会自动刷新,帧率稳定性也更好。
5.2 解码线程与显示线程的配合
视频播放器的核心逻辑其实就两件事:解码一帧,送显一帧。但“解码”和“送显”的速度不一样,必须用多线程和帧缓冲来解耦。
我的设计是:
- 一个Decoder Task:读取当前视频文件,取出一帧压缩数据,交给硬件解码器解出RGB565帧,写入双缓冲中的后台缓冲。
- 一个Display Task:等到VSync事件,把已解码的帧缓冲通过
esp_lcd_panel_draw_bitmap刷到屏幕上,然后切换前后台缓冲。
双缓冲能有效防止撕裂(tearing)。如果只用一个buffer,解码线程往buffer里写数据的同时显示线程正在读取同一块内存,就会出现画面上下两部分不是同一帧的情况。
Decoder Task的伪代码框架:
static void decoder_task(void *arg) { while (1) { // 等待下一帧需要解码的请求 xSemaphoreTake(s_frame_req_sem, portMAX_DELAY); // 从当前视频句柄读一帧压缩数据 int len = video_file_read(s_video_ctx, s_dec_inbuf, DEC_INBUF_SIZE); if (len <= 0) { // 视频读完,切换下一个视频 player_switch_to_next_video(); continue; } // JPEG 硬解 jpeg_decoder_process(s_jpeg_dec, s_dec_inbuf, len, s_frame_buffers[s_write_idx], JPEG_OUT_RGB565); // 通知显示线程可以送显了 xSemaphoreGive(s_frame_ready_sem); } }Display Task的核心逻辑:
static void display_task(void *arg) { uint8_t cur_idx = 0; while (1) { // 等待一帧解码完成 xSemaphoreTake(s_frame_ready_sem, portMAX_DELAY); // 把当前帧刷到屏幕 esp_lcd_panel_draw_bitmap(s_panel, 0, 0, 720, 720, s_frame_buffers[cur_idx]); // 翻转 buffer s_write_idx = (s_write_idx + 1) % 2; cur_idx = (cur_idx + 1) % 2; // 通知解码器可以继续解下一帧 xSemaphoreGive(s_frame_req_sem); } }这里有个需要注意的点:解码器硬解是异步的,我调用的jpeg_decoder_process在真正解码完成前不能去读输出buffer。所以两个Semaphore实际上做了一个简单的生产者/消费者模型:Display等Ready信号,Decoder等Request信号,保证“解一帧、显一帧”的流水线不冲突。
5.3 帧同步与帧率控制:怎么稳定卡在24fps
光靠解码快就能到24fps吗?不一定。如果解码速度比24fps快,画面会像按了快进一样;如果解码速度比24fps慢,画面就会掉帧。想要稳定在24fps,必须以屏幕的节奏为准。
ST7703配置DPI video mode后,屏幕本身就按照24MHz像素时钟在跑,所以番茄钟其实掌握在ST7703的刷新节奏里。但MCU侧刷帧的时机如果不在VSync的固定相位,还是会产生撕裂。
我用的方法是:在Display Task里不直接“准备好了就刷”,而是注册一个VSync中断回调,让刷新动作和VSync对齐。以ESP-IDF的MIPI DSI实现来看,可以通过esp_lcd_dsi_frame_finished这类回调或者定时器延时来让位。如果SDK版本没有现成回调,用高分辨率定时器模拟也可以,关键是刷帧的周期必须严格按1/24秒来。
一种比较简单粗暴的方法是用vTaskDelayUntil:
TickType_t last_wake = xTaskGetTickCount(); const TickType_t frame_interval = pdMS_TO_TICKS(1000 / 24); while (1) { // 等待解完一帧 xSemaphoreTake(s_frame_ready_sem, portMAX_DELAY); // 刷帧 esp_lcd_panel_draw_bitmap(s_panel, 0, 0, 720, 720, s_display_buf); // 严格等到下一帧时间点 vTaskDelayUntil(&last_wake, frame_interval); }用vTaskDelayUntil而不是普通的vTaskDelay,是因为它会把任务的唤醒时间对齐到绝对时间点,不会因为上一帧处理耗时太长而累积延迟。这样才能让长时间轮播不出现“越来越慢”的问题。
5.4 轮播逻辑实现:多视频顺序切换
轮播器里会有一个状态机,记录当前正在播放的视频序号、当前视频文件句柄、当前读取位置。当解码器读文件读到EOF时,就触发切换动作。
我的切换逻辑大概是:
static void player_switch_to_next_video(void) { // 关闭当前视频 if (s_video_ctx) { video_file_close(s_video_ctx); } // 获取下一项播放列表 s_playlist_idx = (s_playlist_idx + 1) % s_playlist_count; playlist_item_t *item = &s_playlist[s_playlist_idx]; // 打开新视频 s_video_ctx = video_file_open(item->path); s_video_elapsed_sec = 0; printf("[player] switch to %s\n", item->path); }如果你在playlist里指定了播放秒数,还需要有一个Timer或者专门的检查逻辑,在播放时间达到设定值后提前触发切换。这个逻辑放到Decoder Task里每次读完一帧后检查即可:如果累计播放秒数大于配置,就立刻切视频。
帧缓冲的申请放到全局:
static uint8_t *s_frame_buffers[2]; // 在 app_main 里分配 s_frame_buffers[0] = heap_caps_malloc(720 * 720 * 2, MALLOC_CAP_SPIRAM); s_frame_buffers[1] = heap_caps_malloc(720 * 720 * 2, MALLOC_CAP_SPIRAM);PSRAM分配这几个MB完全没压力,但在编码时不要忘了检查返回指针是否为空。
6. 常见问题排查与性能优化实录
6.1 黑屏、白屏排查速查
做MIPI屏最折磨人的就是黑屏,因为它的原因可以排出一长串。我遇到过的黑屏情况,用排除法基本能定位:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无亮光 | 背光电源没接好 | 测LED+对地电压,确认5V/电流足够 |
| 背光亮但屏幕全黑 | ST7703复位没生效 | 检查RESET脚拉低时间,确认代码里reset_gpio_num正确 |
| 背光亮但屏幕显示乱码或全白 | DSI Lane数量配置错误 | 检查bus_config里num_data_lanes是否与硬件一致 |
| 屏幕只有半边或滚动 | Panel尺寸配置错误 | 检查h_res/v_res是否和屏一致 |
| 点屏幕无响应且系统重启 | 供电不足 | 换5V/2A电源,测量上电瞬间电压跌落 |
最诡异的一次黑屏是我忘了给ST7703模组接3.3V,只接了背光5V,结果屏幕亮得像小夜灯,但怎么初始化都不出画面。后来才意识到数字逻辑都没供电,自然没法工作。
6.2 花屏、画面撕裂、闪烁问题
花屏往往和RGB的数据格式配置有关。如果你把RGB888的数据写到RGB565的屏上,颜色是乱的;把RGB565的数据按3字节发,帧全部错位。检查panel_config.bits_per_pixel和帧缓冲里像素类型是否一致。
画面撕裂是单缓冲的典型症状,也可能是刷帧时机没有对齐VSync。解决办法就是双缓冲+同步。视频轮播器对画面稳定性要求高,千万不要用单缓冲省那1MB内存,双缓冲带来的稳定体验绝对值这个代价。
闪烁还有可能是背光PWM频率太低。如果你用GPIO做PWM调光,频率低于500Hz,人眼会明显感觉到屏幕在闪。把PWM频率设到1kHz以上,或者背光直接常亮,只用系统来控制亮度,闪烁问题基本就消失了。
6.3 卡顿掉帧:瓶颈到底在哪
如果画面能出,但帧率只有10fps,或者时不时卡一下,先别急着怀疑解码器。我从这个项目里总结出的排查顺序是:
先看SD卡读取速度。如果用SPI模式读SD卡,720×720的MJPEG帧,每帧压缩后也有50KB~200KB,SPI模式往往只能跑到几MB/s,读一帧要几十毫秒,这就成了瓶颈。换成SDMMC 4-bit模式后,读取速度能提升一个量级,整个帧率随之上涨。
再看内存拷贝。硬件解码器解出来的帧,如果要从内部SRAM再拷贝到PSRAM、再从PSRAM传到DSI控制器,中间多一层memcpy就会吃掉不少CPU周期。建议让解码器直接输出到PSRAM的目标buffer,减少中间拷贝。
最后才看解码器配置。JPEG硬解如果输出像素格式选错了,比如选了RGB888但你的屏是RGB565,转换过程会额外耗时。选对格式、对齐分辨率,解码性能会好很多。
我的实测数据是,在720×720 MJPEG素材、Class 10 SD卡、SDMMC 4-bit模式下,解码一帧约20ms,送显一帧约5ms,总耗时25ms左右,刚好卡在24fps的边缘。进一步优化后,比如把像素时钟和DMA配置好,可以稳定跑到24fps甚至30fps。
6.4 调优到24fps的3个关键配置
调优时最关键的一步,是搞清楚瓶颈到底在CPU搬运还是外设刷新。我最后稳定在24fps,主要做了三件事:
第一,把SD卡切换成4-bit SDMMC模式,这是所有优化里收益最大的一步,读取速度提升了将近5倍。
第二,把帧缓冲放在PSRAM里,同时打开DMA传输。显示数据从PSRAM直接DMA到DSI控制器,CPU几乎不参与像素搬运,CPU算力留着给协议解析和轮播逻辑。
第三,把Display Task的优先级调到比Decoder Task高,并且绑在不同的核心上。P4是双核RISC-V,把解码线程跑在Core 0,显示线程跑在Core 1,两个流水线互不干扰,帧率稳定性好很多。
调优之后,整个系统跑了一晚上没有掉帧,屏幕的温度也就比体温高一点,功耗比我预想的低很多。
实操心得:这类项目最容易犯的错是一开始就追求“高大上”。我建议你第一次跑通,先用最简单的MJPEG方案,帧率哪怕25fps都行,先把链路全部打通。通了之后,再去优化到24fps甚至挑战30fps。因为显示链路的坑很多,如果一上来就堆H.264硬解、三缓冲、双核调度,出了问题根本没法定位。
关于后续扩展,这个播放器框架的可玩性其实很高。你可以加一个BLE/WiFi通道,在手机端推送新的播放列表;也可以叠加一个摄像头输入,通过MIPI-CSI做画中画;或者再加入手势传感器,让轮播器的画面跟着人靠近而暂停。硬件基础摆在这儿,ESP32-P4给的扩展空间非常宽裕,真正限制你的不是芯片,而是你的想象力和Debug的耐心。