STM32F103 SPI屏幕移植LVGL程序深度解析

在如今嵌入式设备越来越“看得见、摸得着”的时代,一个直观、流畅的图形界面几乎成了项目的标配。无论是智能仪表、工控面板还是DIY小玩具,用户不再满足于LED闪烁或串口打印——他们想要的是按钮、滑块、动画,甚至是触摸交互。但问题来了:像STM32F103这种经典而资源紧张的MCU(最高72MHz主频,仅20KB SRAM),真的能撑起一个像样的GUI吗?

答案是肯定的。关键在于怎么选技术路线、如何做资源取舍。本文分享的就是一套 完全自研、不依赖标准库或HAL、已在真实硬件上稳定运行 的SPI驱动TFT + LVGL集成方案。它不是靠堆外设或加Flash来“作弊”,而是通过精巧的设计,在有限资源下实现了基本UI功能的可用性。


我们用的是常见的ILI9341驱动的320×240 RGB565 TFT屏,通过SPI四线模式连接到STM32F103。为什么选SPI而不是并行接口?很简单——引脚够省。并行要16条数据线+控制线,而SPI只需要SCK、MOSI、CS、DC四根GPIO,再加上RST,总共才5个IO,对小封装MCU极其友好。

但代价也很明显:速度慢。全屏刷新一次理论数据量是320×240×2 = 153.6KB,如果SPI跑18MHz(这是F1系列APB2总线下能稳定达到的速度极限),理论上每秒最多传输约2.25MB数据——听起来不少,但实际上受限于命令开销和片选切换延迟,真实写屏速率远低于此。更别说RAM根本装不下完整帧缓冲。

所以,这条路从一开始就注定不能“蛮干”。


先看底层通信。很多人喜欢用HAL库封装的 HAL_SPI_Transmit() ,但那里面层层调用、参数检查、状态等待,效率并不高。对于高频调用的屏幕刷图操作,每一微秒都值得抠出来。于是我们选择直接操作寄存器:

#define TFT_CS_LOW()    GPIOB->BRR  = GPIO_Pin_12
#define TFT_CS_HIGH()   GPIOB->BSRR = GPIO_Pin_12
#define TFT_DC_CMD()    GPIOB->BRR  = GPIO_Pin_13
#define TFT_DC_DATA()   GPIOB->BSRR = GPIO_Pin_13

void spi_send_byte(uint8_t byte) {
    while (!(SPI1->SR & SPI_I2S_FLAG_TXE)); // 等待发送寄存器空
    SPI1->DR = byte;
    while (SPI1->SR & SPI_I2S_FLAG_BSY);   // 等待传输完成
}

别小看这几行代码。 BRR BSRR 是STM32特有的原子置位/清零寄存器,比读-改-写整个 ODR 快得多;轮询 TXE BSY 虽然看似阻塞,但在高频SPI下反而比中断或DMA更可控——毕竟DMA配置复杂,且F1系列对SPI DMA支持有限。

这里还有一个细节: lcd_write_command() lcd_write_data() 必须严格区分。因为ILI9341这类芯片靠一个DC引脚判断接下来传的是命令还是数据。一旦搞混,轻则显示错乱,重则初始化失败。所以我们把这两个动作封装成独立函数,并确保每次操作前拉低CS,结束后立即释放:

void lcd_write_command(uint8_t cmd) {
    TFT_CS_LOW();
    TFT_DC_CMD();
    spi_send_byte(cmd);
    TFT_CS_HIGH();
}

void lcd_write_data(uint8_t data) {
    TFT_CS_LOW();
    TFT_DC_DATA();
    spi_send_byte(data);
    TFT_CS_HIGH();
}

注意,有些资料建议在连续发数据时不反复拉高/拉低CS,以提升效率。这没错,但前提是你要非常清楚时序边界。我们在实际调试中发现,某些批次的屏幕对CS保持时间敏感,长时间不释放会导致内部状态机异常。因此,在稳定性优先的前提下,我们宁可牺牲一点性能,也要保证每次传输独立可控。


说到ILI9341,它的初始化序列堪称“玄学”。官方数据手册给了一长串寄存器配置,比如电源设置、伽马校正、地址模式等,顺序不能错,延时也不能少。我们曾尝试删减非关键步骤以加快启动速度,结果出现背光亮但无图像的情况——原来是未正确设置内存访问控制(MADCTL)导致GRAM映射错误。

最终采用的是经过验证的标准初始化流程:

void lcd_init(void) {
    // 硬件复位
    TFT_RST_LOW();
    Delay_ms(100);
    TFT_RST_HIGH();
    Delay_ms(150);

    // 开始发送初始化命令...
    lcd_write_command(0xCF);
    lcd_write_data(0x00); lcd_write_data(0x83); lcd_write_data(0X30);
    // ...其他命令省略
    lcd_write_command(0x36); // MADCTL
    lcd_write_data(0x48);    // 设置为RGB模式、横屏
}

其中MADCTL寄存器尤其重要,它决定了X/Y轴方向、是否镜像、颜色格式等。我们设为 0x48 ,对应MV=0, MX=1, MY=0,即横屏、X方向反转(适配PCB布局)。如果你发现UI左右颠倒,八成是这个值没配对。


真正挑战来自LVGL的集成。LVGL本身设计优雅,但它默认期望你有至少一帧的显存空间。而STM32F103的20KB RAM连半帧都放不下(320×120×2 ≈ 76.8KB)。怎么办?

LVGL提供了一个叫“半缓冲”(half-buffer)的机制:只分配一部分绘图缓冲区,例如一行或多行高度,然后分块刷新。我们定义:

static lv_color_t buf[LV_HOR_RES_MAX * 10]; // 缓冲10行
lv_disp_draw_buf_init(&draw_buf, buf, NULL, sizeof(buf)/sizeof(lv_color_t));

这意味着LVGL每次只会请求不超过10行高的区域进行绘制。当 flush_cb 被调用时,我们需要将这一小块内容写入屏幕对应位置:

void my_flush_cb(lv_disp_drv_t * disp, const lv_area_t * area, lv_color_t * color_p) {
    uint16_t x1 = area->x1;
    uint16_t y1 = area->y1;
    uint16_t x2 = area->x2;
    uint16_t y2 = area->y2;

    lcd_set_address(x1, y1, x2, y2); // 设置GRAM写入范围

    TFT_CS_LOW();
    TFT_DC_DATA();
    uint32_t len = (x2 - x1 + 1) * (y2 - y1 + 1);
    for(uint32_t i = 0; i < len; i++) {
        spi_send_byte(color_p[i].ch.red);
        spi_send_byte(color_p[i].ch.green);
        spi_send_byte(color_p[i].ch.blue);
    }
    TFT_CS_HIGH();

    lv_disp_flush_ready(disp); // 通知LVGL本次刷新完成
}

这里有个隐藏坑点:RGB565是16位颜色,但SPI一次只能发8位。所以必须拆成两个字节发送。然而上面代码看起来像是在发24位?其实不然—— lv_color_t 在RGB565模式下虽然是按结构体存储( .ch.red , .ch.green , .ch.blue ),但内部已经做了颜色压缩。也就是说,这三个字段合起来代表的是原始8位通道值,而实际写入屏幕时仍需转换为16位格式。

更高效的做法是直接访问 color_p[i].full (即16位值),然后拆高低字节:

spi_send_byte(color_p[i].full >> 8);        // 高8位
spi_send_byte(color_p[i].full & 0xFF);      // 低8位

这样每次像素只需两次发送,而不是三次,整体刷新速度提升约25%。这也是为什么我们必须启用LVGL的 LV_COLOR_DEPTH 16 配置的原因。


系统tick的处理也值得注意。LVGL依赖毫秒级计时来驱动动画、超时检测等功能。通常做法是在SysTick中断里调用 lv_tick_inc(1)

void SysTick_Handler(void) {
    lv_tick_inc(1);
}

但要注意,如果你的SysTick周期不是1ms(比如为了降低中断频率设为10ms),那动画就会变得卡顿。反之,如果频繁调用 lv_task_handler() 却又没有足够的CPU余量,也会拖累整体性能。

我们的经验是:主循环中每5~10ms调用一次 lv_timer_handler() (新版称为 lv_timer_handler() ),配合1ms tick中断,既能保证响应性,又不至于让MCU一直忙于GUI任务。

int main(void) {
    SystemInit();
    lvgl_init();

    lv_obj_t * btn = lv_btn_create(lv_scr_act());
    lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0);
    lv_obj_t * label = lv_label_create(btn);
    lv_label_set_text(label, "Hello LVGL");

    while (1) {
        lv_timer_handler();
        Delay_ms(5);
    }
}

别忘了关闭优化选项!GCC默认-Os可能会把一些volatile变量优化掉,尤其是涉及指针操作的LVGL内部逻辑。建议编译时加上 -O2 -Og ,并在关键变量前加 volatile


实践中遇到的问题五花八门。比如屏幕偶尔花屏,查了半天才发现是电源不稳——TFT背光电流突变引起VCC跌落。解决方案是在电源入口加一个100μF电解电容+0.1μF陶瓷电容滤波。

还有SPI时序问题。不同屏幕对CPOL(时钟极性)和CPHA(相位)要求不同。ILI9341一般要求CPOL=0(空闲低电平)、CPHA=0(第一个边沿采样),也就是Mode 0。如果接线没问题却始终无法通信,务必确认SPI模式设置正确:

SPI1->CR1 |= SPI_Mode_Master | SPI_Direction_1Line_Tx |
             SPI_DataSize_8b | SPI_CPOL_Low | SPI_CPHA_1Edge |
             SPI_NSS_Soft | SPI_BaudRatePrescaler_4;

另外,不要忽视延时函数的精度。 Delay_ms() 最好基于SysTick实现,避免使用粗略的循环计数,否则初始化过程中的延时可能不足,导致屏幕未准备好就被写入命令。


这套方案的价值不仅在于“能用”,更在于它的可扩展性和教学意义。你可以轻松将其移植到其他STM32型号,只需调整GPIO和SPI寄存器基地址;也可以替换为ST7789、GC9A01等其他SPI屏幕,只要修改初始化序列即可。

未来想加触摸功能?接入XPT2046电阻屏,通过SPI读取坐标,再注册到LVGL输入设备即可。想显示中文?加载GB2312字体bin文件,使用 lv_label_set_long_mode(LV_LABEL_LONG_WRAP) 自动换行。甚至可以外挂SPI Flash存放图片资源,配合FS接口实现简单文件管理。

这一切的基础,正是这样一个“小而实”的图形驱动框架。


在资源受限的嵌入式世界里,从来不是谁拥有更多硬件谁就赢。真正的高手,懂得如何用软件去弥补硬件的短板。当你在一个只有20KB RAM的MCU上看到按钮按下时的阴影动画缓缓展开,那种成就感,远胜于堆砌豪华外设带来的即时满足。

而这套基于寄存器操作、精心调配内存、深度契合LVGL机制的SPI屏幕驱动方案,正是这种工程智慧的体现——它不炫技,但可靠;不高配,却实用。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐