1. 引言
网络编程是Linux系统开发的核心技能之一。要真正理解Socket编程,首先需要建立对网络协议体系的整体认知:数据如何分层、如何封装、如何寻址,以及最终如何通过套接字接口完成通信。本文将从网络协议层次模型讲起,逐步深入到IP地址、端口、字节序,最后给出TCP/UDP套接字编程的完整流程。
2. 网络协议层次模型
网络通信是一个极其复杂的过程,为了降低设计和实现的难度,业界普遍采用分层模型来组织协议。分层的好处在于:每一层只关注自己的职责,层与层之间通过标准接口交互,某一层的实现变化不会影响其他层。
2.1 OSI七层模型
OSI(开放系统互联)参考模型由国际标准化组织(ISO)提出,将网络通信划分为七个层次,自下而上依次为:
- 物理层:负责比特流的透明传输,定义电气特性、机械特性等,典型设备如网线、集线器。
- 数据链路层:将比特流组装成帧,负责相邻节点之间的可靠传输,通过MAC地址寻址,典型协议有以太网、PPP。
- 网络层:负责数据包的路由选择和转发,通过IP地址寻址,核心协议是IP协议。
- 传输层:提供端到端的可靠或不可靠传输,核心协议是TCP和UDP,引入端口号概念。
- 会话层:负责建立、管理和终止会话连接。
- 表示层:负责数据格式转换、加密解密、压缩解压。
- 应用层:为应用程序提供网络服务接口,典型协议有HTTP、FTP、SMTP、DNS。
2.2 TCP/IP四层模型
TCP/IP模型是实际互联网所采用的模型,它将OSI七层简化为四层,更加贴近工程实践:
- 网络接口层:对应OSI的物理层和数据链路层,负责物理传输和帧封装。
- 网际层:对应OSI的网络层,核心是IP协议,负责路由和寻址。
- 传输层:对应OSI的传输层,核心是TCP和UDP,负责端到端通信。
- 应用层:对应OSI的会话层、表示层和应用层,直接面向用户应用。
2.3 各层常见协议一览
为了更清晰地理解不同协议所处的层次,下面给出常见协议与层次的对应关系:
| 层次 | 常见协议 | 寻址方式 |
|---|---|---|
| 应用层 | HTTP、HTTPS、FTP、SMTP、POP3、DNS、SSH、Telnet | 域名/端口 |
| 传输层 | TCP、UDP | 端口号 |
| 网际层 | IP、ICMP、IGMP、ARP、RARP | IP地址 |
| 网络接口层 | 以太网、PPP、Wi-Fi(802.11) | MAC地址 |
3. 传输层与TCP三次握手
传输层是网络通信中承上启下的关键层次,它负责为应用层提供端到端的通信服务。其中TCP(传输控制协议)提供面向连接的、可靠的字节流服务,UDP(用户数据报协议)则提供无连接的、不可靠的数据报服务。
3.1 TCP三次握手建立连接
TCP在正式传输数据之前,需要通过三次握手建立连接,确保通信双方都具备收发能力。三次握手的过程如下:
- 第一次握手:客户端向服务器发送SYN报文段,其中SYN标志位为1,并携带一个初始序列号seq=x。此时客户端进入SYN_SENT状态。
- 第二次握手:服务器收到SYN报文后,如果同意建立连接,则回复SYN+ACK报文段,其中SYN=1、ACK=1,确认号ack=x+1,并携带自己的初始序列号seq=y。此时服务器进入SYN_RCVD状态。
- 第三次握手:客户端收到服务器的SYN+ACK报文后,再发送一个ACK报文段,其中ACK=1,确认号ack=y+1。此时客户端进入ESTABLISHED状态,服务器收到后也进入ESTABLISHED状态,连接建立完成。
下图用时序图直观展示三次握手的完整过程:
三次握手的核心目的是确认双方的收发能力都正常,并同步初始序列号,为后续可靠传输奠定基础。
3.2 TCP四次挥手断开连接
与建立连接不同,TCP断开连接需要四次挥手,因为TCP连接是全双工的,每一方向都必须单独关闭。过程简述如下:
- 主动方发送FIN报文,表示数据发送完毕。
- 被动方回复ACK确认,进入CLOSE_WAIT状态。
- 被动方发送FIN报文,表示自己也可以关闭。
- 主动方回复ACK确认,经过TIME_WAIT后连接彻底关闭。
下图用时序图直观展示四次挥手的完整过程:
4. 网络数据的传输
数据在网络上传输时,并不是直接以原始形式发送,而是经过逐层封装和拆包的过程。
4.1 数据封装过程
发送端从应用层开始,数据每向下经过一层,就会被加上该层的头部信息,这个过程称为封装。每一层都只关心自己负责的头部字段,下层把上层传下来的数据整体当作自己的载荷(payload)来处理。下面以一次HTTP请求为例,详细说明数据从应用层到物理介质的完整封装过程。
- 应用层:产生原始应用数据。例如浏览器发起一次HTTP请求,生成请求报文,内容包含请求行(方法、URL、协议版本)、请求头和请求体。此时数据还是纯文本形式,尚未携带任何网络传输所需的地址信息。
- 传输层:加上TCP头部,形成报文段(Segment)。TCP头部包含源端口、目的端口、序列号、确认号、标志位、窗口大小、校验和等字段。其中源端口和目的端口用于标识通信双方的进程,序列号和确认号用于可靠传输和流量控制。此时数据被封装为TCP报文段,但还没有IP地址,无法在网络上路由。
- 网际层:加上IP头部,形成数据报(Datagram)。IP头部包含源IP地址、目的IP地址、协议号、生存时间(TTL)、头部校验和等字段。其中源IP和目的IP用于跨网段寻址,协议号用于标识上层协议(TCP为6,UDP为17),TTL用于防止数据包在网络中无限循环。此时数据被封装为IP数据报,具备了在网络中路由转发的能力。
- 网络接口层:加上帧头和帧尾,形成帧(Frame)。帧头包含源MAC地址、目的MAC地址和帧类型,帧尾包含校验和(FCS),用于检测传输过程中的比特错误。MAC地址用于在同一个物理网段内寻址,帧尾的校验和用于保证数据完整性。此时数据被封装为以太网帧,最终通过物理介质(网线、光纤等)以比特流形式发送出去。
为了更直观地理解封装过程,可以把每一层想象成在快递包裹上逐层贴标签:应用层数据是包裹里的货物,传输层贴上「寄件人和收件人的进程端口」标签,网际层贴上「寄件人和收件人的IP地址」标签,网络接口层再贴上「当前网段的MAC地址」标签并封口。每一层只关心自己贴的标签,下层把上层贴好标签的整个包裹当作一个整体继续封装。
下图展示了数据从应用层到网络接口层的逐层封装结构:
需要特别强调的是,封装过程中每一层都会把上层数据完整地保留下来,只是在其前面(或后面)追加本层的头部(或尾部)信息。因此,最终在物理介质上传输的帧,实际上是一个层层嵌套的结构:帧头 + IP头部 + TCP头部 + 应用数据 + 帧尾。接收端在拆包时,会按照相反的顺序逐层剥掉这些头部,最终还原出原始的应用数据。
4.2 数据拆包过程
接收端执行相反的过程,数据每向上经过一层,就剥掉对应层的头部,最终还原出原始应用数据,这个过程称为拆包(解封装)。
理解封装与拆包,是掌握网络抓包分析、排查网络问题的基础。例如使用 tcpdump 抓包时,看到的每一个数据包都包含了从链路层到应用层的完整头部信息。
5. IP地址详解
IP地址是网络层用于标识主机的逻辑地址,它解决了物理网络中MAC地址无法跨网段寻址的问题。
5.1 IP地址的组成与区分
IPv4地址是一个32位的二进制数,通常用点分十进制表示,例如192.168.1.100。每个IP地址由两部分组成:
- 网络号(网段号):标识主机所在的网络。
- 主机号:标识该网络内的具体主机。
网络号和主机号的划分由子网掩码决定。子网掩码是一个32位的二进制数,其规则是:网络号对应的位全部为1,主机号对应的位全部为0。因此,子网掩码中连续1的个数,就是网络号所占的位数;剩余连续0的个数,就是主机号所占的位数。
以192.168.1.100为例,其默认子网掩码为255.255.255.0。将掩码转换为二进制为11111111.11111111.11111111.00000000,可以看到前24位为1、后8位为0,因此网络号占24位,主机号占8位。将IP地址与掩码做按位与运算,得到网络号为192.168.1.0,主机号为100。
计算网络号与主机号的具体步骤如下:
- 把IP地址和子网掩码都转换为二进制形式。
- 统计子网掩码中连续1的个数,即为网络号位数;连续0的个数,即为主机号位数。
- 将IP地址与子网掩码按位与,得到的结果就是网络号。
- 将IP地址与子网掩码取反后按位与,得到的结果就是主机号。
例如IP地址192.168.10.130,子网掩码255.255.255.192。掩码二进制为11111111.11111111.11111111.11000000,前26位为1、后6位为0,所以网络号占26位,主机号占6位。按位与后网络号为192.168.10.128,主机号为2。
5.2 IPv4地址的分类
IPv4地址按照第一个字节的范围,分为A、B、C、D、E五类。各类地址的前1到5位是固定的,用于标识地址类别,这也是判断IP地址类别最直接的方法:
| 类别 | 首字节范围 | 固定前缀位 | 默认子网掩码 | 网络号位数 | 主机号位数 | 适用场景 |
|---|---|---|---|---|---|---|
| A类 | 1-126 | 第1位固定为0 | 255.0.0.0 | 8位 | 24位 | 超大型网络 |
| B类 | 128-191 | 前2位固定为10 | 255.255.0.0 | 16位 | 16位 | 中型网络 |
| C类 | 192-223 | 前3位固定为110 | 255.255.255.0 | 24位 | 8位 | 小型网络 |
| D类 | 224-239 | 前4位固定为1110 | — | — | — | 组播 |
| E类 | 240-255 | 前5位固定为11110 | — | — | — | 保留实验 |
各类IP地址的固定前缀位说明如下:
- A类:第1位固定为0,因此首字节范围为00000001到01111110,即十进制的1到126。
- B类:前2位固定为10,因此首字节范围为10000000到10111111,即十进制的128到191。
- C类:前3位固定为110,因此首字节范围为11000000到11011111,即十进制的192到223。
- D类:前4位固定为1110,因此首字节范围为11100000到11101111,即十进制的224到239,用于组播。
- E类:前5位固定为11110,因此首字节范围为11110000到11111111,即十进制的240到255,保留用于实验。
此外还有几个特殊地址需要留意:127.0.0.1是回环地址,用于本机自测;0.0.0.0表示未知主机或默认路由;255.255.255.255是受限广播地址。
5.3 子网掩码与网段划分
子网掩码是一个32位的二进制数,网络号部分全为1,主机号部分全为0。通过子网掩码与IP地址做按位与运算,即可得到网络号。
例如IP地址192.168.1.100,子网掩码255.255.255.0,二者按位与的结果为192.168.1.0,这就是该主机所在的网段号,主机号为100。
5.4 IP地址计算实例
下面通过一个完整例子演示如何计算网络号、主机号和可用主机范围。
假设IP地址为192.168.10.130,子网掩码为255.255.255.192。
- 将IP和掩码转换为二进制:192.168.10.130对应11000000.10101000.00001010.10000010;255.255.255.192对应11111111.11111111.11111111.11000000。
- 按位与运算得到网络号:11000000.10101000.00001010.10000000,即192.168.10.128。
- 主机号部分为最后6位:000010,即十进制2。
- 该网段可用主机范围为192.168.10.129到192.168.10.190,共62个可用地址(网络地址192.168.10.128和广播地址192.168.10.191不可分配给主机)。
6. 端口号
端口号是传输层用于区分同一台主机上不同应用程序的逻辑标识,它是一个16位的无符号整数,取值范围为0到65535。
6.1 端口号的概念与作用
IP地址解决了“找哪台主机”的问题,端口号则解决了“找主机上的哪个进程”的问题。一个完整的通信端点由IP地址和端口号共同组成,即套接字地址。
6.2 端口号的管理与分类
- 知名端口(0-1023):由IANA统一分配,绑定到固定服务,如HTTP的80、HTTPS的443、FTP的21。
- 注册端口(1024-49151):供用户进程或服务使用,需要注册以避免冲突。
- 动态/私有端口(49152-65535):客户端临时使用,由操作系统动态分配。
6.3 常见协议的默认端口号
| 协议 | 默认端口 | 传输层协议 |
|---|---|---|
| HTTP | 80 | TCP |
| HTTPS | 443 | TCP |
| FTP | 21(控制)/20(数据) | TCP |
| SSH | 22 | TCP |
| Telnet | 23 | TCP |
| SMTP | 25 | TCP |
| POP3 | 110 | TCP |
| DNS | 53 | UDP/TCP |
| DHCP | 67/68 | UDP |
| MySQL | 3306 | TCP |
| Redis | 6379 | TCP |
7. 字节序及转换
不同CPU架构在内存中存储多字节数据时的字节顺序不同,这直接影响到网络数据的正确解析。
7.1 大端序与小端序
- 大端序(Big-Endian):高位字节存储在低地址,低位字节存储在高地址。
- 小端序(Little-Endian):低位字节存储在低地址,高位字节存储在高地址。
例如整数 0x12345678,在大端序中内存排列为12 34 56 78,在小端序中则为78 56 34 12。
7.2 网络字节序
为了保证不同主机之间通信的一致性,TCP/IP协议规定网络传输统一使用大端序,称为网络字节序。因此,在发送多字节数据前,需要将主机字节序转换为网络字节序;接收数据后,再转换回主机字节序。
7.3 字节序转换函数
Linux下提供了标准的字节序转换函数,声明在头文件 arpa/inet.h 中:
#include <arpa/inet.h> uint32_t htonl(uint32_t hostlong); // 主机序转网络序(32位) uint16_t htons(uint16_t hostshort); // 主机序转网络序(16位) uint32_t ntohl(uint32_t netlong); // 网络序转主机序(32位) uint16_t ntohs(uint16_t netshort); // 网络序转主机序(16位)其中 h 代表 host(主机),n 代表 network(网络),s 代表 short(16位),l 代表 long(32位)。在编写Socket程序时,IP地址和端口号都必须使用网络字节序。
8. Socket编程及分类
Socket(套接字)是应用层与传输层之间的编程接口,它屏蔽了底层协议的复杂性,为开发者提供了一套统一的网络通信API。
8.1 Socket的三种类型
- 流式套接字(SOCK_STREAM):基于TCP协议,提供面向连接的、可靠的、按序的字节流传输,适用于HTTP、FTP等需要可靠传输的场景。
- 数据报套接字(SOCK_DGRAM):基于UDP协议,提供无连接的、不可靠的数据报传输,适用于DNS、视频直播等对实时性要求高、允许少量丢包的场景。
- 原始套接字(SOCK_RAW):允许直接访问底层IP层甚至链路层数据,常用于网络诊断、抓包工具(如tcpdump)和安全测试。
8.2 Socket地址结构
在Linux中,通用套接字地址结构为sockaddr,而实际编程中更常用的是sockaddr_in(IPv4专用):
#include <netinet/in.h> struct sockaddr_in { sa_family_t sin_family; // 地址族,AF_INET in_port_t sin_port; // 端口号,网络字节序 struct in_addr sin_addr; // IP地址,网络字节序 char sin_zero[8]; // 填充字节 };9. TCP套接字编程流程
TCP编程遵循“服务器-客户端”模型,服务器被动监听,客户端主动连接。下面分别介绍服务器端和客户端的完整编程流程,并给出可运行的代码示例。
TCP客户端/服务端程序完整调用流程图:
9.1 服务器端流程
服务器端需要依次完成创建套接字、绑定地址、监听、接受连接、收发数据和关闭连接等步骤,具体流程如下:
- 调用socket()创建套接字。
- 调用bind()将套接字绑定到指定IP和端口。
- 调用listen()将套接字设为监听状态,等待客户端连接。
- 调用accept()接受客户端连接,返回新的连接套接字。
- 通过连接套接字调用read()/write()收发数据。
- 通信结束后调用close()关闭连接。
9.2 客户端流程
客户端流程相对简单,不需要绑定和监听,只需创建套接字后主动发起连接,连接建立后即可收发数据:
- 调用socket()创建套接字。
- 调用connect()向服务器发起连接请求,完成三次握手。
- 连接建立后,通过套接字调用read()/write()收发数据。
- 通信结束后调用close()关闭连接。
下图展示了客户端的完整调用流程:
flowchart TD A[socket 创建套接字] --> B[connect 发起连接请求] B --> C[read/write 收发数据] C --> D[close 关闭连接]9.4 编程要点总结
- 字节序转换:端口号和IP地址在写入地址结构前,必须使用htons()/htonl()转换为网络字节序。
- 地址结构清零:使用memset()将sockaddr_in清零,避免残留垃圾数据。
- INADDR_ANY:服务器绑定0.0.0.0,表示监听本机所有网卡地址,便于多网卡环境。
- accept()返回新套接字:accept()返回的是与客户端通信的专用套接字,原监听套接字继续等待其他客户端连接。
- 错误处理:每个系统调用都应检查返回值,出错时使用perror()打印错误信息并做相应处理。
10. UDP套接字编程流程
UDP(用户数据报协议)是无连接的传输层协议,通信前不需要建立连接,也不保证数据报的可靠到达。因此UDP编程模型比TCP简单得多:服务器端无需listen()和accept(),客户端也无需connect(),双方都通过sendto()和recvfrom()直接收发数据报。下面分别介绍UDP服务器和客户端的完整编程流程,并给出可运行的C语言代码示例。
UDP客户端/服务端程序完整调用流程图:
10.1 UDP服务器端流程
UDP服务器端只需创建套接字、绑定地址,然后循环调用recvfrom()接收数据、调用sendto()回发数据,具体流程如下:
- 调用socket(AF_INET, SOCK_DGRAM, 0)创建数据报套接字。
- 调用bind()将套接字绑定到指定IP和端口。
- 调用recvfrom()阻塞等待并接收客户端发来的数据报,同时获取对端的IP和端口。
- 调用sendto()向对端地址回发数据。
- 通信结束后调用close()关闭套接字。
UDP服务器端完整代码(udp_server.c):
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 8888 #define BUFFER_SIZE 1024 int main() { int server_fd; struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); char buffer[BUFFER_SIZE]; // 1. 创建数据报套接字 server_fd = socket(AF_INET, SOCK_DGRAM, 0); if (server_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 绑定IP和端口 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); close(server_fd); exit(EXIT_FAILURE); } printf("UDP服务器已启动,监听端口 %d...\n", PORT); // 3. 循环接收并回显数据 while (1) { memset(buffer, 0, BUFFER_SIZE); ssize_t n = recvfrom(server_fd, buffer, BUFFER_SIZE - 1, 0, (struct sockaddr *)&client_addr, &client_len); if (n < 0) { perror("recvfrom"); continue; } printf("收到来自 %s:%d 的数据:%s\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buffer); // 4. 原样回发给客户端 sendto(server_fd, buffer, n, 0, (struct sockaddr *)&client_addr, client_len); } // 5. 关闭套接字 close(server_fd); return 0; }10.2 UDP客户端流程
UDP客户端更简单,创建套接字后无需绑定和连接,直接调用sendto()发送数据、调用recvfrom()接收回显即可,具体流程如下:
- 调用socket(AF_INET, SOCK_DGRAM, 0)创建数据报套接字。
- 设置服务器地址结构(IP和端口)。
- 调用sendto()向服务器发送数据报。
- 调用recvfrom()接收服务器回显的数据。
- 通信结束后调用close()关闭套接字。
UDP客户端完整代码(udp_client.c):
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> #define PORT 8888 #define BUFFER_SIZE 1024 int main() { int client_fd; struct sockaddr_in server_addr; socklen_t server_len = sizeof(server_addr); char buffer[BUFFER_SIZE]; // 1. 创建数据报套接字 client_fd = socket(AF_INET, SOCK_DGRAM, 0); if (client_fd < 0) { perror("socket"); exit(EXIT_FAILURE); } // 2. 设置服务器地址 memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_port = htons(PORT); inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 3. 循环发送并接收回显 while (1) { printf("请输入要发送的内容(输入exit退出):"); fgets(buffer, BUFFER_SIZE, stdin); buffer[strcspn(buffer, "\n")] = 0; if (strcmp(buffer, "exit") == 0) { break; } // 发送数据报 sendto(client_fd, buffer, strlen(buffer), 0, (struct sockaddr *)&server_addr, server_len); // 接收回显 memset(buffer, 0, BUFFER_SIZE); recvfrom(client_fd, buffer, BUFFER_SIZE - 1, 0, (struct sockaddr *)&server_addr, &server_len); printf("服务器回显:%s\n", buffer); } // 4. 关闭套接字 close(client_fd); return 0; }编译运行方式与TCP示例类似:先编译并启动服务器,再编译并启动客户端。客户端输入任意内容,服务器会原样回显;输入exit可退出客户端。
# 编译 gcc -o udp_server udp_server.c gcc -o udp_client udp_client.c 先启动服务器 ./udp_server 另开终端启动客户端 ./udp_client11. UDP与TCP编程的核心差异
通过对比TCP和UDP的编程流程,可以总结出以下核心差异:
| 对比维度 | TCP | UDP |
|---|---|---|
| 连接方式 | 面向连接,需三次握手建立连接 | 无连接,直接收发数据报 |
| 套接字类型 | SOCK_STREAM(流式套接字) | SOCK_DGRAM(数据报套接字) |
| 服务器端 | 需listen()监听、accept()接受连接 | 无需listen()和accept(),直接recvfrom()接收 |
| 客户端 | 需connect()发起连接 | 无需connect(),直接sendto()发送 |
| 收发函数 | read()/write()或recv()/send() | sendto()/recvfrom(),需携带对端地址 |
| 可靠性 | 可靠传输,有序、无丢失、无重复 | 不可靠,可能丢包、乱序、重复 |
| 数据边界 | 字节流,无消息边界,需自行处理粘包 | 数据报,保留消息边界,一次收发一个报文 |
| 适用场景 | HTTP、FTP、文件传输等要求可靠性的场景 | DNS、DHCP、视频直播、游戏等实时性要求高的场景 |
需要特别强调的是,UDP的sendto()和recvfrom()每次调用都必须在参数中携带对端的IP地址和端口号,因为UDP没有连接的概念,内核不会为套接字维护对端信息。这也是UDP编程与TCP编程最直观的区别之一。
12. 总结
本文围绕Linux网络编程,系统梳理了从网络协议模型到套接字编程的完整知识链路。首先介绍了OSI七层模型和TCP/IP四层模型,明确了各层职责与常见协议的归属层次;随后深入讲解了TCP三次握手与四次挥手的过程,以及数据在网络上逐层封装与拆包的机制。
在IP地址部分,重点说明了IPv4地址的组成、网络号与主机号的划分方法、子网掩码的作用以及地址分类规则,并通过实例演示了网段与可用主机范围的计算。端口号作为传输层标识进程的关键概念,其分类与常见协议默认端口也在文中做了归纳。
字节序方面,介绍了大端序与小端序的区别,以及网络字节序统一使用大端序的约定,并给出了htonl/htons/ntohl/ntohs等转换函数。最后,通过对比TCP与UDP的套接字编程流程,给出了服务器端与客户端的完整C语言代码示例,总结了流式套接字与数据报套接字在连接建立、收发函数、可靠性等方面的核心差异。
掌握这些基础概念与编程流程,是进一步学习网络协议分析、高并发服务器开发以及网络安全等进阶主题的前提。建议读者在理解原理的基础上,动手运行文中代码,通过抓包工具观察实际的数据封装与传输过程,从而加深对Linux网络编程的整体认识。