这篇文章完全是意料之外的。起因是正在上研究生的大学同学突然拿着一个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。我整个工程都打包发给他了，他经过一番尝试之后得出结论：我不禁感叹，这就是我校研究生的水平。可叹啊。-全文完- 这是由于网卡并不知道这些数据是否是有用的，因此只能在收到消息时都中断递交收到的数据，让软件（操作系统）进行判断是否转发给应用程序。但这种情况下，即便不是发给本主机的数据也要占用CPU资源进行处理，性能影响较大。 ↩ 严格来说，网卡还是会收到数据，但这一次网卡会发现这不是发给本主机的数据，因此不需要触发中断，直接丢弃就好。硬件上的自动丢弃不占用CPU资源，因此性能较好。 ↩