파일 시스템이란
이전 글
에서 virtio-blk 드라이버를 구현해 디스크를 섹터 단위로 읽고 쓸 수 있게 되었다. 하지만 read_write_disk는 “몇 번째 섹터를 읽어라"라는 저수준 인터페이스다. 사용자 입장에서는 “hello.txt를 열어서 내용을 보여줘"가 자연스럽다.
섹터 번호와 파일 이름 사이의 간극을 메우는 것이 파일 시스템이다. 파일 시스템은 디스크 위에 파일 이름, 크기, 데이터 위치 등의 메타데이터를 구조화해서 저장하고, 이를 통해 이름 기반 접근을 가능하게 한다.
tar를 파일 시스템으로 사용하기
실제 OS에서는 FAT, ext2, NTFS 같은 파일 시스템을 사용하지만, 이 튜토리얼에서는 tar 아카이브를 파일 시스템으로 사용한다. tar는 원래 자기 테이프(Tape ARchive)용으로 탄생한 포맷으로, 여러 파일을 순차적으로 나열하는 단순한 구조를 가지고 있다.
+----------------+
| tar header | <- 파일 이름, 크기 등 메타데이터
+----------------+
| file data | <- 실제 파일 내용
+----------------+
| tar header |
+----------------+
| file data |
+----------------+
| ... |각 파일마다 “헤더 + 데이터” 쌍이 하나씩 붙는 구조다. FAT의 클러스터 체인이나 ext2의 inode 같은 복잡한 자료구조 없이, 앞에서부터 순서대로 읽으면 모든 파일을 찾을 수 있다.
tar의 한계순차 접근에 최적화된 포맷이라 랜덤 접근에는 부적합하다. 특정 파일을 찾으려면 처음부터 헤더를 하나씩 탐색해야 한다. 교육 목적으로는 이상적이지만, 실제 OS에서 쓰기엔 성능이 부족하다.여러 종류의 tar 포맷이 존재하는데, 이 구현에서는 ustar 포맷을 사용한다.
디스크 이미지 생성
파일 시스템의 내용을 담을 disk 디렉토리를 만들고 파일을 넣는다.
mkdir disk
echo 'Hello, OS!' > disk/hello.txt
vim disk/meow.txt빌드 스크립트에 tar 이미지 생성과 QEMU 디스크 연결을 추가한다.
run.sh
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-drive id=drive0,file=lorem.txt,format=raw,if=none \
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \
-kernel kernel.elf
(cd disk && tar cf ../disk.tar --format=ustar *.txt)
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-drive id=drive0,file=disk.tar,format=raw,if=none \
-device virtio-blk-device,drive=drive0,bus=virtio-mmio-bus.0 \
-kernel kernel.elf
tar cf는 tar 파일을 생성(create)하는 명령이고, --format=ustar는 ustar 포맷을 지정한다. 괄호 (...)는 서브셸을 만들어서 cd가 스크립트의 나머지 부분에 영향을 주지 않도록 한다.
이전 글에서 lorem.txt를 디스크 이미지로 사용했는데, 이제 disk.tar로 교체한다. QEMU 옵션 자체는 동일하고 파일만 바뀐 것이다.
데이터 구조 정의
kernel.h에 tar 헤더 구조체와 파일 관리 구조체를 정의한다.
#define FILES_MAX 2
#define DISK_MAX_SIZE align_up(sizeof(struct file) * FILES_MAX, SECTOR_SIZE)
struct tar_header {
char name[100];
char mode[8];
char uid[8];
char gid[8];
char size[12];
char mtime[12];
char checksum[8];
char type;
char linkname[100];
char magic[6];
char version[2];
char uname[32];
char gname[32];
char devmajor[8];
char devminor[8];
char prefix[155];
char padding[12];
char data[]; // 헤더 뒤에 이어지는 데이터 영역을 가리키는 flexible array member
} __attribute__((packed));
struct file {
bool in_use; // 이 파일 엔트리가 사용 중인지
char name[100]; // 파일 이름
char data[1024]; // 파일 내용
size_t size; // 파일 크기
};tar_header는 ustar 포맷의 헤더를 그대로 C 구조체로 옮긴 것이다. data[]는 flexible array memberC99에서 도입된 기능으로, 구조체의 마지막 멤버를 크기 없는 배열로 선언하면 구조체 바로 뒤의 메모리를 배열처럼 접근할 수 있다. 별도의 메모리를 할당하는 게 아니라 헤더 바로 다음에 있는 파일 데이터를 가리키는 포인터 역할을 한다.로, tar 헤더 바로 뒤에 이어지는 파일 데이터를 가리킨다.
struct file은 메모리에 로드된 파일을 관리하는 구조체다. 이 구현에서는 부팅 시 디스크의 모든 파일을 메모리로 읽어들이는 방식을 쓴다.
파일 시스템 읽기
struct file files[FILES_MAX];
uint8_t disk[DISK_MAX_SIZE];
int oct2int(char *oct, int len) {
int dec = 0;
for (int i = 0; i < len; i++) {
if (oct[i] < '0' || oct[i] > '7')
break;
dec = dec * 8 + (oct[i] - '0');
}
return dec;
}
void fs_init(void) {
for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
read_write_disk(&disk[sector * SECTOR_SIZE], sector, false);
unsigned off = 0;
for (int i = 0; i < FILES_MAX; i++) {
struct tar_header *header = (struct tar_header *) &disk[off];
if (header->name[0] == '\0')
break;
if (strcmp(header->magic, "ustar") != 0)
PANIC("invalid tar header: magic=\"%s\"", header->magic);
int filesz = oct2int(header->size, sizeof(header->size));
struct file *file = &files[i];
file->in_use = true;
strcpy(file->name, header->name);
memcpy(file->data, header->data, filesz);
file->size = filesz;
printf("file: %s, size=%d\n", file->name, file->size);
off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE);
}
}fs_init의 흐름은 다음과 같다.
read_write_disk로 디스크 전체를disk버퍼에 로드disk버퍼를 순회하면서 tar 헤더를 파싱- 각 파일의 이름, 크기, 데이터를
files[]배열에 복사
disk와 files가 스택이 아닌 정적 변수로 선언된 이유는 스택 크기가 제한적이기 때문이다.
여기서 주의할 점은 tar 헤더의 숫자 필드(size 등)가 8진수 문자열이라는 것이다. "000644"처럼 생겨서 10진수로 착각하기 쉽지만, 실제로는 8진수다. oct2int는 이 8진수 문자열을 정수로 변환한다.
off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE)는 다음 파일의 헤더 위치를 계산한다. tar에서 각 파일 엔트리는 섹터 크기(512바이트) 단위로 정렬되어 있기 때문에 align_up이 필요하다.
kernel_main에서 virtio_blk_init() 다음에 fs_init()을 호출한다.
kernel.c - kernel_main
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init();
char buf[SECTOR_SIZE];
read_write_disk(buf, 0, false);
printf("first sector: %s\n", buf);
strcpy(buf, "hello from kernel!!!\n");
read_write_disk(buf, 0, true);
...
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
virtio_blk_init();
fs_init();
...
이전 글에서 테스트용으로 추가했던 read_write_disk 호출도 제거한다.
파일 읽기 테스트
$ ./run.sh
virtio-blk: capacity is 10240 bytes
file: hello.txt, size=11
file: meow.txt, size=0disk 디렉토리에 넣었던 파일들이 정상적으로 인식된다.
파일 시스템 쓰기
fs_flush는 메모리의 files[]를 tar 포맷으로 직렬화해서 디스크에 기록하는 함수다.
void fs_flush(void) {
// files[]를 disk 버퍼에 tar 형식으로 구성
memset(disk, 0, sizeof(disk));
unsigned off = 0;
for (int file_i = 0; file_i < FILES_MAX; file_i++) {
struct file *file = &files[file_i];
if (!file->in_use)
continue;
struct tar_header *header = (struct tar_header *) &disk[off];
memset(header, 0, sizeof(*header));
strcpy(header->name, file->name);
strcpy(header->mode, "000644");
strcpy(header->magic, "ustar");
strcpy(header->version, "00");
header->type = '0';
// 파일 크기를 8진수 문자열로 변환
int filesz = file->size;
for (int i = sizeof(header->size); i > 0; i--) {
header->size[i - 1] = (filesz % 8) + '0';
filesz /= 8;
}
// 체크섬 계산
int checksum = ' ' * sizeof(header->checksum);
for (unsigned i = 0; i < sizeof(struct tar_header); i++)
checksum += (unsigned char) disk[off + i];
for (int i = 5; i >= 0; i--) {
header->checksum[i] = (checksum % 8) + '0';
checksum /= 8;
}
// 파일 데이터 복사
memcpy(header->data, file->data, file->size);
off += align_up(sizeof(struct tar_header) + file->size, SECTOR_SIZE);
}
// disk 버퍼를 virtio-blk에 기록
for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
read_write_disk(&disk[sector * SECTOR_SIZE], sector, true);
printf("wrote %d bytes to disk\n", sizeof(disk));
}흐름은 fs_init의 역순이다.
disk버퍼를 0으로 초기화files[]를 순회하면서 tar 헤더를 구성 (이름, 모드, 매직 넘버, 파일 크기를 8진수로 변환, 체크섬 계산)- 파일 데이터를 헤더 뒤에 복사
read_write_disk로disk버퍼 전체를 디스크에 기록
체크섬 계산에서 int checksum = ' ' * sizeof(header->checksum)로 한 것은 tar 사양에서 체크섬 필드 자체는 공백(0x20)으로 채운 것으로 간주하고 헤더 전체 바이트를 더하도록 정의되어 있기 때문이다.
시스템 콜 추가
파일 시스템의 읽기/쓰기를 애플리케이션에서 사용할 수 있도록 시스템 콜을 추가한다.
#define SYS_READFILE 4
#define SYS_WRITEFILE 5int readfile(const char *filename, char *buf, int len) {
return syscall(SYS_READFILE, (int) filename, (int) buf, len);
}
int writefile(const char *filename, const char *buf, int len) {
return syscall(SYS_WRITEFILE, (int) filename, (int) buf, len);
}int readfile(const char *filename, char *buf, int len);
int writefile(const char *filename, const char *buf, int len);이 시스템 콜은 파일 이름을 직접 인자로 받는다. POSIX의 read/write가 파일 디스크립터open()이 반환하는 정수로, 커널 내부의 열린 파일 테이블 인덱스다. 파일 디스크립터를 쓰면 같은 파일을 여러 번 열어 각각 독립적인 오프셋을 유지할 수 있고, 파일 이름 해석 비용도 한 번만 지불하면 된다.를 사용하는 것과 대비되는 단순화된 설계다.
커널 측 구현
struct file *fs_lookup(const char *filename) {
for (int i = 0; i < FILES_MAX; i++) {
struct file *file = &files[i];
if (!strcmp(file->name, filename))
return file;
}
return NULL;
}fs_lookup은 files[] 배열을 선형 탐색해서 이름이 일치하는 파일을 찾는다.
kernel.c - handle_syscall
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
case SYS_PUTCHAR:
...
case SYS_GETCHAR:
...
case SYS_EXIT:
...
default:
PANIC("unexpected syscall a3=%x\n", f->a3);
}
}
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
case SYS_PUTCHAR:
...
case SYS_GETCHAR:
...
case SYS_EXIT:
...
case SYS_READFILE:
case SYS_WRITEFILE: {
const char *filename = (const char *) f->a0;
char *buf = (char *) f->a1;
int len = f->a2;
struct file *file = fs_lookup(filename);
if (!file) {
printf("file not found: %s\n", filename);
f->a0 = -1;
break;
}
if (len > (int) sizeof(file->data))
len = file->size;
if (f->a3 == SYS_WRITEFILE) {
memcpy(file->data, buf, len);
file->size = len;
fs_flush();
} else {
memcpy(buf, file->data, len);
}
f->a0 = len;
break;
}
default:
PANIC("unexpected syscall a3=%x\n", f->a3);
}
}
읽기와 쓰기 로직이 거의 동일하므로 하나의 case에서 처리한다. 읽기는 file->data를 사용자 버퍼로 복사하고, 쓰기는 반대로 복사한 뒤 fs_flush()로 디스크에 반영한다.
셸에 readfile/writefile 명령 추가
셸에서 파일 읽기/쓰기를 테스트하기 위해, 하드코딩된 hello.txt를 대상으로 하는 명령어를 추가한다.
shell.c
if (strcmp(cmdline, "hello") == 0)
printf("Hello world from shell!\n");
else if (strcmp(cmdline, "exit") == 0)
exit();
else
printf("unknown command: %s\n", cmdline);
if (strcmp(cmdline, "hello") == 0)
printf("Hello world from shell!\n");
else if (strcmp(cmdline, "exit") == 0)
exit();
else if (strcmp(cmdline, "readfile") == 0) {
char buf[128];
int len = readfile("hello.txt", buf, sizeof(buf));
buf[len] = '\0';
printf("%s\n", buf);
}
else if (strcmp(cmdline, "writefile") == 0)
writefile("hello.txt", "Hello from shell!\n", 19);
else
printf("unknown command: %s\n", cmdline);
Page Fault: SUM 비트 문제
셸에 명령을 추가하고 실행하면, 예상과 달리 Page Fault가 발생한다.
$ ./run.sh
> readfile
PANIC: kernel.c:152: unexpected trap scause=0000000d, stval=010004ed, sepc=8020186ascause=0xd는 Load Page Fault다. stval=0x010004ed은 폴트가 발생한 가상 주소, sepc=0x8020186a는 폴트를 일으킨 명령어 주소다.
llvm-objdump로 확인하면 strcmp 함수 내부에서 폴트가 발생한 것을 알 수 있다.
$ llvm-objdump -d kernel.elf
80201862 <strcmp>:
80201862: 03 46 05 00 lbu a2, 0(a0)
80201866: 15 c2 beqz a2, 0x8020188a <strcmp+0x28>
80201868: 05 05 addi a0, a0, 1
8020186a: 83 c6 05 00 lbu a3, 0(a1) <- 여기서 page faultQEMU 모니터에서 stval=010004ed의 페이지 테이블인 0x01000000부분을 확인하면 해당 가상 주소는 정상적으로 매핑되어 있고, rwxu 권한도 있다.
(qemu) info mem
vaddr paddr size attr
-------- ---------------- -------- -------
01000000 000000008026d000 00001000 rwxu-a-매핑도 정상, 권한도 정상인데 왜 Page Fault가 발생할까? 원인은 RISC-V의 SUM(Supervisor User Memory access) 비트다.
SUM 비트란
RISC-V에서는 S-Mode(커널)가 U-Mode(사용자) 페이지에 접근할 수 있는지를 sstatus CSR의 SUM 비트로 제어한다. SUM 비트가 0이면, 커널 코드가 사용자 페이지를 읽거나 쓸 수 없다.
이건 의도적인 안전장치다. 커널이 실수로 사용자 메모리를 참조하는 버그를 방지하기 위함이다.
시스템 콜에서 fs_lookup(filename)이 호출되면, filename은 사용자 공간의 문자열이다. 커널의 strcmp가 이 주소를 읽으려 할 때 SUM 비트가 0이라 Page Fault가 발생한 것이다.
해결
sstatus의 SUM 비트를 사용자 공간 진입 시 설정하면 된다.
#define SSTATUS_SUM (1 << 18)kernel.c - user_entry
__attribute__((naked)) void user_entry(void) {
__asm__ __volatile__(
"csrw sepc, %[sepc]\n"
"csrw sstatus, %[sstatus]\n"
"sret\n"
:
: [sepc] "r" (USER_BASE),
[sstatus] "r" (SSTATUS_SPIE)
);
}
__attribute__((naked)) void user_entry(void) {
__asm__ __volatile__(
"csrw sepc, %[sepc]\n"
"csrw sstatus, %[sstatus]\n"
"sret\n"
:
: [sepc] "r" (USER_BASE),
[sstatus] "r" (SSTATUS_SPIE | SSTATUS_SUM)
);
}
SSTATUS_SPIE에 SSTATUS_SUM을 OR 해주면 된다. 이제 커널이 시스템 콜 처리 중 사용자 메모리에 접근할 수 있다.
동작 확인
파일 읽기
$ ./run.sh
> readfile
Hello, OS!hello.txt에 미리 적어둔 내용이 출력된다.
파일 쓰기
> writefile
wrote 2560 bytes to diskQEMU를 종료한 뒤 disk.tar를 풀어보면 파일이 업데이트된 것을 확인할 수 있다.
mkdir tmp
cd tmp
tar xf ../disk.tar
ls -alh
total 12K
drwxr-xr-x 2 leejw leejw 4.0K Apr 5 19:20 .
drwxr-xr-x 6 leejw leejw 4.0K Apr 5 19:20 ..
-rw-r--r-- 1 leejw leejw 19 Jan 1 1970 hello.txt
-rw-r--r-- 1 leejw leejw 0 Jan 1 1970 meow.txt
cat hello.txt
Hello from shell!셸에서 쓴 "Hello from shell!\n"이 실제 디스크 이미지에 반영되었다.
정리
이전 글의 virtio-blk 드라이버(read_write_disk) 위에 tar 파싱/직렬화 계층을 얹고, 그 위에 시스템 콜 인터페이스를 붙인 것이다. 계층 구조로 보면 이렇다.
┌──────────────────────┐
│ 셸 (readfile 명령) │ ← 사용자 공간
├──────────────────────┤
│ syscall (ecall) │
├──────────────────────┤
│ 파일 시스템 │ ← fs_init, fs_flush, fs_lookup
│ (tar 파싱/직렬화) │
├──────────────────────┤
│ 블록 디바이스 드라이버│ ← read_write_disk
│ (virtio-blk) │
├──────────────────────┤
│ QEMU 가상 디스크 │ ← 하드웨어
└──────────────────────┘이것으로 “OS in 1,000 Lines” 튜토리얼의 핵심 구현이 모두 끝났다!!
부트로더, 메모리 관리, 프로세스, 시스템 콜, 디바이스 드라이버, 파일 시스템 등 OS의 핵심 기능을 1,000줄 안에 담았다.
NEXT?
아마 다음으로 하게 될 건 xv6 일 것 같다. 6.1810: Operating System Engineering 을 따라가면서 과제를 수행해 보려고 한다.