I brought the initial RK3588S port to NSH on an Orange Pi 5 Pro. U-Boot and TF-A still do the firmware work; NuttX starts at the handoff described in the published board documentation.
The work was merged in Apache NuttX PR #19489 as three commits: RK3588 SoC support, the Orange Pi 5 Pro board and configurations, and the bring-up guide.
The port was also featured in a short LinkedIn write-up after the initial boot.
Why this board
I already had an Orange Pi 5 Pro and expected to use it, so it was a practical target. I started from the AM67A tree I had worked on at Baykar and Matteo Golin's initial Raspberry Pi 4B port, which had also been my reference for that earlier bring-up.
Where the port starts
The firmware still owns DRAM training, boot media, and the UART2 clock and pin setup. The path before NuttX is:
BootROM
→ DDR initialization / TPL
→ U-Boot SPL
→ TF-A BL31
→ U-Boot proper
→ NuttX
This follows the Linux ARM64 boot protocol rather than a board-specific calling convention. The downstream U-Boot path passes the DTB address in x0 and branches to the Image entry point. The first port ignores the DTB, but keeping the standard handoff lets booti load NuttX without a custom command.
Before that branch, U-Boot calls cleanup_before_linux(). That path disables the instruction cache; its dcache_disable() clears the data-cache and MMU enable bits, flushes cached data, and invalidates the TLBs. The handoff I observed entered NuttX at EL2.
Image placement and memory
NuttX begins with a Linux-compatible 64-byte ARM64 Image header. Its first two words are executable instructions: the second branches over the header into real_start. The remaining fields tell booti the image size, flags, and a 0x480000 load offset.
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
booti then uses memmove() to place the image there and jumps to that relocated entry point. This is also why CONFIG_RAM_START is 0x02080000: the address follows from the header and loader contract.
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.
From EL2 to the NuttX scheduler
The common ARM64 entry code masks exceptions, gives the primary core an idle stack, runs the early-print hook, and clears .bss. It then inspects the current exception level. On this board the EL2 path initializes the state that EL1 will inherit, including AArch64 execution, timer access, and the GICv3 system-register interface. eret performs the actual drop to 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().
Enough platform support to reach NSH
The initial support adds GICv3 interrupt support, the Arm generic timer, polling UART2, PSCI reset, and the board code needed to reach NSH. The initial MMU table is deliberately small:
UART2 device-nGnRnE
GICD device-nGnRnE
GICR device-nGnRnE
DRAM normal memory
The first byte: arm64_lowputc()
The early UART code is intentionally small. U-Boot has already configured the UART2 clock, pins, and 1.5 Mbaud line rate, and the NuttX configuration suppresses the generic 16550 driver's initial reconfiguration. Consequently arm64_earlyprintinit() is just ret.
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.
b.eq 1b is an assembler-local backward branch to the numeric label 1; it spins until THRE becomes set. The final store writes the character to the transmit register at offset zero. There are no interrupts, locks, or driver state to depend on, so the same routine can print markers while the rest of the ARM64 startup path is still assembly. Once normal serial initialization runs, the generic 16550 driver takes over.
The boot command
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}
What I tested
I tested both the Make and CMake/Ninja configurations on a 4 GiB Orange Pi 5 Pro. UART2 transmit and receive run at 1.5 Mbaud; the generic timer advances /proc/uptime and supports sleep; PSCI handles reboot; and OSTest completes successfully. The commands, observed handoff, and those hardware checks are recorded in the merged board guide.
What carried over from AM67A
This port moved quickly because I had done a harder version of the same job on AM67A at Baykar a year earlier. There, we were building the software stack around a new board at the same time. The NuttX, bootloader, and Linux sides were moving in parallel, and we had not settled every detail of the handoff before early bring-up started.
That showed up in the memory map. UART and other register space, SRAM, and DDR do not want the same MPU attributes. Some of those regions were initially described incorrectly, and the resulting failure did not point back to the map. Once we separated device memory from normal memory and fixed cacheability, the boot path started behaving. The public AM67A tree shows the region types we ended up with.
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.
This time I wrote the memory map down before chasing the first byte: UART and GIC as device-nGnRnE, DRAM as normal memory. I also checked exactly what U-Boot would leave enabled at the handoff. It was the same checklist as AM67A, but this time I did not have to discover it after the first failure.
What the first port leaves out
This is an initial bring-up, not full board support. It does not provide most board peripherals, and the first version does not parse the DTB or start secondary cores.