4. Framebuffer 应用编程:从像素到画面的第一公里

说实话,Framebuffer 是嵌入式 Linux 显示开发里最基础、也最直接的东西。你不需要理解 DRM/KMS 那一套复杂框架,也不需要管 Wayland 合成器怎么调度。打开一个设备文件,映射一段内存,往里面填颜色数据——画面就出来了。

我当年第一次在开发板上点亮屏幕时,用的就是 Framebuffer。那种「我写了一个像素,屏幕就亮了一个点」的掌控感,至今记忆犹新。这一章,我们就从最底层的 mmap 开始,一步步搭出一个能用的简单 GUI。

4.1 mmap 映射帧缓冲:把显存变成你的数组

Framebuffer 设备在 Linux 里是 /dev/fb0。它本质上是一段物理内存,由显示控制器周期性读取并输出到屏幕。用户空间要操作它,最直接的方式就是 mmap。

为什么不用 read/write?因为效率太低。每次写一个像素都要经过系统调用,你想想看,一个 1024×600 的屏幕,刷新一帧就要 60 多万次调用,CPU 直接跑满。mmap 把内核空间的帧缓冲直接映射到用户空间,之后操作就跟操作普通数组一样,没有系统调用开销。

核心流程:
  1. open("/dev/fb0", O_RDWR) 打开设备
  2. ioctl(fd, FBIOGET_VSCREENINFO, &vinfo) 获取屏幕参数
  3. 计算映射大小:screensize = vinfo.xres * vinfo.yres * (vinfo.bits_per_pixel / 8)
  4. mmap(NULL, screensize, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0)
  5. 直接通过指针读写像素数据
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <linux/fb.h>

int main() {
    int fbfd = open("/dev/fb0", O_RDWR);
    if (fbfd < 0) { perror("open fb0"); return -1; }

    struct fb_var_screeninfo vinfo;
    ioctl(fbfd, FBIOGET_VSCREENINFO, &vinfo);

    long screensize = vinfo.xres * vinfo.yres * (vinfo.bits_per_pixel / 8);
    char *fbp = (char *)mmap(NULL, screensize, PROT_READ | PROT_WRITE,
                             MAP_SHARED, fbfd, 0);
    if (fbp == MAP_FAILED) { perror("mmap"); close(fbfd); return -1; }

    // 现在 fbp 就是显存指针,直接写它
    // 比如把第一个像素设为红色(假设 RGB565)
    *(unsigned short *)(fbp) = 0xF800;

    munmap(fbp, screensize);
    close(fbfd);
    return 0;
}
我曾经踩过的坑: 映射时 MAP_SHARED 必须写,不能写成 MAP_PRIVATE。否则你写的是进程私有副本,屏幕根本不会刷新。还有,记得检查 ioctl 返回值,有些驱动在 FBIOGET_VSCREENINFO 阶段会返回错误。

4.2 直接操作显存绘制图形:从点、线到矩形

有了显存指针,画图就是算地址。以 RGB565 为例,每个像素占 2 字节。坐标 (x, y) 对应的偏移量是:

offset = (x + y * vinfo.xres) * (vinfo.bits_per_pixel / 8);

画一个点:

void draw_pixel(char *fbp, int x, int y, unsigned short color) {
    unsigned short *ptr = (unsigned short *)(fbp + (x + y * vinfo.xres) * 2);
    *ptr = color;
}

画一条水平线:

void draw_hline(char *fbp, int x0, int x1, int y, unsigned short color) {
    unsigned short *ptr = (unsigned short *)(fbp + (x0 + y * vinfo.xres) * 2);
    for (int x = x0; x <= x1; x++) {
        *ptr++ = color;
    }
}

画一个填充矩形:

void fill_rect(char *fbp, int x, int y, int w, int h, unsigned short color) {
    for (int row = 0; row < h; row++) {
        draw_hline(fbp, x, x + w - 1, y + row, color);
    }
}

嗯,这里要注意:不同屏幕的像素格式可能不一样。常见的有 RGB565(16位)、RGB888(32位)、ARGB8888(32位)。我习惯在初始化时打印 vinfo.bits_per_pixel 和 vinfo.red/green/blue.offset,确认格式后再动手。

格式 每像素字节 颜色布局 常见场景
RGB565 2 R[15:11] G[10:5] B[4:0] 小屏、低端开发板
RGB888 4 R[23:16] G[15:8] B[7:0] 高性能板卡
ARGB8888 4 A[31:24] R[23:16] G[15:8] B[7:0] 带透明通道的 UI

4.3 Framebuffer 双缓冲技术:告别闪烁

直接往显存画图有个问题:你画到一半时,屏幕刷新正好读到那块区域,用户就会看到「一半新画面、一半旧画面」的撕裂现象。说白了就是闪。

解决方案是双缓冲。在内存里维护一块「后备缓冲区」,所有绘制操作先画到后备缓冲区,画完后一次性拷贝到显存。这样用户看到的永远是完整的帧。

我的个人习惯: 后备缓冲区用 malloc 分配,大小和显存一样。所有绘图函数都操作后备缓冲区,最后用 memcpy 整帧拷贝到 fbp。如果硬件支持 FBIOPAN_DISPLAY,还可以通过切换显示起始地址实现真正的 page flipping,效率更高。
char *backbuf = (char *)malloc(screensize);
// 所有绘制操作都画到 backbuf
fill_rect(backbuf, 10, 10, 100, 50, 0x07E0);  // 绿色矩形
// 画完后一次性提交
memcpy(fbp, backbuf, screensize);

为什么不用 FBIOPAN_DISPLAY?因为很多低端 Framebuffer 驱动不支持这个 ioctl。我遇到过一款全志的板子,文档里写着支持,实际调用返回 EINVAL。所以最稳妥的方式还是 memcpy,虽然多了一次拷贝,但在 800×480 的屏幕上,实测也就 1-2ms,完全可以接受。

4.4 基于 Framebuffer 的简单 GUI 实现

有了双缓冲和基本绘图函数,我们就可以搭一个极简的 GUI 框架了。核心思路是:

  • 窗口管理: 每个窗口是一个矩形区域,记录位置、大小、Z 序
  • 事件循环: 通过 input 子系统读取触摸/鼠标事件
  • 重绘机制: 脏矩形标记 + 局部刷新

下面是一个最小实现的结构:

typedef struct {
    int x, y, w, h;
    int z_order;
    void (*draw)(struct Window *win, char *buf);
    int dirty;
} Window;

void gui_refresh(Window *windows[], int n, char *backbuf, char *fbp) {
    // 按 Z 序从低到高绘制
    for (int z = 0; z < MAX_Z; z++) {
        for (int i = 0; i < n; i++) {
            if (windows[i]->z_order == z && windows[i]->dirty) {
                windows[i]->draw(windows[i], backbuf);
                windows[i]->dirty = 0;
            }
        }
    }
    memcpy(fbp, backbuf, screensize);
}

这个框架虽然简陋,但我在一个工业平板项目里真的用过。当时客户要求 3 天内出原型,来不及上 Qt,我就用这套方案做了几个按钮和进度条,效果还不错。

避坑指南:
  • 触摸坐标和屏幕坐标可能不一致,需要做校准(我曾经被这个坑了整整一天)
  • Framebuffer 的 line_length 不一定等于 xres * bpp/8,有些驱动会做行对齐
  • 多线程绘制时记得加锁,否则后备缓冲区会被撕成两半

4.5 本章知识体系总览

下面这张图把 Framebuffer 应用编程的核心脉络串起来了。从设备文件到像素操作,再到双缓冲和简单 GUI,每一步都是下一层的基础。

Framebuffer 应用编程知识体系 /dev/fb0 设备文件 mmap 映射显存到用户空间 直接操作像素/绘制图形 双缓冲技术(后备缓冲区) 简单 GUI 实现(窗口/事件/重绘) 工业平板 / 简单 UI 原型 每一层都依赖下一层:设备 → 映射 → 绘制 → 缓冲 → GUI → 应用

这一章的内容到这里就结束了。Framebuffer 虽然简单,但它是理解整个显示栈的起点。你掌握了 mmap、像素操作、双缓冲和简单 GUI 框架,后面学 DRM/KMS 时就会发现,很多概念其实是相通的。