Linux 一切皆文件

Unix 有一条著名的哲学——「一切皆文件」(everything is a file)。但严格来说,socket 和 pipe 并不完全符合这一点:它们虽然能被当作文件描述符来读写,却没有被纳入统一的目录树命名空间。本文从这一点出发,探讨”一切皆文件”的真正含义,并展示如何借助 sysfs 把一个 UDP socket 变成目录树中一个真正可读写的文件。

一、一切皆文件,还是一切皆文件描述符?

可见,socket 几乎就是为 TCP 量身定制的接口,与 TCP 状态机相互对应。换句话说,socket 并不是严格意义上的文件——这意味很难用统一的方法处理 socket 的 IO 流和普通文件的 IO 流。于是 Unix 的”一切皆文件”退化成了”一切皆文件描述符”:

  • 一切皆文件:文件属于 Unix/Linux 目录树,编址于统一命名空间。
  • 一切皆文件描述符:文件描述符属于进程的打开文件表,进程内可见。

同样奇怪的还有 pipe 调用:它创建了一对文件描述符,但也仅仅是文件描述符,没有被纳入统一命名空间的目录树中。

Unix 哲学中的”一切皆文件”和”组合小程序”等原则是相辅相成的。如果”一切皆文件”被破坏,就很难简单地串接小程序来实现复杂逻辑:

  • socket 没有标准文件的 open 和 close 操作,不能 cat 一个 socket,也没法向一个 socket 里 echo 数据。

正因如此,才出现了 socat、netcat 这些大家都说好、但本质上并没有必要的微型网络程序。如果一个网络连接也是系统目录树上的文件,就可以这样打开一个连接:

sd = open("/sys/udp/1.1.1.1/53", ...);

socat、netcat 就没有必要了,直接在 shell 上就能完成所有操作。比如发包可以这么做:

echo aaaaaaa >/sys/udp/1.1.1.1/53

对应的收包操作如下:

cat /sys/udp/1.1.1.1/53

甚至可以这样 dup 文件描述符:

exec 6<>/sys/udp/1.1.1.1/53
echo aaaaaaa >&6
read -ru6 # cat

socat、netcat 这些微型网络程序,实际上就是标准文件 IO 接口对 socket IO 接口的封装。

二、bash 里的伪文件机制

其实 bash shell 中就隐藏着这么一个 socket 文件机制——我们可以在 bash 中如下访问 baidu 的主页:

bash-3.2$ exec 6<>/dev/tcp/www.baidu.com/80
bash-3.2$ echo 'GET /index.html HTTP/1.1' >&6
bash-3.2$ echo >&6
bash-3.2$ cat <&6

bash 的 /dev/tcp/host/port/dev/udp/host/port 是一种伪设备文件,让我们能像读写文件一样收发网络数据。类似的机制也体现在 sysfs 中,比如通过写文件来热插拔 CPU:

[root@localhost ~]# echo 0 >/sys/devices/system/cpu/cpu0/online
[root@localhost ~]# echo 0 >/sys/devices/system/cpu/cpu2/online

结果就只剩 2 个 CPU 了:

[root@localhost ~]# cat /proc/cpuinfo | grep processor
processor: 1
processor: 3
[root@localhost ~]#

三、用 sysfs 实现 UDP socket 文件

UDP socket 文件就是基于 sysfs 实现的,我称它为 UDP socket sysfs。本文不是讲 sysfs 原理的,这方面的资源已经很多,不再赘述。这里只提 sysfs 最基本的两个特征:

  • 每一个可以表示为文件的对象(Obj)都是 sysfs 中的一个目录;
  • 每一个 Obj 的任何属性都表示为该 Obj 对应目录下的一个文件。

以上面的 CPU 热插拔为例,/sys/devices/system/cpu 是一个目录,表示系统的所有 CPU,其属性为:

cpu0 cpu1 cpu2 cpu3 cpuidle isolated kernel_max microcode modalias nohz_full offline online possible power present uevent

查看其 offline 属性(表示已经下掉的 CPU),只需读该文件即可:

[root@localhost ~]# cat /sys/devices/system/cpu/offline
0,2

Linux sysfs 使传统的 ioctl 系统调用再无必要。为了获得直观感受,我先演示 UDP socket sysfs 文件的效果,然后再给出源码。

我们希望在 sysfs 下用文件表示 UDP socket,因此先创建一个表示 UDP socket 的目录:

[root@localhost sysfs_test]# ls /sys/kobject_udp/
ctrl

该目录表示 UDP socket 的汇总,它有一个属性文件 ctrl,对其读写会触发一系列事件:

  • create 到 ctrl:创建一个 UDP socket;
  • shutdown 到 ctrl:销毁 UDP socket。
[root@localhost sysfs_test]# echo -n create >/sys/kobject_udp/ctrl
[root@localhost sysfs_test]# ls /sys/kobject_udp/
ctrl instance_0
[root@localhost sysfs_test]# ls /sys/kobject_udp/instance_0/
ctrl data
[root@localhost sysfs_test]#

创建一个 UDP socket sysfs 实例,相当于在 kobject_udp 下创建了一个目录 instance_0。该实例有两个属性:

  • data:用于数据的收发;
  • ctrl:用于控制,读写该文件可以实现 connect、bind、set/getsockopt 等。

数据和控制相分离,但它们都是 Linux 系统目录树中可读写的文件。写 ctrl 就能达到对 socket 进行控制的效果:

[root@localhost sysfs_test]# echo -n bind 127.0.0.1:123 >/sys/kobject_udp/instance_0/ctrl
[root@localhost sysfs_test]# netstat -anup
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
udp 0 0 0.0.0.0:123 0.0.0.0:* -
[root@localhost sysfs_test]#

可见,通过写入 UDP socket sysfs 实例的 ctrl 文件,新建的 socket 便 bind 到了本地 123 端口,netstat 的结果印证了操作已经成功。接下来把它 connect 到本地的另一个端口,试试数据的收发;再仿照 bash 的 /dev/udp/host/port 实现,试试文件描述符 dup——都工作良好,正是想要的效果。

好了,现在给出源码:

// sysudp.c
// make -C /lib/modules/`uname -r`/build SUBDIRS=`pwd` modules
// insmod ./sysudp.ko

#include <linux/module.h>
#include <linux/init.h>
#include <linux/sysfs.h>
#include <linux/ip.h>
#include <linux/in.h>

static struct kobject *udp_kobject, *srv;
static char ctrl, cctrl, data;
struct socket *ksock;
struct sockaddr_in addr, raddr;
struct msghdr msg;
struct iovec iov;
mm_segment_t oldfs;

static int bind_socket(unsigned short port)
{
    memset(&addr, 0, sizeof(struct sockaddr));
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = htonl(INADDR_ANY);
    addr.sin_port = htons(port);
    if (ksock->ops->bind(ksock, (struct sockaddr *)&addr, sizeof(struct sockaddr)) < 0)
        return -1;

    return 0;
}

static ssize_t data_store(struct kobject *kobj, struct kobj_attribute *attr,
                          char *buf, size_t count)
{
    int size = 0;

    if (ksock->sk == NULL)
        return 0;

    iov.iov_base = buf;
    iov.iov_len = count;
    msg.msg_flags = 0;
    msg.msg_name = &raddr;
    msg.msg_namelen = sizeof(struct sockaddr_in);
    msg.msg_control = NULL;
    msg.msg_controllen = 0;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = NULL;
    oldfs = get_fs();
    set_fs(KERNEL_DS);
    size = sock_sendmsg(ksock, &msg, count);
    set_fs(oldfs);
    return size;
}

static ssize_t data_show(struct kobject *kobj, struct kobj_attribute *attr,
                         char *buf)
{
    int size = 2048;

    if (ksock->sk == NULL)
        return 0;

    iov.iov_base = buf;
    iov.iov_len = size;
    msg.msg_flags = 0;
    msg.msg_name = &addr;
    msg.msg_namelen = sizeof(struct sockaddr_in);
    msg.msg_control = NULL;
    msg.msg_controllen = 0;
    msg.msg_iov = &iov;
    msg.msg_iovlen = 1;
    msg.msg_control = NULL;
    oldfs = get_fs();
    set_fs(KERNEL_DS);
    size = sock_recvmsg(ksock, &msg, size, msg.msg_flags);
    set_fs(oldfs);
    return size;
}

static struct kobj_attribute ctrl_attribute = __ATTR(ctrl, 0660, cctrl_show, cctrl_store);

static struct kobj_attribute data_attribute = __ATTR(data, 0660, data_show, data_store);

static ssize_t ctrl_show(struct kobject *kobj, struct kobj_attribute *attr, char *buf)
{
    return sprintf(buf, "create\nshutdown\n[TODO]....\n");
}

static int create_socket(void)
{
    if (sock_create(AF_INET, SOCK_DGRAM, IPPROTO_UDP, &ksock) < 0)
        return -1;

    return 0;
}

Review 源码,可以发现 socket sysfs 与 socket 接口有两点最大的不同,socket sysfs 文件有以下性质:

  • socket 用 sysfs 的一个目录表示:socket sysfs 文件作为一个对象在 sysfs 中是一个目录,该目录下有两个属性文件用于实际操作——一个用于数据通信的 data 属性文件,一个用于控制的 ctrl 属性文件。

  • 消除了 socket 的 open 行为:这是最精妙的一点。socket 只能被创建、不能被打开——只有存在的东西才能被打开。能被打开的是 socket 的 data 属性文件和 ctrl 文件,打开它们的目的就是读写它们,这就是”一切皆文件”在 socket 上的体现。

这个源码之后,其实还有很多 TODO:

  • socket 文件的访问控制如何做:以往的 socket 文件描述符作用域是创建它的进程;如果采用 sysfs 文件的话将是全局可见,如何进行进程访问控制,需要设计一套规则。

  • 性能问题:本文没有涉及任何性能问题,但在实际实现中这是必须要考虑的。

四、用 zsh 实现 TCP 服务器

UDP socket sysfs 文件实现之后,TCP 呢?我们需要实现一个 TCP 的 socket sysfs 文件机制,从而可以用 shell 脚本粘合独立的小程序,实现复杂的 TCP 客户端和 TCP 服务器。

TCP 客户端比较容易实现,和 UDP socket 文件实现类似;TCP 服务端则需要做得更多,典型的问题是如何实现连接管理——或者说,到底还需不需要连接管理、需不需要 socket 的 listen、accept 那一套,这些都是疑问。

bash 没有实现类似 /dev/tcp/$host/$port 那样的伪设备文件来实现 TCP 脚本服务器,但 zsh 的 ztcp module 可以做到。这里给出一个 zsh 脚本实现的 TCP 服务器:

#!/usr/bin/zsh

# 加载 ztcp 模块
zmodload zsh/net/tcp

# 处理 IO,其实就是一个 echo
handle-io() (
    # 从 TCP client 读取一行。这里用 cat <&4 会有问题,因为没有 EOF
    read -ru4 line
    # 打印收到的东西
    echo from client: $line
    # 把收到的内容 echo 回对端
    echo echo from server: $line >&4
    # 关闭描述符
    exec 4>&-
)

ztcp -l -d 3 12345
while ztcp -ad4 3; do
    # 在子进程中处理 TCP client
    (handle-io)
    # 父进程关闭 TCP client
    exec 4>&-
done

多么典型的 TCP accept/fork 编程模型。写一个 sysfs 内核模块,实现以下机制即可:

  • 实现 ztcp -l:通过写 TCP 目录的 ctrl 文件实现;
  • 实现 ztcp -a:接收连接后自动创建 TCP client 目录;
  • 实现 TCP 通信:通过读写 TCP client 目录的 data 文件实现。

这并不困难。可以憧憬,将来 netcat、socat、telnet、ztcp 这类专门处理简单网络操作的微型程序将不再需要,我们只需要 cat/readecho/write 即可。而这个憧憬,在 Plan 9 上已经成了现实。

五、思考

本文结束之前,思考一个问题:

是设计一个新的 API,还是用不同的参数调用既有 API?

以文件操作为例,假设文件 IO 的 read 和 write 都工作得很好,现在有个新需求——要实现的业务逻辑称之为 business。实现这个需求不外乎以下两种方案:

  1. 将 business 的逻辑封装成一个新 API,使用者直接调用;
  2. 将 business 的逻辑抽象成数据流暴露给使用者。

这就又回到了《60行C代码实现一个shell》一文中的例子。请实现式子 (a+b)/c 的计算。

对于第 1 种方案,显然要这么做:

int business(int a, int b, int c)
{
    return (a + b) / c;
}

简单直接,实现又快。对于第 2 种思路,则需要如下步骤:

  1. 分别写一个表示两个数相加、两个数相除的程序;
  2. 用管道将这些程序组织起来,获取结果。

显然没有第 1 种方法直接快速。但是,如果把式子换成一元二次方程的求根公式呢?是重新重构 business 函数,还是重新组合运算符程序、用管道连接它们?

之所以会出现 ioctl 以及 socket 接口这种”奇怪”的 API,正因为它们足够直接、实现足够快速,才因此破坏了 Unix”一切皆文件”的原则。这些 API 的名字基本能看出它们的功能——connect 是建立连接的,bind 是绑定地址的,ioctl 是发送控制命令的——它们只能做自己声明能做的事情。

read 和 write 则不然:参数只是一个裸 buffer,在 API 层面没有任何自解释特征。所以,少就是多,无则是全——read 和 write 事实上可以完成”任意”操作。这便是”一切皆文件”的精髓,也正是我写 sysfs UDP socket 文件模块的缘由。欣赏这种美并宣扬它,便是写这篇文章的动机。


   转载规则


《Linux 一切皆文件》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
Linux 程序调试指南 Linux 程序调试指南
GDB 是 GNU 发布的、运行在 UNIX/Linux 下的命令行调试器,也是 Linux 下 C/C++ 程序员必不可少的工具。它的命令很多,但日常调试只需掌握其中约十个。本文先从一个完整的调试过程讲起,再延伸介绍 pstack、str
2020-09-01
下一篇 
glibc 内存分配器原理 glibc 内存分配器原理
内存管理是 C 程序员日常编码中最常见的技术点,能否用得 glibc 内置的内存管理函数,是衡量 C 程序员水平的重要标准。本文从几个 C 语言中常见的例子出发,深入剖析 malloc/free 这两个最常见内存管理函数的实现机制,并总结如
2020-08-08
  目录