0x00 关于墨水屏
在本系列的前几篇文章中,我们已经学习了 RP2040 的各种功能,包括 I2C、SPI、定时器、DMA 等。现在,我们用这些技术,点亮一块墨水屏。笔者手中的墨水屏是中景园生产的,型号为 ZJY152152-0154GAB-R,分辨率 152×152 像素,黑白红三色,驱动 IC 为 SSD1680,通讯采用 SPI 协议。外观如下:
![](https://www.ruanx.net/content/images/2024/06/QQ--20240623170840.jpg)
![](https://www.ruanx.net/content/images/2024/06/QQ--20240623170831.jpg)
墨水屏本身有 22pin,其中一大半是电源和电容接口。模块只引出了 8pin 给单片机,分别是:
- VCC、GND:电源;
- SCL、SDA、CS#:分别是 SPI 时钟、SPI 数据、SPI 片选信号,其中片选是低电平有效;
- RES#:复位信号;
- DC#:表示正在传输的讯息是指令还是数据。DC# 为高时,SPI 传输数据;为低时,传输指令;
- BUSY:高电平时表示墨水屏忙碌。
这个墨水屏模块没有区分 MISO/MOSI 线,而是使用单条 SDA 线传输信息。根据数据手册,write 和 read 都是利用 SDA 线完成的。对于 write 操作,单片机先发送 8bit 的寄存器地址,然后发送要写入的数据;对于 read 操作,单片机先发送 8bit 寄存器地址(时钟上升沿触发),然后墨水屏会向 SDA 输出数据(时钟下降沿触发)。
文档给出了一些时序要求。SPI 时钟最大频率是 20MHz,我们编程时可以先使用较慢的频率,以便调试。
![](https://www.ruanx.net/content/images/2024/06/image-64.png)
文档中给出的启动流程是:
- 通电并等待 10ms;
- 发送
0x12
指令,进行软件复位,等待 10ms; - 发送
0x01
指令,设置栅极驱动器输出;
发送0x11, 0x44, 0x45
指令,设置显示屏 RAM 大小;
发送0x3C
指令,设置屏幕边框; - 发送
0x18
指令,设置温度传感器;
发送0x22, 0x20
指令,设置波形 LUT;
等待 BUSY 信号变成低电平; - 发送
0x4E, 0x4F, 0x24, 0x26
指令,将图像写进 RAM;
发送0x0C
设置 softstart;
发送0x22, 0x20
驱动显示面板;
等待 BUSY 信号变成低电平; - 发送
0x10
以进入深度睡眠;
断电。
接下来,我们看一看厂家自己的例程。
0x01 阅读例程
厂家例程是以 stm32 为基础的,用 keil 开发,编码甚至是 GB2312。main.c
如下:
#include "delay.h"
#include "usart.h"
#include "EPD_GUI.h"
#include "Pic.h"
u8 ImageBW[2888];
u8 ImageR[2888];
int main()
{
float num=12.05;
u8 dat=0;
delay_init();
uart_init(115200);
EPD_GPIOInit();
Paint_NewImage(ImageBW,EPD_W,EPD_H,0,WHITE); //创建画布
Paint_Clear(WHITE);
Paint_NewImage(ImageR,EPD_W,EPD_H,0,WHITE); //创建画布
Paint_Clear(WHITE);
/************************全刷************************/
EPD_Init();
Paint_SelectImage(ImageBW);
EPD_ShowPicture(0,0,152,152,gImage_1,BLACK);
Paint_SelectImage(ImageR);
EPD_ShowPicture(0,0,152,152,gImage_2,BLACK);
EPD_Display(ImageBW,ImageR);
/****清空画布****/
Paint_SelectImage(ImageBW);
Paint_Clear(WHITE);
Paint_SelectImage(ImageR);
Paint_Clear(WHITE);
/****清空画布****/
EPD_Update();
EPD_DeepSleep();
delay_ms(1000);
EPD_Init();
Paint_SelectImage(ImageR);
EPD_ShowPicture(0,0,152,152,gImage_3,BLACK);
EPD_Display(ImageBW,ImageR);
Paint_Clear(WHITE);
EPD_Update();
EPD_DeepSleep();
delay_ms(1000);
EPD_Init();
while(1)
{
Paint_SelectImage(ImageR);
EPD_ShowString(8,0,"1.54 inch E-Paper",16,BLACK);
EPD_ShowString(4,20,"Resolution:152x152",16,BLACK);
EPD_ShowString(16,40,"Test-2023/10/16",16,BLACK);
EPD_DrawCircle(60,115,10,BLACK,1);
EPD_DrawRectangle(12,105,32,125,BLACK,0);
Paint_SelectImage(ImageBW);
EPD_ShowWatch(12,60,num,4,2,48,BLACK);
EPD_DrawRectangle(120,105,140,125,BLACK,1);
EPD_DrawCircle(90,115,10,BLACK,0);
EPD_ShowChinese(20,136,"郑州中景园电子",16,BLACK);
EPD_DrawRectangle(0,0,151,151,BLACK,0);
num+=0.01;
EPD_Display(ImageBW,ImageR);
EPD_Update();
delay_ms(500);
dat++;
if(dat==5)
{
EPD_Init();
while(1)
{
EPD_Display_Clear();
EPD_Update();
EPD_DeepSleep();
}
}
}
}
上述代码就是进行了一些功能测试,我们从前往后仔细观察。 EPD_GPIOInit()
的作用是把输出端口设为推挽输出,频率为 50MHz。Paint_NewImage()
是在单片机的 RAM 中初始化画布对象,不发送指令。第一次发送指令是 EPD_Init()
函数,实现如下:
void EPD_Init(void)
{
EPD_HW_RESET();
EPD_READBUSY();
EPD_WR_REG(0x12); //SWRESET
EPD_READBUSY();
}
这与文档中的描述一致(发送 0x12
进行软复位)。看一看 EPD_HW_RESET, EPD_READBUSY, EPD_WR_REG
这三个函数:
// 拉低 RES#,等待 10ms,拉高 RES#,等待 10ms,等待 BUSY
void EPD_HW_RESET(void)
{
delay_ms(100);
EPD_RES_Clr();
delay_ms(10);
EPD_RES_Set();
delay_ms(10);
EPD_READBUSY();
}
// 轮询 BUSY
void EPD_READBUSY(void)
{
while(1)
{
if(EPD_ReadBusy==0)
{
break;
}
}
}
// 拉低 DC,发送 reg,拉高 DC
void EPD_WR_REG(u8 reg)
{
EPD_DC_Clr();
EPD_WR_Bus(reg);
EPD_DC_Set();
}
// 以大端序输出
void EPD_WR_Bus(u8 dat)
{
u8 i;
EPD_CS_Clr();
for(i=0;i<8;i++)
{
EPD_SCL_Clr();
if(dat&0x80)
{
EPD_SDA_Set();
}
else
{
EPD_SDA_Clr();
}
EPD_SCL_Set();
dat<<=1;
}
EPD_CS_Set();
}
可见,厂家例程是用软件实现的 SPI 协议。在软复位之后,例程直接调用 EPD_Display()
开始向墨水屏写入数据:
void EPD_Display(const u8 *imageBW,const u8 *imageR)
{
u16 i,j,Width,Height;
Width=(EPD_W%8==0)?(EPD_W/8):(EPD_W/8+1);
Height=EPD_H;
EPD_WR_REG(0x24);
for (j=0;j<Height;j++)
{
for (i=0;i<Width;i++)
{
EPD_WR_DATA8(imageBW[i+j*Width]);
}
}
EPD_WR_REG(0x26);
for (j=0;j<Height;j++)
{
for (i=0;i<Width;i++)
{
EPD_WR_DATA8(~imageR[i+j*Width]);
}
}
}
void EPD_WR_DATA8(u8 dat)
{
EPD_DC_Set();
EPD_WR_Bus(dat);
EPD_DC_Set();
}
上述代码通过 0x24
指令写入黑白画布、通过 0x26
指令写入红色画布。每次写入 1 字节数据时,都要把 DC 拉高、CS 拉低、向 SDA 发送 8bit、CS 拉高。
0x02 初步测试
现在我们使用 bit banging,仿照例程的逻辑,在墨水屏上印出一点图案。先给 BW 和 R 都输出 0xFF
:
#include "pico/stdlib.h"
#include <cstdio>
#include "hardware/spi.h"
#include "pico/rand.h"
const uint pin_cs = 17;
const uint pin_scl = 18;
const uint pin_sda = 19;
const uint pin_res = 20;
const uint pin_dc = 21;
const uint pin_busy = 22;
void init_gpio() {
gpio_init(pin_cs);
gpio_init(pin_scl);
gpio_init(pin_sda);
gpio_init(pin_res);
gpio_init(pin_dc);
gpio_init(pin_busy);
gpio_set_dir(pin_cs, GPIO_OUT);
gpio_set_dir(pin_scl, GPIO_OUT);
gpio_set_dir(pin_sda, GPIO_OUT);
gpio_set_dir(pin_res, GPIO_OUT);
gpio_set_dir(pin_dc, GPIO_OUT);
gpio_set_dir(pin_busy, GPIO_IN);
// 默认片选未选中
gpio_put(pin_cs, true);
// 默认 SCL 为低
gpio_put(pin_scl, false);
}
void wait_for_epaper_busy() {
while(gpio_get(pin_busy)) {
tight_loop_contents();
}
}
void hardware_reset() {
busy_wait_ms(100);
gpio_put(pin_res, false);
busy_wait_ms(10);
gpio_put(pin_res, true);
busy_wait_ms(10);
wait_for_epaper_busy();
}
#define put_and_delay(pin, value) {gpio_put(pin, value); busy_wait_us(10);}
void write_sda(uint8_t data) {
put_and_delay(pin_cs, false);
for(int i=0; i<8; i++) {
put_and_delay(pin_scl, false);
put_and_delay(pin_sda, !!(data & 0x80));
put_and_delay(pin_scl, true);
data <<= 1;
}
gpio_put(pin_cs, true);
}
void write_reg(uint8_t reg) {
gpio_put(pin_dc, false);
write_sda(reg);
gpio_put(pin_dc, true);
}
void software_reset() {
write_reg(0x12);
wait_for_epaper_busy();
}
int main() {
stdio_init_all();
puts("Hello, world!");
init_gpio();
puts("GPIO init ok");
hardware_reset();
software_reset();
puts("reset ok");
// 黑白
write_reg(0x24);
for(uint row=0; row<152; row++) {
for(uint col=0; col<152/8; col++) {
write_sda(0xff);
}
}
// 红
write_reg(0x26);
for(uint row=0; row<152; row++) {
for(uint col=0; col<152/8; col++) {
write_sda(0xff);
}
}
write_reg(0x20);
wait_for_epaper_busy();
while(true) {
tight_loop_contents();
}
}
在长达 22s 的闪烁之后,画面定格在了满屏红色。
![](https://www.ruanx.net/content/images/2024/06/image-65.png)
数据被正确发送:
![](https://www.ruanx.net/content/images/2024/06/image-66.png)
0x24
期间,DC 线拉低;然后拉高 DC,发送 2888 个字节的数据对于 BW 图层,1
表示白色;对于 R 图层,1
表示红色。我们画个棋盘格:
// 黑白
write_reg(0x24);
for(uint row=0; row<152; row++) {
for(uint col=0; col<152/8; col++) {
write_sda((col&1) ? 0xff : 0x00);
}
}
// 红
write_reg(0x26);
for(uint row=0; row<152; row++) {
for(uint col=0; col<152/8; col++) {
write_sda(((row >> 3)&1) ? 0xff : 0x00);
}
}
结果如下:
![](https://www.ruanx.net/content/images/2024/06/image-68.png)
![](https://www.ruanx.net/content/images/2024/06/image-67.png)
现在我们试着让墨水屏显示二维码。首先,用 python 生成 152 × 152 的位图:
import qrcode
import numpy as np
import matplotlib.pyplot as plt
from PIL import ImageOps
qr = qrcode.QRCode(
box_size=4,
border=1,
)
data = "https://www.ruanx.net/"
qr.add_data(data)
qr.make()
img = qr.make_image()
img = ImageOps.expand(img, border=22, fill='white')
img_array = np.array(img)
assert img_array.shape == (152, 152)
plt.imshow(img_array)
plt.show()
buf = np.packbits(img_array)
assert len(buf) == 2888
with open('my_image.h', 'w') as f:
f.write('const uint8_t my_image_r[] = {')
f.write(', '.join(map(str, list(buf))))
f.write('};')
C++ 代码:
// 黑白
write_reg(0x24);
for(uint i=0; i<2888; i++) {
write_sda(0xff);
}
// 红
write_reg(0x26);
for(uint i=0; i<2888; i++) {
write_sda(my_image_r[i]);
}
write_reg(0x20);
wait_for_epaper_busy();
效果:
![](https://www.ruanx.net/content/images/2024/06/image-69.png)
0x03 采用 SPI 外设
SPI 外设可以帮助我们控制 CS#、SCL、SDA 三条线;我们自己控制 DC#,就能完成通讯。代码如下:
void init_gpio() {
gpio_set_function(pin_cs, GPIO_FUNC_SPI);
gpio_set_function(pin_scl, GPIO_FUNC_SPI);
gpio_set_function(pin_sda, GPIO_FUNC_SPI);
gpio_init(pin_res);
gpio_init(pin_dc);
gpio_init(pin_busy);
gpio_set_dir(pin_res, GPIO_OUT);
gpio_set_dir(pin_dc, GPIO_OUT);
gpio_set_dir(pin_busy, GPIO_IN);
spi_init(spi0, 1000 * 1000); // 1MHz
}
void write_sda(uint8_t data) {
spi_write_blocking(spi0, &data, 1);
}
这里采用了 1MHz 的 SPI 时钟频率,理论上可以提升到 50MHz;不过,考虑到墨水屏刷新需要 20s,而现在通讯只需要 100ms,剩余的时间都在等待 BUSY 信号,因此继续提升 SPI 通讯速率意义不大。想获得更高的刷新率,需要更好的屏幕。
0x04 使用 DMA 搬运数据
在现在的代码中,要发送 2888 个字节,则 cpu 需要连续调用 2888 次 spi_write_blocking()
。这个时间可以利用 DMA 节省下来:cpu 只负责拉低 DC#、发送寄存器地址、拉高 DC#,然后由 DMA 向 SPI 外设写入 2888 个字节。DMA 的发送速率则由 SPI 的 DREQ 控制。完整代码如下:
#include "pico/stdlib.h"
#include <cstdio>
#include "hardware/spi.h"
#include "hardware/dma.h"
#include "pico/rand.h"
#include <cstring>
#include "my_image.h"
const uint pin_cs = 17;
const uint pin_scl = 18;
const uint pin_sda = 19;
const uint pin_res = 20;
const uint pin_dc = 21;
const uint pin_busy = 22;
void init_gpio() {
gpio_set_function(pin_cs, GPIO_FUNC_SPI);
gpio_set_function(pin_scl, GPIO_FUNC_SPI);
gpio_set_function(pin_sda, GPIO_FUNC_SPI);
gpio_init(pin_res);
gpio_init(pin_dc);
gpio_init(pin_busy);
gpio_set_dir(pin_res, GPIO_OUT);
gpio_set_dir(pin_dc, GPIO_OUT);
gpio_set_dir(pin_busy, GPIO_IN);
spi_init(spi0, 1000 * 1000); // 1MHz
}
void wait_for_epaper_busy() {
while(gpio_get(pin_busy)) {
tight_loop_contents();
}
}
void hardware_reset() {
busy_wait_ms(100);
gpio_put(pin_res, false);
busy_wait_ms(10);
gpio_put(pin_res, true);
busy_wait_ms(10);
wait_for_epaper_busy();
}
void write_sda(uint8_t data) {
spi_write_blocking(spi0, &data, 1);
}
void write_reg(uint8_t reg) {
gpio_put(pin_dc, false);
write_sda(reg);
gpio_put(pin_dc, true);
}
void software_reset() {
write_reg(0x12);
wait_for_epaper_busy();
}
static uint8_t buf[2888];
void dma_send() {
int chan = dma_claim_unused_channel(true);
dma_channel_config c = dma_channel_get_default_config(chan);
channel_config_set_dreq(&c, DREQ_SPI0_TX);
channel_config_set_transfer_data_size(&c, DMA_SIZE_8);
dma_channel_configure(
chan,
&c,
&spi0_hw->dr,
buf,
count_of(buf),
true
);
printf("DMA chan %d go\n", chan);
// 注意这个函数在 DMA 写完 fifo 之后就会返回,但数据发送完成还需要一定的时间
dma_channel_wait_for_finish_blocking(chan);
sleep_ms(100);
}
int main() {
stdio_init_all();
puts("Hello, world!");
init_gpio();
puts("GPIO init ok");
hardware_reset();
software_reset();
puts("reset ok");
// 黑白
write_reg(0x24);
memset(buf, 0xff, sizeof(buf));
dma_send();
// 红
write_reg(0x26);
memcpy(buf, my_image_r, sizeof(buf));
dma_send();
write_reg(0x20);
wait_for_epaper_busy();
while(true) {
tight_loop_contents();
}
}
dma_channel_wait_for_finish_blocking()
来等待 DMA 传输。实际项目中 cpu 无需在此阻塞。0x05 使用 PIO 控制所有信号
上文所述的方法需要我们手动控制 DC# 信号。现在,我们来考虑能否使用 PIO 来控制所有信号:cpu 只往 PIO 的 tx fifo 里写入数据,无需关心具体的通讯过程。回顾通讯协议:
- 拉低 DC#,通过 SPI 发送寄存器地址,拉高 DC#
- (可选)连续发送 n 个字节
由此,我们可以设计 PIO 的输入方法:首先是 32bit 的寄存器地址,然后是数据长度 n,然后是 n 个字节的数据。如果不带数据(例如软复位指令 0x12
),则 n = 0。伪代码:
def spi_send(data):
CS = 0 # 片选
for bit in data:
SDA = bit
CLK = 1 # 制造上升沿
CLK = 0
CS = 1
while True:
reg <- tx_fifo
wait(BUSY)
DC = 0
spi_send(reg)
DC = 1
len <- tx_fifo
for _ in range(len):
data <- tx_fifo
spi_send(data)
然而,PIO 不能调用函数。我们要么把 spi_send
代码复制一遍(类似于 inline
),要么采用类似于 return address 的方式,决定返回到哪个位置。PIO 只能容纳 32 条指令,我们应当谨慎行事。
除了要节约指令数量以外,我们还要规划引脚使用。前一篇文章提到,PIO 只能配置三种引脚映射,对应 OUT
、SET
和 side-set。上文程序一共涉及 6 根引脚,其中 GP17、GP18、GP19 是 SPI 相关引脚,GP20 是 RES#,GP21 是 DC#,GP22 是 BUSY。我们来分别讨论这些引脚:
- BUSY 引脚(GP22)无需映射,因为
WAIT
指令可以直接等待某个特定的 GPIO - SDA 引脚(GP19)应当分配给
OUT
,从而我们可以使用out pins, 1
来发送一个 bit - SCL(GP18)仅在 CS#(GP17)拉低期间变动,我们可以把 GP17~18 分配给 side-set。这样,想要拉高 SCL,则执行
nop side 0b10
;想要拉低,则执行nop side 0b00
。 - RES#(GP20)在程序启动之后只使用一次,应当由 cpu 来控制。剩下 DC#(GP21),它应当映射到
SET
。
我们选择使用 Y 寄存器表示该返回到哪个地址。PIO 代码如下,共计 23 条指令:
; 引脚映射:
; OUT: GP19(SDA)
; SET: GP21(DC#)
; side-set: GP17(CS#), GP18(SCL)
.program ssd1680
.side_set 2
; 初始化
set pins, 1 side 0b01 ; 拉高 DC#,拉高 CS#,拉低 SCL
; 主循环
main:
pull side 0b01 ; 从 fifo 获取寄存器地址
wait 0 gpio 22 side 0b01 ; 等待 BUSY 信号
; 发送寄存器地址
set pins, 0 side 0b01 ; 拉低 DC#
set y, 1 side 0b01 ; y == 1 表示返回到 ret_reg
jmp spi_send side 0b01 ; 发送寄存器地址
ret_reg:
set pins, 1 side 0b01 ; 拉高 DC#
; 接下来发送数据
pull side 0b01 ; 拉取 num
out x, 32 side 0b01 ; x 是待发送字节数
ret_data:
jmp x-- do_send side 0b01 ; 若 x > 0,发送数据
jmp main side 0b01 ; 若 x == 0,跳回主循环
do_send:
pull side 0b01 ; 从 fifo 获取数据
set y, 0 side 0b01 ; y == 0 表示返回到 ret_data
jmp spi_send side 0b01 ; 发送
; 以下是 SPI
spi_send:
nop side 0b00 ; 拉低 CS#,开始传输这个字节
out null, 24 side 0b00 ; 丢弃 MSB 24 bit
send_bit:
out pins, 1 side 0b00 ; 输出 SDA
nop side 0b10 ; 拉高 SCL,制造上升沿
nop side 0b00 ; 拉低 SCL
jmp !osre send_bit side 0b00
nop side 0b01 ; 拉高 CS#,结束传输
jmp y-- ret_reg side 0b01 ; 返回到 ret_reg
jmp ret_data side 0b01 ; 返回到 ret_data
C++ 代码:
#include "pico/stdlib.h"
#include <cstdio>
#include <initializer_list>
#include <cstring>
#include "ssd1680.pio.h"
#include "hardware/pio.h"
#include "pico/rand.h"
#include "my_image.h"
const uint pin_cs = 17;
const uint pin_scl = 18;
const uint pin_sda = 19;
const uint pin_res = 20;
const uint pin_dc = 21;
const uint pin_busy = 22;
void init_gpio() {
gpio_init(pin_res);
// 默认 RES 拉高
gpio_put(pin_res, true);
gpio_set_dir(pin_res, GPIO_OUT);
}
void hardware_reset() {
busy_wait_ms(100);
gpio_put(pin_res, false);
busy_wait_ms(20);
gpio_put(pin_res, true);
busy_wait_ms(10);
}
void ssd1680_pio_program_init(PIO pio, uint sm, uint offset) {
// 设置 GPIO
for(auto pin: {pin_scl, pin_sda, pin_cs, pin_dc}) {
pio_gpio_init(pio, pin);
pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, true);
}
pio_sm_config c = ssd1680_program_get_default_config(offset);
// GP19(SDA) 分配给 OUT 指令
sm_config_set_out_pins(&c, 19, 1);
// GP17(CS#) GP18(SCL) 分配给 side-set 指令
sm_config_set_sideset_pins(&c, 17);
// GP21(DC#) 分配给 SET 指令
sm_config_set_set_pins(&c, 21, 1);
// 设为向左移动
sm_config_set_out_shift(&c, false, false, 32);
// 分频到 1MHz
sm_config_set_clkdiv_int_frac(&c, 125, 0);
pio_sm_init(pio, sm, offset, &c);
}
void init_epaper() {
hardware_reset();
puts("hw reset ok");
busy_wait_ms(100);
pio_sm_put_blocking(pio0, 0, 0x12);
pio_sm_put_blocking(pio0, 0, 0x00);
}
static uint8_t buf[2888];
void send_buf(uint reg) {
pio_sm_put_blocking(pio0, 0, reg);
pio_sm_put_blocking(pio0, 0, 2888);
for(uint i=0; i<2888; i++) {
pio_sm_put_blocking(pio0, 0, buf[i]);
}
}
void refresh_epaper() {
pio_sm_put_blocking(pio0, 0, 0x20);
pio_sm_put_blocking(pio0, 0, 0);
}
int main() {
stdio_init_all();
puts("Hello, world!");
init_gpio();
puts("GPIO init ok");
auto offset = pio_add_program(pio0, &ssd1680_program);
printf("Load PIO program at offset %d\n", offset);
ssd1680_pio_program_init(pio0, 0, offset);
pio_sm_set_enabled(pio0, 0, true);
init_epaper();
memset(buf, 0xff, sizeof(buf));
send_buf(0x24);
memcpy(buf, my_image_r, sizeof(buf));
send_buf(0x26);
refresh_epaper();
while(true) {
tight_loop_contents();
}
}
成功驱动了墨水屏:
![](https://www.ruanx.net/content/images/2024/06/image-70.png)
WAIT
指令等待 BUSY 信号,于是一旦 BUSY 信号变成 0,数据就马上开始发送。如果不使用 PIO,则要么轮询 BUSY,要么使用中断,两种方案都不如 PIO 方便。