컴퓨터를 만듭시다. 어때요~ 참 쉽죠? (21)
#21. 하드웨어를 넘어
opcode라는 것을 어떻게 정의하느냐에 따라 달라지겠지만 나는 opcode를 cpu에 어떤 동작을 수행하게 만드는 기계어와 일대일로 대응되는 코드라고 정의했다. 그리고 지난 이야기에서 주소 지정 방식에 대해 이야기하며 opcode라는 용어말고 어셈블리어라는 용어도 등장시켰다. 그렇다면 opcode와 어셈블리어의 관계는 어떤 것일까.
역시 정의하는 사람에 따라 다를것이다. 나는 opcode를 어셈블리어에 포함되는 요소라고 본다. 어셈블리어가 opcode로 변경되고 opcode가 기계어로 변경되어서 최종적으로 기계를 동작시키는 것이다. 어셈블리어는 프로그래밍 언어다. opcode는 기계어와 일대일 대응되는 코드다. 그러므로 어셈블리어는 opcode보다 인간의 사고 방식에 조금더 가까운 것이다. opcode는 당연히 기계의 동작 방식에 조금 더 가까운 것이다.
간단한 예로 기계에 어떤 연속적인 동작을 하게 만드는 프로그램을 작성 할 때, opcode만으로 이루어진 프로그램에는 변수나 레이블, 함수 같은 프로그래밍적 요소가 등장하지 않는다. 프로그램 소스 코드 한 줄 한 줄이 모두 그대로 기계어로 번역될 수 있는 형태의 코드만 모여 있을 뿐이다. 그러나 어셈블리어로 작성된 프로그램에는 변수, 레이블, 함수 같은 요소가 모두 등장할 수 있다. 그리고 어셈블러의 구현에 따라서 제어문이나 반복문도 등장할 수 있다. 물론 opcode 만으로도 제어나 반복은 구현할 수 있지만 어셈블러에서 별도로 제공하는 제어나 반복이 등장할 수도 있다는 말이다.
어셈블리어를 opcode 형태로 바꾼다음 그것을 다시 기계어로 바꾸는 것이 논리적인 순서겠지만 보통 어셈블리어를 직접 기계어로 바꾸는 것이 일반적이다. 이렇게 어셈블리어를 기계어로 직접 바꾸는 프로그램을 어셈블러라고 부른다.
같은 기계어를 만드는 어셈블러라도 그 종류는 여러개 있을 수 있다. 그리고 어셈블러에 따라서 거기에 맞춰 작성해야 하는 어셈블리어의 문법은 다를 수 있다. 어셈블리어는 프로그래밍 언어기 때문에 당연히 어셈블러에 종속되어 문법이 다르다. 하지만 opcode는 cpu 제조사에서 정하는 것이기 때문에 한 cpu의 opcode는 그 위에서 동작하는 어셈블러와는 상관없이 모두 동일하다. 다시 말하지만 opcode는 기계어와 일대일 대응되는 코드기 때문에 어셈블러 개발사에서 정하는 것이 아니라 cpu 제조사에서 정하는 것이다. 어셈블리어는 어셈블러를 통해서 기계어가 만들어지는 것이기 때문에 어셈블러 개발사에서 그 문법을 정한다.
물론 나는 내 이야기에서 cpu도 만들고 어셈블리어 문법도 정할 것이다. 어셈블러는 만들지 안만들지 모르겠다. 완전히 동작하는 어셈블러까지는 아니어도 어셈블러를 만드는 과정 정도는 이야기해야 하지 않을까 싶다. 어셈블리어는 그 문법을 어떻게 정의하느냐에 따라서 얼마든지 복잡해 질 수도 단순해 질 수도 있다. 단순해 질 수록 opcode에 가까워지는 것이고 복잡해 질 수록 프로그래밍 언어에 가까워 지는 것이다. 그리고 문법이 복잡할 수록 어셈블러도 구현하기 복잡해 진다. 복잡함과 단순함. 그 사이에서 적절한 수준을 찾는것이 가장 먼저 생각해야 할 문제다.
나만의 어셈블리어를 설계하기 앞서, 지금까지 내가 만들었던 cpu를 개념적 상황에서 확대해 보도록 하겠다. 우선 버스폭은 16비트에서 32비트로 확장시키겠다. 버스폭이 커진 만큼 접근 가능한 메모리의 용량도 32Kbyte에서 4Gbyte로 커진다. 그리고 결정적으로 여유 비트가 많아진 만큼 opcode의 정의에 여유가 더 많이 생긴다.
레지스터도 A, B, PC 세 개에서 16개로 늘리겠다. 16개 중에 4개는 데이터 저장이나 연산 용이 아닌 특수 용도로 사용한다. 당연히 PC는 있어야 하고, LR과 SP 레지스터를 추가하겠다. LR은 Link Register의 약자로 프로그램의 진행이 sub function이나 co-routine에 진입하고 나서 호출했던 위치로 다시 복귀할 필요가 있을 때 호출 지점의 위치를 저장하는 역할을 한다. SP는 Stack Pointer로 현재 사용하고 있는 스택의 메모리 주소를 저장하고 있다. 이 레지스터들이 왜 필요한지 아는 분들은 당연히 컴퓨터 구조나 어셈블리어를 아시는 분일것이다. 모르시는 분들도 나중에 어셈블리어를 이용해 고급언어를 개발할 때 이 레지스터들이 필요하게 됨을 보게 될것이니 지금은 그냥 받아들이시길 바란다. 그리고 마지막 특수 용도 레지스터는 상태 레지스터다. Status라는 영어 단어에서 이름을 따 와서 ST 레지스터라고 정했다. 상태 레지스터는 32비트 중에 4개 비트만 사용한다. 4개 비트는
Overflow
Carry
Zero
Negative
이렇게 사용된다. Overflow는 연산의 결과가 32비트를 넘겼을 때 1이 써진다. Carry는 덧셈이나 곱셈 등 연산에서 넘김 자리수가 발생했을 때 1이 써진다. ALU의 Carry Out 핀이 직접 연결되어 있다고 생각하면 된다. Zero는 연산의 결과가 0일 때 1이 써진다. ALU의 출력 핀이 모두 연결된 32-input-NAND 게이트에 연결되어 있다고 보면 될 것이다. Negative는 연산의 결과가 음수일 때 1이 써진다. 2의 보수 회로가 동작할 때의 Carry Out 핀의 값이 써진다고 보면 된다.
ST 레지스터는 ALU의 연산 결과에 대한 상태를 저장하고 있는 레지스터다. 이 레지스터는 연산 결과에 연동되어 동작하는 새로운 opcode의 동작을 결정짓는 역할을 한다. 관련 내용은 잠시후에 이야기 하겠으니 조금만 기다리시라.
4개의 특수 용도 레지스터를 제외한 12개의 레지스터는 모두 데이터 처리용 레지스터다. A, B 두 개만 있어서 조금 부족했다고 생각해서 과감하게 12개로 늘렸다. 이름은 역시 단순하게 A~L까지로 정했다. 알파벳 순서로 A부터 L까지 12개다. 세어봐도 좋다.
cpu를 32비트 머신으로 변경했으니 opcode도 조금 바뀌는 것이 구색이 맞을 것 같다. opcode도 약간 손보자.
먼저 이동 명령어다.
MOV Rd, {Rs | Value}
Rd는 데이터가 들어갈 레지스터, Rs는 데이터 원본이 있는 레지스터를 뜻한다. Value는 그냥 값이다. 앞으로 opcode를 설명하면서 계속 이 기호를 사용할 것이다. 이동 명령은 Immediate 주소 지정 방식만 사용하기 때문에 직접 레지스터 이름을 넣거나 값을 넣어야 한다. { xxx | yyy } 이렇게 쓴 것은 xxx 아니면 yyy 둘 중 하나를 선택하라는 뜻이다. 그래서 {Rs | Value}는 레지스터 이름을 쓰던가 아니면 그냥 값을 써라하는 의미다. 즉, MOV A, B 혹은 MOV A, 0x3333, 둘 중 하나만 허용된다.
다음은 산술 명령이다. 산술 명령은 더하기, 빼기, 곱하기, 나누기, 나머지. 다섯 개다. 여기서 고민을 해본다. 산술 명령의 주소 지정 방식을 어디까지 허용할 것인가. 어차피 opcode를 정의하고 있는 건 나니까 내 마음대로 하면 된다. 가능한 아홉 가지 방법을 전부 허용할 것인가? 몇 개만 허용할 것인가. 일단 단순하게 만들어 보자.
ADD Rd, R1, {R2 | Value}
SUB Rd, R1, {R2 | Value}
MUL Rd, R1, {R2 | Value}
DIV Rd, R1, {R2 | Value}
MOD Rd, R1, {R2 | Value}
Rd는 최종 결과가 저장되는 레지스터고, R1의 값과 R2 혹은 직접 입력한 Value 값이 산술 연산된다. 이동 명령과 마찬가지로 주소 지정 방식은 Immediate 만 허용하게 했다. 산술 연산에 굳이 메모리 접근 연산을 사용하는 것 보단 로드/스토어 명령을 조합해서 레지스터를 통해 계산하는 것이 opcode를 단순하게 만들 수 있을 것 같다는 판단이 들었기 때문이다.
그래서 로드/스토어 명령에만 Immediate 주소 지정 방식을 제외한 여덟 가지 주소 지정 방식을 모두 사용할 생각이다.
LDR Rd, {Rs | #Rs | [Rs] | [#Rs] | Address | #Address | [Address] | [#Address] }
STR Rd, {Rs | #Rs | [Rs] | [#Rs] | Address | #Address | [Address] | [#Address] }
순서대로 Register Direct, Register-PC Relative Direct, Register Indirect, Register-PC Relative Indirect, Simple Direct, PC Relative Direct, Simple Indirect, PC Relative Indirect 방식을 모두 허용한다. 로드/스토어 명령으로 레지스터에 어떤 값을 메모리에서 가져온다음 이하 여러 연산이나 처리는 모두 레지스터를 이용하게 할 생각이다.
그래서 비교, 논리, 비트 시프트 명령에서도 Immediate 주소 지정 방식만 허용하게 만들겠다.
비교 명령이다.
CMP R1, {R2 | Value}
비교 명령은 연산의 결과가 ST 레지스터에 저장된다. 그래서 Rd, Rs를 쓴것이 아니라 R1, R2를 사용했다. R1과 R2를 비교하거나, R1과 Value 값을 비교한다. 사실상 그냥 SUB 명령과 동일한 동작을 하지만 SUB 명령은 결과에 따라 ST 레지스터의 변화가 없는 반면 CMP 명령은 R1 – R2의 결과가 0이면 ST 레지스터의 Zero 비트가 1이 되고, 음수면 Negative 비트가 1이 된다. 양수면 저 두 비트가 모두 0이다. 이 비교 명령을 통해 ST 레지스터의 상태를 바꾸고 나면 여러 가지 분기 명령을 사용할 수 있다.
분기 명령이다.
BR {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BSUB {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BEQ {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BGT {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BLT {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BGET {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
BLET {Reg | #Reg | [Reg] | [#Reg] | Address | #Address | [Address] | [#Address] }
분기 명령은 쉽게 말해 PC 레지스터의 값을 바꾸는 명령이다. 그래서 분기 명령도 로드/스토어 명령처럼 여덟 가지 주소 지정 방식을 모두 허용하도록 했다.
BR은 그냥 점프하는 명령이다. 주소 지정 방식에 따라 읽어온 메모리 주소로 아무것도 묻지도 따지지도 않고 그냥 PC를 옮겨버리는 것이다.
BSUB는 점프하기 전에 LR레지스터에 BSUB 명령을 사용했을 때의 PC를 저장한다. 보통 분기 명령은 C언어같은 언어에서 함수 호출등을 구현할 때 많이 사용한다. 함수를 호출해서 함수를 모두 실행하고 나면 다시 호출했던 원래 위치로 돌아가야 한다. 이 돌아가는 위치를 기억하고 있는 레지스터가 LR레지스터다. LR 레지스터를 사용하지 않고 스택을 이용해 처리할 수도 있으나 대부분의 프로그램이 잦은 함수 호출로 이뤄져 있는 만큼 복귀 주소를 스택이 아니라 레지스터에서 처리하는 것은 여러모로 시스템 전체 속도 향상에 많은 도움을 준다. 그래서 LR 레지스터를 사용하도록 설계했다.
BEQ는 앞서 CMP 명령에서 R1과 R2가 같은 값일 때 점프를 한다. ST 레지스터의 Zero 비트가 1이면 점프한다는 뜻이다. C언어에서 if문이나 for 문, while문의 조건절 처리에 사용할 수 있을 것이다. 뒤로 나오는 브렌치 명령 모두 ST 레지스터의 조건에 따라 점프하는 명령이다. 모두 조건절 처리에 이용할 수 있다.
BGT는 R1이 R2보다 클 때 점프한다. Zero 비트와 Negative 비트가 모두 0이면 된다. 즉 R1에서 R2를 뺀 값이 양수면 BGT에서 점프한다. BLT는 반대로 R1이 R2보다 작으면 점프한다. Zero 비트는 0이고 Negative 비트가 1이면 조건을 만족한다. BGET는 R1이 R2보다 크거나 같으면 점프한다. Zero 비트가 1이거나 Negative 비트가 0이면 점프하게 된다. 당연이 예상 하셨겠지만 BLET는 R1이 R2보다 작거나 같으면 점프한다. Zero 비트가 1이거나 Negative 비트가 1이면 점프한다.
남아 있는 논리 연산과 비트 시프트 연산 명령은 역시 Immediate 주소 지정 방식만 허용한다.
논리 연산이다.
AND Rd, R1, {R2 | Value}
OR Rd, R1, {R2 | Value}
XOR Rd, R1, {R2 | Value}
NOT Rd, {Rs | Value}
위에서 계속 설명했으니 더 부가 설명은 안해도 될것 같다. 이어서 비트 시프트 명령이다.
SHL Rd, R1, {Rc | Count}
SHR Rd, R1, {Rc | Count}
레지스터 R1의 데이터를 Rc레지스터의 값 혹은 Count 만큼 왼쪽/오른쪽으로 비트 시프트하는 명령이다.
opcode의 문법을 그대로 어셈블리어에서 사용할 것이다. 그리고 부가적인 어셈블리어 문법을 정의해 보자. 정의하기 따라서는 이 부가적 어셈블리어 문법이 opcode 문법의 몇 배가 될수도 있다. 하지만 나는 가장 최소한의 어셈블리어 문법만을 정의할 것이다.
DEF 문. C언어의 #define 문과 동일한 동작을 한다. 심볼과 값이 정의되고 해당 심볼은 어셈블리어에 의해서 값으로 대치된다. 문법은 간단하다.
DEF symbol content
content로 올 수 있는 내용은 레지스터 이름도 될 수 있고, opcode 자체도 될 수 있고 다른 심볼, 주소 무엇이든 올 수 있다. symbol이 content로 대치된 다음에 문법에 위배되지 않으면 된다.
변수 선언문. 변수는 1바이트, 4바이트, 문자열 변수를 선언할 수 있도록 하겠다. 1바이트 변수 선언문은 BYTE 키워드를 사용하고 4바이트 변수를 선언하는 선언문은 WORD 키워드를 사용하겠다. 문자열 변수는 STRING 키워드를 사용하겠다. 변수 선언은 DEF 선언과 다르다. DEF 선언은 symbol의 위치에서 content로 그 내용 자체를 바꿔 버리지만 변수 선언은 메모리의 특정 위치에 변수 값이 저장되어 있다.
BYTE symbol 초기 값
WORD symbol 초기 값
STRING symbol “초기 값”
위와 같은 형식으로 쓸 수 있다. 문자열 변수는 초기 값 시작과 끝에 따옴표를 붙인다. 문자열이기 때문에 따옴표로 시작과 끝을 알려줘야만 어셈블러가 인식할 수 있다. 다음에 어셈블러를 실제로 제작하면서 이야기 하겠지만 변수 선언의 symbol은 그 자체가 변수의 값이 저장되어 있는 메모리 주소를 표현한다. 쉽게 이해하려면 C언어의 전역 변수를 생각하면 된다.
레이블. 변수와 마찬가지로 메모리 상의 특정 주소위치를 가리키는 심볼이다. 변수는 데이터 값이 있는 메모리 주소를 가리키는 심볼이고 레이블은 명령어 값이 있는 메모리 주소를 가리키는 심볼 정도로 이해하면 무난하다.
symbol:
심볼 이름 옆에 콜론(:)을 주면 된다. C언어의 레이블 문법과 동일하다. 보통 브렌치 명령의 인자로 레이블로 선언한 심볼을 그대로 주면 어셈블러가 알아서 해당 위치의 주소로 변경해 주는 식으로 구현이 된다.
그 외에 더 추가할 문법적 요소는 많지만 이정도만 해도 어셈블리어 문법적 요소와 opcode 자체에 대한 구분은 설명이 되었으리라 생각한다. 어셈블리어는 opcode의 확장이고 이 어셈블리어를 opcode로 만들어주는 것이 어셈블러다. 다음 이야기에서는 어셈블러의 실제 구현까지는 아니더라도 어떤 식으로 어셈블러를 구현하는지 이야기해 볼까한다. 어느덧 이야기의 중심이 하드웨어에서 거의 완전히 소프트웨어로 넘어온것 같다. 소프트웨어와 하드웨어는 분리되어 있지 않다. 기계어와 opcode. opcode와 어셈블리어를 통해 연결되어 있다.
댓글 달기