Inhaltsverzeichnis

Überblick

Wie bekommt man man 20 PWM-Kanäle aus einem Atmel mit nur 2 oder 3 HW-Timern?

Der grundsätzliche Ansatz ist, einen der Timer zu benutzen um per Interrupt eine größere Zahl von Ausgängen anzusteuern. So weit so gut, das ist schnell umgesetzt (im Folgenden will ich nur wesentliche Code-Abschnitte einbauen, um die Seite möglichst übersichtlich zu halten):

erster Versuch in c

volatile unsigned char	LEDs[20];

ISR ( TIMER2_COMP_vect )
{
	static unsigned char pwmpos;
	static unsigned char portb;

	PORTB = portb;

	pwmpos++;

	portb =   ( ( LEDs[ 0] > pwmpos ) << 0 )
		| ( ( LEDs[ 1] > pwmpos ) << 1 )
		| ( ( LEDs[ 2] > pwmpos ) << 2 )
		| ( ( LEDs[ 3] > pwmpos ) << 3 )
		| ( ( LEDs[ 4] > pwmpos ) << 4 )
		| ( ( LEDs[ 5] > pwmpos ) << 5 )
		| ( ( LEDs[ 6] > pwmpos ) << 6 );

	PORTB = portb | 0x80;
}

Die Variablen haben folgende Bedeutung:

LEDs: ein Array, in dem die PWM-Werte der einzelnen Ausgänge hinterlegt werden. Es ist als volatile definiert, damit der Compiler vor jeder Benutzung den Wert auch wirklich aus dem Speicher holt und nicht etwa eine „lokale Kopie“ in einem Register verwendet.

pwmpos: zählt hier einfach hoch und läuft nach 255 über, um von vorn anzufangen - der Referenzwert für die Frage, ob ein Pin an- oder ausgeschaltet werden soll.

portb: enthält den Status, auf den PORTB zu Anfang des nächsten Interrupts gesetzt werden soll. Die Überlegung hierbei ist, dass man die Ausgänge möglichst deterministisch schalten möchte. Wenn man nicht von vornherein sagen kann, wie lange die Ermittlung des neuen Zustandes dauert, ist der pragmatische Ansatz, die Ausgänge erst zu setzen, wenn der nächste Interrupt ausgelöst wird.

Der Code tut jetzt nichts anderes als den gewünschten PWM-Wert des jeweiligen Pins gegen die aktuelle PWM-Position zu vergleichen und das Ergebnis (1: der Ausgang muss an sein, 0: der ausgang muss aus sein) so weit nach links zu schieben, dass es am richtigen Pin landet. Das wird für alle Pins gemacht und danach alles zusammengeodert.

Das letzte Bit wird hier zum Benchmarken des Zeitbedarfs immer am Ende eines jeden Zyklus auf 1 gesetzt, am Anfang des nächsten wird das Pin wieder ausgeschalten. Der Fehler, den man sich durch das zu frühe Schreiben der anderen Pins (geplant ist das eigentlich erst zum Beginn des nächsten Zyklus) verursacht, ist zumindest fürs Auge nicht auffällig.

Was für mich daran interessant war - der vom Compiler Erzeugte Assemblercode für die Ermittlung der nächsten Pinzustände sieht so aus:

9e:	80 91 67 00 	lds	r24, 0x0067
a2:	28 17       	cp	r18, r24
a4:	08 f4       	brcc	.+2      	; 0xa8 <__vector_3+0x4a>
a6:	bf c0       	rjmp	.+382    	; 0x226 <__vector_3+0x1c8>
a8:	90 e0       	ldi	r25, 0x00	; 0
...
fa:	98 2b       	or	r25, r24
fc:	9e 2b       	or	r25, r30
fe:	97 2b       	or	r25, r23
100:	96 2b       	or	r25, r22
102:	95 2b       	or	r25, r21
104:	94 2b       	or	r25, r20
106:	93 2b       	or	r25, r19
108:	90 93 64 00 	sts	0x0064, r25
...
226:	92 e0       	ldi	r25, 0x02	; 2
228:	40 cf       	rjmp	.-384    	; 0xaa <__vector_3+0x4c>

In r18 steht pwmpos und in R24 landet hier der gewünschte PWM-Wert eines Pins. Ergibt der Vergleich (Zeile a2), dass das Pin 1 wird, wird nach 0x226 gesprungen (Zeile a6), um dort das Ausgangsregister auf den entsprechenden Wert zu setzen (0x02 für das zweite Pin). Soll das Pin 0 sein, wird der genannte Sprung ausgelassen (Zeile a4). Nachdem für jedes Pin ein Register mit der entsprechenden 0 oder 1 belegt wurde, werden alles zusammengeodert, wie im c-Code vorgeschrieben.

Beim ersten flüchtigen Hinsehen habe ich die kurzen Sprünge (wie in Zeile a4) gesehen und gedacht, dass der die Bits in einer Schleife an die richtige Stelle schiebt …

zweiter Versuch in c

… das geht ja mal gar nicht, dachte ich und habe für einen anderen Ansatz einen Typ definiert, in dem ich in c die einzelnen Bits und das ganze Byte separat zugreifen kann:

typedef union {
	unsigned char value;
	struct a {
		unsigned char a : 1;
		unsigned char b : 1;
		unsigned char c : 1;
		unsigned char d : 1;
		unsigned char e : 1;
		unsigned char f : 1;
		unsigned char g : 1;
		unsigned char h : 1;
	} bits;
} bitfield;

Der geänderte Code zur Ermittlung des Zustandes der einzelnen Pins sah nun so aus:

	static bitfield portb;

	PORTB = portb.value;
	pwmpos++;

	portb.bits.a = LEDs[ 0] > pwmpos;
	portb.bits.b = LEDs[ 1] > pwmpos;
	portb.bits.c = LEDs[ 2] > pwmpos;
	portb.bits.d = LEDs[ 3] > pwmpos;
	portb.bits.e = LEDs[ 4] > pwmpos;
	portb.bits.f = LEDs[ 5] > pwmpos;
	portb.bits.g = LEDs[ 6] > pwmpos;

	PORTB = portb.value | 0x80;

Das Compilerergebnis sieht irgendwie nicht besser aus:

8e:	80 91 66 00 	lds	r24, 0x0066
92:	90 e0       	ldi	r25, 0x00	; 0
94:	48 17       	cp	r20, r24
96:	08 f4       	brcc	.+2      	; 0x9a <__vector_3+0x3c>
98:	91 e0       	ldi	r25, 0x01	; 1
9a:	20 91 64 00 	lds	r18, 0x0064
9e:	2e 7f       	andi	r18, 0xFE	; 254
a0:	29 2b       	or	r18, r25
a2:	20 93 64 00 	sts	0x0064, r18

Hier wird jetzt für jedes einzelne Pin der Wert ermittelt (Zeilen 8e bis 98) und mit dem Wert für den ganzen Port verodert - deutlich schlechter als Version 1 …

erster Versuch in c/inline Assembler

Auch ausgehend von der ersten Version - noch mit dem Gedanken an zu vermeidende Schleifen - habe ich mir ein stückchen Assembler überlegt, der das Drehen der Bits nicht in einer Schleife macht, sondern mit einer Art Duffs Device. Wenn man vorher inline-Assembler nur für NOPs verwendet hat (so wie ich), leistet das Inline Assembler Cookbook eine großartige Hilfe (vielen Dank von dieser Stelle aus).

static inline unsigned char shift_left (
	unsigned char para_in,
	unsigned char para_bitcount )
{
	asm volatile (
		// store adress of 0: on stack
		"rcall 0f"	"\n\t"
		"0:"		"\n\t"

		// laod address of 0: from stack into Z r31 gets the
		// upper, r30 the lower byte of the adress (in words)
		"pop r31"	"\n\t"	
		"pop r30"	"\n\t"
			
		"ldi r29, 14"	"\n\t"	// load immidiate into r29
		"sub r29, %r[bitcnt]"	"\n\t"

		// increase low adress byte by (14 - bitcnt)
		"add r30, r29"	"\n\t"	
		"adc r31, 0"	"\n\t"	// don't forget the carry bit
		"ijmp"		"\n\t"	// jump to the correct lsl

		"lsl %r[in]"	"\n\t"	// jump here for 7x shift
		"lsl %r[in]"	"\n\t"	// jump here for 6x shift
		"lsl %r[in]"	"\n\t"	// ...
		"lsl %r[in]"	"\n\t"
		"lsl %r[in]"	"\n\t"
		"lsl %r[in]"	"\n\t"
		"lsl %r[in]"	"\n\t"
		"mov %r[out], %r[in]"	"\n\t"

		// output operand
		: [out] "=&r" (para_in)
		// input operands
		: [in] "r" (para_in),
		  [bitcnt] "r" (para_bitcount)
		// clobber list - these objects are
		// modified by the code above
		: "r31", "r30", "r29" 
	);
	return para_in;
}

Und der zugehörige Code zur Ermittlung der Ausgangszustände sieht so aus:

	static unsigned char		portb;

	PORTB = portb;
	pwmpos++;

	portb =   ( shift_left ( LEDs[ 0] > pwmpos, 0 ) )
			| ( shift_left ( LEDs[ 1] > pwmpos, 1 ) )
			| ( shift_left ( LEDs[ 2] > pwmpos, 2 ) )
			| ( shift_left ( LEDs[ 3] > pwmpos, 3 ) )
			| ( shift_left ( LEDs[ 4] > pwmpos, 4 ) )
			| ( shift_left ( LEDs[ 5] > pwmpos, 5 ) )
			| ( shift_left ( LEDs[ 6] > pwmpos, 6 ) );

	PORTB = portb | 0x80;

Hier drin sind verschiedene Lapsi versteckt (z.B. hab ich den Eingabeparameter schon zur Ausgabe mitbenutzt, um das mov am Ende zu sparen, was aber noch drin steht …). Der Compiler hält sich an den Vorschlag, das Ergebnis braucht wesentlich mehr Programmspeicher und braucht ein vielfaches an Laufzeit.

dritter Versuch in c

Nach dieser Enttäuschung habe ich die im Prinzip entscheidende Verbesserung in c implementiert - wieder ausgehend von der ersten Version - Zustand des ersten Pins ermitteln, eins weiterschieben, beim nächsten Pin fortsetzen:

	static unsigned char		portb;

	PORTB = portb;
	pwmpos++;

	portb |= ( LEDs[ 6] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 5] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 4] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 3] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 2] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 1] > pwmpos ); portb <<= 1;
	portb |= ( LEDs[ 0] > pwmpos );

	PORTB = portb | 0x80;

Das resultiert in folgendem Assembler-Code:

9e:	80 e0       	ldi	r24, 0x00	; 0
a0:	23 17       	cp	r18, r19
a2:	08 f4       	brcc	.+2      	; 0xa6 <__vector_3+0x48>
a4:	81 e0       	ldi	r24, 0x01	; 1
a6:	98 2b       	or	r25, r24
a8:	80 91 6b 00 	lds	r24, 0x006B
ac:	99 0f       	add	r25, r25

In r18 steht pwmpos, r19 enthält die gewünschte PWM und in r25 baut sich der nächste Wert für PORTB zusammen. Das ist bis hier die sympatischste Version, weniger Sprünge, weniger Befehle …

zweiter Versuch in c/inline Assembler

Hmm dachte ich … mal schauen, wie sich cp (assembler compare) auf das Statusregister auswirkt. Ich hatte das Carry-Flag im Auge, das man mit lsc (linksschieben mit Carry) direkt verwenden kann. Siehe da - wenn cp's Parameter 1 kleiner ist als Parameter 2 ist das Carry-Bit gesetzt - ideal. In Inline-Assembler sieht das dann so aus:

static inline unsigned char cmp_carry_shift_left ( 
	unsigned char para_io,
	unsigned char cmp_para_1,
	unsigned char cmp_para_2 )
{
	asm volatile (
		// compare the parameters
		// (get the carry flag set/unset)
		"cp %r[cmp2], %r[cmp1]"	"\n\t"

		// rotate carry bit into paramter
		"rol %r[out]"		"\n\t"

		// output operand
		: [out] "+r" (para_io)		
		// input operands
		: [in] "r" (para_io),
		  [cmp1] "r" (cmp_para_1),
		  [cmp2] "r" (cmp_para_2)
	);
	return para_io;
}

man kommt ganz ohne Sprünge aus und braucht nur 2 Befehle bzw. Takte pro Pin plus Overhead :).

	static unsigned char portb;

	PORTB = portb;
	pwmpos++;

	portb = 0;
	portb = cmp_carry_shift_left ( portb, LEDs[ 6], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 5], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 4], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 3], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 2], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 1], pwmpos );
	portb = cmp_carry_shift_left ( portb, LEDs[ 0], pwmpos );

	PORTB = portb | 0x80;

compiliert zu folgendem vermutlich optimalen Assemblercode:

9e:	90 81       	ld	r25, Z
a0:	39 17       	cp	r19, r25
a2:	88 1f       	adc	r24, r24

Gewünschte PWM in r25 laden, mit pwmpos vergleichen und Ergebnisbit in r24 reinrotieren.

Zusammenfassung

Code wird offensichtlich nicht linear besser - besonders deutlich wird dasbei einem Blick auf folgende Messwerte (MCU: ATMega8, Takt: interner 8-MHz-Oszillator):

Port D berechnen (6 PWMs) Ports B, C, D (20 PWMs)
erster Versuch in c 11..13 us 19..26 us
zweiter Versuch in c 21 us 33..34 us
erster Versuch in c/inline Assembler 40 us 66 us
dritter Versuch in c 9 us 21 us
zweiter Versuch in c/inline Assembler 4,8 us 12,6 us