从零手写 VNC 客户端:RFB 协议解析、Tight/ZRLE 解码与性能调优实战

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 做低延迟传输,或在客户端加入画面差异检测做智能降帧。

参考资料

相关推荐
酣大智8 天前
TightVNC Server 配置窗口
网络·vnc
sanguine_boy5 个月前
统信UOS配置VNC远程服务
远程工作·vnc·统信uos
佩洛君6 个月前
亚博的super镜像的vnc一直不好用,今天来解决一下
jetson·vnc·英伟达
Norach8 个月前
ubuntu22.04安装ssh-server与realvnc-server
linux·服务器·经验分享·ubuntu·ssh·vnc
夜星辰20239 个月前
rk3576 Ubuntu 22.04 安装与配置 VNC(TigerVNC + x11vnc)
ubuntu·vnc
学习嵌入式的王饱饱10 个月前
2.Linux开发板、树莓派、香橙派等安装VNC远程桌面
linux·远程桌面·vnc·开发板远程桌面
加成BUFF10 个月前
树莓派安装下载及远程连接(共用手机热点)(SSH)(VNC)
linux·计算机·ssh·bash·树莓派·vnc
ITKEY_1 年前
ubuntu24.04 VNC远程桌面awesome wm
vnc·awesome
eguid_11 年前
【从零开始开发远程桌面连接控制工具】01-项目概述与架构设计
java·远程连接·远程控制·远程桌面·vnc·teamviewer