Serial uploader was crashing the Pico when launching games because it accessed SD card (SPI) while Core 1 was refreshing display (also SPI). Display and SD card share the same SPI bus and cannot be accessed simultaneously. Split game launch into prepare and execute phases: - prepare: Re-scan games directory (safe, SD access done immediately) - execute: Load Lua script from SD (deferred until display is idle) Main loop now checks !is_refresh_in_progress() before completing launch, preventing SPI conflicts. Also updated SD card best practices skill to document SPI bus contention as the #1 most critical issue to avoid. Co-Authored-By: Claude <noreply@anthropic.com>
13 KiB
SD Card Best Practices for RP2350 + FatFS
This document captures critical best practices for working with SD card operations in this project, based on lessons learned during development.
1. SPI Bus Contention (CRITICAL)
⚠️ CRITICAL: Display and SD Card Share the Same SPI Bus
The display and SD card use the same SPI bus and CANNOT be accessed simultaneously. Attempting to do so will cause the Pico to crash or behave unpredictably.
Real-World Example: Game Launch Crash
The serial uploader originally crashed when launching games because:
SerialUploader::launch_game()writes file to SD (SPI)- Immediately calls
select_game_by_name() LuaGame::load_script()reads from SD (SPI)- Meanwhile, Core 1 is refreshing the display (also SPI)
- CRASH due to simultaneous SPI access
Solution: Wait for Display to Be Idle
Before any SD card operation that isn't already protected, ensure no display refresh is in progress:
// In main loop:
if (serial_uploader.wants_to_launch_game() && !is_refresh_in_progress()) {
// Safe to launch now - no SPI conflict with display
bool game_launched = serial_uploader.complete_launch();
// ...
}
Key Patterns for Avoiding Contention
Pattern 1: Check is_refresh_in_progress() before SD operations
if (!is_refresh_in_progress()) {
uint prev_speed = sd_card_set_spi_speed();
// SD card operations
sd_card_restore_spi_speed(prev_speed);
}
Pattern 2: Split operations into "prepare" and "execute" phases
// Phase 1: Prepare (safe, no SD access)
void prepare_operation() {
state = READY_TO_EXECUTE;
}
// Phase 2: Execute (only when !is_refresh_in_progress())
bool execute_operation() {
uint prev_speed = sd_card_set_spi_speed();
// SD card operations here
sd_card_restore_spi_speed(prev_speed);
}
Pattern 3: Keep main loop responsive
while (1) {
// Check if we need to do SD operation
if (needs_sd_operation && !is_refresh_in_progress()) {
perform_sd_operation();
}
// Don't sleep if waiting for SD operation window
bool stay_awake = pending_refresh || needs_sd_operation;
if (!stay_awake) {
__wfi(); // Sleep until interrupt
}
}
When This Matters Most
- Game loading: Reading Lua scripts from SD during game launch
- Serial uploads: Writing files and immediately loading them
- Save/load operations: Writing game state to SD
- Directory scanning: Re-scanning games while display is active
The Core Architecture
This project uses dual-core display refresh:
- Core 0: Main logic, input processing, game updates, SD card operations
- Core 1: Display refresh (writes framebuffer to display via SPI)
Core 1 runs asynchronously, so you must explicitly check is_refresh_in_progress() before SD operations.
2. SPI Speed Management
Critical Rule: Always Set SD Card Speed Before Operations
The display and SD card share the same SPI bus but operate at different speeds:
- Display: 32 MHz (fast)
- SD Card: 12.5 MHz (slower, more reliable)
ALWAYS wrap SD card operations with speed switching:
// Save current speed and switch to SD card speed
uint prev_speed = sd_card_set_spi_speed();
// ... SD card operations here ...
// f_open(), f_write(), f_read(), f_readdir(), etc.
// Restore previous speed for display
sd_card_restore_spi_speed(prev_speed);
Why This Matters
Running SD card operations at the wrong SPI speed causes:
- Unreliable reads/writes
- Corrupted data
FR_DISK_ERRerrors from FatFS- Hardware-level protocol failures (0xFF data responses)
Where Speed Switching is Already Handled
These functions handle SPI speed internally (you don't need to wrap them):
LuaGameLoader::register_all_games()- All functions in
sd_card.c(low-level operations)
Where You MUST Handle Speed Switching
Any code that calls FatFS functions directly:
f_open(),f_close()f_read(),f_write()f_opendir(),f_readdir(),f_closedir()f_stat(),f_mkdir(),f_unlink()f_getfree(),f_sync()
2. SD Card Write Protocol
The Data Response Polling Issue
When writing to SD card with CMD24 (write single block), the data response token may not arrive immediately. You must poll for it:
// After sending data block and CRC:
uint8_t response = 0xFF;
for (int i = 0; i < 10; i++) {
response = sd_card_transfer(0xFF);
if (response != 0xFF) {
break; // Got the response
}
}
// Check if data was accepted
if ((response & 0x1F) != SD_DATA_ACCEPTED) {
// Write failed
}
Why: The SD card may need a few clock cycles before sending the data response token. Reading only once may return 0xFF (no response yet).
Wait for Card Ready After CMD24
After sending the write command and before sending data:
// After CMD24 command:
uint8_t ready_byte;
do {
ready_byte = sd_card_transfer(0xFF);
timeout_count++;
if (timeout_count > 1000) {
return false; // Timeout
}
} while (ready_byte != 0xFF);
This ensures the card is ready to receive the data block.
3. FatFS Best Practices
Always Check Return Codes
FRESULT fr = f_open(&fil, path, FA_CREATE_ALWAYS | FA_WRITE);
if (fr != FR_OK) {
printf("ERROR: f_open failed: %d\n", fr);
// Clean up and return
return false;
}
Common FatFS error codes:
FR_OK (0): SuccessFR_DISK_ERR (1): Low-level disk error (often SPI speed issue)FR_NOT_READY (3): Card not initializedFR_NO_FILE (4): File not foundFR_NO_PATH (5): Path not foundFR_EXIST (8): File/directory already exists
Use FA_CREATE_ALWAYS to Overwrite
For rapid iteration (like our serial uploader):
f_open(&fil, path, FA_CREATE_ALWAYS | FA_WRITE);
This overwrites existing files, perfect for development.
Write in Chunks for Large Files
For files larger than 512 bytes, write in chunks:
const uint32_t CHUNK_SIZE = 512;
uint32_t total_written = 0;
while (total_written < total_size) {
uint32_t chunk_size = min(CHUNK_SIZE, total_size - total_written);
UINT bytes_written;
fr = f_write(&fil, buffer + total_written, chunk_size, &bytes_written);
if (fr != FR_OK || bytes_written != chunk_size) {
// Handle error
break;
}
total_written += bytes_written;
}
Always Sync and Close
f_sync(&fil); // Ensure data is written to card
f_close(&fil); // Close file and update directory
Skipping f_sync() can lead to data loss if power is lost.
4. Memory Management
Clean Up After Re-scanning
When re-scanning games (like after upload), clean up old data:
// Clear game launcher entries
game_launcher->clear_games();
// Clear Lua game factory data
LuaGameLoader::clear_factory_data();
// Re-scan
LuaGameLoader::register_all_games(game_launcher);
Why: Without cleanup, you get duplicate registrations and memory leaks.
Delete Old Game Instances Before Creating New Ones
if (selected_game) {
delete selected_game;
selected_game = nullptr;
}
// Now create new game
selected_game = factory(width, height, renderer, gui, input_manager);
Critical for Lua games: Each LuaGame has a Lua state. Not cleaning up the old one before creating a new one causes conflicts and freezes.
5. Debugging Tips
Add Targeted Debug Output
When debugging SD operations, add prints at key points:
printf("✓ Wrote %u bytes to %s\n", bytes_written, filepath);
But avoid spamming the console - it slows down operations significantly.
Check Hardware Layer First
If FatFS returns FR_DISK_ERR, the issue is usually at the hardware level:
- Check SPI speed (most common issue)
- Check SD card write protection
- Check physical SD card connection
- Verify SD card is properly initialized
Use Root Directory for Testing
When debugging writes, test with root directory first:
FIL test_file;
if (f_open(&test_file, "/test.txt", FA_CREATE_ALWAYS | FA_WRITE) == FR_OK) {
printf("Root write OK\n");
f_close(&test_file);
f_unlink("/test.txt");
}
This isolates directory-related issues.
6. Common Pitfalls
❌ DON'T: Access SD Card During Display Refresh (MOST CRITICAL)
// BAD - Will crash the Pico!
void launch_game() {
scan_games(); // Reads SD card
selected_game = create_game(); // Reads Lua script from SD
}
// Called directly without checking if display is refreshing
✅ DO: Wait for Display to Be Idle
// GOOD - Wait for safe window
if (!is_refresh_in_progress()) {
uint prev_speed = sd_card_set_spi_speed();
scan_games();
selected_game = create_game();
sd_card_restore_spi_speed(prev_speed);
}
❌ DON'T: Forget SPI Speed Management
// BAD - Will fail or be unreliable
f_open(&fil, "/games/test.lua", FA_WRITE);
✅ DO: Always Switch Speeds
// GOOD
uint prev_speed = sd_card_set_spi_speed();
f_open(&fil, "/games/test.lua", FA_WRITE);
// ... operations ...
sd_card_restore_spi_speed(prev_speed);
❌ DON'T: Assume Immediate Data Response
// BAD - May get 0xFF (no response yet)
uint8_t response = sd_card_transfer(0xFF);
if (response != 0x05) {
// Might incorrectly fail
}
✅ DO: Poll for Data Response
// GOOD - Poll until response arrives
uint8_t response = 0xFF;
for (int i = 0; i < 10; i++) {
response = sd_card_transfer(0xFF);
if (response != 0xFF) break;
}
❌ DON'T: Skip Error Checking
// BAD
f_write(&fil, buffer, size, &bytes_written);
f_close(&fil);
✅ DO: Check Every Return Value
// GOOD
if (f_write(&fil, buffer, size, &bytes_written) != FR_OK) {
printf("Write failed\n");
f_close(&fil);
return false;
}
❌ DON'T: Create New Games Without Cleanup
// BAD - Memory leak and Lua state conflicts
selected_game = new LuaGame(...);
✅ DO: Clean Up First
// GOOD
if (selected_game) {
delete selected_game;
selected_game = nullptr;
}
selected_game = new LuaGame(...);
7. Serial Upload Pattern
The serial uploader demonstrates the complete pattern:
bool SerialUploader::write_file_to_sd() {
// 1. Validate input
if (!file_buffer || bytes_received == 0) return false;
// 2. Set SD card SPI speed
uint prev_speed = sd_card_set_spi_speed();
// 3. Ensure directory exists
f_mkdir("/games");
// 4. Open file (overwrite mode for iteration)
FIL fil;
FRESULT fr = f_open(&fil, filepath, FA_CREATE_ALWAYS | FA_WRITE);
if (fr != FR_OK) {
sd_card_restore_spi_speed(prev_speed);
return false;
}
// 5. Write in chunks
const uint32_t CHUNK_SIZE = 512;
uint32_t total_written = 0;
while (total_written < bytes_received) {
uint32_t chunk = min(CHUNK_SIZE, bytes_received - total_written);
UINT written;
fr = f_write(&fil, file_buffer + total_written, chunk, &written);
if (fr != FR_OK || written != chunk) {
f_close(&fil);
sd_card_restore_spi_speed(prev_speed);
return false;
}
total_written += written;
}
// 6. Sync and close
f_sync(&fil);
f_close(&fil);
// 7. Restore display SPI speed
sd_card_restore_spi_speed(prev_speed);
return true;
}
9. Testing Checklist
When implementing new SD card functionality:
- SPI bus contention checked - verify
!is_refresh_in_progress()before SD operations - SPI speed switching is in place
- All FatFS return codes are checked
- Files are properly closed after operations
- Memory is cleaned up (no leaks)
- Error messages are informative
- Tested with both small and large files
- Tested overwriting existing files
- Tested with non-existent directories
- Verified data integrity (read back after write)
Summary
The most important rules:
- ⚠️ CRITICAL: Avoid SPI bus contention - Check
!is_refresh_in_progress()before SD operations (display and SD share SPI bus) - Always manage SPI speed around FatFS operations
- Poll for SD card responses - don't assume immediate response
- Check error codes on every operation
- Clean up memory before creating new game instances
- Write in chunks for large files
- Sync before closing to ensure data is written
Following these practices will save hours of debugging SD card issues!