belte

6 Low-Level Features

These features are only enabled in low-level contexts.

Currently, all of these features are enabled everywhere for conciseness. This may change.

Additionally, the Standard Library contains a class named LowLevel that provides various helper methods.

6.1 Low-Level Contexts

Low-level contexts are created by applying the lowlevel modifier to a type declaration, method, or block.

lowlevel class A { ... }
lowlevel struct A { ... }
lowlevel void M() { ... }
lowlevel { ... }

The low-level context extends from the declaration to all statements inside. In other words, if a method is marked lowlevel, the parameter list of that method can use low-level exclusive features.

6.2 Structures

Structures are custom data types that pass by value and use the stack, unlike classes which are heap-allocated.

Structures only allow field declarations with no initializers. Fields within structures cannot be constants or references.

struct MyStruct {
  int a;
  string b;
}

Creating a new instance of a structure uses the same new keyword as classes, but the constructor cannot be overridden and always takes no arguments:

var myInstance = new MyStruct();

Because of this, all fields must manually be written to after structure creation:

myInstance.a = 3;
myInstance.b = "Hello";

6.3 Arrays

Whenever possible, a List should be used in place of C-style arrays.

int![]! v = { 1, 2, 3 };
int![]! v = { 1, 2, 3 };

Arrays are heap allocated and have no members. To sort or get the length of the array, LowLevel.Length<T>(T!) and LowLevel.Sort<T>(T!) can be used.

Arrays are runtime checked, meaning trying to access an index outside the bounds of the array will throw an exception.

6.3.1 Initializer Lists

It is also important to note outside of low-level contexts, an initializer list will create a List, while inside of a low-level context, it will create an array.

Currently, initializer lists always create arrays.

int[] v = { 1, 2, 3 };

6.4 Numerics

To allow for better interop, several numeric types can be used to specify specific sizes. These being int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32, float64. These types are always non-nullable.

All arithmetic upcasts to int and decimal, so casting is required in cases such as:

int32 myInt1 = 5;
int32 myInt2 = 27;
int32 myInt3 = (int32)(myInt1 | myInt2);

Unless knowing the specific size of the integer is required, use the normal int and decimal types, which (eventually) will support specifying ranges.

The actual implementation size of int and decimal are not to be relied on as they can change, though currently int is equivalent to int64 and decimal is equivalent to float64.

6.5 Pointers

To allow for better interop, C-style pointers and be used. Pointers are always non-nullable and can only point to non-nullable types (unless the pointed at type is heap allocated).

6.5.1 Creating and Dereferencing Pointers

To get the address of a local or field, the & operator can be used:

int! myInt = 3;
int* ptr = &myInt;

To dereference the pointer, the * operator can be used:

int! myInt = 3;
int* ptr = &myInt;
int! value = *ptr; // value = 3

Pointers support any level of indirection:

int! myInt = 3;
int* ptr1 = &myInt;
int** ptr2 = &ptr1;
...

Pointers can be freely cast to reinterpret them:

void* ptr = ...;

int* myIntPtr = (int*)ptr;
int myInt = *myIntPtr;

No runtime checks are performed so this operation is inherently unsafe. Consider:

int! myInt = 3;
int* ptr = &myInt;
MyClass* ptr2 = (MyClass*)ptr;
(*ptr2).Method(); // Undefined behavior

class MyClass {
  public void Method() { ... }
}

Unlike all other non-nullable types, pointers can be created without an initializer, in which case they will default to a null pointer:

int* ptr; // ptr = nullptr

The following are all equivalent:

int* ptr;
int* ptr = nullptr;
int* ptr = (int*)null;

6.5.2 Pointer arithmetic

To do arithmetic on a pointer, you must first cast it to an integer type and, do the arithmetic, then cast it back.

For example:

void* myPtr = ...;
// Offset the pointer by 8 bytes
myPtr = (void*)((int64)myPtr + 8);

Indexing an operator will automatically offset the pointer and then dereference it:

char* myPtr = ...;
char! myChar = myPtr[10];

The above example is equivalent to:

char* myPtr = ...;
char! myChar = *((char*)((int64)myPtr + 10 * sizeof(char!)));

6.6 Function Pointers

Function pointers allow calling a function using a pointer to the entry point using the stdcall calling convention.

To get the pointer to managed method, use the & operator. A function pointer can then be called like a normal method:

var myPtr = &MyMethod;
var myInt = myPtr(); // myInt = 4

int32 MyMethod() {
  return 4;
}

When not using var, the explicit function pointer type can be written as returnType(argTypes...)*:

int32(bool, string)* myPtr = &MyMethod;

int32 MyMethod(bool arg1, string arg2) { ... }

Function pointers are treated the same as normal pointers in that they can be freely cast. This is helpful when trying to call a function given a vtable. To declare an unmanaged function pointer (such as with a COM interface vtable), mark it as such with a ~. Consider this example of calling the first function of a vtable:

void** vtable = ...;

((void()*~)vtable[0])();

For clarity, the function pointer set to a temporary:

void** vtable = ...;

var MyFunction = (void()*~)vtable[0];
MyFunction();

6.7 Extern Methods

To call into a unmanaged DLL, an extern method with a DllImport attribute can be declared and called like a typical method:

[DllImport("example.dll")]
static extern void SomeMethod();

SomeMethod();

The method is resolved at runtime, meaning if it cannot be found an exception will be thrown.

Extern methods use the UniCode char set and the stdcall calling convention.

6.8 Fixed Size Buffers

Arbitrary blobs of memory can be reserved with fixed size buffers. Fixed size buffers are struct fields specifying a numeric type and a quantity:

struct MyStruct {
  int32 field[32];
}

In the above example, field reserves a contiguous piece of memory 128 bytes long (sizeof(int32) * 32 = 128).

The field is then treated as a pointer to the start of the blob, which can then be indexed:

var myStruct = new MyStruct();
myStruct.field[0] = 5;
myStruct.field[1] = 10;
...

struct MyStruct {
  int32 field[32];
}

The type pointed at by the buffer can be bool!, uint8, int8, uint16, int16, uint32, int32, uint64, int64, float32, float64, or char!. Note that int and decimal are not valid types in this context because their size is not publicly defined.

6.9 Sizeof Operator

The sizeof(T) operator is the shorthand form of $?LowLevel.SizeOf<T>(). It operates on a type. If the type has a known size at compile time, it replaces the operator with that value as an int32!. Otherwise, it computes the size at runtime. Size is calculated in terms of number of bytes.

The following statements are equivalent:

var myInt = sizeof(bool!);
var myInt = $?LowLevel.SizeOf<bool>();
var myInt = (int32)1;

The following table shows all types with a known size at compile time. All other types compute their size at runtime.

Type Size
bool! 1
int8 1
uint8 1
char! 2
int16 2
uint16 2
int32 4
uint32 4
float32 4
int64 8
uint64 8
float64 8

Note that taking the size of a reference type will return the size of the reference itself, not the object. Similarly, taking the size of a pointer returns the pointer size, not the size of the pointed at type.

6.10 Stackalloc Operator

Similar to fixed sized buffers for fields, the stackalloc T[s] operator can be used to create a segment of memory for indexing where the size of the memory is sizeof(T) * s. The memory is allocated on the stack. The operator results in a pointer to the start of the memory.

int32* ptr = stackalloc int32[10];
ptr[0] = 5;
ptr[1] = 10;
...

6.10.1 Stackalloc Locals

A C-style shorthand is available for stackalloc expressions. The following are equivalent:

int32 ptr[10];
int32* ptr = stackalloc int32[10];

6.11 Inline IL

For performance critical code paths or when you are trying to emit specific instructions with no language equivalent, an inline IL block can be used:

int32 a = 0;

il {
  ldc.i4.0;
  stloc.0;
}

Symbols can be referenced like normal:

int32 a = 0;

il {
  call Func
  stloc.0;
}

int32 Func() {
  return 10;
}

6.11.1 Verification

The instructions in the IL block are minimally verified. All instructions must provide the proper number and kind of arguments.

Additionally, the stack must be balanced within the block. To bypass this check, the noverify modifier can be used:

il noverify {
  add;
}

6.11.2 Unsupported Instructions

The inline IL allows most CIL instructions. The following instructions are not currently supported:

The jmp, switch, and branch instructions are unsupported because there is currently no way to get instruction addresses or define labels.

The endfault, endfilter, endfinally, leave, leave.s, no., rethrow, and throw instructions are not supported because there is currently no way to specify exception handling blocks within the inline IL.

The ret instruction is unsupported to ensure the IL remains localized to it’s block and has a zero delta stack balance.