본문 바로가기
System/Computer Architecture

프로세서의 언어, 명령어 집합 구조 (Instruction Set Architecture, ISA) | 프로그래머가 몰랐던 멀티코어 CPU 이야기, 김민장 저

by wanggoNya 2024. 8. 12.

 

     ※  아래 내용은 스스로 공부한 내용을 정리한 글입니다.
     ※  때로 정확하지 않을 수 있으며, 참고만 부탁드립니다.
     ※  잘못된 내용이 있을시 댓글로 알려주시면 감사하겠습니다.
     ※  해당 글은 『김민장 저, 프로그래머가 몰랐던 멀티코어 CPU 이야기』를 읽고 정리한 글입니다.

 

 

 

요즘 시스템 분야에 흥미를 느껴서 김민장 님 저서인 『프로그래머가 몰랐던 멀티코어 CPU 이야기』를 읽기 시작했습니다. 하루에 한 챕터씩 읽으면서 글과 실물의 괴리감을 줄이기 위해 간단한(?) 사이드 프로젝트도 함께 해볼까 하는데 시간이 가능할지 모르겠네요. 그래도 오래간만에 가슴 뛰는 공부 거리를 찾았습니다. 

 


 

들어가며, 컴파일러의 등장으로 인해 프로그래머들은 프로세서 언어와 거리를 두게 되었다.

컴파일러는 아주 똑똑한 통역사다. 프로그래머가 이해하는 언어를 프로세서가 쓰는 괴팍한 기계어로 멋지게 번역해 준다. 게다가 통역사의 역할을 넘어서 프로그래머가 쓰는 언어의 요점만 간추리는 역할도 한다. 또 프로그래머가 실수로 문법이 틀리거나, 어법에 어긋난 말을 해도, 컴파일러는 이를 정확하게 가려주는 일도 한다.

과거에는 프로그래머가 조금이라도 더 빠른 코드를 만들기 위해 갖은 노력을 했지만, 요즘 컴파일러는 손수 최적화한 코드보다 더 훌륭한 코드를 만들기도 한다. 그래서 프로세서 언어와는 점점 요원해지게 되었다. 우리는 이 책에서 최신 멀티코어 프로세서에 담긴 알고리즘을 배우고자 한다. 이 프로세서의 작동 원리를 알려면 먼저 프로세서가 어떤 언어를 사용하는지부터 알아야 한다. 

 

x86 어셈블리가 그 예시이다. 컴파일러라는 통역사 없이, 잘 모르는 프로세서의 언어를 보게 되면 두려움을 느낄 것이다. 하지만 프로그래머들은 이미 알게 모르게 프로세서의 언어를 직접 경험하고 있다

 

gcc -o0 -g -o hello hello.c /* 디버깅 정보와 함께 컴파일 */

./hello

gdb --quiet hello /* 디버거로 hello 프로그램 실행 */
(gdb) list /* 프로그램 코드 출력 */
...

(gdb) break main /* 함수 main에 중단점 설정 */
...

(gdb) run /* 프로그램 실행, main에서 실행이 멈춤 */
... 

(gdb) disassemble /* 중단된 main 함수를 기계어로 보기 */
...

(gdb) x/s 0x4005b8 /* 주소 0x4005b8의 값을 문자열로 보기 */
...

(gdb) info reg /* 현재 레지스터 값 */
...

(gdb) stepi 500 /* 500개의 명령어를 실행 */
...

(gdb) backtrace /* 호출 스택 관찰 */
...

(gdb) queit /* gdb 종료 */

 

(블로그 작성자도 실무에서 gdb로 디버깅을 굉장히 많이 한다.)

 

이렇게 외부에서 보이는 현재 프로세서 상태를 컴퓨터 구조적 상태(architectural state)라고 부르고, 이것은 현재 프로세서가 수행 중인 프로세스의 상태를 지닌 각종 레지스터의 값들을 가리킨다. 위 gdb 화면이 바로 컴퓨터 구조적 상태를 보여주고 있다. 프로세서는 이런 구조적 상태를 하나씩 가지고 있다.

듀얼코어, 쿼드코어 같은 멀티코어 프로세서는 이 컴퓨터 구조적 상태를 코어 개수만큼 동시에 가진다.

정확히 말하면 코어 개수마다가 아니라 하드웨어 스레드(혹은 논리 프로세서)마다 구조적 상태를 가진다. 이 개념은 챕터 9에서 자세히 설명한다.

 

코드가 한 줄씩 순차적으로 실행되는가?
현대 프로세서에서는 뒤죽박죽 순서가 지켜지지 않은 채 실행되는지 않는가?

 

프로그래머가 덧셈을 명령하면 프로세서는 결과를 정확하게 레지스터나 메모리에 반영해야만 한다. 프로그래머는 자신의 코드가 한 줄씩 순차적으로 실행되는 것으로 이해하고 있다. 이것이 프로세서와 프로그래머 사이의 약속이다. 그런데 현대 프로세서는 훨씬 복잡한 방법으로 처리해서 성능을 높인다. 프로세서 실행 도중 상태를 살펴보면 뒤죽박죽 순서가 지켜지지 않은 채로 있을 수 있다. 이런 복잡함은 프로그래머에게 노출시키지 않는다. 아무리 내부 구현이 복잡해도 프로세서는 프로그래머와 약속된 architectural state를 정확하게 보여준다. 

 

레지스터

레지스터는 컴퓨터가 계산을 하기 위해 필수적으로 필요한, 작은 용량이지만 매우 빠른 임시 기억 장치를 가리킨다. 현대 컴퓨터는 모든 계산을 메모리에서 레지스터로 값을 옮겨온 후 해야 한다. 챕터 3에서 자세히 설명할 예정. 

 

프로세서의 언어는 명령어 집합 구조(Instruction Set Architecture, ISA)이다.

인류가 원시 부족의 문화를 이해하기 위해서는 가장 먼저 그들이 쓰는 언어를 배워야 한다. 마찬가지로 프로세서를 이해하기 위해서는 프로세서가 쓰는 언어를 알아야만 한다. 최소한 그 언어가 어떤 모습인지는 알아야 프로세서를 깊이 알 수 있다.

 

만약 컴파일러가 없다면 어떻게 될까? 프로그래머는 프로세서가 이해하는 기계어(machine language) 혹은 어셈블리어(assembly language)를 이용해야 컴퓨터에 작업을 지시할 수 있다. 이 기계어가 명령어 집합 구조(Instruction Set Architecture)이며, 줄여서 ISA라고 표현한다. 프로세서를 설계할 때도 첫 번째로 결정해야 하는 것은 아마도 이 ISA를 정의하는 것이다. ISA는 프로그래머와 프로세서가 직접적으로 소통할 수 있는 언어이다. 이 ISA는 프로그래밍 방법론을 정의할 뿐만 아니라, 프로세서 구현의 많은 부분 또한 결정하기 때문이다.

 

ISA, 예를 들어 보자

인텔과 AMD 프로세서는 x86이라는 이들만의 언어를 사용한다. 이는 PC와 노트북에 주로 쓰이고 있다. 비록 인텔과 AMD의 내부 구조가 사뭇 다르다고 해도, 같은 언어를 사용하기 때문에 별문제 없이 프로그램이 작동한다.

하지만 ISA가 다른 프로세서라면 어떻게 될까? x86 ISA로 만들어진 프로그램은 바로 작동할 수 없다. 새롭게 컴파일을 하거나, 에뮬레이션을 거쳐야만 한다. Sun의 JVM(자바 가상머신)이나, Microsoft의 .NET 프레임워크는 이런 문제점을 해결하기 위해 여러 다양한 프로세서들은 ISA 대신 가상 프로세서의 언어로 프로그램을 기술해서, 특정 프로세서 구조뿐만 아니라 운영체제와도 독립적으로 프로그램이 작동할 수 있게 한다.

 

프로세서가 이해하는 명령어, Instruction (인스트럭션)

프로세서가 이해하는 명령어 하나하나를 Instruction이라고 부른다. 그리고 프로그램은 수많은 레고 블록으로 이루어진 건물과 같다. 건물을 이루는 블록은 보통 벽돌이나 판 모양 같은 단순한 모양이다. 물론 둥글거나 창문 모양의 특수한 부품도 있다. 이런 부품들이 서로 맞무려 거대한 건물을 만든다. 프로세서가 이해하는 명령어, 그리고 거대한 프로그램 또한 이와 같은 관계라고 할 수 있다.

 

명령어는 여러 종류로 나눌 수 있다. 크게 다음 세 가지로 구분할 수 있다.

1. 기본적인 사칙 연산, 논리 연산

2. 메모리에 쓰고(store) 읽는(load) 명령

3. 프로그램의 실행 흐름을 제어하는 분기 및 호출 명령 (if/goto, call/return)

이 외에도 시스템 내부를 제어하거나 상태를 관찰하는 명령도 있다. 

 

A += 7

이를 x86 언어로 표현하면?

 

add dword ptr [A], 0x07
0x83 0x45 0xF8 0x07

# 간단한 x86 코드

 

하나의 명령어에는 명령어 종류, 피연산자 같은 여러 가지 내용이 기술되어 있다. 마치 문장 속에 주어나 목적어, 동사가 있는 것과 동일하다. 

 

하나씩 살펴보자.

add dword ptr [A], 0x07

 

'add'와 '0x07'은 명확한 뜻이다.

'dword ptr [A]'에서 A는 변수 A의 주소 값을 말한다. dword ptr은 이 주소 값을 4 byte 정수형으로 해석하라는 뜻이다.

(x86은 double word가 4 byte 임)

요약하면 프로세서는 ptrA가 A의 주소(&A)를 담고 있다고 할 때, 다음과 같이 이해한다.

*ptrA = *ptrA + 7

 

C/C++의 포인터 개념은 매우 자연스럽게 기계어에서 넘어온 것이다. 이 기계어 구문만 정확히 이해하면 포인터가 더 이상 어렵게 느껴지지 않을 것이다.

 

아래 16진수는 이 덧셈 명령이 실제로 프로세서로 전달될 때 표현되는 값이다. 이런 이진수 갑으로 바꾸는 것을 인코딩, 그 반대를 디코딩 이라고 한다. 

0x83 0x45 0xF8 0x07

 

이런 "A += 7' 에다가 좀 더 세밀한 동작을 지시해보자.

LOCK add dword ptr [A], 0x07
0xF0 0x83 0x45 0xF8 0x07

# 간단한 x86 코드 : 원자성에 대해서는 챕터 18~19에서 자세히 알아본다.

 

여기서 추가된 "LOCK"은 원자성을 보존하라는 뜻으로, 쉽게 설명하면 멀티스레드 실행에서 안전할 수 있도록 LOCK 접두어만 추가되었다. 

 

이렇게 보았듯이 ISA는 명령어 종류, 피연산자 타입, 레지스터 개수, 인코딩 방법 등 여러 가지를 정의한다. 그 외에 실제로 프로그램이 돌아가려면 ISA 위에 ABI(Application Binary Interface)라는 응용프로그램과 운영체제 사이의 약속도 필요하다. 함수호출규약(calling conventions)이나 바이너리 포맷에 대한 규약이 대표적이다. 

 

이 책에서의 x86 표기 형식

이 책에서는 x86 명령어는 MASM(Microsoft Assembler) 형식을 따른다. 

반면에 리눅스나 유닉스에서 objdump 같은 명령어로 바이너리를 보면 MASM 형식과 다른 GAS(GNU Assembler) 방식을 따른다. 목적지(destination)와 소스(source) 위치가 서로 다르다는 것, 그리고 피연산자 크기를 명령어에 접미사를 붙여 표현한다는 점이 다르다. 

 


[ Reference ]

프로그래머가 몰랐던 멀티코어 CPU 이야기 (김민장 저) 

"챕터 2장, 프로세서의 언어 : 명령어 집합 구조" 中