ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

UDP单聊C++实现:从socket到recvfrom的完整实践

UDP单聊C++实现:从socket到recvfrom的完整实践 UDP 单聊是网络编程里很经典的一个练习。它的核心不是做出一个好看的聊天界面而是让你真正理解 UDP 的“无连接”到底意味着什么不需要握手、没有连接状态、每个数据报独立发送代码量比 TCP 少一大截但可靠性和消息顺序都没有保证。这篇文章用一份可以在两个终端里直接跑的 C 代码带你从 socket 创建、bind、sendto、recvfrom 讲起先跑通单条消息再扩展到连续收发、局域网联机最后把 UDP 的边界和常见踩坑点一起说清楚。想搞懂 UDP 协议、写 Linux 下的 socket 程序或者准备相关面试的读者都可以按这篇的顺序过一遍。1. 先搞懂 UDP 单聊和 TCP 聊天差在哪1.1 UDP 为什么代码更短TCP 写一个聊天程序至少要有 listen、accept、connect 三个建立连接的步骤还要处理连接断开、半关闭、粘包这些问题。UDP 完全不是这个套路它只需要四个核心接口socket、bind、sendto、recvfrom。两个程序各自绑定一个本地端口然后直接往对方的 IP 和端口发数据报谁都不用先“听”也不用建立连接。单聊和群聊、广播不一样。单聊是两个人点对点通信在 UDP 里就是两个对称的 socket 进程互相 sendto、recvfrom。A 不知道 B 在不在线B 也不知道 A 什么时候发消息谁有数据谁就发这种模型非常适合初学者理解数据报协议。这里最容易误解的一点是UDP 单聊里没有传统意义上的“服务端”和“客户端”。两边代码几乎一样只是参数不同。如果你一定要区分可以把先启动、负责等待消息的一方叫“收方”但本质上它们是对等的。1.2 UDP 适合什么场景不适合什么场景UDP 适合实时性要求高、偶尔丢一个包可以接受的场景比如语音、视频、实时游戏状态同步、局域网内的小消息通知。拿我自己的经验说做局域网设备控制或者机器人消息分发时UDP 常常是首选因为延迟低、代码简单丢一帧状态下一帧就补回来了。但 UDP 不适合关键消息。登录、支付、订单确认、文件传输这类一个包都不能丢的场景裸 UDP 是不行的。你可以在应用层做确认和重传也可以直接换 TCP 或 QUIC。学习 UDP 单聊时不用考虑这么深但必须知道边界在哪里能发通的代码不等于能扛住丢包、乱序、重复这些真实网络问题。实际商业聊天软件也不会只用裸 UDP 发文字通常会走可靠协议或者自己在 UDP 上做应用层重传。所以这道练习题的价值在于理解数据报模型而不是直接拿到生产环境去套用。2. 动手前先准备环境并理解四个核心接口2.1 运行环境怎么选我建议先用 Linux 环境最常见的就是 Ubuntu 22.04 这类发行版配一个 g 编译器就够了。代码里用的是 POSIX socket 接口Linux 下最干净。Windows 原生开发也能做但 Winsock 要先 WSAStartup初始化和关闭的细节多一点不适合入门阶段对比概念。如果你是在 Windows 上装了 WSL2 的 Ubuntu没问题本地测试 UDP 程序基本无差异。但要注意 WSL2 默认是 NAT 网络localhost 回环测试可以跨机器局域网联机时往往需要额外配置端口转发或改成桥接网络。只做学习验证的话先用 127.0.0.1 跑通不用急着处理 WSL 网络问题。编译前确认两个东西g --version ip addrg 存在就行版本不用新。ip addr 是用来查自己的局域网 IP后面联机测试要用。2.2 socket、bind、sendto、recvfrom 各自管什么这四个接口是整个 UDP 编程的地基先把它们的作用和返回值分清后面排错才有方向。接口作用关键返回值常见坑socket创建 UDP socket文件描述符负数表示失败忘了用 SOCK_DGRAM或者第三个参数写错bind把本地 IP 和端口绑定到 socket0 表示成功-1 失败端口被占用时报 EADDRINUSEsendto向指定地址发送数据报返回实际发送字节数只代表从本机发出不代表对方收到recvfrom阻塞等待接收数据报返回接收字节数缓冲区太小会截断收不到会一直卡住socket 的第一个参数 AF_INET 表示 IPv4第二个参数 SOCK_DGRAM 就是“数据报 socket”这是和 TCP 的 SOCK_STREAM 最大的区别。第三个参数 0 表示让系统根据前两个参数自动选协议UDP 下就是 IPPROTO_UDP。bind 是很多人第一次容易忽略的步骤。UDP socket 不 bind 也能 sendto因为发送只需要知道对方的地址但如果你不 bind本机就没有固定端口recvfrom 就没有地方收数据。可以把 bind 理解成给自己家的信箱挂门牌号对方要发消息必须知道这个门牌。sendto 比较特殊。它成功返回只代表数据已经交给操作系统发出去了不代表对端真的收到了。UDP 没有连接状态发到一个没人监听的端口通常也不会立刻报错。这一点和 TCP 完全不同TCP 连接不存在时 send 会直接失败UDP 却可能“假装成功”。recvfrom 默认是阻塞的。没有数据时线程会停在那里等等多久要看有没有包进来。这个特性到后面连续聊天时会给设计造成麻烦先记住它。地址结构体方面最常见的是 sockaddr_in里面有三个关键字段sin_family 固定填 AF_INETsin_addr 填 IPsin_port 填端口。因为网络字节序和主机字节序不同端口要用 htons 转换IP 可以用 inet_pton 从字符串转换成二进制。bind 时地址填 INADDR_ANY也就是 0.0.0.0表示接收任意网卡上的数据这样局域网测试时不会漏包。3. 用一份可以同时收发的最小代码跑通单聊3.1 代码设计思路这一节的代码采用对称双进程设计。两个程序跑起来之后各自绑定一个本地端口再配置对方的 IP 和端口。A 的端口是 9000发给 B 的 9001B 的端口是 9001发给 A 的 9000。为了同时收和发接收逻辑放在一个独立线程里主线程负责读键盘输入并发送。// udp_chat.cpp #include iostream #include string #include thread #include cstring #include cstdlib #include unistd.h #include arpa/inet.h #include sys/socket.h const int BUF_SIZE 1024; void recvLoop(int sockfd) { char buf[BUF_SIZE]; struct sockaddr_in peerAddr; socklen_t addrLen sizeof(peerAddr); while (true) { memset(buf, 0, BUF_SIZE); ssize_t n recvfrom(sockfd, buf, BUF_SIZE - 1, 0, (struct sockaddr *)peerAddr, addrLen); if (n 0) { std::cout \n[对方] buf std::endl; std::cout 我说 std::flush; } } } int main(int argc, char *argv[]) { if (argc ! 4) { std::cout 用法: argv[0] 本地端口 对方IP 对方端口 std::endl; return 1; } int localPort atoi(argv[1]); const char *peerIp argv[2]; int peerPort atoi(argv[3]); int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { perror(socket); return 1; } struct sockaddr_in localAddr; memset(localAddr, 0, sizeof(localAddr)); localAddr.sin_family AF_INET; localAddr.sin_addr.s_addr htonl(INADDR_ANY); localAddr.sin_port htons(localPort); if (bind(sockfd, (struct sockaddr *)localAddr, sizeof(localAddr)) 0) { perror(bind); close(sockfd); return 1; } struct sockaddr_in peerAddr; memset(peerAddr, 0, sizeof(peerAddr)); peerAddr.sin_family AF_INET; peerAddr.sin_port htons(peerPort); if (inet_pton(AF_INET, peerIp, peerAddr.sin_addr) 0) { perror(inet_pton); close(sockfd); return 1; } std::thread recvThread(recvLoop, sockfd); recvThread.detach(); std::string line; std::cout UDP 单聊开始输入 exit 退出 std::endl; while (true) { std::cout 我说 std::flush; std::getline(std::cin, line); if (line exit) { break; } ssize_t n sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr *)peerAddr, sizeof(peerAddr)); if (n 0) { perror(sendto); } } close(sockfd); return 0; }这段代码故意没有做太多工程化处理只突出 UDP 的最少要素。recvLoop 里用 detach 分离线程是为了让主线程可以专心读输入学习阶段够用真实项目里要考虑线程退出和 socket 释放的顺序。3.2 编译和本地验证编译命令g -stdc11 udp_chat.cpp -o udp_chat -lpthread这里的 -lpthread 是链接线程库不能省。然后开两个终端。终端 A./udp_chat 9000 127.0.0.1 9001终端 B./udp_chat 9001 127.0.0.1 9000按这个顺序A 绑定 9000发向 9001B 绑定 9001发向 9000。两端都显示“UDP 单聊开始”后在 A 里输入“你好”B 的终端应该出现[对方] 你好。反过来在 B 里输入A 也能看到。判断是否发成功的标准有两个一看 sendto 是否返回了和输入长度一致的正数二看对方终端有没有打印出来。注意 sendto 成功和对方收到是两件事只有第二种才算真的通。我一般会先用 127.0.0.1 两个终端跑通再换局域网 IP 测试。回环地址能排除网卡、防火墙、路由这些干扰UDP 程序调不通时先在这个环境下验一遍能省很多定位时间。3.3 局域网联机测试本地跑通之后把程序复制到第二台机器或者用两台虚拟机配桥接网络。假设 A 机器 IP 是 192.168.1.10B 机器 IP 是 192.168.1.11A 端这样启动./udp_chat 9000 192.168.1.11 9001B 端这样启动./udp_chat 9001 192.168.1.10 9000端口不变只把目标 IP 从 127.0.0.1 换成对方局域网 IP 即可。这里容易踩三个坑第一个是防火墙。Windows 第一次运行时会弹防火墙提示要允许专用网络访问Linux 下如果开了 ufw要先放行对应端口sudo ufw allow 9000/udp sudo ufw allow 9001/udp第二个是路由器或 AP 的隔离设置。有些路由器开了“AP 隔离”或“客户端隔离”同一 WiFi 下的设备互相访问不了表现就是 ping 得通但 UDP 收不到或者说根本 ping 不通。这个问题和代码无关先确认网络环境再继续查。第三个是 WSL2 的 NAT 模式。WSL2 里的 Ubuntu 和 Windows 宿主机之间做 UDP 通信localhost 通常没问题但其他机器访问 WSL 里的端口需要设置端口转发否则外部包进不来。学 UDP 阶段不用陷进去先本机跑通再考虑跨系统通信。4. 从发一条到连续聊解决边发边收的阻塞问题4.1 为什么不能只用单线程第一节的程序能跑但如果你真的登录上去连续聊天会立刻发现一个问题recvfrom 是阻塞的std::getline 读输入也是阻塞的。如果主线程只做接收你就没法在等待消息时输入文字如果主线程只做发送消息来了又没人接收。解决思路有两个一个是用多线程一个是用 select。本文代码选的是多线程因为最直观接收线程一直在 recvfrom 里等主线程负责键盘输入和 sendto两边互不干扰。4.2 接收线程和退出条件接收线程的代码就是一个无限循环。recvfrom 拿到数据后打印然后重新进入阻塞状态。这里有一个体验上不太优雅但学习阶段不用纠结的问题如果对方消息刚好在你输入文字时到达终端上会看到提示符被消息打断光标位置会乱。这是终端程序的正常现象不用花时间美化。退出条件是输入 exit。主线程 getline 读到 exit 后 breakclose(sockfd)进程结束。但注意接收线程是 detach 的主线程退出时接收线程可能还在阻塞实际运行时因为进程结束线程也会被终止学习代码可以接受。如果要做得更严谨可以设置一个退出标志并用 shutdown 让 recvfrom 返回。4.3 用 select 做更正经的收发轮询如果不想开线程可以参考 select 方案。select 可以同时监听 socket 和标准输入哪个有数据就处理哪个fd_set readSet; FD_ZERO(readSet); FD_SET(STDIN_FILENO, readSet); FD_SET(sockfd, readSet); int maxFd sockfd 1; struct timeval timeout {1, 0}; int ret select(maxFd, readSet, NULL, NULL, timeout); if (ret 0) { if (FD_ISSET(sockfd, readSet)) { // 调用 recvfrom } if (FD_ISSET(STDIN_FILENO, readSet)) { // 调用 getline 并 sendto } }select 的好处是不开线程、可以设置超时方便后续加心跳检测。select 每隔一秒返回一次超时后就可以检查上一次收到消息的时间超过 N 秒没消息就认为对方可能离线。这对 UDP 单聊的真实体验很重要因为 UDP 没有连接断开通知你只能靠超时“猜”。注意这里不要一上来就开最大并发。先跑通单条消息再处理连续收发最后再考虑超时、心跳这些工程化功能。顺序反了排错会很痛苦。5. 验证方式和常见报错排查5.1 怎么判断程序真的没问题程序不是“不报错”就代表正确。UDP 程序尤其如此因为很多错误是静默发生的。我会按下面这个顺序验证两端都启动后用ss -ulnp查看端口是否绑定成功能看到进程名和端口号就说明 bind 成功。先发一条短消息确认对端收到。再用中文、英文、空格混合消息测一遍确认编码和缓冲区没问题。连续发二十条确认没有丢包、卡死。最后测相反方向确认对称性。Linux 下还可以用 tcpdump 直接看数据报有没有出现在网卡上sudo tcpdump -i lo udp port 9000本地测试看 lo 网卡局域网测试看实际的 eth 或 wlan 网卡。tcpdump 能抓到包说明数据确实发到了网络层这时对端还没收到问题大概率出在防火墙或对端 bind 地址上。5.2 常见报错清单现象可能原因排查和处理bind: Address already in use端口被占用或上一个进程没退出换一个端口或等进程退出后再试sendto: Network is unreachable目标 IP 错了或路由不通先 ping 对方 IP确认网络可达sendto 成功但对方收不到对方端口不对、bind 地址不对、防火墙拦了检查对方启动参数和防火墙规则能发能收但文字是乱码终端编码不一致两端统一 UTF-8重新连接收到内容被截断recvfrom 缓冲区太小增大 BUF_SIZE或限制单条消息长度程序卡住不退出recvfrom 阻塞线程设置超时或发送 exit 后主动 close5.3 排查顺序先网络再端口最后看代码遇到 UDP 收不到消息我第一步永远是先确认网络通不通然后看端口最后才看代码。很多人一上来就在代码里找 bug浪费大量时间。推荐排查链路看现象。是报错、卡住、收不到还是乱码先明确异常类型。看输入。程序启动参数的顺序不能错尤其是“本地端口、对方 IP、对方端口”三个参数最容易把双方端口填反。看网络。ping 对方 IP确认两台机器在同一网段中间没有隔离策略。看端口。ss -ulnp确认本地 bind 成功tcpdump确认数据报已经到了网卡。看防火墙。Windows 防火墙、Linux ufw都在测试阶段先放行对应 UDP 端口。看代码。最后才检查 sendto、recvfrom 的返回值以及缓冲区大小。说一个我踩过的坑sendto 返回值正常对端就是收不到结果发现对端 bind 的是 127.0.0.1不是 INADDR_ANY局域网包根本进不去。这个用ss -ulnp一看就明白代码本身没问题。6. UDP 单聊的天然边界以及后续怎么升级6.1 UDP 的报文限制和可靠性边界UDP 单聊能跑通不代表能处理所有数据。IPv4 的 UDP 报文最大载荷是 65507 字节这是协议层限制。但在以太网上我建议单条消息控制在 1472 字节以内。因为以太网 MTU 一般是 1500 字节减去 IP 头 20 字节和 UDP 头 8 字节剩下的 1472 字节才是不会触发分片的上限。超过这个值IP 层会分片分片包更容易丢性能也更差。recvfrom 的缓冲区也要注意。代码里 BUF_SIZE 是 1024如果对方发过来 2000 字节的消息后面的内容会被截断。真实项目中要么把缓冲区调大要么在协议层限制单条消息长度。可靠性和顺序方面UDP 天然不保证消息可能丢、可能重复、可能乱序。在局域网里丢包少看起来像没问题一旦切换到公网环境这些问题全部会暴露。6.2 什么时候必须放弃裸 UDP如果聊天消息一条都不能丢裸 UDP 就不够用了。常见需要放弃 UDP 的场景登录、支付、订单等业务消息丢失会直接导致流程错误。单条消息特别大超过一个 UDP 报文能承载的范围。需要会话状态比如用户登录状态、消息历史、离线补发。这些场景要么用 TCP要么在 UDP 上做应用层可靠传输。学习阶段先把“什么时候不能用 UDP”这句话记住比记住 API 更有价值。6.3 在 UDP 上做可靠单聊可以补什么如果一定要在 UDP 上实现可靠聊天可以往协议层加东西这也是很多网络编程面试题里常见的进阶方向。消息格式至少要有这几个字段字段作用type区分文字、心跳、确认、退出seq消息序号用来排序和去重len消息体长度payload实际内容发送方每发一条消息编一个递增序号接收方收到后回一个 ACK发送方在超时时间内没收到 ACK 就重发。接收方根据 seq 去掉重复消息并按照序号重新排序。再定期发心跳检测对方是否在线。这一步做完本质上就是在一个极简的可靠传输协议上聊天了。如果只是学习默认这份代码就够了。如果要长期使用或者扩展到真实项目我建议先把消息格式、ACK 重传、超时检测这三件事想清楚再继续往下做。最后留几个我自己排查时会优先看的点先确认两个端口没有填反再确认 bind 用的是 INADDR_ANY然后看防火墙有没有放行 UDP最后才是检查缓冲区大小和线程退出逻辑。UDP 单聊看起来简单但真正踩过一轮之后会发现很多问题不是接口不会写而是对“无连接”这三个字理解得不够深。先把这篇的代码跑通再往 TCP、select、应用层协议方向扩展网络编程的这条线就顺了。
返回列表