做嵌入式这么久我始终觉得点亮一块屏幕是学习过程中最爽的节点之一。之前写代码全靠串口打印数据printf打完就完事一旦换成OLED屏幕你写出来的每一条曲线、每一个数字都有了形态整个项目的观感立刻不一样了。今天这篇文章就专门讲一件事STM32 HAL库驱动OLED屏幕用的方案是SSD1306控制器的0.96寸I2C接口屏。这是目前DIY圈子里最常见、资料最全、踩坑也最多的一种组合我会把我实际工程里验证过的代码、初始化序列、寻址逻辑、以及各种“灯下黑”的坑全部摊开讲一遍目标是让你照着这篇文章就能把屏幕点亮并且能显示字符、汉字、图片、动画还有传感器数据。这篇文章适合初学者也适合从标准库刚转到HAL库的老开发。我会把I2C的底层原理和HAL库的封装接口对照着讲因为很多问题——比如屏幕点不亮、数据错乱、刷新慢——根源其实都在协议和配置层面而不是写在屏幕驱动那几句函数里。1. 项目整体拆解先想清楚这三件事再动手写代码1.1 SSD1306 OLED凭什么成为首选板子上的显示器件有很多种选择LCD1602、LCD12864、TFT彩屏、段码屏但0.96寸的SSD1306 OLED几乎是学习阶段绕不开的一个。为什么因为它的驱动芯片非常成熟SPI和I2C两种接口都可以接功耗低视角好关键是代码量小一个驱动文件两三百行就能搞定基础显示功能。SSD1306是一颗单色OLED驱动芯片内部自带显存容量是128x64位也就是128列乘64行的像素点阵每个像素用1个bit表示亮灭。这一份显存整整好是1KB也就是128乘以64再除以8。驱动芯片把这1KB显存映射到内部RAM里外部单片机只需要通过I2C或者SPI往这个RAM区域写数据屏幕就会自动把显存内容刷新出来。这意味着你不需要像驱动LCD1602那样手动控制每一个像素的刷新时序只要把数据写好、命令发对显示的事情由SSD1306自己处理。这个设计思路对开发者非常友好尤其是在HAL库这种封装层次比较高的开发方式下你甚至不需要关心底层的引脚翻转只需要调用I2C接口往寄存器里塞数据就行了。1.2 I2C版本和SPI版本到底选哪个市面上0.96寸的SSD1306模块一般有两种版本一种是I2C接口4针一种是SPI接口7针。我在这篇文章里重点讲I2C版本原因很实际I2C只需要两根信号线SCL和SDA加上VCC和GND总共四根线接线极其简单而且可以和其他I2C器件比如温湿度传感器、加速度计、EEPROM挂在同一条总线上。对于一个学习项目来说这是一种自带扩展性的选择。SPI版本的优势是传输速度快刷新率高刷动画的时候优势明显。但代价是要占用更多IO口而且很多初学者在接线的时候会搞错片选和数据/命令引脚。从“先把屏幕点亮”这个角度出发I2C版本的容错率明显更高这也是为什么淘宝上卖得最多的0.96寸OLED就是I2C接口的版本。至于刷新率的问题我在后面动画章节会专门讲怎么在400kbps的I2C速率下尽量把动画刷得流畅。1.3 为什么选HAL库而不是标准库关于开发库的争论一直存在标准库Standard Peripheral Library确实资料多、教程多但ST官方早就停止了标准库的更新新出的芯片型号比如G系列、L4系列基本只提供HAL库。HAL库全称Hardware Abstraction Layer硬件抽象层它的核心思想是把寄存器操作封装成统一的API让同一份代码在不同型号的STM32之间迁移的成本降到最低。HAL库的文件结构其实并不复杂。以STM32CubeMX生成的工程为例代码主要分布在几个目录里Core目录存放用户代码包括main.c、stm32f1xx_it.c这些Drivers目录下面是CMSIS设备头文件和STM32F1xx_HAL_Driver驱动源码如果你用了第三方组件比如FatFS、FreeRTOS、TouchGFX会出现在Middlewares目录下。HAL库的源码文件命名规律也很清楚stm32f1xx_hal_i2c.c就是I2C外设的驱动stm32f1xx_hal_gpio.c就是GPIO的驱动stm32f1xx_hal_conf.h是全局配置文件决定哪些外设模块被编译进工程。用HAL库最大的好处是你不需要反复去翻参考手册里的寄存器位定义只需要理解外设的基本工作方式然后调用对应的HAL接口。以I2C发送数据为例标准库要操作一堆CR、SR寄存器而HAL库一行HAL_I2C_Mem_Write就把问题解决了。对于项目开发来说省下的时间可以花在业务逻辑上。2. 硬件连接与I2C通信的底层逻辑2.1 四根线接好屏幕真的能亮吗别急着写代码先把物理连接搞定。0.96寸I2C OLED模块标准的四个引脚是GND、VCC、SCL、SDA。VCC一般接3.3V大部分模块板上已经做了稳压和电平转换但如果你用的是纯SSD1306裸屏而不是带转接板的模块就必须严格按照3.3V供电绝对不能直接接5V否则屏会烧掉。我建议的接线方式是这样STM32F103C8T6最小系统板上GND接GNDVCC接3.3VSCL接PB6SDA接PB7。为什么选PB6和PB7因为这是STM32F103的I2C1外设的默认引脚当然I2C外设的引脚是可以通过AFIO重映射的但在HAL库里用CubeMX配置时PB6和PB7是最省事的默认选择。这里必须提醒一句直接用GPIO模拟I2C和用硬件I2C外设是两条完全不同的路线。江科大那套非常流行的入门教程用的是软件模拟I2C也就是用普通GPIO手动拉高拉低来仿真时序好处是引脚随便选缺点是CPU占用高。HAL库驱动SSD1306一般走的是硬件I2C外设路线CPU只管往数据寄存器里丢数据时序由硬件生成效率高得多。两种方案我都跑通过但既然标题是HAL库方案我下面讲的都是硬件I2C。2.2 开漏输出加上拉电阻I2C能通信的底层秘密I2C通信有一个非常关键的设计就是总线上的设备输出端都是开漏结构。什么叫开漏你可以把它理解成一个开关一端接地另一端悬空。开漏输出模式下引脚只能主动拉低到GND不能主动输出高电平。那高电平从哪里来从上拉电阻来。总线上必须接上拉电阻到VCC当所有设备都不拉低总线时上拉电阻把总线电平拉到高电平当某个设备想发送低电平时它把开关闭合总线被拉低。这样多个设备就可以共享同一条总线不会出现一个设备输出高、另一个设备输出低直接短路烧毁的问题。这是I2C使用开漏输出加外部上拉电阻的根本原因也是它支持一主多从、甚至多主通信的基础。我见过很多人问为什么不能把I2C引脚配成推挽输出推挽输出既能输出高也能输出低理论上不是更方便吗但推挽输出在多设备共线的情况下一旦两个设备同时输出相反电平就会出现大电流灌入轻则通信失败重则烧毁引脚。所以I2C标准规定了开漏结构这是协议层面的要求不是芯片厂商随意设计的。2.3 I2C时序简析起始、停止、应答和数据帧理解了开漏和上拉再来看看I2C总线上的数据是怎么流动的。一次完整的I2C传输可以分成几段主机先发出START条件也就是SCL保持高电平时SDA从高电平跳变到低电平告诉总线上所有从机“我要开始通信了”然后主机发送7位从机地址加1位读写标志位被寻址的从机在第九个时钟周期拉低SDA作为ACK应答信号之后就是数据字节的传输每8位数据后面跟一个ACK传输结束主机发出STOP条件也就是SCL为高时SDA从低电平跳变到高电平。SSD1306的I2C从机地址一般是0x3C这取决于模块上SA0引脚的电平。SA0接地时地址是0x3C接VCC时是0x3D。很多模块没有引出SA0引脚默认地址就是0x3C。这里有个特别容易踩坑的地方HAL库的I2C地址参数要求的是8位地址也就是7位地址左移一位的结果。0x3C左移一位变成0x78所以你在代码里写地址的时候必须写0x78而不是直接写0x3C否则屏幕完全没有反应。这个坑我在下面的代码里会再次强调。2.4 上拉电阻为什么不能瞎选再讲一个容易被忽略的硬件细节上拉电阻的阻值。很多模块板上已经自带了上拉电阻你接上就能用。但如果你自己做板子或者用杜邦线飞线连接就必须自己配上拉电阻。阻值选多大常规的经验值是4.7kΩ总线长度短、设备少的时候10kΩ也能工作总线电容大的时候可能要用2.2kΩ甚至1kΩ。为什么阻值大小会影响通信因为总线上有寄生电容电阻和电容会形成一个RC充电回路电阻越大SDA和SCL从低电平升到高电平的上升沿就越慢。I2C协议对信号的上升时间有严格要求标准模式下是1000ns快速模式400kbps下是300ns。如果上升沿太慢从机就会采样到错误电平通信随之失败。反过来电阻太小也不行灌电流会变大增加功耗极端情况下也会损伤IO口。所以遇到I2C通信偶尔失败、时好时坏的问题先测量一下上拉电阻阻值很多疑难杂症都是在这里翻的车。3. 环境搭建与CubeMX配置建工程其实有固定套路3.1 开发环境准备CubeMX、Keil5、芯片包和下载器开发环境这块我长话短说因为细节太多我只把最关键的流程列出来。首先你需要安装Java运行环境然后安装STM32CubeMX它负责图形化生成工程骨架接着安装Keil MDK也就是Keil5注意安装的时候勾选C51和ARM两个组件避免以后要用51单片机又要重新配置再打开Keil5的Pack Installer下载STM32F1系列的芯片支持包DFP这样Keil才能识别STM32F103。下载器方面ST-Link V2是最常用的选择驱动安装好之后在Keil的Options for Target里选择ST-Link Debugger然后在Settings里确认能识别到芯片ID。这里有一个很常见的坑ST-Link V2识别不到芯片通常是驱动没装好或者接到了错误的SWD引脚上。SWD接口只需要SWDIO、SWCLK、GND三根线就能连上别把3.3V接错就行。如果你开发过程中遇到下载器连不上芯片的问题可以检查一下目标板供电是否正常、SWDIO和SWCLK是否接反、芯片是不是处于低功耗模式。这些属于通用问题和OLED驱动关系不大但确实会卡住很多人的工程进度。3.2 CubeMX配置I2C外设的详细参数打开STM32CubeMX选择芯片型号STM32F103C8T6然后在Pinout界面把PB6和PB7配置为I2C1_SCL和I2C1_SDA。CubeMX会自动把引脚模式识别为开漏复用模式你不用手动改这点很重要因为软件模拟I2C和硬件I2C的GPIO配置完全不同硬件I2C的引脚模式必须由外设接管。接下来在左侧Categories里找到Connectivity下的I2C1打开它的配置界面。这里有几个关键参数I2C Speed Mode选Fast Mode时钟频率设置为400000Hz也就是400kbps。如果担心线材质量或者总线电容太大可以先选Standard Mode的100000Hz试试稳定性优先跑通之后再提高速率。I2C地址那边不用配置因为STM32作为主机不需要地址。其他参数比如Rise Time这些在F1系列上一般不需要手动调整CubeMX会根据你选的速率自动计算好。整个配置过程大概两分钟和手写寄存器相比效率提升非常明显。3.3 HAL库的文件结构到底是怎么回事很多初学者从标准库转到HAL库之后第一反应是工程目录怎么变得这么复杂。我在这里把HAL库工程的关键目录拆开讲一下让你心里有数。首先生成工程后你会看到Core、Drivers、Middlewares如果没有勾选组件就不会生成、以及一些启动文件和链接脚本。Core/Inc和Core/Src存放的是用户代码main.c、stm32f1xx_it.c、stm32f1xx_hal_msp.c都在这两个目录下。其中stm32f1xx_hal_msp.c特别关键它是外设初始化函数的底层回调部分用来配置引脚复用、时钟使能和中断优先级。你在CubeMX里设置的引脚最终就是被生成到这个文件里。Drivers/STM32F1xx_HAL_Driver目录下是完整的HAL库源码按外设分类名字很有规律。stm32f1xx_hal_i2c.c就是I2C驱动stm32f1xx_hal_i2c_ex.c是扩展接口stm32f1xx_hal_gpio.c是GPIO驱动stm32f1xx_hal_rcc.c是时钟管理。还有一个很重要的文件叫stm32f1xx_hal_conf.h它位于Core/Inc目录下里面有一个模块开关宏列表比如HAL_I2C_MODULE_ENABLED如果你注释掉某个宏对应的HAL外设源文件就不会参与编译。CubeMX生成工程的时候会根据你勾选的外设自动配置好这些宏但如果你手动往工程里加新的HAL库文件就要检查一下这个配置文件有没有打开对应模块。理解了HAL库的文件结构你就不会在编译报错的时候手足无措。错误信息提示找不到某个函数时先确认对应的外设源文件是否被添加到了工程里再确认stm32f1xx_hal_conf.h中的模块宏是否打开。3.4 HAL库的I2C接口怎么用核心API和参数含义HAL库封装了相当完整的I2C主机通信接口驱动OLED屏幕最常用的就是两个函数HAL_I2C_Mem_Write和HAL_I2C_Mem_Read。很多人第一次看到Mem_Write会疑惑这个名字是什么意思这里的Mem指的是从机设备内部的寄存器或者内存地址。对于SSD1306来说它接收两个字节的数据控制字节和显示数据。如果控制字节是0x00表示后面的数据是命令如果是0x40表示后面的数据是显存数据。HAL_I2C_Mem_Write函数恰好就是把“设备地址、内部寄存器地址、寄存器地址宽度、数据缓冲区、数据长度、超时时间”这几部分分开处理非常适合SSD1306这种先发控制字节再发数据的设备。函数签名长这样HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout);调用的时候DevAddress是0x78这是7位地址0x3C左移后的结果MemAddress就是控制字节0x00或者0x40MemAddSize是I2C_MEMADD_SIZE_8BIT表示控制字节是8位宽度pData是真正要发送的命令或显示数据缓冲区Size是缓冲区字节数Timeout是最大等待时间单位毫秒。如果函数返回HAL_OK说明传输成功返回HAL_ERROR或者HAL_BUSY就要排查硬件和参数了。4. SSD1306驱动代码实战从初始化到动画刷屏4.1 驱动文件怎么划分把代码分层方便移植写驱动代码我习惯分两层底层是平台相关层只负责通过HAL库的I2C接口往SSD1306发送命令和数据上层是显示功能层负责清屏、显示字符、显示汉字、显示图片这些用户友好的接口。新建oled.c和oled.h两个文件oled.h里声明所有对外接口函数oled.c里实现细节。底层函数是私有的用static修饰对外只暴露显示功能接口。这样做的最大好处是如果你以后换了屏幕比如换成SH1106或者换一块SPI接口的OLED只需要改底层那两三个函数上层的显示逻辑基本不用动。4.2 初始化序列逐条拆解对着中文手册看每条命令的意义SSD1306刚上电的时候并不会自动进入可显示状态主机必须发送一串初始化命令。网上流传的初始化序列大同小异但很多人只是复制粘贴并不清楚每一条命令在干什么。我结合SSD1306芯片手册把最常用的序列逐条讲一遍。void OLED_Init(void) { HAL_Delay(50); uint8_t init_cmd[] { 0xAE, // 0xAE: 关闭显示 0xD5, 0x80, // 0xD5: 设置显示时钟分频/振荡器频率 0xA8, 0x3F, // 0xA8: 设置多路复用比0x3F表示64行 0xD3, 0x00, // 0xD3: 设置显示偏移量这里为0 0x40, // 0x40: 设置显示起始行从第0行开始 0x8D, 0x14, // 0x8D: 使能电荷泵这是屏幕能亮的关键 0x20, 0x02, // 0x20: 设置内存寻址模式为页寻址模式 0xA1, // 0xA1: 设置段重映射列地址0映射到SEG127 0xC8, // 0xC8: COM扫描方向从上到下 0xDA, 0x12, // 0xDA: 设置COM引脚硬件配置 0x81, 0xCF, // 0x81: 设置对比度0xCF是典型值 0xD9, 0xF1, // 0xD9: 设置预充电周期 0xDB, 0x40, // 0xDB: 设置VCOMH取消选择电平 0xA4, // 0xA4: 从显存恢复显示不用内部ROM内容 0xA6, // 0xA6: 设置正常显示模式点亮为高电平 0xAF // 0xAF: 打开显示 }; for (uint8_t i 0; i sizeof(init_cmd); i) { OLED_WriteCmd(init_cmd[i]); } OLED_Clear(); }这里有几个命令值得重点解释。0x8D和0x14这一组合命令是最容易导致屏幕不亮的元凶之一0x8D是电荷泵设置命令0x14才是开启电荷泵。如果没有开启电荷泵OLED的驱动电压就建立不起来屏幕会始终处于全灭状态你发什么数据都没用。很多自制模块点不亮排查到最后就是少了这一句。0x20加0x02把寻址模式设置成页寻址模式这是很多显示驱动代码能正常工作的前提。所谓页寻址模式就是把64行分成8页每页8行一页对应128字节显存。在页寻址模式下写入数据的地址会自动在同一页内递增超过127个字节后会回到这一页的起始位置。这种模式不太适合快速全屏刷新但胜在实现简单学习阶段用起来最顺手。4.3 显存、坐标和页地址模式弄清这几个概念显示就通了一半SSD1306的分辨率是128x64所谓页Page是一个很容易搞混的概念。不要把它理解成书页它其实是8行像素的集合。整个屏幕从上到下被分成8个页第0页是第0行到第7行第1页是第8行到第15行以此类推直到第7页对应第56行到第63行。每一页里有128个字节一个字节的8个bit分别对应这一列上的8个像素。注意字节的最低位对应页内的第0行像素最高位对应第7行像素也就是bit0在上、bit7在下。这个位序关系在显示汉字和图片的时候尤其重要如果取模方向和写入方向不一致显示出来的图形就会上下颠倒或者左右镜像。在页地址模式下写数据前首先要设置当前页地址和列地址。页地址通过0xB0加上页号来设置比如第2页就是0xB2。列地址分成两次设置先发0x00到0x0F中的低四位再发0x10到0x1F中的高四位。比如列地址是100十进制的100转成十六进算是0x64拆分后低四位0x04通过0x04发送高四位0x06通过0x16发送。这一套逻辑我用下面的函数封装static void OLED_SetPos(uint8_t page, uint8_t col) { OLED_WriteCmd(0xB0 page); OLED_WriteCmd(0x00 (col 0x0F)); OLED_WriteCmd(0x10 ((col 4) 0x0F)); }理解了这套地址逻辑之后清屏就很好实现了。所谓清屏就是把这8页、每页128字节的显存全部写成0。最简单粗暴的办法是先设置起始坐标然后连续发送1024个0x00字节。因为页地址模式下列地址会自动递增所以不需要每写一个字节都重新设置坐标。4.4 底层发送函数为什么建议把数据先缓存再一次性写入很多初版驱动代码是这样写的每显示一个字符就调用一次I2C发送接口一个字符算8个字节一个字符串下来就是几十上百次I2C中断。虽然功能上没问题但速度非常感人。我实测过在400kbps的I2C速率下用单字节发送方式刷一帧128x64的图片肉眼能明显感觉到从上到下慢慢扫过去体验很差。更合理的做法是在STM32内部开一个1024字节的显存缓冲区所有显示操作都先修改这个缓冲区需要刷新的时候再一次性把数据写到SSD1306的RAM里。这个方案本质上就是双缓冲的思路。每次写屏的时候先设置页地址和列地址到0然后连续发送1024个字节传输效率高很多。代价是RAM占用1KB对STM32F103C8T6来说20KB的RAM完全够用。底层发送命令和数据的两个函数我这样写void OLED_WriteCmd(uint8_t cmd) { HAL_I2C_Mem_Write(hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, cmd, 1, 100); } void OLED_WriteData(uint8_t *data, uint16_t len) { HAL_I2C_Mem_Write(hi2c1, 0x78, 0x40, I2C_MEMADD_SIZE_8BIT, data, len, 100); }注意看写命令时MemAddress是0x00写数据时MemAddress是0x40这个控制字节的区别一定要搞明白。如果搞反了屏幕就会出现“有反应但显示完全乱掉”的怪现象。4.5 显示字符和数字从字模到函数的完整链路字模是OLED显示的基础概念。对于单色屏幕一个像素只有亮和灭两种状态所以可以用1个bit表示一个像素。以6x8的ASCII字符为例一个字符占6列8行也就是6个字节每个字节的高位到低位分别对应这列从上到下的8个像素。我用一个数组维护ASCII表const uint8_t Font6x8[][6] { {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, // 空格 {0x00, 0x00, 0x5F, 0x00, 0x00, 0x00}, // ! // 省略中间的字符 {0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, };显示字符串的时候标准流程是先通过OLED_SetPos设置页坐标和列坐标然后遍历字符串把每个字符对应的6个字节数据写入显存缓冲区。写入过程中列坐标会自动递增所以不需要在字符之间手动调整坐标。void OLED_ShowString(uint8_t page, uint8_t col, char *str) { OLED_SetPos(page, col); while (*str) { OLED_WriteData_To_Buffer((uint8_t *)Font6x8[*str - ], 6); str; } }显示数字本质上是把数字转换成字符串。你可以用C库的sprintf函数简单但占Flash比较严重。我更推荐自己写一个整数转字符串的小工具几行代码就能搞定还能避免在嵌入式设备里引入std格式化输出的开销。比如要显示一个ADC采样值直接把数值除10取商作为十位除10取余作为个位再和字符‘0’的ASCII码相加得到对应字符展示出来就是带固定小数位的文本效果。4.6 显示汉字和图片取模工具的正确打开方式汉字显示比ASCII字符复杂一点因为汉字是16x16点阵一个汉字占4个字节一行16行就是32个字节。手工编辑不现实必须要用取模软件。我用得最多的是PCtoLCD2002免费的Windows下打开就能用。用取模工具生成字模有几个选项直接影响显示效果。取模方式选“逐列式”还是“逐行式”要和你的写入函数对应取模方向选“顺向”还是“逆向”对应字模是从左上角开始还是从右下角开始颜色模式选“阴码”还是“阳码”阴码就是点位为1的地方亮阳码反之。针对刚才讲的页地址模式我的经验是选“逐行式、顺向、阴码”生成的数据就能直接按页写入。如果你发现汉字左右镜像、上下颠倒不要怀疑代码先检查取模设置。图片显示的原理和汉字完全一样只不过把整张128x64的图片看成一个大字模。先用PCtoLCD2002加载一张128x64的单色BMP图片生成1024个字节的数组然后一次性写入显存缓冲区并刷新图片就显示出来了。如果你显示完图片再想叠加文字就得用带透明色处理的函数把文字区域按像素做与或运算合并到缓冲区里而不是直接覆盖刷新否则会擦掉整个背景。4.7 动画刷屏的实现思路刷新率和流畅度的取舍OLED本身没有动画概念动画就是连续快速刷新不同帧画面。刚才说过全屏刷新一次要写1024字节在400kbps速率下光数据位传输理论上需要1024乘以8除以400000大概是20.5毫秒再加上起始条件、地址、应答这些协议开销实际刷新一帧的耗时妥妥超过30毫秒也就是人眼感觉到明显卡顿。想让动画流畅有两个方向。第一个方向是局部刷新只更新画面变化的那一小块区域比如一个移动的小球背景不变的话每帧只需要先擦除旧位置的小球再在新位置画一个小球数据量从1024字节直接降到几十字节流畅度立刻上来。第二个方向是换个思路把动画设计成适合OLED小屏的简约风格比如进度条、计数跳动、渐变亮度这类效果对刷新率要求不高实机效果却很好。对比度动画是个很有意思的技巧SSD1306支持通过0x81命令设置对比度从0x00到0xFF改变这个值就可以实现整体画面的呼吸亮暗效果。这种效果不需要重写显存只需要在刷新间隙发送一条命令对I2C带宽的占用几乎可以忽略不计。我做过一个用呼吸灯效果提示报警状态的OLED界面效果比单纯闪烁高亮好很多。5. 把传感器数据搬到OLED屏幕上OLED的真正用武之地5.1 用OLED当调试面板DHT11温湿度显示实战OLED屏幕在项目里的最大价值是作为传感器数据的可视化窗口。我最早做的一个实用项目就是用STM32 HAL库读取DHT11温湿度传感器把温湿度显示在OLED上。DHT11用的是单总线协议只用一根数据线和I2C完全是两种思路但不妨碍它们在同一块板子上共存。DHT11的读取要点是GPIO必须在输出模式和输入模式之间切换。HAL库的GPIO配置有两种方式第一种是先用HAL_GPIO_Init重新配置引脚模式这种方式逻辑清晰但效率低第二种是利用DHT11的单线特性把引脚初始化为开漏输出开漏模式下输出0相当于拉低总线配置成输入读取时把输出寄存器写1即可释放总线。后面这种方式在HAL库下更常用因为不需要反复调用GPIO初始化函数。读取到的温湿度数据是整数部分和小数部分分开的直接用OLED_ShowString把它打印到屏幕上实时更新就行。这里我踩过一个坑DHT11的采样间隔最少要1秒如果循环读取太频繁传感器会持续返回错误的数据。我当时在显示刷新的while循环里忘了加延时结果OLED屏幕上的温度值每隔一会儿就跳成0或者255排查了半天才发现是DHT11的时序要求没满足。5.2 测频法数据显示用OLED展示频率计的实际案例另一个典型的OLED应用场景是频率计。所谓测频法是在一个固定闸门时间内统计输入信号的脉冲个数频率就等于脉冲数除以闸门时间。用STM32实现非常简单定时器2产生1秒的定时中断定时器1工作在外部计数模式脉冲边沿到来时自动加1。1秒到了以后读取定时器1的计数值就是信号的频率。这个项目的显示部分很有意思因为测得的频率范围跨度很大可能是几十赫兹的低频信号也可能是几兆赫兹的高频信号数值位数不一样。我在OLED上把单位自动切换成Hz、kHz、MHz这样显示看起来就专业很多。事实上OLED在嵌入式项目里太百搭了倒车雷达测距、环境监测、电池电压采集、PID调参界面几乎所有需要和人交互的设备都离不开一块小屏幕。文章里讲的这套HAL库SSD1306驱动代码只要把显示内容替换掉就能复用到一大批项目上。6. 踩坑实录点不亮、花屏、上拉电阻和移植问题6.1 屏幕完全点不亮照着这个表格逐项查我帮人排查过很多次OLED不亮的问题90%的原因都能归到下面几个方面。先把现象确认清楚再确定排查方向现象可能原因排查方法完全无显示背光也没有供电问题或者模块损坏用万用表量VCC和GND之间是否有3.3V有微弱亮斑或屏幕边缘亮电荷泵未开启检查0x8D, 0x14这条初始化命令是否执行正常显示但全屏都是乱码I2C地址错误确认设备地址是0x3C还是0x3DHAL里要写左移后的0x78时好时坏偶尔不亮上拉电阻问题或接触不良检查SCL/SDA上拉电阻杜邦线换短的只亮一部分另一部分黑列地址或页地址设置越界检查OLED_SetPos函数的地址计算如果你用的是带转接板的0.96寸模块还有一个很隐蔽的问题模块板上的I2C地址选择电阻。有些模块背面留了SA0的焊接位如果出厂默认焊接到高电平地址就是0x3D而不是0x3C。代码里写死0x3C自然点不亮。我第一次遇到这种批量“点不亮”的情况就是从地址这排查出来的。6.2 花屏和乱码原因和对策花屏和乱码的排查路径和完全不亮不太一样。花屏说明屏幕已经在工作但数据写歪了或者写进去了无效内容。最常见的原因是坐标计算错误。比如在页地址模式下列地址写到了超过127的范围或者页地址写到了超过7的范围显存写入就越界了屏幕表现就是内容错位或者出现无规律的亮斑。这个问题的根源在于我把显存缓冲区和实际硬件显存混用了有时候往缓冲区里写入的坐标和最终刷屏的坐标不一致也会出问题。后来我做了严格的分层上层所有的坐标都是基于缓冲区坐标底层直接把整个缓冲区刷过去这类问题就再也没有了。另外一点初始化序列里0xA0/0xA1和0xC0/0xC8这两组命令会影响显示方向。如果屏幕内容出现左右镜像或者上下翻转就是这两组命令和硬件走线方向不匹配直接修改这两条命令为相反值即可。这不是故障只是驱动配置和模块实际生成的对应关系反了。6.3 上拉电阻太小导致的通信失败一次真实排查前面讲I2C原理的时候我提到过上拉电阻的价值。有一次我调试一块新的OLED模块屏幕总是每次上电后第一次通信失败但重试几次又能成功这个现象非常典型属于上升沿过慢导致的边际问题。我用示波器量了SDA的波形高电平上升沿明显拖了一个很长的尾巴用万用表一查原来模块板上出厂焊接的上拉电阻是10kΩ总线上又并了其他设备总线电容变大上升沿已经不满足400kbps快速模式的要求。解决方式很简单在模块的SCL和SDA之外再并两个2.2kΩ的上拉电阻到3.3V等效上拉电阻变小上升沿瞬间变陡问题就消失了。这件事给我的教训是I2C总线上的设备数量越多总线电容越大上拉电阻就要相应减小。反过来如果总线上只有一块OLED10kΩ上拉通常没问题但硬要把它跑到400kbps可能就会在临界状态出问题。6.4 HAL库驱动从STM32F103移植到APM32等其他芯片还有一个经常被问的问题就是HAL库写的OLED驱动能不能直接移植到别的国产MCU上比如APM32、GD32这类芯片。我的经验是应用层代码几乎不用改只要改了底层那几行HAL库调用就行。APM32虽然和STM32引脚兼容但它有自己的库和移植说明不能直接把STM32的HAL库文件塞进去编译。最稳妥的做法是在APM32的工程里新建一个相同接口的I2C发送函数函数名和参数保持一致底层实现换成APM32自己的库接口。这样一来oled.c里所有调用I2C发送的代码一行都不用动只是底层实现换掉。这个设计就是我在4.1节强调“驱动代码分层”的原因分层能省掉的移植工作量是肉眼可见的。换芯片还有一个需要注意的点不同型号的内存大小不一样。如果从F103C8T6换到F030系列RAM和Flash都小一轮显存缓冲区加上字库数组可能占用过大编译时要注意Flash容量是否够用。字库可以只保留必要的字符减少Flash占用显存缓冲区实在紧张的话也可以改成直接写屏的形式牺牲一点刷新速度换取内存空间。最后分享一点自己的体会OLED驱动这件事我一路上踩过不少坑但回头看每一步其实都是对I2C协议和SSD1306芯片特性的更深一层理解。屏幕点不亮的时候不要急着怀疑代码先量电压、查地址、看波形花屏乱码的时候不要只盯着显示函数多想想坐标、方向、寻址模式这些底层参数。驱动代码我习惯保留一个最精简的版本不带任何业务逻辑专门用来做硬件测试每次拿到新的OLED模块先跑一遍这个测试程序确认屏能亮、能刷、方向正常然后再开始写业务。这个习惯帮我省掉了大量联调时“不知道是硬件问题还是软件问题”的纠结时间。做这个项目最终收获的不仅是一块会亮的屏幕更是对嵌入式开发中引脚配置、通信协议、内存管理这些基础功的一次扎实训练。