简介:这份OLED驱动代码针对SSD1306/SSD1315控制器,专为在单片机项目中驱动常见小尺寸OLED屏而设计,能有效简化屏幕初始化、字符绘制与刷新流程,适合嵌入式初学者、电子爱好者及需要快速完成界面显示的产品开发者。压缩包共4个文件,其中oled.c与oled.h组成驱动核心,完成控制器初始化、像素点绘制、区域刷新等操作;font.c与font.h提供字符点阵数据,支持常用的ASCII字符显示。整个压缩包仅13KB,文件数量少、结构清晰,可直接加入工程。目前已有355人学习/下载。代码将硬件接口与应用层分离,通过宏或函数封装,可灵活切换I2C或SPI总线,也方便移植到不同主控平台。OLED自发光、无需背光,这套驱动能控制每个像素,实现文本、图标与简单图形的清晰显示。阅读这套驱动可理清OLED上电配置、显存组织与字符渲染的基本流程,既能帮助新手掌握屏驱编程思路,也能作为正式项目中的轻量级显示组件二次开发。
1. 屏幕点不亮,问题常在驱动:这份OLED驱动代码解决什么
OLED驱动代码,说到底就是“让屏幕先醒过来,再按页喂数据”的那几百行C。我见过不少开发者焊好线、上完电,屏始终黑着,最后查到控制器还停在上电默认态:内部电荷泵没开、扫描方向不对、列地址没设。这套基于 SSD1306/SSD1315 的驱动代码,把从初始化到像素显存刷新的完整时序都封装好了,目标只有一句话:让你手里的 128x64 OLED 从零开始真正能画点、画线、显字。适合正在做裸机小项目、又不想从数据手册里逐页翻寄存器位的开发者。
2. 架构设计:一个驱动如何拆成显存、总线、命令三层
2.1 显存模型:页寻址和1KB帧缓冲
SSD1306 和 SSD1315 的控制方式,和普通图形 LCD 最大的不同是:它不给你每个像素的坐标,而是把屏幕分成“页”。一块 128x64 的屏幕,纵向 64 行被切成了 8 页,每页 8 行;横向还是 128 列。读写的时候,先指定页地址、再指定列地址,随后写一个字节,这个字节的 bit0 到 bit7 就对应这一列的 8 个像素。
因此驱动里最核心的显存模型就是这样一个二维数组:
#define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_PAGES (OLED_HEIGHT / 8) static uint8_t framebuffer[OLED_PAGES][OLED_WIDTH];用framebuffer[page][col]来表示“第 page 页、第 col 列”的一个字节。y 从 0 到 7 正好落在页内 bit0 到 bit7;y 从 8 到 15 就切到下一页。整块显存一共 8*128=1024 字节,也就是常说的“1KB 帧缓冲”。把这个数组准备好之后,上层绘图逻辑只跟二维数组打交道,真正的发送只在最后刷屏时做一次,效率会高很多。
这个模型决定了整个驱动的写入顺序。很多人第一次写 OLED 驱动时按像素坐标去发数据,发一个字节就切一次页地址,结果刷一屏要调几百次寄存器,速度慢还会闪烁。正确做法是把所有绘图都画在 framebuffer 里,需要显示的时候再一次把 1024 字节灌进控制器,控制器在水平寻址模式下会自动按“列加一、列满换页”的顺序落屏。
2.2 总线层隔离:I2C 与 SPI 只差三个函数
同一个屏幕,外围模块可能是 I2C 接口也可能是 SPI 接口;同一份绘图逻辑不应该因为换了接口就重写。我习惯在驱动里抽象出一个极简的总线结构体:
typedef struct { void (*write_cmd)(uint8_t cmd); void (*write_data)(const uint8_t *data, uint16_t len); } oled_bus_t;write_cmd负责发单字节命令,比如0xAE关显示、0xAF开显示;write_data负责连续发一段像素数据,比如 1024 字节帧缓冲。上层初始化、绘图、刷屏只调用这两个函数,至于底层是 I2C 还是 SPI,完全由实现这两个函数的人决定。
实际工程里我会再把底部分成oled_io_i2c.c和oled_io_spi.c,两个文件都只向外暴露oled_bus_t。主程序初始化时按硬件接法选一个总线结构体,传给oled_init()。这样换屏只需要改一行初始化代码,不用动绘图层。这套分层思路对后面接入其他型号的控制器同样适用。
2.3 文件划分:每个文件只干一件事
一个拆得清楚的小驱动,目录通常长这样:
oled/ oled_core.c # 显存、绘图API、初始化序列 oled_core.h # 对外结构体和函数声明 oled_io_i2c.c # I2C 底层收发 oled_io_spi.c # SPI 底层收发 oled_font.c # ASCII字库oled_core.c是最容易出问题的地方,它负责组织初始化序列和计算页地址;oled_io_*.c只做收发,不关心发出去的是命令还是数据;oled_font.c独立放字库,方便替换成中文点阵。编译时把需要的底层文件加进工程就行,用 I2C 就不编 SPI 文件。
这样的划分有一个很实在的好处:调试时如果屏幕不亮,问题范围一下子缩小一半。命令发不出去查oled_io_*,命令发了但显示不对查oled_core.c,不用从头到尾猜。这也是我每次拿到一套新屏驱动都会顺手重构的原因,驱动代码不长,但职责边界越清楚,后面二次开发越轻松。
3. 核心代码拆解:从初始化到像素落屏的完整链路
3.1 底层收发:控制字节和 D/C 引脚
先看 I2C 接口。OLED 模块的 I2C 从机地址一般是 0x3C,但这里有个容易忽略的细节:每个 I2C 帧的第一个字节并不是给控制器的数据,而是“控制字节”。控制字节为 0x00 表示后续写命令,为 0x40 表示后续写数据。写命令和写数据不能共用同一个控制字节,否则控制器会把数据当命令解析。
#define OLED_I2C_ADDR 0x3C #define OLED_CMD_CTRL 0x00 #define OLED_DATA_CTRL 0x40 static uint8_t tx_buf[OLED_PAGES * OLED_WIDTH + 1]; void oled_i2c_write_cmd(uint8_t cmd) { tx_buf[0] = OLED_CMD_CTRL; tx_buf[1] = cmd; i2c_send(OLED_I2C_ADDR, tx_buf, 2); } void oled_i2c_write_data(const uint8_t *data, uint16_t len) { tx_buf[0] = OLED_DATA_CTRL; memcpy(&tx_buf[1], data, len); i2c_send(OLED_I2C_ADDR, tx_buf, len + 1); }i2c_send是平台提供的硬件 I2C 发送函数,参数分别是设备地址、缓冲区指针、长度。注意命令函数里tx_buf长度为 2,数据函数里长度是len + 1,多出来的 1 就是控制字节。传输 1024 字节整屏数据时,一次性把控制字节和数据拼好发送,比先发一个 0x40 再发数据要省一半启动条件,速度提升明显。
SPI 接口的做法稍有不同,它没有控制字节,而是用一根 D/C 引脚区分命令和数据:
void oled_spi_write_cmd(uint8_t cmd) { gpio_write(OLED_DC_PIN, 0); gpio_write(OLED_CS_PIN, 0); spi_transmit(&cmd, 1); gpio_write(OLED_CS_PIN, 1); gpio_write(OLED_DC_PIN, 1); } void oled_spi_write_data(const uint8_t *data, uint16_t len) { gpio_write(OLED_DC_PIN, 1); gpio_write(OLED_CS_PIN, 0); spi_transmit(data, len); gpio_write(OLED_CS_PIN, 1); }OLED_DC_PIN拉低表示接下来是命令,拉高表示是数据;CS 在整个传输期间保持低电平,传输完再拉高。这里有个小坑:如果 D/C 没来得及在第一个时钟沿之前稳定,控制器会把这一个字节当成命令,后续数据全部错位。所以我在write_cmd里先写 DC 再拉 CS,顺序不能反。SPI 的 MOSI 和 SCLK 接模块对应引脚,模块上一般已经标了 SDA 和 SCL,严格说 OLED 的 SPI 只有单向写,不需要 MISO。
3.2 初始化序列:每个寄存器都在干什么
SSD1306 上电后不是立即就能显示的,必须按照一定顺序写一串配置寄存器。这套初始化序列是黑屏问题最集中的地方,顺序不能随便调。
void oled_init(void) { uint8_t init_seq[] = { 0xAE, // 关闭显示,配置期间画面不闪 0xD5, 0x80, // 内部时钟分频和振荡器频率 0xA8, 0x3F, // 复用比,64 行显示 0xD3, 0x00, // 显示偏移 0 0x40, // 起始行 0 0x8D, 0x14, // 使能内部 DC-DC 电荷泵 0x20, 0x00, // 水平寻址模式 0xA1, // 段重映射 0xC8, // COM 扫描方向 0xDA, 0x12, // COM 引脚硬件配置,128x64 专用 0x81, 0xCF, // 对比度 207 0xD9, 0xF1, // 预充电周期 0xDB, 0x40, // VCOMH 电平 0xA4, // 从 RAM 内容恢复显示 0xA6, // 正常显示,不反显 0x2E, // 关闭滚动 0xAF // 打开显示 }; for (uint16_t i = 0; i < sizeof(init_seq); i++) { bus->write_cmd(init_seq[i]); } }这段代码里我特别标了两个值:0x8D, 0x14是电荷泵开关,0x14表示启用内部 DC-DC。如果不写这一项,屏幕要么全黑,要么亮度极暗,这是最典型的“接好线却点不亮”原因。另一个关键参数是0xA8, 0x3F,0x3F 即 1:64 复用,对应 64 行;如果换了一块 128x32 的屏幕,这里必须改成 0x1F,同时0xDA后面的值也要从 0x12 改成 0x02,否则显示范围会不对。
初始化的最后两步也讲究:0xAE先关显示,所有寄存器配置完成后再0xAF开显示。这样配置过程中屏幕不会闪出随机像素。如果看到开屏瞬间有雪花状噪点,多半是0xAF提前了,或者电荷泵使能后没有留足几毫秒的建立时间。
3.3 绘图 API:坐标如何变成页地址和列地址
显存准备好以后,绘图就变成对二维数组的位操作。画一个像素点只需要两行代码:
void oled_set_pixel(uint8_t x, uint8_t y, uint8_t on) { uint8_t page = y >> 3; // y / 8 -> 第几页 uint8_t bit = 1 << (y & 0x07); // y % 8 -> 页内第几行 if (on) { framebuffer[page][x] |= bit; } else { framebuffer[page][x] &= ~bit; } }这里y >> 3是把行号换算成页号,y & 0x07是页内偏移。注意 OLED 的行顺序是 bit0 在上、bit7 在下,所以 y=0 对应 bit0,别把这个顺序搞反了,否则显示内容会上下颠倒。
画完显存后,刷屏是最后一步。利用水平寻址模式,可以先设列范围 0~127、页范围 0~7,然后一次性发送 1024 字节,控制器自己会按序填满显存。
void oled_flush(void) { bus->write_cmd(0x21); // 设置列地址范围 bus->write_cmd(0); bus->write_cmd(OLED_WIDTH - 1); bus->write_cmd(0x22); // 设置页地址范围 bus->write_cmd(0); bus->write_cmd(OLED_PAGES - 1); bus->write_data(&framebuffer[0][0], OLED_PAGES * OLED_WIDTH); }0x21后面的两个参数是列起始值和列结束值,0x22后面的两个参数是页起始值和页结束值。设置好窗口后,连续写数据会从左上角开始自动向右、向下填充,不用每发一列都重新指定地址。局部刷新也是同一套机制:把0x21、0x22的范围改成需要更新的小窗口,只发送窗口内的字节,速度比整屏刷快很多。
4. 让代码跑起来:一次最小移植的操作记录
4.1 从一个 main 函数开始
移植这套驱动到新板子,我习惯先只看 main 函数需要调用哪几个函数。
int main(void) { hal_init(); // 初始化系统时钟和 GPIO oled_io_init(); // 初始化 I2C/SPI 外设 oled_reset(); // 硬件复位:RES 引脚先低后高 oled_init(); // 配置寄存器序列 oled_clear(); // 清空显存和屏幕 oled_flush(); while (1) { oled_set_pixel(10, 20, 1); oled_flush(); delay_ms(100); oled_clear(); oled_flush(); delay_ms(100); } }hal_init是平台相关初始化,不同开发板不一样。oled_reset里我会把 RES 引脚拉低 20ms、再拉高,然后等 100ms,这个流程让控制器内的状态机回到确定态,比只发送命令可靠。随后oled_init写入上一章的初始化序列,oled_clear把 framebuffer 全部清零并刷一次屏,主循环里画点、刷屏、清屏交替,验证整条链路通没通。
硬件接线按接口不同,常用的接法如下:
| 功能 | I2C 模块 | SPI 模块 |
|---|---|---|
| 电源 | VCC -> 3.3V | VCC -> 3.3V |
| 地 | GND -> GND | GND -> GND |
| 数据 | SDA -> I2C_SDA | SDA/MOSI -> SPI_MOSI |
| 时钟 | SCL -> I2C_SCL | SCLK -> SPI_SCLK |
| 片选 | 无 | CS -> GPIO/SS |
| 控制 | 无 | DC -> GPIO |
| 复位 | RES -> GPIO 或 RC 复位 | RES -> GPIO |
I2C 模块一般只需要四根线,SPI 模块多出 CS 和 DC。RES 如果模块上已经通过 RC 电路连到电源,可以省掉;但要想复位时序可控,建议还是接一个 GPIO。接线完成后先量一下 VCC 对地电压,确认是 3.3V 而不是 5V,OLED 模块长期超过 3.3V 会发烫甚至损坏。
4.2 SSD1306 与 SSD1315:指令集兼容但有三处参数要调
SSD1315 是 SSD1306 的后续控制器,很多 0.96 寸屏幕已经开始用它。实际测试下来,SSD1315 的指令集跟 SSD1306 基本一致,上面这套初始化序列直接跑通常也能点亮,区别主要体现在三处显示效果相关的参数上。
| 寄存器 | SSD1306 常用值 | SSD1315 常见调整 | 影响 |
|---|---|---|---|
| 0x81 对比度 | 0xCF | 0x7F 到 0xFF 之间 | 亮度 |
| 0xD9 预充电 | 0xF1 | 0x22 到 0xF1 | 显示均匀度 |
| 0xDB VCOMH | 0x40 | 0x30 到 0x40 | 残影和亮度 |
我一般会先把 SSD1306 的完整序列发一遍,如果能点亮,再逐项微调这三处。SSD1315 在低负载时功耗更友好,但对预充电参数更敏感,预充电周期太长容易出现拖影。换屏后不要急着改代码,先用最初版点亮,再对比三个参数的实际效果。
4.3 编译验证:先在主机上模拟一遍
嵌入式工程编译要进 IDE,但驱动本身的逻辑可以在 PC 上先验证。把oled_io_i2c.c里的发送函数临时替换成打印函数,跑同样的初始化流程,能看到完整命令序列的输出。这一步能快速发现数组下标越界、参数长度写错这类低级问题。
static void debug_print_cmd(uint8_t cmd) { printf("CMD 0x%02X\r\n", cmd); } static void debug_print_data(const uint8_t *data, uint16_t len) { printf("DATA len=%u first=0x%02X\r\n", len, data[0]); }用这两个函数替换bus结构体里的成员,初始化后应当看到以0xAE开头、0xAF结尾的完整序列;画一个点再刷屏,应当看到0x21、0x22窗口设置和 1024 字节的数据长度。如果打印出来命令只有一半,说明初始化函数里有分支提前返回了,这是最常见的低级错误。
验证完毕后换上真实 I2C/SPI 实现,烧录进板子。屏幕如果亮了但内容不对,再回头看第 5 章的常见坑,多数情况都能对上号。
5. 避坑指南:点不亮、花屏、残影的排查手册
5.1 全黑屏或亮度极暗:电荷泵没有使能
现象:初始化序列执行完,屏幕毫无显示,或者放大镜能隐约看到极暗的笔画,开屏瞬间没有反应。
原因:0x8D 寄存器后面的值写成了 0x10,或者整段 0x8D 没写。0x10 是禁用内部 DC-DC,此时 OLED 面板的负压供电没有建立,像素无法被正常点亮。
解决:检查初始化序列里必须有0x8D, 0x14,并且 0xAF 开屏放在最后;有些模块供电走外部升压,此时禁用电荷泵反而更合适,但绝大多数裸屏必须开。
5.2 I2C 扫描不到设备:地址和上拉电阻
现象:I2C 扫描函数一个地址都扫不到,或者有时能找到、下次又找不到,板子一摸屏就乱。
原因:模块上没有焊接 SDA/SCL 上拉电阻,MCU 引脚配置成了开漏且没有外部上拉;也可能是模块地址不是 0x3C 而是 0x3D,扫描范围没覆盖。
解决:先在 SDA、SCL 对 VCC 各接一个 4.7k 上拉电阻,再写一个 I2C 扫描例程把 0x3C 和 0x3D 都扫一遍。找到地址后把OLED_I2C_ADDR改掉,不要想当然用 0x3C。
5.3 显示内容整体偏移或出现多余竖线
现象:文字、图标整体向左或向右偏了几个像素,屏幕边缘出现一条多余亮线,或者字符镜像反着显示。
原因:控制器里列地址的起始值不是 0。初始化序列里漏掉了设置列地址的命令,上电默认值和面板实际接线对不上。
解决:在初始化序列里显式写入列地址范围,也就是每次刷屏前都执行0x21, 0x00, 0x7F;如果内容镜像,把0xA1换成0xA0试,扫描方向0xC8和0xC0也要配套换。
5.4 花屏和雪花噪点:数据前缀与时钟参数
现象:上电显示随机像素点,像电视雪花;或者画面第一帧是花的,刷新几次后才正常。
原因:I2C 写数据时控制字节用了 0x00,控制器把整段字节都当成命令处理;另一个可能是内部时钟分频 0xD5 太快、预充电时间 0xD9 太短,DC-DC 还没进入状态。
解决:确认数据发送路径里每一帧都以 0x40 开头,命令路径以 0x00 开头;把 0xD5 从 0x80 调到 0x51,0xD9 调到 0x22 再往上加,逐档试到画面清爽。
5.5 长时间显示同一画面后烧出残影
现象:关闭显示或切换画面后,之前固定显示的字符和图标位置留下淡淡印记,很长时间消不掉。
原因:OLED 像素在固定高亮度、固定位置长时间点亮后,发光材料老化速度不一致,留下不可逆痕迹。这不算驱动 bug,但驱动代码可以缓解。
解决:让驱动支持定时取反和滚动偏移;如果项目对显示功耗不敏感,每 30 秒把同一帧内容在横向偏移 1 个像素的位置重绘一次,能明显延缓残影。
这些坑里最玄学的其实是 5.4,有时候万用表量电压都正常,最后发现是发送函数里一个控制字节写错了。遇到类似问题,先冷静去掉可视化干扰,用逻辑分析仪或者主机模拟把字节流拉出来看,比盲调寄存器快得多。
6. 二次开发:把点亮流程改造成可复用的小组件
6.1 加入局部刷新,告别整屏刷新的闪烁
很多项目只需要改动屏幕上某一小块区域,但第一次跑通时往往整屏刷 1024 字节,刷一次要几毫秒,反复刷新就闪烁。改造方向是提供一个带矩形参数的刷屏函数。
void oled_update_rect(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1) { uint8_t page_start = y0 >> 3; uint8_t page_end = y1 >> 3; bus->write_cmd(0x21); bus->write_cmd(x0); bus->write_cmd(x1); bus->write_cmd(0x22); bus->write_cmd(page_start); bus->write_cmd(page_end); for (uint8_t p = page_start; p <= page_end; p++) { bus->write_data(&framebuffer[p][x0], x1 - x0 + 1); } }调用时只更新变化的矩形,比如一个时钟显示每秒只刷数字区域,周围内容不动,肉眼几乎感觉不到刷新过程。注意y1所在页如果跨页,循环会把涉及的页都发出去,这个边界条件最容易踩坑,建议先写一个跨页矩形验证。
6.2 验证驱动正确性的两个土办法
第一个办法是画棋盘格:把偶数页、偶数列置 0x55,奇数位置置 0xAA,如果显示出来是横竖均匀的网格,说明页地址和列地址映射是对的;如果出现竖条纹而不是格子,说明 x 和 page 的换算写反了。第二个办法是逻辑分析仪抓 I2C 帧,重点看第一个数据帧开头是不是 0x40 控制字节,初始化序列开头是不是 0xAE,中间有没有 0x10 之类的电荷泵禁用值。这两个办法加起来,能覆盖驱动里八成以上的低级错误。
从那以后我每次接新屏都强制走一遍这个流程:先跑棋盘格,再抓第一帧字节流,确认无误后才开始画 UI。做显示驱动这种事,最怕的就是在错误前提下反复调参数,参数调得再准,控制字节错了照样花屏。这份 OLED 驱动代码本身不难,真正值钱的是这套“先验证底层、再调效果”的排查习惯。希望帮到你。
本文还有配套的精品资源,点击获取