OP2: A PORTABLE OBERON-2 COMPILER

Presented at the 2nd International Modula-2 Conference, Loughborough,
Sept 91

Regis CRELIER
Institut für Computersysteme, ETH Zürich

ABSTRACT

A portable compiler for the language Oberon-2 is presented. Most related
works pay for portability with low compilation speed or poor code
quality. Portability and efficiency have been given the same importance
in our approach. Hence, an automated retargetable code generation has
not been considered. The compiler consists of a front-end and a
back-end. The front-end does the lexical and syntactic analysis,
including type checking. It builds a machine-independent structure
representing the program. This structure is made up of a symbol table
and an abstract syntax tree, rather than a stream of pseudo-instructions
coded in an "intermediate language". If no errors are found, control is
passed to the back-end which generates code from this intermediate
structure. This structure clearly separates the front-end which is
machine-independent from the back-end which is machine-dependent. While
the front-end can remain unchanged, the back-end has to be reprogrammed,
when the compiler is retargeted to a new machine.

This compiler has been successfully used to port the Oberon System onto
different computers. Code generators have been implemented both for CISC
and RISC processors. Differences in processor architectures are
reflected in the complexity of the back-end, the generated code density
and performance. The compiler is written in Oberon. New compilers have
therefore to be first compiled on an already working Oberon System. If
such a system is not available, a version of the compiler whose back-end
produces C code may be used for the bootstrap.

The compilation techniques presented here are not restricted to Oberon
compilers, but could be used for other programming languages too.
Nevertheless, Oberon and OP2 tend to the same ideal: simplicity,
flexibility, efficiency and elegance.

INTRODUCTION

Portability is an important criterion for program quality. A compiler is
a program as well, and it may be ported. If it should produce the same
code as before on the new machine (cross compiler), then it is not more
difficult to port it than any other program also written in a higher
programming language. But if the produced code must run on the new
machine, the compiler has to be rewritten and it is not the same program
any more. In that sense, the compiler is not, and cannot be, portable.
By the term portable compiler , we refer here to a compiler that needs
reasonably small effort to be adapted to a new machine and/or to be
modified to produce new code. Most related work attempt to reduce this
adaptation cost to a minimum, compromising the compilation speed and the
code quality. A classification of such automated retargetable code
generation techniques and a survey of the works on those techniques are
presented in [1]. The basic idea is to produce code for a virtual
machine. This code is then expanded into real machine instructions. The
expansion can be done by hand-written translators [2] or by a
machine-independent code generation algorithm, in which case each
intermediate language instruction [3] or each recognized pattern of
these [4] is expanded into a fixed pattern of target machine
instructions recorded in tables. Trees may replace linear code to feed
the pattern matching algorithm [5, 6, 7]. These techniques usually yield
poor code quality, making a peephole optimization phase necessary, which
further increases the compilation time.

In our approach, we tried to find the right balance between code
quality, compilation speed and portability. We think it is worth-while
investing, say three man-months, for a port, if the resulting compiler
is very fast and produces efficient code. Thus, a pattern matching or
table-driven code generation has not been considered. Instead, we looked
at more conventional and faster techniques, such as single-pass
compilation [8, 9]. In a single-pass compiler, the compilation phases
are executed simultaneously. No intermediate representation of the
source text is needed between the different phases, making the compiler
compact and efficient, but not very portable. Indeed, since
machine-dependent and machine-independent phases are mixed up, it is
very difficult to modify the compiler for a new machine.

One solution to the problem is to clearly separate the compilation
phases into two groups: the front-end consisting of the
machine-independent phases (lexical and syntactic analysis, type
checking) and the back-end consisting of the machine-dependent phases
(storage allocation, code generation). Only the back-end must be
modified when the compiler is ported. The front-end enters declarations
in a symbol table and builds an intermediate representation of the
program statements, an abstract syntax tree. If no errors were found,
control is passed to the back-end, which generates code from the syntax
tree. Since this structure is guaranteed to be free of errors, type
checking or error recovery are not part of the back-end, which is a
noteworthy advantage. Only implementation restrictions must be checked.
Another advantage of the intermediate structure is that optional passes
may be inserted to optimize the code. Such an optimization phase cannot
be easily embedded in a conventional single-pass compiler. The front-end
and the back-end are implemented separately as a set of modules.

MODULE STRUCTURE

Originally, OP2 has been designed to compile Oberon programs [10] and
has been slightly modified later to compile Oberon-2 programs [11, 12].
It consists of nine modules (see figure 1) all written in Oberon.

The lowest module of the hierarchy is OPM , where M stands for machine.
We must distinguish between the host machine on which the compiler is
running, and the target machine for which the compiler is generating
code. Most of the time, the two machines are the same, except during a
bootstrap or in case of a cross-compiler. The module OPM defines and
exports several constants used to parametrize the front-end. Some of
these constants reflect target machine characteristics or implementation
restrictions. For example, these values are used in the front-end to
check the evaluation of constant expressions on overflow. But OPM has a
second function. It works as interface between the compiler and the host
machine. This interface includes procedures to read the text to be
compiled, to read and write data in symbol files [13], and to display
text (error messages e.g.) onto the screen. All these input and output
operations are strongly dependent on the operating system. If the
compiler resides in the Oberon System environment [14, 15], the
host-dependent part of OPM remains unchanged.

The topmost module (OP2) is very short. It is the interface to the user,
and therefore host machine-dependent. It first calls the front-end with
the source text to be compiled as parameter. If no error is detected, it
then calls the back-end with the root of the tree that was returned by
the front-end as parameter.

Figure 1.    Module import graph (an arrow from A to B means B imports A)

Between the highest and the lowest module, one finds the front-end and
the back-end, which consist of four, respectively three modules. There
is no interaction during compilation between these two sets of modules.
The symbol table and the syntax tree are defined in module OPT and are
used by both the front-end and the back-end. This explains the presence
of import arrows from OPT to back-end modules visible in the import
graph (figure 1). But there is no transfer of control, such as procedure
calls.

The front-end is controlled by the module OPP, a recursive-descent
parser. Its main task is to check syntax and to call procedures to
construct the symbol table and the syntax tree. The parser requests
lexical symbols from the scanner (OPS) and calls procedures of OPT,
the symbol table handler, and of OPB, the syntax tree builder. OPB
also checks type compatibility.

The back-end is controlled by OPV, the tree traverser. It first
traverses the symbol table to enter machine-dependent data (using OPM
constants), such as the size of types, the address of variables or the
offset of record fields. It then traverses the syntax tree and calls
procedures of OPC (code emitter), which in turn synthesizes machine
instructions using procedures of OPL (low-level code generator).

This module structure achieves to make the front-end target-independent
as well as host-independent, and to make the back-end host-independent.

SYMBOL TABLE

The symbol table contains information about declared constants,
variables, types and procedures. It is built by the front-end. The
front-end uses it to check the context conditions of the language and
the back-end retrieves type information from it. The symbol table is a
dynamically allocated data structure with three different element types:

TYPE
  Const = POINTER TO ConstDesc;
  Object = POINTER TO ObjDesc;
  Struct = POINTER TO StrDesc;

An Object is a record (more exactly a pointer to a record), which
represents a declared, named object. The object declaration in the
compiler is the following:

ObjDesc = RECORD
  left, right, link, scope: Object;
  name: OPS.Name;
  (* key *)
  leaf: BOOLEAN;
  (* procedure: leaf; variable: candidate to be allocated in register *)
  mode: SHORTINT;
  (* constant, type, variable, procedure or module *)
  mnolev: SHORTINT;
  (* imported from module -mnolev, or local at procedure nesting level mnolev *)
  vis: SHORTINT;
  (* not exported, exported, read-only exported *)
  typ: Struct;
  (* object type *)
  conval: Const;
  (* numeric attributes *)
  adr, linkadr: LONGINT
  (* storage allocation *)
END ;

The name of the object stored in the object itself (field name) is used
as key to retrieve the object in its scope. Each scope is organized as a
sorted binary tree of objects (fields left and right) and is anchored
(field scope) to the owner procedure, which in turn belongs as object
to the enclosing scope. Parameters of the same procedure, fields of the
same record and variables of the same scope are additionally linked
together (field link) to maintain the declaration order. The flag leaf
indicates whether a procedure is a leaf procedure or whether a variable
is a candidate to be allocated permanently in a register. The back-end
may or may not use this information - Note that this information could
not be available in a single-pass compiler without intermediate
representation of the program. An object has always a type (field typ),
which is described by a record named StrDesc :

StrDesc = RECORD
  form, comp: SHORTINT;
  (* basic or composite type, type class *)
  mno: SHORTINT;
  (* imported from module mno *)
  extlev: SHORTINT;
  (* record extension level *)
  ref, sysflag: INTEGER;
  (* export reference *)
  n, size: LONGINT;
  (* number of elements and allocation size *)
  tdadr, offset: LONGINT;
  (* address of type descriptor *)
  txtpos: LONGINT;
  (* text position *)
  BaseTyp: Struct;
  (* base record type or array element type *)
  link: Object;
  (* record fields or formal parameters of procedure type *)
  strobj: Object
  (* named declaration of this type *)
END ;

There are several classes of types: basic types like character, integer
or set, and composite types like array, open array or record (fields
form and comp). The third element type of the symbol table is ConstDesc.
This record contains numeric attributes of objects, like values of
declared or anonymous constants:

ConstDesc = RECORD
  ext: ConstExt;
  (* extension for string constant *)
  intval: LONGINT;
  intval2: LONGINT;
  setval: SET;
  realval: LONGREAL
END ;

An example is shown in figure 2 below:

CONST
  Pi = 3.14;
TYPE
  A = ARRAY 4 OF LONGINT;
VAR
  i: INTEGER;
  a: A;
  x, y: LONGINT;

Figure 2.    Declarations and corresponding symbol table

SYNTAX TREE

The front-end builds an abstract syntax tree representing all statements
of the program. The Oberon syntax is mapped into a binary tree of
elements called NodeDesc :

Node = POINTER TO NodeDesc;
NodeDesc = RECORD
  left, right, link: Node;
  class, subcl: SHORTINT;
  readonly: BOOLEAN;
  typ: Struct;
  obj: Object;
  conval: Const
END ;

A binary tree has been chosen because each Oberon construct can be
decomposed into a root element identifying the construct and two
subtrees representing its components: an operator has a left and a right
operand, an assignment has a left and a right side, a While statement
has a condition and a sequence of statements, and so on. Some Oberon
constructs are organized sequentially: there are lists of actual
parameters in procedure calls and sequences of statements in structured
statements. It would be expensive to insert dummy nodes to link these
subtrees; an additional link field in the node is much cheaper.

Each node has a class (field class) and possibly a subclass (field
subcl) identifying the represented Oberon construct. Each node has a
type, which is a pointer (field typ) to a StrDesc of the symbol table.
Similarly, a leaf node representing a declared object contains a pointer
(field obj) to the corresponding ObjDesc of the symbol table. A
ConstDesc may be attached (field conval) to a node to describe a
numeric attribute, such as the value of an anonymous constant. A
ConstDesc denoting the position in the source text is anchored to the
root node of each statement. This allows locating compilation errors
reported by the back-end. Figure 3 shows the representation of two
statements manipulating variables declared in Figure 2.

Figure 3.    Statements and corresponding syntax tree

The rules of numeric type compatibility are flexible in Oberon; for
example, it is possible to multiply integers with long integers, as
shown in Figure 3. Note the conversion operator inserted in the tree,
freeing the back-end from type checking.

While generating code for a node, one typically has to recursively
evaluate left and right subtrees, then the node itself, and finally the
linked successors if any. A traversal of the tree looks like this:

Traverse(node: Node):

WHILE node # NIL DO
  Traverse(node^.left);
  Traverse(node^.right);
  Do something with node;
  node := node^.link
END

The intermediate representation could be a stream of instructions for a
virtual machine; we have preferred an internal abstract syntax tree for
different reasons. A virtual machine instruction set should be defined
without knowing anything about the future target machines. Perhaps the
mapping of this instruction set to a real instruction set would not be
easy, the virtual and real machines being very different (RISC vs. CISC
e.g.). Generating these pseudo-instructions already needs a code
generator, whereas building the syntax tree is a trivial recursive task
easily embedded in the recursive-descent parser. Since the tree is a
natural mapping of the Oberon syntax, each procedure of the parser
returns as parameter the root of the subtree corresponding to the
construct just parsed by this procedure. Furthermore, the tree keeps the
program structure intact, so that a control-flow analysis necessary for
an optimization phase can be done easily. Without a tree, this analysis
would be expensive, since basic blocks would have been dissolved in the
linear code. The reordering of program pieces is easier to perform in a
tree than in an instruction stream; for example, the conditional
expression of a While statement may be evaluated at the end of the loop,
while first generating code for the right subtree of the While node, and
then for its left subtree. The syntax tree has nevertheless one drawback
over an intermediate linear code: it needs quite a large heap space,
but, nowadays, this is not a real problem any more.

CODE GENERATION

Although an extensive experience in compiler development is not
necessary to write a new back-end, knowledge of the subject is
nevertheless an advantage. The front-end, which doesn't need any
modifications, has only to be adapted to the new target machine by
editing constants exported from module OPM. Then, the storage allocation
strategy must be adapted to the data alignment requirements of the new
processor. This allocation is done in module OPV, where a procedure
traverses the symbol table and distributes addresses to variables,
offsets to record fields, sizes to structured types, and so on. The last
thing to do, but not least, is to rewrite the code generator. After
storage allocation, code generation takes place. Between the two phases,
OPT shortly gets control in order to produce a symbol file. The whole
process of code generation can be viewed as the process of computing
attribute values for each node of the tree. Attribute values of a node
depend on the attribute values of the children nodes and on the node's
class and type. Hence, module OPV recursively traverses the syntax tree
and calls, in a post-order fashion, procedures of underlying modules
computing attribute values for each traversed node, or subtree of nodes.
These procedures act similarly to an attribute evaluator of an attribute
grammar. The emission of code is actually a side effect of computing
these attributes. Since the side effect (code) is more important than
the computed attribute values, and since these values will only be
reused later to compute the attribute values of parent nodes, they don't
need to be stored in the tree. Instead, they are passed as procedure
parameters named Item during the recursive traversal of the tree.

An Item is a record of attributes representing the operand or the result
of an operation. It indicates where the operand is located (memory,
register or immediate value e.g.). Items make it possible to delay
emission of code, so that processor addressing modes can be optimally
used. There are as many Item modes as processor addressing modes.
Depending on the mode specified by a field of the Item, other
attributes like type, address, offset, register number, value of
constant are stored in Item fields as well. The complexity of a
processor architecture is reflected in the declaration of the Item.
Typically, back-ends for CISC processors have Items with many fields and
many possible modes, whereas the ones for RISC are very simple.

Since almost each procedure of the code generator has to distinguish
between the different Item modes, the complexity of the back-end depends
on the number of modes. Expression evaluation for RISC processors is
therefore very easy to code. A more difficult part of RISC back-ends is
the register allocation. The advantage in performance of RISC over CISC
may be lost if a too simple allocation strategy is used.

An excerpt of the module OPV is listed below, giving an idea how the
back-end works:

PROCEDURE^ expr(n: OPT.Node; VAR x: OPL.Item);
(* forward declaration *)

PROCEDURE design(n: OPT.Node; VAR x: OPL.Item);
  VAR y: OPL.Item;
BEGIN
  CASE n^.class OF
    ...
    | index: design(n^.left, x); expr(n^.right, y); OPC.Index(x, y)
    (* x := x[y] *)
    ...
  END ;
  x.typ := n^.typ
END design;

PROCEDURE expr(n: OPT.Node; VAR x: OPL.Item);
  VAR y: OPL.Item;
BEGIN
  CASE n^.class OF
    ...
    | dyadic:
      expr(n^.left, x); ... expr(n^.right, y);
      CASE n^.subcl OF
      ...
      | plus: OPC.Add(x, y)
        (* x := x + y *)
      ...
      END ;
    ...
  END
  x.typ := n^.typ
END expr;

PROCEDURE stat(n: OPT.Node);
  VAR x: OPL.Item; L0, L1: OPL.Label;
BEGIN
  WHILE n # NIL DO
    CASE n^.class OF
      ...
      | while:
        L0 := OPL.pc;
        (* remind loop beginning *)
        expr(n^.left, x);
        (* evaluate conditional expression into x *)
        OPC.CFJ(x, L1);
        (* if not x then jump to L1 *)
        stat(n^.right);
        (* do statement sequence *)
        OPC.BJ(L0);
        (* backwards jump to L0 *)
        OPL.FixLink(L1)
        (* fix-up L1 with current pc *)
      ...
    END ;
    n := n^.link
  END
END stat;

MEASUREMENTS AND BENCHMARKS

The front-end of OP2 (modules OPS, OPT, OPB and OPP), which remains the
same for all versions of OP2, consists of less than 3500 lines of Oberon
source code. The length of the back-end and the size of the machine code
depend on the target processor architecture.

The very first back-end for OP2 was written by the author in less than
three months for the National Semiconductor NS32532 processor used in
the Ceres workstation developed at ETH [16]. The newest version
(Oberon-2) of this back-end (OPL, OPC and OPV) for that machine consists
of less than 2500 lines of Oberon source code, of which about 500 are
portable (module OPV). The total size of the compiler (including modules
OPM and OP2) is less than 6500 lines.

About 2000 lines (30% of the compiler) have to be rewritten when the
compiler is ported. This number may vary slightly depending on the
target architecture. For the MIPS R2000, for example, the back-end is
500 lines longer. The heap space required to store the syntax tree of a
module depends only on the size of the module, but not on the target
processor architecture; it is about 8 times larger than the source text
of the module. Larger variations are noticed in the size of the machine
code: the whole compiler for NS32532 is 62KB, whereas the one for the
MIPS R2000 is 152KB. The different architecture is not only reflected in
code density, but in performance too: on a Ceres (NS32532, 25 MHz), it
takes 32 seconds to recompile OP2; on a DECstation 5000 (MIPS R3000, 25
MHz), only 6.3 seconds. Note that self-compilation time is a good
indicator for compiler quality, since speed, compactness and code
quality are multiplicative factors contributing to the overall result.

Several other code generators have been implemented at the Institut für
Computersysteme. They have been used to port the Oberon System to
different computers (Sun SPARCstation, Macintosh, DECstation, IBM PS/2
and S/6000) [17, 18, 19]. In most cases, the new back-ends have been
developed on the Ceres workstation in about three or four months by a
single person. The object files, cross-compiled on Ceres, have been then
transferred to the target machine using a diskette or an RS232 line. We
also have a special back-end producing C code. The idea here is not to
shirk the task of writing a code generator, but to use this Oberon-to-C
translator as a preprocessor for a C compiler during the bootstrap of
the compiler only. Remember that OP2 is written in Oberon; so, if there
is no machine with a running Oberon compiler at immediate disposal, the
new OP2 cannot be cross-compiled. So we execute the bootstrap on the
target machine directly. We first translate the new OP2 to C and then
compile it using an existing C compiler. After a self-compilation step
of OP2, we can and should forget the C compiler.

ACKNOWLEDGEMENTS

The single-pass Oberon compiler of N. Wirth for Ceres has been a model
of efficiency and compactness for this project. The advice of M. Odersky
helped a lot during the design phase. J. Templ, M. Franz and H.
Mössenböck made valuable comments and improvement suggestions on OP2 and
on this paper. Many thanks to all of them and to the very first users
who searched desperately in their programs for bugs hidden in OP2.

REFERENCES AND FURTHER READING

1. Ganapathi M., Fischer C. N., Hennessy J. L., Retargetable Compiler
Code Generation , Computing Surveys 14:4, 573-592, 1982.

2. Amman U., Jensen K., Nïgeli H., Nori K., The Pascal 'P' Compiler:
Implementation Notes , Departement Informatik, ETH Zürich, 1974.

3. Tanenbaum A. S., Kaashoek M. F., Langendoen K. G., Jacobs C. J. H.,
The Design of Very Fast Portable Compilers , ACM SIGPLAN Notices 24:11,
125-131, 1989.

4. Glanville R. S., Graham S. L., A New Method for Compiler Code
Generation , Fifth ACM Symposium on Principles of Programming Languages,
231-240, 1978.

5. Aho A. V., Ganapathi M., Tjiang S. W. K., Code Generation Using Tree
Matching and Dynamic Programming , ACM Transactions on Programming
Languages and Systems 11:4, 491-516, 1989.

6. Cattell R. G. G., Newcomer J. M., Leverett B. W., Code Generation in
a Machine-Independent Compiler , ACM SIGPLAN Notices 14:8, 65-75, 1979.

7. Fraser C. W., Wendt A., Integrating Code Generation and Optimization
, ACM SIGPLAN Notices 21:6, 242-248, 1986.

8. Wirth N., A Fast and Compact Compiler for Modula-2 , Report 64,
Departement Informatik, ETH Zürich, 1985.

9. Wirth N., Compilerbau, Eine Einführung , B. G. Teubner Stuttgart,
1986.

10. Wirth N., From Modula to Oberon, The Programming Language Oberon
(revised edition) , Report 143, Departement Informatik, ETH Zürich,
1990.

11. Mössenböck H., Differences between Oberon and Oberon-2, The
Programming Language Oberon-2 , Report 160, Departement Informatik, ETH
Zürich, 1991.

12. Mössenböck H., Object-Oriented Programming in Oberon-2 , Second
International Modula-2 Conference, Loughborough, 1991.

13. Gutknecht J., Compilation of Data Structures: A New Approach to
Efficient Modula-2 Symbol Files , Report 64, Departement Informatik, ETH
Zürich, 1985.

14. Wirth N., Gutknecht J., The Oberon System , Report 88, Departement
Informatik, ETH Zürich, 1988.

15. Reiser M., The Oberon System, User Guide and Programmer's Manual ,
Addison-Wesley, 1991.

16. Eberle H., Development and Analysis of a Workstation Computer ,
Ph.D. Thesis, ETH Zürich, 1987.

17. Templ J., SPARC Oberon - User's Guide and Implementation , Report
133, Departement Informatik, ETH Zürich, 1990.

18. Franz M., The Implementation of MacOberon , Report 141, Departement
Informatik, ETH Zürich, 1990.

19. Pfister C., Heeb B., Templ J., Oberon Technical Notes , Report 156,
Departement Informatik, ETH Zürich, 1991.

20. Crelier R., OP2: A Portable Oberon Compiler , Report 125,
Departement Informatik, ETH Zürich, 1990.

