20 KiB
DSCS Reference (Draft v0.3)
DSCS — Damn Simple Code (Systems)
Introduction
DSCS is the low-level half of the DSC language family — a simple, explicit systems language with the capabilities of a language like C, but with improved safety and ergonomics. It compiles down to DSA assembly and is meant to be usable directly for things like kernels and drivers.
DSCS is also the foundation DSCX (the higher-level, OOP-flavoured member of the family) transpiles down to: every valid DSCS program is a valid DSCX program. This document covers DSCS on its own.
Design goals
- A simple, explicit systems language with all the capabilities of a language like C, with improved safety and ergonomics.
- Static dispatch only. Generics are monomorphized at compile time; there is no
built-in
dyn Trait, no vtables, and no hidden indirect calls. Where genuine runtime polymorphism is needed, you build it explicitly — see Runtime Polymorphism. - Manual memory management. No garbage collector, no automatic reference counting. Allocators are passed explicitly wherever allocation happens.
- No borrow checker, no lifetimes. DSCS trusts the programmer. See Ownership & Memory Safety.
Lexical Structure
// single line comment...
/* multi
line
comment */
Variables, Constants & Statics
fn main() {
let local: Type; // local variables
}
const CONST: Type = val; // strictly cannot change, computed at compile time.
static STATIC: Type = val; // a global value that can change. fixed size.
Primitive Types & Literals
fn types() {
let boolean: bool = false; // or true;
let integer: u32 = 37; // or i32, i16, i8, u16, u8;
let hex: u8 = 0xAA;
let oct: u8 = 0o5;
let binary: u8 = 0b01000011;
}
Arrays
Because the size is stored with the type, the compiler can infer the length:
let arr: [u32; 6] = [0, 1, 2, 3, 4, 5];
// = [1; 6]; // alternative syntax for [1, 1, 1, 1, 1, 1]
Pointers
// pointers. these are not references, so care should be taken to use them safely.
let ptr: *u32 = &integer;
let mut_ptr: *mut u32 = &mut integer;
*T is read-only; *mut T is read/write. Full rules governing when a pointer is
safe to use are covered in Ownership & Memory Safety —
raw pointers carry no compiler-enforced validity guarantee at all.
Control Flow
if/else are expressions — the same "no trailing semicolon on the last line is the
return value" rule applies as in function bodies:
let sign = if x > 0 { 1 } else if x < 0 { -1 } else { 0 };
For a single-statement body, => can be used instead of braces, aligning with
match's own arm syntax:
if condition => do_y();
The same rule applies to else, and the two forms compose — a branch can use
whichever form fits it:
if condition => do_y(); else { a(); b(); }
As an expression, the arrow form has no trailing semicolon on either branch — else
(a keyword) is what tells the parser where a branch's expression ends, same as any
other expression grammar stopping at a token it can't extend through:
let sign = if x > 0 => 1 else if x < 0 => -1 else => 0;
Dangling-else rule: when if statements nest without an explicit else on the
inner one, else binds to the nearest unmatched if — the same convention C and
C++ use. This is a well-known ambiguity class (it's the specific reason Rust
mandates braces on if bodies), reintroduced here deliberately in exchange for the
more compact single-statement form; use explicit braces on the inner if whenever
the binding could be unclear:
if a => if b => x(); else => y(); // else binds to `if b`, not `if a`
if a { if b => x(); } else => y(); // braces make the outer binding explicit instead
while loops:
let mut i: u32 = 0;
while i < 10 {
i += 1;
}
There's no for loop construct defined yet — worth deciding later whether it's
built as sugar over while or added as its own construct.
Structs
struct Point {
// declare a field with the "field" keyword, and end with a semicolon
field x: u32;
field y: u32;
// make a field public with the pub keyword.
pub field id: u32;
// consts can also be stored within struct definitions.
// this associated constant can be accessed with Point::SOME_SIZE
const SOME_SIZE: u32 = 0;
// you can also use associated statics.
// as with statics in other languages,
// you should use an atomic type when dealing with them in concurrent code,
// even on a single core architecture like DSA.
static COUNT: u32 = 0;
pub fn new(x: u32, y: u32) -> Self {
// not thread safe!
let id = COUNT;
COUNT += 1;
Self {
x,
y,
id
}
}
// methods can be declared inside struct definitions.
// methods starting with a *self access the struct immutably
pub fn get_x(*self) -> u32 {
// implicit return on the last expression of a function body;
self.x
}
// the *mut self argument means the method can mutate the instance of the struct it is performed on.
pub fn set_y(*mut self, y: u32) {
self.y = y;
}
// returns a unit vector from the origin in the direction of the point.
// taking self as a parameter implicitly moves self into the function body.
// this means the value is no longer in the scope of the caller and is therefore inaccessible.
// any pointers to the value that still exist may be dangling and should not be used.
pub fn unit(self) -> Self {
Self {
x: self.x / (self.x + self.y),
y: self.y / (self.x + self.y),
}
}
}
Enums
You can use enums to represent various different types at the same time — useful for state machines.
enum Shape {
variant Circle(u32);
variant Rectangle(u32, u32);
variant Square(u32);
variant Point; // doesn't have a size so we use the unit variant.
const PI: u32 = 3; // the lack of floating point values isn't ideal.
pub fn is_square(*self) -> bool {
// pattern matching with match!
match *self {
Self::Square(_) => true,
_ => false,
}
}
}
Generics
DSCS supports simple generics with static dispatch — every generic function or struct is monomorphized once per concrete type it's used with at compile time. There is no runtime type erasure and no hidden indirection.
struct Vec<T> {
field data: *mut T;
field capacity: u32;
field length: u32;
// with_capacity takes a generic T and an allocator, and returns an instance of
// Self holding that type T. DSCS is a language with manual memory management, so
// you must pass an allocator whenever you intend to allocate dynamic memory.
fn with_capacity<A: Allocator>(alloc: *mut A, capacity: u32) -> Self {
let ptr = alloc.alloc(capacity) as *mut T;
Self {
data: ptr,
capacity,
length: 0
}
}
fn length(*self) -> u32 {
self.length
}
fn resize(*mut self, alloc: *mut A, new_size: u32) {
self.data = alloc.realloc(self.data as *mut void, new_size) as *mut T;
self.capacity = new_size;
}
// consumes self and frees its memory.
fn drop(self, alloc: *mut A) {
alloc.free(self.data as *mut *void);
}
}
Note on the original draft: the earlier version of this example passed
alloc: *mut Allocatordirectly, withAllocatorused as a bare trait type rather than an explicit generic bound. This is intentional, not an oversight — see Bare Trait Names as Types below for the rule.
Traits
trait Allocator {
// allocates a new block of memory of the given size and returns a pointer to it.
// a void pointer must be explicitly cast by the caller to the type that they intend to use it as, before it can be dereferenced.
// dereferencing a void pointer is a compiler error.
fn alloc(*mut self, size: u32) -> *mut void;
// frees the memory allocated to `ptr`
// this also sets the pointer to a null pointer so that the user can check it has been freed.
// while we cannot set it to zero by dereferencing it,
// we can cast it to a *null which implicitly sets the pointer to zero.
fn free(*mut self, ptr: *mut *void);
// reallocates the block at `ptr` to be `new_size` bytes, and returns a pointer
// to the (possibly moved) block.
fn realloc(*mut self, ptr: *mut void, new_size: u32) -> *mut void;
}
trait DivisibleByN<T: Integer> {
// the integer_div method belongs to the Integer trait.
fn is_divisible(*self, n: u32) -> bool {
self.integer_div(n) == 0
}
}
Because traits are always statically dispatched, a bound like <T: Integer> or
<A: Allocator> is resolved and monomorphized entirely at compile time — calling a
trait method never involves a runtime lookup.
Bare Trait Names as Types
Since DSCS has no dynamic dispatch, a bare trait name used directly in a type
position is unambiguous — there's no competing dyn Trait reading it could be
confused with — so it's allowed as shorthand for an implicit static-dispatch generic
parameter, the same idea as Rust's impl Trait in argument position, just without
the impl keyword:
fn alloc_point(a: *mut Allocator) -> Box<Point> { ... }
// exactly equivalent to:
fn alloc_point<A: Allocator>(a: *mut A) -> Box<Point> { ... }
Each occurrence gets its own independent, anonymous type parameter — this
matches Rust's actual impl Trait semantics exactly, including the consequence that
two bare-trait-typed parameters in the same signature are not required to be the
same concrete type:
fn transfer(src: *mut Allocator, dst: *mut Allocator) { ... }
// src and dst may be DIFFERENT concrete Allocator types — nothing here forces them
// to agree.
If two or more parameters need to share the same concrete type, use an explicit named generic bound instead:
fn transfer<A: Allocator>(src: *mut A, dst: *mut A) { ... }
// src and dst are now forced to be the same concrete allocator type.
This sugar is currently defined for parameter position only. Return-position bare
trait names (Rust's impl Trait return-position equivalent) raise a separate
question — an opaque return type's exact size wouldn't be visible to the caller,
which matters more in a systems language than it does in Rust — and aren't covered
by this rule yet.
Function Types
Functions are values too — this matters in systems programming, since interrupt handlers need to be installed as data the machine can jump to:
fn add(x: u32, y: u32) -> u32 {
return x + y;
}
// in systems level programming we sometimes need to write ISRs to handle interrupts.
fn invalid_instruction_fault() {
// handle the interrupt
}
// but we can't just put a function in our code and expect interrupts to go to it.
// the machine needs to know where to find our interrupt handlers.
// this means functions also need to be a type!
// C got around this using void pointers, but we can do better here.
// A function pointer has the type `Func<(ArgTypes...), RetType>`, with the extra
// hardcoded constraint that the argument list must be written as a tuple type, even
// for a single argument.
let handler: Func<(), void> = invalid_instruction_fault;
let func: Func<(u32, u32), u32> = add;
func(1, 2); // == 3
A Func<...> value is nothing more than the address of the function it points to —
one machine word, Copy, no heap allocation. It cannot capture or close over
local variables; it's purely a code address. If you need a callable value that also
carries its own state, see the next section.
Runtime Polymorphism (without dynamic dispatch)
Sometimes you genuinely need runtime polymorphism — for example, a driver table
where the concrete device type isn't known until the kernel is running. DSCS has no
built-in dyn Trait for this. Instead, you build the vtable yourself, as an ordinary
struct of function pointers:
struct ShapeOps {
field area: Func<(*void), u32>;
}
fn circle_area(self: *void) -> u32 {
let c = self as *Circle;
3 * (*c).radius * (*c).radius
}
const CIRCLE_OPS: ShapeOps = ShapeOps { area: circle_area };
// A caller holding a `*void` payload pointer and a `ShapeOps` table can now call
// `(ops.area)(payload)` without ever knowing the concrete type behind the pointer —
// this is exactly what `dyn Trait` does in other languages, just fully visible and
// under your control instead of compiler-generated.
This is also the pattern to reach for whenever you need something closure-shaped: a
Func<...> paired with its own *void (or concretely-typed) state pointer, bundled
into a small struct.
Memory Management
You have a few different ways to manage memory.
Manual alloc/free (discouraged)
fn main() {
let mut alloc = ArenaAllocator::new(HEAP_START);
// the pattern below is commonly used in C, however it's strongly discouraged in this language!
// the data is not a valid instance of SomeType, and is not guaranteed to be zero initialised.
// please don't do this, it makes it very easy for UB to happen!
let ptr = alloc.alloc(Point::size()) as *mut Point;
ptr.id = 1;
alloc.free(ptr as *mut *void); // don't use pointer now!
}
defer
Deferring execution of a statement means it's executed as soon as the enclosing
function returns — regardless of which return path was taken. Multiple defer
statements in the same function run in reverse order (last deferred, first executed),
the same as Zig.
Box<T>
fn main() {
let mut point = Box::new(alloc, Point::new(2, 3));
// Box::drop(self, alloc) uses the passed allocator to free its contents when called.
defer point.drop(alloc);
// Box is implemented as a transparent type so we can simply use Box<T> as if it were a *mut T!
// method calls on a Box<T> auto-dereference through it exactly as they would
// through a raw *mut T receiver.
point.set_y(5);
}
Box<T> has exactly one owner. Cloning a Box<T> is a deep clone — it allocates
fresh memory and copies the underlying value, which is why cloning a Box requires
passing an allocator explicitly, same as any other allocation.
Rc<T>
Rc<T> is for values that need multiple simultaneous owners. Unlike Box<T>,
cloning an Rc<T> is cheap: it doesn't touch or copy the underlying data at all, it
just increments a reference count and hands back a new, independent Rc<T> handle to
the same memory.
fn main() {
let a = Rc::new(alloc, Point::new(1, 2));
let b = a.clone(); // cheap: bumps the refcount, no allocation, no data copy
defer a.drop(); // decrements the refcount
defer b.drop(); // decrements the refcount; frees the Point once it reaches zero
}
Like Box<T>, each individual Rc<T> handle is still linear — once you've dropped
or moved a given handle, that specific handle is gone and must not be used again.
Nothing stops you from calling .clone() first, though, to mint another independent
handle before doing so.
Ownership & Memory Safety
DSCS's ownership model is loosely inspired by Rust's, with one major simplification:
there are no references, only pointers. *T/*mut T are not tracked or
validity-checked by the compiler at all.
The rules that do apply:
- Passing a value by value (e.g.
fn unit(self) -> Self, not*self) moves it into the function. After the call, the caller's copy is gone — any pointers still pointing at it may now be dangling and must not be used. - Once a value has been dropped (via
defer, an explicit.drop()call, or a rawalloc.free(...)) or moved out of its original binding, you must not create new pointers to it, and any pointers created before that point are no longer valid to dereference. - There is no borrow checker and no lifetimes. The compiler does not verify any of the above for you — it trusts the programmer entirely. This is a deliberate simplicity/performance tradeoff relative to Rust: DSCX, the higher-level language built on top of DSCS, is where automatic safety is intended to live.
fn dangling_example(alloc: *mut Allocator) -> *u32 {
let boxed = Box::new(alloc, 5);
let p: *u32 = &*boxed; // p now points at boxed's memory
boxed.drop(alloc); // the memory is freed here
p // DANGLING: p now points at freed memory. DSCS will not stop you from
// returning or dereferencing it — this is undefined behaviour.
}
Error Handling
Like Rust, error handling is done with Result<T, E> and optionals with Option<T>.
fn parse_digit(c: u8) -> Result<u32, ParseError> {
if c >= '0' && c <= '9' {
Result::Ok((c - '0') as u32)
} else {
Result::Err(ParseError::NotADigit)
}
}
fn find(arr: [u32; 6], target: u32) -> Option<u32> {
let mut i: u32 = 0;
while i < 6 {
if arr[i] == target {
return Option::Some(i);
}
i += 1;
}
Option::None
}
An owned value in DSCS can never be null or none. Only a pointer can be null, and only once it's been explicitly cast:
let p: *u32 = *null; // must be explicit; there is no implicit null for owned values
Concurrency
DSCS has no threads and no language-level async/await. Concurrency is
cooperative and single-threaded, built from two small pieces — Poll<T> and Task
are ordinary library-level types, not compiler magic:
enum Poll<T> {
variant Ready(T);
variant Pending;
}
trait Task {
type Output;
fn poll(*mut self) -> Poll<Self::Output>;
}
struct Keyboard;
impl Task for Keyboard {
type Output = u8;
fn poll(*mut self) -> Poll<Self::Output> {
if KEYBOARD.has_byte() { Poll::Ready(KEYBOARD.read()) } else { Poll::Pending }
}
}
A scheduler is ordinary DSCS code that polls a task on a loop, halting the CPU (via
the hlt intrinsic — real hardware idling, not a busy spin) whenever there's nothing
left to do:
fn run_scheduler(mut task: Keyboard) {
loop {
match task.poll() {
Poll::Ready(byte) => { /* handle it, then reschedule or exit */ }
Poll::Pending => hlt(), // idle until the next interrupt wakes us
}
}
}
A scheduler juggling several different concrete task types at once needs the same
manual-vtable pattern from Runtime Polymorphism
— a struct pairing a Func<(*void), Poll<...>> with its own state pointer — since
DSCS has no dyn Trait to erase each task's concrete type automatically.
Interrupt Service Routines
An ISR should do as little as possible — typically just flip a flag that the
corresponding Task's poll() checks on its next pass. It must never run task logic
directly:
static UART_READY: bool = false;
fn uart_isr() {
UART_READY = true;
}
struct UartRead;
impl Task for UartRead {
type Output = u8;
fn poll(*mut self) -> Poll<u8> {
if UART_READY {
UART_READY = false;
Poll::Ready(uart_read_byte())
} else {
Poll::Pending
}
}
}
Because an ISR can preempt the scheduler at any instruction boundary — even on a
single-core machine like DSA — any state touched by both an ISR and ordinary code
(like UART_READY above) needs interrupts masked around the access if it isn't a
single atomic instruction, to avoid a torn read/write.