6 minute read

Computer Systems : A Programmer’s Perspective (Chapter 7)

이전 글 - 1. 전처리기, 컴파일러, 어셈블러

2. Static Linking

서론

앞선 글에서는 main.c와 sum.c가 따로따로 전처리기 -> 컴파일러 -> 어셈블러의 작업을 거쳐서 relocatable object file이 되는 과정을 살펴보았다.

main.csum.c를 include하고 있기 때문에 relocatable object file main.o 내의 sum.c관련 부분은 “아직 뭔지 모른다”의 상태이다. 이런 상태의 relocatable file들을 모아서 한 번에 실행될 수 있는 fully linked executable object file을 만드는 과정이 linking이다.

Linking에서 해야 할 일은 두 가지이다.

  1. Symbol resolution
    소스 파일의 각종 변수 이름들과 함수 이름들을 symbol 이라고 부른다. main.csum, array, val 등이 symbol이다. Linking에서는 각 symbol들이 정화히 뭘 의미하는지를 확실히 해야 한다. 가장 익숙한 예는 지역변수이다. 전역 변수 val1이 있는데 지역 변수 val1이 또 등장할 수도 있는데, 이런 경우에 어떤 symbol이 어떤 definition에 대응되는지 정확히 1대1로 정해줘야한다.
  2. Relocation
    컴파일러와 어셈블러의 작업 후 생긴 relocatable object file은 binary file이다. 연속된 바이트의 배열인 이 파일에는 정해진 구역이 있는데, instruction들이 한 구역에, 초기화가 된 전역변수들이 다른 한 구역에, 그리고 초기화되지 않은 변수들이 또다른 한 구역에 위치한다.
    각 relocatable object file 각각의 code section과 data section에서 모르던 남의 symbol들의 정체를 밝히고 연결시켜 줄 때, 해당 section에서 각 symbol들의 주소를 알맞게 재배치해줘야하고 이를 relocation이라고 부른다. 어셈블러가 만들어 준 relocation entry 를 이용해서 작업을 진행하게 된다.

Relocatable Object file의 구조

각 system마다 object file의 형식이 정해져 있는데, 현대의 x86-64 Linux와 Unix는 ELF 라는 형식을 사용한다.

image

여러 개의 구역과 맨 아래의 section header table로 구성되어 있다.
맨 위의 header는 16바이트 크기로, 먼저 이 파일을 생성한 시스템의 word size와 byte ordering(Big Endian인지 Small Endian인지..)의 정보가 적혀 있다. 헤더의 나머지 부분은 linker가 이 파일을 작업할 때 parsing하고 interpret할 때 필요한 정보가 들어 있다.
가장 아래에는 section header table이 있는데, object file 내에서 정해진 크기의 entry에 각 구역의 크기와 위치가 적혀 있다.
일반적인 ELF reloatable object file은 다음과 같은 구역들로 구성된다.

  • .text : 컴파일된 프로그램의 machine code가 담겨 있다.
  • .rodata : printf같은 명령어의 format string이나 switch문의 jump table 같은 읽기 전용 데이터가 위치한다.
  • .data : 초기값이 설정된(initialization) global, static 변수들이 위치한다. 지역 변수들은 run time에 stack에만 올라가고, 여기 외에 object file의 다른 구역에도 없다.
  • .bss : 초기값이 설정되지 않았거나 0으로 초기화된 global, static 변수들이 위치한다. 이들은 object file에서 실제로 공간을 차지하지는 않고 그냥 틀만 존재한다. 이들은 디스크에서 공간을 차지하고 있지 않다가 run time에 초기값 0으로 메모리에 할당되게 된다.
  • symtab : 함수와 전역 변수에 대한 symbol table이 담겨 있다.
  • rel.text : .text 구역에서 다른 object file들과 linking을 통해 수정되어야 ‘위치’들이 담겨 있다. extern function이나 extern reference를 부르는 instruction의 ‘위치’는 보통 반드시 수정된다. ‘위치’에 대해서는 relocation에서 자세히 다룰 것이다. 이 구역은 executable object file에는 포함될 필요가 없으므로, 따로 옵션을 지정해서 linking 하지 않는 한 포함되지 않는다.
  • rel.data : 전역 변수들의 relocation 정보가 담겨 있다. 일반적으로, 전역 변수의 주소나 외부에서 정의된 함수의 주소를 값으로 갖는 전역 변수는 수정될 것이다. 역시 relocation 부분에서 자세히 다룰 예정이다.
  • .strtab : .symtab.debug에 있는 symbol table과 section header에서 각 구역의 명칭을 위한 문자열 table이 담겨있다. Null-terminated 문자열들의 sequence로 이루어져 있다.

Symbol, Symbol table, Symbol resolution

각 relocatable object module m은 m 내에서 정의되거나 참조된 symbol들에 대한 정보가 담긴 symbol table을 갖는다. Linker가 보기에 m의 symbol에는 세 종류가 존재한다.

  • Global symbol : m이 정의했지만, 다른 module들도 참조할 수 있는 것들이다. Static이 아닌 함수와 전역 변수가 해당한다.
  • External symbol : 다른 module이 정의하고 m이 참조한 것들이다. (다른 module에서 정의된) static이 아닌 함수와 전역 변수가 해당한다.
  • Local symbol : m에서 정의되고 m에서만 참조되는 것들이다. Static으로 정의된 함수 및 변수가 해당한다.

Linker symbol에서 local의 의미와 C언어에서 local 변수에는 차이가 있다는 점에 주목해야 한다. C언어의 local nonstatic symbol은 .symtab에 포함되지 않는다. 대신, local static 변수는 .data나 .bss에 위치하게 된다.

int f()
{
    static int x = 0;
}
int g()
{
    static int x = 1;
}

이런 프로그램이 있으면, 컴파일러는 두 x에 x.1, x.2와 같이 다른 이름을 붙여서 어셈블러에게 넘긴다.

Symbol table을 작성하는 것은 어셈블러의 일이다. 컴파일러가 보내준 symbol들로 symbol table을 만들게 된다. Table의 entry는 구조체로 되어 있는데, 멤버 중에 short section에는 각 symbol이 object file 내에서 어떤 구역에 속하는 지의 정보가 담겨 있다. 앞에서 다룬, section header table에 나와 있는 구역 말고 몇 개의 pseudosection이 있다.
이 중 COMMON은 .bss와 유사한데, 앞에서 .bss에는 초기값이 지정되지 않은 global 및 static 변수, 그리고 초기값이 0으로 지정된 global 및 static 변수가 속한다고 했다. 이 중에서 초기화되지 않은 global 변수는 COMMON에 속하고, 나머지가 .bss 구역에 속한다.

여러 종류의 symbol들이 object file 내에서 속하는 구역을 정리하면 다음과 같다.

종류 Section
초기값이 정해진 global 변수 .data
초기값이 정해진 static 변수 .data
초기값이 정해지지 않은 gloal 변수 COMMON
초기값이 0인 global 변수 .bss
초기값이 정해지지 않은 static 변수 .bss
초기값이 0인 static 변수 .bss
Local static 변수 .bss
Local nonstatic 변수 존재하지 않음


이제 본격적으로 linker가 여러 relocatable object file을 어떻게 합치는 지 살펴보자.

각 relocatable object file마다 symbol table이 있을 텐데, 코드 내의 모든 reference를 딱 하나의 symbol로 정해 주는 과정이 필요하다. 지역변수같은 경우는 module별로 이름이 중복되지 않도록 컴파일러가 신경써 주기 때문에 linker가 따로 할 게 없지만 전역변수의 경우 상황이 다르다. 다른 module에서 정의된 것의 참조가 있으면 컴파일러는 ‘다른 곳에서 정의된 거겠구나’ 생각하고 linker에게 symbol table entry로 넘겨서 처리를 맡긴다. Linker는 정해진 규칙에 따라 resolution을 하게 되고, 실패하면 오류를 내고 종료한다.

먼저 컴파일러는 각 심볼이 ‘strong’한지 ‘weak’한지 판별해서 linker에게 보낸다. 함수의 이름, 초기화된 전역 변수의 이름이 strong symbol이고 초기화되지 않은 전역 변수의 이름은 weak symbol이다.
Strong symbol과 weak symbol로 다음 규칙을 정한다.

  1. 두 strong symbol의 이름이 같은 건 허용되지 않는다.
  2. 하나의 strong symbol과 여러 weak symbol의 이름이 같으면, 그 참조는 strong symbol에 대한 것으로 간주한다.
  3. 여러 weak symbol의 이름이 같으면 아무 거나 선택한다.


이 규칙 때문에 전역 변수를 사용할 때는 주의해야 한다. 다음 예시를 살펴보자.

/* foo.c */
#include <stdio.h>
void f();

int y = 15213;
int x = 15212;

int main()
{
    f();
    printf("%p %p\n", &x, &y);
    printf("x = 0x%x, y = 0x%x \n", x, y);
}
/* bar.c */
double x;
void f()
{
    x = -0.0;
}

foo.cbar.c를 link해서 executable object file을 만들면(gcc foo.c bar.c), ./a.out으로 실행했을 때 어떤 결과가 나올까?
foo.c에서 x와 y는 초기값이 지정된 전역 변수이니 global symbol이면서 strong symbol이다. 반면 bar.c에서의 x는 전역 변수이지만 초기값이 지정되지 않았으므로 global symbol이지만 weak symbol이다. 따라서 linker는 함수 f에서 x를 참조할 때, bar.c의 전역변수 double x가 아닌 foo.c의 int x의 심볼을 연결짓게 된다.
하지만 문제는 두 x의 자료형이 다르다는 것이다. bar.c로부터 나온 bar.o의 code section에는, x를 double로 생각하고 machine instruction이 짜여 있다. 즉 bar.o의 instruction을 수행하면 -0.0에 해당하는 8바이트 값 0x8000000000000000가 주소에 할당된다. 하지만 linker가 생각한 그 주소는 foo.c의 int x라서 이 instruction은 int x의 범위를 침범하게 된다. 공교롭게도, foo.c에서 x와 y가 연달아서 정의됐기 때문에 x의 4바이트 다음 4바이트는 y의 공간이다. 따라서 0x80000000 부분이 위로 삐져나가서 y를 잡아먹게 되고, x에는 0x00000000만 저장된다.
gcc도 바보는 아니라서, 이 문제를 예측하고 경고를 띄워 준다.

image


전역 변수를 사용할 때 생길 수 있는 이런 문제를 방지하려면 꼭 초기화를 해서 strong symbol로 만들고, 실수로 이름이 겹치면 linking에서 에러가 나도록 해야 한다.
전역 변수를 해당 파일 내에서만 쓸 경우 static으로 지정하는 방법도 있고, 다른 object module에서 정의된 걸 가져와 쓸 때는 아무 말 없이 쓰지 말고 꼭 앞에 extern을 붙여서 표시해 주자. 애초에 전역 변수를 최대한 쓰지 않는 것이 상책일 수도 있겠다.

Static Library 사용하기

Standard function 처럼 자주 쓰는 함수들은 따로 packaging해서 쓰는 게 편하다. 자주 쓰는 함수의 소스 코드를 relocatable object file(.o)로 만들어 놓고, 이 함수들을 쓰는 새로운 프로그램을 만들 때 이거들을 linking해서 사용하면 좋을 것이다. 이런 목적으로, 여러 relocatable object file을 묶어 놓은 것을 static library라고 부른다.

Linker는 우리의 새 프로그램에서 사용되는 library의 함수가 있다면, library에서 해당 module만 복사해 와서 executable object file을 구성하게 된다.
리눅스에서는 static library가 archive 라는 file format으로 저장되어 있고, .a의 확장자를 가진다.
Static library를 만드는 방법은 다음과 같다.

/* addvec.c */
void addvec(int *x, int *y, int *z, int n)
{
    for(int i = 0 ; i < n ; i++) z[i] = x[i] + y[i]; 
}
/* multvec.c */
void multvec(int *x, int *y, int *z, int n)
{
    for(int i = 0 ; i < n ; i++) z[i] = x[i] * y[i]; 
}

벡터 계산을 하는 두 함수가 있다. 이 둘을 묶어서 static libarary를 만드려면

gcc -c addvec.c multvec.c
ar rcs libvector.a addvec.o multvec.o

첫 번째 명령어로 addvec.c와 multvec.o를 전처리, 컴파일, 어셈블을 거쳐서 relocatable object file로 만든 후, 두 번째 명령어를 통해 libvector.a라는 static library를 만들 수 있다.
만든 static library를 linking에 사용하는 과정은 다음과 같다.

main2.c라는 프로그램에서 addvec 함수를 사용한다고 하자.

gcc -c main2 main2.c
gcc -static -o prog2c main2.o ./libvector.a

첫 번째 명령어가 main2.o를 생성하고, 두 번째 명령어는 아까 만든 static library libvector.a에서 addvector.o부분만 불러와서 executable object file prog2c에 복사해 넣는다. 그리고 linker는 항상 libc.a라는 static library를 같이 link하는데, 여기에는 각종 표준 라이브러리의 함수들이 들어 있다.

Tags:

Categories:

Updated: