2022. 9. 19. 01:34ㆍ개발 관련 책 읽기/리눅스 커널 내부구조
가상 파일시스템(Virtual File System)
태스크는 어떻게 파일시스템에 접근할까? 태스크는 open(),read(),write(),close() 등의 시스템 콜을 사용해 파일시스템에 접근한다. 파일시스템 자체는 특정 운영체제에 구애받지 않고(심지어 운영체제가 없는 상황에서도) 동작 가능한 독립적인 구조로 설계되고 구현되어야 한다.
사용자 태스크는 자신이 접근하는 파일이 어느 파일시스템에 저장되어 잇는지 굳이 알 필요가 없이 일관된 POSIX 표준 인터페이스만을 사용하는 것이 가능해진다.
사용자 태스크는 VFS라는 가상적인 '파일시스템만'을 알고 있으면 충분하다. 따라서 사용자 태스크 입장에서 본다면 VFS는 가상적이긴 하지만 실제 접근을 하게 되는 '파일시스템'인 것이다.
이 가상적인 파일시스템인 VFS는, 모든 파일시스템에서 파일 당 하나씩 주어진다고 했던 디렉터리 엔트리 구조체가 어떻게 생겼을까? 또한 VFS는 다양한 파일시스템과 데이터를 주고받아야 하는데, 이들은 어떻게 관리가 될까? 이를 위해 리눅스는 VFS 내에 4가지 객체를 도입하였으며, 사용자 태스크에게 일관된 인터페이스를 정의하였다.
1.수퍼 블록 객체이다. 수퍼 블록 객체는 현재 사용 중인(마운트 된) 파일시스템 당 하나씩 주어진다.실제로 각 파일시스템은 자신이 관리하고 있는 파티션에 파일시스템 마다 고유한 정보를 수퍼 블록에 저장해 둔다.VFS는 이를 읽어서 관리하기 위해 범용적인 구조체인 수퍼 블록 객체를 정의한다.
2.아이노드 객체이다.특정 '파일'과 관련된 정보를 담기 위한 구조체이다. 각 파일시스템은 파일을 저장하기 위해 각자 정의한 메타데이터를 저장해 놓을 것이다. VFS가 아이노드 객체를 생성하고 파일시스템에 특정 파일에 대한 정보를 요청하면 파일시스템은 자신이 관리하고 있는 영역에서 파일의 메타데이터를 읽어서 아이노드 객체에 채워준다.만약 msdos F/S이라면 해당 파일의 디렉터리 엔트리를 읽어서 아이노드 객체를 채울 것이고, ext2 F/S이라면 해당 파일의 디렉토리 엔트리와 inode를 찾아서 객체를 채워 줄 것이다.(VFS의 inode객체와 Ext2,3에서 디시크에 기록해 둔 inode와는 다른 것이다)
3.파일 객체이다.이 객체는 태스크가 open한 파일과 연관되어 있는 정보를 관리한다. 만약 두 개의 태스크가 한 개의 파일에 동시에 접근하는 경우를 가정해 보자.물리적으로 한 개의 파일이기 때문에 VFS는 하나의 아이노드 객체를 만들어 유지할 것이다.이 때 두 태스크가 접근하는 위치같은 정보는 태스크 마다 다르게 유지해야 한다. 이러한 태스크와 연관된 정보를 유지하는 용도로 사용하는 것이 파일 객체이다.이 객체는 각 태스크가 아이노드 객체에 접근하는 동안만 메모리상에 유지되는 구조체이다.
4.디엔트리 객체이다.태스크가 파일에 접근하려면 해당 파일의 아이노드 객체를 자신의 태스크와 연관된 객체인 파일 객체에 연결시켜야 한다. 이때 이 관계를 조금 더 빠르게 연결하기 위한 일종의 캐시 역할을 하는 것이 디엔트리 객체이다.
VFS과 task_struct를 연결해 보자. 태스크가 ext2 F/S을 사용하려(마운트)한다고 가정해보자
사용자의 마운트 요청을 받은 VFS는 ext2의 마운트 함수를 호출하면서 인자로 빈 수퍼 블록 객체를 하나 넘긴다. 그러면 ext2는 자체적으로 구현한 F/S internal Function을 이용하여, 파티션 앞부분에 기록해 두었던 수퍼블록 구조체를 읽은 뒤 이를 바탕으로 VFS가 넘긴 수퍼 블록 객체의 내용을 채워서 리턴 해준다.
위의 그림은 sys_open() 시스템 콜을 요청으로 인한 처리 과정을 보여준다.
VFS는 빈 아이노드 객체를 인자로하여 ext2내부의 open 함수를 호출한다. 그러면 ext2는 자체적으로 구현한 파일시스템 내부 함수를 이용해, 필요한 정보를 아이노드 객체에 채워서 리턴한다.그러면 VFS는 이 아이노드 객체를 디엔트리 객체에 연결시켜서 사용자의 태스크 구조와 연결해준다.
task_struct에는 files라는 이름의 변수가 있으며,이 변수는 files_struct라는 자료구조를 가리킨다 (struct files_struct files)
files_struct에는 fd_array라는 이름의 변수가 존재하는데 유닉스 계열 운영체제에서는 일반적으로 이것을 fd 또는 file descriptor라고 부른다.이떄 이 fd가 fd_array 배열의 인덱스로 사용되는 것이다. 이 변수는 file이라는 자료구조에 대한 포인터를 갖는 배열이다(struct file* fd_array[])
일반적으로 fd_array[0]은 표준 입력(stdin)으로 설정되며 fd_array[1]은 표준 출력(stdout) fd_array[2]는 표준 에러 출력(stderr)으로 설정된다. 그리고 그다음부터는 태스크가 새로운 파일을 오픈할 때마다 할당되어 사용된다. 한편 files_struct라는 자료구조에는 max_fds 변수가 있으며 이것은 한 태스크가 오픈할 수 있는 파일의 최대 개수를 의미한다.
fd_array[]의 각 항들은 파일 객체(struct file)을 가킨다.흔히 파일 테이블이라 불린다. 즉, 사용자 태스크에게 fd라는 정수로써 접근 할 수 있도록 추상화 계층(abstract Layer) 제공하는 역할을 한다.
파일 객체에는 여러 변수가 있으며 그 중에 중요한 것은 f_dentry,f_pos그리고 f_op이다. f_dentry는 디엔트리 객체를 가리키며, 디엔트리 객체는 다시 아이노드 객체를 가리킨다. 이때 디엔트리 객체는 캐시역할을 한다. f_pos는 현재 파일에서 읽거나 쓸 위치를 나타낸다.파일이 처음 오픈되면 이 값은 0으로 설정되며 읽기/쓰기 연산이 진행됨에 따라 f_pos가 읽혀진(또는 쓰여진)크기만큼 이동하게 된다.(lseek시스템 콜을 이용해 f_pos위치를 바꿀 수도 있다)
파일 객체에서 꼭 알아두어야 할 주요한 변수 중의 하나는 f_op이다. f_op는 file_operation라는 자료구조를 가리키는 포인터이다. 간단히 설명하자면 파일 연산이 요청되면 커널은 현재 요청된 파일이 어떤 파일 유형인가를 보고,각 파일에 적합한 파일 고유 함수를 사용하여 서비스를 제공해야 하는데 이때 사용되는 구조가 file_operation이다. 즉 , 각 파일 유형에 적합한 파일 연산들이 변수에 등록되는 것이다.
아이노드 객체는 파일 당 하나씩 주어진다. 이 객체에는 해당 파일과 관련된 아주 많은 정보가 들어있다.
i_dev는 inode가 실제 존재하고 있는 파일시스템의 위치를 (정확히 디스크 파티션) 나타낸다. i_rdev는 파일이 장치 파일인 경우 관련되어 있는 디바이스 드라이버의 주번호를 나타낸다.i_ino는 inode의 고유한 번호를 나타내며,i_mode는 이 inode가 관리하는 파일의 속성 및 접근 제어 정보를 유지한다.
i_nlink는 이 inode를 가리키고 잇는 파일 수를 의미하고, i_size는 이 아이노드 객체에 해당되는 파일의 크기를 의미한다. 아이노드 객체에는 i_op라는 변수가 있는데 이것 역시 inode_operation라는 자료구조를 가리키는 포인터로 VFS에서 중요한 역할을 한다.
사용자가 create(),mkdir()등 파일시스템의 메타데이터와 관련된 연산을 요청하면 커널은 요청한 연산이 어떤 파일시스템에서 발생했는지 파악하고, 적절한 파일시스템 고유한 함수를 사용하여 서비스를 제공해야 하는데 이때 사용하는 구조가 inode_operation이다.
file_operation와 inode_operation는 함수 포인터들을 필드로 갖는다. 리눅스가 다양한 파일과 파일 시스템을 지원할 수 있는 비밀이 바로 이 두 자료 구조에 있다.
파일시스템 제어 흐름 분석
sys_open() 시스템 콜의 제어 흐름을 분석해 보도록 하자.
1.파일 이름 인자로 sys_open()이 호출되면 파일시스템은 요청된 파일에 대한 inode를 찾는다.
그리고 태스크 구조와 VFS의 객체를 연결한다.
2.sys_open함수는 filp_open()이라는 커널 내부 함수를 호출한다.filp_open() 함수는 인자로 전달된 파일 이름과 디렉터리 구조를 이용해 그 파일에 대응되는 아이노드 객체를 찾아내 리턴한다.
이때 파일 구조의 f_op변수도 초기화되는데, f_op에는 각 파일의 유형에 따라 적합한 파일 연산으로 등록된다.( f_op는 struct file_operations라는 자료 구조이며,file_operations에는 lseek,read,write open 등과 같은 파일 관련 함수를 나타내는 변수들로 구성된다.그리고 각 변수에는 실제 연산을 수행 하는 함수의 시작 주소가 등록된다.)
3.파일 유형에 맞는 파일 연산을 file->f_op->open() 함수를 호출한다. 만일 사용자가 요청한 파일이 ext2에 속한 정규 파일이었다면,ext2가 제공하는 open 함수를 호출할 것이다. NFS일 경우 nfs_file_open()함수가 호출되며 NFS 파일 서버와 클라이언트 간에 RPC를 위한 통롤를 만든다. 파이프일 경우 fifo_open()이 호출되어 파이프를 위한 공간을 할당하며, 장치 파일일 경우 문자 장치인지 블록 장치인지에 따라 chrdev_open(),blkdev_open()이 호출된다. 결국 특정 파일에 고유한 open함수가 호출된다
4.특정 파일에 고유한 open함수까지 호출하고 나면 filp_open()함수는 리턴된다, 그럼 sys_open()함수는 태스크에서 현재 사용하지 않는 파일 디스크립터(fd_array)의 한 항을 할당하고, 이 항이 생성된 파일 객체를 가리키도록 설정한다. 결국 태스크 구조와 VFS객체를 연결하게 되는것이다.
새로운 파일시스템은 어떻게 리눅스와 연결시킬 수 있을까?
파일시스템을 리눅스에 연결하려면 file_operations 구조와 inode_operations 구조도 작성하여 커널에 등록해야한다.이들을 커널에 등록하기 위한 커널 내부 함수가 register_filesystem()이다.
register_filesystem함수는 struct file_system_type이라는 자료구조를 인자로 받는다.이 자료구조에는 파일시스템의 이름을 나타내기 위한 name,속성을 위한 fs_flags,수퍼 블록을 읽어 파티션을 마운트하는 함수의 포인터를 담기위한 mount,그리고 복수개의 file_system_type 구조를 연결하기 위한 리스트와 모듈 정보 등이 들어 있다.
register_filesystem()을 이용해 커널에 등록된 파일시스템은 하나의 file_system_type 자료구조를 갖게 되며,커널에 존재하는 모든 file_system_type 구조들은 리스트로 연결된다.이 리스트의 시작은 file_systems라는 커널 내 전역 변수가 가리킨다.
특정 파일시스템에 대한 마운티 요청시,커널은 file_systems(파일시스템 리스트 헤드)에서 시작되는 리스트를 검색하여 요청된 파일시스템의 file_system_type 자료구조를 찾는다. 그리고 get_sb에 기록된 함수를 호출하여 파일시스템의 수퍼 블록 정보를 얻어 와서 VFS의 수퍼 블록 객체에 저장해 놓는다.
수퍼 블록을 읽으면 해당 파일시스템의 자세한 정보를 얻어 올 수 있다. 따라서 파일시스템이 제공하는 inode-operations나file_operations 같은 구조를 접근할 수 있게 되는 것이다.
결국 새로운 파일시스템을 구현하는 과정은 수퍼 블록과 관련된 수퍼 블록 연산,inode_operations 구조체와 관련된 연산, file_operations 구조체와 관련된 연산등을 작성하는 것이다.
태스크는 시스템 콜 인터페이스를 통해 VFS와 통신한다. 태스크가 원하는 내용은 커널 내부의 캐시인 페이지 캐시에 존재할 수도 있고 아닐 수도 있다. 페이지 캐시에 존재하면 정보를 바로 제공해 줄수 있으며, 페이지 캐시에 존재하지 않으면 실제 I/O가 일어나게 된다. 이 때 디엔트리 객체와 아이노드 객체를 위한 캐시를 관리함으로써 성능을 향상시킨다.(위의 그림을 참고하도록)
VFS는 태스크와 VFS내에 존재하는 객체의 연결 관계를 이용하여 적절한 inode_operations구조체와 file_operations 구조체 내의 함수를 호출한다.따라서 마운트 되어 사용 중인 파일시스템은 가상화되어 사용자에게 일관된 인터페이스로 보이게 된다. 파일시스템이 관리하고 있는 공간은 커널내의 일반 블록 계층(Generic Block Layer)의 gendisk와 연관이 있다.
실제 디스크 I/O를 해야 하는 시점이 되면 해당 gendisk를 관리하는 블록 디바이스 드라이버에게 I/O요청을 보내야 한다. 이떄 성능 향상을 위해 리눅스는 자체적인 디스크 I/O 스케줄링 알고리즘을 통해 요청을 보낸다.
'개발 관련 책 읽기 > 리눅스 커널 내부구조' 카테고리의 다른 글
Chapter 6 - Interrupt/Trap & System call(2) (0) | 2022.09.19 |
---|---|
Chapter 6 - Interrupt/Trap & System call(1) (0) | 2022.09.19 |
Chapter 5 - 파일 시스템(2) (0) | 2022.09.19 |
Chapter 5 - 파일 시스템(1) (1) | 2022.09.19 |
Chapter 4 - 메모리 관리(3) (1) | 2022.09.19 |