1.前置知识
1.网络中的地址管理- 认识IP 地址
IP 协议有两个版本, IPv4 和IPv6. 我们整个文章, 凡是提到IP 协议, 没有特殊说明的,默认都是指IPv4
• IP 地址是在IP 协议中, 用来标识网络中不同主机的地址;
• 对于IPv4 来说, IP 地址是一个4 字节, 32 位的整数;
• 我们通常也使用**"点分十进制" 的字符串表示IP 地址, 例如192.168.0.1** ; 用点分割的每一个数字表示一个字节, 范围是0 - 255;
2. 认识端口号
端口号(port)是传输层协议的内容.
• 端口号是一个2 字节16 位的整数;
• 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
• IP 地址+ 端口号能够标识网络上的某一台主机的某一个进程;
• 一个端口号只能被一个进程占用.
一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定;

端口号范围划分
• 0 - 1023: 知名端口号, HTTP, FTP, SSH 等这些广为使用的应用层协议, 他们的端口号都是固定的.
• 1024 - 65535: 操作系统动态分配的端口号. 客户端程序的端口号, 就是由操作系统从这个范围分配的.
理解"端口号" 和"进程ID"
我们之前在学习系统编程的时候, 学习了pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这两者之间是怎样的关系? pid 和端口号是一样的功能,不过一个属于系统,一个属于网络,分别是两套不同体系
• 进程ID 属于系统概念,技术上也具有唯一性,确实可以用来标识唯一的一个进程,但是这样做,会让系统进程管理和网络强耦合,实际设计的时候,并没有选择这样做。
3.理解socket
• 综上,IP 地址用来标识互联网中唯一的一台主机,port 用来标识该主机上唯一的一个网络进程
• IP+Port 就能表示互联网中唯一的一个进程
• 所以,网络通信的本质,也是进程间通信
• 我们把ip+port 叫做套接字socket
4. 网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
• 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
• 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
• 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
• TCP/IP 协议规定,网络数据流应采用大端字节序,即低地址高字节.
• 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP 规定的网络字节序来发送/接收数据;
• 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;

为使网络程序具有可移植性,使同样的C 代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。

• 这些函数名很好记,h 表示host,n 表示network,l 表示32 位长整数,s 表示16 位短整数。
• 例如htonl 表示将32 位的长整数从主机字节序转换为网络字节序,例如将IP 地址转换后准备发送。
• 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
• 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回
补充函数:
inet_addr
inet_addr函数用于将点分十进制格式的 IPv4 地址字符串转换为 32 位网络字节序的整数。
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);
//cp:以 NULL 结尾的点分十进制 IPv4 地址字符串(如 "192.168.1.1")
返回值
成功:返回 32 位网络字节序的 IPv4 地址
失败:返回 INADDR_NONE(通常为 -1)
inet_ntoa
inet_ntoa函数用于将IPv4地址从网络字节序的二进制形式转换为点分十进制的字符串形式。
cpp
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);
//in:一个 struct in_addr 结构体,包含要转换的32位IPv4地址(网络字节序)
返回值
成功:返回指向点分十进制字符串的静态缓冲区指针
失败:返回NULL
2.有关接口熟悉
本次编程均采用IPv4类型
socket
在Linux系统中,socket() 函数是网络编程的核心,用于创建通信端点
cpp
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数详解:
- domain(协议族/地址族)
指定通信域,决定socket的地址格式:
cpp
// IPv4 因特网协议
AF_INET 或 PF_INET
// 本地通信(UNIX域socket)
AF_UNIX 或 AF_LOCAL
- type(socket类型)
指定通信语义:
cpp
// 面向连接的可靠字节流(TCP)
SOCK_STREAM
// 无连接不可靠数据报(UDP)
SOCK_DGRAM
- protocol(具体协议)
系统会根据__type的值自行选择,因此该项一般可直接指定为0。
protocol为0时,会自动选择type类型对应的默认协议。
cpp
// TCP协议
IPPROTO_TCP
// UDP协议
IPPROTO_UDP
// ICMP协议
IPPROTO_ICMP
4.返回值
成功:返回socket文件描述符(非负整数)
失败:返回-1,并设置errno
在Linux中一切皆文件,网络通信也是文件通信
bind
cpp
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind函数用于将 socket 与特定的 IP 地址和端口号绑定,是服务器端编程的关键步骤。
参数详解
- sockfd
socket 描述符,由 socket 函数创建
- addr
指向 sockaddr 结构体的指针,包含要绑定的地址信息
- addrlen
addr 结构体的大小
返回值
成功:返回 0
失败:返回 -1,并设置 errno
sockaddr 结构:
socket API 是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6 然而, 各种网络协议的地址格式并不相同.

• IPv4 和IPv6 的地址格式定义在**<netinet/in.h>** 中,IPv4 地址用sockaddr_in 结构体表示,包括16 位地址类型, 16 位端口号和32 位IP 地址.
• IPv4、IPv6 地址类型分别定义为常数AF_INET、AF_INET6. 这样,只要取得某种sockaddr 结构体的首地址,不需要知道具体是哪种类型的sockaddr 结构体,就可以根据地址类型字段确定结构体中的内容.
• socket API 可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化 成sockaddr_in; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX DomainSocket 各种类型的sockaddr 结构体指针做为参数;
sockaddr_in 结构
cpp
struct sockaddr_in {
sa_family_t sin_family; // 地址族:AF_INET
in_port_t sin_port; // 端口号(网络字节序)
struct in_addr sin_addr; // in_addr 用来表示一个IPv4 的IP 地址. 其实就是一个32 位的整数
unsigned char sin_zero[8]; // 填充字段只是为了大小对齐,不用管
};
struct in_addr {
uint32_t s_addr; // 32位IPv4地址(网络字节序)
};
虽然socket api 的接口是sockaddr, 但是我们真正在基于IPv4 编程时, 使用的数据结构是sockaddr_in; 这个结构里主要有三部分信息: 地址类型, 端口号, IP 地址.
重要注意事项:
端口选择:
0-1023:特权端口,需要root权限
1024-49151:注册端口
49152-65535:动态/私有端口
字节序:端口号必须使用 htons 转换为网络字节序
IP地址:
INADDR_ANY(0):绑定所有网络接口
特定IP:只绑定指定接口
补充知识:
在 Linux 网络编程中,客户端通常不需要显式地 bind 自己的 IP 和端口
常规情况:不需要显式 bind
内核会自动处理:
IP 地址:自动选择出站网卡的 IP
端口号:从临时端口范围自动分配(通常 32768-60999)
recvfrom
recvfrom 函数用于从 socket 接收数据,并获取发送方的地址信息。主要用于无连接的 socket 类型(如UDP)。
cpp
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数详解
- sockfd
socket 描述符,必须是已绑定的数据报或原始 socket
- buf
指向接收缓冲区的指针,用于存放接收到的数据
- len
接收缓冲区的最大长度
- flags
接收标志,控制接收行为:默认为0,阻塞接收
- src_addr
输出型参数
指向socaddr结构体的指针,用于存储发送方地址
如果为 NULL,则不返回发送方地址
- addrlen
输入输出参数
输入时指定 src_addr 缓冲区的长度
输出时返回实际存储的地址长度
返回值
成功 :返回接收到的字节数
失败:返回 -1,并设置 errno
连接关闭:返回 0(对于面向连接的协议)
sendto
sendto函数用于通过 socket 发送数据到指定地址,主要用于无连接的 socket 类型(如 UDP)。
cpp
#include <sys/types.h>
#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);
参数详解
- sockfd
socket 描述符
- buf
指向发送数据缓冲区的指针
- len
要发送数据的长度(字节数)
- flags
发送标志,控制发送行为:默认为0,阻塞发送
- dest_addr
指向目标地址的 socaddr 结构体指针
- addrlen
目标地址结构体的长度
返回值
成功:返回实际发送的字节数
失败:返回 -1,并设置 errno
2.实例
cpp
.PHONY:all
all:UdpServer UdpClient
UdpServer:UdpServer.cc
g++ -o $@ $^ -std=c++17
UdpClient:UdpClient.cc
g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
rm -f UdpClient UdpServer
cpp
#pragma once
#include<iostream>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include <arpa/inet.h>
#include<string.h>
using namespace std;
class UdpServer
{
public:
UdpServer(string ip,uint16_t port)
:_ip(ip),
_port(port),
_socketfd(-1),
_isrunning(false)
{}
void Init()
{
// 1. 创建socket fd
_socketfd=socket(AF_INET,SOCK_DGRAM,0);
if(_socketfd<0)
{
cout<<"socket fail"<<endl;
exit(1);
}
cout<<"creat socket sucess"<<endl;
// 2. bind
// 2.1: 填充IP和Port
// 该步骤中我们没有实现把socket和file关联起来,只是在内存中定义了一个结构体
struct sockaddr_in local;
//清空结构体
bzero(&local,sizeof(local));
local.sin_addr.s_addr=inet_addr(_ip.c_str());
local.sin_family=AF_INET;
local.sin_port=htons(_port);
// 2.2 和socketfd进行bind
//将sockaddr_in中的信息写入内核
int n=bind(_socketfd,(struct sockaddr*)&local,sizeof(local));
if(n<0)
{
cout<<"bind fail"<<endl;
exit(1);
}
cout<<"bind socket sucess"<<endl;
}
void Start()
{
//所有的服务器都是死循环,一直在运行
_isrunning=true;
while(_isrunning)
{
//定义一个缓冲区从 socket 接收数据
char buffer[1024];
//缓冲区清零
buffer[0]=0;
//当我们收到消息时我们也应该知道消息的发送方是谁以便之后给它回消息
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
// 1. 读取数据
ssize_t n=recvfrom(_socketfd,buffer,sizeof(buffer),0,(struct sockaddr*)&peer,&len);
if(n<0)
{
cout<<"recvfrom fail"<<endl;
_isrunning=false;
exit(1);
}
buffer[n]=0;
string clientip=inet_ntoa(peer.sin_addr);
uint16_t clientpot=ntohl(peer.sin_port);
cout<<"["<<clientip<<":"<<clientpot<<"]# "<<buffer<<endl;
//2.发送数据
string echo_string="server echo# ";
echo_string+=buffer;
sendto(_socketfd,echo_string.c_str(),echo_string.size(),0,(sockaddr*)&peer,sizeof(peer));
}
}
~UdpServer(){}
void Stop()
{
_isrunning=false;
}
private:
int _socketfd;
uint16_t _port;
std::string _ip;
//设一个标志看服务器是否在运行
bool _isrunning;
};
cpp
#include"UdpServer.hpp"
#include<memory>
// ./udp_server serverip serverport
int main(int argc, char *argv[])
{
if(argc != 3)
{
cout<<"格式错误"<<endl;
cout<<argv[0]<<" server ip"<<" server port"<<endl;
exit(1);
}
string ip=argv[1];
uint16_t port =stoi(argv[2]);
unique_ptr<UdpServer> usvr = make_unique<UdpServer>(ip,port);
usvr->Init();
usvr->Start();
return 0;
}
cpp
#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
using namespace std;
// ./udpclient server_ip server_port
int main(int argv, char *argc[])
{
if (argv != 3)
{
cout << "格式错误" << endl;
cout << argc[0] << " server ip" << " server port" << endl;
exit(1);
}
string server_ip = argc[1];
uint16_t server_port = stoi(argc[2]);
struct sockaddr_in server;
socklen_t len=sizeof(server);
bzero(&server,sizeof(server));
server.sin_family=AF_INET;
server.sin_addr.s_addr=inet_addr(server_ip.c_str());
server.sin_port=htons(server_port);
// 创建socket fd
int socketfd = socket(AF_INET, SOCK_DGRAM, 0);
if(socketfd<0)
{
cout<<"socket fail"<<endl;
exit(1);
}
cout<<"socket success"<<endl;
// 客户端通常不需要显式地 bind 自己的 IP 和端口
while(true)
{
cout<<"please@ ";
string line;
getline(cin,line);
//写
sendto(socketfd,line.c_str(),line.size(),0,(struct sockaddr*)&server,sizeof(server));
//读
char buffer[1024];
buffer[0]=0;
ssize_t n=recvfrom(socketfd,buffer,sizeof(buffer),0,(struct sockaddr*)&server,&len);
if(n<0)
{
cout<<"recvfrom fail"<<endl;
exit(1);
}
buffer[n]=0;
cout<<buffer<<endl;
}
return 0;
}

反例:

• 云服务器不允许直接bind 公有IP,我们也不推荐编写服务器的时候,bind 明确的IP,推荐直接写成INADDR_ANY(0.0.0.0)
cpp
#define INADDR_ANY ((in_addr_t) 0x00000000)
现代服务器通常有多个网络接口,绑定到 INADDR_ANY可以让服务在所有网络接口上监听。
在网络编程中,当一个进程需要绑定一个网络端口以进行通信时,可以使用INADDR_ANY 作为IP 地址参数。这样做意味着该端口可以接受来自任何IP 地址的连接请求,无论是本地主机还是远程主机。例如,如果服务器有多个网卡(每个网卡上有不同的IP 地址),使用INADDR_ANY 可以省去确定数据是从服务器上具体哪个网卡/IP 地址上面获取的。
如果服务器绑定了明确的IP,那么客户端只能访问该明确的IP接口,而不能访问其他任何接口(即使其他的IP接口也是这个主机上的)


修改:服务器不bind 明确的IP,直接写成INADDR_ANY
cpp
#pragma once
//...
class UdpServer
{
public:
UdpServer( uint16_t port)
: _port(port),
_socketfd(-1),
_isrunning(false)
{
}
void Init()
{
//...
// 2. bind
// 2.1: 填充IP和Port
// 该步骤中我们没有实现把socket和file关联起来,只是在内存中定义了一个结构体
struct sockaddr_in local;
// 清空结构体
bzero(&local, sizeof(local));
local.sin_addr.s_addr = htonl(INADDR_ANY);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
//...
private:
int _socketfd;
uint16_t _port;
// std::string _ip;
// 设一个标志看服务器是否在运行
bool _isrunning;
};
cpp
#include"UdpServer.hpp"
#include<memory>
// ./udp_server serverport
int main(int argc, char *argv[])
{
if(argc != 2)
{
cout<<"格式错误"<<endl;
cout<<argv[0]<<" server port"<<endl;
exit(1);
}
uint16_t port =stoi(argv[1]);
unique_ptr<UdpServer> usvr = make_unique<UdpServer>(port);
usvr->Init();
usvr->Start();
return 0;
}
UdpClient.cc不变
