2026/10/17 3:51:48

基于xcb的Linux屏幕抓取实现:从X11协议到像素数据的完整解析

基于xcb的Linux屏幕抓取实现:从X11协议到像素数据的完整解析 作为一直在捣鼓 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_ttypedef 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就是根窗口的 IDwidth_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_mask0xffffffff 表示所有位平面 );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/Ubuntulibxcb1-devFedora/RHELlibxcb-develArchlibxcb另外如果想省事把图片转成 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_connectint 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; }按默认规则如果环境变量没有指定DISPLAYxcb_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. 核心代码实现抓取屏幕并输出 PPM4.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 字节假定 // 实践中如果 depth24 且 bpp32则每个像素位于 (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找到匹配 depth24 的 visual然后使用vis-bits_per_pixel作为 bpp并用 mask 进行提取。这样基本能适配各种硬件。4.3 如何变成 PNGPPM 格式太原始不能直接分享。最简单的转换方式是让它通过 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 图形栈更踏实的认知。