跳转至

Linux 桌面与窗口系统

主要作者

@taoky

本文编写中

相比于久负盛名的 Windows 与 macOS,Linux 的桌面以及其生态是独特的。本文将简单介绍 Linux 桌面与窗口系统中一些重要的概念。

X

以下未特殊标明的情况下,X11 协议均使用 Xorg 这个目前最主流的 X 实现。

X、X11、Xorg 的区别⁠

在讨论的时候,我们经常能看到 X、X11、Xorg 这些术语。它们的区别如下:

  • X:泛指 X 窗口系统(X Window System)
  • X11:指 X 窗口系统的第 11 个版本(X Version 11),是目前仍在使用的版本,也极大概率是最后一个版本了
  • Xorg:由 X.Org 基金会维护的 X11 实现,是目前最主流的 X server 实现

@taoky: 关于 XLibre⁠

如果关注 Linux 桌面相关新闻的话,你可能会听说过 XLibre——一个 Xorg 的 fork。随着开发重心从 Xorg 到 Wayland 的转移,Xorg 除去 Xwayland 以外的部分版本发布的频次大幅下降(事实上,尽管仓库仍然一直在更新,Xorg 已经多年没有发布新的大版本了,可以认为除了安全漏洞修复以外,Xorg 很可能不会再有新的大型变更了)。XLibre 的主开发者 metux 在给 Xorg 提交代码时,由于代码质量与兼容性问题与 Xorg 的维护者产生了矛盾,之后在 freedesktop 的 GitLab 上 fork 了 Xorg,修改 README 发表了辱骂 Xorg 开发者的言论而被 freedesktop 封禁。

相比于最新的 Xorg 的稳定版本,XLibre 主要的变化有添加了用于隔离的 Xnamespace 扩展(但是目前没有相关的软件生态)、默认启用防撕裂(TearFree)等,并且版本发布也比 Xorg 快得多。不过围绕 XLibre 的争议也非常多,除了政治与观点相关的争议以外,其代码质量也受到怀疑,一个名场面是 2^16

PR: 2^16 is 2 xor 16 which equals 18, not 2 to the power of 16 which is 65536

metux: Why is 2^16 wrong ? Shouldn't it result in exactly the same ?

相信有 C 基础知识的人都能发现问题在哪里。

我个人也同样不看好 XLibre:

  • 如果 Xorg 仍然有着巨大的技术优势,仅仅只是 freedesktop 不想继续维护的话,那么类似的 fork 应该很早就出现了。
  • metux 本人极端的观点在创建了支持自己的「同温层」的同时,也同时影响了其他人的参与——包括观点中立的个人,以及希望继续使用活跃维护的 X 实现的公司。当然,XLibre 目前的 README,以及 issue 和 discussion 已经收敛很多了,基本回归了正常的技术交流。不过恐怕像 NVIDIA 这样的 "BigTech" 批斗对象,大概率是不会协作做 XLibre 的驱动开发的。
  • X 的很多问题不是在服务端做修改就能完善解决的。

不过,XLibre 是会实际替代 Xorg,或是平分秋色,还是只会成为很小一部分人的选择,只有时间才能告诉我们答案。

部分内容在主章节中有介绍⁠

如果你想知道怎么进行 SSH X Forwarding,以及如何在容器中运行 X 程序,可以参考容器章节中的相关内容

本部分不是 X 的开发介绍⁠

如果对 X 的实现细节有兴趣,可以参考这个交互式的教程:xplain

客户端、服务端与窗口

X 窗口系统起源于 1984 年。在那个时代,桌面环境没有酷炫的效果,相比之下,性能与资源占用重要得多。并且当时个人计算机还是一个新兴的概念,用户更多的时候需要使用终端机连接到服务器上运行任务。因此,X 的设计上包含了当时那个年代设计的局限性,并且有着独特的「网络透明性」的设计:需要显示窗口的程序(客户端)和可以给用户显示窗口的程序(服务端)是可以分离的,通过网络去连接。对于单机场景,这里的「网络」大部分时候是 UNIX socket,而在诸如 SSH X Forwarding 这种通过网络连接的场合则是 TCP socket。

默认情况下,如果你正在使用 Linux 桌面,假设环境变量 DISPLAY=:0,那么客户端默认连接到的 socket 按 libxcb 的顺序则为 @/tmp/.X11-unix/X0(最新代码移除了支持,见下文)、/tmp/.X11-unix/X0 和 TCP localhost:6000。Xlib 库也会调用 libxcb 来选择、打开 socket。

X 的抽象套接字支持⁠

Linux 支持「抽象套接字」(abstract socket),即允许 Unix socket 绑定到一个不在文件系统中的地址(正常的 Unix socket 需要将地址设置为一个文件路径)。在编写代码时,将 bind() 路径(sun_path)的开头设置为 NULL 就表示抽象套接字。可以查看 /proc/net/unix 文件,其中以 @ 开头的条目则是抽象套接字。

可以注意到,默认情况下,X 服务端会同时监听 /tmp/.X11-unix/X0@/tmp/.X11-unix/X0

$ cat /proc/net/unix | grep X11-unix/X0
000000002c61e829: 00000003 00000000 00000000 0001 03 379918 @/tmp/.X11-unix/X0
(省略)
0000000055982f40: 00000002 00000000 00010000 0001 01 20744 /tmp/.X11-unix/X0
(省略)

X 在 2008 年引入这个特性时的相关说明如下:

Unlike normal unix sockets, the abstract namespace is not bound to the
filesystem.  This has some notable advantages; /tmp need not exist, the
socket directory need not have magic permissions, etc.  xtrans servers
will listen on both the normal and abstract socket endpoints; clients
will attempt to connect to the abstract socket before connecting to the
corresponding filesystem socket.

抽象套接字在如今带来了一些安全性的挑战,因为和文件系统上的 /tmp/.X11-unix/X0 可以依靠文件级别的权限控制不同,抽象套接字只能通过网络命名空间实现隔离。但是如果直接关闭 X server 的抽象套接字,攻击者可以创建虚假的名为 @/tmp/.X11-unix/X0 的套接字,欺骗 X 客户端连接。不过连接到 X server 还需要经过一层认证机制(XAuthority),因此如果不去 xhost + 的话,攻击者必须要能够获取 XAuthority 信息,才能够连接到对应的 X server。

目前最新 libxcb 的代码已经移除了抽象套接字的支持

启动一个新的 X Server⁠

存在这样一种场景:你需要启动一个独立的 X server 来测试,而不希望对应的程序使用当前的 X server。其中一个便利的工具是 xvfb-run:Xvfb 是一个无头(无显示)的 X server,对自动化测试场景来说很方便。安装 xvfb 包后,即可使用:

xvfb-run -f xvfb-auth -n 99 xeyes

这里我们设置 XAUTHORITY 文件为 xvfb-auth,并且 DISPLAY:99。关于 XAUTHORITY,请参考容器部分的介绍。然后可以通过以下命令确认:

$ DISPLAY=:99 XAUTHORITY=./xvfb-auth xlsclients 
examplehost  xeyes

如果希望创建一个 X server 并且能够以子窗口的形式显示出来,那么可以考虑使用 Xephyr 或者 Xwayland 来创建。以 Xephyr 为例,以下命令可以创建一个 800x600 的 X server,并且以窗口的形式显示:

Xephyr :123 -ac -screen 800x600

其他应用可以直接用 DISPLAY=:123 环境变量连接到这个 server。在 Wayland 环境下,也可以使用 xwayland-run,以 Xwayland 的 "rootful" 模式运行一个新的 X server。

可以运行 xlsclients 获取连接到当前 X 服务器的客户端列表:

$ xlsclients
examplehost gsd-xsettings
examplehost steamwebhelper
examplehost code
examplehost mutter-x11-frames
examplehost steam

客户端可以创建一个或多个窗口,可以使用 xwininfo 获取窗口信息:

$ xwininfo -root -tree

xwininfo: Window id: 0x503 (the root window) (has no name)

  Root window id: 0x503 (the root window) (has no name)
  Parent window id: 0x0 (none)
     57 children:
     0x1a00004 "desktop.md - Linux201-docs - Visual Studio Code": ("code" "Code")  1920x1200+306+1440  +306+1440
        1 child:
        0x2200007 (has no name): ()  1920x1200+0+0  +306+1440
(以下省略)

反直觉的是,这里「窗口」的概念可能比你想象的要广得多——在传统的 X11 应用程序中,很多小控件(例如按钮、输入框)也都是窗口。可以尝试打开一个比较复杂的传统 X 程序(例如 xedit),然后 xwininfo 看一下:

xedit window

xedit 的界面

$ xwininfo -name xedit -tree

xwininfo: Window id: 0x4200072 "xedit"

  Root window id: 0x9cf (the root window) (has no name)
  Parent window id: 0x3800096 (has no name)
     1 child:
     0x4200073 (has no name): ()  590x440+0+0  +959+143
        6 children:
        0x4200099 (has no name): ()  8x8+572+436  +1531+579
        0x420007b (has no name): ()  8x8+572+84  +1531+227
        0x420007c (has no name): ()  590x351+0+89  +959+232
           4 children:
           0x420008a (has no name): ()  8x8+586+333  +1545+565
           0x420008c (has no name): ()  1x1+0+0  +959+232
              6 children:
              0x4200096 (has no name): ()  179x21+0+0  +960+233
                 2 children:
                 0x4200098 (has no name): ()  85x17+2+2  +963+236
                 0x4200097 (has no name): ()  64x17+87+2  +1048+236
              0x4200094 (has no name): ()  100x18+0+0  +960+233
                 1 child:
                 0x4200095 (has no name): ()  14x4+-1+-1  +960+233
              0x4200093 (has no name): ()  8x8+0+0  +960+233
              0x4200092 (has no name): ()  64x17+0+0  +960+233
              0x420008e (has no name): ()  1x1+0+0  +960+233
                 2 children:
                 0x4200091 (has no name): ()  14x1+-1+-1  +960+233
                 0x420008f (has no name): ()  1x1+15+0  +976+234
                    1 child:
                    0x4200090 (has no name): ()  1x19+0+0  +976+234
              0x420008d (has no name): ()  8x8+0+0  +960+233
           0x420008b (has no name): ()  8x8+0+0  +959+232
           0x420007d (has no name): ()  590x351+0+0  +959+232
              6 children:
              0x4200083 (has no name): ()  8x8+572+347  +1531+579
              0x4200087 (has no name): ()  179x21+0+0  +959+232
                 2 children:
                 0x4200089 (has no name): ()  85x17+2+2  +962+235
                 0x4200088 (has no name): ()  64x17+87+2  +1047+235
              0x4200085 (has no name): ()  100x18+0+0  +959+232
                 1 child:
                 0x4200086 (has no name): ()  14x4+-1+-1  +959+232
              0x4200084 (has no name): ()  8x8+0+0  +959+232
              0x4200081 (has no name): ()  590x332+0+19  +959+251
                 1 child:
                 0x4200082 (has no name): ()  14x332+-1+-1  +958+250
              0x420007e (has no name): ()  590x18+0+0  +959+232
                 2 children:
                 0x4200080 (has no name): ()  496x15+2+1  +961+233
                 0x420007f (has no name): ()  90x15+498+1  +1457+233
        0x420007a (has no name): ()  590x50+0+38  +959+181
        0x4200079 (has no name): ()  590x18+0+19  +959+162
        0x4200074 (has no name): ()  590x18+0+0  +959+143
           4 children:
           0x4200078 (has no name): ()  479x18+111+0  +1070+143
           0x4200077 (has no name): ()  36x18+74+0  +1033+143
           0x4200076 (has no name): ()  36x18+37+0  +996+143
           0x4200075 (has no name): ()  36x18+0+0  +959+143

这和 Windows 的传统桌面 API 的设计是非常类似的。不过创建大量的小窗口需要消耗不少的系统资源,因此目前常见的现代 UI 框架,不管是在 Linux 还是在 Windows 上,都基本上抛弃了这种「万物皆窗口」的理念。

窗口管理器

另一点有趣的是,尽管 X 中存储了各个窗口的状态(以及它们在 Z 轴的栈式关系),但是 X 本身不会去管理这些窗口要怎么被用户移动、缩放、最大最小化等,也不会去尝试装饰窗口,它只会按照自己记录的状态把这些窗口显示出来。对于具体的窗口管理工作,X 就当起了甩手掌柜,把事情都交给了窗口管理器。窗口管理器是一个特殊的 X 客户端,所有我们常用的窗口功能都是由窗口管理器负责的,包括但不限于管理窗口的显示布局、窗口装饰、焦点控制、虚拟桌面等等。X 服务端允许窗口管理器捕获创建窗口的事件,并且允许窗口管理器将对应的窗口 "reparent" 到窗口管理器创建的框架窗口中,以此实现让程序窗口被窗口管理器控制、装饰的效果。

窗口管理器与 X 服务器之间的交互有一些标准规范,例如 ICCCM 与 EWMH,以减小不同的窗口管理器实现之间的混乱与不一致问题。

窗口管理器本身也是一个独立的进程,如果窗口管理器退出,那么其他的 X 客户端不会停止运行,但是你可能无法再控制它们了(例如,可能它们被别的窗口挡住了,而没有窗口管理器的装饰的话,你可能没有办法移动它们)。这种分离的设计也帮助孕育了很多独特的窗口管理器设计,例如平铺式窗口管理器(例如 i3wm),相比于传统的浮动式窗口管理器,可以自动以不重叠的方式显示当前的所有窗口,用户不需要再用鼠标手动调整每个窗口的大小等等。

输入

输入设备

Linux 的输入子系统暴露的设备在 /dev/input 中,用户空间可以打开设备文件以读取输入设备的信息。可以通过 evtest 工具来查看输入设备的事件:

$ sudo evtest
No device specified, trying to scan all of /dev/input/event*
Available devices:
(省略)
Select the device event number [0-17]: 7
(选择鼠标设备,省略)
Testing ... (interrupt to exit)
Event: time 1760901205.303790, type 2 (EV_REL), code 0 (REL_X), value -1
Event: time 1760901205.303790, -------------- SYN_REPORT ------------
Event: time 1760901205.343787, type 2 (EV_REL), code 0 (REL_X), value 1
Event: time 1760901205.343787, -------------- SYN_REPORT ------------
Event: time 1760901205.363788, type 2 (EV_REL), code 0 (REL_X), value 1
Event: time 1760901205.363788, -------------- SYN_REPORT ------------
(省略接下来鼠标移动的事件)

在较早的 Xorg 实现中,X server 会使用 evdev 驱动(xf86-input-evdev)直接读取 /dev/input 中的设备文件以获取输入事件,但是目前绝大部分情况下,evdev 驱动已经不再使用,X server 通过 libinput(xf86-input-libinput)来处理输入设备。libinput 是一个通用的输入处理库,由它解析输入事件后再传递给 X server。

libinput 则可以通过 libinput list-devices 来查看;libinput 程序还支持类似 evtest 的实时事件查看功能,可以使用 libinput debug-events 来查看输入事件:

$ sudo libinput list-devices
Device:                  Power Button
Kernel:                  /dev/input/event1
Id:                      host:0000:0001
Group:                   1
Seat:                    seat0, default
Capabilities:            keyboard 
(以下省略)
$ sudo libinput debug-events /dev/input/event7
-event7   DEVICE_ADDED                 Logitech G304                     seat0 default group1  cap:kp left scroll-nat scroll-button
 event7   POINTER_MOTION               +0.000s	  0.30/  0.00 ( +1.00/ +0.00)
 event7   POINTER_MOTION            2  +0.003s	  1.81/  0.00 ( +2.00/ +0.00)
 event7   POINTER_MOTION            3  +0.007s	  2.22/  0.00 ( +2.00/ +0.00)

而如果要确认 X server 识别到了哪些输入设备,可以使用 xinput 工具。由于 xinput 是和 X server(而不是和设备文件)交互,因此不需要特权。以下是在 Xwayland 下执行的结果:

$ xinput list
WARNING: running xinput against an Xwayland server. See the xinput man page for details.
⎡ Virtual core pointer                    	id=2	[master pointer  (3)]
⎜   ↳ Virtual core XTEST pointer              	id=4	[slave  pointer  (2)]
⎜   ↳ xwayland-pointer:16                     	id=6	[slave  pointer  (2)]
⎜   ↳ xwayland-relative-pointer:16            	id=7	[slave  pointer  (2)]
⎜   ↳ xwayland-pointer-gestures:16            	id=8	[slave  pointer  (2)]
⎣ Virtual core keyboard                   	id=3	[master keyboard (2)]
    ↳ Virtual core XTEST keyboard             	id=5	[slave  keyboard (3)]
    ↳ xwayland-keyboard:16                    	id=9	[slave  keyboard (3)]

输入法

最早期的 X 设计上完全没有考虑输入法的问题。然而在东亚语言(中文、日文、韩文,CJK)场景下,输入法是正常使用桌面的必需组件。因此 X 在 1994 年尝试设计了被称为 XIM(X Input Method)的输入法框架,但是这一套框架逐渐无法满足现代 UI 框架与输入法的需求。因此目前在 X 上,主流的 IBusFcitx(包括 Fcitx 4 与 Fcitx 5)均使用另一种方案:在 GTK 或 Qt 这样的图形库中直接集成输入法支持,而不再使用 XIM。GTK 或 Qt 会直接调用输入法模块,模块内会通过 DBus 与输入法进程通信,实现功能。

这也是在做输入法配置时经常提到需要修改环境变量的原因。以 Fcitx 5 为例,通常需要设置以下环境变量:

XMODIFIERS="@im=fcitx"
GTK_IM_MODULE="fcitx"
QT_IM_MODULE="fcitx"

如果应用程序不使用 GTK 或 Qt,那么一般来讲考虑到输入法需求的应用会基于 XIM 方案实现支持,即 XMODIFIERS 环境变量指定的输入法。

Debian 的 im-config 工具⁠

Debian 提供了 im-config 交互式工具,用于在 X 环境下自动配置输入法相关的环境变量。

im-config

im-config 会写入 ~/.xinputrc/etc/X11/xinit/xinputrc 文件:

# im-config(8) generated on Sat, 02 May 2026 16:22:08 +0000
run_im fcitx5
# im-config signature: 10c4f170d207fa51e0e4fad7fff83b2d  -

显示管理器启动 X session 的时候,/etc/X11/Xsession.d/70im-config_launch 这个 shell 脚本会在输入法相关环境变量都没有配置的情况下加载 /usr/share/im-config/xinputrc.common 中的函数,加载 xinputrc 配置(执行 run_im 函数),并导出设置的环境变量。这个脚本也同时会用 im-launch 来启动输入法。

im-config 也尝试兼容了 Wayland 桌面环境,但是不推荐使用

输出

显示支持与显卡

在早期,显卡只做一件事情:把帧缓冲区(framebuffer)的内容输出到显示器上。此时,显存就是一段内存空间,修改内容,显示器上对应的像素就会变化。帧缓冲区在 Linux 上暴露为 /dev/fb0 这样的设备文件,用户空间程序可以直接打开并且修改它的内容以读取分辨率等信息,并改变显示器上的内容。此时,X server 使用 fbdev(xf86-video-fbdev)驱动来操作帧缓冲区。

尝试直接与 /dev/fb0 交互,在 TTY 中输出图片⁠

尝试搜索资料,写一个程序,打开 /dev/fb0,并使用 ioctl 读取必要的信息,然后 mmap 映射帧缓冲区后,将你想显示的图片数据写入对应的内存区域。

但是之后,显示加速的需求越来越大,显卡厂商之间设计的差异也越来越大,fbdev 已经不够用了。之后出现的一种解决方案是:编写 X 的输出驱动,直接操作 /dev/mem,通过物理地址访问显存,从而实现对显卡的控制。但是这种设计有很多问题:X 需要用 root 权限运行;如果 X 崩溃了,那么显卡的状态很可能也会坏掉;X 与 OpenGL 之间的协作也有不少问题。

因此内核提供了 DRM(Direct Rendering Manager)子系统来统一管理显卡资源,并且因此 GPU 驱动被分为了两部分:一部分在内核空间的 DRM 中(Kernel Mode Driver,KMD),另一部分在用户空间实现(User Mode Driver,UMD),很大程度缓解了所有显卡的东西都挤在 X 里面的混乱局面。此时,应用程序如果需要 GPU 加速渲染,就直接和 GPU 设备通信,而不是和 X 通信,这个架构被称为 DRI(Direct Rendering Infrastructure)。你可以在 /dev/dri 中看到你的显卡的设备文件,一般分为主设备(card0)和渲染设备(renderD128),后者只能做渲染操作,防止将不必要的显卡配置的权限暴露给低权限图形应用。

获取 DRI 设备对应的名称⁠

$ udevadm info /sys/class/drm/card1/device
P: /devices/pci0000:00/0000:00:01.1/0000:01:00.0/0000:02:00.0/0000:03:00.0
M: 0000:03:00.0
R: 0
J: +pci:0000:03:00.0
U: pci
V: amdgpu
(省略)
E: ID_PCI_CLASS_FROM_DATABASE=Display controller
E: ID_PCI_SUBCLASS_FROM_DATABASE=VGA compatible controller
E: ID_PCI_INTERFACE_FROM_DATABASE=VGA controller
E: ID_VENDOR_FROM_DATABASE=Advanced Micro Devices, Inc. [AMD/ATI]
E: ID_MODEL_FROM_DATABASE=Navi 48 [Radeon RX 9070/9070 XT/9070 GRE]

可以从 udev 在其硬件数据库提取的输出看出 card1 设备对应一块 AMD Radeon RX 9070 显卡。

此外,你可能还会经常看到 KMS(Kernel Mode Setting)这个词。KMS 是 DRM 的子模块,负责设置显示模式,这也将 X 从设置显示模式的负担上解放出来,并且帮助实现更平滑的显示切换(例如从 TTY 切换到 X)。

最后回到显示加速上。目前开源驱动一般的做法是:KMS 来设置显示模式,由开源的 Mesa UMD 来具体实现 OpenGL、Vulkan 等图形 API 的功能。X server 就使用 modesetting(xf86-video-modesetting)驱动,不再需要关心显卡的具体实现细节了。但是如果你是 NVIDIA 官方驱动(或者其他小众显卡厂商的闭源驱动)的用户,那么很不幸,你还是需要使用对应厂商提供的专有 X 驱动(也称为 Device Dependent X,DDX)来获得显示加速。

硬件加速简介

实际上,Linux 的图形栈要比上面介绍的还要复杂很多,本文不是开发手册,因此无法涵盖每个细节。这里只能做简单的介绍。

现代桌面应用程序一般都需要 GPU 的 3D 加速功能。在 Linux 上,使用的 3D 图形 API 为 OpenGL 或者 Vulkan。但是不管用什么 API,最终的主要实现都是 GPU 开发商来做的,要应用程序一个一个对接 GPU 开发商不太现实,因此需要一个负责分发的「中间商」。否则就会遇到类似这样尴尬的局面:系统安装的时候,libGL.so 等库是 Mesa 提供的,之后安装了某些 GPU 自己的驱动之后,安装器直接覆盖了 libGL.so 等文件,看起来能用,然后系统一升级,libGL.so 又被覆盖回来了,图形渲染于是全挂了,而且这种模式也无法共存多种实现(例如同时有多块不同产商显卡的时候)。

对 OpenGL 来说,这个任务由 libglvnd(the GL Vendor-Neutral Dispatch library)完成。其包含了 GLX 和 EGL 的接口。GLX 是和 X 绑定的 OpenGL 实现,而 EGL 提供了不和具体的某种窗口系统绑定的统一的 OpenGL 接口。libglvnd 会根据当前的情况调用 Mesa 或者 GPU 开发商私有的 OpenGL(GLX/EGL)实现。

libglvnd 提供的库⁠

在 Debian 中,libglvnd 源码包被分成了多个包,包括:

  • libglvnd0:实际执行分发(dispatch)操作的 libGLdispatch.so
  • libgl1:提供分发 + GLX 功能的老的 libGL.so,目前已经不推荐使用
  • libegl1:提供了 libEGL.so
  • libgles1libgles2:提供 OpenGL ES(精简的 OpenGL API)的相关库,实际是分发库移除了一部分符号的包装
  • libglx0:提供了 libGLX.so
  • libopengl0:提供了 libOpenGL.so,实际是分发库移除了一部分符号的包装

而 Vulkan 在设计时就考虑到了这个问题:它的核心库 libvulkan.so 实际是一个加载器,负责检查指定路径(如 /usr/share/vulkan/)加载 ICD(Installable Client Driver)。例如 AMD 显卡 Vulkan 驱动的 ICD 配置如下:

/usr/share/vulkan/icd.d/radeon_icd.json
{
    "ICD": {
        "api_version": "1.4.305",
        "library_path": "libvulkan_radeon.so"
    },
    "file_format_version": "1.0.0"
}

如果正在使用 Mesa 开源驱动,Mesamatrix 提供了 Mesa 实现的各个驱动对 Vulkan、OpenGL 等标准的支持情况,可以作为参考。

Vulkan layer⁠

除了 ICD 以外,Vulkan 加载器还会加载被称为 layer 的库。这些 layer 会根据条件注入,提供诸如校验(例如 Vulkan 的 validation layer)、帧率显示(例如 Mesa 自带的 overlay,以及更常见的 MangoHud 等)之类的功能。可以在 /usr/share/vulkan/explicit_layer.d//usr/share/vulkan/implicit_layer.d/ 查看系统中安装的 layer。

例如在安装了 Mesa overlay layer 的系统上,可以用 VK_INSTANCE_LAYERS=VK_LAYER_MESA_overlay 在 Vulkan 程序中显示帧率信息。

齿轮样例程序⁠

对 GLX、EGL 和 Vulkan,mesa-utils 都提供了对应的 demo 程序:

  • glxgears
  • eglgears_x11eglgears_wayland
  • vkgears

另外 vulkan-tools 提供了 vkcube,对 Vulkan 测试来说可能更常见一些。

多 GPU 与多驱动实现的场景⁠

如果系统中有多个 GPU,那么 libglvnd 和 Vulkan 加载器会怎么选择合适的驱动实现呢?对 GLX、EGL 和 Vulkan,具体的实现有着一些差异。

GLX 场景下,libglvnd 会使用 GLX_EXT_libglvnd 扩展请求 X server(例如当前的 X screen 应该使用什么 GlxVendor),根据返回的信息选择实现。如果返回的信息无效,那么就回退到加载 libGLX_indirect.so,一般是指向实际实现的软链接:

$ ls -lha /usr/lib/x86_64-linux-gnu/libGLX_indirect.so.0
lrwxrwxrwx 1 root root 16 Jun 17  2025 /usr/lib/x86_64-linux-gnu/libGLX_indirect.so.0 -> libGLX_mesa.so.0

可以设置 __GLX_VENDOR_LIBRARY_NAME 环境变量来修改 libglvnd 对 GLX 实现的选择。而 EGL 中,libglvnd 实现了类似于 Vulkan ICD 的逻辑:

/usr/share/glvnd/egl_vendor.d/50_mesa.json
{
    "file_format_version" : "1.0.0",
    "ICD" : {
        "library_path" : "libEGL_mesa.so.0"
    }
}

EGL 会按顺序依次确认每个库,第一个能处理请求的库获胜。可以用 __EGL_VENDOR_LIBRARY_FILENAMES 等环境变量指定 libglvnd 选择的 EGL ICD JSON 文件。

上述 libglvnd 对 GLX/EGL 的分发选择的是实现,而不是 GPU 设备。如果两块显卡的驱动都是开源的 Mesa(例如核显是 Intel,独显是 AMD),那么就还需要 Mesa 来决定使用的设备。常见的包括使用 MESA_LOADER_DRIVER_OVERRIDE 选择 Mesa 内部实现的驱动、DRI_PRIME 选择用独显还是集显(不适用于 Mesa 以外驱动的 GPU)等。

而 Vulkan API 则不会帮你选择 GPU,而是会将所有的 GPU 信息都提供给应用程序,让应用自己选。但是不少应用就会直接选择列表中的第一块 GPU(不一定是用户想要的)。Vulkan 加载器同样也支持用环境变量(例如 VK_LOADER_DEVICE_SELECT 或者 VK_LOADER_DRIVERS_DISABLE)限制选择的设备或驱动,更多信息可参考官方文档。同样,如果选择了 Mesa 实现,也有一些额外的环境变量可以做额外配置,例如 VK_LAYER_MESA_device_select layer 接受 MESA_VK_DEVICE_SELECT 环境变量用于设备选择。

我没有 GPU 呢?⁠

总有一些场景下没有办法使用 GPU,或者 GPU 实在太老以至于不支持 Vulkan,或者 OpenGL 比较新的特性。这不意味着无法运行任何 OpenGL 或者 Vulkan 程序——只是需要 CPU 来当苦力了。目前,Mesa 的 LLVMpipe 实现了完整的软件渲染 OpenGL 的支持,其会将 OpenGL 的 shader 翻译为 LLVM 的 IR,然后由 LLVM JIT 优化后在 CPU 上运行。对应 Vulkan 则是 Lavapipe(lvp)。

此外,Mesa 的 Zink 驱动可以将 OpenGL 转换为 Vulkan,使得 OpenGL 应用可以在只支持 Vulkan 的 GPU 上运行。

什么是 shader?⁠

Shader 是给 GPU 看的程序,用来控制 GPU 渲染图像和计算。开发者用 GLSL(或者其他的 shader 语言)编写相关的程序,通过编译器编译到中间代码(例如 SPIR-V)后,再由显卡驱动转换为 GPU 可以理解的机器指令。如果对 GPU 编程感兴趣的话,可以在 Shadertoy 使用 GLSL 编写 shader,并通过 WebGL 实时查看效果,也可以欣赏社区其他人写的有趣 shader。

我们平时玩游戏时,加载界面可能会显示「正在编译着色器」,这里编译的东西就是 shader。Shader 需要使用 CPU 编译。如果 shader 比较复杂,就需要更长的时间编译,如果游戏或应用不提前编译好的话,就可能出现进入一个新场景之后会卡顿一小会才能继续玩的情况,影响体验。对游戏主机来说,由于硬件是固定的,游戏开发商可以提前编译好,但在桌面场景下就需要更多的优化策略来缓解相关的问题。在 Linux 上,Steam 会在游戏启动前尝试编译 Vulkan shader,并将编译结果在相同硬件配置玩家之间共享。

Mesa 会将编译好的 shader 缓存到 ~/.cache/mesa_shader_cache

视频编解码加速⁠

GPU 除了加速 OpenGL 与 Vulkan shader 以外,也可以加速视频的编解码,减少 CPU 的压力,降低能耗。目前最主流的接口为 Intel 推出的 VA-API,Intel 与 AMD 的 GPU 驱动对 VA-API 有着不错的支持(NVIDIA 的 VA-API,如果使用官方驱动的话,只有第三方的 nvidia-vaapi-driver 兼容层,并且只支持 Firefox)。NVIDIA 则传统上使用其推出的 VDPAU 接口用于解码加速。应用也可以使用 NVIDIA 专用的 NVENC/NVDEC 接口实现编解码加速。Vulkan 也提供了视频编解码的扩展(VK_KHR_video_*),各个产商较新的显卡和驱动都对其提供了支持。

大部分用户应该都更关心解码的性能。可以使用 mpv 的 hwdec 参数测试相关视频的解码接口是否正常工作:

mpv --no-config --hwdec=vaapi example.webm
mpv --no-config --hwdec=vdpau example.webm
mpv --no-config --hwdec=nvdec example.webm
mpv --no-config --hwdec=vulkan example.webm

打开视频后,按下 i 键(或者 Shift + i 来保持信息一直显示)可以让 mpv 显示详细信息,从中可以看出是否正在使用硬件加速:

An example of mpv with vulkan

如果成功使用对应的硬件加速接口,在 Video 一行的结尾可以看到。截图中使用的是 --hwdec=vulkan

如果想尝试硬件加速视频编码(例如视频转换),可以参考 ffmpeg 的手册信息

在 GPU 渲染完成之后,结果怎么让用户看到呢?对全屏、独占显示的应用来说,直通显示(direct scanout),让显卡直接画到屏幕上是性能最好的选择,但是更多的时候,窗口之间会互相遮挡,还有更加复杂的窗口特效等等需要混成,直接让 GPU 往屏幕上画画就不太合适了。不管是与 X 交互的 GLX,还是支持多种窗口系统后端的 EGL、Vulkan WSI(Window System Integration),它们都需要和窗口系统交互,来知道自己应该渲染到哪里。

这里的渲染目标内核底层一般是 dma-buf,允许在不同的设备间传递同一块 buffer 空间对应的文件描述符,避免复制的开销。如果使用 EGL 的话,那么操作 DRM 的程序一般会使用 GBM 创建 GPU 可以使用的 buffer,并导出为 dma-buf 的文件描述符。NVIDIA 曾经使用的 EGLStream 与 GBM 功能类似,但是不使用 dma-buf。dma-buf 同时提供了同步相关的设施(用来帮助确保不会把画了一半的 buffer 以非预期的方式消费掉),相关的具体实现则超出了本文的范畴。

HiDPI

随着高分屏的推广,如果在使用高分屏时仍然采用和非高分屏一样的策略,那么桌面元素就会变得非常小,因此需要对桌面进行缩放。在介绍下面的内容之前,首先需要了解 DPI(Dots Per Inch,点每英寸)的概念。显示器型号中的「英寸」一般指显示器对角线的长度,因此要计算 DPI,首先可以先从最佳分辨率的长和宽计算出对角线的像素数,然后用对角线的像素数除以对角线的英寸数,就可以得到 DPI 了。例如对一个 27 英寸的 2K(2560x1440)显示器来说:

  1. 计算对角线的像素数:sqrt(2560^2 + 1440^2) ≈ 2937.2 像素
  2. 计算 DPI:2937.2 / 27 ≈ 109 DPI

一般来讲,在 Apple 以外的生态中,默认(1 倍)的 DPI 是 96。因此对上面的显示器,可能需要稍微放大一些,才能获得比较合适的显示效果。如果 DPI 是 144(1.5 倍)或者 192(2 倍)的话,默认缩放的不适感就会更明显。这些显示器也被称为 HiDPI 显示器。

X 本身没有逻辑分辨率的概念,并且一般只有一个全局的坐标系(一个 screen),缩放策略如何全由应用程序自己决定,这也导致了以下记录的各类方法的混乱。

不能不管应用实现,直接让应用以一个更大(或者更小)的分辨率渲染,再把应用渲染出来的内容缩放到正确的分辨率吗?⁠

如果要正确实现 HiDPI,应用程序本身必须知道物理像素与逻辑像素之间的关系(DPI)。例如对一个 12pt(1pt = 1/72 英寸)的字,在 96 DPI 下的物理像素应该为 12 * 96 / 72 = 16px,在 192 DPI 下为 12 * 192 / 72 = 32px,它们的逻辑像素是一样的,但是物理像素则有差异。图标等其他的资源也类似。假如窗口系统不告知应用 DPI 信息,只将应用窗口当成位图缩放,那么最终获得的窗口要么里面的内容会小到无法阅读,要么变成一团模糊。

所以,为了正确实现缩放,应用程序必须至少了解逻辑分辨率、物理分辨率与缩放比(DPI)中的两项。需要注意的是在下文 Wayland 的介绍中,其协议内没有 DPI 的概念,不过在已有的讨论中有时仍然会看到以 DPI / 96 作为缩放比来描述。

而对只支持整数缩放的应用来说,一种需要更多渲染资源但是可行的策略是:告知一个足够大(当前分数缩放比例向上取整)的缩放比例(对应 DPI),然后再将渲染出来的内容缩小到正确的分辨率。在这种情况下,窗口内容内部的大小是正确的,并且缩小位图比放大位图来说,更不容易模糊。

在倍数不大的情况下,调整字体大小是更合适的方案⁠

对于只需要不到 1.5x 缩放的情况下,最简单的方式是把字体稍微调大一些——这可以避免下面所说的所有问题,特别是如果你的工作环境仍然依赖于 X 的话。

X server 早期会尝试获取显示器的 EDID(Extended Display Identification Data)信息,获取显示器的物理尺寸,计算出当前 screen 的 DPI,客户端可以获取到相关信息。但很不幸的是,很多时候显示器提供的 EDID 信息是错误的。如果直接拿来用,那么就可能计算出非常奇怪的 DPI,因此 Xorg 固定使用 96 DPI 作为默认值。并且目前绝大多数情况下,即使连接了多个显示器,X server 的 screen 仍然是唯一的(因为窗口等无法在不同 screen 之间顺畅移动),具体的显示器设置由 RandR 扩展管理,因此 X server 也无法为每个显示器提供不同的 DPI 信息。

因为 X server 提供的 DPI 的不可靠性,大部分应用就转而参考 X resources 中的 Xft.dpi 设置来获取 DPI 信息。X resources 是 X server 提供的一个简单的键值存储系统,记录了字体、颜色等信息,可以使用 xrdb 工具来查看和修改:

$ xrdb -query
Xft.dpi:	96
Xft.antialias:	1
Xft.hinting:	1
Xft.hintstyle:	hintslight
Xft.rgba:	none
Xcursor.size:	24
Xcursor.theme:	Adwaita
$ # 从约定俗成的配置文件位置读取并与当前 X resources 合并
$ xrdb -merge ~/.Xresources

但是在语义上,这么做是存在问题的,因为 libXft 控制的是 X 下字体的渲染,因此它的 DPI 照理来说只是字体渲染时使用的 DPI,而不应该影响包括图片等在内的其他内容(尽管很多时候这个值就被拿来做整体 UI 的缩放了,但 GTK 仍然只用这个值控制字体的缩放);并且这种机制完全无法处理不同显示器不同 DPI 的情况。因此,各类 UI 库又引入了自己的 DPI 设置方式,例如 GTK 使用 GDK_SCALEGDK_DPI_SCALE 环境变量,Qt 则使用 QT_SCALE_FACTORQT_AUTO_SCREEN_SCALE_FACTOR 等环境变量让用户调整 DPI。可以参考 ArchWiki 的 HiDPI 来了解如何配置各个框架的缩放。

此外,由于 X resources 的修改无法通知到客户端,因此出现了 Xsettings 的机制。Xsettings 会在 X server 中创建一个不可见的特殊窗口,其中存储了包括 Xft/DPIGdk/WindowScalingFactor(用于 GTK 程序整数缩放)在内的桌面配置。客户端可以监听这个窗口的变化,从而在运行时动态调整自己的设置。

获取 Xsettings 提供的属性⁠

xsettingsd 是一个 Xsettings 的轻量级实现,其提供了 dump_xsettings 命令可以输出当前 Xsettings 窗口包含的所有属性。

对于多显示器不同 DPI 的情况,也同样没有统一的告知应用不同显示器 DPI 的方式。一种思路是全局设置一个高 DPI,然后使用 xrandr--scale 参数调整低 DPI 显示器的缩放,来让低 DPI 显示器上的应用得到合适的大小,一些桌面环境的显示器设置的缩放就是控制的这一项参数。但是在复杂情况下,完全做对而不模糊仍然非常困难,很多时候费时费力也无法获得合适的显示效果。

从上面的描述,我们可以发现,其实 X 对 HiDPI 的支持是混乱的——不同应用有不同的标准,有很多「设置 DPI」的方式,并且都不完美。如果要考虑到多显示器支持,以及分数缩放的话,现有的机制就更不够用了。

@taoky: 吐槽⁠

我不止一次看到过在笔记本上用 Linux X 桌面的人在需要做报告的时候,HDMI 线接上,然后发现大屏幕上只能显示一部分被截断的内容,或者内容太大/太小,而且还要花大半天才能调好。如果你经常需要拿着笔记本接投影做展示,Wayland 会是好得多的选择。

平行世界:假如只有一种设置方法,X 的 HiDPI 支持会更好吗?⁠

让我们假设一下:假如说我们只有一种设置 DPI 的方式,让 X server 直接管理 DPI,客户端可以获取到每个屏幕的 DPI,并且在 DPI 变化时收到通知。那么这种情况下,X 的 HiDPI 支持会更好吗?

如果我们只考虑一台显示器,那么确实,这个更简洁的模型是更好的。但是,如果我们考虑多显示器的场景,那么有些麻烦的地方就来了:由于 X 维护的是一块由多个显示器拼起来的大 screen(root window),应用需要自行从自己的坐标位置判断当前窗口在哪个显示器上,从而决定使用哪个 DPI 来渲染自己,这就存在两个问题:

  1. 当窗口拖动的时候,应用需要不停向 X server 轮询自己的位置,从而决定使用哪个 DPI 来渲染自己,带来额外的性能开销。
  2. 如果一个窗口跨多个显示器,那么应用需要决定使用哪个显示器的 DPI 来渲染自己,带来了复杂性与不确定性。

虽然有这些问题,但是即使是以上这个模型,在我们这个世界中也已经难以在不破坏兼容性的情况下实现——几乎所有的 UI 框架都需要修改代码才能支持。

Kali Linux 的 HiDPI 设置脚本⁠

作为一个例子,Kali 为它们自定义的 Xfce 桌面环境提供了一个 HiDPI 设置脚本,可以作为以上描述的这种混乱的一个参考。

我们为 Linux 101 自动化构建编写的 101strap 项目提供了修改的版本,参见 toggle-hidpi

混成器

进入二十一世纪之后,桌面环境开始追求更炫酷的视觉效果,例如圆角的窗口、半透明的窗口、有阴影的窗口、不规则形状的窗口、动画效果等等。但是 X 传统仍然假设:窗口是个不透明矩形,X 服务器需要直接把这样的窗口画到屏幕上,并且跳过被挡住的部分——而且这个过程没有缓冲,动画只能靠窗口不停重绘自己来实现,非常不流畅。而混成器做的事情就是:接管图形显示的流程,让窗口不再直接画在屏幕上,而是画在一个缓冲区中,然后由混成器统一将这些缓冲区合成(composite)到屏幕上。这样一来,要显示什么酷炫的效果就由混成器说了算了。在 X 下,混成器需要调用 X Composite 扩展来实现。

我的 X 服务器开启了哪些扩展?⁠

可以使用 xdpyinfo 来查看当前 X 服务器开启了哪些扩展:

$ xdpyinfo
(省略)
number of extensions:    25
    BIG-REQUESTS
    Composite
    DAMAGE
    DOUBLE-BUFFER
(省略)

最著名的例子是 compiz,它实现了很多诸如 3D 立方体桌面切换等等的效果,是 2010 年前后 Linux 桌面炫酷效果的代名词,在当时也吸引了很多用户来使用 Linux 桌面。各个桌面环境的窗口管理器,例如 GNOME 的 mutter、KDE 的 KWin 也都集成了混成器的功能。

Compiz Cube

2007 年的 Compiz 的立方体效果。By Nicofo,CC BY-SA 3.0

显示管理器

在 X 下,显示管理器(Display Manager,DM)负责在 X 服务器上显示登录界面,在用户登录后启动对应的桌面环境或者窗口管理器。常见的 DM 有 GDM(GNOME Display Manager)、SDDM(Simple Desktop Display Manager,KDE 默认使用)、LightDM 等。DM 一般作为 systemd 的服务运行,在系统启动时自动启动 X 服务器,并且显示登录界面。

之所以叫 Display Manager 而不是 Login Manager,是因为 DM 还管理着 X(即 "Display")——比如说,如果 X 崩溃了,DM 会重新启动 X 并且重新显示登录界面。

剪贴板与拖放支持

在 X 下,剪贴板和拖放(Drag and Drop,DND)功能都是由 X 的 Selection 机制实现的。Selection 机制用来表示一个程序拥有某个数据,并且允许其他的程序请求获取这个数据。

X 的剪贴板相比其他的操作系统桌面环境特殊的地方在于:它有两种不同的 Selection(PRIMARY 和 CLIPBOARD),并且 X 不存储剪贴板内容。CLIPBOARD 就是我们非常熟悉的剪贴板了,而 PRIMARY 则代表鼠标刚刚选择的内容,用户可以按下鼠标中键(或者大部分触摸板上三指点击)来粘贴 PRIMARY 中的内容,不需要用户显式点击复制或者按下 Ctrl+C。

在复制时,程序会向 X server 注册自己拥有对应的 Selection;在粘贴时,程序会向 X server 请求拥有对应 Selection 的程序提供指定类型的数据(例如纯文本、HTML、图片等等),然后由拥有 Selection 的程序将数据传递给请求的程序。由于 X server 并不存储剪贴板内容,因此如果拥有 Selection 的程序退出了,那么对应的 Selection 也就不存在了。如果需要保留数据,则需要使用剪贴板管理器程序来保存。

而拖放也是类似的,应用之间使用 XDND 协议,通过 Selection 机制传递数据。具体流程可参考协议给出的示例。

远程桌面访问

X 的网络透明性设计似乎使得远程桌面访问变得非常简单——只需要 ssh -X 或者 ssh -Y 就可以了。但是由于 X 协议本身的设计问题,这么做的性能并不好,主要原因包括:

  1. X 协议很「啰嗦」,大量的操作都需要往返通信,这导致网络延迟会被协议放大数倍,甚至十几倍。
  2. 旧的 X 程序一般会调用 X 协议的接口来画线段、字体等(例如客户端、服务端与窗口中展示的 xedit),但是绝大多数现代 UI 框架(例如 GTK、Qt)早已经不这么做了,而是直接画图给服务器。在远程环境下意味着传输大量未压缩的图像数据,网络带宽消耗大。

因此 X 的网络透明性几乎只适合在极低延迟的网络环境下使用(基于同样的理由,我们也不介绍为远程使用 DM 设计的古早协议 XDMCP)。对于更常见的场景,根据需求不同,可以使用传统的 VNC/RDP 方案,本身作为 X server,支持多种网络与图形协议的 Xpra,针对游戏场景优化的 Sunshine,或者为远程协助设计的 RustDesk 等等。类似的远程桌面方案还有很多,可以按需选择。

SSH + VNC 的远程桌面访问方案⁠

以下介绍一种常见的远程桌面访问需求的解决方案:通过 SSH 隧道访问远程主机上的 VNC 服务器。只要能够建立 SSH 连接,就可以通过这种方法获取到基本的 X 桌面环境,并且用户之间互相隔离,且不需要配置防火墙,远程桌面图像也不会经手第三方。

TigerVNC 实现了 Xvnc,安装 tigervnc-standalone-server 包即可。Xvnc 是一个集成了 VNC 服务器功能的 X server,可以像启动 X 一样启动 Xvnc。这里为了安全起见,我们将启动的 Xvnc 绑定到家目录下的一个 Unix socket 上,由 Unix 的文件权限管理来保证用户之间的隔离(因此不需要设置额外的 VNC 密码),并且避免将 VNC 端口暴露到网络上。启动的脚本如下(使用 tigervncserver(1)):

/usr/local/bin/startvnc
#!/bin/sh

# vncserver -> tigervncserver
exec /usr/bin/vncserver \
  -rfbport -1 \
  -rfbunixpath "$HOME/.vncsock" \
  -SecurityTypes None \
  "$@"

之后我们需要指定 VNC 服务器使用的 session。Debian 下,tigervncserver 会查看 /etc/tigervnc/vncserver-config-defaults$session 变量的值,如果没有定义,就会启动 /usr/bin/x-session-manager。作为例子,我们安装 LXQt。LXQt 是一个非常轻量级的桌面环境:

sudo apt install --no-install-recommends lxqt

使用其他桌面环境⁠

也可以安装其他的桌面环境,例如 GNOME、KDE。在安装完成后,可以使用 Alternatives 来设置默认的 x-session-manager

sudo update-alternatives --config x-session-manager

注意,一些桌面环境可能需要完整的 systemd 支持才能正常工作,例如 GNOME。如果你的环境是没有 systemd 的容器,那么可能需要选择其他桌面环境。

错误调试⁠

Session 的错误输出位于 ~/.xsession-errors 文件中,可以查看该文件来调试 session 启动失败的问题。

之后 SSH 连接时,可以使用 -L 参数将本地的 5900(默认 VNC 端口)转发到远程主机的 Unix socket 上

ssh user@host -L 5900:/home/user/.vncsock

或者直接修改 SSH 配置文件,添加以下内容:

~/.ssh/config
Host myvncserver
    HostName host
    User user
    LocalForward 5900 /home/user/.vncsock

SSH 登录,并且运行我们编写的 startvnc 脚本启动 VNC 服务器后,本地即可通过 VNC 客户端连接到 localhost:5900 来访问远程的 X 桌面环境。TigerVNC 也提供了 vncviewer 命令行客户端:

vncviewer localhost:5900

如果需要重启 VNC 服务器,直接杀死对应的进程,再重新执行 startvnc 即可。

VNC 与 LightDM 集成⁠

以下介绍 USTC Vlab 项目在客户容器中集成 VNC 服务器与 LightDM 的方案。集成的好处是:用户在连接到 VNC 后,就能看到熟悉的 LightDM 的登录界面,可以在界面中输入密码、选择桌面环境等,而不需要预先配置好 VNC session。

lightdm.conf 中,我们可以设置让 LightDM 使用的 X 服务器,以及 LightDM 启动 greeter(实际给用户展示的图形界面)之前要运行的程序:

/etc/lightdm/lightdm.conf
[Seat:*]
xserver-command=/usr/local/bin/vncserver-lightdm
greeter-hide-users=false
greeter-setup-script=/usr/local/bin/vncserver-greeter-setup.sh
/usr/local/bin/vncserver-lightdm
#!/bin/bash
# Need Bash for $PPID

DISPLAY=":0"
LIGHTDM_PID=$PPID
XVNC_PID=0

kill_vnc() {
  kill -s SIGTERM $XVNC_PID
  wait
}

if [ $# -gt 0 ]; then
  DISPLAY="$1"
fi

AUTHORITY="/var/run/lightdm/root/$DISPLAY"

Xvnc "$DISPLAY" -rfbport 5900 -seat seat0 -SecurityTypes None -auth "$AUTHORITY" -SendPrimary=0 &

XVNC_PID=$!
trap kill_vnc SIGTERM

sleep 2
kill -s SIGUSR1 $LIGHTDM_PID

wait
exit 0

安全性注意事项⁠

在这里,VNC 服务没有安全性校验。在 Vlab 中,所有用户对 VNC 的访问都必须经过我们自己编写的 VNC 网关,并且用户之间的 VNC 端口由防火墙阻止互相访问,因此这种设计是安全的。如果你在其他场景使用,请务必注意安全性问题,特别是需要暴露端口到公网的情况。互联网中存在大量自动化扫描空密码、弱密码的 bot。如果你不注意安全,那么之后你就可能会看到自己的 VNC 的桌面被其他人分享在某些社交媒体上,然后发现自己的桌面被陌生人入侵操控。

VNC 传统的密码模式 VncAuth 是不安全的——它的密码明文传输,并且密码长度最多只有 8 个字节,因此不建议使用。建议至少使用 TLSVnc,或者使用 SSH 隧道等方式保护 VNC 连接。

为什么要给 LightDM 发送 SIGUSR1?⁠

这一点在 xserver(1) 手册页中有说明:当 X 服务器发现自己从父进程继承的 SIGUSR1 信号的 handler 是 SIG_IGN(忽略信号)时,就会在启动完成之后向它的父进程发送 SIGUSR1 信号。DM 可以利用这个机制来等待 X 服务器启动完成。

这里的 shell 脚本用了相对粗糙的 sleep 2 来等待 X 服务器启动完成,然后以 X 服务器的这一套信号协议来通知 LightDM。不过,这里的 shell 脚本没有办法用 trap func SIGUSR1 的方式来指定 SIGUSR1 的 handler(可以阅读 POSIX 标准的相关介绍,然后想一下为什么),因此如果需要修改,则需要考虑换成 Python、Perl 之类的方案。

/usr/local/bin/vncserver-greeter-setup.sh
#!/bin/sh
vncconfig -nowin &

vncconfig 的作用⁠

vncconfig(1) 是 TigerVNC 提供的一个小工具,负责处理剪贴板同步等功能。如果不运行它,那么 VNC 客户端与服务器之间的剪贴板将无法同步。

之后 LightDM 在启动时,就会启动 Xvnc,在 5900 端口上监听 VNC 连接。

Wayland

从 1984 年开始到现在,X 陪伴 Unix(与类 Unix)桌面走过了四十多年的时间。在这个过程中,随着计算技术的不断发展,80 年代的设计暴露出了越来越多的问题,包括但不限于:

  • 安全性问题:连接到 X server 的任何客户端都可以随便看其他窗口,随便截取用户输入,带来了严重的安全隐患。
  • 混成器的性能:现代桌面下混成器已经是必需品。但是在 X 的 Composite 扩展框架下,窗口信息需要经过 X server,再经过混成器,混成器画好之后再传给 X server,最后才能显示出来。X server 成为了一个导致性能瓶颈的中间人。
  • 网络透明性的实用性:X 的网络透明性已经不再实用,VNC、RDP 等远程桌面协议已经成为主流选择。
  • 画面同步:X server 的无撕裂(TearFree)需要显卡驱动各自实现。
  • HiDPI 支持的一片混乱:上文已有说明。

对 X12 的设想中也提到了一些 X11 已经无法忽视的问题,并且其中不少问题已经无法在 X11 协议上渐进式地解决了。因此,新的显示协议 Wayland 于 2008 年开始开发,到如今 GNOME、KDE 已经有了完整可用的支持,其他的桌面环境也在逐步跟进,并且越来越多的应用程序开始支持 Wayland。以下部分介绍 Wayland 相关的一些基本概念,以及常见的问题解释。

参考阅读⁠

Xorg 与 Wayland 开发者 Daniel Stone 在 2013 年的 linux.conf.au 会议上做了 the real story behind Wayland and X 的报告,其中详细介绍了 X 的设计问题以及 Wayland 的设计思路,推荐阅读。

本部分也不是 Wayland 的开发介绍⁠

如果对 Wayland 开发感兴趣,可以参考:

基础架构

在 Wayland 架构中,原先的 X server 与混成器合二为一,仍然称为混成器。混成器需要与客户端使用 Wayland 协议通信,与内核使用 KMS、evdev 等接口通信处理输入输出,如下面这张官方架构图所示:

Wayland Architecture

只支持 X 的程序则通过 XWayland 运行,XWayland 既是 Wayland 客户端,也是一个 X server。在 rootless(没有 root window,即 XWayland 不管理整个屏幕,X 程序窗口无缝集成在 Wayland 桌面中)模式下运行时,XWayland 是需要混成器特殊对待的特权客户端,以便尽可能让 X 程序保持兼容性。而在 rootful 模式下,XWayland 就和上文介绍的 Xephyr 表现类似。

xwayland-satellite⁠

对混成器来说,实现 XWayland rootless 的支持并不算很容易。如果 XWayland 没有那么特殊会怎么样?xwayland-satellite 作为普通的 Wayland 客户端实现了类似于 XWayland rootless 的功能,是一些轻量级混成器的选择。

这个窗口是 Wayland 的还是 X 的?⁠

最简单的判断方法是:启动 xeyes 程序,将鼠标移动到窗口上,如果眼睛跟着动,那么这个窗口就是 X 的,否则就是 Wayland 的——因为 xeyes 无法获取 Wayland 窗口下鼠标的位置。

常见的 Wayland 混成器包含:

  • Mutter:GNOME 的混成器,以运行时库的形式集成在 GNOME Shell 中
  • KWin:KDE 的混成器
  • Weston:Wayland 混成器的「参考实现」,通常用于二次开发、测试等场景,普通用户一般不使用
  • Sway:可以看作是 i3 平铺式窗口管理器的 Wayland 版本,基于 wlroots 库实现
  • Hyprland:以炫酷的效果知名的平铺式的混成器
  • labwc:类似 Openbox 的轻量级混成器
  • niri:近来流行的卷轴平铺式的混成器

在与内核特性交互时,混成器一般也不会直接调用内核接口,而是使用一些包装库来简化开发。例如使用 libdrm 调用 DRM 接口操作显卡,使用 libinput 调用 evdev 接口处理输入设备等等。

Wayland 混成器和客户端之间使用的 Wayland 协议是一套二进制协议。与 X 类似,Wayland 混成器默认在 $XDG_RUNTIME_DIR/wayland-<N>XDG_RUNTIME_DIR 默认为 /run/user/<UID>)上监听 Unix socket,客户端通过 WAYLAND_DISPLAY 环境变量指定要连接的 Unix socket(一般为 wayland-0)。

XDG Base Directory 标准⁠

XDG Base Directory 标准 规定了一系列目录的定义,符合标准的程序应默认先按照环境变量设置选取目录,在不包含对应环境变量的情况下,再回退到默认值。标准包括:

  • $XDG_DATA_HOME 存储程序的用户数据,默认在 ~/.local/share
  • $XDG_CONFIG_HOME 存储程序的用户配置,默认在 ~/.config
  • $XDG_STATE_HOME 存储程序与用户有关的状态,默认在 ~/.local/state
  • (没有对应环境变量)用户自己的程序可以放在 ~/.local/bin
  • $XDG_DATA_DIRS 存储程序系统级的数据,默认在 /usr/local/share/:/usr/share/
  • $XDG_CONFIG_DIRS 存储程序系统级的配置,默认在 /etc/xdg
  • $XDG_CACHE_HOME 存储程序的用户缓存,默认在 ~/.cache
  • $XDG_RUNTIME_DIR 存储程序用户级别的运行时文件,没有默认值,只要求目录必须唯一被对应用户所有,并且 Unix 权限必须是 0700。一般来说,在使用 systemd 的系统上,这个值是 /run/user/<UID>。实践上如果这个环境变量没有被设置,一些程序(包括但不限于许多 Wayland 混成器和客户端)可能会无法执行。

尽管 XDG 标准没有规定在 Windows 和 macOS 上这些目录的位置,但是实践上许多跨平台的 XDG 库实现都会尊重 Windows(Known Folders)和 macOS(File System Programming Guide)特有的设置。

相比 X,要看到 Wayland 程序在运行时的协议交互要简单很多,只需要添加 WAYLAND_DEBUG=1 环境变量即可,相关信息会输出到 stderr:

$ WAYLAND_DEBUG=1 gtk4-demo
[1603406.936] {Default Queue}  -> wl_display#1.get_registry(new id wl_registry#2)
[1603406.945] {Default Queue}  -> wl_display#1.sync(new id wl_callback#3)
[1603406.991] {Display Queue} wl_display#1.delete_id(3)
[1603406.997] {Default Queue} wl_registry#2.global(1, "wl_compositor", 6)
[1603406.999] {Default Queue}  -> wl_registry#2.bind(1, "wl_compositor", 6, new id [unknown]#4)
[1603407.001] {Default Queue} wl_registry#2.global(2, "wl_shm", 2)
[1603407.003] {Default Queue}  -> wl_registry#2.bind(2, "wl_shm", 1, new id [unknown]#5)
[1603407.004] {Default Queue} wl_registry#2.global(3, "wl_output", 4)
[1603407.005] {Default Queue}  -> wl_registry#2.bind(3, "wl_output", 4, new id [unknown]#6)
[1603407.033] {Default Queue}  -> wl_display#1.sync(new id wl_callback#7)
(以下省略)

Wayland 协议内容以 XML 定义。最核心的协议(wayland.xml)随 Wayland 库分发。其他不少协议则在 wayland-protocols 仓库 中。可以注意到,只有少数协议是 stable 的(例如 xdg-shelllinux-dmabuf 等),大部分协议都在 staging、unstable 或者 experimental 中。其中除了核心的 wayland.xml 之外,其他的协议混成器都可以可选支持,不过如果要运行通常意义下的桌面程序,至少 xdg-shell 是必须支持的——它定义了「窗口」的概念。当然,特殊用途(嵌入式等)的混成器也可以选择不实现 xdg-shell,例如 Weston 就支持用于车载娱乐系统的 ivi-shell 相关协议(ivi 即 in-vehicle infotainment)。

Wayland 协议细节⁠

Wayland 的协议是「面向对象」的——所有的东西都是对象(object)以及和对象有关的操作。以这个 XML(fractional-scale-v1 的一部分)定义为例:

<interface name="wp_fractional_scale_v1" version="1">
  <description summary="fractional scale interface to a wl_surface">
    An additional interface to a wl_surface object which allows the compositor
    to inform the client of the preferred scale.
  </description>

  <request name="destroy" type="destructor">
    <description summary="remove surface scale information for surface">
      Destroy the fractional scale object. When this object is destroyed,
      preferred_scale events will no longer be sent.
    </description>
  </request>

  <event name="preferred_scale">
    <description summary="notify of new preferred scale">
      Notification of a new preferred scale for this surface that the
      compositor suggests that the client should use.

      The sent scale is the numerator of a fraction with a denominator of 120.
    </description>
    <arg name="scale" type="uint" summary="the new preferred scale"/>
  </event>
</interface>

每个对象都是某个接口(interface)的一个实例(每个 object 都实现了一个 interface),interface 中的请求(request)是客户端可以调用混成器的方法,事件(event)是混成器通知客户端的方法。这里定义了一个名为 wp_fractional_scale_v1 的接口,其中包含的 request 和 event 分别表示客户端可以销毁掉这个对象,以及混成器可以通知客户端推荐的缩放比例。

另一点可以注意到的是,这里 request 和 event 都是单方向的——程序不需要等待 request 执行完成,而是在发送 request 之后继续执行后续代码。在有需要的情况下,协议中会定义对应的 event 来通知客户端。

wl_surfacexdg_surface

Wayland 核心协议的 wl_surface interface 定义了一个矩形的平面。客户端可以在 wl_surface 上面 attach 在内存或者 GPU 显存中的 wl_buffer(存储实际的像素信息),指定接收输入的区域(set_input_region),通知混成器有哪些区域发生了变化(damage)等等。不过 wl_surface 并不是我们所认识的窗口。可以认为它只是一个单纯的画板,它有可能是鼠标光标、拖动内容时显示的图标,当然也有可能是窗口。

真正能让 wl_surface 成为窗口的是 xdg_surface interface。在 xdg-shell 协议中,xdg_wm_base::get_xdg_surface 能够基于 wl_surface 创建出 xdg_surface。之后再由 xdg_surface::get_toplevel 或者 xdg_surface::get_popup 创建普通的窗口(xdg_toplevel)或者菜单(xdg_popup)。进而实现窗口的移动、大小变化、全屏/最大化/最小化等,以及配置菜单相对于其父 xdg_surface 显示的位置。

wl_shell?⁠

在很早期的时候,Wayland 核心协议中也是有「窗口」相关的概念的。对应的 interface 为 wl_shellwl_shell_surface。但是目前这两个 interface 尽管仍然在核心协议中,但是已经被废弃——不管是混成器还是应用都不应该使用它们。相比于 xdg-shell 协议,这两个 interface 缺失了很多功能,而窗口管理的需求更多、更灵活,因此从核心协议中拆出来单独维护是更恰当的选择。

Wayland 下的坐标系⁠

如果需要详细讨论有关窗口位置、缩放等话题的细节,那么坐标系概念是必不可少的。相比 X 只有一种全局的坐标系的设计来说,Wayland 有多个不同的坐标系,让实现合理的缩放等功能成为可能。

Wayland 下的坐标系一般来讲都是左上角为原点,X 轴向右,Y 轴向下。主要的坐标系有三种:

  • wl_buffer 上的 buffer pixel 坐标系(物理坐标,即实际会在显示器上画出来的内容)
  • wl_surface 上的 surface-local 坐标系(逻辑坐标)
  • 混成器自己的全局坐标系(使用逻辑坐标)

Wayland 应用程序无法知道自己在全局坐标系中的位置,也无法指定把自己放到全局坐标的某个位置。

尽管不少协议 stable 化的进展极慢(要让所有人达成共识,是世界上最难的事情之一),不过大量非 stable 状态的协议目前已经被广泛使用,包括:

  • text-input-v3:混成器与客户端之间的输入法协议,在除了 Weston 以外的主流混成器中均有支持。
  • fractional-scale-v1:分数缩放协议,大部分的主流混成器均有支持。
  • color-management-v1:于 2025 年加入的色彩管理协议,正在被各大混成器采纳中。
  • xdg-decoration-v1:允许混成器为客户端提供窗口装饰的协议,除 GNOME、Weston 以外的主流混成器均有支持。

可以注意到,混成器可以选择只支持部分协议,同样客户端也可以选择只使用部分协议。因此类似「Wayland 开始支持 XX 协议」实际的意义大多时候是:该协议进入了 wayland-protocols 仓库(进入的前提一般是有一些混成器/客户端已经写了相关的支持代码),但是不代表所有的混成器/客户端都会自动支持。

很多原来能做的事情做不了了!⁠

X 的老用户在迁移时可能都会有这样的感觉:自己累积起来的不少基于 X 的 workflow(例如自动化操作、获取窗口信息等等)在迁移到 Wayland 之后都受到了影响,甚至有些(取决于混成器)完全无法实现。一个重要的原因是,Wayland 协议在设计上服务于 Policy(策略),而不是 Mechanism(机制)。以右键菜单(是个 popup window)为例:

  • X 不管这是个什么窗口,它只负责把窗口画在指定的坐标处,至于这个窗口在哪里,要做什么额外处理(比如说获取焦点,让 WM 忽略自己),由创建窗口的应用自己负责。
  • Wayland 下,混成器知道这是个 popup window,应用只提供大小、相当于 parent surface 的位置等辅助信息,其他所有事情都由混成器负责。

Wayland 在设计上确实更加干净,当然 Wayland 下能实现的功能也全都取决于混成器。一部分功能以 Wayland 协议的形式实现(不管是已经标准化了的,还是混成器自己做的私有协议)、一部分功能以 Portal 的形式暴露给应用,而还有一部分功能由混成器暴露私有的 API 给插件或者脚本来实现(例如 GNOME 的扩展、Sway 的 IPC 等等)。而像让应用指定自己的窗口位置这样的事情几乎没有混成器支持,这或许是一种默契(混成器作者对策略胜于机制的认同),更有可能的是,这件事情没有那么简单(需要考虑多显示器的布局、显示器的旋转、缩放比、窗口装饰等等问题),而且会导致可能的混乱(如果提供了绝对地址,那么应用就会写死,然后在各种 corner case 出问题),目前也几乎没有应用实现。混成器最多也只会通过配置项让用户自己写相关的规则来控制窗口的位置。

不过换句话说,Wayland 的这种设计选择当然也不是完美的——确实需要控制窗口的程序(例如需要下文中 xx-zones 协议的多窗口应用,以及一些会在窗口上玩花样的游戏)会受到影响,需要用完全不同的方式实现(或者完全无法实现)。比如像 OneShot 或者节奏医生这种需要移动窗口/获取窗口位置的游戏,就会头疼了。就目前来看,如果真的要在 Wayland 下原生实现这些游戏的需求,如果不考虑 xx-zones 支持,唯一的办法可能是:创建一个全屏的窗口,然后在窗口内管理子窗口的位置(不能用 xdg-decoration-v1,只能让客户端画边框),其他的地方完全透明 + 输入穿透,不过这种修改的代价很有可能太大了。

协议采纳的流程好慢!⁠

wayland-protocols 的流程最为人诟病的地方是:整个流程实在是太慢了。一个协议要被采纳至少得好几个月,而且有很多过了好几年才有一点点进展。Valve 的工程师在 2024 年曾经尝试用 frog-protocols 绕过流程来实现需要的功能。各个混成器(KWin、wlroots、Hyprland 等)也有不少私有协议,以支持自己需要的功能。

从 2026 年初的角度来说,wayland-protocols 有一些改善,不过仍然说不上快。目前大致的流程是:

  • review 两周内没有 NACK(反对)的提案会被合并到 experimental(在 xx 命名空间)。
  • 要合并到 staging 至少要 30 天 review
  • 合并到 xdgwp 命名空间的协议需要 3 个成员 ACK(同意),有 3 个开源实现(客户端和混成器都要有),并且没有 NACK
  • 合并到 ext 命名空间的协议需要 2 个成员 ACK,有 2 个开源实现(客户端和混成器都要有)

尽管实验性质的协议确实看起来门槛降低了,但是要让大部分混成器/客户端采纳的话,进入 xdgwp 命名空间仍然是必要的,而这个过程依然很慢。一些用户等了很久的协议包括:

  • xdg-pip:画中画协议。
  • xdg-session-management:会话管理协议(最主要的用途是记住窗口的位置)。于 2026 年 3 月合入。
  • xdg-dbus-annotation:允许将 Wayland 对象与 DBus 对象关联起来的协议,是实现类似 macOS 的全局菜单所需要的特性。
  • xx-zones-v1:方便多窗口应用(例如 GIMP 不使用单窗口时,主窗口、工具箱、画笔图层设置分别是三个窗口)放置窗口的协议,允许应用申请 zone,并在 zone 中组织窗口。于 2026 年 2 月合入,目前仅有 KWin 与 SDL 有实验性实现。

CSD 与 SSD 之争⁠

CSD(Client-Side Decoration)指让应用程序绘制窗口边框,而 SSD(Server-Side Decoration)指让混成器/窗口管理器绘制窗口边框。这可能是 Wayland 持续多年时间最具火药桶味道的争论之一。

基础的 xdg-shell 协议是不支持 SSD 的,意味着应用必须自己画窗口边框,但是不是所有应用都乐意这样做。在 2018 年 9 月,xdg-decoration-v1 合并入 wayland-protocols,应用可以通过这个协议告知混成器自己倾向于使用 SSD 或 CSD 作为自己的窗口边框的绘制方式。目前主流的混成器中,除去 GameScope、Weston 这类特殊用途的混成器外,只有 mutter(GNOME)不支持 xdg-decoration-v1。尽管支持 xdg-decoration-v1 不代表一定要支持 SSD(协议允许混成器自己做决定),但是不支持则意味着程序如果要在 GNOME Wayland 下有正常的窗口边框,需要想办法自己解决。

这当然引发了。如果从这些争吵中抽离出来,mutter 对 SSD 的拒绝可以从两个角度来解释:

  • 如果支持协调的、符合 GNOME 风格的 SSD,那么 mutter 就必须依赖于 gtk 和 libadwaita(否则要自己维护一套和 adwaita 风格一样的绘制、CSS、字体渲染、亮暗色模式切换等等的逻辑,是不太合理的),为 Wayland 混成器添加一个(相对来说)巨大的 toolkit 带来的稳定性风险是无法忽视的。尽管 X 时代是支持 SSD 的,但是如今 mutter 已经把 X 的窗口边框单独拆出了进程,X 边框的进程即使不太稳定,代价也是可以接受的。
  • 而另一方面,就是对 CSD Initiative 设计上的某种固执,即使要冒边框风格不一致的风险,也要坚持让应用自己画边框、利用好标题栏(headerbar)。

可能在未来几年内,这个话题仍然可以一直吵下去。不过对应用和 UI 框架的开发者来说,有一个妥协的方案 libdecor。在支持 xdg-decoration-v1 并且混成器告知使用 SSD 的场景下,它什么都不会做,否则会帮助应用绘制边框。目前其支持使用 cairo 绘制的(非常简陋)的边框,和使用 gtk 绘制的符合 GNOME 风格的边框。这些边框支持是以插件的形式,在需要绘制的时候 dlopen 的。Qt、Electron、SDL 等框架目前也都完善了 Wayland 下 CSD 的支持。

输入法

在 Wayland 架构下,输入法需要作为一种特殊的 Wayland 客户端来获取用户的输入、在其他程序中显示候选词列表等。其中重要的协议为:

由于设计等历史遗留原因,目前 Wayland 下的输入法支持仍然存在一定的混乱。具体来说:

  • GNOME 下由于设计上输入法候选词等由 gnome-shell 直接绘制(而不是由输入法绘制窗口),shell 需要获取具体的候选词列表等信息,因此 GNOME 不支持 input-method 相关协议,而是由 DBus 协议与 iBus 输入法框架通信。Fcitx 亦兼容了这个 DBus 协议。
  • Weston 仅支持 input-method-unstable-v1text-input-unstable-v1 协议。其他大部分混成器至少支持了 input-method-unstable-v2text-input-unstable-v3 协议。
  • 在很长一段时间内,Chromium 仅支持 text-input-unstable-v1 协议,导致在除了 Weston 与 KWin 以外的混成器下无法使用输入法,直到 2024 年 Chromium 129 发布后才支持 text-input-unstable-v3 协议。
  • Qt 同样在很长一段时间内仅支持 text-input-unstable-v2(未被 wayland-protocols 合并),直到 Qt 6.7 才支持 text-input-unstable-v3 协议。

目前来讲,从应用开发者的角度,只支持 text-input-unstable-v3 协议就已经足够(除非有特殊需求需要兼容 Weston)。

输入法模块还能用吗?⁠

在 X 时代的 GTK/QT 的输入法模块在 Wayland 下根据输入法实现的不同,可能仍然可以使用来绕过 Wayland 的输入法协议。以 Fcitx 5 为例:

  • Fcitx 5 的输入法模块能够拿到相对被输入窗口的相对位置。由于输入法模块和被输入窗口在同一个进程里面,输入法模块可以开启一个 xdg_popup 的输入法窗口。但是对不支持 xdg_popup::reposition 的应用来说,移动候选框窗口只能先隐藏再显示,会出现输入法窗口闪烁的情况。
  • 如果用户使用了 GNOME 的 kimpanel 扩展 gnome-shell-extension-kimpanel,那么 Fcitx 5 的输入法模块会给 kimpanel 报告自己的相对位置,由 kimpanel 把 Shell 风格的候选词窗口计算后放在合适的位置。此外仅支持 text-input-unstable-v3 的应用无法通过输入法模块的方式处理,只能让 kimpanel 扩展从混成器获取到正确的候选框绘制位置并绘制。

如果你正在使用 Debian 及其衍生发行版,并且发现在 Wayland 下被莫名其妙设置了输入法模块的环境变量,可以参考 X 中输入法的介绍调整 im-config 配置(设置为 none),并使用混成器提供的配置方式启动输入法。

HiDPI 支持

Wayland 与分数缩放

在 Wayland 最开始设计的时候,就已经考虑到了 HiDPI 的支持问题——应用可以从 wl_output 的 event 拿到显示器的 scale,可以使用 wl_surfaceset_buffer_scale request 来设置自己的缓冲区的缩放比例,从而实现 HiDPI 支持。但是,在 Wayland 设计时,分数缩放的显示器还不普遍,因此 Wayland 最初并没有支持分数缩放。当之后分数缩放的需求越来越多时,混成器只能够使用先整数放大,再缩小的策略来实现分数缩放,对 GPU 性能的消耗较大(题外话,macOS 现在仍然是采用这种策略来实现分数缩放的)。

fractional-scale-v1 协议的出现帮助解决了 Wayland 客户端分数缩放的问题。在协议中,混成器会通过 preferred_scale event 通知客户端推荐的缩放比例。由于 wl_surface 在核心稳定协议中,已有的类型不能随意修改,因此 set_buffer_scale(需要整数)仍然为 1,分数信息则通过另一个 stable 的协议 viewporter 提供。viewporter 允许为 wl_surface 设置一个 viewport,原本用作裁切 surface 使用(客户端提供原始矩形和目标矩形的信息,混成器进行裁切变换)。在分数缩放协议中,客户端会将原始矩形设置为计算得到的 buffer 的大小,将目标矩形设置为逻辑大小(即应用缩放之前的大小)。混成器收到这个 surface 之后,就能知道它的缩放比例,进而决定是否要做额外的缩放操作。

逃离浮点数⁠

我们会希望分数缩放的结果每个像素都是完美的,否则你看到的窗口内容可能会模糊,或者稍微拖动一下就会有严重的抖动。但是在分数缩放的场景下,似乎就要小数点、浮点误差等很容易出错的东西打交道了。为了尽可能避免误差,fractional-scale-v1 协议使用了分母为 120 的分数的方式来表示缩放比例(preferred_scale / 120,preferred_scale 为整数);120 这个分母是各种常用缩放倍率的最小公倍数,因此可以表示诸如 1.25(150/120)、1.5(180/120)、1.3333...(160/120)等常用的缩放比例。

在客户端和服务器计算 viewport 的时候,也需要尽量使用整数运算来避免误差,否则如果客户端从逻辑大小和缩放比计算得到的 buffer 大小不准确的话,就会坏事。一个参考的公式是:

int32_t buffer_width = (surface_width * preferred_scale + 60) / 120;
int32_t buffer_height = (surface_height * preferred_scale + 60) / 120;

通过添加 0.5 的偏移量,加上整数除法的向下取整,就能实现四舍五入的效果。否则如果直接用 naive 的浮点除法的话,就有可能会出现浮点误差导致的 off-by-one 问题。基于 MR 的例子,假设后者使用 32-bit 浮点数存储,如果 preferred_scale 是 126,surface_width 是 30,那么两种计算方式的结果分别是:

  • 参考公式:(30 * 126 + 60) / 120 = 32
  • 浮点计算:round(126 / 120 * 30) = round(31.499998) = 31

如果多了/少了一个像素,就可能会出现渲染出的窗口横竖多了/少了 1px 的情况,影响用户体验。

XWayland 下的 HiDPI 支持

由于仍然还有一些应用程序只支持 X,或在 Wayland 下表现暂时不佳,因此 XWayland 仍然是 Wayland 桌面环境中不可或缺的一部分——也就意味着混成器也需要考虑到 XWayland 下的 HiDPI 支持问题。

目前主流的混成器一般有以下三种策略:

  1. 应用按 1x 渲染,由混成器按比例缩放——窗口的大小是正确的,但是内容模糊。
  2. 混成器与相关工具(例如在 GNOME 下的 gsd-xsettings)尝试通过 Xsettings、X resources 等机制通知应用以特定倍数缩放。混成器知晓使用的 X 缩放比例信息。对 X 应用的缩放与显示器设置不一致的情况下,由混成器按比例缩放。
  3. 混成器对 X 应用不做缩放处理,由用户或相关工具自行配置。

其中 GNOME(49 及以下版本)、Sway 等默认使用第一种策略;GNOME 50 默认使用第二种策略;KDE 下用户可选择使用第一种或者第三种策略。

GTK、XWayland 与分数缩放的问题⁠

GTK 应用在 X 上的分数缩放存在历史悠久的设计上的问题:

  • GTK3 时代的坐标系统采用的都是整数,并且 GTK 的开发者希望文本和边框可以跟物理像素完美对齐,拒绝使用可能导致模糊的抗锯齿方法处理「分数像素」的情况。
  • GTK4 时代的设计架构有了很大变化,坐标系统不再是问题,而新的基于 GPU 渲染的渲染器也使得在分数缩放下实现锐利的字体缩放成为可能。在 Wayland 的分数缩放协议合入 wayland-protocols 后,终于从 GTK 4.12 开始实现了分数缩放功能,并在后续完善。不过由于 X 被视作过时的平台,并且没有统一的分数缩放配置方案(如果直接像其他框架一样用 Xft 配置,会 break 现有的配置),因此 X 上的 GTK4 和 GTK3 一样仍然没有分数缩放支持。

由于仍然有少量的 GTK 应用(特别是 GTK3)依赖于 X,因此为了让 X 应用窗口的大小尽量一致,一种妥协方案就是在 Xsettings 和 X resources 中设置整数缩放,然后混成器总是进行缩放。这带来的问题就是,运行的全屏应用(特别是游戏)渲染的内容量会大于实际值,造成了性能上的浪费。

坐标系视角⁠

从坐标系的视角考虑,X 自己的物理坐标系是一套单独的坐标系。上面的三种策略大致对应三种不同的映射思路:

  1. X 坐标系与全局的逻辑坐标系一一映射。
  2. X 坐标系与全局的逻辑坐标系之间按照某个已知的比例映射。
  3. X 坐标系与全局的物理坐标系一一映射。

不过在实践中,混成器的具体实现有可能不会完全按照上述描述机械性地来做。

关于 mutter 的 XWayland HiDPI 实现⁠

Mutter 内部与之相关定义了两个坐标系:Protocol(X 坐标系)和 Stage 坐标系(全局逻辑坐标系)。它们的映射关系为 Protocol 坐标 = Stage 坐标 * xwayland_scale。xwayland_scale 为所有显示器中最大缩放比向上取整。对 Gtk 等应用,这样可以保证窗口和元素的大小是正确的,但是对 X 下的游戏来说,Protocol 坐标系就可能太大了(因为 xwayland_scale >= 实际的分数缩放比)。

不过在最新的 Xwayland 中(写作时尚未发布)包含了一个与缩放和分辨率相关的补丁:Xwayland 能够额外获取有关物理分辨率的信息,并在 X 程序使用 RandR 请求屏幕分辨率的时候,将物理分辨率作为 "preferred mode" 提供给应用。在这种情况下,全屏游戏如果使用 RandR 提供的这项信息,就能自动选择合适的分辨率,避免不必要的渲染。而如果游戏不听这个值,就还是只能手动在游戏内变更分辨率(如果游戏提供了选项),或者使用 gamescope 嵌套来让游戏读取到正确的分辨率:

# 让 game 以 1920x1080 分辨率全屏(-f)运行
gamescope -f -W 1920 -H 1080 -- ./game

远程桌面访问

Wayland 设计上不支持类似 X 的网络透明性,因为 Wayland 协议需要传递文件描述符,而文件描述符无法直接通过网络传递。不过,waypipe 项目实现了类似的功能(需要本地和远程都安装 waypipe),允许通过 SSH 连接启动远程的 Wayland 客户端程序,并将其显示在本地的 Wayland 桌面中。

而如果要通过 VNC 或 RDP 等方式访问整个图形界面,那么相比于 X 就复杂一些。在 Wayland 下,实现远程桌面有两种方式:与混成器合作或者与内核合作。

GNOME 与 KDE 分别实现了 gnome-remote-desktopKRdp。对于使用 wlroots 的混成器(例如 Sway),则可以使用 wayvnc。此外,TigerVNC 也实现了基于 Remote Desktop Portalw0vncserver,可以共享已经启动的桌面的界面。

由于不同的混成器实现存在差异,如果需要统一的远程桌面解决方案,那么可能就需要基于内核接口来实现。kmsvncreframe 项目实现了基于 DRM/KMS 接口的 VNC 服务器,可以在不依赖混成器的情况下实现远程桌面访问。Sunshine 也支持通过 DRM/KMS 实现屏幕捕获,实现游戏或桌面的串流。

无头的 DRM/KMS 显示⁠

如果 GPU 没有连接显示器,那么基于内核接口的远程桌面方案可能无法使用。可以购买显卡诱骗器(dummy plug),也可以通过一些 trick(添加不存在的显示器的 EDID 信息并设置内核参数,或者使用 VKMS 内核模块)实现。详情可参考 Arch Wiki 的 headless 一文

SSH + wayvnc + LXQt(labwc)的远程桌面方案⁠

由于 DRM 内核接口对硬件依赖较大,并且要求的用户权限也更高,以下介绍基于混成器的类似于上文 X 部分介绍的远程桌面方案的例子。

作为轻量级的且基于 wlroots 的混成器,labwc 支持通过 wayvnc 实现 VNC 远程桌面访问。LXQt 自 2.1.0 之后实现了对 Wayland 的支持,且允许用户在支持的混成器中自选。这里我们选择 labwc 作为混成器。

lxqt-wayland-session 只在 Debian forky(14)及之后版本中可用⁠

该包未被包含在 Debian 目前的 stable 版本(trixie)中。当然也可以不安装 LXQt,而是自行组装基于 labwc 的 Wayland 桌面环境(和基于 OpenBox 的 X 桌面环境类似)。

在安装了 lxqtlxqt-wayland-sessionlabwcwayvnc 之后,编辑 LXQt session 配置:

~/.config/lxqt/session.conf
[General]
compositor=labwc

将 LXQt 的 labwc 配置复制到用户目录:

cp -a /usr/share/lxqt/wayland/labwc ~/.config/

修改 ~/.config/labwc/autostart,添加 wayvnc 启动命令:

~/.config/labwc/autostart
# 省略已有内容

# -u -> Unix socket
wayvnc -u ~/.vncsock

然后配置类似的启动脚本:

/usr/local/bin/startvnc-wayland
#!/bin/sh -e

if [ -z "$XDG_RUNTIME_DIR" ]; then
    echo "XDG_RUNTIME_DIR is not set"
    exit 1
fi

# Start LXQt Wayland session
WLR_BACKENDS=headless startlxqtwayland > ~/startlxqtwayland.log 2>&1 &

XDG_RUNTIME_DIR 环境变量⁠

XDG_RUNTIME_DIR 是必须的环境变量,一般为 /run/user/<UID>。如果是容器等场景,这个环境变量可能未被设置,需要手动处理。

可能需要额外安装的包⁠

如果 DBus 会话总线未启动,startlxqtwayland 脚本会尝试使用 dbus-launch 启动一个新的会话总线,需要 dbus-x11 包。

托盘、桌面图标等 LXQt 组件需要 Qt6 安装 Wayland 支持包 qt6-wayland,否则这些组件会启动为 X 程序,可能会导致非预期的问题。

之后的操作与 X 部分类似,配置 SSH 转发 ~/.vncsock 即可。

包含 GDM 显示管理器集成的 GNOME 远程桌面方案⁠

与 X 不同的是,一般显示管理器使用的混成器和桌面环境的混成器不是同一进程,而让两者在远程桌面场景下无缝切换目前没有通用的解决方案。GNOME 的远程桌面方案利用 RDP 的 Server Redirection 特性实现了 GDM 与 GNOME 桌面环境(Shell)的协同。具体配置方法可参考文档

需要注意的是,不是所有 RDP 客户端都实现了 Server Redirection 特性。

DBus

基础概念

DBus(有些地方写为 D-Bus)是一套 IPC(Inter-Process Communication,进程间通信)机制,被 systemd 以及 Linux 桌面生态广泛使用。DBus 提供了一个集中的消息总线(message bus),进程可以连接到这个总线上,发送消息给其他进程,或者接收其他进程发送的消息。

Hackergame 2024 题目「不太分布式的软总线」⁠

该题目是一道 DBus 基础的应用题目,可参考阅读 writeup

DBus 总线的实现⁠

目前有两个主要的 DBus 总线实现:

  • dbus-daemon:最早的 DBus 总线实现。
  • dbus-broker:更加现代的 DBus 总线实现,只支持 Linux,解决了很多 dbus-daemon 的性能与稳定性问题。

目前 FedoraArch Linux 已经默认使用 dbus-broker。有关 dbus-broker 改进的技术细节,可以参考 Rethinking the D-Bus Message Bus

在基于 systemd 的环境下,一般会有两个 DBus 总线:

  • 系统总线(system bus):位于 /run/dbus/system_bus_socket
  • 会话总线(session bus):地址由环境变量 DBUS_SESSION_BUS_ADDRESS 设置。一般位于 /run/user/<UID>/bus

DBUS_SESSION_BUS_ADDRESS 是谁设置的?⁠

在现代的 systemd 环境下,当用户登录时 systemd 的 PAM 模块会启动用户级别的 systemd 实例,而默认 dbus.socket 是启用的:

/usr/lib/systemd/user/dbus.socket
[Unit]
Description=D-Bus User Message Bus Socket

[Socket]
ListenStream=%t/bus
ExecStartPost=-/bin/systemctl --user set-environment DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus

dbus.socket 启动后,其就会给这个 systemd 实例设置上 DBUS_SESSION_BUS_ADDRESS 环境变量,并且开始监听 /run/user/<UID>/bus,在有程序根据这个环境变量访问这个 Unix socket 的时候,systemd 就会自动启动 dbus.service,从而启动用户级别的 DBus 守护进程。

如果你在 systemd 流行之前就是 Linux 桌面用户的话,那么你可能会对类似这样的配置有印象:

dbus-run-session --exit-with-session your-desktop-environment

这里 dbus-run-session 就会创建一个新的 DBus 会话总线,设置 DBUS_SESSION_BUS_ADDRESS 环境变量,然后运行你给出的命令,从而完成桌面环境必须的 DBus 会话总线的配置。

DBus 的状态可以使用 systemd 提供的命令行工具 busctl 来查看,也可以使用诸如 D-SpyD-Feetqdbusviewer 等图形化工具来查看。以下是 D-Spy 的截图:

D-Spy Screenshot

从图中可以看到 DBus 的几个关键概念:

  • Bus Name:每个连接到 DBus 的进程都至少有一个唯一的 Bus Name,其中进程至少能获取到一个唯一名称(unique name),例如 :1.123。同时,进程也可以拥有(own)公认名称(well-known name),例如 org.freedesktop.NetworkManager,其他进程可以通过这个名称来访问它。可以认为 Unique name 直接标识进程到 DBus 的连接,而 well-known name 则是这个 unique name 的别名——因此可以看到 com.redhat.tuned 这个 well-known name 的 "Owner" 一栏显示了对应的 unique name(:1.16)。
  • Object Path:进程可以有多个对象(object),每个对象都有一个路径(path),例如图中的 /Tuned
  • Interface:每个对象可以有多个接口(interface),例如图中的 com.redhat.tuned.control。每个接口有 Property(属性)、Method(方法)和 Signal(信号)。
  • Property:表示对象的状态。读取属性实际上是调用 org.freedesktop.DBus.Properties 接口的 Get 方法来获取对应属性的值。org.freedesktop.DBus.Properties 也提供 Set 方法来设置属性值,GetAll 方法来获取所有属性的值,以及 PropertiesChanged 信号来通知属性变化。
  • Method:可以调用的方法。
  • Signal:进程可以发送的事件通知,其他进程可以监听这些事件。

此外还可以注意到,DBus 有类型定义。不过对不熟悉的人来讲,这个类型表示是很晦涩的,因为其作为二进制 IPC 协议,在设计时考虑的是方便代码解析类型,而不是人类可读性。图中出现的一些类型包括:

  • (bs):代表一个包含布尔值(b)和字符串(s)的结构体(struct)。
  • a{sa{ss}}:这是一个数组(a),数组的元素类型是字典(dictionary,{}),字典的 key 是字符串,value 是另一个数组,这个数组的元素又是字典,内层字典的 key 和 value 都是字符串。
  • (bsa(ss)):这是一个包含布尔值、字符串、数组的结构体,数组的元素类型是包含两个字符串的结构体。

有关类型系统的更多信息,可参考 DBus 标准文档的类型系统部分

VARIANT 类型⁠

DBus 中还有一种特殊的类型 VARIANT(在类型字符串中表示为 v),表示任意类型的值。这个类型对开发者来说是很方便的:不需要事先定义好类型(有些情况下也不好做),就可以传递任意类型的数据。但是在文档不佳的情况下,这对使用者来说是比较折磨的。以 org.freedesktop.systemd1/org/freedesktop/systemd1 下的 org.freedesktop.systemd1.Manager 接口的 StartTransientUnit 方法为例:

$ busctl introspect org.freedesktop.systemd1 /org/freedesktop/systemd1 --xml
(省略)
  <method name="StartTransientUnit">
   <arg type="s" name="name" direction="in"/>
   <arg type="s" name="mode" direction="in"/>
   <arg type="a(sv)" name="properties" direction="in"/>
   <arg type="a(sa(sv))" name="aux" direction="in"/>
   <arg type="o" name="job" direction="out"/>
  </method>
(省略)

可以看到,这里的 properties 参数类型是 a(sv),表示这是一个数组,数组元素是一个包含字符串和 VARIANT 的结构体。但是这个 VARIANT 究竟能接受哪些类型的数据呢?Systemd 的文档 对此的介绍并不是很具体。在较早期版本的 systemd 中,它可能甚至不会告诉你是 VARIANT 出了问题:

System.Error.ENXIO: No such device or address

而现在的报错或许稍微好一些(虽然也没好到哪里去):

System.Error.ENXIO: Failed to set unit properties: Unexpected message contents

遇到这种情况,只能自己看源代码,别无他法。

可以注意到,开发者需要用 XML 来描述 DBus 接口(在 DBus 那个时代,JSON 什么的还不存在——XML 是最流行的选择)。这被称为 introspection。客户端可以调用对象的 org.freedesktop.DBus.Introspectable 接口的 Introspect 方法来获取某个对象的 XML 描述,从而获取到该对象的接口信息。XML 信息主要用于人类阅读(了解接口信息)和代码生成。

Introspection 可以返回任意信息⁠

DBus daemon 不会验证 introspection 返回的数据是否与实际接口一致。并且考虑到 XML 潜在的复杂性和安全性问题,解析时需要小心。

此外,在图中我们可以看到每个 Bus Name 都有是否可以激活(Activatable)的标记。在使用 systemd 的系统上,DBus 会调用 systemd 的激活机制来启动对应的服务单元(service unit)。例如图中的 org.bluez

/usr/share/dbus-1/system-services/org.bluez.service
[D-BUS Service]
Name=org.bluez
Exec=/bin/false
User=root
SystemdService=dbus-org.bluez.service

如果某个进程尝试连接 org.bluez,但是该服务还没有运行,那么 DBus 会调用 systemd 来启动 dbus-org.bluez.service 单元,从而启动蓝牙服务。如果不和某个 systemd 服务绑定,如果需要让某个程序能在系统总线上拥有对应的 well-known name,也需要编写类似上述的 .service 文件,并配置 NameExec

此外如果在系统总线列表里多点点,可以注意到有些 Bus Name 不允许普通用户请求——这同样也是在 DBus 的配置中定义的,例如下面是 fi.w1.wpa_supplicant1 的配置:

/usr/share/dbus-1/system.d/wpa_supplicant.conf
<!DOCTYPE busconfig PUBLIC
 "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
        <policy user="root">
                <allow own="fi.w1.wpa_supplicant1"/>

                <allow send_destination="fi.w1.wpa_supplicant1"/>
                <allow send_interface="fi.w1.wpa_supplicant1"/>
                <allow receive_sender="fi.w1.wpa_supplicant1" receive_type="signal"/>
        </policy>
        <policy context="default">
                <deny own="fi.w1.wpa_supplicant1"/>
                <deny send_destination="fi.w1.wpa_supplicant1"/>
                <deny receive_sender="fi.w1.wpa_supplicant1" receive_type="signal"/>
        </policy>
</busconfig>

这个配置阻止了 root 以外的用户与 fi.w1.wpa_supplicant1 的交互。

交互与调试

有不少命令行工具可以用来和 DBus 交互(调用方法、发送或接收信号等),例如上面介绍的 busctl,以及 dbus-sendgdbus 等等。并且各类编程语言都有对应的 DBus 库,可以用来编写 DBus 客户端或者服务端程序。

发送通知⁠

notify-send 命令行工具可以发送桌面通知。其实际上会与会话总线的 org.freedesktop.Notifications 接口交互。相关标准文档可参考 Desktop Notifications Specification。其核心是 org.freedesktop.Notifications.Notify 方法

UINT32 org.freedesktop.Notifications.Notify ( STRING app_name,
                                              UINT32 replaces_id,
                                              STRING app_icon,
                                              STRING summary,
                                              STRING body,
                                              as actions,
                                              a{sv} hints,
                                              INT32 expire_timeout );

我们也可以来试一下:

gdbus call --session \
  --dest org.freedesktop.Notifications \
  --object-path /org/freedesktop/Notifications \
  --method org.freedesktop.Notifications.Notify \
  "testapp" 0 "" "This is summary" "This is body text" [] [] 0

运行就可以发现桌面上弹出了我们的通知。

获取当前正在播放的音乐信息⁠

MPRIS 接口 是 Linux 下音乐播放器与桌面组件(以及其他需要相关信息的软件)的标准接口。假如你的音乐播放器名字叫 playmusic,那么它在 DBus 上的 well-known name 就是 org.mpris.MediaPlayer2.playmusicorg.mpris.MediaPlayer2.Player 接口提供了一些属性,可以让我们获取当前播放器的状态,以及歌曲元数据等。

尝试使用你最喜欢的编程语言的 DBus 库,编写一个小程序,获取当前正在播放的音乐信息。

DBus 也允许我们抓取总线上的所有消息以供调试(抓取系统总线需要 root 权限)。dbus-monitorbusctl monitor,以及图形界面的 Bustle 都可以做到这一点。例如,如果希望实时显示会话总线的所有消息,可以运行:

busctl monitor --user

Portal

XDG Desktop Portal 提供了一套标准的 DBus 接口,允许应用程序访问系统与桌面资源。其最常见的用途是:

  • 为沙盒化(Flatpak/Snap 等)的应用提供安全访问沙盒外部资源的能力。
  • 为 Wayland 桌面环境下的应用提供诸如屏幕共享、全局快捷键设置等能力。
    • 其中一些功能可以以 Wayland 协议的形式实现,但是不是所有混成器都会去支持。

不同的桌面环境会有不同的 portal 实现,并且不同 portal 实现支持的功能也存在差异。实现了 portal 用户程序接口的 Rust 库 ASHPD 提供了一个 demo 程序,可以在 Flathub 上找到,可以用来测试你的桌面上 portal 目前实现的功能。

Portal 如何判断应用身份,兼 Linux 桌面的安全模型讨论⁠

可以注意到,portal 有必要准确判断应用的身份,否则假如程序 A 获取了录屏的权限,而程序 B 想偷偷录屏的话,用程序 A 的名字去请求 portal 就行了。因此 portal 不能光凭应用声称的名字说了算,需要有更可靠的身份验证机制。

对 Flatpak、Snap 等 portal 支持的沙盒,portal 可以按照沙盒的设计来获取应用身份:

  • Flatpak 保证沙盒化应用运行时其挂载命名空间中存在 /.flatpak-info 文件,并且该文件无法被沙盒内的程序修改、删除。因此 portal 可以通过 procfs 读取该文件来获取应用的应用名称。
  • 对 Snap,portal 会先从 cgroup 信息确认是否为 Snap 应用,再调用 snap routine portal-info 命令获取应用的 Snap 名称。

同时也有尝试统一各个沙盒化方案下应用身份识别的提议,例如基于 cgroup 扩展属性的方案。

而对非沙盒化应用,portal 会尝试从进程所属 cgroup 的名称来获取到应用名称(例如 app-com.example.test-12345.scope -> com.example.test)。现代桌面环境的应用启动器会做这样的处理,将启动的应用放在名字符合命名标准的 cgroup(systemd service 或者 scope)下。而非沙盒化的应用可以轻松用 systemd-run 来实现这一点:

systemd-run --user --scope -u app-com.example.test-12345.scope path/to/app

非沙盒化应用也可以用 org.freedesktop.host.portal.Registry 来注册自己的名称。

可以注意到:这里事实上假设了非沙盒化的应用是可信的。可以认为这是一种无奈的设计上的妥协,因为 Linux 下没有可靠的方法来验证非沙盒化应用的身份(在安全场景下,/proc/<PID>/exe 不可靠,可以想一下为什么)。同时在这个安全模型下,有一些初看很离谱的安全设计可以得到解释,例如 CVE-2018-19358 汇报的问题是在 GNOME 钥匙环解锁之后,任意能够访问钥匙环 DBus 接口的程序都能访问钥匙环中的任意内容,而这个问题被忽略(或者说,无法处理)的原因正是因为上述提到的安全模型。

在沙盒应用的场景下,应用可以请求 Secret portal 获取一个用于加密的、每个应用不同的 key,应用需要自行使用这个 key 加密自己的数据。Secret portal 不选择中心化存储所有的应用密码,或许是因为 Secret Service 本身没有分应用的概念,是为了简化实现、避免潜在的问题所做的妥协。对使用 libsecret 的应用,这一项变化是透明的(libsecret 会在使用 portal 时自动创建文件存储加密后的密码)。

Portal 是如何将沙盒外的文件提供给沙盒内的应用的?⁠

有两个关键的 portal 接口:Documents portalFile Chooser portal

Documents portal 由 portal 源代码提供的 xdg-document-portal 实现。它提供了一个 FUSE 文件系统,挂载在 /run/user/<UID>/doc/ 下。在 host 上可以看到所有通过 Documents portal 提供的文件,而在沙盒内,应用只能在这个路径看到自己被授权访问的文件。Documents portal 也当然提供 DBus 接口让其他主机上的程序(例如 flatpak)向 portal 添加文件、给指定的应用提供文件权限。

那么应用自己怎么请求访问外部的文件呢?这就是 File Chooser portal 的工作了。Gtk、Qt 等 GUI 框架都实现了对 File Chooser portal 的支持,当应用调用文件选择对话框时,这些框架不会再自己实现文件选择对话框,而是调用 File Chooser portal 的 DBus 接口,请求在指定窗口的上方显示文件选择对话框(在 Wayland 下使用 xdg-foreign-unstable-v2 实现)。用户在 portal 的对话框中选择文件之后,portal 会将用户选择的文件通过 Documents portal 提供给应用——应用看不到主机上的其他文件。

使用 portal 在 Wayland 下自动化键盘输入⁠

在 Wayland 下如果需要自动化键盘输入,目前有以下几种方案:

  • XWayland 实现了 XTEST 的模拟——会把请求转发到 Portal。在支持的环境下,可以使用传统的 xdotool type 来实现。
  • 如果混成器支持 virtual-keyboard-unstable-v1,那么可以使用 wtype 工具(甚至可以「模拟输入」一般不能直接用键盘输入的非 ASCII 字符)。
  • 使用内核的 uinput 接口(/dev/uinput),例如 ydotool 工具。
  • 直接与 Portal 交互。

这里的 portal 方案使用的是 Remote Desktop portal,允许程序在取得授权的情况下控制鼠标、键盘和触摸屏。需要注意的是,portal 一般都需要明确允许,并且 portal 可能不会记住授权状态,因此只适合临时的自动化任务,例如突然需要向某个无法粘贴内容的地方(比如说 IPMI KVM 界面)输入一段文本。

如果你需要一个好用的与 portal 交互的库,可以考虑使用 enigo(Rust)。(题外话:这个库被 Claude Desktop 使用了,但是作者去 Anthropic 面试被拒绝了)。

另外,Hackergame 2024 题目「无法获得的秘密」需要选手向一个禁止剪贴板的 VNC session 中输入大量自己的代码。在 writeup 附录中包含了直接调用 ASHPD 库与 Remote Desktop portal 交互的示例代码,可以作为参考。

libei⁠

libei(library for Emulated Input)是 Wayland 生态下用于模拟/获取输入的组件。客户端(应用程序)通过 EI 协议 与服务端(EIS,Emulated Input Server)交互,提供输入或者获取输入。服务端一般是实现了 EIS 的混成器(mutter、kwin 等),对不支持 EIS 的混成器,portal 本身也可以自行实现 EIS,使用其他 Wayland 协议等与混成器交互。

EI 本身不做鉴权——只要获取了对应的 Unix socket 文件描述符就可以操作,因此服务端一般不会直接暴露 $XDG_RUNTIME_DIR/eis-0 提供使用。实际客户端会通过 Remote Desktop portal 与 Input Capture portal 从 portal 拿到 EIS 提供的 fd。Remote Desktop portal 也提供了不需要 EI 的方式(直接通过 DBus 的 NotifyPointerMotion() 等方法去让 portal「提醒」混成器输入的变化),不过 EI 的方案更被推荐。

在握手阶段,客户端会与 EIS 协商一些信息,其中包括自己的身份是 sender 还是 receiver。这里的 sender 和 receiver 是相对 EIS 来说的。例如对使用 Deskflow 远程控制的场景,控制端的 Deskflow 是 receiver,需要从 EIS 获取键盘、鼠标的变化情况;被控端的 Deskflow 则是 sender,需要向 EIS 发送从控制端拿到的键盘、鼠标的变化。

不过从名称上容易误解的是,Input Capture portal 无法在用户批准后无限制地获取用户输入。尽管理论上可以扩展,其目前唯一的支持场景就是:当鼠标光标移动到指定边界(barrier)之外的时候,才允许应用获取输入信息(并且此时鼠标光标也是冻结住的)。所以这个设计目前只允许了像 Deskflow、Synergy 这类软件用一套键鼠在多台设备之间切换。

Portal 与剪贴板文件传输⁠

跨沙盒进行文件的拖动或剪贴板复制(DND)时,会有一个关键的问题:程序间使用剪贴板传输文件时,一般不会通过这个机制传输完整的文件,而是直接传输一个 URI 或文件路径(一般在 text/uri-list 或者 text/plain;charset=utf-8 这两个 mimetype 里面),避免不必要的拷贝:

$ wl-paste --type text/uri-list
file:///home/user/Downloads/example.txt
$ wl-paste --type text/plain\;charset=utf-8
/home/user/Downloads/example.txt

但是沙盒由于 mount namespace 的隔离,里面的文件路径和外面很可能是不一样的,直接使用文件路径交换就会失败。

File Transfer portal 实现了跨沙盒的文件传输。发送方调用 File Transfer portal 开始传送、添加需要传送的文件后,就可以以 application/vnd.portal.filetransfer 的 mimetype 向对方通过窗口系统的 DND 机制发送 key,接收方通过这个 key 可以从 File Transfer portal 获取对应的文件的实际路径。File Transfer portal 在需要的时候会把文件放进 Documents portal 里面。

不过如果从 GTK 程序复制文件,可能还会看到另一个 mimetype application/vnd.portal.files

$ wl-paste -l
application/vnd.portal.files
application/vnd.portal.filetransfer
text/uri-list
text/plain;charset=utf-8
x-special/gnome-copied-files

这单纯是因为最开始 GTK 实现的时候,mimetype 名字写错了

音频服务

音频框架简介

ALSA(Advanced Linux Sound Architecture)是 Linux 音频服务的基础组件,分为内核态和用户态两部分。内核态 ALSA 实现声卡驱动,并向用户态以 /dev/snd 的形式暴露音频设备(字符设备)。这些设备文件都属于 audio 组,意味着只要用户在这个组里面,就可以对声卡进行任意音频操作。

$ ls /dev/snd/
by-id/     controlC1  controlC4  hwC1D0    pcmC1D3p  pcmC1D9p  pcmC2D8p  pcmC4D0p  pcmC6D0c  pcmC6D1p  pcmC6D3p
by-path/   controlC2  controlC5  hwC2D0    pcmC1D7p  pcmC2D3p  pcmC2D9p  pcmC5D0c  pcmC6D0p  pcmC6D2c  seq
controlC0  controlC3  controlC6  pcmC0D0c  pcmC1D8p  pcmC2D7p  pcmC4D0c  pcmC5D0p  pcmC6D1c  pcmC6D2p  timer

我不在 audio 组里面,为什么我可以放歌?⁠

systemd 包携带的 /usr/lib/udev/rules.d/70-uaccess.rules 文件会给用户需要的设备在 udev 中打上 uaccess 的标签,例如这里的音频设备:

# Sound devices
SUBSYSTEM=="sound", TAG+="uaccess", \
  OPTIONS+="static_node=snd/timer", OPTIONS+="static_node=snd/seq"

sd-login.3 文档中这么描述 uaccess 标签的用途:

When set, access to this device is tied to an active seat. As the session on the seat becomes active or inactive, access to the device is updated accordingly.

systemd-logind 看到标签之后,就会在开启 session 的时候通过 ACL 给设备添加对应用户的访问权限:

$ getfacl /dev/snd/controlC0
getfacl: Removing leading '/' from absolute path names
# file: dev/snd/controlC0
# owner: root
# group: audio
user::rw-
user:username:rw-
group::rw-
mask::rw-
other::---

同时 /proc/asound/sys/class/sound 也提供了用于调试、管理等的信息。不过绝大多数应用程序都不会直接操作它们——如果程序使用 ALSA 的话,几乎都是通过用户态的 libasound(libasound2t64)库提供的接口来播放、录制音频。

ALSA 中的设备模型⁠

ALSA 中的设备有多个层级。首先是声卡(card),可以从 /proc/asound/cards 获取 ALSA 内核态得到的所有(硬件)声卡:

/proc/asound/cards
 0 [XXXX1080PCamera]: USB-Audio - XXXX-1080P-Camera-Audio
                      XXXX-1080P-Camera XXXX-1080P-Camera-Audio at usb-0000:0f:00.0-2, high speed
 1 [HDMI           ]: HDA-Intel - HDA ATI HDMI
                      HDA ATI HDMI at 0xf6ca0000 irq 109
 2 [Generic_1      ]: HDA-Intel - HD-Audio Generic
                      HD-Audio Generic at 0xf6588000 irq 110
 3 [Generic        ]: HDA-Intel - HD-Audio Generic
                      HD-Audio Generic at 0xf6580000 irq 111
 4 [YYYY           ]: USB-Audio - YYYY YYYY
                      XXXX Electronics Inc. YYYY YYYY at usb-0000:0f:00.0-3, full speed
 5 [ZZZZZ          ]: USB-Audio - ZZZZZ 音箱
                      ZZZZZ 音箱 at usb-0000:0f:00.0-4, full speed
 6 [Audio          ]: USB-Audio - USB Audio
                      Generic USB Audio at usb-0000:0f:00.0-12, high speed

一个声卡中有一个或多个设备(device),内核识别到的设备可以从 /proc/asound/devices 查看:

/proc/asound/devices
  1:        : sequencer
  2: [ 3]   : control
  3: [ 1- 3]: digital audio playback
  4: [ 1- 7]: digital audio playback
  5: [ 2- 3]: digital audio playback
  6: [ 2- 7]: digital audio playback
  7: [ 1- 8]: digital audio playback
  8: [ 1- 9]: digital audio playback
  9: [ 1- 0]: hardware dependent
 10: [ 2- 8]: digital audio playback
 11: [ 2- 9]: digital audio playback
 12: [ 2- 0]: hardware dependent
 13: [ 2]   : control
 14: [ 1]   : control
 15: [ 0- 0]: digital audio capture
 16: [ 0]   : control
 17: [ 4- 0]: digital audio playback
 18: [ 4- 0]: digital audio capture
 19: [ 4]   : control
 20: [ 5- 0]: digital audio playback
 21: [ 5- 0]: digital audio capture
 22: [ 5]   : control
 23: [ 6- 0]: digital audio playback
 24: [ 6- 0]: digital audio capture
 25: [ 6- 1]: digital audio playback
 26: [ 6- 1]: digital audio capture
 27: [ 6- 2]: digital audio playback
 28: [ 6- 2]: digital audio capture
 29: [ 6- 3]: digital audio playback
 30: [ 6]   : control
 33:        : timer

其中方括号内部的格式为 [卡号-设备号],例如上面的 ZZZZZ 音箱,如果要从这个音箱直接用 ALSA 硬件设备播放音频,那么对应的设备标记就是 hw:5,0(对应的设备文件为 /dev/snd/pcmC5D0p)。此外,设备还可以有子设备(subdevice),用于多个并发流的场景(硬件混音)。支持播放音频的 ALSA 硬件声卡和设备信息也可以用 aplay -l 命令查看(arecord -l 也对应支持录制音频的设备信息)。

如果需要直接连接 ALSA 硬件测试音频播放/录制是否正常(排除中间层的影响),那么在设备文件未被占用的情况下,可以使用 speaker-testaplayarecord 直接输出/获取 PCM 原始音频流测试(ALSA 出于安全起见默认会静音所有设备,因此可能需要先使用 alsamixer 调整音量)。可以先播放空 PCM 音频来获取设备信息,以上面的 hw:5,0 为例子:

$ aplay -D hw:5,0 --dump-hw-params /dev/zero
Playing raw data '/dev/zero' : Unsigned 8 bit, Rate 8000 Hz, Mono
HW Params of device "hw:5,0":
--------------------
ACCESS: MMAP_INTERLEAVED RW_INTERLEAVED
FORMAT: S16_LE
SUBFORMAT: STD MSBITS_MAX
SAMPLE_BITS: 16
FRAME_BITS: 32
CHANNELS: 2
RATE: [44100 48000]
PERIOD_TIME: [1000 1000000]
PERIOD_SIZE: [45 48000]
PERIOD_BYTES: [180 192000]
PERIODS: [2 1024]
BUFFER_TIME: [1875 2000000]
BUFFER_SIZE: [90 96000]
BUFFER_BYTES: [360 384000]
TICK_TIME: ALL
--------------------
aplay: set_params:1393: Sample format non available
Available formats:
- S16_LE

从上述信息看,hw:5,0 支持 S16_LE(有符号 16 位小端序)格式的 PCM、双声道、48000Hz 的采样率,因此 speaker-test 的参数可以为:

# 播放正弦波,loop (-l) 1 次
speaker-test -D hw:5,0 -c 2 -r 48000 -F S16_LE -t sine -l 1

不过,libasound 实现了插件机制,因此应用实际看到的设备会比内核暴露出来的更多,可以使用 aplay -L(以及 arecord -L)查看:

$ aplay -L
null
    Discard all samples (playback) or generate zero samples (capture)
default
    Default Audio Device
sysdefault
    Default Audio Device
pipewire
    PipeWire Sound Server
hdmi:CARD=HDMI,DEV=0
    HDA ATI HDMI, HDMI 0
    HDMI Audio Output
(省略)

大部分 ALSA 的应用都会直接使用 default 设备。libasound 的插件层也是以下介绍的 PulseAudio 和 PipeWire 实现 ALSA 兼容层的基础。

但是在现代桌面环境下,ALSA 已经无法满足用户的需求:

  • 较老的 ALSA 默认不支持软件混音(mix),这意味着在不支持硬件混音的声卡上,同时只能有一个程序播放音频,需要使用 dmix ALSA 插件添加支持。新的 ALSA 会自动加载 dmix,但是这个插件也仍然不支持给每个应用程序不同的音量,只负责把多个音频流打包发给声卡。
  • ALSA 无法灵活切换输出设备:不支持热插拔切换(比如说插上使用不同声卡的有线耳机的时候自动从外放改成耳机输出),切换时,当前的程序也不会自动跟着切换。
  • ALSA 处理蓝牙耳机很麻烦,需要使用 BlueZ 自己做很多很多事情。

因此,现代桌面一般都在 ALSA 与应用之间添加了一个中间层来处理桌面产生的新需求。这个中间层在比较老的发行版上一般是 PulseAudio,在新发行版上一般是 PipeWire。两者的模型有非常大的差异,以下以介绍 PipeWire 为主。

PulseAudio 和 PipeWire 是怎么实现 ALSA 兼容的?⁠

应用一般使用 libasound 暴露出的 default 设备来播放或者录制音频,因此只要让它能够转到 PulseAudio 或者 PipeWire 处理即可。pulseaudio 包会设置 pcmctl 对应的 default interface 为 pulse

/usr/share/alsa/pulse-alsa.conf
# This file is referred to by /usr/share/alsa/pulse.conf to set pulseaudio as
# the default output plugin for applications using alsa when PulseAudio is
# running.

pcm.!default {
    type pulse
    hint {
        show on
        description "Playback/recording through the PulseAudio sound server"
    }
}

ctl.!default {
    type pulse
}

而 PipeWire 也是类似(pipewire-alsa 包):

/usr/share/alsa/alsa.conf.d/99-pipewire-default.conf
pcm.!default {
    type pipewire
    playback_node "-1"
    capture_node  "-1"
    hint {
        show on
        description "Default ALSA Output (currently PipeWire Media Server)"
    }
}

ctl.!default {
    type pipewire
}

不过,如果应用真的要直接访问硬件,不经过 libasound 的话怎么办呢?PulseAudio 和 PipeWire 目前默认配置下都会自动释放未占用的硬件节点:PulseAudio 通过 module-suspend-on-idle 模块,PipeWire 通过节点的 session.suspend-timeout-seconds 配置。此外,它们也实现了 org.freedesktop.ReserveDevice1 的 DBus 接口,允许应用主动请求来让出设备。

PulseAudio 是一个中心化的音频服务器(它的作者也是 systemd 的作者 Lennart Poettering),对应默认 socket 位于 $XDG_RUNTIME_DIR/pulse/native。在 PulseAudio 中,输出声音的设备被称为 sink,输入声音的设备被称为 source,而播放音频和录制音频的数据流则被分别称为 "sink input" 和 "source output"。PulseAudio 会接收多个 sink input 的音频,混音之后发送给 sink;同样 PulseAudio 收到 source 的音频之后,会负责发送给所有的 source output。PulseAudio 允许加载模块来控制其行为。

相比传统的音频服务器,PulseAudio 从 0.9.11 版本(2008 年)开始引入的一项重要改进是「基于计时器的音频调度」(timer-based audio scheduling,tsched,也被称为 glitch-free audio)。传统上,声卡会给应用(音频服务器)分配固定大小的环形缓冲区,每隔一小段时间(fragment),声卡就会通过中断通知 OS 自己需要新的数据,对应的应用需要监听(select() 或者 poll())声卡的设备文件,写入到对应的缓冲区中。但是,如果应用没有来得及写入数据(比如说 CPU 资源被其他的计算程序抢占),那么播放出来的声音就会出现明显的问题。这种缓冲区没有来得及写入数据的情况也被称为 underrun(在录制音频时,对应的问题是应用没有来得及读取数据,导致录制的音频丢失,被称为 overrun;两者被称为 xrun)。而缓冲区和 fragment 的大小不仅很难确定出最优值,并且它们的大小配置依赖声卡硬件提供的选项进行协商,无法任意调整,一旦设置之后也难以修改,大量的中断在笔记本电脑上也更加耗电。

基于计时器的音频调度则不依赖于声卡的中断进行调度,而是根据软件计时器来决定什么时候把数据提供给声卡缓冲区。PulseAudio 会尽量让 ALSA 关闭声卡的中断,把声卡硬件的缓冲区配置得很大(可以远大于实际需要的延迟,例如 2s),配置定时器(例如 10ms)来唤醒并向缓冲区尽可能填入数据。如果应用需要更低的延迟,PulseAudio 每次被定时器唤醒的时候填充的数据量也就相应减少。并且 PulseAudio 也会随时修改缓冲区的内容(Rewinding),例如当用户暂停音乐的时候,PulseAudio 就会把缓冲区后面的部分清理掉,而不必等到当前 buffer 里面已经有的音频数据全部放完。

而 PipeWire 的设计则和 PulseAudio 很不一样。PipeWire 中应用程序、硬件设备等等都是节点(node),节点上有一些输入的 port 和输出的 port,port 之间用 link 连接。PipeWire 则负责管理这个多媒体节点组成的图。多媒体图设计上不和音频强绑定,因此 PipeWire 也可以处理视频数据。PipeWire 本身不负责怎么连接这些节点,只负责实时执行这一张多媒体图。真正负责建立节点、连线的被称为 session manager,在现代系统上一般是 WirePlumber。早期的系统可能会使用 pipewire-media-session 作为 session manager,不过目前一般已经不使用了。PipeWire 对应默认 socket 位于 $XDG_RUNTIME_DIR/pipewire-0(和用于 session manager 的 $XDG_RUNTIME_DIR/pipewire-0-manager)。

有一些程序可以查看 PipeWire 的多媒体图,例如 qpwgraphhelvum 可以查看、修改节点之间的连线;coppwr 可以查看节点的详细信息等等。

helvum

Helvum 示例图。这里节点 port 之间可以拖动连线,可以轻松实现诸如让 A 程序从蓝牙耳机输出音频,让 B 程序从 USB 音箱输出音频的需求。

相比 PulseAudio,PipeWire 的一大优势就在于延迟。尽管采取了类似于 PulseAudio 的计时器调度的模式,PipeWire 在具体实现上也有很大的差异。PipeWire 的多媒体图中必须包含设备节点,每次图计算都由设备节点触发。触发的频率则取决于图的采样率和(图最小需要的)quantum 的大小。quantum 是每次计算时要处理的数据的帧数,quantum / 采样率 = 每次计算的时间间隔。例如对 48kHz 的采样率(很多情况下都是默认值),quantum = 256 时,每个 quantum 的时间则为 256 / 48000Hz ~= 5.33ms,PipeWire 就会每经过 5.33ms 计算一次多媒体图。每次计算时,当前正在播放的就是上一次计算的结果,每次计算必须在 5.33ms 内完成,否则就会 xrun。并且在计算的过程中,与 PulseAudio 不同,数据流可以不经过 PipeWire 的 daemon,相关的信息是点对点传递的,并且可以实现零复制(link 两端共用同一块 buffer,一端写入完成后使用 eventfd 通知另外一端可以开始处理了)。

使用 pw-top 可以实时查看每个节点的信息,包括 quantum、采样率等信息。

pw-top 输出示例⁠

$ pw-top -b  # 使用非交互模式
(省略)
S   ID  QUANT   RATE    WAIT    BUSY   W/Q   B/Q  ERR FORMAT           NAME
S   29      0      0    ---     ---   ---   ---     0                  Dummy-Driver
S   30      0      0    ---     ---   ---   ---     0                  Freewheel-Driver
S  105      0      0    ---     ---   ---   ---     0                  Midi-Bridge
S   50      0      0    ---     ---   ---   ---     0                  bluez_midi.server
R   90   2048  48000  25.2us  14.6us  0.00  0.00    0    F32LE 2 48000 bluez_output.12_23_34_45_56_67.1
R  175   4320  48000  14.6us   5.3us  0.00  0.00    0    F32LE 2 48000  + Lollypop
S   89      0      0    ---     ---   ---   ---     0                  bluez_input.12:23:34:45:56:67
S  138      0      0    ---     ---   ---   ---     0                  bluez_capture_internal.12:23:34:45:56:67

这里的 WAIT 和 BUSY 分别代表节点等待调度的时间和处理数据的时间(越小越好),W/Q 和 B/Q 则是它们与一个 quantum 时间的比值(也是越小越好)。ERR 表示出现 xrun 的次数。可以看到,目前的音频处理是非常顺畅的。

PipeWire 对 PulseAudio 的兼容⁠

作为 PulseAudio 的继承者,PipeWire 当然也需要实现对 PulseAudio 的兼容。pipewire-pulse 包提供了这一项兼容层,它会在 PulseAudio 相同的 socket 路径上监听,并且把请求转换、发送到 PipeWire 上。可以从 pactl info 的输出判断是不是 PipeWire 提供的服务:

$ pactl info | grep 'Server Name'
Server Name: PulseAudio (on PipeWire 1.6.8)

调整延迟⁠

PulseAudio 和 PipeWire 都支持应用程序调整延迟,在需要较低延迟的场景,或者出现 xrun 需要提高延迟的情况下都很有用。

使用 PulseAudio 的应用(链接了 libpulse)可以通过设置环境变量 PULSE_LATENCY_MSEC 来指定自己希望达到的延迟。在这个环境变量存在的情况下,libpulse 在创建音频流时,会根据 PulseAudio 的延迟控制方式设置相关的 flag 与 buffer 属性(如果应用自行进行延迟控制,相关的设置可能会被覆盖),直接指定希望达到的延迟目标,发送给音频服务器。

PipeWire 的 PulseAudio 兼容实现在不同版本下,对延迟要求可能有不同的处理。对写作时最新的 pipewire-pulse,延迟目标(初始的 tlength,记为 latency)需要分给两部分:图计算一个周期的时长(quantum)和 pipewire-pulse 内置的 buffer 大小对应的时间。不考虑最小限制的情况下,pipewire-pulse 会将 latency / 4 作为每次客户端的数据最小要求的时间(minreq),然后 quantum 时间 = (latency - 2 * minreq) / 2,剩下的部分就留给 buffer(实际的 tlength)。因此对下述例子:

# paplay 会连接 PulseAudio 服务端播放音频
PULSE_LATENCY_MSEC=25 paplay example.ogg

假设采样率为 48kHz,此时可以使用 pw-top 看到 quantum = 300,因为 minreq = 25ms / 4 = 6.25ms,quantum 时间 = (25ms - 2 * 6.25ms) / 2 = 6.25ms,乘以采样率就能得到这个结果。

而在录音时,给定的延迟就直接成为累计对应长度录音数据后向客户端发送的阈值(fragsize),在没有其他限制条件下也是对应的 quantum。

# parecord,同样连接 PulseAudio 服务端录制音频
PULSE_LATENCY_MSEC=25 parecord example.pcm

对上面的例子,如果采样率是 44.1kHz(parecord 默认值),那么 quantum 就会是 44100Hz * 25ms ~= 1102。

以上参数(frag、req、tlength、quantum)的设置可以通过配置修改,参见 PipeWire 对 Protocol Pulse 的文档

对直接连接 PipeWire 的应用来说,PIPEWIRE_LATENCY 环境变量则可以控制应用对应节点需要的 quantum 与采样率:

PIPEWIRE_LATENCY=128/48000 pw-play example.ogg

相关设置也可以在 pipewire.confdefault.clock)修改。

RTkit 与音频服务⁠

实现低延迟音频非常重要的一点是音频处理有足够的 CPU 时间来运行。Linux 支持实时优先级,能够保障指定的应用在指定规则内不被抢占,包含以下三种模式:

  • SCHED_FIFO:同一优先级下按 FIFO 的方式执行,除非线程主动放弃或者被更高优先级抢占,否则一直拿着 CPU 资源。
  • SCHED_RR:在 SCHED_FIFO 基础上,有一个时间片限制,超过时间片后放在队列最尾部。
  • SCHED_DEADLINE:思路与上两者不同,设置调度时需要提供每次调度的运行时间(runtime)、deadline 与周期(period),例如「每 100ms(period)需要 20ms 的运行时间(runtime),需要在 50ms 内完成(deadline)」。如果无法调度,那么优先级设置就会失败。

不过,虽然实时优先级对某些工作负载很重要,但同时也很危险:如果恶意程序或者编写不善的程序获取实时优先级,那么就可能会让系统卡死,无法执行其他操作。RTkit 目前是 PipeWire 项目维护的在 Linux 桌面下为应用提供实时优先级能力的工具,在应用申请下默认提供 SCHED_RR 设置(也可以设置为 SCHED_FIFO。目前不支持 SCHED_DEADLINE)。为了防止应用写坏了导致系统卡死,RTkit 有 watchdog 的设计:一个 SCHED_OTHER 的非实时线程每 5s 发送一次心跳到 SCHED_RR(默认设置)、优先级 99 的 watchdog 线程,如果 watchdog 10s 都没收到心跳,那么就认为系统的实时优先级线程出了问题,会强制降级它设置的所有实时线程,并且拒绝新请求一段时间。

pipewirepipewire-pulse 默认都会通过 RTkit(以及 Portal 暴露的 Realtime Portal)设置自己的数据处理的线程为实时优先级。直接连接 pipewire socket 的用户应用的数据处理线程也会尽量设置为实时优先级。可以通过 chrt 确认:

$ chrt -a -p 462852
pid 462852's current scheduling policy: SCHED_OTHER|SCHED_RESET_ON_FORK
pid 462852's current scheduling priority: 0
pid 462852's current runtime parameter: 2800000
pid 462899's current scheduling policy: SCHED_OTHER|SCHED_RESET_ON_FORK
pid 462899's current scheduling priority: 0
pid 462899's current runtime parameter: 2800000
pid 462900's current scheduling policy: SCHED_RR|SCHED_RESET_ON_FORK
pid 462900's current scheduling priority: 20

此外,相比 PulseAudio,PipeWire 在安全性上也有所改善。(TODO)

(TODO)