0%

STM32——OLED屏幕:CH1116驱动原理与波特律动驱动库

本篇主要学习:驱动一块 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
2
3
4
5
6
7
8
        列 0    列 1    列 2    ...  列 127
页 0 ┌──────┬──────┬──────┬─────┬──────┐
│byte0 │byte1 │byte2 │ ... │byte127│ ← 8 行像素
页 1 ├──────┼──────┼──────┼─────┼──────┤
. │ ... │ ... │ ... │ ... │ ... │
. ├──────┼──────┼──────┼─────┼──────┤
页 7 │ ... │ ... │ ... │ ... │ ... │
└──────┴──────┴──────┴─────┴──────┘

为什么要分页?因为一个字节正好 8 位——一字节数据就能表示某一页某一列 8 个像素的亮灭。比如告诉芯片”第 0 页第 3 列设为 0xC1(1100 0001)”,那么这一列从上到下第 0、1、7 位对应的像素亮起,其余熄灭。

I2C 通信格式:指令 0x00 开头,数据 0x40 开头

CH1116 的 I2C 地址是 0x7A。跟它通信分两类:

  1. 指令:I2C 地址 + 0x00 + 指令字节。比如:
    • 设置页地址:0xB0 ~ 0xB7(第 0 页是 0xB0,第 7 页是 0xB7)
    • 设置列地址:分两次发送——先发低 4 位(如 0x0A 表示低 4 位是 A),再发高 4 位(如 0x15 表示高 4 位是 5),合起来列地址 = 0x5A
  2. 数据:I2C 地址 + 0x40 + 任意数量数据字节,芯片按当前的页/列地址把数据代表的像素亮灭显示出来。

记忆方法:0x00 是”命令门牌号”,0x40 是”数据门牌号”——告诉芯片接下来要进来的字节是干什么的。

列地址自动 +1——“偷懒”神器

CH1116 有个非常方便的特性:写完一字节的 8 个像素后,列地址自动加 1。这样我们只需要把页地址设为 0、列地址设为 0,然后一股脑发送 128 个字节,就完成了一整页的设置;页地址递增,循环 8 次,就点亮了整块屏幕。


CubeMX 配置

1. I2C 提速:标准模式 → 快速模式

I2C1 之前已经用过了,这集只需要做一个小改动:OLED 数据量大(每帧要发 1KB+),把 I2C 速度从标准模式(100K)改为快速模式(400K)。

image-20260803164922648

2. 时钟:把系统时钟配到 72MHz

截止到本集,我们的 STM32 一直跑在内部时钟 HSI 8MHz 上。为了跑得更快,这集要切换到学习板上的外部晶振(HSE 8MHz)并倍频到 72MHz:

  1. System Core → RCC → 把 High Speed Clock (HSE) 设为 Crystal/Ceramic Resonator(外部晶振)image-20260803165228659
  2. Clock Configuration → 把 HCLK 输入框改为 72屏幕截图 2026-08-03 165239
  3. 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
2
3
4
5
6
7
8
9
10
11
12
// OLED器件地址
#define OLED_ADDRESS 0x7A

void OLED_Send(uint8_t *data, uint8_t len) {
HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDRESS, data, len, HAL_MAX_DELAY);
}

void OLED_SendCmd(uint8_t cmd) {
static uint8_t sendBuffer[2] = {0};
sendBuffer[1] = cmd;
OLED_Send(sendBuffer, 2);
}

OLED_SendCmd 里:sendBuffer[0] 恒为 0x00(代表指令),sendBuffer[1] 放指令本体,一起通过 I2C 发出去。注意 static——非阻塞上下文的老朋友了,这里虽然是轮询发送,但 static 保证 buffer 不被栈反复分配。

2. 显存(Frame Buffer)——先在内存里”画画”

思路转变:不要直接往屏幕写点,而是先在内存里维护一块”画布”(显存),画完再一次性刷到屏幕。显存大小正好对应屏幕:

1
2
3
#define OLED_PAGE 8            // OLED页数
#define OLED_COLUMN 128 // OLED列数
uint8_t OLED_GRAM[OLED_PAGE][OLED_COLUMN]; // 显存: 8页 × 128列

清空画布(用 memset,比手写双层 for 循环高效):

1
2
3
void OLED_NewFrame() {
memset(OLED_GRAM, 0, sizeof(OLED_GRAM));
}

把画布刷到屏幕(利用列地址自动 +1 的特性):

1
2
3
4
5
6
7
8
9
10
11
void OLED_ShowFrame() {
static uint8_t sendBuffer[OLED_COLUMN + 1];
sendBuffer[0] = 0x40; // 数据门牌号
for (uint8_t i = 0; i < OLED_PAGE; i++) {
OLED_SendCmd(0xB0 + i); // 页地址: 第 i 页
OLED_SendCmd(0x02); // 列地址低4位
OLED_SendCmd(0x10); // 列地址高4位
memcpy(sendBuffer + 1, OLED_GRAM[i], OLED_COLUMN);
OLED_Send(sendBuffer, OLED_COLUMN + 1); // 整页 128 字节一次发完
}
}

设置一个像素(写显存的核心):

1
2
3
4
5
6
7
8
void OLED_SetPixel(uint8_t x, uint8_t y, OLED_ColorMode color) {
if (x >= OLED_COLUMN || y >= OLED_ROW) return; // 越界保护
if (!color) {
OLED_GRAM[y / 8][x] |= 1 << (y % 8); // 亮: 置1
} else {
OLED_GRAM[y / 8][x] &= ~(1 << (y % 8)); // 灭: 清0
}
}

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
2
3
4
5
6
OLED_DrawLine(0, 0, 127, 63, OLED_COLOR_NORMAL);        // 画线
OLED_DrawCircle(64, 32, 20, OLED_COLOR_NORMAL); // 画圆
OLED_DrawRectangle(10, 10, 50, 30, OLED_COLOR_NORMAL); // 画矩形
OLED_DrawEllipse(64, 32, 30, 15, OLED_COLOR_NORMAL); // 画椭圆
OLED_DrawImage(43, 0, &bilibiliImg, OLED_COLOR_NORMAL); // 画图片
OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL); // 直接打印中文!

驱动库里 OLED_DrawLine 用的是 Bresenham 直线算法(只做整数加减和移位,不碰浮点和乘法——单片机画图的经典算法),圆、椭圆也都是基于它的中点画圆法变体。

4. main.c 主程序

1
2
3
4
5
6
7
8
9
10
11
12
13
/* USER CODE BEGIN 2 */
HAL_Delay(20); // OLED 上电比 STM32 慢,要等 20ms 再初始化
OLED_Init();
/* USER CODE END 2 */

/* USER CODE BEGIN WHILE */
while (1)
{
OLED_NewFrame(); // 清空画布
OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL);
OLED_ShowFrame(); // 刷屏
}
/* USER CODE END WHILE */

5. 中文是怎么显示出来的(取模助手干了什么)

波特律动取模助手生成的中文字模,每个字模的前 4 个字节存的是这个字的 UTF-8 编码:

1
2
// "道" 的 UTF-8 编码是 0xE9 0x81 0x93,后面 32 字节是 16×16 的字模数据
{0xe9, 0x81, 0x93, 0x00, 0x40, 0x40, 0x42, 0xcc, ...}

驱动库的 OLED_PrintString 遍历字符串,用 _OLED_GetUTF8Len 识别每个中文字符的 UTF-8 编码长度(3 字节),在字库里逐条比对前 4 字节找到对应字模,再 OLED_SetBlock 画到显存。这就是为什么可以直接写中文字符串而不需要查表——前提是:

  1. 编译器字符集设置为 UTF-8
  2. 字模用波特律动取模助手生成(带 UTF-8 前缀的格式)

传统 PCtoLCD2002 之类软件没有这个设计,显示中文得一个一个字查坐标,这也是 keysking 写这个取模助手的初衷。


数据流全链路:一个字是怎么一步步显示到屏幕上的

上面讲了一堆零件,现在把它们串起来看主线。就以 main.c 里这行代码为例:

1
OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL);

“点”这个字从代码到屏幕,一共经过 6 步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
"点"字 (UTF-8 编码: E7 82 B9)
│ ① 取模(离线,取模助手干的事)
│ 字符 → 16×16 像素点阵 → 32 字节字模数据
▼
font.c: {0xe7,0x82,0xb9,0x00, 0x00,0x7c,0x44,...} ← 前4字节是UTF-8码
│ ② 识别(OLED_PrintString 遍历字符串)
│ _OLED_GetUTF8Len 判断是3字节中文 → 字库里 memcmp 比对找到字模
▼
找到的字模数据(32字节像素数据)
│ ③ 写显存(OLED_SetBlock → OLED_SetBits 逐位写入)
│ OLED_GRAM[y/8][x] |= 1 << (y%8)
▼
显存 OLED_GRAM[8][128] ← 内存里的"画布",此时屏幕毫无变化
│ ④ 刷屏(OLED_ShowFrame 逐页组织报文)
│ 每页: 0xB0+i 设页地址 → 0x02/0x10 设列地址 → 0x40 + 128字节数据
▼
I2C 报文(8 页 × 129 字节)
│ ⑤ 传输(HAL_I2C_Master_Transmit 硬件时序)
│ 起始位 → 从机地址0x7A → 数据 → 停止位,走 PB6/PB7 两根线
▼
CH1116 芯片内部显存
│ ⑥ 点亮(驱动芯片逐位映射)
│ 每字节 8 位 → 当前页当前列从上到下 8 个像素的亮灭
▼
屏幕上出现"点"字 ✨

图片的路径更短——没有第 ② 步的字符识别,因为图片自带位置信息:

1
2
3
4
bilibiliImg(Image 结构体 {w=43, h=26, data})
│ OLED_DrawImage(x, y, &bilibiliImg, COLOR) → OLED_SetBlock
▼
显存 → 刷屏 → I2C → CH1116 → 屏幕

体会一下这个设计:第 ① 步取模在电脑上离线完成,第 ②~⑤ 步全是内存搬运和通信,第 ⑥ 步由芯片硬件完成——**单片机的职责只是”把数据搬到该去的地方”**,真正把数据变成光的是驱动芯片。这也是”CPU 管逻辑、芯片管硬件”的典型分工。


实操:把文字和本地图片显示到屏幕上

1. 显示文字(以”点灯大王的频道”为例)

第 1 步:取模——打开波特律动 LED 取模助手,进入”字体取模”:

  1. 选一个字体,设置宽高(如 16×16)
  2. 取模方式选 列行式
  3. 阴阳码选 阴码(黑底白字)
  4. 生成模板一定要选”波特律动OLED驱动” ⚠️——只有这个模板会让字模前 4 字节带上字符的 UTF-8 编码,OLED_PrintString 才能直接识别中文字符串;用其他模板就得像传统库一样一个字符一个字符查坐标
  5. 输入需要的文字(如”点灯大王的频道”),复制生成的字模数据

image-20260803171108903

第 2 步:粘贴进工程——打开 font.c,把数据追加到中文字库数组里:

1
2
3
4
// font.c —— 追加新字模(16×16,每字模 = 4字节UTF-8码 + 32字节数据)
{0xe7,0x82,0xb9,0x00, 0x00,0x7c,0x44,...}, // 点
{0xe7,0x8e,0x8b,0x00, ...}, // 看
...

第 3 步:导出声明——在 font.h 中 extern 导出字体结构体:

1
2
// font.h
extern const Font font16x16; // .w=16, .h=16, .chars=上面数组, .len=字数, .ascii=&afont16x8

若新字体的宽高和已有字体相同(比如还是 16×16),数组名不能重复——要么沿用旧的数组,要么把旧的删掉换新的。若宽高不同,直接新建一个 Font 结构体即可(如 font24x24)。

第 4 步:调用——main.c 里直接写中文字符串:

1
OLED_PrintString(0, 32, "点灯大王的频道", &font16x16, OLED_COLOR_NORMAL);

第 5 步:编译烧录。记得编译器字符集设为 UTF-8(否则中文字符串在编译时就变成乱码,字库比对永远找不到)。

2. 显示本地图片(以 B 站 logo 为例)

第 1 步:取模——取模助手进入”图片取模”:

  1. 选择电脑上的图片
  2. 把图片缩放到合适大小(如 43×26),调节二值化阈值让黑白对比清晰
  3. 取模方式选 列行式(和驱动库 OLED_SetBlock 的排列方式一致),阴阳码按效果切换
  4. 复制生成的图模数据

第 2 步:粘贴 + 定义——数据粘贴到 font.c:

1
2
3
4
5
// font.c
const uint8_t bilibiliData[] = {0x00, 0x00, 0x00, ...};

// font.h
extern const Image bilibiliImg; // Image 结构体: {.w=43, .h=26, .data=bilibiliData}

第 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
2
3
4
5
6
7
8
9
10
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
RCC_OscInitStruct.HSEState = RCC_HSE_ON; // 打开外部晶振
RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1; // 8MHz 不分频
RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; // PLL 输入选 HSE
RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // ×9 → 72MHz
...
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK; // 系统时钟选 PLL
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2; // APB1 = 36MHz
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1; // APB2 = 72MHz
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2); // 72MHz 需要 2 个等待周期

HSE 8MHz 不分频进 PLL,倍频 9 倍 → 72MHz。FLASH_LATENCY_2 是跑 72MHz 时必须的 Flash 等待周期(CPU 比 Flash 快,需要插入等待)。

2. MX_I2C1_Init:快速模式

1
2
hi2c1.Init.ClockSpeed = 400000;              // 400kHz 快速模式(之前是 100000)
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; // 7 位地址模式

I2C 时钟在 APB1 总线上(36MHz),400kHz 快速模式的占空比由 I2C_DUTYCYCLE_2 决定。

3. HAL_I2C_MspInit:引脚复用

1
2
3
4
5
__HAL_RCC_GPIOB_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_6|GPIO_PIN_7; // PB6 = SCL, PB7 = SDA
GPIO_InitStruct.Mode = GPIO_MODE_AF_OD; // 复用开漏(I2C 必须开漏 + 外部上拉)
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; // 高速
__HAL_RCC_I2C1_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 中文字模。

78e624d8ae514f39a6997300d09c2663


踩坑记录

🕳️ 坑 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 显示)的伏笔。


参考资源