CPU/구조와 원리

 


1. 명령 주기
2. 설명
2.2. CPU 아키텍처

[image]

1. 명령 주기


[image]
간단화 한 CPU Instruction Cycle(명령 주기)
[image]
예외처리를 추가한 간단한 CPU Instruction Cycle(명령주기)
기본 구성으로는 레지스터[1], 프로그램 카운터[2], 명령어 레지스터[3], 산술논리연산장치(ALU: arithmetic logic unit)[4], 제어부[5]와 내부 버스 등이 있다. 그 외에도 캐시 메모리 같은 부가 장치도 들어가 있는 경우가 대다수.

2. 설명


거의 모든 종류의 CPU가 하는 일은 요약해보면 대부분 아래 4기능이 전부다.
  • Fetch(인출) : 메모리상의 프로그램 카운터가 가리키는 명령어를 CPU로 인출하여 적재.
  • Decode(해석) : 명령어의 해석. 이 단계에서 명령어의 종류와 타겟 등을 판단한다.
  • Execute(실행) : 해석된 명령어에 따라 데이터에 대한 연산을 수행한다.
  • Writeback(쓰기) : 명령어대로 처리 완료된 데이터를 메모리에 기록한다.

위의 CPU는 아주 초급적인 교과서적인 설명에 기반하며, 어떤 상용 CPU도 저런 구조를 가지지 않는다. 그나마 명령어의 실행 순서를 섞을 수 없는 Inorder CPU[6]를 엄청나게 단순화하면 저런 설명이 가능하다고 우길 수도 있으려나? 여튼, 명령어의 실행 순서를 섞을 수 있는 현대의 Out-of-order CPU 들은 아래와 같은 순서를 거쳐 명령어를 실행한다.
  • Fetch: 실행할 명령어들을 가져온다. 이후 단계들도 그렇지만, 한 번에 보통 4개 정도를 처리하는데, 슈퍼스칼라라고 불리는 기술은 이렇게 한 사이클에 여러 명령어를 처리하는 것을 말한다.
  • Decode: 이후 처리를 돕기 위해 명령어의 종류를 구분한다. Intel의 x86 ISA같이 복잡한 명령어를 쓰는 CISC의 경우, 내부적으로 RISC 명령어들로 쪼개지는 과정[7]이 여기서 수행된다. 옛날 프로그램을 짜기 어려웠던 이유는 바로 이 디코드 동작 시 불러오는 CPU 명령함수들이 CPU마다 전부 다 다르다는 것 때문이었다. 현재는 몇 개 회사가 CPU 공급을 독점하면서 그나마 단순화되었다. (같은 회사는 추가 기능이 없다면 같은 명령함수를 쓰는 게 일반적)
  • Rename: 명령어가 가리키는 레지스터(CPU에서 값을 저장하는 x86 ISA의 eax나 ebx 등의 이름있는 공간들)를, 내부에 숨어있는 물리적 레지스터로 매핑한다. 이러한 과정은 Out-of-order CPU에서 발생하는 False-dependency[8] 문제를 해결하기 위해 필수적이다.
  • Dispatch: 명령어가 실행하기 위해 기다리는 대기열 (ROB[9], IQ[10], LSQ[11])에 명령어를 넣는다.
  • Issue: 대기열에 있는 명령어가 실행될 수 있으면[12] 실행하기 위한 장치(가령 계산 명령어는 ALU, 메모리 명령어는 Cache)로 보낸다. 참고로 프로그램에서 시간상 뒤의 명령어가 앞의 명령어보다 먼저 Issue될 수 있다. 이것이 바로 Out-of-order CPU의 핵심 동작 중 하나다.
  • Execute: 실행한다
  • Writeback: 결과값을 레지스터에 써야 한다면 쓴다. 결과값을 기다리고 있던 명령어가 있다면 결과가 생겼다고 알려준다.
  • Commit: 명령어 수행을 완료하고, 명령어 실행을 위해 할당받은 자원을 모두 토해낸다. 명령어의 실행 결과를 사용자에게 노출시키며 (이거 전에는 노출이 안 된다), 이후로는 명령어의 실행을 취소[13] 할 수 없다.
그리고 Out-of-order CPU를 만들기 위해선, 위의 명령어 처리 과정 외에도 몇몇 핵심 기술들이 요구된다.
  • Cache (캐시 메모리): 주 메모리 값을 임시 저장하는 작고 빠른 메모리. 주 메모리 접근은 CPU입장에서 100사이클 정도 놀게 만드는 엄청나게 느린 동작이므로 Cache가 없으면 명령어를 빠르게 실행하고 싶어도 값이 준비가 안 돼서 불가능하다. 캐시 메모리도 한 개만 존재하는 것이 아니라, 보통 중요도와 접근 빈도에 따라 L1~L3의 3개 캐시 메모리를 사용하며, 숫자가 작을수록 용량이 더 작고, 더 빠르다.
  • Branch prediction(분기예측): Fetch를 매 사이클마다 하기 위해선 다음에 실행할 명령어 주소를 알아야 하는데, branch 명령의 결과가 나오려면 꽤나 시간이 걸린다 (게다가 프로그램마다 차이는 있지만, 명령어의 약 30% 정도는 branch이다.) 그러니 branch 명령의 결과를 과거의 기록을 기반으로 예측하는 기술이 필요하다.
  • Speculative memory disambiguation: 메모리 접근 명령어들은 메모리 접근 전에 접근할 메모리 주소를 먼저 구해야 한다. 이 때문에 만약 LSQ에 Store 명령이 접근할 메모리 주소를 계산하기 위해 대기중이면, 이후 모든 Load 명령어는 접근할 메모리 주소가 준비되었다 하더라도 메모리 접근을 할 수 없다. 혹시라도 그 Store가 접근하는 주소가 Load가 접근하는 주소랑 같으면 가장 신선한 값은 메모리가 아니라 그 Store 명령어가 가지고 있기 때문이다.[14] 하지만 할 수 없는 걸 그냥 해버리면? (즉, Speculative memory disambiguation) 대부분의 경우 접근하는 메모리 주소는 다르기 때문에 문제가 없으며, 문제가 있더라도 그 Store가 접근하는 메모리 주소를 계산하기 전까진 Load들을 Commit을 하지 않는 것으로 실행 취소가 가능하다.
이 과정에서 보안 취약점이 나오기도 하는데, 다름 아닌 '''멜트다운·스펙터 취약점'''이다.
참고로 여기서 멀티코어 프로세서로의 확장은 파이프라인 구조 측면에서 별 변화가 없다. 다만 메모리 주소 공간을 공유하기 때문에 캐시간의 동기화가 필요하고, 이를 위한 cache coherence protocol의 도입이 전체 구조의 차이를 만든다.

2.1. CPU 마이크로아키텍처


  1. 명령어 (instruction)
CPU의 동작은 할 일을 적어놓은 명령어가 수행되면서 동작한다. 3개의 명령어를 순차적으로 예를 들어보면, '(1) x라는 값을 읽고, (2) x에 1을 더하고, (3) x를 y에 저장해라' 같은 것들이다.
  1. 파이프라인(Pipeline)
파이프라인은 CPU가 하나의 명령어를 처리하는 과정도 너무 복잡하고 많기 때문에, 이를 잘게 쪼개서 여러 가지 작은 단계로 나누어 처리하는 방식이다. 파이프라인 단계는 여러 가지 나누는 방법이 있지만 현대의 Out-of-Order CPU는 상술한 8단계를 기반으로 나누고 있다. 주목할 점은 완료를 하기 전에는 아직 사용자에게 명령어 수행의 결과가 노출되지 않았다는 것이다. 모종의 이유로 어떤 명령어 수행을 취소해야 하면, 가장 오래된 잘못 수행된 명령어부터 시작해서 이후 모든 명령어를 파이프라인에서 지워버리게 된다.!! 결과론적으로 버릴 명령어 수행에 에너지를 허비했기 때문에 이런 상황은 애초에 피하는 게 좋지만, 그럼에도 피할 수 없는 이유는 아래 추측 실행(Speculative execution)에 설명한다.
  1. Cache (캐시)
주메모리에서 값을 읽는 동작은 CPU의 명령어 처리 속도에 비하면 한참 느리다. 따라서 이 갭을 줄이기 위해 매우 빠르지만 작은 저장 공간이 CPU에 있는데, 이를 캐시라고 한다. 캐시는 공간이 작기 때문에 새로운 값을 읽어들이려면 기존 값을 다시 원래 위치로 돌려보내야 하는데, 주로 가장 오래된 값을 돌려보내는 방식을 취한다. 이는 최근에 접근한 값을 또 접근할 가능성이 높은 프로그램의 성질을 이용한 것이다.(참조지역성. Principle of locality) 또 한 값을 읽으면 그 근처의 값을 읽을 확률이 높은 점을 활용하기 위해, 어떤 값을 캐시로 가져올 때 주변의 값도 같이 가져온다. (역시 참조지역성의 원리) 이렇게 캐시에 값이 들어오는 덩어리를 캐시 라인이라고 하며, 보통 64바이트의 크기를 가진다. 구체적으로 메모리 주소를 64로 나눴을 때 몫이 같은 데이터들은 한 캐시 라인을 구성하게 된다.
  1. 분기 예측(Branch prediction)
분기(Branch) 명령어는 어떤 조건이 맞으면 다음에 실행할 명령어의 위치를 임의로 지정할 수 있게 해준다. 이는 같은 명령어들을 반복해서 실행하거나 조건에 따라 다른 일을 하고 싶을 때 사용하는 매우 기본적인 명령어다. 다만 분기(Branch) 명령어는 파이프라인에서 한가지 성능상 문제를 일으키는데, (1) 분기(Branch)가 가리키는 주소로 이동할지 말지', 그리고 (2) 이동하는 주소가 어디인지 알아내는 것은 해독(Decode)이나 실행(Execute) 단계에 와서야 가능하다는 것이다. 다음 명령어 위치를 마냥 기다리자니 파이프라인의 호출(Fetch) 단계가 잠시 놀게 된다. 이를 막고자 다음 명령어 위치를 예측하는 기법을 사용하는데 이것이 분기 예측(Branch prediction) 이다. 당연히 가리키는 주소로 갈지 말지의 방향 예측과, 가리키는 주소 자체의 예측 두 가지가 함께 이루어진다.
  1. 비순차적 명령어 처리(Out-of-Order Execution, OoOE)
비순차적 명령어 처리(OoOE)는 파이프라인의 송출(Issue) → 실행(Execute) → 회신(Writeback) 단계에 한해서 늦게 온 명령어가 일찍 온 명령어를 새치기할 수 있는 기술이다. 앞에 온 명령어의 처리가 수행될 수 없지만 뒤에 온 명령어는 수행 가능한 경우가 나올 수 있는데, 그러면 CPU를 놀게 하기보다 뒤에 나온 명령어를 먼저 처리하자는 거다. 물론 아무 때나 막 할 수는 없고 이렇게 순서를 바꿔도 사용자가 보는 값이 비순차적 명령어 처리(OoOE)를 하지 않았을 때와 같을 경우만 할 수 있다. 이러한 까다로운 조건 체크 때문에 비순차적 명령어 처리(OoOE)를 달면 속도야 많이 빨라지지만 칩이 커지고 전기도 훨씬 더 먹게 된다. 그래서 초창기엔 휴대기기용 CPU엔 비순차적 명령어 처리(OoOE)가 없었다가 최근에야 최적화를 거치고 사용되기 시작했다.
  1. 추측 실행(Speculative execution)
파이프라인 단계에서 이미 설명했듯이, 완료(Commit) 단계 전에는 아직 명령어 수행을 취소할 수 있다. 그러므로, 어떤 명령어가 특정 파이프라인 단계에 필요한 정보가 없어서 진행이 막혔을 때, 필요한 정보를 예측해서 높은 확률로 맞힌다면 틀렸을 때의 다소 큰 손해를 넘어서는 이익을 취할 수 있다. 고성능의 CPU는 이러한 예측에 기반한 갖가지 기술들을 적극 활용하고 있다.
가령 명령어들을 반복 실행하는 소위 반복문이라는 게 있다고 하자. 이 반복문의 맨 마지막 명령어는 종결 조건이 맞지 않는 동안은 반복문의 맨 처음 명령어로 점프하도록 만들어져 있는데, 결과적으로 마지막 반복 말고는 항상 맨 처음 명령어로 점프하기에 높은 확률로 점프를 한다는 예측이 맞게 된다. 이렇게 예측을 하면 굳이 마지막 명령어를 실행(Execute)할 필요도 없이 다음 명령어를 호출(Fetch)할 수 있고, 기다리는 시간을 줄여서 성능을 대폭 상승시킨다. 다만 맨 마지막 반복에서조차도 마지막 명령어는 반복을 더 하려고 예측이 틀리게 된다. 예측이 틀린지 아는 시점은 명령어의 해독(Decode) 혹은 실행(Execute) 단계 수행 직후이며, 틀렸다면 틀린 명령어의 완료(Commit) 단계 때 자기 자신과 이후 호출(Fetch)된 모든 명령어를 파이프라인에서 지우게 된다.

2.2. CPU 아키텍처


  1. 커널 / 유저 프로세스: 컴퓨터를 켰을 때 돌아가는 운영체제를 또 다른 말로 커널이라고 하며, 커널 이외에 돌아가는 프로그램 하나 하나는 유저 프로세스라 한다.
  2. 보호 링(protection ring): 운영체제는 컴퓨터의 모든 값을 읽고 쓰고 할 수 있다. 그런데 운영체제에서 돌아가는 유저 프로세스들도 마찬가지면 무슨 일이 벌어질까? 유저 프로세스에 버그가 있거나 악성 코드가 있을 경우 컴퓨터 내의 아무 값이나 읽고 쓸 수 있는 심각한 문제가 발생한다. 따라서 운영체제는 CPU에서 제공된 보호 링이라는 것을 써서 (1) 커널 수준과 (2) 유저 수준을 나누게 된다.
결과적으로 CPU가 유저 프로세스를 실행하는 동안은 유저 수준에 있게 되며, 유저 프로세스가 커널에 적법한 방법을 통해(syscall 혹은 systemcall 이라 한다) 도움을 요청(가령 파일 읽고 쓰기는 커널의 도움으로 이뤄진다)하고, CPU는 커널 수준으로 변환하고 커널 코드를 실행하게 된다.
  1. 가상메모리(virtual memory) / 페이지 테이블(page table): 모든 유저 프로세스들이 주메모리의 공간들을 직접 할당 받는다면 어떤 일이 벌어질까? 컴퓨터 환경에 따라서 유저 프로세스가 가정해야 하는 메모리의 주소와 사용 가능한 용량이 자꾸 바뀌게 될 것이다. 프로그래머 입장에서 이러한 현상은 큰 골칫덩어리이며, 따라서 유저 프로세스 각각은 처음 시작할 때 텅 비어있는 똑같은 크기의 가상 메모리를 받게 된다.
가상 메모리의 접근은 결국에는 실제 메모리의 접근으로 이어지는데, 각 유저 프로세스의 가상 메모리 주소로부터 실제 메모리 주소로의 매핑을 저장한 데이터 구조를 페이지 테이블이라고 하며, 각 유저 프로세스마다 독립된 페이지 테이블이 존재하게 된다.
  1. 콘텍스트 스위칭(context switching): 컴퓨터에는 수많은 프로세스들이 동시에 돌고 있다. 그러나 CPU는 논리적으로 한 코어(굳이 논리라고 하는 것은 SMT 기술 때문)에 한 프로세스만 돌리고 있을 수 있다. 그래서 CPU는 한 프로세스를 어느 정도 돌리다가 다른 프로세스로 전환해서 돌리려고 하는데, 이를 콘텍스트 스위칭이라고 한다. 유저 프로세스가 바뀌면 가상 메모리 공간도 바뀌어야 하므로, 페이지 테이블도 유저 프로세스에 맞게 같이 바뀌게 된다. 사실 커널도 하나의 프로세스와 비슷한 거라서 유저 수준에서 커널 수준으로 갈 때도 콘텍스트 스위칭이 일어나게 된다.
  2. 페이지 테이블 항목(page table entry)의 보호 비트(protection bit): 최신의 커널에는 재미있는 최적화가 있는데, 유저 프로세스의 가상 메모리의 특정 주소는 커널 데이터를 담고 있다는 것이다. 이러면 유저 프로세스가 syscall을 호출해서 커널의 도움을 받으러 갈 때 페이지 테이블을 커널 것으로 교체할 필요가 없다. 그래서 파일을 읽고 쓰거나 네트워크 송수신이 더 빠르게 처리될 수 있다. 그런데 이렇게 되면 유저 프로세스가 커널 데이터를 마구 읽을 수 있지 않을까? 이를 위해 페이지 테이블은 실제 메모리 주소와 함께 보호 비트란 것도 달고 있다. 보호 비트는 여러 가지가 있지만 여기서 중요한 것은 이 실제 메모리 주소는 커널 수준만 읽을 수 있다같은 표식이다. CPU에서 유저 수준에서 유저 프로세스를 돌리다가 메모리 값을 읽는 명령어를 처리하려는데, 이 표식을 발견하면 해당 명령어는 규칙을 위반한 것이므로 실제로는 실행되지 않는다.

[1] Register(REG or R) : 연산유닛과 연결된 액세스속도가 가장 빠른 기억장치[2] Program Counter(PC) : 다음에 인출할 명령어의 주소를 가지고 있는 레지스터, 각 명령어가 인출된 후에는 명령어 길이만큼 주소를 증가시킴으로써 주소를 포인팅함. 분기(jump) 명령어가 실행되는 경우에는 목적지 주소로 갱신한다[3] Instruction Register(IR) : 현재 실행 중인 명령의 내용을 기억하고 있는 레지스터[4] 각종 산술연산(덧셈, 뺄셈, 곱셈, 나눗셈)과 논리연산(AND, OR, NOT, XOR 등)들을 수행하는 회로들로 이루어진 회로[5] Control Unit : 명령어를 해석하고, 그것을 실행하기 위한 제어 신호들(control signals)을 순차적으로 발생하는 회로[6] 많은 저전력 CPU가 이러한 구조이다. 가령 예전의 Atom CPU가 그러하다. [7] 가령 movl %eax,16(%esp) 같은 더러운 명령어는 레지스터 읽기, 쓰기, 메모리 접근의 세 가지 동작으로 쪼개질 것이다.[8] True dependency인 read-after-write와 다르게, 실행 순서를 섞는 바람에 생긴 원래는 발생할리 없던 문제들이다. 구체적으로 write-after-read나 write-after-write가 있으며, 가령 전자는 앞선 명령어가 읽을 값을 뒤따르는 명령어가 쓰기 동작으로 덮어쓰는 상황을 일컫는다.[9] ReOrder Buffer. 명령어들의 프로그램에서의 원래 순서를 기억하는 장소. [10] Issue Queue. 명령어가 실행될 수 있을 때까지 기다리는 장소. [11] Load-Store Queue. 메모리 명령들이 처리되는동안 들어가있는 장소. 꽤나 복잡하기 때문에 자세한 설명은 아래 Speculative memory disambiguation에서 설명. [12] 다음 사이클에 내가 필요한 값이 준비되며, 실행을 위한 장치를 쓸 수 있을 때[13] branch prediction등의 틀릴 수 있는 동작은 틀렸을 때 취소할 수 있어야 한다.[14] 접근할 메모리 주소가 나올 때까지 대기하고, 또 주소들을 비교하는 일련의 동작들을 위해 LSQ가 필요한 것이다. LSQ는 이게 어떤 것이라고 정의를 설명하는 것보다, 용례로 설명하는 것이 편하다.