从零开始学习嵌入式P32----网络基础之TCP
上一篇我们学会了 UDP:填好对方地址,
sendto一扔就走,快是快,但丢包、乱序、没人兜底。传文件、登录、支付------这些"一个字节都不能错"的场景,UDP 扛不住,得请出它的兄弟协议 TCP 。TCP 用三次握手先建立连接,用序号、确认、重传保证数据不丢不乱,代价是机制复杂、开销更大。这一篇我们就把 TCP 拆开看:先说清它凭什么可靠(三次握手、包头标志位、序号确认号、滑动窗口、四次挥手),再学会 TCP 编程的固定套路------服务器端socket→bind→listen→accept,客户端socket→connect,连接建好后用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创建出来、经过bind和listen的那个,叫监听套接字------它只做一件事:站在门口迎客,自己不参与聊天;- 每来一个客人,
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),protocol 填 0 按类型选默认协议。
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:传入传出参数,调用前存"想接收的地址长度",调用后存"实际接收到的地址长度";addr传NULL时它也传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 聊天有个短板:gets 和 recvfrom 都阻塞,两端必须严格轮流说话。这一篇直接用多线程解决------一个线程只管收,一个线程只管发,互不阻塞。
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;
}
四处细节值得停下来看:
main里的sockfd是监听套接字 ,accept返回的confd才是连接套接字 ------两个收发线程用的都是confd,程序结束时两个都要close;- 接收线程里
recv返回0直接break:对方关掉了连接,没必要再等; - 退出逻辑用
.quit约定:发送线程发完.quit后pthread_cancel掉接收线程;接收线程收到.quit或对方关闭连接后,反过来取消发送线程------两条线程互相收尾,主线程两个pthread_join一等就行; 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、不用 accept,connect 成功就说明三次握手完成,直接开聊。
编译运行(用到线程库,记得加 -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;
}
三个要点:
sprintf(tmpbuff, "%s\r\n", argv[1]):把文件名和\r\n分隔符拼在一起先发出去------这就是"数据边界"的约定;- 文件是二进制 的(图片),所以用文件 IO 的
open/read原样读字节,不要用fgets这类按行处理的函数; - 发完直接
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;
}
这段代码最精妙的地方就是对粘包的处理,值得逐行看:
- 第一次
recv拿回来的tmpbuff里,是"文件名\r\n文件开头......"粘在一起的数据; strstr(tmpbuff, "\r\n")找到分隔符位置,*ptmp = '\0'把文件名单独截出来------现在tmpbuff就是纯文件名,直接拿去open建文件;ptmp += 2跳过\r\n,指向粘包数据里文件内容的开头;nret - (ptmp - tmpbuff)算出这次recv里属于文件内容的字节数,先写进文件------这部分要是漏了,收到的图片开头就缺一块,文件损坏;- 之后进入循环:每次
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 会被拒绝。服务器的四步曲 socket → bind → listen → accept 一步都不能少。
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 是客户端/服务器结构,服务器走 socket → bind → listen → accept 四步,关键在于分清监听套接字 (迎客)和 accept 返回的连接套接字 (通话);客户端只需 socket → connect。连接建好后用 send / recv 收发,recv 返回 0 是对方关闭连接的信号。两个实战把知识落了地:多线程双向聊天展示了连接的持久和全双工,文件传输则正面撞上并解决了 TCP 特有的粘包问题 ------用 \r\n 做数据边界。至此网络编程的两个传输协议都齐了,下一篇我们往应用层走一步,看看跑在 TCP 之上的 HTTP 协议是怎么回事。