23 KiB
main.c - Ausführliche Code-Erklärung
Dieses Dokument erklärt den kompletten Aufbau von main/main.c auf Deutsch.
📋 Datei-Übersicht
Datei: main/main.c (786 Zeilen)
Funktion: Hauptprogramm des GameBoy-Emulators
Aufgabe: Emulation, Display-Rendering, Audio-Ausgabe koordinieren
🏗️ Code-Struktur
main.c
├── Includes & Definitionen (Zeilen 1-43)
├── APU (Audio) Variablen (Zeilen 44-100)
├── APU Register-Funktionen (Zeilen 101-336)
├── Audio-Ausgabe Task (Zeilen 337-415)
├── GameBoy Palette & Callbacks (Zeilen 416-530)
├── ROM Lade-Funktionen (Zeilen 531-609)
├── Display Task (Zeilen 610-653)
├── SD-Card Mount (Zeilen 654-701)
└── Main Loop (app_main) (Zeilen 702-786)
📦 Teil 1: Includes & Konstanten (Zeilen 1-43)
Include-Dateien
#include "freertos/FreeRTOS.h" // FreeRTOS Kernel
#include "freertos/task.h" // Task-Verwaltung
#include "freertos/semphr.h" // Semaphore für Synchronisation
#include "esp_system.h" // ESP32 System-Funktionen
#include "esp_log.h" // Logging (ESP_LOGI, ESP_LOGE)
#include "esp_heap_caps.h" // PSRAM Allocation
#include "esp_vfs_fat.h" // FAT Filesystem
#include "driver/i2s.h" // I2S Audio-Treiber
Audio-Konstanten
#define SAMPLE_RATE 32768 // GameBoy Audio-Rate (32768 Hz)
#define SAMPLES_PER_FRAME 546 // 32768 Hz / 60 FPS = 546 Samples
#define SAMPLES_PER_BUFFER 512 // I2S Buffer-Größe
#define GB_CPU_FREQ 4194304.0f // GameBoy CPU: 4.194304 MHz
#define CYCLES_PER_SAMPLE 128 // CPU-Takte pro Audio-Sample
Erklärung:
- GameBoy läuft mit 60 FPS (59.73 FPS genau)
- Pro Frame werden 546 Audio-Samples erzeugt (32768 / 60)
- GameBoy CPU läuft mit ~4.19 MHz
- Alle 128 CPU-Takte wird 1 Audio-Sample erzeugt
📦 Teil 2: APU (Audio Processing Unit) Variablen (Zeilen 44-100)
APU Register
static uint8_t apu_regs[48] = {0}; // 48 Audio-Register (0xFF10 - 0xFF3F)
static uint8_t wave_ram[16] = {0}; // 16 Bytes Wave-Pattern (Kanal 3)
GameBoy Audio-Register:
0xFF10-0xFF14: Kanal 1 (Square Wave mit Sweep)0xFF15-0xFF19: Kanal 2 (Square Wave)0xFF1A-0xFF1E: Kanal 3 (Wave Pattern)0xFF1F-0xFF23: Kanal 4 (Noise)0xFF24-0xFF26: Master Control
Kanal-Status Strukturen
// Kanal 1: Square Wave mit Frequency Sweep
static struct {
bool active; // Kanal läuft
bool dac_on; // Digital-Analog-Wandler an
uint8_t duty; // Tastgrad (12.5%, 25%, 50%, 75%)
uint8_t volume; // Lautstärke (0-15)
uint16_t freq_raw; // Frequenz-Wert (0-2047)
float phase; // Aktuelle Phase (0.0 - 1.0)
} ch1;
// Kanal 2: Square Wave (wie Kanal 1, ohne Sweep)
static struct {
bool active;
bool dac_on;
uint8_t duty;
uint8_t volume;
uint16_t freq_raw;
float phase;
} ch2;
// Kanal 3: Wave Pattern (Custom Wellenform)
static struct {
bool active;
bool dac_on;
uint8_t volume_shift; // Lautstärke-Shift (0, 1, 2 bits)
uint16_t freq_raw;
float phase;
} ch3;
// Kanal 4: Noise (Zufallsrauschen)
static struct {
bool active;
uint8_t volume;
uint16_t lfsr; // Linear Feedback Shift Register (Pseudo-Random)
uint8_t divisor; // Frequenz-Teiler
uint8_t shift; // Shift-Anzahl
bool width_mode; // 7-bit oder 15-bit LFSR
float timer; // Timer für nächstes Sample
} ch4 = {.lfsr = 0x7FFF}; // LFSR Initial-Wert
Erklärung der Duty-Werte (Tastgrad):
Duty 0 (12.5%): ─┐_______ (kurzer Puls)
Duty 1 (25%): ─┐┐______ (1/4 hoch)
Duty 2 (50%): ─┐┐┐┐____ (Rechteck)
Duty 3 (75%): ─┐┐┐┐┐┐__ (3/4 hoch)
Audio-System Variablen
static bool audio_enabled = false; // Audio-System läuft
static int16_t *audio_buffer = NULL; // Audio Ring-Buffer
static SemaphoreHandle_t apu_mutex = NULL; // Mutex für Thread-Sicherheit
📦 Teil 3: APU Register Read/Write (Zeilen 101-336)
apu_mem_read() - Register auslesen
uint8_t apu_mem_read(struct gb_s *gb, uint16_t addr)
Funktion: Liest GameBoy Audio-Register aus (0xFF10 - 0xFF3F)
Spezialfälle:
0xFF26(NR52): Master-Enable Status0xFF30-0xFF3F: Wave RAM (16 Bytes)- Andere Register: Direkt aus
apu_regs[]Array
apu_mem_write() - Register schreiben
void apu_mem_write(struct gb_s *gb, uint16_t addr, uint8_t val)
Funktion: Schreibt in GameBoy Audio-Register und aktualisiert Kanal-Status
Wichtige Register:
Kanal 1 (Square mit Sweep):
0xFF10 (NR10): Sweep-Einstellungen
0xFF11 (NR11): Duty + Length
duty = (val >> 6) & 0x03; // Bits 6-7: Tastgrad (0-3)
0xFF12 (NR12): Volume Envelope
volume = (val >> 4) & 0x0F; // Bits 4-7: Start-Lautstärke
dac_on = ((val & 0xF8) != 0); // DAC an wenn Bits 3-7 != 0
0xFF13 (NR13): Frequency Low
freq_raw = (freq_raw & 0x700) | val; // Untere 8 Bits
0xFF14 (NR14): Frequency High + Trigger
freq_raw = (freq_raw & 0xFF) | ((val & 0x07) << 8); // Obere 3 Bits
if (val & 0x80) { // Bit 7: Trigger
active = true; // Kanal starten
phase = 0.0f; // Phase zurücksetzen
}
Kanal 3 (Wave):
0xFF1A (NR30): DAC Enable
dac_on = (val & 0x80) != 0; // Bit 7: DAC an/aus
0xFF1C (NR32): Volume
volume_shift = (val >> 5) & 0x03; // Bits 5-6: Shift (0-3)
// 0 = Stumm, 1 = 100%, 2 = 50%, 3 = 25%
0xFF30-0xFF3F: Wave RAM
wave_ram[addr - 0xFF30] = val; // 16 Bytes Custom-Wellenform
Kanal 4 (Noise):
0xFF21 (NR42): Volume
volume = (val >> 4) & 0x0F; // Bits 4-7: Lautstärke
0xFF22 (NR43): Polynomial Counter
shift = (val >> 4) & 0x0F; // Bits 4-7: Shift
width_mode = (val & 0x08) != 0; // Bit 3: 7-bit (1) oder 15-bit (0)
divisor = val & 0x07; // Bits 0-2: Divisor
0xFF23 (NR44): Trigger
if (val & 0x80) {
lfsr = 0x7FFF; // LFSR zurücksetzen
active = true;
}
Master Control:
0xFF24 (NR50): Master Volume
master_vol_left = (val >> 4) & 0x07; // Bits 4-6: Links (0-7)
master_vol_right = val & 0x07; // Bits 0-2: Rechts (0-7)
0xFF25 (NR51): Panning
// Bits 0-7: Welcher Kanal auf welchem Lautsprecher
// Bit 0: Ch1 rechts, Bit 4: Ch1 links, etc.
0xFF26 (NR52): Master Enable
master_enable = (val & 0x80) != 0; // Bit 7: Audio an/aus
if (!master_enable) {
// Alle Kanäle stoppen
ch1.active = false;
ch2.active = false;
ch3.active = false;
ch4.active = false;
}
📦 Teil 4: Audio Output Task (Zeilen 337-415)
audio_task() - I2S Audio-Ausgabe
static void audio_task(void *arg)
Funktion: Läuft dauerhaft auf Core 1, schreibt Audio-Daten zum I2S
Ablauf:
- Warte auf volle Buffer (512 Samples)
- Schreibe zu I2S (MAX98357A Verstärker)
- Wiederhole
Code-Erklärung:
while (1) {
// Warte bis Buffer voll ist (512 Samples = 1024 bytes)
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// Schreibe zu I2S
size_t bytes_written = 0;
i2s_write(I2S_NUM, audio_buffer, SAMPLES_PER_BUFFER * 2,
&bytes_written, portMAX_DELAY);
}
📦 Teil 5: GameBoy Palette & Callbacks (Zeilen 416-530)
GameBoy Farbpalette
static const uint16_t gb_palette[4] = {
0xFFFF, // Weiß (Hintergrund) - RGB565: 11111 111111 11111
0xAD55, // Hellgrün - RGB565: 10101 101010 10101
0x52AA, // Mittelgrün - RGB565: 01010 010101 01010
0x0000 // Schwarz (Vordergrund) - RGB565: 00000 000000 00000
};
Erklärung RGB565 Format:
16-bit RGB565: RRRRR GGGGGG BBBBB
↑ ↑ ↑
5 bit 6 bit 5 bit
Rot Grün Blau
GameBoy Graustufen:
- GameBoy hat nur 4 Graustufen (2-bit Farbtiefe)
- Wert 0 = Weiß (Hintergrund)
- Wert 1 = Hellgrün
- Wert 2 = Mittelgrün
- Wert 3 = Schwarz (Sprites/Text)
audio_callback() - Audio-Sample Callback
void audio_callback(struct gb_s *gb, uint16_t left, uint16_t right)
Funktion: Wird von Peanut-GB für jedes Audio-Sample aufgerufen (32768x pro Sekunde)
Ablauf:
// 1. Konvertiere unsigned (0-65535) zu signed (-32768 bis +32767)
int16_t sample_l = (int16_t)(left - 32768);
int16_t sample_r = (int16_t)(right - 32768);
// 2. Schreibe in Audio-Buffer (Ring-Buffer)
static int audio_write_pos = 0;
audio_buffer[audio_write_pos++] = sample_l; // Links
audio_buffer[audio_write_pos++] = sample_r; // Rechts
// 3. Wenn Buffer voll → I2S schreiben
if (audio_write_pos >= SAMPLES_PER_BUFFER * 2) {
audio_write_pos = 0;
xTaskNotifyGive(audio_task_handle); // Audio-Task aufwecken
}
// 4. APU-Register auslesen für Status-Anzeige
ch1.active = (apu_regs[0x16] & 0x01); // Kanal 1 läuft
ch2.active = (apu_regs[0x16] & 0x02); // Kanal 2 läuft
ch3.active = (apu_regs[0x16] & 0x04); // Kanal 3 läuft
ch4.active = (apu_regs[0x16] & 0x08); // Kanal 4 läuft
Warum -32768?
- GameBoy Audio: 0-65535 (unsigned)
- I2S erwartet: -32768 bis +32767 (signed)
- Umrechnung: signed = unsigned - 32768
gb_lcd_draw_line() - Display-Zeilen Callback
static void gb_lcd_draw_line(struct gb_s *gb, const uint8_t pixels[160],
const uint_fast8_t line)
Funktion: Wird 144× pro Frame aufgerufen (eine GameBoy-Zeile pro Aufruf)
Parameter:
pixels[160]: 160 GameBoy-Pixel für diese Zeile (Werte 0-3)line: Zeilen-Nummer (0-143)
Scaling-Algorithmus (bei GB_PIXEL_PERFECT_SCALING = 1):
// 1. Vertikales Scaling: GameBoy-Zeile → Display-Y-Position
int y_base = (line * GB_RENDER_HEIGHT) / 144;
// Beispiel bei Scale 1.6: Zeile 10 → Y = 16 (10 * 230 / 144 = 16)
// 2. Horizontales Scaling: Pixel für Pixel
int x_dst = 0;
for (int x = 0; x < 160; x++) {
// Farbwert aus Palette holen
uint16_t c = gb_palette[pixels[x] & 0x03];
// RGB→BGR Byte-Swap für ST7789
uint16_t swapped = (c >> 8) | (c << 8);
// Berechne Pixel-Breite (verhindert Lücken!)
int next_x_dst = ((x + 1) * GB_RENDER_WIDTH) / 160;
int pixel_width = next_x_dst - x_dst;
// Fülle pixel_width Output-Pixel mit dieser Farbe
for (int w = 0; w < pixel_width; w++) {
int dst = (y_base + GB_OFFSET_Y) * GB_SCREEN_WIDTH +
(x_dst + w + GB_OFFSET_X);
render_buffer[dst] = swapped;
}
x_dst = next_x_dst;
}
// 3. Zeilen-Duplikation (für gleichmäßiges vertikales Scaling)
int ny = ((line + 1) * GB_RENDER_HEIGHT) / 144;
if (ny > y_base + 1) {
// Zeile duplizieren (memcpy)
memcpy(&render_buffer[(y_base + 1 + GB_OFFSET_Y) * GB_SCREEN_WIDTH + GB_OFFSET_X],
&render_buffer[(y_base + GB_OFFSET_Y) * GB_SCREEN_WIDTH + GB_OFFSET_X],
GB_RENDER_WIDTH * 2); // *2 weil uint16_t = 2 bytes
}
Warum Byte-Swap?
- Peanut-GB liefert RGB565:
RRRRR GGGGGG BBBBB - ST7789 erwartet BGR565:
BBBBB GGGGGG RRRRR - Byte-Swap:
(c >> 8) | (c << 8)tauscht High-/Low-Byte
Scaling-Beispiel bei 1.6×:
GameBoy: 160 Pixel Breite
Display: 256 Pixel Breite (160 * 1.6 = 256)
Pixel 0 → X 0-1 (2 Pixel breit)
Pixel 1 → X 1-3 (2 Pixel breit)
Pixel 2 → X 3-4 (1 Pixel breit) ← Abwechselnd!
Pixel 3 → X 4-6 (2 Pixel breit)
...
Pattern: 2, 2, 1, 2, 2, 1, ... (8:5 Verhältnis)
📦 Teil 6: ROM Lade-Funktionen (Zeilen 531-609)
gb_rom_read() - ROM-Bytes lesen
uint8_t gb_rom_read(struct gb_s *gb, uint32_t addr)
Funktion: Liest 1 Byte aus ROM an Adresse addr
Code:
return rom_data[addr]; // Einfacher Array-Zugriff
gb_cart_ram_read() - Cartridge RAM lesen
uint8_t gb_cart_ram_read(struct gb_s *gb, uint32_t addr)
Funktion: Liest Save-Game RAM (für Pokemon, Zelda, etc.)
Status: Aktuell nicht implementiert (return 0xFF)
gb_cart_ram_write() - Cartridge RAM schreiben
void gb_cart_ram_write(struct gb_s *gb, uint32_t addr, uint8_t val)
Funktion: Schreibt Save-Game RAM
Status: Aktuell nicht implementiert (TODO)
load_rom_from_sd() - ROM von SD-Card laden
static esp_err_t load_rom_from_sd(const char *path)
Funktion: Lädt GameBoy ROM-Datei von SD-Card
Ablauf:
// 1. Datei öffnen
FILE *f = fopen(path, "rb");
// 2. Größe ermitteln
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
// 3. Speicher allokieren (PSRAM bevorzugt)
rom_data = heap_caps_malloc(size, MALLOC_CAP_SPIRAM);
if (!rom_data) {
rom_data = malloc(size); // Fallback: Normal-RAM
}
// 4. ROM lesen
fread(rom_data, 1, size, f);
// 5. Datei schließen
fclose(f);
// 6. ROM-Info auslesen (Bytes 0x134-0x143: Titel)
char title[17] = {0};
memcpy(title, &rom_data[0x134], 16);
ESP_LOGI(TAG, "ROM: %s (%ld bytes)", title, size);
📦 Teil 7: Display Task (Zeilen 610-653)
display_task() - Display Rendering auf Core 0
static void display_task(void *arg)
Funktion: Läuft parallel zur Emulation, rendert Frames zum Display
Optimierung - Compact Buffer:
// 1. Einmalig schwarze Ränder füllen (nur bei Scaling)
#if GB_PIXEL_PERFECT_SCALING
st7789_fill_screen(0x0000); // Schwarz
// Compact Buffer allokieren (nur GameBoy-Region, ohne Ränder)
uint16_t *compact_buffer = heap_caps_malloc(
GB_RENDER_WIDTH * GB_RENDER_HEIGHT * 2,
MALLOC_CAP_DMA // DMA-fähiger Speicher für SPI
);
#endif
// 2. Frame-Rendering Loop
while (1) {
// Warte auf fertigen Frame
xSemaphoreTake(frame_ready_sem, portMAX_DELAY);
#if GB_PIXEL_PERFECT_SCALING
// Kopiere nur GameBoy-Region (ohne schwarze Ränder)
for (int y = 0; y < GB_RENDER_HEIGHT; y++) {
memcpy(
&compact_buffer[y * GB_RENDER_WIDTH], // Ziel
&display_buffer[(y + GB_OFFSET_Y) * GB_SCREEN_WIDTH + GB_OFFSET_X], // Quelle
GB_RENDER_WIDTH * 2 // Anzahl Bytes
);
}
// SPI-Transfer (33% weniger Daten!)
st7789_draw_buffer_preswapped(
compact_buffer,
GB_OFFSET_X, GB_OFFSET_Y,
GB_RENDER_WIDTH, GB_RENDER_HEIGHT
);
#else
// Fullscreen: Ganzen Buffer senden
st7789_draw_buffer_preswapped(
display_buffer,
0, 0,
GB_SCREEN_WIDTH, GB_SCREEN_HEIGHT
);
#endif
// Signalisiere Emulation: Fertig, nächstes Frame!
xSemaphoreGive(frame_done_sem);
}
Warum Compact Buffer?
Ohne Compact Buffer:
┌──────────────────────────┐
│ Schwarz (40 Pixel) │ ← Wird mit übertragen
├──────────────────────────┤
│ │
│ GameBoy (256×230) │ ← Nur das wird gebraucht!
│ │
├──────────────────────────┤
│ Schwarz (24 Pixel) │ ← Wird mit übertragen
└──────────────────────────┘
Total: 320×240 = 76.800 Pixel
Mit Compact Buffer:
┌──────────────────────────┐
│ GameBoy (256×230) │ ← Nur das wird übertragen!
└──────────────────────────┘
Total: 256×230 = 58.880 Pixel (23% weniger!)
Ergebnis: 3-4ms schneller pro Frame!
📦 Teil 8: SD-Card Mount (Zeilen 654-701)
mount_sd_card() - SD-Karte mounten
static esp_err_t mount_sd_card(void)
Funktion: Initialisiert SD-Card über SPI
Code-Erklärung:
// 1. SPI-Bus Konfiguration
sdmmc_host_t host = SDSPI_HOST_DEFAULT(); // SPI-Modus
sdspi_device_config_t slot_config = SDSPI_DEVICE_CONFIG_DEFAULT();
slot_config.gpio_cs = SD_PIN_CS; // GPIO 41
slot_config.host_id = SD_SPI_HOST; // Shared mit Display
// 2. Mount-Konfiguration
esp_vfs_fat_sdmmc_mount_config_t mount_config = {
.format_if_mount_failed = false, // NICHT formatieren!
.max_files = 5,
.allocation_unit_size = 16 * 1024
};
// 3. Mounten
sdmmc_card_t *card;
esp_vfs_fat_sdmmc_mount("/sdcard", &host, &slot_config, &mount_config, &card);
// 4. Card-Info anzeigen
ESP_LOGI(TAG, "SD Card: %s, %llu MB",
card->cid.name,
((uint64_t) card->csd.capacity) * card->csd.sector_size / (1024 * 1024));
📦 Teil 9: Main Loop (app_main) (Zeilen 702-786)
app_main() - Hauptprogramm
void app_main(void)
Kompletter Programmablauf:
// ===== 1. DISPLAY INITIALISIEREN =====
ESP_LOGI(TAG, "Init Display...");
st7789_init(); // ST7789 mit 80 MHz SPI
st7789_set_backlight(80); // 80% Helligkeit
// ===== 2. SD-KARTE MOUNTEN =====
ESP_LOGI(TAG, "Init SD...");
mount_sd_card(); // FAT32 Filesystem
// ===== 3. ROM LADEN =====
ESP_LOGI(TAG, "Load ROM...");
load_rom_from_sd("/sdcard/tetris.gb"); // Fest verdrahtet
// ===== 4. PSRAM PRÜFEN & BUFFER ALLOKIEREN =====
size_t psram_size = heap_caps_get_free_size(MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "PSRAM: %d KB total, %d KB free",
psram_size / 1024,
heap_caps_get_free_size(MALLOC_CAP_SPIRAM) / 1024);
// Double-Buffering: 2× 320×240 = 150 KB pro Buffer
size_t buffer_size = GB_SCREEN_WIDTH * GB_SCREEN_HEIGHT * 2;
render_buffer = heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM);
display_buffer = heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM);
// Mit Schwarz füllen
memset(render_buffer, 0, buffer_size);
memset(display_buffer, 0, buffer_size);
// ===== 5. AUDIO INITIALISIEREN =====
ESP_LOGI(TAG, "Init Audio...");
// I2S Konfiguration
i2s_config_t i2s_config = {
.mode = I2S_MODE_MASTER | I2S_MODE_TX, // Master, nur Senden
.sample_rate = SAMPLE_RATE, // 32768 Hz
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.dma_buf_count = I2S_DMA_BUF_COUNT, // 8 Buffer
.dma_buf_len = I2S_DMA_BUF_LEN, // 1024 Samples
.use_apll = false,
.tx_desc_auto_clear = true
};
i2s_driver_install(I2S_NUM, &i2s_config, 0, NULL);
// Pin-Konfiguration
i2s_pin_config_t pin_config = {
.bck_io_num = I2S_PIN_BCLK, // GPIO 48
.ws_io_num = I2S_PIN_LRC, // GPIO 47
.data_out_num = I2S_PIN_DIN, // GPIO 16
.data_in_num = -1 // Kein Input
};
i2s_set_pin(I2S_NUM, &pin_config);
// Audio Buffer allokieren
audio_buffer = heap_caps_malloc(SAMPLES_PER_BUFFER * 2 * sizeof(int16_t),
MALLOC_CAP_DMA);
// Audio Task starten (Core 1, Priority 5)
xTaskCreatePinnedToCore(audio_task, "audio", 4096, NULL, 5,
&audio_task_handle, 1);
audio_enabled = true;
// ===== 6. EMULATOR INITIALISIEREN =====
struct gb_s gb;
gb_init(&gb, &gb_rom_read, &gb_cart_ram_read, &gb_cart_ram_write,
&audio_callback, NULL);
gb_init_lcd(&gb, &gb_lcd_draw_line);
// Palette setzen
for (int i = 0; i < 4; i++) {
gb.display.palette[i] = gb_palette[i];
}
// APU Callbacks registrieren
gb.apu.apu_mem_read = apu_mem_read;
gb.apu.apu_mem_write = apu_mem_write;
// ===== 7. DISPLAY TASK STARTEN =====
frame_ready_sem = xSemaphoreCreateBinary(); // Semaphore erstellen
frame_done_sem = xSemaphoreCreateBinary();
xSemaphoreGive(frame_done_sem); // Initial freigeben
xTaskCreatePinnedToCore(display_task, "display", 4096, NULL, 10,
NULL, 0); // Core 0, Priority 10
// ===== 8. EMULATION LOOP =====
ESP_LOGI(TAG, "✓ %s with FIXED AUDIO! 🎮🔊", rom_title);
uint32_t frame_count = 0;
int64_t last_time = esp_timer_get_time();
while (1) {
// Warte auf Display-Task fertig
xSemaphoreTake(frame_done_sem, portMAX_DELAY);
// Emuliere 1 Frame (70224 CPU-Takte, ~16.7ms)
gb_run_frame(&gb);
// Buffer Swap (Render ↔ Display)
uint16_t *temp = render_buffer;
render_buffer = display_buffer;
display_buffer = temp;
// Display-Task aufwecken
xSemaphoreGive(frame_ready_sem);
frame_count++;
// Alle 60 Frames: FPS ausgeben
if (frame_count % 60 == 0) {
int64_t now = esp_timer_get_time();
int32_t time_ms = (now - last_time) / 1000 / 60;
float fps = 1000.0f / time_ms;
ESP_LOGI(TAG, "Frame %ld | time=%ldms (%.1f FPS) | sound=%s | "
"ch1=%d ch2=%d ch3=%d ch4=%d",
frame_count, time_ms, fps,
master_enable ? "ON" : "OFF",
ch1.active, ch2.active, ch3.active, ch4.active);
last_time = now;
}
}
Zusammenfassung:
- Display init (80 MHz SPI, 80% Helligkeit)
- SD-Card mount (FAT32)
- ROM laden (tetris.gb)
- PSRAM check & Buffer alloc (2× 150 KB)
- Audio init (I2S 32768 Hz, Task auf Core 1)
- Emulator init (Peanut-GB mit Callbacks)
- Display Task start (Core 0, parallel rendering)
- Emulation Loop (60 FPS, Double-Buffering, FPS-Logging)
🎯 Performance-Tricks im Code
1. Double-Buffering mit Semaphoren
// Emulation wartet auf Display fertig
xSemaphoreTake(frame_done_sem, portMAX_DELAY);
// ... emuliert ...
// Buffer tauschen (Pointer-Swap, keine Kopie!)
uint16_t *temp = render_buffer;
render_buffer = display_buffer;
display_buffer = temp;
// Display aufwecken
xSemaphoreGive(frame_ready_sem);
Vorteil: Emulation + Display laufen parallel = 50% schneller!
2. Byte-Swapping im Emulator
// RGB→BGR beim Rendern, nicht beim Transfer
uint16_t swapped = (c >> 8) | (c << 8);
render_buffer[dst] = swapped;
Vorteil: Kein Byte-Swap beim SPI-Transfer nötig!
3. Compact Buffer
// Nur GameBoy-Region kopieren, nicht ganze 320×240
for (int y = 0; y < GB_RENDER_HEIGHT; y++) {
memcpy(&compact_buffer[y * GB_RENDER_WIDTH],
&display_buffer[...], GB_RENDER_WIDTH * 2);
}
Vorteil: 23% weniger SPI-Daten = 3-4ms schneller!
4. PSRAM für große Buffer
// Große Buffer in PSRAM (nicht in limited SRAM)
render_buffer = heap_caps_malloc(buffer_size, MALLOC_CAP_SPIRAM);
Vorteil: 8MB verfügbar statt nur 512KB SRAM!
📊 Timing-Übersicht
GameBoy Frame (16.7ms @ 59.73 FPS):
├─ CPU Emulation: ~6ms (70224 Takte)
├─ PPU Rendering: ~4ms (144 Zeilen)
├─ APU Audio: ~2ms (546 Samples)
└─ Buffer Swap: <1ms (Pointer-Tausch)
Display Rendering (parallel auf Core 0):
├─ Compact Buffer: ~2ms (Zeilen kopieren)
├─ SPI Transfer: ~10ms (80 MHz, 117 KB)
└─ Semaphore: <1ms
Total Zeit: ~16ms (beide Cores parallel!)
Ergebnis: 60-90 FPS je nach Spiel
Ende der main.c Erklärung
Dieses Dokument erklärt alle wichtigen Bereiche von main.c auf Deutsch mit Code-Beispielen und Erklärungen. Für weitere Fragen zu spezifischen Funktionen, siehe die Kommentare direkt im Code!