
简介面向STM32嵌入式开发者的U8G2与SSD1306移植例程基于STM32F103C8T6硬件IIC接口配合CUBEMX生成基础工程旨在解决OLED显示屏在STM32平台上的驱动与移植问题适合需要快速上手显示功能、学习U8G2库结构或者参考FreeRTOS集成的中初级单片机开发者。压缩包共包含1048个文件以C源码和头文件为主其中c文件674个、h文件252个并配有启动文件、链接脚本、CubeMX工程文件以及Keil工程文件等整体大小约26.18MB目录结构规整便于按功能模块检索。该资源已有1095人学习/下载。例程注释非常详细几乎每个配置项都写有说明可以逐行理解硬件IIC初始化、U8G2底层对接与画图调用流程同时预留了FreeRTOS入口可进一步扩展为轻量级图形界面或多任务显示任务。工程同时支持MDK与VS Code开发环境从CubeMX引脚配置到U8G2字体渲染均有完整代码脉络是一份可直接参考的OLED显示解决方案。1. U8G2 驱动 SSD1306 在 STM32F103C8T6 上跑硬件 IIC是 CubeMX 工程里最常见的 OLED 移植组合网上搜 u8g2 移植例程一半是 Arduino 版另一半是 GPIO 模拟 IIC 版真正把硬件 IIC 和 HAL 库串起来的干净示例不多。U8G2 是跨平台图形库自带 SSD1306 以及 SSD1315 这类兼容屏的初始化序列和字体系统硬件 IIC 则由 STM32 的 I2C1 外设和 CubeMX 生成的 HAL 代码接管主循环不用再去逐位翻转 SCL/SDA。这套方案要解决的核心问题只有一个把 U8G2 的 Arduino 回调换成 HAL 传输并处理 F103 硬件 I2C 偶发的 BUSY 锁死。下面按 CubeMX 配置、源码移植、绘图调用、异常排查的顺序展开适合手里有最小系统板和 0.96 寸 OLED、想一次跑通还能讲清参数边界的人。整个流程在 Keil、IAR 或 CubeMX 生成的 Makefile 配 VSCode 的工程里都通用差别只在源文件怎么加进编译列表。2. CubeMX 配置 STM32F103C8T6 硬件 IICI2C1 引脚、时钟与 400kHz 参数2.1 最小系统板上 I2C1 的固定引脚PB6/PB7 与 SA0 地址STM32F103C8T6 的 I2C1 引脚是固定的 PB6(SCL)/PB7(SDA)没有重映射选项I2C2 在 PB10/PB11 上也有但绝大多数 OLED 例程和模块默认都按 I2C1 写除非 PB6/PB7 被其它功能占用否则没必要换。蓝色最小系统板已经把这几个引脚引到排针接 0.96 寸 OLED 时 VCC、GND、SCL、SDA 四根杜邦线直接对插即可。模块背面 SA0 的电平决定 7 位 I2C 地址默认 SA0 拉低地址是 0x3C把 SA0 跳线焊到高电平后地址变成 0x3D对应到 U8G2 里要调u8x8_SetI2CAddress(u8g2, 0x7A)。CubeMX 里勾选 I2C1 后PB6/PB7 会自动配成开漏复用模式不需要手动改 GPIO 配置上拉电阻一般已经焊在模块上只有自己用面包板飞线时才需要另外加。提示如果手头的 4 引脚 128×64 OLED 背面丝印是 SSD1315可以直接套用 ssd1306 的 setup 函数两者 I2C 命令集兼容多数模块亮起来没有差别。2.2 时钟树与 APB1U8G2 刷屏的 I2C 频率从哪来F103 的 I2C 外设挂在 APB1 总线上APB1 上限 36MHz。CubeMX 的 Clock Configuration 里选 HSE 8MHz 晶振作为 PLL 源PLL 自动算到 72MHz 系统时钟APB1 Prescaler 保持 /2 就是 36MHz。这里的关键认知是I2C 的 400kHz 速率由 APB1 的 36MHz 分频而来不是系统时钟直接提供改错 APB1 分频会连带影响 TIM、USART 等一串外设。如果板子上没焊 8MHz 晶振部分廉价最小系统板和国产替代板把 PLL Source 切到 HSI 内部 8MHz8MHz×9 同样得到 72MHzAPB1 保持 /2。I2C 对主频来源不敏感重点只在于 APB1 稳定在 36MHz并且 I2C1 配置页里 Speed Mode 选 Fast Mode、Speed 填 400000。2.3 I2C1 参数速查表与生成代码检查项CubeMX 的 I2C1 配置页只需要动两个地方其余保持默认配置项推荐值说明I2C Speed ModeFast Mode400kHz 必须 Fast抗干扰场景选 StandardSpeed (Hz)400000SSD1306 标称上限花屏时降为 100000Rising Time300ns(Fast)/1000ns(Standard)CubeMX 默认值不用改Own Address0x00主机模式下不参与从机地址仲裁Addressing Mode7-bitU8G2 的地址回调按 7 位地址工作生成代码后 i2c.c 里会出现下面的初始化结构HAL 库自动计算分频系数hi2c1.Instance I2C1; hi2c1.Init.ClockSpeed 400000; /* I2C 时钟速率 */ hi2c1.Init.DutyCycle I2C_DUTYCYCLE_16_9; /* Fast mode 占空比 16/9 */ hi2c1.Init.OwnAddress1 0; hi2c1.Init.AddressingMode I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(hi2c1) ! HAL_OK) { Error_Handler(); }DutyCycle 16/9 是 400kHz Fast Mode 对应的占空比由速率档位自动决定Standard 模式不用管它AddressingMode 7BIT 表示调用HAL_I2C_Master_Transmit时传入的是 7 位地址左移一位的结果0x3C 要传 0x78这个细节直接决定下一章的回调能不能通。生成完先编译一次确认 PB6/PB7 没有被其它外设的 GPIO 初始化覆盖。3. U8G2 源码移植到 CubeMX HAL 工程两个回调替换 Arduino 依赖3.1 先分清 U8G2 哪些文件依赖 ArduinoU8G2 官方仓库里C 源码在 csrc 目录旧版本叫 src下载 u8g2 驱动后解开就是。这个目录包含完整的绘图、字体和控制器驱动代码全部是纯 C可以直接编进 Keil、IAR 或者 CubeMX 生成的 Makefile 工程。真正依赖 Arduino 的只有u8g2_arduino.c和u8x8_byte_arduino_hw_i2c.c、u8x8_byte_arduino_sw_i2c.c这几个文件它们封装了 Wire 库和 delay()在 HAL 环境下没有意义。建议的最小编译文件集如下需要加入编译的文件用途u8g2.c、u8g2_d_memory.c、u8g2_d_setup.cU8G2 对象、缓冲分配、setup 宏展开u8x8.c、u8x8_byte.c、u8x8_cad.c、u8x8_gpio.c字节传输、命令抽象层u8x8_d_ssd1306.cSSD1306/SSD1315 控制器初始化序列u8g2_fonts.c全部内置字体文件很大但必须编不要加u8x8_byte_arduino_hw_i2c.c和u8g2_arduino.c否则会出现重复定义或 Wire 符号缺失。用 CubeMX 生成 Makefile 配 VSCode 时在 C_SOURCES 里把 csrc 下这些文件加进去即可Keil 里用 Add Existing Files头文件路径指到 csrc 目录。3.2 字节发送回调 u8x8_byte_stm32_hw_i2c 的写法移植的关键是让 U8G2 的字节发送走HAL_I2C_Master_Transmit而不是默认的软件时序。u8g2_Setup系列宏的最后两个参数就是字节回调和 GPIO/延时回调先写字节回调uint8_t u8x8_byte_stm32_hw_i2c(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch (msg) { case U8X8_MSG_BYTE_SEND: HAL_I2C_Master_Transmit(hi2c1, u8x8_GetI2CAddress(u8x8), (uint8_t *)arg_ptr, arg_int, HAL_MAX_DELAY); break; case U8X8_MSG_BYTE_INIT: case U8X8_MSG_BYTE_SET_DC: case U8X8_MSG_BYTE_SET_I2C_ADR: break; default: return u8x8_byte_default(u8x8, msg, arg_int, arg_ptr); } return 1; }这里的逻辑是U8G2 区分命令和数据不是靠 DC 引脚而是靠 I2C 序列里的控制字节命令前缀 0x00数据前缀 0x40U8G2 的 CAD 层在触发 BYTE_SEND 时已经把控制字节拼进 buffer所以回调只需要把arg_ptr指向的数据原样发出。HAL_I2C_Master_Transmit第二个参数是 8 位设备地址u8x8_GetI2CAddress默认返回 0x780x3C 左移一位如果模块地址是 0x3D主程序里调u8x8_SetI2CAddress(u8g2, 0x7A)即可回调函数不用动。BYTE_INIT、SET_DC、SET_I2C_ADR 三个消息在硬件 IIC 下没有动作外设初始化由MX_I2C1_Init完成DC 已经编码进控制字节地址直接存在 u8x8 对象里。default 分支交给u8x8_byte_default处理 START_TRANSFER 和 END_TRANSFER 这类空操作不能把所有消息都直接 return 1否则部分版本的初始化状态机走不完整。3.3 延时回调与 u8g2_Setup 组装以及调用顺序GPIO 回调比字节回调更简单硬件 IIC 下没有引脚要手动拉电平只有延时需要用 HAL_Delay 补上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_DELAY_MILLI: HAL_Delay(arg_int); break; case U8X8_MSG_GPIO_AND_DELAY_INIT: case U8X8_MSG_GPIO_I2C_CLOCK: case U8X8_MSG_GPIO_I2C_DATA: break; default: break; } return 1; } u8g2_t u8g2; u8g2_Setup_ssd1306_i2c_128x64_noname_f(u8g2, U8G2_R0, u8x8_byte_stm32_hw_i2c, u8x8_gpio_and_delay_stm32); u8g2_InitDisplay(u8g2); u8g2_SetPowerSave(u8g2, 0);u8g2_Setup第一参数是显示对象第二个是旋转方向 U8G2_R0/R1/R2/R3不换接线就能转屏幕方向setup 宏里的 noname 表示不带独立复位引脚128x64 是分辨率_f 是 1024 字节全帧缓冲。InitDisplay会完整跑一遍 SSD1306 初始化序列紧接着必须调SetPowerSave(0)取消省电模式漏掉这一句屏幕就一直是黑的。初始化代码放在 main 里MX_I2C1_Init之后、主循环之前旋转方向最好在初始化前一次定好。4. U8G2 绘图 API、全帧缓存与中文字库F103 上怎么选参数4.1 _f 与 _1 缓存后缀RAM、Flash 和刷新方式的取舍setup 宏的后缀决定显示缓冲策略这是 U8G2 在 F103 上最值得先定住的参数后缀缓冲占用刷新方式适用场景_f1024 字节ClearBuffer 后直接 SendBuffer默认推荐C8T6 的 20KB RAM 压力很小_1128 字节FirstPage/NextPage 循环重画RAM 极紧张时才需要_2256 字节同上两页缓冲动画分割刷新的折中_ f 模式的代码最直观先 ClearBuffer再画若干元素最后 SendBuffer 把整帧推给 SSD1306。_1 模式需要把整帧绘制逻辑塞进 FirstPage/NextPage 循环U8G2 内部按页裁剪输出每页重跑一遍绘制函数省 RAM 但代码结构绕很多C8T6 上没有必要。缓冲数组由u8g2_d_memory.c里的静态变量分配切换后缀后确认这个文件还在编译列表里。4.2 一套常用绘图 API从 DrawStr 到 SendBufferu8g2_ClearBuffer(u8g2); u8g2_SetFont(u8g2, u8g2_font_6x10_tf); u8g2_DrawStr(u8g2, 0, 12, U8G2 on F103); u8g2_DrawBox(u8g2, 0, 18, 128, 2); u8g2_DrawCircle(u8g2, 32, 42, 8, U8G2_DRAW_ALL); u8g2_DrawLine(u8g2, 0, 60, 127, 60); u8g2_SendBuffer(u8g2);DrawStr 的 y 坐标是文字基线而不是左上角第一次画字符串偏出屏幕基本都因为这里SetFont 指定的字体决定字符宽度ASCII 字体下 UTF-8 字符串和普通 char* 没有区别。DrawBox 参数依次是 x、y、宽、高DrawCircle 是圆心坐标加半径最后一个参数用 U8G2_DRAW_ALL 画整圆。显示动态数值时每帧 ClearBuffer 后重画整个界面再 SendBuffer400kHz 下整帧 1024 字节传输约 23ms加上绘制开销肉眼看到的是连续刷新不会有撕裂感。动画场景有一个常用技巧u8g2_SetDrawColor(u8g2, 2)把绘制模式切成异或同一位置画两次就能擦除原图不用每次都重建整帧画面适合做选中框和箭头闪烁。4.3 中文字体unifont 子集和 wqy GB2312 的编码差异U8G2 内置中文只有 unifont 和 wenquanyi 两条线用法差别很大字体编码覆盖说明unifont_t_chinese1UTF-8极小字符子集只有 U4E00 开头一小段日常用不现实unifont_t_chinese2UTF-8常用汉字区大部覆盖明显扩大flash 占用同步上升unifont_t_chinese3UTF-8范围更大字最全64KB flash 下先编译看体积wqy12/14/16_t_gb2312GB2312GB2312 常用字传入字符串必须是 GB2312 字节unifont 系列按 UTF-8 取字源文件存成 UTF-8 就能直接画wqy 系列按 GB2312 内码取字字符串来自 Keil 源文件GB2312 编码或串口收到的 GB2312 字节流都能对上但 UTF-8 中文字符串用 wqy 字体画会全变问号。C8T6 的 64KB flash 里u8g2_fonts.c本身已占用不小体积再叠加 chinese3 这种大字体容易超先编译看 map 文件再定。界面里只有几个固定汉字时更省的做法是把这几个字用取模工具转成位图数组再用 DrawXBM 画完全绕开字体表的体积问题。5. U8G2 硬件 IIC 显示异常排查BUSY 锁死、NACK 与上拉电阻5.1 F103 硬件 I2C 的 BUSY 锁死与 DeInit/Init 复位STM32F103 的硬件 I2C 有一个知名问题总线上出现毛刺、从机没有正确释放 SDA、或者上电顺序不对时外设可能进入 BUSY 状态之后HAL_I2C_Master_Transmit一直返回 HAL_BUSY。这不是 U8G2 的问题是 F103 的 I2C 外设状态机本身容易卡住。常见做法是把传输封装一层出错时检查错误码并复位外设uint8_t i2c1_write(uint16_t addr, uint8_t *buf, uint16_t len) { HAL_StatusTypeDef st HAL_I2C_Master_Transmit(hi2c1, addr, buf, len, 50); if (st HAL_OK) return 1; if (HAL_I2C_GetError(hi2c1) HAL_I2C_ERROR_BERR) { HAL_I2C_DeInit(hi2c1); HAL_I2C_Init(hi2c1); } return 0; }注意超时从 HAL_MAX_DELAY 改成 50ms卡死时所有传输都会一直挂起没有超时整个系统会停掉。BERR总线错误出现后直接把外设 DeInit 再 Init比手动翻 SR1/SR2 清标志位可靠HAL 会把 CCR、TRISE 全部重写一遍U8G2 下一帧传输自然恢复。如果 BERR 出现频率很高优先查硬件接线而不是堆复位逻辑。HAL_I2C_GetError 返回的错误标志可以按下面这张表定位错误标志含义常见原因HAL_I2C_ERROR_AF从机 NACK地址写错、模块没上电HAL_I2C_ERROR_BERR总线错误SDA 被拉死、时序被干扰HAL_I2C_ERROR_BUSY总线忙外设状态机卡住、总线被占用HAL_I2C_ERROR_TIMEOUT超时从机时钟拉伸过久5.2 黑屏排查顺序上拉、地址扫描与 HAL_I2C_IsDeviceReady黑屏不要先改代码先量电和接线VCC/GND 确认 3.3VSCL/SDA 空闲电平应该在 3.3V 附近。如果 SCL/SDA 一高一低或者都接近 0V先查上拉电阻——模块自带的就跳过这步杜邦线直连面包板且板上没有上拉时在 SDA/SCL 各接一只 4.7kΩ 到 3.3V。接着跑地址扫描确认模块真实地址for (uint16_t a 0x08; a 0x78; a) { if (HAL_I2C_IsDeviceReady(hi2c1, a, 2, 10) HAL_OK) { printf(i2c dev at 0x%02X\r\n, a); } }HAL_I2C_IsDeviceReady的地址参数和 Master_Transmit 一样要左移一位探测 7 位地址 0x3C 时传 0x78扫描结果看到 0x78 就是标准模块看到 0x7A 说明 SA0 被拉高回头把u8x8_SetI2CAddress(u8g2, 0x7A)加上。扫描不到设备时检查 PB6/PB7 是否被 CubeMX 里其它外设占用很多示例工程把 PB7 配成按键输入直接把 SDA 拉死这是黑屏里最隐蔽的原因。5.3 400kHz 花屏降速到 100kHz 与克隆屏边界SSD1306 标称 I2C 400kHz实际到货的模块配上长杜邦线后信号边沿被线缆电容磨圆400kHz 下容易出现半行乱码、刷新错位的花屏。最稳的降速办法是 CubeMX 里把 Speed 改成 100000 重新生成花屏立刻消失代价是整帧 1024 字节的传输时间从 400kHz 的约 23ms 变成 100kHz 的约 92ms静态界面完全无感滚动数字会看到刷新变慢。想保留 400kHz 又不花屏就把 SCL/SDA 走线缩短到 10cm 以内或者把上拉电阻从 10kΩ 换到 4.7kΩ让信号沿更陡。6. U8G2 移植完成后的验证用 GPIO 翻转测真实刷新率移植完别只靠眼睛看效果给刷新率做一个定量验证。思路是每帧翻转一个空闲 GPIO用逻辑分析仪量相邻上升沿间隔就是真实的帧耗时。下面这段放在 while 循环里PB0 用 CubeMX 配成推挽输出while (1) { HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_0); u8g2_ClearBuffer(u8g2); u8g2_DrawBox(u8g2, 0, 0, 128, 32); u8g2_SendBuffer(u8g2); }逻辑分析仪抓 PB0 相邻上升沿一帧里包含清缓冲、画 128×32 色块和 1024 字节 I2C 传输。72MHz 主频、400kHz IIC、_f 全帧缓存下这个循环的帧周期大约 30ms 出头其中 SendBuffer 占绝对大头如果看到个别边沿间隔突然拉长到几百毫秒多半是 I2C 传输被 BUSY 卡了一下配合分析仪的 I2C 解码通道能直接看出是 NACK 还是异常 STOP 捣的鬼。这个方法同样适合换 SSD1315、改 DMA 传输或者降速 100kHz 后复测数字不会说谎验证完把测试代码摘掉只保留封装的 i2c1_write 和 U8G2 初始化部分。本文还有配套的精品资源点击获取