aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorUri Shaked2026-02-14 20:04:24 +0200
committerUri Shaked2026-02-14 20:04:24 +0200
commit82f9fa506e252476a07367ec58f898c6a1b4f12f (patch)
tree19326eee87381124845d8d6789e40a5558a9dc70
parentci: remove node 18 (diff)
downloadavr8js-82f9fa506e252476a07367ec58f898c6a1b4f12f.tar.gz
avr8js-82f9fa506e252476a07367ec58f898c6a1b4f12f.tar.bz2
avr8js-82f9fa506e252476a07367ec58f898c6a1b4f12f.zip
feat(timer): ATtiny Timer/Counter1 #143
close #143
Diffstat (limited to '')
-rw-r--r--src/index.ts2
-rw-r--r--src/peripherals/timer-attiny.spec.ts170
-rw-r--r--src/peripherals/timer-attiny.ts429
3 files changed, 601 insertions, 0 deletions
diff --git a/src/index.ts b/src/index.ts
index 9f8d27a..f951897 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -47,6 +47,8 @@ export { AVRSPI, spiConfig } from './peripherals/spi';
export type { SPIConfig, SPITransferCallback } from './peripherals/spi';
export { AVRTimer, timer0Config, timer1Config, timer2Config } from './peripherals/timer';
export type { AVRTimerConfig } from './peripherals/timer';
+export { ATtinyTimer1, attinyTimer1Config } from './peripherals/timer-attiny';
+export type { ATtinyTimer1Config } from './peripherals/timer-attiny';
export * from './peripherals/twi';
export { AVRUSART, usart0Config } from './peripherals/usart';
export { AVRUSI } from './peripherals/usi';
diff --git a/src/peripherals/timer-attiny.spec.ts b/src/peripherals/timer-attiny.spec.ts
new file mode 100644
index 0000000..e81a6e8
--- /dev/null
+++ b/src/peripherals/timer-attiny.spec.ts
@@ -0,0 +1,170 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) Uri Shaked and contributors
+
+import { describe, expect, it } from 'vitest';
+import { CPU } from '../cpu/cpu';
+import { AVRIOPort } from './gpio';
+import { ATtinyTimer1, attinyTimer1Config } from './timer-attiny';
+
+const attinyPortB = {
+ PIN: 0x36,
+ DDR: 0x37,
+ PORT: 0x38,
+ externalInterrupts: [],
+};
+
+const TCCR1 = attinyTimer1Config.TCCR1;
+const TCNT1 = attinyTimer1Config.TCNT1;
+const OCR1A = attinyTimer1Config.OCR1A;
+const OCR1B = attinyTimer1Config.OCR1B;
+const OCR1C = attinyTimer1Config.OCR1C;
+const TIFR = attinyTimer1Config.TIFR;
+const TIMSK = attinyTimer1Config.TIMSK;
+
+const TOV1 = attinyTimer1Config.TOV1;
+const OCF1A = attinyTimer1Config.OCF1A;
+const OCF1B = attinyTimer1Config.OCF1B;
+const OCIE1A = attinyTimer1Config.OCIE1A;
+
+const CTC1 = 1 << 7;
+const CS10 = 1;
+const CS13 = 1 << 3;
+
+const SREG = 95;
+
+function createTimer() {
+ const cpu = new CPU(new Uint16Array(0x1000));
+ new AVRIOPort(cpu, attinyPortB);
+ const timer = new ATtinyTimer1(cpu, attinyTimer1Config);
+ return { cpu, timer };
+}
+
+describe('ATtiny Timer1', () => {
+ it('should update timer every tick when prescaler is 1 (CS=1)', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(TCCR1, CS10);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 2;
+ cpu.tick();
+ expect(cpu.readData(TCNT1)).toEqual(1);
+ });
+
+ it('should update timer every 128 ticks when prescaler is 128 (CS=8)', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(TCCR1, CS13);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 1 + 128;
+ cpu.tick();
+ expect(cpu.readData(TCNT1)).toEqual(1);
+ });
+
+ it('should not update timer when disabled (CS=0)', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(TCCR1, 0);
+ cpu.cycles = 100000;
+ cpu.tick();
+ expect(cpu.readData(TCNT1)).toEqual(0);
+ });
+
+ describe('CTC mode', () => {
+ it('should clear timer on OCR1C match when CTC1 is set', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 9);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.writeData(TCNT1, 8);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 1 + 3;
+ cpu.tick();
+ expect(cpu.readData(TCNT1)).toEqual(1);
+ });
+
+ it('should set TOV1 when timer overflows past OCR1C', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 9);
+ cpu.writeData(TCNT1, 9);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 2;
+ cpu.tick();
+ expect(cpu.readData(TCNT1)).toEqual(0);
+ expect(cpu.data[TIFR] & TOV1).toEqual(TOV1);
+ });
+
+ it('should set OCF1A when timer matches OCR1A', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 249);
+ cpu.writeData(OCR1A, 5);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.writeData(TCNT1, 4);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 3;
+ cpu.tick();
+ expect(cpu.data[TIFR] & OCF1A).toEqual(OCF1A);
+ });
+
+ it('should set OCF1B when timer matches OCR1B', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 249);
+ cpu.writeData(OCR1B, 10);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.writeData(TCNT1, 9);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 3;
+ cpu.tick();
+ expect(cpu.data[TIFR] & OCF1B).toEqual(OCF1B);
+ });
+
+ it('should fire COMPA interrupt when enabled', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 249);
+ cpu.writeData(OCR1A, 0);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.writeData(TCNT1, 248);
+ cpu.writeData(TIMSK, OCIE1A);
+ cpu.data[SREG] = 0x80;
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 3;
+ cpu.tick();
+ expect(cpu.pc).toEqual(0x03);
+ });
+
+ it('should overflow after a full period with prescaler 128', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(TCCR1, CTC1 | CS13);
+ cpu.writeData(OCR1C, 249);
+ cpu.writeData(TIMSK, OCIE1A);
+ cpu.data[SREG] = 0x80;
+
+ // Full timer period: 250 * 128 = 32000 cycles
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 32001;
+ cpu.tick();
+
+ expect(cpu.data[TIFR] & TOV1).not.toEqual(0);
+ });
+ });
+
+ describe('clearing interrupt flags', () => {
+ it('should clear TOV1 by writing 1 to TIFR', () => {
+ const { cpu } = createTimer();
+ cpu.writeData(OCR1C, 9);
+ cpu.writeData(TCNT1, 9);
+ cpu.writeData(TCCR1, CTC1 | CS10);
+ cpu.cycles = 1;
+ cpu.tick();
+ cpu.cycles = 2;
+ cpu.tick();
+ expect(cpu.data[TIFR] & TOV1).toEqual(TOV1);
+ cpu.writeData(TIFR, TOV1);
+ expect(cpu.data[TIFR] & TOV1).toEqual(0);
+ });
+ });
+});
diff --git a/src/peripherals/timer-attiny.ts b/src/peripherals/timer-attiny.ts
new file mode 100644
index 0000000..98bf473
--- /dev/null
+++ b/src/peripherals/timer-attiny.ts
@@ -0,0 +1,429 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) Uri Shaked and contributors
+
+/**
+ * ATtiny25/45/85 Timer/Counter1
+ * Part of AVR8js
+ * Reference: http://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-2586-AVR-8-bit-Microcontroller-ATtiny25-ATtiny45-ATtiny85_Datasheet.pdf
+ */
+
+import { AVRInterruptConfig, CPU, CPUMemoryHook } from '../cpu/cpu';
+import { PinOverrideMode } from './gpio';
+
+type u8 = number;
+type u16 = number;
+
+export interface ATtinyTimer1Config {
+ TCCR1: u8;
+ GTCCR: u8;
+ TCNT1: u8;
+ OCR1A: u8;
+ OCR1B: u8;
+ OCR1C: u8;
+ TIFR: u8;
+ TIMSK: u8;
+
+ ovfInterrupt: u8;
+ compAInterrupt: u8;
+ compBInterrupt: u8;
+
+ TOV1: u8;
+ OCF1A: u8;
+ OCF1B: u8;
+
+ TOIE1: u8;
+ OCIE1A: u8;
+ OCIE1B: u8;
+
+ compPortB: u16;
+ compPinA: u8;
+ compPinB: u8;
+
+ dividers: Record<number, number>;
+}
+
+// TCCR1 bits
+const CTC1 = 1 << 7;
+const PWM1A = 1 << 6;
+const CS_MASK = 0x0f;
+
+// GTCCR bits
+const PWM1B_BIT = 1 << 6;
+const FOC1B = 1 << 3;
+const FOC1A = 1 << 2;
+const PSR1 = 1 << 1;
+
+export const attinyTimer1Config: ATtinyTimer1Config = {
+ TCCR1: 0x50,
+ GTCCR: 0x4c,
+ TCNT1: 0x4f,
+ OCR1A: 0x4e,
+ OCR1B: 0x4b,
+ OCR1C: 0x4d,
+ TIFR: 0x58,
+ TIMSK: 0x59,
+
+ ovfInterrupt: 0x04,
+ compAInterrupt: 0x03,
+ compBInterrupt: 0x09,
+
+ TOV1: 1 << 2,
+ OCF1A: 1 << 6,
+ OCF1B: 1 << 5,
+
+ TOIE1: 1 << 2,
+ OCIE1A: 1 << 6,
+ OCIE1B: 1 << 5,
+
+ compPortB: 0x38,
+ compPinA: 1, // PB1
+ compPinB: 4, // PB4
+
+ dividers: {
+ 0: 0,
+ 1: 1,
+ 2: 2,
+ 3: 4,
+ 4: 8,
+ 5: 16,
+ 6: 32,
+ 7: 64,
+ 8: 128,
+ 9: 256,
+ 10: 512,
+ 11: 1024,
+ 12: 2048,
+ 13: 4096,
+ 14: 8192,
+ 15: 16384,
+ },
+};
+
+export class ATtinyTimer1 {
+ private lastCycle = 0;
+ private tcnt = 0;
+ private tcntNext = 0;
+ private tcntUpdated = false;
+ private ocrA = 0;
+ private ocrB = 0;
+ private ocrC = 0;
+ private divider = 0;
+ private updateDivider = false;
+ private countingUp = true;
+
+ private readonly OVF: AVRInterruptConfig = {
+ address: this.config.ovfInterrupt,
+ flagRegister: this.config.TIFR,
+ flagMask: this.config.TOV1,
+ enableRegister: this.config.TIMSK,
+ enableMask: this.config.TOIE1,
+ };
+
+ private readonly OCFA: AVRInterruptConfig = {
+ address: this.config.compAInterrupt,
+ flagRegister: this.config.TIFR,
+ flagMask: this.config.OCF1A,
+ enableRegister: this.config.TIMSK,
+ enableMask: this.config.OCIE1A,
+ };
+
+ private readonly OCFB: AVRInterruptConfig = {
+ address: this.config.compBInterrupt,
+ flagRegister: this.config.TIFR,
+ flagMask: this.config.OCF1B,
+ enableRegister: this.config.TIMSK,
+ enableMask: this.config.OCIE1B,
+ };
+
+ constructor(
+ private cpu: CPU,
+ private config: ATtinyTimer1Config,
+ ) {
+ const { TCCR1, GTCCR, TCNT1, OCR1A, OCR1B, OCR1C, TIFR, TIMSK } = config;
+
+ cpu.readHooks[TCNT1] = () => {
+ this.count(false);
+ return (cpu.data[TCNT1] = this.tcnt & 0xff);
+ };
+
+ cpu.writeHooks[TCNT1] = (value: number) => {
+ this.tcntNext = value;
+ this.countingUp = true;
+ this.tcntUpdated = true;
+ cpu.updateClockEvent(this.count, 0);
+ if (this.divider) {
+ this.timerUpdated(this.tcntNext, this.tcntNext);
+ }
+ };
+
+ cpu.writeHooks[OCR1A] = (value: number) => {
+ this.ocrA = value;
+ };
+ cpu.writeHooks[OCR1B] = (value: number) => {
+ this.ocrB = value;
+ };
+ cpu.writeHooks[OCR1C] = (value: number) => {
+ this.ocrC = value;
+ };
+
+ cpu.writeHooks[TCCR1] = (value: number) => {
+ cpu.data[TCCR1] = value;
+ this.updateDivider = true;
+ cpu.clearClockEvent(this.count);
+ cpu.addClockEvent(this.count, 0);
+ this.updateCompConfig();
+ return true;
+ };
+
+ // GTCCR is shared with Timer0 (PSR0) — chain with existing hook
+ const prevGtccrHook = cpu.writeHooks[GTCCR] as CPUMemoryHook | undefined;
+ cpu.writeHooks[GTCCR] = (value: number, oldValue: number, addr: number, mask: number) => {
+ if (value & FOC1A) {
+ this.forceCompare('A');
+ }
+ if (value & FOC1B) {
+ this.forceCompare('B');
+ }
+ if (value & PSR1) {
+ this.lastCycle = this.cpu.cycles;
+ }
+ value &= ~(FOC1A | FOC1B | PSR1);
+
+ if (prevGtccrHook) {
+ prevGtccrHook(value, oldValue, addr, mask);
+ } else {
+ cpu.data[GTCCR] = value;
+ }
+
+ this.updateCompConfig();
+ return true;
+ };
+
+ // TIFR/TIMSK are shared with Timer0 — chain with existing hooks
+ const prevTifrHook = cpu.writeHooks[TIFR] as CPUMemoryHook | undefined;
+ cpu.writeHooks[TIFR] = (value: number, oldValue: number, addr: number, mask: number) => {
+ if (prevTifrHook) {
+ prevTifrHook(value, oldValue, addr, mask);
+ } else {
+ cpu.data[TIFR] = value;
+ }
+ cpu.clearInterruptByFlag(this.OVF, value);
+ cpu.clearInterruptByFlag(this.OCFA, value);
+ cpu.clearInterruptByFlag(this.OCFB, value);
+ return true;
+ };
+
+ const prevTimskHook = cpu.writeHooks[TIMSK] as CPUMemoryHook | undefined;
+ cpu.writeHooks[TIMSK] = (value: number, oldValue: number, addr: number, mask: number) => {
+ if (prevTimskHook) {
+ prevTimskHook(value, oldValue, addr, mask);
+ }
+ cpu.updateInterruptEnable(this.OVF, value);
+ cpu.updateInterruptEnable(this.OCFA, value);
+ cpu.updateInterruptEnable(this.OCFB, value);
+ };
+ }
+
+ private get tccr1() {
+ return this.cpu.data[this.config.TCCR1];
+ }
+
+ private get gtccr() {
+ return this.cpu.data[this.config.GTCCR];
+ }
+
+ private get CS() {
+ return this.tccr1 & CS_MASK;
+ }
+
+ private get ctcMode() {
+ return !!(this.tccr1 & CTC1);
+ }
+
+ private get pwmA() {
+ return !!(this.tccr1 & PWM1A);
+ }
+
+ private get pwmB() {
+ return !!(this.gtccr & PWM1B_BIT);
+ }
+
+ private get comA(): number {
+ return (this.tccr1 >> 4) & 0x3;
+ }
+
+ private get comB(): number {
+ return (this.gtccr >> 4) & 0x3;
+ }
+
+ /** TOP = OCR1C in CTC/PWM modes, 0xFF in Normal mode */
+ private get TOP() {
+ if (this.ctcMode || this.pwmA || this.pwmB) {
+ return this.ocrC;
+ }
+ return 0xff;
+ }
+
+ count = (reschedule = true) => {
+ const { divider, lastCycle, cpu } = this;
+ const { cycles } = cpu;
+ const delta = cycles - lastCycle;
+
+ if (divider && delta >= divider) {
+ const counterDelta = Math.floor(delta / divider);
+ this.lastCycle += counterDelta * divider;
+ const val = this.tcnt;
+ const top = this.TOP;
+ const phasePwm = (this.pwmA || this.pwmB) && !this.ctcMode;
+
+ const newVal = phasePwm
+ ? this.phasePwmCount(val, counterDelta)
+ : (val + counterDelta) % (top + 1);
+ const overflow = val + counterDelta > top;
+
+ if (!this.tcntUpdated) {
+ this.tcnt = newVal;
+ if (!phasePwm) {
+ this.timerUpdated(newVal, val);
+ }
+ }
+
+ if (!phasePwm && overflow) {
+ cpu.setInterruptFlag(this.OVF);
+ }
+ }
+
+ if (this.tcntUpdated) {
+ this.tcnt = this.tcntNext;
+ this.tcntUpdated = false;
+ }
+
+ if (this.updateDivider) {
+ const cs = this.CS;
+ const newDivider = this.config.dividers[cs] ?? 0;
+ this.lastCycle = newDivider ? this.cpu.cycles : 0;
+ this.updateDivider = false;
+ this.divider = newDivider;
+ if (newDivider) {
+ cpu.addClockEvent(this.count, this.lastCycle + newDivider - cpu.cycles);
+ }
+ return;
+ }
+
+ if (reschedule && divider) {
+ cpu.addClockEvent(this.count, this.lastCycle + divider - cpu.cycles);
+ }
+ };
+
+ private phasePwmCount(value: number, delta: number): number {
+ const top = this.TOP;
+
+ while (delta > 0) {
+ if (this.countingUp) {
+ value++;
+ if (value >= top) {
+ value = top;
+ this.countingUp = false;
+ }
+ } else {
+ value--;
+ if (value <= 0) {
+ value = 0;
+ this.countingUp = true;
+ this.cpu.setInterruptFlag(this.OVF);
+ }
+ }
+
+ if (!this.tcntUpdated) {
+ if (value === this.ocrA) {
+ this.cpu.setInterruptFlag(this.OCFA);
+ this.updateCompPinPwm('A');
+ }
+ if (value === this.ocrB) {
+ this.cpu.setInterruptFlag(this.OCFB);
+ this.updateCompPinPwm('B');
+ }
+ }
+ delta--;
+ }
+ return value & 0xff;
+ }
+
+ private timerUpdated(value: number, prevValue: number) {
+ const { ocrA, ocrB } = this;
+ const overflow = prevValue > value;
+ if (((prevValue < ocrA || overflow) && value >= ocrA) || (prevValue < ocrA && overflow)) {
+ this.cpu.setInterruptFlag(this.OCFA);
+ if (this.comA && !this.pwmA) {
+ this.updateCompPinNonPwm('A');
+ }
+ }
+ if (((prevValue < ocrB || overflow) && value >= ocrB) || (prevValue < ocrB && overflow)) {
+ this.cpu.setInterruptFlag(this.OCFB);
+ if (this.comB && !this.pwmB) {
+ this.updateCompPinNonPwm('B');
+ }
+ }
+ }
+
+ private forceCompare(channel: 'A' | 'B') {
+ if (channel === 'A' && !this.pwmA && this.comA) {
+ this.updateCompPinNonPwm('A');
+ } else if (channel === 'B' && !this.pwmB && this.comB) {
+ this.updateCompPinNonPwm('B');
+ }
+ }
+
+ private updateCompPinNonPwm(channel: 'A' | 'B') {
+ const com = channel === 'A' ? this.comA : this.comB;
+ const pin = channel === 'A' ? this.config.compPinA : this.config.compPinB;
+ let mode: PinOverrideMode;
+ switch (com) {
+ case 1:
+ mode = PinOverrideMode.Toggle;
+ break;
+ case 2:
+ mode = PinOverrideMode.Clear;
+ break;
+ case 3:
+ mode = PinOverrideMode.Set;
+ break;
+ default:
+ return;
+ }
+ this.cpu.gpioByPort[this.config.compPortB]?.timerOverridePin(pin, mode);
+ }
+
+ private updateCompPinPwm(channel: 'A' | 'B') {
+ const com = channel === 'A' ? this.comA : this.comB;
+ const pin = channel === 'A' ? this.config.compPinA : this.config.compPinB;
+ const invertingMode = com === 3;
+ const isSet = this.countingUp === invertingMode;
+ let mode: PinOverrideMode;
+ switch (com) {
+ case 1:
+ mode = PinOverrideMode.Toggle;
+ break;
+ case 2:
+ case 3:
+ mode = isSet ? PinOverrideMode.Set : PinOverrideMode.Clear;
+ break;
+ default:
+ return;
+ }
+ this.cpu.gpioByPort[this.config.compPortB]?.timerOverridePin(pin, mode);
+ }
+
+ private updateCompConfig() {
+ const port = this.cpu.gpioByPort[this.config.compPortB];
+ if (!port) return;
+ port.timerOverridePin(
+ this.config.compPinA,
+ this.comA ? PinOverrideMode.Enable : PinOverrideMode.None,
+ );
+ port.timerOverridePin(
+ this.config.compPinB,
+ this.comB ? PinOverrideMode.Enable : PinOverrideMode.None,
+ );
+ }
+}