第3章 OLED显示驱动基础:SSD1306/SSH1106驱动芯片介绍、I2C/SPI通信协议、像素与显存映射

好,咱们进入正题。这一章是血氧仪显示部分的核心基础。说白了,你要让屏幕上显示波形和数字,就得先搞懂屏幕是怎么听话的。

我最早做血氧仪项目时,选的就是0.96寸的OLED屏。当时市面上主流的驱动芯片就两种:SSD1306和SSH1106。很多人以为它们差不多,其实坑不少。今天我就把这两兄弟的底细给你扒清楚。

3.1 SSD1306 vs SSH1106:你该选谁?

先看一张对比表,心里有个数:

特性 SSD1306 SSH1106
分辨率 128×64 132×64
显存大小 128×64 = 8192字节 132×64 = 8448字节
页模式 8页(每页8像素高) 8页(每页8像素高)
I2C地址 0x3C 或 0x3D 0x3C 或 0x3D
SPI支持 4线/3线 4线/3线
常见问题 列偏移需软件修正 列数多4列,需注意边界

嗯,这里有个关键点。SSD1306的显存是128列,但SSH1106是132列。我当年第一次用SSH1106时,直接套用了SSD1306的驱动代码,结果屏幕最右边多了4列乱码。排查了半天才发现是列地址范围没改。

避坑指南: 我曾经在批量采购时吃过亏——供应商发来的屏幕标签写的是SSD1306,实际焊的是SSH1106。如果你发现屏幕显示偏移或右侧有异常像素,先检查芯片型号。

3.2 I2C vs SPI:通信协议怎么选?

血氧仪这种小设备,引脚资源很宝贵。我个人习惯是:

  • I2C:只用两根线(SDA、SCL),适合引脚少的MCU。速度一般在400kHz,刷新30帧没问题。
  • SPI:需要4根线(CS、DC、SCLK、MOSI),但速度快得多,能到10MHz以上。如果你要显示动画或高频波形,SPI是首选。

你想想看,血氧仪显示波形时,每秒要刷新几十次。用I2C的话,每次传输一帧数据大约需要:

帧大小 = 128 × 64 / 8 = 1024字节
I2C传输时间 ≈ 1024 × 9 / 400kHz ≈ 23ms
加上命令开销,一帧约30ms

30ms一帧,也就是33fps,够用了。但如果你用SPI,同样的数据量只需要2-3ms。所以我的建议是:

我的经验: 如果MCU引脚够用,优先选SPI。如果引脚紧张(比如用ATtiny或STM32G030这种小封装),I2C也完全能胜任。我在一个低功耗项目中就用I2C,把刷新率降到10fps,电池续航翻了一倍。

3.3 像素与显存映射:屏幕是怎么记住画面的?

这个知识点,说白了就是「屏幕的脑子长什么样」。OLED的显存是按页组织的,每页8个像素高。128×64的屏幕,纵向被分成8页:

页0: 行0-7
页1: 行8-15
页2: 行16-23
...
页7: 行56-63

每一页有128个字节,每个字节对应一列上的8个像素。比如页0的第0个字节,控制的是第0列、第0行到第7行的像素。字节的bit0对应最上面一行,bit7对应最下面一行。

我刚开始学的时候,总觉得这个映射关系很绕。后来画了个图才明白:

列0  列1  列2  ...  列127
+----+----+----+     +----+
| b0 | b0 | b0 |     | b0 |  行0
| b1 | b1 | b1 |     | b1 |  行1
| b2 | b2 | b2 |     | b2 |  行2
| b3 | b3 | b3 |     | b3 |  行3
| b4 | b4 | b4 |     | b4 |  行4
| b5 | b5 | b5 |     | b5 |  行5
| b6 | b6 | b6 |     | b6 |  行6
| b7 | b7 | b7 |     | b7 |  行7
+----+----+----+     +----+
 字节0 字节1 字节2    字节127

所以,如果你想在屏幕左上角点亮一个像素,位置是(0,0),你需要把页0、列0的字节写成0x01。如果想点亮(0,7),就是0x80。

核心公式: 对于坐标(x, y),对应的显存地址为:
页号 = y / 8
列号 = x
字节内偏移 = y % 8
该像素的掩码 = 1 << (y % 8)

3.4 实战:初始化序列与显存操作

好,理论说完了,咱们直接上代码。这是我常用的SSD1306初始化序列,I2C模式:

void OLED_Init(void) {
    // 复位(如果RES引脚接了MCU)
    OLED_RES_LOW();
    delay_ms(10);
    OLED_RES_HIGH();
    delay_ms(10);
    
    // 初始化命令序列
    OLED_WriteCmd(0xAE);  // 关闭显示
    OLED_WriteCmd(0xD5);  // 设置振荡频率
    OLED_WriteCmd(0x80);
    OLED_WriteCmd(0xA8);  // 设置多路复用比
    OLED_WriteCmd(0x3F);  // 64行
    OLED_WriteCmd(0xD3);  // 显示偏移
    OLED_WriteCmd(0x00);
    OLED_WriteCmd(0x40);  // 起始行
    OLED_WriteCmd(0x8D);  // 电荷泵
    OLED_WriteCmd(0x14);  // 开启
    OLED_WriteCmd(0x20);  // 内存地址模式
    OLED_WriteCmd(0x00);  // 水平模式
    OLED_WriteCmd(0xA1);  // 列段重映射
    OLED_WriteCmd(0xC8);  // COM扫描方向
    OLED_WriteCmd(0xDA);  // COM引脚配置
    OLED_WriteCmd(0x12);
    OLED_WriteCmd(0x81);  // 对比度
    OLED_WriteCmd(0xCF);
    OLED_WriteCmd(0xD9);  // 预充电周期
    OLED_WriteCmd(0xF1);
    OLED_WriteCmd(0xDB);  // VCOMH电压
    OLED_WriteCmd(0x40);
    OLED_WriteCmd(0xA4);  // 全局显示开启
    OLED_WriteCmd(0xA6);  // 正常显示(非反色)
    OLED_WriteCmd(0xAF);  // 开启显示
}

这里有个细节。我见过很多新手把0xA1和0xC8搞反。0xA1是列地址从左到右,0xC0是从上到下。如果你发现显示的内容是镜像的,八成是这两个参数配错了。

显存操作的核心函数就两个:

// 设置光标位置(页和列)
void OLED_SetCursor(uint8_t page, uint8_t col) {
    OLED_WriteCmd(0xB0 + page);  // 设置页地址
    OLED_WriteCmd(0x00 + (col & 0x0F));  // 列低4位
    OLED_WriteCmd(0x10 + (col >> 4));    // 列高4位
}

// 写一个字节到显存
void OLED_WriteData(uint8_t data) {
    // I2C或SPI发送数据字节
    I2C_Start();
    I2C_WriteByte(0x3C << 1);  // 设备地址+写
    I2C_WriteByte(0x40);        // 控制字节:数据模式
    I2C_WriteByte(data);
    I2C_Stop();
}
小技巧: 我习惯在MCU内部维护一个完整的显存缓冲区(1024字节),所有绘图操作都在缓冲区里完成,然后一次性刷到屏幕。这样能避免频繁I2C通信,而且方便实现局部刷新。血氧仪的波形显示,我就是用这个思路做的。

3.5 血氧仪中的实际应用

在血氧仪里,我们通常需要显示:

  • 血氧值:大号数字,比如98%
  • 脉率:中号数字,比如72bpm
  • 脉搏波形:实时滚动的曲线
  • 电池图标:小图标

波形显示是难点。我用的方法是:

  1. 维护一个128字节的环形缓冲区,存储最近128个采样点的幅值(0-63)
  2. 每次采样后,更新缓冲区
  3. 将缓冲区数据映射到显存:每个采样点对应一列,幅值决定该列点亮哪些像素
  4. 整屏刷新

代码大概长这样:

#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64

uint8_t waveform_buffer[SCREEN_WIDTH];  // 存储幅值

void DrawWaveform(void) {
    uint8_t page, col, bit_mask;
    
    // 清空显存缓冲区
    memset(frame_buffer, 0x00, 1024);
    
    // 绘制波形
    for(col = 0; col < SCREEN_WIDTH; col++) {
        uint8_t y = waveform_buffer[col];  // 幅值0-63
        if(y >= SCREEN_HEIGHT) y = SCREEN_HEIGHT - 1;
        
        page = y / 8;
        bit_mask = 1 << (y % 8);
        
        // 点亮该列对应的像素
        frame_buffer[page * SCREEN_WIDTH + col] |= bit_mask;
        
        // 如果幅值较大,可以画一条垂直线(从底部到幅值点)
        // 这里省略,实际项目中会加
    }
    
    // 刷到屏幕
    OLED_Refresh();
}

嗯,这里要注意。波形显示时,如果直接画点,看起来就是一堆离散的点。我习惯在点之间画连线,或者用垂直线表示幅值,视觉效果会好很多。

又一个坑: 我曾经在波形刷新时忘了清空显存,结果旧波形和新波形叠加在一起,屏幕上一团乱麻。记住:每次刷新前,要么清空整个显存,要么只更新变化的部分。

3.6 本章小结

这一章我们聊了:

  • SSD1306和SSH1106的区别,选型时别搞混
  • I2C和SPI的取舍,血氧仪用I2C够用,SPI更爽
  • 显存映射的页-列-字节关系,这是所有绘图的基础
  • 初始化序列和显存操作的实际代码
  • 血氧仪波形显示的思路和代码

下一章,我们会深入讲字体渲染和数字显示。毕竟血氧仪上最核心的就是那几个大数字,怎么让它们清晰、美观、省显存,都是学问。

好,今天就到这里。有什么问题,咱们下节课见。