Segmentation - 일반화된 Base/Bound 주소 변환

프로세스가 사용하는 가상화된 메모리는 꾹꾹 눌러담아 사용하는게 아니라 스택, 힙, 코드 영역 등이 띄엄 띄엄 있기 때문에 sparse한데, 이런 경우 세그맨테이션 기법을 통해 메모리를 좀 더 효율적으로 사용할 수 있다.

Segmentation은 가상 주소 공간을 특정 세그먼트 크기로 나눈 뒤, 해당 세그먼트들을 각각 물리 메모리에 매핑하는 방식이다.

세그먼트란 특정한 길이를 가지는 연속적인 주소 공간으로, OS가 이러한 세그먼트들을 나눠서 배치함으로써 가상 주소 공간에서도 사용하지 않는 메모리들을 실제 물리메모리에서도 사용하지 않게끔 한다.

즉, 기존에는 전체 가상 주소 공간을 베이스/바운드 값을 활용해 한 덩어리로 물리 주소 공간으로 변환했다면, 세그맨테이션은 세그먼트마다 베이스/바운드 값을 통해 물리메모리에도 따로따로 올려버리는 것이다.

기존에는 운영체제가 컨텍스트 스위칭 과정에서 프로세스 전체의 베이스/바운드 값만을 보존하고 복원하였다면, 세그맨테이션 기법을 활용할 경우에는 각 세그먼트별 베이스/바운드 값들을 모두 스위칭 해줘야 한다.

fine-grained 방식의 세그맨테이션은 각 세그먼트들을 작은 크기의 공간으로 잘게 쪼개어, 세그맨트 테이블 같은 하드웨어를 둠으로써 수많은 세그먼트들에 대한 정보를 관리할 수 있다.

그러나 일단은 코드, 스택, 힙을 기준으로 세그맨트를 나누는 coarse-grained 방식으로 생각해볼 경우, 사실 세그먼트가 그렇게 많아지지 않기 때문에 추가적인 레지스터들을 둠으로써 각각의 베이스/바운드 값들을 복원할 수 있다.


세그맨테이션 기법을 사용하는 경우, 가상 주소를 물리 주소로 변환할 때 해당 주소가 어떤 세그먼트에서 어느 위치에 있는지 알아내야 한다.

만약 가상 주소 공간이 총 14비트를 사용하여 어드레싱 된다고 하는 경우라고 가정해보자.

14비트를 사용하는 총 16KB짜리 가상 주소 공간에서, 상위 2개 비트를 써서 4종류로 세그먼트를 구별할 수 있고, 나머지 12개 비트로는 각 4개의 세그먼트마다 4KB 만큼 사용할 수 있게 된다.

마치 32비트 값에서 넷마스크를 통해 네트워크 부분과 호스트 부분을 나누는 것과 비슷하다.

세그맨테이션은 결국 베이스/바운드를 일반화시킨 것이기 때문에, 세그먼트별로 각각 베이스/바운드 값들을 가지며 이를

Segment = (VirtualAddress & SEGMENT_MASK) >> SEGMENT_SHIFT
Offset = VirtualAddress & OFFSET_MASK
if (Offset >= Bounds[Segment]) {
    RaiseException(SEGMENT_FAULT)
}
else {
    PhysicalAddress = Base[Segment] + Offset
    Register = AccessMemory(PhysicalAddress)
}

가상주소(프로그램 코드)와, 우리가 사용하는 세그먼트 영역까지를 표현하는 세그먼트 마스크를 AND 연산 때리면(현재는 11000000000000 이 된다) 상위 2개 비트 값만 살아남는다.

이 값을 오른쪽 끝까지 시프트 시키면 최종적으로 해당 가상주소가 속한 세그먼트 값을 구할 수 있다(4가지 종류 - 00 01 10 11).

이를 세그먼트 테이블의 인덱스값처럼 사용함으로써 해당 세그먼트의 베이스/바운드 값을 알아낼 수 있는데, 이런 방식으로 세그먼트 테이블을 유지하기 때문에 어느 세그먼트가 됐든지 동일한 시간 O(1)에 참조가 가능하다.

그러나 세그먼트 값을 가지고 인덱싱을 하기 때문에, 실제로는 10 세그먼트는 사용하고 있지 않더라도, 이 세그먼트에 대한 값 역시 테이블에 유지해야 하는 추가 비용이 발생하긴 한다.

가상주소를 이번엔 오프셋 마스크를 가지고 연산할 경우, 세그먼트 값이 아닌 해당 세그먼트 내에서의 오프셋 값을 확인 가능하다.

만약 이 오프셋 값이, 세그먼트 테이블에 지정된 해당 세그먼트의 바운드 값을 벗어날 경우 Exception을 발생시켜 운영체제가 적절히 처리할 수 있도록 한다.

범위 내에들어올 경우에는, 세그먼트의 베이스값 + 오프셋 값(실제 물리 주소)로 변환해서 cpu가 일할 수 있게 레지스터에 세팅해줌으로써 MMU가 내부적으로 가상 주소를 물리 주소로 변환해주는 것이다.

이 때, MMU가 오프셋과 바운드 값을 비교하는 작업이나, 베이스값과 오프셋을 더하는 등의 작업은 하드웨어적으로 수행 가능하게 구현이 되어있다고 전제를 한다.

이렇게 가상주소의 세그먼트를 매번 확인하는 과정을 거치지 않고, 묵시적으로 결정하는 방식도 존재한다.

만약 가상주소가 PC로부터 읽어온 값이라고 할 경우, PC는 항상 실행 코드로부터 가져오기 때문에 이 가상주소는 반드시 코드의 세그먼트에 속한다고 볼 수 있다.

마찬가지로 SP(스택 포인터)에서 읽어오는 경우에는 스택의 세그먼트에 속한다고 결정하는 방식이다.


프로그램 A가 실행되면서, 메모리로 로딩될 때 운영체제의 재배치 과정을 통해 코드가 어디로 들어가고 크기가 얼마인지 기록하며 세그먼트 테이블 만들어나간다.

뿐만 아니라 A를 위한 힙이나 스택 공간도 잡히게 될 텐데, 이 역시 커널이 알아서 배치한 다음 세그먼트 테이블에 이를 기록한다.

이런 상황에서 프로그램 B가 로딩되는 경우에는 커널이 또 알아서 코드, 스택, 힙 영역들을 메모리 어딘가에 배치한 다음, B를 위한 세그먼트 테이블을 또 기록할 것이다.

그리고 두 프로세스가 교체되는 과정에서 커널은 이 세그먼트 테이블도 같이 스위칭 해주면서 각 프로세스만의 가상 주소를 실제 물리 주소로 정상적으로 변환될 수 있게 된다.

그러나 동일한 프로그램 A를 두번 로딩하는 경우에는, 코드가 동일하기 때문에 실제 물리 메모리에 코드는 한번만 로딩된다.

코드는 같은데 두번 실행했기 때문에 프로세스 두개가 형성되고 각자의 가상 주소 공간을 할당받아서 테이블을 관리할텐데, 사실 코드는 동일하기 떄문에 운영체제가 실제로는 한곳에다가만 로딩해놓고 세그먼트 값을 공유시킨다.

실행 코드는 일반적으로 Read-Only라 실행 도중에 바뀌지 않기 때문이고, 물론 스택이나 힙은 당연히 별도로 메모리 공간을 할당해서 따로 관리해줘야 한다.