4. 像素操作核心:设置窗口函数、写像素点、区域填充与颜色格式转换

各位同学,今天我们来啃一块硬骨头——像素操作。说白了,LCD 屏幕上的一切图形,最终都要落到「怎么把颜色填到正确的位置」这个问题上。我刚开始做 LCD 驱动时,总觉得画点函数写出来就完事了,结果一跑起来满屏花屏……后来才发现,窗口设置和颜色格式这两个坑,踩一次就记住了。

4.1 窗口函数:告诉屏幕「我要画哪里」

先问一个问题:你往屏幕上写数据,LCD 控制器怎么知道这些数据对应哪个像素?

答案就是窗口函数。它相当于在屏幕上画一个矩形框,之后所有写入的数据都只填充这个框内。我习惯叫它「裁剪区」——你想想看,没有这个框,每次写像素都要计算绝对坐标,效率低得吓人。

4.1.1 Set Column Address 和 Set Page Address

这两个命令通常成对出现:

  • Set Column Address:定义 X 轴范围(列起始 ~ 列结束)
  • Set Page Address:定义 Y 轴范围(页起始 ~ 页结束)

以 ILI9341 为例,命令格式如下:

// 设置列范围:列起始 SC,列结束 EC
void LCD_SetColumn(uint16_t sc, uint16_t ec) {
    LCD_WriteCmd(0x2A);          // 列地址命令
    LCD_WriteData(sc >> 8);     // 高字节
    LCD_WriteData(sc & 0xFF);    // 低字节
    LCD_WriteData(ec >> 8);     // 高字节
    LCD_WriteData(ec & 0xFF);    // 低字节
}

// 设置页范围:页起始 SP,页结束 EP
void LCD_SetPage(uint16_t sp, uint16_t ep) {
    LCD_WriteCmd(0x2B);          // 页地址命令
    LCD_WriteData(sp >> 8);
    LCD_WriteData(sp & 0xFF);
    LCD_WriteData(ep >> 8);
    LCD_WriteData(ep & 0xFF);
}
我的习惯:每次设置窗口后,紧接着调用一次 0x2C(写存储器命令),让 LCD 控制器进入数据写入模式。否则有些驱动 IC 会卡在命令解析状态。

4.1.2 窗口设置的典型流程

  1. 关闭自动递增(如果需要)
  2. 发送列地址命令 + 4 字节参数
  3. 发送页地址命令 + 4 字节参数
  4. 发送写存储器命令(0x2C)
  5. 连续写入像素数据

我曾经在项目里犯过一个低级错误:列和页的起始/结束参数搞反了。结果屏幕上画出来的矩形是镜像的,调试了整整一个下午……嗯,从那以后我每次写窗口函数都会在纸上先画一遍坐标。

4.2 写像素点函数:最基础的原子操作

窗口设置好之后,写像素就简单了。但这里有个关键点:颜色格式。你送出去的数据是 16 位还是 24 位,直接决定了写像素函数的实现。

4.2.1 RGB565 格式下的写像素

// 在 (x, y) 位置画一个 RGB565 颜色点
void LCD_DrawPixel(uint16_t x, uint16_t y, uint16_t color) {
    // 1. 设置窗口为单像素大小
    LCD_SetColumn(x, x);
    LCD_SetPage(y, y);
    // 2. 写入颜色数据
    LCD_WriteCmd(0x2C);
    LCD_WriteData(color >> 8);   // 高字节
    LCD_WriteData(color & 0xFF);  // 低字节
}

你可能会问:为什么窗口要设成单像素?直接写坐标不行吗?

答案是不行。大多数 LCD 控制器内部维护一个「写指针」,窗口函数的作用就是重置这个指针的位置。如果不设窗口,写指针会从上一次的位置继续递增,结果就是颜色写到别的地方去了。

注意:每次调用 DrawPixel 都设置一次窗口,效率极低。批量绘制时,应该先设好大窗口,再连续写入像素数据。我见过有人用单像素窗口画满屏,帧率直接掉到个位数。

4.3 区域填充函数:批量操作的威力

区域填充的本质就是「设大窗口 + 批量写数据」。以填充一个矩形为例:

// 填充矩形区域 (x0,y0) 到 (x1,y1),颜色为 color
void LCD_FillRect(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, uint16_t color) {
    uint32_t pixel_count = (x1 - x0 + 1) * (y1 - y0 + 1);
    uint32_t i;

    // 设置窗口
    LCD_SetColumn(x0, x1);
    LCD_SetPage(y0, y1);
    LCD_WriteCmd(0x2C);

    // 连续写入像素
    for (i = 0; i < pixel_count; i++) {
        LCD_WriteData(color >> 8);
        LCD_WriteData(color & 0xFF);
    }
}

这里有个优化点:如果颜色是固定的,可以用硬件自动递增模式。有些 LCD 控制器支持「内存写自动加 1」,你只需要在初始化时配置好,之后连续写数据即可,不需要每次手动计算地址。

4.4 颜色格式转换:RGB565 与 RGB888

这是最容易出 bug 的地方。我见过太多人把颜色值搞错,导致屏幕显示偏色严重。

4.4.1 两种格式的位分布

格式 红色 绿色 蓝色 总位数
RGB565 5 位 (bit15~11) 6 位 (bit10~5) 5 位 (bit4~0) 16 位
RGB888 8 位 (bit23~16) 8 位 (bit15~8) 8 位 (bit7~0) 24 位

4.4.2 转换函数实现

// RGB888 -> RGB565
uint16_t RGB888_to_RGB565(uint8_t r, uint8_t g, uint8_t b) {
    return ((r >> 3) << 11) |   // 红:8 位截断为 5 位
           ((g >> 2) << 5)  |   // 绿:8 位截断为 6 位
           (b >> 3);             // 蓝:8 位截断为 5 位
}

// RGB565 -> RGB888(扩展)
void RGB565_to_RGB888(uint16_t rgb565, uint8_t *r, uint8_t *g, uint8_t *b) {
    *r = ((rgb565 >> 11) & 0x1F) << 3;   // 5 位扩展到 8 位
    *g = ((rgb565 >> 5)  & 0x3F) << 2;   // 6 位扩展到 8 位
    *b = (rgb565 & 0x1F) << 3;             // 5 位扩展到 8 位
}
关键点:RGB565 转 RGB888 时,低位补 0 会导致颜色偏暗。更好的做法是低位补高位——比如 5 位红值左移 3 位后,再用原高 3 位填充低 3 位。这样颜色过渡更平滑。

4.5 知识结构总览

下面这张图把本章的核心逻辑串起来了:

像素操作核心知识结构 窗口函数 (Set Column/Page) 写像素点函数 (DrawPixel) 区域填充函数 (FillRect) 颜色格式转换 (RGB565 ↔ RGB888) 核心流程 设置窗口 → 写像素点 → 批量填充 → 颜色格式匹配 每一步的错误都会导致显示异常,务必逐层验证

4.6 实战中的避坑指南

  • 窗口参数越界:LCD 的列和页范围通常是 0~239 和 0~319(以 240×320 为例)。传入超出范围的值,轻则显示错位,重则死机。我建议在窗口函数内部加参数校验。
  • 字节序问题:RGB565 数据在 16 位总线上是高位在前还是低位在前?不同 LCD 控制器不一样。ILI9341 默认是高位在前,但有些国产屏是低位在前。查数据手册的「Memory Write Cycle」章节。
  • 颜色格式不匹配:如果你用 RGB888 的图片数据直接喂给 RGB565 的屏,颜色会严重失真。反过来,RGB565 的数据喂给 RGB888 的屏,颜色会偏暗。转换函数一定要写对。
我曾经踩过的坑:有一次做摄像头显示项目,图像数据是 RGB888 的,我直接 memcpy 到 LCD 的显存里,结果画面全是紫色条纹。查了两天才发现——LCD 控制器配置成了 RGB565 模式,而数据是 24 位的。从那以后,我每次初始化 LCD 都会先打印颜色格式配置寄存器的值。

4.7 小结

像素操作是 LCD 驱动的基石。窗口函数决定了「画在哪里」,写像素函数决定了「画什么」,区域填充提升了批量效率,颜色格式转换保证了颜色准确。这四个函数写好了,后面画线、画圆、显示图片都会轻松很多。

嗯,记住一点:先验证窗口,再验证颜色。我每次移植新屏驱动,都会先画一个全屏的纯色矩形——如果颜色不对,先查格式;如果位置不对,先查窗口。这个习惯帮我省了无数调试时间。