bigywater.tech
网络协议 · 87

WebSocket:从 HTTP 升级到全双工帧

WebSocket 从一次普通的 HTTP 请求开始,升级之后在同一条 TCP 连接上变成全双工的二进制帧。这篇不借助 WebSocket 客户端库,用套接字手工完成握手、拼帧、读帧,逐字节验证掩码、长度编码、分片、控制帧和关闭握手。

UVpqiCGPdLKx3UxuR7NIgKcZLTw=
我自己用 SHA-1 + base64 算出的 Sec-WebSocket-Accept,与服务器给的一致
6 字节 vs 2 字节
客户端与服务器方向每个小消息的帧头开销(客户端多 4 字节掩码)
1002
客户端发出未掩码帧时服务器回的关闭码

升级握手

实测 · 本机执行输出
  客户端发出:
    GET /chat HTTP/1.1
    Host: 127.0.0.1:9400
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: v4G9Mo9wRNCMNExBg0pZ4w==
    Sec-WebSocket-Version: 13
  服务器回复:
    HTTP/1.1 101 Switching Protocols
    Date: Thu, 15 Oct 2026 10:46:02 GMT
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: UVpqiCGPdLKx3UxuR7NIgKcZLTw=
    Server: Python/3.14 websockets/17.2
  Accept 的计算:base64( SHA-1( Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" ) ) = UVpqiCGPdLKx3UxuR7NIgKcZLTw=;服务器给的 = UVpqiCGPdLKx3UxuR7NIgKcZLTw=;一致
  101 之后同一条 TCP 连接不再说 HTTP,双方改用 WebSocket 帧;握手后服务器多发的字节 0 个

HTTP/1.1 GET + Upgrade;Accept = base64(SHA-1(Key + 固定 GUID)),只证明服务器懂 WebSocket。

帧格式

实测 · 本机执行输出
  客户端→服务器 帧(11 字节):81 85 37 fa 21 3d 7f 9f 4d 51 58
    = 81(FIN=1,操作码 1 文本)| 85(掩码位 1,长度 5)| 37 fa 21 3d(4 字节掩码密钥)| 7f 9f 4d 51 58(= "Hello" 逐字节与密钥循环异或)
    验证:7f^37=48 9f^fa=65 4d^21=6c 51^3d=6c 58^37=6f → b'Hello'
  服务器→客户端 帧(7 字节):81 05 48 65 6c 6c 6f = 81 05 + "Hello"(没有掩码位,没有密钥,头部只有 2 字节)
  所以 5 字节的消息,客户端发的开销是 6 字节(2 + 4 掩码),服务器发的开销是 2 字节

FIN + 操作码 + 掩码位 + 长度;客户端帧必须掩码(4 字节密钥异或),服务器帧不掩码。

长度编码

实测 · 本机执行输出
  载荷    125 字节:客户端帧头 6 字节(2 + 0 长度扩展 + 4 掩码),服务器帧头 2 字节(长度字段 125);回显正确 True
  载荷    126 字节:客户端帧头 8 字节(2 + 2 长度扩展 + 4 掩码),服务器帧头 4 字节(长度字段 126);回显正确 True
  载荷  65535 字节:客户端帧头 8 字节(2 + 2 长度扩展 + 4 掩码),服务器帧头 4 字节(长度字段 126);回显正确 True
  载荷  65536 字节:客户端帧头 14 字节(2 + 8 长度扩展 + 4 掩码),服务器帧头 10 字节(长度字段 127);回显正确 True

≤125 用 7 位,126~65535 用 126+2 字节,更大用 127+8 字节。

分片与控制帧

实测 · 本机执行输出
  服务器回来的帧:[('pong', 1, b'PING1'), ('文本', 1, b'frag-ment-end')]
  分片之间可以夹控制帧(ping / pong / close),但不能夹别的数据消息;服务器先回 pong,分片消息收齐之后才回显

消息可拆成多帧,控制帧可以夹在分片之间。

未掩码的帧

实测 · 本机执行输出
  服务器回 关闭 帧,关闭码 1002,原因 'incorrect masking'

客户端发未掩码帧,服务器回 Close 码 1002(incorrect masking)。

关闭握手

实测 · 本机执行输出
  客户端→服务器 Close 帧:88 85 37 fa 21 3d 34 12 43 44 52(88 = FIN + 操作码 8;载荷 = 码 1000 + "bye")
  服务器→客户端:关闭 帧,载荷 03 e8 62 79 65(码 1000);随后读到的是 连接关闭

Close 帧(码 1000)一来一回,然后 TCP 才关闭。

分步演示

WebSocket
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64(Apple 芯片)/ Python 3.14.8 / tcpdump(libpcap)/ scapy 2.8 / dpkt / dnspython / h2 / aioquic / OpenSSL / curl 8.7.1;抓包在 lo0(环回)上做,少数实验用真实网卡 en0(这台机器上 en0 是 Wi-Fi,tcpdump 看到的是系统转成以太网格式的帧,所以文中的“以太网头”都指这个 14 字节的头)或访问公网(会在文章里注明);需要丢包 / 延迟 / 限速时,用一个只作用于 lo0 端口 9400-9499 的 dummynet 辅助脚本(netlab);所有服务端都是本机自己起的进程;时间戳取自 tcpdump 的 -tt,数字每次运行会有小幅抖动。服务器是 Python 的 websockets 17.2 库写的回显服务(关闭压缩和自动 ping),监听 lo0 上的 9400~9499 端口;客户端的握手和帧都是手工拼的,没有用 WebSocket 客户端库;没有测公网的 WebSocket 服务
    • 没有验证:permessage-deflate 压缩扩展;wss(TLS 之上的 WebSocket);子协议(Sec-WebSocket-Protocol);服务器主动 ping 与超时;代理 / 负载均衡对 WebSocket 空闲连接的超时;浏览器里的 WebSocket API 行为;Origin 头校验(跨站 WebSocket 劫持)
    京ICP备2026067502号-1京公网安备11010502063487号