【自学嵌入式:stm32单片机】OLED显示屏与程序调试方法(软件模拟I2C通信)

OLED显示屏

image

  • OLED(Organic Light Emitting Diode):有机发光二极管
  • OLED显示屏:性能优异的新型显示屏,具有功耗低、响应速度快,宽视角、轻薄柔韧等特点:
  • 0.96寸OLED模块:小巧玲珑、占用接口少、简单易用,是电子设计中非常常见的显示屏模块
  • 供电:3~5.5V
  • 通信协议:I2C/SPI
  • 分辨率:128*64

第一个图是四个针脚的版本,是这篇文章用到的,还有7针脚版本的,占用的IO口就多一些,除了有白色像素的版本,还有黄蓝双色的版本,4针脚用I2C通信协议,7针脚屏幕用SPI通信协议

单片机调试方式

  • 串口调试:通过串口通信,将调试信息发送到电脑端,电脑使用串口助手显示调试信息
  • 显示屏调试:直接将显示屏连接到单片机,将调试信息打印在显示屏上,刷新信息方便
  • Keil调试模式:借助Kei软件的调试模式,可使用单步运行、设置断点、查看寄存器及变量等功能

OLED硬件电路

image
SCL 和 SDA需要接在单片机I2C通信的引脚上或者用IO口模拟I2C通信(本文就是这样)
image

I2C通信

I2C通信内容详见:https://www.cnblogs.com/qinruiqian/p/19017678
此处就是注意这里通信有个co位,如下图,绿色框表示命令位置,蓝色表示传送的字节,绿色框可能指定后面要发的字节是数据还是指令,如果co为1,就只能一个绿色框一个蓝色框这么传,如果co为0,绿色框指定是指令还是数据,后面蓝色框的内容都统一为绿色框指定的数据还是指令,命令和数据不能灵活切换,这个被称为连续模式,连续模式比较麻烦,一般不用
image

SSD1306

image

本文使用的OLED屏幕就是用SSD1306芯片控制和扫描的。
SSD1306是一款OLED/PLED点阵显示屏的控制器,可以嵌入在屏幕中,用于执行接收数据、显示存储、扫描刷新等任务。

  • 驱动接口:128个SEG引脚和64个COM引脚,对应128*64

  • 像素点阵显示屏(GDDRAM):12864bit(1288 Byte)SRAM内置显示存储器

  • 供电:VDD=1.65~3.3V(IC逻辑),VCC-7~15V(面板驱动)

  • 通信接口:8位6800/8080并行接口,3/4线SPI接口,I2C接口

本文用的是4针脚的版本

SSD1306框图及引脚定义

image

引脚 功能
VDD、VCC、VSS、VLSS 供电
VDD=1.65~3.3V,VCC=7~15V ,VDD是用于屏幕逻辑,VCC是电源驱动,屏幕厂商已经在屏幕里面加了升压电路,所以VCC可以不接,所以接一组电源就行了
D0~D7 6800/8080:8位双向数据总线
3/4线SPI:D0为SCLK,D1为SDIN
I2C:D0为SCL,D1为SDAin,D2为SDAout,把D1和D2接在一起就是I2C通信的SDA数据输入输出线
BS0~BS2 选择通信接口
R/W#(WR#) 6800:R/W#,指定读/写操作
8080:WR#,写使能
E(RD#) 6800:E,读/写使能
8080:RD#,读使能
D/C# 6800/8080/4线SPI:指定传输数据/指令
I2C:SA0,指定I2C从机地址最低位,要是有两个I2C通信的从机,要配置以下D/C#,防止地址冲突,
CS# 片选
RES# 复位

与单片机的通信引脚

image
如上图,左侧部分就是这个屏幕与单片机通信的引脚,所印出来的线就是SSD1306和单片机通信协议的引脚,想要与这个屏幕通信,就要按字节为单位向SSD1306发送指令,这个芯片的规定是你发给它一个字节后可以指定发送的数据还是指令,如果是命令,会进入到下图的命令译码器。

命令(指令)译码器

image
命令译码器控制内部电路的执行,比如开启显示,关闭显示,设置对比度,设置显示起始位置等,芯片内定义的有一个命令表(指令集),参照这个命令表,就能直到这个命令是什么功能。

GDDRAM

image
如上图,如果传来的是数据,就会存储到RAM处理器GDDRAM,
传来的数据的字节决定了屏幕上显示什么内容,只要我们把对应内容写入到GDDRAM的对应位置就好,所以我们只需要关注写入到GDDRAM的正确位置就型。

显示控制器

image
如上图,这个是显示控制器,它读取GDDRAM,把GDDRAM的内容扫描刷新到屏幕上。所以控制器就是显示屏的驱动器,下图中,中间是段驱动器
image
输出引脚是SEG0~SEG127,接到点阵的128列,上下两部分是公共端驱动器,输出引脚是COM0~COM63,接到点阵的64行。

振荡器

image
如上图,蓝色圈的内容是振荡器,用于屏幕刷新的时钟,CL是时钟输入脚,CLS(Clock Select)是时钟源选择脚,这个芯片内部自带时钟,但也可以通过CLS选择,CLS接高电平,选择内部时钟,CL引脚不用,CLS接低电平,选择外部时钟,CL加一个外部时钟源,我们一般用内部时钟,使用简单。

电流控制和电压控制

image
蓝圈中的内容就是电流控制和电压控制模块,VCOMH是公共端的电压输出,一般在外面接个滤波电容就行,IREF是驱动电流控制,在外面接个电阻,可以控制驱动屏幕的电流,因为不同厂商生产的屏幕,所需驱动电流可能不一样,如下图的蓝圈的接线:
image
手册里有计算公式,我们不需要过多关心

TE

image
如上图,TE是芯片的测试点,我们用不到

通信接口选择及通信线定义

image
对应引脚的电平不同,决定了使用哪种通信,比如本文要使用I2C通信,则BS0低电平,BS1高电平,BS2低电平,也就是下图原理图蓝色圈位置的接线:
image

CS

image
如上图,片选,接低电平,表示始终选中芯片

DC

image
如上图,DC在I2C模式下,用于配置从机地址,左侧两个电阻,一个接高电平,一个接低电平,这两个电阻只需焊接一个,不能同时接
image
如上图,它是这样设计的,三个焊盘一个电阻,焊在左边,地址是3C,焊在右边,地址是3D,但是
image
如上图,注释写的地址是78和7A,和PCB上标记的不一致,这时因为3C和3D是从机地址的直接形式,78和7A是从机地址左移1位后的形式,因为I2C通信,第一个数据帧传地址,地址只有7位,我手里这个就是地址0X78

RES

image
如上图,RES复位接到这个上电复位电路,

D0,D1,D2

image
如上图,D0是SCL,通过排针引出来
image
如上图,D1和D2接在一起,当作SDA,通过排针印出来
SCL和SDA各接一个4.7K的上拉电阻,为了方便I2C通信开漏输出用

未用到的引脚

image
如上图,这些引脚没用到,接GND

VCOMH

image
如上图,VCOMH接滤波电容

VCC

image
如上图,VCC,面板驱动的供电脚,本来要接7到15V的驱动电压,但这里没有接供电,只有一个电源滤波,所以我们要用内部的升压电路提供VCC

VDD

image
如上图,VDD是逻辑供电,接在单片机那个3.3V的VCC上,

显示逻辑图

image
每8行为一页,进行分页,具体可看这两位大佬的博客,我也是从他们的文章那里学习,然后看江科大视频自学的:
https://www.cnblogs.com/chengerccj/p/15004723.html
https://blog.csdn.net/qianniuwei321/article/details/127486445
具体内容可看这两个博客,我补充点内容:每次传送的某一页某一段竖着8个像素点,正好一个字节,LSB低位在上,MSB高位在下
image
这样的好处是充分利用分页,一次性写入8个像素点,效率也比较高,坏处就是y轴坐标只能8个为一组进行指定,不能在0~63之间,任意指定

如果想实现y轴任意指定,那就得用读取GDDRAM的能力,并行模式下,可以读取GDDRAM,串行模式下读不了,但是可以在程序中定义缓存数组实现,先读取缓存数组,最后再一起更新到屏幕的GDDRAM(比如本文的字库就是这么做的),最后再一起更新到屏幕的GDDRAM里。

命令表(指令集)

  • 通过写命令时序传输的字节,作为发送给SSD1306的一个命令
  • SSD1306查询命令表的定义,执行相应的操作
  • 命令可以由一个字节或者连续的多个字节组成
  • 命令可分为基础命令、滚屏命令、寻址命令、硬件配置命令、时间及驱动命令5大类
    image
    具体命令表看这个大佬的文章:
    https://www.cnblogs.com/chengerccj/p/15004723.html

接线图

image
我没完全这么连,看了一下电路图用杜邦线连的
图里的VCC GND是直接引到了PB6和PB7,直接不初始化这两个引脚就行(如果你是按图中这么连的)

I2C通信驱动OLED屏幕

可以去看我51单片机系列文章对I2C通信的介绍:https://www.cnblogs.com/qinruiqian/p/19017678
按波特率100K进行传输,I2C遵循开漏输出,先写一个软件模拟的I2C通信的代码

软件模拟I2C通信

MYI2C.h

#ifndef __MYI2C_H
#define __MYI2C_H

void MYI2C_Init(void); //初始化I2C通信
void MYI2C_Start(void); //开始I2C通信
void MYI2C_Close(void); //关闭I2C通信
void MYI2C_WriteByte(uint8_t Byte); //向I2C总线写一个字节数据
void MYI2C_ReadByte(void); //向I2C总线读一个字节
uint8_t MYI2C_ReadAck(void); //读取I2C总线确认帧
void MYI2C_WriteAck(uint8_t Ack_bit); //向I2C总线发送确认帧

#endif

MYI2C.c

#include "stm32f10x.h"                  // Device header
#include "Delay.h"

//设置SCL时钟和SDA数据线的宏定义
#define MYI2C_SCL(x) GPIO_WriteBit(GPIOB, GPIO_Pin_8, (BitAction)(x))
#define MYI2C_SDA(x) GPIO_WriteBit(GPIOB, GPIO_Pin_9, (BitAction)(x))
#define MYI2C_GET_SDA() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_9)

//初始化软件模拟I2C通信
void MYI2C_Init(void)
{
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
	GPIO_InitTypeDef GPIO_InitStructure;
 	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; //开漏输出
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; //SCK时钟引脚
 	GPIO_Init(GPIOB, &GPIO_InitStructure);
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; //SDA数据引脚
 	GPIO_Init(GPIOB, &GPIO_InitStructure);
	//开始SCL时钟和SDA数据都是高电平
	MYI2C_SCL(1);
	MYI2C_SDA(1);
}

//开始I2C通信
void MYI2C_Start(void)
{
	//100波特率,SCL时钟周期不能小于10微秒
	//stm32执行一条语句1 / 72MHz ≈ 13.89ns
	//可忽略不计,和51单片机不同,51单片机执行一条语句约1微秒
	//所以这里的延迟都延迟5微秒
	//先都拉高
	MYI2C_SCL(1);
	MYI2C_SDA(1);
	//SCL时钟高电平保持5us不变,SDA下降沿,5us电平翻转一次
	Delay_us(5);
	MYI2C_SDA(0);
	//拉低SCL 5us
	Delay_us(5);
	MYI2C_SCL(0);
}

//关闭I2C通信
void MYI2C_Close(void)
{
	//先都拉低
	MYI2C_SCL(0);
	MYI2C_SDA(0);
	Delay_us(5);
	//先拉高SCL,5us电平翻转
	MYI2C_SCL(1);
	Delay_us(5);
	//再拉高SDA,5us电平翻转
	MYI2C_SDA(1);
	Delay_us(5);
}

//向I2C总线写一个字节的数据
void MYI2C_WriteByte(uint8_t Byte)
{
	uint8_t i = 0;
	for(i = 0; i < 8; i++)
	{
		//获取最高位看是0还是1
		if(Byte & 0x80)
		{
			MYI2C_SDA(1);
		}
		else
		{
			MYI2C_SDA(0);
		}
		Delay_us(5); //5us电平翻转
		MYI2C_SCL(1);
		Delay_us(5); //5us电平翻转
		MYI2C_SCL(0);
		//SCL时钟线从低开始,然后先高后低,读取SDA上的一位比特
		Byte <<= 1; //左移一位,方便下次读取最高位
	}
}

//从I2C总线上读一个字节的数据
void MYI2C_ReadByte(void)
{
	uint8_t i = 0;
	uint8_t Byte = 0x00;
	for(i = 0; i<8 ;i++)
	{
		//拉高SDA,开漏模式,电平交给总线(总线有个电阻上拉)
		MYI2C_SDA(1);
		MYI2C_SCL(1); //拉高时钟SCL,准备接收SDA
		Delay_us(5); //延迟5us电平翻转
		if(MYI2C_GET_SDA()) //为1,则写1,为0则还保持0,用或等于写进去
		{
			Byte |= (0x80>>i);
		}
		MYI2C_SCL(0); //拉低SCL,完成读取一个字节
	}
}

//读取I2C总线的应答信号
uint8_t MYI2C_ReadAck(void)
{
	uint8_t Ack_bit; //应答信号
	MYI2C_SDA(1); //把SDA拉高,释放I2C,让从机把应答信号发在总线上
	Delay_us(5);
	MYI2C_SCL(1);
	Delay_us(5);
	Ack_bit = MYI2C_GET_SDA();
	Delay_us(5);
	MYI2C_SCL(0); //拉低SCL,结束接收应答
	return Ack_bit;
}

//发送应答信号
void MYI2C_WriteAck(uint8_t Ack_bit)
{
	//根据应答状态量决定SDA是1还是0
	if(Ack_bit)
	{
		MYI2C_SDA(1);
	}
	else
	{
		MYI2C_SDA(0);
	}
	//释放SCL
	MYI2C_SCL(1);
	//再拉低
	MYI2C_SCL(0);
}

OLED屏幕驱动代码实现

用标准库实现

代码太多了,不一一列举了,请到我的开源仓库去找(4-1OLED显示屏),我是自己实现的OLED显示屏驱动,利用了江科大提供的ASCII字符的字库,还有一个我做的只有“测试”两个字的中文字库,代码中有详细的注释:https://gitee.com/qin-ruiqian/jiangkeda-stm32

用HAL库实现

IDE设置

image
设置PB8 PB9引脚默认高电平的开漏输出
image

代码实现

由于IDE配置好引脚生产了代码,此处I2C通信初始化就变得很简单,代码逻辑和标准库差不多
依然新建Hardware文件夹
image
在项目-属性中,把生成16进制和二进制文件勾选
image
然后还是在项目-属性中,把Hardware目录添加到include的目录中,让编译器识别
image
注意,HAL的Delay是ms级别的,延迟通信,我们需要延迟的是5us,实际上不延迟也行,因为延迟要用到定时器,目前我还没学到stm32的定时器操作,我就直接把所有的Delay去掉了,这样以一个非标准的波特率传输,实际不会影响
代码太多,还是开源到我的另一个仓库:https://gitee.com/qin-ruiqian/jiangkeda-stm32-hal

Python将二值化图片转为GDDRAM缓存数组的脚本代码

我画了一个只因的二值化图片,如下:
IKUN
为了能让它转换为对应的GDDRAM的数组,我让AI生成了如下Python代码:

from PIL import Image
import numpy as np


def png_to_ssd1306_hex(image_path):
    """
    将128x64的PNG图片转换为SSD1306 OLED屏幕的16进制数据格式
    黑色对应0,白色对应1

    参数:
        image_path: PNG图片路径

    返回:
        按页组织的16进制数据列表,每个页包含128个字节
    """
    # 打开图片并转换为128x64尺寸,转为灰度图
    with Image.open(image_path) as img:
        # 调整图片大小为128x64
        img = img.resize((128, 64), Image.LANCZOS)
        # 转换为单通道灰度图
        img_gray = img.convert('L')
        # 转换为numpy数组 (64行,128列)
        img_array = np.array(img_gray)

        # 二值化处理 (大于127的视为白色/亮,赋值为1,否则为黑色/暗,赋值为0)
        img_binary = (img_array >= 128).astype(np.uint8)

    # SSD1306有8个页,每个页8像素高,128列宽
    num_pages = 8
    page_data = []

    for page in range(num_pages):
        # 计算当前页对应的像素行范围
        start_row = page * 8
        end_row = start_row + 8

        # 存储当前页的数据
        current_page = []

        for col in range(128):
            # 计算当前列、当前页的8个像素组成的字节值
            byte_value = 0
            for bit in range(8):
                row = start_row + bit
                # 如果像素为1(白色),则设置对应的位
                if img_binary[row, col] == 1:
                    byte_value |= (1 << bit)

            current_page.append(byte_value)

        page_data.append(current_page)

    return page_data


def print_hex_data(data):
    """以格式化的方式打印16进制数据"""
    print("[")
    for i, page in enumerate(data):
        # 转换为16进制字符串,确保两位格式
        hex_strings = [f"0x{byte:02X}" for byte in page]
        # 每16个元素换一行,增加可读性
        line_parts = []
        for j in range(0, len(hex_strings), 16):
            line = ", ".join(hex_strings[j:j + 16])
            line_parts.append(f"    {line}")

        page_str = ",\n".join(line_parts)
        # 最后一页不添加逗号
        if i == len(data) - 1:
            print(f"  [{page_str}]")
        else:
            print(f"  [{page_str}],")
    print("]")


if __name__ == "__main__":
    import sys

    if len(sys.argv) != 2:
        print("用法: python png_to_ssd1306.py <图片路径>")
        print("示例: python png_to_ssd1306.py image.png")
        sys.exit(1)

    image_path = sys.argv[1]
    try:
        oled_data = png_to_ssd1306_hex(image_path)
        print("转换后的SSD1306 OLED数据 (黑色=0, 白色=1):")
        print_hex_data(oled_data)
    except Exception as e:
        print(f"转换失败: {str(e)}")
        sys.exit(1)

OLED屏幕驱动显示效果

显示数字和字符串等

image

显示只因

image

posted @ 2025-08-11 12:08  秦瑞迁  阅读(145)  评论(0)    收藏  举报