MENU

【代码札记】使用UDP 组播实现骑行时多人聊天

August 31, 2023 • 瞎折腾

最近和朋友晚上出去骑车遛弯,我手机流量套餐马上要到期了,寻找一个替代方案必不可少。经过一番思索,我实现了一个基于UDP组播通讯的安卓APP。其具有不需要中心服务器、使用加密防止窃听,和节点自动发现的特性。

需求

一开始我一个人骑车,如果不需要导航,我就听书,如果需要导航,我就听歌。后来无意间一个朋友也加入了我的晚间遛弯行列。以前一个人骑车都是走到哪转到哪,典型一胡同串子。但两个人就不一样了,你一时兴起拐了个弯,朋友没反应过来,这不合适。所以这就催生出了骑行时语音通话的需求。

最理想的解决方案自然是无线电台。但问题在于我朋友没有操作证,也没有执照,所以不太可行。而且无线电台的话,虽然手台的发射距离没有问题,但发射方式还有待考察。我的FT70DR只能通过PTT按钮发射,宝峰的一些机型支持语音发射(在说话时自动发射),但那样的话还要去更新执照,用时太长了。不过手台如果支持APRS的话,对于组队找人是个不小的帮助,只可惜我的手台不支持。

更何况我前些日子刚刚斥巨资买了韶音那个骨传导。我之前用的是他家的AS660,虽然那个耳机形状好看,但戴起来时间长了耳朵痛。为了改善骑行体验,我买了他家的S8103。我新买的蓝牙耳机,那肯定得用上。所以最终的解决方案还是落到了手机头上。

设计

因为我用的是安卓手机,所以我就没考虑iOS的事儿。根据安卓系统提供的特性,我初步考虑了两种解决方案:一种是蓝牙的,一种是基于WIFI的。

蓝牙部分的解决方案主要依赖BLE,因为传统蓝牙比较耗电。蓝牙提供了一个叫做Mesh的功能,它类似于其他自组网协议,能够允许多个设备加入网络,并且数据能够在无法直达的情况下进行中继传输。但这种协议一次只能发送15个字节,不太适合传输音频数据。15字节是一个什么数据量呢?如果使用16bit位深,16KHz采样率,单通道音频,那么15字节最多能够传输7个样本,也就是大约0.44ms长度的数据。BLE Mesh的吞吐率大约是不到1.5kbps,也就是说发送7个样本需要2.6ms——完全跟不上我们产生数据的速率。更何况安卓根本就没有提供原生的蓝牙Mesh相关的API。

于是重任自然落到了WIFI头上。我首先想到的就是WIFI Direct。这是一种点对点基于WIFI相连的技术,但不同厂商基于这些功能进行了各自的二次开发(比如小米快传),兼容性问题有待考证。此外,WIFI Direct有group功能,大家能够连接到同一个Group中,相当于成为了一个局域网。但是每个设备的能力各不相同,安卓文档中只保证了最多4个节点,超出的算是厂商送的,做不到也不能怪罪谁。要说为什么想用WIFI Direct,就是它免去了分发密钥的麻烦。

这样一来,我们就只能退回到最基本的通讯方式了:WIFI——都2023年了谁还没个热点啊?当然,事后证明,安卓的WIFI是真的弱,开了弱点之后,宿主机完全收不到热点中的UDP消息。推测是整个系统使用热点时,网络栈是工作在被共享的网络上的(例如其他WIFI或移动网络),热点只是简单的进行了一个NAT转发和DCHP服务。但这个也好办——不用热点不就完了。

协议

选好了通讯方式,接下来就看协议怎么设计了。由于没有中心服务器,所以我们得设计一个P2P协议。用WIFI嘛,那就跟用普通互联网差不多,我们有两种协议可选:TCP和UDP(不会真的有人用ICMP和IGMP来传输数据吧?不会吧不会吧不会吧)。但考虑到我们通信的节点都是在局域网内,并且这种数据具有广播的性质,很明显,我们要用UDP(这是送分题)

UDP多播

在UDP协议中具有多播的设计。具体而言就是划定出一片IP地址作为组播地址。其中的每一个IP都可以作为一个组。只有订阅了这个组的设备才会收到相应的UDP多播报文。同时使用多播的好处在于客户端不必考虑如何分发语音数据。如果使用TCP协议的话,10个人的群聊,我们就要把一份数据发送给9个人,耗费9倍带宽;而UDP多播则只需要将一份数据发送出去,交换机会向其他9个人发送对应的数据,对于客户端来说只需要消耗一份带宽。

确定使用组播之后,我们要考虑如何使用这个地址。最开始我想的是每个人占用一个端口,这样别人就可以去按需订阅它的音频流。但后来想了想,这样设计在编码时太麻烦了——APP运行的时候需要监听一大堆端口,还要拉一大堆线程去接收这些数据(用协程会产生卡顿),那电池使用率,国内厂商定制的系统不得杀疯了?

最终敲定:一个组播地址和一个端口号共同构成一个频道,这样一来一共就可以有大约20亿个频道供用户挑选了。

这里我根据RFC 2365选定了使用的组播范围是239.255.0.0/16,“The IPv4 Local Scope”。根据RFC的定义,这一块IP被用于Local scope广播。什么叫Local scope呢?我的理解是不发到公网上的都叫Local scope。需要注意的是,RFC 2365并没有说路由不可以转发。和“organization-local scope”类似,只要你定义了一个边界,那么这个边界之内你可以随便转发。如果你不想要数据包被路由器转发,你可能需要“link-local scope”,也就是224.0.0.0/24,这一段广播的数据包只会存活在他们被发送的物理链路上。

关于端口,因为安卓底层也是*unix系统,所以没有root权限时1024以下的端口是不能用的。考虑到较高端口号是用于随机分配给出站连接的,所以这里就取低3万个端口号作为可用端口。当然,用户也可以选择更高的端口号,只不过用作估计时我倾向于保守一些。

这样一来,我们的APP只需要在一个端口上监听和发送数据了。但如果数据没有加密,那么任何人只要猜对了频道和端口号、接入了同一个网络,那么它就可以监听到我们说的一切。虽然这个APP设计出来是给骑车用的,隐私并不是第一位,但一想到自己说的话会被别人监听就觉得毛骨悚然,所以加密是第一位的。

加密

根据安卓官方的推荐,如果你的APP没有历史包袱,那么官方推荐使用AES-GCM加密算法。我这里选用的是AES-256-GCM,随机生成12字节(96bit)IV(有时候也成为nonce),密码是256bit,来源是使用官方推荐的SHA-256对用户输入的文字密码进行计算得到的。为了兼容多语言,用户输入的文字密码会先被编码成UTF-8,然后再进行哈希计算。算出来的结果就是密码,同时GCM还能够校验数据完整性,能够避免坏包。关于加密包的格式,我想的比较简单:

[1B:Fixed 0x01][12B:IV][??B:Data]

包的首字节为前缀,用于表示包的版本和结构。因为我们这里只有一种加密算法,所以这个值固定为0x01,表示我们使用了AES-256-GCM加密算法,接下来12字节是IV,然后是加密的数据。

如果未来有所更新的话,我们可以替换首字节为其他版本,然后设计不同的包格式和加密算法。保持安全性的最简单的方法就是拒绝兼容不安全的算法,并且不要和客户端协商。协商看起来提供了很多不同的选择,但这些选择势必有安全和不安全之分,协商机制会无意间增加协议的脆弱性。

自我宣传

对于一个客户端来说,它订阅了一个组播地址,那么便会收到这个二元组上发来的所有数据,包括它自己的。对于代码来说,最简单的方法就是看源IP。这里我们也采用了一个IP对应一个客户端的设计。但IP地址对人来说并不是一个可以和现实中对应起来的名字——你和朋友出去骑车,你能叫得出他们的名字,叫得出他们的外号,可你不一定能叫得出他们手机的IP地址。所以为了解决这个问题,我为每个客户端引入了Nickname。这个Nickname的作用并不是身份认证和识别,而是起到一个帮助人们区别谁是谁的字段。之前说了,区分不同来源的数据包是看源IP,考虑到局域网相对是我们可控的,所以我们暂且排除有恶意攻击者捏造UDP包伪造来源的这种攻击手段。如果要防范这种攻击,那势必要引入公钥体系和签名机制,同时还要防范重放攻击,这无疑加重了设计的复杂度和系统运行的负担。在大多数场景下,WIFI接入点是我们自己控制的,如果发现了坏人,踢了就是了。

但每次发送音频时都带上一个Nickname,好像有点不优雅?如果用户把nickname输入的特别长,比如他叫“达拉崩吧斑得贝迪卜多比鲁翁”,或者叫“昆图库塔卡提考特苏瓦西拉松”,这一下子就占用了39字节。如果每次发送音频数据都带上这个昵称,那我们就痛失了不少宝贵的带宽,更何况我们的客户端可以缓存其他节点的昵称,没必要一直发。

我设计了一个简单的基于计时器的方案:每五秒钟发送一下我们自己的信息。这样一来我们可以随时改名,也可以照顾到新加入的节点。每个节点最多等待5秒就可以收到我们的昵称信息。

当然,还可以通过基于事件的系统来做:本地节点刚启动时,我们发布一下我们的信息。然后通过检测收到的包,如果遇到了以前没见过的源IP,我们就发送一下我们的昵称。平日不发布。这样一来我们能够减少需要不必要的带宽。但说实在的,一个昵称5秒才发送一次,就算你的名字写了三十个字,算他100字节,那平均下来带宽占用还不到200bps。没必要搞得这么麻烦。

关于自我宣传的包,结构如下:

[1B:Fixed 0x01][??B: nickname, utf-8 encoded]

这里和加密包一样,首字节用于区分版本,这里固定为0x01,表示是自我宣传的包。注意这个包不会和加密包的0x01冲突,因为这个包是要经过加密,放到加密包的data字段的。后面的nickname是不定长数据,按理来说我们需要一个字段来表示不定长字段的长度。但是这个包只有一个不定长字段,那么它的长度用packet.length就可以计算出来了。四舍五入我们又节省了2字节的带宽。

音频数据

前面铺垫了那么多,终于说到最关键的音频数据了。音频部分也不需要太多的协商,在采样率部分,普通的音频是44.1KHz,根据采样定理,人类能听到20KHz,所以加上10%的冗余,44.1KHz完全够用。但是这么高的采样率对于音频童话完全就是浪费。在电话业务中,一般使用8KHz,因为人类说话时的音频大多在4KHz以下,根据采样定理,8KHz的采样率足够了。如果考虑使用蓝牙SCO协议的话,根据我在网络上查到的资料,这个协议的采样率也是8KHz。

在实际测试中(使用外放),总觉得8KHz有一种听不太清楚的朦胧感,而16KHz就好了很多,虽然不像44.1KHz那么高保真,但确实没有8KHz那样那么失真。所以这里我在APP中使用了16KHz的采样率。如果后续发现16KHz完全没有必要的话,可以设计新的包来实现8KHz。

目前音频数据包的格式如下:

[1B:Fixed 0x02][??B: Data]

这里还是一样,包头为0x02表示这是一个音频数据包,采用16KHz采样率,位深16bit,使用小端序存储,单通道。

外设

说完了协议部分,我们来说说外设。前面说了,安卓系统在开热点的时候,自己是没办法收到热点中的UDP广播。所以只能依赖外部WIFI了,但是要在自行车上装一个无线路由器,电源是第一个问题。我使用的设备时TPLINK WR802N,这一款路由器是只支持2.4GHz网络,WIFI 4,带宽300Mbps。这是一款比较老的产品了,我印象中可能会追溯到2013~2015年。它的最新一次固件更新是2018年。总之它的表现平平,没有外置天线,性能也就那么回事。除非需求真的很特殊,不然我不推荐购买。我在京东上买的,80块钱包邮送到家。供电则直接使用USB充电宝供电,额定5V 0.6A,感觉一般的充电宝都可以带的动。此外我看到网络上有售卖5V转12V的升压USB线,专门给路由器用的,我没有试过,但理论上可行。只不过使用交流电的无线路由器在设计上并不会很省电,通常都是12V 2A,整体功率在20W左右,而5V 3A的输出可能会导致一些问题。千万要注意用电安全,你也不想骑车起到一半充电宝突然爆炸吧。

另一个解决思路是随身WIFI。这类WIFI能够提供带有互联网访问的WIFI热点,但速率不如专业的路由器。例如那种长得像U盘的4G随身WIFI,通常要150元左右,他们的带宽基本在150Mbps,再高也没有意义,因为按照他们的使用场景,是要你用它上网,而WIFI部分支持的速率超过4G能够承载的速率,用户感知不到,那就是浪费。当然了,你也可以斥巨资购买5G的,那个一般都是600Mbps上下,同时支持5GHz。

不过内网速率通常不是问题,按照我的计算,在16KHz 16bit 单通道的条件下,语音数据本身的带宽是256Kbps,加密包会额外占用1字节包头、12字节IV和16字节HMAC,假设每个包的大小为2048字节,那么需要每秒钟发送16个包,对应产生的额外带宽消耗就是3.8Kbps,加上发送昵称的包,假设我们的昵称是34字节(11个汉字),加上包头一共35字节,加密之后正好64字节,每5秒发送一次,则占用带宽102.4bps。所有这些加起来不到300Kbps,我们姑且就算他是300Kbps,那么假设我们有N个人聊天。一个人需要上传一份,WIFI热点会向其他人发送N-1份,所以一个人会占用N倍的300Kbps,而总体占用就是N^2 * 300Kbps。10个人同时使用这个APP的话,大约就是30Mbps的带宽,即便设备不支持802.11n或ac,这也是完全可以做到的。

和有线网络的交换机不一样,交换机能够做到互不干扰地转发数据。但WIFI因为需要共享频谱资源,所以实际上300Mbps的带宽是共享的。如果A用的多,那么B能用的就少。此外除了接入点支持的速率之外,还要看设备。如果接入点支持更高速率,可设备只支持802.11a/b/g,这些老协议最高只能跑到56Mbps,如果不幸的话,还可能跑到最低的1Mbps。因此接入点速度和设备速度形成了木桶效应,他们中的最小值决定了我这个APP在一个网络中实际能够使用的带宽。

在我的测试中,WIFI速率不是主要问题,反倒是WIFI的距离和有无网络接入才是更重要的问题。

首先说距离,因为我用的路由器没有外置天线,所以路由器的朝向会决定性地影响信号覆盖区域。我最终发现将路由器向上平放在后货架上能够获得相对不错的信号覆盖。但由于高度不够,如果路由器和设备之间有大型金属物体,比如汽车,那么丢包率就会显著上升,甚至还有断连的情况。我构想的一个可能的解决办法是找个非金属杆子把路由器撑起来,但考虑到在自行车上这样做,和汽车上加装一根UV端拉杆天线还不太一样,毕竟自行车的操作力有限,如果把结构改的太大,影响灵活性,那样对骑士的寿命不利。

其次是网络接入的问题。因为我这个路由器只是单纯的一个接入点,因此是没有互联网访问的。我原本以为安卓系统会相对智能的自动切换,后来发现这种未经查证的想法果然是不切实际的。在实践中发现,连上这个WIFI之后,我自己写的APP能通,但其他所有需要用网络的APP就都不行了,包括地图导航。最终我是用OsmAnd这个基于OpenStreetMap的离线地图软件导航的——先用流量在高德上查出目的地所在的楼栋名称或地址,然后再切换回WIFI用这个软件导航。

所以如果真不是我打广告,如果想要一个比较舒适的体验的话,最好是随身WIFI。但考虑到我手机上已经有流量套餐了,为了骑车再买个随身WIFI,一个月30多的额外开销对我这个灵活失业人士来说,多少有些负担不起。不过换一种角度想,骑车的时候没有网,就意味着微信那些提示音不会在骑车的时候让人分心,有助于提升骑士的寿命。

后记

本篇说了说协议设计。这部分是自上而下编写的——在所有代码都写完后,以上帝视角总结出的类似规格说明书的东西。实际上在一开始设计的时候并没有现在这么完善,其中有不少设计都是我写代码的时候临时起意决定的,因为我之前没怎么接触过安卓,更没有做过通话类的软件,所以写的时候都是从最基本的来,比如先看看能不能拿到音频,再看看直接通过udp能不能做到比较好的效果,然后再考虑蓝牙的问题,然后是加密之类的。这些内容将会在下一篇中详细介绍。

这篇文章前前后后写了三天,并不是多难写,而是《吸血鬼幸存者》太好玩了,以至于鸽掉了文章去打游戏了。今天想着都月底了,再拖一天就9月了,这不合适——对不起自己也对不起读者,所以赶忙收尾把文章发出来。不过作为一个生活方式如窜稀的人,我倒觉得这样挺好的——有时候无所事事天天看视频,有时候废寝忘食地写代码,有时候不顾一切地玩游戏,就如同窜稀一样:来了挡不住,走了要缓缓。

来了挡不住指灵感与兴趣来了我就会不顾一切地一头扎进去,时常会从早上起来写代码写到晚上睡觉。吃饭和休息的时候也会忍不住去想这方面的事情,会不断地在脑子中冒出新的想法,然后查证。就和窜稀一样,来了你就不得不去厕所,跑都没得跑。

至于走了要缓缓,反正窜稀是个挺难受的经历,一般来说好了之后都要缓上几天,无论是肠胃还是皮肤。至于我的生活,我一般在灵感和兴趣都实现了之后,就会进入到一种接近于退休的状态——没有目标,没有想做的事,只想放松自己,看看视频,看看电影什么的。

总体来说我这个生活方式还挺像窜稀的。另外我自己肠胃也不是特别好,所以平日间也没少窜。

最后出卖自己的灵魂给自己打个广告:如果你能提供工作机会,并且觉得我比较精神的话,不妨给我一个机会,目前我还在积极寻找合适的工作机会。

-全文完-


知识共享许可协议
【代码札记】使用UDP 组播实现骑行时多人聊天天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code