1. 多平台(STM32、ESP32、Linux、FPGA)模块驱动开发: 0.96寸OLED
基于同一款 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_Port、OLED_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 位写地址0x78(0x3C << 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_FREQ和I2C_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=0→pos=7(Page 7 是屏幕顶部,因为 SSD1306 默认从顶部开始映射)y=63→pos=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(),内部只需:
- 初始化通信接口(IIC/SPI)
- 复制 SSD1306 初始化命令序列(跨平台通用)
- 调用
OLED_Clear()
步骤 4:直接复用逻辑层
将 OLED_ShowChar、OLED_ShowString、OLED_ShowNum、OLED_ShowCHinese、OLED_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 驱动实现。
更多推荐

所有评论(0)