While most software developers have been praising the advantages of Java programming, embedded developers have been sitting idly by, waiting for a Java implementation that would complement their non-standard environments. The wait may soon be over. Java batch compilers and toolsets are starting to emerge, and may make using Java in your next project a more realistic option.
Java is quickly fulfilling its potential to revolutionize the software industry. Fully object-oriented and strongly typed, Java is more structured than C, easier to learn than C++, and more popular than competing languages like Ada and Smalltalk.
So, why aren't more embedded developers using Java on their embedded projects? There are two reasons:
- Java is big. Even in its most stripped down form, Java requires about 500Kb for its standard libraries (in bytecode form) and another 500Kb for its runtime environment. Including application code, it is near impossible to run Java in an embedded system with less than a Megabyte of memory.
- Java is slow. Most benchmarks indicate that a typical Java interpreter will execute code at about one-tenth the speed of compiled C code and no better than one-quarter the speed of compiled C++. Even using faster interpretation devices like JIT compilers, runtime execution is quite a bit slower than that of typical embedded languages.
However, this doesn't mean that Java doesnt have a place in the embedded world. Ironic as it may seem, Java was originally developed as an embedded language, specifically for use in the development of set-top boxes and hand-held electronic devices. Java provides built-in security mechanisms and encryption features that make developing secure environments easy and standardized. Native language support means Java code can call routines written in other programming languages, such as C or Assembly. Java even provides built-in threading capabilities for ease in development of multitasking systems.
In fact, if your embedded system has extra memory and slack performance requirements, there are very few reasons not to consider Java. Unfortunately (or perhaps fortunately), its not so common to find embedded projects with these traits. Therefore, any tool that would help the embedded developer overcome these two major shortcomings of Java would be a welcome addition to the embedded toolbox.
A Java compiler is just such a tool. While still in their infancy, Java compilers (also known as ahead-of-time or AOT compilers) may be the breakthrough needed to propagate widespread use of Java throughout the embedded industry. The next section of this article will discuss how these compilers may solve many of the problems currently associated with using Java in an embedded system. We will then explore the technical implementation of a Java compiler toolset. Finally, we will examine some of the third-party Java toolsets announced for release in the coming months.
Traditional Java
Traditionally, Java is an interpreted language. Prior to runtime, Java application code and supporting libraries are translated to an intermediate form called bytecodes. This is typically accomplished using a standard Java translator, like javac. At runtime, these bytecodes are dynamically downloaded to the target system via a runtime environment called a Virtual Machine (VM). A typical Java implementation is illustrated in Figure 1.
The VM houses a Java interpreter that interprets bytecode on the fly, executing each opcode before interpreting the next. Any code that is reused by the system -- as in the case of a program loop or through recursion -- must be reinterpreted each time it is executed. In addition, Java translators cannot optimize bytecode for size, as this would ultimately slow interpretation of the code at runtime, increasing the negative performance impact. From this, it is obvious why traditional Java implementations are both bloated and slow.
Just-In-Time (JIT) compilers presented a possible solution to many of the problems faced by traditional interpreters. By translating bytecodes just prior to their execution, and storing the translated bytecodes in memory, JITs are more efficient than Java interpreters. Nevertheless, they too have their drawbacks.
JITs still must translate bytecode at runtime, thereby eliminating the possibility for optimization. In addition, a JIT's execution-time advantage is often offset by its horribly slow startup time. Lastly, as part of the VM, JITs still require costly RAM from which to run.
Real-time Java
It may seem that I am ignoring another fundamental drawback of using Java in an embedded environment -- its lack of real-time characteristics. This is not unintentional. Unfortunately, the embedded industry has been hard-pressed to overcome the problems associated with using Java as a real-time development language. A lack of real-time garbage collection implementations and an incomplete scheduling specification by Sun Microsystems will, for the most part, prohibit the use of Java in real-time development efforts for the foreseeable future. Fortunately, many embedded projects dont have real-time constraints and will not be hindered by Java's lack of real-time characteristics. User interfaces, remote-monitoring devices, and non-real-time control systems are a few examples. It is towards the developers of these types of projects that this article is geared. |
Compiling Java
Java compilers will allow developers to precompile their Java code, much the same as traditional compiled languages, such as C or C++. Besides offering embedded developers a familiar development environment, compiling Java provides quite a few other benefits over an interpreted Java implementation.
A system running compiled Java requires less memory than a traditional Java implementation. By eliminating the runtime interpreter and reducing the need for certain parts of the Java runtime, the overall size of the environment is reduced. In addition, because there is no need for on-the-fly interpretation of Java bytecode, the compiler can perform code-size optimizations without facing a performance penalty.
As for performance, compiled Java runs many times faster than its interpreted counterpart. Because compiled code is processor-ready, interpretation at runtime isnt necessary, greatly diminishing runtime overhead. Moreover, because compiled code can be optimized without impacting performance, the compiler can perform speed optimizations at compile-time. With a mature optimizing compiler, it would be realistic to expect performance approaching that of compiled C or C++.
Compiling Java provides other benefits as well. Fully integrated toolsets will allow Java-based object files to be linked and ROMed with compiled code written in other languages. Existing source-level debuggers can be easily retrofit to accommodate Java, allowing users to debug every part of a mixed-language environment. And, flexible runtime configurations will allow developers to determine which parts of the Java runtime to include, and which parts not to include, giving developers more control over runtime code-size and functionality.
Let's get technical
In general, there are many methods for generating native object code from higher-level code files. As such, there are numerous compiler implementations. Java compilers are no different; while there may be unique problems associated with converting Java code to native processor code, there are many methods for doing so.
To give a more detailed examination of the compilation process and the implementation details of a Java compiler, I have decided to focus my discussion on one particular compiler design. Specifically, I have chosen the jc1 Java compiler and toolset currently under development by Cygnus Solutions. Among the reasons for choosing this compiler was the fact that the jc1 is based on the GNU gcc compiler for C and C++. gcc is a high-quality, portable implementation that supports numerous platforms and is popular throughout the embedded industry.
Now lets get down to business. There are four major steps in the Java compilation process:
- Code verification
- Native code generation
- Symbol table generation
- Static reference resolution and linking
Lets examine each of these steps in more detail.
Code verification
The first step in the compilation process is to verify the integrity of the high-level code being passed to the compiler. A mature Java compiler will be able to generate object code from either Java source code files or Java bytecode (.class) files. While the majority of application compilation will most likely be performed directly from Java source code, it is often desirable to compile Java from bytecode files. This is most often the case when developers are provided third-party libraries in bytecode form, without the associated source.
The jc1 compiler can operate on both bytecode files and Java source files. Traditionally, it has been the job of the Java runtime interpreter or JIT to perform runtime analysis and verification of Java bytecode files. In a compiled Java environment, where there often doesnt exist an interpreter or JIT, it is the job of the compiler to perform an equivalent verification of the Java bytecode.
Generally, this bytecode verification will map closely with the verification steps outlined in Sun Microsystems Java Virtual Machine Specification, the verification scheme required by all Sun compliant Java interpreters. jc1 integrates the Sun specified verification steps into a larger overall bytecode verification process. This iterative process includes the following steps:
1. Read the .class file and verify that it is well formed.
2. Evaluate and perform consistency checks on the bytecode symbol table.
3. Determine the flow of the executable code, and generate straight-line code from original code that may contain jumps.
4. Perform verification steps similar to those outlined in Sun's Virtual Machine Specification (section 4.9.2).
Verifying Java source code is somewhat simpler than verifying bytecode. Though the compiler enforces constraints similar to that for bytecode verification, these are simplified by the fact that the actual code structure is present. The two major steps involved in source-code verification not present in bytecode verification are: 1) checking the syntactic and semantic integrity of the source code, and 2) performing simple name resolution.
Once the source or bytecode is verified, the compiler can begin translating the high-level code to native machine code.
Native code generation
As mentioned earlier, the jc1 compiler is based on the GNU gcc compiler for C and C++. The same modular design that gives gcc the flexibility to be easily ported to new processor platforms also gives it the versatility to support new programming languages. This modular design is based on breaking the compiler up into two discrete parts the compiler front-end and the compiler back-end.
The compiler front-end is responsible for translating the higher-level Java code -- either source or bytecode -- to an standard internal representation known as Register Transform Language or RTL. This requires several steps. First, high-level Java code is converted to an intermediate form known as abstract tree representation, a fairly machine-independent, high-level code representation. The front-end then performs various high-level, platform-independent, code optimizations, and finally expands the resulting expressions into RTL representation.
It's the job of the compiler back-end to generate assembly-level code from this RTL representation. While still in RTL representation, the compiler performs a series of platform-dependent code optimizations. The compiler then converts the optimized RTL to target-specific assembly code. This code is passed to the standard GNU assembler, which generates native object code for the target processor.
The beauty of this design is that the compiler front-end can be modified to support new high-level code syntaxes without having to alter the compiler back-end. It must only guarantee that its resulting output be standard RTL representation, understood by the compiler back-end. Moreover, the compiler back-end can be ported to multiple processor platforms without requiring changes to the compiler front-end. Its only requirement is that it must work from this RTL representation.
The gcc compiler currently supports over one hundred processor variants, and, as such, has more than one hundred compiler back-end components. Therefore, once the gcc front-end is modified to support the Java language, jc1 automatically supports each of these processor variants.
Symbol table generation
Generating executable code is only part of the compilers job. The Java runtime requires a great deal of information about the classes, methods, and data that it will be using. This information, known as the meta-data or reflective data of each class, is stored in a large data structure at runtime, and must be initialized before the compiled classes can be used.
Java bytecode files already contain this meta-data. Moreover, if the Java runtime implements a mechanism for dynamic class loading, it knows how to translate this pre-encoded information. Therefore, if compilation is from bytecodes and the runtime supports dynamic class loading, this part of the compilation process merely consists of retrieving the pre-encoded meta-data from the bytecode file and passing it to the resulting compiled file.
If, on the other hand, compilation is being performed from source code or the runtime does not support dynamic class loading, it is the responsibility of the compiler to generate this information and present it in a usable way. jc1 generates this information at compile-time by allocating and initializing static data for each class as it is compiled. By initializing the class meta-data at compile-time, runtime initialization is minimized, with the only runtime initialization task being the registering of each class meta-data in the global symbol-table.
As an example, consider the following Java class:
public class Foo extends Bar { public int a; public int f(int j) { return a+j; } };
This class would compile into something like:
int Foo_f(Foo* this, int j) { return this->a + j; } struct Method Foo_methods[1] = {{ /* name: */ "f"; /* code: */ (Code) &Foo_f; /* access: */ PUBLIC, /* etc */ ... }}; struct Class Foo_class = { /* name: */ "Foo", /* num_methods: */ 1, /* methods: */ Foo_methods, /* super: */ &Bar_class, /* num_fields: */ 1, /* etc */ ... }; static { RegisterClass(&Foo_class, "Foo"); }
Static reference resolution and linking
The compilers final task is to resolve references from one class to static methods or fields of other classes. Assuming both classes are compiled, these static references can simply be replaced by direct function calls or direct access to static field values. Static references to classes that are not precompiled can be supported by adding an extra level of indirection -- creating a trampoline stub that can jump to the correct method while preventing linkage errors.
Once compilation is complete, object files are ready to be linked with other objects or placed in libraries. A robust toolset will allow compiled Java objects to be linked with code or libraries written in other languages, such as C, C++, or assembly.
jc1 generates standard assembly files that are assembled with the GNU assembler. The resulting objects can be linked with other gcc-derived objects or put into a standard static or dynamically linked library. An example of the optimized assembly output from jc1, and a comparison to that from a typical Java interpreter is offered in Table 1.
Runtime requirements
As does traditional Java, a compiled Java implementation depends greatly on a robust runtime environment. In fact, a compiled Java runtime environment may require the exact same functionality offered by standard runtimes. On the other hand, there are many situations where the traditional Java runtime would be overkill for the compiled application. The compiled Java runtime environment can be broken down into discrete parts:
Interpreter/JIT If your embedded application relies on the ability to dynamically download applets or other Java bytecode (.class) files, you will need a standard Java interpreter or JIT integrated into your runtime environment. Embedded systems without the need for dynamically loaded code will not require an interpreter or JIT.
Dynamic Class Loader If your application requires classes to be dynamically loaded at runtime, a dynamic class loader must be integrated into the Java runtime environment.
Standard Java Libraries Just as C and C++ provide standard runtime support libraries, so does Java. These include java.lang, java.io, and other standard Java libraries. Oftentimes, these libraries are statically linked with your application at compile-time, much as the analogous C/C++ libraries are to compiled C/C++ code.
JNI or other Native Interface The Java Native Interface (JNI) is a standard API used for calling native methods -- code written in other languages, such as C/C++/Assembly -- from Java code. A complete runtime environment will include JNI or some other lower-level native interface library.
Garbage Collector Because all memory allocation and collection in Java is done automatically by the Java environment, your Java runtime environment must integrate a garbage collector or a mechanism for adding your own garbage collection thread.
Reflection Information One Java library package in particular -- java.lang.reflect -- requires reflection information about the other Java classes in the system. Because the static linker may only have linked in the Java libraries used by your Java code, this reflection information may not be available in a simple compiled Java implementation. If java.lang.reflect is used by your application, including this reflection information in your runtime environment will be necessary.
To achieve compiled Java's goal of decreasing memory constraints on the developer, the standard compiled Java runtime environment should be easily configurable and scaleable, allowing any subsets of the above-mentioned functionality to be included or removed from the runtime.
Further thoughts about compiler optimization
We've spent most of this article glorifying the optimization potential of Java compilers. In reality, compiled code is generally larger than the original bytecodes. This is not counter-intuitive, considering that most Java VM instructions require only one or two bytes. When compiled, these byte codes are often transformed into multiple-byte processor instructions. Depending on your target architecture, a single Java bytecode might compile to upwards of ten bytes. Even fully optimized, compiled objects may be larger than their bytecode counterparts. But this doesnt necessarily mean compiled Java takes more memory than traditional Java implementations.
Oftentimes, the code-size savings of compiled Java are more a direct result of reduced symbol table information in the runtime code. Both .class files and compiled Java objects must store information about themselves and the other code -- application and libraries -- in the system. For .class files, this extra symbol information is usually very large with respect to the executable code. While compiled objects store similar symbol data, the exact amount is generally considerably less than that of standard .class files.
A benchmarking example by Cygnus Solutions used their jc1 compiler to compile a simple test program that calculates the Fibonacci numbers. While the Java bytecode instructions for this function required only 134 bytes, the optimized compiled instructions required 373 bytes (Intel 80x86 architecture). But, after including symbol table information, the bytecode-based .class files increased to over 1200 bytes; the compiled object, including symbol information, increased only about 500 bytes, taking it to still under 900 total bytes.
Code-size savings are not just a result of compile-time optimizations. Java runtime environments, configured for specific systems, can often eliminate unnecessary functionality, thereby reducing costly overhead. For example, a runtime that does not require dynamic class loading can eliminate its dynamic class loader as well as its interpreter or JIT. Other saving stem from standard libraries being statically linked with Java application code, thus discarding any libraries not directly accessed by your application. In addition, Java application code and the Java runtime can reside in ROM, saving valuable RAM that, in typical Java environments, is required for interpretation.
Real-world solutions
To achieve its goal of providing both enhanced performance and code-size savings over traditional interpreted Java implementations, Java compiler toolsets must be flexible and scalable. Specifically, the compilers themselves should offer an array of optimization capabilities and the Java runtime environments should allow developers to implement as much or as little runtime functionality as desired. In addition, to gain popularity among a majority of embedded programmers, the forthcoming Java tools must eventually support a wide range of development platforms and target environments.
As of this writing, at least three major tool vendors are preparing to announce the release of their embedded Java toolsets. Cygnus Solutions, commercial developers of the GNU embedded tools standard, is planning to announce the Cygnus Foundry Java Edition, its compiler and toolset based around the jc1 Java compiler I've discussed. Operating systems vendor Microtec has announced plans to release a Java toolset based on the VRTX real-time operating system and XRAY debugger. And Diab Data, embedded compiler specialists and a subsidiary of operating systems vendor Integrated Systems, Inc. (ISI), will release its Java compiler suite later this year.
The Cygnus Foundry Java Edition, based on the jc1 compiler, will use the same technology as the GNU gcc compiler for C and C++. Like gcc, jc1 and the associated tools should eventually support most 32- and 64-bit embedded processor platforms, including SPARC, Alpha, 68k, PowerPC and MIPS. The toolset is expected to provide full support for a wide range of GNU tools, including the GNU assembler, linker, and debugger.
The jc1 runtime environment will be based upon the popular Kaffe freeware Java virtual machine. The integrated runtime will be scaleable, allowing developers to choose among Kaffes wide range of functionality. In addition, both JNI and a lower-level, VM specific application binary interface (ABI) will be offered. One of the most useful features of Cygnus runtime environment is that it will support interoperability between precompiled Java code and dynamically downloaded Java bytecode classes. Some runtime performance benchmarks and comparisons released by Cygnus are provided in Table 2.
Currently in beta, Microtec's Java Toolkit provides integrated support with its VRTX real-time operating system and XRAY debugging tools. Microtec Java uses the same back-end code-generator, linker, and assembler as their existing Microtec C and C++ tools, and will support compilation of both Java source and bytecode with full optimization capabilities. The initial release of the toolset will support PowerPC targets with development environments on Windows 95/NT and Solaris hosts.
The Microtec Java runtime environment will be based on a scaled-down VRTX kernel. Future releases of the toolset will integrate support for native threads, providing transparent multithreading and synchronization services between Java threads and standard VRTX tasks. In addition, the runtime will provide its own Java native interface, including ABI support for Microtec C and C++ standard libraries.
Perhaps the nicest feature of the Microtec Java Toolkit is its integration with Microtec's XRAY debugging tool. XRAY is an integrated debugging tool, offering support of various debugging scenarios, including instruction set simulation, target-resident monitoring, and HP processor probe interfacing. Each of these debugging modules is fully Java-enabled, offering debug capabilities of a pure Java or mixed Java/C/C++ environment.
Embedded compiler vendor Diab Data will soon announce its cross-compiler solution for Java-based embedded development. Diab Data's compiler suite will be based on their existing D-CC and D-C++ compilers for C and C++, using the same compiler back-end and user interface. The product will provide support for mixed language environments, without the need for a virtual machine.
The Java compiler uses a clean-room implementation of the most essential Java libraries and provides a flexible and configurable garbage collection mechanism. Diab Data's compiler suite will be integrated with ISI's pRISM and pSOS operating systems, allowing development of deterministic real-time threads that are interoperable with native threads of these operating systems.
Portability
No Java discussion would be complete without touching on the subject of portability. Many Java purists would argue that by compiling Java, you violate Java's "Write Once, Read Anywhere" promise. While its true that compiled Java is not portable between platforms, the original source code and pre-compiled bytecodes still are. In addition, because portability is much less often a requirement in embedded development, this drawback to Java compilation should not alone deter its use.
It is still too early to determine the impact of Java compilers on the embedded software industry. One thing seems certain, though: theyre a step in the right direction. At the very least, Java compilers will provide a mechanism for implementing those pieces of the embedded software that are best-suited for Java, and will no doubt have embedded tool developers planning their newest products with Java developers in mind.
Acknowledgments
The author wishes to thank Per Bothner of Cygnus Solutions, a developer of jc1 who contributed much of the technical detail for this article.
This article was published in the September 1998 issue of Embedded Systems Programming. If you wish to cite the article in your own work, you may find the following MLA-style information helpful:
Steinhorn, Jason. "Compiling Java: Java Compilers for Embedded Systems," Embedded Systems Programming, September 1998, pp. 42-56.