커널, 드라이버

디바이스 드라이버에서 3가지 버퍼 전달 방식

디버그정 2009. 11. 1. 22:27

 출처) http://blog.naver.com/khealin/61938919

아래는 그림 파일이 깨지므로 위의 첨부된 파일을 열면 된다.

이번 포스트에서는 애플리케이션과 드라이버 간의 데이터 전송 방법을 알아보도록 한다. 다음의 코드는 COM 포트로 데이터를 내보는 애플리케이션 함수의 일부분이다. 여기서 chBuffer는 애플리케이션 프로세스 내의 가상주소 공간에 존재한다. 이 쓰기 요청을 드라이버가 처리하기 위해서는 chBuffer에 접근할 수 있는 방법이 있어야 한다. 접근이야 아무때라도 가능하겠지만 그 유효성을 보장받을 수 없을 수도 있다. 드라이버는 대부분의 I/O 요청을  비동기적으로 처리하므로 쓰기 요청을 수행한 스레드의 컨텍스트에서 이 쓰기 요청을 수행할 수 없을 수도 있다. 그렇다면 드라이버는 어떤 메커니즘을 통해 유저 메모리 공간에 존재하는 chBuffer를 안전하고도 정확하게 접근하여 데이터를 COM 포트로 전송할 수 있을까? 이것이 앞으로 두 번의 포스트에 걸쳐 알아볼 사항이다.

 

CHAR chBuffer[128];

...

bRc = WriteFile( hCommPort,            // COM 포트에 대한 핸들
                      chBuffer,                 // COM
포트로 보내고자하는 데이터 버퍼
                      sizeof(chBuffer),      //
버퍼 크기
                     &dwNumWritten,        //
실제로 전송된 바이트 수
                     NULL);      

 

애플리케이션과 드라이버 간의 데이터 전송 방식은 다음의 세 가지가 존재한다. 어떤 방식을 사용할 지는 디바이스의 특성을 고려하여 드라이버 작성자가 결정한다.

   - Buffered I/O
   - Direct I/O
   - Neither I/O

이제 좀더 구체적으로 들어가서 이들 세 가지 방식은 크게 두 가지 범주의 윈도우 API( ReadFile(Ex), WriteFile(Ex) API DeviceIoControl API)를 고려해야 한다. 그 이유는 이들 두 범주의 API는 세 가지 데이터 전송 방식에 대하여 각각 서로 다른 방식으로 동작하기 때문이다.

 

ReadFile(Ex), WriteFile(Ex) API에서의 데이터 전송 메커니즘

 

다음의 코드는 AddDevice 루틴의 일부분이다. IoCreateDevice 함수를 사용하여 디바이스 오브젝트를 생성하고 생성한 디바이스 오브젝트의 Flags 필드에 사용하고자 하는 데이터 전송 방식을 명시하면 된다. 두 가지 이상의 방식을 동시에 조합하여 사용할 수는 없다.

 

PDEVICE_OBJECT fdo;
IoCreateDevice(
.., &fdo );

fdo->Flags |= DO_BUFFERED_IO;        
                    < or >
fdo->Flags |= DO_DIRECT_IO;
                    < or >
fdo->Flags |= 0;

앞서 언급했지만 디바이스 오브젝트는 I/O 동작의 대상이 될 수 있는 물리적 또는 논리적 디바이스를 나타낸다. 이 디바이스를 오픈하여

ReadFile 또는 WriteFile함수로써 I/O를 수행하면 I/O 관리자는 fdo->Flags에 명시된 방식대로 유저 공간의 가상 주소에 존재하는 데이터 버퍼를 적절한 방식으로 드라이버가 접근할 수 있게 해준다.

 

DO_BUFFERED_IO 방식

 

그림 17.1 DO_BUFFERED_IO 방식에 대한 개념이 잘 나타나 있다.

 

 

DO_BUFFERED_IO를 명시한 디바이스 오브젝트에 대한 WriteFile 함수 호출이 있을 경우 I/O 관리자는 유저 버퍼 크기( Length)와 동일한 커널 버퍼를 할당하여 유저 버퍼 데이터를 커널 버퍼로 복사한다. 그리고 IRP의 헤더 부분의 AssociatedIrp.SystemBuffer 필드가 커널 버퍼 주소를 가리키게 한다. 그림 17.1에서는 스택 로케이션의 크기가 3이며 중간의 펑션 드라이버가 I/O를 수행한다고 가정한다. 그리고 버퍼의 크기(Length 인자)는 현재 스택 로케이션의 Parameters.Write.Length로 통해 넘어온다. 이제 드라이버는 WriteFile을 요청한 스레드의 컨텍스가 아니더라도 WriteFile I/O 요청에 대한 IRP 명세서와 커널 버퍼(데이터까지 복사되어 있다)를 이용하여 적절한 시점에 I/O를 수행할 수 있게 되었다. 그리고 I/O를 수행하여 IRP를 완료할 때 드라이버는 실제로 디바이스로 전송한(Write 요청이므로) 데이터 수를 IoStatus.Information 기록한다. 그리고 이 값은 IoCompleteRequest를 호출하여 IRP를 완료할 때 애플리케이션의 WriteFile 함수의 BytesWritten 인자로 복사된다. 앞서 설명한 APC 메커니즘으로 전달된다. BUFFERED 방식의 단점은 커널 메모리가 추가적으로 할당되어야 하고 데이터 복사도 이루어져야 한다는 것이다. 따라서 디바이스가 고속의 대용량 데이터 전송을 빈번하게 한다면 이 방식은 적당하지 않을 것이다.

 

여기서 설명은 WriteFile을 예로 하였지만 ReadFile도 동일하게 동작한다. , 데이터가 커널 버퍼(드라이버가 디바이스로부터 읽어 둔 버퍼)에서 유저 공간의 버퍼로 복사된다는 점만 다를뿐이다.

 

DO_DIRECT_IO

 

이 방식을 설명하기 위해서는 먼저 MDL(Memory Descriptor List)에 대하여 이해를 해야 한다.

MDL은 데이터 버퍼(가상 메모리 상에서는 연속적이지만 물리적으로는 연속적일 필요는 없다)를 기술할 수 있는 구조체로서 주로 애플리케이션의 가상 주소 공간에 존재하는 버퍼에 대응하는 물리 메모리의 페이지 정보를 제공한다. 드라이버에서는 MDL 구조체를 직접 접근하지 않고 DDK 함수를 사용해야 한다. 이들 함수는 아래의 표16.1에 있다.

 

다음의 그림 17.2 17.3을 보면서 MDL DO_DIRECT_IO와의 관계를 설명하기로 한다.

 

 

 

 그림 17.2 WriteFile API를 호출 할 시점의 유저 버퍼의 배치를 나타내고 있다. A가 속한 페이지는 페이지아웃된 상태이고 B C가 속한 페이지는 물리 메모리에 존재하는 상태이다. 이와 같은 상황에서  fdo->Flags DO_DIRECT_IO가 명시되어 있다면 I/O 관리자는 어떤 일을 할까? 그림 17.3을 계속해서 보자.

 

 

 I/O 관리자는 MDL 구조체를 생성하여 유저 버퍼 영역에 대응하는 물리 메모리를 기술한다. 이때 그림 17.2에서 페이지 아웃되었던 A가 속한 페이지가 먼저 물리 메모리로 페이지 인되어진다. MDL 구조체는 그림 17.3에 잘 묘사되어 있으므로 여기서는 세부적인 설명은 생략한다. MDL 구조체의 일부분은 아니지만  MDL 구조체 바로 다음 위치에 유저 버퍼의 가상 주소와 대응하는 물리 메모리를 가리키는 주소가 존재한다. 이렇게 물리 메모리 배치가 끝났다면 I/O 관리자는 이들 물리 메모리가 페이지 아웃되지 않도록 락을 건다. 그런 다음 IRP 헤더 부분의 MdlAddress 필드를 통해 방금 생성한 MDL 구조체의 주소를 넘겨준다.

 

     <17.1> MDL 관련 함수들

 

드라이버는 MDL 구조체를 직접 접근하는 것이 아니라 위의 표에 있는 함수를 사용하여 MDL로부터 필요한 정보를 구하면 된다. 드라이버가 MDL로부터 알 수 있는 것은 유저 버퍼와 대응하는 물리 주소 밖에 없으므로 이것으로는 I/O 작업을 수행할 수 없다. MDL 조작함수MmGetSystemAddressForMdlSafe를 사용하면 MDL로부터 유저 버퍼와 대응하는 물리 주소에 대한 커널 모드 가상 주소를 구할 수 있다.

 

 

 

그림 17.4 MmGetSystemAddressForMdlSafe  함수를 호출한 결과로써 커널 모드 가상 주소 공간에 물리 메모리 A, B, C에 대응하는 주소가 생겼음을 알 수 있다. 단지 주소만 페이지 테이블을 통해 맵핑된 것이다. 실제로 추가적인 물리 메모리가 할당되지 않음에 유의하자. 드라이버는 이제 커널 주소 공간의 버퍼를 접근하여 데이터 전송을 수행할 수 있다. 그림17.4를 통해 애플리케이션과 드라이버가 사용하는 가상 주소만 다를뿐 실제 이들 가상주소와 맵핑된 물리메모리는 동일한 영역임을 알 수 있다.

 

DO_BUFFERED_IO 방식에 비해 MDL을 생성하는 오버헤드는 있지만 커널 버퍼를 위한 추가적인 메모리 소비가 없고 또한 데이터 복사 작업이 존재하지 않으므로 고속의 대용량 데이터 전송에 적합하다.

 

Neither I/O 방식

 

이 방식은 fdo->Flags |= 0 와 같이 앞서 설명한 두 가지 방식 모두를 사용하지 않음을 나타낸다. 그렇다면 어떤 방식으로 애플리케이션 드라이버 간의 데이터 전송이 일어날까? Neither I/O 방식에서는 IRP의 헤더부분 UserBuffer 필드를 통해 유저 버퍼 주소가 단순히 넘어온다. 이것은 어떤 의미를 가지는가? 결국 이 질문은 유저 버퍼 주소가 유효한가라는 질문으로 귀결된다. 유저 버퍼 주소가 유효한 경우는 I/O를 요청한 스레드 컨텍스트에서만이다. 즉 드라이버는 이 주소(유저버퍼 주소)를 임의의 스레드 컨텍스트에서 접근하면 안되고 I/O를 요청한 스레드 컨텍스트에서만  접근해야한다. 드라이버는 I/O 요청을 비동기적으로 대부분 처리하는데 이것이 가능할까? 당연히 불가능이다. 따라서 WDM 드라이버에서는 이 방식을 사용하지 않는다. 하지만 파일 시스템 드라이버는 특정 조건하에서 최적의 성능을 발휘하는 이 방식을 사용한다.

 

아래의 표는 이상으로 언급한 세 가지 방식에 대한 요약 사항이다.

분류 기준은 드라이버가 실제 접근할 수 있는 I/O 요청자의 데이터가 존재하는 위치와 버퍼의 속성( 또는 언락), 버퍼 접근에 필요한 정보가 있는 IRP의 필드들 그리고 버퍼 주소가 유효한 컨텍스트이다.

 

다음 포스트에서는 DeviceIoControl API대하여  Buffered I/O, Direct I/O, Neither I/O 가 각각 어떻게 동작하는지를 알아보기로 한다.

 

이전 포스트(디바이스 드라이버 [0016] - 애플리케이션과 드라이버 간의 데이터 전송방법 1)에서 ReadFile, WriteFile 범주의 API에서 사용하는 데이터 전송 방식 메커니즘을 살펴보았다. 이번 포스트에서는DeviceIoControl API에 대해서 드라이버와 애플리케이션과의 데이터 전송 메커니즘을 알아보자.

 

DeviceIoControl 함수의 원형은 다음과 같다.

BOOL DeviceIoControl (  Handle hDevice,                 // handle to device of interest

                                   DWORD dwIoControlCode,    // control code of operation to perform
                                   LPVOID lplInBuffer,              // pointer to buffer to supply input data(APP -->
드라이버(즉 디바이스))
                                   DWORD nInBufferSize,         // size of input buffer
                                   LPVOID lplOutBuffer,            // pointer to buffer to receive output data(
드라이버(즉 디바이스) ---> APP)
                                   DWORD nOutBufferSize,               // size of output buffer
                                   LPDWORD lpBytesReturned,         // pointer to variable to receive byte count
                                   LPOVERLAPPED lpOverlapped     // pointer to overlapped structure
                                );

 

이들 함수는 어떤 경우에 사용하는가? 이미 많은 분들이 사용했겠지만 간단히 설명하면 디바이스의 일반적인 I/O( Read, Write)가 아닌 디바이스의 기능을 제어하는 용도로 주로 사용된다. 예를 들어 키보드에 NumLock 키를 누르면 O/S 키보드 관련 유저 모드 모듈은 키보드 드라이버로 NumLock LED On하라는 명령을 보낸다. 이때 사용되는 함수가 바로 DeviceIoControl이다. 디바이스 설정 관련 부분을 변경하고자 할 때는 대부분의 경우 이 함수를 사용하여 구현한다. DeviceIoControl 함수의 인자를 잠깐 살펴보자.

hDevice는 설명이 필요없을 것 같고 두번째 인자인 dwIoControlCode는 설명이 필요할 것 같다. 이 코드는 32 비트 값으로서 각 필드는 아래와 같이 정의되어 있다.

 

 

 

 

다음의 코드를 보자. CTL_CODE 매크로를 사용하여 IOCTL_MYCODE_FUNC1 코드를 정의하고 있다. 이 코드 값에 대하여 드라이버와 애플리케이션이 서로 수행할 동작을 약속하여 이 기능이 필요할 때 DevceIoControl 함수를 호출하면서 이 코드 값을 넘겨주면 되는 것이다.

 

#define MY_IOCTL_BASE_INDEX     0x800  
#define IOCTL_MYCODE_FUNC1 CTL_CODE( FILE_DEVICE_UNKNOWN,  \   //
디바이스 유형 명시
                                                                MY_IOCTL_BASE_INDEX,   \   //
제어 유형(드라이버 작성자가 정하기 나름이다)
                                                                METHOD_BUFFERED,        \   //
데이터 전송 방식 명시
                                                                FILE_ANY_ACCESS )               //
모든 access가 허용된다.

...
DeviceIoControl( hDevice, IOCTL_MYCODE_FUNC1,
);

 

위의 CTL_CODE 매크로를 보면 METHOD_BUFFERED가 명시되어 있음을 볼 수 있다. 앞서 우리는 ReadFile, WriteFile API의 경우 I/O 관리자는 DeviceObject->Flags 필드에 명시된 값을 보고 어떤 데이터 전송 방식을 사용할 지를 결정한다. 하지만 DeviceIoControl API의 경우는 I/O 관리자는 드라이버 작성자와 드라이버를 사용하는 애플리케이션 간에 정의된 dwIoControlCode 내의 Transfer Type 필드 즉 bit[1:0]을 보고서 어떤 데이터 전송 방식을 사용할지를 결정한다.

 

참고로 DeviceIoControl  lplInBuffer 인자는 애플리케이션에서 디바이스로 전송할 데이터 용도로 사용되며

                                     lplOutBuffer 인자는 디바이스에서 애플리케이션으로 전송될 데이터 용도로 사용된다는 점에 유의하자.

인자 이름과 I/O 동작이 반대이기 때문에 혼동의 소지가 있다. 하지만 이 함수가 디바이스 관점에서 작성되었다고 생각하면 전혀 어색하지 않음을 알 수 있다. DeviceIoControl을 호출하여 디바이스를 제어하고(제어 값은 lplInBuffer 인자를 통해) 동시에 설정된 값을 읽기를 원한다면(이 값은 lplOutBuffer 인자를 통해 반환받는다) lplInBuffer 인자와 lplOutBuffer 인자를 모두 사용해야 한다. 만약 이들 두 작업을 각각 DeviceIoControl 호출을 통해 두번에 걸쳐 수행한다면 동시에 두 인자 모두를 제공할 필요가 없고 동작에 따라 필요한 인자만 제공하면 된다.

한마디로 DeviceIoControl 함수는 디바이스를 제어할 수 있는 기능을 확장시켜주는 API라고 할 수 있겠다.

 

METHOD_BUFFERED 방식

 

컨트롤 코드를 정의할 때 METHOD_BUFFERED를 명시한 경우라면 개념적으로 ReadFile, WriteFile BUFFERED I/O와 유사하다.

그림 18.1 DeviceIoControl 함수에서 lplInBuffer lplOutBuffer 인자 모두를 사용하는 경우의 예이다. 그림 18.1을 살펴보자. 우선 애플리케이션에서 입/출력 버퍼 모두를 사용하여 DevieIoControl을 호출하고 있다. 이 경우 I/O 관리자는 InputBuffer OutputBuffer 중에 크기가 큰 버퍼만큼의 버퍼를 커널 주소 공간에 할당한다. 하나의 커널 버퍼만이 할당됨에 유의하자.

그리고 유저 공간의 InputBuffer(디바이스로 실제 전송될 데이터)의 내용을 새롭게 할당한 커널 주소 공간의 버퍼로 복사한다. 그리고 IRP의 각 필드를 이용하여 새롭게 할당한 커널 버퍼의 위치와 InputBuffer킈기 OutputBuffer의 크기 등을 드라이버에게 알려준다. 이제 드라이버는 I/O 관리자가 새롭게 할당한 커널 주소 공간의 버퍼에 존재하는 InputBuffer 데이터를 디바이스로 전송(제어)하면 된다. 이들 데이터는 커널 주소 공간에 존재하므로 스레드 컨텍스트와 관계없이 마음껏 사용할 수 있는 영역이다.

 

드라이버는 I/O 관리자가 새롭게 할당한 커널 주소 공간의 버퍼에 존재하는 InputBuffer 데이터를 디바이스로 전송(제어)했다면 이제는 디바이스로부터 원하는 데이터를 읽어와서 커널 주소 공간 버퍼에 채우면된다. 이 과정이 그림 18.2에 나타나 있다. 드라이버가 이 작업을 완료하고 IoStatus.Information 디바이스로부터 읽은 데이터 바이트 수를 기록하고 IoCompleteRequest를 호출하여 IRP를 완료시키면 I/O 관리자는 커널 주소 공간의 버퍼 데이터를 유저 모드 공간의 유저 OutputBuffer로 복사한다.

 

 

 METHOD_IN_DIRECT METHOD_OUT_DIRECT 방식

 

ReadFile, WriteFile 범주의 API에서 사용하는 데이터 전송 방식의 DO_DIRECT_IO 방식에 해당하는 DeviceIoControl API의 방식은 두 가지가 존재한다. 이들 두 방식의 동작 메커니즘은 동일하다. , 유일한 차이점은 lplOutBuffer 인자에 해당하는 유저 버퍼의 메모리 속성 검사가 다를 뿐이다.

 

METHOD_IN_DIRECT

          디바이스 드라이버가 디바이스로부터 데이터를 읽는 경우라면 애플리케이션 버퍼는 읽기와 쓰기가 가능한

          속성을 지녀야 한다.   DeviceIoControl 함수의 lplOutBuffer 인자에 대하여 Write 접근 검사가 이루어진다.

METHOD_OUT_DIRECT

           디바이스 드라이버가 디바이스로 데이터를 쓴다면 애플리케이션 버퍼는 읽기 전용 메모리라도 무방하다.

            lplOutBuffer 인자에 대하여 읽기 접근 검사만 이루어진다.

 

이상을 종합해보면 DeviceIoControl에서 디바이스로부터 데이터를 읽어오는 작업이 없다면 lplOutBuffer 버퍼의 속성을 읽기-전용으로 변경해 놓고 METHOD_OUT_DIRECT 방식을 사용하면 드라이버의 오동작으로 인한 lplOutBuffer 의 손상을 막을 수 있다.

 

DIRECT 방식의 동작 메커니즘을 살펴보자. 그림 18.3 DeviceIoControl dwIoControlCode 코드가 DIRECT 방식을 사용한 경우의 버퍼 상관 상계를 나타내고 있다. DeviceIoControl에서 InputBuffer OutputBuffer 모두를 사용한다고 가정한다. 유의할 점은 DIRECT 방식 임에도 불구하고 InputBuffer BUFFERED 방식과 동일하게 동작하고 OutputBuffer 만이 MDL을 이용한 원래의 DIRECT 방식으로 동작한다는 것이다. 

 

 

 드라이버가 DeviceIoControl 호출에 대한 IRP를 받았을 때 InputBuffer AssociatedIrp.SystemBuffer 통해 접근가능하며 OutputBuffer MDL MmGetSystemAddressForMdlSafe  함수를 호출하여 커널 모드의 가상 주소를 생성해야 사용가능하다. 참고로 그림 18.3에는 유저 모드의 가상 주소(OutputBuffer)만이 페이지 테이블을 통해 물리 메모리에 맵핑되어 있다. 각 버퍼의 Length는 현재 스택 로켄이션의 Parameters.DeviceIoControl.Input(Output)BufferLength 필드를 참조하면 된다.

 

결론적으로 DIRECT 전송 방법을 명시한 제어코드를 가진 DeviceIoControl 함수를 실행하면 I/O 관리자는 InputBuffer(즉 디바이스로 전송되는 데이터) BUFFERED 방식처럼 커널 모드 가상 주소 공간에 추가적인 버퍼가 할당(즉 물리 메모리가 필요하다는 의미이다)되어 유저 버퍼의 데이터가 커널 공간의 버퍼로 복사된다. 그리고 드라이버가 디바이스로부터 읽은 데이터를 유저 애플리케이션으로 전달하는 수단은 OutputBuffer를 통해서인데 OutputBuffer MDL에 의해 기술되며 물리 메모리에 락이 된다. 드라이버는 이 물리 메모리를 접근하기 위해 MDL로부터 적당한 함수를 사용하여 커널 모드의 가상 주소를 생성하여(이때는 추가적인 물리 메모리는 필요하지 않다) 접근한다.

 

 METHOD_NEITHER 방식

 

NEITHER 방식은 개념적으로 ReaFile, WriteFile API의 경우와 동일한다. 다른 점이라면 단지 사용하는 버퍼가 두 개라는 것이다.

그림 18.4에서 NEITHER 방식일 경우 유저 버퍼 정보가 전달되는 IRP의 각 필드를 보여주고 있다. 드라이버에서는 직접적으로 유저 모드 공간의 유저 버퍼를 직접 접근할 수 있지만 반드시 이 주소가 유효할 경우에만 접근해야 한다. I/O 요청한 스레드 컨텍스트에서만 이 주소를 사용해야 한다는 것이다. 이외의 경우라면 아마도 다른 프로세스에 속하는 특정 메모리를 손상시킬뿐이다.

 

그림 18.4를 보면 커널 가상 주소 공간의 버퍼 할당이나 MDL을 통한 메모리 락이 전혀 발생하지 않는다. 단지 유저 공간의 InputBuffer Output Buffer의 주소가 그대로 IRP IO_STACK_LOCATION Parameters.DeviceIoControl.Type3InputBuffer 필드와 UserBuffer 필드를 통해 전달됨을 알 수 있다. 드라이버는 이들 주소를 언제라도 접근할 수 있겠지만 반드시 I/O를 요청한 스레드의 컨텍스트에서만 접근해야 이들 주소가 유효하다는 점을 명심하자.

 

참고로 WDM 드라이버의 경우 앞서 논했지만 디바이스 스택의 최상단 드라이버의 디스패치 루틴은 항상 I/O를 요청한 스레드의 컨텍스트에서 호출됨을 보장받는다.

 

 

 <정리>

아래의 표에 DeviceIoControl 함수에 대한 버퍼 사용 방법에 따른 그 차이를 정리한다.

InBuffer NEITHER 방식을 제외하고는 항상 Buffered 방식으로 동작한다. 그리고 METHOD_IN(또는 OUT)_DIRECT 방식의 동작 차이점은 동일하다. , OutputBuffer의 메모리 속성 검사 유형만 다를뿐이다.

 

이상으로 두 번의 포스트에 걸쳐 드라이버와 애플리케이션 간의 데이터 전송 방법에 대해 알아보았다.