4、显示驱动基础:LCD/OLED屏幕驱动、SPI/I2C协议、显示文字与图形

各位同学,欢迎来到第四章。这一章我们聊聊「显示」这件事。

自动售货机没有屏幕,就像人没有眼睛。用户选了什么商品、付了多少钱、找零多少,全得靠屏幕告诉他。所以,驱动屏幕是嵌入式UI的基本功。

我个人习惯把显示驱动分成三层:物理层(怎么连线的)、协议层(怎么传数据的)、应用层(怎么画东西的)。咱们一层一层往下拆。

4.1 屏幕选型:LCD还是OLED?

先说说屏幕本身。自动售货机里最常见的两种:LCD和OLED。

特性 LCD(如12864) OLED(如SSD1306)
功耗 较高(需要背光) 低(自发光)
对比度 一般 高(纯黑)
视角 有限 广视角
成本 稍高
寿命 有机材料会衰减

我在项目中遇到过一个问题:某款自动售货机放在户外,夏天暴晒。LCD屏幕的背光老化得特别快,半年就发黄了。后来换成OLED,虽然贵了几块钱,但显示效果一直很稳定。嗯,这里要注意:户外场景优先考虑OLED,室内场景LCD性价比更高

4.2 通信协议:SPI vs I2C

屏幕和单片机怎么说话?主流就两种:SPI和I2C。说白了,就是两种「握手」的方式。

4.2.1 SPI协议

SPI,全称Serial Peripheral Interface。它用四根线:

  • SCLK:时钟线,由主机控制节奏
  • MOSI:主机输出,从机输入
  • MISO:主机输入,从机输出(很多屏幕只用一根数据线,所以这根可以省)
  • CS:片选线,选哪个设备说话

SPI的特点就是「快」。你想想看,它全双工,时钟可以跑到几十MHz。刷新一帧128x64的图片,SPI只需要几毫秒。I2C可能要几十毫秒。

核心要点:SPI适合需要快速刷新的场景,比如动画、视频。I2C适合数据量小、引脚少的场景。

4.2.2 I2C协议

I2C只有两根线:SDA(数据线)和SCL(时钟线)。它靠地址来区分设备。一个总线上可以挂多个设备,每个设备有唯一的地址。

我曾经踩过一个坑:I2C总线上挂了屏幕和温度传感器,结果屏幕显示乱码。查了半天,发现是温度传感器的地址和屏幕冲突了。后来换了地址才解决。所以I2C设计时一定要检查地址冲突

4.3 驱动一个OLED屏幕(SSD1306)

咱们拿最常见的SSD1306 OLED屏幕举例。它支持I2C和SPI两种接口。我个人习惯用I2C,因为省引脚,接线简单。

4.3.1 硬件连接

以I2C为例,接线如下:

单片机 -> OLED
VCC   -> 3.3V
GND   -> GND
SCL   -> PB6 (I2C1_SCL)
SDA   -> PB7 (I2C1_SDA)

小技巧:SSD1306的I2C地址通常是0x3C或0x3D。如果屏幕没反应,试试换一个地址。我遇到过一批屏幕地址是0x3D,手册上写的是0x3C,差点被坑。

4.3.2 初始化代码

驱动屏幕的第一步是初始化。说白了,就是告诉屏幕:「嘿,我要开始用了,你准备好。」

# MicroPython示例
from machine import Pin, I2C
import ssd1306

# 初始化I2C
i2c = I2C(1, scl=Pin(6), sda=Pin(7), freq=400000)

# 扫描I2C设备,确认地址
devices = i2c.scan()
print("I2C devices:", [hex(addr) for addr in devices])

# 初始化OLED,128x64分辨率
oled = ssd1306.SSD1306_I2C(128, 64, i2c)

# 清屏
oled.fill(0)
oled.show()

这段代码跑通后,屏幕应该会亮起来,但什么也不显示。别急,接下来我们画点东西上去。

4.4 显示文字

自动售货机最常用的就是显示文字。比如「请投币」、「余额:5.00元」。

SSD1306内置了ASCII字库,可以直接用。但中文不行,需要自己准备字库。

4.4.1 显示英文字符

# 显示文字
oled.text("Hello, Vending!", 0, 0)
oled.text("Price: 3.50", 0, 16)
oled.text("Insert Coin", 0, 32)
oled.show()

这里要注意:text()函数的参数是(x, y)坐标。SSD1306的坐标原点在左上角,x向右增加,y向下增加。每个字符占8x16像素(默认字体)。

4.4.2 显示中文(取模方式)

中文显示需要自己准备字模。我常用的方法是:用PCtoLCD2002软件取模,生成16x16的点阵数据。

# 显示一个16x16的中文字符
# 假设"投"字的字模数据
font_data = [
    0x00, 0x00, 0x7F, 0xFC, 0x08, 0x20, 0x08, 0x20,
    0x08, 0x20, 0x08, 0x20, 0x08, 0x20, 0x08, 0x20,
    0x08, 0x20, 0x08, 0x20, 0x08, 0x20, 0x08, 0x20,
    0x08, 0x20, 0x7F, 0xFC, 0x00, 0x00, 0x00, 0x00
]

# 将字模绘制到屏幕上
for y in range(16):
    for x in range(16):
        if font_data[y*2 + x//8] & (0x80 >> (x%8)):
            oled.pixel(x, y, 1)
oled.show()

注意:取模方式有很多种(横向、纵向、字节顺序等)。一定要和你的代码匹配。我曾经因为取模方向搞反了,显示出来的汉字全是镜像的,调试了一下午才发现。

4.5 显示图形

除了文字,自动售货机还需要显示图形。比如商品图标、二维码、进度条。

4.5.1 画基本图形

# 画一个矩形边框
oled.rect(10, 10, 50, 30, 1)  # x, y, width, height, color

# 画一个填充矩形
oled.fill_rect(70, 10, 50, 30, 1)

# 画一条线
oled.line(10, 50, 120, 50, 1)

# 画一个圆(SSD1306库没有直接支持,需要自己实现)
# 可以用Bresenham算法
def draw_circle(oled, x0, y0, r, color):
    x = 0
    y = r
    d = 3 - 2 * r
    while x <= y:
        oled.pixel(x0 + x, y0 + y, color)
        oled.pixel(x0 - x, y0 + y, color)
        oled.pixel(x0 + x, y0 - y, color)
        oled.pixel(x0 - x, y0 - y, color)
        oled.pixel(x0 + y, y0 + x, color)
        oled.pixel(x0 - y, y0 + x, color)
        oled.pixel(x0 + y, y0 - x, color)
        oled.pixel(x0 - y, y0 - x, color)
        if d < 0:
            d = d + 4 * x + 6
        else:
            d = d + 4 * (x - y) + 10
            y -= 1
        x += 1

draw_circle(oled, 64, 32, 20, 1)
oled.show()

4.5.2 显示图片(BMP转数组)

更复杂的图形,比如商品图标,可以用图片转数组的方式。我常用的是将128x64的BMP图片转换成字节数组。

# 假设有一个128x64的图片数据
image_data = [
    0xFF, 0x81, 0x81, 0x81, ...  # 共1024字节
]

# 将图片数据写入屏幕
for page in range(8):  # SSD1306分8页,每页8行
    for col in range(128):
        oled.buffer[page * 128 + col] = image_data[page * 128 + col]
oled.show()

提示:SSD1306的显存是1024字节(128x64/8)。直接操作buffer是最快的方式。我一般把常用图标预先转成数组,放在代码里,需要时直接刷进去。

4.6 避坑指南

最后,分享几个我踩过的坑:

  • 电源问题:OLED屏幕对电源纹波敏感。如果屏幕闪烁,先检查电源,别急着改代码。
  • I2C上拉电阻:I2C总线需要上拉电阻(4.7kΩ左右)。有些模块内置了,有些没有。没上拉的话,通信会失败。
  • 刷新率:不要频繁调用show()。每次调用都会刷新整个屏幕,太频繁会导致闪烁。我一般控制在30-60Hz。
  • 字库存储:中文字库很大(一个16x16的字需要32字节)。如果显示很多汉字,建议用外部Flash存储字库,别全塞进单片机Flash里。

好了,这一章就到这里。下一章我们聊聊「触摸输入与按键交互」。到时候见。