본 포스팅은『OS in 1,000 Lines』를 학습하며 정리한 기록입니다.
2회독 요약 본이라서 생략 및 압축된 내용이 많으므로, 더 자세한 문맥은 원문을 참고해 주세요.
RISC-V
애플리케이션이 어떤 OS에서 동작하는지 그 차이를 숨겨주는 것처럼, OS 역시 그 아래 계층인 하드웨어의 차이를 숨겨주는 역할을 함
즉, OS는 CPU를 제어하여 애플리케이션에게 추상화된 레이어를 제공하는 프로그램이라고 할 수 있음
RISC-V 는 명령어 집합 아키텍처(ISA) 로 소프트웨어가 하드웨어와 상호 작용하는 방식을 정의하는 CPU 프로그래밍 가능 인터페이스를 정의하는 추상 모델임
레지스터
CPU 내부의 임시 변수와 같음. 메모리보다 훨씬 빠름
CPU는 데이터를 메모리에서 레지스터로 읽어와 산술 연산을 수행한 후 결과를 다시 메모리나 레지스터에 씀
| Register | ABI Name (alias) | Description |
| pc | pc | 프로그램 카운터 (다음 실행할 명령어 주소) |
| x1 | ra | 함수 호출에서 복귀 주소 저장 |
| x2 | sp | 스택 포인터 |
| x8 | fp | 스택 프레임 포인터 |
| x10 - x11 | a0 - a1 | 함수 인자 및 반환값 |
| x18 - x27 | s0 - s11 | 함수 호출 사이에도 값이 보존되는 레지스터 |
레지스터는 아주 빠르지만 개수가 제한되어 있고, 반면 메로리는 훨씬 크지만 속도가 느림
스택
함수 호출과 로컬 변수를 위해 사용되며,
스택 포인터가 스택의 최상단을 가리키고, 위에서 아래로 확장됨
CPU 모드
CPU에는 펌웨어가 동작하는 모드인 M-Mode,
운영체제 커널이 동작하는 모드인 S-Mode,
일반 애플리케이션이 동작하는 모드인 U-Mode 가 있음
U-Mode에서는 실행할 수 없는 명령이 있고, 이를 특권 명령이라 부름
| Opcode and operands | Overview |
| csrr rd, csr | CSR 값을 rd에 읽어옴 |
| csrw csr, rs | rs의 값을 CSR에 씀 |
| csrrw rd, csr, rs | CSR 값을 읽고 쓰는 과정을 한 번에 수행 |
| sret | 트랩 핸들러 복귀(PC, 모드 등 복원) |
CSR은 CPU 내부 상태와 제어를 담당하는 레지스터
인라인 어셈블리
__asm__ __volatile__("assembly code"
: output operands
: input operands
: clobbered registers);
별도의 .S 파일을 만드는 대신, C 코드 중간에 위와 같은 형태로 작성함
- __asm__ : 이 코드가 인라인 어셈블리라는 것을 컴파일러에게 알림
- __volatile__ : 컴파일러에게 이 코드를 최적화하거나 변경하지 말라고 지시
- “assembly code” : 실제 어셈블리 코드를 문자열 리터럴 형태로 작성
- output operands : 어셈블리가 실행한 뒤 결과를 저장할 변수를 지정 (=r 사용)
- input operands : 어셈블리 코드에서 참조할 C 언어 표현식 지정 (r 사용)
- clobbered registers : 어셈블리 내부에서 덮어써서 내용이 파괴되어도 되는 레지스터 지정
uint32_t value;
__asm__ __volatile__("csrr %0, sepc"
: "=r"(value));
→ csrr 명령어로 sepc CSR 레지스터의 값을 읽어와 value 변수에 저장 (%0 → value)
__asm__ __volatile__("csrw sscratch, %0"\\
: /* 출력 오퍼랜드는 비어 있음 */
: "r"(123));
→ csrw 명령어로 sscratch CSR에 123을 씀 (%0 → 123을 담은 레지스터)
부팅
컴퓨터 전원을 켜면 → BIOS/UEFI 가 하드웨어를 초기화 → 디스크에서 OS 불러옴 (스플래시 스크린)
RISC-V의 QEMU 머신 환경에서의 BIOS/IEFI 는 OpenSBI 임
SBI는 OS 커널을 위한 API이며, 가장 널리 사용되는 구현체가 OpenSBI
링커 스크립트
실행 파일의 메모리 배치를 정의하는 파일.
이 정보를 기반으로 함수와 변수가 배치될 메모리 주소를 결정함
ENTRY(boot)
SECTIONS {
. = 0x80200000;
.text :{
KEEP(*(.text.boot));
*(.text .text.*);
}
.rodata : ALIGN(4) {
*(.rodata .rodata.*);
}
.data : ALIGN(4) {
*(.data .data.*);
}
.bss : ALIGN(4) {
__bss = .;
*(.bss .bss.* .sbss .sbss.*);
__bss_end = .;
}
. = ALIGN(4);
. += 128 * 1024; /* 128KB */
__stack_top = .;
}
- boot 함수를 엔트리 포인트로 지정 (프로그램의 진입점)
- 베이스 주소를 0x80200000 으로 지정
- 각 섹션을 .text. .rodata. .data, .bss 순으로 배치
- .bss 이후에 커널 스택을 배치하고 크기는 128KB로 설정
- 4바이트 경계로 주소를 맞춤
최소화된 커널
void kernel_main(void) {
...
}
__attribute__((section(".text.boot")))
__attribute__((naked))
void boot(void) {
__asm__ __volatile__(
"mv sp, %[stack_top]\\n"
"j kernel_main\\n"
:
: [stack_top] "r" (__stack_top)
);
}
- boot 함수를 엔트리 포인트로 지정했으니 코드에 정의 및 구현
- 링커 스크립트가 지정한 스택 영역의 끝 주소를 스택 포인터에 대입
- __attribute __ ((section(”.text.boot”))) : .text.boot 섹션에 이 함수를 배치하도록 함
- __ attribute __ ((naked)) : 함수의 시작과 끝에 컴파일러가 추가로 생성하는 코드 생략
출력하기
struct sbiret sbi_call(long arg0, long arg1, long arg2, long arg3, long arg4,
long arg5, long fid, long eid) {
register long a0 __asm__("a0") = arg0;
register long a1 __asm__("a1") = arg1;
register long a2 __asm__("a2") = arg2;
register long a3 __asm__("a3") = arg3;
register long a4 __asm__("a4") = arg4;
register long a5 __asm__("a5") = arg5;
register long a6 __asm__("a6") = fid;
register long a7 __asm__("a7") = eid;
__asm__ __volatile__("ecall"
: "=r"(a0), "=r"(a1)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3), "r"(a4), "r"(a5),
"r"(a6), "r"(a7)
: "memory");
return (struct sbiret){.error = a0, .value = a1};
}
SBI가 OS를 위한 API이므로 SBI 함수를 호출해야함. 이를 위해 ecall 명령어 사용
위의 코드는 SBI 스펙에서 정의한 대로 OpenSBI를 호출하기 위해 규약에 맞춰짐
ecall 명령어를 실행하면, CPU가 M-Mode로 전환되어 OpenSBI 처리 루틴이 동작
처리가 끝나면 다시 S-Mode로 돌아와 ecall 이후의 명령어부터 실행됨
커널 패닉
커널에서 복구 불가능한 오류를 만났을 때 발생
윈도우에서 가끔 보이는 블루 스크린을 떠올려보면 됨
#define PANIC(fmt, ...) \
printf("PANIC: %s:%d: " fmt "\\n", __FILE__, __LINE__, ##__VA_ARGS__); \
while (1) {}
간단하게 무한 루프에 빠뜨려 더 이상의 처리를 중단
사용자에게 원인을 알려주고 멈출 수 있는 우아한 크래시 메커니즘
예외
CPU가 잘못된 메모리 접근 (페이지 폴트), 유효하지 않은 명령, 그리고 시스템 콜 같은 이벤트가 발생했을 때 커널이 개입하도록 해주는 CPU 기능
예외는 S-Mode에서도 발생할 수 있으며 이럴 때는 치명적인 커널 버그로 이어짐
예외 처리 과정
- CPU는 어떤 모드에서 예외를 처리할지 결정 (OpenSBI 에서는 S-Mode 핸들러에서 처리하도록)
- CPU는 예외가 발생한 시점의 상태를 여러 CSR에 저장
- 커널이 예외 핸들러로 점프
- 예외 핸들러는 프로그램 상태를 별도로 저장한 뒤 예외를 처리
- 처리 후, 저장해둔 실행 상태를 복원하고 예외가 발생했던 지점으로 돌아가 프로그램 재개
#define READ_CSR(reg) \
({ \
unsigned long __tmp; \
__asm__ __volatile__("csrr %0, " #reg : "=r"(__tmp)); \
__tmp; \
})
- CPU가 저장해둔 CSR 레지스터 값을 일반 레지스터로 읽어옴
- #reg : 전달받은 이름을 문자열로 바꿔줌. reg가 sstatus라면 내부적으로 "csrr %0, sstatus” 라는 문자열이 생성
void handle_trap(struct trap_frame *f) {
uint32_t scause = READ_CSR(scause);
uint32_t stval = READ_CSR(stval);
uint32_t user_pc = READ_CSR(sepc);
PANIC("unexpected trap scause=%x, stval=%x, sepc=%x\\n", scause, stval, user_pc);
}
- READ_CSR의 인자로 들어온 이름의 레지스터 값을 읽는 것임
- scause : 어떤 이유로 발생
- stval : 예외 부가 정보 (잘못된 메모리 주소 등)
- sepc : 예외가 일어난 시점의 pc
CPU에게 예외 핸들러 위치 알려주기
#define WRITE_CSR(reg, value) \
uint32_t __tmp = (value); \
__asm__ __volatile__("csrw " #reg ", %0" ::"r"(__tmp));
WRITE_CSR(stvec, (uint32_t) kernel_entry);
stvec 레지스터를 설정.
kernel_entry()는 상태를 저장하고 handle_trap으로 점프시키는 예외 핸들러 함수임
메모리 할당
...
. = ALIGN(4096);
__free_ram = .;
. += 64 * 1024 * 1024; /* 64MB */
__free_ram_end = .;
}
링커 스크립트에 스택 공간 다음에 위치하는 메모리 영역을 설정
링커 스크립트에 정의함으로써, 커널의 정적 데이터와 겹치지 않도록 링커가 위치를 자동으로 조정
extern char __free_ram[], __free_ram_end[];
paddr_t alloc_pages(uint32_t n) {
static paddr_t next_paddr = (paddr_t) __free_ram;
paddr_t paddr = next_paddr;
next_paddr += n * PAGE_SIZE;
if (next_paddr > (paddr_t) __free_ram_end)
PANIC("out of memory");
memset((void *) paddr, 0, n * PAGE_SIZE);
return paddr;
}
- next_paddr : static 변수로 선언해 새로 할당할 메모리 영역의 시작 주소를 언제나 담당할 수 있음
- 초깃값은 __free_ram 주소로 설정해 이후부터 순차적으로 할당
- 링커 스크립트에 4KB로 맞춰줬으므로 이 함수는 항상 4KB 정렬된 주소 반환
프로세스
한 애플리케이션의 인스턴스
각각의 독립적인 실행 컨텍스트와 가상 주소 공간과 같은 리소스를 가지고 있음
프로세스 제어 블록 (PCB)
프로세스 ID나 상태, 스택 포인터 등을 포함하는 프로세스 객체
#define PROC_UNUSED 0 // 사용되지 않는 프로세스 구조체
#define PROC_RUNNABLE 1 // 실행 가능한(runnable) 프로세스
struct process {
int pid; // 프로세스 ID
int state; // 프로세스 상태: PROC_UNUSED 또는 PROC_RUNNABLE
vaddr_t sp; // 스택 포인터
uint8_t stack[8192]; // 커널 스택 (저장된 CPU 레지스터, 함수 리턴 주소, 로컬 변수)
};
프로세스마다 커널 스택을 별도로 준비해두면, CPU 레지스터를 저장, 복원하고 스택 포인터를 바꾸는 방식으로 컨텍스트 스위칭 가능
컨텍스트 스위칭
프로세스의 실행 컨텍스트를 바꾸는 것
- 현재 프로세스의 스택에 callee-saved 레지스터를 저장
- 스택 포인터를 다음 프로세스의 값으로 복원
- 다음 프로세스 스택에서 callee-saved 레지스터 복원
- callee-saved 레지스터
- 호출당한 함수가 원래 값을 복원해야 하는 레지스터
- 함수 호출 후에도 값이 유지됨
- caller-saved 레지스터
- 호출자가 따로 값을 저장하기 때문에 호출당한 함수가 마음대로 쓰다 버려도 되는 레지스터
- 함수 호출 후 값이 바뀔 수 있음
프로세스 생성은 프로세스의 엔트리 포인터를 매개변수로 받아, 프로세스 구조체 포인터를 반환함
스케줄러
프로세스가 많아지면, 어느 프로세스가 다음에 실행될지 결정하는 것이 복잡해짐
이를 해결하기 위해 스케줄러가 실행 순서를 결정하도록 함
struct process *current_proc; // 현재 실행 중인 프로세스
struct process *idle_proc; // Idle 프로세스
void yield(void) {
// 실행 가능한 프로세스를 탐색
struct process *next = idle_proc;
for (int i = 0; i < PROCS_MAX; i++) {
struct process *proc = &procs[(current_proc->pid + i) % PROCS_MAX];
if (proc->state == PROC_RUNNABLE && proc->pid > 0) {
next = proc;
break;
}
}
// 현재 프로세스 말고는 실행 가능한 프로세스가 없으면, 그냥 리턴
if (next == current_proc)
return;
// 컨텍스트 스위칭
struct process *prev = current_proc;
current_proc = next;
switch_context(&prev->sp, &next->sp);
}
각 프로세스가 yield 를 호출해 스케줄러의 알고리즘대로 컨텍스트 스위칭을 하게 함
메모리 관리와 가상 주소
프로그램이 메모리에 접근할 때, CPU는 지정된 가상 주소를 물리 주소로 변환함
이때 두 주소 사이의 매핑 정보를 저장하는 테이블이 페이지 테이블
페이지 테이블을 교체함으로써 같은 가상 주소가 다른 물리 주소를 가리키게 설정할 수 있음
이를 통해 메모리 공간을 분리하고, 커널 영역과 애플리케이션 영역을 분리하여 시스템 보안을 높임
void map_page(uint32_t *table1, uint32_t vaddr, paddr_t paddr, uint32_t flags) {
...
uint32_t vpn1 = (vaddr >> 22) & 0x3ff;
if ((table1[vpn1] & PAGE_V) == 0) {
// 1단계 페이지 테이블 엔트리가 존재하지 않으면 2단계 페이지 테이블을 생성
uint32_t pt_paddr = alloc_pages(1);
table1[vpn1] = ((pt_paddr / PAGE_SIZE) << 10) | PAGE_V;
}
// 2단계 페이지 테이블 엔트리를 물리 페이지에 매핑
uint32_t vpn0 = (vaddr >> 12) & 0x3ff;
uint32_t *table0 = (uint32_t *) ((table1[vpn1] >> 10) * PAGE_SIZE);
table0[vpn0] = ((paddr / PAGE_SIZE) << 10) | flags | PAGE_V;
}
RISC-V 의 페이징 메커니즘은 1단계 페이지 테이블 인덱스, 2단계 인덱스, 페이지 오프셋으로 구성되고,
1단계, 2단계를 계산하여 페이지 테이블 엔트리를 찾은 뒤, offset을 더해 최종 주소를 얻음
커널 메모리 영역 매핑
페이지 테이블은 애플리케이션을 위해서만이 아니라, 커널을 위해서도 설정이 필요
SECTIONS {
. = 0x80200000;
__kernel_base = .;
...
- 링커 스크립트에 커널이 사용하는 시작 주소( __kernel_base) 정의
- PCB 구조체에 페이지 테이블 주소 추가
- 이후 프로세스 생성 함수에서 커널 페이지들을 매핑.
__kernel_base 부터 __free_ram_end 까지를 커버함으로써 정적, 동적 메모리 영역을 모두 접근 가능
컨텍스트 스위칭 시에 프로세스의 페이지 테이블을 스위칭해야 함
satp 레지스터에 1단계 페이지 테이블 주소를 지정하면, 페이지 테이블 전환 가능
애플리케이션
ENTRY(start)
SECTIONS {
. = 0x1000000;
...
}
커널의 링커 스크립트와 상당히 유사한 애플리케이션용 링크 스크립트를 생성
커널 주소 공간과 겹치지 않도록 베이스 주소를 다르게 설정
**extern char __stack_top[];
__attribute__((section(".text.start")))
__attribute__((naked))
void start(void) {
__asm__ __volatile__(
"mv sp, %[stack_top] \\n"
"call main \\n"
"call exit \\n"
:: [stack_top] "r" (__stack_top)
);
}**
엔트리 포인트를 start로 설정했기에 start 함수를 정의 및 구현
스택 포인터를 설정한 뒤 애플리케이션의 main 함수를 호출함
void main(void) {
for (;;);
}
이 임시 애플리케이션은 커널과 별도로 빌드함
위의 애플리케이션용 링커 스크립트 + start 함수가 포함된 스크립트와 같이
빌드 이후 실행 파일을 실제 메모리 내용만 포함하는 바이너리로 변환하고,
C언어에 임베드할 수 있는 오브젝트 파일로 변환함
실행 파일 추출하기
struct process *create_process(const void *image, size_t image_size) {
...
// Map user pages.
for (uint32_t off = 0; off < image_size; off += PAGE_SIZE) {
paddr_t page = alloc_pages(1);
// Handle the case where the data to be copied is smaller than the
// page size.
size_t remaining = image_size - off;
size_t copy_size = PAGE_SIZE <= remaining ? PAGE_SIZE : remaining;
// Fill and map the page.
memcpy((void *) page, image + off, copy_size);
map_page(page_table, USER_BASE + off, page,
PAGE_U | PAGE_R | PAGE_W | PAGE_X);
}
...
}
프로세스 생성 함수를 실행 이미지의 포인터와 이미지 크기를 인자로 받도록 수정
지정된 크기만큼 실행 이미지를 페이지 단위로 복사하여 프로세스의 페이지 테이블에 매핑
사용자 모드로 전환
애플리케이션을 실행하기 위해 U-Mode를 사용
#define SSTATUS_SPIE (1 << 5)
__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)
);
}
sret 명령을 사용하여 이루어짐
모드를 변경하기 전에 두 개의 CSR에 값을 기록
- spec 레지스터 : sret 명령어가 점프할 위치
- sstatus 레지스터의 SPIE 비트 : 하드웨어 인터럽트 활성화 및 stvec 레지스터에 설정된 핸들러 호출
시스템 콜
int syscall(int sysno, int arg0, int arg1, int arg2) {
register int a0 __asm__("a0") = arg0;
register int a1 __asm__("a1") = arg1;
register int a2 __asm__("a2") = arg2;
register int a3 __asm__("a3") = sysno;
__asm__ __volatile__("ecall"
: "=r"(a0)
: "r"(a0), "r"(a1), "r"(a2), "r"(a3)
: "memory");
return a0;
}
- 이건 유저 라이브러리이며, SBI 콜과 매우 유사함
- a3 레지스터에 시스템 콜 번호를, a0부터 a2 레지스터에는 시스템 콜 인자를 저장
- ecall 명령어가 실행되면 예외 핸들러가 호출되어 제어권이 커널로 넘어감
- 커널에서 반환하는 값은 a0 레지스터에 설정됨
유저 라이브러리에서 시스템콜을 호출하면 커널의 예외 트랩 핸들러가 처리함
void handle_trap(struct trap_frame *f) {
uint32_t scause = READ_CSR(scause);
...
if (scause == SCAUSE_ECALL) {
handle_syscall(f);
user_pc += 4;
}
...
WRITE_CSR(sepc, user_pc);
}
scause 값을 확인하면 ecall 명령어가 호출되었는지 판단 가능
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
case SYS_PUTCHAR: // 출력하기
putchar(f->a0);
break;
case SYS_GETCHAR: // 입력 받기
while (1) {
long ch = getchar();
if (ch >= 0) {
f->a0 = ch;
break;
}
yield();
}
break;
case SYS_EXIT: // 프로세스 종료
printf("process %d exited\\n", current_proc->pid);
current_proc->state = PROC_EXITED;
yield();
PANIC("unreachable");
default:
PANIC("unexpected syscall a3=%x\\n", f->a3);
}
}
a3 레지스터의 값을 확인하여 시스템 콜의 종류를 판별하고, 그에 맞는 처리를 함
long getchar(void) {
struct sbiret ret = sbi_call(0, 0, 0, 0, 0, 0, 0, 2);
return ret.error;
}
입력 받기 또한 SBI 콜을 사용함
프로세스 종료는 프로세스 상태를 PROC_EXITED로 변경한 후, yield를 호출
스케줄러는 PROC_RUNNABLE 상태의 프로세스만 실행하기 때문에, 이 프로세스는 다시 실행되지 않음
실제 OS에서는 해당 프로세스에 할당된 메모리 영역과 자원까지 해제해야 함
Virtio
가상 장치를 위한 디바이스 인터페이스 표준
디바이스 드라이버가 장치를 제어하기 위한 API 중 하나
Virtqueue
virtio 장치는 virtqueue 라는 구조체를 가지고 있음. 이는 드라이버와 장치가 공유하는 큐
다음이 세 영역으로 구성됨
- Descriptor Table : 요청의 주소와 크기를 기록
- Available Ring : 장치에 처리할 요청들을 등록
- Used Ring : 장치가 이미 처리한 요청들을 기록
각 요청은 여러 개의 디스크립터로 구성된 디스크립터 체인으로 이루어짐
디스크에 쓰기 요청을 할 때의 과정
- 드라이버는 Descriptor Table에 읽/쓰 요청을 작성
- 드라이버는 디스크립터 체인의 헤드 인덱스를 Available RIng에 추가
- 드라이버는 장치에 새 요청이 있음을 알림
- 장치는 Available Ring에서 요청을 읽어 처리
- 장치는 디스크립터 인덱스를 Used Ring에 기록한 후, 드라이버에게 완료되었음을 알림
QEMU에 virtio-blk 장치를 연결하고 virtio_mmio 버스에 매핑함
커널이 MMIO 레지스터에 접근할 수 있도록 virtio-blk의 MMIO 영역을 페이지 테이블에 매핑
커널에서 virtio 장치와 virtqueue를 초기화 시킴
이때 virtqueue를 위한 메모리 영역을 할당하고, 그 페이지 프레임 번호를 장치에게 알려줌
장치는 이 메모리 영역을 사용하여 요청을 읽거나 씀
드라이버 초기화 과정에서 하는 일은 장치 기능을 확인하고 OS 자원을 할당하며, 파라미터를 설정하는 것
OS는 장치 내부에서 실제로 어떤 일이 일어나는지 신경 쓸 필요가 없음
I/O 요청 보내기
void read_write_disk(void *buf, unsigned sector, int is_write) {
...
// virtio-blk 사양에 따라 요청을 구성합니다.
blk_req->sector = sector;
blk_req->type = is_write ? VIRTIO_BLK_T_OUT : VIRTIO_BLK_T_IN;
if (is_write)
memcpy(blk_req->data, buf, SECTOR_SIZE);
// virtqueue 디스크립터를 구성합니다 (3개의 디스크립터 사용).
struct virtio_virtq *vq = blk_request_vq;
vq->descs[0].addr = blk_req_paddr;
vq->descs[0].len = sizeof(uint32_t) * 2 + sizeof(uint64_t);
vq->descs[0].flags = VIRTQ_DESC_F_NEXT;
vq->descs[0].next = 1;
vq->descs[1].addr = blk_req_paddr + offsetof(struct virtio_blk_req, data);
vq->descs[1].len = SECTOR_SIZE;
vq->descs[1].flags = VIRTQ_DESC_F_NEXT | (is_write ? 0 : VIRTQ_DESC_F_WRITE);
vq->descs[1].next = 2;
vq->descs[2].addr = blk_req_paddr + offsetof(struct virtio_blk_req, status);
vq->descs[2].len = sizeof(uint8_t);
vq->descs[2].flags = VIRTQ_DESC_F_WRITE;
// 장치에 새로운 요청이 있음을 알림.
virtq_kick(vq, 0);
// 장치가 요청 처리를 마칠 때까지 대기(바쁜 대기; busy-wait).
while (virtq_is_busy(vq))
;
...
// 읽기 작업의 경우, 데이터를 버퍼에 복사합니다.
if (!is_write)
memcpy(buf, blk_req->data, SECTOR_SIZE);
}
디스크에 I/O 요청을 보내는 방식은 virtqueue에 처리 요청을 추가하는 것
- 요청 내용을 구성. 접근할 섹터 번호와 읽/쓰 유형을 지정
- 각 영역을 가리키는 디스크립터 체인을 구성
- 디스크립터 체인의 헤드 인덱스를 Available Ring에 추가
- 장치에 새로운 요청이 있음을 알림
- 완료될 때까지 기다리고, 장치의 응답을 확인
파일 시스템 읽기
tar 형식을 파일 시스템으로 사용함 (tar header와 file data로 구성되어 있음)
빌드 스크립트에 tar 파일을 생성하고 이를 QEMU에 디스크 이미지로 전달함
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];
...
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);
}
}
- 위에서 구현한 read_write_disk 함수를 이용해 디스크 이미지를 임시 버퍼에 로드
- 디스크 내용을 로드한 후, 순차적으로 files 배열에 복사
- 이 함수는 virtio-blk 장치 초기화 이후에 호출
파일 시스템 쓰기
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];
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);
}
disk 버퍼에 tar 파일 형식으로 파일들을 구성한 뒤, read_write_disk 함수를 통해 실제 디스크에 기록
디스크 I/O 시스템 콜
위의 기능들을 애플리케이션에서 사용하려면 시스템 콜을 이용함
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);
}
void handle_syscall(struct trap_frame *f) {
switch (f->a3) {
...
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 (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;
}
...
}
}
files 배열에서 해당 파일을 찾은 뒤, 읽기 작업의 경우 파일의 데이터를 버퍼에 복사하고,
쓰기 작업의 경우 버퍼의 내용을 파일 항목에 기록한 후 디스크에 저장
사용자 포인터 접근
RISC-V에서는 S-Mode가 U-Mode 페이지에 접근할 수 있는지 여부를 sstatus CSR의 SUM 비트를 통해 제어할 수 있음
이 비트가 설정되어 있지 않으면, S-Mode에서는 U-Mode 페이지에 접근할 수 없음
이는 커널이 사용자 메모리 영역을 의도치 않게 참조하는 것을 방지
#define SSTATUS_SUM (1 << 18)
__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) // updated
);
}
사용자 공간으로 진입할 때 비트 설정
Seiya Nuta,『OS in 1,000 Lines』를 학습하며 개인적인 공부 목적으로 작성되었으며, 수익을 창출하지 않습니다.
자세한 코드는 원작자의 Git Ripository를 참고하시면 됩니다.