ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OLED驱动开发从文件结构到显存刷新的完整实践指南

OLED驱动开发从文件结构到显存刷新的完整实践指南 简介资源面向STM32F1嵌入式开发者提供基于IIC接口驱动OLED显示屏的源码文件其中封装了初始化、清屏、命令发送、数据写入等基础函数并附带常用汉字字库可直接嵌入HAL库工程使用。压缩包整体只有6KB共4个文件包含3个头文件和1个源文件。头文件负责声明接口与配置结构体源文件则实现IIC时序、显示控制和字库映射代码结构清晰便于按实际引脚调整。目前已有1091人学习下载。使用时只需根据开发板连接修改SDA与SCL所在GPIO端口并配置好对应IIC外设即可快速完成OLED点亮与字符、中文显示。对初学者而言这份资源省去了查阅数据手册和编写底层驱动的时间也适合中级开发者作为精简驱动模板方便在STM32F1系列上扩展菜单、波形等图形界面。 先说明一句这篇不是我第一次点OLED屏时的经验总结而是我把市面上常见的例程翻了个底朝天、又自己在STM32和51上各跑过几轮之后沉淀下来的东西。如果你正卡在“OLED.C和OLED.H到底应该怎么设计”“为什么别人的驱动拿过来改吧改吧就能用我自己写就各种报错”这类问题上这篇文章应该能直接给你省下两三天时间。OLED的驱动本身其实不难难的是文件结构没理清楚。很多新手拿了例程先看main.c看半天看晕了也没搞明白数据是怎么从显存跑到屏幕上的。我今天干脆把这套驱动拆开讲你不需要从零写但看完之后必须能看懂、能改、能跨平台移植这才算真正“会了”。1. 点屏之前先搞懂这两个文件的“势力范围”先说一个我观察到的现象不少人是真的把整个OLED工程“背”下来了的函数名都能倒着默写但你问他一句“为什么这个函数要放在OLED.C里那个宏要定义在OLED.H里”他就卡壳了。这说明对文件的职责划分没有建立起肌肉记忆一旦换主控、换编译器、换屏幕尺寸马上露馅。在C语言工程里.c文件负责“实现”.h文件负责“声明”和“约定”。OLED.C里放的应该是函数的具体逻辑——底层读写时序、显存刷新、画点、画线、显示字符串这些真正干事儿的代码。而OLED.H里放的是外部接口的声明、有意义的宏定义、结构体定义以及必要的包含保护目的是让其他.C文件在引用这些功能时只需要#include oled.h就能拿到所有必要信息而不需要关心OLED.C里到底是怎么实现的。用个不太严谨但非常好懂的生活类比OLED.H是合同OLED.C是施工队。合同上写明白“我能提供显示字符串、显示图片、画线这些服务”但合同上不写施工队是用电锤还是冲击钻干活的。别的文件比如main.c只需要照着合同下单不需要自己提着工具进场干活更不需要关心施工队内部谁负责拌水泥、谁负责砌墙。这样的好处是哪天你想把I2C换成SPI或者把屏幕从0.96寸换成1.3寸你只需要改OLED.C这一层main.c里的业务逻辑可以纹丝不动。1.1 .h文件到底该放什么、不该放什么新手最容易犯的一个错是把函数定义直接写在.h文件里还觉得自己“共享了代码”。举一个特别典型的反面教材// oled.h —— 反面教材 #ifndef __OLED_H #define __OLED_H void OLED_Init(void) { // 一堆初始化代码 ... } #endif这样做编译确实能过但只要你工程里有两个.C文件同时包含这个头文件链接器立刻会报重复定义错误。就算侥幸没报错这种写法也让头文件变得臃肿每次修改驱动内部逻辑都会触发所有包含它的文件重新编译修改效率极低。正确的做法是.h文件里只放声明、宏定义、类型定义。函数实现老老实实待在OLED.C里。// oled.h —— 正确做法 #ifndef __OLED_H #define __OLED_H #include main.h // 屏幕尺寸相关宏定义 #define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_PAGE_NUM (OLED_HEIGHT / 8) // 显存大小128 * 64 / 8 1024字节 #define OLED_GRAM_SIZE (OLED_WIDTH * OLED_HEIGHT / 8) // 对外接口声明 void OLED_Init(void); void OLED_Clear(void); void OLED_ShowString(uint8_t x, uint8_t y, char *str); void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index); void OLED_RefreshGram(void); #endif你会发现这个头文件里写的全是“我能干什么”和“参数怎么理解”没有一个字节是“具体怎么干”。这样main.c的开发者拿到这个文件只看一眼头文件就能开发业务逻辑根本不需要往下翻几百行驱动源码。1.2 一个最小可用的OLED头文件长什么样我自己的工程里OLED.H的基础部分长这样结构比较简洁明了#ifndef __OLED_H #define __OLED_H #include main.h /* 屏幕分辨率定义 */ #define OLED_WIDTH 128 #define OLED_HEIGHT 64 #define OLED_PAGE_NUM (OLED_HEIGHT / 8) /* 底层接口宏定义根据实际硬件接线修改 */ #define OLED_I2C_ADDR (0x78) // 7位地址左移1位后的写法后文细说 #define OLED_SPI_MODE 0 // 1表示SPI, 0表示I2C可以通过宏切换 /* 显存 */ extern uint8_t OLED_GRAM[OLED_PAGE_NUM][OLED_WIDTH]; /* API函数 */ void OLED_Init(void); void OLED_Clear(void); void OLED_DisplayOn(void); void OLED_DisplayOff(void); void OLED_SetPos(uint8_t x, uint8_t y); void OLED_RefreshGram(void); void OLED_ShowChar(uint8_t x, uint8_t y, char ch); void OLED_ShowString(uint8_t x, uint8_t y, char *str); void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index); void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t t); void OLED_Fill(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t dot); #endif你看这个文件信息密度其实很高。屏幕多大、用什么总线、地址是多少、对外提供哪些函数全都在一两屏里能看完。后面不管谁接手这个工程不出五分钟就能上手改东西。这就是文件结构设计得好带来的长期收益。2. 底层通信的.c实现I2C和SPI的写法差距在哪OLED.C的核心分两层最底下的通信层和往上一点的显示逻辑层。通信层决定了你写出来的驱动是“绑定硬件”还是“可以移植”。我这里直接把I2C和SPI两种方案都讲一遍因为它们的差别不仅仅在一个引脚上写代码的思维也有差异。2.1 I2C驱动两个函数打天下市面上最常见的0.96寸OLED默认是I2C接口的。为什么大家都爱用I2C因为它只占两根线SCL和SDA而且地址固定硬件连接极简。I2C驱动的核心写起来就两个函数写命令和写数据。在I2C协议里有个微妙的地方命令字节和数据字节的区分靠的是控制字节Control Byte通常0x00表示后续字节是命令0x40表示后续字节是数据。这一点和SPI用DC引脚电平区分完全不同。如果你把命令当数据发了屏幕会显示一堆莫名其妙的雪花点反过来开关机、清屏这些操作会失灵。下面是我在实际工程里用HAL库写的一套I2C底层直接拿过来改改就能用// OLED_I2C_WriteCmd —— 写命令 static void OLED_I2C_WriteCmd(uint8_t cmd) { uint8_t buf[2]; buf[0] 0x00; // 控制字节命令 buf[1] cmd; HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 2, 100); } // OLED_I2C_WriteData —— 写数据 static void OLED_I2C_WriteData(uint8_t dat) { uint8_t buf[2]; buf[0] 0x40; // 控制字节数据 buf[1] dat; HAL_I2C_Master_Transmit(hi2c1, OLED_I2C_ADDR, buf, 2, 100); }这里有个特别多新手踩的坑I2C地址究竟是0x78还是0x3C答案是这两个值本质上是同一个设备地址只是表达方式不同。SSD1306这个控制器的7位地址是0x3C但在STM32的HAL库里HAL_I2C_Master_Transmit的地址参数需要的是8位地址所以要左移一位变成0x78。如果你用的是Linux的i2c-dev或者Arduino的Wire库那边用的又是7位地址0x3C。换个平台就换个写法理解原理之后就不容易懵。2.2 SPI驱动多一个DC引脚的差别SPI接口的OLED速度比I2C快得多如果你要显示动画、视频流或者频繁刷新大屏建议用SPI。SPI方案一般需要四根线SCLK、MOSIDIN、DC数据/命令选择、CS片选外加一个RST复位引脚。和I2C最大的差别就是它多了一个DC引脚专门用来区分命令和数据。所以SPI驱动里控制逻辑变成拉低DC写命令拉高DC写数据。用HAL库写SPI底层大概长这样// OLED_SPI_WriteCmd —— 写命令 static void OLED_SPI_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, GPIO_PIN_RESET); // DC 0 HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_RESET); // CS 0 HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_SET); // CS 1 } // OLED_SPI_WriteData —— 写数据 static void OLED_SPI_WriteData(uint8_t dat) { HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, GPIO_PIN_SET); // DC 1 HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_RESET); // CS 0 HAL_SPI_Transmit(hspi1, dat, 1, 100); HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_SET); // CS 1 }CS片选信号在每次传输前拉低、传输后拉高是为了确保总线上只有一个设备在响应。如果你的SPI总线上还挂了别的外设这步省略不得。另外要注意SPI模式下OLED的初始化序列和I2C模式基本一致但有个别寄存器配置会根据硬件版本不同有差异拿到新屏先看数据手册里的初始化命令表别直接套用网上别人的老代码。2.3 HAL库和LL库的选择现在做STM32开发HAL库是主流但它有个小毛病I2C的HAL接口封装层次多单次传输耗时偏长在高刷新率场景下可能拖后腿。如果你要做的是“显示一个时钟界面、每隔一秒刷新一次”HAL库完全够用。但你要是想做那种流畅的动画效果建议底层改用LL库或者寄存器操作这里给出LL库的I2C写法示例static void OLED_LL_WriteCmd(uint8_t cmd) { while (LL_I2C_IsActiveFlag_BUSY(I2C1)); LL_I2C_GenerateStartCondition(I2C1); while (!LL_I2C_IsActiveFlag_SB(I2C1)); LL_I2C_TransmitData8(I2C1, OLED_I2C_ADDR); while (!LL_I2C_IsActiveFlag_ADDR(I2C1)); LL_I2C_ClearFlag_ADDR(I2C1); while (!LL_I2C_IsActiveFlag_TXE(I2C1)); LL_I2C_TransmitData8(I2C1, 0x00); while (!LL_I2C_IsActiveFlag_TXE(I2C1)); LL_I2C_TransmitData8(I2C1, cmd); while (!LL_I2C_IsActiveFlag_BTF(I2C1)); LL_I2C_GenerateStopCondition(I2C1); }说实话我自己平时默认用HAL库只有遇到性能瓶颈才切成LL库。你如果刚开始接触不用在底层优化上花太多精力优先跑通功能更重要。3. 显存与刷新机制为什么你的屏幕总是“闪”很多人在第一步“点亮屏幕”之后就卡住了——能显示字符但屏幕一直在闪烁或者刷新速度慢得没法看。这时候问题通常出在两个地方一是显存的设计不合理二是刷新函数写得太粗暴。我这一节把显存的原理和刷新代码一次性讲透。3.1 OLED_GRAM的排布逻辑SSD1306这块控制器内部的GRAM是128×64位也就是总共1024字节。但它的行扫描规则比较特殊不是按“行”线性排列而是把64行分成8个Page页每个Page占8行列数还是128列。因此比较合理的显存定义是uint8_t OLED_GRAM[8][128]; // [页][列]一共8页×128列1024字节这里面的逻辑是这样的第一页管理第0到第7行每一列是一个字节字节的bit0对应第0行、bit7对应第7行。也就是说如果你想在第0页的第0列画一个亮点第0行就执行OLED_GRAM[0][0] | 0x01。理解了这层映射关系后面画点、画线、显示汉字都只是对这张二维表的位操作而已。有个容易搞混的点有些代码里会定义成uint8_t OLED_GRAM[128][8]也就是“列”在外、“页”在内。这种写法的物理意义变成了“每列有8个页字节”虽然总字节数一样、屏幕上效果也一样但底层的索引顺序不同。以我个人的习惯[页][列]更贴近SSD1306的编程模型调试也直观。你选定一种就坚持用别两种混着来改起来极其容易出bug。3.2 刷新函数的核心循环有了显存之后刷新函数做的工作就一句话把GRAM里的1024字节按页、按列的顺序全部发给屏幕。这个循环看着简单但有一个很容易忽略的性能陷阱void OLED_RefreshGram(void) { uint8_t i, n; for (i 0; i 8; i) { OLED_WriteCmd(0xB0 i); // 设置页地址(0~7) OLED_WriteCmd(0x00); // 设置列地址低4位 OLED_WriteCmd(0x10); // 设置列地址高4位 for (n 0; n 128; n) { OLED_WriteData(OLED_GRAM[i][n]); } } }这段代码在I2C模式下每一页开始都要发3个命令字节8页就是24个命令字节再加上1024个数据字节总计发出1048个字节。如果主频不高或者I2C时钟配置得太慢比如默认100kHz整个刷新过程确实会显得拖沓。这就是很多新手觉得“屏幕闪”的重要原因之一——其实屏幕本身不闪是刷新得太慢中间产生了肉眼可见的空白期。要解决这个问题最直接的办法是把I2C时钟从100kHz提到400kHz甚至1MHz。STM32的HAL库配置里I2C_InitStruct.Timing或者直接改时钟分频都可以做到。另一个建议是刷新函数不要频繁调用只在内容变化时才调用比如每秒刷新一次时钟界面时不需要把整个GRAM反复刷可以配合后面的局部刷新方案做优化。3.3 局部刷新比全屏刷新实用得多实际项目里经常会遇到这样的场景界面上有一个跳动的数字或者一个动态变化的进度条。如果你把整个屏幕全部刷新不仅浪费CPU还会感觉到视觉上的闪烁。更聪明的做法是只修改GRAM里对应的那一小片区域然后只把这一小片刷到屏幕上。比如只刷新页2、第20列到第30列这10个字节可以这样处理void OLED_RefreshArea(uint8_t page, uint8_t col_start, uint8_t col_end) { uint8_t n; OLED_WriteCmd(0xB0 page); // 设置页地址 OLED_WriteCmd((col_start 0x0F)); // 列地址低4位 OLED_WriteCmd(0x10 | (col_start 4)); // 列地址高4位 for (n col_start; n col_end; n) { OLED_WriteData(OLED_GRAM[page][n]); } }调用的时候比如OLED_RefreshArea(2, 80, 100)就只刷新第2页的20列数据。这种做法配合显存操作能做到局部数字跳动时“只有数字那块在变其他画面纹丝不动”体验会好很多。这算是我实际调试中觉得性价比最高的一项优化。4. 从ASCII到汉字字模数据的C语言组织屏幕能亮、能刷显存只是第一步真正建立“用户好感”的是能显示清晰的字符和汉字。这一节讲字模在C语言里是怎么组织的。你别小看这部分很多工程跑着跑着乱码、汉字显示成豆腐块问题基本都出在字模这里。4.1 ASCII字模表一维数组按行序排布ASCII字模的基本思路是每个字符用一个固定大小的点阵表示比如常见的8×16字符就是16个字节每个字节代表一行8个bit代表这一行的8个像素。所有字符的字模按ASCII码顺序放进一个大数组里使用时通过ASCII码 - 32计算偏移量取模。// 以8x16 ASCII字模表为例数组定义方式如下 const uint8_t F8x16[][16] { {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // {0x00, 0x00, 0x00, 0xF8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x33, 0x30, 0x00, 0x00, 0x00}, // ! // ... 其他字符 };取模的时候用F8x16[ch - 32]就能拿到字符的16个字模字节。为什么是减32因为空格的ASCII码是32而数组的第一个元素对应空格。这样做的好处是查表速度极快一个减法加一个寻址就完成了。值得提醒的是字模数据的取模方向一定要和显示函数配合好。市面上取模软件一般有“列行式”和“行列式”两种排列选项选错了显示出来的字符会旋转90度或者变成镜像。我的建议是选定一款取模软件后先把26个大写字母中的“A”取出来显示测试确认方向正确再批量取模不然几百个字符全取了却发现方向错了心态会崩。4.2 汉字字模为什么大家都推荐“按索引查表”汉字不能像ASCII那样用简单的减一个偏移量来定位因为汉字的编码不连续。常见的做法是预先准备好一份“需要用到的汉字列表”把每个汉字的字模按顺序放在数组里再用一个索引值来访问。这种静态字库适合绝大多数嵌入式应用省内存又速度快。比如你的界面需要“温度”“湿度”“电压”这三个词那就先把这6个汉字温、度、湿、电、压的字模取出来定义成索引#define INDEX_WEN 0 #define INDEX_DU 1 #define INDEX_SHI 2 #define INDEX_DIAN 3 #define INDEX_YA 4 // 显示函数按索引取模 void OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t index) { // 根据index查字模表显示16x16点阵 }这种方法在工程上叫“预置字模索引”优点是灵活可控、BOM成本低缺点是需要自己维护汉字索引和编码的对应关系。如果你需要显示大段任意的中文文本就得考虑用带字库的屏幕模块或者外挂Flash字库不是单靠驱动文件能解决的。实际操作时还有个容易踩的坑ASCII字符显示函数和汉字显示函数在x坐标上的计算单位不一致。ASCII通常是8像素宽汉字是16像素宽如果你让两个字符挤在一起字会重叠、错位。我的经验是显示混合字符串时把ASCII字符串先拼好再统一传给一个“字符串显示”函数函数内部遇到ASCII码小于128就走字符表大于等于128就把连续两个字节当作一个汉字来处理这样写出来的代码在界面上排版会清爽很多。5. 我在工程中踩过的几个OLED文件相关的坑这一节全是真金白银的踩坑经历每个坑我都自己花过时间排过。整理出来希望大家少走弯路。5.1 重复定义与extern的“爱恨情仇”显存数组OLED_GRAM的归宿问题几乎是每个新手都会踩一遍的。你在OLED.C里定义了uint8_t OLED_GRAM[8][128];然后在OLED.H里也写了uint8_t OLED_GRAM[8][128];结果编译直接报错“multiple definition”。这其实涉及到C语言的一个关键概念头文件里只能声明外部变量不能定义变量。正确的做法是// oled.c uint8_t OLED_GRAM[8][128]; // 定义在这 // oled.h extern uint8_t OLED_GRAM[8][128]; // 声明在这这样别的.C文件包含头文件后知道“有这么一个数组存在”但不会重复分配内存。如果你忘了加extern或者加了extern却在函数外又写了一遍“int a;”链接器照样报错。记住这句口诀定义只能有一次声明可以有无数次。5.2 取模方向和取模大小不一致就花屏有一次我在调试一个项目ASCII字符显示完全正常但显示汉字时上半部分正常、下半部分直接花屏。排查了好久最终发现是取模软件里“纵向取模还是横向取模”没选对。我的字符显示函数是按行扫描的但汉字字模是按列取的两边对不上自然乱套。特别提醒如果你用PCtoLCD2002这类取模软件输出选项里一定要选“阴码、逐行式、顺向”这和大多数OLED例程的显示算法是匹配的。另外汉字常见规格是16×16也就是32个字节一个字如果你显示12×12的汉字函数里计算地址的方式又不一样了。最好把字模尺寸定义成宏比如#define FONT_CHINESE_SIZE 16所有地方统一使用方便后期切换。5.3 I2C地址和时钟延时的坑I2C地址的问题前面提过了这里再补充一个真实场景我用的OLED模块背面SA0引脚被拉高了这时候I2C地址从0x3C变成了0x3D也就是8位地址的0x7A。如果你哪天发现I2C扫描不到设备先检查SA0引脚的电平而不是怀疑代码写错了。另外一个延时坑OLED上电后需要一小段复位时间一般数据手册要求RST引脚拉低至少3微秒再拉高。不少例程直接用HAL_Delay(100)这种毫秒级延时倒是不会出错但如果你用寄存器操作或者RTOS环境确保先把这步做扎实。我见过有人的屏幕每次上电显示花屏后来又正常十有八九就是上电复位时序没做对。6. 工程组织进阶把驱动做成“一次编译、到处好用”的公共组件到这里你已经能正常点亮屏幕、显示内容了。但如果你想让这套驱动在不同项目里复用得顺手建议再花一点时间做工程组织上的优化。这一节我们聊怎么把OLED驱动设计成一个跨项目、跨硬件平台的组件。6.1 用宏开关做总线适配一个很常见的情况这个项目里OLED挂在I2C1上下个项目里换成了SPI2。如果你在代码里直接写死hi2c1、hspi1换项目就得满文件改。更聪明的做法是在OLED.H里定义一个接口宏#define OLED_USE_I2C 1 #define OLED_USE_SPI 0然后在OLED.C里通过条件编译决定使用哪套底层#if OLED_USE_I2C #include i2c.h #define OLED_WRITE_CMD(cmd) OLED_I2C_WriteCmd(cmd) #define OLED_WRITE_DATA(dat) OLED_I2C_WriteData(dat) #elif OLED_USE_SPI #include spi.h #define OLED_WRITE_CMD(cmd) OLED_SPI_WriteCmd(cmd) #define OLED_WRITE_DATA(dat) OLED_SPI_WriteData(dat) #endif这样以后切换总线只需要改头文件里的宏和对应的HAL句柄其余显示逻辑完全不用动移植成本会低很多。我愿称之为“一次编码、处处编译”能让复用体验好上一个台阶。6.2 把底层“接口化”方便换屏换主控再进一步就是把你对OLED的操作接口抽象成几个基础函数比如画点、清屏、刷新。这样就算你以后从SSD1306换到SH1106或者换到更大尺寸的屏幕也只需要改底层绘制函数上层UI逻辑能完整保留// oled.h 中声明基础接口 void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t t); // 画点 void OLED_Fill(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t dot); // 填充区域 void OLED_ShowChar(uint8_t x, uint8_t y, char ch); // 显示字符 void OLED_ShowString(uint8_t x, uint8_t y, char *str); // 显示字符串上层做界面时只调这几个接口不要直接碰GRAM和底层寄存器。这种分层的思路在很多地方都适用嵌入式领域尤其重要因为它让驱动代码的价值最大化。最后分享一个我个人的小习惯每新建一个OLED工程我会先在OLED.H的注释块里写明硬件连接、总线和地址等“板上信息”再写一段备注说出“修改这个文件时需要同步修改哪些地方”。这样半年后自己回来看代码或者同事接手这个项目都能少踩很多坑。驱动代码写得好不好评判标准很简单——换个人、换个板子能不能不看原理图就把屏幕点亮。如果能说明你的文件结构组织得足够清楚了。本文还有配套的精品资源点击获取
返回列表