MCU 屏幕驱动杂谈

0x00 序言

这个月里,笔者跟许多屏幕打了交道,回想起来,踩了不少的坑。本文的主要任务就是记录这些坑点,供未来快速查阅;另一方面,本文所用到的器件,在 DIY 爱好者中相当流行,笔者的经验也许能帮一些读者节省时间。

笔者使用过 LCD1602、0.96 寸 OLED、SPI 协议 LCD 彩屏、墨水屏。其中,LCD1602 在八位机时代就已经能非常简单地驱动(无论是通过并口还是 I2C 协议),OLED 笔者用得较少,本文不再赘述。笔者本月踩的坑集中在其他两种屏幕,包括 ST7789 驱动的 LCD 彩屏,以及 SSD1675、SSD1680 驱动的双色、三色墨水屏。

0x01 调试工具

拿到一块屏幕,笔者首先得用面包板测试屏幕功能,看看能否跑起来,再考虑把裸屏集成到 PCB 上。然而,一块屏幕常常有 7 ~ 8 个接口——例如,对于 ST7789 模块,有 VCC、GND、SCL、SDA、RST、DC、CS、BLK;墨水屏模块则无 BLK 而有 BUSY。另外,算上逻辑分析仪,连接一块屏幕需要大量杜邦线。考虑到测试过程中可能会经常更换开发板(例如,用 RP2040 开发板的某个库驱动失败,改用 STM32 上的库试试)、更换屏幕(例如,怀疑手头的 8pin 模块焊接有问题,换成一个 7pin 模块,而 7pin 模块少了 CS 接口),于是线缆的插拔成为了复杂而容易出错的环节。笔者为此制作了一块调试接线板:

简而言之,接线板上有 16 条信号通道,分为 IN 区域、OUT 区域、logic 区域。最底部的 logic 区域是留给逻辑分析仪使用的,笔者的逻辑分析仪是 DSLogic U2Pro16,对于每条信号,都有信号线和 GND 线,均为母口。在接线板上焊接两排排针,逻辑分析仪便可以直接连接。这个设计使得我们无需再多次插拔逻辑分析仪的线缆,如果要改变测量对象,只需改动 IN 区域和 OUT 区域的接线。

IN 区域和 OUT 区域可以随意连接,每条信号有 5 个接口,足以应对各种场景。例如,可以像上图一样,将模块直接插到接线板的排母上,用杜邦线把单片机和接线板连接起来。这为快速更换屏幕或 MCU 提供了方便。

💡
一个简单的计算:调试 8 pin 模块时,如果采用原先的面包板方案,则我们需要 8 条杜邦线连接单片机与屏幕、2 × 6 = 12 条杜邦线连接逻辑分析仪,共计 20 条线。而现在我们只需要 8 条线连接单片机与转接板(6 条数据线,2 条电源线)。

软件方面,笔者在网上到处冲浪,发现 CircuitPython 是最适合用于调试屏幕的开发环境。它是 MicroPython 的分支(但与 MicroPython 已经不兼容),由 adafruit 维护。adafruit 多年耕耘的开源工作自然不必多说,DIY 玩家常用的器件,几乎都有 adafruit 支持,笔者在使用 arduino 时就经常利用 adafruit 开发的驱动。本文也将利用 adafruit 的 ST7789 库SSD1680 库SSD1675 库

值得一提的是,adafruit 提供了一个 CircuitPython 驱动大合集,支持了市面上常见的显示屏驱动 IC。可以从这里看到支持列表:

因此,想要调试屏幕,最理想的方案就是找一块 CircuitPython 开发板,安装所有驱动;接下来,我们便可以在 Thonny 中用几行代码点亮屏幕了。不过,我们遇到了一点小问题:驱动大合集的尺寸达到 5.6 MB,塞不进 pico 开发板。

本站在 RP2040 PCB 设计的文章中分析了源地工作室的 YD-RP2040 开发板,这块开发板是受到 CircuitPython 官方支持的。笔者手头的是 4MB flash 版本,不过没关系,只需把板载的 W25Q32JV 拆掉,改焊一个 W25Q128JV 上去。烧录 uf2 固件之后,RP2040 自动复位,CircuitPython 启动,弹出虚拟 u 盘,容量 14.9 MB:

💡
CircuitPython 会将自己 flash 上的文件系统通过虚拟 u 盘直接暴露出来,而 MicroPython 只能依靠 Thonny 等工具读写文件系统。这是它们之间的一大区别。

adafruit-circuitpython-bundle-py-20240722/lib 复制到 CircuitPython 的 /lib

数分钟的等待之后,我们搭建好了 CircuitPython 开发板。它只需通过 usb 线与电脑连接(无需另接串口),自带我们想要的全部驱动库,堪称屏幕调试神器。下文的工作都将使用这块开发板完成。

0x02 彩色 LCD:ST7789

市场上随处可见 ST7789 驱动的 TFT LCD,尺寸也是五花八门:正方形、长方形、圆形、圆角矩形。不过,屏幕的 buffer 始终是一个矩形,LCD 显示的像素是它的子集。

我们通过 SPI 协议与 ST7789 交互。CircuitPython 文档中使用了 board.SPI 对象,然而我们的环境中没有这个对象,需要改用 busio.SPI 来调用 SPI 外设。我们本次使用 1.69 寸 280 × 240 圆角矩形屏幕模块(8pin),最终代码如下:

import board
import displayio
import busio
import digitalio
from fourwire import FourWire
from adafruit_st7789 import ST7789

displayio.release_displays()

# 配置 SPI 外设
spi = busio.SPI(clock=board.GP18, MOSI=board.GP19)
while not spi.try_lock():
    pass
spi.configure(baudrate=24000000)
spi.unlock()

tft_cs = board.GP26
tft_dc = board.GP21
tft_res = board.GP20

# 打开背光
blk = digitalio.DigitalInOut(board.GP27)
blk.direction = digitalio.Direction.OUTPUT
blk.value = True

# 配置 fourwire
display_bus = displayio.FourWire(
    spi, command=tft_dc, chip_select=tft_cs, reset=tft_res
)

# 配置屏幕
display = ST7789(display_bus, width=280, height=240, rowstart=20, rotation=90)

# 以下是将屏幕刷成红色
splash = displayio.Group()
display.root_group = splash

color_bitmap = displayio.Bitmap(280, 240, 1)
color_palette = displayio.Palette(1)
color_palette[0] = 0xFF0000

bg_sprite = displayio.TileGrid(color_bitmap,
                               pixel_shader=color_palette,
                               x=0, y=0)
splash.append(bg_sprite)

上述代码初始化了 280 宽度、240 高度的显示屏,起始行为 20,旋转 90 度。接下来将屏幕刷成红色。通讯过程:

现在来驱动 7pin 的 1.47 寸长条形屏幕模块。这个模块少了 CS 引脚,屏幕尺寸为  172 × 320。改动如下:

# 配置 fourwire
display_bus = displayio.FourWire(
    spi, command=tft_dc, reset=tft_res, polarity=1, phase=1, 
)

# 配置屏幕
display = ST7789(display_bus, width=172, height=320, colstart=34)

对于此块 1.47 寸屏幕,代码中的 polarity=1, phase=1 是必须的。如果不设置这两个参数,则屏幕不产生任何反应。

💡
busio.SPI() 中设置的 polarityphase 参数会被 displayio.FourWire() 的参数覆盖。这是一个大坑,然而文档中没有强调这一点。

顺带一提,笔者是在调试 MicroPython 版驱动时发现这个特性的,被这个问题坑了数小时时间。当时的情况是,使用软件 SPI 时,可以驱动屏幕:

import machine
import random
import utime
import st7789
import fonts.bitmap.vga2_bold_16x32 as font

spi = machine.SoftSPI(baudrate=1000000, polarity=1, sck=machine.Pin(14), mosi=machine.Pin(15), miso=machine.Pin(12))
tft = st7789.ST7789(
    spi,
    172,
    320,
    reset=machine.Pin(16, machine.Pin.OUT),
    dc=machine.Pin(17, machine.Pin.OUT),
    backlight=machine.Pin(18, machine.Pin.OUT),
    cs=machine.Pin(21, machine.Pin.OUT)
)

def center(text):
    length = 1 if isinstance(text, int) else len(text)
    tft.text(
        font,
        text,
        tft.width() // 2 - length // 2 * font.WIDTH,
        tft.height() // 2 - font.HEIGHT //2,
        st7789.WHITE,
        st7789.RED)

def main():
    tft.init()
    tft.fill(st7789.RED)
    tft.off()
    tft.on()
    center(b'\xAEHello\xAF')

然而,改为硬件 SPI 则无法驱动:

spi = machine.SPI(1, baudrate=1000000, polarity=1, sck=machine.Pin(14), mosi=machine.Pin(15), miso=machine.Pin(12))

调试之后,发现 polarityphase 必须设为特定值:

spi = machine.SPI(1, baudrate=1000000, polarity=1, phase=1, sck=machine.Pin(14), mosi=machine.Pin(15), miso=machine.Pin(12))

如此便可以驱动了。

0x03 墨水屏:SSD1680

在 CircuitPython 中,操作墨水屏与操作 TFT LCD 非常相似——都是用 fourwire 库建立通讯信道,用 displayio 库控制图像;而各个 IC 的驱动类则是非常薄的一层适配器(几乎只修改 _INIT_SEQUENCE)。这种抽象非常精彩,一方面节省了库代码量,一方面也为不同屏幕的用户提供了一致的 API。

一个月前的文章中,笔者使用 RP2040 PIO 点亮了中景园的 SSD1680 墨水屏。现在用 CircuitPython 进行实验,先点亮黑白红三色屏:

import time
import board
import displayio
import fourwire
import busio
import adafruit_ssd1680

displayio.release_displays()

spi = busio.SPI(clock=board.GP18, MOSI=board.GP19)
epd_reset = board.GP20
epd_dc = board.GP21
epd_cs = board.GP26
epd_busy = board.GP27

display_bus = fourwire.FourWire(
    spi, command=epd_dc, chip_select=epd_cs, reset=epd_reset, baudrate=1000000
)
time.sleep(1)

display = adafruit_ssd1680.SSD1680(
    display_bus,
    width=152,
    height=152,
    busy_pin=epd_busy,
    highlight_color=0xFF0000,
    rotation=0,
)

# 创建棋盘格图案
bitmap = displayio.Bitmap(152, 152, 3)  # 3 colors
palette = displayio.Palette(3)
palette[0] = 0xFFFFFF  # White
palette[1] = 0x000000  # Black
palette[2] = 0xFF0000  # Red

# 绘制棋盘格
square_size = 19  # 8x8 棋盘,每个格子19x19像素
for y in range(8):
    for x in range(8):
        color = (x + y) % 3  # 交替使用三种颜色
        for dy in range(square_size):
            for dx in range(square_size):
                bitmap[x*square_size + dx, y*square_size + dy] = color

# 创建TileGrid并添加到Group
tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette)
group = displayio.Group()
group.append(tile_grid)

# 显示图案
display.root_group = group
display.refresh()
▲ 代码是 claude-3.5-sonnet 代劳的

效果如下:

无论如何刷新,最左侧都残留有一些杂乱的像素。这应该是软件 bug。用相似的代码驱动中景园的 296 × 128 黑白双色墨水屏,画出黑白棋盘格,仍然有残余像素。匪夷所思地,如果我们改用 SSD1675 驱动,则这块中景园黑白 SSD1680 墨水屏可以正确输出。代码如下:

import time
import board
import displayio
import fourwire
import busio
import adafruit_ssd1675

displayio.release_displays()

spi = busio.SPI(clock=board.GP18, MOSI=board.GP19)
epd_reset = board.GP20
epd_dc = board.GP21
epd_cs = board.GP26
epd_busy = board.GP27

display_bus = fourwire.FourWire(
    spi, command=epd_dc, chip_select=epd_cs, reset=epd_reset, baudrate=1000000
)
time.sleep(1)

display = adafruit_ssd1675.SSD1675(
    display_bus,
    width=296,
    height=128,
    rotation=270,
    busy_pin=epd_busy
)

# 创建一个显示组
group = displayio.Group()

# 创建一个位图用于绘制棋盘格
bitmap = displayio.Bitmap(296, 128, 2)
palette = displayio.Palette(2)
palette[0] = 0xFFFFFF  # 白色
palette[1] = 0x000000  # 黑色

# 绘制棋盘格
tile_size = 16  # 每个格子的大小
for y in range(0, 128, tile_size):
    for x in range(0, 296, tile_size):
        color = (x // tile_size + y // tile_size) % 2
        for dy in range(tile_size):
            for dx in range(tile_size):
                if x + dx < 296 and y + dy < 128:
                    bitmap[x + dx, y + dy] = color

# 创建一个TileGrid并添加到组中
tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette)
group.append(tile_grid)

# 设置显示的根组
display.root_group = group

# 刷新显示
display.refresh()

效果:

0x04 垃圾佬手记:老五 2.13 寸 SSD1675 墨水屏

笔者发扬垃圾佬精神,在淘宝店「老五数码之家电子 diy 元件」购买了一些 2.13 寸墨水屏。这些墨水屏曾经被用作电子价签,成色良好,价格 4.9 CNY,远低于墨水屏市场价。实物:

这个电子价签不容易拆。建议首先用剪线钳在侧面剪开两条缝隙,剥掉中间的一小节塑料,露出屏幕板和 PCB 板:

下一步,以剪线钳为杠杆,撬开外壳、pcb、屏幕(小心操作,不要损坏屏幕):

最终获得一块 24pin 墨水屏裸屏和一块 PCB。详细观察 PCB:

回收晶振和 CC2640 芯片,然后可以扔掉 PCB。顺带一提,这颗 CC2640 芯片是蓝牙 5.1 MCU,拥有 128 kB flash,Cortex-M3 核,来自德州仪器,市价约 7.5 CNY。算是垃圾佬的意外收获。

笔者将这块墨水屏接到中景园的模块上,发现 BUSY 信号有正常反馈,但是屏幕不刷新。一筹莫展之际,翻淘宝评论区,发现有买家说使用「YuToo DIY」所售之驱动板可以正常使用,依言行之,果然成功,但是要使用 SSD1680 而非 SSD1675 驱动。代码如下:

import time
import board
import displayio
import fourwire
import busio
import adafruit_ssd1680

displayio.release_displays()

spi = busio.SPI(clock=board.GP18, MOSI=board.GP19)
epd_reset = board.GP20
epd_dc = board.GP21
epd_cs = board.GP26
epd_busy = board.GP27

display_bus = fourwire.FourWire(
    spi, command=epd_dc, chip_select=epd_cs, reset=epd_reset, baudrate=1000000
)
time.sleep(1)

display = adafruit_ssd1680.SSD1680(
    display_bus,
    width=250,
    height=122,
    rotation=270,
    busy_pin=epd_busy,
    highlight_color=0xFF0000,
)

# 创建一个Group来包含所有图形元素
group = displayio.Group()

# 创建一个棋盘格图案
bitmap = displayio.Bitmap(250, 122, 3)  # 3 colors: black (0), white (1), red (2)
palette = displayio.Palette(3)
palette[0] = 0x000000  # Black
palette[1] = 0xFFFFFF  # White
palette[2] = 0xFF0000  # Red

# 填充棋盘格
square_size = 20  # 每个格子的大小
for y in range(122):
    for x in range(250):
        color = ((x // square_size) + (y // square_size)) % 3
        bitmap[x, y] = color

# 创建TileGrid并添加到Group
tile_grid = displayio.TileGrid(bitmap, pixel_shader=palette)
group.append(tile_grid)

# 显示Group
display.root_group = group

# 刷新显示
display.refresh()

效果:

💡
这块屏幕的刷新速度远快于中景园的 152×152 三色屏,笔者准备以后用这块屏做些新项目。

后来,查了一些资料,发现不同 24pin 墨水屏的驱动电路需要不同的电阻。参考微雪的设计

墨水屏外围电路设计方面,有一篇非常详细的帖子可以参考:https://oshwhub.com/lingdy2012/mo-shui-ping-_esp8266-qu-dong-ban-_0603_wos_v0-1

0x05 自行设计驱动板

我们显然不能过于依赖现成的驱动板,因为一些项目需要极致压缩 pcb 面积,此时很难使用 7~8pin 2.54mm 引脚间距的模块。笔者用 KiCad 画了 ST7789 和墨水屏的外围电路,均驱动成功。

ST7789 外围电路:

💡
一定要注意 KiCad 中的三极管符号。我们常用的 S8050 J3Y 三极管的顺序是 1 base、2 emitter、3 collector,所以选择 Device:Q_NPN_BEC 符号,分配 SOT-23 封装。笔者第一次打板时未注意这个问题,选择了 KiCad 自带符号库中的直插式 S8050 符号,再手动改为 SOT-23 封装。焊接时才发现直插式 S8050 的顺序是 EBC。

墨水屏外围电路:

图中 JP2、JP3 是焊接式的跳线,想要选择 4SPI 模式时,只需用锡短路 JP2 的两个焊盘,非常方便。footprint 如下:

笔者希望让 RP2040、STM32G031G8U6、STM32G030F6P6 这三个 MCU 都连接到 ST7789 LCD 和墨水屏,以便先用 RP2040 CircuitPython 测试屏幕好坏,再调试 STM32 上的屏幕驱动程序。原理图:

layout 方面,笔者构造了一个总线结构:SCLK、MOSI、RST、DC、CS、BLK/BUSY 这六条线是平行放置的,而 MCU 和屏幕的信号线则安排在 PCB 的另一面,垂直连接到总线。理论上讲,这个设计可以在双面板上支持任意多个 MCU 和屏幕。

最终实物如下:

各个 MCU 的供电由跳线帽控制。每个 MCU 都引出了调试接口,采用源地工作室开发板的线序(3V3, SWDIO, SWCLK, GND)。另外引出了 2×5pin 1.27mm 的 arm 标准调试接口(匹配 J-Link edu mini),以及 2×7pin 1.27mm 的 STDC14 接口(匹配 STLINK-V3SET,兼容 arm 标准调试端口)。

💡
FPC 拆焊过程中,似乎很容易损坏焊盘。以后的项目可能需要考虑改用插接式 FPC 的屏幕,以方便拆装。PCB 上只焊接 FPC 座子,比直接焊接 FPC 简单。