Introducing Crust. Like C/C++ but C/Rust

I cannot help but offer a heads up to one of the most informative and entertaining YouTube videos on Rust I have ever seen.

"What if Rust was Worse than C" https://www.youtube.com/watch?v=5MIsMbFjvkw&lc=Ugyuq-d0PeRwEsG6EVx4AaABAg.AGx8DBlGR-QAGyeSekCG0Z by Tsoding.

Basically the idea is to use Rust as if it were C, kind of like how people program in C with C++ compilers and try to minimise their use of the C++ standard library or it's advanced features.

The rules of Crust are:

  • Every function is unsafe
  • No references, only raw pointers
  • No std, but libc is allowed
  • Pointers are mutable by default

Bound to annoy Rust purists with a thing about memory safety:) But I picked up a lot of useful tips there. Useful for anyone who wants to get into embedded Rust for example.

Enjoy.

2 Likes

One thing that bothered me about Crust is that Tsoding was using rustc from a Makefile to build it. After futzing around and a little help from an AI buddy I managed to get a minimal Crust program built with Cargo and a build.rs.

Then the problem was that cargo test did not work. After more futzing around I fixed that. And below is how it looks. If anyone has any suggestions that would be great.

// main.rs - A minimal Crust program with tests
//
#![cfg_attr(not(test), no_std)]
#![cfg_attr(not(test), no_main)]

extern crate libc;

#[cfg(not(test))]
use core::panic::PanicInfo;

#[cfg(not(test))]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

#[cfg(not(test))]
#[unsafe(no_mangle)]
pub extern "C" fn main() -> i32 {
    let s = b"Hello world!\n\0";
    unsafe {
        libc::printf(s.as_ptr() as *const i8);
    }
    0
}

#[allow(unused)]
fn add(x: u32, y: u32) -> u32 {
    let r = x + y;
    let s = "%d + %d = %d\n\0";
    unsafe {
        libc::printf(s.as_ptr() as *const i8, x, y, r);
    }
    r
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn it_works() {
        let result = add(2, 2);
        assert_eq!(result, 4);
    }
}
// build.rs - Required for building Crust programs (On Mac OS at least)
fn main() {
    // Link against the system C library on macOS
    println!("cargo:rustc-link-lib=System");
}
# Cargo.toml
[package]
name = "rust_libc"
version = "0.1.0"
edition = "2024"

[dependencies]
libc = "0.2.172"


[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

Can you tell us a bit about the motivation? (I generally watch no videos, and have currently no time available to do watching).

My guess would be to help novices learn unsafe pointer stuff, avoiding coming in contact with real C. Or creating tiny programs with libc, avoiding the megabyte which Rust typically adds with static linking. But note, Zig is already a quite successful C replacement, Carbon is something like a nicer C++ frontend, and Nim is something I used for nearly ten years.

Hmm...well, the thing is Tsoding does not pitch his YT channel as instructional or educational or even useful. No, it's live stream coding for entertainment. He has done all kind of weird and wonderful things all in all kind of languages. C, C++, Zig, Jai, Swift, Ada, shader languages, and much more. Typically languages and things he knows little about before he starts, it's an exploration. He does it with great style and humour. He always strives to illuminate bloat and inefficiency. My favourites have been a series on building and training a neural network from scratch and writing a compiler for his own language, Porth.

Many people waste many hours of their lives watching football or ice hockey or whatever. Me I keep half an eye on things like this whilst I'm doing something else.

In this case he's just having fun. But it turns out there is a lot of useful nuggets of information in there.

As for Zig, Carbon, Nim etc, I'm sure they are just fine. Just that I had never heard of them when I stumbled across Rust 5 years ago and nothing has lured me away from it yet. Actually I'm actively resistant to YAFL (Yet Another F...... Language) after having been through so many over the decades.

Anyway, after Crust I'm more encouraged to use Rust on my micro-controllers.

1 Like

I have a crate inspired by zig’s cImport:

It requires bindgen to be installed.


#![no_std]
#![no_main]

use core::ffi::*;
use defer_lite::defer;


c_import::c_import!(
    "<stdio.h>", 
    "<stdlib.h>",
    "<cairo.h>",
    "$pkg-config --cflags cairo",
    "--link cairo", 
    "--link c"
);

#[no_mangle]
pub extern "C" fn main(argc: c_int, argv: *const *const c_char) -> c_int {
    unsafe {
        let args = core::slice::from_raw_parts(argv, argc as _);
        for (i, arg) in args.iter().enumerate() {
            printf("arg %d is %s\n\0".as_ptr() as _, i, *arg);
        }
        let version = cairo_version();
        let msg_len = snprintf(core::ptr::null_mut(), 0, "Cairo version is: %d\n\0".as_ptr() as _, version);
        let buf: *mut c_char = malloc(msg_len as _) as _;
        defer!(free(buf as _));
        snprintf(buf, msg_len as _, "Cairo version is: %d\n\0".as_ptr() as _, version);
        printf("%s\n\0".as_ptr() as _, buf);
    }
    0
}

#[panic_handler]
fn ph(_: &core::panic::PanicInfo) -> ! {
    loop {}
}

This reminds me about a joke-blog post I have read some time ago, about a guy who during a job interview was asked to solve some very easy problem in Rust (maybe it was fizz-buzz - I can't remember the details). And instead just writing simple solution, he started writing minimal no-std/no-core/no-main binary while constantly talking over the interviewer. And if I recall correctly the punch-line of this blog was that after he finished his "rant" the interviewer asked him if it could be done simpler, to which he stated that yes, you could pre-calculate the solution to the question at build-time in a build script.

I cannot remember whose blog it was, and cannot find a link to it. But if anyone knows what I am talking about and has a link to it I would gladly read it one more time, because I gave me a pretty good laugh the first time.

2 Likes

Ouchies!

I didn't even do this when I was bootstrapping Rust on Nintendo 64.

4 Likes
8 Likes

I broke my Crust.

In the simple example as shown above if I add a loop:

for n in 0..10 {
}

The build fails with link error:

error: linking with `cc` failed: exit status: 1
  |
  = note:  "cc" "/var/folders/z9/wszzqp3136l17fp2n91c8hc00000gn/T/rustcV3ONx0/symbols.o" "<5 object files omitted>" "-lSystem" "/Users/michaael/rust_libc/target/debug/deps/{liblibc-98bd59178008c79a.rlib}.rlib" "<sysroot>/lib/rustlib/aarch64-apple-darwin/lib/{librustc_std_workspace_core-*,libcore-*,libcompiler_builtins-*}.rlib" "-liconv" "-arch" "arm64" "-mmacosx-version-min=11.0.0" "-o" "/Users/michaael/rust_libc/target/debug/deps/rust_libc-40f6e9d0116c8c3a" "-Wl,-dead_strip" "-nodefaultlibs"
  = note: some arguments are omitted. use `--verbose` to show all linker arguments
  = note: Undefined symbols for architecture arm64:
            "_rust_eh_personality", referenced from:
                /Users/michaael/.rustup/toolchains/nightly-aarch64-apple-darwin/lib/rustlib/aarch64-apple-darwin/lib/libcore-1c17864a195820fd.rlib[3](core-1c17864a195820fd.core.70eb9257b402853b-cgu.0.rcgu.o)
          ld: symbol(s) not found for architecture arm64
          clang: error: linker command failed with exit code 1 (use -v to see invocation)

Which I hear is because the for pulls in some panicking and unwinding which I don't have with no_std.

Is there anything I can do about that? Running on Mac OS.

Meanwhile in the spirit of Crust I made my own Range iterator so I can write:

    for n in Range::new(10, 20) {...}

Wow, what? That is Crust on steroids.

Thanks! Can't wait to read it again.

Playing with the Crust idea a bit I wanted to put things on the heap not just the stack. But I don't have Box. No problem:

trait CBox<T>
where
    T: Default,
{
    fn new() -> *mut T {
        let size = size_of::<T>();
        let ptr = unsafe { malloc(size) as *mut T };
        if ptr.is_null() {
            panic!("Memory allocation failed");
        }

        unsafe {
            ptr.write(T::default());
        }
        ptr
    }

    fn delete(ptr: *mut T) {
        unsafe {
            free(ptr as *mut ffi::c_void);
        }
    }
}

#[repr(C)]
#[derive(Default)]
pub struct MyStruct {
    pub value: u64,
    flag: bool,
}

impl CBox<MyStruct> for MyStruct {}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_struct_on_stack() {
        let my_struct = &mut MyStruct::default() as *mut MyStruct;
        unsafe {
            (*my_struct).value = 0xA30BA30BA30BA30B;
            (*my_struct).flag = true;
            {
                assert!((*my_struct).value == 0xA30BA30BA30BA30B);
                assert!((*my_struct).flag == true);
            }
        }
    }

    #[test]
    fn test_struct_on_heap() {
        // Create struct on heap.
        let my_struct = MyStruct::new();
        // Check initial field values
        unsafe {
            assert!((*my_struct).value == 0);
            assert!((*my_struct).flag == false);
        }

        // Check updating of fields
        unsafe {
            (*my_struct).value = 0xA30BA30BA30BA30B;
            (*my_struct).flag = true;

            assert!((*my_struct).value == 0xA30BA30BA30BA30B);
            assert!((*my_struct).flag == true);
        }
        // Must manually free struct on heap
        MyStruct::delete(my_struct);
    }
}

So far Miri does not complain about what I'm doing.

There we go, Crust still has traits, generics, macros, all good stuff. Like C but better :slight_smile:

2 Likes

The madness continues. Tsoding made a dynamic array in Crust so I had to make my own:

use core::mem;
use core::ptr;
use libc::{c_void, free, malloc, realloc, size_t};

#[repr(C)]
pub struct Vector<T> {
    ptr: *mut T,
    len: usize,
    capacity: usize,
}

impl<T> Vector<T> {
    pub fn new() -> Self {
        Vector {
            ptr: ptr::null_mut(),
            len: 0,
            capacity: 0,
        }
    }

    pub fn push(ptr: *mut Vector<T>, value: T) {
        let this = unsafe { &mut *ptr };
        if this.len == this.capacity {
            let new_capacity = if this.capacity == 0 {
                1
            } else {
                this.capacity * 2
            };
            let new_size = new_capacity * mem::size_of::<T>();
            let new_ptr = if this.ptr.is_null() {
                unsafe { malloc(new_size as size_t) as *mut T }
            } else {
                unsafe { realloc(this.ptr as *mut c_void, new_size as size_t) as *mut T }
            };

            if new_ptr.is_null() {
                panic!("Failed to allocate memory");
            }

            this.ptr = new_ptr;
            this.capacity = new_capacity;
        }

        unsafe {
            ptr::write(this.ptr.add(this.len), value);
        }
        this.len += 1;
    }

    pub fn pop(ptr: *mut Vector<T>) -> Option<T> {
        let this = unsafe { &mut *ptr };
        if this.len == 0 {
            None
        } else {
            this.len -= 1;
            Some(unsafe { ptr::read(this.ptr.add(this.len)) })
        }
    }

    pub fn len(ptr: *const Vector<T>) -> usize {
        unsafe { (*ptr).len }
    }

    pub fn is_empty(ptr: *const Vector<T>) -> bool {
        unsafe { (*ptr).len == 0 }
    }

    pub fn capacity(ptr: *const Vector<T>) -> usize {
        unsafe { (*ptr).capacity }
    }

    pub fn get(ptr: *const Vector<T>, index: usize) -> *const T {
        let this = unsafe { &*ptr };
        if index >= this.len {
            panic!("Index out of bounds");
        }
        unsafe { this.ptr.add(index) }
    }

    pub fn get_mut(ptr: *mut Vector<T>, index: usize) -> *mut T {
        let this = unsafe { &*ptr };
        if index >= this.len {
            panic!("Index out of bounds");
        }
        unsafe { this.ptr.add(index) }
    }
}

impl<T> core::ops::Index<usize> for Vector<T> {
    type Output = T;

    fn index(&self, index: usize) -> &Self::Output {
        unsafe { &*Vector::get(self as *const Vector<T>, index) }
    }
}

impl<T> core::ops::IndexMut<usize> for Vector<T> {
    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
        unsafe { &mut *Vector::get_mut(self as *mut Vector<T>, index) }
    }
}

impl<T> Drop for Vector<T> {
    fn drop(&mut self) {
        if !self.ptr.is_null() {
            while Vector::pop(self as *mut Vector<T>).is_some() {}
            unsafe {
                free(self.ptr as *mut c_void);
            }
        }
    }
}

Breaks the rules a bit as some methods use references. TBD.

I think an exception for &self and &mut self should be reasonable

This has the potential to cause UB because of alignment restrictions, depending on the definition of T and the details of the malloc implementation on your system. Miri will only complain about this if it actually observes malloc giving you an incorrectly-aligned pointer.

In practice, malloc probably works with chunks larger than the biggest alignment requirement you'll run across, so it's probably fine for experimentation like this. But best to avoid things like #[repr(align(...))] annotations.

C doesn't have methods, so anyone that has any moral standards at all wouldn't dare! :face_savoring_food:

1 Like

Yes, I see no sensible way around this for creating structs on the stack

       let vec = &mut Vector::new() as *mut Vector<i32>;
       Vector::push(vec, 1);

I was wondering about that malloc and alignment. I decided any platform with such a crazy malloc is no place I want to run :slight_smile:

Good point. I can counter with:

  1. My compiler tells me these are not "methods" they are "associated items". For example the warning message:
warning: associated items `new`, `push`, `len`, `len_1`, `is_empty`, and `capacity` are never used
  1. If an "associated" item takes a raw pointer to self is it really a "method"? It is a common thing in C to have a struct and then a lot of functions that take a pointer to struct as first parameter. Why should I not do that in Crust.

  2. I guess I could define those "methods" outside of an impl block as C style free functions (Which is what Tsoding ended up doing) But then I have to add a type parameter declaration. This:

pub fn len<T>(ptr: *const Vector<T>) -> usize {
    unsafe { (*ptr).len }
}

Instead of this:

    pub fn len(ptr: *const Vector<T>) -> usize {
        unsafe { (*ptr).len }
    }

I don't have much of a problem with namespacing, it's just letting you avoid repeating a prefix in a context where it would be redundant most of the time, and impl blocks are even less offensive. The purest terrible use of Rust as if it were C would be to use macros instead of generics too - but there are some evils that we must not stand silently by as they happen.