Last Updated: 2023-08-08 03:32:53 Tuesday
-- TOC --
UDP是无连接socket,但为什么UDP的socket对象,还可以调用bind和connect函数呢?UDP的报文视角是啥意思?本文解答。
这是UDP报文头:
from RFC768:
0 7 8 15 16 23 24 31
+--------+--------+--------+--------+
| Source | Destination |
| Port | Port |
+--------+--------+--------+--------+
| | |
| Length | Checksum |
+--------+--------+--------+--------+
|
| data octets ...
+---------------- ...
User Datagram Header Format
UDP很单纯,就是发送报文,所有TCP具有的复杂的连接状态和流量控制机制,它都没有。
UDP是报文视角(datagram),其收发的都是一个个独立的报文,不会出现一个UDP报文要接收多次才能收完整的情况。因此,从上图来看,一个UDP报文理论最大也就65535字节,因此发送和接收时,都要注意大小。发送时太大,会有异常抛出。接收时太小,此报文后面部分就会直接丢失,这就是本文代码接收缓存区全部使用65535的原因。特别注意与TCP的字节流视角(stream)区分!
16bit的长度表示整个UDP报文的长度,包含UDP头,因此最小值是8!
其实,严格来说,UDP服务器和客户端的边界是有些模糊的,因为它们的代码都差不多。一定要区分服务器和客户端,就要从业务层面来理解。
下面是用Python实现的UDP服务器端和客户端的实现代码,我们从这段代码开始分析说明:
import socket
address = ('0.0.0.0', 12345) # listen on all ip address
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(address) # must bind to recv
while True:
data, addr = s.recvfrom(65535)
print("(server#1)received:", data.decode(), "from", addr)
s.sendto(data, addr)
if data == b'goodbye':
break
s.close()
服务器端调用bind函数,目的就是固定自己的地址。使用recvfrom和sendto这两个收发函数,是因为它不知道将与它通信的对象地址是啥,要通过recvfrom函数来获取对端地址,然后在sendto中使用这个地址。这样的代码,可以实现1对多的通信。
import socket
address = ('192.168.2.107', 12345) # server addr
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
while True:
msg = input('input msg:')
if msg == 'suicide':
break
s.sendto(msg.encode(), address)
data, addr = s.recv(65535)
print("(client#1)received:", data.decode())
if data == b'goodbye':
break
s.close()
客户端代码就可以不调bind。在发送UDP报文时,系统会给这个UDP socket自动分配地址。而接收报文时,使用了recv,是因为这段客户端代码事先知道与它交互的服务器地址,因此不需要在多此一举。
最后调用close,可以释放系统自动分配的地址。
下面是执行效果:
pi@rp02:~/test/udp $ python3 udp_server.py &
[1] 26717
pi@rp02:~/test/udp $ python3 udp_client.py
input msg:hello
(server#1)received: hello from ('192.168.2.107', 58542)
(client#1)received: hello
input msg:udp test
(server#1)received: udp test from ('192.168.2.107', 58542)
(client#1)received: udp test
input msg:goodbye
(server#1)received: goodbye from ('192.168.2.107', 58542)
(client#1)received: goodbye
[1]+ Done python3 udp_server.py
以上代码,服务器调用了bind确定自己的地址,服务器和客户端都没有调用connect,UDP不需要像TCP那样进行三次握手和四次挥手,因此这样就可以通信了。没有问题!sendto,recvfrom,recv和后面要提到的send,默认都是阻塞函数。
UDP socket对象的bind函数的作用是固定自己的地址,而connect函数是固定通信对方的地址,系统此时会检查对方地址是否可达。
UDP socket对象调用connect后,就可以使用send函数发送报文,它比sendto效率会要一些,因为connect函数实际上(至少)要做地址是否可达的检查(检查自己的路由表),而sendto函数在每次发送报文的时候,可能都要做这个检查。
import socket
address = ('192.168.2.107', 12345)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(address)
while True:
msg = input('input msg:')
if msg == 'suicide':
break
s.send(msg.encode())
data = s.recv(65535)
print("(client#2)received:", data.decode())
if data == b'goodbye':
break
s.close()
用connect指定服务器的地址,后面的代码就可以直接用send和recv。现在我们让客户端#1和#2都与服务器#1通信,下面是执行效果:
pi@rp02:~/test/udp $ python3 udp_server.py &
[1] 26793
pi@rp02:~/test/udp $ python3 udp_client2.py
input msg:hello
(server#1)received: hello from ('192.168.2.107', 37893)
(client#2)received: hello
input msg:i am udp2
(server#1)received: i am udp2 from ('192.168.2.107', 37893)
(client#2)received: i am udp2
input msg:suicide
pi@rp02:~/test/udp $ python3 udp_client.py
input msg:hello
(server#1)received: hello from ('192.168.2.107', 51416)
(client#1)received: hello
input msg:i am udp1
(server#1)received: i am udp1 from ('192.168.2.107', 51416)
(client#1)received: i am udp1
input msg:goodbye
(server#1)received: goodbye from ('192.168.2.107', 51416)
(client#1)received: goodbye
[1]+ Done python3 udp_server.py
客户端#1和#2功能一样,用这个测试代码,测不出性能差异,只是展示不同的函数调用。注意:connect可以多次调用,每次调用,就相当于切换一个通信对象!
服务器#1的实现,任何知道此服务器地址的UDP客户端都可以与之通信,但有的场景,我们希望服务器只接收某个特定地址的UDP消息。要实现这样的功能,就需要我们在服务器侧使用connect函数。
import socket
address = ('0.0.0.0', 12345)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(address)
s.connect(('192.168.2.107', 10001))
while True:
data = s.recv(65535)
print("(server#2)received:", data.decode())
s.send(data)
if data == b'goodbye':
break
s.close()
服务器用bind固定自己的地址,用connect固定对端通信的地址,这样此服务器就只能与connect固定的地址通信。
可是,UDP客户端的地址如何确定呢?前面的代码,都没有确定UDP客户端的地址,其地址都是系统自动分配的。解决方法是:UDP客户端使用bind固定自己的地址。这就是为什么本文开始就提到的,UDP服务器和客户端,其实没有那么严格的划分。
import socket
address = ('192.168.2.107', 10001)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(address)
server_addr = ('192.168.2.107', 12345)
while True:
msg = input('input msg:')
if msg == 'suicide':
break
s.sendto(msg.encode(), server_addr)
data = s.recv(65535)
print("(client#3)received:", data.decode())
if data == b'goodbye':
break
s.close()
此客户端bind自己的地址后,与服务器通信。此时,服务器只能接收固定地址的UDP报文!以上代码测试略。
前面代码已经充分说明,UDP socket对象的bind函数,就是固定自己的地址。bind的地址,必须是主机可以使用的地址!在使用bind函数的时候,也可以不指定IP地址,此时的效果与指定0.0.0.0一样,即绑定在本机的所有IP地址上。
>>> s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
>>> s.bind(('',12345))
>>> s.getsockname()
('0.0.0.0', 12345)
并且,bind使用的端口号,必须要大于1023,即前面1024个端口号是系统使用的,用户不能使用。
我现在理解了,socket的bind和connenct就是确定通信双方的地址。对于raw socket,也有类似的效果,比如icmp的raw socket,如果有connect,linux内核会将connect对应地址的packet copy给那个socket,没有connect的raw socket,没办法,会收到kernel copy的所有icmp报文。
UDP这种报文的性质,很适合用来做服务器的心跳,DNS服务器也是基于UDP的。它不建立连接,不重传,错了没有太大关键。在局域网中,UDP很适合用来发现服务器,比如广播UDP报文,服务器收到后回应自己的TCP连接信息。
Linux下发送UDP广播
# Send UDP broadcast packets
MYPORT = 50000
import sys, time
from socket import *
s = socket(AF_INET, SOCK_DGRAM)
s.bind(('', 0))
s.setsockopt(SOL_SOCKET, SO_BROADCAST, 1)
while 1:
data = repr(time.time()) + '\n'
s.sendto(data, ('', MYPORT))
time.sleep(2)
Windows系统下发送UDP广播
我在Win10系统上测试用python发送UDP广播报文,只有下面这种方法是成功的。假如你的局域网的ip地址段为192.168.1.0/24,请使用192.168.1.255这个局域网广播地址发送UDP报文,不能使用255.255.255.255,也不能使用python给的broadcast选项。
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto((msg).encode(), ('192.168.1.255', 2020))
rdata, raddr = s.recvfrom(256)
print(rdata.decode().split(), raddr)
当然,也可以使用单播轮训的方式实现发送UDP广播的效果!
通过raw UDP的方式,抓取udp报文。
如下示例代码,但有个问题没有解决,就算调用bind接口,也不能让linux kernel只copy对应端口的udp报文到用户态,还是所有的udp报文都能抓到,要自己在代码中过滤。
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_UDP)
while True:
data, addr = s.recvfrom(65535)
# data is an IP packet contains UDP
...
运行raw socket代码,需要sudo权限。
man page清晰的说明,sendto和send一样,返回发送的字节数,有可能小于报文长度。对于TCP来说,这是常态,很好理解。但对于UDP来说,有些费解!
我现在只能说,用sendto发送UDP,出现返回字节数(非-1)小于报文长度的情况,是非常罕见的!能不能说完全不存在呢?不知道。罕见的原因:
AI是这样回答的:
问:当使用sendto发送UDP报文时,在什么情况下会出现返回值小于报文长度?
答:
在使用
sendto()
函数发送UDP报文时,正常情况下,如果数据成功发送,返回值应该等于发送的消息长度。然而,如果返回值小于报文长度,表示并未完全发送所有的数据,这通常表示出现了错误。以下是可能的原因:
- 缓冲区不足:如果内核的发送缓冲区空间不足以容纳待发送的数据,那么
sendto()
可能只发送了部分数据。然而,这在UDP中是不太可能的,因为UDP通常会在缓冲区空间不足时返回错误,而不是发送部分数据。(它也说不太可能)- 信号中断:如果在
sendto()
发送数据时进程接收到了信号并因此被中断,那么可能只有部分数据被发送。在这种情况下,sendto()
会返回实际已经发送的字节数。- 网络问题:如果在发送数据时出现网络问题,如网络拥塞等,那么也可能导致
sendto()
只发送了部分数据。- 硬件问题:如果网络硬件(如网卡)出现故障,那么也可能导致
sendto()
只发送了部分数据。- 系统调用被限制:在某些系统中,
sendto()
的数据长度可能被限制。如果试图发送的数据长度超过了这个限制,那么sendto()
可能只发送了部分数据。如果你遇到了这种情况,你可以查看
errno
来获取更多关于错误的信息。注意,sendto()
在成功时返回发送的字节数,如果发送失败则返回-1
,并将错误代码设置在errno
中。
感觉AI把一些发送TCP的情况,混在了答案里面!
为了让代码更robust,有一个技巧来应对这种潜在的问题:将UDP报文的前2个字节用来表达整个UDP报文的长度,在接收端做长度判断,只有长度与这两个字节能够对应上,才算一个合法UDP报文!如果发送真的出现返回值小于报文长度的情况,这个残缺的UDP报文,接收端可以丢掉,在发送端,也需要做相应的完整重发。
本文链接:https://cs.pynote.net/net/tcp/202109225/
-- EOF --
-- MORE --