프로세서 기본 동작

Jin Hur·2021년 8월 20일
0

reference: "프로그래머가 몰랐던 멀티코어 CPU 이야기" / 김민장, "Computer System A Programmers'Perspective" / 랜달 E.브라이언트

프로세서와 프로그래머 사이의 인터페이스를 결정짓는 명령어 집합 구조.
각종 기본 부품: ALU, 레지스터, CU, 캐시

명령어 처리의 기본적인 다섯 단계

마이크로프로세서는 (기계)명령어를 하나하나 처리하므로 명령어 집합 프로세서(Instruction Set Processor)라 부름. 프로세서가 하는 일이라곤 그저 프로그램에 있는 명령어의 흐름을 지시대로 처리해 컴퓨터 구조적 상태(architectural state == 레지스터 값)만 약속대로 반영하는 것이다.

미시적으로 보다면 명령어 하나를 처리해 그에 맞는 레지스터 및 메모리 상태를 변화시키는 것이 프로세서의 의무. 이 미시적인 변화가 모니터에 표현되는 아름다운 그래픽, 하드디스크의 물리적인 파일의 변화, 혹은 거대한 기계를 움직이는 거시적인 현상으로 일어남.

기본적으로 처리할 명령어 특성
1. ALU 연산: 사칙연산, AND, XOR 같은 비트 논리 연산
2. 메모리 로드(load): 피연산자에 기록된 메모리 주소를 구해 그 내용을 읽어 지정된 레지스터에 값을 씀.
3. 메모리 스토어(store): 메모리 로드와 반대로 주어진 레지스터의 내용을 지정된 메모리 주소에 씀.
4. 분기문: 주어진 조건을 계산해 (ex, comp: 두 피연산자를 뺀 후 FLAGS 비트로 조건 판단) 다음에 수행될 PC를 얻는다.

명령어 종류마다 구현하는 로직이 다를 수 있지만, 분명한 것은 이들 명령 사이에는 중복되는 작업 단계가 있다. 따라서 명령어 처리 작업을 최대한 공통적인 세부 단계로 나누어 구현하는 것이 합리적이다. 본 글은 단순한 명령어 집합을 다루는 프로세서를 대상으로, 다섯 단계로 나누어 명령어가 처리된다고 가정.

명령어 처리 과정
1. 명령어 인출(Instruction Fetch, IF): 처리할 명렁어를 메모리에서 읽음
2. 명령어 해독(Instruction Decoding, ID): 어떤 연산을 할 것인지 알아냄
3. 피연산자 인출(Operand(s) Fetch, OF): 연산에 필요한 피연산자를 레지스터 파일이나 메모리에서 읽음
4. 명령어 실행(Instruction Execution, EX): 실제 계산 수행
5. 결과 저장(Operand Store, OS 또는 Write Back, WB로도 씀.): 최종 결과를 저장

source: https://www.researchgate.net/figure/Replication-of-resources-of-the-nMPRA-architecture-PC-program-counter-IF-ID-Instruction_fig1_314158317


1. 명령어 인출(IF)

처리할 명령어는 PC 혹은 IP 레지스터가 가리키는 주소에 있다. 이 주소에 있는 데이터(기계명령어)를 읽는 것으로 한 명령어는 본격적으로 실행되기 시작.
거의 모든 프로세서는 명령어 캐시(I-cache)를 두어 명령어 인출 레이턴시를 줄인다. 명령어 캐시에 PC의 내용이 있다면 명령어 인출이 빠른 시간 내에 완료될 것이고, 그렇지 않다면 L2 캐시 혹은 L3 캐시에 물어봐야한다. 최악의 경우는 메인 메모리에 접근해야 한다.
명령어 인출을 명령어 캐시에서 현재 PC가 가리키는 곳의 내용, 즉 아직 디코딩 전인 raw 바이트 데이터 뭉치(기계명령어)를 읽어와 바이트 큐에 삽입한다.
명령어 인출 단계는 이런 바이트의 흐름만 큐에 넣어주면 된다. 나머지 명령어 디코딩이나 실행은 다음 단계에서 진행.

cf) 만약 모든 명령어가 4바이트인 간단한 RISC 구조라면 'PC += 4'로 UpdatePC() 함수를 만들 수 있다. 그러나 x86 같은 가변 길이 명령어 구조는 현재 명령어가 몇 바이트인지 알아내야 하므로 복잡. 또한 분기문도 고려해야한다. 분기문 고려 또한 상당히 복잡하다.(story06. 명령어 인출 참고)

데이터뿐 아니라 코드 역시 모두 가상 주소 기반이므로 PC에 저장된 주소 값도 가상 주소이다. 따라서 이 PC 값에 해당하는 데이터를 읽어오려면 반드시 가상 주소를 실제 물리 주소로 바꾸어야 한다. 이 작업을 보통 MMU(Memory Management Unit, 주소 맵핑 담당)라는 장치가 담당.
가상 주소 변환은 가상 주소의 상위 비트 중 일부, 즉 가상 페이지 번호(VPN)을 가져와 프로세스의 페이지 테이블에서 대응되는 물리 페이지 번호(PPN)를 찾아야 함. 그런데 VPN 변환이 반복된다면 캐시같은 장치를 활용해 줄일 수 있다. 이와 같은 장치를 TLB(Translation Lookaside Buffer)다. TLB는 가상 주소 변환을 돕는 캐시 장치라 보면 됨. 실제 구성도 캐시와 같다.
L1 캐시가 데이터 캐시와 명령어 캐시로 분리하듯 TLB도 명령어 TLB와 데이터 TLB로 나눈다.

  1. 명령어 TLB에서 읽는 작업을 수행
  2. 명령어 캐시를 읽는 작업을 수행
  3. 바이트 큐(byteQ)를 채우라고 지시
  4. 다음 명령어를 가져올 곳을 계산하여 PC에 넣는다.

2. 명령어 해독(ID)

바이트 큐에 저장된 명령어는 '0x83 0x45 0xF8 0x07'같은 숫자의 흐름이다. 이 기계명령어는 '어떤 메모리 주소에 상수 7을 더한다'라는 내용으로 디코딩된다. 구체적으로 opcode, 피연산자(operand), 계산 결과가 저장될 목적지, 주소 모드(addressing mode) 또는 간단한 분기문이라면 분기 목적지도 읽는다. 마치 파싱(parsing)과 같다. 그러나 고급 언어 컴파일러처럼 복잡한 문법을 풀 필요 없이 간단한 기계적인 규칙으로 분석한다. (복잡한 CISC라 하더라도 그저 좀 더 복잡한 경우의 수만 있을 뿐.)

RISC는 여기서 또 편리함을 선사. 명령어 길이도 일정하고 옵코드 위치도 같아서 해독이 훨씬 수월. 이런 간단함은 바로 하드웨어의 단순함, 그리고 고속화로 이어진다.
최신 x86 프로세서는 CISC 명령어를 RISC 형태의 uops(마이크로 명령어)로 분해한다. 이렇게 하는 것이 프로세서 고속화에 훨씬 유리. 이때 디코더가 uop로 변환을 담당한다.

add dword ptr [A], 0x07			# 바로 메모리 주소를 인자로 받음

uops를 사용하는 이유

현대 프로세서는 이 명령을 '한 번'에, 즉 한 사이클에 처리하기는 어렵다. 비효율적인 이유가 있기 떄문인데 이 명령어에는 덧셈 외에도 메모리를 읽고 쓰는 작업이 더 있다. 이에 비해 메모리를 취하지 않고 간단한 레지스터를 상대로 계산하는 ALU 명령어는 이보다 더 빨리 마칠 수 있다. 메모리를 상대로 하는 연산도 한 사이클에 작동하게 된다면, 더 빨리 마칠 수 있는 연산들(ex, ALU 연산)이 시간을 낭비하게 되고 전체적인 성능 하락으로 이어질 것이다. 따라서 복잡한 CISC의 명령을 RISC 형식으로 나누면 간단한 명령은 빨리 완료되어 명령어 처리 시간(latency)과 대역폭(bandwidth)을 늘릴 수 있다.

load	temp_reg, dword ptr [A]
add     temp_reg, temp_reg, 7
store	dword ptr[A], temp_reg

위의 작업들이 각각 한 싸이클씩 차지하여 실행된다면 프로세서 전반의 명령어 처리 방식에 효율성을 가져다 줄 수 있다.

명령어 인출과 명령어 해독은 실제 계산 장치가 처리할 명령어의 흐름을 만들기 때문에 "프론트 앤드(front end)"라고 부르기도 한다.

고성능 프로세서에는 계산 장치의 효율도 중요하지만, 무엇보다 꾸준히 명령어를 뽑아와 계산 장치에 넣어주는 프론트 앤드의 성능이 일차적인 조건이다.

정리하자면, ID 단계에서는 인출한 바이트의 흐름이 어떤 일을 하는지 분석하고 x86 같은 경우 대응되는 마이크로 명령어(uop)들로 바꾼다.


3. 피연산자 인출(OF)

명령어를 가져오고 해독까지 했으면 실제 연산에 필요한 준비물, 피연산자(operand)를 읽는 일이다. 피연산자는 주소 모드에 따라 (1)레지스터, (2)상수, (3)메모리 주소로 번역된다.

ld	r0,	[sp + 8]	# 메모리주소 계산과 메모리 내용 읽기
add	r1, r0, 10		# 레지스터와 상수 읽기
st 	[sp + 4], r1		# 메모리주소 계산과 레지스터 읽기(메모리 스토어)
jz	ri,	100		# (jump if zero) 레지스터 및 상수읽기(분기문)

상수: 상수 10은 명령어에 인코딩되어 있으므로 명령어 해독 단계에서 읽은 내용을 바로 쓸 수 있다. 상수는 즉시 쓸 수 있는 값이라는 의미로 immediate라 부른다.

레지스터: 레지스터형은 직관적으로 레지스터 파일에서 읽어오면 된다.

레지스터 파일의 설계 이슈는 크게 두가지이다. 레지스터 파일에 한 사이클만에 원하는 데이터를 읽고 쓸 수 있는가? 동시에 몇 개의 읽기와 쓰기가 지원되는가?
레지스터 파일은 개념적으로 레지스터가 모여있는 배열에 입출력이 지원되는 장치로 생각하면 된다.

메모리 주소: (ex, sp + 4: 스택 포인터 sp에서 4만큼 떨어져있는 주소) 피연산자가 메모리 주소를 가리킬 때 작업은 (1) 가져올 메모리의 주소를 계산, (2) 이 데이터를 읽어, (3) 목적지 레지스터에 저장하는 것(mem to reg). IF 단계에서는 (1), (2)만 수행한다. ===========> (3) 단계는?
특히 메모리 주소를 보통 유효 주소(effective address)라는 표현을 자주 쓴다. 따라서 (1) 작업을 유효 주소 계산이라 부른다. 주소 모드는 ISA에 따라 여러 가지 종류가 있다. 유효 주소를 계산하려면 일단 베이스 레지스터를 읽고, 덧셈/뺄셈과 시프트(곱셈) 연산이 필요하다. 대다수 프로세서는 주소 계산에 특화된 주소 생성 장치인 AGU(Address Generation Unit)를 갖고 있다. AGU에서 읽어올 주소를 구하면 이제 메모리에 데이터를 읽어오라고 요청한다. 데이터를 메모리에서 읽어오는 과정은 명령어 인출 단계에서 본 명령어를 메모리에서 읽는 것과 거의 같다.
마찬가지로 데이터가 있는 주소 역시 가상 주소이므로 DTLB에서 주소를 변환하고 L1 데이터 캐시부터 DRAM까지 메모리 계층을 거쳐 데이터를 가져온다.

어떤 문헌에서는 유효 생성 단계를 실행단계로 분류하고, 별도로 메모리 접근(MEM) 단계를 두어 데이터를 읽는 것으로 한다.

x86 명령어에는 lea(Load Effective Address)라는 것이 있다. 말 그대로 유효 주소만 계산해서 지정된 레지스터에 쓰는 명령으로 메모리를 읽는 mov와 다르다.

지금까지 세 가지 종류의 피연산자를 읽는 과정을 ALU 연산과 메모리 로드 연산으로 설명했다. 비슷하게 메모리 스토어와 분기문일 때도 생각해보면, 메모리 스토어는 메모리에 쓸 소스 레지스터 내용을 읽고, (2) 유효 주소를 구하고, (3) 메모리 접근으로 나눌 수 있다. 본 글에서 나눈 명령어 단계 기준으로 본다면 (1)의 내용은 피연산자 인출 단계(OF)에 해당하고, (2)와 (3)은 마지막 결과 저장 단계(Operand Store, OS)에서 이루어질 것이다.
분기 명령의 경우 ALU 연산처럼 보통 비교할 레지스터나 상수를 읽는다.


4. 명령어 실행(EX 또는 EXE)

처리할 명령어의 종류도 알았고, 피연산자도 준비되었다면 이제 지시에 따라 실제 계산을 명령어 실행 단계(EX)에서 수행한다.
명령어 종류에 따라 차이가 있다. 전형적인 두 피연산자를 받아 계산하는 경우, 지정된 연산 타입에 따라 계산만 수행하면 된다.
조건 분기만은 ISA마다 형태가 다소 달라 약간씩 다르게 처리된다. x86은 보통 비교를 담당하는 test와 cmp를 제공한다. 이 연산은 일반 산술 연산처럼 처리(예를 들어 a > b 비교에서 a - b로 산술 연산하여 FLAGS 값 관찰)되고, 그 결과를 플래그(flag) 레지스터가 받는다. 그러면 이제 실제 분기 명령이 플래그 내용을 기반으로 분기할지 아니할지를 결정한다.

메모리 로드 명령어(ld ~)과 스토어 명령어(st ~)는 본 글에서 나눈 실행 단계에서는 아무런 일을 하지 않는다. 그러나 지금 글에서 다루는 다섯 단계가 가상임을 상기할 것. 실제 비순차 슈퍼스케일러 같은 복잡한 프로세서 구현에서는 사칙 연산같은 것들이 연산될 때, 메모리 로드와 스토어도 ALU 명령어처럼 특정 장치(ex, AGU)에서 처리됨.

명령어 실행 단계 구현시 고려해야 할 문제
명령어 실행 단계 구현시 고려해야 할 문제 중 하나는 프로세서 내 ALU 자원이 한정되어 있다는 것이다. 무한한 자원이 있다면 프로세서는 피연산자가 모두 준비된 명령어를 즉각 처리할 수 있다. 그러나 실제 구현은 자언이 제한되어 있어서 그러지 못하다. (예를 들어 인텔의 네할렘(Nehalem) 구조에서는 연산 장치가 10가지 종류의 12개밖에 없음.) 따라서 자원 할당 문제를 고려해야 한다. 규모가 미시적이지만 운영체제에서 볼 수 있는 스케줄링과 비슷하게 준비된 명령어는 일종의 관리자에게 필요한 장치를 쓸 수 있는지를 물어보고 허락이 떨어질 떄까지 대기.

네할렘 구조의 10가지 ALU 자원
(1) 정수 ALU 및 시프트 연산
(2) 정수 ALU 및 주소 계산
(3) 복잡한 정수 계산
(4) 나눗셈
(5) 분기문
(6) SSE 정수 명령어
(7) SSE 정수 곱셈
(8) 부동소수점 덧셈
(9) 부동소수점 셔플
(10) 부동소수점 곱셈


5. 연산 결과 저장(OS)

지금까지 구한 결과를 레지스터 또는 메모리에 쓰는 일(Operand Storing, OS 혹은 Write Back, WB)을 한다.

  • ALU 연산이라면 연산 결과를 명령어에 지시된 목적지 레지스터에 쓴다.
  • 메모리 로드라면 역시 지시된 목적지 레지스터에 써야 한다.
  • 메모리 스토어는 피연산자 인출 단계에서 쓸 내용이 담겨있는 내용을 읽었을 것이고, OS 단계에선 메모리 유효 주소를 계산(ex, sp+4)하고 메모리에 앞서 읽은 레지스터 내용을 메모리 계층으로 전송한다.
  • 분기문의 경우 직접적으로 연산 결과를 지정할 레지스터를 가지지 않는다. 다만 분기문의 결과로 PC값이 바뀐다.

실제 명령어 구현에서는 위 5단계의 단계보다 더 세분화하여 구현된다.

0개의 댓글