SPI 驱动 OLED 液晶屏幕
这一节我们学习如何使用 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
,或者 MISO
与 MOSI
共线。
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 显示屏模块接口定义:
- GND:电源地。
- VCC:电源正(3.3~5V)。
- D0:OLED 的 D0 脚,在 SPI 通信中为时钟管脚。
- D1:OLED 的 D1 脚,在 SPI 通信中为数据管脚。
- RES:OLED 的 RES 脚,用来复位(低电平复位)。
- DC:数据和命令控制管脚。
- 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 可以在不同的处理器或者不同架构间进行代码的移植,代码通用性强。
构造函数:
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 对象。
machine.SPI(id, ...)
: 在给定的 SPI 通道(id)上构造一个 SPI 对象。在没有附加参数的情况下,SPI 对象被创建但不初始化(它具有总线上次初始化的设置,如果有的话)。如果给出了额外的参数,则总线被初始化。id
:使用的 SPI 通道,可为 1 或者 2,通常用于选择硬件 HSPI、VSPI(HSPI、VSPI 是一样的,只不过是换个名字用于区分)等;- 其余参数与 SoftSPI 一致。
HSPI(id = 1) | VSPI(id = 2) | |
---|---|---|
SCK | 14 | 18 |
MOSI | 13 | 23 |
MISO | 12 | 19 |
将 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. 显示中文
如果你想要在屏幕上显示中文,有两种方法:
- 使用中文字体库,想要使用中文字体库需要烧录支持中文字体库的固件,但是字库文件较大;
- 使用取模软件,对用到的字体进行取模;
我们这个实验的屏幕很小,用来看电子书的话确实有点费眼,而且,我们的主要目的是做一个菜单,用不到太多汉字,因此,我们选择使用取模软件显示中文。在我们资料包中的 3.开发工具
中的 PCtoLCD2002
。
打开 PCtoLCD2002.exe
,点击设置,按照下图设置
之所以要按照上图方式设置的原因:
点阵格式
:设置为阴码,阴码是亮点为 1,阳马是亮点为 0,我们的实验主要是在屏幕背景为暗点(亮点为 0)的情况下进行的,因此,选择阴码;取模方式
:选择行列式,点阵逻辑从上向下变化;取模走向
:选择顺向,更符合日常生活的数学逻辑;输出数制
:选择十六进制,其实无所谓,如果这里选择十六进制,自定义格式的时候数据前缀输入0x
,十进制则什么也不填,因为最后都要转换成二进制数。
之后,我们就可以输入自己想要生成的字模,点击 生成字模
为什么要转换成二进制数?这就涉及到了 OLED 屏幕的显示原理,屏幕相当于有无数个很小的 LED 阵列组成,而我们设置了字宽和字高均为 16,也就是说,我们要在这个 16*16 的点阵上显示我们想要的图案,比如下图,
图中我们可以看到英文字母只占到了汉字一半的空间,这是因为英文字母,符号,数字这些通用字符都是半角(一字符占用一个标准的字符位置),汉字是全角(一个字符占用两个标准字符位置)。因此,我们显示的汉字也是分开显示的,这也是为什么生成的字模中,G 只输出了一行,而极占了两行。
行列式的显示逻辑是从第一行开始向右取 8 个点作为一个字节,然后从第二行开始向右取 8 个点作为第二个字节...依此类推。生成的这些十六进制数,每一个都表示这一行 8 个 点的逻辑状态,比如 0x10
,转换成二进制就是 0b10000
,如果不足 8 位,我们就在他的前面补 0,0b10000
与 0b00010000
对计算机来说并没有区别,代码如下:
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)