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 提供了方便。
软件方面,笔者在网上到处冲浪,发现 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:
将 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()
中设置的 polarity
和 phase
参数会被 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))
调试之后,发现 polarity
和 phase
必须设为特定值:
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 进行实验,先点亮黑白红三色屏:
效果如下:
无论如何刷新,最左侧都残留有一些杂乱的像素。这应该是软件 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()
效果:
后来,查了一些资料,发现不同 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 外围电路:
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 标准调试端口)。