ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103驱动ST7735:SPI+DMA实现20ms高速刷新

STM32F103驱动ST7735:SPI+DMA实现20ms高速刷新 简介STM32F103与ST7735 TFT LCD搭配的显示测试程序面向嵌入式显示驱动开发及入门学习者重点解决使用模拟SPI时序导致刷新缓慢的问题充分发挥硬件SPI的高速传输优势使画面切换更流畅。资源压缩包共296个文件包含C源码与头文件、Keil工程文件、编译生成的hex/axf文件以及调试过程中产生的部分中间文件总大小约11.43MB工程组织清晰便于直接在真实板卡上编译烧录和逐步调试。目前已有4358人学习使用具备较好的参考价值。程序编写了完整的STM32 SPI初始化流程包括时钟分频、CPOL/CPHA极性相位、NSS片选控制与数据模式配置同时提供ST7735的初始化、设定显示区域、清屏、画点等基础驱动函数。研读这段代码可以理解硬件SPI与LCD控制芯片之间的通信机制掌握显示缓冲区与字节顺序的处理思路并在此基础上扩展到其他SPI接口彩屏为后续GUI移植或界面交互开发打下基础。 SPI时钟拉到18MHz128x128的RGB565全屏数据32KB实测一帧刷完不到20ms。这就是ST7735在STM32F103上的真实表现也是我这套测试程序的核心目标——不是“能点亮”而是“刷得快”。这块成本不到十块钱的小屏配上F103这颗老当益壮的MCU是很多人做智能家居面板、小仪表、DIY示波器的首选组合。网上能搜到不少点亮例程但大部分都停留在“能显示”阶段全屏刷新动辄上百毫秒画面肉眼可见地闪。这篇文章不打算重复那些内容而是把“刷新快”这个目标拆解开从硬件连接、SPI与DMA配置、颜色格式取舍到测试程序和性能量化方法完整走一遍我调这块屏的思路。适合已经在用标准库跑STM32、想把小屏刷新率真正做上去的开发者。1. 方案选型为什么是ST7735加F103这套组合1.1 ST7735到底是一块什么屏ST7735是一款小尺寸TFT液晶驱动控制器常见于0.96寸、1.44寸的屏模组分辨率以128x160、128x128为主。它内部集成了一块GRAM显存MCU通过SPI接口把像素数据写进去剩下的扫描、刷新、灰度调制全部由它自己完成。对MCU来说它本质上就是一个“SPI接口的显存外设”你的任务只是把要显示的像素按格式丢过去。选它而不是选同尺寸的OLED理由很实际TFT是主动矩阵液晶色彩丰富没有OLED长期静态画面可能出现的残影问题驱动资料也更成熟。选F103而不是更高端的F4/H7理由更现实F103便宜、资料多、很多人手边正好有。更重要的是ST7735的显示瓶颈在SPI数据带宽而不是MCU算力F103只要把SPI和DMA用好完全能喂饱这块小屏。1.2 “刷新快”这个目标怎么拆解要量化刷新快先得算清一条底层链路像素数据从MCU内存到屏幕GRAM要经过SPI总线传输时间占了绝对大头。以128x128分辨率、RGB565色彩格式为例每像素2字节一帧数据量是128×128×2 32768字节。SPI时钟跑9MHz时传完这些数据的理论最短时间是32768×8÷9000000 ≈ 29ms把时钟拉到18MHz理论时间缩到约14.6ms。再加上指令开销、GPIO翻转、片选切换等杂项实际帧时间大概在理论值的1.2到1.5倍之间。所以“刷新快”的优化方向非常清晰要么提高SPI时钟要么降低每帧数据量要么让传输过程不阻塞CPU。后文的每一步优化都是围绕这三个方向展开的。网上很多例程慢不是屏慢是代码慢——把账算清楚问题就好解决了。2. 硬件连接与最小系统搭建2.1 引脚分配把关键信号理顺我用的方案是SPI1因为SPI1挂在APB2总线上外部时钟72MHzSPI时钟最高能到36MHz而SPI2、SPI3挂在APB1总线上外部时钟36MHz上限只有18MHz。既然目标是刷新快一开始就选上限更高的SPI1。推荐连接表信号引脚说明SCKPA5SPI1_SCK复用推挽输出MOSIPA7SPI1_MOSI复用推挽输出CSPA4片选普通GPIO软件控制DCPB0数据/命令选择普通GPIORSTPB1复位低电平复位BLK3.3V串10R电阻背光直接接高这里有个关键点CS和DC不要交给SPI的硬件NSS管理全部用普通GPIO软件控制。原因是ST7735要求先拉低CS再切换DC再发起SPI传输硬件NSS在这种细粒度控制下会很别扭反而容易出错。2.2 供电与背光的两个坑第一个坑是背光电流。ST7735模组的背光LED工作电流通常在20mA到40mA之间最好不要直接用STM32的GPIO去驱动GPIO输出电流能力有限长时间高电平输出会发热也容易损伤引脚。我习惯在背光引脚和3.3V之间串一个10到22欧姆的电阻限流如果要软件调亮度就外接一个三极管或MOS管用PWM控制亮度。第二个坑是电源稳定性。整屏刷彩色画面时屏内部大量像素同时翻转瞬时电流变化不小。F103从USB取电时如果杜邦线太长、线阻过大电压跌落会导致屏花、闪。测试时尽量用短粗的杜邦线或者在板子的电源入口多并一颗100uF的电解电容效果立竿见影。3. 基础驱动移植先从“能亮”开始3.1 SPI初始化要点模式0效率优先ST7735默认的SPI时序是模式0即CPOL0、CPHA0空闲时时钟为低电平第一个时钟边沿采样数据。这个写错的话屏幕通常直接花屏或者完全不工作。标准库下初始化代码核心部分如下SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode SPI_Mode_Master; SPI_InitStructure.SPI_DataSize SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_4; // 72/418MHz SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE);分频系数选4SPI时钟就是18MHz。这里提醒一句SPI时钟不是越高越好。用杜邦线连接时18MHz已经接近稳定极限如果你用的是一堆飞线建议先用9MHz分频8跑通再用示波器确认波形干净后逐步提升。还要记得把SCK和MOSI的GPIO速度设成50MHzGPIO_Speed_50MHz否则高速SPI下波形会严重失真刷新率反而上不去。3.2 复位与初始化序列ST7735的“开机流程”每次上电后ST7735需要一次硬件复位。时序是RST拉低至少10us再拉高然后等待120ms以上让内部电路稳定。如果这个时序太短后续初始化指令会被屏忽略表现就是白屏。复位完成后发送一串初始化指令。不同模组会略有差异但框架一致核心指令如下0x01软件复位0x11退出睡眠模式SLPOUT0x3A设置像素格式0x05表示16位RGB5650x36设置扫描方向MADCTL同时决定RGB还是BGR顺序0x2A设置列地址CASET0x2B设置行地址RASET0x2C写显存RAMWR很多人移植失败问题不在初始化代码本身而是漏了MADCTL和像素格式的匹配。网上流传的初始化表五花八门有的默认是RGB顺序你的屏却是BGR结果颜色全反有的CASET参数带偏移你的屏不需要偏移结果画面整体偏移。我的建议是先把初始化表跑通然后单独测试0x36这个寄存器分别写0x00、0x08、0xC0、0xC8等常用值观察画面方向选最符合你预期的那一个。这种“试错法”在小屏驱动里非常实用因为同一颗驱动IC在不同模组厂手里走线方向可能完全不同。3.3 写像素函数最简单的帧缓存方式基础绘图操作是“先设窗口再写像素”。填充一个矩形区域的完整逻辑是发送CASET设置列范围发送RASET设置行范围然后发送0x2C进入写显存模式最后连续发送像素数据。void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { LCD_CS_LOW(); // 设置列范围 LCD_DC_LOW(); SPI1_WriteByte(0x2A); LCD_DC_HIGH(); SPI1_WriteByte(x0 8); SPI1_WriteByte(x0 0xFF); SPI1_WriteByte(x1 8); SPI1_WriteByte(x1 0xFF); // 设置行范围 LCD_DC_LOW(); SPI1_WriteByte(0x2B); LCD_DC_HIGH(); SPI1_WriteByte(y0 8); SPI1_WriteByte(y0 0xFF); SPI1_WriteByte(y1 8); SPI1_WriteByte(y1 0xFF); // 进入写显存 LCD_DC_LOW(); SPI1_WriteByte(0x2C); LCD_DC_HIGH(); // 连续写像素 for(uint32_t i 0; i (uint32_t)(x1-x01)*(y1-y01); i) { SPI1_WriteByte(color 8); SPI1_WriteByte(color 0xFF); } LCD_CS_HIGH(); }注意ST7735每像素2字节颜色格式是RGB565高字节在前红5位、绿6位、蓝5位。发送时先发高字节再发低字节写反了颜色会明显不对。4. 刷新优化把一帧时间压到20ms级4.1 先砍掉最常见的“慢代码”很多例程慢慢在哪我见过最典型的三个问题。第一每次全屏刷新都重新发送一遍完整的初始化序列。初始化序列里有不少带延时的指令SLPOUT之后通常要等120ms如果主循环里刷一帧就初始化一次帧率直接掉到个位数。正确的做法是上电初始化一次之后只发数据绝不重复初始化。第二用逐字节查询方式发数据。比如调用SPI_I2S_SendData发一个字节再用while(SPI_I2S_GetFlagStatus(...))等待发送完成标志。这种方式每发一个字节都要等32768字节的数据量会累积成巨大的时间损耗。有效做法是SPI发送加上DMA让硬件自己搬数据。第三没有合理安排窗口每次只改一个像素也要把0x2A、0x2B、0x2C重新发一遍。不如把“设置窗口并连续写数据”封装成一个函数全屏刷新的额外开销只有几条指令而已。4.2 DMA搬运把CPU从数据搬家里解放出来传输32768字节如果靠CPU一个一个发按SPI每字节约8个时钟周期计算CPU要花掉几十万个周期。用DMA的话配置好之后CPU就干自己的事传输结束产生中断就行。F103的SPI1_TX对应DMA1的通道3配置方向为内存到外设。初始化代码要点如下DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)SPI1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)frameBuffer; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize 32768; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode DMA_Mode_Normal; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_InitStructure.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel3, DMA_InitStructure);发送一帧时使能DMA通道SPI在发送完当前字节后会主动触发DMA请求自动从内存取下一个字节。这相当于把一份32768字节的大文件交给快递员而不是自己一趟趟跑腿。用DMA后CPU在传输期间可以去处理别的事情比如准备下一帧的计算、处理按键、跑串口日志。对于实时性要求高的场景这是关键的一步。4.3 颜色格式的取舍RGB565还是RGB444ST7735的0x3A寄存器支持多种像素格式16位RGB565最常用但12位RGB444格式也有数据量只有前者的75%。128x128一帧从32768字节降到24576字节传输时间直接减少四分之一。但RGB444的缺点是颜色精度下降每个颜色通道只有4位渐变区域会出现比较明显的色阶断层。我的建议是如果屏幕显示数字、字符、仪表盘这类对颜色精度不敏感的内容用RGB444很划算如果要显示照片或复杂渐变必须上RGB565。4.4 局部刷新最实用的一招全屏刷新即使优化到20ms一帧如果整个界面都在动仍然会有闪烁感。但大部分界面不是整个都在动的。比如一个时钟界面变化的是秒针区域而表盘背景是不变的。这时候局部刷新就派上用场了。原理不复杂把CASET和RASET设置成只包含变化区域的矩形然后只往这个区域写数据。DMA传输数据量从32768字节降到几千字节刷新时间缩短到几毫秒。实测下来一个60x60像素的秒针区域SPI 18MHz下刷新一次大约3.2ms肉眼完全感觉不到延迟。4.5 双缓冲处理“边算边传”的冲突如果程序在DMA传输的同时修改frameBuffer就会出现“屏幕上显示了一半新数据、一半旧数据”的画面撕裂。最简单的方案是双缓冲定义frameBuffer0和frameBuffer1DMA正在发送buffer0时CPU往buffer1写入下一帧DMA传输完成中断里交换两个buffer的指针。F103的内存足够代码复杂度也不高值得实现。5. 测试程序设计让性能可量化5.1 测试项怎么设计这块屏的测试程序我一般放三组用例。第一组是纯色填充测试红、绿、蓝、白、黑逐帧切换肉眼观察有无花屏、残影同时测量整帧时间。这个能快速暴露出初始化配置、供电稳定性方面的问题。第二组是移动方块测试在黑色背景上画一个平滑移动的白色方块观察运动轨迹是否连贯这是直观感受刷新率的好方法。如果方块出现拖影或者跳动说明刷新帧率不足。第三组是渐变测试逐像素从黑渐变到白检查每级颜色是否正常这个对判断像素格式错误非常方便。量产或项目验收阶段我还会加一个刷新次数计数器每次全屏刷新把计数加1显示在屏幕角落刷1000次不停顿、不花屏基本说明硬件和驱动都稳定。5.2 用GPIO翻转和示波器测单帧耗时这是我在F103上最常用的量化手段定义两个调试引脚放在一帧数据发送代码的前后。发送前拉高发送完成后拉低用示波器量脉宽就是单帧时间。因为没有引入额外的串口打印开销测出来的数据非常真实。代码结构如下DEBUG1_HIGH(); // 开始计时 LCD_DisplayFrame(frameBuffer); DEBUG1_LOW(); // 结束计时实测数据参考RGB565128x128全屏配置SPI时钟单帧耗时逐字节发送、不开DMA9MHz约40msSPI DMA9MHz约29msSPI DMA18MHz约16ms注意这里的帧时间包含了CS、DC、指令等全部开销非常接近理论极限。如果你测出来一帧需要80ms先别怀疑屏检查一下是不是每帧都重复初始化是不是在SPI发送时加了多余的Delay。5.3 串口辅助打印FPS和耗时虽然F103跑不下太大的图形系统但串口打印调试参数还是很有用的。我用USART1输出带时间戳的日志配合一个简单的串口助手就能实时观察刷新性能[SYSTEM] LCD init OK, SPI18MHz [TEST ] frame#100: 16.3ms, FPS61.3 [TEST ] frame#101: 16.1ms, FPS62.1这样一跑刷新性能是快是慢一目了然。如果主循环里还跑着其他任务可以记录循环总耗时看看CPU还有没有余量去处理业务逻辑。6. 调试实录白屏、闪屏、偏色的排查思路6.1 白屏先查复位和供电白屏几乎都是初始化没有真正完成导致的。排查顺序建议是万用表量3.3V和背光电压是否正常示波器抓RST引脚有没有先低后高的复位波形SPI时钟是否正常翻转最后再用逻辑分析仪或示波器确认初始化指令是否真的发出来了。如果以上都正常但依然白屏试试在复位后加一个200ms的延时有些ST7735模组上电需要更长的稳定时间。6.2 花屏多半是窗口坐标或SPI模式错了花屏的故障现象很多常见原因有SPI CPOL/CPHA配错、CASET/RASET坐标反了、发送数据时字节序不对。如果画面出现规律性错位优先检查行地址和列地址是否越界如果颜色错乱但位置正确重点检查0x3A和MADCTL寄存器。6.3 刷新速度上不去怎么办SPI时钟设了18MHz却跑不起来最常见的原因是杜邦线太长或者GPIO速度配置太低。GPIO_Speed要设成50MHz线长控制在10cm以内。另一个容易被忽略的点是如果SPI发送过程中被中断频繁打断每字节之间的间隔会被拉长导致实际传输速度远低于配置值。解决办法是用DMA并合理配置NVIC抢占优先级别让无关中断打扰SPI传输。6.4 颜色发紫或发蓝这个一般是RGB顺序问题。ST7735的MADCTL寄存器0x36里有个BGR位置1时按BGR顺序解释颜色。如果你的屏数据手册不明确直接试几个值就行0x00RGB、0x08BGR是最常用的两个。我遇到过一些模块还要配合0xC0或0xE0这取决于具体模组的偏置设置多试几次总能找到合适的。最后再说几句我自己的体会。这块屏我在F103上反复调了一个多月最大的感触是刷新速度不是靠某个单项极致优化出来的而是SPI时钟、数据量、DMA、局部刷新每一项都做对之后合力带来的结果。你把这几个环节都过一遍手里的屏自然就快了。如果你的板子后续要上LVGL或者简单的菜单系统这套驱动完全可以作为底层支撑起来。LVGL需要显存接口你把“设置窗口填充RAM”封装成LVGL要求的flush回调其余交给LVGL就行。我试过在128x128的1.44寸屏上跑LVGL配合这套底层基本能满足日常界面切换的需求。希望这篇记录对你有帮助调屏愉快。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进