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},
    {