Storing your ATTiny program in...EEPROM?
Storing your ATTiny program in...EEPROM?
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 },
{ PROGRAM_END_BYTE , PROGRAM_END_BYTE }
};
Example 2 - map 2 ADCs to 2 PWMs
Instruction EEMEM eeprom_program [] = {
{ CMD_MAP_ADC_TO_PWM , ( PIN_PB4 << 5 ) | ( PIN_PB0 << 2 ) | ( DIRECT_MAPPING << 1 )},
{ CMD_MAP_ADC_TO_PWM , ( PIN_PB3 << 5 ) | ( PIN_PB1 << 2 ) | ( REVERSED_MAPPING << 1 )},
{ PROGRAM_END_BYTE , PROGRAM_END_BYTE }
};
How does it work?
The key part is that eeprom_program is not a normal RAM array. It is an EEPROM-resident variable because of the EEMEM attribute:
Instruction EEMEM eeprom_program [] = { ... };
EEMEM tells the AVR compiler and linker "Place this variable inside EEPROM memory instead of RAM or FLASH.", so even though it looks like a normal array in C, it actually represents an EEPROM address.
Microcontroller setup
The interpreter requires timer interrupts, ADC initialization, GPIO helpers and PWM support. The following code sets up all required peripherals:
ISR ( TIMER0_COMPA_vect ) {
last_timestamp ++ ;
}
void InitTimer0 () {
TCCR0A = ( 1 << WGM01 );
OCR0A = 124 ;
TCCR0B = ( 1 << CS01 ) | ( 1 << CS00 );
TIMSK |= ( 1 << OCIE0A );
}
void InitAdc ( void ) {
ADMUX = 0 ;
ADCSRA = ( 1 << ADEN ) | ( 1 << ADPS2 ) | ( 1 << ADPS1 );
}
void InitPwm ( uint8_t pin ) {
DDRB |= ( 1 << pin );
TCCR0A = ( 1 << WGM00 ) | ( 1 << WGM01 );
TCCR0B = ( 1 << CS00 );
if ( pin == PIN_PB0 && ! pwm_1_enabled ) {
TCCR0A |= ( 1 << COM0A1 );
} else if ( pin == PIN_PB1 && ! pwm_2_enabled ) {
TCCR0A |= ( 1 << COM0B1 );
}
}
void GpioWrite ( uint8_t pin , uint8_t state ) {
uint8_t hw = kPinToPortbMapping [ pin ];
DDRB |= ( 1 << hw );
if ( state ) PORTB |= ( 1 << hw );
else PORTB &= ~ ( 1 << hw );
}
uint8_t GpioRead ( uint8_t pin ) {
uint8_t hw = kPinToPortbMapping [ pin ];
DDRB &= ~ ( 1 << hw );
return ( PINB & ( 1 << hw )) ? 1 : 0 ;
}
uint16_t AdcRead ( uint8_t pin ) {
uint8_t adc_channel = kPinToAdcChannelMapping [ pin ];
if ( adc_channel == 0xFF ) {
return 0 ;
}
ADMUX = ( ADMUX & 0xF0 ) | adc_channel ;
ADCSRA |= ( 1 << ADSC );
while ( ADCSRA & ( 1 << ADSC ));
return ADC ;
}
void PwmWrite ( uint8_t pin , uint8_t value ) {
switch ( pin ) {
case PIN_PB0 : OCR0A = value ; break ;
case PIN_PB1 : OCR0B = value ; break ;
default: break ;
}
}
Timer interrupt - The timer compare interrupt ISR(TIMER0_COMPA_vect) increments a global timestamp counter periodically. This provides a lightweight software clock used for delays.
Timer initialization - void InitTimer0() configures Timer0 into CTC mode and generates periodic compare-match interrupts. The prescaler and compare register values determine the interrupt frequency.
ADC initialization - void InitAdc(void) enables the ADC peripheral and configures the ADC clock prescaler.
PWM initialization - void InitPwm(uint8_t pin) configures Timer0 for fast PWM generation and enables the selected PWM output channel.
GPIO helpers - GpioWrite() configures a pin as output and writes HIGH or LOW. GpioRead() configures a pin as input and returns its digital state.
ADC reads - AdcRead() translates logical pins into ADC channels, starts a conversion and waits until conversion completes.
PWM output - PwmWrite() updates the PWM duty cycle register corresponding to the selected PWM output pin.
AI is powerful. Snippets are instant. Stop prompting for the same patterns repeatedly. Get almost 100 free VS Code snippets for C++, Python, CMake and Bazel from piko::snippets GitHub repository.
Executing instructions from EEPROM
The core of the interpreter is the instruction execution routine:
void ExecuteInstruction ( Instruction * instruction ) {
if ( instruction -> command_id == 0xFF && instruction -> arguments == 0xFF ) {
instruction_pointer = 0 ;
return ;
}
switch ( instruction -> command_id ) {
case CMD_SET_GPIO : {
uint8_t pin = ( instruction -> arguments >> 5 ) & 0x07 ;
uint8_t state = ( instruction -> arguments & 0x1F ) ? 1 : 0 ;
GpioWrite ( pin , state );
instruction_pointer ++ ;
break ;
}
case CMD_DELAY : {
uint32_t delay_ms = (( uint32_t ) instruction -> arguments ) * 100UL ;
delay_active = true ;
delay_end_time = last_timestamp + delay_ms ;
instruction_pointer ++ ;
break ;
}
case CMD_MAP_ADC_TO_PWM : {
uint8_t adc_pin = ( instruction -> arguments >> 5 ) & 0x07 ;
uint8_t pwm_pin = ( instruction -> arguments >> 2 ) & 0x07 ;
bool reversed_mapping = ( bool )(( instruction -> arguments >> 1 ) & 0x01 );
InitPwm ( pwm_pin );
uint16_t adc_value = AdcRead ( adc_pin );
uint8_t pwm_value = adc_value >> 2 ;
if ( reversed_mapping ) {
pwm_value = 255 - pwm_value ;
}
PwmWrite ( pwm_pin , pwm_value );
instruction_pointer ++ ;
break ;
}
default: {
instruction_pointer ++ ;
break ;
}
}
}
The first thing the interpreter checks is whether the instruction equals 0xFF 0xFF which acts as an end-of-program marker. When encountered, execution jumps back to the beginning by resetting the instruction pointer.
The GPIO instruction extracts:
- the pin number from the upper 3 bits
- the output state from the lower 5 bits
The delay instruction converts the argument byte into milliseconds by multiplying it by 100.
The ADC-to-PWM instruction:
- extracts ADC and PWM pins
- initializes PWM if not yet initialized
- reads ADC value
- converts the 10-bit ADC result into an 8-bit PWM value
- optionally reverses the mapping
- writes the final duty cycle
Each executed instruction increments the instruction pointer so the next EEPROM instruction can be fetched.
Main execution loop
The main firmware loop is extremely small because most logic lives inside the ExecuteInstruction function:
int main () {
InitTimer0 ();
InitAdc ();
sei ();
while ( 1 ) {
if ( delay_active ) {
delay_active = ( last_timestamp < delay_end_time );
continue ;
}
Instruction instruction ;
eeprom_read_block ( & instruction , & eeprom_program [ instruction_pointer ], sizeof ( Instruction ));
ExecuteInstruction ( & instruction );
}
}
Peripheral initialization - InitTimer0 (); InitAdc (); initializes the timer subsystem and ADC peripheral before execution begins.
Global interrupts - sei (); enables interrupts globally so Timer0 interrupts can start updating timestamps.
Delay handling - The interpreter checks whether a delay is currently active:
if ( delay_active ) {
delay_active = ( last_timestamp < delay_end_time );
continue ;
}
If the delay has not expired yet, the loop simply waits without advancing instruction execution.
EEPROM instruction fetch - eeprom_read_block ( & instruction , & eeprom_program [ instruction_pointer ], sizeof ( Instruction )); copies the next instruction from EEPROM into RAM:
&instruction- destination in RAM&eeprom_program[instruction_pointer]- source address inside EEPROMsizeof(Instruction)- number of bytes to copy
Instruction execution - Finally: ExecuteInstruction ( & instruction ); decodes and executes the fetched bytecode instruction.
Building and flashing with CMake
The project can be built entirely using CMake. The following CMakeLists.txt file configures compilation, HEX generation and flashing:
cmake_minimum_required ( VERSION 3.15 )
project ( attiny85_eeprom_interpreter C )
set ( MCU attiny85 )
set ( F_CPU 8000000UL )
set ( CMAKE_C_COMPILER avr-gcc )
set ( CMAKE_OBJCOPY avr-objcopy )
set ( CMAKE_SIZE avr-size )
set ( COMMON_FLAGS -mmcu= ${ MCU } -DF_CPU= ${ F_CPU } -Os -flto -fdata-sections -ffunction-sections -fno-tree-scev-cprop -fno-split-wide-types -mcall-prologues -Wall )
add_executable ( ${ PROJECT_NAME } .elf main.c )
target_compile_options ( ${ PROJECT_NAME } .elf PRIVATE ${ COMMON_FLAGS } )
target_link_options ( ${ PROJECT_NAME } .elf PRIVATE -mmcu= ${ MCU } -Wl,--gc-sections -Wl,--relax -flto )
add_custom_command ( TARGET ${ PROJECT_NAME } .elf POST_BUILD
COMMAND ${ CMAKE_OBJCOPY } -O ihex -R .eeprom ${ PROJECT_NAME } .elf ${ PROJECT_NAME } .hex
COMMAND ${ CMAKE_SIZE } --mcu= ${ MCU } --format=avr ${ PROJECT_NAME } .elf
)
add_custom_target ( flash
COMMAND avrdude -c usbasp -p t85 -U flash:w:${ PROJECT_NAME } .hex:i
DEPENDS ${ PROJECT_NAME } .elf
)
add_custom_command ( TARGET ${ PROJECT_NAME } .elf POST_BUILD
COMMAND ${ CMAKE_OBJCOPY } -O ihex -j .eeprom --change-section-lma .eeprom=0 ${ PROJECT_NAME } .elf ${ PROJECT_NAME } .eep
)
add_custom_target ( flash_eeprom
COMMAND avrdude -c usbasp -p t85 -U eeprom:w:${ PROJECT_NAME } .eep:i
DEPENDS ${ PROJECT_NAME } .elf
)
add_custom_target ( fuses
COMMAND avrdude -c usbasp -p t85 -U lfuse:w:0xE2:m
)
Notice that the flash targets are created without ALL. This is intentional because we do not want the microcontroller to be reflashed every time we simply build the project.
Read also on Medium: How to write Arduino Uno code with Python?
Set fuses
cmake --build . --target fuses
In this project we set the low fuse to 0xE2 because we want the ATtiny85 to run from its internal 8 MHz oscillator without the default clock divider enabled. By default, many AVR chips ship with the CKDIV8 fuse enabled, which divides the system clock by 8. Even though the internal oscillator runs at 8 MHz, the CPU would actually execute at 1MHz. That would make all timing calculations incorrect and the behavior would appear 8 times slower. The 0xE2 fuse configuration disables this divider, so the MCU truly runs at the full 8 MHz specified by:
#define F_CPU 8000000UL
Build firmware
cmake --build .
Flash firmware
cmake --build . --target flash
Flashing EEPROM program
Now, after the FW is flashed, you can call:
cmake --build . --target flash_eeprom
while the MCU is running to replace the previous EEPROM program with the new one.
Why this approach is interesting
Although this interpreter is only a proof of concept, it demonstrates several interesting ideas. The biggest advantage is instruction density. Every operation occupies exactly 2 bytes in EEPROM, which can sometimes be significantly smaller than equivalent compiled C code stored in FLASH memory. Instead of storing large AVR machine instructions, the MCU executes a compact custom bytecode format specifically optimized for the target application.
Other advantages:
- runtime-replaceable logic without reflashing firmware
- deterministic instruction size
- extremely small interpreter core
- easy serialization and external generation of programs
This kind of architecture starts resembling a tiny virtual machine for AVR microcontrollers. While it will never match native compiled performance, it can be surprisingly efficient for automation, GPIO sequencing, LED control or sensor mapping tasks where compactness matters more than raw speed.
Comments
No comments yet. Start the discussion.