MENU

【代码札记】零基础入门Linux套接字(socket)多播(multicast)

November 15, 2022 • 瞎折腾

这篇文章完全是意料之外的。起因是正在上研究生的大学同学突然拿着一个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);

一开始我们告诉网卡加入多播组,退出的时候就得对应地告诉网卡退出多播组。不必担心直接退出程序会将多播组“滞留”在网卡上:这个多播组是通过套接字设定的,而套接字对接的是操作系统。套接字关闭后,操作系统会进行善后工作,如果没有其他进程需要这个多播组了,那么操作系统会告诉网卡退出多播组。

效果展示

写完代码之后,构建一下,把构建产物复制到两个虚拟机上,每台虚拟机各运行一个发送者、一个接收者和一个监听在其他地址的接收者。

ubuntu.png

opensuse.png

可以看到,发送者发出的消息,可以被所有加入了多播组的主机收到;监听在其他多播地址的接收者,即便他们监听了同一端口号,由于多播组不通,他们并不会接收到我们的消息。

吐槽

最后要吐槽一下我的这个同学。

1.png

他这个C语言可能是跟师娘学的。以及他用的CentOS7,自带的cmake版本是2.18;我用的是3.22。我整个工程都打包发给他了,他经过一番尝试之后得出结论:

2.png

我不禁感叹,这就是我校研究生的水平。可叹啊。

3.png

-全文完-


  1. 这是由于网卡并不知道这些数据是否是有用的,因此只能在收到消息时都中断递交收到的数据,让软件(操作系统)进行判断是否转发给应用程序。但这种情况下,即便不是发给本主机的数据也要占用CPU资源进行处理,性能影响较大。
  2. 严格来说,网卡还是会收到数据,但这一次网卡会发现这不是发给本主机的数据,因此不需要触发中断,直接丢弃就好。硬件上的自动丢弃不占用CPU资源,因此性能较好。

知识共享许可协议
【代码札记】零基础入门Linux套接字(socket)多播(multicast)天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code
Leave a Comment

已有 1 条评论
  1. 深有感触。。。我一个本科没毕业的现在也在给咱学校研究生毕业擦屁股@(鄙视)上了三年学深度学习框架都不会用,都是把思路口述给我我来写代码,令人感叹