Managed C++ codegen for new, array manipulation, delete
Managed C++ (MC++) code generation is a cool accomplishment. I think it's another good testimony to the CLR's cross-language charter (and IL's flexibility) that we can support C++.
(Not too mention our support for more dynamic languages like F#, SML.Net, and IronPython that also compile to IL)
As the most basic example, I find it interesting to compare the IL generated for a simple program that allocates a buffer and does some manipulation when written in MC++ and C#.
MC+++ | C# |
|
using System;class Program{ static void Main(string[] args) { Console.WriteLine("Multiplication table"); int size = 10; int [] table= new int [size]; for(int i = 0; i < size; i++) { table[i] = i * i; } // table will be freed by GC }} |
The thing I find that causes a double-take for the most people is how MC++ can have explicit memory management (eg, "new" and "delete") and pointer arithmetic when it runs on the CLR; which does garbage collection (instead of explicit memory management) and safe references (instead of raw pointers).
The programs do the same thing (initialize a table of squares). Here's a side-by-side comparison of the IL (with source inline) between the C# and MC++. I've made some annotations and shaded the rows corresponding to interesting differences.
description | MC++ | C# |
declare function and locals | .method assembly static int32 modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) main() cil managed{ .vtentry 1 : 1 // Code size 73 (0x49) .maxstack 3 .locals ([0] int32 i, [1] int32* table, [2] uint32 V_2, [3] int32 size, [4] int32 V_4) | .method private hidebysig static void Main(string[] args) cil managed{ .entrypoint // Code size 47 (0x2f) .maxstack 4 .locals init ([0] int32 size, [1] int32[] table, [2] int32 i, [3] bool CS$4$0000) |
initialization | IL_0000: ldc.i4.0 IL_0001: stloc.s V_4 | //000006: { IL_0000: nop |
Write into | //000006: Console::WriteLine(L"Multiplication table"); IL_0003: ldstr "Multiplication table" IL_0008: call void [mscorlib]System.Console::WriteLine(string) | //000007: Console.WriteLine("Multiplication table"); IL_0001: ldstr "Multiplication table" IL_0006: call void [mscorlib]System.Console::WriteLine(string) IL_000b: nop |
assign size=10 | //000008: int size = 10; IL_000d: ldc.i4.s 10 IL_000f: stloc.3 | //000009: int size = 10; IL_000c: ldc.i4.s 10 IL_000e: stloc.0 |
allocate table | //000009: int * table= new int [size]; IL_0010: ldloc.3 // size IL_0011: stloc.2 // V_2 IL_0012: ldloc.2 // V_2 IL_0013: ldc.i4 0x3fffffff IL_0018: bgt.un.s IL_001f IL_001a: ldloc.2 // V_2 IL_001b: ldc.i4.4 IL_001c: mul IL_001d: br.s IL_0020 IL_001f: ldc.i4.m1// we've computed number of bytes // to allocate (size * sizeof(int)). // We now pinvoke to: msvcr80*.dll!operator new(unsigned int)// The return result is raw pointer value (a void *) just like in C++. IL_0020: call void* modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) new(uint32) IL_0025: stloc.1 // storing return result as raw pointer into table | //000010: int [] table= new int [size]; IL_000f: ldloc.0 // size// have GC allocate array of Int32.// return result is a managed object reference IL_0010: newarr [mscorlib]System.Int32 IL_0015: stloc.1 // store reference into table. |
begin for | //000011: for(int i = 0; i < size; i ++) IL_0026: ldc.i4.0 IL_0027: stloc.0 IL_0028: br.s IL_002e IL_002a: ldloc.0 // jump target for loop back IL_002b: ldc.i4.1 IL_002c: add IL_002d: stloc.0 IL_002e: ldloc.0 // jump target IL_002f: ldloc.3 IL_0030: bge.s IL_003d // conditional exit at loop termination//000012: { | //000011: //000012: for(int i = 0; i < size; i++) IL_0016: ldc.i4.0 IL_0017: stloc.2 IL_0018: br.s IL_0026//000013: { IL_001a: nop |
array index and assignment | //000013: table[i] = i * i; // unsafe pointer math! IL_0032: ldloc.1 // stack: table IL_0033: ldloc.0 // stack: table, i IL_0034: ldc.i4.4 // stack: table, i, 4 (note 4 =sizeof(int)) IL_0035: mul // stack: table, (i*4) IL_0036: add // stack: (table + i*4) IL_0037: ldloc.0 // stack: (table + i*4), i IL_0038: dup // stack: (table + i*4), i, i IL_0039: mul // stack: (table + i*4), (i*i)// stind.i4 will assign 1st (top-most) stack arg, as i4 sized data, // to address specified by 2nd stack arg. IL_003a: stind.i4 // blind pointer assignment, no boundary checks. | //000014: table[i] = i * i; IL_001b: ldloc.1 // stack: table IL_001c: ldloc.2 // stack: table, i IL_001d: ldloc.2 // stack: table, i , i IL_001e: ldloc.2 // stack: table, i, i, i IL_001f: mul // stack: table, i, (i * i) // stelem.i4 will assign 1st (top-most) stack arg to index // from 2nd stack arg into array specified by 3rd arg. IL_0020: stelem.i4 // explicit array assignment, allows boundary checks. |
end for | //000014: } IL_003b: br.s IL_002a | //000015: } IL_0021: nop//000012: for(int i = 0; i < size; i++) IL_0022: ldloc.2 IL_0023: ldc.i4.1 IL_0024: add IL_0025: stloc.2 IL_0026: ldloc.2 // jump target from loop start IL_0027: ldloc.0 IL_0028: clt IL_002a: stloc.3 IL_002b: ldloc.3 IL_002c: brtrue.s IL_001a // loop back to continue |
free table | //000016: delete [] table; IL_003d: ldloc.1 IL_003e: call void modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) delete(void*) | //000016: // table will be freed by GC |
end of functionand return |
|
|
For trivia points, I'll point out there are some minor cosmetic differences including:
- they generate the for-loop differently. MC++ has the comparison first; C# has the comparison at the end. You can verify for yourself they're result in the same behavior.
- C# places a sequence point on the opening '{', whereas MC++ doesn't. Practically, this means when you first step into 'main', in C# you stop at '{', where as in MC++, you stop at the first line after '{'.
- MC++ needs to distinguish between managed and unmanaged strings. (update: MC++ 2005 will use context to decided, see section 5.1 in the translation guide.) Unmanaged strings are just a pointer (RVA) address to the raw string data (as they are in C++). In MC++, managed strings get prefixed with 'S', (like S"Hello"). Managed strings are loaded with the 'ldstr' opcode. Since all strings in C# are managed, C# doesn't need a special string prefix.
There are also some differences that help MC++ interoperate better with native code (like using the Cdecl calling convention on Main and that .vtentry thing).
For now, the interesting differences are around allocating 'table', doing the assignment "table[i] = i *i", and freeing the memory. I've shaded the rows corresponding to these actions.
The declaration of the 'table' local:
It turns out the languages compile the variable 'table' differently:
- MC++ compiles it to a "int32 *", which the runtimes views as a raw pointer. This is essentially opaque data to the CLR and may as well be a pointer-sized int. (It corresponds to a CorElementType of ELEMENT_TYPE_PTR)
- C# compiles it to "int32[]", which the runtimes views as a managed array. This is a managed reference owned by the CLR, and lives on the GC heap. (It corresponds to a CorElementType of ELEMENT_TYPE_SZARRAY).
One significant consequence of this is debuggability. The native debugger can't do much with inspecting a int32* because it's just a raw pointer. It doesn't know it's actually pointing to an array. And it sure doesn't know how big that array is, so it couldn't display the bounds even if it wanted to. In contrast, an int32[] is far more descriptive to the debugger because managed arrays have rich bound information (see ICorDebugArrayValue in CorDebug.idl). Thus in the C# case, you can expand 'table' and see the full contents.
Allocating the buffer:
C# uses the "newarr" IL opcode to initial 'table'. Since 'table' is a managed array, and 'newarr' allocates a managed array, that should all make sense.
In contrast, MC++ calls a method 'new' which will eventually pinvoke out to msvcr80*.dll!operator new(unsigned int), and that will return a raw pointer value to allocated memory, just as you'd expect in unmanaged C++. You can step into the call in VS and see for yourself that you land in msvcr80!new. It's the same story with the call to delete. This is all consistent with MC++'s 'table' being a raw pointer.
Making assignments:
In the C# case, we've got a strongly typed array, and stelem.* is an il opcode for explicitly assigning into an array. This opcode has the semantics to let the jit do array boundary checks.
In the MC++ case, we're dealing with raw pointers. The stind.* IL opcode allows direct memory assignment. You can see the raw pointer manipulation, just as we'd have in unmanaged C++, for the pointer arithmetic It's still IL, but it's not verifiable. That means the code loses a lot of the protection, such as the explicit boundary checks from the stelem opcode.
C# has an unsafe code regions which lets it do the same pointer operations that MC++ is doing here.
In conclusion:
While this is perhaps a laughably basic example (especially in retrospect), these sorts of qualities for MC++ codegen are a preview of the sort of techniques MC++ uses to codegen more complex constructs (like multiple inheritance, which is not supported in C#).
Comments
- Anonymous
September 04, 2005
Good explanation and it has solved some of my questions too - Anonymous
September 04, 2005
The C++ prefix for a managed string is S, no? Or does the compiler deduce that it should be managed by the context? - Anonymous
September 07, 2005
When MC++ compiles an "unmanaged" class into IL (from IJW), it actually compiles the class into an opaque... - Anonymous
September 07, 2005
Mike Dunn - you're correct on both accounts. 'S' is the prefix for managed strings. 'L' is the prefix for unicode. Whidbey MC++ does the implicit conversion (managed strings are unicode so this conversion is legal).
I'll update the issue above. - Anonymous
September 08, 2005
The comment has been removed - Anonymous
September 10, 2005
Yes. however, these differences become more significant as soon as you start doing more interesting things like pointer arithmetic (which could be common when allocating a buffer) or multiple inheritence.
I view MC++'s codegen here as the first baby step in how the rest of IJW works.