Linux ELF, Shared Library, 그리고 ld.so의 동작 원리
Linux 환경에서 C/C++ 등으로 빌드된 프로그램을 실행하면, 단순히 실행 파일의 기계어 코드가 CPU에 바로 적재되어 실행되는 것처럼 느껴집니다. 하지만 그 이면에는 컴파일러, 링커, 운영체제 커널, 그리고 동적 로더(ld.so)가 협력하여 작동하는 복잡한 과정이 존재합니다.
이 글에서는 Linux의 바이너리 포맷인 ELF의 구조부터 시작하여 정적/동적 실행 파일의 차이, 공유 라이브러리의 링크 및 로딩 방식, 그리고 동적 로더가 스스로를 로딩하여 프로그램을 시작하는 내부 원리까지 알기 쉽게 정리합니다.
1. Hello World 뒤에서 일어나는 일
간단한 C 언어 소스 코드를 통해 실행 과정의 의문을 짚어봅시다.
#include <stdio.h>
int main(void)
{
printf("Hello World!\n");
return 0;
}
이 코드를 컴파일하고 실행합니다.
gcc main.c -o myapp
./myapp
여기서 근본적인 질문을 던질 수 있습니다. 우리는 printf() 함수를 직접 구현하지 않았는데, 프로그램은 이 함수를 어떻게 찾아서 실행하는 것일까요?
정답은 C 표준 라이브러리인 libc에 있습니다. 일반적으로 Linux에서 gcc로 컴파일하면, 컴파일러는 기본적으로 시스템의 표준 공유 라이브러리와 프로그램을 연결합니다. 실행 파일 내부에는 printf()의 실제 기계어 코드가 포함되는 대신, “실행할 때 C 표준 라이브러리(libc.so)가 필요하다”는 정보만 기록됩니다.
2. ELF(Executable and Linkable Format)란?
Linux에서 실행 파일, 공유 라이브러리, 오브젝트 파일 등은 모두 ELF(Executable and Linkable Format)라는 통일된 구조의 바이너리 형식을 사용합니다.
ELF 포맷의 패밀리
├─ 오브젝트 파일 (.o) : 컴파일 결과물로, 아직 링크가 필요한 상태
├─ 정적 라이브러리 (.a) : 오브젝트 파일들을 묶어놓은 아카이브
├─ 공유 라이브러리 (.so) : 런타임에 동적으로 공유하여 로딩될 수 있는 객체
├─ 실행 파일 (Executable) : 커널이 직접 로딩하여 실행할 수 있는 바이너리
└─ 동적 로더 (ld.so) : 동적 실행 파일을 준비하고 실행해 주는 특수 ELF
커널은 실행 파일인지 공유 라이브러리인지 판단할 때 파일의 확장자(.so, .out 등)를 보지 않고, ELF 헤더 내부에 정의된 타입 정보(e_type)와 세그먼트 속성을 참조하여 동작을 결정합니다.
3. 정적 실행 파일(Static) vs 동적 실행 파일(Dynamic)
바이너리가 생성될 때 외부 라이브러리를 결합하는 방식에 따라 정적 실행 파일과 동적 실행 파일로 구분됩니다.
| 비교 항목 | 정적 실행 파일 (Static Executable) | 동적 실행 파일 (Dynamic Executable) |
|---|---|---|
| 링크 시점 | 컴파일/링크 타임에 모든 라이브러리를 실행 파일 내부에 통합 | 런타임(실행 시점)에 공유 라이브러리를 동적으로 연결 |
| 파일 크기 | 매우 큼 (라이브러리 코드가 내포됨) | 매우 작음 (외부 라이브러리 정보만 기록) |
| 메모리 효율 | 동일 라이브러리를 쓰는 프로그램마다 메모리가 중복 점유됨 | 물리 메모리 상의 공유 라이브러리 페이지를 여러 프로세스가 공유 |
| 실행 성능 | 런타임 주소 해결 과정이 없어 초기 시작 속도가 미세하게 빠름 | 런타임에 주소 재배치(Relocation) 및 심볼 탐색 오버헤드 존재 |
| 이식성/독립성 | 라이브러리 의존성이 없어 동일 아키텍처라면 어디서든 실행 가능 | 실행 환경에 정확한 버전의 .so 파일들이 존재해야 함 |
| 커널 흐름 | PT_INTERP 세그먼트가 없으며, 커널이 진입점(Entry Point)으로 바로 점프 |
PT_INTERP가 가리키는 동적 로더(ld.so)를 커널이 먼저 실행 |
4. 라이브러리의 분류: 정적 라이브러리 vs 공유 라이브러리
라이브러리 파일 자체도 링크 방식에 대응하여 두 가지로 나뉩니다.
4.1 정적 라이브러리 (.a)
- 여러 오브젝트 파일(
.o)을 단일 아카이브 파일로 묶은 형태입니다. - 링커가 필요한 오브젝트만 추출하여 실행 파일 내부에 복사해 넣습니다.
- 장점: 실행 파일이 생성되면 독자적으로 실행 가능합니다.
- 단점: 라이브러리에 버그가 있어서 수정하려면 실행 파일을 다시 컴파일하고 링크해야 합니다.
4.2 공유 라이브러리 (.so)
- 메모리에 로딩되어 여러 프로세스에서 공유될 목적으로 빌드된 바이너리입니다.
- 위치 독립 코드(PIC, Position Independent Code)로 컴파일되어 메모리의 어느 주소에 로딩되더라도 정상 작동합니다.
- 장점: 라이브러리 수정 시 실행 파일을 재컴파일하지 않고
.so파일만 교체할 수 있어 유지보수가 용이하며 메모리가 절약됩니다.
5. 공유 라이브러리를 사용하는 두 가지 메커니즘
동적 실행 파일이 공유 라이브러리를 사용하는 방식은 두 가지로 나뉩니다.
공유 라이브러리 로딩 메커니즘
├─ 1. 암묵적 로딩 (Implicit Linking)
│ └─ 빌드할 때 링커에 라이브러리를 지정하여, 프로그램 시작 시 동적 로더가 자동으로 로딩함 (e.g., DT_NEEDED 기록)
└─ 2. 명시적 로딩 (Explicit Linking / Dynamic Loading)
└─ 프로그램 실행 중에 코드 내에서 직접 API를 호출하여 라이브러리를 불러옴 (e.g., dlopen(), dlsym() 사용)
5.1 명시적 로딩 예시 코드
#include <dlfcn.h>
void* handle = dlopen("libmath.so", RTLD_LAZY);
double (*cosine)(double) = dlsym(handle, "cos");
double val = cosine(2.0);
dlclose(handle);
이 방식은 프로그램의 기동 속도를 높이거나 플러그인 아키텍처를 구현할 때 널리 쓰입니다.
6. DT_NEEDED와 PT_INTERP
동적 실행 파일이 실행될 때 필수적인 두 가지 메타데이터가 ELF 헤더 및 세그먼트에 기록됩니다.
6.1 DT_NEEDED (Dynamic Tag - Needed)
- 실행 파일이 런타임에 반드시 불러와야 하는 공유 라이브러리의 이름을 명시합니다.
- 전체 경로가 아닌 이름(예:
libc.so.6)만 저장되는 것이 일반적이며, 실제 경로는 런타임에 동적 로더가 탐색하여 결정합니다. - 확인 방법:
readelf -d myapp | grep NEEDED
6.2 PT_INTERP (Program Header - Interpreter)
- 커널이 이 동적 실행 파일을 로딩할 때, 제어권을 가장 먼저 넘겨줄 동적 로더(인터프리터)의 경로를 저장합니다.
- 일반적으로
/lib64/ld-linux-x86-64.so.2와 같은 파일 경로가 하드코딩되어 있습니다. - 확인 방법:
readelf -l myapp | grep -A1 INTERP
[!NOTE] 두 태그의 직관적 차이
PT_INTERP: “이 프로그램을 실행할 준비를 해줄 로더는 누구인가?” (단 하나만 존재)DT_NEEDED: “이 프로그램이 실행되면서 가져다 쓸 함수들이 담긴 라이브러리는 무엇인가?” (여러 개 존재 가능)
7. 동적 로더(ld.so)의 동작 과정
동적 실행 파일이 커널에 의해 실행(execve)되면 다음과 같은 흐름으로 프로그램이 준비됩니다.
1. 커널이 실행 파일 로딩 후 PT_INTERP 정보를 읽어 지정된 ld.so를 메모리에 매핑
2. 커널이 제어권을 ld.so의 Entry Point로 이동시킴
3. ld.so의 자기 부트스트랩 (Self-Bootstrap) 수행
4. 원래 실행 파일의 ELF 헤더 및 DT_NEEDED에 명시된 라이브러리 로딩
5. 로딩된 바이너리들의 주소 재배치 (Relocation) 및 심볼 해결 (Symbol Resolution)
6. 공유 라이브러리의 생성자(Constructor) 및 초기화 코드 실행
7. 원래 실행 파일의 진입점(_start -> main())으로 제어를 넘겨 프로그램 시작
7.1 동적 로더의 자기 부트스트랩 (Self-Bootstrap)
동적 로더 자신도 공유 라이브러리 구조(ET_DYN)를 가진 ELF 파일입니다. 따라서 실행될 때 메모리 주소가 고정되어 있지 않아 주소 재배치가 필요합니다.
그러나 자신을 재배치해 줄 다른 로더가 없기 때문에, ld.so는 내부적으로 외부 라이브러리나 전역 변수를 참조하지 않는 극히 제한적인 부트스트랩 코드를 직접 실행하여 자기 자신의 메모리 주소를 스스로 계산하고 보정합니다.
7.2 라이브러리 탐색 순서
ld.so가 DT_NEEDED에 기록된 라이브러리를 탐색하는 공식 우선순위는 다음과 같습니다.
DT_RPATH: ELF에 기록된 오래된 탐색 경로 (단,DT_RUNPATH가 없을 때만 유효)LD_LIBRARY_PATH: 사용자가 환경변수로 명시한 임시 라이브러리 탐색 경로 (보안을 위해 setuid 프로그램에서는 무시됨)DT_RUNPATH: ELF에 기록된 권장 탐색 경로 ($ORIGIN등을 활용해 실행 파일 기준의 상대 경로 지정 가능)/etc/ld.so.cache: 시스템 전체 라이브러리 경로 캐시 파일 (ldconfig명령을 통해 갱신)- 시스템 기본 경로:
/lib,/usr/lib,/lib64,/usr/lib64등
8. 실전 분석 및 유용한 명령어들
8.1 의존성 및 실제 로딩 경로 확인 (ldd)
ldd ./myapp
ldd는 단순히 바이너리 구조를 읽는 것이 아니라, 환경변수 LD_TRACE_LOADED_OBJECTS=1을 설정하고 동적 로더를 구동시켜 어떤 실제 경로의 라이브러리가 로딩되는지 모사하여 출력해 주는 도구입니다.
8.2 바이너리 메타데이터 수정 (patchelf)
소스 코드가 없는 상태에서 바이너리의 동적 설정을 변경할 수 있는 강력한 도구입니다.
-
인터프리터(동적 로더) 변경:
patchelf --set-interpreter /custom/ld.so ./myapp -
RUNPATH 설정 (실행 파일 경로 기준 상대 경로로 라이브러리 탐색):
patchelf --set-rpath '$ORIGIN/lib' ./myapp -
의존성 라이브러리 이름 교체:
patchelf --replace-needed libold.so libnew.so ./myapp
9. 흔히 하는 오해 바로잡기
- Q1.
.so파일은 직접 실행할 수 없나요?- A: 아닙니다. 파일 확장자는 힌트일 뿐이며, ELF 구조 내부에 실행 가능한 진입점(
Entry Point)이 구현되어 있다면 실행할 수 있습니다. 대표적인 예로 동적 로더인/lib64/ld-linux-x86-64.so.2를 실행 인자로 프로그램을 주면 실행 권한이 없는 바이너리도 우회하여 실행할 수 있습니다. - 예:
/lib64/ld-linux-x86-64.so.2 ./myapp
- A: 아닙니다. 파일 확장자는 힌트일 뿐이며, ELF 구조 내부에 실행 가능한 진입점(
- Q2.
file명령어는dynamically linked라고 하는데ldd는statically linked라고 합니다. 모순 아닌가요?- A: 동적 로더(
ld.so)를 검사할 때 주로 이런 결과가 나옵니다.file은 ELF 헤더 구조를 보고ET_DYN(동적 포맷)인지를 판별하므로dynamically linked로 표시합니다. 반면ldd는 이 바이너리가 다른 외부 라이브러리(DT_NEEDED)를 필요로 하는지 보는데,ld.so는 외부 의존성 없이 독자 실행 가능하므로statically linked로 보고합니다.
- A: 동적 로더(
- Q3. 동적 로더는 원래 프로그램이 실행된 이후 메모리에서 사라지나요?
- A: 아닙니다. 로더는 프로세스 주소 공간에 상주하며, 프로그램 실행 중에 호출되는
dlopen(),dlsym()등의 런타임 동적 로딩 요청을 계속해서 처리하는 런타임 라이브러리 역할을 병행합니다.
- A: 아닙니다. 로더는 프로세스 주소 공간에 상주하며, 프로그램 실행 중에 호출되는
10. 결론 (Takeaways)
Linux의 동적 실행 과정은 결국 한 문장으로 요약할 수 있습니다.
“OS 커널이 프로그램 내부의
PT_INTERP세그먼트를 보고 동적 로더(ld.so)를 메모리에 매핑하여 실행하면,ld.so가 자신을 스스로 부트스트랩한 뒤DT_NEEDED에 적힌 공유 라이브러리들을 메모리에 올리고 주소를 연결(재배치 및 심볼 해결)한 후, 원래 프로그램의 진짜 진입점으로 제어권을 넘겨준다.”
이 일련의 과정을 이해하면 런타임 라이브러리 미검출 문제(Not Found)나 버전 충돌 문제를 훨씬 유연하고 명확하게 디버깅할 수 있습니다.
Leave a comment