DEV Community

Cover image for Storing your ATTiny program in...EEPROM?
piko::tutorial
piko::tutorial

Posted on • Originally published at pikotutorial.com

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

A few things are worth mentioning here:

  • Instruction represents a single EEPROM bytecode instruction.
  • instruction_pointer acts similarly to a program counter in a CPU.
  • last_timestamp provides a millisecond-style timing source.
  • delay_active and delay_end_time will 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}
};
Enter fullscreen mode Exit fullscreen mode

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}
};
Enter fullscreen mode Exit fullscreen mode

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[] =
{
    ...
};
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Peripheral initialization

InitTimer0();
InitAdc();
Enter fullscreen mode Exit fullscreen mode

initializes the timer subsystem and ADC peripheral before execution begins.

Global interrupts

sei();
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

copies the next instruction from EEPROM into RAM:

  • &instruction - destination in RAM
  • &eeprom_program[instruction_pointer] - source address inside EEPROM
  • sizeof(Instruction) - number of bytes to copy

Instruction execution

Finally:

ExecuteInstruction(&instruction);
Enter fullscreen mode Exit fullscreen mode

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
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Build firmware

cmake --build .
Enter fullscreen mode Exit fullscreen mode

Flash firmware

cmake --build . --target flash
Enter fullscreen mode Exit fullscreen mode

Flashing EEPROM program

Now, after the FW is flashed, you can call:

cmake --build . --target flash_eeprom
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)