1. 项目概述:为什么选择STM32F103驱动GC9306?
在嵌入式开发里,给MCU配上一块屏幕,就像给机器人装上了眼睛,交互体验和项目可玩性直接上了一个台阶。最近在做一个需要显示实时数据的小设备,核心需求是低成本、低功耗,同时屏幕尺寸不能太小,显示内容要清晰。市面上常见的0.96寸OLED虽然省电,但显示面积有限,且多为单色。而一些大尺寸的TFT彩屏虽然效果好,但往往需要并口驱动,对IO口资源消耗巨大,或者需要外挂RAM,增加了系统复杂性和成本。
经过一番筛选,我最终锁定了GC9306这款驱动芯片的LCD屏幕。它有几个吸引我的点:首先,它支持SPI接口,相比并口能节省大量宝贵的IO资源,对于引脚数量有限的STM32F103C8T6这类芯片来说非常友好;其次,它自带显存,这意味着MCU不需要额外提供RAM来缓存整屏图像数据,刷新时直接操作显存即可,大大减轻了MCU的负担;最后,它支持262K色,分辨率常见的有240x320(2.8寸)等规格,显示效果足够细腻,能满足大多数UI和数据显示的需求。
而STM32F103,作为经典的“单片机界标杆”,其丰富的片上资源、成熟的生态(尤其是标准库和HAL库)以及极高的性价比,让它成为驱动这类外设的理想选择。两者的结合,可以说是在成本、性能和开发难度之间找到了一个相当不错的平衡点。这次我就把整个基于STM32F103,使用SPI接口驱动GC9306屏幕的过程、踩过的坑以及优化心得记录下来,给有同样需求的朋友一个参考。
2. 核心硬件与通信协议解析
2.1 GC9306屏幕模块探秘
拿到一块GC9306屏幕模块,首先得搞清楚它的“脾气”。通常,这类模块会将驱动芯片、显存、玻璃基板以及背光电路集成在一块小板上,通过FPC排线引出引脚。关键引脚并不多,主要分为电源、背光控制和SPI通信三类。
电源部分:一般需要两组电压。一是VCC(通常是3.3V或5V),给整个模块的逻辑电路供电;二是LED+和LED-,用于控制背光。背光通常是一组串联的LED,LED+接一个限流电阻到正电压(如3.3V),LED-接MCU的IO口进行PWM调光或简单的开关控制。这里有个坑:有些模块的背光电路设计不同,LED-可能内部已经接了限流电阻到地,此时你需要将LED+接正电压,LED-悬空或接控制信号。务必查阅模块的具体原理图或卖家资料。
SPI通信引脚:
SCK: 时钟线,由主机(STM32)产生。MOSI: 主机输出从机输入,用于STM32向屏幕发送命令或数据。DC(或常写作RS、A0): 命令/数据选择线。这是SPI驱动LCD屏与驱动普通SPI Flash最大的不同之处。GC9306通过此引脚电平来区分当前SPI线上传输的是命令还是数据。通常高电平为数据,低电平为命令。CS: 片选线,低电平有效。用于选中当前设备。RST: 复位线,低电平复位。上电后一个可靠的复位时序是屏幕正常工作的前提。
有些模块还可能引出MISO线,用于从屏幕读回状态或数据,但GC9306的常规显示操作通常只写不读,所以MISO可以不接。
2.2 SPI协议模式与STM32配置要点
SPI协议有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定。GC9306的数据手册明确指出其SPI接口在空闲时时钟为低电平(CPOL=0),在第二个时钟边沿采样数据(CPHA=1),即SPI Mode 0。这是最常用的SPI模式。
在STM32上配置SPI时,需要关注以下几点:
- 速率: GC9306的最大SPI时钟频率需要查手册,常见的有几十MHz。STM32F103的SPI时钟源来自APB2总线(最高72MHz),经过分频后供给。初始调试时,建议先设置一个较低的速率,如9MHz(72/8),确保通信稳定后再尝试提高。
- 数据大小: 设置为8位。
- 帧格式: MSB(最高位)先行。
- 软件控制NSS(CS): 通常我们将硬件NSS(片选管理)功能禁用,改用普通GPIO来手动控制
CS引脚。这样更灵活,可以方便地在通信间隙拉高CS。 - DMA考虑: 对于刷屏这种需要连续发送大量数据的操作,使用DMA可以极大解放CPU。STM32F103的SPI1和SPI2都支持DMA。规划时可以考虑将屏幕的刷新通过DMA来完成。
注意: 务必确认你使用的STM32库函数(标准库或HAL库)对SPI的初始化配置与GC9306的Mode 0要求一致。我曾遇到过因为库版本或配置工具默认值不同,导致实际产生的SPI相位不对,屏幕无法显示的问题。
2.3 硬件连接实战
以STM32F103C8T6(核心板)和一款常见的2.8寸GC9306模块为例,连接如下表所示:
| STM32F103引脚 | GC9306模块引脚 | 功能说明 |
|---|---|---|
| PA5 | SCK | SPI1时钟 |
| PA7 | MOSI (SDA) | SPI1主机输出 |
| PA4 | CS | 片选(可任选IO) |
| PA2 | DC (RS) | 命令/数据选择(可任选IO) |
| PA1 | RST | 复位(可任选IO) |
| 3.3V | VCC | 电源正 |
| GND | GND | 电源地 |
| PA0 | LED- | 背光控制(PWM或GPIO) |
| 3.3V | LED+ | 背光电源(经限流电阻) |
接线心得:
RST和DC虽然可以用任意GPIO,但建议选择同一端口(如GPIOA)的相邻引脚,这样在软件中操作位带或批量操作时效率更高。- 如果板子上有独立的
LED+和LED-焊盘,LED+接3.3V并通过一个合适的限流电阻(如100Ω-220Ω),LED-接MCU的PWM输出引脚(如TIM2_CH1),可以实现亮度调节。如果模块只有一个BL引脚,通常接高电平点亮,接低电平熄灭。 - 电源一定要稳定,最好在
VCC和GND之间并联一个100nF的陶瓷电容和一个10uF的钽电容,靠近屏幕模块放置,以滤除电源噪声,这对显示稳定性至关重要。
3. 底层驱动软件设计与实现
3.1 引脚初始化与基础读写函数
驱动任何外设,第一步都是初始化相关的GPIO。对于SPI引脚(SCK, MOSI),我们需要将其配置为复用推挽输出模式。对于CS、DC、RST这类控制引脚,配置为通用推挽输出模式即可。
// 以标准库为例,初始化GPIO void LCD_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // SPI引脚 SCK(PA5), MOSI(PA7) 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); // 控制引脚 CS(PA4), DC(PA2), RST(PA1) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_2 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 通用推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 背光控制引脚 LED(PA0) GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_Init(GPIOA, &GPIO_InitStructure); // 初始状态 LCD_CS_SET; // 片选拉高,不选中 LCD_DC_SET; // 默认先设为数据模式也无妨,但通常初始化时先拉低 LCD_RST_SET; // 复位引脚拉高 LCD_BL_ON; // 打开背光 }接下来是SPI外设的初始化,配置为Mode 0,主机模式,8位数据,并设置好分频系数。
void LCD_SPI_Init(void) { SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; // 单线发送模式 SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; // CPOL=0 SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; // CPHA=1,对应Mode 0 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件控制NSS SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; // 9MHz @72MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }有了硬件基础,就可以编写最核心的两个底层函数:写命令和写数据。它们的区别仅在于DC引脚的电平。
// 写命令 void LCD_Write_Cmd(uint8_t cmd) { LCD_CS_CLR; // 选中设备 LCD_DC_CLR; // DC置低,表示接下来是命令 SPI_I2S_SendData(SPI1, cmd); // 发送命令字节 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 等待发送完成 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); // 等待SPI总线空闲 LCD_CS_SET; // 取消选中 } // 写数据 void LCD_Write_Data(uint8_t data) { LCD_CS_CLR; LCD_DC_SET; // DC置高,表示接下来是数据 SPI_I2S_SendData(SPI1, data); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); LCD_CS_SET; }实操心得: 在每次发送完数据后等待
BSY标志位清零,再拉高CS,这是一个好习惯。它确保了本次SPI传输完全结束,避免了因总线忙状态下的片选变化可能导致的通信错误。特别是在高波特率下,这个细节很重要。
3.2 GC9306初始化序列详解
屏幕驱动芯片上电后需要一系列配置命令才能正常工作。这个初始化序列通常由屏幕模组厂商提供,或者可以从GC9306的数据手册中提取。初始化序列主要完成以下几件事:
- 复位和唤醒: 发送复位命令或通过硬件RST引脚复位,然后退出睡眠模式。
- 设置接口参数: 如像素格式(RGB565还是RGB666)、接口模式等。
- 设置显示参数: 如显示方向(旋转)、扫描模式、颜色反转等。
- 设置电源和偏压: 调整内部电荷泵、伽马校正等,以获得最佳显示效果和功耗。
- 开启显示: 最后发送命令开启显示。
下面是一个典型的初始化函数片段:
void LCD_Init(void) { // 1. 硬件复位 LCD_RST_CLR; Delay_ms(100); // 保持低电平至少10ms LCD_RST_SET; Delay_ms(120); // 等待复位稳定 // 2. 发送初始化命令序列 LCD_Write_Cmd(0xFE); // 进入扩展命令集 LCD_Write_Cmd(0xEF); // 扩展命令集标识 LCD_Write_Cmd(0x36); // 内存访问控制 (MADCTL) // 参数:设置RGB顺序、扫描方向等。例如0x00为正常方向,0xC0为旋转180度 LCD_Write_Data(0x00); LCD_Write_Cmd(0x3A); // 像素格式设置 (COLMOD) LCD_Write_Data(0x55); // 0x55 代表16位/pixel (RGB565) // ... 此处省略其他电源、伽马等设置命令,约十几条 LCD_Write_Cmd(0x11); // 退出睡眠模式 (SLPOUT) Delay_ms(120); // 等待唤醒 LCD_Write_Cmd(0x29); // 开启显示 (DISPON) Delay_ms(50); }关键点解析:
- 命令参数: 很多命令后面需要跟1个或多个参数。例如
0x3A命令后面跟的0x55,就指定了像素格式为RGB565。这个格式意味着每个像素点用2个字节(16位)表示,其中红色占5位,绿色占6位,蓝色占5位。这是最常用的格式,与STM32的LCD接口和许多图形库兼容。 - 延时: 命令之间的延时(
Delay_ms)至关重要。有些命令(如SLPOUT、DISPON)需要芯片内部花时间处理,不给予足够延时会导致后续命令失效。具体延时值需要参考数据手册,通常几十到一百多毫秒。 - 方向控制:
MADCTL(0x36)命令是控制显示方向的关键。通过修改其参数,可以实现屏幕的0°、90°、180°、270°旋转以及镜像。这对于适配不同的产品结构非常有用。
3.3 设置窗口与像素填充
要在屏幕上显示内容,本质上是向屏幕的显存(GRAM)中写入颜色数据。GC9306支持设置一个“窗口”,即一个矩形的区域,随后写入的颜色数据会自动填充到这个窗口内,地址指针自动递增,这大大提高了连续填充(如刷屏、画矩形)的效率。
设置窗口需要四个命令:设置列地址起始和结束、设置行地址起始和结束。
// 设置显示窗口 (x_start, y_start) -> (x_end, y_end) void LCD_SetWindow(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end) { // 设置列地址 (X方向) LCD_Write_Cmd(0x2A); // 列地址设置命令 LCD_Write_Data(x_start >> 8); // 起始列高8位 LCD_Write_Data(x_start & 0xFF); // 起始列低8位 LCD_Write_Data(x_end >> 8); // 结束列高8位 LCD_Write_Data(x_end & 0xFF); // 结束列低8位 // 设置行地址 (Y方向) LCD_Write_Cmd(0x2B); // 行地址设置命令 LCD_Write_Data(y_start >> 8); // 起始行高8位 LCD_Write_Data(y_start & 0xFF); // 起始行低8位 LCD_Write_Data(y_end >> 8); // 结束行高8位 LCD_Write_Data(y_end & 0xFF); // 结束行低8位 // 发送写GRAM命令,准备接收像素数据 LCD_Write_Cmd(0x2C); }设置好窗口后,发送0x2C命令,之后连续写入的数据就会被填充到该窗口。例如,要填充整个屏幕为红色(RGB565: 0xF800),可以这样做:
void LCD_Fill(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end, uint16_t color) { uint32_t pixel_count = (x_end - x_start + 1) * (y_end - y_start + 1); LCD_SetWindow(x_start, y_start, x_end, y_end); LCD_CS_CLR; LCD_DC_SET; // 进入数据模式 for(uint32_t i=0; i<pixel_count; i++) { while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 等待发送缓冲区空 SPI_I2S_SendData(SPI1, color >> 8); // 先发送颜色高字节 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, color & 0xFF); // 再发送颜色低字节 } while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); // 等待最后数据传输完成 LCD_CS_SET; }这个LCD_Fill函数是后续所有绘图操作的基础。但你可以看到,它使用for循环一个个发送像素数据,效率很低,刷一整屏(240*320=76800像素)需要发送153600字节,耗时很长。这就是性能瓶颈所在。
4. 性能优化与高级功能实现
4.1 启用DMA加速刷屏
要解决上述性能瓶颈,必须请出DMA(直接存储器访问)。DMA可以在不占用CPU的情况下,自动将内存中的数据搬运到SPI的数据寄存器中。我们只需要设置好源地址(颜色数据数组)、目标地址(SPI数据寄存器)、数据量,然后启动DMA,CPU就可以去处理其他任务了。
首先,需要初始化SPI对应的DMA通道(以SPI1_TX使用DMA1_Channel3为例):
void LCD_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel3); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&(SPI1->DR); // 外设地址:SPI数据寄存器 DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)0; // 内存地址稍后设置 DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; // 内存到外设 DMA_InitStructure.DMA_BufferSize = 0; // 数据量稍后设置 DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; // 外设地址固定 DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; // 内存地址递增 DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; // 外设数据宽度8位 DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; // 内存数据宽度16位(一个像素) DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; // 普通模式,传输一次 DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; // 禁用内存到内存模式 DMA_Init(DMA1_Channel3, &DMA_InitStructure); // 先不使能DMA,等待SPI配置好DMA请求 SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Tx, ENABLE); // 使能SPI1的TX DMA请求 }然后,改造我们的填充函数,使用DMA来发送数据:
void LCD_Fill_DMA(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end, uint16_t color) { uint32_t pixel_count = (x_end - x_start + 1) * (y_end - y_start + 1); static uint16_t color_buffer[320]; // 假设最大行宽320,定义一个缓冲区 // 用目标颜色填充缓冲区的一行(或整个缓冲区) for(int i=0; i<320; i++) { color_buffer[i] = color; } LCD_SetWindow(x_start, y_start, x_end, y_end); LCD_CS_CLR; LCD_DC_SET; DMA_Cmd(DMA1_Channel3, DISABLE); // 先禁用DMA DMA_SetCurrDataCounter(DMA1_Channel3, pixel_count * 2); // 设置传输字节数(像素数*2) // 注意:因为SPI是8位发送,而我们的颜色数据是16位,所以需要传输 pixel_count * 2 个字节。 // 但DMA的内存端数据宽度我们设为了HalfWord(16位),它会自动将16位数据拆成两个8位传输。 // 所以这里设置的数据量是“事务数”(number of data items),每个item是16位。 // 更准确的做法是:内存宽度8位,传输数量 pixel_count*2,然后手动将16位颜色拆成两个8位存入缓冲区。 // 为了简化,我们采用另一种策略:使用16位内存宽度,传输数量 pixel_count。 // 但SPI数据寄存器是8位的,DMA会分两次搬运一个16位数据。这要求SPI设置为16位数据模式吗?不,SPI仍是8位模式。 // 这里有一个关键点:当内存宽度(16位)大于外设宽度(8位)时,DMA会自动将16位数据拆成两个8位传输,先低字节后高字节还是先高字节后低字节,取决于DMA的配置(通常是小端,先低后高)。 // 而我们的颜色数据是uint16_t,在内存中是小端存储(低字节在前)。SPI要求MSB先行,所以我们需要发送高字节先。 // 因此,直接传输16位数据会导致顺序错误。我们必须确保发送顺序是高字节在前。 // 所以,最稳妥的方法是:建立一个8位(uint8_t)的缓冲区,手动将每个16位颜色值的高字节和低字节按顺序放好。 } // 更稳妥的DMA填充函数实现 void LCD_Fill_DMA_Safe(uint16_t x_start, uint16_t y_start, uint16_t x_end, uint16_t y_end, uint16_t color) { uint32_t pixel_count = (x_end - x_start + 1) * (y_end - y_start + 1); static uint8_t dma_buffer[320*2]; // 8位缓冲区,大小足以容纳一行像素的字节数 uint8_t color_high = color >> 8; uint8_t color_low = color & 0xFF; // 填充缓冲区:每个像素对应两个字节,高字节在前,低字节在后 for(uint32_t i=0; i<pixel_count; i++) { dma_buffer[i*2] = color_high; dma_buffer[i*2 + 1] = color_low; } LCD_SetWindow(x_start, y_start, x_end, y_end); LCD_CS_CLR; LCD_DC_SET; DMA1_Channel3->CNDTR = pixel_count * 2; // 设置待传输数据项数量(字节数) DMA1_Channel3->CMAR = (uint32_t)dma_buffer; // 设置内存地址 DMA_Cmd(DMA1_Channel3, ENABLE); // 开启DMA传输 // 等待DMA传输完成 while(DMA_GetFlagStatus(DMA1_FLAG_TC3) == RESET); DMA_ClearFlag(DMA1_FLAG_TC3); // 清除传输完成标志 DMA_Cmd(DMA1_Channel3, DISABLE); // 等待最后一个字节从SPI移位寄存器发送出去 while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); LCD_CS_SET; }使用DMA后,刷屏时间从几百毫秒降低到了几十毫秒,视觉上几乎感觉不到延迟,效果提升非常明显。
4.2 显示图片与汉字字库
显示一张图片,本质上就是将图片的像素数据(通常是RGB565格式的数组)搬运到屏幕的指定窗口。如果图片大小等于窗口大小,那就是全屏图片;如果小于窗口,就需要定位。
图片取模: 通常我们需要一个上位机工具(如Img2Lcd、PCtoLCD2002等)将BMP、JPG等格式的图片转换成C语言数组。转换时需要选择正确的颜色格式(RGB565)、扫描模式(通常为水平扫描)和输出格式(C数组)。
// 假设有一张80x60的图片,取模后得到数组 const uint16_t gImage_test[4800] = {0xF800, 0x07E0, ...}; // 4800 = 80 * 60 void LCD_DrawPicture(uint16_t x, uint16_t y, uint16_t width, uint16_t height, const uint16_t *pic) { LCD_SetWindow(x, y, x+width-1, y+height-1); LCD_CS_CLR; LCD_DC_SET; for(uint32_t i=0; i<width*height; i++) { uint16_t color = pic[i]; LCD_Write_Data_16bit(color); // 需要实现一个快速写16位数据的函数,或直接用DMA发送整个数组 } LCD_CS_SET; }显示汉字或字符: 原理类似,但数据是单色的字模。我们需要先根据字符编码(如GBK、ASCII)在字库数组中找到对应的点阵数据,然后根据每个比特是1还是0,来决定在屏幕上画前景色还是背景色。
// 以16x16点阵汉字为例,一个字模用32字节表示(16行,每行16位/2字节) void LCD_DrawChinese(uint16_t x, uint16_t y, uint16_t fc, uint16_t bc, const uint8_t *font) { uint16_t i, j; uint8_t temp; for(i=0; i<16; i++) { // 16行 temp = font[i*2]; // 每行第一个字节 for(j=0; j<8; j++) { // 前8列 if(temp & (0x80>>j)) { LCD_DrawPoint(x+j, y+i, fc); // 画前景色点 } else { LCD_DrawPoint(x+j, y+i, bc); // 画背景色点 } } temp = font[i*2+1]; // 每行第二个字节 for(j=0; j<8; j++) { // 后8列 if(temp & (0x80>>j)) { LCD_DrawPoint(x+j+8, y+i, fc); } else { LCD_DrawPoint(x+j+8, y+i, bc); } } } }为了高效显示文本,通常会将常用汉字和ASCII字符的字库以数组形式存储在外部Flash(如W25Q64)中,或者编译进程序内部。显示时通过查表获取字模数据。
4.3 简单GUI与图形绘制基础
有了画点、填充、显示图片和文字的基础,就可以构建一些简单的图形界面元素了。
画线算法: 最经典的是Bresenham算法,它只用整数运算,效率很高。以下是画任意方向直线的函数:
void LCD_DrawLine(uint16_t x1, uint16_t y1, uint16_t x2, uint16_t y2, uint16_t color) { int dx = abs(x2 - x1); int dy = -abs(y2 - y1); // 注意这里取负 int sx = (x1 < x2) ? 1 : -1; int sy = (y1 < y2) ? 1 : -1; int err = dx + dy; // 误差项 int e2; while(1) { LCD_DrawPoint(x1, y1, color); if(x1 == x2 && y1 == y2) break; e2 = 2 * err; if(e2 >= dy) { // 误差项在Y方向上的分量积累到一定程度,X步进 err += dy; x1 += sx; } if(e2 <= dx) { // 误差项在X方向上的分量积累到一定程度,Y步进 err += dx; y1 += sy; } } }画圆算法: 同样可以使用Bresenham算法。
绘制矩形和填充矩形: 组合画线函数和填充函数即可轻松实现。
有了这些基本图形元素,你就可以绘制按钮、进度条、图表等UI组件了。虽然比不上专业的GUI库(如LVGL、emWin),但对于简单的设备状态显示和交互来说已经完全足够。如果项目复杂度增加,强烈建议移植LVGL这类开源图形库,它们提供了丰富的控件、动画和抗锯齿支持,能极大提升开发效率和界面美观度。
5. 调试技巧与常见问题排查
5.1 上电无显示?按步骤排查
- 检查电源和背光: 这是最容易被忽视的一步。首先用万用表测量屏幕
VCC和GND之间是否有稳定的3.3V(或5V)电压。然后检查背光是否点亮。如果背光不亮,屏幕一片黑,自然看不到内容。确认背光控制引脚电平是否正确,限流电阻是否合适。 - 确认复位时序: 用逻辑分析仪或示波器抓取
RST引脚波形。确保上电后有一个持续至少10ms的低电平脉冲。很多初始化失败都是因为复位不彻底。 - 抓取SPI波形: 将逻辑分析仪连接到
SCK、MOSI、DC、CS引脚。运行初始化代码,观察是否有波形产生。重点检查:CS是否在通信期间拉低。DC电平在发送命令和发送数据时是否正确变化。SCK的频率和极性(空闲是否为低电平,在第二个边沿采样)是否符合Mode 0。MOSI上的数据是否与代码发送的命令/数据一致。
- 核对初始化序列: 将逻辑分析仪解码出的SPI数据(命令和参数)与数据手册或参考代码中的初始化序列逐条对比。特别注意那些带有延时的命令,是否给了足够长的等待时间。
- 检查像素格式和扫描方向: 如果屏幕有亮光但显示雪花、条纹或颜色错乱,很可能是像素格式(
COLMOD)设置错误,或者内存访问控制(MADCTL)设置的扫描方向与你的填充逻辑不匹配。尝试发送一个全屏填充单一颜色的命令,看看颜色是否正确,图像是否完整。
5.2 显示错乱、花屏的可能原因
- SPI时钟速率过高: 虽然芯片标称最高速率可能很高,但受限于PCB布线、导线长度等因素,实际通信速率可能达不到。尝试降低SPI分频系数(如从
SPI_BaudRatePrescaler_2降到SPI_BaudRatePrescaler_8)。 - 电源噪声干扰: 屏幕驱动芯片功耗变化较大,尤其在刷新时。确保电源走线足够粗,并在屏幕电源引脚附近并联足够的去耦电容(如10uF钽电容 + 100nF陶瓷电容)。
- DMA传输竞争: 如果使用了DMA刷屏,同时还有其他高优先级中断或DMA在运行,可能会打断SPI DMA的传输,导致数据丢失。可以尝试提高SPI TX DMA的优先级,或者在DMA传输期间关闭相关中断。
- 显存地址指针未复位: 在完成一次窗口填充后,如果后续操作没有正确设置新的窗口,可能会从错误的显存地址开始写入数据。确保每次填充或画图前都调用
LCD_SetWindow。 - 多任务访问冲突: 在RTOS环境中,如果多个任务同时操作屏幕,需要添加互斥锁(如信号量)来保护SPI和屏幕控制资源,防止命令和数据交叉发送。
5.3 性能优化与内存管理
- 使用DMA和双缓冲: 如前所述,DMA是提升刷屏效率的关键。更进一步,可以建立两个显示缓冲区(Frame Buffer),一个用于后台绘制(CPU或DMA2D),另一个用于前台显示(通过DMA发送到屏幕)。当后台绘制完成时,交换缓冲区,实现无撕裂的流畅显示。这对于动态界面非常有用,但会消耗双倍内存(对于240x320 RGB565,约150KB),STM32F103的内部RAM可能不够,需要考虑外置SRAM。
- 局部刷新: 如果UI只有小部分区域变化(如一个数字、一个图标),只刷新该区域,而不是整个屏幕,可以极大减少数据量。
- 将字库放到外部Flash: 中文字库体积庞大,放在内部Flash会占用大量空间。可以存储到SPI Flash中,需要时读取。注意设计好缓存机制,避免频繁读取影响显示速度。
- 合理规划显示区域: 对于静态的背景、边框等,只需绘制一次。将动态变化的内容(数据、动画)放在独立的图层或区域管理。
驱动一块SPI屏幕,从点亮到流畅显示,是一个涉及硬件连接、协议理解、软件驱动和性能优化的完整过程。STM32F103和GC9306的组合提供了一个性价比极高的入门到进阶的平台。过程中最深刻的体会是,时序和细节决定成败。每一个命令的延时、每一个引脚的波形、每一个字节的顺序,都可能成为屏幕点不亮的“元凶”。耐心阅读数据手册,善用逻辑分析仪调试,从最简单的全屏填充测试开始,逐步增加功能,是解决问题的有效路径。当屏幕上终于显示出你想要的图形和文字时,那种成就感正是嵌入式开发的乐趣所在。