网络套接字编程(UDP)
目录一、预备知识1.端口号的认识1.1为什么需要端口号1.2 什么是端口号1.3 端口号的分类1.4 端口号与IP地址的组合2.TCP传输控制协议2.1 核心特点2.2 典型应用3. UDP用户数据报协议3.1 核心特点3.2 典型应用4.网络字节序4.1 什么是字节序4.2 为什么需要网络字节序4.3 字节序转换函数5.socket编程接口5.1常用Socket API5.2 sockaddr 结构(⭐⭐⭐)5.2.1 struct sockaddr5.2.2 sockaddr_in 与 sockaddr_un5.2.3 强制类型转换二、简单的UDP网络程序1. 服务端实现1.1 制造通信设备socket()1.1.1 文件系统的fd1.1.2 socket返回值的fd(⭐⭐⭐)1.2 填充sockaddr_in local (⭐⭐⭐)1.2.1 bzero1.2.2 确定通信频道sin_family AF_INET1.2.3 端口号的“入乡随俗”htons(port_)1.2.4 IP 地址的双重转换inet_addr()1.2.5 INADDR_ANY1.3 宣誓主权bind() (⭐⭐⭐)1.4 阻塞接收recvfrom()1.5 原路返回sendto()2. 客户端实现2.1 制造通信设备socket()2.2 准备服务端的地址 (填充 sockaddr_in server)2.3 不需要显式 bind()(⭐⭐⭐)2.4 主动出击sendto()2.5 阻塞等待响应recvfrom()3. 代码结果示例3.1 netstat命令或ss(Socket Statistics)命令3.2 lsof命令3.3 通信结果示例4. 完整代码4.1 服务器端4.1.1 UdpServer.hpp4.1.2 main.cpp4.2 客户端一、预备知识1.端口号的认识1.1为什么需要端口号一台主机上可能同时运行着多个网络应用程序例如浏览器、邮件客户端、Web服务器。当数据包到达主机时操作系统需要知道该把它交给哪个进程。IP地址负责将数据包送到正确的主机而端口号则负责将数据包送到主机上正确的进程。1.2 什么是端口号端口号是一个16位的无符号整数取值范围是0 ~ 65535。它用于标识一台主机上的特定进程或服务。1.3 端口号的分类知名端口0 ~ 1023这些端口通常固定分配给一些常用的网络服务。例如80HTTP网页服务443HTTPS21FTP22SSH注册端口Registered Ports1024 ~ 49151用于用户自定义的应用程序但需向IANA注册。动态/私有端口Dynamic/Private Ports49152 ~ 65535一般作为客户端的临时端口由操作系统随机分配。1.4 端口号与IP地址的组合一个完整的通信端点由IP地址 端口号组成称为套接字地址Socket Address。例如192.168.1.100:8080表示IP为192.168.1.100的主机上的8080端口。2.TCP传输控制协议TCP是一种面向连接的、可靠的、基于字节流的传输层协议。2.1 核心特点面向连接通信前需要通过三次握手建立连接通信结束后通过四次挥手释放连接。可靠传输通过确认应答、超时重传、序号机制、校验和等保证数据无丢失、无重复、按序到达。基于字节流TCP将应用层数据视为无结构的字节流不保留消息边界。流量控制通过滑动窗口机制让发送方根据接收方的处理能力调整发送速度。拥塞控制当网络出现拥塞时TCP会自动降低发送速率避免网络瘫痪。全双工通信连接双方可以同时发送和接收数据。2.2 典型应用TCP适用于要求数据准确可靠的场景如文件传输FTP、网页浏览HTTP、电子邮件SMTP等。3. UDP用户数据报协议UDP是一种无连接的、不可靠的、基于数据报的传输层协议。3.1 核心特点无连接发送数据前不需要建立连接直接发送即可。不可靠不保证数据一定能到达也不保证顺序可能丢失、重复或乱序。基于数据报保留消息边界应用层每次写操作对应一个UDP数据报接收方必须按相同大小读取。开销小头部仅8字节TCP头部20字节无复杂的拥塞控制和流量控制传输效率高。支持广播和多播UDP可以一对多发送数据。3.2 典型应用UDP适用于实时性要求高、允许少量丢包的场景如视频直播、语音通话VoIP、DNS查询、网络游戏等。4.网络字节序4.1 什么是字节序字节序是指多字节数据在内存中的存放顺序。主要分为两种大端字节序Big-Endian高位字节存放在低地址低位字节存放在高地址。小端字节序Little-Endian低位字节存放在低地址高位字节存放在高地址。不同的主机可能采用不同的字节序例如x86架构使用小端ARM可配置网络设备多采用大端。4.2 为什么需要网络字节序当数据在不同字节序的主机之间传输时如果不统一规定接收方会错误地解释数据。因此TCP/IP协议规定网络传输必须使用大端字节序称为网络字节序。4.3 字节序转换函数在编写网络程序时我们需要将主机字节序转换为网络字节序或者反过来。这些函数通常由系统提供能自动处理主机字节序与网络字节序之间的转换。常用的函数有以C语言为例htons()host to network short16位htonl()host to network long32位ntohs()network to host short16位ntohl()network to host long32位5.socket编程接口5.1常用Socket API// 创建 socket 文件描述符 (TCP/UDP, 客户端 服务器) int socket(int domain, int type, int protocol); // 绑定端口号 (TCP/UDP, 服务器) int bind(int socket, const struct sockaddr *address, socklen_t address_len); // 开始监听socket (TCP, 服务器) int listen(int socket, int backlog); // 接收请求 (TCP, 服务器) int accept(int socket, struct sockaddr* address, socklen_t* address_len); // 建立连接 (TCP, 客户端) int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);socket()创建一个套接字相当于买一部手机。bind()绑定 IP 和端口号相当于给手机上卡拥有了属于自己的号码。listen()监听连接请求把手机开机随时准备接听电话主要用于 TCP。accept()接收连接接通电话准备开始交流。connect()发起连接主动拨打别人的号码。recv() / send()或read() / write()接收和发送数据。5.2 sockaddr 结构(⭐⭐⭐)5.2.1struct sockaddrstruct sockaddr { sa_family_t sa_family; // 地址家族 (Address Family)例如 AF_INET, AF_INET6, AF_UNIX占 2 字节 char sa_data[14]; // 具体的地址数据IP 端口占 14 字节 };无论是哪种协议前 2 个字节必须是sa_family告诉操作系统你用的是什么协议。操作系统看到这个标识后就知道该怎么解析后面的 14 个字节了。5.2.2sockaddr_in与sockaddr_un虽然 API 要求传入sockaddr但在实际写代码时直接操作那 14 字节的字符数组来拼凑 IP 和端口简直是反人类的。因此针对不同的协议系统提供了专门的、更易读的结构体1网络通信专用的 IPv4 地址结构struct sockaddr_instruct sockaddr_in { sa_family_t sin_family; // 必须是 AF_INET (2字节) in_port_t sin_port; // 16位端口号必须是网络字节序 (2字节) struct in_addr sin_addr; // 32位 IP 地址 (4字节) unsigned char sin_zero[8]; // 填充字段为了保证和 sockaddr 长度一致都是 16 字节 };2本地进程间通信专用的结构struct sockaddr_unstruct sockaddr_un { sa_family_t sun_family; // 地址族固定为 AF_UNIX char sun_path[108]; // 套接字文件路径 };5.2.3 强制类型转换// 1. 定义并初始化特定的 IPv4 地址结构 struct sockaddr_in local_addr; local_addr.sin_family AF_INET; local_addr.sin_port htons(8080); // 别忘了转网络字节序 local_addr.sin_addr.s_addr INADDR_ANY; // 2. 调用底层 API 时强转为通用的 sockaddr* 指针 bind(sockfd, (struct sockaddr*)local_addr, sizeof(local_addr));sockaddr就像是一个没有任何内部隔断的标准集装箱不管你是运苹果IPv4还是运香蕉IPv6只要装得进这个 16 字节的箱子并在箱子外面贴好标签sa_family海关操作系统底层的 Socket API就一视同仁地给你放行。而sockaddr_in就是为了方便我们往箱子里合理摆放苹果而专门设计的模具。二、简单的UDP网络程序1. 服务端实现服务端的生命周期分为三大步创建、绑定、死循环监听。1.1 制造通信设备socket()sockfd_ socket(AF_INET, SOCK_DGRAM, 0);参数 1 (AF_INET)域/协议族。表示使用 IPv4 网络协议。参数 2 (SOCK_DGRAM)套接字类型。DGRAM即 Datagram数据报明确表示我们要使用的是无连接、不可靠的 UDP 协议。参数 3 (0)默认协议。填 0 表示让系统根据前两个参数自动推导出 UDP 协议。返回值 (sockfd_)成功则返回一个非负整数这就是网络文件描述符。后续所有的收发操作全部基于这个数字。1.1.1 文件系统的fd1.1.2 socket返回值的fd(⭐⭐⭐)(1)Linux 内核将这个struct file的底层读写指针替换成了网络协议栈的函数。当我们向fd写入数据时数据并没有像普通文件那样流向磁盘而是流向了内核内存中的“发送缓冲区”。(2)struct file结构体的私有数据指针指向了一个特定的struct socket进而指向了struct sock。你发送的数据包进入了内核内存中的“发送缓冲区”。到这里sendto代码就已经返回成功了。数据被放进了邮局的邮箱。(3)隐藏在缓冲区后面的网络协议栈UDP/IP会通过一个接力跑的过程找到数据包该走的路由给它套上各种报头然后把数据交到网卡驱动程序的队列中。(4)网卡硬件驱动通过DMA直接内存访问技术不需要 CPU 参与直接从内核内存中把数据“吸”到网卡的物理芯片里转换成电信号从网线发射出去。1.2 填充sockaddr_in local(⭐⭐⭐)在创建完 Socket买好手机之后我们必须给它绑定一个网络地址上电话卡。在 C 网络编程中填充sockaddr_in结构体就是在这个“电话卡”上写明我们的 IP 和端口。struct sockaddr_in local; bzero(local, sizeof(local)); local.sin_family AF_INET; //需要保证我的端口号是网络字节序列因为该端口号是要给对方发送的 local.sin_port htons(port_); //(1)string - uint32_t (2)uint32_t必须是网络序列的 local.sin_addr.s_addr inet_addr(ip_.c_str()); // local.sin_addr.s_addr htonl(INADDR_ANY);1.2.1bzero在 C/C 中局部变量如果不初始化内存里存放的都是随机的“垃圾值”。为了防止这些脏数据干扰底层的网络绑定使用bzero或者memset将结构体内部的所有字节清零确保绝对干净。1.2.2 确定通信频道sin_family AF_INETAF_INET代表 IPv4 网络协议告诉系统接下来要按照 IPv4 的规则来解析后面的地址。1.2.3 端口号的“入乡随俗”htons(port_)网络世界规定所有在网络上传输的数据必须是大端字节序网络字节序。htonsHost to Network Short函数就是用来将我们主机上的 16 位端口号翻译成全网通用的网络字节序。1.2.4 IP 地址的双重转换inet_addr()我们日常习惯的 IP 是类似于192.168.1.1的点分十进制字符串。但计算机只认32 位的无符号整数 (uint32_t)。inet_addr()这个函数一次性帮我们做了两件事把人看的字符串 IP 转换成了底层的 32 位整型。顺手把这个 32 位整型转换成了网络字节序。1.2.5INADDR_ANY在真实的云服务器开发中服务端极其不建议绑定具体的公网 IP。INADDR_ANY的本质就是0即0.0.0.0代表任意地址。它的意思是“只要是发到我这台服务器 8080 端口的数据不管是从哪个网卡进来的我全盘接收”1.3 宣誓主权bind()(⭐⭐⭐)bind(sockfd_, (const struct sockaddr *)local, sizeof(local))把刚才填充好的sockaddr_in local结构体与sockfd_绑定在一起。参数 1刚刚创建的网络文件描述符。参数 2强转为struct sockaddr *。这是 C 语言时代的“多态”为了让bind函数能接收各种不同类型的协议地址。参数 3该结构体的大小。核心逻辑告诉操作系统“这台机器上的这个端口8080和这个 IP从现在起被我这个进程接管了外面发来的数据请交给我”1.4 阻塞接收recvfrom()ssize_t n recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)client, len);这是 UDP 接收数据的绝对核心它有 6 个参数sockfd从哪个套接字读数据。buf数据存放的缓冲区我们定义的字符数组。len期望读取的最大字节数 故意减 1 是为了给 C 风格字符串的结尾\0留出空间。flags填 0 表示如果网络没数据程序就在这里死等阻塞直到有数据到来。src_addr(输出型参数):操作系统在把数据给我们的同时会把发送方客户端的 IP 和端口提取出来塞进这个结构体里。addrlen(输入输出型参数)传入时表示缓冲区大小返回时表示实际填充的地址结构体大小。1.5 原路返回sendto()ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);处理完数据后我们要把结果发回去。同样是 6 个参数sockfd套接字文件描述符标识通过哪个网络端点发送数据。buf指向待发送数据的缓冲区。len要发送的数据长度以字节为单位。flags发送选项的标志位。dest_addr:指向目标地址结构体的指针。直接把刚才recvfrom截获的client结构体填进去实现“从哪儿来回哪儿去”。addrlen(输入输出型参数)目标地址结构体的大小。2. 客户端实现客户端的生命周期相对简单创建套接字、准备服务端地址、发送数据、接收响应。它不需要死等别人想什么时候发就什么时候发。2.1 制造通信设备socket()int sockfd socket(AF_INET, SOCK_DGRAM, 0);同服务端一样客户端也需要一个网络文件描述符作为通信的凭证。参数与服务端完全一致。2.2 准备服务端的地址 (填充 sockaddr_in server)客户端发送数据前必须知道“发给谁”。因此需要提前将目标服务端的 IP 和端口填充到地址结构体中这相当于提前写好快递的收件地址。struct sockaddr_in server; bzero(server, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(serverport); // 转换为网络字节序 server.sin_addr.s_addr inet_addr(serverip.c_str()); // 字符串IP转32位整型并转网络序列 socklen_t len sizeof(server);2.3 不需要显式 bind()(⭐⭐⭐)在客户端代码中并没有像服务端那样调用bind()函数。客户端其实也需要绑定 IP 和端口只不过不需要程序员在代码中显式调用。服务端的端口必须明确服务端是被动接收请求的它的端口必须固定且广为人知因为大家都需要找它。客户端的端口无需固定客户端的端口是多少其实不重要只要能保证在本机上的唯一性即可。如果程序员写死客户端端口例如强行绑死 8888万一该端口被电脑上的其他软件占用客户端就会启动失败。操作系统的隐式绑定当客户端第一次调用sendto发送数据时操作系统会在底层随机挑选一个空闲的高位端口自动与该套接字进行绑定。2.4 主动出击sendto()sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)server, len);客户端像是一个带着明确目标地址的快递员主动向外发送请求。2.5 阻塞等待响应recvfrom()struct sockaddr_in temp; socklen_t len sizeof(temp); ssize_t s recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)temp, len);发送完毕后客户端调用recvfrom陷入阻塞静静等待服务端的处理结果顺着网线传回来。3. 代码结果示例3.1netstat命令或ss(Socket Statistics)命令netstat -nlup ss -nlup-n(numeric)以数字形式显示 IP 和端口拒绝将 80 显示为 http拒绝解析域名速度更快。-l(listening)只显示正在“监听”状态的套接字你的服务器跑起来后就是这个状态。-u(udp)只显示UDP协议的网络状况。如果是查 TCP就把u换成t即-nltp。-p(program)显示是哪个进程PID 和进程名占用了这个端口。看其他人的进程可能需要sudo权限。3.2 lsof命令lsof -i :8080这条命令会直接列出占用该端口的进程名和 PID。拿到 PID 后如果你确定那个进程没用了就可以直接kill -9 PID把它干掉然后重新启动你的UdpServer。3.3 通信结果示例4. 完整代码4.1 服务器端4.1.1 UdpServer.hpp#pragma once #include iostream #include string #include strings.h #include cstring #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include functional #include Log.hpp using func_t std::functionstd::string(const std::string); Log lg; enum{ SOCKET_ERR1, BIND_ERR }; uint16_t defaultport 8080; std::string defaultip 0.0.0.0; //任意地址绑定 const int size 1024; class UdpServer{ public: UdpServer(const uint16_t port defaultport, const std::string ip defaultip):sockfd_(0), port_(port), ip_(ip),isrunning_(false) {} void Init() { // 1. 创建udp socket sockfd_ socket(AF_INET, SOCK_DGRAM, 0); // PF_INET if(sockfd_ 0) { lg(Fatal, socket create error, sockfd: %d, sockfd_); exit(SOCKET_ERR); } lg(Info, socket create success, sockfd: %d, sockfd_); // 2. bind socket struct sockaddr_in local; bzero(local, sizeof(local)); local.sin_family AF_INET; local.sin_port htons(port_); //保证端口号是网络字节序列 local.sin_addr.s_addr inet_addr(ip_.c_str()); // local.sin_addr.s_addr htonl(INADDR_ANY); if(bind(sockfd_, (const struct sockaddr *)local, sizeof(local)) 0) { lg(Fatal, bind error, errno: %d, err string: %s, errno, strerror(errno)); exit(BIND_ERR); } lg(Info, bind success, errno: %d, err string: %s, errno, strerror(errno)); } void Run(func_t func) { isrunning_ true; char inbuffer[size]; while(isrunning_) { struct sockaddr_in client; socklen_t len sizeof(client); ssize_t n recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr*)client, len); if(n 0) { lg(Warning, recvfrom error, errno: %d, err string: %s, errno, strerror(errno)); continue; } inbuffer[n] 0; std::string info inbuffer; std::string echo_string func(info); sendto(sockfd_, echo_string.c_str(), echo_string.size(), 0, (const sockaddr*)client, len); } } ~UdpServer() { if(sockfd_0) close(sockfd_); } private: int sockfd_; // 网路文件描述符 std::string ip_; // 任意地址bind 0 uint16_t port_; // 表明服务器进程的端口号 bool isrunning_; };4.1.2 main.cpp#include UdpServer.hpp #include memory #include cstdio void Usage(std::string proc) { std::cout \n\rUsage: proc port[1024]\n std::endl; } std::string Handler(const std::string str) { std::string res Server get a message: ; res str; std::cout res std::endl; return res; } int main(int argc, char *argv[]) { if(argc ! 2) { Usage(argv[0]); exit(0); } uint16_t port std::stoi(argv[1]); std::unique_ptrUdpServer svr(new UdpServer(port)); svr-Init(); svr-Run(Handler); return 0; }4.2 客户端#include iostream #include cstdlib #include unistd.h #include strings.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h using namespace std; void Usage(std::string proc) { std::cout \n\rUsage: proc serverip serverport\n std::endl; } // ./udpclient serverip serverport int main(int argc, char *argv[]) { if (argc ! 3) { Usage(argv[0]); exit(0); } std::string serverip argv[1]; uint16_t serverport std::stoi(argv[2]); struct sockaddr_in server; bzero(server, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(serverport); //? server.sin_addr.s_addr inet_addr(serverip.c_str()); socklen_t len sizeof(server); int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { cout socker error endl; return 1; } string message; char buffer[1024]; while (true) { cout Please Enter ; getline(cin, message); sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)server, len); struct sockaddr_in temp; socklen_t len sizeof(temp); ssize_t s recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)temp, len); if(s 0) { buffer[s] 0; cout buffer endl; } } close(sockfd); return 0; }