目录
预备知识
理解源ip地址和目的ip地址
认识端口号
理解"端口号"和"进程ID"
认识TCP和UDP协议
网络字节序
socket编程接口
socket常见API
socket
bind
listen
accept
conncet
sockaddr与sockaddr_in
socket的使用(简易UDP网络程序的编写)
udp_server.hpp编写
udp_server.cc编写
udp_client.cc编写
预备知识
理解源ip地址和目的ip地址
在ip层报头,有两个ip地址,分别叫源ip地址和目的ip地址,这相关的内容包括mac地址,我在前一章详细的说明了,可以点击这里,拉到最后一个模块就是:网络基础知识
但是我们仔细想一下,我们只有ip地址就可以完成通信了吗?比如我们qq发送消息的例子,有了对方的ip地址可以发送对方的机器上,但是我们不是想给对方的机器识别,而是想给对方电脑上的qq程序,所以我们还需要一个标识(端口号)来区分,这个数据要给哪一个程序解析。
所以网络通信过程,本质是还是进程间通信! 将数据在主机间转发仅仅是手段,机器收到之后,需要将数据交付给指定的进程。
如何知道交给对应的哪一个进程呢,需要用端口号来区分。
认识端口号
端口号(port)是在网络通信中使用的一个16位数字(0-65536),用于标识计算机或网络设备上运行的特定应用程序或服务。在TCP/IP协议中,端口号用于将传输层的数据包分发到相应的应用程序或服务。
有了端口号,我们就可以利用IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。
一个端口号只能被一个进程占用。而一个进程可以绑定多个端口号。
通过将源IP地址、目标IP地址和端口号组合在一起,网络通信中的数据包可以正确地路由到目标应用程序或服务。源端口号用于回复数据包时将数据包返回到正确的发送者。源ip地址和目标IP地址与源端口号和目标端口号的组合构成了网络中唯一的终点,也被称为套接字(Socket)。
理解"端口号"和"进程ID"
我们之前学习系统编程的时候,也知道用pid来表示唯一一个进程,而端口号也用来标识唯一一个进程,它们两个有什么关系呢?
1.其实两个没什么区别,都是用来标识进程的唯一性,用进程pid来替代端口号也是完成 可以做到的,但是没这个必要。因为进程pid是属于进程这一范畴,而端口是属于网络范畴,我们如果将端口号改为使用pid,会使两者糅合在一在,造成代码的可维护性变差。而单独使用端口,便和进程完成了解耦,更便于我们以后的操作和维护。
2.还有并不是以后进程一定需要端口号,但是每个进程都必须要有pid.
认识TCP和UDP协议
此处我们先对TCP(Transmission Control Protocol 传输控制协议)和UDP(User Datagram Protocol 用户数据报协议).有一个直观的认识; 后面我们再详细讨论TCP和UDP的一些细节问题.
TCP有以下四个特点:
- 传输层协议
- 有连接
- 可靠传输
- 面向字节流
UDP有以下四个特点:
- 传输层协议
- 无连接
- 不可靠传输
- 面向数据报
这里注意,可靠传输和不可靠传输只是两个各自的特点,不存在好坏之说。TCP虽然保证了可靠传输,但是由于大量的复杂实现,导致传输速率不如UDP。在实时领域,如我们平常看的直播基本上采用的是UDP协议,速度较快,以提供较低的延迟和更好的实时性能。而TCP由于会有各种错误检测及机制,会使得延迟较高等弊处。
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?
比如A机器想给B机器发送一段数据,但A机器是小段存储,B机器是大端存储,当B读取A发送的数据时,将会得到错误的数据.因此为了避免这样的麻烦:
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节
- 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
- 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
- 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
- TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
- 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
- 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
htonl
:“Host to Network Long”,用于将 32 位(4 字节)的主机字节序整数转换为网络字节序(大端字节序)。它接受一个无符号长整型参数,并返回转换后的结果。
htons
:“Host to Network Short”,用于将 16 位(2 字节)的主机字节序短整数转换为网络字节序(大端字节序)。它接受一个无符号短整型参数,并返回转换后的结果。
ntohl
:“Network to Host Long”,用于将 32 位的网络字节序整数转换为主机字节序。它接受一个无符号长整型参数,并返回转换后的结果。
ntohs
:“Network to Host Short”,用于将 16 位的网络字节序短整数转换为主机字节序。它接受一个无符号短整型参数,并返回转换后的结果。
htonl
和htons
函数用于将主机字节序的整数转换为网络字节序,以便发送给其他计算机。而ntohl
和ntohs
函数用于将接收到的网络字节序整数转换为主机字节序,以便在本地计算机上进行处理。
socket编程接口
socket常见API
-
socket
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器) int socket(int domain, int type, int protocol);
- domain:指定套接字的协议族,如AF_INET(IPv4)或AF_INET6(IPv6)。
- type:指定套接字的类型,如SOCK_STREAM(面向连接的流套接字)或SOCK_DGRAM(无连接的数据报套接字)。
- protocol:指定使用的传输协议,通常可以设置为0以自动选择合适的协议。
- 作用:创建一个套接字,将domain、type和protocol参数传递给socket()函数以指定套接字的特性,并返回一个套接字描述符,用于后续的操作。
-
bind
//绑定端口号 (TCP/UDP, 服务器) int bind(int socket, const struct sockaddr *address, socklen_t address_len);
- sockfd:要绑定的套接字描述符。
- addr:指向要绑定的套接字地址的结构体指针,可以是struct sockaddr、struct sockaddr_in或struct sockaddr_in6等类型的指针。
- addrlen:指定addr结构体的大小。
- 作用:将给定的套接字描述符和地址绑定在一起,使套接字可以通过特定的地址和端口号进行通信。
-
listen
// 开始监听socket (TCP, 服务器) int listen(int socket, int backlog);
- socket:要设置为监听状态的套接字描述符。
- backlog:指定等待连接队列的最大长度,用于限制同时可以等待处理的连接请求的数目。
- 作用:将套接字设置为监听状态,开始接受客户端的连接请求。通过指定backlog参数,可以控制连接队列的长度。
-
accept
// 接收请求 (TCP, 服务器) int accept(int socket, struct sockaddr* address, socklen_t* address_len)
- sockfd:监听 套接字描述符。
- addr:用于存储客户端的地址信息的结构体指针,可以是struct sockaddr、struct sockaddr_in或struct sockaddr_in6等类型的指针。
- addrlen:指向一个整数变量,用于传递addr结构体的大小,并在接受连接后更新为实际的地址长度。
- 作用:等待并接受客户端的连接请求,并返回一个新的套接字描述符,该描述符用于与客户端进行通信。同时,可以获取客户端的地址信息
-
conncet
// 建立连接 (TCP, 客户端) int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- sockfd:要进行连接的套接字描述符。
- addr:要连接的目标地址的结构体指针,可以是struct sockaddr、struct sockaddr_in或struct sockaddr_in6等类型的指针。
- addrlen:指定addr结构体的大小。
- 作用:与另一个套接字建立连接,通常用于客户端连接服务器。通过指定目标地址和端口号,使套接字能够与目标进行通信。
sockaddr与sockaddr_in
上面我们好几个函数参数都包括了struct sockaddr结构体,那么这个结构体是个什么呢?
sockaddr结构体用于表示套接字的地址信息,它是一个通用的地址结构体,在网络编程中经常使用。
该结构体内部如下:
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
sockaddr结构体中的主要字段是sa_family和sa_data. sa_family表示地址的协议族,用来指定该地址的类型,常见的值包括AF_INET(IPv4)和AF_INET6(IPv6)。sa_data是一个字节数组,用于存储地址和端口号信息的具体内容,具体内容的长度和格式依赖于协议族的不同。
很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体。但是sockaddr的缺陷是:sa_data把目标地址和端口信息混在一起了。IPv4为了解决这个问题,于是便设计了sockaddr_in结构体,把port和ip分开来存储。
sockaddr_in
是用于 IPv4 地址的特定地址结构体。它扩展了 sockaddr
,并提供了 IPv4 地址(通过 sin_addr
字段存储)和端口号(通过 sin_port
字段存储)的字段。sockaddr_in
使用的是网络字节序(大端字节序)来存储这些值。
该结构体内部如下:
struct sockaddr_in {
sa_family_t sin_family; // 地址族,AF_INET(IPv4)
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IPv4地址
unsigned char sin_zero[8]; // 为了保持与sockaddr结构体的大小相同而填充的字节,一般不用处理
};
里面还有一个结构体是struct in_addr,这个函数内部如下:
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
它内部其实就是一个4字节的整数,也就是说IP地址其实就是一个4字节整数,具体如何使用,我们在下一模块中讲解。
所以当我们使用IPv4协议簇进行bind时,需要先创建一个struct sockaddr_in结构体类型,填写好内部相关的数据后,然后再传入参数,并将这个参数强转为struct sockaddr*。
具体来说,在 bind()
函数内部,会检查传入的 struct sockaddr
(或其子类型)的 sa_family
字段来判断地址类型。如果 sa_family
字段的值为 AF_INET
,则表示使用的是 IPv4 地址;如果 sa_family
字段的值为 AF_INET6
,则表示使用的是 IPv6 地址。
基于检查到的地址类型,函数会运行相应的实现方式来执行绑定操作
各结构体组成如下:
程序员不应操作sockaddr,sockaddr是给操作系统用的
程序员应使用sockaddr_in来表示地址,sockaddr_in区分了地址和端口,使用更方便。
也就是,程序员把类型、ip地址、端口填充sockaddr_in结构体,然后强制转换成sockaddr,作为参数传递给系统调用函数。
socket的使用(简易UDP网络程序的编写)
以上说了socket常见接口的用法,现在我们就来用代码实现一下,具体是如何使用的。
我们要实现一个简易版的UDP服务,效果是当客户端向服务端发送消息时,服务端收到消息并将消息返回到客户端,客户端收到后,再输出出来.
首先我们需要三个文件,分别是udp_client.cc,udp_server.cc,udp_server.hpp.
udp_server.hpp编写
首先我们要编写udp_server.hpp,代表服务器的相关接口与操作。有三个成员变量:
1、ip地址_ip 2、端口号_port 3、创建的套接字_sock.
然后我们实现第一个成员函数,初始化:
initServer:
- 首先我们要利用socket函数来创建一个套接字.
_sock = socket(AF_INET,SOCK_DGRAM,0);
指定的协议是IPv4,面向数据报读取。
2.然后我们要bind将ip和port绑定我们当前的进程。
首先创建一个sockaddr_in结构体,然后利用bzero函数对结构体所有变量进行初始化,然后分别填入相关的字段:
这里有一个需要说明的地方:我们平常看到的ip地址,例如"111,222.112.113" --> 点分十进制字符串风格的IP地址,是为了给用户看。
一共四个区域,每一个区域的取值范围为[0-255]:即1个字节代表一个区域、理论上,表示一个ip地址,4个区域即4个字节就够了,所以我们平常使用的时候,是先传输给网络,所以要将点分十进制风格的字符串的IP地址转化成4字节(如下代码中最后一行,s_addr就是一个四字节无符号整数)
struct sockaddr_in local;
bzero(&local,sizeof(local));
local.sin_family = AF_INET; //sin_family:地址族,AF_INET(IPv4)
local.sin_port = htons(_port);//sin_port端口号
//1.首先要将字符串风格的IP地址转化成 4字节IP
//2.4字节主机序列 -> 网络序列
//有一套接口,这里使用inet_addr可一次帮我们完成这两件事情
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());//sin_addr结构体中有一个成员变量s_addr,代表4字节表示的ip地址。如果地址为空,则将其设置为INADDR_ANY,代表inet_addr("0.0.0.0")服务器
if (bind(_sock, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logMessage(FATAL, "%d:%s", errno, strerror(errno));
exit(2);
}
第二个成员函数:start()
作为一款网络服务器,它是永远不退出的,我们想要的效果是,客户端clinet给服务端server发送一个消息,然后服务端收到后再原路返回。
这里还需要用到recvfrom函数,该函数用来从指定的套接字中接收数据,并将数据存放在指定的缓冲区中,该函数原型如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
sockfd
:套接字描述符,用于标识要接收数据的套接字。buf
:指向接收数据的缓冲区。len
:缓冲区的大小,表示能够接收的最大数据量。flags
:可选的标志参数,可以为 0 或常量MSG_DONTWAIT
(非阻塞模式)等。src_addr
:指向用于存储发送方地址信息的sockaddr
结构体的指针。addrlen
:指向保存发送方地址信息大小的变量的指针。
所以我们需要提前定义一个sockaddr_in结构体类型的指针来存储发送方的ip地址和端口号等信息。
recvfrom()
函数的返回值是实际接收到的数据的字节数,如果返回 -1,则表示出现错误。
所以代码如下:
void Start()
{
// 作为一款网络服务器,永远不退出!
// 服务器启动 -> 启动 -> 常驻进程 ->永远在内存中存在,除非异常挂掉
// echo serverf :clinet发送消息,我们原封不动返回
char buffer[BUFFER_SIZE];
for (;;)
{
// 注意:
// peer,纯输出型参数
struct sockaddr_in peer;
bzero(&peer, sizeof peer);
// 输入:peer 缓冲区大小
// 输出:实际读到的p eer大小
socklen_t len = sizeof(peer);
// 1.读取数据
ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
if (s > 0)
{
buffer[s] = 0;
// 1.输出发送的数据信息
// 2.是谁发送的信息
uint16_t cli_port = ntohs(peer.sin_port); // 从网络中来的,需要转化成本机序列
string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列IP,需要我们转成本主机字符串风格的IP,方便显示
printf("[%s:%d]# %s\n",cli_ip.c_str(), cli_port, buffer);
}
// 分析和处理数据
// 2.写回数据
sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
}
}
我们在获取到发送方信息的同时将其信息输出到屏幕上,然后将数据利用sendto返回给客户端。
sendto()
函数在网络编程中用于发送数据到指定的套接字。
该函数原型如下:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
sockfd
:套接字描述符,用于标识要发送数据的套接字。buf
:指向要发送数据的缓冲区。len
:要发送的数据的大小。flags
:可选的标志参数,可以为 0 或常量MSG_DONTWAIT
(非阻塞模式)等。dest_addr
:指向包含目标地址信息的sockaddr
结构体的指针。addrlen
:表示目标地址信息的大小。
sendto()
函数的返回值是发送数据的字节数,如果返回 -1,则表示出现错误.
至此,我们的udp_server.hpp便封装完成,后面只要使用这些接口就可以了,就很简单了。
udp_server.cc编写
我们想让用户输入的格式是./udp_server port,所以我们需要用到命令行参数.
我们首先获得到用户输入的端口号port,然后利用unique_ptr指针创建一个UdpServer对象,将port传进去,这样我们就得到了成功创建好了服务器的ip和端口.
代码如下:
static void usage(string proc)
{
cout << "\nUsage: " << proc << " port" << endl;
}
//./udp_serber ip port
int main(int argc, char* argv[])
{
if(argc != 2)
{
usage(argv[0]);
exit(1);
}
// string ip = argv[1];
uint16_t port = atoi(argv[1]);
unique_ptr<UdpServer> svr(new UdpServer(port));
svr->initServer();
svr->Start();
return 0;
}
udp_client.cc编写
同样地,我们需要用户输入连接的服务器的ip 和 port.
然后用户创建一个套接字,此时不需要我们显式bind,因为
client是一个客户端 -> 普通人下载安装启动使用->程序员如果自己bind,也一定bind了一个固定的ip和端口->万一其他客户端提前占用了该port,那这个客户端便无法启动、所以client一般不需要显式bind指定port,操作系统会自动随机选择.
然后提示用户输入信息,并发送到服务端,服务端将消息在返送回来,将返送回来的消息也输出出来.
代码如下:
static void usage(string proc)
{
cout << "Usage: " << proc << "ServerIP ServerPort" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage(argv[0]);
exit(1);
}
int sock = socket(AF_INET,SOCK_DGRAM,0);
if(sock < 0)
{
cerr << "socket error" << endl;
exit(2);
}
//cilent 要不要bind? 需要,但是client不会显式bind,程序员不会自己bind
//client是一个客户端 -> 普通人下载安装启动使用->程序员如果自己bind,也一定bind了一个固定的ip和端口
//->万一其他客户端提前占用了port,那这个客户端便无法启动
//所以client一般不需要显式bind指定port,操作系统会自动随机选择
string message;
struct sockaddr_in server;
bzero(&server,sizeof server);
server.sin_family = AF_INET;
server.sin_port = htons(atoi(argv[2]));
server.sin_addr.s_addr = inet_addr(argv[1]);
char buffer[1024];
while(true)
{
cout << "please Enter Meassage# ";
getline(cin,message);
if(message == "quit")
{
break;
}
//当cilent首次发送消息给服务器的时候,OS会自动给client bind它的ip和port
sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof server);
struct sockaddr_in temp;
bzero(&temp,sizeof temp);
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sock,buffer,sizeof(buffer),0,(struct sockaddr*)&temp,&len);
if(s > 0)
{
buffer[s] = 0;
//将返送回来的消息也输出出来
cout << "server echo# " << buffer << endl;
}
}
return 0;
}
整体效果如下:
整体所有的代码可以私信我要哦,gitee原来的账号无法使用了,没办法上传到gitee.