简介:面向STM32入门者与嵌入式开发者的ILI9341液晶显示工程,基于STM32F103C8T6最小系统板和2.8寸TFT LCD模块,重点演示如何在无FSMC的48脚芯片上用普通GPIO模拟总线,实现16位并口TFT驱动,可直接迁移到类似MCU平台,也常见于高校课程设计与毕业设计等场景。压缩包共182个文件,总大小3.16MB,内容覆盖C/H源码、Keil工程配置、链接脚本、编译后的hex/axf/map等输出,在Keil中打开即可查看或烧录验证。已有5078人学习/下载,尤其适合正在学习STM32裸机驱动、LCD接口移植以及并口时序的开发者参考。资源按模块化硬件电路搭建,代码中包含LCD驱动与标准外设库的定时器、Flash、ADC等模块,有助于理解显示初始化流程和底层寄存器操作,也便于二次开发;需要注意的是触屏输入功能暂未实现,当前使用场景以显示屏驱动为主。整体结构清晰明了,适合逐步阅读和功能裁剪。 手头这块板子是我入门STM32用的C8T6最小系统板,配上2.8寸TFT LCD,很经典的一套显控组合。STM32F103C8T6是64引脚、Cortex-M3内核、主频72MHz、64KB Flash、20KB RAM,虽然不算大,但跑一个小型GUI、做波形显示或者菜单交互完全够用。屏的驱动芯片是ILI9341,分辨率240x320,颜色深度最高支持16位RGB565,刷新流畅度和色彩表现对这个尺寸来说足够。
这套组合适合谁?如果你刚学完点亮LED,想搞一个“能看又能点的屏幕”;或者想做一个天气站、温湿度计、示波器、平衡小车界面;再或者准备用带触摸的版本做电子相册、游戏机,这条路都能打通。因为C8T6的资料最多,ILI9341的参考代码也最多,两者放在一起,遇到问题基本都能在网上找到答案,不会卡死你。下面我把这次从接线、CubeMX配置到代码移植的完整过程整理出来,顺便把调试时踩过的坑都标出来。
1. 项目思路与选型分析
1.1 为什么是STM32F103C8T6 + ILI9341这个组合
C8T6虽然属于F103系列里的“小容量”型号,但外设非常齐全:3个USART、2个SPI、2个I2C、1个USB、多个定时器和ADC,对于驱动一块屏来说绰绰有余。64KB的Flash如果只是放显示驱动、字库和几个界面,空间非常宽裕;20KB的RAM跑不带操作系统的小工程也够用。更关键的是,这块板的国产替代方案非常多,价格便宜,坏了也不心疼,特别适合做实验。
ILI9341则是当前小尺寸TFT屏里最常见的一款驱动芯片,几乎所有2.8寸、2.4寸的SPI屏都基于它。它内部自带GRAM(显存),MCU只需要通过SPI接口把像素数据送进去,屏就会自动刷新显示。这样主控不需要频繁刷新整屏,能省出大量CPU时间去处理其他逻辑。所以这一对组合是“低成本、低引脚占用、资料海量”的典型代表,作为LCD入门项目很合适。
1.2 SPI接口和并行接口的取舍
2.8寸的ILI9341模组通常不止一种接口,常见的有4线SPI、8位并行、16位并行。SPI版只需要CS、SCK、MOSI、DC、RST、BL这6根线,再加上电源和地,C8T6引脚的余量就很宽裕;并行版速度最快,一次能刷整行像素,但需要至少十几根数据线和好几根控制线,C8T6这种小封装引脚马上就紧张了,而且C8T6内部没有FSMC控制器(FSMC是103系列高容量型号才带的外设),做不了“并口屏+FSMC无缝映射”那种硬方案,所以小板上最合理的还是SPI。
顺便说个经验:同样是2.8寸,有些模组上默认是16位并口,需要你手动改电阻或飞线才能切到SPI模式,买之前一定看清店铺标注或者模块丝印,别等焊完才发现引脚不对。如果你手头只有并口屏,又想用C8T6驱动,也不是不行,用GPIO模拟并口时序也能点亮,但代码量和调试难度都会上一个台阶,新手不建议一上来就这么玩。
1.3 硬件SPI比软件SPI强在哪
这次我选择的是硬件SPI,也就是直接把SCK、MOSI接到STM32的SPI1引脚上(PA5、PA7),数据都由外设自己移位发出,CPU只需要往数据寄存器里写一个字节。相比软件模拟SPI,好处有三个:一是速度快,SPI1最大可以跑到18MHz,软模拟一般也就2~4MHz;二是时序稳定,不像GPIO翻转那样受中断和分支指令影响;三是后续可以用DMA,刷大图时CPU可以腾出来干别的。
当然软件SPI也不是没用,当你接到非复用引脚、或者屏和主控不在同一块板上需要自定义接线,软SPI更灵活。实际项目里你可以把底层函数抽象成同一个接口,先用软SPI调通逻辑,再切到硬件SPI提速度,排查问题时这种切换能力特别有用。尤其是遇到“硬件SPI点不亮但软件SPI能亮”的怪问题时,基本就能确定是引脚复用或模式配置的问题。
2. 硬件准备与接线
2.1 需要的材料清单
- STM32F103C8T6最小系统板(蓝色板、红板都行)
- 2.8寸ILI9341 SPI TFT LCD模组(建议带电平转换的版本,或确认逻辑电压是3.3V)
- ST-Link或USB转串口(CH340、CP2102)下载器
- 杜邦线若干,能短则短
- 逻辑分析仪(调试时可选项,建议备一个)
下载器多说一句:如果是从串口下载,C8T6板载的CH340或CP2102驱动装不上是个高频问题。Win10以上一般免驱,老系统记得去芯片厂商官网下载对应驱动,不要乱装那种“万能驱动”。用ST-Link的话,驱动同样去官网装,装好之后在Keil里选择ST-Link下载器即可,速度比串口快不少。
2.2 接线表与引脚分配
我在CubeMX里把SPI1配置为主模式,只发送不接收,所以MISO引脚可以不接。控制引脚分配是这样:
| LCD引脚 | 功能 | STM32引脚 |
|---|---|---|
| VCC | 电源正 | 3.3V |
| GND | 电源地 | GND |
| CS | 片选 | PA4 |
| RST | 复位 | PA1 |
| DC/RS | 数据/命令选择 | PA2 |
| MOSI/SDI | SPI数据输入 | PA7 |
| SCK | SPI时钟 | PA5 |
| BL/LED | 背光 | PA3(软件控制) |
| SDO/MISO | 数据输出 | 不接 |
注意PA5、PA7是SPI1的默认复用引脚,PA4是SPI1的NSS引脚,但因为我是用GPIO软件控制片选,PA4必须配置成普通推挽输出,不能复用成NSS自动模式,否则CS可能会被外设自动拉来拉去,导致收发数据错乱。PA6是SPI1的MISO引脚,我这边没有接屏的SDO,所以PA6可以留作他用;如果后续想读屏的ID或者做回读校验,再把PA6复用成MISO接过去。
RST接法有讲究。很多模组把RST拉高就能用,但ST官方和屏厂建议上电后对RST做一个低脉冲复位,时间至少20us,然后拉高。如果你板上RC复位电路影响时序,最稳的是用一个GPIO控制RST,不要直接并接在主控的NRST脚上,否则屏可能会在MCU复位后仍处于复位状态,等MCU开始初始化了屏还没醒过来。
2.3 CubeMX配置要点
时钟树这里提一嘴:STM32F103C8T6最高主频72MHz,系统时钟建议配到72MHz,否则SPI时钟分频之后速率会偏低。SPI1挂载在APB2总线上,APB2默认是72MHz。SPI1的波特率预分频有2、4、8、16、32、64、128、256分频可选。我用的8分频,实际SPI时钟是9MHz。
为什么不用4分频的18MHz?理论上SPI1可以跑18MHz,但那是在PCB走线非常短、器件都在同一块板上的理想情况。用杜邦线连接的时候,18MHz下波形边沿变差,读取回来容易出花屏,9MHz既快又稳,是2.8寸屏的开销和稳定性的平衡点。
SPI配置参数:
- Mode:Transmit Only Master(只发送主模式)
- Clock Polarity (CPOL):Low
- Clock Phase (CPHA):1 Edge(即SPI模式0,第一个边沿采样)
- NSS:Disable(软件管理)
- 帧格式:8bit,MSB First
关于模式0和模式3,ILI9341其实两种都支持。很多现成代码里用的是模式0,但如果你拿到的屏花屏或第一行错位,可以试着把SPI模式改成模式3(CPOL=High,CPHA=2 Edge),不少人都在这上面栽过,代码逻辑没毛病,就是模式选错了。这个切换不涉及驱动代码改动,改CubeMX重新生成或者直接改寄存器就行。
3. 驱动代码核心实现
3.1 底层命令/数据读写函数
先写底层。LCD作为外设,最关键的是区分“命令”和“数据”,这个由DC引脚决定。DC拉低时,SPI总线上的一个字节被屏当成命令;DC拉高时,被当成数据。两者间的时序关系用代码表达就是:
#define LCD_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define LCD_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define LCD_DC_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_RESET) #define LCD_DC_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_2, GPIO_PIN_SET) void LCD_WriteCmd(uint8_t cmd) { LCD_CS_LOW(); LCD_DC_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); LCD_CS_HIGH(); } void LCD_WriteData(uint8_t data) { LCD_CS_LOW(); LCD_DC_HIGH(); HAL_SPI_Transmit(&hspi1, &data, 1, 10); LCD_CS_HIGH(); }这里有个细节:有些驱动代码会在拉高CS之前把DC也恢复成默认,这个问题不大,但建议每次都按“CS拉低 -> DC切到对应电平 -> 发送 -> CS拉高”这个顺序做,不要反过来,否则第一个字节容易因为建立时间不够而丢失。HAL_SPI_Transmit的最后一个参数是超时时间,我写的10ms,正常情况下单字节发送是微秒级,但如果SPI没有正确初始化,这个函数会一直等待直到超时,可以作为简单的故障判断。
3.2 初始化序列逐条拆解
ILI9341上电后不是直接就能用的,它内部有一个复杂的寄存器组,需要通过初始化命令设置好电源、时序、显示方向和颜色格式。以下是我整理的完整初始化序列:
static const uint16_t lcd_init_seq[] = { 0x01, /* 软件复位 */ 0x11, /* 退出睡眠模式 */ 0x36, 0xC8, /* 内存访问控制:BGR、行列扫描方向 */ 0x3A, 0x55, /* 像素格式:16位 RGB565 */ 0xB0, 0x00, 0xB1, 0x00, 0xB6, 0x0A, 0x82, 0x27, 0xC0, 0x23, 0xC1, 0x10, 0xC5, 0x3E, 0x28, 0xC7, 0x86, 0xE0, 0x00, 0x0D, 0x18, 0x0E, 0x0A, 0x09, 0x26, 0x43, 0x53, 0x0F, 0x13, 0x0F, 0x0D, 0x2B, 0x33, 0xE1, 0x00, 0x0D, 0x19, 0x0C, 0x09, 0x09, 0x26, 0x42, 0x52, 0x0F, 0x12, 0x0E, 0x0D, 0x2A, 0x33, 0x29, /* 开启显示 */ };逐条说一下关键命令:
- 0x01是软件复位,执行后要延时至少5ms再继续。这步不是必须的,但如果你的屏是从别的状态切换过来的,最好别省。
- 0x11是退出睡眠。屏幕上电默认处于睡眠模式,不退出的话就算你写了像素也不显示。这步之后需要延时120ms左右,让内部稳压器稳定。
- 0x36是内存访问控制,这个寄存器的bit7、bit6、bit5分别是MY、MX、MV,控制扫描方向;bit3是BGR顺序控制。0xC8对应的二进制是11001000,也就是MY和MX都被置位,同时BGR为1。如果你发现屏幕上下左右颠倒或者颜色偏红偏蓝,改的就是这个寄存器。
- 0x3A设置像素格式,0x55表示16位色RGB565,也就是每个像素用2个字节表示。如果你用0x66(18位)或者0x77(24位),后面写像素数据的格式都要跟着变,刷屏速度也会更慢。
- 后面的0xB0、0xB1、0xB6、0xC0、0xC1、0xC5、0xC7是电源和显示时序控制,一般照搬参考值就行。E0/E1分别是正负电压的Gamma校正曲线,用来调节灰阶过渡,不同屏幕模组可能会有差异,出现颜色断层时可以微调。
- 最后0x29开启显示,前面所有配置才会真正生效。
初始化函数里加延时的方式我用的是HAL_Delay,注意HAL_Delay依赖SysTick中断,如果项目里关了SysTick,延时就会失效。还有一种写法是用裸延时循环,但建议直接保持SysTick开启。
3.3 设置显示窗口与写GRAM
设置窗口是ILI9341用起来和串口屏最大的区别。你想往屏上任何区域画东西,都要先告诉屏幕“我要写哪个矩形区域”,然后连续写入像素数据,屏幕会自动按行填充,写满一个矩形后再回到起点。
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); }这里两个命令分别是列地址设置(0x2A)和页地址设置(0x2B),参数都是16位的起始值和结束值。写完这两个命令后,连续发过来的像素数据就会写到这个窗口内。
画点函数就是设一个1x1的窗口,然后写入RGB565数据:
void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color) { if (x >= LCD_WIDTH || y >= LCD_HEIGHT) return; LCD_SetWindow(x, y, x, y); LCD_WriteData(color >> 8); LCD_WriteData(color & 0xFF); }这里有一个很关键的坑:ILI9341的GRAM坐标范围是240x320,而2.8寸屏在竖屏模式下显示区域是240宽、320高。如果你的初始化里0x36配置了交换行列(MV=1),那x和y的映射关系就和硬件坐标相反,很多人画矩形时边缘总有一条线错位,多半是这个原因。我的做法是统一用“应用层坐标”,即x范围0~239,y范围0~319,然后根据0x36的方向设置决定是否需要交换。
3.4 矩形填充和字符显示
有了窗口和画点函数,填充矩形就很自然了:
void LCD_Fill(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t pixels = (uint32_t)(x1 - x0 + 1) * (y1 - y0 + 1); LCD_SetWindow(x0, y0, x1, y1); for (uint32_t i = 0; i < pixels; i++) { LCD_WriteData(color >> 8); LCD_WriteData(color & 0xFF); } LCD_SetWindow(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1); }注意最后把窗口复位到全屏,否则后面画其他东西时窗口还停留在上次的位置,显示就会错乱,这个细节能帮你省掉很多调试时间。
字符显示的原理是字模。以8x16字体为例,一个字符用16个字节表示,每个字节对应8列像素,字节的每一位对应一行的黑白。显示的时候先画背景色,再根据字模每一位决定用前景色还是背景色。用PCtoLCD2002取模时,参数要选“阴码、逐列式、逆向”,这样取出来的数组正好是按列从左到右排列的,配合画点逻辑最顺手。汉字因为要用UTF-8编码,直接放在const数组里没问题,但要注意编码格式,工程文件里如果是GBK而代码里写UTF-8,取模后显示出来的字会乱。
3.5 提高刷新速度的几种方式
刷屏速度是很多人做完第一版后才开始关心的。2.8寸SPI屏在9MHz下全屏清一次大概需要几十毫秒,短时间连续刷新会看到明显的闪烁和撕裂。优化手段按优先级排序:
- 连续写GRAM时不要反复拉CS和设置窗口,一次窗口写到底。上面的LCD_Fill就是这么做的。
- 用DMA传输。HAL_SPI_Transmit_DMA可以一次把一整个缓冲区的数据发给屏,CPU不阻塞。适合做动态波形、视频流这种场景。
- 降低像素格式?16位RGB565已经是2字节一个像素了,没有更低的常规选择了,做界面一般不用调。
- 把数据线改成8位或16位并口。前提是MCU和屏都支持,C8T6没有FSMC,但GPIO模拟并口也能跑到几兆Hz,对于240x320的分辨率提升明显。
4. 常见问题与排查技巧
4.1 白屏:先从硬件和基础时序查起
白屏是驱动ILI9341时遇到最多的现象,我把它排在第一位。排查顺序如下:
- 背光亮不亮。如果背光不亮,先看BL引脚电平,很多模组是低电平无效,我直接用GPIO拉高;如果亮了但屏幕无内容,跳过这条。
- 复位是否执行。用GPIO控制RST时,上电后要拉低至少20us再拉高。如果你把RST接到了3.3V上没有用GPIO控制,也大概率能亮,但部分模组由于内部RC复位时序问题会出现“上电概率性白屏”,解决办法就是GPIO控制RST。
- 初始化序列是否执行完整。特别是0x11(退出睡眠)和0x29(开启显示)。我见过有人把初始化代码从参考工程里抄过来,但把0x29漏了,结果屏是什么都不显示。
- SPI模式是否匹配。在CubeMX里把SPI配置从模式0改成模式3试试。注意改完要重新初始化显示,否则时序对不上。
- 用逻辑分析仪抓波形。看SCK上有没有脉冲、MOSI电平是否变化,这是定位“主控根本没发数据”和“屏没收到数据”的最快方法。
4.2 花屏和颜色异常:多为接线、速率、字节序
花屏的典型场景是在点亮成功之后,画面出现彩色噪点、横纹、错位。
第一种可能是接线太长,杜邦线超过15厘米,SPI信号边沿劣化。把线缩短,或者把SPI时钟从9MHz降到4.5MHz试试。稳定后如果还想提速,可以在MOSI、SCK上串33欧电阻改善振铃。
第二种可能是颜色顺序反了。如果你显示红色却变成蓝色,或者人脸发青,十有八九是0x36寄存器里的BGR位没有设对。0x36 = 0xC8是“BGR=1”,如果你用0x08或者0x48,色彩就会颠倒,把BGR位反过来即可。
第三种可能是字节序问题。写RGB565时,大多数驱动采用高字节在前(color >> 8再发送)。如果你用的是现成库,跟另一份代码的字节序不一致,就会看到色块错位、边缘有杂色,统一成高字节在前就好。
4.3 画面偏移、镜像和部分区域不显示
画面整体镜像、上下颠倒,去调0x36的MY和MX位就行。比如你希望竖屏但显示成了横屏(或者反过来),这就是MV位在起作用,0xC8的MY+MX组合会让屏幕按从左到右、从上到下的顺序扫描,具体哪个组合适配你的模组,需要实测两三次。
部分区域不显示或显示到别的地方,则基本可以断定是窗口设置函数有问题。检查0x2A和0x2B传的是不是合法坐标范围,注意x1、y1是包含边界值的,比如画一个从(0,0)到(239,319)的整屏窗口,坐标最大值就是239和319。如果写了240,屏幕GRAM地址就溢出到下一行了。
4.4 触摸和背光干扰显示
如果模组带触摸功能(常见XPT2046),触摸控制器会复用部分SPI引脚。你需要确认屏和触摸是否共用SPI总线和片选,别在初始化屏幕的时候把触摸片选也拉低了,否则触摸控制器会在SPI总线上抢数据,轻则花屏,重则初始化失败。触摸芯片一般单独一个CS引脚,同时在读触摸坐标时会占用一定时间,可以用轮询方式读取,时序上注意和屏幕刷新错开。
背光PWM调节时,如果把PWM频率设置得太低(比如几百Hz),仔细看屏幕能察觉到亮度闪烁。一般把PWM频率提到1kHz以上就基本看不出来了,如果做摄影灯那种高刷新场合,背光干脆不要PWM,直接拉满电压。
5. 后续可以怎么玩
显示打通以后,这个组合能扩展的方向很多。一是加触摸,在2.8寸屏上做按键菜单、滑动手势,配合C8T6的20KB RAM跑一个轻量级状态机完全没问题。二是显示传感器数据,接DHT11、BMP280、MPU6050,把温度、气压、姿态实时画在屏上,这就是一个小型仪表板。三是上RTOS,比如FreeRTOS里开一个显示任务、一个采集任务,让屏的刷新和数据处理解耦,这是嵌入式多任务开发很好的练手项目。
如果对性能不满意,可以试试“画中画”局部刷新,只在数据变化时更新相关区域,而不是整个屏幕重画,实战里很多界面卡顿都是因为“全屏重绘”惹的祸。这个优化思路,比单纯追求SPI频率更实用。最后再分享一个小技巧,做界面之前先把常用的“画矩形、填充、显示字符串”这几个函数封装好,后面所有界面代码都会轻松很多,我第二次做项目时就直接复用这一套底层,节省了大量时间。
本文还有配套的精品资源,点击获取