4. 帧缓冲(Framebuffer)设计:内存映射模型、单色位图结构、页缓冲与全缓冲策略
帧缓冲,说白了就是一块专门用来存放像素数据的内存区域。MCU 往里面写数据,OLED 驱动芯片从里面读数据,一拍即合。我刚开始做 OLED 驱动时,总觉得这玩意儿不就是个数组嘛,有啥好设计的?后来踩了坑才明白——缓冲区的组织方式,直接决定了你的显示刷新率、内存开销,甚至代码的复杂度。
这一节,我们就把帧缓冲的底裤扒干净。从内存映射模型讲起,再到单色位图的结构,最后对比页缓冲和全缓冲两种策略。嗯,都是实战中绕不开的东西。
4.1 内存映射模型:显存与像素的对应关系
先问一个问题:MCU 往地址 0x00 写了一个字节 0xFF,屏幕左上角会亮几个像素?
答案取决于驱动芯片的 内存映射模型。不同的芯片,映射方式可能完全不同。我见过不少新手,拿着 SSD1306 的代码往 SH1106 上移植,结果画面错位,就是因为没搞清楚映射关系。
常见的映射模型有两种:
| 映射模型 | 描述 | 典型芯片 |
|---|---|---|
| 线性映射 | 显存地址连续,每个字节对应一列上的 8 个像素(垂直排列) | SSD1306, SH1106 |
| 行列映射 | 显存地址按行排列,每个字节对应一行上的 8 个像素(水平排列) | 某些自定义驱动 IC |
以最常用的 SSD1306 为例,它的映射模型是这样的:
- 显存被划分为 8 个 页(Page),每页对应 8 行像素。
- 每个字节的 bit0 对应页内的第 0 行,bit1 对应第 1 行……bit7 对应第 7 行。
- 地址从 0x00 到 0x7F(128 字节),对应屏幕的 128 列。
画个图你就明白了:
Memory Mapping 章节。搞清楚字节和像素的对应关系,后面写驱动才不会跑偏。
4.2 单色位图结构:从字节到像素的转换
单色 OLED 的位图结构,说白了就是 1 bit 对应 1 个像素。0 表示灭,1 表示亮。128×64 分辨率的屏幕,总共需要 128 × 64 / 8 = 1024 字节。
但这里有个坑:位图数据在内存中的排列方式,不一定和你在屏幕上看到的顺序一致。我曾经在项目里用取模软件生成了一幅 128×64 的图片,直接刷进 SSD1306,结果画面是镜像的——因为取模软件默认是 水平扫描,而 SSD1306 是 垂直扫描。
常见的位图排列方式有两种:
| 排列方式 | 说明 | 适用场景 |
|---|---|---|
| 列扫描(Page 模式) | 字节内 bit0~bit7 对应同一列上的 8 个像素 | SSD1306, SH1106 |
| 行扫描 | 字节内 bit0~bit7 对应同一行上的 8 个像素 | 某些 LCD 模块、自定义协议 |
来看一个实际的例子。假设我们要在屏幕左上角显示一个 8×8 的字母 "A":
// 列扫描模式(SSD1306 适用)
// 字节内 bit0 在最下面,bit7 在最上面
const uint8_t letter_A_col[] = {
0x00, // 列 0: 全灭
0x18, // 列 1: 00011000
0x24, // 列 2: 00100100
0x42, // 列 3: 01000010
0x7E, // 列 4: 01111110
0x42, // 列 5: 01000010
0x42, // 列 6: 01000010
0x00 // 列 7: 全灭
};
4.3 页缓冲策略:省内存但操作繁琐
页缓冲,就是只开辟 一页(8 行) 的缓冲区。MCU 先往这一页写数据,写完后通过 SPI/I2C 刷到 OLED 的对应页,然后切换到下一页,重复操作。
这种策略的优点很明显:
- 内存占用极小:128×64 屏幕,页缓冲只需要 128 字节。
- 适合资源受限的 MCU:比如 1KB RAM 的 ATtiny 系列。
但缺点也很要命:
- 操作繁琐:每次修改像素,都要先计算它属于哪一页,然后找到对应的缓冲区位置。
- 跨页操作困难:如果你要画一条跨越页边界的竖线,得拆分成两段分别处理。
来看一个页缓冲的写入流程:
// 页缓冲写入示例(伪代码)
#define PAGE_SIZE 128
uint8_t page_buffer[PAGE_SIZE]; // 仅 128 字节
void page_buffer_set_pixel(uint8_t x, uint8_t y, uint8_t color) {
uint8_t page = y / 8; // 计算所在页
uint8_t bit = y % 8; // 计算页内行号
if (color) {
page_buffer[x] |= (1 << bit);
} else {
page_buffer[x] &= ~(1 << bit);
}
}
void page_buffer_flush(uint8_t page) {
// 设置 OLED 的页地址
oled_write_command(0xB0 + page);
// 设置列起始地址(低4位 + 高4位)
oled_write_command(0x00);
oled_write_command(0x10);
// 批量发送 128 字节数据
oled_write_data(page_buffer, PAGE_SIZE);
}
4.4 全缓冲策略:内存换性能
全缓冲,就是直接开辟 整个显存 的缓冲区。对于 128×64 的屏幕,就是 1024 字节。MCU 先在本地缓冲区里随便画,画完之后一次性刷到 OLED。
全缓冲的好处,用过的人都知道:
- 操作简单:像素坐标直接映射到缓冲区索引,不用管什么页不页的。
- 支持复杂图形:画圆、画线、图像叠加,随便折腾。
- 无撕裂:所有修改都在本地完成,刷屏时一次性输出,画面不会出现半截更新半截旧的撕裂现象。
但代价也很实在:
- RAM 开销大:1024 字节对于某些低端 MCU 来说,已经是奢侈了。
- 刷屏耗时:全缓冲刷新需要传输 1024 字节,I2C 模式下可能耗时几十毫秒。
全缓冲的像素操作就直观多了:
// 全缓冲写入示例
#define FB_WIDTH 128
#define FB_HEIGHT 64
#define FB_SIZE (FB_WIDTH * FB_HEIGHT / 8)
uint8_t framebuffer[FB_SIZE]; // 1024 字节
void fb_set_pixel(uint8_t x, uint8_t y, uint8_t color) {
// 直接计算字节索引和位偏移
uint16_t index = x + (y / 8) * FB_WIDTH;
uint8_t bit = y % 8;
if (color) {
framebuffer[index] |= (1 << bit);
} else {
framebuffer[index] &= ~(1 << bit);
}
}
void fb_flush_all(void) {
// 设置页地址为 0
oled_write_command(0xB0);
oled_write_command(0x00);
oled_write_command(0x10);
// 一次性发送全部 1024 字节
for (uint16_t i = 0; i < FB_SIZE; i++) {
oled_write_data_byte(framebuffer[i]);
}
}
4.5 两种策略的对比与选择
你可能会问:到底该用页缓冲还是全缓冲?
我的建议是:看你的 MCU 资源。如果 RAM 超过 4KB,闭着眼睛上全缓冲,开发效率高得多。如果 RAM 只有 128 字节,那就老老实实用页缓冲,精打细算每一字节。
| 对比维度 | 页缓冲 | 全缓冲 |
|---|---|---|
| RAM 占用 | 128 字节(128×64) | 1024 字节(128×64) |
| 像素操作复杂度 | 需要计算页和位偏移 | 直接索引,简单直观 |
| 跨页图形 | 需要拆分处理,容易出 bug | 无感,统一处理 |
| 刷屏速度 | 逐页刷新,可局部更新 | 全量刷新,数据量大 |
| 适用 MCU | ATtiny, STM8, 低端 PIC | STM32, ESP32, 高端 ARM |
最后说一句:不管用哪种策略,帧缓冲的接口一定要封装好。set_pixel()、draw_line()、fill_rect() 这些函数,上层应用只管调用,底层实现是页缓冲还是全缓冲,对应用层透明。这样后期想切换策略,改底层就行了,上层代码不用动。