Linux网络学习:套接字、UDP的封装与应用

一、套接字


数据由进程产生和接收,因此网络通信的本质是进程和进程之间的通信 ,而进程间具有独立性,要实现通信必须要让两个进程看到同一份资源,也就是通过网络协议栈看到网络上的资源。

那么数据接收方在传输层怎么知道要把数据交付给上层的哪个应用?这时引入端口号的概念。


1.认识端口号

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

2.端口号绑定原理

内核通过端口号在协议控制块(PCB)中查找对应的进程,从而实现数据交付,那进程的端口号是谁来定义的呢?app是厂商做的,所以服务端口号已经被内置进app了,从而可以实现一一对应。

一个进程可以绑定多个端口号,反之不行,因为通过端口号必须能找到唯一的进程。


3.端口号 vs pid

pid也可以标识进程唯一性,为什么还需要端口号?

PID 是动态的,进程重启后会发生改变,而端口号是固定的服务标识。通过端口号定位服务,网络层就不需要关心具体进程的 PID,实现了网络服务和操作系统进程管理的解耦。


4.套接字是什么

通过IP地址 + 端口号就能标识网络中某台主机的某个进程,这样一套地址标识就被称为套接字(Socket) 。那么通过两个套接字就可以实现两台主机一对一通信。


5.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结构体指针做为 参数;


二、网络字节序


1.数据在网络中字节序规则

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

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

2.字节序转换接口

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

h表示host,n表示network,l表示32位长整数,s表示16位短整数。

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


三、UDP定义和特性


**UDP(User Datagram Protocol,用户数据报协议)**是TCP/IP协议族中的核心传输层协议之一。

UDP四大特性

1. 无连接

发送数据前,UDP不需要像TCP那样进行"三次握手"建立连接,而是直接把数据包扔到网络上。没有建立连接的时间延迟,这是它更快快的根本原因。

2. 不可靠传输

UDP只管发送,不确认对方是否收到。如果网络拥堵导致数据包丢失,UDP不会主动重传。丢失的数据需要由上层应用自己决定如何处理。

3. 面向报文

TCP会把大块数据切分成小段再发送,而UDP是"整体发送"。应用层给UDP一个完整的数据报,UDP直接加上一个头部就发出去,极大节省了带宽。

4. 支持一对多

TCP只能一对一通信,但UDP支持广播 和组播。


四、UDP的封装及应用:聊天室的实现


结合前文有关知识实现UDP的封装,并实现简易聊天室程序,具体要实现的效果是客户端连接上服务端后,在聊天室中上线,可向聊天室发送消息,同时可以接受到其它用户发送的消息。下面给出实现思路和代码。


1.服务端设计

服务端基于 Udp_Server 类封装,采用回调注入的方式将网络层与业务逻辑解耦。类内部负责 socket 创建、端口绑定、数据收发等底层操作,收到数据后通过handler回调函数把remote地址和消息内容交还给用户自定义逻辑处理(也就是定义聊天室的实现逻辑)。接入的用户通过 map<string, Server_Info> 管理,以 "IP:端口号" 作为唯一标识,记录每个在线用户的地址信息和用户名。广播功能通过遍历该容器、逐个调用 sendto 完成。主函数只需传入端口,创建实例后调用 Init 初始化,再调用 Run 并传入业务处理函数即可启动服务。整体采用 RAII 思想,使用unique_ptr创建服务端并自动管理生命周期,析构时自动关闭 socket。


2.客户端设计

客户端采用双线程模型,_Read 线程负责循环接收服务端的广播消息并打印,_Write 线程负责通过 getline 获取用户输入并发送。两个线程共享 Client_Info 结构体(含 socket 和服务端地址),实现收发分离,避免输入和接收互相阻塞。启动时需指定服务端的 IP 和端口号。


3.通信流程

客户端发送消息后,服务端通过 handler 解析发送方地址,自动识别新用户并加入在线列表,然后将消息组装成 "[用户名]: 消息内容" 的格式,通过广播功能转发给所有在线用户。整个流程实现了简易聊天室的核心功能:用户识别、消息广播、动态上下线管理。


4.具体代码


完整代码详见:

UDP封装与应用:聊天室的实现


(1)udpserver.hpp
cpp 复制代码
#pragma once
#include "log.hpp"
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <functional>
#include <vector>
#include <map>
#include <string>

enum
{
    SOCKET_ERRNO = 1,
    BIND_ERRNO,
    COMMAND_ERRNO,
    POPEN_ERRNO
};

struct Server_Info
{
    std::string ip;
    std::string port;
    std::string user_name;
    struct sockaddr_in sock;
};

class Udp_Server;

typedef std::function<void(const sockaddr_in &remote,
             const std::string &recstr,
             Udp_Server*instance)>
    func;
std::string ip_default = "0.0.0.0";
uint16_t port_default = 8080;

class Udp_Server
{
public:
    Udp_Server(const uint16_t &port)
        : port_(port), ip_(ip_default), running_(false) {}

    void Init()
    {
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);//创建套接字,并得到索引
        if (sockfd_ == -1)
        {
            LOG(FATAL, "socket create fail!");
            exit(SOCKET_ERRNO);
        }
        else
        {
            LOG(INFO, "socket create success!");
        }
        // 初始化socket结构体
        struct sockaddr_in local;
        bzero(&local, sizeof(local));//重置socket结构体
        local.sin_family = AF_INET;
        local.sin_port = htons(port_);//主机转网络,确保端口号的字节序
        local.sin_addr.s_addr = INADDR_ANY;
        
        socklen_t len = sizeof(local);
        if (bind(sockfd_, (const struct sockaddr *)&local, len) < 0)
        {
            LOG(FATAL, "bind fail,errno:%d,%s", BIND_ERRNO, strerror(errno));
            exit(BIND_ERRNO);
        }
        else
        {
            LOG(INFO, "bind success");
        }
    }

    void Adduser(const std::string& key,const sockaddr_in& input){//用里面的信息来填充Server_Info
        Server_Info newuser;
        char buf[INET_ADDRSTRLEN];
        const char *ip_str = inet_ntop(AF_INET, &input.sin_addr, buf, sizeof(buf));//将ip转字符串(顺便会转回主机字节序)
        newuser.ip = ip_str;
        newuser.port = std::to_string(ntohs(input.sin_port));//将网络字节序端口号转主机字节序端口号
        std::string name;
        name += '[';
        name += newuser.ip;
        name += ':';
        name += newuser.port;
        name += ']';
        newuser.user_name = name;
        newuser.sock = input;
        online_user_list[key] = newuser;//利用map的方括号重载将新用户的Server_Info填入在线名单中
        //用户第一次发送消息时,聊天室广播欢迎语
        std::cout << name << " has been log in." << std::endl;
        std::string welcome = "welcome " + name + " !!!";
        Broadcast(welcome);
    }

    bool Checklist(const std::string& key){//根据ip+端口号查找用户是否已经入网(绑定且发过消息)
        auto it = online_user_list.find(key);
        if (it == online_user_list.end()){
            return false;
        }else{
            return true;
        }
    }
    
    std::string Getusername(const std::string& key){//从map存储的Server_Info中获取已经拼接好的用户名
        auto it=online_user_list.find(key);
        if(it==online_user_list.end()){
            LOG(ERROR,"User does not exist");
            return "User does not exist";
        }
        else return it->second.user_name;
    }

    void Broadcast(const std::string& message){//一个用户发送的消息在聊天室广播给所有用户
        for (auto &i : online_user_list)
        {
            sockaddr_in target_sock=i.second.sock;
            socklen_t len= sizeof(target_sock);
            sendto(sockfd_, message.c_str(),message.size(), 0, (sockaddr *)&target_sock,len);
        }
    }

    void Run(func handler = nullptr)//由用户指定业务逻辑(main)
    {
        if (!handler)
        {
            std::cout << "handler is NULL" << std::endl;
            return;
        }
        running_ = true;
        char recbuf[1024];
        
        while (running_)
        {
            memset(recbuf, 0, sizeof(recbuf));
            struct sockaddr_in remote;
            bzero(&remote, sizeof(remote));
            socklen_t len = sizeof(remote);
            size_t n = recvfrom(sockfd_, &recbuf, sizeof(recbuf), 0, (sockaddr *)&remote, &len);//接收消息,并用remote获取用户信息
            if (n == 0)
            {
                LOG(ERROR, "recvfrom fail,errno:%d %s", errno, strerror(errno));
                continue;
            }
            recbuf[1023] = '\0';
            std::string recstr = recbuf;
            handler(remote, recstr, this);//将获取到的
        }
    }

    ~Udp_Server()
    {
        close(sockfd_);//关闭创建套接字时打开的文件
    }

private:
    std::map<std::string, Server_Info> online_user_list;
    //用于管理的上线用户名单,以ip+端口号为键,以 Server_Info为值
    int sockfd_;     // 创建套接字得到的文件fd
    std::string ip_; // ip
    bool running_;
    uint16_t port_; // 端口号
};

(2)main.cc

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

void handler(const sockaddr_in &remote,
             const std::string &recstr,
             Udp_Server*instance)//这个函数被服务端调用的时候服务端要把this指针传进来从而调用对象的成员函数
{
    char buf[INET_ADDRSTRLEN];//16的大小
    const char *ip_str = inet_ntop(AF_INET, &remote.sin_addr, buf, sizeof(buf));
    std::string ip=ip_str;
    std::string port = std::to_string(ntohs(remote.sin_port));
    std::string key = ip + ':' + port;
    // 收到数据后,根据用户id判断用户是否已在容器中
    // 如果不在,根据ip和端口号得到用户名,并写入容器中,在则直接得到用户名并转播消息
    if(!instance->Checklist(key)){
        instance->Adduser(key,remote);
    }
    std::string name=instance->Getusername(key);
    std::string message=name+recstr;//聊天室效果:[用户名(ip+端口号)]:用户发送的消息
    instance->Broadcast(message);//
}

int main(int argc, char *argv[])
{
    // 启动客户端时需要指定一个端口
    if (argc != 2)
    {
        std::cout << "please enter server_port" << std::endl;
        exit(0);
    }
    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    std::unique_ptr<Udp_Server> udp_server(new Udp_Server(port));
    udp_server->Init();
    udp_server->Run(handler);//将方法传给服务端
    return 0;
}

(3)client.cc

cpp 复制代码
#include "log.hpp"
#include <string>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <functional>
#include <pthread.h>

struct Client_Info
{
    int sockfd_;
    struct sockaddr_in remote;
};

void *_Read(void *data)
{
    Client_Info *info = static_cast<Client_Info *>(data);
    char buffer[1024];
    socklen_t len = sizeof(info->remote);
    while (true)
    {
        memset(buffer, 0, sizeof(buffer));
        //注意这里如果放定义字符串也不会自动清空原内容导致错乱,所以选择用memset主动清空
        size_t n = recvfrom(info->sockfd_, buffer, sizeof(buffer), 0, (sockaddr *)&info->remote, &len);
        //从服务端接收广播消息
        if (n == 0)
        {
            LOG(ERROR, "recvfrom fail,errno:%d %s", errno, strerror(errno));
            continue;
        }
        buffer[1023] = '\0';
        std::cout << buffer << std::endl;
    }
}

void *_Write(void *data)
{
    Client_Info *info = static_cast<Client_Info *>(data);
    std::string message;
    socklen_t len = sizeof(info->remote);
    while (true)
    {
        getline(std::cin, message); // 这个getline不是C语言的getline,获取用户输入的聊天文本
        sendto(info->sockfd_, message.c_str(), message.size(), 0, (sockaddr *)&info->remote, len);
        //向服务端传输需要发送到聊天室的聊天文本
    }
}

int main(int argc, char *argv[])
{
    //客户端在启动时要指定服务端的ip和端口号,所以命令应包含3个参数
    if (argc != 3)
    {
        std::cout << "please enter server_ip and server_port" << std::endl;
        exit(0);
    }

    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);//客户端也要创建套接字,通信正是通过两个套接字完成的
    if (sockfd == -1)
    {
        LOG(FATAL, "socket create fail!");
    }
    else
    {
        LOG(INFO, "socket create success!");
    }
    // 初始化socket结构体,和服务端差不多
    struct sockaddr_in remote;
    bzero(&remote, sizeof(remote));
    socklen_t len = sizeof(remote);
    std::string message;
    remote.sin_family = AF_INET;
    remote.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
    remote.sin_addr.s_addr = inet_addr(argv[1]);
    Client_Info info;
    info.sockfd_ = sockfd;
    info.remote = remote;
    char buffer[1024];
    // 建立两个线程,分别执行监听聊天室和向聊天室发送消息的功能(否则用户的输入界面和接收消息界面会混在一起)
    pthread_t rpt;
    pthread_t wpt;
    pthread_create(&rpt, nullptr, _Read, &info);
    pthread_create(&wpt, nullptr, _Write, &info);
    pthread_join(rpt, nullptr);
    pthread_join(wpt, nullptr);
    close(sockfd);
    return 0;
}

5.补充


(1)创建套接字接口socket

作用:创建一个网络通信端点,返回一个文件描述符。

参数1: 协议族,如 AF_INET(IPv4)

参数2: 套接字类型,如 SOCK_DGRAM(UDP)

参数3: 通常填 0,表示使用默认协议

返回值: 操作系统进程级文件描述符表 的一个索引。通过这个索引,内核能找到其内部维护的一个 struct socket 内核对象,该对象包含了套接字的缓冲区、状态、端口绑定信息等。创建失败返回-1。


(2)绑定接口bind

作用:将套接字与本地 IP 和端口号绑定,使该套接字固定监听指定地址。

**参数1:**套接字创建后得到的文件描述符

参数2: 指向已定义的sockaddr_in结构体的指针(包含要绑定的IP和端口)

**参数3:**套接字结构体大小

返回值 :成功返回 0,失败返回 -1。


(3)接收接口recvfrom

作用:从 UDP 套接字接收一个数据报,同时获取发送方的地址信息。

参数1:(发送方)套接字创建后得到的文件描述符

**参数2:**用于接收数据的接收缓冲区

**参数3:**缓冲区大小

参数4: 通常填 0(阻塞等待)

参数5:输出参数,内核将发送方的 IP 和端口填入此处

参数6:输入输出参数,传入时填缓冲区大小,返回时得到实际地址长度

**返回值:**成功返回收到多少个字节,失败返回-1


(4)发送接口sendto

作用:通过 UDP 套接字向指定目标发送一个数据报。

参数1: sockfd:文件描述符

参数2: buf:待发送的数据

参数3: len:数据长度

参数4: flags:通常填 0

参数5: dest_addr:目标地址(IP + 端口)

参数6: addrlen:目标地址结构体的大小

返回值 :成功返回实际发送的字节数,失败返回 -1。

(5)端口号设定成多少适合?

启动客户端时需要指定一个端口,一般而言,0--1023 为系统保留端口,通常需要 root 权限才能绑定。建议使用 1024--65535 之间的端口,常用的开发测试端口如 8080、8888等。

(6)ip地址和字符串转化函数

inet_ntoa函数把返回结果放到了静态存储区.,不需要手动进行释放,这样第二次调用该函数时的结果会覆盖掉上一次的结果,所以inet_ntoa不是线程安全函数,

inet_pton 和 inet_ntop转换函数需要用户传入缓冲区存放转换结果,不依赖静态存储区,不存在覆盖问题,天然线程安全。因此推荐使用。

(7)通配地址INADDR_ANY
cpp 复制代码
local.sin_addr.s_addr = INADDR_ANY;
//等价于
local.sin_addr.s_addr = 0; // 或者 inet_addr("0.0.0.0")

一台机器可能有多个网卡(意味着多个ip),数据可能从其中任意一个网卡进来,套接字ip设置成通配地址意味着只要是发往对应端口号的数据,不管目标IP是什么统统交给该套接字处理。

(8)如何得知服务端的ip?

举例来说,浏览器访问的网址,最终通过 DNS 解析成对应的 IP 地址,从而定位到目标服务器,所以用户通过搜索点击得到网址,也就知道了ip地址。

(9)为什么客户端在启动时要指定服务端的ip和端口号?

总是客户端先向服务器发起请求,服务器收到数据后就能知道客户端的端口号,而服务端的端口号必须是确定的,ip是能被找到的,客户端才能够连接上服务端。

(10)udp全双工的特性

udp是全双工(Full-Duplex)的, 意味着通信双方可以在同一时刻,同时向对方发送数据,互不干扰。因此服务器才能一边接收着新进来的消息(写入接收缓冲),一边同时从发送缓冲把消息转发给所有在线用户,互不阻塞

(11)网络状态查看工具

netstat 是 Linux 下经典的网络状态查看工具 ,它的核心作用是打印出当前系统的网络连接状态、路由表和接口统计信息。本次使用netstat -naup即可查看所有需要的信息。

相关推荐
律宏阔11 小时前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔11 小时前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴11 小时前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千11 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei3711 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei3711 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei3711 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
codigger11 小时前
ObjectSense:一门千行内核、把可靠性写进骨子里的面向对象脚本语言
开发语言·编程·编程语言
GeW11 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
百万蹄蹄向前冲12 小时前
双端同步!云服务器装最新Node.js v26.10全过程追踪
服务器·人工智能·node.js