简介:这是基于STM32CubeIDE非常详细地从零开始驱动ST7735S液晶屏的完整资料包,面向嵌入式初学者及需要快速点亮LCD显示界面的开发者。屏幕采用的驱动芯片为ST7735S,属于1.8英寸TFT全彩屏,通过SPI接口通信,分辨率128乘以160,颜色格式为RGB565,即红色占高5位、绿色占中间6位、蓝色占低5位。资料配有外部接口原理图、工程源码和调试记录,清晰梳理了从引脚连接到软件配置再到显示输出的完整流程。压缩包共269个文件,大小7.38MB,其中包含51个头文件和25个C源文件,以及.ioc、.project、.cproject等CubeIDE工程配置,还保留了.ld、.map、.o、.elf、.bin等编译链接产物,方便对照复现整个构建过程;另有PDF、txt文档用于辅助阅读和说明。目前已有388人学习下载。整套资料按实际项目组织,目录结构清晰,读者可依据这份资源理解ST7735S的多种接口时序,掌握SPI屏幕驱动的移植方法,并借助调试记录快速排查类似问题。
1. 点亮ST7735S这件事,麻烦不在画点在初始化
标题里那句“STM32CubeIDE一步一步驱动LCD”看着像教程,真上手一次就明白:这个屏用STM32CubeIDE点亮,90%的功夫花在把初始化序列按顺序发对,剩下10%才是画点、填充和显示字符。ST7735S是一颗1.8寸TFT彩屏的驱动IC,160×128分辨率,靠SPI串口和主控通信,总共只需要SCK、MOSI、D/C、CS四根信号线,加上复位和背光也就是六根线。它在内部自带一块GRAM,主机画像素的本质是把颜色值写进远端内存,而这块内存的地址由CASET/RASET命令圈定。适合正在做显示界面、需要理解SPI时序和屏幕寄存器的人;本文不借助Arduino库,直接在STM32CubeIDE里用HAL库把整个链路跑通,从原理图引脚到代码调试一次说完。
2. ST7735S的GRAM和SPI总线:先把寄存器模型讲透
2.1 数据手册里的命令地址体系:CASET/RASET/RAMWR
ST7735S内部有一块SRAM做显存,160×128个像素点,每个像素在16位模式下占2字节,颜色格式是RGB565。理解这颗芯片的关键,是接受一个事实:主机不能直接改显存字节,只能通过命令去操作。想要把一个点变成红色,不是发一个“红”字,而是先告诉芯片列地址范围,再告诉行地址范围,最后用RAMWR命令把0xF800这样的颜色值灌进去。芯片内部的行列译码器会用移位时钟逐点推进指针,写满一个地址后自动跳到下一个。
这里有三条命令必须记牢,后面所有画点、画线、清屏函数都是从它们组合出来的。CASET是0x2A,作用是设置列地址(X方向)的起始和结束;RASET是0x2B,设置行地址(Y方向)的起始和结束;RAMWR是0x2C,启动连续写显存模式。地址范围宽高都算在内,比如清全屏就是CASET设成0到159、RASET设成0到127,然后往GRAM里连续写160×128个RGB565值。
| 命令 | 命令码 | 参数长度 | 作用 |
|---|---|---|---|
| CASET | 0x2A | 4字节 | 设置列地址起始和结束,参数是X0高位、X0低位、X1高位、X1低位 |
| RASET | 0x2B | 4字节 | 设置行地址起始和结束,参数是Y0高位、Y0低位、Y1高位、Y1低位 |
| RAMWR | 0x2C | 连续数据 | 启动写GRAM,之后SPI上发多少字节就写多少个像素 |
| RDDID | 0x04 | 读回3字节 | 读芯片ID,用于验证SPI通信是否打通 |
| MADCTL | 0x36 | 1字节 | 扫描方向和RGB/BGR顺序,实现旋转 |
| COLMOD | 0x3A | 1字节 | 像素格式,0x05表示16位/像素 |
除了这六条,初始化还会用到电源控制、伽马、帧率等一串寄存器,那些不属于日常绘图路径,但会影响显示质量。先把模型建立起来:屏幕是一台带显存从机,主机发命令和参数,芯片自己刷屏。写代码时所有通信都围绕“发命令”和“发数据”两个动作,不会有其他玄学。
2.2 D/C信号线、SPI Mode 0和字节顺序
SPI总线本身是纯字节流,芯片靠D/C信号线区分当前收到的是命令还是数据。D/C拉低,当前传输的第一个字节被解释为命令码;D/C拉高,后面所有字节都被解释为数据。一个完整操作通常是:先拉低D/C发命令码,再拉高D/C发参数。比如设置16位像素格式,就是D/C低发0x3A,D/C高发0x05。
时序模式上,ST7735S手册支持Mode 0和Mode 3,大多数屏厂示例驱动用Mode 0。在STM32CubeIDE里对应CPOL=Low、CPHA=1 Edge,也就是时钟空闲为低、数据在第一个边沿采样。要注意一点:F103的SPI1挂在APB2上,72MHz主频经过分频后是9MHz,而ST7735S的SPI时钟上限一般是10MHz左右,9MHz安全。之前见过有人把分频设成2得到36MHz,屏幕花得像雪花屏,还以为是线松了。字节顺序必须MSB First,ST7735S手册上所有寄存器、颜色数据都是高位先出,HAL库里默认就是这个设置,不要改成LSB。
2.3 读ID验证通信:0x04 RDDID最实用
引脚对没对上、焊接有没有虚焊,与其盯着白屏猜,不如先发一条RDDID命令把芯片ID读回来。ST7735S支持0x04命令,从MISO回3个字节,其中第3字节能区分ST7735S和ST7735R。注意F103的SPI是全双工,需要PA6作为MISO,但市面上很多廉价1.8寸屏模块并没有引出SDO引脚,只留了SDA、SCL、RES、DC、CS、BLK六个脚,这种情况读ID就无从谈起,只能靠初始化后是否出画面来判断。
uint8_t lcd_read_id(void) { uint8_t cmd = 0x04; /* RDDID 命令 */ uint8_t rx[3] = {0}; LCD_CS_LOW; LCD_DC_LOW; /* D/C=0,发送命令字节 */ HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); LCD_DC_HIGH; /* D/C=1,之后都是数据周期 */ HAL_SPI_Receive(&hspi1, rx, 3, 10); LCD_CS_HIGH; return rx[2]; /* 第3字节才是ID */ }这段代码的逻辑是:先拉低CS选中从机,D/C拉低把0x04当作命令发出去,然后D/C拉高,HAL_SPI_Receive在收发数据的同时置MISO为高阻输入,把芯片回送的3个字节收进来。ST7735S读回常见值是0x0C或0x88开头,ST7735R可能回0x88/0x8C,不同批次甚至回0x00,所以读到非0xFF就说明SPI链路大概率正常。如果全是0xFF,优先检查PA5到SCL、PA7到SDA的连通性,以及CS是不是被拉低后真的进了片选状态。
提示:读不到ID不一定是线接错,很多模块把SDO引脚内部悬空,读回数据本来就是0xFF。读ID只是辅助手段,不要当成唯一判据。
3. STM32CubeIDE配置SPI1与LCD原理图接线:引脚先对上
3.1 LCD显示屏引脚介绍:ST7735S六线接线表
从模块上看到的引脚比芯片手册简单得多,通常就七个焊盘:VCC、GND、SCL、SDA、RES、DC、CS,有的还有BLK背光控制。VCC接3.3V,个别模块标注5V兼容,但ST7735S逻辑电平是1.8V到3.3V,稳妥起见全用3.3V。BLK是背光LED的使能端,一般内部已经做过处理,外部串一个100Ω电阻到3.3V,想调亮度就接到PWM引脚,频率设1kHz,占空比控制亮度。
| 屏幕引脚 | 功能 | 接STM32F103C8T6 | 备注 |
|---|---|---|---|
| VCC | 电源正 | 3.3V | 板载LDO供电 |
| GND | 电源地 | GND | 与MCU共地,否则SPI电平无参考 |
| SCL | SPI时钟 | PA5 | SPI1_SCK |
| SDA | SPI主出从入 | PA7 | SPI1_MOSI |
| RES | 复位 | PA2 | GPIO输出控制 |
| DC | 命令/数据选择 | PA3 | 0=命令,1=数据 |
| CS | 片选 | PA4 | GPIO输出控制 |
| BLK | 背光使能 | PA1 | GPIO或PWM,高电平点亮 |
原理图上还有两个容易忽略的点。第一,屏幕排线从FPC引出后走线尽量短,超过10cm就要考虑把SPI时钟降到4.5MHz,不然波形反射会在数据线跳变沿出现毛刺。第二,VCC引脚旁边加一个100nF陶瓷电容,靠近屏幕电源脚放置,LCD刷新时瞬间电流能到几十毫安,电源纹波会导致横条纹。有人画原理图只连了线和插座,省掉滤波电容,结果画面出现一条条水平亮线,查了半天是电源噪声耦合进了内部电荷泵。
3.2 在STM32CubeIDE里按这套参数配SPI1
STM32CubeIDE 2.2.0版本下,新建工程时选好MCU型号STM32F103C8T6,双击ioc文件打开设备配置视图。左侧Categories里找到Connectivity-SPI1,打开SPI1的开关,时钟配置会自动把PA5和PA7分配过来。右边参数区按下面这张表设置,其他保持默认。
| 配置项 | 值 | 影响 |
|---|---|---|
| Mode | Full-Duplex Master | 全双工主机模式,MISO可以备而不接 |
| Data Size | 8 Bits | ST7735S所有寄存器都是8位宽 |
| Prescaler | 8 | 72MHz / 8 = 9MHz,小于芯片极限10MHz |
| CPOL | Low | 时钟空闲电平为低 |
| CPHA | 1 Edge | 第一个边沿采样 |
| NSS | Software | PA4用普通GPIO自己控,不用硬件NSS |
| First Bit | MSB First | ST7735S规格要求 |
配完之后点右上角的Generate Code生成初始化代码,会自动生成MX_SPI1_Init函数。如果你发现生成代码按钮灰色不可点,多半是ioc文件没保存,先按Ctrl+S再回来点。生成的代码里SPI初始化部分是固定结构,检查一下BaudRatePrescaler是否真的变成了SPI_BAUDRATEPRESCALER_8,这个数值决定了实际的SCK频率。
hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;分频系数是CubeIDE里最容易改错的一项。Prescaler=8在72MHz主频下得到9MHz,Prescaler=4得到18MHz,这个值已经超过ST7735S允许范围,屏幕大概率不显示或者出现横纹。如果APB2时钟不是72MHz而是36MHz,Prescaler=4也只有9MHz,要先在Clock Configuration里确认APB2 External clock是72MHz。
3.3 复位、背光与电源:暗屏和白屏的两个重灾区
RES引脚用普通GPIO控制,不要直接接RC电路。上电后先拉低50ms再拉高,ST7735S内部需要完整的复位时序才能正确启动。很多白屏案例是RES引脚悬空,芯片上电后内部状态机停在未知状态,初始化命令被吞掉。背光更隐蔽,BLK引脚如果不接,屏幕背光不亮,看起来像黑屏;如果BLK接到了3.3V而模块上没有限流电阻,背光灯珠电流可能超标。加个100Ω串联电阻,既能保护灯珠,也能在调试时用万用表量电压判断背光是否正常。
电源方面再强调一次:屏幕模块和MCU必须共地。只接了电源线、信号线但漏接GND,SPI信号电平没有参考地,逻辑会完全错乱。业余条件下用杜邦线连接时容易出现这种问题,画PCB时把GND铺铜连好,电源脚用100nF+10µF两级滤波。
4. 代码驱动ST7735S:初始化序列、画点函数和字符显示
4.1 先写写命令/写数据两个原子操作
整个驱动建立在两个函数上,一个是写命令,一个是写数据。它们的区别只有一个:D/C引脚的逻辑电平。写命令时D/C拉低,写数据时D/C拉高。CS引脚在每个操作周期内拉低再拉高,保证一个操作完整结束、下个操作干净开始。
void lcd_write_cmd(uint8_t cmd) { LCD_CS_LOW; LCD_DC_LOW; /* D/C=0:命令周期 */ HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); LCD_CS_HIGH; } void lcd_write_data(uint8_t *buf, uint16_t len) { LCD_CS_LOW; LCD_DC_HIGH; /* D/C=1:数据周期 */ HAL_SPI_Transmit(&hspi1, buf, len, 10); LCD_CS_HIGH; } void lcd_wr_cmd_data(uint8_t cmd, uint8_t *param, uint8_t n) { lcd_write_cmd(cmd); lcd_write_data(param, n); }第三个函数把“命令+参数”打包,是初始化序列最常用的入口。HAL_SPI_Transmit的第三个参数是数据长度,单位是字节数而不是位,别写成位宽。超时时间设10ms足够,SPI一字节只有微秒级,出现超时说明总线被其他外设占用或者时钟没配出来。这里有个小坑:CS拉低后D/C必须在第一个字节发送之前稳定,代码里先LCD_DC_LOW再调Transmit,顺序不能反。
4.2 一口气看完整初始化序列:从SLPOUT到DISPON
初始化序列是最容易抄错的部分。不同模块厂商给的序列大同小异,核心是先把芯片从睡眠状态唤醒,再设置扫描方向、像素格式、电源和伽马。我习惯把序列拆成两段,一段只做唤醒和颜色格式,另一段做伽马和帧率,调试时分开验证。下面这段是一个能直接跑通的完整序列,适用于大部分ST7735S模块。
static void lcd_init_sequence(void) { uint8_t p[6]; lcd_write_cmd(0x01); /* SWRESET 软件复位 */ HAL_Delay(120); lcd_write_cmd(0x11); /* SLPOUT 退出睡眠 */ HAL_Delay(120); p[0] = 0x01; p[1] = 0x2C; p[2] = 0x2D; lcd_wr_cmd_data(0xB1, p, 3); /* FRMCTR1 正常模式帧率 */ lcd_wr_cmd_data(0xB2, p, 3); /* FRMCTR2 空闲模式帧率 */ p[0] = 0x01; p[1] = 0x2C; p[2] = 0x2D; p[3] = 0x01; p[4] = 0x2C; p[5] = 0x2D; lcd_wr_cmd_data(0xB3, p, 6); /* FRMCTR3 部分模式帧率 */ p[0] = 0x07; lcd_wr_cmd_data(0xB4, p, 1); /* INVCTR 反色控制 */ p[0] = 0xA2; p[1] = 0x02; p[2] = 0x84; lcd_wr_cmd_data(0xC0, p, 3); /* PWCTR1 电源控制 */ p[0] = 0xC5; lcd_wr_cmd_data(0xC1, p, 1); /* PWCTR2 VGH/VGL升压 */ p[0] = 0x0A; p[1] = 0x00; lcd_wr_cmd_data(0xC2, p, 2); /* PWCTR3 正常模式电源 */ p[0] = 0x8A; p[1] = 0x2A; lcd_wr_cmd_data(0xC3, p, 2); /* PWCTR4 空闲模式电源 */ p[0] = 0x8A; p[1] = 0xEE; lcd_wr_cmd_data(0xC4, p, 2); /* PWCTR5 部分模式电源 */ p[0] = 0x0E; lcd_wr_cmd_data(0xC5, p, 1); /* VMCTR1 VCOM电压 */ p[0] = 0x0F; p[1] = 0x1A; p[2] = 0x0F; p[3] = 0x18; p[4] = 0x2F; p[5] = 0x28; p[6] = 0x20; p[7] = 0x22; p[8] = 0x1F; p[9] = 0x1B; p[10] = 0x23; p[11] = 0x37; p[12] = 0x00; p[13] = 0x07; p[14] = 0x02; p[15] = 0x10; lcd_wr_cmd_data(0xE0, p, 16); /* GMCTRP1 正极性伽马 */ p[0] = 0x0F; p[1] = 0x1B; p[2] = 0x0F; p[3] = 0x17; p[4] = 0x33; p[5] = 0x2C; p[6] = 0x29; p[7] = 0x2E; p[8] = 0x30; p[9] = 0x30; p[10] = 0x39; p[11] = 0x3F; p[12] = 0x00; p[13] = 0x07; p[14] = 0x03; p[15] = 0x10; lcd_wr_cmd_data(0xE1, p, 16); /* GMCTRN1 负极性伽马 */ p[0] = 0xC0; /* MADCTL 扫描方向+BGR */ lcd_wr_cmd_data(0x36, p, 1); p[0] = 0x05; /* COLMOD 16位RGB565 */ lcd_wr_cmd_data(0x3A, p, 1); lcd_write_cmd(0x13); /* NORON 退出部分模式 */ HAL_Delay(10); lcd_write_cmd(0x29); /* DISPON 开显示 */ HAL_Delay(100); }这段序列里,0xB1到0xB4是帧率控制,决定屏幕刷新率,ST7735S内部用行扫描方式刷新面板,这个寄存器配错会出现闪烁或水波纹;0xC0到0xC5是电源管理,内部电荷泵给栅极和VCOM供电,配错会偏色或者花屏;0xE0和0xE1是伽马校正曲线,这是一个16字节查找表,厂家推荐的数值可以直接用,个人调色时微调中间几个字节能改变对比度。最需要注意的是0x36那条,0xC0表示行扫描反向加BGR颜色顺序。如果你的模块显示出来的颜色红蓝互换,把0xC0改成0x00;如果竖屏显示方向反了,后面讲到旋转时会有具体表格。
4.3 圈地址再灌颜色:画点、矩形填充与清屏
有了初始化序列,剩下的绘图函数全都是CASET和RASET的组合。先设置窗口,再连续灌像素数据。一个矩形填充的完整代码如下:
void lcd_fill_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { uint8_t p[4]; p[0] = x >> 8; p[1] = x & 0xFF; p[2] = (x + w - 1) >> 8; p[3] = (x + w - 1) & 0xFF; lcd_wr_cmd_data(0x2A, p, 4); /* CASET: 列范围 */ p[0] = y >> 8; p[1] = y & 0xFF; p[2] = (y + h - 1) >> 8; p[3] = (y + h - 1) & 0xFF; lcd_wr_cmd_data(0x2B, p, 4); /* RASET: 行范围 */ lcd_write_cmd(0x2C); /* RAMWR 开始写 */ for (uint32_t i = 0; i < (uint32_t)w * h; i++) { uint8_t d[2]; d[0] = color >> 8; /* RGB565 高字节 */ d[1] = color & 0xFF; /* RGB565 低字节 */ lcd_write_data(d, 2); } }这里的坐标可以从0到159、0到127。CASET参数是起始列加结束列,包含端点,所以结束列是起始列加宽度减一。RGB565的拆法是一红5位、绿6位、蓝5位,0xF800是红色,0x07E0是绿色,0x001F是蓝色。这个函数每像素调一次HAL_SPI_Transmit,开销很大,刷全屏大约要几十毫秒,只适合画按钮、画色块。实际项目里我用一个160字节的行缓冲,每次填一行再一次性发送,速度能快一个数量级,这个优化后面DMA章节会展开说。
画点函数就更简单,把w和h参数都设成1调用同一个矩形填充即可。常见错误是坐标越界,比如在横屏模式下x写成200,屏幕内部的行列指针会回绕,画面出现奇怪的斜条纹。初始化时约定好一套坐标方向,所有函数共用,不要混用。
4.4 LCD屏显示中文:16×16字模与MADCTL旋转
英文字符和数字用5×7字体点阵,每个字符占5字节,每字节的低5位表示一列像素。中文字符一个16×16点阵占32字节,取模工具生成后直接按字节顺序写入显存。中文显示的关键不在驱动,而在字模来源和编码转换。常见做法是用PCtoLCD2002这类工具把需要显示的汉字取模成数组,烧进STM32F103的Flash。F103C8T6自带64KB Flash,放几百个常用汉字足够;如果要做全字库,就得外挂W25Q16 SPI Flash,把字库文件按地址烧录进去。
void lcd_draw_char(uint16_t x, uint16_t y, uint8_t ch, uint16_t color, uint16_t bg) { const uint8_t *glyph = font5x7[ch - 0x20]; for (int row = 0; row < 7; row++) { uint8_t line = glyph[row]; for (int col = 0; col < 5; col++) { if (line & (0x01 << col)) lcd_draw_pixel(x + col, y + row, color); else lcd_draw_pixel(x + col, y + row, bg); } } } void lcd_draw_cn16(uint16_t x, uint16_t y, uint16_t index, uint16_t color, uint16_t bg) { const uint8_t *gb = cn_font16[index]; for (int row = 0; row < 16; row++) { uint8_t hi = gb[row * 2]; uint8_t lo = gb[row * 2 + 1]; for (int col = 0; col < 8; col++) { if (hi & (0x80 >> col)) lcd_draw_pixel(x + col, y + row, color); else lcd_draw_pixel(x + col, y + row, bg); if (lo & (0x80 >> col)) lcd_draw_pixel(x + 8 + col, y + row, color); else lcd_draw_pixel(x + 8 + col, y + row, bg); } } }字符函数里的取模方向是“逐行式,高位在前”,也就是每行从左到右,每字节最高位对应最左像素。如果取模工具设置成“列行式”或者“低位在前”,显示出来就是镜像或者颠倒。中文函数每行拆成两个字节,分别对应左半边8像素和右半边8像素,这种布局和取模工具默认选项一致。旋转屏幕只需要改一条MADCTL寄存器,0x00竖屏,0x60横屏顺时针90度,0xC0竖屏反转180度,0xA0横屏逆时针90度,改完初始化代码里0x36的参数即可,显示函数不需要动。
5. 调试ST7735S白屏与花屏:分三种现象逐项排查
5.1 白屏、花屏、错位分别先查哪里
调试LCD和调试串口完全是两种体验。串口错了最多乱码,LCD错了会白屏、花屏、黑屏、色偏,每种现象对应不同原因。我把常见的现象整理成一张表,按出现频率排好了:
| 现象 | 先查什么 | 常见原因 |
|---|---|---|
| 全白屏 | 背光使能、复位时序 | BLK悬空、RES低电平时间不足50ms、初始化没跑到DISPON |
| 全黑屏 | 背光、电源地 | BLK一直低、VCC/GND接反、模块电源没起 |
| 花屏/彩色噪点 | SPI时钟频率、接线长度 | SCL/SDA线序接反、分频设太小、杜邦线过长干扰 |
| 全屏纯色但颜色错 | MADCTL的BGR位 | 0x36参数需要加0x08,或者不加 |
| 图形错位/显示一半 | CASET/RASET坐标 | 坐标越界、窗口参数高低字节颠倒 |
| 闪烁/水波纹 | 帧率寄存器、电源纹波 | 0xB1配错、VCC旁路电容缺失 |
白屏是最常见的现象。如果背光已经亮了但没有任何像素,说明显存内容没有被正确写入,优先检查初始化序列是否执行完整。我碰到过一次很隐蔽的坑:HAL_Delay在系统时钟没配好时不会工作,SLPOUT后面的一百多毫秒延迟直接变成0,芯片还没准备好就开始发后续命令,结果前几条寄存器被吞掉,白屏。这种问题用示波器量RES和SCK的时序才能发现。花屏则优先怀疑SPI速率,把分频从8改成16,时钟降到4.5MHz再看,如果画面变正常就是信号完整性导致的。
5.2 用逻辑分析仪验证写命令时序和帧耗时
逻辑分析仪是调试SPI屏幕最实用的工具,不用太贵,采样率50MHz的就行。接线时把SCL、SDA、CS、DC四根线夹到分析仪通道上,触发条件设成CS下降沿,然后跑一遍初始化代码。抓到的波形上,DC信号为低的时段对应的是命令字节,DC为高的时段是数据。先用肉眼确认一条RDDID命令的完整时序:CS拉低,DC低,SCK翻转8次发出0x04;随后DC拉高,再翻转24次读回3字节;最后CS拉高。四根线的相对顺序和代码完全一致,就说明基本通信没问题。
如果波形里CS拉低后SCK完全没有翻转,问题在CubeIDE的GPIO配置,PA5没有被正确复用成SPI1_SCK。如果SCK有翻转但SDA上数据全错位,检查MSB/LSB设置是否改成LSB First。此外还能用分析仪测帧耗时:在初始化完成后跑一次全屏填充,测量CS从第一次拉低到最后一次拉高的总时间,减去HAL函数本身的调用开销,就是刷一屏的原始时间。F103在9MHz SPI下刷160×128理论耗时约36毫秒,如果测得超过100毫秒,说明程序在循环里做了太多无效操作,比如每像素都拉CS或者调用HAL函数,后面优化空间很大。
5.3 二分定位初始化失败:只写一半代码把范围缩小
初始化序列有十几条寄存器命令,任何一条出错都可能导致后续操作不正常。常见的调试习惯是一条一条命令反复对比,效率低。我一般用二分法:把初始化序列拆成前后两段,前半段只做唤醒和设像素格式,然后直接写一个蓝色全屏。如果蓝色显示正常,说明SPI通信、坐标窗口、写显存这三条链路都通,问题一定在剩下的帧率/伽马寄存器里;如果蓝色都出不来,就回退检查最基础的命令收发。
static void lcd_init_part1(void) { lcd_write_cmd(0x01); /* SWRESET */ HAL_Delay(120); lcd_write_cmd(0x11); /* SLPOUT */ HAL_Delay(120); uint8_t p[4]; p[0] = 0x05; lcd_wr_cmd_data(0x3A, p, 1); /* 16位颜色 */ p[0] = 0; p[1] = 0; p[2] = 0; p[3] = 0x9F; lcd_wr_cmd_data(0x2A, p, 4); /* 全屏列窗口 */ p[0] = 0; p[1] = 0; p[2] = 0; p[3] = 0x7F; lcd_wr_cmd_data(0x2B, p, 4); /* 全屏行窗口 */ lcd_write_cmd(0x2C); /* 开始连续写显存 */ for (uint16_t i = 0; i < 160 * 128; i++) { uint8_t c[2] = {0x00, 0x1F}; /* RGB565 蓝色 */ lcd_write_data(c, 2); } }这段代码执行后屏幕应该是纯蓝色。注意它没有发DISPON命令,如果芯片的显示关断位是默认状态,这里也可能不出画面,那就在开头加上0x29命令。这本身就是一次排查:0x29加在哪个位置会直接影响现象。一旦蓝屏出现,就把原来的完整序列后半段加上,再去看伽马和帧率。逐个分组加回去,每次加一组就重新烧录运行,最多四五轮就能定位到具体寄存器,比对着手册盲猜快得多。
6. DMA刷ST7735S整屏提速:用SysTick把帧率算出来
6.1 双行缓冲加SPI DMA的最小改动
复制代码的时候要先意识到一个问题:F103C8T6只有20KB SRAM,160×128×2字节的整屏缓冲要40KB,放不下。所以整屏DMA方案在F103上不可行,常见做法是双行缓冲加逐行DMA,每行160像素占320字节,两个行缓冲加起来640字节,余量很足。发送完一行再发下一行,DMA传送期间CPU可以准备第二行内容。
static uint8_t linebuf[2][160 * 2]; static uint8_t dma_busy; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { dma_busy = 0; /* 当前行DMA发送完成 */ } } void lcd_refresh_line_rows(uint16_t y0, uint8_t rows) { for (uint16_t r = 0; r < rows; r++) { uint16_t y = y0 + r; /* 这里填充linebuf[0]第y行的颜色数据 */ for (int i = 0; i < 160; i++) { linebuf[0][i * 2] = 0xF8; /* 红色行数据示例 */ linebuf[0][i * 2 + 1] = 0x00; } LCD_CS_LOW; LCD_DC_HIGH; dma_busy = 1; HAL_SPI_Transmit_DMA(&hspi1, linebuf[0], 160 * 2); while (dma_busy); /* 等当前行发完再切下一行 */ } }帧率的理论上限可以直接算:9MHz时钟下320字节需要284µs,整屏128行共36.4ms,约27FPS。上面代码每次等待DMA完成会损失一点切换时间,实测能到20FPS左右。想再快就用乒乓缓冲:发送linebuf[0]时填充linebuf[1],DMA完成回调里交换指针,把等待时间从关键路径上拿掉。代价是代码复杂度上升,刷屏函数和DMA回调之间要做好状态同步。
6.2 帧率和CPU占用怎么测
测帧率不需要外接任何仪器,HAL_GetTick提供毫秒级时间戳,刷一屏前后各打一次点就能算出消耗时间。把刷新函数放进主循环,用SysTick计时:
while (1) { uint32_t t0 = HAL_GetTick(); lcd_refresh_line_rows(0, 128); uint32_t cost = HAL_GetTick() - t0; /* cost就是整屏刷新耗时,单位ms */ }用CubeIDE的调试器在cost变量上打断点,或者用串口把数值打印出来,反复复位看数据是否稳定。如果一屏耗时超过60ms,说明不是DMA没生效,就是还有额外等待;如果在等待DMA完成后又调了HAL_Delay,时间会被叠加进刷新周期,画面上出现明显的停顿感。想测CPU占用就把刷新任务和另一个LED翻转任务时间片对比,DMA让出来的时间足够跑一些轻量计算和触摸扫描。
6.3 验证手段:渐变填充和棋盘格不闪
屏幕驱动改完、时序调完,最后一件事是验证显示效果。最有效的是两种画面:16位颜色渐变和棋盘格。渐变用R分量从0递增到31、G分量从0递增到63、B分量从0递增到31,逐像素写入,能看出每个颜色通道有没有断档,也能确认RGB565的字节顺序是否对。棋盘格用16×16像素的格子交替红蓝,屏幕出现规则方格说明坐标窗口计算没有累积误差,同时肉眼观察刷新过程有没有闪烁。
把前面这些代码串起来,LCD驱动在STM32CubeIDE里就算是完全跑通了。后续真正做界面时,只需要在这一层往上面叠加图形库或者控件逻辑,底层SPI收发和DMA切换不会再动。F103在9MHz SPI下做到20FPS的纯色刷新,余量已经够大多数仪表盘和菜单界面使用;如果还需要更快,换192MHz主频的F303或者带硬件FSMC接口的F407,刷新路径从代码层迁移到内存映射层,那是另一个方案了。
本文还有配套的精品资源,点击获取