☰
STM32驱动2.13寸墨水屏:SSD1680寄存器配置与SPI时序实战
2026/10/16 7:43:17 网站建设 项目流程

简介:一套针对2.13英寸微雪墨水屏编写的STM32F103C8T6驱动程序,由官方ZET6示例精简移植而来,接线方式与微雪官网保持一致,运行稳定。面向嵌入式入门开发者、电子纸显示应用工程师,也适合正在学习ARM Cortex-M3驱动开发的爱好者,可应用于电子价签、低功耗信息牌等场景。压缩包共1035个文件,约24.38MB,以C源文件与头文件为主体,兼含Keil MDK-ARM工程文件、IOC引脚配置、启动文件、链接脚本以及DSP数学库,从底层外设初始化到墨水屏刷新控制均有完整覆盖,拿到后可直接用Keil打开编译或按需裁剪移植。已有4727人浏览学习。借助这套驱动代码可重点学习STM32的GPIO/SPI驱动编写、墨水屏帧刷新机制,以及不同开发板间的代码迁移方法,对快速上手墨水屏项目和底层驱动排错都很有参考价值。

1. 一块 2.13 寸墨水屏驱动的落地过程:从微雪模块到 STM32F103C8T6

前阵子做一个低功耗温湿度牌,想在断电报数后只靠电池待机几个月,选来选去还是用了微雪的 2.13 寸墨水屏。这颗屏幕第一眼看上去很不起眼,刷新时甚至会闪几下黑白噪点,但静态显示完全不耗电,这特性让 LCD 和 OLED 在它面前都显得费电。真正动手后发现,驱动这块屏幕比想象中简单,却也藏着不少坑:SPI 时序、忙引脚等待、初始化寄存器顺序,任何一个环节不对,屏幕上就是一片花点或永久残影。这篇笔记就围绕着一份可直接编译的 STM32F103C8T6 驱动展开,适合刚接触墨水屏的嵌入式开发者,也适合想把这颗屏快速接入自己项目的人。整个工程只有几个 C 源文件,却能完成从清屏、画图到局部刷新的完整流程。

2. 硬件连接与工程框架:先把引脚和 SPI 时序理顺

2.1 微雪 2.13 寸屏的引脚定义与接线清单

微雪这颗 2.13 寸墨水屏,常见的是不带底板的裸屏模组,分辨率 250x122,驱动芯片是 SSD1680。接口是标准的 SPI 总线,加上 DC、CS、RST、BUSY 四根控制线,加上电源一共 8 个引脚。对这个裸屏模组,引脚顺序一般标注在柔性排线上,从左到右通常是 VCC、GND、DIN、CLK、CS、DC、RST、BUSY。

电源部分要特别注意,墨水屏虽然静态不耗电,但驱动电压偏高,刷新瞬间电流可以达到十几毫安。模组的 VCC 接 3.3V,GND 共地,不能用 5V 直供。STM32F103C8T6 的开发板大多板载 3.3V LDO,直接取板子上的 3.3V 输出即可。为了减少刷新时电源跌落导致的复位问题,我一般会在 VCC 和 GND 之间加一个 10uF 钽电容,离屏越近越好。

接线参考如下表格,对你自己的板子引脚做相应调整即可。

墨水屏引脚STM32F103C8T6 引脚说明
VCC3.3V电源正极,刷新时电流较大
GNDGND共地
DINPA7(SPI1_MOSI)主发从收数据线
CLKPA5(SPI1_SCK)时钟线,空闲时低电平
CSPA4片选,低电平有效
DCPA6命令/数据选择,低为命令
RSTPA3硬件复位,低电平有效
BUSYPA2忙检测,高电平表示忙碌

这个接法在 Blue Pill 板上可以免飞线直接对着排针插,PA5 和 PA7 同时也是 SPI1 的 SCK 和 MOSI,方便后面切换硬件 SPI。如果你手里的板子没有引出这些引脚,GPIO 模拟也可以,后面驱动函数里只用了这几个引脚输出高低电平,换成任意 GPIO 都行。

2.2 最小工程:STM32 的 GPIO 与 SPI 初始化

这份驱动工程没有使用 HAL 库,直接基于寄存器操作,方便移植到任何 STM32 系列。初始化部分主要做两件事:配置 GPIO 模式和初始电平。墨水屏的 CLK 空闲时要保持低电平,CS 默认拉高,RST 先拉低再拉高完成一次上电复位。

下面是 GPIO 初始化代码,关键注释都标出来了:

static void EPD_GPIO_Init(void) { RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPBEN; // 配置 PA2(PIN2) 为输入:BUSY GPIOA->CRL &= ~(0x0F << (2 * 4)); GPIOA->CRL |= (0x08 << (2 * 4)); // CNF=10, MODE=00 => 浮空输入 // 配置 PA3(PIN3) RST, PA4(PIN4) CS, PA6(PIN6) DC, PA7(PIN7) DIN 为推挽输出 GPIOA->CRL &= ~(0xFFFF << (3 * 4)); GPIOA->CRL |= (0x03 << (3 * 4)) | (0x03 << (4 * 4)) | (0x03 << (6 * 4)) | (0x03 << (7 * 4)); // 配置 PA5(PIN5) CLK 为推挽输出 GPIOA->CRL |= (0x03 << (5 * 4)); EPD_CS_HIGH(); EPD_CLK_LOW(); EPD_RST_HIGH(); }

这段代码里,PA2 作为 BUSY 输入,配置成浮空输入即可,因为模组上的 BUSY 引脚是推挽驱动,不需要上拉。注意 GPIO 配置用的是 CRL 寄存器,PA0 到 PA7 都在 CRL 里,每 4 位控制一个引脚,这里用移位加掩码来避免覆盖别的引脚。所有初始电平都设置到了墨水屏要求的状态,接下来才能安全地发命令。

SPI 这部分我没有用硬件 SPI,而是用 GPIO 模拟,原因有两个:墨水屏时钟最高只有 2MHz 左右,GPIO 模拟完全够用;第二个原因是模拟时序对后面查问题帮助很大,逻辑分析仪抓到哪一步能直接对应到代码。下面是模拟写命令和写数据的函数:

static void EPD_WrCmd(uint8_t cmd) { EPD_DC_LOW(); // DC 拉低,通知屏幕接下来是命令 EPD_CS_LOW(); // 拉低 CS,选中设备 for (int i = 7; i >= 0; i--) { EPD_CLK_LOW(); if (cmd & (1 << i)) EPD_DIN_HIGH(); else EPD_DIN_LOW(); EPD_CLK_HIGH(); } EPD_CS_HIGH(); // 释放片选 EPD_DC_HIGH(); // 默认回到数据模式 } static void EPD_WrData(uint8_t dat) { EPD_DC_HIGH(); // DC 拉高,表示数据 EPD_CS_LOW(); for (int i = 7; i >= 0; i--) { EPD_CLK_LOW(); if (dat & (1 << i)) EPD_DIN_HIGH(); else EPD_DIN_LOW(); EPD_CLK_HIGH(); } EPD_CS_HIGH(); }

写命令和写数据的唯一区别就是 DC 引脚的电平:命令传 0,数据传 1。每个 bit 都是先拉低 CLK、设置 DIN、再拉高 CLK 完成一次上升沿采样。这个时序在 SSD1680 datasheet 里有明确要求:CLK 低电平时数据必须已稳定,所以先改 DIN 再抬 CLK。实际调试中,如果发现花屏,第一件事就是拿示波器看 CLK 的数据建立时间,凡是模拟 GPIO 翻车的问题十有八九出在这里。

3. 驱动代码拆解:SSD1680 初始化寄存器序列与核心函数

3.1 SSD1680 的寄存器分组与工作模式

SSD1680 是这颗屏的驱动 IC,内部 RAM 大小是 250x122 bit,黑白屏每个像素占用 1 bit,1 表示黑色,0 表示白色,所以一屏完整数据接近 4KB。它内部其实有多个 RAM 区域,黑白屏只用其中一个作为显示内容源,红色或三色屏用的是另一块区域。寄存器本身分成三组:命令寄存器、状态寄存器、数据寄存器,通过 DC 引脚区分。

初始化向屏幕写入一组固定配置,常见顺序是:软件复位、设置驱动输出控制、设置数据入口模式、设置 RAM 地址范围、设置边框、设置显示开始线。这些寄存器一旦设定,后续写数据时地址计数器会自动递增,不需要每发一个字节都重新指定坐标。这也是为什么像微雪这类屏驱动起来以后,刷新图片就是连续 write RAM 的过程。

3.2 init 函数的完整调用链

下面是这份驱动最核心的初始化函数,几乎每条命令都在注释里标了用途。平时我们照搬微雪官方例程可能只觉得是模板,但真正移植到别的 MCU 时你才会理解每条命令的作用。

void EPD_Init(void) { EPD_GPIO_Init(); // 硬件复位:拉低 RST 维持 10ms,再拉高 EPD_RST_LOW(); HAL_Delay(10); EPD_RST_HIGH(); HAL_Delay(10); // 等待 BUSY 拉低,表示屏幕空闲 while (EPD_BUSY_READ()) {}; EPD_WrCmd(0x01); EPD_WrData(0x00); EPD_WrData(0x00); EPD_WrData(0x00); EPD_WrData(0x7F); EPD_WrData(0x02); // 驱动输出控制:Gate 输出 121 行,起始位置 0 EPD_WrCmd(0x11); EPD_WrData(0x01); // 数据入口模式:从左上角开始扫描,地址方向从左到右、从上到下 EPD_WrCmd(0x44); EPD_WrData(0x00); EPD_WrData(0x0F); // 设置 RAM X 地址范围:0 到 15(因为宽度按字节算,122 像素约 16 字节) EPD_WrCmd(0x45); EPD_WrData(0x00); EPD_WrData(0x00); EPD_WrData(0x7F); EPD_WrData(0x00); // 设置 RAM Y 地址范围:0 到 121 EPD_WrCmd(0x4E); EPD_WrData(0x00); // 设置 RAM X 地址计数器起始为 0 EPD_WrCmd(0x4F); EPD_WrData(0x00); EPD_WrData(0x00); // 设置 RAM Y 地址计数器起始为 0 EPD_WrCmd(0x50); EPD_WrData(0x57); // 边框选择 VBDL=1,边界数据输出 1(白底) EPD_WrCmd(0x62); EPD_WrData(0x00); EPD_WrData(0x00); // 设置显示开始时第 0 行 EPD_WrCmd(0x30); EPD_WrData(0x17); // 设置 PLL 频率为 50Hz 左右,这个值影响刷新速度 EPD_WrCmd(0x22); EPD_WrData(0xF7); // 显示更新控制:启用内部 LUT,执行全刷新 EPD_WrCmd(0x20); // 激活显示,触发一次刷新 while (EPD_BUSY_READ()) {}; }

这段 init 有几个参数需要说明:0x01 的第四个参数 0x7F 是 Gate 扫描高度,因为 2.13 寸屏实际高度是 122 行,0x7F 是 127 的上限值,配合第三个参数 0x02 后两个字节拼成 0x027F,即 639 行?其实这里不是,SSD1680 的驱动输出寄存器由多个字节组成,但微雪官方驱动通常按模块写死,建议不要随意改。0x11 的 0x01 代表从上到下、从左到右扫,改成其他值可以镜像、旋转,这在做不同装配方向时很常用。0x44 设置 X 范围 0 到 15,正好对应 16 字节 x 8 bit = 128 位,覆盖了 122 行的宽度。0x50 的 0x57 是边框像素选择,设置为 1 表示未写数据的区域显示白底,否则没写数据时屏幕会保留上一次的黑白状态。

3.3 图像数据写入 RAM 的批量发送函数

初始化完成后,屏幕上其实还是空白。要把一副图显示出来,需要按位把每个像素的颜色写入 RAM。写入顺序必须和 0x11 设定的扫描方向一致,否则图片会镜像或者上下颠倒。

下面这段是整屏写入函数,传入的是一个大小为 16 x 122 的字节数组,每个字节代表横向 8 个像素。对墨水屏来说,1 通常为黑,0 为白,这一点和普通 LCD 相反,新手很容易在这里翻车,导致显示出来的图案黑白反转。

void EPD_Display(const uint8_t *image) { uint16_t i; // 重新设置 RAM 地址,回到起点 EPD_WrCmd(0x4E); EPD_WrData(0x00); EPD_WrCmd(0x4F); EPD_WrData(0x00); EPD_WrData(0x00); EPD_WrCmd(0x10); // 进入写 RAM 数据命令 for (i = 0; i < 16 * 122; i++) { EPD_WrData(image[i]); // 逐字节写入 } // 写入完成后激活刷新 EPD_WrCmd(0x22); EPD_WrData(0xF7); EPD_WrCmd(0x20); while (EPD_BUSY_READ()) {}; }

参数 image 是连续存放的位图数据,长度为 1952 字节,即 16(像素/字节) * 122(行)。如果你的图片分辨率不是 250x122,需要在写入前做缩放或裁剪。0x10 是写 RAM 的命令,写入的每个字节从左到右对应横向 8 个像素,注意是 MSB 在前。刷新时 0x22 的 0xF7 选择内部 LUT,这是一个完整刷新的配置,能保证对比度稳定。如果改成 0xFF 之类,屏幕会按 OTP 里的 LUT 走,效果不稳定。我在调试中遇到过刷完有残影的问题,换成 0xF7 后明显改善。

4. 完整刷新流程:从清屏、取模到图片显示

4.1 清屏函数的实现与调用位置

清屏本质上是往 RAM 里填充全白或全白加全黑交替。全部填充 0xFF 是全白,填充 0x00 是全黑。由于墨水屏刷新机制,直接填充 0xFF 再刷新时屏幕可能留下上一帧的残影,所以最稳妥的做法是连续刷新两次:第一次刷全白,第二次再刷想要的图像。这个细节在低温环境下尤其重要。

void EPD_Clear(uint8_t color) { uint16_t i; EPD_WrCmd(0x4E); EPD_WrData(0x00); EPD_WrCmd(0x4F); EPD_WrData(0x00); EPD_WrData(0x00); EPD_WrCmd(0x10); for (i = 0; i < 16 * 122; i++) { EPD_WrData(color); // 全 0 或全 1 } EPD_WrCmd(0x22); EPD_WrData(0xF7); EPD_WrCmd(0x20); while (EPD_BUSY_READ()) {}; }

color 参数传入 0x00 是刷全黑,传入 0xFF 是刷全白。这个函数在程序启动时建议先调用一次全白,把屏幕残留清干净,然后再调用 EPD_Display 写入真正内容。不这么做的话,第一次上电屏上大概率是一半黑一半白的随机噪点,尤其是放了很久的屏。

4.2 图片取模与字节顺序

图片要变成数组,常见取模工具输出的是纵向取模、横向取模两种格式。SSD1680 需要的是横向取模、MSB 在前,也就是每 8 个像素组成一个字节,坐标从左到右填充。如果你用 PCtoLCD2002 这类工具,取模设置里要选「横向取模」「字节正序」「MSB 在前」。如果工具输出的是纵向取模,那填进去以后整个图像会旋转 90 度,这也是很多人第一次显示图片翻车的原因。

拿一张 250x122 的纯黑白图举例,取模后生成的大数组可以直接塞进下面的显示流程:

int main(void) { HAL_Init(); SystemClock_Config(); EPD_Init(); // 初始化寄存器 EPD_Clear(0xFF); // 先刷一次全白,清除残影 HAL_Delay(500); // 等屏幕稳定 EPD_Display(image_weather); // 显示取模后的天气图标 while (1) { // 进入低功耗模式,等待定时唤醒 HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI); } }

这段代码展示了典型的产品级调用方式:上电初始化、清屏、显示一次,然后立刻进低功耗。因为墨水屏静态显示不耗电,刷新完成后再也不需要喂给它任何时钟。我用万用表量过,显示完待机时整机电流不到 10uA,这对电池供电产品来说基本可以忽略不计。

4.3 动态显示文字的思路:字库与 RAM 拼接

很多项目不只是显示图片,还要显示温度、电量等动态数据。一种简单方案是在外部 Flash 里放 GBK 字库,另一种更省事的方案是直接把字符取模成小数组,运行时按坐标写入大位图。这里说一个我常用的方法:先准备一张 16x16 的点阵字库,每个字符 32 字节,然后把字符点阵按位置拷贝到显示缓存里。

假设显示缓存是 uint8_t frame_buffer[16][122],要显示一个字符到坐标 (x, y),常见做法是逐行把字符的点阵字节按位移拼进去。注意覆盖场景,如果两个字符挨得近,要按列处理位运算。下面是一段只支持 8x16 字符的简化写法:

void DrawChar(uint8_t *frame, uint8_t x, uint8_t y, uint8_t ch) { const uint8_t *font = font8x16 + (ch - ' ') * 16; for (uint8_t row = 0; row < 16; row++) { uint8_t data = font[row]; // 确认目标行列不超出屏幕 if (y + row >= 122 || x + 7 >= 250) continue; // 计算 Frame Buffer 对应的字节位置 uint16_t byte_pos = y + row; // 第几行 uint8_t bit_pos = x & 0x07; // 字节内偏移 uint16_t base = byte_pos * 16 + (x >> 3); frame[base] &= ~(0xFF >> bit_pos); frame[base] |= (data >> bit_pos); if (bit_pos > 0) { frame[base + 1] &= ~(0xFF << (8 - bit_pos)); frame[base + 1] |= (data << (8 - bit_pos)); } } }

这段代码里的位运算逻辑是这类横向扫描屏最常见的处理方式:先把目标字节要覆盖的位清干净,再把字体数据右移或者左移拼进去。bit_pos 不为零时,一个字节的字体点阵会横跨两个显示字节,所以要同时处理 base 和 base+1 。我把这个函数独立出来以后,后面画进度条、画矩形都复用了同一套 bit 操作,整体代码量控制得很紧凑。

5. 常见问题与排查:从插线到刷屏最容易翻车的五个地方

5.1 上电后屏幕完全无反应,BUSY 引脚一直为高

现象:程序烧进去后,屏幕什么变化都没有,测量 VCC 正常,但 BUSY 引脚电平死活不拉低。

原因:这种情况八成是 RST 时序没满足。SSD1680 要求复位信号低电平保持时间至少 10ms,如果代码里只 delay 了 1ms 就拉高,IC 可能没完成内部复位,BUSY 永远不会释放。另一个常见原因是 CS 忘记拉高,多个 SPI 设备共线时会互相干扰。

解决:把 RST 拉低时间改成 20ms,拉高后再等 20ms,BUSY 拉不低就逐个引脚量电平,特别注意 DC 初始状态是否被其他外设拉低。

5.2 刷新后屏幕显示全黑或者全白,但图像完全不对

现象:用 EPD_Display 显示图片时,屏幕不是花屏,而是整体全黑或全白,甚至会出现上一次图像的残影。

原因:全黑全白通常说明图像数据其实写进去了,但是写入地址和显示地址对不上。如果初始化里 0x44 和 0x45 设置了错误的范围,RAM 地址计数器绕回后会把数据写到看不见的区域,屏幕自然只显示边框颜色或旧内容。

解决:对照 SSD1680 的 datasheet 确认 0x44 设置 X 范围为 0x00 到 0x0F,0x45 设置 Y 范围为 0x00 到 0x7F。这两个范围必须覆盖你写入数据的长度,多一字节或少一字节都会出问题。

5.3 显示出来的图片左右镜像,字符是反的

现象:图片看起来像是从背面透视的一样,左右反了,但上下正常。

原因:数据入口模式寄存器 0x11 配错了参数。0x01 表示从左到右,如果写成 0x00 就从右向左扫描,同一块数据镜像显示。

解决:把 0x11 的值从 0x01 改成 0x02 或 0x00 观察变化,记住这个值同时也影响刷新方向,改完后记得重新跑一遍清屏。

5.4 SPI 时钟频率太高导致偶发花屏,低概率复现

现象:程序烧进去后有时候正常,有时候刷出来一半好一半花,尤其是一边跑屏幕一边跑串口打印时更明显。

原因:SSD1680 的 SPI 时钟上限一般在 2MHz 左右,而 STM32 硬件 SPI 默认分频后可能还有 9MHz。GPIO 模拟时如果循环优化过度,CLK 高电平时间只有几十纳秒,数据建立时间不够,就会出现偶发采样错误。

解决:GPIO 模拟里每个 bit 最后加一个 NOP 或者少优化等级;硬件 SPI 将分频系数设为 32 以上。我用逻辑分析仪抓过波形,CLK 频率降到 1.5MHz 后问题彻底消失。

5.5 低温环境刷新残影明显,甚至刷新失败

现象:环境温度低于 5 度时,原本正常的刷新流程突然变慢,刷完后残影特别严重,偶尔 BUSY 长时间不释放。

原因:墨水屏的驱动电压和 LUT 参数是按常温设计的,低温下墨水颗粒的移动速度变慢,同样的驱动时序推不动粒子移动,残影自然变严重。BUSY 拉高时间变长也是因为内部波形整形时间延长。

解决:在初始化里把 0x30 的 PLL 频率调低,比如改成 0x2C,同时刷新前先刷两次全白预热屏幕。产品有低温需求的话,刷新间隔不要太短,至少留 2 秒让屏幕恢复稳定。

6. 二次开发思路:局部刷新、低功耗休眠与跨平台移植

拿到这份驱动以后,最常见的一个需求就是局部刷新,比如做一个计时器或者表盘,每秒只想更新数字部分,而不是整屏闪一下。墨水屏的局部刷新并不是直接只发送一片区域的 RAM,而是要先用 0x44 和 0x45 重新设置 RAM 地址范围,再把对应坐标的数据写进去,最后用不同的 LUT 参数驱动。这里有个血泪经验:局部刷新用的 LUT 参数和全刷时不一样,常见的做法是把 0x22 的参数改成 0xF9 或 0xF0,否则刷新时整个屏幕还是会闪一次。如果你不需要低功耗,最简单粗暴的方案是把整屏数据始终保留在内存里,只修改局部数据再整屏刷新,代价只是刷新时间大约多出 200ms,但换来的稳定性是超值的。

休眠配置也是产品化不可缺少的一环。SSD1680 内部有休眠命令 0x10,发送 0x01 即可进入深度睡眠,电流能降到 1uA 以内。唤醒时不能直接写命令,必须先做一次完整的 RST 复位再重新初始化。我最初忽略了这个顺序,调试时发现从休眠唤醒后屏幕一直保持黑屏,折腾半天才意识到唤醒必须重新走一遍 init 流程,这个坑说出来不值钱,但卡住你的那三小时是实打实的。

移植到其他 MCU 的思路其实很清晰:找一个文件专门放底层接口,把前面代码里的 EPD_WrCmd、EPD_WrData、EPD_RST_LOW、EPD_BUSY_READ 这些函数单独提出来。比如要从 STM32F103 移植到 ESP32,只需要把 GPIO 操作换成 Arduino 的 digitalWrite 或者 ESP-IDF 的 gpio_set_level,上层初始化、显示、清屏代码一行都不用改。依赖 HAL_Delay 的地方换成你自己系统的 sleep 函数即可。

最终我想留个习惯:每次拿到一个新墨水屏模组,第一条做法不是直接抄官方例程,而是先看 datasheet 里的时序图,把 CLK、DIN、DC、CS 的先后关系画出来,再照着这个工程从头写一遍 GPIO 模拟时序。这样做过三轮之后,我再也没有被某个新屏的 SPI 时序玄学卡住过。这份驱动把所有流程都拆开了,你照着改自己的项目时只要保持这种「先画时序、再动代码」的节奏,基本能避开九成以上的坑。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询