Booting NuttX on RK3588S
I wanted one thing from my Orange Pi 5 Pro: an
nsh> prompt. Linux already booted on it. How much
would it take to get NuttX there? Before writing board code, I needed
to understand what U-Boot left behind when it jumped into the kernel.
U-Boot and TF-A already did the firmware work. I wasn't going to reimplement it. The tricky bit was finding the exact handoff NuttX could rely on, especially when a bad memory attribute can kill the boot before the UART says a word.
What can I trust from another board?
I had worked on AM67A at Baykar, and Matteo Golin's Raspberry Pi 4B port was another useful reference. Both showed how the ARM64 startup fits together. Neither could tell me what addresses and CPU state this particular board would have. That part needed checking.
Who owns the machine before NuttX?
First, the boot chain. By the time NuttX gets control, DRAM is initialized, TF-A has run, and U-Boot has configured UART2's clock and pins. That is a lot less platform work than starting from a blank SoC. But what exactly happens at the last jump?
BootROM
→ DDR initialization / TPL
→ U-Boot SPL
→ TF-A BL31
→ U-Boot proper
→ NuttX
U-Boot already speaks the ordinary ARM64 Linux Image convention: the
DTB address goes in x0, then execution branches to the
image entry point. The first NuttX port doesn't even parse the DTB,
but booti still saves me from inventing a private
handoff. (Linux ARM64 boot protocol,
U-Boot handoff)
The downstream U-Boot cleanup code disables caches and the MMU on this path. On my board I observed an EL2 entry. I'd rather check those details than assume a generic ARM64 diagram describes the state of my machine.
Wait, where did the image go?
The
ARM64 Image header
turned out to be more interesting than it looked. Alongside the entry
stub, it carries a load offset and a relocatable-image flag. That
means booti may not leave the image where I loaded it.
I load nuttx.bin at 0x02000000. The NuttX
header sets the relocatable-image flag, so the
U-Boot relocation calculation
is:
ALIGN(0x02000000 - 0x00480000, 2 MiB) + 0x00480000
= 0x02080000
I loaded nuttx.bin at 0x02000000. U-Boot
moved it to 0x02080000. There's the answer to an
otherwise odd-looking CONFIG_RAM_START: it came from the
loader calculation, not a number I picked for convenience.
The first version uses identity mappings and exposes a conservative
512 MiB RAM window. That is the configured
CONFIG_RAM_SIZE, not an RK3588S hardware limit. I kept the initial map inside the available DRAM above the firmware
reservations; the port does not yet consume the device tree.
What state are we entering in?
The entry point had moved, and the CPU arrived at EL2. The common
ARM64 startup sets up an early stack, clears .bss,
prepares the EL2 state, and uses eret to reach EL1.
U-Boot booti
→ __start / real_start
→ arm64_earlyprintinit()
→ clear .bss
→ arm64_boot_el2_init()
→ eret to EL1
→ arm64_boot_el1_init()
→ arm64_chip_boot()
→ build the RK3588 mappings and enable the MMU
→ nx_start()
The EL1 setup installs the exception vector table and prepares FP/SIMD
and timer state. The RK3588-specific
arm64_chip_boot()
then creates the platform mappings, enables the MMU, selects the SMC
conduit for PSCI, and hands serial initialization to the normal 16550
driver before the common path enters nx_start().
How little platform support is enough?
I didn't need every peripheral to reach a shell. I needed the interrupt controller, timer, serial console, reset path, and enough memory mapped correctly. I started there, with a deliberately small map.
UART2 device-nGnRnE
GICD device-nGnRnE
GICR device-nGnRnE
DRAM normal memory
Getting the first byte out
Before the first UART byte, debugging is mostly an argument with a silent board. So the early print routine had to be almost embarrassingly small: no scheduler, no interrupts, no initialized driver. Just a register, a status bit, and a character.
U-Boot has already set up UART2's clock, pins, and 1.5 Mbaud rate.
NuttX is configured not to disturb them at the beginning, so
arm64_earlyprintinit() simply returns. (Orange Pi 5 Pro configuration)
arm64_lowputc()
expands to the essential part of a polling transmit path:
ldr x15, =CONSOLE_UART_BASE
1: ldr w2, [x15, #0x14]
tst w2, #(1 << 5)
b.eq 1b
str w0, [x15, #0x00]
ret
The common startup loop
loads each character into w0 before calling the
routine. x15 holds 0xfeb50000, the UART2 base
address exposed as the first configured 16550 instance. With four-byte
register spacing, the line-status register is at 0x14.
Bit 5 is THRE: the transmit holding register is empty and can accept
another byte.
1b loops back until THRE is set; the final store sends
the character. Once that worked, the board was no longer silent.
Everything after it could at least leave a clue.
Trying the boot path
The documented SD-card boot looks like this:
load mmc <dev>:1 0x02000000 /nuttx.bin
load mmc <dev>:1 ${fdt_addr_r} /boot/dtb/rockchip/rk3588s-orangepi-5-pro.dtb
booti 0x02000000 - ${fdt_addr_r}
A prompt is not the whole test
The prompt appeared. Nice. But a prompt doesn't tell me whether the
timer works, whether UART receives as well as transmits, or whether
reboot works. I checked both build configurations, serial I/O,
/proc/uptime and sleep, PSCI reboot, and
OSTest on the board.
Why I checked the memory map first
There's a reason I kept coming back to the memory map. On AM67A at Baykar, incorrect MPU attributes had caused failures that looked nothing like a clean “memory attributes are wrong” message. That experience stayed with me.
Device registers, SRAM, and DDR didn't all want the same treatment. Fixing those attributes and cacheability got the boot moving. So on RK3588S I wrote down the device and normal-memory regions early, before a silent boot could make me question everything else.
The Orange Pi was a different situation. Working Linux and U-Boot support already existed downstream; public board support included this U-Boot patch and Linux device tree. I applied board support for both and was adding NuttX on top of a known path, not bringing every layer up with it.
That is why RK3588S starts with UART and GIC as device-nGnRnE and DRAM as normal memory. It is a small map, not a complete description of the SoC. (memory map)
Where the port stands
I got the shell, and the initial hardware checks passed. That was the goal for this port, not the end of board support. DTB parsing, secondary cores, and most peripherals are still future work.
The port was merged in Apache NuttX PR #19489: RK3588 SoC support, the Orange Pi 5 Pro board, and the bring-up guide.