The ATmega16 is the chip most microprocessor courses are built around. It has the peripherals worth teaching and a register map small enough to read in an afternoon. What it does not have is a convenient way to try code when you do not have the board in front of you.
The usual answer is a desktop tool on Windows. This article is about the other option: compiling ATmega16 firmware and executing it, instruction by instruction, in a browser tab — with the circuit around it simulated too.
What "simulating an ATmega16" involves
Two separate things have to happen, and courses often blur them.
An AVR emulator executes your firmware. Not an approximation of it — the actual compiled machine code, one instruction at a time, including the peripheral registers. When your code writes PORTB, the emulator's PORTB changes.
A circuit simulator handles the analog side. The resistors, the LED, the potentiometer wiper position. This is where ngspice comes in.
An embedded circuit needs both, connected: your code decides when a pin goes high, and the components decide what that means. CircuPilot compiles ATmega16 sources with MightyCore through a real toolchain, runs the binary in an AVR emulator, and drives the schematic from the emulated pins.
That connection is what makes a simulator able to catch a real firmware bug. A wrong prescaler bit produces a wrong frequency here, exactly as it would on the bench.
Register-level code is the point, not a workaround
If you learned the Arduino API first, register code looks like the hard way round. For an ATmega16 course it is the other way round: DDRA, PORTB, ADMUX, TCCR1B and GICR are the syllabus. The exam asks which bit of ADMUX selects the channel.
So the blink program is not digitalWrite:
#define F_CPU 16000000UL
#include <avr/io.h>
#include <util/delay.h>
int main(void) {
DDRB = 0x01; // PB0 as output
for (;;) {
PORTB ^= 0x01; // toggle
_delay_ms(100);
}
}
DDRB sets direction — a 1 makes the pin an output. PORTB sets the level. PINB, which this program does not use, is where you read an input. Those three registers per port are most of AVR I/O, and getting them straight is worth more than any library.
One practical note that costs people an evening: use _delay_ms() from <util/delay.h>, not the Arduino delay(). _delay_ms() is a calibrated busy loop the compiler generates from F_CPU — it needs no peripheral at all. delay() counts timer overflows, which means it depends on a timer being configured and running.
Reading the ADC
The ATmega16 has eight ADC channels on PORTA, ADC0 through ADC7, and a 10-bit result. Wire a potentiometer wiper to PA0, the ends to 5 V and ground, and the conversion reads the position.
Four registers do the work. ADMUX picks the channel and the reference. ADCSRA enables the converter, sets the clock prescaler and starts a conversion. ADCL and ADCH hold the result — and ADC is the macro that reads the pair in the right order.
#include <avr/io.h>
static uint16_t adc_read(uint8_t channel) {
ADMUX = (1 << REFS0) | channel; // AVCC as reference
ADCSRA |= (1 << ADSC); // start conversion
while (ADCSRA & (1 << ADSC)) { } // wait for it to clear
return ADC; // 0..1023
}
int main(void) {
DDRB = 0x0F; // PB0..PB3 outputs
ADCSRA = (1 << ADEN) | (1 << ADPS2) | (1 << ADPS1); // enable, clock /64
for (;;) {
uint16_t raw = adc_read(0); // ADC0 = PA0
uint8_t lit = raw / 205; // 0..4
PORTB = (1 << lit) - 1; // light that many LEDs
}
}
Turning the potentiometer lights more of the four LEDs. It is the standard bar-graph exercise, and it exercises the whole chain: an analog voltage, a conversion, a decision in firmware, and digital outputs.
Two details that matter on real hardware and are easy to skip in a simulator that fakes the ADC:
The reference is a pin, not a setting. REFS1:0 = 01 selects AVCC, 00 selects the AREF pin. If you choose AREF you have to connect AREF, usually to 5 V. Both modes work here and both read the actual pin, so an unconnected reference behaves like an unconnected reference.
AGND is not optional. The analog ground pin has to come back to the same ground as the rest of the circuit. This is one of the more common reasons a board that works in simulation reads noise on a breadboard, and vice versa.
The prescaler has a legal range. The ADC wants a clock between 50 kHz and 200 kHz for full 10-bit accuracy. At 16 MHz that means dividing by at least 128 for the datasheet's guarantee; /64 is the common compromise and what the code above uses.
External interrupts
INT0 on PD2 and INT1 on PD3 are the two edge-triggered external interrupts. The pattern is four steps: make the pin an input, enable its pull-up so it idles high, choose the edge, then enable the interrupt and set the global enable bit.
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(INT0_vect) {
PORTB ^= (1 << 0); // toggle the LED
}
int main(void) {
DDRB |= (1 << 0); // PB0 output
DDRD &= ~(1 << 2); // PD2 input
PORTD |= (1 << 2); // pull-up, so the pin idles high
MCUCR = (1 << ISC01); // falling edge
GICR = (1 << INT0); // enable INT0
sei(); // global interrupt enable
for (;;) { }
}
Press a button that pulls PD2 to ground and the LED changes state. The main loop does nothing at all, which is the entire point — the processor is free until the edge arrives.
The registers are worth naming because they are the ones people mix up. MCUCR holds the sense-control bits: ISC01:ISC00 for INT0, ISC11:ISC10 for INT1, encoded as 00 low level, 01 any change, 10 falling edge, 11 rising edge. GICR has the enable bits, GIFR the flags. And sei() is the one that gets forgotten — without it every other bit is set correctly and nothing fires.
Timer1 and CTC mode
Timer1 is the 16-bit timer, and CTC — clear timer on compare match — is how you get an exact interval rather than an approximate one.
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(TIMER1_COMPA_vect) {
PORTB ^= 0x01;
}
int main(void) {
DDRB = 0x01;
TCCR1A = 0x00;
TCCR1B = (1 << WGM12) | (1 << CS11); // CTC, prescaler 8
OCR1A = 1999; // 16 MHz / 8 = 2 MHz → 2000 ticks = 1 ms
TIMSK = (1 << OCIE1A); // enable compare-match A interrupt
sei();
for (;;) { }
}
The arithmetic is the part to internalise: the prescaler divides the 16 MHz clock to 2 MHz, so one tick is 0.5 µs, and 2000 ticks is 1 ms. OCR1A is set to 1999 because the counter starts at zero — an off-by-one that changes a 1 ms interrupt into 1.0005 ms, which matters when you are counting them to build a clock.
Note TIMSK, singular. The ATmega16 has one interrupt-mask register shared by all three timers, where the ATmega328P splits them into TIMSK0, TIMSK1 and TIMSK2. Code ported from an Arduino example will not compile, and this is usually the first reason why.
What works and what does not
Being specific about this saves more time than pretending otherwise.
| Feature | Status |
|---|---|
Digital I/O — DDRx, PORTx, PINx | Works |
| ADC — 8 channels, AREF and AVCC references, AGND | Works |
External interrupts INT0, INT1 | Works |
| Timer1 — CTC, compare interrupts, PWM on OC1A/OC1B | Works |
_delay_ms() / _delay_us() | Works |
| Timer0 and Timer2 | Not yet |
delay(), millis() — they count Timer0 overflows | Not yet |
INT2 | Not yet |
Timer0 and Timer2 are a structural difference rather than a missing address: the ATmega16 gives each of them a single TCCR0 control register, while the ATmega328P splits the same bits across TCCR0A and TCCR0B, with the waveform-mode bits landing where the clock-select bits sit. Presenting one as the other produces a timer that runs at the wrong rate, which is worse than a timer that is absent, so it is absent.
The practical consequence is the one already mentioned: reach for _delay_ms() rather than delay(). For a course that teaches registers this is the better habit anyway.
Pin reference
| Function | Pins |
|---|---|
| ADC0–ADC7 | PA0–PA7 |
INT0, INT1 | PD2, PD3 |
| OC1A, OC1B — Timer1 PWM outputs | PD5, PD4 |
| Analog supply and reference | AVCC, AREF, AGND |
| SPI | PB5 (MOSI), PB6 (MISO), PB7 (SCK), PB4 (SS) |
| USART | PD0 (RXD), PD1 (TXD) |
Trying it
Open the simulator, place an ATmega16 from the microcontroller palette, wire an LED through a resistor to PB0 and a potentiometer to PA0, and paste the ADC example above. Press Run. The firmware compiles through a real AVR toolchain, the emulator starts, and turning the potentiometer changes how many LEDs are lit.
Nothing is installed, and it runs the same on Linux, macOS, Windows or a Chromebook — which is the part that matters when the lab machine is not the machine you have.
If you want the Arduino side of the same ideas, simulating an Arduino circuit covers digital I/O with the Arduino API, and reading a potentiometer with analogRead is the ADC in four lines instead of four registers.