STM32F103 OLED显示驱动与波形绘制实战:从SPI初始化到避坑指南
2026/9/12 14:20:45 网站建设 项目流程

简介:面向STM32F103嵌入式开发者的OLED显示函数代码包,专门解决STM32F103与OLED屏之间的底层驱动、硬件连线与图形绘制问题。资源聚焦OLED连线与显示两大核心环节:连线部分涵盖SPI/I2C接口的GPIO引脚配置与初始化思路;显示部分则提供初始化、清屏、设置坐标、绘制像素、画线以及波形显示等一系列基础函数实现。压缩包内共2个文件,包括1个C源文件与1个头文件,源文件存放具体实现代码,头文件负责声明函数原型、结构体与常量,整体仅5KB,结构精简,便于快速阅读和二次开发。该代码包特别适合初学者作为学习STM32 OLED驱动和实时波形绘制的实践样本,也可供有经验的工程师参考移植。已有815人学习,适合正在调试OLED显示、需要实现基础波形可视化或希望快速搭建人机交互界面的开发者。

1. OLED 显示函数包不只是点屏,先搞清这套代码的边界

拿到STM32F103OLED显示函数.zip,里面只有oled.coled.h两个文件,但很多人在 GitHub 或 CSDN 下载后直接往工程里一丢,编译报错、屏幕不亮、波形闪烁,最后怀疑人生。这个压缩包的定位不是完整的 Keil 工程,而是给 STM32F103 最小系统板配 OLED 屏的驱动层代码,它把底层的 SPI/I2C 时序、字库取模、画点画线、波形映射全部封装成可移植的函数。

我拆过几十个类似的 OLED 驱动包,这套代码的核心价值在于两点:一是oled连线函数已经把 GPIO/SPI 的初始化逻辑写清楚了,你只需要根据自己板子的原理图改引脚;二是oled显示函数里包含了从 Bresenham 画线到 ADC 波形映射的完整链路,对于做信号采集、心率监测、电源监控这类需要实时显示曲线的项目,直接改数据源就能用。适合的人群是已经有 STM32F103 标准外设库或 HAL 库基础、想快速在 0.96 寸或 1.3 寸 SSD1306/SSH1106 屏上出效果的开发者。需要提前说明的是,这个包用的是经典 SPI 四线(SCK、MOSI、DC、CS)方案,不是 I2C 的两线方案,接线前必须先确认你的 OLED 模块是哪种接口,否则后面对齐代码会白费功夫。下面我按「连线初始化 → 显示函数拆解 → 波形绘制实战 → 常见坑与技巧」的顺序把整包代码讲透,重点标注哪些地方必须改、哪些地方不能动。

2. OLED 连线函数的底层逻辑:GPIO 复用与 SPI 时序初始化

2.1 先区分 SSD1306 的三种接口,再决定用哪组引脚

市面上常见的 0.96 寸 OLED 模块,驱动芯片绝大多数是 SSD1306(1.3 寸可能是 SH1106,指令兼容但有 4 页偏移差异)。模块背面会印有接口选择电阻,通常有 I2C(SDA/SCL 两线)、SPI(SCK/MOSI/DC/CS/RST 五线)、并行 6800/8080 三种模式。oled连线函数在代码里通常表现为一组宏定义或OLED_Init里的 GPIO 配置,例如:

#define OLED_SCK_GPIO_PIN GPIO_Pin_10 // PB10 -> SCK #define OLED_SCK_GPIO_PORT GPIOB #define OLED_MOSI_GPIO_PIN GPIO_Pin_0 // PB0 -> MOSI(SDA) #define OLED_MOSI_GPIO_PORT GPIOB #define OLED_DC_GPIO_PIN GPIO_Pin_1 // PB1 -> DC(数据/命令选择) #define OLED_DC_GPIO_PORT GPIOB #define OLED_CS_GPIO_PIN GPIO_Pin_2 // PB2 -> CS(片选) #define OLED_CS_GPIO_PORT GPIOB #define OLED_RST_GPIO_PIN GPIO_Pin_3 // PB3 -> RST(复位) #define OLED_RST_GPIO_PORT GPIOB

如果用的是软件模拟 SPI,就不需要开启 SPI 外设,只需把上述引脚全部配置为推挽输出。OLED_Init里通常是这样一段:

void OLED_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = OLED_SCK_GPIO_PIN | OLED_MOSI_GPIO_PIN | OLED_DC_GPIO_PIN | OLED_CS_GPIO_PIN | OLED_RST_GPIO_PIN; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // 推挽输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); OLED_CS_LOW(); // 片选拉低,使能 OLED_RST_HIGH(); // 复位时序 delay_ms(100); OLED_RST_LOW(); delay_ms(100); OLED_RST_HIGH(); // 随后写入 SSD1306 的初始化指令序列 }

这段代码的逻辑很直接:先把所有控制引脚初始化,再给复位引脚一个低脉冲,最后通过OLED_WriteCmd写入 SSD1306 的初始化序列。注意OLED_CS_LOW()这类宏定义在oled.h里,通常是GPIO_ResetBitsGPIO_SetBits的封装。这里的引脚选择是示例,你的板子如果用的是 PA5/PA7 这种硬件 SPI 引脚,就不能照搬,必须把宏改成实际原理图对应的端口和引脚。

2.2 硬件 SPI 与软件模拟 SPI 的性能差异和选择依据

这个包里写的oled连线函数如果用的是软件模拟时序,那么每个字节的高低电平翻转时间取决于 GPIO 翻转速度和delay_us精度。STM32F103 主频 72MHz 时,GPIO 翻转一条指令大约是 14ns 左右,但加上循环判断和延时函数,实际刷一帧 128×64 单色位图大约需要 20~30ms,也就是 33~50fps 的上限。如果做波形显示,这个刷新率基本够用,但如果你要在同一块屏上叠加多通道波形,每帧写入的数据量会翻倍,帧率会掉到 15fps 以下,眼睛能明显看到闪烁。

硬件 SPI 的优势在于 DMA 搬运和更稳定的时钟相位。使用 STM32F103 的 SPI1 外设时,需要把 SCK 复用为 PA5、MOSI 复用为 PA7,初始化代码要切换到复用开漏或复用推挽模式,关键代码如下:

RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7; // SCK, MOSI GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用推挽 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); SPI_InitTypeDef SPI_InitStructure; SPI_InitStructure.SPI_Direction = SPI_Direction_1Line_Tx; // 只发不收 SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_High; // 空闲时钟为高 SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge; // 第二个边沿采样 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件管理片选 SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_2; // 36MHz 二分频 SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; // 高位先出 SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE);

替换oled.c里的OLED_WriteData函数为硬件 SPI 发送的核心是每个字节要等待发送完成标志:

void OLED_WriteData(uint8_t data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); }

SPI_CPOL_HighSPI_CPHA_2Edge是 SSD1306 的典型要求:SPI 模式 3。注意SPI_Direction_1Line_Tx在 F1 系列上实际是SPI_Direction_1Line_TxSPI_Direction_2Lines_FullDuplex,因为 OLED 不需要回读,单向发送可以省略 MISO 配置,否则 MISO 引脚悬空可能引入噪声。SPI_BaudRatePrescaler_2在 APB2 时钟 72MHz 下得到 18MHz,SSD1306 最高支持约 20MHz,所以二分频是安全的;再快就可能出现花屏。

2.3 把连线函数改造成你自己的板子:三步替换法

不管代码包里默认的引脚是什么,你拿到手第一件事不是编译,而是打开oled.h看引脚宏定义。我一般按三步走:先看原理图上 OLED 模块的接口是 SPI 还是 I2C;再打开oled.c里的OLED_InitOLED_WriteByte,确认底层发送用的是硬件外设还是 GPIO 模拟;最后把所有引脚宏改成自己板子的实际连接,并检查有没有复用冲突。比如你用的是 STM32F103C8T6 最小系统板,PB3、PB4 默认是 JTAG 的 JTDO 和 JNTRST,直接当普通 GPIO 用必须先在系统初始化里关闭 JTAG:

GPIO_PinRemapConfig(GPIO_Remap_SWJ_JTAGDisable, ENABLE);

否则 PB3/PB4 永远输出不了正确电平,OLED 会一直白屏。这一步是初学者最容易漏的坑,也是很多下载包里没写清楚的隐含依赖。

3. OLED 显示函数拆解:从画点到显示汉字的核心实现

3.1 显存模型:SSD1306 的页地址和列地址规则

oled.c里所有绘图函数最终都是往一个 8 字节一页的内存映射里写数据。SSD1306 内部有 128×64 位的显存,但它的数据组织方式是 8 页(Page0~Page7),每页 128 字节,每个字节的 8 个 bit 对应屏幕一列的 8 行像素。所以OLED_ClearScreen的实现逻辑是把 1024 个字节全部写 0x00:

void OLED_ClearScreen(void) { uint8_t page, col; for (page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); // 设置页地址 0~7 OLED_WriteCmd(0x00); // 设置列地址低 4 位 OLED_WriteCmd(0x10); // 设置列地址高 4 位 for (col = 0; col < 128; col++) { OLED_WriteData(0x00); } } }

0xB0 + page是页寻址命令,0x000x10组合出列地址 0;然后每页连续写 128 个数据字节,硬件会自动递增列地址。理解这个模型后,你就能明白为什么许多 OLED 库绘制一个像素时,要计算byte = y / 8bit = y % 8,因为一次写入是按字节方式更新某页的某列,并且直接OLED_WriteData会覆盖掉该字节原有的其他 7 个 bit。OLED_DrawPixel的常用实现是先读改写:

void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t color) { uint8_t page = y / 8; uint8_t bit = y % 8; uint8_t old_byte = OLED_GRAM[page][x]; // 从本地显存读取 if (color) { old_byte |= (1 << bit); } else { old_byte &= ~(1 << bit); } OLED_GRAM[page][x] = old_byte; // 之后在 OLED_Refresh 中统一写屏 }

注意这里引入了一个本地二维数组OLED_GRAM[8][128]oled.c里如果带了显存缓冲,所有绘图操作都只在缓冲区上进行,最后统一调用OLED_Refresh把整个缓冲刷到屏幕。这种做法能极大减少 SPI 通信次数,否则每画一个点就发一次命令,屏幕会像打字机一样卡顿。如果包里没有显存缓冲,直接在OLED_DrawPixel内部读回 SSD1306 是不支持的,因为你无法从屏上读数据,只能自己维护缓冲区。

3.2 画线函数为什么用 Bresenham,而不是直接差分

OLED_DrawLine通常实现的是 Bresenham 直线算法,它的优点是全程只用整数加减和比较,没有乘除和浮点运算,在 Cortex-M3 上执行速度极快。函数的典型实现如下:

void OLED_DrawLine(int x1, int y1, int x2, int y2) { int dx, dy, sx, sy, err, e2; dx = (x1 < x2) ? (x2 - x1) : (x1 - x2); dy = (y1 < y2) ? (y1 - y2) : (y2 - y1); // 注意 dy 取负值处理斜率 sx = (x1 < x2) ? 1 : -1; sy = (y1 < y2) ? 1 : -1; err = dx + dy; while (1) { OLED_DrawPixel(x1, y1, 1); if (x1 == x2 && y1 == y2) break; e2 = 2 * err; if (e2 >= dy) { err += dy; x1 += sx; } if (e2 <= dx) { err += dx; y1 += sy; } } }

这个实现的细节在于dy故意写成负值,让err的累加统一映射到第一象限,sxsy负责方向纠正。e2是两倍误差项,避免符号判断的边界问题。如果你把dy写成正数,会导致e2 >= dy判断永远不成立或乱跳,线会变成虚线。关于摘要里提到的「起点在终点右边」的情况,Bresenham 算法天然支持任意方向,只要sxsy按两个端点的相对位置取 1 或 -1,无需额外条件分支。实际使用时注意坐标边界,OLED_DrawLine(-5, 10, 100, 10)会直接往OLED_GRAM的越界地址写,破坏栈或导致 HardFault,所以成熟代码里通常会在OLED_DrawPixel内部加范围保护:

if (x >= 128 || y >= 64) return;

3.3 显示文本和汉字的字模机制

oled.cOLED_ShowCharOLED_ShowStringOLED_ShowChinese的实现,本质上都是从字模数组里取字节,然后按显存模型填充。ASCII 字符通常用 8×16 点阵,也就是每个字符占两页,每页 8 字节。oled.h里会包含类似const uint8_t F8x16[][16]这样的数组,数据来源是取模软件(如 PCtoLCD2002)生成的 C 语言数组,格式是「纵向取模、字节倒序」。代码逻辑如下:

void OLED_ShowChar(uint8_t x, uint8_t y, uint8_t ch) { uint8_t c = ch - ' '; // 空白字符偏移 uint8_t i; for (i = 0; i < 8; i++) { OLED_GRAM[y / 8][x + i] = F8x16[c][i]; // 上半部分 OLED_GRAM[y / 8 + 1][x + i] = F8x16[c][i + 8]; // 下半部分 } }

这里的y是字符左上角的 Y 坐标,且必须保证y是 8 的倍数,否则上下半部分会错位到错误的页。显示汉字用 16×16 点阵,每个字占据 32 字节,同一行两个汉字需要 32 列,128 宽度一行最多 8 个汉字。你从网上随便下的字模数组,如果取模方式不是「纵向取模、高位在前」,显示出来会是镜像或乱麻。验证方法很简单:先显示一个全 0xFF 的字符,如果整个 8×16 区域全亮,说明取模格式匹配;如果亮一半或错位,说明字节位序反了。波形显示中如果要在图上叠加文字标签,最好固定把文字放在顶部两页,因为波形曲线会占满整屏,底部刷新频繁时文字显示会被撕裂。

4. 波形显示实战:ADC 采集映射到 OLED 坐标的完整链路

4.1 从 ADC 数据到屏幕 Y 坐标的映射公式

摘要里提到的OLED_ShowWave函数,本质是把一组时间序列数据转换为屏幕上的 (x, y) 点集。STM32F103 内置 12 位 ADC,采样值范围 0~4095,而 OLED 高度是 64 像素。不能直接把原始值当 Y 坐标,需要做归一化映射。常见做法是维护一个环形缓冲区,接收来自 ADC 的中断或 DMA 数据,然后逐个绘制:

#define WAVE_BUFFER_SIZE 128 uint16_t wave_buf[WAVE_BUFFER_SIZE]; uint8_t wave_index = 0; void ADC_IRQHandler_Collect(void) { wave_buf[wave_index] = ADC_GetConversionValue(ADC1); wave_index = (wave_index + 1) % WAVE_BUFFER_SIZE; } void OLED_ShowWave(void) { uint8_t i; int y1, y2; uint8_t x1, x2; OLED_ClearScreen(); for (i = 1; i < WAVE_BUFFER_SIZE; i++) { x1 = i - 1; x2 = i; y1 = (uint8_t)((wave_buf[(wave_index - 1 + i) % WAVE_BUFFER_SIZE] * 60) >> 12); y2 = (uint8_t)((wave_buf[(wave_index - 1 + i + 1) % WAVE_BUFFER_SIZE] * 60) >> 12); y1 = 63 - y1; // OLED Y 轴向下,ADC 值大对应屏幕下方,所以反转 y2 = 63 - y2; OLED_DrawLine(x1, y1, x2, y2); } OLED_Refresh(); }

这里的映射公式(value * 60) >> 12是把 0~4095 缩放到 0~59,留出上下各 2 像素的边框余量;63 - y1是因为 OLED 的 (0,0) 在左上角,ADC 电压越高显示位置应该越靠下,所以取反。使用移位代替除法提高效率,>> 12等价于除以 4096,但编译器在 Cortex-M3 上把整数除法优化为移位+乘法,直接写移位更清晰。

这段代码里我故意没用浮点数,因为OLED_DrawLine内部是整数 Bresenham,浮点转换反而浪费 CPU。注意wave_index的环形取模% WAVE_BUFFER_SIZE在 128 且为 2 的幂时,编译器会优化成位与操作,但如果你改缓冲区大小为 100,取模运算会慢一倍,建议缓冲区长度固定为 2 的幂。

4.2 波形刷新帧率与残影问题:双缓冲才是关键

直接在OLED_ShowWave里先OLED_ClearScreen再逐线绘制,会导致明显的闪烁,因为每次刷屏屏幕会经历「全灭→部分点亮→全亮」的过程。更稳的做法是使用双缓冲:把上一帧的波形擦除,再画新波形。但 SSD1306 不支持局部擦除,所以实际上是在本地OLED_GRAM上做差分更新。我常用的优化策略是只重绘变化区域,也就是把上一帧的波形像素擦成 0,然后把新数据画上去,最后整帧刷新:

uint8_t prev_wave_y[128]; // 记录上一帧每个 x 对应的 y void OLED_ShowWaveSmooth(uint16_t *new_data, uint16_t len) { uint8_t x; for (x = 0; x < len; x++) { OLED_DrawPixel(x, prev_wave_y[x], 0); // 擦除旧点 uint8_t new_y = 63 - (uint8_t)((new_data[x] * 60) >> 12); OLED_DrawPixel(x, new_y, 1); // 绘制新点 prev_wave_y[x] = new_y; } OLED_Refresh(); }

这种「擦旧画新」策略比全屏清空加重画快得多,因为省去了对非波形区域的大量零字节写入。如果波形带毛刺,说明 ADC 采集数据噪声大,可以在映射前加滑动平均滤波,例如:

uint16_t filtered = (new_data[x] + new_data[x-1] + new_data[x-2]) / 3;

注意滑动平均会引入相位延迟,对快速变化的信号不友好,更合适的做法是中值滤波或一阶低通:filtered = (filtered * 7 + raw) / 8

4.3 波形与坐标网格叠加显示的绘制顺序

如果你的项目需要在波形后面显示网格(每个格子 16×16 像素),绘制顺序必须是「先画网格,再画波形」,否则网格会覆盖在曲线上面。网格通常用OLED_DrawHLineOLED_DrawVLine,这两个函数可以复用OLED_DrawLine,但为了效率,单独实现成循环写显存更合适:

void OLED_DrawHLine(uint8_t x1, uint8_t x2, uint8_t y, uint8_t color) { uint8_t page = y / 8; uint8_t bit = y % 8; uint8_t i; for (i = x1; i <= x2; i++) { if (color) OLED_GRAM[page][i] |= (1 << bit); else OLED_GRAM[page][i] &= ~(1 << bit); } }

画竖直线时不能用这种逐列写 bit 的方式,因为一列会跨越多个页,需要逐页写入。一个取巧的办法是直接把整列对应的每个页字节的对应 bit 置 1。坐标网格的颜色建议用低亮度(如像素值 1 表示点亮),与波形的亮度区分,但单色 OLED 没有灰度,只能通过线宽区分:网格用 1 像素点线,波形用 2 像素宽线。OLED_DrawLine加线宽最简单的方式就是在画点函数里把相邻的 y 也点亮:

void OLED_DrawPixelWidth(uint8_t x, uint8_t y, uint8_t width) { OLED_DrawPixel(x, y, 1); if (width >= 2) { OLED_DrawPixel(x, y + 1, 1); OLED_DrawPixel(x, y - 1, 1); } }

但要小心 y 坐标越界,y + 1在 y 为 63 时会跳过范围检查,所以OLED_DrawPixel内部的if (y >= 64) return;必须保留。

5. 踩坑实录:从 Keil 编译错误到屏幕显示异常的排查清单

5.1 编译报错主要集中在头文件包含和宏重定义

下载的oled.c很可能#include "stm32f10x.h"#include "stm32f10x_gpio.h"。如果你的工程是 HAL 库(stm32f1xx_hal.h),直接包含会报大量未定义类型错误。解决办法是把oled.c中的寄存器操作改为 HAL 层封装,或者保持标准外设库不动。如果你用的是 Keil MDK 且安装了不同版本的固件库,还会遇到GPIO_TypeDef冲突,检查工程设置里的 C/C++ Include Paths 是否同时包含了多个库路径。最省事的做法是新建一个文件夹存放oled.coled.h,然后把标准外设库的头文件路径添加进去,并确保stm32f10x_conf.h中的#include "stm32f10x_gpio.h"等配置开启。

另一个高频报错是OLED_WR_ByteOLED_WriteCmd的宏冲突。有些代码包把写命令和写数据封装成带#define OLED_CMD 0#define OLED_DATA 1的宏,如果全局变量里已有OLED_DATADATA等名词,编译会提示 macro redefinition。此时优先改名而不是删除宏,因为删除可能导致函数里OLED_WriteByte(0xAE, OLED_CMD)的参数失效。

5.2 屏幕白屏、花屏、局部不显示的定位方法

白屏是最常见的现象,排查顺序我一般按照「供电 → 复位 → 初始化时序 → 访问模式」来。先量模块 VCC 是否为 3.3V,GND 是否共地。一些 OLED 模块带稳压芯片,可以接 5V,但 STM32F103 的 GPIO 输出是 3.3V 电平,如果模块 VCC 接 5V,SPI 信号的高电平可能不足以达到模块的 VIH 阈值,此时要加电平转换。接着查复位引脚:OLED_RST必须给至少 10ms 的低电平脉冲,如果省掉这一步,SSD1306 可能处于未知状态,初始化命令全部无效。

初始化时序是否正确的验证方法很简单:初始化完成后调用OLED_ClearScreen,然后让屏幕全亮OLED_ShowString(0, 0, "OK")。如果屏幕有反应但不正确,比如显示乱码,多半是页地址或列地址设置命令写错。SSD1306 有三种寻址模式,常用的是页寻址(Page Addressing Mode),初始化序列里一般有0x20, 0x02(页寻址模式)。如果某个 OLED 库默认使用水平寻址模式(0x20, 0x00),而你的代码是按页模式操作显存,屏幕显示会呈蛇形错位。花屏还可能是 SCK 极性错误,SPI_CPOLSPI_CPHA组合不对导致数据在错误的边沿被采样,表现为字符边缘模糊或左右偏移。

5.3 加入了 OLED 函数后程序卡死的特殊场景

热词里有一条「加了oled函数 卡死」,我遇到过几种真实原因:第一种是 OLED 初始化里使用了delay_ms,但你的项目在启动阶段还没开启 SysTick 或 delay 函数是阻塞依赖定时器的,此时进入OLED_Init就会死循环。解决方法是把OLED_Init移到延时系统初始化之后,或者改用 CPU 空循环延时。第二种是引脚复用冲突,比如你把 OLED 的 DC 引脚配到了 PA11/PA12,这两个引脚是 USB 的 D-/D+,如果你的工程还启用了 USB 外设,GPIO 配置互相覆盖导致硬件错误。第三种是局部变量数组过大,比如OLED_GRAM[8][128]作为局部变量定义在函数内部,而 STM32F103 的默认栈只有 1KB 或 2KB,直接爆栈进入 HardFault。检查方法是看OLED_GRAM是全局变量还是局部变量,如果是局部的,立刻改成static uint8_t OLED_GRAM[8][128];或移出函数体到文件顶部。最后一种比较隐蔽:SPI 发送函数里while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET);如果 SPI1 时钟没有使能,该标志永远为 RESET,程序卡死在发送函数里。解决方法是确认开启了 RCC 时钟使能,并且由于 SPI 发送完成后还有SPI_I2S_FLAG_BSY要等,真正的发送函数应该同时检查两个标志:

while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET);

5.4 波形显示闪烁和刷新撕裂的工程化处理

如果你是在主循环里不断调用OLED_ShowWave,波形会闪烁且 CPU 占用极高。更好的方案是使用定时器中断或 DMA 完成刷屏。比如用 TIM2 产生 100Hz 的更新中断,在中断里采集 ADC 并填充缓冲区,然后设置一个标志,主循环检测到标志后调用OLED_Refresh。这样刷新频率固定,且不会因为主循环里其他任务(按键扫描、串口打印)的耗时抖动。如果追求极致流畅,可以开启 OLED 的「滚动显示」命令(0x260x27),但这是硬件滚动,只适合整屏滚动,不适合波形实时更新。波形撕裂的本质是写入显存和刷新到屏之间的时序竞争,解决办法是在OLED_Refresh的写入循环里关闭中断提升优先级,或者使用 DMA 把数据一次性发给 SPI:

DMA_InitTypeDef DMA_InitStructure; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&SPI1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)OLED_GRAM; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = 1024; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel3, &DMA_InitStructure);

这里的 DMA 通道对应SPI1_TX,在 F103 上 DMA1 通道 3 是 SPI1 发送的固定映射。用 DMA 刷屏后,CPU 只需要启动一次传输,然后可以去处理数据,帧率能提升到硬件 SPI 的上限。需要注意 OLED_GRAM 是三字节对齐的内存地址,DMA 对源地址的对齐要求很高,如果编译后数组地址非对齐,DMA 传输会缺失字节。保险做法是把数组声明为__align(4) uint8_t OLED_GRAM[8][128];

6. 进阶技巧:用这个函数包快速移植到 HAL 库和 FreeRTOS

如果你想把这个标准外设库的oled.c用到 STM32CubeMX 生成的 HAL 库工程里,不需要重写所有函数,只需要替换底层发送的五个函数:OLED_WR_ByteOLED_WriteCmdOLED_WriteDataOLED_RST_LOWOLED_RST_HIGH。以 I2C 接口的 OLED 为例,HAL 库版本的核心发送函数如下:

extern I2C_HandleTypeDef hi2c1; #define OLED_I2C_ADDR 0x78 // 7 位地址左移一位后的结果 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 控制字节 0x00 表示命令 HAL_I2C_Master_Transmit(&hi2c1, OLED_I2C_ADDR, buf, 2, 100); } void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; // 0x40 表示数据 HAL_I2C_Master_Transmit(&hi2c1, OLED_I2C_ADDR, buf, 2, 100); }

I2C 模式下没有 DC 和 CS 引脚,控制字节的关键是最高位 Co(continuation bit)和 D/C# 位。0x00的二进制是 0000 0000,Co=0 表示后面还有数据,D/C#=0 表示命令;0x40即 0100 0000,D/C#=1 表示数据。每传一个字节都要先发控制字节,所以速度比 SPI 慢一半,适合对帧率要求不高的时钟、菜单界面。移植时注意oled.h里引脚的宏定义要全部注释掉 I2C 模式不需要的部分,只保留OLED_WriteCmdOLED_WriteData的声明,否则编译会报告未使用的引脚宏占用内存(宏定义不会占内存,但如果宏里包含GPIOB等外设引用,没有报错就不会有问题)。

如果工程用了 FreeRTOS,OLED 显示函数必须考虑互斥访问。因为OLED_GRAM是共享资源,任务 A 在写波形数据时,任务 B 在刷屏,二者同时操作OLED_GRAM数组会导致画面撕裂。我通常的做法是定义一个二值信号量:

SemaphoreHandle_t oled_mutex; void OLED_Init(void) { oled_mutex = xSemaphoreCreateMutex(); // 原有初始化代码... } void OLED_DrawPixelSafe(uint8_t x, uint8_t y, uint8_t color) { xSemaphoreTake(oled_mutex, portMAX_DELAY); OLED_DrawPixel(x, y, color); xSemaphoreGive(oled_mutex); }

刷屏时同样要拿锁,但放在临界区里会阻塞其他任务过长时间,更合理的方式是使用xSemaphoreTake带超时,超过 10ms 就放弃刷屏,等待下一帧:

if (xSemaphoreTake(oled_mutex, pdMS_TO_TICKS(10)) == pdTRUE) { OLED_Refresh(); xSemaphoreGive(oled_mutex); }

移动设备场景下,OLED 的功耗控制很关键。SSD1306 有两个省电命令:0x8D, 0x14开启电荷泵,0xAE关闭显示。如果要在屏幕无操作 30 秒后进入休眠,可以发0xAE关闭显示,但注意电荷泵仍在工作,功耗约 0.1mA。彻底断电需要软件关闭电荷泵:

void OLED_Sleep(void) { OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x8D); // 电荷泵设置 OLED_WriteCmd(0x10); // 关闭电荷泵 }

唤醒时先开电荷泵再开显示:

void OLED_WakeUp(void) { OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 开启电荷泵 OLED_WriteCmd(0xAF); // 开启显示 }

最后分享一个对波形显示特别有用的技巧:OLED_ShowWave里如果数据点数超过 128,需要做降采样,但不要简单跳点,因为可能漏掉尖峰。应该先找出这一段数据的最大值和最小值,然后同时画最大值和最小值两根线,形成包络显示,这在音频频谱分析里非常常见。实现时修改映射逻辑:

uint8_t y_max = 63 - (uint8_t)((max_val * 60) >> 12); uint8_t y_min = 63 - (uint8_t)((min_val * 60) >> 12); OLED_DrawLine(x, y_max, x, y_min); // 画一列竖线形成包络

这样可以在一屏内展示更长时间范围的波形特征,而且视觉识别度远高于细线。这套oled.c加上oled.h的代码,如果自己会改底层发送函数,后续切换屏型号、换接口协议都不需要重写上层绘图逻辑,算是嵌入式显示层值得保留的基础资产。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询