Linux ELF, Shared Library, 그리고 ld.so의 동작 원리

6 minute read

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_NEEDEDPT_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.soDT_NEEDED에 기록된 라이브러리를 탐색하는 공식 우선순위는 다음과 같습니다.

  1. DT_RPATH: ELF에 기록된 오래된 탐색 경로 (단, DT_RUNPATH가 없을 때만 유효)
  2. LD_LIBRARY_PATH: 사용자가 환경변수로 명시한 임시 라이브러리 탐색 경로 (보안을 위해 setuid 프로그램에서는 무시됨)
  3. DT_RUNPATH: ELF에 기록된 권장 탐색 경로 ($ORIGIN 등을 활용해 실행 파일 기준의 상대 경로 지정 가능)
  4. /etc/ld.so.cache: 시스템 전체 라이브러리 경로 캐시 파일 (ldconfig 명령을 통해 갱신)
  5. 시스템 기본 경로: /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
  • Q2. file 명령어는 dynamically linked라고 하는데 lddstatically linked라고 합니다. 모순 아닌가요?
    • A: 동적 로더(ld.so)를 검사할 때 주로 이런 결과가 나옵니다. file은 ELF 헤더 구조를 보고 ET_DYN(동적 포맷)인지를 판별하므로 dynamically linked로 표시합니다. 반면 ldd는 이 바이너리가 다른 외부 라이브러리(DT_NEEDED)를 필요로 하는지 보는데, ld.so는 외부 의존성 없이 독자 실행 가능하므로 statically linked로 보고합니다.
  • Q3. 동적 로더는 원래 프로그램이 실행된 이후 메모리에서 사라지나요?
    • A: 아닙니다. 로더는 프로세스 주소 공간에 상주하며, 프로그램 실행 중에 호출되는 dlopen(), dlsym() 등의 런타임 동적 로딩 요청을 계속해서 처리하는 런타임 라이브러리 역할을 병행합니다.

10. 결론 (Takeaways)

Linux의 동적 실행 과정은 결국 한 문장으로 요약할 수 있습니다.

“OS 커널이 프로그램 내부의 PT_INTERP 세그먼트를 보고 동적 로더(ld.so)를 메모리에 매핑하여 실행하면, ld.so가 자신을 스스로 부트스트랩한 뒤 DT_NEEDED에 적힌 공유 라이브러리들을 메모리에 올리고 주소를 연결(재배치 및 심볼 해결)한 후, 원래 프로그램의 진짜 진입점으로 제어권을 넘겨준다.”

이 일련의 과정을 이해하면 런타임 라이브러리 미검출 문제(Not Found)나 버전 충돌 문제를 훨씬 유연하고 명확하게 디버깅할 수 있습니다.

Reference

Leave a comment