Jedną z pierwszych rzeczy niezbędnych do rozwiązania problemu, a może tylko do poznania mechanizmu działania komunikacji programu z pamięcią EEPROM, jest analiza funkcji, które za to odpowiadają. Obiekt LogicAnalyzerDevice
udostępnia dwie metody do służące do tego celu, są to ReadEeprom
i WriteEeprom
. Wspominałem już o nich w poprzedniej części, ale dopiero niedawno udało mi się im bliżej przyjrzeć i wyłonić obraz ich działania. Dodatkowo w czasie analizy, pojawiło się kilka ciekawych, związanych z tymi funkcjami aspektów. Mowa tutaj o implementacji buforów używanych przez program oraz szyfrowaniu transmisji z urządzeniem, o którym wcześniej nie miałem pojęcia.
Tytuł mówi o komunikacji z pamięcią EEPROM, co może być trochę mylące, bo nie będę mówił o kwestiach hardware-owych ani low-level-owych, ale o komunikacji z tym związanej, z punktu widzenia aplikacji. Czyli bardziej o samej komunikacji aplikacji z urządzeniem w celach związanych z operacjami na pamięci EEPROM. O samym firmware i hardware w kontekście działania z fizycznym układem pamięci podpiętym pod szynę I2C, pewnie będzie w niedalekiej przyszłości.
Bufory
Analizując kod aplikacji można trafić na kilka ciekawych implementacji buforów, używanych w różnych miejscach, w tym także w funkcjach związanych z zapisem i odczytem pamięci EEPROM. Warto się nad nimi trochę zastanowić i je zbadać, na pewno pomoże to w dalszej analizie kodu.
Wydaje mi się, że bufory te bazują na typowym vector
-ze z standardowej biblioteki C++ lub na czymś bardzo podobnym. Przemawiać za tym może ich budowa i implementacja, a także fakt, że gdzieś trafiłem na fragment kodu, który IDA w komentarzach dorzuciła wzmiankę o vectorze.
Jako przykład do analizy może posłużyć kod zaczerpnięty z ReadEeprom
. Najistotniejszy fragment zaczyna się pod adresem 511FA0
:
00511FA0 lea eax, [ebp+var_28] ; vector 00511FA3 push eax 00511FA4 call sub_47F060 ; vector.ctor() 00511FA9 mov eax, 5 00511FAE lea ecx, [ebp+var_28] 00511FB1 mov [ebp+var_4], 2 00511FB8 call sub_503190 ; vector.reserve/resize(5) 00511FBD mov ecx, [ebp+var_18] 00511FC0 sub ecx, [ebp+var_1C] ; length = end - begin |
Przydatny może być również fragment stosu:
00511F10 var_28 = dword ptr -28h 00511F10 var_1C = dword ptr -1Ch 00511F10 var_18 = dword ptr -18h 00511F10 var_14 = dword ptr -14h |
Komentarzami oznaczyłem wydedukowane fragmenty i przeznaczenie poszczególnych zmiennych oraz instrukcji. Na początek warto zajrzeć do sub_47F060
, który wydaje się być konstruktorem vectora.
0047F060 sub_47F060 proc near [...] 0047F082 mov esi, [esp+18h+arg_0] ; esi = &var_28 0047F086 push 4 0047F088 call ??2@YAPAXI@Z ; eax = operator new(4) 0047F08D xor ecx, ecx ; ecx = 0 0047F08F add esp, 4 0047F092 cmp eax, ecx ; if eax == nullptr 0047F094 jz short loc_47F09A ; then loc_47F09A 0047F096 mov [eax], esi ; *eax = var_28 0047F098 jmp short loc_47F09C 0047F09A 0047F09A loc_47F09A: 0047F09A xor eax, eax ; eax = 0 0047F09C 0047F09C loc_47F09C: 0047F09C mov [esi], eax ; *var_28 = eax 0047F09E mov [esi+0Ch], ecx ; *var_1C = 0 0047F0A1 mov [esi+10h], ecx ; *var_18 = 0 0047F0A4 mov [esi+14h], ecx ; *var_14 = 0 0047F0A7 mov eax, esi ; eax = esi [...] 0047F0B6 add esp, 10h 0047F0B9 retn 4 0047F0B9 sub_47F060 endp |
Działanie tego kodu jest proste, opisałem je w komentarzach. Po alokacji 4 bajtów pamięci na wskaźnik do nowego obiektu, który de facto wskazuje sam na siebie, i zapisany zostaje w 4 z 12 bajtów zmiennej var_28
. Następnie inicjalizowane są 3 wskaźniki, które są kolejnymi zmiennymi ze stosu znajdującymi się za var_28
...
Inicjalizacja tych wskaźników dokładnie odzwierciedla to co robi cześć kodu inicjująca vector i podstawowe byty w implementacji tego typu w wykonaniu Microsoftu. Jest do bardzo podobne do fragmentu kodu z nagłówka vector
dołączonego STL-a do VC, gdzie znaleźć można template-ową klasę _Vector_val
, zawierającą poniższy fragment:
_Vector_val() { // initialize values _Myfirst = pointer(); _Mylast = pointer(); _Myend = pointer(); } pointer _Myfirst; // pointer to beginning of array pointer _Mylast; // pointer to current end of sequence pointer _Myend; // pointer to end of array |
Kod ten jest bardzo analogiczny do tego fragmentu przedstawionego wyżej. Tak się składa, że _Vector_val
jest jednym z bazowych elementów implementujących typ kontenera vector
. Dodatkowo 12 bajtowy var_28
może być w całości lub częścią obiektu alokatora.
Jako uzupełnienie konstruktora fajnie sprawdzić działanie destruktora. Kompilator bardzo ładnie optymalizuje kod wynikowy, często również rozwija funkcje w miejscu wywołania, gdy uzna to za stosowne. Takie rzeczy mogą trochę utrudniać analizę kodu. Kod destruktora jest rozwijany, dlatego dla przykładu możemy znów posłużyć się fragmentem z ReadEeprom
:
005120B1 ; var_buffer dtor begin 005120B1 mov eax, [ebp+var_buffer.begin] 005120B4 cmp eax, edi ; edi = 0 -> xor edi, edi at 512072 005120B6 jz short loc_5120C1 ; jmp if var_buffer.begin == nullptr 005120B8 push eax 005120B9 call ??3@YAXPAX@Z ; delete(var_buffer.begin) 005120BE add esp, 4 005120C1 loc_5120C1: 005120C1 mov ecx, [ebp+var_buffer.ptr] 005120C4 push ecx 005120C5 mov [ebp+var_buffer.begin], edi ; var_buffer.begin = 0 005120C8 mov [ebp+var_buffer.end], edi ; var_buffer.end = 0 005120CB mov [ebp+var_buffer.max], edi ; var_buffer.max = 0 005120CE call ??3@YAXPAX@Z ; delete(var_buffer.ptr) 005120D3 add esp, 4 005120D3 ; var_buffer dtor end |
Kod standardowy, gdy vector posiada zalakowany bufor na dane, to zostaje on zwolniony operatorem delete
, a wszystkie wskaźniki, o których pisałem wyżej zostaną wyzerowane. Na koniec zwolnienie pamięci obiektu i koniec, po vectorze nie ma śladu.
Kolejna funkcja - sub_503190
, występująca w parze z konstruktorem odzwierciedla działanie typowej metody vectora służacej do rezerwacji miejsca - reserve
. Choć bardziej prawdopodobne wydaję się to, że jest to resize
, gdyż późniejsze operacje bezpośrednio przeprowadzane są na buforze, a nie ma nigdzie alokacji i relokacji (przynajmniej w tych miejscach, które mnie interesowały w czasie analizy, pod kątem głównego problemu).
Nie będę tutaj za bardzo przedstawiał poszczególnych fragmentów kodu, ponieważ funkcja ta może nie jest długa i skomplikowana, ale wywołuje masę innych funkcji, więc zajęłoby zbyt dużo miejsca i czasu. Jeśli rzucimy okiem na ich kod to większość sprawdza różne warunki, m. in. długość bufora, alokacji i wykonuje operacje na pamięci. W głównej mierze za pomocą standardowych memmove_s
, memset
oraz operatorów new
i delete
. Co uświadamia mnie, że istnieją małe szanse, co do możliwej mojej pomyłki odnośnie przeznaczenia tejże funkcji.
To właśnie gdzieś w drzewie wywołań tych funkcji trafiłem na wspomniany fragment ze stringiem mogącym zdradzać typ obiektu, o którym mówiłem na początku. W funkcji sub_4F6CF0
trafimy na wykorzystanie stringa:
.rdata:00BF856C aVectorTTooLong db 'vector<T> too long',0 |
na krótko przed rzuceniem wyjątku:
call __CxxThrowException@8 ; _CxxThrowException(x,x) |
Moje przewidywania zostały udowodnione. Teraz wystarczy zmienić nazwy poszczególnych funkcji oraz dodać do definicji strukturę w postaci podobnej do:
struct VectorBuf { void* ptr; // obj ptr? int unk[2]; // alocator? char* begin; // ptr to to beginning of buffer array char* end; // ptr to end of used buffer array char* max; // ptr to end of allocated buffer array }; |
aby IDA ładnie podpowiadała w kodzie o wystąpieniach i użyciach vectora w roli bufora.
Czytanie i zapisywanie
Pora przejść do meritum. Znając działanie używanych buforów, analiza kodu oraz działania odczytu i zapisu do EEPROM powinna być dużo łatwiejsza. W prezentowanych fragmentach kodu będę pomijał wszelkie walidacje i nieistotne części, dorzucając komentarze i zmieniając nazwy niektórym zmiennym/adresom, aby odzwierciedlały ich przeznaczenie, bazując na moich odkryciach...
Prototyp funkcji ReadEeprom
po krótkiej analizie można przedstawić następująco:
void __stdcall LogicAnalyzerDevice::ReadEeprom(void* obj, int address, char* buffer, int length); |
Tutaj taka mała uwaga, prócz zmiennej buffer
przekazywanej w argumencie, który jest zwykłym kawałkiem pamięci, w kodzie używana jest zmienna lokalna, leżąca sobie na stosie, o nazwie var_buffer
, która jest poznanym już buforem:
00511F10 var_buffer = VectorBuf ptr -28h |
Jest to jedyna istotna zmienna we fragmentach interesującego kodu. A skoro jest to vectorowy bufor, to pierwszy istotny kod odpowiada za jego inicjalizację i alokację 5 bajtów, dokładnie tak jak opisałem to wyżej:
00511FA9 mov eax, 5 00511FAE lea ecx, [ebp+var_buffer] [...] 00511FB8 call VectorBuf__Resize ; var_buffer.resize(5) |
Najbardziej ciekawy fragment to budowa pakietu danych (żądania) wysyłanego do urządzenia, który wypełnia te 5 zaalokowanych wcześniej bajtów pamięci.
00511FCA mov edx, [ebp+var_buffer.begin] 00511FCD mov byte ptr [edx], 7 ; buffer[0] = 0x07 [...] 00511FE0 mov ecx, [ebp+var_buffer.begin] 00511FE3 mov byte ptr [ecx+1], 33h ; buffer[1] = 0x33 [...] 00511FF7 mov eax, [ebp+var_buffer.begin] 00511FFA mov byte ptr [eax+2], 81h ; buffer[2] = 0x81 [...] 0051200E mov dl, byte ptr [ebp+address] 00512011 mov eax, [ebp+var_buffer.begin] 00512014 mov [eax+3], dl ; buffer[3] = (char)address [...] 00512027 mov dl, byte ptr [ebp+length] 0051202A mov eax, [ebp+var_buffer.begin] 0051202D mov [eax+4], dl ; buffer[4] = (char)length |
A później już wypchnięcie tego do urządzenia przez port USB i oczekiwanie na odpowiedź, która ładowana jest do bufora przekazanego w parametrze.
0051204D mov ebx, [ebp+var_buffer.end] 00512050 sub ebx, [ebp+var_buffer.begin] [...] 0051205D mov eax, [ebp+var_buffer.begin] 00512060 mov edx, [edi] 00512062 mov edx, [edx+8] 00512065 push ebx ; length 00512066 push eax ; buffer 00512067 lea ecx, [esi+0C0h] 0051206D push ecx ; pipeID 0051206E mov ecx, edi ; WindowsUsbDevice object ptr 00512070 call edx ; WindowsUsbDevice::Write 00512072 xor edi, edi 00512074 mov [ebp+address], edi [...] 00512097 movzx edx, byte ptr [ebp+length] 0051209B mov ecx, [esi+4] ; WindowsUsbDevice object ptr 0051209E mov eax, [ecx] 005120A0 mov eax, [eax+0Ch] 005120A3 push edx ; length 005120A4 mov edx, [ebp+buffer] 005120A7 push edx ; buffer 005120A8 add esi, 0C4h 005120AE push esi ; pipeID 005120AF call eax ; WindowsUsbDevice::Read |
Żadnej magii tutaj nie ma, trochę więcej może dziać się w bliźniaczej funkcji służącej do zapisu.
Prototyp WriteEeprom
jest następujący:
void __stdcall LogicAnalyzerDevice::WriteEeprom(void* obj, VectorBuf* buffer); |
WriteEeprom
w roli bufora z danymi do zapisu przyjmuje vector. Podobnie jak przy ReadEeprom
, tutaj również istotną dla nas zmienną jest jedynie var_buffer
leżący na stosie:
00511C50 var_buffer = VectorBuf ptr -24h |
który alokowany jest do rozmiaru zdolnego pomieścić zapisywane dane i 5 bajtowy nagłówek pakietu:
00511D00 mov eax, [ebp+10h] ; ebp = buffer 00511D03 sub eax, [ebp+0Ch] ; len = buffer.end - buffer.begin 00511D06 lea ecx, [esp+44h+var_buffer] 00511D0A add eax, 5 ; len += 5 00511D0D call VectorBuf__Resize ; var_buffer.resize(len) |
Budowa pakietu podobna jest jak przy odczycie, tylko z innymi wartościami identyfikującymi żądanie.
00511D21 mov eax, [esp+44h+var_buffer.begin] 00511D25 mov byte ptr [eax], 6 ; buffer[0] = 0x06 [...] 00511D3A mov edx, [esp+44h+var_buffer.begin] 00511D3E mov byte ptr [edx+1], 42h ; buffer[1] = 0x42 [...] 00511D54 mov ecx, [esp+44h+var_buffer.begin] 00511D58 mov byte ptr [ecx+2], 55h ; buffer[2] = 0x55 [...] 00511D6E mov eax, [esp+44h+var_buffer.begin] 00511D72 mov byte ptr [eax+3], 8 ; buffer[3] = 0x08 [...] 00511D7A mov ebx, [ebp+10h] ; ebp = buffer [...] 00511D81 sub ebx, [ebp+0Ch] ; length = buffer.end - buffer.begin [...] 00511D8E mov edx, [esp+44h+var_buffer.begin] 00511D92 mov [edx+4], bl ; buffer[4] = (char)length |
Tutaj mała uwagą, w parametrze nie jest przekazywany do funkcji ani adres ani offset, mówiący w którym miejscu pamięci EEPROM mają zostać zapisane dane. Za to 3 bajt pakietu wskazuje, że wykonane to ma być za 8 bajtem. Na podstawie tej informacji, mogę wnioskować, że zapisać można jedynie całość dodatkowych danych ulokowanych za identyfikatorami urządzenia w EEPROM. Ciekawa informacja.
Dalej, jak można się spodziewać, następuje dodanie do buforu, zaraz za nagłówkiem, danych jakie zostaną zapisane w EEPROM-ie. Dzieje się to w funkcji sub_512FC0
, ale nim to nastąpi dosyć dużo różnej maści instrukcji warunkowych weryfikujących bufory, rozmiary i tym podobne sprawy. Według mnie trochę za dużo i za bardzo pokomplikowane jest to. Podobne rzeczy dzieją się w w/w funkcji. Nie chcę tego tutaj przedstawiać, bo to trochę dużo jest tego kodu, w gruncie nie tak bardzo istotnego, bo ważne jest finalne działanie. Ciekawi mnie jaki był oryginalny kod źródłowy, który wygenerował tyle tych instrukcji... A może to kompilator...
Pomijając te kwestie, finalnie w kodzie sub_512FC0
, użyta zostaje funkcja memmove_s
, do przeniesienia danych z buffer
na koniec var_ buffer
, co odwzorowując w pseudokodzie można przedstawić jako:
memmove_s(var_buffer.begin + 5, buffer.end - buffer.begin, buffer.begin, buffer.end - buffer.begin); |
Na koniec zapis danych do portu USB, a tym samym do urządzenia, gdzie odpowiedni kod firmware zajmie się resztą.
00511E99 mov edi, [esp+44h+var_buffer.end] 00511E9D sub edi, [esp+44h+var_buffer.begin] [...] 00511EAB mov eax, [esp+44h+var_buffer.begin] 00511EAF mov edx, [esi] 00511EB1 mov edx, [edx+8] 00511EB4 push edi ; length 00511EB5 push eax ; buffer 00511EB6 add ebx, 0C0h 00511EBC push ebx ; pipeID 00511EBD mov ecx, esi ; WindowsUsbDevice object ptr 00511EBF call edx ; WindowsUsbDevice::Write |
Bazując na informacjach, jakie uzyskałem analizując działanie opisanych wyżej funkcji, całość można skrócić do prostego zapisu formatu danych pakietu (żądania), jaki wysyłany jest do urządzenia w celu operowania na pamięci EEPROM.
read: 07 33 81 ADR LEN write: 06 42 55 ADR LEN [data]
Pola ADR
i LEN
to 1 bajtowe wartości określające adres, bądź offset względem początku pamięci oraz długość danych na jakich będzie operować dana instrukcja. Przy odczycie długość określa ilość danych do odczytania, a przy zapisie długość danych zawartych w pakiecie, jakie należy zapisać w pamięci.
Szyfrowanie
Przesyłane dane są szyfrowane. Może to za mocne słowo, lepiej tutaj pasuje "zaciemnianie". Jeśli porównamy zawartość bufora wychodzącą z wyżej opisanych funkcji, z danymi jakie udało się uzyskać z podsłuchania transmisji (chociażby ze wzmianki zawartej w SaeLog #1) to bez trudu dostrzec można, że dane te są różne. Dane wchodzące i wychodzące z funkcji Read
i Write
w WindowsUsbDevice
są inne niż te wysyłane i odbierane na porcie USB.
Początkowo sam się zdziwiłem, gdy to odkryłem. Dopiero wraz z debugerem udało mi się zauważyć, że to właśnie w implementacji WindowsUsbDevice
przed wysłaniem i po odebraniu, dane trafiają najpierw do odpowiednich funkcji. Są nimi sub_4FDBA0
i sub_4FDBE0
, którym można przypisać bardziej adekwatne nazwy, jak EncryptData
i DecryptData
. Parametry do nich przekazywane są poprzez rejestry esi
i edi
, kolejno jako bufor i jego długość. A dane przekształcane są bezpośrednio w buforze źródłowym.
Algorytm szyfrowania danych przy zapisie do portu, zaszyty w kodzie sub_4FDBA0
przedstawia się następująco:
004FDBA0 sub_4FDBA0 proc near 004FDBA0 004FDBA0 var_1 = byte ptr -1 004FDBA0 004FDBA0 push ecx 004FDBA1 xor ecx, ecx 004FDBA3 push ebx 004FDBA4 mov [esp+8+var_1], 9Bh 004FDBA9 mov bl, 54h 004FDBAB test edi, edi 004FDBAD jbe short loc_4FDBDA 004FDBAF nop 004FDBB0 004FDBB0 loc_4FDBB0: 004FDBB0 mov dl, [ecx+esi] 004FDBB3 mov al, dl 004FDBB5 xor al, bl 004FDBB7 xor al, 2Bh 004FDBB9 sub al, 5 004FDBBB xor al, 35h 004FDBBD sub al, 39h 004FDBBF xor al, [esp+8+var_1] 004FDBC3 inc ecx 004FDBC4 xor al, 5Ah 004FDBC6 add al, 50h 004FDBC8 xor al, 38h 004FDBCA sub al, 45h 004FDBCC mov [ecx+esi-1], al 004FDBD0 mov bl, al 004FDBD2 mov [esp+8+var_1], dl 004FDBD6 cmp ecx, edi 004FDBD8 jb short loc_4FDBB0 004FDBDA 004FDBDA loc_4FDBDA: 004FDBDA pop ebx 004FDBDB pop ecx 004FDBDC retn 004FDBDC sub_4FDBA0 endp |
Odpowiednik tego kodu zapisany w języku C++ można przedstawić tak:
void EncryptData(char* buffer /*esi*/, size_t len /*edi*/) { size_t count = 0; char p = 0x9B; char h = 0x54; if (len == 0) return; do { char x; char c = buffer[count]; x = (((c ^ h ^ 0x2B) - 0x05) ^ 0x35) - 0x39; x = (((x ^ p ^ 0x5A) + 0x50) ^ 0x38) - 0x45; buffer[count] = x; h = x; p = c; count++; } while (count < len); } |
Odwrotność tych operacji można znaleźć w kodzie sub_4FDBE0
służącym do deszyfracji przychodzących danych z urządzenia.
Oczywiście w urządzeniu zaimplementowano ten sam algorytm, w postaci odpowiedników tych funkcji. Dla przykładu kod deszyfrujący z firmware, znajdujący się w funkcji code_10F5
:
000010F5 code_10F5: 000010F5 mov RAM_2F, R3 000010F7 mov RAM_30, R2 000010F9 mov RAM_31, R1 000010FB mov RAM_32, R5 000010FD mov RAM_34, #0x9B 00001100 mov RAM_35, #0x54 00001103 mov RAM_37, #0 00001106 00001106 code_1106: 00001106 mov A, RAM_37 00001108 clr C 00001109 subb A, RAM_32 0000110B jnc code_116D 0000110D mov R3, RAM_2F 0000110F mov R2, RAM_30 00001111 mov R1, RAM_31 00001113 mov R7, RAM_37 00001115 mov DPL0, R7 ; DPTR0 Low Byte 00001117 mov DPH0, #0 ; DPTR0 High Byte 0000111A lcall code_FD1 0000111D mov R7, A 0000111E mov RAM_36, R7 00001120 mov RAM_38, RAM_36 00001123 mov A, #0x45 00001125 add A, RAM_38 00001127 mov RAM_38, A 00001129 xrl RAM_38, #0x38 0000112C mov A, #0xB0 0000112E add A, RAM_38 00001130 mov RAM_38, A 00001132 xrl RAM_38, #0x5A 00001135 mov A, RAM_34 00001137 xrl RAM_38, A 00001139 mov A, #0x39 0000113B add A, RAM_38 0000113D mov RAM_38, A 0000113F xrl RAM_38, #0x35 00001142 mov A, #5 00001144 add A, RAM_38 00001146 mov RAM_38, A 00001148 xrl RAM_38, #0x2B 0000114B mov A, RAM_38 0000114D xrl A, RAM_35 0000114F mov RAM_33, A 00001151 mov R3, RAM_2F 00001153 mov R2, RAM_30 00001155 mov R1, RAM_31 00001157 mov R7, RAM_37 00001159 mov DPL0, R7 ; DPTR0 Low Byte 0000115B mov DPH0, #0 ; DPTR0 High Byte 0000115E mov A, RAM_33 00001160 lcall code_1010 00001163 mov RAM_35, RAM_36 00001166 mov RAM_34, RAM_33 00001169 inc RAM_37 0000116B sjmp code_1106 0000116D 0000116D code_116D: 0000116D ret 0000116D ; End of function code_10F5 |
Co podobnie, w zapisie bardziej ludzkim, dokładnie odpowiada operacji odwrotnej do szyfrowania:
void DecryptData(char* buffer /*esi*/, size_t len /*edi*/) { size_t count = 0; char p = 0x9B; char h = 0x54; if (len) { do { char x; char c = buffer[count]; x = (((c + 0x45) ^ 0x38) - 0x50) ^ 0x5A ^ p; x = (((x + 0x39) ^ 0x35) + 0x5) ^ 0x2B ^ h; buffer[count] = x; h = c; p = x; count++; printf("%x ", x); } while (count < len); } } |
O firmware, sposobach jego wydobycia i analizy pewnie napiszę w którymś z następnych wpisów.
Znając już sposób szyfrowania, bez problemu możemy potwierdzić, że działa to tak jak należy. Jeśli weźmiemy dane wysyłane do urządzenia przy pobieraniu identyfikatora (funkcja GetIdFromDevice
) i przepuścimy przez te obliczenia, to w wyniku otrzymamy dokładnie to co wyleci z USB:
input: 07 33 81 08 08 output: DF C6 B3 51 60
Na podstawie załączonych kodów wyraźnie widać, że z prawdziwym szyfrowaniem nie ma to wiele wspólnego, ot proste operacje matematyczne i logiczne.
...
Znając już techniczne niuanse związane z komunikacją programu z urządzeniem, jeśli chodzi o pamięć EEPROM, to można spróbować stworzyć mapę pamięci odzwierciedlającą jej zawartość i znaczenie poszczególnych danych w niej ulokowanych. Może to być ciekawe, choć na razie nie jest mi to potrzebne.
Teraz można przejść do zabaw z firmware, skoro mamy pojęcie jak to działa w aplikacji i wiemy, że to właśnie tam mieści się cała magia. Na tym pewnie będę się koncentrował w najbliższym czasie.
2 thoughts on “SaeLog #2: Komunikacja z pamięcią EEPROM”