리눅스에서 사용 가능한 파일 시스템은 다양하게 존재한다.
먼저, 왜 다양하게 존재하는 걸까? 그 이유는 계속 발전되어왔기 때문이기도 하며, 각 파일시스템마다 장단점이 다르기 때문이기도 하다.
예를들어, 일반적인 데스크탑에선 다양한 시스템에 적용될 수 있는 범용 목적의 기능들이 포함되며, HDDs SDDs 를 위해 최적화 되어 있다. 더 큰 파일 시스템과, 더 많은 범위에 파일 사이즈를 지원한다.
이에 비해 모바일이나 임베디드 디바이스에선 주로 NAND 플래시 메모리를 사용하기 때문에, 그에 최적화된 다른 파일 시스템을 사용한다. 모바일 또는 임베디드 시스템에서 일반적으로 사용되는 NAND 플래시 메모리의 경우 메모리 셀의 프로그램/지우기(P/E) 주기가 제한되어 있으며, 특정 횟수의 P/E 사이클이 지나면 메모리 셀의 신뢰성이 떨어지고 데이터를 정확하게 저장하지 못할 수 있다. 따라서, F2FS와 같이 낸드 플래시 메모리용으로 설계된 파일 시스템은 wear leveling 및 쓰기 증폭 최소화 등의 기능을 구현하여 쓰기 및 지우기 작업을 메모리 셀 전체에 고르게 분산시킨다. 웨어 레벨링은 특정 셀이 과도하게 사용되는 것을 방지하여 개별 셀에 가해지는 스트레스를 줄이고 스토리지 장치의 전체 수명을 연장하는 데 도움이 된다. 쓰기 증폭을 최소화하면 특정 데이터 세트에 필요한 쓰기 작업 횟수가 줄어들어 메모리 셀의 마모가 줄어든다.
잠깐, 왜 모바일/임베디드 디바이스에서 NAND 플래시 메모리를 쓰는가?
- Non-volatile storage: 낸드 플래시 메모리는 전원이 꺼져 있어도 데이터를 유지합니다. 이는 전원이 꺼지거나 간헐적으로 전원이 끊기는 경우가 많은 모바일 및 임베디드 장치에 매우 중요합니다.
- Low power consumption: NAND 플래시 메모리는 읽기, 쓰기 및 유휴 작업 중에 상대적으로 낮은 전력을 소비하므로 스마트폰, 태블릿, IoT 장치와 같은 배터리 구동식 장치에 에너지 효율적인 선택이 될 수 있습니다.
- Fast read and write speeds: NAND 플래시 메모리는 EEPROM 또는 NOR 플래시 메모리와 같은 다른 비휘발성 스토리지 기술에 비해 빠른 읽기 및 쓰기 속도를 제공합니다. 따라서 모바일 및 임베디드 디바이스의 전반적인 성능이 향상됩니다.
- Small form factor: 낸드 플래시 메모리는 작은 물리적 공간에 많은 양의 데이터를 저장할 수 있어 스마트폰 및 기타 소형 전자기기와 같이 공간이 제한된 장치에 이상적입니다.
- Solid-state storage: 하드 디스크 드라이브(HDD)와 달리 낸드 플래시 메모리는 움직이는 부품이 없으므로 기계적 고장, 충격 또는 진동에 덜 민감합니다. 따라서 거친 취급이나 환경적 스트레스를 받을 수 있는 모바일 및 임베디드 장치에서 사용하기에 더 안정적이고 내구성이 뛰어납니다.
- Scalability: NAND 플래시 메모리는 필요에 따라 쉽게 확장하여 더 큰 저장 용량을 제공할 수 있습니다. 따라서 다양한 스토리지 요구 사항을 가진 다양한 모바일 및 임베디드 장치에 적합합니다.
위와 같은 이유들로 리눅스에는 여러 파일시스템이 존재하는데 이를 각각 관리하는 것은 매우 어려울 것이다. 따라서 OS는 VFS (virtual file system) 을 통하여 응용프로그래머들이 사용하는 파일 시스템 종류에 무관하게 파일 시스템을 활용할 수 있도록 도와준다.
VFS에 대하여 알아보기 전에 먼저 여러 파일 시스템 중 리눅스의 Ext2 (Ext means Extended) 에 대하여 알아보고자 한다. (Ext2인 이유는 전공시간에 해당 시스템을 배웠기 때문)
Ext2 파일 시스템
먼저, 리눅스에서는 각 디스크마다 최대 64개까지 파티션을 분할 가능한데 각 파티션마다 파일시스템을 하나씩 만들 수도 있다.
IDE는 통합 드라이브 전자 장치, 즉 디스크 연결하는 인터페이스라고 생각하면 된다. 위 이미지는 한 리눅스 시스템에 2가지 하드 드라이브가 연결되었고 그 중 하나를 또 3개의 파티션으로 나누며, 3개중 한개에 Ext2 시스템을 사용한다는 것을 의미한다.
이 이미지가 좀 더 직관적으로 이해하는데 도움이 되는 것 같다.
Ext2 에 대하여 좀 더 자세히 알아보자.
위 이미지들에서 볼 수 있듯이, Ext2 는 부트스트랩 코드가 존재하는 부트 블록과, 여러개의 블록 그룹들로 구성된다.
각 블록 그룹은 다시 super block, group dscriptor, 디스크 블록을 위한 bitmap, indoe 를 위한 bitmap, inode table 영역, data blocks 영역 6가지 부분으로 구분된다.
디스크를 블록 그룹으로 구성함으로써, Ext2는 관련 데이터와 메타데이터가 서로 가깝게 유지되도록 하여 관련 파일에 엑세스 할 때 디스크 헤드가 먼 거리를 이동하지 않도록 해준다.
- inode: 파일이나 디렉토리에 대한 ownership, permissions, timestamps 관련 메타데이터 값을 저장한다. 모든 파일 or 디렉토리는 고유한 inode number 를 가지며, 파일에 액세스하거나 수정할 때 파일 시스템은 inode number 를 사용하여 해당 메타데이터를 조회하고 필요한 작업을 수행한다
- data blocks: 파일이나 디렉토리는 고정 크기 저장 단위인 데이터 블록에 저장된다. 블록의 크기는 파일 시스템을 만들 때 결정되며, 1KB, 2KB, 4KB 가 될 수 있다. 파일 시스템은 inode 를 사용하여 적절한 데이터 블록을 찾고, 필요한 작업을 수행한다.
super block: 총 inode 및 블록의 수, 블록 크기, 첫 번째 블록 그룹에 대한 포인터와 같이 파일 시스템에 대한 중요한 정보가 포함되어 있다. - bitmap: inode 나 data block 의 할당상태를 추적하는데 사용된다. 비트맵에서 적절한 비트를 확인함으로써 파일 시스템은 블록 또는 이노드가 사용 가능한지 또는 사용 중인지 빠르게 확인할 수 있다. 이를 통해 리소스를 효율적으로 할당하고 할당 해제할 수 있다.
예시로, /usr/member/tom에 대한 data를 읽으려고 한다고 가정하면, Ext2 시스템은 다음과 같이 동작한다.
- 루트 디렉토리(/)의 inode (inode 1)를 찾습니다.
- inode 1에서 루트 디렉토리(/)의 디렉토리 정보가 저장된 디스크 블록(disk block 1)을 알게 됩니다.
- 디스크 블록 1에서 usr 디렉토리에 대한 엔트리(entry)를 읽습니다. 이 엔트리에서 usr 디렉토리의 inode 번호(inode 3)를 찾습니다
- inode 3에서 usr 디렉토리의 디렉토리 정보가 저장된 디스크 블록(disk block 7)을 알게 됩니다.
- 디스크 블록 7에서 member 디렉토리에 대한 엔트리(entry)를 읽습니다. 이 엔트리에서 member 디렉토리의 inode 번호(inode 23)를 찾습니다.
- inode 23에서 member 디렉토리의 디렉토리 정보가 저장된 디스크 블록(disk block 39)을 알게 됩니다.
- 디스크 블록 39에서 tom 디렉토리에 대한 엔트리(entry)를 읽습니다. 이 엔트리에서 tom 디렉토리의 inode 번호(inode 33)를 찾습니다
이렇게 디렉토리 엔트리를 읽는 과정을 통해 최종적으로 tom 디렉토리의 inode 번호(inode 33)를 찾게 되고, 이를 사용하여 해당 디렉토리에 저장된 데이터에 접근할 수 있다.
갑자기 엔트리라는 용어가 튀어나왔는데, 엔트리는 파일 시스템에서 디렉토리에 포함된 파일이나 하위 디렉토리에 대한 정보를 나타내는 데이터 구조이다. 이는, 파일이름과 inode 번호를 포함한다. 디렉토리는 사실상 엔트리의 목록으로 구성되며, 파일 이름과 해당 파일의 inode 번호를 연관시키는 역할을 한다.
disk block 내의 1 .. / 3 usr / 4 dev 와 같이 표현되어 있는 각 행이 directory entry 라고 볼 수 있다.
그런데, 위 과정에서 의아한 점이 있다. inode 에서 entry 를 가진 disk block 으로 어떻게 연결된다는 걸까? 정답은 결국 디렉토리도 파일로 취급하기 때문에 사실 이는 data block 을 통해 접근한 후 얻게 되는 정보인 것이다.
Ext2 에서 사용하는 inode 구조체에 대하여 조금 더 자세히 살펴보면 다음과 같다.
여기서 중점적으로 볼 것은 어떻게 data block 을 관리하느냐 이다. 12개의 direct block pointer 와, 3개의 indirect block, 즉 pointer 들의 pointer 를 관리하는 것을 확인할 수 있다. 이를 multi level indexing 이라고 한다. 여기서 3개의 indirect block 은 각각 single, double, triple indirect block 을 의미한다.
그렇다면, 결국엔 block size 의 최대치, 즉 파일의 최대 크기가 정해져 있을 것 같은데 얼마나 되는걸까? Ext2 파일 시스템에서 가장 일반적으로 사용되는 블록 크기는 1KB, 2KB, 4KB 이다. 최대 파일 사이즈를 계산하는 것이니, 블록 크기는 4KB 로 가정해 보자.
- Direct blocks: 12 * 4,096 = 49,152 bytes
- Single indirect block: (4,096 / 4) * 4,096 = 4,194,304 bytes
- Double indirect block: (4,096 / 4)^2 * 4,096 = 4,294,967,296 bytes
- Triple indirect block: (4,096 / 4)^3 * 4,096 = 4,398,046,511,104 bytes
결론은 4TB. 사실상 파일 최대크기를 넘길까봐 걱정할 필요는 없어 보인다. 심지어 최근에 주로 사용되는 Ext4 는 이보다 큰 파일 최대 크기를 가지고 있을 것이다. 그리고, 파일이 실제로 그 최대크기보다 커지더라도 EFBIG 코드와 함께 에러 핸들링 된다고 하니 크게 신경 쓸 필요 없을 것 같다.
다음으로, i_mode 변수에 대하여 살펴보자. 총 16비트로 구성되어 있다. 먼저 상위 4개 비트는 file의 type 을 나타낸다.
- S_IFREG (0100000): Regular file
- S_IFDIR (0040000): Directory
- S_IFLNK (0120000): Symbolic link
- S_IFCHR (0020000): Character device
- S_IFBLK (0060000): Block device
- S_IFIFO (0010000): FIFO (named pipe)
총 2^4 = 16 가지가 가능하지만, 우선 전체 종류는 6개이다. 3bit 면 충분할 것 같아 보이는데 왜 4bit 일까? 기술적으로는 3비트만 사용하여 파일 유형을 표현할 수도 있지만, 4비트 표현은 Unix의 역사, 호환성 및 파일 유형의 인코딩 체계에 뿌리를 둔 설계 선택이라고 한다. 너무 딥하니 넘어가자.
그 다음 3개의 비트 (u,g,s) 는 특별한 목적으로 사용한다고 하며, 마지막으로 9개 비트는 파일의 접근 제어에 사용된다.
wner: Read (1), Write (1), Execute (1) => Binary: 111 => Octal: 7
Group: Read (1), Write (0), Execute (1) => Binary: 101 => Octal: 5
Others: Read (1), Write (0), Execute (0) => Binary: 100 => Octal: 4
이를 수정하는데 unix 계열에선 다음과 같은 커맨드를 사용할 수 있다.
chmod 754 filename
i_mode 에 대하여 알고나니, inode 에서 entry 를 가진 disk block 으로 어떻게 연결된다는 건지 의아했던 부분이 더욱 쉽게 이해가 되는 것 같다.
이제 Ext2 파일 시스템의 전체적인 그림이 이해가 간다. 하지만 여기서 의아한 점은, 왜 root dir 의 inode number가 2일까? 0이어야 맞는거 아닐까? 찾아보니, Ext2, Ext3, Ex4 모두 root dir 의 inode number 가 2이다. 이유는 다음과 같다고 한다.
1. inode number 0 is reserved and not used in the file system. This is to ensure that a null or uninitialized inode reference (represented as 0) is considered invalid. By doing this, the file system can detect and handle cases where an inode number is not properly set or initialized.
2. Inode number 1 is traditionally reserved for the "bad blocks inode." This inode is used to keep track of bad blocks on the disk. Bad blocks are areas of the storage device that are physically damaged or unreliable, and therefore, should not be used to store data. By reserving inode number 1 for this purpose, the file system can manage bad blocks in a consistent manner.
결국 둘다 0번과 1번 inode number 에는 다른 의미와 용도가 있기 때문이라고 가볍게 이해하고 넘어가도 될 것 같다.
여기까지가 Ext2 에 대한 공부였다. 이제 본론인 VFS 에 대하여 공부해 보자.
VFS (virtual file system)
VFS는 커널에 속하는 소프트웨어 계층으로, 리눅스 표준 파일 시스템과 관련한 모든 시스템 콜을 핸들링한다. VFS의 주요 강점은, 여러 종류의 파일시스템을 사용할 수 있는 추상화된 common interfrace 를 제공한다는 것이다.
흐름은 다음과 같다.
전체적인 구조는 다음과 같다.
가상 파일시스템의 동작 과정은 다음과 같다.
유저 프로그램에서 파일과 관련한 시스템 콜을 통해 소켓을 읽을 수도 있고, 파일이나 디렉토리를 불러올 수도 있고, 디바이스와 관련한 동작을 처리할 수 도 있다. 각각은 다른 파일시스템으로 관리되기 때문에, 이를 mapping 할 수 있는 자료구조가 필요할 것이다. 그 역할을 하는 것이 file_operations 자료구조이다.
각 파일 시스템의 동작을 포인터로 연결하는 해당 객체는파일이 open 될 때 VFS가 인메모리로 관리하는 file 구조체 인스턴스에 포함된다. (open system call 은 일반적인 파일 기준이며, 파일 시스템에 따라 다른 시스템 콜이 사용될 수 있다) 이후에 해당 파일에 대하여 read 시스템 콜이 호출되면, 포인터를 따라 Ext2 에서 정의한 read 동작을 수행하게 된다.
위에서 설명한 과정에 대한 설명이 포함되어있는 이미지이다. file 에 f_op 에 앞서 말한 file system specific 한 file operation 구조체가 등록된다고 보면 된다.
좌측에 프로세스 task_struct 가 가지고 있는 fs_struct 는 각 프로세스별로 한개씩 존재하며, 프로세스의 파일 시스템 관련 상태를 관리한다. 현재 작업 디렉터리, 루트 디렉터리 및 프로세스 관련된 기타 파일 시스템 관련 속성에 대한 정보가 포함된다.
file_struct는 리눅스 커널에서 열린 파일들을 나타낸다. 프로세스가 파일을 새롭게 열면 파일 구조체가 새로 생성되어 file descriptor table 에 추가되며, 이 테이블은 파일 구조체 객체에 대한 포인터 배열이다. open() 시스템 호출의 리턴값이 이 테이블에 대한 index 라고 볼 수 있다.
그런데 자세히 보면, 프로세스의 file descriptor index가 3부터 시작되는데 그이유가 무엇일까? 그 이유는 fd 0, 1, 2 는 이미 프로세스가 생성될 때 할당되기 때문이다. 각각 표준 입력(stdin), 표준 출력(stdout), 표준 오류(stderr)에 대하여 미리 할당되는데, 이 3가지는 표준스트림이라고 하며 운영 체제에서 기본적으로 제공하는 추상화된 입출력 장치를 의미한다.
- 표준입력(STDIN): 표준 입력 장치의 ID 는 숫자로는 0 이며 일반적으로는 키보드가 됩니다.
- 표준출력(STDOUT): 출력을 위한 스트림으로 표준 출력 장치의 ID 는 1이며 일반적으로는 현재 쉘을 실행한 콘솔(console)이나 터미널(terminal)이 됩니다.
- 표준에러(STDERR): 에러를 위한 스트림으로 표준 에러 장치의 ID 는 2이며 일반적으로는 표준 출력과 동일합니다.
이 3가지도 모두 결국 file 이기 때문에, VFS에 의하여 관리된다고 생각하면 된다.
VFS가 in-memory 에서 관리하는 객체로 super block, inode, file, dentry 가 존재한다. 이전 Ext2 에서 공부했던 요소들과 이름이 같지만 엄연히 다른 객체이다.
- super block: 슈퍼블록은 마운트된 특정 파일 시스템에 대한 정보를 포함하는 데이터 구조입니다. 여기에는 파일 시스템 유형, 블록 크기, 총 블록 수, 사용 가능한 블록 및 아이노드와 같은 메타데이터가 저장됩니다. VFS는 슈퍼블록을 사용하여 파일 시스템을 관리하고 액세스합니다. 파일 시스템이 마운트되면 VFS는 슈퍼블록을 읽고 해당 인메모리 VFS 슈퍼블록 개체를 초기화합니다.
- Inode: VFS에서 inode 개체는 파일 시스템에 특정되지 않는 추상 파일을 나타냅니다. 파일에 액세스하면 VFS는 파일 시스템별 이노드와 상호 작용하여 읽기 또는 쓰기와 같은 작업을 수행합니다. 각 파일 시스템에는 고유한 on-disk inode 형식이 있습니다. inode 번호로 파일 시스템 내에서 유일하게 식별됩니다. -
- file: 열려 있는 파일을 나타내는 추상화된 객체입니다. 파일이 열리면 VFS는 열린 파일을 관리하기 위해 file 객체를 생성합니다. 이 구조에는 파일 위치(오프셋), 액세스 모드(읽기, 쓰기, 추가), 연결된 dentry 에 대한 포인터와 같은 정보가 포함됩니다. VFS는 파일 데이터 구조를 사용하여 읽기, 쓰기, 찾기와 같은 파일 작업을 관리합니다. 각 process 가 file에 접근하는 동안 커널 메모리에만 존재합니다.
- dentry: dentry (디렉토리 항목)는 파일 또는 디렉토리 이름과 해당 inode 사이의 연관성을 나타내는 데이터 구조입니다. dentry 는 디렉터리 조회 결과를 캐시하여 파일 및 디렉터리 액세스 속도를 높이는 데 사용됩니다. VFS는 최근에 액세스한 dentry 를 저장하기 위해 dentry 캐시(디렉토리 캐시 또는 dcache라고도 함)를 유지 관리합니다. 해당 경로로 파일 또는 dentry 에 액세스하면 VFS는 먼저 덴트리 캐시에서 일치하는 항목이 있는지 확인합니다. 캐시에 없는 경우 VFS는 파일 시스템 드라이버와 통신하여 디렉터리 조회를 수행한 다음 결과 dentry 캐시에 추가합니다.
사용자 어플리케이션에서 파일을 열고, 내용을 읽고, 파일을 닫는 과정에서 슈퍼블록, 인노드, 파일, 덴트리와 같은 VFS 데이터 구조가 사용되는 과정은 다음과 같다.
- 파일 열기: 사용자 애플리케이션은 파일 경로를 인수로 사용하여 open() 시스템 호출을 호출합니다. VFS는 먼저 dentry 캐시를 확인하여 파일 경로와 일치하는 기존 dentry가 있는지 확인합니다. 찾을 수 없는 경우 VFS는 파일 시스템 드라이버와 상호 작용하여 디렉터리 조회를 수행하고 새 dentry 생성합니다. dentry는 파일 이름을 해당 이노드와 연결합니다.
- inode 액세스: VFS는 dentry를 사용하여 파일과 연결된 inode에 액세스합니다. inode가 아직 메모리에 없는 경우 파일 시스템에서 inode 를 읽어 메모리 내 VFS inode 객체를 생성합니다. 이 inode에는 파일 크기, 권한, 소유자, 디스크의 데이터 블록에 대한 포인터와 같은 중요한 메타데이터가 포함됩니다.
- 파일 객체 생성: open() 시스템 호출이 성공하면 VFS는 열린 파일을 나타내는 파일 데이터 구조를 만듭니다. 파일 객체에는 파일 위치(오프셋), 액세스 모드(읽기, 쓰기, 추가), 연결된 이노드 및 dentry에 대한 포인터와 같은 정보가 포함됩니다.
- 파일 읽기: 사용자 애플리케이션은 열린 파일을 나타내는 file descriptor, 데이터를 저장할 버퍼, 읽을 바이트 수를 지정하는 read() 시스템 호출을 실행합니다. VFS는 파일 객체와 inode를 사용하여 읽기 작업을 수행합니다. 파일 오프셋을 디스크의 해당 데이터 블록으로 변환하고 제공된 버퍼로 데이터를 읽습니다.
- 파일 닫기: 사용자 애플리케이션이 파일 읽기를 완료하면 close() 시스템 호출을 호출하여 파일 설명자 및 관련 리소스를 해제합니다. VFS는 파일 객체를 정리하고 필요에 따라 inode dentry를 업데이트합니다. 파일 시스템의 캐싱 및 writeback 정책에 따라 inode 와 dentry 는 다시 액세스할 경우를 대비하여 잠시 동안 메모리에 남아 있을 수 있습니다.
다음 이미지는 3개의 프로세스가 어떻게 VFS 를 통해 같은 파일과 상호작용하는지를 보여준다.
VFS 구조가 어떻게 이루어져있고, 어떤 식으로 동작한다는지에 대한 감은 어느정도 잡히나, 너무 복잡하다. 이를 간단하게 설명할 순 없을까? 이럴 땐 전반적인 흐름을 예시로 살펴보는 것도 좋을 것 같다.
VFS process for regular file I/O
프로세스가 /usr/member/tom/file.txt에서 파일을 읽으려한다고 가정했을 때, VFS 를 통해 어떤 동작이 일어나는지 살펴보자.
- 경로 확인: VFS는 /usr/member/tom/file.txt 경로를 확인하는데, 이를 /, /usr, /usr/member, /usr/member/tom, /usr/member/tom/file.txt 로 쪼개어 각 dentry cache 를 확인한다.
- dentry cache 조회: 1번 과정에서 resolivng 한 각 경로에 대하여 dentry cache 를 확인한다. 만약 이미 /usr/member/tom/file.txt 가dentry cache 에 존재한다면, 해당 dentry 와 열린 file obejct 를 연결하면 된다. 찾지 못했다면, VFS는 파일시스템 드라이버와 상호작용하여 해당하는 경로에 파일을 읽어와야 한다. 이 때, 파일 시스템이 Ext2 라고 가정한다면 위에서 알아본 inode -> dentry 를 반복하는 과정을 통해 최종적인 파일에 접근할 것이다. 찾았다면, 이제 메모리에 해당하는 in-memory inode 와 dentry object 를 생성하고 dentry 캐시에 이를 추가한다. 여기서, /usr/member/tom/file.txt 에 대한 dentry cache 를 발견하지 못했지만, /usr/member/tom 에 대한 dentry cache 를 발견하였다면, 당연히 파일시스템이 전혀 캐시를 찾지 못한 경우보다 빠르게 file1.txt 에 대한 디렉터리를 조회할 수 있을 것이다.
- inode access: 이제 VFS 는 원하는 파일의 inode 에 접근할 수 있다. VFS 는 프로세스가 해당 파일을 읽는 데 필요한 권한이 있는지 확인한다.
- file obejcet creation: 권한이 있는 것을 확인하였다면, VFS 는 file 객체를 만들어 /usr/member/tom/file.txt dentry 를 가르키도록 한다.
- data reading: 이후 VFS는 file system driver 와 상호 작용하여 파일에서 데이터를 읽어 온다.
- data transfer: VFS는 읽은 데이터를 프로세의 메모리로 전송하여 프로세스가 파일 data 에 최종적으로 access 할 수 있도록 해준다.
- file close: 프로세스가 파일 읽기를 완료하면, 해당 파일을 닫는다. reference count 가 0 이라면 사용하던 dentry cache, inode 의 메모리가 해제 될 것이다. (사실 바로 해제는 안될 것이다. VFS와 파일시스템 드라이버는 성능상의 이유로 일정 기간 동안 이노드를 메모리에 유지하거나 메모리 관리 정책에 따라 이노드를 제거하기로 결정할 것이다)
결론적으로, VFS 는 리눅스 커널에서 다양한 파일 시스템일 일괄적으로 관리할 수 있도록 제공되는 추상화 계층이다. 지금까지는 일반적인 리눅스 파일 시스템 중 조금 이전 버전인 Ext2 에 대하여 공부하고, 이와 관련한 VFS 의 동작흐름을 살펴 보았다.
VFS 는 Ext2 파일 시스템 외에도 다양한 파일 시스템과 상호작용을 할 것이다. 이어서는 소켓이나, 키보드 마우스 입력과 같은 파일 시스템과 어떻게 상호작용하는지 알아보도록 하자.
먼저 헷갈렸던 부분인데, "키보드를 VFS를 통해 핸들링 할 수 있다." 라는 말은 틀린말이다. VFS 는 추상화된 계층이기 때문에 키보드라는 특정 디바이스를 핸들링한다고 표현할 수 없다. VFS 는 다양한 파일시스템과 상호작용할 수 있는거고, 키보드와 마우스는 device driver 와 커널에 의하여 핸들링 되는 것이다.
즉, 키보드는 커널과 해당 장치 드라이버에 의해 처리되고, 유저 프로세스는 VFS에서 관리하는 장치 파일(예: /dev/tty)을 통해 키보드와 통신한다고 표현해야 한다.
먼저, 유저 프로그램이 VFS를 통해 소켓과 상호작용하는 방법에 대하여 알아보자.
VFS process for socket
다음은 C언어로 작성된, 유저 프로그램에서 소켓을 생성하고 HTTP 요청을 보내는 과정이다.
#include <arpa/inet.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
int main() {
// Create a socket object (IPv4, TCP)
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
return 1;
}
// Resolve server address (example.com, port 80)
struct sockaddr_in server_address;
memset(&server_address, 0, sizeof(server_address));
server_address.sin_family = AF_INET;
server_address.sin_port = htons(80);
if (inet_pton(AF_INET, "93.184.216.34", &server_address.sin_addr) <= 0) {
perror("inet_pton");
return 1;
}
// Connect to the remote server
if (connect(sock, (struct sockaddr *)&server_address, sizeof(server_address)) < 0) {
perror("connect");
return 1;
}
// Prepare the HTTP request
const char *request = "GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
// Send the request via the socket
if (send(sock, request, strlen(request), 0) < 0) {
perror("send");
return 1;
}
// Receive the response
char buffer[1024];
ssize_t received;
while ((received = recv(sock, buffer, sizeof(buffer) - 1, 0)) > 0) {
buffer[received] = '\0';
printf("%s", buffer);
}
if (received < 0) {
perror("recv");
return 1;
}
// Close the socket
close(sock);
return 0;
}
여기서 socket() 이라는 시스템 콜을 통해 socket 을 위한 파일을 생성한다. return 값으로 돌려받는 정수가 file descriptor 라고 생각할 수 있다. 해당 시스템콜을 호출하여 VFS에서 이를 처리한 결과이다. 위에서 알아본 Ext2 파일의 open 이 완료되어 file descriptor 를 돌려주는 과정에 대응한다고 볼 수 있다. 과정을 하나씩 살펴보자.
- Socket creation: 유저 포르그램에서 socket() 시스템 콜을 호출하면, VFS 는 socket 을 위한 inode 를 할당하고 dentry -> file 를 생성하는 과정을 통해 최종적으로 유저 프로그램에게 file descriptor 값을 돌려준다. 위에서 regular file 에 대하여 공부할 땐 inode 생성시 Ext2 파일 시스템과 함께 동작하는 것을 살펴보았다. 이와 달리 socket 의 경우 커널에 미리 정의된 net/socket.c 의 file_operations 들을 통하여 VFS 에 의해서만 관리 된다. (따로 하드웨어와 상호작용하는 파일 시스템이 필요 없다고 볼 수 있다)
- Binding and connection: 커널 모드에서 socket object 의 src local address, port 등을 업데이트 한다. OS 레벨에서 네트워크 스택을 통해 remote server 와 커넥션을 생성한다.
- Data transmission: 유저프로그램이 file descriptor 를 통해 소켓을 읽기나 쓰기를 요청하면 VFS 에선 이 요청을 그에 맞는 socket 에게 전달한다. 커널이 실제 데이터 송수신을 담당하며, 데이터를 쓰거나, 읽은 결과 값을 유저 프로그램에 전달하는 역할을 한다. 이는 위에서 살펴본 정규 파일 시스템 동작과 유사하다.
- Closing the socket: 유저 프로그램이 close() 시스템 콜을 통해 소켓을 닫기를 요청하면, OS 는 이와 관련한 file, dentry, inode 등을 메모리 할당 해제한다.
여기서 잠깐, socket() 생성 후 close() 하지 않은 채로 프로세스가 종료되면 어떻게 될까? 커널에서 알아서 처리해주지 않을까 싶은데..
프로세스가 열린 소켓을 닫지 않고 종료되는 경우 운영 체제는 자동으로 소켓을 닫고 관련 리소스를 해제합니다. 프로세스가 종료되면 운영 체제는 정리 작업을 수행하며, 여기에는 소켓과 연결된 파일 디스크립터를 포함하여 열려 있는 모든 파일 디스크립터를 닫는 작업이 포함됩니다.
프로세스가 종료되면 운영 체제에서 자동으로 소켓을 닫지만, 일반적으로 코드에서 명시적으로 소켓을 닫는 것이 좋은 프로그래밍 관행으로 간주된다는 점에 유의하는 것이 중요합니다. 이렇게 하면 소켓이 남아 있거나 리소스가 누수되는 잠재적인 문제를 방지하고 코드를 더욱 견고하고 이해하기 쉽게 만들 수 있습니다.
커널이 처리해주긴 하지만 명시적으로 닫는게 좋다. 소켓이 남아 있거나 리소스가 누수되는 잠재적인 문제를 방지하고 코드의 가독성도 높일 수 있기 때문이라고 한다.
'Linux kernel' 카테고리의 다른 글
[Interrupt] Top half & bottom half (1) | 2023.04.27 |
---|---|
Dynamic Timer (0) | 2023.04.19 |
sysfs (0) | 2023.04.17 |
Device Driver (0) | 2023.04.14 |