计算机网络篇5:UDP 收发接口 recvfrom 与 sendto 的参数语义


📚 本文收录于「流浪」的系列专栏

🐧 Linux系统 ⚙️ C++
📊 数据结构与算法 🐍 Python
🔗 LangChain & LangGraph 🗄️ MySQL 数据库
🌿 Git 工具 🌐 计算机网络
🤖 LLM 💯 大厂面试、八股
📚 学习筑基专栏

🏠 博客主页:流浪 | 📝 原创首发于 CSDN


前言: 计算机网络篇4把 socket、bind、listen、accept、connect 五件套和 sockaddr 通用结构讲完了,但那五个接口只是把通信的架子搭了起来,真正的数据一个字节都还没动过。UDP 里真正干活的是另外两个接口:recvfrom 负责收,sendto 负责发。 本篇把 recvfrom 的六个参数逐个拆开------数据收进来放哪、标志位是干什么的、为什么收个数据还要往外带一个地址结构 ;再讲 sendto 的参数怎么跟 recvfrom 一一对称 ,服务器怎么靠这套参数把数据原路送回去;最后回答一个问题:UDP 明明不建连接,为什么还能你来我往地双向通信。


一、点分十进制是给人看的,机器认的是四字节 IP

1.1 点分十进制用三个点隔出四段十进制数,是 IP 的字符串写法

1. 192.168.0.123 是一个标准的点分十进制 IP
  1. 192.168.0.123 就是我们天天在写的点分十进制 IP,先拿它把这种写法看清楚
  2. IP 地址本质是 32 位的二进制数,也就是四个字节,直接给人看一串 01 没法记也没法念
  3. 于是把它写成四段十进制数、中间用点隔开,「点分十进制」这个名字就是这么来的
2. 每一段对应 IP 四个字节里的一个,取值 0 到 255
  1. 四个字节各对应一段,每段的取值范围是 0 到 255,正好是一个字节能表示的范围
  2. 192.168.0.123 的意思就是四个字节分别是 192、168、0、123
  3. 规则卡死「四段、每段一个字节」,看到一个字符串是不是合法 IP,自己就能判断
3. 这种写法是给人看的,通信时必须转成四字节大端序列
  1. 点分十进制是给人看的写法,好记好念,但网络通信时机器不认这个字符串
  2. 真正通信前它要被转成 4 字节 IP,再转成网络字节序(大端)填进 sockaddr_in,这两步转换在篇4讲字节序接口时已经讲过,这里不重复
  3. 所以写 UDP 代码时会见到两种形态:配置里给人看的点分字符串,结构体里给机器用的四字节整数

人看地址用点分十进制,机器收地址认的是四字节大端序列,中间隔着一层转换。

1.2 两套写法并存,是因为人和机器各取所需

1. 人记不住 32 位二进制,机器解析字符串又费劲
  1. 一个 IP 展开是 32 位 0 和 1 的串,人眼根本没法记,念出来也没法交流,所以必须有给人看的压缩写法
  2. 反过来,内核处理地址走的是定长的二进制结构,每次都去解析点分字符串效率太低,所以机器内部必须用四字节整数
  3. 两套写法各服务一边,转换接口夹在中间,这就是 inet_addr、inet_ntoa 这类函数存在的理由
2. 收发的数据里从来没有点分字符串
  1. 真正发到网络上的报文里,地址字段永远是四字节大端的整数,不存在点分十进制
  2. 点分字符串只活在人的屏幕和配置文件里,从调用发送接口那一刻起就全是二进制了
  3. 理解这一点,后面看 recvfrom 拿回来的地址结构就不会困惑:它带出来的是四字节 IP,要给人看得再转回去

点分十进制只活在人这一侧,网络上跑的地址永远是四字节大端整数。


二、服务器 7×24 小时一直在活动,收数据的动作就是 recvfrom

2.1 服务器永远在线,主循环就是收一条处理一条

1. 服务器 7×24 小时是一直活动的
  1. 服务器和客户端的运行方式完全不同:客户端用完就关,服务器 7×24 小时是一直活动的
  2. 它必须一直开着,因为不知道客户端什么时候来,随时都得有人「值班」
  3. 这个「值班」在代码里就是一个不停转的循环,转一圈干一件事:等数据来、处理、再等
2. 把数据收进来的动作,在 UDP 里就是调 recvfrom
  1. 循环里「等数据来」这一步,UDP 用的接口就是 recvfrom
  2. 服务器的主循环骨架可以概括成一句话:recvfrom 收一条、处理一条、要回话就 sendto 发一条,然后回到 recvfrom 继续等
  3. 本篇的主角 recvfrom 就站在这个循环的入口上,它收得好不好,取决于参数填得对不对

服务器永远在线就是循环永远在转,recvfrom 是这个循环的入口,参数填对它才收得对。

2.2 recvfrom 的签名:返回值是 ssize_t,参数一共六个

1. recvfrom 的函数签名
c 复制代码
#include <sys/socket.h>

// 接收数据报:sockfd 是套接字描述符,buf 是接收缓冲区,len 是缓冲区大小,flags 是标志位(默认 0),
// src_addr 用来带出发送方的地址(不关心可传 NULL),addrlen 是地址结构体大小的指针
// 返回值:成功返回实际收到的字节数,失败返回 -1
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
2. 返回值类型是 ssize_t,成功返回实际收到的字节数
  1. 返回值类型是 ssize_t,这是一个有符号的大小类型,专门用来表示「一次 IO 实际搬运了多少字节」
  2. 成功时它返回本次实际收到的字节数,这个数不一定等于缓冲区大小,而是对方这条数据报实际有多长
  3. 失败时返回 -1,错误原因查 errno,用法和 read 的返回值约定一致

服务器 7×24 在线就是循环里反复调 recvfrom,返回值告诉你这次实际收到了多少字节。


三、recvfrom 的六个参数逐个拆解

socket创建到绑定

cpp 复制代码
 		int _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            exit(1);
        }
        
        // 2 绑定
        // 2.1 先初始化数据
        struct sockaddr_in local;
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);
        local.sin_addr.s_addr = INADDR_ANY;
        // 2.2 绑定
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            exit(2);
        }

3.1 参数一 sockfd:收哪个套接字上的数据

1. 第一个参数是创建好的 sockfd
  1. 第一个参数填创建好的套接字描述符,就是 socket 接口返回的那个 fd
  2. 它的意思是「从这个套接字上收数据」,发到这个套接字所绑地址上的数据报,就是它要收的对象
  3. 服务器循环里这个 fd 从头用到尾,每次 recvfrom 传的都是同一个

sockfd 一锤定音:收的是哪个套接字上的数据,服务器循环里它从头用到尾。

3.2 参数二 buf 和参数三 len:收到的数据放哪、最多收多少

1. 第二个参数 buf 是接收缓冲区,收到的数据被拷贝到这里
  1. 第二个参数 buf 是一块你自己准备的缓冲区,一般是个字符数组
  2. 内核收到数据后,会把数据从内核的接收缓冲区拷贝到这块 buf 里,recvfrom 返回后你从 buf 里读到的就是对方发来的内容
  3. 所以调 recvfrom 之前必须先把 buf 开好,它是个「装数据的容器」,不是函数替你分配的
2. 第三个参数 len 是缓冲区的大小,告诉内核最多往里写多少
  1. 第三个参数 len 填 buf 的大小,比如 buf 开了 1024 字节,len 就传 1024
  2. 它是给内核划的上限:这次最多往 buf 里拷这么多字节,防止数据太长把缓冲区写溢出
  3. buf 和 len 配成一对使用,一个给地方、一个给大小,这是 C 里「传缓冲区」的标准搭配

buf 给地方、len 给大小,一对参数决定收到的数据落在哪、最多装多少。

3.3 参数四 flags:标志位,默认填 0

1. 第四个参数是标志位,控制这一次接收的特殊行为
  1. 第四个参数 flags 是标志位,用来控制这次接收要不要做一些特殊动作,比如收下来但不从缓冲区里移走(MSG_PEEK)
  2. 一般场景不需要任何特殊行为,直接填 0,按默认方式收
  3. 它是给高级玩法预留的开关,入门阶段记住「填 0 是常规阻塞接收」就够用

flags 填 0 就是常规阻塞接收,特殊行为留给需要的时候再开。

3.4 参数五 src_addr:源地址,输出型参数,把「是谁发的」带出来

1. 第五个参数类型是 sockaddr*,传 sockaddr_in 要强转
  1. 第五个参数 src_addr 的类型是 struct sockaddr*,就是篇4讲的那个通用结构
  2. 实际使用时你定义的是 sockaddr_in,传参时把它的指针强转成 sockaddr* 传进来,套路和 bind 一模一样
  3. 为什么长这样篇4已经讲透,本篇只关注它在这儿的角色:它是个「地址盒子」
2. 它是输出型参数,内核把发送方的地址填进去
  1. 这个参数的方向是「往外带」:调用前它就是块空地,调用返回后内核把「这条数据是谁发的」填在里面
  2. 填进来的就是发送方的 IP 加端口号,你从里面能读出对方是从哪台机器哪个端口发来的
  3. 如果确实不关心对方是谁,可以传 NULL,内核就不填了;但服务器要回话就必须拿它,原因下一章展开

src_addr 把「是谁发的」带出来,不关心可以传 NULL,服务器要回话就必须拿它。

3.5 参数六 addrlen:输入输出型参数,传入大小、返回实际赋值

1. 第六个参数 addrlen 调用前要传入结构体大小
  1. 第六个参数是 addrlen,注意它是指针,传的是「一个装着长度的变量」的地址
  2. 调用前你要先把这个变量设成 src_addr 结构体的大小再传进来,这是它的输入一面
  3. 这是在告诉内核:我给的地址盒子有这么大,你填的时候别越界
2. 调用后它被改写成内核实际填进去的字节数
  1. 调用返回后,这个变量的值被内核改写了,变成实际填进 src_addr 的地址信息长度,这是它的输出一面
  2. 一个参数先当输入再当输出,身兼两职,这就是「输入输出型参数」的含义
  3. 忘了先初始化大小就传指针,是 recvfrom 最经典的用法错误,内核拿到一个随机长度,行为不可预期

addrlen 先传结构体大小、再带出实际长度,忘了初始化它是最经典的用法错误。

3.6 阻塞 IO:对方不发数据,recvfrom 就一直阻塞

1. recvfrom 默认是阻塞 IO,没数据就一直等
  1. recvfrom 默认工作在阻塞 IO 模式下,套接字上没有数据可收时,函数不返回,当前执行流就卡在这一行
  2. 什么时候对方的数据报到了,什么时候它被唤醒,把数据收好、地址填好,才返回
  3. 这正好配合服务器 7×24 在线的设定:服务器循环就是「卡在 recvfrom 上等,来一条收一条」,等就是它的工作状态
2. 阻塞等的是数据,不是麻烦
  1. 阻塞不等于出错,它只是把线程挂起,让出 CPU,开销很小
  2. 对单线程的简单 UDP 服务器来说,「阻塞等」反而是最省事的模型:不用轮询、不用设闹钟,数据来了内核自然叫醒你
  3. 要同时管很多路 IO 时阻塞才成为瓶颈,那是后话,先把默认的阻塞模型吃透

recvfrom 六参数各司其职:sockfd 定从哪收、buf 加 len 定收哪放多少、flags 定怎么收、src_addr 加 addrlen 把「谁发的」完整带回来,默认阻塞等到数据才返回。


四、为什么要拿源地址:sendto 的参数和两个接口的对称关系

4.1 收数据必须知道是谁发的,正好就是第五个参数

1. 只拿到数据只完成了一半,还必须知道是谁发的
  1. 服务器收到一条数据,如果只知道内容、不知道发送者,这条数据就没法处理:想回话不知道回给谁,想记账不知道记谁头上
  2. 所以「收到什么」和「谁发的」必须一起拿到,缺一不可
  3. 客户端发来的 UDP 数据报头部本来就带着自己的 IP 和端口号,这部分信息随报文一起到,不劳应用层额外传
2. recvfrom 的第五个参数把发送方地址原样交出来
  1. 内核解析数据报时,把报头里的源 IP、源端口抄下来,填进你传的第五个参数 src_addr 指的结构里
  2. 这个结构里就是完整的客户端地址:IP 加端口号,用它唯一指代「刚才说话的那位」
  3. 服务器拿着这个地址,才知道往哪回话------这正是 src_addr 设计成输出型参数的意义

数据和源地址必须一起拿到,src_addr 把「刚才说话的那位」完整带出来。

4.2 sendto 的后两个参数决定发给谁,目的地址是输入型参数

1. sendto 的签名骨架和 recvfrom 一模一样
c 复制代码
#include <sys/socket.h>

// 发送数据报:sockfd 是套接字描述符,buf 是待发送数据,len 是数据长度,flags 是标志位(默认 0),
// dest_addr 是目的地址(发给谁,要强转成 sockaddr*),addrlen 是目的地址结构体的大小
// 返回值:成功返回实际发送的字节数,失败返回 -1
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);
2. 第 5 个参数是目的地址,后两个参数一起决定这条数据发给谁
  1. sendto 的第 5 个参数 dest_addr 填的是接收方的地址:IP 加端口号,写清楚这条数据要送到谁手里
  2. 后两个参数(dest_addr、addrlen)合起来决定这条数据报发给谁,前四个参数决定发什么、怎么发
  3. 它和 recvfrom 的第五参数方向相反:recvfrom 的 src_addr 是把地址往外带,sendto 的 dest_addr 是把地址往里填
3. 目的地址是输入型参数,填好后拷贝交给系统
  1. dest_addr 是纯输入型参数:你先在应用层把 sockaddr_in 填好(地址族、对方 IP、对方端口),强转后传进来
  2. 内核拿走这份地址去封装数据报,把目的地址写进报头,然后照着发出去
  3. 每次发送都要给一份完整的目的地址,不存在「记住上次发给谁」这回事,这个特性下一章会再用到

后两个参数决定发给谁,目的地址在应用层填好后拷贝交给内核,每次发送都要给一份新的。

4.3 recvfrom 与 sendto 对称:源地址往外带,目的地址往里填

1. 两个接口的前四个参数完全对应

sockfd、buf、len、flags 四个参数两边含义一致:从哪收/从哪发、数据放哪/数据在哪、多大/多长、要不要特殊行为,所以记住一边另一边白送,学这两个接口的成本其实是一个半。

2. 第 5、6 个参数方向相反,正好构成一来一回
  1. recvfrom 的 src_addr 是输出型------把「谁发来的」带出来;sendto 的 dest_addr 是输入型------把「发给谁」递进去
  2. 一收一发刚好接力:recvfrom 拿到的源地址,原样填进 sendto 的 dest_addr,就能把数据原路送回去
  3. 服务器回话的全部秘密就这一句:从哪收来的,就往哪发回去

收的时候带出源地址,发的时候带进目的地址,recvfrom 和 sendto 靠同一个地址结构完成往返接力。


五、UDP 不面向连接,但套接字既能读也能写

5.1 同一个 UDP 套接字既能读也能写,天然适合双工

1. UDP 的套接字既能 recvfrom 也能 sendto
  1. TCP 里通信要用两个方向的读写接口配合,而 UDP 的同一个套接字既能 recvfrom 读、也能 sendto 写
  2. 一个描述符收发双向都通,不需要为「回话」再开一个套接字
  3. 所以 UDP 通信其实是很适合双工的:你来我往,用的始终是同一个 fd

同一个套接字既能读也能写,UDP 不另开接口就实现了双工。

2. 一个套接字身兼两职的底气来自地址参数
  1. 套接字本身不需要记住「对面是谁」,因为每次收发地址都是随参数现给的
  2. recvfrom 现场把对方的地址带出来,sendto 现场把对方的地址填进去,套接字只管搬运
  3. 套接字无状态,地址随报文走,这是一切的前提

5.2 服务器拿到源地址直接回发,回声服务器骨架就三行核心

1. 回发的动作就是「把收到的地址填进 sendto」
  1. client 给 server 发消息,server 从 recvfrom 的第五参数里拿到 client 的地址
  2. 要回话,就把这份地址强转后填进 sendto 的第 5 个参数,把要回的内容发出去
  3. 整个过程没有「建立连接」这个环节,数据报自带往返地址
2. UDP 回声服务器骨架:收数据、拿地址、原路回发(框架名:udp_echo_server)
c 复制代码
// UDP 回声服务器骨架:收到什么就原样发回给谁
while (1) {
    struct sockaddr_in client;                          // 地址盒子:用来装"是谁发的"
    socklen_t len = sizeof(client);                     // 先初始化为结构体大小(输入),返回后是实际长度(输出)
    ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
                         (struct sockaddr*)&client, &len); // 阻塞等数据,顺便带出客户端地址
    sendto(sockfd, buf, n, 0,
           (struct sockaddr*)&client, len);             // 原路回发:地址从 recvfrom 拿,直接用
}
  1. 循环体第一行开地址盒子并初始化长度,第二行阻塞收数据同时带出客户端地址,第三行拿这份地址原样回发
  2. 三行核心就是 UDP 服务器的全部骨架,后面加多少业务逻辑,这三行的位置都不动
  3. 注意 sendto 的第三个参数传的是 n 而不是 sizeof(buf):回发只要实际收到的那 n 个字节

回发的全部动作就是「把 recvfrom 带出的地址填进 sendto」,三行骨架撑起一个 UDP 服务器。

5.3 这就是 UDP 不面向连接在接口层的体现

1. 不握手,地址随报文走
  1. TCP 的服务器要先 accept 出连接才能通,客户端要先 connect 才能发,一来一回的地址关系在连接建立时就锁死了
  2. UDP 完全没有这一步:客户端直接 sendto,报文里自带源地址和目的地址,服务器照着源地址就能回
  3. 「不面向连接」落到接口层,就是「地址不在连接里,而在每次调用的参数里」
2. 无连接不影响有来有回
  1. client 给 server 发消息、server 直接给它回,一来一回完全成立,靠的不是连接而是报文自带的地址
  2. 双向通信的前提只有一个:双方各自的套接字上都能收到发给自己地址的数据报
  3. 所以别把「无连接」理解成「只能单向」,UDP 是不建连接的双工

UDP 不建连接照样有来有回,靠的是报文自带地址而不是提前握好的连接,同一个套接字收发两用。


六、文末面试题

6.1 推导题

1. recvfrom 为什么要设计一个输出型的源地址参数 src_addr,不拿它行不行?

答(推导):不行。服务器收到一条数据,如果只知道内容、不知道发送者,这条数据就没法处理------想回话不知道回给谁。而 UDP 是无连接的,不存在一条提前建立好的连接把「对面是谁」记住,发送方的地址信息只存在于这条数据报自己的报头里。所以 recvfrom 必须在收数据的同时,把报头里的源 IP、源端口抄出来交给应用层,这就是 src_addr 设计成输出型参数的原因。如果不拿它(传 NULL),数据照样能收,但服务器从此成了哑巴:听得见、回不了话。

2. addrlen 为什么设计成输入输出型参数,只做输入或只做输出会怎样?

答(推导):addrlen 的输入一面是告诉内核「我给的地址盒子有多大」,防止内核填地址时越界写坏内存;输出一面是告诉应用层「内核实际填了多少」,因为不同地址族的地址长度不一样,内核填进去的实际字节数不一定等于结构体的名义大小。只做输入,应用层就无法知道结构里有效数据有多长,跨地址族使用时会把垃圾字段当地址读;只做输出,内核在填写前就不知道盒子的边界,存在越界风险。一个参数先传大小、再带出实际长度,两头的安全和正确都照顾到了,这是 C 接口里「值-结果参数」的经典用法。

3. UDP 没有连接的概念,服务器为什么还能把数据回给客户端?

答(推导):因为地址随报文走。客户端调用 sendto 时,报头里自动带上自己的源 IP 和源端口,服务器收到后用 recvfrom 的第五个参数把这份源地址原样取出来,回话时把它填进 sendto 的目的地址参数,数据就原路送回去了。整个过程没有任何「建立连接」的步骤,往返的依据都在报文自身里。这也解释了为什么 UDP 的同一个套接字既能读又能写:套接字不需要记住对端,每次收发的地址都是现场给的,无连接但照样双工。

6.2 真题

1. UDP 服务器是怎么知道客户端地址并向它回复数据的?

答(推导 · 已对照解析,转述):UDP 服务器调用 recvfrom 接收数据时,除了拿到数据本身,还通过输出型参数 src_addr 拿到了发送方的 IP 和端口。数据报头部本身就携带发送方的地址信息,内核解析报文后把它填进这个输出参数。服务器要回复时,把这份地址填进 sendto 的目的地址参数,数据就能发回给客户端。客户端虽然没有显式绑定端口,但首次 sendto 时系统已经自动分配了端口,报文里的源地址是完整有效的,所以服务器总能找得到它。

【真题·转述自 Socket编程UDP】

2. recvfrom 在什么情况下会阻塞?UDP 的 sendto 也会因为缓冲区满而阻塞吗?

答(推导 · 已对照解析,转述):recvfrom 默认工作在阻塞 IO 模式,套接字接收缓冲区里没有数据时,调用线程会一直阻塞休眠,直到有数据报到达才被内核唤醒并返回。而 sendto 在 UDP 场景下不会因为发送缓冲区满而阻塞:UDP 没有真正意义上的发送缓冲区,它不等待确认、不缓存待重发的数据,sendto 只是把应用层的数据拷贝到内核协议栈,加上 UDP 头和 IP 头就发出去,所以不存在缓冲区满导致的阻塞。但要注意 sendto 返回成功只代表数据成功交给了内核,不代表对方已经收到。

【真题·转述自 Linux四种I/O模型】


这一篇把 UDP 收发的两个接口拆到了参数一级:recvfrom 六个参数各管一段,sendto 和它镜像对称,服务器靠「收时带出源地址、发时填入目的地址」这个接力完成回话,UDP 不建连接但照样双工。评论区聊聊:你写第一个 UDP 程序的时候,有没有忘了初始化 addrlen 然后拿到一串乱码地址的经历?

相关推荐
Escalating_xu2 小时前
【Linux 网络编程】UDP Socket 编程(下):回调封装、网络字典与多线程聊天室
linux·网络·udp
.Hypocritical.5 小时前
计算机网络】吃透 OSI 七层模型与 TCP/IP 协议栈
网络协议·tcp/ip·计算机网络
北京盛世宏博18 小时前
通信异常深度排查:以太网温湿度设备 TCP 粘包、UDP 丢包根治方案
单片机·网络协议·tcp/ip·udp·以太网·传感器·温湿度
隐擎fox1 天前
深入 QUIC 与 HTTP/3 协议栈特征:新一代 UDP 流量风控识别与网络防关联工程实战
网络·http·udp
水水不水啊1 天前
告别切换N个窗口调试:我用 Python + WebView 做了款现代化串口助手(含 TCP/UDP、波形、协议解析)
python·tcp/ip·udp
一条泥憨鱼2 天前
【从0开始学习计算机网络】| ARP 协议:地址解析,ARP 欺骗,链路层寻址
网络·后端·学习·计算机网络
kaixin_learn_qt_ing2 天前
udp数据流转---分析_Part2
udp
福大大架构师每日一题2 天前
mediamtx v1.21.2发布:UDP、JWT、RTSP、RTMP、HLS、WebRTC全面修复,稳定性与安全性再提升
网络协议·udp·webrtc
北京盛世宏博2 天前
多协议以太网温湿度变送器:Modbus TCP/UDP/SNMP 协议应用场景详解
单片机·tcp/ip·udp·传感器·温湿度