简介:面向STM32嵌入式开发者的硬件SPI驱动LCD示例,演示如何通过STM32的硬件SPI接口完成LCD屏幕的初始化与显示刷新,适合需要在工业控制、物联网终端中快速实现图形界面的中初级开发者。压缩包仅含1个C源码文件,整体大小约2KB,小巧精炼,便于直接查阅移植。示例详细覆盖GPIO引脚配置、SPI主模式初始化、LCD初始化命令发送、数据透传以及基于中断的非阻塞传输实现,代码段落清晰,并给出HAL库函数调用流程,可帮助理解SPI时序与LCD驱动底层逻辑与调试思路。已有305人学习下载,适合作为竞赛练习、课设参考或快速上手的项目模板。
1. 一块LCD的硬件SPI驱动,比软件模拟省下的不只是CPU
很多嵌入式新手第一次点亮LCD屏,习惯直接用GPIO翻转电平来模拟时钟,把像素数据一位一位“挤”到屏幕上。这个办法能亮起来,但一旦遇到320x240这类TFT屏,或者需要在界面上做流畅切换,问题就立刻暴露:软件模拟的SCLK频率上不去、时序受中断影响抖动大、传输期间CPU被完全占住。这个压缩包里的STM32的硬件SPI驱动LCD例子.c,是一套把GPIO复用、SPI外设初始化、LCD命令序列和像素数据传输串起来的参考实现,覆盖的正是从“能点灯”到“能刷屏”这一段路。适合刚学完GPIO输出、准备上手显示驱动的开发者,也适合做带屏产品原型、需要稳定配置SPI的嵌入式工程师。
硬件SPI的第一个优势在频率。STM32F103的SPI1挂在APB2总线上,外设时钟最高72MHz,分频后SCLK可以从几百kHz一路调到36MHz。软件模拟即使主频同样跑72MHz,发送一个字节往往需要几十条指令,实际SCLK很少能稳定超过2MHz。两种方案的SCLK差距,在240x320 RGB565全屏刷新时直接体现为传输时间的数量级差异。如果再配合DMA搬运,CPU占用率可以从接近100%降到几乎为零。不管是在搜索引擎里搜lcd_stm32还是翻论坛里的SPI驱动帖子,绕不开的就是这套流程。
当然,硬件SPI不是配好就能跑通的,最容易出问题的恰恰是它那组参数:CPOL、CPHA、分频系数、数据帧长度和位顺序,每一项配错的现象都不一样。下面先从SPI协议本身的时序参数说起,这是后面代码配置的基础。
2. SPI时序与引脚约定:CLK极性、采样沿和分频选择
2.1 四线分工与MISO的真实用途
SPI通信的四条信号线各有分工。SCLK由主设备产生,决定数据移位的节奏;MOSI承载主设备发给从设备的数据;MISO是从设备回传数据的通道;CS是片选,低电平有效,只有被选中的从设备才会响应SCLK。驱动LCD时STM32作为主设备,LCD作为从设备,这是基本的主从关系。
在纯刷屏场景里,MISO往往整条线都不接。显示只需要单向写数据,SCLK加MOSI两条线足以把命令和像素数据送进LCD。只有当LCD支持读取GRAM、或者模块上还集成了触摸屏控制芯片时,MISO才真正参与通信。一个值得留意的点是:SPI没有应答机制,主设备把字节发出去了,从设备到底有没有在正确的采样沿收到,主设备是无从知晓的。所以显示一旦异常,不要急着改上层代码,先挂逻辑分析仪看波形,用数据说话。
2.2 CPOL与CPHA对采样沿的影响
CPOL决定SCLK空闲电平,CPHA决定数据在第一个还是第二个跳变沿被采样。两两组合形成SPI Mode 0到Mode 3四种模式。LCD控制器的数据手册里一般会标明支持的模式,最常见的是Mode 0,即CPOL=0、CPHA=0,ST7735、ILI9341都明确支持;有些型号同时列出Mode 3。关键是以手册为准,不要因为上一块屏用Mode 0正常,就默认所有LCD都这样。
CPOL配错的典型现象是屏幕能收到命令,但数据建立时间不够,出现整体偏色或某一类颜色丢失。CPHA配错的感官表现更直接:花屏,尤其是渐变区域会出现斜纹或杂色。调试时先把模式锁定,再对应设置HAL库里的CLKPolarity和CLKPhase,可以省掉大量盲目试参数的时间。另外,片选信号有硬件NSS和软件片选两种控制方式,驱动LCD时普遍把NSS设为软件控制,用普通GPIO拉CS,这是为了避免硬件NSS在发送时自动切换电平,干扰LCD的CS和DC时序配合。
2.3 分频系数选择:从可靠到高速的切换
SPI1的外设时钟来自APB2总线,72MHz下波特率发生器支持2到256分频。SCLK频率不是个固定值,要结合LCD控制器的上限频率、PCB走线长度和刷新率需求来选。常用分频与场景对应关系如下:
| 分频系数 | SCLK频率 | 适用场景 |
|---|---|---|
| 2 | 36MHz | 极短走线、LCD手册明确支持,实际工程很少用 |
| 4 | 18MHz | 同板短走线,追求刷新率时选用 |
| 8 | 9MHz | 大多数TFT屏的默认选择,稳定性和速度兼顾 |
| 16 | 4.5MHz | 杜邦线跨板连接、时序余量不足时的稳妥值 |
| 32 | 2.25MHz | 调试初期、走线较长或LCD本身时钟上限低 |
分频系数不是越小越好。LCD内部的移位寄存器有建立时间约束,SCLK太高时采样点会落到数据稳定区之外,表现就是花屏和随机噪点。工程中的常见做法是:初始化阶段用32分频,保证命令序列被可靠接收;初始化完成后重新配置BaudRatePrescaler并调用HAL_SPI_Init,切到16分频或8分频。不少开发板的LCD驱动就是这么写的,初始化阶段的稳定性和刷屏阶段的速度两头兼顾。
除了分频,FirstBit也要和LCD手册保持一致。绝大多数LCD控制器按MSB优先接收命令和数据,HAL配置里写SPI_FIRSTBIT_MSB一般不会出问题。若换用LSB优先的屏而忘记改,像素颜色会整体错乱,因为RGB565的高低字节被掉转了。这类错误排查起来很隐蔽,因为波形是完好的,问题只在数据解释层。
提示:验证分频参数是否合理时,不要只发几个字节就下结论。连续刷新几十帧渐变图,观察有没有细小噪点或周期性杂色,比单帧纯色测试可靠得多。
3. 初始化代码拆解:GPIO复用、SPI参数与LCD命令写入
3.1 GPIO复用配置:SCK和MOSI必须交给外设
STM32的引脚要作为外设功能使用,必须先配成复用模式。以SPI1为例,常见接法是SCK=PA5、MOSI=PA7,也有重映射到PB3/PB5的。如果GPIO_InitStruct.Mode写成了普通推挽输出而不是复用推挽,SPI外设完全无法控制引脚电平,发送函数返回正常但屏幕上不会有数据。这个错误在CubeMX生成的代码里不会出现,但手写寄存器或移植旧工程时经常踩到。
/* 使能GPIOA和SPI1时钟 */ __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); GPIO_InitTypeDef GPIO_InitStruct = {0}; /* SCK=PA5, MOSI=PA7 配置为复用推挽输出 */ GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* PA4 作为普通输出,用于CS片选 */ GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); /* CS 初始置高 */代码里SCK和MOSI的Speed写HIGH,在低频SPI下不影响功能,真正关键的是复用模式和时钟使能的顺序。CS引脚配成普通输出后要立刻置高,避免上电瞬间LCD被误选中。CS本身不要求高频翻转,Speed用LOW即可,反而能减少信号边沿对其他引脚的耦合干扰。
3.2 SPI外设初始化:每个字段的调整依据
HAL库的SPI初始化集中在这个结构体赋值里,每个参数都和LCD控制器型号强相关:
SPI_HandleTypeDef hspi1 = {0}; hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; /* STM32做主机 */ hspi1.Init.Direction = SPI_DIRECTION_2LINES; /* 全双工 */ hspi1.Init.DataSize = SPI_DATASIZE_8BIT; /* LCD控制器以8位为命令帧 */ hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; /* CPOL=0 */ hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; /* CPHA=0,Mode 0 */ hspi1.Init.NSS = SPI_NSS_SOFT; /* 片选交给GPIO */ hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; /* 4.5MHz */ hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; /* 高位先出 */ HAL_SPI_Init(&hspi1);SPI_DIRECTION_2LINES是全双工模式,发送和接收各占一条线。有人会问只用发送为什么不用单线半双工,答案很简单:保留MISO通道,后续如果要回读LCD状态寄存器或者接触摸屏,不需要改初始化。SPI_NSS_SOFT是驱动LCD的惯例写法,硬件NSS在每次传输时自动拉低拉高CS,做不到和DC引脚逐字节配合;软件片选则可以把CS的时序控制得更精细。分频系数按第2章的表格选型,16分频大约4.5MHz,适合初次上电调试。
3.3 命令和数据怎么写:DC引脚与CS的先后顺序
LCD控制器区分命令和数据的唯一依据是DC引脚电平。DC低电平表示当前发送的是命令,高电平表示数据。所以封装两个底层函数是必须的:
static void LCD_Write_Cmd(uint8_t cmd) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); /* 片选拉低 */ HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_RESET); /* 命令模式 */ HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); /* 片选拉高 */ } static void LCD_Write_Data(uint8_t data) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); /* 数据模式 */ HAL_SPI_Transmit(&hspi1, &data, 1, 100); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); }HAL_SPI_Transmit第三个参数是发送数据的字节数,第四个是超时毫秒数,轮询超时能避免SPI硬件异常时主程序卡死。DC的电平切换必须发生在CS拉低之后、发送函数调用之前。有些代码先切DC再拉CS,大多数LCD控制器不报错,但严格的时序手册会要求在CS有效后再改变DC状态,否则第一个字节可能丢失。这种问题用逻辑分析仪才抓得到,属于典型的“软故障”。
3.4 LCD复位与初始化命令的组织顺序
初始化命令不是随便发的。以ST7735为例,标准顺序是软复位、退出睡眠、设置扫描方向和显存格式,然后配置帧率、偏压和伽马,最后开显示。各阶段的典型命令和注意事项整理如下:
| 阶段 | 典型命令 | 作用 | 注意事项 |
|---|---|---|---|
| 软件复位 | 0x01 | 复位内部逻辑 | 发送后延时150ms |
| 退出睡眠 | 0x11 | 唤醒显示电路 | 发送后延时120ms |
| 显示参数 | 0xB1、0xB4等 | 帧率、偏压、伽马 | 参数随面板型号变化 |
| 开显示 | 0x29 | 输出显示内容 | 出现画面前最后一步 |
硬件复位引脚RST同样不能省。常见做法是在LCD_Init()开头把RST拉低20ms再拉高,再延时120ms让内部电源稳定,然后才开始发送命令序列。RST的复位脉冲长度和SPI波特率无关,这是LCD控制器本身的电源时序要求。若跳过复位,部分LCD在异常掉电重启后会对初始化命令无响应,屏幕保持白屏或黑屏。
4. 窗口设置、显存分块与DMA搬运优化
4.1 用窗口命令限定刷新区域
TFT LCD的GRAM是一整块显存,配合窗口命令可以让连续写入的像素数据自动落在指定矩形区域内,地址自增,不需要逐点切换行列。窗口设置的顺序是固定的:先CASET列地址,再RASET行地址,最后RAMWR写显存:
LCD_Write_Cmd(0x2A); /* CASET: 列地址设置 */ LCD_Write_Data(0x00); LCD_Write_Data(0x00); /* 列起始 0 */ LCD_Write_Data(0x00); LCD_Write_Data(0xEF); /* 列结束 239 */ LCD_Write_Cmd(0x2B); /* RASET: 行地址设置 */ LCD_Write_Data(0x00); LCD_Write_Data(0x00); /* 行起始 0 */ LCD_Write_Data(0x00); LCD_Write_Data(0x13); /* 行结束 319 */ LCD_Write_Cmd(0x2C); /* RAMWR: 写显存命令 */四个地址字节按高字节在前发送,这是绝大多数LCD控制器的约定。窗口设置完成后,后续发送的每个字节都落在窗口内,不需要反复发命令。如果只是重绘一个按钮或进度条,窗口命令能把数据量从全屏的153600字节压缩到目标区域的大小,效果非常明显。
4.2 整帧缓冲放不下时的分块策略
刷新整帧需要2403202共153600字节的像素数据。STM32F103C8T6的SRAM只有20KB,放不下整帧,所以现实做法是把显存拆成行缓冲,分块发送。按16行一组是比较常见的取舍:
#define LCD_WIDTH 240 #define LCD_BUF_LINE 16 static uint16_t lcd_buf[LCD_WIDTH * LCD_BUF_LINE]; /* 240*16*2=7.5KB */ void LCD_FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { uint32_t i; LCD_SetWindow(x, y, x + w - 1, y + h - 1); LCD_Write_Cmd(0x2C); for (uint16_t row = 0; row < h; row += LCD_BUF_LINE) { uint16_t lines = (h - row > LCD_BUF_LINE) ? LCD_BUF_LINE : (h - row); uint32_t words = (uint32_t)LCD_WIDTH * lines; for (i = 0; i < words; i++) lcd_buf[i] = color; HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, GPIO_PIN_SET); HAL_SPI_Transmit(&hspi1, (uint8_t *)lcd_buf, words * 2, 1000); HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); } }这段代码把窗口按16行切块,每块占7.5KB RAM,在F103上能跑。块内行数LCD_BUF_LINE是调优的关键:设太大接近整帧缓冲,RAM不够;设太小,窗口切换和CS翻转次数增加,传输效率下降。如果你用的是F407或更大内存的芯片,直接加大这个值或者申请整帧缓冲,能少写不少循环。
4.3 DMA搬运的配置与完成回调
阻塞式HAL_SPI_Transmit每次发送都轮询TXE标志,CPU空转。换DMA后,内存到SPI数据寄存器的搬运由DMA控制器完成,CPU可以去处理触摸扫描或UI逻辑。DMA初始化代码如下:
__HAL_RCC_DMA1_CLK_ENABLE(); DMA_HandleTypeDef hdma_spi1_tx = {0}; hdma_spi1_tx.Instance = DMA1_Channel3; /* SPI1_TX 默认映射 */ hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE; /* 外设地址固定 */ hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE; /* 内存地址递增 */ hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_spi1_tx.Init.Mode = DMA_NORMAL; hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_spi1_tx); __HAL_LINKDMA(&hspi1, hdmatx, hdma_spi1_tx);注意PeriphDataAlignment=BYTE搭配MemDataAlignment=HALFWORD:外设端SPI寄存器按8位写入,内存端像素按16位读取。如果把内存对齐也设成BYTE,DMA会把每个像素高低字节按小端序拆开搬运,显示时蓝红互换。颜色错乱时,这两行往往是最后查到的地方。另外,对F103来说SPI1_TX占用DMA1_Channel3,SPI1_RX是DMA1_Channel2,发送和接收各走各的通道,不是非要绑定在一起;只写LCD的话一个TX方向就够。
DMA发送完成后,CS的拉高操作要放在传输完成回调中:
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET); } }如果不这样做,而是调用完HAL_SPI_Transmit_DMA后立刻把CS拉高,屏幕尾部会出现半截花屏。原因很简单:函数返回时数据可能还在SPI移位寄存器里,此时拉高CS会截断最后几个字节。这是DMA模式与阻塞模式在流程控制上最本质的差别。
4.4 全屏刷新的理论耗时与实测差异
刷新耗时可以做粗略估算。SCLK=4.5MHz时,153600字节的传输时间约为0.27秒;9MHz时约0.14秒;18MHz时约0.07秒。注意这是纯数据搬运时间,实际还要算命令间隔、窗口设置和代码执行开销。SCLK提上去之后,如果CS翻转太频繁,刷新时间并不会线性下降。优化顺序应该是:先把分块缓冲和DMA做好,再考虑拉高SPI频率,最后在UI层面缩小无效刷新区域。
提示:DMA发送期间不能修改正在被读取的缓冲区,否则会出现画面撕裂。需要双缓冲时,必须等上一次
HAL_SPI_TxCpltCallback之后再切缓冲。
5. 逻辑分析仪验证时序与排查信号故障
5.1 用0x55做SPI自检波形
初始化代码跑通之前,可以先写几行循环发送0x55或0xAA测试字节,把SCK和MOSI挂到逻辑分析仪抓波形。0x55的二进制是01010101,分析仪上能看到规整的方波,频率、极性和数据位顺序一眼就能确认。如果波形正常再接LCD做初始化,排查范围就缩小到命令序列或硬件连接,而不是把时间浪费在猜配置上。这个习惯在换新屏、换新板子时特别省时间。
5.2 常见时序异常与定位方法
| 异常现象 | 可能原因 | 排查思路 |
|---|---|---|
| CS有效期间无SCLK | GPIO复用配置错误或SPI时钟未使能 | 检查AF_PP设置和时钟使能 |
| SCLK频率与配置不符 | 分频系数和外设时钟算错 | 确认SPI挂在APB1还是APB2 |
| MOSI数据位反序 | FirstBit配成了LSB | 改为MSB后重新抓包 |
| 花屏且颜色错乱 | CPHA/CPOL配错 | 对照LCD手册确认Mode |
| 屏幕有细小噪点 | SCLK过高或走线过长 | 降分频或者改短排线 |
5.3 用sigrok命令行解析SPI抓包结果
逻辑分析仪保存的采样文件可以用sigrok-cli在命令行下解析出SPI总线上的完整数据流:
sigrok-cli -i capture.sr -C D0=SCK,D1=MOSI,D2=CS,D3=DC \ -P spi:clk=SCK:mosi=MOSI:cs=CS \ -A spi=spi-packet-decode \ -o spi_packets.csv-C把物理通道编号映射成SCK、MOSI这些逻辑名;-P spi指定SPI协议解码器;-A是注解输出模式。导出的CSV里每一行能看到字节值和相对时间,和代码里发送的命令逐条对照即可。采样率建议设为SCLK的4倍以上,比如4.5MHz的SCLK用25MHz采样,否则会出现混叠,频率读数偏差一倍数。
5.4 上电时序与CS初始状态
最后说一个经常让人查半天的坑:上电时序。MCU复位期间GPIO处于输入浮动状态,CS电平不确定,如果LCD模块的CS引脚恰好为低,上电瞬间的随机波形会被当成命令或数据写入显存。解决方法是软硬结合:硬件上在CS引脚加10k上拉电阻,软件上GPIO配置完成后立刻把CS写高,再拉低RST完成复位脉冲,然后初始化SPI,最后拉低CS开始发命令。顺序反过来一样会出问题——先初始化SPI再拉CS,GPIO配置过程中产生的毛刺照样会误触发LCD。
CS初始电平、RST复位脉冲、DC与CS的先后顺序、DMA完成回调,这四个环节把LCD驱动里绝大多数隐性故障都覆盖了。配置完一轮时序参数,逻辑分析仪挂在四条线上跑一帧数据,哪里先错哪里后错基本一目了然。
本文还有配套的精品资源,点击获取