SPI 驱动 OLED 液晶屏幕

罗大富 BigRich大约 14 分钟ESP32Python

这一节我们学习如何使用 ESP32 开发板,通过 SPI 控制 OLED 液晶屏。

实验原理

1. SPI

SPI(Serial Peripheral Interface) 协议是由摩托罗拉公司提出的通讯协议,即串行外围设备接口,是一种同步、全双工、主从式接口,但并不是所有的 SPI 都是全双工。来自主机或从机的数据在时钟上升沿或下降沿同步。主机和从机可以同时传输数据。SPI 接口可以是 1 线、2 线 3 线式或 4 线式,这节课,我们用到的就是 3 线 SPI。

产生时钟信号的器件称为 主机。主机和从机之间传输的数据与主机产生的时钟同步。同 I2C 接口相比,SPI 器件支持更高的时钟频率。用户应查阅产品数据手册以了解 SPI 接口的时钟频率规格。

标准 4 线 SPI 芯片的管脚上只占用四根线。

  • MOSI: 主器件数据输出,从器件数据输入。
  • MISO:主器件数据输入,从器件数据输出。
  • SCK: 时钟信号,由主设备控制发出。
  • CS(NSS): 从机设备选择信号,由主设备控制。当 CS 为低电平则选中从器件。

3 线 SPI 没有 MISO,或者 MISOMOSI 共线。

SPI 接口只能有一个主机,但可以有一个或多个从机。下图显示了主机和从机之间的 SPI 连接。来自主机的片选信号用于选择从机。这通常是一个低电平有效信号,拉高时从机与 SPI 总线断开连接。当使用多个从机时,主机需要为每个从机提供单独的片选信号。MOSI 和 MISO 是数据线。MOSI 将数据从主机发送到从机,MISO将数据从从机发送到主机。

ESP32 集成了 4 个 SPI 外设。

  • 其中两个在内部用于访问 ESP32 所连接的闪存。两个控制器共享相同的 SPI 总线信号,并且有一个仲裁器来确定哪个可以访问该总线。
  • 另外两个是通用 SPI 控制器,分别称为 HSPI 和 VSPI。它们向用户开放,具有独立的总线信号,分别具有相同的名称。每条总线具有 3 条 CS 线,最多能控制 6 个 SPI 从设备。

I2C 与 SPI 区别

I2C 只需两根信号线,而标准 SPI 至少四根信号,如果有多个从设备,信号需要更多。一些 SPI 变种虽然只使用三根线—— SCK、CS 和双向的 MISO/MOSI,但 CS 线还是要和从设备一对一根。另外,如果 SPI 要实现多主设备结构,总线系统需额外的逻辑和线路。用 I2C 构建系统总线唯一的问题是有限的 7 位地址空间,但这个问题新标准已经解决 --- 使用 10 位地址。

如果应用中必须使用高速数据传输,那么 SPI 是必然的选择。因为 SPI 是全双工,IIC 的不是。SPI 没有定义速度限制,一般的实现通常能达到甚至超过 10Mbps。IIC 最高的速度也就快速+模式(1Mbps)和高速模式(3.4Mbps),后面的模式还需要额外的 I/O 缓冲区,还并不是总是容易实现的。SPI 适合数据流应用,而 IIC 更适合“字节设备”的多主设备应用。

SPI 有一个非常大的缺陷,主要是没有标准的协议,SPI 比较混乱,主要是没有标准的协议,只有moto的事实标准。所以衍生出多个版本,但没有本质的差异。

2. OLED 屏幕

OLED,即有机发光二极管(Organic Light Emitting Diode)。OLED 由于同时具备自发光,不需背光源、对比度高、厚度薄、视角广、反应速度快、可用于挠曲性面板、使用温度范围广、构造及制程较简单等优异之特性,被称为是第三代显示技术。

LCD 都需要背光,而 OLED 不需要,因为它是自发光的。这样同样的显示 OLED 效果要来得好一些。以目前的技术,OLED 的尺寸还难以大型化,但是分辨率确可以做到很高。

我们今天用到的屏幕是 0.96 寸的 SSD1306 芯片驱动的 OLED 屏幕。他的分辨率是 128*64,意思就是横向有 128 个像素点,纵向有 64 个

OLED 显示屏模块接口定义:

  1. GND:电源地。
  2. VCC:电源正(3.3~5V)。
  3. D0:OLED 的 D0 脚,在 SPI 通信中为时钟管脚。
  4. D1:OLED 的 D1 脚,在 SPI 通信中为数据管脚。
  5. RES:OLED 的 RES 脚,用来复位(低电平复位)。
  6. DC:数据和命令控制管脚。
  7. CS:片选管脚。

硬件电路设计

物料清单(BOM 表):

材料名称数量
0.96 寸 OLED 屏幕1
按键2
杜邦线(跳线)若干

软件程序设计

SoftSPI 与 SPI

SPI 和 SoftSPI 与 I2C 和 SoftI2C 的基本上都一样,SPI 指硬件自带的外设功能,SoftSPI 指使用硬件上的 I/O 口模拟 SPI 接口,以实现 SPI 功能。

特点:

  • 相比于 SPI 来说,SoftSPI 占用的 MCU 资源较多,速度相比于 SPI 来说比较慢
  • SPI 发送数据和传送数据,不需要MCU进行处理,是由硬件进行处理。
  • 使用 SoftSPI 可以在不同的处理器或者不同架构间进行代码的移植,代码通用性强。

构造函数:

  1. machine.SoftSPI(baudrate=500000, polarity=0, phase=0, bits=8, firstbit=MSB, sck=None, mosi=None, miso=None):构造一个新的软件 SPI 对象。必须给出额外的参数,通常至少是 sck、mosi 和 miso,这些用于初始化 I2C 总线。

    • baudrate:SPI 通讯速率,也就是 SCK 引脚上的频率,SPI 并没有规定最高速度,通讯速率完全是由通信双方的能力所决定。在实际使用情况中,需要根据通讯双方的数据手册和实际情况来调整通讯速率。
    • polarity:时钟极性(为 1 或者 0),若为 0 则总线空闲时 SCK 输出低电平,反之则输出高电平;
    • phase:时钟相位(为 1 或者 0),若为 0 则在第一个时钟边缘捕获数据,反之则在第二个时钟边缘捕获数据;
    • bits:每次传输的数据位数;
    • firstbit:先传输高位还是低位;
    • sck / mosi / miso:均为 SPI 使用的引脚,应为 Pin 对象。
  2. machine.SPI(id, ...): 在给定的 SPI 通道(id)上构造一个 SPI 对象。在没有附加参数的情况下,SPI 对象被创建但不初始化(它具有总线上次初始化的设置,如果有的话)。如果给出了额外的参数,则总线被初始化。

    • id:使用的 SPI 通道,可为 1 或者 2,通常用于选择硬件 HSPI、VSPI(HSPI、VSPI 是一样的,只不过是换个名字用于区分)等;
    • 其余参数与 SoftSPI 一致。
HSPI(id = 1)VSPI(id = 2)
SCK1418
MOSI1323
MISO1219

ssd1306.py 驱动文件上传到 ESP32 中的 libs 目录下

# MicroPython SSD1306 OLED driver, I2C and SPI interfaces Modified by Bigrich-Luo
 
import time
import framebuf
 
# register definitions
SET_CONTRAST        = const(0x81)
SET_ENTIRE_ON       = const(0xa4)
SET_NORM_INV        = const(0xa6)
SET_DISP            = const(0xae)
SET_MEM_ADDR        = const(0x20)
SET_COL_ADDR        = const(0x21)
SET_PAGE_ADDR       = const(0x22)
SET_DISP_START_LINE = const(0x40)
SET_SEG_REMAP       = const(0xa0)
SET_MUX_RATIO       = const(0xa8)
SET_COM_OUT_DIR     = const(0xc0)
SET_DISP_OFFSET     = const(0xd3)
SET_COM_PIN_CFG     = const(0xda)
SET_DISP_CLK_DIV    = const(0xd5)
SET_PRECHARGE       = const(0xd9)
SET_VCOM_DESEL      = const(0xdb)
SET_CHARGE_PUMP     = const(0x8d)
 
  
class SSD1306:
    def __init__(self, width, height, external_vcc):
        self.width = width
        self.height = height
        self.external_vcc = external_vcc
        self.pages = self.height // 8
        # Note the subclass must initialize self.framebuf to a framebuffer.
        # This is necessary because the underlying data buffer is different
        # between I2C and SPI implementations (I2C needs an extra byte).
        self.poweron()
        self.init_display()
 
    def init_display(self):
        for cmd in (
            SET_DISP | 0x00, # off
            # address setting
            SET_MEM_ADDR, 0x00, # horizontal
            # resolution and layout
            SET_DISP_START_LINE | 0x00,
            SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
            SET_MUX_RATIO, self.height - 1,
            SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
            SET_DISP_OFFSET, 0x00,
            SET_COM_PIN_CFG, 0x02 if self.height == 32 else 0x12,
            # timing and driving scheme
            SET_DISP_CLK_DIV, 0x80,
            SET_PRECHARGE, 0x22 if self.external_vcc else 0xf1,
            SET_VCOM_DESEL, 0x30, # 0.83*Vcc
            # display
            SET_CONTRAST, 0xff, # maximum
            SET_ENTIRE_ON, # output follows RAM contents
            SET_NORM_INV, # not inverted
            # charge pump
            SET_CHARGE_PUMP, 0x10 if self.external_vcc else 0x14,
            SET_DISP | 0x01): # on
            self.write_cmd(cmd)
        self.fill(0)
        self.show()
 
    def poweroff(self):
        self.write_cmd(SET_DISP | 0x00)
 
    def contrast(self, contrast):
        self.write_cmd(SET_CONTRAST)
        self.write_cmd(contrast)
 
    def invert(self, invert):
        self.write_cmd(SET_NORM_INV | (invert & 1))
 
    def show(self):
        x0 = 0
        x1 = self.width - 1
        if self.width == 64:
            # displays with width of 64 pixels are shifted by 32
            x0 += 32
            x1 += 32
        self.write_cmd(SET_COL_ADDR)
        self.write_cmd(x0)
        self.write_cmd(x1)
        self.write_cmd(SET_PAGE_ADDR)
        self.write_cmd(0)
        self.write_cmd(self.pages - 1)
        self.write_framebuf()
 
    def fill(self, col):
        self.framebuf.fill(col)
 
    def pixel(self, x, y, col):
        self.framebuf.pixel(x, y, col)
 
    def scroll(self, dx, dy):
        self.framebuf.scroll(dx, dy)
 
    def text(self, string, x, y, col=1):
        self.framebuf.text(string, x, y, col)
 
 
class SSD1306_I2C(SSD1306):
    def __init__(self, width, height, i2c, addr=0x3c, external_vcc=False):
        self.i2c = i2c
        self.addr = addr
        self.temp = bytearray(2)
        # Add an extra byte to the data buffer to hold an I2C data/command byte
        # to use hardware-compatible I2C transactions.  A memoryview of the
        # buffer is used to mask this byte from the framebuffer operations
        # (without a major memory hit as memoryview doesn't copy to a separate
        # buffer).
        self.buffer = bytearray(((height // 8) * width) + 1)
        self.buffer[0] = 0x40  # Set first byte of data buffer to Co=0, D/C=1
        self.framebuf = framebuf.FrameBuffer1(memoryview(self.buffer)[1:], width, height)
        super().__init__(width, height, external_vcc)
 
    def write_cmd(self, cmd):
        self.temp[0] = 0x80 # Co=1, D/C#=0
        self.temp[1] = cmd
        self.i2c.writeto(self.addr, self.temp)
 
    def write_framebuf(self):
        # Blast out the frame buffer using a single I2C transaction to support
        # hardware I2C interfaces.
        self.i2c.writeto(self.addr, self.buffer)
 
    def poweron(self):
        pass
 
 
class SSD1306_SPI(SSD1306):
    def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
        self.rate = 10 * 1024 * 1024
        dc.init(dc.OUT, value=0)
        res.init(res.OUT, value=0)
        cs.init(cs.OUT, value=1)
        self.spi = spi
        self.dc = dc
        self.res = res
        self.cs = cs
        self.buffer = bytearray((height // 8) * width)
        self.framebuf = framebuf.FrameBuffer1(self.buffer, width, height)
        super().__init__(width, height, external_vcc)
 
    def write_cmd(self, cmd):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.on()
        self.dc.off()
        self.cs.off()
        self.spi.write(bytearray([cmd]))
        self.cs.on()
 
    def write_framebuf(self):
        self.spi.init(baudrate=self.rate, polarity=0, phase=0)
        self.cs.on()
        self.dc.on()
        self.cs.off()
        self.spi.write(self.buffer)
        self.cs.on()
 
    def poweron(self):
        self.res.on()
        time.sleep_ms(1)
        self.res.off()
        time.sleep_ms(10)
        self.res.on()

上传了 ssd1306.py 文件后,我们就可以敲代码了。

1. 显示文本

from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI


# 定义对应的管脚对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))

# 创建 OLED 对象
oled = SSD1306_SPI(width=128, height=64, spi=spi, dc=Pin(2),
                   res=Pin(15), cs=Pin(4))

# 清屏
oled.fill(0)

# 画点
# oled.pixel(30, 30, 1)
# oled.pixel(30, 31, 1)
# oled.pixel(30, 32, 1)
# oled.pixel(30, 33, 1)
# oled.pixel(30, 34, 1)
# oled.pixel(30, 35, 1)

# 画方块

# for x in range(30, 61):
#     for y in range(30, 61):
#         oled.pixel(x, y, 1)

# 打印 Hello world 在屏幕上
oled.text('Hello, world!', 10, 38)

# 显示内容
oled.show()

2. 显示中文

如果你想要在屏幕上显示中文,有两种方法:

  1. 使用中文字体库,想要使用中文字体库需要烧录支持中文字体库的固件,但是字库文件较大;
  2. 使用取模软件,对用到的字体进行取模;

我们这个实验的屏幕很小,用来看电子书的话确实有点费眼,而且,我们的主要目的是做一个菜单,用不到太多汉字,因此,我们选择使用取模软件显示中文。在我们资料包中的 3.开发工具 中的 PCtoLCD2002

打开 PCtoLCD2002.exe,点击设置,按照下图设置

之所以要按照上图方式设置的原因:

  1. 点阵格式:设置为阴码,阴码是亮点为 1,阳马是亮点为 0,我们的实验主要是在屏幕背景为暗点(亮点为 0)的情况下进行的,因此,选择阴码;
  2. 取模方式:选择行列式,点阵逻辑从上向下变化;
  3. 取模走向:选择顺向,更符合日常生活的数学逻辑;
  4. 输出数制:选择十六进制,其实无所谓,如果这里选择十六进制,自定义格式的时候数据前缀输入 0x,十进制则什么也不填,因为最后都要转换成二进制数。

之后,我们就可以输入自己想要生成的字模,点击 生成字模

为什么要转换成二进制数?这就涉及到了 OLED 屏幕的显示原理,屏幕相当于有无数个很小的 LED 阵列组成,而我们设置了字宽和字高均为 16,也就是说,我们要在这个 16*16 的点阵上显示我们想要的图案,比如下图,

图中我们可以看到英文字母只占到了汉字一半的空间,这是因为英文字母,符号,数字这些通用字符都是半角(一字符占用一个标准的字符位置),汉字是全角(一个字符占用两个标准字符位置)。因此,我们显示的汉字也是分开显示的,这也是为什么生成的字模中,G 只输出了一行,而极占了两行。

行列式的显示逻辑是从第一行开始向右取 8 个点作为一个字节,然后从第二行开始向右取 8 个点作为第二个字节...依此类推。生成的这些十六进制数,每一个都表示这一行 8 个 点的逻辑状态,比如 0x10,转换成二进制就是 0b10000,如果不足 8 位,我们就在他的前面补 0,0b100000b00010000 对计算机来说并没有区别,代码如下:

num_list = [0x10,0x13,0x10,0x10,0xFC,0x10,0x30,0x38,0x55,0x55,0x91,0x11,0x12,0x12,0x14,0x11]

for num in num_list:
    # 十六进制转二进制,通过 replace 方法去除 0b 前缀
    num_binary = bin(num).replace('0b', '')
    
    
    # 补 0
    while len(num_binary) < 8:
        num_binary = '0' + num_binary
        
    print(num_binary)

这样,我们就可以很清楚的看出来 字的左半边部分:

所以,如果我们想要显示右半边的话,就需要保持竖轴不变,横轴向右移动 8 个像素,之后使用 pixel() 方法把这些点显示在屏幕上,那我们的程序就可以这么写:

from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI


# 定义对应的管脚对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))

# 创建 OLED 对象
oled = SSD1306_SPI(width=128, height=64, spi=spi, dc=Pin(2),
                   res=Pin(15), cs=Pin(4))

# 清屏
oled.fill(0)

# 定义坐标
x = 30
y = 20

# 汉字字典
character_dict = {
    '极': [0x10,0x13,0x10,0x10,0xFC,0x10,0x30,0x38,0x55,0x55,0x91,0x11,0x12,0x12,0x14,0x11,
          0x00,0xFC,0x84,0x88,0x88,0x90,0x9C,0x84,0x44,0x44,0x28,0x28,0x10,0x28,0x44,0x82],

    '客': [0x02,0x01,0x7F,0x40,0x88,0x0F,0x10,0x2C,0x03,0x1C,0xE0,0x1F,0x10,0x10,0x1F,0x10,
          0x00,0x00,0xFE,0x02,0x04,0xF0,0x20,0x40,0x80,0x70,0x0E,0xF0,0x10,0x10,0xF0,0x10],

    '侠': [0x08,0x08,0x08,0x17,0x10,0x32,0x31,0x50,0x9F,0x10,0x10,0x11,0x11,0x12,0x14,0x18,
          0x40,0x40,0x40,0xFC,0x40,0x48,0x50,0x40,0xFE,0xA0,0xA0,0x10,0x10,0x08,0x04,0x02],#侠2

    '实': [0x02,0x01,0x7F,0x40,0x88,0x04,0x04,0x10,0x08,0x08,0xFF,0x01,0x02,0x04,0x18,0x60,
          0x00,0x00,0xFE,0x02,0x84,0x80,0x80,0x80,0x80,0x80,0xFE,0x40,0x20,0x10,0x08,0x04],#实3

    '验': [0x00,0xF8,0x08,0x48,0x48,0x49,0x4A,0x7C,0x04,0x04,0x1D,0xE4,0x44,0x04,0x2B,0x10,
          0x20,0x20,0x50,0x50,0x88,0x04,0xFA,0x00,0x44,0x24,0x24,0xA8,0x88,0x10,0xFE,0x00],#验4

    '室': [0x02,0x01,0x7F,0x40,0x80,0x3F,0x04,0x08,0x1F,0x01,0x01,0x3F,0x01,0x01,0xFF,0x00,
          0x00,0x00,0xFE,0x02,0x04,0xF8,0x00,0x20,0xF0,0x10,0x00,0xF8,0x00,0x00,0xFE,0x00],#室5
    }


def display_zh_character(character, x, y):
    num_list = character_dict[character]
    
    for i in range(16):
        left = bin(num_list[i]).replace('0b', '')
        right = bin(num_list[i + 16]).replace('0b', '')
        
        # 补 0
        while len(left) < 8:
            left = '0' + left
        
        while len(right) < 8:
            right = '0' + right
        
        num_binary = left+right
        
        for j in range(len(num_binary)):
            oled.pixel(x + j, y + i, int(num_binary[j]))
        
def display_zh(text, x, y):
    for i in range(len(text)):
        display_zh_character(text[i], x + i * 16, y)

display_zh('极客侠实验室', 30, 20)
oled.show()

你也可以使用面向对象的方法,通过继承 SSD1306_SPI 的方式,把 character_dict、display_zh_char、display_zh 变成 SSD1306_SPI 子类的属性和方法。如果你想在屏幕上显示图片也是这个原理。

3. 按键控制菜单

在搞清楚 OLED 显示方法之后,我们就可以设计一个按键控制菜单了,UI 大概就是下面这个样子

按键控制菜单的原理其实很简单 -,当我检测到按键按下的时候,就切换屏幕状态,因为只有部分区域发生了改变,让你产生立箭头移动的错觉,代码如下:

import time
from machine import Pin, SoftSPI
from libs.ssd1306 import SSD1306_SPI


# 定义 SoftSPI 对象
spi = SoftSPI(sck=Pin(18), mosi=Pin(13), miso=Pin(19))

# 定义 SSD1306 SPI 控制对象
oled = SSD1306_SPI(width=128, height=64, spi=spi,
                   dc=Pin(2), res=Pin(15), cs=Pin(4))

# 定义 按键输入引脚对象,并配置上拉电阻
button_up = Pin(12, Pin.IN, Pin.PULL_UP)
button_down = Pin(14, Pin.IN, Pin.PULL_UP)


# 定义菜单选项
menu_items = ['Item 1', 'Item 2', 'Item 3', 'Item 4']
# 记录当前位置的值
current_item = 0


def display_menu(index):
    oled.fill(0)
    oled.text('Menu', 0, 0)
    oled.text('-' * 20, 0, 10)
    for i in range(len(menu_items)):
        if i == index:
            oled.text('> ' + menu_items[i], 0, 20 + i * 10)
        else:
            oled.text(menu_items[i], 0, 20 + i * 10)
    oled.show()
    
# 初始化显示屏幕状态
display_menu(current_item)

while True:
    if not button_up.value():
        current_item = (current_item + 1) % len(menu_items)
        display_menu(current_item)
    if not button_down.value():
        current_item = (current_item - 1) % len(menu_items)
        display_menu(current_item)
    time.sleep(0.1)
上次编辑于:
贡献者: Luo