해당 글은 운영체제를 처음 공부하는 사람이 공부하기 위해 쓴 글입니다.
강동현 교수님의 강의 내용과 책 "운영체제 아주 쉬운 세 가지 이야기" 제2판을 공부하고 이를 요약해본 것입니다.
나중에 다시 읽을 수 있게 정리한 글이라 다른 사람들이 읽기 쉬울지는 잘 모르겠지만, 다른 분들에게도 도움이 된다면 좋겠습니다.
이 글은 처음 운영체제를 공부하는 사람을 대상으로 하고 있는데, 운영체제를 아무것도 모르는 사람이 공부한 내용이다 보니 다소 오류가 많습니다.
오류가 있다면 지적 부탁드립니다.
애플리케이션 프로그램은 어떻게 실행될까?
최근의 애플리케이션 프로그램은 고수준 프로그래밍 언어로 작성된다.
고수준 언어에는 과거의 것인 Fortran, Basic부터 시작해서 최근에는 C 계열 언어, Java 계열 언어, Python, Ruby 등등 다양한 언어가 있다.
컴퓨터 공부를 해본 사람이라면 알겠지만, 사실 고수준 언어는 사람이 조금이나마 읽기 쉽게 만든 언어라서 컴퓨터의 저수준 언어인 기계어, 어셈블리어로 변환해야 한다.
혹시나 기억 안 날 수도 있으니, 기계어와 어셈블리어는 밑에 있는 것과 같은 뜻이다.
기계어: 우리가 흔히 아는 0101로 된 코드.
어셈블리어: 기계어를 영어로 번역만 한 언어이다. 기계어랑 사실 똑같다.
이렇게 하는 과정을 컴파일이라고 한다.
코드를 모두 작성하고 나서 컴파일해서 모든 코드를 기계어로 변환하는 방식이 컴파일 방식, 한 줄 한 줄 작성된 코드를 기계어로 변환하는 방식이 인터프리터 방식인데...
기본적으로 프로그램은 이 과정을 따라가며 만들어지게 된다.
이렇게 만들어진 프로그램은 폰 노이만 컴퓨팅 모델에 따르면 세 가지 과정을 거친다.
1. 반입(Fetch): 실행할 명령어를 메모리에서 CPU 레지스터로 가져온다.
2. 해석(Decode): 가져온 명령어를 해석한다. 무슨 명령어인지 확인하는 과정이다.
3. 실행(Execute): 말 그대로 명령어를 실행하는 과정이다.
이를 명령어 하나하나마다 실행해가며 프로그램을 실행하는 것이다.
실제로는 메모리와 CPU의 속도 차이가 너~무 커서 명령어 하나를 메모리에서 가져올 때 CPU는 사실상 놀게 된다.
CPU를 방치할 순 없으니 메모리와 CPU 사이에 캐시 메모리를 둬서 조금이라도 시간을 줄이고, 그러고도 시간 극복이 안 돼서 가져온 명령어를 해석할 때(Decode) 미리 다음 명령어도 가져와서(Fetch) 시간을 효율적으로 쓰게 된다.
(그림 출처: http://recipes.egloos.com/v/5643663)
위와 같은 구조를 파이프라인이라고 한다.
운영체제란?
그러면 운영체제는 뭘까?
운영체제도 일종의 소프트웨어이다.
운영체제란 애플리케이션 프로그램의 실행하기 위한 소프트웨어이다.
현재 운영체제는 세 가지 역할을 한다.
1. CPU 관리: 프로그램 실행을 위해 CPU 레지스터를 초기화하고, CPU 성능을 최대한 효율적으로 뽑아낸다.
2. Memory 관리: 프로그램이 메모리 공간을 신경 쓸 필요가 없도록 메모리를 할당하고 관리한다.
3. 외부 장치(External devices) 관리
이를 실현하기 위해 운영체제는 가상화(CPU, 메모리), 병행성, 영속성의 특징을 갖는데 이건 나중에 배우게 될 내용이다.
정리하면 운영체제는 응용 프로그램이 시스템을 신경 쓸 필요가 없게끔 관리해주는 소프트웨어이다.
물론 처음부터 운영체제가 이런 형태를 띤 것은 아니라고 한다.
운영체제의 역사
1. 초창기: 단순 라이브러리
초창기의 운영체제는 자주 사용하는 함수들을 모아놓은 라이브러리 정도였다.
우리가 C언어에서 #include <stdin>, #include <stdlib> 같은 함수 라이브러리를 사용하듯이, 운영체제도 그걸 제공하는 정도의 역할밖에 안 했던 것이다.
컴퓨터가 처음 나왔을 50년대에는 지금처럼 C언어 같은 고급 프로그래밍 언어가 없었다.
전부 다 손 코딩, 어셈블리어를 한 땀 한 땀 새겨서 장인정신으로 만들어가던 시기였다.
지금으로 치면 하드디스크에서 메모리로 파일을 옮기고, 메모리에서 CPU로 명령어를 옮기는 것조차 프로그래머가 하나하나 해결해야 했던 셈이다.
그러다 보니 중복되는 코드가 상당히 많아지고 비효율적이었다.
운영체제는 여기서, 모든 저수준 코드를 프로그래머가 각자 짜게 하는 대신, 기본적인 기능을 API 형식으로 제공해주었다.
개발자는 세세한 부분을 '그나마' 덜 신경 쓰게 되어 편했을 것이다.
이제 그렇게 만든 프로그램은 컴퓨터 관리자가 컴퓨터에 프로그램을 수행시켰다.
컴퓨터는 하나였고, 실행시키는 컴퓨터 관리자도 사람이었기 때문에 프로그램을 하나씩 하나씩 넣어서 실행시켰다.
그래서 프로그램이 순차적으로 실행된다고 해서 일괄 처리(Batch) 방식이라 불렀다.
2. 보호의 단계
그런데 이 단계에서는 치명적인 단점 하나가 있었다.
바로 프로그래머가 하드웨어를 직접 접근할 수 있다는 것이다.
코드를 짤 때 아무런 보호 장치가 없다 보니 컴퓨터 내에 있는 어떤 파일도 읽어낼 수 있었고, 마음만 먹으면 개인정보 빼돌리는 일쯤이야 아주 간단한 일이었을 것이다.
그래서 어느 정도 발전하고 나서는 운영체제 코드가 하드웨어 장치를 제어하도록 해서, 다른 코드가 접근 못 하도록 하드웨어 장치와 컴퓨터 내부의 파일을 보호하였다.
사용자 모드(user mode), 커널 모드(kernel mode)로 나누어서 사용자 응용 프로그램은 사용자 모드로 실행하도록 했다.
여기서 사용자 모드로는 컴퓨터에서 접근할 수 있는 범위가 제한되어 있어서 컴퓨터에 중요한 파일에는 접근할 수 없다.
지금으로 따지면 롤 같은 게임이 컴퓨터 시스템 파일을 못 건드리는 것과 같다.
코드로 예를 들자면, C언어의 #include <stdio.h>에서 printf와 같은 함수의 헤더 파일은 사용자 모드, 실제 구현은 커널 모드에 있다.
그래서 헤더 파일에 있는 함수에는 접근할 수 있지만 이러한 라이브러리의 실제 구현을 보려면 운영체제의 커널 단계까지 내려가야 한다.
(커널(Kernel): 운영체제의 핵심 부분을 구현하는 코드)
3. 멀티프로그래밍 시대
미니 컴퓨터 시대가 와서 이용자가 증가하게 되면서 새로운 기법이 생겨나게 된다.
바로 멀티프로그래밍 기법이다.
멀티프로그래밍 기법은 한 번에 하나의 프로그램만 실행하는 대신 여러 작업을 메모리에 올리고 번갈아 실행하도록 한다.
이렇게 하면 CPU 사용률이 향상된다.
왜 그런가 하면, 입출력 장치가 느려서 원래라면 하나의 프로그램만 실행할 수 있다면 입출력하는 동안의 시간은 낭비되게 된다.
여기서 여러 프로그램을 번갈아 실행하면, 입출력 장치를 실행하려고 낭비되는 시간 동안 다른 프로그램을 실행해서 CPU를 좀 더 고효율로 가동할 수 있는 것이다.
다만 이렇게 되면서 병행성 문제가 생기게 되고, 운영체제는 병행성 문제를 해결하기 위해 여러 가지 방법을 사용하게 된다.
여기서 병행성 문제는 나중에 다루게 될 것이다.
4. 현대
이후 개인용 컴퓨터(PC: Personal Computer)가 등장하게 되면서 지금과 같은 컴퓨터의 형태가 확립된다.
여기서도 더 얘기할 게 있긴 하지만, 너무 분량이 많아지는 것 같으니까 궁금한 사람은 직접 검색해보자.
주요 주제
운영체제에서 다루는 주제는 세 가지가 있다.
바로 가상화, 병행성, 영속성이다.
1. 가상화(Virtualization)
앞에서 운영체제의 역할이 세 가지 나왔는데(CPU 관리, 메모리 관리, 외부 장치 관리), 그러기 위해서 운영체제는 어떤 일을 할까?
첫 번째는 가상화이다.
운영체제는 가상화(Virtualization)를 이용한다.
가상화는 말 그대로 없는 걸 있는 것처럼 속이는 거다.
운영체제는 하나밖에 없는 CPU를 여러 개 있는 것처럼 하고, 하나밖에 없는 메모리 공간을 여러 개가 있는 것처럼 환상을 만드는 일을 한다.
- CPU 가상화
지금이야 CPU가 여러 개 부착될 수 있지만 예전에는 하나밖에 사용할 수 없었다. 그러면 실질적으로는 한 번에 한 프로그램밖에 사용할 수 없다.
그런데 CPU를 가상화하면, 만약 100개의 프로그램을 실행했을 때 CPU가 100개 있는 것으로 속여서 모든 프로그램이 각각 하나의, 자기만의 CPU를 가지고 있는 것처럼 행동할 수 있다.
이를 CPU 가상화라고 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
// cpu.c
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
if (argc != 2)
{
fprintf(stderr, "usage: cpu <string>\n");
exit(1);
}
char *str = argv[1];
while(1)
{
sleep(1);
printf("%s\n", str);
}
return 0;
}
|
cs |
이건 책에서 가져온 프로그램인데, Linux 운영체제 기반으로 설계된 것이다.
5번 라인의 #include <unistd.h>을
#include <time.h>
#include <Windows.h>로,
17번 라인의 sleep(1)을 Sleep(1000)으로 변경하면 Windows 10에서도 실행할 수 있다.
이 프로그램은 1초에 한 번, main의 인자 argv[1]을 출력하는 것을 무한히 반복한다.
만약 이걸 동시에 2개 실행하면 어떨까?
CPU는 하나밖에 없음에도 불구하고 프로그램 두 개를 동시에 실행할 수 있다.
2개? 그뿐만 아니라, 몇 개건 프로그램을 실행할 수 있다.
이렇게 동시에 여러 프로그램을 실행할 수 있는 것은 CPU 가상화로 인해 각각의 프로그램이 자기가 자기만의 CPU를 갖고 있기 때문이다.
이렇게 분할해주는 방법이 바로 시분할 기법(Time sharing)이다.
다만 실행되는 순서가 매번 랜덤 하게 바뀌는데, 프로그램이 실행되는 순서는 운영체제의 CPU 스케줄러(CPU Scheduler)가 관리해주게 된다.
- 메모리 가상화
CPU 뿐만 아니라 메모리도 가상화될 수 있다.
두 프로그램을 실행했을 때, 변수를 할당한 메모리의 주소가 같을 수도 있다는 것이다.
이것 같은 경우에는 주소 공간 난수화 기능을 꺼야 테스트를 할 수 있다고 해서 확인해보지 못했다.
책에 있는 내용을 보자면,
int *p = (int*)malloc(sizeof(int));
printf("메모리 주소: % p\n", p);
이렇게 주소를 할당하고 프로그램을 두 개를 동시에 실행했을 때 결과가
메모리 주소: 0x200000
메모리 주소: 0x200000
이렇게 같은 주소를 할당할 수도 있다는 것이다.
이 현상 또한 CPU 가상화와 동일하게, 프로그램마다 자신만의 메모리를 따로 할당받는 것처럼 환상을 보여주기 때문에 발생한다.
이렇게 함으로써 프로그램은 자신만의 CPU와 메모리를 할당받는 것처럼 동작한다.
CPU를 너무 오래 사용해서 다른 프로그램이 사용하지 못한다거나, 메모리 영역이 겹칠 수도 있는 문제는 우리가 짜는 소프트웨어에서는 신경 쓸 필요가 없는 셈이다.
모두 운영체제가 관리하기 때문이다.
2. 병행성(Concurrency)
멀티 스레드 프로그램을 돌리면 꼭 생기는 문제점이 있다.
한 번에 여러 일을 수행하려고 하다 보니, 가끔씩 행동이 무시될 때가 있는 것이다.
count 변수에 1씩 1000번 더하는 Work 함수가 있다.
메인 함수에서 Work 함수를 실행한다면, count가 0에서 1000으로 증가할 것이다.
만약 Work 함수를 두 개의 스레드로 나누어 동시에 수행하면 어떨까?
그렇게 하면 결과는 2000이 나올 것이다.
그런데 Work 함수가 1000번을 더하는 게 아니라 100만 번을 더한다면 어떻게 될까?
결과는 100만 + 100만 = 200만이 될 거라고 생각할 수 있지 않을까?
하지만 실제로 C언어, 자바로 프로그램을 만들어보면 count는 200만이 아니라 141만 같이 애매한 수가 되어 있다.
왜 이런 일이 발생할까?
그 이유는 바로 프로그램은 원자적(atomic)으로 수행되지 않기 때문이다.
무슨 말이냐 하면, count에 1을 더하도록 프로그램을 짜면 세 동작으로 나누어 실행한다는 것이다.
예를 들어
(C언어)
count++;
라는 코드를 실행하면 한 번에 count 값에 1을 더하는 것이 아니라
1. 메모리에서 레지스터로 count 값을 복사한다(Copy memory to register).
2. count 값을 증가시킨다(Increase count value).
3. count 값을 레지스터에서 메모리로 붙여 넣는다(Copy register to memory).
이 세 명령어로 나누어 실행한다는 것이다.
그러다 보니, 두 스레드 A와 B가 있으면
레지스터로 count 복사(A)
-> count 값 증가(A)
-> count 값을 메모리로 붙여 넣음(A)
-> 레지스터로 count 복사(B)
-> count 값 증가(B)
-> count 값을 메모리로 붙여 넣음(B)
count A = count = 0
count A = 0 -> 1 (1 더함)
count = countA = 1
countB = count = 1
countB = 1 -> 2 (1 더함)
count = countB = 2
결과: 2
이렇게 자기 스레드의 일을 모두 마치고 다음 스레드로 일을 넘겨주는 게 아니라,
레지스터로 count 복사(A)
-> 레지스터로 count 복사(B)
-> count 값 증가(B)
-> count 값을 메모리로 붙여 넣음(B)
-> count 값 증가(A)
-> count 값을 메모리로 붙여 넣음(A)
count A = count = 0
countB = count = 0
countB = 0 -> 1 (1 더함)
count = countB = 1
count A = 0 -> 1 (1 더함: countB의 값이 반영 X)
count = countA = 1
결과: 1
A와 B의 동작이 뒤섞여서 더한 값이 무시될 수도 있다...
운영체제에서도 마찬가지로, 여러 동작을 동시에 수행하다 보니 멀티 스레드와 똑같은 문제가 발생할 수 있다.
이걸 병행성(Concurrency) 문제라고 한다.
3. 영속성(persistence)
파일을 저장하지 않고 컴퓨터를 종료하려고 하면, 저장하지 않은 내용이 사라질 수 있다는 메시지가 뜬다.
왜 그러냐 하면, 컴퓨터를 종료하면 메모리(여기서는 DRAM)에 있던 내용은 모두 사라지기 때문이다!
그래서 컴퓨터의 DRAM은 휘발성 메모리라고 한다.
DRAM의 소자 특성상, 전원 공급이 끊어지거나 시스템이 고장(crash) 나면 데이터가 사라져 버리기 때문이다.
공들여 작업한 데이터가 소멸하지 않으려면 데이터를 영구적으로 어딘가에 저장할 필요가 있다.
여기서, 데이터를 영구적으로 안전하게 저장하는 특성을 영속성이라고 한다.
운영체제는 데이터의 영속성을 보장하는 역할을 한다.
컴퓨터의 영속성이 보장되려면 특별한 하드웨어와 소프트웨어가 필요하다.
여기서 사용되는 하드웨어 저장장치는 바로, 하드 드라이브(hard drive)이다.
최근에는 SSD가 자주 사용되니 이것도 포함할 수 있다.
그리고 소프트웨어로는 파일 시스템(file system)이 있다.
- 파일 시스템
파일 시스템은 운영체제에서 I/O (입출력) 장치를 관리하는 소프트웨어이다.
디스크에 파일의 내용을 쓰고(입력), 필요한 파일이 있을 때 읽어오는(출력) 역할을 한다.
정확히 말하면 운영체제와 파일 시스템은 별개라서, 운영체제가 파일 시스템을 이용하는 형태에 가깝다.
당연하지만 CPU나 메모리와 달리, 파일 시스템은 하드 드라이브를 가상화하진 않는다.
드라이브의 내용을 가상화해봐야 뭘 할 수 있을까?
프로그램에게 자기만의 하드 드라이브를 갖는 것처럼 작동하게 하면 자기 파일에만 접근할 수 있기 때문에, 자기가 생성한 파일만 사용할 수 있을 것이다.
오히려 파일은 프로그램끼리 공유할 수 있게 하는 게 더 활용 방안이 넓다.
그래서 굳이 하드 드라이브를 가상화하진 않는다고 한다.
마무리
이외에도 운영체제는 하드웨어에 접근할 수 있게끔 장치 드라이브(device driver)를 관리하고 인터럽트(interrupt)를 처리한다.
운영체제는 이 세 가지 문제를 어떻게 해결할까?
다음 글에는 프로세스부터 시작해서 가상화에 대해 알아보자.
댓글