특히 부분만 읽어보려한다
소스코드에서, 머신 코드로 컴파일 과정 중 일어나는 하나가
링킹(linking) 이다.
이러한 링킹이 있기에,
소스 파일을 여러개로 독립적으로 구성하여,
필요할 때 호출시켜 이어준다.
이러한 역할을 하는 것을 로더(loader)라고 하며,
이 과정을 로드 타임이라고 하고,
별개로 실행 시에도 일어날 수 있다.
큰 프로그램 작성시,
모듈이 없어서, 라이브러리가 없어서,
라이브러리 버전이 안 맞아서,
생기는 에러의 이유를 파악할 수 있게 된다.
전역 변수가 중복 정의 되어 생기는,
프로그래밍 에러 의 이유를 짐작할 수 있다.
중요 시스템 개념 이해에 용이한데,
로딩, 시스템 함수, 가상 메모리, 페이징, 메모리 매핑 등의 걔념의 이유를 알게 된다.
공유 라이브러리 가 최근 들어 많이 쓰이는데,
그러한 라이브러리의 역할 등을 이해할 수 있다.
컴파일하는데 필요한 모듈을,
필요에 따라 호출하는 것을 컴파일러 드라이버 라고 한다.
컴파일 시
gcc -0g -o prog main.c sum.c
등의 명령어 자체가, 컴파일러 드라이버의 동작 내용의 요약이다.
소스 -> [ cpp(전처리기) -> ccl(컴파일러) -> as(어셈블러) ]
-> ld(링커) => prog
로 실행 가능한 파일이 만들어지고,
이때부터 shell이 loader를 호출하여
메모리에 복사, 프로그램을 실행할 수 있게 된다.
ELF, Executable and Linkable Format
... 은 목적 파일 형식을 가리킨다.
여기에서는 재배치 가능 목적 파일의 ELF 형식을 확인한다.
(재배치 가능 목적 파일이란,
실행 가능 목적 파일 전, 다른 목적파일들과 결할 될 수 있는
바이너리 코드와 데이터다.
간단히 말하자면, 링킹을 거치기 전 소스 파일에서
가공된 파일들이라 할 수 있다.)
목적 파일 형식은 11개의 Sections과,
그 섹션을 설명하는 Section header table로 이루어져있다.
구성은 이렇다.
ELF header
16바이트로 워드 크기, 시스템 바이트 순서 배열이 적혀이쏘,
목적 파일을 해설할 때 필요한 정보가 적혀있다.
(헤더 크기, 목적 파일 타입, 메인 타입 등...)
.text
컴파일한 프로그램의 머신 코드.
.rodata
Read-only data.
.data
초기화된 C 전역 변수, 정적 변수. 지역 변수는 포함되지 않는다.
.bss
초기화 안 된 C 전역 변수, 정적 변수. 0으로 초기화된 변수도 여기에 포함된다.
실제 공간을 차지하진 않고, 단순히 위치를 표시한다. (공간 효율성!)
.symtab
프로그램에 정의, 참조된 전역변수, 함수에 대한
심볼 테이블.
.rel.text
.text 섹션에 링커가 수정해야하는 위치 리스트.
(지역함수 인스트럭션은 그래서 포함되지 않는다.)
.rel.data
전역변수 재배치 정보.
(초기값이 전역변수, 또는 외부에 정의된 함수의 주소인 초기화된 전역변수... 등은 수정되어야한다.) (간단히 말하자면 전역 변수 등을 다시 재조정해야하는듯.)
.debug
지역변수, typedef, 최초 c파일에서 정의된 전역변수 등을 위한 디버깅 심볼 테이블.
(-g 옵션을 써야 생긴다.)
.line
최초 c파일과 text 머신 코드 라인 번호 간 매핑.
(-g 옵션을 써야 생긴다.)
.strtab
debug, symtab 헤더의 섹션 이름을 위한 string 테이블.
section header table
섹션 헤더들을 담은 테이블.
섹션들의 위치와 크기가 담겨져있다.
실행 가능 목적파일로 만들어지면,
loader가
메모리에 복사하여,
실행하기 위해
메모리에 복사된 지점 중 entry point에 점프한다.
이 전반적 과정을 로딩(loading)이라고 하며,
메모리에 복사된 가상 주소 메모리 공간을
추상화하여 그리면 이러한 순서다.
순...
code는 주소 0x400000에서 시작하여
위로 쭉 숫자가 올라가는 형식으로 전체 주소가 배분된다.
...
2^48 -1 의 주소에서는
user stack이 역으로 그 지점부터
아래로 늘어난다.
Runtime heap은 malloc에 따라
공간이 상승하며,
여기서 entry point는
code 사이에
_start 함수 주소가 있어 거기서부터 시작되는데,
(그 함수는 crtl.o라는 목적파일에서 정의됨)
그 함수가 시스템 초기화 함수 _libc_start_main 함수를 호출하여,
설정 환경 초기화, main 호출등을 진행한다....
....
링커는 이러한 주소 할당 역할을 하는데,
이를 ASLR, (address-space layout randomization),
주소 공간 배치 랜덤화 로 하여,
프로그램을 실행할 때마다
간격은 일정하게 해도, 실제 배정 주소는
늘 무작위로 설정된다.
임의의 인스트럭션을 I(k),
그 주소를 a(k) 라고 할때,
a(0), a(1), ... a(n-1)로 썼을 때,
a(k+1)등으로 이동하는 걸
제어 이동 (control transfer) 이라고 한다.
이 때 생기는 배열을
제어 흐름, control flow 라고 하는데,
대개는
순차적으로 진행되고,
예외로 jump, call에 따라 움직이는데,
실제로는 프로그램 외 시스템의 상태 변화 등도
항상 감지, 제어가 가능해야하기 때문에,
그를 조정하기 위해 제어흐름에 변화를 준다.
이러한 것을
예외적 제어흐름, exceptional control flow, ECF 라고 하고,
except handler 가
현재 프로세스에서, 다른 프로세스로,
커널 수준의 문맥 전환을 일으킨다.
(임의의 위치로 비지역성 점프...라고도 함.)
ECF는 운영체제의 기본 메커니즘이기때문에,
운영 체제를 이해할 수 있다.
app이 OS에 trap이나 systemcall을 보내는
상호작용을 이해할 수 있다.
ECF 메커니즘으로 쉘이나 웹 서버를 제작해볼 수 있다.
동시성에 대한 이해력이 높아진다.
(프로세스, 스레드, 예외 핸들러, signal handler 등.)
소프트웨어적 예외상황 동작을 이해할 수 있다.
(nonlocal jump...)
(call/return에 위배되는 jump)
현재 인스트럭션을 I(curr), 다음 인스트럭션을 I(next)라고 할때,
exception을 유발하는 상황을 모두
event 라고 한다.
크게 이벤트의 이유 두 가지는
인데,
이름 jump table, exception table에 따라 한다.
그 역할을 수행하는 건 except handler 다.
그러한 처리를 거치면 일어나는 경우는 세 가지다.
하드웨어 - 소프트웨어의 작업 분배가
어떻게 이루어지는지 살펴보자.
한 시스템 내에서,
exception number, 예외 번호 를 할당한다.
프로세서인지, 커널에서 하는 거인지에 따라
예외가 다르다.
이 예외 번호는
exception base register 에서 출발하여,
exception number을 인덱스로 타고 가서,
해당하는 exception table로 진입한다.
exception table에는
해당하는 넘버에 관한 handler 주소가 적혀있다.
프로시저 콜과 다른 점은,
interupt
입출력에서 보낸 신호에 따라 발생,
async, 비동기적으로 일어나며,
다음 인스트럭션 주소로 이동.
(시스템 때문에 발생하고, 이러한 상황은 보통
자연스럽게 흘러가는 것처럼 보이게...함.)
trap
의도적 에러.
sync 하게 일어나며, 다음 인스트럭션으로 이동.
fault
잠재적 복귀 가능 에러,
sync하게 일어나며,
최근 인스트럭션으로 이동.
abort
복귀 불가 에러.
sync하게 일어나며,
어떤 주소로도 돌아가지 않음.
프로세서 pin에 신호를 보내,
except number를 보내둔다.
그러면 현재 인스트럭션 실행이 끝나면 pin이 올라간걸 보고
번호를 읽고 핸들러를 호출한다.
핸들러가 처리를 끝내면 다음 인스트럭션으로 돌려준다.
1을 제외하고,
모두 faulting instrucion이라고 부른다.
(오류 인스트럭션..)
Trap은 전반적으로
system call과 유사한 인터페이스를 띈다.
syscall 의 기능 중 세 가지 기능을 쓰는데,
... 등을 하기 위해,
n 인스트럭션을 trap은 추가로 사용한다.
실제 동작은
syscall 인스트럭션을 보내면,
그 오류에 따라 적절한 예외 핸들러, Trap handler한테 넘어가
I(next), 즉 다음 인스트럭션으로 제어를 넘겨준다.
단, 사용자 모드에서 돌아가므로,
...한다는 점이 있다.
핸들러는
에러 조건이 정정 가능하다면 재실행 하지만,
정정이 불가하면 abort로 넘겨 종료한다.
재실행의 흔한 예로는
페이지 오류 가 있다.
Page는,
가상 메모리의 연속적 블록으로,
가상 메모리 테이블 참조할 때,
실제 메모리에 참조할 page가 존재하지 않는다면,
디스크에서 적절한 페이지를 대신 로드한다.
(그래서 로드하는 페이지만 다르지
로드는 했으므로 재실행으로 간주.)
DRAM, SRAM 고장 등,
치명적 에러에서 발생하며 그대로 프로그램을 종료한다.
예외 상황은 약 256개의 서로 다른 경우로 존재한다.
대표적인 것은 이렇다.
Divide error
프로그램이 0으로 나누려할 때,
나눗셈 인스트럭션 결과가 목적지 오퍼랜드에 비해 클 때.
(Unix는 복구를 시도하지 않고 프로그램을 중단.)
(리눅스는 부동소수 예외로 보고.)
General protection fault (13)
가상 메모리의 정의 되지 않은 영역 참조 시.
read-only segment에 쓰려할 때 등.
대개 Segment fault로 보고.
page fault (14)
이 때는 오류 발생 지점 인스트럭션을 재실행함.
디스크의 가상 메모리 page - 물리 메모리 page
를 매핑 시킨 후, 다시 시작한다.
Machine check (18)
치명적 하드웨어 에러.
제어를 응용프로그램에 돌려주지 않는다.
syscall이 일어나면,
kernel jump table에 있는
offset에 접근한다.
(kernel jump table과 exception table은 다르다.)
(전자는 함수 포인터에 가깝고, 후자는 예외 처리 상황에서만 발생한다.)
이 동작은
syscall 함수로 가능하나,
실제로 사용할 필요는 없고,
컴퓨터가 필요할 때 사용한다.
(사용자가 직접 쓸 일은 거의 없다.)
대개 C의 표준 라이브러리 함수가
syscall로 wrapper 함수를 호출하는데 사용한다.
해당 함수는
등의 역할을 수행할 수 있다.
그래서
syscall과 wrapper 함수를 시스템 수준 함수 라고 부른다.
...
syscall의 인자는
범용 레지스터로 이루어지는데,
예를 들어,
...
등으로 이루어진다.
상위 수준 소프트웨어 형태로,
예외적인 제어흐름을 알린다.
(간단히 말하자면,
하드웨어적보다는
그보다 소프트웨어 수준에서 적용시킬 수 있는,
제어 흐름 작동의 일종을 말한다.)
signal type에 따라,
특정 system event에 대응한다.
...
시그널을 목적 프로세스에 전달하는 데에는
2단계로 이루어진다.
이 때,
보내졌지만 아직 받지 않은 시그널을
pending signal이라고 한다.
어떤 시점이든,
특정 타입에 대해 최대 1개만 존재가 가능하다.
(같은 타입이 또 보내진다면, 보관하지 않는다.)
프로세스마다 특정 시그널 수신 자체를 block하는 것도 가능하다.
(pending은 되지만 수신되진 않는 것이다.)
kernel에서 두 개의 벡터로 보관한다.
bit k를 타입 k 시그널이 도착할 때마다
설정한다.
(수신이 완료되면 Pending 안에 있는 비트를 0으로 설정하는등..)
process group 개념이 사용된다.
(프로세스마다 일종의 아이디를 할당해서
시그널 받고 보내는 객체를 구별함.)
모든 프로세스는 1개의 group에 속한다.
(양수의 process group ID로 식별한다.)
예를 들어,
/bin/kill -9 15213
으로 보내면, SIGKILL(9)을 프로세스 15213에 보낸다는 뜻이다.
/bin/kill -9 -15213
은 15213과 같은 pgid를 가진
모든 process에 전달한다.
이러한 /bin/kill같은
완전한 경로를 쓰는 이유는,
쉘 내에 내장 명령어 kill함수가 따로 있기 때문이다.
job(작업)으로 추상화하는데,
어떤 시점이든,
예를 들어,
ls | sort
같은 명령어를 치면,
두 개의 명령어를 한 개의 fore ground로 간주.
(같은 pgid를 갖게됨..)
fore ground는 연속하여 실행되면
부모 프로세스에,
자식 프로세스가 연결되는데,
자식 프로세스는 pid는 각자 다르게 갖되
pgid는 부모프로세스와 같은 값을 갖는다.
kill은
를 향해 보낼 수 있다.
형식은
int kill(pid_t pid, int sig)
로,
....이다.
자기 자식에게 보낼 떄는
kill(pid,SIGKILL)
로 보낸다.
alarm 함수 는
커널이 sec초마다 SIGALAM 시그널을 보내게 해준다.
만약 설정한 sec = 0이면,
새로운 알람을 보내지 않는다.
unsigned int alarm(unsigned in secs);
이때 어떤 이벤트가 발생하든,
그 함수 자체가 취소되지 않는 한,
남은 시간을 초 단위로 반환하고,
시간이 남지 않았다면 0을 리턴한다.
가상 메모리 영역은
mmap, munmap 함수로 생성, 삭제가 가능하다.
이때,
추가적 가상 메모리를 런타임 동안 할당하고 싶다면,
dynamic memory allocator를 사용한다.
해당 allocator는 heap을 관리하고,
힙의 꼭대기를 가르키는 변수 brk를 사용한다.
....
allocator는
다양한 크기의 block을 집합으로 관리하는데,
으로 구분한다.
allocator는
기본적인 유형이 2가지가 있는데,
할당 반환시 무엇을 사용하느냐에 따라 갈린다.
Explicit allocator
명시적 할당기.(이름 특이하다)
malloc, free 함수가 여기에 속하며,
C++ 에서는 new, delete와 유사하다.
Implicit allocator
묵시적 할당기.
garbage collector가 여기에 해당한다.
자동으로 사용하지 않는 할당된 블록을 반환한다.
데이터 객체에 대해
최소 size byte를 갖는 메모리 블록 ptr을 리턴하는 함수다.
(32bit면 8배수, 64비트면 16배수로 리턴한다.)
할당 가능 크기보다
더 큰 크기를 요청한다면 Null을 리턴하고,
errno를 설정한다.
초기화한 동적 메모리가 필요하면
calloc을 사용한다.
calloc은 malloc + wrapper 형태인데,
wrapper 함수가 메모리를 0으로 초기화해준다.
또,
앞선 block 크기만큼 하고 싶다면
realloc 함수를 사용한다.
실제 malloc 함수는
mmap, munmap 함수로 할당, 반환을 하거나
sbrk 함수를 사용한다.
후자 함수의 형식은
void *sbrk(int ptr_t, incr);
인데,
커널 brk 포인터에 incr을 더해 힙을 늘리거나 줄인다.
void free(void *ptr)
ptr에는 할당된 블록의 시작 주소를 담는것을 사용한다.
단,
잘못 되어도 아무것도 리턴하지않아
prog에 잘못된걸 알아낼 수 없고,
런타임 에러의 흔한 오류가 된다...
(실제 동작은
순서대로 할당하되,
free시 그 시점 포인터를 그대로 저장해둬
그 시점부터 할당하는 메커니즘을 예제로 설명하고 있다.)