简介OLED12864在多种单片机上的驱动例程包面向嵌入式初学者与物联网项目开发者旨在解决不同平台间屏幕驱动与引脚配置问题。例程全部使用软件模拟SPI通过普通GPIO即可完成通信适合无硬件SPI外设或希望灵活连线的场景开发者只需按自己的单片机型号修改管脚宏定义即可快速适配。压缩包共890个文件大小约11.8MB包含C源文件、头文件、Arduino的ino工程、Keil/IAR工程文件、编译生成的hex/bin烧录文件以及原理图图片与说明文档等其中c/h文件为核心驱动逻辑工程和烧录文件便于直接编译验证。已有757人学习下载对学习12864点阵显示、软件SPI时序和跨平台移植均有直接参考价值。通过研读这批例程可掌握OLED初始化、绘图、文本与字符显示等基础操作并理解不同单片机下宏定义调用的思路为后续项目中的屏幕应用提供可复用的代码基础。1. 项目是什么一份打通多种单片机的OLED12864显示例程OLED12864 这种 0.96 寸/1.3 寸的小屏几乎每个嵌入式玩家都绕过不。芯片本身没有多复杂麻烦的是每次换一块开发板驱动代码几乎全得推倒重写。这套例程把 51、STM32、ESP32、AVR 等常见平台的 OLED12864 驱动全部收集齐了I2C 和 SPI 两种总线方式都有覆盖核心是同一颗 SSD1306 控制器的驱动逻辑。新手可以照着把自己的屏幕点亮老手可以直接把字库、显存、绘图原语拆出来移植到新平台。我拆这套例程的时候有一个很直接的感受它不是那种“一个板子一个工程”的孤立代码堆而是把平台相关和平台无关的部分拆得很干净。接下来从目录结构讲起把它的组织方式、核心实现、典型坑位和三个平台的实操步骤完整过一遍最后落到怎么基于这套代码做二次开发让它变成你自己的显示工具箱。如果你正在找一份 OLED12864 例程做参考这篇笔记能帮你少翻半天数据手册。2. 目录与架构从51到ESP32的代码组织与硬件抽象2.1 平台分层的目录结构先看目录。这套例程没有把所有平台的代码混在一起而是按“硬件平台 公共代码”的方式组织大致长这样oled12864_examples/ ├── 51_std/ # 51 单片机标准 8051 C 编译 ├── stm32_hal/ # STM32 HAL 库 ├── stm32_std/ # STM32 标准外设库 ├── esp32_arduino/ # ESP32 Arduino 框架 ├── avr_mcu/ # AVR 系列单片机 ├── pic_mcu/ # PIC 系列单片机 └── common/ ├── ssd1306.h # 平台无关的驱动接口定义 ├── ssd1306_core.c # 字库、绘图、显存操作 ├── font5x7.h # 5x7 ASCII 点阵字库 └── frame_buf.c # 显存缓冲管理为什么要这么分因为 SSD1306 这颗控制器只管接收数据但底层发送数据的方式在不同芯片上差异极大。51 上通常是普通 GPIO 模拟 I2C/SPISTM32 可以用硬件 I2C/SPI 外设ESP32 在 Arduino 框架下又包了一层 Wire 库。如果这些东西全写在一个文件里换板子的时候你会翻车翻到怀疑人生。分开以后上层核心驱动完全一致只有最底层的几个函数需要按平台重写。有个细节值得注意51_std 和 stm32_std 两个目录下的代码结构并不完全一样别以为这是作者偷懒。51 单片机内部 RAM 只有几百字节放不下 1024 字节的全屏缓冲所以 51 版本的显存策略改成了页缓冲一次只缓存一页 128 字节。这是平台约束导致的正常分化而不是代码质量问题。2.2 硬件抽象接口I2C与SPI的分离这套例程的核心驱动位于 common 目录下它不直接操作任何寄存器而是通过一组函数指针与底层交互。这个设计我看下来非常舒服常见做法是定义一个这样的接口/* common/ssd1306_hal.h */ #ifndef SSD1306_HAL_H #define SSD1306_HAL_H #include stdint.h typedef struct { void (*write_cmd)(uint8_t cmd); /* 发送单字节命令 */ void (*write_data)(uint8_t *buf, uint16_t len); /* 发送数据缓冲 */ void (*delay_ms)(uint16_t ms); /* 毫秒延时 */ void (*init_pins)(void); /* 初始化引脚/外设 */ } ssd1306_hal_t; extern const ssd1306_hal_t ssd1306_hal; #endif以我拆代码的经验I2C 和 SPI 的区别其实只在 write_cmd 和 write_data 内部。I2C 方式下写命令要先发控制字节 0x00写数据要先发 0x80而 SPI 四线模式下命令和数据靠 DC 引脚电平区分。有了这层函数指针封装核心逻辑里完全不用关心总线类型切换平台时只需要把底下那几个函数换掉。这里我多说一句用函数指针而不是条件编译好处是平台适配的代码可以分文件独立编译不会出现满屏的#ifdef STM32把逻辑搅成一锅粥。代价是每次调用多一层间接跳转但 OLED 这种低速外设完全感知不到性能差异。2.3 一个最小驱动需要哪几部分一套能正常点亮并支持自定义显示的驱动其实只需要四部分硬件时序层、初始化序列、显存操作、绘图与字库。很多新手教程把初始化序列写得特别玄乎其实 SSD1306 的初始化就那么十几条命令真正决定屏幕能不能亮的关键是电荷泵和对比度这两组。模块职责对应文件时序层I2C/SPI 总线读写、引脚控制各平台目录下的 ssd1306_hal.c初始化发送命令序列配置显示模式ssd1306_core.c 的 init 函数显存操作1024 字节显存读写、清屏frame_buf.c绘图层画点、画线、字符显示ssd1306_core.c 的 draw 系列函数所有绘图操作先写进显存缓冲区最后由你决定何时调用 refresh 把内容同步到屏幕这是这套例程默认的全屏缓冲模型。这样画一个点不用全屏刷一次做动画时能省掉大量低效的 I2C 传输。全屏缓冲的代价是 1024 字节 RAM在 51 这种小内存单片机上需要换成页缓冲模式这个取舍我在 5.3 节再展开。common 目录里提供的核心 API 也就这么几个接口很干净void ssd1306_init(void); /* 初始化屏幕 */ void ssd1306_clear(void); /* 清空显存 */ void ssd1306_refresh(void); /* 全量刷新到屏幕 */ void ssd1306_draw_pixel(uint8_t x, uint8_t y, uint8_t color); /* 画点 */ void ssd1306_draw_line(int16_t x0, int16_t y0, int16_t x1, int16_t y1); void ssd1306_draw_char(uint8_t x, uint8_t y, char c); /* 单个字符 */ void ssd1306_draw_string(uint8_t x, uint8_t y, const char *s); /* ASCII 字符串 */这套 API 的定位很清晰只做显示原语不做页面管理。业务层的菜单、弹窗、状态栏都留给使用者自己在外面封装这种克制是非常合适的做法。如果 API 一上来就给你搞一堆 ui_page、ui_window 的抽象反而难以适应各种业务场景。3. 核心模块拆解从初始化到画出一帧画面的完整链路3.1 初始化序列让屏幕进入工作状态打开任一平台的示例main 函数最开始都会调 ssd1306_init()。这套代码里的初始化其实就是给 SSD1306 发一长串命令告诉它怎么扫描、用什么时钟、要不要开电荷泵。以 I2C 方式为例初始化代码大致长这样void ssd1306_init(void) { hal.init_pins(); hal.delay_ms(10); hal.write_cmd(0xAE); /* 关闭显示进入配置模式 */ hal.write_cmd(0x20); /* 设置内存寻址模式 */ hal.write_cmd(0x00); /* 0x00水平寻址0x01垂直寻址 */ hal.write_cmd(0xB0); /* 设置页起始地址为 Page0 */ hal.write_cmd(0xC8); /* 刷新扫描方向从上到下 */ hal.write_cmd(0x00); /* 列起始地址低 4 位 */ hal.write_cmd(0x10); /* 列起始地址高 4 位 */ hal.write_cmd(0x40); /* 显示起始行 0 */ hal.write_cmd(0x81); /* 对比度设置 */ hal.write_cmd(0x7F); /* 对比度值0~255越大越亮 */ hal.write_cmd(0xA1); /* 段重映射0xA0 / 0xA1 决定左右镜像 */ hal.write_cmd(0xA6); /* 0xA6 正常显示0xA7 反显 */ hal.write_cmd(0xA8); /* 多路复用率 */ hal.write_cmd(0x3F); /* 1/64 duty */ hal.write_cmd(0xD3); /* 显示偏移 */ hal.write_cmd(0x00); /* 偏移 0 */ hal.write_cmd(0xD5); /* 时钟分频 */ hal.write_cmd(0x80); /* 分频系数 */ hal.write_cmd(0xD9); /* 预充电周期 */ hal.write_cmd(0xF1); /* 预充电值 */ hal.write_cmd(0xDA); /* COM 引脚配置 */ hal.write_cmd(0x12); /* 交替 COM */ hal.write_cmd(0xDB); /* VCOMH 电压 */ hal.write_cmd(0x40); /* VCOMH 值 */ hal.write_cmd(0x8D); /* 电荷泵设置 */ hal.write_cmd(0x14); /* 0x14 使能电荷泵0x10 关闭 */ hal.write_cmd(0xAF); /* 打开显示 */ ssd1306_clear(); ssd1306_refresh(); }有几个参数我单独说明。0x20 加 0x00 是水平寻址这也是最常见的模式因为写显存时一行 128 字节可以连续发完中间不用重复设置页地址。0x8D 和随后的 0x14 必须成对出现否则屏幕会一直白屏很多人在这里踩过坑。0x81 对比度默认 0x7F 是数据手册推荐值如果屏幕太暗可以调到 0xFF但整屏功耗会明显上升。这段序列在 I2C 与 SPI 下的差异只在发送函数内部命令内容完全一致。也就是说你只要把这段序列原样搬到任意单片机上都能让屏幕亮起来不需要额外的平台相关代码。如果你想改成 SPI 版唯一要改的是 write_cmd 内部把 DC 引脚拉低后再输出字节。3.2 显存缓冲与刷新策略全量刷新还是局部刷新初始化完成后屏幕还是全黑的因为显存里全是 0。这套例程把 128×64 的屏幕抽象成 8 页、每页 128 字节的缓冲区展开来看就是这样uint8_t frame_buffer[8][128]; void ssd1306_clear(void) { memset(frame_buffer, 0, sizeof(frame_buffer)); } void ssd1306_refresh(void) { for (uint8_t page 0; page 8; page) { ssd1306_set_cursor(page, 0); hal.write_data(frame_buffer[page][0], 128); } } void ssd1306_set_cursor(uint8_t page, uint8_t col) { hal.write_cmd(0xB0 page); /* 页地址 0~7 */ hal.write_cmd(0x00 (col 0x0F)); /* 列地址低 4 位 */ hal.write_cmd(0x10 ((col 4) 0x0F)); /* 列地址高 4 位 */ }这里有一个非常容易误用的点SSD1306 的列地址和页地址必须配合使用列地址在写入数据后会自动累加。所以 refresh 里每页都要先设置一次页地址再从该页的第 0 列开始连续写 128 字节。如果你只设置一次页地址就把 1024 字节全发出去数据会错乱到完全没法看。这套例程默认是全量刷新也就是每次 refresh 都把整个缓冲区刷到屏幕。I2C 在 400 KHz 下刷一帧大约要 30ms人眼基本无感。但如果你要做动画或者高频菜单切换我建议加一个“脏页标记”只刷新被修改过的页。改法很简单在 draw 系列函数里把涉及到的页标记为 dirtyrefresh 时只遍历带标记的页刷新时间能从 30ms 压到 2ms 级别。3.3 绘图原语画点、画线、画字符的实现这一层是这套例程里最能拿出去复用的部分。画点是最基本的操作每个字节存放 8 个纵向排列的像素根据 y 坐标的低 3 位定位到位void ssd1306_draw_pixel(uint8_t x, uint8_t y, uint8_t color) { if (x 128 || y 64) return; /* 超界直接丢弃 */ uint8_t page y / 8; /* 确定在哪一页 */ uint8_t bit y % 8; /* 确定字节内的哪一位 */ if (color) { frame_buffer[page][x] | (1 bit); } else { frame_buffer[page][x] ~(1 bit); } }写字符的原理是把每个字符拆成 5×7 的位图逐列写到显存。字库定义在 font5x7.h 里每个字符占 5 个字节每个字节对应一列最低位是最上方的像素。字符串函数只是循环调用 draw_char并自动把光标移到字符宽度之后void ssd1306_draw_char(uint8_t x, uint8_t y, char c) { const uint8_t *glyph font5x7[(uint8_t)c - 32]; for (uint8_t col 0; col 5; col) { uint8_t line glyph[col]; for (uint8_t row 0; row 7; row) { if (line (1 row)) { ssd1306_draw_pixel(x col, y row, 1); } } } }画线的实现是经典的 Bresenham 算法纯整数运算没有浮点也没有除法在 51 这种不带硬件乘除的单片机上跑也没有压力。整套绘图层全部写在 common/ssd1306_core.c 里换平台时完全不用动真正做到了“写一遍到处跑”。这套字库只覆盖 ASCII 字符显示中文需要外挂 GB2312 字库或者自己取模这是它留给你二次开发的空间。4. 踩坑排查OLED不亮、花屏、乱码的三个高频问题4.1 地址确认0x3C 还是 0x3D现象I2C 扫描不到任何设备或者屏幕偶尔闪一下内容完全不对。原因SSD1306 的 I2C 地址由 SA0 引脚的上下拉决定。接地时地址是 0x3C接高电平时是 0x3D。有些模块的 SA0 没有单独引出直接在 PCB 上焊成了一种固定电平。大多数例程默认按 0x3C 写如果你的模块上 SA0 被接到了 VCC自然怎么发都没反应。解决先用一个 I2C 扫描程序把所有地址探一遍确认实际设备地址。然后检查你的底层代码用的是 7 位地址还是 8 位地址——不少例程里写的是 0x78那是把 0x3C 左移了一位的结果发送前想清楚你的驱动是否内含移位别在同一个变量上重复位移。4.2 电平不匹配3.3V逻辑配5V单片机现象屏幕背光正常但内容黑屏或乱码偶发性得很厉害。原因OLED 模块的逻辑电平通常是 3.3V直接用 5V 单片机的 GPIO 去推 SCL/SDASSD1306 内部引脚的电平阈值会被拉出额定范围I2C 时序的上升沿变缓、数字信号判定错乱通信自然不稳定。51 和 Arduino 这类 5V 主控最容易撞上这个问题。解决最稳妥的是加一个 3.3V 电平转换模块把 SCL、SDA 和 RES 全部走转换后再接屏幕。如果你坚持直连可以尝试 I2C 开漏输出加外部上拉到 3.3V但这只适用于支持开漏模式的引脚而且 SPI 方式下千万不要用开漏会把输出速度拖到完全不可用。4.3 复位与上电时序先拉高还是先发命令现象程序刚下载完第一次运行正常按一下复位键屏幕就黑掉必须断电重启才能恢复。原因SSD1306 的复位引脚 RES 需要先拉低再拉高的一个完整脉冲很多例程只在 init 里配置了 GPIO 方向却忘了做这个复位时序。按下复位键时RES 被直接拉到低电平如果软件里没有重新拉高的动作控制器就一直停在未初始化状态。解决在 ssd1306_init 最开头把 RES 拉低延时至少 10ms再拉高延时 10ms然后再发命令序列。这段延时不能省有些模块内部已经焊了 RC 复位电路如果外部再给一个过快脉冲RC 电容会把低电平时间吃掉导致复位无效。我在 STM32 平台上还遇到过一种情况MCU 复位时 I2C 总线上正好有残留数据屏内状态机卡死这种情况要在初始化前先给一个停止信号让总线回到空闲态。4.4 电荷泵没开启导致白屏现象屏幕亮起来是全白或者全暗但用强光侧着看能隐约看到点阵的痕迹。原因SSD1306 内部有一个 DC-DC 电荷泵专门用来产生屏幕驱动电压。如果初始化序列里的 0x8D 后面跟的是 0x10电荷泵被关闭屏幕缺少驱动电压会停在白屏或暗屏状态。很多从别处抄来的初始化序列把这两条漏掉了。解决检查初始化里有没有完整的两条命令 0x8D 0x14。如果你用的是带外部升压电路的模块电荷泵不开也能亮但绝大多数裸屏模组内部都依赖这颗电荷泵。还有个快速判断法看模块背面有没有一颗小的升压电容有的话就必须把电荷泵打开。5. 实战运行在三个硬件平台上把例程跑起来5.1 用 STM32 标准库点亮 I2C 版 OLEDSTM32 目录下给的是标准外设库版本。拿到例程后先确认 PB6/PB7 是否分配给了 I2C1然后找到底层的 write_cmd 实现。核心动作是发送一个“控制字节 数据字节”的 I2C 帧标准库的写法大致这样void ssd1306_i2c_write(uint8_t control, uint8_t *buf, uint16_t len) { I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, 0x3C 1, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, control); /* 0x00 命令控制字节 / 0x40 数据控制字节 */ while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); for (uint16_t i 0; i len; i) { I2C_SendData(I2C1, buf[i]); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); }调用方式很直接ssd1306_init(); ssd1306_draw_string(0, 0, Hello STM32); ssd1306_refresh();I2C 事件标志的轮询最好加超时保护不然某个从设备没有应答会把主循环卡死。上面的标准库事件轮询方式在 400KHz 下没有压力如果换成 HAL 库可以直接用 HAL_I2C_Mem_Write 简化代码把 DDRAM 地址当作 mem address 传入。点亮之后先画一个点验证坐标方向再显示字符串这样能快速排查左右镜像问题。5.2 在 ESP32 上把例程跑起来ESP32 目录下提供了纯 Wire 库直驱版本。配置非常简单两根信号线可以接到任意 GPIO我用最直接的方式演示#include Wire.h #define OLED_ADDR 0x3C #define SCL_PIN 15 #define SDA_PIN 4 void oledWriteCmd(uint8_t cmd) { Wire.beginTransmission(OLED_ADDR); Wire.write(0x00); /* 命令控制字节 */ Wire.write(cmd); Wire.endTransmission(); } void setup() { Wire.begin(SDA_PIN, SCL_PIN, 400000); /* 参数顺序SDA, SCL, 400KHz */ ssd1306_init(); ssd1306_draw_string(0, 0, ESP32 OK); ssd1306_refresh(); } void loop() {}这里有一个高频踩坑点Wire.begin 的第一个参数是 SDA第二个才是 SCL这个顺序和大多数人的习惯相反写反了屏幕会一直不亮。另外避开 GPIO12它在 ESP32 上电时默认状态会影响启动模式选 GPIO15/GPIO4 这类引脚更省心。烧录后打开串口监视器如果初始化过程中有任何 I2C 报错优先检查接线顺序和地址。5.3 在 51 平台上用 SPI 方式驱动 OLED51 系列因为 RAM 小用 SPI 方式驱动比 I2C 更省内存也更快。这套例程用普通 GPIO 模拟 SPI 时序代码很直观sbit OLED_SCL P1^0; /* 时钟 */ sbit OLED_SDA P1^1; /* 数据 */ sbit OLED_DC P1^2; /* 数据/命令选择 */ sbit OLED_RST P1^3; /* 复位 */ void spi_write_byte(uint8_t dat) { for (uint8_t i 0; i 8; i) { OLED_SCL 0; OLED_SDA (dat 0x80) ? 1 : 0; dat 1; OLED_SCL 1; /* 上升沿锁存数据 */ } }注意这里高位先出和 SSD1306 的 SPI 协议一致。发送命令时把 DC 拉低发送数据时把 DC 拉高其余字节顺序和 I2C 完全一样。因为 51 的 RAM 放不下 1024 字节的全屏缓冲区这套 51 例程把显存缓冲改成了页缓冲一次只缓存 128 字节画完一页直接发送。如果你的内容比较复杂可以在外部扩展 RAM 上开一个 1024 字节的完整缓冲区视觉效果好很多。跑通这套 SPI 例程最少只需要四根线加电源引脚几乎可以任意映射实测 GPIO 模拟 SPI 在 1MHz 左右时钟下刷新完全够用。平台总线方式显存策略主要修改点51GPIO 模拟 SPI页缓冲 128B字节发送函数STM32I2C 外设全屏缓冲 1024BI2C 事件处理ESP32Wire 库全屏缓冲 1024BWire.begin 引脚顺序三份例程跑通后的现象完全一致屏幕点亮左上角显示对应的字符串。差异只在底层传输和内存策略这也反过来验证了这套架构分层是合理的。6. 二次开发把驱动改造成可以复用的显示工具箱6.1 改造点一让刷新不再阻塞 CPU现在的刷新函数是同步阻塞的。做温度计这类低频更新场合够用但要带动画的菜单就会卡。常见做法是给显存加一层页级脏标记static uint8_t dirty_pages 0xFF; /* 位图标记哪些页需要刷新 */ void ssd1306_mark_dirty_all(void) { dirty_pages 0xFF; } void ssd1306_refresh_page(uint8_t page) { if (!(dirty_pages (1 page))) return; ssd1306_set_cursor(page, 0); hal.write_data(frame_buffer[page][0], 128); dirty_pages ~(1 page); }再配合一个“只改一页”的接口比如画完温度数字后只更新温度所在的那一页单次刷新时间能压到 2ms 级别。我改完之后习惯在定时器里翻转一个测试 GPIO用逻辑分析仪量一下刷新脉宽确认脏页标记逻辑真的生效。如果发现某一页一直不更新多半是 dirty 标记被提前清除了调试时先把 dirty_pages 固定成 0xFF 排查。6.2 改造点二给显示加一层页面调度我的习惯是把屏幕内容拆成若干“页面”每个页面是一个绘制函数用一个简单的状态机决定当前显示哪一页。这套例程只提供绘图原语页面概念需要你自己加typedef struct { void (*draw)(void); void (*on_enter)(void); } oled_page_t;然后在主循环里根据按键事件切换页面函数指针。比如菜单页负责高亮当前项详情页显示具体数值状态栏永远固定在最上方。两层显示合成可以用一个小的图层缓冲区把内容合并后再统一刷新。这样业务层不再关心坐标和刷新时机上层只关心“现在处于哪个页面”底层统一调用 draw 和 refresh重复代码会大幅减少。这套改造不需要动 common 里的核心代码在它之上加一层调度就行。从那以后我每次拿到一款新屏幕都先写一个最小点亮 demo确认底层时序没问题再往上叠加页面调度避免在业务逻辑里排查硬件问题。这个习惯帮我避开了很多莫名其妙的显示故障希望帮到你。本文还有配套的精品资源点击获取