Back-End

27 nov, 2013

Como escrever seu próprio Java/ Scala Debugger

Publicidade

Com este artigo vamos explorar como depuradores Java/ Scala são escritos e como funcionam. Depuradores nativos, como WinDbg para Windows ou gdb para Linux/ Unix obtêm energia a partir de hooks que lhes são prestados diretamente pelo sistema operacional para monitorar e manipular o estado de um processo externo. O JVM, agindo como uma camada de abstração sobre o sistema operacional, fornece sua própria arquitetura independente para depurar o bytecode.

Este framework e suas APIs são completamente abertas, documentadas e extensíveis, o que significa que você pode escrever seu depurador próprio facilmente. O projeto atual da estrutura é construído a partir de duas partes principais: o protocolo JDWP e a camada JVMTI API. Cada um tem seu próprio conjunto de benefícios e casos de uso para o qual funciona melhor.

O protocolo JDWP

O Debugger Wire Protocol Java é usado para passar pedidos e receber eventos (tais como mudanças nos estados de linha ou exceções) entre o processo de depuração e utilizando mensagens binárias, normalmente através da rede. O conceito subjacente a esta arquitetura é criar tanto quanto possível a separação entre os dois. Este se destina a reduzir o efeito de Heisenberg de ter o depurador alterando a execução do código target enquanto ele está em execução.

Remover o máximo de lógica do depurador do processo alvo também ajuda a garantir que as mudanças no estado do VM depurado não afetam o próprio depurador. Para facilitar as coisas, o JDK vem com a JDI (Java Debugger Interface) que fornece uma implementação do depurador completa do protocolo, com capacidade de ligar, desligar, controlar e manipular o estado de uma VM de destino.

Este protocolo é o mesmo usado pelo depurador do Eclipse, por exemplo. Se você olhar para os argumentos de linha de comando passados para o seu processo de java, quando ele é depurado pelo IDE, você verá os argumentos adicionais (- agentlib : jdwp = transporte = dt_socket , …) passados a ele pelo Eclipse para ativar a depuração JVM; estabelecendo também a porta na qual os pedidos e os eventos serão enviados.

A API JVMTI

O segundo componente-chave na arquitetura moderna do depurador JVM é um conjunto de APIs nativas cobrindo uma ampla gama de áreas relacionadas com o funcionamento da JVM, conhecidas como a interface Tooling JVM (ie JVMTI). Ao contrário do JDWP, o JVMTI é concebido como um conjunto de C / C + + APIs, juntamente com um mecanismo para a JVM para carregar dinamicamente bibliotecas pré-compiladas (como a. Dll ou . So) que fazem uso dos comandos fornecidos pelo API.

Esta abordagem difere da JDWP em que ele realmente executa o depurador dentro do processo de destino. Isto aumenta a possibilidade do depurador impactar o código de aplicação, tanto em termos de desempenho como estabilidade. A principal vantagem é a capacidade de interagir diretamente com o JVM em tempo quase real.

Já que o JVMTI fornece um poderoso conjunto de baixo nível de conjunto de APIs , eu pensei que seria interessante para mergulhar um pouco mais fundo e explicar como ele funciona e quais são algumas das coisas legais que você pode fazer com ele. Os cabeçalhos de API estão disponíveis através jvmti.h que vem com o JDK.

Escrevendo sua biblioteca depuradora

Escrevendo seu próprio depurador requer a criação de uma biblioteca nativa OS em C + +. Sua função “main “, neste caso, seria semelhante:

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void*)

A função será invocada pela JVM quando seu agente depurador é carregado pelo JVM. O ponteiro sempre importante JavaVM passa para você fornecê-lo com tudo que você precisa para conversar com a JVM. Ele introduz a classe jvmtiEnv disponível através do JavaVM :: method GetEnv que lhe permite interagir com a camada JVMTI através do conceito de capacidades e eventos.

Capacidades JVMTI

Um dos aspectos-chave de escrever um depurador é ser extremamente consciente dos efeitos do seu código depurador sobre o processo de destino. Isto é especialmente importante no caso da biblioteca depuradora nativa onde o código é executado em estreita colaboração com o app. Para ajudá-lo a ter mais controle sobre como seu depurador afeta a execução de código, a especificação JVMTI introduz um conceito de capacidades.

Ao escrever o seu depurador você pode dizer a JVM com antecedência quais conjuntos de comandos API ou eventos que você pretende usar (ou seja, definir pontos de interrupção, suspender threads). Isso permite que a JVM se prepare para isso com antecedência, e lhe dá mais controle sobre a sobrecarga de tempo de execução do seu depurador. Essa abordagem também permite JVMs de diferentes fornecedores programaticamente dizerem quais comandos da API são suportados atualmente fora de toda a especificação JVMTI.

Nem todos os recursos são criados iguais. Alguns recursos vêm em uma parte relativamente pequena da sobrecarga de desempenho. Alguns outros são interessantes, como o can_generate_exception_events para receber retornos de chamada quando a exceção é jogada no código, ou can_generate_monitor_events para receber retornos de chamada quando os bloqueios são adquiridos, têm um custo maior. A razão é evitar que a JVM otimize o código durante a compilação JIT em toda sua extensão e possa forçar a JVM para cair em modo interpretado em tempo de execução .

Outros recursos, como can_generate_field_modification_events (usados para receber uma notificação sempre que um campo de objeto de destino está definido), têm um custo ainda maior, retardando a execução de código por uma percentagem significativa. Mesmo que a JVM suporte o carregamento de várias bibliotecas nativas ao mesmo tempo, algumas capacidades em HotSpot como can_suspend utilizados para suspender e retomar tópicos só podem ser requeridos por uma biblioteca de cada vez.

Uma das partes mais difíceis que enfrentamo na produção de um depurador Takipi era fornecer capacidades semelhantes sem incorrer nesse tipo de sobrecarga (mais sobre isso em um futuro artigo).

Definir callbacks. Uma vez que você tenha recebido o seu conjunto de capacidades, o próximo passo é a criação de callbacks que serão invocadas pela JVM para que você saiba quando as coisas realmente acontecem. Cada um desses retornos de chamada irá fornecer informações bastante profundas quanto ao evento que aconteceu. Por exemplo, para um retorno de chamada exceção esta informação deve incluir o local de bytecode em que a exceção foi lançada.

void JNICALL ExceptionCallback(jvmtiEnv *jvmti,
  JNIEnv *jni, jthread thread, jmethodID method,
  jlocation location, jobject exception,
  jmethodID catch_method, jlocation catch_location)

É importante notar que a capacidade de um recurso é, às vezes, dividida em duas partes. A primeira parte trata simplesmente de permissão, pois fará com que o compilador JIT compile as coisas de forma diferente apenas para criar a possibilidade de fazer chamadas em seu código. A segunda parte vem quando você realmente instala uma função de callback, pois faz com que a JVM escolha caminhos de execução menos otimizados em tempo de execução – aquelas através das quais ele é capaz de fazer uma chamada em seu código, juntamente com a sobrecarga adicional de análise e aprovação de dados significativos.

Breakpoints e relógios. Seu depurador pode fornecer recursos familiares para inspecionar um estado específico em tempo de execução, tais como SetBreakpoint para sinalizar a JVM para suspender a execução de uma instrução de código byte específico, ou SetFieldModificationWatch para pausar a execução sempre que um campo é modificado. Nesse ponto, você pode utilizar outras funções complementares, tais como GetStackTrace e GetThreadInfo para saber mais sobre sua posição atual no código e relatório de retorno.

A maioria das funções JVMTI, como a mostrada abaixo, referem-se à classes e métodos que utilizam jmethodID e jclass (isso deve soar familiar se você já escreveu código Java nativo). Funções adicionais, tais como GetMethodName e GetClassSignature são fornecidas para ajudá-lo a obter os nomes de símbolos reais de piscina constante da classe. Você pode, então, usá-los para registrar dados em arquivos em formato legível, ou torná-los em uma interface de usuário como os que vemos em nosso cotidiano IDEs.

Colocar seu depurador

Depois que você escreveu sua biblioteca, o próximo passo é anexá-la a uma JVM. Existem algumas maneiras de fazer isso:

  1. Conectando JDWP. Se você estiver escrevendo um depurador baseado em JDWP, você precisa adicionar um argumento de inicialização na forma de: agentlib : jdwp = transporte = dt_socket , suspender = y , address = localhost: <port> para depurar para possibilitar o fio da depuração. Estes argumentos detalham a forma de comunicação entre o depurador e o alvo (neste caso, sockets) e se deve ou não começar a ser depurado em modo de suspensão.
  2. Anexar uma biblioteca JVMTI. A JVM carrega bibliotecas JVMTI através de um argumento de linha de comando agentpath passado para o processo de depuração e apontando para o local da sua biblioteca no disco.
    Uma forma alternativa é acrescentar os seus argumentos de linha de comando do agente para o JAVA_TOOL_OPTIONS variável de ambiente global que, se apanhada por cada nova JVM, cujo valor é automaticamente adicionado à sua lista de argumentos existentes.
  3. Anexo Remoto. Um outro método para anexar o depurador é usando a API anexo remoto. Esta API simples e poderosa permite que você anexe agentes para a execução de processos da JVM sem que eles sejam lançados com os argumentos de linha de comando. A desvantagem é que você não terá acesso a alguns dos recursos que você normalmente quer como can_generate_exception_events, já que estes só podem ser solicitados na inicialização VM – infelizmente serão lançados para fora do seu depuradores.

Você pode baixar o Takipi e ver alguns destes métodos em ação.

***

Artigo traduzido pela Redação iMasters com autorização do autor. Original disponível em: http://www.takipiblog.com/2013/09/24/how-to-write-your-own-java-scala-debugger/