STM32串口发送RGB565图片到SPI彩屏:DMA收包与窗口刷屏优化

发布时间:2026/11/5 7:20:03
STM32串口发送RGB565图片到SPI彩屏:DMA收包与窗口刷屏优化

简介:围绕STM32与OV7725摄像头模块的串口图像传输实践,这份资料包给出从下位机采集、编码到上位机解码显示的完整工程,适合已掌握C语言与单片机基础、希望打通图像采集链路的嵌入式学习者。包内共499个文件,以45个h头文件与43个c源文件为主体,配合Keil的uvprojx工程、hex与axf固件、map与lst编译信息,构成miniSTM32端的采集与串口发送代码;另有sln、csproj等Visual Studio上位机工程及bmp样例图,便于对照验证BMP文件头拼装与像素还原是否正确。压缩包约10.92MB,已有5808人学习。读者可从中梳理DMA读取像素、RGB数据打包、UART按波特率控制发送节奏,以及上位机字节序处理与图像重构的实现思路,也可烧录固件、配合上位机复现实验,作为课程设计、毕业设计或远程图像监控原型的起点。

1. 一块 240×320 的彩屏,一张图传了 13 秒:这笔账得先算

某开发者把一块 240×320 的 SPI 彩屏接到 STM32 上,想用最熟悉的方式——上位机通过串口把一张彩色图片发下去显示。代码跑通那一刻他盯着屏幕等了十几秒,图片才从上往下慢慢“刷”出来,中间还夹着几条彩色斜纹。问题不在代码写错,而在“STM32 上位机 发送图片”这件事本身就带着一笔硬性带宽账:240×320 的 RGB565 整帧是 153600 字节,115200 波特率 8N1 每秒只能送 11520 字节,算下来 13.3 秒,一帧都嫌久,更别说动画。这篇笔记就围绕这笔账展开,把图片格式、分包协议、上位机切图、DMA 收包、LCD 窗口刷屏这几段落成能直接抄的代码,也把花屏、错位、卡死这些现场逐个拆开。适合手里已经有屏、有串口、想把彩图真正跑起来的嵌入式开发者。

2. 图片进 MCU 之前:RGB565 与分包协议怎么定

2.1 BMP、JPEG、PNG 在 MCU 侧的三种命运

上位机发图,第一个岔路口是“发什么格式”。很多人第一反应是发 JPG,因为文件小,但这个选择在 MCU 侧会把复杂度整体抬高一个量级。

BMP 的优点是结构最简单,24 位真彩色 BMP 的像素区就是按 BGR 顺序排列的原始字节,没有压缩。但它有两个必须处理的细节:每行字节数要补齐到 4 的倍数,以及像素数据默认自下而上存储。240 宽的三字节像素是 720 字节,正好被 4 整除,不用补;如果是 241 宽就要补 1 字节。这两个点不处理,画面会整体错位或者上下颠倒。

JPEG 需要解码器。常见做法是移植一个几百行的 TJPGD 解码库,它对 RAM 的要求不算离谱,但工作缓冲区、Huffman 表、MCU 块缓存加起来也要几 KB 到十几 KB,而且解码一张 240×320 的图在 72MHz 的 F103 上要几百毫秒。更麻烦的是解码输出通常是 RGB888 或者 YCbCr,还要再转一次 RGB565。PNG 更重,zlib 解压加上滤波还原,一般不在这个场景里考虑。

我的建议是:调试阶段直接在上位机把图片转成 RGB565 裸流,串口发下去的每一个字节就是屏幕要写的每一个像素。这样 MCU 侧完全不需要图片解码,只做协议解析和搬运,RAM 占用和 CPU 时间都可控。BMP 可以作为备选,适合手里只有现成 BMP 文件、不想装 Python 环境的情况。

格式MCU 侧是否需要解码典型 RAM 开销240×320 传输量适用场景
RGB565 裸流否仅接收缓冲153600 字节首选,调试和量产都合适
24 位 BMP否,但要转格式一行缓冲即可约 230KB有现成素材、不想写上位机
JPEG是10KB 以上10KB 到 40KB图片多、带宽紧张、MCU 有余量
PNG是,且更重20KB 以上类似 JPEG一般不推荐

表格里的传输量只是参考,实际还要加协议头。选裸流的核心逻辑是:把复杂度放在上位机,PC 的算力不值钱,MCU 的 RAM 和中断时间才值钱。

2.2 RGB565 与 RGB888 的内存账:整帧缓存到底放不放得下

RGB565 每个像素 2 字节,R 占 5 位、G 占 6 位、B 占 5 位。人眼对绿色更敏感,所以绿色多给一位,这是这个格式在嵌入式显示里长期存在的原因。240×320 的整帧 RGB565 是 153600 字节,也就是 150KB。

F103 系列一般只有 20KB 到 64KB SRAM,整帧放不下;F407 有 192KB,理论放得下,但放了整帧之后协议缓冲、栈、其他外设缓冲区都会紧张。320×480 的屏更夸张,整帧 300KB,直接出局。

所以常见做法是不整帧缓存,改成“块缓冲”。按行或者按固定行数开一块内存,收到一块就刷一块。比如一次缓存 32 行:240×32×2 = 15360 字节,15KB,F103 的 48KB 版本也能承受。块高取 16、32、64 都行,取太小会导致 LCD 窗口设置过于频繁,取太大又挤占 RAM。

/* 块缓冲:一次缓存 32 行,240 宽 */ #define LCD_W 240 #define BLOCK_LINES 32 static uint16_t blk_buf[LCD_W * BLOCK_LINES];

这段定义里BLOCK_LINES是可以调的参数。32 行对应的缓冲是 15KB,配合 2KB 的串口 DMA 环形缓冲,F103 的 48KB 版本还剩不少余量。如果屏幕是 320 宽,同样的 32 行就是 20KB,需要重新评估。参数选择的判断标准是:块缓冲加串口缓冲加协议栈加其他全局变量,总和不要超过 SRAM 的 70%,留出中断嵌套和栈的空间。

还有一个容易被忽略的点:LCD 显存和 MCU 缓冲是两回事。带显存的屏(比如 ILI9341 模组自带 172800 字节 GRAM)不需要 MCU 持续刷新,写完就保持;不带显存的屏或者用 RGB 接口的屏,才需要 MCU 不停喂数据。这个区别直接决定你是“发一帧就完事”还是“必须持续供流”。

2.3 波特率、帧率与 SPI 时钟:三个瓶颈先排个序

把 240×320 RGB565 整帧 153600 字节代入串口带宽公式,8N1 每字节实际占 10 位(1 起始位 + 8 数据位 + 1 停止位),所以:

  • 115200 bps:11520 字节/秒,整帧约 13.3 秒
  • 460800 bps:46080 字节/秒,整帧约 3.3 秒
  • 921600 bps:92160 字节/秒,整帧约 1.67 秒
  • 2 Mbps:200000 字节/秒,整帧约 0.77 秒

这是理论值,实际还要加协议头、校验、上位机写串口的间隔,通常打八折到九折。也就是说 921600 下实际一帧大概 1.8 到 2 秒。

再看 LCD 写入。SPI 跑 40MHz,153600 字节是 1228800 位,除以 40M 约 31 毫秒;SPI 跑 10MHz 约 123 毫秒。所以在 SPI 模式下,LCD 写入通常不是瓶颈,串口才是。但如果是软件模拟 SPI 翻转 IO,等效速率可能只有 1MHz 上下,那就是 1.2 秒,和串口一个量级,需要优先换成硬件 SPI 加 DMA。

第三个瓶颈是 MCU 的接收处理能力。921600 波特率下平均每 10.8 微秒来一个字节,如果每个字节都进中断,CPU 基本被拖死。这就是后面必须用 DMA 加空闲中断的原因,不能靠HAL_UART_Receive_IT一个字节一个字节收。

排序结论很清楚:先确认串口能跑多高,再确认 LCD 是硬件 SPI 还是软件翻转,最后看 MCU 有没有用 DMA。三个都到位,一张图进 2 秒以内是合理的;只改代码不改这三项,等再久也没用。

3. 上位机侧:Python 切图、打包、限速发送

3.1 把任意图片转成 RGB565 字节流

上位机用 Python 加 Pillow 和 NumPy 是最省事的组合,不需要编译环境,改参数也快。核心是三件事:缩放到屏幕分辨率、取 RGB 三个通道、按 5-6-5 移位拼成一个 16 位整数。

from PIL import Image import numpy as np def img_to_rgb565(path, width=240, height=320, swap_rb=False): im = Image.open(path).convert("RGB") # 缩放算法用 BILINEAR,速度快,效果够用;要更锐利可换 LANCZOS im = im.resize((width, height), Image.BILINEAR) arr = np.asarray(im, dtype=np.uint16) r = (arr[:, :, 0] >> 3) & 0x1F # 8 位压到 5 位 g = (arr[:, :, 1] >> 2) & 0x3F # 8 位压到 6 位 b = (arr[:, :, 2] >> 3) & 0x1F if swap_rb: # 屏的 R/B 顺序反了时打开 r, b = b, r v = (r << 11) | (g << 5) | b # "<u2" 表示小端无符号 16 位,低字节先发,和多数屏的接线顺序一致 return v.astype("<u2").tobytes()

>>3和>>2是截断高位,不做四舍五入,速度快但对渐变天空这类画面可能出现轻微色带。如果介意,可以改成((arr + 4) >> 3)做简单舍入,代价是多一次加法。swap_rb这个开关是给自己留的后悔药:不同屏的 RGB 顺序、MADCTL 配置不一样,先在 PC 侧验证是数据问题还是屏配置问题,比在 MCU 里反复烧录快得多。

输出的是按行排列的裸字节,第 0 到第 479 字节是第一行 240 个像素,依此类推。如果屏的扫描方向和图片方向不一致,可以在这里用np.flipud或np.fliplr提前翻转,也可以留给 MCU 的 MADCTL 寄存器处理,二选一,别两边都翻。

3.2 分包协议:帧头、序号、长度、校验一个都不能省

串口是字节流,没有消息边界。不发协议直接连续写,MCU 侧一旦丢一个字节,后面全部错位,画面会出现固定角度的斜纹或者整屏噪点。最小可用的协议包含五个字段:

字段长度说明
帧头2 字节固定 0xAA 0x55,用于快速对齐
包序号2 字节从 0 递增,MCU 侧可发现丢包
载荷长度2 字节本包 payload 字节数,不含头和校验
载荷N 字节RGB565 数据,长度取偶数
CRC162 字节对序号、长度、载荷做校验

载荷长度建议取 1024 字节,也就是 512 个像素,240 宽的一张图正好两行多。取更大,比如 4096,单包出错重传代价高,而且 MCU 的 DMA 缓冲要跟着变大;取更小,协议开销比例上升。1024 是常见平衡点。

import struct, binascii HEAD = b"\xAA\x55" CMD_IMG = 0x01 def build_packet(seq, payload): # 小端打包:序号 2 字节 + 长度 2 字节 body = struct.pack("<BH", seq & 0xFFFF, len(payload)) + payload # CRC16-CCITT,初值 0xFFFF,和 MCU 侧保持一致 crc = binascii.crc_hqx(body, 0xFFFF) return HEAD + body + struct.pack("<H", crc)

binascii.crc_hqx算的是 CRC-CCITT(多项式 0x1021,初值 0xFFFF),MCU 侧必须用同样的初值和多项式,否则每包都校验失败。如果不想在 MCU 里放 CRC 表,也可以退化成累加和,实现简单但检错能力弱,串口线短、干扰小的时候够用。我的习惯是调试阶段先用累加和把链路跑通,再换 CRC16 做最终验证,这样排错时能分清是协议错还是数据错。

3.3 发送节奏与流控:USB 转串口那一环最容易翻车

PC 侧发送代码看着简单,坑主要在“发得太快”。USB 转串口芯片内部有 FIFO,驱动写进去不等于已经发到线上,MCU 处理不过来时数据会在芯片或驱动层被丢掉,而且丢得悄无声息。

import serial, time ser = serial.Serial( port="COM3", baudrate=921600, bytesize=8, parity="N", stopbits=1, timeout=1, write_timeout=2, ) CHUNK = 1024 def send_image(data): total = len(data) seq = 0 for off in range(0, total, CHUNK): payload = data[off:off + CHUNK] if len(payload) % 2: # 保证像素对齐,不足补 0 payload += b"\x00" ser.write(build_packet(seq, payload)) ser.flush() # 等待驱动缓冲真正写出 seq += 1 time.sleep(0.002) # 给 MCU 留处理窗口,按实测调

time.sleep(0.002)是经验值,不是标准答案。921600 下一包 1024 字节线上时间约 11 毫秒,2 毫秒的间隙相当于留了 18% 的余量。如果 MCU 侧有 ACK 回传,最稳的做法是每包等 ACK 再发下一包,把节奏完全交给接收端;没有 ACK 时只能靠固定延时或者硬件流控。

硬件流控要慎用。很多 USB 转串口模块引出了 RTS/CTS,但杜邦线只接了 TX/RX/GND,流控引脚悬空,开了流控反而一包都发不出去。现象是ser.write不返回或者超时,这时候先检查线,再检查 MCU 侧有没有把 CTS 拉低。真要用流控,四根线都要接,而且 MCU 的串口要配置成硬件流控模式。

还有一个隐蔽的坑:部分模块在 2Mbps 以上标称支持,实际长时间连续发送会丢字节。判断方法是发一段已知的递增序列,MCU 侧收完比对,丢在哪个位置、丢多少,一目了然。不要一上来就怀疑协议解析。

4. STM32 侧:DMA 空闲中断收包与 LCD 窗口刷屏

4.1 环形缓冲配合空闲中断,先把字节收稳

STM32 的串口空闲中断(IDLE)在接收线上连续一个字节时间没有新数据时触发,正好用来切分“这一批数据到齐了”。配合 DMA 的循环模式,可以让串口接收全程不占用 CPU。HAL 库较新的版本提供了HAL_UARTEx_ReceiveToIdle_DMA,把 DMA 和 IDLE 封装在一起,比手写 IDLE 中断省事。

#define RX_DMA_SIZE 2048 static uint8_t rx_dma[RX_DMA_SIZE]; void uart_rx_start(void) { /* 循环模式 DMA + 空闲中断,接收长度由回调参数给出 */ HAL_UARTEx_ReceiveToIdle_DMA(&huart1, rx_dma, RX_DMA_SIZE); /* 关掉半传输中断,只在空闲和全满时进回调,减少中断频率 */ __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); } /* 回调里只做搬运,不做解析 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { ringbuf_push(rx_dma, Size); // 拷进软件环形缓冲 /* 重新武装,Size 是本次实际收到的字节数 */ } }

RX_DMA_SIZE取 2048 的理由是:一包 1024 字节加协议头约 1032 字节,两包的容量可以吸收上位机连续发送时的抖动。如果取 512,DMA 会在半包位置就触发全满,虽然不丢数据但中断更频繁。取太大则挤占 RAM,F103 上 2048 已经比较舒服。

HAL_UARTEx_RxEventCallback里只做ringbuf_push,把所有解析工作放到主循环。这一点很关键:回调运行在中断上下文,耗时长了会影响其他中断,也会让下一次 DMA 重新武装变慢,形成“越忙越丢”的正反馈。

环形缓冲用“写指针由中断推进、读指针由主循环推进”的单生产者单消费者模型,不需要关中断。缓冲区大小取 4096,能兜住至少三包突发。

4.2 协议解析状态机:寻头、取长、校验、分发

主循环从环形缓冲取字节,喂给一个状态机。状态机的好处是天然支持分包和粘包,不需要假设“一次读到的就是一整包”。

typedef enum { S_HEAD1, S_HEAD2, S_SEQ_L, S_SEQ_H, S_LEN_L, S_LEN_H, S_PAYLOAD, S_CRC_L, S_CRC_H } parse_state_t; static parse_state_t st = S_HEAD1; static uint16_t seq, plen, idx; static uint8_t payload[1100]; static uint16_t crc_calc, crc_recv; void proto_feed(uint8_t byte) { switch (st) { case S_HEAD1: if (byte == 0xAA) st = S_HEAD2; break; case S_HEAD2: st = (byte == 0x55) ? S_SEQ_L : S_HEAD1; break; case S_SEQ_L: seq = byte; st = S_SEQ_H; break; case S_SEQ_H: seq |= (uint16_t)byte << 8; st = S_LEN_L; break; case S_LEN_L: plen = byte; st = S_LEN_H; break; case S_LEN_H: plen |= (uint16_t)byte << 8; if (plen == 0 || plen > sizeof(payload)) { st = S_HEAD1; break; } idx = 0; st = S_PAYLOAD; break; case S_PAYLOAD: payload[idx++] = byte; if (idx >= plen) { crc_calc = 0xFFFF; st = S_CRC_L; } /* 简化示意 */ break; case S_CRC_L: crc_recv = byte; st = S_CRC_H; break; case S_CRC_H: crc_recv |= (uint16_t)byte << 8; if (crc_recv == crc_calc) img_on_packet(seq, payload, plen); st = S_HEAD1; break; } }

代码里crc_calc的完整计算要在S_PAYLOAD阶段同步累加,示意里省略了。plen > sizeof(payload)这个判断必须有,否则一个误码把长度字段变成 60000,后面的写操作直接越界,表现就是 HardFault 或者随机重启。这类问题在实验室里不一定复现,因为误码本身是概率事件,但现场跑久了必然遇到。

img_on_packet负责把 payload 按块写进 LCD。包序号seq用来判断连续性:如果收到的序号不是上一包加一,说明中间丢了,可以直接放弃这一帧,等上位机重发,而不是把错位的数据继续写进屏幕。屏幕上出现“一半正常一半斜纹”,基本都是丢包后没有重新同步。

4.3 LCD 窗口设置与块刷新:一次设窗,连续写 GRAM

以常见的 ILI9341 类控制器为例,写像素的流程是:发 0x2A 设列地址范围,发 0x2B 设行地址范围,发 0x2C 进入写 GRAM 状态,然后连续送像素数据,地址自动递增。

static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_cmd(0x2A); lcd_data16(x0); lcd_data16(x1); lcd_cmd(0x2B); lcd_data16(y0); lcd_data16(y1); lcd_cmd(0x2C); } /* 刷一个块:从 (0, y0) 开始,连续写 lines 行 */ void lcd_flush_block(uint16_t y0, uint16_t lines, const uint16_t *buf) { lcd_set_window(0, y0, LCD_W - 1, y0 + lines - 1); lcd_write_pixels_dma(buf, LCD_W * lines); lcd_wait_dma_done(); }

lcd_set_window只设一次,后面LCD_W * lines个像素连续写,控制器自己会换行。这比“每写一行设一次窗口”快得多,因为每次设窗口都有命令加四个参数字节的开销,30 行就是 30 次,累积起来不可忽略。

lcd_write_pixels_dma用 SPI DMA 发送,lcd_wait_dma_done等传输完成。等待期间主循环可以去处理环形缓冲里的下一包数据,形成“收一块、刷一块”的流水。如果块缓冲只有一块,必须等 DMA 发完才能覆盖,流水就会断;这就是后面第 6 章要说的双缓冲动机。

还有一个参数是 MADCTL(0x36 寄存器)。它控制行列扫描方向、RGB/BGR 顺序。图片上下颠倒,改 MY 位;左右镜像,改 MX 位;红蓝互换,改 BGR 位。这三个位组合起来有八种情况,先用一张纯红、纯绿、纯蓝的三色图测试,比拿复杂照片试错快得多。

5. 避坑:颜色反、画面斜、刷一半卡死的五种现场

5.1 现象:整屏偏紫、红色变蓝

原因:RGB565 的高低字节顺序或者 R/B 通道顺序和屏的预期不一致。上位机按r<<11 | g<<5 | b打包,小端发送,低字节先到;如果 MCU 侧按大端解释,或者屏的 MADCTL 配成了 BGR 模式,两个通道就换了位置,红色和蓝色互换,混出来的中间色自然偏紫。

解决:先只发一张纯红色图,观察屏上是红还是蓝。是蓝就改一处,不要在数据、字节序、MADCTL 三个地方同时改。优先改 MADCTL 的 BGR 位,因为改数据要重新生成整张图;如果屏不支持 BGR 切换,再回到上位机打开swap_rb。字节序问题则表现为颜色整体“错乱但不像换通道”,用两个字节的测试数据 0x1234 发下去,看 MCU 收到的是 0x1234 还是 0x3412,一测就清楚。

5.2 现象:图片上下颠倒或者左右镜像

原因:BMP 的像素数据默认自下而上,上位机按行读出来后直接发送,MCU 又按从上到下的顺序写 GRAM,两者方向相反。左右镜像则通常是 MADCTL 的 MX 位和上位机的fliplr同时生效,翻了两次等于没翻,或者只翻了一次但方向选错。

解决:固定一个翻转点。我的习惯是上位机只负责缩放和转 RGB565,不碰方向;所有方向调整都放在 MADCTL。这样换屏、换分辨率时只改一个寄存器。调试时做一张左上角有白色方块、其余全黑的图,方块出现在哪个角,方向就一目了然,比看照片快。

5.3 现象:画面出现固定角度的斜纹,越往下越歪

原因:丢包后没有重新对齐。串口丢了一个字节,后续所有字节的位置都前移一位,像素的奇偶配对被打乱,两个字节拼成一个像素时就变成相邻两个像素的混合色,一行 240 个像素累积下来呈现斜向的彩色条纹。包序号检查没做,或者做了但没有丢弃整帧,都会留下这个症状。

解决:在img_on_packet里检查seq是否连续,不连续就标记本帧作废、清空块缓冲、等待下一帧的起始包。同时在上位机侧加 ACK 或者加大包间延时,从源头降低丢包率。斜纹是“位置错”,不是“颜色错”,看到斜纹先查协议同步,不要先去调颜色。

5.4 现象:连续发送几十包后 MCU 卡死或重启

原因:三个高发点。一是 DMA 缓冲在回调里还没拷完就被下一次接收覆盖,环形缓冲的写指针跑过了读指针;二是解析状态机里长度字段越界,写坏了相邻变量;三是刷屏的 DMA 和接收的 DMA 抢同一个 DMA 控制器通道,或者中断优先级配置不当导致 SPI 传输被打断。

解决:先看 HardFault 发生的位置,用调试器读栈回溯,比猜快。环形缓冲加溢出标志,写指针追上读指针时置位并在主循环里打印计数,能直接确认是不是缓冲问题。长度字段做上限检查,这个前面已经写了,属于必须有的防御。DMA 通道冲突则要对照芯片手册的请求映射表,接收和发送尽量用不同控制器,中断优先级上串口接收可以略高于 SPI。

5.5 现象:前几行正常,后面颜色逐渐变淡或者出现噪点

原因:电源或者信号完整性问题。SPI 时钟偏高、杜邦线过长、屏的背光和逻辑共用一路电源,刷屏瞬间电流突增把电压拉低,后半段数据出错。也有可能是 SPI 时钟相位配置在高速下不匹配,低速能跑、高速出噪点。

解决:先把 SPI 时钟降到 10MHz 试,如果噪点消失,就是时序或者走线问题,换短线、加去耦电容、降低时钟三选一。如果降频无效,再查电源,用示波器看刷屏瞬间的 3.3V 有没有跌落。这类问题容易被误判成“图片数据错”,实际上数据在逻辑分析仪上是对的,错在电气层。

6. 从 0.3 帧到 3 帧:双缓冲、行刷新与压缩的取舍

单缓冲的流程是“收满一块、等 DMA 刷完、再收下一块”,串口接收和 LCD 刷新严格串行,实际帧率往往只有理论值的一半。想再快,第一步不是换芯片,是把这两段流水并行起来。

双缓冲的做法是开两块块缓冲,A 块在刷屏时 B 块继续收数据,收满后交换。RAM 开销翻倍,32 行两块就是 30KB,F103 的 48KB 版本会比较紧,可以把块高降到 16 行,两块共 15KB。切换时用一个标志位加临界区保护,避免主循环和 DMA 完成回调同时改指针。

static uint16_t buf_a[LCD_W * 16]; static uint16_t buf_b[LCD_W * 16]; static uint16_t *volatile fill_ptr = buf_a; static uint16_t *volatile draw_ptr = NULL; static volatile uint8_t block_ready = 0; /* 收满一块后调用 */ void block_switch(void) { if (draw_ptr == NULL) { /* 刷屏空闲,直接交换 */ draw_ptr = fill_ptr; fill_ptr = (fill_ptr == buf_a) ? buf_b : buf_a; block_ready = 1; } }

这段的关键是draw_ptr == NULL判断,刷屏忙的时候不交换,宁可等下一块,也不要覆盖正在发送的缓冲。volatile不能省,否则编译器可能把判断优化掉。

第二步是减少窗口设置次数。如果屏的 GRAM 支持整屏连续写,可以一开始设一次全屏窗口,之后所有块按顺序追加,省掉每块四到六个命令字节。这个优化在小块高频刷新时收益明显,32 行一块、一帧 10 块,就省了 10 次窗口设置。

第三步才是压缩。RLE 对卡通图、界面图效果很好,对照片效果一般。实现上在上位机做游程编码,MCU 侧解码后写 GRAM,解码逻辑简单,但压缩率不稳定,同一套参数在不同图片上差异可能从 2 倍到 0.8 倍。更稳的方案是降低色深,比如把 RGB565 降到 RGB332,传输量直接砍半,代价是颜色过渡出现明显阶梯。是否值得,取决于画面内容:界面类可以接受,照片类通常不行。

手段RAM 增量传输量变化实现复杂度适用场景
双缓冲约 15KB不变低所有连续刷图场景
单次全屏设窗0略降低块刷新频繁时
RLE 压缩解码缓冲 1KB 左右视图片,1 到 3 倍中界面图、卡通图
RGB3320减半低颜色要求不高的界面

验证手段也要跟上。别只看“刷得快了”,要量化:在刷屏前后翻转一个空闲 GPIO,用逻辑分析仪测一帧的起止时间;同时统计一秒内收到的完整包数和丢弃包数,两个数对得上,才说明提速没有以丢帧为代价。我曾经为了把帧率从 0.8 提到 2.5,把包间延时压到 0,示波器上看确实快了,但丢包计数也在涨,实际画面反而更花。从那以后我每次调完发送节奏,都强制走一遍“连续发 50 帧、比对丢包计数和 CRC 错误数”的流程,两个数都为 0 才认为这组参数可用。希望帮到你。

本文还有配套的精品资源,点击获取