원래는 글 하나에 다 작성하려고 했지만, 생각보다 분량이 많아져서 두 개로 나누었다.
지난시간까지 작업을 확인해보면
- 커널 부팅
- 문자열 출력
- 커널 실행 가능한 함수 작성
- 표준 함수 구현
- 커널 패닉 & 예외 핸들링
- 메모리 할당
- 프로세스 생성 & 프로세스 스케쥴링
- 페이지 테이블 관리
필자가 몇 번 강조했지만 한 번 더 얘기해도 괜찮을 것 같다. 운영체제의 다양한 기능들 중 가장 중요한 핵심 기능 두 가지는 메모리와 프로세스 관리라고 했다.
위 목록을 보면 가장 중요한 두 기능을 모두 구현했다 이제 우리 커널은 기본적인 운영체제의 모양새를 갖추게 된 것이다.
그래서 이쯤에서 한번 끊어가는게 흐름상 적당할 것이라 생각이 들었다.
이제부터 구현할 부분은 다음과 같다.
- Operating System 이란..
- Application
- User Mode
- SystemCall
- Disk I/O
- File System
다시한번 달려보자. 원 글에는 없는 9 챕터를 한번 추가해봤다. 운영 체제에 대한 좀더 깊은 얘기들을 해 볼까 싶어서다.
9. Operating System 이란..
운영체제가 과연 뭘까? 우리는 운영체제에 대한 별 다른 지식 없이도 손쉽게 운영체제를 사용하고 있다. 특히 windwos 시스템은 unix나 linux보다도 user friendly 한 시스템이란 생각이 든다.
사실 windows가 MS-DOS 커널을 버리면서 NT 커널로 이주를 했고, 큰 관점에서 봤을 때 이제는 세상에 존재하는 거의 모든 커널들이 unix 운영체제를 베이스로 만들어졌다고 봐도 과언이 아니라는 생각이 든다.
mac os 또한 마찬가지다. 또 BSD 종류는 unix의 직계후손이라 봐도 무방하다.
운영체제의 역할이 뭘까? 단순히 사용자에게 GUI 환경을 제공하는 것일까? 물론, 이것도 굉장히 중요한 요소 중 하나다. 서버 시장에서는 linux 운영체제가 점유율이 가장 높지만, PC 용도로는 windows 운영체제가 가장 점유율이 높은 이유가 바로 이 user friendly한 GUI이기 때문이다.
그렇다면 과연 user friendly 한 것만으로 끝일까? 만약 GUI는 굉장히 편리하지만, 속도가 느리다면? 10mb 짜리 파일 하나를 다운로드 받거나 복사하는데 1시간이 넘게 걸린다고 생각해보자. 요즘 같은시대에 누가 그 운영체제를 사용할까?
또 리눅스처럼 와이파이나 유선랜을 연결할때 일일히 네트워크 설정을 만져주어야 된다면? (사실 리눅스도 최근에 이런 부분들이 많이 개선되긴 했다.)
용량이 부족해서 하드디스크를 새로 하나 사서 달았는데, 하드디스크를 마운트하고 초기화 하는 등의 작업을 일일히 저수준으로 수행하야 한다면?
위와 같은은 다양한 애로사항들을 상상해볼 수 있다.
운영체제의 가장 중요한 요소는 바로 하드웨어 레벨의 저수준 작업들을 사용자가 알 필요없도록 만들어 준다는 것이다.
집에 계시는 우리 엄마, 아버지가 메모리가 어떻게 동작하고 파일을 다운로드 받거나, 삭제하면 저장장치에 어떤 변화가 생기는지 이해하실까? (개발자나 보안 전문가 분들은 예외로 하자 :D)
우리는 저런 저수준의 작업들을 몰라도 그냥 SSD를 연결하고, 램 슬롯에 램을 추가하고, 또 그래픽 카드를 연결하고 하는 작업만 해 준다면 아무 문제없이 이 장치들을 컨트롤할 수 있다.
심지어 윈도우 운영체제는 PnP 기능으로 자동으로 드라이버를 잡아주기까지 한다.
따라서 운영체제의 역할은 다음과 같이 정의해볼 수 있을 것 같다.
사용자와 하드웨어 사이에 존재하며, 사용자에게 하드웨어를 손쉽게 다룰 수 있는 인터페이스를 제공해주는 소프트웨어!
여러분도 이런 관점을 가지고 운영체제를 바라보자
10. Application
이전 포스팅에서 메모리 페이징 매커니즘을 용해 프로세스 별로 격리된 가상 주소 공간을 구현했다. 이제 어플리케이션들의 주소 공간을 어떻게 정의할건지 고민해보자.
10-1. Application Memory Layout
user.ld 파일을 만들고 다음 코드를 작성하자.
|
|
커널 코드와 유사하지만 몇 가지 다른점이 눈에 띈다. 우선 ENTRY다
- ENTRY(start) : start 함수를 entry point로 잡고있다.
- . = 0x1000000; : 메모리 시작 지점이 0x100 0000 이다.
ASSERT는 첫 번째 인자로 주어진 조건이 충족하지 않을 경우 링커를 중단한다.
즉 어플리 케이션 메모리 끝이 0x180 0000 을 초과하지 않도록 제한하고 있다.
그 이유가 뭘까?
10-2. Userland Library
이번 챕터에서는 유저랜드에서 동작가능한 소프트웨어를 작성해본다.
|
|
위의 user.ld 링커 스크립트에서 ENTRY(start) 로 정의 했으므로 어플리케이션의 실행은 start 함수에서 부터 시작한다. 커널 부트 과정과 유사하게 스택 포인터를 stack_top으로 설정한 뒤 main 함수를 호출한다.
exit() 함수는 어플리케이션을 종료할 때 사용하지만, 여기서는 무한루프를 돌게 만들었다.
또 putchar() 함수는 common.c 의 printf 함수가 참조하고 있으므로, 여기서는 정의만 해 둔다.
커널 초기화와 달리 .bss 섹션을 0으로 초기화하지 않았는데. 이는 커널의 alloc_pages 함수를 통해 .bss 가 0으로 채워지도록 초기화했기 때문이다.
TIP
대부분의 운영체제에서도, 사용자 프로그램에 할당된 메모리는 이미 0으로 초기화된 상태다. 그렇지 않으면, 다른 프로세스에서 사용하던 민감 정보(예: 인증 정보)가 남아있을 수 있고, 이는 심각한 보안 문제가 될 수 있다.
마지막으로 헤더파일 user.h 를 작성한다
10-3. First Application
첫 번째 어플리케이션을 작성해보자. 우리는 sh 같은 셸 프로그램을 작성해볼 것이다.
다만 일단은 무한루프를 도는 코드를 작성하자.
10-4. Application Build
자 첫 번째 어플리케이션을 빌드해보자. 커널 빌드와는 별도로 진행한다.
파일 이름은 run_app.sh로 하자
|
|
처음 $CC 명령은 커널 빌드와 비슷하다. C 파일들을 컴파일하고, user.ld 링커 스크립트를 사용해 링킹한다.
첫 번째 $OBJCOPY 명령은 ELF 형식의 실행 파일(shell.elf)을 실제 메모리 내용만 포함하는 바이너리(shell.bin)로 변환한다. 우리는 단순히 이 바이너리 파일을 메모리에 로드해 애플리케이션을 실행할 예정이다. 일반적인 OS에서는 ELF 같은 형식을 사용해, 메모리 매핑 정보와 실제 메모리 내용을 분리해서 다루지만, 여기서는 단순화를 위해 바이너리만 사용한다.
두 번째 $OBJCOPY 명령은 이 바이너리(shell.bin)를 C 언어에 임베드할 수 있는 오브젝트(shell.bin.o)로 변환한다. 이 파일 안에 어떤 심볼이 들어있는지 llvm-nm 명령으로 확인해 보자:
우리가 정의하지 않았음에도 불구하고
_binary_라는 접두사 뒤에 파일 이름이 오고, 그 다음에 start, end, size가 붙는다.
이 심볼들은 각각 바이너리 내용의 시작, 끝, 크기를 의미합니다. 보통은 다음과 같이 사용할 수 있다:
이 프로그램은 shell.bin의 파일 크기와 파일 내용의 첫 바이트를 출력한다.
다시 말해, _binary_shell_bin_start 변수를 파일 내용이 들어 있는 배열처럼 간주할 수 있다.
예를 들면, 다음과 같이 생각할 수 있다:
|
|
그리고 _binary_shell_bin_size에는 파일 크기가 들어 있다. 다만, 조금 독특한 방식으로 처리된다. 다시 llvm-nm을 확인해 보지.
llvm-nm 출력의 첫 번째 열은 심볼의 주소를 나타낸다. 여기서 10260(16진수)은 실제 파일 크기와 일치한다. A(두 번째 열)는 이 심볼이 링커에 의해 주소가 재배치되지 않는 ‘절대(Absolute)’ 심볼이라는 뜻이다. 즉, 파일 크기를 ‘주소’ 형태로 박아놓은 것이다.
char _binary_shell_bin_size[] 같은 식으로 정의하면, 일반 포인터처럼 보일 수 있지만 실제로는 그 값이 ‘파일 크기’를 담은 주소로 간주되어, 캐스팅하면 파일 크기를 얻게 된다.
마지막으로, 커널 컴파일 시 shell.bin.o를 함께 링크하면, 첫 번째 애플리케이션의 실행 파일이 커널 이미지 내부에 임베드된다.
10-5. 실행파일 디스어셈블
llvm-objdump -d shell.elf 명령을 실행해보자.
.text 섹션이 제일 앞에 오고 ENTRY point인 start 함수가 0x1000000 영역에 위치한것을 확인할 수 있다.
11. User mode
10 장에서 작성한 shell.elf 파일을 실행해보자.
11-1. 실행 파일 추출하기
ELF와 같은 실행 파일 형식에서는 로드 주소가 파일 헤더(ELF의 경우 프로그램 헤더)에 저장된다. 하지만, 우리의 애플리케이션 실행 이미지는 원시 바이너리(raw binary)이기 때문에, 다음과 같이 고정된 값으로 준비해주어야 한다:
kernel.h 파일에 다음 매크로를 추가하자.
다음으로, shell.bin.o에 포함된 원시 바이너리를 사용하기 위해 심볼들을 정의한다:
또한, 애플리케이션을 시작하기 위해 create_process 함수를 다음과 같이 수정한다:
|
|
create_process 함수를 실행 이미지의 포인터(image)와 이미지 크기(image_size)를 인자로 받도록 수정했다. 이 함수는 지정된 크기만큼 실행 이미지를 페이지 단위로 복사하여 프로세스의 페이지 테이블에 매핑한다. 또한, 첫 번째 컨텍스트 스위치 시 점프할 주소를 user_entry로 설정한다. 현재는 이 함수를 빈 함수로 유지한다.
마지막으로, create_process 함수를 호출하는 부분을 수정하여 사용자 프로세스를 생성하도록 하자:
|
|
11-2. 사용자 모드로 전환하기
애플리케이션을 실행하기 위해, 우리는 user mode 또는 RISC-V에서는 U-Mode 라 불리는 CPU 모드를 사용합니다. U-Mode로 전환하는 것은 의외로 간단합니다. 방법은 다음과 같습니다:
S-Mode에서 U-Mode로의 전환은 sret 명령어를 사용하여 이루어집니다. 다만, 모드를 변경하기 전에 두 개의 CSR에 값을 기록합니다:
sepc 레지스터: U-Mode로 전환 시 실행할 프로그램 카운터를 설정합니다. 즉, sret 명령어가 점프할 위치입니다. sstatus 레지스터의 SPIE 비트: 이 비트를 설정하면 U-Mode로 진입할 때 하드웨어 인터럽트가 활성화되며, stvec 레지스터에 설정된 핸들러가 호출됩니다.