Virtual Filesystem이란
리눅스를 사용하면 다양한 형식으로 포맷된 디스크를 사용할 수 있다. 보통 리눅스어세는 ext2, ext3, ext4를 사용하지만 윈도우에서 사용하는 NTFS나 FAT같은 디스크도 사용할 수 있다. 그런데, 어떤 디스크를 사용하던간에 관계 없이 프로그램을 작성할 땐 open, read, write, close와 같은 시스템 호출을 사용해서 이 모든걸 처리할 수 있다. 지금은 이게 모두 당연하지만, 예전에는 그렇지 않았다. 실제 파일 시스템이 무엇이냐에 관계 없이 공통된 인터페이스 (open/read/write/close 등)로 접근하는 것은 매우 어려운 일이었다. 이렇듯 리눅스에서, 실제 파일시스템에 관계 없이 공통된 인터페이스로 파일시스템에 접근하도록 하는 계층을 가상 파일시스템(Virtual Filesystem Switch, VFS)이라고 한다. 리눅스는 많은 것을 파일로 구현한다. 디바이스 드라이버를 만들어도, procfs를 사용하는 모듈을 만들어도 VFS에 대해서 대략적으로라도 알면 도움이 된다.
관련 자료구조
이번 섹션에서는 VFS에서 파일시스템, 파일 등을 나타내기 위한 자료구조를 살펴볼 것이다. 이 자료구조들은 매우 방대해서 이 글 하나로 모든 것을 설명할 수는 없다. 이 글에서는 각 자료구조의 역할이 무엇인지를 간단히 짚고 넘어갈 것이다. Documentation/filesystems/vfs.rst에서 자세한 설명을 볼 수 있다.
VFS와 상속
C에 OOP의 개념을 완벽하게 동일하게 적용할 수는 없겠지만, 리눅스 커널에서는 이를 다양한 방법으로 이를 구현했다. VFS는 리눅스에서 대표적으로 OOP 개념이 적용된 서브시스템이다. C++, Java와 같은 객체 지향 언어를 공부해보았다면 알겠지만, VFS 자체는 추상 클래스와 비슷하다고 할 수 있다. 예를 들어 open()의 동작은 실제 파일시스템(ext2, ext3, ...)에 다르므로 각각 구현해주어야 한다. 하지만 함수의 호출은 VFS를 통해 이루어지므로 VFS에도 open()함수가 어디에 구현되었는지 알고 있어야 한다. 실제로 VFS는 파일시스템이 open()을 구현한 경우 파일시스템의 open을 수행한다.
아래에서 설명할 inode, super_block, dentry 모두 이러한 방식으로 자식(?)에서 함수를 구현할 수 있도록 inode_operations, super_operations, dentry_operations와 같은 구조체에 함수 포인터를 저장할 수 있도록 한다. 예를 들어 우리가 파일에 read, write, open 등의 함수를 호출했을 때 호출될 함수는 file_operations에 함수 포인터의 형태로 저장되어있으며, 이는 각 파일시스템에서 구현해야 한다. 어떤 파일에 대해 read를 호출하려면 이런 식으로 호출하면 된다. file->f_ops->read(file, ...(파라미터 생략)...)
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iopoll)(struct kiocb *kiocb, bool spin);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;
물론 매번 모든 함수를 초기화할 필요는 없다. 파일시스템에서 구현하지 않는 함수들은 NULL로 초기화할 수도 있다. C89에 경우 구현하지 않는 함수를 모두 NULL로 초기화해주어야 했지만 C99부턴 아래처럼 일부 포인터만 초기화하고 나머지는 0으로 초기화할 수 있다.
struct proc_ops p_ops = {
.proc_read = seq_read,
.proc_open = device_open,
.proc_release = seq_release,
.proc_lseek = seq_lseek,
};
struct super_block (include/linux/fs.h)
마운트된 파일시스템을 나타내는 자료구조이다. 일반적인 디스크 기반 파일시스템의 경우에는 디스크 상에 슈퍼블록과 대응되는 데이터가 저장되어있으며, 마운트 될때마다 super_block이 하나씩 할당될 것이다. 그렇지 않은 가상 메모리 기반 파일시스템 (sysfs, debugfs, procfs 등)은 디스크에서 불러오는 것이 아니라 실행 중에 동적으로 슈퍼블록을 생성한다.
super_block의 super_operations에는 sync_fs (메모리 상에서는 업데이트 되었지만 디스크엔 반영이 안된 내용을 동기화), alloc_inode (inode 할당) 등 파일시스템 수준에서 작동하는 함수들이 정의되어있다.
struct inode (include/linux/fs.h)
inode는 디스크 상의 파일과 디렉토리를 나타낸다. 리눅스에서 디렉토리는 조금 특별한 파일일 뿐이다. 디렉토리와 파일은 모두 inode로 표현된다. inode_operations에선 mkdir, rmdir, rename과 같은 파일 or 디렉토리와 관련된 함수와, 심볼릭 링크 관련 명령 등이 정의되어있다. 아쉽게도 open / read / write / close와 같은 함수들은 구현되어있지 않다. 그건 file 구조체에 구현되어있다. inode는 "디스크 상의 파일"을 나타낸다. 디스크 상의 파일을 open한, "열린" 파일은 file 구조체로 표현된다. 단순하게 생각해보면 디스크 상의 파일은 하나여도 이를 여러 번 open할 수 있으니 열린 파일에 대한 구초제가 필요하다는 걸 알 수 있다.
struct file (include/linux/fs.h)
file은 말 그대로 파일을 나타낸다. 정확하게는 "열린" 파일을 나타낸다. 우리가 open으로 연 파일들이 열린 파일이다. file에는 실제로 어떤 파일을 나타내는지에 대한 dentry 포인터, 어디까지 읽었는지 저장하는 f_pos, 현재 파일을 참조하는 횟수 등이 저장되어있다. file_operations에는 아까 이야기했던 open / read / write / close / unlocked_ioctl 등 열린 파일에 대한 연산이 정의되어있다.
struct dentry (include/linux/dcache.h)
dentry는 파일의 경로를 inode로 변환하기 위해 사용되는 구조이다. dentry는 디스크 상에 저장되는 어떤 객체를 나타내지는 않는다. 단지 성능 향상을 위해 사용되는 구조이다. 예를 들어 "/hello/world"라는 경로가 있다면 루트 디렉토리 "/", "hello", "world"는 모두 덴트리 객체이다. 덴트리 객체는 부모, 자식 덴트리 객체를 포인터로 갖고있으며, 현재 덴트리가 어떤 inode를 가리키는지 포인터로 저장한다. VFS에서는 이처럼 트리 구조로 덴트리를 저장함으로써 파일의 경로로부터 inode를 빠르게 변환할 수 있도록 한다. 아, 그리고 당연히 dentry로 탐색을 하려면 root dentry에 대한 포인터를 알고 있어야 한다. 그건 super_block에 저장되어있다.
Object relationships
지금까지 살펴본 super_block, file, dentry, inode의 관계를 살펴보면 다음과 같다. (file은 inode가 아니라 dentry를 가리키고 있다.)
참고 문서
'Kernel' 카테고리의 다른 글
[Linux Kernel] proc 파일시스템과 seq_file 인터페이스 (1) | 2021.04.18 |
---|---|
[LInux Kernel] 문자 디바이스 드라이버 작성 (5) | 2021.04.17 |
[Linux Kernel] 리눅스 커널 모듈 작성 (0) | 2021.04.13 |
리눅스 커널에 커밋 해보자 (0) | 2021.03.14 |
리눅스 커널 입문에 도움이 될만한 것들 (0) | 2020.01.02 |
댓글