- 컴퓨터 내부 동작과 프로그래밍 언어 실행 방식을 이해하기 위해, LC-3 교육용 아키텍처 위에서 어셈블리 프로그램을 실행하는 약 250줄 C 기반 VM을 직접 구현함
- 구현 대상은 65,536개의 16비트 메모리 위치, 10개 레지스터, 16개 opcode, 조건 플래그, trap routine, 메모리 매핑 레지스터를 갖춘 작은 컴퓨터 모델임
- 실행 루프는
PC가 가리키는 명령을 읽고 증가시킨 뒤 opcode를 해석해ADD,LDI,BR,JMP,TRAP같은 명령을 수행하는 fetch-decode-execute 구조로 동작함 - 프로그램 로딩은 객체 파일의 첫 16비트 origin을 읽어 메모리에 배치하고, LC-3의 big-endian 형식을 대부분의 현대 컴퓨터에서 쓰는 little-endian 형식에 맞게 바이트 스왑함
- 키보드 입력과 콘솔 출력은 trap routine과
KBSR/KBDR메모리 매핑 레지스터로 처리하며, Unix/macOS와 Windows마다 다른 터미널 입력 버퍼링 코드가 필요함
튜토리얼의 목표와 전제
- LC-3 가상 머신을 직접 구현해 어셈블리 언어 프로그램을 실행하는 과정을 따라감
- 최종 코드는 C 기준 약 250줄이며, Unix용
lc3.c와 Windows용lc3-win.c가 제공됨 - 필요한 전제 지식은 기본적인 C 또는 C++ 읽기와 이진 산술임
- 전체 코드는 GitHub repo에 있으며, 튜토리얼 자체는 literate program 형식이라 코드 블록을 엮어 최종 소스를 만듦
가상 머신이 하는 일
- VM은 CPU와 일부 하드웨어 구성요소처럼 동작하는 프로그램임
- 산술 연산을 수행함
- 메모리를 읽고 씀
- I/O 장치와 상호작용함
- 자체 기계어를 이해해 프로그램을 실행함
- VM의 목적에 따라 실제 하드웨어를 충실히 재현할 수도 있고, 소프트웨어 개발 편의를 위해 새 가상 아키텍처를 제공할 수도 있음
- JVM은 표준 실행 플랫폼을 제공하는 VM의 대표 사례이며, JVM이 구현된 장치에서는 Java, Kotlin, Clojure 프로그램을 수정 없이 실행할 수 있음
- 격리 실행도 VM의 중요한 용도임
- 가비지 컬렉션에서는 VM이 실행 중인 프로그램 바깥에서 스택과 메모리 참조를 관찰할 수 있음
- Ethereum smart contract는 파일 시스템, 네트워크, 디스크 등에 접근하지 못하는 VM 안에서 실행됨
LC-3 아키텍처 구성
- 구현 대상은 대학 컴퓨터 구조와 어셈블리 교육에 쓰이는 LC-3임
- LC-3 메모리는 65,536개 위치를 가지며, 각 위치는 16비트 값을 저장함
- 전체 저장 용량은 128KB임
- C 구현에서는
uint16_t memory[MEMORY_MAX]배열로 표현함
- 레지스터는 총 10개임
R0~R7: 범용 레지스터 8개PC: 다음에 실행할 명령의 메모리 주소COND: 직전 계산 결과의 조건 플래그
- LC-3 명령은 모두 16비트이며, 왼쪽 4비트가 opcode임
- opcode는 16개로 정의됨
OP_BR,OP_ADD,OP_LD,OP_ST,OP_JSR,OP_AND,OP_LDR,OP_STR,OP_RTI,OP_NOT,OP_LDI,OP_STI,OP_JMP,OP_RES,OP_LEA,OP_TRAP가 포함됨
- 조건 플래그는 직전 계산 결과의 부호를 나타냄
FL_POS: 양수FL_ZRO: 0FL_NEG: 음수
어셈블리와 기계어
- LC-3 VM이 실제로 실행하는 것은 사람이 읽는 어셈블리가 아니라 16비트 기계어 명령 배열임
- 어셈블러는 텍스트로 작성된 LC-3 어셈블리를 16비트 바이너리 명령으로 변환함
Hello World예시는 다음 흐름을 가짐.ORIG x3000: 프로그램이 로드될 메모리 주소 지정LEA R0, HELLO_STR: 문자열 주소를R0에 적재PUTS:R0이 가리키는 문자열 출력HALT: 프로그램 중지.STRINGZ "Hello World!": 문자열 데이터를 프로그램 안에 저장
.ORIG,.STRINGZ는 CPU 명령이 아니라 어셈블러 지시문임- 조건과 반복은
BRn LOOP처럼goto에 가까운 분기 명령으로 구현됨
실행 루프의 핵심 절차
- VM 실행은 같은 절차를 반복함
PC레지스터 주소에서 명령을 읽음PC를 증가시킴- 명령의 상위 4비트에서 opcode를 얻음
- opcode에 맞는 구현 코드를 실행함
- 다시 다음 명령을 읽음
- 기본 시작 주소는
0x3000임 - 일부 명령은
PC를 직접 바꿔 실행 흐름을 점프시킴- 분기와 점프 명령 덕분에 단순히
PC를 증가시키는 구조에서도 루프와 조건 실행이 가능함
- 분기와 점프 명령 덕분에 단순히
main루프는switch (op)로 opcode별 처리 코드를 호출함OP_ADD,OP_AND,OP_NOT,OP_BR,OP_JMP,OP_JSR,OP_LD,OP_LDI,OP_LDR,OP_LEA,OP_ST,OP_STI,OP_STR,OP_TRAP를 처리함OP_RES,OP_RTI는 사용하지 않는 opcode로abort()처리 가능함
명령 구현 방식
ADD는 두 값을 더해 목적지 레지스터에 저장하고 조건 플래그를 갱신함ADD에는 두 가지 모드가 있음- 레지스터 모드: 두 번째 피연산자를 다른 레지스터에서 읽음
- 즉시값 모드: 두 번째 피연산자를 명령 하위 5비트
imm5에서 읽음
imm5처럼 16비트보다 짧은 값은 sign extension을 거쳐 16비트 값으로 확장해야 함- 양수는 0으로 채움
- 음수는 1로 채워 원래 값을 보존함
- 레지스터에 값을 쓰는 명령은
update_flags로R_COND를 갱신함- 값이 0이면
FL_ZRO - 최상위 비트가 1이면
FL_NEG - 그 외에는
FL_POS
- 값이 0이면
LDI는 “load indirect” 명령임- 명령의
PCoffset9를 sign extension함 - 현재
PC에 더해 메모리 주소를 얻음 - 그 위치에 저장된 값을 다시 주소로 사용해 최종 데이터를 읽음
- 읽은 값을 목적지 레지스터에 저장하고 조건 플래그를 갱신함
- 명령의
주요 명령 집합
- 산술과 비트 연산
ADD: 덧셈AND: 비트 ANDNOT: 비트 NOT
- 제어 흐름
BR: 조건 플래그와 명령의 조건 비트를 비교해PC를 이동함JMP: 지정 레지스터 값을PC로 설정함RET: 명세상 별도 키워드지만JMP의 특수 사례임JSR,JSRR: 현재PC를R7에 저장하고 서브루틴 위치로 점프함
- 메모리 읽기
LD:PC기준 offset 주소에서 읽음LDI: 간접 주소를 한 번 더 따라가 읽음LDR: base register와 offset으로 계산한 주소에서 읽음LEA: 유효 주소 자체를 레지스터에 저장함
- 메모리 쓰기
ST:PC기준 offset 주소에 저장함STI: 간접 주소를 따라가 저장함STR: base register와 offset으로 계산한 주소에 저장함
Trap routine과 I/O
- LC-3는 공통 작업과 I/O 장치 접근을 위해 trap routine을 제공함
- trap routine은 LC-3의 운영체제 또는 API처럼 볼 수 있음
- trap code는 다음처럼 정의됨
TRAP_GETC = 0x20: 키보드에서 문자 입력, 터미널에 echo하지 않음TRAP_OUT = 0x21: 문자 출력TRAP_PUTS = 0x22: word string 출력TRAP_IN = 0x23: 문자 입력 후 터미널에 echoTRAP_PUTSP = 0x24: byte string 출력TRAP_HALT = 0x25: 프로그램 중지
- 공식 LC-3 시뮬레이터에서는 trap routine이 어셈블리로 작성되지만, 이 VM에서는 C 함수로 구현함
PUTS는R0에 저장된 주소부터 시작해x0000을 만날 때까지 문자를 출력함- LC-3 문자열은 C 문자열처럼 1바이트 단위가 아니라 메모리 위치 하나당 문자 하나를 저장함
- 각 메모리 위치는 16비트이므로 C 출력 시
char로 변환해 출력함
HALTtrap은"HALT"를 출력하고 실행 플래그를 0으로 바꿔 VM 루프를 종료함
프로그램 이미지 로딩
- LC-3 어셈블리 프로그램을 기계어로 변환하면 명령과 데이터 배열을 담은 파일이 만들어짐
- 객체 파일의 첫 16비트는 프로그램을 메모리 어디에 놓을지 나타내는 origin임
- 로더는 origin을 먼저 읽고, 나머지 데이터를 origin 주소부터 메모리에 복사함
- LC-3 프로그램은 big-endian 형식임
- 대부분의 현대 컴퓨터는 little-endian이므로 로딩한 각
uint16_t에swap16을 적용함 - 오래된 PPC Mac 같은 big-endian 컴퓨터에서는 스왑하지 않아야 함
- 대부분의 현대 컴퓨터는 little-endian이므로 로딩한 각
read_image는 파일을 바이너리 모드로 열고read_image_file을 호출한 뒤 파일을 닫음
메모리 매핑 레지스터
- 일반 레지스터 테이블로 접근하지 않는 특수 레지스터는 특정 메모리 주소에 매핑됨
- LC-3에서 구현해야 하는 메모리 매핑 레지스터는 두 개임
MR_KBSR = 0xFE00: keyboard status registerMR_KBDR = 0xFE02: keyboard data register
KBSR은 키가 눌렸는지 나타내고,KBDR은 어떤 키가 눌렸는지 저장함GETC는 입력이 들어올 때까지 실행을 막지만,KBSR과KBDR은 장치 상태를 polling해 입력 대기 중에도 프로그램이 계속 반응할 수 있게 함- 메모리 읽기는 직접 배열을 읽지 않고
mem_read를 거침- 주소가
MR_KBSR이면check_key()로 키보드 상태를 확인함 - 키가 있으면
KBSR의 최상위 비트를 세우고KBDR에getchar()값을 저장함 - 키가 없으면
KBSR을 0으로 설정함
- 주소가
플랫폼별 터미널 처리
- 키보드 입력과 터미널 동작을 제대로 처리하려면 플랫폼별 입력 버퍼링 설정이 필요함
- Linux/macOS/UNIX 구현은
termios,select등을 사용함- canonical mode와 echo를 비활성화함
select로 입력 가능 여부를 확인함
- Windows 구현은
GetStdHandle,GetConsoleMode,SetConsoleMode,_kbhit등을 사용함- echo와 line input을 조정함
WaitForSingleObject와_kbhit으로 키 입력을 확인함
- 프로그램 시작 시
disable_input_buffering()을 호출하고, 종료 시restore_input_buffering()을 호출함 SIGINT를 받으면 터미널 설정을 복구하고 줄바꿈을 출력한 뒤 종료함
VM 실행과 디버깅
- VM 빌드 예시는 다음과 같음
gcc lc3.c -o lc3-vm
- 실행하려면 조립된 LC-3 객체 파일을 인자로 넘김
lc3-vm path/to/2048.obj
- 예제로 제공되는 객체 파일은
2048.obj와rogue.obj임 - 2048 예제는 WASD 키로 조작함
- 프로그램이 올바르게 동작하지 않으면 명령 구현 오류일 가능성이 큼
- LC-3 어셈블리 소스를 읽으면서 디버거로 VM 명령을 한 단계씩 실행하는 방식이 권장됨
- 예상한 명령으로 이동하지 않는 지점이 있으면 해당 명령의 명세와 구현을 다시 확인함
선택 사항: C++ 제네릭 기반 구현
- 더 짧은 C++ 구현 기법도 선택 사항으로 다룸
- 여러 명령이 sign extension,
PC기준 offset, 간접 주소 계산 같은 반복 작업을 공유하므로, 명령 실행을 작은 처리 단계들의 파이프라인으로 볼 수 있음 - C++ 템플릿과 비트 플래그를 사용해 opcode별로 필요한 처리 단계만 컴파일 시 포함함
- 이 방식은 코드 중복을 줄이고, 각 처리 단계가 칩의 물리적 공간을 차지하는 실제 하드웨어 배선 방식에 더 가까움
- 아이디어 출처로 Bisqwit’s NES emulator가 언급됨
자료와 기여
- atul-g가 전체 시스템 동작을 요약하는 reference card를 기여함
- 다양한 언어 구현은 GitHub topic
lc3로 정리됨- C, C++, Go, Haskell, Java, JavaScript, Kotlin, Lua, OCaml, Python, Ruby, Rust, Swift, TypeScript, Zig 등이 포함됨
- 자신의 구현을 목록에 보이게 하려면 GitHub topic
lc3를 붙이면 됨 - Windows 플랫폼 지원은 inkydragon이 기여함
- 프로젝트에는 통합 테스트 관련 good first issue가 있음