Home 왜 리눅스는 모든 것은 파일로 취급할까?
Post
Cancel

왜 리눅스는 모든 것은 파일로 취급할까?


리눅스를 공부하다보면 모든 것은 파일이다 라는 말을 보게 되는데요. 최근 리눅스는 왜 모든 것을 파일로 취급하는지에 대해 궁금증이 들었고, 이 과정에서 알게 된 내용을 정리하기 위해 글을 작성하게 되었습니다.

아직 작성 중입니다.






1. “모든 것은 파일이다”의 의미


리눅스에서 Everything is a file 이라는 말은 구현의 동일성 이 아니라, 인터페이스의 통일성 을 의미합니다. 내부 구현을 보면 일반 파일, 소켓, 파이프, 디바이스는 서로 전혀 다른 자료구조와 처리 프로세스를 가지지만, 사용자 공간에서 바라봤을 때는 같은 방식으로 다룰 수 있도록 설계 되었기 때문입니다.

  1. representing objects as file descriptors instead of alternatives like abstract handles or names,
  2. operating on the objects with standard input/output operations, returning byte streams to be interpreted by applications (rather than explicitly structured data), and
  3. allowing the usage or creation of objects by opening or creating files in the global filesystem name space.



모든 자원이 파일이라는 공통된 추상화 뒤에 숨겨져 있으며, 동일한 시스템 콜과 파일 디스크립터 모델 로 접근할 수 있다는 의미죠. 이는 구현을 단순화하기 위한 선택이 아니라, 사용자 공간과 커널 공간 사이의 경계를 깔끔하게 유지 하기 위한 리눅스 설계 철학에 가깝습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
[ User Space ]
       app / shell
            |
            |  read(), write(), open()
            |    (동일한 system calls)
            v
====================  system call  ====================
[ Kernel Space ]
            |
            |
            v
       file descriptor
            |
            v
    common file abstraction
            |
            v
file | socket | pipe | device




리눅스와 같은 운영체제의 중요한 역할 중 하나는 서로 다른 자원을 일관된 방식으로 접근하게 만드는 것 인데요. 예를 들어, 파일을 읽을 때는 readFile( ), 소켓은 recv( ), 디바이스는 또 다른 API로 접근해야 했다면, 사용자 프로그램은 자원마다 서로 다른 호출 방식과 처리 흐름을 구현해야 합니다. 하지만 유닉스 계열 OS는 모든 자원을 실제 구현과 무관하게 파일 디스크립터를 통한 읽기·쓰기 모델로 노출함으로써, 사용자 공간과 커널 공간 사이의 인터페이스를 일관되게 유지했습니다.

1
2
3
4
5
6
static struct file_operations fops = {
    .read    = etx_read,
    .write   = etx_write,
    .open    = etx_open,
    .release = etx_release,
};





2. 구현은 진짜 다를까?


사용자 공간에서는 항상 동일한 시스템 콜을 사용하지만, 커널 내부에서는 파일 디스크립터가 struct file 을 거쳐 inode, 소켓, 파이프 등 자원 종류에 맞는 서로 다른 커널 객체로 연결됩니다. 리눅스 커널은 struct file_operations 을 통해 같은 read 호출이라도 대상 자원에 따라 서로 다른 함수 경로를 실행하며, 일반 파일은 페이지 캐시와 디스크 I/O로, 소켓은 네트워크 스택으로, 파이프는 커널 버퍼 큐로 흐르게 됩니다. 진짜인지 실제 리눅스 코드를 살펴보죠.




2-1. 파일

리눅스에서 VFS는 파일 연산을 위해 struct file_operations 라는 추상 인터페이스를 정의하고, ext4는 이 인터페이스에 대응하는 실제 함수 집합을 ext4_file_operations 로 제공합니다. 파일이 open될 때 해당 구조체가 struct file의 f_op에 연결되며, 이후 read( )와 같은 시스템 호출은 file->f_op를 통해 ext4의 구현 함수로 디스패치됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const struct file_operations ext4_file_operations = {
	.llseek		= ext4_llseek,
	.read_iter	= ext4_file_read_iter,
	.write_iter	= ext4_file_write_iter,
	.iopoll		= iocb_bio_iopoll,
	.unlocked_ioctl = ext4_ioctl,
#ifdef CONFIG_COMPAT
	.compat_ioctl	= ext4_compat_ioctl,
#endif
	.mmap_prepare	= ext4_file_mmap_prepare,
	.open		= ext4_file_open,
	.release	= ext4_release_file,
	.fsync		= ext4_sync_file,
	.get_unmapped_area = thp_get_unmapped_area,
	.splice_read	= ext4_file_splice_read,
	.splice_write	= iter_file_splice_write,
	.fallocate	= ext4_fallocate,
	.fop_flags	= FOP_MMAP_SYNC | FOP_BUFFER_RASYNC |
			  FOP_DIO_PARALLEL_WRITE |
			  FOP_DONTCACHE,
};




이 과정에서 일반 파일의 읽기·쓰기는 페이지 캐시와 블록 I/O 경로로 이어지며, VFS는 파일시스템의 내부 구현을 알지 못한 채 동일한 인터페이스만을 통해 동작을 위임합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static ssize_t ext4_file_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
	struct inode *inode = file_inode(iocb->ki_filp);

	if (unlikely(ext4_forced_shutdown(inode->i_sb)))
		return -EIO;

	if (!iov_iter_count(to))
		return 0; /* skip atime */

#ifdef CONFIG_FS_DAX
	if (IS_DAX(inode))
		return ext4_dax_read_iter(iocb, to);
#endif
	if (iocb->ki_flags & IOCB_DIRECT)
		return ext4_dio_read_iter(iocb, to);

	return generic_file_read_iter(iocb, to);
}




2-2. 소켓

소켓 역시 사용자 공간에서는 read()나 write()로 접근하지만, 커널 내부에서는 파일시스템이 아닌 네트워크 스택으로 연결됩니다. 소켓은 생성 시 VFS에 struct file로 감싸져 등록되며, file_operations 구현체를 통해 일반 파일과 동일한 인터페이스 흐름에 편입됩니다.

1
2
3
4
5
6
7
8
static const struct file_operations socket_file_ops = {
	.owner		= THIS_MODULE,
	.read_iter	= sock_read_iter,
	.write_iter	= sock_write_iter,
	.poll		= sock_poll,
	.unlocked_ioctl = sock_ioctl,
	.release	= sock_close,
};




사용자 공간에서 read()를 호출하면, VFS는 socket_file_ops에 연결된 구현 함수를 호출하고, 이후 흐름은 네트워크 계층으로 넘어갑니다. 이후 호출은 프로토콜 계층(TCP/UDP)으로 전달되며, 일반 파일과는 완전히 다른 실행 경로를 따르게 됩니다.

1
2
3
4
5
static ssize_t sock_read_iter(struct kiocb *iocb, struct iov_iter *to)
{
	return sock_recvmsg(iocb->ki_filp->private_data,
			    iocb, to, iov_iter_count(to), 0);
}




2-3. 파이프

파이프는 디스크나 네트워크를 거치지 않고, 커널 내부 버퍼를 통해 데이터를 전달하는 자원입니다. 하지만 접근 방식 자체는 동일하게 read()와 write()를 사용하며, 이 역시 file_operations 구현체로 연결됩니다.

1
2
3
4
5
6
7
8
/* fs/pipe.c */
const struct file_operations pipefifo_fops = {
.open		= pipe_open,
.read_iter	= pipe_read,
.write_iter	= pipe_write,
.poll		= pipe_poll,
.release	= pipe_release,
};




파이프에 대한 read() 호출은 디스크 I/O가 아닌, 커널이 관리하는 파이프 버퍼 큐를 대상으로 동작합니다. 즉, 같은 read() 호출이라도 실제 데이터 흐름은 커널 내부 메모리 큐로 제한됩니다.

1
2
3
4
static ssize_t pipe_read(struct kiocb *iocb, struct iov_iter *to)
{
/* pipe_buffer에서 데이터 복사 */
}




2-4. 캐릭터 디바이스

캐릭터 디바이스(Character Device)는 디스크나 네트워크가 아닌 드라이버가 직접 처리하는 자원이지만, 사용자 공간에서는 동일하게 read()와 write()로 접근합니다. 디바이스 드라이버는 struct file_operations를 구현해 VFS에 등록되며, 이로써 파일·소켓과 동일한 인터페이스 흐름에 편입됩니다.

1
2
3
4
5
6
static const struct file_operations etx_fops = {
.open		= etx_open,
.read		= etx_read,
.write		= etx_write,
.release	= etx_release,
};




사용자 공간에서 read()를 호출하면, VFS는 해당 파일 디스크립터에 연결된 etx_fops를 따라 드라이버 구현 함수를 호출합니다. 이 경우 데이터 흐름은 파일시스템이나 네트워크를 거치지 않고, 드라이버 코드 내부 로직이나 하드웨어 접근으로 바로 이어집니다.

1
2
3
4
5
static ssize_t etx_read(struct file *filp, char __user *buf,
size_t len, loff_t *off)
{
/* device-specific logic */
}




2-5. 블록 디바이스

블록 디바이스(Block Device) 역시 사용자 공간에서는 일반 파일과 동일하게 read()와 write()로 접근되지만, 커널 내부에서는 블록 계층과 I/O 스케줄러를 거치는 전혀 다른 경로를 따릅니다. 블록 디바이스는 공통적인 file_operations 구현체를 통해 VFS에 연결됩니다.

1
2
3
4
5
6
7
/* block/fops.c */
const struct file_operations def_blk_fops = {
.open		= blkdev_open,
.release	= blkdev_close,
.read_iter	= blkdev_read_iter,
.write_iter	= blkdev_write_iter,
};




블록 디바이스에 대한 read() 호출은 페이지 캐시 이후 블록 계층으로 전달되며, 요청 큐와 I/O 스케줄러를 거쳐 실제 디바이스 드라이버로 내려갑니다. 즉, 인터페이스는 동일하지만 내부에서는 파일시스템과는 다른 블록 I/O 전용 실행 경로가 사용됩니다.

1
2
3
4
5
static ssize_t blkdev_read_iter(struct kiocb *iocb,
struct iov_iter *to)
{
/* block layer I/O path */
}





이런 설계 덕분에 우리는 파일, 소켓, 파이프, 디바이스를 조합해도 동일한 방식으로 다룰 수 있고, 리다이렉션이나 파이프라인 같은 기능도 자연스럽게 동작합니다. 내부 구현은 각자 최적화된 형태로 유지하면서도, 외부에서는 하나의 일관된 모델을 제공하는 것, 이것이 리눅스가 “모든 것을 파일로 취급한다”고 말하는 진짜 이유입니다.

1
2
3
4
5
6
static struct file_operations fops = {
    .read    = etx_read,
    .write   = etx_write,
    .open    = etx_open,
    .release = etx_release,
};





3. 정리


리눅스에서 모든 것은 파일이다 라고 했을 때, 사실 인터페이스를 사용하겠지 정도만 생각했습니다. 그런데 내부가 이렇게 까지 추상화 잘 돼 있을지 몰랐네요.


This post is licensed under CC BY 4.0 by the author.