어셈블러가 목적 모듈을 생성할 때, 코드와 데이터가 최종적으로 메모리 어느 곳에 저장될지 알지 못합니다. 또한, 다른 파일에 정의된 함수나 전역 변수의 주소도 알 수 없습니다.
따라서 어셈블러는 최종 위치를 알 수 없는 대상을 참조할 때마다 재배치 항목(relocation entry)을 생성합니다. 이 항목은 나중에 링커가 목적 파일들을 실행 파일로 병합할 때, 해당 참조를 어떻게 수정해야 하는지에 대한 '지시서' 역할을 합니다.
.rel.text 섹션에 저장됩니다..rel.data 섹션에 저장됩니다.
ELF 재배치 항목은 다음과 같은 필드로 구성됩니다.
이 필드들은 링커(Linker)가 여러 목적 파일(.o)을 합쳐 하나의 최종 실행 파일을 만드는 과정 중, 재배치(Relocation) 단계에서 사용됩니다.
ELF는 32가지의 재배치 타입을 정의하지만, 가장 기본적인 두 가지는 다음과 같습니다.
R_X86_64_PC32: 32비트 PC-상대 주소(PC-relative address)를 사용하는 참조를 재배치합니다.R_X86_64_32: 32비트 절대 주소(absolute address)를 사용하는 참조를 재배치합니다.이 두 타입은 gcc의 기본값인 x86-64 스몰 코드 모델(small code model)을 지원합니다. 이 모델은 실행 파일의 코드와 데이터 전체 크기가 2GB보다 작다고 가정하여, 32비트 주소 지정 방식으로 접근할 수 있도록 합니다.
이것은 어셈블러가 링커에게 남기는 "빈칸 채우기 문제지와 풀이법"이라고 생각하면 완벽합니다. 어셈블러는 sum 함수나 array 변수의 최종 주소를 모르기 때문에, 일단 코드에 빈칸(임시 주소)을 남겨두고, 링커에게 이 문제지를 전달하여 나중에 정답(최종 주소)을 채워 넣도록 지시하는 것입니다.
각 필드는 문제지에서 다음과 같은 역할을 합니다.
offset: 문제 번호 (어디를?).text 섹션 시작점에서부터 7바이트 떨어진 곳에 있는 4바이트짜리 빈칸을 채워라."symbol: 문제의 힌트 (무엇으로?)sum이라는 함수의 최종 주소다."type: 풀이 방법 (어떻게?)symbol의 주소를 어떤 형식으로 계산해서 넣을지 지시합니다.R_X86_64_32: "찾아낸 sum 함수의 주소를 그대로 빈칸에 적어라." (절대 주소)R_X86_64_PC32: "현재 이 명령어의 다음 위치에서 sum 함수까지의 거리를 계산해서 그 값을 적어라." (상대 주소)addend: 추가 조건 (조정 값)type에 따라 주소를 계산한 뒤, 추가로 더하거나 뺄 값을 지정합니다.array 변수의 주소를 찾은 뒤, 거기에 8을 더한 값을 최종 정답으로 적어라." (배열의 특정 원소 array[2]에 접근하는 경우)offset을 보고 수정할 코드의 정확한 위치로 이동합니다.symbol을 보고 필요한 심볼의 최종 확정된 가상 주소를 찾습니다.type과 addend의 지시에 따라 주소를 올바른 형식으로 계산합니다.이제 링커는 재배치 알고리즘을 사용하여 코드 내의 임시 주소들을 실제 실행 시점 주소로 수정하는 '찾아 바꾸기' 작업을 시작합니다. 이 알고리즘의 동작 방식은 Figure 7.10의 의사 코드에 잘 나타나 있으며, Figure 7.11의 main.o 코드를 예시로 사용합니다.


링커는 이 알고리즘을 통해 재배치 항목(relocation entry)이라는 '지시서'를 보고 코드 수정을 수행합니다.
.text, .data 등)을 돌면서, 그 섹션에 속한 모든 재배치 항목(수정 지시서 r)을 하나씩 확인합니다.refptr)를 계산합니다. (refptr = s + r.offset)r.type)에 따라 두 가지 다른 방식으로 주소를 계산하여 덮어씁니다.R_X86_64_PC32):refptr = ADDR(r.symbol) + r.addend - refaddrrefaddr)를 뺀 상대적인 거리(offset)를 계산하여 기록합니다.R_X86_64_32):refptr = ADDR(r.symbol) + r.addendmain.o 코드 재배치 예시 (Figure 7.11)main.o의 기계어 코드에는 array와 sum이라는 두 개의 전역 심볼 참조가 있으며, 어셈블러는 각각에 대한 재배치 항목을 생성했습니다.
array 참조 (오프셋 0xa): 절대 주소 (R_X86_64_32)로 재배치해야 합니다.sum 참조 (오프셋 0xf): PC-상대 주소 (R_X86_64_PC32)로 재배치해야 합니다.링커는 이 두 항목을 다음 섹션에서 설명하는 방식에 따라 수정하게 됩니다.

이 과정은 링커가 call sum과 같은 함수 호출 명령어의 임시 주소를 어떻게 올바른 값으로 채워 넣는지 보여줍니다. 핵심은 목표 함수의 절대 주소를 직접 쓰는 것이 아니라, 현재 위치에서 목표까지의 상대적인 거리(offset)를 계산하여 기록하는 것입니다.
어셈블러는 sum 함수의 최종 주소를 모르기 때문에, call 명령어 뒤에 4바이트짜리 빈칸(00 00 00 00)을 남기고, 링커에게 다음과 같은 지시서(재배치 항목)를 전달합니다.
r.offset = 0xf: 수정할 빈칸은 .text 섹션 시작 후 0xf 바이트 지점에 있습니다.r.symbol = sum: 이 빈칸은 sum 함수를 가리켜야 합니다.r.type = R_X86_64_PC32: 계산 방식은 '현재 위치로부터의 거리'(PC-상대 주소)를 사용해야 합니다.r.addend = -4: 계산할 때 -4를 추가로 보정해주세요.링커는 이제 최종 주소를 알고 있습니다.
.text 섹션의 시작 주소: ADDR(.text) = 0x4004d0sum 함수의 시작 주소: ADDR(sum) = 0x4004e8이제 링커는 지시서에 따라 빈칸에 들어갈 값을 계산합니다.
① 참조 위치 주소 계산 (refaddr): 빈칸(수정될 값) 자체의 실행 시점 주소를 계산합니다.
refaddr = ADDR(.text) + r.offset = 0x4004d0 + 0xf = 0x4004df
② 최종 값 계산 (*refptr): PC-상대 주소 계산 공식을 적용합니다.
*refptr = ADDR(sum) + r.addend - refaddr*refptr = 0x4004e8 + (-4) - 0x4004df*refptr = 0x5
링커는 계산된 최종 값 0x5를 코드의 빈칸에 덮어씁니다.
원래 e8 00 00 00 00이었던 코드가 다음과 같이 수정됩니다.
4004de: e8 05 00 00 00 callq 4004e8 <sum>
프로그램이 실행되어 CPU가 주소 0x4004de의 call 명령어를 실행할 때, 프로그램 카운터(PC)는 이미 다음 명령어의 주소(0x4004e3)를 가리키고 있습니다.
CPU는 이 call 명령어를 다음과 같이 처리합니다.
0x4004e3)을 스택에 저장합니다. (나중에 돌아와야 하므로)PC ← 현재 PC + 명령어에 기록된 값PC ← 0x4004e3 + 0x5 = 0x4004e8결과적으로 PC는 정확히 sum 함수의 시작 주소인 0x4004e8로 점프하게 되어, 우리가 원했던 대로 함수가 올바르게 호출됩니다.
절대 참조 재배치는 PC-상대 참조보다 훨씬 간단합니다. 이 과정은 링커가 전역 변수의 최종 메모리 주소를 코드에 직접 기록하는, 직관적인 '찾아 바꾸기' 작업입니다.
어셈블러는 array 변수의 최종 주소를 모르기 때문에, mov 명령어에 4바이트짜리 자리 표시자(placeholder) 00 00 00 00을 남기고, 링커에게 다음과 같은 지시서를 전달합니다.
r.offset = 0xa: 수정할 위치는 .text 섹션 시작 후 0xa 바이트 지점입니다.r.symbol = array: 이 자리에는 array 변수의 주소가 들어가야 합니다.r.type = R_X86_64_32: 주소를 계산 없이 그대로(절대 주소) 사용해야 합니다.r.addend = 0: 주소에 추가로 더할 값은 없습니다.링커는 이제 array 변수의 최종 주소가 0x601018임을 알고 있습니다. 링커는 지시서에 따라 자리 표시자에 들어갈 값을 계산합니다.
refptr): 절대 주소 계산 공식을 적용합니다.refptr = ADDR(array) + r.addendrefptr = 0x601018 + 0refptr = 0x601018링커는 계산된 최종 주소 0x601018을 코드의 자리 표시자에 덮어씁니다.
원래 bf 00 00 00 00이었던 코드가 다음과 같이 수정됩니다.
(x86 시스템은 리틀 엔디안(little-endian) 바이트 순서를 사용하므로 0x601018은 18 10 60 00으로 저장됩니다.)
4004d9: bf 18 10 60 00 mov $0x601018,%edi
이제 이 mov 명령어는 실행 시점에 정확히 array 변수의 시작 주소인 0x601018을 %edi 레지스터에 복사하게 됩니다.

링커가 PC-상대 참조(sum 호출)와 절대 참조(array 주소)에 대한 모든 재배치 작업을 완료하고 나면, .text와 .data 섹션은 완전한 형태가 됩니다.
이제 이 실행 파일은 운영체제의 로더(loader)에 의해 메모리에 직접 복사되기만 하면, 어떠한 추가 수정 없이도 즉시 실행될 수 있는 '준비 완료' 상태가 됩니다.
링커가 여러 목적 파일들을 병합하고 나면, 텍스트 파일이었던 C 프로그램은 메모리에 로드하여 실행하는 데 필요한 모든 정보를 담고 있는 단일 바이너리 파일, 즉 실행 가능 목적 파일(executable object file)로 변환됩니다.
실행 파일의 형식은 재배치 가능 목적 파일과 유사하지만, 몇 가지 중요한 차이점이 있습니다.

.text, .rodata, .data 같은 섹션들은 재배치 가능 파일과 유사하지만, 모든 주소가 최종 실행 시점 메모리 주소로 확정(재배치)된 상태입니다..init 섹션: 프로그램의 초기화 코드가 호출할 _init이라는 작은 함수를 정의합니다..rel 섹션의 부재: 실행 파일은 이미 모든 링크 작업이 완료된 상태이므로, 추가적인 주소 수정이 필요 없어 재배치 항목(.rel 섹션)이 존재하지 않습니다.ELF 실행 파일은 메모리에 쉽게 로드될 수 있도록 설계되었습니다. 파일의 연속적인 덩어리(chunk)들이 메모리의 연속적인 세그먼트(segment)에 매핑되는데, 이 매핑 정보는 프로그램 헤더 테이블(program header table)에 담겨 있습니다.
이 테이블에 따라 실행 파일의 내용이 두 개의 주요 메모리 세그먼트로 초기화됩니다.
.init, .text, .rodata 섹션들을 포함합니다..data 섹션과 .bss 섹션의 정보를 포함합니다..data 섹션의 내용으로 초기화되며, 파일에 공간을 차지하지 않았던 .bss 영역은 이 세그먼트 내에서 0으로 초기화될 메모리 공간을 예약합니다.링커는 세그먼트의 시작 가상 주소(vaddr)와 파일 내 오프셋(off)이 특정 정렬(align) 값을 기준으로 같은 나머지를 갖도록 주소를 선택합니다.
vaddr mod align = off mod align
이 요구사항은 가상 메모리가 2의 거듭제곱 크기를 갖는 큰 덩어리로 구성되기 때문에, 실행 시 파일의 세그먼트를 메모리로 효율적으로 전송하기 위한 최적화 기법입니다. (자세한 내용은 9장 가상 메모리에서 다룹니다.)
사용자가 셸 커맨드 라인에 ./prog와 같이 실행 파일 이름을 입력하면, 셸은 로더(loader)라는 운영체제 코드를 호출합니다. 로더는 실행 파일의 코드와 데이터를 디스크에서 메모리로 복사한 뒤, 프로그램의 첫 번째 명령어, 즉 엔트리 포인트(entry point)로 점프하여 프로그램을 실행시킵니다. 이처럼 프로그램을 메모리에 복사하고 실행하는 과정을 로딩(loading)이라고 합니다.
실행 중인 모든 리눅스 프로그램은 아래와 유사한 실행 시점 메모리 이미지(run-time memory image)를 가집니다.

0x400000에서 시작하며, 그 뒤를 데이터 세그먼트가 따릅니다.malloc 라이브러리 함수 호출을 통해 낮은 주소에서 높은 주소로 자라납니다.참고: 실제로는 주소 공간 배치 랜덤화(ASLR) 기술로 인해 스택, 공유 라이브러리, 힙 등의 시작 주소는 실행할 때마다 바뀌지만, 이들의 상대적인 위치 관계는 동일하게 유지됩니다.
공유 라이브러리 영역의 핵심 역할은 "자주 사용되는 코드를 메모리에 한 번만 올려놓고, 여러 프로그램이 함께 공유해서 사용하게 하는 것"입니다.
우리가 C언어로 printf("hello"); 코드를 짜면, printf 함수의 실제 기계어 코드는 우리 프로그램에 포함되지 않습니다. 대신, 공유 라이브러리 (Shared Library) 인 libc.so 파일 안에 들어있죠. 이 libc.so가 로드되는 공간이 바로 공유 라이브러리 영역입니다.
이 방식의 장점은 명확합니다.
printf를 사용한다고 상상해 보세요. 정적 라이브러리 방식이라면 100개의 printf 코드 복사본이 메모리를 차지하겠지만, 공유 라이브러리 방식에서는 단 하나의 printf 코드만 물리 메모리(RAM)에 올라가고, 모든 프로그램이 이 코드를 공동으로 참조합니다. 이는 엄청난 메모리 절약 효과를 가져옵니다.printf 같은 거대한 라이브러리 코드가 최종 실행 파일에 포함되지 않으므로, 실행 파일 자체의 크기가 매우 작아집니다. → 프로그램 실행 시 동적 링커가 메모리로 올립니다.printf("hello"); 코드를 보고, "아, 이건 libc.so라는 공유 라이브러리에 있는 함수구나"라고 인식합니다. 그래서 실행 파일에 printf의 기계어 코드 수천 줄을 복사하는 대신, 다음과 같은 자리 표시자(placeholder)만 남깁니다.이것이 바로 실행 파일의 크기가 작은 이유입니다."이 프로그램은 libc.so 파일에 있는 printf라는 이름의 함수를 필요로 합니다."
./prog를 실행하면, 운영체제의 로더(Loader)가 프로그램을 메모리에 올립니다.동적 링커 호출: 로더는 파일에 "공유 라이브러리가 필요하다"는 표시를 보고, 내 프로그램보다 먼저 동적 링커(ld-linux.so)를 실행시킵니다.
라이브러리 로드: 동적 링커는 libc.so 파일을 찾아 메모리의 '공유 라이브러리 영역'에 올립니다. 이제 printf 함수의 실제 코드는 메모리 어딘가에 위치하게 됩니다.
주소 연결 (Linking): 동적 링커는 내 프로그램에 있던 printf 자리 표시자로 돌아가, 방금 메모리에 올라간 printf 함수의 실제 메모리 주소를 정확하게 적어 넣습니다. 이 과정이 바로 '동적 링킹'입니다.
이제 모든 준비가 끝났습니다. 내 프로그램이 실행되다가 printf를 호출하는 부분에 도달하면, 이제는 비어있지 않은 자리 표시자를 보고 연결된 실제 printf 함수의 메모리 주소로 점프하여 코드를 실행합니다.
printf 함수에 보안 취약점이 발견되었다고 해봅시다. 시스템 관리자는 libc.so 파일 단 하나만 새로운 버전으로 교체하면 됩니다. 그러면 해당 라이브러리를 사용하는 모든 프로그램은 다음 실행 시 자동으로 보안 패치가 적용된 새로운 printf 함수를 사용하게 됩니다. 각 프로그램을 다시 컴파일할 필요가 전혀 없습니다.이 모든 과정은 프로그램이 시작될 때 동적 링커(Dynamic Linker)(ld-linux.so)에 의해 자동으로 처리됩니다. 동적 링커가 필요한 .so 파일을 찾아서 이 공유 라이브러리 영역에 매핑하고, 프로그램의 함수 호출과 라이브러리 속 실제 함수를 연결해 줍니다.
로더가 실행되면 다음과 같은 단계를 거쳐 메모리 이미지를 생성하고 프로그램을 실행합니다.
main 함수가 아니라, 항상 시스템 목적 파일(crt1.o)에 정의된 _start 함수의 주소입니다._start 함수는 C 표준 라이브러리(libc.so)에 있는 __libc_start_main 함수를 호출합니다.__libc_start_main 함수는 실행 환경을 초기화하고, 드디어 사용자가 작성한 main 함수를 호출합니다. main 함수가 종료되면 그 반환값을 처리하고, 필요시 제어권을 커널에게 다시 넘깁니다.실행 흐름 요약: 로더 → _start → __libc_start_main → main
crt1.o는 C 프로그램이 main 함수를 실행하기 전에 필요한 최소한의 준비 작업을 해주는 '시동 파일'입니다.
모든 C 프로그램의 진짜 시작점은 main 함수가 아니라, crt1.o 파일 안에 있는 _start라는 아주 작은 함수입니다. 운영체제의 로더는 프로그램을 실행할 때 이 _start 함수를 가장 먼저 호출합니다.
crt1.o의 역할crt1.o의 이름은 C Run-Time startup (object file 1)의 약자입니다. 그 역할은 다음과 같습니다.
_start) 제공: 운영체제로부터 프로그램의 제어권을 가장 먼저 넘겨받습니다._start 함수는 자신이 직접 복잡한 일을 하지 않고, C 표준 라이브러리(libc.so)에 있는 __libc_start_main이라는 더 큰 준비 함수를 호출하는 다리 역할을 합니다.main 함수 호출 준비: __libc_start_main 함수가 스택에서 argc, argv 같은 main 함수에 필요한 인자들을 정리하고, 표준 입출력(stdin, stdout)을 설정하는 등 C 언어 환경을 완벽하게 준비시킵니다.main 함수 실행 및 마무리: 모든 준비가 끝나면, 드디어 우리가 작성한 main 함수를 호출합니다. main 함수가 끝나고 값을 반환하면, 그 값을 받아서 프로그램을 안전하게 종료시키는 exit 시스템 콜을 호출하는 뒷정리까지 담당합니다.
정적 라이브러리는 많은 문제를 해결했지만, 여전히 몇 가지 중요한 단점을 가지고 있습니다.
printf, scanf와 같은 표준 I/O 함수를 사용합니다. 정적 링킹 방식에서는 이 함수들의 코드가 실행 중인 각 프로세스의 텍스트 세그먼트에 중복으로 복사됩니다. 수백 개의 프로세스가 실행되는 일반적인 시스템에서 이는 희소한 메모리 자원의 심각한 낭비입니다..so(Shared Objects) 확장자를 사용합니다..so 파일이 단 하나만 존재합니다. 이 라이브러리를 참조하는 모든 실행 파일들은 이 .so 파일을 공유합니다. (정적 라이브러리는 라이브러리의 내용이 각 실행 파일에 복사되어 포함됩니다.).text 섹션(코드) 복사본 하나를 여러 다른 실행 중인 프로세스들이 함께 공유할 수 있습니다. 이는 엄청난 메모리 절약 효과를 가져옵니다.shared, fpic)를 사용하여 공유 라이브러리를 생성합니다.linux> gcc -shared -fpic -o libvector.so addvec.c multvec.cfpic: 컴파일러에게 위치 독립적인 코드(Position-Independent Code, PIC)를 생성하라고 지시합니다. 이 코드는 메모리의 어떤 주소에 로드되더라도 올바르게 실행될 수 있습니다.shared: 링커에게 공유 목적 파일을 생성하라고 지시합니다.linux> gcc -o prog2l main2.c ./libvector.solibvector.so의 코드나 데이터는 실행 파일 prog2l에 전혀 복사되지 않습니다. 대신 링커는 나중에 로드 시점에 libvector.so의 코드와 데이터 참조를 해결할 수 있도록 해주는 약간의 재배치 및 심볼 테이블 정보만 복사합니다../prog2l을 실행하면, 로더(loader)는 부분적으로 링크된 실행 파일 prog2l을 메모리에 올립니다.prog2l 파일 안에 있는 .interp 섹션을 발견합니다. 이 섹션에는 동적 링커(ld-linux.so 등)의 경로 이름이 담겨 있습니다.libc.so의 코드와 데이터를 메모리 세그먼트에 재배치합니다.libvector.so의 코드와 데이터를 다른 메모리 세그먼트에 재배치합니다.prog2l 코드 내에서 libc.so와 libvector.so의 심볼을 참조하는 모든 부분을 실제 메모리 주소로 재배치(수정)합니다.이 시점 이후로 공유 라이브러리의 메모리 위치는 고정되며 프로그램 실행 중에 바뀌지 않습니다.
실행 흐름 요약: 로더 → 동적 링커 → 애플리케이션
지금까지의 로딩에 대한 설명은 개념적으로는 맞지만, 의도적으로 완전히 정확하게 설명하지는 않았습니다. 로딩이 실제로 어떻게 동작하는지 이해하려면, 아직 다루지 않은 프로세스(process), 가상 메모리(virtual memory), 메모리 매핑(memory mapping)의 개념을 알아야 합니다. 이 개념들을 8장과 9장에서 배우면서 로딩에 대해 다시 살펴보고 점차 그 비밀을 밝힐 것입니다.
성급한 독자를 위해, 로딩이 실제 어떻게 동작하는지 미리 살펴보겠습니다.
리눅스의 각 프로그램은 자신만의 가상 주소 공간(virtual address space)을 갖는 프로세스(process) 컨텍스트 내에서 실행됩니다. 셸이 프로그램을 실행하면, 부모 셸 프로세스는 자신과 똑같은 자식 프로세스를 복제(fork)합니다. 이 자식 프로세스는 execve 시스템 콜을 통해 로더를 호출합니다.
로더는 자식 프로세스의 기존 가상 메모리 세그먼트들을 삭제하고, 새로운 코드, 데이터, 힙, 스택 세그먼트를 생성합니다. 새로운 스택과 힙 세그먼트는 0으로 초기화됩니다. 새로운 코드와 데이터 세그먼트는 가상 주소 공간의 페이지(page)들을 실행 파일의 페이지 크기 덩어리(page-size chunks)에 매핑(mapping)함으로써 초기화됩니다. 마지막으로, 로더는 _start 주소로 점프하고, 이는 최종적으로 애플리케이션의 main 루틴을 호출하게 됩니다.
핵심은, 일부 헤더 정보를 제외하면, 로딩 중에는 디스크에서 메모리로의 실질적인 데이터 복사가 일어나지 않는다는 점입니다. 데이터 복사는 CPU가 매핑된 가상 페이지를 참조하는 시점까지 지연됩니다. 참조가 발생하는 그 순간, 운영체제는 페이징(paging) 메커니즘을 사용하여 해당 페이지를 디스크에서 메모리로 자동 전송합니다.
지금까지는 애플리케이션이 실행되기 직전, 로드될 때 동적 링커가 공유 라이브러리를 로드하고 링크하는 시나리오에 대해 다루었습니다. 하지만, 애플리케이션이 실행 중인 동안 동적 링커에게 임의의 공유 라이브러리를 로드하고 링크하도록 요청하는 것도 가능합니다. 이는 컴파일 시점에 해당 라이브러리에 대해 링크할 필요 없이 이루어집니다.
이러한 실행 시점 동적 링킹은 매우 강력하고 유용한 기술이며, 실제 세계에서 다음과 같은 사례에 사용됩니다.
fork와 execve를 사용하여 자식 프로세스를 생성하고 그 안에서 "CGI 프로그램"을 실행하는 방식으로 동적 콘텐츠를 처리했습니다. 그러나 현대의 고성능 웹 서버는 동적 링킹에 기반한 더 효율적인 접근 방식을 사용합니다.리눅스 시스템은 애플리케이션이 실행 중에 공유 라이브러리를 로드하고 링크할 수 있도록 동적 링커에 대한 간단한 인터페이스를 제공합니다.
dlopen(filename, flag): filename이라는 이름의 공유 라이브러리를 로드하고 링크합니다. flag 인자로는 외부 심볼 참조를 즉시 해결하라는 RTLD_NOW나, 라이브러리의 코드가 실행될 때까지 심볼 해석을 지연시키라는 RTLD_LAZY 중 하나를 반드시 포함해야 합니다.dlsym(handle, symbol): 이전에 열린 공유 라이브러리를 가리키는 핸들(handle)과 심볼 이름을 받아, 해당 심볼의 주소를 반환합니다. 심볼이 없으면 NULL을 반환합니다.dlclose(handle): 해당 라이브러리를 사용하는 다른 공유 라이브러리가 더 이상 없다면, 공유 라이브러리를 언로드(unload)합니다.dlerror(): dlopen, dlsym, dlclose 호출 결과 발생한 가장 최근의 오류를 설명하는 문자열을 반환합니다. 오류가 없었다면 NULL을 반환합니다.이 인터페이스를 사용하여 libvector.so 공유 라이브러리를 실행 중에 동적으로 링크하고 addvec 루틴을 호출하는 예제 프로그램(Figure 7.17)을 컴파일하려면 다음과 같이 gcc를 호출합니다.
linux> gcc -rdynamic -o prog2r dll.c -ldl
공유 라이브러리의 핵심 목적은 여러 실행 중인 프로세스가 메모리에서 동일한 라이브러리 코드를 공유하여 귀중한 메모리 자원을 절약하는 것입니다. 그렇다면 여러 프로세스가 어떻게 프로그램의 단일 복사본을 공유할 수 있을까요?
한 가지 접근법은 각 공유 라이브러리에 미리 전용 주소 공간 덩어리를 할당하고, 로더가 항상 해당 주소에 공유 라이브러리를 로드하도록 하는 것입니다.
이 방식은 간단해 보이지만 다음과 같은 심각한 문제들을 야기합니다.
이러한 문제들을 피하기 위해, 현대 시스템은 공유 모듈의 코드 세그먼트가 링커에 의해 수정될 필요 없이 메모리의 어느 곳에나 로드될 수 있도록 컴파일합니다. 이 접근법 덕분에 공유 모듈 코드 세그먼트의 단일 복사본을 무제한의 프로세스들이 공유할 수 있습니다. (물론, 각 프로세스는 여전히 자신만의 읽기/쓰기 가능한 데이터 세그먼트 복사본을 가집니다.)
이처럼 어떠한 재배치도 필요 없이 로드될 수 있는 코드를 위치 독립적인 코드(Position-Independent Code, PIC)라고 합니다. GNU 컴파일 시스템에서는 gcc에 -fpic 옵션을 사용하여 PIC를 생성하도록 지시하며, 공유 라이브러리는 반드시 이 옵션으로 컴파일되어야 합니다.
x86-64 시스템에서 같은 실행 모듈 내의 심볼을 참조하는 것은 PIC를 위해 특별한 처리가 필요하지 않습니다. 이러한 참조는 PC-상대 주소 지정을 사용하여 컴파일되고 정적 링커에 의해 재배치될 수 있습니다. 그러나, 공유 모듈에 의해 정의된 외부 프로시저나 전역 변수를 참조하는 경우에는 특별한 기법이 필요합니다.
컴파일러는 공유 라이브러리의 코드 세그먼트와 데이터 세그먼트 사이의 거리는 항상 일정하다는 흥미로운 사실을 이용하여 PIC 전역 변수 참조를 생성합니다. 즉, 라이브러리가 메모리의 어느 위치에 로드되더라도, 코드 내의 특정 명령어와 데이터 세그먼트 내의 특정 변수 사이의 거리는 변하지 않는 실행 시점 상수입니다.

컴파일러는 이 원리를 활용하여 데이터 세그먼트의 시작 부분에 전역 오프셋 테이블(Global Offset Table, GOT)이라는 테이블을 만듭니다.
이 방식의 핵심은, 명령어에서 GOT 항목까지의 상대 거리는 항상 일정하므로 PC-상대 주소 지정이 완벽하게 동작한다는 점입니다. 실제 변수 접근은 다음과 같은 2단계 간접 참조로 이루어집니다.
예를 들어, libvector.so의 addvec 루틴은 전역 변수 addcnt의 주소를 GOT의 세 번째 항목(GOT[3])을 통해 간접적으로 로드한 다음, 메모리에서 addcnt 값을 증가시킵니다.
참고: addcnt는 libvector.so 모듈 내에 정의되어 있으므로, 컴파일러는 addcnt에 대한 직접적인 PC-상대 참조를 생성할 수도 있었습니다. 하지만 만약 addcnt가 다른 공유 모듈에 정의되어 있었다면 GOT를 통한 간접 접근이 반드시 필요합니다. 컴파일러는 모든 참조에 대해 가장 일반적인 해결책인 GOT를 사용하도록 선택한 것입니다.