STM32实战:U8g2库硬件SPI驱动SH1106 OLED完整移植指南 简介这是一份面向STM32开发者的u8g2移植资源。主控为F103RCT6基于HAL库与硬件SPI驱动中景园1.3寸SH1106屏分辨率128×64开发环境为MDK。工程内含主程序、u8g2字体源文件、启动文件、链接脚本、编译中间文件及烧录文件能从底层启动到上层绘图完整对照学习降低自行移植时在时序和字体裁剪上的踩坑成本。整包共1381个文件以源码和头文件为主另有配置文件、目标文件、链接脚本、日志等压缩包约25MB便于按模块查阅读者可直接参考工程内的编译配置与源码结构。已有2843人学习浏览适合初次接触u8g2移植或准备在同系列上使用硬件SPI的开发者提供从驱动初始化到图形绘制的完整参考适合二次开发既能快速验证显示效果也能借助完整工程理解各文件在移植中的分工。1. 把U8g2库跑在STM32上HAL库硬件SPI驱动SH1106的实战拆解用STM32驱动一块128x64的OLED屏最容易被卡住的不是接线而是图形库的移植。U8g2库在Arduino生态里几乎开箱即用一旦换到STM32 HAL库 硬件SPI很多人就栽在初始化顺序、SPI时钟极性和底层画点函数上眼看着屏幕亮起来却全是雪花点。这篇文章要拆的就是这条完整的移植链路从CubeMX配置到U8g2库的裁剪接入再到SH1106驱动芯片的底层适配。整个方案经过实际验证跑通之后画面稳定、刷新流畅适合正在做仪表盘、菜单界面或小型交互设备的人参考。你能从这里带走一套可以直接抄的移植步骤以及几个花了一整天才排查出来的坑。2. 移植前的硬件选型与协议分析为什么是SH1106、硬件SPI和HAL库2.1 SH1106和SSD1306的区别别拿SSD1306的驱动硬套很多教程默认OLED都是SSD1306但市面上大量128x64的1.3寸屏用的是SH1106芯片。两者指令集大部分兼容页地址模式、列地址偏移却有明显差异SSD1306的列地址从0开始而SH1106的显存列地址内部从0x02开始对应的列地址偏移量需要单独处理。这就导致直接用SSD1306驱动代码去刷SH1106屏幕显示内容会整体偏移两个像素而且第一列会有异常残影。U8g2库本身同时支持这两种控制器但需要明确告诉库底层用的是哪个芯片。在本项目选用的stm32f103c8t6平台上通过HAL库的硬件SPI接口与SH1106通信帧率比软件模拟SPI高出一截CPU占用也更低。U8g2库在这块屏上使用的是页写模式每页128字节共8页恰好对应64行像素。2.2 硬件SPI接线与CubeMX配置四个引脚一个都不能错我用的核心板是STM32F103C8T6SPI1的硬件接口映射到PA5SCK、PA6MISO、PA7MOSI。OLED这边只用到SCK、MOSI、DC、CS、RESMISO悬空不管。接线如下PA5接SCKPA7接MOSIPA4接CSPA3接DCPA2接RES。供电用3.3V共地是必须的。CubeMX里将SPI1设为全双工主机波特率预分频设为16此时SPI时钟为4.5MHz左右对SH1106来说绰绰有余实际测试4.5MHz到9MHz都能稳定工作。SPI时钟极性CPOL设为Low相位CPHA设为1Edge——这是U8g2库的软件配置里默认使用的方式必须和硬件配置保持一致否则通信数据会错位屏幕上出现乱码。GPIO部分DC、CS、RES全部设为推挽输出初始电平分别拉高、拉高、拉高。初始化顺序有讲究先配置GPIO和SPI再对RES引脚做一个低脉冲复位之后才允许向屏幕发送初始化命令。2.3 U8g2库的裁剪思路STM32资源有限不是所有文件都要编译U8g2库是一个庞大的图形库完整源码包含几十种控制器的驱动文件。如果直接全部加入工程编译时间会很长Flash占用也会增加。我实际操作时只保留了u8g2.h、u8g2_d_setup.c、u8g2_d_memory.c以及控制器相关的u8x8_d_sh1106_128x64_noname.c和底层的u8x8_byte.c、u8x8_gpio.c。这样裁剪之后整个图形库只占用约30KB Flash加上HAL库本身的开销在F103C8T6的64KB Flash里完全放得下。有人的做法是全部文件塞进去再通过宏裁剪那样会在编译时产生大量重复符号不如直接在工程层面筛掉不适用的源文件。3. U8g2库文件接入工程从下载到编译通过的完整步骤3.1 下载和目录规划把这几个文件放进你自己建的u8g2文件夹先准备好U8g2库源码从官方仓库拉取或从任何可信渠道获取完整包。在工程目录下新建一个名为u8g2的文件夹内部再分src和port两个子目录。src存放库的核心源码port存放自己写的移植层文件。这样做的好处是当库升级时只需要更新src不会动自己的适配代码。后续在Keil工程的Manage Project Items里新建一个Group命名为u8g2然后把文件添加进去。我添加的文件列表如下// Keil工程需要添加的U8g2源文件清单 // 路径: /u8g2/src/ u8g2_d_setup.c // 初始化参数配置按控制器选型设置 u8g2_d_memory.c // 显存缓冲区分配内部使用静态数组 u8x8_d_sh1106_128x64_noname.c // SH1106 128x64驱动 u8x8_byte.c // 字节发送层对接底层SPI u8x8_gpio.c // GPIO控制层包括DC/CS/RES引脚操作 // 路径: /u8g2/port/ u8g2_hal_stm32.c // 自己写的HAL适配层后面会详细拆解 u8g2_hal_stm32.h // 适配层头文件这里需要说明的是这段清单展示的只是源文件列表实际文件内容需要从U8g2库源码包中复制。u8g2_d_setup.c包含所有控制器的初始化参数表编译时通过宏U8G2_DRIVER_HEARTBEAT等条件编译项选择当前使用的驱动而不是把全部驱动都编进去。3.2 适配层核心代码实现SPI发送与GPIO控制的HAL写法U8g2库对底层的要求只有两个函数一个是字节发送函数负责把数据通过SPI发出去一个是GPIO控制函数负责拉高拉低DC、CS、RES。在HAL库环境下字节发送可以直接调用HAL_SPI_TransmitGPIO控制则使用HAL_GPIO_WritePin。下面的代码是经过验证的适配层写法// u8g2_hal_stm32.c #include u8g2.h #include spi.h #include gpio.h // 字节发送函数u8g2库通过此函数输出数据和命令 uint8_t u8x8_byte_hw_spi(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch(msg) { case U8X8_MSG_BYTE_SEND: // 发送数据arg_ptr指向数据缓冲区arg_int为长度 HAL_SPI_Transmit(hspi1, (uint8_t *)arg_ptr, arg_int, 100); break; case U8X8_MSG_BYTE_SET_DC: // 设置DC引脚电平区分命令和数据 HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_BYTE_INIT: // SPI初始化此处无需额外操作 break; default: return 0; // 未处理的返回0U8g2库允许忽略未知消息 } return 1; }这段代码中U8X8_MSG_BYTE_SEND是发送数据的主入口欠妥的是直接调用HAL_SPI_Transmit如果SPI时钟配置过高或DMA未开启会阻塞CPU直到发送完成但127字节一帧的显存刷新在这个速率下完全可接受。U8X8_MSG_BYTE_SET_DC是关键它决定当前发送的是命令还是像素数据SH1106靠DC引脚区分两种模式搞反了就会把命令当数据发屏幕直接花掉。GPIO控制函数的写法如下// u8g2_hal_stm32.c 续 uint8_t u8x8_gpio_and_delay_stm32(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch(msg) { case U8X8_MSG_GPIO_AND_DELAY_SET_CS: // 片选信号 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, arg_int ? GPIO_PIN_RESET : GPIO_PIN_SET); break; case U8X8_MSG_GPIO_AND_DELAY_SET_DC: // 数据/命令选择 HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_GPIO_AND_DELAY_SET_RESET: // 复位信号低有效 HAL_GPIO_WritePin(RES_GPIO_Port, RES_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_DELAY_MILLI: // 毫秒延时HAL库自带 HAL_Delay(arg_int); break; default: return 0; } return 1; }这个函数把U8g2库的软硬件抽象层请求映射到HAL库的GPIO操作上。注意CS引脚的电平逻辑arg_int为1时表示选中而SH1106的CS是低有效所以这里做了电平反转。如果用的是三线SPI模式比如只有SCK和MOSI没有CS引脚可以在CubeMX里把CS引脚配成普通GPIO但多余的片选切换会引入额外的时序开销。3.3 初始化代码的写法u8g2_Setup_sh1106_128x64_noname_f函数参数含义完成适配层后需要在主程序中初始化U8g2库。初始化函数的选择有讲究函数名里的f代表全缓冲模式即所有像素数据先在RAM中绘制完成再一次性刷新到屏幕。还有前缀为u8g2_Setup_sh1106_128x64_noname_1的页缓冲模式只用少量RAM但需要多次刷新以及_2的二分页模式这里全缓冲是Flash和RAM都充裕时的最优选择。// main.c 初始化部分 #include u8g2.h #include u8g2_hal_stm32.h u8g2_t u8g2; // 全局U8g2结构体存储显示状态 int main(void) { HAL_Init(); SystemClock_Config(); // 配置系统时钟这里用默认的72MHz MX_GPIO_Init(); MX_SPI1_Init(); // U8g2库初始化绑定底层函数 u8g2_Setup_sh1106_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_hw_spi, u8x8_gpio_and_delay_stm32); u8g2_InitDisplay(u8g2); // 发送完整初始化序列到SH1106 u8g2_SetPowerSave(u8g2, 0); // 关闭省电模式屏幕开始显示 // 绘制一个全屏填充矩形验证显存映射 u8g2_ClearBuffer(u8g2); // 清空内部显存 u8g2_DrawBox(u8g2, 0, 0, 128, 64); // 横向128像素纵向64像素 u8g2_SendBuffer(u8g2); // 把显存内容刷新到屏幕 while(1) { // 主循环可以在这里继续绘制其他内容 } }这里的u8g2_Setup函数选择取的是U8G2_R0即不做旋转。如果屏幕物理方向装反了可以换成U8G2_R2实现180度旋转U8G2_R1和U8G2_R3分别对应90度和270度。旋转操作是在库内部完成的不增加额外负担只是绘图坐标系的对应关系会变。参数说明第一个u8g2是保存所有状态的结构体指针必须是全局变量或静态变量不能是栈上的临时变量第二个参数是旋转方向第三和第四个参数就是我们刚才写的两个适配函数它们的签名必须严格匹配U8g2库定义的函数指针类型。4. 移植后的显示效果与常见翻车现场乱码、偏移、花屏逐个排查4.1 现象一屏幕全亮或全暗什么内容都不显示遇到这种情况先不要改代码用逻辑分析仪或示波器抓SCK和MOSI的波形确认主控确实在发送数据。如果SCK没有波形八成是SPI的GPIO复用功能没有正确配置CubeMX里只勾了SPI1但没有把引脚复用为AF5导致PA5和PA7只是普通GPIO在输出电平。这种问题在CubeMX生成的代码里不太容易出现但如果是手动添加SPI初始化代码就很常见。解决方法是检查MX_SPI1_Init函数里有没有执行GPIO_InitStruct.Alternate GPIO_AF5_SPI1以及时钟有没有正常使能。另一个可能性是RES引脚没有正确复位SH1106要求上电后RES拉低至少10us再拉高如果复位时序不满足芯片会一直处于未知状态。4.2 现象二显示内容整体左移或右移两列这是SH1106最容易踩的坑。很多人第一次点亮后屏幕最左边会有两列黑色残影或者最右边缺两列。原因是SH1106的列地址偏移。SSD1306的列地址从0开始SH1106从2开始U8g2库在底层的驱动文件中已经处理了这个偏移但前提是你选对了setup函数。如果你的屏幕初始化用的是SSD1306的setup函数那么列地址偏移没有被补齐就会出现内容右移两列。解决方法是确认setup函数名必须包含sh1106我用的是sh1106_128x64_noname。如果你手里的屏是1.3寸但分辨率也是128x64这个noname版本适用于大部分兼容SH1106的屏。但如果买了山寨屏列偏移可能是3像素那就只能手动改驱动文件里的偏移宏了。4.3 现象三文字花掉字间隔有噪声点这个问题的根源在SPI时钟极性和相位不匹配。U8g2库默认用CPOL0、CPHA1的时序如果你在CubeMX里把CPOL设成了High那么SCK空闲时是高电平MOSI上的数据在错误的边沿被采样看起来就是花屏。可以在CubeMX的SPI配置页面中将Clock Polarity设为LowClock Phase设为1Edge验证。如果改完还花则把SPI波特率预分频从16换到32排除信号完整性问题——杜邦线超过10cm时高频信号容易产生振铃短接F103和显示屏之间的连线最好控制在5cm以内。4.4 现象四屏幕能显示但刷新画面时会有残留残影SH1106的残影和SSD1306表现不同SSD1306残影是因为没有用整屏写入模式SH1106则是翻页顺序或清屏时序的问题。U8g2的全缓冲模式在调用SendBuffer时会把整块显存按页写入如果某些页的数据没有更新旧内容就会残留。这时检查两个点一是ClearBuffer和SendBuffer之间有没有正好把当前帧的内容全部绘制完二是SPI发送时每发送完一页数据需要有一个拉高CS的时机让屏幕锁存数据这个时序由U8g2的底层函数自动完成。如果自己改了适配层代码比如在SendBuffer内部额外加入了延时或丢失了结束信号就可能产生残影。我遇到过类似的情况后来发现是自己在SPI发送函数里加了一个不必要的延时去掉后残影消失。4.5 现象五屏幕只在刷新的瞬间闪一下随后熄灭这个多半是进入了省电模式。u8g2_InitDisplay之后紧接着调用u8g2_SetPowerSave(u8g2, 0)很重要参数0表示关闭省电模式。如果忘了这行屏幕会亮一下就保持黑屏状态因为U8g2库初始化完成后默认把屏幕置于省电状态。另一个原因是电源问题SH1106工作电流在20mA左右3.3V供电用LDO常见做法是接一个100uF电容稳压我用的是AMS1117-3.3模块屏幕供电直接从板子的3.3V引脚取没有额外加电容也遇到了闪屏现象加了电容后故障排除。劣质杜邦线接触不良可能造成电压跌落同样会表现为闪烁。5. 显示中文和数据刷新u8g2自带字体与自定义字库的实战处理5.1 内置字体清单与选择哪些字体适合小屏幕哪些是坑U8g2库内置了多种字体从4x6到24x32不等。字体命名格式有规律u8g2_font_前缀表示字体后面的字母组合标识风格数字表示高度比如u8g2_font_5x7_tf是5像素宽、7像素高的等宽字体。选字体有两个注意点一是带有_tf后缀的表示包含透明模式适合绘制在已有图形上的文字带_tr后缀的不带透明背景适合独立绘制文字。二是部分字体只包含ASCII字符绘制中文会变成问号或乱码所以在选字体前先看该字体的字符集覆盖范围。项目中我常用u8g2_font_6x13_tf做正文u8g2_font_10x20_tf做数字突出显示这套组合在128x64分辨率下显示效果清晰可辨。5.2 中文字符显示方案U8g2不支持直接写中文的解决办法U8g2库本身不支持中文字库因为中文字符集太大全量字库会占用大量Flash。实际项目里需要显示中文时有以下三个方案第一种是外部Flash字库用SPI Flash存字模每次渲染前读取对应的字模数据。这种方法适合需要显示大量不同中文的场景但需要自己实现字模检索和SPI Flash驱动工程量大一些。第二种是GB2312编码查表映射适合固定显示少量汉字。将需要用的汉字提取成一个常量数组每个汉字对应一个16x16的字模绘制时查表获取字模数据然后按位写入显存。比如一个仪表盘只需要显示电压、电流、温度、状态几个词十几个汉字就够了用u8g2_DrawXBM函数可以直接绘制字模位图。下面是一个绘制16x16汉字的示例截取自模拟项目X的仪表盘代码// 绘制16x16汉字字模x和y是目标位置的左上角坐标 void Draw_Chinese_16(u8g2_t *u8g2, uint16_t x, uint16_t y, const uint8_t *ch) { u8g2_DrawXBM(u8g2, x, y, 16, 16, ch); } // 使用示例在坐标(20, 20)处绘制汉字电 // 字模数据由取模软件生成字节排列为逐行水平扫描 static const uint8_t dian_zimo[] { 0x00, 0x00, 0x7E, 0x42, 0x42, 0x7E, 0x42, 0x42, 0x7E, 0x42, 0x42, 0x7E, 0x00, 0x00, 0x00, 0x00, // 剩余16字节省略实际字模数据一般由工具生成 }; // 在u8g2初始化后使用 Draw_Chinese_16(u8g2, 20, 20, dian_zimo); u8g2_SendBuffer(u8g2);这里u8g2_DrawXBM的位图参数要求字节数据按行排列每行像素数需要对齐到8的倍数16x16字模正好满足这个条件。如果取模软件生成的字节顺序是纵向扫描而非横向扫描显示出来会是旋转90度或镜像的需要在取模设置里选择横向取模字节倒序也需要注意。第三种方案是在PC端把想要显示的汉字预处理成字模数组这其实是方案二的扩展本质同样是查表绘制只是查表逻辑从运行时移到了编译时。对128x64这种小分辨率屏幕能同时显示的汉字数量有限所以大部分项目都选择方案二。5.3 数据刷新时的闪烁问题局部刷新和帧率控制技巧屏幕在显示动态数值时如果每帧都调用ClearBuffer、绘制、SendBuffer数字变化时会有明显闪烁尤其是电压或温度这类每秒多次变化的数据。解决方案有两个方向。第一个是局部擦除不清空整个缓冲只在数值所在区域绘制底色矩形覆盖旧内容再绘制新数值。U8g2提供了u8g2_SetDrawColor函数配合u8g2_DrawBox填充区域。第二个是降低刷新频率人为增加帧间隔比如每50ms刷新一次模拟量区域不必每次MainLoop都刷新全屏。// 局部刷新数值示例在区域(5, 20)到(60, 32)显示三位整数 u8g2_SetDrawColor(u8g2, 0); // 颜色设为0用于擦除 u8g2_DrawBox(u8g2, 5, 20, 55, 12); // 擦除旧内容区域 u8g2_SetDrawColor(u8g2, 1); // 颜色恢复为1用于绘制 u8g2_SetFont(u8g2, u8g2_font_10x20_tf); u8g2_DrawStr(u8g2, 5, 30, 123); // 绘制新的数值字符串 u8g2_SendBuffer(u8g2); // 只发送这一帧这段代码的逻辑是先用颜色0填充旧区域实现局部擦除效果再以颜色1绘制新数值。u8g2_SetDrawColor的0和1是单色屏的基本两色模式SH1106是单色屏不支持灰度所以只有这两个值有效。局部刷新代替全屏刷新能明显降低闪烁感代价是每次绘制前需要明确旧内容所在矩形范围编码稍显繁琐。5.4 功耗和帧率权衡如何榨干F103的性能在72MHz主频下u8g2全缓冲模式刷一帧128x64的内容需要传输1024字节SPI时钟4.5MHz时理论耗时约2ms实际加上绘图操作完全来得及跑30fps以上的动画。如果希望画圆或弧线这类需要大量CPU计算的操作帧率会明显下降但STM32F103跑U8g2的矢量绘图函数还算轻松。实际项目中我一般把动画控制在10fps到15fps画面仍然流畅且CPU占用低。如果连10fps都跑不到检查SPI波特率配置。有些人在CubeMX里把SPI分频设成了128或256SPI时钟降到几百kHz刷新一帧要几十毫秒。建议先看波形确认实际SCK频率再做调整。6. 从验证到进阶硬件SPI模式下DMA传输的接入方法DMA传输在U8g2库中是一个值得尝试的优化方向它能把SPI发送从CPU搬运变成外设自动完成。接线不变重新在CubeMX中打开SPI1的DMA请求将SPI1_TX请求绑定到DMA1Channel3优先级设为Medium或High。生成的代码里会增加一个函数在main函数调用HAL_SPI_Transmit_DMA时CPU可以立即返回继续执行绘图绘制。但这里有一个大坑U8g2库的SendBuffer函数是一次性把1024字节交给底层发送DMA方式发送完成后需要回调通知上层否则下一帧绘制时上一帧还没发完显存会被覆盖导致画面撕裂。我常用的处理方式是定义两个显存缓冲区交替使用当DMA发送当前缓冲区时主循环在另一个缓冲区里绘制。好在u8g2_Setup函数已经允许传入不同的缓冲区位置通过在适配层增加缓冲切换逻辑可以实现双缓冲渲染。// DMA发送完成回调函数 void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi-Instance SPI1) { dma_busy_flag 0; // 标记DMA传输完成可以安全切换缓冲区 } } // 适配层DMA发送函数 uint8_t u8x8_byte_hw_spi_dma(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch(msg) { case U8X8_MSG_BYTE_SEND: while(dma_busy_flag); // 等待上次发送完成 dma_busy_flag 1; // 置传输忙碌标志 HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)arg_ptr, arg_int); break; case U8X8_MSG_BYTE_SET_DC: HAL_GPIO_WritePin(DC_GPIO_Port, DC_Pin, arg_int ? GPIO_PIN_SET : GPIO_PIN_RESET); break; case U8X8_MSG_BYTE_INIT: dma_busy_flag 0; break; default: return 0; } return 1; }这段代码的要点是在发送新数据前检查上次DMA是否完成用dma_busy_flag标志做同步。和普通轮询发送相比DMA方式能把CPU时间从等待SPI发完中释放出来让主循环可以一边刷屏一边做其他事情。实际测试中如果用DMA发送配合双缓冲切换动画帧率能提升接近一倍CPU占用率明显下降。但代价是多了一套缓冲区管理逻辑代码复杂度上升适合画面对流畅度要求高的场景。DMA方式还有一个需要注意的细节HAL_SPI_Transmit_DMA会处于非阻塞模式必须在CubeMX中开启SPI的DMA接收和发送通道而且发送缓冲区的地址必须4字节对齐。如果缓冲区只是普通全局数组编译器可能不会自动对齐需要手动在数组声明前加上__attribute__((aligned(4)))语法。这个细节我踩过一次当时DMA传输出来的数据一直是乱码排查了很久才发现是对齐问题。最后把我个人的使用习惯总结给你按这套方法移植完U8g2库之后我每次拿到一块新OLED屏都不会直接开写代码而是先确认芯片型号再看分辨率然后查看U8g2驱动文件里是否有对应的setup入口。型号对不上就直接换库版本或改偏移不做无谓的适配尝试。从那以后我每次做屏幕驱动都强制走一遍这套流程节省了大量排查时间。希望你能顺着这个步骤快速点亮自己的SH1106屏幕并在U8g2库的加持下专注实现自己的界面逻辑而不是在底层通信里打转。本文还有配套的精品资源点击获取