IL Principles

IL is based on the concept of a virtual machine. In other words, the language is based on a conceptual, fictional machine architecture. This architecture in fact bears little resemblance to the real processors your programs run on, but it has been designed in a way as to support type-safe programming while at the same time allowing highly efficient JIT compilation to machine code. Because this virtual machine is important to the basic structure of an IL program, we need to examine it first.

The IL Virtual Machine

When you are coding in a high-level language, you will be aware that there are certain types of memory that are available to use. For example, when writing unmanaged C or C++ code, variables can be allocated on the stack or on the heap. For C#, VB.NET, or MC++ code, value types are allocated on the stack (or inline inside objects on the heap), reference types are placed on the managed heap (and in the case of MC++ you can additionally place unmanaged instances on the unmanaged heap). Not only that, but most high-level languages make the distinction between local variables, instance fields, static (shared) fields, global variables, and data that has been passed in as an argument to a method.

Although you will be accustomed to thinking of all these types of data in different ways, in terms of the underlying Windows operating system and the hardware, these forms of data are simply different regions of the machine's virtual address space. In a sense, the high-level languages have abstracted away the actual hardware configuration, so that while you are programming, you think in terms of different types of memory that in reality don't exist. This abstraction is of course convenient for developers - being able to work with concepts such as local variables and parameters without worrying about the fact that at the assembly-language level such concepts don't always exist makes for much easier programming. And the compiler for each language of course deals with translating the view of memory presented by the language into the physical hardware.

If you've only ever used high-level languages before, the chances are you've naturally worked with this abstraction without even thinking about it. But it's a concept that's important to understand if you start working with IL, because the IL definition formalizes the concept into what is known as the IL virtual machine. The following diagram summarizes what the computer looks like as far as IL is concerned - in other words, the virtual machine. The boxes in the diagram show the areas of memory, while the arrows indicate the possible paths that data can flow along:

click to expand

You can get an idea of the importance of the evaluation stack - any transfer of data always has to go through it. It's also the only place where you can perform any modifications to data.

In all, the diagram shows that there are several areas of memory that are available to you as you are executing a method, but which are local to the method:

  • Local Variable Table. This is the area of memory in which local variables are stored. It has to be declared at the beginning of each method.

  • Argument Table. This area of memory contains those variables that have been passed to the method as arguments. This memory area also includes the this reference if the current method is an instance method - the this object is strictly speaking passed to the method as the first argument.

  • Local Memory Pool. This is the area of memory that is available for dynamic allocation. Like the local variable and argument tables, it is only visible within the scope of this method, and is reclaimed as soon as the method exits. However, the difference between the pool and the local variable table is that the amount of memory required for the pool can be determined at run time. By contrast, each variable in the local variable array has to be indicated explicitly in the IL code. The pool is the memory that will be used, for example, when a C# stackalloc statement is executed.

  • Evaluation Stack. The evaluation stack is arguably the most crucial area of memory, because it is the only area in which actual computational operations can be carried out. For example, if you want to add two numbers, you must first copy them to the evaluation stack. If you want to test the value of a variable, you first have to copy it to the evaluation stack, and then perform the test. Also, if you call a method, then any parameters to be passed to that method are taken from the evaluation stack. The name of the evaluation stack is no coincidence: it really works just like a stack. You push elements onto it, and pop elements off it, but you can only ever access the topmost element. In other words, if an IL command pushes the integer 27 onto the stack, and then another IL command pushes a reference to the string "Hello, World" onto the stack, it's not possible to access that value of 27 again until the string reference has been removed from the stack.

The evaluation stack is commonly referred to simply as the stack, when there's no risk of confusion with that other meaning of 'stack' - the machine or processor stack - the whole area of memory where value types, local variables, arguments, and the stack frame are stored. We'll often use this shorthand terminology in this book, so you'll need to be aware of these two distinct meanings of the term 'stack'. Usually there's little risk of confusion, since the evaluation stack is an abstract concept that only exists in IL - once the assembly has beenJIT-compiled and is actually running, there's no such thing as an evaluation stack any more.

There are also two areas of memory that have longer lifetimes - they can be accessed from the method currently executing and will continue to be around after the method exits. These areas of memory shouldn't need any introduction, as they are the same areas of memory that are seen by high-level languages:

  • Managed Heap. This is where reference data types and boxed value types are stored. In IL, as in most .NET high-level languages, you rarely access this memory directly, but instead manipulate object references that refer to the managed heap.

  • Static Members. The currently executing method can of course also access static members of any classes that are loaded.

It's worth pointing out that IL does support unmanaged pointers. Since a pointer contains a numeric address, it can point to any location whatsoever in the process's virtual memory space, including the memory that holds the above sets of data. For type safety reasons you should generally avoid using unmanaged pointers if possible - and you will find that you rarely need them.

It is important to understand that every time a new method is invoked, for all practical purposes that method gets a clean slate of local memory. The managed heap, and of course any memory accessed through pointers, is available throughout your code, but the areas of memory for locals, arguments, local dynamic memory, and the evaluation stack are all effectively visible only to the currently executing method.

Example - Adding Two Numbers

In this section, we will illustrate how the IL virtual machine model works with a couple of programs that add numbers together. We will start off with a program called AddConsts.il, which embeds the numbers to be added as hard-coded constants within the code. As usual, you can either download the files from the Wrox Press web site, or type the program yourself into a text editor:

 // AddConsts sample .assembly extern mscorlib {} .assembly AddConsts {    .ver 1:0:1:0 } .module AddConsts.exe .method static void Main() cil managed {    .entrypoint    .maxstack 2        call      void [mscorlib]System.Console::Write(string)    ldc.i4.s  47    1dc.i4    345    add    call      void [mscorlib]System.Console::WriteLine(int32)    ret }  ldstr     "The sum of the numbers is " 

I do realize that a program that adds two hard-coded constants together doesn't exactly look like a best-selling, state-of-the-art, killer application, but be patient. Moving from a high-level language to IL involves a lot of relearning basic programming concepts - and there are a lot of concepts we need to get through before we can write some more useful IL code.

In this code we've just highlighted what's changed since the previous sample. The declaration of the assembly and of the Main() method are exactly the same as our earlier "Hello World" application other than the (trivial) change of name of the assembly.

We are now in a position to understand the purpose of that .maxstack directive: it indicates how big the evaluation stack for that method needs to be - and for the Main() method in this program, we make it size 2:

    .maxstack 2 

The above directive will cause ilasm.exe to write information into the assembly that tells the JIT compiler that there will never be more than two items simultaneously on the evaluation stack for this method - having this information this can in principle make JIT compilation more efficient, because it means the JIT compiler will know in advance how much memory it needs to allocate for the evaluation stack, potentially saving it one pass through the code. I say "in principle" because the JIT compiler in the CLR is relatively sophisticated, and I'm informed it completely ignores the .maxstack size (other than for verification)! However, the definition of IL requires this directive to be present in order to support more basic JIT compilers, such as Rotor's FJIT. It is therefore regarded as an error if the sequence of instructions in the method can place more than the number of elements specified by .maxstack on the evaluation stack while the method is executing. The JIT compiler will detect if this can occur when it analyzes the program, and if so will refuse to compile it.

Note that the size of the stack is not measured in bytes or any fixed unit - it's measured by the number of variables. Hence the .maxstack 2 directive means that only 2 variables can be simultaneously placed on the evaluation stack, but it doesn't place any restriction on the type or size of those variables.

Now let's work through the instructions in the Main() method to see in detail what they do.

When execution of Main() method starts, the evaluation stack will be empty (recall that each method always starts with a clean evaluation stack). After the ldstr command has been executed, the evaluation stack will have one item on it - a reference to the string. The IL assembly syntax here is a bit misleading, since it gives the impression the string itself is embedded in the code. In fact, the string is stored in the metadata for the module, and in the actual binary IL, the opcode for ldstr is followed by a four-byte token that identifies the location of the string in the metadata. When ldstr is executed, it will cause a copy of this string to be placed in the run time literal string pool, and it is a reference to this copy that is placed on the evaluation stack.

In the documentation, it's common practice to indicate the effect that a command has on the evaluation stack. This is usually described by a stack-transition diagram, and occasionally also by an integer known as the stack delta. The stack-transition diagram is a diagram that shows the changes that occur to the evaluation stack when the instruction is executed, while the stack delta is the number of items by which the evaluation stack grows.

For ldstr, the stack-transition diagram looks like this:

Important 

... ..., string

In the diagram, the ellipsis (...) represents any data that was on the stack before the command was executed, and which is not affected by the instruction being executed. The items to the left of the arrow indicate the state of the evaluation stack before the instruction is executed, and the items to the right of the arrow indicate its state afterwards. The above diagram makes it clear that ldstr does not remove any items from the stack, but pushes one item, a string reference, onto it. Although this stack transition diagram names the type of data being loaded (string), there are no hard-and-fast rules about how we describe the items on the stack that are affected by the instruction. As you'll see in the other diagrams we present, it's more common for the text in a stack transition diagram to indicate the meaning of the value rather than its type.

The stack delta for ldstr is 1, since one item is placed on the stack. If an instruction causes the number of items on the evaluation stack to fall, its stack-delta will be negative.

The next instruction to be executed is the call instruction:

   call      void [mscorlib]System.Console::Write(string) 

This instruction indicates that we are to call a System.Console method. It also indicates that this method requires a string and returns a void. As we mentioned earlier, parameters to the method are taken from the stack, while any return value will have been pushed onto the stack when the method returns. Since this overload of Write() takes one parameter, just one parameter (a string) will be removed from the stack. And since it returns nothing, no return value will be put on the stack. The stack delta for this line of code is therefore -1, and the diagram looks like this:

Important 

..., string ...

Although the stack delta is -1 in this case, call has a variable stack delta, since it depends on the signature of the method called. In general, the stack delta will be minus the number of parameters for a method that returns void, and one minus the number of parameters for a method that returns some value.

It is important to understand that the parameters to be passed into a method are always actually popped off the evaluation stack in the caller method (and appear in the argument table of the called method). Hence, if you subsequently want to reuse any of those values in the caller method, you'll have to reload them onto the evaluation stack. This is in general true of most IL commands - the process of using a value from the evaluation stack causes it to be popped off the stack.

In our case, the evaluation stack will be empty after execution of the call instruction, since there are no other values on it.

The next two instructions each load a constant value onto the evaluation stack:

   ldc.i4.s  47   ldc.i4    345 

These instructions appear to have a different syntax, but they both do basically the same thing: take a constant numeric value (which will be embedded inline in the IL code as an argument that immediately follows the instruction), and copy it onto the evaluation stack. You might be surprised to see dots in the mnemonic text, but don't worry about this - ldc.i4 and ldc.i4.s really are simply the mnemonics for two different commands. The reason we are using two different instructions has to do with making the assembly as small as possible - ldc.i4.s loads a one-byte signed integer and so can't load a number outside the range (-128,127) - which means we can't use it to load the number 345. However, ldc.i 4 loads a four-byte signed integer, meaning that it doesn't have this restriction, but takes up more bytes in the generated assembly. We'll explain more about the dotted mnemonic notation and the idea of having different variants of commands soon, in the Variants of Instructions section, but we'll finish going through our sample code first.

The ldc.i4.s and ldc.i4 command both have a stack delta of 1, and their effect on the stack can be represented like this:

Important 

... .., value

In this diagram, value represents whatever constant value was specified as the argument to the load-constant instruction.

There is some confusion between the terms argument and operand as applied to IL instructions. Terminology in this area is not generally consistent, even within the partition documents, which means that in the documentation you'll often need to deduce the meanings of the terms from the context. However, to provide some consistency, in this book I will always use the term argument to indicate any hard-coded data (such as a token or a branch offset) that follows the opcode in the instruction stream in the assembly, and operand to indicate any value that the IL instruction reads from the evaluation stack when executing. Note that argument also has a separate meaning of argument (parameter) of a method (as opposed to an IL instruction - but it will normally be clear from the context which meaning is intended).

So, after executing both instructions, the stack will have two int32 values on it - 47 and 345. And the evaluation stack is now at the maximum size indicated by the .maxstack directive.

The next instruction in our sample code adds together the two numbers we have loaded onto the stack:

   ldc.i4.s  47   ldc.i4    345   add 

The add instruction is the first of the arithmetic operation instructions that we will encounter. It adds two numbers together, and, as always for IL operations, it expects to find the numbers on the stack. To be precise, it pops the top two items off the evaluation stack (these must both be of the same numeric data type), adds them together, and pushes the result onto the evaluation stack. Hence the add command has a stack delta of -1 and is represented by the following diagram:

Important 

..., value, value..., result

In our program, the only item on the evaluation stack after executing this command will be the integer 392 (which is what you get when you add 47 and 345). This item will be popped off the stack when we call Console.WriteLine(). This means that when we hit the ret statement, the evaluation stack will be empty. That's important because the definition of IL requires that the evaluation stack is empty when we return from a void method. (As mentioned earlier, if the method is not void, then the evaluation stack should contain one item, the return value, when we return from the method.)

More About the Evaluation Stack

The idea of having to load everything onto the evaluation stack may seem a bit strange and unfriendly if you're used to dealing with high-level languages, but it is a common concept in lower-level languages and language compilers. There's even a formal name for the abstract conceptual processors that languages such as IL work with: abstract stack machines. Java bytecode is another example of such a language. The real advantage of the abstract stack machine architecture is that it makes it easier to write front-end compilers that convert high-level languages into the abstract stack language. However, using the abstract stack concept does have an extra advantage in that it makes it very easy to enforce type safety. Recall that one of the goals of .NET is to ensure that programs can easily be checked for type safety, so that there is no risk of them doing anything bad like overwriting memory that doesn't belong to them. Forcing all operations to go through the evaluation stack makes it relatively simple to check whether a program is verifiably type-safe. The JIT compiler can easily see what data types the IL code will cause to be loaded onto the stack, and so can check that these data types are consistent with whatever you are going to do with them. Indeed, one of the basic points about type safety is that the JIT compiler can always figure out exactly how many values are on the evaluation stack and what their data types are at any particular point in the program. We'll say more about how type safety is verified in IL code in Chapter 3.

IL Data Types

In this section we'll briefly review the data types that are supported by IL. One of the strengths of the .NET Framework is the way it has provided language interoperability - and a key aspect of that is its unified type system. I'd have to say, though, that one of .NET's weaknesses is its failure to provide any simple, language-independent way of describing those types that makes it easy to remember what's what. Take for example the simple, 32-bit, signed integer: in C# and MC++ that's an int; in VB it's an Integer. But then we have to remember that really, behind the scenes, it's just an instance of the type System.Int32. And we also need to be aware that int/Int32 is one of the Common Language Specification-compliant (CLS) types, which means it should be available from any language. That's of course unlike System.UInt32, which isn't CLS-compliant, isn't recognized by VB.NET, and is known as uint in C# and unsigned int in MC++. By the way, in IL, the signed one is called int32 and the unsigned one is called unsigned int32. Except that sometimes in instruction mnemonics they are referred to as .i4 and .u4. Confused yet?

So the bad news is that we have a whole new set of names to learn for types that you're probably familiar with from your high-level language programming. And it gets worse...

  • The set of types that are recognized as part of the IL language is not the same as the CLS types. (There's no real reason why it should be, but it would have been nice...). The CLS types are selected to offer inter-language operability, which means they include some types that are not primitive types. On the other hand, the IL types are the types that it is sensible to define as primitive types on hardware grounds or on the grounds of their use in at least one high-level language.

  • Although the primitive types include integer types of various sizes from one byte upwards, the CLR internally expects to operate only on four-byte or eight-byte integers. IL will therefore always promote any type that occupies less than four bytes into a four-byte type when loading it onto the evaluation stack, and truncate types if storing from the evaluation stack into a memory location that is declared as holding a smaller value. The promotion occurs by zero-extending unsigned types and sign-extending signed types, so values are always preserved.

The only redeeming feature of this is that if you are only learning IL so you can read IL code, and you're not intending to write direct IL yourself, you can almost certainly get by without understanding the finer points of IL typing. And if you do write IL code, after a while, it will become apparent that there are good reasons for all the various rules, and these rules will eventually start to make sense intuitively (even if the multiplicity of different names for the same underlying types doesn't).

We'll look at those points in more detail soon, but first here's the list of IL types:

IL name

Corresponding .NET base type

Meaning

CLS-compliant

Can store on evaluation stack without promotion

void

 

<no data> Only used for method return types

Yes

No

bool

System.Boolean

Boolean value - true or false

Yes

No

char

System.Char

16-bit Unicode character

Yes

No

int8

System.SByte

1-byte signed integer

No

No

int16

System.Intl6

2-byte signed integer

Yes

No

int32

System.Int32

4-byte signed integer

Yes

Yes

int64

System.Int64

8-byte signed integer

Yes

Yes

native int

System.IntPtr

signed integer

Yes

Yes

unsigned int8

System.Byte

1-byte unsigned integer

Yes

No

unsigned int16

System.UIntl6

2-byte unsigned integer

No

No

unsigned int32

System.UInt32

4-byte unsigned integer

No

Yes

unsigned int64

System.UInt64

8-byte unsigned integer

No

Yes

native unsigned int

System.UIntPtr

unsigned integer

No

Yes

float32

System.Single

4-byte floating point value

Yes

No

float64

System.Double

8-byte floating point value

Yes

No

object

System.Object

reference to an object on the managed heap

Yes

Yes

&

 

managed pointer

Yes

Yes

*

System.IntPtr

unmanaged pointer

No

Yes

typedref

System.Typed Reference

special type that holds some data and explicitly indicates the type of the data

No

Yes

array

System.Array

one-dimensional zero-indexed array (also known as a vector)

Yes

Yes

string

System.String

reference to a System.String instance on the managed heap

Yes

Yes

The types listed in this table are those types recognized by the IL language and by the CLR as primitive types. This means that the names in the first column of the table are keywords in IL assembly. For example, you can use these keywords to identify the types as parameters or return types in method signatures:

 .method static int32 DoSomething(int16, float32, object) 

Contrast this with the way that we pass non-primitive types to methods. The following IL calls the method Rectangle.Intersect() method - in other words, it's the equivalent of this C# code:

 // rectl is of type System.Drawing.Rectangle rect1.Intersect(rect2); 

The corresponding IL is this:

 call instance void [System.Drawing]System.Drawing.Rectangle::Intersect(                          valuetype [System.Drawing]System.Drawing.Rectangle) 

Notice that we explicitly specify the type in the call, and even the assembly the Rectangle struct can be found in. Full details of the type have to be specified every time that type is used - so we see the same details repeated for the type that defines the method and for the argument - and in a method that returned some non-primitive type you'd similarly have to specify details for the return type. Notice also that when calling instance (as opposed to static) methods, we need to use the keyword instance when indicating the method signature.

Besides the difference in IL assembly syntax, details of primitive types are stored in a different format in the assembly metadata.

The second and third columns in the table indicate the .NET base type that formally represents this type (if there is one) and the meaning of the type. Then the fourth column tells us whether this type is compliant with the CLS - the only relevance of this is that a 'No' in this column means you shouldn't use that type in any position where it could be visible outside your assembly, because it could prevent other code written in some languages (such as VB!) from using your library.

The final column is more interesting: a 'No' in this column indicates that, although this type is recognized in the definition of the IL language, it must be widened to a four-byte type before it can be operated on. As mentioned earlier, this widening will happen automatically when instances of the type are loaded onto the evaluation stack.

In general, there is no difference between signed and unsigned data types other than how the value in them is interpreted. Although a number of unsigned types are marked as storable on the evaluation stack, in reality they are stored there in exactly the same format as if they were signed types, but instructions are available which interpret them as unsigned. Instructions that interpret a value as signed will treat the highest value (leftmost) bit of the value as a sign bit, while instructions that interpret a value as unsigned will treat that bit as a high-value numeric bit.

The typedref type is mostly present to support languages such as VB. VB has a very free style which doesn't always require the programmer to be very explicit about the types being used. The result is that on occasions the VB compiler doesn't have sufficient type information to emit IL code to invoke methods using the usual techniques - typedref is a special type designed to support this situation. However, code that uses typedrefs is still fully type-safe due to late type checking (at a performance cost).

The array type is not really so much a data type as a generic. (Whoever said that .NET doesn't have generics?) In other words, when instantiating an array, you need to indicate the type of data that each element will hold. We'll examine arrays in Chapter 2.

You'll note that there are three pointer types: object, & and *.object is a type in its own right, but & and * have to be qualified by the type that they point to, such as int32 & or int32 *. We'll look briefly at each of these types now.

Object References

object is a reference to an object on the managed heap. It is pretty much equivalent to object in C#, System.Object* in MC++, and Object in VB, etc., and is used in much the same way, for example to call methods on reference types. For example this C# code:

 MyClass myClass = new MyClass();    // MyClass is reference type myClass.DoSomething(); 

will be converted to IL code that uses object references.

Managed Pointers

& is IL notation for a managed pointer. It will typically be used when passing values by reference to methods. In other words, when you compile this C# code:

    int x = 30;    DoSomething (ref x); 

Then the IL code emitted will use managed pointers.

Managed pointers differ from object references to the extent that they can legally refer to data either on the managed heap or on the stack, whereas object will always refer to an object on the managed heap (unless it contains the value null or has somehow been corrupted by unsafe code!). Also, managed pointers are designed to point to the data in the object instance itself, whereas object actually points to some header information that precedes the instance data for each reference object. (We'll look at the structure of reference types in Chapter 3. Suffice to say here that each reference instance contains not only the data forming its fields, but also a pointer to that type's method table - equivalent to the vtables of unmanaged C++ code - and a sync block index that is used for thread synchronization. Value types do not have headers - each instance of a value type occupies only the memory needed for its data.)

There is one other use for managed pointers - if calling methods on value types such as Int32.ToString(), you'll need to call the methods through managed pointers rather than object references.

Unmanaged Pointers

Unmanaged pointers are designed to point to literally anything, which means that they will be what you'll need to use if you need to refer to some arbitrary unmanaged types by address. You'll find that you manipulate them in much the same way as managed pointers and often using the same IL commands in your IL code. However, the garbage collector will pay no attention to unmanaged pointers, whereas it will pay attention to managed pointers when deciding what objects can be garbage collected, and it will update managed pointers when it moves objects around on the heap. Also, any dereferencing of unmanaged pointers in your code will instantly make it fail the .NET type-safety checks. Be aware that if you push an unmanaged pointer onto the stack, it will be regarded for typing purposes as a native int by IL instructions that expect a signed type, and as a native unsigned int by IL instructions that expect an unsigned type. (However, it is tracked as a * by the verification process.)

IL Types and the Evaluation Stack

You're probably wondering why Microsoft would go to the trouble of having IL recognize all the above types in the above table and then decree that quite a few of them have to be auto-converted to wider types before they can be used on the evaluation stack. This is due to the conflicting demands of modern hardware, which is generally based on 32-bit processing, and the needs of developers - who generally find it convenient to work with shorter types such as Booleans and characters. C++ developers, for example, will be familiar with using the C++ bool data type, which can only store the values true and false. However, on a modern 32-bit machine, bool will almost invariably be treated at an executable level as a 32-bit integer when it is loaded into registers - with the C++ compiler making sure that any non-zero value of this integer will be interpreted as true, zero as false. The .NET Framework works on the same principle. Although you are free to store short data types in memory (for example as local variables, fields, or parameters passed to methods), this data has to get promoted to 32-bits if you want to actually do anything with it. Hence, any of the data types bool, int8, or int16 will automatically get converted to int32 as they are loaded onto the evaluation stack. Conversely, if an IL command needs to pop an item off the evaluation stack into some memory location that requires a short data type, it will truncate it. These conversions happen automatically, and in the ruthlessly type-safe IL language, are the only type conversions that ever happen implicitly. If you want to do an explicit conversion, there are a large number of IL conversion commands, which all have mnemonics beginning with conv.

As a rule, this padding and truncating of numbers happens quietly behind the scenes and doesn't really affect your life as a developer. The only time that you need to be aware of it is if there is a risk that truncating some integer on the evaluation stack may cause data loss. We won't deal with this situation in the book, but if you do encounter it, the relevant conv.* commands are detailed in the downloadable appendix.

We've just described the situation for integers. For floating-point numbers, similar principles hold, except that all floating-point arithmetic is done using a native representation of floating-point numbers defined in the ISO/IEC standard, IEC 60559:1989. When float32 or float64 values are stored as static or instance member fields (including as array elements), the CLR reserves the 32 or 64 bytes for them, but everywhere else (including not just the evaluation stack but also locals and arguments), floats will be stored in the native, processor-specific, format. When necessary, conversions will be made automatically when IL instructions cause floats to be copied around. Note that the native representation is always greater than 64 bytes, so no data loss occurs.

For the most part, this automatic conversion of floating-point numbers is not a problem - few people will object to having their floating-point arithmetic done to a greater accuracy than they need (because of modern processor architecture, there is generally no performance loss). However, there are some mathematical algorithms that specifically require intermediate results to be computed to the same accuracy as the precision to which the results are stored. This will occur only for a small minority of code, and if you've not encountered those issues before, then you almost certainly don't need to worry about it. If your code is affected by these issues, you'll need to look up the IL conv.r4 and conv. r8 commands.

One other point you need to be aware of is that the format used to store floating-point numbers includes special representations for plus or minus infinity and for 'not a number' (NaN - which is what you get if you try to do something like divide zero by zero). These special values are not Microsoft-specific - they are defined in the IEEE 754 standard. They will generally behave in the normal intuitive way in comparison and arithmetic operations; for example, adding anything to NaN gives NaN as a result.

IL Instruction Variants

We said earlier that we'd have a closer look at way that IL has groups of similar instructions. There are quite a few cases in IL where several different instructions have a similar effect, and this is normally reflected in the IL assembly mnemonic codes for those instructions. We can see this by using a couple of examples. First a relatively simple example - the add instruction. There are three variants of add:

Mnemonic

OpCode and argument(s)

Purpose

add

0x58

Add the top two items on the stack.

add.ovf

0xd6

add.ovf.un

0xd7

We're not really concerned about opcodes in this chapter, but I've included them in the table to emphasize that these really are different instructions. It just happens that because their meanings are similar they've all been given mnemonics that begin with add. The difference between them is that add does plain addition. It's the one to use for performance, but you should bear in mind that it will take no action if an overflow occurs - your program will just carry on working with the wrong results from the addition, add.ovf does the same as add, but will detect if an overflow has occurred and throw a System.OverflowException. Obviously, this automatic overflow detection carries a performance hit. Addition operations in languages such as C# and MC++ will normally by default compile to IL code that contains add, while VB normally generates add.ovf. The mnemonic add.ovf.un is similar to add.ovf, but it assumes the numbers are unsigned.

Where we have groups of instructions that perform similar tasks, we'll often refer to them as instruction families, and use a generic .* suffix for the mnemonic. Thus we'll write add.* to denote any of the instructions add, add.ovf, and add.ovf.un.

There are similar instructions to perform the other main arithmetic operations - mul.* and sub.*, as well as rem.* to take the remainder; div, the division instruction, has no .ovf version. Full details of these and similar bitwise operations are in the appendix.

So far so good. Now let's look at the ldc.* family of instructions, for which the situation is a bit more complex. The ldc.* instructions all push a constant value onto the evaluation stack. So far we've met two such instructions: ldc.i4 and ldc.i4.s. Amazingly there are no fewer than 15 instructions that push a constant numeric value onto the stack. Here's the full list:

Mnemonic

OpCode and argument(s)

Purpose

ldc.i4.0

0x16

Push the value 0 onto the stack

ldc.i4.1

0x17

Push the value 1 onto the stack

ldc.i4.2

0x18

Push the value 2 onto the stack

ldc.A.3

0x19

Push the value 3 onto the stack

ldc.i4.4

0x1a

Push the value 4 onto the stack

ldc.i4.5

0x1b

Push the value 5 onto the stack

ldc.i4.6

0x1c

Push the value 6 onto the stack

ldc.i4.7

0x1d

Push the value 7 onto the stack

ldc.i4.8

0x1e

Push the value 8 onto the stack

ldc.i4.ml

0x15

Push the value -1 onto the stack

OR

  

ldc.i4.M1

  

ldc.i4.s

0x1f <int8>

Push the argument onto the stack

ldc.i4

0x20 <int32>

 

ldc.i8

0x21 <int64>

 

ldc.r4

0x22 <float32>

 

ldc.r8

0x23 <float64>

 

1dnull

0x14

Push the null reference onto the stack (this is of type object)

Let's go over these instructions in detail. The first instruction, ldc.i4.0 (opcode 0x16), pushes zero onto the stack. Because this instruction is so specific, it doesn't need an argument. If the JIT compiler encounters the opcode 0x16, it knows we want the value zero pushed onto the stack. There are similar instructions for the numbers up to 8 and for -1. (The opcode 0x15 has two mnemonics, ldc.i4.m1 and ldc.i4.M1; that simply means that ilasm.exe will recognize either mnemonic and convert it to the opcode 0x15.) The implication of all this is that if you want to push an integer between -1 and 8 onto the stack, you can get away with a command that occupies just one byte in the assembly. If the constant you need to push onto the stack is larger in magnitude, but can still be represented in one byte, you can use the ldc.i4.s command - opcode 0x1f. If the JIT compiler encounters the opcode 0x1f, it knows that the following byte contains the number to be loaded. On the other hand, if your number is larger still but can be represented as a four-byte integer (int32) you can use ldc.i4 - and the instruction will occupy a total of five bytes in the assembly. Finally, as far as ints are concerned, ldc.i8 occupies nine bytes but can put an int64 onto the stack. ldc.r4 and ldc.r8 will do the same thing, but their arguments are interpreted as floating point numbers. This has two consequences: firstly, they will be converted to a native size floating point format as they are passed to the stack. Secondly, and more importantly, the JIT compiler knows that the top slot on the evaluation stack contains a float - which is important for type checking.

So why do we have all these instructions? Is it really worth having ldc.i4.0 and ldc.i4.1 etc. when ldc.i8 will serve the same purpose? The answer comes in the ".NET" part of the Microsoft .NET marketing publicity. Remember, one of the motivations of the .NET Framework is to make it easy for us to do network-based programming - and in particular that means that the framework contains a lot of support for deploying applications remotely and downloading code or updated versions of assemblies on demand. For such code, the download time - and hence the assembly size - is an important contributor to the performance of the code. And if there are compact versions of the most commonly used IL instructions then those bytes saved in the assembly can all add up and make a real difference.

In general, there is a fairly regular pattern to the suffixes that are put on the mnemonic, so you can easily tell what a variant of an instruction is for:

Suffix

Meaning

.ovf

This instruction detects overflows and throws an exception if one occurs.

.un

This instruction interprets its data as unsigned.

.s

This is a short version of the instruction. Its argument occupies fewer bytes than normal, which restricts its range but saves assembly file size.

There are also suffixes that indicate the data type that a particular variant of the instruction interprets its data to be. Unfortunately, these suffixes are not the same as the IL assembly keywords for the primitive data types. The suffixes are:

Suffix

DataType

.i1

int8

.i2

int16

.i4

int32

.i8

int64

.u1

unsigned int8

.u2

unsigned int16

.u4

unsigned int32

.u8

unsigned int64

.ref

object

.r4

float32

.r8

float64

Note that not all primitive types have corresponding suffixes, as there are no instructions dedicated to some of the types.



Advanced  .NET Programming
Advanced .NET Programming
ISBN: 1861006292
EAN: 2147483647
Year: 2002
Pages: 124

flylib.com © 2008-2017.
If you may any questions please contact us: flylib@qtcs.net