In this proof of concept, we will build a tiny interpreter for the ATtiny85 which executes instructions directly from EEPROM memory instead of FLASH. The goal is not to replace native firmware execution, but to experiment with compact instruction encoding and runtime programmability. Each instruction in this interpreter consists of only 2 bytes: one byte for the command ID and one byte for command arguments packed bit-by-bit.
Scope of this PoC
For this PoC, the interpreter supports three commands.
Set GPIO (
0x01) - sets a digital output pin to high or low. The first 3 bits of the arguments byte specify the pin number and the remaining 5 bits define the state.Delay (
0x02) - a non-blocking delay instruction that pauses EEPROM program execution without freezing the MCU itself. The arguments byte contains the delay duration in units of 100 milliseconds.-
Map ADC to PWM (
0x03) - reads an ADC channel and maps its value proportionally to a PWM output. The arguments byte contains:- first 3 bits - ADC source pin
- next 3 bits - PWM output pin
- next 1 bit - direct or reversed mapping
- last 1 bit - may be used for smoothing/averaging support in the future (now I skip it)
End of program (
0xFF) - marks the end of the EEPROM program and allows the interpreter to loop forever over the program.
The resulting EEPROM “program” looks similar to a compact instruction stream rather than traditional firmware, but because every operation occupies exactly and only two bytes, programs become very dense and easy to parse.
Required dependencies
Before starting, install the AVR toolchain and required utilities:
sudo apt install \
gcc-avr \
avr-libc \
binutils-avr \
avrdude \
cmake
The interpreter also requires several AVR-specific headers, definitions, state variables and helper structures:
#include <avr/eeprom.h>
#include <avr/interrupt.h>
#include <stdbool.h>
#define PIN_PB0 0
#define PIN_PB1 1
#define PIN_PB2 2
#define PIN_PB3 3
#define PIN_PB4 4
#define STATE_LOW 0
#define STATE_HIGH 1
#define DIRECT_MAPPING 0
#define REVERSED_MAPPING 1
#define CMD_SET_GPIO 0x01
#define CMD_DELAY 0x02
#define CMD_MAP_ADC_TO_PWM 0x03
#define PROGRAM_END_BYTE 0xFF
uint32_t last_timestamp = 0;
uint32_t delay_end_time = 0;
uint8_t instruction_pointer = 0;
bool delay_active = false;
bool pwm_1_enabled = false;
bool pwm_2_enabled = false;
static const uint8_t kPinToPortbMapping[] = {PB0, PB1, PB2, PB3, PB4};
static const uint8_t kPinToAdcChannelMapping[] = {0xFF, 0xFF, 1U, 3U, 2U};
typedef struct
{
uint8_t command_id;
uint8_t arguments;
} Instruction;
A few things are worth mentioning here:
-
Instructionrepresents a single EEPROM bytecode instruction. -
instruction_pointeracts similarly to a program counter in a CPU. -
last_timestampprovides a millisecond-style timing source. -
delay_activeanddelay_end_timewill be used for implementing non-blocking delays. - the pin mapping tables translate logical pins into AVR hardware-specific identifiers.
Writing EEPROM programs
Let's take a look at 2 example programs.
Example 1 - classic blink
Instruction EEMEM eeprom_program[] =
{
{CMD_SET_GPIO, (PIN_PB0 << 5) | STATE_LOW},
{CMD_DELAY, 10},
{CMD_SET_GPIO, (PIN_PB0 << 5) | STATE_HIGH},
{CMD_DELAY, 10},
{