
前面三篇实验我们分别用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×2153600字节按每次写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毫秒FSMCDATAST9约0.17微秒约13毫秒FSMCDATAST3约0.08微秒约7毫秒注意这里光算的是数据搬运时间没算循环指令本身的开销但趋势已经很明显FSMC方式比GPIO方式快了至少一个数量级。而且关键的一点是FSMC写入过程中CPU只要发起一次写操作剩下的事情由硬件完成CPU负载大大降低。对于想要做GUI、做简单动画、甚至双缓冲的应用这个差距就是能用和不能用的区别。2. FSMC与LCD的硬件连接和地址映射A0这根线是灵魂2.1 引脚分配数据线、控制线、地址线怎么接FSMC驱动LCD的硬件连接有一个标准套路。以STM32F103ZET6这类大容量型号为例FSMC的引脚分布大致是数据线PD0~PD15对应D0~D15PE7~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拉低RS0写数据地址0x60000002外部A0拉高RS1理解了这一层你就不会犯数据地址应该是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区10x60000000~0x63FFFFFFNE1Bank1区20x64000000~0x67FFFFFFNE2Bank1区30x68000000~0x6BFFFFFFNE3Bank1区40x6C000000~0x6FFFFFFFNE4LCD的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字段。实际的建立时间为(ADDSET1)个HCLK周期所以ADDSET1时就是2×13.9≈27.8纳秒。AddressHoldTime地址保持时间对应ADDHOLD字段。这个参数主要影响读操作写操作模式A中基本用不上。DataSetupTime数据建立时间对应DATAST字段。这是影响写速度的主要参数。实际时间为(DATAST1)个HCLK周期。BusTurnAroundDuration总线周转时间对应BUSTURN字段。用于读写切换时数据总线的方向切换等待。AccessMode访问模式配置为FSMC_ACCESS_MODE_A。这是异步SRAM最常用的模式时序结构是地址建立、数据建立、地址保持非常适合LCD。要特别注意的是使能扩展模式ExtendedMode之后读写时序可以分别配置但同时要多费一个时序结构体。如果没把握建议先关掉扩展模式用同一套时序跑通再说。3.3 一份最小可跑的初始化配置及计算过程下面是我在72MHz的STM32F103上验证过的一份FSMC初始化配置思路是先算好再填参数不是瞎填。先看控制器手册写周期需要100纳秒地址建立需要15纳秒。我用HCLK72MHz计算地址建立ADDSET至少满足15纳秒选ADDSET1实际建立时间2个HCLK约27.8纳秒满足数据建立DATAST至少满足15纳秒同时整个写周期要满足100纳秒。选DATAST9数据建立时间10个HCLK约139纳秒。整个写周期大约是(ADDSET1DATAST1)个HCLK即12×13.9≈167纳秒满足100纳秒要求余量很足地址保持先选ADDHOLD1具体数值对写时序影响不大读时序才有明显影响总线周转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。我实测这块控制器在DATAST3时也能正常显示速度能再上一个台阶但再往下压就会出现偶发性花屏。所以稳妥起见正式项目我会在最快速度和稳定性之间找个中间值。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); // BGR1, 竖屏方向 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的波形确认DATAST9时一次写周期大约165纳秒和理论计算吻合。实测填充一屏240×320加上开窗和循环开销大约18毫秒。这个速度下做简单的菜单切换、动画已经非常顺滑了。再激进一点把DATAST改成3一次写周期压到约80纳秒整屏填充约9毫秒。但我发现连续长时间刷屏时偶发出现个别像素颜色错的瑕疵后来又调回了DATAST4勉强稳定最终我选择DATAST4作为刷屏性能和稳定性的折中。这里我的建议是如果要做产品不要追求极限留出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控制器需要ADDHOLD2到3才能稳定读回数据默认的1不够。第三件把BUSTURN从0调到2试试。这个参数影响读写切换时数据总线的方向转换时间。如果总线上同时挂了多个外设或者走线较长导致信号反射明显BUSTURN不足就会出现读错数据。我自己的配置最后是ADDSET2、DATAST10、ADDHOLD3、BUSTURN2读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同时驱动内存和屏幕感受一下纯硬件刷屏和软件刷屏一起工作时那种还是挺明显的变化。