NT 가상 메모리 매니저와 파일 시스템

Communication between Virtual Memory Manager and File System

 

가상 메모리 매니저를 다루는 이유는 결국 파일 시스템 드라이버 입장에서 가상 메모리 매니저의 동작을 이해하고 이를 적절히 운용하여 시스템의 성능을 올리기 위해서이다. 대부분의 운영체제에서 가상 메모리 매니저와 파일 시스템간의 정보를 정의 하고 있으나 실제 NT에서는 가상 메모리 매니저의 코드를 볼 수 없기 때문에 어떠한 인터페이스를 통해서 기존 운영체제의 컨셉을 이용하고, 시스템을 조율 할 지가 난해 할 수 있다. 파일 시스템 드라이버 개발자는 가상 메모리 매니저에 의해서 운용되는 사상된 메모리 이미지(Mapped Memory Image)나 가상 메모리 매니저만이 오직 조작 할 수 있는 물리 메모리 주소 영역을 이해하고 파일 시스템 드라이버가 동작하는 시스템 가상 주소 공간을 운용하기 위한 동작을 이해 해야 할 뿐만 아니라, 두 모듈간의 의존성을 파악하여 시스템을 설계 해야 한다.

 

정명수 |

필자는 지난 3년간 삼성전자에서 플래시 메모리와 관련된 연구와 임베디드 소프트웨어, 커널 드라이버 등을 개발 했었다. 현재는 조지아 공대(Georgia Institute of Technology) 컴퓨팅 칼리지에 재학 중이다. 글쓰기를 매우 좋아하며 학부시절에는 객체 지향 패러다임을 통하여 해석하는 프로그래밍 언어론에 관심이 있었으나 실무과정을 거치면서 컴퓨터 아키텍처로 관심사가 옮겨졌다. 최근에 관심 있는 분야는 운영체제, 파일시스템, 실시간 스케줄링 등이다.

 

NT의 가상 메모리 매니저의 마지막 칼럼으로서 메모리 매니저가 파일 시스템 드라이버나 커널 드라이버에게 노출 시켜 놓은 함수나 루틴들을 알아보고 그 안의 동작을 이해하도록 한다. 이러한 가상 메모리 매니저의 API들은 대부분 파일 시스템 드라이버 개발자에게 노출 되어있고 매우 중요한 역할을 하고 있으나 실제로 잘 문서화가 되어 있지 않기 때문에 파일 시스템 드라이버 개발자가 간과 하기 쉽다.

 

하지만 현재 파일 시스템 드라이버가 운용되는 시스템 가상 주소 공간과 메모리 할당, 그리고 사용자 모드의 어플리케이션과 통신을 위해서 어디까지가 파일 시스템의 영역이고 어디까지가 가상 메모리 영역까지 인지를 판단하여 시스템의 성능을 향상하도록 설계하기 위해서는 가상 메모리 매니저의 내부와 이를 이해하여 파일 시스템 정책을 적절히 추가 해야 한다. 이번 칼럼에서는 메모리 페이징을 위한 서로 간의 영역과 역할 차이, 그리고 운영체제의 핵심이 되는 페이지 폴트 핸들링, 마지막으로 파일 시스템과의 교류를 위한 여러 가지 가상 메모리 매너저의 API들을 소개한다.

 

MPW, 수정되거나 사상된 페이지 쓰기 스레드

이전에 컬럼에 언급 되었듯이, NT 가상 메모리 매니저 (VMM, Virtual Memory Manager)는 물리적 메모리 제약과 관계 없이 각 프로세스마다 자신의 독립된 가상의 메모리 공간을 제공하기 위한 태스크(Task)를 가지고 있다. 이 태스크를 위해서 NT VMM은 메모리에 존재하는 페이지 데이터와 정보들을 블록 디바이스(Block Device)로 내보 내거나(스왑 아웃, Swap out이라고도 불린다.) 블록 디바이스로 내보냈던 데이터를 다시 메모리로 불러 들어오는 동작(스왑 인, Swap in)을 지원 해야 한다. 페이징(Paging)이라고 부르는 시스템에서 수행 되는 프로세스에 대해서 모두 균등하게 적용 된다. 적은 물리 메모리를 가지고서 상대적으로 큰 메모리를 사용자에게 보여주기 위해서는 가상 메모리에 할당된 물리 메모리의 양에 한계가 발생할 때 앞서 말한 스왑 인,아웃 작업을 통해 새로운 페이지 프레임(Page Frame)을 할당 받아야만 한다. 따라서 이런 작업을 위해 NT VMM은 자동적으로 데이터가 쓰여진 페이지(더티 페이지, Dirty Page)나 수정된 데이터를 블록 디바이스로 플러시(Flush)하게 된다. 이렇게 페이지 프레임 안에 수정된 데이터는 크게 두 가지 방법으로 블록 디바이스로 스왑 아웃 된다. 첫 번째는 하나에서 열 여섯 개의 페이지 파일을 통해서 수정된 데이터를 쓰는 작업을 통해 스왑 아웃 하는 것이다. 만약 페이지 파일이 사상된 섹션 오브젝트(Mapped Section Object)로 할당 되어 있다면 수정된 데이터는 디스크상에 이름이 있는 파일 형태로 저장된다. 만약 페이지 프레임이 디스크로 플러시 되지 않는다면 NT VMM은 페이지 프레임을 재 사용 할 수 없고 이는 데이터 손실로 이어진다.

 

메모리 사용이 요청에 대해서 언제든지 서비스 할 수 있도록 NT VMM은 항상 사용 가능한 충분한 램(Ram)을 가지고 있어야 한다. 이를 위해서 NT VMM은 일정량의 사용 가능한 페이지 프레임을 고정적으로 보유하고 있다. 따라서 이 페이지 프레임은 NT VMM이 페이지 할당 시에 이를 사용 할 수 있도록 어떤 수정된 데이터도 가지고 있지 않아야 한다. 먄약 VMM이 이러한 가용 가능한 페이지 프레임들의 영역을 소유하고 있지 않다면 페이지 프레임을 필요하는 대부분의 프로세스가 블록 되게 된다. 그 프로세스들의 수정된 데이터를 위한 페이지 프레임을 확보 하기 위해 미리 확보된 가용 공간을 페이지 프레임을 사용 하는 것이 아니라 필요 즉시에 스왑 아웃을 하여 이를 확보 하기 때문에 스왑 아웃 시에 블록 디바이스로 접근이 발생 함으로서 이에 대한 지연시간이 모두 프로세스에게 보이게 되기 때문이다. 대부분 이해하고 있듯이 현재까지 컴퓨터 아키텍처에서 속도상 가장 병목을 보이는 것이 I/O이기 때문에 프로세스의 스왑 아웃 작업이 그대로 사용자에게 보여지게 된다면 큰 문제가 될 수 있다.

 

따라서 NT VMM은 수정되거나 사상된 페이지 쓰기(Modified and Mapped Thread, 이후 MPW로 호칭)에 대한 스레드를 적어도 두 개 이상 소유하고 있다. 이 중에 하나는 비동기적(Asynchronous)으로 사상된 페이지를 페이지 파일에 쓰기 위해서 생성된다. 수정된 페이지 프레임을 쓰든지 아니면 사상된 페이지를 쓰든지 그 기본적인 기능은 동일 하기 때문에 보통은 이 둘을 하나의 통일된 용어로 쓰는 것이 일반적이다. 이 스레드는 앞서 언급 된 병목 현상과 디스크에 대한 지연시간을 감추기 위한 것이기 때문에 항상 NT VMM이 요청에 따라 즉각적으로 할당할 수 있는 페이지 프레임을 보유하도록 해주며 이와 중첩적으로(Overlapped) 블록 디바이스로 데이터를 쓴다. MPW 스레드 각각은 이러한 방법을 통하여 높은 시스템 포퍼먼스를 확보하기 위해 실시간 스레드로 정의 되는데 NT에서는 이를 LOW_REAL_TIME_PRIORITY 보다 한 단계를 더 높은 우선순위를 가진다.

 

NT VMM은 데이터를 블록 디바이스로 플러시 하기 위해서 앞서 언급된 비동기 쓰기를 사용하는데 이때 I/O 매니저의 IoAsynchronousPageWrite()를 참조한다. 이API 호출은 I/O 매니저에 의해서 해당 사상된 페이지 파일이나 이름이 붙여진 형태의 파일을 가진 디스크 볼륨을 관리하는 파일 시스템에게로 바로 전달 된다. 파일 시스템 개발자는 이런 면 때문에 개발 당시 I/O를 좀 더 세분화 하여 관리 할 필요가 있다. NT VMM에 의해서 수정되거나 사상된 페이지를 플러시할 때 생성된 입출력은 다른 일반적인 입출력과 구분 할 수 있도록 IRP_PAGING_I/O의 파라미터를 가는 요청으로 수행된다. 이 입출력 요청 패킷(IRP, I/O Request Packets)에는 페이징에 대한 정보 말고도 IRP_NOCACHE 플래그도 설정되어 있다. 파일 시스템 개발자가 관심을 가져야 하는 것은 페이징 I/O 쓰기 요청을 처리하면서 절대 다른 페이지 폴트를 발생시켜서는 안된 다는 것이다. I/O 매니저는 다른 케이스의 입출력과 페이징에 대해서 다른 형태로 페이지 쓰기 형태를 관리 하기 때문이다. I/O 매니저는 페이징 I/O의 비동기 쓰기의 커널 APC를 정상적으로 마무리하기 위하여 MiWriteComplete() 루틴을 사용한다.

 

페이지 폴트 핸들링(Page Fault Handling)

NT VMM 물리 메모리 주소공간에 표현 되지 않는 가상 메모리의 컨텐츠를 참조하는 모든 케이스를 처리 해야 하는 책임이 있다. MMU (Memory Mapping Unit)와 같은 하드웨어가 일반적으로 가상 주소와 물리 주소를 번역하긴 하지만 NT 커널에서는 MMU가 이전 컬럼에 언급된 PTE가 메모리에 없는 경우 이 문제를 해결 하기 위에 직접적으로 매핑 정보에 접근 하는 것이 아니라 VMM에게 해당 문제를 전달 하는 것으로 종료 된다. VMM은 페이지 폴트가 발생 했을 때 커널 모드든 유저 모드든 MmAccessFault()라는 함수로 이 제어권을 넘겨 받는다. 다른 NT함수들도 마찬가지 이겠지만 MmAccessFault()는 VMM이 하는 가장 기본이 되는 기능 중에 하나임에도 불구 하고 그 내부 정보를 알기가 매우 힘들 정도로 기술 문서가 부족하다. 따라서 MmAccessFault()의 함수에 대해서 좀 더 이해하는 시간을 가져 보도록 하자.

MmAccessFault()함수는 크게 3가지 종류의 정보를 받아 들일 수 있도록 파리미터를 정의하고 있다. 하나는 페이지 폴트가 일어난 가상 주소 공간에 대한 정보 이고 다른 하나는 현재 페이지 폴트가 일어난 동작(Operation)에 대한 속성을 나타내는 플래그이다. 다시 말해 폴트가 발생한 메모리 페이지가 쓰기를 하다가 일어난 것인지 아니면 읽기를 하다가 일어 난 것인지를 나타내며 일반적으로 FALSE형태가 읽기상태에서 발생 한 것으로 알려져 있다. 마지막으로는 해당 MmAccessFault() 함수가 사용자, 커널 모드 둘 다를 관리 하므로 이를 구분하기 위한 추가 정보가 하나 더 존재 한다.

 

VMM이 페이지 폴트를 핸들링하기 위해 제어권을 받으면 우선적으로 하는 것이 현재IRQL이문제가 없는 것인지를 확인 하는 것이다. MInAccessFault()함수는 현재 IRQL레벨을 체크하는데 만약 APC_LEVEL보다 높고 페이지 테이블 디렉토리(Directory)와 페이지 테이블 엔트리(Entry)가 유효하지 않은 것이라면 VMM에게 현재 IRQL 레벨을 디버거 정보로 출력하게 한다. 그 다음이 페이지 폴트 문제를 해결 하기 위해 호출 되는 함수는 MiDispatchFault()함수이다. 이 함수는 처음 이야기 하였듯이 VMM이 페이지 폴트의 제어권을 할당 받은 MmAccessFault() 함수가 페이지 프레임을 유효하게 하기 위해 실행 되는 함수이다. 이 루틴은 가상 주소 공간의 상위 2GB인 시스템 주소 공간가 사용자 주소 공간을 접근 하기 위한 전처리 작업들을 수행한다. 페이지 폴트에 관련된 문제들을 해결 하기 위해 좀 더 세부적인 루틴들이 연이어서 호출 되는데 이런 서브루틴들은 폴트가 발생한 주소에 따라 다르다.

 

만약 폴트가 일어난 주소가 페이지 파일을 기반으로 하고 있다면 MiResolvePageFileFault()함수가 호출 되는데 이 함수는 우선적으로 MiEnsureAvailablePageOrWait() 함수를 이용하여 해당 페이지 파일로부터 데이터를 읽기 위해서 충분한 페이지 프레임을 메모리로 할당한다. 메모리 할당을 마치고 나면 이 MiResolvePageFileFault() 함수는 PTE로부터 읽기 동작이 지정된 해당 페이지 파일을 파악하고 가용 가능한 물리 페이지의 리스트를 가지고 있는 메모리 디스크립터 리스트(MDL, Memory Descriptor List, 메모리 기술자 리스트라고도 불림) 생성한다. MDL이 생성되고 나면 해당 패이지에 대한 메모리 처리가 진행중인 것을 PTE에 기록 하고서 이를 호출한 MiDispatchFault()에게 0xC0033333으로 사전 예약된 상태를 리턴한다. 0xC0033333로 정의 된 상태 값을 리턴 받은 MiDispatchFault()는 I/O 매니저의 API인 IoPageRead()를 사용하여 페이징에 대한 읽기 동작을 수행한다. 앞선 섹션 MPW 스레드에서 언급 했듯이, 파일 시스템 드라이버는 IRP_PAGING_IO와 IRP_NOCACHE 플래그로 먼저 요청사항을 수행 했기 때문에 이번 IoPageRead를 페이징에 관한 읽기 동적으로 수행 할 수 있다. VMM은 페이지 폴트가 일어난 읽기 요청이 모두 끝날 때 까지 기다리고서 요청이 성공적으로 끝나면 해당 프로세스에 대한 워킹 셋(Working Set Model)에 페이지를 추가 한다. 워킹 셋은 NT 커널에서만 적용 되는 것이 아니라 여러가지 운영체제에서 빈번하게 일어나는 메모리 폴트에 의해 운영체제 자체가 제 기능을 못하게 되는 것을 막기 위해서 사용된다.

 

워킹 셋에 대한 문제는 NT 가상 메모리 매니저가 언급된 2회전 칼럼에서부터 계속 언급 되어 왔다. 독자의 이해를 돕기 위 워킹 셋에 대한 학술적 정의와 함께 이를 사용하는 모델이 어떤 식으로 설계 되는 지를 첨부 하였다.

워킹 셋(Working Set)

프로세스의 워킹 셋은 메모리 사용의 다이나믹(Dynamic) 로컬리티를 가진 모델에 쓰이곤 하는데 Peter Denning 에 의해 1980년에 프로세스가 필요로 하는 페이지의 집합(Set)으로 정의 되었다. 좀더 정확한 정의를 살펴 보자면 워킹셋 집합은 WS(t,w) = { (t,t-w) 의 시간 사이에 참조된 페이지들}으로 정의 될 수 있다. 다시 말하면 인터벌 안에 정해진 페이지 참조 수를 채우는 동안의 참조된 페이지의 집합이며 여기에 사용된 t는 일반적인 시간, w는 워킹셋 윈도우 사이즈(working set window size)를 의미한다. 즉 워킹 셋 윈도우(working-set window) 사이즈, w라는 것은 고정된 페이지 참조 수를 이야기 하는 것을 판단 할 수 있다.

 

워킹 셋을 어떻게 구성하는가에 따라 시스템의 성능이 민감하게 반응하는데 만약에 윈도우 사이즈(참조 수)를 너무 작게 잡으면 로컬리티(Locality)를 모두 내포(Encompass) 할 수 없고 윈도우 사이즈를 크게 잡으면 몇몇 로컬리티(Locality)들을 커버 할 수 있고 만약 무한대로 잡는다면 프로그램 전체를 커버 할 수 있다. 이렇게 워킹 셋은 참조된 페이지들을 의미하는 반면 워킹 셋 사이즈(Working Set Size, WSS)는 워킹 셋 안에 페이지 수를 의미한다. 다시 말해 interval(t, t-w)안에 참조된 된 페이지의 수에 의미를 두는 것으로 로컬리티가 떨어지는 경우에는 더 많은 페이지들이 프로그램의 로컬리티에 따라 WSS는 변화 한다. 직관적으로 봤을 때, 워킹 셋은 반드시 스레싱(Threshing)이라고 불리는 메모리의 자주 일어나는 메모리 폴트 (heavy fault)를 보호 할 수 있어야 한다.

   

워킹 셋 모델(Working Set Model)

워킹 셋 모델은 워킹셋을 어떻게 사용 할 것인지를 다루는 것으로 특정 페이지가 WS(t, w) 안에 속하였으면 메모리에 남겨두고, 아니면 스왑 아웃 시킨다. 워킹 셋 모델은 이 원칙에 따라 페이지 프레임의 교체 할당, 플러시등의 동작들을 결정한다. 워킹 셋 페이지의 교체를 위해 마지막 k 번 참조된 페이지들의 집합을 유지하는 것은 매 참조 마다 각 페이지들의 최근 참조 수를 워킹 셋 윈도우 사이즈와 비교 해야 하기 때문에 매우 비용이 싸다. 따라서 과거 인터벌 내에 사용된 페이지의 집합으로서 대략적인 워킹 셋을 사용하고 이를 위해서는 아래 도표와 같이 현재 가상 시간(virtual time)을 사용한다.

 

   

현재 가상시간(current virtual time)이라는 것은 프로세스가 실제로 사용한 CPU 시간의 총량으로 나타내어지기 때문에 페이지 테이블로부터 워킹 셋 안에 없는 페이지를 찾아서 그것을 교체 대상으로 선정한다. 좀더 상세하게 보자면 각 이벤트 사이의 인터벌(Interval) 시간은 각 PTE의 필드 중 Tlast라고 불리는 마지막 사용 시간(time of last use)값을 이용하고 주기적 클럭 인터럽트(Clock interrupt)에 의해 R 비트는 제거한다. 모든 페이지 폴트 때 교체하기 에 알맞은 페이지를 찾기 위해서 테이블을 스캔 하는데 R비트가 1일때 현재 가상 시간을 타임스탬프 해둔다(Tlast := Tcurrent). 만약 R비트가 0이고 Tcurrent - Tlast가 정해진 시간 동안 레퍼런스가 없어서 특정 수치보다 커질 때 페이지를 교체 한다. 교체로 선정된 대상이 아닐 때는 페이지의 큰 나이값(Age)를 기록 해두었다가 추후 교체처리 한다.

워킹 셋과 페이지 테이블을 이용한 워킹 셋 모델 운용의 예

 

만약 폴트가 발생한 주소의 PTE가 처리중임을 나타낸다면 MiResolveTransitionFault()를 호출 한다. 처리 중에 있는 페이지는 앞서 언급된 PTE에 기록된 정보를 통해서 알아내는데 이것이 처리중인 경우를 좀 더 상세히 살펴 보면 아래와 같은 세가지 이유가 주를 이룬다.

 

  1. 페이지 프레임이 유효한 정보를 가지고 있음에도 불구하고 해당 페이지가 프리(Free) 페이지 영역에 존재 할 경우.
  2. 마찬가지 이유로 페이지 프레임이 유효한 정보를 가지고 있음에도 불구하고 프로세스의 워킹 셋에 의해 수정된 페이지 리스트에 존재 하는 경우.
  3. 페이지가 블록 디바이스로부터 읽어오는 중인 경우

 

MiResolveTransitionFault()가 처리 중에 있는 페이지 정보를 PTE로부터 인출 하고 나면 이 함수는 블록 되고 나서 입출력이 모두 끝나기를 기다리게 된다. 만약 에러가 일어난다면 PTE에 무효(Invalid) 상태로 바꾸고 나서 함수를 정상 리턴 시킨다. 이것은 강제적으로 다른 페이지 폴트에 의해서 해당 PTE가 더 이상 진행 중이라는 상태를 번복해서 저장 하지 않기 위함이다. 이러한 경우가 아니라면 PTE를 유효 상태로 두고 현재 프로세스에 워킹 셋에 이를 추가한다. MInAccessFault() 함수는 메모리 사상 파일이나 공유 메모리 범위에 존재하는 가상 주소 공간이 폴트를 냈을 경우 PPTE(Prototype PTE)와 함께 MiDispatchFault()를 호출 하게 된다. 이 경우 MiDispatchFault()는 MiResolveProtoPteFault() 함수를 호출하여 문제를 처리한다. 만약 사상 파일에 소유된 PPTE메모리로 폴트가 발생한 페이지의 집합을 결정 해야 하는 경우, VMM은 여퍼 페이질을 한번에 처리 하여 시스템 성능을 올리기 위해 MiResolveProtoPteFault() 루틴을 이용하여 MDL을 할당하고 0xC00333333을 리턴 한다. 만약 PPTE가 페이지 파일내에 공유된 메모리를 백업하기 위해 생성 된 것이라면 MiResolvePageFileFault() 루틴이 호출 되는데 이 루틴은 페이징에 대한 읽기 동작을 수행하기 위한 페이지 파일 넘버(PFN)을 결정하고 이를 통하여 읽기에 사용될 MDL 구조체를 생성한 뒤 앞선 상황과 마찬가지로 0xC00333333을 리턴 한다. 만약 PPTE가 사용 중이라면 이 루틴은 MiResolveTransitionFault() 서브 루틴을 자체적으로 호출하여 이를 처리 할 것이며 만약 '0'으로 채워진 제로페이지를 요구하는 경우라면 MiREsolveDemandZeroFault()를 호출 할 것이다.

 

이제까지 언급된 서브 루틴들이 적절히 호출 되고 성공적으로 완료를 하였다면 MiResolveProtoPteFault() 함수는 PPTE의 데이터들을 반영하는 PTE를 만들게 된다. 한번 프로세스를 위한 PTE를 생성하고 나면 페이징에 대한 읽기가 가능한 해당 PTE는 PEN 데이터 베이스 엔트리에 추가 되게 된다. 간혹 NT VMM은 페이지 폴트에 대한 응답으로 '0'으로 채워진 제로 페이지 프레임이 필요한 경우가 있는데 이런 대부분 디스크 상에 파일을 확장하거나 또는 새롭게 할당된 영역에 특정 스레드가 접근 하는 경우다. 이런 경우는 MiDispatchFault() 루틴이 가용가능한 페이지 프레임의 리스트로부터 제로 페이지 프레임을 할당 해주는 MiResolveDemandZeroFault() 서브 루틴을 호출 하는 것으로 해결 될 수 있다. 만약 제로 페이지 프레임을 할당 하기 위한 가용 가능한 페이지가 없다면 MiResolveDemandZeroFault() 함수는 0xC7903004를 리턴하여 가용 가능한 페이지 프레임을 생성 하도록 하는 페이지 폴트를 발생시켜 이를 해결 한다.

 

지금까지 기술 되었던 것처럼 NT VMM은 시스템 메모리에 기술 되지 않는 페이지를 폴트를 발생 시킴으로써 MMU가 가상 주소와 물리 주소를 번역 할 수 있도록 하고 있다. 만약 우리가 파일 시스템을 드라이버에서 페이지 폴트와 관계된 작업을 수행할 때 IRQ 레벨을 DISPATCH_LEVEL이나 그 이상의 우선순위를 가지는 일련의 동작을 만들었다면 각별한 주의를 해야 한다. VMM은 언급된 IRQL 레벨에서 페이지 폴트를 만족 시켜주지 못하기 때문이다. 우리에게 비 페이징 영역과 페이징 영역을 구분하여 할당 해야 한다면 어떤 기준으로 할 것인가? 라는 질문을 누군가 던진다면 단순히 그 페이지가 빈번하게 동작하거나 중요한 메타(meta)데이터를 가지고 있기 때문이라고 답할 수 없다. 이번 컬럼에서 알 수 있듯이 IRQL 레벨이 DISPATCH_LEVEL에서 수행되거나 또는 그 이상에서 수행 되는 경우 반드시 시스템 메모리를 비 페이징 영역으로 고정 시키고 해당 페이지에 접근 해야 한다.

 

 

파일 시스템 드라이버와 상호작용

NT VMM과 파일 시스템 드라이버는 서로에게 여러 가지 기능을 의존 하고 있다. 이러한 의존성은 소프트웨어의 엔트로피(Entropy)를 높이는 역할을 하기 때문에 일반적으로 소프트웨어 공학에서 이를 회피 하려고 하지만 가상머신을 통한 운영체제가 아닌 이상 가상 메모리 관리와 폴트 시 파일 시스템의 서로간의 협업은 반드시 필요하기 때문에 두 모듈간의 의존성은 불가 피하다. 특히 NT 커널상에서는 파일 시스템이 페이지 파일 I/O와 메모리 사상에 의한 오브젝트를 처리 해주기 때문에 VMM이 파일 시스템에 의존 할 수 밖에 없고 파일 시스템 입장에서는 NT VMM이 시스템 드라이버에서 발생한 페이지 폴트를 처리뿐만 아니라 파일 시스템이 사용하는 캐시처리 메모리 할당등의 메모리 처리 문제 때문에 VMM에 의존 적일 수 밖에 없다. 이것은 기능상의 모듈을 위반하는 것이 아니라 오히려 부분적 순서(Partial Order) 상의 레이어드(Layerd) 아키텍처의 최대한 지키면서 각자 해야 할 일을 명확히 정의 한 것으로 볼 수 있다.

 

이런 두 모듈간에 협조 속에서도 서로 침범 하지 않아야 하는 것들이 있는데 파일 시스템 드라이버 입장에서는 일부 데이터에 대하여 메모리를 명시적으로 조절 하는 것이나 모듈 자체가 VMM의 기능에 의해서 더럽혀지는 것을 막아야 하는 부분이다. NT 플랫폼(Platform)에서 파일 시스템은 VMM이 제공하는 메모리 공간의 시스템에 동적으로 올라가는 실행 이미지이기 때문에 기본적으로 파일 시스템과 다른 기타 커널 드라이버들은 페이징 되지 않는다. 다시 말해 이러한 모듈들이 올라가 있는 동안 이 드라이버들은 램상에 상주하게 된다. 물론 커널 드라이버와 관련된 전역 메모리 부분들도 기본적으로 스왑 아웃 되지 않는 것을 기본으로 한다. 만약 우리가 이런 기본 사항을 변경 하기 위해서는 컴파일러에게 시스템 성능 향상을 위해 스왑 아웃을 명시적으로 알려 줄 수 있다. #progma alloc_text() 라는 전처리로 컴파일러에게 전달 할 수 있으며 첫 번째 매크로 인자로서는 페이징 가능한 코드를 알려주는 4자의 유일한 스트링을 정의 해주어야 한다. 두 번 째 인자는 루틴의 개수를 알려 주는 것이다. 이렇게 전처리를 통해 컴파일러에게 명시적으로 자신의 코드의 특정 부분이 페이징이 가능하다고 알려 줄 수도 있지만 mmLockPageableDataSection()이나 LmLockPageableCodeSection() 같은 함수를 사용하여 동적으로 코드나 데이터를 페이징으로 막기 위해서 락(Lock) 시킬 수도 있다.

 

사용자 스레드들과 유사하게 파일 시스템과 커널 드라이버도 VMM의 상호 참조 구조 안에서도 동적 메모리를 할 당 할 수 있다. 전형적으로 개발자가 명시적으로 페이징이 가능한 메모리, 불가능한 메모리 그리고 캐시 메모리에서 데이터 정렬이 되어 있는 메모리를 ExAllocatePoolWithTag()를 사용하여 할당한다. 이러한 조건과 함께 메모리를 할당하는 경우 메모리 할당이 실패할 경우 시스템 전체에 문제가 생길 수 있다는 것을 숙지하고 예외 처리를 잘 해야 한다. 또한 NT의 실행부에서 이러한 메모리 할당을 지원 하긴 하지만, 절대적으로 이 해당 주소의 물리 메모는 VMM만이 조작 가능 하다는 것도 알고 있어야 한다. ExAllocatePool() 관련된 함수들 중 하나를 사용하여 할당하는 가상 주소 공간의 어떤 포인터들도 커널 가상 주소 공간을 벗어 날 수 없다. 엄격히 말해서 사용자 프로세스와 달리 커널 프로세스는 실제로 하나의 가상 주소 공간을 사용하고 있지만, ZwAllocateVirtualMemory()를 사용하여 할당 당시에 컨텍스트에서만 접근이 가능한 메모리를 할당 받을 수 있다. 커널 가상 주소 공간은 현재 실행 되고 있는 커널 모드 드라이버가 어떤 프로세스의 컨텍스트에 동작 하던지 간에 엑세스가 가능 해야 하기 때문에 (NT 커널은 사용자와 커널 드라이버간에 1:1 매핑 구조를 사용 하고 있다. 사용자 모드와 커널 모드간의 프로세스 매핑은 전형적인 운영체제 이슈로 이 부분을 모르는 독자들은 swblog.net의 펀더멘털 노트를 참조하기 바란다) NT VMM은 가상 공간을 하위 2GB로 사용하는 모든 프로세스가 시스템 가상 주소 공간으로 예약된 주소를 사용하며 시스템에서 실행되는 모든 프로세스 컨텍스트 안에서 같은 주소 간간으로 사상된다.

 

파일 시스템 개발자는 사용자 모드의 코드에서 전달된 파라미터안의 사용자 공간의 버퍼를 조작 해야 하는 경우가 있는데 이는 대부분 사용자 모드에서 정의된 주소 공간에 데이터 넣거나 빼는 작업이다. 따라서 메모리 조작에 특히 조심해야 하는 것들이 있다. 이렇게 전달 된 주소 공간은 실제로는 커널 공간의 가상 주소가 아니기 때문에 사용자 모드의 특정 프로세스 컨텍스트 안에서만 유효한 값을 가지게 되는 경우가 존재 한다. 물론 앞서 언급한 VMM간의 특징 때문에 사용자 모드에서 APC_LEVEL보다 높은 우선 순위로 전달된 메모리를 접근 하기 위해서는 반드시 페이지 락을 통해서 이 버퍼를 사용하다가 페이지 폴트가 발생 하지 않도록 하기 위해 물리 페이지를 고정해야 한다. 다시 말해 우연하게 할당된 메모리의 프로세스 컨텍스트와 동일한 환경에서 실행 된다고 하더라도 페이지 아웃이 되어 있다면 완전 쓰레기 값을 참조하거나 기대치 못한 폴트를 발생 시켜 전체 시스템이 멈출 수 있다는 것을 상기 해야 한다. VMM은 언급된 개발자의 고려사항을 모두 지원한다. 우선 VMM의 MmProveAndLockPages() 이나 MmBuildMdl()과 같은 보조 함수들을 이용하여 어떤 버퍼들이든 간에 관련된 물리 메모리를 고정 시킬 수 있다. VMM의 MmProveAndLockPages() 이나 MmBuildMdl()의 함수는 내부적으로 해당 가상 주소 공간 뒤에 존재하는 물리 페이지 프레임들을 기술하여 리스트로 가지고 있는 MDL을 생성하며 부가적으로 기술된 페이지들이 메모리상에서 쫓겨 나가지 않도록 관리하여 버퍼를 편하게 쓸 수 있는 방법을 제시하고 있다. 두 번째로 사용자 모드에서 전달된 메모리 공간을 시스템 가상 주소 공간으로 사상 시켜 해당 주소가 존재하는 컨텍스트와 관계 없이 버퍼를 쉽게 접근하게 하는 것을 툴을 제공하는데 이 것이 MmGetSystemAddressForMdl()이다.

 

이러한 것 이외에도 시스템적으로 메모리를 관리 해야 하는 여러 가지 일련의 작업들을 처리한다. 예를 들어 NT VMM은 시스템에서 사용되는 모든 스레드에게 4KB 페이지 프레임 기준으로 3개의 페이지를 고정적으로 할당 된 스택 프레임들도 관리한다. 앞서 언급 되었듯이 물리 페이지 프레임을 조작 할 수 있는 것은 오직 VMM 만 가능하기 때문에 VMM이 파일 시스템과 NT 캐시 매니저의 캐싱 파일 데이터들의 관리를 보조하고 페이지 폴트가 생겼을 때 시스템 성능을 증가 시키기 위해서 페이지 클러스터(Cluster)를 지원 한다. 여기서 클러스터라는 것은 16 페이지를 하나의 클러스트로 묶어서 64KB 사이즈 또는 128KB 단위로 접근하게 하는 것을 의미한다. 이런 클러스터의 개념은 아주 오래 전에 Marshall K. McKusick에 의해 제시된 "A Fast File System for Unix""The Design and Implementation of a Log-Structured File System" 에 의해서 그 성능이 검증 된 바 있다. 이러한 성능상에 문제 말고도 VMM은 커널 필터 드라이버나 사용자 모드의 어플리케이션간에 커뮤니케이션을 위해서 공유 메모리 오브젝트나 메모리 사상 파일들도 대신 해서 생성 해준다.

 

typedef struct _MDL {

struct _MDL *Next;

CSHORT Size;

CSHORT MdlFlags;

struct _EPROCESS *Process;

PVOID MappedSystemVa;

PVOID StartVa;

ULONG ByteCount;

ULONG ByteOffset;

} MDL, *PMDL;

MDL 자료구조

메모리 디스크립터 리스트(Memory Descriptor List, MDL)은 물리 주소 공간을 사용하여 버퍼로서의 가상 주소 공간일 기술 하는 시스템 정의 자료 구조이다. MDL은 배열을 포함하고 있는데 해당 배열의 각 원소들은 가상 주소 공간에 사상되어 있는 물리 주소 페이지 프레임의 인덱스를 나타낸다. 메모리 레이아웃으로 봤을 때 이 물리 페이지 프레임을 나타내는 주소에 대한 배열은 MDL 바로 뒤에 존재한다. 다시 말해서 시스템에서 할당한 비 페이징 영역의 메모리에 해당 배열과 MDL구조가 연속적으로 할당 된다는 것이다. 계속 언급 되어 왔듯이 파일 시스템 드라이버 개발자들은 사용자 주소 공간에서 할당된 버퍼에 접근 하기 위해서 NT VMM에게 이와 같은 MDL을 계속 할당 받아 사용자 주소 공간을 시스템 가상 주소 공간에 사상시킨다.

 

이러한 작업은 개발자가 해당 버퍼를 건드리는 동안 페이지를 스왑 아웃 시키지 않도록 해주고 MDL을 통해 물리 주소 공간의 사상 내용을 메모리에 가지고 있음으로써 시스템의 가상 주소 공간으로 접근하여 어떤 프로세스 컨텍스트에서도 해당 버퍼에 접근 하는 것을 허락해준다. 대부분의 커널 자료구조가 그렇듯이 MDL을 통하지 않고 물리 페이지 주소가 있는 위치로 바로 접근 하면 안 된다. 이는 MDL과 해당 기술 배열이 연속적인 비 페이징 영역에 존재 하더라도 이 두 개의 메모리 레이아웃이 언제든지 NT 커널 설계에 의해서 변경 될 수 있다는 것을 의미한다.

 

파일 시스템 드라이버에게 유용한 VMM의 지원 함수가 있는데 그것은 MmQuerySystemSize() 이다. NT VMM은 내부적으로 MmSystemSize라는 전역 변수를 초기화하여 시스템의 메모리량을 대략적으로 알려주는데 MmQuerySystemSize()는 이 변수의 정보를 얻는 함수이다. 이 함수에 의해서 반환 되는 시스템 메모리양은 크게 3가지인데 하나는 MmSmallSystem, MmMediumSystem 그리고 MmLargeSystem이다. 이 시스템 정의 값은 이름에서 알 수 있듯이 메모리 양이 적은 시스템, 중간형태, 큰 시스템을 대략적으로 구분 해준다.


NTOSAPI

MM_SYSTEM_SIZE

DDKAPI

MmQuerySystemSize(

VOID);

MmQuerySystemSize의 프로토타입 정의

 

typedef enum _MM_SYSTEM_SIZE {

MmSmallSystem,

MmMediumSystem,

MmLargeSystem

} MM_SYSTEM_SIZE;

MM_SYSTEM_SIZE의 타입정의

 

이 리턴 값을 가지고 정확한 물리 메모리의 양을 파악 할 수는 없지만 파일 시스템 개발자는 현재 시스템에 대해서 동적으로 시스템의 성능을 고려한 정책을 사용 할 수 있다. 예를 들어 MmQuerySystemSize() 함수가 MmSmallSystem을 리턴했다면 파일 시스템은 큰 메모리 시스템이나 중간형태의 메모리 시스템 보다 적은 형태로 메모리를 할당 받거나, 미리 존(Zone)형태로 메모리를 할당 받아 시스템을 운영 할 수 있고 또한 다른 큰 시스템이나 중간 크기 메모리 사이즈를 가진 시스템보다 적은 워커(Worker) 스레드를 할 당 할 수 있다. 파일 시스템 개발자는 AcquireFileForNtCreateSection()과 ReleaseFileForNtCreateSection() 두 함수를 반드시 제공해야 하는데 이유는 공유 메모리와 메모리 사상 파일을 지원 하기 위해 VMM이 하이라키(Hierarchy) 상에서 파일 시스템 아래에 존재 하기 때문에 파일 시스템의 적절한 콜백 시스템을 요구하기 때문이다.

 

 

파일 시스템 구현을 위한 VMM 지원 루틴.

NT VMM은 MmFlushImageSection과 MmCanFileBeTruncated()라는 두 개의 함수를 제공한다. 이 두 함수는 파일 시스템 설계자나 개발자들에게 매우 중요한 역할을 하는 함수 인데 다른 VMM의 지원 루틴이나 API들처럼 잘 문서화 되어 있지 않다. 이번 칼럼에서는 이 두 개의 함수를 좀 더 구체적으로 알아보는 것으로 글을 마감하고자 한다.

 

/* Used in MmFlushImageSection */

typedef enum _MMFLUSH_TYPE

{

MmFlushForDelete,

MmFlushForWrite

} MMFLUSH_TYPE;

MMFLUSH_TYPE 타입 정의

 

NTKERNELAPI

BOOLEAN

NTAPI

MmFlushImageSection (

IN PSECTION_OBJECT_POINTERS SectionObjectPointer,

IN MMFLUSH_TYPE FlushType

);

MmFlushImageSection 함수 프로토타입.

 

MmFlushImageSection()는 특정 이미지 섹션 오브젝트와 관련된 정보를 담고 있는 메모리상의 페이지가 취소 될 수 있는 지를 파일 시스템이 NT VMM에게 질의를 요청 할 수 있도록 해주는 함수이다. 예를 들어서 만약 사용자가 메모리에 사상되어 있는 특정 오피스 실행 파일을 복사하려다가 취소를 하고 이를 지우는 것을 원했다고 하자. 이런 경우 해당 이미지는 마지막 소프트웨어 버전의 사본을 갱신하려고 할 것이다. 따라서 파일 시스템 드라이버 개발자는 파일을 삭제 하기 전에 파일 데이터를 포함하고 있는 모든 페이지가 플러시 되거나 완전히 지워지는 것을 확인 해야 한다. 일반적으로 실행 시간에 이 페이지들은 모든 유저가 파일을 닫았다고 하더라도 파일 스트림 데이터들을 그대로 유지 하고 있을 수 있다. 하지만 만약 유저가 파일 스트림을 삭제 하려 한다면 파일 시스템은 이러한 정보들이 메모리에 머물 수 없도록 해야 한다. 따라서 파일 시스템 드라이버가 파일 스트림을 쓰기 위해 파일을 여는 작업을 원하는 스레드에게 해당 동작을 허가 해주기 전에 MmFlushImageSection()를 호출 해주여야 하며 NT VMM은 다른 스레드가 이전에 해당 파일을 메모리에 실행 이미지로 사상 시켜 놓은 경우 이 파일 스트림을 열려는 해당 스레드를 제한 해야 한다. NT VMM이 한번 이미지 섹션에 대한 플러싱 동작이 안전하다고 결정하면 VMM은 해당 섹션을 보유하고 있는 리스트의 모든 페이지를 더티(Dirty) 상태로 표시하고 모두 블록 디바이스로 비운다. 따라서 파일 스트림에 대한 비 동기적으로 수정된 모든 쓰기 동작은 반드시 플러시 전에 정지 되어야 한다. 파일 시스템 개발자 입장에서는 VMM이 더티 페이지로 리스트의 정보를 바꾸는 것은 순간적이고 VMM이 비동기 쓰기 동작에 대한 확인을 한 후 바로 쓰기를 할 것이기 때문에 파일 시스템 드라이버가 더티에 대한 정보를 백업하고 있다면 해당 페이지가 플러시에 의해서 다시 한번 쓰여 질것이라는 것을 고려 해야 한다.

 

NTKERNELAPI

BOOLEAN

NTAPI

MmCanFileBeTruncated (

IN PSECTION_OBJECT_POINTERS SectionObjectPointer,

IN PLARGE_INTEGER NewFileSize

);

MmCanFileBeTruncated 프로토타입

 

MmCanFileBeTruncated()는 VMM이 파일 시스템 드라이버가 파일 스트림을 잘라내는 것을 반드시 실행 해야 할지 아니면 못할지를 결정하는 것에 대한 실마리를 제공한다. 만약 사용자가 해당 파일 스트림이 이미지로서 메모리에 사상 되어 있는 경우에 이에 대한 조작을 허락하지 않도록 요청 할 수 있고 VMM 이 이미지 섹션 오브젝트를 생성하였기 때문에 이에 대한 스트림을 잘라 내는 요청을 거절 할 수 있다. MmCanFileBeTruncated()는 해당 요청에 대한 결과를 반환 해주는 것으로 파일 시스템은 파일 스트림을 잘라내기 전에 이를 확인하여 불필요한 예외상황을 미연에 방지하는 것이 좋다. 파일 시스템 드라이버 개발자는 이 함수를 호출 하기 전에 파일 스트림을 배타적으로 획득 되었는지 보장 해야 한다. 따라서 MmCanFileBeTruncated()를 호출 하기 전에 MainResource를 배타적으로 획득 하는 작업을 선행 한다.

 

 

다음 칼럼에는

이로서 3회에 걸친 NT 가상 메모리 매니저에 대한 이야기를 마칠까 한다. 파일 시스템 드라이버를 개발 하기 위해서는 자신의 정책과 설계가 가장 중요한 요인이겠지만, NT상에서 이러한 컨셉과 정책을 구현 하려면 실제 파일 시스템과 떼어 낼수 없는 캐시 메니저, 그리고 가상 메모리 매니저를 반드시 이해 해야 한다. 어떻게 보면 좀 구체적인 구현 이슈이긴 하지만 NT상의 파일 시스템의 구조 전체를 변경 할 만큼 중요한 이슈이므로 파일 시스템 구현 자체에 들어가기 전에 각 모듈들을 6회에 걸쳐서 소개 하였다.

 

다음 칼럼에서는 이제까지 소개 되었던 파일 시스템의 자료구조, I/O 매니저, 캐시 매니저, 그리고 가상 메모리 매니저에 대한 지식을 기반으로 하여 파일 시스템 드라이버가 실제 NT 커널에서 어떤 식으로 구현 되는지를 알아 볼 것이다.

 

References

Rejeev Nagar, "Windows NT File System Internals": A Developer Guide, O'Reilly 1998

P. B. Kruchten."The 4+1 View Model of architecture."

David Garlan and Mary Shaw January 1994 "An Introduction to Software Architecture"

Kernel Source http://reactos-mirror.googlecode.com/svn

Kernel Source http://nuwen.net

NT의 가상 주소 변환

The Virtual Address Translation with Considering MMU and TLB

 

가상 주소를 변환 하는 것 자체는 매우 간단한 일이다. 가상 주소와 물리 주소의 매핑(Mapping)정보를 이용하여 단순히 해당 물리 주소의 정보를 가져오는 것으로 해결 할 수 있다. 하지만 여기에는 해당 컴퓨터 아키텍처의 성능을 고려 해야 한다. 이에 따라 이러한 매핑 정보들을 캐시하는 TLB (Translation Lookaside Buffer)가 일반적으로 사용된다. 이러한 TLB은 하드웨어적으로 구성되며 각 시스템의 아키텍쳐에 따라 완전히 다른 형태를 이루게 된다. NT 가상 메모리 매니저(VMM, Virtual Memory Manager)는 이종간의 TLB들을 관리하도록 하며 가상 주소와 물리 주소간의 번역을 하드웨어적으로 처리하는 MMU들의 동작을 범용적으로 추상화 하기 위해서 몇 가지 자료구조와 복잡한 알고리즘을 사용한다.

 

정명수 |

필자는 지난 3년간 삼성전자에서 플래시 메모리와 관련된 연구와 임베디드 소프트웨어, 커널 드라이버 등을 개발 했었다. 현재는 조지아 공대(Georgia Institute of Technology) 컴퓨팅 칼리지에 재학 중이다. 글쓰기를 매우 좋아하며 학부시절에는 객체 지향 패러다임을 통하여 해석하는 프로그래밍 언어론에 관심이 있었으나 실무과정을 거치면서 컴퓨터 아키텍처로 관심사가 옮겨졌다. 최근에 관심 있는 분야는 운영체제, 파일시스템, 실시간 스케줄링 등이다.

 

 

가상 주소와 물리 주소간의 번역은 아키텍쳐에 따라 매우 다를 수 있다. 설사 주소 변환 또는 번역 작업이 쉽게 이루어 질 수 있다고 하더라도 이를 수행하는 여러 가지 MMU와 TLB와 같은 하드웨어들을 도와 호환성과 이식성을 가진 운영체제를 만드는 것은 복잡한 데이터 조작과 하드웨어 관리를 필수적으로 수반한다. 이번 컬럼에서는 윈도우 NT가 VMM을 통해서 이러한 작업을 어떻게 수행 하는 지와 더불어 이를 위한 자료구조, 그리고 그들간의 연결 상태들을 알아보도록 하자.

 

만약 독자가 MMU와 TLB동작을 제대로 이해하지 못하고 있다면 필자의 블로그의 Fundamental Note의 OS 카테고리의 Virtual Address 란을 반드시 읽어보기 바란다. 본 컬럼에서는 가상 주소에 대한 기본을 다루는 것이 아니라 윈도우 NT를 중심으로 가상 주소를 운용하는 기법을 이야기 하는 것으로 가상 주소에 대한 기본 운영체제 지식이 수반 되지 않으면 전반적이 이해에 대하여 어려움을 겪을 수도 있다.

 

가상 주소 번역 (Translation of Virtual Address)

윈도우 NT의 각 가상 주소(Virtual Address)는 32 bit로 구성 된다. 이러한 가상 주소는 반드시 바이트 단위의 특정 주소 공간으로 번역 할 수 있는데, 이를 위해서는 크게 두 가지의 시스템 콤포넌트(Component)에 대한 이해가 필요하다. 첫째는 프로세서에 의해 제공되는 메모리 관리 유닛(MMU, Memory Management Unit, 이후 MMU로 언급)이고 다른 하나는 운영체제에 의해 구현된 가상 메모리 매니저(VMM, Virtual Memory Manager)이다. 디바이스 드라이버 개발자가 일반적으로 이해는 물리, 가상 주소간의 번역은 가상 주소에서 물리 주소로의 번역이 일반적이나, 반대로 물리 주소에서 가상 주소로의 번역 또한 가능하다. 예를 들어서 주소간 번역을 맡고 있는 페이지 매핑 테이블의 사이즈가 부족하여 특정 주소 공간의 컨텐츠를 메모리가 아닌 스토리지로 저장해서 공간을 확보해야 하는 경우, 해당 컨텐츠를 스토리지로 내보내고 나서 이를 무효화 처리를 해주어야 하는데 이때 해당 물리 주소가 어떤 가상 주소에 매핑 되어 있는지를 찾아야 적절한 무효화 처리를 해줄 수 있다.

 

통상적으로 가상 주소 번역은 MMU 하드웨어에 의해 이루어 지는데 이를 위해서는 VMM은 주소 번역에 필요한 적절한 맵(Map)들과 MMU가 실제 주소를 번역 할 때 사용되는 페이지 테이블(Page Table)들을 관리해주어야 한다. 일반적으로 윈도우 NT에서 VMM이 프로세스의 가상 주소 공간에 대한 정보를 구성하는 것은 프로세스가 컨텍스트 스위칭(Context Switching)을 처음 시도 할 때이다. VMM은 컨텍스트 스위칭이 시작 될 때 해당 프로세스의 적절한 물리 주소 번역에 대한 정보를 담아 두기 위해 페이지 테이블을 구성한다.

 

프로세스가 가상 주소 공간을 접근을 시도할 때 MMU는 가상 주소에서 물리 주소공간으로 번역을 시도한다. 이 때 일반적으로 해당 가상 주소 공간에 대한 매핑 정보를 필요로 하는데 해당 매핑 정보 또한 메모리에 테이블 형태로 존재하기 때문에 특정 프로세스가 가상 주소의 컨텐츠에 접근을 시도할 때 상당한 오버헤드가 존재 할 수 있다. 우선적으로 주소 번역을 위한 매핑 테이블을 메모리에서 한번 읽어드려야 하고 이렇게 읽어온 매핑 정보를 통하여 가상 주소를 물리주소로 번역한 뒤, 다시 메모리를 접근하여 원하는 컨텐츠를 확보 해야 한다. 따라서 하나의 가상 주소 접근을 위해 2번의 메모리 접근이 발생하므로 이를 줄이기 위해 TLB라는 매핑 정보에 대한 캐시가 통상적으로 제시 된다. TLB는 가장 최근에 번역 되어진 주소 관간의 정보들을 프로세스 ID(PID)를 통해서 분류하여 저장 해둔다.(PID를 통해서 각 프로세스마다 다른 주소 공간을 분류 하는 경우도 있고 아닌 경우도 있는데 이는 해당 컴퓨터 아키텍쳐(Architecture)에 따라 다르다) 따라서 MMU가 가상 주소를 물리 주소로 번역을 시도 할 때 TLB를 먼저 확인 하여 TLB에 매핑 정보가 있다면 메모리에 접근하여 해당 매핑 정보를 읽어 드릴 필요 없이 바로 처리 할 수 있게 된다. TLB에 대한 자세한 내용은 뒤에서 한번 더 언급 하도록 하겠다. 다시 돌아가서 MMU는 앞서 언급된 번역 속도를 향상 하기 위해 TLB에 먼저 매핑 정보가 있는지 확인한다. 만약 TLB에서 매핑 정보를 찾지 못하는 경우에만 메모리에 접근 하여 매핑 정보를 가져와 가상 주소에서 물리 주소로의 번역을 시도한다. 여기서 각 번역은 페이지 프레임(Page Frame)으로 이루어져 있음을 상기 해야 한다.

가상 주소에서 물리 주소로 변역을 하고 나서 해당 물리 주소가 현재 메모리에 매핑 되어 있는 경우, 프로세스는 바로 해당 물리 주소의 메모리로의 접근을 허락 한다. 그렇지 못한 경우는 우선적으로 예외 (Exception)을 발생 하고 예외 핸들러(Handler)는 페이지 폴트(Fault)를 발생시킨다. 페이지 폴트가 발생하면 이에 대한 핸들러에게 제어권을 보내어 이를 관리 하는데 윈도우 NT의 경우는 VMM 페이지 폴트 핸들러가 이를 담당한다. VMM 페이지 폴트 핸들러는 적절한 데이터를 시스템 메모리로 가져 온 뒤 매핑 정보를 업데이트하고 예외를 발생한 페이지로 제어권을 반환하는 절차를 일반적으로 거친다. 물론 이러한 예외 처리는 페이지 프로텍션(Protection)에 대한 충돌충 있을 때도 하드웨어에 의해 동일하게 일어 날 수 있다.

 

MMU에 대한 설계는 윈도우 NT의 VMM에 상당히 많은 영향을 미친다. MMU 인터페이스 격인 VMM은 하드웨어 의존성이 매우 강하고, 이 때문에 이종간의 플랫폼에서 호환성 및 이식성이 현저히 떨어진다. 저번 컬럼에서 언급 되었듯이 VMM은 시스템의 물리 메모리를 관리 하기 위해서 비페이징 영역(Non-paged pool)에 페이지 프레임에 대한 데이터베이스를 유지 하고 있다. 이 데이터 베이스는 연속적인 물리 주소 공간의 페이지 프레임들의 집합으로 이루어져 있다. 각각의 물리 페이지 프레임이 순차적으로 구성 되기 때문에 (n개의 물리 RAM이 있다면 페이지 프레임 숫자는 페이지 프레임 0번에서 페이지 프레임 n-1으로 구성 된다고 볼 수 있음) 페이지 프레임을 위한 페이지 프레임 숫자(PFN, Page Frame Number) 데이터 베이스 엔트리(Entry) 계산은 매우 간단 하다. 한번 가상 주소가 물리 주소로 번역 되고 나면 PFN은 PFN 데이터 베이스의 사이즈에 의해 곱해지고, 곱한 후에 결과 주소는 물리 베이스 주소 (Base Address)에 더해진 뒤 PFN 데이터 베이스 할당 되게 된다.

 

32 비트 가상 주소를 위한 자료구조

윈도우 NT의 32비트 가상 주소를 고려 하여 주소 번역에 사용되어야 하는 자료 구조, 또는 하드웨어 사이즈들을 고려 해보도록 하자. 이러한 작업은 실제 파일 시스템 드라이버 개발자에게는 빈번한 일로, 주소 공간에 대한 이해와 함께 이러한 주소 공간을 어떤 식으로 관리하고, 자료구조를 구성하는지를 이해하는데 필수적이다. 페이지 테이블 사이즈가 4096 바이트이기 때문에 페이지 오프셋 계산에 12 비트를 필요로 한다. 해당 12 비트는 LSB(Lease Significant Bit)에 저장 된다. 따라서 MMU는 페이지 테이블 내에 PTE의 페이지 프레임들을 구분하는데 20비트를 가지고(32비트 12 비트) 계산 하게 된다. PTE는 20비트로 구성된 (백만 개의) 순차적 배열 정도로 추상화 할 수 있다. 인텔의 x86 아키텍처를 비롯하여 대부분의 아키텍처에서 PTE에 대한 자료구조들을 미리 정의 해두었다. 인텔 플랫폼(Platform)의 경우 각각의 PTE는 반드시 4바이트로 구성된다. 현재까지 고려한 것을 다시 정리하면 가상 주소 공간을 위한 번역 정보에 필요한 사이즈를 유추 할 수 있다. 다시 말해 백만개 엔트리와 각 엔트리(PTE) 4바이트로 구성 되기 때문에 222 (4MB)형태의 사이즈를 필요로 한다. 각각의 페이지 테이블은 하나의 페이지 사이즈에 저장 되어야 하기 때문에 하나의 프로세스에 대한 페이지 테이블은 1024 페이지 프레임으로 모든 PTE들을 구성 될 수 있다.

 

주소 번역에 필요한 정보를 위한 메모리도 매우 큰 사이즈이기 때문에 이에 대한 성능저하를 피하기 위해 페이지 테이블 역시 페이징 된다. 이를 위해서 x86 프로세스는 이중 레벨의 페이지테이블 엔트리를 정의 해두었다. 각각의 프로세스는 페이지 테이블의 PTE들을 포함하는 페이지 디렉토리(Directory)를 가지고 있다. 이 디렉토리는 한 개의 페이지 사이즈와 동일하기 때문에 1024개의 각각 페이지 테이블을 참조하는 PTE들을 가지고 있다. 일반적으로 프로세스를 위한 가상 주소는 10 비트를 예약 해두는데 이 10비트는 페이지 디렉토리로부터 페이지 테이블을 구분하는데 사용 되며 페이지 내에 오프셋으로 12 비트를 사용한다.

 


<그림 1, 가상 에서 물리 주소로의 번역>

 

TLB를 포함하여 가상 주소가 번역 되는 과정을 다시 정리 해보자. MMU는 TLB를 우선적으로 확인하여 해당 가상 주소에서 물리 주소로의 매핑 정보가 있는지를 먼저 확인한다. 만약 TLB에 존재 하는 경우 (이 경우를 보통 TLB hit로 부른다) 단순히 해당 물리 주소를 바로 반환 하여 작업을 종료 할 수 있다. 만약 TLB에 존재 하지 않는 경우 ( 이 경우는 TLB miss로 불린다)는 조금 작업이 복잡하다. 우선 프로세스마다 존재하는 페이지 테이블 중 현재 가상 주소의 번역을 요청한 페이지 테이블을 확인하여 엑세스가 필요한 물리 페이지 프레임의 정보를 가지고 있는 PTE의 위치를 찾는다. PTE가 가리키는 물리 주소가 해당 프로세스의 권한으로 접근이 가능하고(Protection check) 현재 메모리에 상주하고 있다면 MMU는 해당 주소의 접근을 허락한다. 다른 경우라면 앞서 언급 되었던 것처럼 페이지 폴트 또는 프로텍션 위반으로 예외를 발생 시킨다. 이러한 예외는 결국 VMM에 의해서 관리 된다.

 

여기서 우리는 한가지 개념을 더 이해 해야 한다. 그림 1에 나와 있는 프로토타입 페이지 테이블이다. 사실, 하나 이상의 가상 주소가 같은 물리 주소에 매핑 되는 경우가 있기 때문인데, 프로토 타입 페이지 테이블은 이렇게 하나 이상의 프로세스의 가상 주소 공간이 하나의 물리 페이지로 매핑 되는 페이지 프레임들과 페이지 테이블 엔트리들의 정보를 보유하고 있다. 이를 위해서 우리는 공유 메모리와 메모리 맵드(Mapped) 파일의 컨셉을 이해 해야 한다.

 

 

공유 메모리와 메모리 맵드 파일(Memory Mapped File)

우리가 어플리케이션 레벨에서 개발을 할 때에는 메모리 접근에 대해서 그다지 어려움을 느끼지 못한다. 어플리케이션 프로세스는 간단히 malloc 을 호출 하는 것으로 VMM으로부터 가상 주소를 얻을 수 있고 프로세스는 단순히 해당 가상 주소를 통해서 메모리 블록을 접근 할 수 있다. 계속 언급되어 왔듯이 운영 체제는 가상 주소와 물리주소간의 번역과 이때 성능향상을 위해 채용된 하드웨어들을 관리 해야 하는 책임이 있다. 더욱이 운영체제는 시스템에서 동작하는 모든 프로세스 동작 형태를 확인 할 수 있어야 하며 특정 프로세스에 물리 메모리를 할당하는 작업들도 수반 해야 한다. 동시에 대부분의 어플리케이션은 자신에 필요한 만큼의 메모리를 계속 요청 할 수 있고, 특정의 경우는 스토리지와 같은 디바이스로 I/O를 신청 할 수도 있다. 추가적으로 복잡한 어플리케이션의 경우 공유 메모리를 통한 데이터 공유를 시도하기도 한다.

 

일반적으로 I/O는 파일 시스템의 읽기 쓰기의 시스템 콜을 통해서 이루어진다. 따라서 사용자가 I/O를 요청 하는 경우 시스템 트랩에 의해서 사용자 모드에서 커널 모드로 프로세서가 옮겨가고 스토리지로부터 데이터를 인출 하고 나면 다시 모드를 변경 하게 된다. 읽기의 경우, 파일 시스템은 반드시 데이터를 읽어서 시스템 메모리에 적재한뒤 다시 그것을 사용자 어플리케이션의 버퍼 영역으로 복사 해주어야 한다. 쓰기 요청의 경우는 운영체제가 어플리케이션 버퍼로부터 시스템 메모리로 복사를 하는 작업을 먼저 하게 된다. 시스템 버퍼로부터 데이터를 복사하는 행위는 I/O 요청에 대한 시스템 호출에 대한 오버헤드와 함께 어플리케이션의 실행에 큰 부하를 미칠 수 있다. 더욱이 같은 파일에 대해서 다른 두 프로세스가 접근 하는 경우 정말 필요 없는 부하가 가중 될 수 있다. 이러한 경우 두 어플리케이션 프로세스는 같은 바이트 영역을 접근 하지만 앞서 언급된 바대로 자신이 소유한 버퍼를 가지고 하나의 물리적 주소의 데이터를 가져온다. 하나의 데이터에 대해서 다른 두 버퍼를 사용 하는 오버헤드 문제 이외에 데이터의 코히어런스(Coherence)문제도 존재 한다. 두 프로세스의 이름을 프로세스1과 2로 붙여 주고 문제가 되는 부분을 예로 살펴 보자. 프로세스 1이 자신이 소유한 버퍼를 통해서 물리적 주소의 데이터를 가지고 와서 수정하였지만 아직 다시 해당 물리 주소 공간으로 데이터를 업데이트 하지 않았는데 프로세스 2가 같은 물리 주소 공간에 대해서 읽기를 요청하는 경우 프로세스 1에 의해서 수정된 데이터를 보지 못하고 단지 해당 물리 페이지의 데이터를 그대로 가져와서 사용 하게 된다.

 

이와 달리 만약 각 프로세스가 자신이 가지 버퍼를 물리 주소로 매핑 한다면 상황이 달라진다. VMM은 스와핑(Swapping) 데이터를 스토리지로부터 읽어 이를 가상 메모리로 언제든지 제공 할 수 있다. 어플리케이션은 해당 하는 스토리지 파일의 I/O를 신청 하는 대신 특정 메모리를 할당하여 이를 접근 하도록 한다. 그러면 해당 페이지는 페이지 폴트를 일으킬 것이고 이 페이지 폴트는 운영체제에 의해서 데이터가 교환 되고 정상적인 페이지 접근으로 변경 될 것이다. 따라서 어플리케이션은 이러한 방법으로 물리 메모리를 접근 할 수 있다.

 

이러한 파일과 가상 주소 공간의 매핑 방법은 앞서 언급한 문제들을 해결 하는 것 이외에도 다른 장점도 가지고 있다. 같은 파일에 대한 매핑을 신청하는 모든 어플리케이션이 가상 주소 공간에 물리 페이지를 접근 함으로서 어떤 어플리케이션 프로세스가 해당 파일을 수정 하던 간에 일괄적으로 최신의 데이터를 볼 수 있다는 것이다. 따라서 NT VMM은 이러한 방법의 수단으로 파일 매핑을 지원한다. 매핑된 오브젝트는 이러한 스토리지에 존재하는 파일을 대신하는 수단이 된다. 사용자가 파일을 실행 할 때 NT VMM은 이를 신청한 사용자의 프로세스에 가상 공간에 매핑된 오브젝트를 할당시키고 나서 인스트럭션을 실행하게 된다. 만약 같은 머신의 다른 프로세스가 같은 파일을 실행하게 된다면 아까 할당되었던 매핑된 오브젝트를 그 프로세스의 가상 주소 공간에 할당 해준다. 해당 물리 페이지의 VAD(Virtual Address Descriptor)는 이미 메모리에 상주하고 있기 때문에 사용자 프로세스는 상당히 빠른 시간 안에 자신의 요청한 파일을 볼 수 있다.

파일 매핑은 두 프로세스의 물리 메모리를 공유 하는 방법만을 지칭 하지는 않는다. 가상 주소에 해당하는 물리 페이지 프레임으로부터 독립적으로 VAD 조작이 가능 하기 때문에 VMM은 모든 프로세스에 대해서 해당 프로세스의 VAD를 약간 수정 하는 것만 으로 간단히 공유 메모리를 제공 할 수 있다. 이는 사용자 프로세스는 파일을 기반으로 한 공유 메모리를 할당 하는 것 만으로 공유 메모리 오브젝트를 할당 할 수 있다는 것을 의미하기도 한다. 이러한 공유 기능은 VMM의 기본이 되는 작업으로 VMM이 맵핑된 오브젝트 그리고 공유 오브젝트를 제공하기 위해 사용 하는 자료 구조가 바로 앞서 언급되었던 프로토타입 페이지 테이블이다.

 


<그림 2, 같은 페이지를 프로세스마다 다른 가상 주소 공간에 매핑 시킨 예>

 

 

 

프로토타입 페이지 테이블 (Prototype Page Table)

페이지 프레임은 프로토타입 페이지 테이블(PPT)로 기술되는 특별한 구조체에 의해 공유 되도록 설계되어 있다. PPT는 다른 중요 커널 오브젝트나 자료구조와 달리 비페이징 영역 이외에도 페이징 영역 모두에 할당 되어 사용 될 수 있다. VMM이 프로세스를 위해 매핑 정보 또는 공유 오브젝트를 생성할 때는 파일 매핑을 기본으로한 물리 페이지를 기술 하기 위해 프로토타입 페이지 테이블 엔트리(PPTE) 또한 같이 할당한다. 매핑된 오브젝트를 위한 PPT는 같은 오브젝트를 매핑하는 모든 프로세스에 의해 공유 된다. 각각의 PPTE는 실제 메모리를 가리킬 수도 아닐 수도 있다. 다시 말하면 해당 페이지는 페이지 물리 페이지 프레임에 매핑 되어 있거나 아니면 스토리지에 매핑 되어 있을 수 있다는 것이다. 모든 프로세스가 같은 PPT를 사용 하기 때문에 프로세스들은 같은 페이지 프레임과 매핑된 데이터를 볼 수 있다. 페이지 프레임이 PPTE에 할당 되면 처음에는 PPTE는 항상 유효 상태(Valid)로 표시 된다. 인텔 x86 MMU와 MIPS 이종간의 아키텍처라도 MMU는 프로토타입 페이지 테이블을 위해 PPTE와 같은 유사 구조 페이지 테이블을 제공하지만 각각은 동일 하지 않다. NT VMM은 이를 위해서 다음과 같은 형태로 공유 메모리를 이종간 MMU에서 동작 시킬 수 있도록 한다.

 

우선 인텔 x86 의 아키텍처의 MMU는 PTE와 페이지 테이블을 정확히 기술하고 있다. 따라서 VMM은 프로세스가 파일 매핑을 생성할 때는 언제든지 PPT와 PPTE를 상주 메모리에 할당한다. 프로세스가 매핑된 파일 오브젝트의 가상 주소를 접근을 시도할 때 MMU는 해당 가상 주소를 페이지 디렉토리 테이블 오프셋을 가지고 적절한 페이지 테이블로 가상 주소를 번역 한다. 초기 상태에서 해당 PTE에 대한 접근은 페이지 폴트를 일으키게 된다. (초기에 물리 주소의 데이터가 로드 되어 있지 않으므로) 이 페이지 폴트는 VMM 페이지 폴트 핸들러고 뛰게 되고 VMM의 페이지 폴트 핸들러는 해당 가상 주소의 정보를 담고 있는 VAD로 하여금 매핑된 오브젝트를 가리키도록 변경 시키게 한다. 따라서 VMM은 적절한 PPTE를 찾을 수 있게 되는데 이 시점에 PPTE는 유효 상태로 마크 되어 있기 때문에 PFN 데이터 베이스 엔트리와 해당 백포인터를 연결 할 수 있다. 동시에 VMM은 PTE를 유효상태로 변경하고 PTE가 적절한 물리 주소를 가리킬 수 있도록 한다. 이러한 연결 방법은 PPTE와 PTE가 적절한 물리 주소 정보를 담을 수 있도록 하고 PFN 데이터 베이스 엔트리는 이 PPTE의 백포인터를 설정하도록 한다. 이후 메모리 접근이 재시도 되고 MMU은 정상적으로 초기화된 PTE를 찾을 수 있게 되므로 해당 가상 주소를 물리 주소로 번역이 가능해지게 된다.

 

 

페이지 테이블 설계에 있어서의 고려사항

여기서 주의 해야 할 것은 PFN 데이터베이스 엔트리는 PTE를 직접 참조 하기 못한다는 것이다. VMM은 PFN 데이터베이스 엔트리로부터 가상 주소가 공유 메모리 오브젝트로 할당된 정보를 가지는 PTE를 찾을 방법이 없다. VMM이 할 수 있는 최선책은 PFN 데이터베이스 엔트리를 참조하는 PPTE를 찾는 것이다. 이러한 기교는 심각한 결함을 야기 할 수 있다. 예를 들어, 커널 모드 컴포넌트가 VMM이 특정 물리 페이지를 쫓아 내기를 원한다고 하면 일반적으로 VMM에게 해당 PFN 데이터베이스 엔트리를 무효화 시키도록 하여 해결 한다.

 

무효화된 PFN 데이터베이스 엔트리는 나중에 MMU로 하여금 해당 가상 주소를 참조 할 때 페이지 폴트를 유발 시기키기 위해서 PTE를 무효화로 만든다. 문제는 해당 페이지가 매핑된 오브젝트를 가지고 있을 때이다. VMM은 공유 페이지를 보유하고 있는 페이지 프래임을 PTE를 접근할 방법이 없다. 따라서 만약 VMM에게 특정 페이지 프레임을 메모리에서 쫓아내게 하려고 한다면 VMM은 에러를 반환하여 이 작업이 매핑된 오브젝트와 관련되었을 경우 처리 할 수 없음을 표시하도록 한다. 이것은 우리와 같은 시스템 개발자에게 큰 문제가 될 수 있다.

 

섹션과 (Section and View)

윈도우 NT 시스템은 오브젝트를 기반으로 한다. 여기서 오브젝트는 OOP(Object Oriented Paradigm)에서 이야기하는 클래스와는 다른 개념이다. 다시 말해 윈도우 NT는 대부분의 기능 조작들을 오브젝트 현태로 제공한다. 따라서 파일 매핑은 생성되고 접근 될 때 아래와 같은 두 가지를 고려 해주어야 한다.

 

파일 매핑과 공유 메모리 오브젝트를 관리하는 섹션 오브젝트 (Section Object)는 VMM에 의해서 생성된다.

프로세스가 매핑된 파일이나 공유 메모리 오브젝트에 대해서 접근을 원하는 경우, 호출자는 반드시 VMM에게 해당 파일로 뷰(View)를 매핑 시키도록 요구해야 한다. 결과적으로 이 뷰를 통해서 파일을 보고 제한된 범위 내에서 이를 접근 할 수 있도록 한다. 물론 프로세스로 하여금 같은 파일에 대해서 동시에 여러 개의 뷰를 생성하는 것을 허락 하기도 한다. 또 이 반대로 하나의 파일에 대해서 여러 프로세스에 뷰를 다른 뷰를 제공하는 것도 가능하다.

 

섹션 오브젝트는 다른 NT 오브젝트라 가지고 있는 것과 같이 프로텍션에 대한 특성을 가지고 있다. 섹션 오브젝트에 대한 프로텍션 특성을 명세 함으로서 프로세스는 해당 오브젝트와 파일 오브젝에 대한 데이터들을 정의 할 수 있다. 섹션 오브젝트는 크게 아래 두 가지 카테고리로 분리 된다.

 

실행 이미지에 대한 파일 매핑

비 실행 파일에 대한 매핑

 

우리가 VMM이 매핑 파일을 나타내는 섹션 오브젝트를 생성하도록 요구하는 경우, 우리는 어떻게 매핑된 파일을 다룰 것인가를 명세 할 수 있다. 시스템 로더(Loader) 는 파일 매핑을 사용 하여 명세된 파일 매핑이 실행 가능한 이미지를 실행하도록 해준다. 하지만 만약 복사와 같은 작업을 요청한다면 파일 매핑을 비 실행 파일로 매핑 시킨다. VMM은 섹션 오브젝트가 생성 될 때 마다 이것이 실행 가능한 이미지를 다루는지를 항상 정검한다. 만약 우리가 텍스트 파일등을 실행 가능한 이미지로 파일 매핑을 원한다면 VMM은 에러를 반환 하게 될 것이다.

 

실행 이미지 파일 매핑과 비 실행 파일에 대한 매핑 사이의 가장 큰 차이점은 VMM에 의한 매핑 범위가 어떤 식으로 수행 되는 가에 있다. 비 실행 파일 매핑이 프로세스에 수정 될 때 물리 메모리의 컨텐츠가 VMM에 변경 되기 때문에 이 수정에 대한 요청은 같은 파일 매핑을 사용하는 모든 프로세스에 보여 질 수 있다. 이러한 수정 작업은 나중에 스토리지로 데이터가 플러시 될 때 한번에 반영 된다. 하지만 이미지 파일에 대한 매핑이 수정될 때는 이와 다르게 별도의 페이지에 복사된다. 복사된 이후에 별도의 페이지는 페이지 파일에 의해서 가리켜진다. 만약 프로세스가 파일의 매핑을 취소하면 이 수정사항은 실제 스토리지로 반영 되지 않고 사라 질 수 있다.

 

공유 메모리 오브젝트 또는 섹션 오브젝트를 생성하기 위해서는 NT VMM은 NtCreateSection() 을 후출 해야 한다. 이 루틴은 커널 모드 개발자에게 알려져 있지는 않지만 대신에 ZwCreateSection()을 통해서 이를 대신 수행 할 수 있다.

 

ZwCreateSection

NTSTATUS 
  ZwCreateSection(
    OUT PHANDLE  SectionHandle,
    IN ACCESS_MASK  DesiredAccess,
    IN POBJECT_ATTRIBUTES  ObjectAttributes OPTIONAL,
    IN PLARGE_INTEGER  MaximumSize OPTIONAL,
    IN ULONG  SectionPageProtection,
    IN ULONG  AllocationAttributes,
    IN HANDLE  FileHandle OPTIONAL
    ); 

 

이 루틴은 커널 모드에 의해서 공유 메모리 오브젝트나 스토리지를 우한 파일 매핑을 생성하는데 사용 될 수 있다. 파일 시스템 드라이버 개발자가 네트워크 파일 시스템을 개발 하는 경우라 할 지라도 우리는 이를 공유 메모라니 매핑된 파일 오브젝트를 생성하는데 사용 할 수 있다. 때로는 커널 모드 드라이버 개발자가 공유 메모리 데이터를 사용자 공간의 모듈과 공유를 원하거나 커널 모드 드라이버가 네트워크 건너편에 전에 존재하는 데이터를 원하는 경우 우리는 간단한 공유 메모리 오브젝트나 파일 기반의 공유 오브젝트를 얻어 쉽게 이를 조작 할 수 있다. 또한 커널 모드와 사용자 모드의 모듈간에 데이터를 서로 교환 해야 하는 경우에도 이러한 섹션 오브젝트가 응용 될 수 있다.

 

섹션 오브젝트에 대한 다른 Zw 함수들은 DDK 문서를 참조하여 써도 무방하다. 이에는 아래와 같은 함수들이 있다.

 

ZwOpenSection

NTSTATUS 
  ZwOpenSection(
    OUT PHANDLE  SectionHandle,
    IN ACCESS_MASK  DesiredAccess,
    IN POBJECT_ATTRIBUTES  ObjectAttributes
    );

ZwMapViewOfSection

NTSTATUS 
  ZwMapViewOfSection(
    IN HANDLE  SectionHandle,
    IN HANDLE  ProcessHandle,
    IN OUT PVOID  *BaseAddress,
    IN ULONG_PTR  ZeroBits,
    IN SIZE_T  CommitSize,
    IN OUT PLARGE_INTEGER  SectionOffset  OPTIONAL,
    IN OUT PSIZE_T  ViewSize,
    IN SECTION_INHERIT  InheritDisposition,
    IN ULONG  AllocationType,
    IN ULONG  Win32Protect
    );

ZwUnmapViewOfSection

NTSTATUS 
  ZwUnmapViewOfSection(
    IN HANDLE  ProcessHandle,
    IN PVOID  BaseAddress
    );

 

다음 칼럼에는

다음 컬럼에서는 이번 컬럼에 이어서 파일 매핑의 자료구조와 남은 이슈들을 다루고 VMM이 가상 주소를 관리함에 있어서 가장 키 이슈가 되는 페이지 폴트부분을 중점 적으로 다루도록 하겠다. 마지막으로 파일 시스템 드라이버 개발에 있어서 VMM을 위해 필요한 FSD 구현 사항들을 마지막으로 VMM에 대한 컬럼을 마무리 하도록 할 것이다.

 

References

Rejeev Nagar, "Windows NT File System Internals": A Developer Guide, O'Reilly 1998

P. B. Kruchten."The 4+1 View Model of architecture."

David Garlan and Mary Shaw January 1994 "An Introduction to Software Architecture"

A-PEGASIS

from Drafts/Hardware(Trans) 2009/12/18 00:36

Implement and Evaluate Advanced Power Efficient Gathering in Sensor Information System (개선된 센서 라우팅 방식 A-PEGASIS 구현 및 성능 평가)

 

정명수               박규영

(Myoungsoo Jung) (Gyuyoung Park)

 

본 기술 문서는 서창진과 양진웅에 의해서 구현된 개선된 센서 라우팅 방식(A-PEGASIS)을 구현하고 TyniOS와 TOSSIM을 이용하여 기존 LEACH 라우팅 방식과의 성능 비교를 위해 작성 되었다. 본 기술 문서에서는 단순히 A-PEGASIS 논문에 제시된 애매한 제한된 스패닝 체인 트리 메소드를 보다 명확히 기술하고 이에 대한 알고리즘과 동작방식을 구체적으로 제시한다. 본 기술 문서의 실험치는 TinyOS와 TOSSIM환경 위에서 구현된 A-PEGASIS 통신 방식은 기준 LEACH에 비해서 에너지 소비량(산술적 평균) 24% 향상, Base station과 통신을 위해 각 노드들의 생존량이 1.85~3배 정도되는 것을 보여 준다.

 

1. INTRODUCTION

최초 계층 구조 알고리즘인 LEACH는 Base station과 통신하기 위해 Figure 1에서처럼 전체 네트워크의 망을 특정한 개수 개의 센서 노드를 가진 클러스터로 나누고 그 중에서 클러스터 헤더 또는 coordinator로 불리는 대표자를 선출하여 이를 통하여 Base station과 통신을 한다. 통신을 할때는 해당 클러스터의 모든 측정값들을 하나의 패킷으로 Fusion하여 통신을 하게 되는데 그 결과 멀리 떨어져 있는 Base station과 집적 통신하는 노드의 수를 줄이고 클러스터 헤더에서 일괄적으로 퓨전하여 전송 되는 에너지 소비를 줄였다.

Figure 1클러스터의 구성 방법과 LEACH의 계층 구조

 

클러스터 헤더를 통한 LEACH의 통신 접근 방법은 무선 선서 네트워크의 가장 간단한 통신방법인Flooding이나 Gossiping 보다 훨씬 효율적인 동작을 수행 하지만 클러스터 헤더를 선출(Selection) 하는 과정에서 비효율적인 문제가 발생한다. 다시 말해서 각각의 클러스터 안에 분산적이며 확률적인 방법으로 선택된 클러스터 헤더의 센서 노드들은 클러스터가 중첩(Overlap)되는 특정 지역이 발생하여 클러스터 헤더가 밀집되는 현상이 발한다. 이러한 현상은 Base Station에게 측정값을 전달하는 토폴로지의 경로가 길어지게 되는 결과를 초래하며 이로 인해 성능 하락을 보이게 된다.

 

이에 반하여, 서창진과 양진웅에 의해서 제안된 A-PEGASIS는 기존 계층 구조 알고리즘들을 계선한 PEGASIS 알고리즘에 약간의 수정을 가하여 좀 더 효율적인 통신 알고리즘을 제안한다. 이 두 논문 다 Coordinator(PEGASIS군에서는 클러스터라는 의미가 존재 하지 않으므로 대표자라는 이름으로 대신하며 이는 클러스터의 헤더와 같은 역할을 한다)가 100% 퓨전을 하는 가정을 기반으로 하였기 때문에 Coordinator수가 하나로 축약된 계층 알고리즘으로 에너지 전달의 소비를 대폭 감소하였다. 다만 PEGASIS에서는 체인을 생성할 당시 Prim Algorithm을 사용하는데 이를 통하여 생성된 체인은 평균 길이가 길고 마지막으로 선정된 링크의 길이가 다른 노드들에 비하여 다소 긴 경향을 가지는 단점을 가지고 있다. 다소 긴 링크를 가지는 노드의 인접 노드들은 전송 에너지가 다른 노드들에 비하여 일찍 고갈 되는 경향이 존재 하는데 A-PEGASIS는 이러한 단점을 극복하기 위하여 Kruskal Algoritm의 유망집합(Promising set)을 사용하고 링크 스와핑 기법을 통해서 이를 조금 더 개선한 알고리즘이다.

 

본 기술 문서는 아래와 같은 순서로 구성되어 있다. 섹션 2에서는 A-PEGASIS를 실제 구현하고 평가 하기 위해서 문제가 되는 것들을 제시하고 이에 대한 해결 방법을 모색해본다. 섹션 3에서는 실제 문제가 되는 애매한 제한된 스패닝 체인 트리의 알고리즘을 명확하게 기술하고 TinyOS와 TOSSIM에서 A-PEGASIS를 구현하기 위한 기타 방법들을 기술한다. 섹션 4는, TinyOS와 TOSSIM위에서 구현된 A-PEGASIS의 Coordinator 선출 과 체인 변경에 실제 예를 제시하며 성능 결과치를 제공한다.

 

2. IMPLEMENTATION CHALLENGES

제안된 A-PEGISIS 알고리즘을 구현 하기 위해서 우리는 아래와 같은 몇 가지 문제를 해결 해야 했다.

  1. 슈퍼라운드 변경 시까지 생성된 체인을 모두 동기화 하기 위한 정보 공유.
  2. A-PEGASIS에서 제안된 제한된 스패닝 체인을 구성하기 위한 실제 알고리즘의 부재
  3. A-PAGASIS에서 사용되는 가중치를 구하기 위한 도출 식들의 가정을 실제 구현에 반영하는 문제
  4. 기존 LEACH를 변경 하는 것이 아니라 새로운 모듈을 작성 하는 수준의 구현사항
  5. 각 노드들의 위치 정보를 TinyOS가 제공하지 않음으로써 발생하는 환경 정보 가공 문제

 

첫째, TinyOS와 NesC의 구조상, 각 노드들은 하나의 코드를 공유하여 인스턴스를 생성한다. LEACH에 구현된 MHLeachPSM 모듈을 기반으로 구현을 되짚어보면 각 노드들은 다른 노드들의 정보를 하나의 전역 변수에 선언하여 이를 공유하게 되는데 이 정보들을 Advertise라는 커뮤니케이션 방식에 따라서 이를 공유한다. 여기서 공유하게 되는 것은 노드들의 에너지, 노드 주소, 클러스터헤더인지 아닌지, 노드가 살아 있는지 아닌지 정도를 공유하게 된다. A-PEGASIS와 본 기술 문서에서 제시된 변형된 Kruskal의 제한된 스패닝 체인 트리는 각 노드간의 edge정보, 방향성, 그리고 논문의 도출식에 의해서 계산된 가중치 정보들은 튜플 형태의 벡터로 구성되고 완전한 정보를 제공하기 위해 완전 그래프(Complete Graph)형태로 제시 되기 때문에 데이터 사이즈가 커서 advertise와 같은 프로토콜로 공유 될 수 없다. 따라서 TyniOS와 TOSSIM기반의 A-PEGASIS 구현에서는 매 노드에서 이를 공유하는 방법이 하나의 문제점으로 부각된다.

 

둘째, 제시된 A-PEGASIS의 알고리즘은 섹션 3의 구현과 해결책에서 다시 한번 제시 되겠지만 크게 3가지로 프로시저로 분류된다. 가중치 계산을 처음 진행하고 이에 따라 제한된 Kruskal 스패닝 체인 트리를 형성한 뒤 링크간 스와핑의 적용이 가능한 경우 이를 해결 해야 한다. 문제는 토폴로지를 형성에 핵심이 되는 제한된 스패닝 체인 트리의 형성에 있다. 우선적으로 Kruskal 알고리즘은 최소 가중치를 가지는 토폴로지를 형성하기 위해 유망 집합(Promising Set)을 사용하는데 Kruskal에 의해 원래 그래프와 완전이 서로소인 솔루션 유망 집합을 얻고 나면 이를 체인으로 변경하는데 문제가 발생한다. 왜냐하면 트리를 끊어내는 방식으로 체인을 형성하면 그래프간의 단절이 발생하고 이 때문에 하나의 coordinator로 base station과 통신할 수 없는 문제가 발생한다. 이 부분에 대해서 A-PEGASIS는 의사수준의 알고리즘도 제공하고 있지 않다. 이 문제에 대해서는 섹션 3에서 다시 좀 더 구체화 하여 토론 해보도록 하겠다.

 

마지막으로, A-PEGASIS는 가중치 계산과 Coordinator선출에 있어서 각 노드들의 위치를 기반으로 한 거리를 판단 할 수 있어야 하며 에너지 잔량을 측정 할 수 있어야 한다. TyniOS와 TOSSIM기반의 시뮬레이션에서 작성된 A-PEGASIS 모듈은 이러한 환경 정보를 읽어오는데 한계가 있다. 따라서 본 기술 문서의 프로젝트 코드에서는 TOSSIM의 네트워크 형성을 Grid로 가정하고 각 거리를 계산 하였으며 에너지는 모든 노드가 동일하게 가지고 시작하며 스패닝 체인 정보 공유를 위해 Coordinator가 변경 되는 순간에 일괄적으로 이를 반영하는 가정을 포함한다. 또한 본 프로젝트는 원래 LEACH 코드를 이해하고 이를 개선 방법을 공부하는 것과 함께 LEACH를 완전히 대체하는 A-PEGASIS 모듈을 따로 작성하였다.

 

3. DETAILED IMPLEMENTATION AND SOLUTIONS

3.1 Kruskal Spanning Tree

최소 신장 비용 트리(Minimum Spanning Tree)를 위하여 첫 번째로 구현된 Kruskal Algorithms은 구현 된 소스의 A-PEGASIS 모듈의 BuildMST() 인터페이스를 통해 이루어진다. 기존 PEGASIS 논문과 A-PEGASIS 논문에서 제시된 알고리즘은 모두 토폴로지 형성에 있어서 Node Degree와 Adjacent Node가 2인 특수 트리인 체인을 통해서 만들어진다. 기존 PEGASIS의 논문에서는 Prim Algorithms을 통해서 PEGASIS 체인을 형성하지만 서창진과 양진웅에 의해서 구현된 A-PAGASIS는 최소 신장 비용 트리를 형성하는 Kruskal Algorithm에 f가 2d인 제약사항을 가하여 토폴로지를 형성한다.

BuildMST(Graph, FinalGraph) 입력 그래프에 대하여 최소 비용 신장 토폴로지를 생성한다.

Input : 가중치, 비 방향 간선들을 가진 complete Graph

Output : 최소 신장 비용 토폴로지

Begin procedure

1: F = {공집합}

2: Edge들은 가중치로 sorting

3: 입력 Grape의 Vertex에 의 서로 소 부분 집합 구축

4: Graph에 속한 Edge을 가중치가 작은 것부터 차례로 정렬

5 : loop 모든 부분집합이 하나로 합쳐질 때 까지

6:          if 서로 소 부분집합의 두 접점이 연결 되었는지 여부 // feasibility check

7:          두 부분집합을 Merge.

8:          Edge를 F에 추가.

End procedure

Table 1 Kruskal 스패닝 트리 구현 알고리즘



Figure 2은 스타를 내포한 육각형 네트워크의 중심에 base station이 있고 설명의 편의를 위하여각 edge마다 에너지와 distance를 고려한 가중치가 숫자로 주어져 있다. Kruskal Algorithms은 Figure 2에서 볼 수 있듯이 우선적으로 가중치가 낮은 순서대로 edge를 소팅하여 가중치가 낮은 edge부터 최소 비용 신장 트리에 삽입을 고려한다. 이때 가중치로만 고려를 하기 때문에 base station을 포함한 edge임은 고려 대상이 아니다. 최소 비용 신장 트리에 삽입 되기전에 Kruskal Algorithms에서는 포리스트(Forest)로 불리는 유망집합(Promising set)들(Figure에는 회색으로 표기된 것들 것 각각의 유망 집합들이다)을 각각 구성하고 최소 가중치를 가진 edge부터 유망 집합에 병합하는 작업을 진행한다.(여기서 유망 집합은 생성 되기 전까지 node개수만큼 많은 독립된 집합으로 형성이 가능하다) 이렇게 유망 집합은 공집합으로 시작하여 Figure 2에 주어진 모든 노드들을 유망 집합에 삽입하고 기존 집합과 서로소가 될 때 이를 종료한다. Table 1는 A-pegasis 모듈을 BuildMST 인터페이스에 구현된 소스의 의사코드를 기술하고 있다.

 

3.2 Limited Spanning Chain Tree

A-PEGASIS에서는 제한된 스패닝 체인 트리를 형성한다. 서창진과 양진웅에 의해서 제시된 스패팅 체인 트리 절차는 대략적으로 아래와 같다.

  1. 거리와 남은 에너지를 가지고 각 edge마다 가중치를 계산
  2. 가중치를 바탕으로 Kruskal 알고리즘을 통하여 스패닝 트리를 형성
  3. 스패닝 트리의 링크 스와핑.

거리와 남은 에너지를 가지고 계산하는 가중치는 A-PEGASIS의 방식을 TyniOS와 TOSSIM에서 사용이 가능한 정보와 가정수준에서 새로 구성하였으며 에너지 도출 식(1,2)은 아래와 같다.

 

BuildChain(Graph, FinalGraph) 입력 그래프에 대하여 최소 비용은 스패닝 체인을 생성한다.

Input : 가중치, 비 방향 간선들을 가진 complete Graph

Output : 최소 신장 비용 스패닝 체인

Begin procedure

1: F = {공집합}

2: F' = {공집합}

3: 입력 Grape의 Vertex에 의 서로 소 부분 집합 구축

4: 시작 노드를 base station으로 설정

5 : loop 모든 부분집합이 하나로 합쳐질 때 까지

6:    Graph에 속한 Edge을 가중치가 작은 것부터 차례로 정렬

7:    시작 노드에 인접한 노드중 가장 가중치가 적은 노드를 선정

8:    If 방문하지 않았고 edge가 집합 F에 존재 하지 않는다면

9:         방문을 하여 방문한 노드의 인접 노드들이 어디 있는 지 임시 F'에 병합(Merge)

10:    If 최소치를 구하지 못하거나 graph정보에 시작 노드의 정보를 가진 튜플이 없다면

11:       모든 그래프의 정보를 역으로 탐색하고 방문정보가 있는 지 확인

12:   If 방문 하지 않고 역방향 간선이 존재한다면

13:       다시 가중치를 계산하여 소팅

14:       유효성 검사 후 F'에 병합

15:    if 서로 소 부분집합의 두 접점이 연결 되었는지 여부 // feasibility check

16:       두 부분집합을 Merge.

17:       F'을 F에 업데이트

18:      방문 정보 벡터 업데이트

19:      방문 노드를 시작노드로 재설정

20:      링크 연결 크로스시 스와핑.

End procedure

Table 2 본 기술문서에서 제시되고 작성된 제한된 스패닝 체인 트리 구성 알고리즘






에너지를 통한 가중치가 계산되면 스패팅 체인 트리를 형성 해야 하는데 여기에는 섹션 2에서 언급되었던 문제가 발생한다. 유망 집합과 원래 입력 그래프의 서로소 상태를 확인 하며 스패닝 트리를 형성한 뒤 다시 제한된 스페닝 체인을 형성하기 위하여 트리를 잘라 체인을 만들면 그래프가 분리되어 하나 이상의 Coordinator가 선출 되는 문제가 발생한다. 이 부분의 기술이 미흡한 관계로 우리는 Table 2에서 제시된 알고리즘 대로 방문 노드와 방문 노드로부터 인접한 노드들의 가중치를 고려하여 다시 스패닝 트리를 작성하였다.

 

Figure 3는 본 기술 문서에서 해결책으로 작성된 제한된 스패닝 체인 트리의 작성 세부 절차를 보여 준다.

4. EVALUATION

 

4.1 Coordinator 변경 실례

우리는 솔루션에 제시되고 논문이 기술 된 바대로 슈퍼 라운드마다 스패닝 체인을 형성하여 coordinator를 재선정한다. Figure XX는 TyniOS와 TOSSIM에서 실제 구현된 결과를 반영하여 슈퍼라운드시 마다 실제 체인 형성이 완전히 새로 구성되며 이를 통하여 새로 선정된 coordinator와 base station간의 통신 상태를 보여준다.

<Figure 4, 새로 생성되는 스패닝 체인과 Coordinator 변경 실례>

 

이러한 체인 재 형성과정과 재 선정된 coordinator는 이론적으로 A-PEGASIS의 100% 퓨전이 가능하다고 가정하면 LEACH에서 발생하는 문제인 특정 영역 밀집 현상과 클러스터마다 클러스터 헤더가 base station과 통신하는 비용 및 이로 인한 에너지 절감 효과가 충분히 가능하다는 사실을 보여준다.

 

4.2 데드 노드 빈도수

성능 결과치를 도출 하기 위해 우리는 각 edge간 거리를 기본 유닛을 1으로 설정하고 최소거리 1, 최대 거리가 3으로 가정하였다. 디스턴스 팩터(Distance Factor)는 거리 1일 때 40, 2일 때 80, 3일 때 120으로 설정하였다. 에너지를 구하는 식은 모든 노드가 초기 1000이라는 값으로 에서 시작을 하고 슈퍼라운드를 결정하는 각 라운드의 이전 노드가 가지고 있던 에너지에서 디스턴스 펙터를 감산한다.


<Figure 5, 데드노드 빈도수 – 1차 시도>


<Figure 6, 데드노드 빈도수 – 2차 시도>

 

 

Figure XX를 통한 결과치는 각각 1차 2차, 3차 시도를 표현하고 있다. LEACH의 경우 클러스터 헤더를 확률적 선택방법에 의존하여 랜덤하게 추출하기 때문에 하기 때문에 재선정 되고 전체 시뮬레이션 기간 동안 각 노드가 처리해야 하는 빈도수가 A-PEGASIS보다 높기 때문에 일찍 죽을 수 있다는 것을 설명한다. 반면에 A-PEGASIS는 coordinator는 클러스터가 아닌 전체 스패닝 체인에 따라 선출 기회가 균등하게 이루어 지기 때문에 시간이 지남에 따라 죽는 빈도가 낮다.

 

4.2 시간대 별 평균 잔존 에너지량.

평균 에너지는 총 노드에 잔존 에너지들의 합을 총 가정 노드 수(6)을 통해 산술적 평균으로 구해진다. Figure XX는 LEACH는 클러스터 헤더들이 A-PEGASIS 보다 많이 선출 되기 때문에 A-PEGASIS비하여 Base station과 통신을 하기 위해 에너지를 많이 소비하게 된다. 반면에 A-PEGASIS는 전체 체인에서 하나의 coordinator 를 균일하게 선출하기 때문에 좀 더 안정적인 결과를 보여준다.


<Figure 7, 시간대별 평균 잔존 에너지량>

 

시간대 죽은 노드 개수 - 1 시도

sec.

0

100

200

300

400

LEACH

0

0

1

2

2

A-PEGASIS

0

0

0

0

1

시간대 죽은 노드 개수 - 2 시도

sec.

0

100

200

300

400

LEACH

0

1

2

2

3

A-PEGASIS

0

0

0

0

1

시간대 남은 평균 에너지

sec.

0

100

200

300

400

LEACH

6000

5280

4460

3780

3040

A-PEGASIS

6000

5440

4820

4140

3780

<Figure 6, 각 실험 별 통계 수치>

 

5. CONCLUSION

위에 기술한 결과를 통해서 보면 A-PEGASIS가 LEACH보다 모트들의 에너지 특성을 더 효율적으로 고려한다는 것을 알 수 있고더 나은 성능을 보여주었다. LEACH도 역시 WSN의 효율적인 이용을 위해 고안된 프로토콜이긴 하지만 각 클러스터의 헤더를 선출하는 방법에 있어서 Random하게 선출하기에 때문에 각 모트의 에너지 상태를 반영하지 못한다는 한계를 가지고 있다. 즉, 헤더가 자신의 클러스터에 속한 다른 모트들로부터 받은 데이터를 퓨징하여 베이스 스테이션으로 한꺼번에 전달하게 되는데 네트워크의 규모가 커질 수 록 클러스터의 개수가 늘어나게 되고 클러스터의 헤더들은 다른 모트들보다 더 많은 에너지를 소비하게 된다. 이 때 헤더의 선출에 남은 에너지량을 고려하지 않고 Random하게 헤더를 선출하게 되므로 직전 라운드에 헤더였던 노드가 다시 선택된다면 그 모트는 빨리 에너지가 고갈될 수 밖에 없다. 이러한 문제점을 A-PEGASIS가 해결해 주었다는 것을 본 문서의 결과를 통해 알 수 있었다. 네트워크의 체인을 형성하고 헤더를 선출하는 과정에 있어서 각 모트와 베이스 스테이션사이의 거리, 그리고 각 모트의 남은 에너지량을 통해서 가장 베이스 스테이션과의 비용이 적은 모트를 각 라운드마다 선출함으로써 전체적인 모트들의 평균 에너지량을 균일하게 맞출 수 있었고 각 모트들의 수명 또한 더 오래 지속될 수 있었다. 실험치는 TinyOS와 TOSSIM환경 위에서 구현된 A-PEGASIS 통신 방식은 기준 LEACH에 비해서 에너지 소비량(산술적 평균) 24% 향상, Base station과 통신을 위해 각 노드들의 생존량이 1.85~3배 정도되는 것을 보여 준다.

 

6. REFERENCE

[1] 서창진, 양진웅, "개선된 센서 라우팅 방식 : A-PEGASIS (A-PEGASIS : Advanced Power Efficient GAthering in Sensor Information Systems)", 2007 12, 정보 과학회

[2] Stephanie Lindsey, Cauligi S. Raghavendra,, "PEGASIS: Power-Efficient Gathering in Sensor

Information Systems"

[3] D. Estrin, R. Govindan, J. Heidemann, and Satish Kumar. "Next Century Challenges: Scalable Coordination in Sensor Networks. In Proceedings of Mobicom" 99, 1999.

[4] W. Heinzelman, A. Chandrakasan, and H. Balakrishnan." Energy-Efficient Communication Protocol for Wireless Microsensor Networks. In Proceedings of the Hawaii Conference on System Sciences", Jan. 2000.

'Drafts > Hardware(Trans)' 카테고리의 다른 글

A-PEGASIS  (0) 2009/12/18
Native Command Queue #2  (0) 2007/10/14
Native Command Queue #1  (0) 2007/10/14

NT 가상 메모리 매니저

NT Virtual Memory Manager Overview

 

가상 주소 공간은 개발자에게 어떤 의미를 가지는가? 단순히, 허락된 물리 메모리 공간을 넘어 좀 더 유연한 메모리를 지원 해주기 위한 주소 공간이라고 생각 하더라도, 실제 이를 구현 하기 위한 물리 주소와 가상 주소간의 사상(Address Mapping)은 복잡한 구현에 이루어진다. 더불어 각 태스크(Task)별 주소 공간을 분리하여 주기 위해서는 스토리지와 같은 저장 매체와 함께 이를 다루어야 하며, 소유권 등의 관리 또한 해주어야 한다. 이러한 일련의 작업들은 기존 운영체제 등에 많은 부하를 주고 특히 저장 매체를 접근 하게 되는 경우 병목현상(I/O bottleneck)에 의한 오버헤드는 해당 시스템의 성능을 많이 좌우 하기 때문에 이를 개선하기 위한 하드웨어 등이 필수적으로 이용 되고 있다. 파일 시스템 개발과 같은 커널 개발자의 경우, 어플리케이션이 바라보던 메모리 주소공간을 넘어서, 커널 주소 공간에서 개발을 진행 하기 때문에 이러한 가상 메모리 매니저의 기능을 이해하지 못하면, 성능 향상은 물론이거니와, 예상하던 동작의 정상적인 기능조차 어려워 질 수 있다. 따라서 커널 개발자가 알아야 할 NT 가상 메모리 매니저의 기능과 파일 시스템과의 관련성까지 약 3회 걸쳐서 알아보기로 하자.

 

정명수 (Myoungsoo "Mozer" Jung) |

필자는 지난 3년간 삼성전자에서 플래시 메모리와 관련된 연구와 임베디드 소프트웨어, 커널 드라이버 등을 개발 했었다. 현재는 조지아 공대(Georgia Institute of Technology) 컴퓨팅 칼리지에 재학 중이다. 글쓰기를 매우 좋아하며 학부시절에는 객체 지향 패러다임을 통하여 해석하는 프로그래밍 언어론에 관심이 있었으나 실무과정을 거치면서 컴퓨터 아키텍처로 관심사가 옮겨졌다. 최근에 관심 있는 분야는 운영체제, 파일시스템, 실시간 스케줄링 등이다.

 

가상 메모리 매니저는 파일 시스템과 상당히 밀접한 관련을 가진다. 특히 NT 계열의 커널에서는 파일 시스템의 요청이 가상 메모리 매니저를 통해서 다시 파일 시스템을 호출 하는 경우나 자신이 사용하는 캐시 매니저와 맞물려서 페이지 폴트(page fault)와 같은 처리들을 담당 하기도 한다. 따라서 파일 시스템 드라이버 개발자는 반드시 가상 메모리 매니저를 이해해야 하는데 이는 가상 메모리 관리에 대한 운영체제의 동작을 포괄 하기 때문에 이를 이해하기 위해 독자들은 운영체제 가상 메모리 관리의 개념을 반드시 이해하고 있어야지 앞으로 진행 되는 가상 메모리 매니저 칼럼이 도움이 될 것이다. 가상 메모리 매니저 섹션에서도, 이제까지 그래 왔듯이, 운영체제의 기본 컨셉들을 자세히 다루지 않는다. 물론 필요한 부분들은 짚어보고 지나가지만 본 칼럼에서는 범용 운영 체제의 기능, 동작 설명보다는 그를 바탕으로 하는 NT 커널의 실체를 설명 하는 데 초점을 맞출 것이다.

 

DSP (Digital Signal Processor)와 같은 특수 제품을 제외하고 리소스 관리가 필요한 대부분의 플랫폼과 이를 지원하는 운영체제는 가상 메모리(Virtual)관리를 지원한다. 전통적으로 휘발성 메모리인 RAM 자원은 해당 플랫폼에서 동작하는 어플리케이션의 요구량보다 적었다. 따라서 대부분의 운영체제들은 한정된 메모리 자원의 공유를 지원하고 이를 위해서 각 어플리케이션의 자원 관리에 직접 관여 해야 했다.

 

간혹 혹자는 가상 메모리를 적은 메모리 량을 더 큰 단위로 만들어 주는 것이 가상 메모리 매니저(이후 VMM, Virtual Memory Manager로 편의상 기재하도록 하겠다) 의 역할이라고 생각 한다. 물론 근본적인 목적은 실제 물리 메모리보다 더 풍부한 가상 메모리를 제공하는 것이 VMM의 역할이지만, 이를 위해서는 몇 가지 부가적인 고려사항들이 필요하다. 첫째 하나의 머신 위에서 동시에 동작하는 어플리케이션은 그들의 태스크(Task)가 반드시 다른 태스크와는 독립적으로 동작 해야 한다. 다시 말해 같은 물리 공간을 사용 하고 있지만, 각 태스크마다 완전히 독립적인 메모리를 제공 해야 한다는 것이다. 따라서 대부분의 운영체제는 자신의 시스템에서 동작하는 모든 어플리케이션에 그들만이 소유하고 있는 독립적 메모리를 제공한다. 다시 말해, 이는 각 어플리케이션 마다 독립적인 자료구조, 그리고 코드등을 사용하도록 허락 해준다는 것이다. 아울러 코드와 자료구조들이 독립적으로 태스크마다 분리 되어 있어야 하므로, 다른 태스크나 어플리케이션이 특정 어플리케이션이나 태스크의 자료구조나 코드를 침범 하지 않도록 운영 체제는 이를 보호 관리 해야 한다. 이러한 프로텍션(Protection) 개념은 운영체제 스스로에게도 해당하며 자신의 커널 코드나 자료구조를 비정상적인 어플리케이션으로부터 보호 할 수 있어야 한다. 반대로, 좀 더 복잡한 어플리케이션의 경우 하나의 시스템에서 여러 개의 서로 다른 인스턴스(Instance)를 가지고서 같은 자료 구조를 공유해야 하는 경우가 있다. 물론 IPC(Inter procedure call)과 같은 것으로 이를 극복 할 수도 있지만, VMM은 각 어플리케이션의 소유권을 고나리 하는 동시에 이러한 공유 리소스도 관리 해줄 수 있어야 한다.

 

다시 돌아와서, NT 윈도우 시스템에서 가상 메모리 매니저라고 이름을 붙인 것은 아래와 처음 혹자가 언급한 것과 유사한 이유를 가지고 있다. 다시 말해 NT의 모든 어플리케이션은 VMM이 제공하는 가상의 메모리 안에서 자신만이 독립적으로 실행 되는 것과 같이 메모리를 사용 할 수 있으며, 물리적 메모리와 관계 없이 자신에게는 무한한 메모리가 주어 졌다고 가정하고 실행 할 수 있게 해준다. 이러한 기본 기능에 대한 추상화 때문에 가상 메모리 메니저라는 이름이 붙었지만, 앞서 언급 했듯이 실제 VMM은 좀 더 복잡한 기능들을 수행해야만 한다.

 

VMM 주요 기능 (Functionality)

NT 윈도우즈의 VMM 우선 클러스터링(Clustering)과 함께 디멘드 페이징(Demand-paging)을 지원 해야 한다. 일반적으로 운영체제는 세그먼트(Segment) 또는 페이지(Page) 단위로 가상 메모리 지원을 위한 매핑을 하는데 NT 윈도우의 경우 기본 방침이 페이지 단위의 매핑이며, 각각의 프로세스들은 VMM이 제공하는 가상 메모리 주소 공간을 가지게 된다. 이 가상 메모리 주소 공간 뒤에는 실제 물리 메모리 공간이 일부 존재 하며 현재 시스템에서 사용 가능한 물리 페이지 중 해당 프로세스에의 요구(Demanding, 이 때문에 디멘드 페이징이라고 호칭 된다)에 의해서 매핑 된다. 매핑을 해야 하는 이유는 범용적인 운영체제의 수업 교재들을 참조 하기 바란다.

 

두번째, VMM에 의해서 관리 되는 가상 주소 메모리 공간은 물리 페이지의 조작 또는 운영체제의 동작과는 별개로 완전히 독립되어 (다시 말해 이를 쓰는 어플리케이션이나 프로세스는 해당 물리 주소 공간의 동작을 전혀 모르도록) 각 프로세스들이 자신의 가상 주소 공간에서 할당, 해제, 그리고 조작등이 가능하도록 지원 되어야 한다.

 

세번째, VMM은 반드시 로컬 파일 시스템의 도움을 받아서 가상 주소 공간을 구성 해야 한다는것이다. 이는 적은 물리 메모리 공간을 무한대로 보여주기 위해서 가상 주소 공간의 특정 페이지들을 하드디스크와 같은 스토리지로 매핑 시켜야 한다. 이를 NT에서는 위탁 메모리(실제로는 Committed memory라고 부르는데, 커밋 메모리라고 기재하기 힘들어서 의역했다. 독자는 이를 가급적 원래 용어대로 부르는 것이 좋다)라고 부르며 이는 페이지 요구량에 따라서 동적으로 사이즈가 조절된다.

 

네번째, VMM은 메모리의 관리를 하면서 워킹셋(Working Set) 모델에 따라 정책을 결정하며 모든 물리 메모리의 할당과 해제는 반드시 VMM에 의해서만 수행 되어야 한다. 메모리 프로텍션을 위해서는 엑세스 컨트롤 리스트(Access Control List) 라는 것을 사용하여 제공한다. 또한 VMM은 각 페이지단위로 보호할 수 있도록 copy-on-write 페이지 기능을 제공하는데 여기 언급된 사항들을 차후에 다시 설명 될 것이다. 단지 여기서는 VMM이 해야 하는 기능들을 알아 보고 있는 것이므로 여기에 대한 지식이 없다면 다음 절로 넘어 가도 무관하다.

 

VMM은 이외에도 메모리 맵드 파일(Memory Mapped-File)을 제공 해야 하며, 이종간의 프로세스가 메모리를 공유 할 수있도록 IPC(Inter Process Communication, 앞에서 언급된 IPC와는 다른 것이다.)를 지원 해야 한다. 지원 되는 가상 메모리는 프로세스가 새로 생성될 때 즉, fork와 exec와 같은 API를 호출 했을 때의 가상 메모리 관리는 POSIX 표준을 따른다.

 

프로세스 주소공간(Process Address Space)

주소라는 것은 간단히 이야기 해서 특정 메모리의 위치를 가리키는 숫자에 불과 하다. 우리가 흔히 어드레스 공간이라고 부르는 것은 (여기서는 프로세스의 어드레스 공간) 해당 프로세스가 접근 할 수 있는 숫자들의 집합이라고 보는 것이 맞다. 한국에는 한국만의 주소 공간이 있고 일본에는 일본만의 주소 공간이 있듯이 (사실 이는 물리적 개념이기는 하지만) 프로세스는 자신의 어드레스 공간을 반드시 가지고 있어야 한다. 전형적으로 각 프로세스들은 전역 데이터, 힙(Heap), 코드, 스택(Stack), 공유메모리, 공유 라이브러리와 같은 것들을 자신의 주소 공간에 가지고 있다. 물론 이들은 반드시 물리 주소 공간에 저장 되어 있거나 기억 되어 있어야지 프로세스가 정상 동작 하지만, 이들이 동시에 물리 메모리에 모두 올라와 있어야 할 필요는 없다. 필요에 의해서 각 부분들은 물리 메모리 공간으로 로드 될 수 있는데 이를 위해서는 해당 프로세스가 이러한 정보들이 저장된 위치를 알 수 있는 방법이 필요 하다. 따라서 각각의 프로세스는 이를 분별하는 방법으로 각자 독립된 주소 공간을 가지고 있다.

 

사실, 물리 메모리의 특정 부분들은 커널 코드와 자료 구조를 위해 할당 되는데 이를 제외 하고 나머지 주소를 가지고 VMM은 프로세스에 관련된 가상 주소 공간을 생성한다. 이 가상 주소 공간은 운영체제의 각 콤포넌트(Component)들에 의해서 참조가 가능 해야 한다. 이러한 요구 사항은 다른 프로세스들처럼 4GB의 가상 메모리를 가지는 시스템 프로세스를 할당하여 해결할 수 있다. 이제까지 우리가 배워 왔듯이, 사용자 프로세스는 파일 시스템과 관련되어 I/O 동작에 대한 요청을 이러한 시스템 프로세스를 통해서 할 수 있을 뿐만 아니라, 사용자 프로세스 메모리 조작, 새로운 프로세스 생성과 같은 것들을 요청할 수 있다. 따라서 NT 운영체제는 이 요청을 신청하는 프로세스의 가상 메모리 주소 공간에 접근 할 수 있는 특정한 시스템 프로세스가 필요 했다. 이러한 태스크는 사용자 프로세스의 컨텍스트(Context) 안에서 운영체제의 코드와 데이터에 접근 할 수 있어야 하는데, 이를 위해 NT는 4GB의 가상 주소 공간을 상위, 하위 2GB로 각각 나누고 유저 모드에서 실행 되는 2GB에 해당하는 주소 공간을 사용자 공간(User space)이라고 부르고 나머지 2GB에 대해서 커널 공간(Kernel space)라고 부른다. 따라서 사용자 모드(User mode)의 어플리케이션은 해당 사용자 공간에만 접근이 가능 하고, 커널 모드의 프로세스는 4GB에 모두 접근 할 수 있으나, 사용자 공간이 어떤 어플리케이션에 매핑 되어 있을지는 그때 그때 다르다. (이에 대한 이야기는 앞서 I/O 관리자를 설명해서 충분히 했을 것이라고 생각한다). 다시 말해 사용자 모드의 가상 주소 공간이 각각의 사용자에 프로세스에 의해 참조 된다고 하여도, 하위 2GB는 언제나 운영체제의 코드와 데이터에 접근 할 수 있는 주소 공간을 제공한다는 것이다.

 

우리가 이해 해야 하는 또 하나 다른 주소 공간의 개념은 각 프로세스 마다 4GB로 보이는 주소공간에 할당 되어 있는 하이퍼스페이스(Hyperspace)이다. 하이퍼스페이스가 특별한 이유는 2GB의 커널 주소 공간에 존재 하지만, NT VMM에 의해서 관리 되는 내부 자료 구조들이 관리 되기 때문이다. 이 자료 구조는 프로세스의 페이지 테이블과 VMM 자료 구조들을 포함 하고 있다.

 

파일 시스템 개발자는 특히나 이 두 개의 이질 주소 공간을 반드시 이해 해야 하는데, 그 이유는IRP를 통해서 받은 파일 시스템 컨텐츠 데이터가 유저 공간에 있을 경우, 이 주소 공간을 무조건 접근 할 수 없다는 것에 있다. IRP를 요청 했을 때 상위 2GB에 물려 있던 프로세스가 현재 어떤 다른 프로세스로 매핑 되어 있는지 알 수 없기 때문에 컨텐츠 자체가 유효 하지 않다. 반면에 하위 2GB는 커널의 메모리 주소공간이므로 모든 프로세스에서 동일하다. 따라서 어떤 포인트를 사용 하더라도 커널 메모리 주소 공간을 가리키는 것이라면 우리는 아무런 의심없이 사용 할 수 있다. 그렇다면 우리는 어떻게 사용자 주소 공간에 유효한 방법으로 접근 할 수 있을 것인가? NT VMM이 이를 위한 API를 제공하고 있다. MmGetSystemAddressForMdl() 함수는 사용자 주고 공간을 커널 시스템 개발자가 안전하게 커널 영역 주소 공간으로 매핑 시키고 접근 할 수 있도록 허락 해준다.

<그림 1: NT 윈도우의 주소공간 >

 

MmGetSystemAddressForMdl은 우리로 하여금 특정 주소를 기반으로 커널 영역을 매핑 시키기 해주지만, 반대로 특정 프로세스의 사용자 주소 공간을 보고 싶을 경우도 있을 수 있다. 이를 위해서 제공 되는 것은 NT DDK에 제공되는 KeAttachProcess() 이다.

 

KeAttachProcess

 

VOID

DDKAPI

KeAttachProcess(

IN PEPROCESS Process);

 

KeAttachProcess 는 커널 모드 스레드를 명시된 타겟 프로세스에 붙일 수 있게 해준다. 따라서, 붙여진 이후 커널 모드의 스레드는 해당 프로세스의 컨텍스트 안에서 실행 될 수 있고 이는 해당 프로세스가 가지고 있는 모든 자원들을 사용하거나 접근하는 것을 허락 하도록 해준다.

 

KeAttachProcess를 호출 할 때 프로세스라고 명시되어 있는 인자에는 해당 프로세스의 포인터를 넘겨주면 된다. 일반적으로 현재 프로세스의 주소를 명시적으로 얻으려고 할때는 I/O 매니저에서 제공하는 IoGetCurrentProcess를 통하여 얻을 수 있다.

 

이를 사용하여 주소 공간을 확인 할 때는 각별히 주의 해야 할 점이 두 가지가 있다. 그 중에 하나는 IRQL_LEVEL에 대한 문제인데, 이는 잘못 사용 할 경우 시스템에 큰 손상을 가져올 수 있다. 이를 사용하는 커널 드라이버 개발자는 절대 IRQL이 DISPATCH_LEVEL 보다 높은 상태에서 호출 하면 안된다. NT 커널 코드 자체는 공개 된 바 없기 때문에 내부적으로 어떤 식으로 작성 되어 있는지 알 수 없지만, 이 루틴의 내부에서 자신의 자료 구조를 보호하기 위해 스핀락(Spinlock)을 사용 하는 것으로 암암리 알려져 있다. 따라서 이를 호출 하는 커널 스레드가 DISPATCH_LEVEL보다 높은 경우 데드락에 빠질 가능성이 있기 때문에 항상 DISPATCH_LEVEL보다 높은 곳에서 실행 하지 않는 것이 좋다. 두번 째는 KeAttachProcess에 의해서 한번 주소 공간이 변경 되고 나면 두번째는 변경 되지 않는 다는 것이다. 이를 위해서는 keDetachProcess를 통하여 현재 강제 매핑 된 주소 공간을 환원하고, 다음 수행 하는 것이 올바르다.

 

keDetachProcess

 

VOID

DDKAPI

KeDetachProcess(

VOID);

 

keDetachProcess는 이전에 KeAttachProcess에서 의해서 붙여진 커널 모드 스레드를 자유롭게 해준다. 물론 이 함수 또한 내부적으로 스핀락을 사용 하는 것으로 알려져 있기 때문에 DISPATCH_LEVEL 이하에서 수행 해주는 것이 올바르다.

 

 

물리 공간 관리(Physical Memory Management)

물리 메모리 조작을 어떻게 하는지를 가장 쉽게 이해하는 방법은 가상 주소공간이 어떤식으로 물리 주소에 매핑 되는 지를 보면 알 수 있다. 따라서 여기서부터 물리 메모리 관리에 대해 이야기 해보도록 하자.

 

페이지 프레임(Page Frame)과 페이지 프레임 데이터베이스

NT VMM은 시스템에서 실제 가용 가능한 물리 메모리들에 대해서 책임지고 관리 해야 하는데 이를 위해서 페이지 기반의 메커니즘을 이용한다. 페이지 기반은 솔라릭스, UNIX, HPUX등과 같은 상용 운영 체제 등에 의해서 거의 표준화 되었다. NT VMM은 고정된 사이즈의 페이지 프레임들에 메모리를 매핑 시키는데 이 페이지 프레임 사이즈는 4K부터 64K까지 변경 될 수 있다. 하지만 대부분 HDD의 특징을 고려 하여 4K단위로 최적화 되어 있는 것으로 알려져 있다.

 

각각의 페이지 프레임들은 페이지 프레임 데이터베이스(PFN database)이라고 불리는 자료 구조에 의해서 표현된다. 이 페이지 프레임 데이터베이스는 페이징이 되지 않는 메모리 주고 공간에 할당된 일종의 배열과 유사하다.

 

이렇게 표현된 각각의 페이지 프레임은 페이지 프레임 테이터베이스 엔트리에 의해 표현 되는 페이지 프레임의 물리 주소를 20bit로 제한 하고 있다. 따라서 12 bit를 페이지 오프셋(offset)으로 사용하여 32bit, 즉 우리가 계속 언급 해왔던 4GB 주고 공간을 표현할 수 있도록 작성 되어 있다. 효율적 관리를 위해서 페이지 프레임 자료구조는 해당 페이지 프레임이 수정 된 적이 있는 지를 가리키는 상태 비트를 하나 가지고 있고, 해당 페이지 프레임이 읽기에 의해서 수행 되었는지 아니면 쓰기에 의해서 수행되어 있는 지를 나타내는 상태 비트 또한 가지고 있다. 마지막으로 이 페이지 프레임의 소유자 프로세스의 의해 이것이 공유 되고 있는 지 아니면 독립적으로 운용 되고 있는 지를 표현 할 수 있다.

 

이 외에 페이지 프레임은 몇 개의 연결 구조를 위한 필드를 할당 하고 있다. 첫째, 페이지 테이블 엔트리(PTE, Page Table Entry)와 프로토타입 페이지 테이블 엔트리(PPTE, Prototype Page Table Entry)를 위한 포인터를 가지고 있다. 이 포인터는 해당 가상 주소로부터 물리 주소를 찾기 위해 역으로 주소 번역을 해야 할 때 유용하게 사용 된다. 페이지 프레임 간에 앞,뒤 주소에 대한 포인터도 쌍으로 존재 하는데 이는 효율성을 위해 해시(hash)테이블에 의해서 관리 된다.

 

마지막으로 페이지에 대한 레퍼런스 카운터를 가지고 있는데, 이에 대해서는 좀 더 상세한 언급이 필요하다. 일반적으로 유효한 페이지는 카운터가 0이 아닌 레퍼런스 카운터 상태를 가지고 있다. 이 페이지 프레임은 특정 프로세스에 의해서 활성화된 페이지 정보를 가지고 있는데, 페이지 프레임이 PTE(Page Table Entry)에 의해 더 이상 참조 되지 않고, 레퍼런스 카운터가 0가 되면 이 페이지는 어떤 프로세스로부터도 참조 되지(또는 사용되지)않는다는 것을 암시한다. 비활성화 페이지는 사용되지 않는 페이지와는 그 의미가 다른데 그것은 레퍼런스 카운터 때문이다. 사용되지 않는 페이지 프레임들은 몇 가지 리스트에 의해서 관리 된다.

 

첫째, 이 중에 에러가 발생한 페이지는 (ECC 에러 체크와 같은 것으로 검출되어 에러 페이지로 밝혀진) BPL(Bad Page List)에 등록 되어 관리 된다. 이와 같은 방식으로 FPL(Free Page List)도 관리 되는데, 여기에 등록 되어 있는 페이지들은 즉시에 바로 재 사용 할 수 있는 페이지들이다. 다만 비 활성화 페이지임을 보장하지는 않는다. 다시 말해서 레퍼런스 카운터가 0가 아닐 수 있다. 사실, NT커널의 VMM은 US의 DOD 보안 등급인 C2 레벨을 만족 시키기 위해서 설사 페이지 프레임의 컨텐츠가 사용 되고 있지 않더라도 이 페이지를 재사용 하지 않는다. 다만 해제된 페이지의 레퍼런스 카운터가 0가 될 때 비로서 시스템 워커 스레드(System Worker Thread)를 비 동기적으로 깨워 준다는 것이다.

 

둘째, BPL, FPL과 유사하게 ZPL(Zero Page List)과 MPL(Modified Page List)라는 것을 가지고 있다. ZPL의 경우, 즉시 재사용 할 수 있는 페이지들을 링크드 리스트로 페이지 프레임들 정보를 유지 하고 있으며 MPL의 경우 다시는 특정 프로세스에 의해서 레퍼런스 될 일은 없지만 거기 수정된 정보가 아직 스토리지와 같은 저장 장치에 모두 쓰여지지 않아서 즉시 사용 될 수 없는 페이지들을 링크드 리스트로 가지고 있다. 이런 수정된 페이지들은 페이지 쓰기 콤포넌트(Modified Page Writer, Mapped Page Writer)에 의해서 비동기적으로 스토리지에 저장 되는데 차후에 좀 더 상세히 언급 하도록 하겠다.

 

마지막으로 SPL (Standby Page List)가 있는데 SPL의 경우는 프로세서의 워킹 셋으로부터 제거된 페이지들을 링크드 리스트로 가지고 있다. NT커널의 VMM의 경우 프로세스의 패턴에 따라서 자신이 보유하고 있는 이러한 페이지 프레임들의 양을 조절 하는데 SPL은 이러한 동작을 수행 하도록 만들어져 있다. SPL은 사실상 페이지 프레임을 보유 했던 프로세스에게 해당 페이지를 사용할 수 있는 기회나 권한을 좀 더 부여 해주는 기능으로 볼 수 있다. NT 커널에서 사용되는 워킹 셋이라는 것은 프로세스에 할당된 페이지 프레임의 수를 의미한다고 보아도 무방하다. 프로세스는 좀 더 효율적으로 페이지 프레임을 사용 하기 위해서 이 워킹 셋을 조절 하는데, 만약 프로세스에 할당된 페이지 프레임이 워킹 셋 조절에 의해서 빼앗기게 되는 경우 VMM은 더 이상 이 페이지를 조절하지 않는다. 이유는 앞서 언급 된 것처럼, 워킹 셋 조절에 의해서 페이지가 빼앗기는 동작은 사실 워킹 셋을 조절하는 예상이 틀린 것으로 해당 페이지를 다른 곳에 쓰는 것이 아니라 그를 소유 했던 프로세스가 이를 다시 참조 할 수 있도록 SPL에 담아두고 시간을 유보시켜주는 역할을 하는 것이다.

 

NT커널의 VMM은 자신이 관리하는 SPL, BPL, FPL등에 페이지 프레임들의 최소 페이지 수와 최대 페이지 수를 제한 해두고 있다. 따라서 어느 리스트에 등록 되어 있던 간에 페이지 프레임의 수가 정해진 최소 또는 최대 값을 넘어 갔을 때는 VMM 전역 이벤트를 통하여 언제든지 이를 알 수 있다. VMM은 이 전역 이벤트를 이용하여 시스템에 필요한 유효한 페이지 프레임 수를 결정한다. 이러한 이벤트 이외에도 VMM은 효율적인 물리 메모리 관리를 위해서 내부 루틴을 사용하여 유효한 메모리들의 정보를 관리 하기도 한다. 예를 들어서 만약에 우리가 MmAllocateNonCachedMemory()와 같은 VMM 함수를 통해서 메모리를 할당 하는 경우, 이 함수는 우리에게 쓸 수 있는 페이지를 할당 해주기 위해 내부적으로 MiEnsureAvailablePageOrWait()함수를 통하여 우리를 위한 가용 가능한 페이지나 SPL에 대기중인 페이지가 존재 하는 지를 확인 한다. 만약 쓸 수 있는 페이지가 없다면 MiEnsureAvailablePageOrWait() 함수의 루틴은 요구된 페이지에 대한 가용 가능한 페이지를 반환 해주기 위해서 기다렸다가 쓸 수 있는 페이지를 반환 해준다. 물론, 이 루틴은 기다리는 과정에서 이벤트를 사용하는데 만약 기다릴 때 커널이 정해 놓은 시간 안에 이벤트가 정상적으로 확인 되지 않을 경우 시스템은 커널 패닉으로 빠질 수 있다.

 

페이지 프레임과 그에 해당하는 데이터 베이스에서 우리가 알아야 할 것은 상기 기술된 페이지 프레임에 대한 동작들은 매우 빈번하게 일어 난 다는 것이다. 따라서 페이지 프레임과 관련된 동작들은 동시성을 최대한 이용하도록 구현되어 있다. 일단 동일 시스템에서 동시성이라는 말이 나오기 시작하면 우리는 동기화 문제를 빼 놓을 수 없다. 역시나 NT커널의 VMM도 이 동시성을 최대로 이용하기 위해서 전역의 락(Locking) 매커니즘을 사용 하는데, (물론, 이 락은 스핀 락으로써 PFN 사용을 위해 데이터 베이스에 엑세스하여 사용 될 때 마다 걸리게 되어 시스템 저하를 가져 올 수 있으나 여기서 하고자 하는 이야기는 효율성과는 다른 이야기이다) 이는 DISPATCH_LEVEL이하에서 동작 하게 되므로 만약 페이지 폴트(Fault) 가 발생 할 때 우리가 작성하는 코드가 DISPATCH_LEVEL보다 높은 IRQL을 가지고 있다면 시스템은 패닉에 빠지게 된다. 따라서 파일 시스템 개발 도중, 메모리 캐싱과, VMM을 사용 하는 루틴에서 IRQL을 강제로 높이거나 그 이상의 IRQL로 진입하는 경우 시스템을 매우 불안정 하게 할 수 있다는 점을 숙지 해야 한다.

 

 

가상 주소 지원 (Virtual Address Support)

프로세스의 주소 공간을 살펴 보았다면 이제는 가상 주소를 지원하기 위한 몇 가지 이슈들을 살펴 보자. VMM이 가상 메모리 주소를 지원 하기 위해서 고려해야 하는 사항은 앞서 언급 한 것들 것 유사하다. 우선 VMM에 의해서 지원 되는 가상 주소 공간의 범위는 물리 주소 공간과는 완전히 독립적으로 변형 또는 지원 가능 해야 한다. 또한 가상 주소 공간의 내용이 물리 주소 공간 또는 스토리지와 같은 미디어 저장공간으로 매핑 되는 경우 이를 지원 하는 하드웨어(TLB, PTE와 같은) 와 완전히 조화를 이루어야 한다. 스토리지로 매핑 되는 경우 VMM은 파일 시스템을 사용하여 이 주소 공간에 쓰기 또는 읽기가 가능 하며 가상 메모리의 페이징 정책은 NT 커널에 있어서는 VMM이 전적으로 일임한다. (스토리지에 매핑 되는 이유에 대해서는 전반부에 간략히 설명 되었기 때문에 생략한다).

 

NT커널의 VMM은 시스템의 성능을 결정하는데 가장 큰 역할을 하는 콤포넌트이다. 현대에 있어서 메모리 값이 계속 싸지고 있고, 그 양도 방대해지고 있지만, 여전히 하드웨어중에 가격을 결정하는 중요한 요인 중 하나다. 뿐만 아니라 VMM과 가상 메모리 관리 콤포넌트가 제대로 작성 되어 있지 않다면 시스템은 심한 성능 하락을 가져올 수 밖에 없다. 왜냐하면 하드웨어 플랫폼 중 가장 병목이 되는 것이 VMM이 지원하는 스토리지이며, 그 다음이 메모리 이기 때문이다. 캐시가 이를 많이 극복 시켜 주고 있지만, 여전히 코히어런스나 컨시스턴시(Coherence, consistency miss)같은 것들은 시스템 성능을 좀 먹을 수 밖에 없다. 이에 대해서는 좀더 자세한 정보를 알고 싶다면 "The Impact of Architecture Trends on Operating System Performance" 논문을 참조 해보는 것이 큰 도움이 될 것이다. 돌아 와서, 이러한 이유로 VMM은 최소 메모리 요구량에 아주 민감하게 설계되어야 한다. 또한 이러한 설계 정책은 각각 장단점을 발생하므로 다음 장에 걸쳐서 NT커널의 VMM의 구현, 설계상의 장 단점에 대해서 계속 거론 할 것이다.

 

가상 주소 조작 (Virtual Address Manipulation)

각 프로세스마다 가상 주소 공간을 할당 하기 위해서 NT 커널의 VMM은 스스로 균형을 맞추는 트리(Self balance tree, 누차 이야기하건대, 가급적 용어는 현업에서 사용 하는 것을 그대로 쓰는 것이 나중에 가져올 혼란을 미연에 방지 할 수 있다)를 가지고 있고 이 트리는 각 프로세스에 대한 가상 주소 공간에 대한 디스크립터(VAD, Virtual Address Descriptor)를 다루게 된다. 이 트리의 루트노드의 포인터는 프로세스 구조체에 삽입 된 형태로 제공된다. VAD는 가상 주소의 범위를 표현 하기 위하여 그 시작과 끝에 대한 주소를 가지고 있다. 또한 관리의 편리를 위해서 트린내에 다른 VAD들의 포인터를 가지고 있다. 가장 흔하게 사용 되는 것들은 VAD가 다루고 있는 가상 주소의 페이지 속성이나 정보들인데 여기에는 가상 주소가 어떤 프로세스에 할당 되었는지, 그리고 그 페이지가 공유속성을 가지고 있는지 Copy-on-write를 통한 자식 프로세스간 메모리 공유는 이루어지고 있는지 등에 대한 것들이 있다. Copy-on-write는 POSIX 스타일의 fork() 동작으로 프로세스가 새로 생성 될 때 자식 프로세스에게 메모리를 할당 해주지 않고 부모의 것을 그대로 상속 받아 사용 하고 있다가 자식 프로세스가 뭔가 쓰기를 시도 하려고 하면 그 때 복사하여 자식 프로세스에게 메모리를 할당 해주는 방식을 의미한다. 일반적으로 프로세스가 생성 될 때 가장 오버헤드가 되는 것이 메모리를 할당하고 이를 초기화 해주는 작업으로 리눅스의 경우는 POSIX를 따르고는 있지만 각 OS정책에 따라 fork()의 방식이 다르다.

 

VMM은 가상 주소의 메모리가 프로세스를 위해서 할당이 되었건 아니면 단 순히 가상 주소 공간에 파일 뷰를 매핑 시키던 간에 이에 대한 VAD를 생성하고 이를 앞서 이야기된 트리에 삽입한다. 물론 두 메모리는 그 속성이 다르다. 후자의 경우는 단순히 가상 메모리 주소 공간에 대한 숫자들만 가지고 있기 때문에 VAD를 생성하지 않아도 될 것 같지만, VMM의 입장에서 가상 주소를 사용하는 이 둘간의 차등 적용은 필요가 없다. NT커널의 VMM은 각 프로세스마다 스스로 가상 주소공간의 메모리를 할당 할 수도 있도록 이를 지원 해주는데 이 때 사용 하는 것이 NtAllocateVirtualMemory() 루틴이다. 하지만 이 루틴은 파일 시스템 드라이버 개발자와 같은 커널 모드 드라이버에게애는 허용 되지 않기 때문에 이를 대신 하여 ZwAllocateVirtualMemory()함수를 사용 하여야 한다.

 

ZwAllocateVirtualMemory

 

NTSTATUS

ZwAllocateVirtualMemory(

HANDLE ProcessHandle,

PVOID *BaseAddress,

ULONG ZeroBits,

PSIZE_T RegionSize,

ULONG AllocationType,

ULONG Protect

);

 

이 루틴은 가상 주소 공간의 하위 2GB영역의 메모리 할당만을 허락 한다. 따라서 커널 모드에서 이를 사용 하기 위해서는 앞서 언급했던 프로세스를 붙이는 방법을 동반하여 사용 된다. 만약 어떤 프로세스가 있던지 관계 없이 메모리를 할당 해야 한다면 이 루틴 이외에 ExAllocatePool() 함수를 사용하여 메모리를 사용 할 수 있다.

 

ProcessHandle 멤버의 경우 ZwAllocateVirtualMemory를 사용하여 할당 되는 메모리의 컨텍스트를 소유한 프로세스를 기술 해주어야 한다. 이를 위해서는 NtCurrentProcess() 함수에 실제 인자를 -1로 할당 함으로서 현재 시스템 프로세스를 기반으로하는 프로세스를 얻을 수 있다. 이렇게 얻은 프로세스를 통하여 초기화 하는 것이 일반적이다. 만약 우리가 현재 프로세스 이외에 다른 프로세스의 컨텍스트를 사용하여 메모리를 써야 한다면 ZwAllocateVirtualMemory보다 앞서 기술 된 NtAllocateVirtualMemory() 루틴이 훨씬 도움이 될 것이다. keAttachProcess()를 통하여 우리가 꾀하는 컨텍스트를 기반으로 메모리를 조작 할 수 있기 때문이다.

 

BaseAddress는 ZwAllocateVirtualMemory가 성공적으로 수행 되었을 때 우리에게 할당 되어지는 주소의 시작 숫자이다. 만약 우리가 ZwAllocateVirtualMemory를 호출 할 때 BaseAddress 필드에 NULL값이 아닌 다른 값으로 초기화된 변수를 할당해 주었다면 VMM은 최대한 제공된 값을 주소로 고려하여 이에 대한 주소를 할당 해주려고 노력한다. 반대로 NULL인 경우, VMM은 가용 가능한 주소를 골라서 반환 해주는 것으로 작업을 마무리한다.

 

ZwAllocateVirtualMemory 함수는 스토리지로 내려가지 않은 상태의 가상 주소 공간을 예약 할 수 있또록 도와 주며 해당 주소 공간에 대해서 이전에 예약 되어 있을 경우 이를 스토리지로 쓸 수 있도록 도와 준다. ZwAllocateVirtualMemory와 상응 하는 함수는 ZwFreeVirtualMemory() 루틴이다.

 

ZwFreeVirtualMemory

 

NTSTATUS

ZwFreeVirtualMemory(

HANDLE ProcessHandle,

PVOID *BaseAddress,

PSIZE_T RegionSize,

ULONG FreeType

);

 

이 루틴의 멤버들은 ZwAllocateVirtualMemory의 쌍으로 이루어 지는 것으로 굳이 설명 하지 않아도 충분히 유추 할수 있다. ZwFreeVirtualMemory를 사용하면 우리가 ZwAllocateVirtualMemory를 통해서 예약하였던 주소 공간을 스토리지에 쓰여진 메모리와 가상 주소 범위에 대해서 이 둘을 모두 해제 한다.

 

ZwFreeVirtualMemory 루틴은 할당할 때와 다르게 주소 공간의 범위를 우리가 수정 할 수 있도록 허락해준다. 하지만 두 개의 VAD에 따로 할당된 주소 공간을 ZwFreeVirtualMemory을 통해서 한번에 해제 할 없다. 다시 말해 ZwFreeVirtualMemory를 호출 할때는 VAD에 대한 단일 주소 범위를 명시 해주어야 한다. 만약에 RegionSize 를 0으로 준다면 VMM은 VAD의 전체를 해제 하려 할 것이다. 물론 이때 주의 할 점은 BaseAddress를 정확히 기술 해주어야 한다는 점에 있다. VAD에 대한 단일 주소 공간의 범위를 해제 하기 위해서 VAD를 쪼개어 나누고 일부 해제, 쪼개진 새로운 VAD는 프로세스로 할당하여 관리 하도록 한다.

 

다음 칼럼에는

이번 컬럼에서는 가상 주소와 주소공간에 대한 주제를 가지고 가상 메모리 매니저가 하는 역할을 이해했다. 가상 주소 공간의 번역은 오늘 언급된 내용을 바탕으로 다시 서술되어야 하는데 그 이유는 가상 메모리 매니저, VMM이외에 실제 가상 주소와 물리 주소간의 매핑을 돕는 MMU나 TLB와 같은 하드웨어 로직이 끼여 들기 때문이다. 다음 칼럼에서는 가상 주소와 물리 주소간의 매핑 방법 그리고 공유 메모리와 매모리 맵드 파일에 대하여 알아보도록 하자.

 

References

Rejeev Nagar, "Windows NT File System Internals": A Developer Guide, O'Reilly 1998

P. B. Kruchten."The 4+1 View Model of architecture."

David Garlan and Mary Shaw January 1994 "An Introduction to Software Architecture"

NT I/O 매니저 공용 데이터 구조

Common Data Structures of NT I/O Manager

 

레이어(Layer)와 모듈(Module)간의 차이는 무엇인가? 이 질문에 대답은 명쾌하지만 경험적 개발자에게는 그 정의가 모호 할 수 있다. 레이어도 일종의 기능을 추상화 한 소프트웨어의 덩어리이고 모듈 또한 어떠한 기능을 정의하여 각 소프트웨어간 역할을 구분 한 것이기 때문이다. 근본적으로 레이어드 아키텍처(Layered Architecture)는 그를 구성하는 각 레이어들은 근접한 소프트웨어 모듈간에 통신만을 허락하며 두 레이어 이상의 간섭을 허락 하지 않는다. 이에 반하여 모듈은 상호간의 간섭에 자유롭다. NT 코어(Core)는 근본적으로 레이어 아키텍처를 기반으로 작성 되어 있기 때문에 이 아키텍처의 근본적인 유지보수의 약점을 고스란히 상속 받았다. 따라서 마이크로 소프트는 I/O 매니저를 작성하여 전체 레이어간의 통신을 조율한다. 이에 따라 I/O 매니저는 여러 가지 자료구조들을 개발자에게 제공하고 있다. 이에 대한 이해는 우리의 파일 시스템 드라이버가 NT 코어안에서 적절한 기능을 하도록 하는데 기초가 된다.

 

정명수 (Myoungsoo "Mozer" Jung) |

필자는 지난 3년간 삼성전자에서 플래시 메모리와 관련된 연구와 임베디드 소프트웨어, 커널 드라이버 등을 개발 했었다. 현재는 조지아 공대(Georgia Institute of Technology) 컴퓨팅 칼리지에 재학 중이다. 글쓰기를 매우 좋아하며 학부시절에는 객체 지향 패러다임을 통하여 해석하는 프로그래밍 언어론에 관심이 있었으나 실무과정을 거치면서 컴퓨터 아키텍처로 관심사가 옮겨졌다. 최근에 관심 있는 분야는 운영체제, 파일시스템, 실시간 스케줄링 등이다.

 

앞서 언급 되었듯이 NT I/O 매니저는 커널 모드의 디자이너나 개발자에게 유용한 자료 구조들을 정의 해두었다. 이러한 자료 구조들은 커널 모드 특성과 밀접한 관련이 있는 만큼 자료 구조 사용의 순서, 그리고 특정 순간의 멤버 변수들의 내제적 의미가 매우 중요하다. 종종 파일 시스템 개발자들은 파일 시스템에서 제공하는 기능들을 구현 하기 위하여 여러 개의 NT 공용 자료 구조들을 생성하고 이 들을 관리 해야 한다. 이번 컬럼에서는 저번 컬럼에서 다루지 않았던 NT의 공용 자료 구조들을 배우고 이야기 나누어 볼 것이다. 하지만 본 컬럼에서는 DDK에 기술된 구조적 설명 이외에 왜 이러한 자료 구조들을 생성하고 이러한 자료구조들을 어떤 방법과 순서로 우리 코드에서 다룰 수 있는 가에 초점을 둘 것이다. 크게 이 컬럼에서 소개 되는 자료 구조들은 아래와 같다.

 

  1. 드라이버 오브젝트 (Driver Object)
  2. 디바이스 오브젝트 (Device Object)
  3. 파일 오브젝트 (File Object)

 

드라이버 오브젝트(Driver Object)

NT에서 드라이버 오브젝트는 DRIVER_OBJECT라는 구조체 이름으로 다루어 지며, 이 구조체의 인스턴스는 메모리 상에 로드(Load)된 드라이버의 이미지를 대표한다. 따라서 여러 개의 인스턴스를 가지는 커널 모드의 드라이버라 할지라도 그 인스턴스들이 나타내는 드라이버가 동일하다면 메모리 상에서는 NT I/O 매니저에 의해 하나만 로드 된다는 것을 의미한다.

 

저번 칼럼에서 우리는 NT에 패킷 기반의 IO 모델에 대해서 알아 보았다. 각각의 I/O 리퀘스트 패킷(I/O Request Packet)은 사용자의 입출력에 대한 요청뿐만 아니라 드라이버로부터 제공되는 여러 가지 기능들을 다루게 된다. 따라서 우리는 이러한 IRP가 어떤 I/O 드라이버 루틴을 디스패치(Dispatch)하고 이들의 정보들을 관리 할 것이라는 것을 예상 할 수 있다. 드라이버 오브젝트에 대한 자료구조는 아래 제시 되어 있다.

 

DIRVER_OBJECT DATA STRUCTURE

typedef struct _DRIVER_OBJECT

{

SHORT Type;

SHORT Size;

PDEVICE_OBJECT DeviceObject;

ULONG Flags;

PVOID DriverStart;

ULONG DriverSize;

PVOID DriverSection;

PDRIVER_EXTENSION DriverExtension;

UNICODE_STRING DriverName;

PUNICODE_STRING HardwareDatabase;

PFAST_IO_DISPATCH FastIoDispatch;

LONG * DriverInit;

PVOID DriverStartIo;

PVOID DriverUnload;

LONG * MajorFunction[28];

} DRIVER_OBJECT, *PDRIVER_OBJECT;

<코드 1, 드라이버 오브젝트 자료구조>

 

이 DRIVER_OBJECT를 한번이라도 조사 해본 적이 있다면 MajorFunction이라고 불리는 함수 포인터에 대한 멤버를 확인 할 수 있을 것이다. 이 MajorFunction은 커널 모드 드라이버가 초기화 해야 하는 정보 중에 하나 인데, 이 포인터 배열 끝에 존재 하는 함수들은 초기화를 담당 했던 드라이버의 각각의 기능들을 대표하게 된다. 초기화 당시 파일 시스템 드라이버 개발자는 MajorFunction의 모든 함수 위치를 초기화 할 필요는 없다. 다시 말해서 MajorFuntion에는 초기화 되어야 하는 함수의 개수 그리고 지정되는 함수의 중복 여부등에 대한 어떠한 요구사항이나 제약사항도 가지고 있지 않다. 다만 커널 모드 개발자라면 적어도 하나의 드라이버 함수를 제공 할 것이고 MajorFunction에 대한 초기화를 자신이 제공하는 적절한 기능 함수들로 연결 해야 할 것이다.

 

드라이버 오브젝트의 DriverStartIo와 DriverUnload 멤버 변수들 또한 드라이버 개발자가 초기화 하도록 하고 있는데 낮은 레벨의 윈도우 NT 드라이버들은 이외에도 전형적으로 Startle라는 함수를 제공한다. 이 함수는 IRP가 드라이버에 디스패치 되었을 때 분만 아니라 큐(Queue)에서 처음 빠져 나왔을 때 도 불린다. DriverStartIo 멤버는 드라이버가 시작 IO를 시작할 때 커널에 의해서 직접 호출 되는 함수를 연결 해주는 것인데 전형적으로 파일 시스템 드라이버 개발자에게는 이를 초기화 해줄 필요가 없다. (일반적으로 파일 시스템 개발자 이외에 특정 필터 드라이버 개발자 또한 이와 유사하게 초기화를 할 필요가 없다.) 왜냐하면 파일 시스템 개발에 있어서는 개발되는 드라이버들 대부분의 그들의 드라이버의 미결된 (Pending) I/O 리퀘스트 패킷들을 내부적인 큐로 관리 하기 때문이다.

 

DriverUnload 멤버는 드라이버가 메모리에서 내려가게 될 때 호출 되는 함수 포인터를 가리키게 정해져 있다. 이것은 각기 드라이버가 메모리상에서 사라지게 될 때 드라이버 자신의 현재 상태나 컨텍스트(Context)들을 저장할 기회를 제공 하는 것과 같다. 따라서 메모리상에 계속 상주 해야 하는 드라이버라면 이 멤버의 초기화를 할 필요가 없다. 특히 파일 시스템 드라이버 개발자들은 드라이버가 요청에 의해서 메모리상에서 쫓겨 나게 될 때 자신의 메타(Meta) 데이터들을 저장해두기 상당히 까다롭다. (여기서 까다롭다는 의미는 일반적으로 시스템 컨시스턴시(Consistency)를 위해서 이러한 일을 수행 하지 않는다는 것이다) 따라서 드라이버 개발자들은 반드시 드라이버 오브젝트의 DriverUnload 멤버 필드를 특정한 기능 제공 함수로 연결 하지 말아야 한다. 다시 말해 항시 NULL로 초기화 해주면 된다.

 

많은 커널 모드 드라이버는 하나 또는 여러 개의 드라이버 오브젝트 구조체를 생성한다. 이 오브젝트 구조체는 드라이버 오브젝트의 DeviceObject에 연결 된다. 이에 따라서 드라이버가 메모리에 올라오는 시점에 이 DeviceObject는 빈 체로 존재 한다. 하지만 N/T 매니저는 우리가 생성한 드라이버에서 IoCreateDevice() 서비스 루틴을 사용해서 생성한 디바이스 오브젝트들을 연결 해준다.

 

드라이버 로드 I/O 매니저의 동작 이해

드라이버를 로드(Load) 하기 위해서 I/O 매니저는 IopLoadDriver()라고 불리는 내부적인 함수를 사용한다. IopLoadDriver() 함수는 우선적으로 메모리상에 올려야 하는 드라이버의 이름을 결정하고 이 드라이버가 이미 메모리상에 상주하고 있는지의 여부를 판단 한다.

일반적으로 NT I/O 매니저는 메모리 상에 상주하고 있는지의 여부를 전역 커널 모드의 링크드 리스트를 통하여 확인한다. 만약 드라이버가 이미 로드 되어 있다면 I/O 매니저는 더 이상의 작업에 관여 하지 않고 SUCCESS 플래그를 반환하고 이에 대한 동작을 멈춘다. 그런 것이 아니라면 드라이버 로드 동작을 계속 수행 하게 되는데, 우리가 작성 드라이버를 I/O 매니저가 메모리상에 올리기 위해서는 우리가 제공하는 인스톨 유틸리티가 우선적으로 생성되어 있어야 하며 적절한 레지스트리(Registry) 엔트리(Entry)를 제공해야 한다. 파일 시스템 개발자가 이를 어떻게 제공 할 것인가는 다음 컬럼에서 소개 하도록 하겠다.

 

돌아가서 I/O 매니저가 우리의 드라이버의 메모리 상주 여부를 파악하고 나서 메모리 올릴 때는 가상 메모리 매니저(VMM)에게 메모리에 실행할 드라이버의 주소를 사상 (Map, 이하 매핑이라고 지칭하도록 하겠다.) 시키도록 요청한다. 드라이버 코드의 매핑 파트에 있어서 VMM은 파일이 유요한 윈도우 실행 포맷(일반적으로 WEF, Windows Executable Format이라고 칭함)인지 확인하는 과정을 거친다. 이 과정은 만약 드라이버가 정상적으로 작성 되지 않았다면 WEF의 형태의 규약을 위반 하게 되었을 것이고 이를 통하여 VMM은 IO 매니저가 요청한 드라이버를 메모리상에 매핑 시키지 않을 수 있다.

 

메모리 상주여부, 그리고 VMM을 통한 메모리 매핑까지 모두 끝내고 나면 I/O 매니저는 새로운드라이버 오브젝트를 생성하기 위해서 오브젝트 매니저를 호출한다. 앞서 소개에서 언급 되었듯이 DRIVER_OBJECT 타입은 I/O 매니저가 미리 정의 해놓은 것으로 I/O 매니저에 시스템 초기화 시간에 생성 된다. 따리서 이미 오브젝트 메니저를 호출 하는 시점에서는 오브젝트 자체는 NT 오브젝트 메니저에 의해서 유효한 타입으로 시스템에서 인식 될 수 있기 때문에 생성된 드라이버 오브젝트는 비 페이징 메모리 영역(Non-paged pool, 이 부분에 대한 이야기는 DDK나 기타 다른 커널 드라이버 소개에서 찾아 볼 수 있을 것이다.)에 이를 할당하고 반환 해줌으로써 모든 IRQ 레벨에서 접근이 가능하다.

 

NT 오브젝트 매니저에 의해서 할당 되고 반환된 드라이버 오브젝트를 I/O 매니저는 모두 0으로 초기화 한다. 또한 MajorFunction의 각각의 슬롯들은 모두 유효하지 않음을 나타내기 위해서 IoInvalidDeviceRequest()에 의해 초기화 된다. 이것은 다양한 엔트리 포인트를 위해서 기본 디스패치 루틴으로 초기화 하는데 이 기본 디스패치 루틴이 수행하는 일은 다른 것이 아니라 단지 STATUS_INVALID_DEVICE_REQUEST 플래그를 세팅 해서 반환 하는 것으로 전형적인 빈 함수(Stub) 이다.

 

I/O 매니저는 MajorFunction 초기화 되고 나면 DriverInit 멤버를 우리가 작성한 드라이버의 DriverEntry루틴인 초기화 루틴으로 연결 해준다. DriverSection 멤버 같은 경우는 실행 이미지를 위를 위한 섹션 오브젝트와 매핑 해주고 DriverStart의 경우는 드라이버 이미지가 매핑된 가상 메모리 주소의 시작 주소(Base address)로 초기화 해여 주며 마지막으로 DriverSize는 우리가 작성한 드라이버 이미지의 사이즈로 초기화 시켜 준다.

 

I/O 매니저는 오브젝트 매니저에게 우리가 작성한 드라이버에 대하여 관리 하고 있는 드라이버 오브젝트들을 관련된 리스트에 삽입하도록 요청하는데 이를 통해서 I/O 매니저는 로드할 드라이버의 오브젝트들의 핸들들을 쉽게 얻을 수 있다. 이 핸들들은 결국 I/O 매니저에 의해서 참조 될 때 또는 메모리에서 나가게 될 때 사용 되며 후자의 경우 메모리에 할당된 오브젝트이 정상적으로 삭제 되는 것을 보장 해준다.

 

HardwareDatabase 멤버는 하드웨어 설정 매니저가 관리 하는 정보들로 연결되는데 이 멤버는 하위 레벨의 디바이스 드라이버가 현재 부트 사이클에 드라이버 설정 정보들을 결정하는데 사용 된다. I/O 매니저는 이외에도 DriverName 멤버 변수를 추후 에러 로깅(Error logging) 콤포넌트가 관련된 정보들을 남길 때 사용될 수 있도록 드라이버 이름으로 초기화 하여 올려 준다.

 

마지막으로 I/O 매니저는 드라이버 초기화 루틴을 호출 하는데, 이는 드라이버에게 스스로 초기화 시간에 제어권을 가질 수 있도록 기회를 부여한다. 따라서 파일 시스템 개발자들은 초기화 기회를 얻기 위해서 반드시 드라이버 초기화 루틴을 드라이버 오브젝트 구조체에 할당 해주어야 한다. 여기서 주의 해야 할 점은 우리가 개발하는 드라이버의 초기화 루틴은 반드시 IRQL_PASSIVE_LEVEL에서 수행 된다는 것이다. 이는 우리의 드라이버 최기화 루틴의 시스템 컨텍스트 레벨에서 수행 된다는 것을 같이 의미한다. 이것은 우리가 특정 오브젝트들을 생성할 때 매우 중요한 역할을 하게 되는데, 이전 컬럼에서 언급 했듯이 IRQL 레벨에 맞도록 오브젝트들을 생성하고 이에 대응 하도록 적절한 관리를 해주어야 한다. 만약 우리가 개발한 드라이버가 초기화 시 실패했다면 NT I/O 매니저에 의해 다시 메모리에서 내려 질 것이다. 따라서 NT I/O 매니저에게 제어권을 반환 하기 전에 할당 되었던 모든 오브젝트의 메모리들을 적절히 해제 해주어야 하며 열려 있는 오브젝트에 대한 적절한 참조 정보들을 해제 시켜 줘야 한다. 그렇지 않으면 나중에 이러한 댕글링(Dangling) 주소들 때문에 시스템은 비 정상적인 동작을 수행 할 수 있으며 시스템의 성능 저하를 가져 오게 된다.

 

이제 대부분의 드라이버 오브젝트의 멤버 변수들이 초기화 되는 순서와 절차에 대해서 언급되었다. 마지막으로 FastIoDispatch 멤버와 DriverExtension 루틴이 존재 하는데 FastIoDisptach 루틴의 경우 NT 캐시 매니저 컬럼에서 매우 상세히 언급되었으므로 이를 제외 해도 될 것이다. DriverExtension의 경우는 하위 레벨 드라이버에 의해서 자주 사용되는 것으로 DRIVER_EXTENSION 구조체의 인스턴스를 가리키도록 초기화 되는데 파일 시스템 개발자에게 있어서 이는 크게 중요하지 않으므로 본 컬럼에서는 다루지 않도록 하겠다.

 

디바이스 오브젝트 구조체(Device Object)

드라이버 오브젝트는 우리가 제작하는 커널 드라이버의 이미지를 대표 하는 구조체였다고 하면 디바이스 오브젝트는 이와는 달리 논리, 물리, 가상 다비이스들의 정보들을 관리 하기 위해서 커널 모드 드라이버에 의해서 생성 되는 것으로 볼 수 있다. 이해를 돕기 위해서 예를 들자면 우리가 자주 다루게 되는 디스크 드라이브의 경우는 물리 디바이스로 메모리에 디바이스 오브젝트로 표현 될 것이다. 이러한 물리 디스크 드라이버는 다시 여러 개의 파티션으로 나뉘어 지는데 이에 따라서 디바이스 오브젝트 또한 이에 적절한 가상 디바이스를 대표하는 디바이스 오브젝트들을 할당한다. 마지막으로 논리 디바이스들은 우리 파일 시스템 개발자에 의해서 각기 다시 나뉘어 질 수 있다. 이러한 디바이스 오브젝트들은 다른 드라이버들이 입출력 요청을 하기 위해서 사용되는데 만약 이러한 디바이스 오브젝트들이 없다면 커널 모드 드라이버들은 어떠한 입출력 요청을 받을 수 없을 것이다. 왜냐하면 NT I/O 매니저에 의해서 디스패치 되는 모든 입출력 요청들은 타겟이 되는 디바이스가 존재 해야 하기 때문이다. 예를 들어서 만약 우리가 디스크 드라이버를 개발하면서 디바이스 오브젝트를 생성하지 않으면 이 디스크를 접근 할 수 있는 유저는 단 한명도 없다. 하지만 한번이라도 디스크를 위한 디바이스 오브젝트가 할당된다면 사용자 모드의 프로세스는 해당 디스크에 대해서 언제든지 읽기와 쓰기를 수행 할 수 있다.

 

간혹 우리는 이름이 없는 디바이스 오브젝트를 생성하기도 하는데 이 것은 다른 커널 모드나 사용자 모드 프로세서에게 해당 디바이스에 접근을 하지 못하게 한다. 따라서 파일 시스템 드라이버 개발자가 디바이스 오브젝트에 이름을 할당 하지 않으면 어떤 콤포넌트도 직접 해당 디바이스에 입출력 요청을 할 수 없다. 이는 종종 파일 시스템 드라이버가 연결된 (Mounted) 파일 시스템 볼륨을 표현 하기 위해서 사용 된다. 이 경우에 물리나 가상의 디스크를 대표하는 디스크 드라이버에 의해 생성된 디바이스 오브젝트는 파일 시스템 볼륨이 상주하며 볼륨 파라미터 블록 (Volume Parameter Block, VPB) 구조체가 이름이 붙어진 물리 디스크 디바이스 오브젝트와 파일 시스템 드라이버에 의해서 생성된 이름 없는 논리 디바이스 오브젝트의 연관을 짓는 일들을 수행 하도록 한다.

 

드라이버가 디바이스 오브젝트를 생성하기 위해서 IoCreateDevice()를 호출 할 때 그것은 새로 생성될 다바이스 오브젝트와 연관시키기 위해 추가적으로 많은 양의 비 페이징 메모리 영역에 메모리 할당을 야기한다. NT I/O 매니저는 DeviceExtension 구조체에 이렇게 할당된 영역의 메모리 주소를 지정하도록 초기화 하고 이를 파일 시스템 개발자가 사용 하는데 어떠한 제약사항 등을 할애 하지 않는다. 파일 시스템 드라이버 뿐만 아니라 대부분의 드라이버를 처음 개발함에 있어서 궁금한 점은 전역 메모리에 자신이 사용할 변수나 인스턴스를 할당하여 관리 하는 것과 DeviceExtension을 통해서 메모리르 관리 하는 것의 차이를 궁금해 한다. 왜냐면 개발자의 코드상에서는 정적으로 할당된 전역 변수가 DeviceExtension을 통해서 관리, 접근 하는 것 보다 편리하고 의미적 차이를 느끼기 어렵기 때문이다. 하지만 여기에는 큰 차이가 있다. DeviceExtension을 사용 하는 것이 훨씬 깨끗한 코드를 보장 하는데, 디바이스 오브젝트 익스텐션(Extension)에 저장된 전역 변수들(다시 말해 관리되는 메모리들은)은 직접적으로 디바이스 오브젝트에 연관 지어지기 때문에 동기화 리소스 획득이나 기타 디바이스 오브젝트에서 사용되는 데이터들을 이용할 때 불필요한 추가 작업들을 피할 수 있게 해준다.

 

본 절에서 설명하게 될 디바이스 오브젝트를 대표하는 구조체인 DEVICE_OBJECT는 아래와 같이 언 되어 있다.

DEVICE_OBJECT DATA STRUCTURE

typedef struct _DEVICE_OBJECT

{

SHORT Type;

WORD Size;

LONG ReferenceCount;

PDRIVER_OBJECT DriverObject;

PDEVICE_OBJECT NextDevice;

PDEVICE_OBJECT AttachedDevice;

PIRP CurrentIrp;

PIO_TIMER Timer;

ULONG Flags;

ULONG Characteristics;

PVPB Vpb;

PVOID DeviceExtension;

ULONG DeviceType;

CHAR StackSize;

BYTE Queue[40];

ULONG AlignmentRequirement;

KDEVICE_QUEUE DeviceQueue;

KDPC Dpc;

ULONG ActiveThreadCount;

PVOID SecurityDescriptor;

KEVENT DeviceLock;

WORD SectorSize;

WORD Spare1;

PDEVOBJ_EXTENSION DeviceObjectExtension;

PVOID Reserved;

} DEVICE_OBJECT, *PDEVICE_OBJECT;

<코드 2, 드바이스 오브젝트 자료구조>

 

어떤 커널 모드들이던지 NT I/O 매니저의 IoCreateDevice()를 통해서 디바이스 오브젝트를 할당 할 수 있는데 이는 앞서 언급 했던 것처럼 비 페이징 영역에 메모리를 할당하여 이에 대한 포인터를 반환 해준다. 이 디바이스 오브젝트의 몇몇 주요한 멤버들의 사용에 대하여 알아 보도록 하자.

 

ReferenceCount 멤버는 현재 디바이스 오브젝트를 참조 하고 있는 수를 나타내는 데 일반적으로 이 값은 언제나 양수 값을 가지게 된다. 이는 두 가지 중요한 정보를 암시적으로 나타내는데 RefereceCount 멤버가 NULL이 아니라면 해당 디바이스 오브젝트는 지워 지지 않을 것이며 이 디바이스 오브젝트와 관련 있는 드라이버 오브젝트도 지워지지 않을 것이라는 사실이다.

 

파일 시스템 드라이버 개발자가 작성한 드라이버는 특정 파일 스트림이 연결 되어 있는 경우 절대 메모리에서 내려 가지 않는데 이는 ReferenceCount 멤버가 해당 드라이버를 통하여 열려 있는 파일 스트림을 나타내고 있고 사용자가 존재 하는데도 불구하고 메모리상에 파일 시스템 드라이버가 상주함을 보장 하지 못하면 시스템은 손상을 입게 될 것이기 때문이다. 따라서 파일 스트림이 열려 있는 특정 디바이스 오브젝트와 해당 드라이버 오브젝트가 메모리에 상주하는 것을 ReferenceCount가 보장한다.

 

디바이스 오브젝트는 여러 가지 개발자의 드라이버 관련된 데이터들을 관리 할 수 있게 해줌으로 해당 디바이스 오브젝트의 소유권 드라이버를 기술 할 수 있는데 이것이 DriverObject 멤버이다. 이는 IoCreateDevice를 호출 하였던 드라이버의 오브젝트로 초기화 된다.

 

커널 드라이버에 의해서 생성된 모든 디바이스 오브젝트는 서로 연결되어 있는 데 이를 나타내는 것이 NextDevice 멤버이다. 우리는 이 멤버를 사용하여 다른 디바이스 오브젝트를 탐색 할 수 있는데 이때 주의 해야 할 것은 커널 모드 드라이버에 생성된 디바이스 오브젝트 탐색 순서는 기술 되어 있지 않다는 것이다. 다만 NT I/O 매니저는 새로운 디바이스 오브젝트를 생성하면 이 링크드 리스트의 헤더에 삽입 하기 때문에 마지막 삽입된 오브젝트를 리스트의 시작에서 찾을 수 있다.

 

 


< 그림 1, 디바이스 오브젝트를 붙이는 경우 자료구조 표현 형태>

 

특정 디바이스 오브젝트를 가로 채어 다른 디바이스 오브젝트들을 삽입 할 수도 있는데 이렇게 디바이스를 삽입 할 때는 IoAttatchDevice를 사용한다. 일반적으로 이러한 기법들은 파일 시스템 드라이버 보다는 필터 드라이버 개발자들에 의해서 많이 사용 된다. 그림 1에서 볼 수 있듯이 한번 디바이스 오브젝트가 붙여지면 붙여진 모체가 되는 디바이스 오브젝트의 AttatcedDevice 멤버는 붙인 디바이스 오브젝트의 주소를 가리키게 된다.

 

CurrentIrp 멤버는 디바이스 오브젝트에서 가장 흥미로운 부분이다. NT 커널은 IRP를 통해서 시스템의 일관성을 보장하는데 커널 개발자는 커널을 사용하는 사용자나 개발자에 의해서 어떤 드라이버가 생성되고 이들이 동적을 할당 될지 모르는데 이들이 사용하는 자료 구조들을 어떤식으로 일관시키고 시스템을 유지 할 수 있는 가 하는 것이 IRP이고 각 모듈이 사용하는 IRP들이 관관 되는 것이 CurrentIrp 멤버이다. NT I/O 매니저가 제공하는 IoStartNextPacket()이나 IoStartPacket() API들을 사용 하면 미결된 IRP들의 드라이버 큐에 넣거나 뺄 수 있다. 이를 수행 하기 위해서 각 드라이버들은 StartIo 디스패치 루틴이 호출 되어 있을 때 CurrentIrp에 해당 IRP 포인터를 삽입함으로써 수행 할 수 있다.

 

Time 멤버는 IoInitializeTimer()에 의해 초기화 되는데 이는 NT I/O 매니저가 이를 사용한 드라이버를 초 단위로 타이머 루틴을 사용 할 수 있도록 보장한다. Characteristics 멤버는 가상, 물리적, 논리적 각각의 디바이스의 기타 속성들을 다를 수 있게 하는데 예를 들면 FILE_REMOVABLE_MEDIA, FILE_READ_ONLY_DISK, FILE_DEVICE_IS_MOUNTED와 같은 것들을 기록 하게 된다.

 

파일 오브젝트 구조체 (File Object)

마지막으로 파일 시스템 드라이버 개발자가 가장 친근해야 하는 파일 오브젝트 구조체에 대해서 알아보도록 하자. 이 구조체는 파일 시스템 개발자뿐만 아니라 드라이브와 관련된 어떤 필터 드라이버를 개발하는 모든 개발자에게 있어서 매우 중요하다. NT I/O 매니저는 파일 오브젝트를 파일 열기 동작 수행 시 특정 인스턴스를 대표 하기 위해서 생성하는데 만약 같은 파일 스트림에 대해서 열려 있다고 해도 NT I/O 매니저는 또 다른 파일 오브젝트를 생성하여 이를 초기화 해준다. 디스크 위해서 모든 입출력 동작들이나 논리적 볼륨 위에서 파일 오브젝트의 요청들은 지정된 목적지가 있어야 하기 때문에 개발자는 반드시 이전에 성공적으로 수행 했던 파일 오브젝트를 기억 하고 있어야 한다.

 

파일 오브젝트 데이터를 생성하고 관리 하는 것은 NT I/O 매니저뿐만 아니라 파일 시스템 개발자에게도 있다. 파일 오브젝트는 커널 모드 파일 시스템 드라이버에게 열거나 생성하는 요청을 전달 되기 전에 NT I/O 매니저에 의해 생성된다. 이 요청에 관련된 IRP는 새로 할당된 파일 오브젝트의 포인터가 포함 되어 있는데 그 이유는 파일 시스템 개발자가 이 요청 IRP가 자신에게 전전 될 때 특정 멤버들을 초기화 해주여야 하는 책임이 있기 때문이다.

 

FILE_OBJECT DATA STRUCTURE

typedef struct _FILE_OBJECT {

CSHORT Type;

CSHORT Size;

PDEVICE_OBJECT DeviceObject;

PVPB Vpb;

PVOID FsContext;

PVOID FsContext2;

PSECTION_OBJECT_POINTERS SectionObjectPointer;

PVOID PrivateCacheMap;

NTSTATUS FinalStatus;

struct _FILE_OBJECT *RelatedFileObject;

BOOLEAN LockOperation;

BOOLEAN DeletePending;

BOOLEAN ReadAccess;

BOOLEAN WriteAccess;

BOOLEAN DeleteAccess;

BOOLEAN SharedRead;

BOOLEAN SharedWrite;

BOOLEAN SharedDelete;

ULONG Flags;

UNICODE_STRING FileName;

LARGE_INTEGER CurrentByteOffset;

ULONG Waiters;

ULONG Busy;

PVOID LastLock;

KEVENT Lock;

KEVENT Event;

PIO_COMPLETION_CONTEXT CompletionContext;

KSPIN_LOCK IrpListLock;

LIST_ENTRY IrpList;

PVOID FileObjectExtension;

} FILE_OBJECT, *PFILE_OBJECT;

<코드 3, 파일 오브젝트 자료구조>

 

코드 3은 파일 오브젝트의 상세 멤버들을 나타낸다. DeviceObject와 Vpb 멤버는 파일 시스템 드라이버가 열거나 생성하는 요청을 보내기 전에 NT I/O 매니저에 의해 초기화 된다. DeviceObject가 해당 요청이 가리키는 가상 또는 논리, 물리 디바이스의 주소를 초기화 한다면 Vpb 멤버는 목적지 다비이스 오브젝트와 연관된 VPB 를 가리키도록 초기화한다. FsContext, FxContex2, SectionObjectPointer, PrivateCacheMap 멤버들은 NT 캐시 매니저와 파일 시스템 드라이버 구현 시 초기화 되는 매우 중요한 맴버이다. 이에 대한 기술은 이전 파일 시스템 드라이버의 자료구조를 소개 하면서 언급 되었던 내용이니 넘어가도록 하겠다.

 

FileName 멤버는 파일 뿐만 아니라 볼륨 그리고 열려있는 물리 디바이스를 나타내기 위한 파일 스트링을 의미한다. 이 스트링은 절대적 경로 그리고 상대적 경로 모두를 사용 하여 표현 될 수 있으며 상대적 경로 또는 이름은 RelativeFileObject에 의해서 지정 된다. 앞서 언급 된 것처럼 이전에 성공적으로 수행 되었던 파일 오브젝트를 관리 하는 것이 중요한 이슈인데 RelativeFileObject는 이 정보를 관리 하게 된다. 이를 사용 하면서 주의 해야 할 점은 RelativeFileObject는 파일 생성 요청 시점에서만 유효하다는 것이다. 이 시점을 제외한 모든 시점에서는 해당 멤버의 내용은 정상적인 값을 가리키고 있지 않다는 것을 상기 해야 한다.

 

CurrentByteOffset 멤버는 파일 시스템이 동기적으로 접근을 위해 열려 있는 파일 오브젝트를 관리 하기 위해서 파일 시스템 개발자가 사용 한다. 이 멤버는 파일 스트림을 위한 현재 위치와 읽기와 쓰기 입출력 수행시 성공 후 업데이트 된 위치를 가리키고 있다. CompletionContext 멤버는 NT I/O 매니저가 IRP를 마무리 시 로컬 프로시저 콜(Local Procedure Call, LPC)를 보내기 위한 메시지를 다루기 위해 사용 된다. 파일 시스템 개발자는 DeletePending 맴버를 잘 살펴야 하는데 그 이유는 이 멤버가 지워야 하는 파일 스트림을 기술 하고 있기 때문이다.

 

또한 파일 시스템 개발자들은 IoCheckShareAccess() API를 통하여 파일 오브젝트의 ReadAccess, WriteAccess, DeleteAccess, SharedReasd, SheardWrite, SharedDelete와 같은 멤버가 나타내는 상태를 참조 할 수 있다. 일반적으로 NT I/O 매니저에 의해 제공되는 IoCheckShareAccess는 파일 시스템 드라이버 개발자에 의해서만 사용 된다. 파일 오브젝트는 지연 가능한 커널 모드 오브젝트이다. 다시 말해서 스레드는 비 동기화 입출력 요청을 수행 할 수 있으며 순차적으로 이 비동기 입출력이 끝나는 것을 기다릴 수 있다. 따라서 이를 관리하는 멤버도 파일 오브젝트 안에 존재 하는데 이것이 Event 멤버이다. Event 멤버는 해당 파일 오브젝트를 사용하여 입출력이 시작될 때 NT I/O 매니저에 의해 비신호(Not-signald)상태로 초기화 된다. 비동기 입출력이 마무리 되면 이에 대한 신호를 받아서 상태를 변경하여 이를 관리 해주기 때문에 호출자는 비동기 입출력 요청을 위해 다른 커널 리소스를 할당 받아 관리 하지 않아도 된다.

 

자료 구조들에 대한 주의점

어떤 오브젝트를 사용 할 것인가에 대한 가장 간단한 룰은 모든 오브젝트를 같이 다 사용 하라는 것이다. 우리가 개발한 드라이버 메모리에 로드 될 때 드라이버 오브젝트가 생성 되고 우리 초기화 루틴에 NT I/O 매니저에 의해서 전달 될 것이다. 개발자는 드라이버 오브젝트의 디스패치 루틴을 나타내는 MajorFunction과 같은 특정 멤버 변수들을 초기화 한다. 만약 이러한 멤버들을 적절히 초기화 하지 못했을 경우 개발자의 드라이버가 NT I/O 매니저에 의해 호출이 되지 않거나 기대했던 기능을 전혀 수행 하지 못할 수 있다.

 

개발을 함에 있어서 데이터나 정보들을 관리 하기 위해서는 전역 변수를 할당 하지 말고 디바이스 오브젝트를 할당 받고, DeviceExtention등을 사용하는 것이 올바른 접근 방법이다. 다수의 디바이스 오브젝트를 할당받아 사용 하는 것도 문제가 없는데 이들 간에는 다른 식별자로 이름이 부여 되기 때문이다. 파일 시스템 개발자는 반드시 볼륨이 연결(Mount) 되는 시점에 VPB와 같은 자료구조들을 올바르게 관리 해주여야 한다. IRP를 받았다면 자연스러운 입출력 동작을 수행하기 위해서 해당 드라이버에게 할애된 IRP 정보를 사용 해야 하는데 이는 CurrentIrp 멤버를 통해서 접근 할 수 있으며 또한 IRP를 통해서 동기, 비동기 입출력 동작도 관리 할 수 있다.

IRP에 대한 더 자세한 내용은 다음 컬럼에서 따로 분리 해서 다루겠지만 개발을 하다 보면 IRP와 본 컬럼에 소개 되지 않은 인터럽트 오브젝트, 어뎁터 오브젝트, 컨트롤러 오브젝트를 다루어야 할 수도 있다. 하지만 디바이스 오브젝트와 드라이버 오브젝트, 그리고 파일 오브젝트는 매우 기본적인 것으로 이들에 대한 지식은 반드시 필요하다. 또한 파일 시스템 개발자는 UNIX 운영체제에서 파일 스트림을 사용하기 위해 쓰이는 vnode 와 같은 형태의 FCB (File Control Block)이나 CCB(Context Control Block)등을 반드시 다루어 주어야 하는데 이는 NT I/O 매니저 컬럼 이전에 파일 시스템 내부 자료 구조에서 다루어져 있으므로 해당 칼럼을 참조 하기 바란다.

 

 

다음 칼럼에는

I/O 자료구조의 내부적 운용, 그리고 팁에 대한 컬럼을 마지막으로 I/O 매니저와 관련된 이슈를 정리 하고자 한다. 물론 여기서 빼놓을 수 있는 입출력 리퀘스트 패킷 (IRP, I/O request Packet)에 대한 자료 구조와 그와 연관되어 있는 I/O 매니저의 행동 방식은 매우 흥미롭고 거론이 필요한 것이지만, IRP는 그 운용 범위가 매우 넓고 영향을 미치는 모듈들이 워낙 다양하므로, NT의 가상 메모리 매니저(VMM, Virtual Memory Manager)에 대한 이해가 먼저 이루어지고 나서 언급 되는 것이 올바를 것이라 생각한다. 다음 칼럼부터는 파일 시스템 드라이버 개발자에게 있어서 캐시 매니저, I/O 매니저와 함께 코어 내부 동작에 대한 이해가 반드시 필요한 VMM에 대하여 3회 정도에 걸쳐 알아보고, IRP에 대한 이슈들을 정리할 것이다.

 

References

Rejeev Nagar, "Windows NT File System Internals": A Developer Guide, O'Reilly 1998

P. B. Kruchten."The 4+1 View Model of architecture."

David Garlan and Mary Shaw January 1994 "An Introduction to Software Architecture"

IO 매니저 디자인 컨셉

IO Manager Design Concepts

 

특수한(Special) 목적과 범용적(General) 목적 모두를 만족 해야 한다면 여러분은 어떠한 방식으로 설계를 하겠는가? 대부분의 커널 개발자들이 처음 겪게 되는 수많은 매크로와 이해 할 수 없는 구조의 파라미터 전달 방식, 그리고 하나의 기능을 수행하기 위해 정형화 되어 있는 데이터 처리 프로세스 등은 이러한 고뇌의 흔적들이라고 할 수 있다. 물론, 추상화 수준이 미약하고 가끔 잘못된 형태의 디자인을 발견 할 수 있으나, 레이어드 아키텍처의 고질적인 문제를 해결 하기 위해 NT는 IO 서브시스템을 표준화 하는데 많은 노력을 하였음은 사실이다. 이번 칼럼에서는 IO 서브시스템의 디자인 컨셉을 살펴보고, 초보 개발자들에게 혼란을 주는 스레드 문제를 정리 하고자 한다.

 

정명수 , Myoungsoo "Mozer" Jung |

필자는 지난 3년간 삼성전자에서 플래시 메모리와 관련된 연구와 임베디드 소프트웨어, 커널 드라이버 등을 개발 했었다. 현재는 조지아 공대(Georgia Institute of Technology) 컴퓨팅 칼리지에 재학 중이다. 글쓰기를 매우 좋아하며 학부시절에는 객체 지향 패러다임을 통하여 해석하는 프로그래밍 언어론에 관심이 있었으나 실무과정을 거치면서 컴퓨터 아키텍처로 관심사가 옮겨졌다. 최근에 관심 있는 분야는 운영체제, 파일시스템, 실시간 스케줄링 등이다.

 

레이어드 아키텍처(Layered Architecture)는 4+1 뷰에서 제시되는 아키텍처상의 스타일(Architectural style) 중 하드웨어와 함께 소프트웨어 계층 구조를 표현하는데 가장 적절한 아키텍처 방법 중 하나로 꼽힌다. 각각의 레이어들은 상위 레이어에게 자신의 기능(Functionality)을 서비스하고 하위의 레이어에게는 데이터를 전달 하는 역학을 하는 것이 이 아키텍처 스타일의 정의이다. 이러한 이키텍처 스타일은 추상화 레벨을 증가 시키고 재사용 성을 확보하는데 많은 도움이 된다. 윈도우 NT이외에도 현재 일반적으로 사용 되고 있는 범용 운영체제들과 임베디드 시스템들은 이러한 레이어드 아키텍처를 기반으로 설계되어 있다고 볼 수 있다. 다만 이 아키텍처상의 스타일은 각 인접한 레이어들간에 커플링(Coupling)이 높아져서 유지 보수가 어려워지고 정확히 구분 되지 않은 단점이 있으며, 부가적으로 유지보수 시 각 레이어들의 요구 기능이 변경 되어 수정되는 경우 레이어간 전달 인자간의 디자인 변경이 자주 일어 날 수 있다는 단점이 있다. 윈도우 NT에서는 이러한 레이어드 아키텍처 단점을 극복하고 추상화 수준을 높이기 위해서 표준 파라미터(e.g., IRP)와 오보젝트들을 통해 전체 레이어간의 전달 인자 관리하고 이를 통해 적절한 상호작용들을 관리 해주는 IO 서브 시스템을 제공한다. 이번 칼럼에서는 여기에 근간이 되는 NT의 IO 서브시스템의 중요한 특징들을 알아보도록 하자. 잠시 앞에서 언급 하였듯이 NT IO 서브시스템은 대부분의 커널 드라이버의 컨시스턴시(Consistency)를 위해 전체 레이어에 걸쳐 있다. IO 서브시스템의 디자인은 크게 아래와 같은 3가지 컨셉으로 생각 해볼 수 있다.

 

  1. 패킷 기반의 입출력 파라미터 전달
  2. 오브젝트 모델 기반의 동작
  3. 레이어드 아키텍처 기반의 모듈간 결합 방식

 

 

패킷 기반의 입출력(Packet-based I/O)

IO 서브시스템은 packet 전달 방식을 기반으로 한다. 다시 말해서 커널 레벨에서 전달을 받거나, 요청을 하는 드라이버들의 모든 입출력 정보들은 I/O Request Packet이라는 IRP기반으로 커뮤니케이션 한다. 일반적으로 파일 시스템 드라이버 개발자 이외에 어떤 커널 모듈 개발자라도 입출력을 위해서는 IRP라는 구조체를 사용하여 정보를 전달하거나 받으며 이는 일반적으로 IO 매니저에 의해서 생성되어 전달 된다. 물론 우리가 직접, 내부적으로 I/O를 수행 해야 하는 경우에도 정보 전달은 IRP를 통해서 이루어져야 하는데 이때는 IoAllocateIrp()의 공개 IO 매니저 인터페이스를 통해서 IRP를 생성 할 수 있다. 이렇게 생성된 IRP는 IoCallDriver()를 통해서 자신 보다 아래에 있는 드라이버에게 전달 된다.

이렇게 IRP를 사용 하는 것은 차후에 다시 언급 되겠지만, NT IO 매니저가 시스템 전체의 컨시스턴시(Consistency)를 확보하는데 큰 도움이 된다. 이 시스템들은 레이어드 아키텍쳐(KWIC 디자인 모델을 참조하면 레이어드 아키텍쳐의 자세한 내용을 참조 할 수 있다). IRP를 우리가 생성한다고 해봐야 사실 해당 구조체를 특정 메모리에 잡는 것인데 이를 IO 매니저가 관리 하므로 우리는 특별히 해당 메모리의 유효 범위(Scope)를 신경 쓸 필요가 없다. 다만 메모리를 명시적으로 할당 받았으므로 해제 시에 이를 IO메니저에게 통보 해야 하는데 이는 해당 IRP를 생성한 주인이 명시적으로 IoCompleteRequest()의 서비스 루틴을 호출 할때 이루어 진다. 물론 I/O 매니저가 IRP를 생성하지 않은 경우의 이야기이다) IoCompleteRequest()를 호출 하게 되면 해당 입출력 동작은 IRP의 구조체 멤버를 통해서 IRP에 담겨 있는 정보에 해당하는 입출력처리를 마치고 마무리하겠다는 의미로 플래그(Flag)를 세팅 한다. 차후 I/O 매니저가 이를 확인하여 (Trigger되면) 해제가 가능한지 입출력이 완전히 종료 되었는 지를 확인 한 뒤 이를 해제 한다.

IRP를 핸들링 할 때 파일 시스템 드라이버 개발자가 주의 해야 할 것은 Fast I/O 모드 이다. 해당 모드에 대해서는 이전 칼럼에 자세히 설명 했으므로 여기서는 제외하고 넘어가도록 하자.

 

NT 오브젝트 모델(NT Object Model)

I/O 매니저는 NT 오브젝트 모델의 정의와 일치하도록 설계 되었으며 NT 실행부(Executive)에 오브젝트 매니저 콤포넌트에 의해 구현 되었다. 커널 모드 드라이버, 주변 기기 디바이스, 컨트롤러카드, 어댑터 카드, 인터럽트 등, 개발자가 다루어야 하는 모든 것들을 오브젝트 기반으로 조작 할 수 있도록 설계 되어 있다. 일전에 잠시 언급한 바가 있지만, 여기서 이야기하는 오브젝트는 클래스의 인스턴스처럼 만들어진 오브젝트는 아니다. 그렇지만, 이를 오브젝트라고 부르는 이유는 추상화 수준의 데이터 타입이 (ADT, Abstraction Data Type) 엄격하게 속성(Attribute)와 동작(Methods, 이후는 메서드라고 호칭함)로 구분 되어 구조체로 구현 되었기 때문이다. 따라서 각 오브젝트들은 구조체로 구현 되어 있음에도 불구하고 해당 오브젝트에 알맞은 메서드들이 정의 되어 있다. 예를, 들어 각각의 컨트롤러 카드는 컨트롤러 오브젝트라는 이름으로 구현 되어 있고, 일전에 소개된 파일 오브젝트들은 파일 스트림을 관리 하는데 적용 되어 있다. 특히 파일 시스템을 간략히 소개 할 때 언급 되었던 파일 오브젝트는 현재 I/O 매니저에 의해서 정의 되는 것으로 보면 된다.

여기서 주의 할 점은 ADT가 오브젝트 형식으로 만들어져 있을 뿐이지 언어 자체가 이를 지원 하는 것이 아니다. 다시 말해서 메모리 할당(사실 할당 부분은 대부분 각 매니저가 이를 수행 하도록 정의 되어 있기 때문에 매번 수행 한다고 볼 수는 없다)과 해당 구조체의 초기화 (클래스의 생성자에 해당하는 일)를 반드시 해당 디바이스 드라이버 개발자가 수행 해주어야 한다. 특히 파일 시스템 개발자들은 볼륨 오브젝트와 같은 스토리지와 관련된 구조체들의 할당과 초기화를 반드시 책임지고 수행 해주어야 한다.

 

계층구조 형태의 드라이버(Layered Drivers)

IO 매니저는 레이어드 커널 모드 드라이버를 지원한다. 대부분의 커널 개발자가 익히 알고 있는 계층구조를 생각 하면 된다. 계층구조의 각각의 드라이버들은 IRP를 전달 받고 이를 처리 한 뒤 자신 보다 하위에 존재하는 드라이버에게 이를 다시 전달 하는 구조이다. 이러한 범용적인 레이어드 아키텍처는 유지보수를 편리하게 하며 각 콤포넌트의 코히런스(Coherence)를 높여주는데 큰 역할을 한다. 파일 시스템 드라이버 개발자의 위치는 하드웨어로부터 어느 정도 거리를 두고 있기 때문에 대부분 I/O 매니저나 이전 칼럼에서 소개 된 캐시 매니저를 통한 커뮤니케이션이 더 많다. 하지만 특정 혼합형 파일 시스템이나 특정 디바이스들을 지원 해야 하는 경우 파일 시스템 드라이버 개발자 또한 하드웨어를 조작해야 하는 하위 디바이스 드라이버를 개발 해야 할 수 있다. 레이어드 아키텍처를 기반으로 하는 NT의 드라이버 모델은 앞서 말한 바와 같이 특정한 기능을 추가 해야 하는 경우에 매우 많은 이익을 디자이너에 제공한다. 이러한 특징은 중간 계층에 존재 하는 인터미디어트(Intermediate) 드라이버나 필터(Filter)드라이버 개발을 용의 하게 하며 이러한 각각의 레이어어드 드라이버 추가 방식의 설계는 때때로 기능의 추가성 뿐만 아니라 각 레이어에 독립적인 유닛 테스트, 디버깅, 유지보수를 쉽게 해준다.

 

Asynchronous I/O

NT IO 매니저는 비동기화 IO를 지원한다. 이는 커널 스레드의 드라이버가 이전에 요청한 입출력 요청을 완전히 마무리 하지 않았더라도 연속적으로 다른 입출력 요청을 하는 것을 허락 한다. 이러한 입출력 방식은 매우 고전적인 입출력 처리의 병렬성을 확보 해주는 것이긴 하지만, 이를 지원 하지 못하는 경우 순서상 이전 입출력이 끝나기 전에는 그대로 스레드가 계속 기다려야 하는 단점을 가지게 된다. 동기와 비동기 방식의 입출력은 가장 느린 디바이스 중에 하나인 스토리지를 사용 함에 있어서 매우 중요한 IO 처리 방식이며 나중에 완료 루틴에서 이를 처리 해주기 위해서는 이 둘의 개념 차이를 완전히 이해하고 있어야 한다.

 

< 그림 1, 비동기와 동기식 동작의 차이>

 

그림 1은 이 둘의 개념 차이를 쉽게 이해하기 위해서 시간에 따른 동기와 비동기간의 입출력 처리 방식의 차이를 나타낸다. 그림 1에서 관찰 할 수 있듯이 비동기 IO를 사용하는 스레드는 IO 서비스와 CPU를 사용하는 컴퓨팅 서비스를 동시에 처리 할 수 있도록 하여 높은 퍼포먼스를 얻도록 도와 준다.

 

선점과 인터럽트에 의한 제어(Preemptive and interruptible)

I/O 서브 시스템은 선점이 가능하며, 인터럽트에 의해서 언제나 제어권의 변경이 가능하다. 이것을 이해하는 것은 파일 시스템 개발자뿐만 아니라 모든 커널 모드 드라이버 개발자에게 있어서 매우 중요하다. 물론 선점에 대한 이야기와 인터럽트가 가능하다는 것에 대한 이야기는 전반적으로 매우 흔하디 흔한 이야기이므로 이의 컨셉을 다룬다기 보다는 실제 IO매니저와 연결하여 이야기 해보도록 하자. 모든 커널 스레드는 시스템이 정의 해놓은 인터럽트 리퀘스트 레벨(Interrupt Request Level, 이하 IRQL로 언급함)이라는 것을 가지고 있다. 각각의 IRQL은 Windows NT에 의해 인터럽트 벡터에 32개 이 다른 IRQL 들로 정의되어있다. IRQL은 말 그대로 인터럽트를 발생 했을 때 (다시 말해 요청(Request)이 있을 때) 해당 스레드의 IRQL보다 높은 IRQL을 가진 스레드가 존재 한다면 이때의 우선순위를 결정해주는 것으로 높은 IRQL을 가진 스레드가 실행 된다. 인터럽트가 일어나면 해당 스레드는 인터럽트 서비스 루틴으로 점프하여 인터럽트와 관련된 작업을 수행 하는 것이 일반적인 형태이다. IRQL은 코드에서 가장 낮은 값은 단순히 0을 PASSIVE_LEVEL로 정의해 둔 것에 지나지 않는다. 이 PASSIVE_LEVEL은 모든 사용자 스레드와 시스템 스레드의 기본 레벨이고 가장 높은 값은 31을 정의(C에서 define)해 놓은 HIGH_LEVEL이다. 대부분의 파일 시스템 드라이버 개발자는 자신의 드라이버가 PASSIVE_LEVEL에서 동작하는 것을 인지 하고 있을 것이다. 하지만 앞에서 언급 했던 것처럼 특정 혼합형 파일 시스템 개발이나 디바이스를 지원 해야 한다고 하면 하위 디바이스 드라이버 루틴과 같은 형태의 IRQL을 다루어야 할 경우가 존재 한다. (물론 일반적인 경우에도 인위적으로 IRQL을 높이는 경우가 있는데, 그렇게 흔한 경우는 아니며 권장 사항도 아니다.)

다시 IO 서브 시스템으로 돌아가서 IO 서브시스템이 인터럽트가 가능하기 때문에 파일 시스템 개발자는 자신의 데이터가 다른 레벨의 IRQL을 가진 드라이버에게 오용되지 않도록 반드시 적절한 동기화와 프로텍션(Protection) 기능을 가져야 한다. 앞서 말했듯이 레이어드 아키텍처에서는 자신의 하위에 있는 드라이버가 이론적으로는 무한히 확장 될 수 있고, 이들에게 IO 매니저를 통하여 전달되는 IRP나 데이터들은 IO 서브시스템이 인터럽트 가능함으로 보다 높은 IRQL의 드라이버에게 오용 될 수 있는 가능성을 충분히 가지고 있다. (본 절에서 동기화 도구를 설명하지는 않을 것이다) IO 서브시스템이 선점이 가능 하다는 것은 어떤 식으로 해석 할 수 있을까? Window NT 운영체제는 스레드의 실행 우선순위를 정의 해놓았다. IRQL에서 설명과 같이 사용자 레벨과 시스템 레벨의 스레드들은 일반적으로 낮은 우선 순위가 할당 되어 있다. NT 커널은 스케줄 할 때, 각 스레드마다 할당된 이 우선순위를 확인 하여 높은 우선 순위를 가진 코드가 낮은 우선 순위의 코드가 실행 되고 있는 것은 선점하고 자신이 실행 할 수 있도록 하는 것을 허락한다. 스레드가 선점 된다는 사실은 개발자로 하여금 반드시 데이터 컨시스턴시를 확보 할 수 있도록 동기화 매커니즘을 권유한다는 것으로 다시 해석 할 수 있다. 일부 UNIX버전의 운영체제나 Windows 구식 (3.1) 버전에서는 이를 지원하지 않지만 선점형 프로세스는 요즘 매우 일반적으로 적용되는 컨셉이다. 특히 Windows NT에서는 개발자를 위해서 어떠한 자동으로 이를 보호 해주는 도구를 사용 하고 있지 않으므로 개발자가 공용의 메모리 자원을 접근할 때는 각별한 주위가 요구 된다.

 

만약에 파일 시스템 드라이버 개발자가 개발 하고 있는 드라이버가 DISPATCH_LEVEL이나 그 이하의 IRQL에서 동작 하고 한 개 이상, 커널의 동기화 리소스에 접근 해야 한다면 이러한 리소스를 획득할 때 사용하는 락(Lock)의 순서에 매우 신경 써야 한다. 예를 들어 FAST_MUTEX와 같은 구조체의 인스턴스인 커널 동기화 리소스 imaso_fast_mutex1과 imaso_fast_mutex2가 있다고 가정 해보자. 이 둘을 우리가 앞서 말한 IRQL 레벨 이하에서 조작 한다면 반드시 모든 코드에서 다시 말해 모든 스레드에서 획일적인 순서로 이들을 획득하고 풀어줘야 한다. 다시 imaso_fast_mutex1, imaso_fast_mutex2의 순서를 모든 코드에서 지켜 준다거나 혹은 그 반대로 하더라도 순서를 지켜 줘야 한다. 이러한 락 획득 순서를 지켜야 하는 이유는 흔히 이야기 하는 데드락(Dead lock)상황에 빠지기 쉽기 때문이다.

< 그림 2, 뮤텍스 획득 시 데드락 예제>

 

자신의 커널 스레드 1이 imaso_fast_mutex1의 락을 쥐고 있으면서, 다른 imaso_fast_mutex2를 쥐려고 하는 중에 컨텍스트 스위칭이 발생하고 이러한 락 순서를 지키지 않은 다른 커널 스레드 2가 imaso_fast_mutex2리소스를 획득하고 imaso_fast_mutex1을 쥐려고 한다면 커널 스레드2는 절대 imaso_fast_mutex1을 획득 할 수 없다. 스레드 2는 스레드 1이 imaso_fast_mutex1이 풀리기를 기다리고 있게 되고 스레드 1은 스레드 2가 imaso_fast_mutex2를 풀어주길 기다리게 되어 데드락 상황에 빠지게 된다.

 

하드웨어 독립적인 구성(Portable and hardware independent)

IO 서브시스템의 또 다른 특징은 특정 하드웨어와 관계 없이 동작 하도록 설계 되어 있으며 이를 위해서 최대한 호환성을 확보하고 있다. 이는 레이어드 아키텍처링 디자인기법의 가장 기본이 되는 것으로 가장 하위에 하드웨어를 직접 핸들링 하는 부분을 HAL(Hardware Abstraction Layer)라는 계층을 만들어서 이에게 전담하게 하고 그 상위에 커널 드라이버들에게는 동일한 API를 제공하게 함으로써 하드웨어에 독립적 구성을 가능하게 하였다. NT 커널은 대부분 C언어로 작성 되어 있는데 부분적으로 필요한 부분들은 어셈블러로 구성 되어 있다. 파일 시스템을 개발 하면서 기본적으로는 모두 C언어로 이를 작성 해야 하고, 가급적이면 어셈블러는 지양해야 하는데, 이것은 각 플랫폼마다 다른 ISA때문에 NT 커널의 기본 설계 방침인 하드웨어 독립적 모듈에 위반 되기 때문이다. (사실, 그전에 특정 환경에서만 돌아가는 코드를 회사에서 짜라고 하지도 않을 것이다.) 물론 C이외에 C++과 같은 서드파티의 라이브러리를 사용하여 객체지향적으로 구현 할 수도 있고 Object C와 같은 방법으로 파일 시스템을 구현 할 수도 있다. (이 부분은 필자의 블로그에 "Why do not use C++ in kernel mode driver?" 글을 통해서 확인 해볼 수 있다.)

 

마이크로 프로세서에 대한 독립성

IO 서브 시스템은 멀티프로세서 환경에서도 안전하도록 구현 되어 있다. 당연히 그렇게 해야 하겠지만 여기에 대한 이슈는 파일 시스템 개발자를 정말 난처하게 한다. 일반적으로 멀티 프로세서 환경의 커널 코드의 코드나 드라이버들은 데이터 컨시스턴시 문제를 피해가도록 유의해서 커널 동기화 도구들을 사용 해야 한다. 예를 들어 단일 프로세서의 환경에서는 인터럽트 시 다른 프로세스가 인터럽트를 쓰지 못 하도록 인터럽트를 비활성화 모드로 만들어서 데이터 컨시스턴시를 확보하는데 만약 그 머신이 멀티 프로세서 환경이 된다면 이러한 인터럽트 비활성화 방법으로 컨시스턴시를 확보 할 수 없다는 것이다. 다른 프로세서의 의한 프로세스가 인터럽트에 대하여 진입하는 것을 막는 것이 아니기 때문이다. 이를 위해서 우리는 스핀락과 같은 동기화 도구를 사용 하게 되는데 데이터 컨시스턴시에 대한 더 자세한 내용은 John henessy 아저씨의 "Memory Consistency and Event Ordering in Scalable Shared-Memory Multiprocessors" 를 반드시 한번 읽어 보기 바란다. 개인적으로 필자도 메모리 컨시스턴시가 너무나 이해하기 어려웠는고 특정 바이러스 개발 회사나 필터 드라이버 컨설턴트나 개발자를 통해서도 이러한 메모리 컨세스턴시 모델을 명확하게 설명 해주는 사람을 만나지 못했다. 컴퓨터 아키텍처 바이블의 저자인 John henessy의 이 논문은 메모리 컨시스턴시에 대해서 어느 누구보다 명확하게 설명 해 준다.(언제 기회가 되면 이에 대한 소개를 할 수 있도록 하겠다)

 

모듈 기반 구성

NT IO 서브 시스템은 어느 기타 커널 드라이버와 마찬가지로 쉽게 교체 할 수 있는 형태의 모듈 기반의 소프트웨어이다. 필자가 알기로는 리눅스의 경우 2.6.12 이후 버전부터 이러한 커널 모듈 기능이 제외 되었는데, 윈도우의 경우는 커널 코드 자체를 공개 할 수 없기 때문에 사실 모듈에 대한 기능이 장점이라기 보다는 필수에 가깝다. 그럼 여기서 굳이 모든 커널 드라이버가 모듈 기반인데 IO 서브 시스템을 언급하면서 모듈을 장점으로 이야기 하는 이유는 뭘까? (더욱이 I/O 서브 시스템은 모든 커널 드라이버와 관계 하기 때문에 모듈이지만 교환이 힘들다.) 대부분의 레이어드 아키텍처를 기반으로 하는 임베디드 소프트웨어를 개발 해본 사람들은 알겠지만 각 레이어간에 통신을 위한 아규먼트 전달은 정말 유지 보수 입장에서 치명적이다. 각 레이어들의 기능이 추가 되거나 변경 될 때 마다 파라미터 정의는 지저분해지고, 코드는 안 좋은 냄새로 가득 차게 된다. 커널 드라이버를 개발 하게 되면 가장 놀라운 점이 IO 서브 시스템이 이들을 최대한 깨끗하게 관리 해줄려고 노력 한다는 것이다. 물론 추상화 관점에서 너무 코드를 감춤으로써 전혀 직관적이지 않은 부분들도 있지만 모든 커널 드라이버들이 똑 같은 디스패치 루틴과 아규먼트 전달을 통해 정상적인 레이어드 아키텍처 소프트웨어 구조를 간직 하면서 모듈로서의 기능을 자유롭게 할 수 있는 것은 모두 IRP (I/O Request Packet)과 더불어 이를 관장하는 I/O 서브 시스템 때문이다.

 

동적 하드웨어 설정 구성

드디어 마지막 단계에 왔다. IO 서브 시스템의 모든 콤포넌트들은 동적으로 설정이 가능하다. IO 서브시스템은 주변장치나 하드웨어가 변경 되더라도 앞서 언급된 HAL을 통하여 실행 시간에 이를 동적으로 설정 하여 사용 할 수 있게 하는데 이는 윈도우 부팅 때 결정 된다. 부팅 시점에 관련된 연결된 디바이스들을 탐색하고 이 디바이스에 필요한 콤포넌트의 개인적 자료 구조들을 초기화 한다. 이러한 절차는 OS 커널에 설정 파일들을 하드 코딩 하지 않도록 하여 좀더 유연한 동작을 지원 한다. 사실 MS의 말처럼 윈도우가 완벽한 동적 설정 기능을 제공하지는 않는다. 커널 드라이버를 한번 정도 윈도우에 올려본 사람이라면, 부팅 시 자신의 디바이스를 컨택하고 결정하기 전에 자신의 코드가 커널 메모리 영역에 올라가기 위해 레지스터를 몇 번씩 뒤집고 파야 하는지 경험 해보았을 것이다. 이러한 레지스트리를 통해서 IO 서브 시스템은 동적으로 디바이스마다 적절한 설정을 부여 할 수 있고, 또한 그 디바이스에게 맞는 커널 드라이버를 자신의 커널 영역에 올려 놓거나 내려 놓는 것을 수행 할 수 있는 것이다. 따라서 파일 시스템 개발자들도 자신의 드라이버가 컴파일타임에 모든 설정을 결정할 수 있도록 제작 하는 것보다는 동적 설정이 가능하도록 구현 하는 것이 더욱 선호 된다.

 

프로세스와 스레드 컨텍스트 (Process and Thread Context)

IRP를 언급하면서 잠시 이야기하였지만, IO 서브시스템은 대부분의 시스템 드라이버 또는 콤포넌트와 상호작용을 유도한다. 따라서 IO 서브시스템의 세부사항과 내부 구조를 파헤쳐 보기 전에 우리는 프로세스와 스레드 컨텍스트에 대해서 좀더 명확히 할 필요가 있다. 이는 파일 시스템 드라이버를 성공적으로 개발하고 설계 하기 위해서도 이 둘간의 정의를 확실히 해야 한다. 다만, 우리가 여기서 다루려는 프로세스와 스레드 켄텍스트들은 범용적은 운영체제의 내용을 다루는 것이 아니라 상세한 데이터 구조와 내부 스킴들을 살펴 보고 이 특징을 이해하고자 하는 것이니 스레드와 프로세스의 일반적 정의를 이해하지 못하고 있다면 웹이나 운영체제 교재등을 통해서 이해하는 것이 더 바람 직 할 것이라 생각 한다.

모든 프로세스는 각 프로세스마다 유일한 프로세스 오브젝트(Process Object) 자료 구조와 실행 컨텍스트(Execution Context)를 가지고 있다. 실행 컨텍스트 자료구조는 가상 주소 공간(Virtual Address Space)와 각 프로세스에 할당 되어 있는 자원들의 집합, 그리고 그 프로세스의 포함되는 스레드들로 구성된다. 여기서 이야기 하는 자원이란 프로세스가 사용하던 파일 핸들이나, 프로세스에 의해서 생성, 사용된 동기화 오브젝트들을 이야기 한다. 운영체제마다 프로세스와 스레드간의 매핑 방식이 다르지만, 윈도우의 경우는 최소한 한 개의 프로세스 안에는 하나 이상의 스레드가 존재 해야 한다. 또한 재미 있는 사실은 스케줄링 단위이다. 윈도우 NT에서는 스케줄링 단위가 프로세스가 아니라 스레드 단위라는 것을 명심해야 한다.

윈도우 NT에서는 각 프로세스들이 소유하는 자료들을 담아 두기 위해서 프로세스 환경 블록(Process Environment Block, 가급적 한글 용어보다 정의된 용어 그대로를 사용 하기 바란다, 여기서는 이하 PEB로 언급 할 것이다.) 이 PEB 구조체에는 해당 프로세스의 바이너리 이미지의 시작 주소, 프로세스 단위의 동기화 도구들과 같은 프로세스의 전역 데이터들이 담겨 있다. 또한 윈도우 NT에서는 윈도우 NT 오브젝트에 접근 할 때 사용되는 토큰(Token)을 이 PEB에 담아 둔다. PEB 이외에 또 다른 중요한 구조체가 있는데 그것은 오브젝트 테이블이다. 오브젝트 테이블은 새로운 오브젝트 프로세스가 생성 될 때마다 새로 생성 되는데, 부모가 존재 하고 OJB_INHERIT 라는 오브젝트 상속 속성을 가지고 있는 프로세스의 오브젝트 테이블은 부모의 오브젝트 테이블을 복사하여 가지고 있고, 그렇지 않은 경우는 빈 테이블을 유지 하고 있다. 이 오브젝트 테이블을 통해서 그 테이블의 엑세스 토큰과 프로세스의 우선순위가 결정되기 때문에 부모에게 상속 받은 경우 이러한 정보들을 그대로 가져 오게 된다.

윈도우 NT 커널에서 사용되는 스레드 오브젝트는 앞서 언급 한대로 스케줄의 기본 단위 일뿐만 아니라 프로그램 실행의 단위이다. 하나의 프로세스는 여러 개의 스레드를 가질 수 있으므로 모든 스레드는 자신에게 해당 하는 프로세스 오브젝트를 공유 할 수 있다. 이러한 이유로, 스레드는 프로세스 오브젝트 테이블에 열려 있는 모든 리소스들을 접근 할 수 있다. 언급된 오브젝트 테이블, 그리고 각 오브젝트들이 서로의 데이터에 접근 하는 것들이 오브젝트 매니저에 의해서 관리 된다.

 

우리가 익히 알고 있듯이 스레드는 프로그램 카운터와 해당 레지스터의 몇몇 만을 가지고 있는 것으로 메모리 주소를 그대로 공유하고 있기 때문에 그 실행의 특성이 운영체제가 동작하고 있는 기반 프로세서 마다 조금씩 다를 수 있다. 예를 들어 단일 프로세스에서는 각 스레드가 반드시 한번에 하나만 실행 될 수 있지만, 멀티 프로세스의 경우에서는 하나의 프로세스 내에 다른 스레드들끼리도 동시에 실행 될 수 있다.

 

실행 컨텍스트(Execution Context)

현업에서 파일 시스템 드라이버 툴킷(FSD Toolkit)과 같은 상용화 도구들을 통해서 자신이 원하는 작업을 원활히 할 수 있고, 자신이 작성한 코드의 흐름에 따라 파일 시스템의 동작을 충분히 예상할 수 있음에도 불구 하고 우리가 스레드, 프로세스를 다시 정의하고 실행 컨텍스트를 알아 보는 이유는 무엇일까? 이 질문에 대해서 먼저 생각 해봐야 할 것은 우리의 코드는 어떤 스레드에 의해서 실행 될 것인가 하는 것이다. 필자가 제일 처음 드라이버를 겪었을 때 가장 이해가 되지 않는 부분이 내가 짠 코드를 실행 하고 있는 스레드가 계속 바뀐다는 것이다. (조금 부끄러운 이야기이지만 처음에는 이러한 부분도 몰랐지만, 차후에 메모리 복사를 수행해야 할 일이 생겼을 때 주소 공간이 변하는 것을 보게 된 뒤로 내가 짠 코드를 수행하는 실행 자원이 변경 될 수 있다는 것을 인지 했다.) 리눅스와 달리 대부분의 윈도우 드라이버는 커널에 공유 이미지 형태로 삽입 또는 마운트 된다. 따라서 우리가 짠 코드는 이를 실행해주는 스레드에 의존하게 된다. 특히 파일 시스템 드라이버 개발자의 코드는 입출력을 다룰 때는 유저 모드의 스레드, 페이지 폴트와 같은 문제로 인해 타 커널 오브젝트에 의해서 호출 되는 경우, 일부 시스템 워크의 스레드, 또는 특정 작업을 위해 내가 생성한 시스템 모드 스레드 등, 여러 가지 스레드에서 실행 될 수 있다. 이는 다시 말해, 우리가 작성한 코드가 수행 될 때의 컨텍스트를 작성 당시에 정확히 결정 할 수 없으므로 관련된 작업을 수행할 때는 주의 해야 한다는 것이다. 이를 위해서 파일 시스템 드라이버 개발자는 실행 컨텍스트를 이해하고 드라이버를 설계 해야 한다. 다행히도 우리 코드를 수행 하는 실행 컨텍스트는 다음 4개 중에 하나이다.

 

  1. 시스템 서비스를 요청한 유저 모드의 컨텍스트.
  2. 독자가 생성한 워커 스레드(Worker Thread)의 컨텍스트
  3. 다른 커널 모드 컴포넌트가 생성한 워커 스레드의 컨텍스트
  4. I/O 매니저에 의해 생성된 시스템 워커 스레드의 컨텍스트
  5. 임의의 스레드의 컨텍스트

 

유저 모드 컨텍스트 기반.

상위 드라이버들은 이런 경우가 없지만, 파일 시스템 드라이버 개발자의 코드에는 비일비재한 일이다. 읽기 동작과 같이 시스템 서비스를 요청한 유저 모드의 스레드에 의해 파일 시스템 드라이버의 코드가 호출 되는 경우, 우리는 커널 주소 공간 상위 2GB뿐만 아니라 유저 모드의 주소 공간 하위 2GB에도 접근 할 수 있다. 중요한 것은 우리가 접근 할 수 있는 하위 2GB는 유저 모드 스레드의 컨텍스트가 보유하고 있는 가상 주소(Virtual Address) 이다. 파일 시스템 드라이버 개발자에게는 입출력 서비스 수행 시 사용자가 요청한 데이터를 읽어 오거나 써야 하는 경우가 종종 있는데, 유저 모드에서 수행 되고 있다는 것을 확신 할 수 있는 코드에서는 유저 모드 주소공간을 사용하여 직접 접근 할 수 있다(물론, 대부분의 경우에 이러한 직접 접근은 위험 할 수 있다. 이는 임의 스레드 컨텍스트 부분을 참고하도록 하자.)

 

워커 스레드의 컨텍스트 기반.

파일 시스템 개발자는 드라이버 개발을 하면서 때때로 다른 콤포넌트 (이를 테면 캐시 매니저나 IO 서브시스템)들과 요청 받은 동작을 전달 하거나 받기 위해서 반드시 유저 모드가 아닌 시스템 스레드에서 수행 해야 하는 경우가 있다. 앞서 말한 이유에서 우리는 우리가 작성하는 코드가 실행되는 컨텍스트가 시스템 스레드가 아닐 가능성이 충분히 존재 하기 때문에 우리가 직접 시스템 스레드를 생성하여 이를 수행 하는 경우가 존재한다. 이러한 스레드를 우리가 통칭 워커(Woker)라고 칭하며 PsCreateSystemThread()와 같은 시스템 서비스에 의해서 생성 된다.

 

I/O 매니저에 의해 생성된 시스템 워커 스레드 컨텍스트.

사실 이 경우는 대부분 우리가 개발하게 되는 파일 시스템 드라이버 하위 단에서 겪게 되는 일 중에 하나이다. (대부분의 IO 매니저의 시스템 스레드는 파일 시스템 드라이버에 의해서 구현 되기 때문이다.) 하지만 파일 시스템 상위의 필터 드라이버(Filter driver)가 시스템 스레드를 생성하여 IO 매니저를 통하여 이를 전달하면 파일 시스템 드라이버 또한 비슷한 상황을 겪을 수 있다. IO 매니저가 요청을 시스템 스레드에서 수행 하게 되는 경우 그것을 구현한 하위 모든 스레드의 디스패치 루틴(Dispatch routine)은 모두 시스템 스레드에서 수행 하게 된다. 앞서 말한 이유와 비슷하게 이기도 가상 주소 공간의 변경에 대한 문제가 있다. 유저 모드에서 요청된 서비스가 IO 매니저의 시스템 스레드로 변환 되어 호출 되는 경우 (이를 일반적으로 IO 포스팅이라고 한다) 가상 주소 공간은 엄연히 다르다. 사용자가 만든 어플리케이션의 프로세스와 시스템이 사용하고 있는 프로세스의 컨텍스트와 이에 따른 오브젝트 테이블, 가상 주소 공간 그리고 PEB의 자료 구조는 엄격히 다를 수 밖에 없다. 따라서 이러한 경우는 유저 모드의 하위 주소 공간 2GB에 접근 하는 것이 바람직하지 않다.

 

임의의 스레드 컨텍스트

IRP를 서비스 해야 하는 경우에 대부분 우리는 이를 우리가 직접 처리하고 바로 상위 드라이버에게 완료를 통지하거나 또는 IRP를 받아서 큐잉(Queueing)한다. 특히 파일 시스템 드라이버에서는 하위 데이터 쓰기 등이 블록 될 수 있기 때문에 성능을 위해서 비동기식 입출력 방식을 사용 하게 되는데 이때 IRP등을 큐잉 한다. 우리가 여기서 상기해야 하는 것은 IRP를 서비스 하는 경우 입출력에 필요한 대부분의 정보들을 IRP를 통해서 저장하고 불러 오게 된다.

문제는 여기서 발생 한다. 정확히 임의(Arbitrary)라는 용어도 여기서 유래 되었다. IRP를 큐잉 하는 시점에 스레드가 유저 모드 A 스레드였다고 하면 완료될 때 스레드는 유저 모드 B일 수도 있고, 시스템 워커 스레드일 수도 있다. 일반적으로 비동기적 입출력은 현재 IO서비스를 스토리지나 하위 드라이버에게 전달 하고 나서 기다린다. 이후 하드웨어가 우리로부터 요청 받은 데이터들을 모두 처리하고 나서 인터럽트를 발생 시켜 주어 이 완료를 통지하게 되는데, 이때 이를 받아 들이는 것이 인터럽트 서비스 루틴(ISR, Interrupt Service Routine)이다. 그렇다면 인터럽트 서비스 루틴이 불리는 시점을 정확히 정의 할 수 있을까? 하드웨어가 소프트웨어의 요청을 완료 했다는 것을 비동기적으로 특정 시점에 알리고자 하는 것이 인터럽트이고 이를 받는 것이 ISR인데 이를 정확히 예측한다는 것은 당연히 불가능하다. 따라서 이때 어떤 스레드가 이 ISR를 처리 하게 될 지 모는 것이고 이때의 IRP에 저장된 가상 주소 공간, 또는 컨텍스트는 우리가 IRP를 처음 저장 할 때의 그것과 다를 수 밖에 없다.

 

스레드 컨텍스트 정리

결국 우리가 작성한 코드는 앞에서 언급한 5가지 스레드 컨텍스트에서 반드시 실행 된다. 이를 이해하지 않고 사용 한다면 유효하지 않은 주소에 접근 하거나 리소스 오브젝트에 접근 할 수 있다. 왜냐하면 가상 주소 공간과 리소스 오브젝트들은 해당 스레드가 소속된 프로세스마다 각기 다른 값을 가지고 있기 때문에 이를 기반으로 하는 동작들은 모두 에러가 발생 할 수 있다. 단순히 코드상에서는 볼 수 없기 때문에 가끔 어플리케이션 개발자들이 직접 파일 시스템 드라이버를 건드리거나 커널 모드 드라이버를 개발 할 때 혼란을 겪는 것을 흔히 볼 수 있다.

마무리 하기에 앞서, 실제 스레드의 이해가 부족할 경우 겪게 되는 몇 가지 예로 이해를 돕고 정리를 하도록 하자. 만약 여러분의 코드가 특정 파일 스트림을 열어야 하는 경우 드라이버 엔트리(Driver Entry)에서 이를 열어서 핸들을 저장 해두었다고 하자. 드라이버 엔트리의 경우 시스템 스레드 기반에서 사용 되기 때문에 만약 여러분이 이를 잘 이해하고서 시스템 워커 스레드를 생성하여 앞서 저장된 핸들을 사용 한다면 전혀 문제가 없지만, 만약 이해를 하지 못하고 아무 곳에서 이를 열 경우 유저 모드 스레드에서 실행 된다면 코드의 문제는 없어 보이지만 실패 하게 된다. 반대로 유저 모드의 요청을 수행 하던 도중 생성한 리소스 오브젝트 리소스를 이후 사용 하기 위해 자신만의 자료 구조에 저장 해두었다고 생각 해보자. 이러한 경우에도 자신이 동작하는 코드가 어떤 경우나 부분에서 유저 모드 스레드에서 사용 되는지 이해하고 있으면 이를 유용하게 사용 할 수 있지만, 시스템 워커 스레드에서 이를 접근 하려 한다면 심각한 경우 커널 패닉을 겪을 수 있다. 더 심각한 경우는 사용자 버퍼 주소 공간을 접근 하는 경우인데, 이 경우는 파일 시스템 드라이버 개발자에는 매우 빈번한 일이니 이후 칼럼에서 좀 더 자세히 다루도록 하자.

 

실행 컨텍스트 아래서의 오브젝트 다루기

일반적으로 윈도우 NT 커널 모드에서 실행되는 콤포넌트에 의해 생성되는 모든 오브젝트들은 두 가지 방법으로 참조된다. 첫 번째 방법은 오브젝트를 생성 할 당시에 반환 되는 핸들 구조체의 정보를 이용 하는 경우이고 다른 방법은 오브젝트의 포인터를 이용 하는 방법이다. 이 두 가지 오브젝트를 다루는 방법을 사용함에 있어서 커널 모드 실행 시, 자신의 코드가 실행되는 컨텍스트에 대한 이해를 하지 못하면 잘못된 오브젝트나 유효하지 않은 주소를 접근하게 되어 시스템 결함을 야기 할 수 있다. 만약 우리가 오브젝트의 주소를 사용하여 오브젝트를 거드리는 경우를 생각 해보자. 앞서 언급 하였듯이 오브젝트들이 커널 모드로부터 생성 되었으므로 이 주소를 사용 하는 것은 언제나 유효하다. 다시 말해서 커널 모드 생성시 할당된 가상 주소 공간이 파일 시스템 드라이버 개발자가 접근하게 되는 주소 공간과 차이가 없기 때문에 사상에 의해서 표현되는 물리 주소 공간 또한 동일하게 사용 할 수 있다. 만약 어플리케이션에서 자주 사용 하듯이 오브젝트를 핸들 기반으로 접근 하려고 한다면 이는 자신의 실행 컨텍스트가 어떤 상황에서 동작 중 인지에 따라 상황이 많이 다를 수 있다.

한 가지 더 중요한 점은 오브젝트는 윈도우 NT의 오브젝트 매니저가 그 인스턴스를 관리 한다는 것이다. 이를 관리 하는 방법으로는 레퍼런스(Reference) 카운터를 통해서 현재 관리 되고 있는 오브젝트의 인스턴스가 몇 개의 커널모드 컴포넌트로부터 사용 되고 있는 지를 확인 하도록 만들어져 있다. 따라서 개발자가 처음 오브젝트를 ObReferenceObjectHandle()로 오브젝트를 참조 하게 되면 레퍼런스 카운터는 자동으로 1개가 되게 된다. (우리가 처음 접근 한다는 가정하에) 해당 ObReferenceObjectHandle() 함수는 DDK에서 확인 해볼 수 있다. 이러한 레퍼런스가 없다면 우리가 해당 오브젝트에 접근 하기 전에 오브젝트 매니저가 해당 주소를 해제 할 수 있고, 그렇게 되면 우리가 가지고 있는 오브젝트 핸들은 무효 주소 참조 포인터(Dangling pointer)가 되어 시스템 결함을 가져 올 수 있다. 반대의 경우로 핸들을 사용 하고 나면 커널 모드 개발자들은 ZwClose()를 통하여 핸들을 닫고 이 동작은 레퍼런스 카운터를 감소 시켜 오브젝트 매니저로 하여금 더 이상 사용하지 않는 오브젝트에 대해서 메모리를 해제 시킬 수 있게 한다. 물론 DDK의 ObDeferenceObject()을 통해서도 레퍼런스 카운터를 감소 시킬 수 있다. 따라서 오브젝트를 사용 하기 위해서는 핸들을 생성하고 쓰는 방법은 일반적인 오브젝트 핸들을 이용 하는 것과 같이 생성, 삭제 하지만, 이에 접근 할 때에는 전역 주소 공간(사실상 멀티코어환경을 고려 했을 때 이러한 전역 주소 공간 보다는 IRP를 통한 주소 저장 방식이 더욱 바람직하다.)에 오브젝트의 주소를 저장해두고 이를 사용 하는 것이 일반적인 방법이다.

 

 

다음 칼럼에는

이번 칼럼에서는 IO 서브시스템의 설계 철학을 이해했고 파일 시스템 드라이버가 동작에 따르는 프로세스와 스레드에 대하여 알아 보았다. 특히 커널 모드가 수행되는 프로세스와 스레드는 대부분의 개발자가 겪는 혼란스러운 이슈 중에 하나 이므로 기반 지식이 없다면 반드시 한번 명확히 해놓는 것이 좋다고 생각한다. 다음 칼럼에서는 IO 서브시스템을 사용 함에 있어서 또 구성함에 있어서 사용되는 내부 자료 구조들을 살펴 보도록 하자.

 

References

Rejeev Nagar, "Windows NT File System Internals": A Developer Guide, O'Reilly 1998

P. B. Kruchten."The 4+1 View Model of architecture."

David Garlan and Mary Shaw January 1994 "An Introduction to Software Architecture"

'Drafts > Kernel mode' 카테고리의 다른 글

NT Virtual Memory Manager Overview  (0) 2009/12/13
Common Data Structures of NT I/O Manager  (0) 2009/09/30
IO Manager Design Concepts  (0) 2009/09/04
Extended NT Cache Interface and communication issues  (0) 2009/08/05
NT Cache Internal Structure  (0) 2009/06/20
NT Cache Manger Overview  (0) 2009/05/16