A small compiler written entirely from scratch in C# (.NET 9), without parser generators or compiler libraries. It compiles a simple PLC-style control language into bytecode for a stack-based virtual machine (PLC-VM).
This project was developed as my IPA, the final practical exam project of a software development apprenticeship.
Note on what was given vs. built: The language definition (EBNF grammar) and the target VM with its instruction set (opcode table and binary format) were provided as part of the assignment. Everything else, meaning the lexer, parser, AST, semantic checks, and code generator, was designed and implemented from scratch.
The compiler takes a .txt source file written in the control language and produces a .bin file containing PLC-VM bytecode:
source.txt ──► Lexer ──► Parser ──► Code Generator ──► source.bin
(tokens) (AST) (PLC-VM bytecode)
dotnet run -- path/to/program.txt
# produces path/to/program.binA minimal imperative language for PLC-style logic: read digital/analog inputs, do arithmetic, comparisons and boolean logic, branch with if/else, and set or reset outputs.
// blink output 1 if input 3 is high and the analog value is large enough
var threshold;
threshold = 200;
if (get in 3 and get analog 0 > threshold) {
set out 1;
} else {
reset out 1;
}
- Variable declarations and assignments (
var x;,x = ...;) - Integer arithmetic:
+ - * /(incl. negative literals) - Comparisons:
== != < <= > >= - Boolean logic:
and,or,xor,not - Digital and analog input reads:
get in N,get analog N - Output control:
set out N;,reset out N; if (...) { ... } else { ... }with arbitrary nesting- Line comments:
// ...
The grammar below was given as part of the assignment and defines the language the compiler must accept. Operator precedence is encoded directly in the rule hierarchy (lowest first: or → and → xor → not → comparison → + - → * /).
Program = { Statement | Comment } ;
Statement = VarDecl | Assignment | IfStatement | OutputStatement ;
VarDecl = "var" Identifier ";" ;
Assignment = Identifier "=" Expression ";" ;
IfStatement = "if" "(" Expression ")" Block [ "else" Block ] ;
OutputStatement = ( "set" | "reset" ) OutputRef ";" ;
Block = "{" { Statement | Comment } "}" ;
Expression = OrExpr ;
OrExpr = AndExpr { "or" AndExpr } ;
AndExpr = XorExpr { "and" XorExpr } ;
XorExpr = NotExpr { "xor" NotExpr } ;
NotExpr = [ "not" ] RelExpr ;
RelExpr = AddExpr [ RelOp AddExpr ] ;
RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=" ;
AddExpr = MulExpr { ( "+" | "-" ) MulExpr } ;
MulExpr = Primary { ( "*" | "/" ) Primary } ;
Primary = Number | Identifier | InputRef | "(" Expression ")" ;
InputRef = "get" ( "in" Number | "analog" Number ) ;
OutputRef = "out" Number ;
Identifier = Letter { Letter | Digit | "_" } ;
Number = [ "-" ] Digit { Digit } ;
Comment = "//" { any printable character } ;One deliberate deviation: the grammar defines Number = ["-"] Digit {Digit}, but the lexer only emits unsigned number tokens. The unary minus is resolved in the parser (ParsePrimary), since the lexer has no context to distinguish unary from binary minus.
The compiler is a classic three-stage pipeline. Each stage is a clean interface, which also made each stage independently testable.
Reads the source character by character and produces a flat List<Token>. Each token carries its TokenType, an optional Value (only for identifiers and numbers), and its source Line for error messages. Whitespace and // comments are discarded and never become tokens. Keywords are distinguished from identifiers by a lookup after identifier recognition.
The lexer only throws errors where the parser structurally can't help: unknown characters, and a lone ! not followed by =.
A hand-written recursive-descent parser: every grammar rule maps to one parse method, and operator precedence falls out of the call hierarchy (ParseOrExpr → ParseAndExpr → ParseXorExpr → … → ParsePrimary) rather than explicit precedence tables.
The output is an AST built from a small node hierarchy:
Node
├── StatementNode
│ ├── VarDeclNode var x;
│ ├── AssignmentNode x = expr;
│ ├── IfStatementNode condition + then/else statement lists
│ └── OutputStatementNode set/reset out N
└── ExprNode
├── BinaryExprNode arithmetic / logic / comparison
├── UnaryExprNode not
├── InputRefNode get in N / get analog N
├── NumberNode
└── IdentifierNode
Purely syntactic artifacts (parentheses, semicolons, braces) don't appear in the AST. Blocks are intentionally not their own node type: a block is just a List<StatementNode>, which keeps traversal in the code generator simple.
Implements the visitor pattern and traverses the AST post-order (operands before operators, exactly the order a stack machine needs), emitting PLC-VM opcodes into a byte buffer.
It also performs the semantic checks the parser can't: use of undeclared variables and duplicate declarations raise a SemanticException before any faulty binary can be written.
if/else is compiled with backpatching: JUMP_IF_ZERO and JUMP are emitted with placeholder operands, and the real jump targets (instruction numbers) are written back once the block lengths are known. Label IDs for nested constructs are allocated outer-before-inner so the output matches the VM's reference binaries.
The target format was provided as part of the assignment: most instructions are 3 bytes (1-byte opcode + 2-byte little-endian operand); arithmetic, logic and comparison instructions are 1 byte with no operand. Every program ends with HLT (0xFF). The VM numbers instructions starting at 1, and jump operands are instruction numbers, not byte offsets.
| Instruction | Opcode | Size |
|---|---|---|
PUSH_CONST |
0x01 |
3 bytes |
PUSH_VAR |
0x02 |
3 bytes |
POP_TO_VAR |
0x03 |
3 bytes |
PUSH_IN |
0x10 |
3 bytes |
PUSH_ANALOG |
0x11 |
3 bytes |
SET_OUT |
0x12 |
3 bytes |
RESET_OUT |
0x13 |
3 bytes |
ADD / SUB / MUL / DIV |
0x20-0x23 |
1 byte |
EQ / NEQ / LT / LE / GT / GE |
0x30-0x35 |
1 byte |
AND / OR / XOR / NOT |
0x40-0x43 |
1 byte |
LABEL |
0x50 |
3 bytes |
JUMP |
0x51 |
3 bytes |
JUMP_IF_ZERO |
0x52 |
3 bytes |
HLT |
0xFF |
1 byte |
(JUMP_IF_NOT_ZERO 0x53 exists in the instruction set but is intentionally unused: JUMP_IF_ZERO plus an unconditional JUMP covers every branching pattern needed.)
Every stage reports errors with a type, a description, and where possible a line number, and all exceptions are caught centrally in Program.cs so the user only ever sees a clean message:
| Exception | Stage | Example cause |
|---|---|---|
CompilerInputException |
Input | missing file, wrong extension, empty file, no read permission |
LexerException |
Lexer | unknown character, lone ! |
ParseException |
Parser | unexpected token, missing ; or }, with line number |
SemanticException |
Code generator | undeclared variable, duplicate declaration |
Requires the .NET 9 SDK.
Platform note: This project was developed and tested on Windows only. It contains no platform-specific code and should build on Linux and macOS via the .NET SDK, but this has never been tested.
dotnet build
dotnet run -- program.txt # writes program.bin next to the source fileThe resulting .bin runs on the PLC-VM, which is not part of this repository.
Program.cs Entry point, file validation, central error handling,
plus debug helpers (token dump, AST pretty-printer, hex dump)
Lexer.cs Character scanner → token list
Token.cs Token data class + TokenType enum
Parser.cs Recursive-descent parser → AST
Node.cs Abstract AST base class
BaseNodes.cs StatementNode / ExprNode base classes
StatementNodes.cs VarDecl, Assignment, IfStatement, OutputStatement nodes
ExpressionNodes.cs Binary, Unary, InputRef, Number, Identifier nodes
ProgramNode.cs AST root
IVisitor.cs Visitor interface
CodeGenerator.cs Visitor implementation → PLC-VM bytecode
CompilerExceptions.cs Custom exception types
The full written project documentation (requirements analysis, risk analysis, test protocols, work journal) is part of the IPA submission and is not included in this repository.