Linux下 Socket编程 —— UDP网络编程

欢迎来到我的频道!【点击跳转专栏】

本文所有代码已托管至码云:【点此转跳】

文章目录

  • [1. 接口初识](#1. 接口初识)
    • [1.1 socket()](#1.1 socket())
    • [1.2 sockaddr_in](#1.2 sockaddr_in)
    • [1.3 bind()](#1.3 bind())
    • [1.4 recvfrom()](#1.4 recvfrom())
    • [1.5 sendto()](#1.5 sendto())
    • [1.6 inet_ntoa()](#1.6 inet_ntoa())
  • [2. V1版本 - Echo server](#2. V1版本 - Echo server)
  • [3. V2版本- DictServer](#3. V2版本- DictServer)
  • [4. V3版本- 简单聊天室](#4. V3版本- 简单聊天室)
    • [4.1 核心思路与流程图](#4.1 核心思路与流程图)
    • [4.2 服务端](#4.2 服务端)
      • [1. 服务端创建 UDP Socket](#1. 服务端创建 UDP Socket)
      • [2. 接收客户端消息](#2. 接收客户端消息)
      • [3. 维护在线用户列表](#3. 维护在线用户列表)
      • [4. 为什么要使用线程池](#4. 为什么要使用线程池)
      • [5. route_message() 的职责](#5. route_message() 的职责)
    • [4.3 客户端](#4.3 客户端)
      • [1. 发送线程](#1. 发送线程)
      • [2. 接收线程](#2. 接收线程)
    • [5 .一次完整聊天流程](#5 .一次完整聊天流程)
  • [5. 一些补充内容](#5. 一些补充内容)
    • [5.1 netstat指令](#5.1 netstat指令)
    • [5.2 ssh与sshd是什么](#5.2 ssh与sshd是什么)
    • [5.3 在其他系统进行套接字编程访问云服务器](#5.3 在其他系统进行套接字编程访问云服务器)

1. 接口初识

1.1 socket()

socket() 用来创建一个 Socket,也就是创建一个"通信端点"。它成功后返回一个文件描述符,后续用 bind()connect()send()recv() 等函数操作它。

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

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

三个参数分别表示:

参数 含义 常见取值
domain 使用哪类地址 AF_INET:IPv4;AF_INET6:IPv6;AF_UNIX:本机进程通信
type 通信方式 SOCK_STREAM:可靠字节流;SOCK_DGRAM:数据报;SOCK_RAW:原始报文
protocol 具体协议 通常填 0,系统根据前两个参数自动选择 ,在网络中基本上指明type就自动知道是哪个协议了

最常见的 TCP Socket:

c 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);

含义是:创建一个 IPv4、面向连接、可靠传输的 Socket,系统自动选择 TCP。

UDP Socket:

c 复制代码
int fd = socket(AF_INET, SOCK_DGRAM, 0);

本机 Unix 域 Socket:

c 复制代码
int fd = socket(AF_UNIX, SOCK_STREAM, 0);

返回值:

  • 成功:返回非负整数 fd
  • 失败:返回 -1,错误原因放在 errno

例如:

c 复制代码
int fd = socket(AF_INET, SOCK_STREAM, 0);
if (fd == -1) {
    perror("socket");
}

注意:socket() 只是"创建电话",还没有拨号。

  • 服务端通常继续:bind()listen()accept()
  • 客户端通常继续:connect()
  • 用完后:close(fd)

1.2 sockaddr_in

struct sockaddr_in 是 IPv4 Socket 地址结构体,用来保存"IP 地址 + 端口号"。

c 复制代码
#include <netinet/in.h>

struct sockaddr_in 
{
    sa_family_t    sin_family;  // 地址族
    in_port_t      sin_port;    // 端口号
    struct in_addr sin_addr;    // IPv4 地址
    unsigned char  sin_zero[8]; // 填充字段
};

------------------------------------

各字段含义:

  • sin_family(本质为 unsigned short int ):通常填 AF_INET,表示使用 IPv4。
  • sin_port:端口号,必须用网络字节序保存,所以使用 htons()
  • sin_addr:IP 地址,通常用 inet_pton() 转换。
  • sin_zero:没有业务含义,初始化为 0 即可。

补充:bzero(清零) && inet_addr(字符串转4字节数)

bzero()

作用:把一块内存全部清零。

c 复制代码
#include <strings.h>

void bzero(void *s, size_t n);

例如初始化地址结构体:

c 复制代码
struct sockaddr_in addr;
bzero(&addr, sizeof(addr));

这样 addr 的端口、IP、填充字段都会先变成 0,避免未初始化的数据造成问题。

不过 bzero() 是旧接口,现在更推荐:

c 复制代码
memset(&addr, 0, sizeof(addr));

或者直接:

c 复制代码
struct sockaddr_in addr = {0};

inet_addr()

作用:把点分十进制 IPv4 字符串转换为网络字节序的 32 位地址(自动转大端)。

c 复制代码
#include <arpa/inet.h>

in_addr_t inet_addr(const char *cp);

例如:

c 复制代码
addr.sin_addr.s_addr = inet_addr("127.0.0.1");

它把 "127.0.0.1" 转成 Socket 能使用的二进制 IP 地址。

补充:4字节数转字符串,字符串转4字节

可以这么写

cpp 复制代码
struct ip
{
  uint32_t parta:8;
  uint32_t partb:8;
  uint32_t partc:8;
  uint32_t partd:8;
};

核心知识点:位域(bit‑field)

语法格式:

类型 成员名 : 占用多少位;

parta:8 代表:这个成员只占用 8 bit

整体结构解析:

底层容器是 uint32_t(32位无符号整型,一共32bit)

复制代码
parta 占8bit + partb占8bit + partc占8bit + partd占8bit = 32 bit

内存排布:

复制代码
bit 31~24 │ bit23~16 │ bit15~8 │ bit7~0
  parta      partb     partc    partd

注:位域高低位顺序依赖编译器大小端,上图是小端的形式。

使用示例1 ,4字节转字符串

c 复制代码
// 1.定义一个32位无符号整数存放IP数值
uint32_t ip_addr = XXXX;

// 2.强制类型转换:把ip_addr的内存地址,强行当成 struct ip* 位域结构体指针
struct ip *p = (struct ip *)&ip_addr;

//3.取出四个位域成员,拼接成 "a.b.c.d" 格式的IP字符串
to_string(p->parta)+"." + to_string(p->partb)+"."+to_string(p->partc)+"."+to_string(p->partd)

使用示例2 , 字符串转4字节

将 IP 字符串 192.168.1.12 拆分出四段数值 192、168、1、12,借助指针强制转换位域结构体,写入到 32 位整型变量 uint32_t ip_addr

cpp 复制代码
((struct ip*)&ip_addr)->parta = 192;
((struct ip*)&ip_addr)->partb = 168;
((struct ip*)&ip_addr)->partc = 1;
((struct ip*)&ip_addr)->partd = 12;

补充:hton* 和 ntoh*

把主机字节序的整数,转换成网络字节序,或者反过来

c 复制代码
#include <arpa/inet.h>

uint16_t htons(uint16_t hostshort);

网络字节序规定使用大端序。但多数电脑是小端序,所以端口号等数据发送到网络前要转换,如:

c 复制代码
addr.sin_port = htons(8080);

如果不转换,在小端机器上 8080 可能会被对方当成另一个端口号。

常见配套函数:

函数 含义
htons() 主机 16 位 → 网络字节序
htonl() 主机 32 位 → 网络字节序
ntohs() 网络 16 位 → 主机字节序
ntohl() 网络 32 位 → 主机字节序

1.3 bind()

bind() 就是给 Socket 绑定一个本地"地址"。

对 IPv4 来说,这个地址是:

text 复制代码
本机 IP + 端口号

例如:

text 复制代码
0.0.0.0:8080

表示程序占用本机的 8080 端口,等待别人连接。

c 复制代码
int bind(int sockfd,
         const struct sockaddr *addr,
         socklen_t addrlen);
  • sockfdsocket() 创建出来的 Socket。
  • addr:要绑定的地址。
  • addrlen:地址结构大小。

1.4 recvfrom()

recvfrom() 用来从 Socket 接收数据,并且可以得到发送方的地址。它最常用于 UDP。

c 复制代码
#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:通常填 0,表示阻塞读取
  • src_addr:接收方地址结构体,函数会写入"谁发来的"。
  • addrlen:传入 src_addr 的大小,返回实际地址长度

前面的参数buf len表示接受的信息本身 src_addraddrlen是用来标识信息是谁发送的!本质是是个输出型参数

UDP 示例:

c 复制代码
char buf[1024];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);

ssize_t n = recvfrom(fd, buf, sizeof(buf) - 1, 0,
                     (struct sockaddr *)&client_addr,
                     &client_len);

if (n >= 0)
 {
    buf[n] = '\0';
    printf("收到:%s\n", buf);
}
  • 客户端调用 recvfrom() :地址参数通常得到 服务端地址 ;
  • 服务端调用 recvfrom() :地址参数通常得到 客户端地址 ,服务端依靠这个地址把数据回发给客户端。

返回值:

  • 大于等于 0:实际收到的字节数。
  • -1:出错,检查 errno

注意:

  • UDP 会保留一条报文的边界;如果 buf 太小,超出的部分通常会被丢弃。
  • 默认是阻塞调用,没有数据时会等待。

1.5 sendto()

sendto() 用来通过 Socket 向指定的 IP 和端口发送数据,最常用于 UDP。

c 复制代码
#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:通常填 0,默认为阻塞发送。
  • dest_addr:目标地址,通常是 sockaddr_in
  • addrlen:目标地址结构大小,IPv4 通常填 sizeof(struct sockaddr_in)

UDP发送方式一般是默认全双工

UDP 发送示例:

c 复制代码
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));

server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = inet_addr("192.168.1.100");

char message[] = "hello";

int result = sendto(sockfd, message, strlen(message), 0,
                    (struct sockaddr *)&server_addr,
                    sizeof(server_addr));

if (result == -1) {
    perror("sendto failed");
}

返回值:

  • 成功:返回已交给系统发送的字节数。
  • 失败:返回 -1,可用 perror() 查看原因。

注意:sendto() 成功只表示数据已交给本机系统处理,UDP 不保证对方一定收到。每次调用通常对应一条独立的 UDP 报文。

它和 recvfrom() 是一对:sendto() 发送数据,recvfrom() 接收数据并可得到发送方地址。

1.6 inet_ntoa()

inet_ntoa() 用来把网络字节序的 IPv4 地址转换成点分十进制字符串。

c 复制代码
#include <arpa/inet.h>

char *inet_ntoa(struct in_addr in);

例如:

c 复制代码
struct in_addr address;
address.s_addr = htonl(0xC0A80101);

printf("%s\n", inet_ntoa(address));
// 192.168.1.1

简单记忆:

text 复制代码
inet_addr()  :字符串 IP -> 网络字节序 IP
inet_ntoa()  :网络字节序 IP -> 字符串 IP

2. V1版本 - Echo server

代码因为篇幅问题详情请见gitte【在lesson60/1.echo_server路径下】

最佳实践所得结论

  1. 云服务器的公网ip是禁止被显示绑定的 !

你看到的 106.54.172.42 是对外使用的公网地址,但服务器操作系统内部可能并没有这个地址。

cpp 复制代码
外部电脑
    |
    | 访问 106.54.172.42:8080
    v
腾讯云公网网关/NAT
    |
    | 转换为服务器私网地址,例如 10.0.0.5:8080
    v
云服务器
  1. 在服务器开发的时候,不建议你显示绑定ip地址(实践所得结论)
  2. 云服务器的公网ip,是禁止被显式绑定的 但不等于 云服务器的公网ip不会被使用。(客户端依然可以使用该IP发送信息,不过切记防火墙权限问题)

这是因为客观上 : 用户 -> 云公网 IP:端口 -> 云平台映射/NAT -> 云服务器私网 IP:端口 -> 你的程序。

如果 在服务端显示绑定 IP地址 那么只能拿发送给自己绑定的IP报文,就是说即使别的IP指向服务端 客户端发送给未能绑定服务器但指向服务器的报文,也依然拿不到!

云服务器内存在的三种IP

  1. 主机自身127.0.0.1
  2. 服务器内的局域网IP,因为局域网内部存在多个主机!
  3. 公网IP(没有配置到机器内部,这个我们无法查询 ,云服务器厂商收到报文后会转发到你的机器上)

为何绑定云服务器公网IP会失败?怎么解决这个问题

因为 bind() 的含义是:

要求操作系统把程序绑定到本机拥有的某个 IP 地址上。

而云服务器公网 IP 通常只是云平台网络层的映射地址,并没有配置在 Linux 网卡上:

text 复制代码
Linux 网卡实际拥有:10.0.0.5
云平台对外映射:106.54.172.42

当程序执行:

c 复制代码
bind(..., "106.54.172.42", ...);

Linux 会检查本机网卡,发现没有 106.54.172.42,于是失败!

正确做法:

c 复制代码
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

它表示监听本机所有网卡地址:

text 复制代码
外部访问 106.54.172.42:8080
        ↓
腾讯云转发
        ↓
服务器 10.0.0.5:8080
        ↓
程序监听 INADDR_ANY

此时 测试公网 IP,需要在另一台电脑上运行,就可以顺利测试成功了!

3. V2版本- DictServer

这个版本本质就是给服务端再添加一个字典翻译的处理程序(代码详情请见gitte

V2、V3 版本都在lesson62目录下

4. V3版本- 简单聊天室

代码详情请见gitte

V2、V3 版本都在lesson62目录下

4.1 核心思路与流程图

我们还可以将服务端接入线程池,使其变成UDP 多人聊天室服务器

核心思路:

cpp 复制代码
多个客户端发消息
        ↓
server_udp 统一接收消息
        ↓
把"转发消息"封装成任务
        ↓
交给线程池处理
        ↓
线程池执行 route_message()
        ↓
把消息转发给所有在线客户端(客户端双线程负责收、发)

#mermaid-svg-58AFNBz2M0fBbyGe{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-58AFNBz2M0fBbyGe .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-58AFNBz2M0fBbyGe .error-icon{fill:#552222;}#mermaid-svg-58AFNBz2M0fBbyGe .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-58AFNBz2M0fBbyGe .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-58AFNBz2M0fBbyGe .marker{fill:#333333;stroke:#333333;}#mermaid-svg-58AFNBz2M0fBbyGe .marker.cross{stroke:#333333;}#mermaid-svg-58AFNBz2M0fBbyGe svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-58AFNBz2M0fBbyGe p{margin:0;}#mermaid-svg-58AFNBz2M0fBbyGe .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-58AFNBz2M0fBbyGe .cluster-label text{fill:#333;}#mermaid-svg-58AFNBz2M0fBbyGe .cluster-label span{color:#333;}#mermaid-svg-58AFNBz2M0fBbyGe .cluster-label span p{background-color:transparent;}#mermaid-svg-58AFNBz2M0fBbyGe .label text,#mermaid-svg-58AFNBz2M0fBbyGe span{fill:#333;color:#333;}#mermaid-svg-58AFNBz2M0fBbyGe .node rect,#mermaid-svg-58AFNBz2M0fBbyGe .node circle,#mermaid-svg-58AFNBz2M0fBbyGe .node ellipse,#mermaid-svg-58AFNBz2M0fBbyGe .node polygon,#mermaid-svg-58AFNBz2M0fBbyGe .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-58AFNBz2M0fBbyGe .rough-node .label text,#mermaid-svg-58AFNBz2M0fBbyGe .node .label text,#mermaid-svg-58AFNBz2M0fBbyGe .image-shape .label,#mermaid-svg-58AFNBz2M0fBbyGe .icon-shape .label{text-anchor:middle;}#mermaid-svg-58AFNBz2M0fBbyGe .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-58AFNBz2M0fBbyGe .rough-node .label,#mermaid-svg-58AFNBz2M0fBbyGe .node .label,#mermaid-svg-58AFNBz2M0fBbyGe .image-shape .label,#mermaid-svg-58AFNBz2M0fBbyGe .icon-shape .label{text-align:center;}#mermaid-svg-58AFNBz2M0fBbyGe .node.clickable{cursor:pointer;}#mermaid-svg-58AFNBz2M0fBbyGe .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-58AFNBz2M0fBbyGe .arrowheadPath{fill:#333333;}#mermaid-svg-58AFNBz2M0fBbyGe .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-58AFNBz2M0fBbyGe .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-58AFNBz2M0fBbyGe .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-58AFNBz2M0fBbyGe .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-58AFNBz2M0fBbyGe .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-58AFNBz2M0fBbyGe .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-58AFNBz2M0fBbyGe .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-58AFNBz2M0fBbyGe .cluster text{fill:#333;}#mermaid-svg-58AFNBz2M0fBbyGe .cluster span{color:#333;}#mermaid-svg-58AFNBz2M0fBbyGe div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-58AFNBz2M0fBbyGe .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-58AFNBz2M0fBbyGe rect.text{fill:none;stroke-width:0;}#mermaid-svg-58AFNBz2M0fBbyGe .icon-shape,#mermaid-svg-58AFNBz2M0fBbyGe .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-58AFNBz2M0fBbyGe .icon-shape p,#mermaid-svg-58AFNBz2M0fBbyGe .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-58AFNBz2M0fBbyGe .icon-shape .label rect,#mermaid-svg-58AFNBz2M0fBbyGe .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-58AFNBz2M0fBbyGe .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-58AFNBz2M0fBbyGe .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-58AFNBz2M0fBbyGe :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} UDP 聊天服务器
多个 UDP 客户端
message
message
message
message
sendto() 转发
sendto() 转发
sendto() 转发
sendto() 转发
客户端 A
客户端 B
客户端 C
客户端 D
server_udp

recvfrom() 接收消息
创建转发任务

保存消息内容和发送者地址
线程池

多个工作线程
route_message()

遍历在线客户端

线程池的作用是: server_udp 专注于快速接收 UDP 数据;较耗时的"组织、转发、广播"工作交给工作线程处理,避免接收消息被阻塞。

再来张图:

同时因为聊天中 你不可能发一条消息收一条消息(recvfrom是阻塞等待的)所以客户端我们也可以用多线程的方式,一个收一个听!

4.2 服务端

服务端的核心职责可以拆成两部分:

text 复制代码
1. 收到客户端消息
2. 将消息广播给其他在线客户端(线程池做)

流程如下:

text 复制代码
客户端发来 UDP 报文
        ↓
server_udp 调用 recvfrom()
        ↓
得到:消息内容 + 客户端 IP + 客户端端口
        ↓
将该客户端记录为在线用户
        ↓
创建 route_message() 转发任务
        ↓
交给线程池执行
        ↓
遍历在线用户并调用 sendto()
        ↓
其他客户端收到消息

1. 服务端创建 UDP Socket

服务端首先创建 UDP 套接字:

cpp 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

参数含义:

text 复制代码
AF_INET      使用 IPv4
SOCK_DGRAM   使用 UDP 协议
0            让系统自动选择对应协议

接着绑定端口:

cpp 复制代码
struct sockaddr_in local;
memset(&local, 0, sizeof(local));

local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = htonl(INADDR_ANY);

bind(sockfd, (struct sockaddr *)&local, sizeof(local));

其中:

cpp 复制代码
htonl(INADDR_ANY)

表示监听服务器本机的所有网卡地址。部署到云服务器时,不建议将程序直接绑定到云厂商提供的公网 IP,因为公网 IP 往往是云平台映射的地址,未必真实存在于 Linux 网卡中。

2. 接收客户端消息

服务端循环调用 recvfrom()

cpp 复制代码
char buffer[1024];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);

ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                     (struct sockaddr *)&client_addr,
                     &client_len);

if (n > 0)
{
    buffer[n] = '\0';
}

此时服务器得到了两类信息:

text 复制代码
消息内容:buffer
发送者信息:client_addr

例如:

text 复制代码
客户端 A:192.168.1.10:50001
消息内容:hello

对于 UDP 聊天室,客户端不需要显式登录。只要服务器首次收到该地址发来的 UDP 数据,就可以暂时认为该客户端在线。

3. 维护在线用户列表

服务端通常维护一个在线客户端列表:

cpp 复制代码
std::vector<sockaddr_in> online_users;

当收到消息后,检查客户端地址是否已存在:

text 复制代码
不存在:加入在线用户列表
已存在:说明该用户仍在线

客户端地址不能只比较 IP,还要比较端口:

text 复制代码
IP 相同、端口相同,才是同一个 UDP 客户端

原因是多台客户端可能处于同一局域网中,对服务器而言,它们可能通过不同端口通信。

4. 为什么要使用线程池

如果服务器收到一条消息后,直接在接收线程中广播:

text 复制代码
recvfrom()
    ↓
遍历所有用户
    ↓
多次 sendto()
    ↓
再回去 recvfrom()

当在线用户较多时,广播过程会占用接收线程。此时新的 UDP 数据可能来不及处理,甚至因接收缓冲区满而丢失。

因此,将"转发消息"交给线程池:

text 复制代码
主线程:
    负责快速 recvfrom() 接收消息

工作线程:
    负责 route_message() 广播消息

对应结构:

text 复制代码
server_udp
    ↓
接收 message
    ↓
ThreadPool::Enqueue(route_message)
    ↓
线程池工作线程执行任务
    ↓
sendto() 广播消息

5. route_message() 的职责

route_message() 可以理解为聊天室中的"消息路由器"。

它拿到一条消息后:

text 复制代码
1. 获取在线用户列表
2. 遍历每一个客户端
3. 使用 sendto() 发送消息
4. 可选择跳过消息发送者自己

代码如下:

cpp 复制代码
void route_message(const std::string &message,
                   const sockaddr_in &sender)
{
    LockGuard guard(online_users_mutex);

    for (const auto &user : online_users)
    {
        if (user.sin_addr.s_addr == sender.sin_addr.s_addr &&
            user.sin_port == sender.sin_port)
        {
            continue;
        }

        sendto(sockfd, message.c_str(), message.size(), 0,
               (struct sockaddr *)&user, sizeof(user));
    }
}

在线用户列表是共享资源:

text 复制代码
接收线程:可能添加新用户
工作线程:可能遍历用户并广播

因此必须使用互斥锁保护,否则可能发生:

text 复制代码
一个线程正在遍历 vector
另一个线程向 vector 插入用户
程序崩溃或数据错乱

4.3 客户端

客户端的任务比较明确:

text 复制代码
发送线程:读取用户输入,并发送给服务器
接收线程:等待服务器消息,并显示在终端

如果只使用一个线程,程序会遇到问题:

text 复制代码
程序正在 getline() 等待用户输入
        ↓
此时服务器发来消息
        ↓
客户端无法及时 recvfrom() 接收并显示

因此客户端需要两个线程。

1. 发送线程

发送线程不断读取标准输入:

cpp 复制代码
void SendMessage()
{
    InetAddr serveraddr(server_port, server_ip);

    while (true)
    {
        std::string message;

        std::cout << "Please Enter# " << std::flush;
        std::getline(std::cin, message);

        if (message.empty())
        {
            continue;
        }

        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
                           (struct sockaddr *)serveraddr.GetNetAddress(),
                           serveraddr.Len());

        if (n < 0)
        {
            perror("sendto");
        }
    }
}

输入:

text 复制代码
Please Enter# hello everyone

客户端就会将 hello everyone 发送给服务器。

首次调用 sendto() 时,客户端即使没有调用 bind(),Linux 也会自动为该 UDP Socket 选择一个临时本地端口。

2. 接收线程

接收线程不断等待服务器广播的消息:

cpp 复制代码
void RecvMessage()
{
    while (true)
    {
        char buffer[1024];

        struct sockaddr_in server_addr;
        socklen_t len = sizeof(server_addr);

        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr *)&server_addr, &len);

        if (n < 0)
        {
            perror("recvfrom");
            continue;
        }

        buffer[n] = '\0';
        std::cerr << buffer << std::endl;
    }
}

使用 std::cerr 输出的一个常见原因是:可以让聊天消息与用户输入提示分开处理。

但如果将标准错误重定向到命名管道:

bash 复制代码
./client_udp 服务器IP 端口 2> chat_fifo

必须有另一个程序读取管道:

bash 复制代码
cat chat_fifo

否则写入命名管道可能阻塞。普通 .txt 文件不需要读取端,但命名管道必须存在读写双方。


5 .一次完整聊天流程

假设客户端 A、B、C 都已经向服务器发过消息,服务器已记录它们的地址。

现在客户端 A 输入:

text 复制代码
hello

完整过程如下:

text 复制代码
客户端 A 的发送线程
    ↓
getline() 读取 hello
    ↓
sendto() 发送给服务器
    ↓
服务器 recvfrom() 收到 hello
    ↓
服务器得到客户端 A 的 IP 和端口
    ↓
创建 route_message 任务
    ↓
任务加入线程池
    ↓
某个工作线程取出任务
    ↓
遍历在线客户端 B、C
    ↓
sendto() 将 hello 发给 B、C
    ↓
客户端 B、C 的接收线程 recvfrom()
    ↓
std::cerr 输出 hello

5. 一些补充内容

5.1 netstat指令

netstat 用来查看系统的网络连接、监听端口、路由表和网卡统计信息。

Linux 中常见格式:

bash 复制代码
netstat [选项]
选项 含义
-a 显示所有连接和监听端口
-n 直接显示数字 IP 和端口
-t 只显示 TCP
-u 只显示 UDP
-l 只显示监听状态
-p 显示对应进程和 PID
-r 显示路由表
-i 显示网卡信息
-s 显示网络协议统计
-c 持续刷新显示
-4 只显示 IPv4
-6 只显示 IPv6

常用组合:

cpp 复制代码
netstat -anup//作用:查看所有 UDP 端口以及对应的进程。
netstat -antp//作用:查看所有 TCP 连接、监听端口以及对应的进程。

5.2 ssh与sshd是什么

sshdSSH 服务端程序,全称是:

text 复制代码
SSH daemon

它运行在 Linux 服务器上,负责接收其他设备发起的 SSH 远程连接。

例如你执行:

bash 复制代码
ssh root@106.54.172.42

连接过程大致是:

text 复制代码
你的电脑
   |
   | SSH 连接
   v
服务器 106.54.172.42:22
   |
   v
sshd
   |
   v
登录 Shell

常见特点

  • 默认监听 TCP 22 端口
  • 负责用户身份认证
  • 支持密码登录和密钥登录
  • 登录成功后提供远程命令行
  • 通常以后台服务形式运行

查看 sshd 是否监听:

bash 复制代码
netstat -antp | grep sshd

可能看到:

text 复制代码
tcp  0  0  0.0.0.0:22  0.0.0.0:*  LISTEN  1234/sshd

这表示 sshd 正在监听所有 IPv4 网卡的 22 端口。

需要注意:

  • ssh:客户端命令,用来连接别人
  • sshd:服务器程序,等待别人连接

例如:

text 复制代码
ssh   -> 发起连接
sshd  -> 接收连接

sshd这样的服务,绑定在22号端口,我们能用xshell远程登上云服务器,是因为我们云服务器上启动了绑定22号端口的这个服务!我们通过ssh访问服务器,从而实现"云服务",而这个程序都是云服务厂家提前打开的!

cpp 复制代码
Xshell/ssh
    ↓
连接公网 IP:22
    ↓
安全组、防火墙检查
    ↓
云平台将公网 IP 映射到服务器私网 IP
    ↓
sshd 接收连接
    ↓
SSH 加密握手(目前了解)
    ↓
用户名密码或密钥认证(目前了解)
    ↓
创建 Shell 会话
    ↓
执行 ls
    ↓
命令结果通过 SSHD 加密返回
    ↓
网络传输
    ↓
Xshell 解密
    ↓
Xshell 界面显示结果

通过 Xshell 使用 SSH 协议访问云服务器,云服务器上的 sshd 在 TCP 22 端口监听并处理连接,从而实现远程登录和管理云服务器。

流程为:

cpp 复制代码
客户端发送命令:
Xshell 加密 -> sshd 解密

服务器返回结果:
sshd 加密 -> Xshell 解密

5.3 在其他系统进行套接字编程访问云服务器

OS内核是不一样的,但是网络协议栈所有系统都是一致的,所以核心接口应该是一致的!

以博主的Macos为例子(可以利用ai生成代码):

相关推荐
酒神dnspup1 小时前
服务器端口检测怎么做?用 DNSPup 核对开放端口、监听服务与暴露风险
网络·网络协议·http·ssl证书·http状态码·服务器端口
lajidecrd1 小时前
Ubuntu 24.04 LTS安装
linux·运维·ubuntu
沫璃染墨1 小时前
《从零入门Linux系统篇(二十九):文件篇·二——深入文件描述符:从文件描述符表到重定向,再到Shell实现》
linux·运维·服务器·c++·驱动开发·系统架构
2301_800895101 小时前
计算机组成原理保研面试题目整理---h
网络
jing.wang_20251 小时前
制作最小Linux(Ubuntu)系统
linux·嵌入式硬件·ubuntu·dsp开发
陈年老古董1 小时前
Shell 脚本编程基础完整学习笔记
linux·sql·mysql
酒神dnspup2 小时前
HTTP状态码检测怎么做?用 DNSPup 排查 4xx、5xx、重定向与网站可用性
网络·网络协议·http·http状态码
byte轻骑兵2 小时前
【BlueZ 】蓝牙地址与 UUID:BlueZ 源码中核心标识的定义与使用
linux·bluez·电脑蓝牙·嵌入式蓝牙
Baron X2 小时前
Linux PAM登录告警
linux·运维·告警