이 글은 김영근 교수님의 컴퓨터 구조 강의를 듣고 정리한 내용입니다.
이전 글에서 언급했던 바와 같이, 데이터를 연산하기 위해서는 단순 연산 작업 외에도
데이터를 불러오고 저장하는 작업도 필요하므로 이에 대한 명령어와 부가적인 제어(분기 등)에 대한 명령어가 존재한다.
본 글에서는 유형에 따라 어떤 명령어가 존재하고 어떤식으로 동작하는지 살펴보고자 한다.
산술 및 논리 연산자(Arithmetic & Logical Operations)는 기본적으로
3개의 피연산자를 가지고, 그 중 2개는 source / 1개는 destination인
공통된 구조의 명령어를 사용한다.
이렇게 공통된 구조를 갖는 경우,
명령어별 구조를 확인하는데 필요한 추가 비용이 없어 성능을 향상 시킬 수 있다는 장점이 있다.
산술 연산에 쓰이는 명령어로, 아래 C / Assembly Code 예시를 통해 살펴보자.
C Code
// f in x19, g in x20, h in x21, i in x22, j in x23 f = (g + h) - (i + j);
RISC-V Assembly Code
add x5, x20, x21 add x6, x22, x23 sub x19, x5, x6
위에서 언급한 바와 같이 사칙연산에 의해 변환된 Assembly Code를 확인해보면,
1개 명령어에는 3개의 피연산자가 존재하며, 앞의 1개가 destination / 다음 2개가 source이다.
또한 16진수로 표현된 값은 각각 Register의 주소이다.
※ 연산자 종류
산술 연산에 쓰이는 명령어는 다음과 같다. 다만 Add Immediate
, Load upper immediate
와 같이
조금 다른 형태의 명령어가 있는데 이는 아래에서 다시 다루겠다.
앞서 산술 연산과 달리 Bit-Wise 연산을 위해 사용되는 논리 연산에 대한 명령어도 존재한다.
논리 연산 명령어도 산술 연산 명령어와 동일하게 사용하기 때문에 해당 부분은 생략하고,
AND, OR, XOR 연산이 어떤 목적으로 사용되는지 살펴보려고 한다.
※ 연산자 종류
논리 연산에 쓰이는 명령어는 다음과 같다.
여기에 Shift Right 명령어 2개(Shift right logical
, Shift right arithmetic
)가 존재하는데,
Shift Right로 인해 부호(+,-)로 사용되는 최상단 Bit에 변동이 생기는 것을 어떻게 처리할지에 따라서 다음과 같이 2개로 구분하여 사용한다.
위에서 언급했었던 addi(Add Immediate)
는 I-Type의 명령어로,
2개의 source 중 하나만 Register를 참조하고, 다른 하나는 상수를 입력 받아 사용하는 반면
기존의 add
는 R-Type 명령어로, 2개 source 모두 Register를 참조한다.
I-Type의 명령어는 rs2(5bit), funct7(7bit) 에 사용되던 bit를 상수(12bit)에 할당하는 방식이다.
(나머지 rs1, rd, funct3, opcode는 명령어에 필요한 부분이라 다른 곳에 활용할 수 없다.)
해당 방식에는 어떤 이점이 알아보기 위해, 한번 1부터 10까지의 값을 더하는 경우를 생각해보자.
add
명령어의 경우, 1~10까지의 값이 저장된 Register(or Memory)를 매번 참조하게 될 것이다.
반면 addi
명령어를 사용한다면, 명령어 자체에 1~10에 해당하는 상수 값이 포함되어
Register(or Memory) 참조하는 과정이 생략되어 연산 속도가 더 향상될 것이다.
Immediate Operands에는 이러한 이점이 있어 많이 사용되고,
다만 한계로는 상수의 범위가 까지인 점이다.
위에서 설명한 Immediate Operands는 명령어에 범위 내의 상수를 포함시킬 수 있었다.
다만 Integer의 크기는 , Integer 범위 내의 상수를
Immediate Operands를 통해 대응할 수 없다는 한계가 존재한다.
따라서 RISC-V에서는 Upper Immediate 명령어인 lui
를 사용하여 범위의 상수를 만들어낸다.
lui
는 U-type의 명령어로, 32 bit의 저장 공간 중 상위 20개 bit에 명령어의 상수를 할당하는 명령어다.
※ U-Type 명령어 : rd(5bit), opcode(7bit)를 제외한 나머지 20bit를 상수로 할당 가능
RISC-V에서는 아래 그림과 같이 2개 명령어를 사용하여 범위 내의 상수를 생성한다.
① lui
를 사용하여 특정 주소의 상위 20개 bit에 원하는 상수 할당
② addi
를 사용하여 위와 동일한 주소의 하위 12개 bit에 원하는 상수 할당
위에서는 실제 연산 작업(아래 그림 2번)에 해당하는 명령어를 다뤘고,
이제 연산 작업 전후로 이뤄지는 데이터 전송(아래 그림 1,3번)에 해당하는 명령어를 설명하고자 한다.
데이터 전송의 대상에 따라 Load
와 Store
는 다음과 같이 정의할 수 있다.
Load
명령어 내 Base Address, Offset, Destination(Register)Store
명령어 내 Base Address, Offset, Source(Register)ld/sd
(), 부호 사용 안하는 경우 ldu/sdu
()아래 그림은 ld: Load doubleword
, sd: Store doubleword
명령어이다.
sd
의 경우 저장할 위치가 Register가 아닌 Memory 이기 때문에 rd
대신 rs
를 사용하고,
기존 포맷을 유지하기위해 rd
위치에 상수를 위한 데이터를 할당한다.
예시
위의 Load, Store 명령어를 C / Assembly Code 예시를 통해 살펴보자.
C Code
// h in x21 // base address of A in x22 A[12] = h + A[8];
RISC-V Assembly Code
ld x9, 64(x22) add x9, x21, x9 sd x9, 96(x22)
※ 연산자 종류
Data Transfer에 쓰이는 명령어는 다음과 같다.
여기에서 알 수 있는 점은 Load
또는 Store
명령어 사용 시 데이터의 크기를 지정할 수 있다는 것인데,
RISC-V 는 Byte마다 주소가 지정되기 때문에, Byte 단위부터 Half-Word, Word 단위까지 지정하여
데이터를 전송/저장하는 명령어가 별도로 존재한다.
마지막으로 다룰 명령어의 유형은 Control Transfer 명령어인데,
이는 우리가 흔하게 사용하는 분기(If-then-else), 반복(Loops) 등의 기능을 수행할때 필요한 명령어이다.
Control Transfer 명령어는 크게 아래 2가지로 나눌 수 있다.
부가적으로 Assembly Languages에서는 Control Transfer 명령어로 인해
프로그램 흐름이 분기되고 이동하게 되는 목적지를 별도로 표현해주고 이를 label
이라고 말한다.
본 글에서는 branch
명령어에 대해서 먼저 다루고, jump에 대해서는 다음 글에서 다루겠다.
유형 | 기능 | 명령어 종류 |
---|---|---|
branch | 명령어에 정의된 조건에 따라 프로그램의 흐름을 분기시키는 명령어 | beq , bne , blt , bge , bltu , bgeu |
jump | 무조건적으로 프로그램의 흐름을 분기/변경시키는 명령어 (함수 호출/복귀 등) | jar , jalr |
모든 Branch 명령어는 SB-Type 으로 이루어져 있다.
명령어 자체에(opcode
+ funct3
)에 조건이 명시되어 있고, rs1
과 rs2
는 비교할 대상을 의미한다.
또한 imm
값은 조건에 따라 분기될 대상 주소를 지정하기 위한 Offset 값이다.
(Branch 명령어에서 대상 주소를 지정하기 위해 사용하는 방식은 아래에서 다시 다루겠다.)
그럼 실제 High-Languages로 작성된 조건문/반복문이
어떤 형태의 Low-Languages로 변환되는지 예시를 통해 살펴보자.
조건문(If) 예시
아래 예시를 보면 조건문의 경우 지정된 Label 주소로 강제 이동되는 것을 알 수 있는데,
이는 우리가 조건문 대신 goto
구문으로 작성하더라도 동일한 Assembly Code가 만들어진다는 것이다.
사실 이 부분을 수업에서 듣고 적잖이 충격을 받았었는데,
프로그래밍을 배울때부터 goto문은 죄악이다.. 성능에도 좋지 않다라는 얘기를 들어왔는데
실제로는 goto
, if
가 똑같은 동작을 한다는게 충격이었다.
물론 같은 성능이라면, 가독성이 훨씬 좋은 if
, while
등의 구문을 쓰는게 좋겠지만
굳이 성능 때문에 goto
를 쓰지 않을 이유는 없다는 것을 이번 기회에 알게 되었다.
C Code
// f in x19, g in x20, h in x21, i in x22, j in x23 if (i == j) f = g + h; else f = g - h;
RISC-V Assembly Code
bne x22, x23, L1 add x19, x20, x21 beq x0, x0, Exit L1: sub x19, x20, x21 Exit:
반복문(While) 예시
아래는 반복문을 컴파일 했을 때 만들어지는 Assembly Code이다.
한가지 특이한 점은, 동일한 C Code이나 만들어진 Assembly Code가 다르다는 점이다. (하늘색 네모)
컴파일 과정은 High-Language으로 작성된 Code를 컴파일러에서 한번 전처리하는 과정을 거친 뒤,
이 전처리 된 Code를 Assembly / Machine Code로 변환하게 된다.
이때 컴파일러에 주어진 옵션에 따라서 동일하게 작성된 High-Language Code라도,
다르게 전처리 방식으로 인해 결과물도 달라질 수 있는데, 아래가 바로 그러한 경우이다. (빨간색 네모)
Assembly Code를 살펴보면, -Og
옵션의 경우 반복문(L2) 내에 명령어가 5개 존재하나
-O2
옵션의 경우 반복문(L3) 내에 명령어가 3개만 존재하는 것을 알 수 있다.
이는 프로그램의 성능과 직결되는 문제로 이런 디테일들을 알고 Code를 작성한다면
더 높은 성능을 가진 프로그램을 만드는데에도 도움 될 것이다.
Target Addressing는 주로 Control Transfer 명령어에서 분기할 대상 명령어의 주소를 의미하며,
Target Addressing을 지정하는 방식의 2가지 특징에 대해서 살펴볼 것이다.
① PC-relative Addressing
먼저 Taget Addressing은, 이전 글의 Addressing Mode에서 잠깐 소개되었던 PC-relative 방식을 사용하는데 이는 PC(Program Counter)에 저장되어있는 현재 명령어의 주소에 Offset(Imm) 값을 더함으로써 Address를 지정하는 방식이다.
이는 반복문에서도 많이 활용되는 Control Transfer 명령어의 특성상,
Loop의 마지막(PC에 저장) 주소로부터 Loop의 시작 주소로 이동할 일이 많은데
주소의 절대 지정이 아닌 PC 기반 상대 지정 방식을 통해, 더 적은 bit 수를 통해 주소를 지정하는 방식이다.
② Offset shift
글 하단의 Control Transfer의 명령어를 살펴보면, Offset에 1 bit-left shift를 하는 걸 알 수 있다.
이는 명령어 Address의 특징을 통해 Offset 값에 줄 수 있는 한정된 bit(imm12 or imm20)를 보다 효율적으로 쓰기 위함인데, 여기에 대해 좀 더 살펴보자.
먼저 명령어의 크기는 주로 32 bit로 이루어졌지만 16 bit 크기의 명령어도 존재한다.
이는 명령어의 최소 크기가 16 bit = 2 Byte이고, 주소값이 짝수(2,4, ...)임을 알 수 있는데,
binary code 에서 짝수는 과 같이 최하단 bit는 0으로 고정된다.
그렇다면 우리가 Offset 값에도 짝수 값만 사용할테니 최하단 bit는 사용하지 않아도 될 것이고,
명령어의 Offset(imm) 값에 1 bit-left shift 함으로써 실제로 사용하는 bit(12 or 20 bit) 보다
1 bit를 더 사용하는 효과를 낼 수 있는 것이다.
다만 이보다 더 많은 bit를 Offset에 할당하고 싶은 경우,
위에서 언급했던 Upper Immediate Operations 방식을 사용한다. (lui-20 bit
+ jair-12 bit
)
Branch 명령어에는 부호를 사용하는 Signed 명령어(bge
, blt
등)가 있고,
부호를 사용하지 않는 Unsigned 명령어(bgeu
, bltu
등)가 존재한다.
예를 들어, 아래 x22 값이 x23 값보다 작은지 비교했을 때 결론은 같아도 과정이 다르다는 것을 알 수 있다.
Signed 명령어의 경우 2의 보수를 기준으로 하여 라고 판단했지만
Unsigned 명령어의 경우 bit 그 자체의 값이 기준으로 이라고 판단했다.
※ 연산자 종류
Control Transfer 명령어에는 아래와 같은 종류가 있다.