이미지 로딩 중...

Rust로 만드는 나만의 OS 파일 시스템 완벽 가이드 - 슬라이드 1/9
A

AI Generated

2025. 11. 13. · 4 Views

Rust로 만드는 나만의 OS 파일 시스템 완벽 가이드

Rust로 운영체제를 만들면서 파일 읽기/쓰기 기능을 구현하는 방법을 배웁니다. VFS 추상화, FAT32 파일 시스템 구현, 버퍼링 전략까지 실무에서 바로 활용할 수 있는 수준으로 다룹니다.


목차

  1. VFS(Virtual File System) 추상화
  2. File Descriptor와 Open File Table
  3. FAT32 구조체와 부트 섹터 파싱
  4. 디렉토리 엔트리 탐색
  5. 파일 읽기 구현
  6. 파일 쓰기와 클러스터 할당
  7. 버퍼 캐시 전략
  8. inode 구조 설계

1. VFS(Virtual File System) 추상화

시작하며

여러분이 운영체제를 만들다가 FAT32, ext4, NTFS 같은 다양한 파일 시스템을 모두 지원해야 하는 상황을 생각해보세요. 각 파일 시스템마다 완전히 다른 방식으로 파일을 읽고 써야 한다면, 애플리케이션 코드는 얼마나 복잡해질까요?

이런 문제는 실제 개발 현장에서 자주 발생합니다. Linux, Windows, macOS 모두 이 문제를 해결하기 위해 VFS라는 추상화 레이어를 사용합니다.

각 파일 시스템의 구체적인 구현을 숨기고, 통일된 인터페이스를 제공하는 것이죠. 바로 이럴 때 필요한 것이 VFS(Virtual File System) 추상화입니다.

이것은 파일 시스템의 공통 인터페이스를 정의하여, 상위 레이어가 구체적인 파일 시스템을 몰라도 파일을 다룰 수 있게 해줍니다.

개요

간단히 말해서, VFS는 모든 파일 시스템이 구현해야 하는 trait(인터페이스)의 집합입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 시스템 콜 레이어에서 read(), write(), open() 같은 함수들이 파일 시스템의 종류를 알 필요가 없게 됩니다.

예를 들어, USB 드라이브(FAT32)에서 읽든, 하드디스크(ext4)에서 읽든 동일한 코드로 처리할 수 있습니다. 전통적인 방법에서는 각 파일 시스템마다 별도의 시스템 콜을 만들었다면, 이제는 단 하나의 read() 시스템 콜로 모든 파일 시스템을 지원할 수 있습니다.

VFS의 핵심 특징은 trait 기반 다형성, 통일된 경로 해석, 마운트 포인트 관리입니다. 이러한 특징들이 운영체제의 유연성과 확장성을 크게 향상시킵니다.

코드 예제

// VFS trait 정의 - 모든 파일 시스템이 구현해야 함
pub trait FileSystem: Send + Sync {
    // 파일을 열고 inode 번호 반환
    fn open(&self, path: &str) -> Result<usize, FsError>;

    // inode로부터 데이터 읽기
    fn read(&self, inode: usize, offset: usize, buf: &mut [u8]) -> Result<usize, FsError>;

    // inode에 데이터 쓰기
    fn write(&self, inode: usize, offset: usize, buf: &[u8]) -> Result<usize, FsError>;

    // 파일 메타데이터 조회
    fn stat(&self, inode: usize) -> Result<FileStat, FsError>;
}

// VFS 레이어 - 파일 시스템들을 관리
pub struct VFS {
    filesystems: Vec<Box<dyn FileSystem>>,
}

설명

이것이 하는 일: 이 코드는 Rust의 trait을 사용하여 파일 시스템의 공통 인터페이스를 정의합니다. 모든 파일 시스템(FAT32, ext4 등)은 이 trait을 구현해야 하며, VFS는 이들을 동적 디스패치를 통해 관리합니다.

첫 번째로, FileSystem trait이 네 가지 핵심 메서드를 정의합니다. open()은 경로를 받아 파일을 찾고 고유 식별자(inode)를 반환합니다.

왜 이렇게 하냐면, 경로 해석은 비용이 큰 작업이므로 한 번만 수행하고, 이후에는 inode로만 접근하기 위함입니다. 두 번째로, read()와 write() 메서드가 실제 데이터 전송을 처리합니다.

offset 매개변수를 통해 파일의 임의 위치에 접근할 수 있으며, 버퍼를 통해 데이터를 주고받습니다. Send + Sync trait bound가 있는 이유는 멀티스레드 환경에서 안전하게 사용하기 위함입니다.

세 번째로, VFS 구조체가 Box<dyn FileSystem>을 사용하여 여러 파일 시스템을 저장합니다. 이것이 동적 디스패치의 핵심이며, 런타임에 어떤 구현체를 호출할지 결정됩니다.

여러분이 이 코드를 사용하면 새로운 파일 시스템을 추가할 때 기존 코드를 전혀 수정하지 않아도 됩니다. FAT32 구조체를 만들고 FileSystem trait만 구현하면, VFS가 자동으로 통합해줍니다.

이는 개방-폐쇄 원칙(OCP)의 완벽한 예시입니다.

실전 팁

💡 Result<T, FsError>를 반환하도록 설계하세요. 파일 시스템 작업은 실패할 수 있으므로(디스크 오류, 권한 부족 등), Rust의 타입 시스템으로 에러 처리를 강제하는 것이 안전합니다.

💡 trait 메서드에서 &self를 사용하여 불변 참조만 받도록 하세요. 내부 가변성이 필요하면 Mutex나 RwLock을 사용하면 됩니다. 이렇게 하면 읽기 작업들을 병렬로 처리할 수 있습니다.

💡 inode 번호는 usize보다 전용 타입(struct InodeId(u64))을 만드는 것이 좋습니다. 타입 안전성이 향상되고, 실수로 잘못된 숫자를 전달하는 버그를 방지할 수 있습니다.

💡 async trait을 고려해보세요. 파일 I/O는 블로킹 작업이므로, async_trait 크레이트를 사용하면 비동기 프로그래밍과 잘 통합됩니다.


2. File Descriptor와 Open File Table

시작하며

여러분이 한 프로세스에서 같은 파일을 두 번 열었을 때, 각각 독립적인 파일 오프셋을 가져야 하는 상황을 생각해보세요. 첫 번째 핸들로는 파일 시작부터 읽고, 두 번째 핸들로는 중간부터 읽어야 한다면 어떻게 해야 할까요?

이런 문제는 실제 운영체제에서 반드시 해결해야 하는 과제입니다. 프로세스마다, 그리고 open() 호출마다 독립적인 파일 상태를 관리해야 합니다.

오프셋, 플래그(읽기/쓰기/추가 모드), 파일 잠금 상태 등이 모두 별도로 유지되어야 하죠. 바로 이럴 때 필요한 것이 File Descriptor와 Open File Table입니다.

이 이중 구조는 프로세스별 파일 핸들과 시스템 전역 파일 상태를 분리하여 관리합니다.

개요

간단히 말해서, File Descriptor는 프로세스가 열린 파일을 참조하는 작은 정수이고, Open File Table은 실제 파일 상태를 저장하는 커널 자료구조입니다. 왜 이 구조가 필요한지 실무 관점에서 설명하면, fork()로 프로세스를 복제할 때 파일 디스크립터도 복사되지만 Open File Table 엔트리는 공유됩니다.

예를 들어, 부모 프로세스가 파일을 절반 읽은 후 fork()하면, 자식 프로세스도 동일한 오프셋에서 이어서 읽게 됩니다. 전통적인 방법에서는 프로세스마다 파일 전체 상태를 복사했다면, 이제는 참조 카운팅으로 효율적으로 공유할 수 있습니다.

핵심 특징은 프로세스별 FD 테이블, 시스템 전역 Open File Table, 참조 카운팅입니다. 이러한 특징들이 메모리 효율성과 프로세스 간 파일 공유를 가능하게 합니다.

코드 예제

// Open File Table 엔트리 - 시스템 전역
pub struct OpenFile {
    inode: usize,           // 어떤 파일인지
    offset: AtomicUsize,    // 현재 읽기/쓰기 위치
    flags: FileFlags,       // O_RDONLY, O_WRONLY, O_APPEND 등
    refcount: AtomicUsize,  // 참조 카운트
}

// 프로세스별 File Descriptor Table
pub struct FdTable {
    entries: Vec<Option<Arc<OpenFile>>>,
}

impl FdTable {
    // 새 파일 열기
    pub fn open(&mut self, inode: usize, flags: FileFlags) -> usize {
        let open_file = Arc::new(OpenFile::new(inode, flags));
        // 비어있는 FD 슬롯 찾기
        for (fd, entry) in self.entries.iter_mut().enumerate() {
            if entry.is_none() {
                *entry = Some(open_file);
                return fd;  // FD 번호 반환
            }
        }
        panic!("Too many open files");
    }
}

설명

이것이 하는 일: 이 코드는 이중 레이어 구조로 파일을 관리합니다. OpenFile 구조체는 파일의 실제 상태를(오프셋, 플래그 등) 저장하고, FdTable은 프로세스마다 어떤 OpenFile을 참조하는지 추적합니다.

첫 번째로, OpenFile 구조체가 AtomicUsize를 사용하는 이유는 멀티스레드 환경에서 여러 스레드가 동시에 같은 파일을 읽을 때 오프셋을 안전하게 업데이트하기 위함입니다. Arc(Atomic Reference Counting)로 감싸져 있어서, 참조가 0이 되면 자동으로 메모리가 해제됩니다.

두 번째로, FdTable의 entries가 Vec<Option<Arc<OpenFile>>>인 이유는 파일을 닫으면 해당 슬롯을 None으로 만들어 재사용할 수 있기 때문입니다. 배열 인덱스가 곧 File Descriptor 번호가 되므로, O(1) 시간에 접근할 수 있습니다.

세 번째로, open() 메서드가 Arc::new()로 OpenFile을 생성하면, 나중에 dup()이나 fork()로 복제할 때 Arc::clone()만 하면 됩니다. 실제 OpenFile 객체는 복사되지 않고, 포인터만 복사되므로 메모리 효율적입니다.

여러분이 이 코드를 사용하면 프로세스 간 파일 공유가 자연스럽게 구현됩니다. 부모-자식 프로세스가 같은 Arc<OpenFile>을 가리키면, 한쪽이 읽으면 오프셋이 증가하여 다른 쪽도 영향을 받습니다.

이는 파이프와 리다이렉션의 기초가 됩니다.

실전 팁

💡 FdTable의 크기를 동적으로 늘리세요. 초기에는 작은 배열(예: 16개)로 시작하고, 필요하면 2배씩 증가시킵니다. 대부분의 프로세스는 적은 수의 파일만 열므로 메모리를 절약할 수 있습니다.

💡 표준 입출력(stdin=0, stdout=1, stderr=2)은 프로세스 생성 시 자동으로 할당하세요. 모든 프로그램이 이 규칙에 의존하므로, FdTable 초기화 시 처음 3개 슬롯을 미리 채워야 합니다.

💡 close() 구현 시 Arc의 strong_count()를 확인하지 마세요. Arc는 자동으로 참조 카운팅을 처리하므로, Option을 None으로만 바꾸면 됩니다. 마지막 참조가 사라지면 Drop trait이 자동으로 정리합니다.

💡 O_CLOEXEC 플래그를 지원하세요. exec() 호출 시 자동으로 파일을 닫아야 하는 경우가 많습니다. FileFlags에 비트 플래그를 추가하고, exec 구현에서 체크하면 됩니다.


3. FAT32 구조체와 부트 섹터 파싱

시작하며

여러분이 USB 드라이브를 꽂았을 때 운영체제가 어떻게 그 안의 파일들을 읽을 수 있는지 궁금했던 적 있나요? 디스크의 첫 섹터에는 파일 시스템의 모든 정보가 담긴 "지도"가 있습니다.

이런 초기화 과정은 모든 파일 시스템 구현의 시작점입니다. 부트 섹터를 읽지 못하면 파일 시스템 전체를 사용할 수 없습니다.

클러스터 크기, FAT 테이블 위치, 루트 디렉토리 위치 같은 핵심 정보들이 모두 여기에 있습니다. 바로 이럴 때 필요한 것이 부트 섹터 파싱입니다.

이것은 디스크의 첫 512바이트를 읽어서 FAT32 파일 시스템의 레이아웃을 파악하는 과정입니다.

개요

간단히 말해서, 부트 섹터는 파일 시스템의 메타데이터를 담고 있는 특별한 섹터입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 디스크의 물리적 위치를 논리적 파일로 변환하려면 클러스터 크기, 섹터 크기, FAT 테이블 시작 위치 같은 정보가 필요합니다.

예를 들어, 3번 클러스터가 실제로 디스크의 몇 번째 바이트인지 계산하려면 부트 섹터의 값들이 필요합니다. 전통적인 방법에서는 하드코딩된 값을 사용했다면, 이제는 디스크마다 다른 설정을 동적으로 읽어옵니다.

핵심 특징은 리틀 엔디안 바이트 해석, 구조체 필드 정렬, 체크섬 검증입니다. 이러한 특징들이 안정적인 파일 시스템 마운트를 보장합니다.

코드 예제

// FAT32 부트 섹터 구조체 (512바이트)
#[repr(C, packed)]
pub struct BootSector {
    jmp_boot: [u8; 3],
    oem_name: [u8; 8],
    bytes_per_sector: u16,      // 보통 512
    sectors_per_cluster: u8,    // 보통 8
    reserved_sectors: u16,
    num_fats: u8,               // 보통 2
    // ... 중략 ...
    fat_size_32: u32,           // FAT 테이블 크기(섹터 단위)
    root_cluster: u32,          // 루트 디렉토리 시작 클러스터
}

pub struct Fat32 {
    boot_sector: BootSector,
    disk: Arc<dyn BlockDevice>,
}

impl Fat32 {
    pub fn new(disk: Arc<dyn BlockDevice>) -> Result<Self, FsError> {
        let mut buf = [0u8; 512];
        disk.read_block(0, &mut buf)?;  // 첫 섹터 읽기

        // unsafe: 바이트 배열을 구조체로 재해석
        let boot_sector = unsafe {
            ptr::read_unaligned(buf.as_ptr() as *const BootSector)
        };

        Ok(Self { boot_sector, disk })
    }
}

설명

이것이 하는 일: 이 코드는 디스크의 첫 512바이트를 읽어서 BootSector 구조체로 변환합니다. #[repr(C, packed)] 속성은 Rust 컴파일러에게 구조체 필드를 바이트 정렬 없이 그대로 배치하라고 지시합니다.

첫 번째로, bytes_per_sector와 sectors_per_cluster를 곱하면 한 클러스터의 바이트 크기를 얻을 수 있습니다. FAT32는 클러스터 단위로 공간을 할당하므로, 이 값이 파일 시스템의 "할당 단위"가 됩니다.

보통 512 * 8 = 4096바이트입니다. 두 번째로, ptr::read_unaligned()를 사용하는 이유는 바이트 배열이 구조체의 정렬 요구사항을 만족하지 않을 수 있기 때문입니다.

일반 포인터 캐스팅은 정렬되지 않은 주소에서 크래시를 일으킬 수 있지만, read_unaligned는 안전하게 처리합니다. 세 번째로, root_cluster 필드가 루트 디렉토리의 시작점을 알려줍니다.

파일을 열 때는 루트부터 경로를 따라 내려가야 하므로, 이 값이 파일 탐색의 시작점이 됩니다. 여러분이 이 코드를 사용하면 다양한 FAT32 디스크를 자동으로 지원할 수 있습니다.

USB 드라이브마다 클러스터 크기가 다를 수 있지만, 부트 섹터를 읽으면 동적으로 대응할 수 있습니다. 하드코딩된 값을 사용하면 특정 디스크에서만 작동하겠죠.

실전 팁

💡 boot_sector의 시그니처(0x55AA)를 반드시 검증하세요. 마지막 2바이트가 0x55, 0xAA가 아니면 유효한 부트 섹터가 아닙니다. 잘못된 디스크를 마운트하면 시스템이 크래시할 수 있습니다.

💡 리틀 엔디안 변환을 고려하세요. x86은 리틀 엔디안이지만, from_le_bytes()를 명시적으로 호출하면 빅 엔디안 아키텍처에서도 작동합니다.

💡 BootSector를 Drop 구현하지 마세요. packed 구조체는 필드에 대한 참조를 만들 수 없으므로, 복잡한 타입(String, Vec 등)을 담을 수 없습니다. 모든 필드는 Copy 타입이어야 합니다.

💡 cluster_to_sector() 헬퍼 함수를 만드세요. 클러스터 번호를 섹터 번호로 변환하는 공식은 (cluster - 2) * sectors_per_cluster + data_start_sector입니다. 이 계산을 여러 곳에서 사용하므로 함수로 추출하세요.


4. 디렉토리 엔트리 탐색

시작하며

여러분이 "/home/user/documents/file.txt"라는 경로를 열 때, 운영체제가 어떻게 이 파일을 찾는지 생각해보세요. 루트부터 시작해서 "home" 디렉토리를 찾고, 그 안에서 "user"를 찾고, 또 그 안에서 "documents"를 찾고, 마지막으로 "file.txt"를 찾습니다.

이런 경로 탐색은 모든 파일 작업의 기초입니다. 각 단계에서 디렉토리는 자식 엔트리들의 목록을 담고 있으며, 우리는 이 목록을 선형 검색하거나 해시 테이블로 찾아야 합니다.

바로 이럴 때 필요한 것이 디렉토리 엔트리 탐색 알고리즘입니다. FAT32에서 디렉토리는 특별한 파일이며, 32바이트 엔트리들의 배열로 구성됩니다.

개요

간단히 말해서, 디렉토리 엔트리는 파일 이름, 크기, 시작 클러스터, 속성을 담고 있는 32바이트 구조체입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 경로의 각 부분을 inode로 변환해야 파일에 접근할 수 있습니다.

예를 들어, "documents" 문자열을 받으면 부모 디렉토리를 읽어서 해당 이름을 가진 엔트리를 찾고, 그 엔트리의 시작 클러스터를 얻어야 합니다. 전통적인 방법에서는 전체 경로를 한 번에 파싱했다면, 이제는 한 단계씩 내려가면서 심볼릭 링크나 마운트 포인트를 처리할 수 있습니다.

핵심 특징은 8.3 파일명 형식, LFN(Long File Name) 엔트리, 속성 비트마스크입니다. 이러한 특징들이 FAT32의 하위 호환성과 확장성을 보장합니다.

코드 예제

// FAT32 디렉토리 엔트리 (32바이트)
#[repr(C, packed)]
pub struct DirEntry {
    name: [u8; 11],         // 8.3 형식 (예: "FILE    TXT")
    attr: u8,               // 속성 비트
    reserved: u8,
    create_time_tenth: u8,
    // ... 시간 필드들 ...
    first_cluster_high: u16,
    first_cluster_low: u16,
    file_size: u32,
}

impl Fat32 {
    // 디렉토리에서 파일 찾기
    pub fn find_entry(&self, dir_cluster: u32, name: &str) -> Option<DirEntry> {
        let mut buf = vec![0u8; self.cluster_size()];
        self.read_cluster(dir_cluster, &mut buf).ok()?;

        // 32바이트씩 끊어서 엔트리 파싱
        for chunk in buf.chunks_exact(32) {
            let entry = unsafe {
                ptr::read_unaligned(chunk.as_ptr() as *const DirEntry)
            };

            // 삭제된 엔트리나 빈 엔트리 스킵
            if entry.name[0] == 0xE5 || entry.name[0] == 0x00 {
                continue;
            }

            if self.match_name(&entry, name) {
                return Some(entry);
            }
        }
        None
    }
}

설명

이것이 하는 일: 이 코드는 주어진 디렉토리 클러스터를 읽어서 32바이트 단위로 파싱하고, 각 엔트리의 이름을 검색 문자열과 비교합니다. 첫 번째로, cluster_size()는 부트 섹터에서 읽은 값들을 곱해서 계산합니다.

한 클러스터가 4KB라면 4096바이트를 읽어서, 그 안에 128개의 엔트리(4096 / 32 = 128)가 있을 수 있습니다. 두 번째로, chunks_exact(32)를 사용하면 정확히 32바이트씩 자를 수 있습니다.

만약 마지막에 32바이트가 안 되는 부분이 있으면 무시됩니다. 디렉토리는 항상 클러스터 크기의 배수이므로 문제없습니다.

세 번째로, 0xE5는 삭제된 파일을 의미하고, 0x00은 디렉토리 끝을 의미합니다. 0x00을 만나면 더 이상 검색할 필요가 없으므로 조기 종료할 수 있습니다(위 코드는 단순화를 위해 생략).

여러분이 이 코드를 사용하면 경로 탐색을 재귀적으로 구현할 수 있습니다. "/home/user"를 열려면 먼저 find_entry(root_cluster, "home")을 호출하고, 반환된 엔트리의 클러스터로 find_entry(home_cluster, "user")를 호출하면 됩니다.

실전 팁

💡 대소문자 구분 없이 비교하세요. FAT32는 대소문자를 구분하지 않으므로, "README.txt"와 "readme.txt"는 동일한 파일입니다. to_ascii_uppercase()로 변환 후 비교하세요.

💡 LFN(Long File Name) 엔트리를 지원하세요. 8.3 형식을 초과하는 파일명은 여러 엔트리에 나뉘어 저장됩니다. attr이 0x0F이면 LFN 엔트리이므로, 역순으로 읽어서 조합해야 합니다.

💡 디렉토리 캐싱을 구현하세요. 같은 디렉토리를 반복 검색하면 성능이 나빠집니다. HashMap<u32, Vec<DirEntry>>로 클러스터별 엔트리 목록을 캐싱하세요.

💡 "."과 ".." 엔트리를 특별히 처리하세요. 모든 디렉토리는 자기 자신(.)과 부모(..) 엔트리를 가지며, 이들은 검색에서 제외해야 합니다.


5. 파일 읽기 구현

시작하며

여러분이 100MB 파일을 읽을 때, 운영체제가 어떻게 디스크의 여러 조각에 흩어진 데이터를 순서대로 읽어오는지 궁금했던 적 있나요? FAT32는 "클러스터 체인"이라는 링크드 리스트로 파일을 저장합니다.

이런 구조는 파일의 단편화를 허용합니다. 디스크에 연속된 공간이 없어도 파일을 저장할 수 있죠.

하지만 그 대가로 파일을 읽으려면 FAT 테이블을 여러 번 참조해야 합니다. 바로 이럴 때 필요한 것이 클러스터 체인 따라가기 알고리즘입니다.

시작 클러스터부터 시작해서 FAT 테이블을 보고 다음 클러스터를 찾고, 또 그 다음을 찾고... EOC(End Of Chain)를 만날 때까지 반복합니다.

개요

간단히 말해서, FAT 테이블은 클러스터 번호를 인덱스로 받아 다음 클러스터 번호를 반환하는 배열입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 파일이 어떻게 배치되었는지 미리 알 수 없습니다.

예를 들어, 3번 클러스터에 있는 파일의 다음 부분이 4번에 있을 수도, 1000번에 있을 수도 있습니다. FAT 테이블을 보고 동적으로 따라가야 합니다.

전통적인 방법에서는 파일을 연속된 공간에 저장했다면, 이제는 단편화를 허용하면서도 읽기 성능을 유지할 수 있습니다(캐싱을 통해). 핵심 특징은 FAT 테이블 조회, EOC 마커 검출, 부분 읽기 지원입니다.

이러한 특징들이 유연한 파일 저장과 임의 접근을 가능하게 합니다.

코드 예제

impl Fat32 {
    // FAT 테이블에서 다음 클러스터 찾기
    fn next_cluster(&self, cluster: u32) -> Option<u32> {
        let fat_offset = cluster * 4;  // FAT32는 엔트리당 4바이트
        let fat_sector = self.boot_sector.reserved_sectors as u32
                         + (fat_offset / self.boot_sector.bytes_per_sector as u32);

        let mut buf = [0u8; 512];
        self.disk.read_block(fat_sector as usize, &mut buf).ok()?;

        let entry_offset = (fat_offset % self.boot_sector.bytes_per_sector as u32) as usize;
        let next = u32::from_le_bytes([
            buf[entry_offset], buf[entry_offset+1],
            buf[entry_offset+2], buf[entry_offset+3],
        ]) & 0x0FFFFFFF;  // 상위 4비트 제거

        // 0x0FFFFFF8 이상이면 EOC(End Of Chain)
        if next >= 0x0FFFFFF8 {
            None
        } else {
            Some(next)
        }
    }

    // 파일 읽기
    pub fn read_file(&self, start_cluster: u32, offset: usize,
                     buf: &mut [u8]) -> Result<usize, FsError> {
        let cluster_size = self.cluster_size();
        let mut current = start_cluster;
        let mut skip_clusters = offset / cluster_size;
        let mut bytes_read = 0;

        // offset까지 클러스터 스킵
        while skip_clusters > 0 {
            current = self.next_cluster(current).ok_or(FsError::InvalidCluster)?;
            skip_clusters -= 1;
        }

        // 실제 읽기
        let mut cluster_buf = vec![0u8; cluster_size];
        let start_offset = offset % cluster_size;

        loop {
            self.read_cluster(current, &mut cluster_buf)?;
            let to_copy = (cluster_size - start_offset).min(buf.len() - bytes_read);
            buf[bytes_read..bytes_read+to_copy]
                .copy_from_slice(&cluster_buf[start_offset..start_offset+to_copy]);

            bytes_read += to_copy;
            if bytes_read >= buf.len() {
                break;
            }

            current = self.next_cluster(current).ok_or(FsError::EndOfFile)?;
        }

        Ok(bytes_read)
    }
}

설명

이것이 하는 일: 이 코드는 두 단계로 파일을 읽습니다. 첫 번째 단계는 FAT 테이블을 참조하여 클러스터 체인을 따라가고, 두 번째 단계는 각 클러스터의 데이터를 실제로 읽어옵니다.

첫 번째로, next_cluster() 함수가 FAT 테이블의 물리적 위치를 계산합니다. FAT32는 4바이트 엔트리를 사용하므로, 3번 클러스터의 FAT 엔트리는 12바이트 오프셋(3 * 4)에 있습니다.

이 오프셋을 섹터 크기(512)로 나누면 어느 섹터인지 알 수 있습니다. 두 번째로, offset 매개변수가 파일 중간부터 읽기를 가능하게 합니다.

offset이 10000이고 클러스터 크기가 4096이면, 처음 2개 클러스터(8192바이트)를 스킵하고 3번째 클러스터의 1808바이트 위치부터 읽습니다. 세 번째로, 루프가 버퍼가 가득 찰 때까지 클러스터를 읽습니다.

copy_from_slice()로 클러스터 버퍼에서 사용자 버퍼로 복사하며, 첫 클러스터는 start_offset부터, 나머지는 처음부터 복사합니다. 여러분이 이 코드를 사용하면 lseek() 시스템 콜을 쉽게 구현할 수 있습니다.

OpenFile의 offset을 변경하고, 다음 read()에서 그 위치부터 읽으면 됩니다. 전체 파일을 처음부터 읽을 필요가 없죠.

실전 팁

💡 FAT 테이블을 캐싱하세요. 매번 디스크에서 읽으면 성능이 끔찍합니다. 시스템 시작 시 전체 FAT를 메모리에 로드하거나, LRU 캐시로 최근 조회한 엔트리만 유지하세요.

💡 클러스터 체인을 한 번에 구축하세요. read_file()이 호출될 때마다 체인을 따라가지 말고, 파일을 열 때 Vec<u32>로 전체 체인을 만들어두면 임의 접근이 O(1)이 됩니다.

💡 read_cluster()에서 부분 읽기를 지원하세요. 마지막 클러스터는 일부만 사용될 수 있으므로, file_size를 체크하여 불필요한 바이트를 반환하지 마세요.

💡 Scatter-Gather I/O를 구현하세요. 여러 클러스터가 연속되어 있으면 한 번의 디스크 읽기로 처리할 수 있습니다. readv() 시스템 콜처럼 iovec 배열을 받는 함수를 추가하세요.


6. 파일 쓰기와 클러스터 할당

시작하며

여러분이 새 파일을 만들거나 기존 파일에 데이터를 추가할 때, 운영체제가 어떻게 빈 클러스터를 찾아서 할당하는지 생각해보세요. 디스크에는 수천 개의 클러스터가 있는데, 어느 것이 사용 중이고 어느 것이 비어있는지 어떻게 알까요?

이런 공간 관리 문제는 모든 파일 시스템의 핵심입니다. FAT32는 FAT 테이블 자체를 비트맵처럼 사용합니다.

엔트리 값이 0이면 빈 클러스터이고, 0이 아니면 사용 중입니다. 바로 이럴 때 필요한 것이 클러스터 할당 알고리즘입니다.

빈 클러스터를 찾아서 체인에 연결하고, FAT 테이블과 디렉토리 엔트리를 업데이트해야 합니다.

개요

간단히 말해서, 파일 쓰기는 빈 클러스터를 찾고, 데이터를 쓰고, FAT 테이블을 업데이트하는 3단계 과정입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 파일이 커지면 새 클러스터가 필요합니다.

예를 들어, 4KB 파일에 1KB를 추가하면 두 번째 클러스터를 할당하고 첫 번째 클러스터의 FAT 엔트리를 업데이트하여 체인을 만들어야 합니다. 전통적인 방법에서는 파일 크기를 미리 예약했다면, 이제는 필요할 때마다 동적으로 할당하여 공간을 절약합니다.

핵심 특징은 First Fit 할당, FAT 동기화, 디렉토리 엔트리 업데이트입니다. 이러한 특징들이 일관성 있는 파일 시스템을 유지합니다.

코드 예제

impl Fat32 {
    // 빈 클러스터 찾기
    fn allocate_cluster(&mut self) -> Option<u32> {
        // FAT 테이블 선형 검색
        for cluster in 2..self.total_clusters() {  // 0, 1은 예약됨
            if self.get_fat_entry(cluster) == 0 {
                // EOC 마커 쓰기
                self.set_fat_entry(cluster, 0x0FFFFFFF)?;
                return Some(cluster);
            }
        }
        None  // 디스크 풀
    }

    // 파일 쓰기
    pub fn write_file(&mut self, start_cluster: u32, offset: usize,
                      data: &[u8]) -> Result<usize, FsError> {
        let cluster_size = self.cluster_size();
        let mut current = start_cluster;
        let mut skip_clusters = offset / cluster_size;

        // offset까지 클러스터 이동 (필요하면 할당)
        while skip_clusters > 0 {
            let next = self.next_cluster(current);
            current = match next {
                Some(c) => c,
                None => {
                    // 체인 끝이면 새 클러스터 할당
                    let new = self.allocate_cluster()
                        .ok_or(FsError::DiskFull)?;
                    self.set_fat_entry(current, new)?;
                    new
                }
            };
            skip_clusters -= 1;
        }

        // 데이터 쓰기
        let mut bytes_written = 0;
        let start_offset = offset % cluster_size;

        while bytes_written < data.len() {
            let mut cluster_buf = vec![0u8; cluster_size];
            // 부분 쓰기면 기존 데이터 먼저 읽기
            if start_offset != 0 || data.len() - bytes_written < cluster_size {
                self.read_cluster(current, &mut cluster_buf)?;
            }

            let to_write = (cluster_size - start_offset).min(data.len() - bytes_written);
            cluster_buf[start_offset..start_offset+to_write]
                .copy_from_slice(&data[bytes_written..bytes_written+to_write]);

            self.write_cluster(current, &cluster_buf)?;
            bytes_written += to_write;

            if bytes_written < data.len() {
                // 다음 클러스터 필요
                let next = self.next_cluster(current);
                current = match next {
                    Some(c) => c,
                    None => {
                        let new = self.allocate_cluster()
                            .ok_or(FsError::DiskFull)?;
                        self.set_fat_entry(current, new)?;
                        new
                    }
                };
            }
        }

        Ok(bytes_written)
    }
}

설명

이것이 하는 일: 이 코드는 파일에 데이터를 쓰면서 필요에 따라 클러스터를 동적으로 할당합니다. offset이 파일 끝을 넘으면 새 클러스터를 체인에 추가합니다.

첫 번째로, allocate_cluster()가 FAT 테이블을 순회하면서 값이 0인 엔트리를 찾습니다. 찾으면 0x0FFFFFFF(EOC 마커)로 설정하여 "사용 중"으로 표시합니다.

이 선형 검색은 O(n)이지만, 실제로는 free cluster hint를 유지하여 최적화합니다. 두 번째로, 부분 쓰기를 처리하기 위해 기존 데이터를 먼저 읽습니다.

예를 들어, 4KB 클러스터의 중간 1KB만 수정하려면 전체를 읽고, 1KB를 덮어쓰고, 다시 전체를 써야 합니다. 이것이 Read-Modify-Write 패턴입니다.

세 번째로, set_fat_entry(current, new)가 클러스터 체인을 확장합니다. 현재 클러스터의 FAT 엔트리가 EOC였다면, 이제 새 클러스터 번호를 가리키고, 새 클러스터는 EOC가 됩니다.

여러분이 이 코드를 사용하면 append 모드를 쉽게 구현할 수 있습니다. offset을 파일 크기로 설정하고 write_file()을 호출하면, 자동으로 체인 끝에 클러스터가 추가됩니다.

파일 크기도 디렉토리 엔트리에서 업데이트해야 한다는 점만 기억하세요.

실전 팁

💡 FAT 테이블을 두 개 모두 업데이트하세요. FAT32는 보통 중복 FAT를 두 개 유지하므로, set_fat_entry()에서 양쪽을 동기화해야 합니다. 한쪽만 업데이트하면 fsck 에러가 발생합니다.

💡 디렉토리 엔트리의 file_size를 업데이트하세요. 클러스터를 할당해도 디렉토리 엔트리를 업데이트하지 않으면 파일이 여전히 작은 크기로 보입니다.

💡 Best Fit이나 Next Fit 알고리즘을 고려하세요. First Fit은 단순하지만 단편화를 유발합니다. last_allocated_cluster 힌트를 유지하면 디스크의 다른 영역을 사용하여 단편화를 줄일 수 있습니다.

💡 트랜잭션을 지원하세요. 쓰기 중 크래시가 발생하면 파일 시스템이 일관성을 잃을 수 있습니다. 저널링이나 COW(Copy-On-Write)를 구현하여 원자성을 보장하세요.


7. 버퍼 캐시 전략

시작하며

여러분이 같은 파일을 반복해서 읽을 때, 매번 디스크에 접근하면 얼마나 느릴까요? 디스크 읽기는 밀리초 단위인데, 메모리 접근은 나노초 단위입니다.

1000배 이상 차이가 나죠. 이런 성능 격차를 해결하는 것이 운영체제의 핵심 역할입니다.

최근에 읽은 데이터를 메모리에 보관하면, 다음 접근 시 디스크를 건너뛸 수 있습니다. 이것을 페이지 캐시 또는 버퍼 캐시라고 부릅니다.

바로 이럴 때 필요한 것이 LRU(Least Recently Used) 캐시입니다. 제한된 메모리에서 가장 자주 사용되는 데이터를 유지하는 전략입니다.

개요

간단히 말해서, 버퍼 캐시는 디스크 블록을 메모리에 저장하여 반복 접근을 빠르게 만드는 자료구조입니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 디스크 I/O는 전체 시스템 성능의 병목입니다.

예를 들어, FAT 테이블을 매번 디스크에서 읽으면 파일 하나 여는 데 수십 밀리초가 걸리지만, 캐싱하면 마이크로초로 줄어듭니다. 전통적인 방법에서는 단순한 해시맵을 사용했다면, 이제는 LRU 정책으로 메모리를 효율적으로 관리합니다.

핵심 특징은 LRU 교체 정책, dirty 플래그, write-back 전략입니다. 이러한 특징들이 성능과 일관성의 균형을 맞춥니다.

코드 예제

use std::collections::{HashMap, VecDeque};

pub struct BlockCache {
    cache: HashMap<u64, CacheEntry>,  // 블록 번호 -> 데이터
    lru_queue: VecDeque<u64>,         // 최근 사용 순서
    max_size: usize,                  // 최대 캐시 크기
}

struct CacheEntry {
    data: Vec<u8>,
    dirty: bool,  // 수정되었는지
}

impl BlockCache {
    // 블록 읽기 (캐시 활용)
    pub fn read_block(&mut self, disk: &dyn BlockDevice,
                      block: u64, buf: &mut [u8]) -> Result<(), IoError> {
        if let Some(entry) = self.cache.get(&block) {
            // 캐시 히트!
            buf.copy_from_slice(&entry.data);
            self.touch(block);  // LRU 업데이트
            return Ok(());
        }

        // 캐시 미스 - 디스크에서 읽기
        disk.read_block(block as usize, buf)?;

        // 캐시에 추가
        self.insert(block, buf.to_vec(), false);
        Ok(())
    }

    // 블록 쓰기 (write-back)
    pub fn write_block(&mut self, block: u64, data: &[u8]) {
        self.insert(block, data.to_vec(), true);  // dirty = true
    }

    fn insert(&mut self, block: u64, data: Vec<u8>, dirty: bool) {
        // 캐시가 가득 찼으면 LRU 제거
        if self.cache.len() >= self.max_size {
            if let Some(old_block) = self.lru_queue.pop_front() {
                if let Some(entry) = self.cache.remove(&old_block) {
                    if entry.dirty {
                        // dirty 블록은 디스크에 쓰기 (실제로는 비동기)
                        // disk.write_block(old_block, &entry.data);
                    }
                }
            }
        }

        self.cache.insert(block, CacheEntry { data, dirty });
        self.lru_queue.push_back(block);
    }

    fn touch(&mut self, block: u64) {
        // LRU 큐에서 제거하고 끝에 추가
        self.lru_queue.retain(|&b| b != block);
        self.lru_queue.push_back(block);
    }
}

설명

이것이 하는 일: 이 코드는 블록 번호를 키로 하는 해시맵과 LRU 큐를 결합하여 캐시를 구현합니다. 읽기 시 캐시를 먼저 확인하고, 없으면 디스크에서 읽어서 캐시에 추가합니다.

첫 번째로, read_block()이 캐시 히트를 확인합니다. HashMap의 get()은 O(1)이므로, 메모리에 있는지 확인하는 데 거의 시간이 걸리지 않습니다.

히트하면 touch()로 LRU 큐를 업데이트하여 "최근 사용"으로 표시합니다. 두 번째로, write_block()이 즉시 디스크에 쓰지 않고 dirty 플래그만 설정합니다.

이것이 write-back 전략입니다. 나중에 캐시에서 제거될 때 또는 sync() 호출 시 실제로 디스크에 쓰입니다.

이렇게 하면 같은 블록을 여러 번 수정해도 디스크 쓰기는 한 번만 발생합니다. 세 번째로, LRU 교체가 발생합니다.

캐시가 가득 차면 lru_queue의 앞(가장 오래 사용되지 않은 블록)을 제거합니다. 만약 dirty 플래그가 설정되어 있으면 디스크에 쓴 후 제거합니다.

여러분이 이 코드를 사용하면 파일 시스템 성능이 극적으로 향상됩니다. 실제 측정에서는 캐시 히트율이 90% 이상인 경우가 많으므로, 디스크 접근이 1/10로 줄어듭니다.

실전 팁

💡 VecDeque보다 LinkedHashMap을 사용하세요. touch() 메서드가 O(n) retain()을 호출하므로 비효율적입니다. lru 크레이트의 LruCache를 사용하면 O(1)에 처리됩니다.

💡 블록 크기를 페이지 크기(4KB)로 맞추세요. 메모리와 디스크의 단위를 일치시키면 복사 오버헤드가 줄어듭니다.

💡 주기적으로 flush하세요. dirty 블록이 메모리에만 있다가 크래시가 발생하면 데이터가 손실됩니다. 5초마다 dirty 블록들을 디스크에 쓰는 백그라운드 스레드를 만드세요.

💡 읽기-앞서-읽기(Read-Ahead)를 구현하세요. 블록 N을 읽으면 N+1, N+2도 미리 읽어서 캐시에 넣으세요. 순차 읽기 패턴에서 성능이 크게 향상됩니다.

💡 ARC(Adaptive Replacement Cache)를 고려하세요. LRU는 단순하지만, 최근성과 빈도를 동시에 고려하는 ARC가 히트율이 더 높습니다.


8. inode 구조 설계

시작하며

여러분이 파일 시스템을 추상화할 때, FAT32, ext4, btrfs 같은 서로 다른 시스템들을 어떻게 통합할 수 있을까요? 각 시스템은 파일을 다르게 표현하지만, VFS 레이어에는 공통 인터페이스가 필요합니다.

이런 추상화 문제를 해결하는 것이 inode 구조입니다. Unix 철학에서 모든 것은 파일이며, inode는 파일의 추상적 표현입니다.

이름은 디렉토리에 있지만, 실제 메타데이터는 inode에 있습니다. 바로 이럴 때 필요한 것이 범용 inode 설계입니다.

파일 타입, 크기, 권한, 타임스탬프, 데이터 위치를 담는 구조체입니다.

개요

간단히 말해서, inode는 파일의 메타데이터를 담는 자료구조이며, 파일 시스템마다 다른 표현을 VFS 레이어에서 통합합니다. 왜 이것이 필요한지 실무 관점에서 설명하면, 시스템 콜(read, write, stat)이 파일 시스템의 종류를 모르고 작동해야 합니다.

예를 들어, FAT32의 디렉토리 엔트리와 ext4의 inode는 완전히 다르지만, 둘 다 공통 inode 구조체로 변환되어 상위 레이어에서 동일하게 다뤄집니다. 전통적인 방법에서는 각 파일 시스템마다 별도의 데이터 구조를 사용했다면, 이제는 단일 inode 타입으로 통합하여 코드 중복을 제거합니다.

핵심 특징은 파일 타입 플래그, 참조 카운팅, 파일 시스템별 private 데이터입니다. 이러한 특징들이 유연하고 확장 가능한 VFS를 만듭니다.

코드 예제

use std::time::SystemTime;

// VFS inode - 파일 시스템 독립적
pub struct Inode {
    pub ino: u64,               // inode 번호 (고유 식별자)
    pub mode: FileMode,         // 파일 타입 + 권한
    pub size: u64,              // 파일 크기 (바이트)
    pub atime: SystemTime,      // 마지막 접근 시간
    pub mtime: SystemTime,      // 마지막 수정 시간
    pub ctime: SystemTime,      // 메타데이터 변경 시간
    pub nlink: u32,             // 하드링크 수
    pub uid: u32,               // 소유자 ID
    pub gid: u32,               // 그룹 ID
    pub fs_private: Box<dyn std::any::Any>,  // 파일 시스템별 데이터
}

bitflags! {
    pub struct FileMode: u16 {
        const TYPE_MASK = 0xF000;
        const REGULAR   = 0x8000;  // 일반 파일
        const DIRECTORY = 0x4000;  // 디렉토리
        const SYMLINK   = 0xA000;  // 심볼릭 링크

        const RUSR = 0o400;  // 소유자 읽기
        const WUSR = 0o200;  // 소유자 쓰기
        const XUSR = 0o100;  // 소유자 실행
        // ... 그룹, 기타 권한
    }
}

// FAT32 전용 데이터
struct Fat32InodePrivate {
    start_cluster: u32,
    cluster_chain: Vec<u32>,  // 미리 계산된 체인
}

impl Inode {
    // FAT32 디렉토리 엔트리를 inode로 변환
    pub fn from_fat32(entry: &DirEntry, ino: u64) -> Self {
        let start_cluster = ((entry.first_cluster_high as u32) << 16)
                          | (entry.first_cluster_low as u32);

        let mode = if entry.attr & 0x10 != 0 {
            FileMode::DIRECTORY | FileMode::RUSR | FileMode::XUSR
        } else {
            FileMode::REGULAR | FileMode::RUSR | FileMode::WUSR
        };

        Self {
            ino,
            mode,
            size: entry.file_size as u64,
            nlink: 1,
            fs_private: Box::new(Fat32InodePrivate {
                start_cluster,
                cluster_chain: Vec::new(),
            }),
            // ... 타임스탬프 파싱
        }
    }
}

설명

이것이 하는 일: 이 코드는 VFS 레이어의 공통 inode 구조체를 정의하고, 각 파일 시스템이 자신의 표현을 inode로 변환하는 방법을 제공합니다. 첫 번째로, FileMode가 비트플래그로 파일 타입과 권한을 동시에 표현합니다.

상위 4비트(0xF000)는 파일 타입(일반 파일, 디렉토리, 심볼릭 링크 등), 하위 비트는 Unix 권한(rwxrwxrwx)을 나타냅니다. 이것은 POSIX 표준을 따릅니다.

두 번째로, fs_private 필드가 Box<dyn Any>를 사용하여 파일 시스템별 데이터를 저장합니다. FAT32는 start_cluster를 저장하고, ext4는 block pointers를 저장할 수 있습니다.

VFS는 이 내용을 몰라도 되고, 실제 I/O 시에만 downcast하여 사용합니다. 세 번째로, from_fat32() 메서드가 FAT32의 디렉토리 엔트리를 inode로 변환합니다.

first_cluster_high와 low를 조합하여 32비트 클러스터 번호를 만들고, attr 비트를 검사하여 파일 타입을 결정합니다. 여러분이 이 코드를 사용하면 새로운 파일 시스템을 추가할 때 변환 함수만 작성하면 됩니다.

VFS의 나머지 코드(open, read, write)는 전혀 수정할 필요가 없습니다. 이것이 추상화의 힘입니다.

실전 팁

💡 inode 캐시를 구현하세요. inode를 매번 디스크에서 읽으면 비효율적입니다. HashMap<u64, Arc<Inode>>로 inode 번호별 캐싱을 하면, 같은 파일을 여러 번 열어도 메모리에 하나만 존재합니다.

💡 atime 업데이트를 최적화하세요. 모든 읽기마다 atime을 업데이트하면 디스크 쓰기가 폭증합니다. relatime 옵션을 사용하여 mtime보다 오래되었을 때만 업데이트하세요.

💡 nlink를 정확히 관리하세요. 하드링크 생성 시 증가, 삭제 시 감소하며, 0이 되면 실제 데이터를 삭제합니다. 디렉토리는 자동으로 nlink >= 2입니다(자기 자신과 ".").

💡 RwLock으로 동시성을 제어하세요. 여러 스레드가 같은 inode를 읽는 것은 허용하되, 쓰기는 배타적으로 처리해야 합니다. Arc<RwLock<Inode>>를 사용하세요.

💡 cluster_chain을 lazy하게 계산하세요. 파일을 열 때 전체 체인을 구축하지 말고, 실제로 필요한 부분만 계산하세요. 큰 파일의 open() 성능이 향상됩니다.


#Rust#FileSystem#VFS#FAT32#OSdev#시스템프로그래밍

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.