简介:面向APM32E103系列单片机的入门与进阶开发者,这份仿江科大工程的OLED显示源码包,专门解决OLED屏驱动移植与MDK工程搭建两大痛点,提供可直接编译运行的完整工程模板。整个工程共82个文件,以43个.h头文件与35个.c源文件为主,涵盖OLED驱动、按键、蜂鸣器等硬件外设模块,并包含APM32E10x标准外设库、启动文件、uvprojx工程配置及keilkill清理脚本,压缩包仅240KB,结构精简,硬件层、库函数层与用户层划分清晰,便于对照理解。已有145人学习下载,适合刚接触APM32平台或想从STM32迁移的开发者快速上手。通过该工程可掌握OLED初始化、显示函数封装与中文取模调用流程,同时理解外设库中断配置和GPIO复用方式,为后续扩展传感器与通信模块打下基础。
1. APM32E103驱动OLED:为什么“屏幕点不亮”是个多解方程
APM32E103作为国产Cortex-M3单片机,引脚和大部分外设寄存器都对着STM32F103来设计,很多人拿它做替代方案时第一个验证的外设就是OLED。但OLED显示看着是入门demo,移植那套网上流传很广的OLED驱动工程时,照样有人卡在“屏幕全黑”上——说玄学真不是玄学。这块0.96寸屏的驱动芯片是SSD1306,正常工作的前提是I2C地址对得上、RES时序到位、电荷泵正确打开,而且APM32E103主频能跑到96MHz,延时不按实际主频算,初始化命令就会在错误的时间点发出去。这篇文章就把软件模拟I2C、显存寻址、工程文件关系、移植踩坑和SPI+DMA的进阶改法一次讲完,适合正在做APM32E103选型评估、或者从STM32迁过来想快速点亮外设的人。
2. 从零搭软件模拟I2C:引脚、延时和SSD1306初始化时序
2.1 为什么不用硬件I2C:软件模拟在APM32上的三个现实理由
APM32E103硬件上是有I2C外设的,但说实话,硬件I2C在这类Cortex-M3芯片上的口碑一向不太好。中断标志位多、仲裁逻辑复杂,很多人用中断+DMA调了两三天最后还是切回模拟。这个不是APM32E103独有的问题,整个F103系生态都这样,所以网上那套OLED驱动工程直接用软件模拟I2C,是有道理的。
软件模拟I2C有三个现实优势。第一,引脚任意,想接PB8/PB9就接,想换到PA口也行,不涉及I2C外设的引脚映射;第二,时序完全可控,起始、停止、每个bit的电平翻转都在代码里显式执行,排查问题只需要看GPIO输出逻辑,不用去翻I2C状态寄存器;第三,OLED屏的I2C速率上限是400kHz,软件模拟到200~300kHz完全够用,不存在性能瓶颈。
有人担心软件模拟占用CPU。确实,全屏刷新时如果每送一个字节都要CPU翻转引脚,刷新一帧画面会比较慢。但初始化阶段、显示静态文本阶段,软件模拟的CPU开销完全可接受。真要做动画或高频刷新,后面第六章有SPI+DMA方案,那是产品级做法。
2.2 引脚与GPIO配置:PB8/PB9推挽输出就够
网上那套工程默认SCL接PB8、SDA接PB9,这个接法沿用STM32F103的经典配置,APM32E103的PB8/PB9同样没有特殊复用冲突,可以直接用。OLED模块的I2C接口通常自带上拉电阻,所以MCU这边配置成推挽输出就行,不需要开漏。
/* APM32E103 GPIO初始化,标准外设库风格 */ void OLED_GPIO_Init(void) { GPIO_Config_T gpioConfig; /* 使能GPIOB时钟,AFIO时钟也打开备用 */ RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_GPIOB); RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_AFIO); /* 先按默认值填充结构体,避免遗漏字段 */ GPIO_ConfigStructInit(&gpioConfig); gpioConfig.pin = GPIO_PIN_8 | GPIO_PIN_9; gpioConfig.mode = GPIO_MODE_OUT_PP; /* 推挽输出 */ gpioConfig.speed = GPIO_SPEED_50MHz; /* 50MHz翻转速率足够 */ GPIO_Config(GPIOB, &gpioConfig); }这段代码逻辑很直接:先开GPIOB的时钟,再配置PB8和PB9为推挽输出。重点说一下GPIO_SPEED_50MHz这个参数,软件模拟I2C的翻转频率撑死几百kHz,其实用2MHz档都够,但50MHz档在APM32E103上的输出驱动能力更强,能保证SDA在长导线场景下还有足够的边沿速率,所以一般沿用它。RCM_EnableAPB2PeriphClock这个函数在APM32固件库里对应STM32的RCC_APB2PeriphClockCmd,作用完全一样,只是命名前缀不同。
还有一点值得注意:软件模拟I2C的GPIO不需要开复用功能,因为根本没有把引脚交给任何外设,纯粹是CPU写寄存器翻转电平。这一点和硬件I2C的配置有本质区别,别在初始化里画蛇添足接上GPIO_PIN_8的复用映射。
2.3 延时与I2C时序:按96MHz主频校准SysTick
软件模拟I2C最核心的东西其实是延时。起始信号要保证至少4.7us的建立时间,每个bit的SCL高/低电平要保持至少2.5us。如果主频是72MHz,延时一个循环就能算准;但APM32E103能跑到96MHz,直接沿用STM32F103工程里的延时函数,时序会被放大1.33倍,OLED虽然一般也能响应,但整个初始化序列的时间就不对了,后续容易出白屏、花屏这种难查的问题。
我一般用SysTick做微秒延时,简单可靠:
static void OLED_Delay_Init(void) { /* 关闭SysTick,配置计数器为24位自由运行模式 */ SysTick->CTRL = 0; SysTick->LOAD = 0xFFFFFF; SysTick->VAL = 0; /* 使用HCLK作为时钟源,使能计数器 */ SysTick->CTRL = 0x5; } void OLED_Delay_us(uint32_t us) { /* LOAD = 主频(MHz) * 微秒数 */ SysTick->LOAD = 96 * us; SysTick->VAL = 0; /* 等待COUNTFLAG置位,即计数到0 */ while ((SysTick->CTRL & (1 << 16)) == 0) { } }这里用SysTick的24位计数器手动做延时,不依赖任何库函数,也不开中断,逻辑就是“加载一个计数值,等它减到0”。96这个数字是主频96MHz换算来的,如果APM32E103跑在72MHz,这里要改成72。0xFFFFFF装载值是让计数器之前处于自由运行状态,实际延时精度靠LOAD寄存器重新装载保证。
有了可靠的延时,I2C时序就好写了。起始、停止、数据传输必须严格按照协议来:
/* 用宏定义引脚操作,后续换引脚只改这里 */ #define OLED_SCL_Clr() GPIO_ResetBits(GPIOB, GPIO_PIN_8) #define OLED_SCL_Set() GPIO_SetBits(GPIOB, GPIO_PIN_8) #define OLED_SDA_Clr() GPIO_ResetBits(GPIOB, GPIO_PIN_9) #define OLED_SDA_Set() GPIO_SetBits(GPIOB, GPIO_PIN_9) void OLED_I2C_Start(void) { OLED_SDA_Set(); OLED_SCL_Set(); OLED_Delay_us(4); OLED_SDA_Clr(); /* SCL高电平期间SDA拉低 = 起始信号 */ OLED_Delay_us(4); OLED_SCL_Clr(); } void OLED_I2C_Stop(void) { OLED_SDA_Clr(); OLED_SCL_Set(); OLED_Delay_us(4); OLED_SDA_Set(); /* SCL高电平期间SDA拉高 = 停止信号 */ OLED_Delay_us(4); } void OLED_I2C_WriteByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) { OLED_SDA_Set(); } else { OLED_SDA_Clr(); } dat <<= 1; OLED_SCL_Set(); /* 上升沿之后,从机读走SDA */ OLED_Delay_us(2); OLED_SCL_Clr(); OLED_Delay_us(2); } /* 第9个时钟:释放SDA,检测从机ACK(不做判断只释放) */ OLED_SDA_Set(); OLED_SCL_Set(); OLED_Delay_us(2); OLED_SCL_Clr(); OLED_Delay_us(2); }起始信号的精髓是“SCL为高电平时SDA发生下降沿”,停止信号正好相反。写字节时数据在SCL高电平期间必须保持稳定,所以先置SDA,再拉高SCL。每个bit的延时2us对应250kHz的I2C速率,在SSD1306的400kHz上限之内。ACK那一个周期我刻意没有读回电平,因为SSD1306对命令字节的ACK响应比较“随性”,不读反而能避免误判。
2.4 SSD1306初始化序列:关键命令逐条拆解
当I2C时序就绪后,SSD1306的初始化就一个模式:连续发送一串配置命令。网上那套物联网常见的初始化序列如下,命令前面加0x00表示后续字节是命令,加0x40才是写显存数据。
void OLED_WriteCmd(uint8_t cmd) { OLED_I2C_Start(); OLED_I2C_WriteByte(0x78); /* 0x78 = SSD1306写地址 */ OLED_I2C_WriteByte(0x00); /* Co=0, D/C=0,后续字节为命令 */ OLED_I2C_WriteByte(cmd); OLED_I2C_Stop(); } void OLED_Init(void) { /* 复位引脚时序,确保内部状态机复位 */ GPIO_ResetBits(GPIOB, GPIO_PIN_12); /* RES拉低 */ OLED_Delay_us(100); GPIO_SetBits(GPIOB, GPIO_PIN_12); /* RES拉高 */ OLED_Delay_us(100); OLED_WriteCmd(0xAE); /* 关闭显示 */ OLED_WriteCmd(0x20); /* 内存寻址模式 */ OLED_WriteCmd(0x02); /* 页寻址模式 */ OLED_WriteCmd(0xB0); /* 页起始地址0 */ OLED_WriteCmd(0x00); /* 列地址低4位 */ OLED_WriteCmd(0x10); /* 列地址高4位 */ OLED_WriteCmd(0x40); /* 显示起始行0 */ OLED_WriteCmd(0x81); /* 对比度设置 */ OLED_WriteCmd(0x7F); /* 对比度值 */ OLED_WriteCmd(0xA1); /* 段重映射,左右镜像修正 */ OLED_WriteCmd(0xA6); /* 正常显示 */ OLED_WriteCmd(0xA8); /* 多路复用比 */ OLED_WriteCmd(0x3F); /* 64行 */ OLED_WriteCmd(0xC8); /* COM方向,上下镜像修正 */ OLED_WriteCmd(0xD3); /* 显示偏移 */ OLED_WriteCmd(0x00); /* 偏移0 */ OLED_WriteCmd(0xD5); /* 时钟分频 */ OLED_WriteCmd(0x80); /* 分频因子 */ OLED_WriteCmd(0xD9); /* 预充电周期 */ OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDA); /* COM引脚硬件配置 */ OLED_WriteCmd(0x12); OLED_WriteCmd(0xDB); /* VCOMH倍率 */ OLED_WriteCmd(0x40); OLED_WriteCmd(0x8D); /* 电荷泵设置 */ OLED_WriteCmd(0x14); /* 使能电荷泵 */ OLED_WriteCmd(0xAF); /* 开启显示 */ }这里命令数组里0x78是7位地址0x3C左移一位的结果,OLED模块上SA0焊盘默认接低,所以写地址固定是0x78。如果哪块屏点不亮,首先要查的就是这个字节。0x8D/0x14这组命令我很强调一下:SSD1306内部电荷泵不打开,整个OLED面板没有驱动电压,屏幕必然全黑,而很多初始化序列抄漏了这行。0xA1和0xC8是镜像修正,不设置也能显示,但字符方向会反过来,这是芯片默认扫描方向和常见PCB丝印不一致造成的,建议始终按0xA1+0xC8配置。
注意:初始化结束前一定要加一个几十毫秒的延时再进入正常显示模式,因为电荷泵启动需要建立时间。
3. SSD1306的显存和寻址模式:页、列与坐标换算
3.1 1KB显存的结构:从Bitmap到页地址
OLED显示和MCU之间不是直接像素点对点的数据流,SSD1306内部有一块1KB的GDDRAM,128列×64行,每个像素占1bit,所以总共128×64/8=1024字节。MCU要做的事情很简单:先把要显示的内容写进这块显存,SSD1306自己不停地把显存内容刷到OLED面板上。
这块显存的排列方式是“页”结构:8个行像素称为一页,64行一共8页,编号0~7;每一页里有128个字节,对应128列。第0页的Byte0对应第0行的第0列~第7列。也就是说,一个字节在水平方向代表了8个连续的像素点,bit7在左,bit0在右。
这个结构决定了后面所有坐标换算的底层逻辑:要写屏幕第x列、第y行的一个点,先算它属于哪一页:page = y / 8;再算它在页内的行偏移:bit = y % 8。写字节的时候,这个点对应的是字节中的(1 << bit)。理解了这一点,读字模数组时就自然知道为什么取模软件生成的数据是那个顺序。
3.2 三种寻址模式对比:I2C下为什么页寻址最省心
SSD1306支持三种显存寻址模式,通过命令0x20后面的参数切换:
| 模式代码 | 名称 | 写入顺序 | 适用场景 |
|---|---|---|---|
| 0x00 | 水平寻址 | 同一行从左到右,到底后自动跳下一行 | 全屏图片刷新,配合DMA效率高 |
| 0x01 | 垂直寻址 | 同一列从上到下,到底后自动跳下一列 | 竖屏滚动、柱状图更新 |
| 0x02 | 页寻址 | 固定页内从左到右,写满128字节后光标回到页首 | I2C单字节写入、字符显示 |
网上那套OLED驱动工程用的是页寻址模式,原因是:字符显示是跳跃式的——先在第0页写一个字符,可能又跳到第5页写第二行。如果用了水平或垂直寻址,每写一段不连续区域都要重新设置列地址、页地址、甚至要构造一个能准确定位的数据帧。页寻址模式下,只要每次写之前把页地址和列地址都设置好,想写哪就写哪,逻辑非常直白。
3.3 坐标封装:把“行/列”翻译成“页/列”
弄清寻址模式后,下一步就是把坐标设置封装成函数。最常见的封装是输入页号(0~7)和列号(0~127),内部拆成两条命令:
void OLED_SetPos(uint8_t page, uint8_t column) { /* 设置页地址:命令0xB0 + 页号 */ OLED_WriteCmd(0xB0 + page); /* 设置列地址低4位 */ OLED_WriteCmd(column & 0x0F); /* 设置列地址高4位:0x10 | 高位 */ OLED_WriteCmd(0x10 | ((column >> 4) & 0x0F)); }页地址命令范围是0xB0~0xB7,对应第0~7页,所以0xB0 + page直接定位到目标页。列地址是8位数值,SSD1306要求分两次设置:先送低4位(命令0x00~0x0F),再送高4位(命令0x10~0x1F),这就是代码里两次OLED_WriteCmd的作用。这里有一个容易踩的坑:如果只设置页地址不设置列地址,上电后列地址默认为0,第二次写数据会从上次的列位置继续,肉眼看到的就是字符错位。
更友好的封装是直接传入像素坐标,比如x范围0~127、y范围0~63,内部自动算出页号:
void OLED_SetPixelPos(uint8_t x, uint8_t y) { uint8_t page = y / 8; /* 8行为一页 */ OLED_SetPos(page, x); }这样上层应用调用时就非常自然了,比如在屏幕左上角显示一个点,直接写OLED_SetPixelPos(0, 0),不用关心页概念。但从效率角度,频繁显示字符时建议直接用页版坐标,省掉一次除法。
3.4 对比度、反色、滚动:几个建议慎用的命令
SSD1306还支持对比度调节、反色显示、水平滚动这些能力,在菜单场景中偶尔会用到。
对比度设置是命令0x81加一个0x00~0xFF的数值,数值越大越亮。不过OLED屏的亮度本质由面板驱动电流决定,对比度寄存器只是数字调节,最高值也不一定就是最合适的,一般0x7F左右比较均衡,调太高长时间显示固定内容容易加速烧屏。
反色显示是0xA6(正常)和0xA7(反相),切换的瞬间整屏内容会翻转。这个命令在页寻址模式下对已显示内容即时生效,适合做“选中态”高亮。但要注意:反色是全局的,不能只反某一个矩形区域。要局部反色,只能在字模数据上做反转:数据取反后再送显存。
滚动命令我个人建议少用。SSD1306的滚动是一套连续命令:先设置滚动区域(0x26/0x27)、起始页、结束页、帧间隔,再发0x2F启动。看起来做广告条很合适,但实际效果是整屏横向移动,不是内容滚动,和很多产品想要的列表滚动完全不搭。如果真要做菜单滚动,还是在MCU端改显存数据更实在。
4. 把那套流传很广的OLED驱动工程拆开看:文件结构、字模和调用链
4.1 三个源文件的职责划分
网上那套在STM32圈子里流传很广的OLED驱动工程,文件结构非常干净,通常只有三个源文件加一个主程序:
| 文件 | 职责 | 关键内容 |
|---|---|---|
| OLED.h | 对外接口声明、引脚宏定义 | 引脚操作宏、函数声明 |
| OLED.c | 驱动实现 | I2C时序、初始化、字符/字符串/图片显示函数 |
| OLED_Font.h | 字模数据 | ASCII字符数组、汉字数组 |
这套设计把“硬件相关”和“数据相关”拆开了。OLED.c里只有驱动逻辑,不包含任何中文字模,所以文件体积很小。OLED_Font.h动辄几百上千个字节的数组数据,单独放一个头文件里方便替换和增删汉字。我后来自己在项目里也保持这个习惯,换字体、加字模,基本只改OLED_Font.h和调用处的坐标逻辑。
4.2 字模数据的字节序:列行式取模的读法
字模数据的理解是这套工程里最容易懵的地方。以8×16的ASCII字符为例,每个字符占16个字节;以16×16的汉字为例,每个汉字占32个字节。打开OLED_Font.h看到的是类似这样的数组:
/* 'A',8x16像素,列行式取模 */ const uint8_t OLED_Font_A[] = { 0x00, 0x00, 0x10, 0x18, 0x2C, 0x46, 0x82, 0x00, 0x00, 0xFE, 0x02, 0x02, 0x02, 0xFC, 0x00, 0x00 };这是列行式取模的结果:每个字节代表一列8个像素,高bit在上。第一个字节0x10二进制是00010000,表示第0列从上往下第4个像素点亮;第二个字节0x18是00011000,表示第1列第4、5个像素点亮,如此拼出一个“A”的轮廓。
这个数组直接贴到显存页里就能显示,因为SSD1306的显存结构恰好也是“字节的bit7在上、bit0在下”这个顺序。如果取模时选了“行式取模”或者“低位在前”,显示出来就是花的或者镜像的。取模软件里设置成“列行式、横向取模、C51格式”,生成结果才可以直接放进OLED_Font.h。
4.3 OLED_ShowString的调用链和坐标步进
字符显示函数的调用链很重要,搞清它才能在移植时准确定位问题。以字符串显示为例:
void OLED_ShowChar(uint8_t page, uint8_t column, char ch) { uint8_t i; /* 光标移动到目标位置 */ OLED_SetPos(page, column); /* 上半部分8列:8个数锯写在页 */ for (i = 0; i < 8; i++) { OLED_WriteData(OLED_Font_ASCII[ch - ' '][i]); } /* 下半部分8列:写到下一页 */ OLED_SetPos(page + 1, column); for (i = 8; i < 16; i++) { OLED_WriteData(OLED_Font_ASCII[ch - ' '][i]); } } void OLED_ShowString(uint8_t page, uint8_t column, const char *str) { while (*str != '\0') { OLED_ShowChar(page, column, *str); column += 8; /* 字符宽度8像素 */ if (column > 120) /* 超过右边界则换行 */ { column = 0; page += 2; /* 8x16字符占2页高度 */ } str++; } }这个实现有两个细节值得反复咀嚼。第一,一个8×16字符跨两个页显示,上半部分放page页,下半部分放page+1页,所以两次调用OLED_SetPos。第二,字符串显示完一个字符后column加8,因为8×16字符的宽度就是8个像素,到第120列时必须换行,否则第121~127列写不下下一个字符;换行时page加2,因为跳过一个字符占用的两页高度。
OLED_WriteData内部就是OLED_I2C_Start、WriteByte(0x78)、WriteByte(0x40)、WriteByte(data)、Stop这套。0x40表示后续数据传输的是显存数据,不是命令,这个字节漏了或写错,屏幕就会显示乱码。
4.4 移植到APM32E103:需要改动的最小集合
把这套工程移植到APM32E103上,需要改的地方其实很少,总共就三个点。
第一是引脚宏定义,OLED.h里的OLED_SCL_Set、OLED_SDA_Set这些宏,原来写的是GPIOB->BSRR或GPIO_SetBits(GPIOB, GPIO_PIN_8),在APM32E103上用同名库函数就能直接通过编译,因为APM32的GPIO API兼容STM32标准库。如果用的是HAL风格的APM32库,对应改成GPIO_WriteBit相关的调用。
第二是延时函数,前面2.3节已经说过,把SysTick的LOAD值改成按96MHz计算,或者直接用OLED_Delay_us里的系数96。这一项是静态库函数里几乎必改的,因为网上那套工程默认按72MHz跑。
第三是OLED_Init开头加入对RES引脚的控制,因为部分版本的OLED模块没有把RES和MCU引脚连起来,板子上电时RES已经由RC电路自动复位过一次,但有些模块把RES引脚引出来了,不控制就会出现“初始化命令全发出去了,屏幕就是没反应”的现象。建议始终在APM32E103上找一个GPIO接RES,并在初始化函数里做一次低脉冲。
5. APM32E103移植避坑:五个现实中的翻车现场
5.1 屏幕全灭:I2C地址是0x78还是0x7A
现象:初始化代码跑完,OLED完全黑屏,用示波器或逻辑分析仪抓SCL和SDA,能看到波形在动,但屏幕没有响应。
原因:SSD1306的7位I2C地址由SA0引脚的电平决定,SA0接地时地址是0x3C(写地址0x78),接VCC时是0x3D(写地址0x7A)。网上很多模块默认SA0接GND,写0x78没问题,但有些模块设计时SA0悬空或者接了高电平,0x78就找不到设备了。
解决:把OLED_WriteCmd里的地址字节改成0x7A试一次,如果屏幕亮了就说明是地址问题。规范做法是把这个地址定义成宏,放在OLED.h顶部,遇到不通的模块改一处即可。
#define OLED_I2C_ADDR_W 0x78 /* 也可以改成0x7A */这种问题最坑的点在于I2C总线上没有设备时,软件模拟是检测不到NACK的(代码里做了释放SDA但不读取),所以MCU这边完全感知不到“写失败”,只能靠屏幕反应来反推。从那以后我做OLED调试,第一件事永远是确认地址。
5.2 命令全对却白屏:RES引脚没有参与控制
现象:I2C地址换过了,初始化序列也对照手册检查过,电荷泵命令没漏,但屏幕依旧白屏,而且背光亮。
原因:SSD1306内部有一个上电复位电路,但只靠RC复位在快速上电场景下不可靠。如果模块引出了RES引脚且没有连接MCU,上电瞬间内部状态机可能没有正确复位,后续命令就被忽略了。背光亮不代表驱动芯片工作正常,我见过很多案例卡在这一步。
解决:把RES接到APM32E103的一个GPIO上,初始化I2C之前先执行复位时序。代码在2.4节已经出现过,拉低100us再拉高,之后再延时100us然后送初始化命令。这一条花不了5分钟,但能过滤掉70%的“白屏类问题”。
5.3 显示花屏乱码:主频96MHz把延时函数“加速”了
现象:屏幕能亮,也能显示内容,但字符明显错乱,或者刷新时有拖影,偶尔白屏,反复上电现象不一致。
原因:APM32E103外部晶振8MHz,PLL倍频可以到96MHz,而网上那套OLED工程默认按STM32F103的72MHz主频写延时。把96MHz当成72MHz,等于所有延时都缩短了约1/4。起始信号、字节时序的建立时间不够,SSD1306采样数据时就有可能采到错误电平。
解决:把OLED_Delay_us改成2.3节那种基于SysTick的实现,并且把96这个系数写清楚。更稳妥的做法是把主频降到72MHz保持完全兼容,如果项目其他部分依赖96MHz带来的性能,就必须重算延时系数。
注意:SysTick的时钟源可以选择HCLK或HCLK/8,上述代码用的是HCLK直连。如果库初始化时把SysTick配成了HCLK/8,LOAD值要改成12×us,而不是96×us,这个坑在调试时容易误导。
5.4 有横纹闪烁:电荷泵把电源拉出纹波
现象:显示静态内容时能明显看到横纹或水波纹,用手摸模块背面电容时纹路变动,把对比度调低一点会好转。
原因:SSD1306内部电荷泵工作时会产生周期性电流脉冲,如果OLED模块的VCC引脚和APM32E103的电源走线共用同一条细线,电荷泵启动瞬间会把VCC瞬时拉低几十到一百多毫伏,显示亮度就会跟着波动。
解决:在OLED模块的电源引脚处就近加一个10uF钽电容或电解电容,再并联一个0.1uF陶瓷电容,分别吸收低频纹波和高频噪声。如果板子空间紧张,至少也要在模块VCC和GND之间加个10uF电容,这是性价比最高的稳定手段。
5.5 引脚电平正常却不工作:撞上JTAG调试口
现象:把OLED的SCL/SDA从PB8/PB9改到其他引脚,例如PA15、PB3、PB4后,GPIO代码明明执行了,用万用表也能量到电平翻转,但屏幕就是没反应。
原因:PA15、PB3、PB4在F103系芯片上默认复用为JTAG调试功能,不关闭JTAG的话,这几根引脚由调试逻辑控制,普通GPIO配置不生效。APM32E103同样继承了这种引脚布局。
解决:换到非JTAG引脚,或者显式关闭JTAG复用:
/* 关闭JTAG,释放PA15/PB3/PB4给普通GPIO */ GPIO_ConfigPinRemux(PIN_REMU_PS2_JTAGDISABLE, ENABLE);这个函数在不同的APM32固件库里名字可能略有差异,但原理都是配置AFIO的映射寄存器。除非极缺引脚,否则我建议直接换引脚,因为调试器还要用SWD,关闭JTAG后虽然SWD不受影响,但每次备份工程或换调试器时多一个变量不划算。
6. 从I2C到SPI+DMA:把OLED刷新带进产品级
6.1 四线SPI接线和驱动改版
软件模拟I2C适合调试和静态显示,但刷一帧全屏内容时要写1024字节,每页都要重新发起I2C起始、停止、地址帧,效率很低。产品级的做法是切换到SPI模式:SSD1306的SPI接口是四线制,比I2C多了DC(数据/命令选择)和CS(片选)两根控制线,典型接线如下:
| OLED引脚 | APM32E103引脚 | 说明 |
|---|---|---|
| CS | PA4 | 片选,低有效 |
| DC | PA1 | 高电平写数据,低电平写命令 |
| RES | PA0 | 复位,低脉冲 |
| SCL | PA5 | SPI时钟 |
| SDA | PA7 | SPI主机输出 |
驱动函数改为同步发送:
void OLED_WR_Byte(uint8_t dat, uint8_t mode) { GPIO_ResetBits(GPIOA, GPIO_PIN_4); /* CS拉低 */ if (mode) { GPIO_SetBits(GPIOA, GPIO_PIN_1); /* DC=1,写数据 */ } else { GPIO_ResetBits(GPIOA, GPIO_PIN_1);/* DC=0,写命令 */ } SPI_SendData(SPI1, dat); /* 写入SPI数据寄存器 */ while (SPI_GetFlagStatus(SPI1, SPI_FLAG_TXE) == RESET) { } GPIO_SetBits(GPIOA, GPIO_PIN_4); /* CS拉高 */ }这里用的是APM32E103的SPI1主机模式,通信速率可以配到十几个MHz,全屏刷1024字节需要的时间不到1ms级。DC引脚的作用是告诉SSD1306当前字节是命令还是数据,这是SPI模式和I2C模式最大的区别,写错了屏幕就显示乱数据。
6.2 Framebuffer与DMA:一次全屏刷新只发1KB
解决SPI速率问题之后,进一步优化是让SPI传输不占用CPU。做法很简单:MCU内存中维护一块1024字节的framebuffer,所有显示函数只往这块缓冲区写;刷新时用SPI+DMA把这1024字节一次性送给SSD1306。
uint8_t OLED_Framebuffer[1024]; void OLED_Refresh_Framebuffer(void) { /* 全屏刷新前把SSD1306设置为水平寻址模式,一次写满1KB */ OLED_WR_Byte(0x20, 0); OLED_WR_Byte(0x00, 0); OLED_WR_Byte(0x00, 0); /* 列起始低4位 */ OLED_WR_Byte(0x10, 0); /* 列起始高4位 */ OLED_WR_Byte(0xB0, 0); /* 页起始0 */ OLED_WR_Byte(0x40, 1); /* 连续数据流 */ /* 启动SPI DMA传输 */ DMA_Config_T dmaConfig; dmaConfig.peripheralAddr = (uint32_t)&SPI1->DATA; dmaConfig.memoryAddr = (uint32_t)OLED_Framebuffer; dmaConfig.bufferSize = 1024; dmaConfig.direction = DMA_DIR_PERIPHERAL_DST; DMA_Config(DMA1_Channel3, &dmaConfig); DMA_Enable(DMA1_Channel3, ENABLE); }水平寻址模式配合DMA,一帧数据只发一次起始地址,然后连续输出1024字节,SSD1306内部显存写完自动停止。CPU在DMA搬运期间可以继续做别的事,刷新率从软件模拟I2C的每秒几帧提升到几十帧,动画显示才真正可用。
6.3 验证手法:用示波器抓CLK和CS,不要靠猜
最后建议每次改完驱动,用示波器看两个信号:一是CLK是否连续稳定地翻转,二是CS低电平的持续时间是否符合预期。如果CLK波形中间有大段空白,说明SPI在等数据,优先查DMA配置;如果CS一直低,说明DC/CS逻辑反了。这套方法看起来基础,但能把前面四章所有“玄学”问题收敛成可观测的信号问题。从那以后我每次移植OLED工程,都强制把地址、RES、延时、电源、引脚这五件事过一遍,再打开示波器确认时序,整个过程不会超过十分钟。希望帮到你。
本文还有配套的精品资源,点击获取