🟣 회로는 왜 우리 코드를 바로 못 읽나
프로세서는 결국 트랜지스터로 만든 회로다. 회로가 아는 것은 전압의 높고 낮음, 곧 0과 1뿐이다. 반면 우리가 쓰는 코드는 변수 이름과 반복문과 함수로 이뤄져 있다. 이 둘 사이에는 아무런 접점이 없다. 회로에게 for 반복문을 그대로 건네줄 방법은 없다.
그래서 중간에 약속이 하나 필요하다. 회로가 직접 해독할 수 있는 아주 단순한 명령어들의 목록을 미리 정해 두고, 컴파일러는 우리 코드를 그 명령어들의 나열로 번역한다. 이 명령어 목록의 규격이 명령어 집합(ISA, Instruction Set Architecture)이다. 어떤 연산이 있는지, 각 명령어가 몇 비트인지, 비트를 어떻게 잘라 읽어야 하는지가 전부 여기서 정해진다.
ISA의 값어치는 이 약속이 양쪽을 갈라놓는다는 데 있다. 컴파일러를 만드는 사람은 회로의 배선을 몰라도 명령어 규격만 지키면 되고, 칩을 설계하는 사람은 어떤 프로그램이 돌지 몰라도 규격대로 명령어를 실행하는 회로만 만들면 된다. 한쪽을 바꿔도 다른 쪽이 안 무너지는 이 계약 구조가, 같은 프로그램이 세대가 다른 여러 칩에서 도는 이유다. 이 책에서는 학부 교재의 MIPS를 기준으로 그 계약의 안을 들여다본다.
🟣 MIPS의 선택: 적게, 단순하게, 같은 길이로
MIPS는 RISC(Reduced Instruction Set Computer) 계열이다. 이름 그대로 명령어의 종류를 줄이고 하나하나를 단순하게 만든 설계다. 명령어를 화려하게 많이 두는 반대 진영(CISC)도 있었지만, MIPS는 반대로 갔다. 명령어가 단순하고 길이가 일정하면 회로가 그것을 빠르고 규칙적으로 해독할 수 있기 때문이다. 이 결정이 뒤에 나올 datapath와 파이프라인을 깔끔하게 만드는 밑바탕이 된다.
RISC의 핵심 규칙 하나가 특히 중요하다. 연산은 레지스터끼리만 한다. 메모리에 있는 값을 더하고 싶으면, 먼저 레지스터로 불러오고(load), 레지스터에서 더한 뒤, 결과를 다시 메모리에 저장한다(store). 메모리를 직접 연산 대상으로 삼는 명령어는 없다. 이것을 load/store 구조라고 부른다.
레지스터를 이렇게 앞세우는 이유는 속도다. 레지스터는 프로세서 안에 박힌 극소량의 초고속 저장소이고, 메모리 접근보다 압도적으로 빠르다. MIPS는 32비트 레지스터 32개를 둔다. 개수를 32개로 못 박은 것도 우연이 아니다. 명령어 안에 "몇 번 레지스터"인지를 적어 넣어야 하는데, 32개는 5비트면 지정된다. 저장 공간이 넉넉할수록 좋지만, 레지스터 번호가 명령어의 비트를 잡아먹으므로 성능과 명령어 크기 사이에서 타협한 숫자가 32였다.
🟣 명령어의 속살: 32비트를 나누는 세 가지 방법
MIPS 명령어는 전부 32비트로 길이가 같다. 대신 그 32비트를 어떻게 잘라 읽느냐가 명령어 종류마다 다르다. 크게 세 형식으로 나뉜다. 레지스터끼리 연산하는 R형식, 상수나 메모리 주소를 다루는 I형식, 멀리 점프하는 J형식이다.
가장 앞의 6비트는 항상 연산 코드(opcode)다. 회로는 이 6비트를 먼저 보고 나머지 26비트를 어떤 형식으로 해독할지 정한다. 세 형식의 비트 배치를 늘어놓으면 이렇다.
| 형식 | 31–26 | 25–21 | 20–16 | 15–11 | 10–6 | 5–0 |
|---|---|---|---|---|---|---|
| R | opcode (6) | rs (5) | rt (5) | rd (5) | shamt (5) | funct (6) |
| I | opcode (6) | rs (5) | rt (5) | immediate (16) | ||
| J | opcode (6) | address (26) |
R형식은 add $rd, $rs, $rt처럼 레지스터 두 개(rs, rt)를 읽어 연산하고 결과를 세 번째 레지스터(rd)에 넣는다. 뒤쪽 funct 6비트가 실제 연산이 덧셈인지 뺄셈인지 논리곱인지를 마저 구분한다. opcode만으로는 부족한 세부 연산을 funct가 채우는 구조다.
I형식은 상수(immediate)를 16비트로 품는다. addi $rt, $rs, 100처럼 즉시 상수를 더하거나, lw·sw로 메모리를 오갈 때 쓴다. lw $rt, 8($rs)는 rs가 가리키는 주소에서 8만큼 떨어진 곳의 값을 rt로 불러오는데, 이때 그 8이 바로 immediate 자리에 들어간다. 연산과 메모리 접근과 상수 처리가 전부 이 한 형식으로 모인다.
J형식은 opcode 하나만 남기고 26비트를 통째로 점프할 주소에 쓴다. 멀리 뛰어야 하니 주소 필드가 가장 넓다.
🟣 조건과 반복은 어디서 오나: 분기 명령어
반복문과 조건문은 고급 언어의 개념이다. 회로에는 그런 것이 없다. 대신 다음에 실행할 명령어의 위치를 바꾸는 분기 명령어가 있고, 컴파일러가 모든 if와 for를 이 분기로 번역한다.
프로세서에는 PC(Program Counter)라는 레지스터가 있어서 지금 실행할 명령어의 주소를 들고 있다. 보통은 명령어를 하나 실행할 때마다 PC가 다음 명령어로 4바이트씩 넘어간다. 분기 명령어는 이 PC를 다른 곳으로 튕겨 흐름을 꺾는다.
MIPS의 대표 분기는 beq(같으면 분기)와 bne(다르면 분기)다. beq $t0, $t1, LABEL은 두 레지스터가 같으면 LABEL로 뛰고, 아니면 그냥 다음 명령어로 간다. 크기 비교는 조금 돌아간다. slt(set less than) 명령어로 "작으면 1, 아니면 0"을 레지스터에 만든 뒤, 그 결과를 bne로 검사한다. 비교 자체를 여러 단순 명령어의 조합으로 푸는 것이 RISC답다.
분기가 뛰는 방식에도 판단이 담겨 있다. beq는 절대 주소가 아니라 현재 PC에서 얼마나 떨어졌는가를 16비트 상수로 담는다. 조건 분기는 대개 가까운 곳으로 뛰고(반복문 안쪽), 상대 거리로 적으면 16비트로 충분한 데다, 코드가 메모리 어디에 올라가도 상대 거리는 그대로라 위치에 안 묶인다. 반면 함수 호출처럼 멀리 뛰는 j·jal은 J형식으로 넓은 절대 주소를 쓴다. 가까운 분기와 먼 점프를 다른 형식으로 나눈 이 구분이, 다음 장에서 볼 제어 신호 설계로 그대로 이어진다.
🟣 코드가 실행 파일이 되기까지
우리가 짠 소스가 이 기계어로 바뀌어 메모리에 올라가기까지는 몇 단계를 거친다. 이 과정을 알아 두면 "왜 헤더가 필요한가", "왜 라이브러리를 링크한다고 하는가" 같은 실무의 말들이 한 줄기로 정리된다.
먼저 컴파일러가 소스를 어셈블리로 바꾼다. 어셈블리는 add, lw 같은 사람이 읽을 수 있는 명령어 표기다. 다음으로 어셈블러가 이 어셈블리를 앞서 본 32비트 기계어로 인코딩해 목적 파일(object file)을 만든다. 여기까지는 파일 하나 안의 일이다.
문제는 프로그램이 대개 여러 조각으로 나뉘어 있고, 남이 만든 라이브러리 함수도 부른다는 점이다. 내 코드가 printf를 불러도, 어셈블 시점엔 printf가 메모리 어디에 놓일지 모른다. 그래서 그 자리를 빈칸으로 남긴다. 이 빈칸들을 채워 여러 목적 파일을 하나로 잇는 일이 링킹이다. 링커는 흩어진 심볼들의 실제 주소를 확정하고, 서로를 가리키도록 주소를 다시 계산해 채워 넣는다(재배치).
마지막으로 프로그램을 실행하는 순간, 로더가 이 실행 파일을 메모리에 펼쳐 올린다. 코드 영역, 전역 데이터, 스택, 힙이 각자의 자리를 잡고, PC가 첫 명령어를 가리키면서 실행이 시작된다. 소스에서 실행까지의 이 사슬을 한 번 꿰어 두면, 이제 남은 질문은 하나다. 메모리에 올라간 이 기계어를 회로가 실제로 어떻게 한 줄씩 실행하는가.
🟣 라즈베리파이로 확인해 본 것
교재의 MIPS는 실물로 만져 볼 일이 없어서, 손에 있던 라즈베리파이로 실제 명령어를 뜯어봤다. 라즈베리파이는 ARM 프로세서를 쓰고 MIPS와는 다른 ISA지만, 레지스터 중심의 load/store RISC라는 뼈대는 똑같았다. 간단한 C 함수를 컴파일한 뒤 objdump로 디스어셈블해 보면, 값을 레지스터로 불러오고, 레지스터끼리 연산하고, 결과를 저장하고, 조건에 따라 분기하는 흐름이 교재의 MIPS와 거의 겹쳐 보였다.
명령어 이름과 레지스터 개수, 인코딩 세부는 달랐지만 설계 철학이 같으니 개념이 그대로 옮겨졌다. 교육용 ISA로 배운 것이 실제 하드웨어에서 헛되지 않다는 확인이었다. ISA는 특정 칩의 사양이 아니라 하드웨어와 소프트웨어가 만나는 자리의 문법이라는 감각이 이때 잡혔다.
🟣 정리
- 회로는 0과 1만 안다. ISA는 회로가 해독할 단순 명령어들의 규격이자, 컴파일러와 칩 설계자를 갈라놓는 계약이다.
- MIPS는 RISC라 명령어가 단순하고 길이가 32비트로 일정하다. 연산은 레지스터끼리만 하고 메모리는 load/store로만 오간다.
- 32비트는 R·I·J 세 형식으로 잘라 읽는다. 앞 6비트 opcode가 나머지를 어떻게 해석할지 정한다.
- 조건문과 반복문은 분기 명령어로 번역된다.
beq는 PC 상대 주소로 가까이 뛰고,j는 절대 주소로 멀리 뛴다. - 소스는 컴파일과 어셈블을 거쳐 목적 파일이 되고, 링킹으로 심볼 주소가 채워진 뒤, 로더가 메모리에 올려 실행된다.
면접에서 "왜 RISC냐"거나 "레지스터가 왜 32개냐"는 질문이 들어오면, 명령어 크기와 성능의 타협이라는 축으로 설명할 수 있다. 명령어 하나가 32비트에 어떻게 담기는지를 필드 단위로 아는 것이, 다음 장의 datapath에서 각 필드가 어느 회로로 흘러가는지를 이해하는 전제가 된다.
기계어가 메모리에 올라갔으니, 이제 회로 차례다. 다음 장에서는 이 32비트 명령어 하나가 프로세서 안의 datapath를 어떻게 통과하며 실행되는지, 그리고 그 통로의 스위치를 누가 조작하는지를 본다.