634 lines
20 KiB
Markdown
634 lines
20 KiB
Markdown
# 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](#runtime-polymorphism-without-dynamic-dispatch).
|
|
- **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](#ownership--memory-safety).
|
|
|
|
---
|
|
|
|
## Lexical Structure
|
|
|
|
```rust
|
|
// single line comment...
|
|
/* multi
|
|
line
|
|
comment */
|
|
```
|
|
|
|
---
|
|
|
|
## Variables, Constants & Statics
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
let arr: [u32; 6] = [0, 1, 2, 3, 4, 5];
|
|
// = [1; 6]; // alternative syntax for [1, 1, 1, 1, 1, 1]
|
|
```
|
|
|
|
### Pointers
|
|
|
|
```rust
|
|
// 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](#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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
if condition => do_y();
|
|
```
|
|
|
|
The same rule applies to `else`, and the two forms compose — a branch can use
|
|
whichever form fits it:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
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 Allocator` directly, with `Allocator` used as a bare trait type rather
|
|
> than an explicit generic bound. This is intentional, not an oversight — see
|
|
> [Bare Trait Names as Types](#bare-trait-names-as-types) below for the rule.
|
|
|
|
---
|
|
|
|
## Traits
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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)
|
|
|
|
```rust
|
|
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>`
|
|
|
|
```rust
|
|
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.
|
|
|
|
```rust
|
|
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 raw
|
|
`alloc.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.
|
|
|
|
```rust
|
|
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>`.
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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:
|
|
|
|
```rust
|
|
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](#runtime-polymorphism-without-dynamic-dispatch)
|
|
— 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:
|
|
|
|
```rust
|
|
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.
|