Linux网络:基于协议栈原理实现UDP通信

前言:

Linux网络通信基于分层模型实现,Linux遵循的协议栈通常简化为四层:应用层、传输层、网络层、数据链路层。数据发送时自上而下逐层封装,接收时反向解封。IP地址用于在网络中唯一标识一台主机,而端口则是主机上区别不同进程的数字标识,IP与端口组合起来便能唯一确定一个网络通信的端点。套接字是操作系统对网络通信接口的抽象,可以理解为一个用于收发数据的通道,UDP网络通信主要使用数据报套接字,UDP以数据报为传输单位,每个数据报都是携带完整目标地址的独立消息,UDP在发送数据前不需要确认对方是否存在,也不协商任何参数,因此效率更高但只提供尽力而为的交付,不保证数据一定到达,本文将从Linux网络通信的相关基础概念、通信协议栈入手,并在此基础上手写实现基于套接字的UDP网络通信。
目录

一、网络基础

1、网络发展

2、协议

(1)背景

(2)分层

[<1> OSI七层模型](#<1> OSI七层模型)

[<2> TCP/IP分层](#<2> TCP/IP分层)

(3)传输

[<1> MAC地址](#<1> MAC地址)

[<2> 主机通信](#<2> 主机通信)

[<3> IP地址](#<3> IP地址)

二、Socket

1、背景

2、端口号

(1)理解

(2)范围划分

(3)源端口号和目的端口号

3、Socket理解

4、传输层

5、网络字节序

6、Socket相关接口

[(1) socket](#(1) socket)

(2)bind

(3)send

(4)recv

(5)close

7、sockaddr结构

三、UDP通信

1、互斥锁

(1)mutex

(2)lockguard

2、日志

(1)logstrategy

(2)consolelogstrategy

(3)filelogstrategy

(4)loglevel

(5)levelstr

(6)gettimestamp

(7)logger

(8)logmessage

3、UDP客户端

4、UDP服务器

(1)构造函数

(2)init

(3)start

(4)udpserver.cc

5、通信流程


一、网络基础

1、网络发展

网络发展经历了以下历史模式:

独立模式:计算机之间相互独立

网络互联:多台计算机连接在一起,完成数据共享

局域网LAN:计算机数量更多了,通过交换机和路由器连接在一起。

广域网WAN:将远隔千里的计算机都连在一起。

2、协议

(1)背景

计算机之间的传输媒介主要是光电信号,通过频率、强弱来表示0、1这样的信息,要想传递各种不同的信息,就需要约定好双方的数据格式。

(2)分层

协议本质上也是软件,在设计上为了更好的进行模块化、解耦合,也是被设计成为层状结构的,分层可以实现解耦合,让软件维护的成本更低。

<1> OSI七层模型

OSI七层网络模型称为开放式系统互联参考模型,是一个逻辑上的定义和规范。

把网络从逻辑上分为了7层,每一层都有相关、相对应的物理设备,比如路由器、交换机。

OSI七层模型是一种框架性的设计方法,其最主要的功能就是帮助不同类型的主机实现数据传输。

它的最大优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚、理论也比较完整,通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。

在网络角度,OSI定的协议7层模型非常完善,但在实际操作的过程中,会话层、表示层是不可能接入到操作系统中的,所以在工程实践中,最终落地的是5层协议。

<2> TCP/IP分层

TCP/IP是一组协议的代名词,它还包括许多协议,组成了TCP/IP协议簇。

TCP/IP通讯协议采用了5层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求。

**物理层:**负责光/电信号的传递方式,物理层的能力决定了最大传输速率、传输距离、抗干扰性等,集线器工作在物理层。

**数据链路层:**负责设备之间的数据帧的传送和识别,如网卡驱动、帧同步、冲突检测、数据差错校验等工作,交换机工作在数据链路层。

网络层: 负责地址管理和路由选择,如在IP协议中,通过IP地址来标识一台主机,并通过路由表的方式规划出两台主机之间的数据传输的线路。路由器工作在网络层。

传输层: 负责两台主机之间的数据传输,如传输控制协议(TCP),能够确保数据可靠的从源主机发送到目标主机。

**应用层:**负责应用程序间沟通,如简单电子邮件传输(SMTP),文件传输协议(FTP),网络远程访问协议(Telnet)等,网络编程主要针对应用层。

物理层考虑的比较少,只考虑软件相关的内容,因此一般也称为TCP/IP四层模型。

一般而言

对于一台主机,它的操作系统内核实现了从传输层到物理层的内容

对于一台路由器,它实现了从网络层到物理层

对于一台交换机,它实现了从数据链路层到物理层

对于集线器,它只实现了物理层

为什么要有TCP/IP协议?

从上图可以看出,本质是通信主机距离变远了,TCP/IP协议的本质是一种解决方案,TCP/IP协议能分层,前提是因为问题们本身能分层。

TCP/IP协议与操作系统的关系:

所以究竟什么是协议?先来看看下面的这张图

主机B能识别data,并且准确提取a=10,b=20,c=30,这是因为双方都有同样的结构体类型struct protocol,也就是说,用同样的代码实现协议,用同样的自定义数据类型,天然就有"共识",能够识别对方发来的数据,这就是协议。

关于协议的朴素理解:所谓协议,就是通信双方都认识的结构化的数据类型。

因为协议栈是分层的,所以,每层都有双方都有协议,同层之间,互相可以认识对方的协议。

(3)传输

<1> MAC地址

每台主机在局域网上,要有唯一的标识来保证主机的唯一性:MAC地址。

MAC地址用来识别数据链路层中相连的节点,长度为48比特位,即6个字节,一般用16进制数字加上冒号的形式来表示,如08:00:27:03:fb:19。

MAC地址在网卡出厂时就确定了,不能修改。MAC地址通常是唯一的。

以太网中,任何时刻,只允许一台机器向网络中发送数据。如果有多台同时发送,会发生数据干扰,称为数据碰撞。所有发送数据的主机要进行碰撞检测和碰撞避免。没有交换机的情况下,一个以太网就是一个碰撞域。局域网通信的过程中,主机通过对目标MAC地址进行判断,以此确认该报文是否是发给自己的。

<2> 主机通信

同一网段内两台主机进行通信:

而其中每层都有协议,所以进行传输时,要进行封装和解包。

报头部分,就是对应协议层的结构体字段,除了报头,剩下的部分称为有效载荷。

报文=报头+有效载荷

不同的协议层对数据包有不同的称谓,在传输层叫做段,在网络层叫做数据报,在链路层叫做帧。

应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部,称为封装。

首部信息中包含了一些类似于首部有多长、载荷有多长、上层协议是什么等信息。

数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,根据首部中的"上层协议字段"将数据交给对应的上层协议处理。

两台计算机通过TCP/IP协议通讯的过程如下所示:

在网络传输的过程中,数据不是直接发送给对方主机的,而是先要自顶向下将数据交付给下层协议,最后由底层发送,然后由对方主机的底层进行接收,再自底向上进行向上交付。

数据包封装和分用:

数据封装的过程如下所示:

数据分用的过程如下所示:

<3> IP地址

IP协议有两个版本,IPv4和IPv6。

IP地址是在IP协议中,用来标识网络中不同主机的地址。

对于IPv4来说,IP地址是一个4字节、32位的整数。

通常也使用点分十进制的字符串表示IP地址,如192.168.0.1,用点分割的每一个数字表示一个字节,范围为0~255。

跨网段主机的数据传输,数据从一台计算机到另一台计算机传输过程中要经过一个或多个路由器。

目标IP的意义:

路由器解包和重新封包的过程:

对比IP和MAC地址的区别,从上图可以看出:

IP地址在整个路由过程中,一直不变

MAC地址一直在变

目的IP是一种长远目标,Mac是下一阶段目标,目的IP是路径选择的重要依据,mac地址是局域网转发的重要依据

IP网络层存在的意义:提供网络虚拟层,让世界的所有网络都是IP网络,屏蔽最底层网络的差异。

二、Socket

1、背景

IP在网络中,用来标识主机的唯一性。

数据传输到主机并不是目的,因为数据是给人用的,而进程是人在系统中的代表,只要把数据传给进程,人就相当于拿到了数据,因此,数据传输到主机并不是目的,而是手段,到达主机内部,在交给主机内的进程,才是目的。

但在系统中,同时会存在非常多的进程,当数据到达目标主机之后,怎么转发给目标进程?就要在网络的背景下,在系统中,标识主机的唯一性。

2、端口号

(1)理解

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

另外,一个进程可以绑定多个端口号,但一个端口号不能被多个进程绑定。

(2)范围划分

0~1023:知名端口号,HTTP、FTP、SSH等应用层协议,端口号都是固定的。

1024~65535:操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统从这个范围分配的。

(3)源端口号和目的端口号

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

3、Socket理解

综上,IP地址用来标识互联网中唯一的一台主机,port用来标识该主机上唯一的一个网络进程

IP+Port就能表示互联网中唯一的一个进程

通信,本质是两个互联网进程来进行通信,(源IP,源端口,目的IP,目的端口)这样的4元组就能标识互联网中唯二的两个进程。

网络通信的本质,就是进程间通信。

IP+Port也就是套接字socket

4、传输层

传输层属于内核,那么如果要通过网络协议栈进行通信,必定调用传输层提供的系统调用,来进行网络通信。

TCP传输控制协议的主要特点:

传输层协议

有连接

可靠传输

面向字节流

UDP用户数据报协议的主要特点:

传输层协议

无连接

不可靠传输

面向数据报

5、网络字节序

发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出。

接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存。网络数据流发送数据的特点是:先发出的数据是低地址,后发出的数据是高地址。

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。

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

如果当前发送主机是小端,则需先将数据转成大端,否则就忽略,直接发送即可。

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

这些函数也很好记忆,h表示host,n表示network,l表示32位长整数,s表示16位短整数。

例如htonl表示将32位的长整数从主机字节序转换为网络字节序,将IP地址转换后准备发送。

如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。

如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

所有发送到网络上的数据,都必须是大端的!

6、Socket相关接口

(1) socket

socket接口是网络编程的第一步,用来创建一个套接字,domain为创建时指定通信的域,AF_INET为IPv4通信、AF_INET6为IPv6通信,type为通信类型,SOCK_STREAM为TCP、SOCK_DGRAM为UDP,protocol为相关协议,通常设为0表示自动匹配,创建成功后会返回一个文件描述符,后续的所有操作都依赖这个描述符,失败返回-1。

(2)bind

bind用于把一个IP地址和端口号绑定到之前创建的套接字上,服务器端通常会调用这个函数,socket为创建的套接字描述符,address为指向结构体的指针,包含要绑定的IP地址和端口号,对于IPv4使用struct sockaddr_in,对于IPv6使用struct sockaddr_in6,实际传参时需要强制转换为struct sockaddr*,address_len为addr结构体的长度,调用成功返回0,失败返回-1。

(3)send

客户端和服务端连接建立后,send用于发送数据,sockfd为已建立连接的套接字描述符,buf为指向要发送数据的缓冲区指针,len为要发送的数据长度,flags为控制选项,通常设为0,调用成功返回实际发送的字节数,失败返回-1。

(4)recv

recv用于接收数据,sockfd为已建立连接的套接字描述符,buf为接收数据的缓冲区指针,len为缓冲区的大小,flags为控制选项,通常设为0,调用成功返回实际接收的字节数,返回0表示对方已关闭连接,失败返回-1。

(5)close

通信结束后,调用close来关闭套接字,fd为要关闭的套接字描述符,释放资源。关闭后,这个套接字不再可用于发送或接收数据,如果对方已经关闭了连接,读取时会返回0,表示连接已正常结束,关闭成功返回0,失败返回-1。

7、sockaddr结构

socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及UNIX Domain Socket,然而,各种网络协议的地址格式并不相同。

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结构体指针作为参数。

sockaddr结构

sockaddr_in结构

虽然socket api的接口是sockaddr,但真正在基于IPv4编程时,使用的数据结构是sockaddr_in,这个结构里主要有三部分信息:地址类型、端口号、IP地址。

in_addr结构

in_addr用来表示一个IPv4的IP地址,其实就是一个32位的整数。

三、UDP通信

下面将实现一个基于UDP通信的日志记录系统和网络通信程序,整体分为3个部分:日志模块、UDP客户端、UDP服务器。

1、互斥锁

mutex.hpp提供了线程同步的基本工具互斥锁。

(1)mutex

cpp 复制代码
#pragma once
#include<iostream>
#include<pthread.h>
using namespace std;
namespace mutexmodule
{
    class mutex
    {
    public:
        mutex()
        {
            pthread_mutex_init(&_mutex,nullptr);
        }
        void lock()
        {
            int n=pthread_mutex_lock(&_mutex);
            (void)n;
        }
        void unlock()
        {
            int n=pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        ~mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }
    private:
        pthread_mutex_t _mutex;
    };
}

mutex类将对POSIX互斥锁进行封装,构造函数mutex()调用pthread_mutex_init进行初始化,lock和unlock函数分别进行加锁和解锁,析构函数~mutex()用于互斥锁的销毁。

(2)lockguard

cpp 复制代码
class lockguard
{
public:
    lockguard(mutex& mutex):_mutex(mutex)
    {
        _mutex.lock();
    }
    ~lockguard()
    {
        _mutex.unlock();
    }
private:
    mutex& _mutex;
};

lockguard类采用RAII机制管理互斥锁,构造函数实现锁的自动添加,析构函数自动解锁,避免手动加锁解锁可能遗漏的问题。

2、日志

log.hpp提供了线程安全的日志记录功能,支持日志信息输出到控制台或文件。

(1)logstrategy

cpp 复制代码
#ifndef _LOG_HPP_
#define _LOG_HPP_
#include<iostream>
#include<string>
#include<cstdio>
#include<filesystem>
#include<fstream>
#include<sstream>
#include<memory>
#include<ctime>
#include<unistd.h>
#include"mutex.hpp"
using namespace std;
namespace logmodule
{
    using namespace mutexmodule;
    const string gsep ="\r\n";
    class logstrategy
    {
    public:
        virtual ~logstrategy()=default;
        virtual void synclog(const string& message)=0;
    };
}

logstrategy是一个抽象基类,定义了日志策略接口synclog,用于同步输出日志信息。

(2)consolelogstrategy

cpp 复制代码
class consolelogstrategy:public logstrategy
{
public:
    consolelogstrategy(){}
    void synclog(const string& message) override
    {
        lockguard guard(_mutex);
        cout<<message<<gsep;
    }
    ~consolelogstrategy(){}
private:
    mutex _mutex;
};

consolelogstrategy类继承自logstrategy,实现了日志信息输出到控制台的策略,synclog函数会将日志信息输出到标准输出,并在末尾加上分隔符,内部使用互斥锁来保证多线程安全。

(3)filelogstrategy

cpp 复制代码
const string defaultpath="./log";
const string defaultfile="my.log";
class filelogstrategy:public logstrategy
{
public:
    filelogstrategy(const string& path=defaultpath,const string& file=defaultfile)
    :_path(path),
     _file(file)
    {
        lockguard guard(_mutex);
        if(filesystem::exists(_path))
        {
            return;
        }
        try
        {
            filesystem::create_directories(_path);
        }
        catch(const filesystem::filesystem_error& e)
        {
            cerr<<e.what()<<endl;
        }
     }
     void synclog(const string& message) override
     {
         lockguard guard(_mutex);
         string filename=_path+(_path.back()=='/'?"":"/")+_file;
         ofstream out(filename,ios::app);
         if(!out.is_open())
         {
             return;
         }
         out<<message<<gsep;
         out.close();
     }
     ~filelogstrategy(){}
private:
    string _path;
    string _file;
    mutex _mutex;
};

filelogstrategy继承自logstrategy,实现了日志信息输出到文件的策略,构造函数会检查日志目录是否存在,如果不存在则创建。synclog函数会将日志信息追加写入到指定的日志文件中,同样使用互斥锁来保证线程安全。

(4)loglevel

cpp 复制代码
enum class loglevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

loglevel为日志级别枚举类,日志级别分别为:DEBUG,INFO,WARNING,ERROR,FATAL。

(5)levelstr

cpp 复制代码
string levelstr(loglevel lev)
{
    switch(lev)
    {
        case loglevel::DEBUG: return "DEBUG";
        case loglevel::INFO: return "INFO";
        case loglevel::WARNING: return "WARNING";
        case loglevel::ERROR: return "ERROR";
        case loglevel::FATAL: return "FATAL";
        default: return "UNKNOWN";
    }
}

levelstr函数用于将日志级别枚举转换为对应的字符串,用于日志输出时的级别标识。

(6)gettimestamp

cpp 复制代码
string gettimestamp()
{
    time_t t=time(nullptr);
    struct tm curr_tm;
    localtime_r(&t,&curr_tm);
    char timebuffer[128];
    snprintf(timebuffer, sizeof(timebuffer),"%4d-%02d-%02d %02d:%02d:%02d",
        curr_tm.tm_year+1900,
        curr_tm.tm_mon+1,
        curr_tm.tm_mday,
        curr_tm.tm_hour,
        curr_tm.tm_min,
        curr_tm.tm_sec
    );
    return timebuffer;
}

gettimestamp函数用于获取当前系统时间,格式化为"年-月-日 时:分:秒"的字符串,用于记录日志的发生时间。

(7)logger

cpp 复制代码
class logger
{
public:
    logger():_ptr(nullptr)
    {
        enableconsolelogstrategy();
    }
    void enableconsolelogstrategy()
    {
        _ptr=make_unique<consolelogstrategy>();
    }
    void enablefilelogstrategy()
    {
        _ptr=make_unique<filelogstrategy>();
    }
    class logmessage
    {};  
    logmessage operator()(loglevel lev,const string& name,int line)
    {
        return logmessage(name,lev,line,*this);
    }
    ~logger()
    {
            
    }
    static logger log;
    #define LOG(level) logmodule::log(level,__FILE__,__LINE__)
    #define Enable_Console_Log_Strategy() logmodule::log.enableconsolelogstrategy()
    #define Enable_File_Log_Strategy() logmodule::log.enablefilelogstrategy()
private:
    unique_ptr<logstrategy> _ptr;
};
#endif

logger是日志器的核心类,内部持有一个日志策略指针,enableconsolelogstrategy和enablefilelogstrategy用于切换日志输出策略,内部还有一个logmessage嵌套类(下面实现),用于构建单条日志内容。operator()函数返回一个logmessage对象,支持链式操作,LOG宏简化了调用方式,自动传入文件名和行号。

(8)logmessage

cpp 复制代码
class logmessage
{
public:
    logmessage(const string& src,loglevel level,int num,logger& logger)
    :_pid(getpid())
    ,_src(src)
    ,_num(num)
    ,_curr_time(gettimestamp())
    ,_level(level)
    ,_logger(logger)
    {
        stringstream ss;
        ss<<"["<<_curr_time<<"]"<<"["<<levelstr(_level)<<"]"<<"["<<_pid<<"]"<<"["                               <<_src<<"]"<<"["<<_num<<"]"<<"-";
        _loginfo=ss.str();
    }
    template<class K>
    logmessage& operator<<(const K& info)
    {
        stringstream ss;
        ss<<info;
        _loginfo+=ss.str();
        return *this;
    }
    ~logmessage()
    {
        if(_logger._ptr)
        {
            _logger._ptr->synclog(_loginfo);
        }
    }
private:
    string _curr_time;
    loglevel _level;
    pid_t _pid;
    string _src;
    int _num;
    string _loginfo;
    logger& _logger;
};

logmessage类嵌套在logger内部,用于构建一条完整的日志消息,构造函数中会记录时间、级别、进程ID、源文件名、行号等信息。operator<<支持追加日志内容,析构函数中调用日志策略的synclog函数在析构时将日志信息输出。

3、UDP客户端

udpclient.cc用于实现一个UDP客户端程序。

cpp 复制代码
#include<iostream>
#include<string>
#include<cstring>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
using namespace std;
int main(int argc,char*argv[])
{
    if(argc!=3)
    {
        cerr<<"usage:"<<argv[0]<<"ip port"<<endl;
        return 1;
    }
    string ip= argv[1];
    uint16_t port=stoi(argv[2]);
    int socketfd=socket(AF_INET,SOCK_DGRAM,0);
    if(socketfd<0)
    {
        cerr<<"socket fail"<<endl;
        return 2;
    }
    struct sockaddr_in server;
    memset(&server,0,sizeof(server));
    server.sin_family=AF_INET;
    server.sin_port=htons(port);
    server.sin_addr.s_addr=inet_addr(ip.c_str());
    while(true)
    {
        string st;
        cout<<"please enter:";
        getline(cin,st);
        int n=sendto(socketfd,st.c_str(),st.size(),0,(struct sockaddr*)&server,sizeof(server));
        (void)n;
        char buffer[1024];
        struct sockaddr_in addr;
        socklen_t len=sizeof(addr);
        int m=recvfrom(socketfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&addr,&len);
        if(m>0)
        {
            buffer[m]=0;
            cout<<"收到服务端消息:"<<buffer<<endl;
        }
    }
    return 0;
}

main函数从命令行参数获取服务器IP和端口号,创建UDP套接字后,循环从标准输入读取用户输入,通过sendto发送给服务器,然后通过recvfrom接收服务器回发的消息并打印到屏幕。

4、UDP服务器

UDPserver.hpp实现了一个UDP服务器,可以处理客户端请求并返回响应。

(1)构造函数

cpp 复制代码
#pragma once
#include<iostream>
#include<functional>
#include<string>
#include<string.h>
#include<sys/types.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include"log.hpp"
using namespace std;
using namespace logmodule;
const int defaultfd=-1;
using func_t=function<string(const string&)>;
class udpserver
{
public:
    udpserver(uint16_t port,func_t func)
    :_socketfd(defaultfd)
    ,_port(port)
    ,_func(func)
    ,_isrunning(false)
    {}
private:
    int _socketfd;
    uint16_t _port;
    bool _isrunning;
    func_t _func;
};

Udpserver构造函数初始化套接字描述符为默认值defaultfd,保存端口号、业务处理函数,设置运行状态为false。

(2)init

cpp 复制代码
void init()
{
    _socketfd=socket(AF_INET,SOCK_DGRAM,0);
    if(_socketfd<0)
    {
        LOG(loglevel::FATAL)<<"套接字创建失败";
        exit(1);
    }
    LOG(loglevel::INFO)<<"套接字创建成功"<<_socketfd;
    struct sockaddr_in addr;
    bzero(&addr,sizeof(addr));
    addr.sin_family=AF_INET;
    addr.sin_port=htons(_port);
    addr.sin_addr.s_addr=INADDR_ANY;
    int n=bind(_socketfd,(struct sockaddr*)&addr,sizeof(addr));
    if(n<0)
    {
        LOG(loglevel::FATAL)<<"套接字绑定失败";
        exit(2);
    }
    LOG(loglevel::INFO)<<"套接字绑定成功"<<_socketfd;
}

init函数用于创建UDP套接字,若创建失败则会输出相关日志信息并退出,然后绑定套接字到指定窗口,绑定后会记录相关日志信息。

(3)start

cpp 复制代码
void start()
{
    _isrunning=true;
    while(_isrunning)
    {
        char buffer[1024];
        struct sockaddr_in addr;
        socklen_t n=sizeof(addr);
        ssize_t s=recvfrom(_socketfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&addr,&n);
        if(s>0)
        {
            int port=ntohs(addr.sin_port);
            string ip=inet_ntoa(addr.sin_addr);
            buffer[s]=0;
            string result=_func(buffer);
            LOG(loglevel::DEBUG)<<"["<<ip<<":"<<port<<"]#"<<buffer;
            LOG(loglevel::DEBUG)<<"准备消息回发给客户端";
            sendto(_socketfd,result.c_str(),result.size(),0,(struct sockaddr*)&addr,n);
            LOG(loglevel::DEBUG)<<"消息已回发";
        }
    }
}

start函数用于启动服务器主循环,循环中通过recvfrom接收客户端消息,收到消息后记录客户端IP和端口,调用业务处理函数处理消息,最后通过sendto将处理结果回发给客户端。

(4)udpserver.cc

cpp 复制代码
#include<iostream>
#include<memory>
#include"udpserver.hpp"
using namespace std;
string defaultfunc(const string& message)
{
    string str="hello,";
    str+=message;
    return str;
}
int main(int argc,char*argv[])
{
    if(argc!=2)
    {
        cerr<<"usage:"<<argv[0]<<"port"<<endl;
        return 1;
    }
    uint16_t port=stoi(argv[1]);
    Enable_Console_Log_Strategy();
    unique_ptr<udpserver> up=make_unique<udpserver>(port,defaultfunc);
    up->init();
    up->start();
    return 0;
}

defaultfunc为默认业务处理函数,用来接收客户端发来的字符串,main函数从命令行参数获取端口号,启用控制台日志策略,创建一个udpserver对象,调用init初始化,随后调用start启动服务器。

5、通信流程

1、服务器启动:创建UDP套接字,绑定端口,进入循环等待接收消息。

2、客户端连接:客户端指定服务器IP和端口,发送用户输入的消息。

3、消息处理:服务器收到消息后,调用业务处理函数加工,然后将结果回发给客户端。

4、日志记录:服务器运行过程中通过日志模块记录关键步骤,支持输出到控制台或文件。

Makefile

bash 复制代码
.PHONY:all
all:udpclient udpserver
udpclient:udpclient.cc
	g++ -o $@ $^ -std=c++17
udpserver:udpserver.cc
	g++ -o $@ $^ -std=c++17
.PHONY:clean
clean:
	rm -rf udpclient udpserver

通过Makefile即可实现一键化编译,通过g++编译,采用C++17标准,编译将生成udpclient udpserver可执行文件,即客户端、服务端可执行文件。

需要注意的是,云服务器不允许直接bind公有IP,推荐写成INADDR_ANY,在网络编程中,当一个进程需要绑定一个网络端口进行通信时,可以使用INADDR_ANY作为IP地址参数。这样意味着该端口可以接受来自任何IP地址的连接请求,无论是本地主机还是远程主机。

Makefile编译通过后,先启动服务器,指定相应端口号8889,即./udpserver 8889,再启动客户端,指定本地服务器IP、端口号,即./udpclient 127.0.0.1 8889,127.0.0.1为本地环回IP,指向本机,运行结果如下所示:

先启动服务端:

可以看到服务器输出套接字创建和绑定成功的日志,随后阻塞在recvfrom,等待客户端发送消息

在另一个终端启动客户端:

客户端启动,并输入消息,可以看到服务器收到消息,打印DEBUG日志,并回发相应消息给客户端,成功实现客户端和服务器的UDP通信。

总结:协议栈是指网络中不同层级协议的集合,每一层负责特定的功能,上层依赖下层提供的服务,最常见的模型是TCP/IP四层模型,从下到上依次是:数据链路层、网络层、传输层和应用层数据链路层负责在物理网络上发送和接收数据帧,网络层主要是IP协议,负责数据包的路由和转发,实现端到端的寻址。传输层则是TCP和UDP所在的层级,提供进程之间的通信服务。应用层是用户直接接触的协议,如HTTP、FTP、DNS等。UDP工作在传输层,它在IP的基础上增加了端口号和多路复用功能,不提供可靠性UDP通信实现基于套接字接口,完成了从socket创建,bind地址绑定,sendto/recvfrom数据收发至close资源释放的完整生命周期管理。通过分离日志策略与业务逻辑、实现了非阻塞式异步I/O模型,并利用RAII机制互斥锁保证了日志模块的线程安全性,最终成功实现了客户端和服务端的UDP通信,通过本次UDP通信实现,为后续在高并发、低延迟场景下构建用户态协议栈或应用层可靠传输机制提供了实战基础。

相关推荐
老绿光2 小时前
Python 字典完全指南:从入门到实战
linux·服务器·python
tryCbest2 小时前
Nginx常用操作命令-Linux和Windows系统
linux·windows·nginx
狂奔蜗牛(bradley)2 小时前
使用数组重构责任链实现通信协议解析
网络·mcu·重构
何中应3 小时前
如何给虚拟机系统扩容
linux·运维·服务器
风逸尘_lz3 小时前
05-LPB3568针对不同网段实现UDP通信
网络·网络协议·udp
缘友一世3 小时前
tmux 共享终端:AI 模型执行命令的实时审计方案
linux·llm·tmux·agent终端交互审计
沐雪轻挽萤3 小时前
无人系统:Ubuntu 操作系统全景架构与实战工程指南
linux·运维·ubuntu
白緢3 小时前
嵌入式 Linux + 内核开发高频问题及排查
java·linux·运维
学编程就要猛3 小时前
JavaEE初阶:网络编程
运维·服务器·网络