起因

工控机上电,操作工看到的第一样东西是画面。这篇记录的就是显示输出怎么接手。接手用的 GOP 有来历:UEFI 接 BIOS 班时,把实模式 INT 10h/VBE 那套点亮屏幕的调用换成了结构体加函数指针的图形输出协议,帧缓冲地址和模式信息从它手里直接拿。对面的 Intel 核显同样有故事——2010 年前后(Arrandale/Clarkdale 起)显示单元并进了 CPU 封装,从此在 Intel 平台长期占着 bus0:dev2:func0 的固定座位。固件用 GOP 把屏幕点亮、谈好模式,把帧缓冲的物理地址交出来;固件退场之后,显示引擎的 pipe/plane/PLL 配置原样留着继续扫描,Intel 核显在我们这儿就是一个已经点亮的扫描输出引擎,配置一行不用碰。剩下的活看着只有一件——往那块内存里写像素——但真写稳了才知道,像素格式、行距、这块内存的来路、为什么慢、怎么变快,一个都绕不过去。

一、GOP 交出来的到底是什么

locate_protocol(gEfiGraphicsOutputProtocolGuid) 拿到 GOP,有用的东西全在 Mode 指针里,逐字段过一遍:

字段 含义 实战备注
FrameBufferBase 帧缓冲物理基址 Intel 核显上通常指向偷取内存或 GTT 映射窗口,见第三节
FrameBufferSize 字节总数 校验用:应 ≈ H × pitch × 4
Info->HorizontalResolution / VerticalResolution 分辨率 工控屏直接用面板原生模式
Info->PixelsPerScanLine 行距(像素单位) **不是宽度!**对齐 padding 在每行末尾
Info->PixelFormat 像素格式 四种,见下

PixelFormat 的四种处理:

  • 0 PixelRedGreenBlueReserved8BitPerColor:内存字节序 R,G,R,x
  • 1 PixelBlueGreenRedReserved8BitPerColor:内存字节序 B,G,R,x——Intel 核显几乎总是它,QEMU 也是。小端机直接写数值 0xAARRGGBB,落到内存正好是 B,G,R,x,一个字节都不用换
  • 2 PixelBitMask:给 R/G/B 各自的位掩码,按掩码移位拼色
  • 3 PixelBltOnly:没有线性帧缓冲,只能调 GOP 的 Blt 函数——而 ExitBootServices 之后 Blt 就随固件一起没了。真硬件上罕见(个别虚拟化/特殊驱动才有),检测到就直接拒绝启动、串口报原因,别硬撑着写一个永远画不出的屏

模式选择不用纠结:遍历 max_mode 逐个 QueryMode,找到与面板原生分辨率一致的那个(通常 mode 0 就是)。工控单屏固定模式,选完写死,没有 DPI、没有多屏、没有热插拔。

二、像素与上屏:第一批代码

格式做成一个枚举,所有颜色先 pack 再落地,格式差异被关在这一处:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#[derive(Clone, Copy)]
pub enum PixelFmt { Bgrx, Rgbx, Mask { r: u32, g: u32, b: u32 } }

impl PixelFmt {
    #[inline]
    pub fn pack(self, r: u8, g: u8, b: u8) -> u32 {
        match self {
            PixelFmt::Bgrx => (b as u32) | (g as u32) << 8 | (r as u32) << 16,
            PixelFmt::Rgbx => (r as u32) | (g as u32) << 8 | (b as u32) << 16,
            PixelFmt::Mask { .. } => { /* 按掩码移位拼装 */ 0 }
        }
    }
}

pub struct Screen {
    base: *mut u32,        // frame_buffer_base
    width: usize,
    height: usize,
    pitch: usize,          // pixels_per_scan_line —— 真帧缓冲的行距
    fmt: PixelFmt,
    back: Vec<u32>,        // 后备缓冲,stride = width(来自内核堆)
}

pitch 的坑值得单独一节:不少平台 pixels_per_scan_line > horizontal_resolution(扫描硬件要求行首对齐,padding 补在行尾)。按 width 算地址的症状极具迷惑性——屏幕整体"斜"掉、每行往左错开 (pitch−width) 个像素的累积量,第一次见会以为是分辨率设错。纪律只有一条:真帧缓冲按 pitch 步进,后备缓冲按 width 步进,两套行距永远别混用

上屏因此必须逐行拷,不能一整块 memcpy:

1
2
3
4
5
6
7
8
9
pub fn present(&mut self) {
    unsafe {
        for y in 0..self.height {
            let dst = self.base.add(y * self.pitch);
            core::ptr::copy_nonoverlapping(
                self.back.as_ptr().add(y * self.width), dst, self.width);
        }
    }
}

双缓冲的账:1080p@32bpp 一帧 1920×1080×4 ≈ 7.9MB。直接往真帧缓冲上画,扫描扫到一半的界面会被用户看见;画在 back 上整帧 present,画面有原子性。工控界面以静态+局部刷新为主,配合脏矩形(图形篇展开)只拷变化的行区间,一次状态数字刷新通常 < 64KB——8MB 全刷和 32KB 局刷之间是 250 倍的差距,这套设计的基本盘就有了。

三、这块显存在哪:PCI 视角与"内存映射里查不到"的偷取内存

接管不止是拿到地址,还得知道这块内存的性质,否则后面分配器会替你踩雷。

PCIe 枚举 找到核显:Intel 平台上固定在 bus 0 : dev 2 : func 0(vendor 0x8086,class 03)。读它的 BAR:

  • BAR0 = GTTMMADR:寄存器窗口 + GTT(图形地址翻译表)所在
  • BAR2 = GMADR:GTT 映射窗口,CPU 通过它访问被图形驱动重映射的内存

GOP 给的 FrameBufferBase 在 Intel 核显上通常落在两处之一:stolen memory(固件开机时从物理内存顶部"偷"走一块给显示用,DSM/ISM),或 BAR2 窗口内的 GTT 映射地址。不用去猜是哪种——把 FrameBufferBase 跟两个 BAR 的区间比对一下就知道,这个核对同时也是防呆:万一某天换了独显(BAR 里分配显存),代码路径不用改。

真正要记住的事实是:stolen memory 不会出现在 UEFI 内存映射的可用区里——它不是 conventional memory,有的固件标成保留,有的干脆不提。这就是内存管理器篇坚持"GOP 区间显式排除"的原因:不排除,页帧分配器哪天把这段"没人认领"的内存分给内核堆,屏幕花掉的同时堆数据也废了,两头一起完蛋。

这块内存是真实 RAM(核显共享系统内存),可以回读:开机对前 4KB 做 memset+memcmp 自检,结果进自检报告。它是设备侧路径,默认缓存属性是 UC——慢的根源在下一节。

四、UC 太慢:MTRR 把帧缓冲改成写合并

先把现象摆出来:默认 UC(Uncacheable,强一致)属性下,CPU 对帧缓冲的每一笔写都直捅内存总线,没有缓存吸收、没有写合并。1080p 全屏填充在 UC 下是几十毫秒量级的事——图形界面稍微一动就有肉眼可见的拖影,present 变成主循环里最重的一步。

解法:把帧缓冲的物理区间设成 WC(Write Combining,写合并)——写操作先攒在合并缓冲里,成行地突发到内存。改法是 MTRR(Memory Type Range Registers),一组 MSR:

MSR 名称 用途
0xFE IA32_MTRRCAP bits 7:0 = 可用变量区间数(典型 8~10 对,够用但别浪费)
0x200 / 0x201 IA32_MTRR_PHYSBASE0 / PHYSMASK0 第一对变量区间,之后每对 +2
0x3FF IA32_MTRR_DEF_TYPE bit11 固定区间使能 / bit12 MTRR 总使能 / bits 10:8 默认类型
1
2
3
4
5
6
7
8
/// 把 [base, base+size) 设为 WC。size 必须是 2 的幂,base 按 size 对齐。
unsafe fn set_mtrr_wc(idx: usize, base: u64, size: u64, phys_bits: u32) {
    let phys_base = base | 0x1;                          // 低 8 位是类型:1 = WC
    let mask = !(size - 1) & ((1u64 << phys_bits) - 1);  // 物理地址宽度来自 CPUID.80000008
    let phys_mask = mask | (1 << 11);                    // bit11 = 该区间有效
    wrmsr(0x200 + 2 * idx, phys_base);
    wrmsr(0x201 + 2 * idx, phys_mask);
}

四个坑,每个都值一次烧机:

  • 对齐:size 必须 2 的幂、base 按 size 对齐。帧缓冲通常自带 MB 级对齐(stolen memory 整块划的),拿 size.next_power_of_two() 往上取;若扩出来的区间侵入了常规内存,宁可拆两段小幂拼,也别让 WC 盖到内核数据——WC 盖住普通内存的后果是读乱序,比慢可怕得多
  • 双核一致性:MTRR 是每核一份 MSR。SMP 唤醒之后两个核都要设相同值多核篇的 rendezvous 会合点正好干这个)。两核 MTRR 不一致的症状是"一核画得快一核画得慢还偶发花屏",极难查
  • 顺序:先读 MTRRCAP 确认有空闲对,写 PHYSBASE/PHYSMASK,最后才动 DEF_TYPE 的使能位;DEF_TYPE 要读-改-写,别的位别碰
  • WC 的可见性:写合并缓冲里的数据在读回、上锁、串行化指令前不一定落地——读自己刚写的像素前先 sfence,否则可能读到旧值

改造收益的量级:UC→WC 后全屏填充从几十 ms 掉到几 ms,差一个数量级起步。别信这里的数字,跑通后自己测一遍填色耗时记进日志当基线——这是后面所有图形优化的参照系。

五、撕裂怎么办

GOP 没有 flip/vsync 接口,present 的拷贝和扫描周期不同步:拷贝进行中恰好扫过那段,剧烈动画会有横向撕裂。双缓冲已经把窗口缩小到"上屏拷贝期间",而工控界面以静态显示和低频局部刷新为主,拷贝窗口里通常本来就没有正在变化的内容,撕裂无从谈起;哪天真要做剧烈动画了,再回头补 vsync(自己编程 Intel 显示引擎做页面翻转),基础篇不背这个包袱。

六、GOP 之后干不了的事

  • 换分辨率/换模式:ExitBootServices 之后 GOP 随固件消失。要换模式等于自己编程 Intel 的 PLL/pipe/port——EDID 篇讲了怎么读显示器的能力,真正的模式编程是自研显示线路的活,另文再说
  • 热插拔检测:DisplayPort 的 HPD 中断要自己接显示引擎的寄存器,GOP 时代不归我们管
  • 硬件加速 2D:Intel 有 legacy 2D BLT 引擎(XY_SRC_COPY_BLT 那套命令流),但喂它要建命令流、管 GTT,复杂度换来的收益对工控 UI 是负的——CPU + WC + 脏矩形已经富余,这条路明确不走
  • 多屏:专用系统单屏,不做

总结

实战清单:GOP Mode 五元组逐字段核对 + 四种 PixelFormat 处理(BltOnly 拒启);PixelFmt::pack 把格式差异关进一处;pitch 纪律 + 双缓冲 + 逐行 present;PCI 视角核对 BAR/stolen memory、分配器显式排除、回读自检;MTRR 四步(CAP 查空位 → PHYSBASE/PHYSMASK → DEF_TYPE 最后动 → 双核同设 + sfence 纪律)。跑通标志:全屏色条 + 四边走线不斜屏;帧缓冲回读自检通过;WC 前后的填色耗时差一个数量级并记录在案;分配器全量扫描确认帧缓冲区间从未被分出去。

下一篇在这块画布上建图形系统:渲染原语、Unicode 点阵字体、控件和事件分发。

参考链接