简介:这份资源是江协科技0.96寸OLED驱动函数的HAL库移植版本,面向使用STM32微控制器进行嵌入式显示开发的初学者与进阶开发者,帮助解决OLED屏在HAL库环境下初始化、通信与绘图操作繁琐的问题。资源包共151个文件,以c与h源码文件为核心,配合o、crf等编译中间文件,以及uvprojx、ioc等工程配置文件和少量bmp、txt辅助资料,压缩包约5.71MB,可直接导入Keil工程使用。内容涵盖OLED初始化、命令与数据发送、基本图形绘制、字符与字符串显示等功能,封装了I2C或SPI通信细节,并包含STM32F1系列HAL库外设驱动文件,便于理解底层交互与移植思路。目前已有1452人学习下载,适合希望快速上手OLED显示、减少底层调试时间并在此基础上扩展图形界面与交互效果的开发者参考。
1. 0.96 寸 OLED 驱动移植:为什么 HAL 库下最容易翻车的不是接线
很多人第一次把 0.96 寸 OLED 接到 STM32 上,都会经历同一个剧本:接线检查了三遍,地址也扫出来了,屏幕就是不亮,或者亮一半花一半。问题往往不在硬件,而在驱动函数的移植方式上。江协科技那套 0.96 寸 OLED 驱动函数是很多人的入门起点,它原本多跑在标准库上,代码紧凑、时序直接操作寄存器,一旦换到 HAL 库环境,延时函数、GPIO 写法和 I2C 收发全变了,直接复制粘贴必然翻车。这篇笔记就围绕这套驱动函数在 HAL 库下的移植展开,把 I2C 与 SPI 两种接法、显存刷新逻辑、字库取模和常见时序坑一次讲透。适合已经能点亮 LED、会用 CubeMX 生成工程,但被 OLED 卡住的嵌入式新手,也适合想把这套驱动整理成可复用模块的老手。
2. 先搞清驱动函数的结构:移植前必须看懂的四个层次
2.1 从 OLED 显存到屏幕:驱动函数到底在干什么
0.96 寸 OLED 常见控制芯片是 SSD1306 或 SH1106,分辨率 128×64。它内部有一块 GDDRAM,也就是显存,大小是 128×64 bit,按页组织,共 8 页,每页 8 行,每列对应一个字节。驱动函数的核心工作只有三件事:初始化寄存器、把要显示的内容写进显存、把显存推给屏幕。
江协科技那套驱动的分层很清晰。最底层是写命令和写数据两个函数,负责在 I2C 或 SPI 上搬运字节;中间层是初始化序列和显存缓冲区操作;上层是字符、字符串、数字、图片显示函数;最外层是刷新函数,把缓冲区整体推到屏幕。移植时真正要改的只有最底层,上层逻辑几乎可以原样保留。
理解这一点很关键。很多人移植失败,是因为把上层显示函数也一起改了,结果坐标算错、字库对不上。正确做法是:底层通信函数按 HAL 库重写,上层保持接口不变。
显存缓冲区通常是一个uint8_t OLED_GRAM[8][128]的二维数组。所有画点、画字符操作都先改这个数组,最后统一刷新。这样做的好处是刷新次数可控,不会每画一个点就发一次 I2C,避免屏幕闪烁和总线拥堵。
2.2 I2C 还是 SPI:两种接法的选型理由
0.96 寸 OLED 模块一般同时引出 I2C 和 SPI 接口,买的时候要看清是哪种。I2C 只要两根线,SCL 和 SDA,接线简单,但速率受限,标准模式 100kHz,快速模式 400kHz。SPI 要四根线以上,CS、DC、RES、SDA、SCL,接线多,但速率可以到几 MHz,刷新整屏明显更快。
选型上有个实用判断:如果只是显示几行文字、刷新频率不高,I2C 足够,省引脚。如果要显示动画、波形、频繁整屏刷新,SPI 更稳。我一般做小项目用 I2C,做需要实时刷新的界面用 SPI。
HAL 库下 I2C 有两种用法:硬件 I2C 和软件模拟 I2C。硬件 I2C 用HAL_I2C_Master_Transmit,代码短,但某些型号的硬件 I2C 在总线异常时会卡死,需要加超时和复位逻辑。软件模拟 I2C 自己翻转 GPIO,时序完全可控,移植性最好,江协科技原版驱动大多就是软件模拟。我的建议是:新手先用软件模拟 I2C 把屏幕点亮,确认驱动逻辑没问题,再考虑换硬件 I2C 优化。
SPI 接法下,HAL 库用HAL_SPI_Transmit,关键是 DC 引脚要手动控制,命令和数据的区分靠 DC 高低电平。这一点和 I2C 不同,I2C 是靠从机地址后的控制字节区分。
2.3 移植前要确认的引脚与时钟配置
在 CubeMX 里先把引脚配好。软件模拟 I2C 的话,SCL 和 SDA 都配成 GPIO 输出开漏,外部上拉 4.7k 到 10k。硬件 I2C 就选 I2C 外设,速率设 400kHz。SPI 的话,SCK、MOSI 配 SPI 外设,CS、DC、RES 配普通 GPIO 输出。
时钟这块有个容易忽略的点:软件模拟 I2C 的延时依赖系统时钟,如果延时函数用的是HAL_Delay,最小单位是 1ms,对 I2C 来说太慢了。所以底层延时要用微秒级,常见做法是用定时器或者DWT周期计数器,或者简单点用空循环for延时。江协科技原版用的是自己写的延时函数,移植时要把这部分替换成 HAL 库环境下可用的微秒延时。
下面是一个基于 DWT 的微秒延时实现,移植时直接放进工程即可:
// 基于 DWT 的微秒延时,HAL 库环境下替代原版延时函数 #include "stm32f1xx_hal.h" static uint32_t cpu_freq_mhz = 0; void Delay_Init(void) { // 使能 DWT 和 CYCCNT 计数器 CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 获取系统主频,单位 MHz cpu_freq_mhz = HAL_RCC_GetHCLKFreq() / 1000000; } void Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * cpu_freq_mhz; while ((DWT->CYCCNT - start) < ticks); }这段代码的逻辑是:DWT 是 Cortex-M 内核自带的调试周期计数器,每个时钟周期加一。先使能它,再根据主频算出目标微秒数对应的周期数,然后空转等待。参数上,cpu_freq_mhz必须和实际主频一致,否则延时不准。如果主频是 72MHz,HAL_RCC_GetHCLKFreq()返回 72000000,除以 1000000 得 72。注意 DWT 在部分低端内核上不可用,移植前先确认芯片手册。
3. 用 HAL 库重写底层通信:I2C 与 SPI 两套最小实现
3.1 软件模拟 I2C 的四个基础函数
软件模拟 I2C 只需要四个基础动作:起始、停止、发送一个字节、等待应答。把这四个写对,上层就能跑。下面给出 HAL 库下的实现,GPIO 操作直接用HAL_GPIO_WritePin和HAL_GPIO_ReadPin。
// 软件模拟 I2C 基础函数,SCL/SDA 引脚宏按实际工程修改 #define OLED_SCL_PIN GPIO_PIN_6 #define OLED_SCL_PORT GPIOB #define OLED_SDA_PIN GPIO_PIN_7 #define OLED_SDA_PORT GPIOB // SDA 方向切换:输出或输入 static void SDA_Out(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = OLED_SDA_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull = GPIO_PULLUP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(OLED_SDA_PORT, &gpio); } static void SDA_In(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = OLED_SDA_PIN; gpio.Mode = GPIO_MODE_INPUT; // 输入模式读应答 gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(OLED_SDA_PORT, &gpio); } // 起始信号:SCL 高时 SDA 由高变低 void I2C_Start(void) { SDA_Out(); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); Delay_us(2); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_RESET); Delay_us(2); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); Delay_us(2); } // 停止信号:SCL 高时 SDA 由低变高 void I2C_Stop(void) { SDA_Out(); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_RESET); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); Delay_us(2); HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, GPIO_PIN_SET); Delay_us(2); } // 发送一个字节,返回应答位,0 表示应答 uint8_t I2C_SendByte(uint8_t byte) { uint8_t ack; SDA_Out(); for (uint8_t i = 0; i < 8; i++) { // 高位先发 HAL_GPIO_WritePin(OLED_SDA_PORT, OLED_SDA_PIN, (byte & 0x80) ? GPIO_PIN_SET : GPIO_PIN_RESET); byte <<= 1; Delay_us(2); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); Delay_us(2); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); Delay_us(2); } // 第 9 个时钟读应答 SDA_In(); Delay_us(2); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_SET); Delay_us(2); ack = HAL_GPIO_ReadPin(OLED_SDA_PORT, OLED_SDA_PIN); HAL_GPIO_WritePin(OLED_SCL_PORT, OLED_SCL_PIN, GPIO_PIN_RESET); Delay_us(2); return ack; }逻辑说明:起始和停止信号是 I2C 协议的帧边界,时序要求 SCL 为高时 SDA 跳变。发送字节时高位先出,每发一位拉高 SCL 再拉低,第 9 个时钟释放 SDA 读从机应答。参数上,Delay_us(2)对应约 250kHz 的速率,如果屏幕不亮可以适当加大到 5us 降低速率。SDA 方向切换是软件 I2C 的关键,发送时输出,读应答时输入,漏掉这一步应答永远读不到。
3.2 写命令与写数据:移植时改动最大的两个函数
有了基础函数,写命令和写数据就简单了。I2C 从机地址常见是 0x78(写)和 0x79(读),7 位地址是 0x3C。控制字节 0x00 表示后面是命令,0x40 表示后面是数据。
// I2C 写命令与写数据,地址按模块实际修改 #define OLED_ADDR 0x78 void OLED_WriteCmd(uint8_t cmd) { I2C_Start(); I2C_SendByte(OLED_ADDR); // 从机地址 I2C_SendByte(0x00); // 控制字节:命令 I2C_SendByte(cmd); I2C_Stop(); } void OLED_WriteData(uint8_t data) { I2C_Start(); I2C_SendByte(OLED_ADDR); I2C_SendByte(0x40); // 控制字节:数据 I2C_SendByte(data); I2C_Stop(); }如果换成硬件 I2C,这两个函数可以合并成一个:
// 硬件 I2C 版本,用 HAL 库收发 void OLED_WriteBytes(uint8_t ctrl, uint8_t *buf, uint16_t len) { uint8_t tmp[128]; tmp[0] = ctrl; for (uint16_t i = 0; i < len; i++) { tmp[i + 1] = buf[i]; } HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, tmp, len + 1, 100); }逻辑说明:硬件 I2C 把控制字节和后续数据拼成一个缓冲区一次发出,减少起始停止次数,效率更高。参数上,100是超时毫秒数,总线异常时不会永久卡死。注意tmp大小要够,整屏刷新时一次最多 128 字节数据加 1 字节控制,所以 129 够用,但分页刷新时按页发更稳。
SPI 版本的区别在 DC 引脚:
// SPI 写命令与写数据,DC 引脚区分 void OLED_WriteCmd(uint8_t cmd) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_RESET); // DC 低:命令 HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); } void OLED_WriteData(uint8_t data) { HAL_GPIO_WritePin(OLED_DC_PORT, OLED_DC_PIN, GPIO_PIN_SET); // DC 高:数据 HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &data, 1, 100); HAL_GPIO_WritePin(OLED_CS_PORT, OLED_CS_PIN, GPIO_PIN_SET); }3.3 初始化序列与刷新函数:不要逐字节发
初始化序列是一串固定命令,设置对比度、扫描方向、显示模式等。江协科技原版里这部分是逐条调用写命令,移植时保持原样即可,但要注意命令值不能改,改错一条屏幕就可能不亮或反色。
刷新函数是性能关键。原版常见写法是逐页发送,每页 128 字节。用硬件 I2C 时,如果每字节都起始停止,整屏要 1024 次,非常慢。正确做法是按页打包发送:
// 按页刷新显存,OLED_GRAM 是 8x128 缓冲区 void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); // 设置页地址 OLED_WriteCmd(0x00); // 列低地址 OLED_WriteCmd(0x10); // 列高地址 // 一次发送该页 128 字节数据 OLED_WriteBytes(0x40, OLED_GRAM[page], 128); } }逻辑说明:SSD1306 的页地址命令是 0xB0 到 0xB7,列地址分高低两半,0x00 和 0x10 是起始列。每页 128 字节一次发完,比逐字节快几十倍。参数上,OLED_GRAM[page]是二维数组的一行,正好 128 字节。如果屏幕是 SH1106,列地址要偏移 2,即 0x02 和 0x12,这是常见坑,后面会细说。
4. 避坑与排查:移植时最常踩的五个坑
4.1 屏幕全黑但地址能扫到
现象:I2C 扫描工具能扫到 0x3C,但屏幕一点反应没有。原因通常是初始化序列没发完,或者 RES 复位引脚没接、没拉高。解决:先确认 RES 引脚在初始化前拉低至少 10ms 再拉高,然后逐条检查初始化命令,特别是 0xAE 关显示和 0xAF 开显示这两条,漏发 0xAF 屏幕永远黑。
4.2 屏幕亮但显示花屏、错位
现象:能亮,但字符重叠、上下半屏错位。原因多半是显存缓冲区大小或页地址算错。常见是OLED_GRAM定义成[8][128]但刷新时按[128][8]访问,行列颠倒。解决:统一用[页][列]顺序,刷新时OLED_GRAM[page][col],画点时GRAM[y/8][x]。
4.3 用 SH1106 芯片时右边缺两列
现象:SSD1306 驱动点亮 SH1106 屏,左边正常,右边少两列或整体右移。原因:SH1106 的显存是 132 列,起始列不是 0 而是 2。解决:初始化里列地址命令改成 0x02 和 0x12,或者在刷新时每页先偏移两列。这个坑很隐蔽,因为屏幕能亮,只是边缘不对。
4.4 硬件 I2C 跑一段时间后卡死
现象:程序运行几分钟后 OLED 不再刷新,调试发现卡在HAL_I2C_Master_Transmit。原因:硬件 I2C 在总线受干扰或从机无应答时会进入死锁,HAL 库默认超时可能没生效。解决:给每次传输加超时参数,并在超时后调用HAL_I2C_DeInit和HAL_I2C_Init复位外设。更稳的做法是直接用软件模拟 I2C,不依赖硬件状态机。
4.5 延时函数用 HAL_Delay 导致刷新极慢
现象:整屏刷新要好几秒,肉眼可见逐行刷。原因:HAL_Delay最小 1ms,软件 I2C 每位延时 1ms,一帧要几秒。解决:换成 DWT 微秒延时,每位 2us 左右,整屏刷新降到几十毫秒。注意 DWT 初始化要在主循环前调用一次。
5. 把驱动整理成可复用模块:字库、取模与一个验证技巧
移植完成后,建议把这套驱动整理成独立的oled.c和oled.h,只暴露OLED_Init、OLED_Clear、OLED_ShowString、OLED_Refresh这几个接口,底层 I2C 或 SPI 用宏切换。这样换项目时只改引脚宏,不用动逻辑。
字库方面,江协科技原版用的是 8×16 点阵 ASCII 字库,取模方式是从上到下、从左到右。如果你要显示中文,需要用取模软件生成 16×16 点阵,注意取模方向要和驱动里的画点顺序一致,否则字会转 90 度或镜像。常见取模设置是:阴码、逐列式、顺向。改字库时先只加一个汉字测试,确认方向对了再批量加。
一个实用的验证技巧:写一个自检函数,在屏幕四角各画一个像素,再画一个边框,然后刷新。如果四角像素位置正确、边框闭合,说明坐标映射和刷新逻辑都没问题。这个自检比直接显示字符串更容易定位问题,因为字符串还涉及字库,变量太多。
// 屏幕自检:四角像素加边框,用于验证坐标映射 void OLED_SelfTest(void) { OLED_Clear(); // 四角像素 OLED_DrawPoint(0, 0, 1); OLED_DrawPoint(127, 0, 1); OLED_DrawPoint(0, 63, 1); OLED_DrawPoint(127, 63, 1); // 边框 for (uint8_t x = 0; x < 128; x++) { OLED_DrawPoint(x, 0, 1); OLED_DrawPoint(x, 63, 1); } for (uint8_t y = 0; y < 64; y++) { OLED_DrawPoint(0, y, 1); OLED_DrawPoint(127, y, 1); } OLED_Refresh(); }逻辑说明:OLED_DrawPoint内部把坐标换算成页和位,写进OLED_GRAM。四角像素能验证边界,边框能验证整行整列映射。如果四角对但边框断,说明刷新时某页数据丢了;如果四角错位,说明坐标换算公式有问题。这个函数我每次移植新屏幕都会先跑一遍,比盲猜快得多。
最后说个习惯:移植任何屏幕驱动,先把写命令和写数据两个函数用逻辑分析仪或者示波器抓一次波形,确认起始、地址、控制字节、数据都对,再往上查。很多玄学问题其实是底层时序差了几个微秒,抓一次波形就现原形。希望帮到你。
本文还有配套的精品资源,点击获取