简介:STM32F103RCT6微控制器搭配1.44英寸TFT彩色液晶屏的嵌入式显示工程,面向需要学习单片机串行外设接口驱动、彩色屏幕初始化以及图形界面显示的开发者。资源压缩包内共有八十六个文件,以C语言源码和头文件为主体,同时包含启动文件、工程配置文件、可烧写固件、说明文档以及烧录辅助脚本,整个压缩包仅二百八十九KB,目录结构清晰,可以直接导入Keil工程并运行。资源内提供了底层液晶驱动、图形绘制库、汉字字库、延时控制以及指示灯控制等模块,并通过示例程序完整展示了从引脚功能定义、串行外设接口初始化、命令与数据发送,到显存缓冲区更新和屏幕刷新显示的全流程。同时附带标准外设库与处理器启动文件,便于深入理解寄存器操作与工程启动机制。该项目已吸引四千零五十一人学习下载,对电子竞赛、课程设计或入门嵌入式显示开发均有参考价值,能够帮助开发者快速跑通屏幕显示并迁移到其他基于该微控制器的应用场景。
1. STM32F103RCT6 + 1.44TFT:一块 128×128 小屏点亮背后的完整链路
某开发者第一次拿到 1.44 寸 TFT 屏幕时,以为接上五根线就能显示,结果上电白屏,折腾两天才发现是复位时序不对。STM32F103RCT6+1.44TFT屏幕显示这个组合,看起来只是“点一块屏”,实际链路从 SPI 总线、ST7735 驱动芯片初始化、坐标窗口管理,一直到字模和图片数据的组织,每一环都藏着坑。这块屏分辨率 128×128、色彩 16 位 RGB565,适合做小体积人机交互终端、实验仪表显示面板,或者作为入门 STM32 的第一个外设驱动项目。下面按顺序拆开这份驱动工程:引脚连接、SPI 配置、ST7735 核心初始化、画点画线显示文字、常见坑点排障,最后落到怎么改成菜单框架。初级开发者可以照着把屏幕点亮,有经验的人可以直接拿底层接口去改业务。
2. 硬件连接与 SPI 总线配置:先让数据真正跑到屏幕上
2.1 分清 SPI 屏与串口屏:八根线怎么接
1.44 寸 TFT 模块上的丝印一般写着 VCC、GND、CS、RESET、DC、SDA、SCL、BL。这块屏内部是 ST7735 驱动,四线 SPI 协议,DC 脚决定当前字节是命令还是数据,跟常见的串口屏完全是两码事。串口屏通常只有 TX、RX 两根通信线,内部自带协议解析;而 SPI 屏的所有时序都要 MCU 自己控制,这也是很多人把“串口屏”的接法套到 SPI 屏上翻车的原因。
我常用的接法是走 STM32F103RCT6 的 SPI1 默认引脚,PA5 做 SCK、PA7 做 MOSI,不接 MISO(只写不读)。CS 接到 PA4,DC 接到 PA1,RESET 接到 PA0,BL 背光控制接到 PA2 或者直接 3.3V。完整的引脚分配见下表,实际焊接前先对一下自己板子的原理图,别照抄网上的图。
| 屏幕引脚 | 功能说明 | STM32F103RCT6 引脚 |
|---|---|---|
| VCC | 模块电源 3.3V | 3.3V |
| GND | 电源地 | GND |
| CS | 片选,低电平有效 | PA4 |
| RESET | 硬件复位,低有效 | PA0 |
| DC | 数据/命令选择 | PA1 |
| SDA | SPI 数据输入 | PA7 (MOSI) |
| SCL | SPI 时钟 | PA5 (SCK) |
| BL | 背光,高电平点亮 | PA2 或直接 3.3V |
接之前一定要确认模块上有没有电平转换和稳压芯片。老式模块 VCC 标 5V 也能工作,但 1.44 寸这些模块多数是纯 3.3V,直接怼 5V 会烧屏,这是血泪经验。烧了之后屏幕的表现是通电后全屏一个颜色不变,背光正常但不显示任何内容,这时候只能换屏。还有一点,有些模块丝印写的 SDA、SCL 看起来像 I2C,其实在 SPI 屏上就是 MOSI 和 SCK,别被丝印误导。
2.2 使能时钟与 GPIO 初始化:用标准库把引脚管起来
工程我用 STM32 标准外设库,工程模板建好之后,第一步是开 GPIOA 和 SPI1 的时钟。GPIO 里要区分两种引脚:CS、DC、RESET、BL 是普通推挽输出,SCK 和 MOSI 是复用推挽输出。很多新人把所有引脚全配置成 GPIO_Mode_Out_PP,SPI 硬件模式下发数据会失败,因为 MOSI 和 SCK 必须交给 SPI 外设控制,不能再当普通 IO 口用。
void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); /* CS / DC / RESET / BL 这些控制脚,普通推挽即可 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* SCK / MOSI 复用为 SPI1 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 初始状态 */ GPIO_SetBits(GPIOA, GPIO_Pin_0); /* RESET 先拉高 */ GPIO_SetBits(GPIOA, GPIO_Pin_2); /* BL 默认点亮或关闭 */ GPIO_SetBits(GPIOA, GPIO_Pin_4); /* CS 默认拉高,不选中 */ }这里把控制脚和 SPI 数据脚分开配置,是因为外设模式下 GPIO 必须是复用推挽,普通推挽只是“能拉高拉低”,但引脚已经不属于 GPIO 外设管理了。RESET 引脚初始化时先拉高,后面初始化序列里再按“拉低-延时-拉高”走一遍。CS 默认拉高,避免在 SPI 还没配置好时误选中屏幕。
2.3 SPI 模式与波特率:CPOL 和 CPHA 决定了能不能点亮
ST7735 的数据手册里 SPI 时序支持 Mode 0 和 Mode 3,绝大多数模块的驱动代码是 Mode 0:CPOL=Low,CPHA=1Edge。有个很隐蔽的问题:网上有些教程把 CPHA 写成 2Edge,屏幕也能亮,但采样点落在数据变化沿附近,长时间跑会出现偶发闪屏,这种问题最难定位,因为不是每次上电都复现。
void LCD_SPI_Init(void) { 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_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }SPI_BaudRatePrescaler_8 在 72MHz 主频下得到 9MHz 的 SPI 时钟,短杜邦线环境下够用。如果你的板子主频不是 72MHz,记得按实际时钟树去算分频后的总线速率。写 SPI 字节的底层函数用轮询方式,发送前查 TXE 标志位,发送后等 BSY 拉低,这是保证一字节完整送出去的常规写法。
static void LCD_SPI_WriteByte(uint8_t data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); }写完一个字节等 BSY 清掉,再发下一个。这个函数是底层所有显示操作的最终出口,后续写命令、写像素数据都会调它。换成 HAL 库时,GPIO 用 GPIO_MODE_AF_PP 配置,SPI 通过句柄调用 HAL_SPI_Transmit,CPOL/CPHA 参数一一对应,初始化序列可以直接照搬。
3. 核心模块拆解:从 ST7735 初始化到第一帧画面
3.1 初始化序列:代码里那串 0x11、0x36 到底在做什么
ST7735 是这块屏幕的“大脑”,内部有一组控制寄存器。上电后寄存器处于默认状态,显示是关闭的,必须通过 SPI 写入一段初始化序列,告诉屏幕“像素格式用 RGB565、扫描方向怎么走、开显示”。我摘出最常用的初始化函数:
void LCD_Init(void) { LCD_GPIO_Init(); LCD_SPI_Init(); /* 硬件复位时序 */ LCD_RESET_Clr(); delay_ms(10); LCD_RESET_Set(); delay_ms(20); LCD_WR_REG(0x11); /* Sleep Out,退出睡眠模式 */ delay_ms(120); LCD_WR_REG(0x36); /* MADCTL,设置扫描方向和 RGB/BGR */ LCD_WR_DATA(0x60); LCD_WR_REG(0x3A); /* COLMOD,像素格式 */ LCD_WR_DATA(0x05); /* 16bit / pixel */ LCD_WR_REG(0x20); /* INVON,反色关闭 */ LCD_WR_REG(0x13); /* NORON,正常显示模式 */ delay_ms(10); LCD_WR_REG(0x29); /* DISPON,打开显示 */ delay_ms(100); }这里省略了伽马校正相关的命令段(0xE0、0xE1 那一串),因为驱动 IC 出厂时 ROM 里已经烧了默认值,直接写核心命令也能正常显示。0x11 退出睡眠后必须延时 120ms 等内部时钟稳定,0x29 开显示后再延时 100ms,这两个延时是屏幕不发白的两个关键点。0x36 里的数据 0x60 含义是 MX=1、MY=1,按左上角为原点布局;如果屏幕上下颠倒,就把 0x60 改成 0xA0 或 0xC0 试试,这是调屏幕方向最快的入口。
3.2 命令与数据通道:DC 引脚把路分开
ST7735 的 DC 引脚决定当前字节是命令还是数据,驱动里封装两个函数来区分:
void LCD_WR_REG(uint8_t reg) { LCD_DC_Clr(); /* DC=0 表示命令 */ LCD_CS_Clr(); LCD_SPI_WriteByte(reg); LCD_CS_Set(); } void LCD_WR_DATA(uint8_t data) { LCD_DC_Set(); /* DC=1 表示数据 */ LCD_CS_Clr(); LCD_SPI_WriteByte(data); LCD_CS_Set(); }每次传输前把 CS 拉低、传输完拉高,DC 决定走命令通道还是数据通道。注意连续往显存搬数据时,CS 可以一直拉低不用来回切换,但 DC 必须保持高电平,否则屏幕会把像素数据当成命令解析。
3.3 坐标窗口与打点:把每个像素翻译成 RGB565
屏幕显示的第一步是“选点”。ST7735 内部有一块 132×162 的显存,但 1.44 寸屏只显示 128×128 的区域,所以要先用 CASET(列地址)和 RASET(行地址)定义一块窗口,再往窗口里填像素数据。窗口设置函数如下:
void LCD_SetWindow(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { LCD_WR_REG(0x2A); /* CASET */ LCD_WR_DATA(x0 >> 8); LCD_WR_DATA(x0 & 0xFF); LCD_WR_DATA(x1 >> 8); LCD_WR_DATA(x1 & 0xFF); LCD_WR_REG(0x2B); /* RASET */ LCD_WR_DATA(y0 >> 8); LCD_WR_DATA(y0 & 0xFF); LCD_WR_DATA(y1 >> 8); LCD_WR_DATA(y1 & 0xFF); LCD_WR_REG(0x2C); /* RAMWR,准备写显存 */ }窗口设完之后,写入的数据会按行列顺序自动填充。打点函数就是先设一个 1×1 的窗口,再写一个 16 位颜色值:
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_DC_Set(); LCD_CS_Clr(); LCD_SPI_WriteByte(color >> 8); LCD_SPI_WriteByte(color & 0xFF); LCD_CS_Set(); }颜色值是 RGB565 格式,高字节在前,所以分两次发送。如果整个工程都按低字节在前发,屏幕颜色就会整个错位,红色显示成蓝色之类,这是很典型的翻车点。LCD_WIDTH 和 LCD_HEIGHT 是宏定义,固定 128,二次开发换屏时只改宏。
3.4 画线、画矩形、画圆:在打点基础上做加法
有了打点函数,画实心矩形就是双重循环填充,写起来最直接:
void LCD_FillRect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint16_t i, j; for (i = y0; i <= y1; i++) { for (j = x0; j <= x1; j++) { LCD_DrawPoint(j, i, color); } } }画圆用中点画圆算法,利用八分圆的对称性,每算出一个坐标就画 8 个对称点:
void LCD_DrawCircle(uint16_t xc, uint16_t yc, uint16_t r, uint16_t color) { int x = 0, y = r, d = 1 - r; while (x <= y) { LCD_DrawPoint(xc + x, yc + y, color); LCD_DrawPoint(xc - x, yc + y, color); LCD_DrawPoint(xc + x, yc - y, color); LCD_DrawPoint(xc - x, yc - y, color); LCD_DrawPoint(xc + y, yc + x, color); LCD_DrawPoint(xc - y, yc + x, color); LCD_DrawPoint(xc + y, yc - x, color); LCD_DrawPoint(xc - y, yc - x, color); if (d < 0) { d += 2 * x + 3; } else { d += 2 * (x - y) + 5; y--; } x++; } }这几个函数组成了 GUI 层的最小集合。真正的项目里不会在应用层直接调 LCD_WR_REG,而是封装成 LCD 驱动库,应用层只认 DrawPoint、DrawLine、DrawCircle、ShowString 这几个接口。
4. 显示效率与数据组织:刷新一屏究竟要多久
4.1 算一笔数据账:128×128×2 字节的代价
1.44 寸屏全屏 128×128,RGB565 每个像素 2 字节,全屏刷新需要连续写入 32768 字节。SPI 时钟 9MHz,一个字节 8 位,纯数据搬运时间约 29ms,再加上命令和窗口切换开销,一屏刷完大概 35~40ms,实际刷新率 25 帧左右。这也是很多初学者写出全屏循环画点后觉得“屏幕闪得不行”的原因,因为 SPI 的带宽就摆在那里。
想要提速,最直接的办法是连续写。画实心矩形不要一个点一个点调 LCD_DrawPoint,而是把窗口设为整个矩形区域,然后用循环连续送数据:
void LCD_FillRectFast(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) { uint32_t total = (x1 - x0 + 1) * (y1 - y0 + 1); uint32_t i; LCD_SetWindow(x0, y0, x1, y1); LCD_DC_Set(); LCD_CS_Clr(); for (i = 0; i < total; i++) { LCD_SPI_WriteByte(color >> 8); LCD_SPI_WriteByte(color & 0xFF); } LCD_CS_Set(); }这样数据流只做一次窗口设置,剩下的全是 SPI 连续搬运,刷新同样一个矩形区域的时间能缩短一个数量级。驱动层写好连续写接口,上层才能做高效渲染。
4.2 局部刷新与 DMA:小内存下的显示策略
很多界面只是更新一个数字或一段进度条,没必要整屏重绘。ST7735 的窗口机制天然支持局部刷新,比如显示一个温度值,只需要把数值所在的矩形窗口 set 出来写入新内容。这样单次刷新的数据量从 32KB 降到几百字节,整机响应速度快很多。
| 刷新方式 | 数据量 | 典型耗时 | 适用场景 |
|---|---|---|---|
| 全屏填充颜色 | 32KB | 约 35ms | 页面切换、清屏 |
| 局部窗口刷新(8×16 字符) | 256 字节 | 小于 1ms | 温度、速度等数值实时更新 |
| DMA 后台刷新 | 32KB | CPU 不阻塞 | 动画演示、波形绘制 |
如果要做动画效果,可以用 DMA 搬运。做法是把显示数据放到一个数组里,SPI 初始化之后用 SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE) 启动 DMA 发送,CPU 可以继续跑业务逻辑。但这个方案会吃掉大量 SRAM,F103RCT6 的 48KB SRAM 放一帧全屏数据就占了 32KB,需要非常克制,我一般只在局部窗口小动画时才用。
4.3 显示字符串与汉字:字模数据怎么组织
英文字符可以用取模软件直接取 6×8 或 8×16 的点阵,存成 const 数组放 Flash。显示一个字符的代码大致是:
void LCD_ShowChar(uint16_t x, uint16_t y, char ch, uint16_t color) { uint8_t i, j; const uint8_t *font = ASCII8x16[(uint8_t)ch - 32]; for (i = 0; i < 16; i++) { for (j = 0; j < 8; j++) { if (font[i] & (0x80 >> j)) { LCD_DrawPoint(x + j, y + i, color); } else { LCD_DrawPoint(x + j, y + i, LCD_BLACK); } } } }这个函数每个点都走打点,显示一个字符需要 8×16=128 次打点调用,速度不算快但足够用。汉字建议用 16×16 点阵,F103RCT6 内部 256KB Flash 放常用汉字没问题,但如果是几千字的完整字库就要外挂 SPI Flash 或者从 SD 卡读取。取模参数要固定在文件头注释里,包括字体、宽高、扫描方式、是否需要反色,方便后来者快速对上。
5. 避坑指南:白屏、花屏、偏色的排查顺序
5.1 白屏但背光亮:先查复位时序
现象:上电后屏幕纯白,背光亮,代码编译下载都没报错,逻辑分析仪看到 MOSI 有波形。
原因:白屏且背光亮说明电源和背光正常,问题大概率出在 ST7735 的复位时序或 SPI 通信没建立起来。很多模块的 RESET 引脚如果直接接 VCC,内部 RC 复位电路触发不了完整的复位流程,寄存器状态混乱。
解决:让 RESET 先拉低 10ms 再拉高 20ms,再进初始化序列:
LCD_RESET_Clr(); delay_ms(10); LCD_RESET_Set(); delay_ms(20);如果这条解决不了,用示波器看 MOSI 上有没有波形。有波形但白屏,大概率是初始化序列少了关键命令;没波形,则是 SPI 没跑起来,回头查 GPIO 复用配置和 SPI 时钟使能。
5.2 花屏或斜纹:SPI 速率和飞线
现象:屏幕能显示内容,但画面带斜向条纹,或者刷新时边缘闪烁,显示不稳定。
原因:SPI 时钟太高,或者杜邦线飞得太长、太乱导致信号串扰。9MHz 对 ST7735 不是极限,但飞线环境下波形会变形,数据采样出错就是花屏。
解决:把 SPI_BaudRatePrescaler 从 8 改成 16,时钟降到 4.5MHz 试一下。画面干净就是速率问题,再把线剪短、GND 线接好、SCK 和 MOSI 分开走。做原型验证时这种玄学问题最耗时间,我一般先降速再查线。
5.3 颜色整个不对:RGB/BGR 位序和字节序
现象:红色显示成蓝色,或者整个画面颜色像调色盘错位,偏冷偏暖明显。
原因:ST7735 的 0x36 寄存器里 BGR 位控制颜色通道顺序,模块不同厂商对这个位的默认值处理不一样。另外 RGB565 发送时高字节在前,如果工程里统一发了低字节在前,颜色也会错乱。
解决:初始化函数里把 0x36 的数值在 0x60 和 0x68 之间切换。0x60 是 RGB 顺序,0x68 是 BGR 顺序,逐个试到颜色正常为准。写像素时强制 high byte first,驱动层统一封装,不要在应用层临时拼字节。
5.4 上电偶尔白屏,按一下复位就好:电源在捣乱
现象:上电后随机白屏,按复位键可能恢复;运行几分钟后偶尔花屏、闪屏。
原因:电源问题。屏幕模块启动瞬间电流较大,如果 MCU 和屏幕共用一个 LDO 且电容不足,电压跌落导致 ST7735 初始化失败或运行中掉数据。
解决:屏幕 VCC 引脚旁边就近并一个 10uF 电解电容加一个 0.1uF 陶瓷电容。背光供电如果条件允许就单独走一路。这类问题排查优先级应该放在 SPI 时序之前,不然改代码调几天都没用。
5.5 字符缺上半截或偏移:取模方式对不上
现象:显示英文字符时上半截缺失、左右偏移,或者显示汉字时出现乱点。
原因:取模软件里“行扫描”和“列扫描”选错,或者高位在前、低位在前配置和解析代码不一致。代码里用 font[i] & (0x80 >> j) 逐位判断,就要求取模时也是高位在左、逐行排列。
解决:取模时固定选择“行扫描,高位在前”。为了不再踩同一个坑,我把取模参数写进字模文件的注释头:字体、宽高、扫描方式、反色标志,半年后翻代码也能一眼对上。
6. 二次开发实战:把固定画面改成按键菜单框架
6.1 驱动层与应用层分离
拿到这份工程后,第一件事是把 LCD_WR_REG、LCD_SetWindow 这些底层函数锁在 lcd_drv.c 里,对外只暴露 DrawPoint、DrawLine、FillRect、ShowString、ShowNum 这类接口。应用层代码不直接操作 SPI。这样后续换库、换屏幕,比如从 1.44 寸换成 2.0 寸,只改驱动层,页面代码不用动。
我一般把上层显示封装成页面函数,比如 Page_Home、Page_Config,再用按键触发切换。切换骨架用状态机实现:
typedef enum { PAGE_HOME, PAGE_CONFIG, PAGE_ABOUT } PageId; void UI_Process(uint8_t key) { static PageId current = PAGE_HOME; switch (key) { case KEY_NEXT: if (current < PAGE_ABOUT) { current++; } else { current = PAGE_HOME; } break; case KEY_OK: /* 页面内的确认动作,交给当前页面处理 */ break; default: break; } switch (current) { case PAGE_HOME: Page_Home(); break; case PAGE_CONFIG: Page_Config(); break; case PAGE_ABOUT: Page_About(); break; } }按键用 GPIO 外部中断扫描,去抖延时 20ms。每次切换重新绘制整个页面,页面内部尽量使用局部刷新,比如配置页面调整数值时只重绘数字所在窗口,不重绘整屏。
6.2 换屏和换驱动的预留方案
如果想把这块 1.44 寸屏换成 1.8 寸或 2.0 寸屏,驱动芯片往往变成 ST7735R 或 ILI9341。底层接口保持 LCD_SetWindow、LCD_DrawPoint 不变,只需要改初始化序列、屏幕宽高宏定义。把屏宽高定义在 lcd_conf.h 里,上层 GUI 全部引用宏而不直接写 128,是每次点屏都必须养成的习惯。
我从第一次做 1.44 寸屏翻车之后,现在写显示工程会强制走一遍流程:确认驱动芯片型号、确认引脚和电压、写初始化序列、跑一个纯色全屏测试、再写字符和图形。这套流程每换一块屏都能省掉不少调试时间,希望帮到你。如果你正需要一份能直接编译的 1.44TFT 驱动底子,工程里除了上述源码,还有完整的可编译工程结构和取模说明。
本文还有配套的精品资源,点击获取