컴퓨터 하드웨어 구조
프로그램과 데이터를 같은 메모리에 두는 폰 노이만 구조, CPU가 명령어를 실행하는 과정과 레지스터, 메모리 계층과 캐시, 구성 요소를 잇는 버스와 장치 컨트롤러를 운영체제의 관점에서 정리합니다.
운영체제는 하드웨어를 관리하는 소프트웨어이므로, 운영체제의 설계에는 하드웨어의 성질이 그대로 묻어 있습니다. 작업을 바꿀 때 왜 비용이 드는지는 CPU 안의 레지스터를 알아야 이해할 수 있고, 가상 메모리가 왜 필요한지는 물리 메모리의 한계를 알아야 이해할 수 있습니다. 캐시가 왜 있는지도 CPU와 메모리의 속도 차이를 알아야 설명할 수 있습니다. 하드웨어를 모른 채 운영체제의 개념을 외우면 "무엇"은 알아도 "왜"는 빠지게 됩니다.
이 글에서는 컴퓨터를 이루는 CPU, 메모리, 입출력 장치가 어떻게 연결되어 있고 각각 어떤 성질을 가졌는지 살펴봅니다. 컴퓨터 구조를 더 깊이 공부하고 싶다면 컴퓨터구조 교재로 이어 갈 수 있습니다.
프로그램을 메모리에 두는 생각: 폰 노이만 구조
1940년대 중반의 ENIAC은 프로그램을 배선으로 만들었습니다. 계산 순서를 바꾸려면 수많은 케이블과 스위치를 다시 연결해야 했고, 다른 계산을 하도록 준비하는 데 며칠이 걸리기도 했습니다. 1945년 존 폰 노이만(John von Neumann)이 정리한 EDVAC 보고서 초안은 다른 방법을 제시했습니다. 명령어도 결국 숫자로 표현할 수 있으니, 데이터와 같은 메모리에 저장해 두고 CPU가 하나씩 읽어 실행하면 된다는 것입니다. 이렇게 하면 프로그램을 바꿀 때 배선이 아니라 메모리의 내용만 바꾸면 됩니다. 이 저장 프로그램(stored program) 방식이 지금 우리가 쓰는 범용 컴퓨터의 바탕이 되었고, 이 구조를 폰 노이만 구조(von Neumann architecture)라고 부릅니다.
폰 노이만 구조의 컴퓨터는 세 가지 부분으로 이루어집니다. CPU(central processing unit)는 명령어를 실행해 계산하고 판단합니다. 메모리는 프로그램의 명령어와 데이터를 함께 저장하며, CPU가 주소로 직접 읽고 쓸 수 있습니다. 입출력 장치는 키보드, 디스크, 모니터, 네트워크 카드처럼 컴퓨터 밖과 데이터를 주고받습니다. 이 셋은 주소와 데이터, 제어 신호를 주고받는 연결로 이어져 있습니다.
하드웨어 · 구성 요소의 관계
CPU는 메모리와 I/O 컨트롤러에 연결되어 있습니다
메모리는 명령어와 데이터를 저장하고, I/O 컨트롤러는 장치와 데이터를 교환합니다. 선은 구성 요소의 관계이며 실행 순서가 아닙니다.
선은 구성 요소의 연결 관계입니다. 메모리는 명령어와 데이터를 함께 저장합니다. 실제 하드웨어의 캐시·전용 채널·PCIe 토폴로지를 모두 하나의 물리적 버스로 나타낸 것은 아닙니다.
그림의 선은 부품 사이의 연결 관계를 나타낼 뿐, 실행 순서나 실제 배선을 그대로 그린 것은 아닙니다. 실제 컴퓨터에서는 CPU와 메모리 사이에 캐시와 전용 채널이 있고, 장치는 PCIe 같은 별도 연결로 붙어 있습니다.
이 구조에는 근본적인 약점이 하나 있습니다. 명령어와 데이터가 같은 메모리에 있으므로, CPU는 명령어를 가져올 때도 데이터를 가져올 때도 메모리를 거쳐야 합니다. CPU의 속도는 빠르게 높아졌지만 메모리의 응답 속도는 그만큼 빨라지지 않아, CPU가 메모리를 기다리는 시간이 점점 큰 비중을 차지하게 되었습니다. 이 현상을 폰 노이만 병목(von Neumann bottleneck)이라고 하며, 뒤에서 볼 캐시가 이 병목을 줄이려고 생겼습니다.
명령어 메모리와 데이터 메모리를 물리적으로 나누는 하버드 구조(Harvard architecture)도 있습니다. 명령어와 데이터를 동시에 가져올 수 있어 병목이 줄지만 구조가 복잡해지며, Arduino에 쓰이는 AVR 같은 마이크로컨트롤러에서 볼 수 있습니다. 오늘날의 고성능 CPU는 프로그램이 보기에는 명령어와 데이터가 같은 메모리에 있는 폰 노이만 구조이지만, CPU 안의 L1 캐시를 명령어용과 데이터용으로 나누어 두 가지를 동시에 가져옵니다. 이런 방식을 수정된 하버드 구조(modified Harvard architecture)라고 부릅니다.
CPU 안에서 일어나는 일
CPU가 하는 일은 단순합니다. 메모리에서 명령어를 하나 읽고, 해석하고, 실행한 다음, 다음 명령어로 넘어갑니다. 이 일을 1초에 수십억 번 되풀이할 뿐입니다. 이를 위해 CPU 안에는 크게 세 부분이 있습니다.
산술 논리 장치(arithmetic logic unit, ALU)는 덧셈과 뺄셈 같은 정수 산술과 AND, OR, 비교 같은 논리 연산을 합니다. x = y + z의 덧셈도, if (a > b)의 비교도 ALU에서 일어납니다. 부동소수점 연산이나 여러 값을 한꺼번에 계산하는 벡터 연산은 별도의 실행 장치가 맡는 경우가 많습니다.
제어 장치(control unit)는 명령어를 해석해 다른 부분에 신호를 보냅니다. "주소 0x1000에서 값을 읽어라", "ALU에서 더하라", "결과를 이 레지스터에 넣어라" 같은 지시를 알맞은 순서와 타이밍에 내리는 것이 제어 장치의 일입니다. 제어 장치 자체는 계산하지 않고, 각 부분이 맞물려 동작하도록 조율합니다.
레지스터(register)는 CPU 안에 있는 아주 빠르고 작은 저장 공간입니다. x86-64에는 64비트 범용 레지스터가 16개 있어, 지금 계산 중인 값과 중간 결과를 담습니다. 메모리에서 값을 읽으려면 수백 클록을 기다려야 할 수도 있지만, 레지스터의 값은 바로 쓸 수 있습니다. 운영체제의 관점에서 특히 중요한 레지스터는 다음 세 가지입니다.
| 레지스터 | 담는 값 | 운영체제와의 관계 |
|---|---|---|
| 프로그램 카운터(program counter, PC) | 다음에 실행할 명령어의 주소. x86에서는 명령어 포인터(instruction pointer)라고 부른다 | 이 값을 저장해 두어야 나중에 작업을 멈춘 자리에서 이어 갈 수 있다 |
| 스택 포인터(stack pointer, SP) | 현재 스택의 맨 위 주소 | 함수 호출의 복귀 주소와 지역 변수가 스택에 놓이므로, 작업마다 자기 스택을 가리켜야 한다 |
| 상태 레지스터 | 직전 연산의 결과(0인지, 자리 올림이 있었는지 등)와 인터럽트를 받을지 같은 제어 정보 | 인터럽트를 잠시 막는 것도 이 레지스터의 비트로 한다 |
x86에서 지금 실행 중인 코드의 특권 수준은 상태 레지스터가 아니라 코드 세그먼트 레지스터(CS)의 낮은 두 비트에 들어 있습니다. 앞 글에서 본 링 0과 링 3이 여기에 표시됩니다.
운영체제가 실행 중인 작업을 멈추고 다른 작업으로 바꾸는 것을 컨텍스트 스위칭(context switch)이라고 합니다. 이때 운영체제는 멈출 작업이 다시 실행될 때 필요한 레지스터 값과 스택 위치를 저장하고, 다음 작업의 값을 되살립니다. 레지스터가 CPU 안에 하나뿐이니, 여러 작업이 번갈아 쓰려면 저장과 복원이 필요한 것입니다. 이 과정은 3장에서 자세히 다룹니다.
더 깊이: 작업 전환 때 모든 레지스터를 복사하지는 않는다
"컨텍스트 스위칭 비용은 레지스터 개수에 비례한다"고 생각하기 쉽지만, 실제 구현은 그렇게 단순하지 않습니다. Linux x86-64의 진입과 전환 코드(v6.12)를 보면 두 종류의 저장이 나뉘어 있습니다. 사용자 모드에서 커널로 들어올 때는 사용자 레지스터를 커널 스택에 한 번 저장합니다. 커널 안에서 다른 작업으로 바꿀 때는 함수 호출 규약상 보존해야 하는 몇 개의 레지스터와 스택 포인터만 바꿉니다. 나머지는 이미 진입할 때 저장했거나 호출 규약에 따라 호출한 쪽이 저장해 두었기 때문입니다.
부동소수점과 벡터 레지스터처럼 큰 상태는 작업이 사용자 모드로 돌아갈 때까지 복원을 미루기도 합니다. 그리고 컨텍스트 스위칭의 비용에는 레지스터 저장 말고도 주소 공간을 바꾸는 비용, 새 작업의 데이터가 캐시와 TLB에 없어서 생기는 비용이 더해집니다. 그래서 레지스터 개수만으로 컨텍스트 스위칭 비용을 계산할 수는 없습니다.
명령어 사이클과 파이프라인
CPU는 명령어 하나를 다음 네 단계로 처리하며, 이를 명령어 사이클(instruction cycle)이라고 합니다.
- 인출(fetch): 프로그램 카운터가 가리키는 주소에서 명령어를 읽어 옵니다.
- 해석(decode): 어떤 연산인지, 피연산자가 무엇인지 알아냅니다.
- 실행(execute): ALU가 연산하거나 메모리에 접근합니다.
- 기록(write-back): 결과를 레지스터나 메모리에 씁니다.
CPU의 동작은 클록(clock)이라는 일정한 박자에 맞추어 진행됩니다. 3GHz CPU는 1초에 30억 번의 클록 박자를 가집니다. 단계마다 1클록이 걸린다고 하면, 명령어 하나를 처리하는 데 4클록이 걸립니다.
그런데 인출을 맡은 회로는 명령어 1을 인출한 뒤, 명령어 1이 해석되고 실행되는 동안 할 일이 없습니다. 이 회로가 쉬지 않고 명령어 2를 미리 인출하게 하면 어떨까요? 이렇게 여러 명령어의 단계를 겹쳐 처리하는 방식을 파이프라이닝(pipelining)이라고 합니다. 서로 의존하지 않는 명령어 세 개를 처리할 때 각 클록에서 어떤 명령어가 어느 단계에 있는지 따라가 보겠습니다.
| 클록 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| 명령어 1 | 인출 | 해석 | 실행 | 기록 | ||
| 명령어 2 | 인출 | 해석 | 실행 | 기록 | ||
| 명령어 3 | 인출 | 해석 | 실행 | 기록 |
명령어를 하나씩 끝내고 다음 것을 시작하면 4 × 3 = 12클록이 걸리지만, 파이프라인에서는 첫 명령어가 4클록째에 끝난 뒤 매 클록 하나씩 끝나 6클록이면 됩니다. 여기서 주의할 점이 있습니다. 전체 처리량은 늘었지만, 명령어 1이 끝나는 데는 여전히 4클록이 걸립니다. 파이프라인은 명령어 하나를 빠르게 하는 것이 아니라, 같은 시간에 더 많은 명령어를 끝내게 하는 기법입니다.
이 계산은 명령어 사이에 의존이 없고, 메모리를 기다리는 일도 없다고 가정한 것입니다. 명령어 2가 명령어 1의 결과를 써야 한다면, 그 결과가 나올 때까지 명령어 2의 실행을 미루어야 합니다. 조건 분기는 더 까다롭습니다. if 문의 조건이 계산되기 전에는 다음에 실행할 명령어가 어느 쪽인지 알 수 없기 때문입니다. 그래서 CPU는 분기의 결과를 미리 예측해 그쪽 명령어를 먼저 처리하는 분기 예측(branch prediction)을 합니다. 예측이 틀리면 미리 처리하던 명령어를 모두 버리고 맞는 쪽에서 다시 시작해야 하므로, 예측하기 어려운 분기가 많은 코드는 느려질 수 있습니다.
더 깊이: 한 클록에 명령어 여러 개
오늘날의 고성능 CPU는 실행 장치를 여러 개 두고 한 클록에 여러 명령어를 동시에 시작하는 슈퍼스칼라(superscalar) 구조입니다. 또 앞의 명령어가 메모리를 기다리는 동안, 그 결과와 상관없는 뒤의 명령어를 먼저 실행하는 비순차 실행(out-of-order execution)도 합니다. 그래서 "명령어 하나에 몇 클록"이라는 단순한 계산으로 실제 프로그램의 실행 시간을 정확히 예측할 수는 없습니다. 파이프라인과 분기 예측의 자세한 동작은 컴퓨터구조 교재에서 다룹니다.
메모리 계층: 빠를수록 작고 비싸다
저장 장치에는 피할 수 없는 맞바꿈이 있습니다. 빠른 저장 장치일수록 비트당 값이 비싸고 용량을 키우기 어렵습니다. 그래서 컴퓨터는 빠르고 작은 저장 장치부터 느리고 큰 저장 장치까지 여러 층을 쌓아 쓰며, 이를 메모리 계층(memory hierarchy)이라고 합니다. 아래 표의 숫자는 층 사이의 규모 차이를 보여 주기 위한 대략적인 값이며, 실제 값은 제품과 접근 방식에 따라 달라집니다.
| 계층 | 접근 시간(대략) | 용량(대략) | 특징 |
|---|---|---|---|
| 레지스터 | 1ns 미만 | 수백 바이트 | CPU 코어 안 |
| L1 캐시 | 약 1ns | 코어마다 수십 KB | 코어 전용 |
| L2 캐시 | 3–10ns | 수백 KB에서 수 MB | 코어 전용이거나 몇 코어가 공유 |
| L3 캐시 | 10–40ns | 수 MB에서 수백 MB | 여러 코어가 공유 |
| 메인 메모리(DRAM) | 50–100ns | 수 GB에서 수 TB | 전원이 꺼지면 내용이 사라진다 |
| SSD | 수십 μs | 수백 GB에서 수 TB | 전원이 꺼져도 유지, 블록 단위 접근 |
| HDD | 5–10ms | 수 TB | 기계 장치가 움직여야 한다 |
레지스터와 HDD의 접근 시간은 수천만 배 차이가 납니다. 사람의 시간으로 바꾸면, 레지스터에서 값을 꺼내는 데 1초가 걸린다고 할 때 HDD에서 읽는 데는 수백 일이 걸리는 셈입니다. 그래서 운영체제와 하드웨어는 자주 쓰는 데이터를 위쪽 층에 두려고 많은 노력을 합니다. CPU 캐시는 하드웨어가 관리하고, 메인 메모리와 디스크 사이의 계층은 9장의 가상 메모리와 10장의 페이지 캐시가 운영체제 차원에서 관리합니다.
캐시는 왜 효과가 있을까: 지역성
캐시(cache)는 메인 메모리의 일부를 CPU 가까이에 복사해 두는 작고 빠른 메모리입니다. CPU가 메모리의 값을 읽으면, 하드웨어가 그 값을 포함한 캐시 라인(cache line)을 통째로 캐시에 가져옵니다. 캐시 라인은 캐시가 메모리와 주고받는 단위로, 오늘날의 x86과 많은 ARM 프로세서에서 64바이트입니다. 다음에 같은 주소나 같은 라인 안의 주소를 읽으면 느린 메모리 대신 캐시에서 바로 읽습니다. 이를 캐시 적중(cache hit)이라고 하고, 캐시에 없어서 메모리까지 가야 하는 경우를 캐시 미스(cache miss)라고 합니다.
캐시가 작은데도 효과가 큰 이유는 프로그램의 메모리 접근에 지역성(locality)이 있기 때문입니다. 시간 지역성(temporal locality)은 최근에 쓴 데이터를 곧 다시 쓸 가능성이 높다는 성질입니다. 반복문 안에서 같은 변수를 계속 쓰면, 처음 한 번만 메모리에서 가져오고 그 뒤로는 캐시에서 읽을 수 있습니다. 공간 지역성(spatial locality)은 방금 쓴 데이터 근처의 데이터를 곧 쓸 가능성이 높다는 성질입니다. 배열을 앞에서부터 차례로 읽으면 첫 원소를 읽을 때 같은 캐시 라인의 다음 원소들도 함께 올라오므로, 뒤의 원소들은 캐시에서 읽게 됩니다.
같은 개수의 값을 읽더라도 어떤 순서로 읽느냐에 따라 필요한 캐시 라인의 수가 크게 달라집니다. 다음 Python 코드는 4바이트 정수 1,024개를 차례로 읽을 때와 64바이트 간격으로 1,024개를 읽을 때, 거쳐 가는 서로 다른 캐시 라인의 수를 셉니다. 실제 캐시를 측정하는 것이 아니라 주소만으로 라인 수를 계산하는 모델입니다.
LINE = 64 # 캐시 라인 크기(바이트)
def lines_touched(addresses):
return len({addr // LINE for addr in addresses})
sequential = [i * 4 for i in range(1024)] # 4바이트 정수를 차례로
strided = [i * 64 for i in range(1024)] # 64바이트 간격으로
print("차례로 읽기:", lines_touched(sequential), "라인")
print("64바이트 간격:", lines_touched(strided), "라인")차례로 읽기: 64 라인
64바이트 간격: 1024 라인차례로 읽으면 라인 하나에 정수 16개가 들어 있으므로 1,024개를 읽는 데 64개의 라인이면 됩니다. 64바이트 간격으로 읽으면 읽을 때마다 새 라인에 들어가므로 1,024개의 라인이 필요합니다. 라인이 처음에 모두 캐시에 없다면, 앞의 방식은 메모리에서 64번 가져오면 되지만 뒤의 방식은 1,024번 가져와야 합니다. 실제 하드웨어는 다음에 쓸 라인을 미리 가져오는 프리페치(prefetch)도 하므로 실행 시간이 꼭 16배 차이 나지는 않지만, 방향은 같습니다.
배열과 연결 리스트의 차이도 같은 원리로 설명됩니다. 다음 C 코드는 두 자료구조를 순회하는 부분만 떼어 낸 조각입니다. 연결 리스트를 만드는 코드는 생략했으므로 그대로 컴파일되는 프로그램은 아닙니다.
/* 배열: 원소가 메모리에 연속으로 놓인다. */
long sum = 0;
for (int i = 0; i < n; i++)
sum += arr[i]; /* 다음 원소가 같은 캐시 라인에 있을 가능성이 높다. */
/* 연결 리스트: 노드가 메모리 여기저기에 흩어질 수 있다. */
struct node { int val; struct node *next; };
long sum2 = 0;
for (struct node *cur = head; cur != NULL; cur = cur->next)
sum2 += cur->val; /* 다음 노드의 주소는 지금 노드를 읽어야 알 수 있다. */배열은 원소가 연속으로 놓여 있어 공간 지역성이 좋고, 하드웨어가 다음 라인을 미리 가져오기도 쉽습니다. 연결 리스트는 노드가 흩어져 있을 수 있고, 다음 노드의 주소를 지금 노드에서 읽어야 알 수 있으므로 미리 가져오기도 어렵습니다. 그래서 두 순회는 모두 O(n)이지만 실제 실행 시간은 크게 다를 수 있습니다. 다만 그 차이는 노드가 메모리에 어떻게 배치되었는지, 데이터가 캐시에 얼마나 들어가는지에 따라 달라지므로, 실제 차이는 측정해서 확인해야 합니다.
여러 코어의 캐시를 맞추는 일: 캐시 일관성
캐시를 코어마다 따로 두는지, 여러 코어가 함께 쓰는지는 CPU 설계마다 다릅니다. 예를 들어 Arm Cortex-A53은 코어마다 L1 캐시를 두고 L2 캐시는 여러 코어가 함께 씁니다. 어느 설계든 코어마다 전용 캐시가 있으면, 같은 메모리 주소의 사본이 여러 코어의 캐시에 동시에 들어 있을 수 있습니다.
이때 코어 0이 변수 x를 바꾸어 자기 캐시에만 반영하고, 코어 1의 캐시에는 x의 옛 값이 남아 있다면 두 코어가 서로 다른 x를 보게 됩니다. 이를 캐시 일관성(cache coherence) 문제라고 합니다. 하드웨어는 MESI 같은 캐시 일관성 프로토콜로 이 문제를 풉니다. 한 코어가 어떤 캐시 라인에 쓰려면 먼저 다른 코어들의 캐시에 있는 같은 라인의 사본을 무효화(invalidate)하고, 다른 코어가 그 라인을 다시 읽을 때는 새 값을 받아 가게 합니다.
이 프로토콜은 캐시 라인 단위로 동작하기 때문에 뜻밖의 성능 문제를 만들기도 합니다. 두 스레드가 서로 다른 변수를 고치는데 두 변수가 우연히 같은 캐시 라인에 있으면, 한 스레드가 쓸 때마다 다른 코어의 사본이 무효화되어 라인이 두 코어 사이를 계속 오갑니다. 실제로는 데이터를 공유하지 않는데도 공유하는 것처럼 비용을 치르는 이 현상을 거짓 공유(false sharing)라고 합니다. 여러 스레드가 한 프로세스의 메모리를 함께 쓰는 방식은 4장에서 다룹니다.
구성 요소를 잇는 길: 버스와 인터커넥트
버스(bus)는 구성 요소들이 데이터를 주고받는 공유 통로입니다. 여러 장치가 하나의 버스를 함께 쓰면 한 번에 한 장치만 데이터를 보낼 수 있으므로, 누가 쓸지 정하는 중재가 필요합니다. 이 중재는 주로 하드웨어가 하고, 운영체제는 요청의 순서를 정하거나 장치를 설정하는 방식으로 부하에 영향을 줍니다. 전통적인 버스는 쓰임새에 따라 세 가지로 나뉩니다.
| 버스 | 전달하는 것 |
|---|---|
| 주소 버스(address bus) | 읽거나 쓸 메모리나 장치의 위치 |
| 데이터 버스(data bus) | 실제로 오가는 데이터. 폭이 넓을수록 한 번에 많이 보낸다 |
| 제어 버스(control bus) | 읽기와 쓰기의 구분, 인터럽트, 클록 같은 제어 신호 |
주소의 비트 수는 가리킬 수 있는 공간의 크기를 정합니다. 바이트마다 주소를 붙일 때 주소가 32비트면 바이트, 곧 4GiB를 가리킬 수 있습니다. 여기서 프로그램이 쓰는 가상 주소의 폭과 하드웨어의 물리 주소 폭은 서로 다르다는 점에 주의해야 합니다. 64비트 가상 주소로는 이론상 16EiB를 가리킬 수 있지만, 실제 x86-64 프로세서는 그 가운데 48비트나 57비트만 씁니다. 물리 주소의 폭은 이와 따로 정해지는데, Linux 커널 문서에 따르면 4단계 페이지 테이블을 쓰던 x86-64는 가상 주소 공간 256TiB(48비트), 물리 주소 공간 64TiB(46비트)로 제한되었습니다. 이 차이는 9장의 페이지 테이블에서 다시 만납니다.
오늘날의 컴퓨터는 모든 부품이 버스 하나를 나누어 쓰지 않고, 두 지점을 직접 잇는 점대점(point-to-point) 연결을 주로 씁니다. 메모리 컨트롤러가 CPU 안에 들어와 CPU와 메모리가 전용 채널로 연결되고, 여러 CPU 소켓이나 칩 조각 사이는 Intel의 UPI(앞 세대는 QPI)나 AMD의 Infinity Fabric 같은 인터커넥트로 연결됩니다. 전용 연결에도 대역폭의 한계는 있지만, 하나의 버스를 놓고 다투던 병목은 크게 줄었습니다.
입출력 장치는 주로 PCIe(Peripheral Component Interconnect Express)로 연결됩니다. PCIe는 레인(lane)이라는 송수신 통로를 여러 개 묶어 쓰며, 레인 수가 많을수록 대역폭이 커집니다. 그래픽 카드는 보통 16레인(x16), NVMe SSD는 보통 4레인(x4)을 쓰고, 네트워크 카드는 제품에 따라 다릅니다.
장치 컨트롤러와 드라이버
CPU가 디스크의 모터나 네트워크 카드의 신호를 직접 다루지는 않습니다. 각 장치에는 장치 컨트롤러(device controller)라는 전용 회로가 있고, 컨트롤러에는 명령, 상태, 데이터를 주고받는 레지스터가 있습니다. 예를 들어 디스크에서 데이터를 읽을 때 CPU는 컨트롤러의 명령 레지스터에 "이 위치의 블록을 읽어라"라고 쓰고, 실제로 디스크를 움직여 데이터를 가져오는 일은 컨트롤러가 합니다. 데이터가 준비되면 컨트롤러가 CPU에 인터럽트를 보내거나, CPU가 상태 레지스터를 확인해 알아냅니다. 이 두 방법은 다음 글에서 자세히 다룹니다.
장치 컨트롤러를 다루는 커널 코드를 장치 드라이버(device driver)라고 합니다. 드라이버는 특정 하드웨어의 레지스터 배치와 명령 순서를 알고 있고, 커널의 나머지 부분에는 표준화된 인터페이스를 제공합니다. 그래서 파일 시스템 코드는 "이 블록을 읽어라"라는 일반적인 요청만 하면 되고, 그 장치가 SATA SSD인지, NVMe SSD인지, USB 외장 디스크인지는 드라이버가 처리합니다.
Linux에서는 몇 가지 명령으로 하드웨어 구성을 확인할 수 있습니다. lspci는 PCI 장치 목록을, lsblk는 디스크 같은 블록 장치 목록을 보여 줍니다. dmesg는 커널이 부팅하면서 하드웨어를 찾고 드라이버를 적재한 기록을 보여 주므로, 장치가 인식되지 않을 때 먼저 확인해 볼 만합니다. 배포판에 따라 dmesg를 읽으려면 root 권한이 필요할 수 있습니다.
정리
- 폰 노이만 구조는 명령어와 데이터를 같은 메모리에 두어 프로그램을 바꾸기 쉽게 했지만, CPU가 메모리를 기다리는 폰 노이만 병목을 낳았습니다.
- CPU는 ALU, 제어 장치, 레지스터로 이루어지며, 운영체제는 작업을 바꿀 때 프로그램 카운터와 스택 포인터 같은 상태를 저장하고 되살립니다.
- 파이프라이닝은 명령어의 단계를 겹쳐 처리량을 높이지만, 명령어 하나의 지연을 줄이지는 않습니다. 의존과 분기 예측 실패는 파이프라인을 멈추게 합니다.
- 메모리 계층은 빠르고 작은 저장 장치와 느리고 큰 저장 장치를 층으로 쌓은 것이며, 캐시는 지역성 덕분에 효과를 냅니다.
- 코어마다 캐시가 있으면 일관성 프로토콜이 사본을 맞추며, 캐시 라인 단위로 동작하기 때문에 거짓 공유가 생길 수 있습니다.
- 장치는 컨트롤러를 통해 제어되고, 커널의 장치 드라이버가 장치마다 다른 세부를 감추고 표준 인터페이스를 제공합니다.
연습 문제
1. 명령어 다섯 개의 파이프라인
인출, 해석, 실행, 기록의 네 단계가 각각 1클록이고 서로 의존하지 않는 명령어 다섯 개를 실행합니다. 하나씩 차례로 실행할 때와 파이프라인으로 실행할 때 걸리는 클록 수를 각각 구하고, 첫 명령어가 끝나는 시점도 비교하세요.
해설 보기
하나씩 실행하면 명령어마다 4클록이므로 4 × 5 = 20클록입니다. 파이프라인에서는 첫 명령어가 4클록째에 끝나고, 그 뒤 매 클록 하나씩 끝나므로 나머지 네 개가 5, 6, 7, 8클록째에 끝나 모두 8클록이 걸립니다. 전체 시간은 20클록에서 8클록으로 2.5배 줄었지만, 첫 명령어가 끝나는 시점은 두 방식 모두 4클록째입니다. 파이프라인은 처리량을 늘릴 뿐 개별 명령어의 지연을 줄이지 않는다는 것을 확인할 수 있습니다. 명령어 사이에 의존이 있거나 분기 예측이 틀리면 빈 단계가 생겨 8클록보다 더 걸립니다.
2. 시작 위치가 어긋난 정수 16개
캐시 라인이 64바이트이고 4바이트 정수 16개를 차례로 읽습니다. 첫 정수가 라인의 시작에 놓였을 때와, 라인 시작에서 4바이트 뒤에 놓였을 때 각각 몇 개의 캐시 라인을 거치는지 구하세요. 앞의 Python 함수로 확인해 보아도 좋습니다.
해설 보기
라인의 시작에 놓이면 16개 × 4바이트 = 64바이트가 정확히 라인 하나를 채우므로 1개의 라인이면 됩니다. 4바이트 뒤에서 시작하면 첫 라인에는 오프셋 4부터 63까지 정수 15개가 들어가고, 마지막 정수는 다음 라인의 오프셋 0에 놓이므로 2개의 라인을 거칩니다. 앞의 함수로는 lines_touched([4 + i * 4 for i in range(16)])가 2를 돌려줍니다. 읽는 값의 개수가 같아도 메모리에 놓인 위치에 따라 필요한 라인 수가 달라지며, 그래서 성능에 민감한 코드는 자료를 캐시 라인에 맞추어 정렬하기도 합니다.
3. 따로 쓰는데도 느린 두 카운터
두 스레드가 각자 자기 카운터만 1억 번씩 증가시킵니다. 두 카운터는 long counters[2]라는 배열의 두 원소입니다. 두 스레드가 서로의 데이터를 전혀 읽지 않는데도 각 스레드가 혼자 실행될 때보다 크게 느려질 수 있는 이유를 설명하고, 해결 방법을 제안하세요.
해설 보기
long이 8바이트라면 두 원소는 연속한 16바이트 안에 있으므로 같은 64바이트 캐시 라인에 들어갈 가능성이 높습니다. 캐시 일관성 프로토콜은 라인 단위로 동작하므로, 스레드 A가 counters[0]에 쓰면 다른 코어에 있는 그 라인의 사본이 무효화되고, 스레드 B가 counters[1]에 쓰려면 라인을 다시 가져와야 합니다. 이 일이 쓰기마다 되풀이되며 라인이 두 코어 사이를 오가는 것이 거짓 공유입니다. 해결하려면 두 카운터를 서로 다른 캐시 라인에 두면 됩니다. 각 카운터 뒤에 빈 공간을 채워 64바이트 간격을 두거나, 정렬 지정자로 카운터마다 라인 하나를 차지하게 하거나, 스레드마다 지역 변수에 세었다가 마지막에 한 번만 합칠 수 있습니다.
다음 글 인터럽트와 I/O에서는 장치가 일을 마쳤을 때 CPU에 어떻게 알리는지, 그리고 데이터가 장치와 메모리 사이를 어떤 방식으로 오가는지 살펴봅니다.