[컴구] #02. MIPS 명령어
이전 글에서 컴퓨터 하드웨어에게 일을 시키려면 하드웨어가 알아먹을 수 있는 언어가 필요하다고 했었죠? 이렇게 컴퓨터가 알아들을 수 있는 단어를 Instruction이라고 하고, 이 Instruction의 문법을 Instruction Set이라고 합니다.
기계어(Machine Language) 역시 여러 가지가 있지만, High-Level Language인 C++, Java 등과는 반대로 Machine Language들의 구조는 대부분 비슷하기 때문에 하나만 배우면 다른 언어들도 쉽게 배울 수 있습d니다. 저는 MIPS라는 ISA(Instruction Set Architecture)를 이용해 기계어에 대한 설명을 진행할 것입니다.
기본 연산
그럼 이제 MIPS에 존재하는 Instruction을 하나하나 살펴보겠습니다. 먼저 모든 컴퓨터는 기본적으로 연산을 할 수 있어야 합니다.
add a, b, c
위 어셈블리어는 두 변수 b와 c를 더해서 a에 넣으라는 명령을 의미합니다. 사실, 어셈블리어에서는 a, b와 같은 변수는 사용하지 않습니다. 그러나 지금은 기본적인 Instruction에 대한 설명을 우선 해야하므로 약식으로 변수를 사용하도록 하겠습니다.
다시 돌아와서, MIPS에서 산술 명령어는 반드시 한 종류의 연산만을 지시하며 변수를 3개만 갖습니다. 따라서 4개의 변수 b, c, d, e의 합을 a에 집어넣는 에시는 아래와 같이 작성합니다.
add a, b, c
add a, a, d
add a, a, e
이렇게 4개의 변수의 합을 구하기 위해서는 3개의 Instruction이 필요합니다. 이게 조금 비효율적으로 보일 수도 있겠지만, 사실 그게 아닙니다. 왜냐하면 모든 명령어가 연산자를 3개를 갖기 때문에, 하드웨어가 훨씬 단순해질 수 있기 때문입니다. 연산자의 개수가 가변적이면, 모든 케이스에 맞추어 하드웨어가 다 다르게 동작해야 하기 때문에 하드웨어가 복잡해집니다.
사실 이런 내용는 MIPS가 아닌 다른 어셈블리어에도 적용되는 내용입니다.
하드웨어 설계 원칙 1 : 간단하게 하기 위해서는 규칙적인 것이 좋다.
C++로 아래와 같은 코드를 작성했다고 생각해 봅시다.
f = (g + h) - (i + j)
이를 어셈블리어로 바꾸면 어떻게 될까요? 먼저 위를 계산하기 위해서는 g와 h를 더하고, i와 j를 더한 뒤, 두 값을 빼줘야 합니다. 따라서, 컴파일러(컴파일러가 뭔지 모르시겠다면 전 글을 참고하세요)는 임시 변수인 t0와 t1을 만들어 두 값을 저장합니다.
add t0, g, h
add t1, i, j
sub f, t0, t1
그래서 이것이 위 C++ 코드를 어셈블리 언어로 변환한 것입니다. (sub는 add와 똑같이 작동하지만, 더하는 것이 아니라 빼는 계산을 수행하는 Instruction입니다.)
레지스터(Register)
다음으로 알아볼 것은 레지스터입니다. 레지스터는 CPU와 가장 가까운 곳에 있어서 그 속도가 굉장히 빠르지만, 공간은 제한된 메모리입니다. 갑자기 하드웨어 이야기를 하는 이유는 레지스터가 MIPS 명령어에서 연산자로 사용되기 때문입니다. 이 레지스터가 하드웨어에서 어떤 역할을 하는지는 Memory Hierarchy를 다룰 때 다시 이야기하도록 합시다.
레지스터의 개수는 32개로 제한되어 있으며, 각 레지스터는 32bit의 데이터를 저장할 수 있습니다. (MIPS에서는 이처럼 32비트가 한 덩어리로 움직이는 경우가 많기 때문에, 32개의 비트를 하나의 word라고 부릅니다.) 32개의 레지스터에는 각자에 이름이 있는데요, 나중에 레지스터의 이름에 대해 다루겠지만, 지금은 그냥 레지스터의 이름은 $로 시작한다는 사실만 알고 넘어가도록 합시다.
f = (g + h) - (i + j)
아까 위의 코드를 어셈블리어로 변환해 보았죠? 그때는 g, h 등의 변수를 어셈블리어에서도 그대로 사용했는데, 원래는 그렇게 하지 않습니다. 컴파일러는 위 코드를 보고, f, g, h, i, j를 레지스터 s0, s1, s2, s3, s4에 할당시킵니다. 그리고 나머지는 똑같습니다. 사용했던 변수 f, g, h, i, j 대신에 레지스터 이름을 사용하는 것입니다.
add t0, s1, s2
add t1, s3, s4
sub s0, t0, t1
이렇게요. 사실 레지스터 이름 앞에 $를 붙여주어야 하지만, 편의상 생략하고 적도록 하겠습니다.
레지스터는 단 32개만 존재합니다. 즉, MIPS 명령어를 만들 때 연산자의 종류는 항상 32개로 제한된다는 것입니다. 여기에서 2번째 하드웨어 설계 원칙을 확인할 수 있습니다.
하드웨어 설계 원칙 2 : 작은 것이 더 빠르다.
사실 당연한 겁니다. 레지스터의 개수가 많아지면 많아질수록, 레지스터에 전기 신호가 도달하기 위해서 더 먼 거리를 이동해야 하므로 클럭 사이클이 늘어납니다.
메모리 연산
MIPS에서 일어나는 산술 연산은 레지스터에서만 실행된다는 사실에 대해 알아보았습니다. 그러면, 메모리에 저장되어 있는 값으로 연산을 하기 위해서는 메모리와 레지스터가 서로 데이터를 주고받을 수 있어야 합니다. 이를 담당하는 명령어를 Data Transfer Instruction이라고 합니다.
메모리에 저장된 데이터에 접근한다고 생각해 봅시다. 이때, 우리는 메모리의 어느 위치에서 데이터를 가져올지를 정해야 합니다. 이 값을 우리는 메모리 주소(Memory Address)라고 합니다. 메모리는 주소 0에서부터 시작하는 아주 큰 1차원 배열이라고 생각하시면 됩니다.
메모리에서 레지스터로 데이터를 가져오는 명령어는 lw입니다. 변수 g, h가 레지스터 s1, s2에 할당되어 있고, 배열 A가 있다고 생각합시다. 이때, 배열 A의 시작 주소(Base Address)는 레지스터 s3에 저장되어 있습니다.
g = h + A[8];
이 때, 위 코드를 MIPS 명령어로 바꿔보겠습니다. 먼저 C++ 프로그램에 연산은 하나밖에 없지만, 메모리에 있는 데이터를 레지스터로 가져오는 lw 명령어를 작성한 뒤에 연산 명령어를 작성해야 합니다.
저희가 메모리에서 가져오고 싶은 값은 A[8]으로, A의 시작 주소에서 8만큼 이동한 곳에 있습니다. 따라서, lw 명령어는 다음과 같이 작성합니다.
lw t0, 8(s3)
위 명령어는 s3에 저장된 주소를 시작 주소에 8을 더한 곳에 있는 데이터를 레지스터 t0로 복사하겠다는 의미를 가집니다. 그리고 이제 덧셈 연산을 진행해 주면 됩니다.
add s1, s2, t0
그런데 사실 위와 같이 MIPS 명령어를 작성하면 안 됩니다. 그 이유는 배열의 데이터 구조에 있습니다. 프로그램에서는 8개의 Bit를 묶어 하나의 Byte라고 표현합니다. 그리고 이 주소 역시 Byte 단위로 구성되어 있습니다. 따라서, 배열에서 한 칸은 32개의 Bit = 4개의 Byte를 차지하므로 배열에서 서로 이웃한 칸은 4만큼의 주소 차이를 가집니다. 즉, A[8]의 주소를 구하고 싶으면 시작 주소에서 32를 더해주어야 합니다.
lw t0, 32(s3)
그러므로, 위와 같이 적어주는 게 맞겠죠?
반대로, 레지스터에서 메모리로 데이터를 보내는 명령은 sw입니다. 작동 방식은 lw와 거의 동일합니다.
A[12] = h + A[8]
변수 h가 레지스터 s2에 할당되어 있고, 배열 A의 시작 주소가 s3에 들어있다고 가정하고 위 코드를 MIPS 명령어로 변환하면 아래와 같습니다.
lw t0, 32(s3)
add t0, s2, t0
sw t0, 48(s3)
자세한 설명은 필요하지 않을 것 같아 생략하도록 하겠습니다.
상수 연산자
MIPS 명령어를 작성하다 보면, 연산에서 상수를 이용해야 할 경우가 종종 있습니다. s3에 저장된 값에 4를 더하고 싶을 때, add 명령어를 바로 사용할 수는 없습니다. 왜냐하면 add 명령어는 3개의 레지스터를 연산자로 받기 때문입니다. 이럴 경우에는, addi 명령어를 사용합니다.
addi s3, s3, 4
위 명령어는 레지스터 s3에 저장된 값에 4라는 상수를 더하는 연산을 수행합니다.
상수 중에서도 0은 특별한 역할을 수행하는 경우가 많습니다. 예를 들어, 레지스터에 저장된 값을 다른 레지스터로 옮기고 싶을 경우 addi 명령어에 상수 0을 넣고 사용하면 되기 때문에, move라는 새로운 명령어를 만들 필요가 없어집니다. 이렇게 상수 0은 사용하는 곳이 많기 때문에, MIPS에서는 레지스터 $zero가 존재해 이 레지스터에 언제나 0을 저장해 둡니다.
부호가 있는 수
잠시 명령어에서 벗어나 다른 이야기를 해보도록 하겠습니다. 컴퓨터는 모든 숫자를 이진수로 표현합니다. 예를 들어, 11이라는 수를 이진수로 나타내면 아래와 같습니다.
이렇게 각 Bit에 위와 같이 번호를 붙일 수 있습니다. 이때, 가장 오른쪽에 있는 비트를 LSB(Least Significat Bit)라고 하며 가장 왼쪽에 있는 비트를 MSB(Most Significant Bit)라고 합니다.
MIPS에서 word의 크기는 32비트이기 때문에, $2^{32}$가지의 서로 다른 수를 표현 가능합니다.
이런 방식으로 0부터 $2^{32}-1=4,294,967,295$까지의 숫자를 표시할 수 있다는 것을 알 수 있습니다.
그런데 컴퓨터 프로그램은 음수도 계산할 수 있어야 하잖아요? 그래서 사람들은 양수와 음수를 구별할 수 있는 방법을 연구하기 시작합니다. 가장 단순한 방법으로는, 32개의 비트 중 하나의 비트를 Sign Bit로 만들어 Sign Bit가 0이면 양수, Sign Bit가 1이면 음수, 이런 식이죠.
이 방법의 문제점은 양의 0과 음의 0이 둘 다 존재한다는 것입니다. 이러한 문제점을 해결하기 위해 현재 사람들이 사용하는 방식이 바로 Two's Complement, 2의 보수법입니다.
이 방법은 먼저 0부터 $2^{31}-1$까지는 부호 없는 수와 같습니다. 그다음은 가장 큰 음수인 $-2^{31}$를 나타내고, 여기서부터 하나씩 크기가 증가합니다.
이런 방식의 장점은 먼저 MSB가 0일 경우 양수, 1일 경우 음수로 부호를 쉽게 판단할 수 있습니다. 또한, 아래의 식을 통해서 그 값을 쉽게 계산할 수 있습니다.
$$x_{31}\times -2^{31}+x_{30}\times 2^{30}+x_{29}\times 2^{29}+\cdots +x_{1}\times 2^{1}+x_{0}\times 2^{0}$$
이런 2의 보수법에서 양수를 음수로, 음수를 양수로 바꾸는 빠른 테크닉이 존재합니다.
- 모든 0을 1로, 모든 1을 0으로 바꿉니다.
- 그 수에 1을 더합니다.
그러면 부호 변환이 끝납니다. 아주 간단하죠? 예를 들어 +2는 이진법으로 0000....0010입니다. 여기서 모든 0을 1로, 모든 1을 0으로 바꾸게 되면 1111....1101이 되는데, 여기에 1을 더한 1111....1110이 바로 -2를 의미합니다.
역으로 1111....1110에서 0을 1로, 1을 0으로 바꾸면 0000....0001이 되는데, 여기에 1을 더하면 0000....0010으로 다시 +2로 돌아오는 것을 확인할 수 있습니다.
Instruction의 이진 표현
지금까지 저희는 High-Level Programming Language를 어셈블리어로 변환하는 과정을 간략하게 알아보았습니다. 이제는 어셈블리어를 Binary Machine Language로 변환하는 과정을 알아보겠습니다. (이게 무슨 뜻인지 모르겠으면 전 글을 참고하세요.)
먼저 거의 대부분의 명령어가 레지스터를 사용하기 때문에, 레지스터를 숫자로 표현할 필요성이 있습니다. 당연하게도 MIPS에는 레지스터를 숫자로 변환하는 규칙이 존재합니다. s0부터 s7까지는 16에서 23, t0에서 t7까지는 8에서 15에 해당됩니다. 나머지 레지스터는 조금 있다가 설명드리도록 하겠습니다.
MIPS에서 어셈블리어 한 줄은 각각 32개의 비트로 변환됩니다. 예를 들어,
add t0, s1, s2
위 어셈블리어를 기계어로 바꾸면 "00000010001100100100000000100000"이 됩니다. 이는 2진수이므로, 16진수로 변환하여 "0x02324020"라고 표현하기도 합니다. 이제 어떻게 이런 결과가 나오는지 하나하나 살펴보도록 하겠습니다.
기계어의 32비트는 위와 같이 세부적으로 나뉩니다. 각 부분을 저희는 필드라고 부르며, 각 필드의 의미는 아래와 같습니다.
- op : 명령어가 실행할 연산의 종류를 나타냅니다. Opcode라고 부릅니다.
- rs : 첫 번째 연산자 레지스터를 나타냅니다.
- rt : 두 번째 연산자 레지스터를 나타냅니다.
- rd : 목적지 레지스터를 나타냅니다. 다른 말로, 연산의 결과가 저장되는 레지스터를 나타냅니다.
- shamt : 나중에 다룰 특수 명령어에서 사용됩니다. 그전까지 이 부분은 00000으로 고정입니다.
- funct : Opcode는 연산의 종류를 나타냈다면, funct는 연산을 구체적으로 지정합니다.
그런데 만약 필드의 길이가 더 길어야 하는 경우에는 어떻게 해야 할까요? 예를 들어 lw 명령어는 레지스터 필드 2개와 상수 필드 하나가 필요합니다. rs, rt, rd 중 하나를 상수 필드로 사용한다면 5bit, 즉 32보다 작은 값만을 사용할 수 있습니다. 모든 명령어의 길이를 같으면 좋겠으면서도, 명령어 형식을 한 가지로 통일하면 좋겠다는 충돌이 발생하게 되는 것입니다.
하드웨어 설계 원칙 3 : 적당한 절충이 필요하다.
MIPS 설계자들의 절충안은, 모든 명령어의 길이를 같게 하면서 그 구조를 명령어에 따라 다르게 하는 것이었습니다. 예컨대, 위의 명령어 형식을 저희는 R 형식이라고 부릅니다. 그리고 lw 명령어와 같은 명령어는 I 형식을 사용합니다.
I 형식에서는 정수 혹은 주소값을 넣는 16비트의 필드가 있으므로, $2^{15}$ 이하의 상수를 이용할 수 있습니다.
이 명령어가 R 형식을 사용할지, I 형식을 사용할지는 Opcode를 보고 구분할 수 있습니다. 형식별로 Opcode가 가질 수 있는 값이 다르기 때문이죠.
위 표는 지금까지 나왔던 MIPS 명령어를 사용할 때, 각 필드에 어떤 값이 들어가야 하는지를 정리해 둔 표입니다. 위 표를 이용해 실제 프로그래밍 언어를 기계어로 한번 바꿔보겠습니다.
A[300] = h + A[300]
먼저 배열 A의 시작 주소는 t1에 저장되어 있고, 변수 h는 s2에 대응된다고 생각합시다. 먼저 위 C++ 코드를 어셈블리어로 변환할 겁니다. 먼저 A[300]에 저장된 값을 불러와 임시 레지스터에 저장하고, 덧셈 연산을 수행한 뒤에, 그 결과를 A[300]에 다시 저장하는 총 3번의 단계가 필요합니다. 따라서, 위를 어셈블리어로 변환한 코드는 아래와 같습니다.
lw t0, 1200(t1)
add t0, s2, t0
sw t0, 1200(t1)
이제 위 Instruction들을 하나하나 기계어로 바꿔보겠습니다. 먼저 lw 명령어는 I 형식을 사용합니다. op는 35, rs는 베이스가 되는 레지스터인 t1에 해당되는 값인 9, rt는 목적지 t0에 해당되는 값인 8을 넣어주어야 합니다. Constant 필드에는 1200을 넣어주면 되겠죠. 이제 35, 9, 8, 1200을 자릿수에 맞게 2진수로 바꿔주면, "100011 01001 01000 0000010010110000"이 됩니다. 띄어쓰기는 제가 여러분들 보기 쉬우라고 일부러 한 것이고, 실제로는 다 붙여 쓰는 게 맞습니다.
다음으로 add 명령어는 R 형식을 사용합니다. op는 0, rs는 첫 번째 연산자인 s2에 해당되는 값인 18, rt는 두 번째 연산자인 t0에 해당되는 값인 8, rd는 목적지인 t0에 해당되는 값인 8을 넣어줍니다. shamt 필드에는 아직 0을, 그리고 add 명령어는 32라는 funct 값을 사용하므로 funct 필드에는 32를 넣어주면 됩니다. 이제 이들을 2진수로 바꾼 "000000 10010 01000 01000 00000 10000"이 두번째 Instruction의 기계어 번역입니다.
마지막 sw 명령어는 첫 번째 lw 명령어와 유사하게 생각하면 됩니다. 그냥 op 필드에 들어가는 값만 43으로 바꿔주면 되겠네요. 변환하면 "101011 010001 01000 0000010010110000"이 됩니다.'
내장 프로그램
이렇게 MIPS 명령어는 011..100과 같은 기계어로 변환된다고 말씀드렸습니다. 그리고 이런 2진 프로그램 역시, 메모리에 저장된 변수들의 값과 같이 메모리에 저장되기 때문에 주소값을 가집니다. 그래서 이런 프로그램 역시 데이터처럼 읽고 쓸 수 있습니다. 즉, 나중에 나오겠지만 어떤 프로그램이 있는 메모리 주소를 주고, 그 프로그램을 실행한다던가 하는 행동도 가능합니다.
논리 연산
다음으로 알아볼 것은 MIPS에서의 논리 연산들입니다. 논리 연산자는 어떤 값에 대한 연산이 아닌 그 값을 이진수로 변환했을 때 나타나는 bit 그 자체에 대한 연산들을 의미합니다.
대표적인 것이 바로 Shift 연산입니다. Shift 연산은 word 내부에 존재하는 모든 비트를 왼쪽 또는 오른쪽으로 이동시킨 뒤, 빈자리를 0으로 채우는 역할을 합니다. 예를 들어 아래와 같은 값이 s0 레지스터에 저장되어 있다고 생각합시다.
이때, 이 값을 왼쪽으로 4번 Shift 시키는 연산을 수행하면 그 값은 아래와 같이 변합니다.
직관적으로 Shift라는 것이 무엇을 뜻하는지 이해가 되시죠? 이렇게 왼쪽으로 Shift 시키는 명령어가 sll (Shift Left Logical), 오른쪽으로 Shift 시키는 명령어가 srl (Shift Right Logical)입니다. Instruction은 아래와 같이 적습니다.
sll t2, s0, 4
(원래 값이 있는 자리가 s0, 결과가 저장될 레지스터가 t2입니다.)
R 형식의 명령어를 설명할 때 shamt 필드를 계속 00000으로 두라고 설명했었는데, 이 필드는 sll, srl 명령어에서 사용합니다. 위 명령어를 기계어로 바꾸면 op와 funct 필드는 0, rs 역시 사용하지 않으므로 0, rt는 s0를 나타내는 16, rd는 t2를 나타내는 10이 됩니다. 그리고 shamt 자리에 얼마나 Shift 시킬지, 그러니까 4를 집어넣어 줍니다.
10진수에서 1,230을 왼쪽으로 4번 Shift 하면 12,300,000이 되어 원래 값의 $10^4$배가 되듯, 2진수에서 왼쪽으로 4번 Shift 하면 그 값이 $2^4$배가 됩니다.
또 다른 연산자로는 AND와 OR, NOT 등의 연산자들이 있습니다. AND는 하나의 비트와 하나의 비트를 연산하는 연산자로, 두 비트값이 모두 1일 경우에만 결과가 1이 됩니다.
예를 들어 이 두 값이 각각 $t1, $t2에 저장되어 있다고 생각하고, 아래 명령어를 실행시켜 보겠습니다.
and t3, t1, t2
or t4, t1, t2
두 레지스터 t3와 t4에 들어있는 값은 각각 아래와 같을 것입니다.
NOT 명령어는 연산자를 하나 받아서 받은 연산자의 비트가 1이면 0을, 0이면 1을 출력합니다. 그런데 여기서, MIPS 개발자들은 연산자를 3개로 유지하기 위해서 NOT 대신 NOR 명령어를 만들었습니다. 이 명령어는 두 연산자를 OR 연산한 후 NOT 연산하여 출력합니다. 따라서, 하나의 연산자에 0을 집어넣으면 NOT 연산자와 똑같이 행동합니다.
판단 명령어
컴퓨터 프로그램이 단순 계산기와 다른 이유는 바로 판단 기능이 있다는 것입니다. 입력값의 계산 결과에 따라서 다음 행동을 정할 수 있는 것인데요, 이 작업을 수행해 주는 것이 바로 beq와 bne 명령어입니다.
beq reg1, reg2, L1
beq 명령어는 reg1과 reg2에 저장된 값이 같으면, L1에 해당하는 문장으로 가라는 뜻입니다. Branch If Equal의 약자이죠.
bne reg1, reg2, L1
bne 명령어는 reg1과 reg2에 저장된 값이 같지 않으면, L1에 해당하는 문장으로 가라는 뜻입니다. Branch If Not Equal의 약자이죠. 이를 이용하면 if else문을 MIPS 어셈블리어로 변환할 수 있습니다.
if (i == j) f = g + h;
else f = g - h;
이 코드를 어셈블리어로 변환하면 아래와 같습니다. f, g, h, i, j는 각각 s0부터 s4까지에 해당합니다.
bne s3, s4, Else
add s0, s1, s2
j Exit
Else: sub s0, s1, s2
Exit:
설명하지 못한 새로운 명령어인 j가 나왔는데, 이 명령어는 주어진 문장으로 아무 조건 없이 무조건 가라는 뜻입니다.
bne, beq도 충분히 강력한 도구이지만, for문이나 while문 같은 순환문을 만들기 위해서는 다른 명령어가 필요합니다.
slt t0, reg1, reg2
slt 명령어는 reg1과 reg2에 저장된 값을 비교하여 reg1 < reg2일 경우, t0에 저장된 값을 1로, 아니면 0으로 바꾸는 명령어입니다. 만약에 상수를 사용하고 싶으면, slti 명령어를 사용하면 됩니다.
slti t0, reg1, 10
위 명령어는 reg1에 저장된 값이 10보다 작을 경우 t0에 저장된 값을 1로, 아니면 0으로 바꿉니다.
마무리
이번 글에서는 MIPS에서 사용되는 어셈블리어와 기계어에 대해 살펴보았습니다.
감사합니다.
이 글은 컴퓨터공학과 학부생이 개인 공부 목적으로 작성한 글이므로, 부정확하거나 틀린 내용이 있을 수 있으니 참고용으로만 봐주시면 좋겠습니다. 레퍼런스 및 글에 대한 기본적인 정보는 이 글을 참고해 주세요.