Bare-metal notes / Allwinner F133

Running code directly from SRAM.

How I power up the F133, enter FEL mode, build a small RISC-V program, place it at the correct address, and execute it without a conventional software runtime.

Allwinner F133 board used for bare-metal experiments
The F133 hardware I used while experimenting with FEL, UART, GPIO, startup code, and direct register access.

The Allwinner F133 is a small RISC-V system-on-chip with a much more involved startup process than a typical microcontroller. That makes it interesting: before writing useful application code, I first need to understand who controls the CPU after reset, where my program can live, and how execution reaches my first instruction.

For these experiments I use my F133 Bare-Metal Example. It is intentionally small. It contains a startup file, a linker script, a Makefile, and basic UART and GPIO code. Together, those files show the complete path from C source to a binary running from the F133 internal SRAM.

The main idea

Build the program for a known SRAM address, upload the raw bytes to that same address through FEL, and tell the CPU to begin execution there.

Powering up the board

I power the board through its intended regulated input, normally USB or the board's 5-volt input. The F133 itself uses several lower internal voltage rails, so I do not feed an arbitrary voltage directly into the chip or its GPIO pins. The development board or product PCB handles the regulators, reset timing, and clock source.

For serial debugging, I connect a 3.3-volt USB-to-UART adapter:

F133 GND  → UART adapter GND
F133 TX   → UART adapter RX
F133 RX   → UART adapter TX

TX and RX cross over, and both devices need a common ground. This is logic-level UART, not the higher-voltage electrical signaling used by traditional RS-232 ports.

What happens after reset?

When reset is released, the CPU does not begin at my main() function. It begins inside permanent code built into the SoC. This first-stage code is usually called the Boot ROM, or BROM.

Power or reset On-chip Boot ROM Enter FEL USB mode Upload binary to SRAM Jump to my startup code Set stack and call main()

The Boot ROM normally looks for something bootable in the available storage devices. For bare-metal development, I use another path already provided by the Boot ROM: FEL mode.

FEL mode

FEL is Allwinner's low-level USB recovery and loading protocol. It works before my own firmware starts. From an Ubuntu computer, sunxi-fel can communicate with the Boot ROM and read memory, write memory, or begin execution at an address I choose.

How FEL is entered depends on the board. Some boards have a FEL or BOOT button. On others, the boot-storage path must be made unavailable or a running firmware environment can reboot into the recovery state. Once the board is in FEL, I verify the USB connection with:

sudo sunxi-fel version

If the command reports the connected Allwinner device, I have a direct path from my computer to the F133 Boot ROM.

Installing the tools

I use Ubuntu and install the RISC-V toolchain together with the FEL utility:

sudo apt update

sudo apt install \
    make \
    gcc-riscv64-unknown-elf \
    binutils-riscv64-unknown-elf \
    gdb-multiarch \
    sunxi-tools

Then I clone the example project:

git clone https://github.com/irakligvaladze/F133-Baremetal-Example.git
cd F133-Baremetal-Example

The files that make C possible

A normal application receives a prepared execution environment. Bare-metal code does not. I have to provide the memory layout, first instruction, stack pointer, and peripheral initialization myself.

startup.sThe first instructions executed by the CPU.
linker.ldPlaces program sections at the intended SRAM address.
main.cThe C application entered after startup is complete.
MakefileCompiles, links, and creates the raw binary.
F133_registers.hDefines memory-mapped peripheral registers.
UART_hal.h / GPIO_hal.hSmall helpers for talking directly to hardware.

How the linker decides where code lives

Compiling a C file produces machine-code sections, but it does not by itself decide the final physical address. The linker script gives those sections an address in the F133 memory map.

ENTRY(_start)

MEMORY
{
    SRAM (rwx) : ORIGIN = 0x00024000, LENGTH = 16K
}

SECTIONS
{
    .text : {
        *(.text.start)
        *(.text*)
        *(.rodata*)
    } > SRAM

    .data : {
        *(.data*)
    } > SRAM

    .bss : {
        __bss_start = .;
        *(.bss*)
        *(COMMON)
        __bss_end = .;
    } > SRAM

    _stack_top = ORIGIN(SRAM) + LENGTH(SRAM);
}

ENTRY(_start) tells the linker which symbol is the program entry point. ORIGIN = 0x00024000 tells it where the program is expected to begin in SRAM. The same address must later be used by the FEL write and execute commands.

The linker also creates _stack_top at the upper end of the reserved memory. The stack starts there and grows downward while code and static data occupy the lower part of the region.

The first instruction: startup.s

C functions need a valid stack. That is why the CPU enters a small assembly routine before it enters main().

.section .text.start
.global _start

_start:
    la sp, _stack_top
    call main

hang:
    j hang

The first instruction loads the stack pointer with the address created by the linker. The next instruction calls main(). If main() returns, the CPU remains in a controlled infinite loop instead of running into unrelated memory.

A more complete startup routine also clears the .bss section so uninitialized global and static variables begin at zero. It can later grow to include trap vectors, cache setup, clocks, and other early hardware work.

Compiler options for a small RISC-V image

-march=rv64imac
-mabi=lp64
-mcmodel=medany
-ffreestanding
-nostartfiles
-nostdlib

These options build 64-bit RISC-V code using integer, multiply/divide, atomic, and compressed instructions. The freestanding options are especially important: they tell the compiler and linker not to expect a hosted runtime or automatically insert standard startup files and libraries.

From source code to gpio.bin

The project is built with:

make

The important outputs are:

gpio.elf

Contains sections, symbols, addresses, and debugging information.

gpio.bin

Contains only the raw bytes that will be copied into SRAM.

The ELF file remembers that _start belongs at 0x00024000. The raw BIN file does not carry that information, so I must supply the destination address when I upload it.

I can inspect the linked image before running it:

riscv64-unknown-elf-readelf -h gpio.elf
riscv64-unknown-elf-objdump -h gpio.elf
riscv64-unknown-elf-nm -n gpio.elf
riscv64-unknown-elf-size gpio.elf

The entry point and first code section should line up with the selected SRAM address.

Uploading and executing from RAM

First I copy the raw binary into SRAM:

sudo sunxi-fel write 0x00024000 gpio.bin

This does not start the program. It only copies the file's bytes into memory beginning at 0x00024000.

Then I transfer execution to the same address:

sudo sunxi-fel exec 0x00024000
gpio.bin SRAM at 0x00024000 _start Stack pointer main()

The application is now executing directly from internal SRAM. Because SRAM is volatile, it disappears after a reset or power cycle. During development that is useful: I can rebuild and upload repeatedly without writing permanent storage.

Talking directly to peripherals

UART, GPIO, timers, and other peripherals are controlled by memory-mapped registers. A register is simply a documented address where reads and writes have hardware meaning.

#define REGISTER(address) (*(volatile uint32_t *)(address))

REGISTER(GPIO_DATA_ADDRESS) |= (1U << pin);

The volatile keyword matters because the value is connected to hardware and may change independently of normal program flow. It also prevents the compiler from removing register accesses that appear unnecessary from a purely software point of view.

The example wraps those accesses in simple functions:

HAL_GPIO_Init(GPIOD, pin, GPIO_MODE_OUTPUT);
HAL_GPIO_SetPin(GPIOD, pin);
HAL_GPIO_ResetPin(GPIOD, pin);

The UART code follows the same idea: configure the UART registers, wait until the transmitter is ready, and write a byte to the transmit register. No driver sits between the application and the peripheral.

A detail that can cause confusing failures

FEL provides a way to load and start code, but it does not mean every peripheral is fully configured for my program. Before a UART or another hardware block works, I may still need to:

  • Enable its clock in the clock-control unit.
  • Release the peripheral from reset.
  • Select the correct pin-multiplexer function.
  • Configure pull resistors and drive strength.
  • Program the peripheral's own registers.

If an experiment works after a warm reboot but fails after a complete power cycle, it often means an earlier firmware stage left a clock or pin configuration enabled. A truly independent bare-metal program should initialize everything it relies on.

Common checks

FEL cannot find the board

Confirm that the board is really in FEL mode, the USB cable supports data, and the correct USB port is being used. I also run sunxi-fel with sudo unless I have installed an appropriate udev rule.

The program uploads but does nothing

Check that all three addresses agree:

Linker origin:      0x00024000
FEL write address:  0x00024000
FEL exec address:   0x00024000

UART output is unreadable or missing

Check the UART voltage, common ground, crossed TX/RX lines, selected pins, pin function, input clock, baud rate, and the terminal's 8-N-1 configuration.

The program grows and becomes unstable

Inspect the ELF section addresses and total size. Code, data, BSS, and stack must all fit inside the SRAM region reserved by the linker script.

The complete mental model

Write C and assembly Compile for RV64IMAC Link at 0x00024000 Create ELF and raw BIN Enter FEL mode Write BIN into SRAM Execute at 0x00024000 Set stack and call main() Control hardware registers directly

The key is consistency. The linker, the upload command, and the execution command must describe the same memory layout. Once they agree, the process is straightforward: the binary is copied into SRAM, the CPU jumps to its first instruction, startup code creates the minimum C environment, and the application takes control of the hardware.

View GitHub repository Back to projects