Przygoda z mikrokontrolerem

Na początku maja odkopałem swoją starą zabawkę, jeszcze z czasów gimnazjum - "komputer testowy" oparty na mikrokontrolerze 80C535. Układ składa się niemal wyłącznie z kontrolera, pamięci (EPROM + RAM), podłączenia do zasilania oraz portu szeregowego (RS-232) i kilku portów wyjściowych. Gniazdo portu szeregowego służy do podłączania układu do PC-ta i wgrywania na niego programów, napisanych w prostym asemblerze.

Komputer testowy 80C535

Komputer testowy 80C535

Pojawiły się jednak dwa problemy. Pierwszy - współczesne komputery rzadko mają gniazdo RS-232, a laptopy chyba już wcale. Ten łatwo było rozwiązać, zamawiając z internetu adapter podłączany przez USB. Drugi problem był poważniejszy.

Otóż jak nietrudno się domyślić, jako uczeń gimnazjum nie miałem większego wkładu w projekt układu. Autorem był mój nauczyciel, który wyposażył nas (mnie i innych uczestników kółka elektronicznego) również w oprogramowanie do obsługi kontrolera. Problem w tym, że w ciągu 14 lat oprogramowanie gdzieś mi zaginęło, a z nauczycielem nie mam kontaktu. Cóż, stwierdziłem, jestem teraz dorosły i całkiem nieźle obeznany w programowaniu, więc chyba jakoś sobie poradzę ;)

Tak zatem zaczęła się moja przygoda z reverse-engineeringiem zabawki z kółka elektronicznego.

Dzień 1

Przejściówka USB-RS232 została zamówiona, jednak przesłanie jej pocztą musiało chwilę zająć. Zacząłem więc robić jedyne, co mogłem, czyli analizować połączenia na płytce.

Dowiedziałem się więc, że płytka zawiera procesor 80C535, pamięć EPROM 27C256 (32 kB) oraz pamięć RAM D43256AC-12L (też 32 kB). Chipy te zostały podłączone równolegle do nóżek procesora odpowiadających za adresowanie zewnętrznej pamięci i transfer danych. Układ zawiera również jeden chip z bramkami NAND, z których przynajmniej jedna jest wykorzystana jako negator, co zasugerowało mi, że chipy pamięci mogą być umieszczone w osobnych przestrzeniach adresowych - już wyjaśniam.

Procesor adresuje zewnętrzną pamięć 16 bitami, co daje mu 2^16 = 65536 możliwych adresów - 64 kB przestrzeni adresowej. Załóżmy teraz, że chcemy ustawić chip EPROM w adresach 0-32767, a RAM w adresach 32768-65535.

Adresy 0-32767 mają najwyższy, 16 bit równy 0, a 32768-65535 - równy 1. Pozostałe bity kodują wartości od 0 do 32767 zarówno w niższym, jak i w wyższym zakresie. To oznacza, 15 bitów adresu możemy podłączyć do EPROM i do RAM bezpośrednio i wszystko będzie ok, musimy jedynie aktywować jeden z tych chipów na podstawie bitu 16. Wystarczy zatem chip RAM podłączyć do 16 bitu połączonego bramką AND z nóżką aktywującą pamięć, a chip EPROM - do zanegowanego bitu 16 podobnie połączonego z aktywacją pamięci. Istnienie bramki NAND połączonej jako negator wspiera tę hipotezę. Nie byłem jednak w stanie sprawdzić jej do końca, gdyż niektóre połączenia na płytce były zasłonięte chipami. Cóż, bywa.

Kolejny chip zawiera 8 tzw. przerzutników typu D i jest podłączony do nóżek portu 0 procesora, który ma dwojaką funkcję - służy on do przekazywania zarówno danych, jak i 8 bitów adresu. Przerzutniki D służą do umożliwienia mu tego. Procesor chcąc odwołać się do pamięci, najpierw poda na wyjście adres i aktywuje przerzutniki - to spowoduje zapamiętanie w nich 8 bitów. Następnie na to samo wyjście mogą zostać podane dane. Nóżki adresowe chipów pamięci są podłączone do przerzutników, a nóżki danych bezpośrednio do portu 0 - dzięki czemu pamięć odbiera na wyjściu jedno i drugie. Proste i skuteczne.

Ostatnim wartym wspomnienia chipem jest układ służący do komunikacji z portem szeregowym. Zakres napięć pracy portu szeregowego jest inny, niż zakres pracy procesora, przez co potrzebny jest układ sterujący. Dwie nóżki procesora, odpowiedzialne za nadawanie i odbieranie danych podłączone są do chipu, a chip - do portu szeregowego. W ten sposób wszystko może bezproblemowo działać.

Ta analiza przybliżyła mi nieco działanie komputera, ale niestety nie pomogła mi w znalezieniu odpowiedzi na pytanie - jak właściwie się z nim komunikować? To musiało poczekać na przejściówkę.

Dzień 2

Przejściówka dotarła, nadszedł czas podjęcia prób komunikacji.

Na laptopie używam Linuxa. U niektórych ma on reputację systemu, w którym sprzęty nie chcą działać, ale moje doświadczenia są zgoła inne - chyba nie trafiłem na sprzęt, który po podłączeniu do komputera z Linuxem nie zacząłby od razu współpracować. Tak było i tym razem - podłączenie przejściówki do portu USB poskutkowało natychmiastowym pojawieniem się /dev/ttyUSB0 w systemie plików.

Drobny problem - nie mam pojęcia, jaką ustawić szybkość transmisji (ang. baud rate, co przekłada się z grubsza na bity na sekundę). Cóż, możliwości nie ma zbyt wiele, więc znajdzie się odpowiednią za pomocą prób i błędów.

W międzyczasie poszukiwałem też kandydatów na oprogramowanie, które mogło być wgrane na chip EPROM (jakieś musiało być - procesor sam z siebie nie potrafiłby interpretować danych przychodzących na port szeregowy, potrzebne było coś, co by nim sterowało). Znalazłem coś o nazwie ASEM-51 i postanowiłem to przetestować.

Instrukcja ASEM-51 nakazywała nastawienie szybkości na 9600 bps, podłączenie terminala do portu szeregowego i reset układu. Wykonałem te kroki i... nic. Zero reakcji ze strony komputera. Próbowałem resetować go jeszcze kilka razy, ale nic to nie dało, kontroler wciąż milczał.

Spróbowałem innej metody: cat /dev/ttyUSB0 | hexdump -C. To polecenie nakazuje systemowi odczytywać dane bezpośrednio z urządzenia szeregowego i przekazywać je do programu, który wypisze je jako liczby w systemie szesnastkowym. Reset i... coś jest! Dane niestety niewiele mówiły i mocno się powtarzały, coś było nie w porządku. Postanowiłem spróbować innych szybkości transmisji i przy 4800 bps odebrane dane zrobiły się sensowniejsze. Przede wszystkim, pojawiły się w nich sekwencje "0D 0A", które dla wprawnego oka są znakami nowej linii w kodowaniu ASCII. Sprawa wyglądała obiecująco. Niestety, żaden z odbieranych 51 bajtów nie wychodził poza zakres 01h - 16h (literka h na końcu oznacza zapis szesnastkowy), a czytelne znaki w ASCII zaczynają się od 20h. W przypadku ASEM51 natomiast powinienem spodziewać się czytelnego komunikatu, więc to nie to. Próbowałem chwilę znaleźć w przesłanej wiadomości jakikolwiek wzór, który podpowiedziałby mi, czego próbować dalej, ale daremnie. Wyglądało na to, że zabawa skończy się bardzo szybko.

Dzień 3

Tego dnia przyszło oświecenie. Potrzebuję wiedzieć, jak właściwie działa program zapisany na pamięci EPROM, tak? Jest przecież "prosty" sposób, by się tego dowiedzieć. Odczyt pamięci jest banalny - wystarczy podać napięcie na odpowiednie nóżki żeby przekazać adres i uruchomić odczyt, a napięcie na innych nóżkach powie mi, jaka jest zawartość pamięci pod tym adresem. Do nóżek danych mogę podłączyć diody, których świecenie pokaże mi wartość bajtu, mogę też tak poustawiać kabelki żeby możliwie łatwo było mi układać różne adresy... Zebrałem swój sprzęt elektroniczny i tak powstało urządzenie ze zdjęcia poniżej.

Czytnik EPROM v1

Czytnik EPROM v1

Płytka komputera służy tu wyłącznie za zasilanie - wiedziałem, że mam na nią podawane odpowiednie napięcie, ma ona też kilka wyprowadzeń bezpośrednio z linii zasilania. Druga płytka to płytka uniwersalna, na której umieściłem chip EPROM, diody i kabelki pozwalające ustawiać adres. Żółte diody wyświetlały górne 4 bity bajtu, zielone - dolne 4 bity. Przycisk włączał odczyt pamięci.

Uzbrojony w tenże układ zacząłem odczyty. Pierwsze 3 bajty zapisane szesnastkowo przedstawiały się tak: 02 02 03. Rzut oka w dokumentację procesora pozwolił mi stwierdzić, że jest to rozkaz skoku pod adres 0203h, albo dziesiętnie: 515. W końcu jakiś czytelny wynik!

Przeczytałem jeszcze trochę bajtów pod kolejnymi adresami (3, 4, 5, ...), ale po czymś, co wyglądało jak kolejne polecenie skoku (02 40 03... pod adres 4003h? 16387? mam nadzieję, że nie będę musiał czytać aż tylu bajtów...) zaczęło się powtarzać FF, więc przeszedłem do adresów zaczynających się od 0200h.

To przyniosło ciekawsze efekty, bowiem bez wątpienia był to wykonywalny kod. Jedno z poleceń jednak mnie zaniepokoiło: 12 07 45. Jest to rozkaz wywołania funkcji pod adresem 0745h - czyli na dziesiętne: 1861. To znaczyło, że czekał mnie jeszcze odczyt co najmniej 1300 bajtów... Niezbyt przyjemna perspektywa, gdy odczytanie jednego bajtu zajmuje ok. 30 sekund. Po odczytaniu 128 bajtów kodu skończyłem pracę na ten dzień i poszedłem spać.

Dzień 4

Tego dnia stwierdziłem, że przedsięwzięcie mocno by się uprościło, gdybym adresy ustawiał przełącznikami, zamiast przekładaniem kabelków (które do tego były splotami drutów i miały tendencję do rozdwajania się przy przekładaniu). Zakupiłem więc kilka i skonstruowałem Czytnik v2.

Czytnik v2

Czytnik v2

Przerzucanie przełączników szło znacznie szybciej, niż przekładanie kabelków, dzięki czemu zacząłem czytać bajty w zawrotnym tempie ;) Odczytałem ponad 700 bajtów, w których znalazłem kilka ciekawych rzeczy.

Pierwszą z nich był kod 12 0B D9. Kolejne wywołanie funkcji, tym razem z adresu 0BD9h - dziesiętnie 3033. Super, kod okazuje się jeszcze większy - jeszcze więcej czytania. W tym momencie miałem jednak za sobą już ponad 800 bajtów, czyli ok. 1/4 przewidywanej długości kodu. Nie tak źle.

Drugą ciekawą rzeczą było kilka ciągów bajtów, które ewidentnie układały się w tekst. Po pierwsze, charakterystyczna sekwencja 0D 0A, kilka 20h (spacji) i bajty z zakresu 41h - 5Ah - czyli duże litery. Oto, co udało mi się odczytać:

  • LJMP TO 4100H...
  • --EMON52-- version 0.1 (2.7.1992) RAMTOP=
  • INTERRUPT, IE0
  • INTERRUPT, IE1
  • INTERRUPT, TF0
  • ...

Jedynym sensownym zastosowaniem takich ciągów znaków w programie na układzie bez ekranu, ale z portem szeregowym, jest przesyłanie ich przez tenże port. Żaden z tych ciągów jednak nie pojawiał się wśród odbieranych przeze mnie bajtów. Albo coś w programie zaczynało od przesyłania innych ciągów, albo coś było nie tak. W tym punkcie miałem jednak wciąż za mało informacji, by cokolwiek stwierdzić na pewno.

Nawiasem mówiąc: w internecie trafiłem na wzmianki o programie EMON52, co potwierdzało że coś takiego istniało, jednak nic więcej nie udało mi się o nim dowiedzieć, poza tym, że najprawdopodobniej był to program produkcji niemieckiej ;)

Dzień 5

Czytamy dalej.

Po kilkunastu ciągach typu INTERRUPT... znów zaczął się kod. Miał on jednak bardzo powtarzalną strukturę:

75 A8 00 90 XX YY 12 0B D9 02 04 AE

To samo powtórzone wiele razy z różnymi XX YY. XXYY okazały się być adresami poszczególnych napisów INTERRUPT..., natomiast same fragmenty kodu pojawiały się w miejscach, do których wcześniej odwoływał się kod wywołujący funkcję pod adresem 0745h.

Nie wygląda to na wiele, ale można było z tego wywnioskować naprawdę dużo. Po kolei.

Procesor 80C535 posiada obsługę przerwań (ang. interrupts). Przerwanie to coś, co (jak sama nazwa wskazuje) przerywa pracę procesora i tymczasowo zmusza go do wykonywania kodu pod predefiniowanym adresem - tzw. kodu obsługi przerwania. Przerwań jest więcej niż jedno, są one zwykle numerowane, a w przypadku 80C535 mają także nazwy: IE0, IE1, TF0...

Wróćmy do powtarzających się fragmentów kodu. 75 A8 00 wpisuje 0 pod adres wewnętrzny A8 - tak się składa, że jest to flaga określająca, czy przerwania są włączone. Tak więc ta instrukcja wyłącza przerwania. 90 XX YY ładuje adres napisu do rejestru DPTR - jest to rejestr służący do odwoływania się do danych. 12 0B D9 - wywołanie funkcji, 02 04 AE - skok do 04AEh, pod którym to adresem znajduje się... 02 02 03 - skok do początku programu.

Powtórzmy jeszcze, że adresy tych fragmentów kodu były ładowane do rejestru DPTR przed wywołaniem funkcji 0745h.

...Widać? Nie widać?

Pierwszy wniosek nasuwa się sam. Funkcja pod adresem 0BD9h jest wywoływana zawsze po załadowaniu adresu napisu do DPTR, a co można zrobić z napisem? Zapewne wypisać go na jakieś wyjście, tu: port szeregowy. Podejrzewam zatem, że pod adresem 0BD9h mamy funkcję "drukującą" napis.

Powtarzające się fragmenty kodu wyłączają przerwania, wypisują komunikat "INTERRUPT, xxx" i wracają do początku programu. Prawdopodobnie są to zatem funkcje, które obsługują właśnie przerwania. Zwykle takie funkcje wyłączają na chwilę możliwość wystąpienia innych przerwań, żeby nic innego nie przeszkadzało im w pracy i z tym właśnie mamy również tu do czynienia.

Wróćmy jeszcze do funkcji 0745h. Do czego może ona służyć? Jest wywoływana za każdym razem po załadowaniu adresu funkcji obsługi przerwania do DPTR. Sensowną hipotezą wydaje się więc, że jest to funkcja ustawiająca obsługę przerwań. I w ten sposób na podstawie kilku drobnych fragmentów kodu wiemy już bardzo dużo ;)

Na szczęście miałem już przełączniki. Zacząłem na szybko odczytywać kod spod adresu 0BD9h i moje podejrzenia się potwierdziły. Odkryłem kilka funkcji, które pozwalały na wypisywanie pojedynczych znaków, ciągów znaków, liczb szesnastkowych, a także na wczytywanie znaków i liczb - a wszystko, oczywiście, przez port szeregowy.

Na tym zakończyłem kolejny dzień, ale byłem już przekonany, że coś z tego wszystkiego będzie.

Dzień 6

Co dalej? A jakże, czytamy.

Tym razem jednak w międzyczasie zabrałem się też do deasemblowania kodu. Ściśle rzecz biorąc, zacząłem to robić już dzień wcześniej, ale teraz deasemblowałem w miarę na bieżąco.

Czym jest deasemblowanie? Otóż ludzie dość szybko wymyślili, że ciężko czyta się kod typu 75 A8 00 90 02 F3 12 0B D9... i wymyślili tzw. asembler. Asembler to język, w której każdej instrukcji kodu maszynowego odpowiada tzw. "mnemonik" - czyli ciąg znaków, układający się w skrót zrozumiałego dla człowieka słowa. Np. powyższy kod można byłoby zapisać tak:

mov - od "move" - przenieś (w kontekście komputera akurat bardziej pasuje kopiowanie, ale przyjęło się "move"). Pierwsza instrukcja zapisuje 0 pod adresem A8h (zero na początku jest wymagane, żeby asembler nie pomyślał, że chodzi o etykietę - o tym za chwilę). Druga zapisuje 02F3h do DPTR, trzecia wywołuje ("call") funkcję pod adresem 0BD9h. Czytelniejsze, prawda?

Czytelność można posunąć dalej przy pomocy etykiet. Otóż zwykle ładując adres jakiegoś napisu albo wywołując funkcję chodzi nam o adres danej struktury w kodzie, a nie o konkretny numer bajtu. Kiedy coś zmienimy w kodzie, adresy funkcji i danych mogą się pozmieniać. Ręczne zmienianie ich w każdym miejscu byłoby koszmarem, dlatego wymyślono etykiety.

Etykiety pozwalają przypisać nazwy miejscom w kodzie. Np. powyższe mogłoby wyglądać tak:

Wcześniej natomiast mogłoby się znaleźć:

I od razu lepiej widać, co dany fragment kodu ma robić. Pierwszy fragment ewidentnie obsługuje jakieś przerwanie, wypisując przy tym jakiś tekst, a drugi ustawia adres pierwszego jako adres funkcji obsługującej przerwanie. Nie ma zagadkowych 0BD9h czy 0745h, tylko od razu nazwy.

Oczywiście, procesor wciąż potrzebuje kodu maszynowego, zatem taki asembler trzeba na niego tłumaczyć. Proces takiego tłumaczenia nazywa się "asemblowaniem" lub "kompilowaniem" (choć to drugie odnosi się raczej do tłumaczenia języków wyższego poziomu na asembler lub kod maszynowy), a proces odwrotny - "deasemblowaniem". Moim celem było więc sprowadzenie jak największej części odczytanego kodu do postaci asemblera.

Jak postanowiłem, tak zrobiłem i w ten sposób odkryłem kolejne ciekawe rzeczy. Wyniki moich wysiłków można obejrzeć tutaj.

Po pierwsze, odkryłem, że komputer zaraz po uruchomieniu powinien wysłać przez port szeregowy znalezione wcześniej "--EMON52--...". Dlaczego tego nie robił? Ot, zagadka (którą zaraz wyjaśnię). Po drugie - program tworzy swego rodzaju interaktywną konsolę. Obsługuje kilka prostych komend:

  • Ciągi zaczynające się od ":" interpretuje jako tzw. Intel HEX format (sposób zapisu kodu jako liczb szesnastkowych) - co pozwala na ładowanie programów do pamięci przez port szeregowy
  • Po wpisaniu litery "P" wypisuje 128 bajtów pamięci zaczynając od podanego adresu - i tak oto czytnik stał się przestarzały :) (o ile udałoby mi się uruchomić komunikację przez port szeregowy)
  • Po wpisaniu litery "X" zaczyna wykonywać kod od podanego adresu
  • Jeszcze kilka komend, które jednak mają już mniejsze znaczenie

Nabrało zatem sensu zmontowanie komputera z powrotem i próby skomunikowania się z nim przez port szeregowy. Czemu jednak to nie działało...?

Postanowiłem spróbować podłączyć całość do komputera z Windowsami. Zainstalowałem sterowniki podlinkowane przez sprzedawcę z Allegro, jakiś mały programik do prostej komunikacji przez port szeregowy, uruchomiłem... --EMON52-- version 0.1 (2.7.1992). Aha, czyli jednak przejściówka nie działa na Linuksie. Super.

No to szukamy rozwiązania. Przejściówka jest oparta na chipie CH340 - szukamy sterowników. Linux ma sterowniki do tego chipu wbudowane w jądro, ale ewidentnie nie działają. Na jakiejś stronie znalazłem sterowniki producenta w formie kodu źródłowego. Ściągnąłem, kompiluję... nic z tego. Brak potrzebnych plików. Ech. Instaluję pliki wymagane do kompilacji modułów jądra, próbuję jeszcze raz... Błąd. Sterowniki są w starej wersji.

Dobra, zmiana podejścia. Strona producenta chipu, wszystko po chińsku, ale jest wyszukiwarka. Wpisuję "CH340", jest wynik, nowszy kod sterowników. Kompiluję, sukces, ładuję sterownik... Komunikacja przez port szeregowy nadal nie działa.

W tym momencie miałem ochotę się poddać, ale stwierdziłem, że to niemożliwe, ktoś musiał mieć problem podobny do mnie. Zajrzałem nawet w kod sterownika, ale stwierdziłem, że zrozumienie go to robota na parę dni i nie mam obecnie na to ochoty. No to google... Jest jakiś wynik: ktoś na liście mailingowej jądra Linuxa zauważył problem i zaproponował poprawkę. Post co prawda sprzed roku, ale spróbuję.

Ściągnąłem kod sterownika z repozytorium Linuxa na Githubie, naniosłem poprawkę, kompilacja, ładowanie, uruchomienie komputera... --EMON52-- version 0.1 (2.7.1992). TAK! DZIAŁA!

Przetestowałem komendy, faktycznie byłem w stanie już czytać kod przez laptopa. Czytnik poszedł w odstawkę, kilka pozostałych dziur w kodzie załatałem już tym sposobem (choć wciąż nie wszystkie - może jeszcze do tego wrócę).

Dla testu postanowiłem wgrać prosty program i go uruchomić. Jako że póki co muszę robić to ręcznie, postanowiłem, że będzie to coś prostego:

75 B0 AA 22

Ten program ładuje AAh (binarnie 10101010) na jeden z portów, do których mogę podłączać "wyświetlacze" widoczne na pierwszym zdjęciu. Po poprawnym uruchomieniu powinien pojawić się ładny wzorek naprzemiennie zgaszonych i zapalonych lampek.

Żeby poprawnie wgrać program, musiałem jeszcze wybrać dla niego adres początkowy i przerobić go na Intel HEX. Pierwotnie wybrałem adres 8000h, ale to nie zadziałało - gdyby ROM i RAM były ustawione w przestrzeni adresowej obok siebie, jak przypuszczałem na początku, to powinno wgrać mój program na początek RAMu, ale tak się nie stało. Potrzebowałem niższego adresu, wybrałem więc 4100h - wspomniany w jednym z napisów.

Do pełnego formatu HEX brakuje jeszcze tylko sumy kontrolnej, co w sumie daje:

:0441000075B0AA22CA

(":" zaczyna blok formatu HEX, 04 to liczba bajtów danych, 4100 to adres, dalej 4 bajty kodu i suma kontrolna CA).

Ok, poszło. Wysyłamy komendę X4100... Lampki świecą jak należy :)

Podsumowanie

To jest etap, na jakim jestem teraz. Planuję jeszcze napisać jakiś prosty asembler, program do automatycznego ładowania kodu na kontroler (może przy okazji poćwiczę Rusta...) i jakieś proste programiki. Jak coś stworzę, na pewno opiszę tutaj. Do następnego razu :)