RP2040 学习笔记(五):点亮墨水屏

0x00 关于墨水屏

在本系列的前几篇文章中,我们已经学习了 RP2040 的各种功能,包括 I2C、SPI、定时器、DMA 等。现在,我们用这些技术,点亮一块墨水屏。笔者手中的墨水屏是中景园生产的,型号为 ZJY152152-0154GAB-R,分辨率 152×152 像素,黑白红三色,驱动 IC 为 SSD1680,通讯采用 SPI 协议。外观如下:

墨水屏本身有 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 协议很不规范,常规的 SPI 外设可能不符合其通讯要求。我们尽可能使用 SPI 外设,迫不得已的情况下考虑 bit banging 或 PIO。

文档给出了一些时序要求。SPI 时钟最大频率是 20MHz,我们编程时可以先使用较慢的频率,以便调试。

文档中给出的启动流程是:

  1. 通电并等待 10ms;
  2. 发送 0x12 指令,进行软件复位,等待 10ms;
  3. 发送 0x01 指令,设置栅极驱动器输出;
    发送 0x11, 0x44, 0x45 指令,设置显示屏 RAM 大小;
    发送 0x3C 指令,设置屏幕边框;
  4. 发送 0x18 指令,设置温度传感器;
    发送 0x22, 0x20 指令,设置波形 LUT;
    等待 BUSY 信号变成低电平;
  5. 发送 0x4E, 0x4F, 0x24, 0x26 指令,将图像写进 RAM;
    发送 0x0C 设置 softstart;
    发送 0x22, 0x20 驱动显示面板;
    等待 BUSY 信号变成低电平;
  6. 发送 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 的闪烁之后,画面定格在了满屏红色。

数据被正确发送:

▲ 发送寄存器地址 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);
        }
    }

结果如下:

现在我们试着让墨水屏显示二维码。首先,用 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();

效果:

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 只能配置三种引脚映射,对应 OUTSET 和 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();
    }
}

成功驱动了墨水屏:

💡
我们在 PIO 中使用 WAIT 指令等待 BUSY 信号,于是一旦 BUSY 信号变成 0,数据就马上开始发送。如果不使用 PIO,则要么轮询 BUSY,要么使用中断,两种方案都不如 PIO 方便。