简介:OLED驱动代码面向嵌入式开发者和电子爱好者,基于SSD1306/SSD1315控制器,通过I2C或SPI接口完成显示屏初始化、像素绘制、字符与图形显示等功能,可以帮助快速搭建小型显示屏项目的底层驱动。压缩包共4个文件,包括oled.c、oled.h、font.c、font.h等2个C源文件与2个头文件,显示逻辑与字库资源分离存放,目录结构简洁,便于直接移植或二次开发,整体体积仅13KB,非常轻量。已有355人学习下载。代码覆盖控制器寄存器配置、显示模式设置、对比度调整和中英文字符显示等关键环节,接口按模块化思路封装,注重可读性与可移植性;无论是初学者对照学习驱动编写思路,还是有经验的开发者迁移到不同硬件平台,都能快速上手复用。
1. 一份能直接点屏的 OLED 驱动代码:SSD1306/SSD1315 实战解读
手头有块 0.96 寸、128×64 的 OLED 屏,主控是 SSD1306 或 SSD1315,网上搜到的驱动代码要么只有初始化序列、没有画点函数,要么把 I2C 时序封装成了一坨没法调试的黑匣子。这份驱动代码的价值在于它不是 Demo,是一套可以直接烧进 STM32/51/ESP32 跑起来的完整驱动目录:包含 I2C 底层、显存管理、画点画线、字符汉字显示,同时兼容 SSD1306 和 SSD1315 两种主控。对刚接触这类屏的开发者,照这份代码能少踩三五个坑;对想改造成自由协议、双缓存刷新的熟手,目录和模块分层也留好了扩展口。下面按我拆项目的习惯,从架构到改法一层层说清。
2. 驱动架构拆解:I2C 时序、显存模型与模块分层
2.1 你能拿到的代码目录长什么样
解压后先看目录结构,这份驱动的组织方式是「硬件抽象层 + 中间控制层 + 应用接口层」三段式。OLED 初始化、写命令、写数据这些直接操作寄存器的逻辑都收在底层,画点画圆、显示字符串、显示图片这类业务逻辑放在上层,两层之间用一组函数指针和结构体解耦。
oled/ ├── i2c_hal.c/i2c_hal.h // 硬件 I2C 抽象层,屏蔽具体芯片平台 ├── oled_core.c/oled_core.h // SSD1306/SSD1315 初始化序列与显存管理 ├── oled_gfx.c/oled_gfx.h // 画点、画线、画矩形、显示图片 ├── oled_utf8.c/oled_utf8.h // ASCII 字符与汉字点阵渲染 ├── fonts/ // 取模后的点阵字库,ASCII 和汉字分开存放 └── example/main.c // 一个从初始化到动态显示的完整例程文件职责分得很干脆:i2c_hal 只做一件事——把「起始信号 + 地址 + 数据 + 停止信号」这条时序发出去;oled_core 只关心屏幕的初始状态和显存缓冲区的读写;oled_gfx 和 oled_utf8 不碰寄存器,只往显存里填数据。这种分层的好处是,你想把 I2C 换成 SPI 屏,只需要替换 i2c_hal.c,上层一行都不用改。
底层接口统一成三个核心函数,OLED 的所有操作最终都会落到这三个函数上:
// i2c_hal.h 核心接口,所有平台只需实现这三个函数 void i2c_hal_init(void); // 初始化 I2C 外设(速率、引脚) void i2c_hal_write_cmd(uint8_t cmd); // 发送控制字节 + 命令字节 void i2c_hal_write_data(uint8_t *buf, uint16_t len); // 发送控制字节 + 数据载荷看到这里你可能已经注意到 i2c_hal_write_data 需要传长度,这对应 SSD1306 的显存连续写模式——一次 I2C 突发传输最多可以写 128 字节的显存行数据,比逐字节写快一个数量级。这是这份驱动在设计上比较聪明的一点:底层预留的是批量接口,而不是傻瓜式的一字节一写。
2.2 SSD1306 与 SSD1315:两个主控的差别在初始化命令,不在驱动框架
SSD1306 和 SSD1315 是两颗引脚兼容、寄存器大部分互通的控制芯片。SSD1306 是市场存量最大的 OLED 驱动 IC,SSD1315 是它的精简版,常见于现在出货量很大的低成本 0.96 寸模组。它们在驱动代码上的关键差异集中在初始化序列的几条命令上。
| 对比项 | SSD1306 | SSD1315 |
|---|---|---|
| 内部电荷泵 | 需要显式开启 charge pump | 部分模组硬件电路已固定,开启命令无效或反而异常 |
| 显示时钟分频 | 0x80 兼容性最好 | 0x80 也兼容,但部分批次需要调低频率防闪烁 |
| VCOMH 电平 | 常规设置 0x2A | 部分模组建议 0x02,否则对比度偏低 |
| I2C 地址 | 0x3C/0x3D 可选 | 大部分模组固定 0x3C |
代码里做兼容的常见做法是:在 oled_core.h 里留一个宏开关,用条件编译切两套初始化序列。这份代码的 oled_core.c 里就是这么处理的——宏 OLED_USE_SSD1315 置 0 走 SSD1306 的电荷泵开启逻辑,置 1 则跳过该命令。有些翻车现场就是直接拿 SSD1306 的初始化序列驱动 SSD1315 模组,画面初始化后全黑,不是写命令时序错了,而是模组内部电路已经把电荷泵电压固定了,你再写开启命令反而把电位拉乱。
2.3 显存模型:一页 8 像素,块写比点写快得多
SSD1306 的显存寻址方式比较特殊,它不是一维数组,而是分页模式。纵向 64 像素分成 8 页(page 0 到 page 7),每页对应 8 行像素,横向 128 个点对应 128 个字节,每个字节的 bit0 到 bit7 从上到下映射这 8 行。也就是说,整屏显存是 128×8 = 1024 个字节。
这份驱动在 oled_core.c 里维护了一个 8×128 的二维数组作为影子显存,叫 OLED_GRAM。所有绘图操作先画到这块内存区域,再通过刷屏函数整体推到屏幕。这种方式有两个直接收益:一是画点不卡顿——操作内存是微秒级,而 I2C 发一帧全屏数据在 400kHz 下大概是 20 毫秒级;二是可以做局部刷新——哪部分变了刷哪部分,而不必每画一个点就重传一整屏数据。
OLED_WriteFullScreen 这个函数的实现核心就是一个 for 循环把 8 页的数据全量搬到 SSD1306 的显存:
// 刷全屏:从 OLED_GRAM 拷贝到 SSD1306 内部显存 void OLED_WriteFullScreen(void) { uint8_t page, col; for (page = 0; page < 8; page++) { i2c_hal_write_cmd(0xB0 + page); // 设置页地址:0xB0~0xB7 i2c_hal_write_cmd(0x00); // 设置列地址低 nibble i2c_hal_write_cmd(0x10); // 设置列地址高 nibble i2c_hal_write_data(&OLED_GRAM[page][0], 128); // 连续写一页数据 } }代码逻辑很直白:外层循环切换页地址,内层通过一次突发传输把 128 字节全写进去。注意三个写命令的顺序——先定页、再定列低四位、最后定列高四位,缺一不可。这里最容易踩坑的是列地址的拆分:SSD1306 的列地址是 7 位,拆成低 4 位放 0x00~0x0F,高 3 位放 0x10~0x1F,新手经常只写 0x00 不写 0x10,结果屏幕内容错位成一列乱码。
2.4 I2C 总线通信协议:一个 0x00 和一个 0x40 的区别
用 I2C 驱动 SSD1306/SSD1315 时,总线上的每个传输帧由「控制字节 + 数据字节」组成。控制字节是紧跟从机地址后的第一个字节:0x00 表示后续字节是命令,0x40 表示后续字节是显存数据。这份驱动把控制字节的拼接放到了 i2c_hal 层内部,上层调用 OLED_WriteCmd 时只需要传入纯命令值。
从机地址 0x78 是 7 位地址 0x3C 左移一位得到的字节,在代码里一般定义成宏:
#define OLED_ADDR 0x78 // SSD1306/SSD1315 I2C 地址:7位地址0x3C左移1位把地址左移一位是因为 I2C 总线协议要求 7 位地址后面必须跟一个读写位,所以在驱动代码里写 0x78 而不是 0x3C,是直接在字节层面把「写位 0」拼进去。这里有相当一部分开发者会写错:直接用 0x3C 发出去,屏幕完全无响应。排查时可以拿逻辑分析仪看波形,正常情况下 SDA 上发出的第一个字节应该是 0x78,你要是看到 0x3C,问题就出在这里。
3. 核心代码逐行拆解:初始化、画点与汉字渲染的完整实现
3.1 初始化序列:六条命令让屏幕从黑到亮
OLED 驱动里最容易让人困惑的就是初始化序列——为什么会有十几条命令?去掉哪些能亮?这份代码的初始化部分提取了最小可用集合,拆开看每一条的作用就很清晰了。
// oled_core.c 中的最小初始化序列 void OLED_Init(void) { // 1. 开启内部电荷泵(SSD1315需要跳过,由硬件固定) i2c_hal_write_cmd(0x8D); // 电荷泵控制命令 i2c_hal_write_cmd(0x14); // 开启电荷泵(0x10为关闭) // 2. 设置显示时钟分频比和振荡器频率 i2c_hal_write_cmd(0xD5); i2c_hal_write_cmd(0x80); // 默认值,频率300Hz // 3. 设置复用比,64行驱动 i2c_hal_write_cmd(0xA8); i2c_hal_write_cmd(0x3F); // 64行复用 // 4. 设置显示偏移,一般0 i2c_hal_write_cmd(0xD3); i2c_hal_write_cmd(0x00); // 5. 设置起始行,数据手册通常建议0行 i2c_hal_write_cmd(0x40); // 6. 段重映射与COM扫描方向 i2c_hal_write_cmd(0xA0); // 正常段映射 i2c_hal_write_cmd(0xC8); // 正常COM扫描方向 // 7. 开启显示 i2c_hal_write_cmd(0xAF); // 打开屏幕 }这段代码每条命令都值得解释:0x8D 电荷泵命令只有 SSD1306 需要,SSD1315 模组如果硬件电路已经提供电压,再写 0x14 可能导致电荷泵输出偏高,画面显灰雾状。0xD5 设置内部振荡器频率,在内置 DC-DC 电路工作的场景下不建议跳过低频率档位,否则灰阶翻转会有拖影。0xA8 复用比命令必须和屏幕物理行数一致——128×32 的屏写 0x1F,128×64 的屏写 0x3F,不要为了省事直接套 64 行的配置在小屏上,结果底部暗区出现水平亮线。
初始化完成后屏幕会亮起来但全黑,这是预期状态,因为显存内部默认全是 0。接下来进入绘图阶段前,通常需要先执行一次 OLED_Clear 刷掉可能残留的随机显存内容。
3.2 画点函数:为什么它是所有绘图 API 的地基
画点是 OLED 驱动代码最底层的绘制单元,所有字符串、汉字、图片最终都会拆解成画点或画字节操作。这份代码的画点逻辑非常标准——先把坐标 (x,y) 映射到具体的页和字节位:
// 画一个像素点,x:0~127, y:0~63 void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t color) { uint8_t page = y / 8; // 算出该点在第几页(0~7) uint8_t bit = y % 8; // 算出在页内的第几位(0~7) if (x > 127 || y > 63) return; // 越界保护,不处理非法坐标 if (color) { OLED_GRAM[page][x] |= (1 << bit); // 点亮:置1 } else { OLED_GRAM[page][x] &= ~(1 << bit); // 熄灭:清0 } }这里有个值得注意的映射关系:OLED_GRAM[page][x],第一维是页,第二维是列,而不是常规图形成像的 [x][y]。如果后面要做图像翻转或旋转,必须在画点函数里改变下标换算方式,其他模块不需要动。1 << bit 表示页内 8 行的位偏移,bit0 是最上面那一行。如果显示出来文字上下颠倒,优先来这里检查 bit 方向和 COM 扫描方向是否匹配。
画线的实现则是在此基础上做 Bresenham 或简单的线性插值。这份驱动选的是简化版数值微分画线法,用整数步进避免浮点运算,在无 FPU 的 51 单片机上也能跑得动:
// Bresenham 画线:支持任意斜率 void OLED_DrawLine(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) { int dx = (x1 > x0) ? (x1 - x0) : (x0 - x1); int dy = (y1 > y0) ? (y1 - y0) : (y0 - y1); int sx = (x0 < x1) ? 1 : -1; int sy = (y0 < y1) ? 1 : -1; int err = dx - dy; while (1) { OLED_DrawPoint(x0, y0, 1); if (x0 == x1 && y0 == y1) break; int e2 = 2 * err; if (e2 > -dy) { err -= dy; x0 += sx; } if (e2 < dx) { err += dx; y0 += sy; } } }算法核心是决策参数 err 的累加:它衡量当前点与理想直线的偏差,每走一步就更新一次横纵坐标的步长方向。代码里对 x0/x1、y0/y1 的大小做了对称处理,所以不管从左上画到右下还是从右上画到左下,线段都很均匀,不会出现阶梯状断裂。如果有了解 Bresenham 经典实现的同学,会发现这份代码正好就是整数版本,所有运算都在 int 范围内,一个浮点乘法都没有。
3.3 汉字显示:点阵取模格式与字库渲染
汉字显示通常是 OLED 项目里最劝退人的环节——你需要解决「取模格式怎么设」和「字库怎么存」两个问题。这份驱动采用的标准做法是 16×16 点阵,横向取模,即每个汉字按行扫描,一行 2 字节,共 16 行,合计 32 字节存于字库。
// 显示一个16x16汉字,x,y为左上角坐标 void OLED_ShowChinese(uint8_t x, uint8_t y, const uint8_t *font16x16) { uint8_t i, j; for (i = 0; i < 16; i++) // 逐行扫描 { for (j = 0; j < 2; j++) // 每行两个字节 { uint8_t byte = font16x16[i * 2 + j]; // 将该字节的每一位映射到屏幕 for (uint8_t k = 0; k < 8; k++) { if (byte & (0x80 >> k)) OLED_DrawPoint(x + j * 8 + k, y + i, 1); } } } }i 循环表示 16 行中的第 i 行,j 循环处理每行的两个字节。font16x16 数组的排列顺序如果和取模软件不一致,最典型的结果就是汉字上下两半错位。取模时常见的设置组合是「纵向取模 + 字节正序」和「横向取模 + 字节正序」,这套代码用的是后者。你从字库工具导出数据时,一定确认点阵格式选的是「横向取模」,否则字符看起来是散的。
ASCII 字符显示原理和汉字一样,只是字库变成了 8×16 或 6×8 点阵,行数少一半,一个字符占 16 或 8 字节。显示字符串的核心就是循环调用显单字符函数,同时维护一个 x 坐标累加器:
void OLED_ShowString(uint8_t x, uint8_t y, const char *str) { while (*str) { if (*str >= 0x80) // 非ASCII字符,按UTF-8解析汉字 { OLED_ShowChinese(x, y, GetChineseFont(*str, *(str+1))); str += 2; x += 16; } else // ASCII字符 { OLED_ShowChar(x, y, *str); str++; x += 8; } if (x > 120) { x = 0; y += 16; } // 自动换行 } }判断字符类型的方式是检查字节的最高位:UTF-8 编码的中文首字节大于等于 0x80,ASCII 则小于 0x80。这里有个血泪经验:很多 51 工程直接用 GB2312 内码,而这份驱动的字符串接口是按 UTF-8 设计的,直接把 GB2312 字符串传进来会发生乱码。解决办法有两个:一是在编译选项里把所有源文件编码统一成 UTF-8,二是在调用前用 C 标准库的 mbstowcs 做一次转码。我一般倾向前者,简单省心。
3.4 图片显示:取模格式不对,出来的就是镜像
显示图片的函数 OLED_ShowImage 在写法上比较简单,核心是逐字节连续推数据到显存页中,与整屏刷新函数共用一套页地址设置逻辑。但它对取模格式的要求更苛刻——图片取模时每一行按 128 字节排列,和 SSD1306 显存的页结构完全一致,也就是一页 8 行像素正好对应图片取模的一行数据。
如果你取模时用的是「按列取模」而不是「按行取模」,图片在屏幕上会呈现 90 度旋转的诡异效果。这个问题的根源是取模软件的坐标系和 SSD1306 的显存坐标系不一致。判断取模格式是否正确有一个快速方法:把图片第一行前 8 个像素取出来,观察取模出来的第一个字节的 bit 排列是否从左到右对应屏幕 x 方向;如果是反的,图片会左右镜像。
4. 避坑指南:五个在 OLED 驱动上最值得警惕的问题
4.1 现象:开机黑屏,I2C 波形看着一切正常
这是 OLED 项目最经典的翻车场景。示波器上看 SCL/SDA 波形、地址、ACK 都没问题,但屏幕就是不亮。原因通常有两个:一是初始化序列中电荷泵相关的命令顺序不对,SSD1306 必须在开启显示(0xAF)之前先把电荷泵打开(0x8D + 0x14),反了就会被内部电路拒绝;二是复位引脚没拉高,很多 I2C 模组把 RST 引脚引出来了,但代码从没碰过它,一些模组上电默认复位状态悬空,需要手动拉高。解决方式就是在 OLED_Init 最开始加一段:先将 RST 拉低 20ms,再拉高 20ms,给芯片一个确定的复位时序,然后再执行命令序列。
4.2 现象:屏幕有显示但整屏偏暗,对比度不对
画面能亮,但字迹发灰、白色不够白。这大概率不是亮度问题,而是显示对比度命令 0x81 的设置值太小,或者 VCOMH 设置不当。代码默认设置对比度为 0x7F,在一些 SSD1315 模组上要调整到 0xFF 才够亮。我一般会把对比度值做成函数参数,允许运行期调整——开机先设一个中间值,给 UI 留一个「屏幕亮度」的调节入口,比每次改代码重新烧录强得多。VCOMH 命令 0xDB 的值在 0x02 到 0x30 之间变动时,同样会影响屏体的整体观感,0x2A 是兼容性最好的基准值。
4.3 现象:屏幕右半部分出现残影或花屏
这个现象常见于用局部刷新函数时,页地址设置没做完整。SSD1306 在连续写一页数据后,内部列地址会自动回到 0,但页地址不会自动增加——如果你只写了当前页的部分列,下一次刷新时没有先设置页地址,数据会写到预料之外的页上,造成视觉上的残影。解决方式比较机械:在每次写显存数据前,强制设置页地址和列地址,不要依赖上一次写入后的内部状态。不要去猜当前硬件指针在哪,直接告诉它去哪里,代码确定性越高,越不容易出玄学问题。
4.4 现象:刷屏速度慢,图表有明显闪烁感
很多开发者直接用 100kHz 的标准 I2C 速率去推全屏数据,结果 1024 字节的传输耗时接近 100ms,看起来像幻灯片。解决方式是启用 I2C 外设的高速模式,SSD1306 支持的 I2C 时钟最高可达 400kHz(部分批次到 1MHz)。把速率提到 400kHz 后,全屏刷新大约降到 25ms,配合局部刷新基本可以做到动画流畅。如果你用的是软件模拟 I2C,这里就需要注意延时函数的精度——模拟时序里每翻转一次电平要延时 1.25us,做不好就会超过芯片的建立时间要求,表现为数据错位。
4.5 现象:睡眠唤醒后显示内容乱码
部分项目会把 OLED 设为休眠状态省电(0xAE),需要再唤醒(0xAF)。但唤醒后如果只是简单发送 0xAF,内部的显存内容可能已被 DC-DC 电路掉电清空,重新唤醒后显示的是显存里的随机值。所以在唤醒后建议先执行一次完整刷新,把所有 GRAM 数据重推一遍。注意这里不是初始化整个屏幕,之前的配置依然有效——只需要把 OLED_WriteFullScreen 再调用一次,一切会恢复。
5. 二次开发思路:把这份驱动改造成你需要的形态
5.1 从 I2C 换到 SPI:两个接口,十几行代码的事
如果你要把这块屏换成 SPI 版本,驱动分层的好处就体现出来了。替换 i2c_hal.c 里的三个函数即可,SPI 模式的写命令/写数据时序与 I2C 差了两位,但原理一样:在 SCL 时钟沿把每个 bit 移出去。差别在于 SPI 没有从机地址的概念,每个字节直接发,另外控制字节还是需要手动传输。OLED core 层代码根本不用碰,因为高层只依赖函数签名。
// 把 i2c_hal_write_cmd 改成 SPI 版本,仅一行核心 void spi_hal_write_cmd(uint8_t cmd) { uint8_t prefix = 0x00; // SPI 模式下命令前缀 HAL_SPI_Transmit(&hspi1, &prefix, 1, 10); HAL_SPI_Transmit(&hspi1, &cmd, 1, 10); }这里容易忽略的是 SPI 模式下的显存写同样需要前缀,但前缀值从 0x00 变成了 0x40。有同学改完 SPI 后屏幕全花,就是忘了把写数据前置的控制字节改成 0x40,硬件把数据当命令解析了。
5.2 帧率优化:从整屏刷新到脏矩形刷新
驱动默认的 OLED_WriteFullScreen 全量刷新是性能瓶颈。如果你做的是一个翻页动画或简单仪表盘,大部分时候只有局部区域变化,去做脏矩形刷新能节省大量时间。在 OLED_GRAM 之上做一层 diff 很容易,每次更新完图形后,遍历所有需要变化的页面,只把差异字节传输上去。
// 脏矩形刷新:比较新旧显存差异,只更新变化的区域 void OLED_UpdateRect(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) { for (uint8_t page = y0 / 8; page <= y1 / 8; page++) { for (uint8_t col = x0; col <= x1; col++) { if (OLED_GRAM[page][col] != prev_GRAM[page][col]) { // 设置页地址/列地址后,写该字节 OLED_UpdateSingleByte(page, col); prev_GRAM[page][col] = OLED_GRAM[page][col]; } } } }注意比较用的是 prev_GRAM,也就是上一帧的显存快照,原驱动里可以很方便地增加一个全局数组来缓存。它的好处在 I2C 速率受限时尤其明显:一个 20×16 像素的变化区域只涉及 3 页×20 列=60 字节,传输时间降到全刷新的十分之一以内。如果动画区域很大,比如整屏拖拽,这种情况下脏矩形和有整刷差不多,但至少不会更差多少。
5.3 低内存平台的显存取舍:影子显存会造成双倍开销
内置 1024 字节的 OLED_GRAM 加上一份 prev_GRAM,光显存就占掉 2KB——这对 RAM 只有 2KB 的 51 单片机非常不友好。如果你做的是 STC15 这类资源紧张的片子,一个可行的改造方案是取消 prev_GRAM,用「写点后立即计算是否修改当前页」的方式替代,也就是从全量刷变成页级增量刷:维护一个 dirty_page 位图,任何写点操作都会把对应页标记为脏,刷屏时只遍历脏页。这样 RAM 消耗回到 1KB,代价是画复杂图形时可能比 diff 模式多发一两个整页。
5.4 协议级升级:把 I2C 发送改成 DMA 方式
在高刷新率的 UI 场景下,阻塞式的 HAL_I2C_Master_Transmit 会让 CPU 干等。改 DMA 前要先确认你的 I2C 外设支持 DMA 模式,并且注意传输完成中断要和显存读操作串行化——DMA 传输期间如果 CPU 又去修改 OLED_GRAM,会拿到一半新一半旧的数据。我一般的做法是分两个缓冲区:待传输缓冲区和当前绘图缓冲区,在 DMA 完成回调里交换两个缓冲区的指针,既能不断刷新又不撕裂。这个思路稍微复杂,但对帧率要求苛刻的赛车表盘、示波器类界面很受用。
有一点我必须提醒:OLED 屏的物理寿命问题。长久显示同一静态画面,会有烧屏印记。如果你的设备里有固定菜单或常亮图标,建议加一个屏幕休眠策略——比如超时 2 分钟无操作,执行 0xAE 关闭显示,检测到触碰或按键时再打开。纸上谈兵很容易,但真正在设备上跑一段时间,这个细节是用户感知最强的一个点。
拆过这么多份 OLED 驱动,我发现最出问题的往往不是复杂的图形算法,而是命令序列、地址换算和取模格式这三个「基本功」。从那以后我每次拿到新模组,都强制先跑一遍最小初始化验证,确认屏幕能亮、能画对角线、能显示全角字符,再开始往上堆功能。这套流程救过我不少次,你可以按这个顺序去调你自己的板子。希望帮到你。
本文还有配套的精品资源,点击获取