Linux 网络实战:TCP/IP 协议栈与 UDP 通信编程
Linux 网络编程基于 TCP/IP 协议栈分层模型,涵盖应用层、传输层等核心概念。重点讲解 UDP 协议通信接口,包括套接字创建、地址绑定、数据发送与接收的具体实现。通过服务端与客户端的代码示例,演示了如何使用 socket、bind、sendto 及 recvfrom 函数完成网络通信,并涉及网络字节序转换及长距离通信原理。

Linux 网络编程基于 TCP/IP 协议栈分层模型,涵盖应用层、传输层等核心概念。重点讲解 UDP 协议通信接口,包括套接字创建、地址绑定、数据发送与接收的具体实现。通过服务端与客户端的代码示例,演示了如何使用 socket、bind、sendto 及 recvfrom 函数完成网络通信,并涉及网络字节序转换及长距离通信原理。

协议即为双方共同遵守的规定——统一约定。 理解:你需要和对方共同遵守一套协议,才能和对方交流。只要遵守网络标准,就能实现通信。 因此网络也有自己的一套协议,它由权威、最有价值、被所有人认可的机构或者组织一起定制出的网络'约定'。
如果所有协议全部挤在一起,那么不好维护,因此就出现了协议分层。将协议分层实质是解耦的过程,方便后面维护。网络协议是层状结构的。
OSI 七层模型是最初的完整的网络通信协议,包括应用层、表示层、用户层等,较为复杂。利用它的理论逻辑形成了更加实用现实精简的分层结构——TCP/IP 分层模型。
Linux 网络编程完全遵循 TCP/IP 分层逻辑,每层各司其职,自上而下层层封装数据,接收方自下而上层层解包。常用'五层模型'(清晰易懂),而我们仅了解其中的物理层(不涉及编程)即可:
应用层:直接对接用户进程(如浏览器、QQ),负责处理具体业务逻辑。核心协议:HTTP(网页)、FTP(文件传输)、SMTP(邮件)、Telnet(远程登录)。Linux 中实现:由用户编写的代码实现。 传输层:负责两台主机之间的'端到端数据传输',解决'数据发给哪个进程'的问题。核心协议:TCP(可靠、有连接)、UDP(不可靠、无连接)。关键标识:端口号(16 位整数),用来标识主机上的进程。Linux 中实现:由操作系统内核实现,通过系统调用(如 socket)供用户调用。 网络层:负责'地址管理'和'路由选择',解决'数据发给哪个主机'的问题。核心协议:IP(IPv4 为主)、ICMP(网络差错控制)。关键标识:IP 地址(32 位),用来标识网络中的主机。Linux 中实现:内核内置。 数据链路层:负责'相邻设备间的数据帧传输',解决'局域网内发给哪个设备'的问题。核心协议:以太网协议、无线 LAN 协议。关键标识:MAC 地址(48 位),网卡出厂固化的唯一标识。Linux 中实现:由网卡驱动程序实现。 物理层:负责'光/电信号传输',是数据的物理载体。核心载体:网线、光纤、电磁波。Linux 中实现:完全由硬件实现。
网络协议被划分成了 TCP/IP 协议栈:应用、传输、网络、数据链路层。而 TCP/IP 协议栈主要由操作系统来实现,因此二者存在如下关系:协议栈其实是在操作系统里面。

学习通信过程之前,我们需要先知道报文:报文=报头(每层的识别标志)+ 有效载荷(数据内容)。 以'你好'为例:消息从发出到网卡,需要经过四大层,每层将上一层打包的数据增加报头,就形成了该层的报文。

MAC 地址:每个网卡自出厂开始设置有唯一的编号,这是全球内的。 局域网:小区域的计算机网络范围。 以太网:一种网络通信技术规定,任何时刻,只允许一台机器向网络中发送数据。
完成了上面的四层,此时需要经过网卡来完成信息传递:当数据被打包完成后会经过网卡发送到局域网:数据不是准确发送的,它需要一个个访问。

如果对方不是目标 MAC,途中被访问到的网卡也会自动摒弃,继续寻找。 如果对方是目标 MAC,就被对方网卡接收,开始逐层解包。
逆向开始逐层解包。
IP 地址:每个设备在全球的唯一通信地址(例如:192.168.1.101,传输中不会变)。 MAC 地址:MAC 在远距离传输的时候会变换为途中的中间设备的网卡 MAC 地址。 端口号:用于区分一台计算机上不同的应用程序。 路由器:连接不同网络的设备。
当数据在网卡中准备发送时,应该是如何样子:
注意:MAC 在传输的过程中,会随着接触到的网卡逐渐改变,但是自己 IP 和目标 IP 不会。

内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,就可能因为大小端的问题导致读取数据不一致的问题。为了统一数据读取方式,网络中规定在应用层传入的数据必须是大端。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
在网络协议中,分为了 TCP 和 UDP 两套协议,UDP 是面向非连接的,下面我们学习 UDP 协议。 如果你听见'网络套接字',其实就是'IP 地址 + 端口号 + 协议'的总称,即网络通信相关。 既然网络协议是在操作系统里面,那我们自己要实现通信就必须使用系统调用系列接口:socket。
原型:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数:
AF_INET: IPv4 互联网协议AF_INET6: IPv6 互联网协议AF_UNIX 或 AF_LOCAL: 本地通信(进程间通信)SOCK_STREAM: 流式套接字,提供可靠、面向连接的通信(对应 TCP)SOCK_DGRAM: 数据报套接字,提供不可靠、无连接的通信(对应 UDP)SOCK_RAW: 原始套接字,允许直接访问底层协议(如 IP、ICMP)0,表示根据 domain 和 type 自动选择默认协议。返回值:
-1,并设置 errno。作用:创建一个用于网络通信的端点。
原型:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
socket() 函数返回的套接字文件描述符。struct sockaddr 类型的指针,该结构体包含了要绑定的 IP 地址和端口号等信息。
struct sockaddr_in 结构体,并强制转换为 struct sockaddr *。struct sockaddr_in6。addr 指向的结构体的大小。返回值:
0。-1,并设置 errno。作用:将一个套接字与特定的 IP 地址和端口号绑定。
原型:
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
0,表示默认行为)。dest_addr 结构体的大小。返回值:
-1,并设置 errno。作用:给指定的 IP 和端口发送数据。
原型:
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数:
0,默认行为)。socklen_t 类型的变量地址(变量等于存储'寄件人'结构体大小)。返回值:
-1,并设置 errno。作用:接收对方发送的数据。
作为服务端,它的 IP 和端口是固定的,所以我们需要:创建套接字->绑定->(自选)发送 || 接收。
//创建套接字
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if(fd == -1) {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "错误码:%d, 错误原因:%s", errno, strerror(errno));
exit(1);
} else {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "socket is successful!");
}
IP 设置:由于部分设备有两个网卡,所以如果我们绑定了其中一个,就接收不到另外的一个了,因此这里我们实验就采用 INADDR_ANY,允许所有人访问。 端口设置:同时端口需要自己使用 1024 以上的端口(避免冲突和权限问题)。
//绑定 IP 和端口
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(8000);
memset(&local.sin_zero, 0, sizeof(local.sin_zero));
int bd = bind(fd, (struct sockaddr *)&local, sizeof(local));
if(bd == -1) {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "错误码:%d, 错误原因:%s", errno, strerror(errno));
exit(1);
} else {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "bind is successful!");
}
接收数据我们就设置为循环,因为服务器都是随时打开供客户访问的:
//接收数据
char buffer[1024] = {'\0'};
struct sockaddr_in local;
socklen_t sz = sizeof(local);
while(1) {
ssize_t re = recvfrom(fd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)&local, &sz);
if(re == -1) // 根据 -1 来判断是错误还是没有数据
{
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 没有数据,稍等片刻再试
printf("暂时没有数据,等待...\n");
sleep(1);
} else {
// 发生其他错误,处理错误
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "错误码:%d, 错误原因:%s", errno, strerror(errno));
exit(1);
}
} else {
buffer[re] = 0;
std::cout << "读取到数据:" << buffer << std::endl;
}
}
你可以在接收到对方发送的内容之后,直接返回信息给对方,自行选择即可。
此时接收到数据,就卡在"recvfrom()"那里了,很正常。
客户端需要向服务端发送,所以客户端需要知道对方的 IP 和端口,这里就需要给 main 传参。
和服务端实现一样:
//创建套接字
int cd = socket(AF_INET, SOCK_DGRAM, 0);
if(cd == -1) {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "错误码:%d, 错误原因:%s", errno, strerror(errno));
exit(1);
} else {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "socket is successful!");
}
bind 无非就是解决 IP 和端口的绑定问题,下面我们看为什么一般不需要绑定: 下面的 IP 分配和端口设置都为 0 其实是操作系统隐藏式的 bind(OS 自动选择 bind)。 IP:向对方发送信息,我们只需要知道对方的地址即可,一般选择 0(网络分配)。 端口:一般选择 0,由操作系统自己分配,若自己绑定,可能造成当前端口正在被使用,bind 失败。
//发送数据
const char* ptr = "Hello,你吃了吗!";
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_addr.s_addr = inet_addr(argv[1]);
local.sin_port = htons(atoi(argv[2]));
ssize_t ac = sendto(cd, ptr, strlen(ptr), 0, (const sockaddr*)&local, sizeof(local));
if(ac == -1) {
log_message(LOG_LEVEL_ERROR, __FILE__, __LINE__, "错误码:%d, 错误原因:%s", errno, strerror(errno));
exit(1);
}
你可以在接收到对方回发的内容之后,接收打印查看。
现在我们同时运行客户端和服务端,同时打开公网 IP 端口(或者直接采用别的任意 IP)开始通信。

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online