☰
STM32驱动2.13寸墨水屏:接线SPI配置与避坑指南
2026/10/16 7:45:02 网站建设 项目流程

简介:这是一份针对2.13寸微雪墨水屏编写的STM32F103C8T6驱动程序,面向嵌入式开发者和电子纸显示应用爱好者。驱动基于ARM Cortex-M3平台,已按微雪官网接线方式配置,并成功从ZET6示例移植到C8T6上稳定运行,适合需要快速点亮墨水屏、学习STM32外设与电子纸时序控制的场景。压缩包共1035个文件,以C源码和头文件为主体,辅以Keil MDK-ARM工程文件、IOC引脚配置文件、链接脚本及编译生成的hex、axf等固件文件,整体体积24.38MB,目录包含User、Inc、Src、Drivers等模块,便于按功能定位代码。资源已有4727人学习下载,关注度较高。除可直接烧录的驱动程序外,用户还能获得完整的工程组织方式、外设初始化配置示例以及从ZET6迁移到C8T6的移植思路,对掌握STM32CubeMX配置、Keil项目管理和墨水屏底层驱动开发都有实际参考价值。

1. 墨水屏驱动到这里就绕开黑匣子:2.13 寸面板与 STM32F103C8T6 的搭配逻辑

墨水屏这玩意儿,很多人以为驱动很难,其实本质上就是 SPI 写入一堆寄存器配置,把图像 RAM 填好,再给一个刷新命令,剩下的交给面板内部的升压和波形控制器。我最早接触这块 2.13 寸墨水屏时,用的就是 STM32F103C8T6,改完驱动后发现单色帧缓冲只占不到 3KB,整套驱动文件量也小,特别适合做桌面便签、温湿度显示、小型工牌。这篇笔记不是我抄官方代码的搬运记录,而是把驱动从别人的工程里拆出来、移植到 ARM 核心的 F103 上、再调通供电和时序的全过程。新手能照着接线,熟手能看出边界和参数坑。适合手头有这块屏和一块最小系统板的从业者。

2. 接线与协议:SPI 引脚怎么分配,时序哪里容易踩

2.1 引脚分配:F103C8T6 上的 PA5/PA6/PA7 与四个控制脚

这块屏的电气接口非常标准,就是一组 SPI(SCLK、MOSI、CS)加三个控制脚(DC、RST、BUSY),还有一个电源一个地。和普通 LCD 比,它没有内置显存,也不需要频繁刷新,MCU 只有写命令和数据时才占用总线。

我一般把 SPI1 的引脚用作硬件 SPI:PA5 做 SCLK,PA6 做 MISO(其实这里用不到,除非你要读状态寄存器),PA7 做 MOSI。片选 CS 可以用 PB0 或任意 GPIO,DC 用 PB1,RST 用 PB8,BUSY 用 PB9。这样安排的好处是 PA5/PA7 避开默认的 JTAG 调试脚,PB0/PB1 又是普通 IO,不会和 USB 或者串口下载冲突。下面是我实际用的一张分配表,驱动源码里也按这个写。

屏引脚F103 引脚方向说明
VCC3V3-必须 3.3V,别接 5V
GNDGND-共地必须可靠
SCLKPA5SPI1_SCK模式 0,时钟 2MHz 以下
MOSIPA7SPI1_MOSI写数据用,不需要 MISO
CSPB0GPIO 推挽输出片选,低有效
DCPB1GPIO 推挽输出低=命令,高=数据
RSTPB8GPIO 推挽输出低复位,复位后拉高
BUSYPB9GPIO 上拉输入低电平表示忙(注意版本)

注意:有些版本默认 BUSY 高电平表示忙,一定要以你手头屏的丝印和卖家给的例程为准。我用的这块是低电平忙,后面所有代码都按这个语义写。

2.2 SPI 模式与初始化:CPOL=0、CPHA=0,时钟别拉满

墨水屏的 SPI 不像 SD 卡那么快,控制器手册一般建议时钟不超过 2MHz 或 4MHz。我习惯把 SPI1 分频到 2MHz。STM32F103 的 APB2 总线是 72MHz,SPI1 挂在 APB2 上,直接 36 分频就是 2MHz。这里有个隐形坑:如果用集成开发环境自动配置,默认可能给你 4 分频,SPI 时钟到 18MHz,线一旦超过 20 厘米,第一帧数据就会错位。

下面这段是 STM32 标准外设库风格改成的 HAL 初始化,我用在驱动包的第一层接口:

void EPD_SPI_Init(void) { GPIO_InitTypeDef gpio = {0}; SPI_HandleTypeDef hspi = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_SPI1_CLK_ENABLE(); gpio.Pin = GPIO_PIN_5 | GPIO_PIN_7; gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_8; gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, &gpio); gpio.Pin = GPIO_PIN_9; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &gpio); hspi.Instance = SPI1; hspi.Init.Mode = SPI_MODE_MASTER; hspi.Init.Direction = SPI_DIRECTION_2LINES; hspi.Init.DataSize = SPI_DATASIZE_8BIT; hspi.Init.CLKPolarity = SPI_POLARITY_LOW; hspi.Init.CLKPhase = SPI_PHASE_1EDGE; hspi.Init.NSS = SPI_NSS_SOFT; hspi.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_36; hspi.Init.FirstBit = SPI_FIRSTBIT_MSB; HAL_SPI_Init(&hspi); }

这段逻辑不复杂:先把 PA5/PA7 复用为 SPI1 引脚,PB0/PB1/PB8 设成普通推挽输出,PB9 设成带上拉的输入,然后配置 SPI1 为主模式、8 位数据。关键参数是CLKPolarity_LOW和CLKPhase_1EDGE,对应 SPI 模式 0;这两个写反,屏大概率不响应。BaudRatePrescaler_36就是 72MHz/36 = 2MHz,墨水瓶刷新不需要快,稳定优先。

2.3 命令和数据怎么区分:DC 引脚是分水岭

这块屏没有地址线,全靠 DC 引脚的电平决定当前 SPI 字节是命令还是参数。向 SPI 发数据之前,先拉低 DC,发一个命令字节,然后拉高 DC,再跟一串参数。片选 CS 在一个命令周期内保持低,结束后拉高。这个时序我封装成两个函数,后面所有驱动代码都只调用它们:

static void EPD_SendCmd(uint8_t cmd) { EPD_DC_LOW(); // 命令模式 EPD_CS_LOW(); // 选中 HAL_SPI_Transmit(&hspi, &cmd, 1, 100); EPD_CS_HIGH(); // 释放 EPD_DC_HIGH(); // 恢复数据模式 } static void EPD_SendData(uint8_t data) { EPD_DC_HIGH(); // 数据模式 EPD_CS_LOW(); HAL_SPI_Transmit(&hspi, &data, 1, 100); EPD_CS_HIGH(); }

注意EPD_DC_HIGH()和EPD_CS_HIGH()之间最好加几微秒延时,因为有些屏对 DC 建立时间要求大于 SPI 发完最后一个字节的间隔。我在调试时用逻辑分析仪量过,F103 在 72MHz 下跑完这条语句本身会吃掉几个时钟周期,但把EPD_CS_HIGH()放在EPD_DC_HIGH()前更容易出现片上 DC 判错。为稳妥,两个函数末尾都加了一个delay_us(1),代价微不足道。

3. 驱动初始化:寄存器序列和 BUSY 超时保护

3.1 驱动库文件里到底有什么

这包资源是典型的分层结构。根目录下有src、examples、fonts等几个文件夹。作为嵌入式开发者,只关心里面和单片机相关的三层:

  1. 平台无关的墨水屏驱动:负责命令、复位、等待 BUSY,对应epd_2in13*源文件。
  2. 硬件抽象层:一般叫dev_driver,封装 GPIO 和 SPI,这是唯一需要按自己板子改的地方。
  3. 应用层:画点、画线、显示字符串,这部分可以在 F103C8T6 上直接复用,但要注意字库数组占 Flash。

拿到包之后,建议把官方例子目录删掉,只保留面板驱动和字体数组,然后新建一个bsp_epd.c把硬件抽象层截到自己写的 GPIO/SPI 上。我一般会从原包里提取这些文件放到工程里:

src/ epd_2in13_V2.c epd_2in13_V2.h fonts/ font8.c font16.c examples/ (只参考,不编译)

其中epd_2in13_V2.c是核心,二次开发时重点看它末尾的初始化命令数组和刷新函数。原包里可能还有 BML 加载相关代码,用不上的直接屏蔽,能省 2KB 左右代码空间。

3.2 初始化序列:四段式

我把整个初始化拆成四段:复位、设置驱动输出、设置窗口坐标、启动一次空刷新。下面这段是从官方库精简出来的,寄存器地址按常见驱动芯片映射,如果你的屏芯片不同,以数据手册为准:

void EPD_Init(void) { // 1. 硬件复位 EPD_RST_HIGH(); HAL_Delay(10); EPD_RST_LOW(); HAL_Delay(10); EPD_RST_HIGH(); HAL_Delay(10); // 2. 设置驱动输出电压和升压 EPD_SendCmd(0x01); EPD_SendData(0x03); EPD_SendData(0x00); EPD_SendData(0x26); EPD_SendData(0x26); EPD_SendData(0x26); EPD_SendCmd(0x18); EPD_SendData(0x80); // 3. 设置数据模式和窗口 EPD_SendCmd(0x11); EPD_SendData(0x01); EPD_SendCmd(0x44); EPD_SendData(0x00); EPD_SendData(0x0F); EPD_SendCmd(0x45); EPD_SendData(0xC8); EPD_SendData(0x00); EPD_SendData(0x00); EPD_SendData(0x00); // 4. 启动清空显示 EPD_SendCmd(0x22); EPD_SendData(0xC4); EPD_SendCmd(0x20); EPD_WaitBusy(); }

逻辑说明:第 1 步先把芯片拉进复位状态再释放,让内部状态机归零;第 2 步是给电源电路一个参考电压配置,不同批次的屏数值可能不同,如果屏发灰,优先看这一步;第 3 步0x44/0x45定义了面板内存窗口,0x00~0x0F是 X 方向字节范围,0xC8是 Y 方向起点,这个对应 212×104 面板的实际映射;最后0x22=0xC4触发内部清屏,0x20是启动刷新,这一条命令会在内部刷一遍全白,把上次断电后的残影消掉。

注意:不同来源的屏,0x01后面的参数可能不一致。我有一次换了一版面板,按旧参数初始化后每次显示都偏灰,后来查数据手册发现是升压参考电压位不同。拿不准时,直接用卖家给的初始化数组,别自己猜数值。

3.3 BUSY 引脚与超时保护

等待 BUSY 是初始化最容易被忽略的一步。上面第 4 步调用了EPD_WaitBusy(),它的实现必须带超时,因为墨水屏刷新一次要几百毫秒到几秒,如果程序跑到一半被看门狗咬死,或者 BUSY 接线错了,代码就会在 while 循环里出不来。我的习惯是加一个毫秒计数器,超过 5 秒直接返回错误:

uint8_t EPD_WaitBusy(void) { uint32_t timeout = 0; while (EPD_BUSY_READ() == BUSY_LEVEL) { HAL_Delay(1); if (++timeout > 5000) { return 1; // 超时 } } return 0; }

这里的BUSY_LEVEL根据原理图定义,我的是0(低电平忙)。你只需要修改这个宏,就能适配高电平忙的那一批板子。超时之后不要盲目重试,先检查供电,再看 DC/CS 引脚有没有接错,十有八九是物理接线问题。

4. 刷屏与缓冲:212×104 数据怎么写到 RAM 再刷新

4.1 单色帧缓冲只要 2808 字节

墨水屏的像素不是每个 bit 对应一个字节,而是 8 个像素压缩成一个字节。屏幕分辨率 212×104,单色缓冲大小是 ceil(212/8)×104 = 27×104 = 2808 字节。F103C8T6 的 SRAM 有 20KB,放这个数组完全没压力。但如果你驱动的是带红/黄的三色版,B/W 和 RED 两个平面各一份,就是 5616 字节,依然在安全线以内。

我习惯在工程里建静态数组,不动态分配:

#define EPD_WIDTH_BYTES 27 // 212/8 向上取整 #define EPD_HEIGHT 104 static uint8_t frame_bw[EPD_WIDTH_BYTES * EPD_HEIGHT]; static uint8_t frame_red[EPD_WIDTH_BYTES * EPD_HEIGHT]; // 三色版需要

一次性把两帧都开出来,代价是 5.6KB RAM,换来的是画图函数不用频繁切换缓冲区。如果只做单色,可以把frame_red用条件编译关掉,省下 2.8KB 给协议栈或 UI 状态机用。

4.2 写入 RAM 的命令序列:0x24 与 0x20

画图函数在缓冲区里改好数据后,需要把缓冲区整体推到面板控制器。常见的序列是先发0x24写黑白平面,再发0x22/0x20激活刷新。下面这段代码同时处理黑白和红色平面:

void EPD_DisplayFrame(void) { uint32_t i; // 写黑色/白色平面 EPD_SendCmd(0x24); for (i = 0; i < sizeof(frame_bw); i++) { EPD_SendData(frame_bw[i]); } // 写红色/黄色平面(三色屏需要,单色屏把这段注释掉) if (has_red_plane) { EPD_SendCmd(0x26); for (i = 0; i < sizeof(frame_red); i++) { EPD_SendData(frame_red[i]); } } // 触发刷新 EPD_SendCmd(0x22); EPD_SendData(0xF7); EPD_SendCmd(0x20); EPD_WaitBusy(); }

0x24是黑白 RAM 写入命令,0x26是红色 RAM 写入命令。0x22=0xF7的意思是启用完整 LUT 刷新,具体解释在数据手册里叫 “Display Update Control”;0x20把它真正触发。发送图像前不需要重新设置窗口,初始化已经把整个面板的坐标范围设好了;如果你修改了窗口,就必须同步调整0x44/0x45的地址边界,否则刷出来就是错位的。

4.3 清屏与局部刷新:不要每次整屏刷,也不要频繁局部刷

最简单的清屏是把frame_bw全部清零,然后调用EPD_DisplayFrame()。但要注意,墨水屏全屏刷新一次大约 2 到 3 秒,中间不能断电,否则显示会停留在半刷状态。局部刷新可以用面板的局部窗口命令,但我在底层驱动里没有单独做,因为墨水屏本身不适合短时间频繁局部刷新,残影会积累到不可接受。

如果你确实需要局部更新数字,比如每小时改一次温湿度,可以在帧缓冲里修改一个矩形区域,然后仍然全屏刷一次,效果最稳定。电子墨水屏的物理特性决定了它不可能像 LCD 那样实时刷新,想要快只能选更贵的刷新方案。

4.4 参数选择:对比度与刷新时间

面板的对比度、刷新时间和边框强度都受 LUT 数据影响。官方库把 LUT 存在const uint8_t数组里,一般不用动。唯一值得调的是0x01命令里的输出电压,数值越大对比度越高,但残影也会加重。低温环境下需要把升压目标调高一些,常温保持默认。调试时用一张黑白棋盘格观察边界是否发灰,如果发灰,把0x18的值从0x80改成0x81,让内部升压电路更强一点。改完要断电等 10 秒再测,不要连续刷,不然你分不清是调参效果还是残影叠加。

5. 避坑与常见问题:五个真实翻车现场

5.1 白屏不显示:SPI 模式写错

现象:初始化流程能跑完,BUSY 也正常,但屏幕一片白,偶尔出现几条横线。

原因:SPI 时序和面板控制器对不上。我第一次移植时用了集成环境默认的 SPI 模式 3,因为之前驱动 Flash 芯片也是这么用的,但墨水屏只认模式 0(CPOL=0、CPHA=0)。

解决:把 SPI 初始化里的CLKPolarity和CLKPhase改成 LOW/1EDGE,重新编译下载。改完还白屏,接着看下一个问题。

5.2 图像发灰残影重:电源纹波过大

现象:刚刷出来的黑白图案,黑色发灰,白色区域还透出上一帧的轮廓。

原因:面板内部的升压泵对电源纹波很敏感。我最初用开发板上的线性稳压直接供电,板载 LED 一闪烁,纹波就怼到几百毫伏,升压泵工作不稳。

解决:给墨水屏单独加一颗 10uF 钽电容并联一颗 100nF 陶瓷电容,放在屏的电源接口附近。条件允许用低噪声 LDO,别用电荷泵;MCU 和屏共地以后再供电,不要形成地环路。

5.3 BUSY 永远忙:引脚配置成了推挽输出

现象:代码卡在while(BUSY),用万用表量电平在跳变或一直拉低。

原因:我把 BUSY 引脚误配成GPIO_MODE_OUTPUT_PP,MCU 内部的推挽输出直接和屏的开漏输出打架,电平就僵住。

解决:BUSY 必须配成输入模式,并且打开内部上拉。如果屏的 BUSY 是开漏输出,还要额外接一个 10K 上拉到 3.3V,保证高电平识别可靠。

5.4 显示缺了一块:坐标窗口没设完整

现象:图案右下角少一块,或者顶部多一条噪声条。

原因:初始化时0x44和0x45的窗口范围和实际分辨率不一致。我曾经把 X 方向范围按字节数写成0x00~0x1A,但驱动芯片要求的是像素坐标区块偏移量,结果窗口比面板实际 RAM 大,最后一行被截断。

解决:X 方向取值范围是 0 到 (212/8 - 1) = 26,但我更建议直接用原库里的0x00 0x0F这类参数,因为它代表的是面板 RAM 的字节边界而不是简单的宽除以 8。换型号时不能只改分辨率宏,要同时核对窗口命令的字节值。

5.5 上一次内容消不掉:刷新前没做清屏

现象:第二次刷屏时,新图像叠在旧图像上,像打印重影。

原因:上电后直接写 RAM 然后刷新,跳过了初始化里的清屏事件,面板里残留的电荷把上一帧波形又打了一遍。

解决:每次上电初始化后先执行一次只含0x22=0xC4的空刷新,把面板清成白屏,再写新帧。如果多次连续刷新,中间加 5 秒以上的静置时间,让电子墨水稳定下来。

6. 进阶:确认刷新顺序、关闭未用外设,让显示更干净

6.1 把 GPIO 复用配置写在初始化最前面

很多驱动不成功,其实是上电时系统默认把 PA5/PA7 当普通 IO,SPI 外设还没开启,总线上空闲电平不对。我的习惯是在main()开头先调用EPD_SPI_Init(),紧接着初始化墨水屏,中间不插费时的串口打印。另外,F103C8T6 的 PB3、PB4 默认走 JTAG,如果你把它们用作 CS 或 DC,必须在启动代码里关闭 JTAG,只保留 SWD:

GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);

否则引脚电平拉不动,屏就会选到芯片内部默认逻辑,表现出来的症状就是乱码。这行代码要在 GPIO 初始化之前写。

6.2 用测试图验证像素和窗口对齐

我每次改完驱动,会先显示一张四角带边框的测试图,而不是直接显示汉字。因为汉字字体数组容易掩盖偏移问题。测试图的做法:在frame_bw的四个角对应的字节写边界值,刷新后看屏幕四角是否都有黑边。

frame_bw[0] = 0xFF; frame_bw[EPD_WIDTH_BYTES - 1] = 0x80; frame_bw[(EPD_HEIGHT - 1) * EPD_WIDTH_BYTES] = 0x01; frame_bw[(EPD_HEIGHT - 1) * EPD_WIDTH_BYTES + EPD_WIDTH_BYTES - 1] = 0x01;

看到四条边后,说明分辨率宏和窗口寄存器配对成功,然后再调字库和 GUI;如果某个角黑边缺失或错位,优先回来查0x44/0x45和EPD_WIDTH_BYTES。

6.3 关掉不需要的时钟和外设,防止刷新过程被中断干扰

墨水屏刷新最忌讳的,是在EPD_WaitBusy()里被高频定时器打断。虽然 Cortex-M3 的中断延迟很短,但内部升压泵在关键相位被系统抢占,波形会畸变。我做标签项目时,强制规定刷新期间关闭无关外设时钟,SysTick 中断优先级降到最低,刷新完成后再恢复。从那以后,每次刷屏前我都会先看一下哪些外设还在跑,能关的就关,花屏概率从偶尔一次降到了几乎为零。墨水屏不是高速外设,但对中断的敏感度比 LCD 高很多,希望这个习惯能帮到你。

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

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

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

立即咨询