링킹은 여러 개의 코드와 데이터를 모아서 연결하여 메모리에 로드될 수 있고, 실행될 수 있는 한 개의 파일로 만드는 작업이다. 링킹은 컴파일 시에 수행할 수 있으며, 이때 소스코드는 머신코드로 번역된다. 프로그램이 메모리에 로드되고, 로더에 의해서 실행될 때에는 로드 타임에, 응용프로그램에 의해서 심지어 실행시에도 수행될 수 있다. 현대 시스템에서 링킹은 링커라고 부르는 프로그램에 의해서 자동으로 수행된다.
링커는 독립적인 컴파일을 가능하게 만든다. 즉, 큰 규모의 응용 프로그램을 한개의 소스 파일로 구성하는 대신 별도로 수정 및 컴파일할 수 있는 보다 유지보수에 용이한 더 작은 모듈로 나눌 수 있다. 이러한 링커를 배워야 하는 이유는 대략적으로 다음과 같다.
- 링커가 참조를 해결해 나가는 방법, 라이브러리가 무엇인지, 링커가 참조를 해결하기 위해 라이브러리를 사용하는 방법을 이해햐면 종종 모듈이 없어서, 라이브러리가 없어서, 또는 맞지 않는 라이브러리 버전으로 인한 에러들을 해결할 수 있다.
- 리눅스 링커가 심볼 참조를 해결할 때 하는 결정들은 프로그램의 정확성에 영향을 줄 수 있다. 단적으로 전역변수를 중복해서 정의한 프로그램도 기본 설정의 경우 경고 메시지 없이 링커를 통과하므로 런타임시 혼란을 야기할 수 있다.
- 링킹을 이해하면 어떻게 언어의 변수 영역 규칙이 구현되었는지 이해하는데 도움을 줄 수 있다. 예를들어, 전역변수와 지역변수의 차이 및 static을 사용한 변수나 함수의 정의시 의미를 말한다.
- 링킹에 대해 이해하면 다른 중요한 시스템 개념에 대해 이해할 수 있는데, 가령 링커가 만든 실행 가능한 객체 파일은 로딩과 프로그램 실행과 같은 중요한 시스템 함수, 가상메모리, 페이징, 메모리 매핑에서 중요한 역할을 한다.
- 링킹을 이해하면 공유 라이브러리에 대해 이해할 수 있다. 예를들어, 많은 소프트웨어 제품에서 공유 라이브러리를 사용해 크기가 줄어든 바이너리를 런타임에 업그레이드할 수 있게 해준다. 또한 많은 웹서버는 동적 콘텐츠를 서비스하기 위해서 공유 라이브러리의 동적 링킹을 사용한다.
컴파일러 드라이버
대부분의 컴파일 시스템은 사용자를 대신해서 언어 전처리기, 컴파일러, 어셈블러, 링커를 필요에 따라 호출하는 컴파일러 드라이버를 제공한다. 예를들어, 사용하는 GNU 컴파일 시스템을 사용해서 쉘에 gcc 명령을 입력하여 GCC 드라이버를 호출할 수 있다.
예를 들어보면 이 GCC 드라이버가 아스키 소스 파일을 실행 목적파일로 번역할 때 드라이버의 동작 내용은 다음과 같다.
- 드라이버는 먼저 C 전처리기를 돌리고, 이것은 C 소스 파일 main.c를 아스키 중간 파일은 main.i로 번역한다.
- 드라이버는 C 컴파일러를 돌려서 main.i를 아스키 어셈블러 언어 파일인 main.s로 번역해준다.
- 그 다음에 드라이버는 어셈블러를 돌려서 main.s를 재배치 가능한 바이너리 목적파일인 main.o로 번역한다.
- 또한 드라이버는 마찬가지로 sum.o를 생성하기 위해 동일한 과정을 수행한다.
- 마지막으로 링커 프로그램 ld를 실행하는데, 이것은 필요한 시스템 목적파일과 함께 실행 가능 목적파일 prog를 생성하기 위해 main.o와 sum.o를 연결한다.
이렇게 쉘은 loader라고 부르는 운영체제 내의 함수를 호출하며, loader는 실행파일 prog의 코드와 데이터를 복사하고, 제어를 프로그램의 시작 부분으로 전환한다.
정적연결
리눅스 LD 프로그램과 같은 정적 링커들은 재배치 가능한 목적 파일들과 명령줄 인자들을 입력으로 받아들여 로드될 수 있고, 실행될 수 있는 완전히 링크된 실행 가능 목적파일을 출력으로 생성한다.
실행파일을 만들기 위해서 링커는 2가지 주요 작업을 수행해야 한다.
- symbol resolution. 목적파일들은 심볼들을 정의하고 참조하여 여기서 각 심볼은 함수, 전역변수 또는 정역변수에 대응된다. 또한 non-static 지역 변수는 포함하지 않는다. 심볼 해석의 목적은 각각의 심볼 참조를 정확하게 하나의 심볼 정의에 연결하는 것이다.
- relocation. 컴파일러와 어셈블러는 주소 0번지에서 시작하는 코드와 데이터 섹션들을 생성한다. 링커는 이 섹션들을 각 심볼 정의와 연결시켜서 재배치하며, 이 심볼들로 가는 모든 참조들을 수정해서 이들이 이 메모리 위치를 가리키도록 한다.
목적파일
목적파일들은 단지 바이트 블록들의 집합이다. 이 블록들 중 일부는 프로그램 코드를 포함하고, 다른 블록들은 프로그램 데이터를, 또 다른 블록들은 링커와 로더를 안내하는 데이터 구조를 포함한다. 링커는 블록들을 함께 연결하고, 이 연결된 블록들을 위한 런타임 위치를 결정하며, 코드와 데이터 블록 내에 여러 가지 위치를 수정한다.
목적파일에는 3가지 형태가 있다.
- relocatable object file. 포맷에 컴파일 할 때, 실행 가능 목적파일을 생성하기 위해서 다른 재구성 가능 목적파일들과 결합될 수 있는 바이너리 코드와 데이터를 포함한다.
- executable object file. 메모리에 직접 복사될 수 있고, 실행될 수 있는 형태로 바이너리 코드와 데이터를 포함한다.
- shared object file. 로드 타임 또는 런타임 시에 동적으로 링크되고, 메모리에 로드될 수 있는 특수한 유형의 재배치 가능 목적 파일이다.
여기서 컴파일러와 어셈블러는 relocatable object file을 생성하고(공유 목적파일 포함), 링커는 excutable object file을 생성한다.
Relocatable Object File
여기서 ELF란 Excutable and Linkable File을 나타낸다. 그리고 위 그림은 전형적은 ELF 재배치 가능 목적파일의 포맷을 보여준다. ELF 헤더는 이 파일을 생성한 워드 크기와 시스템의 바이트 순서를 나타내는 16바이트 배열로 시작한다. 그리고 헤더의 나머지는 링커가 목적파일을 구문분석하고 해석하도록 하는 정보를 포함한다. ELF 헤더와 섹션 헤더 테이블 사이에 섹션 내용이 들어 있는데, 전형적인 ELF 재배치 가능 목적파일은 다음과 같은 섹션들을 포함한다.
- .text : 컴파일된 프로그램의 머신 코드
- .rodata : printf 문장의 포맷 스트링, switch 문의 점프 테이블과 같은 읽기-허용 데이터
- .data : 초기화된 C 전역변수 및 정적변수. C 지역변수들은 런타임에 스택에 저장되며, data나 .bss 섹션에는 나타나지 않는다.
- .bss : 초기화되지 않은 C 전역변수와 정적변수 그리고 0으로 초기화된 전역변수 및 정적변수. 이 섹션은 목적파일에 실제 공간을 차지하지는 않는다. 런타임에 이 초기화되지 않은 변수들이 메모리에 0으로 초기화되어 할당된다.
- .symtab : 프로그램에서 정의되고 참조되는 전역변수들과 함수에 대한 정보를 가지고 있는 심볼 테이블이다. 사실 모든 재배치 가능 목적 파일은 .symtab에 심볼 테이블을 가지고있다. 그러나 컴파일러 내부의 심볼 테이블과는 달리 .symtab 심볼 테이블은 지역변수에 대한 엔트리를 가지고 있지 않다.
- .rel.text : 링커가 이 목적 파일을 다른 파일들과 연결할 때 수정되어야 하는 .text 섹션 내 위치들의 리스트이다.
- .rel.data : 이 모듈에 의해 정의되거나 참조되는 전역변수들에 대한 재배치 정보이다.
- .debug : 프로그램 내에서 정의된 지역변수들과 typedef, 프로그램과 최초 C 소스 파일에서 정의되고 참조되는 전역변수들을 위한 엔트리를 갖는 디버깅 심볼 테이블이다. 이것은 컴파일러 드라이버가 -g 옵션으로 불린 경우에 생성된다.
- .line : 최초 C 소스 프로그램과 .text 섹션 내 머신 코드 인스트럭션 내 라인 번호들간의 매핑이다.
- .strtab : .strtabl과 .debug 섹션들 내에 있는 심볼 테이블과 섹션 헤더들에 있는 섹션 이름들을 위한 스트링 테이블이다.
실행 가능한 목적파일
실행 가능 목적파일의 포맷은 재배치 가능한 목적파일의 포맷과 유사하다. ELF 헤더가 이 파일의 전체적인 포맷을 설명하며, 이것은 프로그램이 실행될때 첫 번째 인스트럭션 주소인 프로그램 엔트리 포인트를 포함한다. 실행 가능한 목적파일 prog를 리눅스 쉘 명령줄에서 실행하게 되면, 쉘은 loader라고 알려진 메모리 상주 운영체제 코드를 호출해서 이 프로그램을 실행한다. 모든 리눅스 프로그램은 execve 함수를 호출해서 로더를 호출할 수 있다. 로더는 디스크로부터 실행 가능한 목적파일 내의 코드와 데이터를 메모리로 복사하고 이 프로그램의 첫번째 인스트럭션. 즉, 엔트리 포인터로 점프해서 프로그램을 실행한다. 이와 같이 프로그램을 메모리로 복사하고, 실행하는 과정을 로딩이라고 부른다.
공유 라이브러리
공유 라이브러리들은 정적 라이브러리의 단점들을 극복하는 현대의 혁신이다. 공유 라이브러리는 런타임이나 로드타임에 임의의 메모리 주소에서 로드되고, 메모리에서 프로그램으로 연결될 수 있는 목적 모듈이다. 이 과정은 동적 링킹이라고 알려져 있으며, 동적 링커라고 하는 프로그램에 의해서 수행된다. 공유 라이브러리들은 또한 공유 객체라고 불리며, 리눅스 시스템에서 .so 확장자로 나타낸다. 마이크로소프트 운영체는 많은 공유 라이브러리를 사용하며, 이들은 DLL (dynamic link libraries)라고 부른다.
공유 라이브러리들은 2가지 방법으로 공유되는데, 첫째는 어떤 주어진 파일 시스템에서 특정 라이브러리에 대해 정확히 1개의 .so 파일만이 존재하고, 이 파일 내의 코드와 데이터는 이 라이브러리를 참조하는 모든 실행 가능한 목적파일에 의해서 공유된다. 이 것은 참조하는 실행 파일들 내에 복사되고 내장되는 정적 라이브러리의 내용과는 정반대이다. 둘째는 메모리에 있는 공유 라이브러리의 .text 섹션은 서로 다른 실행중의 프로세스들에 의해 공유될 수 있다. 이 부분은 가상메모리에서 조금 더 자세히 다룬다.
Loading
리눅스 쉘에서 다음과 같이 ./prog 실행 파일 이름을 입력하면 해당 프로그램을 실행할 수 있는데, 쉘은 이를 실행 파일의 이름이라고 판단한다. 그리고 loader라고 불리는(메모리에 언제나 상주하는) OS의 코드를 실행함으로써 해당 프로그램의 실행을 개시한다. 리눅스 프로그램은 execve라는 함수를 호출함으로써 loader를 실행하도록 되어있다. loader는 디스크에 있는 실행 파일로부터 프로그램의 코드와 데이터를 메모리에 복사하고, Entry Point에 해당하는 첫 번째 명령어의 주소로 점프함으로써 해당 프로그램을 실행한다. 이러한 과정을 loading이라고 한다.
다음은 x86-64 리눅스에서 실행되는 모든 프로그램의 런타임 메모리 이미지이다.
즉, loader는 실행되면 위 그림과 유사한 메모리 이미지를 만들어 낸다. 그리고 실행 파일의 프로그램 헤더 테이블에 적힌 정보를 바탕으로 실행 파일의 연속적인 바이트 청크들을 코드 세그먼트와 데이터 세그먼트에 복사한다. 그리고 _start 함수의 시작 주소에 해당하는 Entry Point로 점프함으로써 해당 프로그램의 실행을 개시한다. 그리고 _start 함수는 system startup function인 __libc_start_main 함수를 호출한다. 이는 실행 환경을 초기화하고, 유저 프로그램의 main 함수를 호출하며, 그것의 반환 값을 처리하고, 필요한 경우에는 제어를 커널로 옮기는 역할을 수행한다.
'책 - 요약 정리 > CSAPP' 카테고리의 다른 글
Network Programming (0) | 2023.11.17 |
---|---|
Virtual Memory (0) | 2023.11.08 |
Exceptional Control Flow (0) | 2023.11.07 |
CSAPP - Procedures (1) | 2023.11.01 |