n the first part of this article series
you explored the basics of managed code execution and created your first Intermediate Language Assembler application; the well-known "Hello World" sample. In this second part you'll see how to use flow control statements, declare variables, create your own methods, and call them. And you'll add some more metadata to your assemblies.
This article won't cover object-oriented programming topics such as creating your own classes, calling instance methods, creating properties and fields, etc., but you'll find those topics in the third and final part of this series.
A Simple Arithmetic Sample
The Hello World sample application didn't do much, so here's a small application that performs some work. This is a simple arithmetic application that calculates the volume of a sphere. It asks users to input a floating point value for the radius of the sphere, then calls a method that calculates and returns the volume. Finally, it prints the value to the screen.
First you'll have to write the manifest for your assembly to the beginning of your source file. This time there will be a few more features than in the "Hello World" application from Part I
.assembly extern mscorlib
You need to specify the name of the assembly you are about to write. But this time the code also includes a version declaration. The .ver
directive tells the compiler to write the version number 22.214.171.124 to the metadata of your assembly. As you can see, there's also a second .assembly
directive, but with the additional keyword external
, which states that this is a reference to an external assembly. That's similar to the /r
parameter of the csc.exe
C# Compiler which references other assemblies that needed to compile an assembly. In this case, the .ver
directive after the second .assembly
references version 1.1 of the .NET framework. If you were to write the assembly code without using that version directive it would default to framework version 0.0.0.0.
directive specifies the name of the module (you can think of this as the filename) where the compiler will write the compiled assembly.
The last directive in the manifest is .subsystem
with a value of 3
, meaning that this executable will be a console application. You could omit that, because a console application is the default target anyway.
Now that your manifest is finished you can write the rest of the code. It's not too long, so I'll show it all at once:
.method static void main() cil managed
ldstr "Radius: "
call void [mscorlib]System.Console::Write(string)
call string [mscorlib]System.Console::ReadLine()
call float64 [mscorlib]System.Double::Parse(string)
call float64 CalcSphereVolume(float64)
ldstr "Volume: "
call void [mscorlib]System.Console::Write(string)
call void [mscorlib]System.Console::WriteLine(float64)
method declaration is the same as for the Hello World sample, but the .maxstack
directive here has a value of 2
because you need two items on the stack at the same time to create the memory space you need to store and retrieve one of the values to or from a local variable.
When you look back at the first part of this article series you'll find that the first two "real" code lines are essentially the same as the "Hello World" Sample. Those are followed by a call to System.Console::ReadLine
, which puts a string on the evaluation stack when it returns. The code is basically equivalent to calling System.Console.ReadLine()
Because all input from the console is a string containing the characters the user typed in, you must convert the string value to a 64-bit floating point value. Perhaps you'd usually use the Convert class, but this sample uses the System.Double.Parse
method to illustrate the difference in type names between the CLI CTS and high-level languages such as C#.
Note that so far there's no error checking or handling around the conversion code. You'll add that to the sample later on.
At this point, you will have one float64
element on the evaluation stack that represents the radius of the sphere. Here's how to do the calculation. You could simply inline the code, but that would be too easy; instead, you'll implement a method that returns the volume of the sphere as the topmost item on the evaluation stack. I'll show you that method later. For now, it's important to notice that the code calls the CalcSphereVolume
method without giving it a fully qualified name; the call specifies only the return type, method name, and the parameter list. That's because CalcSphereVolume
is a static global function defined in the same assembly. In fact, you aren't even allowed
to use a fully qualified name (you use square brackets (
) to designate cross-assembly references) for calls within the same assembly.
method pushes one float64
item from the stack and puts one float64 item back on the stack when it returns. Conveniently, at this point you have one float64
on the stack from the System.Double.Parse
conversion, exactly what you need for the CalcSphereVolume
method. Here's the code for this method:
.method static float64 CalcSphereVolume(float64) cil managed
looks pretty scary, doesn't it? You'll find though, that when you examine the code line by line it's not that hard to understand.
is a short version of ldarg
, which is the token for the "load argument" opcode
(operation code). Normally ldarg
requires an argument specifying the index of the value it should load from the argument list. That version would use two bytes in the binary. This shorthand version (ldarg.0)
requires only one byte. There are short versions for the first four arguments; if your method uses more than four arguments you have to call them with an operand to load the corresponding item on the execution stack, for example ldarg 5
You need to write code to implement the following equation to solve the mathematical problem:
Volume = Radius³ * Pi
is a new method, so you have a fresh and clean method state and therefore an untouched, virginal evaluation stack. ldarg.0
places the first argument from the caller on the evaluation stack. The dup
statement duplicates the value at the top of the stack. Therefore, after the second dup
statement the stack looks like this:
Radius, Radius, Radius
You need the three duplicates to cube the radius value. Next you'll see two mul
operations. A mul
takes two items from the stack, multiplies them together, and puts the result back on the top of the stack. You get the following stack transition diagram for the two multiplications:
Radius, Radius, Radius --> Radius, Radius²
Radius, Radius^2 --> Radius^3
Perfect! Now you have the cubed Radius value on the stack and only need to multiply that by Pi. But Pi is nowhere on the stack, so you need to load the Pi constant onto the stack before you can multiply. For this you will use the ldc
statement, which is short for "load constant". The Pi constant is a float64
(eight bytes) so you use the ldc.r8
directive. Be careful, the type
is called float64
, but all operations for that type use the abbreviation r8
, which stands for "real 8 byte."
After the load operation your evaluation stack looks like this:
Now you can call mul
to multiply those two values, which will put the result back on the stack. Your method should return a float64, therefore you can simply call ret
to return the last item on the stack to the caller.
Note: Remember that the execution flow rules state that the evaluation stack must always be empty when a methods ends. In this case ret
pops the last item from the stack and passes it back to the caller, leaving the stack empty at the end of the method scope.
In addition to getting input and output and making calculations, you need to be able to implement basic flow control in ILAsm. In this section you'll see how to branch on conditional statements and how to repeat code in a loop.
I'll show only the important parts of the code here, for the complete source to this section take a look at the FlowControl.il
file in the downloadable sample code