用 ESP32-S3 驱动 SSD1306:把「月薪猫」塞进 0.96 寸屏

手上一直有一块 0.96 寸的 SSD1306,买的时候想的是「随手点个灯就够了」,结果一直躺在元件盒里。最近把手上的 ESP32-S3 重新翻出来,索性给它安排点正事:让屏幕上的小猫动起来。

先算清楚这块屏能干什么

SSD1306 是单色屏,128×64 个像素,一个像素一位,整块缓冲区就是 128 × 64 / 8 = 1024 字节。也就是说,每刷新一帧,至少要往屏幕里写 1KB 的数据。这 1KB 就是后面所有性能问题的起点。

单色屏没什么灰度可言,所以做动画的思路很简单:把每一帧都压成 1bit 位图,然后一帧一帧地推过去。难点不在「能不能动」,而在「动得够不够顺」。

接线:ESP32-S3 的 I2C 可以随便挑引脚

ESP32-S3 有 GPIO 矩阵,I2C 的外设信号可以映射到大部分 GPIO 上,所以不必死守默认引脚。我用的是下面这组,接线短、走线也顺:

屏幕引脚ESP32-S3说明
VCC3V3不要接 5V,模块虽然有稳压,但没必要冒险
GNDGND共地
SCLGPIO9时钟线
SDAGPIO8数据线

地址一般是 0x3C,少数模块是 0x3D。上电先跑一个 I2C 扫描,确认地址再往下写,能省掉一半的玄学问题。

把 GIF 拆成 C 数组

素材是一段十几帧的小动画。用 Pillow 拆帧、缩放、二值化,再输出成 C 数组,脚本不到 40 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from PIL import Image, ImageSequence

W, H = 128, 64
src = Image.open('salary_cat.gif')

frames = []
for frame in ImageSequence.Iterator(src):
img = frame.convert('L').resize((W, H))
img = img.point(lambda v: 255 if v > 128 else 0, '1')
buf = bytearray()
for y in range(H):
for x in range(0, W, 8):
byte = 0
for b in range(8):
if img.getpixel((x + b, y)):
byte |= 1 << (7 - b)
buf.append(byte)
frames.append(buf)

with open('frames.h', 'w', encoding='utf-8') as f:
f.write('const uint8_t FRAMES[][1024] PROGMEM = {\n')
for fr in frames:
f.write(' {' + ','.join('0x%02X' % v for v in fr) + '},\n')
f.write('};\n')
f.write('#define FRAME_COUNT %d\n' % len(frames))

注意二值化的阈值。阈值太高,线条会断;太低,整片糊成一团。单色屏没有中间调,这一步基本决定了成品好不好看,值得多试几组。

Arduino 端的播放逻辑

用 U8g2 的全缓冲模式,每帧把整个 1024 字节写进缓冲区再一次性 sendBuffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <U8g2lib.h>
#include <Wire.h>
#include "frames.h"

U8G2_SSD1306_128X64_NONAME_F_HW_I2C u8g2(U8G2_R0, U8X8_PIN_NONE);

void setup() {
Wire.begin(8, 9); // SDA, SCL
Wire.setClock(400000); // 400kHz 快速模式
u8g2.begin();
}

void loop() {
for (uint16_t i = 0; i < FRAME_COUNT; i++) {
u8g2.clearBuffer();
u8g2.drawXBMP(0, 0, 128, 64, FRAMES[i]);
u8g2.sendBuffer();
delay(80); // 约 12fps
}
}

U8g2 的 drawXBMP 会自动处理 PROGMEM 读取,所以数组放在 flash 里也不会读错。如果换成 Adafruit_SSD1306,就要自己用 pgm_read_byte,这点后面踩坑会讲到。

帧率到底能跑多快

以 400kHz 的 I2C 来算,一帧 1024 字节,加上地址字节和分页控制开销,实际传输大概在 21~25ms。理论上限差不多 30 帧左右,但那是「什么都不干只管发数据」的理想值。

加上清缓冲、绘制、解析的开销,实测稳定在 12~15 帧。想再快,只有两条路:

  • 把 I2C 拉到 1MHz(部分 SSD1306 模块能撑住,但线长了容易出错);
  • 只刷变化区域,把整屏刷新拆成局部刷新。

对一段十几帧的循环动画来说,12 帧已经完全够用了,所以我没有继续优化——够用就停,是我这几年学到的最省钱的一条经验。

踩过的坑

1. 屏幕其实不是 SSD1306

有一批 0.96 寸模块用的是 SH1106 驱动,它的显存是 132 列,比 SSD1306 多 4 列。用 SSD1306 的驱动去推,画面会整体偏移,右边还会缺一条。现象是「文字左边被切、右边有竖线」,遇到这个先怀疑驱动型号,别急着改代码。

2. PROGMEM 忘了 pgm_read_byte

Adafruit_SSD1306 的 drawBitmap 默认按 RAM 读取,把数组声明成 PROGMEM 之后必须自己解包,否则屏幕上会随机花屏——因为读到的是 flash 的地址空间,不是数据。这类问题不会报错,只会让你怀疑人生。

3. ESP32-S3 的默认引脚不能想当然

网上很多教程是 ESP32 的默认 SDA=21/SCL=22,S3 上这两个编号并不通用。老老实实 Wire.begin(8, 9) 显式指定,别抄。

小结

一块 3 块钱的屏幕 + 一块 ESP32-S3,就能做出一个放桌上的小动画。真正花时间的不是驱动,而是素材处理和那些「看起来像硬件坏了、其实是软件细节」的坑。

下一步想给它加个按键切换动画,再把桌面时钟接上去。等做完了再写一篇。