【C/C++】C 语言实现 WebSocket:握手、帧解析、掩码和回显

【C/C++】C 语言实现 WebSocket:握手、帧解析、掩码和回显

在这里插入图片描述

1. WebSocket 为什么要先握手

WebSocket 不是一开始就直接发送二进制帧,它先通过 HTTP 发起升级请求。浏览器会发送类似这样的请求头:

GET / HTTP/1.1
Host: 127.0.0.1:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

服务端要读取 Sec-WebSocket-Key,按 RFC 6455 的规则生成 Sec-WebSocket-Accept,再返回 101 Switching Protocols。之后这条 TCP 连接才进入 WebSocket 帧通信阶段。

项目中的 WebSocket 状态用 conn->state 表示:

  • state == 0:还没有完成握手。
  • state == 1:握手完成,等待 WebSocket 数据帧。
  • state == 2:已经解析出一帧 payload,准备回包。

2. 握手:Key + GUID + SHA1 + Base64

WebSocket 协议规定服务端要把客户端的 Sec-WebSocket-Key 拼上固定 GUID:

#define GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

然后做 SHA1,再 Base64:

char combined[512];
snprintf(combined, sizeof(combined), "%s%s", client_key, GUID);
unsigned char hash[SHA_DIGEST_LENGTH];
SHA1((unsigned char *)combined, strlen(combined), hash);

char accept_key[256];
base64_encode(hash, SHA_DIGEST_LENGTH, accept_key);

最后拼出 HTTP 101 响应:

int response_length = snprintf(conn->wbuffer, sizeof(conn->wbuffer),
                               "HTTP/1.1 101 Switching Protocols\r\n"
                               "Upgrade: websocket\r\n"
                               "Connection: Upgrade\r\n"
                               "Sec-WebSocket-Accept: %s\r\n\r\n",
                               accept_key);

conn->wlength = response_length;

这段响应通过 Reactor 的 send_cb() 写回浏览器。浏览器收到合法的 101 响应后,ws.onopen 才会触发。

3. 从 HTTP 文本切换到 WebSocket 帧

websocket_request() 根据状态分两种处理:

int websocket_request(struct connection *conn) {
    if (conn->state == 0) {
        if (handshake(conn) < 0) {
            return -1;
        }
        conn->state = 1;
    } else if (conn->state == 1) {
        ws_frame_t frame;
        ws_parse_result_t result = ws_parse_frame((const uint8_t *)conn->rbuffer,
                                                  conn->rlength,
                                                  &frame);
        if (result == WS_PARSE_OK) {
            conn->state = 2;
            for (uint64_t index = 0; index < frame.payload_len; ++index) {
                conn->payload[index] = frame.payload[index] ^ frame.masking_key[index % 4];
            }
            conn->payload_length = (int)frame.payload_len;
        }
    }
    return 0;
}

握手之前,收到的是 HTTP 文本;握手之后,收到的是 WebSocket 二进制帧。代码里最重要的转折点就是 conn->state = 1

4. WebSocket 帧头结构

项目中定义了 ws_frame_t 保存解析结果:

typedef struct
{
    uint8_t fin;
    uint8_t rsv1;
    uint8_t rsv2;
    uint8_t rsv3;
    uint8_t opcode;

    uint8_t masked;
    uint64_t payload_len;
    uint8_t masking_key[4];

    size_t header_len;
    const uint8_t *payload;
} ws_frame_t;

WebSocket 帧的前两个字节非常关键:

  • 第 1 字节:FINRSV1/2/3opcode
  • 第 2 字节:MASK 和 payload length code。
  • 如果 length code 是 126,后面还有 2 字节长度。
  • 如果 length code 是 127,后面还有 8 字节长度。
  • 浏览器发给服务端的数据必须带 masking key。

项目里的解析代码先读前两个字节:

uint8_t b0 = buf[0];
uint8_t b1 = buf[1];

frame->fin = (b0 >> 7) & 0x01;
frame->rsv1 = (b0 >> 6) & 0x01;
frame->rsv2 = (b0 >> 5) & 0x01;
frame->rsv3 = (b0 >> 4) & 0x01;
frame->opcode = b0 & 0x0F;

frame->masked = (b1 >> 7) & 0x01;
uint8_t len_code = b1 & 0x7F;

5. 长度解析和协议校验

长度字段有三种情况:

if (len_code <= 125) {
    frame->payload_len = len_code;
} else if (len_code == 126) {
    if (len < pos + 2) {
        return WS_PARSE_NEED_MORE;
    }
    frame->payload_len = read_be16(buf + pos);
    pos += 2;
} else {
    if (len < pos + 8) {
        return WS_PARSE_NEED_MORE;
    }
    frame->payload_len = read_be64(buf + pos);
    pos += 8;
}

项目也做了基础协议校验:

if (frame->rsv1 || frame->rsv2 || frame->rsv3) {
    return WS_PARSE_PROTOCOL_ERROR;
}

if (!is_valid_opcode(frame->opcode)) {
    return WS_PARSE_PROTOCOL_ERROR;
}

if (!frame->masked) {
    return WS_PARSE_PROTOCOL_ERROR;
}

这里的 !frame->masked 判断非常关键。浏览器作为客户端发给服务端的 WebSocket 帧必须 mask;如果没有 mask,服务端应该认为协议错误。

6. 解除 mask 得到真实 payload

客户端 payload 并不是明文直接放在帧里,而是用 4 字节 masking key 做异或。项目里的还原逻辑很简洁:

for (uint64_t index = 0; index < frame.payload_len; ++index) {
    conn->payload[index] = frame.payload[index] ^ frame.masking_key[index % 4];
}
conn->payload_length = (int)frame.payload_len;

如果浏览器发送文本 hello,服务端最终在 conn->payload 里拿到的才是真正的 hello

7. 服务端打包响应帧

服务端回给浏览器的帧通常不需要 mask。项目里的 ws_pack_frame() 默认构造 FIN=1 的完整帧:

out[pos++] = 0x80 | (opcode & 0x0f);

if (payload_len <= 125) {
    out[pos++] = 0x00 | (uint8_t)payload_len;
} else if (payload_len <= 0xffff) {
    out[pos++] = 0x00 | 126;
    write_be16(out + pos, (uint16_t)payload_len);
    pos += 2;
} else {
    out[pos++] = 0x00 | 127;
    write_be64(out + pos, payload_len);
    pos += 8;
}

memcpy(out + pos, payload, (size_t)payload_len);

然后在 websocket_response() 中把刚才解析出来的 payload 打包成文本帧返回:

ws_pack_result_t result = ws_pack_frame((uint8_t *)out_buf,
                                        sizeof(out_buf),
                                        &out_len,
                                        WS_OPCODE_TEXT,
                                        conn->payload,
                                        conn->payload_length);
if (result == WS_PACK_OK) {
    memcpy(conn->wbuffer, out_buf, out_len);
    conn->wlength = out_len;
}
conn->state = 1;

这就形成了一个 WebSocket Echo Server:浏览器发什么文本,服务端解析后再封装成 WebSocket 文本帧回给浏览器。

8. 前端测试页面

项目里的 websocket.html 是一个非常简单的浏览器客户端:

<script>
    let ws;

    function doConnect(addr) {
        ws = new WebSocket("ws://" + addr);
        ws.onopen = () => {
            document.getElementById("log").value += (" Connection opened\n");
        };
        ws.onmessage = (event) => {
            document.getElementById("log").value += (" Receive: " + event.data + "\n\n");
        };
    }
</script>

启动服务端:

gcc reactor.c websocket.c -o websocket -lssl -lcrypto
./websocket

浏览器打开 websocket.html,把地址改成:

127.0.0.1:8080

点击连接后发送文本,页面日志里应该能看到服务端回显。

9. 小结

WebSocket 的核心流程可以概括为:

  1. HTTP Upgrade 请求进入服务端。
  2. 服务端根据 Sec-WebSocket-Key 生成 Sec-WebSocket-Accept
  3. 服务端返回 101,连接升级完成。
  4. 后续数据不再是 HTTP 文本,而是 WebSocket 帧。
  5. 客户端到服务端的帧必须 mask,服务端要先解析再异或还原 payload。
  6. 服务端响应时重新打包帧,写回 TCP 连接。

项目把 WebSocket 接在 Reactor 上,代码层次比较清楚:reactor.c 管 fd 和事件,websocket.c 管协议状态和帧格式。

学习链接: https://github.com/0voice

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值