Linux篇第十八篇——网络套接字编程(预备知识+UDP套接字的编写)

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Linux篇第十八篇——网络套接字编程(预备知识+UDP套接字的编写)相关的知识,希望对你有一定的参考价值。

⭐️ 本篇博客开始给大家介绍网络编程中的套接字编程——基于UDP协议的套接字和基于TCP的套接字,这篇博客主要介绍基于UDP协议套接字,下一篇介绍基于TCP协议的套接字。在介绍套接字编程之前,我会先给大家介绍一些预备知识:源IP地址和目的IP地址、源端口号和目的端口号等,方便大家更好地理解网络套接字编写的整个流程。需要注意的是,我们是站在应用层进行编写套接字的,所以接下来会用到都是传输层的接口。话不多说,先看今天的主要内容~

目录


🌏预备知识

🌲源IP地址和目的IP地址

IP地址在上一篇博客中也介绍过,它是用来标识网络中不同主机的地址。两台主机进行通信时,发送方需要知道自己往哪一台主机发送,这就需要知道接受方主机的的IP地址,也就是目的IP地址,因为两台主机是要进行通信的,所以接收方需要给发送方进行一个响应,这时接收方主机就需要知道发送方主机的IP地址,也就是源IP地址。有了这两个地址,两台主机才能够找到对端主机。

  • 源IP地址: 发送方主机的IP地址,保证响应主机“往哪放”
  • 目的IP地址: 接收方主机的IP地址,保证发送方主机“往哪发”

🌲端口号

端口号是属于传输层协议的一个概念,它是一个16位的整数,用来标识主机上的某一个进程
注意: 一个端口号只能被一个进程占用
在上面说过,公网IP地址是用来标识全网内唯一的一台主机,端口号又是用来标识一台主机上的唯一一个进程,所以IP地址+端口号 就可以标识全网内唯一一个进程

端口号和进程ID:
二者都是用来唯一标识某一个进程。它们的区别和联系是:

一台主机上可以存在大量的进程,但不是所有的进程都需要对外进行网络请求。任何的网络服务和客户端进程通信,如果要进行正常的数据通信,必须要用端口号来唯一标识自身的进程,只有需要进行网络请求的进程才需要用端口号来表示自身的唯一性,所以说端口号更多的是网络级的概念。进程pid可以用来标识所有进程的唯一性,是操作系统层面的概念。二者是不同层面表示进程唯一性的机制。

源端口号和目的端口号:
两台主机进行通信,只有对端主机的IP地址只能够帮我们找到对端的主机,但是我们还需要找到对端提供服务的进程,这个进程可以通过对端进程绑定的端口号找到,也就是目的端口号,同样地,对端主机也需要给发送方一个响应,通过源IP地址找到发送方的那一台主机,找到主机还是不够的,还需要找到对端主机是哪一个进程发起了请求,响应方需要通过发起请求的进程绑定的端口号找到该进程,也就是源端口号,然后就可以进行响应。

  • 源端口号: 发送方主机的服务进程绑定的端口号,保证接收方能够找到对应的服务
  • 目的端口号: 接收方主机的服务进程绑定的端口号,保证发送方能够找到对应的服务

socket通信的本质: 跨网络的进程间通信。从上面可以看出,网络通信就是两台主机上的进程在进行通信。

🌲认识TCP协议和UDP协议

这两个协议都是传输层的协议,这里不会过多地介绍两个协议的具体细节,因为现在我们只有认识它们就够了,更多的细节会在后面的博客中具体介绍。
TCP(Transmission Control Protocol)协议: 传输控制协议

  • 传输层协议
  • 有连接
  • 可靠传输
  • 面向字节流

UDP(User Datagram Protocol)协议: 用户数据报协议

  • 传输层协议
  • 无连接
  • 不可靠传输
  • 面向数据报

为什么还需要不可靠传输协议?

可靠意味着需要进行更多的工作来保证可靠性,成本也会更多,效率也会低一些。不可靠协议的特点就是简单,高效。实际中,我们需要根据需求来选择不同的协议。

🌲网络字节序

内存中的多字节数据的存储相对于内存地址有大端和小端之分:

  • 大端字节序: 高位存放在低地址,低位存放在高地址
  • 小端字节序: 低位存放在低地址,高位存放在高地址

网络数据流同样有大端和小端之分,发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,所以网络数据流的地址规定如下:

  • 先发出的数据是低地址,后发出的数据是高地址

如果双方主机的数据在内存存储的字节序不同,就会造成接收方收到的数据出现偏差,所以为了解决这个问题,又有了下面的规定:

  • TCP/IP协议规定,网络数据流采用大端字节序,不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据

所以如果发送的主机是小端机,就需要把要发送的数据先转为大端,再进行发送,如果是大端,就可以直接进行发送。

为了方便我们进行网络程序的代码编写,有下面几个API提供给我们用来做网络字节序和主机字节序的转换,如下:

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

说明:

  • h代表的是host,n代表的是network,s代表的是16位的短整型,l代表的是32位长整形
  • 如果主机是小端字节序,函数会对参数进行处理,进行大小端转换
  • 如果主机是大端字节序,函数不会对这些参数处理,直接返回

🌏socket常见API

🌲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);

🌲sockaddr结构

  • sockaddr_in用来进行网络通信,sockaddr_un结构体用来进行本地通信
  • sockaddr_in结构体存储了协议家族,端口号,IP等信息,网络通信时可以通过这个结构体把自己的信息发送给对方,也可以通过这个结构体获取远端的这些信息
  • 可以看出,这三个结构体的前16位时一样的,代表的是协议家族,可以根据这个参数判断需要进行哪种通信(本地和跨网络)
  • 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; 这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针为参数

sockaddr_in的结构: 因为我们主要用到网络通信,所以这里主要介绍这个结构体,打开/usr/include/linux/in.h


sin_family代表的是地址类型,我们主要用的是AF_INETsin_port代表的是端口号,sin_addr代表的是网络地址,也就是IP地址,用了一个结构体struct in_addr进行描述

这里填充的就是IPv4的地址,一个32位的整数

🌏地址转换函数

IP地址可以用点分十进制的字符串(如127.0.0.1),也可以用一个32位整数表示,其中就涉及到二者之间的转换,以下是二者相互转换的库函数:
字符串转in_addr的函数:

#include <arpa/inet.h>

int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
int inet_pton(int af, const char *src, void *dst);

inet_addr

函数原型:

in_addr_t inet_addr(const char *cp); 

参数:

  • cp: 点分十进制的字符串IP

返回值: 整数IP

in_addr转字符串的函数:

char *inet_ntoa(struct in_addr in);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

inet_ntoa

函数原型:

char *inet_ntoa(struct in_addr in);

参数:

  • in_addr:描述IP地址的结构体

返回值: 字符串IP

注意: inet_ntoa这个函数内部会申请一块空间,保存转换后的IP的结果,这块空间被放在静态存储区,不需要我们手动释放。且第二次调用该函数,会把结果放到上一次的静态存储区中,所以会覆盖上一次调用该函数的结果,是线程不安全的。inet_ntop这个函数是由调用者自己提供一个缓冲区保存结果,是线程安全的。

🌏基于UDP协议的套接字程序

🌲服务端

套接字本质上也是一个文件描述符,指向的是一个“网络文件”。普通文件的文件缓冲区对应的是磁盘,数据先写入文件缓冲区,再刷新到磁盘,“网络文件”的文件缓冲区对应的是网卡,它会把文件缓冲区的数据刷新到网卡,然后发送到网络中。
创建一个套接字做的工作就是打开一个文件,接下来就是要将该文件和网络关联起来,这就是绑定的操作,完成了绑定,文件缓冲区的数据才知道往哪刷新。

🍯整体框架

这里封装一个类,类里面的成员有要绑定的端口号、套接字两个成员,不需要IP成员,后面具体说

#define DEFAULT 8081 // 端口号

class UdpServer

public:
  UdpServer(/*std::string ip,*/ int port = DEFAULT)
    :_port(port)
    ,_sockfd(-1)
  
   ~UdpServer()
  
    if (_sockfd >= 0)
      close(_sockfd);
    
  
private:
  int _port;
  int _sockfd;
;

🍯服务器初始化

🐚创建套接字

创建套接字用到的是socket这个接口创建,具体介绍如下:

函数原型:

 int socket(int domain, int type, int protocol); 

参数:

  • domain:协议家族,我们用的都是IPV4,这里会填AF_INET
  • type:协议类型。可以选择SOCK_DGRAM(数据报,UDP)和SOCK_STREAM(流式服务,TCP)
  • protocol:协议类别,这里填写0,根据前面的参数自动推导需要那种类型

返回值: 成功返回一个文件描述符,失败返回-1

代码如下:

void UdpServerInit()

    // 创建套接字
    // 协议家族 AF_INET(16位)
    // 服务类型  TCP: SOCK_STREAM 流式服务   UDP: SOCK_DGRAM 数据报
    // 服务类别 0  根据前两个参数自动推导所需协议
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (_sockfd < 0)
      std::cerr << "socket creat fail" << std::endl;
      exit(-1);
    
    std::cout << "socket creat success, socketfd: " << _sockfd << std::endl;

创建失败就直接退出进程,成功就继续绑定端口号,把文件和网络关联起来

🐚绑定端口号

绑定端口号用到的是bind这个接口,具体细节如下:

函数原型:

int bind(int sockfd, struct sockaddr *my_addr, socklen_taddrlen); 

参数:

  • sockfd:套接字
  • my_addr:这里传一个sockaddr_in的结构体,里面记录这本地的信息:sin_family(协议家族)、sin_port(端口号)和sin_addr(地址),用来进行绑定
  • addrlen:第二个参数的结构体的大小

返回值: 成功返回0,失败返回-1

这里端口号我们填充一个8081,协议家族填充的还是AF_INET,这里IP绑定一个字段叫INADDR_ANY,值为0,表示取消对单个IP的绑定,服务器端有多个IP,如果指明绑定那个IP,那么服务端只能够从这个IP获取数据,如果绑定INADDR_ANY,那么服务端可以接受来自本主机任意IP对该端口号发送过来的数据
填充好了这个结构体,我们需要它进行强转为struct sockaddr

注意: 因为数据是要发送到网络中,所以要将主机序列的端口号转为网络序列的端口号

代码实现如下:

 void UdpServerInit()
     
    // 绑定端口号 上面的步骤只是创建了一个文件,需要把文件和网络关联起来,这样才可以从网络中读取数据和把数据刷新到网卡中
    // 所以需要绑定端口号
    // sockaddr
    struct sockaddr_in local;
    
    memset(&local, '\\0', sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 将主机序列的端口号转为网络序列的端口号
    //local.sin_addr.s_addr = inet_addr(_ip.c_str());// 将字符串ip转为整数ip
    local.sin_addr.s_addr = INADDR_ANY;// 取消单个ip绑定,可以接受来自任意client的请求,从任意ip获取数据
  
    if (bind(_sockfd, (sockaddr*)&local, sizeof(local)) == -1)
        std::cerr << "bind fail" << std::endl;
        exit(-1);
    
    std::cout << "bind port success, port: " <<  _port << std::endl;
  

同样地,绑定成功服务器初始化的操作就完成了,失败就退出进程。

🍯服务器启动

🐚读取数据和发送数据

这里介绍两个接口——recvfromsendto
recvfrom

作用: 从一个套接字中获取信息,面向无连接
函数原型:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr* src_addr, socklen_t *addrlen); 

函数参数:

  • sockfd:从该套接字获取信息
  • buf:缓冲区,把数据读取到该缓冲区中
  • len:一次读多少自己的数据
  • flags:表示阻塞读取
  • src_addr:一个输出型参数,获取到对端的信息,有端口号,IP等,方便后序我们对其进行响应
  • addrlen:输入输出型参数,传入一个想要读取对端src_addr的长度,最后返回实际读到的长度

返回值: 实际读取到的数据大小

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:表示阻塞发送
  • dest_addr:本地的网络相关属性信息,要填充好发送给对方,确保对方能够响应
  • addrlen:dest_addr的实际大小

返回值: 成功返回实际写入的数据大小,失败返回-1

读取和发送数据的代码如下:

void UdpServerStart()

  while (1)
    // recvfrom 读数据  flags 0非阻塞读取
    char buf[1024];
    struct sockaddr_in peer;// 获取远端数据和信息
    socklen_t len = sizeof(peer);// 输入输出型参数
    ssize_t size = recvfrom(_sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&peer, &len);
    // 客户端发送退出请求,客户端直接退出,服务端继续运行
    if (strcmp("exit",buf)==0)
      continue;
    if (size > 0)
      buf[size] = 0;
      int peerPort = ntohs(peer.sin_port);
      std::string peerIp = inet_ntoa(peer.sin_addr);// 将网络序列整数的ip转为主机序列的字符串ip
      

      std::cout << peerIp << ":" << peerPort << "# " << buf << std::endl;
      
      std::string echo_msg;
      echo_msg = "server get!-> ";
      echo_msg += buf;
      // 发送数据
      sendto(_sockfd, echo_msg.c_str(), echo_msg.size(), 0, (struct sockaddr*)&peer, len);
    
    else
      std::cerr << "recvfrom error" << std::endl;
      std::string error_msg = "server recvfrom error ! ! !";
      sendto(_sockfd, error_msg.c_str(), error_msg.size(), 0, (struct sockaddr*)&peer, len);
    
  

注意:

  • 客户端发出退出请求,只需要让客户端退出即可,服务端继续去读取其它客户端的请求即可,不能因为一个客户端退出,服务端就直接退出。
  • 服务器一次读取数据失败也不可以直接退出,重新读取就好了

🍯服务器的初始化和启动

我们用一个udp_server.cc的文件编写这些操作,如下:

/*udp_server.cc*/
#include "udp_server.hpp"

// ./udo_server port
int main(int argc, char* argv[])

  if (argc != 2)
    std::cerr << "Usage:" << argv[0] << "port:" << std:: endl;
    exit(-1);
  
  int port = atoi(argv[1]);
  UdpServer* usr = new UdpServer(port);
  usr->UdpServerInit();
  usr->UdpServerStart();

  delete usr;

  return 0;

这里我们采用命令行的方式获取我们服务器需要绑定的端口号,如果命令行参数格式输入错误,我们打印一个使用手册给用户即可。然后根据这些参数创建一个服务器类,并进行初始化,然后启动即可。
操作如下:

接下来就是等待客户端想服务器发送数据。

🌲客户端

🍯整体框架

用一个类封装客户端,里面包含的成员需要有套接字,远端端口号和远端IP,具体如下:

class UdpClient

public:
  UdpClient(int server_port, std::string server_ip)
    :_server_port(server_port)
     ,_server_ip(server_ip)
     ,_sockfd(-1)
  
  ~UdpClient()
  
    if (_sockfd >= 0)
      close(_sockfd);
    
  
private:
  int _server_port;
  std::string _server_ip;
  int _sockfd; 
;

🍯客户端初始化

客户端初始化只需要创建套接字,不需要我们手动进行绑定,调用sendto时,会给我客户端分配一个端口号进行绑定,所以我们不需要手动绑定。
如果我们手动给客户端绑定了一个端口号,且该端口号已经被占用,就会绑定失败,这样是特别不好的,所以让系统给我们的客户端分配一个端口号即可。
代码如下:

bool UdpClientInit()

   // 创建套接字
   _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
   if (_sockfd < 0)
     std::cerr << "sockfd creat fail" << std::endl;
     return false;
   
   std::cout << "sockfd creat success, sockfd: " << _sockfd << std::endl;
   // 不需要绑定端口号,sendto会自动分配一个,且该端口号会变
   return true;
 

🍯客户端启动

客户端启动后进行发送数据和读取响应,调用的是sendtorecvfrom两个接口,发送数据时,需要将自己的网络信息发送个对方,也就是用远端端口号和远端IP进行填充
注意: 远端端口号需要转为网络字节序再进行发送,字符串IP需要使用addr转为整数

代码实现如下:

void UdpClientStart()

  struct sockaddr_in peer;
  memset(&peer, '\\0', sizeof(peer));

  peer.sin_family = AF_INET;
  peer.sin_port = htons(_server_port);
  peer.sin_addr.s_addr = inet_addr(_server_ip.c_str());
  
  std::string msg;
  
  while (1)
    std::cout << "Please Enter# ";
    getline(std::cin,  msg);
    sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
 
    if (strcmp(msg.c_str(), "exit") == 0)
      break;
    char buf[1024];
    struct sockaddr temp;
    socklen_t len = sizeof(temp);
    ssize_t size = recvfrom(_sockfd, buf, sizeof(buf)-1, 0, (struct sockaddr*)&temp, &len);
    if (size > 0)
      buf[size] = 0;
      std::cout << buf << std::endl;
    
  

🌏代码测试

🌲本地测试

IP地址为127.0.0.1是一个本地环回,表示的是本主机,可以用来测试本地网络是否畅通。
同时用下面的指令可以查看当前网络状态:

netstat [选项]
- n 拒绝显示别名,能显示数组尽量全部转化为数字
- l 仅列出在Listen状态下的服务状态
- p 显示建立相关链接的程序名
- t 显示tcp相关内容
- u 显示udp相关内容
- a 显示所以,不显示LISTEN相关

测试如下:

🌲外网测试

这次让其它机器连我们主机的外网,然后进行数据发送,测试如下:
服务端:

客户端:

上面有两台主机向服务器发起了数据。

🌏服务端增加一些功能

服务端是用来给客户端提供服务的,我们可以根据客户端发送的一些指令,给客户端响应客户想要获取的数据,比如:客户端发送ls,服务器就把本地ls的内容全部响应给客户端。
这里我们对客户端发送过来的数据先进行扫描,看是否是我们提供给用户的指令,如果不是就直接回显;如果是,我们就创建一个匿名管道,然后进行fork创建子进程,子进程进行程序替换,把运行指令的结果重定向到管道中,让父进程进行读取,然后再响应给客户端
具体代码如下:

void UdpServerStart()

  while (1)
    // recvfrom 读数据  flags 0非阻塞读取
    char buf[1024];
    struct sockaddr_in peer;// 获取远端数据和

以上是关于Linux篇第十八篇——网络套接字编程(预备知识+UDP套接字的编写)的主要内容,如果未能解决你的问题,请参考以下文章

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

Linux篇第十九篇——网络套接字编程(TCP套接字的编写+多进程版本+多线程版本+线程池版本)

python全栈开发基础第十八篇网络编程(socket)

Linux从青铜到王者第十八篇:Linux网络基础第二篇之TCP协议

Python基础篇第十四篇:网络编程