本篇主要学习:驱动一块 128×64 的 OLED 屏幕。它同样挂在 I2C1 总线上,所以我们之前学的 I2C 收发全部复用,本篇的核心增量是:
| 和上一篇的区别 | 上一篇(AHT20) | 本篇(OLED) |
|---|---|---|
| 数据方向 | 读传感器数据(Receive) | 写屏幕数据(Transmit) |
| 数据量 | 几字节 | 每帧 1KB(128×64 位) |
| 编程模式 | 轮询→中断→DMA 状态机 | 显存(双缓冲思想) |
| 关键知识 | 状态机、static、extern | 页/列寻址、取模、驱动库 |
硬件原理:屏幕是怎么亮起来的
像素与分辨率
所谓屏幕,其实就是一个个发光小灯组成的阵列,每个小灯叫一个像素。屏幕上小灯的排列数目就是分辨率。这块 OLED 是 128×64——128 列 × 64 行,一共 8192 个像素。
如果像控制 RGB 灯那样一个 IO 控制一个像素,得 8000 多个引脚——显然不可能。所以屏幕都带一颗驱动芯片:我们只需要通过 I2C/SPI 和芯片通信,由它去操纵这 8000 多个小灯的亮灭。keysking学习板上用的是 CH1116,常见的还有 SSD1306,都能驱动 128×64 的 OLED,原理大同小异,只是部分指令和特殊功能有差异。
类比:驱动芯片就像”包工头”——你只要给包工头下达指令(哪个灯亮),不用自己去一个个拉线。
页(Page)结构——1 字节管 8 个像素
CH1116 把屏幕划分为 8 个页(Page),每页 128 列 × 8 行:
1 | 列 0 列 1 列 2 ... 列 127 |
为什么要分页?因为一个字节正好 8 位——一字节数据就能表示某一页某一列 8 个像素的亮灭。比如告诉芯片”第 0 页第 3 列设为 0xC1(1100 0001)”,那么这一列从上到下第 0、1、7 位对应的像素亮起,其余熄灭。
I2C 通信格式:指令 0x00 开头,数据 0x40 开头
CH1116 的 I2C 地址是 0x7A。跟它通信分两类:
- 指令:I2C 地址 +
0x00+ 指令字节。比如:- 设置页地址:
0xB0 ~ 0xB7(第 0 页是 0xB0,第 7 页是 0xB7) - 设置列地址:分两次发送——先发低 4 位(如
0x0A表示低 4 位是 A),再发高 4 位(如0x15表示高 4 位是 5),合起来列地址 = 0x5A
- 设置页地址:
- 数据:I2C 地址 +
0x40+ 任意数量数据字节,芯片按当前的页/列地址把数据代表的像素亮灭显示出来。
记忆方法:
0x00是”命令门牌号”,0x40是”数据门牌号”——告诉芯片接下来要进来的字节是干什么的。
列地址自动 +1——“偷懒”神器
CH1116 有个非常方便的特性:写完一字节的 8 个像素后,列地址自动加 1。这样我们只需要把页地址设为 0、列地址设为 0,然后一股脑发送 128 个字节,就完成了一整页的设置;页地址递增,循环 8 次,就点亮了整块屏幕。
CubeMX 配置
1. I2C 提速:标准模式 → 快速模式
I2C1 之前已经用过了,这集只需要做一个小改动:OLED 数据量大(每帧要发 1KB+),把 I2C 速度从标准模式(100K)改为快速模式(400K)。
2. 时钟:把系统时钟配到 72MHz
截止到本集,我们的 STM32 一直跑在内部时钟 HSI 8MHz 上。为了跑得更快,这集要切换到学习板上的外部晶振(HSE 8MHz)并倍频到 72MHz:
System Core→RCC→ 把High Speed Clock (HSE)设为 Crystal/Ceramic Resonator(外部晶振)
Clock Configuration→ 把HCLK输入框改为 72
- CubeMX 自动配置时钟树:HSE 8MHz × PLL 倍频 9 = 72MHz
注意:要先开 HSE 再改 HCLK。若顺序颠倒,CubeMX 只能用内部时钟 HSI(PLL 输入 4MHz、最大倍频 16 倍,极限 64MHz)配不出 72MHz,会弹出 “no solution found using 72MHz” 提示改用 64MHz。
3. 代码生成设置
老规矩:Project Manager → Code Generator → 勾选 **”Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”**,每个外设单独生成 .c/.h 文件。
核心代码
1. 底层发送函数
1 | // OLED器件地址 |
OLED_SendCmd 里:sendBuffer[0] 恒为 0x00(代表指令),sendBuffer[1] 放指令本体,一起通过 I2C 发出去。注意 static——非阻塞上下文的老朋友了,这里虽然是轮询发送,但 static 保证 buffer 不被栈反复分配。
2. 显存(Frame Buffer)——先在内存里”画画”
思路转变:不要直接往屏幕写点,而是先在内存里维护一块”画布”(显存),画完再一次性刷到屏幕。显存大小正好对应屏幕:
1 |
|
清空画布(用 memset,比手写双层 for 循环高效):
1 | void OLED_NewFrame() { |
把画布刷到屏幕(利用列地址自动 +1 的特性):
1 | void OLED_ShowFrame() { |
设置一个像素(写显存的核心):
1 | void OLED_SetPixel(uint8_t x, uint8_t y, OLED_ColorMode color) { |
y / 8 算出这个像素在第几页,y % 8 算出在页内第几位,然后移位操作这一位——和 AHT20 里”拆字节”的位运算套路一模一样。
类比:显存就是一张草稿纸,
OLED_NewFrame翻新纸,OLED_SetPixel往纸上打点,OLED_ShowFrame把纸上的内容誊到屏幕。程序里再也不用”想显示什么就发什么”,统一先画到草稿纸,最后统一刷新——这就是双缓冲思想,后面做动画、做游戏全靠它。
3. 图形与文字(波特律动驱动库)
上面是手写版原理。实际上这期视频的主角是 keysking 大佬自己写的工具:
- 波特律动 LED 取模助手:https://led.baud-dance.com —— 在线工具,输入文字/选图片,实时生成字模/图模数据(支持列行式/行列式/逐列式/逐行式四种取模方式、阴阳码切换、自定义模板)
- 波特律动 OLED 驱动库(MIT 协议,可闭源商用):
oled.c+font.c,封装好了全套绘制函数
把库里的 oled.c、font.c、oled.h、font.h 拖进工程,注册一下(OLED_Init 之后调用),就能用了:
1 | OLED_DrawLine(0, 0, 127, 63, OLED_COLOR_NORMAL); // 画线 |
驱动库里 OLED_DrawLine 用的是 Bresenham 直线算法(只做整数加减和移位,不碰浮点和乘法——单片机画图的经典算法),圆、椭圆也都是基于它的中点画圆法变体。
4. main.c 主程序
1 | /* USER CODE BEGIN 2 */ |
5. 中文是怎么显示出来的(取模助手干了什么)
波特律动取模助手生成的中文字模,每个字模的前 4 个字节存的是这个字的 UTF-8 编码:
1 | // "道" 的 UTF-8 编码是 0xE9 0x81 0x93,后面 32 字节是 16×16 的字模数据 |
驱动库的 OLED_PrintString 遍历字符串,用 _OLED_GetUTF8Len 识别每个中文字符的 UTF-8 编码长度(3 字节),在字库里逐条比对前 4 字节找到对应字模,再 OLED_SetBlock 画到显存。这就是为什么可以直接写中文字符串而不需要查表——前提是:
- 编译器字符集设置为 UTF-8
- 字模用波特律动取模助手生成(带 UTF-8 前缀的格式)
传统 PCtoLCD2002 之类软件没有这个设计,显示中文得一个一个字查坐标,这也是 keysking 写这个取模助手的初衷。
数据流全链路:一个字是怎么一步步显示到屏幕上的
上面讲了一堆零件,现在把它们串起来看主线。就以 main.c 里这行代码为例:
1 | OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL); |
“点”这个字从代码到屏幕,一共经过 6 步:
1 | "点"字 (UTF-8 编码: E7 82 B9) |
图片的路径更短——没有第 ② 步的字符识别,因为图片自带位置信息:
1 | bilibiliImg(Image 结构体 {w=43, h=26, data}) |
体会一下这个设计:第 ① 步取模在电脑上离线完成,第 ②~⑤ 步全是内存搬运和通信,第 ⑥ 步由芯片硬件完成——**单片机的职责只是”把数据搬到该去的地方”**,真正把数据变成光的是驱动芯片。这也是”CPU 管逻辑、芯片管硬件”的典型分工。
实操:把文字和本地图片显示到屏幕上
1. 显示文字(以”点灯大王的频道”为例)
第 1 步:取模——打开波特律动 LED 取模助手,进入”字体取模”:
- 选一个字体,设置宽高(如 16×16)
- 取模方式选 列行式
- 阴阳码选 阴码(黑底白字)
- 生成模板一定要选”波特律动OLED驱动” ⚠️——只有这个模板会让字模前 4 字节带上字符的 UTF-8 编码,
OLED_PrintString才能直接识别中文字符串;用其他模板就得像传统库一样一个字符一个字符查坐标 - 输入需要的文字(如”点灯大王的频道”),复制生成的字模数据
第 2 步:粘贴进工程——打开 font.c,把数据追加到中文字库数组里:
1 | // font.c —— 追加新字模(16×16,每字模 = 4字节UTF-8码 + 32字节数据) |
第 3 步:导出声明——在 font.h 中 extern 导出字体结构体:
1 | // font.h |
若新字体的宽高和已有字体相同(比如还是 16×16),数组名不能重复——要么沿用旧的数组,要么把旧的删掉换新的。若宽高不同,直接新建一个 Font 结构体即可(如
font24x24)。
第 4 步:调用——main.c 里直接写中文字符串:
1 | OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL); |
第 5 步:编译烧录。记得编译器字符集设为 UTF-8(否则中文字符串在编译时就变成乱码,字库比对永远找不到)。
2. 显示本地图片(以 B 站 logo 为例)
第 1 步:取模——取模助手进入”图片取模”:
- 选择电脑上的图片
- 把图片缩放到合适大小(如 43×26),调节二值化阈值让黑白对比清晰
- 取模方式选 列行式(和驱动库
OLED_SetBlock的排列方式一致),阴阳码按效果切换 - 复制生成的图模数据
第 2 步:粘贴 + 定义——数据粘贴到 font.c:
1 | // font.c |
第 3 步:调用:
1 | OLED_DrawImage(43, 0, &bilibiliImg, OLED_COLOR_NORMAL); |
代码讲解:HAL_I2C_Master_Transmit 参数
全篇最核心的 I2C 发送函数,老面孔但值得再复习一遍:
| 参数 | 含义 | 本项目填的值 |
|---|---|---|
| I2C 句柄指针 | 操作哪个 I2C 外设 | &hi2c1 |
| 从机地址 | 目标设备地址 | OLED_ADDRESS (0x7A) |
| 数据指针 | 要发送的数据首地址 | sendBuffer |
| 数据长度 | 发送字节数 | 2(指令)或 129(整页数据) |
| 超时时间 | 等多久放弃(轮询模式) | HAL_MAX_DELAY(一直等) |
解读初始化代码
1. SystemClock_Config:72MHz 是怎么来的
1 | RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; |
HSE 8MHz 不分频进 PLL,倍频 9 倍 → 72MHz。FLASH_LATENCY_2 是跑 72MHz 时必须的 Flash 等待周期(CPU 比 Flash 快,需要插入等待)。
2. MX_I2C1_Init:快速模式
1 | hi2c1.Init.ClockSpeed = 400000; // 400kHz 快速模式(之前是 100000) |
I2C 时钟在 APB1 总线上(36MHz),400kHz 快速模式的占空比由 I2C_DUTYCYCLE_2 决定。
3. HAL_I2C_MspInit:引脚复用
1 | __HAL_RCC_GPIOB_CLK_ENABLE(); |
复用开漏是 I2C 引脚的关键:I2C 协议靠”释放总线 = 高电平”工作,开漏输出 + 外部上拉电阻实现线与逻辑。
配置对应关系表
| CubeMX 设置 | 生成的代码 | 底层效果 |
|---|---|---|
| HSE: Crystal/Ceramic | RCC_HSE_ON + PLL_MUL9 |
8MHz × 9 = 72MHz 系统时钟 |
| HCLK = 72 | AHBCLKDivider = DIV1 |
AHB = 72MHz |
| I2C1 Clock Speed 400K | ClockSpeed = 400000 |
I2C 快速模式 |
| PB6/PB7 I2C1 | GPIO_MODE_AF_OD |
复用开漏引脚 |
编译 & 烧录
VS Code + CMake 工程,老流程:编译 → 烧录。确认 font.h / oled.h 被 include、font.c / oled.c 参与编译即可。
注意:编译器字符集必须设为 UTF-8,否则 main.c 里的中文字符串会乱码,
OLED_PrintString就找不到字模了(这是驱动库官方注释里反复强调的)。
效果展示
屏幕正中显示”点灯大王的频道”,16×16 中文字模。
踩坑记录
🕳️ 坑 1:OLED 上电比 STM32 慢
STM32 启动(毫秒级)比 OLED 上电快,初始化太早 OLED 没准备好会白屏。所以 OLED_Init() 前加 HAL_Delay(20)。
🕳️ 坑 2:刚上电”花屏”
CH1116 显存里的数据是随机的——数据为 1 的地方像素亮、为 0 的地方熄灭,所以刚点亮时屏幕是杂乱的花屏。OLED_NewFrame()(清显存)+ OLED_ShowFrame() 刷一帧全 0 就干净了。
🕳️ 坑 3:列地址偏移 2 位
学习板上的屏幕为了滚屏等操作,列地址整体左移了 2 位:想点亮”第 0 列”,实际要设置列地址 0x02。所以 OLED_ShowFrame 里列地址低 4 位写的是 0x02 而不是 0x00,第一次手写驱动的人很容易栽在这。
总结
新知识点
| 知识点 | 一句话 |
|---|---|
| 驱动芯片 CH1116 | 通过 I2C 指令操纵 8192 个像素的”包工头” |
| 页结构 | 8 页 × 128 列 × 8 行,1 字节 = 一列 8 像素 |
| 指令/数据格式 | 0x00 开头是指令,0x40 开头是数据 |
| 列地址自动 +1 | 设好页/列后连续发 128 字节 = 刷完一页 |
| 显存(双缓冲) | 先画草稿纸,再一次性刷屏 |
| Bresenham 算法 | 只用整数运算的画线算法 |
| 取模助手 | 波特律动 LED 取模助手生成字模/图模,带 UTF-8 前缀 |
| 时钟树 | HSE 8MHz × PLL9 = 72MHz,HSI 极限 64MHz |
与上一篇的对比
上篇的 AHT20 是”读”数据(Receive + 状态机),本篇的 OLED 是”写”数据(Transmit + 显存);上篇解决”数据怎么来”,本篇解决”数据怎么展示”。两者共用 I2C1 总线——I2C 是一个多从机总线,设备地址不冲突就可以挂在同一条线上,这也是将来温湿度上屏(AHT20 读到 → OLED 显示)的伏笔。


