APM32E103驱动OLED:从I2C时序到SPI+DMA的完整指南

📅 发布时间:2026/10/20 19:09:24
APM32E103驱动OLED:从I2C时序到SPI+DMA的完整指南
简介面向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、显存寻址、工程文件关系、移植踩坑和SPIDMA的进阶改法一次讲完适合正在做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开销完全可接受。真要做动画或高频刷新后面第六章有SPIDMA方案那是产品级做法。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); /* Co0, D/C0后续字节为命令 */ 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丝印不一致造成的建议始终按0xA10xC8配置。注意初始化结束前一定要加一个几十毫秒的延时再进入正常显示模式因为电荷泵启动需要建立时间。3. SSD1306的显存和寻址模式页、列与坐标换算3.1 1KB显存的结构从Bitmap到页地址OLED显示和MCU之间不是直接像素点对点的数据流SSD1306内部有一块1KB的GDDRAM128列×64行每个像素占1bit所以总共128×64/81024字节。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看到的是类似这样的数组/* A8x16像素列行式取模 */ 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页下半部分放page1页所以两次调用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外部晶振8MHzPLL倍频可以到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/8LOAD值要改成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到SPIDMA把OLED刷新带进产品级6.1 四线SPI接线和驱动改版软件模拟I2C适合调试和静态显示但刷一帧全屏内容时要写1024字节每页都要重新发起I2C起始、停止、地址帧效率很低。产品级的做法是切换到SPI模式SSD1306的SPI接口是四线制比I2C多了DC数据/命令选择和CS片选两根控制线典型接线如下OLED引脚APM32E103引脚说明CSPA4片选低有效DCPA1高电平写数据低电平写命令RESPA0复位低脉冲SCLPA5SPI时钟SDAPA7SPI主机输出驱动函数改为同步发送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); /* DC1写数据 */ } else { GPIO_ResetBits(GPIOA, GPIO_PIN_1);/* DC0写命令 */ } 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所有显示函数只往这块缓冲区写刷新时用SPIDMA把这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、延时、电源、引脚这五件事过一遍再打开示波器确认时序整个过程不会超过十分钟。希望帮到你。本文还有配套的精品资源点击获取