public string DevelopmentDiary(Knowledge knowledge) {
if (knowledge != null) {
return "level up"
} else {
return "level down"
}
}
6주차에 진입하지도 않았지만 집필 중이다.
악명 높은 '그 녀석'이 나오기 때문이다.

맹활약할 그 녀석이다.

모자이크 처리했다.
근데 또 뭐 괜히 나 혼자 오버하는 걸수도 있다.
자료구조와 알고리즘 책에서 그 녀석이 나와서 공부하다가
'내가 이렇게까지 공부해야하나?', '그냥 이건 넘어갈까?'라고 생각했던 친구라서 오버 좀 해봤다.
일단 들어가보자.
RB트리의 기본 개념에 대해 이해하고 이후 기본 규칙으로 들어가보겠다.
레드블랙트리란 자가 균형 이진 탐색 트리(self-balancing binary search tree)의 한 종류로, 삽입, 삭제, 검색 연산에서 항상 O(log n)의 시간 복잡도를 보장하는 효율적인 자료구조입니다.
이진 탐색 트리의 일종: 각 노드는 최대 두 개의 자식 노드를 가집니다.
자가 균형 유지: 트리의 균형을 색상 속성(빨간색, 검은색)과 몇 가지 규칙을 통해 자동으로 유지합니다.
최악의 경우에도 효율적: 트리의 높이가 O(log n)으로 제한되어, 데이터가 한쪽으로 치우치지 않고 항상 균형에 가깝게 유지됩니다
라고 AI가 말했다.
이렇게만 봤을 때 되게 좋아보인다. 최악의 경우에도 효율적으로 작동할 수 있다니
이 얼마나 멋진 구조인가?
언제나, 멋진 물건과 시스템. 그 이면에는 불편한 진실이 도사리고있다.
'최악의 경우에도 효율적'을 유지하기 위해 수많은 코드들이 부품처럼 일하고 있다.
기본 규칙은 레드블랙트리를 유지하는데 있어서 중요하다.
추후 나오는 삽입과 삭제 관련 머리 아픈 규칙들은 결국 이 기본 규칙을 유지하기 위해서 생긴 규칙들이기 때문이다.
우선은 한 눈에 보기 편하게 쭉 나열하고 이후 이에 대해 설명하겠다.
1. 노드의 색은 검은색 또는 빨강색이다.
2. 루트 노드는 항상 검은색이다.
3. 모든 리프 노드는 검은색이다. (모든 Nil 노드는 검은색이다.)
4. 빨간색 노드의 자식은 반드시 검은색이다.
5. 어떤 노드에서 리프 노드까지 가는 모든 경로에는 같은 개수의 검은색이 있다.
가장 기본이 되는 규칙이다. RB트리가 RB트리라고 불리는 이유다. RB트리는 이진트리에 색을 입힌게 전부이기 때문에 결국 빨간색과 검은색을 어떻게 다루느냐가 중점이 된다.
어려울 것 없는 규칙이다. 왜? 검은색인지에 대해서 아직 찾아보지도, 생각해보지도 않았지만 아무래도 새로 삽입하는 노드가 빨간색이라는 규칙과 관련이 있지 않을까 싶다.
리프 노드는 트리에서 자식이 없는 노드를 말한다. 이는 레드블랙트리도 마찬가지인데,
큰 차이점이 있다면, RB트리의 리프 노드는 값을 가지지 않는다.
RB트리의 값이 있는 모든 노드는 자식이 있으며, 자식은 값이 있는 노드 혹은 Nil노드 둘 중 하나다.
다음 예시를 보면 이해가 쉽다. 편의상 색은 생략하겠다.
10
/ \
5 15
/ \ / \
nil 7 nil 20
/ \ / \
nil nil nil nil
이런 느낌이다.
다시말해 빨간색 노드는 두 번 연속 등장할 수 없다는 의미이다. 검은색의 자식은 빨간색과 검은색 둘 다 될 수 있지만, 빨간색 노드는 검은색 자식만 가질 수 있다.
여기서 리프 노드는 당연히 nil노드를 말하는 거다.
예시를 들어보겠다.
10
/ \
5 15
/ \ / \
nil 7 nil 20
/ \ / \
nil nil nil nil
여기에서 나올 수 있는 경로는
10 - 5 - nil
10 - 5 - 7 - nil
10 - 15 - nil
10 - 15 - 20 -nil
이렇게 나온다.
벌써 여기서 각 노드들의 색이 어떻게 칠해질지 정해진다고 볼 수 있다.
1) 루트 노드는 검정이다.
10(B) - 5 - nil
10(B) - 5 - 7 - nil
10(B) - 15 - nil
10(B) - 15 - 20 -nil
2) nil 노드 역시 검정이다.
10(B) - 5 - nil(B)
10(B) - 5 - 7 - nil(B)
10(B) - 15 - nil(B)
10(B) - 15 - 20 -nil(B)
3) 빨간색의 자식은 무조건 검은색이다.
왜 이 규칙이 나왔느냐 하면, 만약 둘 다 검은색이 되면
10(B) - 5(B) - nil(B) => 검은색 노드 3개
10(B) - 5(B) - 7(B) - nil(B) => 검은색 노드 4개
위와 같은 결과가 나오므로 5번 규칙에 위배된다.
따라서 5와 7이 빨간색이 되어야 하는데, 4번 규칙에 의해 5-7은 둘 다 빨간색이 될 수 없으므로
5가 빨간색이거나 7이 빨간색이 돼야한다.
3-1) 5가 빨간색인 경우
10(B) - 5(R) - nil(B) => 검은색 노드 2개
0(B) - 5(R) - 7(B) - nil(B) => 검은색 노드 3개
따라서 위배된다.
3-2) 7이 빨간색인 경우
10(B) - 5(B) - nil(B) => 검은색 노드 3개
0(B) - 5(B) - 7(R) - nil(B) => 검은색 노드 3개
따라서 예시를 수정하면
10(B)
/ \
5(B) 15(B)
/ \ / \
nil(B) 7(R) nil(B) 20(R)
/ \ / \
nil(B) nil(B) nil(B) nil(B)
아마 위와 같은 그래프가 될 것이다.
이제부터는 위의 규칙을 지키기 위해 고군분투하는 과정이 될 것이다.
삽입/삭제 규칙을 잘 모르겠다면 아무튼 규칙을 지키기 위해 만들 수 있는 그래프를 생각해보자.
RB트리에서 회전이라는 개념은 중요하다. 삽입과 관련해서 개념을 정리하다 회전 개념을 따로 정리해두는 것이 좋을 것 같아서 새로 추가 중이다.
RB트리의 회전은 B트리의 상승 분할과 유-사하면서 다른 면이 있다.
유사과학 좋아하는가?
나는 싫어한다.
근데 지구평평설은 좋아한다. 뭔가 바다가 욕조에 담겨 있는 것 같아서 재밌다.
잠깐 B트리를 보겠다.
3차 B트리이고 다음과 같은 구조라고 하겠다. 물론 B트리에서 다음과 같은 구조는 있을 수 없긴하다.
이는 RB트리의 회전이 상승 분할과 유사하다는 걸 보여주는 장치일 뿐이다.
11,18
/ | \
9 14 19
/ \ \
12 17 20
여기서 모종의 이유로 19가 상승한다고 할 때
11,18,19
/ | \
9 14 20
/ \
12 17
이런 모양이 되고
18
/ \
11 19
/ \ \
9 14 20
/ \
12 17
최종적으로는 이런 모양이 될 것이다.
이 모양을 잘 기억해두자.
회전에는 두 가지가 있다.
왼쪽 회전과 오른쪽 회전
쉽게 생각하면
왼쪽 회전(반시계 방향 회전)
오른쪽 회전(시계 방향 회전)
이라고 생각하면 편하다.
이해하기 쉽게 삼각형을 그리도록 하겠다.
이진 탐색 트리의 일종이므로 크기를 색으로 표현하겠다.
빨간색이 가장 작고, 초록색이 가장 크다고 생각하면 된다.
나머지 노드들은 부모 노드를 따라가기 때문에 크게 신경쓰지 않아도 된다.
반시계 방향으로 회전한다고 생각해보라
그럼 초록색이 주황색 자리에 위치하게 될 것이다. 그러면
이런 형태가 되는데, 노란색이 어디에 붙으면 가장 이상적일지 고민해보자.
초록색보다 작고
주황색보다 크므로
저 위치에 붙게 되는 것이다. 이게 왼쪽 회전이다.
숫자로 보면 복잡하지만 그림으로 보면 간단하다.
아까 B트리와 유사한 무색 RB트리를 보자.
11
/ \
9 18
/ \
14 19
/ \ \
12 17 20
여기서 11을 기준으로 왼쪽으로 회전할 것이다.
9가 빨간색, 11이 주황색, 18이 초록색, 14가 노란색이다.
구조에 대해서 잠깐 고민하는 시간을 가져보자.
아래와 같은 형태가 된다.
18
/ \
11 19
/ \ \
9 14 20
/ \
12 17
사실 여기까지 이해했으면
오른쪽 회전도 결국 똑같기 때문에 별거 없다.
앞서 설명했던 B트리와 비교해보자.
11,18
/ | \
9 14 19
/ \ \
12 17 20
B트리와 비교해보면
11
/ \
9 /\
14 18
/ \ \
12 17 19
\
20
14는 11과 18사이에 걸쳐있다고 생각해도 좋다.
위 그래프에서는 중력? 때문에 18쪽으로 기울어져 있고
18
/ \
/\ 19
11 14 \
/ / \ 20
9 12 17
위 그래프에서는 중력 때문에 11쪽으로 기울어져 있다.
시공간을 왜곡시켰다.
시계 방향으로 회전한다고 생각해보라
그럼 빨간색이 노란색 자리에 위치하게 될 것이다. 그러면
이런 형태가 되는데, 주황이 어디에 붙으면 가장 이상적일지 고민해보자.
빨간색보다 크고
노란색보다 작으므로
저 위치에 붙게 되는 것이다. 이게 오른쪽 회전이다.
숫자로 보면 복잡하지만 그림으로 보면 간단하다.
다시 한 번 무색 RB트리를 보자.
18
/ \
11 19
/ \ \
9 14 20
/ \
12 17
여기서 18을 기준으로 오른쪽으로 회전할 것이다.
11이 빨간색, 14가 주황색, 18이 노란색, 19가 초록색이다.
구조에 대해서 잠깐 고민하는 시간을 가져보자.
아래와 같은 형태가 된다.
11
/ \
9 18
/ \
14 19
/ \ \
12 17 20
여기부터는 고찰이 필요할 것 같아 다른 글에 정리할 예정이다.
https://velog.io/@mogiyoon/RB-Tree에-대한-고찰
서로가 서로를 참조하고 있다.
Bryant, R. E., & O’Hallaron, D. R. (2023). Computer systems: A programmer’s perspective (3rd ed.). Pearson.
전통적인 링커의 역할은 정적인 연결이었다. 시대가 변함에 따라 모듈이 많아지고 공유 라이브러리와 동적 링킹 사용 증가에 따라 중요성이 커졌다.
정적 링커는
입력: 재배치 가능*한 목적 파일, 명령줄 인자(command line arguments)
을 통해 로드되고
출력: 완전히 링크된 실행 가능 목적 파일
함
* 재배치 가능: 나중에 링커가 여러 목적 파일을 결합할 때 각 코드와 데이터의 실제 위치(주소)를 자유롭게 “재배치(relocate)“할 수 있도록 만들어졌음 (- AI)
실행파일을 만들기 위한 링커의 두 가지 주요 작업
(1) 심볼* 해석: 목적 파일들은 심볼들을 정의하고 참조함. 심볼 해석은 각각의 심볼 참조를 정확하게 하나의 심볼 정의에 연결함.
(2) 재배치: 컴파일러와 어셈블러는 주소 0번지에서 시작하는 코드와 데이터 섹션들을 생성함. 링커는 이 섹션들을 각 심볼 정의와 연결시켜 재배치 함. 이 심볼들로 가는 모든 참조를 수정하여 이 메모리 위치를 가리키도록 함.
* 심볼: 변수명, 함수명 등 엔티티를 식별하기 위해 사용하는 이름
이를 이해하기 위해 컴파일 시스템을 다시 떠올려보자
hello.c -> (전처리) -> hello.i -> (컴파일러) -> hello.s -> (어셈블러) -> hello.o -> (링커) -> hello
즉, 어셈블러에서 어떤 함수, 혹은 전역 변수를 호출한다는 내용만 담겨있다.
main:
... ; (main 함수 코드)
call foo ; foo 함수 호출 (주소는 미정, 심볼로만 표기)
이후 링커에서는 목적파일들을 통해 함수나 변수의 주소를 정한다.
AI의 설명을 곁들이겠다.

개인적으로 나는 call foo는 그대로 두고
링킹 전
call foo => a 참조
a => 주소 모름
링킹 후
call foo => a 참조
a => 0x400520
0x400520 => foo()
인 줄 알았는데
바로 call 0x400520을 한다니 신기했다.

바로로 해버린다.
목적 파일에는 3가지 종류가 있다.
(1) 재배치 가능 목적파일
-포맷에 컴파일 할 때 실행 가능 목적 파일을 생성하기 위해 다른 재구성가능 목적파일들과 결합될 수 있는 바이너리 코드와 데이터를 포함함.
(2) 실행 가능 목적파일
-메모리에 직접 복사될 수 있고 실행될 수 있는 형태로 바이너리 코드와 데이터를 포함함.
(3) 공유 목적파일
-로드타임 또는 런타임 시에 동적으로 링크되고 메모리에 로드될 수 있는 특수한 유형의 재배치 가능 목적파일.
컴파일러와 어셈블러는 재배치 가능 목적파일을 생성한다.
링커는 실행 가능한 목적파일을 생성한다.
목적 모듈은 바이트의 배열이며
목적파일은 디스크에 파일로 저장된 목적 파일이다.
목적 파일들은 특정 목적파일 형식에 따라 구성되며 현대 x86-64 리눅스와 유닉스 시스템은 ELF를 사용한다.
컴파일러와 링커가 이해할 수 있는 파일 포맷 표준이라고 한다.
| ELF header |
|---|
| .text |
| .rodata |
| .data |
| .bss |
| .symtab |
| .rel.text |
| .rel.data |
| .debug |
| .line |
| .strtab |
| Section header table |
rel은 재배치 정보를 담고 있는 섹션(주소가 확정되지 않음)이다.
ELF 파일은 크게 3개의 중요한 부분으로 나뉜다.
(1)ELF Header: 파일의 기본 정보 (타입, 아키텍처, 오프셋 등)
(2)Section Header Table: 섹션들의 위치/크기/타입 등을 담은 테이블
(3)섹션들(Sections): 코드, 데이터, 심볼, 문자열 등이 들어있는 실제 내용
링커가 목적파일을 구문분석하고 해석할 수 있도록 하는 정보를 포함함



?
| 섹션 이름 | 설명 |
|---|---|
.text | 기계어 코드 (실행 가능한 명령어) |
.rodata | 읽기 전용 데이터 (ex. 문자열 상수) |
.data | 초기화된 전역 및 정적 변수 |
.bss | 초기화되지 않은 전역 및 정적 변수 (파일에 저장 안 됨) |
.symtab | 심볼 테이블 (함수, 변수 정보) |
.rel.text | .text 섹션의 재배치 정보 |
.rel.data | .data 섹션의 재배치 정보 |
.debug | 디버깅 정보 |
.strtab | 문자열 테이블 (심볼 이름 등) |
링커의 문맥 내에서는 서로 다른 세 가지 심볼이 있다.
(1) 모듈 m에 의해 정의되고 다른 모듈들에 의해서 참조될 수 있는 전역 심볼
(2) 모듈 m에 의해 참조되지만 다른 모듈에 의해 정의된 전역 심볼, external이라고 부름
(3) 모듈 m에 의해 배타적으로 참조되고 정의된 지역 심볼. 모듈 m 내 어디어서 접근 가능하지만 다른 모듈에 의해서는 참조될 수 없음. (static으로 선언된 전역 변수)
지역 링커 심볼들은 지역 프로그램 변수와 같지 않다.
symtab에 있는 심볼 테이블은 지역 비정적 프로그램 변수들에 대응되는 심볼을 전혀 포함하지 않는다.
이들은 런타임에 스택에 의해 관리되며 링커의 관심거리가 아니다.
왜냐하면 링커는 다른 파일에서 참조되는 변수와 함수에만 관심이 있다.
따라서 함수 내부에서만 쓰이는 지역 변수는 신경을 쓰지 않아도 되는 것이다.
그러나 static 변수들은 선언된 위치에 상관없이 스택에서 관리되지 않고, .data나 .bss에서 각 정의에 대한 공간을 할당한다.
또한 심볼 테이블 내 지역 링커 심볼들이 유일한 이름을 갖도록 한다.
예를 들어 다른 두 함수에서 x가 static으로 선언됐다면 x.1을 첫 번째 함수, x.2를 두 번째 함수의 x에 대한 정의를 위해 사용된다.
컴파일러에 의해 .s파일을 생성하고 여기서 export된 심볼을 사용하여 어셈블러가 심볼 테이블을 만든다.
ELF 포맷에서 심볼 테이블은 .symtab 섹션에 있다.
typedef struct {
int name;
...
long size;
} Elf64_symbol;
위와 같은 구조체가 .symtab에 연속적인 배열로 저장되어 있다.
심볼의 해석은 동일한 모듈 내에 정의된 지역 심볼들로 참조를 한 경우는 간단하다.
컴파일러는 모듈당 단 하나의 지역 심볼 정의만을 허용한다.
이게 무슨 말이냐 하면
같은 모듈 안에
static int x = 1;
static int x = 2;
를 허용하지 않는다는 것이다.
따라서 정적 지연 변수들이 유일한 이름을 갖도록 보장한다.
만약 현재 모듈에서 정의되지 않은 심볼을 만나면 다른 모듈에서 정의되어 있다고 가정하고
링커 심볼 엔트리를 생성하여 링커가 이것을 처리하도록 남겨둔다.
만약 링커가 참조를 해석할 수 없으면 종료하게 된다.
링커는 중복된 심볼 이름을 처리하기 위해 아래와 같은 규칙을 사용하는 데, 일단 강한 심볼과 약한 심볼에 대한 개념부터 정의하겠다.
강한 심볼: 함수들과 초기화된 전역 변수
약한 심볼: 비초기화된 전역변수
(1) 동일한 이름을 갖는 복수의 강한 심볼은 허용되지 않는다.
(2) 동일한 이름의 강한 심볼과 다수의 약한 심볼들이 있으면 강한 심볼을 선택한다.
(3) 동일한 이름의 여러 개의 약한 심볼이 있으면 어떤 약한 심볼을 선택해도 관계없다.
정적 라이브러리를 사용하지 않았을 때의 단점
(1) 컴파일러가 표준 함수들로의 모든 호출을 인식하고 직접 적절한 코드 생성
=> C 표준에서는 많은 수의 표준 함수들이 정의되어 있고 따라서 컴파일러에 상당한 복잡성을 더하게 됨.
(2) 모든 표준 C 함수들을 한 개의 재배치 가능 목적 모듈에 저장해서 링크하도록 함.
=> 모든 실행파일들이 표준 함수들의 모음 전체의 복사본을 포함하게 되며 디스크 공간을 낭비함.
(3) 각각의 표준 함수에 대해서 재배치 가능 파일을 별도로 생성하고 잘 알려진 디렉토리에 저장함.
=> 명시적으로 적절한 객체 모듈을 자신의 실행파일에 링크해야함.
연관된 함수들은 별도의 객체 모듈로 컴파일하여 하나의 정적 라이브리러리 파일로 패키지화 할 수 있음.
링크 시 링커는 오직 프로그램이 참조하는 객체 모듈들만 복사함.
응용 프로그래머는 일부 라이브러리 파일의 이름을 포함하기만 하면 됨.
리눅스에서는 정적 라이브러리는 아카이브라고 알려진 파일 포맷으로 디스크 상에 저장된다.
E: 실행파일을 구성하기 위해 합쳐질 재배치 가능 목적 파일들의 집합
U: 미해석 심볼 집합
D: 이전 입력파일에서 정의된 심볼 집합
f: 입력 파일
f가 목적 파일일 경우: 링커는 f를 E에 추가하고 심볼 정의와 f에서 참조를 반영하도록 U와 D를 갱신함.
f가 아카이브일 경우: 링커는 U안의 미해석 심볼들을 아카이브의 멤버들에 의해 정의된 심볼들과 매칭하려고 시도함.
링커가 명령줄의 입력 파일들을 스캔하는 작업을 끝마칠 때
그러나 이 알고리즘은 링크 타임 에러를 발생시키기도 한다. 라이브러리의 목적파일의 순서 때문이다.
링커의 심볼 해석 단계가 끝나면 코드 내 각 심볼 참조는 정확히 한 개의 심볼 정의에 연결된다.
이 때 링커는 입력 목적 모듈들 안의 코드와 데이터 섹션의 정확한 크기를 알고 있다.
재배치 단계에서는 입력 모듈을 합치고 각 심볼에 런타임 주소를 할당한다.
링커는 같은 종류의 모든 섹션들을 하나의 새로운 통합된 동일한 타입의 섹션으로 합친다. 입력 모듈들의 .data 섹션들은 출력 실행 목적파일을 위한 한 개의 .data 섹션으로 합쳐진다. 이후 링커가 런타임 메모리 주소를 할당한다. 이후 모든 인스트럭션과 전역변수들은 유일한 런타임 메모리 주소를 가진다.
링커는 코드와 데이터 섹션들 내의 모든 심볼 참조들을 수정해서 정확한 런타임 주소를 가리키도록 한다. 이를 위해 '재배치 엔트리'라고 알려진 재배치 가능 목적 모듈들 안의 자료구조에 의존한다.
A) 재배치 엔트리
어셈블러가 궁극적인 위치를 알지 못하는 객체 참조를 만나면 재배치 엔트리를 생성한다.
이 재배치 엔트리는 링커가 목적 파일을 실행 가능 파일로 합칠 때 참조를 어떻게 수정할 수 있는지 알려준다.
코드에 대한 재배치 엔트리들은 .rel.text에, 초기화 데이터들에 대한 재배치 엔트리들은 .rel.data에 저장된다.
B) 심볼 참조의 재배치
실행 가능 목적 파일의 포맷은 재배치 가능한 목적 파일의 포맷과 유사하다.
| ELF header |
|---|
| Segment header table |
| .init |
| .text |
| .rodata |
| .data |
| .bss |
| .symtab |
| .debug |
| .line |
| .strtab |
| Section header table |
linux> ./prog
prog가 내장 쉘 명령어에 대응되지 않기 때문에 prog를 실행 가능한 목적파일이라고 가정한다.
쉘은 loader라고 알려진 메모리 상주 운영체제 코드를 호출해서 이 프로그램을 실행한다.
모든 리눅스 프로그램은 evecve 함수를 통해 로더를 호출할 수 있다.
로더는 디스크로부터 실행 가능한 목적파일 내 코드와 데이터를 메모리로 복사하고 프로그램의 첫 번째 인스트럭션(엔트리 포인트)로 점프해서 프로그램을 실행한다.
프로그램을 메모리로 복사하고 실행하는 과정을 로딩이라고 부른다.
프로그램 헤더 테이블
-어떤 파일 오프셋을 메모리 어디에 맵핑할지를 로더에게 알려줌
( ex) .text, .rodata는 읽기/실행 허용 영역, .data, .bss는 읽기/쓰기 허용 영역)
가상 메모리에서 효율적인 페이지 맵핑을 위해
vaddr mod align == offset mod align을 만족함.
Address Space Layout Randomization
-힙, 스택, 공유 라이브러리의 위치는 매 실행 시마다 변경될 수 있음.
-보안 향상을 위한 기술임
-전체적인 상대적 순서는 동일함(스택이 힙보다 위쪽에 위치함)
정적 라이브러리는 코드 중복과 유지 보수의 어려움이 있다.
예를 들어 대부분의 C 프로그램에는 printf와 sacnf 표준 함수를 쓰게 된다.
다시 말하면 모든 실행 파일에 이 함수들이 복사되어 들어가며 메모리 낭비를 유발한다.
또한 프로그램을 새 버전의 라이브러리로 바꾸고 싶을 때
관련 모든 프로그램을 다시 링크해야 한다.
공유 라이브러리란
실행 시간 또는 적재 시간에 메모리에 로딩되고 실행 중에 링킹될 수 있다.
리눅스에서는 .so 윈도우에서는 .dll 확장자이다. .dll 확장자는 굉장히 익숙하다.
공유 방식
(1) 파일 공유: 디스크 상의 하나의 .so 파일을 여러 프로그램이 함께 사용함.
(2) 메모리 공유: 메모리에 로딩된 .text 섹션이 여러 프로세스 간에 공유됨(메모리 사용 최적화)
(.text 섹션은 위치 독립적이고 여러 프로세스 간 공유가 가능함.)
fpic: 위치 독립 코드 생성
shared: 공유 라이브러리 생성
프로그램 실행 시, ELF 실행 파일 안의 .interp 섹션이 가리키는 동적 링커가 실행된다.
동적 링커가 하는 일
뭔가 이렇게 보면 정적 링커와 하는 일이 다른 것이 없는 것 같다.
하지만 정적 링커와 동적 링커의 결정적 차이점은 언제 그 일을 하는 지이다.
앞서 설명했든 정적 링커는 실행 가능 목적 파일로 합칠 때 심볼들의 주소를 모두 결정한다.
그러나 동적 링커는 공유 라이브러리에 포함된 심볼들의 주소를 프로그램이 실행될 때 채우게 된다.
적재시간 동적 로딩: 프로그램이 실행되기 직전에 공유 라이브러리를 로딩하는 방식
런타임 동적 로딩: 프로그램이 실행 도중 직접 공유 라이브러리를 로딩하는 방식
적재시간 동적 로딩과 관련된 함수는 없다.
런타임 동적 로딩과 관련된 함수는 <dlfcn.h> 헤더 파일에 선언돼 있다.
(1) dlopen()을 통해 .so 파일을 로딩하고
(2) dlsym()을 통해 해당 파일이 있는 함수/변수의 실제 주소를 얻는다.
(3) 함수처럼 호출하고
(4) dlclose()로 라이브러리를 언로드한다. (선택적)
dlopen에 RTLD_LAZY를 하면 심볼 주소를 실제로 사용할 때 해석하고, RTLD_NOW를 하면 심볼 주소를 로딩할 때 바로 해석한다.
유용성
위치 독립 코드의 정의 및 필요성
PIC에서 데이터 참조
PIC에서 함수 호출
lazy binding: 외부 함수가 처음 호출될 때만 실제 주소를 동적으로 결정하고 이후에는 GOT를 통해 바로 호출함.
프로그램이 호출하는 라이브러리 함수의 동작을 가로채거나 감시, 변경하는 기법
이를 통해 함수 호출의 입력/출력 값을 추적하거나 완전히 새로운 구현으로 대체 가능함.
C 전처리기의 매크로 기능을 활용해 프로그램 함수 호출을 래퍼(wrapper) 함수로 치환하는 방식이다.
이 기법을 사용하면 라이브러리 함수를 호출할 때마다 중간에 원하는 동작을 삽입할 수 있다.
관점지향 프로그래밍이 생각나는 부분이다.
특징 및 장단점
링커의 특별한 옵션을 이용해, 프로그램이 호출하는 라이브러리 함수를 개발자가 만든 래퍼 함수로 대체하는 기법이다. 소스 코드를 수정하지 않고 오브젝트 파일만 있으면 적용할 수 있다.
특징 및 장단점
프로그램의 실행 시점에 라이브러리 함수 호출을 가로채는 기법으로 컴파일 타임이나 링크 타임과 달리 실행 파일만 있으면 적용할 수 있다. 동적 링커가 제공하는 환경 변수 LD_PRELOAD를 활용한다.
동작 원리
LD_PRELOAD 환경 변수에 하나 이상의 공유 라이브러리(.so 파일) 경로를 지정하면, 동적 링커는 프로그램을 실행할 때 이 라이브러리들을 가장 먼저 로드한다.
프로그램이 외부 함수(예: malloc, free 등)를 호출할 때, LD_PRELOAD로 지정한 라이브러리에 동일한 함수가 있으면 그 구현이 우선적으로 호출된다.
이로써 기존 함수 호출을 “래퍼(wrapper)” 함수로 대체하거나, 호출을 감시·로깅·검증·변경할 수 있다.
특징 및 장단점
장점
단점
시스템은 프로그램 내/외부적인 흐름 변화에도 반응해야 한다.
하드웨어 수준: 하드웨어에 의해 검출되는 이벤트들은 예외 핸들러로 제어이동 발생
운영체제: 컨텍스트 스위치로 프로세스 간 제어권이 이동한다.
애플리케이션: 시그널을 통한 비동기 이벤트를 처리한다.
프로그램: 오류 발생시 비지역 점프를 통해 임의 위치로 이동한다.
예외는 프로세서의 상태 변화에 대응하여 현재 실행 중인 명령어 흐름을 하드웨어적으로 중단하고
미리 정의된 예외 처리 루틴으로 제어를 넘기는 현상이다.
예외는 프로세서가 Icurr라는 명령어를 실행 중일 때, 내/외부 이벤트가 발생하면 트리거 된다.
이벤트가 발생하면 프로세서는 예외 테이블을 참조하여 해당 예외 번호에 맞는 예외 처리기로 간접 호출을 수행한다.
이후 다음 셋 중에 하나를 수행한다.
예외는 프로세서의 상태 변화에 의해 발생하며,
이 이벤트는 현재 명령어 실행과 관련이 있을 수도 없을 수도 있다.
시스템의 각 예외 유형은 고유한 예외 번호를 가지며 프로세서 설계자와 운영체제 커널이 할당함
예외 테이블
예외 처리기의 동작
예외 처리 종료와 복귀
| 클래스(Class) | 원인(Cause) | 동기/비동기(Sync/Async) | 반환 동작(Return Behavior) |
|---|---|---|---|
| Interrupt | I/O 장치의 신호 | 비동기(Async) | 항상 다음 명령어로 반환 |
| Trap | 의도적인 예외 (예: system call) | 동기(Sync) | 항상 다음 명령어로 반환 |
| Fault | 복구 가능한 잠재적 오류 | 동기(Sync) | 현재 명령어로 반환 가능 (복구 여부에 따라 다름) |
| Abort | 복구 불가능한 오류 | 동기(Sync) | 절대로 반환하지 않음 |
비동기 - 명령어와 관련이 없음
동기 - 명령어와 관련이 있음
프로세스의 정의
프로세스의 주요 추상화
운영체제의 역할
논리적 제어 흐름
운영체제는 여러 프로세스를 짧은 시간 단위로 번갈아 실행함. 각 프로세스는 자신만의 논리적 흐름을 갖고 다른 프로세스의 존재를 인식하지 못함. 실제로는 여러 프로세스가 CPU를 공유함.
동시성 흐름
프로세스의 동시성
멀티태스킹
프로세스의 주소 공간
주소 공간의 구조
(운영체제와 하드웨어(MMU)에 의해 지원됨)
주소 공간의 격리와 보호
CPU에는 현재 프로세스가 어떤 권한으로 실행되고 있는지를 나타내는 모드 비트가 있음.
비트가 설정되어 있으면 커널 모드, 설정되어 있지 않으면 사용자 모드임.
사용자 모드
커널 모드
모드 전환
중요성 및 역할
컨텍스트 스위치
컨텍스트 스위치의 동작 과정
(1) 커널의 스케줄러가 어떤 프로세스를 실행할지 결정함(스케줄링).
(2) 현재 프로세스의 컨텍스트(레지스터, 스택, 커널 데이터) 저장
(3) 이전에 중단된 프로세스의 컨텍스트 복원
(4) 복원된 프로세스의 실행을 이어감.
컨테스트 스위치가 발생하는 상황
각각의 프로세스는 0이 아닌 고유의 양수 프로세스 ID(PID)를 가진다.
PID는 커널이 프로세스를 식별하고 관리하는데 사용된다.
PID 확인 함수
프로그래머의 관점에서 프로세스의 상태
자식 프로세스의 생성 - fork
프로세스의 종료 - exit
프로세스의 상태
자식 프로세스가 종료되면 커널은 자식의 종료 상태와 최소한의 정보를 커널 테이블에 남겨둠.
부모 프로세스가 이 종료 상태를 회수(reap)할 때까지 자식 프로세스는 "좀비" 상태가 됨.
좀비 프로세스는 실제로 실행되지는 않지만, 프로세스 테이블 엔트리를 차지함.
따라서 부모가 적절히 수거하지 않으면 시스템 자원이 고갈됨.
자식 프로세스 수거 방법
고아 프로세스와 init 프로세스
비동기 수거: 부모 프로세스가 자식의 종료를 즉시 기다리지 않아도 시그널을 통해 자식의 종료를 감지하고 핸들러에서 waitpid를 호출해 비동기적으로 수거 가능함.
sleep 함수
execve 함수
fork와 execve패턴
(1) fork
(2) execve
(3) 부모 프로세스의 역할
execve는 현재 실행 중인 프로세스를 덮어쓰는 함수로 자기 자신한테만 호출 가능함.
따라서 전혀 다른 프로세스로 덮어쓰는게 아니기 때문에
수거가 필요함.
장점
시그널
시그널 전송
커널이 목적지 프로세스의 컨텍스트 내 상태를 갱신함으로써 시그널이 전달이 됨
(커널이 프로세스의 시그널 관련 상태를 수정해놓고, 그 프로세스가 다음에 실행될 때 처리하도록 만듦)
시그널 수신
커널이 프로세스에게 시그널을 수신하도록 강제하면 프로세스는 시그널에 반응해야 함.
pending signal(보류 중인 시그널)
pending: 시그널이 전송되었지만 아직 처리되지 않은 상태. 한 종류의 시그널은 한 번에 하나만 pending 상태가 될 수 있음, 같은 종류의 시그널이 여러번 오더라도 큐잉되지 않고, 추가로 온 시그널은 무시됨.
시그널 마스크와 비트 벡터 - 각 프로세스는 커널 내에 두 개의 비트 벡터를 가짐
시그널 마스크는 시그널 번호들의 집합으로 운영체제는 이 집합을 참고하여 시그널을 block함.
pending: 현재 보류 중인 시그널의 종류
blocked: 현재 블록된 시그널의 종류를 나타냄
시그널 핸들러
특정 시그널이 도착했을 때 시스템이 자동으로 호출해주는 함수
시그널 핸들링
시그널을 수신하면 커널은 프로세스의 흐름을 시그널 핸들러로 전환함. 핸들러 실행이 끝나면 원래 중단된 위치로 돌아감.
(시그널이 발생하면 커널은 명령어 흐름을 잠시 멈추고 핸들로 함수 쪽으로 점프시킴)
모든 프로세스는 하나의 프로세스 그룹에 속하며 그룹은 정수형 ID로 구분됨
시그널 전송 메커니즘
키보드 시그널
alarm 함수 시그널
시그널 수신 과정
시그널 기본 동작
시그널 핸들러 설치와 동작
핸들러의 실행 흐름
암시적 블로킹
명시적 블로킹
시그널 핸들러는 프로그래밍에서 까다로운 부분임.
올바르고 안전하게 작성하지 않으면 예측 불가능한 버그를 유발할 수 있음.
핸들러 작성이 위험하고 어려운 이유
동일한 시그널이 여러 번 발생해도 모두 큐잉되어 순서대로 전달이 됨. 같은 시그널이 여러 번 발생하면 각각의 시그널이 모두 보장된 순서대로 처리됨.
특징
장점
전통적인 UNIX 시그널로 동일한 종류의 시그널이 여러 번 발생해도 한 번만 pending 상태로 남으며 큐잉되지 않음. 즉, 여러 번 발생해도 하나만 처리됨.
특징
제한점
동시성 오류
(1) 스케쥴러 (부모)
부모 (fork -> addjob)
(2) 스케쥴러 (부모)
부모 (addjob)-> 자식
(3) 스케쥴러 (자식)
부모 (addjob) - 자식 (종료)
(4) 스케쥴러 (부모)
부모 (addjob) [sigchld 수신] - 자식(종료)
(5) 스케쥴러 (시그널 핸들러)
시그널 핸들러 (deletejob)
부모 (addjob) - 자식(종료)
(6) 스케쥴러 (부모)
부모 (addjob)
(7) addjob 오류
약간 이런 느낌이라고 생각했다.

명시적으로 시그널을 기다려야 하는 상황
방법
???
C 언어가 제공하는 사용자 수준의 예외적 제어 흐름
일반적인 함수 호출/반환 순서를 따르지 않고 한 함수에서 현재 실행 중인 다른 함수로 직접 제어를 이동시킴
주로 setjmp, longjmp 함수로 구현된다.
쉽게 말해 우리가 흔히 코드에서 사용하는 try-catch 구문이랑 관련있다.
int setjmp
void longjmp
C 언어는 try-catch가 없으므로
setjmp로 위치를 기억해뒀다가 longjmp로 이동한다.
비지역성 점프의 주의점
시그널 핸들러와 비지역성 점프
가상 메모리는 각 프로세스에 하나의 크고 통합된, 사적 주소공간을 제공한다.
가상메모리가 제공하는 기능
물리 주소
가상 주소
주소 변환
주소 공간
가상 주소 공간
물리 주소 공간
주소 공간의 구분이 중요한 이유
주소 공간
| 속성(주소) | 데이터 객체(바이트) |
|---|---|
| 0xFF | 0100 (4) |
인가 해서 물어봤더니
GPT는 다음과 같이 알려줬다.
참고로 int=4가 저장돼 있다는 걸 나타내고 싶었다.
| 주소 | 바이트 값(리틀 엔디안 기준) |
|---|---|
| 0xFF | 0x00 |
| 0xFE | 0x00 |
| 0xFD | 0x00 |
| 0xFC | 0x04 |
그래서 엔디안이 뭐인지 궁금해서 물어봤다.
엔디안: 멀티바이트 데이터를 메모리에 저장할 때 바이트의 순서를 정하는 방식
리틀 엔디안: 가장 작은 바이트를 가장 낮은 주소에 저장, 대부분의 PC에서 사용하는 방식이라고 한다.
어쩌면 pintos할 때 필요할 지도 모르는 지식이겠다.
가상 메모리는 디스크에 저장된 N개의 연속적인 바이트 배열(가상 주소 공간)을 메인 메모리(DRAM)에 캐시하는 구조이다.
(즉, 가상 주소 공간 전체가 디스크에 저장된 큰 데이터고, 그 중 일부를 메인 메모리에 올려놓는다는 의미다)
각 바이트는 고유한 가상 주소를 가지며, 이 중 일부만이 실제로 메인 메모리에 적재되어 실행됨.
메인 메모리를 디스크의 캐시로 활용하는 것과 유사함.
페이지와 페이지 프레임
페이지의 상태
DRAM 캐시: 가상 메모리 시스템에서 DRAM(메인 메모리)이 디스크를 캐시하는 방식
SRAM 캐시: CPU와 메인 메모리 사이의 SRAM 캐시(L1/L2 캐시)
* 캐시 메모리에서 3가지 기본 연관도
| 방식 | 설명 | 예시 |
|---|---|---|
| 직접 매핑(Direct-mapped) | 하나의 주소는 오직 한 곳에만 저장 가능 | 주소A -> 캐시라인 5 |
| 집합 연관(Set-associative) | 몇 개의 고정된 후보 위치에만 저장 가능 | 주소A -> 캐시라인 5 또는 13 |
| 완전 연관(Fully associative) | 아무 위치에나 저장 가능 | 주소A -> 캐시라인 아무데나 가능 |


* 쓰기 정책
Write-Back: 데이터를 수정할 때, 먼저 캐시에만 반영하고, 메인 메모리(또는 디스크)는 나중에 업데이트
| 쓰기 정책 | 설명 | 장점 | 단점 |
|---|---|---|---|
| Write-Back | 나중에 한 번에 메모리에 반영 | 빠름, 효율적 | 데이터 일관성 관리가 필요함 |
| Write-Through | 매번 메모리도 같이 갱신 | 일관성 유지 쉬움 | 느림, 비효율적 (디스크 접근 많음) |
추가로 가상메모리에서 항상 디스크 얘기를 해서 궁금해졌다.
만약 디스크가 꽉차게 되면 가상메모리가 제대로 동작하지 않을까?

네.
페이지 테이블의 역할
페이지 테이블의 동작
* 페이지 폴트
프로세스가 접근하려는 메모리 주소에 해당하는 페이지가 현재 물리적 메모리에 존재하지 않을 때 발생하는 예외나 인터럽트
페이지 테이블의 구조와 크기
CPU가 접근하려는 가상 주소의 페이지가 이미 물리 메모리에 적재되어 있는 경우
MMU는 페이지 테이블에서 해당 가상 페이지의 유효 비트가 켜져 있음을 확인하고, PTE에 저장된 물리 페이지 번호를 사용해 물리 주소를 생성한다.
CPU가 참조하려는 가상 페이지가 물리 메모리에 없음.
가상 메모리에서 DRAM 캐시 미스는 '페이지 폴트'라고 부름.
'malloc' 등을 호출해 새로운 메모리 공간을 요청할 때 운영체제의 동작 방식
새로운 가상 페이지를 할당한다는 건 페이지 테이블에 해당 가상 페이지가 디스크의 특정 위치에 매핑되어 있음을 기록하는 것이고, 실제 데이터는 접근이 발생할 때까지 물리 메모리에 적재되지 않음.
프로그램이 실행되는 동안 참조하는 전체 페이지 수는 물리 메모리보다 클 수 있지만, 어느 한 시점에 집중적으로 접근하는 활성 페이지 집합(working set, resident set)은 비교적 작음.
지역성의 원리에 따라 working set만 메모리에 올라오면 대부분의 메모리 접근이 DRAM에서 처리됨.
초기에는 working set을 메모리에 올리느라 디스크 접근이 발생하지만, 이후에는 추가적인 디스크 접근 없이 대부분 접근이 메모리에서 해결됨.
지역성의 종류
문제 상황: Thrashing
만약 working set의 크기가 물리 메모리보다 커지면 페이지들이 계속해서 디스크와 메모리 사이를 오가게 됨. 이를 쓰레싱이라고 하며 이 경우 프로그램 성능이 급격히 저하됨. 따라서 효율적인 가상 메모리 운영을 위해서는 working set이 물리 메모리에 들어올 수 있을 만큼 충분히 작아야 함.
VM은 메모리 관리의 복잡성을 크게 줄이고, 운영체제가 각 프로세스에 독립적인 주소 공간을 제공할 수 있도록 해줌.
프로세스별 독립적인 주소 공간 제공
메모리 관리의 단순화
메모리 할당의 유연성
메모리 보호의 필요성
VM을 통한 메모리 접근 제어
하드웨어와 소프트웨어의 협력
메모리 보호의 효과
주소 변환의 목적과 개념
가상 주소 공간과 물리 주소 공간은 서로 독립적이며, 각 프로세스는 자신만의 가상 주소 공간을 가짐. 이로 인해 메모리 보호, 효율적 관리, 프로세스 간 독립성이 보장됨.
주소 변환의 구성 요소
주소 변환 공식화
주소 변환의 동작 과정
CPU가 페이지를 가져오는 단계
| 단계 | Page Management |
|---|---|
| 1 | CPU가 VA 생성 |
| 2 | MMU가 PTE주소 요청 |
| 3 | PTE 읽기 |
| 단계 | Page Hit | page Fault |
|---|---|---|
| 4 | valid = 1 물리주소 생성 | valid = 0 페이지 폴트 예외 발생 |
| 5 | 데이터 변환 | 커널이 피해자 페이지 선정 및 교체 디스크에서 페이지 적재, PTE 갱신 |
| 6 | 원래 명령 재실행 (Page Hit로 처리) |
가상 메모리와 캐시 메모리가 동시에 존재하는 시스템에서는, 캐시가 "가상 주소"로 접근해야 하는지, "물리 주소"로 접근해야 하는지 결정해야 한다. 대부분의 시스템은 물리 주소 방식을 채택한다.
물리 주소 캐시를 사용하는 이유
* 접근 권한 체크
물리 주소 캐시의 동작 흐름
가상 주소를 물리 주소로 변환할 때, MMU는 매번 페이지 엔트리(PTE)를 참조해야 함.
PTE를 메모리에서 읽어오는 데 수십~수백 사이클이 소요될 수 있음
PTE가 L1 캐시에 있다면 비용이 줄지만 캐시 미스가 발생할 수 있음.
이런 오버헤드를 줄이려면 TLB라는 소규모 PTE캐시를 MMU 내부에 둠.
TLB란
TLB 접근 과정
주소 변환을 단일 페이지 테이블로 처리한다고 가정할 때 크기가 매우 크고, 메모리 낭비 또한 매우 큼.
ex)
32비트 주소 = 개의 주소를 표현 가능
| 32비트 | 주소 |
|---|---|
| 00000000 00000000 00000000 00000000 | 0번 주소 |
| 00000000 00000000 00000000 00000001 | 1번 주소 |
| ... | |
| 11111111 11111111 11111111 11111111 | 번 주소 |
각 주소는 1바이트 단위로 메모리를 가리킴.
개의 주소가 있다는 것은 바이트 메모리 접근이 가능하다는 것을 의미함.
페이지 크기가 4KB일 경우()
전체 가상 공간 크기/페이지 크기 = 페이지 개수
즉 개의 페이지 생성
각 페이지마다 PTE 하나가 필요함
PTE는 4byte
모든 가상 페이지에 대한 PTE =
계층적 페이지 테이블
메모리 절약 효과
주소 변환 과정
PTE8의 정체//스택?//스택의 방향//왜 하필 PTE8인지
현대 운영체제와 하드웨어는 2, 3, 4단계 등 다양한 계층 구조를 사용함.
TLB는 여러 단계의 PTE를 캐싱하여 주소 변환 속도를 보완함.
AI가 정리한 걸 그대로 가져왔다.
읽고 싶으면 읽자.
가상 메모리 영역의 초기 내용을 디스크 상의 객체와 연결하여 관리함. 크게 두 가지 타입의 객체와 매핑할 수 있음.
읽으면서도 잘 읽히지 않는 부분이 있다.
(1) '가상 메모리 영역을 리눅스 파일 시스템의 정규 파일(실행 파일, 데이터 파일)의 연속된 일부와 연결할 수 있음.'
이 부분을 잘 모르겠다.

라고 하는데 더 자세하게 물어보겠다.

(2) '매핑된 영역이 파일보다 크다면 남는 부분은 0으로 패딩됨.'
쉽게 말해서 메모리에 매핑된(가상 주소 공간이 할당된) 부분 중 일부 사용하는 부분은 파일 데이터로 채워지고, 남는 부분은 0으로 채워진다는 의미이다.
| 가상메모리 주소 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| 데이터 | h | e | l | l | o | \0 | \0 | \0 |
이런 느낌이다.
'매핑된 영역이 너무 많다면 메모리 낭비가 심해지는거 아니냐'고 물어봤다.
어떻게 생각하는가?
...
맞다. 접근하기 전까지는 할당되지 않는다고 했다.
PTE Table을 두 단계로 나눈 것도 이와 관련이 있다.
다만 가상 주소 공간은 여전히 쓰기 때문에
참고로 항상 AI를 두 개를 쓰면서 교차검증?하고 있다.
틀렸다고 하면 보통 그대로 쓰는 편이고
맞다고 할 때는 교차검증 하는 편이다.
익명에 관해서 예전에 한 번 언급한 적이 있다.
익명은 한 번 쓰고 버리는 용도라고 했다.
아마 접근됐을 때 메모리를 0으로 초기화하고 버리는 용도라고 생각한다.
* 스왑 공간: RAM이 부족할 때, 일부 메모리 내용을 디스크에 잠시 저장해두는 공간
메모리 매핑은 객체를 공유 객체 또는 사적 객체로 매핑할 수 있음.
나는 공유 객체를 공유 라이브러리 정도로 생각했는데, 그보다 좀 더 넓은 개념이라고 한다. 여러 프로세스가 동시에 접근하는 파일이라고 한다.
공유 객체 매핑의 동작방식
사적 객체와 Copy-on-Write
사적 객체의 복사본을 저장하려고 하면 어떻게 되는 지에 대해서 물어봤다.

위와 같은 결론이 난다고 한다.
fork 함수는 현재 프로세스의 복제본을 만들어 새로운 자식 프로세스를 생성한다. 자식 프로세스는 부모 프로세스와 완전히 독립적인 가상 주소 공간을 사용하는데, 실제 메모리 복사를 모두 수행하면 비효율적이기 때문에 COW 기법을 사용한다.
fork 동작 과정
효율성 및 장점
execve의 동작 과정
순간 동적 링킹과 동적 매핑이 헷갈려서 질문을 했다.
다시 정리를 하겠다.
mmap 함수
새로운 가상 메모리 영역을 생성하거나 파일 또는 익명 객체를 해당 영역에 매핑할 수 있게 만드는 함수
커널에 직접 요청을 보내어 원하는 형태의 메모리 매핑을 동적을 생성할 수 있게 해줍니다.
살짝 malloc의 냄새가 난다.

mmap 동작 예시
munmap 함수
지정한 가상 메모리 영역을 해제(매핑 해제), 해제된 영역에 접근 시 세그멘테이션 폴트가 발생
mmap 함수의 특징 및 장점