目录
一、预备知识
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 API
5.2 sockaddr 结构(⭐⭐⭐)
5.2.1 struct sockaddr
5.2.2 sockaddr_in 与 sockaddr_un
5.2.3 强制类型转换
二、简单的UDP网络程序
1. 服务端实现
1.1 制造通信设备:socket()
1.1.1 文件系统的fd
1.1.2 socket返回值的fd(⭐⭐⭐)
1.2 填充sockaddr_in local (⭐⭐⭐)
1.2.1 bzero
1.2.2 确定通信频道:sin_family = AF_INET
1.2.3 端口号的“入乡随俗”:htons(port_)
1.2.4 IP 地址的双重转换:inet_addr()
1.2.5 INADDR_ANY
1.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.hpp
4.1.2 main.cpp
4.2 客户端
一、预备知识
1.端口号的认识
1.1为什么需要端口号?
一台主机上可能同时运行着多个网络应用程序(例如浏览器、邮件客户端、Web服务器)。当数据包到达主机时,操作系统需要知道该把它交给哪个进程。IP地址负责将数据包送到正确的主机,而端口号则负责将数据包送到主机上正确的进程。
1.2 什么是端口号?
端口号是一个16位的无符号整数,取值范围是0 ~ 65535。它用于标识一台主机上的特定进程或服务。
1.3 端口号的分类
知名端口:0 ~ 1023,这些端口通常固定分配给一些常用的网络服务。例如:
80:HTTP(网页服务)
443:HTTPS
21:FTP
22:SSH
注册端口(Registered Ports):1024 ~ 49151,用于用户自定义的应用程序,但需向IANA注册。
动态/私有端口(Dynamic/Private Ports):49152 ~ 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 short(16位)
htonl():host to network long(32位)
ntohs():network to host short(16位)
ntohl():network to host long(32位)
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 sockaddr
struct 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_in
struct 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_un
struct 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 文件系统的fd
1.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_INET
AF_INET代表 IPv4 网络协议,告诉系统接下来要按照 IPv4 的规则来解析后面的地址。
1.2.3 端口号的“入乡随俗”:htons(port_)
网络世界规定:所有在网络上传输的数据,必须是大端字节序(网络字节序)。
htons(Host 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::function<std::string(const std::string&)>; Log lg; enum{ SOCKET_ERR=1, 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_ptr<UdpServer> 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; }