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。刚好。
缓冲区管理有几个关键操作:
- 清空缓冲区:全部置 0,相当于清屏。
- 填充缓冲区:全部置 0xFF,相当于全亮。
- 刷新到屏幕:把 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 知识体系:画点背后的全局
为了让你更直观地理解这一章的知识结构,我画了一张图:
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 的原子操作。理解坐标系统、管好缓冲区、做好边界检查、掌握优化技巧——这四个点吃透了,后面画线、画矩形、显示字符都会水到渠成。