简单理解linux文件描述符
简单理解什么是linux文件描述符。
什么是linux文件描述符
文件描述符可以简单理解为一个不透明的句柄,当我们创建文件或者打开已有文件的时候,内核就会向进程返回文件描述符,它可以简单理解为是操作系统为为文件创建的索引。
在linux系统中,每个进程都有3个默认的文件描述符,分别用0、1、2来表示,含义分别如下:
0:标准输入,对应的设备文件为/dev/stdin。
1:标准输出,对应的设备文件为/dev/stdout。
2:错误输出,对应的设备文件为/dev/stderr。
除了默认文件描述符以外,还能为进程创建其他文件描述符,甚至是tcp套接字。
在linux中,可以查看/proc/进程号/fd/下的文件来查看该进程创建的文件描述符。
比如在终端中使用vim打开了一个新的文件123。除了0、1、2等默认文件描述符外,还有其他的描述符,比如:
3 -> /root/.123.swp
如果在终端中打开了一个套接字,比如telnet,那么也会有相应的文件描述符中,比如:
4 -> socket:[179916]
这个套接字指向的是一个inode号,可以使用lsof等工具查询。
进程产生创建的文件描述符
在系统中,任何进程都可以调用系统open函数,或者建立新的网络连接来创建文件描述符,而这些文件描述符都属于该进程自己。而在linux中,同样也可以创建文件描述符,而该文件描述符依然属于该进程,可以看看这二者之间的差别。
关于进程创建文件描述符,举一个最简单的例子,比如我们编写如下的一段go代码:
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
fmt.Println("pid: ", os.Getpid())
for i := 0; i < 10; i++ {
_, err := os.Create(fmt.Sprintf("file_%d", i))
if err != nil {
fmt.Println("open error", err)
}
}
net.Dial("tcp", "192.168.1.1:22")
net.Dial("tcp", "192.168.1.2:80")
time.Sleep(time.Hour * 1)
}
上述代码,首先打印了进程的pid,而后在当前目录下新建了file_0到file_9这10个文件,创建了的文件都没有进行close操作,也就是说,文件句柄还在进程中,然后使用net库创建了2个tcp连接,这是还是没有close操作,最后使用sleep睡眠了1个小时。
测试一下进程创建文件描述符号,首先进行编译:
go build -o fdtests
而后执行该可执行文件:
# ./fdtests
pid: 1362
得到了pid之后,新开一个终端,查看该进程的fd信息。
# ls -l /proc/1362/fd/
total 0
lrwx------. 1 root root 64 Jun 4 23:45 0 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 4 23:45 1 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 4 23:45 10 -> /root/file_5
lrwx------. 1 root root 64 Jun 4 23:45 11 -> /root/file_6
lrwx------. 1 root root 64 Jun 4 23:45 12 -> /root/file_7
lrwx------. 1 root root 64 Jun 4 23:45 13 -> /root/file_8
lrwx------. 1 root root 64 Jun 4 23:45 14 -> /root/file_9
lrwx------. 1 root root 64 Jun 4 23:45 15 -> socket:[22365]
lrwx------. 1 root root 64 Jun 4 23:45 16 -> socket:[22366]
lrwx------. 1 root root 64 Jun 4 23:45 2 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 4 23:45 3 -> /root/file_0
lrwx------. 1 root root 64 Jun 4 23:45 4 -> anon_inode:[eventpoll]
lrwx------. 1 root root 64 Jun 4 23:45 5 -> anon_inode:[eventfd]
lrwx------. 1 root root 64 Jun 4 23:45 6 -> /root/file_1
lrwx------. 1 root root 64 Jun 4 23:45 7 -> /root/file_2
lrwx------. 1 root root 64 Jun 4 23:45 8 -> /root/file_3
lrwx------. 1 root root 64 Jun 4 23:45 9 -> /root/file_4
#
通过上面的例子,可以看到众多的文件句柄,其中0、1、2分别是系统创建的默认的句柄,还可以看到指向文件file_*的句柄,以及2个socket句柄,是最后的2个tcp连接。其中有2个特殊的句柄anon_inode,这个暂不讨论。
上述这些句柄都是属于进程的,其他进程是无法直接使用的,也需要调用系统open函数来实现自己的句柄才行。
linux 输入输出重定向
linux重定向可以分为输出重定向和输入重定向。在系统中除了标准输入、标准输出、错误输出以外,其他的重定向,都需要指定文件描述符id,也可以称为句柄。
在linux中,可以通过查看/proc/进程pid号/fd/下的文件来查看该进程有多少个fd,比如:
# ls -l /proc/$$/fd/
total 0
lrwx------. 1 root root 64 Jun 3 22:22 0 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 3 22:22 1 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 3 22:22 2 -> /dev/pts/0
#
上述命令中$$表示该进程本身的pid,而该fd文件下,可以看到有0、1、2 三个文件,都指向/dev/pts/0,这是一个字符设备文件,表示当前的终端。
输出重定向原理
对于系统而言,任何一个程序都有2个输出,分别是:
- 文件描述符为1的标准输出
- 文件描述符为2的错误输出
所以,当执行一个命令输出重定向的时候,比如:
command >> filename
上述命令是将command命令执行的标准输出结果重定向到filename文件中,你可以理解为在执行该命令的时候,其进程标准输出文件描述符1由默认的/dev/pts/0指向了filename文件,为此可以来写一个程序来试试。
程序如下:
#!/bin/bash
echo "pid: $$" >&2
while true
do
echo 123
sleep 3
done
这个程序非常简单,首先现输出该脚本的pid到错误输出上,即文件描述符为2,而后定一个无限循环,每隔3秒输出123。
在执行的时候,将该文件输出重定向到filename中。
# bash test.sh >> filename
pid: 42699
程序执行会卡主,这是正常的,这是因为有无限循环,每隔3秒会输出123字符串至标准输出上。
而后打开一个新的终端,查看42699的fd文件信息。
# ls -l /proc/42699/fd/
total 0
lrwx------. 1 root root 64 Jun 3 23:41 0 -> /dev/pts/0
l-wx------. 1 root root 64 Jun 3 23:41 1 -> /root/fd/filename
lrwx------. 1 root root 64 Jun 3 23:41 2 -> /dev/pts/0
lr-x------. 1 root root 64 Jun 3 23:41 255 -> /root/fd/test.sh
#
通过上面执行的结果可以发现,其标准输出,文件描述符为1已经指向一个文件:/root/fd/filename,而非终端字符设备。所以说,在上面案例中,重定向后,实际上是将句柄给改写到文件中,所以屏幕没有输出任何数据。基于此,标准输入和错误输出的原理也是这样的。
输出重定向
输出重定向一般格式为:[n]>[|]filename,其中n是文件描述符,>|表示覆盖保护绕过机制,不过一般不用,即常用的写法为:[n]>filename。
比如,将字符串abcdef的标准输出重定向到filename中:
echo "abcdef" 1> filename
默认情况下,1>可以缩略写为>,这也是常用的写法:
echo "abcde" > filename
将命令dasdsada的错误结果写入到文件filename中:
dasdsada 2> filename
因为没有这个命令,所以一定会报错-bash: dasdsada: command not found。
对于该重定向而言,它在执行前会清理掉原始内容,从而写入新的内容。
除此之外,还有追加重定向,其格式为[n]>>filename,其中n是文件描述符,>>表示追加,它不会清理掉原有内容。比如将abc追加到文件filename中。
echo 123 1>> filename
同样的,也可以将1>>缩略写为>>。
将命令aaa的错误结果追加写入到文件filename中:
aaa 2>> filename
输入重定向
在linux系统中,任何一个程序的标准输入文件描述符id为0,而输入重定向一般格式为[n]<filename,其中n表示文件描述符。
所以,如下命令:
cat 0< filename
它的意思是将filename文件的内容作为标准输入提供给cat调用,结果是打印了filename的内容,上述命令中的0<可以缩略写为<。
不仅如此,还能在循环中,搭配read,将文件的信息按行读取。
比如有以下文件内容:
# cat filename
aa
bb
cc
dd
#
有脚本内容如下:
#!/bin/bash
while read ll
do
echo read_at: ${ll}
done 0< filename
上述脚本表示将filename文件作为输入源,供while调用,按照的方式是read命令按行调用,将行内容赋值到ll变量上,这个变量名称可以自定义。
其结果为:
# bash test.sh
read_at: aa
read_at: bb
read_at: cc
read_at: dd
#
输入重定向还有一种表达方式是here-documents。表示多行定义输入,其语法为:
command [n]<<word
here doc
word
其中n表示文件操作符,如果省略,则使用默认的文件操作符0,而后是关键字<<,word是自定义的结束符,让后续读取到word后停止读取。
比如:
cat <<END
123
456
789
END
输出的结果为
123
456
789
上述命令中的END是可以替换为任何字符串的,只要后续出现该字符串就会停止输入,注意这个字符,一定是要在单独的一行出现的,不能有其他字符,包括空格。
here-documents会进行命令替换,变量替换等,比如有如下命令:
# x=123
# cat <<END
${x}
$(pwd)
aa
bb
cc
END
输出的结果会先进行命令替换,变量替换等,比如将${x}替换为123,将$(pwd)替换为当前目录。如果不想其被替换,则需要添加单引号进行转义。
重定向的顺序
注意看,如下有2条命令:
ls -l > filename 2>&1
和
ls -l 2>&1 > filename
所执行的结果是完全不一样的,第一条命令是将标准输出和错误输出都写入到filename中,而第二条则是将错误输出输出到屏幕上,而将标准输出写入到文件中,如果不是很明白,可以尝试拆解一下命令:
首先,命令的前提是:标准输出 和 错误输出 都在某个设备上,比如pty终端,假设设备为/dev/pts/0,所以文件描述符信息为:
1 -> /dev/pts/02 -> /dev/pts/0
然后第一条命令,ls -l > filename 其实是将文件描述符1给指向了文件filename,这个时候,文件描述符信息为:
1 -> filename2 -> /dev/pts/0
这个时候再使用2>&1表示将错误消息也写入到文件描述符为1的文件中,即filename,所以最后的文件描述符为:
1 -> filename2 -> filename
而第二条命令,一开始都是输出到某个设备上:
1 -> /dev/pts/02 -> /dev/pts/0
这个时候,它将错误输出指向2>&1,而文件描述符为1是/dev/pts/0,所以这第一步,就没动过,而第二部则是将标准输出指向filename,即:
1 -> filename2 -> /dev/pts/0
所以第二条命令,错误输出输出到屏幕上,而将标准输出写入到文件中。
自定义文件描述符的基本操作
在linux中,会为每一个进程都分配0、1、2 这三个文件描述符,包括使用的终端,而自定义的文件描述符必须从3开始,而linux建议使用3到9这几个文件描述符,当然,这只是建议,你可以不遵守,只要创建的文件描述符id不超过系统允许最大的值即可,在linux系统中使用如下命令查询允许最大的文件描述符:
ulimit -n
当然也可以动态修改它,比如将其修改为65535,只需要在n的后面添加即可:
ulimit -n 65535
创建文件描述符
在linux中,创建一个可读可写的文件描述符,使用<>关键字即可,例如:
exec 3<>/root/a.log
如上命令创建了一个可读可写的文件描述符3,指向/root/a.log,在linux中创建的文件描述符是在/dev/fd目录中,例如:
# ls -l /dev/fd/
total 0
lrwx------. 1 root root 64 Jun 5 23:47 0 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 5 23:47 1 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 5 23:47 2 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 5 23:47 3 -> /root/a.log
lr-x------. 1 root root 64 Jun 5 23:47 4 -> /proc/1852/fd
#
注意,该目录/dev/fd是一个虚拟目录,不同的进程查看该目录会得到不一样的结果,即:该目录下的句柄,只能创建该句柄的进程所使用,其他进程均无法使用。
向文件描述符写入数据
使用重定向写入内容,比如上面文件描述符id为3,即:
echo "123" >& 3
注意,>&是一个关键字,表示向文件描述符写入数据,而3则表示文件描述符id,注意:没有>>&这种写法,这种写法是错误的。
如果要将一个标准错误的数据写入到对应的文件描述符中,需要用到2>&关键字,即:
abcd 2>& 3
都知道没有abcd这个命令,所以解释权会向错误输出报错找不到命令,使用2>&将该报错写入到文件描述符id为3中。
文件描述符读取数据
使用<&关键字可以读取文件描述符的内容,比如:
cat <& 3
可以读取文件描述符id为3的内容,但是实际执行后,你会发现,什么内容都没有,如:
# cat <& 3
#
这是因为linux中有一个叫做文件指针的概念(offset),在向该文件描述符写入内容的时候,已经同步将指针移动了到了最后,所以在读取的时候,什么内容也没有。需要重置offset为开头,即重新设置一下文件描述符:
# exec 3<>/root/a.log
# cat <& 3
123
-bash: abcd: command not found
#
如下即可读取到文件描述符指向文件的内容了。
关闭文件描述符
使用如下命令可以关闭id为3的文件描述符:
exec 3>&-
其中,3>&-不能有任何空格,否则会报错。
关闭后,/dev/fd/文件中就没有相关文件的文件描述符了。
# ls -l /dev/fd/
total 0
lrwx------. 1 root root 64 Jun 5 23:49 0 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 5 23:49 1 -> /dev/pts/0
lrwx------. 1 root root 64 Jun 5 23:49 2 -> /dev/pts/0
lr-x------. 1 root root 64 Jun 5 23:49 3 -> /proc/1293/fd
#
创建只读、只写文件描述符
上述是创建了一个可读写的文件测试服,文件指针会随着写入变化而变化,linux还允许创建只读、只写的文件描述符,比如:
创建只写文件描述符id为5,指向文件/root/a1.log,命令如下:
# exec 5> /root/a1.log
可以使用查看/dev/fd/5权限,如下:
# ls -l /dev/fd/5
l-wx------. 1 root root 64 Jun 5 23:49 /dev/fd/5 -> /root/a1.log
#
其中它的权限为-wx,只有写和执行权限,没有读权限。
所以,若读取的话,会报错:
# cat <& 5
cat: -: Bad file descriptor
#
创建只读文件描述符id为6,同样指向文件/root/a1.log,命令如下:
# exec 6< /root/a1.log
同样的,该文件权限只有读和执行权限,没有写权限。
# ls -l /dev/fd/6
lr-x------. 1 root root 64 Jun 5 04:27 /dev/fd/6 -> /root/a1.log
#
这个时候使用文件描述符5来写数据,使用文件描述符6来去读数据。
# echo '123' >& 5
# cat <& 6
123
# cat <& 6
#
读取完成后,文件指针会移动到读取后的位置,所以重复读取是没有用的。
文件描述符小例子
使用命名管道来限制多进程同时执行,代码如下:
#!/bin/bash
mkfifo pipe
exec 3<>./pipe
for i in $(seq 2)
do
echo "${i}" >&3
done
for i in $(seq 10)
do
{
read -u 3 id
echo id: ${id} time: $(date +"%F %T") id: ${i}
sleep 3
echo ${id} >&3
}&
done
exec 3>&-
wait
rm -f pipe
echo "done"
上述脚本利用了管道来限制多进程同时执行,首先使用mkdifo是用来创建管道,而后定义了一个文件描述符id为3来指向该管道文件,第一个for循环表示允许同时最多几个进程运行,上述定义的是2个,而后定义了10个进程来同时运行,使用read -u来读取文件描述符指向文件的内容,并且写入id变量,当read读取不到管道数据数据的时候,会阻塞当前进程,由于提前写入了2个数据,所以,只允许2个进程同时运行,并且执行完毕后,将id重新写入到管道中,以便实现循环读取。最后关闭文件描述符,删除管道文件。
所以上述脚本执行结果如下:
[root@localhost bash]# bash fd_test.sh
id: 1 time: 2025-06-05 23:47:46 id: 7
id: 2 time: 2025-06-05 23:47:46 id: 3
id: 1 time: 2025-06-05 23:47:49 id: 2
id: 2 time: 2025-06-05 23:47:49 id: 4
id: 1 time: 2025-06-05 23:47:52 id: 6
id: 2 time: 2025-06-05 23:47:52 id: 5
id: 1 time: 2025-06-05 23:47:55 id: 1
id: 2 time: 2025-06-05 23:47:55 id: 8
id: 1 time: 2025-06-05 23:47:58 id: 10
id: 2 time: 2025-06-05 23:47:58 id: 9
done
[root@localhost bash]#
可以通过输出发现,同一时间只有2个任务在同时运行。
总结
简单介绍了一下文件描述符,这是一个非常有意思的事情,将其理解为一个不透明的句柄就可以了。linux操作系统为每一个进程都创建了3个文件描述符,分别是标准输入:0、标准输出1和错误输出2。如果程序后期打开了新的文件,则会在进程描述符下创建新的fd文件,比如:/proc/进程Id/fd下,linux下一切皆文件,甚至是tcp句柄,比如:
exec 3<>/dev/tcp/127.0.0.1/80
这个命令表示创建了一个读写的文件描述符,id为3,指向/dev/tcp/127.0.0.1/80这个文件,这个文件表示建立一个127.0.0.1:80的tcp连接。
echo -e "GET / HTTP/1.1\r\n\r\n" >&3
向这个文件描述符写入简单的http请求报文。
cat <&3
读取文件描述符所对应的文件内容,即服务器返回的响应报文。
后面简单介绍了一下标准输出和错误输出,如果理解了文件描述符,那么理解标准输出和错误输出就不成问题了。
要理解输入输出重定向,一定要理解文件的描述符!对于每个进程,都会产生3个默认的文件描述符,分别是 标准输入、标准输出 和 错误输出,用文件描述符id:0、1、2 来表示。
其重定向就是修改这些文件描述符的指向,将他们指向到文件中即可。
对于输入重定向而言,有一种表达方式是here-documents,它可以和输出重定向结合,将终端中输入的文件写入到文件中,比如:
cat 1>> filename 0<<END
a
b
c
d
e
END
表示将终端输入的a、b... e字符提供给cat命令,而cat将内容重定向到文件filename中。可以发现上面有1>>表示将标准输出的内容重定向到某个文件,0<<表示将标准输入的内容重定向到某个文件,上述命令中的1>>可以缩写为>>,而0<<可以缩写为<<。
在linux中,每个进程打开文件、建立网络连接,其实都是在文件描述符中增加相应的id,若超过系统设置的值,则会报错Too many open files。
在linux中,可以手动指定文件描述符,相关操作如下,比如创建id为3的文件描述符各项操作:
创建只读的文件描述符:
exec 3<filename
创建只写的文件描述符:
exec 3>filename
创建可读写的文件描述符:
exec 3<>filename
进行文件描述符读取操作:
<&3
进行文件描述符写入操作:
>&3
最后是关闭文件描述符:
3>&-
最后介绍了一个小例子,使用文件描述符配合管道来实现控制shell脚本的同时运行。