前言
本质上,谈及图形原理必会涉及渲染和显示两部分。但是显示过程比较简单和直接,而渲染过程要复杂得多,更重要的是,渲染牵扯到操作系统内部的组件更多,因此,本章我们主要讨论渲染过程。我们不想只浮于理论,结合具体的 GPU 进行讨论更有助于深度理解计算机的图形原理。相比于 NV 及 ATI 的 GPU ,我们选择相对更开放一些的 Intel 的 GPU 进行讨论。Intel 的 GPU 也在不断的演进,本书写作时主要针对的是用在 Sandy Bridge 和 Ivy Bridge 架构上的 Intel HD Graphics 。
显存是图形渲染的基础,也是理解图形原理的基础,因此,本章我们从讨论显存开始。或许读者会说,显存有什么好讨论的,不就是一块存储区吗?早已是陈词滥调。但是事实并非如此,通过显存的讨论,我们会注意到 CPU 和 GPU 融合的脚步,会看到它们是如何的和谐共享物理内存的。或许,已经有 GPU 和 CPU 完美地进行统一寻址了。
然后,我们分别讨论 2D 和 3D 的渲染过程。在其间,我们将看到到底何谓硬件加速,我们也会从更深的层次去展示 3D 渲染过程中所谓的 Pipeline 。以往,很多教材都会为了辅助 OpenGL 的应用开发,多少从理论上谈及一点 Pipeline ,而在这一章中,我们从操作系统角度和 Pipeline 进行一次亲密接触。
最后,我们讨论了很多读者认为神秘而陌生的 Wayland 。其实,Wayland 既不神秘也不陌生,它是在 DRI 和复合扩展发展的背景下产生的,基于 DRI 和复合扩展演进的成果。从某个角度,Wayland 更像是去除了基于网络的服务器/客户端的 X 和复合管理器的一次整合。
一、渲染和显示
计算机将图形显示到显示设备上的过程,可以划分为两个阶段:第一阶段是渲染(render)过程,第二阶段是显示(display)过程,如图 8-1 所示。
1、渲染
所谓渲染就是将使用数学模型描述的图形,如 “DrawRectangle(x1,y1,x2,y2)” ,转化为像素阵列,或者叫像素数组。像素数组中的每个元素是一个颜色值或者颜色索引,对应图像的一个像素。对于 X 窗口系统,数组中的元素是一个颜色索引,具体的颜色根据这个索引从颜色映射(Colormap)中查询得来。
渲染通常又被分为两种:一种渲染过程是由 CPU 完成的,通常称为软件渲染,另外一种是由 GPU 完成的,通常称为硬件渲染,也就是我们常常提到的硬件加速。
谈及渲染,不得不提的另外一个关键概念就是帧缓冲(Framebuffer)。从字面意义上讲,frame 表示屏幕上的某个时刻对应的一帧图像,buffer 就是一段存储区了,因此,在狭义上,合起来的这个词就是指存储一帧屏幕图像像素数据的存储区。
但是从广义上,帧缓冲则是多个缓冲区的统称。比如在 OpenGL 中,帧缓冲包括用于输出的颜色缓冲(Color Buffer),以及辅助用来创建颜色缓冲的深度缓冲( Depth Buffer )和模板缓冲( Stencil Buffer)等。即使在 2D 环境中,帧缓冲这个概念也不仅指屏幕上的一帧图像,还包含用于存储命令的缓冲(Command Buffer)等。每一个应用都有自己的一套帧缓冲。
在 OpenGL 环境中,为了避免渲染和显示过程交叉导致冲突,从而出现如撕裂(tearing)以及闪烁(flicker)等现象,颜色缓冲又被划分为前缓冲(front buffer)和后缓冲(back buffer)。如果为了支持立体效果,则前缓冲和后缓冲又分别划分为左和右各两个缓冲区,我们不讨论这种情况。前缓冲和后缓冲中的内容都是像素阵列,每个像素或者是一个颜色值,或者是一个颜色索引。只不过前缓冲用于显示,后缓冲用于渲染。
2D 可以看作 3D 的一个特例,因此,我们将 2D 程序中用于输出的缓冲区称为前缓冲。为了避免歧义,在容易引起混淆的地方我们尽量不使用这个多义的帧缓冲一词。
2、显示
一般而言,显示设备也使用像素来衡量,比如屏幕的分辨率为 1366×768,那么其可以显示 1049 088 个像素,一个像素对应屏幕上的一个点,图像就是通过这些点显示出来的。通常,图像中一个像素对应屏幕上的一个像素,那么将图像显示到屏幕的过程就是逐个读取帧缓冲中存储的图像的像素,根据其所代表的颜色值,控制显示器上对应的点显示相应颜色的过程。
通常,显示过程基本上要经过如下几个组件:显示控制器(CRTC)、编码器(Encoder)、发射器(Transmitter)、连接器(Connector),最后显示在显示器上。
(1)显示控制器
显示控制器负责读取帧缓冲中的数据。对于 X 来说,帧缓冲中存储的是颜色的索引,显示控制器读取索引值后,还需要根据索引值从颜色映射中查询具体的颜色值。显示控制器也负责产生同步信号,典型的如水平同步信号(HSYNC)和垂直同步信号(VSYNC)。水平同步信号目的是通知显示设备开始显示新的一行,垂直同步信号通知显示设备开始显示新的一帧。所谓同步,以垂直同步信号为例,我们可以这样来通俗地理解它:显示控制器开始扫描新的一帧数据了,因此它通过这个信号告诉显示器开始显示,跟上我,不要掉队,这就是同步的意思。以 CRT 显示器为例,这两个信号控制着电子枪的移动,每显示完一行,电子枪都会回溯到下一行的开始,等待下一个水平同步信号的到来。每显示完一帧,电子枪都会回溯到屏幕的左上角,等待一下垂直同步信号的到来。
(2)编码器
对于帧缓冲中每个像素,可能使用 8 位、16 位、32 位甚至更多的位来表示颜色值,但是对于具体的接口来说,却远没有这么多的数据线供使用,而且不同的接口有不同的格式规定。比如对于 VGA 接口来说,总共只有三根数据线,每个颜色通道占用一根数据线;对于 LVDS 来说,数据是串行传输的。因此,需要将 CRTC 读取的数据编码为适合具体物理接口的编码格式,这就是编码器的作用。
(3)发射器
发射器将经过编码的数据转变为物理信号。读者可以将其想象成:发射器将 1 转化为高电平,将 0 转化为低电平。当然,这只是一个形象的说法。
(4)连接器
连接器有时也被称为端口(Port),比如 VGA 、LVDS 等。它们直接连接着显示设备,负责将发射器发出的信号传递给显示设备。
二、显存
Intel 的 GPU 集成到芯片组中,一般没有专用显存,通常是由 BIOS 从系统物理内存中分配一块空间给 GPU 作专用显存。一般而言,BIOS 会有个默认的分配规则,有的 BIOS 也会为用户留有接口,用户可以通过 BIOS 设置显存的大小。如对于具有 1GB 物理内存的系统来说,可以划分 256MB 内存给 GPU 用作显存。
但是这种静态的分配方式带来的问题之一就是如何平衡系统与显示占用的内存,究竟分配多少内存给 GPU 才能在系统常规使用和运行图形计算密集的应用(如 3D 应用)之间达到最优。如果分配给 GPU 的显存少了,那么在进行图形处理时性能必然会降低。而单纯提高分配给 GPU 的显存,也可能会造成系统的整体性能降低。而且,过多的分配内存给显存,那么当不运行 3D 应用时,就是一种内存浪费。毕竟,用户的使用模式不会是一成不变的,比如对于一个程序员来说,在编程之余也可能会玩一些游戏。但是我们显然不能期望用户根据具体运行应用的情况,每次都进入 BIOS 修改内存分配给显存的大小。
为了最优利用内存,一种方式就是不再从内存中为 GPU 分配固定的显存,而是当 GPU 需要时,直接从系统内存中分配,不使用时就归还给系统使用。但是 CPU 和 GPU 毕竟是两个完全独立的处理器,虽然现在 CPU 和 GPU 正在走融合之路,但是它们依然有自己的地址空间。显然,我们不能允许 CPU 和 GPU 彼此独立地去使用物理内存,这样必然会导致冲突,也正是因为这个原因,才有了我们前面提到的 BIOS 会从物理内存中划分一块区域给 GPU ,这样 CPU 和 GPU 才能井水不犯河水,分别使用属于自己的存储区域。
1、动态显存技术
为了解决这个矛盾,Intel 的开发者们开发了动态显存技术(Dynamic Video Memory Technology),相比于以前在内存中为 GPU 开辟专用显存,使用动态显存技术后,显存和系统可以按需动态共享整个主存。
动态显存中关键的是 GART(graphics address remapping table),也被称为 GTT(graphics translation table),它是 GPU 直接访问系统内存的关键。事实上,这是 CPU 和 GPU 的融合过程中的一个产物,最终,CPU 和 GPU 有可能完全实现统一的寻址。
GTT 就是一个表格,或者说就是一个数组,表格中的每一个表项占用 4 字节,或者指向物理内存中的一个页面,或者设置为无效。整个 GTT 所能寻址的范围就代表了 GPU 的逻辑寻址空间,如 512KB 大小的 GTT 可以寻址 512MB 的显存空间(512K/4*4KB=512MB),如图 8-2 所示。
这是一种动态按需从内存中分配显存的方式,GTT 中的所有表项不必全部都映射到实际的物理内存,完全可以按需映射。而且当 GTT 中的某个表项指向的内存不再被 GPU 使用时,可以收回为系统所用。通过这种动态按需分配的方式,达到系统和 GPU 最优分享内存。内核中的 DRM 模块设计了特殊的互斥机制,保证 CPU 和 GPU 独立寻址物理内存时不会发生冲突。
我们注意到,GPU 是通过 GTT 访问内存的(内存中用作显存的部分),所以 GPU 首先要访问 GTT,但是,GTT 也是在内存中。显然,这又是一个先有鸡还是先有蛋的问题。因此,需要另外一个协调人出现,这个协调人就是 BIOS 。在 BIOS 中,仍然需要在物理内存中划分出一块对操作系统不可见、专用于显存的存储区域,这个区域通常也称为 Graphics Stolen Memory 。但是相比于以前动辄分配几百兆的专用显存给 GPU ,这个区域要小多了,一般几 MB 就足矣,如我们前面讨论的寻址 512MB 的显存,只需要一个 512KB 的 GTT 表。
BIOS 负责在 Graphics Stolen Memory 中建立 GTT 表格,初始化 GTT 表格的表项,更重要的是,BIOS 负责将 GTT 的相关信息,如 GTT 的基址,写入到 GPU 的 PCI 的配置寄存器(PCI Configuration Registers),这样,GPU 可以直接找到 GTT 了。
2、Buffer Object
与 CPU 相比,GPU 中包含大量的重复的计算单元,非常适合如像素、光影处理、3D 坐标变换等大量同类型数据的密集运算。因此,很多程序为了能够使用 GPU 的加速功能,都试图和 GPU 直接打交道。因此,系统中可能有多个组件或者程序同时使用 GPU ,如 Mesa 中的 3D 驱动、X 的 2D 驱动以及一些直接通过帧缓冲驱动直接操作帧缓冲的应用等。但是多个程序并发访问 GPU ,一旦逻辑控制不好,势必导致系统工作极不稳定,严重者甚至使 GPU 陷入一个混乱的状态。
而且,如果每个希望使用 GPU 加速的组件或程序都需要在自身的代码中加入操作 GPU 的代码,也使开发过程变得非常复杂。
于是,为了解决这一乱象,开发者们在内核中设计了 DRM 模块,所有访问 GPU 的操作都通过 DRM 统一进行,由 DRM 来统一协调对 GPU 的访问,如图 8-3 所示。
DRM 的核心是显存的管理,当前内核的 DRM 模块中包含两个显存管理机制:GEM 和 TTM。TTM 先于 GEM 开发,但是 Intel 的工程师认为 TTM 比较复杂,所以后来设计了 GEM 来替代 TTM 。目前内核中的 ATI 和 NVIDA 的 GPU 驱动仍然使用 TTM ,所以 GEM 和 TTM 还是共存的,但是 GEM 占据主导地位。
GEM 抽象了一个数据结构 Buffer Object ,顾名思义,就是一块缓冲区,但是比较特别,是 GPU 使用的一块缓冲区,也就是一块显存。比如一个颜色缓冲的像素阵列保存在一个 Buffer Object ,绘制命令以及绘制所需数据也分别保存在各自的 Buffer Object,等等。笔者实在找不到一个准确的中文词汇来代表 Buffer Object,所以只好使用这个英文名称。开发者习惯上也将 Buffer Object 简称为 BO,后续为了行文方便,我们有时也使用这个简称,其定义如下:
// linux-3.7.4/include/drm/drmP.h: struct drm_gem_object { ... struct file *filp; ... int name; ... };
其中两个关键的字段是 filp 和 name 。
对于一个 BO 来说,可能会有多个组件或者程序需要访问它。GEM 使用 Linux 的共享内存机制实现这一需求,字段 filp 指向的就是 BO 对应的共享内存,代码如下:
// linux-3.7.4/drivers/gpu/drm/drm_gem.c: int drm_gem_object_init(...) { ... obj->filp = shmem_file_setup("drm mm object", size, ...); ... }
既然多个组件需要访问 BO,GEM 为每个 BO 都分配了一个名字。当然这个名字不是一个简单的字符,它是一个全局唯一的 ID,各个组件使用这个名字来访问 BO 。
BO 可以占用一个页面,也可以占用多个页面。但是,通常 BO 都是占用整数个页面,即 BO 的大小一般是 4KB 的整数倍。在 i915 的 BO 的结构体定义中,数据项 pages 指向的就是 BO 占用的页面的链表,这里并不是使用的简单的链表,结构体 sg_table 使用了散列技术。具体代码如下:
// linux-3.7.4/drivers/gpu/drm/i915/i915_drv.h: struct drm_i915_gem_object { ... struct sg_table *pages; ... };
为了可以被 GPU 访问,BO 使用的内存页面还要映射到 GTT 。这个映射过程也比较直接,就是将 BO 所在的页面填入到 GTT 的表项中。
综上,我们看到,BO 本质上就是一块共享内存,对于 CPU 来说 BO 与其他内存没有任何差别,但是 BO 又是特别的,它被映射进了 GTT,所以它既可以被 CPU 寻址,也可以被 GPU 寻址,如图 8-4 所示。
为了方便程序使用内核的 DRM 模块,开发者们开发了库 libdrm 。在库 libdrm 中 BO 的定义如下:
// libdrm-2.4.35/intel/intel_bufmgr.h: struct _drm_intel_bo { ... unsigned long offset; ... void *virtual; ... };
其中两个重要的数据项是 offset 和 virtual 。
事实上,BO 只是 DRM 抽象的在内核空间代表一块显存的一个数据结构。那么 GPU 是怎么找到 BO 的呢?如同 CPU 使用地址寻址一个内存单元一样,GPU 也使用地址寻址。GPU 根本不关心什么 BO ,它只认显存的地址。因此,每一个 BO 在显存的地址空间中,都有一个唯一的地址,GPU 通过这个地址寻址,这就是 offset 的意义。offset 是 BO 在显存地址空间中的虚拟地址,显存使用线性地址寻址,任何一个显存地址都是从起始地址的偏移,这就是 offset 命名的由来。offset 通过 GTT 即可映射到实际的物理地址。当我们向 GPU 发出命令访问某个 BO 时,就使用 BO 的成员 offset 。
有时需要将 BO 映射到用户空间,其中数据项 virtual 就是记录映射的基址。
前面,我们讨论了 BO 的本质。下面我们从使用的角度看一看 CPU 与 GPU 又是如何使用 BO 的。BO 是显存的基本单元,所以从保存像素阵列的帧缓冲,到 CPU 下达给 GPU 的指令和数据,全部使用 BO 承载。下面,我们分别从软件渲染和硬件渲染两个角度看看 BO 的使用。
(1)软件渲染
当 GPU 不支持某些绘制操作时,代表帧缓冲的 BO 将被映射到用户空间,用户程序直接在 BO 上使用 CPU 进行软件绘制。从这里我们也可以看出,DRM 巧妙的设计使得 BO 非常方便地在显存和系统内存之间进行角色切换。
(2)硬件渲染
当 GPU 支持绘制操作时,用户程序则将命令和数据等复制到保存命令和数据的 BO ,然后 GPU 从这些 BO 读取命令和数据,按照 BO 中的指令和数据进行渲染。
库 libdrm 中提供了函数 drm_intel_bo_subdata 和 drm_intel_bo_get_subdata ,在程序中一般使用这两个函数将用户空间的命令和数据复制到内核空间的 BO 读者也会见到 dri_bo_subdata 和 dri_bo_get_subdata 。对于 Intel 的驱动来说,后面两个函数分别是前面两个函数的别名而已。后面讨论具体渲染过程时,我们会经常看到这几个函数。
Linux DRM(四) – loongson driver
深度探索Linux操作系统 —— Linux图形原理探讨2: https://developer.aliyun.com/article/1598096