devxlogo

Down to the Metal: Managed Code Under the Hood (Part II)

Down to the Metal: Managed Code Under the Hood (Part II)

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 Sphere   {      .ver 1:0:0:0   }      .assembly extern mscorlib   {      .ver 1:0:500:0   }      .module Sphere.exe      .subsystem 0x00000003

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 1.0.0.0 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.

Next, the.module 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   {      .entrypoint      .maxstack 2         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)         ret   }

The .main 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() in C#.

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.

The CalcSphereVolume 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   {      .maxstack 3            ldarg.0      dup      dup      mul      mul      ldc.r8   3.141592654      mul      ret   }

Uhhh, ? 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.

ldarg.0 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

CalcSphereVolume 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:

   Radius^3, Pi

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.

Flow Control
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.

The Sample Application
Let’s assume you want an application to behave as follows: If a user enters a value less than zero, you want to perform action A; if the value is equal to zero you want action B; for values larger than zero but smaller than ten you want to loop action C, and for all other values you want action D. This sample will show you how to use branching (conditional) statements and how to build a basic loop.

Conditional Statements
Conditional statements are useful when you need to execute code based on the value of an item. They are similar to if/else combinations in most high-level languages. Here’s an example:

   ldstr   "Enter some integer: "   call   void [mscorlib]System.Console::Write(string)   call   string [mscorlib]System.Console::ReadLine()   call   int32 [mscorlib]System.Int32::Parse(string)   dup   brzero   ZeroLabel      dup   ldc.i4.0   blt.s   LessThanZeroLabel   br.s   BiggerThanZeroLabel      ZeroLabel:   ldstr   "You entered 0"   call   void [mscorlib]System.Console::WriteLine(string)   pop   ret      LessThanZeroLabel:   ldstr    "You entered "   call   void [mscorlib]System.Console::Write(string)   call   void [mscorlib]System.Console::WriteLine(int32)   ret      BiggerThanZeroLabel:   Dup   ldc.i4.s 9   ble.s   LoopPoint   ldstr   "Numbers higher than nine don't cause a loop."   call   void [mscorlib]System.Console::WriteLine(string)   pop   ret   

In the preceding code snippet you’ll find two new elements: labels and branch operands. Labels are used as targets for jumps. You can also use a relative value that specifies the jump offset?how many bytes of intermediate bytecode you want to skip forward or back from the current position. To get that value, you would have to sum the size of all opcodes and operands from your starting point to your endpoint to find the jump offset. Obviously it’s much easier and more convenient to use labels and let the ILAsm compiler calculate the offsets needed for the jump.

The branch statements all begin with “b” followed by some abbreviation that tells you what kind of jump it is. The code shown always uses a short version that has “.s” at the end, because you can be sure that the calculated offset won’t be far away. The short version uses only one byte for the offset, while the long version requires four bytes. To help keep the assembly size down, try to use the short version whenever possible.

All branch statements work the same way; they take the topmost values from the stack (if they need to compare something), check for the comparison they are enforcing and then continue code execution at the specified label when the expression evaluates to true or at the next line if it doesn’t.

Table 1 shows a list of all branch operands:

Table 1. IL Branch Operands

Statement

Offset
size

Items
popped
off the stack

Description

br

int32

Branch bytes from the current position

br.s

int8

?

brfalse
brnull
brzero

int32

int32

Branch if the top item on the stack 0

brfalse.s

int8

int32

Note that there is no brnull.sor brzero.s

brtrue
brinst

int32

int32

Branch if the value on the stack is nonzero (or a valid address of an object instance)

brtrue.s
brinst.s

int8

int32

?

beq

int32

*,*

Branch if equal

bge

int32

*,*

Branch if greater or equal

bgt

int32

*,*

Branch if greater

ble

int32

*,*

Branch if lower or equal

blt

int32

*,*

Branch if lower

bne.un

int32

*,*

Branch if not equal (unsigned compare)

bge.un

int32

*,*

Branch if greater or equal (unsigned compare)

bgt.un

int32

*,*

Branch if greater (unsigned compare)

ble.un

int32

*,*

Branch if lower or equal (unsigned compare)

blt.un

int32

*,*

Branch if lower (unsigned compare)

beq.s

int8

*,*

?

bge.s

int8

*,*

?

bgt.s

int8

*,*

?

ble.s

int8

*,*

?

blt.s

int8

*,*

?

bne.un.s

int8

*,*

?

bge.un.s

int8

*,*

?

bgt.un.s

int8

*,*

?

ble.un.s

int8

*,*

?

blt.un.s

int8

*,*

?

The pop opcode still needs explaining. This one is simple; it discards the topmost item of the evaluation stack.

The ret statement simply returns control to the caller when the code reaches a point where no other code or condition remains to be evaluated.

Take a look at the accompanying code download, which includes stack transition diagrams that function as code comments for easier traceability.

Writing Loops in IL
Here’s a typical loop construct. Remember the assumptions about the purpose of the program stated at the beginning of this article: If the value is larger than 0 and smaller than 10 the code should loop a number of times according to the amount entered.

   LoopPoint:   ldc.i4.m1   add   ldstr   "looping..."   call   void [mscorlib]System.Console::WriteLine(string)   dup   ldc.i4.0   bgt.s LoopPoint   pop   ret

To understand this code you have to know that when execution reaches the LoopPoint label there is one integer value on the evaluation stack that contains the number of loops still to be performed.

First, the code uses the directive ldc.i4.m1, which loads the value -1 onto the stack and then calls add to decrement the loop counter. You could also use ldc.i4.1 and sub to achieve this, and of course you could decrement the counter at the end of the loop instead of the beginning.

Next appears the loop workload. In this case the code simply outputs the string “looping?” for each loop iteration. In more typical code, that’s the place where your repeating code should go.

After completing each iteration, you need to compare the loop counter to zero to decide whether to jump back to the beginning of the loop or whether the loop is complete and execution should continue with the code lines after the loop. You’ve already seen how such comparisons pop the value from the evaluation stack. But if you pop the value, you would lose the loop counter. Therefore, a dup statement clones the topmost stack item, the loop counter, before making the comparison. When writing more complex loops a better idea is to store the counter to a local variable, which gives you greater flexibility and better control of the evaluation stack. You’ll see how to do that in the next sample.

The ldc.i4.0 directive loads zero onto the evaluation stack. Here’s the stack transition diagram for that source line:

   Counter, Counter --> Counter, Counter, 0

The bgt.s directive pops the top two items from the evaluation stack and continues execution at the code line marked by the label LoopPoint if the item that’s lower on the stack is greater than the topmost item. This means that the statement will branch until the counter reaches the value 0.

After the loop you want to exit the program, but because you always dup the value within the loop, you’ll still have the counter left on the evaluation stack. You won’t need it anymore. Simply pop it off the stack before calling ret.

Exception Handling
To err is human. And therefore you will need structured exception handling if you want to write secure code. Always check the input values before you start processing them. That will keep you out of a lot of trouble.

As always let’s start off with the sample code. We’ll enhance the sphere example a little to make it a respectable citizen of your computer. The calculation method remains unchanged. Here’s the part that changed:

   .method static void main() cil managed   {      .entrypoint      .maxstack 2      .locals init (float64)      TryAgain:      ldstr   "Radius: "      call   void [mscorlib]System.Console::Write(string)         .try      {         call   string [mscorlib]System.Console::ReadLine()         call   float64 [mscorlib]System.Double::Parse(string)         stloc.0         leave.s NothingBadHappend      }      catch   [mscorlib] System.Exception      {         pop         ldstr   "Incorrect format. Try again, please."         call   void            [mscorlib]System.Console::WriteLine(string)         leave.s   TryAgain      }      NothingBadHappend:      ldloc.0      call   float64 CalcSphereVolume(float64)            ldstr   "Volume: "      call   void [mscorlib]System.Console::Write(string)      call   void [mscorlib]System.Console::WriteLine(float64)         ret   }

You’ve already seen a good deal of the preceding code in the first sample, so I’ll concentrate on what’s changed?the error-handling code.

Local Variables
When using guarded blocks (code blocks that do take care of exceptions) you always need to leave the block with an empty evaluation stack. Because you’ll need the value calculated in the guarded block later on, you have to store it to a local variable to get it off the evaluation stack.

To declare a local variable use the .locals init statement and include all local variables in a comma-separated list within parentheses. Remember that CLI defines a separate memory space for local variables and you need to declare all of them at the beginning of the method. In contrast, most high-level languages let you define variables wherever you like within the method body. Another difference is that in ILAsm you don’t need to use variable names as you do in high-level languages; instead, ILAsm addresses variables by their index. You can use variable names, but the compiler will translate those names to indexes anyway.

The sample above declares one float64 variable to store the radius for the calculation.

To access this variable you use two methods: stloc stores the topmost item from the evaluation stack to a variable and ldloc pushes a variable’s value on the stack. The code sample uses the short versions (ldloc.0 and stloc.0) to access the first (and only) local variable.

Guarded Blocks
To begin a guarded block use the .try directive (just as in C# or VB.NET) and delineate the block with curly brackets ({}). Although those brackets mark the beginning and end of the guarded block clearly you have to use a special opcode to actually leave the guarded block, the directive leave (or the short version leave.s). The leave directive causes execution to leave the guarded block without throwing an exception and to continue at a label position when no error occurred.

If an error occurs, code evaluation continues at the line containing the catch statement. The catch statement must specify what kind of exception it should catch, which is known as exception filtering. This simple example catches every exception of any kind, which you can do by using System.Exception, the base class of all exceptions as the caught exception type.

You haven’t learned to use class instances, so for now, one way to handle an error is to simply pop the exception object off the evaluation stack and write a message to the user that something bad happened. Alternatively, you can jump back to a point in the code that forces the user to retry another input until the code finally completes without throwing an exception.

To sum up, so far you’ve seen the basics of functional programming with ILAsm. The list of opcodes shown here is not even close to complete, but a detailed explanation of ILAsm programming is beyond the scope of one article. If you are interested in more detail, take a look at the related resources column of this article. You can download the sample code here.

Additional Resources
See these two files in the Tool Developers Guide subdirectory of your SDK Installation.

  • Partition I Architecture.doc
  • Partition III CIL.doc
devxblackblue

About Our Editorial Process

At DevX, we’re dedicated to tech entrepreneurship. Our team closely follows industry shifts, new products, AI breakthroughs, technology trends, and funding announcements. Articles undergo thorough editing to ensure accuracy and clarity, reflecting DevX’s style and supporting entrepreneurs in the tech sphere.

See our full editorial policy.

About Our Journalist