프로그램을 오늘날 시스템에서 실행할 때, 마치 시스템 전체에서 단 하나의 프로그램만 돌아가는 것 같은 착각이 들게 된다. 즉 우리의 프로그램은 시스템의 프로세서와 메모리를 독점하여 사용하는 것처럼 보인다. 이러한 착각은 프로세스라는 개념에 의해서 이뤄진다.
프로세스의 고전적 정의는 실행 프로그램의 인스턴스이다. 시스템 내의 각 프로그램은 어떤 프로세스의 컨텍스트에서 돌아간다. 이때 컨텍스트는 프로그램이 정확하게 돌아가기 위해 구성된 상태라고 생각할 수 있다. 예를 들어 메모리에 저장된 프로그램의 코드(.text)와 데이터(.bss, .data, stack, heap), 프로그램 카운터, 환경 변수 열려있는 파일 디스크립터 등이 그것이다.
사용자가 실행 목적 파일의 이름을 쉘에 입력해서 프로그램을 돌릴 때마다 쉘은 새로운 프로세스를 생성하고 실행 목적 파일을 새롭게 생성된 프로세스의 문맥에서 실행한다.
또한 응용 프로그램에서도 유저는 fork 를 통해서 새로운 프로세스를 만들 수 있으며, 이때 생성된 프로세스의 문맥 내에서 자신의 코드나 다른 응용 프로그램의 코드를 돌릴 수 있다.
프로세스는 독립된 논리적 제어 흐름이다.
프로세스는 다른 많은 프로그램들이 일반적으로 시스템에서 동시에 실행되고 있음에도 불구하고 각 프로그램이 프로세서를 독점적으로 사용하는 것처럼 착각하게 한다. 만약 우리가 디버거를 사용하여 프로그램의 실행을 한 단계씩 진행해보면, 프로그램 카운터(PC) 값을 관찰할 수 있을 것이다. 이러한 일련의 PC 값을 논리적 제어 흐름 또는 단순히 논리적 흐름이라고 부른다.
위의 그림을 살펴보자. 세 개의 프로세스가 실행되고 있으며 이 프로세서의 하나의 물리적 흐름은 각 프로세스에 대해서 한 개씩 세 개의 논리적 흐름으로 나뉘어진다. 예제에서 세 개의 논리흐름들이 서로 중첩되지 않는다는 점을 중점적으로 살펴보자.
프로세스 A는 잠시동안 돌다가 B가 실행되고 C가 실행된다. 다시 A가 동작하다가 다시 C가 실행된다. 해당 그림의 요점은 하나의 프로세서를 사용해서 여러 프로세스들이 교대로 돌아간다는 점이다. 각 프로세스는 자신의 흐름의 일부분을 실행하고 나서 다른 프로세스들로 순서를 바꾸어 실행하는 동안 선점된다(일시적으로 정지된다). 이 프로세스들 중에서 하나의 문맥에서 실행되는 프로그램은 순간 마치 프로세서를 배타적으로 소유한 것처럼 보인다.
이렇게 중간에 프로세스의 동작이 멈춰도 문제는 없다. 프로세서가 정지할 때마다 프로그램의 메모리 위치나 레지스터 내용에 변경되는 사항 없이 프로그램 실행은 순차적으로 다시 실행되며, 실행되는 프로세스가 달라지면 컨텍스트 스위칭이라는 과정을 거쳐 번갈아 여러 프로세스가 수행되도 문제가 없게 된다.
프로세스는 Private 주소 공간을 갖는다.
프로세스는 각 프로그램에 자신이 시스템의 주소공간을 혼자서 사용한다는 착각을 불러일으킨다. 프로세스는 각 프로그램에 자신만의 사적 주소 공간을 제공한다. 이 공간의 특정 주소에 연결된 메모리의 한 개의 바이트가 일반적으로 다른 프로세스에 의해서는 읽혀지거나 쓰여질 수 없다.
비록 각각의 사적 주소공간에 연결된 메모리의 내용이 일반적으로 서로 다를지라도 각각의 이런 공간은 동일한 일반적인 구조를 갖는다.
예를 들어 위의 그림은 x86 프로세스에 대한 주소공간의 구조를 보여준다. 주소 공간의 아랫부분은 텍스트, 데이터, 힙, 스택 세그먼트를 갖는 사용자 프로그램을 위해 예약된다. 이와 같은 프로세스의 주소공간 구조는 모든 프로세스가 공유하게 된다는 점을 기억하자. 이러한 공간들은 가상 메모리 체계에 의해서 관리된다.
동시성 흐름
논리 흐름은 컴퓨터 시스템 내에서 여러 가지 형태를 가지며, 예외 핸들러, 프로세스, 시그널 핸들러, 쓰레드, 자바 프로세스 등이 이러한 논리흐름의 예이다.
이 중 자신의 실행 시간이 다른 흐름과 겹치는 논리흐름은 동시성 흐름이라고 부르게 된다. 좀 더 엄밀히 말해서 만일 Y가 시작해서 Y가 완전히 종료되기 이전에 X가 시작되고, 또 다시 X가 종료되기 이전에 Y가 다시 한 번이라도 시작이 된다면 이를 동시성 흐름이라고 할 수 있다.
이처럼 프로세스가 다른 프로세스들과 교대로 실행되는 것을 멀티태스킹이라고 하며, 한 프로세스가 자신의 흐름 중 일부를 실행하는 매시간 주기는 타임 슬라이스라고 부른다.
앞서 살펴본 동시성 흐름의 경우에는 흐름이 돌아가는 프로세스 코어나 컴퓨터의 개수에 무관하다. 만약 두 흐름이 시간상 중첩된다면, 이들이 동일한 프로세서에서 돌아가더라도 이들은 동시적이다.
그렇지만 병렬 흐름이라고 알려진 동시성 흐름의 부분집합을 구별하는 것이 필요하다. 만일 두 개의 흐름이 서로 다른 프로세서 코어나 컴퓨터에서 동시에 돌아가고 있다면 이는 병렬 흐름(parrallel flow)이라고 부르게 된다.
Init process (pid = 1)
각각의 프로세스는 공유한 id로 식별이 가능하다. 프로세는 파일 디렉토리와 같은 계층적인 구조를 가지고 있으며 자식과 부모 관계를 갖는다. 그렇게 따라 올라간 프로세스 트리의 꼭대기에는 하나의 제어 프로세스가 존재하는데 해당 프로세스는 init 프로세스라고 불리며, 궁극적으로 모든 시스템과 사용자 프로세스의 조상으로 정의된다.
'CS > OS' 카테고리의 다른 글
시스템 콜의 에러 처리 (0) | 2022.12.07 |
---|---|
컨텍스트 스위치 (0) | 2022.12.06 |
Exception Control Flow 소개 (0) | 2022.12.06 |
공유 라이브러리로 동적 링크하기 (run-time linking) (0) | 2022.12.06 |
공유 라이브러리로 동적 링크하기 (load-time linking) (0) | 2022.12.05 |
프로세서에 전원을 처음 공급하는 시점부터 전원을 끌 때까지 프로그램 카운터는 연속된 값들을 가정한다. 프로그램 카운터는 계속해서 인스트럭션 I에 해당하는 주소를 받게 되고, 이러한 제어 이동의 배열을 제어 흐름 혹은 프로세서의 제어 흐름이라고 부른다.
대부분의 상황에서는 우리가 생각하는 점진적인 순서로 프로그램을 실행하기 위한 제어 흐름만이 필요하겠지만, 시스템들은 내부 프로그램 변수에 의해 표시되지 않으며 프로그램 실행과는 상관 없이 시스템의 상태 변화를 추척하고 반응할 수 있는 방법이 필요하다.
예를 들어 하드웨어 타이머는 규칙적인 간격으로 꺼지며, 시스템은 이를 반드시 처리해야만 한다. 또한 프로그램은 디스크로부터 데이터를 요청하며, 그 후에 데이터가 준비되었다는 통지를 받을 때까지 다른 일을 수행한다. 이를 DMA라고한다. 자식 프로세스를 생성하는 부모 프로세스는 자신의 자식이 종료되었을 때 SIG_CHLD 시그널을 받는다는 것도 기존 제어 흐름만으로는 설명이 불가능하다.
현대의 시스템들은 제어흐름의 갑작스런 변화를 반드는 방법으로 이러한 상황에 반응한다. 일반적으로 이와 같은 급격한 변화를 예외적인 제어흐름(Exceptional control flow, ECF)라고 한다. 예외적인 제어 흐름은 컴퓨터 시스템의 모든 수준에서 발생한다. 예를 들어 하드웨어 수준에서 하드웨어에 의해 검출되는 이벤트들은 예외 핸들러로 갑작스런 제어의 이동을 발생시킨다.
운영체제 커널 수준의 문맥전환을 통해 사용자 프로세스에서 다른 프로세스로의 제어가 이동하는 것도 이러한 ECF의 예시이다. 또한 응용 수준에서 프로세스는 시그널을 수신하고 있는 시그널 핸들러로 제어를 급격히 이동하는 다른 프로세스로 시그널을 보낼 수 있다. 개별 프로그램은 일반적인 스택 운영을 회피하고 다른 함수 내의 임의의 위치로 비지역성 점프를 하는 방법으로 에러에 대응할 수 있다.
예외 상황
예외 상황은 어떤 프로세스 상에 상태의 변화에 대한 대응으로 발생하는 제어흐름의 갑작스런 변화이다.
위의 그림은 예외 상황에 대한 기본 아이디어를 보여준다. 그림에서 프로세서는 현재 어던 명령어 I를 실행하고 있으며, 이때 프로세서 상태의 중요한 변화가 일어나고 있다. 이 상태는 프로세서 내 다양한 비트들과 신호들로 인코드된다.
이러한 상태 변화를 이벤트라고 하며, 이 이벤트는 현재 인스트럭션의 실행에 직접적으로 관련되는 경우가 있다. 예를 들어, 가상 메모리의 페이지 오류, 산술 오버플로우, divide by zero를 시도하는 경우가 이러한 이벤트의 예시이다.
또 반대로 이벤트가 현재 인스트럭션의 실행과 관련이 없을 수 있다. 예를 들어 시스템 타이머가 정지하거나 I/O 요청이 완료되는 경우가 그렇다.
어느 경우이던지 프로세서가 이벤트의 발생을 감지하면 예외 테이블이라고 부리는 점프 테이블을 통해 이 특정 종류의 이벤트를 처리하기 위해 특별히 설계된 운영체제의 서브 루틴으로 간접 프로시져 콜을 하게 된다.
the indirect call uses an instruction call with a register as argument (here rax). The register is previously loaded either directly with the fixed address of the subroutine that is to be called, or with a value fetched from somewhere else, such as another register or a place in memory where the subroutine’s address was previously stored.
간접 호출은 레지스터를 인수(여기서 rax)로 사용하는 명령 호출을 사용합니다. 레지스터는 호출될 서브루틴의 고정 주소로 직접 로드되거나 다른 레지스터 또는 서브루틴의 주소가 이전에 저장된 메모리의 위치와 같은 다른 위치에서 가져온 값으로 로드됩니다.
예외 처리 핸들러가 처리를 끝마치면 예외 상황을 발생시킨 이벤트의 종류에 따라 다음과 같은 세 가지 중에 한 가지 일이 발생한다.
- 핸들러는 제어를 중단된 기존 인스트럭션으로 다시 돌려준다.
- 핸들러는 제어를 예외 상황이 발생하지 않았다면 다음에 실행되었을 명령어로 돌려준다.
- 핸들러는 중단된 프로그램을 종료한다.
예외 처리
한 시스템 내에서는 가능한 예외 상황의 종류마다 중복되지 않는 양의 정수를 예외번호로 할당하고 있다. 이 숫자들의 일부는 프로세서 설계자가 부여한 것으로, 나머지 번호는 운영체제 커널의 설계자가 할당한다. 전자의 예로는 divide by zero, 페이지 오류, 메모리 접근 위반, breakpoint, 산술연산 오버플로우가 포함된다. 후자의 예에는 시스템 콜, 외부 I/O 디바이스로부터의 시그널이 포함된다.
시스템 부팅 시 운영체제는 예외 테이블이라고 하는 점프 테이블을 할당하고 초기화하여 엔트리 k가 예외 상황 k에 대한 핸들러의 주소를 갖는다. 위의 그림에서 각각의 예외 상황에 대해 번호가 붙어있는 것을 확인할 수 있다. 시스템이 부팅되는 경우 운영체제는 예외 테이블이라고 하는 점프 테이블을 할당하고 초기화하여 각각의 엔트리 k가 예외상황 k에 대한 핸들러의 주소를 갖는다. 예를들어 divide error 가 발생할 경우, 예외 테이블의 0 번으로 접근하여 해당 엔트리에 연결된 divide error를 처리하기 위한 핸들러의 주소를 가지고 적절한 대응이 가능할 것이다.
즉 런타임 시에 프로세서는 이벤트의 발생을 감지하고 대응되는 예외 번호 k를 결정한다. 프로세서는 그 후에 예외 테이블의 엔트리 k를 통해 간접 프로시져 콜을 하는 방법으로 예외 상황을 발생시킨다.
예외의 종류
앞서 계속 설명한 예외 상황은 총 4 가지의 종류로 구분이 가능하다.
- 인터럽트 (Interrupt)
- 트랩 (trap)
- 오류 (fault)
- 중단 (abort)
인터럽트
인터럽트는 프로세서 외부에 있는 입출력 디바이스로부터의 시그널의 결과로 비동기적(Asynchronous)으로 발생한다. 하드웨어 인터럽트는 비동기적이며, 이러한 예시로 DMA를 생각할 수 있다. 이러한 하드웨어 인터럽트를 위한 예외 핸들러는 인터럽트 핸들러라고 불리며, 위 그림 2와 같은 예외 테이블 중 인터럽트 테이블을 거쳐 동작한다.
위의 그림은 인터럽트의 처리 과정을 요약한 것이다. 네트워크 어뎁터, 디스크 컨트롤러, 타이머 칩과 같은 입출력 디바이스들은 프로세서 칩의 핀에 시그널을 보내서 인터럽트를 발생시키고, 인터럽트에 대한 예외 번호를 시스템 버스에 보낸다.
현재의 인스트럭션이 실행을 완료한 후에 프로세서는 인터럽트 핀이 high로 올라갔다는 것을 인지하고, 시스템 버스에서 예외 번호를 읽으며 적당한 인터럽트 핸들러를 호출한다. 이후 핸들러가 리턴할 때, 제어를 다음 인스트럭션으로 돌려주게 되며, 그 이후 프로그램이 마치 인터럽트가 발생하지 않았던 것처럼 계속해서 실행된다.
나머지 예외의 종류들은 (트랩, Fault, Abort) 지금의 인스트럭션을 실행한 결과로 동기적으로 일어난다. 이를 묶어서 오류 인스트럭션(faulting instruction)이라고 부른다.
트랩과 시스템 콜
트랩은 의도적인 예외 상황으로 어떤 인스트럭션을 실행한 결과로 발생한다. 인터럽트 핸들러와 마찬가지로 트랩 핸들러는 제어를 다음 인스트럭션으로 리턴한다. 트랩의 가장 중요한 사용은 시스템 콜이라고 알려진 사용자 프로그램과 커널 사이의 프로시져와 유사한 인터페이스를 제공하는 것이다.
사용자 프로그램은 파일을 읽거나 (read), 새로운 프로세스를 만들거나 (fork), 새 프로그램을 로드하거나 (execve), 현재 프로세스를 종료하는 등의 서비스를 종종 커널로부터 요청할 필요가 있다. 이러한 커널 서비스에 제한된 접근을 하기 위해 프로세서는 system call이라는 인스트럭션을 제공하며, 사용자가 system call 인스트럭션을 실행하면 트랩이 인자들을 해독하고 적절한 커널 루틴을 호출하는 예외 핸들러로 가게 한다.
위의 그림은 시스템 콜에 대한 처리작업을 요약한 것이다. 프로그래머 관점에서 시스템 콜은 보통의 함수 호출과 동일하다. 그러나 실제 구현 상에서 시스템 콜은 커널 모드로의 진입이 포함된다는 점이 기존 함수와 차이가 있다.
기존 함수를 생각해보자. 시스템 콜이 배제된 채, 유저가 만든 함수는 철저하게 유저 모드에서 돌아간다. 이해가 잘 되지 않는다면 시스템 콜이 호출되는 경우를 살펴보자. 시스템 콜의 호출은 결국 하드웨어 혹은 OS 레벨에서 특정 작업을 수행해달라는 유저의 요청이다. 이러한 요청을 받게 되면 OS는 명시적으로 이를 처리하는 대신, 시스템 콜 호출 시 유저에게서 잠시 제어권을 가져온 채, 커널 모드에서 작업을 수행한 후 다시 유저에게 제어권을 넘긴다.
이때 제어권을 가져오고 넘기는 것을 가능하게 하는 예외 처리가 바로 트랩이다.
Fault
Fault는 핸들러가 정정할 수 없을 지도 모르는 에러 상태를 말한다. Fault가 발생하면 프로세서는 제어를 Fault 핸들러로 이동해준다. 만일 핸들러가 에러 조건을 정정할 수 있다면, 오류를 발생시킨 인스트럭션으로 제어를 다시 돌려주며 거기서부터 재실행한다. 그렇지 않다면 핸들러는 커널 내부의 abort 루틴으로 리턴해서 오류를 발생시킨 응용 프로그램을 종료한다.
Fault의 고전적인 예시는 페이지 폴트로, 인스트럭션이 가상 메모리 테이블을 참조했을 때 대응되어지는 실제 메모리 Page가 존재하지 않는 상황에서 디스크로부터 데이터를 가져와야 하는 상황을 의미한다. 페이지 폴트 핸들러는 디스크에서 적절한 페이지를 로드해서 제어를 오류가 발생한 인스트럭션으로 다시 넘겨준다. 이 인스트럭션이 다시 실행될 때 적절한 물리페이지는 메모리에 있게 되므로, 인스트럭션은 오류를 발생시키지 않고 완료할 때까지 실행이 가능해진다.
Abort
Aborts는 대개 DRAM이나 SRAM이 고장날 떄 발생하는 패리티 에러와 하드웨어와 같은 복구할 수 없는 치명적인 에러에서 발생한다. Abort 핸들러는 절대로 응용프로그램으로 제어를 리턴하지 않는다. 핸들러는 제어를 응용프로그램을 종료하는 중단 루틴으로 넘겨준다.
앞서 설명한 fault와 abort를 총칭하는 개념으로 Exception을 사용하기도 한다
'CS > OS' 카테고리의 다른 글
컨텍스트 스위치 (0) | 2022.12.06 |
---|---|
프로세스 (0) | 2022.12.06 |
공유 라이브러리로 동적 링크하기 (run-time linking) (0) | 2022.12.06 |
공유 라이브러리로 동적 링크하기 (load-time linking) (0) | 2022.12.05 |
링커가 정적 라이브러리를 통해 참조를 해석하는 방법 (0) | 2022.12.05 |
앞선 글에서 공유 라이브러리를 소개하며 실행되기 직전에 동적 로더가 공유 라이브러리를 로드하고 링크하는 시나리오에 대해서 살펴보았다. 그러나 응용프로그램이 동적 링커에게 응용 프로그램이 돌고 있는 동안에 응용 프로그램을 컴파일 시에 라이브러리와 링크할 필요 없이 임의의 동적 라이브러리를 로드하고 링크할 것을 요청할 수도 있다.
- 소프트웨어의 배포: MS의 응용 프로그램 개발자들은 소프트웨어 업데이트를 배포하기 위해 공유 라이브러리를 사용한다. 이들은 공유 라이브러리의 새로운 복사본을 생성하고, 사용자들은 이것을 다운로드하고 현재 벼전을 고체해서 사용할 수 있게 된다.다음번에 이들이 자신의 응용 프로그램을 실행할 때, 자동으로 새로운 공유 라이브러리와 링크하고 로드하게 된다.
- 고성능 웹서버의 개발: 많은 웹 서버들은 개인화된 웹페이지들, 계좌 잔고, 광고 전단과 같은 동적 컨텐츠를 생성한다. 초기 웹 서버드른 자식 프로세스를 만들고 "CGI 프로그램"을 자식 프로세스의 컨텍스트에서 실행하기 위해 fork와 execve를 사용해서 동적 컨텍츠를 생성하였다. 그렇지만 현대의 고성능 웹 서버들은 동적 링킹에 기반한 보다 효율적이고 복잡한 방법을 사용하여 동적 컨텐츠를 생성할 수 있다.
아이디어는 공유 라이브러리에서 동적 컨텐츠를 생성하는 각 함수들을 패키징하는 것이다. 웹 브라우저가 요청을 받으면 fork와 execve를 사용해서 자식 프로세스를 로드하고 링크한 뒤에 직접 호출한다. 이 함수는 서버의 주소공간에 캐시된 상태로 남으며, 그래서 후속 요청들은 간단한 함수 호출 비용만으로 처리될 수 있다. 이것은 접속량이 많은 사이트의 처리량에 대해서 큰 영향을 줄 수 있다.
리눅스 시스템들은 응용 프로그램이 런타임에 공유 라이브러리들을 로드하고 링크하도록 하는 동적 링커로의 간단한 인터페이스를 제공한다.
#include <dlfcn.h>
void *dlopen(const char *filename, int flag);
Returns: ptr to handle if OK, NULL on error
dlopen은 공유 라이브러리 파일 이름을 로드하고 링크한다. filename에 정의된 외부 심볼들은 이전에 RTLD_GLOBAL 플래그로 오픈된 라이브러리들을 사용해서 해석된다.
만일 현재 실행파일이 -rdynamic 플래그로 컴파일되었다면, 이 파일의 전역 심볼들 또한 심볼 해석을 위해서 사용 가능하다. flag 인자는 링커에게 외부 심볼들로의 참조를 즉시 해석하라고 하는 RTLD_NOW나 라이브러리로부터의 코드가 실행될 떄까지 심볼 참조를 연기하라고 링커에게 지시하는 RTLD_LAZY 플래그를 포함해야 한다.
#include <dlfcn.h>
void *dlsym(void *handle, char *symbol);
Returs: ptr to symbol if OK, NULL on error
dlsym 함수는 이전에 오픈된 공유 라이브러리와 symbol 이름에 대한 handle을 받아서 만일 심볼이 존재하면 심볼의 주소를 리턴하고, 그렇지 않으면 NULL을 리턴한다.
#include <dlfcn.h>
int dlclose(void *handle);
Returns: 0 if OK, -1 on error
dlclose 함수는 만일 여전히 사용하고 있는 다른 공유 라이브러리가 하나도 없다면 이 공유 라이브러리를 언로드(Unload)한다.
예시
#include <stdio.h>
#include <stdlib.h>
#include <dlfcn.h>
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
void *handle;
void (*addvec)(int *, int *, int *, int);
char *error;
/* Dynamically load the shared library that contains addvec() */
handle = dlopen("./libvector.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
/* Get a pointer to the addvec() function we just loaded */
addvec = dlsym(handle, "addvec");
if ((error = dlerror()) != NULL) {
fprintf(stderr, "%s\n", error);
exit(1);
}
/* Now we can call addvec() just like any other function */
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
/* Unload the shared library */
if (dlclose(handle) < 0) {
fprintf(stderr, "%s\n", dlerror());
exit(1);
}
return 0;
}
Compile
gcc –L/usr/local/lib -I/usr/local/inc –o test test.c -lmylib
이때 -L 뒤에는 공유 라이브러리의 경로가, -I 뒤에는 Include 할 파일들의 경로가 제공된다.
마지막으로 -l 뒤에는 라이브러리의 이름이 오게 되는데, 앞서 제공된 공유 라이브러리 경로에서 해당 공유 라이브러리의 이름을 찾아서 활용한다.
실패 시에는 런타임 에러가 발생하게 된다.
'CS > OS' 카테고리의 다른 글
프로세스 (0) | 2022.12.06 |
---|---|
Exception Control Flow 소개 (0) | 2022.12.06 |
공유 라이브러리로 동적 링크하기 (load-time linking) (0) | 2022.12.05 |
링커가 정적 라이브러리를 통해 참조를 해석하는 방법 (0) | 2022.12.05 |
Linker의 역할 - 2. Relocation(재배치) (0) | 2022.12.03 |
직전 글의 정적 라이브러리는 응용 프로그램이 사용할 수 있는 많은 관련 함수들을 만드는데 관련된 여러 이슈들을 다뤘다.
그러나 정적 라이브러리는 여전히 몇 가지 중대한 단점이 있다. 정적 라이브러리들은 다른 모든 소프트웨어처럼 관리해야 하고 주기적으로 갱신해야 한다. 만일 응용 프로그래머들이 가장 최근의 라이브러리 버전을 사용하고 싶으면, 이들은 어떤 방식으로든 라이브러리가 변경되었다는 것을 인지해야만 하며, 그 후에 명시적으로 자신들의 프로그램과 갱신된 라이브러리들을 재링크(Re-link) 해야 한다.
또 다른 이슈는 거의 모든 프로그램이 printf와 scanf와 같은 표준 I/O 함수들을 사용한다는 점이다. 런타임에 이 함수들을 위한 코드는 각각 실행되는 프로세스의 텍스트 세그먼트 내에서 복제된다. 50~100개의 프로세스가 돌고 있는 전형적인 시스템에서 이것은 부족한 메모리 시스템 자원의 상당한 낭비가 있다.
정적 라이브러리는 갱신 추적이 어렵고, printf와 같은 시스템에 필수적인 공용 함수를 포함할 경우
메모리의 심각한 낭비가 야기될 수 있다.
공유 라이브러리들은 정적 라이브러리의 단점들을 극복하는 현대의 혁신이라고 한다. 공유 라이브러리는 런타임에 임의의 메모리 주소에서 로드되고 메모리에서 프로그램으로 연결될 수 있는 목적 모듈이다. 이 과정은 동적 링킹이라고 알려져 있으며 동적 링커라고 하는 프로그램에 의해 수행된다.
공유 라이브러리는 또한 공유 객체라고 불리며, Unix 시스템에서 일반적으로 .so 확장자로 나타낸다. 마이크로소프트 운영체제는 많은 공유 라이브러리를 사용하며, 이들은 DLL(Dynamic Link Libraries)라고 부른다.
공유 라이브러리들은 두 가지 다른 방법으로 공유된다. 첫째는 어떤 주어진 파일 시스템에서, 특정 라이브러리에 대해 정확히 한 개의 .so 파일을 갖고 이 .so 파일 내의 코드와 데이터는 이 라이브러리를 참조하는 모든 실행 가능한 목적 파일들에 의해 공유된다. 이는 앞서 참조하는 실행 파일들 내에 라이브러리의 코드와 데이터가 복사되는 정적 라이브러리의 방식과는 정반대이다. 둘째 메모리에 있는 .text 섹션은 서로 다른 실행 중의 프로세스들에 의해 공유될 수 있다. 이 부분은 이후 가상 메모리를 배우면서 다시 살펴보도록 하자.
아래 그림은 예제 프로그램에 의해서 동적 링크 과정을 요약한 것이다.
벡터 관련 루틴들의 경우 공유 라이브러리 libvector.so를 만들기 위해서 컴파일러 드라이버를 링커로 다음과 같은 특별한 명령어를 사용해서 호출한다. -shared 플래그는 링커가 공유 목적파일을 생성하도록 지시한다.
unix> gcc -shared -o libvector.so addvec.c multvec.c
일단 라이브러리를 만들었다면, 이를 다음과 같이 링크할 수 있다.
unix> gcc -o program main.c ./libvector.so
이는 실행 가능 목적파일 program을 런타임에 libvector.so와 링크될 수 있는 형태로 생성한다. 기본 아이디어는 링킹의 일부가 실행가능 파일이 생성될 때 정적으로 수행되도록 하고, 프로그램이 로드가 완료된 이후 링킹 작업을 동적으로 완료하는 것이다.
기본 링킹만이 수행이 되는 시점에서, 코드와 데이터 섹션 중 실제로 program 실행파일로 복사된 것은 아무것도 없다는 것이 중요하다. 대신 링커는 일부 재배치와 심볼 테이블 정보를 복사하며, 이들은 libvector.so 내부의 코드와 데이터에 대한 참조가 런타임에 해결되도록 해준다.
로더가 실행 파일 program을 로드하고 실행하면, 실행 가능 목적파일의 로딩 과정에 따라 부분적으로 링크된 실행파일 program을 로딩한다. 다음으로 로더는 program이 .interp 섹션을 포함하는 것을 감지하고 제어를 응용프로그램으로 넘기는 대신 로더는 동적 링커를 로드하고 동적 링커를 실행한다.
동적 링커는 링킹 작업을 다음과 같은 재배치를 수행해서 완료한다.
- libc.so 의 텍스트와 데이터를 일부 메모리 세그먼트에 재배치한다.
- 다른 메모리 세그먼트로 libvector.so의 텍스트와 데이터를 재배치한다.
- libc.so와 libvector.so에서 정의된 심볼로 program의 참조를 재배치한다.
즉, libc.so와 libvector.so의 텍스트와 데이터를 일부 메모리 세그먼트에 재배치하고, 이를 가지고 로더를 거쳐 심볼 참조를 해결하고자 하는 program의 unresolved symbol들을 해결한다.
마지막으로 동적 링커는 제어를 응용 프로그램으로 넘겨준다. 이 지점에서 공유 라이브러리의 위치는 고정되며 프로그램을 실행하는 동안에 바뀌지 않는다.
'CS > OS' 카테고리의 다른 글
Exception Control Flow 소개 (0) | 2022.12.06 |
---|---|
공유 라이브러리로 동적 링크하기 (run-time linking) (0) | 2022.12.06 |
링커가 정적 라이브러리를 통해 참조를 해석하는 방법 (0) | 2022.12.05 |
Linker의 역할 - 2. Relocation(재배치) (0) | 2022.12.03 |
심볼과 심볼 테이블 (0) | 2022.12.02 |
Symbol Resolution 단계에서 링커는 파일들과 컴파일러 드라이버의 명령줄 라인에 나타난 것과 같은 순차적인 순서로 좌에서 우로 재배치가능 목적파일들과 아카이브들을 스캔한다. 이 스캔 과정에서 링커는 집합 E, U, D를 유지하게 되는 데 각각의 역할을 다음과 같다.
- 집합 E: 실행파일을 구성하기 위해 합쳐질 재배치가능 목적파일들의 집합
- 집합 U: 미해석 심볼 집합 (Unresolved symbol, 참조되긴 하지만 아직 정의되지 않은)
- 집합 D: 이전 입력 파일에서 정의(Defined 즉, resolve 완료)된 심볼 집합
초기 상태에서 모든 집합은 비어 있게 된다. 이때 링커의 resolution 동작을 앞서 설명한 집합과 함께 설명하면 다음과 같다.
- 명령줄 상의 매 입력 파일 f에 대해서, 링커는 만약 f가 목적 파일인지 아카이브인지를 결정한다. 만일 f가 목적파일이라면 링커는 f를 E에 추가하고 심볼 정의와 f에서의 참조를 반영하도록 U와 D를 갱신하고 다음 입력 파일로 넘어간다.
- 만일 f가 아카이브라면, 링커는 U 안의 미해석 심볼들을 아카이브의 멤버들에 의해 정의된 심볼들과 매칭하려고 시도한다. 만일 일부 아카이브 멤버 m이 U 내의 참조를 해결하는 심볼을 정의하려면 m은 E에 추가되고 링커는 U와 D를 m에 있는 심볼 정의와 참조를 반영하도록 갱신한다. 이 과정은 U와 D가 더이상 바뀌지 않는 일정 지점까지 아카이브 내의 멤버 목적 파일들에 대해 반복 실행된다. 이 시점까지 E에 포함되지 않은 모든 멤버 함수들은 간단히 버리고 링커는 다음 입력 파일로 진행한다.
- 링커가 명령줄의 입력 파일들을 스캔하는 작업을 끝마칠 때, U가 비어있지 않다면 에러를 출력하고 종료한다. 그렇지 않다면 E에 있는 목적파일들을 합치고 재배치(relocation)해서 출력 실행파일을 작성한다.
때문에 만약 gcc를 통해 링커를 동작시키는 경우, 컴파일 옵션 순서를 잘못 주게 되어 심볼을 정의하는 라이브러리가 심볼을 참조하는 목적파일 전에 명령줄에 나타난다면, 이 참조는 해결되지 않으며 링킹 작업은 실패한다. 다음의 예시를 살펴보자.
unix> gcc -static ./libvector.a main.c
libvector.o: In function `main':
libvector.o(.text+0x18): undefined reference to `addvec'
목적파일과 아카이브에는 아무런 문제가 없다고 가정한다.
링커가 실행되고 libvector.a가 처리될 때, U는 비어있었다. 때문에 아카이브 파일이 제공되었지만 그 어떤 심볼도 U에서 D로 이동하지 못했다. 끝까지 D가 비어있었다면, 문제 없이 링커의 동작이 완료되었겠지만, 마지막에 main.c가 입력으로 제공되며 해당 파일에 존재하는 unresolved symbol 들이 U에 들어가게 된다. 결과적으로 링커가 더 입력파일을 제공받지 못했고, 여전히 U에는 심볼 목록들이 남아있기 때문에 E는 하나의 실행가능 목적파일로 합쳐지지 못하고 링커에서 에러를 출력한다.
'CS > OS' 카테고리의 다른 글
공유 라이브러리로 동적 링크하기 (run-time linking) (0) | 2022.12.06 |
---|---|
공유 라이브러리로 동적 링크하기 (load-time linking) (0) | 2022.12.05 |
Linker의 역할 - 2. Relocation(재배치) (0) | 2022.12.03 |
심볼과 심볼 테이블 (0) | 2022.12.02 |
Linker의 역할 - 1. Symbol Resolution (0) | 2022.12.02 |
앞선 글에서 링커가 다수의 재배치가능 목적파일들을 읽어들이고 이들을 연결해서 한 개의 출력 실행파일을 만든다고 가정해왔다. 실제로, 모든 컴파일 시스템은 관련된 객체 모듈들을 정적 라이브러리라고 부르는 한 개의 파일로 패키징하는 매커니즘을 제공하며 이 라이브러리는 다음에 링커의 입력으로 제공될 수 있다. 출력 실행파일을 만들 때, 링커는 응용 프로그램이 참조하는 라이브러리 내의 객체 모듈만을 복사한다.
Approach 1 (컴파일러에 의존)
정적 라이브러리가 없다고 하더라도 컴파일러를 통해서 C의 모든 표준함수에 대한 호출을 인식하고 이에 기반한 코드를 작성할 수도 있다. 다만 이러한 방식은 컴파일러에 상당한 복잡성을 제공하고, 함수가 추가되고 삭제될 시에 새로운 컴파일러 버전이 필요하게 될 수도 있다.
unix> gcc main.c /usr/lib/libc.o
Approach 2 (다수의 목적파일을 활용한 링킹)
또 다른 접근 방법은 모든 C 표준 함수들을 한 개의 재배치 가능 목적 모듈에 저장해서 응용 프로그래머들이 자신의 실행 파일에 링크하도록 하는 것이다. 이는 앞서 컴파일러에게 과도한 복잡성을 부여했던 문제를 해결할 수는 있으나, 시스템의 모든 실행 파일이 표준 함수들의 모듈 전체의 복사본을 포함하게 되어 디스크 공간을 극도로 낭비하게 될 것이라는 단점이 발생한다(예를 들어 libc.a가 8 MB이고, libm.a은 1 MB 정도이다.). 더욱 문제가 되는 것은 각각 실행되고 있는 프로그램들이 이 함수들의 복사본을 메모리에 모두 로드하게 되어서 메모리가 극도로 낭비될 것이라는 것이다.
unix> gcc main.c /usr/lib/printf.o /usr/lib/scanf.o ...
각각의 표준 함수에 대해서 재배치 가능 파일을 별도로 생성하고, 응용 프로그래머들에게 자신의 실행 파일에 링크하도록 만들 수 있다. 그렇지만 이러한 접근 방식은 응용 프로그래머들에게 명시적으로 적절한 객체 모듈을 자신의 실행파일에 링크할 것을 요구하며, 이 작업은 에러가 생겨나기 쉽고 시간도 낭비된다.
Approach 3 (정적 라이브러리)
이와 같은 정적 라이브러리 개념은 이러한 여러 가지 접근 방법의 단점을 해결하기 위해 개발되었다. 연관된 함수들은 별도의 모듈로 컴파일해서 하나의 정적 라이브러리 파일로 패키지화 할 수 있다. 앞서 수많은 재배치 파일을 링커에게 입력 파일로 제공해야 했다면, 이제는 해당 함수들을 포함한 정적 라이브러리를 링커에게 제공해줌으로써 직전 방식의 불편을 해소할 수 있다.
unix> gcc main.c /usr/lib/libm.a /usr/lib/libc.a
링커 시 링커는 오직 프로그램이 참조하는 객체 모듈만을 복사하므로 디스크와 메모리 상의 실행 파일의 크기를 줄일 수 있다. 한편, 응용 프로그래머는 일부 라이브러리 파일의 이름을 include하기만 하면 된다.
Unix 시스템에서 정적 라이브러리는 아카이브라고 알려진 특정 파일 포맷으로 디스크 상에 저장된다. 아카이브는 연결된 재배치 가능 목적파일들의 집합으로, 헤더는 각 목적 파일의 크기와 멤버 함수의 위치를 기술한다. 아카이브 파일 이름은 .a 접미어로 나타낸다. 라이브러리에 대한 우리의 논의를 좀더 구체적으로 하기 위해서 libvector.a라는 정적 라이브러리에서 다음의 addvec.o 과 multvec.o 에 내장된 벡터 루틴을 제공하길 원한다고 가정하자.
/* addvec.o */
void addvec(int *x, int *y, int *z, int n)
{
int i;
for (i = 0; i < n; i++)
z[i] = x[i] + y[i];
}
/* multvec.o */
void addvec(int *x, int *y, int *z, int n)
{
int i;
for (i = 0; i < n; i++)
z[i] = x[i] * y[i];
}
라이브러리를 생성하기 위해서 다음의 ar 명령어를 활용할 수 있다.
unix> gcc -c addvec.c multvec.c # 재배치 목적파일로의 변환
unix> ar rcs libvector.a addvec.o multivec.o # 하나의 아카이브 파일로 합침
이러한 아키아브 파일은 다음과 같이 응용 프로그램에서 활용될 수 있다.
/* main.c */
#include <stdio.h>
#include "vector.h"
int x[2] = {1, 2};
int y[2] = {3, 4};
int z[2];
int main()
{
addvec(x, y, z, 2);
printf("z = [%d %d]\n", z[0], z[1]);
return 0;
}
실행 파일을 만들기 위해서는 입력 파일 main.o와 libvector.a를 컴파일하고 링크한다.
unix> gcc -c main.c
unix> gcc -static -o program main.o ./libvector.a
이때 -static 인자는 컴파일러 드라이버에게 메모리에 적재될 수 있고 로드 시에 추가적인 링킹 과정 필요없이 돌아가도록 완전히 링크된 실행가능 목적파일을 링커가 만들어야 한다는 것을 의미한다.
이때 링커가 실행되면 링커는 addvec.o에서 정의된 addvec 심볼이 main.o에서 참조되는지 결정해야 하며, main.o에 addvec이 활용된다는 것을 심볼 해석과 재배치과정을 거쳐 알고 addvec.o를 실행파일에 복사한다. 이때 핵심은 프로그램이 multvec.o에 의해 정의된 심볼들을 참조하지 않기 때문에 링커는 이 모듈을 실행파일에 복사하지 않는다는 것이다. 링커는 또한 C 런타임 시스템으로부터 다른 모듈들과 함께 printf.o 모듈을 libc.a에서 복사한다.
'CS' 카테고리의 다른 글
L7 프로토콜 - HTTP (0) | 2023.08.08 |
---|---|
IPv4에서 IPv6로의 전환 매커니즘 (0) | 2023.08.06 |
실행가능한 목적파일 (0) | 2022.12.03 |
Linking(링킹) 소개 (0) | 2022.12.02 |
시간 지역성을 위한 캐시 재배치 (0) | 2022.12.01 |
앞선 글에서 어떻게 링커가 다수의 목적 모듈을 하나의 실행 간으 목적파일로 합치는지 살펴보았다. 우리의 C 프로그램은 아스키 텍스트 파일로 일생을 시작해서 프로그램을 메모리에 로드하고 실행하는데 필요한 모든 정보를 포함하는 하나의 바이너리 파일로 변환되었다.
위 그림은 전형적인 ELF 파일에 있는 정보들의 종류를 요약한 것이다. 실행 가능 목적파일의 포맷은 재배치가능한 목적파일의 포맷과 유사하다. ELF 헤더는 우선 이 파일의 전체적인 포맷을 설명한다. 또한 프로그램이 실행될 경우 첫 번째 인스트럭션의 주소인 프로그램 엔트리 포인트를 포함한다. .text, .rodata, .data 섹션들은 이들 섹션들이 각자의 최종 런타임 주소로 재배치되었다는 점을 제외하고는 재배치 가능 목적파일에 있는 섹션들과 유사하다. .init 섹션은 _init이라는 작은 함수를 정의하는데, 이것은 프로그램의 초기화 코드에서 호출한다. 실행파일이 완전히 링크되었기 때문에 여기에서는 .rel 섹션이 필요하지 않다.
실행가능 목적파일의 로딩
실행가능 목적파일 p를 실행하기 위해서, Unix 쉘의 명령줄에는 다음과 같은 명령어를 입력한다.
> ./p
p가 내장 쉘 명령어에 대응되지 않기 때문에 쉘에서는 해당파일을 실행 가능한 목적파일이라고 가정할 것이다. 쉘은 로더라고 알려진 메모리 상주 운영체제 코드를 호출하여 이 프로그램을 실행한다. 모든 Unix 프로그램은 execve 함수를 호출하여 로더를 호출할 수 있으며, 로더의 동작은 이후 다시 자세히 설명하도록 하겠다. 간단히 설명하자면 로더는 디스크로부터 실행가능한 목적파일 내의 코드와 데이터를 메모리로 복사하고 이 프로그램의 첫 번째 인스트럭션, 즉 엔트리 포인트로 점프해서 프로그램을 실행한다. 이와 같이 프로그램을 메모리로 복사하고 실행하는 과정을 로딩이라고 부른다.
모든 Unix 프로그램은위와 같은 런타임 메모리 이미지를 가진다. 32 비트 리눅스 시스템에서 코드 세그먼트는 0x08048000 에서 시작한다. 데이터 세그먼트가 다음 4 KB 정렬된 주소에 배치되며 이후 런타임 힙이 위치한다. 또한 공유 라이브러리를 위해 예약된 세그먼트가 존재한다. 사용자 스택은 항상 가장 큰 사용자 주소에서 시작해서 아래로 성장한다. 스택의 위에서 시작하는 세그먼트는 커널이라고 알려진 운영체제의 메모리 상주 부분에 있는 코드와 데이터를 위해 예약되어 있다.
로더는 실제로 어떻게 동작하는가?
Unix 시스템에서 각 프로그램은 자신의 가상 주소공간을 갖는 프로세스의 컨텍스트에서 실행된다. 쉘이 프로그램을 실행할 때 부모인 쉘 프로세스는 부모와 자신의 복제인 자식 프로세스를 fork 한다. 자식 프로세스는 execve 시스템 콜을 통해서 로더를 호출한다. 로더는 자식의 기존 가상메모리 세그먼트를 삭제하고 새로운 코드, 데이터, 힙, 스택 세그먼트를 생성한다. 새로운 스택과 힙 세그먼트들은 0으로 초기화된다. 새로운 코드와 데이터 세그먼트들은 가상 주소공간 페이지들을 실행파일의 패이지 크기 덩어리들로 매핑시켜서 실행파일의 내용으로 초기화된다. 마지막으로 로더는 _start 주소로 점프하여 이는 궁극적으로 main 루틴을 호출한다. 헤더 정보와는 별도로, 로딩하는 동안에 디스크에서 메모리로의 데이터 복사는 일어나지 않는다.
'CS' 카테고리의 다른 글
IPv4에서 IPv6로의 전환 매커니즘 (0) | 2023.08.06 |
---|---|
정적 라이브러리 (0) | 2022.12.05 |
Linking(링킹) 소개 (0) | 2022.12.02 |
시간 지역성을 위한 캐시 재배치 (0) | 2022.12.01 |
공간 지역성을 높이기 위한 루프 재배치 (2) | 2022.12.01 |