A minimal bare-metal operating system for the ESP32-C3 (RISC-V) microcontroller with OLED display support.
- Bare-metal RISC-V code execution
- UART console output
- GPIO control
- I2C communication (bit-banged)
- SSD1306 OLED display driver (128x64)
- Interactive command shell (serial input → OLED output)
- Custom bootloader (work in progress)
- Rust implementation available as alternative to C version
- Seeed XIAO ESP32-C3 (or any ESP32-C3 board)
- SSD1306 OLED Display (128x64, I2C)
- I2C Address: 0x3C (default) or 0x3D
- Pins: GPIO6 (SDA), GPIO7 (SCL)
esp32-riscv-bare-metal-os/
├── main/
│ ├── main.c # Your application code
│ ├── shell.c/h # Interactive command shell
│ ├── CMakeLists.txt # Build configuration
│ │
│ ├── startup/ # Low-level startup code
│ │ ├── boot.S # RISC-V entry point
│ │ └── linker.ld # Memory layout
│ │
│ ├── drivers/ # Hardware drivers (HAL)
│ │ ├── gpio.c/h # GPIO control
│ │ ├── i2c.c/h # I²C peripheral
│ │ └── console.c/h # USB Serial/JTAG console
│ │
│ ├── devices/ # External device drivers
│ │ └── ssd1306.c/h # OLED display driver
│ │
│ └── assets/ # Fonts, images, etc.
│ └── font5x7.h # 5x7 character font
│
├── bootloader/ # Custom bootloader (WIP)
├── rust/ # Rust implementation (alternative to C)
│ ├── src/ # Rust source files
│ │ ├── main.rs # Entry point
│ │ ├── console.rs # USB Serial/JTAG driver
│ │ ├── gpio.rs # GPIO driver
│ │ ├── i2c.rs # I2C driver
│ │ ├── ssd1306.rs # OLED driver
│ │ ├── shell.rs # Command shell
│ │ └── font5x7.rs # Font data
│ ├── Cargo.toml # Rust project config
│ └── README.md # Rust-specific instructions
│
├── build/ # Build artifacts (generated)
└── README.md # This file
- ESP-IDF v5.0 or later
- XIAO ESP32-C3 board or compatible
- SSD1306 OLED display (optional)
| OLED Pin | ESP32-C3 Pin | Label |
|---|---|---|
| VCC | 3V3 or 5V | Power |
| GND | GND | Ground |
| SCL | GPIO7 | D5 |
| SDA | GPIO6 | D4 |
# Set up ESP-IDF environment
. $HOME/esp/esp-idf/export.sh
# Build
idf.py build
# Flash and monitor
idf.py flash monitor# Navigate to rust directory
cd rust
# Build and flash
cargo run --releaseSee rust/README.md for detailed Rust setup instructions.
#include "ssd1306.h"
// Initialize display
ssd1306_config_t config = {
.i2c_addr = SSD1306_I2C_ADDR_DEFAULT,
.scl_pin = 7,
.sda_pin = 6
};
ssd1306_init(&config);
// Draw text
ssd1306_clear();
ssd1306_draw_string(0, 0, "Hello World!");
ssd1306_display();ssd1306_init()- Initialize displayssd1306_clear()- Clear bufferssd1306_display()- Update screenssd1306_set_pixel()- Set individual pixelssd1306_draw_char()- Draw characterssd1306_draw_string()- Draw text stringssd1306_fill_rect()- Draw filled rectanglessd1306_set_contrast()- Adjust brightnessssd1306_display_on()- Turn on/offssd1306_invert_display()- Invert colors
The shell accepts input from the USB Serial console and displays output on the OLED:
#include "shell.h"
// Initialize the shell
shell_init();
// In your main loop, process incoming serial characters
while (1) {
char c = console_getchar(); // Read from USB Serial
if (c != 0) {
shell_process_char(c); // Process and display on OLED
}
}Shell Features:
- Command history
- Line editing (backspace support)
- Echo commands to both serial console and OLED display
- Extensible command system
The ESP32-C3 uses a modified Harvard architecture where the same physical memory (SRAM) can be accessed through different address ranges. This document explains why and how this works.
| Address Range | Size | Purpose | Bus Type |
|---|---|---|---|
| 0x3FC80000 - 0x3FCDF000 | 400KB | SRAM (Data Access) | Data Bus |
| 0x4037C000 - 0x403DFFFF | 400KB | SRAM (Instruction Access) | Instruction Bus |
| 0x42000000 - 0x427FFFFF | 4MB | Flash Memory | Flash Bus |
| 0x60000000 - 0x600D0FFF | ~832KB | Peripherals | Peripheral Bus |
From the datasheet: "The memory space with gray background is not available for use."
This means addresses like:
- 0x3FCE0000 and above are NOT accessible even though they're in the address space
- Always check the datasheet to verify accessible ranges!
The ESP32-C3 diagram shows two arrows pointing to the SRAM block:
Address Range 1: 0x3FC80000 - 0x3FCDF000 ──┐
├─→ [SAME PHYSICAL SRAM]
Address Range 2: 0x4037C000 - 0x403DFFFF ──┘
The same physical memory byte can be accessed through different addresses:
Physical SRAM Byte #0:
├─ Data Access: 0x3FC80000
└─ Instruction Access: 0x4037C000
↓
SAME physical byte!
CPU
|
Single Bus
|
┌──┴──┐
│ │
Code Data
- One bus for everything
- CPU can't fetch instructions and read/write data simultaneously
- Simpler, but slower
CPU
/ \
/ \
I-Bus D-Bus
| |
Code Data
- Separate buses for instructions and data
- CPU can fetch next instruction WHILE reading/writing data
- More complex, but faster (parallelism)
For boot.S:
# Stack pointer should use DATA BUS address
lui sp, 0x3FCDF # 0x3FCDF000 (data bus)For linker.ld:
MEMORY {
dram : ORIGIN = 0x3FC80000, LENGTH = 0x5F000
}
SECTIONS {
.data : { *(.data*) } > dram
.bss : { *(.bss*) } > dram
}You'd use the instruction bus address (0x4037C000) when:
-
Executing code from RAM (for speed)
// Copy frequently-used function to RAM void fast_function(void) __attribute__((section(".iram")));
-
Self-modifying code (rare, advanced)
-
Tight loops that need maximum performance
For now: Just use the data bus addresses (0x3FC80000). Instruction bus is an optimization for later.
Address Space (32-bit = 4GB total)
0x00000000
↓
[Reserved/ROM]
↓
0x3FC80000 ┌─────────────────┐
│ │
│ SRAM (Data) │ ← Use this for stack, .data, .bss
│ 400 KB │
0x3FCDF000 ├─────────────────┤
│ │
│ [Inaccessible] │ ← Gray blocks in datasheet
│ │
0x3FEF0000 └─────────────────┘
0x4037C000 ┌─────────────────┐
│ │
│ SRAM (Code) │ ← Same physical SRAM!
│ 400 KB │
│ │
0x403DF000 └─────────────────┘
0x42000000 ┌─────────────────┐
│ │
│ Flash Memory │ ← Your code lives here
│ 4 MB │
│ │
0x427FFFFF └─────────────────┘
0x60000000 ┌─────────────────┐
│ │
│ Peripherals │ ← UART, GPIO, I2C, etc.
│ (Memory-mapped)│
│ │
0x600D0FFF └─────────────────┘
lui sp, 0x3FCE0 # CRASH! This is gray/inaccessiblelui sp, 0x3FCDF # Good! Within accessible range// Don't put instruction pointer in data bus address
void (*func_ptr)(void) = 0x3FC80000; // Wrong!// Stack in data bus
uint32_t stack_var = 0x3FCDF000; // Correct!- Two addresses, same physical memory - Harvard architecture feature
- Use 0x3FC80000 range for stack, variables (data bus)
- Use 0x4037C000 range only for executing code from RAM (advanced)
- Gray blocks in datasheet = inaccessible - always verify addresses!
- SRAM is 400KB from 0x3FC80000 to ~0x3FCDF000
Quick Reference:
- Stack pointer:
0x3FCDF000(top of SRAM, grows down) - SRAM start:
0x3FC80000 - SRAM size:
400KB (0x5F000 bytes) - Flash start:
0x42000000 - Peripherals:
0x60000000
Remember: The ESP32-C3 is Harvard architecture - separate buses for code and data access to the same physical memory. For basic bare-metal work, just use the data bus addresses (0x3FC80000 range).
- ESP32-C3 Technical Reference Manual - Chapter 3: Memory System
- RISC-V Privileged Specification - Memory Models
- Understanding Harvard vs Von Neumann Architecture
Good luck with your bare-metal OS! 🚀
- "LUI adds 000 to the end" (three hex zeros = 12 bits)
This project includes a custom bare-metal bootloader in the bootloader/ directory. While not yet fully integrated with ESP-IDF's build system, it demonstrates the principles of a minimal bootloader.
- bootloader/bootloader_start.S - Assembly entry point
- bootloader/bootloader_main.c - Bootloader logic
- bootloader/bootloader.ld - Bootloader linker script
-
ROM Bootloader (chip ROM)
- Validates and loads 2nd stage bootloader from flash offset 0x1000
- Jumps to bootloader entry point
-
Custom Bootloader (our code at 0x1000)
- Initializes stack
- Prints boot message via UART
- Validates application
- Jumps to application at 0x42000020
-
Application (our main code at 0x10000)
- Starts at
_startin main/boot.S - Initializes C runtime (copy .data, clear .bss)
- Calls
main()
- Starts at
The custom bootloader is created but not yet integrated. The project currently uses ESP-IDF's minimal bootloader for simplicity.
To fully integrate the custom bootloader, you would need to:
- Replace ESP-IDF's bootloader component
- Ensure proper flash layout (bootloader at 0x1000, app at 0x10000)
- Handle image verification and loading
- Configure CMake to build both bootloader and app separately
For learning purposes, you can examine the bootloader code to understand the boot process, then gradually replace ESP-IDF components as you need more control.