Reference no: EM133105164
Assignment - Detailed example
In this assignment you will study the development process and the implementation of a simple, non-trivial example program. In the subsequent exercises you can borrow ideas from this example for your own programs, but for now, the most important thing is that you will learn:
- what an assembly program looks like
- how the basic programming constructs work in assembly (if/else, while, for, etc.)
- how to transform an idea into a good specification
- how to transform a specification into an assembly program
Since this is an introductory exercise, we will start with a very simple example problem. We are going to develop a program that prints all prime numbers below 1000. The algorithm that we will use to solve this problem was developed by an old scholar who went by the name of Eratosthenes of Cyrene. Eratosthenes lived in northern Africa from 276 B.C. to 194 B.C. and he devised what is probably the oldest known algorithm for finding prime numbers: the Sieve of Eratosthenes. The algorithm is not terribly efficient but it is very easy to understand, which is exactly why we use it here.
We will start by describing the Sieve algorithm in plain English. We will then write the algorithm down more formally in pseudocode. Finally, we will translate the pseudocode into working assembly code and we will discuss that code in line by line detail.
Step 1: description of the algorithm
The Sieve algorithm is quite simple. We start by constructing a list of all numbers between 2 and some upper limit, which in our case is the number 1000:
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ... 997 998 999 1000
We then take the first element of the list (the number 2, which is a prime!) and remove all multiples of this number from the list, except for 2 itself:
2 3 . 5 . 7 . 9 . 11 . 13 . 15 . 17 . 19 . 21 ... 997 . 999.
We then continue with the next element after 2 which is still in the list. This number is 3, again a prime number, and we remove all multiples of 3 except 3 itself:
2 3 . 5 . 7 ... 11 . 13 ... 17 . 19 ... 997 ...
We continue this process with the next number in the list (5, again a prime), etc. until we reach the end of the list. You should be able to convince yourself that the remaining numbers are all the prime numbers below 1000.
Step 2: specification of the algorithm
Now that we are familiar with the basics of Eratosthenes's Sieve, we will transform the algorithm into a formal specification. Why do we have to do this? Well, because there are many small, practical details that we need to consider before we can actually implement the algorithm. One such problem is the problem of representation: how will we represent the list of numbers in our program? Will we use a linked list2 structure containing the numbers or is it better to store the numbers in a list of fixed size? What implications will it have for the complexity of our program if we choose one representation over another? Resolving such questions is part of the creative challenge of programming, so usually you will have to decide on these matters for yourself. However, we will always expect you to formalise these decisions in the form of a good specification, before you start programming. As an example of what we consider to be a good specification, we present the specification of our sieve program below.
Instead of maintaining a list of remaining numbers, we will maintain an array of Boolean values to denote which numbers are still present and which have been crossed out. All entries in this array are initialised to true, since all numbers are initially present. We remove a number from the list by setting its corresponding table entry to false. Here is the pseudocode that describes the algorithm:
The pseudocode above uses only simple operations on simple data types (integers, booleans) and basic programmatic constructs such as for-loops and if statements. These constructs can easily be translated to assembler programs, as we shall see in the next step.
Step 3: implementation
The final step in our development process is to translate the specification into working assembler code. We present the complete implementation of the sieve program in working x86-64 assembler on the next page, followed by a detailed explanation of the code. In later exercises, you can use this program as a template for your own work. Try to read along and try to understand what happens, using the comments in the code and the subsequent explanations as a guide. Do not be intimidated if you do not understand all the details just yet.
Assembler Directives
The commands that start with a period (e.g. .bss, .text, .global, .skip, .asciz, etc.) are assembler directives. Assembler directives have special functions in an assembler program. For instance, the .bss and .text directives on lines 5 and 8 tell the assembler to put all the subsequent code in a specific section. Other assembler directives, like .global on line 11, make certain labels visible to the outside world. Study the description of these assembler directives in paragraph 3.5.3.
Instructions
The example program uses a number of commonly used instructions, such as mov, push, cmp and jmp. You can look up the exact meaning and function of these and other useful instructions in paragraph 3.5.2.
The beginning of the program
At the beginning of the program, which actually starts at main on line 19, we see that the base pointer is being initialised. This opaque ritual has to be performed at the start of each subroutine, including the main subroutine. You will learn all its secrets when we discuss subroutines and the stack in Assignment 1.
Input and Output
If you examine the pseudocode and the resulting assembly code of the example carefully, you can see that we have translated the print(number); statement into the following lines of assembler code:
movq $formatstr, rdi # first argument:
formatstr movq rax, rsi # second argument: the number
call printf # print the number
This deserves a thorough explanation. First of all, doing input and output in a computer program is not really a trivial job. It involves a call to the kernel and the gory details on how to do this differ from operating system to operating system. Luckily, there is an easy way to circumvent all these difficulties and that is by using the IO functions in the operating system's standard C library3. Calling functions in the standard C library is no different from calling subroutines in your own programs. We will discuss this mechanism in the next assignment.
Registers, variables and the stack
The example program uses a number of variables to store data. On lines 20 through 24 it uses the loop counter i and on line 28 we see in the comments that we are using a variable called "number" which corresponds to the one used in the pseudocode. But where do these variables live? Do they exist in the registers, on the stack or somewhere in main memory? The answer is: all of the above. Sometimes, like in the case of i, we can simply keep our variables in the registers. The registers are fast and easy to access, so if possible we like to keep our variables there. The number of registers is limited however, so sometimes we may have to temporarily store their values on the stack, as we see with the number example: it is created by pushing the number 2 onto the stack on line 28, but later we load it into a register (line 30) when we need to check its value.
Aside from register shortage, there is one very important reason to store variables on the stack in some cases: on the x86-64 platform, registers are caller saved by convention. This means that if you call a subroutine from your program, like printf or one of your own subroutines, the subroutine may and will likely overwrite some of your registers. In other words, if you need the data in your registers to be consistent after you call a subroutine, you will need to save it on the stack. We will delve into stack details in one of the exercises.
Programming constructs (if/else, for, while, etc.)
Our pseudocode program is defined in terms of familiar program constructs, such as for loops and if statements. In assembly, we do not have such high level constructs, but we do have a number of standard tricks to achieve similar results. These tricks are all listed and explained in Section 3.2. You can use this information to translate your pseudocode into assembly language systematically.
The end of the program
At the end of the program, we see another call to a function in the C system library. In most operating systems, programs need to return an error code which tells the operating system whether your program encountered any internal errors while running. By convention, programs return zero if no errors were encountered. Furthermore, the operating system may want to do some cleaning up after a program runs. To facilitate this, we call the exit function with our error code (zero) in the same way we used the printf function earlier.