Archive for October, 2008

Buffered, Direct ou Neither em IOCTLs

16 de October de 2008

Depois de uma pitada de memória virtual para entendermos os conceitos mais relevantes e darmos uma boa passeada nos métodos de transferências de dados entre aplicação e driver, hoje vamos fechar essa trilogia falando sobre os métodos de transferências de dados em IOCTLs. Se você não sabe criar ou utilizar IOCTLs, este outro post pode ajudar.

Flags não ajudam aqui

No post referente aos métodos de transferências de dados foi visto que definimos o método de transferência através de uma máscara de bits que está localizada no campo Flags em um DEVICE_OBJECT. O método escolhido aqui define como o I/O Manager vai manipular os dados nas operações de leitura (IRP_MJ_READ) e escrita (IRP_MJ_WRITE) do driver. O método escolhido é aplicado para ambas as operações. Não podemos ter escritas utilizando um método enquanto as leituras são realizadas utilizando outro. No caso das IOCTLs, a história é diferente. O método de transferência é escolhido quando se define o control code utilizando a macro CTL_CODE.

#define IOCTL_Device_Function CTL_CODE(DeviceType, Function, Method, Access)

Para se ter uma explicação mais detalhada sobre o uso desta macro, visite este post, ou dê uma olhada na referência. Aqui comentarei apenas sobre os métodos de transfêrencias de dados que é selecionado pelo parâmetro Method desta macro. A utilização desta macro para definir IOCTLs é normalmente feita em um arquivo de header que será compartilhado entre a aplicação e o driver. A definição desta macro é obtida a partir do header Windows.h para User-Mode e Ntddk.h para Kernel-Mode. Abaixo segue a definição dos IOCTLs que implementaremos neste post.

//-f--> Aqui definimos os IOCTLs de cópia utilizando os
//      deferentes métodos de transferência de dados entre
//      aplicação e driver.
 
//-f--> Utilizando cópia de sistema
#define IOCTL_COPY_BUFFERED CTL_CODE(FILE_DEVICE_UNKNOWN,   \
                                     0x800,                 \
                                     METHOD_BUFFERED,       \
                                     FILE_ANY_ACCESS)
 
//-f--> Travando as páginas da aplicação
#define IOCTL_COPY_DIRECT   CTL_CODE(FILE_DEVICE_UNKNOWN,   \
                                     0x801,                 \
                                     METHOD_OUT_DIRECT,     \
                                     FILE_ANY_ACCESS)
 
//-f--> Seja o que Deus quiser
#define IOCTL_COPY_NEITHER  CTL_CODE(FILE_DEVICE_UNKNOWN,   \
                                     0x802,                 \
                                     METHOD_NEITHER,        \
                                     FILE_ANY_ACCESS)

Como se pode observar, podemos ter diferentes métodos de transferência de dados para diferentes IOCTLs. Neste post vou criar um driver que ofereça três IOCTLs que simplesmente copiam os dados recebidos no buffer de entrada para buffer de saída. O que teremos que fazer inicialmente é utilizar a macro CTL_CODE para criar as IOCTLs dos serviços que nosso driver de exemplo irá oferecer. O código completo do driver de exemplo está disponível para download ao final deste post.

Pô Fernando, eu incluí o Windows.h em minha aplicação de teste, mas ainda está faltando a definição da macro CTL_CODE e recebo a mensagem de erro abaixo. Estou usando um Windows.h incompleto?

Z:\sources\testapp.cpp(45) : error C3861: 'CTL_CODE': identifier not found

O negócio é o seguinte: O Wizard das versões mais recentes do Visual Studio cria o arquivo StdAfx.h contendo, entre outras, as seguintes linhas:

#define WIN32_LEAN_AND_MEAN             // Exclude rarely-used stuff from Windows headers
// Windows Header Files:
#include 

Observe que o símbolo WIN32_LEAN_AND_MEAN é definido antes da inclusão do arquivo Windows.h. A fim de ganhar velocidade de compilação, este símbolo evita a declaração de algumas toneladas de definições que raramente são utilizadas por aplicações. O que está acontecendo é que a macro CTL_CODE é uma destas coisas raramente utilizadas. É rapaz, interagir com drivers não é pra qualquer um não. Enfim, para resolver este problema é só comentar a definição deste símbolo e todos viverão felizes para sempre.

Calma lá Fernando! Todos menos eu, que não usei o Wizard do Visual Studio. Estou utilizando o arquivo SOURCES para compilar minha aplicação. O fato é que em meu fonte não existe nenhuma definição desse tal de “Win32 Lemming“. Qual é a desculpinha agora?

Se você está utilizando o arquivo SOURCES para compilar sua aplicação de teste, assim como estou fazendo no exemplo deste post, você precisará adicionar a linha em destaque abaixo para que o símbolo WIN32_LEAN_AND_MEAN não seja definido pelo makefile padrão do WDK.

TARGETNAME=TestApp
TARGETTYPE=PROGRAM
USE_LIBCMT=1
UMTYPE=console
NOT_LEAN_AND_MEAN=1
 
SOURCES=TestApp.cpp

Utilizando um buffer de sistema

O primeiro método que veremos aqui é o Buffered I/O, definido pela utilização do valor METHOD_BUFFERED como parâmetro da macro CTL_CODE. Aqui não teremos grandes novidades para quem leu o post anterior. A grande diferença aqui é que na mesma chamada ao driver, dois bufferes são passados para a função DeviceIoControl, um de entrada e outro de saída. Aqui o I/O Manager vai alocar um único buffer de sistema com o tamanho igual ao maior deles. Complicou? Um exemplo ajuda. Numa chamada em que a aplicação ofereça o buffer de entrada com 50 bytes e um buffer de saída com 100 bytes, o buffer de sistema será alocado com 100 bytes. O I/O Manager vai copiar os 50 bytes do buffer de entrada da aplicação para o buffer de sistema. A IRP é enviada ao driver, e ao ser completada, o I/O Manager copia o conteúdo do buffer de sistema para o buffer de saída da aplicação. Quantidade de bytes copiada de volta à aplicação é determinada pelo campo pIrp->IoStatus.Information, assim como no post anterior.

Uma coisa importante a ser notada aqui é que já que o buffer de sistema é único tanto para a entrada como para saída dos dados, o driver precisa ler os dados de entrada antes de começar a escrever os dados de saída, que sobrescreveriam o buffer de entrada.

Conforme já comentei, nosso driver de exemplo vai copiar o buffer de entrada para o buffer de saída. Vamos dar uma olhada na implementação da nossa rotina que vai tratar o IOCTL que usará um buffer de sistema. Leiam os comentários.

/****
***     OnCopyBuffered
**
**      Rotina de tratamento da IOCTL responsável por
**      fazer a cópia do buffer de entrada para o buffer
**      de saída. (usando METHOD_BUFFERED)
*/
 
NTSTATUS
OnCopyBuffered(IN PDEVICE_OBJECT    pDeviceObj,
               IN PIRP              pIrp)
{
    NTSTATUS            nts;
    PIO_STACK_LOCATION  pStack;
 
    //-f--> Obtemos um ponteiro para a stack location
    //      corrente.
    pStack = IoGetCurrentIrpStackLocation(pIrp);
 
    //-f--> Output dos valores obtidos.
    //      Reparem que o buffer de entrada e o buffer
    //      de saída é o mesmo. Isso significa que você
    //      não pode escrever no buffer de saída até que
    //      tenha lido todos os bytes do buffer de entrada.
    DbgPrint("========== OnCopyBuffered ===========\n"
             "Input buffer address: 0x%p\n"
             "Input buffer size:    %d\n"
             "Output buffer addres: 0x%p\n"
             "Output buffer size:   %d\n\n",
             pIrp->AssociatedIrp.SystemBuffer,
             pStack->Parameters.DeviceIoControl.InputBufferLength,
             pIrp->AssociatedIrp.SystemBuffer,
             pStack->Parameters.DeviceIoControl.OutputBufferLength);
 
    //-f--> Vamos verificar se o buffer de entrada cabe no buffer
    //      de saída antes de fazer a cópia.
    if (pStack->Parameters.DeviceIoControl.OutputBufferLength <
        pStack->Parameters.DeviceIoControl.InputBufferLength)
    {
        //-f--> Ops!
        nts = STATUS_BUFFER_TOO_SMALL;
        pIrp->IoStatus.Information = 0;
    }
    else
    {
        //-f--> Copiar pra quê?
        //      Como o buffer de entrada e o buffer de saída
        //      oferecidos pela aplicação são copiados para um
        //      único buffer de sistema, não precisamos fazer
        //      nenhuma cópia. O I/O Manager já fará isso por
        //      nós. Vamos apenas informar à aplicação quantos
        //      bytes são válidos no buffer de saída.
        nts = STATUS_SUCCESS;
        pIrp->IoStatus.Information =
            pStack->Parameters.DeviceIoControl.InputBufferLength;
    }
 
    //-f--> Fecha a conta e passa a régua.
    pIrp->IoStatus.Status = nts;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return nts;
}

Travando a memória da Aplicação

Apesar de o IOCTL ser uma solicitação normalmente utilizada para controle, nada nos impede de utilizar este meio de comunicação para obter ou enviar dados para o driver. Se nestas leituras ou escritas, grandes volumes de dados forem trocados, o método Buffered passa a ser pouco eficiente. Utilizando o método Direct não haverá cópias intermediárias em buffer de sistema. Nada muito diferente do que já vimos no post anterior, mas aqui temos dois bufferes em uma só chamada. O buffer de entrada faltou na escola bem no dia da aula sobre MDLs, e por isso ainda chega ao driver utilizando um buffer de sistema. É isso mesmo! Igualzinho ao método buffered. Por esta razão, não usaremos o buffer de entrada para enviar grandes quantidades de dados ao driver.

Mas e se eu quiser enviar uma grande quantidade de dados para o driver através de um IOCTL? Aqui a conversa entorta um pouco. Repare que para utilizar o método Direct em IOCTLs, podemos usar tanto o parâmetro METHOD_IN_DIRECT como o METHOD_OUT_DIRECT. Com o método Direct, você pode utilizar o buffer de saída como entrada para o driver. Hein? Tá bom, vamos mais devagar. Ambas as opções criam uma MDL para descrever as páginas que compõem o buffer de saída oferecido pela aplicação. Quando a IRP chega ao driver, você utiliza a função MmGetSystemAddressForMdlSafe para obter um ponteiro de System Space que mapeia as mesmas páginas físicas oferecidas pela aplicação. Isso significa que o ponteiro que você recebe vai escrever diretamente nas páginas oferecidas pela aplicação. Já sei! Se o ponteiro aponta para as mesmas páginas da aplicação, então podemos ler os dados contidos nestas páginas? É exatamente isso. Podemos enviar dados ao driver preenchendo o buffer de saída antes de chamar a função DeviceIoControl. Assim, quando o driver receber a IRP, ele pode ler estes dados. Isso permite que o driver receba grandes quantidades de dados de entrada, mas utilizando o buffer de saída. O parâmetro METHOD_IN_DIRECT sinaliza ao I/O Manager que o buffer que será utilizado para montar a MDL será utilizado para leitura, assim o buffer é testado para leituras no processo de criação da MDL. Alternativamente, o parâmetro METHOD_OUT_DIRECT indica que o buffer receberá leituras e escritas do driver.

Lembre-se que METHOD_IN_DIRECT ou METHOD_OUT_DIRECT define apenas o tipo de teste que será feito sobre o buffer de saída, permitindo do driver ler o buffer de saída. O buffer de entrada sempre virá por intermédio de um buffer de sistema. Tá tá tá, vamos ao código por favor?

/****
***     OnCopyDirect
**
**      Rotina de tratamento da IOCTL responsável por
**      fazer a cópia do buffer de entrada para o buffer
**      de saída. (usando METHOD_DIRECT_XXX)
*/
 
NTSTATUS
OnCopyDirect(IN PDEVICE_OBJECT  pDeviceObj,
             IN PIRP            pIrp)
{
    NTSTATUS            nts;
    PIO_STACK_LOCATION  pStack;
    PVOID               pOutputBuffer;
 
    //-f--> Obtemos um ponteiro para a stack location
    //      corrente.
    pStack = IoGetCurrentIrpStackLocation(pIrp);
 
    //-f--> O ponteiro de saída vem de uma MDL criada
    //      pelo I/O Manager.
    pOutputBuffer = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,
                                                 LowPagePriority);
 
    if (!pOutputBuffer)
    {
        //-f--> Ops! Estamos sem recursos para mapear as
        //      páginas descritas pelo MDL em System Space.
        pIrp->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
        pIrp->IoStatus.Information = 0;
        IoCompleteRequest(pIrp, IO_NO_INCREMENT);
        return STATUS_INSUFFICIENT_RESOURCES;
    }
 
    //-f--> Já o ponteiro de entrada sempre vem por um
    //      buffer de sistema como no método Buffered
    DbgPrint("=========== OnCopyDirect ============\n"
             "Input buffer address: 0x%p\n"
             "Input buffer size:    %d\n"
             "Output buffer addres: 0x%p\n"
             "Output buffer size:   %d\n\n",
             pIrp->AssociatedIrp.SystemBuffer,
             pStack->Parameters.DeviceIoControl.InputBufferLength,
             pOutputBuffer,
             pStack->Parameters.DeviceIoControl.OutputBufferLength);
 
    //-f--> Vamos verificar de o buffer de entrada cabe no buffer
    //      de saída antes de fazer a cópia.
    if (pStack->Parameters.DeviceIoControl.OutputBufferLength <
        pStack->Parameters.DeviceIoControl.InputBufferLength)
    {
        //-f--> Ops!
        nts = STATUS_BUFFER_TOO_SMALL;
        pIrp->IoStatus.Information = 0;
    }
    else
    {
        //-f--> Neste caso teremos que fazer a cópia, já que o buffer
        //      de entrada e o buffer de saída são fisicamente distintos
        RtlCopyMemory(pOutputBuffer,
                      pIrp->AssociatedIrp.SystemBuffer,
                      pStack->Parameters.DeviceIoControl.InputBufferLength);
 
        //-f--> Sinaliza sucesso ao I/O Manager e informa à aplicação a
        //      quantidade de bytes válidos no buffer de saída.
        nts = STATUS_SUCCESS;
        pIrp->IoStatus.Information =
            pStack->Parameters.DeviceIoControl.InputBufferLength;
    }
 
    //-f--> Fecha a conta e passa a régua.
    pIrp->IoStatus.Status = nts;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return nts;
}

Nem Buffered I/O nem Direct I/O

Isso pode parecer repetitivo para você, mas neste método, indicado pelo parâmetro METHOD_NEITHER, o I/O Manager não fará nada por você. Assim, você terá que testar o contexto do processo e também testar o acesso aos bufferes como vimos no post anterior. Mais uma vez, a grande diferença aqui é que teremos dois bufferes. O buffer de entrada virá por pStack->Parameters.DeviceIoControl.Type3InputBuffer e o buffer de saída é obtido por pIrp->UserBuffer. O buffer de entrada deve ser testado com ProbeForRead, já que o driver fará leituras neste buffer, e o buffer de saída deve ser testado com ProbeForWrite. Acho que o resto o código de exemplo é capaz de explicar. Para testar o contexto do processo, utilizamos a função que já foi explicada no post anterior.

/****
***     OnCopyNeither
**
**      Rotina de tratamento da IOCTL responsável por
**      fazer a cópia do buffer de entrada para o buffer
**      de saída. (usando METHOD_NEITHER)
*/
 
NTSTATUS
OnCopyNeither(IN PDEVICE_OBJECT pDeviceObj,
              IN PIRP           pIrp)
{
    NTSTATUS            nts;
    PIO_STACK_LOCATION  pStack;
 
    //-f--> Obtemos um ponteiro para a stack location
    //      corrente.
    pStack = IoGetCurrentIrpStackLocation(pIrp);
 
    //-f--> Output dos valores obtidos.
    DbgPrint("=========== OnCopyNeither ===========\n"
             "Input buffer address: 0x%p\n"
             "Input buffer size:    %d\n"
             "Output buffer addres: 0x%p\n"
             "Output buffer size:   %d\n\n",
             pStack->Parameters.DeviceIoControl.Type3InputBuffer,
             pStack->Parameters.DeviceIoControl.InputBufferLength,
             pIrp->UserBuffer,
             pStack->Parameters.DeviceIoControl.OutputBufferLength);
 
    //-f--> Como estamos utilizando o método Neither, temos que
    //      estar executando no mesmo contexto do processo que
    //      gerou a IRP., pois vamos acessar o User Space em Kernel-Mode.
    if (!EstouNoContextoDoProcessoQueGerouEssaIrp(pIrp))
    {
        //-f--> Ops!
        pIrp->IoStatus.Status = STATUS_INVALID_ADDRESS;
        pIrp->IoStatus.Information = 0;
        IoCompleteRequest(pIrp, IO_NO_INCREMENT);
        return STATUS_INVALID_ADDRESS;
    }
 
    //-f--> Aqui já sabemos que estamos no contexto certo, mas ainda
    //      precisamos testar os bufferes oferecidos pela aplicação.
    //      Não queremos que uma aplicação infeliz envie um ponteiro
    //      inválido e o sistema termine em tela azul por conta disso.
    __try
    {
        //-f--> O driver fará leituras no buffer de entrada
        ProbeForRead(pStack->Parameters.DeviceIoControl.Type3InputBuffer,
                     pStack->Parameters.DeviceIoControl.InputBufferLength,
                     1);
 
        //-f--> E fará escritas no buffer de saída
        ProbeForWrite(pIrp->UserBuffer,
                      pStack->Parameters.DeviceIoControl.OutputBufferLength,
                      1);
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        //-f--> Ahaaa!!
        nts = GetExceptionCode();
 
        //-f--> Completa a IRP e xinga a mãe do cara que escreveu
        //      a aplicação (a menos que tenha sido você mesmo).
        pIrp->IoStatus.Status = nts;
        pIrp->IoStatus.Information = 0;
        IoCompleteRequest(pIrp, IO_NO_INCREMENT);
        return nts;
    }
 
    //-f--> Vamos verificar de o buffer de entrada cabe no buffer
    //      de saída antes de fazer a cópia.
    if (pStack->Parameters.DeviceIoControl.OutputBufferLength <
        pStack->Parameters.DeviceIoControl.InputBufferLength)
    {
        //-f--> Ops!
        nts = STATUS_BUFFER_TOO_SMALL;
        pIrp->IoStatus.Information = 0;
    }
    else
    {
        //-f--> Feche os olhos, diga "Sangue de Jesus tem poder",
        //      acredita em São Walter Oney e copia o buffer de
        //      entrada para o buffer de saída.
        RtlCopyMemory(pIrp->UserBuffer,
                      pStack->Parameters.DeviceIoControl.Type3InputBuffer,
                      pStack->Parameters.DeviceIoControl.InputBufferLength);
 
        //-f--> Ufa! Todos vivos?
        //      Sinaliza sucesso ao I/O Manager e informa à aplicação a
        //      quantidade de bytes válidos no buffer de saída.
        nts = STATUS_SUCCESS;
        pIrp->IoStatus.Information =
            pStack->Parameters.DeviceIoControl.InputBufferLength;
    }
 
    //-f--> Fecha a conta e passa a régua.
    pIrp->IoStatus.Status = nts;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return nts;
}

Compilando o exemplo

Para compilar o exemplo disponível para download, você pode utilizar o atalho de ambiente instalado pelo WDK e chamar o Build do diretório raiz do exemplo. Repare que em um único passo compilamos o driver e a aplicação de teste. A figura abaixo ilustra isso. Ou você pode usar o DDKBUILD como eu já expliquei neste outro post para compilar a partir do Visual Studio.


Fernando, mais uma dúvida antes de você sumir na névoa. Na tabela de Dispatch Routines, que preenchemos na estrutura DRIVER_OBJECT, contém apenas uma entrada para IRP_MJ_DEVICE_CONTROL. Como você criou uma rotina para cada método? Essa eu vou deixar o código de exemplo abaixo responder, mas se ainda assim você tiver alguma dúvida, é só me mandar um e-mail, que está em meu perfil do Blogger, e aí a gente sai na porrada.

/****
***     OnDeviceControl
**
**      Aqui recebemos todos os DeviceIoControl
**      enviados para o driver e separamos em rotinas
**      específicas para o tratamento de cada IOCTL.
**      Todo o tratamento de todas as IOCTLs poderiam
**      estar em uma só função, mas não custa nada ser
**      organizado de vez em quando.
*/
 
NTSTATUS
OnDeviceControl(IN PDEVICE_OBJECT   pDeviceObj,
                IN PIRP             pIrp)
{
    PIO_STACK_LOCATION  pStack;
 
    //-f--> Obtemos um ponteiro para a stack location
    //      corrente.
    pStack = IoGetCurrentIrpStackLocation(pIrp);
 
    //-f--> Obtém o código do IOCTL para encaminhar
    //      para a rotina certa, ou não. 🙂
    switch(pStack->Parameters.DeviceIoControl.IoControlCode)
    {
    case IOCTL_COPY_BUFFERED:
        return OnCopyBuffered(pDeviceObj,
                              pIrp);
 
    case IOCTL_COPY_DIRECT:
        return OnCopyDirect(pDeviceObj,
                            pIrp);
 
    case IOCTL_COPY_NEITHER:
        return OnCopyNeither(pDeviceObj,
                             pIrp);
    }
 
    //-f--> Ops! Recebemos um IOCTL diferente dos que
    //      estávamos esperando.
    pIrp->IoStatus.Status = STATUS_NOT_IMPLEMENTED;
    pIrp->IoStatus.Information = 0;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
 
    return STATUS_NOT_IMPLEMENTED;
}

Hoje vou me despedir ao estilo mr4nd3r50n, que é um amigo que trabalhou comigo na SCUA.

Intel mais, já vou Windows!
🙂

IoctlCopy.zip

Buffered, Direct ou Neither

1 de October de 2008

No post passado apresentei uma breve introdução sobre alguns pontos referentes à memória virtual que considero ser os mais relevantes aos desenvolvedores de drivers. Com essa pequena carga de conhecimento, ficará mais simples de explicar as diferenças entre os métodos de transferências de dados entre aplicações e drivers, assim como outras questões, tais como o atendimento de interrupções, mapeamento de memória e contexto de execução.

Se formos pensar de maneira bem simplificada (e bota simplificada nisso), drivers são basicamente os módulos que extraem dados de dispositivos e os disponibilizam para as aplicações e vice-versa. Deste ponto de vista, parece mesmo que escrever drivers seja fácil. Parece até uma maneira de fazer um memcpy de software para hardware. Hoje veremos um pouco sobre as maneiras as quais um driver pode optar para fazer essa transferência de dados.

Utilizando um Buffer de sistema

O primeiro método é o chamado Buffered I/O. Este método utiliza um buffer de sistema para fazer a transferência de dados entre a aplicação e o driver. Quando uma aplicação chama a função WriteFile passando os dados a serem enviados para o driver, o I/O Manager faz uma cópia dos dados da aplicação para um buffer de sistema. Mas o que é um buffer de sistema? Se trata de uma alocação em System Space que foi feita em Kernel-Mode, e por isso, é acessível em qualquer contexto de processo. Já falamos disso no post passado.

BOOL WINAPI WriteFile(
  __in         HANDLE hFile,
  __in         LPCVOID lpBuffer,
  __in         DWORD nNumberOfBytesToWrite,
  __out_opt    LPDWORD lpNumberOfBytesWritten,
  __inout_opt  LPOVERLAPPED lpOverlapped
);

O I/O Manager obtém o tamanho do buffer a ser alocado do parâmetro nNumberOfBytesToWrite, então realiza uma alocação de memória do tipo não paginada e faz a cópia dos dados que estão em User Space para System Space. Se você estiver derrapando nos termos System Space, User Space e tipos de alocação de memória, dê uma lida neste post para que as coisas fiquem menos obscuras para você.

No driver, o ponteiro para este buffer é obtido pela IRP e seu tamanho é obtido pela stack location da IRP. Legal, mas o que é uma IRP? Dê uma olhada neste mini exemplo abaixo para ter uma idéia, ou você pode ver o exemplo utilizado neste outro post que também utiliza o método Buffered para enviar e receber strings de um driver de exemplo.

//-f--> Rotina de tratamento da IRP_MJ_WRITE.
//      Exemplo de obtenção do buffer de sistema
//      alocado pelo I/O Manager no método Buffered
 
NTSTATUS
OnDispatchWrite(__in PDEVICE_OBJECT pDeviceObj,
                __in PIRP           pIrp)
{
    PIO_STACK_LOCATION  pStack;
    PVOID               pBuffer;
    ULONG               ulLength;
 
    //-f--> Obtém a stack corrente da IRP
    pStack = IoGetCurrentIrpStackLocation();
 
    //-f--> Aqui obtemos o ponteiro para o buffer
    pBuffer = pIrp->AssociatedIrp.SystemBuffer;
 
    //-f--> Aqui o tamanho do buffer
    ulLength = pStack->Parameters.Write.Length;
 
    ...
}

Nas operações de leitura, o método é bem parecido, mas apesar de neste caso o I/O Manager também fazer a locação em memória não paginada, ele não faz nenhuma cópia para o buffer de sistema. O buffer de sistema é recebido pelo driver também através de pIrp->AssociatedIrp.SystemBuffer, mas o seu tamanho é obtido em pStack->Parameters.Read.Length. O driver preencherá o buffer com os dados vindos do dispositivo, o I/O Manager fará a cópia do buffer de System Space para User Space quando a IRP for completada, preenchendo o buffer da aplicação.

Opa opa! Até onde eu sei, drivers normalmente completam IRPs em contexto arbitrário, o que significa que “só Deus sabe em qual contexto de processo”. Tudo bem se o driver escrever em um buffer de sistema, o qual é válido para qualquer processo, mas o buffer da aplicação está em User Space e só é acessível no contexto do próprio processo. Como o I/O Manager faria a cópia do buffer de sistema para o buffer da aplicação em contexto arbitrário? Nossa, essa realmente foi uma excelente pergunta. Acho que nem eu teria imaginado uma pergunta tão boa. As IRPs podem ser tratadas tanto sincronamente como assincronamente. O I/O Manager sabe como a IRP foi processada, e no caso síncrono, o I/O Manager já está no contexto do processo que fez a solicitação, e neste caso ele tem acesso a ambos os bufferes, já que o I/O Manager roda em Kernel-Mode. Quando a IRP é tratada assincronamente, o I/O Manager enfila uma APC (Asynchronous Procedure Call), que é uma chamada assincrona executada no contexto de uma dada thread. Essa thread é justamente a thread que iniciou a operação, e que portanto, estará no contexto do processo certo para acessar o User Space da aplicação.

BOOL WINAPI ReadFile(
  __in         HANDLE hFile,
  __out        LPVOID lpBuffer,
  __in         DWORD nNumberOfBytesToRead,
  __out_opt    LPDWORD lpNumberOfBytesRead,
  __inout_opt  LPOVERLAPPED lpOverlapped
);

Na chamada da função ReadFile, o parâmetro nNumberOfBytesToRead indica o tamanho do buffer que a aplicação está oferecendo ao driver. O driver recebe este valor como quantidade máxima de bytes que podem ser retornados à aplicação. Supondo que a aplicação tenha oferecido 1000 bytes, o I/O Manager faz uma alocação de 1000 bytes e repassa o buffer para o driver. Vamos supor que o driver tenha apenas 500 bytes a serem retornados à aplicação, neste caso, o I/O Manager terá de copiar para o buffer da aplicação apenas 500 dos 1000 bytes alocados. O I/O Manager recebe este valor através do campo pIrp->IoStatus.Information, que é preenchido pelo driver antes da IRP ser completada. Desta forma, o I/O Manager copia somente os bytes válidos do buffer de sistema para o buffer da aplicação. Este mesmo valor é retornado à aplicação através do parâmetro lpNumberOfBytesRead.

Utilizar memória não paginada para o manter o buffer de sistema nos assegura que as páginas não serão removidas da RAM por paginação. Isso permite que a página seja acessada mesmo a partir de threads que estejam rodando em alto nível de prioridade. Contudo, o método Buffered é indicado apenas para pequenas movimentações de dados. Imagine que uma aplicação queira fazer uma escrita de 10 MB de uma só vez. O I/O Manager teria que fazer uma alocação em System Space de 10 MB de memória não paginada, o que não seria nada adequado, pois memória não paginada é um recurso escasso. Se o I/O Manager conseguir fazer a alocação, ele ainda terá que fazer uma cópia de 10 MB do buffer da aplicação para o buffer de sistema. Isso até funcionaria, mas teriamos sérios problemas de performace. Neste caso, o mais indicado seria utilizar o método que é visto em seguida.

Travando a memória da Aplicação

No método chamado Direct I/O, como o nome já sugere, o driver faz acesso diretamente às páginas de memória da aplicação sem utilizar um buffer intermedirário. Desta forma, o I/O Manager não faz uma alocação em memória não paginada e também não tem que ficar no BPL-BPC (Buffer pra lá – Buffer pra cá). Ao invés disso, o I/O Manager testa as páginas de memória que compõem o buffer oferecido pela aplicação, cria uma MDL e trava as páginas de memória na RAM. Nossa! Calma aí meu amigo, vamos devagar!

  • Testa as páginas de memória – Nada impede um programador de fazer besteira. Um buffer inválido pode ser passado para o I/O Manager. Pode ser que o buffer não tenha sido alocado, ou que o buffer seja menor que o valor indicado na chamada às funções ReadFile ou WriteFile, pode ser também que as páginas de memória oferecidas à ReadFile estejam protegidas contra escrita, pode ser também que algumas das páginas utilizadas pelo buffer tenham sido paginadas para o disco. Se um driver tenta acessar uma página inválida ou protegida, uma exceção é gerada e uma tela azul sugirá das trevas. Mas como o I/O Manager vai testar a memória? Se o buffer é passado como parâmetro para a função ReadFile, então o driver fará escritas neste buffer. Para ganhar tempo, o I/O Manager fará uma escrita de um byte de cada página de RAM apenas para testar o acesso a elas. Essa escrita é feita sob um manipulador de exceção. Se o buffer for inválido ou protegido, o manipulador de exceção tratará isso e devolverá um erro para a aplicação. Se o buffer é passado para a função WriteFile, então o buffer será lido pelo driver, e neste caso, o teste seria a leitura de um byte de cada página.

  • Cria uma MDL – Uma MDL (Memory Descriptor List) é uma estrutura de dados que descreve as várias páginas de memória que compõem um buffer. Estas páginas são as mesmas páginas físicas utilizadas pela aplicação, ou seja, quando o driver escrever nestas páginas, este já estará escrevendo diretamente no buffer da aplicação. Assim o I/O Manager não precisará fazer nenhum BPL-BPC.

  • Trava as páginas na RAM – Esse passo faz com que as páginas de memória que compõem o buffer da aplicação se tornem não pagináveis. Assim, estas poderão ser acessadas pelo driver em threads que estejam sendo executadas em alto nível de prioridade.

Depois de todo esse ritual, o driver agora recebe o buffer através de pIrp->MdlAddress, mas o que teremos aqui é um ponteiro para uma MDL. Mas o que eu faço com uma MDL? Na maioria das vezes, você vai passar como argumento em um serviço oferecido por outro driver ou componente do sistema. Alguns exemplos são drivers de DMA (Direct Memory Access) que utilizam MDL na chamada para a função MapTransfer, ou mesmo quando uma MDL é repassada para rotinas de controladores USB (Universal Serial Bus), tais como UsbBuildInterruptOrBulkTransferRequest. MDLs são estruturas opacas, mas se você quer ter acesso ao buffer da aplicação, então devemos chamar a função MmGetSystemAddressForMdlSafe para conseguir o endereço para o buffer a ser escrito/lido. Reparem que o endereço retornado por esta função está em System Space, e assim, acessível em qualquer contexto de processo. Mas o buffer da aplicação não está em User Space? Sim, mas o que temos aqui é uma página de memória física sendo mapeada tanto para o espaço de endereçamento da aplicação quanto para o espaço de endereçamemto de sistema.

O tamanho do buffer descrito pela MDL também é obtido pela stack location da IRP, assim como no método Buffered.

Nem Buffered I/O nem Direct I/O

O terceiro método é simplesmente o não uso dos dois primeiros. No método chamado Neither, o I/O Manager não faz uma cópia em um buffer de sistema nem monta uma MDL para descrever o buffer da aplicação. Quando a IRP chega ao seu driver, você tem acesso ao endereço virtual do buffer oferecido pela aplicação por pIrp->UserBuffer. Este endereço aponta para User Space, e por isso, lembre-se que este endereço só é valido no contexto do processo que solicitou o I/O. O uso deste método requer mais cuidado, pois seu driver precisa ser o primeiro driver na pilha de dispositivos, e dessa forma, garantir que a IRP chegue ao seu driver no contexto do prcesso que fez a solicitação de I/O.

Você pode verificar se você está no contexto do processo que solicitou a operação fazendo o teste abaixo.

/****
***     EstouNoContextoDoProcessoQueGerouEssaIrp
**
**      Função com nome ridículo que verifica se o
**      estamos no contexto do processo que gerou
**      a IRP passada como parâmetro.
*/
 
BOOLEAN EstouNoContextoDoProcessoQueGerouEssaIrp(__in PIRP pIrp)
{
    PETHREAD    pEThread;
    PEPROCESS   pEProcess;
 
    //-f--> Obtém a thread que gerou a IRP
    pEThread = pIrp->Tail.Overlay.Thread;
 
    //-f--> Obtém o processo referente a thread
    pEProcess = IoThreadToProcess(pEThread);
 
    //-f--> Aqui comparamos o processo corrente com
    //      o processo que gerou a IRP
    return (PsGetCurrentProcess() == pEProcess);
}

O fato de estar no contexto do processo correto não garante que o buffer passado como parâmetro seja válido. Diferente do método Direct, neste ponto o I/O Manager não testou o buffer antes de passar a IRP para o driver. Teremos que fazer isso por nós mesmos. Lembre-se que, assim como em User-Mode, ao acessar um buffer inválido o driver receberá uma exceção que deve ser manipulada, caso contrário, tudo azul.

Para realizar o teste, teremos que acessar um byte de cada página do buffer que nos foi passado e verificar se o mundo acaba. As rotinas ProbeForRead e ProbeForWrite fazem isso por nós, mas estas devem ser chamadas dentro de um manipulador de exceção. Vale lembrar que em uma operação de leitura, a aplicação nos envia um buffer onde o driver escreverá dados para a aplicação. Já que o driver fará uma escrita neste buffer, teremos que realizar um teste de escrita (ProbeForWrite) nas operações de leitura (ReadFile). De maneira análoga, o driver deverá fazer um teste de leitura (ProbeForRead) nas operações de escrita (WriteFile). Dê uma olhada no exemplo de rotina de leitura que segue abaixo. Como você já deve estar acostumado, leia os comentários que complementam o texto.

/****
***     OnDispatchRead
**
**      Outra função com nominho besta de exemplo.
**      Valida o buffer enviado pela aplicação pelo
**      método Neither e escreve uma seqüência numérica
**      no buffer da aplicação.
*/
 
NTSTATUS OnDispatchRead(__in PDEVICE_OBJECT pDeviceObj,
                        __in PIRP           pIrp)
{
    PIO_STACK_LOCATION  pStack;
    PVOID               pBuffer;
    ULONG               ulLength;
 
    //-f--> Verifica se estamos no contexto do processo
    //      que gerou esta IRP
    if (!EstouNoContextoDoProcessoQueGerouEssaIrp(pIrp))
    {
        //-f--> Sinaliza ao I/O Manager que a casa caiu
        pIrp->IoStatus.Status = STATUS_INVALID_PARAMETER;
        pIrp->IoStatus.Information = 0;
 
        //-f--> Completa a IRP com falha
        IoCompleteRequest(pIrp, IO_NO_INCREMENT);
        return STATUS_INVALID_PARAMETER;
    }
 
    //-f--> Obtém a stack location corrente
    pStack = IoGetCurrentIrpStackLocation(pIrp);
 
    //-f--> Aqui obtemos o endereço do buffer oferecido
    //      pela aplicação bem como seu tamanho.
    //      Note que este buffer deve estar em User Space
    pBuffer = pIrp->UserBuffer;
    ulLength = pStack->Parameters.Read.Length;
 
    //-f--> As funções que testam o buffer lançam exceções
    //      no caso de o buffer ser inválido. Por isso temos
    //      que fazer o teste dentro de um manipulador de exceções
    __try
    {
        //-f--> Se você der uma olhada na referência, verá que
        //      esta rotina retorna VOID, por isso a única
        //      maneira de saber se o buffer é inválido é
        //      manipulando a exceção que será gerada.
        ProbeForWrite(pBuffer,
                      ulLength,
                      1);
    }
    __except(EXCEPTION_EXECUTE_HANDLER)
    {
        NTSTATUS    nts;
 
        //-f--> Ops! Buffer inválido.
        //      Vamos obter o código da exceção e dar um
        //      fim nesse sofrimento
        nts = GetExceptionCode();
 
        pIrp->IoStatus.Status = nts;
        pIrp->IoStatus.Information = 0;
        IoCompleteRequest(pIrp, IO_NO_INCREMENT);
        return nts;
    }
 
    //-f--> Aqui sabemos que o buffer está seguro para receber
    //      escritas. Vamos apenas escrever uma seqüência numérica
    //      só pra...
    for (ULONG i = 0; i < ulLength, i++)
        ((PUCHAR)pBuffer)[i] = (UCHAR)i;
 
    //-f--> Aqui informamos que a operação foi realizada com
    //      sucesso.
    pIrp->IoStatus.Status = STATUS_SUCCESS;
 
    //-f--> Apesar de no método Neither o I/O Manager não
    //      utilizar este número para fazer BPL-BPC, este
    //      número ainda é retornado pela função ReadFile
    //      para informar à aplicação quantos bytes do buffer
    //      são válidos para a aplicação ler.
    pIrp->IoStatus.Information = ulLength;
 
    //-f--> Dá um Fatality na IRP
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

Para todos os métodos, é importante notar que setar o campo pIrp->IoStatus.Information diz à aplicação a quantidade de bytes foram lidos ou escritos no buffer, independente de haver ou não a cópia de buffer de sistema como no caso do método Buffered.

Outra coisa que não vai mudar para os diferentes métodos é a obtenção do tamanho do buffer oferecido pela aplicação. Este valor sempre vem pela stack location como já foi visto nos métodos já discutidos.

Como selecionar o método

Faz todo o sentido que a escolha do método seja feita antes da primeira IRP chegar ao driver. Isso é feito logo depois que o device é criado. Depois que a chamada à função IoCreateDevice termina, recebemos o novo device através de um ponteiro de saída. O membro Flags da estrutura DEVICE_OBJECT é uma máscara de bits e os bits DO_BUFFERED_IO e DO_DIRECT_IO configuram o método de transferência.

Então é fácil assim. Para configurar o método Buffered, setamos o bit DO_BUFFERED_IO, para configurar o método Direct, setamos o bit DO_DIRECT_IO, e finalmente para setar o método Neither, não setamos nenhum destes bits. Já li em algum livro, que não encontro agora, que o comportamento não é previsto se você setar ambos os bits.

Segue mais um exemplinho besta de como setar o device que acaba de ser criado para transferências no método Buffered.

    //-f--> Cria o device que irá receber as IRPs
    nts = IoCreateDevice(pDriverObj,
                         0,
                         &usDeviceName,
                         FILE_DEVICE_UNKNOWN,
                         0,
                         FALSE,
                         &pDeviceObj);
 
    //-f--> Verifica se o device foi criado
    if (!NT_SUCCESS(nts))
        return nts;
 
    //-f--> Aqui já podemos configurar o método
    //      de transferência desejado, que neste
    //      exemplo é o Buffered I/O
    pDeviceObj->Flags |= DO_BUFFERED_IO;

Mas e se uma IRP for entregue ao driver antes de setarmos estes bits? Outra excelente pergunta. As coisas acontecem assim. Sempre quando um novo device é criado, o bit DO_DEVICE_INITIALIZING é setado. As IRPs só começam a ser entregues a este device quando o driver baixar este bit. Isso nos permite inicializar o device antes que qualquer IRP chegue.

Boa tentativa espertão, mas seu exemplo não baixa este bit. Como você explica isso? Você hoje está impossível! Ao termino da rotina DriverEntry, quando o controle volta ao I/O Manager, ele varre a lista de devices criados pelo driver e baixa este bit por nós. É importante lembrar que ainda precisamos baixar este bit quando criamos um novo device depois que a rotina DriverEntry terminou. Um exemplo muito comum são os devices de WDM, que são criados na rotina AddDevice, mas isso vai ficar para uma outra vez. Esse post já ficou muito longo.

Até mais! 🙂