Chapter 13

프로그램은 어떻게 실행되는가

프로그래머는 total = total + price 같은 사람이 읽을 수 있는 글을 쓴다. CPU는 0010 00001111 같은 숫자만 안다. 그 사이를 잇는 것이 컴파일러와 인터프리터다. 이 장에서는 아주 작은 프로그래밍 언어의 컴파일러를 직접 돌려 코드 한 줄이 기계어가 되는 과정을 보고, 실행 중인 프로그램이 메모리를 어떻게 쓰는지 들여다본다. 앞 장들의 CPU, 메모리, 운영체제가 모두 한자리에 모인다.

사람의 언어와 기계의 언어 사이

4장에서 CPU가 실행하는 명령은 숫자, 즉 기계어라고 했다. 초창기 프로그래머는 정말로 숫자를 직접 적었다. 너무 힘들어서 LOAD, ADD 같은 이름표를 붙인 어셈블리어가 나왔고, 이를 숫자로 바꿔 주는 프로그램(어셈블러)이 생겼다. 그래도 어셈블리어는 CPU마다 다르고, 덧셈 하나에 명령 세 개가 필요하다.

그래서 사람이 생각하는 방식에 가까운 고급 언어가 만들어졌다. C, 자바, 파이썬, 자바스크립트 같은 언어다. 고급 언어로 쓴 글을 소스 코드라 하고, 이를 기계어로 번역하는 프로그램이 컴파일러(compiler)다.

// 같은 일을 세 가지 수준으로 고급 언어: total = total + price 어셈블리어: LOAD total ADD price STORE total 기계어: 0001 00010000 0010 00010001 0100 00010000

미니 컴파일러 돌려 보기

아주 작은 언어를 하나 정의했다. 할 수 있는 것은 세 가지뿐이다. 변수 = 식(식에는 숫자, 변수, +, -만), print 식, 그리고 while 변수 { … }(변수가 0이 아닌 동안 반복). 이 언어를 4장의 장난감 CPU를 확장한 기계(메모리 256칸)의 기계어로 번역한다. 왼쪽 위 코드를 고쳐 보자. 네 단계가 실시간으로 바뀐다.

SIMULATOR

소스 코드 → 기계어 → 실행

예제
① 소스 코드 사람이 쓴다
② 토큰 글자를 낱말로 자르기
③ 구문 트리 문장의 구조 파악
④ 어셈블리 → 기계어 CPU의 언어로
해볼 것: “1부터 5까지 합”에서 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)가 일을 시작한다.

  1. 새 프로세스를 만들고 빈 가상 주소 공간을 준비한다(8·9장).
  2. 실행 파일의 안내문을 읽고 코드와 데이터를 가상 주소 공간에 배치한다. 실제로는 페이지 테이블에 “디스크의 이 부분”이라고 적어 두고, 처음 건드릴 때 페이지 폴트로 읽어 온다(9장).
  3. 필요한 공유 라이브러리(.dll, .so)를 찾아 연결한다. 이미 다른 프로세스가 올려 둔 것이면 같은 프레임을 공유한다.
  4. 스택 영역을 만들고, 프로그램의 시작 주소를 PC에 넣는다(4장).
  5. 스케줄러에 등록하면, 차례가 왔을 때 CPU가 첫 명령을 가져오기 시작한다.

프로세스의 메모리 지도

실행 중인 프로그램의 가상 주소 공간은 보통 네 구역으로 나뉜다.

스택 (Stack)함수 호출, 지역 변수 ↓ (비어 있는 공간) 힙 (Heap)실행 중 요청한 메모리 ↑ 데이터: 전역 변수·상수 코드: 기계어 명령 (읽기 전용) 높은 주소 낮은 주소 ← 함수를 부를 때마다 위에서 아래로 쌓였다가, 끝나면 사라진다. 크기가 정해진 지역 변수, 돌아갈 주소를 담는다. 빠르고 자동이다. ← 크기를 미리 모르는 데이터(사진, 목록)를 담는다. 프로그램이 요청해서 받고, 다 쓰면 돌려줘야 한다. ← 프로그램 내내 살아 있는 값. ← 실수로 덮어쓰지 못하게 읽기 전용으로 보호한다(9장).
그림 13-1. 프로세스의 가상 주소 공간. 스택과 힙이 빈 공간을 사이에 두고 서로를 향해 자란다.

함수 호출과 스택: 접시 쌓기

프로그램은 일을 함수로 나눈다. 함수가 다른 함수를 부르면 CPU는 ① 지금 하던 곳(돌아올 주소), ② 새 함수의 지역 변수를 담을 공간을 스택 꼭대기에 한 층(프레임) 쌓고 새 함수로 점프한다. 함수가 끝나면 맨 위층을 걷어 내고 적혀 있던 주소로 돌아간다. 마지막에 쌓은 것을 먼저 꺼내는 접시 쌓기 방식이다.

함수가 자기 자신을 부르는 재귀에서 스택이 가장 잘 보인다. 아래는 팩토리얼(4! = 4 × 3 × 2 × 1)을 재귀로 계산하는 코드다.

SIMULATOR

호출 스택 따라가기: fact(4)

 
재귀 호출이 끝나지 않으면(예: n이 1에서 멈추는 조건을 빼먹으면) 스택이 끝없이 쌓여 할당된 공간을 넘친다. 이것이 스택 오버플로다. 개발자 질문 사이트의 이름도 여기서 왔다.

힙과 메모리 관리

사진 편집 앱이 사진을 몇 장 열지는 실행 전에 알 수 없다. 이렇게 크기가 실행 중에 정해지는 데이터는 힙에 둔다. 프로그램이 “30 MB 주세요”라고 요청하면 메모리 관리자가 빈 곳을 찾아 주소를 돌려준다(6장의 포인터!). 다 쓴 뒤에는 돌려줘야 한다.

앱을 오래 켜 두면 느려지는 이유 중 하나

메모리 누수가 있는 앱은 시간이 지날수록 힙이 계속 커진다. 결국 RAM이 부족해지고 9장의 스왑과 스래싱이 시작된다. 앱을 껐다 켜면 해결되는 이유는, 프로세스가 끝날 때 운영체제가 그 프로세스의 메모리를 통째로 회수하기 때문이다.

핵심 정리

  1. 고급 언어 → (컴파일러) → 어셈블리/기계어. 컴파일은 어휘 분석 → 구문 분석 → 의미 분석 → 최적화 → 코드 생성으로 진행된다.
  2. if, while 같은 제어문은 조건 점프와 점프로 번역된다.
  3. 컴파일러는 미리 전체를, 인터프리터는 실행하며 한 줄씩 번역한다. JIT는 둘을 섞는다.
  4. 운영체제의 로더가 실행 파일을 읽어 프로세스를 만들고, 메모리에 배치하고, PC를 시작 주소로 맞춘다.
  5. 프로세스 메모리는 코드 · 데이터 · 힙 · 스택으로 나뉜다.
  6. 함수 호출마다 스택 프레임이 쌓이고 반환하면 사라진다. 실행 중 크기가 정해지는 데이터는 힙에 두고 관리한다.

확인 퀴즈

컴파일러가 소스 코드를 처리하는 첫 단계로, 글자들을 의미 있는 낱말(변수 이름, 숫자, 기호)로 자르는 것은?

먼저 글자를 토큰으로 나누고, 그다음 토큰들의 문법 구조(구문 트리)를 만든다.

while 반복문은 기계어에서 주로 무엇으로 바뀌는가?

4장에서 본 JZ와 JUMP의 조합이다. 미니 컴파일러의 출력에서 직접 확인할 수 있다.

파이썬 코드를 한 줄씩 읽으며 바로 실행하는 방식은?

인터프리터는 동시통역사처럼 실행하면서 해석한다.

fact(4)를 재귀로 계산할 때 스택 프레임이 가장 많이 쌓인 순간의 프레임 수는? (fact(4), fact(3), …)

fact(4) → fact(3) → fact(2) → fact(1)까지 4층이 쌓인 뒤, 위에서부터 하나씩 반환하며 사라진다.

실행 중에 크기가 정해지는 데이터(예: 사용자가 연 사진)를 두는 메모리 영역은?

힙은 실행 중 요청해서 받고, 다 쓰면 돌려주는 영역이다. 돌려주지 않으면 메모리 누수가 생긴다.