Skip to content

SamAmandes/PLC-Compiler-from-scratch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

PLC Compiler

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.


What it does

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.bin

The language

A 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.

Example

// 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;
}

Features

  • 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: // ...

EBNF grammar

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: orandxornot → 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.


Architecture

The compiler is a classic three-stage pipeline. Each stage is a clean interface, which also made each stage independently testable.

1. Lexer (Lexer.cs, Token.cs)

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 =.

2. Parser (Parser.cs, node classes)

A hand-written recursive-descent parser: every grammar rule maps to one parse method, and operator precedence falls out of the call hierarchy (ParseOrExprParseAndExprParseXorExpr → … → 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.

3. Code generator (CodeGenerator.cs, IVisitor.cs)

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.


Binary format (given)

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.)


Error handling

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

Building and running

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 file

The resulting .bin runs on the PLC-VM, which is not part of this repository.


Project structure

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.

About

My IPA (Individuelle Praktische Arbeit) for my apprenticeship: a simple compiler developed entirely from scratch.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages