2025. 3. 17. 22:03ㆍCS/OS

파일 시스템 | OS in 1,000 Lines
operating-system-in-1000-lines.vercel.app
1. tar

- tar
: Tape archive
: 컴퓨터에서, Tape Archive를 위해 고안된 파일 형식.
이 교안에서는 tar를 파일 시스템으로 사용했다.
tar는 다수의 파일을 하나로 묶을 수 있는 아카이브 형식이고 각 파일의 내용뿐만 아니라 파일명, 생성일자 등 파일 시스템에 필요한 모든 메타데이터를 저장할 수 있기 때문이다.
가장 중요한 것은 tar의 데이터 구조는 FAT나 ext2 같은 일반적인 파일 시스템에 비해 매우 단순하다.
2. 디스크 이미지 생성하기
디스크 이미지로 사용할 tar 파일을 생성한다.
mkdir disk
touch disk/meow.txt disk/hello.txt
echo "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." > disk/hello.txt
echo "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum." > disk/meow.txt
tar로 만들 디렉터리와 파일들 추가.
파일 안에 더미 데이터를 채워 넣는다.
(cd disk && tar cf ../disk.tar --format=ustar *.txt) # 추가
$QEMU -machine virt -bios default -nographic -serial mon:stdio --no-reboot \
-d unimp,guest_errors,int,cpu_reset -D qemu.log \
-drive id=drive0,file=disk.tar,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)
: 괄호(...)
는 서브셸을 생성하여 내부 명령이 외부 영향을 주지 않음.cf
: tar 파일 생성 (create)-format=ustar
: ustar 형식으로 생성
3. 파일 시스템
파일 시스템 구현에 앞서 tar의 구성을 먼저 알아본다.
+----------------+
| tar header |
+----------------+
| file data |
+----------------+
| tar header |
+----------------+
| file data |
+----------------+
| ... |
ustar(unix standard tar) 포맷 기준으로 tar 파일은 각 파일마다 한 쌍의 "tar header"와 "file data"로 구성된다.
가. 읽기
우리가 구현한 방식에서는 부팅 시 디스크의 모든 파일을 메모리로 읽어온다.
- kernel.h
#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[]; // Array pointing to the data area following the header
// (flexible array member)
} __attribute__((packed));
struct file {
bool in_use; // Indicates if this file entry is in use
char name[100]; // File name
char data[1024]; // File content
size_t size; // File size
};
#define FILES_MAX 2
: 로드할 수 있는 파일의 최대 개수#define DISK_MAX_SIZE align_up(sizeof(struct file) * FILES_MAX, SECTOR_SIZE)
: 디스크 이미지의 최대 크기char data[];
: 헤더 다음에 이어지는 데이터 영역을 가리키는 배열
- kernel.c
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;
}
8진수 텍스트를 10진수 정수로 변환하는 함수.
- kernel.c
struct file files[FILES_MAX];
uint8_t disk[DISK_MAX_SIZE];
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);
}
}
부팅 시 파일 시스템을 초기화하기 위한 코드다.

한 줄씩 살펴보자.
for (unsigned sector = 0; sector < sizeof(disk) / SECTOR_SIZE; sector++)
read_write_disk(&disk[sector * SECTOR_SIZE], sector, false); // 읽기
부팅 시 디스크의 모든 섹터를 메모리로 읽어온다.
읽어온 섹터는 disk[]
에 저장된다.
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;
이제 disk[]
에서 파일을 인식하고 메모리에 올린다.
인식된 파일은 files
에 저장된다.
우선 tar_header
를 찾는다.
if (strcmp(header->magic, "ustar") != 0)
PANIC("invalid tar header: magic=\"%s\"", header->magic);
헤더의 magic
필드가 ustar
인지 확인한다.
이는 디스크에서 읽어온 데이터가 올바른 ustar 형식인지 확인하는 검증 단계다.
int filesz = oct2int(header->size, sizeof(header->size));
헤더에서 파일 크기를 추출한다.
disk[]
에서 읽어온 header->size
는 8진수 문자로 저장되어 있다.
이를 앞서 정의한 oct2int()
함수를 사용하여 10진수 정수로 변환한 다음 filesz
에 저장한다.
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);
헤더에서 읽은 정보를 바탕으로 file
구조체를 설정한다.
off += align_up(sizeof(struct tar_header) + filesz, SECTOR_SIZE);
다음 파일을 읽기 위해서 다 읽은 파일 크기만큼 오프셋(off
)을 재설정한다.
- kernel.c
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(); // 추가
/* omitted */
}
virtio-blk
장치 초기화 이후 수행.
$ ./run.sh
...
virtio-blk: capacity is 10240 bytes
file: hello.txt, size=575
file: meow.txt, size=575
나. 쓰기
파일을 쓰는 작업은 메모리 상의 files
변수에 있는 파일 내용을 tar 파일 형식으로 다시 디스크에 기록.

읽기의 역순으로 file
→ disk
→ virtio-blk
순으로 갱신한다.
아래의 구현 방식은 한 번 호출 시 모든 파일에 대하여 디스크에 기록한다.
- kernel.c
void fs_flush(void) {
// Copy all file contents into `disk` buffer.
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';
// Turn the file size into an octal string.
int filesz = file->size;
for (int i = sizeof(header->size); i > 0; i--) {
header->size[i - 1] = (filesz % 8) + '0';
filesz /= 8;
}
// Calculate the checksum.
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;
}
// Copy file data.
memcpy(header->data, file->data, file->size);
off += align_up(sizeof(struct tar_header) + file->size, SECTOR_SIZE);
}
// Write `disk` buffer into the 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));
}
memset(header, 0, sizeof(*header));
:header
의 시작 위치부터header
크기만큼0
으로 채운다.
// Calculate the checksum.
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;
}
체크섬은 tar 파일의 무결성을 검증하는 데 사용.
- 초기값으로 체크섬 필드의 크기만큼 공백 문자를 곱한 값을 설정
- tar 헤더의 모든 바이트를 순회하면서 바이트 값을 더함
- 최종 체크섬 값을 8진수 문자열로 변환하여 헤더의
checksum
필드에 저장
4. 시스템 호출 구현
가. 시스템 콜 호출
파일 읽고 쓰기 역시 시스템 콜을 통해서 실행된다.
- common.h
#define SYS_READFILE 4
#define SYS_WRITEFILE 5
시스템 콜 호출 번호.
- user.h
int readfile(const char *filename, char *buf, int len);
int writefile(const char *filename, const char *buf, int len);
- user.c
int 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);
}
filename
과 buf
는 문자열을 가리키는 포인터(메모리 주소)이기 때문에 syscall()
함수에 전달될 때 int
형으로 캐스팅하여 전달한다.
나. 시스템 콜 구현
- kernel.c
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;
}
if (!strcmp(file->name, filename)) return file;
:strcmp()
함수는 두 문자열이 같으면 0을 반환함.
: 파일 이름으로files
배열에서 해당 파일을 찾는다.
- kernel.c
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
/* omitted */
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);
}
}
파일 읽기와 쓰기 작업은 거의 동일하므로 하나의 케이스에서 처리.
쓰기 작업은 버퍼의 내용을 파일 항목에 기록한 후 fs_flush
를 호출하여 디스크에 저장해야 한다.
5. Shell 명령어 구현
가. 명령어 추가
해당 교안에서는 명령줄 인자 파싱을 구현하지 않았는다.
아직 인자를 인식할 수 없기 때문에 읽기와 쓰기는 무조건 hello.txt
파일을 대상으로 한다.
- shell.c
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", "Hell from shell!!\n", 19);
나. 커널의 사용자 포인터 접근 허용
$ ./run.sh
> readfile
PANIC: kernel.c:459: unexpected trap scause=0000000d, stval=01000428, sepc=80201364
아직 실행하면 page fault가 발생한다.
이는 커널이 유저 페이지에 접근할 권한이 없기 때문이다.
RISC-V에서는 S-모드(커널)가 U-모드(사용자) 페이지에 접근할 수 있는지 여부를 sstatus
CSR의 SUM 비트를 통해 제어할 수 있다.
이는 커널이 사용자 메모리 영역을 의도치 않게 참조하는 것을 방지하기 위한 안전장치. 인텔 CPU에도 "SMAP (Supervisor Mode Access Prevention)"이라는 유사한 기능이 있다.
SUM (Supervisor User Memory access) 비트를 설정한다.
- kernel.h
#define SSTATUS_SUM (1 << 18)
- kernel.c
__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) // 수정
);
}
이런 오류는 디버깅하기 쉽지 않겠다. ㄷㄷㄷ
6. 테스트
$ ./run.sh
> readfile
Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy te
> writefile
wrote 2560 bytes to disk
QEMU를 종료한 후 disk.tar
파일을 다시 열어보면 업데이트된 내용을 확인할 수 있다.
$ mkdir tmp
$ cd tmp
$ tar xf ../disk.tar
$ ls -al
total 16
drwxrwxr-x 2 tiredi tiredi 4096 Mar 17 12:47 ./
drwxrwxr-x 4 tiredi tiredi 4096 Mar 17 12:47 ../
-rw-r--r-- 1 tiredi tiredi 19 Jan 1 1970 hello.txt
-rw-r--r-- 1 tiredi tiredi 575 Jan 1 1970 meow.txt
$ cat hello.txt
Hell from shell!!
7. 마치며
- 메모리 할당 개선 : 메모리 해제가 가능한 메모리 할당기
- 스케줄링 개선 : 디스크 I/O에 대해 바쁜 대기(busy-wait)를 하지 않도록 개선
- 파일 시스템 개선 : ext2와 같은 파일 시스템 구현이 좋은 출발점이 될 수 있음
- 네트워크 통신(TCP/IP) : virtio-net은 virtio-blk와 매우 유사함!
교안에서 언급하는 개선점은 위와 같다.
거기에 더하여 개인적으로 shell도 개선해 더 많은 명령어를 구현하면 좋겠다.
만약 기회가 된다면 조금 더 고도화된 교육용 OS인 RISC-V version of xv6까지 학습하면 좋겠다.
운영체제 구현에 생각보다 많은 시간을 썼다.
직접 바닥부터 구현하면서 이론과 실습을 병행하는 방식이 잘 맞는 거 같다.
피상적으로 이해하는 것이 아닌 구체적으로 동작하는 방식을 이해할 수 있어서 제대로 공부한 느낌이다.
DB, LLM, Web Server도 밑바닥부터 구현하는 시간을 가져야겠다.
'CS > OS' 카테고리의 다른 글
[1000줄 OS 구현하기] 디스크 I/O (0) | 2025.03.15 |
---|---|
[1000줄 OS 구현하기] 시스템 콜 (0) | 2025.03.11 |
[1000줄 OS 구현하기] 유저 모드 (0) | 2025.03.11 |
[1000줄 OS 구현하기] 애플리케이션 (0) | 2025.03.09 |
[1000줄 OS 구현하기] 페이지 테이블 (0) | 2025.03.07 |