【Linux系统化学习】网络套接字(编写简单的UDP服务端和客户端)

发布于:2024-05-09 ⋅ 阅读:(26) ⋅ 点赞:(0)

目录

理解源IP地址和目的IP地址

认识端口号

端口号和进程ID的区别

源端口号和目的端口号

认识TCP和UDP协议

TCP协议

UDP协议

网络字节序

socket编程接口

socket常见API

sockaddr结构

简单的UDP网络程序

UDP服务端

创建套接字

填充本地网络信息

绑定

收取消息

完整的服务端封装代码

UDP客户端

创建套接字

填充服务端信息

操作系统自动绑定

发送消息

 客户端完整代码

一些补充

本地回环测试

netstat指令


理解源IP地址和目的IP地址

  1. 源IP地址(Source IP Address):源IP地址是指发送数据包的设备或主机的IP地址。它是数据包的来源地址,标识了数据包从哪个设备发送出来。当你发送数据到网络上的其他设备时,你的设备会将数据包标记上源IP地址,以便接收设备知道数据来自哪里。

  2. 目的IP地址(Destination IP Address):目的IP地址是指接收数据包的设备或主机的IP地址。它是数据包的目标地址,标识了数据包应该被发送到哪个设备。当你发送数据到网络上的另一个设备时,你的设备会将数据包标记上目的IP地址,以便网络路由器和接收设备知道将数据包传送到哪里。


认识端口号

  • 端口号(port)是传输层协议的内容.
  • 端口号是一个2字节16位的整数;
  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理;
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
  • 一个端口号只能被一个进程占用.

注:上篇文章我们说了两台主机可以通过网络进行通信,在准确一点就是两台主机中的进程通过网络进行通信,就像我们可以在QQ给自己的基友发消息,通过网络你的基友机会在自己主机的QQ上收到你的消息;两台主机上的两个QQ进程就通过网络这同一份资源进行远距离通信。

今天我们是通过网络,对于双方而言:

  1. 数据首先要到达目标主机(IP)
  2. 找到指定的进程
  3. IP地址的是用来表示互联网中唯一的主机,端口号用来标识该指定的机器中进程的唯一性;因此IP加端口号可以用来表示互联网中唯一的一个进程。IP加端口就是套接字(socket)

端口号和进程ID的区别

端口号和进程ID是两个不同层次、不同领域的标识符。端口号用于网络通信中标识不同的应用程序或服务,而进程ID用于操作系统中标识不同的进程。在某些情况下,可以将端口号和进程ID关联起来,例如查看特定端口上运行的进程,但它们仍然是不同的概念。

注:一个进程可以有多个端口号,但是一个端口号只能代表一个进程

源端口号和目的端口号

  1. 源端口号(Source Port Number):源端口号是发送数据包的设备或主机上的应用程序或服务使用的端口号。在建立连接时,发送端的应用程序会随机选择一个空闲端口作为源端口号,并将其包含在发送的数据包中。源端口号帮助目标设备知道从哪个端口接收到了数据包,以便回复响应。

  2. 目的端口号(Destination Port Number):目的端口号是接收数据包的设备或主机上的应用程序或服务期望接收数据包的端口号。在发送数据包时,发送端会指定目标设备的IP地址和目标端口号。接收设备根据目的端口号确定将数据包传递给哪个应用程序或服务。

其实我们可以简单理解为:就是在描述 "数据是谁发的, 要发给谁"


认识TCP和UDP协议

我们先对TCP(Transmission Control Protocol 传输控制协议)、UDP(User Datagram Protocol 用户数据报协议)有一个直观的认识; 后面的文章我们再详细讨论TCP、UDP的一些细节问题.

TCP协议

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

UDP协议

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

网络字节序

我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分, 磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分, 网络数据流同样有大端小端之分. 那么如何定义网络数据流的地址呢?

  • 发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
  • 接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
  • 因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
  • TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
  • 不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据;
  • 如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;

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

  • 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
  • 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。 

注:简单来说就是通过网络必须传送的是大端序,因此在传送之前不清楚自己主机是大端还是小端必须进行转换为大端序 


socket编程接口

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是有很多分类的:

  • unix socket(域间套接字) :同一台机器上的文件路径,类似于命名管道,用于本主机内部进行通信
  • 网络socket:ip+port用于网络通信
  • 原始socket:跳过运输层,直接访问网络层;用于编写一些网络工具

sockaddr结构

网络编程的时候,有不同的应用场景,理论上而言我们应该给每一种场景都设计一套编程接口,但是设计者想使用一套接口,因此sockaddr是一个通用的接口;

sockaddr就相当与基类,当传入不同的类型是判断每个结构体的前两个字节进行类型的强转换。

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 Domain Socket各种类型的sockaddr结构体指针做为参数; 

简单的UDP网络程序

UDP服务端

初始化服务器

创建套接字

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

返回值

  • socket 函数的返回值代表了新创建的套接字的文件描述符,如果创建失败则返回 -1。
  • 在成功创建套接字后,socket 函数会返回一个非负整数,表示新创建的套接字的文件描述符。

参数

domain(地址族)

  • 类型:int
  • 意义:指定套接字的地址族,即通信所采用的协议族。
  • 常见取值:
    • AF_INET:IPv4 地址族。
    • AF_INET6:IPv6 地址族。
    • AF_UNIXAF_LOCAL:Unix 域(本地)套接字。

上面提到有三种套接字的分类,我们是网络套接字并且只介绍IPv4因此选择第一个取值

type(套接字类型)

  • 类型:int
  • 意义:指定套接字的类型,即套接字的通信模式。
  • 常见取值:
    • SOCK_STREAM:流套接字,提供可靠的、面向连接的、基于字节流的服务,使用 TCP 协议。
    • SOCK_DGRAM:数据报套接字,提供不可靠的、无连接的、基于数据报的服务,使用 UDP 协议。
    • SOCK_RAW:原始套接字,允许对底层协议进行直接访问,常用于网络监控和特殊应用。

 编写的是UDP服务器,因此选择第二个参数

protocol(协议)

  • 类型:int
  • 意义:指定套接字所使用的协议,通常为 0,表示使用默认的协议。
  • 对于 SOCK_STREAMSOCK_DGRAM 类型的套接字,协议通常可以省略,因为它们分别与 TCP 和 UDP 相关联。
  • 对于 SOCK_RAW 类型的套接字,可以指定底层的协议,如 IP、ICMP 等。

一般为0; 

填充本地网络信息

因为我们是网络套接字,因此需要一个struct sockaddr_in的结构体;将我们的IP地址、端口号和套接字类型填充到结构体中;

注:

  • 创建好结构体后需要对结构体里的内容清零
  • 填充端口号时我们要将我们的主机序列转为网络序列
  • 填充IP地址时我我们要将原始的点分十进制的字符串转换为四字节的网络序列
  • 我们不可以使用我们的服务器的IP地址作为我们程序的IP地址,下一步绑死后只能收到该IP发送的报文。
  • 我更推荐使用任意IP地址,INADDR_ANY 是一个特殊的 IP 地址,在网络编程中经常用于绑定套接字到本地计算机的所有网络接口上。
  • 具体来说,INADDR_ANY 表示接受任何来自本地计算机所有网络接口(包括所有网卡)的数据包。这在服务器编程中非常有用,因为服务器通常需要监听来自所有网络接口的连接请求。

绑定

上篇文章我们提到传输层作用于内核中,我们上一步填充的各种信息只存在于栈中,因此我们需要告诉操作系统内核,某个特定的套接字对应于网络上的某个地址。这个过程就是绑定;

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

返回值

bind 函数的返回值代表了函数执行的结果,它通常有以下两种可能:

  1. 如果绑定成功,bind 函数返回值为 0。这表示套接字已成功绑定到指定的地址和端口上。

  2. 如果绑定失败,bind 函数返回值为 -1,并且会设置相应的错误码以指示失败的原因。这种情况可能

参数 

套接字描述符

  • 类型:int(整数)
  • 意义:要绑定的套接字的文件描述符。
  • 在调用绑定函数时,需要传递已经创建好的套接字的文件描述符。

地址结构指针

  • 类型:const struct sockaddr *
  • 意义:指向存储目标地址信息的结构体的指针。
  • 绑定函数需要知道要绑定的目标地址和端口号。通常使用的是 sockaddr 结构体或其派生结构体,如 sockaddr_in(IPv4 地址)或 sockaddr_in6(IPv6 地址)等。
  • 使用 const 关键字修饰指针,表示绑定函数不会修改该地址结构。

地址结构的长度

  • 类型:socklen_t(整数)
  • 意义:地址结构的长度。
  • 用于指定地址结构的实际长度,以确保绑定函数能够正确地解析地址结构。

启动服务器

收取消息

启动服务器就是启动一个程序且程序没收到特定的指令不退出,即就是一个死循环。

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

 recvfrom 函数用于从指定的套接字接收数据,并将数据存储到指定的缓冲区中,同时还可以获取数据发送方的地址信息。这个函数通常在使用 UDP 协议进行通信时使用,因为 UDP 是面向数据报的,每个数据包都有自己的源地址和目标地址。

返回值

recvfrom 函数的返回值是接收到的数据的字节数,即实际读取到缓冲区中的数据量。如果发生错误,返回值为 -1。

参数 

套接字描述符

  • 类型:int(整数)
  • 意义:要接收数据的套接字的文件描述符。
  • 在调用 recvfrom 函数时,需要传递已经创建好的套接字的文件描述符。

缓冲区指针

  • 类型:void *
  • 意义:指向存储接收数据的缓冲区的指针。
  • 接收到的数据将被存储到这个缓冲区中。

缓冲区大小

  • 类型:size_t(无符号整数)
  • 意义:缓冲区的大小,即接收数据的最大长度。
  • 在调用 recvfrom 函数之前,应该确保缓冲区足够大,以容纳接收到的数据。

标志

  • 类型:int(整数)
  • 意义:用于指定接收数据的选项。
  • 可以设置为 0,表示没有特殊的选项。

发送方地址结构指针

  • 类型:struct sockaddr *
  • 意义:用于存储发送方的地址信息的结构体指针。
  • 如果不需要获取发送方的地址信息,可以将这个参数设置为 NULL

发送方地址结构的长度指针

  • 类型:socklen_t *
  • 意义:发送方地址结构的长度。
  • 在调用 recvfrom 函数之前,需要将这个参数设置为一个指向长度变量的指针,用于接收实际的地址结构长度。

完整的服务端封装代码

Udpserver.hpp

#pragma once 
#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>
#include<cerrno>
#include<unistd.h>
#include<cstring>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<strings.h>
using namespace std;
static const uint16_t defaultport = 8888;
static const int sockfd=-1;
static const int size=1024;
class UdpServer
{
public:
    UdpServer(uint16_t port=defaultport)
    :_port(port),_sockfd(sockfd)
    {
    }
    //初始化服务器
    void Init()
    {
        //创建套接字
        _sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd<0)
        {
            //表示创建失败
            cout<<"Fatal Error"<<errno<<strerror(errno)<<endl;
            exit(2);
        }
        else{
            cout<<"socket success "<<"sockfd : "<<_sockfd<<endl;
        }
        //绑定套接字


        struct sockaddr_in local;
        //初始化local 
        //指定的一块内存清零
        bzero(&local,sizeof(local));
        //填充
        //进行网络通信
        local.sin_family = AF_INET;
        //端口号
        //转为网络序列
        local.sin_port = htons(_port);
        //1.4字节点分十进制字符串 2. 转为网络序列
        //local.sin_addr.s_addr = inet_addr(_ip.c_str());
        //使用地址任意
        //实现IP动态绑定
        local.sin_addr.s_addr = INADDR_ANY;
        //到此还没有设置到内核中,只存在于栈

        //绑定
        //强制类型转换
        int n = ::bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
        if(n!=0)
        {
            cout<<"Fatal Error , bind error "<<errno<<" "<<strerror(errno)<<endl;
            exit(3);
        }
    }
    //启动服务器
    //服务器永远不退出
    //是一个死循环
    void Start()
    {
        //收发消息
        //返回值实际收到的消息
        //第一个参数为文件描述符
        //第二个
        //第三个为期望
        //第四个参数为收数据的模式通常设置为0 阻塞式
        //最后两个参数为输出型参数
        //保存我们客户端的信息 ip port
        char buffer[size];
        while(1)
        {
            //预留一个\0
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            
            ssize_t n = recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
            if(n>0)
            {
                //拿到客户端的信息
                //网络序列转主机序列
                uint16_t clientport = ntohs(peer.sin_port);
                string clientip = inet_ntoa(peer.sin_addr);

                string info = clientip;
                info+=":";
                info+=std::to_string(clientport);
                buffer[n]=0;
                cout<<"["<<info<<"]"<<buffer<<endl;
                //返回消息
                sendto(_sockfd,buffer,n,0,(struct sockaddr*)&peer,len);
            }
        }
    }
    ~UdpServer()
    {
        if(_sockfd!=-1)
        {
            close(_sockfd);
        }
    }
private:
    uint16_t _port;//端口号
    int _sockfd;
};

 Main.cc

#include<iostream>
#include<memory>
#include<string>
#include"Udpserver.hpp"
using namespace std;
enum comm
{
    Usage_Err=1
};
void Usage(string proc)
{
    cout<<"usage: \n\t"<<proc<<"locak_port"<<endl;
}
int main(int argc , char * argv[])
{
    //告诉如何使用
    if(argc!=2)
    {
        Usage(argv[0]);
        return Usage_Err;
    }
    // string ip = argv[1];
    uint16_t port= stoi(argv[1]);

    UdpServer* usvr  = new UdpServer(port);
    usvr->Init();
    usvr->Start();
    delete usvr;
    return 0;
}

UDP客户端

作为客户端我们一定知道服务端的IP和端口号,作为服务端IP和端口号是不可能随便改变的;

创建套接字

和服务端类似

填充服务端信息

和服务端类似

操作系统自动绑定

客户端不需要显示使用bind函数进行绑定,而是操作系统自动隐式绑定;因为如果客户端能够绑定特定的端口号,那么可能会导致端口冲突的问题。假设多个客户端应用都试图绑定到相同的固定端口号上,那么在同一台计算机上运行这些应用时就会发生冲突。为了避免这种情况,操作系统会自动为客户端分配一个可用的临时端口。

发送消息

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

返回值

  • 如果成功,sendto 函数返回发送的字节数。这通常与 len 参数相同,但也可能更少(例如,如果套接字是非阻塞的,并且没有足够的缓冲区空间来容纳整个消息)。
  • 如果发生错误,sendto 函数返回 -1,并设置全局变量 errno 以指示错误类型。可能的错误包括 ECONNREFUSED(连接被拒绝)、EHOSTUNREACH(主机不可达)、EMSGSIZE(消息太大)、ENOBUFS(没有可用的缓冲区空间)等。

参数

sockfd这是一个打开的 socket 文件描述符,用于标识一个打开的 socket。

buf这是一个指向要发送数据的缓冲区的指针。

len这是要发送的数据的字节数。

flags这是可选的标志参数,可以用来指定不同的操作行为。常见的标志包括:

  • MSG_CONFIRM:告知底层传输层协议确认数据。
  • MSG_DONTROUTE:告知底层传输层协议不要路由数据。
  • MSG_EOR:表示发送的数据是一个消息的末尾。
  • 等等。具体的标志因操作系统和网络协议栈的不同而有所不同。

dest_addr这是一个指向目标地址信息的结构体的指针。它是一个 sockaddr 结构体,可以是 sockaddr_insockaddr_in6 结构体,具体取决于使用的网络协议版本。

addrlen这是目标地址结构体的大小,以字节为单位。通常情况下,它可以通过 sizeof(struct sockaddr) 来获取。

 客户端完整代码

Client.cc

#include <iostream>
#include <sys/types.h>
#include <sys/socket.h>
#include <cerrno>
#include <string.h>
#include <string>
#include <unistd.h>
#include<arpa/inet.h>
#include<netinet/in.h>

using namespace std;
void Usage(const string &process) 
{
    cout<<"Usage : "<<process<<" server_ip server_port"<<endl;
}
int main(int argc, char*argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        return 1;
    }
    string serverip = argv[1];
    uint16_t serverport = stoi(argv[2]);
    int sock = socket(AF_INET, SOCK_DGRAM, 0);
    if (sock < 0)
    {
        cout << "socket error : " << strerror(errno) << endl;
        return 1;
    }
    cout << "socket success!" << endl;
    // 作为客户端必须知道服务器的ip和端口号

    // 作为客户端一定要进行绑定,但是不是显示绑定,客户端会在首次发送数据的时候进行自动绑定
    // 服务端端口号,一定是总所周知的不可改变的,
    // 客户端需要prot,绑定随机端口
    // why?
    // 因为客户端非常的多
    // 绑定确定的端口号,可能会在某一时刻启动失败
    // 让本地操作系统自动随机绑定,随机选择端口
    struct  sockaddr_in server;
    memset(&server,0,sizeof(server));
    server.sin_family =AF_INET;
    server.sin_port = htons(serverport);
    server.sin_addr.s_addr = inet_addr(serverip.c_str());
    // 发消息
    while (1)
    {
        //我们要发的数据
        string inbuffer;
        cout << "plase Entre#: ";
       std::getline(std::cin, inbuffer);
        //发给谁
        ssize_t n = sendto(sock,inbuffer.c_str(),inbuffer.size(),0,(struct sockaddr*)&server,sizeof(server));
        if(n>0) 
        {
            //收消息
            //UDP支持全双工通信
            char buffer[1024];
            struct sockaddr_in temp;
            socklen_t len = sizeof(temp);
            ssize_t m = recvfrom(sock,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&temp,&len);
            if(m>0)
            {
                buffer[m]='\0';
                cout<<"server echo# "<<buffer<<endl;

            }
            else
            {
                break;
            }
        }
        else 
        {
            break;
        }
    }
    //套接字类型为文件描述符
    //使用结束后需要关闭
    close(sock);
    return 0;
}

一些补充

本地回环测试

我们可以让封装的服务端带上IP地址,使用127.0.0.1这个IP地址在本地的一台服务器上开上两个窗口启动服务端和客户端进行本地回环测试,检查代码和程序;上面的代码可以做到一些简单的交互联动。

netstat指令

netstat 命令,你可以查看本地计算机上的网络连接、路由表、网络接口统计信息等

常用选项

  • -a:显示所有连接和监听端口。
  • -t:显示 TCP 协议的连接。
  • -u:显示 UDP 协议的连接。
  • -l:显示监听状态的连接。
  • -n:以数字形式显示地址和端口。
  • -p:显示与连接相关的进程 ID。
  • -r:显示路由表。
  • -i:显示网络接口的统计信息。
  • -s:显示网络协议的统计信息。

今天对网络套接字的分享到这就结束了,希望大家读完后有很大的收获,也可以在评论区点评文章中的内容和分享自己的看法;个人主页还有很多精彩的内容。您三连的支持就是我前进的动力,感谢大家的支持!!!