欢迎光临散文网 会员登陆 & 注册

Wayland从入门到入土P0---HelloWorld

2023-06-02 15:03 作者:怎么取名字这么难额啊  | 我要投稿

声明

这个文章就是写着玩的,纯Linux新手。本人不对文章的准确性做任何负责,仅以读后感的形式分享。很多纰漏还请多多交流


引入

Hmmm,怎么说呢,X11命不久矣,也就还剩一口气,现在是Wayland的时代力(really?)。目前不少Linux应用都开始原生支持Wayland,包括最近非常令人兴奋的Wine的原生Wayland支持,消除XWayland的开销以后应该能提升不少性能。

所以想着想着,我觉得也确实有必要深入学习一下Wayland的工作原理了,说不定还能帮助开源社区迁移一些软件到Wayland上(现在是幻想时间)。

主要资料

[The Wayland Book] (https://wayland-book.com/):帮助很大,Wayland的初级工作原理就是从这里学的。

[Linux Man Pages Online] (https://www.man7.org/linux/man-pages/):在线查Linux的API手册,同样帮助很大,因为涉及到内存操作。

[Wayland Official Document] (https://wayland.freedesktop.org/docs/html/):Wayland官方的简单手册。

初步认识Wayland

X11/Wayland只是一个协议,并非具体实现

这个很好理解:

   1.C语言标准 vs C语言编译器的具体实现(GCC等)

   2.英语语言和语法 vs 用英语写成的文章

说白了,Wayland只是告诉大家:“实现的时候这样搞,我们就可以通用力”,包括X11也是一样的。

那么具体实现在哪里?

具体实现就是Linux的整个图形栈,从下到上就是DRM---Driver---Compositer---Protocol(Wayland和X11在这里)

说人话就是:我们通过特定的语言(Wayland或者X11的规定)和Compositer(混合器)交流。

除了Wayland官方的参考设计---合成器Weston以外,更常用的混合器有Gnome的Mutter,KDE的KWin等。

边看代码边读手册

我不喜欢对着文档一通分析,这种东西就应该直接上手,然后RTFSC和RTFM。

前置任务

[南京大学操作系统课程]:JYY永远的神。

[Wayland Hello World] (https://github.com/emersion/hello-wayland):Wayland官方的简单Hello World。

直接开搞

连接服务器

Wayland是一个Client---Host模型为基础的显示协议,我们要显示的窗口就是Client,混成器为Host

跳到最底下的main函数

struct wl_display *display = wl_display_connect(NULL);

一上来就用了个结构体指针,从名字大概可以猜出这是初始化一个显示对象。合情合理,想要显示东西,那总得找个地方罢。

**但是,这里的display和我们电脑的显示器没啥关系,名字有点误导**

if (display == NULL)
{

   fprintf(stderr, "failed to create display\n");
   return EXIT_FAILURE;
}

如果连接不上服务器的话,就会得到空指针。那就直接结束退出咯

RTFM

struct wl_display wl_display_connect(const char * name)

参数:字符串name的指针

用处:连接指定的Wayland服务器,一般只有一个(即wayland-0),留NULL则按照以下顺序尝试:

   1.读取$WAYLAND_DISPLAY

   2.无此变量或连接失败则尝试wayland-0

   3.无wayland-0直接失败退出

Wayland服务器不是显示屏,和屏幕数量无关,Wayland服务器一般就是Compositer。

注册

这里就上点强度了,三行代码对应的具体实现占了main.c的一大半

struct wl_registry *registry = wl_display_get_registry(display);
wl_registry_add_listener(registry, &registry_listener, NULL);
wl_display_roundtrip(display);

RTFM

**先说明:我对这地方也是一知半解的状态,因为官方文档在这里写的也有点迷糊.....**

前面我们说过(吗?),Wayland协议中,规定了Client<-->Host的通信模型,我们的窗口就是Client,Compositer就是Host。

不难想象,两边应该有这样一个过程:Host和Client都要告诉对面自己支持哪些操作。假如一款支持光追的游戏恰好运行在1080Ti上,但是他不知道这块显卡不支持光追,如果开启就很可能会得到一个SEGFALUT(不过现代API的容错率应该很高)

那现在回头看看这三行代码都干了啥:

struct wl_registry *registry = wl_display_get_registry(display);

输入:之前得到的指向服务器的结构体指针

作用:获取对应服务器支持的所有操作接口(interface),返回一个结构体指针。下面是在我的配置下,Mutter所支持的interfaces:

interface: 'wl_compositor', version: 5, name: 1
interface: 'wl_drm', version: 2, name: 2
interface: 'wl_shm', version: 1, name: 3
interface: 'wl_output', version: 4, name: 4
interface: 'zxdg_output_manager_v1', version: 3, name: 5
interface: 'wl_data_device_manager', version: 3, name: 6
interface: 'zwp_primary_selection_device_manager_v1', version: 1, name: 7
interface: 'wl_subcompositor', version: 1, name: 8
...

但是这个函数的返回值并不是这个列表,这个列表是用另一个程序打印出来的,后面就慢慢清楚了。

wl_registry_add_listener(registry, &registry_listener, NULL);

前面我们获取了服务器所支持的所有操作列表,那下一步,我们应该注册我们所需要的函数。

同样的,在Client--Host这个通信模型中,两边都可以是说话的以及听话的,既然我们现在是编写的Client,所以应该告诉Host:“带我一个”。如果只有一个窗口那告不告诉也无所谓,但是Wayland是针对多窗口设计的协议,因此作为Client要主动一些,和对面建立联系,提出需求。

扯远了哈,回到主代码块上。

在上面的函数里,我们传入了结构体registry_listener的指针,于是跳转到对应结构体:

static const struct wl_registry_listener registry_listener = {
   .global = handle_global,
   .global_remove = handle_global_remove,
};

在这里有一些我没见过的操作:1.变量前的"."是什么?2.handle_***是函数,为啥不加括号?

Google一番之后找到了答案

1.结构体变量前加"."是为了打破结构体赋值时的顺序限制

资料:[GCC文档] (http://gcc.gnu.org/onlinedocs/gcc/Designated-Inits.html)

2.函数后不加括号,相当于只告诉程序:“早上好程序,现在我有这个函数,我很喜欢这个函数”,而不去调用它。

毕竟函数的开头“int/void/... function()”,如果去掉括号,那不就能用对应类型的指针去指向它了吗,所以就成了函数指针。

既然我们能把函数变成指针,肯定也能以指针的形式调用,下面是个例子

int max(int a, int b)//具体实现就不写了
int (*res)(int , int) = max;//max函数转化为指针,名为res
max_val = (*res)(2,5);//调用函数指针的方法

总之,函数指针还有很多的变化,甚至可以实现调用同一个函数,但是对应原型却完全不同的操作。

资料:[菜鸟教程---函数指针] (https://www.runoob.com/cprogramming/c-fun-pointer-callback.html)

对于第二点还是没能搞清楚究竟为啥这样写,还得更深入一些。所以跳转到头文件里

//wayland-client-protocol.h

void (*global)(void *data,

          struct wl_registry *wl_registry,
          uint32_t name,
          const char *interface,
          uint32_t version);
//wayland-client-protocol.h

//main.c
static void handle_global(void *data, struct wl_registry *registry,
       uint32_t name, const char *interface, uint32_t version) {
   if (strcmp(interface, wl_shm_interface.name) == 0) {
       shm = wl_registry_bind(registry, name, &wl_shm_interface, 1);
   } else if (strcmp(interface, wl_seat_interface.name) == 0) {
       struct wl_seat *seat =
           wl_registry_bind(registry, name, &wl_seat_interface, 1);
       wl_seat_add_listener(seat, &seat_listener, NULL);
   } else if (strcmp(interface, wl_compositor_interface.name) == 0) {
       compositor = wl_registry_bind(registry, name,
           &wl_compositor_interface, 1);
   } else if (strcmp(interface, xdg_wm_base_interface.name) == 0) {
       xdg_wm_base = wl_registry_bind(registry, name, &xdg_wm_base_interface, 1);
   }
}
//main.c

根据上面的说法,这里就是把 handle_global 转化成了一个名字叫 global 的函数指针。不过还没完成捏,请注意global本身不含任何具体实现,所以还得回到 handle_global。

里面用了一系列的if-else语句加strcmp函数,可以猜测合成器内部会发生某种循环匹配。同时如果匹配成功,则会执行对应的绑定注册函数

wl_registry_bind(struct wl_registry *wl_registry,
                                    uint32_t name,
                                    const struct wl_interface *interface,
                                    uint32_t version)

如果你还记得前面的内容,我们成功获取了Host支持的操作的列表wl_registry,既然菜单有了,接下来我们的Client就应该“点菜”,Host为我们“上菜”。

关于绑定,可以在(https://wayland-book.com/registry/binding.html)看到更多。

多个类型相同的操作放在一起成为一组接口(interface),然后为每个Client所需要的interface分配一个编号。因为Host会管理多个窗口,为了区分不同Client所发出的信号,我们会给每个Client的每个interface分配唯一的编号。

现在再回去看那段if-else代码,应该不难理解,我们是“按需取用”。你当然可以将所有interface都做一个绑定,但是真的没啥必要。所以我们使用函数指针,作为一个“钩子”,当且仅当在我们需要时候去执行函数获取对应的数据。

读代码读到现在有很多疑问,最关键还是在我们究竟是如何触发handle_global里的函数的???(要 来 力)

Anyway,以上的这一套操作下来,我们实际上还没有和Host进行过真正的通信,这也就是下面这一行代码要干的:

wl_display_roundtrip(display);

参数:指向对应服务器的结构体指针

作用:开启和服务器之间的通信

**我不懂Wayland在设计的时候有没有这个考量,但从“每一帧都是完美的”设计思想以及我之前在单片机上玩弄U8g2的经历来看,这种“先准备好数据再发送”的操作似乎是每个图形库的基本设计原则。**

然后我本来想在这里插入一些GDB调试画面啥的,但太懒了。

总而言之,在上面的准备工作完成后,开启与Host的通信会发生以下一件事:

遍历wl_registry中的各项条目(各种支持的interface)--->触发handle_global函数,通过*interface传入对应的名称--->进行匹配,如果符合,则为Client绑定相对应的interface,返回对应结构体指针。

如果你用GDB调试的话,就可以看到interface的名字就是上文那个列表的里的名字,从上到下进行遍历

//In case you miss it
interface: 'wl_compositor', version: 5, name: 1
interface: 'wl_drm', version: 2, name: 2
interface: 'wl_shm', version: 1, name: 3
......

(才发现自己漏了一个wl_seat函数,这玩意是用来处理输入事件的,没有他窗口动不了,也没法处理键盘,这里就不展开了,具体见[Seats: Handling input] (https://wayland-book.com/seat.html)。这个Hello程序用它来使得窗口能被鼠标拖动。)

浅浅的讲完了这三行代码。到目前为止,我们完成了以下三件事

1.寻找Host

2.获取Host支持的interfaces

3.注册绑定到自己所需要的interfaces

关于更多interface的信息,可以在[Wayland Protocol Doc] (https://wayland.app/protocols/)找到。

创建数据缓存

经过一个简单的异常处理if语句后,来到了第二个核心部分,给数据创建缓存。

struct wl_buffer *buffer = create_buffer();

create_buffer函数的具体实现如下

   int stride = width * 4;
   int size = stride * height;

前面两行代码是用来计算所需缓冲区大小的,计算公式一般为:长*宽\*单个像素大小\*2(这里省略了,只有开启双缓冲后需要两倍内存),单位byte。

像素大小根据颜色来定,下面的宏**WL_SHM_FORMAT_ARGB8888**指定了颜色格式,名字很容易看出来,A、R、G、B四个通道,每个通道8bit,那我们需要32bits=4byte的空间保存每个像素。

int fd = create_shm_file(size);

经典中的经典fd,接触过LinuxAPI编程的人应该都会对这玩意有条件反射。这也就是为什么我推荐去看JYY的操作系统课作为前置知识。

本来是想继续深入create_shm_file函数的,但是懒。所以就粗略解释一下:

首先Unix哲学,一切皆文件。我们想要在内存里面开辟一块区域出来放图片,就需要有这么一个文件,先把位子给占了。

anonymous_shm_open() 结合 randname() 尝试创建一个名为“hello-wayland-(六位随机字符)”的文件,大小就是我们上面计算得出的size。

然后可能比较迷惑的点就是在创建完成后马上执行销毁函数**shm_unlink**。这一开始我也很奇怪,但是读了LinuxAPI文档之后就解惑了

If one or more references to the shared memory object exist when
      the object is unlinked, the name shall be removed before
      shm_unlink() returns, but the removal of the memory object
      contents shall be postponed until all open and map references to
      the shared memory object have been removed.

调用了shm_unlink函数不一定会导致内容立刻消失,已经映射的内存将会继续存在且可以操作(前提是你创建时规定了他可以操作),直到使用它的进程退出。所以现在调用shm_unlink实际上是为程序退出后释放资源做了准备。

现在我们已经成功把茅坑占了,接下来就是为这块内存分配用途

shm_data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
memcpy(shm_data, MagickImage, size);

关于mmap函数,RTFM。这里将图片缓冲区与刚才创建的文件做了一个内存映射,然后把图片数据拷贝进去。

为什么要这样做捏?不能直接写 fd 吗?具体的原因我自己也不大清楚lol,根据查到的资料大概意思就是fd只是一个描述符,能读取但不能直接写(what?)

struct wl_shm_pool *pool = wl_shm_create_pool(shm, fd, size);

创建一个共享内存池,Client往里面存数据,Host从里面读数据

struct wl_buffer *buffer = wl_shm_pool_create_buffer(pool, 0, width, height,
      stride, WL_SHM_FORMAT_ARGB8888);

有了池子还不够,我们还需要告诉对面池子里存了啥玩意(数据的格式),这样才能保证Host按照我们期望的样式读取并显示内容。

这里还有一个新东西:stride,直译过来就是“步幅”“步进”。如果直接 Google 就错了,因为这个词在不同场合有不同意思。根据Wayland文档的解释:

stride is the number of bytes from the beginning of one row to the beginning of the next row
stride是用来指定每行数据大小的数值

提醒Host读数据读多少时候该换行了。这里很明显不能直接用像素数量,因为 Host 读取的单位是字节,每行的字节多少由长度和单位像素大小决定。

wl_shm_pool_destroy(pool);

这个释放资源的函数与 shm_unlink 有异曲同工之处,我们可以提前调用使其进入准备状态,然后在程序退出时函数自动执行剩下的资源释放操作。

一套下来,最终数据的流动方向如下:

图片数据--->Client端--->shm_data--->fd--->Host端--->图形栈--->显示

至此,我们已经将显示数据准备完毕

Wayland 中的 surface 概念

大白话:PS里面的图层+局部修改

为什么要这样做捏,还是为了性能。如果一个画面里只有按钮“x”发生了变化,那我们希望只对这个局部进行渲染。同时显示多窗口画面的时候,我们希望底层的窗口减少刷新频率。再者,当我们程序的刷新率超出显示器帧率时候,我们只能选择性的将帧放出。

不过,现在我们还是回到 HelloWorld 上,看看都干了些啥

surface = wl_compositor_create_surface(compositor);

参数:指向混成器的结构体指针

作用:在对应混成器中创建一个表面,并返回该表面的指针

这个表面就是我们的画布(显示器),所有的画面最终都会画到上面,大小应该就是对应显示器的分辨率(瞎猜的)

有了画布也不够啊,咋画捏?直接画全屏?别急,Wayland 有一堆扩展的协议,帮助我们在画布上创建合适的窗口。

最最常用的窗口协议扩展就是 xdg shell。根据官方文档,xdg shell解决最基本的窗口问题,比如最大最小化,隐藏等。

另外,这也是 Wayland 美妙的地方之一,只需要使用 Core 部分的底层函数(开头wl的函数),我们就可以在此基础上抽象出更加方便快捷的扩展协议。 xdg 就是这样做出来的

从这个部分开始,文档全部参考[Wayland Protocol Doc] (https://wayland.app/protocols/)和[Wayland Official Doc] (https://wayland.freedesktop.org/docs/html/)

struct xdg_surface *xdg_surface =
   xdg_wm_base_get_xdg_surface(xdg_wm_base, surface);
xdg_toplevel = xdg_surface_get_toplevel(xdg_surface);

要初始化 xdg shell,我们要创建一个 xdg surface,没错,画布里面还有画布捏。只有这样才更好操作(实际上很多UI设计工具也遵循了这一设计思想)

之后的 xdg_surface_get_toplevel 将窗口的层级状态赋给xdg_toplevel指针,大白话就是:用一个指针来储存这个窗口是否在顶层/可显示/可操作的状态。

xdg_surface_add_listener(xdg_surface, &xdg_surface_listener, NULL);
xdg_toplevel_add_listener(xdg_toplevel, &xdg_toplevel_listener, NULL);

两个函数的原型都是:

wl_proxy_add_listener((struct wl_proxy *) xdg_toplevel,
                    (void (**)(void)) listener, data);

这时候又要引出Wayland协议中的另一个概念---代理。

(emmm,写到这里心态已经有点崩了,咋这么复杂捏???)

根据官方的文档,Proxy是将高级信号转化为低级别的Wayland线信号,也就是一大堆16进制的数据。这一步是必要的,但通常就是随手一写就行了。更具体的解释在[Wire protocol basics] (https://wayland-book.com/protocol-design/wire-protocol.html)

这里我们真正需要关注的是“add_listener”这个操作,这一步对于我们的窗口能够响应操作非常重要,从这个直白的名字也能看出,就是添加侦听器,来侦听我们对窗口发出的信号。

于是上面两段代码连起来的效果就是:

1.我们先用 xdg_wm_base_get_xdg_surface(); 和 xdg_surface_get_toplevel(); 两个函数,将窗口的两个基本属性所对应的指针获取

2.然后用 xdg_surface_add_listener();xdg_toplevel_add_listener();给对应的指针绑定到对应的侦听器,这样我们就可以监测窗口属性的变化。

不过,别急,和之前的 wl_registry_add_listener() 一样,我们这里并不包含具体实现,真正的原型在 xdg_surface_listener 和 xdg_toplevel_listener 里。

现在来看一下两个函数对应的原型,首先是第一个:

static void xdg_surface_handle_configure(void *data,
       struct xdg_surface *xdg_surface, uint32_t serial) {
   xdg_surface_ack_configure(xdg_surface, serial);
   wl_surface_commit(surface);
}

static const struct xdg_surface_listener xdg_surface_listener = {
   .configure = xdg_surface_handle_configure,
};

这里我们将 xdg_surface_listener 这个钩子挂上 xdg_surface_handle_configure 函数实现,每次触发时都会执行两个函数:

xdg_surface_ack_configure();
wl_surface_commit();

xdg_surface_ack_configure() 必须在wl_surface_commit之前的某个时间点被执行一次,idk why。

wl_surface_commit() 从名字大概可以看出,这东西就是更新表面时用到的。根据文档,此操作会让混成器立刻执行更新动作。但是这里我的解释十分甚至九分的不精确,还是看自己看看[文档](https://wayland.freedesktop.org/docs/html/apa.html#protocol-spec-wl_surface)罢

xdg_toplevel_listener() 里面的具体实现就不写了,在这里就一个作用,监测窗口是否被关闭,如果被关闭则设变量running为false


等等,我们的窗口还没关闭按钮,咋搞?和游戏一样呗,我们的窗口没有不代表别的地方没有啊。我用的GNOME,只要退到全局窗口视图,就可以看见关闭按钮了。如果要实现我们自己的关闭按钮也是很简单的(这里就不搞了)。


一个比较有趣的地方在于,有的参数不允许留空指针(不挂函数),像是我们没提到的输入处理。这里程序用了个名字叫noop()的空函数代替。

static void noop() {
   // This space intentionally left blank
}

static const struct wl_pointer_listener pointer_listener = {
   .enter = noop,
   .leave = noop,
   .motion = noop,
   .button = pointer_handle_button,
   .axis = noop,

};

从中也可以看出,如果我们要针对鼠标的动作/进入/离开挂上对应的函数也是比较简单的(大概)

噢草,终于要接近尾声了,我们又执行了一次提交,以及和服务器通信

wl_surface_commit(surface);
wl_display_roundtrip(display);

到现在,我们又做了以下的事情:

1.把要显示的数据准备好

2.创建了表面(画布),输入事件

3.将需要监测的数据加上侦听器

所以到现在我们还缺啥?有构思(数据)和画布(表面),现在应该动手画画了

wl_surface_attach(surface, buffer, 0, 0);
wl_surface_commit(surface);

wl_surface_attach,将缓冲区内容转移到表面上,然后提交变化。注意,自wl_surface的第五个版本起,wl_surface_attach后面的xy参数只能设置为0,其他值无效。

while (wl_display_dispatch(display) != -1 && running) {
   // This space intentionally left blank
}

wl_display_dispatch().....这啥玩意啊,貌似是检测窗口事件数量的?查了一圈没找到很清楚的解释lol

写到这里,我突然意识到一件事:我们上面写的那么多东西,会不会也是一种编程?用“Wayland语言”在给我们的混成器下达指令。

总之最后我们的程序就停在了这个死循环里面,well,虽然我们的程序是死循环,但是混成器可不是,接下来所有的事件都会在混成器里面处理。我们依然可以愉快的拖动窗口欣赏可爱的小猫。

但是这个写法是很不好的,很多系统(或者说混成器?)检测到我们的主程序进入死循环一段时间后会弹出无响应的标识(比如GNOME)。正常的用法应该是在这个死循环里面执行我们其他的函数(比如写一个游戏程序?)。为了阻止无响应,我们可以使用官方的参考混成器weston来做实验。

weston启动以后,如果检测到有正在运行的混成器,则它自己会变成一个披着Client皮的Host,直接在现有混成器上显示出一个简单桌面。这个时候会创建第二个host:wayland-1,前面我们提到了连接Wayland服务器的方法,将服务器名称写死wayland-1是个可行的方法,更好的方法是留NULL,然后设置环境变量$WAYLAND_DISPLAY=wayland-1。


Wayland从入门到入土P0---HelloWorld的评论 (共 条)

分享到微博请遵守国家法律