16-bit-PWM mit 8-bit-Timer

Eine beliebte Anwendung der Atmel-Mikrocontroller ATmega bzw. ATtiny ist das Ansteuern und Dimmen von Leuchtdioden. Aufkommende „Vollfarb-LEDs“ (ich würde sie eher RGB-LEDs nennen) ermöglichen das Erstellen von beliebigen Mischfarben (für's Auge, Brillianz nicht immer perfekt) und einstellbarer Helligkeit.

8-Bit-PWM-Kanäle sind für ruckelfreie Animationsprogramme zu grob gestuft, und die meisten Atmel-Controller haben nur zwei 16-Bit-PWM-Kanäle, leider. Klar, dass für diese Anwendung D/A-Wandler Overkill und Energieverschwendung sind, daher die Pulsweitenmodulation (=PWM). Der Artikel zeigt, wie man einen 8-Bit-PWM-Kanal für eine echte 16-Bit-PWM verwenden kann, und das bei nur moderaten Anforderungen an Rechenleistung und Interruptlatenz.

16-bit-PWM mit 8-bit-Timer

Im folgenden beziehe ich mich auf ATmega8. Die Ausführungen sind auch auf die Typen ATmega48, ATmega88, ATmega168, ATmega16 und größer sowie ATtiny24, ATtiny44, ATtiny84 und ATtiny2313 anwendbar.
Diesen Mikrocontrollern gemeinsam ist ein 16-bit-Timer1 mit zwei echten 16-bit-PWM-Kanälen und (mindestens) 1 weiterer 8-bit-Timer (Timer0 oder Timer2) mit (mindestens) einem 8-bit-PWM-Kanal.

Verfügbare PWM-Ausgänge
MikrocontrollerGesamtanzahl realisierbarer 16-bit-PWM-Kanäle
ATtiny24, ATtiny44, ATtiny84, ATtiny23134 (2+2) (lies: 2 Hardware plus 2 Software)
ATtiny25, ATtiny45, ATtiny854 (0+2+2)
ATtiny13, ATtiny262 (0+2)
ATtiny261, ATtiny461, ATtiny8614 (0+4)
ATtiny11, ATtiny120 – kein PWM-Ausgang
ATtiny10, ATtiny4/5/92 (2+0) – kein 8-Bit-Timer
ATmega48, ATmega88, ATmega1686 (2+2+2)
ATmega83 (2+1)
ATmega16, ATmega324 (2+1+1)
ATmega648 (6+2)

Beispiel-Implementierung

Derzeitige Implementierungen
8051PICAVRC166
--LED-PWM.zip-

Die Implementierung von 16-Bit-PWM auf einem 8-Bit-Timer erfordert:

Vom Timer wird nur der Überlauf-Interrupt benötigt. Der Output-Compare-Interrupt wird nicht benötigt. Ein 8-Bit-Timer darf mehrere PWM-Ausgänge bedienen, sofern vorhanden. Etwa bei den ATtiny sowie ATmegaX8.

Kommentare zum Quelltext

Das Programm fährt die Helligkeit der 3 LEDs augenscheinlich linear (also exponentiell!) in insgesamt 3 Phasen rauf und runter.

.nolist
.include "m8def.inc"
.include "makros.i90"
.list
Der Quelltext ist für avrasm2 von Atmel, nicht für avr-gcc. Die selbst erstellte Makrosammlung „makros.i90“ erspart Tipparbeit.
// Port B
// PB0		(14)	SLEEP-Anzeige
// PB1	OC1A	(15)
// PB2	OC1B	(16)	PWM-Ausgänge für LEDs
// PB3	OC2	(17)
Hier sind die 3 Leuchtdioden am ATmega8 (DIL, Durchsteck) anzuschließen. Mit Vorwiderstand nach Masse — oder über einen Strom verstärkenden Transistor.
// Register R8..R15 sowie R23 für ISR erforderlich
.def	ZERO	=r8	// immer 0x00
.def	ONES	=r9	// immer 0xFF
.def	LEN2L	=r10	// gepuffert (Pulslänge, von außen zu setzen)
.def	LEN2H	=r11
.def	isrt1	=r12	// ISR-Register (SREG-Backup)
.def	TCNT2H	=r13	// High-Teil Zählerstand
.def	OCR2L	=r14	// ungepuffert
.def	OCR2H	=r15

.def	r	=r4	// Rote LED-Helligkeit, 0=AUS, 255=voll, logarithmisch skaliert
.def	g	=r5	// Grün
.def	b	=r6	// Blau
.def	isrt0	=r23	// ISR-Register, muss ≥ R16 sein
Das Beispielprogramm verwendet eine Menge Register für die ISRs. Das ist wegen der Geschwindigkeit auch erforderlich! Der ATmega8 läuft mit 8 MHz. Für die Animation als solche werden nur 8-bit-Zahlen r, g und b gezählt.
.org 0
	rjmp	Init

.org OVF2addr
// Timer2-Überlauf-ISR (darf sich [255 Takte minus CLI-Zeit] Zeit gönnen)
// Erzeugt CPU-Last, da Aufruf alle 256 Takte (32 µs @ F_CPU == 8 MHz)!
	in	isrt1,SREG
	inc	TCNT2H			// High-Teil in Software
	brne	t2a
	 movw	OCR2H:OCR2L,LEN2H:LEN2L	// Bei Null Wert übernehmen (Doppel-Puffer)
	 in	isrt0,TCCR2
	 sbr	isrt0,1<<WGM21|1<<WGM20	// PWM EIN: WGM=3
	 out	TCCR2,isrt0
	 out	OCR2,ONES		// startet erst wenn TCNT2H == 1
t2a:	cp	OCR2H,TCNT2H
	brne	t2b
	 out	OCR2,OCR2L		// LOW-Zeit setzen
t2b:	mov	isrt0,OCR2H
	inc	isrt0			// Sonderfall OCR2H == 0xFF?
	breq	t2c
	cp	isrt0,TCNT2H		// Wenn  TCNT2H == OCR2H+1 ...
	brne	t2c
	 in	isrt0,TCCR2
	 cbr	isrt0,1<<WGM21|1<<WGM20	// PWM AUS: WGM=0
	 out	TCCR2,isrt0		// (COM2 bleibt 2! Löscht OC2 später)
t2c:	out	SREG,isrt1
	reti				// max. 26 Takte (inkl. Aufruf + RETI)
Die Interruptserviceroutine für den 8-bit-Timer2 realisiert die 16-bit-PWM als 8-bit-PWM in einer von 256 „Zählrunden“. Davor (von HIGH-Zählerstand Null ab) ist das Ausgangssignal permanent HIGH (durch Setzen von 255 in das Register OCR2), danach permanent LOW (durch Ausschalten der PWM).
Die Implementierung unterscheidet sich von der unten angegebenen Grafik, dass der Start des PWM-Impulses bei TCNT2H == 1 (nicht Null) liegt.
WaitT1:	// Wartet auf Timer1-Überlauf (bei F_CPU == 8 MHz ca. alle 8 ms)
w1:	sbi	PORTB,0			// SLEEP-Kontrollausgabe an Pin 14
	sleep				// Strom sparen
	cbi	PORTB,0
	in	r16,TIFR
	jbrc	r16,2,w1
	outi	TIFR,	0b00000100	// EOI
	ret
Diese kleine Warteroutine wird für die Hauptschleife (Animationsprogramm) gebraucht und wartet eine volle Umrundung von 16-bit-Timer1.
Nicht-fette „Befehle“ sind Makros, definiert in der Datei „MAKROS.I90“.
Init:	clr	ZERO
	clr	ONES
	dec	ONES
	outihl	SP,RAMEND		// Stackpointer initialisieren
	outi	TIMSK,	0b01000000	// Timer2: Interrupt aktivieren
	outi	TCCR2,	0b01101001	// Timer2: VT1, schnelle nichtinv. PWM
	outi	TCCR1A,	0b10100010	// Timer1: schnelle nichtinv. PWM
	outi	TCCR1B,	0b00011001	// Timer1: VT1
	outhl	ICR1,ONES,ONES		// Timer1: Volle 16 bit Zählumfang
	
	mov	r,ZERO
	mov	g,ZERO
	mov	b,ONES			// Start mit 100 % Blau
	
	outi	DDRB,	0b00011111	// Ausgabeports
	outi	MCUCR,	0b10000000	// SLEEP aktivieren, nur IDLE
	sei
Initialisiert wird mit massivem Einsatz der „makros.i90“.
MainLoop:
p1:	// Farbübergang Blau ⇒ Rot
	rcall	WaitT1
	dec	b
	rcall	SetPwmB
	inc	r
	rcall	SetPwmR
	cp	r,ONES
	brne	p1
p2:	// Farbübergang Rot ⇒ Grün
	rcall	WaitT1
	dec	r
	rcall	SetPwmR
	inc	g
	rcall	SetPwmG
	cp	g,ONES
	brne	p2
p3:	// Farbübergang Grün ⇒ Blau
	rcall	WaitT1
	dec	g
	rcall	SetPwmG
	inc	b
	rcall	SetPwmB
	cp	b,ONES
	brne	p3
	rjmp	MainLoop
Die Grafik erklärt die Funktion viel besser als jeder Text:-)
SetPwmR:
	mov	ZL,r
	rcall	getexp
	outhl	OCR1A, r17,r16
	in	r16,TCCR1A
	tst	r
	breq	ppr1
	sbr	r16,1<<COM1A1
	rjmp	ppr2
ppr1:	cbr	r16,1<<COM1A1
ppr2:	out	TCCR1A,r16
	ret
	
SetPwmG:
	mov	ZL,g
	rcall	getexp
	outhl	OCR1B, r17,r16
	in	r16,TCCR1A
	tst	g
	breq	ppg1
	sbr	r16,1<<COM1B1
	rjmp	ppg2
ppg1:	cbr	r16,1<<COM1B1
ppg2:	out	TCCR1A,r16
	ret
	
SetPwmB:
	mov	ZL,b
	rcall	getexp
	movw	LEN2H:LEN2L,r17:r16
	in	r16,TCCR2
	tst	b
	breq	ppb1
	sbr	r16,1<<COM21
	rjmp	ppb2
ppb1:	cbr	r16,1<<COM21
ppb2:	out	TCCR2,r16
	ret
So werden bei Änderung von r, g und b die PWM-Ausgänge nachgeführt.
getexp:
// PE: ZL = Index 0..255
// PA: R17:R16 = logarithmischer OCR-Wert mit der Eigenschaft:
//	Y[ZL=0] = egal (PWM-Länge 0, ganz AUS)
//	Y[ZL=1] = 0 (PWM-Länge 1, minimal)
//	Y[ZL=255] = 65535 (PWM-Länge 65536, ganz EIN)
// Die PWM des ATtiny/ATmega hat die innewohnende Eigenschaft der
// Impulsausgabe: 1 Takt länger als der OCR-Wert
	clr	ZH
	lsl	ZL			// WORD-Adresse generieren (0..510)
	rol	ZH
	subi	ZL,LOW(-exptab*2)	// Anfangsadresse der Tabelle "addieren"
	sbci	ZH,HIGH(-exptab*2)
	lpm	r16,Z+			// Tabellenwert lesen
	lpm	r17,Z+
	ret
exptab:
.nolist
.dw	0,0,1,2,3,4,5,7,8,9,10,12,13,15,16,18
.dw	19,21,22,24,26,28,30,32,34,36,38,40,42,44,47,49
.dw	52,54,57,60,63,65,68,72,75,78,81,85,89,92,96,100
.dw	104,108,113,117,122,126,131,136,141,146,152,158,163,169,175,182
.dw	188,195,202,209,216,224,232,240,248,257,265,275,284,293,303,314
.dw	324,335,346,358,369,382,394,407,420,434,448,463,478,493,509,526
.dw	543,560,578,596,616,635,655,676,698,720,743,766,790,815,841,867
.dw	894,923,951,981,1012,1044,1076,1110,1144,1180,1216,1254,1293,1333,1375,1417
.dw	1461,1506,1553,1601,1650,1701,1753,1807,1863,1920,1980,2040,2103,2168,2234,2303
.dw	2373,2446,2521,2598,2678,2760,2844,2931,3021,3113,3208,3306,3407,3511,3618,3729
.dw	3842,3959,4080,4204,4332,4464,4600,4740,4885,5033,5186,5344,5507,5674,5847,6024
.dw	6207,6396,6590,6790,6997,7209,7428,7654,7886,8125,8372,8626,8887,9157,9435,9721
.dw	10016,10319,10632,10954,11287,11629,11981,12344,12718,13104,13501,13910,14331,14765,15212,15673
.dw	16148,16637,17140,17659,18194,18745,19312,19897,20499,21120,21759,22417,23096,23795,24515,25257
.dw	26021,26808,27619,28455,29316,30203,31117,32058,33028,34027,35056,36116,37209,38334,39494,40688
.dw	41919,43186,44492,45838,47224,48652,50123,51639,53201,54809,56467,58174,59933,61745,63612,65535
Um aus einem „augenscheinlich linearen“ Helligkeitswert die zugehörige PWM-Einschaltzeit zu ermitteln, ist eine Exponentialtabelle hilfreich. Diese wurde mit einem awk-Programm erstellt, und zwar derart, dass: ist. Diese 256 Werte um 1 erniedrigt füllen die o.a. Tabelle. Der Index 0 wird eigentlich nicht benötigt, denn dann wird die PWM ausgeschaltet (siehe Marke SetPwmR usw.).

Der Linearisierungseffekt für's Auge ist von den verwendeten LEDs sowie der Umgebungshelligkeit abhängig. Jedenfalls sind hier kleine Helligkeiten so fein wie technisch möglich gequantelt.

Für das angegebene Beispielprogramm (je 2 Grundfarben wechseln sich ab) erscheinen die Wendungen augenscheinlich zu spitz. Ist genau so eine Anwendung geplant, ist es günstiger, die Tabelle als eine „exponentierte Sinusfunktion“ zu erstellen.

Erläuterungen grafisch

Ein Bild sagt mehr als 1000 Worte, daher hier noch eine grafische Verdeutlichung des Funktionsprinzips und der Randprobleme. Wer das Bild kopieren möchte findet die zugehörige Vektorgrafik im .ZIP-Archiv.

Mehr PWM-Ausgänge für mehr LEDs

Alternativer Controller

Die MSP430 haben viele PWM-Ausgänge (4..20) standardmäßig, allesamt mit 16 Bit.

LED-Matrix-Ansteuerung

Für LEDs genügen durchaus auch 13 Bit. Wie man es für hunderte Ausgänge bewerkstelligt ist dort (von mir) beschrieben.

Man kann beim gewohnten ATmega bleiben, und dieser ist trotz dieser Fülle noch nicht mal ganz ausgelastet.