Tech Handbook Null Yard

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:


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:

  1. pobiera A9,
  2. rozpoznaje instrukcję,
  3. pobiera kolejny bajt,
  4. 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:

  1. przygotować argumenty,
  2. zapamiętać miejsce powrotu,
  3. przejść do innego fragmentu kodu,
  4. wykonać funkcję,
  5. wrócić,
  6. 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ł:

Visual Studio Code

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:

  1. uruchomi C64,
  2. załaduje plik PRG,
  3. 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:

  1. łączy moduły,
  2. przydziela adresy,
  3. rozwiązuje symbole,
  4. 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:

  1. kończy bieżącą instrukcję,
  2. zapisuje potrzebny stan,
  3. przechodzi do procedury przerwania,
  4. obsługuje zdarzenie,
  5. 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ź:

  1. wejście programu,
  2. główną pętlę,
  3. podprogramy,
  4. dane,
  5. dostęp do pamięci,
  6. wywołania systemowe lub firmware,
  7. 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ść:

  1. rejestry,
  2. pamięć,
  3. pętle,
  4. subroutines,
  5. zero page,
  6. ekran tekstowy,
  7. kolory,
  8. klawiatura,
  9. sprite'y,
  10. raster,
  11. SID,
  12. przerwania,
  13. własne struktury danych,
  14. 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:

GitHub

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:

  1. napisz poprawny kod,
  2. sprawdź działanie,
  3. zmierz,
  4. znajdź wąskie gardło,
  5. 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:

  1. Procesor wykonuje kod maszynowy.
  2. Assembler tłumaczy symbole na kod maszynowy.
  3. Assembly zależy od ISA.
  4. Rejestry znajdują się wewnątrz CPU.
  5. RAM jest adresowaną przestrzenią bajtów.
  6. PC wskazuje instrukcję.
  7. SP wskazuje stos.
  8. Flagi opisują wyniki operacji.
  9. JMP zmienia przepływ wykonania.
  10. CALL/JSR + RET/RTS budują funkcje.
  11. Linker łączy moduły i rozwiązuje symbole.
  12. ABI mówi, jak binarne fragmenty programu współpracują.
  13. System operacyjny dodaje kolejną warstwę ponad ISA.
  14. 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


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

  1. 20 współczesnych języków programowania, które warto znać
  2. Stare języki programowania, które ukształtowały informatykę
  3. Assembler od podstaw - od rejestrów i pamięci do prawdziwego programu - ten artykuł
  4. 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.