2025. 3. 6. 01:07ㆍCS/OS
프로세스 | OS in 1,000 Lines
operating-system-in-1000-lines.vercel.app
1. 프로세스
운영체제는 프로세스의 생성, 실행, 일시 중지, 종료 등의 상태 전환을 관리.
각 프로세스가 사용하는 시스템 자원(메모리, 파일 등)을 할당 및 관리한다.
2. PCB
가. PCB
- Process Control Block
- 운영체제에서 각 프로세스에 대한 상태와 제어 정보를 저장하는 데이터 구조.
PCB에 포함되는 주요 정보 | 설명 |
프로세스 ID | 각 프로세스를 고유하게 식별할 수 있는 번호 |
프로세스 상태 | 실행 중, 준비 완료, 대기 등 프로세스의 현재 상태 |
프로그램 카운터(PC) | 다음에 실행할 명령어의 주소 |
레지스터 상태 | CPU 레지스터들의 값. 문맥 전환 시 중요한 역할을 함 |
메모리 관리 정보 | 페이지 테이블, 세그먼트 정보 등 프로세스가 사용하는 메모리 영역에 대한 정보 |
계정 정보 및 우선순위 | 프로세스의 자원 사용 정보나 스케줄링 우선순위 등 |
입출력 상태 정보 | 프로세스가 사용 중인 입출력 장치 정보 등 |
이번에는 간단한 PCB를 추가해 본다.
- kernel.c
#define PROCS_MAX 8 // 최대 프로세스 개수
#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]; // 커널 스택
};
vaddr_t sp;
: 프로세스의 현재 스택 위치를 가리키는 포인터
: 커널 스택의 포인트uint8_t stack[8192];
: 커널 스택.
: 프로세스가 커널 모드에서 실행될 때 사용되는 메모리 영역.
: 각 프로세스마다 별도로 할당.
: 저장된 CPU 레지스터, 함수 리턴 주소, 로컬 변수를 저장.
: 커널 스택은 커널 모드에서만 접근할 수 있어 안전하다.
: 여기선 8KB 할당.
나. 커널 스택
커널 스택(Kernel Stack)은 운영체제가 커널 모드에서 실행될 때 사용하는 메모리 영역입니다.
일반적인 프로세스는 커널 스택과 유저 스택을 각각 하나씩 가지고 있다.
사용자 모드 프로세스는 커널 스택에 접근할 수 없다.
인터럽트, 시스템 콜, 컨텍스트 스위칭 시 유저 모드에서 커널 모드로 전환된다.
커널 스택은 일반적으로 사용자 스택보다 작은 크기로 할당된다.
운영체제/시스템 | 커널 스택 크기 | 비고 |
리눅스 | 8KB~16KB | 커널 설정에 따라 다름 |
Windows | 12KB~24KB | |
macOS | 16KB | |
64비트 시스템 | 최대 64KB | 일반적으로 더 큰 스택 크기 사용 |
임베디드 시스템 | 4KB 이하 | 리소스 제약으로 인해 제한적 |
커널 모드로 전환하여 시스템 콜이나 인터럽트 작업을 수행하고, 다시 유저 모드로 돌아가기 위해서 필요한 정보 중 일부를 저장한다.
(일부는 다른 곳에 저장된다 - CPU 제어 레지스터, 메모리 관리 정보, 인터럽트 컨트롤러 상태 등은 별도의 시스템 구조체나 하드웨어 등)
대표적인 저장 정보 | 설명 |
레지스터 상태 | 현재 프로세스의 CPU 레지스터 값 |
함수 호출 프레임 | 함수 호출 시 반환 주소, 파라미터, 지역 변수 |
인터럽트 컨텍스트 | 인터럽트 발생 시 현재 실행 중이던 프로세스의 컨텍스트 |
시스템 콜 정보 | 사용자 모드에서 커널 모드로 전환 시 필요한 정보 |
예외 처리 정보 | 예외 발생 시 디버깅 정보 및 복구에 필요한 데이터 |
커널 모드에서 다시 사용자 모드로 돌아가면 유저 스택을 사용합니다.
또 커널 모드에서 호출한 함수의 지역변수는 해당 프로세스의 커널 스택에 할당된다.
따라서 지나치게 많은 지역변수와 재귀함수는 스택오버플로우를 유발할 수 있다.
💡링커 스크립트에서 설정한 스택 vs PCB의 커널 스택
같은 스택이라 용어가 혼란스러웠다.
커널 자체의 스택은 커널 내 함수 호출 시 반환 주소, 지역 변수, 매개변수 등을 저장하는 공유 메모리 영역으로,
모든 커널 함수의 호출 체인과 실행 컨텍스트를 유지하는 데 사용됩니다.
PCB의 커널 스택은 프로세스별로 할당되어 유저 모드에서 커널 모드로 전환될 때 상태 정보를 저장합니다.
3. 컨텍스트 스위칭
- Context Switching
: CPU가 한 프로세스에서 다른 프로세스로 전환할 때
: PCB에 저장된 정보를 사용하여 이전 프로세스의 상태를 보존하고, 새로운 프로세스의 상태를 복원한다.
- kernel.c
__attribute__((naked)) void switch_context(uint32_t *prev_sp,
uint32_t *next_sp) {
__asm__ __volatile__(
// 현재 프로세스의 스택에 callee-saved 레지스터를 저장
"addi sp, sp, -13 * 4\n" // 13개(4바이트씩) 레지스터 공간 확보
"sw ra, 0 * 4(sp)\n" // callee-saved 레지스터만 저장
"sw s0, 1 * 4(sp)\n"
"sw s1, 2 * 4(sp)\n"
"sw s2, 3 * 4(sp)\n"
"sw s3, 4 * 4(sp)\n"
"sw s4, 5 * 4(sp)\n"
"sw s5, 6 * 4(sp)\n"
"sw s6, 7 * 4(sp)\n"
"sw s7, 8 * 4(sp)\n"
"sw s8, 9 * 4(sp)\n"
"sw s9, 10 * 4(sp)\n"
"sw s10, 11 * 4(sp)\n"
"sw s11, 12 * 4(sp)\n"
// 스택 포인터 교체
"sw sp, (a0)\n" // *prev_sp = sp
"lw sp, (a1)\n" // sp를 다음 프로세스의 값으로 변경
// 다음 프로세스 스택에서 callee-saved 레지스터 복원
"lw ra, 0 * 4(sp)\n"
"lw s0, 1 * 4(sp)\n"
"lw s1, 2 * 4(sp)\n"
"lw s2, 3 * 4(sp)\n"
"lw s3, 4 * 4(sp)\n"
"lw s4, 5 * 4(sp)\n"
"lw s5, 6 * 4(sp)\n"
"lw s6, 7 * 4(sp)\n"
"lw s7, 8 * 4(sp)\n"
"lw s8, 9 * 4(sp)\n"
"lw s9, 10 * 4(sp)\n"
"lw s10, 11 * 4(sp)\n"
"lw s11, 12 * 4(sp)\n"
"addi sp, sp, 13 * 4\n"
"ret\n"
);
}
__attribute__((naked))
: 속성은 컴파일러가return
명령어와 같은 불필요한 코드를 함수 본문 전후에 생성하지 않도록 지시
: 인라인 어셈블리 코드가 정확히 함수 본문이 되도록 보장합니다.ra
: Return addresss0
~s11
: Temporary registers saved across calls
기존의 프로세스의 ra
, s0
~ s11
레지스터 정보와 스택 포인터를 저장하고, 스위칭할 프로세스의 ra
, s0
~ s11
레지스터 정보와 스택 포인터를 로딩한다.
가. 프로세스 생성 함수
프로세스 구조체를 생성하고 포인터를 반환한다.
- kernel.c
struct process procs[PROCS_MAX]; // 모든 프로세스 제어 구조체 배열
struct process *create_process(uint32_t pc) {
// 미사용(UNUSED) 상태의 프로세스 구조체 찾기
struct process *proc = NULL;
int i;
for (i = 0; i < PROCS_MAX; i++) {
if (procs[i].state == PROC_UNUSED) {
proc = &procs[i];
break;
}
}
if (!proc)
PANIC("no free process slots");
// 커널 스택에 callee-saved 레지스터 공간을 미리 준비
// 첫 컨텍스트 스위치 시, switch_context에서 이 값들을 복원함
uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)];
*--sp = 0; // s11
*--sp = 0; // s10
*--sp = 0; // s9
*--sp = 0; // s8
*--sp = 0; // s7
*--sp = 0; // s6
*--sp = 0; // s5
*--sp = 0; // s4
*--sp = 0; // s3
*--sp = 0; // s2
*--sp = 0; // s1
*--sp = 0; // s0
*--sp = (uint32_t) pc; // ra (처음 실행 시 점프할 주소)
// 구조체 필드 초기화
proc->pid = i + 1;
proc->state = PROC_RUNNABLE;
proc->sp = (uint32_t) sp;
return proc;
}
struct process procs[PROCS_MAX];
: 해당 교본에선 모든process
구조체의 배열procs
은 BSS 섹션에 할당된다.uint32_t pc
: 시작 시 처음으로 동작시킬 함수의 주소를 뜻한다.if (!proc) PANIC("no free process slots");
: 더 이상 pcb를 추가할 공간이 없다면 패닉.uint32_t *sp = (uint32_t *) &proc->stack[sizeof(proc->stack)];
: 프로세스의 스택 포인터를 초기화.
: 해당 프로세스에 할당된 커널스택의 가장 끝 바로 다음 주소를 가리키는 포인터.
:uint32_t *sp = (uint32_t *) &((*proc).stack[sizeof((*proc).stack)]);
와 같다.
: 스택은 밑에서부터 위로 쌓기 때문.
:&배열명[크기]
와 같은 표현은 문법적으로 유효한 주소 계산식, 실제로 메모리에 접근하지 않고 주소값만 계산하기 때문에 문법 오류가 발생하지 않음.*--sp = 0;
: s11부터s0
,ra
까지 역순으로 스택에 저장.
💡-> 연산자
C 언어에서 구조체 포인터의 멤버에 접근할 때 사용하는 연산자로 (*proc).stack과 동일하다.
나. 컨텍스트 스위칭 테스트하기
“멀티프로세스”를 찍먹 해보자.
- kernel.c
void delay(void) {
for (int i = 0; i < 30000000; i++)
__asm__ __volatile__("nop"); // do nothing
}
struct process *proc_a;
struct process *proc_b;
void proc_a_entry(void) {
printf("starting process A\n");
while (1) {
putchar('A');
switch_context(&proc_a->sp, &proc_b->sp);
delay();
}
}
void proc_b_entry(void) {
printf("starting process B\n");
while (1) {
putchar('B');
switch_context(&proc_b->sp, &proc_a->sp);
delay();
}
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
WRITE_CSR(stvec, (uint32_t) kernel_entry);
proc_a = create_process((uint32_t) proc_a_entry); // 추가
proc_b = create_process((uint32_t) proc_b_entry); // 추가
proc_a_entry(); // 추가
PANIC("unreachable here!");
}
임의의 두 개의 프로세스 a와 b를 만들어 테스트해 보았다.
“ABAB...” 무한 반복되면 정상이다.
4. 스케쥴러
가. 이론
스케줄링 알고리즘은 CPU 자원을 프로세스들에게 어떻게 할당할지 결정하는 방법이다.
주요 목표는 CPU 활용도를 최대화하고, 처리량을 높이며, 응답 시간을 최소화하는 것.
스케줄링 알고리즘 | 풀네임 | 설명 | 장점 | 단점 |
FCFS | First-Come, First-Served (선착순) | 프로세스가 도착한 순서대로 CPU를 할당. 비선점형 알고리즘. | 가장 단순한 스케줄링 알고리즘 | 큰 프로세스 뒤에 대기하는 작은 프로세스들이 오래 기다려야 하는 상황 (콘보이 효과) |
SJF | Shortest Job First (최단 작업 우선) | CPU 버스트 시간이 가장 짧은 프로세스에게 먼저 CPU를 할당. | 평균 대기 시간을 최소화하는 최적의 알고리즘 | 버스트 시간 예측 어려움 |
SRTF | Shortest Remaining Time First (최소 잔여 시간 우선) | SJF의 선점형 버전으로, 현재 실행 중인 프로세스보다 CPU 버스트 시간이 더 짧은 프로세스가 도착하면 CPU를 빼앗음. | 짧은 작업 빠른 처리 | 잦은 컨텍스트 스위칭, 기아 상태 가능 |
RR | Round Robin (라운드 로빈) | 각 프로세스에 동일한 시간 할당량을 부여하고, 시간이 만료되면 CPU를 다음 프로세스에게 넘김. | 응답 시간 좋음, 공평성 | 컨텍스트 스위칭 오버헤드, 시간 할당 설정 민감 |
Priority | Priority Scheduling (우선순위 스케줄링) | 각 프로세스에 우선순위를 부여하고, 우선순위가 높은 프로세스에게 먼저 CPU를 할당. | 중요 작업 우선 처리 | 기아 상태 발생 가능, 우선순위 결정 어려움 |
Multilevel Queue | (다단계 큐 스케줄링) | 프로세스를 여러 그룹으로 나누어 각 그룹마다 다른 스케줄링 알고리즘을 적용. | 프로세스 특성별 최적화 | 큐 간 자원 분배 복잡, 오버헤드 증가 |
Multilevel Feedback | (다단계 피드백 큐) | 다단계 큐의 확장형으로, 프로세스가 한 큐에서 다른 큐로 이동할 수 있다. CPU 사용 패턴에 따라 프로세스의 우선순위를 동적으로 조정. | 동적 우선순위 조정 | 구현 복잡, 오버헤드 큼 |
CFS | Completely Fair Scheduler (완전 공정 스케줄러) | 리눅스 커널에서 사용하는 스케줄러로, 프로세스 간에 CPU 시간을 공정하게 분배하는 것을 목표. 레드-블랙 트리 자료구조를 사용. | CPU 시간 공정 분배 | 복잡한 구현, 계산 오버헤드 |
운영체제 | 대표 스케줄링 알고리즘 | 특징 |
리눅스(Linux) | CFS(Completely Fair Scheduler) | 공정한 CPU 시간 분배를 목표로 레드-블랙 트리 자료구조 사용 |
윈도우(Windows) | 우선순위 기반 선점형 스케줄링 | 0-31 우선순위 레벨로 프로세스 실행 순서 관리 |
macOS/iOS | GCD(Grand Central Dispatch) | 작업 단위 관리 최적화 및 멀티코어 활용 극대화 |
솔라리스(Solaris) | FSS(Fair Share Scheduler) | 사용자 그룹 간 CPU 자원 공정 분배 |
FreeBSD | ULE 스케줄러 | 다중 프로세서 시스템 최적화 및 반응성 향상 |
실시간 운영체제(RTOS) | EDF(Earliest Deadline First) | 데드라인이 임박한 작업을 우선적으로 처리 |
나. 구현
- kernel.c
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);
}
void kernel_main(void) {
memset(__bss, 0, (size_t) __bss_end - (size_t) __bss);
printf("\n\n");
WRITE_CSR(stvec, (uint32_t) kernel_entry);
idle_proc = create_process((uint32_t) NULL); // 추가
idle_proc->pid = 0; // idle // 추가
current_proc = idle_proc; // 추가
proc_a = create_process((uint32_t) proc_a_entry);
proc_b = create_process((uint32_t) proc_b_entry);
yield(); // 추가
PANIC("switched to idle process"); // 추가
}
처음에 pid가 0인 idle_proc
부터 시작해서 모든 프로세스를 처리하고 다시 idle_proc
로 돌아온다.
void proc_a_entry(void) {
printf("starting process A\n");
while (1) {
putchar('A');
yield(); // 수정
}
}
void proc_b_entry(void) {
printf("starting process B\n");
while (1) {
putchar('B');
yield(); // 수정
}
}
각 프로세스는 지속적으로 컨택스트 스위칭 수행.
5. 예외 처리기 수정
기존의 예외 핸들러는 예외 발생 시점의 실행 상태를 스택에 저장했다.
이제 프로세스마다 별도의 커널 스택에 저장하도록 수정한다.
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;
__asm__ __volatile__( // 추가
"csrw sscratch, %[sscratch]\n" // 추가
: // 추가
: [sscratch] "r" ((uint32_t) &next->stack[sizeof(next->stack)]) // 추가
); // 추가
// 컨텍스트 스위칭
struct process *prev = current_proc;
current_proc = next;
switch_context(&prev->sp, &next->sp);
}
sscratch
: Supervisor Scratch
: RISC-V 아키텍처에서 제공하는 특수 레지스터로, 주로 예외 처리 시 임시 값을 저장하는 용도로 사용됨.- 다음 실행할 프로세스(next)의 스택 최상단 주소를
sscratch
레지스터에 저장함.
void kernel_entry(void) {
__asm__ __volatile__(
// 추가
"csrrw sp, sscratch, sp\n" // sp와 sscratch 값을 스왑
"addi sp, sp, -4 * 31\n" // 스택 포인터를 31개 레지스터 저장 공간만큼 확보
"sw ra, 4 * 0(sp)\n" // 리턴 주소 레지스터 저장
"sw gp, 4 * 1(sp)\n" // 전역 포인터 레지스터 저장
"sw tp, 4 * 2(sp)\n" // 스레드 포인터 레지스터 저장
"sw t0, 4 * 3(sp)\n" // 임시 레지스터 0 저장
"sw t1, 4 * 4(sp)\n" // 임시 레지스터 1 저장
"sw t2, 4 * 5(sp)\n" // 임시 레지스터 2 저장
"sw t3, 4 * 6(sp)\n" // 임시 레지스터 3 저장
"sw t4, 4 * 7(sp)\n" // 임시 레지스터 4 저장
"sw t5, 4 * 8(sp)\n" // 임시 레지스터 5 저장
"sw t6, 4 * 9(sp)\n" // 임시 레지스터 6 저장
"sw a0, 4 * 10(sp)\n" // 인자 레지스터 0 저장
"sw a1, 4 * 11(sp)\n" // 인자 레지스터 1 저장
"sw a2, 4 * 12(sp)\n" // 인자 레지스터 2 저장
"sw a3, 4 * 13(sp)\n" // 인자 레지스터 3 저장
"sw a4, 4 * 14(sp)\n" // 인자 레지스터 4 저장
"sw a5, 4 * 15(sp)\n" // 인자 레지스터 5 저장
"sw a6, 4 * 16(sp)\n" // 인자 레지스터 6 저장
"sw a7, 4 * 17(sp)\n" // 인자 레지스터 7 저장
"sw s0, 4 * 18(sp)\n" // 저장 레지스터 0 저장
"sw s1, 4 * 19(sp)\n" // 저장 레지스터 1 저장
"sw s2, 4 * 20(sp)\n" // 저장 레지스터 2 저장
"sw s3, 4 * 21(sp)\n" // 저장 레지스터 3 저장
"sw s4, 4 * 22(sp)\n" // 저장 레지스터 4 저장
"sw s5, 4 * 23(sp)\n" // 저장 레지스터 5 저장
"sw s6, 4 * 24(sp)\n" // 저장 레지스터 6 저장
"sw s7, 4 * 25(sp)\n" // 저장 레지스터 7 저장
"sw s8, 4 * 26(sp)\n" // 저장 레지스터 8 저장
"sw s9, 4 * 27(sp)\n" // 저장 레지스터 9 저장
"sw s10, 4 * 28(sp)\n" // 저장 레지스터 10 저장
"sw s11, 4 * 29(sp)\n" // 저장 레지스터 11 저장
"csrr a0, sscratch\n" // sscratch(원래 프로세스의 sp)를 a0에 읽어옴
"sw a0, 4 * 30(sp)\n" // 예외 발생 시점의 sp 값을 저장
// 추가
"addi a0, sp, 4 * 31\n" // a0에 (sp + 31*4)를 계산 후 저장
"csrw sscratch, a0\n" // 다음 예외를 위해 sscratch 레지스터 재설정
"mv a0, sp\n" // 현재 스택 포인터를 a0에 저장(handle_trap 함수의 인자로 전달)
"call handle_trap\n" // 트랩 핸들러 호출
tmp = sp;
sp = sscratch;
sscratch = tmp;
앞에 나온 어셈블리 코드 중 "csrrw sp, sscratch, sp\n"
이 하는 동작은 다음과 같다.
sp
: 프로세스가 실행 중일 때는 현재 실행 중인 프로세스의 스택 포인터를 가리킴sscratch
: 프로세스 전환 시yield()
함수에서 다음 실행할 프로세스의 스택 최상단 주소를 저장함
도대체 왜 이렇게 복잡하게 동작하는 걸까?
가. 왜?
다시 말해서, 왜 스택 포인터를 매번 리셋해야 할까?
예외가 발생한 시점의 sp
부터 이어서 레지스터 정보를 저장하지 않고 굳이 sscratch
를 통해 커널 스택으로 리셋한다.
이는 “예외가 발생했을 때의 스택 포인터를 신뢰할 수 없기” 때문이라고 한다.
설명에 따르면 다음과 같다.
예외 핸들러는 다음 세 가지 상황을 고려해야 합니다:
- 커널 모드에서 예외가 발생한 경우
- 커널 모드에서 예외를 처리하는 도중(중첩 예외) 다시 예외가 발생한 경우
- 유저 모드에서 예외가 발생한 경우
(1)에서는 스택 포인터를 그대로 써도 큰 문제가 없다.
맞음.
(2)에서는 중첩 예외 시에 저장 영역이 겹칠 수 있지만, 보통 중첩 예외가 발생하면 커널 패닉으로 처리하므로 문제는 없습니다.
오케이. 인정.
2번의 경우만 제외한다면 커널 스택의 최상위 주소로 sp
설정해도 커널 스택에 저장된 정보가 없으므로 데이터를 덮어쓸 경우는 존재하지 않음.
문제는 (3)입니다. 유저 모드에서 예외가 발생하면,
sp
는 유저(애플리케이션)의 스택을 가리킬 수 있습니다. 이 스택 포인터를 그대로 커널이 써버리면, 매핑되지 않은 주소에 접근하거나 악의적으로 조작된 주소에 접근해 커널이 오동작(또는 보안 취약점)이 생길 수 있습니다.
!!!!
그래서 신뢰할 수 있는 커널 스택의 주소(정확히는 그 바로 다음 주소)로 리셋하는 것이다.
다른 해결책으로, 예외 핸들러를 아예 분리하는 방안도 있다고 한다.
결국 모든 입력을 의심해야 하기 때문이었다.
'CS > OS' 카테고리의 다른 글
[1000줄 OS 구현하기] 애플리케이션 (0) | 2025.03.09 |
---|---|
[1000줄 OS 구현하기] 페이지 테이블 (0) | 2025.03.07 |
[1000줄 OS 구현하기] 메모리 할당 (0) | 2025.02.11 |
[1000줄 OS 구현하기] 예외 (0) | 2025.02.05 |
[1000줄 OS 구현하기] 커널 패닉 (0) | 2025.02.03 |