简介:这份资源是面向嵌入式开发初学者与硬件工程师的OLED驱动代码包,针对SSD1306与SSD1315两款常用控制器,解决显示屏初始化、字符图形绘制与刷新控制等底层驱动问题,适用于智能穿戴、便携仪表等小尺寸显示场景。压缩包共4个文件,包含2个C源文件与2个H头文件,整体约13KB,其中驱动实现与字体数据分离,头文件提供对外接口声明,便于直接移植到I2C或SPI通信的硬件平台。目前已有353人学习下载,可作为入门OLED显示的参考实现。代码围绕寄存器配置、显示模式设置、对比度调节与像素级控制展开,读者可据此理解自发光屏的灰度显示原理,并在此基础上扩展图形与文字显示功能,快速完成驱动调试与模块化封装。
1. OLED驱动代码(SSD1306/SSD1315):一块0.96寸屏背后的时序暗礁
手里拿到一块0.96寸OLED模块,SSD1306或SSD1315驱动芯片,I2C或SPI接口,第一反应往往是“不就写几个寄存器吗”。真动手才发现,屏幕要么全黑,要么花屏,要么亮一下就灭,示波器抓波形也看不出明显异常。问题不在代码写没写对,而在初始化序列的时序、电荷泵配置、显存分页映射这三处细节上。SSD1306和SSD1315在寄存器层面高度兼容,但SSD1315在电荷泵启动时间和预充电周期上略有差异,直接套用同一份初始化代码,SSD1315可能偶发上电不亮。这篇笔记面向正在用MCU裸机驱动OLED的嵌入式开发者,从I2C地址确认、初始化命令表、显存写入方式到常见翻车点,给出一套可复现的驱动框架。不依赖特定厂商库,HAL层只做I2C收发,其余全部自己控制。
2. 先搞清SSD1306和SSD1315的寄存器差异,再决定初始化表怎么写
2.1 两颗芯片的兼容边界与选型判断
SSD1306和SSD1315在命令集上几乎一致,0xAE关显示、0xAF开显示、0x20设置寻址模式、0xB0到0xB7设置页地址、0x00到0x0F设置列低地址、0x10到0x1F设置列高地址,这些命令两者通用。差异集中在电荷泵和预充电阶段。SSD1306的电荷泵使能命令是0x8D后跟0x14,SSD1315同样接受0x8D和0x14,但SSD1315内部电荷泵的软启动时间更长,如果上电后立即发送0xAF开显示,SSD1315可能因为VCC内部稳压未建立而出现首帧不亮。稳妥做法是在0x8D 0x14之后插入至少100ms延时,再发送0xAF。另一个差异是SSD1315的对比度寄存器0x81默认值略低,如果直接沿用SSD1306的0xCF,SSD1315可能偏暗,建议从0x7F开始调。
选型上,如果模块已经焊好,看排线或PCB丝印上的驱动IC型号,没有丝印就用I2C扫描确认地址。SSD1306的I2C地址通常为0x3C或0x3D,SSD1315也是这两个地址,但个别模块出厂时把SA0引脚拉高,地址变成0x3D。地址不对,后续所有命令都无响应,这是新手第一个翻车点。
2.2 初始化命令表的逐条拆解与参数依据
下面是一份针对SSD1315优化过的初始化命令表,SSD1306可直接复用,只需把延时从100ms缩短到10ms。命令以I2C控制字节0x00开头表示后续为命令流,0x40开头表示后续为数据流。
// SSD1315/SSD1306 初始化命令序列,I2C控制字节0x00表示命令 const uint8_t oled_init_cmds[] = { 0x00, // 控制字节:命令流 0xAE, // 关闭显示,初始化期间必须关,避免花屏 0xD5, 0x80, // 设置时钟分频因子,0x80为默认,高频MCU可适当降低 0xA8, 0x3F, // 设置多路复用比,0x3F对应64行,0.96寸屏固定64行 0xD3, 0x00, // 设置显示偏移,通常为0 0x40, // 设置显示起始行,从第0行开始 0x8D, 0x14, // 电荷泵使能,0x14为内部VCC开启,必须发 0x20, 0x00, // 设置内存寻址模式,0x00为水平寻址,0x02为页寻址 0xA1, // 段重映射,0xA1为左右翻转,根据排线方向调整 0xC8, // 扫描方向,0xC8为上下翻转,配合0xA1使用 0xDA, 0x12, // 设置COM引脚硬件配置,0x12为交替模式,0.96寸常用 0x81, 0x7F, // 设置对比度,SSD1315从0x7F起调,SSD1306可用0xCF 0xD9, 0xF1, // 设置预充电周期,0xF1为推荐值,影响亮度和响应 0xDB, 0x40, // 设置VCOMH电压,0x40为默认,过高会偏亮 0xA4, // 全局显示开启,0xA4表示跟随显存内容 0xA6, // 正常显示,0xA6不反色,0xA7为反色 0xAF // 开启显示,必须在电荷泵稳定后发送 };这份表里最容易被忽略的是0xD9预充电周期和0xDB VCOMH电压。0xD9的0xF1拆开看,高4位是预充电周期,低4位是放电周期,0xF1意味着预充电15个时钟、放电1个时钟。如果屏幕出现边缘暗、中间亮,优先调0xD9。0xDB的0x40对应VCOMH电压约0.83V,调高到0x50会明显变亮但功耗上升,调低到0x30则偏暗。这两个参数没有绝对最优,取决于屏体批次,建议留出宏定义方便现场调。
2.3 I2C地址确认与最小通信验证
在写完整初始化之前,先用一段最小代码确认I2C链路通。下面这段基于HAL库的扫描代码,遍历0x08到0x77,找到应答地址。
// I2C地址扫描,确认OLED模块地址 void oled_i2c_scan(I2C_HandleTypeDef *hi2c) { for (uint8_t addr = 0x08; addr < 0x78; addr++) { if (HAL_I2C_IsDeviceReady(hi2c, addr << 1, 2, 10) == HAL_OK) { printf("OLED found at 0x%02X\r\n", addr); } } }HAL_I2C_IsDeviceReady的第二个参数是左移一位后的地址,因为HAL库内部把最低位当读写位。扫描到0x3C或0x3D后,先发一条0xAE关显示命令,用逻辑分析仪看是否有ACK。如果没有ACK,检查上拉电阻,OLED模块通常自带4.7k上拉,但有些裸屏需要外部加。上拉电阻太大,波形上升沿变缓,400kHz速率下会通信失败,降到100kHz可能又能用,这是典型的硬件问题伪装成软件问题。
3. 显存写入与页寻址:把像素点亮起来的最小闭环
3.1 水平寻址和页寻址的选择依据
SSD1306/SSD1315的显存是128x64位,分为8页,每页8行,每列对应一个字节。水平寻址模式下,写入一个字节后列地址自动加1,跨页时自动跳到下一页,适合整屏刷新。页寻址模式下,列地址在页内递增,写到页尾后需要手动设置下一页,适合局部刷新。如果屏幕刷新率要求不高,比如每秒10帧以内,水平寻址整屏刷最省事。如果只更新局部区域,比如状态栏,页寻址能减少I2C数据量。
设置水平寻址的命令是0x20 0x00,然后设置列地址范围0x21 0x00 0x7F,页地址范围0x22 0x00 0x07。设置页寻址的命令是0x20 0x02,然后0xB0到0xB7选页,0x00到0x0F设列低地址,0x10到0x1F设列高地址。注意0x20命令之后必须跟一个参数,0x00或0x02,漏掉参数会导致后续命令错位,屏幕表现是随机花屏。
3.2 整屏刷新函数与显存缓冲区设计
推荐在MCU内存里开一个1024字节的缓冲区,对应128x64位。所有绘图操作先改缓冲区,再一次性通过I2C发送,避免频繁I2C事务导致闪烁。
// 显存缓冲区,1024字节对应128x64 static uint8_t oled_buf[1024]; // 整屏刷新,水平寻址模式下一次发送1024字节 void oled_refresh(I2C_HandleTypeDef *hi2c, uint8_t addr) { uint8_t cmd[] = {0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07}; HAL_I2C_Master_Transmit(hi2c, addr << 1, cmd, sizeof(cmd), 100); // 控制字节0x40表示后续为显存数据 uint8_t ctrl = 0x40; HAL_I2C_Master_Transmit(hi2c, addr << 1, &ctrl, 1, 100); HAL_I2C_Master_Transmit(hi2c, addr << 1, oled_buf, 1024, 1000); }这里分三次I2C传输:先发命令设置列和页范围,再发控制字节0x40,最后发1024字节数据。有些驱动把控制字节和命令合并,但分开写更清晰。HAL_I2C_Master_Transmit的超时参数给1000ms,因为1024字节在100kHz速率下需要约90ms,400kHz约23ms,超时给太小会误报失败。如果MCU的I2C外设支持DMA,把最后一步改成DMA传输,CPU占用率从90%降到接近0。
3.3 打点函数与页内位操作
在缓冲区上打点,需要根据y坐标算出页号和页内位偏移。y从0到63,页号是y除以8,位偏移是y对8取余。
// 在缓冲区打点,x范围0-127,y范围0-63 void oled_draw_pixel(uint8_t x, uint8_t y, uint8_t color) { if (x >= 128 || y >= 64) return; uint8_t page = y >> 3; // 页号,0-7 uint8_t bit = y & 0x07; // 页内位偏移,0-7 uint16_t idx = page * 128 + x; // 缓冲区索引 if (color) { oled_buf[idx] |= (1 << bit); } else { oled_buf[idx] &= ~(1 << bit); } }注意位偏移的方向。SSD1306的页内位0对应页内最上面一行,位7对应最下面一行。如果打点后上下颠倒,检查0xC8扫描方向命令是否发对。0xC8是上下翻转,0xC0是不翻转。配合0xA1段重映射,四种组合对应屏幕的四个旋转方向。实际调试时,先画一个从(0,0)到(127,63)的对角线,看方向对不对,再调这两个命令。
4. 避坑与排查:OLED驱动代码最常见的五类翻车
4.1 上电全黑,但I2C有ACK
现象:扫描到地址,发命令有ACK,屏幕完全不亮。原因通常是电荷泵命令0x8D 0x14没发,或者0xAF发得太早。SSD1315的电荷泵软启动需要时间,如果0xAF紧跟0x8D 0x14,内部VCC还没建立,显示驱动没有工作电压。解决:在0x8D 0x14之后加100ms延时,再发0xAF。如果用的是SSD1306,10ms足够。另一个可能是0xA4没发,0xA4是全局显示跟随显存,如果误发0xA5,屏幕会全亮而不显示内容。
4.2 屏幕花屏,随机噪点
现象:上电后屏幕出现随机亮暗点,不随显存内容变化。原因通常是初始化命令序列错位,某个命令少发了参数。比如0x20后面必须跟0x00或0x02,如果漏掉,下一条命令会被当成参数吃掉,后续所有命令偏移。解决:用逻辑分析仪抓I2C波形,逐条核对命令和参数。另一个可能是I2C速率过高,400kHz下如果上拉电阻大于10k,波形上升沿变缓,数据采样出错。把速率降到100kHz测试,如果花屏消失,就是硬件问题。
4.3 显示内容上下颠倒或左右镜像
现象:打点坐标和实际显示位置不符,比如(0,0)显示在左下角。原因是0xA1和0xC8的组合不对。0xA1是段重映射,0xC8是扫描方向。四种组合对应四个旋转方向。解决:先发0xA1和0xC8,画对角线确认。如果上下颠倒,把0xC8改成0xC0。如果左右镜像,把0xA1改成0xA0。注意这两个命令在初始化序列里的位置,必须在0xAF之前发,运行时改也可以,但会闪一下。
4.4 局部刷新时内容错位
现象:用页寻址模式更新局部区域,内容出现在错误的页或列。原因是页寻址模式下,每次写数据前必须重新设置页地址和列地址。如果连续写两页,第一页写完后列地址停在页尾,第二页没有重新设列地址,数据会从页尾继续写,导致错位。解决:每次切换页时,发0xB0+页号、0x00、0x10三条命令,把列地址复位到0。或者改用水平寻址模式,设置一次列和页范围后连续写,不用手动切页。
4.5 屏幕亮度不均,边缘暗中间亮
现象:整屏点亮后,四周比中间暗。原因是预充电周期和VCOMH电压不匹配。0xD9的预充电周期太短,边缘像素充电不足。解决:把0xD9从0xF1改成0xF5或0xF9,增加预充电时钟数。同时把0xDB从0x40调到0x50,提高VCOMH电压。两个参数配合调,每次改一个,观察亮度变化。如果调完还是不均,可能是屏体本身的问题,换一块模块对比。
5. 进阶技巧:用DMA加双缓冲把刷新率推到100帧
5.1 双缓冲机制与DMA传输链
单缓冲下,绘图和I2C传输不能并行,刷新率受限于I2C速率。128x64整屏1024字节,400kHz I2C下传输约23ms,理论最高43帧。如果开双缓冲,一个缓冲区用于绘图,另一个用于DMA传输,传输期间CPU继续绘图,传输完成后交换缓冲区指针。这样刷新率可以接近I2C带宽上限,实际测试能到80到100帧。
// 双缓冲,两个1024字节缓冲区 static uint8_t oled_buf_a[1024]; static uint8_t oled_buf_b[1024]; static uint8_t *draw_buf = oled_buf_a; // 绘图缓冲区 static uint8_t *send_buf = oled_buf_b; // 发送缓冲区 static volatile uint8_t dma_done = 1; // DMA完成标志 // DMA传输完成回调 void HAL_I2C_MasterTxCpltCallback(I2C_HandleTypeDef *hi2c) { dma_done = 1; } // 交换缓冲区并启动DMA void oled_swap_and_send(I2C_HandleTypeDef *hi2c, uint8_t addr) { if (!dma_done) return; // 上一次DMA未完成,跳过本帧 dma_done = 0; uint8_t *tmp = draw_buf; draw_buf = send_buf; send_buf = tmp; uint8_t cmd[] = {0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07}; HAL_I2C_Master_Transmit(hi2c, addr << 1, cmd, sizeof(cmd), 100); uint8_t ctrl = 0x40; HAL_I2C_Master_Transmit(hi2c, addr << 1, &ctrl, 1, 100); HAL_I2C_Master_Transmit_DMA(hi2c, addr << 1, send_buf, 1024); }关键点是dma_done标志。如果上一帧DMA还没完成就启动下一帧,I2C总线冲突,屏幕会花。用volatile修饰,因为它在中断里被修改。交换缓冲区时,draw_buf和send_buf互换,绘图操作始终写draw_buf,DMA始终读send_buf。注意HAL_I2C_Master_Transmit_DMA的地址参数也要左移一位。
5.2 局部刷新与脏矩形标记
双缓冲整屏刷虽然快,但I2C数据量还是1024字节。如果只更新一个小区域,比如时间显示,可以标记脏矩形,只传输脏矩形覆盖的页和列。实现方式是在打点函数里记录最小和最大的x、y,刷新时只发这个范围的数据。
// 脏矩形边界 static uint8_t dirty_x_min = 127, dirty_x_max = 0; static uint8_t dirty_y_min = 63, dirty_y_max = 0; // 打点时更新脏矩形 void oled_draw_pixel_dirty(uint8_t x, uint8_t y, uint8_t color) { oled_draw_pixel(x, y, color); if (x < dirty_x_min) dirty_x_min = x; if (x > dirty_x_max) dirty_x_max = x; if (y < dirty_y_min) dirty_y_min = y; if (y > dirty_y_max) dirty_y_max = y; } // 局部刷新,只发送脏矩形覆盖的页 void oled_refresh_dirty(I2C_HandleTypeDef *hi2c, uint8_t addr) { if (dirty_x_min > dirty_x_max) return; // 无脏区域 uint8_t page_start = dirty_y_min >> 3; uint8_t page_end = dirty_y_max >> 3; for (uint8_t p = page_start; p <= page_end; p++) { uint8_t cmd[] = {0x00, 0xB0 + p, 0x00 + (dirty_x_min & 0x0F), 0x10 + (dirty_x_min >> 4)}; HAL_I2C_Master_Transmit(hi2c, addr << 1, cmd, sizeof(cmd), 100); uint8_t ctrl = 0x40; HAL_I2C_Master_Transmit(hi2c, addr << 1, &ctrl, 1, 100); uint8_t width = dirty_x_max - dirty_x_min + 1; HAL_I2C_Master_Transmit(hi2c, addr << 1, &draw_buf[p * 128 + dirty_x_min], width, 100); } dirty_x_min = 127; dirty_x_max = 0; dirty_y_min = 63; dirty_y_max = 0; }这里用的是页寻址模式,每条命令设置页号和列地址,然后发该页内脏矩形宽度的数据。注意列地址的高4位和低4位要分开算,0x00加低4位,0x10加高4位。局部刷新适合UI场景,比如只更新数值区域,I2C数据量从1024字节降到几十字节,刷新率轻松上200帧。
5.3 用逻辑分析仪验证时序的实操方法
调OLED驱动,逻辑分析仪比示波器好用。抓I2C波形,重点看三个地方:起始条件后的地址字节是否收到ACK,命令字节后的ACK,以及数据字节流的连续性。如果地址字节无ACK,查上拉和地址。如果命令字节无ACK,查从机是否忙。如果数据流中间出现停止条件,查DMA是否被中断打断。采样率设1MHz以上,能看清400kHz的波形细节。抓到的波形和初始化命令表逐条对比,哪条命令少了参数一目了然。
我自己的习惯是,每调一块新屏,先跑I2C扫描,再发0xAE和0xAF看屏幕有没有反应,确认链路通再上完整初始化表。这个顺序能省掉很多来回改代码的时间。希望帮到你。
本文还有配套的精品资源,点击获取