實例演繹Unix/Linux的"一切皆文件"思想

  • 2019 年 10 月 6 日
  • 筆記

大家習慣了使用socket來編寫網路程式,socket是網路編程事實上的標準。

我們知道,在Unix/Linux系統中「一切皆文件」,socket也被認為是一種文件,socket被表示成文件描述符。

但socket的行為並不很像文件。比如:

  • 無法用 「open一個路徑」 的方式打開一個socket,必須用socket系統調用來創建。
  • 文件系統的close可以關閉socket描述符,但優雅關閉TCP socket卻需要shutdown。
  • 標準文件系統沒有諸如bind,connect,accept,recvfrom等操作。
  • socket編程繁瑣易錯,與標準文件操作open/read/write/close等迥異。

如果能像對待標準文件那樣對待socket,用read/write讀寫它,該有多好。本文就來實現這麼一個機制來實現UDP socket的數據收發,非常簡單,大概160行程式碼。

在給出程式碼之前,我們得先理解什麼是「一切皆文件」。這一切還要從最開始說起。

「一切皆文件」之始

在Unix原始論文《The UNIX TimeSharing System》中,里奇和湯普森就提出了「一切皆文件」的樸素思想。

「Unix將普通文件和設備通過目錄統一在了一個遞歸的樹形結構中。形成了一個統一的命名空間。」

Unix文件系統是一個掛載在ROOT的樹形目錄結構,每一個目錄節點都可以掛載一棵子樹。

「一切皆文件」意味著這棵樹上可以掛載一切。比如nfs就可以將網路上另外一台主機的文件系統掛載到本機的目錄樹上,想想看,這棵文件樹上的一個文件竟然在另一台機器上,這是多麼不可思議。

但這終究只是個理想。

「一切皆文件」之殤

Unix宣稱的 「一切皆文件」 並沒有完全做到。我們看兩個破壞優雅的反例:

  1. 奇怪的ioctl
  2. 奇怪的BSD socket

「一切皆文件」的背後是一切操作都可以抽象成open,read,write,close。但是ioctl是什麼鬼?

有一些行為很難用read和write來定義,比如光碟播放時快進。ioctl的出現彌補了read/write的缺失。但是ioctl有自己的問題:

  • 無法根據一個文件描述符確定ioctl命令列表,只能根據錯誤碼來判斷。
  • ioctl命令和設備緊耦合,重依賴設備類型,控制命令呈暴增趨勢。

解決這個問題非常簡單,為每一個設備增加一個名叫ctrl的文件。將ioctl的調用轉換為針對ctrl文件的讀寫即可。典型的例子參見PCIe設備的配置空間的讀寫。

總體而言,ioctl增加了文件操作的複雜性。 …

現在說說socket的問題。

雖然socket也是一個文件描述符,它的操作介面和標準文件介面非常不同:

  • 創建socket必須用socket調用而不是open,socket在打開之前不能存在。
  • bind,connect,accept等都是獨立的系統調用,沒有標準文件操作與之對應。

socket一開始是作為一種封裝TCP/IP網路流的IPC機制出現的,而TCP/IP一開始就沒有被抽象成文件。下圖來自關於socket的wiki:

可見,socket幾乎就是為TCP量身訂製的介面,和TCP狀態機相互對應。換句話說,socket並不是嚴格意義上的文件。

這意味著很難用統一的方法處理socket的IO流和普通文件的IO流。

Unix 「一切皆文件」 退化成了「一切皆文件描述符」:

  • 一切皆文件: 文件屬於Unix/Linux目錄樹,編址於統一命名空間。
  • 一切皆文件描述符: 文件描述符屬於進程打開文件表,進程內可見。

同樣奇怪的是pipe調用,它創建了一對文件描述符,但也僅僅是文件描述符,而沒有被納入到統一命名空間的Unix/Linux目錄樹中。

Unix哲學中的「一切皆文件」和其它的原則比如「組合小程式」等是相輔相成的。如果「一切皆文件」被破壞,那麼便很難簡單串接小程式實現複雜邏輯:

  • socket沒有標準文件的open和close操作,不能cat一個socket,也沒法向一個socket里echo數據。

因此就出現了socat,netcat這種大家都說好,但實際上沒有必要的微型網路程式。

如果一個網路連接也是一個系統目錄樹上的文件,便可以如下打開一個連接:

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

sacat,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 <&6 將面臨EOF問題。  

socat,netcat這些微型網路程式實際上就是標準文件IO介面對socket IO介面的封裝。

其實,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  HTTP/1.1 200 OK  Accept-Ranges: bytes  Cache-Control: no-cache  Connection: Keep-Alive  Content-Length: 14615  Content-Type: text/html  ...  

是不是連telnet也不需要了呢?嗯,wget,curl都可以消失。

事實上,我們可以用和處理普通文件完全一樣的程式來處理socket,理論上只要有cat/read和echo/write兩類4個命令就可以了。

遺憾的是, /dev/tcp/www.baidu.com/80 文件並不存在,這只是bash為我們提供的一種善意的假象。

如果你用過socat(可以直接用yum install安裝),你會發現更酷的玩法,我們來看一個TCP伺服器用socat怎麼實現:

[root@localhost ~]# socat tcp-listen:1234,reuseaddr,fork exec:bash,pty,stderr  

不用寫一行程式碼。此時從另一個終端,我們便可以用telnet登錄這個伺服器:

[root@localhost ~]# telnet 127.0.0.1 1234  Trying 127.0.0.1...  Connected to 127.0.0.1.  Escape character is '^]'.  bash: 此 shell 中無任務控制  [root@localhost ~]# pwd  pwd  /root  

當然了,你可以同樣用socat,這麼干:

[root@localhost ~]# socat -,raw,echo=0 tcp:host:1234  

如果一個連接可以表示成目錄樹中的一個文件,我們就不需要socat,telnet了,我們只需要cat/read和echo/write等兩類命令即可完成所有這一切。

我們可以自己實現這樣的機制。

插曲-「一切皆文件」之Plan 9原教旨

寫這篇文章的想法源自於我在班車上刷知乎,偶然間看到了Plan 9這個出自湯普森,里奇這幫人之手號稱要取代Unix的下一代分散式作業系統(好長的描述)…

Plan 9承諾徹底貫徹執行 一切皆文件。 這將使其有能力對外提供統一的文件系統視圖,以實現分散式。

Plan 9是一個真正的分散式系統,它可以將分布在不同位置的所有資源作為文件統一在同一棵目錄樹中,這便是Unix最初的願景:

看上圖,一台機器上的一個可執行文件可以運行在另一台機器的CPU上,這一切對於用戶都是透明的,Plan 9用9P屏蔽了底層的通訊細節。之所以能實現這樣的效果,全部拜「一切皆文件」所賜。

Plan 9的創舉在於,它將分散的電腦的內部硬體組件和軟體組件(而不是電腦本身)看成了獨立的資源,而不管它們之間是如何連接的:

  • 同一台機器內部的資源通過內部匯流排連接。
  • 不同機器的資源通過網路連接。

讓我們領略一下Plan 9中的「一切皆文件」對於TCP而言意味著什麼:

暫時不要戀戰,先把本文看完,因為我下面的UDP的實例算是Plan實踐的簡版,理解了這個簡版,再仔細閱讀下面的鏈接以體會原汁原味的Plan 9-The Organization of Networks in Plan 9: http://doc.cat-v.org/plan_9/4th_edition/papers/net/

Plan 9並沒有由於其設計的先進性而變得流行起來,不過幸運的是,它的思想被Linux吸收了。我們便有機會在熟悉的Linux系統實現憧憬中的socket文件機制了。

「一切皆文件」之Linux

Linux貫徹一切皆文件的程度要遠遠超過傳統Unix。Linux除了普通文件,目錄,設備文件,管道等之外,實現非常多的特殊文件,這些都是直接或間接來自Plan 9:

  • procfs【此乃Plan 9的嫡系】
  • sysfs
  • cpuset
  • debugfs
  • cgroup

真的是一切皆文件了。你無需調用特殊的介面,只需要echo就可以在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 ~]#  

Linux 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,該UDP socket sysfs實例有兩個屬性:

  • 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/host/port的實現,試試文件描述符dup:

OK,工作的不錯。就是要這樣的效果。

好了,現在該給出源碼了:

// 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 cctrl_store(struct kobject *kobj, struct kobj_attribute *attr, char *buf, size_t count)  {  	// 為了程式碼簡短,未做字元串解析,採用了硬編碼,且僅支援一個socket的創建  	if (!strcmp(buf, "connect 127.0.0.1:321")) { // 寫ctrl文件實現connect  		memset(&raddr, 0, sizeof(struct sockaddr));  		raddr.sin_family = AF_INET;  		raddr.sin_addr.s_addr = htonl(0x7f000001);  		raddr.sin_port = htons(321);  	} else if (strstr(buf, "bind 127.0.0.1:123")) { // 寫ctrl文件實現bind  		bind_socket(123);  	} else if (strstr(buf, "setsockopt")) { // sockopt也是寫文件完成  		// TODO  	}  	return count;  }    static ssize_t cctrl_show(struct kobject *kobj, struct kobj_attribute *attr, char *buf)  {  	return sprintf(buf, "bind x.x.x.x:yyynconnect x.x.x.x:yyynsetsockopt valuen[TODO]....n");  }    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, "createnshutdownn[TODO]....n");  }    static int create_socket()  {  	if (sock_create(AF_INET, SOCK_DGRAM, IPPROTO_UDP, &ksock) < 0)  		return -1;    	return 0;  }    static ssize_t ctrl_store(struct kobject *kobj, struct kobj_attribute *attr, char *buf, size_t count)  {  	// 僅支援一個socket  	if (!strcmp(buf, "create")) { // 寫ctrl文件創建socket實例  		if (!srv) {  			srv = kobject_create_and_add("instance_0", udp_kobject);          	sysfs_create_file(srv, &ctrl_attribute.attr);          	sysfs_create_file(srv, &data_attribute.attr);  			create_socket();  		}  	} else if (!strcmp(buf, "shutdown")) { // 寫ctrl文件銷毀socket實例  		if (srv)  			if (ksock)  				sock_release(ksock);  			kobject_put(srv);  			srv = NULL;  	}    	return count;  }  static struct kobj_attribute foo_attribute =__ATTR(ctrl, 0660, ctrl_show, ctrl_store);    static int __init sysudp_init (void)  {  	int error = 0;    	srv = NULL;  	udp_kobject = kobject_create_and_add("kobject_udp", NULL);  	if(!udp_kobject)  		return -ENOMEM;    	error = sysfs_create_file(udp_kobject, &foo_attribute.attr);    	return error;  }    static void __exit sysudp_exit (void)  {  	if (srv) {  		if (ksock)  			sock_release(ksock);  		kobject_put(srv);  	}  	kobject_put(udp_kobject);  }  MODULE_LICENSE("GPL");  module_init(sysudp_init);  module_exit(sysudp_exit);  

Review源碼,我們發現了socket sysfs和socket介面的兩點最大不同,socket sysfs文件有以下性質:

  • socket用sysfs的一個目錄表示 即socket sysfs文件作為一個對象在sysfs是一個目錄,該目錄下兩個屬性文件用於實際操作,一個是數據通訊用的data屬性文件,一個是作為控制使用的ctrl屬性文件。
  • 消除了socket的open行為 這是最精妙的。socket只能被創建而不是被打開,只有存在的東西才能被打開。能被打開的是socket的data屬性文件和ctrl文件,打開它們的目的是讀寫它們,這就是「一切皆文件」在socket上的體現。

在這個源碼之後,其實還有很多的TODO:

  • socket文件的訪問控制如何做 以往的socket文件描述符的作用域是創建它的進程,如果採用sysfs文件的話,將會是全局可見,如何進程訪問控制,需要設計一套規則。
  • 性能問題 本文到此為止沒有涉及任何有關性能的問題,但是在實際實現中,這個是必須要考慮的。

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/read,echo/write即可。而這個憧憬在Plan 9上已經成了現實。

思考

本文結束之前,我們來思考一個問題:

  • 是設計一個新的API,還是用不同的參數調用既有API?

以文件操作為例,假設文件IO的read和write都工作的很好,現在又個新的需求,要實現的業務邏輯我們稱之為business,如何實現這個需求不外乎以下兩種:

  1. 將business的邏輯封裝成一個新的API,使用者直接調用
  2. 將business的邏輯抽象成數據流暴露給使用者

這就又回到了《60行C程式碼實現一個shell》一文中的例子。請實現式子

的計算。

對於第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是發送控制命令的。這些API只能做它們聲明能做的事情。

read和write則不然,參數只是一個裸buffer,在API層面沒有任何自解釋特徵,所以,少就是多,無則是全,read和write事實上可以完成 「任意」 操作!

這便是「一切皆文件」的精髓,這便是我寫sysfs UDP socket文件模組的緣由,欣賞這種美並宣揚它便是我寫這篇文章的動機。

(完)