基于SSD1306的OLED显示实验:I2C时序与字模点阵实战解析
发布时间:2026/9/15 0:44:15来源:尧图网络
简介针对嵌入式初学者的OLED显示实验资源包基于STM32F10x平台完整演示字符、数字与汉字的点阵显示程序设计。压缩包共128个文件大小仅2.78MB以C/H源码作为主体涵盖标准外设库、OLED驱动源文件、字模数组与主控逻辑同时包含hex可执行文件、uvprojx工程配置、sct链接脚本、bat批处理等文件既可直接烧录观察效果也可用Keil打开工程进行单步跟踪。目前已有1812人学习下载适合正在学习I2C/SPI通信或嵌入式图形显示的中低年级学生、竞赛选手及开发者。通过该实验可理清SSD1306控制器的初始化、坐标设置与显存刷新流程重点掌握汉字编码到点阵数据的映射及逐行发送方法并能基于现有代码扩展自绘图形和动画为后续物联网终端人机界面开发积累实用经验。1. OLED显示实验到底在验证什么把OLED显示实验放在单片机学习的后半程不是因为它难而是因为它把三件事串在了一起I2C时序、显存映射、字模编码。你前面点灯、按键用的都是GPIO电平到了OLED屏幕上一个汉字要变成128×64点阵里的16×16个点程序要先跟控制器通信再把点阵写入显存。很多人在这一步第一次遇到“进程卡在某个while里出不来”的情况原因往往不是代码逻辑错而是I2C应答脚没拉好。这个实验的典型场景是一块0.96寸的OLED模块四个引脚接在单片机GPIO上开机点亮后先显示一两行ASCII字符再显示中文“温度”或“电压”最终把采集到的数据动态刷新到屏幕上。它解决的其实是嵌入式设备的人机交互问题耗电比LCD低、对比度高、不依赖背光所以智能硬件上你看到的很多小屏幕都是OLED。学这个实验的人一般是正在做STM32或51单片机课程的在校学生也可能是想给自己的传感器项目加个小屏的工程师。我见过不少人在这个实验上卡住不是显示器不亮就是显示出来全是乱码。乱码九成是字模取模方式不对不亮则是I2C地址或者初始化顺序有问题。所以这篇内容不写那些“照着抄就能跑”的苍白说明而是把显示链路上的每个环节拆开讲清楚。2. OLED显示实验的接口选型与硬件接线2.1 常见OLED模块的驱动芯片和通信接口市面上最常见的实验模块是0.96寸、128×64分辨率的OLED驱动芯片以SSD1306为主。SSD1306内部有128×64比特的显存RAM也就是说屏幕上的每个像素点都对应显存里的一个位写1就是点亮写0就是熄灭。单片机不需要直接控制每个像素的刷新时序只要通过I2C或SPI接口把显示数据写进SSD1306的显存芯片自己会周期性把显存内容扫到屏幕上这就是“显存映射”的基本工作方式。接口选型上多数模块板载了I2C和SPI两种接口的焊盘或电阻切换位置。我一般优先用I2C因为只需要两根信号线SCL和SDA而且SSD1306的I2C协议相对简单最大速率可以跑到400kbit/s对一屏静态内容完全够用。SPI的刷新速度更高适合需要频繁显示视频或高速动画的场景但要多占用复位、数据/命令选择等引脚接线数量是I2C的一倍。下表是我常用的三种显示实验方案对比接口类型信号线数典型驱动芯片适合场景缺点I2C2SSD1306静态文字、仪表盘、菜单高速刷新时有带宽瓶颈SPI 4线4SSD1306动态波形、动画引脚占用多8080并口8SSD1331大屏全彩、视频引脚占用巨大51单片机顶不住需要说明的是同样的SSD1306芯片I2C和SPI模式下的初始化寄存器地址并不完全相同。模块背面的电阻用来选择接口模式比如把某个电阻焊到R1位置就是I2C焊到R_non位置就是SPI。所以实验之前先看模块背面的丝印和电阻省得后面代码怎么调都点不亮。2.2 四根线的连接顺序和I2C地址位I2C型OLED模块通常引出四个引脚VCC、GND、SCL、SDA。有的模块还有第5根线RES但I2C模式下复位线可以悬空或者直接接到单片机的复位引脚上。接线顺序是OLED引脚接单片机说明VCC3.3V或5V多数模块带稳压但最好按模块说明供电GNDGND共地SCL任意空闲GPIO我常用PB8时钟线SDA任意空闲GPIO我常用PB9数据线I2C地址是最容易踩的坑。SSD1306在I2C模式下默认7位地址是0x3C但有些模块把地址引脚SA0拉高变成0x3D。在代码里写地址时还要注意I2C通信发送的是8位地址字节这个字节由7位地址加1位读写标志组成所以写操作地址是0x780x3C左移一位读操作地址是0x79。如果你用HAL库的HAL_I2C_Mem_Write地址参数填的是7位地址0x3C还是8位地址0x78取决于库函数内部是否做移位这在不同例程里写法不一样必须看头文件注释。我的建议是直接在代码里定义一个宏#define OLED_ADDR 0x3C // 7位地址HAL_Mem_Write接口使用我遇到过把0x3C换成0x78就能通讯的情况因为那个例程是直接用裸寄存器写地址字节的。所以排查不亮时第一件事不是怀疑单片机而是确认驱动代码里填的地址是7位还是8位。2.3 上电前最值得做的三个测量接线完成后别急着通电下载程序。先做三个检查能省掉后面至少两个小时的排错时间用万用表蜂鸣档测VCC和GND是否短路部分劣质模块焊接时容易桥接直接通电会把模块烧掉。确认VCC电压是3.3V还是5V。0.96寸模块很多是3.3V供电但板上带稳压芯片的话5V也可以只是稳压芯片会发热。如果模块上没有丝印说明按3.3V接最稳妥。用示波器或逻辑分析仪看SCL和SDA是否空闲为高电平。I2C总线必须靠上拉电阻保持高电平模块板上一般已经焊了4.7k欧姆的上拉电阻如果因为改装被拆掉通信会异常表现为初始化函数一直卡在等待应答。这些检查都做完再进入写程序的环节。OLED的调试特别奇怪有时候代码完全正确就是因为SDA线接触不良导致每次写显存时第一个字节丢失屏幕亮一半或者显示乱码。所以物理接线要压紧杜邦线尽量短。3. 用HAL库写OLED显示程序从初始化到点亮一块屏3.1 初始化GPIO和I2C时序的最小代码走HAL库路线时我一般不用STM32的硬件I2C外设而是用软件模拟I2C。原因很直接硬件I2C在调试时容易因为时钟延展或地址不匹配卡死软件I2C的代码可读性强出问题也容易用逻辑分析仪查。这个实验的目的是搞懂OLED显示逻辑不是研究STM32的I2C外设容错机制。GPIO初始化代码用CubeMX生成具体是把SCL和SDA所在的引脚都设为开漏输出上拉使能速度设为低速。开漏输出很关键因为I2C协议要求任何设备都可以把总线拉低开漏才能实现多设备共享总线。/* 在main.c里OLED相关GPIO初始化以PB8/PB9为例 */ void OLED_GPIO_Init(void) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin GPIO_PIN_8 | GPIO_PIN_9; // PB8SCL, PB9SDA gpio.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出I2C总线标准接法 gpio.Pull GPIO_PULLUP; // 内部上拉模块不带外部上拉时可兜底 gpio.Speed GPIO_SPEED_FREQ_LOW; // 低频足够减少干扰 HAL_GPIO_Init(GPIOB, gpio); }这段代码的逻辑是开漏模式让单片机引脚只能拉低或释放释放后由外部上拉电阻把电平拉高这样才符合I2C的线与逻辑。内部上拉是给调试时用的如果模块板上的上拉电阻已经存在内部上拉会略微降低总线阻抗但不会影响通信。速度设成低速是因为OLED不要求高速而且低速能减少线缆间的串扰杜邦线长度超过20厘米时尤其明显。接着是软件I2C最底层的四个函数启动、停止、发送字节、接收应答。#define SCL_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_RESET) #define SCL_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_8, GPIO_PIN_SET) #define SDA_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_RESET) #define SDA_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_9, GPIO_PIN_SET) static void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); HAL_Delay(1); // 保持建立时间 SDA_LOW(); // SCL为高时SDA拉低 起始条件 HAL_Delay(1); SCL_LOW(); // 拉低时钟准备发送数据 } static void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); HAL_Delay(1); SDA_HIGH(); // SCL为高时SDA拉高 停止条件 HAL_Delay(1); } static void I2C_WriteByte(uint8_t dat) { for (uint8_t i 0; i 8; i) { if (dat 0x80) SDA_HIGH(); else SDA_LOW(); dat 1; SCL_HIGH(); // 时钟上升沿对方采样数据 HAL_Delay(1); SCL_LOW(); // 拉低时钟准备下一位 HAL_Delay(1); } }每个HAL_Delay(1)是1毫秒整个字节发送大约需要8毫秒刷新一屏要写1024字节这样算下来刷新一帧要接近10秒肉眼可见的慢但适合教学理解。实际调试中我会把HAL_Delay(1)改成微秒级延时比如for (i0;i2;i);这样一帧刷新可以到几十毫秒。参数注意点是起始条件是SCL为高时SDA产生下降沿停止条件是SCL为高时SDA产生上升沿数据位则在SCL低电平期间变化高电平期间保持。只有采样到SCL上的上升沿时SSD1306才把SDA的电平读走。3.2 命令和数据的区分控制字节的作用SSD1306的I2C协议在每次数据帧前还要发一个控制字节。控制字节为0x00时后面跟的是命令为0x40时后面跟的是显存数据。很多初学者把控制字节漏掉直接把0xAE发给模块结果屏幕没反应因为芯片把0xAE当成了显存数据写入了显存里的一个非显示区域。void OLED_Write_Cmd(uint8_t cmd) { I2C_Start(); I2C_WriteByte(OLED_ADDR 1); // 写地址 I2C_WriteByte(0x00); // 当前字节是命令 I2C_WriteByte(cmd); // 命令本身 I2C_Stop(); } void OLED_Write_Data(uint8_t dat) { I2C_Start(); I2C_WriteByte(OLED_ADDR 1); // 写地址 I2C_WriteByte(0x40); // 当前字节是数据 I2C_WriteByte(dat); // 数据本身 I2C_Stop(); }OLED_ADDR 1这步就是前面说过的把7位地址左移一位变成8位写地址0x78。如果你用的HAL库HAL_I2C_Master_Transmit第一个参数地址同样要左移一位因为HAL内部不会自动移位。这里很多例程直接写0x78两者效果一致但写成OLED_ADDR 1能让代码同时兼容7位地址定义。3.3 初始化序列和显示字符的真正位置SSD1306上电后处于关闭显示状态必须发送一串初始化命令才能开启显示。命令序列网上很多但有几个字节不能随意删。以常用序列为例void OLED_Init(void) { OLED_GPIO_Init(); HAL_Delay(50); OLED_Write_Cmd(0xAE); // 关闭显示先配置再开屏 OLED_Write_Cmd(0x20); // 设置内存寻址模式 OLED_Write_Cmd(0x02); // 页寻址模式每页8像素行共8页 OLED_Write_Cmd(0xB0); // 设置显示起始页地址为Page0 OLED_Write_Cmd(0xC8); // 列扫描方向从上到下 OLED_Write_Cmd(0x40); // 设置显示起始行 OLED_Write_Cmd(0x81); // 设置对比度命令 OLED_Write_Cmd(0xCF); // 对比度值0xCF为默认值 OLED_Write_Cmd(0xA1); // 段重映射配合0xC8使用 OLED_Write_Cmd(0xA6); // 正常显示A7则是反显 OLED_Write_Cmd(0xA8); // 设置多路复用比 OLED_Write_Cmd(0x3F); // 64行0x3F即64 OLED_Write_Cmd(0xD3); // 设置显示偏移 OLED_Write_Cmd(0x00); // 偏移0行 OLED_Write_Cmd(0xD5); // 时钟分频和振荡器频率 OLED_Write_Cmd(0x80); // 默认值 OLED_Write_Cmd(0xD9); // 预充电周期 OLED_Write_Cmd(0xF1); OLED_Write_Cmd(0xDA); // COM引脚硬件配置 OLED_Write_Cmd(0x12); OLED_Write_Cmd(0xDB); // VCOMH稳压器输出 OLED_Write_Cmd(0x40); OLED_Write_Cmd(0xAF); // 开启显示 }0x20和0x02这两个字节如果写错最典型的表现是屏幕亮度正常但写入数据时每隔8个像素行就跳一块或者显示内容整体斜着。因为页寻址模式下显存被分成8个页每页高度是8个像素行写入数据会自动换列到第128列后换到下一页。如果设成水平寻址模式写入流程会变成自动换行字模显示逻辑就完全变了。字符显示的最底层函数是写一个8×16的点阵。这里需要先理解一个坐标关系屏幕宽128列对应显存地址0到127高64行分成8页每页8行。所以要在屏幕上某个位置画一个字符要先定位到页地址0xB0到0xB7再定位到列地址低4位和高4位。void OLED_SetPos(uint8_t page, uint8_t col) { OLED_Write_Cmd(0xB0 page); // 页地址范围0到7 OLED_Write_Cmd(0x00 (col 0x0F)); // 列地址低4位 OLED_Write_Cmd(0x10 ((col 4) 0x0F)); // 列地址高4位 } void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { uint8_t page y / 8; // 把y坐标换算成页 OLED_SetPos(page, x); for (uint8_t i 0; i 8; i) { OLED_Write_Data(F8X16[ch - ][i]); } OLED_SetPos(page 1, x); // 字符下半部分在下一页 for (uint8_t i 8; i 16; i) { OLED_Write_Data(F8X16[ch - ][i]); } }这里的F8X16是一个从ASCII码0x20开始的二维数组每个字符占16个字节前8字节显示上半部分后8字节显示下半部分。page y / 8是因为每页高8像素y坐标从0到7在第一页8到15在第二页。一次写16字节正好填满一个8×16字符块所以用这个函数时字符之间的x坐标间隔是8y坐标间隔是16否则字符会叠在一起。4. OLED显示汉字的原理与字模取参4.1 汉字显示的核心不是编码是点阵ASCII字符能靠内置的8×16数组显示是因为英文字符少每个字符用128个字节的字模表就够了。但汉字有几万个SSD1306内部没有中文字库所以实验里显示汉字本质是把汉字的字形转换成16×16或12×12的点阵数据然后像显示图片一样把这些点阵原样搬进显存。一个16×16汉字点阵在逐行式取模下每行16个点正好2字节共16行所以总大小为32字节。16×8的ASCII字符是8列×16行逐行式下每行1字节共16字节。这两个尺寸决定了汉字字模数组的形态const uint8_t hanzi_temp[32] { // 以“温”为例取模后生成的32个字节 0x01, 0x00, 0x01, 0x00, 0x21, 0xF8, 0x21, 0x08, 0x21, 0x08, 0x21, 0x10, 0x7F, 0xE0, 0x21, 0x00, 0x21, 0x10, 0x21, 0x08, 0x27, 0x08, 0x21, 0x04, 0x20, 0x04, 0x20, 0x04, 0x20, 0x14, 0x20, 0x08 };这里0x01, 0x00表示第一行的最左1个点和最右0个点0x21, 0xF8表示该行有两段连续点亮区域。把这个数组直接按16字节送到第page页、再按16字节送到第page1页屏幕的显示区域就会拼出汉字。注意汉字是16列写数据时列地址要连续增加16次然后换页再增加16次所以要用两个循环。4.2 取模软件里最关键的8个参数PCtoLCD2002是最常用的取模软件设置界面里一堆选项但真正影响显示结果的就8项。我常用的配置如下参数项推荐设置含义和坑点阵格式阴码1表示点亮0表示熄灭容易理解取模走向逐行式每行2字节和SSD1306页显存布局一致取模走向逆向从低位到高位即第一个字节的最低位对应最左点输出数制十六进制直接粘贴进C数组每行显示数16方便查看像素排列自定义格式C51与Keil/CubeIDE兼容去掉行号勾选避免复制时带出无用的行号前缀分隔符0x方便直接编译其中“逆向”这个参数最容易出问题。如果取模时选了“顺向”数据最低位对应屏幕最右点显示出来的汉字会镜像翻转。很多博客里的字模库显示乱码就是这个参数交叉使用造成的。逆向的时候一个16位行值的最高位写在第一字节的最高位最低位写在第二字节的最低位SSD1306接收数据时先收到的字节放高段所以必须配合逆向。还有一个容易被忽略的点取模时对话框里输入的“温”字编码方式要和单片机工程里的源文件编码一致。如果PC端用的是Windows下的GBK编码而你的STM32工程源文件是UTF-8那么同样的汉字在数组注释里显示正常但实际取模来源已经不同。我一般把工程源码编码也设置为GB2312或者在注释里不写汉字直接写拼音彻底避免编码不一致。4.3 在C代码里组织字模表实验里通常不止显示一个汉字而是显示“温度”“湿度”这类词组。常见做法是把所有要用的汉字字模按顺序放到一个二维数组里再用一个索引表去查。但更规范的做法是做一个“字模对应表”把汉字的内码和数组地址关联起来。typedef struct { const uint8_t *index; // 汉字点阵数据指针 char code[3]; // 汉字内码2字节结束符 } Font_TypeDef; // 这里只列出两个字 const Font_TypeDef font_table[] { { hanzi_temp_0, 温 }, { hanzi_temp_1, 度 }, { hanzi_temp_2, }, { NULL, } };显示字符串时先判断字符是不是ASCII码如果是ASCII码就查F8X16表如果是汉字就提取字符串里的两个字节用这两个字节和font_table里的code比较找到索引后取出字模。这个流程比把所有汉字放在一个大数组里按index直接指定要清晰得多。因为当你多加一个汉字时只改表格不用改显示函数。4.4 混合显示中文和数字的完整函数下面是实验中比较实用的混合显示函数骨架可以同时处理ASCII字母和GB2312汉字void OLED_ShowString(uint8_t x, uint8_t y, const char *str) { uint8_t page_base y / 8; while (*str) { if (*str 0x80) { // ASCII字符x坐标前进8像素 OLED_ShowChar(x, y, *str); x 8; } else { // 汉字从字符串取一个完整的GB2312内码 char hanzi[3]; hanzi[0] *str; hanzi[1] *str; hanzi[2] \0; for (uint8_t i 0; font_table[i].index ! NULL; i) { if (strcmp(font_table[i].code, hanzi) 0) { // 汉字16x16点阵上半页8字节下半页8字节 OLED_SetPos(page_base, x); for (uint8_t j 0; j 16; j) OLED_Write_Data(font_table[i].index[j]); OLED_SetPos(page_base 1, x); for (uint8_t j 16; j 32; j) OLED_Write_Data(font_table[i].index[j]); x 16; // 汉字占16像素宽度 break; } } } } }这个函数的判断条件*str 0x80是利用ASCII码最高位为0、汉字第一个字节最高位为1实现的。注意strcmp需要包含string.h如果不想引入标准库也可以直接用两个字节比较。汉字默认宽度是16而ASCII字符宽度是8坐标x在混排时必须以字宽为单位递增否则会出现字符重叠或错位。首次实现时建议固定y为第一页即y0或y16先跑通整行显示再研究跨页换行。5. OLED显示实验的排错技巧和动态显示进阶5.1 屏幕不亮或乱码时的排查顺序我把这个实验中经常遇到的现象和原因整理成一个检查序列按顺序排查基本能覆盖90%的问题现象优先检查项常见原因完全黑屏无字符电源、初始化顺序0xAF没发送或模块损坏有显示但亮度极低对比度寄存器0x81对比度值设成了0x00显示镜像/倒置段重映射0xA1和扫描方向0xC8这两个命令写反了汉字左右翻转取模的“逆向/顺向”取模参数与写数据顺序不匹配字符上下分裂页地址计算pagey/8写成了y%8不显示汉字但ASCII正常字模表索引strcmp内码与取模输入汉字编码不一致另外有两类“看起来正常但实际错乱”的情况要单独说。一是屏上出现重复的横向条纹通常是I2C通信时序过快SSD1306没来得及处理。这时把传输字节里的延时加大比如从1微秒加到10微秒条纹会消失。二是第一次显示正常第二次调用显示函数后屏幕彻底变黑多半是显示函数开头没有清除显存数据写满后读写指针越界把控制寄存器覆盖了。建议每次刷新前先调用一次清屏函数按页把0x00写入整个显存。5.2 用反显和水平滚动做出仪表盘效果实验做到最后通常不会满足于静态显示常用的进阶功能是反显和滚动。反显的命令是0xA7也就是把显示内容黑白反转。这个功能非常适合做菜单选中效果默认用0xA6正常显示当光标移动到某项时先调用一个只有该项背景色区域的填充函数再调用0xA7就能实现高亮选中。注意反显命令影响整个屏幕不是局部反显所以实际项目里是通过先擦除项内容、再画黑底白字模拟局部高亮的。水平滚动的命令配置如下void OLED_Scroll_Horizontal(uint8_t start_page, uint8_t end_page) { OLED_Write_Cmd(0x2E); // 先关闭滚动 OLED_Write_Cmd(0x26); // 右滚动左滚动命令为0x27 OLED_Write_Cmd(0x00); // 保留字节必须为0 OLED_Write_Cmd(start_page); OLED_Write_Cmd(0x07); // 垂直滚动偏移由SEG扫描方向决定 OLED_Write_Cmd(end_page); OLED_Write_Cmd(0x00); // 保留字节 OLED_Write_Cmd(0xFF); // 滚动帧间隔约6帧一张 OLED_Write_Cmd(0x2F); // 开启滚动 }启用滚动后0x26和0x27的差异在于方向帧间隔为0xFF时滚动速度最快但肉眼依然能看到移动过程。这个命令不需要不断刷新显存SSD1306会自动把显存内容滚动所以对单片机来说几乎零开销。但要注意滚动模式下不要同时执行逐页写入因为滚动是硬件行为会打断正常显存更新造成闪烁。5.3 用逻辑分析仪确认IIC通信协议时序调试OLED显示程序到最后最好抓一次I2C时序图确认地址、控制字节和数据字节确实按照预期发送。最简单的办法是使用逻辑分析仪在没有逻辑分析仪时也可以把OLED_Write_Cmd里的HAL_Delay(1)临时改成50毫秒观察屏幕是否以肉眼可见的速度分段刷新以此确认通信是否完整。逻辑分析仪抓到波形后按以下三个位置核对起始位必须是SCL高电平时SDA下跳沿地址字节第一位是0写方向控制字节与命令字节交替出现。正常波形中会看到连续三段完整的数据帧第一段帧里总共有三个字节地址0x78、控制0x00、命令0xAE。如果抓到的地址字节是0x79说明代码里把读写位搞反了如果控制字节总为0x00那说明数据写入路径错了。最后一个小技巧是把字模数组里的数据直接在串口助手打印出来对比取模软件生成的十六进制文本。很多“显示乱码”其实不是通信问题而是复制字模时漏了一两个字节导致后续所有字模错位。这个对比法比盯着屏幕猜乱码原因高效得多也是我把这个实验讲给同事时一定会提到的验证方式。本文还有配套的精品资源点击获取
网站建设高端定制企业官网