프로그램은 어떻게 실행되는가
프로그래머는 total = total + price 같은 사람이 읽을 수 있는 글을 쓴다. CPU는 0010 00001111 같은 숫자만 안다. 그 사이를 잇는 것이 컴파일러와 인터프리터다. 이 장에서는 아주 작은 프로그래밍 언어의 컴파일러를 직접 돌려 코드 한 줄이 기계어가 되는 과정을 보고, 실행 중인 프로그램이 메모리를 어떻게 쓰는지 들여다본다. 앞 장들의 CPU, 메모리, 운영체제가 모두 한자리에 모인다.
- 소스 코드가 토큰 → 구문 트리 → 어셈블리 → 기계어로 바뀌는 컴파일 과정을 따라간다.
while같은 반복문이 4장의 조건 점프로 번역되는 것을 확인한다.- 컴파일러, 인터프리터, JIT의 차이를 비유로 설명한다.
- 운영체제가 실행 파일을 읽어 프로세스를 만드는 과정(로더)을 안다.
- 프로세스 메모리의 코드·데이터·힙·스택 영역과 함수 호출 시 스택이 쌓이고 풀리는 모습을 본다.
사람의 언어와 기계의 언어 사이
4장에서 CPU가 실행하는 명령은 숫자, 즉 기계어라고 했다. 초창기 프로그래머는 정말로 숫자를 직접 적었다. 너무 힘들어서 LOAD, ADD 같은 이름표를 붙인 어셈블리어가 나왔고, 이를 숫자로 바꿔 주는 프로그램(어셈블러)이 생겼다. 그래도 어셈블리어는 CPU마다 다르고, 덧셈 하나에 명령 세 개가 필요하다.
그래서 사람이 생각하는 방식에 가까운 고급 언어가 만들어졌다. C, 자바, 파이썬, 자바스크립트 같은 언어다. 고급 언어로 쓴 글을 소스 코드라 하고, 이를 기계어로 번역하는 프로그램이 컴파일러(compiler)다.
미니 컴파일러 돌려 보기
아주 작은 언어를 하나 정의했다. 할 수 있는 것은 세 가지뿐이다. 변수 = 식(식에는 숫자, 변수, +, -만), print 식, 그리고 while 변수 { … }(변수가 0이 아닌 동안 반복). 이 언어를 4장의 장난감 CPU를 확장한 기계(메모리 256칸)의 기계어로 번역한다. 왼쪽 위 코드를 고쳐 보자. 네 단계가 실시간으로 바뀐다.
소스 코드 → 기계어 → 실행
① 소스 코드 사람이 쓴다
② 토큰 글자를 낱말로 자르기
③ 구문 트리 문장의 구조 파악
④ 어셈블리 → 기계어 CPU의 언어로
while이 JZ(0이면 탈출)와 JUMP(처음으로)로 바뀌는 것을 찾아보자. 식 a + b - c는 LOAD 한 번과 ADD/SUB 여러 번으로 바뀐다. 숫자 상수도 메모리 칸을 하나씩 차지한다(이 CPU에는 숫자를 명령 안에 직접 넣는 기능이 없다).실제 컴파일러도 큰 흐름은 같다. ① 글자를 낱말로 자르는 어휘 분석, ② 문법에 맞춰 나무 구조를 만드는 구문 분석, ③ 뜻이 맞는지(변수가 선언되었는지, 숫자에 글자를 더하지 않는지) 검사하는 의미 분석, ④ 더 빠르게 다듬는 최적화, ⑤ 기계어를 만드는 코드 생성. 문법이 틀리면 ②에서, 타입이 안 맞으면 ③에서 오류가 난다. 프로그래머가 매일 보는 “컴파일 오류”가 바로 이것이다.
컴파일러, 인터프리터, 그리고 JIT
고급 언어를 실행하는 방법은 크게 두 가지다.
| 컴파일러 | 인터프리터 | |
|---|---|---|
| 방식 | 실행 전에 전체를 한꺼번에 기계어로 번역해 실행 파일을 만든다. | 실행하면서 한 줄씩 읽고 바로 수행한다. |
| 비유 | 번역서: 책 전체를 미리 번역해 출판 | 동시통역사: 말하는 즉시 옮김 |
| 장점 | 실행이 빠르다. 오류를 미리 많이 잡는다. | 바로 실행해 볼 수 있다. 어느 컴퓨터든 인터프리터만 있으면 된다. |
| 단점 | 번역 시간이 든다. CPU·OS마다 따로 번역해야 한다. | 매번 해석하느라 느리다. |
| 예 | C, C++, Rust, Go | 파이썬(전통적으로), 셸 스크립트 |
요즘은 둘을 섞는다. 자바스크립트 엔진과 자바는 처음엔 해석하며 실행하다가, 자주 실행되는 부분을 발견하면 그 자리에서 기계어로 컴파일해 바꿔치기한다. 이를 JIT(Just-In-Time) 컴파일이라 한다. 동시통역사가 자주 나오는 문장은 미리 번역문을 써 두는 셈이다. 웹 페이지의 복잡한 앱이 빠르게 도는 이유다.
실행 파일과 로더: 더블클릭하면 생기는 일
컴파일 결과는 실행 파일(윈도우의 .exe, 맥·리눅스의 실행 파일, 안드로이드의 앱 묶음)로 SSD에 저장된다. 실행 파일 안에는 기계어 코드뿐 아니라 초기 데이터, 그리고 “코드는 어디에 올리고, 어떤 라이브러리가 필요하고, 첫 명령은 어디인지” 같은 안내문이 들어 있다.
아이콘을 더블클릭하면 운영체제의 로더(loader)가 일을 시작한다.
- 새 프로세스를 만들고 빈 가상 주소 공간을 준비한다(8·9장).
- 실행 파일의 안내문을 읽고 코드와 데이터를 가상 주소 공간에 배치한다. 실제로는 페이지 테이블에 “디스크의 이 부분”이라고 적어 두고, 처음 건드릴 때 페이지 폴트로 읽어 온다(9장).
- 필요한 공유 라이브러리(.dll, .so)를 찾아 연결한다. 이미 다른 프로세스가 올려 둔 것이면 같은 프레임을 공유한다.
- 스택 영역을 만들고, 프로그램의 시작 주소를 PC에 넣는다(4장).
- 스케줄러에 등록하면, 차례가 왔을 때 CPU가 첫 명령을 가져오기 시작한다.
프로세스의 메모리 지도
실행 중인 프로그램의 가상 주소 공간은 보통 네 구역으로 나뉜다.
함수 호출과 스택: 접시 쌓기
프로그램은 일을 함수로 나눈다. 함수가 다른 함수를 부르면 CPU는 ① 지금 하던 곳(돌아올 주소), ② 새 함수의 지역 변수를 담을 공간을 스택 꼭대기에 한 층(프레임) 쌓고 새 함수로 점프한다. 함수가 끝나면 맨 위층을 걷어 내고 적혀 있던 주소로 돌아간다. 마지막에 쌓은 것을 먼저 꺼내는 접시 쌓기 방식이다.
함수가 자기 자신을 부르는 재귀에서 스택이 가장 잘 보인다. 아래는 팩토리얼(4! = 4 × 3 × 2 × 1)을 재귀로 계산하는 코드다.
호출 스택 따라가기: fact(4)
힙과 메모리 관리
사진 편집 앱이 사진을 몇 장 열지는 실행 전에 알 수 없다. 이렇게 크기가 실행 중에 정해지는 데이터는 힙에 둔다. 프로그램이 “30 MB 주세요”라고 요청하면 메모리 관리자가 빈 곳을 찾아 주소를 돌려준다(6장의 포인터!). 다 쓴 뒤에는 돌려줘야 한다.
- 직접 관리 (C, C++): 프로그래머가 직접 돌려준다. 빠르지만, 깜빡하면 메모리가 계속 쌓이는 메모리 누수가, 이미 돌려준 메모리를 또 쓰면 이상한 버그와 보안 구멍이 생긴다.
- 가비지 컬렉션 (자바, 파이썬, 자바스크립트, Go): 실행 환경이 주기적으로 “더 이상 아무도 가리키지 않는 메모리”를 찾아 자동으로 회수한다. 편하고 안전하지만, 회수하는 동안 잠깐 멈칫할 수 있다.
- 소유권 규칙 (Rust): 컴파일러가 “이 메모리는 누가 책임지는지”를 검사해 실행 전에 누수와 잘못된 접근을 막는다.
메모리 누수가 있는 앱은 시간이 지날수록 힙이 계속 커진다. 결국 RAM이 부족해지고 9장의 스왑과 스래싱이 시작된다. 앱을 껐다 켜면 해결되는 이유는, 프로세스가 끝날 때 운영체제가 그 프로세스의 메모리를 통째로 회수하기 때문이다.
핵심 정리
- 고급 언어 → (컴파일러) → 어셈블리/기계어. 컴파일은 어휘 분석 → 구문 분석 → 의미 분석 → 최적화 → 코드 생성으로 진행된다.
if,while같은 제어문은 조건 점프와 점프로 번역된다.- 컴파일러는 미리 전체를, 인터프리터는 실행하며 한 줄씩 번역한다. JIT는 둘을 섞는다.
- 운영체제의 로더가 실행 파일을 읽어 프로세스를 만들고, 메모리에 배치하고, PC를 시작 주소로 맞춘다.
- 프로세스 메모리는 코드 · 데이터 · 힙 · 스택으로 나뉜다.
- 함수 호출마다 스택 프레임이 쌓이고 반환하면 사라진다. 실행 중 크기가 정해지는 데이터는 힙에 두고 관리한다.
확인 퀴즈
컴파일러가 소스 코드를 처리하는 첫 단계로, 글자들을 의미 있는 낱말(변수 이름, 숫자, 기호)로 자르는 것은?
while 반복문은 기계어에서 주로 무엇으로 바뀌는가?
파이썬 코드를 한 줄씩 읽으며 바로 실행하는 방식은?
fact(4)를 재귀로 계산할 때 스택 프레임이 가장 많이 쌓인 순간의 프레임 수는? (fact(4), fact(3), …)
실행 중에 크기가 정해지는 데이터(예: 사용자가 연 사진)를 두는 메모리 영역은?