로더
고친 사람 github-actions[bot]
로더는 디스크에 있는 프로그램을 메모리로 옮겨 돌 수 있게 만드는 운영체제의 구성요소입니다. 파일에 적힌 내용을 어느 주소에 놓을지 정해 그대로 옮깁니다. 준비가 끝나면 맨 처음 실행할 명령어로 제어를 넘깁니다. 무언가를 읽어 들여 바로 쓸 수 있게 올려놓는 다른 물건도 로더라고 부릅니다.
쉽고 빠른 이해
로더는 프로그램 파일을 메모리에 올려 돌 준비를 끝냅니다. 터미널에 명령을 치고 나서 프로그램의 첫 줄이 돌기까지의 짧은 사이를 로더가 씁니다.
이 준비가 따로 있는 까닭은 파일과 돌고 있는 프로그램이 다르게 생겼기 때문입니다. 파일은 순서대로 적힌 바이트일 뿐입니다. 돌고 있는 프로그램은 코드와 데이터가 서로 다른 주소에 놓여 있어야 합니다.
어떻게 도나:
- 파일을 열어 어느 구간을 어느 주소에 놓을지 읽습니다
- 그 주소에 구간을 올리고 모자란 공간을 마련합니다
- 함께 써야 할 라이브러리를 불러와 주소를 이어 줍니다
- 첫 명령어의 주소로 제어를 넘깁니다
대가는 프로그램이 첫 일을 하기 전에 이 준비를 늘 치러야 한다는 것입니다. 불러올 라이브러리가 많을수록 그 시간이 깁니다.
상세
이사를 떠올려 봅니다. 트럭에 실린 짐은 상자에 담겨 순서대로 쌓여 있습니다. 새 집에서는 그 상자를 방마다 풀어 놓아야 씁니다. 짐이 저마다의 위치에 놓이고 나서야 그 집에서 생활이 시작됩니다.
로더가 하는 일이 이 풀어 놓기입니다. 로더는 실행 파일에 적힌 내용을 메모리에 배치해 실행을 시작할 수 있는 상태로 만드는 일을 맡습니다.
셸에 명령 이름을 치면 커널이 그 파일을 찾아 새 프로세스를 만듭니다. 그 프로세스의 메모리를 실행 파일의 내용으로 채우는 일이 로더의 몫입니다. 로더가 끝내야 프로그램의 첫 줄이 돕니다.
맥락마다 가리키는 것
「로더」는 여러 곳에서 씁니다. 운영체제에 있습니다. 자바 가상 머신 안에도 있습니다. 올리는 것과 올리는 곳이 맥락마다 다릅니다.
| 맥락 | 올리는 것 | 올리는 곳 |
|---|---|---|
| 운영체제 | 실행 파일 | 새로 만든 프로세스의 메모리 |
| 컴퓨터 켜기 | 운영체제 커널 | 본체의 메모리 |
| 자바 가상 머신 | 클래스 파일 | 가상 머신이 관리하는 메모리 |
| 프런트엔드 빌드 | 이미지·스타일 같은 원본 파일 | 하나로 묶은 배포용 결과물 |
네 줄에 같은 모양이 있습니다. 밖에 있던 것을 안으로 들여 바로 쓸 수 있는 꼴로 바꿔 놓습니다. 이 문서는 첫째 줄을 다룹니다. 곧 프로그램을 메모리에 올리는 로더입니다.
돌아가는 코드가 전부 로더를 거치는 것은 아닙니다. 스크립트를 돌릴 때 메모리에 올라가는 실행 파일은 인터프리터 쪽입니다. 스크립트 파일 자체는 그 인터프리터가 읽어 들입니다. 로더는 맨 처음 한 번만 관여합니다.
실행 파일에 무엇이 적혀 있나
실행 파일은 기계어만 담긴 덩어리가 아닙니다. 어떻게 올려야 하는지를 적은 안내가 앞쪽에 붙어 있습니다. 로더는 이 안내를 먼저 읽습니다.
안내에는 세그먼트 목록이 들어 있습니다. 세그먼트는 파일의 한 구간과 그것을 올릴 주소를 짝지은 것입니다. 기계어가 담긴 구간과 초기값이 있는 변수가 담긴 구간처럼, 성격이 다른 것끼리 나뉘어 있습니다.
초기값이 없는 변수는 파일에 공간을 차지하지 않습니다. 「이만큼을 비워 0으로 채워 달라」는 크기만 적혀 있습니다. 파일을 작게 유지하려는 것입니다.
마지막으로 진입점이 적혀 있습니다. 진입점은 올리기가 끝난 뒤 맨 처음 실행할 명령어의 주소입니다. 로더는 이 주소로 제어를 넘기고 손을 뗍니다.
지금까지 적은 것을 파일 쪽과 메모리 쪽으로 나란히 놓으면 이렇습니다.
block-beta columns 2 h1["실행 파일"] h2["프로세스 메모리"] a1["안내"] a2["로더가 읽어 배치를 정한다"] b1["기계어 구간"] b2["기계어 구간 · 진입점이 가리키는 곳"] c1["초기값 있는 변수 구간"] c2["초기값 있는 변수 구간"] space d2["0으로 채운 구간"]
두 쪽이 한 줄씩 짝을 이루다가 맨 아랫줄에서 어긋납니다. 0으로 채운 구간은 메모리에만 생깁니다. 파일에는 그 줄에 해당하는 것이 없습니다.
올리는 순서
로더는 안내를 읽는 것에서 시작해 제어를 넘기는 것으로 끝납니다. 그 사이에 하는 일을 순서대로 놓으면 이렇습니다.
flowchart TD
subgraph S1["파일 하나만 보면 되는 일"]
A["실행 파일의 안내를 읽는다"] --> B["세그먼트를 정해진 주소에 올린다"]
B --> C["비워 둘 구간을 0으로 채운다"]
end
subgraph S2["밖에서 가져와야 하는 일"]
D["필요한 라이브러리를 올린다"] --> E["이름으로 적힌 참조를 주소로 바꾼다"]
end
C --> D
E --> F["진입점 주소로 제어를 넘긴다"]
가운데 묶음은 밖에서 가져와야 할 것이 있을 때만 생깁니다. 마지막 한 걸음으로 프로그램에 제어가 넘어갑니다. 아래 두 소절이 가운데 묶음을 풀어 씁니다.
링커가 끝낸 일과 로더가 시작하는 일
링커는 빌드가 끝날 때 여러 목적 파일과 라이브러리를 하나로 합쳐 실행 파일을 만듭니다. 목적 파일은 소스 하나를 기계어로 옮겨 놓은 중간 결과물입니다.
로더는 그렇게 만들어진 파일을 실행할 때 메모리로 옮깁니다. 하나는 파일을 만드는 쪽입니다. 하나는 만들어진 파일을 쓰는 쪽입니다.
flowchart TD
subgraph 빌드 때
S["소스 코드"] --> O["목적 파일"]
O --> L["링커"]
L --> X["실행 파일"]
end
subgraph 실행 때
X2["실행 파일"] --> LD["로더"]
LD --> M["프로세스 메모리"]
end
X -.- X2
가운데 점선으로 이은 둘은 같은 파일입니다. 빌드가 내놓은 결과를 실행이 입력으로 받습니다. 두 묶음이 만나는 곳이 실행 파일입니다.
공유 라이브러리는 실행 파일 안에 합쳐지지 않고 따로 남아 여러 프로그램이 같이 쓰는 파일입니다. 이런 라이브러리를 쓰는 프로그램에서는 링커가 모든 것을 잇지 못합니다. 라이브러리 함수의 주소는 그 라이브러리를 어디에 올릴지 정해야 알 수 있기 때문입니다. 그래서 실행 파일에는 주소 대신 이름만 적혀 있습니다.
그 이름을 주소로 바꾸는 일은 실행할 때 동적 링커가 합니다. 이 방식이 동적 링크입니다.
올리는 주체도 여기서 갈립니다. 커널이 동적 링커를 먼저 올립니다. 나머지 올리기는 동적 링커가 이어받습니다. 로더라는 이름이 커널 쪽과 동적 링커 쪽 양쪽에 걸쳐 쓰이는 까닭입니다.
명령을 친 뒤부터 프로그램의 첫 줄이 돌기까지를 주체별로 놓으면 이렇습니다.
sequenceDiagram
participant SH as 셸
participant K as 커널
participant DL as 동적 링커
participant P as 프로그램
SH->>K: 이 파일을 실행해 달라
K->>K: 새 프로세스를 만들고 세그먼트를 올린다
K->>DL: 동적 링커를 올리고 제어를 넘긴다
Note over K,DL: 여기까지가 커널이 맡는 올리기다
DL->>DL: 공유 라이브러리를 올린다
DL->>DL: 이름으로 적힌 참조를 주소로 바꾼다
DL->>P: 진입점으로 제어를 넘긴다
제어가 커널에서 동적 링커로 넘어가는 대목이 가운데에 있습니다. 「로더」라는 이름은 그 앞뒤 두 구간에 걸쳐 있습니다.
주소를 미리 못 박지 않는다
로더가 세그먼트를 어느 주소에 올릴지는 실행할 때 정해집니다. 처음부터 그랬던 것은 아닙니다. 옛 방식은 실행 파일에 절대 주소를 박아 두는 것이었습니다. 그러면 그 프로그램은 언제나 같은 주소에 올라가야 합니다.
여러 프로그램이 같은 주소를 원하면 곤란해집니다. 가상 메모리 덕분에 지금은 프로그램마다 자기만의 주소 공간을 갖습니다. 남의 프로그램과 주소가 겹칠 걱정은 사라졌습니다.
그런데도 올리는 주소를 매번 바꾸는 방식이 널리 쓰입니다. 이번에는 보안 때문입니다. 주소를 미리 알 수 없으면 공격 코드가 노릴 곳을 못 정합니다.
위치 독립 코드는 어느 주소에 올라가도 도는 코드입니다. 절대 주소 대신 「지금 돌고 있는 명령어에서 얼마만큼 떨어진 곳」으로 가리킵니다. 떨어진 거리는 어디에 올려도 안 변하기 때문에 이 방식이 통합니다.
위치 독립 코드로도 못 지우는 참조가 남습니다. 다른 파일에 있는 이름을 가리키는 참조는 여전히 주소를 모릅니다. 올리면서 정해진 주소를 그 참조에 채워 넣는 일이 재배치입니다.
「올리는 순서」의 다섯째 걸음이 이 재배치입니다. 앞 소절의 동적 링크도 여기에 듭니다. 공유 라이브러리의 이름을 주소로 바꾸는 것이 곧 그 참조를 채우는 일이기 때문입니다.
같은 프로그램을 서로 다른 기준 주소에 올려 보면 무엇이 변하고 무엇이 안 변하는지 드러납니다.
block-beta columns 2 h1["기준 주소 A 에 올렸을 때"] h2["기준 주소 B 에 올렸을 때"] a1["명령어"] a2["명령어"] b1["40바이트 뒤를 가리킨다"] b2["40바이트 뒤를 가리킨다"] c1["A+40 에 닿는다"] c2["B+40 에 닿는다"] d1["남는 참조 · A 기준으로 채워진다"] d2["남는 참조 · B 기준으로 채워진다"]
가운데 두 줄은 양쪽이 같습니다. 떨어진 거리가 안 변하기 때문입니다. 맨 아랫줄만 양쪽이 다릅니다. 재배치가 채우는 곳이 그 줄입니다.
필요해질 때 마저 읽는다
로더가 파일 전체를 메모리로 복사한다고 생각하기 쉽습니다. 대개는 그렇게 하지 않습니다. 「이 주소 범위는 이 파일의 이 구간에 해당한다」는 대응만 걸어 둡니다.
프로그램이 그 주소를 처음 건드릴 때 해당 페이지만 읽어 옵니다. 페이지는 메모리를 나눠 관리하는 고정 크기 단위입니다. 필요해진 부분만 이렇게 읽어 오는 것을 요구 페이징이라고 합니다.
덕분에 시작이 빨라집니다. 한 번도 안 쓰는 부분은 아예 안 읽힙니다. 그 대신 처음 건드리는 순간마다 짧게 멈춥니다. 이 멈춤이 페이지 폴트입니다.
로더가 실패하면
로더가 실패하면 프로그램은 첫 줄도 못 돌고 끝납니다. 코드에는 문제가 없습니다. 코드를 읽어도 원인이 안 보이는 실패입니다.
| 무엇이 어긋났나 | 어느 단계에서 드러나나 |
|---|---|
| 이 시스템이 아는 실행 파일 형식이 아니다 | 안내를 읽는 첫 단계 |
| 이름으로 적어 둔 라이브러리를 못 찾는다 | 라이브러리를 올리는 단계 |
| 올릴 공간이 모자라다 | 세그먼트를 올리는 단계 |
셋 다 빌드 때 링커가 이름을 못 찾아 실패하는 것(미정의 참조)과는 다른 실패입니다. 빌드는 성공했습니다. 파일도 멀쩡히 있습니다.
앞의 두 줄은 대개 실행하는 기계가 빌드한 기계와 달라서 납니다. 개발 기계에서 돌던 프로그램이 배포한 컨테이너 안에서 시작조차 안 되는 일이 여기 속합니다.
관련 항목
로더 앞뒤에 서는 빌드·실행 단계
컴파일러 · 어셈블러 · 링커 · 동적 링커 · 빌드 · 컴파일 타임 · 로드 타임 · 런타임 · 실행
로더가 읽어 들이는 파일
실행 파일 · 목적 파일 · 공유 라이브러리 · 정적 라이브러리 · ELF · Mach-O · PE · 클래스 파일
로더가 채우는 메모리 구조
메모리 · 주소 공간 · 가상 메모리 · 페이지 · 세그먼트 · 스택 · 힙 · 프로세스
로더가 올리면서 하는 작업
재배치 · 심볼 · 동적 링크 · 정적 링크 · 위치 독립 코드 · 요구 페이징 · 진입점
같은 이름으로 불리는 다른 로더
부트로더 · 클래스 로더 · 동적 로딩 · 번들러 · 인터프리터 · 가상 머신
로더를 부르는 명령과 시스템 콜
올리는 도중이나 직후에 나는 오류
세그멘테이션 폴트 · 페이지 폴트 · 미정의 참조 · 런타임 오류
다른 이름: loader · 적재기 · 프로그램 로더