3. 显示驱动移植:从像素点亮到屏幕刷新
好,咱们进入第三章。前面两章我们把GUI框架和硬件抽象层搭好了,现在要真正让屏幕亮起来。说白了,显示驱动就是GUI和硬件之间的最后一道桥梁。这道桥搭不好,上层画得再漂亮也出不来。
我个人习惯把显示驱动分成三层来看:硬件接口层、帧缓冲管理层、像素操作层。今天咱们一层层拆开讲。
3.1 LCD/OLED驱动原理:先搞懂屏幕怎么“听话”
不管LCD还是OLED,驱动芯片的接口就那么几种。我列个表,你一看就明白:
| 接口类型 | 引脚数 | 速度 | 常见场景 |
|---|---|---|---|
| SPI | 4~5 | 中 | 小尺寸OLED、低端LCD |
| I2C | 2 | 慢 | 极简引脚、静态显示 |
| 8080并行 | 8~16 | 快 | 中尺寸TFT-LCD |
| RGB接口 | 16~24 | 极快 | 大尺寸、高刷屏 |
| MIPI DSI | 差分对 | 最快 | 手机、高端嵌入式 |
驱动芯片内部其实维护了一个GRAM(显存)。你通过接口往GRAM里写数据,芯片就自动把数据映射到对应的像素点上。我刚开始做驱动移植时,以为要自己控制每个像素的刷新时序,后来才发现——你只管写GRAM,刷新是硬件的事。
3.2 帧缓冲(FrameBuffer)概念:GUI的“画板”
帧缓冲是什么?说白了,就是内存里的一块区域,大小刚好能存一帧画面的所有像素数据。
举个例子:一块320×240的屏幕,颜色深度16位(RGB565)。那么帧缓冲的大小就是:
320 × 240 × 2 = 153,600 字节 ≈ 150KB
GUI库在帧缓冲里画图,画完后把整个缓冲或变化区域刷到屏幕GRAM里。这就是“双缓冲”的雏形。
我画了一张图,帮你理清数据流向:
3.3 实现像素点绘制函数:最底层的“画笔”
所有GUI绘图操作,最终都会落到一个函数上——画点。这个函数不优化,上层再花哨也没用。
先看一个最基础的实现:
// 帧缓冲基地址
static uint16_t *fb_base = (uint16_t *)0x24000000;
// 屏幕参数
#define SCREEN_WIDTH 320
#define SCREEN_HEIGHT 240
// 像素点绘制函数
void lcd_draw_pixel(int x, int y, uint16_t color) {
// 边界裁剪
if (x < 0 || x >= SCREEN_WIDTH) return;
if (y < 0 || y >= SCREEN_HEIGHT) return;
// 计算帧缓冲偏移
uint32_t offset = y * SCREEN_WIDTH + x;
// 写入帧缓冲
fb_base[offset] = color;
}
嗯,这里要注意:颜色格式必须和屏幕GRAM一致。RGB565是最常见的16位格式,5位红、6位绿、5位蓝。我曾经在项目里把RGB和BGR搞反了,结果红色按钮显示成蓝色,排查了半天才发现是颜色宏定义写错了。
__attribute__((aligned(4))) 或放在特定内存段。
3.4 区域刷新优化:别让屏幕“卡成PPT”
如果每次只改一个像素就全屏刷新,那效率太低了。想想看,你拖动一个窗口,每秒60帧,每帧可能只变化了屏幕的10%区域。全屏刷新就是浪费。
优化的核心思路:只刷新变化区域。
我常用的方法是维护一个“脏矩形”(Dirty Rectangle):
// 脏矩形结构
typedef struct {
int x1, y1; // 左上角
int x2, y2; // 右下角
} dirty_rect_t;
static dirty_rect_t dirty = {0, 0, 0, 0};
static int has_dirty = 0;
// 标记脏区域
void mark_dirty(int x, int y, int w, int h) {
if (!has_dirty) {
dirty.x1 = x;
dirty.y1 = y;
dirty.x2 = x + w - 1;
dirty.y2 = y + h - 1;
has_dirty = 1;
} else {
// 合并脏区域
if (x < dirty.x1) dirty.x1 = x;
if (y < dirty.y1) dirty.y1 = y;
if ((x + w - 1) > dirty.x2) dirty.x2 = x + w - 1;
if ((y + h - 1) > dirty.y2) dirty.y2 = y + h - 1;
}
}
// 刷新脏区域到屏幕
void flush_dirty(void) {
if (!has_dirty) return;
int w = dirty.x2 - dirty.x1 + 1;
int h = dirty.y2 - dirty.y1 + 1;
// 设置屏幕GRAM写入窗口
lcd_set_window(dirty.x1, dirty.y1, w, h);
// 批量写入像素数据
for (int row = dirty.y1; row <= dirty.y2; row++) {
uint32_t offset = row * SCREEN_WIDTH + dirty.x1;
lcd_write_data(&fb_base[offset], w);
}
has_dirty = 0;
}
这个优化在刷图片或文字时效果特别明显。我测试过,全屏刷新一帧大约需要8ms,而脏矩形刷新平均只需要1~2ms——性能提升4倍以上。
lcd_write_data 改成DMA传输。CPU只需要设置好源地址、目标地址和长度,剩下的交给DMA。这样CPU可以继续处理下一帧的绘制,实现“绘制-传输”流水线。
3.5 实战:把画点函数挂接到GUI库
以LVGL为例,它要求你实现一个“刷新回调”:
// LVGL的刷新回调
static void lvgl_flush_cb(lv_disp_drv_t *drv,
const lv_area_t *area,
lv_color_t *color_p) {
// 获取刷新区域
int x1 = area->x1;
int y1 = area->y1;
int x2 = area->x2;
int y2 = area->y2;
// 设置屏幕GRAM窗口
lcd_set_window(x1, y1, x2 - x1 + 1, y2 - y1 + 1);
// 批量写入
lcd_write_data((uint16_t *)color_p,
(x2 - x1 + 1) * (y2 - y1 + 1));
// 通知LVGL刷新完成
lv_disp_flush_ready(drv);
}
你看,LVGL自己管理了脏矩形,它传给回调的就是需要刷新的区域。你只需要把这块区域的帧缓冲数据写到屏幕GRAM就行。
我刚开始移植时犯过一个低级错误——忘记调用 lv_disp_flush_ready。结果屏幕只刷新了一次就卡死了。LVGL内部有个等待机制,你不告诉它刷新完成,它就不会发起下一次刷新。
3.6 性能对比:不同刷新策略的差异
| 刷新策略 | 每帧传输量 | CPU占用 | 适用场景 |
|---|---|---|---|
| 全屏刷新 | 150KB | 高 | 初始显示、全屏切换 |
| 脏矩形刷新 | 10~50KB | 中 | 日常交互、动画 |
| DMA+脏矩形 | 10~50KB | 低 | 高性能场景 |
| 双缓冲+VSync | 150KB | 低 | 视频播放、游戏 |
我个人建议:初期先用脏矩形刷新,等系统跑稳了再上DMA优化。一步到位容易出问题,出了问题还不好定位。