Architektura procesora

Istnieje wiele architektur procesorów, obecnie najczęściej występujące to:
  • Intel x86 (32-bitowe, wywodzące się z procesora Intel 8086)
  • AMD64 (64-bitowe, rozszerzenie architektury x86) - Intel posiada prawie identyczną EM64T
  • POWER (w starszych Macach PowerPC, obecnie używana głównie na komputerach typu mainframe)
  • ARM (32- i 64-bitowy wariant, wszechobecny w smartfonach, tabletach, a od niedawna też niektórych laptopach)

Każda z nich charakteryzuje się innym zestawem dostępnych operacji, sposobem kodowania instrukcji, kompatybilnymi podzespołami bazowymi, zabezpieczeniami programów działających w userspace (o tym za chwilę), zwykle też producentem i patentami. Podczas naszych warsztatów zajmiemy się programami napisanymi pod architektury Intel x86 i AMD64, ponieważ występują one najczęściej na komputerach osobistych i serwerach, są do siebie bardzo podobne i mamy z nimi do czynienia na co dzień.

Schemat blokowy „elementów” procesora

_images/cpudiagram.png
Opis:
  • Random Access Memory - pamięć o dowolnym dostępie - zwykle \geq 2 GiB
  • Cache poziomu 1, 2[, 3] - pamięc podręczna procesora, pośredniczy w dostępie do RAMu, przyspieszając go - zwykle \leq 32 MiB
  • Memory Management Unit - układ zarządzania pamięcią - przekierowuje dostępy do pamięci do odpowiednich adresów pamięci fizycznej (RAM lub innych urządzeń)
  • Instruction Fetcher - pobiera kolejne instrukcje z pamięci
  • Instruction Decoder - dekoduje instrukcje do mikrokodu - uproszczonego zestawu instrukcji, faktycznie wykonywanych przez CPU
  • Scheduler - może zmieniać kolejność instrukcji, opóźniać ich wykonanie, „zlecać” pobranie danych z pamięci - aby jak najlepiej wykorzystać układy dostępne w procesorze
  • Arithmetic and Logic Unit - wykonuje obliczenia i operacje logiczne na danych z rejestrów i pamięci
  • Rejestry podstawowe (rax, rbx, …, r8, …, r16) - program może wykorzystywać je dowolnie do obliczeń, przekazywania i przechowywania danych
  • Rejestry wektorowe (xmm*) - służą do obsługiwania instrukcji SIMD (Single Instruction Multiple Data), znajdujących się w rozszerzeniach architektury takich jak SSE, AVX
  • Rejestry sterujące (cr0-cr3) - program nie ma do nich dostępu, system operacyjny może nimi włączać/wyłączać różne funkcje procesora oraz sterować m.in. MMU
  • Rejestry stosu (rsp, rbp) - zawierają lokalizację stosu w pamięci, procesor używa ich przy wielu instrukcjach
  • Rejestr rip - instruction pointer - zawiera lokalizację aktualnie wykonywanej instrukcji w programie, nie ma do niego bezpośredniego dostępu, trzeba używać specjalnych instrukcji, takich jak call, jmp

Pamięć wirtualna

_images/vmem.png

Źródło: Wiki OSDev <http://wiki.osdev.org/File:Virtual_memory.png>

Programy zwykle nie otrzymują bezpośredniego dostępu do RAMu i urządzeń podłączonych do procesora. Zamiast tego komunikacja następuje przez pamięć wirtualną, którą przydziela i zarządza system operacyjny.

W architekturze x86 są dostępne 4 GiB pamięci wirtualnej (32-bitowe adresowanie), a w AMD64 co najmniej 256 TiB (48-bitowe adresowanie, ale pojedyncze modele procesorów mogą wspierać aż do 64 bitów). Pamięć ta jest podzielona na 4KiB-, 2MiB-, albo 1GiB- (system może wybrać różny rozmiar dla różnych obszarów pamięci) niepodzielne „strony”, każda z nich może być przypisana do danego adresu pamięci fizycznej, ograniczona pod względem odczytu lub zapisu, lub ustawiona jako „pułapka”.

Taka pułapka w momencie próby dostępu do niej przerywa wykonywanie programu, pozwalając systemowi operacyjnemu na podjęcie działania, np. wczytanie danych do pamięci z dysku, zapisując na dysk inną stronę pamięci (swapping), co pozwala na uzyskanie dostępu do większej ilości pamięci niż jest dostępna w RAMie. Innym możliwym rozwiązaniem jest uznanie takiego dostępu jako błąd, wtedy na systemach *nix wysyłany jest sygnał SIGSEGV (segmentation fault), a na Windowsie błąd 0xc0000005.

Ciekawostka: to, że „nie wolno” zrobić dereferencji wskaźnika null, jest zaimplementowane właśnie za pomocą nie ustatwiania pierwszej strony pamięci wirtualnej jako dostępnej dla programów, ale np. na Linuxie za pomocą funkcji mmap da się uzyskać dostęp do adresu 0x0.

Stos i sterta

Pisząc programy często korzystamy ze „stosu” i „sterty”. Są to dwa obszary pamięci, do których dostęp daje większość bibliotek standardowych języków programowania. Sterta to zwykle obszar znajdujący się tuż za danymi i kodem programu w pamięci wirtualnej, dlatego może być rozszerzana praktycznie bez ograniczeń. Natomiast stos „rośnie w dół” - jego podstawę umieszcza się w pewnym adresie w pamięci i każda kolejna dana na nim umieszczona zmniejsza adres jego góry, aż do osiągnięcia limitu, który najdalej może być tuż za kodem aplikacji.

Trzymanie danych na stosie umożliwia zwykle szybszy do nich dostęp, ponieważ jest większe prawdopodobieństwo, że są one trzymane w pamięci podręcznej procesora. Pobranie 64 bajtów danych z RAMu do pamięci podrzęcznej na obecnych procesorach zajmuje zwykle około 200-300 cykli, podczas gdy dodawanie około 1 cyklu!

Jeżeli stos nie jest właściwie zabezpieczony, odniesienie się w nim odpowiednio „daleko” da dostęp do danych programu, co będzie jedną z metod PWNingu, którą pokażemy na warsztatach.

Pierścienie ochrony


Jeden, by wszystkimi rządzić, Jeden, by wszystkie odnaleźć,
Jeden, by wszystkie zgromadzić i w ciemności związać

Jak procesor odróżnia system operacyjny od programów? Do tego służą właśnie 4 (czasami 5) „pierścieni” - określających uprawnienia wykonywanego kodu.

  • Pierścień 0 - gdy procesor się włącza i wczytuje kod, pracuje w R0 (Ring 0) - jest to także ten pierścien, w którym pracuje system operacyjny
  • Pierścienie 1, 2 - zwykle nie są używane, niektóre systemy (np. OS/2) używały ich dla sterowników
  • Pierścień 3 - najbardziej ograniczony, nie może zarządzać pamięcią wirtualną ani wejściem/wyjściem, nie ma dostępu do stron „chronionych” To w nim właśnie działają programy użytkowników.
  • Pierścien -1 - ma jeszcze większe uprawnienia niż R0, jest używany przy technologiach wirtualizacji, aby kontrolować maszyny wirtualne przyspieszone sprzętowo

Jak przechodzi się między pierścieniami? Z R0 do R3 system może wykonać specjalną instrukcję skoku, która zmieni aktualny pierścień. Natomiast aby wrócić z programu do poziomu systemu jest kilka opcji:

  • Poczekać na przerwanie(interrupt) - urządzenia zewnętrzne, np. zegar systemowy, mogą generować przerwania, które są obsługiwane w R0
  • Wywołać przerwanie programowe - instrukcją int można zasymulować przerwanie, tak w architekturze x86 programy komunikują się z jądrem systemu operacyjnego
  • Użyć specjalnej instrukcji - w architekturze AMD64 wprowadzono instrukcję syscall, która pozwala na znacznie szybsze przejście do R0 i spowrotem niż przerwania programowe

Podstawowe operacje logiczne, arytmetyczne i powiązane

Podstawowa składnia asm:

; To jest komentarz
mov eax, 7 ; ustaw eax na 7
mov eax, ebx ; ustaw eax na wartość ebx
mov eax, [8] ; ustaw eax na wartość z pamięci pod adresem z 8
mov eax, [ebx] ; ustaw ebx na wartość z pamięci pod adresem odczytanym z ebx
mov eax, [ebx + ecx*4 + 128] ; ustaw eax na wartość z pamięci pod adresem
; obliczonym z wyrażenia [...] (wyrażenia są bardzo ograniczone)


  • mov CEL, ŹRÓDŁO - skopiuj wartość ze źródła do celu (mogą być to rejestry, lokalizacje w pamięci lub konkretne wartości
  • add CEL, ŹRÓDŁO - dodaje źródło do celu
  • sub CEL, ŹRÓDŁO - odejmuje źródło od celu
  • shl/shr CEL, ŹRÓDŁO - wykonuje przesunięcie bitowe w lewo/prawo na celu o źródło bitów
  • xchg A, B - zamienia A z B (muszą być to rejestry bądź rejestr i miejsce w pamięci)
  • lea CEL, ADRES - ADRES podaje się jak miejsce w pamięci, ale jest ono obliczane i zapisywane do celu tak jak robi to mov
  • mul B - mnoży (unsigned) rax przez B, zapisuje wynik do rdx(wyższe bity) i rax(niższe bity)
  • imul B - tak jak mul, ale wykonuje mnożenie ze znakiem (signed)
  • imul CEL, ŹRÓDŁO - mnoży cel przez źródło, zapisuje wynik do celu
  • imul CEL, A, B - mnoży A przez B i zapisuje do celu
  • div B - dzieli(unsigned) rdx:rax przez B, zapisuje część całkowitą do rdx, a resztę do eax
  • idiv B - tak jak div, ale dzieli ze znakiem (signed)
  • neg A - wykonuje negację A, zgodnie z metodą 2’s complement (A <- ~A + 1)
  • cdq - Convert dword to qword - rozszerza znak rax do rdx
  • inc A - zwiększa A o 1
  • dec A - zmniejsza A o 1
  • and/or/xor CEL, ŹRÓDŁO - wykonuje odpowiednią operację logiczną i zapisuje wynik do celu
  • not A - neguje wszystkie bity A


  • push A - odkłada wartość/rejestr A na stos
  • pop A - zdejmuje wartość ze stosu i zapisuje do A
  • pushf - odkłada flagi procesora na stosie
  • popf - zdejmuje flagi procesora ze stosu


  • nop - nic nie robi :D
  • naz.wa: - etykieta, oznacza adres kolejnej instrukcji; jeżeli zaczyna się kropką, to jest lokalna dla nadrzędnej etykiety
  • jmp ADRES - wykonuje skok do adresu (ustawia rip na adres)
  • call ADRES - odkłada adres następnej instrukcji na stos, a następnie skacze do adresu (jak wywołanie funkcji)
  • ret - zdejmuje adres ze stosu i do niego skacze (jak powrót z funkcji)
  • int BAJT - wywołuje przerwanie programowe o podanym numerze (1-bajtowym)
  • (tylko 64-bitowe) syscall - obsługuje syscall zgodnie z interfejsem systemu operacyjnego


  • test A, B - wykonuje and bitowy na A i B, flaga znaku jest ustawiana na najbardziej znaczący bit, zera jeżeli wynik jest zerem, parzystości, gdy wynik ma parzystą liczbę jedynek
  • cmp A, B - porównuje A z B i ustawia odpowiednie flagi
  • je/jz ADRES - skacze gdy ZF=1 (cmp: A==B)
  • jne/jnz ADRES - skacze gdy ZF=0 (cmp: A!=B)
  • jg ADRES - skacze gdy SF=OF;ZF=0 (cmp: A>B)
  • jge ADRES - skacze gdy SF=OF;ZF=1 (cmp: A>=B)
  • ja ADRES - tak jak jg, ale porównanie bez znaku
  • jae ADRES - tak jak jge, ale porównanie bez znaku
  • jl ADRES - skacze gdy SF!=OF (cmp: A>B)
  • jle ADRES - skacze gdy SF!=OF lub ZF=1 (cmp: A>=B)
  • jb(e) ADRES - są dla jl i jle tak jak ja i jae dla jg i jge
  • jo ADRES - skacze gdy OF=1 (overflow flag)
  • jno ADRES - skacze gdy OF=0 (overflow flag)
  • js ADRES - skacze gdy SF=1 (sign flag)
  • jns ADRES - skacze gdy SF=0 (sign flag)
  • loop ADRES - zmniejsza rcx o 1, skacze jeżeli rcx jest jeszcze większe od 0
  • loop(e/ne/z/nz) - jeżeli warunek jest spełniony to działa tak jak loop, jeżeli nie to jest pomijana