网络套接字编程(一)

网络套接字编程(一)

文章目录

预备知识

源IP地址和目的IP地址

在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。

源IP地址的作用是让接收方能够将数据返回过来。

目的IP地址的作用进行路由选择和确认数据是否传输到指定位置。

端口号

端口号

在了解端口号前要明确一些概念,首先,网络通信使得网络中的一台主机可以将数据传输给另一台主机,其目的是为了让多台主机协同完成工作。其次,在网络协议栈中由应用层产生数据,交付给下层进行传输。而网络应用层中产生数据的其实是进程,进程为了完成一个工作通过网络将数据交付给其他主机的进程,从而完成多个进程协同工作,因此网络通信的本质是进程间通信

如下图,客户端进程通过网络将数据传输给服务端进程,让服务端进程对所得到的数据进行处理,从而完成任务:

既然,网络通信的本质是进程间通信,因此在网络传输时,必须知道数据要定位接收方进程,因此使用端口号定位主机中的一个进程 ,又因为IP地址能够定位网络中的一台主机,因此IP地址+端口号可以定位网络中的唯一一个进程。完整的IP中包含IP地址和端口号。

端口号(port)作为传输层协议的内容,其内容如下:

  • 端口号是一个2字节16位的整数。
  • 端口号用来标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理。
  • IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。
  • 一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定。

不采用进程PID定位进程而采用端口号的原因

网络的管理在操作系统中属于文件管理的范畴,而进程PID是属于进程管理的范畴,并且网络在文件管理中属于一个单独的模块,如果使用进程PID就会将网络管理和进程管理关联起来,会使得二者耦合度变高,不利于维护。

网络将数据交付给进程的原理

网络的管理在操作系统中属于文件管理的范畴,进程的管理属于进程管理的范畴,网络要将数据交给进程需要将数据加载到文件的缓冲区中,然后让进程使用文件操作读取缓冲区的内容,最终得到网络传输过来的数据。

TCP/UDP协议特点

应用层进程主要是从传输层获取网络传输的数据,传输层中有两种协议:TCP协议、UDP协议。

TCP协议特点

TCP(Transmission Control Protocol 传输控制协议):

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

注: 可靠传输是指使用TCP协议传输数据时,如果数据丢失了,会采用重传数据等策略保证接收端接收到数据。

UDP协议特点

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

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

注: 不可靠传输是指使用UCP协议传输数据时,即使数据丢失了,也不会采取任何措施。

说明一下: 可靠传输和不可靠传输是TCP协议和UDP协议的特点,而不是优缺点,因为执行可靠传输一定要付出协议复杂,传输时间长等的代价,不可靠传输由于协议简单,会有传输时间短等的优点。

网络字节序

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

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

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

c 复制代码
#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(网络),l表示32位长整数,s表示16位短整数。
  • 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
  • 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;。
  • 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

socket编程

socket常用API

c 复制代码
#include <sys/types.h>         
#include <sys/socket.h>

// 创建 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结构

socket编程中用于定义网络地址的结构体种类分为struct sockaddr_instruct sockaddr_un,其中struct sockaddr_in用于IPv4网络,struct sockaddr_un用于Unix域(本地局域网),为了兼容两种结构体,采用struct sockaddr作为socket接口的参数,在接收参数时,会判断前16位地址类型是AF_INET还是AF_UNIX,区分使用的是那种结构体。

IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示;包括16位地址类型,16位端口号和32位IP地址.

sockaddr_in的结构:

c 复制代码
struct sockaddr_in
{
    sa_family_t _sinfamily;//unsigned short int类型参数
    in_port_t sin_port;//端口号
    struct in_addr sin_addr;//IP地址
    //填充字段
    unsigned char sin_zero[sizeof (struct sockaddr) -
                           __SOCKADDR_COMMON_SIZE -
                           sizeof (in_port_t) -
                           sizeof (struct in_addr)];
};

typedef uint32_t in_addr_t;
struct in_addr
{
    in_addr_t s_addr;
};

简易UDP网络程序

为了更好的理解socket编程,编写一组简易的UDP网络程序,其包含客户端和服务端,客户端发送数据给服务端,服务端将接收的数据回传给客户端。

服务端创建套接字

我们把服务器封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字。创建套接字需要使用socket函数:

c 复制代码
//socket函数所在的头文件和函数声明
#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain, int type, int protocol);
  • 该函数用于创建网络套接字。
  • domain参数: 指明数据传输域,传入AF_INET为网络通信,传入AF_UNIX为本地通信。
  • type函数: 指明套接字种类,传入SOCK_STREAM为流式套接,传入 SOCK_DGRAM 为数据报套接。
  • protocol参数: 指明所使用的协议,默认为0,该函数会自动识别所使用的协议。
  • 返回值: 调用成功返回一个文件描述符,调用失败返回-1,错误码被设置。

socket函数属于什么类型的接口?

在计算机软硬体系结构中,程序员编程形成程序都是在操作系统之上的用户层进行的,对应TCP/IP网络协议栈的应用层,因此,socket函数是操作系统提供属于应用层的系统接口。

socket函数底层做了什么?

socket函数是被引用层的进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。

在调用socket函数后,操作系统会为该进程创建该套接字对应的文件,将其记录在该进程的文件描述符表中:

将数据写入该套接字对应的文件中,该文件刷新缓冲区的数据后,就会将数据写入网卡设备中,网卡会将数据传输出去。

创建套接字

cpp 复制代码
enum
{
    SOCKET_ERR=1
};
class UdpServer
{
    public:
    void InitServer()
    {
        //创建套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock < 0)
        {
            std::cerr << "socket create error:" << strerror(errno) << std::endl;
            exit(SOCKET_ERR); 
        }
    }
    private:
    int _sock; // 网络文件描述符
};

服务端绑定IP地址和端口号

现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。绑定IP地址和端口号需要使用bind函数:

c 复制代码
//bind函数所在的头文件和函数声明
#include <sys/types.h>         
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • 该函数用于绑定套接字的IP地址和端口号。
  • sockfd参数: 要接收数据的套接字,即调用socket函数返回的文件描述符。
  • addr参数: 用于传入一个指向存有目标IP地址和端口号的sockaddr类型指针。
  • addlen参数: sockaddr类型变量的长度。
  • 返回值: 调用成功返回一个文件描述符,调用失败返回-1,错误码被设置。
  • 云服务不需要调用bind函数绑定指定IP地址,因为云服务可以存在多个网卡设备,需要让云服务自身制定IP地址。可以在将sockaddr类型中的IP地址字段赋值为INADDR_ANY让云服务绑定任意IP地址。

sockaddr_in的结构

前面提到了使用网络通信时,采用的sockaddr结构中的sockaddr_in结构,sockaddr_in具体的数据结构如下:

c 复制代码
typedef unsigned short int sa_family_t;
typedef uint16_t in_port_t;

typedef uint32_t in_addr_t;
struct in_addr
{
    in_addr_t s_addr;
};

struct sockaddr_in
{
    sa_family_t _sinfamily;//unsigned short int类型参数
    in_port_t sin_port;//端口号
    struct in_addr sin_addr;//IP地址
    //填充字段
    unsigned char sin_zero[sizeof (struct sockaddr) -
                           __SOCKADDR_COMMON_SIZE -
                           sizeof (in_port_t) -
                           sizeof (struct in_addr)];
};
  • _sinfamily: 标识该sockaddr结构要进行的是网络通信,还是本地通信.网络通信时赋值为AF_INET,本地通信时赋值为AF_UNIX.
  • sin_port: 16位无符号整型表示的端口号。
  • s_addr: 32位无符号正式表示的IP地址。

给进程绑定IP地址和端口号的原理

进程在应用层将要绑定的IP地址和端口号写入到对应的sockaddr结构中,然后调用操作系统提供的bind系统接口,让操作系统完成进程的IP地址和端口号的绑定工作。

本地端口号和网络字节序的转化

进程执行时定义一个无符号的16位整型port变量记录要绑定的端口号后,想要将其写入sockaddr结构前,需要调用系统提供的网络字节序接口htons接口,将port转换成符合网络字节序的16位端口号,完成网络字节序的转化后,才能将其写入sockaddr结构中,并使用其绑定端口号。

绑定IP地址和端口号

cpp 复制代码
enum
{
    SOCKET_ERR = 1,
    BIND_ERROR
};
class UdpServer
{
    public:
    UdpServer(uint16_t port):_port(port) {}
    void InitServer()
    {
        // 创建套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock < 0)
        {
            std::cerr << "socket create error:" << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }
        // 绑定IP地址和端口号
        struct sockaddr_in local;//创建sockaddr结构写入IP地址和端口号
		memset(&local, 0, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_addr.s_addr = INADDR_ANY;//云服务器IP地址赋值
        local.sin_port = htons(_port);
        socklen_t len = sizeof(local);
        int n = bind(_sock, (struct sockaddr*)&local, len);//端口号绑定
        if (n < 0)
        {
            std::cerr << "bind error:" << strerror(errno) << std::endl;
            exit(BIND_ERROR);
        }
    }
    private:
    int _sock; // 网络文件描述符
    uint16_t _port;//端口号
};

字符型IP地址VS整型IP地址

IP地址是由 . 分割,由四个部分形成的,每个部分的取值范围位0\~255,如果使用字符型记录,至少需要12个字节(不记录.)也就是96个比特位,如果采用整型记录只需要4个字节,也就是32位,具体的记录方式是将一个4字节的无符号整形数据按照字节划分成4个部分,每个部分都占一个字节的空间,而一个字节的空间刚好能记录0\~255的数据:

操作系统提供了字符型IP地址和整形IP地址的转换函数,我们直接调用即可:

inet_addr函数

inet_addr函数的功能是将字符串IP转换成整数IP。

cpp 复制代码
//inet_addr函数所在的头文件和函数声明
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
in_addr_t inet_addr(const char *cp);

该函数使用起来非常简单,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP。除此之外,inet_aton函数也可以将字符串IP转换成整数IP,不过该函数使用起来没有inet_addr简单。

inet_ntoa函数

inet_ntoa函数的功能是将整数IP转换成字符串IP。

cpp 复制代码
//inet_ntoa函数所在的头文件和函数声明
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
char *inet_ntoa(struct in_addr in);

需要注意的是,传入inet_ntoa函数的参数类型是in_addr,因此我们在传参时不需要选中in_addr结构当中的32位的成员传入,直接传入in_addr结构体即可。

服务端运行

服务器运行起来后,需要完成从网络中接收数据和将数据回传给客户端的任务,接收数据需要用到recvfrom函数:

recvfrom函数

c 复制代码
//recvfrom函数所在的头文件和函数声明
#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函数返回的文件描述符。
  • buf参数: 指向用来存储接收数据的缓冲区。
  • len参数: 缓冲区长度(即最多可以接收的字节数)。
  • flags参数: 用于控制recvfrom函数的行为,默认为0,阻塞读取。
  • src_addr参数: 存储发送方的地址信息(IP地址和端口号)的sockaddr类型变量的地址。
  • addrlen参数: 指向存放发送方地址信息的sockaddr类型变量的长度的变量的地址。
  • 返回值: 成功接收数据时,返回接收到的字节数。连接关闭时,返回0。发生错误时,返回-1,并设置errno变量以指示具体的错误原因。

服务端接受数据后要发送数据,发送数据需要使用sendto函数:

sendto函数

c 复制代码
//sendto函数所在的头文件和函数声明
#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参数: 用于控制sendto函数的行为,默认为0。
  • dest_addr参数: 指定目标地址(即接收方的地址信息,包括IP地址和端口号)的sockaddr类型变量的指针。
  • addrlen参数: 指定目标地址信息的大小,即dest_addr的长度。
  • 返回值: 成功发送数据时,返回成功发送的字节数。发生错误时,返回-1,并设置errno变量以指示具体的错误原因。

启动服务器函数

启动服务器函数的功能让其从网络中接收数据,并将数据回传给客户端。

cpp 复制代码
class UdpServer
{
    public:
    void StartServer()//服务端运行
    {
        char buffer[128];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);//必须写明
            ssize_t n = recvfrom(_sock, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                buffer[n] = 0;
            }
            else
                continue;
            std::string clientip = inet_ntoa(peer.sin_addr);
            uint16_t clientport = ntohs(peer.sin_port);
            std::cout << clientip << "-" << clientport << "send#" << buffer << std::endl;
            sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
        }
    }
    private:
    int _sock; // 网络文件描述符
    uint16_t _port;//端口号
};

recvfrom函数接收sockaddr结构

recvfrom函数使用sockaddr结构从网络中接收数据时,就是按照网络字节序接收的,因此再交给sendto函数发送数据时,无需进行网络字节序的转化。

缓冲区问题

使用buffer缓冲区从网络中接收数据时,按照C语言规定需要为缓冲区预留一个字节用于存储'\0'。

使用buffer缓冲区向网络中发送数据时,无需发送'\0',因为那是C语言的规定不是网络的规定。

recvfrom函数接收数据时会将网络字节序转换为主机序列写入缓冲区,sendto函数向网络发送数据时会将缓冲区数据从主机序列转换成网络字节序发送。

运行服务端

调用服务端类内部的函数进行服务端的初始化,并启动服务端。为了给错误启动服务端纠错,引入了命令行参数,在启动服务端时做纠错提示:

cpp 复制代码
void Usage(const char *proc)
{
    std::cout << "Usage:\n\t" << proc << "port\n" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
    }
    uint16_t port = atoi(argv[1]);

    std::unique_ptr<UdpServer> ustr(new UdpServer(port));
    ustr->InitServer();
    ustr->StartServer();
    return 0;
}

启动服务端

启动服务端后,使用netstat -naup指令查看服务端进程信息:

客户端创建套接字

同样的,将客户端封装成类,在使用客户端时,只需要创建类对象,然后调用对应的函数即可使用客户端。在创建客户端类对象后的第一步就是初始化客户端,在初始化客户端时,首先就需要创建套接字:

cpp 复制代码
enum
{
    SOCKET_ERR = 1,
    BIND_ERROR
};
class Udp_Client
{
    public:
    void InitClient()
    {
        //创建套接字
        _sock = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sock < 0)
        {
            std::cerr << "socket create error:" << strerror(errno) << std::endl;
            exit(SOCKET_ERR);
        }
    }
    private:
    int _sock;
};

客户端绑定问题

使用socket进行网络通信,是需要通过IP地址和端口号确定唯一进程,然后再进行通信的,客户端如果不进行IP地址和端口号的绑定,服务端就无法将数据再回传给客户端,因此客户端是一定需要绑定IP地址和端口号的。

一台主机上会存在大量的客户端进程,如果每个客户端进程都要指定绑定端口号,可能会因为客户端端口号冲突造成客户端启动失败的问题,并且客户端只要能够实现和服务端进行网络通信的功能即可,端口号的具体值并不重要,因此客户端不能绑定指定的端口号,需要让操作系统来完成客户端端口号的绑定。

服务端是给众多客户端提供网络服务的,服务端的端口号如果随意改变,客户端就会因为服务端的端口号的改变导致无法找到服务端。因此服务端的端口号一定需要自主绑定。

客户端在首次调用发送数据的系统调用时,操作系统会自动选择端口号和自身的IP地址绑定到客户端。

启动客户端

运行客户端函数

运行客户端函数的功能是接受用户输入的数据将其发送给服务端,然后接受服务端回传的数据。

cpp 复制代码
class Udp_Client
{
    public:
    Udp_Client(std::string serverip, uint16_t serverport):_serverip(serverip), _serverport(serverport)
    {}
    void StartClient()
    {
        while(true)
        {
            std::cout << "Please enter message#";
            std::string message;
            getline(std::cin, message);
            struct sockaddr_in peer;//指明服务端IP地址和端口号
            peer.sin_family = AF_INET;
            peer.sin_addr.s_addr = inet_addr(_serverip.c_str());
            peer.sin_port = htons(_serverport);
            sendto(_sock, message.c_str(), message.size(), 0, (struct sockaddr*)&peer, sizeof(peer));
            struct sockaddr_in temp;
            socklen_t tlen;
            char buffer[128];
            ssize_t n = recvfrom(_sock, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&temp, &tlen);
            if (n > 0)
            {
                buffer[n] = 0;
                std::cout << "server echo:" << buffer << std::endl;
            }
        }
    }
    private:
    int _sock;
    std::string _serverip;
    uint16_t _serverport;
};

revfrom函数的注意事项

recvfrom函数最后两个参数src_addr,addrlen都是输出型参数,在函数中会进行赋值操作,因此不能传空指针。

启动客户端

和服务端相同,调用客户端类内部的函数进行客户端的初始化,并启动客户端。为了给错误启动客户端纠错,引入了命令行参数,在启动客户端时做纠错提示:

cpp 复制代码
void Usage(const char *proc)
{
    std::cout << "Usage:\n\t" << proc << "port\n" << std::endl;
}

int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERROR);
    }
    uint16_t port = atoi(argv[1]);

    std::unique_ptr<UdpServer> ustr(new UdpServer(port));
    ustr->InitServer();
    ustr->StartServer();
    return 0;
}

程序测试

本地测试

现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8080,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8080。

客户端运行之后提示我们进行输入,当我们在客户端输入数据后,客户端将数据发送给服务端,此时服务端再将收到的数据打印输出,这时我们在服务端的窗口也看到我们输入的内容。

此时我们再用netstat命令查看网络信息,可以看到服务端的端口是8080,客户端的端口是。这里客户端能被netstat命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。

网络测试

网络测试和本地测试的方式类似,只是网络测试输入的IP地址得是服务端的IP地址:

不同于本地测试的是,可以使用其他主机访问该服务端,只需要让其他主机获取该客户端程序,然后在其他主机运行客户端时输入服务端IP地址和端口号即可完成网络通信。

相关推荐
zzzzzz3104 小时前
9K Star 炸裂开源!这个 C 语言写的代码知识图谱,把 Linux 内核索引压缩到了 3 分钟
linux·服务器·sql
XIAOHEZIcode4 小时前
Linux系统鼠标偏移常见原因以及修复方案
linux·运维·游戏
用户03284722207020 小时前
如何搭建本地yum源(上)
运维
A小辣椒2 天前
TShark:Wireshark CLI 功能
linux
A小辣椒2 天前
TShark:基础知识
linux
BingoGo2 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
JaguarJack2 天前
PHP 泛型之殇 泛型 RFC 提案被拒绝
后端·php
AlfredZhao2 天前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao3 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户3074596982073 天前
PHP 扩展——从入门到理解
php