51单片机驱动0.96寸OLED屏幕——从IIC协议到OLED移植完整指南

前言

在嵌入式开发中,OLED屏幕凭借其高对比度、低功耗和显示清晰等优点,成为了单片机项目中最常用的显示设备之一。而对于51单片机开发者来说,驱动一块0.96寸的IIC接口OLED屏幕,是掌握通信协议和显示控制的绝佳入门项目。

本文将基于中景园电子提供的OLED代码demo,从硬件连接到IIC协议原理,从驱动代码移植到显示原理,逐层剖析整个技术栈。无论你是刚开始学习51单片机的新手,还是想深入理解IIC协议和OLED驱动原理的开发者,这篇文章都会给你带来收获。

一、硬件篇:认识你的OLED屏幕

1.1 屏幕规格与引脚定义

市面上常见的0.96寸OLED屏幕,分辨率为128×64像素,驱动芯片为SSD1306。屏幕模组通常引出4个引脚:

引脚 名称 功能 连接
1 GND 电源地 接单片机GND
2 VCC 电源正极 接单片机5V(兼容3.3V)
3 SCL IIC时钟线 接单片机P1.5
4 SDA IIC数据线 接单片机P1.4

注意:部分屏幕模块的引脚顺序可能不同(如VCC和GND位置互换),接线前请务必确认模块上的丝印标记。

1.2 上拉电阻------一个容易被忽视的关键细节

IIC总线是开漏输出 架构,这意味着单片机引脚只能将总线拉低 (输出0),而不能主动拉高 (输出1)。总线的高电平状态完全依赖于连接在SDA和SCL线上的上拉电阻

上拉电阻的阻值选择直接影响通信稳定性:

  • 阻值过大(如10KΩ以上) :总线上升沿时间变长,在高速模式下可能导致建立时间不足

  • 阻值过小(如1KΩ以下) :当总线被拉低时电流过大,增加功耗甚至可能损坏IO口

建议:使用4.7KΩ到10KΩ的上拉电阻。如果你的OLED模块已经板载了上拉电阻(大部分模块都有),则无需额外添加。

1.3 SSD1306驱动芯片简介

SSD1306是OLED屏幕的核心驱动芯片,负责:

  1. 通过IIC/SPI接口接收单片机发送的数据和命令

  2. 将数据存储到内部的GDDRAM(图形显示数据RAM)中

  3. 驱动OLED像素点阵,将显存内容显示在屏幕上

SSD1306的显存大小为128×64 = 8192 bit = 1024字节。这1024字节的显存被组织成8页(PAGE0~PAGE7) ,每页包含128列(SEG0~SEG127),每列对应1个字节(8个像素点)。

关键理解:向OLED发送1个字节的数据,实际上控制的是同一列上连续的8个像素点(同一页内的8行)。字节的bit0对应最上面的像素,bit7对应最下面的像素。

二、IIC协议详解------从时序到代码

2.1 什么是IIC?

IIC(Inter-Integrated Circuit)总线是飞利浦公司开发的一种简单、双向、二线制、同步串行总线。它只需要两根线:

  • SCL(Serial Clock Line) :串行时钟线,由主机(单片机)产生,用于同步数据传输

  • SDA(Serial Data Line) :串行数据线,用于双向传输数据

IIC是半双工 通信------同一时间只能单向传输数据。但它的优势在于:只需要两根线就能挂载多达127个设备,每个设备都有唯一的7位地址。

2.2 IIC的四种核心信号

(1)空闲状态

当总线空闲时,SDA和SCL都处于高电平

(2)起始信号(START)

起始信号的定义:在SCL为高电平时,SDA从高电平跳变到低电平。

代码实现(来自iic.c):

cpp 复制代码
void IIC_Start(void)
{
    IIC_SCL = 1;      // SCL拉高
    IIC_SDA = 1;      // SDA拉高
    Delay5us();       // 短暂延时
    IIC_SDA = 0;      // SDA拉低------产生下降沿
    Delay5us();
    IIC_SCL = 0;      // SCL拉低,准备传输数据
}
(3)停止信号(STOP)

停止信号的定义:在SCL为高电平时,SDA从低电平跳变到高电平。

代码实现:

cpp 复制代码
void IIC_Stop(void)
{
    IIC_SCL = 1;      // SCL拉高
    IIC_SDA = 0;      // SDA拉低
    Delay5us();
    IIC_SDA = 1;      // SDA拉高------产生上升沿
}
(4)数据传输与应答

数据传输的核心原则:

  • SCL低电平期间:允许SDA数据变化

  • SCL高电平期间:SDA数据必须保持稳定(此时数据被采样)

数据高位(MSB)在前 ,逐位传输。每传输完一个字节(8位),接收方需要在第9个时钟周期回复一个应答信号(ACK)

  • ACK=0(低电平) :表示成功接收

  • ACK=1(高电平) :表示未成功接收(NACK)

代码实现(发送一个字节):

cpp 复制代码
void IIC_Send_Byte(unsigned char IIC_Byte)
{
    char i;
    for(i = 0; i < 8; i++)
    {        
        IIC_SDA = IIC_Byte & (0x80 >> i);  // 从高位开始发送
        IIC_SCL = 1;                       // SCL拉高,数据被采样
        Delay5us();
        IIC_SCL = 0;                       // SCL拉低,准备下一位
        Delay5us();        
    }
}

2.3 SSD1306的IIC从机地址

根据SSD1306数据手册,IIC通信的第一个字节必须是从机地址+读写位

b7 b6 b5 b4 b3 b2 b1 b0
0 1 1 1 1 0 SA0 R/W#
  • 高7位(0b011110 + SA0)是从机地址

  • SA0由D/C#引脚决定:SA0=0时地址为0x3C(写)/0x3D(读);SA0=1时地址为0x3D(写)/0x3E(读)

  • R/W#=0表示写,R/W#=1表示读

大多数OLED模块将SA0固定为0,因此写地址为0x78(0x3C << 1) 。代码中使用的正是这个地址:

cpp 复制代码
Write_IIC_Byte(0x78);  // 从机地址 + 写位

2.4 命令与数据的区分

SSD1306通过IIC接收两种类型的数据:

  1. 命令(Command) :控制屏幕的工作模式、亮度、寻址方式等

  2. 数据(Data) :要显示在屏幕上的像素内容

在IIC通信中,发送完从机地址后,需要发送一个控制字节来区分后续是命令还是数据:

cpp 复制代码
Write_IIC_Byte(0x00);  // 后续是命令(Co=0, D/C#=0)
Write_IIC_Byte(0x40);  // 后续是数据(Co=0, D/C#=1)

代码中通过OLED_CMD(0)和OLED_DATA(1)来区分:

cpp 复制代码
#define OLED_CMD  0   // 写命令
#define OLED_DATA 1   // 写数据

三、OLED驱动移植------代码分层解析

3.1 代码的整体架构

整个项目采用了清晰的分层设计:

cpp 复制代码
┌─────────────────────────────────────────────┐
│              应用层 (main.c)                 │
│   显示汉字、字符串、数字、图片               │
├─────────────────────────────────────────────┤
│             驱动层 (oled.c)                  │
│   初始化、清屏、画点、显示字符/图片          │
├─────────────────────────────────────────────┤
│            协议层 (iic.c)                    │
│   IIC起始、停止、发送字节、接收应答          │
├─────────────────────────────────────────────┤
│             硬件层 (main.h)                  │
│   SCL=P1.5, SDA=P1.4 引脚定义               │
└─────────────────────────────────────────────┘

3.2 硬件层------引脚定义

main.h中定义了IIC引脚:

cpp 复制代码
sbit IIC_SCL = P1^5;
sbit IIC_SDA = P1^4;

oled.h中进一步封装为宏函数:

cpp 复制代码
#define OLED_SCLK_Clr() OLED_SCL=0
#define OLED_SCLK_Set() OLED_SCL=1
#define OLED_SDIN_Clr() OLED_SDIN=0
#define OLED_SDIN_Set() OLED_SDIN=1

这种宏定义的方式提高了代码的可读性和可移植性------如果要换引脚,只需修改一处定义。

3.3 协议层------IIC通信函数

iic.c实现了IIC协议的核心函数:

函数 功能
IIC_Start() 产生起始信号
IIC_Stop() 产生停止信号
IIC_Send_Byte() 发送1个字节
IIC_Recv_Ack() 接收应答信号

oled.c在此基础上封装了面向OLED的函数:

cpp 复制代码
void Write_IIC_Command(unsigned char IIC_Command)
{
    IIC_Start();
    Write_IIC_Byte(0x78);      // 从机地址
    IIC_Wait_Ack();    
    Write_IIC_Byte(0x00);      // 控制字节:命令
    IIC_Wait_Ack();    
    Write_IIC_Byte(IIC_Command); 
    IIC_Wait_Ack();    
    IIC_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();
}

最终统一为OLED_WR_Byte(dat, cmd)函数:

cpp 复制代码
void OLED_WR_Byte(unsigned dat, unsigned cmd)
{
    if(cmd)
        Write_IIC_Data(dat);
    else
        Write_IIC_Command(dat);
}

3.4 驱动层------OLED初始化

OLED初始化是屏幕正常工作的前提。OLED_Init()函数发送了一串初始化命令序列:

cpp 复制代码
void OLED_Init(void)
{
    OLED_WR_Byte(0xAE, OLED_CMD);  // 关闭显示
    OLED_WR_Byte(0x00, OLED_CMD);  // 设置低列地址
    OLED_WR_Byte(0x10, OLED_CMD);  // 设置高列地址
    OLED_WR_Byte(0x40, OLED_CMD);  // 设置起始行地址
    OLED_WR_Byte(0xB0, OLED_CMD);  // 设置页地址
    OLED_WR_Byte(0x81, OLED_CMD);  // 对比度控制
    OLED_WR_Byte(0xFF, OLED_CMD);  // 对比度值
    OLED_WR_Byte(0xA1, OLED_CMD);  // 段重映射
    OLED_WR_Byte(0xA6, OLED_CMD);  // 正常显示(非反显)
    OLED_WR_Byte(0xA8, OLED_CMD);  // 设置多路复用率
    OLED_WR_Byte(0x3F, OLED_CMD);  // 1/64 duty
    OLED_WR_Byte(0xC8, OLED_CMD);  // COM扫描方向
    OLED_WR_Byte(0xD3, OLED_CMD);  // 设置显示偏移
    OLED_WR_Byte(0x00, OLED_CMD);
    OLED_WR_Byte(0xD5, OLED_CMD);  // 设置时钟分频
    OLED_WR_Byte(0x80, OLED_CMD);
    OLED_WR_Byte(0xD8, OLED_CMD);  // 设置区域颜色模式
    OLED_WR_Byte(0x05, OLED_CMD);
    OLED_WR_Byte(0xD9, OLED_CMD);  // 设置预充电周期
    OLED_WR_Byte(0xF1, OLED_CMD);
    OLED_WR_Byte(0xDA, OLED_CMD);  // 设置COM引脚配置
    OLED_WR_Byte(0x12, OLED_CMD);
    OLED_WR_Byte(0xDB, OLED_CMD);  // 设置Vcomh
    OLED_WR_Byte(0x30, OLED_CMD);
    OLED_WR_Byte(0x8D, OLED_CMD);  // 设置电荷泵
    OLED_WR_Byte(0x14, OLED_CMD);  // 使能电荷泵
    OLED_WR_Byte(0xAF, OLED_CMD);  // 开启显示
}

关键命令解析

命令 功能 说明
0xAE 关闭显示 初始化期间关闭显示,避免异常闪烁
0xAF 开启显示 初始化完成后开启显示
0x81 + 0xFF 设置对比度 值越大亮度越高
0xA1 段重映射 列地址与SEG的映射关系
0xC8 COM扫描方向 行地址与COM的映射关系
0xA8 + 0x3F 多路复用率 64行对应1/64 duty
0xD5 + 0x80 时钟分频 设置显示时钟频率
0x8D + 0x14 电荷泵使能 内部升压,让3.3V能驱动OLED

注意:初始化命令的顺序很重要,顺序错了屏幕可能无响应。

3.5 驱动层------显存与显示函数

坐标设置
cpp 复制代码
void OLED_Set_Pos(unsigned char x, unsigned char y)
{
    OLED_WR_Byte(0xb0 + y, OLED_CMD);                 // 设置页地址(0~7)
    OLED_WR_Byte(((x & 0xf0) >> 4) | 0x10, OLED_CMD); // 设置列高地址
    OLED_WR_Byte((x & 0x0f), OLED_CMD);               // 设置列低地址
}
  • y的范围是0~7,对应8页(每页8行)

  • x的范围是0~127,对应128列

显示字符

字符显示依赖于字模数据。oledfont.h中包含了两种字体的字模:

  • 6×8字体 :用于显示ASCII字符(F6x8数组)

  • 8×16字体 :用于显示ASCII字符(F8X16数组)

  • 16×16汉字 :用于显示汉字(Hzk数组)

以16×16字体显示字符为例:

cpp 复制代码
void OLED_ShowChar(u8 x, u8 y, u8 chr, u8 Char_Size)
{
    unsigned char c = chr - ' ';  // 获取偏移值
    if(Char_Size == 16)
    {
        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); // 下半部分
    }
}
显示汉字

16×16的汉字需要32个字节的数据(16列×2页):

cpp 复制代码
void OLED_ShowCHinese(u8 x, u8 y, u8 no)
{
    OLED_Set_Pos(x, y);
    for(t = 0; t < 16; t++)
        OLED_WR_Byte(Hzk[2*no][t], OLED_DATA);    // 上半部分
    OLED_Set_Pos(x, y + 1);
    for(t = 0; t < 16; t++)
        OLED_WR_Byte(Hzk[2*no+1][t], OLED_DATA);  // 下半部分
}
显示BMP图片

OLED_DrawBMP()函数用于显示128×64的整屏图片:

cpp 复制代码
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;
    for(y = y0; y < y1; y++)
    {
        OLED_Set_Pos(x0, y);
        for(x = x0; x < x1; x++)
        {
            OLED_WR_Byte(BMP[j++], OLED_DATA);
        }
    }
}

BMP1[]BMP2[]数组就是预先取模好的图片数据(存放在bmp.h中)。

3.6 应用层------main函数

main.c展示了OLED的典型使用流程:

cpp 复制代码
int main(void)
{
    OLED_Init();          // 1. 初始化OLED
    OLED_Clear();         // 2. 清屏
    
    // 3. 显示内容
    OLED_ShowCHinese(0, 0, 0);   // 中
    OLED_ShowCHinese(18, 0, 1);  // 景
    // ... 更多显示内容
    
    while(1)
    {
        // 4. 循环显示动态内容
        OLED_ShowChar(48, 6, t, 16);
        t++;
        delay_ms(80);
        OLED_DrawBMP(0, 0, 128, 8, BMP1); // 显示图片
        delay_ms(80);
    }
}

四、取模软件的使用

4.1 什么是取模?

取模是将字符或图形转换为二进制点阵数据的过程。每个字节的8个位(bit)对应8个像素点------1表示点亮,0表示熄灭。

4.2 常用取模软件

  • PCtoLCD2002:最常用的取模软件

  • 字模提取 V2.1:另一款常用工具

4.3 取模设置要点

使用取模软件时,需要注意以下设置:

  1. 像素大小:ASCII字符通常用8×16或6×8;汉字用16×16

  2. 取模方向:需要与驱动代码的显示方向匹配(一般选择"纵向取模、字节倒序"或"横向取模")

  3. 输出格式:选择C语言数组格式

4.4 图片取模

对于128×64的整屏图片:

  1. 准备一张128×64分辨率的单色BMP图片

  2. 使用取模软件打开图片

  3. 设置阈值为128(将灰度图转换为黑白二值图)

  4. 生成C语言数组------共1024个字节

bmp.h中的BMP1[]BMP2[]就是通过这种方式生成的。

五、常见问题与解决方案

5.1 屏幕不亮

可能原因 解决方案
电源问题 检查VCC和GND是否接反,电压是否为3.3V~5V
上拉电阻缺失 确认模块是否板载上拉电阻,没有则外接4.7KΩ
引脚接错 确认SCL→P1.5,SDA→P1.4
初始化失败 确认初始化命令序列是否正确

5.2 显示乱码或位置不对

可能原因 解决方案
字模方向不匹配 检查取模软件的设置是否与驱动代码匹配
坐标计算错误 确认OLED_Set_Pos()的x(0~127)和y(0~7)范围
段/COM映射错误 检查初始化中的0xA1和0xC8命令

5.3 通信不稳定

可能原因 解决方案
延时不够 增加Delay5us()的延时时间
总线电容过大 减小上拉电阻值(如用3.3KΩ)
电源纹波 在VCC和GND之间加一个10uF电容

六、总结

本文从硬件连接到IIC协议,从驱动代码到显示原理,系统地介绍了51单片机驱动0.96寸OLED屏幕的完整过程。核心要点可以概括为:

核心流程:51单片机通过软件模拟IIC协议,向SSD1306驱动芯片发送命令和数据。命令用于配置屏幕的工作模式,数据则是要显示的像素内容。SSD1306将接收到的数据存入GDDRAM显存,并自动刷新到OLED屏幕上。

代码分层:硬件层(引脚定义)→ 协议层(IIC时序)→ 驱动层(OLED命令/数据)→ 应用层(显示内容)。

关键理解

  • IIC是半双工同步串行总线,需要上拉电阻

  • SSD1306的显存是1024字节,组织为8页×128列

  • 1个字节控制8个像素点(同一列上的8行)

  • 命令和数据通过控制字节(0x00/0x40)区分

掌握了这些知识,你不仅能用好这块OLED屏幕,更能举一反三,理解其他IIC设备(如传感器、EEPROM等)的驱动原理。希望这篇教程能帮助你在嵌入式开发的道路上更进一步!