DuckStack is a simple stack-based bytecode VM for executing compiled duckyScript binaries.
duckyPad uses it for HID macro scripting.
- 32-Bit Data Path, 16-bit Addressing.
- Shared Executable & Stack Region
- Variable-length Instructions
- Functions with Arguments, Locals, & Recursions.
- HID-specific Instructions
- Architecture Overview
- Instruction Set
- String Encoding
- Printing Variables
- Run-time Exceptions
- Calling Convention
- Standalone Compiler
duckStack uses 32-bit variables, arithmetics, and stack width.
Addressing is 16-bit, executable 64KB max.
- Single Data Stack
- Flat memory map
- Byte-addressed
- Program Counter (PC)
- 16-bit byte-addressed
- Stack Pointer (SP)
- 16-bit byte-addressed
- Points to the next free stack slot
- Frame Pointer (FP)
- Points to current function base frame
| Address | Purpose | Size | Comment | PEEK andPOKE-able |
|---|---|---|---|---|
0000EFFF |
Shared Executable and Stack |
61440 Bytes | See Notes Below | ✅ |
F000F3FF |
User-defined Global Variables |
1024 Bytes 4 Bytes/Entry 256 Entries |
ZI Data | ✅ |
F400F7FF |
Scratch Memory |
1024 Bytes | General-purpose | ✅ |
F800FBFF |
Reserved | 1024 Bytes | ❌ | |
FC00FDFF |
Persistent Global Variables |
512 Bytes 4 Bytes/Entry 128 Entries |
Non-volatile Data Saved on SD card |
✅ |
FE00FEFF |
VM Internal Variables |
256 Bytes 4 Bytes/Entry 64 Entries |
Read/Adjust VM Settings |
❌ |
FF00FFFF |
Memory- Mapped IO |
256 Bytes | ✅ |
- Binary executable is loaded at
0x0 - Stack grows from
0xEFFFtowards smaller address- Each item 4 bytes long
- In actual implementation, SP can be 4-byte aligned for better performance.
- Smaller executable allows larger stack, vise versa.
| Address | Purpose | Size | Comment | PEEK andPOKE-able |
|---|---|---|---|---|
0000DFFF |
Binary Executable | 57344 Bytes | ❌ | |
E000EFFF |
Data Stack | 4096 Bytes | Grows towards smaller address |
❌ |
F000F0FF |
User-defined Global Variables |
256 Bytes 4 Bytes/Entry 64 Entries |
ZI Data | ✅ |
.... |
Unused | ❌ | ||
F400F4FF |
Scratch Memory |
256 Bytes | General-purpose | ✅ |
.... |
Unused | ❌ | ||
FC00FC7F |
Persistent Global Variables |
128 Bytes 4 Bytes/Entry 32 Entries |
Non-volatile Data Saved on SD card |
✅ |
.... |
Unused | ❌ | ||
FE00FE7F |
VM Internal Variables |
128 Bytes 4 Bytes/Entry 32 Entries |
Read/Adjust VM Settings |
❌ |
.... |
Unused | ❌ |
- Similar to duckyPad Pro
- Just with less usable space due to limited RAM
Variable-length between 1 to 5 bytes.
- First byte (Byte 0): Opcode.
- Byte 1 - 4: Optional payload.
⚠️ All multi-byte payloads are Little-endian
- 1 stack item = 4 bytes
PUSHR/POPROffset is a byte-addressed signed 16-bit integer- Positive: Towards larger address / Base of Stack
- Negative: Towards smaller address / Top of Stack (TOS)
| Name | Inst. Size |
Opcode Byte 0 |
Comment | Payload Byte 1-4 |
|---|---|---|---|---|
NOP |
1 | 0/0x0 |
Do nothing | None |
PUSHC16 |
3 | 1/0x1 |
Push unsigned 16-bit (0-65535) constant on stack For negative numbers, push abs then use USUB. |
2 Bytes:CONST_LSBCONST_MSB |
PUSHI |
3 | 2/0x2 |
Read 4 Bytes at ADDRPush to stack as one 32-bit number |
2 Bytes:ADDR_LSBADDR_MSB |
PUSHR |
3 | 3/0x3 |
Read 4 Bytes at offset from FP Push to stack as one 32-bit number |
2 Bytes:OFFSET_LSBOFFSET_MSB |
POPI |
3 | 4/0x4 |
Pop one item off TOS Write 4 bytes to ADDR |
2 Bytes:ADDR_LSBADDR_MSB |
POPR |
3 | 5/0x5 |
Pop one item off TOS Write as 4 Bytes at offset from FP |
2 Bytes:OFFSET_LSBOFFSET_MSB |
BRZ |
3 | 6/0x6 |
Pop one item off TOS If value is zero, jump to ADDR |
2 Bytes:ADDR_LSBADDR_MSB |
JMP |
3 | 7/0x7 |
Unconditional Jump | 2 Bytes:ADDR_LSBADDR_MSB |
ALLOC |
3 | 8/0x8 |
Push n blank entries to stackUsed to allocate local variables on function entry |
2 Bytes:n_LSBn_MSB |
CALL |
3 | 9/0x9 |
Construct 32b value frame_info:Top 16b current_FP,Bottom 16b return_addr (PC+3).Push frame_info to TOSSet FP to TOS Jump to ADDR |
2 Bytes:ADDR_LSBADDR_MSB |
RET |
3 | 10/0xa |
return_value on TOSPop return_value into temp locationPop items until TOS is FPPop frame_info, restore FP and PC.Pop off ARG_COUNT itemsPush return_value back on TOSResumes execution at PC |
2 Bytes:ARG_COUNTReserved |
HALT |
1 | 11/0xb |
Stop execution | None |
PUSH0 |
1 | 12/0xc |
Push 0 to TOS |
None |
PUSH1 |
1 | 13/0xd |
Push 1 to TOS |
None |
DROP |
1 | 14/0xe |
Discard ONE item off TOS | None |
DUP |
1 | 15/0xf |
Duplicate the item on TOS | None |
RANDINT |
1 | 16/0x10 |
Pop TWO item off TOS First Upper, then Lower.Push a SIGNED random number inbetween (inclusive) on TOS |
None |
RANDUINT |
1 | 17/0x10 |
Pop TWO item off TOS First Upper, then Lower.Push an UNSIGNED random number inbetween (inclusive) on TOS |
None |
PUSHC32 |
5 | 18/0x11 |
Push 32-bit constant on stack | 4 BytesCONST_LSBCONST_B1CONST_B2CONST_MSB |
PUSHC8 |
2 | 19/0x12 |
Push unsigned 8-bit (0-255) constant on stack For negative numbers, push abs then use USUB. |
1 Byte |
VMVER |
3 | 255/0xff |
VM Version Check Abort if mismatch |
2 Bytes:VM_VERReserved |
- All single-byte instructions
- Pop ONE item off TOS as
ADDR- Then...
| Name | Opcode Byte 0 |
Comment |
|---|---|---|
PEEK8 |
24/0x18 |
Read ONE byte at ADDRPush on stack SIGN-extended |
PEEKU8 |
25/0x19 |
Read ONE byte at ADDRPush on stack ZERO-extended |
PEEK16 |
26/0x1a |
Read TWO bytes at ADDRPush on stack SIGN-extended |
PEEKU16 |
27/0x1b |
Read TWO bytes at ADDRPush on stack ZERO-extended |
PEEK32 |
28/0x1c |
Read FOUR bytes at ADDRPush on stack AS-IS |
- Pop TWO item off TOS
- First
ADDR, thenVAL - Then...
- First
| Name | Opcode Byte 0 |
Comment |
|---|---|---|
POKE8 |
29/0x1d |
Write low 8 bits of VAL to ADDR |
POKE16 |
30/0x1e |
Write low 16 bits of VAL to ADDR |
POKE32 |
31/0x1f |
Write VAL to ADDR as-is |
Binary as in involving two operands.
- All single-byte instructions
- Pop TWO items off TOS
- First item: Left-hand-side
- Second item: Right-hand-side
- Perform operation
- Push result back on TOS
| Name | Opcode Byte 0 |
Comment |
|---|---|---|
EQ |
32/0x20 |
Equal |
NOTEQ |
33/0x21 |
Not Equal |
LT |
34/0x22 |
SIGNED Less Than |
LTE |
35/0x23 |
SIGNED Less Than or Equal |
GT |
36/0x24 |
SIGNED Greater Than |
GTE |
37/0x25 |
SIGNED Greater Than or Equal |
ADD |
38/0x26 |
Add |
SUB |
39/0x27 |
Subtract |
MULT |
40/0x28 |
Multiply |
DIV |
41/0x29 |
SIGNED Integer Division |
MOD |
42/0x2a |
SIGNED Modulus |
POW |
43/0x2b |
Power of |
LSL |
44/0x2c |
Logical Shift Left |
ASR |
45/0x2d |
Arithmetic Shift Right (Sign-extend) |
BITOR |
46/0x2e |
Bitwise OR |
BITXOR |
47/0x2f |
Bitwise XOR |
BITAND |
48/0x30 |
Bitwise AND |
LOGIAND |
49/0x31 |
Logical AND |
LOGIOR |
50/0x32 |
Logical OR |
ULT |
51/0x33 |
UNSIGNED Less Than |
ULTE |
52/0x34 |
UNSIGNED Less Than or Equal |
UGT |
53/0x35 |
UNSIGNED Greater Than |
UGTE |
54/0x36 |
UNSIGNED Greater Than or Equal |
UDIV |
55/0x37 |
UNSIGNED Integer Division |
UMOD |
56/0x38 |
UNSIGNED Modulus |
LSR |
57/0x39 |
Logical Shift Right (Zero-extend) |
- All single-byte instructions
- Pop ONE items off TOS
- Perform operation
- Push result back on TOS
| Name | Opcode Byte 0 |
Comment |
|---|---|---|
BITINV |
60/0x3c |
Bitwise Invert |
LOGINOT |
61/0x3d |
Logical NOT |
USUB |
62/0x3e |
Unary Minus |
- All single-byte instructions
| Name | Opcode Byte 0 |
Comment |
|---|---|---|
DELAY |
64/0x40 |
Delay Pop ONE item Delay amount in milliseconds |
KDOWN |
65/0x41 |
Press Key Pop ONE item |MSB|B2|B1|LSB|Unused|Unused|KeyType|KeyCode| |
KUP |
66/0x42 |
Release Key Pop ONE item |MSB|B2|B1|LSB|Unused|Unused|KeyType|KeyCode| |
MSCL |
67/0x43 |
Mouse Scroll Pop TWO items First hline, then vlineScroll hline horizontally(Positive: RIGHT, Negative: LEFT)Scroll vline vertically(Positive: UP, Negative: DOWN) |
MMOV |
68/0x44 |
Mouse Move Pop TWO items: x then yx: Positive RIGHT, Negative LEFT.y: Positive UP, Negative DOWN. |
SWCF |
69/0x45 |
Switch Color Fill Pop THREE items Red, Green, BlueSet ALL LED color to the RGB value |
SWCC |
70/0x46 |
Switch Color Change Pop FOUR item N, Red, Green, BlueSet N-th switch to the RGB value If N is 0, set current switch. |
SWCR |
71/0x47 |
Switch Color Reset Pop ONE item If value is 0, reset color of current key If value is between 1 and 20, reset color of that key If value is 99, reset color of all keys. |
STR |
72/0x48 |
Type String Pop ONE item as ADDRPrint zero-terminated string at ADDR |
STRLN |
73/0x49 |
Type Line Pop ONE item as ADDRPrint zero-terminated string at ADDRPress ENTER at end |
OLED_CUSR |
74/0x4a |
OLED Set Cursor Pop TWO items: x then y |
OLED_PRNT |
75/0x4b |
OLED Print Pop TWO items: OPTIONS then ADDRPrint zero-terminated string at ADDR to OLEDOPTIONS Bit 0: If set, print center-aligned. |
OLED_UPDE |
76/0x4c |
OLED Update |
OLED_CLR |
77/0x4d |
OLED Clear |
OLED_REST |
78/0x4e |
OLED Restore |
OLED_LINE |
79/0x4f |
OLED Draw Line Pop FOUR items x1, y1, x2, y2Draw single-pixel line in-between |
OLED_RECT |
80/0x50 |
OLED Draw Rectangle Pop FIVE items opt, x1, y1, x2, y2Draw rectangle between two points optBit 0: Fill, Bit 1: Color |
OLED_CIRC |
81/0x51 |
OLED Draw Circle Pop FOUR items opt, radius, x, yDraw circle with radius at (x,y)optBit 0: Fill, Bit 1: Color |
BCLR |
82/0x52 |
Clear switch event queue |
SKIPP |
83/0x53 |
Skip Profile Pop ONE item as nIf n is positive, go to next profileIf n is negative, go to prev profile |
GOTOP |
84/0x54 |
Goto Profile Pop ONE item as ADDRRetrieve zero-terminated string at ADDRIf resolves into an integer nGo to nth profile.Otherwise jump to profile name |
SLEEP |
85/0x55 |
Sleep Put duckyPad to sleep Terminates execution |
RANDCHR |
86/0x56 |
Random Character Pop ONE item as bitmask. Bit 0: Letter Lowercase Bit 1: Letter Uppercase Bit 2: Digits Bit 3: Symbols Bit 8: Type via Keyboard Bit 9: OLED Print-at-cursor |
PUTS |
87/0x57 |
Print String Pop ONE item off TOS ------ Bit 0-15: ADDRBit 16-23: nBit 29: OLED Print-at-cursor Bit 30: OLED Print-Center-Aligned Bit 31: Type via Keyboard Print string starting from ADDR------ If n=0, print until zero-termination.Else, print max n chars (or until \0). |
HIDTX |
88/0x59 |
Pop ONE item off TOS as ADDRRead 9 bytes from ADDRConstruct & send raw HID message See HIDTX() in duckyScript doc |
The following commands involves user-provided strings:
STRING/STRINGLNOLED_PRINT/OLED_CPRINTGOTO_PROFILEPUTS()
Strings are zero-terminated and appended at the end of the binary executable.
The starting address of a string is pushed onto stack before calling one of those commands, who pops off the address and fetch the string there.
Identical strings are deduplicated and share the same address.
STRING Hello World!
STRINGLN Hello World!
OLED_PRINT Hi there!
3 PUSHC16 16 0x10 ;STRING Hello World!
6 STR ;STRING Hello World!
7 PUSHC16 16 0x10 ;STRINGLN Hello World!
10 STRLN ;STRINGLN Hello World!
11 PUSHC16 29 0x1d ;OLED_PRINT Hi there!
14 OLED_PRNT ;OLED_PRINT Hi there!
15 HALT
16 DATA: b'Hello World!\x00'
29 DATA: b'Hi there!\x00'
When printing a variable, its info is embedded into the string between two separator bytes.
0x1ffor Global Variables- Contains: Little-endian memory address
[0x1f][ADDR_LSB][ADDR_MSB][Format Specifiers][0x1f]
0x1efor Local variables & arguments inside functions- Contains: FP-Relative Offset
[0x1e][OFFSET_LSB][OFFSET_MSB][Format Specifiers][0x1e]
VAR foo = 255
STRING Count is: $foo%02x
3 PUSHC16 255 0xff ;VAR foo = 255
6 POPI 63488 0xf800 ;VAR foo = 255
9 PUSHC16 14 0xe ;STRING Count is: $foo%02x
12 STR ;STRING Count is: $foo%02x
13 HALT
14 DATA: b'Count is: \x1f\x00\xf8%02x\x1f\x00'
Exceptions such as Division-by-Zero, Stack Over/Underflow, etc, result in immediate termination of the VM execution.
- Multiple arguments, one return value.
- Supports nested and recursive calls
- TOS grows towards smaller address
Outside function calls, FP points to base of stack.
| ... | |
|---|---|
| ... | |
FP -> |
Base (EFFF) |
When calling a function: foo(a, b, c)
- Caller pushes 32-bit arguments right to left to stack
- Don't push if no args.
a |
|
b |
|
c |
|
| ... | |
FP -> |
Base (EFFF) |
Caller then executes CALL instruction, which:
- Constructs a 32b value
frame_info- Top 16b:
current_FP - Bottom 16b:
return_address
- Top 16b:
- Pushes
frame_infoto TOS - Sets FP to TOS
- Jumps to the function address
FP -> |
Prev_FP | Return_addr |
a |
|
b |
|
c |
|
| ... | |
Base (EFFF) |
Once in function, callee uses ALLOC n to make space for local variables.
To reference arguments and locals, FP + Byte_Offset is used.
- Negative offset towards smaller address / TOS / locals.
FP - 4points to first local, etc
- Positive offset towards larger address / base of stack / args.
FP + 4points to leftmost argument, etc
- Use
PUSHR + OffsetandPOPR + Offsetto read/write to args and locals.
| ... | |
FP - 8 |
localvar_2 |
FP - 4 |
localvar_1 |
FP -> |
Prev_FP | Return_addr |
FP + 4 |
a |
FP + 8 |
b |
FP + 12 |
c |
| ... | |
Base (EFFF) |
At end of a function, return_value is on TOS.
- If no explicit
RETURNstatement, 0 is returned.
return_value |
|
temp data |
|
FP - 8 |
localvar_2 |
FP - 4 |
localvar_1 |
FP -> |
Prev_FP | Return_addr |
FP + 4 |
a |
FP + 8 |
b |
FP + 12 |
c |
| ... | |
Base (EFFF) |
Callee executes RET n instruction, which:
- Pops off
return_valueinto temp location - Pop off items until
frame_infois on TOS- AKA
SP + 4 == FP
- AKA
- Pops off
frame_info- Loads
previous FPinto FP - Loads
return addressinto PC
- Loads
- Pops off
narguments - Pushes
return_valueback on TOS - Resumes execution at PC
- Return value now on TOS for caller to use
return_val |
|
| ... | |
FP -> |
Base (EFFF) |
Normally, duckyScript compilation is taken care of in the configurator.
But of course you can also try a standalone version below.
- Download / clone this repo
- Prepare a duckyScript source file
test.txt- Learn More: Writing duckyScript
- In
ds_compilerdirectory, run:
python3 ./dsvm_make_bytecode.py test.txt test.dsb
A minimal C-based VM is provided. Based on real duckyPad firmware, but uses placeholders for hardware commands.
In ds_c_vm folder, run python3 ./compile.py to compile the source. (Or write your own Makefile)
Run the VM: ./main test.dsb
Set PRINT_DEBUG to 1 in main.h for execution and stack trace.
Please feel free to open an issue, ask in the official duckyPad discord, or email dekuNukem@gmail.com!