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 列。

画个图你就明白了:

SSD1306 内存映射模型(128×64 分辨率) Page 0 (行 0-7) Page 1 (行 8-15) Page 2 (行 16-23) ...... 共 8 页 ...... Page 7 (行 56-63) 列 0 列 30 列 64 列 100 列 127 0xFF bit7(行7) ... bit0(行0) 对应 Page 内 8 行像素的亮灭
我的习惯: 拿到一款新 OLED 芯片,第一件事就是看数据手册里的 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);
}
适用场景: 页缓冲最适合 静态文本显示 或 简单图形。如果你只是显示几行文字,页缓冲完全够用,还能省下宝贵的 RAM。

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
我的经验: 在 STM32F103 上做 OLED 显示时,我习惯用全缓冲。1024 字节对 20KB RAM 来说不算什么,但换来的是代码可读性和维护性的大幅提升。只有在 ATtiny85 这种 512 字节 RAM 的芯片上,我才会用页缓冲。

最后说一句:不管用哪种策略,帧缓冲的接口一定要封装好。set_pixel()、draw_line()、fill_rect() 这些函数,上层应用只管调用,底层实现是页缓冲还是全缓冲,对应用层透明。这样后期想切换策略,改底层就行了,上层代码不用动。