리눅스 동적 라이브러리 분석

처리 과정과 API

M. Tim Jones, 컨설턴트 엔지니어, Emulex Corp.

요약: 동적으로 링크된 공유 라이브러리는 GNU/리눅스(Linux®)에서 중요한 측면입니다. 공유 라이브러리는 실행 시점에서 동적으로 외부 함수에 접근하도록 만들기에 (필요할 때만 기능을 사용하는 방법으로) 전반적인 메모리 사용량을 줄입니다. 이 기사는 동적 라이브러리를 생성하고 활용하는 과정을 검토하며, 동적 라이브러리를 살피는 다양한 도구를 상세히 알아보고, 라이브러리 동작 원리를 속속들이 살펴봅니다.

원문 게재일:  2008 년 8 월 20 일 번역 게재일:   2008 년 12 월 30 일
난이도:  중급 영어로:  보기
페이지뷰: 1066 회
의견: 0 (의견 추가)

1 star2 stars3 stars4 stars5 stars 평균 평가 등급 (총 4표)

라이브러리는 비슷한 기능을 단일 유닛으로 묶어놓는 설계 기법이다. 이런 유닛은 다른 개발자와 공유할 수 있으며 모듈 프로그램이라고 부르는 개발 방법이 가능하게 만들었다. 리눅스는 두 가지 라이브러리 유형을 지원한다. 각 유형마다 장단점이 있다. 정적 라이브러리는 컴파일 시점에서 정적으로 프로그램에 결합된 기능을 포함한다. 이는 응용 프로그램이 메모리에 올라올 때 함께 올라와 실행 시점에서 결합이 이뤄지는 동적 라이브러리와는 사뭇 다르다. 그림 1은 리눅스에서 라이브러리 계층을 보여준다.


그림 1. 리눅스에서 라이브러리 계층
리눅스에서 라이브러리 계층

실행 중에 동적으로 링크하거나 프로그램 제어하에 동적으로 메모리에 올려서 활용하는 등 공유 라이브러리를 여러 가지 방법으로 활용할 수 있다. 이 기사는 두 가지 방법 모두를 소개한다.

정적 라이브러리는 기능이 최소로 필요한 작은 프로그램에 유리하다. 여러 라이브러리가 필요한 프로그램이라면, 공유 라이브러리가 프로그램이 요구하는 메모리 사용량(디스크 공간과 실행 중 메모리 관점에서)을 줄일 수 있다. 이렇게 되는 이유는 여러 프로그램이 동시에 공유 라이브러리를 활용할 수 있기 때문이다. 따라서 라이브러리를 하나만 메모리에 올리면 된다. 정적 라이브러리를 사용하면, 실행하는 프로그램마다 라이브러리를 독자적으로 메모리에 올려야 한다.

GNU/리눅스는 공유 라이브러리를 다루는 두 가지 방법을 제공한다(각 메서드는 썬 솔라리스 운영체제에서 따 왔다). 공유 라이브러리로 프로그램을 동적으로 링크해 (이미 메모리에 올라와 있지 않을 때) 실행 중에 리눅스가 라이브러리를 메모리에 올리도록 만드는 방법이다. 대안으로 동적 적재라는 과정을 거쳐 라이브러리 내부에 들어있는 함수를 선택적으로 프로그램이 호출하게 한다. 동적 적재를 사용하면, 프로그램은 (이미 메모리에 올라와 있지 않을 때) 특정 라이브러리를 메모리에 올린 다음에 라이브러리 내부에 존재하는 특정 함수를 호출할 수 있다(그림 2는 두 가지 방법을 보여준다). 이는 플러그인을 지원하는 응용 개발 과정에서 일반적으로 나타나는 활용 패턴이다. 동적 적재용 API를 살펴보고 이 기사 후반부에 예제를 보여주겠다.


그림 2. 정적 링킹 대 동적 링킹
정적 링킹 대 동적 링킹

리눅스에서 사용하는 동적 링킹

그렇다면 리눅스에서 동적 공유 라이브러리 활용 방안을 살펴보자. 사용자가 응용 프로그램을 시동할 때, ELF(Executable and Linking Format) 이미지를 수행한다. 커널은 ELF 이미지를 사용자 영역 가상 메모리에 올리는 과정부터 시작한다. 커널은 Listing 1에서 보여주는 듯이 동적 링크 사용(/lib/ld-linux.so)을 알려주는 .interp라는 ELF 섹션에 주목한다. 이는 유닉스에서 스크립트 파일을 위한 인터프리터 정의(#!/bin/sh, shebang)와 흡사하다. 단지 사용하는 맥락이 다를 뿐이다.


Listing 1. readelf를 사용해 프로그램 헤더 보기
	
mtj@camus:~/dl$ readelf -l dl

Elf file type is EXEC (Executable file)
Entry point 0x8048618
There are 7 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
  INTERP         0x000114 0x08048114 0x08048114 0x00013 0x00013 R   0x1
      [Requesting program interpreter: /lib/ld-linux.so.2]
  LOAD           0x000000 0x08048000 0x08048000 0x00958 0x00958 R E 0x1000
  LOAD           0x000958 0x08049958 0x08049958 0x00120 0x00128 RW  0x1000
  DYNAMIC        0x00096c 0x0804996c 0x0804996c 0x000d0 0x000d0 RW  0x4
  NOTE           0x000128 0x08048128 0x08048128 0x00020 0x00020 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x4

  ...

mtj@camus:~dl$

ld-linux.so 자체도 ELF 공유 라이브러리라는 사실에 주목하자. 하지만 ld-linux.so는 정적으로 컴파일되어 있기에 공유 라이브러리 의존성이 없다. 동적 링킹이 필요하면, 커널은 동적 링커(ELF 해석기)를 부트스트랩해, 자신을 초기화한 다음에 (이미 메모리에 올라와 있지 않을 경우) 지정된 공유 목적 파일을 메모리에 올린다. 그러고 나서 지정된 공유 목적 파일이 다시 내부에서 사용하는 공유 목적 파일을 포함하여 필요한 재배치를 수행한다. LD_LIBRARY_PATH 환경 변수는 사용 가능한 공유 목적 파일 위치를 지정한다. 작업이 끝나면 원본 프로그램으로 제어권이 넘어가 수행을 계속한다.

재배치는 GOT(Global Offset Table)와 PLT(Procedure Linkage Table)라는 간접 메커니즘으로 처리한다. 이런 테이블은 외부 함수와 자료 주소를 제공하며, ld-linux.so가 재배치 과정에서 이를 메모리에 올린다. (테이블을 사용해서) 간접 지정하는 구조를 따르기에 코드 수정이 필요없다. 단지 테이블만 조정하면 끝난다. 원하는 함수가 필요한 시점이나 메모리에 올라온 즉시 재배치 작업을 수행할 수 있다(나중에 '리눅스에서 사용하는 동적 적재'에서 차이점을 좀 더 자세하게 다룬다).

재배치 작업이 끝나고 나면, 동적 링커는 메모리에 올라온 공유 목적 파일이 추가적인 초기화 코드를 수행하도록 허용한다. 이런 기능을 활용하면 라이브러리가 내부 자료를 초기화하고 사용 준비를 마칠 수 있다. 이 코드는 ELF 이미지에서 .init 섹션에 정의된다. 또한 라이브러리가 메모리에서 내려가면 (이미지에서 .fini 섹션에 정의된) 종료 함수를 호출한다. 초기화 함수를 호출하고 나서, 동적 링커는 메모리에 올라온 이미지에 제어권을 양도한다.


리눅스에서 사용하는 동적 적재

리눅스가 프로그램을 위해 자동으로 라이브러리를 메모리에 올려 링킹 과정을 수행하는 대신에 응용 프로그램과 제어권을 공유할 수도 있다. 이런 경우를 동적 적재라 한다. 동적 적재를 사용하면 응용 프로그램이 메모리에 올릴 특정 라이브러리를 지정하고, 실행 파일처럼 이 라이브러리를 사용한다(다시 말해 이 라이브러리 내부에 존재하는 함수를 호출한다). 하지만 직전에 배운 내용에 따르면, 동적 적재에 사용되는 공유 라이브러리는 표준 공유 라이브러리와 차이가 없다(ELF 공유 목적 파일이다). 실제로 ld-linux 동적 링커가 ELF 로더와 해석기로서 동적 적재 과정에 계속 참여한다.

DL(Dynamic Loading) API는 동적 적재를 지원하는 라이브러리이며, 공유 라이브러리를 사용자 영역 프로그램에서 사용할 수 있게 한다. API 개수는 적지만 필요한 모든 기능을 제공하며, 힘든 작업은 베일에 가려져 있다. 전체 API를 표 1에 정리했다.


표 1. Dl API
함수 설명
dlopen 프로그램에서 목적 파일에 접근이 가능하도록 만든다.
dlsym dlopen으로 연 목적 파일 내부에서 심볼 주소를 얻는다.
dlerror 발생한 마지막 오류 내역을 문자열로 반환한다.
dlclose 목적 파일을 닫는다.

과정은 접근 권한과 모드를 파일 객체에 넘기는 방식으로 dlopen 호출로 시작한다. dlopen 호출 결과로 나중에 사용될 객체를 가리키는 핸들이 넘어온다. mode 매개변수는 재배치를 언제 수행할지 동적 링커에 알려준다. 두 가지 값이 가능하다. 첫째로 RTLD_NOW는 동적 링커가 dlopen 호출 시점에서 필요한 모든 재배치를 완료하라고 알려준다. 둘째 대안으로 RTLD_LAZY는 필요할 때 재배치를 수행하라고 알려준다. 둘째 대안은 동적 링커로 재배치해야 하는 모든 작업 요청을 돌려서 내부적으로 처리한다. 따라서 동적 링커는 새로 참조할 때 요청 시점을 알며, 이 시점에서야 정상적인 재배치 작업이 이뤄진다. 계속해서 호출할 경우 재배치 작업을 반복하지 않는다.

두 가지 다른 모드 옵션은 비트 OR을 사용해 mode 인수에 지정한다. RTLD_LOCAL은 다른 목적 파일이 재배치 과정을 위해 메모리에 올라오는 공유 목적 파일 심볼을 사용하지 못하도록 막는다. RTLD_GLOBAL을 사용한다면 원본 프로세스 이미지에 있는 심볼을 공유 목적 파일이 수행하도록 허용한다.

dlopen 함수는 또한 공유 라이브러리 의존성을 자동으로 해결한다. 이런 방식으로, 다른 공유 라이브러리에 의존성이 걸려 있는 목적 파일을 열면, 자동으로 다른 공유 라이브러리도 메모리에 올린다. 이 함수는 이어지는 DL 계열 API에서 사용하는 핸들을 반환한다. dlopen 서식은 다음과 같다.

#include <dlfcn.h>

void *dlopen( const char *file, int mode );

ELF 목적 파일을 가리키는 핸들을 dlsym에 넘겨 목적 파일 내부에 존재하는 심볼 주소를 파악할 수 있다. 이 함수는 목적 파일에 들어있는 함수 심볼 이름을 매개변수로 받는다. 반환값은 목적 파일 내부에 존재하는 호출 가능한 심볼 주소다.

void *dlsym( void *restrict handle, const char *restrict name );

이 API 호출 도중에 오류가 발생하면 dlerror 함수를 사용해 사람이 읽을 수 있는 오류 문자열을 얻을 수 있다. 이 함수는 인수를 받지 않으며 직전에 발생한 오류 문자열이나 오류가 발생하지 않을 경우 NULL을 반환한다.

char *dlerror();

마지막으로 공유 목적 파일을 대상으로 추가 호출이 필요하지 않다면 응용 프로그램은 dlclose를 호출해 핸들과 객체 참조가 더 이상 필요하지 않다고 운영체제에 알려준다. 적절한 참조 카운터를 사용하므로, 공유 목적 파일을 사용하는 여러 사용자가 서로에게 영향을 미치지 않는다(한 명이라도 사용하는 사람이 남아 있다면 메모리에 그대로 유지된다). 일단 목적 파일을 닫고 나면 dlsym으로 찾아낸 심볼은 더 이상 사용 가능하지 않다.

char *dlclose( void *handle );


동적 적재 예제

지금까지 API를 살펴봤으니, 이제 DL API 예제를 살펴볼 시간이다. 이 응용 프로그램은 기본적으로 라이브러리, 함수, 인수를 지정한 다음 수행하는 셸 구현 형태다. 다시 말해, 사용자가 (이 응용 프로그램에 링크되어 있지 않은) 라이브러리를 지정한 다음에 라이브러리에 들어있는 임의 함수를 호출한다. DL API를 사용해 라이브러리 내부에 있는 함수 위치를 파악하고 사용자가 정의한 인수(결과를 출력한다)를 넘겨 호출한다. 완벽한 응용 프로그램 코드는 Listing 2에 제시했다.


Listing 2. DL API를 사용하는 셸
	
#include <stdio.h>
#include <dlfcn.h>
#include <string.h>

#define MAX_STRING      80


void invoke_method( char *lib, char *method, float argument )
{
  void *dl_handle;
  float (*func)(float);
  char *error;

  /* 공유 목적 파일을 연다. */
  dl_handle = dlopen( lib, RTLD_LAZY );
  if (!dl_handle) {
    printf( "!!! %s\n", dlerror() );
    return;
  }

  /* 목적 파일에서 심볼(메서드)을 찾는다. */
  func = dlsym( dl_handle, method );
  error = dlerror();
  if (error != NULL) {
    printf( "!!! %s\n", error );
    return;
  }

  /* 찾아낸 메서드를 호출해 결과를 출력한다. */
  printf("  %f\n", (*func)(argument) );

  /* 객체를 닫는다. */
  dlclose( dl_handle );

  return;
}


int main( int argc, char *argv[] )
{
  char line[MAX_STRING+1];
  char lib[MAX_STRING+1];
  char method[MAX_STRING+1];
  float argument;

  while (1) {

    printf("> ");

    line[0]=0;
    fgets( line, MAX_STRING, stdin);

    if (!strncmp(line, "bye", 3)) break;

    sscanf( line, "%s %s %f", lib, method, &argument);

    invoke_method( lib, method, argument );

  }

}

이 응용 프로그램을 빌드하려면 GCC(GNU Compiler Collection)로 컴파일한다. -rdynamic 옵션은 (dlopen에서 역추적을 허용하도록) 모든 심볼을 동적 심볼 테이블에 추가하라고 링커에 알려주는 목적이 있다. -ldldllib를 프로그램에 링크하도록 지시한다.

gcc -rdynamic -o dl dl.c -ldl

Listing 2로 돌아가보면, main 함수는 단순히 해석기로 동작해 입력 행에서 인수 셋을 받아 해석한다(라이브러리 이름, 함수 이름, 부동 소수점 인수). bye를 입력하면 응용은 종료한다. 그렇지 않으면 인수 셋을 DL API를 사용하는 invoke_method로 넘긴다.

목적 파일에 접근 권한을 얻기 위해 먼저 dlopen 호출부터 시작한다. 핸들이 NULL로 돌아오면 목적 파일을 찾지 못했으므로 프로세스는 종료된다. 그렇지 않다면, 목적 파일을 가리키는 핸들을 사용해 추가 정보를 얻어낸다. dlsym API 함수를 사용해서, 새로 열린 목적 파일 내부에 존재하는 심볼을 찾으려고 시도한다. 이 과정에서 심볼을 가리키는 유효한 포인터를 얻거나 오류가 발생했을 경우 NULL을 얻는다.

ELF 목적 파일에서 심볼을 찾으면, 다음 단계는 바로 함수 호출이다. 이 코드와 동적 링킹에서 설명한 내용에 차이가 있음에 주목하자. 이 예제에서 목적 파일 내부에 존재하는 심볼 주소를 함수 포인터로 형변환한 다음에 호출했다. 직전 예제에서 객체 이름을 함수로 사용해서 동적 링커가 심볼이 적절한 위치를 가리키도록 만들었다. 동적 링커가 이런 잡일을 대신해주긴 하지만, 응용 프로그램에서 추가적으로 수고를 감수하는 방법으로 실행 시간에 확장이 가능한 아주 동적인 응용 프로그램을 만들 수 있다.

ELF 목적 파일에서 원하는 함수를 호출한 다음에는 dlclose로 목적 파일 접근을 종료한다.

Listing 3에 테스트 프로그램 활용 예를 소개한다. 이 예제에서 컴파일하고 프로그램을 수행한다. 그러고 나서 math 라이브러리(libm.so)에 들어있는 몇 가지 함수를 호출한다. 시연 과정에서 프로그램은 동적 적재 기법을 활용해 공유 목적 파일(라이브러리)에 들어있는 임의 함수를 호출할 수 있다. 이는 강력한 프로그래밍 수단이며, 새로운 기능으로 프로그램을 확장하도록 허용한다.


Listing 3. 라이브러리 함수를 호출하는 간단한 프로그램 활용
	
mtj@camus:~/dl$ gcc -rdynamic -o dl dl.c -ldl
mtj@camus:~/dl$ ./dl
> libm.so cosf 0.0
  1.000000
> libm.so sinf 0.0
  0.000000
> libm.so tanf 1.0
  1.557408
> bye
mtj@camus:~/dl$


도구

리눅스는 (공유 라이브러리를 포함해) ELF 목적 파일을 살펴보고 파싱하는 다양한 도구를 제공한다. 가장 유용한 도구는 ldd 명령어다. 이 명령을 사용해 공유 라이브러리 의존성을 파악한다. 예를 들어, 앞서 만든 dl 응용 프로그램에 ldd 명령을 내리면 다음과 같은 결과가 나온다.

mtj@camus:~/dl$ ldd dl
        linux-gate.so.1 =>  (0xffffe000)
        libdl.so.2 => /lib/tls/i686/cmov/libdl.so.2 (0xb7fdb000)
        libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7eac000)
        /lib/ld-linux.so.2 (0xb7fe7000)
mtj@camus:~/dl$

ldd는 이 ELF 이미지가 linux-gate.so(특수 공유 목적 파일로 시스템 호출을 다루며 파일 시스템에서 관련된 파일은 없다), libdl.so(DL API), libc.so(GNU C 라이브러리), 마지막으로 리눅스 동적 로더(공유 라이브러리 의존성이 있기 때문이다)에 의존한다는 사실을 알려준다.

readelf 명령은 ELF 목적 파일을 파싱하고 읽도록 풍부한 기능을 담고 있는 유틸리티다. readelf를 사용할 때 목적 파일 내부에 재배치 가능한 항목을 파악하는 흥미로운 활용법도 있다. (Listing 2에 실린) 우리가 만든 간단한 프로그램을 대상으로 재배치가 필요한 심볼을 살펴보면 다음과 같다.

mtj@camus:~/dl$ readelf -r dl

Relocation section '.rel.dyn' at offset 0x520 contains 2 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
08049a3c  00001806 R_386_GLOB_DAT    00000000   __gmon_start__
08049a78  00001405 R_386_COPY        08049a78   stdin

Relocation section '.rel.plt' at offset 0x530 contains 8 entries:
 Offset     Info    Type            Sym.Value  Sym. Name
08049a4c  00000207 R_386_JUMP_SLOT   00000000   dlsym
08049a50  00000607 R_386_JUMP_SLOT   00000000   fgets
08049a54  00000b07 R_386_JUMP_SLOT   00000000   dlerror
08049a58  00000c07 R_386_JUMP_SLOT   00000000   __libc_start_main
08049a5c  00000e07 R_386_JUMP_SLOT   00000000   printf
08049a60  00001007 R_386_JUMP_SLOT   00000000   dlclose
08049a64  00001107 R_386_JUMP_SLOT   00000000   sscanf
08049a68  00001907 R_386_JUMP_SLOT   00000000   dlopen
mtj@camus:~/dl$

이 목록을 보면, DL API 호출(libdl.so)을 포함해 재배치가 필요한(libc.so) 다양한 C 라이브러리 호출을 확인할 수 있다. 함수 __libc_start_main은 프로그램 main 함수 호출 이전에 불리는 C 라이브러리 함수다(필요한 초기화를 담당하는 셸이다).

목적 파일을 처리하는 다른 유틸리티로 목적 파일 정보를 출력하는 objdump, (디버깅 정보를 포함한) 목적 파일에서 심볼 목록을 보여주는 nm이 있다. 또한 수동으로 이미지를 시작할 때 ELF 프로그램으로 직접 리눅스 동적 링커를 호출할 수도 있다.

mtj@camus:~/dl$ /lib/ld-linux.so.2 ./dl
> libm.so expf 0.0
  1.000000
>

추가로 --list 옵션을 넘기면 ld-linux.so로도 (ldd 명령과 동일하게) ELF 이미지 의존성을 열거할 수도 있다. ld-linux.so는 필요할 때 커널이 부트스트래핑하는 사용자 응용 프로그램일 뿐이라는 사실을 기억하자.


한걸음 더 나가면

이 기사는 동적 링커가 제공하는 몇 가지 기능을 수박 겉 핥기식으로 살펴보고 넘어갔다. 아래 소개하는 참고자료 절에서, ELF 이미지 형식과 심볼 재배치나 처리 과정에 대한 세부 소개 자료를 찾을 수 있다. 리눅스에서 늘 그렇듯이 (참고자료에 나오는) 동적 링크 원시 코드를 내려받아 내부를 파고들어 보자.


참고자료

교육

제품 및 기술 얻기

토론

필자소개

M. Tim Jones 사진

M. Tim Jones는 임베디드 펌웨어 아키텍트이자 Artificial Intelligence: A Systems Approach, GNU/Linux Application Programming(2판이 나왔다), AI Application Programming(2판이 나왔다), BSD Sockets Programming from a Multilanguage Perspective의 저자이기도 하다. Jones의 공학 배경은 정지 위성을 위한 커널 개발에서 시작해 임베디드 시스템 아키텍처와 네트워크 프로토콜 개발에 이르기까지 다양한 분야를 아우른다. Jones는 콜로라도 주, 롱몬트 소재 Emulex 사에서 컨설턴트 엔지니어로 활약한다.

+ Recent posts