4. 像素操作与画点:set_pixel 函数实现、坐标系统理解、画点性能优化、内存缓冲区管理

好,咱们进入第四讲。前面几章我们把硬件接口和初始化流程走通了,现在终于可以开始「画东西」了。

画东西最基础的操作是什么?就是画一个点。别小看这个点,所有复杂的图形——线条、矩形、字符、图片——归根结底都是点的组合。你想想看,一个 128×64 的 OLED 屏幕,总共 8192 个像素点,每个点要么亮要么灭。我们做的所有 UI 工作,本质上就是在控制这 8192 个点的状态。

这一章,我们就来彻底搞懂「画点」这件事。

4.1 坐标系统:屏幕上的数学

在开始写代码之前,先得把坐标系理清楚。OLED 屏幕的坐标系统和数学课本上的不太一样。

坐标系 原点位置 X 轴方向 Y 轴方向
数学直角坐标系 左下角 向右 向上
OLED 屏幕坐标系 左上角 向右 向下

我个人习惯把 OLED 的坐标系称为「屏幕坐标系」。原点在左上角,X 从 0 到 127,Y 从 0 到 63。你写 set_pixel(0, 0) 就是点亮左上角第一个像素;set_pixel(127, 63) 就是点亮右下角最后一个。

关键点:Y 轴向下为正。这意味着你画一个矩形时,左上角坐标 (x, y) 和右下角坐标 (x+w, y+h) 的关系是:y 值越大,位置越靠下。很多新手在这里栽跟头,画出来的图形是上下颠倒的。

我曾经在做一个菜单动画时,因为坐标系搞反了,箭头一直往反方向跑。排查了半天才发现是 Y 轴增量写成了负值。嗯,这种错误犯过一次就不会再犯了。

4.2 set_pixel 函数实现:从零开始

现在我们来写核心函数。SSD1306 的显存是按页(Page)组织的,每页 8 个像素点,对应一列。这个我们在上一章提过,现在要落实到代码里。

先看最直接的实现:

// 最简单的实现,不做边界检查
void set_pixel(uint8_t x, uint8_t y, uint8_t color) {
    uint8_t page = y / 8;          // 计算所在页
    uint8_t bit = y % 8;           // 计算页内偏移
    if (color) {
        buffer[page][x] |= (1 << bit);   // 点亮
    } else {
        buffer[page][x] &= ~(1 << bit);  // 熄灭
    }
}

这段代码逻辑很直白:

  • page = y / 8:Y 坐标除以 8,得到在哪一页。Y 范围 0-63,所以 page 范围 0-7。
  • bit = y % 8:取余数,得到在该页的第几个 bit。范围 0-7。
  • 1 << bit:生成一个掩码,比如 bit=3 时掩码是 0b00001000。
  • 按位操作:点亮用 OR,熄灭用 AND 取反。

小技巧:如果你想让代码更紧凑,可以把 color 参数改成布尔类型。我个人习惯用 uint8_t,0 表示灭,非 0 表示亮。这样调用时写 set_pixel(x, y, 1) 或 set_pixel(x, y, 0) 就很直观。

4.3 边界检查:别让程序崩了

上面的代码有个问题——没有边界检查。如果调用者传了 x=200 或 y=100,数组会越界,轻则显示异常,重则死机。

我建议加上防御性编程:

void set_pixel_safe(uint8_t x, uint8_t y, uint8_t color) {
    // 边界检查
    if (x >= SCREEN_WIDTH || y >= SCREEN_HEIGHT) {
        return;  // 超出范围,直接忽略
    }
    uint8_t page = y / 8;
    uint8_t bit = y % 8;
    if (color) {
        buffer[page][x] |= (1 << bit);
    } else {
        buffer[page][x] &= ~(1 << bit);
    }
}

这里我用了 SCREEN_WIDTH 和 SCREEN_HEIGHT 宏定义,方便以后换屏幕尺寸时修改。128×64 的屏幕,这两个值就是 128 和 64。

注意:边界检查虽然安全,但会带来额外的性能开销。在性能敏感的场合(比如动画循环中每秒调用几千次),可以考虑在调用侧保证坐标合法,跳过检查。我一般会在调试阶段开启检查,发布时用宏控制是否启用。

4.4 内存缓冲区管理:显存的幕后

buffer 是什么?它是一个二维数组,模拟了 SSD1306 的显存结构。

// 显存缓冲区定义
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 64
#define PAGE_COUNT    (SCREEN_HEIGHT / 8)  // 8 页

uint8_t buffer[PAGE_COUNT][SCREEN_WIDTH];  // 8 x 128 = 1024 字节

为什么是 1024 字节?128×64 的屏幕,每个像素 1 bit,总共 128×64 = 8192 bit = 1024 byte。刚好。

缓冲区管理有几个关键操作:

  1. 清空缓冲区:全部置 0,相当于清屏。
  2. 填充缓冲区:全部置 0xFF,相当于全亮。
  3. 刷新到屏幕:把 buffer 内容通过 SPI/I2C 发送到 SSD1306 的 GDDRAM。

清空和填充很简单:

void buffer_clear(void) {
    memset(buffer, 0x00, sizeof(buffer));
}

void buffer_fill(void) {
    memset(buffer, 0xFF, sizeof(buffer));
}

刷新到屏幕的函数我们在上一章写过,这里再回顾一下:

void display_update(void) {
    for (uint8_t page = 0; page < PAGE_COUNT; page++) {
        // 设置页地址
        ssd1306_command(0xB0 + page);
        // 设置列地址(低4位 + 高4位)
        ssd1306_command(0x00);  // 列起始低4位
        ssd1306_command(0x10);  // 列起始高4位
        // 发送该页数据
        for (uint8_t col = 0; col < SCREEN_WIDTH; col++) {
            ssd1306_data(buffer[page][col]);
        }
    }
}

性能要点:每次刷新都发送 1024 字节数据。如果 SPI 时钟是 10MHz,传输时间大约 1ms。对于 60fps 的刷新率,这个开销是可以接受的。但如果你的 UI 需要频繁局部更新,可以考虑「脏矩形」优化——只刷新变化的部分。这个我们后面章节会讲。

4.5 画点性能优化:别让 CPU 白忙

set_pixel 本身很简单,但如果你在循环里调用几千次,性能问题就暴露出来了。我遇到过最夸张的情况——一个简单的画线函数,因为每次画点都做边界检查、除法和取模,导致动画卡成幻灯片。

优化思路有几个方向:

优化方法 原理 效果
预计算页和位 把 y/8 和 y%8 提前算好存表 减少除法指令
位操作合并 一次操作多个 bit 减少循环次数
批量写入 整页整页地写,不逐点操作 减少函数调用开销
DMA 传输 用硬件自动搬运数据 CPU 零等待

对于初学者,我建议先掌握「预计算」这个技巧。比如你要画一条垂直线,可以这样优化:

// 优化前:逐点画
for (uint8_t y = y_start; y <= y_end; y++) {
    set_pixel(x, y, 1);
}

// 优化后:按页批量操作
void draw_vertical_line(uint8_t x, uint8_t y_start, uint8_t y_end) {
    for (uint8_t y = y_start; y <= y_end; ) {
        uint8_t page = y / 8;
        uint8_t bit_start = y % 8;
        uint8_t bit_end = (y_end < (page+1)*8) ? y_end : (page+1)*8 - 1;
        // 生成掩码,一次设置多个 bit
        uint8_t mask = 0;
        for (uint8_t b = bit_start; b <= bit_end; b++) {
            mask |= (1 << b);
        }
        buffer[page][x] |= mask;
        y = (page + 1) * 8;  // 跳到下一页
    }
}

这个优化把多次 set_pixel 调用合并成几次直接内存操作。在画粗线条或填充区域时,性能提升非常明显。

我的经验:在嵌入式平台上,函数调用开销比你想象的大。一个 set_pixel 函数如果被调用 1000 次,光进出栈就要消耗几百个 CPU 周期。能用宏或内联函数的地方,尽量用。我写驱动时,set_pixel 经常用宏实现,只在调试时才用函数版本。

4.6 知识体系:画点背后的全局

为了让你更直观地理解这一章的知识结构,我画了一张图:

像素操作与画点知识体系 set_pixel 核心 坐标系统 内存缓冲区管理 边界检查 性能优化 原点左上角 Y轴向下 1024字节 页结构 防御性编程 性能权衡 预计算 批量操作 核心原则 先保证正确性,再考虑性能 缓冲区操作要快,屏幕刷新要稳

4.7 实战练习:画一个点阵图案

理论讲完了,来点实战。写一个函数,在屏幕上画一个 8×8 的点阵图案。比如一个笑脸:

// 8x8 笑脸点阵数据(1 表示亮,0 表示灭)
const uint8_t smile_face[8] = {
    0b00111100,  // 0:  ████
    0b01000010,  // 1: █    █
    0b10100101,  // 2: █ █  █ █
    0b10000001,  // 3: █      █
    0b10100101,  // 4: █ █  █ █
    0b10011001,  // 5: █  ██  █
    0b01000010,  // 6: █    █
    0b00111100   // 7:  ████
};

void draw_bitmap(uint8_t x, uint8_t y, const uint8_t *data, 
                 uint8_t width, uint8_t height) {
    for (uint8_t row = 0; row < height; row++) {
        for (uint8_t col = 0; col < width; col++) {
            if (data[row] & (1 << (7 - col))) {
                set_pixel_safe(x + col, y + row, 1);
            }
        }
    }
}

// 调用:在屏幕中央画笑脸
draw_bitmap(60, 28, smile_face, 8, 8);
display_update();

这段代码展示了如何用 set_pixel 组合出复杂图案。你想想看,所有汉字、图标、动画,本质上都是这种点阵数据的集合。掌握了画点,你就掌握了显示的基础。

本章核心:set_pixel 是 UI 的原子操作。理解坐标系统、管好缓冲区、做好边界检查、掌握优化技巧——这四个点吃透了,后面画线、画矩形、显示字符都会水到渠成。