简介:针对STM32驱动5寸液晶屏的嵌入式开发需求,这份C语言源文件提供了完整可参考的驱动实现,面向使用HAL或LL库的开发者,解决LCD接口初始化、时序参数配置、像素数据写入及触摸事件处理等关键问题。资源共1个文件,为单个.c源文件,压缩包体积仅4KB,代码精简聚焦,便于快速阅读和移植到800×480分辨率、16位色深的液晶屏项目中。目前已有502人学习下载,适合正在调试同类型屏幕或希望理解STM32与液晶屏底层通信逻辑的工程师。源码中可看到初始化函数、数据发送与触摸处理等模块,结合描述中的六步驱动流程,能帮助读者理清GPIO配置、DMA传输和图形库调用之间的关系,节省排查硬件时序和初始化序列的时间,为后续扩展图形界面打下基础。
1. 碰到5寸TFT白屏,先别量线,查FSMC配置
第一次驱动5寸TFT液晶屏的人,遇到白屏时的第一反应通常是查接线,但实际出问题的地方往往在GPIO复用配置和初始化序列顺序上。一块800×480、16位色深的屏,一帧裸数据就是800×480×2=768,000字节,约750KB,STM32内部SRAM根本放不下整帧,所以从第一批代码开始就得把DMA和显存方案想清楚,否则画面刷新时会出现撕裂和闪烁。接下来把FSMC/LTDC接口选型、初始化序列、RGB565排布、触摸坐标校准和双缓冲整条链路串起来,适合正在用STM32F407、F429或类似带FSMC外设的型号驱动5寸TFT的人参考。
2. 800x480这种5寸屏,先确定接口再决定用FSMC还是LTDC
2.1 MCU接口屏和RGB接口屏,驱动逻辑完全不同
5寸屏并不是只有一种接口。带液晶控制器的模组,内部有GRAM,只要用8080并口往GRAM里写像素,屏自己会刷新,这种屏用STM32的FSMC外设去模拟时序,F103和F407都能跑。另一种是RGB接口裸屏,没有控制器,需要STM32的LTDC外设按固定帧率把整帧数据不断推向屏,通常要F429或F746并外挂SDRAM做帧缓冲。同样是800x480的5寸模组,两种版本并存,所以拿到项目先看原理图,数排线信号就能区分:MCU接口有D0~D15、WR、RD、RS、CS;RGB接口则是DE、HSYNC、VSYNC、DCLK加数据线。判断错了,后面所有代码都是白写。F407没有LTDC,如果板上是RGB裸屏,带不动,只能换带控制器的模组或换MCU;反过来F429有LTDC,但驱动MCU接口屏时一样用FSMC,互不冲突。
2.2 从模组数据手册倒推FSMC时序参数
MCU接口屏的数据手册里都有一份8080时序表,FSMC里配的寄存器值要按HCLK周期倒推。HCLK为168MHz时一个周期约5.95nS,用需求值除以周期向上取整,得到AddressSetupTime和DataSetupTime。下面是一份常见5寸模组的时序基准,不同主控差别不大:
| 参数 | 含义 | 典型值 |
|---|---|---|
| PWR | 完整写周期时间 | 320nS |
| 地址建立时间 | CS/RS有效到WR上升沿 | 50nS |
| 数据建立时间 | 数据有效到WR上升沿 | 80nS |
| 数据保持时间 | WR结束后数据保持 | 15nS |
| PFRD | 完整读周期时间 | 320nS |
模板工程里常见的FSMC配置4+8基本能点亮多数屏,但碰到对时序敏感的模组,配置太快会出现花屏或偶发错色。我一般会先按4+10跑起来,确认稳定后再把DataSetupTime逐档往下降,每次降2个周期并长时间跑刷屏测试,降到花屏就退回上一档。边界值附近必须留余量,尤其是高温环境下时序会劣化,余量太少现场会随机出问题。
2.2.1 余量按一个HCLK周期取整
320nS折算下来是54个HCLK周期,但我通常不会把这个数值直接搬进FSMC配置,因为FSMC内部还有地址建立和数据建立的固定开销,实际总线周期比AddressSetupTime+DataSetupTime要大。用手册给的最小值减去固定开销后再折算,才是有效做法。留10%~15%的余量,能省下很多现场排查时间。
2.3 用HAL库把FSMC配置填进去
CubeMX里先把FSMC外设打开,模式选NOR/PSRAM,数据宽度选16bit,然后代码里按之前算好的周期配置结构体:
FSMC_NORSRAM_TimingTypeDef fsmc_timing = {0}; fsmc_timing.AddressSetupTime = 4; // 地址建立时间,约23.8nS fsmc_timing.DataSetupTime = 10; // 数据建立时间,约59.5nS fsmc_timing.AccessMode = FSMC_ACCESS_MODE_B; FSMC_NORSRAM_InitTypeDef fsmc_control = {0}; fsmc_control.FSMC_Bank = FSMC_Bank1_NORSRAM1; fsmc_control.FSMC_MemoryType = FSMC_MEMORY_TYPE_SRAM; fsmc_control.FSMC_MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; fsmc_control.FSMC_WriteOperation = FSMC_WriteOperation_Enable; fsmc_control.FSMC_ExtendedMode = FSMC_ExtendedMode_Disable; HAL_SRAM_Init(&hsram, &fsmc_control, &fsmc_timing);AccessMode = FSMC_ACCESS_MODE_B对应的是SRAM/PSRAM的时序模型,8080并口屏通常按这个模式走。FSMC_Bank1_NORSRAM1对应NE1,片选地址从0x60000000开始。没有使能扩展模式意味着读和写共用一套时序,普通应用够用;如果模组读时序特别慢,也可以打开扩展模式单独给读操作配一套更宽的时序。注意CubeMX自动生成的GPIO初始化已经处理了引脚复用,但屏的RS信号接在哪条地址线上,仍然由板子决定,软件里要根据实际接线换算地址。
2.4 RS引脚挂在A6上,命令和数据就有了地址差
MCU接口屏的RS(也叫D/CX)区分命令和数据。RS接在FSMC的A6上时,命令口地址是0x60000000,数据口地址是0x60000040,因为A6对应位权是0x40。C代码里最常用的是这两个宏:
#define LCD_REG_ADDR (*(__IO uint16_t *)0x60000000) #define LCD_DATA_ADDR (*(__IO uint16_t *)0x60000040)这样写的原因是FSMC访问地址时,会按地址线输出对应的电平组合,CS、WR、RD这些控制信号都由FSMC硬件完成,不需要用GPIO手动翻转。(__IO uint16_t *)告诉编译器这是16位宽度映射,FSMC按16bit总线往外部发数据。如果板子上RS接的是A16,数据口地址就变成0x60020000,差值是0x20000,这个值一定要和原理图对起来。8bit并口屏的地址差算法也会不同,RS挂A6时数据口偏移同样是0x40,但寄存器访问宽度要改成8bit。
3. 拆开STM32驱动液晶屏.c:初始化序列、RGB565与DMA
3.1 初始化序列用结构体数组管理
液晶屏控制器上电后默认状态不是直接可显示,需要发一串命令配置显示方向、像素格式、伽马和背光。几十条命令散放在main里很难维护,调屏时要来回改,我用结构体数组保存,一条命令一个条目:
typedef struct { uint16_t cmd; uint8_t params[8]; uint8_t len; } lcd_seq_t; static const lcd_seq_t lcd_init_list[] = { {0x01, {0x00}, 0}, // 软件复位 {0x11, {0x00}, 0}, // 退出睡眠模式 {0x36, {0x60}, 1}, // MADCTL,扫描方向按模组接线调 {0x3A, {0x55}, 1}, // 像素格式,0x55表示16位RGB565 {0x29, {0x00}, 0}, // 打开显示 }; void lcd_send_init_sequence(void) { for (uint32_t i = 0; i < sizeof(lcd_init_list) / sizeof(lcd_init_list[0]); i++) { lcd_write_cmd(lcd_init_list[i].cmd); for (uint8_t j = 0; j < lcd_init_list[i].len; j++) { lcd_write_data(lcd_init_list[i].params[j]); } } }len为0表示该命令不带参数,比如0x01、0x11、0x29都是无参命令;0x36和0x3A各带一个参数。const关键字一定不要省,这样数组放在Flash里,不占SRAM。STM32F407内部SRAM一共才128KB,整帧750KB本来就放不下,SRAM留给行缓冲和DMA描述符更划算。还需要注意,不同厂商模组即使主控芯片相同,伽马设置也会不一样,命令集必须按模组手册来,不能拿其它屏的初始化代码直接套。
3.2 命令和数据通过RS地址口分开
前面地址差为0x40的设计,到写入环节才真正发挥作用。写命令时把数据送到LCD_REG_ADDR,写数据时送到LCD_DATA_ADDR,FSMC自动驱动RS脚,屏控制器才知道总线上内容应该当命令还是参数:
static void lcd_write_cmd(uint16_t cmd) { LCD_REG_ADDR = cmd; } static void lcd_write_data(uint16_t data) { LCD_DATA_ADDR = data; }这两个函数写法几乎一样,区别只在目标地址。新手最容易犯的错误是两个都发到同一个地址,结果屏收到的是连续的「命令命令命令」,显示完全错乱。调试这种问题可以抓RS和WR波形,确认RS在WR有效期间是否确实在命令和数据间切换。
3.3 RGB565在800x480上的排布
16位色深在这块屏上是RGB565:高5位红、中间6位绿、低5位蓝,24位彩色转成16位时按位截取:
#define RGB565(r, g, b) \ (((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3))r、g、b三通道各8位,转换时红和蓝丢掉低3位,绿丢掉低2位,肉眼很难看出差别。像素写进GRAM前要先设窗口,否则会顺着上次写入命令的地址继续排,出现错位:
static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { lcd_write_cmd(0x2A); lcd_write_data(x0 >> 8); lcd_write_data(x0 & 0xFF); lcd_write_data(x1 >> 8); lcd_write_data(x1 & 0xFF); lcd_write_cmd(0x2B); lcd_write_data(y0 >> 8); lcd_write_data(y0 & 0xFF); lcd_write_data(y1 >> 8); lcd_write_data(y1 & 0xFF); lcd_write_cmd(0x2C); }0x2A是列地址,0x2B是行地址,0x2C表示开始写显存。坐标参数要拆成高字节和低字节分两次发,因为这条屏的命令参数是8bit宽度。窗口设好后连续写入的数据按行填充。这块屏一行800个像素就是1600字节,480行合计768,000字节:
| 区域 | 像素数 | 占用字节 |
|---|---|---|
| 整屏 800x480 | 384,000 | 768,000 |
| 一行扫描线 | 800 | 1,600 |
| 8行行缓冲 | 6,400 | 12,800 |
这也是为什么不能像小屏那样开768KB数组当帧缓冲,只能按行或按小块区域搬运。
3.4 用DMA搬像素,CPU只负责切行
带控制器的MCU接口屏,一次搬一行最稳妥。先把一行像素准备好,让DMA把这1600字节送到LCD_DATA_ADDR,CPU可以趁机处理下一行数据:
void lcd_dma_send(uint16_t *buf, uint16_t len) { while (__HAL_DMA_GET_FLAG(&hdma, DMA_FLAG_TC5) == RESET) ; __HAL_DMA_CLEAR_FLAG(&hdma, DMA_FLAG_TC5); HAL_DMA_Start_IT(&hdma, (uint32_t)buf, (uint32_t)&LCD_DATA_ADDR, len); }&LCD_DATA_ADDR就是对FSMC数据端口地址取地址,展开后就是0x60000040,DMA把像素按16bit宽度写到这个地址,CPU全程不参与传输。len在DMA配置为半字宽度时表示像素个数,不是字节数,写错头像会变成一行拉长或缩短。DMA外设宽度和内存宽度必须都配成16bit,如果某个通道配成8bit,FSMC侧会看到两个连续8bit访问,性能直接掉一半。while等待上一次传输结束再启动下一次,是为了同一个DMA通道不会同时跑两段传输。
3.4.1 DMA完成中断里不要做重活
行传输结束的中断里只适合交换缓冲指针和置一个标志位,不要在中断里调用lcd_set_window或画像素函数。FSMC写寄存器有等待周期,在中断里做这些会把中断服务时间拉长,反而把DMA的并行优势抵消掉。常见做法是置位标志,主循环看到标志后设置下一行窗口并启动下一次DMA。
对于RGB接口裸屏,软件DMA这套思路就不适用了。LTDC内部有专门的DMA,会不断从SDRAM帧缓冲读数据再推给屏,软件只需要把帧缓冲首地址写进LTDC图层控制寄存器,剩下的刷新是硬件自动完成。所以同样是DMA,MCU接口屏和RGB屏是完全两个概念。
4. STM32触摸驱动:GT911的I2C读时序和坐标校准
4.1 先读设备ID,确认I2C地址和通信链路
5寸电容触摸模组里最常见的控制器是GT911,走I2C。GT911的7位地址由引脚电平决定,常见0x5D和0x29两组。上手第一件事不是读坐标,而是读设备ID,把链路先确认下来:
#define GT911_ADDR (0x5D << 1) static void gt911_read(uint16_t reg, uint8_t *buf, uint8_t len) { HAL_I2C_Mem_Read(&hi2c1, GT911_ADDR, reg, I2C_MEMADD_SIZE_16BIT, buf, len, 100); } static void gt911_write(uint16_t reg, uint8_t data) { HAL_I2C_Mem_Write(&hi2c1, GT911_ADDR, reg, I2C_MEMADD_SIZE_16BIT, &data, 1, 100); } uint8_t gt911_check(void) { uint8_t id[3] = {0}; gt911_read(0x8140, id, 3); return (id[0] == 0x39) && (id[1] == 0x31) && (id[2] == 0x31); }GT911内部寄存器是16位地址,所以I2C_MEMADD_SIZE_16BIT必须带上;0x5D << 1是把7位地址左移成8位地址再交给HAL。ID读回0x39 0x31 0x31,基本能确认器件在线。读不到时优先查中断和复位引脚的时序:GT911要求RST拉低后延时10ms以上再拉高,INT引脚在复位期间要保持特定电平,顺序反了设备根本起不来。
4.2 坐标读取要分两个寄存器事务
GT911把触点信息放在0x814E开始的缓冲区。常规顺序是先读0x814E一个字节,判断最高位是否为1,有新触点再读坐标缓冲,读完把0x814E写0清除标志:
uint8_t status = 0; gt911_read(0x814E, &status, 1); if (status & 0x80) { uint8_t touch[7]; gt911_read(0x8150, touch, 7); uint16_t raw_x = (touch[1] << 8) | touch[2]; uint16_t raw_y = (touch[3] << 8) | touch[4]; } gt911_write(0x814E, 0);status & 0x80是buffer status标志,不判断直接读0x8150,会一直读到上一次的旧坐标。坐标数据在寄存器里是高字节在前,(touch[1] << 8) | touch[2]就是拼回整数;如果某个固件高低字节顺序相反,触摸轨迹会沿对角线跳动,对调一下即可。写完0x814E清标志后,GT911才会产生下一次中断并更新坐标缓冲。
4.2.1 多点触摸的数据布局
status低4位是本次触点个数,每个触点占7字节,连续排在0x8150之后。多个点时不需要把缓冲区全部读回来,按for (i = 0; i < points; i++)从0x8150 + i * 7处循环读取即可。单点应用只看第一个触点的7字节就够;如果要实现双指缩放,还是要按这个布局把每个点的坐标都解析出来。
4.3 触摸坐标到屏幕坐标的线性校准
触摸感应区的RAW范围通常和显示分辨率不一致,比如这块5寸模组的触摸区可能是1024x768甚至更高,直接拿RAW坐标当显示坐标肯定对不上。标准做法是左上角和右下角各采一次RAW值,换算比例和偏移:
| 采样点 | raw_x | raw_y | 目标显示坐标 |
|---|---|---|---|
| 左上角 | 105 | 87 | (0, 0) |
| 右下角 | 920 | 590 | (799, 479) |
x方向scale是800 / (920 - 105),y方向scale是479 / (590 - 87),offset分别为105和87。运行时按下面公式换算:
typedef struct { int16_t offset_x; int16_t offset_y; float scale_x; float scale_y; } touch_cal_t; uint16_t calibrate_x(touch_cal_t *cal, uint16_t raw_x) { return (uint16_t)(((int32_t)raw_x - cal->offset_x) * cal->scale_x); }中间用int32_t是因为当raw_x小于offset_x时差值是负数,uint16_t直接相减会溢出成很大的正数。scale用float保留小数,左下角或右上角做第二采样点还能顺便判断是否需要X方向翻转。校准参数实测一次后可以存在片内Flash里,下次开机直接读,不用每次校准。
5. STM32显示优化:双缓冲、背光PWM与白屏定位手段
5.1 行缓冲双份,画图和DMA搬运重叠
750KB整帧放不进内部SRAM,行缓冲可以。准备两份800像素的缓冲区,一份给DMA搬,一份给CPU画,DMA完成中断里互换角色:
static uint16_t line_buffer[2][800]; static uint8_t active_buf; void lcd_dma_irq_callback(void) { active_buf ^= 1; } void lcd_draw_line(uint16_t y) { fill_line(line_buffer[active_buf], y); lcd_set_window(0, y, 799, y); lcd_dma_send(line_buffer[active_buf], 800); }fill_line画到的是上一次DMA已经搬完、当前空闲的那块,和DMA正在读的缓冲区分开。如果只有一块行缓冲,每行都要等DMA跑完才能画下一行,帧率直接减半。屏带TE脚的话,在TE中断里启动DMA还能避免整幅撕裂。
5.2 背光亮度用gamma曲线映射
PWM背光频率放在1kHz到10kHz之间,太低闪烁,太高增加开关损耗。占空比不要线性调,人眼对低亮度更敏感:
static uint16_t level_to_ccr(uint8_t level) { if (level == 0) return 0; if (level > 100) level = 100; return (uint16_t)(1000.0f * powf(level / 100.0f, 2.2f)); }2.2的gamma指数补偿人眼感知,返回值0到1000对应PWM比较值,记得引入math.h。固定背光没有调光脚的模组,这段不适用。
5.3 白屏时先读一次控制器ID
初始化命令发完还白屏,先读屏控制器ID,判断数据通路通不通:
uint16_t lcd_read_id(void) { lcd_write_cmd(0x04); return lcd_read_data(); }读到非0xFFFF、非0的值,说明FSMC地址映射和命令写入正常,问题多半在初始化序列。一直读回0xFFFF,优先查RS地址差是否和板子一致、RD信号有没有被FSMC复用。少数模组只写不读,读回全1也正常,这时写一屏纯色看颜色有没有变化即可。
本文还有配套的精品资源,点击获取