Skip to content

Unsafe code and C ​

Safe Nova cannot express everything a systems program needs: talking to hardware, calling C, implementing an allocator. For those there is unsafe, kept small and visible. The contract unsafe code must keep is in Unsafe code, and the C boundary in the type system's C interoperability section.

Raw pointers ​

*T and *mut T are raw pointers: plain addresses, Copy, with no borrow attached. Making a pointer is safe; using one (read, write, add, turning it back into a reference) is allowed only inside unsafe.

nova
/// Safety: `p` points to `n` initialised `u32`s that nothing else writes while this runs.
unsafe fn sum(p: *u32, n: usize) -> u64 {
    var total: u64 = 0;
    for (i in 0..n) {
        total += p.add(i).read() as u64;
    }
    return total;
}

/// Safety: the pointer comes from a live array and stays inside it.
fn main() {
    var values = [1u32, 2, 3, 4];
    let p = borrow mut values[0] as *mut u32;
    unsafe {
        p.add(3).write(10);
    }
    Console.writeLine(unsafe { sum(p, values.length) });
}
text
16

The Safety: rule ​

Every unsafe fn, and every function that contains an unsafe block, must say in its doc comment why it is sound, on a line starting with Safety:. The compiler enforces it:

nova
fn first(p: *u8) -> u8 {
    return unsafe { p.read() };
}
text
main.nova:1:4: error[N0283]: function 'first' uses 'unsafe' and needs a '/// Safety:' doc comment saying why it is sound (type-system.md §27.4)
  |
1 | fn first(p: *u8) -> u8 {
  |    ^^^^^

For an unsafe fn, the line states what callers must guarantee; for a safe function with an unsafe block inside, why the block cannot go wrong. Reviewers read these lines; the compiler checks that they exist.

unsafe permits only the pointer operations, calls of unsafe functions and C functions, and volatile accesses. Everything else in an unsafe block is checked exactly as in safe code.

Calling C ​

C functions are declared in an extern "C" block and called inside unsafe, since Nova cannot check what C does. Their types are C's: integers, bool, raw pointers, *CChar for C strings, and @repr(C) structs.

nova
extern "C" {
    fn strlen(s: *CChar) -> usize;
    fn puts(s: *CChar) -> i32;
}

/// Safety: the literal ends in a zero byte, as `puts` and `strlen` need.
fn main() -> i32 {
    let text = "hello from C\0";
    let p = text.ptr as *CChar;
    unsafe {
        puts(p);
    }
    return truncate<i32>(unsafe { strlen(p) });
}

C files are built with the program: novac build main.nova helpers.c -o program, or the c list of a package's manifest.

Being called from C ​

@export makes a Nova function callable from C, and @repr(C) gives a struct C's layout. novac writes nova_exports.h with their declarations for the C side to include.

nova
@repr(C)
struct Point {
    x: i32;
    y: i32;
}

/// The Manhattan length of a point, callable from C as `geo_length(struct Point)`.
@export("geo_length")
fn length(p: Point) -> i32 => abs(p.x) + abs(p.y);

fn abs(v: i32) -> i32 => if (v < 0) -v else v;

fn main() {
    Console.writeLine(length(Point { x: 3, y: -4 }));
}

@callback gives a function a C address without exporting a symbol, for C APIs that take a function pointer and a void * argument, as kernels' timer and thread APIs do. extern "C" fn(…) -> R is the type of such a pointer.

C structs from the C code's headers ​

A C codebase's own structs are not re-declared in Nova, where the two copies could drift apart. They are imported from its headers: Nova uses them by pointer or embedded by value, names only the fields it uses, and the C compiler checks each one at build time.

nova
@header("kernel/timer.h")
extern "C" {
    @ctype("struct timer")
    struct timer;
    @effects()
    fn timer_start(t: *mut timer, delay_ns: u64);
}

See C structs from headers and Kernel code.

Arrays, keyword names and tables ​

A C array member is declared as a Nova array (uint8_t uuid[16] is uuid: [u8; 16]) and used like one: indexed with bounds checks, sliced, copied. A member whose name is a Nova keyword takes @cname. An array of C structs is indexed in place. A constant table can be exported to C as data with @export.

nova
@header("kernel/gpt.h")
extern "C" {
    struct gpt_part {
        first_lba: u64;
        @cname("type")
        kind: [u8; 16];
        uuid: [u8; 16];
    }
    struct gpt_table {
        nparts: u32;
        parts: [gpt_part; 32];
    }
}

/// `const uint8_t gpt_type_esp[16]` in C.
@export("gpt_type_esp")
const TYPE_ESP: [u8; 16] = [0x28, 0x73, 0x2a, 0xc1, 0x1f, 0xf8, 0xd2, 0x11, 0xba, 0x4b, 0x00, 0xa0, 0xc9, 0x3e, 0xc9, 0x3b];

/// Is partition `i` an EFI system partition?
fn isEsp(table: borrow gpt_table, i: usize) -> bool {
    let kind = table.parts[i].kind; // a copy of the C array
    for (k in 0..kind.length) {
        if (kind[k] != TYPE_ESP[k]) { return false; }
    }
    return true;
}

This is how CosmoOS's GPT parser is written in Nova (the C9 report; the rules are ADR 0014).

Next ​

Kernel code.

Pre-1.0: the language and its tools are still changing.