NestedVM – ASM MIPS para JAVA

1. Introdução

Linguagens de alto nível como FORTRAN, C, C++ e Pascal foram inicialmente desenvolvidas para terem certa portabilidade e serem independentes de arquiteturas. Contudo, são dependentes de compilação, ou seja, o mesmo sistema precisa ser recompilado várias vezes, para cada arquitetura.

Por outro lado, linguagens como JAVA e C# foram construídas para permitirem que os programas escritos nestas tecnologias possam correr em qualquer arquitetura e sistema operacional com a mesma compilação.

O que permite esta independência de plataforma é o código objeto em “bytecodes”.

Ao invés de compilar para instruções de máquina o programa executará em uma Máquina Virtual. Assim, o programador se preocupa com a compatibilidade entre seu programa e a MV – enquanto a MV interpreta as instruções na máquina.

Desta forma, além de aprimorar a portabilidade do programa, permite a construção de um código mais seguro, pois não permite execução de instruções inválidas, nem acesso à regiões arbitrárias de memória.

Linguagens consideradas seguras como JAVA são relativamente novas, comparadas com o tempo de vida das linguagens compiladas, como o C. Sendo assim, existe no mercado vasto número de bibliotecas e programas escritos nessas linguagens.

Por isso, podemos nos deparar com a necessidade de utilizar um código escrito em C num sistema em desenvolvimento na linguagem JAVA.

Logo, há uma grande motivação para a conversão de programas e bibliotecas em C para JAVA.

Como exemplo pensar nos inúmeros aplicativos escritos para *NIX que poderiam ser facilmente portados para Windows, e vice-versa, sem a necessidade de diferentes compilações.

Veremos aqui sobre a NestedVM, montador que converte o Assembly MIPS para o bytecode JAVA.

Discutiremos também o processo de “cross-compilation”. Pois precisaremos compilar um código em C++ para um executável da arquitetura MIPS. Será o objeto desta compilação que converteremos para um .class JAVA.

Comentaremos também sobre alguns métodos alternativos e mais populares para integração de sistemas já prontos com o JAVA, como o JNI, Jazillian, c2j etc. E porquê esses sistemas ficam aquém à NestedVm.

 

2. JAVA Native Interface: JNI

A JNI é uma “ponte” entre uma Java Virtual Machine (JVM) e o código compilado em uma linguagem dependente de plataforma, chamado de “código nativo”.

A JNI permite que aplicativos escritos em JAVA interajam com código nativo. Isso resolve, em parte, o problema de reutilização de software, pois permite que bibliotecas já escritas em uma linguagem “conversem” com o programa JAVA.

Contudo, isso acaba com a portabilidade do JAVA, pois as bibliotecas de código nativo continuaram dependentes da plataforma para qual foram compiladas.

O uso de JNI também não resolve o problema de segurança. A JVM não terá controle sobre o código nativo, permitindo assim a execução das instruções herdadas, inseguras e suscetíveis (como buffer overflow e heaps corruptions).

O reaproveitamento de software também não é total pois alguns ambientes JAVA (Applets e Servlets) não são compatíveis com JNI.

Normalmente JNI são utilizadas para aproveitar alguns drivers que não são acessíveis pela JVM.

Saiba mais:

http://en.wikipedia.org/wiki/Java_Native_Interface

http://java.sun.com/developer/onlineTraining/Programming/JDCBook/jni.html

http://java.sun.com/docs/books/jni/html/intro.html#1811

http://today.java.net/pub/a/today/2006/10/19/invoking-assembly-language-from-java.html

http://ringlord.com/publications/jni-howto/

 

3. Soluções Portáveis

Considere o diagrama abaixo:

p3f1

Observe os 4 objetos: 2 são códigos-fontes e 2 são códigos-objetos. Os destacados de vermelho são os diferentes compiladores, responsáveis em transformar códigos-fontes em códigos de máquina.

O que estamos querendo encontrar aqui é o caminho que saia do código .c e gere um .class, compatível com as JVM.

3.1. Tradução Fonte-para-Fonte (Source-to-Source)

Baseado no diagrama da Figura 01, este método realiza as conversões representadas abaixo:

p3f2

As ferramentas que utilizam esta técnica de tradução são ainda divididas em: tradução parcial (que converte somente partes de código seguras, mas precisam ser completadas pelo programador) e as que fazem a tradução total (e geram erros e conflitos ). Segue abaixo o comentário de algumas soluções.

Jazillian é uma ferramenta de tradução parcial que produz código JAVA altamente legível a partir de um fonte C.

Contudo, traduz somente uma parte da linguagem C e é utilizado principalmente pela sua inteligência na análise sintática do código para evitar conflitos.

Uma vantagem é que ao invés de converter apenas de linguagem para linguagem, converte para algumas APIs do JAVA. Por exemplo, na tradução de arrays de char, do C legado, converte para classe String, do JAVA. “char string1[] = strcpy(string2); ” vira “String string1 = string2;”

Infelizmente, Jazillian não produz toda conversão de código e costuma gerar erros quando converte bibliotecas que possuem código ASM embutido, como as de IO.

É boa para se ter “um norte” quando estiver reprogramando alguma biblioteca de C para o JAVA.

Uma segunda ferramenta de conversão parcial de código, semelhante ao Jazillian, é o Mocha-JAVA. Ela trabalha da mesma forma que o Jazillian, contudo, converte C++ em JAVA.

Outras ferramentas, tais como, o c2j, c2j++, Cappuccino e Ephedra, permitem uma tradução completa de código C/C++ para JAVA. Cada uma das quatro mencionadas acima suportam uma grande parte de código convertida, contudo, costumam gerar alguns erros de E/S.

Porém, a conversão de código-para-código não são satisfatórias devido ao fato de haver diferentes recursos entre o C/C++ e o JAVA.

O erro mais comum encontrado na transcrição de código é relacionado às operações de entrada e saída. Muitas bibliotecas do C são dependentes de plataforma, pois possuem código _asm embutido.

Outro fator de incompatibilidade se dá por que o JAVA não têm recursos – no nível da linguagem – que permitem aritmética de ponteiros (inclusive pelo projeto da linguagem).

Associado à isso, o C/C++ também encontra recursos como structs, unions, casting de tipos dinâmicos, classes amigas, interfaces (que difere do conceito de interface do java), e outros elementos de código que não estão presentes na linguagem JAVA.

Logo, pode-se dizer que a forma menos eficiente de se portar um código para java é com a transcrição código para código.

3.2 Tradução Fonte-para-Bytecode ( source – to – bytecode )

Este caso envolve o processo direto de compilação de uma linguagem para um bytecode.

p3f3

Há uma versão experimental do gcc, atualmente conhecido como egcs-jvm, que tenta converter o código C para bytecode ao invés de instruções de máquina.

Existe também um compilador conhecido como lcc-java.

Em ambos compiladores há um sério problema de compatibilidade, pois ainda não resolveram totalmente os problemas com as aritméticas e manipulações de ponteiros.

Outro grande problema se dá devido ao modelo de gerência de memória empregado por estes compiladores. A gerencia de memória deveria ficar toda para a JVM, e não para o ambiente, levando a existência de conflitos quando o programa necessitar acessar algumas regiões da memória.

 

4. NestedVM

Diferente dos métodos mencionados até aqui, a NestedVM não converte diretamente um código de alto nível (C ou C++) em um outro código na linguagem JAVA e também não compila este código para bytecode.

Na verdade, a abordagem escolhida consiste em traduzir um arquivo compilado em um bytecode.

O processo de conversão do NestedVM é realmente interessante: utilizamos um compilador a parte para converter o código C para ASM MIPS. Em seguida utilizamos o NestedVM para interpretar este assembly e transformar em bytecode.

Entretanto, ainda há a opção do NestedVm realizar uma decompilação, transformando o código assembly em um código-fonte JAVA. Chamamos este processo de binary-to-source.

A forma de trabalho do NestedVM traz uma série de vantagens. A primeira delas é que a NestedVM não irá precisar se preocupar com os processos de compilação, deixando este trabalho a cargo do compilador.

Como o NestedVM trabalha com o código assembly, é possível converter em JAVA o código escrito em qualquer linguagem (e não só em C, embora seja a linguagem que mais estamos mencionando aqui). Basta que o compilador possa traduzir a linguagem em ASM MIPS.

Ou seja, o mesmo compilador que gera o código nativo, é o que gera o assembly utilizado pelo NestedVm. Assim, Makefiles, cabeçalhos, estruturas complexas, fica tudo compatível.

 

4.1 Por quê MIPS?

Segundo os criadores do NestedVM, a escolha pelo MIPS se dá por 3 motivos:

1. MIPS é uma arquitetura muito conhecida no meio da ciência da computação. Foi uma das primeiras arquiteturas RISC e muitas outras arquiteturas se basearam em seus conceitos. Sendo assim, há uma quantidade muito grande de ferramentas e compiladores para MIPS.

2. O MIPS ISA é muito mais simples que o x86.

3. A criadora do JAVA, Sun Microsystems, possui uma tecnologia de processadores baseada no MIPS: o SPARC. Provavelmente por isso a ISA (Instruction Set Architeture) da JVM seja parecida com do conjunto de instruções do SPARC. E, portanto, tão semelhante com a ISA MIPS.

A “GNU Compile Colection” (GCC) é capaz de compilar C, C++, JAVA, FORTRAN, Objective C e Pascal para a arquitetura MIPS. Portanto, é possível converter qualquer programa escrito nessas linguagens para JAVA.

Outra grande vantagem do MIPS é que, assim como o JAVA, ele possui a maioria das instruções com o tamanho fixo de 32 bits. Sendo assim, tanto o MIPS quanto o JAVA conseguem endereçar a mesma quantidade de memória.

Isso supera os problemas mencionados nos casos de compilação direta para o bytecode JAVA.

O NestedVM pode representar a memória como um array int[ ][ ] no JAVA. Ele indexa por pagina os primeiro n bits como sendo de endereço e os bits restantes como sendo os valores.

Logo, quebrando a memória em páginas, torna-se possível alocar dinamicamente espaço na memória da JVM, permitindo uma compatibilidade com comandos new, delete, malloc e free do C/C++ – funções problemáticas para os outros meios de conversão que comentamos.

Veja uma instrução de acesso à memória MIPS convertida em JAVA:

MIPS:

// lw v0, 20(sp) ; # carreg uma palavra de 4 bytes em sp+20, sp(stack pointer)

JAVA:

v0 = memory[(sp+20)>>>16][(sp+20)0xffff];

Além do tamanho das instruções de manipulação de memória, o tamanho das instruções aritméticas também são semelhantes. O MIPS r2000 permite instruções de multiplicar e dividir como sendo de única ou dupla precisão de unidade de ponto flutuante. Esse conjunto de instruções também é encontrado na JVM. Ou seja, a maioria das instruções MIPS é semelhante às instruções do bytecode.

4.2 Binary – to – Source

O primeiro modo operacional do NestedVM é a tradução binary-to-source. Deste modo, NVM traduz o binário MIPS em um código fonte JAVA e só então compila em um bytecode.

Veja o esquema abaixo:

p3f4

O processo de tradução segue 4 etapas: 1 – código fonte C é compilado e feito todo processo de linkagem. 2 – NestedVM é usado para emitir o arquivo .java (fonte JAVA). 3 – O arquivo resultante .java é compilado em um bytecode .class via javac. 4 – em tempo de execução a JVM invoca o método run() na geração da classe, isso irá equivaler à entrada da função main() do C.

4.3 Binary – to – Binary

p3f5

Este modo é mais recomendado e possui algumas vantagens.

Gerando o bytecode diretamente do MIPS há chance de gerar código que não seria compatível se transformássemos em um source .java. Algumas operações com ponteiros, permissão de acesso e operações específicas da linguagem inicial poderiam não ter estruturas no fonte .java.

Gerando .class diretamente eliminamos o tempo de compilação do javac .

Compilação direta para arquivos .class permitem tradução dos binários MIPS com o carregamento via ClassLoader.fromBytes() , eliminando a necessidade de compilar um programa que carrega todos seus módulos de uma vez na memoria.

4.4 Chamadas ao sistema – syscalls

Sabemos que um programa, quando compilado, além de dependente de arquitetura se torna dependente de sistema operacional.

Isso acontece por que o sistema operacional deveria ser o responsável pelo gerenciamento dos dispositivos de hardware e nosso programa deve interagir com o SO para solicitar os recursos de entrada e saída.

Em um nível mais baixo, a comunicação entre aplicativo e SO é feita através de códigos associados à instrução MIPS chamada syscall.

Funciona assim: o programa envia um valor relacionado à um recurso do sistema operacional. O controle do programa naquele ponto é transferido para o Kernel do SO. Então, o kernel verifica se o código equivale á uma operação válida e decide o que fazer. Feita a operação, retorna a execução do programa.

Um exemplo com um “Hello World” em ASM MIPS:

string: .asciiz "Hello World \n"        ; # string é um label
        li        $v0, 4                                ; # código da chamada do sistema
        la        $a0, string                        ; # carrega e coloca em $a0
        syscall                                        ; # faz a chamada de sistema
        # REFERENCIA DO SYSTEMCALL
        # http://www.doc.ic.ac.uk/lab/secondyear/spim/node8.html

É importante discutir sobre esta comunicação pois o NestedVM têm ainda que converter chamadas do sistema em chamadas para a JVM.

4.5 NestedVM Runtime

Na hora da tradução o NestedVM embute um código que simula as syscalls nas comunicações com o aplicativo. Ele trabalha como o kernel real, mantendo as informações de estados por processos: tabela descritora de arquivos, caching, diretório atual, etc.

A responsável por controlar as solicitações de recursos e atuar como o SO é uma classe chamada Runtime, que faz parte do NestedVM. Para cada interação via syscall temos uma nova instância da Runtime.

Por exemplo, uma instrução syscall, do java, mapeada como uma método chamado syscall:

v0 = syscall(v0,a0,a1,a2,a3); // syscall

Exemplo: Implementando em JAVA a chamada syscall

/* Aqui o método syscall() executa uma operação baseada no 
                número-código de referencia */
 
        int syscall(int sc, int a, int b, int c, int d) {
          switch(sc) {
                  case SYS_write: return sys_write(a,b,c,d);
                  case SYS_open: return sys_open(a,b,c,d);
                  ....
          }
        }

Há duas implementações do NVM Runtime, uma simples com suporte mínimo de instruções requeridas para compilar código ANSI C e outra mais sofisticada, que emula grande parte das systemcalls da implementação POSIX.

4.5.1 ANSI C Runtime

O ANSI C Runtime oferece operações tipicas de entrada e saída tais como, open(), read(), write(), close() e seek().

Os descritores de arquivos são implementados da mesma forma como são nos kernels dos SO.

A tabela de arquivos é mantida e os descritores agem como índices desta tabela.

Cada descritor de arquivo é representado como uma classe Java RandomAccessFile .

O gerenciamento de memória no nível de processos é feito através da syscall sbrk() que extende o heap do processo adicionando mais páginas à tabela de memória.

Operações de cópia e liberação de memória pode ser feita com os métodos memset() e memcpy(), que invocam os métodos System.arraycopy() do Java.

A chamada exit() realiza a saída do processo e libera o controle para a JVM.

4.5.2 POSIX Runtime

POSIX (Portable Operating System Interface), como o próprio nome sugere, é um conjunto de normas (padrão IEEE 1003) criada para manter a portabilidade entre sistemas diferentes que implementam esta mesma norma.

É usada pela NestedVM para garantir a portabilidade entre as máquinas virtuais do sistema traduzido.

A implementação POSIX estende a ANSI C nos modelos de E/S – incluindo um sistema de arquivos e nós de dispositivos – tratando dispositivos como arquivos com interface de programação idênticas.

O uso de uma classe que simula a implementação POSIX como interface de acesso à JVM permite um isolamento da camada de tratamento de dispositivos pelo JAVA.

Para o acesso cada sistema de arquivos é implementado em uma classe Java, que poderia, por exemplo, acessar um host do sistema e executar operações tais como: state(), lstat(), mkdir(), unlink(), o conteúdo de um arquivo compactado ou até mesmo a resposta de um servidor externo.

A chamada fork() é implementada no Java pelo uso de um método clone() ( herdado da classe Object ). Uma nova instancia é adicionado à tabela de processos para facilitar a comunicação entre eles.

O metodo exec(), que carrega o binário MIPS do sistema de arquivos, se valida da classe Class.loadBytes() para a conversão MIPS – to – bytecode .

A API waitpid() permite, que um processo pai bloqueie a execução de um processo filho, é modelado pelo uso do processo wait() do JAVA.

Já a chamada de sistema pipe(), que permite a comunicação entre processos pai e filho, é emulada todo controle de escalonamento e deadlock dentro da JVM, exatamente como nos sistemas UNIX.

O suporte à rede também é tratado com socket. É provido pelos métodos opensocket(), close(), listensocket() e accept().

Assim, todo o sistema fica isolado do mundo externo pelas syscalls emuladas pelas implementações do Java.

5. Cross-Compile

Segundo a Wikipédia: “Um cross compile é a capacidade de um compilador de criar códigos executáveis para uma plataforma diferente da plataforma em que se está sendo compilada.

“Geralmente são usadas para gerar executáveis para sistemas embutidos ou multi-plataformas”.

Veja mais em: http://en.wikipedia.org/wiki/Cross_compile

5.1 Cross-compile e NestedVM

Como o NestedVM utiliza o código asm MIPS, utilizamos uma “cross compilation” para transformar o código da linguagem inicial (C, FORTRAN, Pascal) em um código compilado ASM.

Para isso, utilizamos a “NewLib” ( http://sourceware.org/newlib/ ), biblioteca escrita em C mantida pela Red Hat e usada para compilar fontes para sistemas embutidos.

Quando compilamos o NestedVM fornecido pelo autor ( http://nestedvm.ibex.org/ ), o arquivo Makefile faz o download de todas as dependências necessárias. Contudo, há alguns arquivos que estão com os links quebrados. O que pode acarretar em uma falha ao compilar.

Contudo, há uma versão alternativa do Make, que calcula as dependências e baixa os arquivos necessários. Para conseguir a versão do nosso site faça o download do arquivo em http://www.sawp.com.br/nestedvm/nestedvm-2008-08-06.tar.gz .

6. Instalando NestedVM

O tutorial aqui é utilizando o fonte do nosso site [ http://www.sawp.com.br ], que possui as correções de dependência, além de possuir todos os pré-requisitos para compilação no próprio site.

6.1 instalando no UNIX

Abra um shell e digite os passos abaixo:

$ cd ~
 
$  wget http://www.sawp.com.br/nestedvm/nestedvm-2008-08-06.tar.gz

Após o download do arquivo, descompacte o tarball:

        $ tar -xfz nestedvm-2008-08-06.tar.gz
 
        $ cd nestedvm-2008-08-06
 
        $ make

Provavelmente irá compilar com sucesso. Após a compilação, você pode verificar a instalação:

        $ make test

Se o NestedVM for compilado com sucesso, algo como o resultado abaixo deve aparecer:

        Constructor!
 
        Entered main()
 
        16777215
 
        16777215
 
        16777215
 
        16777215
 
        argv[0] = "tests.Test"
 
        argv[1] = "arg 1"
 
        argv[2] = "arg 2"
 
        argv[3] = "arg 3"
 
        getenv("USER") = "(null)"
 
        getenv("HOME") = "(null)"
 
        getenv("TZ") = "(null)"
 
        GMT GMT 0
 
        Running ctime
 
        ctime returned: 0x2b958
 
        Current time: Wed Aug  6 14:39:37 2008
 
        Trying to open /nonexistent
 
        open: No such file or directory
 
        Tyring to mkdir .mkdirtest
 
        Attempted to use a UnixRuntime syscall in Runtime (18)
 
        mkdir: Function not implemented
 
        Trying to opendir .
 
        opendir: No such file or directory
 
        1.574000e+00
 
        -4.315000e+01l
 
        -43
 
        -4.315000e+01
 
        4.315000e+01
 
        Hello, World
 
        7F
 
        fabs(-2.24) = 2.34
 
        Destructor!

Se você chegou neste ponto, provavelmente já está com as ferramentas necessárias instaladas para compilar binários em MIPS (gcc, red hat newlib e binutils).

Caso haja algum problema durante a compilação, por favor, relatar via e-mail: sawp@sawp.com.br

6.2 Compilando de C para MIPS

Agora, adicione o ambiente de cross-compilation à variavel de CLASSPATH:

        $ make env.sh
 
        $ source env.sh

Feito esses passos, agora iremos demonstrar os passos e como proceder para compilar um código .c, .cpp, .pas ou .f para um .class java.

Para isso, faça o download de 2 arquivos de teste no nosso site:

        $ wget http://www.sawp.com.br/nestedvm/test.c
 
        $ wget http://www.sawp.com.br/nestedvm/test2.c

Agora, iremos compilar os arquivos em um binário MIPS:

        $  mips-unknown-elf-cpp -o test.mips test.c
 
        $  mips-unknown-elf-cpp -o test2.mips test2.c

Pronto! Acabamos de compilar em código objeto MIPS.

Para visualizar a diferença entre o assembly da sua máquina e o assembly MIPS, execute:

        $  mips-unknown-elf-cpp -S test.c         (test.s em MIPS)
 
        $gcc -S test.c        (test.s na arquitetura da máquina).

6.3 Convertendo de MIPS para bytecode JAVA

Até aqui temos um código objeto que executaria perfeitamente em uma arquitetura MIPS. Agora, para converter para JAVA execute:

        $ java org.ibex.nestedvm.Compiler -outfile Test1.class Test1 test.mips

Se tudo correr bem, nada aparecerá. Para testar, digite:

        $ java Test1
 
        Hello World

O segundo exemplo, testa a entrada de dados. Para isso, digite um valor de entrada para o programa:

$ java org.ibex.nestedvm.Compiler -outfile Test2.class Test2 test2.mips
 
        $ java Test2
 
        100 #digite um inteiro
 
        2 
 
        3 
 
        5 
 
        7 
 
        11
 
        13 
 
        17 
 
        19 
 
        23 
 
        29 
 
        31 
 
        37 
 
        41 
 
        43 
 
        47 
 
        53 
 
        59 
 
        61 
 
        67 
 
        71 
 
        73 
 
        79 
 
        83 
 
        89 
 
        97

Se tudo estiver ok, o programa mostrará todos primos de 2 até o valor de entrada.

6.4 Observações

Tentaremos, agora, compilar o Código da Pílula Vermelha:

        $  wget http://www.sawp.com.br/nestedvm/redpill.c
 
        $ mips-unknown-elf-gcc -o redpill.mips redpill.c
 
        $ java org.ibex.nestedvm.Compiler -outfile Redpill.class Redpill redpill.mips

Se o NestedVM estiver instalado corretamente na máquina, tudo dará certo até aqui. Contudo, quando executarmos o executável Java:

        $  java Redpill

Provavelmente teremos o erro:

org.ibex.nestedvm.Runtime$ExecutionException: Jumped to invalid address in trampoline (r2: 268435376 pc: 268435376) at (unknown)
 
                at Redpill.trampoline(redpill.mips)
 
                at Redpill._execute(redpill.mips)
 
                at org.ibex.nestedvm.Runtime.__execute(Runtime.java:506)
 
                at org.ibex.nestedvm.Runtime.execute(Runtime.java:523)
 
                at org.ibex.nestedvm.Runtime.run(Runtime.java:545)
 
                at org.ibex.nestedvm.Runtime.run(Runtime.java:538)
 
                at org.ibex.nestedvm.Runtime.run(Runtime.java:537)
 
                at Redpill.main(redpill.mips)

Isso ocorre porque o código C continha instruções assembly de outra arquitetura embutidas. Note que a operação de desvio não foi reconhecida, por isso não pode ser emulada.

Para o NestedVM funcionar corretamente é preciso que o código seja escrito de forma mais portável possível, sem utilizar instruções ou recursos dependentes de plataforma.

Operações bit-a-bit, como shift left (<<), embutir blocos _asm{} e códigos de baixo nível inviabilizam o uso do NestedVM, pois inserem no código objeto instruções "não-MIPS". Sendo assim, é importante possui o código original mais independente possível de arquitetura, referenciando-se somente na linguagem.