上次在整理 Linux 抓屏方案的时候,顺手把一路踩过的坑都记了下来。今天展开聊聊 XCB 这套路的完整玩法,从环境搭建到核心原理,再到可以直接抄作业的代码,基本覆盖一个抓屏工具从零到能用的全部环节。标题里的 049 是我本地练习 Linux 图形编程的序号,这一篇就是围绕 Linux 平台用 XCB 实现屏幕捕获的实战记录。
很多开发者在 Linux 下做屏幕捕获,第一反应是 FFmpeg 的 x11grab,或者干脆 GNOME 截图工具,真到需要定制帧率、选区、裸数据输出的时候,还是得自己写。自己写,就绕不开 X11 客户端编程,绕不开 XCB 这套库。XCB 是 X Window System 的 C 语言绑定,比老的 Xlib 更轻、更贴近协议本身,写抓屏这类高频访问工具时优势非常明显。这篇文章适合刚接触 XCB、想在 Linux 下做屏幕录制或截图工具的开发者参考,也适合已经在用 Xlib 但想迁移到 XCB 的同行作为对照。
1. 项目背景与整体设计思路
1.1 为什么选 xcb 而不是 Xlib
几年前第一次写抓屏工具,用的是 Xlib 的 XGetImage。那会儿觉得 Xlib 文档多、示例多,抄起来快,但真正跑起来就发现问题了:Xlib 内部有自己的一套缓存和同步机制,调用链一长,偶尔会出现“程序逻辑没问题,但就是抓不到画面”的怪现象。排查到最后才发现,Xlib 把请求分成了同步和异步两类,混用的时候容易踩到序列号错乱的坑。
XCB 的设计思路是“薄”,它几乎就是 X11 协议的一比一映射,每个请求都对应一个 cookie,用xcb_get_image_reply去等服务器的响应,请求和应答的关系非常明确。我换过来之后,最直观的感受是代码的可预测性变高了——发出去的请求和拿回来的数据能对得上,调试起来省了很多力气。
XCB 另一个天然优势是字节序可控。X11 服务器运行在不同架构的机器上,Xlib 会替你悄悄做转换,但一旦你拿到的是裸像素数据,不知道服务器的原始字节序,后续做颜色通道拆解就会非常痛苦。XCB 保留了协议层的原始信息,反而更透明,你可以根据屏幕的root深度和服务器配置自己决定怎么解析。
1.2 抓屏路径的全貌拆解
把抓屏这件事拆开来看,其实就是四个步骤:建立到 X server 的连接、确定要抓的屏幕和区域、向服务器请求该区域的像素数据、把返回的裸数据按像素格式解码并保存或编码。每一步都有它自己的坑,但整体思路是直的。
X server 并不区分“窗口内容”和“屏幕内容”,它只认窗口 ID 和坐标矩形。你向 root 窗口请求一块区域,拿到的就是屏幕上这块区域当前的真实内容。如果向一个普通窗口请求,在某些窗口管理器组合下可能拿到的是空内容或过时内容,因为窗口不一定被映射、可能被遮挡,甚至可能被渲染到离屏缓冲区里。所以抓屏的稳妥方案永远是抓 root 窗口,它代表整个屏幕的合成结果。
数据返回格式上,绝大多数现代 X server 会返回 ZPixmap 格式,也就是“每个像素按固定位数排列”的位图。拿到手之后到底是不是 RGB 顺序、要不要做通道重排、alpha 通道怎么处理,完全取决于服务器的 depth 和本地字节序。这就是为什么 XCB 版本的核心逻辑拆成“获取连接-拿到屏幕参数-发请求-解析数据”四段,而不是把整个抓屏塞成一个函数。把主流程拆清,后面的优化和排错都会顺很多。
2. 环境准备与最小工程搭建
2.1 依赖库的选择与安装
基础依赖其实只有两个东西:XCB 主库和它的 image 辅助库。image 库不是必需的,但它提供了xcb_image_create_native这类便捷函数,能帮你把裸数据包装成易于处理的图像对象。我做基础版本时只用了主库,后来要处理格式转换才引入 image 库,建议从一开始就装上,避免后面来回折腾。
Debian 系系统安装命令如下:
sudo apt install libxcb1-dev libxcb-image0-dev x11-utilsRed Hat 系对应的是:
sudo dnf install libxcb-devel xcb-util-image-devel x11-utilsx11-utils 里的xdpyinfo是排错神器,后面会用到。装完之后可以用一个非常简单的方式验证环境是否就绪:编译一个什么都干不了的 XCB 连接程序,只要能成功连接再断开,工具链就基本没问题了。
2.2 验证环境与工程骨架
我先拿xdpyinfo看了下当前环境的分辨率和深度,确保 X server 正常可访问:
xdpyinfo | grep dimensions输出类似dimensions: 1920x1080 pixels (508x286毫米),这说明 X server 正常。屏幕上跑的会话也必须是 X11 会话,Wayland 下这套方案不算完全失效但会走 XWayland 兼容层,行为会有差异。
最小工程骨架就是一个 C 文件加一个编译命令,不引入构建系统,先跑通主流程:
gcc -o snap snap.c -lxcb -lxcb-image下面是最简连接代码:
#include <stdio.h> #include <xcb/xcb.h> int main(void) { // 第二个参数传 NULL,让库自己读 DISPLAY 环境变量 xcb_connection_t *conn = xcb_connect(NULL, NULL); int err = xcb_connection_has_error(conn); if (err) { fprintf(stderr, "连接 X server 失败,错误码: %d\n", err); return 1; } fprintf(stdout, "连接成功\n"); xcb_disconnect(conn); return 0; }编译运行通过后,整个开发环境就没问题了。我习惯三步走:先证明能连上,再拿到屏幕参数,最后才发抓屏请求。每一步的报错都能准确定位到问题域,不会出现“代码写了一堆却不知道哪里断”的情况。
3. 核心原理:xcb_get_image 背后的数据流
3.1 X11 抓屏的真实机制
很多人以为抓屏是“从显卡读像素”,但实际上在 X11 架构下,客户端进程是向 X server 发一个矩形区域的请求,真正读显存或读合成器输出的是 X server 自己。这个请求就是xcb_get_image。
xcb_get_image_cookie_t xcb_get_image( xcb_connection_t *conn, uint8_t format, // 取 XCB_IMAGE_FORMAT_Z_PIXMAP xcb_drawable_t drawable, // 一般传 screen->root int16_t x, int16_t y, // 起点坐标 uint16_t width, uint16_t height, // 矩形宽高 uint32_t plane_mask // 通常填 0xffffffff );这里最容犯错的是drawable参数。虽然名字叫 drawable,但它既可以传窗口也可以传 pixmap,传 root 窗口才能拿到全屏幕的合成结果。传普通窗口时,如果窗口在别的屏幕或者被完全遮挡,返回的数据经常是空的或者错乱的。我第一版就栽在这上面。
plane_mask填0xffffffff,意思是“我全都要”,即返回所有颜色位平面。这个参数在黑白屏时代还有用,现在大家基本都只关心彩色数据,直接全填。
format填XCB_IMAGE_FORMAT_Z_PIXMAP,表示像素按连续位排列,这样服务器返回的数据就是一块连续内存,后续解析也方便。如果填 XYBitmap 之类,它返回的是按位平面分开的数据,处理起来非常绕。
服务器返回的结构体是xcb_get_image_reply_t,核心字段就两个:depth表示像素深度,还有一个data字段跟着整个结构体走,需要用xcb_get_image_data宏来取。很多人直接把reply强转然后取data成员,这在小端机器上碰巧能跑,换到服务器字节序不同的环境就崩了,所以建议永远用宏函数。
下面是我调试时打印的现场信息:
xcb_generic_error_t *xcb_error = NULL; xcb_get_image_reply_t *reply = xcb_get_image_reply(conn, cookie, &xcb_error); if (xcb_error) { fprintf(stderr, "get_image 失败, 错误码: %d\n", xcb_error->error_code); free(xcb_error); return 1; } printf("depth=%d, width=%d, height=%d, length=%d\n", reply->depth, reply->width, reply->height, xcb_get_image_data_length(reply));在正常 32 位色深的 X server 上,depth 是 24,但服务器返回的每个像素实际占 32 位(4 字节),多出来的一个字节是 padding。这个细节直接引出下一小节的问题。
3.2 像素格式与字节序处理
X server 返回 ZPixmap 数据时,每个像素的排列并不统一。在我测过的多数 Intel 和 AMD 显卡驱动的 X server 上,实际返回的是 BGRA 排列:第一个字节是 Blue,第二个是 Green,第三个是 Red,第四个是 alpha 或固定值 255。这个顺序和 PNG 的 RGB 完全反着来,不转换直接保存,图片会偏蓝偏红,肉眼一眼就看出来。
处理方法有两种:一种是在保存前把 B 和 R 两个通道互换,另一种是用 XCB image 辅助库的xcb_image_create_native,让它自己去匹配服务器格式。我推荐后一种,因为你要处理的不只是颜色通道顺序,还有扫描线的对齐问题。
X11 要求每一行扫描线的起始地址按 32 位对齐,有些分辨率的高宽换算出来不是 4 的倍数,就会有 padding 字节。如果直接按width * 4一行的方式来读,后面的行全部偏移,图像直接花掉。xcb_image_create_native能返回正确的stride,你按stride来跳行,就永远不会错。
xcb_image_t *img = xcb_image_create_native(conn, reply, 0); uint8_t *data = img->data; int stride = img->stride; int bpp = img->bpp;拿到img之后,你的坐标换算公式就变成了:
int offset = y * stride + x * (bpp / 8); unsigned char r = data[offset + 2]; unsigned char g = data[offset + 1]; unsigned char b = data[offset];bpp可能是 24 也可能是 32,取决于服务器配置。24 的时候单像素 3 字节,32 的时候 4 字节,这也是能用stride而不是简单乘 3 或乘 4 的原因。
4. 从零实现:完整抓屏代码与优化版本
4.1 最简版本:抓取全屏并保存原始像素
先给一个不依赖任何图像库的最小实现,功能就是抓全屏并保存成 raw 文件。raw 文件不直接肉眼可视,但能用来验证像素数据是否真的拿到,是后续所有功能的地基。
#include <stdio.h> #include <stdlib.h> #include <xcb/xcb.h> int main(int argc, char *argv[]) { xcb_connection_t *conn = xcb_connect(NULL, NULL); if (xcb_connection_has_error(conn)) { fprintf(stderr, "无法连接 X server\n"); return 1; } const xcb_setup_t *setup = xcb_get_setup(conn); xcb_screen_iterator_t iter = xcb_setup_roots_iterator(setup); xcb_screen_t *screen = iter.data; xcb_get_image_cookie_t cookie = xcb_get_image( conn, XCB_IMAGE_FORMAT_Z_PIXMAP, screen->root, 0, 0, screen->width_in_pixels, screen->height_in_pixels, 0xffffffff); xcb_get_image_reply_t *reply = xcb_get_image_reply(conn, cookie, NULL); if (!reply) { fprintf(stderr, "get_image 失败\n"); xcb_disconnect(conn); return 1; } uint8_t *data = xcb_get_image_data(reply); int length = xcb_get_image_data_length(reply); FILE *fp = fopen("snapshot.raw", "wb"); if (!fp) { perror("fopen"); free(reply); xcb_disconnect(conn); return 1; } fwrite(data, 1, length, fp); fclose(fp); fprintf(stdout, "抓取 %ux%u, depth=%u, 数据长度=%d, 已保存 snapshot.raw\n", screen->width_in_pixels, screen->height_in_pixels, reply->depth, length); free(reply); xcb_disconnect(conn); return 0; }编译运行后,snapshot.raw就是一块裸数据。想快速确认数据对不对,可以用 ImageMagick 看一下:
identify -size 1920x1080 -depth 8 rgba:snapshot.raw如果屏幕是 1920x1080,这条命令能直接把 raw 数据当 RGBA 图识别出来。颜色不对也没关系,主要是确认图片尺寸和内容轮廓是对的,证明抓屏主链路已经打通。
4.2 升级:shm 共享内存加速
xcb_get_image有个性能问题:每次请求,服务器都要把像素数据从自己的地址空间复制到传输缓冲区,再经 socket 发送到客户端,客户端再拷进自己的内存。如果只是截图还好,要做 30fps 的视频录制,这套拷贝链路会把 CPU 吃得很难看。
X11 提供了一个共享内存扩展 SHM,把一块内存同时映射到 X server 和客户端进程的地址空间。服务器直接把像素写进共享内存,客户端直接从共享内存读,省掉两次跨越进程边界的拷贝。XCB 对 SHM 的封装是xcb_shm_get_image。
先检查扩展是否可用:
xcb_shm_query_version_cookie_t vc = xcb_shm_query_version(conn); xcb_shm_query_version_reply_t *vreply = xcb_shm_query_version_reply(conn, vc, NULL); if (!vreply) { fprintf(stderr, "SHM 扩展不可用\n"); return 1; }然后创建共享内存段并 attach:
#include <sys/ipc.h> #include <sys/shm.h> int shmid = shmget(IPC_PRIVATE, buf_size, IPC_CREAT | 0600); void *shmaddr = shmat(shmid, NULL, 0); xcb_shm_seg_t segment = 0; xcb_shm_attach(conn, segment, shmid, 0); // 把 shmid 对应的共享内存 attach 到 X serverattach 完成后就可以发抓屏请求:
xcb_shm_get_image_cookie_t sc = xcb_shm_get_image( conn, screen->root, 0, 0, screen->width_in_pixels, screen->height_in_pixels, 0xffffffff, XCB_IMAGE_FORMAT_Z_PIXMAP, segment, 0); xcb_shm_get_image_reply_t *sreply = xcb_shm_get_image_reply(conn, sc, NULL); // 此时像素数据已经在 shmaddr 里请求完成后记得 detach:
xcb_shm_detach(conn, segment); shmdt(shmaddr); shmctl(shmid, IPC_RMID, NULL);我用一套 1920x1080 的连续抓帧测试对比过,常规 get_image 大约每帧 8ms 到 15ms,shm 之后稳定在 2ms 到 4ms。对于截图工具这点差距无所谓,但做监控或录屏应用,shm 带来的提升是体感级别的。
4.3 落地:保存 PNG 并支持区域抓取
raw 数据毕竟不能直接用,要落地成 PNG 得引入一个编码器。不想引入 FFmpeg 之类重依赖的话,强烈推荐 lodepng。它是单个源文件加单个头文件的纯 C 库,把解码、编码、滤波、压缩全封装好了,编译时不依赖任何外部库,非常适合这种工具型项目。
先下载 lodepng 的两个文件放进工程目录,然后写一个核心转换函数,把 XCB 拿到的数据转成 lodepng 要的 RGBA8 格式:
#include "lodepng.h" static unsigned char *xcb_bgra_to_rgba(uint8_t *data, int width, int height, int stride) { unsigned char *rgba = (unsigned char *)malloc(width * height * 4); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { size_t src = y * stride + x * 4; size_t dst = (y * width + x) * 4; rgba[dst + 0] = data[src + 2]; // R rgba[dst + 1] = data[src + 1]; // G rgba[dst + 2] = data[src + 0]; // B rgba[dst + 3] = 255; // A } } return rgba; }然后调用 lodepng 输出:
unsigned char *rgba = xcb_bgra_to_rgba(data, width, height, stride); lodepng_encode32_file("snapshot.png", rgba, width, height); free(rgba);如果需要区域抓取,很简单,把请求里的x, y, width, height从屏幕完整尺寸改成用户传入的目标区域即可。但有一个隐蔽问题:多显示器环境下,副屏的坐标可能是负数。如果你直接取x < 0的区域,有些 X server 会拒绝或返回空数据。稳妥做法是先把目标区域和屏幕矩形求交集,然后再发请求。我用一个简单函数做过裁剪:
typedef struct { int x, y, w, h; } Rect; Rect clip_rect(Rect target, Rect screen) { int x1 = target.x > screen.x ? target.x : screen.x; int y1 = target.y > screen.y ? target.y : screen.y; int x2 = (target.x + target.w) < (screen.x + screen.w) ? (target.x + target.w) : (screen.x + screen.w); int y2 = (target.y + target.h) < (screen.y + screen.h) ? (target.y + target.h) : (screen.y + screen.h); Rect r = {x1, y1, x2 - x1, y2 - y1}; return r; }整个工具到这一步,已经是完整的“截取区域-处理像素-输出 PNG”闭环,能应对日常绝大多数需求。
5. 常见问题与避坑实录
5.1 高频问题速查表
把实操中踩过的以及朋友问得最多的问题汇总成一张表,方便直接查。
| 现象 | 直接原因 | 解决方案 |
|---|---|---|
| 返回空图像或全黑 | 请求窗口 ID 不是 root,或窗口未映射 | 抓 root 窗口,而不是某个应用窗口 |
| 颜色偏红偏蓝 | 通道顺序没转换 | 按本文思路交换 R/B 通道 |
| 图像下半部分花掉 | 扫描线 stride 没对齐 | 用xcb_image_create_native拿 stride |
| 抓不到副屏区域 | 多显示器坐标为负 | 先 clip 求交集,再发请求 |
| get_image 卡几秒后报错 | 权限不足,Xauthority 无效 | 检查 DISPLAY 和 XAUTHORITY 环境变量 |
| Wayland 会话下能连但抓黑屏 | 走了 XWayland 兼容层 | 改走 PipeWire 或换 Wayland 原生协议 |
| 连续抓帧 CPU 飙高 | socket 拷贝开销太大 | 改用 shm_get_image |
表中每一行都是我实际遇到或实测过的,不是拍脑袋总结。最坑的是第一行,因为报错并不会很显眼,服务器可能直接返回一张内容为空的图,让你误以为是渲染问题。
5.2 深度排查思路与独家技巧
遇到抓屏黑屏,如果确认请求的是 root,那问题八成出在连接层。xcb_connect(NULL, NULL)里的第二个 NULL 是让库自动读DISPLAY环境变量。如果你是通过 SSH 或者 systemd 服务去跑抓屏程序,DISPLAY 可能是空的,甚至 Xauthority 根本没带上,服务器直接拒绝授权。验证命令:
echo $DISPLAY xauth list如果输出为空或者缺少访问认证记录,加上参数重新连接:
xcb_connection_t *conn = xcb_connect(":0", NULL);这种情况在桌面环境本地跑很少见,但放到任务调度或远程触发时几乎是必踩。另一个技巧是开启 X server 的扩展错误日志,很多桌面发行版默认不打印协议错误,你加一行环境变量可以把它打开:
export LIBXCB_ALLOW_SLOPPY_LOCK=1这只解决部分兼容性问题。真正的排查归宿还是回到xcb_generic_error_t的错误码,打印出来对照 X11 协议错误定义表看。error_code为 3 是 BadWindow,8 是 BadMatch,10 是 BadAccess,看到 BadAccess 基本可以确定是授权问题。
我自己用得最多的一个调试手段,是先用xwd这个自带工具对照抓一次。xwd -root -out test.xwd能立刻告诉你这台机器的 X server 是否支持该区域抓取,如果 xwd 也黑,那问题出在环境而不是你的代码,能省下一堆时间。
还有一个性能向的独家技巧:如果要做固定帧率录制,不要每次抓屏都重新 attach 共享内存段。把共享内存段一次性 attach 好,录制循环里只反复发xcb_shm_get_image请求并轮询回复,实测帧率能从 25fps 提升到接近 50fps,差别非常大。
写在日常里的几点体会
做抓屏工具这几年最大的感受是:X11 的坑不是“能不能抓到”,而是“抓到的对不对”。通道顺序、stride 对齐、权限上下文、多显示器坐标,任何一个点出错,输出的图都可能是错的,但代码却不报错。XCB 的好处就是它把决定权还给开发者,你对自己发的每一个请求、收到的每一段数据都清清楚楚,代价是你得自己理解像素格式和协议细节。
如果想把这套工具继续扩展,下一步自然的方向是录屏。把 shm 拿到的 RGBA 帧直接送进编码器,比如 FFmpeg 的 libx264,就是一条低成本录制链路。或者再进一步,做成一个常驻后台的定时截图服务,把区域、间隔、输出格式都做成命令行参数,就是一个完整的桌面监控小工具。至少对我自己而言,掌握 XCB 抓屏这一个点之后,很多看似复杂的桌面自动化需求都慢慢打开了思路。