UDP socket 编程从传输层协议到聊天室的诞生我的github(https://github.com/xcx55/ubuntu-linux-project)感谢各位大佬参观我的github上一篇把网络分层、封装解包、IP 和端口的故事讲完了这篇进入真正的第一段代码——UDP socket 编程。从 8 月 26 号到 29 号我们沿着传输层协议 → 网络字节序 → socket 接口 → 服务端/客户端 → 聊天室一路写下来这篇按这条线整理。一、传输层代表协议TCP 与 UDP现实生活就这两类先明确一个定位传输层及以下就是系统调用了。应用层写的是代码往下走一层就碰到 OS 提供的能力。传输层最有代表性的两个协议是 TCP 和 UDP它们是两个完全不同的协议。现实生活里恰好能找到对应电话—— 有连接先拨号建立连接、确认对方在听再说事说完挂断发邮件—— 无连接写完直接发对面收没收到、什么时候看不管。现实生活就这两类网络通信也不例外。可靠 vs 不可靠是特点不是缺点很多人一听到UDP 不可靠就觉得它低人一等这里要纠正一个认知可靠 / 不可靠指的是对数据丢失处不处理。TCP 会确认、重传、排序UDP 发出去就不管了。但反过来问一句UDP 都不可靠了为什么还要用它不要把不可靠传输当缺点要当作特点不可靠一定更轻量化速率更快可靠性意味着要做更多的工作确认、重传、拥塞控制……每一项都是成本。所以运用场景自然分开直播就是 UDP丢一两帧无所谓卡顿才致命电话一般是 TCP一个字都不能错。面向字节流 vs 面向数据报TCP 面向字节流——像自来水公司水数据连续地流没有边界UDP 面向数据报——像快递站一件一件收发每个包都是独立的、有边界的。二、网络字节序大小端的千年之争什么是大端小端同样的地址排布权值放在大地址还是小地址就是大小端的分歧。记住一个规律越后面的数字其权值越低。以0x11 22 33 44为例权值从高到低小权值的数据写在低地址→ 小端高权值的数据写在高地址→ 大端。口诀小小小小大大前一个小是小权值放低地址……记成小端小端小权值低地址大端大权值高地址就行。为什么网络选了大端存储技术刚起步时各家存数据是大端还是小端全由自己公司决定后来网络普及摩擦就来了。网络最终特别刚地选择了大端序理由很充分网络传输先发送低地址数据、再发送高地址数据接收也一样——这和小端序正好冲突大端贴合人类阅读习惯从左到右就是高位到低位低地址向上访问字节也符合抓包的效率。于是规则定死网络字节序 大端序。但我们的机器大概率是小端所以才有了一系列转换函数htons、inet_addr等。好消息是大部分情况操作系统会自动做大小端转换但结构体里的字段需要我们手动转——不知道自己机器是什么端序就一直用函数转换得了反正无害。三、socket 接口设计一个父类结构体的巧思为什么要统一接口socket 可以支持网络通信也可以支持本地通信。如果网络一套 API、本地一套、抓包又是另一套那就太难用了。所以设计者把参数封装为结构体统一一套软件层参数一样、返回值一样。方便就是首选。sockaddr 家族structsockaddr_in{__SOCKADDR_COMMON(sin_);// 前 2 字节地址族类型in_port_tsin_port;// 2 字节端口structin_addrsin_addr;// 4 字节 IP内部是 s_addrunsignedcharsin_zero[...];// 填充字段凑齐 sockaddr 大小};这里用的是 C 语言实现多态的那套思路我们在 VFS 那篇讲过struct sockaddr是通用外壳父类结构体前 2 字节标记这个指针实际是什么类型sockaddr_in网络通信、sockaddr_un本地通信都是子类API 参数统一收sockaddr*内部按前 2 字节决定转换成哪个子类——指针放在子类里面多态完成。只要学会了 INET socket本地通信也秒懂。UDP 报头8 字节定长UDP 的报头极其简洁一共8 字节16 位源端口号16 位目的端口号16 位 UDP 长度16 位 UDP 检验和后面直接跟数据。源端口 目的端口正好呼应上一篇ip:port 全球唯一网络进程的结论。四、核心函数的背后的信息socket()创建一个网络文件socket()创建用于网络的文件放进进程的 file 数组返回 struct file——又回到那句话Linux 下一切皆文件网卡也是 fd。以后对网络的读写就是对这个 fd 的读写。bind()把 ipport 绑到文件上bind()依据传入的sockaddr结构体负责将端口号和 IP 绑定在 socket 文件上返回 0 成功、0 失败。端口号 16 位IP 32 位ip:port 组合全局唯一——实现对单一进程的精确控制而不仅仅是端口控制多 IP 情况下绑定 ip端口选择更多、更灵活如果 bind 的 IP 用INADDR_ANY意味着任何一块网卡上来的、打到这个端口的数据都能收——服务器不建议只绑一个 IP因为一台服务器往往不止一个 IP显式绑死太片面。htons / inet_addr端序转换out.sin_porthtons(port);// host to network shortout.sin_addr.s_addrinet_addr(server_ip.c_str());// 点分十进制字符串 → 4 字节整数并转网络序端口号和 IP 在放进结构体之前必须转成网络序列。recvfrom / sendtoUDP 的读写recvfrom收数据时除了数据本身还会带回发送方的 IP 端口号通过通用结构体sockaddr接收——这一点至关重要因为服务端要给客户端回信必须先知道客户端是谁。sendto发数据时目标写进结构体传进去。还有一个隐藏功能如果 sendto 之前没绑定过端口它会先给 sockfd 随机绑一个端口再发送——这就是客户端不需要显式 bind 的底层原因。五、客户端为什么不显式 bind这是 UDP 编程里最经典的问题。客户端需要自己的 IP 和 Port 吗需要需要显式 bind 吗不要推理链条client port 只需要具有唯一性即可具体是几不重要服务端的端口是服务端硬编码的大厂有自己的服务器 IP所有客户端都往这个端口发不会冲突但如果让客户端显式 bind 一个固定端口麻烦就来了同一台机器上可能跑着不同厂家的客户端大家要是都硬编码同一个端口必然冲突——而各厂家之间是不会商量端口的所以干脆交给 OS不 bind 就由操作系统随机分配一个未使用的端口给 sockfd天然避免冲突。一句话总结客户端是主动发起通信的一方端口随机服务端必须显式 bind因为客户端要拿着它的 ipport 才能找到它。本地环回 127.0.0.1测试时用的127.0.0.1是本地环回地址数据只经过网络协议栈、不真正出网卡和物理网络完全解耦。本地环回通信没问题就证明代码没问题有问题则是网络的问题——它是分离代码 bug和网络故障的利器。云服务器的坑端口默认是关的还有一个实战大坑云服务器的端口号默认是不允许开启绑定的腾讯云的入口服务器会拦住外部流量。解决办法是在云控制台配置安全组或防火墙把要用的端口打开——不然代码写得再对外部也连不上。六、V3 聊天室把所有知识拧在一起UDP 服务端我们按三个版本递进V1 Echo server原样返回→V2 DictServer查词典词典逻辑做成可注入的回调→V3 简单聊天室。V3 的架构图值得画出来这也是 29 号那篇感悟的核心图client Server ┌─────────────┐ ┌──────────────────────────┐ │ 读线程 ────┼──发送────────▶ │ 收消息的 udpserver │ │ 从标准输入 │ │ │ 推送转发任务 │ │ 获取数据 │ │ ▼ │ │ 写线程 ◀───┼──接收──────── │ 执行发消息的线程池 │ │ 打印到另一 │ │ 线程│线程│线程│… │ │ 个终端 │ └──────────────────────────┘ └─────────────┘ 路由数据并转发出去几个关键设计客户端读写两个线程不用分先后——因为recvfrom会阻塞线程读线程阻塞时写线程照常工作天然并行。打印到另一个终端可以用命名管道这样就能用终端代替图形界面服务端收到消息后不做转发而是把转发作为任务推给线程池——网络章和线程章在这里会师服务端保存客户端 ipport 时用本机序列作为参数传入 sendto 之前再转网络序列——存的时候按人看的存用的时候按网看的转一次转换都不浪费服务端要路由数据从在线用户表里找到该收的人逐个转发。至此从协议是快递单到一个能聊天的程序网络编程的第一条链路走通了。总结TCP/UDP 是两类协议有连接可靠 vs 无连接不可靠不可靠是特点不是缺点网络字节序统一大端端口和 IP 进结构体前必须htons/inet_addrsockaddr 家族用C 语言多态统一网络与本地通信socket 创建网络文件、bind 绑定 ip:port、recvfrom 顺带带回对端地址客户端不显式 bind随机端口防冲突、sendto 隐式绑定服务端必须 bind被找到云服务器记得开安全组本地测试用 127.0.0.1。下一篇整理 29 号的两篇感悟——lambda 的类型擦除和条件变量等待的真相。