简介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 驱动 ICSSD1315 是它的精简版常见于现在出货量很大的低成本 0.96 寸模组。它们在驱动代码上的关键差异集中在初始化序列的几条命令上。对比项SSD1306SSD1315内部电荷泵需要显式开启 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 的屏写 0x1F128×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, *(str1))); str 2; x 16; } else // ASCII字符 { OLED_ShowChar(x, y, *str); str; x 8; } if (x 120) { x 0; y 16; } // 自动换行 } }判断字符类型的方式是检查字节的最高位UTF-8 编码的中文首字节大于等于 0x80ASCII 则小于 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 驱动我发现最出问题的往往不是复杂的图形算法而是命令序列、地址换算和取模格式这三个「基本功」。从那以后我每次拿到新模组都强制先跑一遍最小初始化验证确认屏幕能亮、能画对角线、能显示全角字符再开始往上堆功能。这套流程救过我不少次你可以按这个顺序去调你自己的板子。希望帮到你。本文还有配套的精品资源点击获取