随着屏幕的阵阵闪烁刷新、黑白字符图案浮现眼前,毕业设计总算有了起色。经历了几个下午的不懈努力总算把墨水屏驱动搞定,点亮的何止是小小的墨水屏,还有我骚动的心呐!一开始还想着从头啃芯片手册造轮子,最后由于时间紧迫 + 能力有限,于是想(tou)到(lan)把微雪提供的 STM32 例程移植到 8266。
由于都是纯 C 的代码,整个过程说不上十分艰辛,但也踩了不少坑:从最初对 8266 硬件 SPI 的不明觉厉,到迫于无奈用 GPIO 软件模拟、最后移植适配官方 STM32 例程,与网上的 Arduino、Lua 实现不同,我是在乐鑫 Non-OS SDK 环境下开发,步骤稍多,特此记录。
开发环境
- SDK:ESP8266_NONOS_SDK-2.1.0 Toolchain:乐鑫标配 VirtualBox + lubuntu 编译环境
- Editor:VS Code / Nodepad++ / 随便你啦
- board: NodeMCU
友情提示:没有接触过 8266 SDK 开发?没事,在正式开始之前,请按照文档《ESP8266 SDK 入门指南》把编译、下载的流程玩一遍即可。
屏幕相关资料
- 产品详情:2.9inch e-Paper Module - Waveshare Wiki
- 用户手册:2.9inch-e-paper-user-manual-cn.pdf
- 数据手册:2.9inch e-Paper Datasheet.pdf
- 例程源码:2.9inch_e-Paper_Module_code.7z
waveshare 2.9 寸黑白墨水屏(模块)介绍
产品参数
电压 | 3.3 V |
---|---|
通信接口 | 3-wire SPI、 4-wire SPI |
分辨率 | 296 × 128 |
显示颜色 | 黑、白 |
灰度等级 | 2 |
刷新时间 | 2-3 s |
管脚定义
VCC | 3.3 V |
---|---|
GND | GND |
DIN | SPI 通信 MOSI 引脚 |
CLK | SPI 通信 SCK 引脚 |
CS | SPI 片选引脚(低电平有效) |
DC | 数据/命令控制引脚(高电平表示数据,低电平表示命令) |
RSY | 外部复位引脚(低电平复位) |
BUSY | 忙状态输出引脚(高电平表示忙) |
数据显示原理
屏幕通过 SPI 总线与 MCU 连接作为从机使用,通过接受命令/数据完成相应功能。和其他模块相似,墨水屏 SPI 总线上传输的比特位也分为数据和命令两种类型,并根据 DC 引脚电平区分(高电平数据低电平命令)。而其中最重要的数据便是显存了,它决定着屏幕每个像素点的状态,控制显示内容。
2.9 寸墨水屏分辨率为 296 × 128,即宽度有 296 个像素点、高度有 128 个像素点。对应到显存数据就是横向每 8 个点用一个字节表示(1 byte = 8 pixels),一行就包含 296/8 = 37 个字节,共有 128 行。显示方式是从左到右从上到下,如下图所示:
知道数据是如何显示在屏幕上,对我们编写相关显示函数十分有用。当然,在例程中已经实现了显示字符、矩形、圆形等常用函数,直接调用相应函数,就可以设置显存数据为显示内容(各种画图显示函数只不过在折腾这一堆字节数据而已)。
局部刷新原理
局部刷新可以避免显示时全屏闪烁,加快刷新速度并提高用户观感。如此神奇的的功能是怎么做到的呢?其实通过手册的描述不难猜测实现原理。
黑白墨水屏的显示流程可粗略分为 4 步:
- 准备显存数据(调用各种显示函数折腾一堆字节数据)
- 传输显存数据(只是将数据传到屏幕内部存储空间中,此时屏幕无动静)
- 发送刷新命令(刷新屏幕、显示相应内容)
- 等待刷新完成(MCU 检测 BUSY 引脚电平)
而实现局部刷新的玄机就藏在第 2 和 第 3 步之间 —— 屏幕内部其实有两份显存空间,这两块空间轮流接收 MCU 传来的显存数据。当收到刷新命令时,内部控制器并不是直接显示所有显存数据,而是先把要显示的数据与另一块空间(上一次的旧数据)作对比,只刷新发生变化的数据,完成显示(这一切都是由硬件自动完成,程序员照常发送完整一帧数据)。
PS: 这其实和「差分 OTA」或「增量更新」是同一思想,都存在「比较数据差异」、「更新变化数据」的过程。
注意事项
- 供电电压和引脚信号电平需保持一致,否则显示异常(【坑】Arduino 引脚电平固定 5 V 输出,需临时用 5 V 供电测试)。
- 显示区域像素宽度必须为 8 的倍数,原因见上面「数据显示原理」。
- 只支持黑、白两级灰度,非黑即白(显示浓淡墨色的中国画就别想了,毕竟不是 Kindle)。
- 【坑】三色屏并不是白送一种颜色,还多「送」了十几秒的刷新时间,并且不支持局部刷新!选型时注意。
官方 STM32 例程分析
解压例程包 2.9inch_e-Paper_Module_code.7z,其中包含了 Arduino、树莓派以及 STM32 三个版本的例程。打开 stm32
文件夹,其中 BSP
目录存放了墨水屏驱动相关源文件和头文件,Fonts
目录则是字模数组相关文件,其余部分都是通用 STM32 程序的必要文件。
从最顶层的 Src/mian.c
着手分析程序,一步步深挖源码。 main()
函数虽然有点长,但分段来看并不复杂。总的来说,示例程序做了这么几件事情:
1. 芯片外设、墨水屏初始化
从程序起始处一些带有 Init
字样的函数名可以看出,程序第一件事就是初始化各种外设,这也是一个系统的「例行公事」。其中包括对系统时钟、GPIO、SPI、串口等芯片外设的初始化(由 STM32 HAL 库函数完成)和墨水屏硬件及显示参数的初始化(通过向屏幕发送指定命令/数据完成)。
/* USER CODE END 1 */
/* MCU Configuration----------------------------------------------------------*/
/* Reset of all peripherals, Initializes the Flash interface and the Systick. */
HAL_Init();
/* USER CODE BEGIN Init */
/* USER CODE END Init */
/* Configure the system clock */
SystemClock_Config();
/* USER CODE BEGIN SysInit */
/* USER CODE END SysInit */
/* Initialize all configured peripherals */
MX_GPIO_Init();
MX_SPI1_Init();
MX_USART2_UART_Init();
/* USER CODE BEGIN 2 */
EPD epd;
if (EPD_Init(&epd, lut_full_update) != 0) {
printf("e-Paper init failed\n");
return -1;
}
Paint paint;
Paint_Init(&paint, frame_buffer, epd.width, epd.height);
Paint_Clear(&paint, UNCOLORED);
EPD_Init()
函数会发送一长串命令/数据序列对模块硬件进行初始设置,接着调用 Paint_Init()
函数设置画板的缓冲数组(显存)和宽高。整个初始化工作直到执行完 Paint_Clear(&paint, UNCOLORED);
语句 —— 清空显存数据为止。
2. 显示字符、矩形和圆形
/* For simplicity, the arguments are explicit numerical coordinates */
/* Write strings to the buffer */
Paint_DrawFilledRectangle(&paint, 0, 10, 128, 34, COLORED);
Paint_DrawStringAt(&paint, 0, 14, "Hello world!", &Font16, UNCOLORED);
Paint_DrawStringAt(&paint, 0, 34, "e-Paper Demo", &Font16, COLORED);
/* Draw something to the frame buffer */
Paint_DrawRectangle(&paint, 16, 60, 56, 110, COLORED);
Paint_DrawLine(&paint, 16, 60, 56, 110, COLORED);
Paint_DrawLine(&paint, 56, 60, 16, 110, COLORED);
Paint_DrawCircle(&paint, 120, 90, 30, COLORED);
Paint_DrawFilledRectangle(&paint, 16, 130, 56, 180, COLORED);
Paint_DrawFilledCircle(&paint, 120, 160, 30, COLORED);
/* Display the frame_buffer */
EPD_SetFrameMemory(&epd, frame_buffer, 0, 0, Paint_GetWidth(&paint), Paint_GetHeight(&paint));
EPD_DisplayFrame(&epd);
EPD_DelayMs(&epd, 2000);
初始化工作完成后,就可以开始折腾显存数据、显示内容了。和一开始在「局部刷新原理」部分提到的流程一样:首先调用一波类似 Paint_DrawXXX()
的函数,按照需要处理显存缓冲数组 frame_buffer
的内容(本质就是位操作)。然后调用 EPD_SetFrameMemory()
和 EPD_DisplayFrame()
发送数据、刷新屏幕、延时等待刷新完成。
3. 清显存、显示图片 LOGO
/**
* there are 2 memory areas embedded in the e-paper display
* and once the display is refreshed, the memory area will be auto-toggled,
* i.e. the next action of SetFrameMemory will set the other memory area
* therefore you have to set the frame memory and refresh the display twice.
*/
EPD_ClearFrameMemory(&epd, 0xFF);
EPD_DisplayFrame(&epd);
EPD_ClearFrameMemory(&epd, 0xFF);
EPD_DisplayFrame(&epd);
/* EPD_or partial update */
if (EPD_Init(&epd, lut_partial_update) != 0) {
printf("e-Paper init failed\n");
return -1;
}
/**
* there are 2 memory areas embedded in the e-paper display
* and once the display is refreshed, the memory area will be auto-toggled,
* i.e. the next action of SetFrameMemory will set the other memory area
* therefore you have to set the frame memory and refresh the display twice.
*/
EPD_SetFrameMemory(&epd, IMAGE_DATA, 0, 0, epd.width, epd.height);
EPD_DisplayFrame(&epd);
EPD_SetFrameMemory(&epd, IMAGE_DATA, 0, 0, epd.width, epd.height);
EPD_DisplayFrame(&epd);
第二部分要显示微雪的 LOGO(对!就是广告上那个),原理和前面一样,只不过显存是预先准备好的图片取模数据。在显示前需要调用 EPD_ClearFrameMemory()
全局清屏刷新。注意这里是发送数据到屏幕清内部显存,而不像初始化时的 Paint_Clear()
只是对 MCU 内存中的缓冲数组赋值,因此要重复操作两块内部显存。
4. 显示时间计数(局部刷新)
time_start_ms = HAL_GetTick();
/* USER CODE END 2 */
/* Infinite loop */
/* USER CODE BEGIN WHILE */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
time_now_s = (HAL_GetTick() - time_start_ms) / 1000;
time_string[0] = time_now_s / 60 / 10 + '0';
time_string[1] = time_now_s / 60 % 10 + '0';
time_string[3] = time_now_s % 60 / 10 + '0';
time_string[4] = time_now_s % 60 % 10 + '0';
Paint_SetWidth(&paint, 32);
Paint_SetHeight(&paint, 96);
Paint_SetRotate(&paint, ROTATE_90);
Paint_Clear(&paint, UNCOLORED);
Paint_DrawStringAt(&paint, 0, 4, time_string, &Font24, COLORED);
EPD_SetFrameMemory(&epd, frame_buffer, 80, 72, Paint_GetWidth(&paint), Paint_GetHeight(&paint));
EPD_DisplayFrame(&epd);
EPD_DelayMs(&epd, 500);
}
/* USER CODE END 3 */
例程的最后一部分演示了局部刷新功能,在 while(1)
循环中获取系统时间并计算显示。这次使用了 Paint_SetWidth()
、Paint_SetHeight()
函数将显示区域划定为 32 × 96 大小,只清缓冲数组不清内部显存,实现局部刷新时钟走秒。
最后建议找一块 STM32F103 的开发板把例程跑一下,看看墨水屏实际显示效果。建议不断改动源码、对比运行效果差异,加深对程序的理解。
驱动程序移植
不同芯片的软件框架与硬件配置都存在差异,STM32 与 ESP8266 也是如此,只有了解两者各自的特点才能实现「为我所用」。
乐鑫 ESP8266 IoT_Demo 项目结构
在 SDK 入门教程中提供的 IoT_Demo 代码演示了完整的 ESP8266 Non-OS SDK 工程的组成,可通过 gcc 和 make 工具构建项目,在 Linux 下运行项目目录的 gen_misc.sh
脚本即可编译固件。
IoT_Demo
目录下存放项目总的 Makefile
文件,其下的 user
、driver
子目录也有独立的 Makefile 文件。构建时,子目录下的文件将单独编译为 .a
文件,最后合并为 .bin
固件。
需要特别注意 Makefile 文件的一些关键参数,总 Makefile
文件中的 24 行通过 SUBDIRS
变量引入子目录:
SUBDIRS= \
user \
driver
在 49 行的 COMPONENTS_eagle.app.v6
将编译后的子模块包含进来:
COMPONENTS_eagle.app.v6 = \
user/libuser.a \
driver/libdriver.a
其中 libuser.a
等文件名在各子目录 Makefile
文件中的 GEN_LIBS
变量指定:
ifndef PDIR
GEN_LIBS = libdriver.a
endif
include
目录下放置项目用到的所有头文件,使用 INCLUDES := $(INCLUDES) -I $(PDIR)include
语句引入。
PS:include 下子目录头文件引入时需写全路径,如 #include “driver/key.h”。
微雪例程 BSP 代码结构
所谓程序移植,核心在于修改具体硬件相关代码。良好的代码结构有利于移植工作,在例程目录 BSP
中的文件包含了墨水屏操作函数,并按照不同层次分为 epdif.c
、epd2in9.c
和 epdpaint.c
三个文件(及对应头文件)。
其中和硬件相关、最底层的是 epdif.c
,包含 SPI 数据发送、GPIO 电平控制、毫秒延时等基本操作函数,以适配不同硬件并向上提供接口。在此基础上根据不同型号屏幕特性进行封装,形成 epd2in9.c
(2.9 寸屏)文件,完成基本的初始化、数据命令交互功能。最上层的 epdpaint.c
与画图相关,只是对图像缓存数据进行处理,跟硬件无关。
显然,移植工作的重点是修改 epdif.c
文件,重写底层硬件相关功能函数,这时需要了解两者软硬件上的一些差异,像 SPI 接口、定时器等。
SPI 字节发送函数
MCU 与墨水屏通过 SPI 总线协议通讯,因此 SPI 数据发送函数便是所有功能的根本。微雪例程中, SPI 发送函数使用 STM32 HAL 库封装好的 HAL_SPI_Transmit()
完成数据发送,在 epdif.c
文件 75 行的 EpdSpiTransferCallback()
处调用:
void EpdSpiTransferCallback(unsigned char data) {
...
HAL_SPI_Transmit(&hspi1, &data, 1, 1000);
...
}
8266 带有两个硬件 SPI 接口并提供接口函数,但由于硬件 SPI 从寄存器层面就针对 Flash 存储器做了特定的支持,需要与之通讯的 SPI 设备做对应的匹配处理,也就是说 ESP8266 提供的硬件 SPI 接口并不通用。自己尝试折腾了一遍相关函数,连墨水屏程序仅需的字节发送功能都实现不了,于是果断放弃。最后决定使用软件 GPIO 模拟的方式实现 SPI 接口数据发送。
延时函数与看门狗复位
epdif.c
文件还需要提供定时器毫秒延时函数接口,很不幸, 8266 的 SDK 只有微秒延时函数,且最大值为 65535 us,就需要循环调用来实现更长的延时,这倒不是多大的问题。相比之下,「喂狗」问题更值得注意 —— 和 stm32 的裸机程序不同,在 ESP8266 Non-OS SDK 上用户线程不能长时间占用 CPU(像 while(1)
这种),否则会导致看门狗复位,ESP8266 重启。
解决方法是勤「喂狗」,正如官方文档所说:
如果某些特殊情况下,⽤户线程必须执⾏较长时间(⽐如⼤于 500 ms),建议经常 调⽤ system_soft_wdt_feed() API 来喂软件看门狗,⽽不建议禁⽤软件看门狗。
在墨水屏驱动程序中,经常出现等待屏幕响应刷新的情况(墨水屏动作出了名的慢),如果不注意「喂狗」问题,芯片便会莫名其妙重启,你就一脸懵逼捉急。
C 标准与 GCC 编译选项
STM32 程序中使用了 C99 标准的循环写法 for (int i = 0; ; i++){...}
,官方例程在 MDK 配置附上了 C99 编译选项,而 ESP8266 所用的 xtensa-lx106-elf-gcc
编译器默认使用 C89 标准,因此也需要增加额外编译参数才能正常编译示例源码。
更多具体细节将在下一篇继续探讨,并正式开始程序的移植工作。
参考资料