
简介面向使用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 是单向数据输入不需要 MISOSCL 是软件产生的时钟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_WritePinHAL_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 的窗口、偏移和 RGB565ST7735 不是上电就能画点的屏。它内部有电源控制、帧率控制、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 RAM128×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可以开两个本文还有配套的精品资源点击获取