当你在客户端复制文本,然后在虚拟机中粘贴时,数据经过了怎样的旅程?文件拖放又如何实现?本文追踪VDAgent通信的完整链路。

VDAgent通信架构
SPICE通过VDAgent(Virtual Desktop Agent)实现主机与虚拟机之间的增强功能:剪贴板共享、文件传输、分辨率调整、鼠标无缝集成等。通信架构涉及三端:客户端、服务端、Guest内的Agent进程。
为什么需要三端架构?
一个自然的问题是:为什么不让客户端直接与Guest Agent通信,而要通过服务端中转?
原因有三:
- 安全控制 :服务端的
AgentMsgFilter可以在中转时过滤消息------管理员可以禁用剪贴板共享或文件传输而不需要修改Guest内的Agent。这对安全敏感环境(如金融、政府)至关重要 - 多客户端支持:多个客户端可以同时连接同一虚拟机,服务端负责将Agent消息路由到正确的客户端,或在多客户端间仲裁(如谁拥有剪贴板)
- 协议复用 :Agent消息通过MainChannel的
SPICE_MSG_MAIN_AGENT_DATA传输,复用已有的SPICE通道基础设施(加密、认证、流控),无需为Agent单独建立通信链路
按需剪贴板(Clipboard by Demand)设计
SPICE的剪贴板不是"复制时立即传输数据",而是按需传输:
- 复制方只发送GRAB("我拥有这些类型的数据"),不发送实际内容
- 粘贴方发送REQUEST("给我text/plain类型的数据")
- 复制方才发送CLIPBOARD(实际数据)
这种设计避免了不必要的数据传输------用户可能复制一张10MB的图片,但从不在另一端粘贴,按需传输就完全不会传这10MB。对于频繁复制但很少跨端粘贴的用户(大多数场景),这可以节省大量带宽。
这与X11剪贴板的Selection机制一致:X11的剪贴板也是"声明所有权+按需提供",SPICE的设计自然地映射到了X11模型。
服务端:RedCharDeviceVDIPort ↔ virtio-serial ↔ Guest Agent
cpp
// server/reds.cpp
// VDAgent设备:RedCharDeviceVDIPort
// 与QEMU的virtio-serial端口对接
// Guest内的spice-vdagent进程通过/dev/virtio-ports/com.redhat.spice.0与主机通信
分析:RedCharDeviceVDIPort是SPICE侧的字符设备实现,负责将Agent消息封装为VDI块,通过virtio-serial传递给QEMU,最终到达Guest内核的字符设备,由spice-vdagent读取。
客户端:MainChannel ↔ SpiceGtkSession ↔ 系统剪贴板
c
// spice-gtk: MainChannel 负责 Agent 消息的收发
// 通过 main-clipboard-selection-grab, main-clipboard-selection-request 等信号
// 与 GTK 的 GtkClipboard 交互
分析:客户端MainChannel接收和发送Agent数据。剪贴板相关信号在收到特定VDAgent消息时触发,由应用层连接到gtk_clipboard API实现与系统剪贴板的同步。
消息格式:VDIChunkHeader + VDAgentMessage + payload
c
// VDI块头
struct VDIChunkHeader {
uint32_t port; // VDP_CLIENT_PORT 或 VDP_SERVER_PORT
uint32_t size; // 后续数据大小
};
// VDAgent消息头
struct VDAgentMessage {
uint32_t protocol; // VD_AGENT_PROTOCOL
uint32_t type; // VD_AGENT_CLIPBOARD_GRAB 等
uint32_t size; // 数据大小
uint64_t opaque; // 不透明数据
uint8_t data[0]; // 消息内容
};
分析:VDIChunkHeader用于设备寻址,VDAgentMessage标识消息类型和载荷。整个结构通过SPICE_MSG_MAIN_AGENT_DATA在MainChannel上传输。
剪贴板:客户端 → Guest
步骤1:系统剪贴板变化
当用户在客户端复制内容时,GTK的剪贴板触发owner-change信号:
c
// spice-gtk: 连接剪贴板信号
g_signal_connect(clipboard, "owner-change",
G_CALLBACK(clipboard_owner_change), channel);
static void clipboard_owner_change(GtkClipboard *clipboard,
GdkEvent *event,
gpointer user_data)
{
SpiceMainChannel *channel = user_data;
SpiceMainChannelPrivate *c = channel->priv;
guint selection = ...; // 根据clipboard确定selection
if (!c->agent_connected)
return;
// 获取剪贴板中的数据类型
gtk_clipboard_request_contents(clipboard, atom_targets,
clipboard_targets_received, channel);
}
分析:owner-change在剪贴板内容变化或所有权变化时触发。request_contents异步获取支持的MIME类型,在回调中构建GRAB消息。
步骤2:客户端发送Grab
获取到剪贴板支持的类型后,发送VD_AGENT_CLIPBOARD_GRAB:
c
// spice-gtk: channel-main.c
void spice_main_channel_clipboard_selection_grab(SpiceMainChannel *channel,
guint selection,
guint32 *types,
int ntypes)
{
SpiceMainChannelPrivate *c = channel->priv;
VDAgentClipboardGrab *grab;
size_t msgsize;
g_return_if_fail(c->agent_connected);
msgsize = sizeof(VDAgentClipboardGrab);
if (test_agent_cap(channel, VD_AGENT_CAP_CLIPBOARD_SELECTION)) {
msgsize += 4;
}
grab = g_malloc0(msgsize);
grab->types[0] = types[0]; // text/plain, text/html 等
// ...
agent_msg_queue_add(channel, VD_AGENT_CLIPBOARD_GRAB, grab, msgsize);
}
分析:agent_msg_queue_add将消息加入队列,受Agent Token流控约束。GRAB仅声明"我拥有这些类型的数据",不携带实际内容,实现按需传输(clipboard by demand)。
步骤3:服务端中转
MainChannelClient收到SPICE_MSGC_MAIN_AGENT_DATA后,交由reds_on_main_agent_data处理:
cpp
// server/main-channel.cpp
case SPICE_MSGC_MAIN_AGENT_DATA:
reds_on_main_agent_data(reds, this, message, size);
break;
cpp
// server/reds.cpp
void reds_on_main_agent_data(RedsState *reds, MainChannelClient *mcc,
const void *message, size_t size)
{
RedCharDeviceVDIPort *dev = reds->agent_dev.get();
AgentMsgFilterResult res;
res = agent_msg_filter_process_data(&dev->priv->write_filter,
static_cast<const uint8_t *>(message), size);
switch (res) {
case AGENT_MSG_FILTER_OK:
break;
case AGENT_MSG_FILTER_DISCARD:
return; // 剪贴板被禁用时丢弃
case AGENT_MSG_FILTER_PROTO_ERROR:
mcc->shutdown();
return;
}
header = reinterpret_cast<VDIChunkHeader *>(dev->priv->recv_from_client_buf->buf);
header->port = VDP_CLIENT_PORT;
header->size = size;
dev->write_buffer_add(dev->priv->recv_from_client_buf);
}
分析:agent_msg_filter根据copy_paste_enabled等配置过滤消息。通过过滤的消息添加VDI块头后写入设备缓冲区,由RedCharDevice的写路径送入virtio-serial,最终到达Guest Agent。
步骤4:Guest请求数据
用户在Guest中执行粘贴时,Guest的spice-vdagent向服务端发送VD_AGENT_CLIPBOARD_REQUEST。服务端从virtio-serial读取后,通过MainChannel转发给客户端:
c
// 服务端从 RedCharDeviceVDIPort 读取
// read_one_msg_from_device() 解析 VDAgentMessage
// 类型为 VD_AGENT_CLIPBOARD_REQUEST 时,通过管道发送给 MainChannelClient
// MainChannelClient 将消息封装为 SPICE_MSG_MAIN_AGENT_DATA 发往客户端
分析:CLIPBOARD_REQUEST的size/opaque等可包含selection、type信息。客户端收到后触发main-clipboard-selection-request信号,应用层需响应并提供数据。
步骤5:客户端提供数据
客户端连接main-clipboard-selection-request信号,在回调中从系统剪贴板读取并发送:
c
// spice-gtk
// 信号连接:
// g_signal_connect(channel, "main-clipboard-selection-request",
// G_CALLBACK(clipboard_selection_request_cb), ...);
static void clipboard_selection_request_cb(SpiceMainChannel *channel,
guint selection,
guint32 type,
gpointer user_data)
{
GtkClipboard *clipboard = gtk_clipboard_get_for_display(..., selection);
gtk_clipboard_request_contents(clipboard, type_to_atom(type),
clipboard_received_cb, channel);
}
static void clipboard_received_cb(GtkClipboard *clipboard,
GtkSelectionData *data,
gpointer user_data)
{
SpiceMainChannel *channel = user_data;
const guchar *buf = gtk_selection_data_get_data(data);
gsize len = gtk_selection_data_get_length(data);
spice_main_channel_clipboard_selection_notify(channel, selection, type, buf, len);
}
分析:request_contents异步获取指定MIME类型的数据。clipboard_selection_notify构建VD_AGENT_CLIPBOARD消息并发送,数据经服务端中转到Guest,完成粘贴。
剪贴板:Guest → 客户端(反向)
类似流程但方向相反
当用户在Guest中复制、在客户端粘贴时,流程相反:
- Guest Agent 发送 VD_AGENT_CLIPBOARD_GRAB
- 服务端 读取后通过 MainChannel 发给客户端
- 客户端 main-clipboard-selection-grab 信号
- 客户端可调用 gtk_clipboard_set_with_data() 声明拥有剪贴板
- 用户在客户端粘贴时,客户端请求剪贴板内容
- 客户端发送 VD_AGENT_CLIPBOARD_REQUEST 经服务端到 Guest
- Guest Agent 提供数据,经服务端中转到客户端
- 客户端 gtk_selection_data_set() 或类似API放入系统剪贴板
分析:双向剪贴板的核心是"请求-响应"模式。拥有方通过GRAB声明,需求方通过REQUEST获取,数据提供方通过CLIPBOARD消息发送实际内容。服务端仅做透明中转。
客户端 gtk_selection_data_set() 放入系统剪贴板
c
// 当收到 Guest 的 CLIPBOARD 数据时
// 客户端调用 gtk_clipboard_set_with_data() 或
// 在 selection-get 回调中 set_selection_data
分析:GTK剪贴板采用延迟供应机制,在收到paste请求时才通过selection-get回调提供数据。SPICE在收到CLIPBOARD后,可先缓存,待客户端应用请求时再提供;或主动调用set_with_data使剪贴板立即可用。
AgentMsgFilter过滤机制
copy_paste_enabled / file_xfer_enabled 配置
c
// server/agent-msg-filter.c
void agent_msg_filter_config(AgentMsgFilter *filter,
gboolean copy_paste, gboolean file_xfer,
gboolean use_client_monitors_config)
{
filter->copy_paste_enabled = copy_paste;
filter->file_xfer_enabled = file_xfer;
filter->use_client_monitors_config = use_client_monitors_config;
}
AgentMsgFilterResult agent_msg_filter_process_data(AgentMsgFilter *filter,
const uint8_t *data, uint32_t len)
{
struct VDAgentMessage msg_header;
memcpy(&msg_header, data, sizeof(msg_header));
msg_header.type = GUINT32_FROM_LE(msg_header.type);
switch (msg_header.type) {
case VD_AGENT_CLIPBOARD:
case VD_AGENT_CLIPBOARD_GRAB:
case VD_AGENT_CLIPBOARD_REQUEST:
case VD_AGENT_CLIPBOARD_RELEASE:
if (!filter->copy_paste_enabled) {
filter->result = AGENT_MSG_FILTER_DISCARD;
}
break;
case VD_AGENT_FILE_XFER_START:
case VD_AGENT_FILE_XFER_STATUS:
case VD_AGENT_FILE_XFER_DATA:
if (!filter->file_xfer_enabled) {
filter->result = AGENT_MSG_FILTER_DISCARD;
}
break;
default:
break;
}
return filter->result;
}
分析:过滤器在消息进入设备前检查。copy_paste_enabled和file_xfer_enabled由spice_server_set_agent_file_xfer等API配置,允许管理员禁用剪贴板或文件传输,满足安全策略。
按消息类型过滤
每种VDAgent消息类型可独立过滤。MONITORS_CONFIG在use_client_monitors_config时返回特殊结果,用于显示器配置覆盖逻辑。
文件传输全链路
拖放触发
用户从文件管理器拖放文件到SpiceDisplay时,GTK触发drag-data-received:
c
// spice-gtk: spice-widget.c
static void drag_data_received_callback(SpiceDisplay *self,
GdkDragContext *drag_context,
gint x, gint y,
GtkSelectionData *data,
guint info, guint time,
gpointer *user_data)
{
const guchar *buf = gtk_selection_data_get_data(data);
gchar **file_urls = g_uri_list_extract_uris((const gchar*)buf);
int n_files = g_strv_length(file_urls);
GFile **files = g_new0(GFile*, n_files + 1);
for (i = 0; i < n_files; i++) {
files[i] = g_file_new_for_uri(file_urls[i]);
}
g_strfreev(file_urls);
spice_main_channel_file_copy_async(d->main, files, 0, NULL, NULL, NULL,
file_transfer_callback, NULL);
for (i = 0; i < n_files; i++)
g_object_unref(files[i]);
g_free(files);
gtk_drag_finish(drag_context, TRUE, FALSE, time);
}
分析:g_uri_list_extract_uris解析text/uri-list格式(如file:///path/to/file)。每个URI转为GFile后传入file_copy_async。GFile是GLib的文件抽象,支持本地和GIO可访问的任意后端。
spice_main_channel_file_copy_async() 启动传输
c
// spice-gtk: channel-main.c
void spice_main_channel_file_copy_async(SpiceMainChannel *channel,
GFile **sources,
GFileCopyFlags flags,
GCancellable *cancellable,
GFileProgressCallback progress_callback,
gpointer progress_callback_data,
GAsyncReadyCallback callback,
gpointer user_data)
{
FileTransferOperation *xfer_op;
SpiceFileTransferTask *xfer_task;
xfer_op = g_new0(FileTransferOperation, 1);
xfer_op->channel = channel;
xfer_op->progress_callback = progress_callback;
xfer_op->xfer_task = g_hash_table_new(g_direct_hash, g_direct_equal);
for (i = 0; sources[i] != NULL; i++) {
xfer_task = spice_file_transfer_task_new(channel, sources[i], flags);
g_hash_table_insert(xfer_op->xfer_task,
GINT_TO_POINTER(xfer_task->task_id), xfer_task);
xfer_op->stats.num_files++;
}
xfer_op->task = g_task_new(NULL, cancellable, callback, user_data);
file_transfer_start_next(xfer_op);
}
分析:为每个文件创建SpiceFileTransferTask,用task_id索引。GTask管理异步完成回调和取消。file_transfer_start_next启动第一个文件,完成后依次处理下一个。
传输过程
- VD_AGENT_FILE_XFER_START:客户端构建文件名、大小等信息,通过agent_msg_queue发送
- 服务端中转到Guest Agent
- Guest Agent回复 VD_AGENT_FILE_XFER_STATUS (CAN_SEND_DATA):表示可接收数据
- 客户端分块读取文件:g_file_read_async() 或 g_input_stream_read_async()
- 每块通过 VD_AGENT_FILE_XFER_DATA 发送
- 服务端中转到Guest,Guest Agent写文件
- 最后发送 size=0 的 FILE_XFER_DATA 表示文件结束
- Guest Agent 发送完成确认
分析:CAN_SEND_DATA是流控机制,避免客户端 flooding。分块大小通常为几十KB,平衡网络效率和内存占用。
SpiceFileTransferTask生命周期
c
// 创建 -> spice_file_transfer_task_new()
// 初始化 -> 发送 FILE_XFER_START,等待 CAN_SEND_DATA
// 读取 -> 异步读取文件块
// 发送 -> 每块封装为 FILE_XFER_DATA,受 agent token 限制
// 完成 -> 发送 size=0,收到完成确认后标记任务完成
// 取消 -> GCancellable 取消时,发送 CAN_NOT_SEND_DATA 或类似状态
分析:每个文件一个独立任务,多文件串行或可并行(取决于实现)。任务状态可与UI进度条绑定。
进度和取消
c
// GFileProgressCallback 原型
typedef void (*GFileProgressCallback)(goffset current_num_bytes,
goffset total_num_bytes,
gpointer user_data);
// 在读取/发送时调用
if (progress_callback) {
progress_callback(total_sent, file_size, progress_callback_data);
}
// GCancellable 用于取消
if (g_cancellable_is_cancelled(cancellable)) {
// 停止传输,清理资源
}
分析:progress_callback在每块发送后调用,用于更新进度条。GCancellable由调用方创建并传入,用户点击取消时g_cancellable_cancel(),传输循环检查并退出。
服务端Agent数据读写路径
写入路径(Client → Guest)
Client SPICE_MSGC_MAIN_AGENT_DATA
-> MainChannelClient::handle_message
-> reds_on_main_agent_data
-> agent_msg_filter_process_data (过滤)
-> 添加 VDIChunkHeader
-> RedCharDeviceVDIPort::write_buffer_add
-> virtio-serial 写入
-> Guest spice-vdagent 读取
分析:写入路径在MainChannel的接收逻辑中,无需额外线程。Token流控在客户端限制发送速率。
读取路径(Guest → Client)
Guest spice-vdagent 写入 virtio-serial
-> QEMU 通知 SPICE
-> RedCharDeviceVDIPort::read_one_msg_from_device
-> 解析 VDIChunkHeader + VDAgentMessage
-> 封装为 RedPipeItem
-> MainChannelClient 发送 SPICE_MSG_MAIN_AGENT_DATA
-> Client 接收并处理
分析:读取由RedCharDevice的read路径触发,通常在主循环或worker中。消息解析后通过channel的send机制发给对应的MainChannelClient。
总结
VDAgent通信实现了剪贴板和文件传输的双向透明中转:
- 剪贴板客户端→Guest:owner-change → GRAB → 服务端过滤并中转 → Guest收到GRAB;用户粘贴 → Guest发REQUEST → 客户端request → CLIPBOARD(data) → 服务端中转 → Guest粘贴
- 剪贴板Guest→客户端:流程对称,GRAB/REQUEST/CLIPBOARD方向相反
- AgentMsgFilter:按copy_paste_enabled、file_xfer_enabled过滤,满足安全策略
- 文件传输:drag_data_received → file_copy_async → FILE_XFER_START → CAN_SEND_DATA → FILE_XFER_DATA分块 → 完成
- SpiceFileTransferTask:每文件一任务,支持进度回调和取消
整个设计依赖MainChannel的Agent数据和Token流控,服务端不解析应用层载荷,仅做透明转发,保持了协议的可扩展性。
Agent Token流控
Token机制的工作原理
Agent消息(剪贴板、文件传输等)通过MainChannel传输,但最终消费者是Guest Agent------一个运行在Guest用户态的进程,其处理速度受Guest CPU和virtio-serial吞吐量限制。如果客户端不加限制地发送Agent数据,可能导致:
- 服务端的
RedCharDeviceVDIPort写缓冲区溢出 - virtio-serial环满,QEMU阻塞SPICE的写入
- Guest Agent来不及处理,消息堆积导致内存耗尽
Token机制是一个经典的信用(credit)流控方案:
c
// 初始化:服务端在 SPICE_MSG_MAIN_INIT 中设置初始 token 数
// init.agent_tokens = REDS_AGENT_WINDOW_SIZE (通常为10)
// 客户端发送流程:
void agent_msg_queue_many(SpiceMainChannel *channel, int type, void *data, size_t len)
{
// 将消息加入 agent_msg_queue
// ...
agent_send_msg_queue(channel); // 尝试发送
}
static void agent_send_msg_queue(SpiceMainChannel *channel)
{
SpiceMainChannelPrivate *c = channel->priv;
while (c->agent_tokens > 0 && !g_queue_is_empty(c->agent_msg_queue)) {
item = g_queue_pop_head(c->agent_msg_queue);
c->agent_tokens--; // 消耗一个token
// 将item封装为 SPICE_MSGC_MAIN_AGENT_DATA 发送
}
// tokens耗尽或队列为空时停止
}
// 服务端补充token:
// 当 RedCharDeviceVDIPort 成功将数据写入 virtio-serial 后
// 发送 SPICE_MSG_MAIN_AGENT_TOKEN 给客户端
// 客户端收到后:c->agent_tokens += msg->num_tokens
// 触发 agent_send_msg_queue() 继续发送队列中的消息
设计考量 :Token窗口大小(REDS_AGENT_WINDOW_SIZE = 10)是一个权衡------太小会导致频繁等待Token补充,增加RTT延迟;太大会在Guest处理慢时积压过多数据。10是一个经验值,在大多数场景下工作良好。
剪贴板与文件传输的竞争 :两者共享同一个Token池和agent_msg_queue。大文件传输会持续消耗Token,可能使剪贴板GRAB/REQUEST等小消息排在长队列后面。这是当前设计的一个局限------理论上可以通过优先级队列改善,但实际中文件传输块不大(FILE_XFER_CHUNK_SIZE),影响有限。
VDAgent能力协商
连接建立时,Guest Agent 通过 VD_AGENT_ANNOUNCE_CAPABILITIES 声明能力:
c
// 能力标志示例
#define VD_AGENT_CAP_CLIPBOARD_BY_DEMAND (1 << 0) // 按需剪贴板
#define VD_AGENT_CAP_CLIPBOARD_SELECTION (1 << 1) // 多选择支持
#define VD_AGENT_CAP_FILE_XFER (1 << 2) // 文件传输
分析:客户端根据能力决定是否发送 GRAB、是否支持多 selection 等。能力协商保证了新旧版本 Agent 的兼容性。
剪贴板MIME类型
SPICE剪贴板支持多种MIME类型,常见有:
c
// text/plain - 纯文本
// text/html - HTML片段
// image/png, image/jpeg - 图像
// 类型在 GRAB 的 types[] 中声明
// REQUEST 时指定请求的类型
分析:Guest Agent 和客户端根据各自支持的格式协商。文本粘贴最常用,图像需要两端都支持相应编解码。
VDI端口与virtio-serial
virtio-serial通信原理
virtio-serial是QEMU提供的虚拟串口设备,基于virtio标准实现高效的Host-Guest通信。与传统串口不同,virtio-serial使用共享内存的virtqueue实现,吞吐量接近内存拷贝速度。
>通信路径:
Client ──SPICE_MSGC_MAIN_AGENT_DATA──→ SPICE Server
│
RedCharDeviceVDIPort
│
VDIChunkHeader + payload
│
virtio-serial
│
/dev/virtio-ports/com.redhat.spice.0
│
Guest spice-vdagent
VDIChunkHeader 的port字段有两个值:
VDP_CLIENT_PORT (1):标识消息来自客户端,Guest Agent据此知道这是远端的请求VDP_SERVER_PORT (2):标识消息来自Guest,服务端读取时只转发CLIENT_PORT的消息,忽略SERVER_PORT(服务端自身处理)
消息分块 :VDAgentMessage的最大载荷为VD_AGENT_MAX_DATA_SIZE (2048)字节。大于此限制的消息(如大型图片剪贴板数据或文件传输块)会被自动分块------发送方拆分为多个VDI块,接收方通过msg_data_to_read状态跟踪重组。服务端的agent_msg_filter必须能正确处理分块消息,不能在消息中间切断过滤。
文件传输Task ID
每个SpiceFileTransferTask有唯一task_id,用于关联FILE_XFER_START、FILE_XFER_DATA、FILE_XFER_STATUS消息。Guest Agent用task_id区分并发传输的多个文件。
分析:多文件拖放时,每个文件独立task_id,消息可交错传输。Guest Agent维护每个task的状态机(等待START、接收数据、完成)。
总结(补充)
VDAgent架构的优势在于:服务端无需解析剪贴板或文件内容,仅做透明转发,降低了复杂度和安全风险。配置化的AgentMsgFilter使管理员可灵活控制功能开关,满足不同部署场景的安全合规要求。