[시스템프로그래밍] Library - Linking and Loading, Static and Shared Library, Library Interpositioning

 

라이브러리(Library)의 모든 것 - static, dynamic, and interpositioning

지난번에 컴파일 시스템의 전반적인 흐름을 살펴보면서, 우리가 작성한 소스 코드가 어떻게 컴퓨터가 이해하는 언어로 변환되는지 그 여정을 따라가 본 적이 있다. 컴파일러가 소스 파일을 목적 파일(.o)로 만들면, 링커(Linker)라는 친구가 이 조각들을 모아 실행 가능한 파일로 완성한다는 큰 그림을 그릴 수 있었다. 오늘은 그 과정의 핵심, 특히 라이브러리(Library) 라는 개념에 대해 low-level 관점에서 아주 깊이 있게 파헤쳐 보고자 한다. 라이브러리는 단순 코드모음 그 이상이다. 프로그램이 어떻게 빌드되고, 메모리에 올라가며, 심지어 실행 중에 어떻게 동작을 바꿀 수 있는지에 대한 비밀을 품고 있기 때문이다.




1. 링킹(Linking)과 오브젝트 파일 다시보기

라이브러리를 이해하려면 먼저 링커가 하는 일과 오브젝트 파일의 구조를 다시 한번 짚고 넘어갈 필요가 있다.

  • 오브젝트 파일 (.o, Relocatable Object File): 컴파일러(compiler&assembler)가 C 소스 파일(.c)을 번역한 결과물이다. 여기에는 기계어 코드, 데이터, 그리고 중요한 심볼(Symbol) 테이블재배치(Relocation) 정보가 담겨있다. 심볼은 함수 이름이나 전역 변수 이름 같은 것들이고, 재배치 정보는 "이 코드가 메모리의 X번지에 놓이면, 내부의 특정 주소 참조는 X+Y로 바꿔야 해" 같은 지침이다. 아직 최종 주소가 정해지지 않은 '반제품' 상태라고 할 수 있다.


  • 링커 (Linker, ld)의 역할:

    1. 심볼 해석 (Symbol Resolution): 여러 .o 파일이나 라이브러리 파일을 뒤져서, "main.c에서 부르는 printf 함수가 어디에 정의되어 있지?" 같은 질문에 답을 찾아 연결해준다. 각 파일이 참조하는 심볼을 실제 정의된 심볼과 짝지어주는 것이다.
      Symbol Resolution

    2. 재배치 (Relocation): 각 .o 파일은 자신이 0번지부터 시작한다고 가정하고 주소를 매겼지만, 실제 실행 파일에서는 각기 다른 위치에 배치된다. 링커는 이 상대 주소들을 최종 실행 파일 내의 절대 주소(또는 실행 시 결정될 주소에 대한 정보)로 수정해준다.
Relocating Code and Data

이러한 오브젝트 파일의 표준 형식(Standard binary format) 중 하나가 바로 ELF (Executable and Linkable Format)이다. 리눅스 등 유닉스 계열 시스템에서 널리 사용되며, 내부는 .text (코드), .data (초기화된 데이터), .bss (초기화 안 된 데이터, 공간만 차지), .rodata (읽기 전용 데이터), .symtab (심볼 테이블) 등 여러 섹션(Section)으로 나뉘어 있다. 라이브러리도 결국은 이런 .o 파일들의 묶음이거나, 실행파일(Executable object files a.out ) 및 공유라이브러리(Shared object files .o )를 통합한 형이기에 이 구조를 이해하는 것이 중요하다.

ELF Object File Format

ELF Object File Format (cont.)

위의 Linking 과정을 통해 생성된 실행가능한 파일은 로더(Loader)에 의해 디스크에서 메모리로 적재(loading)하여 CPU가 실행할 수 있도록 준비한다.

Loading Executable Object Files



2. 라이브러리(Library)란 무엇인가? 왜 사용하는 걸까?

드디어 오늘의 주인공, 라이브러리이다! 간단히 말해, 라이브러리는 자주 사용되는 함수나 데이터들을 미리 컴파일해서 모아 놓은 파일들의 집합이다. printf(), malloc() 같은 표준 함수들이 대표적인 예이다.

Why do we use library?


  • 사용 이유:
    • 코드 재사용성: 내가 만들지 않아도, 잘 만들어진 기능을 가져다 쓸 수 있다.
    • 모듈성: 프로그램을 기능 단위로 나누어 개발하고 관리하기 용이하다.
    • 컴파일 시간 단축: 이미 컴파일된 코드를 사용하므로, 전체 빌드 시간을 줄일 수 있다.

라이브러리는 크게 두 가지 형태로 우리 앞에 나타난다. 바로 정적 라이브러리(.a)공유 라이브러리(.so)이다. 이 둘의 동작 방식은 low-level에서 상당한 차이를 보이며, 시스템에 미치는 영향도 다르다.

Packaging Method



3. 정적 라이브러리 (.a): 실행 파일에 '복사 & 붙여넣기' 🧱

정적 라이브러리(Static Library).a 확장자(archive 파일)를 가지며, 여러 개의 오브젝트 파일(.o)들을 단순히 묶어 놓은 것이다. ar (archiver)라는 유틸리티를 사용해 만들 수 있다.

Creating Static  Libraries

  • Low-level 링킹 과정:

    1. 링커가 프로그램을 빌드할 때, 실행 파일이 필요로 하는 심볼(함수, 변수)이 정적 라이브러리 안에 있는지 찾아본다.
    2. 만약 libfoo.a 안에 있는 bar.o 파일에 필요한 심볼이 있다면, 링커는 bar.o 파일의 내용 전체(코드 및 데이터)를 실행 파일 안으로 복사해온다. 라이브러리 내의 모든 .o 파일이 아니라, 실제로 사용되는 심볼을 포함한 .o 파일 단위로 복사된다.
    3. 복사된 코드는 실행 파일의 다른 코드들과 마찬가지로 재배치 과정을 거쳐 최종 주소가 결정된다.
      Linking with Static Libraries

  • 장점:

    • 실행 파일 하나에 필요한 모든 코드가 포함되므로 (standalone), 해당 라이브러리가 시스템에 설치되어 있지 않아도 프로그램 실행에 문제가 없다. 배포가 간편하다.
    • 런타임에 추가적인 작업(심볼 찾기 등)이 없어 약간의 속도 이점이 있을 수 있다 (요즘은 미미한 수준).
  • 단점:

    • 라이브러리 코드가 실행 파일마다 중복해서 들어가므로, 실행 파일 크기가 커진다.
    • 여러 프로그램이 동일한 정적 라이브러리를 사용하더라도, 각 프로그램의 메모리 공간에 라이브러리 코드가 별도로 로드된다. 이는 메모리 낭비로 이어진다.
    • 라이브러리에 버그 수정이나 업데이트가 발생하면, 해당 라이브러리를 사용한 모든 프로그램을 다시 링크해야 한다.

  • 링킹 예시 및 주의점:

    Warning: Command line order

    한 가지 중요한 점은 링커가 라이브러리를 스캔하는 순서이다. 일반적으로 링커는 심볼 참조가 나타난 후에 그 심볼을 라이브러리에서 찾는다. 따라서 libtest.o가 libvector.a의 함수를 사용한다면, libtest.o 다음에 libvector.a를 명시하는 것이 안전하다. 순서가 바뀌면 "undefined reference" 에러를 만날 수 있다.

    (gcc –static –o p ./libvector.a libtest.o 보다는 gcc –static –o p libtest.o ./libvector.a 가 일반적이다.)




4. 공유 라이브러리 (.so): 필요할 때 빌려 쓰고, 메모리는 함께 쓰는 지혜 🤝

공유 라이브러리(Shared Library) 또는 동적 라이브러리(Dynamic Library).so 확장자(shared object)를 가진다. 이름에서 알 수 있듯이, 이 라이브러리는 여러 프로그램이 메모리 상에서 공유해서 사용할 수 있도록 설계되었다.


  • Low-level 링킹 및 로딩 과정: 정적 라이브러리와는 사뭇 다르다.

    1. 빌드 시 (컴파일 타임 링킹):

      • 링커(ld)가 main.c를 컴파일한 main.o와 공유 라이브러리 libfoo.so를 링크할 때, libfoo.so의 코드를 실행 파일에 복사하지 않는다.
      • 대신, 실행 파일에는 "나는 libfoo.so 라이브러리가 필요하고, 그 안의 bar 함수를 사용할 거야" 라는 참조 정보만 기록해둔다. (실행 파일의 .interp 섹션에는 동적 링커의 경로가, .dynamic 섹션에는 필요한 라이브러리 목록 등이 기록된다.)


    2. 실행 시 (로드 타임 동적 링킹):

      • 사용자가 ./my_program을 실행하면, 커널의 로더(Loader) (주로 execve 시스템 콜의 일부)가 실행 파일을 메모리에 올린다.
      • 로더는 실행 파일 헤더를 보고 이 프로그램이 동적 링킹을 필요로 함을 인지하고, 제어권을 동적 링커(Dynamic Linker) (리눅스에서는 주로 ld-linux.so)에게 넘긴다.
      • 동적 링커는 실행 파일에 기록된 정보를 바탕으로 필요한 공유 라이브러리들(예: libfoo.so, libc.so)을 찾는다. (표준 경로 /lib, /usr/lib 또는 LD_LIBRARY_PATH 환경 변수 등을 참조)
      • 해당 라이브러리가 아직 메모리에 없다면 메모리에 로드하고, 이미 다른 프로그램에 의해 로드되어 있다면 그 메모리 공간을 공유한다 (코드 세그먼트).
      • 그 후, 동적 링커는 라이브러리 내 심볼들의 실제 메모리 주소를 결정하고, 프로그램 코드 내의 참조 부분(예: bar 함수 호출 부분)을 이 실제 주소로 연결(재배치)하는 작업을 수행한다. 이 과정이 바로 동적 링킹이다.


    3. 실행 중 동적 링킹 (Run-time Dynamic Linking):

      • 프로그램이 실행 중에 필요에 따라 명시적으로 공유 라이브러리를 로드하고 사용할 수도 있다. C에서는 dlfcn.h 헤더에 정의된 함수들을 사용한다.
        • dlopen(): 지정된 .so 파일을 메모리에 로드하고 핸들(handle)을 반환한다.
        • dlsym(): 로드된 라이브러리 핸들과 심볼 이름을 받아, 해당 심볼의 주소를 반환한다. (예: 함수 포인터 획득)
        • dlclose(): 라이브러리 사용이 끝나면 메모리에서 언로드한다.
      • 이는 플러그인(plug-in) 아키텍처나 프로그램 시작 시 모든 라이브러리를 로드할 필요가 없는 경우에 유용하다.
  • 장점:

    • 실행 파일 크기가 작아진다 (라이브러리 코드가 포함되지 않으므로).
    • 메모리 효율성: 여러 프로그램이 동일한 공유 라이브러리를 사용할 경우, 라이브러리의 코드 부분은 메모리에 한 번만 로드되어 공유된다. 이는 시스템 전체의 메모리 사용량을 크게 줄여준다.
    • 유지보수 용이성: 공유 라이브러리에 버그 수정이나 기능 개선이 이루어지면, 해당 .so 파일만 새 버전으로 교체하면 된다. 이 라이브러리를 사용하는 모든 프로그램은 재컴파일/재링크 없이도 즉시 혜택을 볼 수 있다 (단, ABI 호환성이 유지되어야 함).
  • 단점:

    • 런타임 의존성: 프로그램 실행 시 해당 .so 파일이 시스템에 없거나 버전이 맞지 않으면 프로그램이 실행되지 않는다 (일명 "DLL Hell"의 유닉스 버전).
    • 로드 타임에 동적 링킹 작업으로 인해 아주 약간의 시작 시간 오버헤드가 발생할 수 있다.
  • 프로세스 메모리 레이아웃과 공유 라이브러리:

    실행 파일이 로드될 때, 그 프로세스의 가상 메모리 공간에는 코드(.text), 데이터(.data, .bss), 힙(heap), 스택(stack) 영역 등이 배치된다. 공유 라이브러리는 이 가상 주소 공간의 특정 영역(주로 스택과 힙 사이 또는 다른 예약된 공간)에 매핑된다. 각 프로세스는 자신만의 가상 주소 공간을 가지지만, 공유 라이브러리의 물리 메모리 페이지(특히 코드 페이지)는 여러 프로세스에 의해 공유될 수 있다.

Memory Layout for Shared Library




5. 라이브러리와 심볼 해석(Symbol Resolution) 심층 탐구

링커의 주요 임무 중 하나인 심볼 해석은 라이브러리와 함께 사용할 때 더욱 흥미로운 양상을 띤다. 링커는 심볼을 StrongWeak 두 가지 유형으로 구분한다.

  • Strong Symbol: 함수 정의나 초기화된 전역 변수.
  • Weak Symbol: 초기화되지 않은 전역 변수. (또는 특정 컴파일러 확장으로 명시된 weak 심볼)
Symbol

링커는 다음 규칙에 따라 심볼 충돌을 해결한다:

  1. 같은 이름의 Strong 심볼이 여러 개 있으면 링크 에러! (중복 정의)
  2. Strong 심볼 하나와 같은 이름의 Weak 심볼 여러 개가 있으면 Strong 심볼이 선택된다.
  3. 같은 이름의 Weak 심볼만 여러 개 있으면, 링커가 그중 하나를 임의로 선택한다 (이 경우 어떤 것이 선택될지 예측하기 어려워 주의해야 한다).

라이브러리를 사용할 때, 만약 내 프로그램 코드(main.o)와 내가 링크하는 라이브러리(libfoo.a 또는 libfoo.so) 모두에 같은 이름의 전역 함수 my_func가 정의되어 있다면 어떻게 될까?

  • 정적 라이브러리: 일반적으로 링커는 .o 파일을 먼저 보고, 거기서 해결되지 않은 심볼을 라이브러리에서 찾는다. 만약 main.omy_func가 Strong 심볼로 이미 있다면, 라이브러리의 my_func는 무시될 가능성이 높다. (세부 동작은 링커와 옵션에 따라 다를 수 있다).
  • 공유 라이브러리: 동적 링커의 규칙이 적용된다. LD_PRELOAD 같은 메커니즘(아래에서 설명)을 통해 특정 라이브러리의 심볼이 우선권을 갖도록 할 수도 있다.

.h 헤더 파일의 역할도 중요하다. 헤더 파일은 주로 함수 선언(extern int func(void);)이나 외부 변수 선언(extern int global_var;)을 포함한다. 이는 컴파일러에게 "이런 이름의 함수나 변수가 어딘가에 정의되어 있으니, 일단 이름과 타입만 알고 있어!"라고 알려주는 역할을 한다. 실제 정의(코드 본체나 메모리 할당)는 .c 파일이나 라이브러리 파일에 있고, 링커가 이들을 연결해준다.

Symbol Linking Examples




6. 라이브러리 인터포지셔닝: 라이브러리 함수 호출 가로채기의 마법 ✨

라이브러리 인터포지셔닝(Library Interpositioning)은 기존 라이브러리에 있는 함수 호출을 가로채서, 우리가 만든 커스텀 함수를 대신 실행하도록 하는 아주 강력하고 흥미로운 기술이다. 디버깅, 성능 분석, 보안 강화 등 다양한 목적으로 활용될 수 있다.

mallocfree 함수를 가로채는 예시를 통해 인터포지셔닝이 발생하는 세 가지 상황을 알아보겠다.

  1. 컴파일 타임 인터포지셔닝 (Compile-time Interpositioning):

    • 원리: C 전처리기 매크로(#define)를 사용한다.
    • 작동 방식: 소스 코드에서 malloc(size) 호출을 컴파일 전에 mymalloc(size, __FILE__, __LINE__) 같은 우리만의 함수 호출로 바꿔치기한다.
    • 특징: 소스 코드를 직접 수정하거나 컴파일 옵션을 통해 전처리 단계에서 변경이 일어난다. 라이브러리 자체를 건드리는 것이 아니라, 라이브러리 함수를 호출하는 부분을 바꾸는 것이다.
      Compile-time Interpositioning

  2. 링크 타임 인터포지셔닝 (Link-time Interpositioning):

    • 원리: 정적 링커(ld)의 기능을 활용한다 (예: GNU ld--wrap symbol 옵션).
    • 작동 방식: gcc ... -Wl,--wrap,malloc ... 와 같이 옵션을 주면, 코드 내의 모든 malloc 심볼에 대한 참조는 __wrap_malloc 함수 호출로 변경된다. 그리고 우리가 __wrap_malloc 함수를 정의하여 원하는 작업을 수행한 후, 실제 malloc 함수를 호출하고 싶다면 __real_malloc을 호출하면 된다. (링커가 __real_malloc을 원래 malloc으로 연결해준다).
    • 특징: 소스 코드 수정 없이, 링크 시점에 심볼 참조를 재지정한다. 정적 라이브러리나 실행 파일 내 다른 오브젝트 파일과의 관계에서 주로 사용된다.
      Link-time Interpositioning


  3. 런타임 인터포지셔닝 (Run-time Interpositioning):

    • 원리: 동적 링커(ld-linux.so)의 기능을 활용한다 (주로 LD_PRELOAD 환경 변수).
    • 작동 방식: LD_PRELOAD=/path/to/my_malloc_lib.so ./my_program 과 같이 환경 변수를 설정하고 프로그램을 실행하면, 동적 링커는 my_malloc_lib.so를 다른 표준 라이브러리(예: libc.so)보다 먼저 로드한다. 만약 my_malloc_lib.so 안에 malloc이라는 이름의 함수가 정의되어 있다면, 프로그램이 malloc을 호출할 때 표준 libc.somalloc 대신 my_malloc_lib.somalloc이 호출된다!
    • 우리 커스텀 malloc 함수 내에서 원래의 malloc 함수를 호출하고 싶다면, dlsym(RTLD_NEXT, "malloc") 함수를 사용하여 다음 순서로 로드될 라이브러리(이 경우 libc.so)에 있는 malloc 함수의 주소를 얻어와 호출할 수 있다.
    • 특징: 프로그램 재컴파일이나 재링크 없이, 실행 시점에 라이브러리 함수의 동작을 변경할 수 있다. 매우 유연하고 강력한 방법이다.
      Run-time Interpositioning

이 세 가지 인터포지셔닝 기법은 각각 다른 시점(컴파일, 링크, 실행)에 개입하며, 각각의 장단점과 적용 분야가 있다. 시스템 프로그래머라면 이러한 도구들을 적재적소에 활용할 수 있어야 한다.




7. 결론: 라이브러리, 시스템을 떠받치는 보이지 않는 기둥

오늘 우리는 링킹과 로딩의 맥락에서 라이브러리가 시스템 프로그래밍에서 얼마나 중요한 역할을 하는지 깊이 있게 살펴보았다. 정적 라이브러리는 실행 파일에 코드를 직접 심어 안정적인 실행을 보장하는 반면, 공유 라이브러리는 메모리 효율성과 유지보수 용이성이라는 강력한 이점을 제공한다.

특히 공유 라이브러리가 동적 링커에 의해 메모리에 로드되고, 심볼이 해석되며, 여러 프로세스 간에 코드가 공유되는 과정은 그 자체로 경이로운 메커니즘이다. 또한, 라이브러리 인터포지셔닝 같은 고급 기법을 통해 이미 존재하는 라이브러리의 동작까지 제어할 수 있다는 점은 low-level 프로그래밍의 매력을 한껏 느끼게 해준다.

이처럼 라이브러리의 내부 동작 원리를 이해하는 것은 단순히 코드를 가져다 쓰는 것을 넘어, 프로그램의 성능을 최적화하고, 문제를 해결하며, 더 견고하고 효율적인 시스템을 만드는 데 필수적인 밑거름이 될 것이다.



추천글:
hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2