2023-04-16
운영체제 #1 — 컴퓨터 시스템 개요
운영체제 얘기를 하기 전에 운영체제가 올라앉을 기계를 먼저 본다. CPU와 메인 메모리가 버스로 연결되어 있고, 메인 메모리는 휘발성이고, 그 바깥에 주변 장치가 붙어 있다. 여기서부터 시작한다.
레지스터
레지스터는 CPU 안에서 데이터를 담는 작은 저장 장치다. 크게 두 부류로 나뉜다.
User-visible register는 프로그래머가 어셈블리를 쓸 때 직접 쓸 수 있는 레지스터다. 나머지는 OS를 통해서만 접근할 수 있다.
Control/status register는 CPU의 동작을 제어하고 상태를 담는다.
- PC — 다음에 가져올 명령어의 메모리 주소. 프로그램이 시작할 때는 첫 명령어의 주소가 들어 있고, 명령어를 가져오면 다음 주소로 바뀐다.
- IR — PC가 가리킨 주소에서 가져온 명령어를 담는다. CPU는 여기서 읽어 실행한다.
- PSW — condition code(Positive, Negative, Zero, Overflow), 인터럽트 허용 여부, 그리고 현재 모드를 담는다.
메모리 입출력에는 MAR(주소)과 MBR(데이터)이 쓰이고, I/O 장치 쪽에는 I/O AR과 I/O BR이 따로 있다. 중요한 건 I/O가 메모리를 거쳐서 일어난다는 점이다. 보조기억장치의 컨트롤러 안에도 버퍼 메모리가 있고, 메인 메모리에 그 버퍼의 주소가 매핑된다.
Fetch-Execute
명령어 하나가 처리되는 과정은 두 단계다.
- Fetch — PC가 가리키는 명령어를 가져와 IR에 넣는다. 그리고 PC를 다음 명령어로 옮긴다.
- Execute — IR에 들어 있는 명령어를 실행한다.
PC에 300이 들어 있으면 메모리 300번지의 값을 IR로 가져오고(IR = 1940), PC를
증가시킨다. 그다음 opcode를 해석해서 — 예를 들어 opcode가 1이면 940번지의 값을
AC(accumulator)에 로드하라는 뜻이니 — 그대로 실행한다. 마지막 명령어까지 이걸
반복한다.
명령어의 종류는 크게 네 가지다. 프로세서와 메모리 사이의 전송(load, store), 프로세서와 I/O 장치 사이의 전송, 산술·논리 연산, 그리고 jump 같은 제어 명령이다.
인터럽트가 필요한 이유
동영상을 재생하는 중에도 마우스를 움직이거나 키보드를 치면 반응해야 한다. 즉 I/O 장치에서 이벤트가 일어났을 때 CPU가 하던 일을 잠시 멈추고 그걸 처리한 다음 돌아와야 한다. 이게 인터럽트다.
그래서 fetch-execute 사이클에 단계가 하나 붙는다. execute 다음에 interrupt check다. 만약 지금 하는 일이 정말 중단되면 안 되는 일이라면 interrupt를 disable할 수도 있다.
인터럽트가 들어왔을 때 벌어지는 일의 순서는 이렇다.
- 디바이스 컨트롤러가 인터럽트를 보낸다.
- 프로세서는 지금 실행 중인 명령어를 끝낸 뒤 interrupt check를 한다.
- 프로세서가 잘 받았다고 acknowledgment를 보낸다.
- 돌아오기 위해 PSW와 PC를 control stack에 저장한다.
- PC를 interrupt handler 코드의 위치로 바꾼다.
- 나머지 레지스터도 저장한다.
- handler를 실행한다.
- 끝나면 저장해둔 값들을 복구하고 원래 명령어로 돌아간다.
stack pointer는 이 상태를 저장하는 자료구조의 top을 가리킨다. 값을 쌓을수록 주소가 감소한다. handler가 return을 만나면 저장해둔 PC와 레지스터를 되돌리고 stack pointer도 원래 값으로 되돌린다.
인터럽트가 없다면
I/O를 일으키는 코드를 생각해보자. 사용자 코드는 하드웨어를 직접 건드리지 못하니
WRITE 같은 시스템 콜을 부른다. 시스템 콜이 실제로 I/O 커맨드를 내리고, I/O가
끝나면 뒷정리를 하고 돌아온다.
문제는 하드디스크가 일하는 동안 CPU가 놀고 있다는 것이다. 인터럽트를 쓰면 I/O 커맨드를 내려놓고 CPU는 다음 일을 계속하다가, 끝났다는 인터럽트가 오면 그때 뒷정리를 한다. 프로세스가 wait하는 시간이 사라진다.
그런데 인터럽트만으로는 부족하다. I/O가 오래 걸려서 두 번째 WRITE가 올
때까지 인터럽트가 안 왔다면, 원칙적으로 두 번째 WRITE를 실행하면 안 된다.
첫 I/O 커맨드가 끝나고 handling까지 마쳐야 다음으로 갈 수 있다. (이 원칙이
나중에 깨지는 건 뒤에서 배운다.)
그래서 멀티프로그래밍이 나온다. 기다려야 하는 상황이면 아예 상관없는 제3의 프로그램을 실행한다. 프로세서 하나가 여러 프로그램을 실행하는 것이고, 순서는 CPU 스케줄링이 정한다. 그래서 interrupt handler가 끝났다고 반드시 원래 프로그램으로 돌아가야 하는 것도 아니다. 현재 실행 대상 중 아무거나로 가면 된다.
메모리 계층과 캐시
저장 장치는 접근 빈도에 따라 나눈다. 자주 접근되는 데이터는 빠른 장치에, 덜 접근되는 데이터는 느린 장치에 둔다. 이게 가능한 이유는 locality of reference 때문이다. CPU는 모든 데이터와 명령어를 균일하게 접근하지 않고, 특정 데이터나 명령어를 반복해서 쓴다.
백업 장치는 필요할 때 붙이니 off-line, 하드디스크는 켜면 같이 켜지지만 메인보드 밖이니 outboard storage, 메인 메모리는 메인보드 안이니 onboard storage다.
CPU와 D-RAM의 속도 격차가 심하다. I/O가 일어날 때는 하드디스크가 병목이지만, I/O가 없을 때는 메모리가 병목이다. 그래서 캐시를 둔다.
- CPU ↔ 캐시는 word 단위(명령어 하나, 데이터 하나)로 주고받는다.
- 캐시 ↔ 메인 메모리는 block 단위로 주고받는다.
읽으려는 주소 RA가 캐시에 있는지 확인하고, 없으면 메인 메모리에서 RA를 포함하는 블록을 통째로 캐시에 올린 뒤 RA만 CPU로 보낸다. 블록 단위인 이유는 어떤 명령어나 데이터를 쓰면 그 주변도 곧 쓸 가능성이 크기 때문이다. 그렇다고 블록을 무작정 키우면 안 쓰는 것까지 딸려 들어와서 캐시 공간이 낭비된다.
캐시 설계에서 결정할 것 두 가지가 있다.
- Mapping function — 블록이 캐시에 있는지 빨리 확인할 수 있어야 한다.
- Write policy — 캐시에 반영된 값을 언제 메모리에도 반영할 것인가. 매번 반영하면 오버헤드가 너무 크니, 보통 업데이트된 블록이 캐시에서 쫓겨날 때 반영한다.
멀티코어에서는 코어마다 캐시를 가지므로 같은 데이터의 값이 코어마다 다를 수 있다. cache coherence problem이다. 한 캐시에서 값이 바뀌면 같은 데이터를 들고 있는 다른 캐시에 cache invalidation 메시지를 보내 값을 다시 가져오게 한다.
디스크 캐시도 있다. 메모리와 디스크 사이에서 캐시 역할을 하는데, 별도의 RAM이 아니라 메인 메모리의 일부를 쓴다.
I/O를 누가 하느냐
I/O란 결국 메모리와 컨트롤러 안의 버퍼 사이의 입출력이다. Input은 컨트롤러 버퍼에서 메모리로 가져오는 것, output은 메모리에서 컨트롤러 버퍼로 보내는 것이다. 하드디스크에 output이 되면, 그다음은 하드디스크 컨트롤러가 버퍼의 데이터를 마그네틱 디스크에 저장한다.
I/O 장치의 컨트롤러는 메모리 어디에 접근해야 하는지 모르고, 메모리 접근 권한도 없다. 그래서 CPU가 버퍼를 통해 모든 I/O에 관여하게 된다. 이 방식이 단계적으로 나아진다.
- Polling — I/O 장치의 busy bit를 CPU가 계속 확인한다. not-ready 루프를 도는 동안 CPU가 무의미한 일을 반복한다. 낭비다.
- Interrupt-driven I/O — CPU는 유의미한 일을 하다가 인터럽트가 오면 처리한다. 다만 CPU는 word 단위로 일하기 때문에 데이터를 word 단위로 옮겨야 해서 비효율적이다.
- DMA — CPU가 아니라 별도의 장치가 I/O를 대신한다. CPU가 DMA에게 읽어달라고 부탁하고 자기 일을 하다가, DMA가 끝내고 보낸 인터럽트를 받아 결과를 쓴다.
코어
CPU 하나를 core라고 생각해도 된다. 코어 하나 안에 CPU가 갖춰야 할 것이 전부 들어 있고, 우리는 그 코어 여러 개를 한 덩어리로 묶어 CPU라고 부를 뿐이다.