☰
基于xcb的Linux屏幕抓取实现:从X11协议到像素数据的完整解析
2026/10/17 3:51:40 网站建设 项目流程

作为一直在捣鼓 Linux 桌面工具的人,屏幕抓取算是我反复绕不开的一个需求。写远程控制、录屏脚本、甚至给状态栏做个缩略图,都得把屏幕上正在渲染的内容拿到手。网上现成的工具不少,但真到自己想做个轻量级小工具、只想依赖最少的环境、还希望尽量可控时,我发现最底层、最值得折腾的路径之一,就是直接通过 X11 协议家族里的 xcb 库来抓屏。

这篇就把我基于 xcb 实现 Linux 屏幕抓取的过程完整拆开讲。你会看到为什么不用老掉牙的 XGetImage,也会搞清楚 xcb_get_image 返回的那堆字节到底什么格式,最后还有一份可以直接拿去用的 C 代码,抓完屏直接输出成图片文件。比较适合已经会写一点 C、想深入了解 X11 图形栈,或者正在做桌面小工具、远程同步方案的同学。

1. 项目概述与抓屏方案选型

1.1 抓屏到底要解决什么问题

在 Linux 下抓屏,本质上就是向 X 服务器或者 Wayland 合成器要一块像素数据。我们日常看到的桌面窗口、壁纸、光标,都是经过合成器管理的一层一层的画面。抓屏的需求通常分为两类:

一种是想抓整个屏幕或者某个窗口,用于分享、录屏、远程控制,这种需要连续、稳定的帧数据,对性能要求高,最好走共享内存或者 DMA-BUF 之类的零拷贝路线。

另一种是抓一次静态图,比如给窗口缩略图、调试图形渲染输出、做像素级自动化测试。这种只需要调用一次抓取接口,拿到内存里的数据再处理即可。

我这次的场景是第二种,但又不希望引入太重的框架。Qt、GTK 都自带抓屏 API,但为了一个不到一千行的小工具去装整个 GUI 库,不值得。于是目光自然落到 X11 的底层库上。

1.2 为什么最终选了 xcb

在 X11 的世界里,抓屏传统上有几条路:

  • xwd是古老的 X 窗口转储工具,命令行可以直接抓取窗口,输出为 xwd 格式。
  • XGetImage是 Xlib 提供的函数,网络上大量老代码都用它。
  • imlib2、gdk-pixbuf等图像库封装了抓屏操作,但带着一堆图像格式依赖。
  • xcb_get_image是 xcb 库对应的底层协议调用。

我最后选 xcb,核心原因是它足够底、足够轻。Xlib 本质上是一层比较老土的同步 C API,内部有大量隐式状态,多线程场景下还得小心应对。xcb 则直接对 X11 协议做了一比一的封装,链路上是异步的,连接句柄、请求、应答的关系更清晰,比较适合我这种想精确控制每个字节的人。

另一个原因是 xcb 在现代 Linux 生态中依然活跃,很多新工具都基于它。虽然 X11 正在被 Wayland 替代,但在 X11 会话里,xcb 依然是最常见、最可靠的接口。对于抓屏这个功能,直接走协议层可以清楚看到每次请求返回了什么,也好排查问题。

1.3 不同方案的性能与复杂度对比

方案依赖性能适合场景
xwd 命令行x11-apps低一次性手动抓取
Xlib XGetImagelibX11中老项目维护
xcb_get_imagelibxcb中高轻量工具、学习协议
XShm + xcb_shmlibxcb-shm高连续帧、录屏
PipeWire / wlrootsWayland 协议极高Wayland 原生抓屏

如果只是抓一张静态图,xcb_get_image 完全够用。如果要录视频,我会优先考虑 XShm,绕过数据拷贝的瓶颈。我的方案在后续扩展时也可以无缝往 XShm 方向迁移,因为 xcb 和 xcb_shm 的接口风格一致。

2. xcb抓屏的核心原理与数据结构

2.1 X11 协议里“屏幕”长什么样

X11 采用的是客户端-服务器模型。你的应用是客户端,真正在管理显示器、绘制窗口的是 X 服务器。抓屏就是从服务器内存里读取已合成的像素图。

在 X11 协议中,“屏幕”是一个逻辑上的会话。一个 X 服务器可以管理多个屏幕,但绝大多数桌面只有一个屏幕,也就是screen 0。连接建立后,可以从 setup 信息里拿到根窗口、屏幕尺寸、颜色深度等信息。根窗口是屏幕上所有窗口的祖先,它覆盖了整个可见区域,因此抓根窗口就等于抓全屏。

相关数据结构中,最关键的是xcb_screen_t:

typedef struct xcb_screen_t { xcb_window_t root; xcb_colormap_t default_colormap; uint32_t white_black; uint32_t current_input_masks; uint16_t width_in_pixels; uint16_t height_in_pixels; uint16_t width_in_millimeters; uint16_t height_in_millimeters; uint16_t allowed_depths_len; xcb_depth_iterator_t allowed_depths; } xcb_screen_t;

其中root就是根窗口的 ID,width_in_pixels和height_in_pixels是屏幕像素尺寸。注意,在 Xinerama 或者双屏虚拟桌面环境下,根窗口尺寸可能小于所有显示器的物理区域,这点后面说坑的时候再展开。

2.2 xcb_get_image 到底干了什么

抓屏的核心协议请求是GetImage。对应到 xcb 中,一次典型的调用长这样:

xcb_get_image_cookie_t cookie = xcb_get_image( conn, // xcb_connection_t* XCB_IMAGE_FORMAT_Z_PIXMAP, drawable, // 通常是 root 窗口 x, // 左上角 X 坐标 y, // 左上角 Y 坐标 width, // 抓取宽度 height, // 抓取高度 ~0 // plane_mask,0xffffffff 表示所有位平面 );

xcb 是异步设计,调用xcb_get_image只是把请求发出去,立刻返回一个 cookie。随后执行xcb_get_image_reply时会阻塞等待服务端回复,把所有数据拷贝到 reply 结构体里。

好多人以为 xcb_get_image 和 XGetImage 是等价替换,其实不完全一样。Xlib 的 XGetImage 内部封装了对各种图像格式的转换,返回一个 XImage 结构,你可以直接用。xcb 则只忠实返回协议字节流,字节怎么排列、通道怎么存放、有没有填充位,都得自己按屏幕的 visual 信息去解释。这既是 xcb 的“原始感”,也是它的门槛。

2.3 图像数据的格式问题

xcb_get_image 返回的数据,规格取决于抓取对象的 depth 和 visual。最常见的 desktop 是 24 位深度,但底层每个像素不一定只占 3 字节。

实际 X 服务器常用两种包装方式:

  • 每个像素 4 字节,字节序为 0xBB 0xGG 0xRR 0x00,低 24 位有效,高位忽略。这是最常见的 32 位 XRGB 视口。
  • 每个像素 3 字节,严格按字节顺序存储,通道顺序由 visual 的 mask 决定。

有些服务器或较老的硬件还会出现 16 位深度,甚至 8 位伪彩色,但现代 Linux 桌面基本遇不到。

为了避免假设失误,我建议在代码里读取屏幕的root_depth,同时从 setup 中找到 root 窗口对应的 visual,再根据其中的 red_mask、green_mask、blue_mask 来提取通道。抓屏数据在内存里实际上是按像素整数存储的,所以最可靠的方式是先把每像素的字节当成一个整数复制到本地,然后按 mask 做位运算。

此外还有一个重要细节:每一行像素可能会填充到某个边界。xcb 返回的数据块是连续字节流,但一行的字节数不一定刚好等于width * bytes_per_pixel。为了正确换行,不能直接data + row * width * bpp,而要根据data_length / height得到实际的 stride。这一步不处理,图像会斜着切开。

3. 从零搭建 xcb 抓屏环境

3.1 安装依赖和准备工具

要在系统上编译 xcb 程序,需要安装 libxcb 开发包。不同的发行版包名不太一样:

  • Debian/Ubuntu:libxcb1-dev
  • Fedora/RHEL:libxcb-devel
  • Arch:libxcb

另外,如果想省事把图片转成 PNG 看看效果,可以再装一个 ImageMagick 的命令行工具,或者准备 clang/gcc。真正运行时只需要 X11 会话,如果你在 Wayland 会话里跑,xcb 连不上默认的 Xwayland 屏幕,所以最好在 X11 或者 XWayland 兼容环境下测试。

装好依赖后,编译命令非常简单:

gcc capture.c -o capture $(pkg-config --cflags --libs xcb)

pkg-config会帮你处理好头文件路径和链接参数,不要手动写-lX11,那不是 xcb。

3.2 初始化 X 连接并获取根窗口

万事开头都是连接 X 服务器。xcb 的入口是xcb_connect:

int screen_num = 0; xcb_connection_t *conn = xcb_connect(NULL, &screen_num); if (xcb_connection_has_error(conn)) { fprintf(stderr, "cannot connect to X server\n"); return 1; }

按默认规则,如果环境变量没有指定DISPLAY,xcb_connect(NULL, ...)会从环境里读。它通常会连接本机当前的 X 服务器。第二个参数会返回实际使用的屏幕编号,在多屏机器上可能不是 0,所以要保存。

然后用 setup 信息遍历屏幕列表:

const xcb_setup_t *setup = xcb_get_setup(conn); xcb_screen_iterator_t it = xcb_setup_roots_iterator(setup); xcb_screen_t *screen = it.data; // 这里默认屏编号为0

如果屏幕编号不是 0,需要循环移动:

for (int i = 0; i < screen_num; i++) { xcb_screen_next(&it); }

得到screen之后,根窗口就是screen->root,全屏宽高是screen->width_in_pixels和screen->height_in_pixels。

3.3 确认 visual 和 depth

要正确解析图像,需要额外知道 root 窗口的 visual。xcb_screen_t本身有个root_visual字段,但更严谨的方法是直接遍历该 screen 的 depth 列表,找到深度是 24 或 32 的那个,然后找到 visual。不过在多数场景下,桌面默认就是 24 深度、32 位 XRGB,所以可以在代码里做一个保守假设,同时通过root_depth做分支。

我更喜欢写一个辅助函数,根据visual->bits_per_pixel来到设置解析参数。从xcb_setup_roots_iterator得到的 screen 里,allowed_depths是一个迭代器,里面存着各种 depth 和对应的 visuals。

这部分代码比较琐碎,但比盲目写死要稳。下面是提取 24 位深度下第一个 visual 的常见写法:

xcb_depth_iterator_t depth_it = xcb_screen_allowed_depths_iterator(screen); const xcb_visualtype_t *visual = NULL; while (depth_it.rem) { xcb_depth_t *d = depth_it.data; if (d->depth == 24) { xcb_visualtype_iterator_t vis_it = xcb_depth_visuals_iterator(d); visual = vis_it.data; break; } xcb_depth_next(&depth_it); }

拿到 visual 之后,visual->bits_per_pixel通常就是 32,而visual->red_mask/green_mask/blue_mask能用来提取各通道。

4. 核心代码实现:抓取屏幕并输出 PPM

4.1 一行一行拷贝像素

这一段是代码里最容易出问题的地方,值得单独拿出来讲。

xcb_get_image_reply_t中真正有用的字段如下:

typedef struct xcb_get_image_reply_t { uint8_t response_type; uint8_t depth; uint16_t sequence; uint32_t length; xcb_visualid_t visual; uint32_t size; // 图像数据长度(减去头部后) uint8_t pad0[4]; uint8_t data[0]; // 实际字节流,实际访问用 xcb_get_image_data(reply) } xcb_get_image_reply_t;

xcb_get_image_data(reply)是一个宏,等价于reply->data。xcb_get_image_data_length(reply)则返回数据长度。注意 X11 协议规定了回复中图像数据的长度和行长是按 32 位对齐的,所以size不一定等于width * height * bpp。

我处理数据的方式概括成三步:

  1. 计算实际的一行字节数stride = data_length / height。
  2. 根据 visual 的 bits_per_pixel 决定每像素占用字节数bpp。
  3. 逐行遍历每个像素,把字节复制成整数,再按红绿蓝掩码提取通道。

提取通道的 C 代码:

static void pixel_to_rgb(const uint8_t *px, int bpp, const xcb_visualtype_t *vis, uint8_t *r, uint8_t *g, uint8_t *b) { uint32_t val = 0; memcpy(&val, px, bpp); // 小端或大端?实际取决于连接字节序,但X协议会保证时使用相同顺序,这里默认手编译环境 *r = (val & vis->red_mask) >> 16; *g = (val & vis->green_mask) >> 8; *b = (val & vis->blue_mask); }

这段代码有一个隐含假设:三个 mask 分别落在 0xff0000、0x00ff00、0x0000ff。对于常见的 32 位 visual 这正是标准布局。如果遇到非常规 visual(比如 5-6-5 的 16 位色),后续可以扩展成查表方式。蓝绿红顺序会不会反?大家在显示器上看到的通常是红绿蓝,但抓回来的字节在小端机器上从低到高是 B、G、R,所以存储为整数后可以通过 mask 正确取出来,因此无需额外做 BGR 交换。

4.2 完整的抓屏代码骨架

下面是一份简化但完整能用的抓全屏存 PPM 的程序。意图是让大家能直接编译运行,而不是贴一堆不可行的伪代码。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <xcb/xcb.h> static void save_ppm(const char *path, const uint8_t *rgb, int width, int height) { FILE *fp = fopen(path, "wb"); if (!fp) { perror("fopen"); exit(1); } fprintf(fp, "P6\n%d %d\n255\n", width, height); fwrite(rgb, 1, (size_t)width * height * 3, fp); fclose(fp); } int main(int argc, char *argv[]) { const char *outfile = (argc > 1) ? argv[1] : "screen.ppm"; int screen_num = 0; xcb_connection_t *conn = xcb_connect(NULL, &screen_num); if (xcb_connection_has_error(conn)) { fprintf(stderr, "cannot connect to X server\n"); return 1; } const xcb_setup_t *setup = xcb_get_setup(conn); xcb_screen_iterator_t it = xcb_setup_roots_iterator(setup); for (int i = 0; i < screen_num; i++) { xcb_screen_next(&it); } xcb_screen_t *screen = it.data; int width = screen->width_in_pixels; int height = screen->height_in_pixels; xcb_get_image_cookie_t cookie = xcb_get_image( conn, XCB_IMAGE_FORMAT_Z_PIXMAP, screen->root, 0, 0, width, height, ~0 ); xcb_get_image_reply_t *reply = xcb_get_image_reply(conn, cookie, NULL); if (!reply) { fprintf(stderr, "xcb_get_image_reply failed\n"); xcb_disconnect(conn); return 1; } uint8_t *data = xcb_get_image_data(reply); int data_len = xcb_get_image_data_length(reply); int stride = data_len / height; // 为简单,这里从 setup 取 visual 更严谨,但先用 4 字节假定 // 实践中,如果 depth=24 且 bpp=32,则每个像素位于 (row * stride + col * 4) int bpp = 4; const xcb_visualtype_t *vis = NULL; // 这里可以通过深度迭代器获取 visual,篇幅原因不再展开 // 直接按常见 desktop 的24深度/32位视觉来做 // vis->red_mask = 0xff0000, green_mask = 0x00ff00, blue_mask = 0x0000ff uint8_t *rgb = malloc(width * height * 3); for (int row = 0; row < height; row++) { for (int col = 0; col < width; col++) { const uint8_t *px = data + row * stride + col * bpp; uint32_t val = 0; memcpy(&val, px, bpp); rgb[(row * width + col) * 3 + 0] = (val >> 16) & 0xff; rgb[(row * width + col) * 3 + 1] = (val >> 8) & 0xff; rgb[(row * width + col) * 3 + 2] = (val >> 0) & 0xff; } } save_ppm(outfile, rgb, width, height); free(rgb); free(reply); xcb_disconnect(conn); printf("saved to %s (%dx%d)\n", outfile, width, height); return 0; }

这份代码里,我故意没写完整获取 visual 的逻辑,是为了让主干清晰。实际项目中别这么偷懒,因为遇到 16 位色、30 位色时,固定位移会得到错误的颜色。

如果要严格一点,就按前面说的遍历allowed_depths,找到匹配 depth=24 的 visual,然后使用vis->bits_per_pixel作为 bpp,并用 mask 进行提取。这样基本能适配各种硬件。

4.3 如何变成 PNG

PPM 格式太原始,不能直接分享。最简单的转换方式是让它通过 ImageMagick 变成 PNG:

./capture screen.ppm convert screen.ppm screen.png

如果你不想依赖 ImageMagick,另外一个比较轻的办法是在代码里接入单文件库stb_image_write.h,它支持直接写 PNG,整个库只有头文件,不依赖 libpng。把上面得到的rgb缓冲区丢进去:

#include "stb_image_write.h" stbi_write_png("screen.png", width, height, 3, rgb, width * 3);

用stbi_write_png后,把save_ppm换掉即可。注意 PCM 那行里width * 3是步长参数,不能写成 0,否则函数默认对齐策略可能导致图像错位。

5. 实操中的坑与排查技巧

5.1 BadMatch 和几何坐标问题

抓屏过程中最经典的报错是xcb_get_image返回 BadMatch 错误。BadMatch 在 X11 协议里含义很多,但抓屏场景里大多是因为请求的坐标或大小超出 drawable 的边界。

我踩过的例子是:一台双屏机器,左边屏幕在主屏幕左侧,位置坐标是负值,此时根窗口的坐标原点在主屏幕左上角,而物理桌面的虚拟边界包含了负坐标区域。直接抓screen->width_in_pixels宽高的根窗口并不能覆盖整个虚拟桌面,正确做法是通过 XRandR 扩展枚举所有显示器,计算它们的联合边界,再以联合边界的左上角作为抓取起点。

一个简单的规避方法是先抓每个屏幕各自区域,再用程序拼接,但复杂度和延迟都会上升。如果只是抓主屏,那么从 (0,0) 开始抓根窗口尺寸就够了。

5.2 像素格式错乱的处理

如果你抓出来的图有颜色通道颠倒、颜色发蓝、或者整图斜着分裂,多半是下面几个原因:

  • 每像素字节数不对,应该 4 却按 3 解析,或者反过来。
  • stride 计算错误,直接按 width * bpp 换行了。
  • 通道提取 mask 没生效,把 alpha 当成红或蓝。
  • 字节序问题,大端机器上memcpy成整数后必须以相同字节序解析。

排查顺序建议:先用二进制工具查看 PPM 头,确认宽高和文件大小,再检查stride * height == data_len是否成立。然后用一块纯色的窗口做测试,比如全屏一个红色窗口,再抓屏,看输出里像素值是不是 (255,0,0)。如果不是,反推 bpp、stride、mask 哪个出了问题。

5.3 多次抓屏内存管理

xcb_get_image_reply 返回的结构体里,data 字段是跟随 reply 结构体的连续内存,释放reply即可,不要把data单独 free。如果你是在循环里连续抓屏,记得每轮都释放一个 reply,否则内存涨得飞快。

如果发现抓屏偶发卡顿,不要怪 xcb,阻塞式xcb_get_image_reply会等服务器拷贝整个画面。帧率要求高时换 XShm。XShm 的原理是客户端创建一块共享内存,让 X 服务器直接往那块内存里画,省去协议传输。同样是 xcb 风格,通过xcb_shm_attach和xcb_shm_get_image实现。

5.4 Wayland 下的兼容问题

在 Wayland 会话中,普通客户端无法通过 X11 协议访问根窗口,因为根窗口属于 XWayland 的兼容层。这时抓到的要么是受限的 XWayland 窗口,要么直接失败。

如果你需要做 Wayland 原生屏幕捕获,正确方向是 xdg-desktop-portal 的 ScreenCast 接口,或者 PipeWire 协议。这部分和 xcb 就无关了。我的建议是:xcb 抓屏只针对 X11 会话,真要跨协议,最好封装一层,底层分别对接 X11 和 Portal。

5.5 权限和远程会话注意事项

以前在虚拟机里测试时,X 服务器如果运行在安全模式或启用了访问控制,客户端会因为认证失败连不上。这时需要确保 XAUTHORITY 环境变量正确。用xauth list可以检查当前权限。

另外,如果你通过 SSH 登录到另一台机器跑这个程序,且没有开启 X 转发,xcb_connect会失败。因为默认 DISPLAY 没有指向远程服务器。解决方案要么启用 X11Forwarding,要么在本地会话里运行。

6. 实际测试与效果

我找了一台普通的 X11 桌面环境测试,屏幕分辨率是 1920x1080,运行./capture screen.ppm后再转换为 PNG,整个过程耗时大约几十毫秒,尺寸正确,颜色和桌面肉眼看起来基本一致。

因为 xcb 是异步的,我可以把xcb_get_image请求发出去之后,先干别的初始化工作,再等待 reply。如果你有多个屏幕要抓,可以用多个 cookie 并发提交,再统一等待,比串行抓取效率高得多。这也是 xcb 相比 Xlib 一个很不显眼但实用的小优势。

改一点代码,还可以把命令行参数变成窗口 ID,实现抓指定窗口:

xcb_get_image(conn, XCB_IMAGE_FORMAT_Z_PIXMAP, (xcb_window_t)strtoul(argv[1], NULL, 0), 0, 0, width, height, ~0);

但此时 width 和 height 要用窗口的实际尺寸,而不是屏幕尺寸,一般需要额外请求GetGeometry获取。

7. 后续还能怎么扩展

这个抓屏工具的核心已经具备,扩展空间其实很大。

一个方向是给程序加一个-g参数,支持指定矩形区域,这样就能截图某个窗口或屏幕局部。再加上-o file.png参数,直接调用 stb_image_write 写 PNG,就是一个非常趁手的小截图工具。

另一个方向是做定时抓屏,比如每隔一段时间抓一次,用于监控屏幕变化、生成使用记录。结合异步 cookie 和 XShm 后,可以做到稳定小帧率录制。

如果还想继续深入,把抓到的 RGB 数据直接喂给 FFmpeg 的 rawvideo 输入,就能低成本实现一个录屏程序。当然录屏对时序、丢帧、缓存都有更高要求,不是简单循环抓屏就能稳定,那是另一个大坑了。

我个人在实际操作中的体会是:xcb 抓屏的难点从来不在“调用 API”,而在真正理解 X11 的数据表达方式。一旦你搞懂了 visual、depth、stride、mask 这几个概念之间的联系,后面不管换 XShm 还是换 Wayland Portal,都不会再被底层细节绊住。

如果你也准备写一个 Linux 下的抓屏工具,别急着去抄 Qt 的grabWindow,花一个下午时间把xcb_get_image啃明白,你收获的不仅是一个能跑的截图程序,还有对整个 X11 图形栈更踏实的认知。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询