前言
上一章我们全面掌握了USART串口通信的全场景开发,实现了STM32与上位机的数据交互,而显示外设则是嵌入式设备人机交互的核心载体,是调试日志、状态显示、菜单交互的必备组件。对应51单片机开发中,我们通常通过软件模拟I2C/SPI时序驱动OLED/LCD屏,存在时序精度差、CPU占用率高、刷新速度慢、代码移植性差的痛点;而STM32内置硬件I2C/SPI控制器,可自动生成标准通信时序,配合DMA实现零CPU占用的高速刷新,完美适配工业场景的显示需求。新手入门显示驱动普遍面临三大痛点:屏不亮无显示、画面乱码花屏、中文显示异常、硬件总线卡死。本章将严格遵循「先协议原理拆解,再驱动逻辑实现」的顺序,联动51单片机对应知识点,从底层到实操全面吃透I2C OLED与SPI LCD的驱动开发,完成字符、图片、中文全场景显示实战。
本章目录
- 一、本章学习目标
- 二、核心知识点
- 2.1 工业显示外设选型与51单片机驱动方案核心对比
- 2.2 I2C协议核心原理与SSD1306 OLED屏驱动逻辑
- 2.3 SPI协议核心原理与ILI9341 LCD屏驱动逻辑
- 2.4 字符/图片/中文显示底层原理与取模规则
- 2.5 HAL库硬件I2C/SPI封装逻辑与核心API深度解析
- 三、STM32CubeMX+Keil5保姆式实操:显示外设全场景驱动实战
- 3.1 工程创建与基础配置
- 3.2 I2C、SPI与GPIO图形化配置
- 3.3 工程代码生成与驱动文件移植
- 3.4 字符/中文/图片显示业务代码编写
- 3.5 编译、烧录与效果验证
- 四、保姆式排错指南
- 五、我的踩坑记录
- 六、课后小练习(附完整标准答案)
- 6.1 基础巩固练习
- 6.2 进阶实战练习
- 七、核心知识点速记
- 八、本章小结
一、本章学习目标
- 掌握I2C、SPI串行通信协议的核心时序与工作原理,对比51单片机软件模拟时序方案的核心差异,理解硬件总线的工程优势
- 吃透0.96寸SSD1306 OLED屏、2.4寸ILI9341 LCD屏的驱动架构与指令规则,能独立完成底层驱动的移植与修改
- 掌握ASCII字符、中文汉字、图片的取模规则与显示逻辑,联动C语言数组、指针、const关键字知识点,实现全类型内容的稳定显示
- 熟练使用HAL库硬件I2C/SPI完成OLED、LCD屏的驱动开发,实现滚动显示、数值实时刷新、图片显示等全场景功能,代码符合工业级规范
- 能独立排查屏不亮、乱码花屏、中文显示异常、硬件总线卡死等高频问题,掌握DMA高速刷屏的实现方法,为后续项目人机交互开发打下坚实基础
二、核心知识点
2.1 工业显示外设选型与51单片机驱动方案核心对比
嵌入式开发中最常用的两款显示外设为0.96寸I2C OLED屏 与2.4寸SPI LCD屏,二者的核心特性与适用场景如下:
| 显示外设 | 核心参数 | 通信接口 | 核心优势 | 工业适用场景 |
|---|---|---|---|---|
| 0.96寸OLED屏 | 128×64分辨率,单色,自发光,无需背光,宽视角,低功耗 | I2C(2线) | 接线极简、功耗极低、驱动简单、无需初始化GRAM,上电即可显示 | 设备状态指示、调试日志显示、小型低功耗设备、工业传感器节点 |
| 2.4寸LCD屏 | 240×320分辨率,16位彩色,TFT液晶,需背光驱动 | SPI(4线+2控制线) | 彩色显示、分辨率高、刷新速度快、可显示图片/视频/复杂界面 | 人机交互菜单、工业控制面板、数据可视化、智能设备交互界面 |
我们从51单片机的驱动方案出发,无缝衔接理解STM32硬件总线驱动的核心优势:
| 驱动方案 | 51单片机软件模拟时序 | STM32硬件I2C/SPI驱动 | 对开发的核心影响 |
|---|---|---|---|
| 时序实现 | 手动翻转IO口,通过延时控制时序,精度差,受主频影响大 | 硬件控制器自动生成标准时序,精度高,不受软件干扰 | 软件模拟时序极易出现通信异常,不同主频的芯片需重新调整延时;硬件时序兼容性极强,代码可跨芯片移植 |
| CPU占用 | 通信全程CPU需循环翻转IO,占用率100%,无法同步执行其他任务 | 仅需配置初始参数,数据收发全程硬件自动完成,配合DMA可实现零CPU占用 | 软件模拟方案刷新大屏时CPU完全卡死,无法同步执行采集、控制逻辑;硬件方案刷新期间CPU可正常执行核心业务,系统实时性大幅提升 |
| 通信速度 | I2C最高约100Kbps,SPI最高约500Kbps,刷新速度极慢,大屏刷新需数百毫秒 | I2C最高400Kbps,SPI最高18Mbps,刷新速度提升数十倍,2.4寸LCD全屏刷新仅需十几毫秒 | 软件模拟方案无法实现流畅的画面刷新、动画效果;硬件方案可实现高速刷屏、图片滑动、菜单切换等流畅交互 |
| 代码复杂度 | 需手动实现起始/停止信号、应答、时序同步,代码量大,逻辑复杂,易出bug | HAL库已封装标准通信API,仅需调用收发函数即可完成通信,代码极简,稳定性强 | 软件模拟方案需开发者完全掌握协议底层时序,开发周期长;硬件方案可快速实现驱动开发,聚焦显示业务逻辑,开发效率大幅提升 |
2.2 I2C协议核心原理与SSD1306 OLED屏驱动逻辑
术语通俗解释:I2C全称集成电路总线,是一种两线式串行半双工通信总线,仅需SCL(串行时钟线)、SDA(串行数据线)两根线即可实现主设备与多个从设备之间的通信,类比一条公路上的主车与多辆从车,SCL是统一的行驶节拍,SDA是传输的货物,通过地址区分不同的从车。
I2C协议核心时序规则
- 总线空闲状态:SCL、SDA均为高电平,上拉电阻保证总线空闲电平;
- 起始信号:SCL为高电平时,SDA从高电平跳变到低电平,启动一次通信;
- 数据传输:SCL为低电平时,SDA可改变数据;SCL为高电平时,SDA数据必须保持稳定,接收端在SCL高电平时采样数据,每次传输8位数据,高位在前;
- 应答信号:每传输8位数据后,接收端会在第9个时钟周期拉低SDA,发送应答信号ACK,确认数据接收成功;
- 停止信号:SCL为高电平时,SDA从低电平跳变到高电平,结束本次通信。
I2C从机地址规则
I2C总线上的每个从设备都有唯一的7位地址,第8位为读写位:0=写操作,1=读操作。工业常用0.96寸SSD1306 OLED屏的默认7位地址为0x3C,8位写地址为0x78,读地址为0x79,部分屏地址为0x3D,对应写地址0x7A。
SSD1306 OLED屏驱动核心逻辑
OLED屏的驱动核心是通过I2C总线向SSD1306控制器发送命令 与数据:
- 命令 :配置OLED屏的工作模式,如显示开关、对比度、扫描方向、地址设置等,通过控制字节
0x00标识; - 数据 :写入GRAM的显示内容,1个字节对应8个像素点,通过控制字节
0x40标识; - 128×64分辨率的OLED屏分为8页(Page0~Page7),每页8行,128列,写入1个字节对应一页中同一列的8个像素点,实现点阵显示。
联动C语言知识点 :OLED的显示内容均以uint8_t类型的数组存储,通过指针遍历数组,批量写入GRAM,实现字符、图片的显示;字模数组需定义为const类型,存储在Flash中,节省宝贵的SRAM内存。
2.3 SPI协议核心原理与ILI9341 LCD屏驱动逻辑
术语通俗解释:SPI全称串行外设接口,是一种四线式串行全双工通信总线,通过SCK(串行时钟)、MOSI(主发从收)、MISO(主收从发)、CS(片选)四根线实现高速通信,类比主设备与从设备之间的专用高速通道,CS是通道开关,SCK是传输节拍,MOSI/MISO是双向数据通道,可同时收发数据。
SPI协议核心时序规则
- 片选控制:通信前拉低对应从设备的CS引脚,选中目标设备,通信结束后拉高CS引脚,释放总线;
- 4种工作模式 :由CPOL(时钟极性)与CPHA(时钟相位)组合决定,工业最常用模式0(CPOL=0,CPHA=0) :
- CPOL=0:总线空闲时SCK为低电平;
- CPHA=0:在SCK的第一个跳变沿(上升沿)采样数据,第二个跳变沿(下降沿)更新数据;
- 全双工传输:每个SCK时钟周期,主设备通过MOSI发送1位数据,同时通过MISO接收1位数据,收发同步完成,传输效率远高于I2C。
ILI9341 LCD屏驱动核心逻辑
2.4寸ILI9341 LCD屏为240×320分辨率、16位RGB565彩色屏,驱动核心分为三部分:
- 控制引脚 :除了SPI总线的SCK、MOSI、CS引脚外,还需2个GPIO引脚:
- DC(数据/命令)引脚:低电平表示传输的是命令,高电平表示传输的是显示数据;
- RES(复位)引脚:低电平复位LCD控制器,上电后需执行硬件复位,保证控制器初始化正常;
- 初始化流程:上电复位后,通过SPI总线向ILI9341控制器发送初始化命令序列,配置像素格式、扫描方向、显示窗口、PLL时钟等参数,初始化完成后开启显示;
- 显示原理:LCD的每个像素点对应16位的RGB565数据(5位红+6位绿+5位蓝),240×320分辨率的全屏共需240×320×2=153600字节数据,通过SPI总线批量写入GRAM,即可实现画面显示。
联动C语言知识点 :LCD的图片、字模均以uint16_t类型的数组存储,通过C语言的循环、指针实现指定区域的批量写入,配合DMA可实现高速刷屏,无需CPU干预数据传输。
2.4 字符/图片/中文显示底层原理与取模规则
所有显示内容的本质都是点阵数据,通过取模软件将字符、汉字、图片转换为MCU可识别的十六进制数组,再写入显示控制器的GRAM,即可实现内容显示,这是所有显示驱动的核心基础。
1. ASCII字符显示原理
ASCII字符为8×16或6×12点阵,每个字符对应一个固定的点阵数组,通过ASCII码值偏移找到对应字符的点阵数据,写入对应坐标的GRAM,即可实现字符显示。常用取模格式:列行式、逆向、高位在前,与OLED/LCD的扫描方向匹配。
2. 中文汉字显示原理
中文显示的核心是GB2312编码与字模库:
- 每个中文汉字对应GB2312编码中的一个区位码,常用16×16或24×24点阵,每个汉字对应32字节或72字节的点阵数组;
- 字模库是按GB2312编码顺序排列的汉字点阵数组,通过汉字的GB2312编码计算偏移量,找到对应点阵数据,实现中文显示;
- 取模核心规则:必须与驱动代码的扫描顺序、字节序完全一致,否则会出现乱码、翻转、错位等问题。
3. 图片显示原理
图片显示的核心是将图片转换为对应分辨率的点阵数组:
- OLED单色图片:将图片转换为128×64分辨率的单色位图,取模生成
uint8_t类型的数组,写入OLED的GRAM即可显示; - LCD彩色图片:将图片转换为240×320分辨率的16位RGB565格式位图,取模生成
uint16_t类型的数组,批量写入LCD的GRAM即可显示; - 取模核心要求:图片分辨率必须与屏的显示区域匹配,色彩格式必须与驱动配置一致,否则会出现花屏、颜色错乱等问题。
2.5 HAL库硬件I2C/SPI封装逻辑与核心API深度解析
HAL库将I2C/SPI的底层寄存器操作封装为标准化的API函数,无需手动模拟时序,仅需简单调用即可完成数据收发,大幅提升开发效率与代码稳定性。
1. 硬件I2C核心API
| HAL库API函数 | 核心功能 | 适用场景 |
|---|---|---|
HAL_I2C_Init(I2C_HandleTypeDef *hi2c) |
I2C外设初始化,配置时钟频率、地址模式、自身地址 | 工程初始化阶段配置I2C参数 |
HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
向I2C从设备的指定寄存器写入数据,自动处理起始、停止、应答时序 | OLED屏命令/数据写入,核心驱动函数 |
HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
从I2C从设备的指定寄存器读取数据 | 读取OLED控制器状态、I2C传感器数据 |
HAL_I2C_Mem_Write_DMA(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint16_t MemAddress, uint16_t MemAddSize, uint8_t *pData, uint16_t Size) |
DMA模式写入数据,全程零CPU占用 | 高速批量数据写入,OLED全屏刷新 |
OLED驱动核心调用示例:
c
// 向OLED写入命令
HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, cmd, 1, 100);
// 向OLED写入显示数据
HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x40, I2C_MEMADD_SIZE_8BIT, data_buf, 128, 100);
2. 硬件SPI核心API
| HAL库API函数 | 核心功能 | 适用场景 |
|---|---|---|
HAL_SPI_Init(SPI_HandleTypeDef *hspi) |
SPI外设初始化,配置模式、波特率、数据位宽、时钟极性/相位 | 工程初始化阶段配置SPI参数 |
HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
轮询模式发送数据,阻塞式等待发送完成 | LCD命令/短数据发送,简单场景 |
HAL_SPI_Receive(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout) |
轮询模式接收数据 | 读取SPI设备寄存器、传感器数据 |
HAL_SPI_Transmit_DMA(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size) |
DMA模式发送数据,全程零CPU占用 | LCD全屏刷新、图片显示,高速批量数据传输 |
LCD驱动核心调用示例:
c
// 写LCD命令:DC拉低,发送1字节命令
LCD_DC_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
// 写LCD数据:DC拉高,发送批量数据
LCD_DC_HIGH();
HAL_SPI_Transmit_DMA(&hspi1, pic_buf, 153600);
三、STM32CubeMX+Keil5保姆式实操:显示外设全场景驱动实战
本次实操适配STM32F103C8T6核心板,实现两大核心功能:① 0.96寸I2C OLED屏字符、中文、滚动显示;② 2.4寸SPI LCD屏字符、中文、图片显示,全程无跳步,零基础可零报错跟随完成。
硬件说明
| 显示外设 | 引脚分配 | 核心参数 | 接线说明 |
|---|---|---|---|
| 0.96寸OLED屏 | SCL→PB6(I2C1_SCL)、SDA→PB7(I2C1_SDA) | SSD1306控制器,128×64分辨率,I2C地址0x3C | VCC接3.3V,GND接核心板GND,严禁接5V |
| 2.4寸LCD屏 | SCK→PA5(SPI1_SCK)、MOSI→PA7(SPI1_MOSI)、CS→PA4、DC→PA2、RES→PA3 | ILI9341控制器,240×320分辨率,16位RGB565 | VCC接5V,背光接3.3V,GND接核心板GND |
3.1 工程创建与基础配置
- 打开STM32CubeMX,点击
ACCESS TO MCU SELECTOR,搜索选择STM32F103C8T6,点击Start Project创建工程。 - 调试接口配置:点击左侧
System Core -> SYS,Debug选项选择Serial Wire,开启SWD串行调试。 - 时钟配置:点击
RCC,HSE选项选择Crystal/Ceramic Resonator(外部8MHz晶振);进入Clock Configuration选项卡,配置PLL倍频为x9,系统时钟设置为72MHz,I2C、SPI外设时钟均为72MHz,无红色错误提示。
3.2 I2C、SPI与GPIO图形化配置
- I2C1配置:
- 点击左侧
Connectivity -> I2C1,Mode选择I2C; - 配置参数:I2C Speed Mode为
Fast Mode,I2C Clock Speed为400 KHz,其余保持默认; - 引脚自动映射为PB6(I2C1_SCL)、PB7(I2C1_SDA),均为复用开漏输出模式,自动开启上拉。
- 点击左侧
- SPI1配置:
- 点击左侧
Connectivity -> SPI1,Mode选择Full-Duplex Master(全双工主机模式); - 配置参数:
- Mode:Full-Duplex Master
- Hardware NSS Signal:Disable(软件CS)
- Baud Rate Prescaler:2分频,72MHz/2=36MHz,符合ILI9341最高时钟要求
- Clock Polarity (CPOL):Low
- Clock Phase (CPHA):1 Edge
- Data Size:8 Bits
- First Bit:MSB First
- 引脚自动映射为PA5(SPI1_SCK)、PA6(SPI1_MISO)、PA7(SPI1_MOSI),均为复用推挽输出模式。
- 点击左侧
- GPIO配置:
- PA2、PA3、PA4:均选择
GPIO_Output,推挽输出、高速、默认高电平,User Label分别设为LCD_DC、LCD_RES、LCD_CS。
- PA2、PA3、PA4:均选择
- DMA配置(SPI1发送):
- 点击SPI1配置界面的
DMA Settings,点击Add添加DMA通道:- DMA Request:SPI1_TX
- Channel:DMA1 Channel 3
- Direction:Memory To Peripheral
- Priority:High
- Mode:Normal
- Data Width:Byte,Memory地址自增,Peripheral地址不增
- 点击SPI1配置界面的
- NVIC配置:
- 点击左侧
System Core -> NVIC,优先级分组选择Priority Group 2; - 勾选
I2C1 event interrupt、SPI1 global interrupt、DMA1 channel3 global interrupt,抢占优先级均设为1。
- 点击左侧
3.3 工程代码生成与驱动文件移植
- 工程生成配置:进入
Project Manager,设置全英文无空格的工程名与保存路径,Toolchain/IDE选择MDK-ARM V5;进入Code Generator,勾选Generate peripheral initialization as a pair of '.c/.h' files per peripheral和Keep User Code when re-generating,点击GENERATE CODE生成工程,完成后点击Open Project打开Keil5工程。 - 驱动文件移植:
- 在工程中新建
oled.c、oled.h、lcd.c、lcd.h、font.h五个文件,添加到工程中; font.h文件中存放ASCII字模、中文GB2312字模、图片点阵数组,取模格式严格匹配驱动代码;- 驱动代码核心逻辑见下文,所有代码均适配STM32F103C8T6,可直接复制使用。
- 在工程中新建
OLED核心驱动代码(oled.c)
c
#include "oled.h"
#include "i2c.h"
#include "font.h"
// OLED写命令
void OLED_Write_Cmd(uint8_t cmd)
{
HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x00, I2C_MEMADD_SIZE_8BIT, &cmd, 1, 100);
}
// OLED写数据
void OLED_Write_Data(uint8_t *data, uint16_t len)
{
HAL_I2C_Mem_Write(&hi2c1, 0x78, 0x40, I2C_MEMADD_SIZE_8BIT, data, len, 100);
}
// 设置光标位置
void OLED_Set_Pos(uint8_t x, uint8_t y)
{
OLED_Write_Cmd(0xb0 + y);
OLED_Write_Cmd((x & 0x0f) | 0x00);
OLED_Write_Cmd(((x & 0xf0) >> 4) | 0x10);
}
// OLED清屏
void OLED_Clear(void)
{
uint8_t buf[128] = {0};
for(uint8_t y=0; y<8; y++)
{
OLED_Set_Pos(0, y);
OLED_Write_Data(buf, 128);
}
}
// OLED初始化
void OLED_Init(void)
{
HAL_Delay(100);
OLED_Write_Cmd(0xAE); // 关闭显示
OLED_Write_Cmd(0xd5); // 设置时钟分频因子
OLED_Write_Cmd(0x80);
OLED_Write_Cmd(0xa8); // 设置驱动路数
OLED_Write_Cmd(0x3f); // 1/64 duty
OLED_Write_Cmd(0xd3); // 设置显示偏移
OLED_Write_Cmd(0x00); // 无偏移
OLED_Write_Cmd(0x40); // 设置显示开始行
OLED_Write_Cmd(0x8d); // 电荷泵设置
OLED_Write_Cmd(0x14); // 开启电荷泵
OLED_Write_Cmd(0x20); // 内存地址模式
OLED_Write_Cmd(0x02); // 页地址模式
OLED_Write_Cmd(0xa1); // 段重定义设置,bit0=1,列127对应SEG0
OLED_Write_Cmd(0xc8); // COM扫描方向,从COM[N-1]到COM0
OLED_Write_Cmd(0xda); // 设置COM硬件引脚配置
OLED_Write_Cmd(0x12);
OLED_Write_Cmd(0x81); // 对比度设置
OLED_Write_Cmd(0xcf);
OLED_Write_Cmd(0xd9); // 设置预充电周期
OLED_Write_Cmd(0xf1);
OLED_Write_Cmd(0xdb); // 设置VCOMH取消选择级别
OLED_Write_Cmd(0x30);
OLED_Write_Cmd(0xa4); // 全局显示开启,bit0=1,关闭全局显示
OLED_Write_Cmd(0xa6); // 设置显示方式,bit0=0,正常显示
OLED_Write_Cmd(0xAF); // 开启显示
OLED_Clear();
}
// 显示ASCII字符
void OLED_Show_Char(uint8_t x, uint8_t y, uint8_t ch)
{
uint8_t i = 0;
ch = ch - ' '; // 偏移到空格开始
OLED_Set_Pos(x, y);
OLED_Write_Data(&asc2_1608[ch*16], 8);
OLED_Set_Pos(x, y+1);
OLED_Write_Data(&asc2_1608[ch*16+8], 8);
}
// 显示字符串
void OLED_Show_String(uint8_t x, uint8_t y, char *str)
{
while(*str)
{
OLED_Show_Char(x, y, *str);
x += 8;
if(x > 120)
{
x = 0;
y += 2;
}
str++;
}
}
// 显示16×16中文汉字
void OLED_Show_Chinese(uint8_t x, uint8_t y, uint8_t index)
{
OLED_Set_Pos(x, y);
OLED_Write_Data(&hz_1616[index*32], 16);
OLED_Set_Pos(x, y+1);
OLED_Write_Data(&hz_1616[index*32+16], 16);
}
// 滚动显示
void OLED_Scroll_Left(uint8_t start_page, uint8_t end_page, uint8_t speed)
{
OLED_Write_Cmd(0x2E); // 关闭滚动
OLED_Write_Cmd(0x27); // 向左滚动
OLED_Write_Cmd(0x00); // 虚拟字节
OLED_Write_Cmd(start_page); // 起始页
OLED_Write_Cmd(speed); // 滚动速度
OLED_Write_Cmd(end_page); // 结束页
OLED_Write_Cmd(0x00); // 虚拟字节
OLED_Write_Cmd(0xFF);
OLED_Write_Cmd(0x2F); // 开启滚动
}
LCD核心驱动代码(lcd.c)
c
#include "lcd.h"
#include "spi.h"
#include "font.h"
#include "gpio.h"
// LCD写命令
void LCD_Write_Cmd(uint8_t cmd)
{
LCD_CS_LOW();
LCD_DC_LOW();
HAL_SPI_Transmit(&hspi1, &cmd, 1, 100);
LCD_CS_HIGH();
}
// LCD写数据
void LCD_Write_Data(uint8_t *data, uint16_t len)
{
LCD_CS_LOW();
LCD_DC_HIGH();
HAL_SPI_Transmit(&hspi1, data, len, 1000);
LCD_CS_HIGH();
}
// LCD写16位数据
void LCD_Write_Data16(uint16_t data)
{
uint8_t buf[2] = {data>>8, data&0xFF};
LCD_CS_LOW();
LCD_DC_HIGH();
HAL_SPI_Transmit(&hspi1, buf, 2, 100);
LCD_CS_HIGH();
}
// 设置显示窗口
void LCD_Set_Window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1)
{
LCD_Write_Cmd(0x2A); // 列地址设置
LCD_Write_Data16(x0);
LCD_Write_Data16(x1);
LCD_Write_Cmd(0x2B); // 行地址设置
LCD_Write_Data16(y0);
LCD_Write_Data16(y1);
LCD_Write_Cmd(0x2C); // 写入GRAM
}
// LCD清屏
void LCD_Clear(uint16_t color)
{
uint32_t pixel_num = 240*320;
LCD_Set_Window(0, 0, 239, 319);
LCD_CS_LOW();
LCD_DC_HIGH();
for(uint32_t i=0; i<pixel_num; i++)
{
LCD_Write_Data16(color);
}
LCD_CS_HIGH();
}
// LCD初始化
void LCD_Init(void)
{
LCD_RES_LOW();
HAL_Delay(100);
LCD_RES_HIGH();
HAL_Delay(100);
LCD_Write_Cmd(0xCF);
LCD_Write_Data16(0x00);
LCD_Write_Data16(0xC1);
LCD_Write_Data16(0X30);
LCD_Write_Cmd(0xED);
LCD_Write_Data16(0x64);
LCD_Write_Data16(0x03);
LCD_Write_Data16(0X12);
LCD_Write_Data16(0X81);
LCD_Write_Cmd(0xE8);
LCD_Write_Data16(0x85);
LCD_Write_Data16(0x00);
LCD_Write_Data16(0x78);
LCD_Write_Cmd(0xCB);
LCD_Write_Data16(0x39);
LCD_Write_Data16(0x2C);
LCD_Write_Data16(0x00);
LCD_Write_Data16(0x34);
LCD_Write_Data16(0x02);
LCD_Write_Cmd(0xF7);
LCD_Write_Data16(0x20);
LCD_Write_Cmd(0xEA);
LCD_Write_Data16(0x00);
LCD_Write_Data16(0x00);
LCD_Write_Cmd(0xC0); // 功率控制
LCD_Write_Data16(0x23); // VRH[5:0]
LCD_Write_Cmd(0xC1); // 功率控制
LCD_Write_Data16(0x10); // SAP[2:0];BT[3:0]
LCD_Write_Cmd(0xC5); // VCM控制
LCD_Write_Data16(0x3E);
LCD_Write_Data16(0x28);
LCD_Write_Cmd(0xC7); // VCM控制2
LCD_Write_Data16(0x86);
LCD_Write_Cmd(0x36); // 内存访问控制
LCD_Write_Data16(0x00); // 扫描方向
LCD_Write_Cmd(0x3A); // 像素格式
LCD_Write_Data16(0x55); // 16位RGB565
LCD_Write_Cmd(0xB1); // 帧速率控制
LCD_Write_Data16(0x00);
LCD_Write_Data16(0x18);
LCD_Write_Cmd(0xB6); // 显示功能控制
LCD_Write_Data16(0x08);
LCD_Write_Data16(0x82);
LCD_Write_Data16(0x27);
LCD_Write_Cmd(0xF2); // 3伽马功能关闭
LCD_Write_Data16(0x00);
LCD_Write_Cmd(0x26); // 伽马曲线设置
LCD_Write_Data16(0x01);
LCD_Write_Cmd(0xE0); // 正伽马校正
uint8_t gamma_p[] = {0x1F,0x1A,0x18,0x0A,0x0F,0x06,0x45,0X87,0x32,0x0A,0x07,0x02,0x07,0x05,0x00};
LCD_Write_Data(gamma_p, 15);
LCD_Write_Cmd(0XE1); // 负伽马校正
uint8_t gamma_n[] = {0x00,0x25,0x27,0x05,0x10,0x09,0x3A,0x78,0x4D,0x05,0x18,0x0D,0x38,0x3A,0x1F};
LCD_Write_Data(gamma_n, 15);
LCD_Write_Cmd(0x11); // 退出睡眠
HAL_Delay(120);
LCD_Write_Cmd(0x29); // 开启显示
LCD_Clear(0xFFFF); // 清屏白色
}
// 显示ASCII字符
void LCD_Show_Char(uint16_t x, uint16_t y, uint8_t ch, uint16_t color, uint16_t bg_color)
{
ch = ch - ' ';
LCD_Set_Window(x, y, x+7, y+15);
for(uint8_t i=0; i<16; i++)
{
uint8_t temp = asc2_1608[ch*16+i];
for(uint8_t j=0; j<8; j++)
{
if(temp & (0x80>>j))
LCD_Write_Data16(color);
else
LCD_Write_Data16(bg_color);
}
}
}
// 显示16×16中文
void LCD_Show_Chinese(uint16_t x, uint16_t y, uint8_t index, uint16_t color, uint16_t bg_color)
{
LCD_Set_Window(x, y, x+15, y+15);
for(uint8_t i=0; i<32; i++)
{
uint8_t temp = hz_1616[index*32+i];
for(uint8_t j=0; j<8; j++)
{
if(temp & (0x80>>j))
LCD_Write_Data16(color);
else
LCD_Write_Data16(bg_color);
}
}
}
// 显示240×320全屏图片
void LCD_Show_Pic(uint16_t *pic_buf)
{
LCD_Set_Window(0, 0, 239, 319);
LCD_CS_LOW();
LCD_DC_HIGH();
HAL_SPI_Transmit_DMA(&hspi1, (uint8_t *)pic_buf, 240*320*2);
LCD_CS_HIGH();
}
3.4 字符/中文/图片显示业务代码编写
在main.c文件中编写业务代码,所有代码必须写在用户代码区,避免重新生成时被覆盖。
c
/* USER CODE BEGIN Includes */
#include "oled.h"
#include "lcd.h"
#include "font.h"
/* USER CODE END Includes */
/* USER CODE BEGIN 0 */
// DMA发送完成回调,释放片选
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
if(hspi->Instance == SPI1)
{
LCD_CS_HIGH();
}
}
/* USER CODE END 0 */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_I2C1_Init();
MX_SPI1_Init();
/* USER CODE BEGIN 2 */
// OLED与LCD初始化
OLED_Init();
LCD_Init();
// OLED显示内容
OLED_Show_String(0, 0, "STM32 OLED Test");
OLED_Show_Chinese(0, 2, 0); // 中
OLED_Show_Chinese(16, 2, 1); // 文
OLED_Show_Chinese(32, 2, 2); // 显
OLED_Show_Chinese(48, 2, 3); // 示
OLED_Show_String(0, 4, "Author: STM32");
OLED_Scroll_Left(0, 7, 0x07); // 全屏向左滚动
// LCD显示内容
LCD_Clear(0x0000); // 清屏黑色
LCD_Show_Chinese(10, 10, 0, 0xF800, 0x0000); // 红色中文
LCD_Show_Chinese(30, 10, 1, 0x07E0, 0x0000); // 绿色中文
LCD_Show_Chinese(50, 10, 2, 0x001F, 0x0000); // 蓝色中文
LCD_Show_Chinese(70, 10, 3, 0xFFFF, 0x0000); // 白色中文
LCD_Show_String(10, 40, "STM32 LCD Test", 0xFFFF, 0x0000);
// 显示全屏图片
LCD_Show_Pic(gImage_test);
/* USER CODE END 2 */
while (1)
{
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
}
3.5 编译、烧录与效果验证
- 点击Keil顶部Build按钮(F7快捷键)编译工程,底部提示
0 Error(s), 0 Warning(s),说明编译成功。 - 仿真器配置:点击魔法棒图标(Alt+F7快捷键),Debug选项卡选择
ST-Link Debugger,Settings中确认Port为SW,能识别到芯片;Flash Download选项卡勾选Reset and Run,点击OK保存。 - 硬件接线核心注意事项:OLED屏必须接3.3V,严禁接5V;LCD屏VCC接5V,背光接3.3V;所有外设GND必须与核心板GND共地。
- 效果验证:
- 上电后OLED屏正常点亮,显示字符串、中文汉字,全屏向左滚动;
- LCD屏正常点亮,显示彩色中文、字符串,最终显示全屏测试图片,无花屏、乱码;
- SPI DMA刷屏全程无CPU占用,系统可同步执行其他任务。
四、保姆式排错指南
| 异常现象/报错信息 | 核心根因 | 一步到位解决方法 |
|---|---|---|
| OLED屏完全不亮,无任何显示 | 1. 接线错误,SCL/SDA接反、VCC/GND接反;2. I2C地址错误,0x3C与0x3D不匹配;3. I2C时钟配置错误,超过400KHz;4. 屏硬件损坏,或供电电压错误 | 1. 严格核对接线:SCL→PB6、SDA→PB7,VCC接3.3V,GND共地;2. 更换I2C写地址0x78或0x7A,匹配屏的硬件地址;3. 配置I2C为Fast Mode,400KHz及以下;4. 更换屏测试,确认硬件无损坏 |
| OLED屏点亮,但显示乱码、错位、翻转 | 1. 取模格式与驱动代码不匹配,字节序、扫描方向错误;2. 初始化命令错误,扫描方向、地址模式配置错误;3. 显示坐标超出屏的范围,数据写入错位 | 1. 严格匹配取模格式:列行式、逆向、高位在前,与驱动代码一致;2. 核对初始化命令,配置正确的段重定义与COM扫描方向,解决翻转问题;3. 限制显示坐标在0≤x≤127、0≤y≤7范围内 |
| LCD屏白屏/黑屏,无任何显示 | 1. 接线错误,SPI引脚接反、DC/RES/CS引脚配置错误;2. 初始化命令序列错误,控制器未正常初始化;3. SPI模式配置错误,CPOL/CPHA与屏不匹配;4. 背光引脚未接,背光未开启 | 1. 严格核对接线:SCK→PA5、MOSI→PA7、DC→PA2、RES→PA3、CS→PA4,GND共地;2. 核对ILI9341初始化命令序列,确保时序与参数正确;3. 配置SPI为模式0,CPOL=0、CPHA=0,MSB在前;4. 背光引脚接3.3V,确保背光开启 |
| LCD屏显示花屏、颜色错乱、图片错位 | 1. 取模格式错误,RGB565字节序与驱动不匹配;2. 显示窗口配置错误,行列地址超出屏的范围;3. SPI通信速度过快,数据传输错误;4. CS引脚控制错误,通信期间CS未持续拉低 | 1. 取模时选择RGB565、16位色、高位在前,与驱动代码匹配;2. 配置显示窗口时,列地址0239,行地址0319,禁止超出范围;3. 降低SPI波特率,改为4分频或8分频,提升通信稳定性;4. 通信全程拉低CS引脚,通信结束后再拉高 |
| 中文显示乱码,无法显示正确汉字 | 1. 汉字GB2312编码与字模库的索引不匹配;2. 取模的点阵大小与驱动代码的显示尺寸不匹配;3. 字模数组未定义为const,导致内存溢出;4. 汉字索引计算错误,偏移量不对 | 1. 确保取模的汉字编码为GB2312,与字模库的排列顺序一致;2. 16×16汉字必须匹配16×16的显示函数,禁止混用不同尺寸的字模;3. 字模数组必须加const修饰,存储在Flash中,避免SRAM溢出;4. 严格按汉字在字模库中的顺序设置索引,确保偏移量正确 |
| 硬件I2C通信卡死,程序进入死循环 | 1. I2C总线出现死锁,从设备拉低SDA线无法释放;2. 通信超时时间设置过短,未完成传输就强制退出;3. 未开启I2C事件中断,或中断优先级配置错误 | 1. 增加I2C总线死锁恢复代码,模拟9个时钟周期释放SDA线;2. 增加通信超时时间,设置为100ms以上;3. 开启I2C事件中断,配置合理的抢占优先级,避免中断被屏蔽 |
| DMA刷屏时程序卡死,进入HardFault | 1. 图片数组定义为局部变量,栈空间溢出;2. 数组长度与传输长度不匹配,内存越界;3. DMA传输完成后未释放CS引脚,导致后续SPI通信异常 | 1. 图片数组必须定义为全局const数组,存储在Flash中,禁止使用局部数组;2. 严格核对传输长度为240×320×2字节,与数组长度一致;3. DMA传输完成回调中拉高CS引脚,释放SPI总线 |
五、我的踩坑记录
-
踩坑现象 :第一次驱动OLED屏,接线、配置都检查了,屏就是不亮,I2C通信一直返回超时,折腾了很久都没解决。
底层原因 :我想当然地认为OLED屏的I2C地址是0x3C,写地址是0x3C<<1=0x78,但我手里的屏地址是0x3D,写地址应该是0x7A,地址不匹配,从设备不会应答,I2C通信一直超时。同时我把OLED屏的VCC接到了5V上,这款屏只支持3.3V供电,差点把屏烧了。
最终解决方案:将OLED屏的VCC接到3.3V,把I2C写地址改为0x7A,重新烧录后屏立即点亮,显示完全正常。后来养成习惯,拿到新屏先查手册确认地址和供电电压,避免再踩这个坑。 -
踩坑现象 :OLED屏能点亮,但显示的字符全是乱码,上下翻转,左右错位,完全看不出内容。
底层原因 :我用取模软件生成的字模是行列式、顺向的,而驱动代码用的是列行式、逆向的取模格式,字节序完全不匹配,写入GRAM的数据全是错的,自然显示乱码。同时初始化命令里的扫描方向配置反了,导致画面上下翻转。
最终解决方案:重新设置取模软件,格式改为列行式、逆向、高位在前,和驱动代码完全匹配,同时修改初始化命令,配置正确的段重定义和COM扫描方向,重新生成字模后,显示完全正常,无错位、无翻转。 -
踩坑现象 :LCD屏能点亮,但显示中文和图片时,颜色完全不对,红色变成了蓝色,蓝色变成了红色,画面花屏。
底层原因 :取模软件生成的RGB565数据是低位在前,而驱动代码是高位在前,字节序反了,导致RGB颜色分量完全错位,颜色错乱。同时SPI的波特率配置为2分频36MHz,超过了我手里这款屏的最大SPI时钟,数据传输出现错误,导致花屏。
最终解决方案:取模时选择高位在前的RGB565格式,同时将SPI波特率改为4分频18MHz,符合屏的规格要求,重新生成图片数组后,颜色显示完全正确,无花屏、无错位。 -
踩坑现象 :把字模数组直接定义成了普通的全局数组,结果编译时提示SRAM内存溢出,程序无法下载,甚至下载后芯片直接卡死。
底层原因 :16×16的中文汉字库有几百个汉字,每个汉字32字节,加上图片数组,总大小超过了STM32F103C8T6的20KB SRAM,直接导致内存溢出。普通数组默认存储在SRAM中,而const数组会存储在64KB的Flash中,我完全忽略了这一点。
最终解决方案:所有字模、图片数组全部加上const修饰,存储在Flash中,编译后内存占用大幅降低,不再溢出,程序正常运行,芯片也不会卡死了。
六、课后小练习(附完整标准答案)
6.1 基础巩固练习
练习1:在OLED屏上实现ADC采集电压的实时刷新显示,每秒更新一次。
标准答案:
c
/* USER CODE BEGIN PV */
uint16_t adc_val = 0;
float voltage = 0.0f;
char buf[32] = {0};
/* USER CODE END PV */
/* USER CODE BEGIN WHILE */
while (1)
{
// 采集ADC值
HAL_ADC_Start(&hadc1);
HAL_ADC_PollForConversion(&hadc1, 100);
adc_val = HAL_ADC_GetValue(&hadc1);
HAL_ADC_Stop(&hadc1);
// 计算电压
voltage = (float)adc_val / 4095.0f * 3.3f;
// 格式化字符串
sprintf(buf, "Voltage:%.2fV", voltage);
// 清除对应行,显示新内容
OLED_Set_Pos(0, 6);
OLED_Write_Data((uint8_t *)" ", 16);
OLED_Show_String(0, 6, buf);
// 每秒更新一次
HAL_Delay(1000);
}
/* USER CODE END WHILE */
练习2:在LCD屏上实现24位色深的彩虹渐变背景,叠加显示字符串。
标准答案:
c
// 彩虹渐变背景绘制函数
void LCD_Draw_Rainbow(void)
{
for(uint16_t y=0; y<320; y++)
{
// 按行生成渐变颜色
uint8_t r = (y * 255) / 320;
uint8_t g = 255 - (y * 255) / 320;
uint8_t b = 128;
// 转换为RGB565
uint16_t color = ((r>>3)<<11) | ((g>>2)<<5) | (b>>3);
// 绘制整行
LCD_Set_Window(0, y, 239, y);
for(uint16_t x=0; x<240; x++)
{
LCD_Write_Data16(color);
}
}
}
// 主函数调用
/* USER CODE BEGIN 2 */
LCD_Init();
LCD_Draw_Rainbow();
LCD_Show_String(40, 150, "Rainbow Test", 0x0000, 0xFFFF);
/* USER CODE END 2 */
练习3:实现OLED屏的指定区域清屏,避免全屏清屏导致的闪烁。
标准答案:
c
// 指定区域清屏函数
void OLED_Clear_Area(uint8_t x0, uint8_t y0, uint8_t x1, uint8_t y1)
{
uint8_t buf[128] = {0};
for(uint8_t y=y0; y<=y1; y++)
{
OLED_Set_Pos(x0, y);
OLED_Write_Data(buf, x1 - x0 + 1);
}
}
// 调用示例:清除第2行,0~127列
OLED_Clear_Area(0, 2, 127, 3);
6.2 进阶实战练习
练习1:基于SPI DMA实现LCD图片滑动切换效果,实现左右滑动动画。
标准答案:
c
/* USER CODE BEGIN PV */
// 两张240×320的图片数组
extern const uint16_t gImage_pic1[];
extern const uint16_t gImage_pic2[];
uint8_t slide_offset = 0;
uint8_t slide_dir = 1; // 1右滑,-1左滑
/* USER CODE END PV */
// 滑动显示函数
void LCD_Slide_Show(const uint16_t *pic1, const uint16_t *pic2, uint8_t offset)
{
for(uint16_t y=0; y<320; y++)
{
LCD_Set_Window(0, y, 239, y);
LCD_CS_LOW();
LCD_DC_HIGH();
// 显示pic2的offset列
HAL_SPI_Transmit(&hspi1, (uint8_t *)&pic2[y*240 + 240 - offset], offset*2, 100);
// 显示pic1的剩余列
HAL_SPI_Transmit(&hspi1, (uint8_t *)&pic1[y*240], (240 - offset)*2, 100);
LCD_CS_HIGH();
}
}
// 主循环滑动动画
/* USER CODE BEGIN WHILE */
while (1)
{
if(slide_dir == 1)
{
slide_offset += 8;
if(slide_offset >= 240)
{
slide_offset = 240;
slide_dir = -1;
HAL_Delay(1000);
}
}
else
{
slide_offset -= 8;
if(slide_offset == 0)
{
slide_dir = 1;
HAL_Delay(1000);
}
}
LCD_Slide_Show(gImage_pic1, gImage_pic2, slide_offset);
HAL_Delay(20);
}
/* USER CODE END WHILE */
练习2:实现OLED屏的多级菜单界面,支持按键上下选择、确认进入。
标准答案:
c
/* USER CODE BEGIN PV */
// 菜单列表
char *menu_list[] = {"1. Voltage Check", "2. Temp Monitor", "3. System Set", "4. About"};
uint8_t menu_num = sizeof(menu_list)/sizeof(char *);
uint8_t current_sel = 0; // 当前选中项
uint8_t key_state = 0;
/* USER CODE END PV */
// 菜单刷新函数
void OLED_Menu_Refresh(void)
{
OLED_Clear();
for(uint8_t i=0; i<menu_num; i++)
{
if(i == current_sel)
{
OLED_Show_String(0, i*2, ">"); // 选中箭头
}
OLED_Show_String(8, i*2, menu_list[i]);
}
}
// 主循环按键处理
/* USER CODE BEGIN WHILE */
OLED_Menu_Refresh();
while (1)
{
// 上键:PA0
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
HAL_Delay(20);
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
{
if(current_sel > 0) current_sel--;
OLED_Menu_Refresh();
while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET);
}
}
// 下键:PA1
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET)
{
HAL_Delay(20);
if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET)
{
if(current_sel < menu_num-1) current_sel++;
OLED_Menu_Refresh();
while(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_1) == GPIO_PIN_RESET);
}
}
HAL_Delay(10);
}
/* USER CODE END WHILE */
七、核心知识点速记
- I2C是两线半双工总线,仅需SCL、SDA两根线,通过7位地址区分从设备,OLED屏常用地址0x3C,写地址0x78,通信速度最高400Kbps。
- SPI是四线全双工总线,SCK、MOSI、MISO、CS四根线,工业常用模式0(CPOL=0、CPHA=0),通信速度远高于I2C,适合LCD大屏高速刷新。
- 显示驱动的核心是点阵数据,所有字符、汉字、图片都需要通过取模软件转换为十六进制数组,取模格式必须与驱动代码的扫描顺序、字节序完全一致,否则会出现乱码。
- 字模、图片数组必须加
const修饰,存储在Flash中,避免占用宝贵的SRAM内存,防止内存溢出。 - OLED屏驱动分为命令与数据,0x00标识命令,0x40标识数据,128×64屏分为8页,每页8行,1个字节对应同一列的8个像素。
- LCD屏驱动通过DC引脚区分命令与数据,低电平为命令,高电平为数据,240×320分辨率16位色全屏共153600字节数据,配合DMA可实现零CPU占用高速刷屏。
- 中文显示的核心是GB2312编码与字模库,通过汉字的区位码计算字模数组的偏移量,常用16×16点阵,每个汉字对应32字节数据。
- 硬件I2C必须处理总线死锁问题,通信时增加超时机制,避免程序卡死;SPI通信必须严格控制CS引脚,通信期间拉低,结束后拉高。
- 显示内容实时刷新时,禁止全屏清屏重绘,仅刷新变化的区域,避免屏幕闪烁,提升显示流畅度。
八、本章小结
本章我们深入拆解了I2C、SPI串行通信协议的核心时序,对比51单片机软件模拟时序方案的核心差异,吃透了SSD1306 OLED屏与ILI9341 LCD屏的驱动逻辑,掌握了字符、中文、图片的取模规则与显示实现,完成了HAL库硬件I2C/SPI的全场景驱动开发,解决了屏不亮、乱码花屏、总线卡死等高频问题。本章是I2C、SPI协议的实战应用,下一章我们将深入拆解I2C通信协议的底层时序与主从机通信逻辑,完成AT24C02、MPU6050等工业传感器的驱动开发,全面掌握I2C总线的工业级应用。