
1. 项目缘起为什么是STM32 I2C OLED如果你玩过STM32大概率会想找个屏幕来显示点东西。数码管太简陋TFT彩屏又有点“杀鸡用牛刀”接线还麻烦。这时候一块小巧的0.96寸或1.3寸的OLED屏就成了绝佳选择。它自发光、对比度高、功耗低最关键的是通过I2C接口驱动只需要两根信号线SDA, SCL加上电源和地总共四根线就能搞定极大节省了宝贵的IO口特别适合在引脚资源紧张的小型项目中使用。我最初接触这个组合是在做一个环境监测的小玩意儿上。需要实时显示温湿度、气压数据数据量不大但要求显示清晰、待机时间长。LCD1602需要背光在暗处还行阳光下看不清功耗也高而这块OLED屏黑色部分完全不发光显示白色字符非常锐利功耗可以低到令人发指用STM32的低功耗模式配合电池续航能延长好几倍。市面上最常见的驱动芯片是SSD1306它控制着128x64或128x32像素的点阵我们写的每一个字符、画的每一条线最终都是通过操作SSD1306内部的显存GDDRAM来实现的。所以这个“STM32 OLED屏I2C接口显示”项目本质上就是教会STM32如何通过I2C这个“慢速但简洁的对话通道”向SSD1306芯片发送命令和数据从而在屏幕上点亮特定的像素。整个过程涉及硬件连接、I2C总线配置、SSD1306初始化、以及最核心的“画点”函数实现。一旦有了画点函数显示字符、字符串、图形甚至中文都只是在这个基础上的软件构建。网上有很多现成的库但如果不搞清楚底层原理一旦屏幕不亮或者显示乱码排查起来就会非常痛苦。接下来我就从硬件到软件把每一步的细节、原理和容易踩的坑掰开揉碎了讲清楚。2. 硬件连接与I2C总线基础四根线背后的门道硬件连接看起来简单但细节决定成败。首先确认你的OLED模块。常见的0.96寸OLED模块背面通常会有一个小芯片上面印着SSD1306。模块会引出几个引脚VCC、GND、SCL、SDA有些还有RESET和DC引脚。对于纯I2C接口的模块我们只使用前四个。注意有些模块为了兼容SPI接口会通过电阻焊盘选择通信模式你需要确保模块被配置为I2C模式通常是通过焊接电阻将BS0/BS1/BS2引脚设置为对应电平。接线如下OLED VCC- STM32的3.3V。绝对不要接5V大多数这类OLED模块的工作电压是3.3V接5V可能会损坏屏幕或驱动芯片。OLED GND- STM32的GND。OLED SCL- STM32的任意一个I2C时钟引脚例如对于STM32F103C8T6I2C1的SCL是PB6I2C2的SCL是PB10。OLED SDA- STM32的对应I2C数据引脚例如I2C1的SDA是PB7I2C2的SDA是PB11。这里就引出了第一个关键点上拉电阻。I2C总线是开源漏极Open-Drain结构这意味着SCL和SDA线本身无法输出高电平只能拉低或者释放。释放后需要靠外部电阻将电平拉到高电平即3.3V。好消息是STM32的I2C引脚内部可以配置上拉电阻通过GPIO的上拉模式但内部的电阻值通常较大约40kΩ在总线电容稍大、速率稍高时可能造成上升沿过缓导致通信失败。因此最稳妥的做法是在SCL和SDA线上各接一个4.7kΩ的外部上拉电阻到3.3V。很多OLED模块为了用户方便已经把这些上拉电阻集成在板子上了你可以观察模块背面SCL和SDA线路上是否有贴片电阻。如果没有就必须自己外加。注意如果你使用STM32CubeMX配置I2C并将引脚模式设置为开漏输出Open-Drain并启用内部上拉对于短距离、低速比如我们用的100kHz或400kHz通信有时也能工作。但一旦出现通信不稳定第一个要怀疑的就是上拉电阻。接下来是I2C设备地址。SSD1306的I2C地址是0x78写地址或0x79读地址这是7位地址格式。在发送时我们需要左移一位并在最低位加上读写位。所以常用的写地址是0x78对应的8位地址即STM32 HAL库发送的地址通常是0x78。但有些模块的地址可能是0x7A这取决于模块上SA0引脚的电平接地或接VCC。如果你用0x78地址通信失败可以尝试0x7A。在代码中我们通常定义一个宏#define OLED_ADDRESS 0x78。3. 软件驱动层从I2C初始到基本绘图引擎有了硬件基础我们进入软件部分。我将驱动分为三层I2C底层传输函数、SSD1306命令/数据发送函数、基本图形功能函数画点、画线、显示字符。很多教程会直接给你一个整合好的库但我建议你自己写一遍理解每一层的作用。3.1 I2C初始化与底层发送函数首先使用STM32CubeMX配置I2C外设。以STM32F103C8T6的I2C1为例在Pinout Configuration标签页找到I2C1将模式设置为I2C。在Configuration标签页进入I2C1的参数设置。时钟速度Clock SpeedSSD1306最高支持400kHzFast Mode。对于初学者可以先设为100kHzStandard Mode以保证稳定性。我通常直接设400kHz只要上拉电阻没问题通信都很稳定。其他参数保持默认即可。注意STM32的I2C时钟源是APB1时钟通常36MHz或72MHzCubeMX会自动计算分频值。生成代码。CubeMX会生成MX_I2C1_Init函数。接下来我们需要封装两个最基础的函数写一个字节用于发送命令和写多个字节用于发送数据块如图像数据。// 向OLED发送一个命令控制字节为0x00 void OLED_WriteCommand(uint8_t cmd) { uint8_t buf[2] {0x00, cmd}; // 0x00 是控制字节表示后面跟的是命令 HAL_I2C_Master_Transmit(hi2c1, OLED_ADDRESS, buf, 2, HAL_MAX_DELAY); } // 向OLED发送数据控制字节为0x40 void OLED_WriteData(uint8_t data) { uint8_t buf[2] {0x40, data}; // 0x40 是控制字节表示后面跟的是数据 HAL_I2C_Master_Transmit(hi2c1, OLED_ADDRESS, buf, 2, HAL_MAX_DELAY); } // 发送多个数据用于填充显存 void OLED_WriteDataBuffer(uint8_t *data, uint16_t size) { // 这里需要先发送控制字节0x40然后连续发送数据。 // HAL库的I2C传输一次只能发255字节而SSD1306的显存一页是128字节需要处理。 uint8_t *p data; while (size 0) { uint16_t chunk (size 255) ? 255 : size; // 技巧我们可以把控制字节和数据放在一个缓冲区里一起发送 uint8_t *send_buf (uint8_t*)malloc(chunk 1); if (send_buf NULL) return; // 内存分配失败处理 send_buf[0] 0x40; // 控制字节 memcpy(send_buf[1], p, chunk); HAL_I2C_Master_Transmit(hi2c1, OLED_ADDRESS, send_buf, chunk 1, HAL_MAX_DELAY); free(send_buf); p chunk; size - chunk; } }注意HAL_MAX_DELAY在等待I2C传输完成时会让CPU空转在实时性要求高的系统中建议使用中断或DMA模式并设置合理的超时。但对于OLED刷新这个延迟可以接受。3.2 SSD1306初始化序列让屏幕准备好OLED模块上电后SSD1306芯片处于一个未知状态我们必须通过一系列命令对其进行初始化设置驱动电路参数、扫描方式、对比度等。这个命令序列是固定的通常可以在SSD1306的数据手册或厂家示例代码中找到。以下是一个典型的初始化函数void OLED_Init(void) { HAL_Delay(100); // 上电后等待屏幕稳定非常重要 // 关闭显示 OLED_WriteCommand(0xAE); // Display OFF // 设置时钟分频和振荡频率 OLED_WriteCommand(0xD5); OLED_WriteCommand(0x80); // 建议值 // 设置多路复用比率 (MUX Ratio) OLED_WriteCommand(0xA8); OLED_WriteCommand(0x3F); // 对于128x64的屏幕值是64-10x3F。对于128x32值是32-10x1F。 // 设置显示偏移 (Display Offset) OLED_WriteCommand(0xD3); OLED_WriteCommand(0x00); // 无偏移 // 设置显示起始行 (Start Line) OLED_WriteCommand(0x40); // 行地址0 // 设置充电泵 (Charge Pump) OLED_WriteCommand(0x8D); OLED_WriteCommand(0x14); // 使能电荷泵必须否则屏幕不亮 // 设置内存地址模式 (Memory Addressing Mode) OLED_WriteCommand(0x20); OLED_WriteCommand(0x00); // 水平地址模式方便我们后续操作 // 设置段重映射 (Segment Re-map) OLED_WriteCommand(0xA0 | 0x01); // 0xA1: 列地址127映射到SEG0 (左右翻转) // 设置COM扫描方向 (COM Output Scan Direction) OLED_WriteCommand(0xC8); // 0xC0: 正常扫描0xC8: 上下翻转 // 设置COM硬件引脚配置 (COM Pins Hardware Configuration) OLED_WriteCommand(0xDA); OLED_WriteCommand(0x12); // 对于128x64常用0x12。对于128x32可能是0x02。 // 设置对比度 (Contrast Control) OLED_WriteCommand(0x81); OLED_WriteCommand(0xCF); // 对比度值可调范围0~255 // 设置预充电周期 (Pre-charge Period) OLED_WriteCommand(0xD9); OLED_WriteCommand(0xF1); // 建议值 // 设置VCOMH电压等级 (VCOMH Deselect Level) OLED_WriteCommand(0xDB); OLED_WriteCommand(0x40); // 建议值 // 开启整个显示 (Entire Display ON) OLED_WriteCommand(0xA4); // 输出跟随RAM内容0xA5: 强制全亮测试用 // 设置正常/反色显示 (Normal/Inverse Display) OLED_WriteCommand(0xA6); // 正常显示0xA7: 反色显示 // 设置显示时钟比率和振荡频率 (Display Clock Divide Ratio/Oscillator Frequency) // 已在前面设置过 // 最后打开显示 OLED_WriteCommand(0xAF); // Display ON // 清屏 OLED_Clear(); }这段初始化代码有几个极易出错的点电荷泵命令0x8D, 0x14这是必须的它提供驱动OLED像素点所需的内部电压。如果漏掉屏幕会一片漆黑但I2C通信可能是正常的让你误以为是硬件问题。多路复用比率0xA8必须根据你的屏幕分辨率设置。128x64屏是0x3F128x32屏是0x1F。设错了会导致显示高度错乱或完全乱码。COM引脚配置0xDA这个值也和屏幕硬件设计有关。大部分128x64模块用0x12但有些是0x02。如果显示上下错位或重叠可以尝试修改这个值。上电延时HAL_Delay(100)非常关键。SSD1306上电后需要一段时间稳定内部电路如果立即发送初始化命令可能导致部分命令未被正确执行。3.3 核心显存映射与画点函数这是驱动中最核心的部分。SSD1306的显存GDDRAM是一个位图bitmap每一位对应屏幕上一个像素点的亮1或灭0。对于128x64的屏幕显存被组织为8页Page0-Page7每页有128列Column0-Column127。每一页对应屏幕上的8行像素。具体来说Page0控制着屏幕最顶部的8行行0~行7Page1控制行8~行15以此类推。在水平地址模式我们初始化时设置的下写入显存的数据会自动按列递增写完一页的128列后会自动跳到下一页的起始列。这非常符合我们“从左到右从上到下”的刷新习惯。我们需要在STM32的内存中开辟一个缓冲区buffer大小是128 * 64 / 8 1024字节。这个缓冲区完全模拟了SSD1306的显存结构。我们所有的绘图操作画点、画线、写字都先在这个缓冲区里修改对应的位修改完成后再一次性将这个缓冲区通过I2C发送到SSD1306的显存中。这种方法避免了频繁的I2C通信效率高且能实现局部刷新。uint8_t OLED_Buffer[1024]; // 全局显存缓冲区 // 清空缓冲区全黑 void OLED_Clear(void) { memset(OLED_Buffer, 0x00, sizeof(OLED_Buffer)); } // 将整个缓冲区内容更新到OLED屏幕 void OLED_Update(void) { for (uint8_t page 0; page 8; page) { // 设置页地址 OLED_WriteCommand(0xB0 page); // 设置页起始地址 (Page Start Address) // 设置列地址的低4位和高4位 OLED_WriteCommand(0x00); // 设置列起始地址的低4位为0 OLED_WriteCommand(0x10); // 设置列起始地址的高4位为0 // 发送该页的128字节数据 OLED_WriteDataBuffer(OLED_Buffer[page * 128], 128); } }有了缓冲区就可以实现最基础的画点函数。给定屏幕坐标(x, y)x: 0~127, y: 0~63我们需要计算出这个点对应缓冲区中的哪个字节的哪一位。void OLED_DrawPixel(uint8_t x, uint8_t y, uint8_t color) { if (x 128 || y 64) return; // 边界检查 uint8_t page y / 8; // 确定在哪一页 uint8_t bit_pos y % 8; // 确定在字节中的哪一位 if (color) { OLED_Buffer[page * 128 x] | (1 bit_pos); // 点亮像素 } else { OLED_Buffer[page * 128 x] ~(1 bit_pos); // 熄灭像素 } }这个函数是图形显示的基石。color参数为1表示点亮白色为0表示熄灭黑色。这里有一个非常重要的细节SSD1306显存中一个字节的最低位LSB对应页的顶部行。也就是说对于Page0bit0对应屏幕Y坐标0bit1对应Y坐标1...bit7对应Y坐标7。我们的计算(1 bit_pos)符合这个约定。如果你的显示上下颠倒可能是COM扫描方向0xC0/0xC8或这个位映射关系没搞对。4. 从点到面构建字符、图形与用户界面有了画点函数我们就可以构建更高级的功能了。显示字符的本质就是显示一个小的位图字模。我们需要一个字模数组里面存储了每个字符对应的像素信息。通常我们使用ASCII字符集大小为8x16像素宽x高或6x8像素。4.1 显示ASCII字符首先你需要一个字体库。可以从网上下载现成的也可以用取模软件如PCtoLCD2002自己生成。这里以8x16字体为例每个字符用16个字节表示因为高度16像素每8像素一页所以占2页每页8字节宽度。// 8x16 ASCII字模示例仅示例字符A const uint8_t Font8x16[][16] { {0x00,0x00,0x00,0x18,0x24,0x42,0x42,0x7E,0x42,0x42,0x42,0x42,0x00,0x00,0x00,0x00}, // A // ... 其他字符 }; void OLED_ShowChar(uint8_t x, uint8_t y, char ch, uint8_t size, uint8_t color) { uint8_t i, j; uint8_t *pFont; if (ch || ch ~) ch ; // 只处理可打印ASCII pFont (uint8_t*)Font8x16[(ch - ) * 16]; // 找到字符字模起始地址 for (i 0; i 16; i) { // 16行 uint8_t line_data pFont[i]; for (j 0; j 8; j) { // 8列 if (line_data (0x80 j)) { // 从最高位开始判断 OLED_DrawPixel(x j, y i, color); } else { if(color ! 2) { // 2表示透明模式不绘制背景 OLED_DrawPixel(x j, y i, !color); } } } } } // 显示字符串 void OLED_ShowString(uint8_t x, uint8_t y, char *str, uint8_t size, uint8_t color) { while (*str) { OLED_ShowChar(x, y, *str, size, color); x 8; // 字符宽度间距 str; if (x 120) { // 换行处理简单示例 x 0; y 16; } } }显示中文的原理类似但中文字模更大通常16x16且需要根据编码如GB2312在字库中查找。你可以将中文字库放在外部Flash或SD卡中或者直接以数组形式编译进代码会占用大量ROM。4.2 绘制基本图形基于画点函数可以衍生出画线、画矩形、画圆等函数。例如画线可以使用Bresenham算法这是一个经典的只使用整数运算的高效算法。void OLED_DrawLine(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t color) { int dx abs(x2 - x1); int dy abs(y2 - y1); int sx (x1 x2) ? 1 : -1; int sy (y1 y2) ? 1 : -1; int err dx - dy; int e2; while (1) { OLED_DrawPixel(x1, y1, color); if (x1 x2 y1 y2) break; e2 2 * err; if (e2 -dy) { err - dy; x1 sx; } if (e2 dx) { err dx; y1 sy; } } } // 画矩形空心 void OLED_DrawRectangle(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t color) { OLED_DrawLine(x1, y1, x2, y1, color); OLED_DrawLine(x1, y2, x2, y2, color); OLED_DrawLine(x1, y1, x1, y2, color); OLED_DrawLine(x2, y1, x2, y2, color); } // 画矩形实心 void OLED_FillRectangle(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2, uint8_t color) { uint8_t i, j; for (i y1; i y2; i) { for (j x1; j x2; j) { OLED_DrawPixel(j, i, color); } } }4.3 显示图像显示一张位图就是将图片的二进制数据直接写入显存缓冲区。图片需要事先转换成单色位图并保存为C数组。图片的宽度必须是8的倍数因为按字节组织高度任意。写入时需要根据图片尺寸和起始坐标计算在缓冲区中的位置然后进行内存拷贝。// 显示一幅图像 (width必须是8的倍数) void OLED_DrawBitmap(uint8_t x, uint8_t y, uint8_t width, uint8_t height, const uint8_t *bitmap, uint8_t color) { uint8_t i, j, byteWidth width / 8; if (width % 8) byteWidth; // 如果宽度不是8的倍数需要进位 for (j 0; j height; j) { for (i 0; i width; i) { uint16_t byteIndex j * byteWidth i / 8; uint8_t bitIndex i % 8; // 注意位图数据通常也是LSB对应顶部像素但取决于取模软件设置 if (bitmap[byteIndex] (1 bitIndex)) { OLED_DrawPixel(x i, y j, color); } else { if(color ! 2) { OLED_DrawPixel(x i, y j, !color); } } } } }5. 实战调试与性能优化让显示稳定又流畅理论通了代码写了但第一次上电屏幕可能还是不亮。别慌按以下步骤排查检查硬件连接用万用表测量VCC是否为3.3VGND是否连通。检查SCL和SDA线上是否有4.7kΩ上拉到3.3V。检查I2C地址使用逻辑分析仪或STM32的I2C扫描程序确认OLED模块的I2C地址。发送地址后看是否有ACK应答。这是最直接的通信测试。检查初始化序列特别是电荷泵命令0x8D, 0x14和打开显示命令0xAF是否成功发送。可以在每条命令后加一个HAL_Delay(1)确保芯片有足够时间处理。检查显存更新初始化后尝试向显存写入全0xFF全亮或全0x00全黑然后更新屏幕。如果屏幕有反应全白或全黑说明基础通信和初始化是好的问题出在画点或字库逻辑。显示错位或镜像如果字符显示是反的、倒的检查初始化命令中的段重映射0xA0/A1和COM扫描方向0xC0/C8。可以尝试不同的组合。屏幕闪烁如果刷新整个屏幕1024字节时肉眼能看到明显的闪烁是因为I2C传输这1KB数据需要时间在100kHz下约需100ms。优化方法提高I2C速率在稳定前提下将I2C时钟设为400kHz。局部刷新只更新屏幕上发生变化的部分区域。例如更新一个数字时只重写这个数字所在的矩形区域对应的缓冲区数据然后只向SSD1306发送这一小块数据。这需要修改OLED_Update函数使其能指定更新区域。双缓冲区开辟两个显存缓冲区。一个用于后台绘制Buffer_A一个用于前台显示Buffer_B。当后台绘制完成一帧后交换缓冲区指针然后通过DMA将新的前台缓冲区数据发送出去。这样绘制过程不会影响显示能获得更流畅的动画效果但对STM32的内存和DMA配置要求更高。关于取模软件PCtoLCD2002是常用工具。取模时要注意设置取模方式逐行式 vs 逐列式。SSD1306是逐列式、高位在前MSB还是低位在前LSB。这需要和你的OLED_DrawPixel及字模解析逻辑匹配。通常选择“逐列式”字节内像素点顺序选择“从上到下”即LSB对应顶部。如果不匹配显示出来的字符会是横向或纵向错乱的。阴码/阳码阴码表示点亮像素为1熄灭为0阳码则相反。我们的代码通常按阴码处理。最后一个完整的显示流程应该是这样的int main(void) { HAL_Init(); SystemClock_Config(); MX_I2C1_Init(); OLED_Init(); // 初始化OLED OLED_Clear(); // 清屏 OLED_ShowString(0, 0, Hello, OLED!, 16, 1); // 在(0,0)显示字符串 OLED_ShowString(0, 16, Temp: 25.6C, 16, 1); OLED_DrawLine(0, 40, 127, 40, 1); // 画一条横线 OLED_Update(); // 将缓冲区内容刷到屏幕 while (1) { // 动态更新部分例如刷新温度值 // 1. 在缓冲区中擦除旧值用背景色重绘矩形 // 2. 绘制新值 // 3. 调用 OLED_Update() 或局部刷新函数 HAL_Delay(1000); } }通过以上步骤你应该能够彻底掌握STM32驱动I2C OLED屏的全过程。从最底层的信号线连接到中间层的协议通信和初始化再到上层的图形界面构建每一层都有需要注意的细节和潜在的坑。自己动手实现一遍远比直接拷贝一个库文件收获大得多。当屏幕上如期亮起你想要的字符和图形时那种成就感就是嵌入式开发的乐趣所在。