A C runtime (C99/C11, no C++) for a custom interpreted language, using
.fgh source files. The project is split into two parts that share
the same underlying memory model:
- Self-describing memory (
mem.c/mem.h): every variable is a record written directly into a single buffer (memory), carrying its own metadata (scope, size, value length, method list) — no separate symbol table is needed to read it back. - Compile-time-offset frames (
frame.c/frame.h): an alternative/complementary model, meant for function calls, where a scope is a template of a size known at compile time, instantiated at runtime by moving two cursors (base/end) — no runtime symbol lookup.
Alongside these sits the source script analysis layer
(loader.c/loader.h + memory_parser.c/memory_parser.h), which
reads a .fgh file, splits it into logical lines, recognizes scopes
(functions, if, while, for, etc.) and the let declarations inside
them, and builds a variable table (name/size/rank/repetition) ready to
be used by the rest of the interpreter.
fegh.c holds a demo/test main(): it exercises the mem.c API by
hand, then loads and processes test.fgh through
loader.c/memory_parser.c.
| File | Content |
|---|---|
mem.h / mem.c |
Self-describing memory model: allocation, variable declaration/update, metadata reads, array/matrix indexing. |
frame.h / frame.c |
Compile-time-offset frame model: frame_enter/frame_exit, argument passing (push_argument/pull_argument), return values (reserve_return_slot/write_return/read_return). |
loader.h / loader.c |
Loads the .fgh file into logical lines (script[]) and recognizes scopes (scope_table[]: functions, if/else/while/for/during, macros, C blocks). |
memory_parser.h / memory_parser.c |
Parses let declarations found inside scopes into a variable table (var_table[]) with name, cell size, value, method, rank and repetition (array/matrix/tensor). |
sintax_keyword.h |
Syntax keywords and separators (let, !, if, else, esleif, #) and the array/matrix indexing formula. |
fegh.c |
Demo main(): exercises mem.c by hand, then loads and processes test.fgh. |
test.fgh |
Example script used by fegh.c. |
Every variable is written in sequence inside the single global buffer
memory (allocated by begin()), as a record made of fields whose
byte width is configurable (via the global variables byte_for_scope,
byte_for_dim, byte_for_vleng, byte_for_method_lenght, to be set
before calling begin()):
[scope?] [dim] [vleng] [value...] [method_lenght?] [method...]
- scope: which scope the variable belongs to (optional, present
only if
byte_for_scope > 0). - dim: absolute address of the first byte right after this record — lets you jump from one variable to the next without needing to know its content.
- vleng: how many bytes the
valueoccupies. - value: the actual payload,
vlengbytes. - method_lenght / method: optional, a method list attached to the variable.
initialize_variable() is the central function: it declares a new
variable (appended at the first free address, memory_cursor) or
updates an existing one (by passing use_address + the address). The
size of value and of method is fixed at the first declaration and
can never change afterward (subsequent
update_value_of_variable_from_address() calls must fit inside it).
The get_* functions read the various fields of a record starting
from its address, with no need for an external symbol table: the
address plus the byte_for_* constants are enough to correctly parse
the raw bytes.
resolve_array_index_from_normal_sintax() resolves the absolute
address of a cell inside an array/matrix/tensor, starting from the
base address of the data struct (Xa), the size of each axis
(repetition[]) and the requested indices, following the row-major
notation described in sintax_keyword.h:
:((Xa + o) + idx[i..rank] * repetition[i+1..rank] * $a)
where $a is the size in bytes of a single cell. No magic numbers:
rank, dimensions and stride are always read from metadata, never
hard-coded.
An alternative geared toward function calls: a scope is not a series
of dynamically allocated records, but a template of a size known
at compile time (scope_size), instantiated at runtime by moving two
cursors:
base→ start address of the active scopeend→ first free byte (stack pointer)
Every variable/argument inside the scope is therefore resolved at
compile time as base + offset; there is no runtime symbol lookup.
Isolation between frames is guaranteed statically (by the code
generator, not by a runtime check).
Typical calling convention:
size_t target_base = frame_prepare_call(scope_size_leggi);
push_argument(target_base, 0, 1, 'c');
push_argument(target_base, 1, 1, 'i');
/* ... */
size_t prev_base, prev_end;
frame_enter(target_base, scope_size_leggi, &prev_base, &prev_end);
/* callee scope body: pull_argument(offset, byte_lenght) */
frame_exit(prev_base, prev_end);The return value instead lives in the caller's frame (so it
survives the callee's frame_exit), resolved with
reserve_return_slot() before frame_enter, and written/read with
write_return()/read_return().
See the comments at the top of frame.h for the full explanation and
a complete example.
-
load_script(filename)reads the.fghfile character by character and splits it into logical lines insidescript[]:{and}always close off a line of their own,;and\nend the current statement. Every line is stripped of stray spaces/tabs (clean_line/remove_spaces) — spacing in the source is never reliable and must never be assumed by the rest of the parsing. -
build_scope_signatures()scansscript[]and, for every line that opens a scope ({), builds asign_of_scopeentry inscope_table[]: type (scope/function,if,else,esleif,while,during,for,#macro,Cblock), opening and closing line, and — if it's a function — its name and argument list. -
create_metadata_for_var_struct_in_a_scope(start, end)(memory_parser.c) scans a scope's lines looking forletdeclarations (both in the body and in function arguments) and passes them toreturn_dimension_in_byte_of_var_struct(), which tokenizes them on:and passes them toparse_let(), which finally appends them tovar_table[]as avar_data_struct(name, cell size, value, method, rank, repetition).
Format of a let declaration (see parse_let in memory_parser.c
for the exact token layout):
let name:cell_size:[repetition...]:method:value
Example (from test.fgh, a [3][3][2] matrix of 1-byte cells,
method=199, initial value 8 in every cell):
scope main() {
let matr :1:3:3:2:199:8;
}
- No C++, no magic numbers: rank, dimensions and stride are always read from metadata at runtime, never hard-coded in the code.
- Spacing in
.fghsource is never reliable: all parsing explicitly strips spaces before interpreting a line. - The size of a variable's
valueandmethodis fixed at first declaration and can never be resized afterward.
All of the engine's runtime errors (memory overflow, a value that
doesn't fit in the reserved space, out-of-bounds address,
out-of-memory, malformed .fgh declarations, an unopenable script
file, etc.) are now reported consistently on stderr (no longer mixed
in with normal output on stdout), in the format:
[ERROR] <file>:<line> in <function>(): <description> [+ any relevant values]
so you can immediately trace back to the exact point in the source
that raised the error just by reading the message — especially useful
since the program's normal output can be very verbose (e.g. during a
memory or var_table dump).
No dedicated build system is included in this file set; a plain gcc command is enough, e.g.:
gcc -o fegh fegh.c frame.c loader.c mem.c memory_parser.c
./feghfegh.c expects to find test.fgh in the current directory.
A small experimental memory model based on size-oriented storage instead of type-oriented storage.
The project explores how variables, scopes, arrays, and methods can be represented directly inside a raw byte buffer without relying on the host language type system.
The prototype is written in C, but the final goal is a small compiled language where the compiler builds the complete memory layout before execution.
The fundamental idea is:
A variable is not a type. A variable is a memory record with a known size.
The runtime only needs to know:
- where a record starts;
- how large it is;
- where its value bytes are located.
Everything else can be resolved before execution.
This repository currently contains a minimal C proof of concept.
It is not:
- a general purpose allocator;
- a C replacement;
- a production memory manager;
- a complete programming language.
The current implementation demonstrates:
- manual memory layout;
- variable records;
- automatic size calculation;
- fixed-size reassignment;
- scope metadata;
- method placeholders;
- sequential memory allocation.
The complete language design extends this prototype with:
- compile-time memory mapping;
- Memory Print generation;
- AST bytecode generation;
- resolved addresses;
- runtime scope instantiation.
Memory is represented as a single flat byte buffer:
uint8_t *memory;The buffer is allocated once:
begin(size);and every variable is stored sequentially inside it.
The allocator does not know whether a value is:
- an integer;
- a character;
- an object;
- an array;
- another structure.
It only knows:
how many bytes are required
Every variable is stored as a contiguous record:
[ scope ][ dim ][ value length ][ value ][ optional methods ]
Default layout:
[ scope (2B) ]
[ dim (2B) ]
[ value length (1B) ]
[ value (N bytes) ]
[ method length (2B) ]
[ method address ]
The scope field identifies the owner of the variable.
Example:
0 = global scope
1 = main scope
2 = function scope
The current prototype supports scope metadata, while the final compiler will generate scope identifiers automatically.
dim is the most important field.
It stores the address where the next record begins.
Example:
address 0
[ variable A ]
dim = 10
address 10
[ variable B ]
Because records are contiguous:
next address = current address + record size
dim can therefore be interpreted as:
- next record address;
- record end address;
- traversal offset;
- array stride.
This allows the memory buffer to behave like a linked structure without using real pointers.
The value length stores how many bytes belong to the actual value.
Example:
[ length = 2 ][ value ][ value ]
means the variable owns two bytes of data.
The size is fixed after declaration.
The value is stored directly as raw bytes.
Example:
511
requires:
11111111 00000001
and therefore occupies two bytes.
The memory system does not interpret these bytes.
Interpretation belongs to the language layer.
If the user does not specify the size of a variable, the minimum required size is calculated automatically.
Example:
5
requires:
1 byte
while:
511
requires:
2 bytes
The function responsible for this calculation determines the smallest number of bytes needed to represent the value.
Once created, a variable record cannot grow.
Example:
let a = 5
creates:
value size = 1 byte
Later:
a = 500
fails because:
500 requires 2 bytes
The allocator never:
- moves following variables;
- reallocates individual records;
- changes existing addresses.
This guarantees address stability.
The prototype uses a bump-pointer style allocator.
A cursor tracks the first free byte:
size_t memory_cursor;Creating a variable:
start = memory_cursor
write record
memory_cursor = record_end
No searching is required.
No fragmentation is created.
The initialization function supports two modes:
The record is appended:
start = memory_cursor
The record is overwritten:
start = provided address
The original allocation position is preserved.
This allows direct memory manipulation while keeping the layout stable.
Scopes are stored with a small header:
[ scope id ][ scope size ][ variables... ]
The prototype can automatically write:
- scope identifier;
- total scope length.
Example:
scope 1
size = 30 bytes
variable A
variable B
variable C
The final language uses this information as a scope template.
The final language changes the allocation model.
Variables are not created dynamically during execution.
Instead, the compiler performs:
The compiler calculates:
- every global variable;
- every scope;
- every variable offset;
- every record size;
- every default value;
- every method reference.
The result is saved as:
Memory Print
The source code is converted into bytecode.
All names are replaced by resolved addresses.
Example:
Source:
value = 5
becomes:
copy :(x+6) :5
The runtime never performs symbol lookup.
A scope signature is not an allocated memory block.
It is a template.
Entering a scope:
base = end
end += scope_size
Leaving:
end = previous_end
No variable allocation occurs.
No records are individually created.
The whole scope exists because its layout was already calculated.
Recursive calls work naturally.
A function has:
scope template
not a fixed address.
Each call receives a different runtime base:
call 1
base = 100
call 2
base = 150
Both use the same template but different memory regions.
The global scope is special.
It is:
- created once;
- never released;
- always available.
Local scopes are temporary stack-like regions.
Globals and locals are intended to live in separate address spaces so that scope destruction never affects permanent data.
Addresses are represented as:
x + y
where:
x= runtime scope base;y= compile-time offset.
Three operations exist:
| Syntax | Meaning |
|---|---|
x |
address |
'x |
raw byte |
:x |
structured value |
Example:
x -> record address
'x -> dim byte
:x -> stored value
Arrays reuse the same variable record.
There is no separate array type.
An array is:
one record
+
multiple equally sized value cells
Example:
5 cells
2 bytes each
means:
value length = 10 bytes
Known indexes are resolved at compile time.
Example:
array[5]
becomes:
base + fixed offset
Dynamic indexing uses:
array_base + index * stride
where:
- array base is known;
- index is read from memory;
- stride comes from the record dimension.
All elements must have identical structure.
This includes attached methods.
Variables may optionally contain references to methods.
Current prototype:
- supports the storage format;
- stores a method address;
- allows future object-like behavior.
The final compiler resolves methods at compile time.
A variable therefore becomes:
data
+
behavior reference
without requiring a traditional object system.
The memory format is not fixed.
Parameters such as:
byte_for_dim
byte_for_scope
byte_for_method_length
byte_for_value_lengthdefine the size of metadata fields.
Changing them changes the available address space without changing the model.
The final system aims for:
- compile-time memory planning;
- zero runtime symbol resolution;
- deterministic addresses;
- stack-like scope allocation;
- recursive functions;
- compact byte storage;
- pointer-free internal addressing;
- movable memory buffers.
Because addresses are offsets instead of real pointers, the underlying memory
buffer can grow using realloc() without invalidating stored addresses.
The prototype still lacks:
- parser;
- compiler;
- Memory Print generator;
- AST generator;
- virtual machine;
- symbol table;
- error propagation;
- bounds checking;
- multiple methods per variable;
- dynamic arrays.
These are future layers built on top of the memory model.
The project explores a simple question:
What if memory layout was decided before execution instead of during execution?
By reducing variables to:
metadata + bytes
and moving complexity into compilation, the runtime can become extremely small.
The goal is not to replace existing languages, but to experiment with a different foundation where memory itself is the primitive abstraction.