ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32驱动TFTLCD:FSMC并口时序、初始化与DMA刷屏优化

STM32驱动TFTLCD:FSMC并口时序、初始化与DMA刷屏优化

简介:面向STM32初学者的TFTLCD显示实验程序,依托ALIENTEK MINISTM32实验10整理,围绕GPIO与SPI/I2C时序配置、驱动芯片初始化、显示缓冲区管理、绘图函数和刷屏流程展开,帮助读者理解嵌入式图形显示从底层接口到屏幕更新的完整链路。压缩包共40个文件,以18个.h头文件和11个.c源文件为主体,辅以工程配置、Hex文件、启动汇编、说明文本和批处理脚本,整体约87KB,目录按USER、HARDWARE、SYSTEM等模块组织,LCD、OLED、KEY、EXTI、TIMER、LED、WDG及delay、sys、usart等代码便于按功能查阅。已有5190人学习,亲测可用的工程可作为课程实验、开发板调试和显示驱动入门的参考,通过对照源码能快速梳理外设初始化、命令数据传输与屏幕刷新之间的关系,减少搭建验证环境时的重复摸索。

1. 白屏那十分钟里,STM32 到底在等什么

把一块 240×320 的并口 TFTLCD 接到 STM32 上,通电那一刻大概率是白屏,背光亮着、屏上什么都没有——这时候很多人第一反应是屏坏了,其实九成是时序和初始化序列没对上。这份 TFTLCD 显示程序要解决的正是这一段:从 FSMC 或 GPIO 模拟的 8080 并口时序、上电复位延时、控制器初始化表,到画点、填块、字符显示和刷屏优化,一整条链路能跑通并且可移植。它适合刚拿到屏和板子、想先把图形界面底盘打稳的人,也适合已经把屏点亮但刷得慢、颜色不对、局部刷新有撕裂的熟手。下面按接线、初始化、绘图、排查、加速的顺序拆。

2. 接口与时序怎么选:FSMC 并口、GPIO 模拟差在哪

屏能不能点亮,一半取决于接口方案选得对不对。240×320 分辨率、每像素 2 字节,一屏就是 153600 字节;如果用 GPIO 逐位翻转搭 8080 并口,一条写数据指令可能就要占用十几个 CPU 周期,刷一整屏的耗时能到几百毫秒,动画根本谈不上。FSMC(部分系列叫 FMC)的价值就在这儿:它把并口写操作变成一次内存写指令,CPU 只管往固定地址丢数据,读写时序由硬件产生,CPU 不用空转等。

2.1 8080 并口真正需要关心的只有四组信号

别看屏的排线有十几二十根,按功能拆开其实就四组:片选 CS、数据/命令选择 DC(有的丝印写 RS)、写使能 WR、读使能 RD,加上 8 位或 16 位数据总线。控制器的内部逻辑非常简单——WR 上升沿采样总线,此刻 DC 为低就当成命令,DC 为高就当成数据。理解了这一条,后面所有时序参数都是在保证“采样那一刻总线上数据是稳的”。

时序上真正决定成败的是三个参数:地址建立时间、数据建立时间、地址保持时间。数据建立时间不够,控制器采到的就是上一位的残留值,表现出来就是花屏、颜色串位;地址建立时间不够,DC 和 CS 还没到位数据就来了,表现为偶发写飞、初始化表执行到一半就卡死。这种问题最玄学的地方在于:示波器上看着波形都在,但换一块同型号的屏又不一定复现,所以参数宁可一开始放宽一点,跑通之后再往回压。

我一般的做法是先按“慢速可靠”的参数把屏点亮,确认初始化序列没问题,再逐档压缩数据建立时间,每压一档就全屏填充一次看有没有噪点。这个方法比对着示波器一点点量要快得多,也更贴近实际使用条件。

2.2 把 DC 接到地址线上:FSMC 地址映射的真实含义

FSMC 最反直觉的设计是:它没有专门的“命令/数据”引脚,而是把 DC 挂在某根地址线上,靠访问不同地址来区分。原理是 FSMC 访问 16 位位宽存储器时,内部地址 HADDR[25:1] 映射到 FSMC_A[24:0],也就是说 FSMC_A[k] 对应字节偏移 2^(k+1)。

所以如果 DC 接在 FSMC_A18 上:

DC 接的地址线字节偏移宏定义写法
A160x20000LCD_BASE + 0x20000
A170x40000LCD_BASE + 0x40000
A180x80000LCD_BASE + 0x80000

这份账算错的后果很典型:往命令地址写数据时屏上出来了乱码,或者写入完全没反应。因为偏移不对,DC 线电平和你以为的正好相反。

/* 假设 LCD 挂在 FSMC Bank1 / NE1 上,DC 接 FSMC_A18,16 位数据宽度 */ #define LCD_BASE ((uint32_t)0x60000000UL) #define LCD_REG (*(volatile uint16_t *)(LCD_BASE)) /* DC = 0,写命令 */ #define LCD_DATA (*(volatile uint16_t *)(LCD_BASE + 0x80000UL)) /* DC = 1,写数据 */

这段宏是整套驱动的基础。volatile不能省,否则编译器优化时会把连续写合并或删除;uint16_t指针保证一次访问正好是一个 16 位总线周期,写成uint32_t会一次发出两个周期,命令和数据就错位了。如果你的板子用的是 8 位并口,偏移要改成 2^k,同时指针类型换成uint8_t。

FSMC 初始化部分,不同系列在 HAL 库里前缀不一样,F1/F2 叫FSMC_,F4/H7 叫FMC_,宏名基本一一对应:

SRAM_HandleTypeDef hsram; FSMC_NORSRAM_TimingTypeDef timing; hsram.Instance = FSMC_NORSRAM_DEVICE; hsram.Extended = FSMC_NORSRAM_EXTENDED_DEVICE; hsram.Init.NSBank = FSMC_NORSRAM_BANK1; hsram.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; hsram.Init.MemoryType = FSMC_MEMORY_TYPE_SRAM; hsram.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; hsram.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; hsram.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; hsram.Init.WrapMode = FSMC_WRAP_MODE_DISABLE; hsram.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS; hsram.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE; hsram.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE; hsram.Init.ExtendedMode = FSMC_EXTENDED_MODE_ENABLE; hsram.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE; hsram.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE; hsram.Init.ContinuousClock = FSMC_CONTINUOUS_CLOCK_SYNC_ONLY; timing.AddressSetupTime = 2; /* 地址建立,FMC 时钟周期数 */ timing.AddressHoldTime = 1; timing.DataSetupTime = 6; /* 数据建立,先给足,跑通后再压 */ timing.BusTurnAroundDuration = 0; timing.CLKDivision = 0; timing.DataLatency = 0; timing.AccessMode = FSMC_ACCESS_MODE_A; HAL_SRAM_Init(&hsram, &timing, &timing);

参数说明里最值得动手的就是DataSetupTime。它是按 FMC 时钟周期计的,HCLK 168MHz 时一个周期约 6ns,写 6 大约是 36ns,对多数控制器的写周期(通常要求不小于 50ns 左右的 WR 脉宽)是安全的。跑通之后可以试着压到 3 甚至 2,得到更高的刷屏带宽;如果压完出现雪花点,说明这块屏的时序余量到头了,老老实实回退一档。AddressSetupTime影响的是 DC 和 CS 的建立裕量,通常和 CD 的极性配合使用,给 1 到 2 就够。

2.3 GPIO 模拟版的落地写法与适用边界

不是所有板子都空闲出 FSMC 引脚,比如引脚本来就紧张的 48 脚封装。这时候就得用 GPIO 模拟版。它的写法思路是:把 8 位或 16 位数据口整组配置成推挽输出,用位带或者寄存器直接赋值代替逐位操作。

/* 以 8 位并口为例,数据口用一组连续的 GPIO 引脚 */ static inline void LCD_WR_CMD(uint8_t cmd) { LCD_DC_LOW(); /* DC = 0,表示命令 */ GPIOB->ODR = (GPIOB->ODR & 0xFF00) | cmd; /* 低 8 位灌数据,其余位保持 */ LCD_CS_LOW(); LCD_WR_LOW(); /* WR 拉低 */ __NOP(); __NOP(); /* 给控制器一点采样窗口 */ LCD_WR_HIGH(); /* 上升沿锁存 */ LCD_CS_HIGH(); } static inline void LCD_WR_DATA(uint8_t data) { LCD_DC_HIGH(); /* DC = 1,表示数据 */ GPIOB->ODR = (GPIOB->ODR & 0xFF00) | data; LCD_CS_LOW(); LCD_WR_LOW(); __NOP(); __NOP(); LCD_WR_HIGH(); LCD_CS_HIGH(); }

这段代码的核心是ODR的读-改-写:& 0xFF00保留高 8 位,| cmd写入低 8 位,一次操作完成整字节输出。__NOP()的数量决定了 WR 低电平持续多久,具体要看主频和屏的时序手册,主频高的时候需要加,主频低的时候可以省。

GPIO 模拟的实际测速参考:72MHz 主频、8 位并口、每次写约 8 到 10 个周期,折合单像素写入约 0.12μs,全屏填充 76800 像素约 9ms 到 15ms。数字看着还行,但实际画图时中间还要夹窗口设置、坐标计算,实际会到 20ms 以上。画小图标、刷新几个字符完全够用,做动画或滚动列表就明显不够了。我的判断标准是:如果项目里需要整屏刷新频率高于每秒 30 次,直接上 FSMC,别在 GPIO 上想办法优化。

3. 初始化序列与读 ID:让屏先承认自己在线

屏点亮不了的第二大原因是初始化序列。网上一搜能搜到七八套看起来差不多的表,但直接抄往往不生效,因为不同批次屏的控制器、偏压参数、伽马曲线可能完全不一样。这一章讲清楚初始化表里哪些是真必需的、哪些可以放、以及怎么在写代码之前先用读 ID 的方式确认自己在跟谁打交道。

3.1 上电、复位与延时:省掉的每一个毫秒都会反咬一口

控制器上电之后并不是立刻就能接收命令的。内部的电荷泵需要时间建立稳定的偏压,VCC 和 VDDI 的爬升速度又受外部电容影响。手册里普遍要求上电后至少延时 5ms 到 120ms 才能发第一条指令,这个区间看着很宽,但实际项目里往往卡在短的那一端出事:冷启动能用,掉电重启就白屏。

复位时序更简单也更要命:RES 拉低至少 10μs,拉高之后还要等 120ms 左右,控制器才会完成内部寄存器复位。很多人写HAL_Delay(1)就往下走,结果只有在室温下能跑。

void LCD_HardwareReset(void) { LCD_RST_LOW(); HAL_Delay(20); /* 远大于手册要求的 10us,留足余量 */ LCD_RST_HIGH(); HAL_Delay(120); /* 复位后等内部逻辑稳定,别改小 */ }

注意:上电延时和复位后的等待,用示波器看波形是看不出问题的,它影响的是内部电荷泵和寄存器状态。这类问题最典型的症状就是“放一晚上第二天开机不亮,按一下复位又好了”。

3.2 写命令 / 写数据的分层:封装一旦顺手,后面全都省事

驱动层的骨架就是把“写命令”和“写数据”两件事封装干净,其余全部基于这两个函数搭建。分层清楚的好处是:换接口(FSMC 换 GPIO 模拟)只改这两个函数,上层绘图代码一行不动。

static inline void LCD_WriteCmd(uint16_t cmd) { LCD_REG = cmd; /* FSMC 方案:直接写命令地址 */ } static inline void LCD_WriteData(uint16_t dat) { LCD_DATA = dat; /* FSMC 方案:直接写数据地址 */ } /* 批量写同样数据,填充矩形/清屏用得上 */ void LCD_WriteDataRepeat(uint16_t dat, uint32_t count) { while (count--) { LCD_DATA = dat; /* 无循环内判断以外开销,编译器会展开优化 */ } }

LCD_WriteDataRepeat这个函数看着废话,但它的实际作用不小:把“写一个像素”的循环写在驱动层,编译器有机会把LCD_DATA = dat优化成寄存器写加地址自增,比在应用层写for循环快 10% 到 20%。如果编译器的优化级别是-O2以上,效果更明显。

初始化表本身长这样,参数值需要按你手上的屏手册来改:

typedef struct { uint8_t cmd; uint8_t len; /* 后跟的参数个数 */ uint8_t para[16]; /* 参数缓存 */ uint16_t delay_ms; /* 本条执行后需要等待的时间 */ } LCD_InitCmd_t; const LCD_InitCmd_t LCD_InitTable[] = { {0x01, 0, {0}, 120}, /* 软复位,之后必须等 */ {0x11, 0, {0}, 120}, /* 退出睡眠,等电荷泵建立 */ {0x36, 1, {0x08}, 0}, /* 扫描方向 + BGR 顺序,后面细说 */ {0x3A, 1, {0x55}, 0}, /* 像素格式:16 位 RGB565 */ {0x2A, 4, {0x00,0x00,0x00,0xEF}, 0}, /* 列地址范围 0~239 */ {0x2B, 4, {0x00,0x00,0x01,0x3F}, 0}, /* 行地址范围 0~319 */ {0x29, 0, {0}, 20}, /* 打开显示,最后才发 */ };

表里三条是必需的:软复位(0x01)、退出睡眠(0x11)、打开显示(0x29),最后一条必须排在所有配置之后,否则屏幕会在配置过程中短暂显示乱码。像素格式(0x3A)设成 0x55 表示 16 位 RGB565,这个和后面绘图函数的颜色编码必须一致,设成 0x66 就是 18 位,代码里所有颜色值都得跟着改。delay_ms字段不能省,尤其是 0x11 之后的那 120ms,这是电荷泵建立时间,省了就会出现亮度不均或者局部发暗。

3.3 用读 ID 做上电自检:先确认对面是谁

写初始化之前先读一次控制器 ID,是省时间的好习惯。读到了预期值,说明接线、时序、DC 地址映射这三件事都对了;读不到或者读到全 0、全 F,问题一定在前三步,这时候再去调初始化表纯属浪费时间。

uint16_t LCD_ReadID(void) { uint16_t id; LCD_WriteCmd(0x04); /* 读显示 ID 指令,部分控制器用 0xD3 */ LCD_ReadData(); /* 空读一次,丢弃首个无效字节 */ id = (LCD_ReadData() << 8) & 0xFF00; id |= LCD_ReadData() & 0x00FF; return id; } void LCD_SelfCheck(void) { uint16_t id = LCD_ReadID(); if (id == 0x0000 || id == 0xFFFF) { /* 全 0 / 全 F 基本等于总线没通,先查 CS、WR、DC 和地址偏移 */ LCD_ErrorHandler(); } }

三个细节值得说清楚。第一,读之前必须先把 FSMC 配成支持读的模式,扩展模式下读写时序是分开配置的,只配写时序读回来永远是 0。第二,第一个字节要丢弃,因为控制器需要一拍时钟把数据推到总线上,这一拍读到的是上一次总线残留。第三,不是所有控制器都支持 0x04,另一批用 0xD3,返回 4 个字节,实际有效的是后两个。常见做法是两套都试一次,哪个返回的不是 0x0000/0xFFFF 就用哪个。

4. 绘图底层:从画点、填块到字符的三层结构

屏点亮之后,真正拉开体验差距的是绘图层的组织方式。同样是画一个 100×100 的矩形,有人写 10000 次画点函数,有人设一次窗口连续灌 10000 个像素,耗时能差三倍以上。这一章按“窗口 → 画点 → 填块 → 字符”的顺序把层搭起来,每一层只依赖下一层的接口。

4.1 窗口设置:刷屏性能的分水岭

控制器内部有一块 GRAM,显示的时候是扫描电路在自动读 GRAM 送屏。CPU 写 GRAM 时,写入位置由列地址和行地址寄存器决定,每写一个像素自动递增,碰到列尾回到行首。所以设置一次窗口(0x2A 设列范围、0x2B 设行范围),后面连续写 0x2C 就相当于往一个矩形区域里顺序灌数据,地址自增由硬件做。

void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WriteCmd(0x2A); /* 列地址设置 */ LCD_WriteData(x0 >> 8); LCD_WriteData(x0 & 0xFF); LCD_WriteData(x1 >> 8); LCD_WriteData(x1 & 0xFF); LCD_WriteCmd(0x2B); /* 行地址设置 */ LCD_WriteData(y0 >> 8); LCD_WriteData(y0 & 0xFF); LCD_WriteData(y1 >> 8); LCD_WriteData(y1 & 0xFF); LCD_WriteCmd(0x2C); /* 之后的数据都写进这个窗口 */ }

参数范围一定要做边界钳位,x1超过 239 或者y1超过 319,控制器会直接把地址绕回到 0,表现出来就是画到屏幕右边的图形从左边缘冒出来,找 bug 的时候极易误判成线圈错位。

注意:窗口设置和后续数据写入之间不要再插其他命令,包括读状态。中间插一条指令,控制器的写入指针就会重置,前面设的窗口作废。

4.2 画点到填块:把重复的坐标计算砍掉

最底层的画点函数必须遵守“先设窗口,再写一个像素”的顺序:

void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { LCD_SetWindow(x, y, x, y); /* 窗口退化为 1x1 */ LCD_WriteData(color); }

单个画点其实很慢——一次画点要发三条命令加五个数据,总线上二十多个周期就没了。所以画线、画矩形、填充这些操作绝不能循环调用它。正确的做法是填块:

void LCD_FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { uint32_t total = (uint32_t)w * h; if (x >= LCD_W || y >= LCD_H) return; if (x + w > LCD_W) w = LCD_W - x; /* 裁剪到屏幕内 */ if (y + h > LCD_H) h = LCD_H - y; total = (uint32_t)w * h; LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_WriteDataRepeat(color, total); }

这段代码里裁剪的两个if看着不起眼,但它决定了绘图函数能不能安全处理越界坐标。实际项目里 UI 元素带圆角、带阴影,坐标算出来轻微越界太正常了,没有裁剪的话,轻则画出屏幕,重则触发地址绕回,把另一块区域刷掉。

total用uint32_t是必须的。w * h最大到 240×320 = 76800,已经超过uint16_t上限,虽然这里是 16 位乘法不会溢出,但一旦屏换成 320×480,uint16_t就炸了,静默刷一半就停,很难查。

有了填块,画线就是一组短填块或者 Bresenham 画点,画空心矩形是四条填块,画实心圆是按行算弦长再填块。实测在 FSMC 方案下,全屏 240×320 填充耗时约 4ms 到 6ms,用画点循环拼则要 30ms 以上,差距就是这么来的。

4.3 字符与汉字:字库、取模与半透明叠加

字符显示分两派:ASCII 用点阵字库,汉字用取模软件生成的数组。ASCII 常用 8×16 或 12×24,字库存成const uint8_t ascii_8x16[95][16],每个字符 16 字节,按列或按行扫描。汉字用 16×16 点阵,一个汉字 32 字节。

/* 按行扫描的 16x16 汉字点阵,每行 2 字节 */ void LCD_ShowChinese16(uint16_t x, uint16_t y, const uint8_t *font, uint16_t color, uint16_t bg) { for (uint8_t row = 0; row < 16; row++) { uint16_t bits = (font[row * 2] << 8) | font[row * 2 + 1]; for (uint8_t col = 0; col < 16; col++) { if (bits & (0x8000 >> col)) { LCD_DrawPoint(x + col, y + row, color); } else if (bg != COLOR_TRANSPARENT) { LCD_DrawPoint(x + col, y + row, bg); } } } }

这段是最直观的实现,但性能很差——16×16 一个汉字要调用 256 次画点。实用版本要做两件事:一是把 16 行合并成一次窗口设置,同一行的 16 位一次性写成 16 个像素;二是把颜色提取出来预先生成一个 16 像素的缓冲区,一次性灌进去。改完之后单个汉字的耗时能压到 200μs 以内。

bg传COLOR_TRANSPARENT时只画前景点,这就是所谓叠字效果,做弹出菜单、提示框这类需要压在半透明背景上的场景会用到。判断透明色不要用==比较浮点或者魔法值,定义一个明确常量并在编译期固定下来。

字库占用的 Flash 也要算清楚:常用 3500 个汉字 × 32 字节 ≈ 112KB,加上 ASCII 部分,对 Flash 只有 64KB 的型号来说根本放不下。常见做法是外挂 SPI Flash 存字库,运行时按区位码查表读取;或者只放项目里实际用到的那几十上百个字。

5. 避坑与排查:花屏、偏色、闪屏的五条血泪记录

屏调不出来的时候,症状往往长得很像,但根因分布在完全不同的层。这一章按“现象 → 原因 → 解决”的格式,记录五个踩过并排查过的问题,遇到新问题时对照症状比盲改代码快得多。

5.1 背光亮着,屏上什么都没有,读 ID 全是 0xFF

现象:上电后背光点亮,屏上保持纯白或纯黑,串口打印读回来的 ID 恒等于 0xFFFF,换初始化表也没用。

原因:数据总线没有真正接到位,或者 CS 一直处于无效状态。FSMC 的片选是硬件自动拉低的,但 GPIO 模拟方案里如果 CS 被误配置成浮空输入,控制器永远收不到有效片选。

解决:万用表量一遍 CS、WR、DC 三根线,静置状态下 CS 应为高、WR 应为高、DC 状态取决于当前操作。然后确认 FSMC 的 NE 编号和代码里用的 Bank 一致——NE1 对应FMC_NORSRAM_BANK1,接 NE2 却写 BANK1 是高频错误。最后确认读时序也配了,只配写时序读回来必然是 0xFF。

5.2 屏幕有内容但满屏雪花点,静止图像也有噪点

现象:能看出图形轮廓,但整屏覆盖细密噪点,像信号干扰。把数据建立时间调大之后噪点减少但不消失。

原因:典型的时序余量不足。数据建立时间不够,或者主频刚调高没重新算 FSMC 参数。另外电源纹波大也会造成类似现象,尤其是屏和板子共用一路 3.3V、背光升压又在附近时。

解决:把DataSetupTime从当前值翻倍,如果噪点明显减少就说明方向对,逐档压回去找到临界点再留一档余量。如果翻倍后毫无改善,转去查电源:给屏的 VCC 并一个 10μF 加 100nF,背光走独立供电,噪声常常就消失了。

5.3 红色画出来是蓝色,颜色整体反了

现象:所有颜色看起来都“对调”了,红蓝互换,或者整屏是负片效果。

原因:两个地方会出这个问题。一是初始化表里 0x36(内存访问控制)的 BGR 位没设对,RGB 和 BGR 顺序颠倒;二是像素格式和代码里的颜色编码不匹配,控制器按 RGB565 接收,代码里按 BGR565 拼。

解决:先改 0x36 的 bit3。这一位是 BGR 顺序开关,取反试一次,如果颜色正常了就是它。如果改完颜色还是偏,检查颜色宏本身:#define RED 0xF800在 RGB565 下是红色,如果屏是 BGR 顺序,同样的值会显示成蓝色。统一在一处定义颜色宏,不要在各个绘制函数里手写十六进制。

5.4 图形沿对角线错位,或者上下左右镜像

现象:画一个矩形,显示出来是斜的平行四边形;或者坐标 (0,0) 显示在右下角。

原因:0x36 寄存器的扫描方向位(MY、MX、MV)设置和实际使用方式不匹配。这三个位控制行列扫描方向和交换,不同屏厂默认值不一样。

解决:先把 0x36 设成 0x00,画一个原点在左上角的矩形,看它出现在哪个角。出现在左上角说明不用改;出现在右上角,把 MX 位置 1;出现在左下角把 MY 位置 1。MV 位是行列交换,一般只在横竖屏切换时用。横屏模式除了改 MV,还得同步交换列地址和行地址的范围(0x2A 从 0~239 改成 0~319,0x2B 反过来)。

5.5 局部刷新时屏幕闪烁,或者刷新区域边缘有拖影

现象:整屏刷新看不出来,一旦只更新一小块区域(比如进度条、数字),那块区域会闪一下,或者边缘残留上一帧的像素。

原因:窗口没设对导致写入越界到相邻区域,或者刷新时先清背景再画前景,两次写 GRAM 之间被扫描电路显示出来了,就形成闪烁。

解决:第一,检查窗口设置里的坐标是不是包含右边界和下边界,习惯上x1和y1要传x + w - 1而不是x + w,多传一个像素就写到了相邻行首。第二,局部刷新不要把“先填充背景色再画内容”拆成两次窗口操作,合并成一次:先算好这块区域的完整像素内容放进缓冲区,一次窗口写完。这样扫描电路看到的永远是完整帧,不会有中间态。

6. 进阶:用 DMA 搬像素,并且真的量出快了多少

前面所有优化都是在“CPU 亲自搬数据”这个前提下做的,再压也压不出质变。真正能把刷屏速度提一个档的做法是把像素搬运交给 DMA:源地址是内存里的帧缓冲,目的地址是LCD_DATA这个固定地址,DMA 配成存储器到外设、地址不自增、传输宽度按半字。

void LCD_BlitDMA(uint16_t x, uint16_t y, uint16_t w, uint16_t h, const uint16_t *buf) { LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_WriteCmd(0x2C); /* 先切到写 GRAM 状态 */ /* 外设地址固定为数据口,内存地址自增,半字宽度 */ HAL_DMA_Start(&hdma_mem2mem, (uint32_t)buf, (uint32_t)&LCD_DATA, (uint32_t)w * h); /* 等本次传输完成,避免下一帧覆盖缓冲区 */ while (__HAL_DMA_GET_FLAG(&hdma_mem2mem, DMA_FLAG_TC) == RESET) { } __HAL_DMA_CLEAR_FLAG(&hdma_mem2mem, DMA_FLAG_TC); }

三个参数点必须对上,否则数据会半截错位。第一,DMA 传输宽度要和 FSMC 的存储器位宽一致,16 位并口必须用半字模式,用字节模式会出现高低字节颠倒。第二,外设地址不自增,因为所有像素都写同一个LCD_DATA;内存地址自增,因为缓冲区里是连续的。第三,源地址和目的地址的数据宽度要匹配,帧缓冲用uint16_t数组,DMA 配置填DMA_PDATAALIGN_HALFWORD | DMA_MDATAALIGN_HALFWORD。

缓冲区大小要算清楚:240×320×2 字节 = 150KB。这个量级只有带外部 SRAM 或者 256KB 以上内部 RAM 的型号才放得下。内存不够就退到“双小块缓冲”方案:开两块 240×16 的缓冲区,一块给 DMA 搬,一块给 CPU 画,乒乓切换。这样既能把 CPU 和总线重叠起来,占用也只有 15KB。

加速效果到底多少,别信感觉,量出来。用 DWT 周期计数器最省事:

static inline uint32_t DWT_GetCycles(void) { return DWT->CYCCNT; } void LCD_Benchmark(void) { uint32_t t0, t1; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 使能周期计数器,别忘开 */ t0 = DWT_GetCycles(); LCD_FillRect(0, 0, 240, 320, COLOR_BLACK); t1 = DWT_GetCycles(); printf("fill 240x320: %lu cycles\r\n", (unsigned long)(t1 - t0)); }

在 168MHz 主频下,我手上这套 FSMC 配置跑出来的结果是:LCD_WriteDataRepeat整屏填充大约 4.8ms,DMA 方案大约 3.1ms,提升约 35%。提升幅度没有想象中大,因为瓶颈已经从 CPU 转移到了总线时序本身——数据建立时间 6 个 FMC 时钟周期,每个像素的物理下限就在那里摆着。想再快只能压时序参数或者提高 FMC 时钟,而这两条都会撞上屏的物理极限。

另外一个容易被忽略的收益是 CPU 占用。DMA 搬运期间 CPU 是空闲的,可以把这段时间用来算下一帧的内容或者处理按键。做简单动画时,这一点比那 1.7ms 的绝对时间更重要。

测速这件事我后来形成了固定习惯:任何一块新屏接入,先把填充、画线、单字显示这三个基准测一遍,把数字记在项目文档里。换屏、改主频、改时序参数之后重测一次对比,数值突然变差就说明某处配置被改坏了。这个习惯救过我两次——一次是同事把 FMC 时钟配置改回了默认值导致刷屏慢了一倍,另一次是 DMA 配置被误改成了字节模式,画面高低字节颠倒,靠基准数字十分钟就定位到了原因。希望帮到你。

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

返回列表