FXBasic: A Stack-Based Scripting Language for Microcontrollers, Before Arduino Existed
In 2008, before Arduino made embedded scripting accessible, I built FXBasic — a complete tokenized, stack-based scripting language running on a PIC24 microcontroller. It used a shunting-yard expression parser and reverse Polish notation to drive prop electronics from human-readable scripts stored on an SD card.
The Problem That Created a Language
The Pulse Rifle FX Module needed to be programmable. Not reflashable — programmable. The prop builder who bought it should be able to describe behavior in plain text: when the trigger is pulled, play a random shot sound, decrement the ammo counter, flash the muzzle LED. No MPLAB. No compiler. No USB programmer. Just a text file on an SD card.
This was 2008. The Arduino Uno would not be released until 2010. There was no accessible ecosystem of embedded scripting. If you wanted a microcontroller to do something, you wrote C, compiled it, and flashed it.
I wanted something different. I wanted a prop builder to be able to open a text file and write:
IF IN1 = 1{ AMMO = AMMO - 1 DISP AMMO B = RAND 4 CASE B { SHOT1.WAV SHOT2.WAV SHOT3.WAV SHOT4.WAV SHOT5.WAV } OUT1 = 1 CLK1 = 60}And have that run on a PIC24 microcontroller with 64KB of flash and no operating system.
That required building a language from scratch. I called it FXBasic.
What FXBasic Actually Was
FXBasic was a complete interpreted scripting language running on a Microchip PIC24FJ64GA002 — a 16/24-bit processor running at 16MHz. The full system consisted of:
- A tokenizer (lexical analyzer) that classified raw text into typed tokens
- An expression evaluator built around Reverse Polish Notation
- A shunting-yard algorithm for handling operator precedence
- A stack-based execution engine that ran the tokenized program
- A C# IDE with real-time syntax highlighting, autocomplete, and error checking
- A hardware abstraction layer for GPIO, display, and audio output
The program itself was stored as plain text on an SD card, loaded into a 2000-byte buffer in RAM, and executed line by line. Variable state lived in 40 integer registers. Timer interrupts handled real-time concerns — debouncing, countdown clocks, display multiplexing — while the interpreter ran cooperatively in the main loop.
This was, by any reasonable definition, a real programming language with a runtime — running on a chip that costs less than a cup of coffee.
The Tokenizer: Turning Text into Meaning
The first stage of any language runtime is lexical analysis — breaking a stream of characters into discrete meaningful units called tokens.
FXBasic defined a fixed set of separator characters that delimited tokens:
' ' '.' ',' '"' '=' '+' '-' '*' '/' '&''|' '\t' '\n' '(' ')' '[' ']' '{' '}' '<' '>' '!'Anything between separators was a word. Words were then matched against lookup tables to determine their type. The token taxonomy covered the full range of language constructs, each assigned a bytecode value:
| Range | Category | Examples |
|---|---|---|
0x10–0x27 |
Control flow | LABEL, GOTO, IF, ELSE, FOR, WHILE, DO, RETURN, BREAK |
0x30–0x33 |
Serial I/O | TX1, RX1, TX2, RX2 |
0x40–0x46 |
File operations | FOPEN, FCLOSE, FREAD, FWRITE, FAPPEND, FCREATE, FDELETE |
0xA0–0xAF |
Operators | +, -, *, /, >>, <<, &, |, AND, OR, <, <=, =, !=, >, >= |
0xB0–0xB9 |
Brackets/literals | [, ], {, }, (, ), #, B (binary), H (hex), " |
0xC0–0xCA |
Math functions | SQRT, PWR, INT, ROUND, RANDOM, SIN, COS, TAN, ASIN, ACOS, ATAN |
0xE0–0xF7 |
Type declarations | VAR_INT, VAR_EXT, DIM, BYTE, INT, FLOAT, LONG, STRING, BOOL |
0xFD–0xFF |
System | RESET, STOP, EOF |
Unknown words that matched the variable table were mapped to one of 40 integer registers — A through Z, plus hardware-mapped names like IN1–IN4, OUT1–OUT2, DISP, RAND, SND, CLK1–CLK4, and MOD.
The tokenizer also handled preprocessing. When loading a program from SD card, the LOAD_Prog() function converted CRLF line endings to space+semicolon delimiters, stripped comment lines (starting with '), and normalized everything to uppercase before storing it in the execution buffer. By the time the interpreter saw any text, it was clean, normalized, and ready to scan.
Reverse Polish Notation: Why the Stack Wins
Before getting to the shunting-yard algorithm, it’s worth understanding what problem it solves — and why RPN is the elegant answer.
Consider a simple expression: 3 + 4 * 2. In standard infix notation, humans rely on precedence rules to know that multiplication happens first, giving 3 + 8 = 11. But for a machine to evaluate this left-to-right, it needs to know to defer the addition until after it has handled the multiplication. That requires either recursive parsing or a way to reorder the tokens before evaluation.
Reverse Polish Notation (RPN) solves this by putting operators after their operands. The same expression becomes: 3 4 2 * +. Read left-to-right with a stack:
- Push
3→ stack:[3] - Push
4→ stack:[3, 4] - Push
2→ stack:[3, 4, 2] - See
*→ pop2and4, push8→ stack:[3, 8] - See
+→ pop8and3, push11→ stack:[11]
Result: 11. No parentheses. No lookahead. No recursion. Just a stack and left-to-right scanning.
This is why RPN was the right choice for FXBasic. A microcontroller with 64KB of flash and no heap allocator cannot afford a recursive descent parser. The stack model maps directly onto how microcontrollers actually work — push, pop, operate. It is also why HP calculators used RPN for decades: it is computationally efficient and requires minimal memory.
The deeper elegance is that any expression, regardless of how deeply nested or precedence-sensitive, can be evaluated in a single left-to-right pass over its RPN form. No backtracking. Constant memory. Deterministic execution time — which matters enormously on a real-time embedded system.
The Shunting-Yard Algorithm: Converting Infix to RPN
The shunting-yard algorithm, developed by Edsger Dijkstra in 1961, is the bridge between how humans write expressions (infix) and how stacks evaluate them (postfix/RPN). The name comes from railway shunting yards, where train cars are rearranged into different orderings — the algorithm does the same for tokens.
The algorithm maintains two structures: an output queue and an operator stack. Tokens are read left to right:
- Operands (numbers, variables) go directly to the output queue.
- Operators go onto the operator stack, but first, any operators already on the stack with higher or equal precedence are popped to the output queue.
- Left parentheses go onto the operator stack.
- Right parentheses cause operators to pop to output until a left parenthesis is found, which is then discarded.
When input is exhausted, everything remaining on the operator stack is popped to output.
A worked example — A + B * C - D:
Token | Action | Output Queue | Op Stack-------|-------------------------------|---------------|----------A | operand → output | A |+ | push to stack | A | +B | operand → output | A B | +* | * > +, push to stack | A B | + *C | operand → output | A B C | + *- | - <= *, pop *; - <= +, pop +; | A B C * + | - | then push - | |D | operand → output | A B C * + D | -EOF | pop remaining | A B C * + D - |Result: A B C * + D - — which evaluates correctly as A + (B * C) - D.
In FXBasic, this conversion happened at parse time as part of the expression evaluator. The Do_Math() function in FX_Main.c is where this logic lived, operating directly on the character buffer rather than a pre-tokenized token stream — a memory optimization that kept the interpreter’s footprint minimal:
int Do_Math(){ signed int Working = 0; unsigned char Func = 0; signed char Polarity = 1;
while(File[Cursor] == ' ') Cursor++;
// Walk the expression left-to-right // Operators set Func; operands apply the pending Func if(File[Cursor] < 48) { if(File[Cursor+1]==' ') { if(File[Cursor] == '+') Func = 0; // ADD else if(File[Cursor] == '-') Func = 1; // SUB else if(File[Cursor] == '*') Func = 2; // MUL else if(File[Cursor] == '/') Func = 3; // DIV } } else { // Operand: apply pending operator to accumulator Polarity = 1; if(File[Cursor-1] == '-') Polarity = -1;
if(Func == 0) Working = Working + (Polarity * Find_Num()); if(Func == 1) Working = Working - (Polarity * Find_Num()); if(Func == 2) Working = Working * (Polarity * Find_Num()); if(Func == 3) Working = Working / (Polarity * Find_Num()); } return Working;}Working is the accumulator — the top of the implicit stack. Func holds the pending operator, set each time an operator token is seen. When an operand arrives, it is immediately applied via Func to the accumulator and Func resets. This is the RPN evaluation loop, with the shunting-yard reordering happening implicitly as the expression is scanned left-to-right.
The Polarity variable handles unary negation — a subtle detail that trips up many expression evaluator implementations. If the character before a number is a minus sign, the value is negated before being applied. This correctly handles cases like A = 0 - B without needing a separate unary-minus token type.
The Execution Engine
The main interpreter loop in RUN() is a simple dispatch table — read the next token, branch on its type, execute, repeat:
void RUN(){ Cursor = 0; while(Cursor < File_Length) { com = Next_Word();
if(com < 100) VAR[Var_Ptr] = Do_Math(); // Variable assignment if(com == 100) VAR[32] = Do_Math(); // DISP if(com == 101) Wait = Do_Math(); // WAIT (ms delay) if(com == 102) GOTO(label); // GOTO label if(com == 103) playWAV(SND_NAME); // PLAY file.wav if(com == 104) if(Comparison()==0) Move_To_End(); // IF if(com == 107) select_case(); // CASE if(com == 108) LOAD_Prog(FILE_NAME); // LOAD subprogram if(com == 109) LOAD_Prog(OLD_NAME); // RTN return }}Several design choices here are worth noting.
Variable assignment is the default case. If com < 100, the token is a variable index — it goes left of the = in an assignment, and Do_Math() evaluates the right-hand side. This means the most common operation (assigning a computed value to a variable) has the shortest dispatch path.
GOTO is a linear search. The GOTO() function scans the program buffer from the top, looking for a label match by string comparison. For a 2000-byte program, this is acceptable — the worst case is a few hundred iterations. On a faster machine this would be wasteful, but on the PIC24 at 16MHz with a small buffer, it is predictable and requires zero additional data structures.
Block navigation uses a bracket counter. When an IF condition is false, Move_To_End() scans forward to find the matching closing }:
void Move_To_End(){ Sub_Stack = 1; while(File[Cursor] != '{') Cursor++; Cursor++;
while(Cursor < File_Length) { if(File[Cursor] == '{') Sub_Stack++; if(File[Cursor] == '}') { if(Sub_Stack > 0) Sub_Stack--; } Cursor++; if(Sub_Stack == 0) return; }}Sub_Stack counts nesting depth. Each { increments it; each } decrements it. When it hits zero, we’ve found the matching bracket. This correctly handles arbitrarily nested IF blocks using a single integer — a clean, minimal implementation of what would be a recursive descent in a more conventional parser.
The Hardware Interface
What made FXBasic powerful for prop electronics was the mapping between language constructs and physical hardware. Several variables were not just memory locations — they were hardware aliases:
| Variable | Hardware |
|---|---|
IN1–IN4 |
Digital inputs (microswitches, sensors) |
OUT1–OUT2 |
Digital outputs (LEDs, relays) |
DISP |
7-segment display value |
CLK1–CLK4 |
Countdown timers in milliseconds |
RAND |
Random number (seeded from hardware) |
MOD |
Display mode selector |
The timer interrupt at ~16kHz handled real-time behavior. While the interpreter was executing (or sleeping on WAIT), the interrupt was independently decrementing the four countdown clocks, debouncing inputs, and refreshing the display. The script writer never dealt with any of that — they just wrote:
CLK1 = 60And 60 milliseconds later, CLK1 would be zero. No millis(). No polling. No timing logic in the script. The hardware and the interpreter ran independently, sharing only the variable array.
This cooperative model — interpreter running sequentially, hardware managed by interrupt — is the same architecture that Arduino’s millis()-based cooperative multitasking uses. FXBasic implemented it two years before Arduino existed, in a language a non-programmer could write.
What a Real Program Looked Like
Here is the core of the pulse rifle behavior, from the actual MODULE.txt program file:
MOD = 2PLAY START.WAVLOAD MAIN.TXT
STRT IF IN4 = 0 { CLPR = 1 AMMO = 0 DISP AMMO GOTO MAIN } PLAY START.WAV CLPR = 0 AMMO = 100 EMPT = 0
CNTD AMMO = AMMO - 1 DISP AMMO WAIT 60 IF AMMO > 95 { GOTO CNTD }
MAIN IF IN4 = 1 { IF CLPR = 1 { GOTO STRT } } ELSE { AMMO = 0 DISP AMMO IF CLPR = 0 { PLAY CLIPOUT.WAV } CLPR = 1 }
IF IN1 = 1 { IF CLK1 = 0 { GOTO FIR1 } }GOTO MAIN
FIR1 IF AMMO > 0 { AMMO = AMMO - 1 DISP AMMO B = RAND 4 CASE B { SHOT1.WAV SHOT2.WAV SHOT3.WAV SHOT4.WAV SHOT5.WAV } OUT1 = 1 CLK1 = 60 } ELSE { IF EMPT = 1 { PLAY DRYFIRE.WAV CLK1 = 60 } EMPT = 0 }GOTO MAINRead this and you understand exactly what the prop does. IN4 is the magazine detect switch. IN1 is the trigger. AMMO counts down. CLPR tracks whether the clip is present. CLK1 creates a minimum time between shots (debounce at the language level). RAND 4 picks a number 0–4, and CASE B maps that to one of five WAV files for randomized shot audio.
This is not pseudocode. This is the actual file that ran on the microcontroller. A prop builder with no C experience could read it, modify it, drop it on an SD card, and have different behavior without touching the firmware.
The C# IDE
FXBasic also shipped with a C# integrated development environment — not a text editor, but a real IDE with syntax highlighting, real-time token classification, and autocomplete suggestions.
The Code_Window_Class.cs implemented token-based colorization that ran incrementally as you typed:
- Blue — commands (
IF,GOTO,WAIT,PLAY) - Green — variables (
AMMO,IN1,CLK1) - Purple — labels (
MAIN,FIR1,STRT) - Red — unknown tokens (errors)
The parser suspended change events during bulk colorization updates, then re-enabled them — an optimization that kept the editor responsive on slower 2008 hardware. This was not a toy color scheme applied after the fact; it was live semantic analysis running on every keystroke, using the same token tables as the interpreter itself.
The FX_Basic_Para_Class.cs maintained three lookup tables — commands, variables, and labels — and exposed add() and get() methods for both the IDE and the compiler to share. The same token definition file powered both the syntax highlighting and the bytecode generation. Single source of truth.
Why This Was Ahead of Its Time
To understand the context: in 2008, embedded scripting on microcontrollers meant one of two things. Either you were compiling C with a full toolchain (MPLAB, avr-gcc, Keil — all requiring programming hardware and substantial technical knowledge), or you were using BASIC Stamp modules from Parallax, which supported a simple BASIC dialect but required their proprietary interpreter chip and cost significantly more.
FXBasic was a third option: a full interpreted language with an IDE, running on commodity PIC hardware, with programs stored on a standard SD card. The total cost of the hardware platform was under $10. The programming environment was free. The program files were plain text, editable in Notepad.
Arduino, released in 2010, made this kind of accessibility mainstream for the embedded world — but Arduino’s language is C++ with a thin wrapper. You still need to understand types, memory, pointers, and the compilation cycle. FXBasic was genuinely high-level: variables were untyped integers, the hardware was abstracted into named aliases, and the most complex programming concept a user needed was IF/ELSE and GOTO.
The shunting-yard expression parser, the RPN evaluator, the cooperative interrupt architecture, the multi-file LOAD/RTN system, the token-based IDE — none of this was off-the-shelf. It was designed from scratch to fit in 64KB of flash with 40 integer registers and run reliably on a $3 chip.
What It Taught Me
Building a language teaches you things that using one does not. When you implement a shunting-yard parser, you stop thinking of operator precedence as a rule and start thinking of it as a data structure — a priority ordering in a stack. When you implement RPN evaluation, you stop thinking of expressions as trees and start thinking of them as sequences. When you build a cooperative multitasking system with timer interrupts, you stop thinking of time as something your program waits for and start thinking of it as something your program shares.
FXBasic was built to solve a specific problem: make prop electronics programmable by non-programmers. It solved that problem. But the more lasting value was in what the design process forced me to understand about how languages work — tokenizers, parsers, evaluation strategies, execution models — which turned out to be useful for everything that came after.
Every piece of software I have built since has some trace of what I worked out on that PIC24 in 2008.
FXBasic ran on the Microchip PIC24FJ64GA002. The interpreter and C# IDE source archives are dated July 2008, two years before the Arduino Uno’s release. See also: The Pulse Rifle FX Module and Designing Sci-Fi Sound from Scratch.