크래프톤 정글 TIL : 0806

lazyArtisan·2024년 8월 6일
0

정글 TIL

목록 보기
37/147

🎯 To-do List


✅ 백준 1문제 풀기
✅ CS:APP | 7장, 8.1 ~ 8.2
☑️ CS:APP | 8장, 9장
☑️ c++ 공부



📝 배운 것들


🏷️ 정적 링크와 동적 링크 차이

링커는 실행 파일 만들 때 쓰는 것 뿐만 아니라 런타임 때도 쓰여서 실제 메모리 주소를 할당하는 건가?

링커는 두 가지 주요 상황에서 사용됩니다: 컴파일 타임(정적 링크)과 런타임(동적 링크)입니다. 두 상황 모두에서 링커는 심볼 해석과 메모리 주소 할당을 수행하지만, 그 방법과 시기는 다릅니다.

정적 링크 (Static Linking)

정적 링크는 컴파일 타임에 이루어지며, 이 과정에서 링커는 여러 개의 재배치 가능한 객체 파일을 하나의 실행 가능한 목적 파일로 결합합니다. 이 단계에서 링커는 다음 작업을 수행합니다:
1. 심볼 해석: 각 심볼 참조를 정확히 하나의 심볼 정의와 연결합니다.
2. 재배치: 객체 파일 내의 코드와 데이터를 수정하여, 모든 심볼 참조가 올바른 메모리 주소를 가리키도록 합니다.
3. 실행 파일 생성: 최종 실행 파일을 생성하여 디스크에 저장합니다.

정적 링크가 완료되면, 실행 파일은 완전한 형태로 디스크에 저장되며, 프로그램이 실행될 때 로더에 의해 메모리에 로드됩니다. 이 단계에서는 추가적인 링크 작업이 필요하지 않습니다.

동적 링크 (Dynamic Linking)

동적 링크는 프로그램이 실행될 때(런타임) 수행됩니다. 이 경우, 일부 라이브러리 함수는 프로그램의 실행 파일에 포함되지 않고, 대신 공유 라이브러리 파일로 별도로 저장됩니다. 동적 링크는 동적 링커(dynamic linker)에 의해 수행되며, 다음 작업을 포함합니다:

  1. 프로그램 실행: 사용자가 프로그램을 실행하면, 운영 체제의 로더가 프로그램을 메모리에 로드합니다.
  2. 동적 링커 호출: 실행 파일의 .interp 섹션에 지정된 경로에 따라 동적 링커가 호출됩니다.
  3. 공유 라이브러리 로드: 동적 링커는 필요한 공유 라이브러리를 메모리에 로드합니다.
  4. 심볼 해석 및 재배치: 동적 링커는 각 공유 라이브러리 내의 심볼을 해석하고, 프로그램의 코드와 데이터를 수정하여 올바른 메모리 주소를 가리키도록 합니다.

차이점 요약

  • 정적 링크: 컴파일 타임에 수행되며, 실행 파일 생성 시 모든 심볼이 해석되고 재배치됩니다. 실행 파일은 독립적이며 추가적인 링크 작업이 필요 없습니다.
  • 동적 링크: 런타임에 수행되며, 프로그램이 실행될 때 필요한 공유 라이브러리가 메모리에 로드되고 심볼이 해석됩니다. 실행 파일과 공유 라이브러리 간의 심볼 연결은 프로그램 실행 중에 이루어집니다.

따라서, 링커는 실행 파일을 만들 때만 사용되는 것이 아니라, 동적 링크의 경우 런타임에도 사용되어 실제 메모리 주소를 할당하고 심볼 참조를 해결합니다. 이 동적 링크 덕분에 프로그램이 더 적은 메모리 자원을 사용하고, 라이브러리를 쉽게 업데이트할 수 있는 유연성을 제공합니다.

📖 CS:APP


📝 Chapter 7 링커

심볼 해석 규칙

  • 코드랑 데이터 모아서 메모리에 로드 가능하고 실행 가능한 파일로 만드는 과정
  • 컴파일할 때나 실행될 때 등등 여러 상황에서 수행될 수 있다
  • 별도로 수정하고 링크해서 큰 규모의 응용 프로그램을 별도로 수정할 수 있게된다

링커를 배워야 하는 이유

  • 큰 프로그램을 작성하는데 도움됨 : 모듈, 라이브러리, 맞지 않는 라이브러리 버전 때문에 링커 에러를 발생시킬 때가 있다. 어떻게 링커가 참조를 해결해나가는지, 라이브러리가 뭔지 모르면 링커 에러날 때 당혹스러울 것.

  • 위험한 에러 회피 : 리눅스 링커가 심볼 참조를 해결할 때 하는 결정들은 프로그램의 정확성에 영향을 조금 줄 수 있다. 전역변수를 중복해서 정의한 프로그램도 기본 설정의 경우 경고 메시지 없이 링커를 통과할 수 있다. 이렇게 되면 런타임 동작 때 혼란스럽게 작동하고 디버깅도 어렵다.

  • 언어의 변수 영역 규칙 구현 이해 : 전역 변수와 지역 변수의 차이는 무엇인가? static을 사용해서 변수나 함수를 정의할 때, 이것은 무슨 의미인가? 등을 알 수 있다.

  • 중요한 시스템 개념 이해 : 링커가 만든 실행 가능 객체 파일은 로딩과 프로그램 실행 같은 중요한 시스템 함수, 가상메모리, 페이징, 메모리 매핑에서 중요한 역할을 하게 됨

  • 공유 라이브러리 이해

7.1 컴파일러 드라이버

대부분의 컴파일 시스템은 사용자를 대신해서 언어 전처리기, 컴파일러, 어셈블러, 링커를 필요에 따라 호출하는 컴파일러 드라이버를 제공한다.

↑ (드라이버가 ASCII 소스 파일에서 예제 프로그램을 실행 목적 파일로 번역할 때 드라이버의 동작 내용) ↑

  1. C 전처리기(cpp) : C 소스 파일 main.c를 ASCII 중간 파일인 main.c로 번역
  2. C 컴파일러(ccl) : main.i를 어셈블리어 파일 main.s로 번역
  3. 어셈블러(as) : main.s를 재배치 가능한 바이너리 목적파일 main.o로 번역
  4. 링커 프로그램(ld) : 필요한 시스템 목적파일들과 함께 실행 가능 목적파일 prog을 생성하기 위해 main.osum.o을 연결한다
  5. 실행 : linux> ./prog으로 실행시키면 쉘은 로더(loader)라고 부르는 운영체제 내의 함수를 호출하며, 로더는 실행파일 prog의 코드와 데이터를 메모리로 복사하고, 제어를 프로그램의 시작 부분으로 전환.

7.2 정적 연결 (Static Linking)

컴파일 후에 소스 코드와 라이브러리 코드를 결합해서 단일 실행 파일을 생성하는 과정.
실행 파일이 실행될 때 추가적인 연결이나 로딩작업이 필요하지 않아서 "정적"이라는 용어가 사용됨.

링커가 실행파일 만드는 과정

  1. 심볼 해석(symbol resolution)
  • 각 목적 코드 파일은 함수, 전역 변수, 정적 변수(static으로 선언된 변수) 등 여러 심볼을 정의하고 참조함
  • 심볼 해석 과정에서는 각 심볼 참조를 정확히 하나의 심볼 정의와 연결함
  • 링커는 각 목적 파일의 심볼 테이블을라이브러리들 간의 의존성이 있는 경우, 의존성을 만족시키기 위해 명령줄에 라이브러리를 여러 번 반복해서 나타내야 할 수도 있 검사하여 정의되지 않은 심볼을 찾아내고, 이를 다른 파일에서 정의된 심볼과 매칭시킴
  1. 재배치(relocation)
  • 컴파일러와 어셈블러는 각각의 목적 파일을 생성할 때, 이들이 메모리의 주소 0번지에서 시작한다고 가정함.
  • 그러나 실행 파일이 생성될 때는 각 목적 파일이 실제 메모리의 어느 위치에 놓일지 결정해야 함.
  • 그 과정이 재배치임. 각 심볼 정의에 실제 메모리 주소를 할당하고, 해당 심볼에 대한 모든 참조를 그 주소로 수정함.

목적 파일들은 단지 바이트 블록들의 집합임

7.3 목적파일 (Object Files)

목적파일 : 컴파일러와 어셈블러가 소스 코드를 기계어로 변환하여 생성하는 파일

목적파일에는 세 가지 형태가 있음

  1. 재배치 가능 목적파일 (Relocatable object file) : 바이너리 코드와 데이터를 포함하고 있음. 컴파일할 때 다른 재배치 가능 목적파일과 결합하여 실행 가능한 목적파일을 만드는 데 사용됨
  2. 실행 가능 목적파일 (Executable object file) : 바이너리 코드와 데이터를 포함. 메모리에 직접 복사되어 운영 체제에서 직접 실행할 수 있음.
  3. 공유 목적파일 (Shared object file) : 1.의 특수한 형태. 메모리에 로드되어 동적으로 링크될 수 있음. 일반적으로 공유 라이브러리에 사용됨.

7.4 재배치 가능 목적파일 (Relocatable Object Files)

현대의 x86-64 리눅스와 유닉스 시스템들은 목적파일 형식으로 Executable and Linkable Format (ELF)을 사용함

ELF 헤더 : 파일의 전체적인 형식을 설명 (ELF 헤더 크기, 목적파일 타입, 머신 타입 등)

섹션 헤더 테이블 : 목적파일의 각 섹션에 대한 정보를 설명

ELF 파일의 주요 섹션

  1. .text : 컴파일된 프로그램의 기계 코드가 포함됨.
  2. .rodata : 읽기 전용 데이터가 포함됨. (ex. printf 문의 형식 문자열, switch 문의 점프 테이블)
  3. .data : 초기화된 전역 변수와 정적 변수가 포함됨. 지역 변수는 런타임 스택에 저장되고 .data.bss 섹션에는 안 나타남
  4. .bss : 초기화되지 않은 전역 변수와 정적 변수가 포함됨. 실제 목적파일에서 공간을 차지하진 않음. 위치만 표시함. 런타임 시 이 변수들은 메모리에서 0으로 초기화됨.
  5. .symtab : 프로그램에서 정의되고 참조된 함수와 전역 변수에 대한 정보들이 포함됨.
  6. .rel.text : .text 섹션에서 다른 객체 파일과 결합될 때 수정되어야 하는 위치 목록
  7. .rel.data : 이 모듈에 의해 정의되거나 참조되는 전역변수들에 대한 재배치 정보.
  8. .debug : 프로그램에 정의된 로컬 변수와 typedef, 전역 변수, 원본 C 소스 파일에 대한 디버깅 심볼 테이블. (컴파일러가 -g 옵션으로 호출될 때만 포함됨)
  9. .line : 최초 C 소스 프로그램과 .text 섹션 내 기계어 명령어들의 라인 번호들 간의 매핑. (컴파일러가 -g 옵션으로 호출될 때만 포함됨)

7.5 심볼과 심볼 테이블

심볼(Symbols)

심볼은 프로그램에서 함수, 변수, 상수와 같은 엔티티를 나타내는 이름이다.
각 심볼은 프로그램 내에서 고유한 엔티티를 참조하며, 이 엔티티의 위치(주소)와 다른 속성들을 정의한다.
링커가 심볼을 통해 프로그램의 여러 부분을 결합하는데, 이는 다음과 같은 세 가지 유형으로 분류할 수 있다.

  1. 전역 심볼 (Global Symbols)
  • 모듈에서 정의되고 다른 모듈에서 참조할 수 있는 심볼
  • 비정적 C 함수와 전역 변수
  1. 외부 심볼 (External Symbols)
  • 모듈에서 참조되지만 다른 모듈에서 정의된 심볼
  • 비정적 C 함수와 다른 모듈에 정의된 전역 변수
  1. 로컬 심볼 (Local Symbols)
  • 모듈 내에서만 정의되고 참조되는 심볼
  • 정적 C 함수와 static으로 정의된 전역 변수
  • 로컬 심볼은 모듈 내에서만 보이므로 다른 모듈에선 참조할 수 없음

지역 링커 심볼들은 지역 프로그램 변수와는 같지 않다.
symtab에 있는 심볼 테이블은 지역 비정적 프로그램 변수들에 대응되는 심볼을 전혀 포함하지 않음.
이들은 런타임에 스택에 의해 관리되며 링커와는 관련이 없다.

대신, static으로 정의된 지역 변수는 스택이 아닌 .data.bss 섹션에 공간이 할당되고, 심볼 테이블에는 고유한 이름으로 로컬 심볼이 생성된다.

심볼 테이블(Symbol Tables)

각 재배치 가능 목적 모듈 m은 m에 의해서 정의되고 참조되는 심볼들에 대한 정보를 포함하는 심볼 테이블을 갖고 있음.
심볼 테이블은 어셈블러가 컴파일러에서 내보낸 심볼을 사용하여 구축함.
심볼 테이블은 .symtab 섹션에 포함됨. 이 테이블은 각 항목이 배열 형태로 구성되어 있음

들어있는 정보들 : 이름, 값, 크기, 유형(데이터인지 함수인지), 바인딩(지역인지 전역인지)

7.6 심볼 해석 (Symbol Resolution)

링커는 자신의 입력 재배치 가능 목적파일들의 심볼 테이블로부터 정확히 한 개의 심볼 정의에 각 참조를 연결시켜서 심볼 참조를 해석.
동일한 모듈 내에 정의된 지역 심볼에 대한 참조를 해석하는 것은 비교적 간단함.
하지만 전역 심볼들에 대한 참조를 해결하는 것은 더 복잡함.

컴파일러가 현재 모듈에서 정의되지 않은 심볼을 발견하면, 해당 심볼이 다른 모듈에서 정의되었을 거라고 가정하고 링커에게 그 심볼의 처리를 맡김. 링커가 해당 심볼을 찾을 수 없으면 에러 메시지를 출력하고 종료함. 예를 들어, 다음 코드를 컴파일하고 링크하려고 할 때,

void foo(void);

int main() {
    foo();
    return 0;
}

컴파일러는 성공적으로 실행되지만, 링커는 foo에 대한 정의를 찾을 수 없어 다음과 같은 오류 메시지를 출력한다.

/tmp/ccSz5uti.o: In function ‘main’: undefined reference to ‘foo’

7.6.1 링커가 중복으로 정의된 전역 심볼을 해결하는 방법

심볼 해석 규칙

  1. 강한 심볼이 여러 개 있는 경우 에러를 발생시킴
  2. 강한 심볼과 여러 개의 약한 심볼이 있는 경우 강한 심볼을 선택
  3. 여러 약한 심볼이 있는 경우, 임의의 약한 심볼을 선택함

컴파일 할 때, 컴파일러는 각 전역 심볼을 어셈블러로 강하게 또는 약하게 보내며, 어셈블러는 이 정보를 재배치 가능 목적파일의 심볼 테이블에 묵시적으로 인코딩한다.

강한 심볼 : 함수들과 초기화된 전역변수들
약한 심볼 : 비초기화된 전역변수

7.6.2 정적 라이브러리와 링크하기

링커가 재배치 가능 객체 파일들을 읽어들이고, 이를 결합하여 출력 실행 파일을 생성할 때 정적 라이브러리를 사용하면 필요한 함수들만 사용자들에게 제공해서 용량을 줄일 수 있다.

정적 라이브러리는 관련 객체 모듈들을 하나의 파일로 패키징한 것으로, 링커가 이 파일을 입력으로 받아서 프로그램에 필요한 객체 모듈만을 복사하여 사용한다.

7.6.3 링커가 참조를 해석하기 위해 정적 라이브러리를 이용하는 방법

정적 라이브러리를 사용할 때 링커가 이에 대한 참조를 해석하면서 혼란이 발생하기도 한다.

링커는 심볼 해석 단계에서 컴파일러 드라이버의 명령줄에 나타난 순서대로 재배치 가능 객체 파일과 아카이브를 왼쪽에서 오른쪽으로 순서대로 스캔한다. 이 과정에서 링커는 세 개의 집합을 관리한다.

  • E : 병합할 재배치 가능 객체 파일들의 집합
  • U : 아직 정의되지 않은 심볼들의 집합
  • D : 이전 입력 파일에서 정의된 심볼들의 집합
  1. 객체 파일 처리 : 입력 파일이 목적 파일이면, 이를 E에 추가하고 U와 D를 업데이트한다.
  2. 아카이브 파일 처리 : 입력 파일이 아카이브 파일이면, U의 미해석 심볼을 해당 아카이브의 심볼과 매칭하여 해석된 심볼이 있는 경우 E에 추가한다. 이 과정은 더 이상 U와 D가 변하지 않을 때까지 반복된다.
  3. 최종 처리 : 명령줄의 모든 입력 파일을 스캔한 후 U가 비어 있지 않으면, 링커는 에러를 출력하고 종료한다. U가 비어있으면 E에 있는 객체 파일들을 병합하여 출력 실행 파일을 생성한다.

과정이 이러하기 때문에, 라이브러리가 객체 파일 앞에 나타나면 참조가 해결되지 않아 링크가 실패할 수 있다.

gcc -static ./libvector.a main2.c

이러면 링크 안됨

gcc main2.c -L. -lvector

라이브러리를 명령줄 끝에 배치하면 링크 됨

라이브러리들 간의 의존성이 있는 경우, 의존성을 만족시키기 위해 명령줄에 라이브러리를 여러 번 반복해서 나타내야 할 수도 있다.

7.7 재배치 (Relocation)

재배치 : 링커가 심볼 해석을 완료한 후, 각 심볼 참조를 정확히 하나의 심볼 정의와 연결하는 과정. 입력 모듈들을 합치고 각 심볼에 런타임 주소를 할당한다. 이 때 링커는 입력 목적 모듈들 안에 코드와 데이터 섹션들의 정확한 크기를 알고 있다.

재배치 과정은 두 가지 단계로 구성된다

1. 섹션 및 심볼 정의 재배치

이 단계에서 링커는 같은 종류의 모든 섹션을 새로운 집합 섹션으로 합친다.
예를 들어, 입력 모듈들의 .data 섹션들은 출력 실행 목적파일을 위한 한 개의 .data 섹션으로 합쳐진다.
그 후 런타임 메모리 주소를 새로운 통합된 섹션들, 입력 모듈들에 의해 정의된 각 섹션들, 입력 모듈들에서 정의된 각 심볼들에 할당한다.
이 단계가 완료되면 프로그램의 각 명령어와 전역 변수가 고유한 런타임 메모리 주소를 갖게 된다.

2. 섹션 내 심볼 참조 재배치

링커가 코드와 데이터 섹션들 내의 모든 심볼 참조들을 수정해서 이들이 정확한 런타임 주소를 가리키도록 한다.
이 단계를 수행하기 위해서 링커는 재배치 가능 목적 모듈의 '재배치 엔트리'라는 자료구조를 사용한다.

재배치 항목 (Relocation Entries)

어셈블러가 객체 모듈을 생성할 때, 코드와 데이터가 궁극적으로 메모리의 어디에 저장될 지 알 수 없다.
또한, 모듈이 참조하는 외부 정의 함수나 전역 변수의 위치도 모른다.
따라서 어셈블러는 참조의 궁극적인 위치를 알 수 없을 때마다 해당 참조를 수정하는 방법을 설명하는 재배치 항목을 생성한다.
코드의 재배치 항목은 .rel.text 섹션에, 데이터의 재배치 항목은 .rel.data 섹션에 배치된다.

7.8 실행 가능한 목적파일

앞서 설명한 것들은 링커가 다수의 목적파일들을 하나의 실행 가능 목적파일로 합치는 과정이었다.

이제 C 프로그램이 ASCII 텍스트 파일 모음에서 시작해서,
메모리에 로드하고 실행하는데 필요한 모든 정보를 포함하는 단일 바이너리 파일로 변환되었다.

실행 파일의 구조 (ELF 형식)

  • ELF 헤더,
  • .text, .rodata, .data 섹션 : 재배치 가능한 객체 파일과 유사하지만, 링커에 의해 최종적인 런타임 주소로 재배치된다.
  • .init 섹션 : 프로그램의 초기화 코드에 의해 호출되는 작은 함수 _init을 정의한다.
  • .rel 섹션은 없다. 실행 파일이 완전히 링크되었기 때문에, 추가적인 재배치 정보 섹션이 필요하지 않기 때문이다.

ELF 실행 파일

ELF 실행 파일은 메모리에 쉽게 로드될 수 있도록 설계되었다.
즉, 실행 파일의 연속적인 청크들이 연속적인 메모리 세그먼트에 매핑된다.

프로그램 헤더 테이블 : 실행 파일이 메모리에 어떻게 매핑될지를 설명. 코드 세그먼트와 데이터 세그먼트로 나뉨.

  • 코드 세그먼트 : 실행 파일의 기계어 명령어를 포함한다.
    - 명령어 코드 : 컴파일러에 의해 생성된 프로그램의 기계어 명령어

    • 초기화 코드 : 프로그램 시작 시 실행되는 초기화 루틴 (_init 함수)
    • 읽기 전용 데이터 : 읽기 전용으로 사용되는 상수 데이터 (.rodata 섹션)
  • 데이터 세그먼트 : 초기화된 전역 변수와 정적 변수를 포함한다.
    - 초기화된 데이터 : 프로그램 시작 시 특정 값으로 초기화된 전역 변수와 정적 변수 (.data 섹션)

    • 초기화되지 않은 데이터 : 프로그램 시작 시 0으로 초기화되는 전역 변수와 정적 변수 (.bss 섹션)

7.9 실행 가능한 목적 파일의 로딩

사용자가 터미널에서 실행 파일을 실행하려고 할 때, 예를 들어 ./prog를 입력하면, prog가 내장 셸 명령어에 대응되지 않기 때문에 셸은 해당 파일이 실행 가능한 목적 파일이라고 가정하고 이를 실행하려고 시도한다. 셸은 로더를 호출하여 이 작업을 수행한다.

로더는 디스크에서 실행 파일을 메모리에 로드한다. 이 때, 프로그램 헤더 테이블을 참조하여 실행 파일의 각 세그먼트를 메모리의 적절한 위치에 복사한다. 로더는 먼저 코드와 데이터 세그먼트를 메모리에 복사한 후, 프로그램의 진입점으로 점프하여 프로그램 실행을 시작한다.

이 진입점은 보통 _start 함수로, 시스템 초기화 루틴을 호출하고, 사용자 프로그램의 main 함수를 호출하는 역할을 한다.

이와 같이 프로그램을 메모리로 복사하고 실행하는 과정을 로딩이라고 부른다.

로딩 과정

  1. 메모리 매핑 : 프로그램 헤더 테이블에 따라 파일의 내용을 메모리에 매핑한다.
  2. 초기화 코드 실행 : _init 함수와 같은 초기화 코드를 실행한다.
  3. 진입점으로 점프 : 프로그램의 진입점으로 점프하여 메인 프로그램 실행을 시작한다.

런타임 메모리 이미지

실행 파일이 메모리에 로드되면, 각 프로그램은 런타임 메모리 이미지를 갖게 된다.

  • 코드 세그먼트: 읽기/실행 권한이 부여되며, 프로그램의 기계어 명령어가 포함된다.
  • 데이터 세그먼트: 읽기/쓰기 권한이 부여되며, 초기화된 전역 변수와 정적 변수가 포함된다.
  • 힙: 동적으로 할당된 메모리가 포함되며, 런타임 중에 크기가 증가할 수 있다.
  • 스택: 함수 호출과 관련된 로컬 변수 및 리턴 주소를 포함하며, 일반적으로 위에서 아래로 성장한다.
  • 메모리 맵 영역: 공유 라이브러리가 로드되는 영역이다.

📝 Chapter 8 예외적인 제어흐름

제어 흐름의 양상은 크게 세 종류이다.

  • 메모리에 연속적으로 할당되어 있는 명령어들을 순차적으로 실행 (기본적)
  • 프로그램 상태의 변화에 반응하여 제어 흐름이 갑자기 바뀌는 경우 jump, call, return 등 실행
  • 예외적인 제어 흐름(ECF)

예외적인 제어 흐름(ECF)은 시스템 상태의 갑작스러운 변화에 대응하여 프로그램의 실행 흐름이 변화하는 것을 의미한다.

ECF는 하드웨어, 운영체제, 응용 프로그램 수준에서 모두 발생할 수 있으므로 이에 대한 대응도 전부 준비되어 있어야 한다.

  • 하드웨어 : 하드웨어에 의해서 검출되는 이벤트들은 예외 핸들러로 갑작스런 제어이동을 발생시킨다.
  • 운영체제 : 문맥전환을 통해서 사용자 프로세스에서 다른 프로세스로 제어가 이동한다.
  • 응용수준 : 프로세스는 시그널을 수신하는 곳에 있는 시그널 핸들러로 제어를 급격히 이동하는 다른 프로세스로 시그널을 보낼 수 있다.
  • 개별 프로그램 : 일반적인 스택 운영을 회피하고 다른 함수 내 임의의 위치로 비지역성 점프를 하는 방법으로 에러에 대응할 수 있다.

ECF를 이해해야 하는 이유

  • 시스템 개념 이해 : ECF는 운영체제가 입출력, 프로세스, 가상메모리를 구현하기 위해 사용하는 기본 메커니즘임

  • 응용 프로그램과 운영체제의 상호작용 이해 : 응용 프로그램은 트랩(trap) 또는 시스템 콜(system call)이라고 알려진 ECF를 이용해서 운영체제로부터 서비스를 요청함. 기본 시스템 콜 메커니즘을 이해하면 데이터를 디스크에 쓰거나, 네트워크에서 데이터를 읽는 등의 서비스들이 어떻게 응용 프로그램에 제공되는지 이해하는 데 도움됨

  • 재미있는 새로운 응용프로그램 작성 : 새로운 프로세스를 만들거나, 프로세스가 종료하기를 기다리거나, 다른 프로세서에게 시스템 내의 예외 이벤트를 알리거나, 이러한 이벤트를 감지하고 반응하는 등의 작업을 위한 ECF 메커니즘은 운영체제가 응용프로그램들에게 제공한다. ECF 메커니즘들을 이용해야 Unix 쉘과 웹 서버 같은 흥미로운 프로그램을 작성할 수 있다.

  • 동시성 : ECF는 컴퓨터 시스템에서 동시성을 구현하는 기본 메커니즘임. 실행 시간이 중첩되는 프로세스, 쓰레드, 응용프로그램의 실행을 가로채는 예외처리 핸들러, 응용프로그램의 실행을 가로채는 시그널 핸들러 등...

  • 소프트웨어적인 예외의 동작 이해 : C++과 자바 같은 언어는 try, catch, throw 문장을 통해서 소프트웨어 예외 메커니즘을 제공함. 소프트웨어 예외는 프로그램이 에러 발생 시에 비지역성(nonlocal) 점프(즉, 일반적인 call/return 스택 방식에 위배되는 점프)를 하도록 해준다. 비지역성 점프는 응용 수준의 ECF이며, C에서는 setjmp, longjmp 함수로 제공된다. 이러한 저수준 함수들을 이해하면 소프트웨어 수준의 고수준 예외를 이해할 수 있게됨.

앞 장까지는 응용이 하드웨어와 어떻게 상호작용하는지 배우는 거였고,
이번 장은 응용이 운영체제와 어떻게 상호작용하는지 배우기 시작한다.
이러한 상호작용들은 모두 ECF를 중심으로 돌아간다.

8.1 예외 (Exceptions)

예외 : 프로세서 상태의 변화에 대한 대응이며, 제어흐름의 갑작스런 변화.

프로세서가 어떤 명령어를 실행하고 있을 때 프로세서 상태에 변화가 일어나면(명령어와 관련 있을수도 있고 없을수도 있음) 예외 테이블이라는 점프 테이블을 통해서 이 특정 종류의 이벤트를 처리하기 위해 특별히 설계된 운영 체제 서브루틴(예외처리 핸들러)으로 간접 프로시저 콜을 하게 된다.

예외처리 핸들러가 처리를 끝마치면, 예외상황을 발생시킨 이벤트의 종류에 따라서 세 가지 중 하나가 발생한다.

  1. 핸들러가 제어를 이벤트가 발생했을 때 실행되고 있던 명령어로 돌려준다.
  2. 핸들러가 제어를 예외가 발생하지 않았다면 다음에 실행되었을 명령어로 돌려준다.
  3. 핸들러가 중단된 프로그램을 종료한다.

8.1.1 예외처리 (Exception Handling)

예외 처리 : 예외가 발생했을 때 운영체제와 하드웨어가 이를 처리하는 과정

예외 번호 : 가능한 예외 상황들에 중복되지 않는 양의 정수를 예외 번호로 할당함

  • 프로세서 설계자가 부여한 것 : divide by zero, 페이지 오류, 메모리 접근 위반, breakpoint, 산술연산 오버플로우 등...
  • 운영체제 커널 설계자가 할당한 것 : 시스템 콜, 외부 I/O 디바이스로부터의 시그널 등...

예외 처리 과정

  1. 현재 명령어 완료 : 현재 실행중인 명령어가 완료됨
  2. 예외 발생 감지 : 예외가 발생했음을 감지
  3. 상태 저장 : 현재 프로세스의 상태(프로세서 레지스터 값, 프로그램 카운터 등)를 저장
  4. 예외 조회 : 예외 테이블을 참조하여 해당 예외의 예외 핸들러를 찾음
  5. 예외 처리기 실행 : 예외 처리기를 실행하여 예외 처리
  6. 상태 복구 및 복귀 : 예외 처리기가 완료되면, 저장된 상태를 복구하여 원래의 프로그램 흐름으로 복귀하거나 프로그램을 종료함.

일반적인 함수의 호출과 예외 핸들러의 차이점

  • 프로세서는 프로시저 콜을 사용해서 핸들러로 분기하기 전에 스택에 리턴 주소를 저장해둠. 예외의 종류에 따라 리턴 주소는 현재 명령어이거나 다음 명령어가 됨.
  • 프로세서는 핸들러가 리턴할 때 중단된 프로그램을 다시 시작하기 위해 필요하게 될 추가적인 프로세서 상태를 스택에 푸시함.
  • 제어가 사용자 프로그램에서 커널로 전환하고 있을 때, 모든 정보들은 사용자 스택 위가 아니라 커널 스택 상에 푸시됨.
  • 예외 핸들러는 커널 모드에서 돌아가는데, 이것은 이들이 모든 시스템 자원에 완전히 접근할 수 있는 것을 의미함.

8.1.2 예외의 종류 (Types of Exceptions)

발생 원인과 처리 방식에 따라 분류한 예외 종류 (책마다 정의 다름)

인터럽트 (Interrupts)

  • 비동기적으로 발생하며, 외부 장치(ex. 키보드, 마우스, 타이머)에서 발생함.
  • 현재 명령어가 완료된 후 처리됨.
  • 주로 하드웨어 장치에서 발생하며, 프로세서에게 즉시 주의를 끌어야 하는 중요한 이벤트를 알림.
    - 하드웨어 인터럽트: 키보드 입력, 마우스 움직임, 타이머 이벤트, 네트워크 패킷 수신 등과 같은 외부 장치의 요청에 의해 발생함.
    - 타이머 인터럽트: 운영체제의 스케줄러가 프로세스의 실행 시간을 관리하기 위해 주기적으로 발생시
    - I/O 인터럽트: 디스크, 네트워크 인터페이스 등 I/O 장치에서 데이터 전송이 완료되었음을 알리기 위해 발생함.

트랩 (Traps)

  • 동기적으로 발생하며, 주로 소프트웨어 조건(ex. 시스템 호출, 디버그 이벤트)에서 발생함.
  • 명령어가 완료된 후 발생하며, 예외 처리 후 원래의 명령어 흐름으로 복귀함.
    - 시스템 콜 : 프로그램이 운영체제의 커널 서비스를 요청할 때 발생함. (ex. 파일 입출력, 메모리 할당, 프로세스 제어 등의 작업)
    - 디버그 트랩 : 디버거가 프로그램의 실행을 제어하기 위해 브레이크포인트를 설정하면 발생.

폴트 (Faults)

  • 동기적으로 발생하며, 주로 오류 조건(ex. 페이지 폴트, 정수 연산 오버플로)에서 발생함.
  • 문제가 해결되면 원래 명령어로 복귀하여 다시 실행을 시도함.
    - 페이지 폴트 : 프로그램이 접근하려는 메모리 페이지가 현재 메모리에 로드되지 않았을 때 발생. 커널은 페이지 폴트 처리기를 호출하여 필요한 페이지를 메모리에 로드함.
    - 보호 오류 (Protection Faults): 프로그램이 접근할 수 없는 메모리 영역에 접근하려고 할 때 발생. 예를 들어, 읽기 전용 메모리에 쓰기를 시도하는 경우.
    - 정수 연산 오버플로 : 정수 연산 중 오버플로가 발생할 때 발생함.

어보트 (Aborts)

  • 비동기적으로 발생하며, 치명적인 하드웨어 오류나 시스템 오류에서 발생함.
  • 어보트는 복구할 수 없는 상황을 나타내며, 프로그램을 즉시 종료함.
    - 하드웨어 오류 : 메모리 오류, 버스 오류 등 치명적인 하드웨어 오류가 발생할 때 발생함.
    - 시스템 불안정 : 시스템이 안정적으로 운영될 수 없을 때 발생함.

8.2 프로세스 (Process)

프로세스는 실행 중인 프로그램의 인스턴스를 의미함.
운영체제는 여러 프로세스를 동시에 실행시켜 사용자에게 여러 작업이 동시에 수행되는 것처럼 보이게 함.

각각의 프로세스는 특정 문맥에서 실행됨.
문맥(Context) : 프로그램이 올바르게 실행되기 위해 필요한 상태 정보들의 집합

사용자가 실행 목적파일의 이름을 쉘에 입력해서 프로그램을 돌릴 때마다 쉘은 새로운 프로세스를 생성하고, 실행 목적파일을 이 새로운 프로세스의 문맥에서 실행함.

프로세스는 두 가지 중요한 추상화를 제공함.

독립적인 논리적 제어 흐름 (Independent Logical Control Flow)
: 프로세서는 프로그램 내의 명령어들을 차례대로 중단 없이 실행하는 것처럼 보이고,

개인 주소 공간 (Private Address Space)
: 프로그램의 코드와 데이터는 시스템 메모리 상의 유일한 객체인 것처럼 보임.

8.2.1 논리적인 제어흐름 (Logical Control Flow)

프로세스는 시스템에 서로 다른 여러 프로그램들이 동시적으로 동작하고 있지만, 프로세서를 혼자서 사용한다는 착각을 느끼게 한다.

디버거 써보면 명령어들에 PC (프로그램 카운터) 값들이 대응된다는 걸 알 수 있음.
이러한 PC 값들의 배열을 논리적 제어흐름 또는 간단히 논리흐름이라고 부름.

하나의 프로세서에는 여러 프로세스들이 교대로 돌아감.
각 프로세스는 자신의 흐름의 일부를 실행한 후 다른 프로세스들이 실행되는 동안 일시적으로 정지된다.

8.2.2 동시성 흐름 (Concurrent Flow)

논리흐름은 컴퓨터 시스템 내에서 여러 가지 다른 형태를 가짐.
예외 핸들러, 프로세스, 시그널 핸들러, 쓰레드, 자바 프로세스 등...

동시성 흐름 : 자신의 실행시간이 다른 흐름과 겹치는 논리흐름. 이 두 흐름은 동시에 실행한다고 표현함.
동시성 : 여러 개의 논리적 흐름이 동시적으로 실행되는 것
멀티태스킹 : 프로세스가 다른 프로세스들과 교대로 실행되는 것. 타임 슬라이싱이라고도 함.
타임 슬라이스 : 한 프로세스가 자신의 흐름 일부를 실행하는 매 시간 주기

동시적 흐름은 프로세서 코어나 컴퓨터 개수와는 무관함. 두 흐름이 시간상으로 중첩되면, 이들이 동일한 프로세서에서 돌아가고 있더라도 이들은 동시적. 두 개의 흐름이 서로 다른 프로세서 코어나 컴퓨터에서 동시에 돌아가고 있다면 이건 병렬 흐름.

8.2.3 사적 주소공간 (Private Address Space)

프로세스는 각 프로그램에 사적 주소 공간을 제공함으로써 자신이 시스템의 주소공간을 혼자서 사용한다는 착각을 불러일으킨다.

사적 주소 공간의 특정 주소에 연결된 메모리의 한 개의 바이트가 일반적으로 다른 프로세스에 의해서 읽히거나 쓰일 수 없기 때문에, 이 공간은 사적(Private)이다.

각각의 사적 주소공간에 저장된 내용은 서로 다르더라도, 구조는 동일하다.

8.2.4 사용자 및 커널 모드 (User and Kernel Mode)

프로세서는 응용프로그램이 실행할 수 있는 명령어들을 제한해야 한다.
프로세서는 이를 위해 프로세스가 현재 가지고 있는 특권(Privilege)을 저장하는 일부 제어 레지스터로 모드 비트를 사용한다.

모드 비트가 설정되면 프로세스는 커널 모드로 동작한다. 커널 모드에서 돌고 있는 프로세스는 모든 명령어를 사용할 수 있고, 시스템 내의 어떤 메모리 위치도 접근할 수 있다.

모드 비트가 없으면 프로세스는 사용자 모드에서 돌아간다. 사용자 모드의 프로세스는 프로세서를 멈추거나, 모드 비트를 변경하거나, 입출력 연산을 초기화하는 등의 특수 인스트럭션을 실행할 수 없다. 이러한 시도를 하면 보호 오류가 발생한다. 그 대신 사용자 프로그램은 시스템 콜을 통해서 커널 코드와 데이터에 간접적으로 접근해야 한다.

프로세스가 사용자 모드에서 커널 모드로 진입하는 유일한 방법은 인터럽트, 오류같은 예외를 통해서다. 예외 핸들러에서 돌아오면 다시 유저 모드가 된다.

8.2.5 문맥 전환 (Context Switch)

운영체제 커널은 문맥 전환이라는 ECF의 상위수준 형태를 사용해서 멀티태스킹(프로세스가 다른 프로세스들과 교대로 실행되는 것)을 구현하고 있다. 문맥 전환은 ECF에 해당하는 예외 매커니즘을 기반으로 구현된다.

  • 문맥 : 커널이 중단됐던 프로세스를 다시 시작하기 위해서 필요로 하는 상태 정보들이다. 범용 레지스터, 프로그램 카운터, 상태 레지스터, 유저 스택, 커널 스택 등의 정보들로 구성된다.

  • 스케줄링 : 프로세스가 실행되는 동안 어떤 시점에 현재 프로세스를 정지하고 다른 프로세스를 다시 시작할지 결정하는 것. 스케줄러라고 불리는 커널 내부의 코드에 의해 처리됨. 커널이 실행할 새 프로세스를 선택하면 그 프로세스는 커널에 의해 스케줄된 것.

  • 문맥 전환 : 커널이 실행할 새 프로세스를 스케줄한 후에 현재 프로세스를 정지하는 것

문맥 전환은 커널이 사용자를 대신해서 시스템 콜을 실행하고 있을 때 일어날 수 있다. 만약 어떤 시스템 콜이 특정 이벤트의 발생을 기다리고 있다면, 커널은 현재 프로세스를 sleep시키고 다른 프로세스로 문맥 전환한다.

문맥 전환은 인터럽트에 의해 발생할 수도 있다. 예를 들어, 모든 시스템은 대개 1 ms 또는 10 ms마다 주기적인 타이머 인터럽트를 생성하는 어떤 메커니즘을 갖고 있다. 타이머 인터럽트가 일어날 때마다 커널은 현재 프로세스가 충분히 오래 실행됐다고 판단하고 문맥 전환한다.



⚔️ 백준


📌 15649 N과 M (1)

N, M = map(int, input().split())

arr = [i for i in range(1,N+1)]

def permutation(arr, case):
    if len(arr) == N-M:
        for n in case:
            print(n, end=' ')
        print()
        return

    for i in range(len(arr)):
        case.append(arr[i])
        permutation(arr[:i]+arr[i+1:len(arr)],case)
        case.pop()

permutation(arr,[])

똑같은거 풀었던 기억으로 금방 풀긴 했는데 사실상 답을 기억해내서 푼거라 이게 맞나? 싶긴함. 근데 안 푼 것보단 낫지 않나 싶음.

0개의 댓글