14、位操作与图形学:用位运算实现像素点的设置与清除
图形学,听起来很高大上对吧?其实说白了,就是跟像素打交道。而像素,在底层就是一个个的位(bit)。
我刚开始接触嵌入式图形开发时,总觉得画点、清点这些操作很神秘。后来发现,用位运算来处理像素,简直是天作之合。今天我就带你看看,怎么用位操作来搞定像素点的设置与清除。
14.1 像素点的本质:一个位的故事
先想一个问题:一个像素点需要多少存储空间?
如果是黑白屏,一个像素只需要1个bit——0表示黑,1表示白。如果是灰度屏,可能需要4个bit(16级灰度)。如果是彩色屏,那就需要更多了,比如RGB565格式需要16个bit。
我们今天先聚焦在最简单的黑白屏场景。一个字节有8个bit,可以表示8个像素点。你想想看,一个128×64的单色OLED屏,总共需要多少字节?
128 × 64 ÷ 8 = 1024字节。刚好1KB。
这就是位操作的用武之地。我们要在1KB的内存里,精确地控制每一个像素点。
14.2 像素坐标到内存地址的映射
假设我们的屏幕是128像素宽、64像素高。每个字节管理8个像素(水平方向)。那么,坐标(x, y)对应的字节地址怎么算?
我习惯这样计算:
// 屏幕参数
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
// 计算像素(x, y)所在的字节索引
uint16_t byte_index = y * (SCREEN_WIDTH / 8) + (x / 8);
// 计算该像素在字节中的位位置(0~7)
uint8_t bit_position = x % 8;
这里有个细节要注意:x / 8 得到的是该像素在第几个字节里,x % 8 得到的是该字节内的第几个位。
14.3 设置像素点:用位或运算
设置一个像素点,就是把对应的位变成1。怎么做?用位或(|)运算。
原理很简单:
- 先把1左移到目标位置
- 然后跟当前字节做位或
// 设置像素点(x, y)为白色(1)
void set_pixel(uint8_t *framebuffer, uint16_t x, uint16_t y) {
uint16_t byte_index = y * (SCREEN_WIDTH / 8) + (x / 8);
uint8_t bit_position = x % 8;
// 1 << bit_position 生成一个只有目标位为1的掩码
// 位或操作:目标位变成1,其他位保持不变
framebuffer[byte_index] |= (1 << bit_position);
}
你看,核心代码就一行。这就是位操作的魅力——简洁、高效。
1 << bit_position 这个表达式,生成的是一个「只有目标位为1,其他位都是0」的掩码。位或运算保证了只有目标位被置1,其他位不受影响。
14.4 清除像素点:用位与运算
清除像素点,就是把对应的位变成0。这次我们用位与(&)运算。
思路是这样的:
- 先生成一个掩码:目标位为0,其他位为1
- 然后跟当前字节做位与
// 清除像素点(x, y)(设置为黑色,即0)
void clear_pixel(uint8_t *framebuffer, uint16_t x, uint16_t y) {
uint16_t byte_index = y * (SCREEN_WIDTH / 8) + (x / 8);
uint8_t bit_position = x % 8;
// ~(1 << bit_position) 生成一个只有目标位为0的掩码
// 位与操作:目标位变成0,其他位保持不变
framebuffer[byte_index] &= ~(1 << bit_position);
}
这里用到了取反运算符(~)。~(1 << bit_position) 把掩码翻转了——原来为1的位变成0,原来为0的位变成1。然后位与运算就把目标位清零了。
framebuffer[byte_index] &= ~(1 << bit_position); 不需要加括号。但为了代码可读性,我建议还是加上括号:framebuffer[byte_index] &= (~(1 << bit_position));
14.5 读取像素点:用位与运算
有时候我们还需要读取某个像素点的状态。这同样用位与运算:
// 读取像素点(x, y)的状态
// 返回1表示白色,0表示黑色
uint8_t get_pixel(uint8_t *framebuffer, uint16_t x, uint16_t y) {
uint16_t byte_index = y * (SCREEN_WIDTH / 8) + (x / 8);
uint8_t bit_position = x % 8;
// 位与运算提取目标位,然后右移到最低位
return (framebuffer[byte_index] & (1 << bit_position)) ? 1 : 0;
}
这里 framebuffer[byte_index] & (1 << bit_position) 的结果,要么是0(目标位为0),要么是 1 << bit_position(目标位为1)。用三元运算符转成0或1,方便后续判断。
14.6 批量操作:整行像素的填充
单个像素操作是基础。但在实际项目中,我们经常需要批量操作。比如画一条水平线,或者填充一个矩形。
如果逐像素调用 set_pixel,效率太低了。更好的做法是直接操作字节:
// 画一条水平线:从(x1, y)到(x2, y)
void draw_hline(uint8_t *framebuffer, uint16_t x1, uint16_t x2, uint16_t y) {
uint16_t byte_start = y * (SCREEN_WIDTH / 8) + (x1 / 8);
uint16_t byte_end = y * (SCREEN_WIDTH / 8) + (x2 / 8);
// 如果起点和终点在同一个字节内
if (byte_start == byte_end) {
uint8_t mask = (0xFF >> (x1 % 8)) & (0xFF << (7 - (x2 % 8)));
framebuffer[byte_start] |= mask;
return;
}
// 处理第一个字节(可能不是完整的8位)
framebuffer[byte_start] |= (0xFF >> (x1 % 8));
// 处理中间的完整字节
for (uint16_t i = byte_start + 1; i < byte_end; i++) {
framebuffer[i] = 0xFF; // 整字节全置1
}
// 处理最后一个字节(可能不是完整的8位)
framebuffer[byte_end] |= (0xFF << (7 - (x2 % 8)));
}
这段代码看起来复杂,其实逻辑很清晰:
- 第一个字节:只设置从x1开始的位
- 中间字节:全部置1
- 最后一个字节:只设置到x2结束的位
这种批量操作,比逐像素调用快了不止一个数量级。
14.7 知识体系总览
下面这张图总结了像素点操作的核心逻辑:
14.8 实际项目中的避坑指南
我在实际项目中遇到过不少跟像素操作相关的问题,这里分享几个典型的:
- 位序搞反了——有的屏幕是MSB在左,有的是LSB在左。不确认清楚,画出来的图形是镜像的。
- 忘记考虑字节对齐——有些MCU对非对齐访问会触发异常。如果framebuffer的地址不是4字节对齐,某些优化写法可能会出问题。
- 批量操作时边界处理错误——画水平线时,第一个字节和最后一个字节的掩码计算最容易出错。我建议写个测试函数,画几条线然后打印出framebuffer内容来验证。
dump_framebuffer() 函数,把framebuffer的内容以十六进制打印出来。然后用肉眼检查每个字节的位模式是否正确。虽然原始,但非常有效。
14.9 性能优化建议
如果你在资源受限的MCU上做图形开发,性能优化很重要。这里有几个方向:
| 优化方向 | 具体做法 | 效果 |
|---|---|---|
| 减少函数调用 | 将set_pixel/clear_pixel定义为内联函数或宏 | 减少调用开销,适合频繁调用场景 |
| 批量操作 | 用整字节操作代替逐像素操作 | 速度提升5~10倍 |
| 查表法 | 预计算常用掩码,用查表代替移位运算 | 减少CPU计算时间 |
| DMA传输 | 用DMA将framebuffer刷到屏幕 | 释放CPU,提升整体性能 |
我个人最推荐的是「批量操作」这个方向。因为它的优化效果最明显,而且代码逻辑清晰,不容易出错。
好了,关于像素点的设置与清除,就讲到这里。位操作在图形学中的应用远不止这些,后面我们还会聊到更高级的话题,比如图形裁剪、字体渲染等等。但今天这些基础操作,是后续所有内容的地基。
记住一句话:图形学的底层,就是位操作的天下。