
简介面向STM32F103嵌入式开发者的OLED显示函数代码包专门解决STM32F103与OLED屏之间的底层驱动、硬件连线与图形绘制问题。资源聚焦OLED连线与显示两大核心环节连线部分涵盖SPI/I2C接口的GPIO引脚配置与初始化思路显示部分则提供初始化、清屏、设置坐标、绘制像素、画线以及波形显示等一系列基础函数实现。压缩包内共2个文件包括1个C源文件与1个头文件源文件存放具体实现代码头文件负责声明函数原型、结构体与常量整体仅5KB结构精简便于快速阅读和二次开发。该代码包特别适合初学者作为学习STM32 OLED驱动和实时波形绘制的实践样本也可供有经验的工程师参考移植。已有815人学习适合正在调试OLED显示、需要实现基础波形可视化或希望快速搭建人机交互界面的开发者。1. OLED 显示函数包不只是点屏先搞清这套代码的边界拿到STM32F103OLED显示函数.zip里面只有oled.c和oled.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 模块驱动芯片绝大多数是 SSD13061.3 寸可能是 SH1106指令兼容但有 4 页偏移差异。模块背面会印有接口选择电阻通常有 I2CSDA/SCL 两线、SPISCK/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 - MOSISDA #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_ResetBits和GPIO_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_High和SPI_CPHA_2Edge是 SSD1306 的典型要求SPI 模式 3。注意SPI_Direction_1Line_Tx在 F1 系列上实际是SPI_Direction_1Line_Tx或SPI_Direction_2Lines_FullDuplex因为 OLED 不需要回读单向发送可以省略 MISO 配置否则 MISO 引脚悬空可能引入噪声。SPI_BaudRatePrescaler_2在 APB2 时钟 72MHz 下得到 18MHzSSD1306 最高支持约 20MHz所以二分频是安全的再快就可能出现花屏。2.3 把连线函数改造成你自己的板子三步替换法不管代码包里默认的引脚是什么你拿到手第一件事不是编译而是打开oled.h看引脚宏定义。我一般按三步走先看原理图上 OLED 模块的接口是 SPI 还是 I2C再打开oled.c里的OLED_Init和OLED_WriteByte确认底层发送用的是硬件外设还是 GPIO 模拟最后把所有引脚宏改成自己板子的实际连接并检查有没有复用冲突。比如你用的是 STM32F103C8T6 最小系统板PB3、PB4 默认是 JTAG 的 JTDO 和 JNTRST直接当普通 GPIO 用必须先在系统初始化里关闭 JTAGGPIO_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 个字节全部写 0x00void 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是页寻址命令0x00和0x10组合出列地址 0然后每页连续写 128 个数据字节硬件会自动递增列地址。理解这个模型后你就能明白为什么许多 OLED 库绘制一个像素时要计算byte y / 8和bit 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的累加统一映射到第一象限sx和sy负责方向纠正。e2是两倍误差项避免符号判断的边界问题。如果你把dy写成正数会导致e2 dy判断永远不成立或乱跳线会变成虚线。关于摘要里提到的「起点在终点右边」的情况Bresenham 算法天然支持任意方向只要sx、sy按两个端点的相对位置取 1 或 -1无需额外条件分支。实际使用时注意坐标边界OLED_DrawLine(-5, 10, 100, 10)会直接往OLED_GRAM的越界地址写破坏栈或导致 HardFault所以成熟代码里通常会在OLED_DrawPixel内部加范围保护if (x 128 || y 64) return;3.3 显示文本和汉字的字模机制oled.c里OLED_ShowChar、OLED_ShowString和OLED_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_DrawHLine和OLED_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.c和oled.h然后把标准外设库的头文件路径添加进去并确保stm32f10x_conf.h中的#include stm32f10x_gpio.h等配置开启。另一个高频报错是OLED_WR_Byte或OLED_WriteCmd的宏冲突。有些代码包把写命令和写数据封装成带#define OLED_CMD 0、#define OLED_DATA 1的宏如果全局变量里已有OLED_DATA或DATA等名词编译会提示 macro redefinition。此时优先改名而不是删除宏因为删除可能导致函数里OLED_WriteByte(0xAE, OLED_CMD)的参数失效。5.2 屏幕白屏、花屏、局部不显示的定位方法白屏是最常见的现象排查顺序我一般按照「供电 → 复位 → 初始化时序 → 访问模式」来。先量模块 VCC 是否为 3.3VGND 是否共地。一些 OLED 模块带稳压芯片可以接 5V但 STM32F103 的 GPIO 输出是 3.3V 电平如果模块 VCC 接 5VSPI 信号的高电平可能不足以达到模块的 VIH 阈值此时要加电平转换。接着查复位引脚OLED_RST必须给至少 10ms 的低电平脉冲如果省掉这一步SSD1306 可能处于未知状态初始化命令全部无效。初始化时序是否正确的验证方法很简单初始化完成后调用OLED_ClearScreen然后让屏幕全亮OLED_ShowString(0, 0, OK)。如果屏幕有反应但不正确比如显示乱码多半是页地址或列地址设置命令写错。SSD1306 有三种寻址模式常用的是页寻址Page Addressing Mode初始化序列里一般有0x20, 0x02页寻址模式。如果某个 OLED 库默认使用水平寻址模式0x20, 0x00而你的代码是按页模式操作显存屏幕显示会呈蛇形错位。花屏还可能是 SCK 极性错误SPI_CPOL和SPI_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 的「滚动显示」命令0x26或0x27但这是硬件滚动只适合整屏滚动不适合波形实时更新。波形撕裂的本质是写入显存和刷新到屏之间的时序竞争解决办法是在OLED_Refresh的写入循环里关闭中断提升优先级或者使用 DMA 把数据一次性发给 SPIDMA_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_Byte、OLED_WriteCmd、OLED_WriteData、OLED_RST_LOW、OLED_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 引脚控制字节的关键是最高位 Cocontinuation bit和 D/C# 位。0x00的二进制是 0000 0000Co0 表示后面还有数据D/C#0 表示命令0x40即 0100 0000D/C#1 表示数据。每传一个字节都要先发控制字节所以速度比 SPI 慢一半适合对帧率要求不高的时钟、菜单界面。移植时注意oled.h里引脚的宏定义要全部注释掉 I2C 模式不需要的部分只保留OLED_WriteCmd和OLED_WriteData的声明否则编译会报告未使用的引脚宏占用内存宏定义不会占内存但如果宏里包含GPIOB等外设引用没有报错就不会有问题。如果工程用了 FreeRTOSOLED 显示函数必须考虑互斥访问。因为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的代码如果自己会改底层发送函数后续切换屏型号、换接口协议都不需要重写上层绘图逻辑算是嵌入式显示层值得保留的基础资产。本文还有配套的精品资源点击获取