1. 引言:为什么还要自己写一个 VNC 客户端
远程桌面几乎是每个开发者都会用到的工具。TeamViewer、向日葵、Windows 自带的 RDP,用起来都很顺手。但如果你做过嵌入式设备调试、云主机运维、或者需要把远程桌面能力集成进自己的产品里,就会发现现成的软件往往不够用:
- 设备端只有一块屏幕和网络,装不了庞大的客户端;
- 需要把远程画面嵌进 Web 页面或自有 App,而不是弹出一个独立窗口;
- 需要按业务定制画质、帧率、权限,甚至做画面录制与审计。
这时候,VNC(Virtual Network Computing)就是最合适的选择。它足够简单、足够开放,协议文档公开,服务端实现(TigerVNC、TightVNC、x11vnc、macOS 内置的屏幕共享)随处可见,客户端却可以完全由我们自己掌控。
本文不是一篇「VNC 是什么」的科普,而是一次完整的开发实战:我们会从 RFB 协议讲起,手写一个能连上真实服务端、能显示画面、能发送键鼠事件的 VNC 客户端,最后再讨论性能优化与工程化落地。
2. VNC 与 RFB 协议速览
VNC 的核心是 RFB(Remote Framebuffer)协议。它的设计哲学非常朴素:服务端维护一块帧缓冲(Framebuffer),客户端只需要告诉服务端「我要看哪块区域」,服务端就把这块区域的像素变化推过来。
2.1 三个关键概念
- Framebuffer:服务端屏幕的像素矩阵,有宽、高、像素格式(bpp、字节序、RGB 掩码)。
- Encoding:像素数据的编码方式。Raw 是最朴素的逐像素传输,Hextile、Tight、ZRLE、Zlib 则是各种压缩方案。
- Update :一次画面更新。客户端发
FramebufferUpdateRequest,服务端回FramebufferUpdate,里面包含若干矩形区域及其编码后的像素。
2.2 握手流程
一次完整的 RFB 连接大致分为四步:
"服务端" "客户端" "服务端" "客户端" #mermaid-svg-WI32IsRCWpPaK7ng{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WI32IsRCWpPaK7ng .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WI32IsRCWpPaK7ng .error-icon{fill:#552222;}#mermaid-svg-WI32IsRCWpPaK7ng .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WI32IsRCWpPaK7ng .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WI32IsRCWpPaK7ng .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WI32IsRCWpPaK7ng .marker.cross{stroke:#333333;}#mermaid-svg-WI32IsRCWpPaK7ng svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WI32IsRCWpPaK7ng p{margin:0;}#mermaid-svg-WI32IsRCWpPaK7ng .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WI32IsRCWpPaK7ng text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-WI32IsRCWpPaK7ng .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WI32IsRCWpPaK7ng .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-WI32IsRCWpPaK7ng .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-WI32IsRCWpPaK7ng .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-WI32IsRCWpPaK7ng #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-WI32IsRCWpPaK7ng .sequenceNumber{fill:white;}#mermaid-svg-WI32IsRCWpPaK7ng #sequencenumber{fill:#333;}#mermaid-svg-WI32IsRCWpPaK7ng #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-WI32IsRCWpPaK7ng .messageText{fill:#333;stroke:none;}#mermaid-svg-WI32IsRCWpPaK7ng .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WI32IsRCWpPaK7ng .labelText,#mermaid-svg-WI32IsRCWpPaK7ng .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-WI32IsRCWpPaK7ng .loopText,#mermaid-svg-WI32IsRCWpPaK7ng .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-WI32IsRCWpPaK7ng .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-WI32IsRCWpPaK7ng .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WI32IsRCWpPaK7ng .noteText,#mermaid-svg-WI32IsRCWpPaK7ng .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-WI32IsRCWpPaK7ng .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WI32IsRCWpPaK7ng .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WI32IsRCWpPaK7ng .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-WI32IsRCWpPaK7ng .actorPopupMenu{position:absolute;}#mermaid-svg-WI32IsRCWpPaK7ng .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-WI32IsRCWpPaK7ng .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-WI32IsRCWpPaK7ng .actor-man circle,#mermaid-svg-WI32IsRCWpPaK7ng line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-WI32IsRCWpPaK7ng :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} "ProtocolVersion (RFB 003.008)" "ProtocolVersion" "SecurityTypes 选择" "SecurityResult" "ClientInit (shared flag)" "ServerInit (宽高/像素格式/名称)" "SetPixelFormat / SetEncodings" "FramebufferUpdateRequest" "FramebufferUpdate (矩形+像素)"
握手完成后,连接就进入稳定的「请求---更新」循环。注意 RFB 是服务端推送、客户端请求的半双工模式:客户端不请求,服务端就不会主动发画面。
3. 开发环境与项目结构
我们用一个最小可运行的工程来演示,技术选型如下:
- 语言:Python 3.10+(便于快速验证协议细节,后续可平移到 C++/Go)
- 依赖:仅标准库
socket、struct、zlib,不引入第三方 VNC 库 - 服务端:本地用
x11vnc或 TigerVNC 起一个测试实例
项目结构:
text
vnc-client/
├── rfb/
│ ├── protocol.py # 常量与消息结构
│ ├── connection.py # 连接、握手、收发
│ ├── encoding.py # Raw / Hextile / ZRLE 解码
│ └── framebuffer.py # 帧缓冲与脏矩形管理
├── ui/
│ └── viewer.py # 画面渲染与输入事件
└── main.py
启动一个测试服务端:
bash
# 安装并启动 x11vnc,监听 5900 端口
sudo apt install x11vnc
x11vnc -display :0 -forever -shared -rfbport 5900 -nopw
生产环境务必开启密码认证,
-nopw仅用于本地调试。
4. 第一步:完成 RFB 握手
握手是协议里最容易踩坑的部分,因为不同版本的服务端行为差异很大。我们按 RFB 3.8 实现,并对 3.3 做兼容降级。
python
import socket
import struct
class RFBConnection:
def __init__(self, host: str, port: int = 5900):
self.sock = socket.create_connection((host, port), timeout=10)
self.sock.settimeout(None)
self.width = 0
self.height = 0
self.name = ""
self.pixel_format = None
def _recv_exact(self, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("服务端关闭了连接")
buf += chunk
return buf
def handshake(self, password: str | None = None):
# 1. 版本协商
server_version = self._recv_exact(12).decode()
major, minor = int(server_version[4:7]), int(server_version[8:11])
if (major, minor) >= (3, 8):
self.sock.sendall(b"RFB 003.008\n")
self._security_38(password)
else:
self.sock.sendall(b"RFB 003.003\n")
self._security_33(password)
# 2. ClientInit:shared=1 表示允许与其他客户端共享
self.sock.sendall(struct.pack(">B", 1))
# 3. ServerInit
header = self._recv_exact(24)
self.width, self.height = struct.unpack(">HH", header[0:4])
self.pixel_format = self._parse_pixel_format(header[4:20])
name_len = struct.unpack(">I", header[20:24])[0]
self.name = self._recv_exact(name_len).decode("utf-8", "replace")
def _parse_pixel_format(self, raw: bytes) -> dict:
bpp, depth, big_endian, true_color = struct.unpack(">BBBB", raw[0:4])
r_max, g_max, b_max = struct.unpack(">HHH", raw[4:10])
r_shift, g_shift, b_shift = struct.unpack(">BBB", raw[10:13])
return {
"bpp": bpp, "depth": depth, "big_endian": bool(big_endian),
"true_color": bool(true_color),
"r_max": r_max, "g_max": g_max, "b_max": b_max,
"r_shift": r_shift, "g_shift": g_shift, "b_shift": b_shift,
}
安全类型协商部分单独拆出来,3.8 版本会先返回一个安全类型列表:
python
def _security_38(self, password):
count = self._recv_exact(1)[0]
if count == 0:
reason_len = struct.unpack(">I", self._recv_exact(4))[0]
raise ConnectionError(self._recv_exact(reason_len).decode())
types = list(self._recv_exact(count))
# 1 = None, 2 = VNC Authentication
chosen = 2 if (password and 2 in types) else 1
self.sock.sendall(struct.pack(">B", chosen))
if chosen == 2:
self._vnc_auth(password)
result = struct.unpack(">I", self._recv_exact(4))[0]
if result != 0:
raise ConnectionError("认证失败")
VNC 认证用的是 DES 加密挑战值,密钥是密码补零到 8 字节后按位反转:
python
def _vnc_auth(self, password: str):
challenge = self._recv_exact(16)
key = password.encode()[:8].ljust(8, b"\x00")
key = bytes(int(f"{b:08b}"[::-1], 2) for b in key)
# 标准库无 DES,生产环境建议用 pyDes 或 cryptography
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
encryptor = Cipher(algorithms.TripleDES(key * 3), modes.ECB()).encryptor()
self.sock.sendall(encryptor.update(challenge) + encryptor.finalize())
5. 第二步:请求并解码画面
握手成功后,先设置像素格式与编码列表,再进入更新循环。
python
def set_encodings(self, encodings: list[int]):
payload = struct.pack(">BxH", 2, len(encodings))
payload += b"".join(struct.pack(">i", e) for e in encodings)
self.sock.sendall(payload)
def request_update(self, incremental: bool = True,
x: int = 0, y: int = 0, w: int | None = None, h: int | None = None):
w = w or self.width
h = h or self.height
self.sock.sendall(struct.pack(">BBHHHH", 3, int(incremental), x, y, w, h))
服务端返回的 FramebufferUpdate 结构是:消息类型 0 + 1 字节填充 + 2 字节矩形数量,随后是每个矩形的 x, y, w, h, encoding 以及编码数据。
python
def read_update(self) -> list[dict]:
msg_type = self._recv_exact(1)[0]
if msg_type != 0:
raise ValueError(f"非预期消息类型: {msg_type}")
self._recv_exact(1)
n_rects = struct.unpack(">H", self._recv_exact(2))[0]
rects = []
for _ in range(n_rects):
x, y, w, h, enc = struct.unpack(">HHHHi", self._recv_exact(12))
rects.append({"x": x, "y": y, "w": w, "h": h,
"encoding": enc, "data": self._read_encoding(enc, w, h)})
return rects
5.1 Raw 编码
Raw 最简单:w * h * bpp/8 字节的原始像素,按行优先排列。
python
def _read_raw(self, w: int, h: int) -> bytes:
bpp = self.pixel_format["bpp"] // 8
return self._recv_exact(w * h * bpp)
5.2 Hextile 编码
Hextile 把矩形切成 16×16 的小块,每块带一个子编码字节,支持「与上一块相同」「纯色」「原始像素」等模式,是带宽与实现复杂度的良好折中。
python
def _read_hextile(self, w: int, h: int) -> bytes:
bpp = self.pixel_format["bpp"] // 8
out = bytearray(w * h * bpp)
bg = fg = b"\x00" * bpp
for ty in range(0, h, 16):
for tx in range(0, w, 16):
tw, th = min(16, w - tx), min(16, h - ty)
sub = self._recv_exact(1)[0]
if sub & 0x01: # Raw
raw = self._recv_exact(tw * th * bpp)
for row in range(th):
s = row * tw * bpp
d = ((ty + row) * w + tx) * bpp
out[d:d + tw * bpp] = raw[s:s + tw * bpp]
continue
if sub & 0x02: bg = self._recv_exact(bpp)
if sub & 0x04: fg = self._recv_exact(bpp)
# 纯色填充
for row in range(th):
d = ((ty + row) * w + tx) * bpp
out[d:d + tw * bpp] = bg * tw
if sub & 0x08: # 子矩形
n = self._recv_exact(1)[0]
for _ in range(n):
sx, sy, sw, sh = struct.unpack(">BBBB", self._recv_exact(4))
color = self._recv_exact(bpp) if sub & 0x10 else fg
for row in range(sh):
d = ((ty + sy + row) * w + tx + sx) * bpp
out[d:d + sw * bpp] = color * sw
return bytes(out)
5.4 像素格式转换
与 ZRLE 编码
Hextile 在局域网里够用,但一旦跨公网,带宽就成了瓶颈。Tight 和 ZRLE 是两种更「现代」的编码:前者把压缩交给 zlib 甚至 JPEG,后者用 zlib 加调色板把像素压到极致。
Tight 编码
Tight 的矩形数据以一个控制字节开头,低 4 位是压缩方式,高 4 位是填充/重置标志:
0x0:Basic Compression,zlib 流压缩的像素数据;0x1:Fill Compression,纯色填充,后面跟一个像素值;0x8:Fill + 显式像素值;0x9:JPEG,后面跟长度 + JPEG 数据(仅当服务端协商了Tight的 JPEG 质量等级);0xA:PNG,后面跟长度 + PNG 数据。
Tight 的 zlib 流是跨矩形持续复用的,不能每个矩形新建一个解压器,否则会直接解压失败。JPEG/PNG 分支则是一次性的独立数据块。
python
import zlib
class TightDecoder:
def __init__(self):
self._streams = {} # 最多 4 个 zlib 流,按 compctl 低 2 位区分
def _stream(self, idx: int) -> zlib.Decompress:
if idx not in self._streams:
self._streams[idx] = zlib.decompressobj()
return self._streams[idx]
def decode(self, conn, w: int, h: int) -> bytes:
bpp = conn.pixel_format["bpp"] // 8
out = bytearray(w * h * bpp)
compctl = conn._recv_exact(1)[0]
comp = compctl & 0x0F
stream_id = (compctl >> 4) & 0x03
if comp in (0x1, 0x8): # Fill
color = conn._recv_exact(bpp)
for row in range(h):
d = row * w * bpp
out[d:d + w * bpp] = color * w
return bytes(out)
if comp == 0x9: # JPEG
length = conn._recv_exact(3)
size = int.from_bytes(length, "big")
jpeg = conn._recv_exact(size)
return self._decode_jpeg(jpeg, w, h, bpp)
if comp == 0xA: # PNG
length = conn._recv_exact(3)
size = int.from_bytes(length, "big")
png = conn._recv_exact(size)
return self._decode_png(png, w, h, bpp)
# Basic:zlib 流压缩的原始像素
raw_len = w * h * bpp
data = b""
stream = self._stream(stream_id)
while len(data) < raw_len:
chunk = conn._recv_exact(4096)
data += stream.decompress(chunk)
return data[:raw_len]
def _decode_jpeg(self, jpeg: bytes, w: int, h: int, bpp: int) -> bytes:
# 生产环境用 Pillow / turbojpeg 解码,再按像素格式回写
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(jpeg)).convert("RGB")
return self._rgb_to_pixels(img.tobytes(), w, h, bpp)
def _decode_png(self, png: bytes, w: int, h: int, bpp: int) -> bytes:
from io import BytesIO
from PIL import Image
img = Image.open(BytesIO(png)).convert("RGB")
return self._rgb_to_pixels(img.tobytes(), w, h, bpp)
def _rgb_to_pixels(self, rgb: bytes, w: int, h: int, bpp: int) -> bytes:
# 简化:统一转成 32bpp 小端 BGRA 之外的通用形式,交给 to_rgba 前先对齐
out = bytearray(w * h * bpp)
for i in range(w * h):
r, g, b = rgb[i * 3], rgb[i * 3 + 1], rgb[i * 3 + 2]
px = (r << 16) | (g << 8) | b
out[i * bpp:(i + 1) * bpp] = px.to_bytes(bpp, "little")
return bytes(out)
注意:Tight 的 JPEG 分支是有损的,文字边缘会出现明显振铃。桌面场景建议把 JPEG 质量等级协商到 6 以上,或者干脆只用 PNG 分支。
ZRLE 编码
ZRLE 的思路是「先分块、再调色板、最后 zlib」。它把矩形切成 64×64 的 tile,每个 tile 用 1 字节的 tile 子编码描述:
0:Raw 像素;1:Solid,单色填充;2到16:PackBits 调色板,调色板大小 = 子编码值;17到127:Plain RLE;128:Plain RLE 的调色板版本;130到255:调色板 + RLE 组合。
所有 tile 数据都写进同一个 zlib 流,客户端必须持续解压。另外 ZRLE 的像素用 CPIXEL(Compressed Pixel)表示:当 bpp 为 32 且三个颜色通道的掩码能塞进 3 个字节时,CPIXEL 只占 3 字节,这是省带宽的关键。
python
class ZRLEDecoder:
def __init__(self):
self._zstream = zlib.decompressobj()
self._buf = b""
def _read(self, conn, n: int) -> bytes:
while len(self._buf) < n:
chunk = conn._recv_exact(4096)
self._buf += self._zstream.decompress(chunk)
data, self._buf = self._buf[:n], self._buf[n:]
return data
def _cpixel_size(self, pf: dict) -> int:
if pf["bpp"] == 32 and pf["depth"] <= 24:
return 3
return pf["bpp"] // 8
def _read_cpixel(self, conn, pf: dict) -> int:
n = self._cpixel_size(pf)
raw = self._read(conn, n)
if n == 3:
# 3 字节 CPIXEL 按小端拼成 32 位,再按掩码取通道
return int.from_bytes(raw + b"\x00", "little")
return int.from_bytes(raw, "big" if pf["big_endian"] else "little")
def decode(self, conn, w: int, h: int) -> bytes:
pf = conn.pixel_format
bpp = pf["bpp"] // 8
out = bytearray(w * h * bpp)
for ty in range(0, h, 64):
for tx in range(0, w, 64):
tw, th = min(64, w - tx), min(64, h - ty)
sub = self._read(conn, 1)[0]
if sub == 0: # Raw
self._tile_raw(conn, pf, out, w, tx, ty, tw, th)
elif sub == 1: # Solid
color = self._read_cpixel(conn, pf)
self._fill(out, w, tx, ty, tw, th, color, bpp)
elif sub <= 16: # Packed palette
self._tile_palette(conn, pf, out, w, tx, ty, tw, th, sub)
elif sub == 128: # Plain RLE
self._tile_rle(conn, pf, out, w, tx, ty, tw, th, None)
elif sub >= 130: # Palette RLE
pal = [self._read_cpixel(conn, pf) for _ in range(sub - 128)]
self._tile_rle(conn, pf, out, w, tx, ty, tw, th, pal)
return bytes(out)
def _fill(self, out, w, tx, ty, tw, th, color, bpp):
px = color.to_bytes(bpp, "little")
for row in range(th):
d = ((ty + row) * w + tx) * bpp
out[d:d + tw * bpp] = px * tw
def _tile_raw(self, conn, pf, out, w, tx, ty, tw, th):
bpp = pf["bpp"] // 8
for row in range(th):
for col in range(tw):
color = self._read_cpixel(conn, pf)
d = ((ty + row) * w + tx + col) * bpp
out[d:d + bpp] = color.to_bytes(bpp, "little")
def _tile_palette(self, conn, pf, out, w, tx, ty, tw, th, size):
bpp = pf["bpp"] // 8
pal = [self._read_cpixel(conn, pf) for _ in range(size)]
bits = 1 if size == 2 else 2 if size <= 4 else 4
total = tw * th
idx = 0
while idx < total:
byte = self._read(conn, 1)[0]
for shift in range(8 - bits, -1, -bits):
if idx >= total:
break
color = pal[(byte >> shift) & ((1 << bits) - 1)]
row, col = divmod(idx, tw)
d = ((ty + row) * w + tx + col) * bpp
out[d:d + bpp] = color.to_bytes(bpp, "little")
idx += 1
def _tile_rle(self, conn, pf, out, w, tx, ty, tw, th, pal):
bpp = pf["bpp"] // 8
total = tw * th
idx = 0
while idx < total:
if pal is None:
color = self._read_cpixel(conn, pf)
else:
color = pal[self._read(conn, 1)[0]]
run = 1
while True:
b = self._read(conn, 1)[0]
run += b
if b != 255:
break
for _ in range(run):
if idx >= total:
break
row, col = divmod(idx, tw)
d = ((ty + row) * w + tx + col) * bpp
out[d:d + bpp] = color.to_bytes(bpp, "little")
idx += 1
带宽与 CPU 的取舍
| 维度 | Tight | ZRLE |
|---|---|---|
| 压缩方式 | zlib + 可选 JPEG/PNG | zlib + 调色板/RLE |
| 带宽 | 低(JPEG 分支最低) | 低到极低 |
| 解码 CPU | 中(JPEG 解码较重) | 中高(逐像素 RLE 展开) |
| 画质 | JPEG 有损,PNG 无损 | 全程无损 |
| 实现复杂度 | 中 | 高 |
经验上的选择是:图文混排的桌面、带宽紧张但 CPU 有余,选 ZRLE;需要快速出画面、能接受轻微画质损失,选 Tight 的 JPEG 分支;纯文字办公场景,Tight 的 PNG 分支往往比 ZRLE 更划算。 两者都依赖 zlib 流的连续性,调试时最容易踩的坑就是「每个矩形新建解压器」,表现为解压报错或画面错位。
5.3 像素格式转换
服务端可能返回 16bpp 的 RGB565,而我们的渲染层通常要 32bpp 的 RGBA。转换时要用 ServerInit 里的掩码,而不是硬编码:
python
def to_rgba(data: bytes, pf: dict) -> bytes:
bpp = pf["bpp"] // 8
out = bytearray(len(data) // bpp * 4)
for i in range(0, len(data), bpp):
px = int.from_bytes(data[i:i + bpp], "big" if pf["big_endian"] else "little")
r = (px >> pf["r_shift"]) & pf["r_max"]
g = (px >> pf["g_shift"]) & pf["g_max"]
b = (px >> pf["b_shift"]) & pf["b_max"]
j = i // bpp * 4
out[j] = r * 255 // pf["r_max"]
out[j + 1] = g * 255 // pf["g_max"]
out[j + 2] = b * 255 // pf["b_max"]
out[j + 3] = 255
return bytes(out)
6. 第三步:发送键鼠事件
画面能看之后,就要能操作。RFB 的输入事件只有两种:KeyEvent 和 PointerEvent。
python
def send_key(self, keysym: int, down: bool):
self.sock.sendall(struct.pack(">BBxxI", 4, int(down), keysym))
def send_pointer(self, x: int, y: int, button_mask: int):
self.sock.sendall(struct.pack(">BBHH", 5, button_mask, x, y))
keysym 用的是 X11 keysym 编码,不是 ASCII。常用键需要一张映射表:
python
KEYSYM_MAP = {
"Return": 0xFF0D, "BackSpace": 0xFF08, "Tab": 0xFF09,
"Escape": 0xFF1B, "Delete": 0xFFFF,
"Left": 0xFF51, "Up": 0xFF52, "Right": 0xFF53, "Down": 0xFF54,
"Control_L": 0xFFE3, "Shift_L": 0xFFE1, "Alt_L": 0xFFE9,
}
def char_to_keysym(ch: str) -> int:
if ch in KEYSYM_MAP:
return KEYSYM_MAP[ch]
return ord(ch) # 可打印 ASCII 与 keysym 一致
鼠标按键掩码按位组合:左键 0x01、中键 0x02、右键 0x04,滚轮用按键 4/5 的按下抬起模拟。
7. 第四步:渲染与事件循环
把解码后的 RGBA 数据交给渲染层。为了不阻塞网络线程,通常采用「网络线程收包 → 队列 → UI 线程渲染」的生产者消费者模型。
python
import queue
import threading
class Viewer:
def __init__(self, conn: RFBConnection):
self.conn = conn
self.fb = bytearray(conn.width * conn.height * 4)
self.queue: queue.Queue = queue.Queue(maxsize=8)
self.running = True
def net_loop(self):
self.conn.set_encodings([0, 5]) # Raw + Hextile
self.conn.request_update(incremental=False)
while self.running:
rects = self.conn.read_update()
self.queue.put(rects)
self.conn.request_update(incremental=True)
def apply_rects(self, rects: list[dict]):
w = self.conn.width
for r in rects:
rgba = to_rgba(r["data"], self.conn.pixel_format)
for row in range(r["h"]):
src = row * r["w"] * 4
dst = ((r["y"] + row) * w + r["x"]) * 4
self.fb[dst:dst + r["w"] * 4] = rgba[src:src + r["w"] * 4]
UI 层可以用 Tkinter 的 PhotoImage、Qt 的 QImage,或者 Web 端把 fb 塞进 ImageData 再 putImageData。核心只有一句:帧缓冲是一块连续内存,渲染就是把这块内存贴到屏幕上。
8. 性能优化:让远程桌面「跟手」
能跑通只是开始,真正难的是体验。以下是几个收益最明显的优化点。
8.1 编码选择
| 编码 | 带宽 | 解码开销 | 适用场景 |
|---|---|---|---|
| Raw | 极高 | 极低 | 局域网、小区域更新 |
| Hextile | 中 | 低 | 通用场景,兼容性最好 |
| Tight | 低 | 中 | 广域网、图文混排桌面 |
| ZRLE | 低 | 中高 | 带宽受限、CPU 充裕 |
实践建议:优先协商 Tight,失败再降级到 Hextile,最后兜底 Raw。
8.2 增量更新与脏矩形
永远用 incremental=1 请求,只让服务端推送变化区域。同时维护一个脏矩形列表,合并相邻矩形后再统一重绘,避免频繁触发全屏刷新。
8.3 帧率与延迟的权衡
不要无脑追求高帧率。视频播放场景可以放宽到 30fps,而代码编辑场景 10fps 就足够。可以在客户端做「请求节流」:上一次更新处理完再发下一次请求,天然形成背压。
8.4 网络层
- 开启
TCP_NODELAY,避免小包被 Nagle 算法攒批; - 对广域网场景,考虑在 VNC 之上套一层 SSH 隧道或 WebSocket 代理;
- 断线重连要保留最后一次帧缓冲,重连后先请求全量再转增量。
python
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
9. 工程化落地:从 Demo 到产品
一个能跑的 Demo 和一个能交付的产品之间,还差这些:
- 认证与加密:VNC 原生认证只有 DES 挑战,安全性有限。生产环境应强制走 SSH 隧道或 TLS 包装,并做访问审计。
- 多会话管理:一个客户端同时连多台设备时,需要连接池、心跳保活、失败重试与状态机。
- 剪贴板同步 :RFB 的
ClientCutText/ServerCutText消息可以实现双向剪贴板,注意做长度限制与内容过滤。 - 画面录制:在帧缓冲更新处挂一个编码器(如 FFmpeg),即可实现会话录制,用于运维回溯。
- Web 化:把 RFB 客户端用 WebSocket 桥接到浏览器,前端用 Canvas 渲染,是目前最常见的产品形态。
10. 常见坑与排查清单
- 握手卡住 :多半是版本字符串没带
\n,或安全类型数量读错位。 - 画面花屏 :像素格式掩码用错,或
big_endian判断反了。先用 Raw 编码验证,再上压缩编码。 - 颜色偏色 :
r_max不是 255 时忘记做归一化,直接当 8 位用了。 - 键盘无响应:keysym 用了 ASCII 而不是 X11 keysym,或者按下后没发抬起事件。
- 鼠标错位:坐标是相对帧缓冲的,若做了缩放显示,需要按比例换算回原始坐标。
- 带宽爆炸 :忘了设
incremental=1,或者编码列表里只有 Raw。
11. 总结
VNC 的魅力在于它的「薄」:协议简单到可以手写实现,却又足以支撑起完整的远程桌面体验。回看整条主线,我们其实只做了四件事:先用 ProtocolVersion 协商与安全类型握手把连接建立起来,再通过 SetEncodings + FramebufferUpdateRequest 拉取画面,用 Raw / Hextile / Tight / ZRLE 逐层把像素解出来,最后把 X11 keysym 与指针掩码打包成 KeyEvent / PointerEvent 发回服务端。四步走完,一个能看能操作的客户端就成型了。
真正决定体验的,是握手之后的那部分工程细节。编码选择上,优先协商 Tight、降级 Hextile、兜底 Raw;更新策略上,永远用 incremental=1 并合并脏矩形;节奏控制上,用「上一次更新处理完再发下一次请求」形成天然背压;网络层则打开 TCP_NODELAY、必要时套 SSH 隧道。这些优化单看都不复杂,但叠加起来,才是「跟手」与「卡顿」的分水岭。
从 Demo 到产品,还差认证加密、多会话管理、剪贴板同步、画面录制与 Web 化这几块拼图。建议的推进顺序是:先跑通 Raw 编码的全链路 → 再加 Hextile/Tight 压缩 → 最后做多会话与安全加固。每一步都能独立验证,出问题时也容易定位。
后续可以深入的方向有不少:用 WebSocket 把 RFB 桥接到浏览器,前端用 Canvas 渲染,是最常见的产品形态;在传输层加 TLS 或强制 SSH 隧道,弥补 VNC 原生 DES 认证的不足;实现连接池与心跳保活支撑多会话并发;再往前一步,还可以结合 WebRTC 做低延迟传输,或在客户端加入画面差异检测做智能降帧。
参考资料
- RFC 6143 --- The Remote Framebuffer Protocol (RFB):RFB 3.8 的权威规范,握手、编码、消息格式均以此为准。
- TigerVNC 官方文档:主流开源服务端/客户端实现,Tight 编码与 JPEG 质量等级的实现细节可参考其源码。
- x11vnc 官方文档:把现有 X11 桌面直接共享出来的轻量服务端,适合本地调试。
- LibVNCServer / LibVNCClient:C 语言实现的 RFB 库,ZRLE、Tight 等编码的参考实现。
- The RFB Protocol 官方文档(RealVNC):社区长期维护的协议说明,对 CPIXEL、调色板等细节描述更细。