50.002 Computation Structures
Information Systems Technology and Design
Singapore University of Technology and Design
Assemblers and Compilers
You can find the lecture video here. You can also click on each header to bring you to the section of the video covering the subtopic.
Overview
The goal of this chapter is to help us understand how to improve the programmability of the \(\beta\) (or any ISA in general). The \(\beta\) machine language is encoded into 32-bit instructions each.
For example, the 32-bit ADD
instruction is:
100000 | 00100 | 00010 | 00011 | 00000000000
The \(\beta\) understands it as: Reg[R4] = Reg[R2] + Reg[R3]
However, most of us would prefer to write: ADD(R2, R3, R4)
, or even a = b+c
because we couldn’t care less which registers are used as long as the CPU can add two variables together and produce the correct result.
We need to improve the programmability and usability of our machine by providing abstraction.
i.e: so that we can program (e.g: write code) and/or use the machine in an easier way by removing irrelevant data, hence allowing us to concentrate on one issue at a time.
We can do this by writing various softwares that allow us to abstract some details so that it is easier to fulfil our tasks.
Software: Abstraction Strategy
Abstraction is a fundamental concept in software engineering and computer science. It refers to process of removing physical, spatial, or temporal details to allow us to focus our attention on matters of greater importance.
We can engineer various softwares to provide abstraction in a computer system. A central form of abstraction in computing is the language abstraction.
Language abstraction: generational development of programming languages from the machine language to the assembly language and the high-level language. Each stage can be used as a stepping stone for the next stage.
We can also create new abstraction within a programming language, such as subroutines, modules, software components, polymorphism, and so on.
There are several layers to software abstraction, summarised below:
- Basic Layer: compilation tool softwares such as assemblers, interpreters and compilers:
- Assemblers and Interpreters: hides bit-level representations, hex locations, and binary values
- Compilers: hides machine instructions, registers, machine architecture
- Higher Layers: interpretive tool softwares such as Operating System and Applications (web browser, video player, text editors, etc):
- Operating System: hides resources (memory, CPU, I/O devices) limitations and details.
- Applications: hides local parameters, network location, security details, etc.
Assembler
An assembler is technically a program for writing programs. It also can be called as a primitive compiler. It provides:
- A symbolic language (assembly language) for representing strings of bits.
In this course, we use
beta.uasm
and this file enables us to represent strings of bits in terms of symbols. - A program for translating assembly source code to machine code in binary.
In this course, this translator program is built into
bsim
.
UASM
In order to write a code thats runnable in BSIM conveniently, we need:
- A symbolic language (UASM) for representing strings of bits
- A Program for translating UASM source code into binary.
A UASM source file contains, in symbolic text, values of successive bytes to be loaded into memory. We can define various things in UASM source file:
- Basic values: Decimals, binary (with
0b
prefix), and hexadecimal (with0x
prefix) – all will be loaded (from low to high address) as 1 byte each, separated by spaces.For example,
5 6 7 8
will result as08 07 06 05
in memory. Anything more than 256 (1 byte: \(2^8\)):256 257 258 259
will be truncated into 1 byte only, and resulted as the following in memory (only remainder left):03 02 01 00
- Symbols, defined by the
=
sign allows us to “rename” basic values (like defining variables):
1
2
3
4
5
x = 0x1000
y = 0x1004
R0 = 0
R1 = 1
- Special variable
.
(period): means next byte address to be filled:
1
2
. = 0x100
ADDC(R0, 3, R1) | means to load this instruction at address 0x100
- Labels \(\rightarrow\) symbols that represent memory addresses. Defined using
:
syntax:
1
2
3
4
5
6
. = 0x108
ADDC(R31, 3, R1)
| begin_loop is a label the address of SUBC instruction
begin_loop : SUBC(R1, 3, R1)
BEQ(R1, begin_loop, R31)
ADDC
is loaded at (byte) address0x108
. SinceADDC
’s length is 4 bytes,SUBC
is loaded at the subsequent address :0x10C
.
- Macroinstructions: parameterized abbreviations, or shorthand.
- These two macros,
WORD
andLONG
allows us to assemble inputx
that is more than 256 into longer streams of bytes - There are two ways of storing bytes in memory: the little endian format where lowest byte stored at the lowest address and vice versa, and the big endian format where the highest byte is stored at the lowest address and vice versa.
- The \(\beta\) CPU follows the little-endian format
- These two macros,
1
2
.macro WORD(x) x%256 (x/256)%256
.macro LONG(x) WORD(x) WORD(x >> 16)
For example, suppose we want to store the word 0xDEADBEEF
to memory address 0x0
. We start by loading 0xEF
to address 0x0
, then 0xBE
to address 0x1
, and so on. This is so tedious to do. Using the macro: LONG(0xDEADBEEF)
has the same effect as storing: 0xEF 0xBE 0xAD 0xDE
to memory in sequence from low to high memory address, resulting in the following:
Address | 0x0 | 0x1 | 0x2 | 0x3 |
---|---|---|---|---|
Content | 0xEF | 0xBE | 0xAD | 0xDE |
If one were to store 0xDEADBEEF
in big-endian format, it will result in:
Address | 0x0 | 0x1 | 0x2 | 0x3 |
---|---|---|---|---|
Content | 0xDE | 0xAD | 0xBE | 0xEF |
Note that our bsim.jar
program displays the memory address the other way around, that is high address on the left and low address on the right, so our little-endian format in \(\beta\) looks like the big-endian format for easy debugging:
Address | 0x3 | 0x2 | 0x1 | 0x0 |
---|---|---|---|---|
Content | 0xDE | 0xAD | 0xBE | 0xEF |
\(\beta\) instructions are also created by writing convenient macroinstructions. For example, we want to load the following instruction into memory:
110000 00000 11111 1000 0000 0000 0000
The above is an ADDC
instruction, to add contents of R31
with -32768
and store it at R0
.
Without any symbols, would need to write them as:
0b00000000 0b10000000 0b00011111 0b11000000
to be loaded properly where the OPCODE
is stored at a higher address than Rc
, and Rc
is at a higher memory address than Ra
, and 16-bit constant c
is at the lowest memory address of the entire word.
But the above is so unintuitive! We need to chop the original instruction into 1 byte chunks and “load” them from right to left so they’re stored from lowest to highest memory location to follow the little-endian format.
With a slight improvement from macro LONG
, we can write them as:
LONG(0b11000000000011111000000000000000)
Better yet, we can define a macro called betaopc
and ADDC
that relies on the former:
1
2
3
4
5
.macro betaopc(OP,RA,CC,RC) {
.align 4
LONG((OP<<26)+((RC%32)<<21)+((RA%32)<<16)+(CC % 0x10000))
}
.macro ADDC(RA,C,RC) betaopc(0x30,RA,C,RC)
Then we can utilise it easily to load our instruction above in a more intuitive way:
1
ADDC(R15, -32768, R0)
In summary, the file beta.uasm
given for your lab provides support for all \(\beta\) instructions so that we can write instructions for \(\beta\) in a much more intuitive way without caring about the details on how to load these values properly into memory (abstraction provided).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| BETA Instructions:
|| OP instructions
.macro ADD(RA,RB,RC) betaop(0x20,RA,RB,RC)
.macro AND(RA,RB,RC) betaop(0x28,RA,RB,RC)
.macro MUL(RA,RB,RC) betaop(0x22,RA,RB,RC)
|| OPC instructions
.macro ADDC(RA,C,RC) betaopc(0x30,RA,C,RC)
.macro ANDC(RA,C,RC) betaopc(0x38,RA,C,RC)
.macro MULC(RA,C,RC) betaopc(0x32,RA,C,RC)
...
|| Memory Access instructions
.macro LD(RA,CC,RC) betaopc(0x18,RA,CC,RC)
.macro ST(RC,CC,RA) betaopc(0x19,RA,CC,RC)
.macro LDR(CC, RC) betabr(0x1F, R31, RC, CC)
...
|| Transfer Control instructions
.macro betabr(OP,RA,RC,LABEL) betaopc(OP,RA,((LABEL-.)>>2)-1, RC)
.macro JMP(RA, RC) betaopc(0x1B,RA,0,RC)
.macro BEQ(RA,LABEL,RC) betabr(0x1D,RA,RC,LABEL)
.macro BNE(RA,LABEL,RC) betabr(0x1E,RA,RC,LABEL)
...
Interpreter and Compiler
We are naturally more accustomed to higher level language. They’re more readable, concise, and portable.
Some higher level languages, like Java and C/C++ have to be compiled first using its respective compilers (javac
for Java or GCC
for C/C++), resulting in an executable (a sequence of binary instructions directly understandable by the CPU).
Compilers typically go through sophisticated steps of analysing the entire high-level source code to produce an optimized set of instructions for the machine. We will not be able to execute the program if the compiler meets an error, hence making it comparatively harder to debug. It slows down program development but it will result in faster execution.
Other languages like Python and Ruby are interpreted. The interpreter for these languages execute the program directly, often by translating each statement into a sequence of one or more standard subroutines, and finally into machine code.
There’s not too much analysing of source code done, and will translate the program on the fly. It will execute the program until the first error is met, hence debugging will be comparatively easier than debugging compiled languages.
Compiling Expressions
In this course, we will not dive into how compilers, assemblers, or interpreters work in too much detail. In fact, we are going to hand compile the high-level language (we will use C) ourselves into \(\beta\) assembly (and then hand assemble them into the binary executable).
Don’t worry. Compilation to unoptimized code is pretty straightforward. You won’t be required to write C-code either, only to read them.
There are several rules to keep in mind in order to do this well:
- Variables are assigned to memory locations and accessed via
LD
andST
- Operators translate to ALU
OP
- Small constants translate to
c
(literal-mode) ALU instructions - Large constants must be loaded to registers first
- Small constants translate to
- Conditionals and loops involve
BEQ
orBNE
Let’s dive into simple examples to make this clearer.
To aid your learning, copy each snippet to
bsim
and observe the instruction execution step by step.
Example 1: Basic Variable Declarations
C Code:
1
2
3
int x,y;
x = 20
y = x + 5
Translates to the following \(\beta\) assembly code:
1
2
3
4
5
6
7
8
9
.include beta.uasm
LD(R31, x, R1) | load the content of memory address x to R1
ADDC(R1, 5, R0) | now that '20' is in R1, add it with 5, store it at R0
ST(R0, y, R31) | store the result (at R0) to location y
HALT()
x : LONG(20) | label x points to where 20 is stored
y : LONG(0) | label y points to where 0 is stored
Example 2: Arrays
C Code:
1
2
3
4
int x[5];
x[0] = 12;
x[1] = 13;
x[2] = x[0] + x[1];
Translates to the following \(\beta\) assembly code:
1
2
3
4
5
6
7
8
9
10
11
12
13
.include beta.uasm
ADDC(R31, 12, R0) | supposed content of x[0]
ST(R0, x) | store '12' in R0 at address x
ADDC(R31, 13, R1) | supposed content of x[1]
ADDC(R31, 4, R2) | index 1 (x[1] -> x+4)
ST(R1, x, R2) | store '13' in R1 at address (x+4)
ADD(R0, R1, R3) | x[0] + x[1] = 25
ADDC(R31, 8, R2) | index 2 (x[2] -> x+8)
ST(R3, x, R2) | store '25' in R3 at address (x+8)
HALT()
x : .
Example 3: Conditionals and Loops
C Code:
1
2
3
4
5
6
7
8
int n = 20;
int r = 1;
while (true){
if (n <= 0) break;
r = r*n;
n = n-1;
}
Might translate to the following \(\beta\) assembly code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.include beta.uasm
LD(R31, n, R1)
LD(R31, r, R2)
check_while: CMPLT(R31, R1, R0) | compute whether n > 0
BNE(R0, while_true, R31) | if R0 != 0, go to while_true
ST(R2, r, R31) | store the result to location 'r'
HALT()
while_true: MUL(R1, R2, R2) | r = r*n
SUBC(R1, 1, R1) | n = n-1
BEQ(R31, check_while, R31) | always go back to check_while
n : LONG(20)
r : LONG(1)
Summary
You may want to watch the post lecture videos here:
There are a lot of ways to translate higher level language into a lower level language. The examples given above are not necessarily the most optimised way, for example, we can ask ourselves:
- Is it possible to reduce the number of instructions?
- Is it possible to reduce the amount of
LD
andST
?
The examples above are also not exhaustive. There are several undiscussed parts:
- How do we reuse boilerplate code?
- How do we write functions? Declare structures?
- Where do we keep local variables?
We will address them in the next chapter.
Note that optimization in compilation is not a trivial task. For now, don’t worry too much about it. We simply only need to hand assemble C into \(\beta\) assembly language, and have a general idea on what a compiler, interpreter, and assembler are for – that is to enhance the programmability of a computer by providing software abstraction.