Jak radzić sobie z błędami w oprogramowaniu?

| Technika

Błędy dostarczają inżynierom oprogramowania wielu tematów do opowieści. Podczas, gdy reszta populacji wolałaby raczej siedzieć na tłuczonym szkle, niż słuchać anegdoty kończącej się słowami „… i wtedy zdałem sobie sprawę, że to powinien być 16-bitowy licznik, ha, ha, ha”, inni inżynierowie uwielbiają słuchać o paskudnych sztuczkach, jakie potrafi robić nasz kod. W tym artykule zajmiemy się kilkoma z bardziej zdradzieckich błędów. Pierwszy zestaw błędów zawiera zmiany, które nie powinny powodować żadnej różnicy w działaniu systemu, ale o dziwo, powodują. Opcje optymalizacyjne są pomyślane tak, by nie wprowadzać różnic w działaniu, jedynie zmienić czas wykonania i rozmiar programu. W systemach czasu rzeczywistego przyspieszenie pewnych fragmentów kodu może powodować powstanie wyścigów, ale są jeszcze inne subtelne skutki optymalizacji, które czasem mogą zaskakiwać. Wiele kompilatorów ma opcję, która pozwala zaoszczędzić miejsca poprzez przechowywanie tylko jednej kopii identycznych napisów. Na przykład, jeśli napis „hello” pojawia się w kodzie trzykrotnie, to tylko jedna kopia jest faktycznie tworzona, a w pozostałych miejscach w kodzie jest odwołanie do tejże kopii. Ponieważ ta technika ma zastosowanie wyłącznie do stałych napisów, nie ma niebezpieczeństwa, że jakiekolwiek odwołanie spowoduje modyfikację napisu.

Jak radzić sobie z błędami w oprogramowaniu?
Błędy dostarczają inżynierom oprogramowania wielu tematów do opowieści. Podczas, gdy reszta populacji wolałaby raczej siedzieć na tłuczonym szkle, niż słuchać anegdoty kończącej się słowami „… i wtedy zdałem sobie sprawę, że to powinien być 16-bitowy licznik, ha, ha, ha”, inni inżynierowie uwielbiają słuchać o paskudnych sztuczkach, jakie potrafi robić nasz kod. W tym artykule zajmiemy się kilkoma z bardziej zdradzieckich błędów.

Pierwszy zestaw błędów zawiera zmiany, które nie powinny powodować żadnej różnicy w działaniu systemu, ale o dziwo, powodują. Opcje optymalizacyjne są pomyślane tak, by nie wprowadzać różnic w działaniu, jedynie zmienić czas wykonania i rozmiar programu. W systemach czasu rzeczywistego przyspieszenie pewnych fragmentów kodu może powodować powstanie wyścigów, ale są jeszcze inne subtelne skutki optymalizacji, które czasem mogą zaskakiwać.
Wiele kompilatorów ma opcję, która pozwala zaoszczędzić miejsca poprzez przechowywanie tylko jednej kopii identycznych napisów. Na przykład, jeśli napis „hello” pojawia się w kodzie trzykrotnie, to tylko jedna kopia jest faktycznie tworzona, a w pozostałych miejscach w kodzie jest odwołanie do tejże kopii. Ponieważ ta technika ma zastosowanie wyłącznie do stałych napisów, nie ma niebezpieczeństwa, że jakiekolwiek odwołanie spowoduje modyfikację napisu.
Pewnego razu zdarzyło się, że w kodzie znaleziono błąd, którego nie było w poprzedniej wersji. Analiza zmian dokonanych w stosunku do poprzedniej wersji pozwoliła na zawężenie poszukiwań tylko do wspomnianej wcześniej opcji kompilatora. Dokładniejsza analiza pokazała, że pewna niefrasobliwość spowodowała, że kod był wrażliwy na skutki tej optymalizacji. Rysunek 1 pokazuje w uproszczoną wersję kodu.
Ostatnia instrukcja IF porównuje dwa wskaźniki. Są to dwie zaszyte kopie napisu „hello”. Oznaczmy przez hello1 i hello2 miejsca w pamięci, w których te kopie się znajdują. Po zbyt wielu dniach używania w C++ klasy string, która miała przeładowany operator == do porównywania zawartości dwóch obiektów (tj. napisów), ta sama logika została zastosowana do surowych wskaźników w C. Jednak operator == zastosowany do wskaźników nie sprawdza, czy napisy są takie same. Sprawdza, czy wskaźniki odnoszą się do tego samego miejsca w pamięci. Z wyłączona optymalizacją hello1 i hello2 to inne miejsca, więc nawet jeśli x wskazuje na „hello” a y wskazuje na „hello” w innym miejscu pamięci, to ostatecznie wynikiem porównania będzie FALSE.
Natomiast z włączoną optymalizacją to się zmieni. Ponieważ oba napisy są identyczne kompilator przechowuje tylko jedną kopię napisu „hello” i hello1 oraz hello2 odnoszą się teraz do tego samego obszaru pamięci. Wykonanie instrukcji x = „hello” i y = „hello” powoduje, że wynikiem ostatniego porównania będzie TRUE.
Jak w przypadku wielu drobnych zmian, przełączenie opcji optymalizacji byłoby zupełnie bez znaczenia, gdyby nie błąd w kodzie: if(x==y) nie było właściwym sposobem porównania. Zamiast tego powinna być użyta funkcja strcmp() lub jakiś jej odpowiednik.
Przy tej okazji warto wspomnieć, że w odniesieniu do typów innych niż proste operatory „==” i „=” powinny być używane dość ostrożnie. Przy programowaniu w C++ na ogół intencją programisty używającego „==” jest porównanie dwóch obiektów, nie zaś wskaźników. Jeśli z analizy kodu wynika, że porównywane są dwa wskaźniki, to warto zwrócić baczną uwagę, na ten fragment kodu, gdyż może być on źródłem błędów. W przypadku bardziej złożonych obiektów warto się też dobrze zastanowić, co właściwie znaczy, że są one „równe”. Zdarza się, że intencje programisty definiującego operator „==” i programisty go używającego są w tym względzie rozbieżne.
Podobnie jest w przypadku „=”. Domyślna wersja tego operatora przypisuje wskaźniki, podczas gdy zazwyczaj chcemy przypisać zawartość odpowiednich obszarów pamięci. Oczywiście, nierzadko zdarza się, że przypisania wskaźników są zamierzone i zupełnie poprawne. Sprawa się trochę komplikuje, gdy w obiekcie jest np. tablica wskaźników do obiektów (tego samego lub innego typu). Wtedy nawet przekopiowanie zawartości tablicy, zamiast tylko wskaźnika do tej tablicy, nie zawsze rozwiązuje problem. Niewłaściwe zdefiniowanie bądź użycie operatora „=” prowadzi zazwyczaj do nieprzyjemnych błędów przy zwalnianiu pamięci.

Jurassic Park

W filmie Jurassic Park jest scena, w której dr Malcolm odkrywa, że ich metoda sprawdzania, czy wszystkie dinozaury są obecne polegała na liczeniu zwierząt, przy czym po osiągnięciu oczekiwanej liczby przestawano liczyć. Fakt, że liczba dinozaurów się zwiększała, pozostał niezauważony, ponieważ nigdy nie próbowano liczyć ponad spodziewaną liczbę. Malcolm sugeruje, żeby komputer szukał znacznie większej liczby dinozaurów, i kiedy liczba znalezionych okazuje się większa od pierwotnej, grupa zdaje sobie sprawę, że mają znacznie więcej prehistorycznych zwierząt, niż kiedykolwiek przypuszczali. Można to nazwać „błędem Jurassic Park”. Polega on na ustaleniu górnego ograniczenia na pewną wartość w przekonaniu, że większe wartości nie wystąpią, lub, jeśli nawet wystąpią, to nie ma to znaczenia. To uproszczenie często czyni programowanie łatwiejszym, ale powoduje, że te nieoczekiwane stany będą dla systemu niewidoczne.
Czy w Waszym oprogramowaniu występuje ten błąd? Jeśli pewna wartość jest przechowywana jako liczba 8-bitowa, liczenie kończy się na 255. Ta wartość powinna być traktowana jako oznaczenie błędu, albo też w tym miejscu nie powinna być już dalej inkrementowana a pozostawiona jako 255, jako przybliżenie największej możliwej wartości. Która możliwość jest najlepsza? – To zależy od tego, czy licznik normalnie może osiągnąć 255, czy też zawsze jest to oznaka błędu.
Podczas wykonywania działań na przeskalowanych wartościach przydatne jest ograniczenie tychże tak, by użycie wartości z poza zakresu nie prowadziło do przepełnienia. Załóżmy, że w pewnym przypadku ograniczono wartość mierzonego przepływu gazu do 25l/min. W związku z tym, że system steruje przepływem do 15l/min, przyjęto, że nawet jeśli wystąpi przepływ powyżej 25l/min, to do tego celu można go potraktować jako równy 25l/min.
W niektórych przypadkach przepływ rzeczywiście przekraczał 25l/min, ale zgodnie z wcześniejszym założeniem nie było to uznawane za istotne. Jednym z takich przypadków był test porównujący wskazania dwóch identycznych czujników. Zakładając, że czujniki działały poprawnie i były odpowiednio skalibrowane, wartość przepływu pochodząca z każdego z nich powinna mieścić się w zakresie tolerancji drugiego. Kiedy ciśnienie zewnętrznego zasilania gazem było bardzo duże, mogło się zdarzyć, że test ten został wykonany przy przepływie większym niż 25l/min. Zakładając, że wystąpił dryft kalibracji jednego z czujników, wartości z czujników mogły być równe, na przykład 26 i 29l/min. Obie zostały następnie zaokrąglone do 25l/min z powodów opisanych wcześniej. Zatem podczas porównywania obu odczytów obie wartości były równe. Tym samym test porównawczy nie działał przy przepływach powyżej 25l/min. Warto zauważyć, że błąd by nie wystąpił, gdyby ktoś nie chciał sobie ułatwić pracy i nie użył funkcji odczytującej wartość przepływu – która miała być wykorzystywana do sterowania – do celów kalibracji, bez dokładnego zapoznania się z jej działaniem.
W powyższym przykładzie wartość przepływu została ograniczona programowo. Nawet jeśli nie wprowadzi się w oprogramowaniu tego typu ograniczenia, to może ono zostać wymuszone przez sprzęt. Na przykład napięcie wyjściowe z czujnika może być ograniczone, co przekłada się na ograniczenie odczytywanych wartości. W tej sytuacji można by doradzić używanie zawsze czujników o większym zakresie, niż może być kiedykolwiek w danym systemie wykorzystany. Jednakże warto pamiętać, że wybór czujnika o większym zakresie na ogół oznacza stratę rozdzielczości. Z kolei czujnik o węższym zakresie i większej rozdzielczości pozwoli osiągnąć większą dokładność, ale może pozostawiać pewien obszar, w których nie wiadomo, co się dzieje.
Podobne błędy mogą też występować w dziennikach zdarzeń. Rozważmy urządzenie, które zapisuje nietypowe zdarzenia w dzienniku. Miejsce na przechowywanie zapisów jest ograniczone i tworzony jest dziennik na 30 zdarzeń. Każdy wpis stanowi strukturę zawierającą szczegóły zdarzenia, czas i, być może, aktualne ustawienia urządzenia. Pełny dziennik zawiera 30 zdarzeń. Jednakże, jeśli wystąpi 50 zdarzeń, to dziennik nadal zawiera tylko 30 z nich, więc rzeczywista liczba i charakter zdarzeń pozostaje nieznany. W tym wypadku problem może zostać złagodzony poprzez wprowadzenie jednego dodatkowego pola, które zawiera liczbę niezapisanych zdarzeń. Nie dostarcza to informacji o szczegółach tych zdarzeń, ale przynajmniej daje informację, że coś zostało pominięte. W trakcie eksploatacji urządzenia ten licznik może posłużyć jako wskaźnik, czy warto zainwestować w dodatkowe miejsce na dziennik zdarzeń – zakładając, że jest to względnie łatwo konfigurowalne.

Ale ja tylko zmieniłem komentarz

Jeden z programistów pracował nad sumami kontrolnymi używanymi do sprawdzenia, czy uaktualnione wersje oprogramowania zostały załadowane bez błędów. Dla sprawdzenia notował wartości sum kontrolnych dla każdego z plików wykonywalnych. Za którymś razem stwierdził, że znalazł coś, co jego zdaniem musi być błędem kompilatora. Jako że kompilator miał sporo błędów, jeden więcej nikogo nie zdziwił.
W programie został zmieniony komentarz, po czym suma kontrolna była różna od poprzednio otrzymanej. W normalnym świecie zmiana komentarza nie powinna mieć wpływu na wygenerowany kod, więc suma kontrolna powinna być identyczna przed i po zmianie komentarza. Natychmiastowym założeniem było, że została zmieniona któraś z opcji kompilatora, co mogło spowodować zmianę w pliku wykonywalnym bez żadnej zmiany w kodzie źródłowym. Ale nie było takiej zmiany. Sprawdzono też, czy może program był kompilowany na innym komputerze, z inną wersją kompilatora. Ale to także nie była właściwa odpowiedź.
W kodzie był przechowywany numer wersji. W niektórych projektach ten numer był uaktualniany automatycznie przy każdej kompilacji, zatem nie powstawały nigdy dwa identyczne efekty kompilacji. Jednakże w tym projekcie numer zmieniał się tylko wtedy, gdy został ręcznie zmieniony przez programistę. Tym razem się nie zmienił, zatem nie mógł spowodować zmiany sumy kontrolnej.
Kolejnym przypuszczeniem było to, że może gdzieś został wstawiony jakiś znacznik czasu. Jeśli znacznik czasu byłby zapisany w pliku wykonywalnym, to wtedy suma kontrolna zmieniłaby się nawet, jeśli w kodzie nigdzie nie byłoby odwołań do takiego znacznika. Po przekopaniu się przez dokumentację kompilatora nie natrafiono na żadną wzmiankę o takiej technice. Ewentualny wpływ daty kompilacji również został wykluczony przez ponowne przekompilowanie kodu – bez zmian. Czas się zmienił, ale suma kontrolna została ta sama, zatem nie był to problem związany ze znacznikiem czasu.
Używając narzędzia do porównywania plików sprawdzono miejsca w przekompilowanym kodzie, gdzie zaszły jakiekolwiek zmiany. Jeśli byłoby możliwe znalezienie jednego takiego miejsca w pliku binarnym, można by odnieść je do kodu źródłowego. Okazało się, że zmian jest dużo i są rozsiane po całym module, w którym zmieniono komentarze. Wyglądało to jak zmiana opcji kompilacji, gdyż to spowodowałoby zmiany w wielu miejscach, ale tę możliwość już wcześniej wyeliminowano. Rozmiar kodu również się nie zmienił, co sugerowało, że to nie optymalizacja była przyczyną.
Przy kolejnym spojrzeniu na zmieniony komentarz zauważono, że została dodana jedna linia. Nie zmieniało to sensu programu, ale zmieniało numerację linii. Dopiero wtedy wszystko stało się jasne. W kodzie często było używane makro assert(). To makro używa wbudowanego makra kompilatora __LINE__, które pozwala na określenie numeru linii w kompilowanym kodzie. Jeśli zmieniły się numery linii, to wartości makra __LINE__ używane w kolejnych wywołaniach assert() były inne, i te właśnie wartości znalazły się w pliku wykonywalnym.
W ten sposób tajemnica sum kontrolnych została rozwikłana. Faktycznie okazało się, że nie był to nawet błąd, bo nie powoduje to zupełnie żadnych problemów dopóki programiści są świadomi, że zmiana komentarza może wywołać taki skutek.

Paskudne kompilatory

Rys. 1. Kompilator może dodać drobne zmiany do tego kodu

W poprzednim rozdziale podejrzewano producenta kompilatora, który jednak okazał się niewinny. Niestety, nie zawsze tak musi być, a błędy w samym kompilatorze są bardzo trudne do znalezienia, i to nie tylko dlatego, że programista zasadniczo nie ma dostępu do kodu źródłowego kompilatora.
Swego czasu można było trafić na kompilator, który regularnie błędnie wskazywał numery linii, w których wykrył błędy składniowe. Była to pewna niedogodność, ale jeśli poszukało się kilka linii wstecz od miejsca wskazanego przez kompilator, znajdowało się linię, w której znajdował się błąd. W niektórych przypadkach numer linii podawany przez kompilator się zgadzał, czasami różnił się o 10 lub więcej. W miarę postępu projektu i zwiększania się liczby i rozmiarów plików problem stawał się coraz wyraźniej widoczny. Przy wykonaniu krokowym programu linie podawane przez debuger nie były tymi, które były rzeczywiście wykonywane. Było to znacznie poważniejsze niż niepoprawne raporty o błędach składniowych, gdyż w niektórych przypadkach uniemożliwiało prześledzenie kolejności wykonania kodu.
Producent kompilatora zapewniał, że przyczyna tkwi w optymalizacji, która spowodowała, że niektóre linie nie były faktycznie wykonywane. To mogło być wygodne wytłumaczenie problemu z debugerem, ale nie wyjaśniało błędnego lokalizowania błędów przez kompilator.
I tu na scenę znów wkracza makro __LINE__. W niektórych przypadkach, jak wspomniane już makro assert(), makro __LINE__ jest używane przez programistów do podawania w czasie wykonania programu miejsca w kodzie, w którym zachodzą pewne zdarzenia. Jednakże kompilator również może wykorzystywać to makro dla swoich wewnętrznych zastosowań. Wartość __LINE__ może zostać zmieniona dyrektywa #line. Jeśli komuś nie odpowiada sposób, w jaki kompilator numeruje linie, można to zmienić np. w ten sposób:
#line 2000
Oczywiście spowoduje to, że wszystkie błędy składniowe będą zaraportowane ze zmienionymi numerami linii.
Kod wygenerowany przez kompilator może używać takich dyrektyw #line do synchronizacji numerowania linii z oryginalnym kodem źródłowym. Ta technika była powszechnie używana w kompilatorach C++ generujących kod w C. Wygenerowany kod w C zawierał dyrektywę #line na początku każdego bloku odpowiadającego pojedynczej linii w C++. Zazwyczaj plik C był znacznie większy niż oryginalny plik C++, ale dyrektywy #line zapewniały, że numer linii pokazywany użytkownikowi odnosił się do właściwej lokalizacji w kodzie źródłowym w C++.
Podobna technika jest wykorzystywana przez cpp – preprocesor C. Rozwija on wszystkie makra, usuwa komentarze, wstawia zawartość plików wymienionych w dyrektywach #include. W wyniku tworzony jest pojedynczy plik, który następnie jest przetwarzany przez kompilator.
Oczywiście, ten pojedynczy plik jest znacznie większy, niż oryginalny plik ze względu na to, że jest wiele plików włączanych przez #include. Wyjście cpp jest zazwyczaj plikiem tymczasowym, którego użytkownik może nawet nie widzieć, choć większość kompilatorów daje możliwość zachowania go w celu ewentualnego przeanalizowania. Tak też zrobiono w opisywanym przypadku. Na rysunku 2 przedstawiono przykładowe dwa małe pliki i plik wygenerowany przez cpp. W każdym z plików nagłówkowych dyrektywa #line jest używana do oznaczenia numeru linii i nazwy pliku, które powinny być podane w ewentualnym komunikacie o błędzie.
Na przykład, jeśli wystąpiłby błąd w poleceniu typedef w pliku header.h, dyrektywa #line mówi kompilatorowi, że bieżąca linia, z punktu widzenia użytkownika, jest linią 4. Dyrektywa #line ma opcjonalny drugi argument, który oznacza nazwę pliku. Jest to niezbędne, gdyż trzeba rozróżnić od siebie poszczególne pliki nagłówkowe i plik główny po tym, jak zostaną połączone w jeden plik wyjściowy cpp.

Rys. 2. Dwa małe pliki i plik wygenerowany przez cpp

Po przeanalizowaniu tego pliku dostrzeżono problem polegający na tym, że dyrektywa #line następująca po nagłówku wskazywała, że następną linią powinna być np. linia 4, podczas gdy w rzeczywistości następna linia była piątą linią pliku źródłowego. Od tego miejsca wszystkie numery linii były przesunięte o 1. Po włączeniu następnego pliku przesunięcie zwiększało się do dwóch linii. Niektóre pliki nagłówkowe wywoływały ten efekt, inne nie. Ostatecznie stwierdzono, że jest to sprawa ostatniej linii plików. Używany edytor pozwalał na to, żeby ostatnia linia nie była zakończona znakiem CR. Z kolei używana wersja cpp nie zwiększała numeru linii, jeśli nie było znaku CR. Żeby numerowanie linii było właściwe, ostatnia linia w pliku musiała być zakończona znakiem powrotu karetki a dopiero potem znakiem końca pliku. Wiele edytorów to wymusza, stąd znaczna część użytkowników nie jest narażonych na wystąpienie tego błędu.
W tym przypadku ciężko było przekonać producenta kompilatora, że jest to rzecz warta poprawienia. Niektóre wersje cpp nie tolerują linii bez powrotu karetki zgłaszając to jako błąd, co jest znacznie łatwiejsze do zaakceptowania, gdyż przynajmniej od razu wiadomo, gdzie jest problem. Jednakże w opisywanym przypadku poprawienie błędu wymagało ręcznego sprawdzenia we wszystkich plikach, czy ostatnia linia zawsze jest zakończona znakiem powrotu karetki. Po poprawieniu plików problem synchronizacji znikł.

Przejścia

Czasami odczyt prostego sygnału wejściowego może okazać się kłopotliwy, jeśli nie weźmie się pod uwagę stanu początkowego tego sygnału. W jednym z projektów występował prosty obwód porównujący napięcie na baterii z napięciem odniesienia. Wyjście komparatora stanowiło wejście do mikrokontrolera pokazującego, czy bateria jest odpowiednio naładowana. Sygnał, zwany BATT_LOW, został podłączony do dedykowanego wejścia mikrokontrolera, gdzie następnie był zatrzaskiwany (ang. latch) w rejestrze i pozostawał tam aż nie został skasowany przez program. Zatrzaskiwanie było wyzwalane zboczem narastającym sygnału.
Sygnał ten mógł być podłączony do przerwania, co było początkowym zamiarem przy projektowaniu sprzętu. Ponieważ spadek napięcia baterii jest powolny, to szybka odpowiedź z przerwania nie była konieczna, więc zamiast tego użyto zwykłego odpytywania. Opisywany błąd wystąpiłby jednak niezależnie od tego, która z metod zostałaby zastosowana.
Oprogramowanie wyłączało system, kiedy bateria była słaba, o ile system pracował na bateriach. System mógł także być zasilany z sieci i wtedy bit BATT_LOW był ignorowany. Podczas pracy na bateriach bit BATT_LOW był sprawdzany i jeśli został ustawiony, system był wyłączany.
Okazało się, że w niektórych przypadkach, gdy przełączono zasilanie z sieciowego na bateryjne system działał nawet, jeśli napięcie na baterii spadło znacznie poniżej określonego poziomu, podczas gdy BATT_LOW powinien spowodować wyłączenie systemu. Praca przy niedostatecznym naładowaniu baterii mogła pociągnąć za sobą trudne do przewidzenia skutki. W innych przypadkach system wyłączał się dokładnie tak, jak zaplanowano. Prześledzenie bitu BATT_LOW pokazało, że nigdy nie był on ustawiany w błędnych przypadkach. Problem polegał na tym, że jeśli bateria była rozładowana, gdy system był uruchomiony na zasilaniu z sieci, to przejście do stanu rozładowanego nie miało szans wystąpić, a tym samym nie występowało narastające zbocze sygnału BATT_LOW, które z kolei wyzwalało zapis do rejestru. Skoro nie było narastającego zbocza, to bit wskazujący na niski poziom baterii pozostawał nieustawiony.
Były dwie przyczyny tego błędu. Zastosowanie wyzwalania zboczem nie było właściwe w tym przypadku. Kiedy została podjęta decyzja o nieużywaniu przerwania, bardziej odpowiednie byłoby przeprojektowanie obwodu do określania stanu baterii, niż wykrywanie przejścia. Drugi problem leżał w nieuwzględnieniu stanu początkowego sygnału, który miał być zatrzaśnięty w rejestrze. Przyjęto milcząco dość głupie założenie, że zawsze zaczyna się pracę z dobrą baterią, bo przecież jeszcze nie zdążyła się zużyć. Oczywiście, mogła być zużyta po poprzednim użyciu. Jeśli w tej sytuacji włączymy urządzenie na zasilaniu sieciowym to otrzymamy scenariusz, w którym początkowo jest sygnalizowany stan „rozładowany”, więc nie ma możliwości przejścia ze stanu „naładowany” do stanu „rozładowany”. Jeśli zaś nie wystąpi to przejście, to bit BATT_LOW nie zostanie nigdy ustawiony, gdyż jest wyzwalany zboczem.

Czy potrafisz liczyć aż do jednego?

Programiści lubią używać stałych zdefiniowanych z użyciem dyrektywy #define lub zadeklarowanych jako const int. Zwykle pozwala im to na luksus łatwej zmiany tej wartości w przyszłości, bez uczucia, że dokonali rzeczywiście zmian w kodzie czy w algorytmie. Zasadniczo uznaje się to za dobrą praktykę w oprogramowaniu.
Istotne jest, że zmiana wartości może mieć skutki bardziej dramatyczne, niż zamierzano. W pewnym systemie używano opóźnień podczas oczekiwania na ustabilizowanie się pewnych warunków pneumatycznych. Początkowo stwierdzono, że jedna z części tego systemu wymagała opóźnienia trzyminutowego. Ponieważ był tam licznik inkrementowany co minutę, zdefiniowano wartość 3 jako WARMUP_DELAY. Na początku zapisywano wartość licznika minut jako DelayStart. W momencie, gdy licznik osiągnął wartość DelayStart+3, oczekiwanie było zakończone.
Licznik był aktualizowany co minutę, więc pomiar czasu miał jednominutową rozdzielczość. Opóźnienie rozpoczynało się w momencie, który nie był zsynchronizowany z aktualizacją licznika, więc faktyczne opóźnienie wynosiło od dwóch do trzech minut, co przedstawiono na rys. 3. Nie stanowiło to problemu, gdyż długość opóźnienia nie była krytyczna. Tak naprawdę wszystko działało dobrze, o ile to opóźnienie było dłuższe niż około 20 sekund.
W późniejszej fazie projektu próbowano zmniejszyć czas, w którym system oczekiwał na niektóre z przejść pneumatycznych. Trzy minuty to było długo jak na opóźnienie WARMUP_DELAY, więc ktoś zmniejszył ten czas do jednej minuty. Najwyraźniej nie wziął pod uwagę, że wartość jeden w tym wypadku oznaczała czas mniejszy lub równy jednej minucie. Tym samym w niektórych przypadkach opóźnienie było mniejsze niż 20 sekund, co powodowało awarię systemu. Czas ten się zmieniał w zależności od tego, jak odległy był początek opóźnienia od momentu, w którym był inkrementowany licznik minut. Dlatego problem czasami występował, a czasami nie, co czyniło go kłopotliwym do wyśledzenia.
Morał z tej historii jest taki, że rozdzielczość i kontekst użycia powinny być zawsze uwzględnianie przy zmianie stałych. Warto zauważyć, że ten problem liczników służących jako timery o ograniczonej dokładności jest taki sam, jak przy użyciu timerów dostarczanych przez system operacyjny czasu rzeczywistego (RTOS), albo licznika, który jest inkrementowany za pomocą przerwania. Zazwyczaj te timery będą raczej odmierzały milisekundy niż minuty, ale zasada jest taka sama: jeśli licznik jest używany do mierzenia czasu i opóźnienie nie jest zsynchronizowane z chwilami inkrementowania licznika, to niedokładność tego opóźnienia będzie wynosiła do jednego tyknięcia licznika.

Ręce precz

Ostatni przykład jest szczególnie ciekawy, gdyż pokazuje jak łatwo jest zaprojektować oprogramowanie bez zwrócenia dostatecznej uwagi na sposób, w jaki urządzenie będzie fizycznie obsługiwane. Jest to ten gatunek problemów, który sprawia, że oprogramowanie wbudowane jest znacznie bardziej interesujące niż odpowiednie oprogramowanie na PC.
Jako metoda oszczędzania energii w urządzeniu medycznym został zastosowany tryb „przyciemniony”, w którym zmniejszane było natężenie światła wszystkich diod LED i podświetlenia panelu. Ponieważ urządzenie miało być używane na oddziałach szpitalnych, to przyciemniony tryb pracy służył zarówno do oszczędzania baterii, jak również sprawiał, że urządzenie mniej przeszkadzało w pokojach, gdzie zostało zgaszone światło podczas snu pacjentów. Plan polegał na tym, że gdy natężenie światła w pomieszczeniu spadało poniżej pewnego poziomu, oprogramowanie powodowało przejście do trybu przyciemnionego. Rankiem, gdy wzeszło słońce, albo zostały zapalone światła, urządzenie wracało do normalnego trybu pracy. Jedynym elementem elektronicznym, jaki był potrzebny, był jeden fototranzystor umieszczony za szybką na przednim panelu.
W praktyce okazało się, że urządzenie przechodziło do trybu przyciemnionego w nieoczekiwanych chwilach. Chwile te nie były zupełnie losowe. Wyglądało na to, że zdarza się to raczej, gdy ktoś obsługuje urządzenie, niż gdy jest pozostawione nieużywane. Krótka obserwacja pozwoliła odkryć przyczynę. Fototranzystor był umieszczony mniej więcej na środku panelu. Kiedy praworęczna osoba naciskała przycisk po lewej stronie panelu, fototranzystor był zasłonięty ręką i urządzenie uznawało, że nastała noc. Mimo, że dla tego sygnału zastosowano opóźnienie przełączania, to okazało się to niewystarczające dla wyeliminowania skutku cienia rzucanego przez kilka sekund przez rękę.
Rozwiązanie było proste: zwiększono znacząco czas opóźnienia. Ponadto zignorowano jakiekolwiek zmiany sygnału zachodzące w ciągu kilku sekund od naciśnięcia jakiegoś klawisza. Idealne rozwiązanie wymagałoby też zmiany położenia czujnika, ale w momencie wykrycia problemu sprzęt był w zaawansowanej fazie produkcji i z tego względu programowe rozwiązanie problemu było preferowane.

Rób mniej błędów

Błędy takie jak tu opisane idą w ślad za wzorcami powtarzanymi w różnych projektach, pisanych przez różnych programistów. Zrozumienie błędów, które spotkało się wcześniej – tych, które się popełniło samemu, tych, które popełnili koledzy i tych, o których się tylko czytało – pomaga zdiagnozować nowe błędy. Miłego szukania.

Marek Strzelczyk

Zobacz również