Socket编程预备

1. 理解源IP地址和⽬的IP地址

• IP 在⽹络中,⽤来标识主机的唯⼀性

但是这⾥要思考⼀个问题:数据传输到主机是⽬的吗?

不是的。因为数据是给⼈⽤的。
⽐如:聊天是⼈在聊天,下载是⼈在下载,浏览⽹⻚是⼈在浏览?
但是⼈是怎么看到聊天信息的呢?怎么执⾏下载任务呢?怎么浏览⽹⻚信息呢?通过启动的qq,迅雷,浏览器。⽽启动的qq,迅雷,浏览器都是进程。换句话说,进程是⼈在系统中的代表,只要把数据给进程,⼈就相当于就拿到了数据
所以:数据传输到主机不是⽬的,⽽是⼿段。到达主机内部,在交给主机内的进程,才是⽬的。

但是系统中,同时会存在⾮常多的进程,当数据到达⽬标主机之后,怎么转发给⽬标进程?

这就要在⽹络的背景下,在系统中,标识主机的唯⼀性。
在这里插入图片描述

2. 认识端⼝号

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

在这里插入图片描述
端⼝号范围划分
• 0 - 1023 :知名端⼝号,HTTP, FTP, SSH 等这些⼴为使⽤的应⽤层协议,他们的端⼝号都
是固定的.
• 1024 - 65535 :操作系统动态分配的端⼝号.客⼾端程序的端⼝号,就是由操作系统从这个范围分配的.

理解"端⼝号"和"进程ID"

我们之前在学习系统编程的时候,学习了pid 表⽰唯⼀ 个进程;此处我们的端⼝号也是唯⼀表⽰⼀个进程.那么这两者之间是怎样的关系?

进程 PID 是操作系统用来唯一标识系统内所有进程的编号,属于操作系统系统级概念;
端口号 Port 是用来唯一标识本机上需要进行网络通信的进程的编号,属于网络通信概念。
一台主机上存在大量进程,但绝大部分都是本地后台进程,不需要网络通信。
虽然 PID 本身也能唯一标识进程,但在网络通信场景下,直接使用 PID 并不合适。
可以用一个非常通俗的比喻理解:
每个人都有全国唯一的身份证号(PID),可以唯一标识一个人。
但在学校我们有学号(端口号),在公司我们有工号(端口号)。
为什么不直接用身份证号管理学校、公司人员?
因为身份证号是国家行政全局编号;
学号是学校内部管理编号,工号是公司内部管理编号。
并不是所有人都在这所学校、这家公司,内部管理完全没必要使用全局身份证号。
同时学号、工号还可以自带年份、部门、类别等便于内部管理的信息。
同理:
PID 是操作系统全局所有进程的标识;
端口号是网络环境下,仅对外网络通信进程的专用标识。
不同场景使用不同唯一编号,更加适配对应场景、便于管理。

另外,⼀个进程可以绑定多个端⼝号;但是⼀个端⼝号不能被多个进程绑定;

理解源端⼝号和⽬的端号
传输层协议( TCP 和 UDP )的数据段中有两个端⼝号,分别叫做源端⼝号和⽬的端⼝号.就是在描述"数据是谁发的,要发给谁";

3.理解socket

• 综上, IP 地址⽤来标识互联⽹中唯⼀的⼀台主机, port ⽤来标识该主机上唯⼀的⼀个⽹络进程
• IP+Port 就能表⽰互联⽹中唯⼀的⼀个进程
• 所以,通信的时候,本质是两个互联⽹进程代表⼈来进⾏通信,{srcIp,srcPort,dstIp,dstPort}
这样的4元组就能标识互联⽹中唯⼆的两个进程
• 所以,⽹络通信的本质,也是进程间通信
• 我们把 ip+port 叫做套接字 socket

理解socket这个名字

socket在英文上有“插座”的意思,插座上有不同规格的插孔,我们将插头插入到对应的插孔当中就能够实现电流的传输。

在进行网络通信时,客户端就相当于插头,服务端就相当于一个插座,但服务端上可能会有多个不同的服务进程(多个插孔),因此当我们在访问服务时需要指明服务进程的端口号(对应规格的插孔),才能享受对应服务进程的服务。

4.传输层的典型代表

• 如果我们了解了系统,也了解了⽹络协议栈,我们就会清楚,传输层是属于内核的,那么我们要通过⽹络协议栈进⾏通信,必定调⽤的是传输层提供的系统调⽤,来进⾏的⽹络通信。

在这里插入图片描述

认识TCP协议

此处我们先对 TCP ( Transmission Control Protocol 传输控制协议)有⼀个直观的认识;后
⾯我们再详细讨论TCP的⼀些细节问题.
• 传输层协议
• 有连接
• 可靠传输
• ⾯向字节流

TCP协议叫做传输控制协议,TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。

TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。

认识UDP协议

此处我们也是对 UDP ( User Datagram Protocol ⽤⼾数据报协议)有⼀个直观的认识;后⾯再详细讨论.
• 传输层协议
• ⽆连接
• 不可靠传输
• ⾯向数据报

UDP协议叫做用户数据报协议,UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。

使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。

既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?

TCP 虽然可靠,但结构复杂、开销大、速度慢;
UDP 虽然不可靠,但简单、轻量、速度快、延迟低。
UDP 实现极其简单
不需要建立连接、不需要确认、不需要重传、不需要排序,内核处理成本极低。
UDP 传输速度更快、延迟更低
没有 TCP 的三次握手、拥塞控制、滑动窗口等机制,发完就走,适合实时场景。

5.⽹络字节序

我们已经知道,内存中的多字节数据相对于内存地址有⼤端和⼩端之分,磁盘⽂件中的多字节数据相对于⽂件中的偏移地址也有⼤端⼩端之分,⽹络数据流同样有⼤端⼩端之分.

计算机在存储数据时是有大小端的概念的:

大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。

如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。

例如,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。

但由于发送端和接收端采用的分别是小端存储和大端存储,此时对于内存地址从低到高为44332211的序列,发送端按小端的方式识别出来是0x11223344,而接收端按大端的方式识别出来是0x44332211,此时接收端识别到的数据与发送端原本想要发送的数据就不一样了,这就是由于大小端的偏差导致数据识别出现了错误。

由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。

如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
如果发送端是大端,则可以直接进行发送。
如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
如果接收端是大端,则可以直接进行数据识别。

网络字节序与主机字节序之间的转换

为使网络程序具有可移植性,使同样的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位长整数从主机字节序转换为网络字节序。
如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。
如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。

6.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);

7.sockaddr结构

sockaddr结构的出现

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

为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。

在这里插入图片描述

sockaddr 通用地址结构原理

调用 socket 系列接口(bind/connect/accept)传参时,不需要分别传入 sockaddr_in(网络通信)、sockaddr_un(本地域套接字通信)等不同结构体,而是统一传入通用结构体 sockaddr。
在初始化地址参数时,通过设置结构体里的协议家族字段(sa_family),来区分当前是网络通信还是本地本地通信。
操作系统 API 内部,会提取 sockaddr 结构体头部 16 位的协议家族字段,识别通信类型,再执行对应逻辑。
通过这种通用 sockaddr 抽象,Linux 把网络套接字、本地套接字的入参类型统一了,实现一套接口适配多种地址类型。
注意:
实际开发写代码时,我们依然定义的是专用结构体 sockaddr_in;
只是在调用 bind/connect 传参时,把它的地址强制类型转换成 struct sockaddr* 传入即可。

struct sockaddr → 父类(通用接口)
struct sockaddr_in(网络 IP 端口)→ 子类 1
struct sockaddr_un(本地域套接字)→ 子类 2

简单的UDP网络程序

**

服务端创建套接字

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

socket函数

创建套接字的函数叫做socket,该函数的函数原型如下:

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

参数说明:

domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。

type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。

protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。

返回值说明:
套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。

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

网络协议栈采用分层结构,按照 TCP/IP 四层模型,自顶向下依次为:应用层、传输层、网络层、数据链路层。
我们当前编写的代码都属于用户态应用层代码。
传输层、网络层、数据链路层的协议逻辑,全部在内核操作系统内部实现。
因此我们在应用层开发网络程序时,本质是调用操作系统下三层提供的底层能力,而这些操作系统提供给应用层的接口,统一叫做系统调用接口。

socket函数是被谁调用的?

socket 函数并不是被源代码直接调用,而是我们编写代码编译形成可执行程序,程序运行后加载到内存成为进程。
当 CPU 调度执行这个进程,执行流运行到 socket 函数指令时,才会触发系统调用,进入内核执行创建套接字的逻辑。
因此严格来说:
socket 函数是被进程调用的,不是代码、不是程序、不是开发者直接调用。

socket函数底层做了什么?

socket 函数是由进程调用的。

每个进程在内核中都拥有独立的进程地址空间、PCB(task_struct)、文件描述符表(files_struct),以及所有打开的文件结构体。

文件描述符表内部包含一个指针数组 fd_array[]:
下标 0 → 标准输入
下标 1 → 标准输出
下标 2 → 标准错误
当我们调用 socket() 创建套接字时,本质是在内核中打开了一个 “网络文件”:
内核会创建一个对应的 struct file 结构体
将该结构体链入进程的文件双链表
将结构体指针放入 fd_array[] 中最小的未使用下标(通常是 3)
把这个下标(文件描述符)返回给用户态进程

struct file 包含文件所有信息:
文件属性(由 struct inode 维护)
文件操作方法(由 struct file_operations 函数指针表维护)
文件缓冲区

对于普通文件:
文件缓冲区 → 最终刷到磁盘。
对于 socket 网络文件:
文件缓冲区 → 最终刷到网卡。
操作系统将数据写入网卡,网卡负责把数据发送到网络中,完成网络通信。

总结
socket 本质就是进程打开的一个网络文件,文件缓冲区对应网卡;
进程通过文件描述符读写缓冲区,操作系统负责将数据投递到网络。
在这里插入图片描述

服务端创建套接字

当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。

class UdpServer
{
public:
    bool InitServer()
    {
        //创建套接字
        _sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd < 0)
        {
            std::cerr <<"socket error"<<std::endl;
            return false;
        }

        std::cout<<"socket create success, socket: "<< _sockfd << std::endl;
        return true;
    }

    ~UdpServer()
    {
        if(_sockfd > 0)
        {
            close(_sockfd);
        }
    };
private:
    int _sockfd;
};

注意: 当析构服务器时,我们可以将sockfd对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的。

这里我们可以做一个简单的测试,看看套接字是否创建成功。

#include <memory>

int main()
{
    std::unique_ptr<UdpServer> svr = std::make_unique<UdpServer>();
    svr->InitServer();
}

运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3,这也很好理解,因为0、1、2默认被标准输入流、标准输出流和标准错误流占用了,此时最小的、未被利用的文件描述符就是3。
在这里插入图片描述

服务端绑定

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

bind函数

绑定的函数叫做bind,该函数的函数原型如下:

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

参数说明:

sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen:传入的addr结构体的长度。

返回值说明:
绑定成功返回0,绑定失败返回-1,同时错误码会被设置。

struct sockaddr_in结构体

在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in结构体。

在这里插入图片描述

struct sockaddr_in当中的成员如下:

sin_family:表示协议家族。
sin_port:表示端口号,是一个16位的整数。
sin_addr:表示IP地址,是一个32位的整数。
剩下的字段一般不做处理,当然你也可以进行初始化。

其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
在这里插入图片描述

如何理解绑定?

在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。

增加IP地址和端口号

由于绑定时需要用到IP地址和端口号,因此我们需要在服务器类当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号对对应的成员进行初始化。

#pragma once

#include <iostream>
#include <unistd.h>     // close()
#include <sys/socket.h> // socket()
#include <netinet/in.h> // AF_INET
#include <cstdlib>      // 标准库
#include <string>

class UdpServer
{
public:
    UdpServer(uint16_t port,std::string ip)
    :_sockfd(-1),
     _port(port),
     _ip( ip)
    {}



    bool InitServer()
    {
        //创建套接字
        _sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd < 0)
        {
            std::cerr <<"socket error"<<std::endl;
            return false;
        }

        std::cout<<"socket create success, socket: "<< _sockfd << std::endl;
        return true;
    }

    ~UdpServer()
    {
        if(_sockfd > 0)
        {
            close(_sockfd);
        }
    };
private:
    int _sockfd;
    uint16_t _port;
    std::string _ip;
};

服务端绑定

套接字创建完毕后我们就需要进行绑定了,但在绑定之前我们需要先定义一个struct sockaddr_in结构,将对应的网络属性信息填充到该结构当中。由于该结构体当中还有部分选填字段,因此我们最好在填充之前对该结构体变量里面的内容进行清空,然后再将协议家族、端口号、IP地址等信息填充到该结构体变量当中。

需要注意的是,在发送到网络之前需要将端口号设置为网络序列,由于端口号是16位的,因此我们需要使用前面说到的htons函数将端口号转为网络序列。此外,由于网络当中传输的是整数IP,我们需要调用inet_addr函数将字符串IP转换成整数IP,然后再将转换后的整数IP进行设置。

当网络属性信息填充完毕后,由于bind函数提供的是通用参数类型,因此在传入结构体地址时还需要将struct sockaddr_in强转为struct sockaddr类型后再进行传入。

class UdpServer
{
public:
    UdpServer(uint16_t port,std::string ip)
    :_sockfd(-1),
     _port(port),
     _ip( ip)
    {}

    bool InitServer()
    {
        //创建套接字
        _sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd < 0)
        {
            std::cerr <<"socket error"<<std::endl;
            return false;
        }

        std::cout<<"socket create success, socket: "<< _sockfd << std::endl;
        return true;
    }

    //填充网络通信相关信息
    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

    //绑定
    int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
    if(n < 0)
    {
        std::cerr<<"bind error"<<std::endl;
        return false;
    }


    ~UdpServer()
    {
        if(_sockfd > 0)
        {
            close(_sockfd);
        }
    };
private:
    int _sockfd;
    uint16_t _port;
    std::string _ip;
};

字符串IP VS 整数IP

IP地址的表现形式有两种:

字符串IP:类似于192.168.233.123这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。
整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。

整数IP存在的意义

这是点分十进制字符串。
算一算长度:
192.168.1.100 → 至少 13 个字节
要是再长一点,比如 100.100.100.100 → 15 个字节
问题:
网络传输带宽特别宝贵
发一个 IP 就要 15 字节,太浪费!

IP 是 4 段:
192 . 168 . 1 . 100
每一段范围:0~255
而 0~255 只需要 8 位(1 字节) 就能存下。
所以:
192 → 1 字节
168 → 1 字节
1 → 1 字节
100 → 1 字节
总共 4 字节 = 32 位整数!
这就是 整数 IP。

网络带宽很贵,IP 不能用字符串传,必须压缩成 4 字节整数,节省空间、提高速度。

字符串IP和整数IP相互转换的方式

转换的方式有很多,比如我们可以定义一个位段A,位段A当中有四个成员,每个成员的大小都是8个比特位,这四个成员就依次表示IP地址的四个区域,一共32个比特位。

然后我们再定义一个联合体IP,该联合体当中有两个成员,其中一个是32位的整数,其代表的就是整数IP,还有一个就是位段A类型的成员,其代表的就是字符串IP。
在这里插入图片描述
由于联合体的空间是成员共享的,因此我们设置IP和读取IP的方式如下:

当我们想以整数IP的形式设置IP时,直接将其赋值给联合体的第一个成员就行了。
当我们想以字符串IP的形式设置IP时,先将字符串分成对应的四部分,然后将每部分转换成对应的二进制序列依次设置到联合体中第二个成员当中的p1、p2、p3和p4就行了。
当我们想取出整数IP时,直接读取联合体的第一个成员就行了。
当我们想取出字符串IP时,依次获取联合体中第二个成员当中的p1、p2、p3和p4,然后将每一部分转换成字符串后拼接到一起就行了。

inet_addr函数

实际在进行字符串IP和整数IP的转换时,我们不需要自己编写转换逻辑,系统已经为我们提供了相应的转换函数,我们直接调用即可。

将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:

in_addr_t inet_addr(const char *cp);

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

inet_ntoa函数

将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:

char *inet_ntoa(struct in_addr in);

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

运行服务器

UDP服务器的初始化就只需要创建套接字和绑定就行了,当服务器初始化完毕后我们就可以启动服务器了。

服务器实际上就是在周而复始的为我们提供某种服务,服务器之所以称为服务器,是因为服务器运行起来后就永远不会退出,因此服务器实际执行的是一个死循环代码。由于UDP服务器是不面向连接的,因此只要UDP服务器启动后,就可以直接读取客户端发来的数据。

recvfrom函数

UDP服务器读取数据的函数叫做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:对端网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen:调用时传入期望读取的src_addr结构体的长度,返回时代表实际读取到的src_addr结构体的长度,这是一个输入输出型参数。
返回值说明:

读取成功返回实际读取到的字节数,读取失败返回-1,同时错误码会被设置。

sendto函数

UDP客户端发送数据的函数叫做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结构体的长度。

返回值说明:
写入成功返回实际写入的字节数,写入失败返回-1,同时错误码会被设置。

void Start()
    {
        _isrunning = true;
        while(_isrunning)
        {
            char buffer[1024];
            buffer[0] = 0;//清空缓冲区
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            //1.读取数据
            ssize_t n = recvfrom(_socket, buffer, sizeof(buffer), 0, (struct sockaddr*)&peer,&len);
            if(n > 0)
            {
                //client是谁啊??ip和端口给我!
                uint16_t clientport = ntohs(peer.sin_port);
                std::string clientip = inet_ntoa(peer.sin_addr);

                buffer[n]=0;
                LOG(LogLevel::DEBUG) << "[" << clientip 
                    << ":" << clientport << "]# " << buffer;

                std::string echo_string = "server echo# ";
                echo_string += buffer;

                sendto(_socket, echo_string.c_str(),echo_string.size(),0,(struct sockaddr*)&peer, len);
                
            }

        }
        _isrunning = false;
    }

INADDR_ANY

一台服务器不止一个 IP:
对外公网 IP
内网 IP
本地回环 127.0.0.1
可能还有 Docker 虚拟网卡 IP

INADDR_ANY 不是 “对外暴露 IP”,它是 “本机监听所有 IP”
它只决定服务器能从哪些 IP 收数据,
不挑哪个 IP,
只要数据包是发给这台机器、这个端口的,
不管从哪来,都收
客户端在同一台机器 → 能连
客户端在局域网 → 能连
客户端在公网 → 能连
客户端用内网 IP → 能连
客户端用公网 IP → 能连

local.sin_addr.s_addr = htonl(INADDR_ANY);

听本机的所有 IP(127.0.0.1、192.168.1.100、公网 IP)都行。

引入命令行参数

鉴于构造服务器时需要传入端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的端口号即可。

#include "UdpServer.hpp"
#include <iostream>
#include <memory>

void Usage(std::string proc)
{
    std::cerr "Usage: " << proc <<" localport"<<std::endl;

}

//.Udp_server serverport
int main(int argc,char *argc[])
{
    if(argc != 2)
    {
        Usage(argc[0]);
        exit(0);
    }

    uint16_t port = std::stoi(argc[1]);

    EnableConsoleLogStrategy();
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
    usvr -> InitServer();
    usvr -> Start();
}

客户端

关于客户端的绑定问题

  1. 服务端必须 bind IP + 端口
    服务端要被别人找到
    端口必须固定、众所周知
    必须独占端口(一个端口只能一个进程用)
    必须用 INADDR_ANY 监听所有网卡
    不 bind → 客户端找不到你。

  2. 客户端不需要 bind
    客户端是主动发起访问的
    端口由 OS 自动分配(临时端口 / 动态端口)
    不需要固定,只要唯一就行
    不需要独占,用完就回收
    bind 反而会害了客户端:端口被占就启动不了

启动客户端

增加服务端IP地址和端口号

作为一个客户端,它必须知道它要访问的服务端的IP地址和端口号,因此在客户端类当中需要引入服务端的IP地址和端口号,此时我们就可以根据传入的服务端的IP地址和端口号对对应的成员进行初始化。

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

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " serverip serverport" << std::endl;
}

// ./udp_client server_ip server_port
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        Usage(argv[0]);
        exit(0);
    }

    std::string serverip = argv[1];
    uint16_t serverport = std::stoi(argv[2]);

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        std::cout << "create socket errror" << std::endl;
        return 0;
    }
    // [srcip, srcport] [dstip, dstport]
    // client要不要:显示的bind自己的ip和端口?不要!!!
    // client 要不要 隐式bind IP和端口?
    // 为什么?client会在自己OS的帮助下,随机bind端口号

    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 (true)
    {
        std::cout << "Please Enter@ ";
        std::string line;
        std::getline(std::cin, line);

        // 写
        sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr*)&server, sizeof(server));

        //读
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);
        char buffer[1024];
        int m = recvfrom(sockfd, buffer, sizeof(buffer), 0, (struct sockaddr*)&temp, &len);
        if(m > 0)
        {
            buffer[m] = 0;
            std::cout << buffer << std::endl;
        }
    }

    return 0;
}

本地测试

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

在这里插入图片描述

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

UdpServer.hpp

#pragma once

#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <strings.h>
#include <cstdlib>
#include "Logger.hpp"


static const int gdefaultsockfd = -1;
class UdpServer
{
public:
    UdpServer(uint16_t port)
    :   _port(port),
        _sockfd(gdefaultsockfd),
        _isrunning(false)
    {}
    void Init()
    {
        // 1. 创建socket fd
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if(_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(1);
        }
        LOG(LogLevel::INFO) << "create socket success : " << _sockfd; // 3

        // 2. bind
        // 2.1: 填充IP和Port
        // 我们有没有实现,把socket和file关联起来呢??没有!!!
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        // local.sin_addr.s_addr = INADDR_ANY; // 任意IPbind
        // 什么叫做任意IP bind? 不明确具体IP,只要是发给我对应的主机,对应的port
        // 我都能收到!
        local.sin_addr.s_addr = htonl(INADDR_ANY); // 任意IPbind
        //local.sin_addr = inet_addr(_ip.c_str()); //inet_addr:  1. 字符串转整数ip 2. 整数ip是网络序列的
        // ?
        //local.sin_addr.s_addr = inet_addr(_ip.c_str()); //inet_addr:  1. 字符串转整数ip 2. 整数ip是网络序列的


        // 2.2 和socketfd进行bind
        int n = bind(_sockfd, (struct sockaddr*)&local, sizeof(local));
        if(n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(2);
        }
        LOG(LogLevel::INFO) << "bind socket success : " << _sockfd; // 3
    }
    void Start()
    {
        // 所有的服务器都是死循环
        _isrunning = true;
        while(_isrunning)
        {
            char buffer[1024];
            buffer[0] = 0; // 清空缓冲区
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 1. 读取数据
            ssize_t n = recvfrom(_sockfd, buffer, sizeof(buffer), 0,
                (struct sockaddr*)&peer, &len);
            if(n > 0)
            {
                //client是谁啊??ip和端口给我!
                uint16_t clientport = ntohs(peer.sin_port);
                std::string clientip = inet_ntoa(peer.sin_addr);

                buffer[n] = 0;
                LOG(LogLevel::DEBUG) << "[" << clientip 
                    << ":" << clientport << "]# " << buffer;

                std::string echo_string = "server echo# ";
                echo_string += buffer;

                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0,
                    (struct sockaddr*)&peer, len);
            }
        }
        _isrunning = false;
    }
    void Stop()
    {
        _isrunning = false;
    }
    ~UdpServer(){}
private:
    int _sockfd; 
    uint16_t _port;
    // std::string _ip; // 暂时,"192.168.1.1"

    bool _isrunning;
};

UdpServer.cc

#include "UdpServer.hpp"
#include <iostream>
#include <memory>

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport" << std::endl;
}

// ./udp_server serverport
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    uint16_t port =std::stoi(argv[1]);

    EnableConsoleLogStrategy();
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
    usvr->Init();
    usvr->Start();


    return 0;
}

UdpClient.cc

#include "UdpServer.hpp"
#include <iostream>
#include <memory>

void Usage(std::string proc)
{
    std::cerr << "Usage: " << proc << " localport" << std::endl;
}

// ./udp_server serverport
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(0);
    }

    uint16_t port =std::stoi(argv[1]);

    EnableConsoleLogStrategy();
    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port);
    usvr->Init();
    usvr->Start();


    return 0;
}

总结

  1. socket 是什么
    客户端、服务端各自创建独立的 socket,不是同一个。
    socket 就是操作系统给进程开的网络通信文件 / 缓冲区,用来存要发、要收的数据。
  2. bind 绑定到底干嘛
    不绑定 IP + 端口 → socket 和网卡完全没关系,不能收发数据
    绑定 = 把 socket 挂到网卡上,告诉内核:
    发给这个 IP + 端口的数据,交给我这个 socket。
  3. 服务端 vs 客户端 绑定区别
    服务端必须手动 bind:固定 IP + 固定端口,众所周知、独占端口,用来被别人找到。
    用 INADDR_ANY = 监听本机所有网卡所有 IP,内网、本地、公网都能收到。
    客户端不用手动 bind:调用 sendto 时系统自动隐式绑定,自动选网卡、自动分配随机临时端口,用完释放,不会占用。
  4. 通信完整流程
    客户端创建自己的 socket(文件),把数据塞进自己 socket 发送缓冲区
    客户端知道服务端 IP + 端口,内核打包 UDP 报文,报文自带客户端自己 IP + 端口
    数据通过网卡、网络,发到服务端网卡
    内核识别端口,把数据放进服务端自己的 socket接收缓冲区
    服务端 recvfrom 读取数据,同时内核把客户端 IP + 端口填出来(输出参数)
    服务端就能知道是谁发的,也能给对方回复

双方各自有独立 socket 通信文件;
服务端手动绑定 IP 端口挂网卡待客;
客户端系统自动绑定随机端口主动找人;
数据包自带双方地址,内核通过 IP + 端口把数据精准投递到对方 socket。

客户端把数据 → 发到服务端的网卡
内核拿到数据包,识别目标端口
把数据从网卡,投递到服务端对应的 socket 缓冲区
服务端 read/recvfrom 读取,并从报文里拿到客户端 IP + 端口
服务端要回复时,把数据写入自己的 socket 发送缓冲区
内核根据客户端 IP + 端口,把数据发到客户端的网卡
客户端内核再投递到客户端 socket

  1. 网卡 / IP / 端口 三者区别
    网卡:物理硬件,收发信号,有 MAC 地址
    IP:逻辑地址,定位哪台机器
    端口:逻辑编号,定位机器上哪个进程(socket)

网卡是公共入口,
内核根据 IP + 端口分拣,
把属于你的数据,扔进你的 socket 缓冲区。

分发客户端

只要你的服务器有公网 IP,外网任何人都能访问你的 UDP 服务
不管他在哪:
家里
学校
外地
手机流量
只要知道你的公网 IP + 端口,就能连接、发消息。

此时我们可以先使用sz命令将该客户端可执行程序下载到本地机器,然后将该程序发送给你的朋友。而我们分发客户端的过程实际上就是我们在网上下载各种PC端软件的过程,我们下软件下的实际上就是客户端的可执行程序,而与之对应的服务端就在Linux服务器上部署着。

时你先把你的服务器启动起来,然后你的朋友将你的IP地址和端口号作为命令行参数运行客户端,就可以访问你的服务器了。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐