作为一直在捣鼓 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 XGetImage | libX11 | 中 | 老项目维护 |
| xcb_get_image | libxcb | 中高 | 轻量工具、学习协议 |
| XShm + xcb_shm | libxcb-shm | 高 | 连续帧、录屏 |
| PipeWire / wlroots | Wayland 协议 | 极高 | 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。
我处理数据的方式概括成三步:
- 计算实际的一行字节数
stride = data_length / height。 - 根据 visual 的 bits_per_pixel 决定每像素占用字节数
bpp。 - 逐行遍历每个像素,把字节复制成整数,再按红绿蓝掩码提取通道。
提取通道的 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 图形栈更踏实的认知。