Buffered, Direct ou Neither
1 de October de 2008 - Fernando RobertoNo 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!
Fernando, é possível acessar o registry de dentro de um VxD ?
Sim, é possível…
_RegOpenKey(…)
_RegQueryValueEx(…)
_RegCloseKey(…)
Have fun!
Fernando, vi que por VxD consigo ler arquivos .ini mas não encontrei nada sobre alterar/gravar. Sabe de algum modo de conseguir alterar um arquivo .ini pelo VxD ? Obrigado.
Até onde sei, existem funções na API para obter valores do SISTEM.INI, mas não de um INI diferente. Não conheço funções para escrever no SYSTEM.INI, e menos para um INI diferente.
Essa vou ficar devendo.
:-\
Boa “Googleada”!
Usando o VToolsD, sei que tem o Get_Profile_String para ler do .ini mas para gravar nao consegui encontrar. Seria para ler/gravar no system.ini mesmo.
Obrigado