Assembler od podstaw - od rejestrów i pamięci do prawdziwego programu
Assembler wygląda na pierwszy rzut oka jak język z innej planety:
LDA #$01
STA $0400
INX
BNE loop
Nie ma klas.
Nie ma obiektów.
Nie ma npm install.
Nie ma garbage collectora.
Nie ma nawet jednej wspólnej wersji języka.
A mimo to assembler jest jednym z najlepszych sposobów, żeby naprawdę zrozumieć:
- jak działa procesor,
- czym są rejestry,
- czym jest stos,
- jak wygląda pamięć,
- skąd biorą się adresy,
- czym naprawdę jest funkcja,
- co robi kompilator,
- co robi linker,
- czym różni się kod źródłowy od kodu maszynowego,
- dlaczego C wygląda tak, jak wygląda,
- skąd bierze się koszt wywołania funkcji,
- czym jest ABI,
- co znaczy „32-bit”, „64-bit” albo „8-bit”.
Ten materiał nie ma zrobić z Ciebie zawodowego programisty assemblera.
Ma sprawić, że kiedy zobaczysz:
mov rax, rbx
albo:
lda #$20
będziesz rozumiał, co komputer właściwie robi.
Głównym torem praktycznym będzie:
MOS 6502 / MOS 6510 / Commodore 64
bo jest wystarczająco prosty, żeby zobaczyć procesor bez tysięcy szczegółów współczesnego x86-64.
Na końcu porównamy go z:
- x86-64,
- ARM,
- RISC-V.
Powiązane materiały:
- C - czytanie, kompilacja i debugowanie
- Debian - shell
- Visual Studio Code
- GitHub
- 20 współczesnych języków programowania, które warto znać
- Stare języki programowania, które ukształtowały informatykę
Najpierw jedna ważna rzecz: assembler czy assembly?
W codziennej polszczyźnie często mówi się:
programuję w assemblerze
i wszyscy wiedzą, o co chodzi.
Technicznie warto jednak rozróżnić dwie rzeczy.
Assembly language
To język asemblera - symboliczny zapis instrukcji procesora.
Przykład:
LDA #$01
Assembler
To program tłumaczący zapis tekstowy na kod maszynowy.
Przykłady assemblerów:
ca65
NASM
GNU as
MASM
64tass
DASM
Czyli:
assembly source
|
v
assembler
|
v
machine code
W tym artykule będziemy używać potocznego słowa „assembler” również na określenie samego języka, ale dobrze wiedzieć, co technicznie oznacza.
Nie istnieje jeden assembler
To najważniejsza różnica względem Pythona, Javy czy Go.
Kod:
LDA #$10
ma sens dla procesorów z rodziny 6502.
Kod:
mov rax, 10
jest charakterystyczny dla x86-64.
Kod:
mov x0, #10
może pojawić się w AArch64.
Kod:
li a0, 10
jest typowy dla RISC-V.
Każda architektura ma własny:
Instruction Set Architecture
czyli:
ISA
ISA definiuje między innymi:
- jakie instrukcje rozumie procesor,
- jakie posiada rejestry,
- jak adresuje pamięć,
- jak reprezentowane są instrukcje,
- jakie typy operacji potrafi wykonywać.
ISA - umowa między programem a procesorem
Wyobraź sobie procesor jako maszynę, która zna określony zestaw poleceń.
Przykładowy hipotetyczny procesor mógłby znać:
LOAD
STORE
ADD
SUB
JUMP
COMPARE
Program:
LOAD A, 10
LOAD B, 20
ADD A, B
jest jedynie wygodnym zapisem dla człowieka.
Procesor nie czyta słowa:
ADD
Procesor widzi liczby.
Na przykład hipotetycznie:
00010010 00000001 00000010
Assembler wykonuje więc tłumaczenie:
ADD A, B
na odpowiedni ciąg bitów.
Kod maszynowy
Kod maszynowy jest bezpośrednio wykonywany przez CPU.
Jeżeli instrukcja 6502:
LDA #$01
zostanie zakodowana jako:
A9 01
to:
A9
jest kodem operacji:
LDA immediate
a:
01
jest argumentem.
W pamięci mamy więc dwa bajty:
A9 01
Procesor:
- pobiera
A9, - rozpoznaje instrukcję,
- pobiera kolejny bajt,
- wykonuje operację.
Hexadecymalny zapis liczb
W assemblerze bardzo często używa się systemu szesnastkowego.
Zamiast:
0
1
2
...
9
10
11
mamy:
0
1
2
...
9
A
B
C
D
E
F
10
Jedna cyfra hex reprezentuje dokładnie:
4 bity
Dwie cyfry:
8 bitów = 1 bajt
Przykład:
$00 = 0
$01 = 1
$0A = 10
$10 = 16
$FF = 255
W dokumentacji 6502 często używa się:
$FF
W C:
0xFF
W NASM:
0xFF
To ta sama wartość.
Bity i bajty
Bit może mieć wartość:
0
albo:
1
Osiem bitów tworzy bajt:
10110100
Zakres bajtu bez znaku:
0..255
czyli:
$00..$FF
Procesor 6502 jest nazywany procesorem 8-bitowym między innymi dlatego, że jego podstawowy akumulator i główne operacje danych mają szerokość 8 bitów.
Nie oznacza to, że potrafi zaadresować tylko 256 bajtów.
Ma 16-bitową przestrzeń adresową:
$0000..$FFFF
czyli:
65536 bajtów = 64 KiB
Co właściwie robi CPU?
W ogromnym uproszczeniu:
pobierz instrukcję
|
v
zdekoduj instrukcję
|
v
wykonaj instrukcję
|
v
pobierz następną
To cykl:
fetch -> decode -> execute
W środku procesora znajdują się między innymi:
- rejestry,
- jednostka arytmetyczno-logiczna,
- dekoder instrukcji,
- logika sterująca.
Rejestry - najszybsza pamięć procesora
Rejestr jest małym miejscem przechowywania danych bezpośrednio wewnątrz CPU.
Nie myl go z RAM-em.
RAM jest osobnym obszarem pamięci.
Rejestr:
CPU
┌────────────────────┐
│ register A │
│ register X │
│ register Y │
│ program counter │
│ stack pointer │
└────────────────────┘
RAM:
CPU <----> RAM
Dostęp do rejestru jest fundamentalnie inną operacją niż odczyt pamięci.
MOS 6502 i MOS 6510
Klasyczny MOS 6502 był jednym z najważniejszych procesorów ery 8-bitowej.
Rodzina trafiła między innymi do:
- Apple II,
- Atari 2600 - wariant 6507,
- komputerów Atari 8-bit,
- BBC Micro,
- NES - pochodny procesor,
- wielu systemów embedded.
Commodore 64 używa procesora:
MOS 6510
6510 jest bliskim krewnym 6502.
Najważniejszym dodatkiem jest wbudowany port I/O wykorzystywany przez C64 między innymi do sterowania mapowaniem pamięci.
Do nauki instrukcji możemy myśleć o nim praktycznie jak o:
6502 + kilka cech specyficznych C64
Rejestry 6502
6502 ma zaskakująco mało rejestrów.
To jedna z rzeczy, dzięki którym świetnie nadaje się do nauki.
A - accumulator
A
Główny rejestr danych.
Wiele operacji działa właśnie na nim.
Przykład:
LDA #10
oznacza:
Load Accumulator
czyli:
A = 10
X
Rejestr indeksowy:
X
Przydaje się między innymi do:
- indeksowania tablic,
- liczników,
- pętli.
Przykład:
LDX #0
Y
Drugi rejestr indeksowy:
Y
Podobny w zastosowaniach do X, choć zestaw dostępnych instrukcji nie jest identyczny.
PC - Program Counter
PC
to licznik programu.
Przechowuje adres następnej instrukcji.
Jeżeli:
PC = $C000
procesor pobiera instrukcję spod adresu:
$C000
Po wykonaniu przechodzi dalej.
Instrukcja skoku zmienia PC.
Przykład:
JMP $C100
oznacza w praktyce:
PC = $C100
SP - Stack Pointer
SP
wskazuje pozycję stosu.
W 6502 stos znajduje się zawsze w stronie pamięci:
$0100..$01FF
SP jest 8-bitowy.
Jeżeli:
SP = $FD
to bieżąca pozycja stosu znajduje się w okolicy:
$01FD
P - Processor Status
Rejestr statusu zawiera flagi.
Najważniejsze z nich:
N - Negative
V - Overflow
B - Break
D - Decimal
I - Interrupt Disable
Z - Zero
C - Carry
Można spotkać zapis:
NV-BDIZC
Każda flaga jest pojedynczym bitem.
Flaga Zero
Jeżeli wynik operacji wynosi zero:
Z = 1
Przykład:
LDA #0
ustawi flagę Zero.
Możemy potem zrobić:
BEQ somewhere
czyli:
Branch if Equal
W praktyce instrukcja sprawdza flagę Z.
Flaga Carry
Carry jest wykorzystywana między innymi przy:
- dodawaniu,
- odejmowaniu,
- przesunięciach bitowych,
- liczbach większych niż jeden bajt.
Przykład:
CLC
LDA #200
ADC #100
Matematycznie:
200 + 100 = 300
Ale 8-bitowy rejestr mieści maksymalnie:
255
Wynik w A „zawinie się”, a dodatkowy bit informacji znajdzie się w Carry.
Pamięć
Dla CPU pamięć jest zasadniczo dużą tablicą bajtów.
Wyobraź sobie:
adres wartość
$0000 $12
$0001 $A0
$0002 $FF
$0003 $00
...
Instrukcja:
LDA $2000
oznacza:
weź bajt spod adresu $2000
i włóż go do A
Instrukcja:
STA $2000
oznacza:
zapisz zawartość A pod adresem $2000
Natychmiastowa wartość kontra adres
To jedna z pierwszych rzeczy, która myli początkujących.
Immediate
LDA #$10
oznacza:
A = $10
Znak:
#
oznacza wartość bezpośrednią.
Absolute
LDA $0010
oznacza:
A = zawartość pamięci pod adresem $0010
To ogromna różnica.
#$10
to liczba.
$0010
to adres.
Adresowanie
6502 posiada kilka trybów adresowania.
Nie trzeba zapamiętywać ich wszystkich od razu.
Najważniejsze:
Immediate
LDA #$20
Wartość jest częścią instrukcji.
Zero page
LDA $20
Adres z zakresu:
$0000..$00FF
6502 posiada specjalne krótsze i często szybsze instrukcje dla tego obszaru.
Absolute
LDA $2000
Pełny 16-bitowy adres.
Indexed X
LDA table,X
Adres:
table + X
Idealne do tablic.
Indexed Y
LDA table,Y
Indirect
Adres jest pobierany z pamięci.
To coś zbliżonego do wskaźnika.
Zero page
Adresy:
$0000..$00FF
tworzą:
zero page
Dla 6502 ten obszar jest szczególnie ważny.
Można traktować go trochę jak zestaw bardzo szybkich pseudo-rejestrów.
Przykład:
LDA $10
może być krótsze niż:
LDA $2010
W starych programach 6502 zero page była cennym zasobem.
Stos
Stos działa według zasady:
Last In, First Out
czyli:
LIFO
Wyobraź sobie stos talerzy.
Kładziesz:
A
B
C
Zdejmujesz:
C
B
A
Push
6502:
PHA
czyli:
Push Accumulator```
## Pull
```asm
PLA
czyli:
Pull Accumulator
Przykład:
LDA #10
PHA
LDA #20
PLA
Po PLA A ponownie będzie zawierać:
10
Po co stos?
Między innymi do:
- zapisywania tymczasowych wartości,
- obsługi podprogramów,
- obsługi przerwań.
Instrukcja:
JSR subroutine
musi gdzieś zapamiętać:
dokąd wrócić
Adres powrotu trafia na stos.
Potem:
RTS
go odczytuje.
Funkcja w assemblerze nie jest magią
W C:
foo();
wygląda jak jedna operacja.
Pod spodem trzeba jednak:
- przygotować argumenty,
- zapamiętać miejsce powrotu,
- przejść do innego fragmentu kodu,
- wykonać funkcję,
- wrócić,
- odzyskać wynik.
Na 6502 podstawowy mechanizm wygląda tak:
JSR foo
a funkcja kończy się:
RTS
Przykład:
JSR clear_screen
JSR draw_player
JSR update_score
To bardzo bezpośrednia forma wywołań funkcji.
Etykiety
Zamiast pisać:
JMP $C042
możemy nadać adresowi nazwę:
JMP game_loop
A potem:
game_loop:
...
Assembler podczas budowania programu wyliczy właściwy adres.
To właśnie jedna z podstawowych rzeczy, które odróżniły assembler od ręcznego kodu maszynowego.
Pętla
Przykład 6502:
LDX #0
loop:
INX
CPX #10
BNE loop
Znaczenie:
X = 0
loop:
X = X + 1
porównaj X z 10
jeśli nie są równe:
wróć do loop
To dokładnie ta sama logika, którą w C zapisalibyśmy:
for (int x = 0; x < 10; x++) {
}
Porównania na 6502
Instrukcja:
CMP
porównuje akumulator z wartością.
Przykład:
LDA score
CMP #10
BEQ player_won
CPU nie tworzy magicznej wartości:
true
Ustawia flagi.
Potem:
BEQ
BNE
BCC
BCS
BMI
BPL
sprawdzają te flagi.
To pokazuje, skąd w językach wysokiego poziomu biorą się instrukcje:
if
while
for
Procesor ich nie zna.
Kompilator buduje je ze skoków i porównań.
Dodawanie
Na 6502:
CLC
LDA #10
ADC #20
Po wykonaniu:
A = 30
CLC oznacza:
Clear Carry
Dlaczego jest potrzebne?
Bo ADC wykonuje:
A + wartość + Carry
Jeżeli Carry zostało po poprzedniej operacji ustawione, wynik mógłby być większy o 1.
Odejmowanie
SEC
LDA #30
SBC #10
SEC oznacza:
Set Carry
W 6502 semantyka Carry przy odejmowaniu jest trochę nieintuicyjna dla początkujących.
Dlatego typowy schemat zaczyna się od:
SEC
Liczby większe niż 255
6502 jest 8-bitowy, ale oczywiście może liczyć większe wartości.
Trzeba tylko podzielić liczbę na bajty.
Na przykład liczba 16-bitowa:
$1234
składa się z:
high byte = $12
low byte = $34
Dodawanie dwóch 16-bitowych liczb wykonujemy na dwóch bajtach, przenosząc Carry.
Przykład ideowy:
CLC
LDA a_low
ADC b_low
STA result_low
LDA a_high
ADC b_high
STA result_high
Carry z pierwszego dodawania trafia do drugiego.
W języku C piszesz:
uint16_t c = a + b;
Kompilator wykonuje podobną pracę za Ciebie.
Little endian
6502 zapisuje wartości wielobajtowe w kolejności:
low byte
high byte
To:
little endian
Liczba:
$1234
w pamięci może wyglądać:
adres $2000 -> $34
adres $2001 -> $12
x86 również jest little endian.
To między innymi dlatego czytając dump pamięci trzeba uważać na kolejność bajtów.
Commodore 64 - mapa pamięci
C64 jest kapitalnym komputerem do nauki assemblera, bo sprzęt jest stosunkowo prosty i bardzo dobrze udokumentowany.
Procesor widzi przestrzeń:
$0000..$FFFF
czyli 64 KiB adresów.
Najważniejsze obszary:
| Adres | Znaczenie |
|---|---|
$0000-$00FF |
zero page |
$0100-$01FF |
stos CPU |
$0400-$07E7 |
domyślna pamięć ekranu tekstowego |
$0801... |
typowy początek programu BASIC |
$A000-$BFFF |
BASIC ROM, zależnie od mapowania |
$D000-$DFFF |
I/O / character ROM, zależnie od mapowania |
$D800-$DBE7 |
pamięć kolorów |
$E000-$FFFF |
KERNAL ROM, zależnie od mapowania |
To mapa uproszczona.
C64 potrafi przełączać widoczność RAM-u, ROM-u i I/O.
Ekran tekstowy C64
Domyślna pamięć znaków zaczyna się pod:
$0400
Każda komórka odpowiada jednej pozycji na ekranie.
40 kolumn:
40
25 wierszy:
25
Razem:
1000 znaków
Jeżeli zapiszesz odpowiedni kod ekranu pod:
$0400
zmienisz znak w lewym górnym rogu.
Przykład:
LDA #1
STA $0400
Przy standardowym zestawie znaków kod ekranowy 1 odpowiada literze A.
To jest jedna z najpiękniejszych rzeczy w starym sprzęcie:
zapis do pamięci
=
zmiana obrazu
Bez:
DOM
Canvas
OpenGL
DirectX
browser API
Pamięć koloru
Kolor znaków znajduje się osobno:
$D800...
Przykład:
LDA #2
STA $D800
zmienia kolor pierwszej komórki.
Numer koloru zależy od palety C64.
Możemy więc:
LDA #1
STA $0400
LDA #2
STA $D800
i jednocześnie ustawić:
znak
+
kolor
Rejestry sprzętowe
W starych komputerach sprzętem często steruje się przez pamięć.
To:
memory-mapped I/O
Przykładowo rejestry układu VIC-II znajdują się w przestrzeni:
$D000...
Zmiana odpowiedniego bajtu może:
- przesunąć sprite,
- zmienić kolor tła,
- ustawić tryb grafiki,
- zmienić raster.
Dla procesora instrukcja:
STA $D020
jest po prostu zapisem do adresu.
Ale sprzęt interpretuje ten adres jako:
rejestr koloru ramki
Zmiana koloru ramki C64
Jedno z najprostszych doświadczeń.
LDA #2
STA $D020
Adres:
$D020
to kolor ramki.
2
oznacza czerwony w standardowej palecie C64.
Dwie instrukcje i fizyczny wygląd ekranu się zmienia.
KERNAL - gotowe funkcje w ROM-ie
Nie wszystko trzeba robić ręcznie.
C64 ma ROM zawierający zestaw procedur systemowych.
Jedną z najbardziej znanych jest:
CHROUT
pod adresem:
$FFD2
Jeżeli umieścimy znak w A:
LDA #'A'
i wykonamy:
JSR $FFD2
KERNAL wypisze znak.
To odpowiednik bardzo prymitywnego systemowego API.
Nasz toolchain 6502/C64
Użyjemy:
cc65
Nie dlatego, że chcemy pisać C.
Pakiet cc65 zawiera cały zestaw narzędzi:
cc65 - kompilator C
ca65 - assembler 6502
ld65 - linker
cl65 - program spinający build
da65 - disassembler
sim65 - simulator
Dla naszego artykułu najważniejsze są:
ca65
ld65
cl65
Instalacja cc65 - Windows
Projekt cc65 publikuje buildy dla Windows.
Strona projektu:
https://cc65.github.io/
Repozytorium:
https://github.com/cc65/cc65
Po instalacji lub rozpakowaniu narzędzi katalog bin dodajemy do:
PATH
Sprawdzenie:
ca65 --version
cl65 --version
Instalacja cc65 - macOS
Homebrew:
brew install cc65
Sprawdzenie:
ca65 --version
cl65 --version
Instalacja cc65 - Linux / Debian
sudo apt update
sudo apt install cc65
Sprawdzenie:
ca65 --version
cl65 --version
Pakiet zawiera pełny zestaw cross-development dla systemów 6502, w tym target C64.
Emulator C64 - VICE
Nie potrzebujemy prawdziwego C64.
Do uruchamiania użyjemy:
VICE
VICE emuluje między innymi:
- C64,
- C128,
- VIC-20,
- PET,
- Plus/4.
VICE - Windows
Aktualne buildy znajdziesz na stronie projektu:
https://vice-emu.sourceforge.io/
Po instalacji interesuje nas przede wszystkim emulator C64:
x64sc
VICE - macOS
Homebrew:
brew install vice
Sprawdzenie:
x64sc --version
VICE - Debian
VICE znajduje się w sekcji:
contrib
repozytorium Debiana.
Po włączeniu contrib:
sudo apt update
sudo apt install vice
Sprawdzenie:
x64sc --version
W zależności od sposobu instalacji emulator może wymagać dostępnych legalnie obrazów ROM.
VS Code
Assembler świetnie pisze się w zwykłym edytorze.
Możesz używać:
- VS Code,
- Vim,
- Neovim,
- Micro,
- dowolnego edytora tekstowego.
Pełny materiał:
Najważniejsze rzeczy:
- syntax highlighting,
- terminal,
- możliwość uruchamiania buildów,
- wygodne przełączanie między źródłem a debugerem.
Nie potrzebujesz rozbudowanego IDE.
Pierwszy prawdziwy program C64
Utwórz:
hello.s
Kod:
.segment "CODE"
start:
ldx #0
loop:
lda message,x
beq done
jsr $ffd2
inx
bne loop
done:
rts
message:
.byte "HELLO FROM 6502!", 13, 0
Co ten kod robi?
Segment
.segment "CODE"
informuje assembler i linker, że kolejne dane należą do segmentu kodu.
X = 0
LDX #0
X będzie indeksem tekstu.
Pobieranie znaku
LDA message,X
Czyli:
A = message[X]
Koniec tekstu
Tekst kończymy bajtem:
0
Po LDA procesor ustawia flagę Z, jeśli załadowana wartość jest zerem.
Dlatego:
BEQ done
kończy pętlę.
Wypisanie
JSR $FFD2
wywołuje KERNAL CHROUT.
Następny znak
INX
czyli:
X++
Pętla
BNE loop
Ponieważ X jest 8-bitowy, po 255 przepełni się do zera.
Dla krótkiego tekstu nie ma to znaczenia.
Budowanie programu C64
cc65 posiada specjalną konfigurację dla programów assemblerowych C64:
c64-asm.cfg
Możemy użyć:
cl65 \
-o hello.prg \
-u __EXEHDR__ \
-t c64 \
-C c64-asm.cfg \
hello.s
Opcja:
-u __EXEHDR__
dodaje mały nagłówek BASIC.
Dzięki temu po załadowaniu programu można wykonać:
RUN
zamiast ręcznie wpisywać adres SYS.
Uruchomienie w VICE
x64sc -autostart hello.prg
VICE:
- uruchomi C64,
- załaduje plik PRG,
- wystartuje program.
To już jest pełny cykl:
source
↓
assembler
↓
object code
↓
linker
↓
PRG
↓
emulator
↓
MOS 6510
ca65 i ld65 osobno
cl65 robi kilka kroków automatycznie.
Warto jednak wiedzieć, co jest pod spodem.
Assembler
ca65 -t c64 hello.s -o hello.o
Powstaje:
hello.o
To nie jest jeszcze gotowy program C64.
Linker
Linker:
ld65
łączy:
- segmenty,
- symbole,
- biblioteki,
- adresy.
Do praktycznej pracy na C64 konfiguracja linkera ma ogromne znaczenie.
Dlatego na początku wygodniej używać:
cl65
Co robi linker?
Wyobraź sobie dwa pliki.
main.s:
.import print_message
JSR print_message
print.s:
.export print_message
print_message:
...
RTS
Assembler przetwarza je oddzielnie.
W main.o jeszcze nie musi być znany finalny adres:
print_message
Linker:
- łączy moduły,
- przydziela adresy,
- rozwiązuje symbole,
- poprawia odwołania.
To dokładnie ten sam mechanizm, który spotkasz później w C i C++.
Symbole
Assembler pozwala definiować nazwy:
SCREEN = $0400
BORDER = $D020
CHROUT = $FFD2
Dzięki temu:
STA BORDER
jest czytelniejsze niż:
STA $D020
Program:
SCREEN = $0400
COLOR = $D800
LDA #1
STA SCREEN
LDA #2
STA COLOR
jest prawie samodokumentujący.
Stałe kontra dane
Stała assemblera:
BORDER = $D020
nie zajmuje pamięci programu jako zmienna.
To po prostu symbol zastępowany podczas składania.
Zmienna:
counter:
.byte 0
faktycznie tworzy bajt w danych programu.
Tablica
numbers:
.byte 1, 2, 3, 4, 5
Możemy czytać:
LDX #0
LDA numbers,X
Potem:
INX
i pobrać następny element.
Kopiowanie tablicy
ldx #0
loop:
lda source,x
sta destination,x
inx
cpx #10
bne loop
Odpowiednik C:
for (int x = 0; x < 10; x++) {
destination[x] = source[x];
}
To bardzo dobry moment, żeby zobaczyć jak blisko C stoi assemblera.
Wskaźniki
W C:
char *ptr;
W assemblerze nie istnieje specjalny magiczny typ:
pointer
Adres jest po prostu liczbą.
Na 6502 typowy wskaźnik 16-bitowy możemy przechowywać w dwóch bajtach zero page:
ptr:
.word $0000
Potem używać adresowania pośredniego.
Przykład:
LDA (ptr),Y
Znaczenie ideowe:
A = memory[ptr + Y]
To bardzo bliski odpowiednik:
ptr[y]
Dlaczego C i assembler są tak blisko?
C powstał jako język do pisania systemów.
Dlatego konstrukcje takie jak:
*p
p++
array[i]
uint8_t
uint16_t
mają bardzo naturalne odpowiedniki niskopoziomowe.
Jeżeli assembler zacznie być czytelny, wiele elementów C nagle przestaje wyglądać dziwnie.
Zobacz:
C - czytanie, kompilacja i debugowanie
Mini-projekt: wypełniamy ekran
Domyślna pamięć ekranu:
$0400
Chcemy wypełnić pierwsze 256 znaków literą A.
SCREEN = $0400
.segment "CODE"
start:
ldx #0
lda #1
loop:
sta SCREEN,x
inx
bne loop
rts
Dlaczego pętla wykona się dokładnie 256 razy?
X jest 8-bitowy.
Kolejne wartości:
0
1
2
...
254
255
0
Po przejściu:
255 -> 0
flaga Zero zostanie ustawiona.
BNE loop
nie wykona skoku.
To piękny przykład wykorzystania zachowania procesora zamiast osobnego licznika.
Czyszczenie całego ekranu
Ekran ma:
1000
komórek.
Nie zmieści się więc w jednej 8-bitowej pętli.
Możemy czyścić go blokami:
SCREEN = $0400
.segment "CODE"
start:
lda #32
ldx #0
loop:
sta SCREEN,x
sta SCREEN+$0100,x
sta SCREEN+$0200,x
inx
bne loop
ldx #0
last:
sta SCREEN+$0300,x
inx
cpx #232
bne last
rts
Dlaczego:
232
Bo:
1000 - 768 = 232
Pierwsza pętla czyści:
3 * 256 = 768
komórek.
Druga:
232
Razem:
1000
Makra
Assembler może posiadać system makr.
ca65 pozwala napisać:
.macro set_border color
lda #color
sta $d020
.endmacro
Potem:
set_border 2
Assembler rozwinie makro podczas budowania.
To nie jest funkcja wykonywana przez CPU.
To transformacja kodu przed utworzeniem kodu maszynowego.
Dyrektywy assemblera
Linia:
.byte 1, 2, 3
nie jest instrukcją CPU.
To polecenie dla assemblera:
umieść te bajty w wyniku.
Podobnie:
.word $1234
.segment "CODE"
.import foo
.export bar
To:
assembler directives
CPU nigdy ich nie widzi.
Instrukcja CPU kontra dyrektywa
Instrukcja:
LDA #1
zamieni się na kod maszynowy wykonywany przez procesor.
Dyrektywa:
.byte 1
mówi assemblerowi, żeby umieścił bajt w pliku.
To fundamentalna różnica.
Pseudo-instrukcje
Niektóre assemblery oferują zapis wyglądający jak instrukcja procesora, ale tak naprawdę składający się z jednej lub wielu prawdziwych instrukcji.
To:
pseudo-instruction
Jest to szczególnie częste w RISC-V.
Na przykład:
li a0, 100
może zostać zamienione przez assembler na odpowiednią sekwencję instrukcji zależnie od wartości.
Przerwania
Normalnie CPU wykonuje:
instrukcja
instrukcja
instrukcja
instrukcja
Ale czasem sprzęt mówi:
potrzebuję uwagi teraz
To:
interrupt
Procesor:
- kończy bieżącą instrukcję,
- zapisuje potrzebny stan,
- przechodzi do procedury przerwania,
- obsługuje zdarzenie,
- wraca.
Przerwania mogą obsługiwać między innymi:
- timer,
- klawiaturę,
- kartę sieciową,
- układ graficzny,
- kontroler dysku.
Raster interrupt na C64
VIC-II rysuje ekran linia po linii.
Programista może skonfigurować przerwanie w określonej linii rastra.
Dzięki temu można np.:
- zmieniać kolor w trakcie rysowania ekranu,
- multipleksować sprite'y,
- synchronizować animację,
- wykonywać efekty demoscenowe.
To już zaawansowany temat.
Ale warto zrozumieć ideę:
sprzęt
|
v
interrupt
|
v
nasz kod
Cykl zegara
Stare procesory są świetne do nauki wydajności, bo koszt instrukcji jest bardzo namacalny.
Instrukcja może kosztować np.:
2 cykle
3 cykle
4 cykle
Przy kodzie synchronizowanym z rasterem różnica jednego cyklu może mieć znaczenie.
Współczesny x86 jest dużo bardziej złożony:
- pipeline,
- cache,
- out-of-order execution,
- branch prediction,
- superscalar execution.
Na 6502 związek między instrukcją a czasem jest znacznie łatwiejszy do obserwowania.
Samomodyfikujący się kod
Ponieważ program jest zapisany w pamięci, program może teoretycznie zmieniać własne instrukcje.
Przykład ideowy:
zmień operand instrukcji LDA
Takie techniki były wykorzystywane w starych systemach dla wydajności.
Dziś samomodyfikujący się kod jest dużo rzadszy w normalnych aplikacjach, między innymi z powodów:
- bezpieczeństwa,
- ochrony pamięci,
- cache procesora,
- czytelności.
Ale warto wiedzieć, że kod też jest po prostu danymi w pamięci.
Kod i dane
Dla procesora bajt:
$A9
sam w sobie nie „jest instrukcją”.
Staje się instrukcją, jeśli Program Counter wskaże go jako początek instrukcji.
Ten sam bajt może być:
liczbą
znakiem
kolorem
fragmentem instrukcji
częścią adresu
pikselem
Znaczenie nadaje kontekst.
Disassembler
Assembler robi:
assembly -> machine code
Disassembler próbuje zrobić odwrotnie:
machine code -> assembly
W cc65 znajdziemy:
da65
W NASM:
ndisasm
W GNU binutils:
objdump
Przykład:
objdump -d program
To jedno z podstawowych narzędzi reverse engineeringu.
Debugger
Debugger pozwala:
- zatrzymać CPU,
- wykonać jedną instrukcję,
- sprawdzić rejestry,
- obejrzeć pamięć,
- ustawić breakpoint.
W assemblerze debugger jest szczególnie wartościowy.
Na wysokim poziomie patrzysz:
zmienna x
Na niskim poziomie:
RAX
RSP
memory[0x7fff...]
flags
Monitor VICE
VICE posiada wbudowany monitor.
To debugger świata C64.
Pozwala między innymi:
- oglądać pamięć,
- disassemblować,
- ustawiać breakpointy,
- sprawdzać rejestry,
- zmieniać pamięć.
To idealne narzędzie do nauki.
W emulatorze można wejść do monitora i zobaczyć coś w rodzaju:
A:00 X:00 Y:00 SP:F6
Nagle abstrakcyjne rejestry stają się czymś realnym.
Symbole debugowe cc65
cc65 potrafi generować informacje debugowe.
Przy większym projekcie warto budować np. z opcją:
-g
Dzięki symbolom debugger może pokazywać nazwy zamiast samych surowych adresów.
Drugi świat: x86-64
6502 jest prosty.
Procesor w Twoim współczesnym PC jest zupełnie inną bestią.
Najpopularniejsza architektura komputerów PC:
x86-64
nazywana również:
AMD64
Intel używa też nazwy:
Intel 64
x86-64 ma dużo większe rejestry
Przykłady:
RAX
RBX
RCX
RDX
RSI
RDI
RSP
RBP
R8
R9
...
R15
Typowy rejestr ogólnego przeznaczenia ma:
64 bity
czyli:
8 bajtów
To ogromna różnica względem 8-bitowego A w 6502.
RAX i jego fragmenty
Historyczne dziedzictwo x86 sprawia, że jeden rejestr ma kilka nazw.
RAX - 64 bity
EAX - dolne 32 bity
AX - dolne 16 bitów
AL - dolne 8 bitów
AH - kolejne 8 bitów historycznego AX
Czyli:
RAX
┌────────────────────────────────────────────────────────────────┐
│ 64 bity │
└────────────────────────────────────────────────────────────────┘
└──────── EAX ───────────────────┘
└── AX ───────┘
AL / AH
To jeden z przykładów historycznego balastu architektury x86.
Instalacja NASM - Windows
NASM:
Netwide Assembler
to bardzo popularny assembler x86/x86-64.
Oficjalna strona:
https://www.nasm.us/
Pobieramy installer lub archiwum dla Windows.
Po dodaniu do PATH:
nasm -v
NASM - macOS
brew install nasm
Sprawdzenie:
nasm -v
NASM - Debian
sudo apt update
sudo apt install nasm
Sprawdzenie:
nasm -v
x86-64: Hello World na Linuxie
Linux pozwala programowi komunikować się z kernelem przez:
system calls
Przykład NASM:
section .data
message db "Hello from x86-64!", 10
message_len equ $ - message
section .text
global _start
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, message_len
syscall
mov rax, 60
xor rdi, rdi
syscall
Co oznaczają liczby?
Dla Linux x86-64:
rax = 1
oznacza syscall:
write
Argumenty:
rdi = file descriptor
rsi = adres danych
rdx = długość
Czyli:
mov rdi, 1
oznacza:
stdout
Potem:
syscall
prosi kernel o wykonanie operacji.
Kompilacja x86-64 Linux
Assembler:
nasm -f elf64 hello.asm -o hello.o
Linker:
ld hello.o -o hello
Uruchomienie:
```bash./hello
Mamy więc dokładnie ten sam model co wcześniej:
```text
hello.asm
|
v
NASM
|
v
hello.o
|
v
ld
|
v
hello
Dlaczego ten sam kod nie działa na Windows?
Bo assembler zależy nie tylko od CPU.
Zależy również od:
systemu operacyjnego
ABI
formatu pliku wykonywalnego
API systemowego
Linux x86-64 używa między innymi:
ELF
syscall ABI Linux
Windows używa:
PE/COFF
Windows x64 ABI
WinAPI
Procesor może być ten sam.
Środowisko programu jest inne.
ABI
ABI oznacza:
Application Binary Interface
Określa między innymi:
- gdzie przekazuje się argumenty,
- gdzie zwracany jest wynik,
- które rejestry funkcja musi zachować,
- jak wygląda stos,
- jak program komunikuje się z systemem.
Na Linux x86-64 typowe argumenty funkcji trafiają kolejno do:
RDI
RSI
RDX
RCX
R8
R9
Na Windows x64 pierwsze argumenty trafiają do:
RCX
RDX
R8
R9
Ten sam procesor.
Inne ABI.
Calling convention
Wyobraź sobie funkcję C:
int add(int a, int b);
Kompilator musi wiedzieć:
gdzie położyć a?
gdzie położyć b?
gdzie znaleźć wynik?
kto sprząta stos?
które rejestry wolno zniszczyć?
Odpowiedź daje:
calling convention
Bez tego dwa moduły skompilowane przez różne narzędzia nie potrafiłyby ze sobą współpracować.
Stack pointer w x86-64
Rejestr:
RSP
wskazuje szczyt stosu.
Instrukcje:
push rax
pop rax
działają podobnie koncepcyjnie do:
PHA
PLA
na 6502.
Różnica:
6502 -> stos w stałym obszarze $0100-$01FF
x86-64 -> stos w normalnej przestrzeni pamięci procesu
CALL i RET
x86:
call function
zapisuje adres powrotu na stosie i przechodzi do funkcji.
Powrót:
ret
To ta sama podstawowa idea co:
JSR
RTS
na 6502.
6502 kontra x86-64
| Cecha | 6502/6510 | x86-64 |
|---|---|---|
| epoka | lata 70./80. | współczesność |
| główna szerokość danych | 8 bit | 64 bit |
| przestrzeń adresowa klasycznego CPU | 16 bit | bardzo duża 64-bitowa |
| rejestry ogólne | bardzo mało | dużo |
| ISA | stosunkowo prosta | bardzo duża |
| stos | strona $0100 |
normalna pamięć |
| zastosowanie do nauki | świetne | trudniejsze |
| współczesny desktop | nie | tak |
Trzeci świat: ARM
ARM jest dziś wszędzie:
- telefony,
- tablety,
- Raspberry Pi,
- routery,
- mikrokontrolery,
- serwery,
- Mac z Apple Silicon.
Współczesne 64-bitowe ARM nazywamy:
AArch64
Rejestry AArch64
Podstawowe rejestry:
X0..X30
Każdy:
64 bity
Dolna 32-bitowa część:
W0..W30
Przykład:
mov x0, #10
mov x1, #20
add x2, x0, x1
Znaczenie:
X0 = 10
X1 = 20
X2 = X0 + X1
To wygląda znacznie bardziej regularnie niż x86.
ARM i load/store
Architektury RISC często mocno rozdzielają:
operacje na rejestrach
od:
dostępu do pamięci
Typowy schemat:
ldr x0, [x1]
add x0, x0, #1
str x0, [x1]
Czyli:
załaduj z pamięci
oblicz w rejestrze
zapisz do pamięci
Cross-toolchain ARM - Debian
Dla mikrokontrolerów Cortex-M/R:
sudo apt update
sudo apt install gcc-arm-none-eabi
W pakiecie znajduje się również assembler GNU:
arm-none-eabi-as
Sprawdzenie:
arm-none-eabi-as --version
ARM - Windows i macOS
Arm publikuje oficjalny:
Arm GNU Toolchain
dla:
- Windows,
- Linux,
- macOS.
Strona:
https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
W zależności od targetu używa się narzędzi takich jak:
arm-none-eabi-gcc
arm-none-eabi-as
aarch64-none-elf-gcc
Czwarty świat: RISC-V
RISC-V jest otwartą ISA.
To ważna różnica.
x86 i ARM są architekturami kontrolowanymi przez konkretne firmy i ekosystemy licencyjne.
Specyfikacja RISC-V jest otwarta.
Architektura jest projektowana modułowo.
Podstawowy zestaw instrukcji może być rozszerzany o:
- mnożenie,
- atomiki,
- floating point,
- vector,
- compressed instructions.
RISC-V wygląda bardzo regularnie
Przykład:
li a0, 10
li a1, 20
add a2, a0, a1
Rejestry argumentów:
a0
a1
...
Rejestry tymczasowe:
t0
t1
...
Zapisane rejestry:
s0
s1
...
To sprawia, że kod jest często łatwiejszy do czytania niż x86.
RISC-V na Debianie
Cross-toolchain Linux:
sudo apt install gcc-riscv64-linux-gnu
Bare metal:
sudo apt install gcc-riscv64-unknown-elf
Dostępne są narzędzia w rodzaju:
riscv64-linux-gnu-as
riscv64-unknown-elf-as
RISC kontra CISC
To temat dużo bardziej subtelny niż internetowe:
RISC = proste
CISC = skomplikowane
ale na poziomie wprowadzającym:
CISC
Klasyczny przykład:
x86
Duża liczba instrukcji i wiele historycznych trybów.
RISC
Przykłady:
ARM
RISC-V
Bardziej regularny zestaw instrukcji i silna filozofia operacji wykonywanych na rejestrach.
Współczesne CPU są jednak znacznie bardziej złożone wewnętrznie i proste etykietki nie opisują całej rzeczywistości.
Ta sama operacja na czterech ISA
Chcemy:
10 + 20
6502
CLC
LDA #10
ADC #20
Wynik:
A
x86-64
mov rax, 10
add rax, 20
AArch64
mov x0, #10
add x0, x0, #20
RISC-V
li a0, 10
addi a0, a0, 20
Ta sama idea.
Cztery różne języki procesora.
Dlaczego assembler nie jest przenośny?
Kod C:
int x = a + b;
może zostać skompilowany dla:
x86-64
ARM
RISC-V
PowerPC
Kompilator wybiera odpowiednie instrukcje.
Kod assemblerowy:
mov rax, rbx
jest związany z x86-64.
Dlatego assembler jest:
architekturozależny
Co robi kompilator C?
Weźmy:
int add(int a, int b) {
return a + b;
}
Kompilator może wygenerować coś w rodzaju:
mov eax, edi
add eax, esi
ret
Dzięki temu możesz używać C bez ręcznego pisania instrukcji procesora.
Kompilator jest automatycznym generatorem assemblera i kodu maszynowego.
Zobacz assembler wygenerowany przez GCC
Utwórz:
add.c
int add(int a, int b) {
return a + b;
}
Wygeneruj assembler:
gcc -S -O2 add.c
Powstanie:
add.s
To jedno z najlepszych ćwiczeń do nauki assemblera.
Zmieniaj C:
if
for
while
function
struct
i patrz, co generuje kompilator.
Intel syntax kontra AT&T syntax
Na x86 spotkasz dwa główne style zapisu.
Intel
mov rax, rbx
Czytaj:
destination <- source
AT&T
movq %rbx, %rax
Kolejność operandów jest odwrotna:
source -> destination
Dodatkowo pojawiają się:
%
$
suffixes
Dlatego ten sam kod w dwóch tutorialach może wyglądać inaczej.
NASM używa stylu zbliżonego do Intel syntax.
GNU as tradycyjnie używa AT&T, choć narzędzia GNU potrafią również pracować z Intel syntax.
Object file
Po assemblerze często nie powstaje od razu program.
Powstaje:
object file
Na Linuxie:
.o
Zawiera między innymi:
- kod maszynowy,
- dane,
- symbole,
- informacje relokacyjne,
- czasem debug info.
Relokacja
Załóżmy, że assembler tworzy:
call foo
ale nie wie jeszcze, pod jakim finalnym adresem znajdzie się:
foo
Zostawia informację:
linker, popraw ten adres później.
To:
relocation
Linker po rozmieszczeniu segmentów wie już:
foo = 0x401040
i może naprawić instrukcję.
Segmenty i sekcje
Typowy program posiada osobne obszary.
Przykładowo:
.text
.data
.bss
.rodata
.text
Kod wykonywalny.
.data
Zainicjalizowane dane zapisywalne.
.rodata
Dane tylko do odczytu.
.bss
Dane, które na starcie mają być wyzerowane.
W cc65 zobaczymy analogiczne koncepcje segmentów, choć ich nazwy i organizacja zależą od konfiguracji targetu.
Loader
Po utworzeniu programu ktoś musi go załadować do pamięci.
Na współczesnym systemie robi to loader systemu operacyjnego.
W C64 plik:
PRG
zawiera między innymi adres ładowania.
System wie, gdzie umieścić program w pamięci.
Potem CPU musi dostać adres startowy.
Firmware, ROM i boot
Po włączeniu komputera CPU nie „wie”, gdzie jest system operacyjny.
Architektura definiuje mechanizm startu.
6502 pobiera wektory startowe z określonych adresów.
Współczesny PC przechodzi przez firmware:
UEFI
a potem bootloader.
Na mikrokontrolerze kod może wystartować bezpośrednio z flash.
Assembler pozwala zobaczyć ten świat dużo wyraźniej.
Reverse engineering
Assembler jest podstawowym językiem reverse engineeringu.
Jeżeli nie masz źródeł programu, możesz nadal zobaczyć:
kod maszynowy
i go zdisassemblować.
Narzędzia:
- Ghidra,
- IDA,
- Binary Ninja,
- radare2,
- objdump,
- gdb.
Disassembler próbuje zamienić bajty na instrukcje.
Decompiler próbuje pójść krok dalej i zbudować coś przypominającego C.
Assembler i bezpieczeństwo
Wiele klas podatności staje się dużo bardziej zrozumiałych po poznaniu podstaw assemblera:
- buffer overflow,
- stack smashing,
- use-after-free,
- ROP,
- shellcode,
- calling conventions,
- return address overwrite.
Nie dlatego, że musisz pisać exploit.
Po prostu widzisz, co naprawdę znaczy:
nadpisanie pamięci
Buffer overflow - idea
Wyobraź sobie stos:
[ local buffer ]
[ saved register ]
[ return address ]
Jeżeli program zapisze poza granicę bufora, może nadpisać:
return address
Po RET procesor może przejść pod inny adres.
Współczesne systemy stosują zabezpieczenia:
- ASLR,
- NX,
- stack canaries,
- PIE,
- CFI.
Ale mechanizm staje się znacznie bardziej zrozumiały, gdy wiesz, czym są:
stack
return address
PC/RIP
Cache
6502 pozwala myśleć o pamięci prawie jak o jednolitej przestrzeni.
Współczesny CPU posiada kilka poziomów cache:
registers
L1
L2
L3
RAM
storage
Czas dostępu może różnić się gigantycznie.
Dlatego wydajność współczesnego kodu nie zależy tylko od liczby instrukcji.
Liczy się również:
- locality,
- cache misses,
- memory bandwidth,
- branch prediction.
To jeden z powodów, dla których ręczne „optymalizowanie assemblera” na nowoczesnym CPU jest znacznie trudniejsze niż na 6502.
Pipeline
CPU nie musi kończyć jednej instrukcji, zanim zacznie przygotowywać następną.
Może nakładać etapy:
fetch
decode
execute
memory
writeback
na wiele instrukcji jednocześnie.
To:
pipeline
Branch prediction
Jeżeli CPU widzi:
if
nie zawsze chce czekać, aż zna wynik.
Próbuje przewidzieć:
która gałąź zostanie wykonana
Jeżeli zgadnie:
super
Jeżeli nie:
pipeline trzeba częściowo wyczyścić
To kolejny powód, dla którego koszt instrukcji we współczesnym CPU nie jest prostą tabelką.
Out-of-order execution
Współczesny CPU może wykonywać instrukcje w kolejności innej niż zapis programu, jeśli nie zmienia to obserwowalnego wyniku.
Przykładowo:
A zależy od RAM
B jest niezależnym dodawaniem
CPU może zacząć liczyć B, czekając na dane A.
To jeden z fundamentów wydajności współczesnych procesorów.
Na 6502 świata tego praktycznie nie musimy brać pod uwagę.
SIMD
Współczesne procesory potrafią wykonywać tę samą operację na wielu danych jednocześnie.
To:
SIMD
Przykłady x86:
SSE
AVXAVX2
AVX-512
ARM:
NEON
SVE
RISC-V:
Vector Extension
To ważne dla:
- grafiki,
- audio,
- kompresji,
- ML,
- obliczeń naukowych.
Czy warto pisać normalne aplikacje w assemblerze?
Zwykle nie.
Powody:
- dużo kodu,
- słaba przenośność,
- trudniejsze testowanie,
- trudniejsze utrzymanie,
- kompilatory potrafią świetnie optymalizować.
Assembler ma sens między innymi w:
- bootloaderach,
- fragmentach kernela,
- firmware,
- startup code,
- bardzo specyficznym embedded,
- kryptografii,
- ręcznych optymalizacjach,
- reverse engineeringu,
- demoscenie,
- retrocomputingu.
Assembler jako narzędzie do nauki
To być może jego najważniejsze zastosowanie dla większości współczesnych programistów.
Jeżeli rozumiesz assembler, lepiej rozumiesz:
C
wskaźniki
stack
heap
ABI
debugger
kompilator
linker
system calls
proces
pamięć
CPU
Nie musisz pisać w nim zawodowo.
Ćwiczenie 1 - rejestr
6502:
LDA #10
Zadaj sobie pytanie:
co teraz zawiera A?
Odpowiedź:
10
Ćwiczenie 2 - pamięć
LDA #10
STA $2000
Pytanie:
co znajduje się pod $2000?
Odpowiedź:
10
Ćwiczenie 3 - licznik
LDX #0
loop:
INX
CPX #5
BNE loop
Po zakończeniu:
X = 5
Ćwiczenie 4 - tablica
values:
.byte 10, 20, 30, 40
LDX #2
LDA values,X
W A znajdzie się:
30
Indeks zaczyna się od zera.
Ćwiczenie 5 - podprogram
JSR foo
...
foo:
LDA #10
RTS
Po powrocie:
A = 10
Ćwiczenie 6 - stos
LDA #10
PHA
LDA #20
PLA
Po PLA:
A = 10
Ćwiczenie 7 - przepełnienie
CLC
LDA #255
ADC #1
8-bitowy A nie może przechować:
256
Po operacji:
A = 0
Carry = 1
To jedna z najbardziej namacalnych lekcji o szerokości liczb.
Mini-projekt: migająca ramka C64
Możemy zrobić prostą pętlę zmieniającą kolor ramki.
BORDER = $D020
.segment "CODE"
start:
ldx #0
loop:
stx BORDER
jsr delay
inx
txa
and #$0f
tax
jmp loop
delay:
ldy #0
delay_outer:
ldx #0
delay_inner:
dex
bne delay_inner
dey
bne delay_outer
rts
To celowo prymitywny delay.
W prawdziwym kodzie C64 lepiej synchronizować animację ze sprzętem, np. rasterem.
Ale ćwiczenie pokazuje:
- zapis do I/O,
- pętle,
- rejestry,
- podprogram,
- maskowanie bitów.
AND i maski bitowe
Instrukcja:
AND #$0F
zostawia tylko dolne cztery bity.
$0F = 00001111
Jeżeli A:
10110110
to:
10110110
AND
00001111
=
00000110
Wynik:
6
Maskowanie bitów jest wszędzie w programowaniu niskopoziomowym.
OR
ORA #$80
ustawia określone bity.
Przykład:
00100010
OR
10000000
=
10100010
XOR
6502:
EOR
czyli exclusive OR.
EOR #$FF
odwróci wszystkie bity A.
Shifty
Przesunięcia bitowe:
ASL
LSR
ASL
Arithmetic Shift Left
Przesunięcie w lewo o jeden bit jest dla liczby bez znaku podobne do:
* 2
jeżeli nie wystąpi przepełnienie.
LSR
Logical Shift Right
jest podobne do:
/ 2
dla wartości bez znaku.
Bitowe flagi sprzętu
Rejestr sprzętowy często przechowuje wiele ustawień w jednym bajcie.
Przykład hipotetyczny:
bit 7 = enable
bit 6 = interrupt
bit 5 = mode
...
Zamiast zmieniać cały bajt, możemy manipulować pojedynczym bitem:
ORA #%10000000
albo:
AND #%01111111
To fundament pracy z:
- mikrokontrolerami,
- sterownikami,
- urządzeniami.
Binarny zapis
ca65 pozwala używać zapisu:
%10101010
To bardzo wygodne przy bitach.
Przykład:
LDA #%00000001
Hex:
LDA #$01
Dziesiętnie:
LDA #1
To ta sama wartość.
Jak czytać obcy assembler?
Nie próbuj czytać każdej instrukcji osobno.
Najpierw znajdź:
- wejście programu,
- główną pętlę,
- podprogramy,
- dane,
- dostęp do pamięci,
- wywołania systemowe lub firmware,
- warunki i skoki.
Szukaj wzorców.
Przykład:
loop:
...
dec counter
bne loop
Od razu wiesz:
pętla sterowana licznikiem
Nazwy instrukcji 6502, które warto znać
Transfer danych
LDA
LDX
LDY
STA
STX
STY
Transfer rejestrów
TAX
TAY
TXA
TYA
TSX
TXS
Stos
PHA
PLA
PHP
PLP
Arytmetyka
ADC
SBC
INC
DEC
INX
DEX
INY
DEY
Logika
AND
ORA
EOR
Przesunięcia
ASL
LSR
ROL
ROR
Porównania
CMP
CPX
CPY
Skoki
JMP
JSR
RTS
Branch
BEQ
BNE
BCC
BCS
BMI
BPL
BVC
BVS
Flagi
CLC
SEC
CLI
SEI
CLD
SED
CLV
Nie musisz uczyć się całej tabeli na pamięć.
Po kilku małych programach większość zaczyna wyglądać naturalnie.
Branch kontra jump
JMP
JMP somewhere
zawsze zmienia wykonanie.
Branch
BNE somewhere
wykonuje skok tylko wtedy, gdy spełniony jest warunek.
Dodatkowo klasyczne branche 6502 mają ograniczony zasięg względem bieżącego adresu.
To ważne przy dużych funkcjach.
JSR kontra JMP
JMP foo
przechodzi do foo bez automatycznej drogi powrotnej.
JSR foo
zapisuje adres powrotu.
Dlatego funkcja kończy się:
RTS
BRK
Instrukcja:
BRK
wywołuje programowe przerwanie.
Nie jest po prostu zwykłym:
stop CPU
W 6502 kieruje wykonanie przez mechanizm przerwania.
NOP
NOP
oznacza:
No Operation
Procesor wykonuje instrukcję, która zasadniczo nic nie zmienia.
NOP-y przydają się między innymi do:
- wyrównywania,
- timingów,
- patchowania kodu,
- debugowania.
Illegal opcodes
Klasyczny 6502 posiada kombinacje kodów instrukcji, które nie były oficjalnie dokumentowane, ale powodują określone zachowania.
Są nazywane między innymi:
illegal opcodes
undocumented opcodes
Demoscena i stare gry czasem ich używały.
Na początek najlepiej ich unikać.
Najpierw poznaj oficjalny zestaw instrukcji.
6502 kontra 6510
W większości prostych przykładów instrukcje są takie same.
6510 dodaje port I/O widoczny między innymi pod:
$0000
$0001
C64 wykorzystuje to do konfiguracji mapowania pamięci.
Zmiana $0001 może wpływać na to, czy CPU widzi:
- BASIC ROM,
- KERNAL ROM,
- I/O,
- RAM znajdujący się „pod” ROM-em.
To jedna z rzeczy, które czynią C64 ciekawszym niż czysty komputer laboratoryjny 6502.
RAM pod ROM-em
C64 ma fizycznie 64 KiB RAM-u.
A jednocześnie w tej samej przestrzeni adresowej widzimy:
ROM
I/O
Jak to możliwe?
Sprzęt przełącza, co jest widoczne pod określonym adresem.
Pod adresem:
$A000
CPU może w jednym ustawieniu widzieć BASIC ROM, a w innym RAM.
To:
bank switching / memory mapping
Sprzęt jest częścią programu
Na współczesnym systemie program często widzi:
abstrakcję systemu operacyjnego
Na C64 program może bezpośrednio dotykać:
VIC-II
SID
CIA
RAM
ROM
Dlatego assembler retro jest świetnym sposobem na naukę architektury komputera jako całości.
Co warto opanować na C64 po tym artykule?
Naturalna kolejność:
- rejestry,
- pamięć,
- pętle,
- subroutines,
- zero page,
- ekran tekstowy,
- kolory,
- klawiatura,
- sprite'y,
- raster,
- SID,
- przerwania,
- własne struktury danych,
- optymalizacja cykli.
Po tym assembler nie wygląda już jak tajemnicze zaklęcia.
Czy uczymy się 6502 czy C64?
To dwie warstwy.
6502/6510
Uczymy się:
CPU
instructions
registers
flags
stack
addressing
C64
Uczymy się:
memory map
VIC-II
SID
CIA
KERNAL
BASIC ROM
screen memory
Możesz znać instrukcje 6502 i nadal niewiele wiedzieć o C64.
I odwrotnie.
Jak zbudować mały projekt
Prosty katalog:
hello-c64/
├── src/
│ └── main.s
├── Makefile
└── README.md
Przykładowy Makefile:
PROGRAM = hello
SOURCE = src/main.s
all:
cl65 -o $(PROGRAM).prg \
-u __EXEHDR__ \
-t c64 \
-C c64-asm.cfg \
$(SOURCE)
run: all
x64sc -autostart $(PROGRAM).prg
clean:
rm -f $(PROGRAM).prg
Budowanie:
make
Uruchomienie:
make run
Czyszczenie:
make clean
Po co Makefile przy jednym pliku?
Przy jednym pliku prawie nie jest potrzebny.
Ale po chwili dojdą:
main.s
screen.s
sprites.s
sound.s
input.s
i długa linia builda stanie się irytująca.
Automatyzacja procesu budowania to naturalny kolejny krok.
Git
Assembler jest zwykłym tekstem.
Nadaje się idealnie do Git.
git init
git add .
git commit -m "Initial C64 assembly project"
Zobacz:
Nie wrzucaj do repo tylko wygenerowanych binarek, jeśli możesz je odtworzyć z kodu.
Typowe .gitignore:
*.o
*.prg
*.map
*.lbl
Map file
Linker może wygenerować mapę programu.
To plik pokazujący między innymi:
- gdzie leżą segmenty,
- jakie adresy dostały symbole,
- ile pamięci zajmuje kod.
W programowaniu niskopoziomowym to bardzo przydatne.
Label file
Dla emulatora/debuggera można generować symbole, aby zamiast:
$C042
widzieć:
game_loop
To dramatycznie ułatwia debugowanie.
Jak myśleć o optymalizacji
Nie zaczynaj od:
ile cykli mogę urwać?
Najpierw:
- napisz poprawny kod,
- sprawdź działanie,
- zmierz,
- znajdź wąskie gardło,
- dopiero optymalizuj.
Nawet na C64. Kod:
krótszy
nie zawsze znaczy:
szybszy
A kod szybszy nie zawsze jest wart utraty czytelności.
Rozmiar kontra szybkość
W retro często wybierasz między:
mniej bajtów
a:
mniej cykli
Przykładowa tablica może zastąpić obliczenie:
więcej pamięci
mniej CPU
Albo odwrotnie:
mniej pamięci
więcej obliczeń
To kompromis obecny również we współczesnym programowaniu.
Dlaczego demoscena kocha assembler?
Demoscena często próbuje zrobić maksymalnie dużo z bardzo ograniczonego sprzętu.
Liczy się:
- każdy bajt,
- każdy cykl,
- dokładna synchronizacja ze sprzętem.
Assembler daje kontrolę, której język wysokiego poziomu może nie zapewniać.
Dlatego platformy takie jak:
- C64,
- Amiga,
- Atari ST,
- ZX Spectrum,
mają gigantyczną historię programowania niskopoziomowego.
Assembler a mikrokontrolery
W embedded nadal czasem trzeba czytać assembler, nawet jeśli projekt jest pisany w C lub Rust.
Przykłady:
- startup code,
- bootloader,
- interrupt vector,
- context switch,
- fault handler.
Na Cortex-M możesz spotkać plik:
startup_stm32.s
Warto wiedzieć, co tam się dzieje, nawet jeśli nie zamierzasz pisać całej aplikacji ręcznie.
Assembler a system operacyjny
Kernel musi wykonywać rzeczy, których zwykły program nie może.
Przykładowo:
- zmieniać tryb CPU,
- obsługiwać przerwania,
- przełączać kontekst procesów,
- zarządzać tablicami stron,
- wykonywać instrukcje uprzywilejowane.
Część takiego kodu naturalnie trafia do assemblera.
Większość kernela może być napisana w C lub Rust, ale najniższe warstwy nadal muszą rozumieć CPU.
Assembler w kryptografii
Kryptografia często potrzebuje:
- bardzo wysokiej wydajności,
- SIMD,
- instrukcji sprzętowych,
- przewidywalności.
Dlatego biblioteki kryptograficzne mogą zawierać ręcznie napisane fragmenty assemblera.
Ale takie optymalizacje powinny być tworzone przez ludzi bardzo dobrze znających konkretną architekturę.
Inline assembly
C i C++ pozwalają w niektórych kompilatorach umieszczać assembler wewnątrz kodu.
Przykład ideowy:
asm("nop");
To:
inline assembly
Jest jednak:
- zależne od kompilatora,
- zależne od architektury,
- łatwe do zepsucia.
Zwykle lepiej ograniczać takie fragmenty do minimum.
Intrinsics
Zamiast pisać czysty assembler, współczesny kod często używa:
intrinsics
To funkcje udostępniane przez kompilator reprezentujące konkretne instrukcje CPU.
Przykład dotyczy często:
- SIMD,
- kryptografii,
- atomików.
Kompilator nadal zarządza:
- rejestrami,
- calling convention,
- schedulerem.
To często lepszy kompromis niż ręczny assembler.
Co naprawdę znaczy „64-bit”?
Nie istnieje jedna definicja dla wszystkich architektur.
Może odnosić się między innymi do:
- szerokości rejestrów,
- rozmiaru adresów,
- naturalnego typu danych,
- ISA.
x86-64 ma 64-bitowe rejestry ogólne.
Ale współczesne procesory nie muszą fizycznie implementować pełnych 64 bitów adresu pamięci.
To kolejny przykład, gdzie marketingowa etykieta upraszcza techniczną rzeczywistość.
Co to jest opcode?
Opcode:
operation code
identyfikuje operację.
6502:
A9
może oznaczać:
LDA immediate
Cała instrukcja:
A9 10
oznacza:
LDA #$10
Opcode:
A9
Operand:
10
Instrukcja o zmiennej długości
6502 ma instrukcje o różnych długościach:
1 bajt
2 bajty
3 bajty
x86 idzie dużo dalej - instrukcje mogą mieć bardzo różne długości.
RISC-V ma bardziej regularny model, choć rozszerzenie compressed dodaje krótsze instrukcje.
Sposób kodowania instrukcji jest częścią ISA.
Alignment
Niektóre architektury preferują lub wymagają określonego wyrównania danych.
Przykład:
adres podzielny przez 4
dla 32-bitowej wartości.
Na starym 6502 temat wygląda inaczej niż na ARM czy nowoczesnym x86.
Współczesny programista spotka alignment między innymi przy:
- strukturach C,
- SIMD,
- pamięci,
- ABI.
Atomiki
Wielordzeniowe CPU wymagają mechanizmów bezpiecznej synchronizacji pamięci.
ISA udostępnia specjalne operacje atomowe.
Na x86:
LOCK
CMPXCHG
ARM i RISC-V mają własne mechanizmy.
To fundament implementacji:
- mutexów,
- spinlocków,
- atomics,
- lock-free structures.
6502 w typowym C64 nie musi rozwiązywać problemu wielu rdzeni.
Privilege levels
Współczesne CPU mają różne poziomy uprzywilejowania.
Kod aplikacji nie może po prostu zrobić:
wyłącz ochronę pamięci
Kernel może korzystać z instrukcji niedostępnych dla procesu użytkownika.
Na x86 spotkasz pojęcia:
rings
na ARM:
exception levels
To część fundamentu bezpieczeństwa systemu operacyjnego.
Syscall kontra funkcja biblioteczna
W C:
printf("Hello");
nie jest bezpośrednio instrukcją systemu operacyjnego.
printf jest funkcją biblioteki.
Może ostatecznie użyć wywołania systemowego:
write
Assembler pozwala ominąć część tych warstw i bezpośrednio wywołać kernel.
Ale tracimy wygodę biblioteki.
CPU nie zna tekstu
Procesor nie wie, czym jest:
"Hello"
Widziać może tylko bajty.
Na przykład:
48 65 6C 6C 6F
interpretujemy jako znaki ASCII.
Ta sama sekwencja mogłaby być potraktowana jako:
- liczby,
- instrukcje,
- piksele,
- dźwięk.
Format danych nadaje znaczenie.
CPU nie zna zmiennych
W kodzie wysokiego poziomu:
int score = 10;
CPU nie zna nazwy:
score
Po kompilacji może to być:
rejestr
albo:
adres pamięci
Nazwa istnieje głównie dla człowieka i narzędzi.
CPU nie zna pętli
CPU zna:
skok
warunek
adres
Pętla:
while (x != 0) {
x--;
}
może zamienić się w:
loop:
dec ...
jne loop
To bardzo ważna zmiana perspektywy.
CPU nie zna funkcji
CPU zna:
adresy
stos
jump/call
return
„Funkcja” jest konwencją zbudowaną na tych mechanizmach.
CPU nie zna obiektów
Obiekt C++:
player.move();
na końcu staje się:
- adresami,
- wskaźnikami,
- funkcjami,
- danymi,
- instrukcjami CPU.
Warstwy abstrakcji są bardzo użyteczne.
Ale pod nimi nadal istnieje assembler.
Dlaczego nie trzeba bać się assemblera?
Bo podstawy są prostsze niż składnia współczesnego frameworka frontendowego.
6502 ma bardzo mało podstawowych elementów:
kilka rejestrów
kilkadziesiąt instrukcji
pamięć
flagi
stos
skoki
Trudność zaczyna się wtedy, gdy próbujesz z tych klocków zbudować duży system.
Do zrozumienia podstaw nie potrzebujesz wielkiej matematyki.
Minimalna lista rzeczy do zapamiętania
Jeśli po tygodniu zapomnisz większość artykułu, zapamiętaj:
- Procesor wykonuje kod maszynowy.
- Assembler tłumaczy symbole na kod maszynowy.
- Assembly zależy od ISA.
- Rejestry znajdują się wewnątrz CPU.
- RAM jest adresowaną przestrzenią bajtów.
- PC wskazuje instrukcję.
- SP wskazuje stos.
- Flagi opisują wyniki operacji.
JMPzmienia przepływ wykonania.CALL/JSR+RET/RTSbudują funkcje.- Linker łączy moduły i rozwiązuje symbole.
- ABI mówi, jak binarne fragmenty programu współpracują.
- System operacyjny dodaje kolejną warstwę ponad ISA.
- C64 jest świetnym laboratorium, bo sprzęt jest widoczny bez setek warstw abstrakcji.
Ściąga 6502
| Instrukcja | Znaczenie |
|---|---|
LDA |
załaduj A |
STA |
zapisz A |
LDX |
załaduj X |
STX |
zapisz X |
LDY |
załaduj Y |
STY |
zapisz Y |
ADC |
dodaj z Carry |
SBC |
odejmij z Carry |
CMP |
porównaj A |
CPX |
porównaj X |
CPY |
porównaj Y |
INC |
zwiększ pamięć |
DEC |
zmniejsz pamięć |
INX |
X++ |
DEX |
X-- |
INY |
Y++ |
DEY |
Y-- |
AND |
AND bitowe |
ORA |
OR bitowe |
EOR |
XOR bitowe |
ASL |
shift left |
LSR |
shift right |
JMP |
bezwarunkowy skok |
JSR |
wywołaj podprogram |
RTS |
wróć |
BEQ |
skocz, jeśli Z=1 |
BNE |
skocz, jeśli Z=0 |
BCC |
skocz, jeśli C=0 |
BCS |
skocz, jeśli C=1 |
BMI |
skocz, jeśli N=1 |
BPL |
skocz, jeśli N=0 |
PHA |
push A |
PLA |
pull A |
CLC |
wyczyść Carry |
SEC |
ustaw Carry |
NOP |
nic nie rób |
Ściąga rejestrów
6502/6510
A - accumulator
X - index
Y - index
PC - program counter
SP - stack pointer
P - status
x86-64
RAX RBX RCX RDX
RSI RDI
RSP RBP
R8-R15
RIP
RFLAGS
AArch64
X0-X30
SP
PC - konceptualnie, nie jako zwykły GPR
PSTATE
RISC-V
x0-x31
pc
z nazwami ABI:
zero
ra
sp
gp
tp
t0-t6
s0-s11
a0-a7
Narzędzia - ściąga
| Cel | 6502/C64 | x86-64 | ARM | RISC-V |
|---|---|---|---|---|
| assembler | ca65 |
nasm / as |
arm-none-eabi-as |
riscv64-unknown-elf-as |
| linker | ld65 |
ld |
GNU ld |
GNU ld |
| disassembler | da65 |
ndisasm, objdump |
objdump |
objdump |
| emulator/sim | VICE / sim65 | QEMU / natywny CPU | QEMU / sprzęt | QEMU / sprzęt |
| debugger | VICE monitor | GDB | GDB | GDB |
Instalacja - szybka ściąga
C64 / 6502
Debian
sudo apt install cc65
sudo apt install vice
VICE wymaga repozytorium contrib.
macOS
brew install cc65
brew install vice
Windows
Pobierz aktualne buildy:
cc65
VICE
ze stron projektów.
x86-64
Debian
sudo apt install nasm binutils gdb
macOS
brew install nasm
Windows
NASM:
https://www.nasm.us/
Do linkowania natywnych programów Windows wygodnie użyć również Visual Studio Build Tools albo MinGW/MSYS2, zależnie od wybranego workflow.
ARM bare-metal
Debian
sudo apt install gcc-arm-none-eabi
Windows/macOS
Arm GNU Toolchain:
https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
RISC-V bare-metal
Debian
sudo apt install gcc-riscv64-unknown-elf
Linux cross:
sudo apt install gcc-riscv64-linux-gnu
Jak ćwiczyć dalej?
Nie zaczynaj od:
napiszę własny system operacyjny
Lepsza ścieżka:
Krok 1
Rejestry:
LDA
LDX
LDY
Krok 2
Pamięć:
STA
Krok 3
Pętle:
CMP
BNE
Krok 4
Tablice i indeksowanie.
Krok 5
Podprogramy:
JSR
RTS
Krok 6
Stos:
PHA
PLA
Krok 7
C64:
screen RAM
color RAM
VIC-II
Krok 8
KERNAL.
Krok 9
Przerwania.
Krok 10
Sprite'y i SID.
Dopiero potem warto mocniej zanurzyć się w:
x86-64
ARM
RISC-V
Dlaczego zaczynamy od 6502, a nie Intela?
Bo na 6502 można niemal objąć cały model CPU głową.
Masz:
A
X
Y
SP
PC
flags
Na x86-64 natychmiast dochodzą:
- dziesiątki lat kompatybilności,
- wiele rozmiarów rejestrów,
- wiele trybów adresowania,
- SIMD,
- ABI,
- privilege levels,
- nowoczesny pipeline,
- ogromna ISA.
6502 jest małym modelem, na którym nauczysz się zasad.
Potem x86 przestaje wyglądać jak kompletny chaos.
Wygląda raczej jak:
te same podstawy, tylko czterdzieści lat rozbudowy.
Najważniejszy eksperyment: porównaj C z assemblerem
Utwórz:
example.c
int sum(int a, int b)
{
return a + b;
}
Potem:
gcc -O0 -S example.c -o example-O0.s
gcc -O2 -S example.c -o example-O2.s
Porównaj oba pliki.
Zobaczysz, co robi optymalizator.
To niezwykle pouczające.
Następnie:
gcc -O2 -c example.c -o example.o
objdump -d example.o
Masz pełną drogę:
C
↓
assembly
↓
object code
↓
disassembly
Compiler Explorer
Do szybkiego porównywania kodu wysokiego poziomu z assemblerem świetnie nadaje się:
https://godbolt.org/
Możesz wpisać:
int add(int a, int b) {
return a + b;
}
i zobaczyć kod dla:
- GCC,
- Clang,
- x86-64,
- ARM,
- RISC-V,
- wielu poziomów optymalizacji.
To jedno z najlepszych narzędzi edukacyjnych do nauki zależności:
C/C++/Rust -> assembly
Co assembler daje programiście wysokiego poziomu?
Nawet jeśli już nigdy nie napiszesz programu w czystym assemblerze, łatwiej zrozumiesz:
Wskaźniki
To adresy.
Referencje
Na końcu muszą prowadzić do danych.
Stos
To realna pamięć z konkretnym wskaźnikiem.
Funkcje
To konwencja przechodzenia między adresami i przekazywania danych.
Typy
CPU widzi bity. Typy są warstwą nad nimi.
Overflow
Wynika z fizycznej szerokości reprezentacji.
Segmentation fault
Proces odwołał się do niedozwolonego obszaru pamięci.
Calling convention
To umowa, która pozwala modułom ze sobą rozmawiać.
Compiler optimization
To automatyczna transformacja kodu na bardziej efektywną sekwencję instrukcji.
Czego assembler Ci nie nauczy?
Assembler nie zastąpi wiedzy o:
- algorytmach,
- architekturze aplikacji,
- bazach danych,
- sieciach,
- bezpieczeństwie aplikacyjnym,
- testowaniu,
- projektowaniu API.
To jedna warstwa.
Bardzo ważna, ale nadal jedna.
Najważniejszy wniosek
Komputer nie wykonuje:
button.addEventListener(...)
Nie wykonuje:
for x in items:
Nie wykonuje:
go worker()
Nie wykonuje:
printf(...)
Na samym dole wykonuje instrukcje swojej architektury.
Cały współczesny stos:
framework
runtime
biblioteka
język
kompilator
system operacyjny
ostatecznie prowadzi do:
rejestry
pamięć
instrukcje
CPU
Assembler pozwala zajrzeć właśnie tam.
I dlatego warto go poznać, nawet jeśli nigdy nie będzie Twoim głównym językiem.
Powiązane materiały TechHandbooka
- C - czytanie, kompilacja i debugowanie
- Go - czytanie kodu
- Debian - shell
- Programowanie w shellu
- Debian - desktop i serwer
- Visual Studio Code
- GitHub
- 20 współczesnych języków programowania, które warto znać
- Stare języki programowania, które ukształtowały informatykę
Oficjalne źródła i dokumentacja
Stan sekcji narzędziowej: wrzesień 2026.
6502 / cc65
- cc65: https://cc65.github.io/
- ca65 User's Guide: https://cc65.github.io/doc/ca65.html
- ld65 User's Guide: https://cc65.github.io/doc/ld65.html
- C64-specific information: https://cc65.github.io/doc/c64.html
- GitHub cc65: https://github.com/cc65/cc65
Commodore 64
- VICE: https://vice-emu.sourceforge.io/
x86 / x86-64
- NASM: https://www.nasm.us/
- Intel Software Developer Manuals: https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
- AMD64 Architecture Programmer's Manual: https://www.amd.com/en/search/documentation/hub.html
ARM
- Arm developer documentation: https://developer.arm.com/
- Arm GNU Toolchain: https://developer.arm.com/downloads/-/arm-gnu-toolchain-downloads
RISC-V
- RISC-V International: https://riscv.org/
- Specifications: https://riscv.org/technical/specifications/
Narzędzia
- Compiler Explorer: https://godbolt.org/
- GNU Binutils: https://www.gnu.org/software/binutils/
- GDB: https://www.gnu.org/software/gdb/
Dalej w serii
- 20 współczesnych języków programowania, które warto znać
- Stare języki programowania, które ukształtowały informatykę
- Assembler od podstaw - od rejestrów i pamięci do prawdziwego programu - ten artykuł
- Ada - język, w którym błędy mają być trudniejsze do popełnienia
Następny artykuł przejdzie w zupełnie inną filozofię.
Po assemblerze, gdzie programista ma niemal całkowitą kontrolę nad maszyną, zobaczymy Adę - język zaprojektowany tak, żeby programista nie mógł swobodnie zrobić wszystkiego, co tylko przyjdzie mu do głowy.
I właśnie dlatego oba języki świetnie pokazują dwa skrajnie różne podejścia do programowania.