Atari CC65 tutorial
Wstęp
Yaron Nir zrobił tutorial cc65 na Atari, niestety po hebrajsku.
[Youtube] Tutorial Yarona Nira 1Pobrałem filmy yt-dlp, odseparowałem audio, wrzuciłem mp3 do goodtape.io, a hebrajski srt do tłumacza google, co dało mi napisy po polsku:
[Zip] NapisyDwie warstwy AI, jakość jest fatalna i często bezsensowna. Ale jeśli ktoś czytał poniższe, to coś dla siebie wyciągnie:
[Link] De re Atari po polsku"Mapping the Atari" Ian Chadwick
Nie jestem ekspertem od C ani od Atari, nie znam asemblera, to jest zapis moich eksperymentów. Komputer: Windows, Atari: 800XL.
Środowisko
[Link] https://github.com/cc65/cc65Jeśli mamy gita i MS Visual Studio 2022 to klonujemy repo, w katalogu `C:\cc65\src` otwieramy cc65.sln i kompilujemy. Może być potrzeba podbicia wersji WinSDK, na co pozwalamy.
Jeśli nie zamierzamy kompilować to pozostaje pobrać starą wersję w Releases i gdzieś wypakować zipa.
Do zmiennej path dodajemy C:\cc65\bin
i to już powinno działać w terminalu: cc65 --version
Jako edytor polecam Visual Studio Code, ale notepad++ też wystarczy. Kompilować będziemy w terminalu.
Jako emulator polecam Altirrę do uruchamiania i debugowania kodu.
[Link] AltirraNie potrzebujemy dodatkowych romów, ale możemy je pobrać ze starym emulatorem X-Former 2.5
[Link] xf25Mad-Studio do grafiki
[Link] Mad-StudioMoże się też przydać hex edytor do oglądania plików xex, notepad++ ma wtyczkę HEX-Editor.
Hello world
Napiszmy hello world:
#include <stdio.h>
#include <conio.h>
int main (void)
{
printf("Hello, world!");
while(!kbhit());
return 0;
}
Skompilujmy cl65 -t atari hello_printf.c -o hello_printf.xex
- cl65 to frontend kompilatora i linkera cc65.
- -t atari wybiera komputer na jaki kompilujemy(jest też dostępny atarixl ale nie będziemy go teraz używać)
- -o plik wynikowy. xex to popularne na atari rozszerzenie zamiast exe
Plik hello_printf.xex przeciągamy na okno Altirry.
[Obrazek] ScreenRozmiar pliku xex to 2937 bajtów. Czyli strasznie dużo z naszego 64kB, a tak naprawdę z 62kB(bo Hardware), a tak naprawdę 48kB(bo OS i DOS). Zastąpmy printf użyciem puts(): puts("Hello, world!");
Skompilujmy, rozmiar u mnie to: 1068 bajtów. Albo użyciem cputs:
#include <conio.h>
int main (void)
{
cputs("Hello, world!");
while(!kbhit());
return 0;
}
844 bajtów.
A może piszmy prosto do pamięci RAM obrazu:
#include <conio.h>
#include <atari.h>
#include <string.h>
#include <atari_screen_charmap.h>
char* hello = "Hello, world!";
#include <atari_atascii_charmap.h>
int main (void)
{
memcpy(OS.savmsc, hello, 13);
while(!kbhit());
return 0;
}
677 bajtów.
Albo użyjmy procedury OSa CIOV we wstawce asemblerowej:
#include <atari.h>
#include <conio.h>
#define CIOV 0xE456
#define CHANNEL 0
void ciov(void){
asm("LDX #%b", CHANNEL * 16);
asm("JSR %w", CIOV);
asm("RTS");
}
void printl(void* c, int l)
{
OS.iocb[CHANNEL].buffer=c;
OS.iocb[CHANNEL].buflen=l;
OS.iocb[CHANNEL].command=IOCB_PUTCHR;
ciov();
}
int main (void)
{
printl("Hello, world!", 13);
while(!kbhit());
return 0;
}
633 bajty.
Jak widać cc65 pożera RAM jak szalony. Której metody używać? Żadnej, każdej, to zależy. printf("Wynik=%d", zmienna) przydaje się do formatowania danych. puts jest napisana w C i używa procedury write(C:\cc65\libsrc\common
), być może jest bardziej przenośna na inne platformy. cputs jest napisana w asemblerze(C:\cc65\libsrc\conio
) i używa cputc.s(C:\cc65\libsrc\atari
). Pisanie bezpośrednio do pamięci ekranu przyda się w grach, zamiast sztywnego #include <atari_screen_charmap.h>
możemy też dynamicznie konwertować ATASCII na internal screen codes. CIOV to już typowa funkcja Atari OS. Nie warto zbyt wcześnie optymalizować, używajmy printf lub puts, a w innych trybach ekranowych i tak będziemy pisać do pamięci.
Optymalizacje
Ogólnie chodzi o to żeby nie pisać zbyt wysokopoziomowo, należy traktować cc65 jako zaawansowany asembler.
[Link] ilmenit / CC65-Advanced-Optimizationsilmenit pisze żeby nie optymalizować zbyt wcześnie, ale niektóre punkty według mnie warto stosować od razu.
- 03 - używać unsigned char gdzie się tylko da. 6502 jest procesorem 8 bitowym. Przy okazji: char w cc65 jest bez znaku tzn. char = unsigned char. W ogóle to polecam zaimportować
<stdint.h>
i używać wszędzie typów:uint8_t, uint16_t, uint32_t, int8_t, int16_t, int32_t
. - 05 - nie używać struct. No chyba, że globalne struct do organizacyjnego zebrania w kupę kilku zmiennych lub tablic. 6502 słabo radzi sobie ze wskaźnikami, lepiej używać indeksowania tablicy. Jeśli w tablicy jest 256 lub mniej elementów to tym lepiej, tablica będzie adresowana 1 bajtem.
- 06 nie używać enumów tylko #define SOME_CONST 1(enumy są intem i mają 2 bajty)
- 10 nie robić skomplikowanej matematyki w warunku if() ani w tablicy. Wprowadzić prostą zmienną uint8_t, wczytać do niej dane spod adresu, wykonać na niej matematykę, zapisać zmienną do tablicy.
- malloc() i free() odpada
- wielowymiarowe tablice odpadają
- zamiast fopen, fwrite, fread, i fclose ze stdio spróbować użyć open, write, read, close z fcntl.h
- używać preinkrementacji ++a, cc65 może źle wykryć kiedy jest potrzebny efekt uboczny(rvalue) postinkrementacji i postdekrementacji
- 1 deklarować prototypy funkcji
- 2 nie deklarować zmiennych w zagnieżdżonych blokach
- 4 long jest powolny(uint32_t, int32_t)
- 5 liczby ze znakiem większe od bajta(int16_t, int32_t) są powolne
- 10 do adresowania stałych miejsc w pamięci użyj nagłówka atari.h i adresuj pamięć strukturami OS, BASIC, GTIA_READ, GTIA_WRITE, POKEY_READ, POKEY_WRITE, PIA, ANTIC
- 11 inicjalizuj zmienne w deklaracji jeśli się da, rozbij deklarację na 2 osobne ciągi niezainicjowanych oraz zainicjowanych zmiennych
- 12 jeszcze raz: używaj indeksowania tablicy[] zamiast wskaźnika *
- używaj odpowiednich sufiksów U, L lub UL do dużych literałów np: 0x8000U bo domyślnym w C jest signed long L
Mapa pamięci
[Obrazek] Mapa pamięci z De re Atari [Link] Prezentacja Yarona Nira [Link] Mapa pamięci$00 = 0x00 = hex zapis asemblera, 1 bajt
$0000 = 2 bajty, word
$00 - $7F | strona zerowa OSa - nie używać |
$80 - $FF | strona zerowa której możesz użyć |
$100 - 1FF | stos 6502 |
$200 - $2FF | wektory i rejestry OSa |
$300 - $3FF | rejestry OSa, IOCB, HATABS, CIO |
$400 - $5FF | bufory OSa, magnetofon, QMEG |
$600 - $6FF | strona 6 dla programów Basic - nie używać |
$700 - $1FFF | DOS - nie używać |
$2000 - RAMTOP | nasz kod |
$8000 - $9FFF | cartridge albo RAM |
$A000 - $BFFF | drugi kartridż lub wysoka połowa kartridźa 16k lub RAM |
$C000 - $CFFF | OS część 1 |
$CC00 - $CFFF | międzynarodowy zestaw znaków |
$D800 - $DFFF | FPP operacje zmiennoprzecinkowe Basica, PBI |
$E000 - $FFFF | OS część 2 |
$E000 - $E3FF | domyślny zestaw znaków |
$D000 - $D7FF - czipy Atari, dodatkowe wyspecjalizowane procesory, każdy zajmuje 1 stronę(256 bajtów), ale niekoniecznie całej używa
$D000 - $D0FF | GTIA "druga karta graficzna" odpowiada za piksele, kolory, sprajty/graczy pociski, wprowadzanie znaków z klawiatury |
$D100 - $D1FF | PBI port równoległy, gniazdo rozszerzeń |
$D200 - $D2FF | POKEY "karta muzyczna" odpowiada za paddle(wiosełko), klawiaturę, transmisję szeregową SIO i dźwięk |
$D300 - $D3FF | PIA odpowiada za joystick, zarządzanie pamięcią, np wyłączenie OSa i Basica |
$D400 - $D4FF | ANTIC "karta graficzna" odpowiada za DMA dostarcza GTIA danych |
$D500 - $D5FF | Rejestry kartridży |
$D600 - $D7FF | PBI, Zarezerwowane dla wbudowanych rozszerzeń np: VBXE |
Tutorial właściwy
W nowym folderze tworzymy pliki
build.bat
cl65 -t atari -O -S main.c
move main.s main.asm
cl65 --debug-info -Wl --dbgfile,guess.lab -m guess.map -Ln guess.lbl -t atari -Oi main.c -o guess.xex -C atari.cfg
guess.xex
cl65 -t atari -O -S main.c
- wygeneruj kod asemblera bez kompilacji--debug-info
lub-g
- generuj informacje debuggera-Wl
lub--ld-args
- przekaż poniższe parametry do linkera--dbgfile,guess.lab
- generuj informacje debuggera do pliku guess.lab-m guess.map
lub--mapfile
- plik mapy-Ln guess.lbl
- plik etykiet-t atari
- typ komputera-Oi
- optymalizacje inline, inne to s oraz r-o guess.xex
- plik wynikowy-C atari.cfg
- plik z definicjami pamięciguess.xex
- odpal Altirrę z naszym plikiem
atari.cfg
Z folderu C:\cc65\cfg
kopiujemy sobie plik atari.cfg
do naszego folderu. W przyszłości użyjemy go do organizacji pamięci naszego programu.
FEATURES {
STARTADDRESS: default = $2000;
}
Gdzie zaczyna się nasz kod.
[Link] MEMLO różnych DOSówSYMBOLS {
__EXEHDR__: type = import;
__SYSTEM_CHECK__: type = import; # force inclusion of "system check" load chunk
__AUTOSTART__: type = import; # force inclusion of autostart "trailer"
__STACKSIZE__: type = weak, value = $0800; # 2k stack
__STARTADDRESS__: type = export, value = %S;
__RESERVED_MEMORY__: type = weak, value = $0000;
}
[Link] Sekcja symboli
Widoczne symbole generowane dla asemblera. import
- symbol jest zdefiniowany gdzieś indziej i jego definicja będzie ładowana na etapie linkowania. export
- definiujemy i udostępniamy symbol. weak
- jak export, ale tylko, jeśli nikt inny nie zdefiniował symbolu.
__STACKSIZE__
= rozmiar stosu C, można zmniejszyć jeśli zastosujemy optymalizację ilmenita 04 - ograniczenie parametrów funkcji.
%S
= STARTADDRESS z FEATURES
w poprzednim akapicie. Musi być zdefiniowane wcześniej niż sekcja SYMBOLS
.
__RESERVED_MEMORY__
= RAM na pamięć obrazu dla -t atari
. Dla -t atarixl
nie używane, zamiast tego zwiększamy STARTADDRESS
.
MEMORY {
ZP: file = "", define = yes, start = $0082, size = $007E;
# file header, just $FFFF
HEADER: file = %O, start = $0000, size = $0002;
# "system check" load chunk
SYSCHKHDR: file = %O, start = $0000, size = $0004;
SYSCHKCHNK: file = %O, start = $2E00, size = $0300;
SYSCHKTRL: file = %O, start = $0000, size = $0006;
# "main program" load chunk
MAINHDR: file = %O, start = $0000, size = $0004;
MAIN: file = %O, define = yes, start = %S, size = $BC20 - __STACKSIZE__ - __RESERVED_MEMORY__ - %S;
TRAILER: file = %O, start = $0000, size = $0006;
}
[Link] Memory areas
Nazywamy sobie obszary pamięci. Do nich przypiszemy segmenty w sekcji poniżej. start
- początek nazwanego obszaru pamięci, size
- rozmiar.
MAIN = 48160 - 2048 - 0 - 8192 = 37920 bajtów na kod i dane.
define
- generuj etykiety asemblera dla obszaru pamięci
SEGMENTS {
ZEROPAGE: load = ZP, type = zp;
EXTZP: load = ZP, type = zp, optional = yes;
EXEHDR: load = HEADER, type = ro;
SYSCHKHDR: load = SYSCHKHDR, type = ro, optional = yes;
SYSCHK: load = SYSCHKCHNK, type = rw, define = yes, optional = yes;
SYSCHKTRL: load = SYSCHKTRL, type = ro, optional = yes;
MAINHDR: load = MAINHDR, type = ro;
STARTUP: load = MAIN, type = ro, define = yes;
LOWBSS: load = MAIN, type = rw, optional = yes; # not zero initialized
LOWCODE: load = MAIN, type = ro, define = yes, optional = yes;
ONCE: load = MAIN, type = ro, optional = yes;
CODE: load = MAIN, type = ro, define = yes;
RODATA: load = MAIN, type = ro;
DATA: load = MAIN, type = rw;
INIT: load = MAIN, type = rw, optional = yes;
BSS: load = MAIN, type = bss, define = yes;
AUTOSTRT: load = TRAILER, type = ro;
}
Jakie segmenty trafiają kolejno do których obszarów pamięci. ZEROPAGE do obszaru ZP, itd.
[Link] SegmentsFEATURES {
CONDES: type = constructor,
label = __CONSTRUCTOR_TABLE__,
count = __CONSTRUCTOR_COUNT__,
segment = ONCE;
CONDES: type = destructor,
label = __DESTRUCTOR_TABLE__,
count = __DESTRUCTOR_COUNT__,
segment = RODATA;
CONDES: type = interruptor,
label = __INTERRUPTOR_TABLE__,
count = __INTERRUPTOR_COUNT__,
segment = RODATA,
import = __CALLIRQ__;
}
[Link] Features]
Pliki cfg od zera fajnie omawia Ben Eater:
[Youtube] A simple BIOS for my breadboard computer [Youtube] Running MSBASIC on my breadboard 6502 computerguess.c
#include <atari.h>
void main(void){
}
Kompilujemy naszym build.bat w terminalu w naszym katalogu.
Plik guess.xex nic ciekawego nie robi.
Plik guess.map zawiera mapę pamięci naszego programu.
Zedytujmy guess.c:
#include <stdio.h>
void main (void)
{
while(1)
printf("Hello, world!");
}
Kompilujemy naszym build.bat a w Altirze wybieramy Debug -> Enable Debugger
.
F8 - zatrzymuje/wznawia program. W zakładce Disassembly widzimy gdzie w kodzie ASM jesteśmy.
[Obrazek] Altirra debuggerW linii poleceń na dole(Altirra>) możemy wpisać .help
żeby dostać listę komend debuggera. Komenda lm
powinna wyświelić załadowane symbole, w tym nasze pliki guess.lab i guess.lbl. Sprawdźmy jaki adres w pliku guess.lbl ma funkcja _main.
al 00207B .L0005
al 0028D4 .S0001
al 00206F ._main
00206F - wpiszmy do pola tekstowego Disassembly:
[Obrazek] 00206FKlikamy PPM Go to source
:
Powinien się nam załadować nasz plik guess.c. Możemy to sprawdzić w menu Debug -> Source file list
. Klikamy prawym w kodzie i wybieramy Toggle breakpoint
.
F8 - zatrzymujemy/wznawiamy kod. Czerwony kolor oznacza breakpoint, żółty że się w nim zatrzymaliśmy.
F10 - Step over
W oknie Console na dole widzimy rejestry A, X i Y 6502 dla zatrzymanego breakpointa:
Breakpoint 0 hit
(359: 83, 52) A=0E X=00 Y=02 S=F7 P=30 ( ) 206F: A9 D4 _main LDA #$D4
(360:304, 71) A=0E X=00 Y=02 S=F7 P=30 ( ) 207B: 4C 6F 20 JMP _main [$206F] = $A9
Usuńmy breakpoint - PPM na czerwonym podświetleniu i Toggle breakpoint
. Możemy też je wyświetlić Altirra> bl
i skasować bc 0
:
Altirra> bl
Breakpoints:
0 PC 206F (_main) `hello.c:6`
Altirra> bc 0
Breakpoint 0 cleared.
Otwieramy zakładkę Memory 1(można ją przywołać Alt+7 lub Debug -> Window -> Memory -> Memory 1). W mapie pamięci Atari poszukajmy adresu z zegarem czasu rzeczywistego.
[Link] Mapa pamięci18,19,20 $12,$13,$14 RTCLOK Internal realtime clock. Location 20 increments every stage one VBLANK interrupt (1/60 second = one jiffy) until it reaches 255 ($FF); then location 19 is incremented by one and 20 is reset to zero (every 4.27 seconds). When location 19 reaches 255, it and 20 are reset to zero and location 18 is incremented by one (every 18.2 minutes or 65536 TV frames).
Użyjmy komendy Altirra> wb
- watch byte:
Altirra> wb 14
[Obrazek] wb 14
Pojawi nam się mały czarny prostokąt z aktualną wartością komórki pamięci. Aby wyświetlić watche użyj wl
, aby usunąć wc 0
. Pamiętaj o F8.
Zmieńmy kod:
unsigned char my_char;
void main (void)
{
my_char = 1;
while(1)
;
}
Skompilujmy, i obejrzyjmy guess.lbl
al 002074 .L0005
al 00206F ._main
al 002100 ._my_char
Po uruchomieniu xex, możemy w zakładce Memory 1 skoczyć pod adres $2100 i sprawdzić, że rzeczywiście jest tam 1. Zapiszmy sobie zrzut pamięci do pliku:
Altirra> .writemem mem.txt $2100 L4
Wrote 2100-2103 to mem.txt
Folder zapisu to nasz folder z plikiem xex, od adresu $2100, 4 bajty. Plik mem.txt możemy podejrzeć w notepad++ wtyczka HEX-Editor.
Debug gier
Załadujmy sobie jakąś grę do Altirry np: Crownland. W Altirze trzeba zwiększyć rozmiar RAMu: System -> Configure system -> Memory -> Memory type -> wystarczy 128k(130XE).
[Link] Crownland (v3,128).xexDajmy F8 i polecenie Altirra> .gtia
Altirra> .gtia
Player 0: color = 04, pos = 40, size=0, data = 00
...
Playfield colors: 00 | 94 96 92 2c
PRIOR: 21 (pri= 1, multicolor , normal)
VDELAY: 00
GRACTL: 03, player DMA, missile DMA
CONSOL: 08 set <-> 0f input, speaker
M0PF: PF0 PF1 PF3
M1PF: PF0 PF1 PF3
M2PF:
M3PF:
...
Widzimy kolejno dane graczy PMG min. ich kolor i pozycję. Kolory pola gry w sztuce pięciu. PRIOR priorytety wyświetlania duchów i pola gry. GRACTL DMA. M0PF kolizje pocisków i graczy z polem gry i innymi graczami.
Dajmy Altirra> .antic
Altirra> .antic
DMACTL = 3d : narrow missiles players 1-line dlist
CHACTL = 02 : invert
DLIST = ff80
HSCROL = 0f
VSCROL = 00
PMBASE = f0
CHBASE = b8
NMIEN = c0 : dli vbi
NMIST = 9f : dli
PENH/V = 0c 98
Skoczmy do CHBASE $b800 i zrzućmy czcionkę do pliku .writemem mem.fnt $B800 L1024
otwórzmy Mad-Studio i wybierzmy Character Set Editor -> otwórzmy nasz plik mem.fnt. Te dane powinny nabrać dla nas teraz więcej sensu.
Dajmy Altirra> .dumpdlist
Altirra> .dumpdlist
FF80: x2 blank 8
FF82: blank.i 8
FF83: mode.i 4 @ FF40
FF86: mode.i 4 @ FF60
FF89: mode.i.h 4 @ BC02
FF8C: mode.i.h 4 @ BE02
FF8F: mode.i.h 4 @ C002
FF92: mode.i.h 4 @ C202
...
FFC5: mode.i.h 4 @ EC02
FFC8: mode.h 4 @ EE02
FFCB: waitvbl FF80
Widzimy kolejne linie trybowe wraz z ładowaniem pamięci co $200(512 DEC) bajtów.
Edit 2024.12.29 - korekta, mapa pamięci, linki.
Edit 2025.04.06 - HTML