这篇文章完全是意料之外的。起因是正在上研究生的大学同学突然拿着一个txt后缀的C代码问我怎么sendto怎么返回参数错误。经过一阵奇妙的发展,最终变成了我用一下午的时间简单入门了一下Linux的套接字多播。本文简单记述了这个过程。
背景
这里简单介绍一下背景吧。所谓的套接字,就是Socket,它是一个用C语言开发的抽象层,它主要作为计算机进程间通讯的端点,通过抽象,它隐藏了底层通讯的细节,而提供了一个统一的交互界面。套接字根据传输层协议主要分为两类,一类是packets,底层是TCP支持的,用于两个进程间(相对)可靠的一对一通信;另一类是datagrams,底层是UDP支持的,用于不那么可靠的通讯,可以一对一,也可以一对多。
说起多播,它介于单播和广播之间。单播其实就是一对一。而广播是向网络中的全体无差别发送。多播,有时候也称为组播,它是向网络中特定一组目标发送数据。
多播的用处比较广泛,假如说你想向许多人分发数据,例如直播影像。如果采用单播,那么来一个人你就得建立一个套接字给他发送数据,人多了就得同一份数据向好多个套接字里写入。如果采用广播,那么你的数据会无差别发送给网络中的所有主机,如果别人不想要你的数据,那处理这种数据(哪怕仅仅是简单的拒绝1)就变成了一种负担。采用多播,你可以指定一个多播地址,数据只需要向套接字中写入一次,所有加入了这个地址的主机都会收到这个消息;而没有订阅的主机则不会收到消息2。
在这里我们并不以此构建什么应用,只是简单的写一个收发程序来验证这个事情。
目标
编写一个C程序,使用如下命令行启动:
# Sender
./multicast s 172.16.71.138 224.0.0.1 9000
# Receiver
./multicast r 224.0.0.1 9000
作为发送者启动时,程序将向多播套接字中写入数据;作为接收者启动时,程序将从多播套接字中读取数据。写入的数据由用户输入。
实现
有一说一,我从大一之后就没再写过C,一直写Java比较多。如果各位觉得这C代码总有一股子咖啡味儿,还请多担待。
建立项目
虽然是C语言,但是作为一个Java程序员,多少先得弄个项目。CLion默认采用CMake管理项目,那话不多说,配置文件CMakeLists.txt
的内容如下:
cmake_minimum_required(VERSION 3.22)
project(LinuxNetwork C)
set(CMAKE_C_STANDARD 11)
add_executable(multicast src/multicast/main.c src/multicast/sender.c src/multicast/receiver.c src/multicast/receiver.h src/multicast/sender.h src/multicast/common.h)
这里要求CMake的最低版本号其实没什么讲究,只因为我自己用的版本是3.22,所以就用了这个。使用的标准是C11。这里为了项目规整,又为了给日后这个同学再来问我问题留出余地,这次的代码我放到了src/multicast
文件夹中。等之后他要是还有别的问题,我就可以把之后的代码放到其他文件夹中,从而避免了频繁创建工程。
其实写代码的时候顺序是自底向上(先完成底层收发,再写命令行的部分),但是写完代码需要写文章讲解的时候,就变成自顶向下了(先说命令行,再说底层细节),所以实际写代码时候的顺序应该是按文章介绍顺序反着来。
项目结构
这里一共有这么几个文件:
main.c
:主函数,负责解析命令行参数,根据参数调用对应的过程sender.c
:作为发送者的实现sender.h
:发送者的头文件,用于在主函数中引用receiver.c
:作为接收者的实现receiver.h
:接收者的头文件,用于在主函数中引用common.h
:共用的定义,例如消息大小
头文件
三个头文件起到了类似接口的做用:先定义函数签名,以便其他人能够在代码还没有被写出来时继续各自的开发工作。
sender.h
:
#ifndef LINUXNETWORK_SENDER_H
#define LINUXNETWORK_SENDER_H
void sender_run(char *local_if, char *multicast_ip, char *port_str);
#endif //LINUXNETWORK_SENDER_H
发送者的实现主要有三个参数:本地网络接口(多播从哪个网口发)、多播IP和端口(往哪个多播发)。当用户没有指定本地网络接口的时候,可以将local_if
设定为NULL
,以便使用默认的设置。
receiver.h
:
#ifndef LINUXNETWORK_RECEIVER_H
#define LINUXNETWORK_RECEIVER_H
void receiver_run(char *ip, char *port_str);
#endif //LINUXNETWORK_RECEIVER_H
接收者的实现则相对简单一些:只需要告知接收哪个多播即可。
common.h
:
#ifndef LINUXNETWORK_COMMON_H
#define LINUXNETWORK_COMMON_H
#define MAX_MESSAGE_LEN 1024
#endif //LINUXNETWORK_COMMON_H
最简单的就是共用定义,就是一个消息大小,类似于缓冲区大小。
主函数
主函数其实挺简单的:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "receiver.h"
#include "sender.h"
int main(int argc, char *args[]) {
if (strlen(args[1]) == 1) {
if (args[1][0] == 'r') {
if (argc == 4) {
// command r mc_ip mc_port
receiver_run(args[2], args[3]);
exit(0);
} else if (argc == 5) {
// command r lo_if(ignored) mc_ip mc_port
receiver_run(args[3], args[4]);
exit(0);
}
} else if (args[1][0] == 's') {
if (argc == 4) {
// command s mc_ip mc_port
sender_run(NULL, args[2], args[3]);
exit(0);
} else if (argc == 5) {
// command s lo_if mc_ip mc_port
sender_run(args[2], args[3], args[4]);
exit(0);
}
}
}
fprintf(stderr, "Usage: %s type [local_interface] multicast_ip multicast_port\n", args[0]);
fprintf(stderr, "\ttype: 'r' for receiver, 's' for sender\n");
fprintf(stderr, "\tlocal_interface: set outgoing interface\n");
fprintf(stderr, "\tmulticast_ip: sent to this multicast ip\n");
fprintf(stderr, "\tmulticast_port: sent to this port\n");
exit(1);
}
真的挺简单的:检查第一个参数:如果是r
,就调用接收者的过程;如果是s
,就调用发送者的过程。调用成功之后退出程序。如果没有进入任何过程,那么打印帮助信息,然后退出。
发送者
创建UDP套接字
既然是多播套接字,就得首先有套接字:
int socket_def = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (socket_def == -1) {
perror("Failed to create socket");
exit(1);
}
其中socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
调用就指定了我们的套接字是IPv4(AF_INET
)、套接字类型是datagrams(SOCK_DGRAM
),使用的传输层协议是UDP(IPPROTO_UDP
,这个参数也可以写0让系统自动匹配)。如果返回了-1,则说明出现错误,使用perror
函数将errno
解析成人类可读错误消息,然后退出程序。
设定套接字多播参数
接下来对套接字进行设定:
{ // Target to local network only
unsigned char multicast_ttl = 1;
if (setsockopt(
socket_def, IPPROTO_IP, IP_MULTICAST_TTL,
(void *) &multicast_ttl, sizeof(multicast_ttl)) < 0) {
perror("Failed to set socket option");
close(socket_def);
exit(1);
}
}
{ // set outgoing interface
if (local_if != NULL) {
struct in_addr localInterface;
localInterface.s_addr = inet_addr(local_if);
if (setsockopt(socket_def, IPPROTO_IP, IP_MULTICAST_IF,
(void *) &localInterface, sizeof(localInterface)) < 0) {
perror("Failed to set outgoing interface");
exit(1);
}
}
}
这里用代码块的做用是限制参数的作用域,避免后面的程序意外用到这些参数。
第一个参数是设定多播的TTL。将TTL设定为1可以保证多播报文不会被转发出本地网络。第二个参数就是设定本地网络接口。由于多播是传输层协议的一部分,因此这些配置的level都是IPPROTO_IP
。
创建多播地址
接下来需要告诉套接字,我们要往哪多播:
struct sockaddr_in multicast_addr;
memset((char *) &multicast_addr, 0, sizeof(multicast_addr));
multicast_addr.sin_family = AF_INET;
multicast_addr.sin_addr.s_addr = inet_addr(multicast_ip);
multicast_addr.sin_port = htons(strtol(port_str, NULL, 10));
其实就是初始化了一个sockaddr_in
结构体,这个结构体是sockaddr
的IPv4变体,专门用于存放IPv4的数据。如果是IPv6的话,就要用sockaddr_in6
,另外代码的其他部分也要跟着变。由于这里只是简单的实验,v4就足够用了。
首先我们要把结构体清空,避免内存中的脏数据引发预期之外的行为。这里我们为多播地址设置了三个参数:它是IPv4地址(AF_INET
),它的多播IP是multicast_ip
,他的端口号是port_str
。
准备妥当之后,我们就可以发数据了。
发送数据
发送数据其实就是一个循环:
char message[MAX_MESSAGE_LEN];
memset(message, 0, sizeof(message));
size_t data_len;
printf("Type your message, one per line:\n> ");
while (fgets(message, MAX_MESSAGE_LEN, stdin) != NULL) {
data_len = strlen(message);
printf("Sending %zu bytes\n", data_len);
ssize_t val = sendto(socket_def, (void *) message, data_len, 0,
(struct sockaddr *) &multicast_addr, sizeof(multicast_addr));
if (val < 0) {
perror("Failed to send message");
close(socket_def);
exit(1);
}
if (val != data_len) {
fprintf(stderr, "Should send %zu bytes, but actually send %zu", data_len, val);
perror("Returned byte count doesn't match sent count");
close(socket_def);
exit(1);
}
printf("> ");
memset(message, 0, sizeof(message));
}
这里为了好看,每行输入数据的时候都会有一个这样的前缀:
> 这是消息,前面的尖括号是前缀,有点Linux命令行的感觉
通过fgets
,我们从标准输入中按行读取字符串,这个字符串是带换行符的,我们需要这个换行符和数据一起发送,因为一会儿接收端也要按行读取数据。这部分关于初始化和清空内存就不多说了(memset
)。
这里的关键就是sendto
调用:这个调用将给定缓冲区(message
)中的前data_len
个字节写入到套接字socket_def
中,目标地址是multicast_addr
。其中那个0
对应消息传输类型标志,这里用默认的即可,也就是什么标志都没有。这个函数如果发送成功,返回发送成功的字节数;如果失败则返回-1,并把错误代码放到errno
里,我们可以通过perror
来访问。
这里还额外验证了发送的数据量:如果实际发送的数据量和预期的不同,我们将其认定为一种错误。
当然,我们的程序不可能一直卡死在这里。当标准输入流被关闭(Ctrl+D)时,这个循环就会退出。但是程序并不能随之退出,我们最好处理一下关闭并释放资源的代码。
退出
退出就是告诉关闭套接字,然后程序退出,占用的内存也随之归还系统。
close(socket_def);
接收者
有了发送者,我们看看怎么接收这些多播报文。
创建套接字
同样地,我们也得先创建一个套接字,和发送者一模一样的套接字:
int socket_def = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (socket_def == -1) {
perror("Failed to create socket");
exit(1);
}
允许套接字重用
这里我们可以设定允许套接字重用。也就是说,我们这个程序监听了给定的多播地址,别的程序还可以监听同样的地址,这样就避免了排他性地独占资源。(毕竟是个只读操作,没有必要独占。)
{ // set reuse, so we can share the same datagrams across multiple instances of this application
int reuse = 1;
if (setsockopt(socket_def, SOL_SOCKET, SO_REUSEADDR, (void *) &reuse, sizeof(reuse)) < 0) {
perror("Failed to set reuse");
close(socket_def);
exit(1);
}
}
这里可以看到,允许地址重用是针对套接字设定的(它的level是SOL_SOCKET
)。
绑定监听地址
我们的套接字需要一个入站连接才能接收到别人的消息。
struct sockaddr_in listen_addr;
memset((char *) &listen_addr, 0, sizeof(listen_addr));
listen_addr.sin_family = AF_INET;
listen_addr.sin_addr.s_addr = inet_addr(ip);
listen_addr.sin_port = htons(strtol(port_str, NULL, 10));
if (bind(socket_def, (struct sockaddr *) &listen_addr, sizeof(listen_addr)) < 0) {
perror("Failed to bind incoming port");
close(socket_def);
exit(1);
}
这里和之前创建多播地址一样,但额外地,我们要监听这个端口,因此需要把这个端口和套接字绑定在一起(bind
)。值得一提的是,在我们的系统中,没有一个网络接口的IP是我们想监听的多播地址,但Linux并没有因此而报错(如果你尝试把多播地址换成诸如192.168.1.1这类地址,bind会报错Cannot assign requested address,因为Linux不知道改绑定到哪个网卡)。这是因为Linux知道这是一个多播地址,操作系统收到发给这个地址的报文后,将其转发给监听在此地址的应用程序即可。如果需要过滤多播报文的来源网络接口,那么就需要在下面的结构体中设置(即控制哪个网络接口加入多播组)。
创建并设置多播请求
那么我们如何告诉套接字接收多播报文呢?我们需要一个ip_mreq
结构体。如果是v6的话则需要ipv6_mreq
。
struct ip_mreq multicast_req;
memset((char *) &multicast_req, 0, sizeof(multicast_req));
multicast_req.imr_interface.s_addr = INADDR_ANY;
multicast_req.imr_multiaddr.s_addr = inet_addr(ip);
if (setsockopt(socket_def, IPPROTO_IP, IP_ADD_MEMBERSHIP, (void *) &multicast_req, sizeof(multicast_req)) < 0) {
perror("Failed to add multicast group");
close(socket_def);
exit(1);
}
这里我们设置了两个参数:imr_interface
设置为0.0.0.0
,意为接收来自任何网络接口的多播报文(即所有网络接口都加入这个多播组);imr_multiaddr
则指定了我们所在的多播组(也就是多播IP,由于主机按照“订阅”的多播IP被分组,因此multicast有时也被译为「组播」)。
创建完结构体,通过套接字设定告诉网络接口我们加入了这个多播组,也就是让网卡订阅了来自这个组的消息,这样网卡收到对应组的消息时就会转发给操作系统,我们作为应用程序才能收到相应的数据。
读取并打印数据
现在万事俱备,只差读数据了。毫无意外,这也是个循环:
char message[MAX_MESSAGE_LEN];
while (1) {
memset(message, 0, sizeof(message));
struct sockaddr_in source_addr;
size_t source_addr_len = sizeof(source_addr);
ssize_t received_len = recvfrom(socket_def, message, MAX_MESSAGE_LEN, 0, (struct sockaddr *) &source_addr,
(socklen_t *) &source_addr_len);
if (received_len < 0) {
perror("Receiving error");
break;
}
printf("Received %zu bytes from %s:%d: ", received_len, inet_ntoa(source_addr.sin_addr), source_addr.sin_port);
printf("%s", message);
}
这里主要通过recvfrom
调用来读取数据:从socket_def
代表的套接字中读取最多MAX_MESSAGE_LEN
个字节,写入到缓冲区message
里,源地址保存到source_addr
里面。这里的0和上面sendto
的0一样,是标志。
这里打印信息的效果是:
Received 7 bytes from 172.16.71.138:39077: 123123
可以看到,通过UDP报文我们可以获取到源地址和源端口。(什么?你说123123
只有6个字节?换行符也占一个啊)
退出
这里的退出其实就不是特别明显了,因为这个循环的退出依赖于读取出错。但是UDP报文没有状态一说,因此更谈不上什么断开连接这种操作。即便所有发送者都关闭了程序,对于接收端来说还是在等待下一个报文的到达。
但是有没有的,还是得假一下:
if (setsockopt(socket_def, IPPROTO_IP, IP_DROP_MEMBERSHIP, (void *) &multicast_req, sizeof(multicast_req)) < 0) {
perror("Failed to exit multicast group");
close(socket_def);
exit(1);
}
close(socket_def);
一开始我们告诉网卡加入多播组,退出的时候就得对应地告诉网卡退出多播组。不必担心直接退出程序会将多播组“滞留”在网卡上:这个多播组是通过套接字设定的,而套接字对接的是操作系统。套接字关闭后,操作系统会进行善后工作,如果没有其他进程需要这个多播组了,那么操作系统会告诉网卡退出多播组。
效果展示
写完代码之后,构建一下,把构建产物复制到两个虚拟机上,每台虚拟机各运行一个发送者、一个接收者和一个监听在其他地址的接收者。
可以看到,发送者发出的消息,可以被所有加入了多播组的主机收到;监听在其他多播地址的接收者,即便他们监听了同一端口号,由于多播组不通,他们并不会接收到我们的消息。
吐槽
最后要吐槽一下我的这个同学。
他这个C语言可能是跟师娘学的。以及他用的CentOS7,自带的cmake版本是2.18;我用的是3.22。我整个工程都打包发给他了,他经过一番尝试之后得出结论:
我不禁感叹,这就是我校研究生的水平。可叹啊。
-全文完-
【代码札记】零基础入门Linux套接字(socket)多播(multicast) 由 天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。
深有感触。。。我一个本科没毕业的现在也在给咱学校研究生毕业擦屁股@(鄙视)上了三年学深度学习框架都不会用,都是把思路口述给我我来写代码,令人感叹