详解UDP编程(Python版)

Last Updated: 2023-08-08 03:32:53 Tuesday

-- TOC --

UDP是无连接socket,但为什么UDP的socket对象,还可以调用bind和connect函数呢?UDP的报文视角是啥意思?本文解答。

UDP Datagram

这是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服务器和客户端

其实,严格来说,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调用connect的作用

UDP socket对象的bind函数的作用是固定自己的地址,而connect函数是固定通信对方的地址,系统此时会检查对方地址是否可达。

提高UDP通信的效率

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可以多次调用,每次调用,就相当于切换一个通信对象!

限制与UDP服务器通信的Client地址

服务器#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报文!以上代码测试略。

bind函数的作用

前面代码已经充分说明,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报文。

深入理解bind和connect

发送UDP广播

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

通过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权限。

如果sendto返回的字节数小于UDP报文长度

man page清晰的说明,sendto和send一样,返回发送的字节数,有可能小于报文长度。对于TCP来说,这是常态,很好理解。但对于UDP来说,有些费解!

我现在只能说,用sendto发送UDP,出现返回字节数(非-1)小于报文长度的情况,是非常罕见的!能不能说完全不存在呢?不知道。罕见的原因:

AI是这样回答的:

问:当使用sendto发送UDP报文时,在什么情况下会出现返回值小于报文长度?

答:

在使用 sendto() 函数发送UDP报文时,正常情况下,如果数据成功发送,返回值应该等于发送的消息长度。然而,如果返回值小于报文长度,表示并未完全发送所有的数据,这通常表示出现了错误。以下是可能的原因:

  1. 缓冲区不足:如果内核的发送缓冲区空间不足以容纳待发送的数据,那么 sendto() 可能只发送了部分数据。然而,这在UDP中是不太可能的,因为UDP通常会在缓冲区空间不足时返回错误,而不是发送部分数据。(它也说不太可能
  2. 信号中断:如果在 sendto() 发送数据时进程接收到了信号并因此被中断,那么可能只有部分数据被发送。在这种情况下,sendto() 会返回实际已经发送的字节数。
  3. 网络问题:如果在发送数据时出现网络问题,如网络拥塞等,那么也可能导致 sendto() 只发送了部分数据。
  4. 硬件问题:如果网络硬件(如网卡)出现故障,那么也可能导致 sendto() 只发送了部分数据。
  5. 系统调用被限制:在某些系统中,sendto() 的数据长度可能被限制。如果试图发送的数据长度超过了这个限制,那么 sendto() 可能只发送了部分数据。

如果你遇到了这种情况,你可以查看 errno 来获取更多关于错误的信息。注意,sendto() 在成功时返回发送的字节数,如果发送失败则返回 -1,并将错误代码设置在 errno 中。

感觉AI把一些发送TCP的情况,混在了答案里面!

为了让代码更robust,有一个技巧来应对这种潜在的问题:将UDP报文的前2个字节用来表达整个UDP报文的长度,在接收端做长度判断,只有长度与这两个字节能够对应上,才算一个合法UDP报文!如果发送真的出现返回值小于报文长度的情况,这个残缺的UDP报文,接收端可以丢掉,在发送端,也需要做相应的完整重发。

本文链接:https://cs.pynote.net/net/tcp/202109225/

-- EOF --

-- MORE --