前面三篇实验,我们分别用GPIO模拟和串行方式把LCD点亮了,但有个问题始终让人心里不踏实——刷屏太慢。画一张240×320的纯色图,肉眼能看见刷新过程,像老式电视机的滚屏;稍微做点动画,基本没法看。所以这一篇我决定上FSMC,把LCD当成一块挂在总线上的内存,一条赋值语句就是一次并口写入。这篇主要记录FSMC驱动LCD的思路、关键配置、代码骨架,还有几个让我折腾到半夜的坑。如果你也卡在"GPIO刷屏太慢"这一步,这篇应该能帮上忙。
1. 为什么第四篇一定要上FSMC:先从性能账讲起
1.1 GPIO模拟方式的时间到底浪费在哪
GPIO模拟并口的思路很直接:把LCD的D0~D15接到单片机的16个普通IO,然后用程序控制电平,先拉低片选,设置RS表示当前是命令还是数据,再把16个引脚的电平逐位设置,最后拉低再拉高写信号WR,完成一次写入。
这里面每一步都有时间开销。设置一个引脚电平,哪怕用寄存器直接操作,也要几条指令;16个引脚的电平得一轮一轮地敲;再加上为了满足LCD控制器的建立时间、保持时间,往往还得插入几个空循环延时。算下来,一次16位写入大概要1~5微秒,具体看优化水平和有没有用延时。
刷一屏240×320的RGB565画面,数据量是240×320×2=153600字节,按每次写2字节算,大约要76800次写操作。就算每次写压缩到2微秒,一屏也要150毫秒以上。这个速度放在静态图片上还能忍,放到动画上就是灾难——一秒钟最多六七帧,而且CPU全程被占死,什么都干不了。
1.2 FSMC的"总线式写入"和内存映射思想
FSMC的全称是灵活的静态存储控制器,它天生就是用来和SRAM、NOR Flash这类并行存储器打交道的。它的核心思想,是把外部设备映射到单片机的地址空间里。
这么说吧:普通GPIO方式是你自己一个人搬砖,一块一块搬;FSMC方式是你修了一条传送带,把砖放到传送带入口,机器自动把砖送到指定位置。你不需要关心传送带的每一个轮子怎么转,只需要告诉它"这一批砖放哪儿"。
具体到操作,就是往某个内存地址写一个值。FSMC外设会自动把这次写操作翻译成一套完整的并口时序:地址建立、片选拉低、写信号拉低、数据锁存、写信号拉高、片选拉高。你的程序里只看到一条赋值语句,而波形层面已经完成了一次标准的LCD并口写周期。
这种"内存映射"的思路,是FSMC最值钱的地方,也是后面所有配置的出发点。如果你能理解"写外部设备=写内存地址",FSMC就学会了一半。
1.3 性能账对比:一屏数据两种方式的量级差距
我把两种方式的耗时做了个粗略对比,前提是72MHz主频:
| 方式 | 单次16位写入时间 | 刷一屏240×320耗时光算 |
|---|---|---|
| GPIO模拟+延时 | 约2微秒 | 约150毫秒 |
| FSMC+DATAST=9 | 约0.17微秒 | 约13毫秒 |
| FSMC+DATAST=3 | 约0.08微秒 | 约7毫秒 |
注意这里光算的是数据搬运时间,没算循环指令本身的开销,但趋势已经很明显:FSMC方式比GPIO方式快了至少一个数量级。而且关键的一点是,FSMC写入过程中CPU只要发起一次写操作,剩下的事情由硬件完成,CPU负载大大降低。对于想要做GUI、做简单动画、甚至双缓冲的应用,这个差距就是"能用"和"不能用"的区别。
2. FSMC与LCD的硬件连接和地址映射:A0这根线是灵魂
2.1 引脚分配:数据线、控制线、地址线怎么接
FSMC驱动LCD的硬件连接有一个标准套路。以STM32F103ZET6这类大容量型号为例,FSMC的引脚分布大致是:
- 数据线:PD0~PD15对应D0~D15,PE7~PE15也有一部分数据线,具体看你板子上的引出情况
- 写使能:PD5对应FSMC_NWE,接LCD的WR脚
- 读使能:PD4对应FSMC_NOE,接LCD的RD脚
- 片选:PD7对应FSMC_NE1,接LCD的CS脚
- 地址线:我常用FSMC_A0,即PD11,接LCD的RS脚(有的模块叫DC脚)
- RESET:随便用一个普通GPIO,比如PB0
- 背光:普通GPIO控制或直接接3.3V串限流电阻
这里有一个高频疑问:为什么RS不直接用一个普通GPIO,非要接FSMC地址线?原因很简单——如果用普通GPIO控制RS,那么每次写命令和写数据之前,都要先操作一次GPIO来切换RS状态,这又回到了"用软件做每一件事"的套路,FSMC的硬件自动时序优势就没了。
把RS接到地址线A0上,就可以通过不同的内存地址来区分命令和数据。写命令时访问地址0;写数据时访问地址2。同一个片选、同一套数据线、同一条写信号线,只需要换一个地址,FSMC就会在时序里自动拉高或拉低RS,完全不需要软件干预。
2.2 16位总线下,命令和数据靠地址怎么区分
这里有个细节必须掰扯清楚:FSMC的数据宽度设为16位时,内部地址和外部地址的映射关系不是一一对应的。
16位模式下,存储是以半字(2字节)为单位的,所以内部地址线HADDR从第1位开始映射到外部地址线A0,内部HADDR的第0位不直接输出到管脚。换句话说,你在程序里写的地址偏移2,对应的外部A0才会变化1。
所以当RS接A0时:
- 写命令地址:0x60000000,外部A0拉低,RS=0
- 写数据地址:0x60000002,外部A0拉高,RS=1
理解了这一层,你就不会犯"数据地址应该是0x60000001"这种错误。有人拿着8位并口LCD的接线习惯来套16位总线,结果RS永远没切换过,屏幕白屏或者乱码,查半天都查不到点上。
如果把RS接在A6这类更高位的地址线上,那么命令和数据地址的差值就不是2,而是64或者更大。这完全合法,只是地址宏的定义要跟着改。我见过一些例程把RS接在A16上,本质上和接A0没有区别,只是为了避开某些和调试端口冲突的引脚。
2.3 为什么基址是0x60000000而不是别的地址
这个问题的答案要从FSMC的存储区划分说起。FSMC对外部设备的寻址空间分成多个Bank,其中Bank1专门给NOR Flash和PSRAM这类设备用,起始地址就是0x60000000。
Bank1内部又分了4个子区,每个子区64MB,分别对应NE1到NE4四个片选引脚:
| 子区 | 地址范围 | 片选信号 |
|---|---|---|
| Bank1区1 | 0x60000000~0x63FFFFFF | NE1 |
| Bank1区2 | 0x64000000~0x67FFFFFF | NE2 |
| Bank1区3 | 0x68000000~0x6BFFFFFF | NE3 |
| Bank1区4 | 0x6C000000~0x6FFFFFFF | NE4 |
LCD的CS接在NE1上,所以它的基址就是0x60000000。程序中对这个地址的访问,会映射到一次对NE1所连外设的读写操作。这个映射关系是芯片设计好的,不用也不能修改,你只能选择用哪个子区。
有的板子LCD的CS接的是NE4,那基址就是0x6C000000,对应的代码也要改。所以拿到一块新板子,第一件事就是对着原理图查CS脚接的是NE几,再确定基址,不要想当然。
3. 时序参数与HAL库配置:填结构体之前先读懂时序图
3.1 LCD控制器的写周期到底要求什么
FSMC初始化时序参数之前,必须先弄清楚LCD控制器本身的时序要求。以常见的ILI9341这类RGB565接口控制器为例,写周期的大致要求是:
- 写周期时间WC:最低约100纳秒
- 地址建立时间:最低约15纳秒
- 数据建立时间:最低约15纳秒
- 地址保持时间:最低约10纳秒
这些数字是控制器芯片手册里的典型值。如果你的FSMC时序比这个快,LCD控制器来不及锁存数据,就会出现花屏、颜色错乱、写寄存器无效等问题;如果太慢,虽然稳定,但性能上不去。
所以配置的思路是:先算出一份"肯定能点亮"的宽松时序,跑通之后再去压紧,找到一个不花屏的最快速度。千万别上来就把时序往死里压,否则屏幕不亮的时候你根本分不清是接线问题、配置问题,还是时序问题。
3.2 FSMC_NORSRAM_TimingTypeDef 各字段与HCLK的关系
HAL库中配置FSMC异步访问时序,核心是一个结构体,里面的字段最终会写入FSMC的时序寄存器。所有时间都是以HCLK周期为基本单位来换算的。
HCLK周期很好算:72MHz主频下,一个时钟周期是1/72MHz≈13.9纳秒。如果你是其他频率,先算出这个周期再照下面的公式套。
关键字段如下:
- AddressSetupTime:地址建立时间,对应ADDSET字段。实际的建立时间为(ADDSET+1)个HCLK周期,所以ADDSET=1时就是2×13.9≈27.8纳秒。
- AddressHoldTime:地址保持时间,对应ADDHOLD字段。这个参数主要影响读操作,写操作模式A中基本用不上。
- DataSetupTime:数据建立时间,对应DATAST字段。这是影响写速度的主要参数。实际时间为(DATAST+1)个HCLK周期。
- BusTurnAroundDuration:总线周转时间,对应BUSTURN字段。用于读写切换时数据总线的方向切换等待。
- AccessMode:访问模式,配置为FSMC_ACCESS_MODE_A。这是异步SRAM最常用的模式,时序结构是地址建立、数据建立、地址保持,非常适合LCD。
要特别注意的是,使能扩展模式ExtendedMode之后,读写时序可以分别配置,但同时要多费一个时序结构体。如果没把握,建议先关掉扩展模式,用同一套时序跑通再说。
3.3 一份最小可跑的初始化配置及计算过程
下面是我在72MHz的STM32F103上验证过的一份FSMC初始化配置,思路是先算好再填参数,不是瞎填。
先看控制器手册,写周期需要100纳秒,地址建立需要15纳秒。我用HCLK=72MHz计算:
- 地址建立:ADDSET至少满足15纳秒,选ADDSET=1,实际建立时间2个HCLK约27.8纳秒,满足
- 数据建立:DATAST至少满足15纳秒,同时整个写周期要满足100纳秒。选DATAST=9,数据建立时间10个HCLK约139纳秒。整个写周期大约是(ADDSET+1+DATAST+1)个HCLK,即12×13.9≈167纳秒,满足100纳秒要求,余量很足
- 地址保持:先选ADDHOLD=1,具体数值对写时序影响不大,读时序才有明显影响
- 总线周转:BUSTURN先填0,跑通后再调
对应HAL库代码如下:
#include "stm32f1xx_hal.h" static SRAM_HandleTypeDef h_sram; static FSMC_NORSRAM_TimingTypeDef timing; void FSMC_LCD_Init(void) { __HAL_RCC_FSMC_CLK_ENABLE(); // GPIO初始化:这里只是示意,实际上要把用到的FSMC引脚全部配成复用推挽输出 // 以PD4/PD5/PD7/PD11/PD0-PD15等为例 GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOD_CLK_ENABLE(); gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_14 | GPIO_PIN_15; HAL_GPIO_Init(GPIOD, &gpio); // 其他引脚根据板子原理图自行补充,不同板子引出的FSMC引脚可能不同 timing.AddressSetupTime = 1; // ADDSET timing.AddressHoldTime = 1; // ADDHOLD timing.DataSetupTime = 9; // DATAST timing.BusTurnAroundDuration = 0; timing.ClockDivision = 0; timing.DataLatency = 0; timing.AccessMode = FSMC_ACCESS_MODE_A; h_sram.Instance = FSMC_Bank1; h_sram.Extended = FSMC_Bank1_E; h_sram.Init.DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; h_sram.Init.MemoryType = FSMC_MEMORY_TYPE_SRAM; h_sram.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; h_sram.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; h_sram.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; h_sram.Init.WrapMode = FSMC_WRAP_MODE_DISABLE; h_sram.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS; h_sram.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE; h_sram.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE; h_sram.Init.ExtendedMode = FSMC_EXTENDED_MODE_DISABLE; h_sram.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE; h_sram.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE; h_sram.Init.ContinuousClock = FSMC_CONTINUOUS_CLOCK_SYNC_ONLY; h_sram.Init.WriteFifo = FSMC_WRITE_FIFO_DISABLE; h_sram.Init.PageSize = FSMC_PAGE_SIZE_NONE; if (HAL_SRAM_Init(&h_sram, &timing, NULL) != HAL_OK) { // 初始化失败,多半是GPIO没有配对,或者HAL版本不对 while (1); } }跑通之后再尝试压缩DATAST。我实测这块控制器在DATAST=3时也能正常显示,速度能再上一个台阶,但再往下压就会出现偶发性花屏。所以稳妥起见,正式项目我会在最快速度和稳定性之间找个中间值。
4. LCD驱动的命令/数据基础函数与初始化序列
4.1 用宏定义命令/数据地址
FSMC初始化完成之后,LCD就"挂"在地址空间上了。接下来定义两个地址宏,一个指向命令地址,一个指向数据地址:
#define LCD_BASE_ADDR ((uint32_t)0x60000000) #define LCD_REG_ADDR (*(volatile uint16_t *)(LCD_BASE_ADDR + 0x00)) #define LCD_RAM_ADDR (*(volatile uint16_t *)(LCD_BASE_ADDR + 0x02))为什么数据地址是0x02而不是0x01,前面已经解释过了:16位总线模式下,外部地址线A0对应内部地址的第1位,所以命令/数据的地址间隔是2。
有了这两个宏,写命令和写数据就变成了两条简单的函数:
static inline void LCD_WriteCmd(uint16_t cmd) { LCD_REG_ADDR = cmd; } static inline void LCD_WriteData(uint16_t data) { LCD_RAM_ADDR = data; }注意这里我用的是static inline,因为这个函数会被调用几十万次,如果写成普通函数,每次调用都有压栈出栈的开销,刷屏性能会有明显损失。内联之后,编译器会把赋值语句直接展开在调用处,刷屏效率高不少。
4.2 复位与初始化序列(以常见控制器为例)
LCD控制器上电之后,第一步不是发初始化命令,而是先做硬件复位。我的标准操作是:用一个普通GPIO控制LCD的RES脚,拉低至少10微秒,然后拉高,再延时120毫秒,等控制器内部电源稳定。
void LCD_HardReset(void) { HAL_GPIO_WritePin(RESET_GPIO_Port, RESET_Pin, GPIO_PIN_RESET); HAL_Delay(20); HAL_GPIO_WritePin(RESET_GPIO_Port, RESET_Pin, GPIO_PIN_SET); HAL_Delay(120); }有些模块的RES脚直接接到了电源上,只靠上电RC复位,这种设计不太可靠,经常出现上电白屏、要重新上电才能显示的毛病。如果你遇到这种板子,建议飞线把RES引到GPIO上。
复位完成后,发基础的初始化命令序列。以ILI9341这类控制器为例,最简流程如下:
void LCD_InitSequence(void) { LCD_WriteCmd(0x01); // 软件复位 HAL_Delay(120); LCD_WriteCmd(0x11); // 退出睡眠模式 HAL_Delay(120); LCD_WriteCmd(0x36); // 显存访问控制 LCD_WriteData(0x48); // BGR=1, 竖屏方向 LCD_WriteCmd(0x3A); // 像素格式 LCD_WriteData(0x55); // RGB565 LCD_WriteCmd(0x29); // 开显示 }这个序列只做最基础的显示配置,能点亮、能刷色块,但如果你发现颜色发白、偏色严重,多半是功率控制寄存器、gamma校正等没配置。再对照控制器的数据手册,把0xC0、0xC1、0xC5、0x36后面的参数补上,颜色才会正常。
初始化完了就可以设置显示窗口,然后填充像素数据:
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); // 写GRAM } void LCD_FillColor(uint32_t pixel_count, uint16_t color) { volatile uint16_t *lcd_ram = (volatile uint16_t *)0x60000002; for (uint32_t i = 0; i < pixel_count; i++) { *lcd_ram = color; } }4.3 刷屏速度实测
上面代码跑起来,我用逻辑分析仪看了一眼写信号NWE的波形,确认DATAST=9时,一次写周期大约165纳秒,和理论计算吻合。实测填充一屏240×320,加上开窗和循环开销,大约18毫秒。这个速度下做简单的菜单切换、动画已经非常顺滑了。
再激进一点把DATAST改成3,一次写周期压到约80纳秒,整屏填充约9毫秒。但我发现连续长时间刷屏时,偶发出现个别像素颜色错的瑕疵,后来又调回了DATAST=4,勉强稳定,最终我选择DATAST=4作为刷屏性能和稳定性的折中。这里我的建议是:如果要做产品,不要追求极限,留出30%以上的时序余量,换来的稳定性比那几毫秒值得多。
5. 踩坑实录:花屏、白屏、读ID失败的完整排查
5.1 花屏:不是FSMC太快,是地址宏写错了
有一回我把初始化命令都发完了,屏幕能亮,但显示出来的内容完全是乱的,像雪花一样。一开始我怀疑时序太快,把DATAST调到15,更慢了,还是花屏。后来静下心来看宏定义,才发现数据地址写成了0x60000001。
这个问题就是典型的"16位总线下A0映射理解不到位"。0x60000001在16位模式下访问,本质还是同一个半字单元,并没有让外部A0拉高,所以RS一直保持在低电平,所有写数据的操作全被LCD控制器当成写命令来处理,画面当然全乱。
排查花了半小时,改一个宏就解决了。之后我养成了一个习惯,拿FSMC驱动任何并口设备,先把电阻表量一下RS和A0之间的通路,再对着芯片手册确认地址映射,最后才上电调试。
5.2 白屏:复位、背光、供电三板斧
白屏是FSMC驱动LCD最常碰到的现象,而且原因五花八门。我总结了一套排查顺序,按概率从高到低排:
第一看复位。用示波器量RES脚,如果上电后没有出现一个明显的低电平然后拉高的波形,或者干脆一直是低,那控制器就没起来。很多模块复位脚悬空或者直连电源,就会出现"第一次上电白屏、手动复位才正常"。解决办法就是改成GPIO控制复位。
第二看片选和写信号有没有真正产生波形。在初始化代码里添加一个临时GPIO翻转点,初始化前把某个引脚拉高,初始化后拉低,再用示波器在LCD的CS脚和WR脚附近测量。如果CS脚有波形而WR脚没有,查FSMC_NWE的GPIO复用配置是否错配到了别的功能上。
第三看供电电压纹波。LCD控制器工作电流不大,但如果和电机、继电器共用电源,上电瞬间电压跌落会导致控制器启动异常。我用了一个独立LDO给LCD供电之后,这类问题基本没再出现过。
5.3 读ID失败:读时序的三个隐藏参数
FSMC驱动LCD,写操作一般都能正常跑通,但读操作经常出问题。读ID读回来的数据要么是0xFFFF,要么是0x0000。
如果你也遇到这种问题,不要急着怀疑芯片坏了,先检查三件事。
第一件,读GRAM之前要做一次哑读。很多LCD控制器在发完读命令之后,第一次读出来的数据是无效的,必须再读一次才是有效数据。代码里读数据之前先随便读一次丢弃掉。
uint16_t LCD_ReadData(void) { // 先空读一次,丢弃无效数据 (void)(*(volatile uint16_t *)0x60000002); return *(volatile uint16_t *)0x60000002; }第二件,把ADDHOLD调大。读时序和写时序不同,读操作需要地址保持时间,尤其是RS切换之后,地址线不能立刻变化。我实测有些LCD控制器需要ADDHOLD=2到3才能稳定读回数据,默认的1不够。
第三件,把BUSTURN从0调到2试试。这个参数影响读写切换时数据总线的方向转换时间。如果总线上同时挂了多个外设,或者走线较长导致信号反射明显,BUSTURN不足就会出现读错数据。
我自己的配置最后是ADDSET=2、DATAST=10、ADDHOLD=3、BUSTURN=2,读ID一次成功,读回来的值和控制器手册对得上。
顺带说一句,如果你读回来的ID不是预期值,先查一下你的屏到底是什么控制器。现在市面上很多屏虽然是同一种引脚定义,但内部控制器五花八门,读ID的命令都不一样。我遇到过读出来是0x1010的,查手册才发现是另一种控制器,初始化序列完全不同。
6. FSMC还能拿来做什么:同一条总线的其他玩法
6.1 外部SRAM扩展
写完LCD实验之后,FSMC这条总线的潜力其实才展示了一点。FSMC的标准用途是接SRAM,这和接LCD的配置几乎一模一样,区别只在于SRAM没有RS脚,也不需要命令/数据地址区分。
把一片16位SRAM挂在NE1上之后,它映射到0x60000000起始的1MB地址空间。你可以定义一个指针指向这片区域,直接当普通数组用:
volatile uint16_t *external_sram_base = (volatile uint16_t *)0x60000000; void SRAM_Test(void) { external_sram_base[0] = 0x1234; external_sram_base[1] = 0xABCD; // 读取回来校验是否一致 }这个能力在做大缓冲区的时候极其好用,比如音频采样缓存、图像双缓冲、GUI的帧缓冲,都可以放到外部SRAM里。我在一个波形采集项目里就是用FSMC外扩了1MB SRAM,解决了内部RAM不够用的问题。
6.2 NOR Flash并行访问
FSMC同样可以接并行NOR Flash。好处是可以像读内存一样直接读Flash里的数据,甚至可以从NOR Flash里执行代码,这在XIP场景下非常有用。
不过要注意,NOR Flash的写操作不是简单的总线写周期,需要先发命令序列。所以读操作直接用FSMC内存映射,写操作还是要软件方式发送命令、地址、数据。我的建议是,除非你的应用需要大容量、快速随机读取,否则并行NOR Flash的性价比不如SPI NOR Flash,毕竟引脚占用差好几倍。
6.3 接FPGA、并口DAC/ADC的思路
FSMC还能接一些非存储类的并行设备,比如并口输出的高速ADC、并口输入的DAC、FPGA的寄存器接口。思路和LCD一样:把你需要访问的设备看作一组"寄存器",通过地址线来选寄存器,通过FSMC的读写时序来交换数据。
我当时用一个14位的并口ADC挂在FSMC上,通过改变地址线的值来切换通道,读数据就是一个内存读取操作,非常简单。这种做法比GPIO模拟省去了所有的时序控制,而且采集速率直接由FSMC的读时序决定,非常稳定。
如果你也在考虑类似方案,唯一要留意的是:FSMC的写信号宽度、读信号宽度这些时序参数需要适配目标设备的时序要求,不同设备对建立时间、保持时间的要求千差万别,拿到设备手册先把时序图看明白,再填FSMC结构体,这是通用流程。
最后再分享一个小技巧:每次调FSMC时序参数,我都会把配置函数独立出来,在屏幕上刷一个纯色画面,然后用逻辑分析仪抓写信号波形,记录不同参数组合下的实际写周期。这样既能直观看到参数变化对速度的影响,也能在后续排查问题时快速定位是硬件还是配置的问题。你要是有兴趣,可以在这个基础上试试把LCD的帧缓冲放到外扩SRAM里,用FSMC同时驱动内存和屏幕,感受一下纯硬件刷屏和软件刷屏一起工作时那种还是挺明显的变化。