IL Instructions
When a method is executed, three categories of memory local to the method plus one category of external memory are involved. All these categories represent typed data slots, not simply an address interval as is the case in the unmanaged world. The external memory manipulated from the method is the community of the fields the method accesses (except the fields of value types belonging to the local categories). The local memory categories include an argument table, a local variable table, and an evaluation stack. Figure 13-1 describes data transitions between these categories. As you can see, all IL instructions resulting in data transfer have the evaluation stack as a source or a destination, or both.
Figure 13-1. Method memory categories
The number of slots in the argument table is inferred from the method signature at the call site (not from the method signature specified when the method is defined—remember vararg methods). The number of slots in the local variable table is inferred from the local variable signature whose token is specified in the method header. The number of slots in the evaluation stack is defined by the MaxStack value of the method header, specified in ILAsm by the .maxstack directive (default evaluation stack depth is 8, as you remember).
The slots of the argument and local variable tables have static types, which can be any of the types defined in the .NET Framework and the application. The slots of the evaluation stack hold different types at different times during the course of the method execution. The types of stack slots change as the computations progress, and the same stack slots are used for different values. The execution engine of the common language runtime implements a coarser type system for the evaluation stack: the only types a stack slot can have at a given moment are int32, native int, int64, Float (the current implementation uses 64-bit floating-point representation, which covers both float32 and float64 types), & (a managed pointer), ObjectRef (an object reference, which is an instance pointer to an object), or an instance of a value type.
The IL instruction sequences that make up the IL code of a method can be valid or verifiable, or both, or neither. The concept of validity is easy to grasp: invalid instruction sequences are rejected by the JIT compiler, so nothing really bad can happen if you emit an invalid sequence—except that your code won’t run.
Verifiability of the code is a security issue, not a compilation issue. The verifiable code is guaranteed to access only the memory it is allowed to access and hence is not capable of any malice or hidden hacks, so you can download a verifiable component from a remote location and run it without fear. If the code is deemed unverifiable—that is, if the code contains segments that just might contain a hack—the runtime security system will not allow it to be run except from a local disk. (I’ll discuss the verifiability of IL code in the “Code Verifiability” section.) Generally, it’s a good idea to check your executables with the PEVerify utility, distributed with the Microsoft .NET Framework SDK. This utility provides metadata validation and IL code verification, which includes checking both aspects—code validity and verifiability.
IL instructions consist of an operation code (opcode), which for some instructions is followed by an instruction parameter. Opcodes are either 1 byte or 2 bytes long; in the latter case, the first byte of the opcode is always 0xFE. In later sections of this chapter, opcodes are specified in parentheses following the instruction specification. Some instructions have synonyms, which I’ve also listed in parentheses immediately after the principal instruction name.
Long-Parameter and Short-Parameter Instructions
Many instructions that take an integer or an unsigned integer as a parameter have two forms. The long-parameter form requires a 4-byte integer, and the short-parameter form, recognized by the suffix .s, requires a 1-byte integer. Short-parameter instructions are used when the value of the parameter is in the range –128 through 127 for signed parameters and in the range 0 through 255 for unsigned parameters. The long-parameter form of an instruction can also be used for parameters within these ranges, but it leads to unnecessary bloating of the IL code.
Instructions that take a metadata token as a parameter don’t have short forms: metadata tokens are always used in the IL stream in uncompressed and uncoded form, as 4-byte unsigned integers.
The byte order of the integers embedded in the IL stream must be little endian—that is, the least significant byte comes first.
Labels and Flow Control Instructions
Flow control instructions include branching instructions, the switch instruction, exiting and ending instructions used with managed EH blocks, and a return instruction.
All these instructions, except the return instruction, use integer offsets (in bytes) from the current position within the method IL code to specify the target instruction. The “current position” in this case is the offset of the beginning of the next instruction—the one following the flow control instruction. The target offset (which is the sum of the current position and the offset specified in the branching instruction) must point at the beginning of some instruction of this method. In other words, the target offset cannot be less than zero, cannot be larger than the method’s code size, and cannot point at the middle of an instruction. If the target instruction is prefixed (the prefix instructions are discussed later in this chapter), the target offset cannot point at the prefixed instruction directly and must point at the prefix instruction. From the flow control point of view, a combination of a prefix instruction and a prefixed instruction is a single instruction.
Unconditional Branching Instructions
Unconditional branching instructions take no arguments from the stack and have a signed integer parameter. The parameter specifies the offset in bytes from the current position (which is the beginning of the next instruction—the one following the branching instruction) within the IL stream. The ILAsm notation does allow you to specify the offset explicitly (for example, br -234), but this practice is not recommended for an obvious reason: it’s difficult to calculate the offset correctly when you’re writing in a programming language.
It is much safer and less troublesome to use labels instead, letting the ILAsm compiler calculate the correct offsets. Labels, which you’ve already encountered many times, are simple names followed by a colon.
...
Loop:
...
br Loop
...
By default, the IL assembler does not automatically choose between long-parameter and short-parameter forms. Thus, if you specify a short-parameter instruction and put the target label farther away than the short parameter permits, the calculated offset is truncated to 1 byte, and the IL assembler issues an error message. Versions 2.0 and later of the IL assembler feature the command-line option /OPT, which turns on the automatic replacement of long-parameter instructions by the short-parameter instructions whenever the parameter size permits.
Unconditional branching instructions take nothing from the evaluation stack and put nothing on it.
Conditional Branching Instructions
Conditional branching instructions differ from the unconditional instructions in one aspect only: they branch only if the condition (<value>, which they take from the evaluation stack) is true (nonzero) or false (zero).
Comparative Branching Instructions
Comparative branching instructions take two values (<value1>, <value2>) from the evaluation stack and compare them according to the <condition> specified by the opcode. Not all combinations of types of <value1> and <value2> are valid; Table 13-1 lists the valid combinations.
Table 13-1. Valid Type Combinations in Comparison Instructions
Type of <value i > |
Can Be Compared with Type |
---|---|
int32 |
int32, native int. |
int64 |
int64. |
native int |
int32, native int, & (equality or nonequality comparisons only). |
Float |
Float. Without exception, all floating-point comparisons are formulated as <condition> or unordered. Unordered is true when at least one of the operands is NaN (“not a number,” undefined floating point value discussed below in Arithmetic Instructions section). |
&(managed pointer) |
native int (equality or nonequality comparisons only), &. Unless the compared values are pointers to the same array or value type or pointers to pinned variables, comparing two managed pointers should be limited to equality or nonequality comparisons because the garbage collection subsystem might move the managed pointers in an unpredictable way at unpredictable moments. |
ObjectRef |
ObjectRef (equality or nonequality comparisons only). “Greater-than” unsigned comparison is also admissible and is used to compare an object reference to null because objects are subject to garbage collection, and their references can be changed by the GC at will. |
The switch Instruction
The switch instruction implements a jump table. This instruction is unique in the sense that it has not one, not two, but N+1 parameters following it, where N is the number of cases in the switch. The first parameter is a 4-byte unsigned integer specifying the number of cases, and the following N parameters are 4-byte signed integers specifying offsets to the targets (cases). There is no short-parameter form of this instruction. The ILAsm notation is as follows:
switch(Label1,Label2,...,LabelN)
... // Default case
Label1:
...
Label2:
...
...
LabelN:
...
As in the case of branching instructions, the ILAsm syntax allows you to replace the labels in a switch(...) instruction with explicit offsets, but I definitely do not recommend this.
The instruction takes one value from the stack and converts it to an unsigned integer. It then switches to the target according to the value of this unsigned integer. A 0 value corresponds to the first target offset on the list. If the value is greater than or equal to the number of targets, the switch instruction is ignored, and control is passed to the instruction immediately following it. In this sense, the default case in ILAsm is always the first (lexically) case of the switch.
The break Instruction
This break instruction isnot equivalent to the break statement in C, which is used as an exit from the switch cases or loops. The break instruction in IL inserts a breakpoint into the IL stream and is used to indicate that if a debugger is attached, execution will stop and control will be given to the debugger. If a debugger is not present, the instruction does nothing. This instruction does not have parameters and does not touch the evaluation stack.
Managed EH Block Exiting Instructions
The blocks of code involved in managed exception handling cannot be entered or exited by simple branching because of the strict stack state requirements imposed on them. The leave instruction, or its short-parameter form, is used to exit a guarded block (a try block) or an exception handler block. You cannot use this instruction, however, to exit a filter, finally, or fault block. (For more details about these blocks, see Chapter 14.)
The instruction has one integer parameter specifying the offset of the target and works the same way as an unconditional branching instruction except that it empties the evaluation stack before the branching. The ILAsm notation for this instruction is similar to the notation for unconditional branching instructions: leave <label> or leave <int32>; the latter one is highly unrecommended.
EH Block Ending Instructions
IL has two specific instructions to mark the end of filter, finally, and fault blocks. Unlike leave, these instructions mark the lexical end of a block (the instruction that has the highest offset in the block) rather than an algorithmic end or point of exit (which may just as well be located in the middle of the block). These instructions have no parameters.
The ret Instruction
The return instruction—ret—returns from a called method to the call site (immediately after the call site, to be precise). It has no parameters. If the called method should return a value of a certain type, exactly one value of the required type must be on the evaluation stack at the moment of return. The ret instruction causes this value to be removed from the evaluation stack of the called method and put on the evaluation stack of the calling method. If the called method returns void, its evaluation stack must be empty at the moment of return.
Arithmetical operations deal with numeric data processing and include stack manipulation instructions, constant loading instructions, indirect (by pointer) loading and storing instructions, arithmetical operations, bitwise operations, data conversion operations, logical condition check operations, and block operations.
Stack Manipulation
Stack manipulation instructions work with the evaluation stack and have no parameters.
Another useful application of the nop instructions is specific to version 2.0 or later of the common language runtime (or, more exactly, its JIT compiler). Compilers, emitting the debug information into the PDB files, specify so-called code points, which bind source code lines and columns to offsets in the method code. In versions 1.0 and 1.1, the JIT compiler provided the ability to set the breakpoints at any code point specified in the PDB file. In version 2.0, an “optimized” mode was introduced, in which the JIT compiler effectively ignores the code points specified in the PDB and allows setting the breakpoints only according to some “heuristics.” These heuristics include, for example, the moments when the evaluation stack is empty or when nop is encountered. Empty evaluation stack heuristics work for most imperative high-level languages because, as a rule, each completed statement in these languages translates into code that begins and ends with the evaluation stack empty. This does not work, of course, for ILAsm, so in “optimized” mode you cannot set a breakpoint on an arbitrary instruction, and you cannot “walk” the ILAsm code instruction by instruction under a debugger. Inserting nop instructions after each meaningful instruction would probably help, but it is not a feasible option. Fortunately, the traditional mode, which allows setting the breakpoints according to PDB code points, is also supported in versions 2.0+. But I had to fight for preserving it and explain at lengths why it is important. You can find more details of debug modes in Chapter 16.
Constant Loading
Constant loading instructions take at most one parameter (the constant to load) and load it on the evaluation stack. The ILAsm syntax requires explicit specification of the constants (in other words, you cannot use a variable or argument name), in decimal or hexadecimal form:
ldc.i4–1
ldc.i40xFFFFFFFF
Some instructions have no parameters because the value to be loaded is specified by the opcode itself.
Note that for integer and floating-point values, the slots of the evaluation stack are either 4- or 8-bytes wide, so the constants being loaded are converted to the suitable size.
Indirect Loading
An indirect loading instruction takes a managed pointer (&) or an unmanaged pointer (native int) from the stack, retrieves the value at this pointer, and puts the value on the stack. The type of value to be retrieved is defined by the opcode. The indirect loading instructions have no parameters.
Indirect Storing
Indirect storing instructions take a value and an address, in that order, from the stack and store the value at the location specified by the address. Since you are copying the memory (stack slot to specified location) without the need to interpret it, all you care about really is the size of the value to be stored. That’s why the indirect storing instructions for integers don’t have “unsigned” modifications. The address can be a managed or an unmanaged pointer. The type of the value to be stored is specified in the opcode. These instructions have no parameters.
Arithmetical Operations
All instructions performing the arithmetical operations except the negation operation take two operands from the stack and put the result on the stack. If the result value does not fit the result type, the value is truncated. Table 13-2 lists the admissible type combinations of operands and their corresponding result types. If the type combination is not admissible, the result is undefined and will most probably cause an exception. Note also that this table lists generally admissible type combinations of the operands; particular arithmetic instructions may have their own limitations.
Table 13-2. Admissible Operand Types and Their Result Types in Arithmetical Operations
Operand Type |
Operand Type |
Result Type |
---|---|---|
int32 |
int32 |
int32 |
native int |
native int |
native int |
native int |
int32 |
native int |
int64 |
int64 |
int64 |
int64 |
native int |
int64 |
int64 |
int32 |
int64 |
& |
int32, native int (addition or subtraction only, unverifiable) |
& |
Float |
Float (except unsigned division) |
Float |
Float |
int32 (except unsigned division) |
Float |
Float |
native int (except unsigned division) |
Float |
Float |
int64 (except unsigned division) |
Float |
& |
& (subtraction only, unverifiable) |
native int |
The arithmetical operation instructions are as follows:
0 * infinity = NaN, x * NaN = NaN, x * infinity = infinity
0 / 0 = NaN, infinity / infinity = NaN, x / infinity = 0
infinity rem x = NaN, x rem 0 = NaN, x rem infinity = x, x rem NaN = NaN
ldc.i40x80000000// Max. negative number for int32,
//-2147483648
neg
call void[mscorlib]System.Console::WriteLine(int32)
// Output: -2147483648;
// The same effect with subtraction:
ldc.i4.0
ldc.i40x80000000
sub
call void[mscorlib]System.Console::WriteLine(int32)
// Output: -2147483648;
The previous problem is not in any way IL specific; it stems from the binary representation of integer numbers. Floating-point numbers don’t have this problem. Negating NaN returns NaN because NaN, which is not a number, has no sign.
If an arithmetical operation is applied to integer operands and the result overflows the target, the result is bit truncated to fit the target type, with most significant bits thrown away:
ldc.i40xFFFFFFF0// 4294967280
ldc.i40x000000FF// 255
add
call void[mscorlib]System.Console::WriteLine(int32)
// Output: 239 (0xEF);
When int32 and native int are used as operands of arithmetic instructions on a 64-bit platform, int32 operand is sign extended to native int.
Overflow Arithmetical Operations
Overflow arithmetical operations are similar to the arithmetical operations described in the preceding section except that they work with integer operands only and generate an Overflow exception if the result does not fit the target type. The ILAsm notation for the overflow arithmetical operations contains the suffix.ovf following the operation kind. The type compatibility list, shown in Table 13-3, is similar to the list shown in Table 13-2.
Table 13-3. Acceptable Operand Types and Their Result Types in Overflow Arithmetical Operations
Operand Type |
Operand Type |
Result Type |
---|---|---|
int32 |
int32 |
int32 |
native int |
native int |
native int |
int32 |
native int |
native int |
int64 |
int64 |
int64 |
int64 |
native int |
int64 |
int64 |
int32 |
int64 |
& |
int32, native int (unsigned addition or subtraction only, unverifiable) |
& |
& |
& (unsigned subtraction, unverifiable) |
native int |
The overflow operation instructions are as follows:
Bitwise Operations
Bitwise operations have no parameters and are defined for integer types only; floating-point, pointer, and object reference operands are not allowed. As a result, the related operand type compatibility list, shown in Table 13-4, is pretty simple.
Table 13-4. Acceptable Operand Types and Their Result Types in Bitwise Operations
Operand Type |
Operand Type |
Result Type |
---|---|---|
int32 |
int32 |
int32 |
int32 |
native int |
native int |
native int |
native int |
native int |
int64 |
int32 |
int64 |
int64 |
native int |
int64 |
int64 |
int64 |
int64 |
Three of the bitwise operations are binary, taking two operands from the stack and placing one result on the stack; and one is unary, taking one operand from the stack and placing one result on the stack:
ldc.i40x80000000// Max. negative number for int32,
// -2147483648
not
call void[mscorlib]System.Console::WriteLine(int32)
// Output: 2147483647 (0x7FFFFFFF);
// Of course, it's not +2147483648,
// which cannot be expressed by an int32,
// but at least we have the max. positive number
When int32 and native int are used as operands of bitwise instructions on a 64-bit platform, int32 operand is sign-extended to native int.
Shift Operations
Shift operations have no parameters and are defined for integer operands only. The shift operations are binary: they take from the stack the shift count and the value being shifted, in that order, and put the shifted value on the stack. The result always has the same type as the operand being shifted, which can be of any integer type. The type of the shift count cannot be int64 and is limited to int32 or native int.
It is interesting that, as you can see, there are no rotational shift operations in the IL, and it is not obvious how these operations could be implemented through shl and shr.un because rotational shift operations are size specific: rol(i, n) == (shl(i, n%sizeof(i)) | shr.un(i, sizeof(i)-n%sizeof(i))), where i is the operand being shifted and n is the shift count.
Conversion Operations
The conversion operations have no parameters. They take a value from the stack, convert it to the type specified by the opcode, and put the result back on the stack. The specifics of the conversion obviously depend on the type of the converted value and the target type (the type to which the value is converted). If the type of the value on the stack is the same as the target type, no conversion is necessary, and the operation itself is doing nothing more than bloating the IL code.
For integer source and target types, several rules apply. If the target integer type is narrower than the source type (for example, int32 to int16, or int64 to int32), the value is truncated—that is, the most significant bytes are thrown away. If the situation is the opposite—if the target integer type is wider than the source—the result is either sign-extended or zero-extended, depending on the type of conversion. Conversions to signed integers use sign-extension, and conversions to unsigned integers use zero-extension.
If the source type is a pointer, it can be converted to either unsigned int64 or native unsigned int. In either case, if the converted pointer was managed, it is dropped from the GC tracking and is not automatically updated when the GC rearranges the memory layout. A pointer cannot be used as a target type.
If both source and target types are floating point, the conversion merely results in a change of precision. In float-to-integer conversions, the values are truncated toward 0; for example, the value 1.1 is converted to 1, and the value –2.3 is converted to –2. In integer-to-float conversions, the integer value is simply converted to floating point, possibly losing less significant mantissa bits.
Object references cannot be subject to conversion operations either as a source or as a target.
Overflow Conversion Operations
Overflow conversion operations differ from the conversion operations described in the preceding section in two aspects: the target types are exclusively integer types, and an Overflow exception is thrown whenever the value must be truncated to fit the target type. In short, the story is the same as it is with overflow arithmetical operations and arithmetical operations. Note that the overflow condition when converting an integer value depends on this value being considered signed or unsigned. That’s why there are special *.un variants of overflow conversion operations, which presume the integer value on stack being unsigned.
Logical Condition Check Instructions
Logical condition check operations are similar to comparative branching instructions except that they result not in branching but in putting the condition check result on the stack. The result type is int32, and its value is equal to 1 if the condition checks and 0 otherwise; in other words, logically the result is a Boolean value. The two operands being compared are taken from the stack, and since no branching is performed, the condition check instructions have no parameters.
The logical condition check instructions are useful when you want to store the result of the condition check for multiple use or for later use. If you need the condition check to decide only once and on the spot whether you need to branch, you would be better off using a comparative branching instruction.
The admissible combinations of operand types are the same as for comparative branching instructions (see Table 13-1). There are, however, fewer condition check instructions than conditional branching operations: some conditions are just logical negations of other conditions (“not equal” is not “equal,” “less or equal” is not “greater,” and so on), so it would be redundant to introduce special check instructions for such conditions.
Block Operations
Two IL instructions deal with blocks of memory regardless of the type or types that make up this memory. Because of their type blindness, both instructions are unverifiable.
Addressing Arguments and Local Variables
A special group of IL instructions is dedicated to loading the values of method arguments and local variables on the evaluation stack and storing the values taken from the stack in local variables and method arguments. It is to be noted that in the case of vararg methods, the argument-addressing instructions described in the following sections cannot target the arguments of the variable part of the signature.
Method Argument Loading
The following instructions are used for loading method argument values on the evaluation stack:
Method Argument Address Loading
These two instructions are used for loading method argument addresses on the evaluation stack:
Method Argument Storing
These two instructions are used for storing a value from the stack in a method argument slot:
Method Argument List
The following instruction is used exclusively in vararg methods to retrieve the method argument list and put an instance of the value type [mscorlib]System.RuntimeArgumentHandle on the stack. Chapter 10 discusses the application of this instruction.
Local Variable Loading
Local variable loading instructions are similar to argument loading instructions except that no “invisible” items appear among the local variables, so local variable number 0 is always the first one specified in the local variable signature.
Local Variable Reference Loading
The following instructions load references (managed pointers) to the local variables on the evaluation stack:
Local Variable Storing
It would be strange to have local variables and be unable to assign values to them. The following two instructions take care of this aspect of your life:
Local Block Allocation
With all due respect to the object-oriented approach, sometimes it is necessary (or just convenient) to obtain a plain, C-style chunk of memory. The IL instruction set provides an instruction for such allocation. It is to be noted, however, that this memory is available only while the method is executing and is deallocated on the method exit (via ret or an exception). Only the allocating method itself and the methods it calls can access this memory.
Prefix Instructions
The prefix instructions listed in this section have no meaning per se but are used as prefixes for the pointer-consuming instructions—that is, the instructions that take a pointer value from the stack, such as ldind.*, stind.*, ldfld, stfld, ldobj, stobj, initblk, and cpblk—that immediately follow them. When used as prefixes of instructions that don’t consume pointers, the prefix instructions are ignored and do not carry on to the nearest pointer-consuming instruction.
A prefix instruction affects only the immediately following instruction and does not mark the respective pointer as unaligned or volatile throughout the entire method. Both prefixes can be used with the same instruction—in other words, the pointer on the stack can be marked as both unaligned and volatile; in such a case, the order of appearance of the prefixes does not matter.
The ILAsm syntax requires the prefix instructions to be separated from the next instruction by at least a space symbol:
volatile. ldind.i4// Correct
volatile.
ldind.i4// Correct
volatile.ldind.i4// Syntax error
Such a mistake is unlikely with the unaligned. instruction because it requires an integer parameter:
unaligned.4 ldind.i4
The prefix instructions tail. and constrained. are specific to method calling, and the prefix instruction readonly. is specific to array manipulation. These prefix instructions are discussed in respective sections of this chapter.
Addressing Fields
Six instructions can be used to load a field value or an address on the stack or to store a value from the stack in a field. A field signature does not indicate whether the field is static or instance, so the IL instruction set defines separate instructions for dealing with instance and static fields. Instructions dealing with instance fields take the instance pointer—an object reference if the field addressed belongs to a class and a managed pointer if the field belongs to a value type—from the stack.
The ILAsm notation requires full field specification, which is resolved to <token> at compile time:
ldfld int32Foo.Bar::ii
The applicable conversion rules when loading and storing values are the same as those discussed earlier. Note also that the fields cannot be of managed pointer type, as was discussed in Chapter 8.
Calling Methods
Methods can be called directly or indirectly. In addition, you can also use the special case of a so-called tail call, discussed in this section. The method signature indicates whether the method is instance or static, so separate instructions for instance and static methods are unnecessary. What the method signature doesn’t hold, however, is information about whether the method is virtual. As a result, separate instructions are used for calling virtual and nonvirtual methods. Besides, even a virtual method may be called as nonvirtual. In this case, the call is not dispatched through the class’s virtual table, and all your nice overrides have no effect. Because of that, the nonvirtual calls of the virtual methods have been declared unverifiable in version 2.0 except in some specific contexts (see “Direct Calls”).
Method call instructions have one parameter: the token of the method being called, either a MethodDef or a MemberRef. The arguments of the method call should be loaded on the stack in order of their appearance in the method signature, with the last signature parameter being loaded last, which is exactly the opposite of what you would normally expect. Instance methods have an “invisible” first argument (an instance pointer, which is an object reference for reference types or a managed pointer for value types) not present in the signature; when an instance method is called, this instance pointer should be loaded on the stack first, preceding all arguments corresponding to the method signature.
Unless the called method returns void, the return value is left on the stack by the callee when the call is completed.
Direct Calls
The IL instruction set contains three instructions intended for the direct method calls (well, “direct” in the sense that all these instructions directly specify the method being called; some purists would not consider a virtual call “direct” because under the hood it is done via the v-table):
CLR 2.0 or later considers nonvirtual calls of virtual methods unverifiable except in the following cases:
These exceptions cover almost all cases when the called virtual method is guaranteed not to be overridden. I say “almost” because there is at least one such case not covered—when the type of the object reference is reliably traceable and the called method belongs to this type:
.class public A
{
...
.method public virtual void f() { ... }
}
.class public B extends A
{
...
.method public virtual void f() { ... }
}
...
newobj instance void B:: .ctor()
dup
// The objects on the stack are known to be B (not B's descendants cast to B)
call instance void B::f()// should be verifiable, the type matches the object
call instance void A::f()// unverifiable – the type doesn't match the object
...
Indirect Calls
Methods in IL can be called indirectly through the function pointer loaded on the evaluation stack. This allows you to make calls to computed targets—for example, to call a method by a function pointer returned by another method. Function pointers used in indirect calls are unmanaged pointers represented by native int. Two instructions load a function pointer to a specified method on the stack, and one other instruction calls a method indirectly:
It’s easy enough to see that the combination ldftn/calli is equivalent to call, as long as you don’t consider verifiability, and the combination ldvirtftn/calli is equivalent to callvirt.
The ILAsm notation requires full specification of the method in the ldftn and ldvirtftn instructions, similar to the call and callvirt instructions. The method signature accompanying the calli instruction is specified as <call_conv> <ret_type>(<arg_list>). For example,
.locals init(native int fnptr)
...
ldftn void[mscorlib]System.Console::WriteLine(int32)
stloc.0// Store function pointer in local variable
...
ldc.i412345// Load argument
ldloc.0// Load function pointer
calli void(int32)
...
Tail Calls
Tail calls are similar to method jumps (jmp) in the sense that both lead to abandoning the current method and passing the arguments to the tail-called (jumped-at) method. However, since the arguments of a tail call have to be loaded on the evaluation stack explicitly (a tail call discards the stack frame of the current method, unlike a jump, which preserves the stack frame and can use the arguments already loaded), a tail call—unlike a jump—does not require the entire signature of the called method to match the signature of the calling method; only the return types must be the same or compatible. The tail calls are very useful in implementing massively recursive methods: the caller’s stack frame is discarded in the process of a tail call, so there is no risk of overflowing the stack, no matter how deep the recursion is. This is important for the functional languages, which use recursion instead of loops.
Tail calls are distinguished by the prefix instruction tail. immediately preceding a call, callvirt, or calli instruction:
The difference between a method jump and a tail call is that the tail call instruction pair is verifiable in principle, subject to the verifiability of the call arguments, as long as it is immediately followed (in the caller instruction stream) by the ret instruction. As is the case with other prefix instructions, it is illegal to bypass the prefix and branch directly to the prefixed instruction, in this case, call, callvirt, or calli.
Constrained Virtual Calls
Constrained virtual calls were introduced in version 2.0 of the common language runtime in order to deal with instantiations of generic types or methods when the type whose method is called is represented by a type parameter and hence equally might be a reference type or a value type:
.class public G<(IFoo)T>// Interface IFoo specifies method void Foo(int32)
{
.method public static void CallVirtFoo(class!T t, int32val)
{
// How do I get the object ref?
// If T is a reference type, I just needldarg.0
// If T is a value type, I needldarga.s0 and thenbox
ldarg.1
callvirt instance void IFoo::Foo(int32)
...
Obviously, the applicable calling mechanism must be identified “on the spot,” when the virtual call is about to be executed and the nature of the type instance becomes known. This is not good—the IL code of the method becomes dependent on the nature of the generic parameter.
The constrained virtual calls, unlike unconstrained calls and virtual calls, require a managed pointer (&) to the type instance (this pointer), whether this instance is an object reference or a value type instance. In unconstrained calls, as you know, the this pointer must be an object reference (O) or a managed pointer (&) to a value type instance. Uniform usage of a managed pointer in constrained calls allows you to use the same IL instructions, “preparing” the instance pointer for the virtual call:
.class public G<(IFoo)T>// Interface IFoo specifies method void Foo(int32)
{
.method public static void CallVirtFoo(class!T t, int32val)
{
ldarga.s0// load managed pointer to t
ldarg.1
constrained. class!T
callvirt instance void IFoo::Foo(int32)
...
The applicable calling mechanism is identified as follows:
Constrained calls are distinguished by the prefix instruction constrained. immediately preceding a callvirt or ldvirtftn instruction:
The mechanism of constrained virtual calls unifies the way the methods can be called on reference and value types and hence is very useful to compilers, which now don’t have to figure out whether the instance is an object reference or a value type instance. Considering that some languages don’t even make a distinction between reference types and value types and treat all types as objects, the constrained virtual call mechanism is indeed a good addition to the IL instruction set.
Addressing Classes and Value Types
Being object oriented in its base, IL offers quite a few instructions dedicated specifically to manipulating class and value type instances:
ldobj[ .module other.dll]Foo.Bar
ldstr"Hello World!"
ldstr"Hello"+" World!"
or as a byte array, like
ldstr bytearray(A1 00 A2 00 A3 00 A4 00 A5 00 00 00)
In the first case, at compile time the composite quoted string is converted to Unicode before being stored in the #US stream. In the second case, the byte array is stored “as is” without conversion. It can be padded with one 0 byte to make the byte count even (if you forget to do it, the IL assembler will do it as a courtesy). Storing a string in the #US stream gives the compiler the string token, which it puts into the IL stream.
newobj instance void[mscorlib]System.Object:: .ctor()
The newobj instruction is also used for array creation:
newobj instance void int32[0...,0...]:: .ctor(int32, int32)
An array constructor takes as many parameters as there are undefined lower bounds and sizes of the array being created. (Hence, the same number of integer values must be loaded on the stack before newobj is invoked.) In the example just shown, both lower bounds of the two-dimensional array are specified in the array type, so you need to specify only two sizes.
The ILAsm notation requires full specification for classes (value types), methods, and fields used in ldtoken. This instruction is the only IL instruction that is not specific to methods only or fields only, and thus the keyword method or field must be used:
ldtoken[mscorlib]System.String
ldtoken method instance void[mscorlib]System.Object:: .ctor( )
ldtoken field int32Foo.Bar::ff
Arrays and vectors are the only true generics implemented in the first release of the common language runtime. Vectors are “elementary” arrays, with one dimension and a zero lower bound. In signatures, vectors are represented by type ELEMENT_TYPE_SZARRAY, whereas “true” arrays are represented by ELEMENT_TYPE_ARRAY. The two different array types have different layouts and are for the most part unrelated to each other. You can, of course, declare a single-dimensional, zero-lower-bound array (whose ILAsm notation is <type>[0...]), which will be a true array, as opposed to a vector (whose ILAsm notation is <type>[ ]).
Note The IL instruction set defines specific instructions dealing with vectors but not with arrays. To handle array elements and arrays themselves, you need to call the methods of the .NET Framework class [mscorlib]System.Array, from which all arrays are derived. However, don’t look in vain among the System.Array’s methods to find the most useful ones—Get, Set, and Address. These methods are provided by the runtime, and unlike other runtime-provided methods, they are not reflected in the metadata of Mscorlib.dll. The Get method takes N (where N is the rank of the array) arguments (all int32) representing indexes in respective array dimensions and returns the value of the indexed element. The Address method takes the same arguments and returns the managed pointer to the indexed element. The Set method takes N indexes and the element value to be assigned, assigns the specified value to the indexed element, and returns void.
Now let’s get back to the vectors.
Vector Creation
In order to work with a vector, it is necessary to create one. The IL instruction set contains special instructions for vector creation and vector length querying:
.locals init(int32[] arr)
ldc.i4123
newarr[mscorlib]System.Int32// newarr int32 would work too
stloc.0
For specific details about array creation, see the description of the newobj instruction.
Element Address Loading
You can obtain a managed pointer to a single vector element by using the following instruction:
Element Loading
Element loading instructions load a vector element of an elementary type on the stack. All these instructions take the element index (native int) and the vector reference (an object reference) from the stack and put the value of the element on the stack. If the vector reference is null, the instructions throw a NullReference exception. If the index is negative or greater than or equal to the element count of the vector, an IndexOutOfRange exception is thrown.
Element Storing
Element storing instructions store a value from the stack in a vector element of an elementary type. All these instructions take the value to be stored, the element index (native int), and the vector reference (an object reference) from the stack and put nothing on the stack. Generally, the instructions can throw the same exceptions as the ldelem.* instructions described in the preceding section.
Special stelem.* instructions for unsigned integer types are missing for an obvious reason: the stelem.i* instructions are equally applicable to signed and unsigned integer types because the sizes of these integer types are the same.
Code Verifiability
The verification algorithm associates IL instructions with the number of stack slots occupied and available at each moment and with valid evaluation stack states. Stack overflows and underflows render the code not only unverifiable but invalid as well. The verification algorithm also requires that all local variables are zero initialized before the method execution begins. As a result, the .locals directive—at least one, if several of these are used throughout the method—must have the init clause in order for the method to be verifiable.
The verification algorithm simulates all possible control flow paths and branchings, checking to see whether the stack state corresponding to every reachable instruction is legal for this instruction. It is impossible, of course, to predict the actual values stored on the evaluation stack at every moment, but the number of stack slots occupied and the types of the slots can be analyzed.
As mentioned, the evaluation stack type system is coarser than the metadata type system used for field, argument, and local variable types. Hence, the type validity of instructions transferring data between the stack and other typed memory categories depends on the type conversion performed during such transfers. Table 13-5 lists type conversions between different type systems (for example, a value of a local variable of type int8 loaded on the stack becomes int32, and the managed pointer to the same local variable is int8&).
Table 13-5. Evaluation Stack Type Conversions
Metadata Type |
Stack Type |
Managed Pointer to Type |
---|---|---|
[unsigned] int8, bool |
int32 |
int8& |
[unsigned] int16, char |
int32 |
int16& |
[unsigned] int32 |
int32 |
int32& |
[unsigned] int64 |
int64 |
int64& |
native [unsigned] int, function pointer |
native int |
native int& |
float32 |
Float (“native-sized”) |
float32& |
float64 |
Float (“native-sized”) |
float64& |
Value type |
Same type (see substitution rules in this section) |
Same type& |
Object |
Same type (see substitution rules in this section) |
Same type& |
According to verification rules, if top-of-stack has type A, then the current instruction, expecting to find a type B, is verifiable in the following cases only:
These substitution rules set the limits of “type leeway” allowed for the IL code to remain verifiable. As the verification algorithm proceeds from one instruction to another along every possible path, it checks the simulated stack types against the types expected by the next instruction. Failure to comply with the substitution rules results in verification failure and possibly indicates invalid IL code.
A few verification rules, rather heuristic than formal, are based on the question “Is it possible in principle to do something unpredictable using this construct?”
A great many additional rules regulate exception handling, but the place to discuss them is the next chapter.
18.118.0.42