帧缓冲(FrameBuffer)原理:Linux Framebuffer设备、mmap映射、像素格式与颜色深度

好,咱们进入正题。上一章聊了双缓冲的整体思路,这一章得把地基打牢——帧缓冲到底是什么?

说白了,帧缓冲就是一块内存区域。你往里面写像素数据,显示硬件就自动把它刷到屏幕上。听起来简单吧?但实际用起来,坑不少。我最早接触这东西是在一个嵌入式项目上,当时想把摄像头画面直接怼到LCD上,结果屏幕花得一塌糊涂。后来才发现,我对帧缓冲的理解太浅了。

Linux Framebuffer设备

在Linux系统里,帧缓冲被抽象成一个设备文件——/dev/fb0。你打开它,操作它,就像操作一个普通文件。

但这不是普通文件。它背后对应的是显存(或者系统内存里划出来的一块区域)。显示驱动初始化的时候,会注册一个fb_info结构体,里面包含了分辨率、颜色深度、像素格式、显存地址等信息。

我习惯用fbset命令先看一眼当前配置:

# fbset

mode "1024x768-60"
    # D: 65.00 MHz, H: 48.36 kHz, V: 60.00 Hz
    geometry 1024 768 1024 768 32
    timings 15384 168 16 30 2 136 6
    accel true
    rgba 8/16,8/8,8/0,8/24
endmode

注意geometry那行——1024 768 1024 768 32。前两个是可见分辨率,中间两个是虚拟分辨率(后面讲双缓冲时会用到),最后一个32是颜色深度,也就是每个像素占32位。

关键点:Framebuffer设备本质上是一个字符设备。你用open()打开,用ioctl()获取/设置参数,用mmap()映射显存到用户空间。就这么几个操作,但每个都有讲究。

mmap映射:把显存变成你的数组

为什么需要mmap?因为显存在内核空间,用户程序不能直接访问。你当然可以用read()/write()来读写,但那样效率太低——每次读写都要经过内核缓冲区,画一帧数据要拷贝好几次。

mmap直接把内核空间的显存映射到用户空间的虚拟地址。映射之后,你操作那个指针,就是在操作显存。零拷贝,效率拉满。

来看代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <linux/fb.h>

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

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

    // 计算显存大小
    size_t screensize = vinfo.xres * vinfo.yres * (vinfo.bits_per_pixel / 8);

    // mmap映射
    char *fbp = (char *)mmap(0, screensize, 
                             PROT_READ | PROT_WRITE, 
                             MAP_SHARED, fbfd, 0);
    if (fbp == MAP_FAILED) {
        perror("mmap failed");
        close(fbfd);
        return -1;
    }

    // 现在 fbp 就是显存指针,直接写像素
    // 比如把屏幕左上角第一个像素设为红色
    *(unsigned int *)(fbp) = 0x00FF0000;  // 假设是32位ARGB格式

    munmap(fbp, screensize);
    close(fbfd);
    return 0;
}

我的经验:映射的时候一定要用MAP_SHARED,不能用MAP_PRIVATE。后者会创建写时拷贝,你写进去的数据根本到不了显存。我曾经在这个坑里浪费了半天。

像素格式与颜色深度

像素格式决定了内存里的二进制位怎么对应到屏幕上的颜色。常见的有这么几种:

颜色深度 像素格式 每个像素大小 常见场景
8位 索引色(调色板) 1字节 老式VGA、低端MCU
16位 RGB565 / ARGB1555 2字节 嵌入式LCD、低成本设备
24位 RGB888 3字节 标准桌面显示
32位 ARGB8888 / XRGB8888 4字节 现代GPU、双缓冲常用

32位格式里,每个像素占4字节。通常的排列是:

  • ARGB8888:Alpha(8位) + Red(8位) + Green(8位) + Blue(8位)
  • XRGB8888:保留位(8位) + Red(8位) + Green(8位) + Blue(8位)

你想想看,如果颜色深度是32位,一帧1024x768的画面需要多少内存?

1024 × 768 × 4 = 3,145,728 字节 ≈ 3 MB

嗯,3MB。如果做双缓冲,就需要6MB。这在今天的硬件上不算什么,但在资源受限的嵌入式设备上,这个数字你得心里有数。

注意:不同硬件平台的像素字节序可能不同。x86是小端,ARM默认也是小端,但有些老式PowerPC是大端。写像素的时候,0x00FF0000在小端下是Blue通道,在大端下可能是Red通道。我建议用结构体或宏来定义颜色,别硬编码数值。

帧缓冲的核心结构图

下面这张图把帧缓冲的整个数据流串起来了。从用户程序到屏幕,中间经过了哪些环节,一目了然。

帧缓冲数据流与内存映射结构 用户空间 应用程序 open("/dev/fb0") ioctl(FBIOGET_VSCREENINFO) mmap(显存) 直接写像素数据 内核空间 Framebuffer驱动 fb_info 结构体 显存物理地址 分辨率/颜色深度/像素格式 刷新时序控制 硬件层 LCD控制器 / GPU DMA从显存读取像素 → 屏幕 系统调用 DMA读取 关键路径 用户程序通过mmap直接操作显存,无需经过内核缓冲区 硬件通过DMA周期性地从显存读取数据刷新屏幕

颜色格式的细节

刚才提到32位ARGB,但实际项目中更常见的是XRGB8888——Alpha位不用,保留。为什么?因为很多LCD控制器根本不支持Alpha混合,你写了Alpha值也没用。

我建议你通过FBIOGET_VSCREENINFO获取fb_var_screeninfo结构体,里面有个transp字段,告诉你Alpha通道的偏移和长度。如果transp.length是0,说明不支持Alpha。

16位RGB565的布局是这样的:

  • 高5位:Red
  • 中间6位:Green
  • 低5位:Blue

为什么Green多1位?因为人眼对绿色最敏感。这是硬件设计上的一个经典取舍。

避坑指南:我曾经在一个项目里用16位色,结果显示出来的图片偏绿。查了半天,发现是驱动把RGB565的字节序搞反了——它按BGR565来解析。遇到这种情况,用FBIOGET_FSCREENINFO获取fb_fix_screeninfo,看visual字段是FB_VISUAL_TRUECOLOR还是FB_VISUAL_DIRECTCOLOR。前者是直彩色,后者需要查调色板。

虚拟分辨率与双缓冲的关联

还记得fbset输出里的虚拟分辨率吗?比如geometry 1024 768 2048 768 32,虚拟宽度是2048,是可见宽度的两倍。这意味着显存里有两帧数据并排存放。

怎么切换显示哪一帧?通过FBIOPAN_DISPLAY这个ioctl,设置fb_var_screeninfo的xoffset和yoffset。比如:

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

// 切换到第二帧(偏移一个屏幕宽度)
vinfo.xoffset = 1024;  // 假设可见宽度是1024
vinfo.yoffset = 0;
ioctl(fbfd, FBIOPAN_DISPLAY, &vinfo);

这就是双缓冲的硬件基础。你往后台帧写数据,然后通过FBIOPAN_DISPLAY把后台帧切换到前台。整个过程没有屏幕撕裂,因为切换发生在VBlank(垂直消隐)期间。

核心要点:帧缓冲的原理并不复杂——它就是一块内存,加上一个硬件DMA引擎。但理解它的细节,尤其是mmap的映射方式、像素格式的字节排列、以及虚拟分辨率与双缓冲的关系,是后面实现高效双缓冲刷新的前提。