【Linux网络编程】UDP Echo Server的实现

本文专栏: Linux网络编程

目录

一,Socket编程基础

1,IP地址和端口号

端口号划分范围

理解端口号和进程ID

源端口号和目的端口号

理解Socket

2,传输层的典型代表

3,网络字节序

4,Socket编程接口

sockaddr结构

[二,Echo Server(UDP实现)](#二,Echo Server(UDP实现))

1,服务器端(server端)代码编写

创建套接字(socket)

[绑定端IP和 端口号](#绑定端IP和 端口号)

接受消息(recvfrom)

发送消息

2,客户端(client端)代码编写

3,代码总体

4,现象演示


一,Socket编程基础

1,IP地址和端口号

IP地址:

  • 定义:IP在网络中,用来标识主机的唯一性。或者说IP地址是分配给网络设备的唯一标识,用于在互联网中定位设备。

作用:

  • 设备寻址:确保数据包从源设备发送到目标设备

  • 路由选择:路由器根据IP地址决定数据包的转发路径。
    端口号:

  • 端口号是传输层协议的内容。

  • 端口号是一个2字节,16为的整数。

  • 端口号用来标识唯一进程,告诉操作系统,当前的这个数据要交给哪个进程来处理。

  • 一个端口号只能 被一个进程占用。

所以IP地址+端口号能够标识网络上的某一台主机的某一个进程。

端口号划分范围

  • 0-1023:是指明端口号,HTTP,SSH,FTP等广为使用的应用层协议,他们的端口号都是固定的。
  • 1024-65535:是操作系统动态分配的端口号。客户端运行的程序,就是由操作系统从这个范围分配的。

理解端口号和进程ID

在操作系统中,我们知道pid表示唯一一个进程。而这里端口号也是表示唯一一个进程 。这两个之间有什么关系?

进程ID属于系统概念,技术上 也具有唯一性,确实可以用来标识唯一的一个进程 。但是如果让进程ID代替端口号,会让系统进程管理和网络强耦合 。

源端口号和目的端口号

传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号。

就是在 描述"数据是谁发的,要发给谁"。

理解Socket

  • IP地址用来标识主机的唯一性,port端口号用来标识该台主机上 唯一的一个进程。
  • 所以IP+port就能 表示互联网中唯一的一个进程。
  • 所以,通信的时候,本质是两个互联网进程在通信,所以,网络通信的本质是进程间通信。
  • 我们把IP+port叫做套接字(Socket)。

2,传输层的典型代表

传输层是属于内核的,所以进行网络通信,必定调用的是传输层提供的系统调用。

TCP协议:

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

UDP协议:

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

3,网络字节序

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

所谓小端,就是对于多字节的数据,它的低权值位存放在低地址处,高权值位存放在高地址处。

大端与之相反,低权值位存放在高地址处,高权值位存放在低地址处 。

发送主机通常将发送的数据按内存地址从低到高发出。

接受主机把从网络上接受的数据依次保存在缓冲区,也是按内存地址从低到高的顺序保存的。

而不同的主机,它的存储序列可能不同,可能是大端存储,也有可能是小端存储。

如果两台机器的存储形式不同,一台是大端存储,另一台是小端存储,那么在接受数据的时候,就会将数据解释错了。

解决方法:于是就有了一个规定, 发送到网络中的数据必须是大端形式的

所以,不管这台主机 是大端序列还是小端序列,都会按照这个规定的网络字节序来发送/接受数据。

如果当前主机是小端,就需要先将数据转化为大端;如果是大端,就忽略。

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

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

4,Socket编程接口

常见API

C
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器 )
int socket(int domain,int type,int protocol);


//绑定端口号(TCP/UDP,服务器)
int bind(int socket,const struct sockaddr* address,sock_lent addresslen);


//开始监听socket(TCP,服务器)
int listen(int socket,int backlog);


//接收请求(TCP,服务器)
int accept(int socket,struct sockaddr* address,sock_lent* addresslen);


//建立连接(TCP,客户端)
int connect(int sockfd,const struct sockaddr* addr,sock_lent* addrlen);

在上面的API接口中,sockaddr这个结构体经常出现。它是什么呢?

sockaddr结构

二,Echo Server(UDP实现)

Echo Server(回显服务器)是一种网络应用程序。其核心功能是接受客户端发来的数据,并将接受到的数据原样返回给客户端。

核心逻辑:

  1. 服务端

    创建套接字 → 绑定地址 → 监听连接 → 接受请求 → 读取数据 → 回传数据。

  2. 客户端

    创建套接字 → 连接服务器 → 发送数据 → 接收回显数据。

不过我们实现的是UDP的,所以没有监听连接这一步。

1,服务器端(server端)代码编写

这里对服务器端代码编写时,会采用面向对象的思路,简单的进行封装。

大致框架:

复制代码
class udpserver
{
    public:
    udpserver(uint16_t port)
    :_port(port), 
    _isrunning(false)
    {}
    //


    /
    ~udpserver()
    {}
    private:
    int _sockfd;//套接字
    uint16_t _port;//端口号
    bool _isrunning;//是否在进行网络通信
};

下面就是正式对网络通信的代码编写:

首先,定义一个init方法,在该函数中,完成创建套接字和绑定的任务。

创建套接字(socket)

关于该函数的返回值 ,文档中的说明如下:

可以看出,该函数的返回值是一个文件描述符,-1表示失败,和文件系统中的open一样。所以可以简单的理解成创建套接字,在系统内部会打开一个文件,然后分配一个文件描述符。

复制代码
        //1,创建套接字
        _sockfd=socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd<0)
        {
            std::cout<<"创建套接字失败"<<std::endl;
            exit(1);
        }
        std::cout<<"创建套接字成功"<<std::endl;

绑定端IP和 端口号

为当前服务器进程绑定端口号和IP地址。

前面讲过IP+端口号可以标识网络中某台主机上的唯一一个进程。

在绑定之前,需要我们填写addr这个结构体中的内容。

我们是进行网络通信,所以需要填写下图中的sockaddr_in结构体,有三个成员。最后的8字节用0填充即可。

我们在填写该结构体中的端口号时,考虑到不同机器的大小端存储形式可能不一样。所以需要先将 端口号转化为网络字节序,使用htons接口。

同时IP地址在填充的时候 ,还有一个问题,具体内容看下面代码的注释:

复制代码
        //2,绑定端口号和IP
        //填充sockarr_in信息
        struct sockaddr_in local;
        //先将结构体内容清0
        bzero(&local,sizeof(local));
        local.sin_family=AF_INET;//头部的16为,表示网络通信
        local.sin_port=htons(_port);//端口号
        //INADDR_ANY是一个宏,这个宏的值是0
        //可能存在多个客户端要访问该服务器进程
        //有的客户端拿着内网IP,有的客户端拿着本地回环IP 127.0.0.1访问该服务器进程
        //那么该服务器进程的IP就必须保持不变,是一个绑死的值,只有和该IP相等的客户端才能访问
        //将IP地址设为0的好处是:
        //不同的客户端,拿着不同的IP访问该服务器进程,只要端口号和该进程相等,就都可以访问呢
        local.sin_addr.s_addr=INADDR_ANY;//IP地址
        int n=bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
        if(n<0)
        {
            std::cout<<"绑定失败"<<std::endl;
            exit(2);
        }
        std::cout<<"绑定成功"<<std::endl;

接受消息(recvfrom)

再定义一个start方法 ,来实现接受消息和发送消息。

  • 第一个参数是创建的套接字
  • 第二个参数和第三个参数是缓冲区及对应的大小,用来存放接受到的数据
  • 第四个参数flags:该参数设置为0,表示阻塞式IO,如果对方 不发数据,该函数(进程)就会一致阻塞在这里等同于scanf
  • 第五个参数:我们作为服务器端,需要知道是哪个主机的哪个进程发的数据,就存放在该结构体中。
  • 最后一个参数:表示该结构体的大小
复制代码
//缓冲区------存放消息
char buffer[1024];
//获取哪台主机的哪个进程发送的数据,可以理解为这是一个输出型参数
struct sockaddr_in peer;
socklen_t len=sizeof(peer);
//接受消息
//我们将接受到的消息是按字符串存储的
//这里sizeof(buffer)-1是为了保留最后一个位置填充1
ssize_t n=recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);

注意:我们从网络中拿到了发送过来的数据,这些数据包括struct sockaddr结构体。

所以我们想要知道是哪台主机的哪个进程发送过来的,只要拿到该结构体中的IP和端口号即可。但是这些字段是从网络中来的,都是大端形式的。所以我们还需再做一步,将网络字节序转化为主机字节序,可以使用ntohs接口。

发送消息

服务器端接收到客户端发来的消息后,要进行出来后,再返回给客户端。也就是用户做了某个要要求,服务器端执行完后,返回给用户一个结果。

  • 第一个参数:创建的套接字
  • 第二个参数和第三个参数:发送数据的内容和长度
  • 第四个参数:和上面接受消息的一样
  • 最后两个参数:发送数据,需要知道是给谁发的,也就是目标主机的IP和端口号,这些内容存放于该结构体中

对于接受到的数据,进行处理,再返回给客户端一个返回结果。所以我们可以自定义一个处理函数,对接受到的数据执行某种方法,再将结果返回给用户。

复制代码
 //获取发送方的信息
 //获取端口号(客户端的)
 int peer_port=ntohs(peer.sin_port);//网络字节序转主机字节序
 //获取IP(点分十进制格式)
 //该函数做两个工作 1,将网络字节序转化为主机字节序 2,将主机字节序的IP再转化为点分十进制形式
 std::string peer_ip=inet_ntoa(peer.sin_addr);
buffer[n]=0;//接受到的数据
 //处理buffer,自定义一个函数
//产生一个处理结果,返回给发送方,也就是客户端
 std::string result=_func(buffer);
 //发送消息
sendto(_sockfd,result.c_str(),result.size(),0,(struct sockaddr*)&peer,len);

2,客户端(client端)代码编写

客户端代码编写与服务器端代码类似,比服务器端代码更简单。

客户端也需要创建套接字:

复制代码
//以命令行参数的形式获取IP和端口号
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];//IP
    uint16_t server_port = std::stoi(argv[2]);//端口号

    // 1. 创建socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        return 2;
    }
    

    return 0;
}

但是客户端不需要显示的绑定端口号和IP了当客户端尝试发送消息的时候,操作系统会为该客户端进程分配一个随机端口号,操作系统也知道本主机的IP地址,所以操作系统会在内部进行绑定。所以就不需要我们手动绑定了。

而为什么要这么做?

  • 首先,一个端口号只能被一个进程绑定。

  • 如果我们手动显示的绑定了,也就是我们把一个进程和一个端口号绑定在一起,绑死了。比如我们写了一个客户端,绑定一个端口号666,它没有运行。那么当再启动一个淘宝APP时,如果淘宝也绑定了这个端口号,那么我们的客户端进程就无法启动了。

  • 所以,这样采用随机端口的方式,是为了避免client端口冲突的问题。

  • 所以,客户端对应的端口号是多少不重要,只要是唯一的就行。
    而为什么服务器端需要绑定?

  • 首先,服务器肯定是被多个客户端进行访问的。

  • 服务器的IP和端口号必须是众所周知且不能轻易改变的。

  • 改变了,客户端就无法访问了。

这就好像再生活中,一些公共部门的电话是众所周知,且不能改变的,比如110,120,119等等。而我们的电话是可以改的。

所以创建套接字后,就可以发送消息了,不需要显示bind了。

发送消息,接受消息和服务器端的代码类似,这里不做过多赘述了。

3,代码总体

udpserver.hpp文件

复制代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <functional>

using func_t=std::function<std::string(const std::string&)>;
class udpserver
{
    public:
    udpserver(uint16_t port,func_t func)
    :_port(port), 
    _isrunning(false),
    _func(func)
    {}
    //
    void init()
    {
        //1,创建套接字
        _sockfd=socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd<0)
        {
            std::cout<<"创建套接字失败"<<std::endl;
            exit(1);
        }
        std::cout<<"创建套接字成功"<<std::endl;

        //2,绑定端口号和IP
        //填充sockarr_in信息
        struct sockaddr_in local;
        //先将结构体内容清0
        bzero(&local,sizeof(local));
        local.sin_family=AF_INET;//头部的16为,表示网络通信
        local.sin_port=htons(_port);//端口号
        //INADDR_ANY是一个宏,这个宏的值是0
        //可能存在多个客户端要访问该服务器进程
        //有的客户端拿着内网IP,有的客户端拿着本地回环IP 127.0.0.1访问该服务器进程
        //那么该服务器进程的IP就必须保持不变,是一个绑死的值,只有和该IP相等的客户端才能访问
        //将IP地址设为0的好处是:
        //不同的客户端,拿着不同的IP访问该服务器进程,只要端口号和该进程相等,就都可以访问呢
        local.sin_addr.s_addr=INADDR_ANY;//IP地址
        int n=bind(_sockfd,(struct sockaddr*)&local,sizeof(local));
        if(n<0)
        {
            std::cout<<"绑定失败"<<std::endl;
            exit(2);
        }
        std::cout<<"绑定成功"<<std::endl;
    }
    void start()
    {
        _isrunning=true;
        while(_isrunning)
        {
            //缓冲区------存放消息
            char buffer[1024];
            //获取哪台主机的哪个进程发送的数据,可以理解为这是一个输出型参数
            struct sockaddr_in peer;
            socklen_t len=sizeof(peer);
            //接受消息
            //我们将接受到的消息是按字符串存储的
            //这里sizeof(buffer)-1是为了保留最后一个位置填充1
            ssize_t n=recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len);
            if(n>0)
            {
                //获取发送方的信息
                //获取端口号(客户端的)
                int peer_port=ntohs(peer.sin_port);//网络字节序转主机字节序
                //获取IP(点分十进制格式)
                //该函数做两个工作 1,将网络字节序转化为主机字节序 2,将主机字节序的IP再转化为点分十进制形式
                std::string peer_ip=inet_ntoa(peer.sin_addr);

                buffer[n]=0;//接受到的数据
                //处理buffer,自定义一个函数
                //产生一个处理结果,返回给发送方,也就是客户端
                std::string result=_func(buffer);
                //发送消息
                sendto(_sockfd,result.c_str(),result.size(),0,(struct sockaddr*)&peer,len);
            }

        }
    }
    /
    ~udpserver()
    {}
    private:
    int _sockfd;//套接字
    uint16_t _port;//端口号
    bool _isrunning;//是否在进行网络通信
    func_t _func;//回调函数
};

udpserver.cpp文件

复制代码
#include "udpserver.hpp"
#include <memory>


std::string defaulthandler(const std::string &message)
{
    std::string hello = "hello, ";
    hello += message;
    return hello;
}

//端口号以命令行参数的形式获取
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return 1;
    }
    uint16_t port = std::stoi(argv[1]);


    std::unique_ptr<udpserver> usvr = std::make_unique<udpserver>(port,defaulthandler);
    usvr->init();
    usvr->start();

    return 0;
}

udpclient.cpp文件

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

//以命令行参数的形式获取IP和端口号
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cerr << "Usage: " << argv[0] << " server_ip server_port" << std::endl;
        return 1;
    }
    std::string server_ip = argv[1];//IP
    uint16_t server_port = std::stoi(argv[2]);//端口号

    // 1. 创建socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        return 2;
    }
     // 填写服务器信息
     struct sockaddr_in server;
     memset(&server, 0, sizeof(server));
     server.sin_family = AF_INET;
     server.sin_port = htons(server_port);
     server.sin_addr.s_addr = inet_addr(server_ip.c_str());
     while(true)
     {
         std::string input;
         std::cout << "Please Enter# ";
         std::getline(std::cin, input);
        //发送消息
         int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
         (void)n;
 
         char buffer[1024];
         struct sockaddr_in peer;
         socklen_t len = sizeof(peer);
         //接受消息
         int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
         if(m > 0)
         {
             buffer[m] = 0;
             std::cout << buffer << std::endl;
         }
     }

    return 0;
}

4,现象演示

相关推荐
笑远5 分钟前
Vim/Vi 常用命令速查手册
linux·编辑器·vim
撒旦骑路西法,大战吕布9 分钟前
如果你在使用 Ubuntu/Debian:使用 apt 安装 OpenSSH
linux·ubuntu·debian
Go高并发架构_王工21 分钟前
基于 GoFrame 框架的电子邮件发送实践:优势、特色与经验分享
网络·经验分享·golang
mahuifa25 分钟前
(2)VTK C++开发示例 --- 绘制多面锥体
c++·vtk·cmake·3d开发
SlientICE25 分钟前
预防WIFI攻击,保证网络安全
网络·安全·php
_李筱夜36 分钟前
ubuntu桌面版使用root账号进行登录
linux·ubuntu
that's boy1 小时前
字节跳动开源 LangManus:不止是 Manus 平替,更是下一代 AI 自动化引擎
运维·人工智能·gpt·自动化·midjourney·gpt-4o·deepseek
23级二本计科1 小时前
C++ Json-Rpc框架-3项目实现(2)
服务器·开发语言·c++·rpc
struggle20251 小时前
Trinity三位一体开源程序是可解释的 AI 分析工具和 3D 可视化
数据库·人工智能·学习·3d·开源·自动化
rigidwill6661 小时前
LeetCode hot 100—搜索二维矩阵
数据结构·c++·算法·leetcode·矩阵