{T}

TCP字节流特性与数据边界处理

TCP(Transmission Control Protocol)是一种面向字节流(Byte Stream)的传输层协议。与 UDP 的数据报(Datagram)模式不同,TCP 不保留应用层消息的边界信息。本文将深入分析 TCP 字节流特性的本质,以及如何在应用层正确处理数据边界问题。

TCP 字节流特性

在简单的客户端-服务器示例中,TCP 似乎呈现出"请求-响应"的模式:

code
发送端:network  ---->  接收端响应:Hi, network
发送端:program  ---->  接收端响应:Hi, program

这实际上是一种假象,源于网络条件良好且数据量较小。TCP 的本质特性是:应用层消息与 TCP 分组之间不存在一一对应的映射关系

发送端视角

当应用程序调用 send() 函数后,数据并未立即从网络发送出去,而是从应用程序拷贝到操作系统内核协议栈的发送缓冲区。实际发送时机取决于:

  • 发送窗口(Send Window)大小
  • 拥塞窗口(Congestion Window)大小
  • 当前发送缓冲区大小
  • 网络路径 MTU(Maximum Transmission Unit)

假设发送端连续调用 send() 发送 "network" 和 "program" 两个报文,实际传输可能出现以下情况:

图表渲染中…

接收端视角

接收端缓冲区以字节流形式存储数据。无论发送端如何构造 TCP 分组,接收端应用程序读取的字节流始终保持正确的顺序:

code
xxxxxxxxxxxxxxxxxnetworkprogramxxxxxxxxxxxx

关键特性:

  1. 顺序保证:先调用 send() 发送的字节必定在后调用 send() 发送的字节之前,由 TCP 协议严格保证;
  2. 可靠传输:若 TCP 分组丢失,后续分组到达后会被缓存,直到丢失分组重传成功,再按序交付给应用程序。
图表渲染中…

网络字节序

数据在网络传输中以二进制形式存在。对于多字节数据(如 0x0201),存在两种存储方式:

  • 大端字节序(Big-Endian):高字节存放在起始地址
  • 小端字节序(Little-Endian):低字节存放在起始地址
图表渲染中…

网络协议统一使用大端字节序。POSIX 标准提供以下转换函数:

code
uint16_t htons(uint16_t hostshort);   // host to network short
uint16_t ntohs(uint16_t netshort);    // network to host short
uint32_t htonl(uint32_t hostlong);    // host to network long
uint32_t ntohl(uint32_t netlong);     // network to host long

函数命名规则:h = host(主机),n = network(网络),s = short(16位),l = long(32位)。

若主机本身为大端字节序,这些函数为空实现:

code
#if __BYTE_ORDER == __BIG_ENDIAN
#define ntohl(x) (x)
#define ntohs(x) (x)
#define htonl(x) (x)
#define htons(x) (x)
#endif

内核版本注记:Linux 7.0 中,这些转换函数已高度优化,部分架构支持内联汇编实现,性能开销可忽略不计。

报文格式设计

由于 TCP 字节流本身不保留消息边界,应用层必须自行定义报文格式。常见的两种方法:

  1. 显式编码报文长度:在报文头部包含消息长度字段
  2. 特殊字符分隔:使用特定字符作为消息边界
图表渲染中…

显式编码报文长度

报文格式

code
+----------------+----------------+------------------+
| 消息长度 (4B)  | 消息类型 (4B)  | 消息体 (变长)    |
+----------------+----------------+------------------+

发送端程序

code
int main(int argc, char **argv) {
    if (argc != 2) {
        error(1, 0, "usage: tcpclient <IPaddress>");
    }

    int socket_fd;
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(SERV_PORT);
    inet_pton(AF_INET, argv[1], &server_addr.sin_addr);

    socklen_t server_len = sizeof(server_addr);
    int connect_rt = connect(socket_fd, (struct sockaddr *) &server_addr, server_len);
    if (connect_rt < 0) {
        error(1, errno, "connect failed ");
    }

    struct {
        u_int32_t message_length;
        u_int32_t message_type;
        char buf[128];
    } message;

    int n;

    while (fgets(message.buf, sizeof(message.buf), stdin) != NULL) {
        n = strlen(message.buf);
        message.message_length = htonl(n);
        message.message_type = 1;
        if (send(socket_fd, (char *) &message, sizeof(message.message_length) + sizeof(message.message_type) + n, 0) < 0)
            error(1, errno, "send failure");
    }
    exit(0);
}

程序关键点:

  • 第 29 行:使用 htonl() 将消息长度转换为网络字节序;
  • 第 30 行:实际发送字节数 = 消息长度字段(4B) + 消息类型字段(4B) + 消息体长度。

接收端程序

code
static int count;

static void sig_int(int signo) {
    printf("\nreceived %d datagrams\n", count);
    exit(0);
}

int main(int argc, char **argv) {
    int listenfd;
    listenfd = socket(AF_INET, SOCK_STREAM, 0);

    struct sockaddr_in server_addr;
    bzero(&server_addr, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    server_addr.sin_port = htons(SERV_PORT);

    int on = 1;
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));

    int rt1 = bind(listenfd, (struct sockaddr *) &server_addr, sizeof(server_addr));
    if (rt1 < 0) {
        error(1, errno, "bind failed ");
    }

    int rt2 = listen(listenfd, LISTENQ);
    if (rt2 < 0) {
        error(1, errno, "listen failed ");
    }

    signal(SIGPIPE, SIG_IGN);

    int connfd;
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);

    if ((connfd = accept(listenfd, (struct sockaddr *) &client_addr, &client_len)) < 0) {
        error(1, errno, "accept failed ");
    }

    char buf[128];
    count = 0;

    while (1) {
        int n = read_message(connfd, buf, sizeof(buf));
        if (n < 0) {
            error(1, errno, "error read message");
        } else if (n == 0) {
            error(1, 0, "client closed \n");
        }
        buf[n] = 0;
        printf("received %d bytes: %s\n", n, buf);
        count++;
    }

    exit(0);
}

readn 函数

readn() 函数的语义是:读取指定数量的字节,若接收缓冲区数据不足,则阻塞等待。

code
size_t readn(int fd, void *buffer, size_t length) {
    size_t count;
    ssize_t nread;
    char *ptr;

    ptr = buffer;
    count = length;
    while (count > 0) {
        nread = read(fd, ptr, count);

        if (nread < 0) {
            if (errno == EINTR)
                continue;
            else
                return (-1);
        } else if (nread == 0)
            break;                /* EOF */

        count -= nread;
        ptr += nread;
    }
    return (length - count);        /* return >= 0 */
}

read_message 函数

code
size_t read_message(int fd, char *buffer, size_t length) {
    u_int32_t msg_length;
    u_int32_t msg_type;
    int rc;

    rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
    if (rc != sizeof(u_int32_t))
        return rc < 0 ? -1 : 0;
    msg_length = ntohl(msg_length);

    rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
    if (rc != sizeof(u_int32_t))
        return rc < 0 ? -1 : 0;

    if (msg_length > length) {
        return -1;
    }

    rc = readn(fd, buffer, msg_length);
    if (rc != msg_length)
        return rc < 0 ? -1 : 0;
    return rc;
}

程序关键点:

  • 第 6 行:读取 4 字节的消息长度;
  • 第 10 行:将网络字节序转换为主机字节序;
  • 第 17 行:校验消息长度是否超过缓冲区容量;
  • 第 21 行:读取指定长度的消息体。

运行测试

服务器端:

code
$ ./streamserver
received 8 bytes: network
received 5 bytes: good

客户端:

code
$ ./streamclient
network
good

特殊字符分隔

HTTP 协议使用回车换行符(\r\n)作为报文边界。

code
int read_line(int fd, char *buf, int size) {
    int i = 0;
    char c = '\0';
    int n;

    while ((i < size - 1) && (c != '\n')) {
        n = recv(fd, &c, 1, 0);
        if (n > 0) {
            if (c == '\r') {
                n = recv(fd, &c, 1, MSG_PEEK);
                if ((n > 0) && (c == '\n'))
                    recv(fd, &c, 1, 0);
                else
                    c = '\n';
            }
            buf[i] = c;
            i++;
        } else
            c = '\n';
    }
    buf[i] = '\0';

    return (i);
}

程序关键点:

  • 第 9 行:使用 MSG_PEEK 标志"窥视"下一个字符,不将其从接收缓冲区移除;
  • 第 10-11 行:若下一个字符是 \n,则读取并丢弃;
  • 第 12-13 行:若不是 \n,则将 \r 转换为 \n

内核版本注记:Linux 4.14+ 引入了 recvmsg()MSG_BATCH 标志,支持批量接收数据,提升性能。Linux 7.0 中,TCP 的零拷贝接收(splice() + vmsplice())机制更加成熟,可显著降低大流量场景下的 CPU 开销。

总结

TCP 字节流特性的核心要点:

  1. 无消息边界:TCP 不保留应用层消息的边界,应用层需自行处理;
  2. 顺序保证:字节流顺序由 TCP 协议严格保证;
  3. 报文格式设计:通过显式编码长度或特殊字符分隔来定义消息边界;
  4. 网络字节序:多字节数据传输需统一使用大端字节序。

思考题

  1. HTTP 报文格式中,为何需要同时处理仅有回车符(\r)和回车换行符(\r\n)两种情况?
  2. 本文所述的应用层报文格式与 TCP 分组的报文格式有何区别与联系?

版本信息

  • 更新日期:2026-06-09
  • 目标内核:Linux 7.0