从零开始玩转u8g2:STM32上用SPI驱动OLED的实战全记录
你有没有遇到过这种情况?买了一块SSD1306 OLED屏,兴冲冲接到STM32板子上,代码一烧录——屏幕要么完全不亮,要么花屏乱码。调试半天,发现不是I²C地址错了,就是DC引脚接反了。
别急,这几乎是每个嵌入式开发者都会踩的坑。今天我们就来彻底解决这个问题:如何在STM32平台上,通过SPI接口稳定高效地运行u8g2图形库。
我们不讲空话套话,直接从硬件连接、底层通信机制,到软件移植细节,一步步带你打通“能显示”到“好显示”的最后一公里。
为什么选u8g2?它到底强在哪?
市面上做OLED显示的库不少,但真正能在资源紧张的MCU上跑得又稳又快的,还得看u8g2。
它是德国开发者Oliver Kraus写的开源项目,专为单色屏设计。支持超过150种控制器(SSD1306、SH1106、PCD8544……),而且不管你是用Arduino、ESP32还是STM32,都能无缝移植。
最关键是:它不需要操作系统,也不动态申请内存。所有缓冲区都是编译期就定好的静态空间,特别适合裸机系统或实时性要求高的场景。
更爽的是,它内置了上百种字体,连中文点阵都可以塞进去。你想画个圆、写串UTF-8文本、甚至显示一个小图标?几行API搞定。
那它是怎么做到这么轻量又强大的呢?关键就在于它的分层架构。
u8g2是怎么工作的?
简单说,u8g2把整个流程拆成了三层:
- 图形引擎层:处理绘图逻辑,比如你要画一个字符串,它会算出每个像素该不该点亮。
- 设备驱动层:根据你的屏幕型号(比如
SSD1306_128x64_NONAME_F_HW_SPI)生成初始化命令和通信协议。 - 硬件抽象层(HAL):真正和MCU外设打交道的地方,负责发SPI数据、控制GPIO。
也就是说,只要你把最底层的“怎么发一个字节”这件事告诉它,剩下的它全包了。
而这个“告诉它”的过程,靠的就是一个回调函数 ——byte_cb。
SPI通信:不只是接四根线那么简单
很多人以为SPI就是SCK、MOSI、CS、GND四根线一接,再配个时钟就能通。但实际上,只要有一个参数不对,屏幕就可能罢工。
先来看标准接法:
| STM32引脚 | 连接OLED引脚 | 功能说明 |
|---|---|---|
| PA5 (SCK) | SCK | 时钟信号 |
| PA7 (MOSI) | DIN/MOSI | 数据输出 |
| PB6 | CS | 片选,低有效 |
| PB7 | DC | 命令/数据选择 |
| PB8 | RST | 复位(可选) |
注意:DC引脚不是SPI的一部分,但它至关重要。没有它,屏幕无法区分你现在传的是“清屏命令”,还是“Hello World”这几个字。
时序问题:Mode 0才是王道
绝大多数OLED模块(如SSD1306)都要求使用SPI Mode 0,也就是:
- CPOL = 0:空闲时SCK为低电平
- CPHA = 0:在第一个上升沿采样数据
如果你误设成Mode 3(CPOL=1, CPHA=1),虽然也能传数据,但很可能出现高位丢bit或者命令错乱的问题。
另外,STM32的SPI模块有个坑:默认方向是双线全双工(TX/RX同时启用)。但我们连MISO都没接,所以建议改成1-line Tx Only 模式,避免总线冲突。
波特率怎么设?太快也不行!
虽然SPI理论上可以跑到几十MHz,但OLED控制器的主频一般只有几MHz。以SSD1306为例,最大支持10MHz SCK频率。
如果你的APB2时钟是84MHz(常见于STM32F4),那SPI分频至少要选/4或更慢:
hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; // → 21MHz? 超了!等等!21MHz已经超过10MHz了怎么办?
其实不用慌。手册里写的“最大10MHz”是指可靠工作的上限,实际测试中很多模块在16~20MHz也能正常工作。但为了稳定性,推荐设置为/8或/16,即约5–10MHz之间。
手把手教你写底层传输函数
u8g2不关心你是用HAL库、LL库还是寄存器操作,它只认一个回调函数:u8x8_msg_cb byte_cb。
我们来写一个基于HAL库的完整实现。
第一步:初始化SPI和GPIO
先用CubeMX配置好SPI1为主模式,关闭CRC、禁用中断/DMA,其他保持默认即可。
生成代码后,确保以下几点:
- 使用软件管理CS片选(NSS = Soft)
- 数据大小为8位
- MSB先行
然后定义几个宏方便移植:
#define OLED_CS_GPIO_Port CS_PIN_GPIO_Port #define OLED_CS_Pin CS_PIN_Pin #define OLED_DC_GPIO_Port DC_PIN_GPIO_Port #define OLED_DC_Pin DC_PIN_Pin第二步:实现核心回调函数
这是整个移植成败的关键。
uint8_t u8g2_spi_transfer_cb(u8x8_t *u8x8, uint8_t msg, uint8_t arg_int, void *arg_ptr) { switch(msg) { case U8X8_MSG_BYTE_SEND: HAL_SPI_Transmit(&hspi1, (uint8_t*)arg_ptr, arg_int, HAL_MAX_DELAY); break; case U8X8_MSG_BYTE_INIT: // 初始化SPI和GPIO(已在main中完成,此处可留空) break; case U8X8_MSG_BYTE_SET_DC: HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, (GPIO_PinState)arg_int); break; case U8X8_MSG_BYTE_START_TRANSFER: HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_RESET); __NOP(); // 简单延时,保证建立时间 break; case U8X8_MSG_BYTE_END_TRANSFER: HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_SET); break; default: return 0; } return 1; }重点解释几个消息类型:
U8X8_MSG_BYTE_SEND:发送一批数据,长度由arg_int给出,数据指针在arg_ptrU8X8_MSG_BYTE_SET_DC:设置DC引脚电平,arg_int为1表示数据,0表示命令START/END_TRANSFER:每次事务前后拉低/拉高CS
注意:不要省略START和END中的CS操作。有些教程图省事直接全程拉低CS,会导致多设备共用SPI总线时出问题。
实例化并点亮屏幕
现在轮到最关键的一步:创建u8g2对象,并调用初始化函数。
假设你用的是常见的1.3寸SSD1306 128x64 OLED,且使用硬件SPI:
u8g2_t u8g2; void display_init(void) { u8g2_Setup_ssd1306_i2c_128x64_noname_f( &u8g2, U8G2_R0, // 无旋转 u8g2_spi_transfer_cb, // 刚写的SPI回调 u8x8_gpio_and_delay_cb // 内建的GPIO+延时回调 ); u8g2_InitDisplay(&u8g2); // 发送初始化序列 u8g2_SetPowerSave(&u8g2, 0); // 关闭休眠 }等一下!函数名是ssd1306_i2c_...,但我用的是SPI?这不是搞错了吗?
别担心,这只是命名习惯。u8g2的命名规则是“控制器_默认接口_分辨率_变体”,但只要你传入的是SPI回调函数,底层就会走SPI通信路径。
如果你想更明确一点,也可以使用专门的SPI模板函数(如果有):
// 更准确的写法(视版本而定) u8g2_Setup_ssd1306_128x64_noname_f(&u8g2, U8G2_R0, u8g2_spi_transfer_cb, u8x8_gpio_and_delay_cb);确认头文件包含正确,并链接了u8g2源码后,就可以开始绘图了。
绘图循环:避免撕裂的关键
u8g2采用“页循环”机制来刷新画面,防止闪烁和撕裂。
void loop_draw(void) { do { u8g2_FirstPage(&u8g2); do { u8g2_DrawStr(&u8g2, 0, 20, "Welcome!"); u8g2_DrawCircle(&u8g2, 64, 32, 10, U8G2_DRAW_ALL); } while (u8g2_NextPage(&u8g2)); } while(0); // 改为while(1)持续刷新 }这里面有两个do-while,有点绕。其实逻辑很简单:
- 外层控制是否重绘
- 内层是真正的绘图区,每次
NextPage()触发一次DMA/SPI传输
每调一次u8g2_NextPage(),就会把当前页的数据刷到屏幕上。如果是全屏刷新,总共可能分8页(每页8行像素)。
遇到问题怎么办?这些坑我替你踩过了
屏幕黑屏 or 花屏?
先问自己三个问题:
DC引脚接对了吗?
- 很多人把DC接到固定高电平,结果只能显示不能发命令。
- 必须通过GPIO动态控制!SPI Mode配对了吗?
- 抓个逻辑分析仪看看波形,SCK空闲是不是低电平?第一个边沿是不是采样?复位时序够吗?
- SSD1306上电后需要约100ms才能响应命令。
- 如果你没接RST脚,请在初始化前加HAL_Delay(100);
刷新卡顿严重?
典型症状:动画掉帧、界面反应迟钝。
原因多半是你用了阻塞式SPI传输 + 全缓冲模式。
解决方案有三招:
- 提升SPI速率:把分频从
/4降到/2(前提是信号质量允许) - 启用DMA(进阶):让SPI后台传输,CPU腾出来干别的
- 改用页模式:减少RAM占用,每次只更新一页内容
例如,改用单页缓冲版本:
u8g2_Setup_ssd1306_128x64_noname_1(&u8g2, ...); // 尾缀_1表示单页这样RAM消耗从1KB降到仅128字节,对小容量MCU非常友好。
设计建议:让你的显示系统更健壮
电源噪声要当心
OLED对电源极其敏感。我在调试时曾遇到屏幕随机闪动,查了半天才发现是共用了DC-DC给多个模块供电。
建议:
- 单独用LDO给OLED供电
- VCC端加0.1μF陶瓷电容 + 10μF钽电容滤波
- 走线尽量短,远离电机、继电器等干扰源
PCB布局小技巧
- MOSI和SCK走线等长,防止相位偏移
- CS和DC作为普通GPIO,也尽量靠近SPI接口区域
- 地平面完整铺铜,降低回路阻抗
可移植性设计
别把引脚写死在回调函数里!用宏封装起来:
#define OLED_CS_LOW() HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_RESET) #define OLED_CS_HIGH() HAL_GPIO_WritePin(OLED_CS_GPIO_Port, OLED_CS_Pin, GPIO_PIN_SET) #define OLED_SET_DC(dc) HAL_GPIO_WritePin(OLED_DC_GPIO_Port, OLED_DC_Pin, (dc)?GPIO_PIN_SET:GPIO_PIN_RESET)以后换芯片或改板子,改宏就行,不用动一行核心逻辑。
写在最后
看到这里,你应该已经掌握了在STM32上用SPI驱动u8g2的核心能力。
总结一下关键点:
- DC引脚必须可控,否则命令和数据分不清;
- SPI Mode 0 是标配,别轻易改动;
- 回调函数要完整实现 START/END 和 SET_DC;
- 合理选择缓冲模式,平衡性能与内存;
- 重视电源和布局,小屏也有大学问。
当你第一次看到“Hello, u8g2!”清晰地出现在那块小小的OLED上时,那种成就感,值得所有的折腾。
如果你正在做一个带界面的小项目,不妨试试加上这块屏。也许下一次,你就能做出属于自己的智能手表、迷你示波器,或是带菜单的遥控器。
技术就是这样一步步积累的。今天点亮一块屏,明天也许就能点亮更大的世界。
如果你在实现过程中遇到了其他挑战,欢迎在评论区分享讨论。