网络编程套接字(一)

目录

源IP地址与目的IP地址

端口号

MAC地址

网络字节序

socket常见API

sockaddr结构

基于UDP协议实现通信

测试


源IP地址与目的IP地址

在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址,源IP地址是数据发送者的IP地址,目的IP地址是数据接收者的地址。

源IP地址唯一标识了发送数据的设备。在数据包从源头到目的地的传输过程中,源IP地址帮助路由器确定数据包的来源网络。当目的地设备接收到数据并需要回复时,源IP地址用作回复数据的目的地。

目的IP地址唯一标识了数据包的最终接收者。目的IP地址帮助路由器决定如何将数据包路由到正确的网络和最终目的地。在数据到达目的网络后,目的IP地址确保数据包能够正确地分发给目标设备。

端口号

只有IP地址就可以完成通信了嘛? 有了IP地址能够把消息发送到对方的机器上, 但是还需要有一个其他的标识来区分出, 这个数据要给哪个程序进行解析。

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

与IP相似,传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号,他们标识的是数据是由哪个程序发送的,又由哪个程序接送。

MAC地址

MAC地址是全网唯一的,在出厂时被烧录在物理网卡中,在我们进行网络通信时,不仅需要指定IP地址和端口号,还需要目的MAC地址。在我们指定了IP后,操作地址会通过查表自动填充MAC地址。

为什么我们有了IP地址还需要MAC地址。

  • IP地址属于网络层(第三层),负责在不同网络之间路由数据包。
  • MAC地址属于数据链路层(第二层),负责在局域网内传输数据帧
  • IP地址用于在整个互联网范围内标识设备,确保数据包能够被路由到正确的网络和主机。
  • MAC地址仅在局域网内有效,用于标识网络接口卡,确保数据帧能够被传输到同一局域网内的特定设备
  • 在局域网内,设备通过MAC地址直接通信。
  • 在跨网络通信时,设备通过IP地址进行通信,而路由器负责将IP数据包转发到正确的网络。
  • 在发送数据之前,设备需要知道目的IP地址对应的MAC地址,这通过ARP协议实现,它将IP地址解析为MAC地址。

大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。

源MAC地址和目的MAC地址是包含在链路层的报头当中的,而MAC地址实际只在当前局域网内有效,因此当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,因此当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。

在图中主机1向主机2发送数据的过程中,数据的源MAC地址和目的MAC地址的变化过程:

历程源MAC地址目的MAC地址
初始主机1的MAC地址路由器A的MAC地址
经过路由器A之后路由器A的MAC地址路由器B的MAC地址
经过路由器B之后路由器B的MAC地址路由器C的MAC地址
经过路由器C之后路由器C的MAC地址路由器D的MAC地址
经过路由器D之后路由器D的MAC地址主机2的MAC地址

网络字节序

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

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

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

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

socket常见API

创建套接字:(TCP/UDP,客户端+服务器)

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

绑定端口号:(TCP/UDP,服务器)

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

监听套接字:(TCP,服务器)

int listen(int sockfd, int backlog);

接收请求:(TCP,服务器)

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

建立连接:(TCP,客户端)

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

sockaddr结构

socket不仅支持跨网络的进程间通信,还支持本地的进程间通信(即域间套接字)。而这两种通信方式使用的是同一套接口。不同的通信方式需要用到不同的地址结构,但它们都是从通用的 sockaddr 结构派生出来的。

在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。

这三种结构体的头部的16位被称为协议家族,我们通过设置协议家族告诉接口我们采用的是什么通信协议。

基于UDP协议实现通信

我们将完成两个类,一个类为服务器,一个类为客户端。客户端将信息发送到服务端,服务端接收信息并显示。

在服务端创建套接字

无论是UDP还是TCP,服务器还是客户端,实现网络通信都首先需要创建一个套接字。

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

参数说明:

domain:指定通信协议。 

  • AF_INET:用于IPv4网络通信。
  • AF_INET6:用于IPv6网络通信。
  • AF_UNIX:用于UNIX域通信,即在同一主机上的进程间通信。
  • AF_UNSPEC:允许系统选择最合适的域。

type:指定套接字的类型,定义套接字用于何种通信方式。

  • SOCK_STREAM:提供基于字节流的、可靠的连接服务,即TCP。
  • SOCK_DGRAM:提供数据报服务,不保证数据包的顺序、完整性或可靠性,即UDP。
  • SOCK_SEQPACKET:提供有序、可靠的、固定长度的数据包服务。
  • SOCK_RAW:提供原始网络协议的访问,允许发送任意协议的数据包。

protocol:指定使用的特定协议。通常设置为0,表示使用domain和type指定的默认协议。  

返回值:

  • 如果创建成功,socket 函数返回新创建的套接字的文件描述符。
  • 如果创建失败,返回-1,并设置全局变量 errno 以指示错误类型。

 当我们创建套接字时操作系统做了什么?

在Linux系统中,有一个核心设计哲学就是“一切皆文件”(everything is a file)。意味着操作系统中的所有资源,包括硬件设备、网络连接、进程间通信等,都可以使用文件的读写操作来访问和控制。

Linux将所有资源抽象为文件,通过统一的文件描述符来访问。这简化了编程接口,因为开发者可以使用相同的系统调用来操作不同类型的资源。每个进程打开的文件(无论是实际的文件、设备、套接字还是管道)都会被分配一个文件描述符,这是一个小的非负整数。 Linux提供了一套标准I/O库函数(如 open, read, write, close),这些函数可以用于文件和其他资源的I/O操作。

设备文件是特殊类型的文件,它们代表了系统中的硬件设备。例如,/dev/tty 代表控制终端,/dev/null 是一个特殊的文件,写入到它的数据会被丢弃。Linux系统中的 /dev 目录包含了许多设备文件。这些文件按照设备类型(如字符设备或块设备)进行组织。设备文件和其他文件一样,具有权限、所有者等属性,可以用来控制访问 Linux支持符号链接,允许用户为设备或资源创建别名。

Linux中的文件系统不仅包括磁盘上的文件,还包括网络文件系统、内存文件系统(如 proc, sysfs)等。Linux中的 /proc 目录是一个虚拟文件系统,提供了一种访问进程和内核信息的方式。网络通信的套接字和进程间通信的管道也可以通过文件描述符进行操作。新的设备驱动程序或文件系统可以通过添加相应的设备文件或挂载点来集成到现有的文件系统中。

当我们创建一个套接字时,会打开一个文件描述符,在进程PCB中维护着一张文件描述符表,文件描述符表中指向的是打开文件的struct file结构体。

struct file结构体中包含着打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的。文件对应的操作方法实际就是一堆的函数指针(比如read*和write*)在内核当中就是由struct file_operations结构体来维护的。而文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡。

对普通文件执行写操作时,数据通过文件描述符表被写入到文件系统的缓存中,随后,操作系统会根据其缓存管理策略,将缓存中的数据异步或同步刷新到磁盘上。这个过程通常对用户是透明的。

对于套接字这种特殊的“网络文件”,当用户通过文件描述符向套接字写入数据时,数据同样首先进入内核的缓冲区。但与普通文件不同的是,套接字的数据最终会被操作系统发送到网络中。操作系统会定期将缓冲区内的数据传输到相应的网络接口(如网卡),然后通过物理网络发送到目标地址。

class Server
{
public:
    //构造函数
    Server(std::string ip,uint16_t port)
        :_sockfd(-1)
        ,_ip(ip)
        ,_port(port)
    {}


    //初始化
    int init()
    {
        //创建套接字
        int socket_fd=socket(AF_INET,SOCK_DGRAM,0);
        if(socket_fd<0)
        {
            return socket_fd;
        }
        _sockfd=socket_fd;
    }

    void start()
    {

    }

    //析构函数
    ~Server()
    {
        //关闭文件描述符表
        if(_sockfd>=0)
        {
            close(_sockfd);
        }
    }

private:
    //文件描述符
    int _sockfd;
    //ip地址
    std::string _ip;
    //端口号
    uint16_t _port;
}

绑定端口

创建了套接字后,我们需要绑定端口,使服务器监听指定端口上的传入数据报,并指定接收数据的端口。

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

 参数说明:

  • sockfd:这是由 socket() 函数创建的套接字的文件描述符。它是一个整数,唯一地标识了打开的套接字。
  • addr:这是一个指向 struct sockaddr 的指针,sockaddr 结构包含了套接字要绑定的网络地址信息。对于IPv4地址,这通常是 struct sockaddr_in 结构,而对于IPv6地址,则可能是 struct sockaddr_in6。结构中包含了协议族(如 AF_INET)、IP地址和端口号等信息。
  • addrlen:这是一个 socklen_t 类型的值,表示 addr 参数指向的地址结构的大小(以字节为单位)。这个长度必须正确,以确保 bind 函数知道它处理的地址结构的长度。

返回值:

  • 如果函数调用成功,它返回0。
  • 如果函数调用失败,它返回-1,并设置全局变量 errno 来指示错误类型。 

我们使用要建立网络通信,传入的结构体应为 sockaddr_in 。 

struct sockaddr_in {
    sa_family_t sin_family;   // 地址族,通常是 AF_INET
    in_port_t sin_port;       // 网络字节序的端口号
    struct in_addr sin_addr;  // IP 地址
    char sin_zero[8];         // 填充,保证结构体大小的兼容性
};

在使用 struct sockaddr_in 时,我们需要对其进行初始化,设置 sin_family,使用 htons 函数将端口号转换为网络字节序,并使用 inet_addr 或 inet_pton 函数将点分十进制的 IP 地址转换为 sin_addr 成员所需的格式。在使用 struct sockaddr_in 时,通常会对其进行初始化,设置 sin_family,使用 htons 函数将端口号转换为网络字节序,并使用 inet_addr 或 inet_pton 函数将点分十进制的 IP 地址转换为 sin_addr 成员所需的格式。

    //初始化
    bool init()
    {
        //创建套接字
        int socket_fd=socket(AF_INET,SOCK_DGRAM,0);
        if(socket_fd<0)
        {
            std::cerr << "socket error" << std::endl;
            return false;
        }
        std::cout << "socket succes" << std::endl;

        _sockfd=socket_fd;

        //绑定
        struct sockaddr_in local;
        //清零
        memset(&local, '\0', sizeof(local));
        //协议家族
        local.sin_family = AF_INET;
        //端口号
        local.sin_port=htons(_port);
        //IP
        local.sin_addr.s_addr = inet_addr(_ip.c_str());

        //绑定
        if(bind(_sockfd,(struct sockaddr*)&local,sizeof(struct sockaddr_in)) < 0 )
        {
            //绑定失败
            std::cerr << "bind error" << std::endl;     
            return false;
        }
        std::cout << "bind success" << std::endl;
       return true;
    }
接受并显示信息

完成绑定后,我们就可以进行网络通信。服务器的本质就是一个死循环,在每次循环时我们会通过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,表示默认行为。
  • src_addr:这是一个指向 sockaddr 结构的指针,用于接收发送方的地址信息。如果不需要发送方地址,可以设置为 NULL。
  • addrlen:这是一个指向 socklen_t 类型的指针,指定 src_addr 指向的缓冲区的大小。在调用返回时,它将被设置为实际接收到的地址结构的大小。

返回值

  • 成功时,recvfrom 返回接收到的字节数。
  • 失败时,返回-1,并设置全局变量 errno 以指示错误类型。 
    void start()
    {
        char buffer[1024];
        while(1)
        {
            //信息发送方信息
            struct sockaddr_in sendfrom;
            socklen_t len=sizeof(struct sockaddr_in);
            ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&sendfrom, &len);
			if (size > 0)
            {
				buffer[size] = '\0';
				int port = ntohs(sendfrom.sin_port);
				std::string ip = inet_ntoa(sendfrom.sin_addr);
				std::cout << ip << ":" << port << "# " << buffer << std::endl;
			}
			else
            {
				std::cerr << "recvfrom error" << std::endl;
			}
        }
    }

 利用命令行参数指定端口与ip

在构造类时我们需要传入IP地址与端口号。我们这里可以利用命令行参数指定端口与ip。在这里我们直接使用"127.0.0.1"。

127.0.0.1 是一个特殊的IPv4地址,称为回环地址(loopback address)。当我们想要在本地计算机上进行网络通信测试或访问网络服务时,可以使用 127.0.0.1 作为IP地址。流量不会离开你的计算机,而是在同一台主机上进行路由。

#include"server.hpp"

int main(int argc,char** argv)
{
    if(argc<2)
    {
        std::cerr << "port" << std::endl;
        return -1;
    }

    //端口号转化

    uint16_t port=atoi(argv[1]);
    std::string ip="127.0.0.1";
    Server server(ip,port);

    server.init();
    server.start();

    return 0;
}

我们编译后简单运行一下。

我们可以通过netstat命令来查看当前网络的状态,这里我们可以选择携带nlup选项。

netstat常用选项说明:

  • -n:直接使用IP地址,而不通过域名服务器。
  • -l:显示监控中的服务器的Socket。
  • -t:显示TCP传输协议的连线状况。
  • -u:显示UDP传输协议的连线状况。
  • -p:显示正在使用Socket的程序识别码和程序名称。

可以看到server正在运行。下面我们开始编写客户端。

 客户端创建套接字

客户端我们同样定义了一个类,构造时我们传入服务器端口与IP地址,在初始化时创建套接字,在析构时关闭套接字描述符。

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

class Client
{
public:
    Client(std::string server_ip , uint16_t server_port)
        :_sockfd(-1)
        ,_server_ip(server_ip)
        ,_server_port(server_port)
    {}

    bool init()
    {
        //创建套接字

        _sockfd=socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd<0)
        {
            std::cerr << "socket error" <<std::endl;
            return false;
        }
    }

    ~Client()
    {
        if(_sockfd>=0)
        {
            close(_sockfd);
        }
    }
private:
    uint16_t _server_port;
    std::string _server_ip;
    int _sockfd;
}

创建套接字后,我们并不需要显式绑定。

关于客户端的绑定问题

客户端不是不需要绑定端口,而是不能绑定特定端口。而服务端需要进行指定端口号的绑定,而客户端不需要。服务器就为了给客户端提供服务的,因此服务器必须要让别人知道自己的IP地址和端口号,IP地址一般对应的就是域名,而端口号一般没有显示指明过,因此服务端的端口号一定要是一个众所周知的端口号,并且选定后不能轻易改变,否则客户端是无法知道服务端的端口号的,这就是服务端要进行绑定的原因,只有绑定之后这个端口号才真正属于自己,因为一个端口只能被一个进程所绑定,服务器绑定一个端口就是为了独占这个端口。

而客户端在通信时虽然也需要端口号,但客户端一般是不进行绑定的,客户端访问服务端的时候,端口号只要是唯一的就行了,不需要和特定客户端进程强相关。

如果客户端绑定了某个端口号,那么以后这个端口号就只能给这一个客户端使用,就是这个客户端没有启动,这个端口号也无法分配给别人,并且如果这个端口号被别人使用了,那么这个客户端就无法启动了。所以客户端的端口只要保证唯一性就行了,因此客户端端口可以动态的进行设置,并且客户端的端口号不需要我们来设置,当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端分配一个唯一的端口号,称为隐式绑定。

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

class Client
{
public:
    Client(std::string server_ip , uint16_t server_port)
        :_sockfd(-1)
        ,_server_ip(server_ip)
        ,_server_port(server_port)
    {}

    bool init()
    {
        //创建套接字

        _sockfd=socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd<0)
        {
            std::cerr << "socket error" <<std::endl;
            return false;
        }
        return true;
    }


    ~Client()
    {
        if(_sockfd>=0)
        {
            close(_sockfd);
        }
    }
private:
    uint16_t _server_port;
    std::string _server_ip;
    int _sockfd;
}

客户端发送信息 

在客户端我们会将信息发送到客户端由客户端接受,我们通过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,表示阻塞写入。
  • dest_addr:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
  • addrlen:传入dest_addr结构体的长度。

 返回值:

  • 如果发送成功,sendto 函数返回实际发送的字节数,这个值通常与 len 相同。
  • 如果发送失败,函数返回-1,并设置errno以指示错误。 
    void start()
    {
        char buffer[1024];
        struct sockaddr_in server;
        memset(&server, '\0', sizeof(server));
        //协议家族
        server.sin_family = AF_INET;
        //端口号
        server.sin_port=htons(_server_port);
        //IP
        server.sin_addr.s_addr = inet_addr(_server_ip.c_str());
        //发送
        while (1)
        {
            std::cout << "Please enter# "
            std::cin.getline(buffer, sizeof(buffer));
            sendto(_sockfd,buffer,1024,0,(struct sockaddr*)&server,(socklen_t)sizeof(struct sockaddr_in));   
        }
    }

我们在main函数中使用命令行参数,指定服务器IP地址与端口。 

#include"client.hpp"

int main(int argc,char** argv)
{
    if(argc < 3)
    {
        std::cout << "name  ip  port" << std::endl;
        return -1;
    }
    uint16_t port=atoi(argv[2]);
    std::string ip=argv[1];
    Client client(ip,port);

    client.init();
    client.start();

    return 0;
}

测试

下面我们简单测试一下

 

可以看到我们成功完成了通信。

 

 

 

 

 

  • 21
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

knight-n

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值