基于同一款 SSD1306 OLED 模块,分别在 STM32、ESP32、Linux、FPGA 四个平台上完成驱动开发
重点讲解驱动架构设计、分层抽象、移植策略等工程实践方法。


不建议重复造轮子!!!但需要了解模块原理,需要源码可以 免费分享。如何使用源码看文章最后的B站视频。

0、驱动使用方法及演示教程

一、驱动架构总览:分层设计思想

编写任何硬件驱动,核心原则都是分层解耦。将驱动划分为三个独立层次,每一层只与相邻层交互:

+------------------------------------------+
|           应用层 (Application)            |  显示字符串、数字、图片、汉字
+------------------------------------------+
|          逻辑层 (Logic / Display API)      |  OLED_ShowChar, OLED_ShowNum, OLED_Clear...
+------------------------------------------+
|          传输层 (Transport / HAL)          |  OLED_WR_Byte(dat, cmd) ← 唯一抽象接口
+------------------------------------------+
|          硬件层 (HW Abstraction)           |  软件 IIC / 硬件 IIC / Linux /dev/i2c-1
+------------------------------------------+
|          硬件 (SSD1306)                   |
+------------------------------------------+

关键设计:只要传输层实现了 OLED_WR_Byte(dat, cmd) 这个统一接口,逻辑层应用层的代码就可以在所有平台之间零修改复用。下面通过四个平台的实际代码,具体说明各层如何编写。


二、传输层(核心差异层)

传输层是四个平台差异最大的部分,也是驱动移植的核心工作。本节逐平台分析。

2.1 STM32:软件模拟 IIC

STM32 方案使用 GPIO 位操作直接模拟 IIC 时序,适用于没有硬件 IIC 外设或需要灵活选择引脚的场景。

2.1.1 GPIO 抽象宏(lt_oled.h

通过宏将 GPIO 操作封装为平台无关的接口:

/* lt_oled.h — STM32 HAL 库版本 */
#include "main.h"

#define delay_ms(x) HAL_Delay(x)

#define OLED_SCLK_Clr() HAL_GPIO_WritePin(OLED_SCL_GPIO_Port, OLED_SCL_Pin, GPIO_PIN_RESET)
#define OLED_SCLK_Set() HAL_GPIO_WritePin(OLED_SCL_GPIO_Port, OLED_SCL_Pin, GPIO_PIN_SET)
#define OLED_SDIN_Clr() HAL_GPIO_WritePin(OLED_SDA_GPIO_Port, OLED_SDA_Pin, GPIO_PIN_RESET)
#define OLED_SDIN_Set() HAL_GPIO_WritePin(OLED_SDA_GPIO_Port, OLED_SDA_Pin, GPIO_PIN_SET)

#define OLED_CMD  0   // 写命令
#define OLED_DATA 1   // 写数据

设计要点

  • 引脚定义(OLED_SCL_GPIO_PortOLED_SDA_Pin)不在驱动文件中写死,而是在 main.h 中定义,由 CubeMX 生成。用户只需在 CubeMX 中修改引脚即可完成硬件适配,驱动源码无需任何改动。
  • delay_ms 直接映射到 HAL_Delay(),避免引入额外依赖。
2.1.2 软件 IIC 时序实现(lt_oled.c
/* lt_oled.c — STM32 软件 IIC 实现 */

/* IIC 起始信号:SCL 高电平时 SDA 产生下降沿 */
void IIC_Start(void)
{
    OLED_SCLK_Set();
    OLED_SDIN_Set();
    OLED_SDIN_Clr();   // SDA 下降沿(SCL 仍为高)
    OLED_SCLK_Clr();   // 拉低 SCL,准备传输
}

/* IIC 停止信号:SCL 高电平时 SDA 产生上升沿 */
void IIC_Stop(void)
{
    OLED_SCLK_Set();
    OLED_SDIN_Clr();
    OLED_SDIN_Set();   // SDA 上升沿(SCL 仍为高)
}

/* 逐位发送,MSB 优先 */
void Write_IIC_Byte(unsigned char IIC_Byte)
{
    unsigned char i, m, da;
    da = IIC_Byte;
    OLED_SCLK_Clr();
    for (i = 0; i < 8; i++) {
        m = da & 0x80;          // 取最高位
        if (m == 0x80)
            OLED_SDIN_Set();
        else
            OLED_SDIN_Clr();
        da = da << 1;
        OLED_SCLK_Set();         // SCL 上升沿,从机采样
        OLED_SCLK_Clr();         // SCL 下降沿,准备下一位
    }
}

/* 写命令:Start → 地址 → 控制字节(0x00) → 命令 → Stop */
void Write_IIC_Command(unsigned char IIC_Command)
{
    IIC_Start();
    Write_IIC_Byte(0x78);           // SSD1306 写地址 (SA0=0 → 0x3C<<1 = 0x78)
    IIC_Wait_Ack();
    Write_IIC_Byte(0x00);           // 控制字节:后续为命令
    IIC_Wait_Ack();
    Write_IIC_Byte(IIC_Command);
    IIC_Wait_Ack();
    IIC_Stop();
}

/* 写数据:Start → 地址 → 控制字节(0x40) → 数据 → Stop */
void Write_IIC_Data(unsigned char IIC_Data)
{
    IIC_Start();
    Write_IIC_Byte(0x78);
    IIC_Wait_Ack();
    Write_IIC_Byte(0x40);           // 控制字节:后续为数据
    IIC_Wait_Ack();
    Write_IIC_Byte(IIC_Data);
    IIC_Wait_Ack();
    IIC_Stop();
}

/* 统一的写字节接口 — 传输层的唯一入口 */
void OLED_WR_Byte(unsigned dat, unsigned cmd)
{
    if (cmd)
        Write_IIC_Data(dat);       // 写数据
    else
        Write_IIC_Command(dat);     // 写命令
}

设计要点

  • 命令和数据通过控制字节区分:0x00 表示命令,0x40 表示数据。这是 SSD1306 IIC 协议的规定。
  • 软件 IIC 不需要额外的延时函数,因为 GPIO 操作本身需要一定执行时间(72MHz 下约 100ns),足以满足 IIC 的时序要求。
  • 每个字节独立发送 Start/Stop,简化了逻辑但牺牲了效率。对 OLED 来说,显示内容不多,这个简化是合理的。
2.1.3 编译条件切换 IIC/SPI

STM32 版本通过宏 OLED_IIC 编译切换 IIC 和 SPI 两种传输方式:

/* lt_oled.h */
#define OLED_IIC  1    // 1=IIC模式, 0=SPI模式

#if OLED_IIC
    // IIC 模式的引脚宏和函数声明
#else
    // SPI 模式额外需要 RES/DC/CS 三个引脚
    #define OLED_RST_Clr() HAL_GPIO_WritePin(OLED_RES_GPIO_Port, OLED_RES_Pin, GPIO_PIN_RESET)
    #define OLED_RST_Set() HAL_GPIO_WritePin(OLED_RES_GPIO_Port, OLED_RES_Pin, GPIO_PIN_SET)
    #define OLED_DC_Clr()  HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, GPIO_PIN_RESET)
    #define OLED_DC_Set()  HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, GPIO_PIN_SET)
    #define OLED_CS_Clr()  HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_RESET)
    #define OLED_CS_Set()  HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_SET)
#endif

对应 SPI 模式的 OLED_WR_Byte 实现也通过 #if OLED_IIC 条件编译:

/* lt_oled.c — SPI 模式 */
void OLED_WR_Byte(unsigned char dat, unsigned char cmd)
{
    unsigned char i;
    if (cmd)
        OLED_DC_Set();       // DC=1: 数据
    else
        OLED_DC_Clr();       // DC=0: 命令

    OLED_CS_Clr();           // 片选有效
    for (i = 0; i < 8; i++) {
        OLED_SCLK_Clr();
        if (dat & 0x80)
            OLED_SDIN_Set();
        else
            OLED_SDIN_Clr();
        OLED_SCLK_Set();     // 上升沿采样
        dat <<= 1;
    }
    OLED_CS_Set();           // 片选无效
    OLED_DC_Set();
}

设计要点

  • SPI 模式通过 DC 引脚区分命令/数据,无需控制字节。这是 SPI 接口与 IIC 接口的核心区别。
  • SPI 模式有硬件复位引脚 RES,初始化时需要手动拉低再拉高复位芯片。
  • 条件编译使得同一份代码可以适配不同接口模块,只需修改一个宏即可切换。

2.2 ESP32:硬件 IIC(ESP-IDF 驱动)

ESP32 方案利用 ESP-IDF 的 i2c_master_write_to_device() API 完成硬件 IIC 通信,不再手动操作 GPIO。

2.2.1 头文件设计(lt_oled.h
/* lt_oled.h — ESP32 版本 */
#include <stdint.h>
#include <unistd.h>

#define OLED_I2C_ADDR  0x3C    // 7位地址,ESP-IDF 自动左移

#define OLED_CMD  0
#define OLED_DATA 1

/* API 函数声明 */
int OLED_Init(uint8_t oled_iic_num);   // 传入 I2C 总线编号
void OLED_WR_Byte(uint8_t dat, uint8_t cmd);
// ... 其余逻辑层函数声明(与 STM32 完全一致)

与 STM32 版本的关键差异

  • 不再需要 GPIO 操作宏(OLED_SCLK_Set() 等),由 ESP-IDF 的 IIC 驱动接管底层。
  • OLED_Init() 接收 I2C 总线编号作为参数,允许选择使用哪条 I2C 总线。
  • I2C 地址使用 7 位格式 0x3C,ESP-IDF 内部自动左移为 8 位地址;而 STM32 软件模拟使用的是 8 位写地址 0x780x3C << 1)。两者本质相同,只是表示方式不同。
2.2.2 硬件 IIC 传输实现(lt_oled.c
/* lt_oled.c — ESP32 硬件 IIC 实现 */
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "iic.h"        // ESP32 IIC 初始化函数

static uint8_t oled_iic = 0;

/* 通过 ESP-IDF API 一次性写入 2 字节(控制字节 + 数据/命令) */
int I2C_WriteByte(uint8_t type, uint8_t data)
{
    uint8_t buffer[2];
    buffer[0] = type;    // 0x00=命令, 0x40=数据
    buffer[1] = data;

    esp_err_t ret = i2c_master_write_to_device(
        oled_iic,              // I2C 总线编号
        OLED_I2C_ADDR,         // 从机 7 位地址
        buffer, 2,             // 数据缓冲区和长度
        2 / portTICK_PERIOD_MS // 超时时间
    );

    if (ret != ESP_OK) {
        printf("i2c_master_write_to_device err, code:%d\r\n", ret);
        return -1;
    }
    return 0;
}

/* 统一写接口 */
void OLED_WR_Byte(uint8_t dat, uint8_t cmd)
{
    if (cmd == OLED_CMD)
        I2C_WriteByte(0x00, dat);   // 命令
    else
        I2C_WriteByte(0x40, dat);   // 数据
}

/* 初始化 */
int OLED_Init(uint8_t oled_iic_num)
{
    esp_err_t ret = lt_iic_init(oled_iic_num, 400000);  // 400kHz IIC 时钟
    if (ret != ESP_OK) {
        printf("i2c init failed");
        return -1;
    }
    oled_iic = oled_iic_num;

    // SSD1306 初始化命令序列(与 STM32 完全相同)
    OLED_WR_Byte(0xAE, OLED_CMD);  // display off
    OLED_WR_Byte(0x00, OLED_CMD);  // set low column address
    // ... 其余命令 ...

    vTaskDelay(100);                 // FreeRTOS 延时
    OLED_Clear();
    return 0;
}

设计要点

  • ESP-IDF 的 i2c_master_write_to_device() 内部自动处理 Start/Stop/ACK,无需手动实现 IIC 时序。
  • 每次写入 2 字节(控制字节 + 数据),由硬件自动拼接为一个 IIC 事务。
  • 延时使用 vTaskDelay()(FreeRTOS 系统调用),而非忙等待,不占用 CPU。
  • 与 STM32 版本对比:传输层代码从 ~50 行缩减到 ~10 行,且时序更精确、速度更快(硬件 IIC 支持 400kHz 快速模式)。

2.3 Linux:/dev/i2c 设备节点

Linux 方案通过内核 IIC 子系统的 /dev/i2c-N 设备节点与 OLED 通信,实现用户空间驱动。

2.3.1 头文件设计(lt_oled.h
/* lt_oled.h — Linux 版本 */
#include <stdint.h>
#include <unistd.h>

#define OLED_I2C_ADDR  0x3C
#define OLED_CMD  0
#define OLED_DATA 1

/* 初始化传入设备路径和地址 */
int OLED_Init(const char *i2c_device, uint8_t addr);
void OLED_Close(void);   // Linux 特有:关闭文件描述符
// ... 其余函数声明

设计要点

  • OLED_Init() 接收设备路径(如 "/dev/i2c-1")和 I2C 地址,使驱动可以在不同 Linux 板子上使用不同 I2C 总线。
  • 新增 OLED_Close() 函数用于关闭文件描述符、释放资源。这是 Linux 驱动特有的需求。
2.3.2 Linux IIC 传输实现(lt_oled.c
/* lt_oled.c — Linux I2C 实现 */
#include <fcntl.h>
#include <sys/ioctl.h>
#include <linux/i2c-dev.h>

static int i2c_fd = -1;
static uint8_t oled_addr = OLED_I2C_ADDR;

void Delay_1ms(uint32_t Del_1ms)
{
    usleep(Del_1ms * 1000);
}

int I2C_WriteByte(uint8_t type, uint8_t data)
{
    uint8_t buffer[2];
    buffer[0] = type;
    buffer[1] = data;

    if (write(i2c_fd, buffer, 2) != 2) {
        return -1;
    }
    return 0;
}

void OLED_WR_Byte(uint8_t dat, uint8_t cmd)
{
    if (cmd == OLED_CMD)
        I2C_WriteByte(0x00, dat);
    else
        I2C_WriteByte(0x40, dat);
}

int OLED_Init(const char *i2c_device, uint8_t addr)
{
    /* 打开 I2C 设备节点 */
    i2c_fd = open(i2c_device, O_RDWR);
    if (i2c_fd < 0) {
        perror("open i2c device failed");
        return -1;
    }

    /* 通过 ioctl 设置从机地址 */
    if (ioctl(i2c_fd, I2C_SLAVE, addr) < 0) {
        perror("ioctl set slave addr failed");
        close(i2c_fd);
        return -1;
    }

    oled_addr = addr;

    /* SSD1306 初始化序列 */
    OLED_WR_Byte(0xAE, OLED_CMD);  // display off
    // ... 其余命令与 STM32 完全相同 ...

    Delay_1ms(100);
    OLED_Clear();
    return 0;
}

void OLED_Close(void)
{
    if (i2c_fd >= 0) {
        OLED_Display_Off();
        close(i2c_fd);
        i2c_fd = -1;
    }
}

设计要点

  • Linux 用户空间 IIC 的三步操作:open()ioctl(I2C_SLAVE)write()。其中 ioctl 将文件描述符绑定到特定从机地址,之后每次 write() 就是一次 IIC 写事务。
  • write(i2c_fd, buffer, 2) 一次性发送 2 字节,内核 IIC 驱动自动处理 Start/Stop/ACK。
  • 延时使用 usleep(),精度足够(OLED 不需要微秒级精度)。
  • 新增了 OLED_Close() 进行资源清理,这是良好的工程习惯。

2.4 FPGA:纯硬件 IIC 状态机

FPGA 方案与上述三个软件平台截然不同,使用 Verilog 硬件状态机 在 RTL 层面实现 IIC 协议。

2.4.1 模块端口定义
/* oled_driver.v — FPGA OLED 驱动顶层模块 */
module oled_driver (
    input  reset_n,           // 复位(低有效)
    input  sysclk,            // 系统时钟

    // 数据接口(来自显示逻辑模块)
    input  [7:0] s_data,      // 待显示数据
    input        s_data_en,  // 数据有效信号
    input  [7:0] s_data_lenght,  // 数据长度
    input  [7:0] s_x,        // X 坐标 (0~127)
    input  [2:0] s_y,        // Y 坐标 (页地址 0~7)

    // IIC 物理接口
    output oled_scl,          // IIC 时钟
    inout  oled_sda,          // IIC 数据(双向)

    // 状态输出
    output        oled_init,  // 初始化完成标志
    output [7:0]  s_data_cnt,// 已发送数据计数
    output        s_data_end // 数据发送完成标志
);

parameter SLAVE_ADDR = 7'b0111100;  // 7位地址 0x3C
parameter CLK_FREQ   = 26'b10111110101111000010000000;  // 系统时钟频率
parameter I2C_FREQ   = 18'b110000110101000000;           // IIC 目标频率
2.4.2 模块层次结构

FPGA 驱动采用自顶向下的模块化设计,包含三个子模块:

oled_driver (顶层)
├── oled_init_inst    : 初始化状态机(发送 SSD1306 配置命令序列)
│   └── oled_clear_init : 清屏状态机(发送 8 页全零数据)
├── oled_show_inst    : 显示数据状态机(接收坐标和数据,发送到 OLED)
└── u_oled_iic_write  : IIC 写底层状态机(产生 SCL/SDA 时序)
2.4.3 顶层状态调度
/* 顶层状态机:依次执行 初始化 → 清屏 → 等待显示数据 → 发送显示数据 */
parameter OLED_RST      = 8'b00000000;  // 复位状态
parameter OLED_INIT     = 8'b00000001;  // 发送初始化命令
parameter OLED_INIT_WAT = 8'b00000010;  // 等待初始化完成
parameter OLED_DATA     = 8'b00000011;  // 发送显示数据
parameter OLED_DATA_WAT = 8'b00000100;  // 等待发送完成

wire [7:0] oled_state;
wire i2c_exec;       // IIC 写请求
wire i2c_rh_wl;      // 读写方向(0=写)
wire [7:0] i2c_addr; // IIC 地址
wire [7:0] i2c_data_w;  // IIC 写数据

/* 状态机根据当前状态选择数据源 */
assign i2c_addr   = (oled_state == OLED_INIT) ? oled_init_inst/i2c_addr
                   : oled_show_inst/i2c_addr;
assign i2c_data_w = (oled_state == OLED_INIT) ? oled_init_inst/i2c_data_w
                   : oled_show_inst/i2c_data_w;
2.4.4 设计要点
  • 纯硬件并行:初始化、清屏、显示数据由不同状态机并行处理,通过顶层仲裁决定当前 IIC 总线的使用者。
  • IIC 分频:通过 CLK_FREQI2C_FREQ 参数计算分频计数器,自动适配不同系统时钟频率。
  • 地址参数化SLAVE_ADDR 通过 parameter 定义,可灵活修改为其他 IIC 从机地址。
  • 与软件方案的对比
    • 软件:串行执行(初始化→清屏→显示),一次只能做一件事
    • FPGA:状态机可并行,初始化完成后自动切换到数据传输状态

三、传输层对比总结

维度 STM32 (软件IIC) ESP32 (硬件IIC) Linux (/dev/i2c) FPGA (RTL)
IIC 实现 GPIO 位操作模拟 ESP-IDF IIC 驱动 内核 IIC 子系统 Verilog 状态机
时钟速度 ~100kHz(受限于GPIO操作) 400kHz(硬件支持) 100/400kHz(内核配置) 可精确控制分频
地址格式 8位写地址 0x78 7位地址 0x3C 7位地址 0x3C 7位参数
Start/Stop 手动模拟 API 自动处理 write() 自动处理 状态机产生
延时方式 HAL_Delay() vTaskDelay() usleep() 时钟计数器
多设备支持 修改宏 传入 i2c_num 传入设备路径 参数化实例化
代码量 ~50行 ~10行 ~15行 ~数百行(状态机)

四、逻辑层(平台无关的显示 API)

逻辑层包含所有显示功能的实现逻辑。由于 OLED_WR_Byte(dat, cmd) 是统一的传输接口,逻辑层代码在四个平台上完全一致(STM32 的 IIC 和 SPI 分支除外)。

4.1 显存管理

4.1.1 显存数组
/* 128 列 x 8 页,共 1024 字节 */
unsigned char OLED_GRAM[128][8];

SSD1306 的显存按 页(Page) 组织,每页高 8 像素,共 8 页覆盖 64 行:

Page 0 → Y: 0~7     (OLED_GRAM[0~127][0])
Page 1 → Y: 8~15    (OLED_GRAM[0~127][1])
...
Page 7 → Y: 56~63   (OLED_GRAM[0~127][7])
4.1.2 显存刷新函数
void OLED_Refresh_Gram(void)
{
    unsigned char i, n;
    for (i = 0; i < 8; i++) {
        OLED_WR_Byte(0xb0 + i, OLED_CMD);   // 设置页地址
        OLED_WR_Byte(0x00, OLED_CMD);         // 列低地址
        OLED_WR_Byte(0x10, OLED_CMD);         // 列高地址
        for (n = 0; n < 128; n++)
            OLED_WR_Byte(OLED_GRAM[n][i], OLED_DATA);
    }
}

注意:这里的刷新方式是逐字节写入,每次写入都需要 Start+地址+控制字节+数据+Stop,效率较低。STM32 版本的 OLED_ShowChar 采用了另一种方式——直接定位写入位置,跳过显存数组。

4.2 坐标设置

void OLED_Set_Pos(unsigned char x, unsigned char y)
{
    /* Flowers_screen 宏处理不同批次模块的列偏移问题 */
    #if Flowers_screen
    x = x;
    #else
    x = x + 2;   // 老版模块需要偏移 2 列
    #endif

    OLED_WR_Byte(0xb0 + y, OLED_CMD);               // 页地址
    OLED_WR_Byte((((x) & 0xf0) >> 4) | 0x10, OLED_CMD);  // 列高 4 位
    OLED_WR_Byte(((x) & 0x0f), OLED_CMD);           // 列低 4 位
}

设计要点Flowers_screen 宏用于处理不同批次 OLED 模块的显示偏移问题。老版模块内部有 2 列的偏移,新版模块正常。这是实际产品开发中常见的硬件兼容性处理。

4.3 画点函数

画点是所有图形操作的基础。核心是将 (x, y) 坐标映射到显存数组的正确位置:

void OLED_DrawPoint(unsigned char x, unsigned char y, unsigned char t)
{
    unsigned char pos, bx, temp = 0;
    if (x > 127 || y > 63) return;   // 边界保护

    pos = 7 - y / 8;      // 页号(从底部算起,因为 0xb0+0 对应顶部)
    bx = y % 8;            // 页内行号
    temp = 1 << (7 - bx);  // 对应位的掩码

    if (t)
        OLED_GRAM[x][pos] |= temp;    // 置位(点亮)
    else
        OLED_GRAM[x][pos] &= ~temp;   // 清位(熄灭)
}

坐标映射关键理解

  • y=0pos=7(Page 7 是屏幕顶部,因为 SSD1306 默认从顶部开始映射)
  • y=63pos=0(Page 0 是屏幕底部)
  • 每个字节中,bit 7 对应页内最上面一行,bit 0 对应最下面一行

4.4 字符显示

void OLED_ShowChar(uint8_t x, uint8_t y, uint8_t chr, uint8_t Char_Size)
{
    unsigned char c = chr - ' ';   // ASCII 偏移(字库从空格开始)

    if (x > Max_Column - 1) { x = 0; y = y + 2; }  // 自动换行

    if (Char_Size == 16) {
        // 16x16 字体:上半部分 8 字节写入当前页,下半部分 8 字节写入下一页
        OLED_Set_Pos(x, y);
        for (i = 0; i < 8; i++)
            OLED_WR_Byte(F8X16[c * 16 + i], OLED_DATA);    // 上半部
        OLED_Set_Pos(x, y + 1);
        for (i = 0; i < 8; i++)
            OLED_WR_Byte(F8X16[c * 16 + i + 8], OLED_DATA);  // 下半部
    } else {
        // 6x8 字体:6 字节写入单页
        OLED_Set_Pos(x, y);
        for (i = 0; i < 6; i++)
            OLED_WR_Byte(F6x8[c][i], OLED_DATA);
    }
}

设计要点

  • 16x16 字体的字库数组每个字符占 16 字节:前 8 字节为上半部分(对应一页),后 8 字节为下半部分(对应下一页)。写入时需要两次 OLED_Set_Pos() 分别定位到上下两页。
  • 6x8 字体的字库每个字符仅 6 字节,只占一页高度(8 像素),因此只需一次 OLED_Set_Pos()
  • 字库数组 F8X16[]F6x8[][]oledfont.h 中定义,通过 PCtoLCD2002 等字模软件生成。

4.5 汉字显示

void OLED_ShowCHinese(uint8_t x, uint8_t y, uint8_t no)
{
    uint8_t col;

    for (col = 0; col < 16; col++) {
        // 上半部分:逐列写入
        OLED_Set_Pos(x + col, y);
        OLED_WR_Byte(Hzk[no * 2][col], OLED_DATA);

        // 下半部分:逐列写入
        OLED_Set_Pos(x + col, y + 1);
        OLED_WR_Byte(Hzk[no * 2 + 1][col], OLED_DATA);
    }
}

字库结构解析:汉字字库 Hzk[][] 中,每个 16x16 汉字占 2 个 16 字节数组:

  • Hzk[no*2]:上半部分的 16 列数据
  • Hzk[no*2+1]:下半部分的 16 列数据

这种逐列存储的格式与 ASCII 字体的逐行存储格式(F8X16 是上 8 行 + 下 8 行)不同。汉字字库是按列方向组织的,每列一个字节(8 bit),16 列共 16 字节,所以每个汉字上下两部分各 16 字节。

4.6 数字与字符串

static uint32_t oled_pow(uint8_t m, uint8_t n)
{
    uint32_t result = 1;
    while (n--) result *= m;
    return result;
}

void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len, uint8_t size2)
{
    uint8_t t, temp;
    uint8_t enshow = 0;  // 前导零抑制标志

    for (t = 0; t < len; t++) {
        temp = (num / oled_pow(10, len - t - 1)) % 10;  // 提取每一位
        if (enshow == 0 && t < (len - 1)) {
            if (temp == 0) {
                OLED_ShowChar(x + (size2 / 2) * t, y, '0', size2);  // 前导零显示为'0'
                continue;
            } else {
                enshow = 1;
            }
        }
        OLED_ShowChar(x + (size2 / 2) * t, y, temp + '0', size2);
    }
}

前导零处理策略:当前实现中 enshow 标志用于在非最后一位遇到非零数字时激活显示。遇到前导零时显示 '0' 而非空格,这是一个设计选择——某些场景需要占位零,某些需要空格。根据需求修改即可。

4.7 图片与填充

void OLED_DrawBMP(unsigned char x0, unsigned char y0,
                  unsigned char x1, unsigned char y1, unsigned char BMP[])
{
    unsigned int j = 0;
    unsigned char x, y;

    if (y1 % 8 == 0) y = y1 / 8;
    else y = y1 / 8 + 1;

    for (y = y0; y < y1; y++) {
        OLED_Set_Pos(x0, y);
        for (x = x0; x < x1; x++) {
            OLED_WR_Byte(BMP[j++], OLED_DATA);  // 逐字节写入
        }
    }
}

注意:STM32 软件模拟 IIC 版本在 OLED_DrawBMP 中加入了 delay_ms(1) 延时,这是因为软件 IIC 速度较慢,连续大量写入可能导致从机来不及处理。


五、初始化序列详解

无论哪个平台,SSD1306 的初始化命令序列都相同。以下逐条解释每个命令的作用:

void OLED_Init(void)
{
    delay_ms(200);  // 等待 OLED 上电稳定(VCC 达到工作电压)

    // ① 关闭显示(先配置再开显示,避免配置过程中闪烁)
    OLED_WR_Byte(0xAE, OLED_CMD);

    // ② 设置列地址范围(初始化时通常设为 0~127 全范围)
    OLED_WR_Byte(0x00, OLED_CMD);  // 列低地址 = 0
    OLED_WR_Byte(0x10, OLED_CMD);  // 列高地址 = 0

    // ③ 设置显示起始行(0x40 = 从第 0 行开始)
    OLED_WR_Byte(0x40, OLED_CMD);

    // ④ 设置对比度(0x00~0xFF,越大越亮)
    OLED_WR_Byte(0x81, OLED_CMD);  // 对比度命令
    OLED_WR_Byte(0xCF, OLED_CMD);  // 对比度值 = 0xCF

    // ⑤ 段重映射(控制左右翻转)
    OLED_WR_Byte(0xA1, OLED_CMD);  // 0xA0=左右反置, 0xA1=正常

    // ⑥ COM 扫描方向(控制上下翻转)
    OLED_WR_Byte(0xC8, OLED_CMD);  // 0xC0=上下反置, 0xC8=正常

    // ⑦ 正常显示模式(非反色)
    OLED_WR_Byte(0xA6, OLED_CMD);  // 0xA6=正常, 0xA7=反色

    // ⑧ 设置复用率(屏幕行数)
    OLED_WR_Byte(0xA8, OLED_CMD);
    OLED_WR_Byte(0x3F, OLED_CMD);  // 64 行(0x3F = 64-1)

    // ⑨ 显示偏移(垂直滚动偏移)
    OLED_WR_Byte(0xD3, OLED_CMD);
    OLED_WR_Byte(0x00, OLED_CMD);  // 无偏移

    // ⑩ 时钟分频 / 振荡器频率
    OLED_WR_Byte(0xD5, OLED_CMD);
    OLED_WR_Byte(0x80, OLED_CMD);  // 分频比=1, 振荡器频率=1000

    // ⑪ 预充电周期
    OLED_WR_Byte(0xD9, OLED_CMD);
    OLED_WR_Byte(0xF1, OLED_CMD);  // Phase1=15, Phase2=1

    // ⑫ COM 引脚硬件配置
    OLED_WR_Byte(0xDA, OLED_CMD);
    OLED_WR_Byte(0x12, OLED_CMD);  // Sequential COM pin config

    // ⑬ VCOMH 电压
    OLED_WR_Byte(0xDB, OLED_CMD);
    OLED_WR_Byte(0x40, OLED_CMD);  // ~0.77V

    // ⑭ 电荷泵使能(必须开启,否则屏幕不亮)
    OLED_WR_Byte(0x8D, OLED_CMD);
    OLED_WR_Byte(0x14, OLED_CMD);  // 开启内部电荷泵

    // ⑮ 开启显示
    OLED_WR_Byte(0xAF, OLED_CMD);

    delay_ms(200);
    OLED_Clear();  // 初始化完成后清屏
}

不同模块的初始化差异:STM32 版本通过 OLED_30Pin 宏区分两种不同批次的模块。30Pin 模块和非 30Pin 模块的初始化序列在个别命令上有差异(如 VCOMH 值),实际使用时应根据具体模块调整。


六、驱动移植实战步骤

当需要将 OLED 驱动移植到新平台时,遵循以下步骤:

步骤 1:确定传输方式

  • 有硬件 IIC → 使用硬件 IIC(效率最高)
  • 无硬件 IIC → 软件 GPIO 模拟
  • 高速需求 → SPI 接口

步骤 2:实现传输层

只需实现一个函数:OLED_WR_Byte(dat, cmd)

// 伪代码:新平台的传输层实现
void OLED_WR_Byte(uint8_t dat, uint8_t cmd)
{
    uint8_t ctrl = cmd ? 0x40 : 0x00;  // 数据/命令控制字节
    // 调用平台的 IIC 写函数,发送 [ctrl, dat] 两个字节
    platform_iic_write(SLAVE_ADDR, ctrl, dat);
}

步骤 3:实现初始化函数

根据平台实现 OLED_Init(),内部只需:

  1. 初始化通信接口(IIC/SPI)
  2. 复制 SSD1306 初始化命令序列(跨平台通用)
  3. 调用 OLED_Clear()

步骤 4:直接复用逻辑层

OLED_ShowCharOLED_ShowStringOLED_ShowNumOLED_ShowCHineseOLED_DrawBMP 等函数直接复制,无需修改。

步骤 5:添加字库数据

oledfont.h(包含 ASCII 和汉字字库数组)加入工程。

步骤 6:编写应用代码

// 伪代码:应用层示例(所有平台通用)
OLED_Init();
OLED_Clear();
OLED_ShowString(0, 0, "Hello!", 16);
OLED_ShowNum(0, 2, 2026, 4, 16);
OLED_ShowCHinese(0, 4, 0);  // 显示字库中第 0 个汉字

七、工程技巧与踩坑总结

7.1 IIC 地址的三种表示

表示方式 使用场景
7 位地址 0x3C ESP-IDF、Linux ioctl、FPGA parameter
8 位写地址 0x78 = 0x3C << 1 STM32 软件 IIC
8 位读地址 0x79 = (0x3C << 1) | 1 读取操作(本文未涉及)

7.2 花屏问题处理

STM32 版本通过 Flowers_screen 宏处理不同批次模块的列偏移:

#define Flowers_screen 1   // 老版=0(偏移2列), 新版=1(无偏移)

如果移植后出现左偏 2 列的花屏现象,检查列地址低位的设置。

7.3 字模取模配置

使用 PCtoLCD2002 提取字模时,必须确保以下配置与驱动解析方式一致:

配置项 ASCII 字库 F8X16 汉字字库 Hzk
取模方式 阴码(1=亮点) 阴码
取模走向 逐行式 逐列式
位序 高位在前 高位在前
数据排列 上半8字节 + 下半8字节 上半16字节 + 下半16字节

如果不一致,显示出来的字符/汉字会乱码或旋转。

7.4 FPGA 特有注意事项

  • FPGA 的 IIC 状态机需要精确的时钟分频计算,确保 SCL 频率在 100kHz~400kHz 之间。
  • SDA 是双向信号(inout),需要三态门控制:发送时为输出,接收时为高阻。
  • FPGA 没有操作系统,延时通过时钟计数器实现,不存在 delay_ms()

7.5 Linux 权限问题

Linux 下访问 IIC 设备需要 root 权限或添加用户到 iic 组:

# 方法 1:sudo 运行
sudo ./oled_demo

# 方法 2:添加 udev 规则(永久解决)
echo 'KERNEL=="i2c-[0-9]*", MODE="0666"' | sudo tee /etc/udev/rules.d/99-i2c.rules
sudo udevadm control --reload-rules

八、四个平台的 API 对比速查表

函数 STM32 ESP32 Linux FPGA
初始化 OLED_Init() OLED_Init(i2c_num) OLED_Init(dev, addr) 复位后自动初始化
关闭 OLED_Close()
清屏 OLED_Clear() OLED_Clear() OLED_Clear() oled_clear_init 模块
显示字符 OLED_ShowChar(x,y,chr,size) 相同 相同 通过 s_data 总线传入
显示字符串 OLED_ShowString(x,y,*p,size) 相同 相同 同上
显示数字 OLED_ShowNum(x,y,num,len,size) 相同 相同 同上
显示汉字 OLED_ShowCHinese(x,y,no) 相同 相同 同上
显示图片 OLED_DrawBMP(x0,y0,x1,y1,BMP) 相同 相同 同上
画点 OLED_DrawPoint(x,y,t) 相同 相同 同上
填充区域 OLED_Fill(x1,y1,x2,y2,dot) 相同 相同 同上

从表中可以看出,除了初始化函数和 Linux 的关闭函数外,所有显示 API 在四个平台上完全一致。这就是分层设计的价值。


本文基于零涂科技团队实际项目代码整理,代码覆盖 STM32 HAL、ESP-IDF、Linux 用户空间、FPGA Verilog 四个平台的完整 OLED 驱动实现。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐