O multipleksowaniu wyświetlaczy LED…

Naszła mnie mała ochota na podzielnie się kilkoma uwagami (typowe marudzenie) w kontekście sterowania multipleksowego wyświetlaczy LED. Jest kilka rzeczy jakie mi się nie podobają, przeszkadzają lub są często powtarzane na forach, czy też powielane w różnych projektach.

Na początek warto wspomnieć o fakcie, że mimo upływu lat, prócz popularnych ciekłokrystalicznych wyświetlaczy alfanumerycznych LCD (obecnie często zastępowane przez graficzne/kolorowe LCD/LED/OLED), 7-segmentowe LED-y są często wykorzystywane w różnych układach mikroprocesorowych i nie tylko. Jedną z ich niezaprzeczalnych zalet jest szeroki wybór rozmiaru wyświetlanych cyfr oraz ich jasność świecenia. Przez co są one znacznie bardziej wyraziste i czytelne od innych, co czasem ma istotne znaczenie.

Gdy takich wyświetlaczy jest kilka (kilka cyfr) w układzie, wtedy najczęściej sterowane są one dynamicznie z wykorzystaniem multipleksowania. Niewątpliwie, niekwestionowaną zaletą tego rozwiązania jest minimalna liczba wymaganych połączeń, gdyż współdzielona jest magistrala danych, wykorzystywana naprzemiennie przez każdą grupę segmentów. A także pobór prądu, bo w danej chwili świeci tylko jedna grupa (cyfra). Oczywiście pociąga to za sobą nieco bardziej rozbudowaną implementację programową.

Ponoć już przy szybkim zapalaniu i gaszeniu kolejnych elementów wyświetlacza z częstotliwością 50Hz, bezwładność oka ludzkiego daje złudzenie ciągłego świecenia wszystkich elementów. Różne źródła podają różne wartości optymalnej częstotliwości przełączania, przeważnie 100 lub 200Hz. Z moim testów wynika, że w zupełności wystarczy przełączać wyświetlacze (cyfry) z częstotliwością 100Hz. Nie zauważyłem przy takiej szybkości żadnych efektów ubocznych.

[foto w renderingu]

Na różnych forach i w różnych projektach, wielokrotnie spotykałem się z tezą, że „multipleksowanie robi się na przerwaniach„, dobitnie uwydatniając przy tym tezę, że jeśli robisz to inaczej to robisz to źle i jest to wielki błąd. A czy jest tak faktycznie?

Oczywiście, że nie! Ale, jak to zawsze bywa, to zależy… Można zgodzić się z tym, że wymaga to zastosowania timera, a co za tym idzie i skorzystania z jego przerwania. Bo w istocie potrzebne jest (w miarę) dokładne odmierzenie czasu, aby w porę przełączać poszczególne wyświetlacze. Niekoniecznie jednak trzeba od razu wrzucać wszystko w przerwania liczników (timerów). Czasem z różnych powodów nie ma takiej możliwości.

Wtedy poza manipulacjami odpowiednimi flagami informującymi o potrzebie odświeżenia (przełączenia) aktualnego wyświetlacza w przerwaniu, całą obsługę można zostawić w pętli głównej. Czyli standardowe podejście w świecie mikrokontrolerów. A czy przełączenie spóźni się kilka cykli procesora, kilka mikrosekund, nie ma większego znaczenia. Bo co to zmieni? Oko ludzkie i tak tego przecież nie wychwyci.

Rzadko ostatnio bawię się z mikrokontrolerami, ale był to dobry motyw, aby coś na szybko podłączyć i poeksperymentować. Bo poza pracą, gdzie i tak głównie reverse enginerring i hackowanie, to po godzinach pracy, nawet kodu nie chce mi sie zbytnio pisać, niezależnie na jaką miałoby to być platformę czy architekturę.

Na wygrzebanej płytce EvB 5.1 (rzadko korzystam z takich deweloperskich i testowych płytek, ale czasem nie chce się znów łączyć masy przewodów na prototypowych) podłączyłem ATmegę 644P do wyświetlacza 7-segmentowego z czterema cyframi, ze wspólną anodą. Port C procsora pełni rolę magistrali danych, a niższa (młodsza) połówka portu B rolę sterownika dla tranzystorów kluczujących poszczególne cyfry. Całość wygląda mniej więcej tak jak na poniższym schemacie.

[schemat w renderingu]

Jakoś do tej pory nie udało mi się ogarnąć planowanych bibliotek, ani zebrać gdzieś w jednym miejscu wspólnych definicji, często wykorzystywanych w różnych miejscach. Dlatego na początek zapodałem sobie odpowiednie dane do wyświetlaczy 7-segemntowych.

const uint8_t SegDigitData[] = {
 
			// P  7654 3210
			// p  hgfe dcba
	0x3F,	// 0  0011 1111
	0x06,	// 1  0000 0110
	0x5b,	// 2  0101 1011
	0x4f,	// 3  0100 1111
	0x66,	// 4  0110 0110
	0x6d,	// 5  0110 1101
	0x7d,	// 6  0111 1101
	0x07,	// 7  0000 0111
	0x7F,	// 8  0111 1111
	0x6F,	// 9  0110 1111
	0x80,	// .  1000 0000
 
};
 
const size_t SegDigitCount = sizeof(SegDigitData)/sizeof(SegDigitData[0]);

Chciałem zapisać to według typowej konwencji używanych w scalonych dekoderach, ale w większości układów segment A podłączany jest do najbardziej znaczącego bitu magistrali, więc taka forma wydawała mi się najodpowiedniejsza. Dlatego, jako aktywny stan (zapalenia) segmentu jest logiczna 1. Odpowiada to wyświetlaczom ze wspólną katodą. Ale dla wersji ze wspólną anodą wystarczy tylko odwrócić (zanegować) bity odpowiednim operatorem.

Dalej już można bezproblemowo utworzyć docelowy kod z pełną obsługą wyświetlaczy zawarty w przerwaniu jednego z timerów. Naturalną czynnością było zapisanie tego w bardzo prostej i zwięzłej postaci, którą ciężko spotkać w innych projektach:

const size_t DigitsCount = 4;
uint8_t digits[DigitsCount] = {};
uint8_t currDigit = 0;
 
ISR(TIMER0_COMPA_vect) {
 
	const uint8_t pos = 1 << currDigit;
	const uint8_t dig = digits[currDigit];
 
	LedDrive = ~pos;
	LedData = ~SegDigitData[dig];
 
	currDigit++;
 
	if (currDigit == DigitsCount)
		currDigit = 0;
 
}

Nawet nie wyobrażam sobie niewykorzystania tablicy (digits) do przechowywania danych do wyświetlania przez poszczególne wyświetlacze. Ale to jeszcze nic, bo często można spotkać rozległy kod oparty na instrukcji switch, w którym dla każdego przypadku powielany jest ten sam fragment kodu, ze zmianą tylko 1 lub 2 wartości. I powstają wtedy takie potworki, wołające o pomstę do nieba:

switch (currDigit) {
	case 0:
		LedDrive = ~1;
		LedData = ~SegDigitData[digits[0]];
		currDigit++;
		break;
 
	case 1:
		LedDrive = ~2;
		LedData = ~SegDigitData[digits[1]];
		currDigit++;
		break;
 
	case 2:
		LedDrive = ~4;
		LedData = ~SegDigitData[digits[2]];
		currDigit++;
		break;
 
	case 3:
		LedDrive = ~8;
		LedData = ~SegDigitData[digits[3]];
		currDigit = 0;
		break;
}

Nie dość, że jest to rozlegle, mało czytelne i nie elegenckie, to jeszcze wynikowy kod, niezależnie od optymalizacji, będzie dłuższy i paskudny, bo dla każdego przypadku kompilator wygeneruje bardzo podobny fragment kodu. Czemu nikt nie pomyśli, nie zastanowi się i nie napisze tego po ludzku ;)

Zdecydowanie wersja generyczna jest bardziej odpowiednia i prosta w utrzymaniu. Wszelkie zmiany przy multipleksowaniu, jak rozbudowa o kolejne cyfry, ogranicza się jedynie do modyfikacji tablicy i zarzadzania jej danymi, bez jakichkolwiek zmian w kodzie zarządzającym wyświetlanie… A dane do wyświetlania są przygotowywane w głównej pętli programu, w której kreci się bezustannie nasz mikrokontroler.

unsigned int count = 0;
while (true) {
 
	auto num = count;
	for (int i = DigitsCount - 1; i >= 0; i--) {
 
		digits[i] = num % 10;
		num /= 10;
	}
 
	count++;
	_delay_ms(100);
}

Dane w tablicy digits są układane od tyłu, najpierw setki, dziesiątki, jedności, itd…, czyli od najmniej znaczący pozycji do najbardziej. A wszytsko po to, aby indeksy tablicy odpowiadały bitom sterującym tranzystorów multipleksujących wyświetlacze. Bez tego trzeba byłoby przy każdym odświeżaniu dokonywać dodatkowych operacji dodawania/usuwania w przerwaniu, żeby wyznaczyć odpowiedni indeks tablicy dla aktualnie przełączanej cyfry.

Można pokusić się jeszcze bardziej i zamiast cyfr przechować już tylko czyste dane o wymaganych segmentach do zaświecenia jakie należy podać na magistrale danych. Wtedy w przerwaniu będzie naprawdę bardzo mało instrukcji do wykonania związanych z całym multipleksowaniem. Niestety, gdy dane te są potrzebne w programie także do innych celów, to idea taka odpada. No chyba, że zawartość tablicy digits bedzie dedykowana do obsługi 7-segmentowców i nie będzie do niczego innego, sensownego się nadawała. Bo do czego jeszcze mogą przydać się rozbite na osobne elementy (cyfry) jakieś wartości (liczby)? Może do szybkiej zamiany na odpowiednie wartości w kodzie ASCII i ich tekstową reprezentację :)

Przechodząc dalej w kierunku minimalnego kodu obsługi przerwania, przenosząc całe sterowanie i odświeżanie wyświetlaczy do kodu głównej pętli, zmienia się nieco podejście. Teraz będzie ono oparte na typowej obsłudze zdarzeń (akcji), których wykonanie zależne będzie od ustawionych odpowiednich bitów w jakimś rejestrze tudzież flagach. A pętla główna będzie cały czas sprawdzać czy jakaś akcje nie powinna być wykonana.

W moim przykładowym kodzie są dwie główne akcje:

enum Action : uint8_t {
	UpdateDisplay	= 0x01,
	UpdateCount		= 0x02,
};

Jedna to sterowanie podłączonymi wyświetlaczami – ich multipleksowanie. A druga odpowiada za zwiększanie licznika, którego wartość prezentowana jest na wyświetlaczach wraz z przygotowaniem gotowych danych do prezentacji.

Kod zawarty w przerwaniu bardzo się upraszcza. Jedynym jego zadaniem jest ustawianie odpowiednich bitów w zmiennej flagowej, zależnie od upłynnionego czasu:

volatile uint8_t ActionFlag = 0;
 
uint8_t ticks = 0;
 
ISR(TIMER0_COMPA_vect) {
 
	// on every ~2ms, 400Hz = 2.5
	ActionFlag |= Action::UpdateDisplay;
 
	// on very 100ms
	if ((ticks % 100) == 0)
		ActionFlag |= Action::UpdateCount;
 
	ticks++;
}

Tutaj dla testowego przykładu wykorzystałem operacje modulo, jest to kosztowna operacja (AVR-y nie posiadają sprzętowego dzielenia) jak na nasze proste i szybkie przerwanie. W docelowym układzie lepiej byłoby zastosować jakieś typowe rozwiązanie w stylu sprawdzania wartości licznika i jego zerowania po osiągnieciu granicznej wartości.

Cała logika znajduje się obecnie w głównej pętli programu, wykonując odpowiednie akcje zależnie od ustawionych „flag„:

while (true) {
 
	if (UpdateCount) {
		count++;
 
		auto num = count;
		for (int8_t i = DigitsCount - 1; i >= 0; i--) {
 
			uint8_t v = num % 10;
			num /= 10;
 
		#if NO_DISPLAY_LEADING_ZEROS
			if (v == 0 && num == 0)
				v = ~0;
		#endif
 
			digits[i] = v;
		}
 
		UpdateCount = false;
	}
 
	if (UpdateDisplay) {
 
		const uint8_t pos = 1 << currDigit;
		const uint8_t dig = digits[currDigit];
 
		LedDrive = ~pos;
		LedData = ~SegDigitData[dig];
 
		currDigit++;
 
		if (currDigit == DigitsCount)
			currDigit = 0;
 
		UpdateDisplay = false;
	}
 
}

Dodatkowo dodałem możliwość wygaszania wiodących zer, co widoczne jest dla wartości liczbowych o długości mniejszej od ilości cyfr wyświetlacza. Bardzo prosto to zrobić w czasie przygotowywania danych do wyświetlania. Dla niepotrzebnych cyfr wystarczy ustawić wygaszenie wszystkich segmentów.

const size_t DigitsCount = 4;
uint8_t digits[DigitsCount] = { 0xFF, 0xFF, 0xFF, 0xFF };

Warto także zainicjować tablice digist podobnymi danymi, czyli 0 lub jego negacją – 0xFF, aby po stracie układu, dla wartości zerowej licznika oraz spoczynku, wyświetlacze były wygaszone.

Na koniec mam jeszcze kilka dodatkowych uwag i potencjalnych problemów na jakie można trafić przy zaawie z multipleksowaniem.

Przy szybkim przełączaniu cyfr wyświetlacza mogą pojawiać się prześwity, tzw. duchy. Ja w swoich przykładach tego nie zaobserwowałem. Ale w czasie kilku cykli, wynikłych z opóźnienia pomiędzy wpisaniem nowej wartości na magistrale danych, a zmianą wysterowania tranzystorów kluczujących cyfry, jeden z wyświetlaczy wyświetla dane sąsiada. Można się tego pozbyć poprzez wygaszenie poprzedniego wyświetlacza przed wpisaniem nowych danych na magistrale, a dopiero następnie włączenie aktualnej cyfry.

W moim przykładowym kodzie, sterowanie tranzystorami następuje przez wpisanie odpowiedniej wartości na port D, nadpisując przy tym drugą, akurat nieużywaną, połówkę portu. Dlatego, że jest nieużywana, nie ma to żadnego znaczenia w tym przypadku. Ale w wielu sytuacjach piny są do czegoś używane, a takie operacje na pewno będą wprowadzać pewne niespodziewane błędy i problemy w poprawnym działaniu układu.

Nieco lepszym rozwiązaniem byłoby zastosowanie maski bitowej i modyfikacja tylko niezbędnej połówki portu (sterującej tranzystorami kluczującymi), na przykład w taki standardowy sposób:

#define LedDriveMask	0x0F
LedDrive = (LedDrive & ~LedDriveMask) | (~pos & LedDriveMask);

Niestety to również nie jest dobrym rozwiązaniem, gdyż wykonanie tych operacji nie będzie atomowe. W przypadku, gdy stan tych pinów zmieni się pomiędzy odczytem a zapisem do portu, to znów wprowadzi to niezdefiniowane działanie układu.

Idealnym rozwiązaniem byłyby operacja na samych bitach. Nieco modyfikując kod obsługi sterowania wyświetlaczami, można to osiągnąć bardzo prosto, zmieniając miejsce inkrementacji zmiennej currDigit:

// off previous LED group
LedDrive |= (1 << currDigit);
 
currDigit++;
 
if (currDigit == DigitsCount)
	currDigit = 0;
 
LedData = ~SegDigitData[digits[currDigit]];
 
// on current LED group
LedDrive &= ~(1 << currDigit);

Oczywiście w pierwszej iteracji tuż po starcie programu, wyświetlenie pierwszej cyfry zostanie pominięte. Nie wpłynie to jednak na cały efekt, bo kto zauważy kilku milisekundową przerwę ;)

Pełny kod przykładowych implementacji, powstałych w ramach tej notatki, wkrótce gdzieś wrzucę ostatecznie wrzuciłem na swojego gista, w ramach github-owego konta. Zainteresowane osoby będą mogli przejrzeć cala implementację i sobie poeksperymentować w razie potrzeby.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *