ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103C8T6软件SPI驱动ST7735 1.8寸TFT屏实战

STM32F103C8T6软件SPI驱动ST7735 1.8寸TFT屏实战

简介:面向使用STM32F103C8T6进行小尺寸彩屏开发的嵌入式学习者与工程师,这份驱动工程围绕HAL库与软件SPI实现1.8寸ST7735 TFT LCD的点亮、初始化与像素数据传输,适合在硬件SPI资源紧张或需要灵活引脚分配的场景中参考,也可作为掌握SPI时序、命令/数据切换和屏幕刷新机制的实践样例。压缩包共86个文件,约4.14MB,包含C/H源码、Keil工程与启动文件、调试配置、HEX输出及说明文本等,便于直接编译验证与按模块查阅。已有5735人学习下载,具备一定的参考热度。工程覆盖GPIO与SPI底层配置、ST7735初始化序列、显示数据发送和主程序调用等内容,读者可据此理解软件模拟SPI的时序组织、移植到相近型号时的改动点,以及从工程配置到屏幕显示的完整链路。

1. 一块 1.8 寸屏接上 STM32F103C8T6:为什么我优先用软件 SPI 点亮

手头只有一块 1.8 寸 ST7735 彩屏和一片 STM32F103C8T6 最小系统板时,最容易翻车的不是初始化代码,而是引脚不够和硬件 SPI 被占死。这份驱动把 STM32 TFT 显示拆成软件 SPI 位翻转、ST7735 初始化、开窗写显存三段,不依赖 SPI 外设,任意 GPIO 都能挪。适合第一次点屏、需要快速验证界面原型的开发者,也适合想看懂 ST7735 时序而不是只会调库的人。

2. 接线与软件 SPI 时序:把 72 MHz 拆成能看懂的四根线

软件 SPI 的本质是用 GPIO 手动模拟时钟和数据,速度比硬件 SPI 慢,但换引脚自由、调试直观。STM32F103C8T6 的 72 MHz 主频足够把 1.8 寸 128×160 的 ST7735 刷到能看的程度,前提是别在时序里塞太多 HAL 层函数。先把线接对,再谈初始化,否则后面白屏、花屏会把人带进玄学区。

2.1 引脚分配:CS、DC、RST、SCL、SDA 与背光

ST7735 模块常见引脚是 VCC、GND、CS、RESET、A0/DC、SDA、SCL、LED/BLK。其中 SDA 是单向数据输入,不需要 MISO;SCL 是软件产生的时钟;DC 决定当前字节是命令还是数据;CS 低有效;RST 低电平复位。背光 LED 一般串一个限流电阻,或者模块自带电阻,直接给 3.3V 之前先看丝印。

信号建议引脚方向说明
SCLPA5输出软件 SPI 时钟,模式 0 空闲低
SDAPA7输出命令/数据共用,MSB 先发
CSPA4输出低有效,刷屏时尽量整段拉低
DCPA3输出低为命令,高为数据
RSTPA2输出独立复位,不要接 MCU 复位
BLK3.3V 或 PA1输出/电源背光,注意限流

我一般会把 CS 单独留出来,不直接接地。虽然只挂一个屏时 CS 常拉低也能跑,但后面加第二块 SPI 设备、或者用逻辑分析仪抓波形时,CS 就是最清楚的边界。RST 也不要省,软件复位命令 0x01 只能复位控制器内部状态,硬件复位时序不对时,软件复位经常救不回来。

GPIO 初始化用 HAL 只做一次,之后刷屏内层循环不要反复进 HAL。下面这段把五根控制线一次性配成推挽输出,速度开到最高档:

#define LCD_SCL_PIN GPIO_PIN_5 #define LCD_SDA_PIN GPIO_PIN_7 #define LCD_CS_PIN GPIO_PIN_4 #define LCD_DC_PIN GPIO_PIN_3 #define LCD_RST_PIN GPIO_PIN_2 #define LCD_GPIO_PORT GPIOA static void lcd_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); gpio.Pin = LCD_SCL_PIN | LCD_SDA_PIN | LCD_CS_PIN | LCD_DC_PIN | LCD_RST_PIN; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(LCD_GPIO_PORT, &gpio); LCD_CS_H(); LCD_DC_H(); LCD_SCL_H(); LCD_SDA_H(); }

GPIO_SPEED_FREQ_HIGH不是可选项,软件 SPI 的边沿越陡,时序越容易稳定。引脚尽量短,杜邦线超过十几厘米后,时钟边沿和串扰会开始影响 ST7735 的采样,尤其是面包板供电时,白屏不一定是代码问题。

2.2 软件 SPI 的时序底线:模式 0、时钟极性和建立时间

ST7735 的 4 线串行接口一般在 SCL 上升沿采样 SDA,对应 SPI 模式 0:空闲低电平,第一个边沿采样。软件模拟时顺序不能反:先把数据放到 SDA,再拉高 SCL,稍作停留,再拉低 SCL。很多白屏是先在 SCL 上升沿之后才改 SDA,控制器采到的全是上一个 bit。

#define LCD_SPI_DELAY() do { __NOP(); __NOP(); } while (0) static void lcd_spi_write_byte(uint8_t data) { for (uint8_t i = 0; i < 8; i++) { if (data & 0x80) { LCD_SDA_H(); } else { LCD_SDA_L(); } data <<= 1; LCD_SCL_H(); LCD_SPI_DELAY(); LCD_SCL_L(); LCD_SPI_DELAY(); } }

这段按 MSB 先发,data & 0x80取最高位,左移后处理下一位。LCD_SPI_DELAY()里放两个__NOP()只是保守起点,72 MHz 下大约几十纳秒级,实际能不能再压要看线长和屏幕模块。逻辑分析仪抓 SCL 周期,先保证数据建立时间明显大于 ST7735 要求,再逐步减少 NOP;如果一减就花屏,说明不是“屏幕玄学”,而是建立时间不够。

注意:软件 SPI 的时钟不能只看主频,还要算上函数调用、循环和 GPIO 写入。用HAL_GPIO_WritePin写内层循环,实际边沿会被 HAL 层开销拉得很不均匀,后面刷屏会明显拖慢。

2.3 用 BSRR/BRR 替代 HAL_GPIO_WritePin

HAL_GPIO_WritePin可读性好,但内部有参数检查、端口判断和函数调用。刷一个 128×160 的 16 位像素要发 327680 个 bit,每个 bit 再进一次 HAL,时间全耗在跳转上。STM32F103 的 GPIO 有 BSRR 和 BRR 寄存器,置位和复位是单条写寄存器,原子操作,适合软件 SPI 的内层循环。

#define LCD_SCL_H() (LCD_GPIO_PORT->BSRR = LCD_SCL_PIN) #define LCD_SCL_L() (LCD_GPIO_PORT->BRR = LCD_SCL_PIN) #define LCD_SDA_H() (LCD_GPIO_PORT->BSRR = LCD_SDA_PIN) #define LCD_SDA_L() (LCD_GPIO_PORT->BRR = LCD_SDA_PIN) #define LCD_CS_H() (LCD_GPIO_PORT->BSRR = LCD_CS_PIN) #define LCD_CS_L() (LCD_GPIO_PORT->BRR = LCD_CS_PIN) #define LCD_DC_H() (LCD_GPIO_PORT->BSRR = LCD_DC_PIN) #define LCD_DC_L() (LCD_GPIO_PORT->BRR = LCD_DC_PIN) #define LCD_RST_H() (LCD_GPIO_PORT->BSRR = LCD_RST_PIN) #define LCD_RST_L() (LCD_GPIO_PORT->BRR = LCD_RST_PIN)

BSRR低 16 位置位、高 16 位复位;BRR只做复位。这里只用了低 16 位,所以宏参数直接写GPIO_PIN_x。把这几条宏放在头文件里,后面命令、数据、开窗、刷屏都复用同一套,不会出现某一段忘了拉 DC 的情况。

软件 SPI 写字节的函数不用改,只把里面的LCD_SDA_H()、LCD_SCL_H()换成本组宏。实际测下来,同一块板、同一根线,去掉 HAL 写引脚后全屏填充时间能明显下降。这个优化不改变接线,也不依赖 SPI 外设,属于软件 SPI 最划算的一步。

3. ST7735 初始化序列:128×160 的窗口、偏移和 RGB565

ST7735 不是上电就能画点的屏。它内部有电源控制、帧率控制、Gamma、显示方向、颜色格式和显存窗口,初始化就是把这些寄存器按模块厂家要求排一遍。1.8 寸 128×160 模块常见控制器是 ST7735S 或兼容型号,初始化序列大差不差,但偏移量、颜色顺序和 Gamma 会因屏而异。代码能点亮只是第一步,能稳定开窗、不越界、颜色正确才算把驱动捏住。

3.1 ST7735 的寄存器地图:先复位,再谈初始化

硬件复位时序很简单:RST 拉低至少 10 ms,拉高后再等 120 ms 左右,让内部电荷泵和振荡器稳定。很多模块 datasheet 写 5 ms 也行,但面包板供电上升慢,我一般给 20 ms 低电平、120 ms 高电平,留足余量。复位后先发SWRESET 0x01,再发SLPOUT 0x11退出睡眠,最后DISPON 0x29开显示。

static void lcd_hard_reset(void) { LCD_RST_L(); HAL_Delay(20); LCD_RST_H(); HAL_Delay(120); } static void lcd_write_cmd_data(uint8_t cmd, const uint8_t *data, uint16_t len) { LCD_CS_L(); LCD_DC_L(); lcd_spi_write_byte(cmd); LCD_DC_H(); while (len--) { lcd_spi_write_byte(*data++); } LCD_CS_H(); }

lcd_write_cmd_data把一条命令和它的参数放在同一个 CS 低电平区间里。ST7735 的串行接口在 CS 拉高后可能复位字节状态,命令和参数分两次 CS 操作,有的模块能跑,有的模块会偶发不认参数。刷屏时也一样,RAMWR 0x2C之后要连续发像素,CS 不要每个像素都翻一次。

3.2 初始化序列逐条拆:帧率、电源、Gamma 与 MADCTL

下面这段是 1.8 寸 128×160 常见模块的保守初始化。帧率、电源和 Gamma 不是越激进越好,先让屏稳定点亮,再调刷新率和颜色。MADCTL 0x36控制扫描方向和 RGB/BGR 顺序,COLMOD 0x3A设成0x05表示 16 位 RGB565。LCD_X_OFFSET和LCD_Y_OFFSET先按常见模块给 2 和 1,后面用边框测试确认。

#define LCD_WIDTH 128 #define LCD_HEIGHT 160 #define LCD_X_OFFSET 2 #define LCD_Y_OFFSET 1 static void lcd_init(void) { lcd_gpio_init(); lcd_hard_reset(); lcd_write_cmd_data(0x01, NULL, 0); /* SWRESET */ HAL_Delay(150); lcd_write_cmd_data(0x11, NULL, 0); /* SLPOUT */ HAL_Delay(120); const uint8_t frm1[] = {0x01, 0x2C, 0x2D}; lcd_write_cmd_data(0xB1, frm1, sizeof(frm1)); const uint8_t frm2[] = {0x01, 0x2C, 0x2D}; lcd_write_cmd_data(0xB2, frm2, sizeof(frm2)); const uint8_t frm3[] = {0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D}; lcd_write_cmd_data(0xB3, frm3, sizeof(frm3)); const uint8_t inv[] = {0x07}; lcd_write_cmd_data(0xB4, inv, sizeof(inv)); const uint8_t pw1[] = {0xA2, 0x02, 0x84}; lcd_write_cmd_data(0xC0, pw1, sizeof(pw1)); const uint8_t pw2[] = {0xC5}; lcd_write_cmd_data(0xC1, pw2, sizeof(pw2)); const uint8_t pw3[] = {0x0A, 0x00}; lcd_write_cmd_data(0xC2, pw3, sizeof(pw3)); const uint8_t pw4[] = {0x8A, 0x2A}; lcd_write_cmd_data(0xC3, pw4, sizeof(pw4)); const uint8_t pw5[] = {0x8A, 0xEE}; lcd_write_cmd_data(0xC4, pw5, sizeof(pw5)); const uint8_t vm[] = {0x0E}; lcd_write_cmd_data(0xC5, vm, sizeof(vm)); lcd_write_cmd_data(0x20, NULL, 0); /* INVOFF */ const uint8_t madctl[] = {0xC8}; lcd_write_cmd_data(0x36, madctl, sizeof(madctl)); const uint8_t colmod[] = {0x05}; lcd_write_cmd_data(0x3A, colmod, sizeof(colmod)); const uint8_t gm_p[] = { 0x02, 0x1C, 0x07, 0x12, 0x37, 0x32, 0x29, 0x2D, 0x29, 0x25, 0x2B, 0x39, 0x00, 0x01, 0x03, 0x10 }; lcd_write_cmd_data(0xE0, gm_p, sizeof(gm_p)); const uint8_t gm_n[] = { 0x03, 0x1D, 0x07, 0x06, 0x2E, 0x2C, 0x29, 0x2D, 0x2E, 0x2E, 0x37, 0x3F, 0x00, 0x00, 0x02, 0x10 }; lcd_write_cmd_data(0xE1, gm_n, sizeof(gm_n)); lcd_write_cmd_data(0x13, NULL, 0); /* NORON */ HAL_Delay(10); lcd_write_cmd_data(0x29, NULL, 0); /* DISPON */ HAL_Delay(100); }

FRMCTR1/2/3里三个参数控制不同模式下的帧率,0x01, 0x2C, 0x2D是常见保守值,不是唯一答案。PWCTR1的0xA2, 0x02, 0x84影响电荷泵和电压,屏偏暗或闪的时候可以先动这里,但不要一次改太多。MADCTL的0xC8是MY|MX|RGB,如果画面上下颠倒、左右镜像,改这一位组合比在绘图函数里到处翻转坐标干净。

颜色顺序是另一个高频坑。0xC8里的 bit3 控制 RGB/BGR,如果红色显示成蓝色,先切这一位,再怀疑字节序。COLMOD 0x05固定 16 位色,发像素时高字节在前。Gamma 两组各 16 字节,不同屏厂给的曲线不一样,但先跑通再微调,不要一开始就在 Gamma 上耗时间。

3.3 开窗与写显存:CASET、RASET、RAMWR 的边界

ST7735 画图不是直接给坐标,而是先设列地址范围CASET 0x2A、行地址范围0x2B,再发RAMWR 0x2C,后面连续发的像素按窗口自动递增。窗口坐标要加上模块偏移,否则 128×160 的图会整体偏几个像素,右边或下边出现花边。很多“显示偏移”不是初始化错,而是偏移宏和窗口裁剪没对上。

static void lcd_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1) { uint8_t buf[4]; x0 += LCD_X_OFFSET; x1 += LCD_X_OFFSET; y0 += LCD_Y_OFFSET; y1 += LCD_Y_OFFSET; buf[0] = x0 >> 8; buf[1] = x0 & 0xFF; buf[2] = x1 >> 8; buf[3] = x1 & 0xFF; lcd_write_cmd_data(0x2A, buf, 4); buf[0] = y0 >> 8; buf[1] = y0 & 0xFF; buf[2] = y1 >> 8; buf[3] = y1 & 0xFF; lcd_write_cmd_data(0x2B, buf, 4); } static void lcd_fill_rect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color) { if (x >= LCD_WIDTH || y >= LCD_HEIGHT) return; if (x + w > LCD_WIDTH) w = LCD_WIDTH - x; if (y + h > LCD_HEIGHT) h = LCD_HEIGHT - y; lcd_set_window(x, y, x + w - 1, y + h - 1); LCD_CS_L(); LCD_DC_L(); lcd_spi_write_byte(0x2C); LCD_DC_H(); for (uint32_t i = 0; i < (uint32_t)w * h; i++) { lcd_spi_write_byte(color >> 8); lcd_spi_write_byte(color & 0xFF); } LCD_CS_H(); }

lcd_set_window里先加偏移再拆高低字节,CASET和RASET各四个参数:起始列高、起始列低、结束列高、结束列低。lcd_fill_rect先做边界裁剪,防止x + w超出 128 后写到面板 RAM 外,表现为右边一条杂色。RAMWR命令发出后 CS 保持低,像素连续发完再拉高,这是刷屏稳定的关键。

注意:不同批次 1.8 寸模块的偏移可能是 0/0,也可能是 2/1。先画一个 1 像素边框,看四条边是否对称;偏移不对时不要急着改初始化序列,先把LCD_X_OFFSET和LCD_Y_OFFSET单独试。

4. 从画点到刷字符:显存、局部刷新和字模的取舍

STM32F103C8T6 只有 20 KB RAM,128×160 的 RGB565 全屏帧缓冲要 40 KB,放不下。这个约束决定了驱动不能照搬“先画到内存再整屏推送”的桌面思路,只能直接写屏,或者用小块 tile 缓冲做局部刷新。软件 SPI 的带宽也有限,全屏刷新一次要算清楚时间,不然界面会卡到怀疑人生。

4.1 画笔抽象:draw_pixel 与 RGB565 颜色换算

RGB565 用 5 位红、6 位绿、5 位蓝,总共 16 位。绿色多一位是因为人眼对绿色更敏感。把 8 位 RGB 转成 565 时,红色右移 3 位、绿色右移 2 位、蓝色右移 3 位,再拼起来。发像素时高字节在前,低字节在后,和COLMOD 0x05对应。

static inline uint16_t lcd_rgb565(uint8_t r, uint8_t g, uint8_t b) { return (uint16_t)(((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)); } void lcd_draw_pixel(uint16_t x, uint16_t y, uint16_t color) { if (x >= LCD_WIDTH || y >= LCD_HEIGHT) return; lcd_set_window(x, y, x, y); LCD_CS_L(); LCD_DC_L(); lcd_spi_write_byte(0x2C); LCD_DC_H(); lcd_spi_write_byte(color >> 8); lcd_spi_write_byte(color & 0xFF); LCD_CS_H(); }

lcd_draw_pixel每画一个点都要重设窗口,效率不高,但作为底层验证非常有用。点屏阶段先用它画十字线、边框、四个角,能快速确认偏移和方向。批量绘图时不要循环调用它,应该用lcd_fill_rect或者lcd_push_rect,把窗口设一次,连续发一片像素。

如果红色和蓝色互换,先看MADCTL的 bit3,再看lcd_rgb565的移位。0xF8保留红色高 5 位,0xFC保留绿色高 6 位,0x1F是蓝色 5 位。三个掩码写错一个,颜色就会偏得很规律,反而好排查。

4.2 局部刷新:脏矩形比全屏刷新更划算

全屏 128×160 共 20480 个像素,每个像素 16 位,光像素数据就是 327680 bit。软件 SPI 就算跑到 4 MHz 量级,加上命令和循环开销,一帧也要几十毫秒到上百毫秒。界面里只改一个数值、一个进度条时,全屏重刷既慢又闪,正确做法是记录“脏矩形”,只推发生变化的小块。

缓冲区像素数RAM 占用适用场景
8×8 tile64128 B小图标、字符块
16×16 tile256512 B局部控件、进度条
32×32 tile10242 KB中等区域刷新
128×160 全屏2048040 KB本芯片 RAM 放不下

下面这个lcd_push_rect接收一块 RGB565 缓冲区,按行优先排列,窗口设一次后连续推像素。16×16 的 tile 只占 512 B,可以开两个

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进