前言
在使用 STM32 驱动 SSD1306 OLED 时,很多新手都会遇到汉字错位、爱心旋转、图案乱码等问题,而这些问题的根源,往往不是硬件接线,而是对 OLED 页寻址模式、PCtoLCD2002 取模规则、显示函数底层逻辑的不理解。
今天我将基于你提供的完整工程,从硬件配置、字模原理、驱动源码到显示函数逐行拆解,带你吃透 16x16 汉字 / 图标显示的本质,让你不仅能写出正确的代码,还能自己定制任意图案,告别 "玄学调参"。
一、基础配置与原理
1. 硬件与开发环境
- 主控:STM32F103(标准库)
- 外设:0.96 寸 I2C 接口 SSD1306 OLED 屏
- 引脚分配:PB6 = SCL、PB7 = SDA
- 字模工具:PCtoLCD2002
- 寻址模式:SSD1306 默认页寻址模式
2. PCtoLCD2002 取模配置(适配本驱动)
确定驱动唯一正确的取模方式,任何一个参数改动都会导致图案错位:
| 参数 | 配置 | 含义 |
|---|---|---|
| 点阵格式 | 阴码 | 数据位为 1 时点亮像素,为 0 时熄灭 |
| 取模方式 | 列行式 | 先按列从上到下取 8 个点组成 1 字节,再向右取下一列 |
| 取模走向 | 逆向(低位在前) | 每列最上方的点对应字节的 bit0,最下方对应 bit7 |
| 点阵大小 | 16×16 | 单个汉字 / 图标占 16 行 ×16 列像素 |
3. 16x16 字模存储规则
SSD1306 屏幕的显存按 "页" 管理,1 页对应 8 行像素,因此 16×16 的图案需要分上下两部分存储:
- 上半部分(第 0~7 行):16 列,每列 1 字节,共 16 字节
- 下半部分(第 8~15 行):16 列,每列 1 字节,共 16 字节✅ 因此,任意一个 16x16 的汉字 / 图标,固定占用 32 字节,数组中按 "上 16 字节→下 16 字节" 的顺序排列。
二、完整工程源码
1 、oled.c 驱动文件
#include "stm32f10x.h"
#include "oled.h"
#include "tim.h"
#include "codetab.h"
/**
* @brief I2C1 外设初始化(PB6=SCL,PB7=SDA)
* @note 配置为复用开漏模式,400kHz快速I2C通信
*/
void I2C_Configuration(void)
{
I2C_InitTypeDef I2C_InitStructure;
GPIO_InitTypeDef GPIO_Initstructure;
// 开启GPIOB和I2C1外设时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1 , ENABLE);
// 配置PB6、PB7为复用开漏模式(I2C必须使用开漏)
GPIO_Initstructure.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_Initstructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_Initstructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_Initstructure);
// 初始化I2C1
I2C_DeInit(I2C1);
I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; // 使能应答
I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; // 7位地址
I2C_InitStructure.I2C_ClockSpeed = 400000; // 400kHz快速模式
I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; // 占空比2:1
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; // 标准I2C模式
I2C_InitStructure.I2C_OwnAddress1 = 0x30; // 主机地址(可自定义)
I2C_Init(I2C1, &I2C_InitStructure);
I2C_Cmd(I2C1, ENABLE);
}
/**
* @brief I2C底层写一个字节(含寄存器地址和数据)
* @param addr OLED寄存器地址(0x00为命令,0x40为数据)
* @param data 要发送的命令或数据
*/
void I2C_WriteByte(uint8_t addr,uint8_t data)
{
while( I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY) ); // 等待I2C总线空闲
I2C_GenerateSTART(I2C1, ENABLE); // 产生起始信号
while( !I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT) ); // 等待主机模式确认
I2C_Send7bitAddress(I2C1, OLED_ADDRESS, I2C_Direction_Transmitter); // 发送OLED设备地址
while( !I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED) );
I2C_SendData(I2C1, addr); // 写入寄存器地址(命令/数据选择)
while( !I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED ) );
I2C_SendData(I2C1, data); // 写入实际数据
while( !I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED ) );
I2C_GenerateSTOP(I2C1, ENABLE); // 产生停止信号
}
/**
* @brief 向OLED写入命令
* @param I2C_Command 要写入的命令字
*/
void WriteCmd(unsigned char I2C_Command)
{
I2C_WriteByte(0x00,I2C_Command); // 0x00表示后续为命令
}
/**
* @brief 向OLED写入显示数据(像素点数据)
* @param I2C_Data 要写入的像素数据
*/
void WriteData(unsigned char I2C_Data)
{
I2C_WriteByte(0x40,I2C_Data); // 0x40表示后续为显示数据
}
/**
* @brief OLED初始化(SSD1306标准配置序列)
* @note 配置为页寻址模式,开启电荷泵,设置对比度等
*/
void OLED_Init(void)
{
delay_ms(100); // 等待OLED上电稳定
WriteCmd(0xAE); // 关闭显示
WriteCmd(0x20); // 设置内存地址模式
WriteCmd(0x02); // 选择页寻址模式(本驱动核心)
WriteCmd(0xb0); // 设置页起始地址
WriteCmd(0xc8); // COM扫描方向倒置
WriteCmd(0x00); // 列地址低4位
WriteCmd(0x10); // 列地址高4位
WriteCmd(0x40); // 显示起始行
WriteCmd(0x81); // 对比度设置
WriteCmd(0xff); // 亮度最大值
WriteCmd(0xa1); // 段地址映射(左右翻转)
WriteCmd(0xa6); // 正常显示模式(非反色)
WriteCmd(0xa8); // 多路复用比设置
WriteCmd(0x3F); // 1/64占空比
WriteCmd(0xa4); // 输出跟随RAM数据
WriteCmd(0xd3); // 显示偏移设置
WriteCmd(0x00); // 无偏移
WriteCmd(0xd5); // 时钟分频因子设置
WriteCmd(0xf0); // 分频比
WriteCmd(0xd9); // 预充电周期设置
WriteCmd(0x22);
WriteCmd(0xda); // COM引脚硬件配置
WriteCmd(0x12);
WriteCmd(0xdb); // VCOMH电压设置
WriteCmd(0x20);
WriteCmd(0x8d); // 电荷泵设置
WriteCmd(0x14); // 开启电荷泵(必须,否则屏幕不亮)
WriteCmd(0xaf); // 开启显示
}
/**
* @brief 设置OLED光标位置(页寻址模式专用)
* @param x 列坐标(0~127)
* @param y 页号(0~7,1页=8行像素)
*/
void OLED_Setpos(unsigned char x,unsigned char y)
{
WriteCmd(0xb0 + y); // 选择当前页(0xb0~0xb7对应页0~7)
WriteCmd((x&0xf0)>>4|0x10); // 列地址高4位(x的高4位 + 0x10前缀)
WriteCmd(x & 0x0F); // 列地址低4位(x的低4位)
}
/**
* @brief 全屏填充指定数据(亮屏/清屏)
* @param Fill_Data 填充数据(0x00为全灭,0xFF为全亮)
*/
void OLED_Fill(unsigned char Fill_Data)
{
unsigned char m,n;
for(m=0;m<8;m++) // 遍历所有8个页
{
WriteCmd(0xb0+m); // 设置当前页
WriteCmd(0x00); // 列地址低4位为0
WriteCmd(0x10); // 列地址高4位为0,即从列0开始
for(n=0;n<128;n++) // 写入128列数据
{
WriteData(Fill_Data);
}
}
}
/**
* @brief 清屏(全屏置0)
*/
void OLED_Close(void)
{
OLED_Fill(0x00);
}
/**
* @brief 开启OLED显示(唤醒电荷泵和屏幕)
*/
void OLED_ON(void)
{
WriteCmd(0X8D); // 电荷泵设置
WriteCmd(0X14); // 开启电荷泵
WriteCmd(0XAF); // 开启显示
}
/**
* @brief 关闭OLED显示(休眠模式)
*/
void OLED_OFF(void)
{
WriteCmd(0X8D); // 电荷泵设置
WriteCmd(0X10); // 关闭电荷泵
WriteCmd(0XAE); // 关闭显示
}
/*********************************************************
* @brief 16x16汉字/图标显示函数(核心重点)
* @param x 起始列坐标(0~127)
* @param y 起始页号(0~7,控制上下位置)
* @param N 字模在F16X16数组中的索引(从0开始)
* @note 适配PCtoLCD2002列行式、逆向、阴码取模
*********************************************************/
void OLED_ShowCN(unsigned char x,unsigned char y,unsigned char N)
{
unsigned char wn = 0;
unsigned int addr = 32*N; // 每个16x16图案固定32字节,计算偏移地址
// 第一步:写入上半部分(上8行)
OLED_Setpos(x,y); // 定位到起始列x、起始页y
for(wn=0;wn<16;wn++) // 循环写入16列数据(每列1字节,共16列)
{
WriteData(F16X16[addr]); // 写入当前列的像素数据
addr+=1; // 地址+1,准备写下一列
}
// 第二步:写入下半部分(下8行)
OLED_Setpos(x,y+1); // 页号+1,切换到下一页(下8行像素)
for(wn=0;wn<16;wn++) // 继续写入16列数据,填满下8行
{
WriteData(F16X16[addr]);
addr+=1;
}
}
2、核心函数 OLED_ShowCN 深度解析
这是整个工程的灵魂,也是最容易出错的地方,我们拆成 5 步,讲透每一行代码的作用。
① 函数参数说明
| 参数 | 含义 | 控制效果 |
|---|---|---|
x |
起始列坐标(0~127) | 控制图案在屏幕上的左右位置 |
y |
起始页号(0~7) | 控制图案在屏幕上的上下位置(1 页 = 8 行像素) |
N |
字模在数组中的索引 | 选择要显示的图案(从 0 开始编号) |
②执行逻辑分步拆解
步骤 1:计算字模偏移地址
unsigned int addr = 32*N;
- 原理:每个 16x16 图案固定占用 32 字节,通过索引
N直接偏移,精准定位到当前要显示的字模起始位置。 - 示例:N=0(爱心)→ addr=0;N=1(李)→ addr=32,避免数组越界和数据错位。
步骤 2:定位上半部分的起始坐标
OLED_Setpos(x,y);
- 调用
OLED_Setpos函数,将 OLED 的写入光标定位到x列、y页。 - 此时,后续写入的数据将从该位置开始,依次向右填充 16 列,对应上 8 行像素。
步骤 3:写入上半部分(上 8 行)数据
for(wn=0;wn<16;wn++)
{
WriteData(F16X16[addr]);
addr+=1;
}
- 循环 16 次,每次写入 1 个字节,对应 1 列的 8 个像素。
- 写入顺序:第 x 列→第 x+1 列→...→第 x+15 列,刚好填满 16 列、上 8 行的区域。
- 数据来源:
F16X16[addr]到F16X16[addr+15],即字模数组的前 16 字节。
步骤 4:切换到下半部分(下 8 行)
OLED_Setpos(x,y+1);
- 关键操作:页号
y+1,因为上半部分已经用了第y页(8 行像素),下半部分必须用第y+1页,才能拼接成完整的 16 行像素。 - 列坐标保持不变,确保上下两部分对齐,图案不会错位。
步骤 5:写入下半部分(下 8 行)数据
for(wn=0;wn<16;wn++)
{
WriteData(F16X16[addr]);
addr+=1;
}
- 继续循环 16 次,写入字模数组的后 16 字节,填满下 8 行、16 列的区域。
- 上下两部分拼接,最终形成完整的 16×16 汉字 / 图标。
2、 main.c 主函数
#include "stm32f10x.h"
#include "main.h"
#include "stdio.h"
#include "sg90.h"
#include "oled.h"
/**
* @brief 简易软件延时函数(约1ms延时,基于72MHz主频)
* @param time 延时毫秒数
*/
void delay(uint16_t time)
{
uint16_t i = 0;
while(time --)
{
i = 12000;
while(i --);
}
}
int main(void)
{
unsigned char i = 0;
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 中断优先级分组
// 初始化外设
I2C_Configuration();
OLED_Init();
delay(2000); // 等待OLED初始化稳定
// 屏幕测试:全亮→全灭
OLED_Fill(0XFF); // 全屏点亮
delay(2000);
OLED_Fill(0X00); // 全屏清屏
delay(2000);
// 循环显示数组内的16x16图案(从索引0开始,依次排列)
for(i=0; i<6; i++)
{
// 每个图案占16列,起始列依次+16,实现水平排列
OLED_ShowCN(22+i*16, 0, i);
}
while(1)
{
// 主循环,可添加其他任务
}
}
3、 codetab.h 字模数组文件
#ifndef _CODETAB_H
#define _CODETAB_H
// 16x16字模数组,每32字节为一个完整图案
unsigned char F16X16[] =
{
// 爱心♥ 索引0(32字节)
0x00,0xF8,0xFC,0xFE,0xFE,0xFC,0xF8,0xF0,0xF8,0xFC,0xFE,0xFE,0xFC,0xF8,0x00,0x00,
0x00,0x03,0x07,0x0F,0x1F,0x3F,0x7F,0xFF,0x7F,0x3F,0x1F,0x0F,0x07,0x03,0x00,0x00,
// 汉字"李" 索引1(32字节)
0x80,0x84,0x44,0x44,0x24,0x14,0x0C,0xFF,0x0C,0x14,0x24,0x44,0x44,0x84,0x80,0x00,
0x08,0x08,0x08,0x08,0x09,0x49,0x89,0x79,0x0D,0x0B,0x09,0x08,0x08,0x08,0x08,0x00,
};
#endif
三、工程使用规范与拓展
- 新增图案 :只需在
codetab.h中按 "32 字节一组" 的格式追加字模,无需修改驱动代码。 - 多字符排列 :多个图案并排显示时,起始列
x每次 + 16 即可,刚好错开不重叠。 - 修改图案位置 :调整
y参数可以控制图案的上下位置,例如y=1会让图案显示在屏幕第 8~15 行。 - 拓展功能 :可以在
OLED_ShowCN的基础上,封装字符串显示函数,实现自动换行、居中显示等效果。