링커란
링커는 컴파일러가 생성한 여러 개의 오브젝트 파일과 라이브러리를 하나로 묶어주는 프로그램입니다. 마치 음식에서 여러 명의 쉐프들이 여러 개의 요리를 만들면 그걸 하나의 접시에 담아 확인하는 헤드쉐프라고 볼 수 있습니다.
링커의 동작 과정
- 심벌 해석: 링커가 여러 개의 오브젝트 파일을 연결할 때 각 심벌의 참조를 정확히 하나의 심벌로 연결하는 과정입니다. 주요 대상은 전역 심벌과 외부 심벌입니다.
- 심벌 수집: 오브젝트 파일에 정의된 심벌 테이블에서 전역 변수와 외부 변수를 수집합니다.
- 심벌 매칭: 각 심벌 참조(예: extern 변수, 다른 파일의 함수 호출)를 해당 정의와 연결합니다. 정의가 없는 참조는 링커 오류를 발생시킨다.
- 중복 심벌 처리: 동일한 이름의 심벌이 여러 파일에 정의된 경우, 링커는 strong/weak 규칙을 적용합니다.
- 메모리 주소 할당: 심벌 참조가 잘 되어있다면 메모리 주소를 할당합니다.
- 실행 파일 생성: 링커는 코드, 데이터, 심벌 테이블, 주소 정보, 섹션 헤더, 라이브러리 의존성, 엔트리 포인트 등 프로그램 실행에 필요한 모든 정보를 종합하여 실행 파일을 생성합니다.
- 재배치: 여러 오브젝트 파일의 섹션을 통합하고, 각 심벌과 섹션에 실제 메모리 주소를 할당하며, 코드와 데이터 내 심벌 참조를 절대 주소로 수정하는 단계입니다.
오브젝트 파일: Hello world.c라는 파일을 컴파일을 하면 기계어로 번역한 파일이 생성되는데 이것이 오브젝트 파일입니다.
심벌: 코드에 선언된 함수와 변수 등을 생각하시면 됩니다.
전역 심벌: 각 오브젝트 파일에 정의된 전역 변수와 함수입니다. 프로그램 전체에서 참조될 수 있으며, 링커는 이 심벌들에 대해 메모리 주소를 할당합니다.
외부 심벌: 한 오브젝트 파일에서 선언만 되었고, 실제 정의는 다른 파일에 있는 변수나 함수를 의미합니다.. 링커가 다른 파일에서 정의를 찾아 연결 합니다.
심벌 테이블: 컴파일, 링커가 소스코드나 오브젝트 파일 할 때 사용되는 데이터 구조입니다.
Strong 심벌: 함수와 초기화된 전역 변수. 둘 이상 정의되면 에러. Strong과 Weak이 함께 있으면 Strong이 선택됩니다.
Weak 심벌: 초기화되지 않은 전역 변수. 여러 개 정의되어도 하나만 선택(비결정적).
정적 라이브러리, 동적 라이브러리, 실행 파일에 대하여
오랜 기간동안 코드를 개발하다보면 점점 코드 양이 많아져 점점 컴파일이 오래 걸리고 어떤 코드를 활용해야되는지도 모르게 되어 이미 있는 코드를 작성하게 됩니다. 이런 문제를 해결하기 위해 특정 목적을 하는 코드 파일을 묶어서 따로 컴파일하고 정의된 전역 함수만 접근해서 사용할 수 있게 라이브러리를 만드는데 이것을 정적 라이브러리라고 합니다.
이처럼 쉽고 빠르게 연결할 수 있다는 장점도 있지만 단점도 존재합니다. 바로 많은 디스크와 메모리 사용하여 컴퓨터 리소스를 낭비할 수 있다는 점입니다. 라이브러리 30개 내외정도면 크지 않겠지만 400개, 500개가 넘어간다면 큰 낭비를 가져올 것입니다. 이 문제를 해결하기 위해 나온 것이 동적 라이브러리입니다.
동적 라이브러리도 정적 라이브러리와 마찬가지로 코드 영역과 데이터 영역은 존재하지만 데이터 영역, 코드 영역, 심벌 테이블 정보를 모두 복사하는 정적 라이브러리와 달리 라이브러리 이름, 심벌 테이블, 재배치 정보 등과 같은 필수 정보만 복사합니다. 그렇기 때문에 실행 파일의 크기를 줄일 수 있습니다.
컴파일 단계에 모든 정보가 복사되는 정적 라이브러리와 다르게 동적 라이브러리는 동적 링크라는 방식으로 프로그램 시작 시점까지 미룰 수 있습니다. 동적 링크가 실행되는 방식은은 두가지가 있는데 바로 메모리 로딩 시점에 실행되는 동안 코드를 통해 동적 링크를 실행하는 방법입니다.
- 메모리 적재 시점: 프로그램이 메모리에 로딩이되면 로더라는 프로세스를 통해 실행 파일을 디스크에서 읽어 메모리에 적재합니다. 적재된 메모리에는 동적 링커라는 프로세스가 실행되어 동적 라이브러리의 존재 여부, 위치, 심벌의 메모리 위치 등을 파악하여 링크 과정이 끝납니다.
- 런타임 동적 링크: 프로그래머가 특정 API로 라이브러리를 호출하면 메모리가 적재되며 동적 링크가 실행 됩니다. 이 과정은 실행 파일 내부에 동적 라이브러리 정보가 저장되지 않습니다.
동적 라이브러리 장단점
장점
- 의존하는 프로그램이 몇개라도 동적 라이브러리는 디스크에 복사본 하나만 저장하기 때문에 디스크, 메모리 사용을 크게 줄일 수 있습니다.
- 코드가 수정되면 수정된 라이브러리만 다시 컴파일하면 됩니다. 실행 파일이 실행 될 때 변경된 라이브러리를 바라보기 때문입니다.
- 런타임 환경에서도 동적 링크가 실행되는 특성을 활용하여 플러그인과 같은 쉽게 확장할 수 있는 도구를 만들 수 있습니다.
- CPython과 같이 다른 언어끼리 동적 링크를 통해 각 언어의 장점을 혼합하여 활용할 수 있습니다.
단점
- 메모리에 로딩 시점이나 프로그램 실행 환경에서 링크되기 때문에 정적 라이브러리보다는 링크 시간이 느립니다.
- 동적 라이브러리는 위치 독립 코드(position-independent code)로 불릴만큼 메모리에 독립적으로 하나의 복사본만 가지고 있습니다. 여러 프로세스에서 동적으로 로딩하여 사용할 수 있도록 설계되었기 때문에 절대 메모리 주소를 가질 수 없습니다.
- 동적 라이브러리가 가진 종속성과 버전이 호환되도록 맞추지 않으면 실행되지 않습니다.
재배치 과정에 대한 이해
모든 전역 변수와 함수들은 실제 메모리에 할당되어 동작한다는 것을 알게되었습니다. 하지만 여기서 의문이 드는 건 어떻게 링커가 재배치 과정을 통해 메모리 주소를 할당해주는지에 대해 의문입니다. 아래의 그림을 통해 알아봅시다.
각 소스코드에는 대칭하는 대상파일이 있고 그 안에는 기계어가 있는 코드 영역, 전역 변수와 static 변수가 들어있는 데이터 영역, 전역 변수와 함수를 식별하는데 활용하는 심볼 테이블이 있습니다. 그외에 .rero.text 와 .rero.data가 있는데 메모리 주소를 확정할 수 없는 변수가 있을 때마다 .rero.text에 명령어를 저장하고 명령어와 관련된 데이터는 .rero.data에 저장합니다.
링커는 심벌 해석이 잘 완료되어야지만 실행파일을 생성하기 때문에 .rero.text파일에 기록하고 실행파일에 병합시킵니다. 대상 파일에서 각 영역들이 하나의 실행 파일로 합쳐지고 프로그램이 실행되면 foo 함수에 런타임 메모리 주소가 할당됩니다. 링커가 프로그램이 실행되는 시점에 .rero.text 파일을 읽어 런타임 메모리 주소들을 재배치 시켜주기 때문에 메모리 주소가 할당될 수 있는 것입니다.
여기서 의문이 드는 것은 어떻게 링커가 런타임 메모리 주소를 알고 있는지에 대해 궁금증이 생깁니다. 어떻게 링커는 런타임 메모리 주소를 모두 알 수 있는 걸까요? 그건 바로 모두가 익히 들어본 가상 메모리라는 기술을 사용하기 때문입니다.
가상 메모리와 프로그램 메모리
프로그램이 실행되면 프로세스가 메모리에 적재되는데 위의 그림은 메모리가 어떻게 적재되는지 보여줍니다. 상위 주소에는 명령어를 처리하는 커널과 스택영역이 존재하고 그 사이에 빈 공간을 지나 힙영역이 존재합니다. 저희가 실행 시킨 foo 함수가 바로 힙 영역에 메모리를 할당되는 곳입니다. 그리고 코드 영역과 텍스트 영역에 실행 파일의 정보가 메모리에 적재되는 것입니다.
여기서 놀라운 점은 여러 개의 프로그램이 하나의 메모리 주소에서 시작될 수 있다는 점입니다. 리눅스 ELF 실행 파일 같은 경우 모두 0x400000에서 시작합니다. 그렇다면 동시에 방화벽 프로그램과 패키지 매니저 프로그램이 뜬다면 어떻게 메모리를 할당해서 가져올까요? 실제 동작을 시켜보면 방화벽 프로그램에서 가져온 0x400000은 방화벽에 속한 명령어를 가져오고 패키지 매니저에 가져온 0x400000은 패키지 매니저의 명령어를 가져옵니다.
그 이유는 바로 위와 같은 가상 메모리 구조를 활용하기 때문입니다. 링커 설계 구조를 간소화하기 위해 각 시스템마다 표준화된 메모리 구조를 가지고 있기 때문입니다. 어떤 프로그램을 실행하더라도 가상의 메모리 공간 안에서 같은 메모리 주소를 바라보기 때문에 서로 다른 프로그램 안에서 같은 메모리 주소를 활용할 수 있는 것입니다.
cpu가 돌아가기 위해서는 결국은 물리적인 메모리 주소를 활용해서 관리해야 될텐데 실제 물리 주소는 어떻게 찾을까요? 바로 각 프로그램마다 사상(mapping)관계를 가지고 있는 페이지 테이블을 가지고 있기 때문입니다. 페이지 안에 저장되는 메모리들은 메모리 페이지 단위로 관리되며 실제 프로그램이 가상 메모리에 접근 했을 때 페이지 테이블을 참조하여 물리 메모리 위치로 변환한 뒤 접근합니다.
요약
- 모든 프로세스의 가상 메모리는 표준화되어 있고 크기가 동일하다. 각 영역이 배치되는 순서도 동일하다.
- 실제 메모리의 크기와 가상 메모리의 크기는 무관하며 물리 메모리는 가상 메모리처럼 영역을 나누지 않는다.
- 모든 프로세스는 페이지 테이블을 가지고 있고 같은 메모리 주소를 바라보더라도 서로 다른 물리 주소를 바라보기 때문에 충돌이 발생하지 않는다.
추상화가 중요한 이유
개발자들은 개발하면서 프로그램의 동작의 원리와 흐름에 대해 추상화하여 그림과 글로 자주 표현하고는 합니다. 그 이유는 여러가지가 있겠지만 가장 큰 이유 두가지는 표현력을 높여 커뮤니케이션의 효율을 높이는 것과 세부사항을 숨기는 것에 있습니다.
프로그래밍의 추상화
프로그래밍의 추상화는 복잡한 내부 동작을 숨김으로써 외부에서 추상화된 개념으로 쉽게 사용할 수 있게 만듭니다. 그리고 기능의 내부 사항이 변경되더라도 외부는 추상화된 API에 의존하고 있기 때문에 큰 영향을 받지 않습니다.
- 복잡한 계산 로직을 숨기고 인터페이스를 통해 추상화된 API만을 제공합니다.
- 추상 클래스를 통해 공통된 로직을 쉽게 활용할 수 있도록 합니다.
- 다형성을 활용하여 특정 모듈의 기능들을 쉽게 확장할 수 있습니다.
시스템 설계의 추상화
우리가 알게모르게 놓치고 있던 많은 영역들이 추상화되어 있다는 것을 알고 계셨나요? 프로그래밍 언어부터 시작해서 파일, 프로세스, 가상 메모리, 소켓, 컨테이너 등 낮은 레벨의 동작들이 추상화되어있습니다. 이런 추상화 덕에 저희는 프로그래밍의 추상화 처럼 내부 동작을 깊게 고려하지 않아도 손쉽게 개발할 수 있습니다.
- 기계 명령어는 고급 프로그래밍 언어로 추상화 되어있습니다.
- 입출력 장치는 파일로 추상화 되었습니다.
- 실행중인 프로그램은 프로세스로 추상화 되어있습니다.
- 실제 물리 메모리와 파일은 가상메모리로 추상화 되어있습니다.
- 네트워크 프로그래밍을 소켓으로 추상화 되어있습니다.
- 프로세스에 종속적인 환경은 컨테이너로 추상화되었습니다.
- CPU, 운영체제, OS 등과 같은 환경은 가상 머신으로 추상화 되었습니다.
저레벨의 이해는 필요가 없는 것일까?
이제는 AI와 툴이 결합되어 인간의 자연어만으로 간단한 프로그램은 만들 수 있을 정도 추상화되어가고 있습니다. 기술이 발전할수록 프로그램 개발은 더더욱 높은 수준으로 추상화 될 것 입니다. 그렇다면 이제 프로그래머가 할 수 있는 것은 없다는 의미일까요? 저는 아니라고 생각합니다.
자동차 수리공이 자동차를 수리할 때 자동차의 동작 원리를 이해하고 교체가 필요한 부품을 판별하거나 고장의 원인을 추측하는 것처럼 저희도 추상화된 프로그래밍 환경 속에서 그안에 내부동작을 학습하고 이해하고 있어야 더 높은 수준의 프로그램을 만들고 남들이 해결하지 못하는 문제들을 파악할 수 있습니다. 매우 정밀한 기계가 필요한 자동차 부품과 달리 저희는 컴퓨터만 있다면 추상화 밑에 있는 계층들을 돌아다닐 수 있습니다. 그렇기 때문에 아무리 추상화된 기술이 좋아졌더라고 하더라도 낮은 수준의 동작 과정을 이해하고 있어야 합니다.
'CS' 카테고리의 다른 글
[컴퓨터 밑바닥의 비밀] CPU 진화론 (3) | 2025.06.29 |
---|---|
[컴퓨터 밑바닥의 비밀] 프로그래밍 개념 파헤치기 (3) | 2025.06.01 |
[컴퓨터 밑바닥의 비밀] 소스 코드의 역사 (0) | 2025.05.11 |
비개발자를 위한 마이크로 서비스 아키텍처(MSA) 안내서 (2) | 2024.01.21 |
당신의 API가 실패하는 이유: 최고의 API 설계 비법 5가지 (9) | 2024.01.07 |