简介:一份围绕STM32F103C8T6微控制器的OLED显示屏驱动程序资源,面向嵌入式开发、物联网及智能硬件爱好者,帮助解决OLED屏与STM32之间的接口驱动与显示控制问题。资源包共134个文件,包含C源文件与H头文件、Keil工程配置、编译生成的axf/hex等固件文件,以及备份与说明文档,整体约1.79MB。已有6544人学习下载。驱动代码涵盖屏幕初始化、SPI/I2C或GPIO接口通信、字符与图像显示逻辑,并附带TIM、Flash等外设配置示例;工程文件可直接在MDK中编译调试,便于开发者对照硬件时序排查问题。对于需要在智能仪表、便携设备上实现低功耗高对比度显示的应用场景,这份资料能帮助快速上手OLED驱动开发,减少底层适配时间,也适合初学者通过源码学习STM32的显示编程思路。
1. 一块SSD1306 OLED,接上STM32F103C8T6就白屏了
买过0.96寸OLED模块的人基本都经历过这个场景:四根杜邦线往STM32F103C8T6最小系统板上一插,例程烧进去,屏幕没有任何反应,甚至供电正常也只是一片均匀的背光。问题几乎都出在驱动上——SSD1306这颗控制芯片的初始化序列、I2C时序和数据通道切换,任何一个环节错了,屏就是一块白板。这个压缩包里的OLED驱动工程就是直接可用的Keil MDK项目,针对F103C8T6标准外设库环境,覆盖了I2C通信、SSD1306初始化、字符显示和工程文件管理。适合正在做电子钟、温湿度计、仪表显示或者想搞明白OLED底层时序的嵌入式开发者和物联网爱好者阅读。
2. SSD1306驱动核心:I2C时序与页寻址显存模型
2.1.1 从硬件连接看I2C的优势
F103C8T6在最小系统板上引出了全部GPIO,驱动0.96寸OLED模块只需要两根信号线。这个工程用的是I2C模式,具体引脚定义在工程头文件的宏里,常见接法是PB8接SCL、PB9接SDA,也可以用PA8/PA9或者任意两个普通IO口模拟时序。硬件I2C和GPIO模拟I2C的区别在于前者依赖芯片内部外设,需要处理总线状态寄存器,后者用工时换灵活度。实际项目里我倾向于GPIO模拟,原因有两个:一是OLED的I2C速率只需要100kHz到400kHz,CPU完全忙得过来;二是模拟I2C可以把SCL和SDA接到任意引脚,不受硬件外设复用限制,改板子时不用重新配置AFIO。
2.1.2 SSD1306显存的分页机制
SSD1306内置了128 byte的显存,但它不是线性排列的,而是按页组织。128x64分辨率的屏幕被分成8页,每页高8个像素,页0到页7对应从屏幕顶部到底部的8个水平条带。每一页里有128列,每列对应一个字节,这个字节的每一位代表这一列上某一行像素是否点亮,位0对应页内最上面那行。理解了这套模型,画点函数就好写了:先算y坐标属于哪一页,再算该页第几行,最后对相应列做位运算。
页地址通过命令0xB0到0xB7设置,列地址分低4位和高4位两部分,0x00到0x0F是列低地址,0x10到0x17是列高地址。写入顺序是先发页地址、再发列低地址、最后发列高地址。这套寻址方式困扰过不少人,尤其是从LCD转过来的朋友,习惯了线性显存会觉得页地址莫名其妙。
2.1.3 指令通道和数据通道的切换
I2C总线上所有内容都走同一条物理链路,SSD1306靠控制字节区分指令和数据。第一个字节是slave地址,0x78是写地址、0x7A是读地址,市面上绝大多数OLED模块的I2C地址是0x3C,左移一位就是0x78。第二个字节是控制通道标识,0x00表示后续字节是命令,0x40表示是显存数据。这个设计非常克制,只有两个值,但搞反了就会出现屏幕乱码或者完全不亮。
处理时序的底层函数里,起始条件、停止条件、应答位这些细节都要逐位操作。用GPIO模拟时先拉高SDA再拉低SCL产生起始条件,停止条件则相反。总线忙碌时两条线都要保持高电平,这是I2C协议的硬性规定,很多白屏问题就出在起始条件前总线状态没复位。
3. 从零写OLED驱动:初始化序列与显示函数实现
3.1.1 底层I2C读写函数的实现
// 起始条件:SCL高电平期间,SDA由高变低 void I2C_Start(void) { OLED_SDA_H(); OLED_SCL_H(); OLED_SDA_L(); OLED_SCL_L(); } // 停止条件:SCL高电平期间,SDA由低变高 void I2C_Stop(void) { OLED_SDA_L(); OLED_SCL_H(); OLED_SDA_H(); }起始条件的时序要求是SDA必须先于SCL跳变,而且SCL为高时SDA保持稳定。代码里先拉高SDA、再拉高SCL、然后拉低SDA、最后拉低SCL,顺序反了设备会不识别起始位。停止条件的高低电平切换过程恰好相反,先把SDA拉低,拉高SCL后再释放SDA。
3.1.2 写字节与ACK处理
void I2C_WriteByte(uint8_t dat) { for (uint8_t i = 0; i < 8; i++) { if (dat & 0x80) OLED_SDA_H(); else OLED_SDA_L(); dat <<= 1; OLED_SCL_H(); OLED_SCL_L(); } } void OLED_WriteCmd(uint8_t cmd) { I2C_Start(); I2C_WriteByte(0x78); // 器件地址,0x3C左移一位 I2C_WriteByte(0x00); // 后续字节为命令 I2C_WriteByte(cmd); I2C_Stop(); } void OLED_WriteData(uint8_t dat) { I2C_Start(); I2C_WriteByte(0x78); I2C_WriteByte(0x40); // 后续字节为显存数据 I2C_WriteByte(dat); I2C_Stop(); }写一个完整字节需要8个时钟周期,每个周期内先设置SDA电平再产生SCL上升沿。0x78是器件写地址,0x3C是7位地址,两者关系是左移一位,这是I2C协议固化的格式。ACK应答位这里没有单独判断,因为SSD1306对指令的响应速度足够快,在400kHz时钟下不会出现死等,省略NACK检查能减少代码量和总线占用时间。0x00和0x40这两个控制字节是硬编码的,如果后期需要打开I2C的连续读写模式,才需要额外处理控制字节中的Co位和D/C位。
3.1.3 SSD1306初始化序列逐条解释
void OLED_Init(void) { delay_ms(100); // 上电后等电源稳定 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); // 设置内存寻址模式 OLED_WriteCmd(0x02); // 页寻址模式 OLED_WriteCmd(0xB0); // 页地址0 OLED_WriteCmd(0xC8); // 扫描方向:从下到上 OLED_WriteCmd(0x00); // 列地址低字节0 OLED_WriteCmd(0x10); // 列地址高字节0 OLED_WriteCmd(0x40); // 显示起始行第0行 OLED_WriteCmd(0x81); // 对比度设置 OLED_WriteCmd(0x7F); // 对比度值,范围0x00~0xFF OLED_WriteCmd(0xA1); // 段重映射,左右反置 OLED_WriteCmd(0xA6); // 正常显示,A7为反色 OLED_WriteCmd(0xA8); // 多路复用比率 OLED_WriteCmd(0x3F); // 64行 OLED_WriteCmd(0xA4); // 关闭整屏点亮 OLED_WriteCmd(0xD3); // 显示偏移 OLED_WriteCmd(0x00); // 偏移0 OLED_WriteCmd(0xD5); // 时钟分频 OLED_WriteCmd(0x80); // 频率因子 OLED_WriteCmd(0xD9); // 预充电周期 OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDA); // COM引脚配置 OLED_WriteCmd(0x12); OLED_WriteCmd(0xDB); // VCOMH电压级 OLED_WriteCmd(0x40); OLED_WriteCmd(0x8D); // 电荷泵开关 OLED_WriteCmd(0x14); // 开启 OLED_WriteCmd(0xAF); // 开启显示 OLED_Clear(); }0xAE和0xAF这组开关是最容易忽略的。很多人写完初始化序列发现屏幕不亮,检查半天硬件,最后发现是漏了0xAF。0x8D加0x14是电荷泵使能,没有这一步屏幕连背光都不会亮,这是SSD1306特有的电源管理机制。0xC8和0xA1决定了屏幕的上下左右方向,如果显示的是镜像或者倒置,问题就出在这两条命令上。0x20加0x02把寻址模式锁定在页模式,显存地址自动换行,适合字符类UI绘制,画图或者滚动显示时更适合改成水平寻址模式(0x00)。
3.1.4 画点、清屏与整屏刷新
void OLED_SetPos(uint8_t x, uint8_t y) { OLED_WriteCmd(0xB0 + y / 8); // 页地址,y范围0~63 OLED_WriteCmd(0x00 + (x & 0x0F)); // 列低地址 OLED_WriteCmd(0x10 + (x >> 4)); // 列高地址 } void OLED_Clear(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); OLED_WriteCmd(0x00); OLED_WriteCmd(0x10); for (uint8_t col = 0; col < 128; col++) OLED_WriteData(0x00); } }OLED_SetPos里的关键运算是y / 8和x & 0x0F,前者把像素行坐标换算成页号,后者取出列坐标的低四位作为列低地址。清屏函数循环遍历8页和128列,每次写入0x00。整屏刷新一次需要写1024个字节,在400kHz I2C下大约耗时几十毫秒,肉眼能感觉到轻微闪烁。这个工程没有做显存缓冲,直接写SSD1306的内部RAM,好处是省了F103的RAM空间——要知道C8T6只有20KB RAM,开一个128x64的缓冲要占8KB。代价是连续刷新时I2C总线占用率高,这和后面要讲的局部刷新方案直接相关。
3.1.5 显示字符串与ASCII字库
const uint8_t F8x16[][16] = { // 字符 'A',字体宽8像素、高16像素 {0x00,0x00,0x00,0xFC,0x66,0x66,0x66,0x7C, 0x60,0x60,0x62,0x3C,0x00,0x00,0x00,0x00}, // 其他字符... }; void OLED_ShowChar(uint8_t x, uint8_t y, char ch) { OLED_SetPos(x, y); for (uint8_t i = 0; i < 8; i++) OLED_WriteData(F8x16[ch - ' '][i]); OLED_SetPos(x, y + 8); for (uint8_t i = 8; i < 16; i++) OLED_WriteData(F8x16[ch - ' '][i]); }16像素高的字符被拆成上下两半写入,上半部分在y页,下半部分在y+8页。取模工具里常用的阴码、逐列式、C51格式就是生成这类数组的标准配置。字库数组用ch - ' '做索引,ASCII码0x20之前都是控制字符,不需要存储,空格从0号索引开始,这样能省一部分Flash空间。F103C8T6的64KB Flash装几个完整字库绰绰有余,但如果是1.44寸的OLED带中文字库,128KB Flash的芯片会更有余量。
4. Keil MDK工程集成:标准外设库配置与ST-Link烧录
4.1.1 工程文件梳理:哪些能删,哪些不能删
压缩包里那几个uvgui和.bak文件是不同使用者在不同电脑上打开工程时留下的窗口布局缓存,对编译结果零影响,可以放心删除。STM32-DEMO.axf是上一次编译生成的ELF格式镜像文件,每次重新编译会自动覆盖,删掉也不影响工程。真正不能动的是stm32f10x_tim.c和stm32f10x_flash.c这两个源文件,它们属于标准外设库的定时器和Flash驱动模块。
这个工程用的是标准外设库(SPL)而不是HAL库,从文件命名和#include "stm32f10x.h"的风格就能判断。标准外设库的API更底层,操作寄存器更直接,适合想弄明白外设工作原理的场景。如果更习惯HAL库,工程改写时主要关注点有两个:GPIO初始化从GPIO_InitTypeDef变成HAL_GPIO_Init,有延时需求的函数从delay_ms换成HAL_Delay。但I2C模拟时序部分几乎可以原封不动搬过去,这部分的代码不依赖任何库函数,只用了GPIO的高低电平操作。
4.1.2 引脚配置与I2C模式选择
GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure);推挽输出模式下,GPIO可以直接驱动OLED模块的SCL和SDA引脚,不需要外接上拉电阻——模块板上已经自带了。这里用50MHz的翻转速率是保守选择,模拟I2C的时序由代码里的延时函数控制,GPIO本身的翻转速度远超400kHz,不会成为瓶颈。如果改用硬件I2C外设,还需要额外配置AFIO时钟和I2C_InitTypeDef结构体,把I2C_ClockSpeed设为400000、I2C_DutyCycle设为I2C_DutyCycle_16_9,工作量比模拟方式大不少。
4.1.3 编译链接与ST-Link烧录
工程在Keil MDK里的配置要点如下:
| 配置项 | 推荐值 | 注意事项 |
|---|---|---|
| Device | STM32F103C8 | 不要选成C8T6的封装变体 |
| C/C++ Define | USE_STDPERIPH_DRIVER | 标准外设库必须启用 |
| Debugger | ST-Link | 对应CMSIS-DAP选别的选项 |
| Flash Download | Reset and Run 勾选 | 烧录后直接运行 |
| 优化级别 | -O0(调试阶段) | 发布前可以改-O2 |
烧录时ST-Link和板子之间用SWD四线连接:SWDIO、SWCLK、GND、3.3V。F103C8T6支持SWD和JTAG两种方式,SWD只需要两根信号线,接错的概率低很多。如果烧录时报No target connected,先检查BOOT0跳线帽是否接地,再确认板上有没有独立供电导致电位冲突。
4.1.4 白屏、花屏问题的排查顺序
白屏是最常见的故障现象,排查顺序有讲究。第一步确认OLED模块的I2C地址是0x3C还是0x3D,部分模块的地址选择电阻位不同;第二步检查初始化序列里0xAF和0x8D+0x14是否执行到位;第三步用万用表量SCL和SDA的对地电压,正常空闲状态应该都是高电平;第四步接逻辑分析仪抓起始条件,看slave地址有没有正确发出。花屏或者乱码则优先怀疑寻址模式混乱,把0x20后面跟的0x02改成0x00试试,因为很多网上流传的例程用的是水平寻址模式,初始化序列混用就会出问题。
提示:判断OLED是否正常工作,可以先把整个显存全部写入0xFF,屏幕全亮说明硬件链路无问题,再逐段排查驱动代码。
5. 进阶:16x16汉字显示、局部刷新与DMA加速
5.1.1 汉字取模与数据组织
汉字显示是OLED应用的常见需求,用16x16点阵存一个汉字需要32个字节。取模软件里设置为阴码、列行式、逆向即可,生成的数据按每8个字节一行、共4行的顺序排列。显示时把汉字当作两个8x16字符的拼接来处理,先显示左半边再显示右半边,就能拼出完整的16x16字符。中文字库数组按GB2312编码顺序排列,查询时用待显示汉字的机内码减去0xA0再计算索引。F103C8T6的64KB Flash存进几百个常用汉字没压力,但如果要装全字库,建议改用外部Flash或SD卡存储。
5.1.2 局部刷新策略
整屏刷新一次要传1024字节的数据,在400kHz I2C下大约需要20毫秒,动态更新时钟或者温度值时会出现明显的闪烁感。把寻址模式改为水平寻址后,可以有效缩小刷新范围。比如只更新时间数字所在的8x16字符区域,先定位到字符左上角的页地址和列地址,再连续写入16字节即可。这样的局部刷新能把单次传输量从1024字节降到一个字符的16字节,耗时缩短一个数量级,肉眼基本感知不到闪烁。
5.1.3 用空闲DMA通道推显存
如果项目里同时用到了定时器中断或者串口,CPU忙不过来,可以考虑用DMA搬运显存。F103C8T6内置2个DMA控制器,每个控制器有7个通道,驱动OLED刷屏时通常占用DMA1通道7,它是I2C1的发送请求映射。配置DMA前先用SPL库函数把I2C外设初始化好,再设置DMA的外设地址为I2C1->DR、内存地址为显存数组首地址、传输数据量为1024。关键一步是把DMA传输完成中断打开,在中断处理函数里关闭I2C的DMA请求,否则下一次刷屏会冲突。DMA方式下CPU只需要发起一次传输请求,剩下的字节搬移全交给DMA控制器,运行频率72MHz下刷完整屏的时间能压进10毫秒以内。
整屏刷新代码的核心在于先关DMA请求,再改DMA内存地址指向新的显存缓冲,然后重新使能通道。DMA的增量模式设置为固定,外设地址存到寄存器里,内存地址按字节递增。中断标志位在高位为1时说明一次传输完成,此时在中断里做清标志和失能操作。
本文还有配套的精品资源,点击获取