目录
-
- [2.7 套接字编程:生成网络应用](#2.7 套接字编程:生成网络应用)
-
- [2.7.1 UDP套接字编程](#2.7.1 UDP套接字编程)
- [2.7.2 TCP套接字编程](#2.7.2 TCP套接字编程)
2.7 套接字编程:生成网络应用
这小节要解决的问题其实就一句话:
"我们以前只是在概念上说 Web、邮件这些应用是怎么工作的,现在要亲自写程序,让两个进程在网络上通过**套接字(socket)**说话。"
1. "典型的网络应用是一对程序"
书上说:
典型的网络应用是由一对程序(即客户程序和服务器程序)组成的,它们位于两个不同的端系统中。
翻译一下:
- 一个网络应用 = 至少两段程序:
- 客户端程序(运行在一台机器上)
- 服务器程序(运行在另一台机器上)
- 两台机器就是"两个端系统"。
你可以把它想成:
- 你手机上的 微信 app 是一个客户端程序;
- 腾讯机房里的一台服务器上跑着 微信服务器程序。
2. "当运行这两个程序时,创建了一个客户进程和一个服务器进程"
- "程序"是写好放在磁盘里的代码。
- 一旦你点开运行,操作系统就会把这段程序"放活",变成一个正在运行的进程。
所以:
- 客户端程序 → 运行后 → 客户进程
- 服务器程序 → 运行后 → 服务器进程
3. "它们通过套接字读写数据进行通信"
书上说:
同时它们通过从套接字读出和写入数据在彼此之间进行通信。
- 套接字 socket = 进程通往网络的"门" / "插座"。
- 客户和服务器进程:
- 往自己的 socket 里 写数据 → 数据被交给操作系统,通过 TCP/UDP 送到对方。
- 从 socket 里 读数据 → 得到别人发来的内容。
你可以想像:
- 进程 = 房间
- 网络 = 走廊
- socket = 房门
进程把信从门口"塞出去",对方进程从自己门口"拿进来"。
4. "开发者创建网络应用,主要任务是什么?"
开发者创建一个网络应用时,其主要任务就是编写客户程序和服务器程序的代码。
意思就是:
- 作为应用层程序员,你 不需要自己实现 TCP、UDP、IP 那些东西(已经在操作系统里了)。
- 你要做的就是写两个程序:
- 客户端代码
- 服务器端代码
通过 socket 调接口,收发数据。
两类网络应用程序:开放的 vs 专用的
书上接下来分两类:
1. 第一类:基于"公开协议标准"的应用(称为开放应用)
一类是由协议标准(如一个 RFC 或某种其他标准文档)中所定义的操作的实现;这样的应用程序有时称为"开放"的...
解释:
- 互联网里的很多协议是公开的,比如 HTTP、SMTP、FTP 等。
- 这些协议的具体"规则"写在文档里(RFC)。
- 任何人都可以照着 RFC 写自己的客户端程序或服务器程序。
所以这种应用叫"开放的":
- 规则公开
- 谁都能实现
只要大家都遵守 RFC,互相之间就能正常通信。
例如:
某客户程序可能是 HTTP 协议客户端的一种实现... 该协议由 RFC2616 明确定义;
- 你的浏览器 = HTTP 客户端的实现;
- Apache / Nginx = HTTP 服务器的实现;
- 双方都按 RFC2616 这个文档的规则来收发报文,所以互通。
重点:
不同公司写的程序,只要严格按照同一个 RFC 来写,就算没商量过,也能互联互通。
2. 第二类:专用的 / 私有协议的应用
另一类网络应用程序是专用的网络应用程序。
- 这种程序使用的"协议"没有公开写在 RFC 里,而是这个开发者/团队自己定的,只在自己公司内部知道。
RFC 就是:
Request For Comments(征求意见稿)
简单理解:
- 互联网的各种"规矩"(比如 HTTP、TCP、IP、SMTP 邮件协议......)
都写在一份一份公开的技术文档里;- 这些文档大多数名字都叫 RFC + 编号 ,例如:
- RFC 2616:早期的 HTTP/1.1 协议说明;
- RFC 791:IP 协议;
- RFC 793:TCP 协议。
- 客户 + 服务器程序都由同一个开发者/团队写,他们自家的人当然知道规则;但别人不知道,就没法写兼容的程序。
书上说:
但是因为这些代码并没有实现一个开放的协议,其它独立的开发者将不能开发出与该应用程序交互的代码。
简单理解:
- 开放应用:像"普通话规范都写在字典里",谁学谁会说,大家都能交流。
- 专用应用:像"只有自己小团队知道的一种暗号",别人听不懂,也没法说。
现实例子:
- 很多公司内部的业务系统、游戏私有协议、某些早期的 P2P 软件协议,都是专用的。
本节要做什么?------亲手写一个简单的客户端--服务器程序
书上说:
在本节中,我们将考察研发一个客户--服务器应用程序中的关键问题...
他们打算:
- 用一个非常简单的小例子 来带你写:
- 一个客户端程序
- 一个服务器端程序
- 重点不是业务功能,而是:
- 怎么用 TCP 或 UDP
- 怎么用 套接字 API
1. 第一个决策:用 TCP 还是 UDP?
开发者必须最先做的一个决定是,应用程序是运行在 TCP 上还是运行在 UDP 上。
回忆一下:
- TCP
- 面向连接
- 提供可靠的字节流
- UDP
- 无连接
- 按"独立的报文"发送
- 不保证一定送到 / 不乱序
书上这里只是提醒:
前面讲过 TCP 是面向连接的... UDP 是无连接的... 不对交付提供任何保证。
2. 端口号(port number)再提醒一下
前面也讲过当客户或服务器程序实现了一个由某 RFC 定义的协议时,它应当使用与该协议关联的周知端口号;
意思是:
- 一些常见协议的端口号是"约定俗成 + RFC 规定"的:
- HTTP:80
- HTTPS:443
- SMTP:25
- 如果你写的是"标准 HTTP 服务器",就应该监听 80 端口,这样浏览器才知道往 80 发。
与之相反,当研发一个专用应用程序时,研发者必须注意避免使用这些周知端口号。
- 你自己写一个乱七八糟的实验程序,就别占用 80、443 这种别人约好要用的端口。
- 一般就随便挑个比较大的数字(比如 12000),只要不和系统里别的服务撞就行。
准备动手写代码:用 Python 做例子
我们用一个简单的 UDP 应用程序和一个简单的 TCP 应用程序来介绍 UDP 和 TCP 套接字编程。
- 先写 UDP 版本
- 再写 TCP 版本
- 语言用 Python 3,因为代码短、好看、逻辑清楚。
同时书上说:
- 你也可以用 Java / C / C++ 来写,一样的思路;
- 只是 Python 更适合教学,行数少,结构清晰;
- 如果你有 Java / C 基础,可以去他们网站上看对应的完整代码和实验。
最后那一堆参考文献(Donahoo、Stevens 等)是在推荐:
如果想学 C 语言的网络编程,可以去看这些经典教材。
对你现在来说知道就行,不用看。
2.7.1 UDP套接字编程
这一小节就是:
"我们来从零写一个用 UDP 通信的客户端和服务器,看一下它们是怎么通过套接字互动的。"
1. 再复习一下"进程通过套接字通信"这件事
2.1 节讲过,运行在不同机器上的进程彼此通过套接字发送报文来进行通信。
你现在可以把"套接字"牢牢记成:
- "这扇门后面是网络"
- 进程往门里塞东西 → 经网络出去
- 从门里拿东西 → 是别人从网络送来的
2. 使用 UDP 时要做的事:把地址附在"分组"上
书上说:
在发送进程能够将数据分组推出套接字之前,若是使用 UDP 时,必须先将目的地的地址附在该分组之上。
什么意思?
- 你是发送方进程。
- 你准备了一小块数据(书里叫"分组",其实就是一坨 bytes)。
- 你如果是用 UDP 发,就必须告诉操作系统:"这坨东西要发给哪台主机、哪一个端口"。
所以要附上:
- 目的主机 IP 地址
- 目的端口号(也就是对方那个 socket 的"门牌号")
3. IP 地址 + 端口号 = 能找到"哪台机器上的哪个进程"
接下来书上开始解释:
因此你可能现在想知道,附在分组上的目的地址包含了什么?
- 分组里的目的地址其实分两层:
- IP 地址:告诉网络"要送到哪台主机"
- 端口号 (port):告诉目的主机"要交给哪一个应用进程"
为什么要这样分开?
- 因为一台主机可能同时跑:
- Web 服务器(80)
- 邮件服务器(25)
- 你自己写的 UDP 程序(比如 12000)
- 路由器只负责根据 IP 地址 把分组送到正确的主机;
- 主机内部再根据 端口号 把分组交给对应进程。
所以书上说:
当生成一个套接字时,就为它分配一个称为端口号(port number)的标识符。
... 发送进程为分组附上的目的地址也包括该套接字的端口号。
一句话:
IP 地址 定位"哪台计算机";端口号 定位"这台计算机上的哪个应用进程"。
4. 源地址也要附上,但这一步是操作系统自动做的
此外... 发送方的源地址也是由源主机的 IP 地址和源套接字的端口号组成,该源地址也要附在分组之上。
为什么要源地址?
- 方便对方回信:
- 对方看"你是谁发来的"(源 IP + 源端口)
- 回包的时候就把它作为"目的地址"。
然而,将源地址附在分组之上通常并不是由 UDP 应用程序代码所为,而是由底层操作系统自动完成的。
意思是:
- 你写 Python 程序的时候 不需要 手动填"我是 10.0.0.1:12345";
- 操作系统知道:
- 你是哪个 socket(端口号)
- 你这台机器的 IP 地址
它会自动把源地址填到报文头里。
应用程序干了什么?------书上给了 4 步:
- 客户从键盘读入一行字符(数据),并把这些数据发给服务器;
- 服务器收到后,把这些字符转成大写;
- 服务器把修改后的字符串再发回给客户端;
- 客户端收到后,在屏幕上打印出来。
所以这个小程序本质上就是一个"大写转换器":
- 客户端:输入 "hello world"
- 服务器:收到后改成 "HELLO WORLD"
- 再发回去,客户端输出
它看起来很无聊,但正好能展示:
- 客户端如何:
- 创建 socket
- 给出服务器 IP、端口
- send
- recv
- 服务器如何:
- 创建 socket
- 绑定端口
- recvfrom(得到数据 + 客户地址)
- sendto(把结果发回去)

1. 服务器端(左边)
1)创建套接字:
创建套接字,port = x:
serverSocket = socket(AF_INET, SOCK_DGRAM)
socket()是创建套接字的系统调用 / 函数。AF_INET:表示使用 IPv4 地址族。SOCK_DGRAM:表示 UDP(数据报套接字)。- 这一步之后你有了一个"门"(serverSocket),接下来要把它"装在某个门牌号上"(绑定端口)。
书上实际上还会有一步 bind(...),图里省略了细节。
2)从 serverSocket 读 UDP 报文段
从 serverSocket 读 UDP 报文段
- 服务器进入一个循环,等待客户端发来的 UDP 分组。
- 一旦有数据到达目的端口 x,对应的 socket 就会"收到"。
- 程序调用
recvfrom()之类的函数获取数据。
3)向 serverSocket 写响应,指定客户地址和端口号
向 serverSocket 写响应,指定客户地址、端口号
- 服务器处理完(把小写改成大写)之后,要回给客户端。
- 因为是 UDP,它必须知道:
- 对方 IP 地址
- 对方端口号
- 这些信息其实在
recvfrom()的时候一起拿到了,所以直接塞给sendto()就行。
2. 客户端(右边)
1)创建套接字
创建套接字:
clientSocket = socket(AF_INET, SOCK_DGRAM)
- 同样是 UDP 套接字,类型是
SOCK_DGRAM。 - 客户端没说要 bind 哪个端口,操作系统会自动给它分配一个临时端口号(比如 54321)。
2)创建具有 serverIP 和 port = x 的数据报,经 clientSocket 发送数据报
意思是:
- 客户端准备一块数据(你从键盘读到的那一行字符串)。
- 调用
sendto(message, (serverIP, x)):serverIP:服务器那台机器的 IPx:服务器监听的端口号(比如 12000)
3)从 clientSocket 读数据报
- 客户端调用
recvfrom()或类似函数,阻塞等待服务器的回应。 - 收到后得到服务器发回的大写字符串。
4)关闭 clientSocket
- 通信结束,调用
close()把这个套接字关闭。 - UDP 本身没有"断开连接"这个概念,关闭只是告诉操作系统"我不再用这个 socket 了"。
3. 程序文件名和端口号
书最后一行说:
客户程序被称为 UDPClient.py,服务器程序被称为 UDPServer.py。... 对于本应用程序,我们任意选择了 12000 作为服务器的端口号。
解释:
- 他们会在后面给两个 Python 文件:
UDPClient.py------ 客户端代码UDPServer.py------ 服务器端代码
- 服务器监听的端口号他们随便选了一个 12000 :
- 不和"周知端口"冲突
- 范围也在合法端口号里(0--65535)
还有一句:
为了强调关键问题,我们有意提供最少的代码。"好代码"无疑将具有更多辅助性的代码行,特别是用于处理出现差错的情况。
意思就是:
- 书里的代码只放"最核心的几行",让你能一眼看清流程。
- 真正工程里会加很多:
- 错误检查
- 超时判断
- 异常处理
等等,这些他们暂时都省略了。
UDPClient.py 逐行讲解
python
from socket import * # 将socket的函数、常量全部导入进来
serverName = 'hostname' # 真实使用换成服务器地址/IP
serverPort = 12000 # 服务器监听的UDP端口号
clientSocket = socket(AF_INET, SOCK_DGRAM) # 创建一个客户端套接字,变量名为clientsocket
# AF_INET IPv4协议族;AF_INET6是ipv6
# SOCK_DGRAM 数据报套接字UDP;SOCK_STREAM是TCP
# 没有给客户端socket指定端口号,OS会自动分配一个临时端口
message = raw_input('Input lowercase sentence: ') # 输入函数
clientSocket.sendto(message.encode(), (serverName, serverPort))
modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
print(modifiedMessage.decode())
clientSocket.close()
5)message = raw_input('Input lowercase sentence: ')
raw_input()是 Python2 的输入函数(Python3 里是input())。- 执行到这行时,程序会在屏幕上显示提示语:
Input lowercase sentence:
- 用户在键盘上输入一行内容(比如
hello world),回车后:- 这一行字符串就被保存到变量
message里。
- 这一行字符串就被保存到变量
6)clientSocket.sendto(message.encode(), (serverName, serverPort))
这一行比较关键,拆开看:
message.encode()- Python 里的字符串是"字符序列";
- 发送网络数据时,需要字节序列;
encode()把字符串 → 字节(默认 UTF-8)。
sendto(数据, (服务器地址, 服务器端口))sendto()是 UDP 套接字用来发送报文的函数;- 第一个参数:要发出去的字节序列;
- 第二个参数:是一个二元组
(serverName, serverPort),表示 目的地 :serverName会被解析成 IP;serverPort就是我们设的 12000。
- 操作系统拿到这些信息之后会做几件事:
- 在 UDP 头里填上:
- 源 IP + 源端口(自动填)
- 目的 IP + 目的端口(你提供的)
- 封装成 IP 包 → 交给底层网络发出去。
- 在 UDP 头里填上:
所以这一行就把"你输入的一行小写字母句子"包装成一个 UDP 报文,扔给服务器了。
7)modifiedMessage, serverAddress = clientSocket.recvfrom(2048)
recvfrom(2048):- 从
clientSocket上接收一个 UDP 报文; 2048是缓冲区大小,表示"最多一次收 2048 字节";- 返回两个东西:
modifiedMessage: 收到的"数据部分"(字节序列);serverAddress: 发送方的地址(一个(ip, port)的元组)。
- 从
- 这行的意思是:
- 等待服务器回一个数据报;
- 收到后,把数据放到
modifiedMessage; - 把服务器的 IP + 端口放到
serverAddress。
在这个例子里,其实客户端已经知道服务器 IP 和端口了,所以 serverAddress 这个变量并没有真正用到,只是让你看看"源地址也可以拿到"。
8)print(modifiedMessage.decode())
modifiedMessage是"字节序列",要在屏幕上打印需要转成"字符串";decode()把字节 → 字符串;- 打印出来的就是服务器改成大写后的句子,比如:
HELLO WORLD。
9)clientSocket.close()
- 把客户端的套接字关掉;
- 让操作系统把相关资源回收;
- 此时这个进程也马上结束。
UDPServer.py 逐行讲解
python
from socket import *
serverPort = 12000 # 指定服务器要监听的端口号 = 12000。
serverSocket = socket(AF_INET, SOCK_DGRAM)
serverSocket.bind(('', serverPort))
print("The server is ready to receive")
while True:
message, clientAddress = serverSocket.recvfrom(2048)
modifiedMessage = message.decode().upper()
serverSocket.sendto(modifiedMessage.encode(), clientAddress)
1)from socket import *
和客户端一样:导入 socket 模块所有东西。
2)serverPort = 12000
- 指定服务器要监听的端口号 = 12000。
- 客户端发包时就要指向这个端口。
3)serverSocket = socket(AF_INET, SOCK_DGRAM)
- 创建一个 UDP 套接字,跟客户端类型一样:
- IPv4 (
AF_INET) - UDP (
SOCK_DGRAM)
- IPv4 (
- 变量名
serverSocket。
4)serverSocket.bind(('', serverPort))
很重要的一行:
bind(本机地址, 端口号)的作用是:- 把这个套接字"绑定"到指定 IP + 端口上;
- 以后发到这个 IP + 这个端口的 UDP 包,就会交给这个 socket。
- 这里第一个参数是
''(空字符串),表示:- 不指定特定网卡地址,听本机所有 IP 上 12000 端口的包;
- 等价于 C 里面的
INADDR_ANY。
所以这行可以理解成:
"让 serverSocket 在本机所有网卡的 12000 端口上,等着接 UDP 包"。
5)print("The server is ready to receive")
- 纯提示语:告诉你"服务器已经准备好了,正在监听"。
6)while True:
- 无限循环;
- 说明服务器会一直运行,随时处理客户端的请求。
7)message, clientAddress = serverSocket.recvfrom(2048)
- 跟客户端那行类似,但这次是从 服务器的 socket 上收包。
- 直到有客户端往
服务器IP:12000发包,这里才会返回:message:客户端发来的那行小写字符串(字节)clientAddress:客户端的地址(clientIP, clientPort)
这个 clientAddress 后面要用来回包,相当于信封上写的"寄信人地址"。
8)modifiedMessage = message.decode().upper()
message.decode():字节 → 字符串;.upper():把字符串里的字母变成大写;- 得到新的字符串
modifiedMessage,比如把hello world变成HELLO WORLD。
9)serverSocket.sendto(modifiedMessage.encode(), clientAddress)
- 把修改后的字符串
modifiedMessage:encode()→ 字节;- 通过
sendto()发回给刚才那个clientAddress(客户端的 IP + 端口)。
- 这样客户端就能收到大写后的句子。
10)循环继续
while True不停地转:- 来一个包 → 处理 → 回一个包;
- 服务器一直运行,直到你手动 Ctrl+C 停止。
在一台机器同时当客户端 & 服务器
- 服务器进程监听
本机 IP:12000; - 客户端进程向
127.0.0.1:12000(或本机真实 IP + 12000)发 UDP 包; - 操作系统通过 端口号 和 进程的 socket 来区分不同程序;
- 只要是两个不同进程,一个当 server、一个当 client,就可以在同一台机器上互相收发。
做实验的步骤就是:
- 在 Ubuntu 上打开两个终端(Terminal 1、Terminal 2);
- Terminal 1 运行服务器程序;
- Terminal 2 运行客户端程序,目标 IP 写
127.0.0.1; - 效果跟两台机器是一样的。
1)127.0.0.1 = 回环地址 / 本机地址 / localhost
127.0.0.1是一段专门保留的 IP ,代表"我自己这台机器"。- 你经常看到的
localhost,其实就等价于127.0.0.1。可以这样理解:
平时你寄快递,要写目标地址:北京市xx小区xx号。
如果你写"寄给我自己",快递这趟其实根本不出小区,直接在你家门口转一圈又塞回你家信箱。
这就有点像
127.0.0.1------ "发给我自己"。2)和"真 IP 地址"的区别
- 真 IP :比如
192.168.1.10
- 网络包会走网卡、走交换机、路由器,真的在网络上"飞一圈"。
- 别的机器上只要能访问到这个 IP,就可以连到你。
- 127.0.0.1 :
- 数据包不会跑到网卡上,操作系统直接在内部"打个转就回来";
- 只有本机能用 ,别的机器完全访问不到你的
127.0.0.1。所以:
- 在同一台机器上测试 客户端 & 服务器 → 写
127.0.0.1就行;- 跨两台机器测试 → 客户端的目标 IP 必须写"服务器那台机子的真实 IP"(比如
192.168.1.10),不能写127.0.0.1。
下面给你完整、可以直接编译运行的 C 代码。
c++
// udp_client.cpp - 简化 UDP 客户端,对应 UDPClient.py
#include <iostream>
#include <string>
#include <cstring>
#include <arpa/inet.h> // socket, sockaddr_in, inet_pton
#include <unistd.h> // close
int main() {
// 1. 创建 UDP 套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 2. 准备服务器地址:这里用 127.0.0.1 表示"本机"
sockaddr_in serv{};
serv.sin_family = AF_INET;
serv.sin_port = htons(12000); // 服务器端口 12000
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr); // 目标 IP = 本机
// 3. 从键盘读一行小写句子
std::cout << "Input lowercase sentence: ";
std::string msg;
std::getline(std::cin, msg);
// 4. 发送给服务器
sendto(sockfd,
msg.c_str(), msg.size(), // 要发的数据和长度
0,
(sockaddr*)&serv, sizeof(serv) // 目的 IP + 端口
);
// 5. 等待服务器返回
char buf[2048];
ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0, nullptr, nullptr);
if (n > 0) {
buf[n] = '\0'; // 补上 C 字符串结尾
std::cout << "From server: " << buf << std::endl;
}
// 6. 关闭套接字
close(sockfd);
return 0;
}
c++
// udp_server.cpp - 简化 UDP 服务器,对应 UDPServer.py
#include <iostream>
#include <cstring>
#include <arpa/inet.h> // socket, sockaddr_in, inet_ntop
#include <unistd.h> // close
int main() {
// 1. 创建 UDP 套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// 2. 绑定到本机的 12000 端口
sockaddr_in serv{};
serv.sin_family = AF_INET;
serv.sin_addr.s_addr = htonl(INADDR_ANY); // 相当于 Python 里的 '',监听所有本地 IP
serv.sin_port = htons(12000);
bind(sockfd, (sockaddr*)&serv, sizeof(serv));
std::cout << "The server is ready to receive" << std::endl;
// 3. 一直循环:收数据 -> 改大写 -> 发回去
while (true) {
char buf[2048];
sockaddr_in cli{};
socklen_t cli_len = sizeof(cli);
// 收到客户端发来的 UDP 包
ssize_t n = recvfrom(sockfd, buf, sizeof(buf) - 1, 0,
(sockaddr*)&cli, &cli_len);
if (n <= 0) continue;
buf[n] = '\0';
// 把收到的内容改成大写(等价于 Python 的 .upper())
for (ssize_t i = 0; i < n; ++i) {
if (buf[i] >= 'a' && buf[i] <= 'z') {
buf[i] = buf[i] - 'a' + 'A';
}
}
// 把结果发回刚才那个客户端
sendto(sockfd, buf, n, 0,
(sockaddr*)&cli, cli_len);
}
close(sockfd);
return 0;
}

2.7.2 TCP套接字编程
与 UDP 不同,TCP 是一个面向连接的协议。
翻成人话:
- UDP:像寄明信片
- 不提前打招呼;
- 直接写上对方地址就扔出去;
- 可能丢、可能乱序、也不知道对方收没收到。
- TCP:像先打电话,再聊天
- 先"拨号 → 接通"(建立连接),两边互相确认;
- 接通之后,才开始持续说话;
- 电话没挂之前,双方可以不停说话;
- 运营商保证:对方听到的顺序跟你说的顺序一样(可靠、有序)。
所以对于 TCP:
客户与服务器在能互相发送数据之前,必须先建立一个 TCP 连接。
这条连接就是"客户进程的套接字 ↔ 服务器进程的套接字"之间的一条虚拟通道。
和 UDP 的"附地址方式"有啥区别?
UDP 里你看到的是:
- 每发一个分组,都在分组上附上目的 IP + 目的端口;
- "分组去哪儿"完全靠每个分组头部的地址。
TCP 不一样:
- 一开始建立连接时
客户端(TCP 套接字)和服务器(TCP 套接字)之间通过"三次握手"建立关联:- 握手阶段里会用到双方的 IP+端口;
- 握手成功后就有了一条"逻辑连接"。
- 之后发送数据时
程序只管往这个连接里写字节就行了:- 不用每次
send()时再指定目的 IP 和端口; - 内核知道"这条连接是从我这里到哪一台主机的哪个端口",自动帮你把报文加上正确的头。
- 不用每次
书上说:
TCP 连接的一端与客户套接字相关联,另一端与服务器套接字相关联...使用创建的 TCP 连接,当一侧向另一侧发送数据时,它只需在其套接字上写数据。
这就是"面向连接"的含义。
服务器在 TCP 里有什么特别的职责?
书接着说:现在来看 TCP 客户程序和服务器程序的交互。
1)服务器必须先运行起来(和 UDP 一样)
第一,与 UDP 中的情形一样,TCP 服务器在客户试图发起接触前必须作为进程运行起来。
- UDP:服务器得先
bind并recvfrom,不然没有人接你的明信片。 - TCP:服务器也必须先跑起来,不然客户端
connect()会直接失败(连接不上)。
2)TCP 服务器必须有一个"特别的套接字":欢迎套接字
第二,服务器程序必须具有一扇特殊的门,更精确地说是一个特殊的套接字,该门(该套接字)欢迎运行在任何主机上的客户端进程的某种初始接触。
这就是图里的 "欢迎套接字(welcome socket)" ,书里的变量名一般叫 serverSocket 或 welcomeSocket。
你可以这么想象:
- 一栋楼的大铁门 = 欢迎套接字
负责接新客人:有人按门铃 → 看看是谁 → 给它开个通道。 - 客人进楼后,每个客人和楼里某个办公室之间还会有一条专用走廊 (后面讲的 连接套接字)。
欢迎套接字的作用:
- 绑定一个固定端口(例如 12000);
- 调
listen()说:我开始听连接请求了; - 调
accept()等待客户端来"敲门"。
连接套接字:给每个客户端开一条专用通路
书上说:
当该服务器"听"到敲门声时,它将生成一扇新的门(更精确地讲是一条 TCP 套接字),它专用于某个特定的客户...称为连接套接字(connectionSocket)。
流程是:
- 客户端调用
connect()连服务器的欢迎套接字; - 服务器端的
accept()成功返回时:- 内核创建了一个新的套接字(连接套接字);
- 这个新套接字专门服务这个客户端;
accept()返回的就是这个新 socket 的句柄(Python 变量connectionSocket,C 里是connfd)。
于是:
serverSocket:一直在那儿listen+accept,负责"接客";- 每来一个新客户端,就得到一个新的
connectionSocket:- 以后这个客户端收发数据都只走
connectionSocket; - 客户端和服务器之间的 TCP 连接,其实就是
客户端的 clientSocket ↔ 服务器的 connectionSocket。
- 以后这个客户端收发数据都只走
图 2-27 画的就是这个关系:

- 客户进程里只有一个 客户套接字;
- 服务器进程里有两个:
- 欢迎套接字(上面那个);
- 为某个客户生成的连接套接字(下面那个)。
TCP 保证什么?字节流 + 可靠 + 双向
书上说:
TCP 保证服务器进程能够按发送的顺序接收(通过连接套接字)每个字节。
- TCP 把应用数据看作连续字节流 :
- 你可以随便
send()几次、每次几字节; - 对方
recv()时看到的是按顺序排列的字节序列; - 不丢、不重、不乱(除非连接真的崩了,会报错)。
- 你可以随便
客户进程不仅能向它的套接字发出字节,也能从中接收字节;
类似地,服务器进程不仅从它的连接套接字接收字节,也能向其发送字节。
- 所以 TCP 连接是双向的 :
- 客户端可以发,也可以收;
- 服务器也可以发、可以收;
- 完全是"两边都能说话的电话线"。
TCP 客户--服务器程序的流程图
看一下图 2-28 的流程(我帮你翻成一口气的步骤):

服务器端流程
-
创建欢迎套接字
serverSocket = socket()实际上是:
socket(AF_INET, SOCK_STREAM),表示 TCP 套接字。 -
等待入连接请求
connectionSocket = serverSocket.accept()- 在
accept()这里阻塞; - 某个客户端
connect过来时,内核就会返回一个新的connectionSocket。
- 在
-
从 connectionSocket 读请求
- 其实就是
recv(),从 TCP 连接里读字节。
- 其实就是
-
向 connectionSocket 写响应
- 用
send()把处理好的数据写回去。
- 用
-
关闭 connectionSocket
- 结束这次会话;
- 欢迎套接字仍然打开,可以继续等待下一个客户。
客户端流程
-
创建 clientSocket
clientSocket = socket()用的也是
AF_INET, SOCK_STREAM(TCP)。 -
用 clientSocket 连接服务器
clientSocket.connect((serverIP, port=x))- 指定服务器 IP 和端口;
- 连接成功后,TCP 三次握手完成,通道建立。
-
使用 clientSocket 发送请求
send()把想发的内容塞进 TCP 连接。
-
从 clientSocket 读响应
recv()等待服务器回来的字节。
-
关闭 clientSocket
- 连接结束。
TCP 服务器:tcp_server.c
c
// tcp_server.c 极简 TCP 服务器:收到一条消息 -> 打印 -> 原样发回
#include <stdio.h> // 为了使用 printf
#include <string.h> // 为了使用 memset
#include <unistd.h> // 为了使用 read、write、close
#include <arpa/inet.h> // 为了使用 socket、sockaddr_in、htons、htonl 等
int main() { // 主函数,程序入口
int listenfd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个 TCP 套接字,用来"监听"别人连进来
struct sockaddr_in addr; // 定义一个结构体,保存"服务器自己的地址"(IP+端口)
memset(&addr, 0, sizeof(addr)); // 把这块内存全部清零,避免有脏数据
addr.sin_family = AF_INET; // 说明用 IPv4
addr.sin_port = htons(12000); // 端口号设为 12000,htons = 主机字节序 -> 网络字节序
addr.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY 表示本机所有 IP,都监听
bind(listenfd, (struct sockaddr*)&addr, sizeof(addr)); // 把"监听套接字"和上面这个地址绑在一起
listen(listenfd, 1); // 让这个套接字进入"监听状态",最多排队 1 个连接
printf("server: waiting for connection...\n"); // 打个提示,告诉我们服务器已经在等人连了
int connfd = accept(listenfd, NULL, NULL); // 在这里"阻塞"等待客户端连上来,成功后得到一个"连接套接字"
char buf[1024]; // 准备一个缓冲区,用来存放收到的数据
int n = read(connfd, buf, sizeof(buf) - 1); // 从连接里读数据(最多读 1023 个字节)
buf[n] = '\0'; // 在读到的最后面补一个 '\0',变成 C 风格字符串
printf("server received: %s\n", buf); // 在服务器终端上打印一下,看看收到了什么
write(connfd, buf, n); // 把同样的数据原样写回给客户端(不改大小写)
close(connfd); // 关掉这条连接套接字
close(listenfd); // 关掉监听套接字
return 0; // 程序正常结束
}
TCP 客户端:tcp_client.c
c
// tcp_client.c 极简 TCP 客户端:输入一条消息 -> 发给服务器 -> 打印服务器返回
#include <stdio.h> // printf、fgets
#include <string.h> // strlen、memset
#include <unistd.h> // read、write、close
#include <arpa/inet.h> // socket、sockaddr_in、htons、inet_pton
int main() { // 主函数
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建一个 TCP 套接字,用作"客户端套接字"
struct sockaddr_in addr; // 用来保存"服务器的地址"(IP+端口)
memset(&addr, 0, sizeof(addr)); // 清零
addr.sin_family = AF_INET; // IPv4
addr.sin_port = htons(12000); // 服务器端口也是 12000,要和服务端保持一致
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); // 把字符串形式的 IP "127.0.0.1" 转成二进制,127.0.0.1 = 本机
connect(sockfd, (struct sockaddr*)&addr, sizeof(addr)); // 主动"拨号":连接到上面的服务器 IP+端口
char buf[1024]; // 准备一个缓冲区存要发送的内容
printf("Input a sentence: "); // 提示用户输入
fgets(buf, sizeof(buf), stdin); // 从键盘读一行(带回车)
int n = strlen(buf); // 算一下这行字符串有多长
write(sockfd, buf, n); // 把这行内容写进 TCP 连接,发给服务器
n = read(sockfd, buf, sizeof(buf) - 1); // 再从连接里读服务器回来的数据
buf[n] = '\0'; // 在末尾补 '\0',变成字符串
printf("From server: %s\n", buf); // 打印服务器回来的内容
close(sockfd); // 关闭这个客户端套接字
return 0; // 程序结束
}