欢迎光临
我们一直在努力

【Linux】三十二.《Linux 网络编程:UDP Socket 编程深度梳理:IP 端口、网络字节序、socket API,手写多线程聊天室巩固网络模型》

一.Socket编程预备知识:

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

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

但是这⾥要思考⼀个问题:数据传输到主机是⽬的吗?不是的。因为数据是给⼈⽤的。⽐如:聊天是⼈在聊天,下载是⼈在下载,浏览⽹⻚是⼈在浏览。但是⼈是怎么看到聊天信息的呢?怎么执⾏下载任务呢?怎么浏览⽹⻚信息呢?通过启动的 qq,迅雷,浏览器。⽽启动的 qq,迅雷,浏览器都是进程。换句话说,进程是⼈在系统中的代表,只要把数据给进程,⼈就相当于就拿到了数据。
所以:数据传输到主机不是⽬的,⽽是⼿段。到达主机内部,再交给主机内的进程,才是⽬的。
但是系统中,同时会存在⾮常多的进程,当数据到达⽬标主机之后,怎么转发给⽬标进程?这就要在⽹络的背景下,在系统中,标识主机的唯⼀性。

理解源IP地址和目的IP地址看上一节唐僧取经的内容,有详细解答方便理解

IP 数据包头部,携带了两个关键地址:源 IP 地址、目的 IP 地址。

一台主机如果没有 IP,就没法参与网络通信。IP 地址用来标识网络里的一台主机,所以当数据从一台主机发给另一台主机时,IP 报头就必须带上源 IP 与目的 IP。

  • 源 IP:数据是谁发出来的(发送方主机)
  • 目的 IP:数据要交给谁(接收方主机)

但是注意:IP 只能定位到整台主机,这并不是通信的终点。真正收发数据的,其实是主机上应用层的各个应用软件(进程)。

一台电脑上同时运行着大量进程,QQ、浏览器、视频软件等等。公网 IP 只能找到这台机器,数据包到达主机之后,操作系统怎么确定:A 进程发过来的数据,要交给本机上哪一个 B 进程去处理?

问题:用什么标识同一台主机内部不同进程,保证数据准确给到对应的软件?

 答案就是端口号 (port)IP 找主机,端口号找主机里面的进程,IP + 端口就可以定位全网唯一的一个通信进程。


2.理解端口号和进程ID

认识端口号:

端口号是用来区分同一台设备上不同网络应用 / 进程的数字标识。端口是16 位无符号整数,取值范围 0 ~ 65535。

当一个网络程序要对外提供通信能力时,会向操作系统申请占用某一个端口,这个动作叫做绑定 (binding),程序会在该端口上等待接收外来数据。当外部主机发来数据包,操作系统读到报文里的目的端口,就会把数据交付给已经绑定该端口的进程。

注意:端口和进程并不是严格的一对一永久映射。 一台主机上,同一个端口同一时刻只能被一个进程绑定,避免冲突;但一个进程可以同时绑定多个不同端口。不能理解成写死的一对一关系。

同一台机器上,不同进程绑定互不相同的端口,就可以做到多个软件同时联网收发数据,互不干扰,实现并发网络通信。

IP 地址负责定位网络中唯一一台主机,而端口号负责定位主机内部的通信进程。 端口号属于传输层的概念,由 TCP/UDP 头部携带。

  • 服务端进程:一般使用固定知名端口对外提供服务;
  • 客户端进程:大多由操作系统随机分配临时端口。

IP + 端口号,二者组合,就可以标识全网当中唯一的一个通信进程。

  • 端⼝号是⼀个 2 字节 16 位的整数;
  • 端⼝号⽤来标识⼀个进程, 告诉操作系统, 当前的这个数据要交给哪⼀个进程来处理;
  •  IP地址 + 端⼝号能够标识⽹络上的某⼀台主机的某⼀个进程;
  •  ⼀个端⼝号只能被⼀个进程占⽤.(一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定)

牢牢记住这句话:

IP 地址(标识唯一主机)+ 端口号(标识唯一进程)能够标识网络上的某一台主机的某一个进程(全网唯一的进程

端⼝号范围划分

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

HTTP 协议服务端默认端口是80。浏览器访问网站时,浏览器作为客户端,操作系统会给它分配一个随机临时端口,向服务器的80 端口发送请求,和服务端 Web 进程通信。服务器处理完成后返回网页数据,数据从服务器的 80 端口发出,送达浏览器的临时端口。80 是 Web 服务的监听端口,专门接收 HTTP 请求。

FTP 协议的控制连接默认端口 21。FTP 客户端进行文件上传、下载、删除时,先和服务器 21 端口建立连接,传递各类操作命令。

补充:FTP 传输真正的文件数据使用 20 端口,21 只负责交互指令

很多网络协议都规定了对应的默认知名端口,服务端绑定这些端口对外提供服务,客户端就可以直接默认访问。常见知名端口:

  • 20:FTP 数据端口
  • 21:FTP 控制端口
  • 22:SSH 远程安全登录
  • 25:SMTP 发送邮件
  • 53:DNS 域名解析
  • 80:HTTP 明文网页
  • 443:HTTPS 加密网页

注意知名端口是给服务端监听使用,客户端一般不用这些端口,由系统自动分配 49152‑65535 区间的临时端口


将数据送给对方的机器是我们的目的吗?不是的,是手段。真正的网络通信过程,本质其实就是进程间通信

进程,就是人在操作系统里的代表。数据交给进程,相当于用户拿到了数据。数据传到主机只是手段,真正的目的,是把数据递交给主机内部对应的进程。上网本质就两类行为:

  • 从远端服务器获取数据(下载、浏览网页)
  • 把本地数据上传给远端服务器(发消息、上传文件)
  • 数据流转过程: 进程内存 –> 网卡 –> 网络 网络 –> 网卡 –> 进程内存 这整套属于跨主机的 IO 操作。

    网络通信的本质:不同主机上面的两个进程在做数据交互,本质就是跨主机的进程间通信。普通本机进程间通信,是同一台机器内的进程共享资源; 而网络,就是用来让两台不同主机上的进程,能够看到同一份数据资源。


    既然操作系统已经有 PID 可以标识进程,为什么网络通信还需要端口号

  • 归属层面不一样,实现系统与网络解耦 :PID 是操作系统内核层面用来标识进程的,只在本机系统内部生效;端口号是传输层网络协议定义的概念,是网络层面的标识。 如果直接拿 PID 在网络里传输,就会把操作系统内部的进程编号暴露给网络,不同操作系统 PID 规则还不一样,网络协议就会强绑定操作系统,不利于跨平台通信。使用端口号,就把操作系统和网络两者解耦开来。
  • PID 是动态变化的,服务端端口一般固定不变: 进程每次重启,分配到的 PID 都会发生改变。如果客户端依靠 PID 去寻找服务,服务重启 PID 一变,客户端就找不到服务了。 而服务端的监听端口是固定的,就好比报警电话 110、急救 120,对外号码不变。客户端预先就知道服务的目标端口,可以直接发起访问。
  • 二者的职责范围不同 系统里每一个进程都一定拥有 PID;但并不是所有进程都要进行网络通信,很多本地程序完全不需要端口号。端口号只给需要做网络收发的进程使用。
  • 端口与进程不是一一永久绑定 同一时刻,一个端口只能被一个进程绑定;但是一个进程可以同时绑定多个不同端口,提供多种网络服务。

  • 那么客户端第一次通信,是怎么知道要向哪个 IP、哪个端口发送数据?

    端口:是协议预先约定内置的。 像 HTTP 默认 80、HTTPS 默认 443,这些知名端口直接写在客户端程序内部。我们访问网页时,浏览器就默认去找服务器的这两个端口,不需要临时再去询问服务器。

    IP 地址并不是内置的。 我们平时输入的是域名,比如 www.baidu.com。客户端会先发起 DNS 请求,通过域名解析拿到服务器对应的 IP 地址,有了 IP,再搭配约定好的目标端口,才可以正式发送业务请求。


    3.理解源端⼝号和⽬的端⼝号

    和 IP 类似,端口同样分为源端口、目的端口 发送数据包的时候,报文头部不仅要带上源 IP、目的 IP,还要带上源端口、目的端口。

    • 目的 IP + 目的端口:告诉对方,数据要交给哪台机器上的哪个进程
    • 源 IP + 源端口:标记我方地址,对方收到之后,按照这个地址把响应数据回传给我们。 这些信息都封装在 TCP/UDP 协议头部,作为数据包的一部分向外发送。

    4.认识TCP、UDP协议

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

    我们用的套接字接口一定会使用传输层协议,不会绕过传输层去调用下面的协议。传输层的协议分为 TCP 协议和 UDP 协议。 

    a.认识TCP协议

    TCP(Transmission Control Protocol,传输控制协议)特点

    属于传输层协议

  • 有连接:正式收发业务数据之前,必须先建立连接(三次握手),通信结束后还要断开连接(四次挥手)。
  • 可靠传输:协议底层内部做了大量机制保障数据完整交付,处理丢包、乱序、重复等问题,保证数据能够完整、有序、不重复地到达对端。
  • 面向字节流:把数据当成一串连续的字节流,没有明确报文边界。

  • b.认识UDP协议

    UDP(User Datagram Protocol,用户数据报协议)特点

    属于传输层协议

  • 无连接:不用提前建立连接,想发数据包就直接发送,也没有专门的断开流程。
  • 不可靠传输:协议本身不做保障,网络中会发生丢包、乱序、数据包重复,UDP 不会自动重传纠错。
  • 面向数据报:以一个完整 UDP 数据报为最小单位,自带报文边界,一次收发就是一个完整报文。
  • 补充:传输层不只是单纯解决可靠性。 TCP 负责实现可靠传输;UDP 只负责完成寻址(端口)与数据分发,不处理可靠性。可靠性是 TCP 的能力,不是整个传输层的统一目标。


    既然 UDP 不可靠,为什么还需要 UDP?

    这里要特别注意:可靠和不可靠是描述协议能力的中性词,没有优劣好坏之分,只是两种不同的技术特性。想要做到可靠传输是要付出成本的:TCP 需要维护连接状态、确认应答、丢包重传、流量控制、拥塞控制一系列机制,会消耗额外的 CPU、内存以及网络带宽,内部逻辑比较复杂。

    UDP 在协议层不去实现可靠保障,因此开销极小,实现逻辑简单,收发数据效率很高。 UDP 不可靠不等于业务就一定要不可靠,协议层不提供可靠,应用层完全可以自己在上层实现可靠性

    理解就是:TCP 把可靠性内置在协议内部;UDP 把 要不要保证可靠这个选择权交给上层应用。


    c. ⽹络字节序

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

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

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

    •  这些函数名很好记, h 表⽰ host , n 表⽰ network , l 表⽰ 32 位⻓整数, s 表⽰ 16 位短整数。
    •  例如 htonl 表⽰将 32 位的⻓整数从主机字节序转换为⽹络字节序,例如将IP地址转换后准备发送。
    •  如果主机是⼩端字节序,这些函数将参数做相应的⼤⼩端转换然后返回;
    •  如果主机是⼤端字节序,这些函数不做转换,将参数原封不动地返回。

    注意:各位老铁

    ⽹络规定 所有发送到⽹络上的数据,都必须是⼤端的!


    二.socket编程

    1.理解socket套接字

    IP 地址标识互联网当中唯一一台主机,port 端口号标识这台主机上唯一的网络进程。 IP + Port 组合,就可以标识互联网中唯一的一个通信进程,我们把 IP+port 叫做套接字 socket

    网络通信本质,就是两个跨主机的进程互相通信。通信过程中,{srcIp, srcPort, dstIp, dstPort} 这个四元组,唯一标识通信两端的一对进程。

    四元组 = 源 IP、源端口、目的 IP、目的端口。一条网络连接,靠四元组做区分。

    套接字(Socket):不单单只是 IP + 端口,它也是网络编程的抽象接口,是应用层和传输层之间的桥梁。我们写代码时,借助 socket 接口,就可以完成不同主机之间的数据收发。一次通信存在两个通信端点:客户端套接字、服务端套接字,两端通过 socket 互相收发数据。套接字依托 TCP/IP 协议簇工作,根据底层协议,主要分为两类:

  • 流套接字(Stream Socket)—— 面向连接 底层基于 TCP 协议,提供面向连接、可靠传输。数据按顺序完整交付,不会丢包乱序。 类比打电话:通信之前必须先建立连接,通话结束再挂断。
  • 数据报套接字(Datagram Socket)—— 无连接 底层基于 UDP 协议,属于无连接通信。数据以独立数据报为单位发送,协议层面不保障顺序、不保障数据一定送达。 适合可以容忍少量丢包、不需要可靠传输的业务场景。
  • 形象记忆:socket 本意是插座、插孔。形象理解:操作系统给程序留出网络 “插座”,进程插上这个 socket 插座,就可以接入网络进行通信。

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

    a.socket

    创建 socket ⽂件描述符 (TCP/UDP, 客⼾端 + 服务器)


    b.bind

    绑定端⼝号 (TCP/UDP, 服务器)先发出的数据是低地址, 后发出的数据是⾼地址

    c.bind

    开始监听socket (TCP, 服务器)

    d.accept

    接收请求 (TCP, 服务器)

    e.connect

    建⽴连接 (TCP, 客⼾端)


    3.sockaddr结构

    socket API是⼀层抽象的⽹络编程接⼝,适⽤于各种底层⽹络协议,如IPv4、IPv6,以及后⾯要讲的UNIXDomain Socket. 然⽽, 各种⽹络协议的地址格式并不相同.

    套接字常见分为三类:网络套接字、域间套接字(Unix 域套接字)、原始套接字,三者分工不同,对应不一样的通信场景:

  • 网络套接字(Network Socket) 支持跨主机网络通信,当然也支持本机进程之间通信。基于 IP 协议,就是我们写 TCP/UDP 网络编程最常用的套接字,对应结构体 sockaddr_in,存放 IP 地址 + 端口号信息。
  • 域间套接字(Unix Domain Socket,UDS)仅能用于同一台主机内部进程通信,不经过网络协议栈。不需要 IP 和端口,依靠本地文件系统的文件路径完成进程寻址,对应结构体 sockaddr_un。小优势:本机通信效率比网络套接字更高,不走网卡,适合本机多个程序高速交互。
  • 原始套接字(Raw Socket) 可以绕过传输层 TCP、UDP,直接操作 IP 层报文。 一般用来抓包、自定义构造 IP 数据包、分析底层网络,普通业务开发几乎不会使用。
  • sockaddr_in 和 sockaddr_un 分别对应网络通信、Unix 域本地通信两种完全不同的场景。 结构体最开头前两个字节是协议家族(地址族)标识符 sa_family_t,靠这个字段就可以区分这到底是哪一种地址结构。但是我们在调用 bind()、connect() 这类系统 API 的时候,并不直接给函数传 sockaddr_in 或者 sockaddr_un。 函数形参统一写的是 const struct sockaddr *addr。

    举个例子:做网络编程,我们实际填充的是 sockaddr_in 结构体,传参的时候必须做强制类型转换,把 sockaddr_in* 强转为 sockaddr* 再传入函数。在内核内部,拿到统一的 sockaddr 指针之后,读取结构体最开头的 sa_family(地址族),判断是什么协议家族,再反向强转回对应的真实结构体(sockaddr_in / sockaddr_un)做处理。

    类比理解:可以把 C 语言里的sockaddr理解成基类,sockaddr_in、sockaddr_un当作派生类,用 C 语言的结构体 + 地址族字段模拟出面向对象的多态效果

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

    a.sockaddr 结构

    b.sockaddr_in 结构

    虽然socket api的接⼝是sockaddr, 但是我们真正在基于IPv4编程时, 使⽤的数据结构是sockaddr_in;这个结构⾥主要有三部分信息: 地址类型, 端⼝号, IP地址.

    c.in_addr结构

    in_addr⽤来表⽰⼀个IPv4的IP地址. 其实就是⼀个32位的整数;

    三.程序编写—-简单的UDP网络程序demo程序

    这里我们分为三个版块:服务端的实现,用户端的实现,本地间进行通信。

    1.服务端的实现


    a.创建套接字socket

    通信开始第一步,调用 socket() 创建套接字。 在 Linux 下,一切皆文件,socket 本质就是打开一个特殊的文件,函数作用:创建套接字,返回一个文件描述符,把这个文件描述符和网卡关联起来。

    socket 是计算机网络提供的一个系统调用接口,它对传输层做了相关的一层文件系统级别的封装的接口

    参数简单理解:

    domain:协议家族(地址族),用来选定通信场景。

    • AF_INET:网络套接字,用于跨主机 IP 网络通信,对应结构体sockaddr_in
    • AF_UNIX:Unix 域套接字,仅本机进程间通信,对应结构体sockaddr_un

    type:套接字类型

    • SOCK_STREAM:流套接字,对应 TCP
    • SOCK_DGRAM:数据报套接字,对应 UDP

    protocol:协议,一般直接填 0,让系统根据前两个参数自动选择协议


    返回值:

    • 成功:返回套接字文件描述符(int 整数),后续 bind、listen、read/write 都用这个 fd 操作;
    • 失败:返回

    简单理解就是: 调用 socket,相当于向操作系统申请拿到一张网络 入场券(文件描述符)。后续所有收发网络数据,我们就像读写普通文件一样,对这个 fd 做读写,内核底层会帮我们和网卡打交道。 注意:socket 只是创建出来套接字,此时还没有绑定 IP 和端口,也没有建立连接


    根据上面的描述可知,以后的各种操作都要通过这个文件描述符,所以在服务端类中还需要一个成员变量表示文件描述符。

    创建完成套接字后,接下来就是需要绑定 IP 和端口号。


    b.绑定bind

    bind系统调用作用:把指定的 IP、端口和我们创建好的 socket 套接字,在内核当中进行强绑定。

    函数原型

    #include <sys/socket.h>
    int bind(int socket, const struct sockaddr *address, socklen_t address_len);

    我们不能直接给 bind 传sockaddr,实际要先定义、填充 sockaddr_in 结构体,再强转成sockaddr* 传入。

    struct sockaddr_in 网络地址结构体

    struct sockaddr_in {
    short int sin_family; // 地址族,固定填 AF_INET
    unsigned short int sin_port; // 端口号,必须网络字节
    struct in_addr sin_addr; // IP地址(4字节整数,网络字节序)
    unsigned char sin_zero[8]; // 填充位,让结构体大小和sockaddr对齐,直接置零
    };

    点分十进制字符串 IP,例如192.168.110.132,本质会转换成一个 4 字节整数,sockaddr_in里面存的就是这个 4 字节整数,不存字符串。


    创建完结构体,一定要先把内存清空初始化。 两种方式:memset / bzero

    #include <strings.h>
    void bzero(void *s, size_t n);

    struct sockaddr_in local;
    bzero(&local, sizeof(local));


    大小端转换接口 <arpa/inet.h>

    网络统一使用大端(网络字节序),我们主机一般是小端,端口、IP 都要做转换。

    // 主机字节序 –> 网络字节序
    uint32_t htonl(uint32_t hostlong);
    uint16_t htons(uint16_t hostshort);

    // 网络字节序 –> 主机字节序
    uint32_t ntohl(uint32_t netlong);
    uint16_t ntohs(uint16_t netshort);

    htons:host to network short,专门转换端口(short 两个字节)。

    inet_addr():把点分十进制 IP 字符串,直接转换成网络字节序的 4 字节整数。

    local.sin_addr.s_addr = inet_addr("192.168.1.100");

    bool initServer()
    {
    //1. 创建套接字,AF_INET代表IPv4网络,SOCK_DGRAM代表UDP数据报,protocol填0自动选择UDP协议
    _sock = socket(AF_INET, SOCK_DGRAM, 0);
    if(_sock < 0)
    {
    // socket创建失败,打印错误码与错误描述,直接终止程序
    logMessage(FATAL, "%d:%s", errno, strerror(errno));
    exit(2);
    }

    //2. bind绑定:把IP、port和当前socket在内核做关联,内核收到报文才知道交给这个socket
    struct sockaddr_in local;
    // bzero把整个结构体内存置0,防止栈上残留脏数据,保证sin_zero填充为0
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET; // 设置地址族为IPv4
    local.sin_port = htons(_port); // 主机字节序端口 –> 网络大端字节序,网络通信必须统一大端
    local.sin_addr.s_addr = inet_addr(_ip.c_str()); // 将点分十进制IP字符串转成网络字节序4字节整数

    // bind参数:套接字fd,强转为通用sockaddr*,传入真实结构体大小
    if(bind(_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
    {
    // bind失败,常见原因:端口被占用、ip地址非法
    logMessage(FATAL, "%d:%s", errno, strerror(errno));
    exit(2);
    }
    // 走到这里代表UDP套接字创建+绑定全部完成
    logMessage(NORMAL, "init udp server done … %s", strerror(errno));
    return true;
    }

    bind 是服务端必不可少的一步,告诉操作系统:收到目标为这个 IP + 端口的数据,交给当前这个 socket。


    c.启动服务器 Start

    网络服务器程序一般不会主动结束,属于常驻内存的后台进程,进程一旦跑起来就驻留在内存,只有异常崩溃或者人为杀死才会停止。 运行服务的时候,我们通过命令行把要绑定的 IP 地址、端口号传给程序。

    #include "udp_server.hpp"
    #include <memory>
    #include <cstdlib>

    static void usage(std::string proc)
    {
    std::cout << "\\nUsage: " << proc << " ip port\\n" << std::endl;
    }

    // 程序运行格式:./udp_server ip port
    int main(int argc, char *argv[])
    {
    // 判断命令行参数数量,参数不对打印使用说明直接退出
    if(argc != 3)
    {
    usage(argv[0]);
    exit(1);
    }
    // 读取命令行传入的ip字符串
    std::string ip = argv[1];
    // 将命令行的端口字符串转为整型数字
    uint16_t port = atoi(argv[2]);
    // 使用智能指针管理服务对象,把ip、port传入构造函数
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));
    svr->initServer(); // 完成套接字创建与bind绑定
    svr->Start(); // 启动服务,进入循环收发数据
    return 0;
    }

    argc、argvargc:命令行参数的总个数;argv:字符串指针数组,保存每一个命令行参数。argv[0]是程序本身名字,argv[1]是 ip,argv[2]是 port。 运行 ./udp_server 0.0.0.0 8080,此时argc=3。

    usage 函数 帮助提示函数,参数不对的时候打印程序该怎么使用,告诉用户运行格式。

    atoi() 把字符串转成 int 整数;命令行拿到的全部是字符串,端口需要数值,所以做转换。

    IP 该如何传入配置,看后面的内容。


    d.读取数据 recvfrom

    #include <sys/types.h>
    #include <sys/socket.h>
    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、addrlen:输出型(值‑结果)参数。内核会把发送数据的客户端地址信息填充进来,调用前addrlen需要设置成地址结构体大小,调用后内核会改写该值。拿到客户端地址之后,就可以用sendto向该客户端回复消息。

    #define SIZE 1024

    char buffer[SIZE];
    for (;;)
    {
    // 注意:peer:纯输出型参数,用来存放客户端地址信息
    struct sockaddr_in peer;
    bzero(&peer, sizeof(peer));
    // 输入:peer缓冲区大小;输出:内核改写为实际客户端地址结构体大小
    socklen_t len = sizeof(peer);
    // start. 读取数据
    ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) – 1, 0, (struct sockaddr*)&peer, &len);


    e.地址转换函数

    收到客户端 UDP 报文后,对端的 IP、端口信息全部存放在输出参数peer(struct sockaddr_in)结构体中。 结构体内部的 IP 是网络字节序(大端)的 32 位整数,不能直接打印输出;需要先做字节序处理,再转为我们熟悉的点分十进制字符串。系统提供地址转换接口完成该工作

    inet_ntoa 函数分析

    char *inet_ntoa(struct in_addr in);

    功能:把网络字节序的 in_addr 结构体,转换成 IPv4 点分十进制字符串。

  • 返回char*,字符串存放在函数内部静态缓冲区,不是 malloc 出来的堆内存
  •  不需要调用free()手动释放内存
  •  静态缓冲区会被后续调用覆盖,多次调用,上一次返回的指针内容会被篡改
  • 测试代码

    那如果我们调用多次这个函数,会有什么样的效果呢? 

    #include <stdio.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>

    int main()
    {
    struct sockaddr_in addr1;
    struct sockaddr_in addr2;

    addr1.sin_addr.s_addr = 0;
    addr2.sin_addr.s_addr = 0xffffffff;

    // 两次调用inet_ntoa,共用同一块静态缓冲区
    char* ptr1 = inet_ntoa(addr1.sin_addr);
    char* ptr2 = inet_ntoa(addr2.sin_addr);

    // ptr1、ptr2指向同一块静态内存,第二次调用覆盖第一次结果
    printf("ptr1: %s, ptr2: %s\\n", ptr1, ptr2);
    return 0;
    }

    运行输出

    现象:ptr1 本该输出0.0.0.0,实际和 ptr2 输出完全一样。 原因:inet_ntoa使用函数内部静态全局缓冲区,第二次调用直接覆盖缓冲区旧内容,两个指针指向同一片内存。

    如果多个线程同时调用 inet_ntoa,会出问题吗?

    •  APUE 书中明确提到,inet_ntoa不是线程安全函数。 它会把 IP 字符串放到函数内部的静态缓冲区,所有线程共用同一块内存。多线程并发调用,数据会互相覆盖,打印出来的 IP 就会出错。
    • 在 CentOS7 测试没看到报错 CentOS7 的 glibc 库内部给inet_ntoa额外加了互斥锁,对静态缓冲区做了保护,所以很难复现错误。 但这只是系统额外做的实现,不是 C 网络标准的要求。换到嵌入式、老旧系统就没有这层保护,bug 就会暴露出来。 这种属于概率性 bug,程序不一定崩溃,只是数据悄悄篡改,后期很难排查,不能靠本地测试没问题就放心使用。
    • 多线程环境正确的使用方式 多线程环境不要使用inet_ntoa,推荐使用inet_ntop。 缓冲区由我们程序员自己提供,每个线程可以使用自己的局部数组,不存在共享资源竞争,天然线程安全,同时兼容 IPv4 和 IPv6。

    同样获取端口号的时候也要由网络序列转成主机序列:

    字节序转换(端口)

    uint16_t ntohs(uint16_t netshort);

    • ntohs:网络字节序短整型 –> 主机字节序,接收客户端端口用这个
    • htons:主机字节序短整型 –> 网络字节序,bind绑定端口用这个

    端口是 16 位,固定使用ntohs / htons,不要用htonl/ntohl。

    void Start()
    {
    // echo server: client给我们发送消息,暂时先原封不动返回
    char buffer[SIZE];
    for (;;)
    {
    // 注意:peer:纯输出型参数
    struct sockaddr_in peer;
    bzero(&peer, sizeof(peer));
    // 输入:peer(缓冲区大小);输出:实际读到的peer的大小
    socklen_t len = sizeof(peer);
    // start. 读取数据
    ssize_t s = recvfrom(_sock, buffer, sizeof(buffer) – 1, 0, (struct sockaddr*)&peer, &len);
    if (s > 0)
    {
    buffer[s] = 0; // 目前就把数据当作字符串
    uint16_t cli_port = ntohs(peer.sin_port); // 注意:peer是从网络中来的
    std::string cli_ip = inet_ntoa(peer.sin_addr); // 4字节的网络序列的IP->本主机的字符串风格的IP,方便显示
    printf("[%s:%d]# %s\\n", cli_ip.c_str(), cli_port, buffer);
    }
    // 分析和处理数据,TODO
    // end. 写回数据
    sendto(_sock, buffer, strlen(buffer), 0, (struct sockaddr *)&peer, len);
    }
    }

    现在只需要等待用户端发送数据就可以了


    基于 IPv4 的 socket 网络编程,sockaddr_in结构体里的sin_addr用来保存 32 位网络字节序 IP。我们日常使用点分十进制字符串(例如192.168.1.1),需要一组 API 完成字符串格式和in_addr 结构体互相转换。

    A. 字符串 IP –> in_addr(点分十进制转网络序整数)

    // 老接口,仅支持IPv4
    int inet_aton(const char *strptr, struct in_addr *addrptr);

    // 老接口,返回in_addr_t,仅IPv4,已经不推荐
    in_addr_t inet_addr(const char *strptr);

    // 推荐接口,支持IPv4、IPv6
    int inet_pton(int family, const char *strptr, void *addrptr);

    B. in_addr –> 字符串 IP(网络序转点分十进制)

    // 旧接口,只支持IPv4,内部静态缓冲区,非线程安全
    char *inet_ntoa(struct in_addr inaddr);

    // 推荐接口,支持IPv4/IPv6,由调用者提供缓冲区,线程安全
    const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);

    inet_pton、inet_ntop参数是void*,所以既能接收 IPv4 的in_addr,也能接收 IPv6 的in6_addr,兼容性更强。

    C. 简单测试 demo

    #include <stdio.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>

    int main()
    {
    struct sockaddr_in addr;
    // 字符串IP转换成网络序存进结构体
    inet_aton("127.0.0.1", &addr.sin_addr);

    // 强转,打印原始的32位网络序数值
    uint32_t* ptr = (uint32_t*)&addr.sin_addr;
    printf("addr: %x\\n", *ptr);

    // 网络序IP转为点分十进制字符串打印
    printf("addr_str: %s\\n", inet_ntoa(addr.sin_addr));
    return 0;
    }

    运行结果:


    2.客户端的实现

    客户端要向外发送 UDP 数据,必须明确指定目标服务端的 IP 与端口,这里填写的 IP、port,代表数据要发给哪一台主机的哪个进程,不是客户端自己的 IP 端口。

    这里的 IP 和 port 指的是要发送给谁

    UDP 客户端同样第一步需要创建 socket 套接字,创建方式和 UDP 服务端完全一致。

    a.绑定问题

    客户端确实需要 IP + 端口,用来标识一台主机上的唯一进程,但不需要我们手动调用 bind 做显式绑定。

    那为什么服务端一定要显式 bind 指定端口?

    服务器的端口是对外公开、客户端预先知晓的。端口不能随便变化,一旦端口随机改变,外部客户端就不知道该往哪个端口发请求,就找不到服务器程序了。所以服务端必须固定端口,通过 bind 把指定 IP、端口和 socket 套接字强绑定在一起。

    反观客户端,端口只需要做到本地唯一就够了,不需要让别人提前记住。客户端只用来标识自己这个进程,不需要对外公布自己的端口号。

    举个生活例子理解:就好比给快递公司寄件。服务端相当于快递站点,地址(端口)固定不变,所有人都知道站点在哪;客户端相当于寄件人,你不需要固定一个专属门牌号,家里门牌号只要不和同小区其他人重复就行。

    拿手机 App 来举例: 手机上面会运行各式各样的 App(客户端),后台对接各个公司的服务端。如果要求每个客户端程序都手动写死绑定一个固定端口,很容易出现端口冲突。假如两个不同 App 硬编码选用同一个端口,同一台设备上就无法同时运行这两个程序,直接报错。

    所以 UDP 客户端交给操作系统处理:当第一次调用sendto向外发送数据的时候,操作系统会自动挑选一个没有被占用的临时端口,完成隐式 bind。我们程序员只需要创建 socket 套接字,不用关心自己的端口是多少。


    b.发送数据 sendto

    sendto 和 recvfrom 是 UDP 一对配套的接口,参数大部分是对应的。 recvfrom 是把对端发来的 IP、端口填到地址结构体里给我们读;而 sendto 需要我们自己手动填充目的 IP、目的端口,告诉操作系统这个数据包要发给谁。

    client 要不要 bind?

    要,但是一般 client 不会显示的 bind,程序员不会自己手动调用 bind。

    client 作为普通用户使用的客户端程序:如果我们程序员自己写死 bind,让 client 绑定一套固定的 IP 和端口。万一机器上别的程序提前占用了这个端口,我们客户端直接启动失败,发生端口冲突。所以客户端一般不需要显式 bind 指定端口,交给 OS 自动随机挑选一个空闲临时端口。

    绑定时机:客户端第一次调用sendto向外发送消息给服务器的时候,操作系统会自动帮客户端完成隐式 bind,分配本机 IP + 随机端口。


    3.进行本地间通信

    a.代码如下:

    .PHONY:all 声明all是伪目标,不是磁盘上的文件名,执行make默认执行 all,一次性编译生成udp_client、udp_server两个可执行程序。

    .PHONY:clean clean伪目标,执行make clean,删除两个可执行文件,做项目清理。

    大致流程:

    客户端输入字符串

    sendto() → 发给 127.0.0.1:8080

    内核网络协议栈 → udp_server recvfrom()拿到数据,同时拿到对端 {ip:127.0.0.1,port:44624}

    服务端打印客户端IP端口+收到内容

    服务端直接用拿到的peer结构体 sendto 原路把数据发回去

    客户端recvfrom接收,打印 server echo# xxx

  • 服务器端口 8080:固定端口 服务器必须 bind 固定端口,客户端才知道往哪个端口发数据。
  • 客户端端口 44624:临时端口 客户端代码没有写 bind,调用 sendto 的时候操作系统自动随机分配一个可用端口,每次运行客户端这个数字会变。
  • 0.0.0.0(INADDR_ANY) 代表服务器监听本机所有网卡,本机任意 IP 收到 8080 的数据都交给这个 udp_server 进程。
  • netstat 参数说明
    • -a 显示全部套接字
    • -n 数字形式展示 IP、端口,不解析域名
    • -u 只看 UDP
    • -p 显示进程 PID 和程序名字(普通用户部分进程看不到,root 权限看全部)

    b.IP 的绑定

    这里的 127.0.0.1 叫做本地环回(loopback)。当客户端与服务器使用 127.0.0.1 通信时,数据包只会在本机内核的 TCP/IP 协议栈内部完成流转,不会交给物理网卡硬件,也不会真正跑到外部物理网络上。作用:专门用于本地网络代码调试测试。

    注意:绑定127.0.0.1并不是数据包完全不走协议栈,应用层数据依然会完整经过传输层、网络层处理,只是协议栈直接把报文回传给本机,不会下送到物理层,不会通过网线 / 无线向外发送。 这种情况下,只有本机上的程序能够访问该服务,局域网内其他主机是无法连接过来的。


    我这个是两个参数:

    修改成三个参数:

    当我们运行起来后想要查看网络情况就可以用指令 netstat,后边也可以附带参数:

    • -a:显示所有Socket,包含已连接、监听、未监听的套接字
    • -e:显示额外网络信息,如 UID、inode 等
    • -i:查看网卡设备信息(网卡列表、收发数据包统计)
    • -l:只显示处于监听状态的服务端 Socket,专门看正在等待客户端连接的程序
    • -n:数字形式输出 IP 与端口,不做域名解析,执行速度更快,排查问题必用
    • -p:显示占用该 Socket 的进程 PID 和程序名(root 权限才能看到全部进程)
    • -t:只查看 TCP 协议相关连接
    • -u:只查看 UDP 协议相关套接字


    那如果我们想要全网通信呢?难道是云服务器上的公网 IP 吗? 

    我们可以发现,绑定不了云服务器上的公网 IP。因为云服务器是虚拟化服务器(不是真实的 IP),所以不能直接绑定公网 IP。

    那试一下内网呢?(局域网 IP)呢?

    可以的,说明这个 IP 是属于这个服务器的,说明如果这里不是一个内网的就无法找到。


    那服务器启动之后,已经到达主机网卡的消息,要怎么向上交付给到我们写的应用程序呢?

    内核收到网络报文之后,会根据报文的目标端口号,把数据向上交付给绑定了该端口的套接字对应的用户进程。 这里就体现出 INADDR_ANY 的意义:一台服务器主机可能配有多张网卡,拥有多个不同 IP(IP1、IP2……)。实际写服务器的时候,一般不建议手动指定某一个具体 IP 去 bind。如果我们强行绑定 IP1,那么只有目标地址是 IP1 的报文才会交给进程;客户端往 IP2 发送同样端口的数据,内核就不会把这个数据交付给我们的服务器,程序直接收不到报文。

    使用 INADDR_ANY(也就是0.0.0.0)绑定之后:本机所有网卡上、目标端口等于我们绑定端口的 UDP 报文,内核都会向上交付给当前服务器进程,不管报文是发到 IP1 还是 IP2,服务都可以正常接收数据,不会漏掉任意网卡过来的请求。


    这里的 INADDR_ANY 实际上就是 0,这样绑定之后,再发送到这台主机上所有的数据,只要是访问绑定的端口(8080)的,服务器都能收到。这样就不会因为绑定了一个具体的 IP 而漏掉其他 IP 的信息了。其实就是让服务器在工作的工程中,可以从任意的 IP 中获取数据。

    以后不用传IP了:

    四.深入了解DUP功能

    想要在程序内部执行一条 shell 命令,并且拿到这条命令输出的结果,一套底层思路是: 调用pipe()创建管道,再通过fork()创建子进程,子进程调用exec*系列函数执行目标 command 命令;父子进程借助管道完成数据传递,父进程就可以读到命令输出内容。

    但自己手动写pipe + fork + exec整套流程比较繁琐,Linux 给我们封装好了接口:popen()。 popen返回FILE*文件指针,我们可以直接用文件操作函数(fread、fgets)读取命令执行输出结果,不用自己处理管道、进程创建、程序替换这些细节。


    实现多线程网络通信(又叫做网络聊天室)

    原理是:

    我们发送消息会经过服务端的转发,让每个在线的客户端都能看到发送的消息,这样就实现了群聊。

    流程大致是:

    客户端发消息 –> 服务端子线程接收 –> 加锁遍历在线集合 –> 广播转发给其他客户端 –> 其他客户端收到打印消息。

    a.加入聊天室进行聊天的用户

    b.分析和处理数据

    c.多线程处理

    UDP 客户端如果只用单线程,recvfrom是阻塞接口。程序调用接收消息的时候,代码会卡在这一步,无法同时读取键盘输入发送消息;如果先读键盘输入,又没法及时收到别人发来的聊天消息,两者互相阻塞,客户端不能一边输入发消息、一边打印别人的聊天内容。解决思路:把接收、发送拆成两个独立线程。两个线程各司其职,互不阻塞,实现一边看聊天、一边输入发消息。


    d.代码如下:

    UDP 的 socket 本质就是内核里的一个文件描述符,同一个 socket 文件描述符,可以被多个线程同时用来 recvfrom、sendto

    UDP 属于全双工,收发互相独立互不阻塞干扰。我们客户端代码就是典型例子:一个线程专门做发送,读取键盘数据调用sendto;另一个线程专门做接收,阻塞调用recvfrom,两个线程操作的是同一个 socket。


    e.运行结果:

    赞(0)
    未经允许不得转载:171主机测评 » 【Linux】三十二.《Linux 网络编程:UDP Socket 编程深度梳理:IP 端口、网络字节序、socket API,手写多线程聊天室巩固网络模型》
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址