从零开始学习嵌入式P32----网络基础之TCP

从零开始学习嵌入式P32----网络基础之TCP

上一篇我们学会了 UDP:填好对方地址,sendto 一扔就走,快是快,但丢包、乱序、没人兜底。传文件、登录、支付------这些"一个字节都不能错"的场景,UDP 扛不住,得请出它的兄弟协议 TCP 。TCP 用三次握手先建立连接,用序号、确认、重传保证数据不丢不乱,代价是机制复杂、开销更大。这一篇我们就把 TCP 拆开看:先说清它凭什么可靠(三次握手、包头标志位、序号确认号、滑动窗口、四次挥手),再学会 TCP 编程的固定套路------服务器端 socketbindlistenaccept,客户端 socketconnect,连接建好后用 send / recv 收发数据,最后写两个实战:多线程双向聊天和 TCP 文件传输。

本篇目标

学完这一篇,我们应该能够:

  • 说清三次握手和四次挥手的过程,知道 SYN、ACK、FIN 等标志位的含义;
  • 说清 TCP 靠什么保证可靠:序号与确认号、滑动窗口、拥塞控制、掉线检测;
  • 说清 TCP 服务器端和客户端各自的编程步骤,分清监听套接字连接套接字
  • 会用 socket / bind / listen / accept 写服务器,用 socket / connect 写客户端;
  • 会用 send / recv 收发数据,知道 recv 返回 0 意味着什么;
  • 说清什么是粘包问题,会用"加分隔符"等办法处理;
  • 写出多线程的 TCP 双向聊天,以及基于 TCP 的文件传输程序。

一、TCP 凭什么可靠

1. 三次握手:先打招呼再说话

UDP 发数据像写信------写好地址扔进邮筒,对方收没收到不知道。TCP 像打电话------先拨号,对方接起来"喂"一声,确认双方都通了,才开始说正事。这个"拨号确认"的过程就是三次握手

复制代码
客户端                                服务器
  |----------  SYN  ------------->|   在吗?
  |<--------  ACK + SYN  ---------|   在,你在吗?
  |----------  ACK  ------------->|   我也在,开始吧
  |========= 连接建立,开始传数据 =========>|
  • 客户端发 SYN:请求建立连接;
  • 服务器回 ACK + SYN:确认收到,同时反问客户端;
  • 客户端再回 ACK:确认收到,连接建立。

三次握手由内核自动完成------代码里客户端调 connect、服务器调 accept,背后走的就是这套仪式。握手的意义在于:双方都确认了自己的发送能力和对方的接收能力没问题,连接才建立。

2. TCP 包头与六个标志位

TCP 头部固定部分有 20 个字节(对比 UDP 只有 8 字节,这就是 TCP 开销大的原因之一),里面有源端口、目的端口、序号、确认号、窗口大小,还有六个控制标志位:

标志位 含义
SYN 请求建立连接(三次握手用)
ACK 确认应答(ACK 置 1 时,确认序号才有效)
FIN 结束通信(四次挥手用)
RST 连接异常,重新建立连接
PSH 催促对方尽快把数据交给应用层
URG 数据加急(紧急指针有效)

日常通信中最常见的是 SYN、ACK、FIN 三个------握手、确认、挥手全靠它们。

3. 序号与确认号:一个都不能少

TCP 把数据看成一条字节流,每个字节都有编号:

  • 序号:本包数据起始字节的编号(本次发送的序号,一般是上次收到的确认号);
  • 确认序号:接收方告诉发送方"下次请从第几个字节开始发"(一般是上次收到的序号 + 实际收到的长度)。

发送方发出数据后等确认,超时没等到 ACK 就重传;接收方靠序号把乱序到达的包重新排好、把重复的包丢掉。丢包重传、乱序重排,靠的就是这一对编号------这是 TCP 可靠性的核心。

4. 滑动窗口与拥塞控制

发得太快,接收方处理不过来怎么办?发得太猛,把网络堵死怎么办?TCP 有两道闸门:

  • 滑动窗口(流量控制):接收方通过包头里的"窗口大小"字段,实时告诉发送方"我还能收多少",发送方据此调节发送速率------看接收方的脸色办事;
  • 拥塞控制:如果网络拥堵、带宽占用较大,TCP 自带的拥塞控制机制会主动降低发送速率,等网络缓过来再提速------看网络的脸色办事。

另外,三次握手建立后到四次挥手结束前,双方一直保持连接状态,TCP 还有掉线检测机制,可以检测连接是否还维持着。

5. 四次挥手:好聚好散

连接是双向的,断开也要双向确认,所以关闭连接要四次挥手

复制代码
主动关闭方                            被动关闭方
  |----------  FIN  ------------->|   我说完了
  |<----------  ACK  -------------|   知道了(但我可能还有话要说)
  |<----------  FIN  -------------|   我也说完了
  |----------  ACK  ------------->|   好,散了吧
  • 主动关闭方发 FIN
  • 被动方先回 ACK------注意此时被动方可能还有数据没发完,所以不能立刻跟着发 FIN;
  • 被动方数据发完,再发自己的 FIN
  • 主动方回 ACK,连接彻底关闭。

如果被动方正好也没有数据要发了,中间的 ACK 和 FIN 可以合并成一个包,四次挥手就"变成"三次。挥手完成前,TCP 默认一直保持连接状态。

6. TCP 和 UDP 再对比一次

对比项 TCP UDP
连接 有连接:三次握手建立、四次挥手关闭 无连接,直接发
可靠性 安全可靠:序号、确认、重传、排序 不安全不可靠,尽力投递
实现机制 复杂(流量控制、拥塞控制、掉线检测) 简单
资源开销 大:头部 20 字节起,还要维护连接状态 小:头部只有 8 字节
数据边界 字节流,无边界(有粘包问题) 报文式,一次一发一收
典型场景 文件传输、网页、登录 视频通话、广播、实时数据上报

一句话总结:TCP 用复杂换可靠,UDP 用简单换速度。


二、TCP 编程的流程

TCP 是客户端/服务器(C/S)模型:服务器先起来,绑定固定地址等别人来连;客户端主动发起连接。两端步骤不对称:

服务器端 客户端
1 socket 创建套接字(SOCK_STREAM socket 创建套接字(SOCK_STREAM
2 bind 绑定自己的 IP+端口 (一般不绑定,内核自动分配)
3 listen 开始监听连接请求 connect 向服务器发起连接(触发三次握手)
4 accept 接受连接,返回新套接字
5 send / recv 收发数据 send / recv 收发数据
6 close 关闭连接套接字和监听套接字 close 关闭套接字

这里有一个新手最容易绕晕的点:TCP 服务器有两个套接字

  • socket 创建出来、经过 bindlisten 的那个,叫监听套接字------它只做一件事:站在门口迎客,自己不参与聊天;
  • 每来一个客人,accept 就返回一个新的连接套接字 ------后面所有的 send / recv 都走这个新套接字。

打个比方:监听套接字是餐厅门口迎宾,连接套接字是给每桌客人配的服务员。迎宾只负责把人领进门,点菜上菜全是服务员的事。


三、TCP 编程的函数接口

1. socket:创建流式套接字

c 复制代码
int tcpsocket = socket(AF_INET, SOCK_STREAM, 0);

和 UDP 唯一的区别是第二个参数:SOCK_DGRAM 是 UDP,SOCK_STREAM 是 TCP (流式套接字)。domain 依然填 AF_INET(IPv4),protocol0 按类型选默认协议。

2. connect:客户端发起连接

c 复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 功能:向指定地址的服务器发送一个连接请求------三次握手就发生在这个函数里,握手成功函数才返回;
  • 参数
    • sockfd:套接字文件描述符;
    • addr服务器 的地址(IP + 端口),用 struct sockaddr_in 填好后强转;
    • addrlen:地址结构体长度;
  • 返回值 :成功返回 0,失败返回 -1

connect 失败最常见的原因:服务器没启动、IP 或端口写错、网络不通。

3. listen:开始监听

c 复制代码
int listen(int sockfd, int backlog);
  • 功能:把套接字变成监听状态,开始接收连接请求;
  • 参数
    • sockfd:套接字文件描述符(已经 bind 过的);
    • backlog:尚未处理的连接请求最多排几个(队列长度),常填 10
  • 返回值 :成功返回 0,失败返回 -1

listen 本身不阻塞------它只是告诉内核"这扇门开始迎客了"。真正等客人上门的是 accept

4. accept:接受连接

c 复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 功能 :处理监听队列中的第一个连接请求,阻塞等待------没有客户端来连就一直睡;
  • 参数
    • sockfd:监听套接字;
    • addr:用来存放客户端 地址信息的空间首地址;不关心可以传 NULL
    • addrlen:传入传出参数,调用前存"想接收的地址长度",调用后存"实际接收到的地址长度";addrNULL 时它也传 NULL
  • 返回值 :成功返回新的文件描述符 (连接套接字),失败返回 -1

accept 的返回值是全套 TCP 编程里最关键的东西:它是和这位客户端一对一通话的专线。服务器要给客户端发数据,send 的第一个参数必须填它,而不是监听套接字。

5. send:发送数据

c 复制代码
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  • 功能:向已建立连接的套接字中发送数据;
  • 参数
    • sockfd:文件描述符(客户端填自己的套接字,服务器填 accept 返回的连接套接字);
    • buf / len:发送数据空间的首地址和长度;
    • flags:属性,默认填 0
  • 返回值 :成功返回实际发送的字节数,失败返回 -1

sendto 对比一下:sendto 每次都要带目标地址,因为 UDP 无连接;send 不用带地址,因为连接已经建好,数据顺着连接走就行。

6. recv:接收数据

c 复制代码
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • 功能 :从套接字中接收数据,阻塞等待;
  • 参数
    • sockfd:文件描述符(同 send 的规则);
    • buf / len:存放数据空间的首地址和最多接收的字节数;
    • flags:属性,默认填 0
  • 返回值 :成功返回接收到的字节数,失败返回 -1对方关闭连接时返回 0

recv 返回 0 是 TCP 特有的信号:对方四次挥手说了"我说完了"(FIN),这条连接上没有数据可等了,我们该收场了。写接收循环时一定要处理这个返回值,否则会对着一条死连接空转。


四、实战:TCP 多线程双向聊天

目标:两端各起一个程序,建立 TCP 连接后互相聊天,输入 .quit 退出。一端做服务器(bind + listen + accept),另一端做客户端(connect)。

上一篇 UDP 聊天有个短板:getsrecvfrom 都阻塞,两端必须严格轮流说话。这一篇直接用多线程解决------一个线程只管收,一个线程只管发,互不阻塞。

1. 服务器端(B 端)

服务器绑定 192.168.0.138:50000,监听并等待客户端连接。把"创建监听套接字"封装成函数:

c 复制代码
#include "head.h"

pthread_t tid_send;
pthread_t tid_recv;
int confd = 0;

void *sendfun(void *arg)
{
    char tmpbuff[4096] = {0};
    ssize_t nret = 0;

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        gets(tmpbuff);
        nret = send(confd, tmpbuff, strlen(tmpbuff), 0);
        if (-1 == nret)
        {
            perror("fail to send");
            return NULL;
        }

        if (0 == strcmp(tmpbuff, ".quit"))
        {
            break;
        }
    }
    pthread_cancel(tid_recv);

    return NULL;
}

void *recvfun(void *arg)
{
    char tmpbuff[4096] = {0};
    ssize_t nret = 0;

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        nret = recv(confd, tmpbuff, sizeof(tmpbuff), 0);
        if (-1 == nret)
        {
            perror("fail to recv");
            return NULL;
        }
        else if (0 == nret)
        {
            break;
        }

        if (0 == strcmp(tmpbuff, ".quit"))
        {
            break;
        }

        printf("RECV:%s\n", tmpbuff);
    }
    pthread_cancel(tid_send);

    return NULL;
}

int CreateListenSocket(const char *pIp, int Port)
{
    int ret = 0;
    int sockfd = 0;
    struct sockaddr_in seraddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        perror("fail to socket");
        return -1;
    }

    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(Port);
    seraddr.sin_addr.s_addr = inet_addr(pIp);
    ret = bind(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if (-1 == ret)
    {
        perror("fail to bind");
        return -1;
    }

    ret = listen(sockfd, 10);
    if (-1 == ret)
    {
        perror("fail to listen");
        return -1;
    }

    return sockfd;
}

int main(void)
{
    int sockfd = 0;

    sockfd = CreateListenSocket("192.168.0.138", 50000);

    confd = accept(sockfd, NULL, NULL);
    if (-1 == confd)
    {
        perror("fail to accept");
        return -1;
    }

    pthread_create(&tid_send, NULL, sendfun, NULL);
    pthread_create(&tid_recv, NULL, recvfun, NULL);

    pthread_join(tid_send, NULL);
    pthread_join(tid_recv, NULL);

    close(confd);
    close(sockfd);

    return 0;
}

四处细节值得停下来看:

  1. main 里的 sockfd监听套接字accept 返回的 confd 才是连接套接字 ------两个收发线程用的都是 confd,程序结束时两个都要 close
  2. 接收线程里 recv 返回 0 直接 break:对方关掉了连接,没必要再等;
  3. 退出逻辑用 .quit 约定:发送线程发完 .quitpthread_cancel 掉接收线程;接收线程收到 .quit 或对方关闭连接后,反过来取消发送线程------两条线程互相收尾,主线程两个 pthread_join 一等就行;
  4. accept 的地址参数传了 NULL:这个聊天程序不需要知道客户端是谁,拿到连接就用。

2. 客户端(A 端)

客户端向 192.168.0.138:50000 发起连接,收发线程的结构和服务器一模一样,只是建连方式不同:

c 复制代码
#include "head.h"

int sockfd = 0;
pthread_t tid_send;
pthread_t tid_recv;

void *sendfun(void *arg)
{
    char tmpbuff[4096] = {0};
    ssize_t nret = 0;

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        gets(tmpbuff);
        nret = send(sockfd, tmpbuff, strlen(tmpbuff), 0);
        if (-1 == nret)
        {
            perror("fail to send");
            return NULL;
        }

        if (0 == strcmp(tmpbuff, ".quit"))
        {
            break;
        }
    }
    pthread_cancel(tid_recv);

    return NULL;
}

void *recvfun(void *arg)
{
    char tmpbuff[4096] = {0};
    ssize_t nret = 0;

    while (1)
    {
        memset(tmpbuff, 0, sizeof(tmpbuff));
        nret = recv(sockfd, tmpbuff, sizeof(tmpbuff), 0);
        if (-1 == nret)
        {
            perror("fail to recv");
            return NULL;
        }
        else if (0 == nret)
        {
            break;
        }

        if (0 == strcmp(tmpbuff, ".quit"))
        {
            break;
        }

        printf("RECV:%s\n", tmpbuff);
    }
    pthread_cancel(tid_send);

    return NULL;
}

int CreateTcpConnection(const char *pIp, int Port)
{
    int ret = 0;
    struct sockaddr_in seraddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        perror("fail to socket");
        return -1;
    }

    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(Port);
    seraddr.sin_addr.s_addr = inet_addr(pIp);
    ret = connect(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if (-1 == ret)
    {
        perror("fail to connect");
        return -1;
    }

    return sockfd;
}

int main(void)
{
    int sockfd = 0;

    sockfd = CreateTcpConnection("192.168.0.138", 50000);

    pthread_create(&tid_send, NULL, sendfun, NULL);
    pthread_create(&tid_recv, NULL, recvfun, NULL);

    pthread_join(tid_send, NULL);
    pthread_join(tid_recv, NULL);

    close(sockfd);

    return 0;
}

客户端比服务器简单得多:不用 bind(内核自动分配本地端口)、不用 listen、不用 acceptconnect 成功就说明三次握手完成,直接开聊。

编译运行(用到线程库,记得加 -lpthread;先起服务器,再起客户端):

bash 复制代码
gcc clientB.c -o server -lpthread
gcc clientA.c -o client -lpthread

./server          # 先跑,阻塞在 accept 上等待
./client          # 后跑,connect 成功后两端即可互发消息

两端随便打字互发,不用轮流------这就是"多线程 + TCP 连接"带来的自由。输入 .quit 或对端退出,程序都会干净地收场。


五、实战:TCP 文件传输

第二个实战更有嵌入式味道:把开发板上的一张图片(src.jpg)通过网络传到另一台机器。发送方是客户端,接收方是服务器。

1. 先说粘包问题

TCP 是字节流 :数据像水一样在管道里流,没有"一条消息"的概念。发送方分多次 send 的数据,可能发生粘连,被接收方一次 recv 全部收走------这就是粘包。

文件传输正好撞上这个问题:文件名和文件内容是分两次 send 的,接收方却可能一次 recv 就收到"文件名 + 文件开头一部分内容"粘在一起的数据。常见的处理办法有三种:

  • 加延时 :两次 send 之间睡一会,让前一包先走完------简单粗暴,不可靠,只用于演示;
  • 设置数据边界 :消息里带分隔符,比如用 \r\n 隔开文件名和文件内容,接收方按分隔符拆开------本例用这个;
  • 定长发送:每条消息固定长度,不足补零,接收方每次按定长读------规整但浪费带宽。

2. 发送端(客户端)

流程:connect 建立连接 → 先发一行"文件名 + \r\n" → 再循环 read 文件、send 内容 → 发完 close

c 复制代码
#include "head.h"

int CreateTcpConnection(const char *pIp, int Port)
{
    int sockfd = 0;
    int ret = 0;
    struct sockaddr_in seraddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        perror("fail to socket");
        return -1;
    }

    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(Port);
    seraddr.sin_addr.s_addr = inet_addr(pIp);
    ret = connect(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if (-1 == ret)
    {
        perror("fail to connect");
        return -1;
    }

    return sockfd;
}

int main(int argc, char *argv[])
{
    int sockfd = 0;
    ssize_t nret = 0;
    int fd = 0;
    char tmpbuff[4096] = {0};

    if (argc != 2)
    {
        fprintf(stderr, "Usage:./a.out filename\n");
        return -1;
    }

    sockfd = CreateTcpConnection("192.168.0.138", 50000);

    sprintf(tmpbuff, "%s\r\n", argv[1]);
    nret = send(sockfd, tmpbuff, strlen(tmpbuff), 0);
    if (-1 == nret)
    {
        perror("fail to send");
        return -1;
    }

    fd = open(argv[1], O_RDONLY);
    if (-1 == fd)
    {
        perror("fail to open");
        return -1;
    }

    while (1)
    {
        nret = read(fd, tmpbuff, sizeof(tmpbuff));
        if (nret <= 0)
        {
            break;
        }

        nret = send(sockfd, tmpbuff, nret, 0);
        if (-1 == nret)
        {
            perror("fail to send");
            return -1;
        }
    }

    close(fd);
    close(sockfd);

    return 0;
}

三个要点:

  1. sprintf(tmpbuff, "%s\r\n", argv[1]):把文件名和 \r\n 分隔符拼在一起先发出去------这就是"数据边界"的约定;
  2. 文件是二进制 的(图片),所以用文件 IO 的 open / read 原样读字节,不要用 fgets 这类按行处理的函数;
  3. 发完直接 close(sockfd)------这一下 close 会触发四次挥手,接收端的 recv 因此返回 0,正好作为"文件传完了"的信号,不用再额外发明结束标记。

3. 接收端(服务器)

流程:listen + accept 等连接 → 第一次 recv 收到"文件名 + 可能粘上的文件开头" → 按 \r\n 拆开,建文件、写入开头部分 → 循环 recv 剩余内容写入 → recv 返回 0 说明传完:

c 复制代码
#include "head.h"

int CreateListenSocket(const char *pIp, int Port)
{
    int ret = 0;
    int sockfd = 0;
    struct sockaddr_in seraddr;

    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (-1 == sockfd)
    {
        perror("fail to socket");
        return -1;
    }

    seraddr.sin_family = AF_INET;
    seraddr.sin_port = htons(Port);
    seraddr.sin_addr.s_addr = inet_addr(pIp);
    ret = bind(sockfd, (struct sockaddr *)&seraddr, sizeof(seraddr));
    if (-1 == ret)
    {
        perror("fail to bind");
        return -1;
    }

    ret = listen(sockfd, 10);
    if (-1 == ret)
    {
        perror("fail to listen");
        return -1;
    }

    return sockfd;
}

int main(void)
{
    int sockfd = 0;
    int confd = 0;
    char tmpbuff[4096] = {0};
    ssize_t nret = 0;
    int fd = 0;
    char *ptmp = NULL;

    sockfd = CreateListenSocket("192.168.0.138", 50000);

    confd = accept(sockfd, NULL, NULL);
    if (-1 == confd)
    {
        perror("fail to accept");
        return -1;
    }

    nret = recv(confd, tmpbuff, sizeof(tmpbuff), 0);
    if (-1 == nret)
    {
        perror("fail to recv");
        return -1;
    }

    ptmp = strstr(tmpbuff, "\r\n");
    *ptmp = '\0';

    fd = open(tmpbuff, O_WRONLY | O_CREAT | O_TRUNC, 0664);
    if (-1 == fd)
    {
        perror("fail to open");
        return -1;
    }

    ptmp += 2;
    write(fd, ptmp, nret - (ptmp - tmpbuff));

    while (1)
    {
        nret = recv(confd, tmpbuff, sizeof(tmpbuff), 0);
        if (-1 == nret)
        {
            perror("fail to recv");
            return -1;
        }
        else if (0 == nret)
        {
            break;
        }
        write(fd, tmpbuff, nret);
    }

    close(fd);
    close(confd);
    close(sockfd);

    return 0;
}

这段代码最精妙的地方就是对粘包的处理,值得逐行看:

  1. 第一次 recv 拿回来的 tmpbuff 里,是"文件名 \r\n 文件开头......"粘在一起的数据;
  2. strstr(tmpbuff, "\r\n") 找到分隔符位置,*ptmp = '\0' 把文件名单独截出来------现在 tmpbuff 就是纯文件名,直接拿去 open 建文件;
  3. ptmp += 2 跳过 \r\n,指向粘包数据里文件内容的开头;nret - (ptmp - tmpbuff) 算出这次 recv 里属于文件内容的字节数,先写进文件------这部分要是漏了,收到的图片开头就缺一块,文件损坏;
  4. 之后进入循环:每次 recv 多少就 write 多少(用返回值 nret,不要用 sizeof),直到 recv 返回 0------发送端关闭连接,文件传完。

编译运行(先起接收端):

bash 复制代码
gcc recv.c -o recv -lpthread
gcc send.c -o send -lpthread

./recv                  # 先跑,等待连接
./send src.jpg          # 后跑,传完后用 md5sum src.jpg 两端对比验证

验证文件有没有传对,最硬核的办法是两端各算一次 md5sum,值一样就说明一个字节都没差------这也顺带展示了 TCP 的可靠性。


六、常见错误

1. 用监听套接字收发数据

c 复制代码
sockfd = CreateListenSocket("192.168.0.138", 50000);
accept(sockfd, NULL, NULL);
send(sockfd, "hello", 5, 0);        // 错:监听套接字不能传数据

监听套接字只负责迎客。accept 返回的连接套接字 confd 才是和这位客户端通话的专线,send / recv 必须用它。

2. 服务器忘记 listen

bind 之后直接 accept 是不行的------没有 listen,套接字不会进入监听状态,客户端的 connect 会被拒绝。服务器的四步曲 socketbindlistenaccept 一步都不能少。

3. 先跑客户端,connect 直接失败

和 UDP 相反,TCP 有连接:服务器没起来,客户端 connect 立刻报错 (Connection refused)。这不是坏事,反而是 TCP 可靠性的体现------连不上就明说。调试顺序铁律不变:先启动服务器端

4. 把 recv 当报文,期望"一次收一条"

TCP 是字节流:recv 拿到的是"当前到了多少字节",不保证和发送方的 send 次数一一对应------可能几次 send 粘成一次 recv(粘包),也可能一次 send 被拆成几次 recv。要么像文件传输那样设计边界,要么定长收发,绝不能假设"发一次收一次"

5. recv 返回 0 没处理

c 复制代码
while (1)
{
    nret = recv(confd, buf, sizeof(buf), 0);
    write(fd, buf, nret);           // 错:nret 为 0 或 -1 时还在写
}

对方关闭连接后 recv 返回 0,不判断就循环,轻则死循环空转,重则往文件里写垃圾。recv 的三个返回值(>0 收到数据、=0 对方关闭、<0 出错)要分开处理。

6. 程序退出后端口立刻重绑失败

服务器主动断开后再重启,偶尔会遇到 bind: Address already in use------连接关闭后端口会进入一小段 TIME_WAIT 等待期。调试用小技巧:换个端口先做实验,或者等几十秒;正式代码里一般用 setsockopt 设置 SO_REUSEADDR 允许地址复用。


七、TCP 编程速查

步骤 接口 要点
创建套接字 socket(AF_INET, SOCK_STREAM, 0) SOCK_STREAM = TCP
绑定地址 bind(sockfd, &addr, sizeof(addr)) 服务器必须绑定固定 IP+端口
开始监听 listen(sockfd, 10) 不阻塞;backlog 是排队上限
接受连接 accept(sockfd, &cliaddr, &len) 阻塞;返回新的连接套接字 ;不关心对方地址可传 NULL, NULL
发起连接 connect(sockfd, &seraddr, len) 客户端用;触发三次握手
发送 send(fd, buf, len, 0) 不用带地址;服务器端 fd 是 accept 的返回值
接收 recv(fd, buf, len, 0) 阻塞;返回 0 = 对方关闭连接
关闭 close(fd) 服务器要关两个:连接套接字 + 监听套接字
易混淆点 结论
监听套接字 vs 连接套接字 前者只迎客(listen/accept),后者才收发数据(send/recv
sendto/recvfrom vs send/recv 前者 UDP 用、每次带地址;后者 TCP 用、地址已在连接里
recv 返回 0 vs -1 0 是对方正常关闭(四次挥手),-1 才是出错
三次握手 vs 四次挥手 握手建连接(SYN → ACK+SYN → ACK);挥手关连接(FIN → ACK → FIN → ACK)
字节流 vs 报文 TCP 无消息边界(会粘包),UDP 一次一发一收
粘包怎么办 加延时(演示用)、加分隔符(如 \r\n)、定长发送

小结

这一篇我们啃下了传输层的另一位主角 TCP。先看懂它可靠的本钱:三次握手 确认双方收发能力,序号 + 确认号 保证数据不丢不乱,滑动窗口拥塞控制 调节发送速率,掉线检测 维持连接状态,四次挥手 好聚好散------这一切的代价是 20 字节的包头和复杂的机制。再看编程模型:TCP 是客户端/服务器结构,服务器走 socketbindlistenaccept 四步,关键在于分清监听套接字 (迎客)和 accept 返回的连接套接字 (通话);客户端只需 socketconnect。连接建好后用 send / recv 收发,recv 返回 0 是对方关闭连接的信号。两个实战把知识落了地:多线程双向聊天展示了连接的持久和全双工,文件传输则正面撞上并解决了 TCP 特有的粘包问题 ------用 \r\n 做数据边界。至此网络编程的两个传输协议都齐了,下一篇我们往应用层走一步,看看跑在 TCP 之上的 HTTP 协议是怎么回事。

相关推荐
云絮.2 小时前
网络原理---- 网络层,数据链路层,应用层
网络
传奇开心果编程3 小时前
【Rust入门知识点学与练】第21课:Trait 进阶 Advanced Traits
开发语言·学习·rust
水巷石子3 小时前
备考系统架构设计师第一天
学习·架构·软考·设计·考试
Johny_Zhao4 小时前
网络安全等级保护测评实施方案
linux·网络·人工智能·网络安全·信息安全·云计算·等保测评·系统运维·itsm
是隼人4 小时前
buuctf-pwn inndy_echo(32位fmt)题解(学习过程持续更新)
c语言·学习·安全·pwn入门·ctf入门
xian_wwq4 小时前
【学习笔记】深度认知系列-第16讲-提示词工程
人工智能·笔记·学习
洛阳纸贵4 小时前
MATLAB-matlab基础知识
学习·算法·matlab
Vcaker5 小时前
Linux学习25-harbor私有仓库部署
linux·运维·学习
刃神太酷啦5 小时前
Redis 进阶核心:持久化 (RDB/AOF)、事务与主从复制全解析----《Hello Redis!》(5)
linux·c语言·数据库·c++·redis·缓存·bootstrap