Skip to content

arch/riscv: Fix RISC-V backtrace#19439

Open
hitHuang wants to merge 3 commits into
apache:masterfrom
hitHuang:fix/riscv_backtrace
Open

arch/riscv: Fix RISC-V backtrace#19439
hitHuang wants to merge 3 commits into
apache:masterfrom
hitHuang:fix/riscv_backtrace

Conversation

@hitHuang

Copy link
Copy Markdown
Contributor

Summary

The RISC-V implementation of up_backtrace() (arch/risc-v/src/common/riscv_backtrace.c) has several correctness issues under CONFIG_BUILD_KERNEL. This PR fixes three independent, compounding problems found while debugging dumpstack on rv-virt:knsh_romfs.

Problem 1: xcp.ustkptr is stale and no longer maintained

ustkptr was introduced in 77e90d9c875 ("RISC-V: Include support for kernel stack"). The intent was: on syscall entry, update ustkptr to the user-mode SP; on syscall exit, reset it back to NULL.

76e5204a806 ("risc-v/backtrace: correct stack pointer if enable ARCH_KERNEL_STACK backtrace") then started consuming ustkptr in up_backtrace(), using *(ustkptr + 1) as the starting frame pointer for a task that is currently inside a syscall (running on the kernel stack instead of the user stack).

However, e6973c764cd ("riscv/syscall: Optimize user service call performance") appears to have dropped the maintenance of ustkptr on the syscall entry/exit path. As a result, ustkptr is only ever set once, at task creation time, and never updated afterwards — the != NULL check in up_backtrace() is effectively dead, and the value it reads is always the stale "initial" one.

This alone causes dumpstack to return zero frames for any user-mode task, because ustkptr + 1 no longer points anywhere near a valid frame — it's out of range of the user stack entirely.

Separately, I have to admit I don't fully understand what *(ustkptr + 1) was supposed to compute in the first place — it doesn't correspond to any ABI-defined frame pointer slot I can find. If I'm missing something about how 76e5204a806 was verified at the time, I'd appreciate clarification; otherwise this looks like it was never exercised against a real out-of-range case.

Fix: instead of relying on ustkptr, use the existing TCB_FLAG_SYSCALL flag together with xcp.sregs[REG_FP] / xcp.sregs[REG_EPC] (the syscall entry's saved register context, already maintained elsewhere) to detect "this task is currently inside a syscall" and get its correct frame pointer/PC.

Problem 2: cross-tcb backtrace reads across the wrong address environment

Independently of problem 1, when dumpstack targets a different task's TCB (CONFIG_ARCH_ADDRENV, e.g. knsh_romfs), backtrace()'s fp/ra chain lives in the target task's address space, while buffer (where results are written) belongs to the caller. The RISC-V backtrace() never switched address environments at all, so the fp-chain walk dereferenced the target task's stack addresses using the caller's page tables — reading whatever physical page happens to be mapped at that virtual address in the caller's own environment, not the target's actual stack contents.

Fix: backtrace() now takes an optional addrenv parameter and wraps only the two frame-chain reads (ra = *(fp - 1), next_fp = *(fp - 2)) in addrenv_select()/addrenv_restore(). The write into buffer[i++] = ra stays outside that window, since buffer is the caller's own memory and must be written using the caller's own address environment — switching addrenv around that write as well would corrupt/lose the result (confirmed by hardware testing; an earlier, wider-scoped attempt that included the buffer write inside the switch produced all-zero backtraces).

Problem 3: leaf syscall wrapper functions don't save ra on the stack

Even with problems 1 and 2 fixed, backtraces crossing a syscall boundary can still be broken. sys_call0()..sys_call6() in arch/risc-v/include/syscall.h are leaf functions as far as the compiler can tell (they never call another C function themselves), so even with CONFIG_FRAME_POINTER (-fno-omit-frame-pointer), the compiler only bothers to save s0 (the frame pointer) around the ecall — it never allocates or writes a stack slot for ra, because as far as the compiler's leaf-function analysis is concerned, ra is never clobbered inside the function body and ret (which is jalr x0, 0(x1)) works correctly straight out of the register.

The problem is that backtrace()'s fp-chain walk assumes the standard prologue layout (ra at fp - 1, saved s0 at fp - 2). In these leaf wrappers, s0 ends up at fp - 1 instead (since there's no ra slot to begin with), and fp - 2 is simply never written — uninitialized stack garbage. Disassembly of waitpid() before the fix:

c000ba08 <waitpid>:
c000ba08:       1141                    addi    sp,sp,-16
c000ba0a:       c622                    sw      s0,12(sp)
c000ba0c:       872a                    mv      a4,a0
c000ba0e:       0800                    addi    s0,sp,16
c000ba10:       87ae                    mv      a5,a1
c000ba12:       86b2                    mv      a3,a2
c000ba14:       02300513                li      a0,35
c000ba18:       85ba                    mv      a1,a4
c000ba1a:       863e                    mv      a2,a5
c000ba1c:       00000073                ecall
c000ba20:       0001                    nop
c000ba22:       4432                    lw      s0,12(sp)
c000ba24:       0141                    addi    sp,sp,16
c000ba26:       8082                    ret

Note s0 is saved at sp+12 (i.e. fp - 1), and ra is never spilled at all. backtrace() reading *(fp - 1) here gets the caller's old s0, not a return address; *(fp - 2) gets whatever garbage was left on the stack from a previous call.

Fix: force ra to be spilled/reloaded around the ecall by adding it to the clobber list, gated behind a new RISCV_ECALL_CLOBBERS macro:

#if defined(CONFIG_FRAME_POINTER) && defined(CONFIG_SCHED_BACKTRACE)
#  define RISCV_ECALL_CLOBBERS "memory", "ra"
#else
#  define RISCV_ECALL_CLOBBERS "memory"
#endif

This only takes effect when both CONFIG_FRAME_POINTER (the fp-chain is structurally meaningful at all) and CONFIG_SCHED_BACKTRACE (the only consumer of up_backtrace()'s fp-chain walk) are enabled — otherwise there is no fp-chain consumer to fix up for, and the extra spill/reload would be pure overhead. Disassembly of waitpid() after the fix:

c000ba9c <waitpid>:
c000ba9c:       1141                    addi    sp,sp,-16
c000ba9e:       c422                    sw      s0,8(sp)
c000baa0:       c606                    sw      ra,12(sp)
c000baa2:       0800                    addi    s0,sp,16
c000baa4:       872a                    mv      a4,a0
c000baa6:       87ae                    mv      a5,a1
c000baa8:       86b2                    mv      a3,a2
c000baaa:       02300513                li      a0,35
c000baae:       85ba                    mv      a1,a4
c000bab0:       863e                    mv      a2,a5
c000bab2:       00000073                ecall
c000bab6:       0001                    nop
c000bab8:       40b2                    lw      ra,12(sp)
c000baba:       4422                    lw      s0,8(sp)
c000babc:       0141                    addi    sp,sp,16
c000babe:       8082                    ret

ra and s0 are now both correctly saved at fp - 1/fp - 2, matching what backtrace() expects. This adds two instructions and one stack word of overhead to each sys_callN() wrapper, but only in configurations where both gating options above are enabled.

Known follow-up (not part of this PR)

While debugging this, I found that AArch64's up_backtrace() under CONFIG_BUILD_KERNEL appears to have a similar class of problem. I'd like to hold off on that until this PR is reviewed/confirmed, and will submit it as a separate follow-up PR.

Impact

  • Affects RISC-V only, and only when CONFIG_ARCH_KERNEL_STACK and/or CONFIG_ARCH_ADDRENV are enabled (i.e. CONFIG_BUILD_KERNEL-style configurations such as knsh_romfs/knsh64_romfs). No change in behavior for FLAT/PROTECTED builds that don't use these options.
  • The syscall.h clobber change only adds instructions when both CONFIG_FRAME_POINTER and CONFIG_SCHED_BACKTRACE are enabled; otherwise it's a no-op (RISCV_ECALL_CLOBBERS expands to the original "memory").
  • No public API/ABI changes. backtrace() (static, file-local) gained an extra parameter, but up_backtrace()'s external signature is unchanged.
  • Purely a correctness fix for dumpstack/sched_backtrace() output; does not change scheduling, syscall semantics, or any other runtime behavior.

Testing

Tested on rv-virt:knsh_romfs (QEMU), with CONFIG_SCHED_BACKTRACE, CONFIG_FRAME_POINTER and CONFIG_SYSTEM_DUMPSTACK manually enabled. Also verified okay on nsh and knsh64_romfs.

(Not focusing here on why the printed timestamp is an obviously bogus value — that looks like a separate, pre-existing issue unrelated to this fix.)

Before fix 1

For kernel threads, dumpstack works fine. For a user-mode application, dumpstack produces no output at all:

NuttShell (NSH) NuttX-13.0.0
nsh> uname -a
NuttX 13.0.0 20579ab531-dirty Jul 15 2026 11:05:29 risc-v rv-virt
nsh> ps
  TID   PID  PPID PRI POLICY   TYPE    NPX STATE    EVENT     SIGMASK            STACK COMMAND
    0     0     0   0 FIFO     Kthread   - Ready              0000000000000000 0003040 Idle_Task
    1     0     0 100 RR       Kthread   - Waiting  Semaphore 0000000000000000 0001968 lpwork 0x80600010 0x80600060
    3     3     0 100 RR       Task      - Running            0000000000000000 0003008 /system/bin/init
nsh> dumpstack 0
[17179869184000.001000] sched_dumpstack: backtrace| 0: 0x8020bd02 0x806086f0 0x8020119a 0x8020004a
nsh> dumpstack 1
[2357937045504000.001000] sched_dumpstack: backtrace| 1: 0x8020b8c4 0x802170c6 0x8020844e 0x80208472 0x80202e16 0x80202798
nsh> dumpstack 3
nsh> dumpstack 3

dumpstacking itself also produces no output:

NuttShell (NSH) NuttX-13.0.0
nsh> ps
  TID   PID  PPID PRI POLICY   TYPE    NPX STATE    EVENT     SIGMASK            STACK COMMAND
    0     0     0   0 FIFO     Kthread   - Ready              0000000000000000 0003040 Idle_Task
    1     0     0 100 RR       Kthread   - Waiting  Semaphore 0000000000000000 0001968 lpwork 0x80600010 0x80600060
    3     3     0 100 RR       Task      - Running            0000000000000000 0003008 /system/bin/init
nsh> dumpstack 0 10
[1473173782528000.001000] sched_dumpstack: backtrace| 0: 0x8020bd02 0x806086f0 0x8020119a 0x8020004a
[1511828488192000.001000] sched_dumpstack: backtrace| 1: 0x8020b8c4 0x802170c6 0x8020844e 0x80208472 0x80202e16 0x80202798

After fix 1

User-mode init (TID 3) now produces some output, but it's still incomplete — this is expected, since problem 2 (addrenv) hasn't been fixed yet:

NuttShell (NSH) NuttX-13.0.0
nsh> uname -a
NuttX 13.0.0 7c92d4fb7b-dirty Jul 15 2026 11:22:33 risc-v rv-virt
nsh> ps
  TID   PID  PPID PRI POLICY   TYPE    NPX STATE    EVENT     SIGMASK            STACK COMMAND
    0     0     0   0 FIFO     Kthread   - Ready              0000000000000000 0003040 Idle_Task
    1     0     0 100 RR       Kthread   - Waiting  Semaphore 0000000000000000 0001968 lpwork 0x80600010 0x80600060
    3     3     0 100 RR       Task      - Running            0000000000000000 0003008 /system/bin/init
nsh> dumpstack 0 10
[3143916060672000.001000] sched_dumpstack: backtrace| 0: 0x8020bd02 0x806086f0 0x8020119a 0x8020004a
[3156800962560000.001000] sched_dumpstack: backtrace| 1: 0x8020b8c4 0x802170c6 0x8020844e 0x80208472 0x80202e16 0x80202798
[3156800962560000.001000] sched_dumpstack: backtrace| 3: 0xc000ba20
[3173980831744000.001000] sched_dumpstack: backtrace| 4: 0xc000271a 0xc0803fd0

After fix 2

NuttShell (NSH) NuttX-13.0.0
nsh> uname -a
NuttX 13.0.0 ac32077475-dirty Jul 15 2026 11:26:42 risc-v rv-virt
nsh> ps
  TID   PID  PPID PRI POLICY   TYPE    NPX STATE    EVENT     SIGMASK            STACK COMMAND
    0     0     0   0 FIFO     Kthread   - Ready              0000000000000000 0003040 Idle_Task
    1     0     0 100 RR       Kthread   - Waiting  Semaphore 0000000000000000 0001968 lpwork 0x80600010 0x80600060
    3     3     0 100 RR       Task      - Running            0000000000000000 0003008 /system/bin/init
nsh> dumpstack 0 10
[1387274436608000.001000] sched_dumpstack: backtrace| 0: 0x8020bd02 0x806086f0 0x8020119a 0x8020004a
[1511828488192000.001000] sched_dumpstack: backtrace| 1: 0x8020b8c4 0x802170c6 0x8020844e 0x80208472 0x80202e16 0x80202798
[1550483193856000.001000] sched_dumpstack: backtrace| 3: 0xc000ba20 0xc08029f0 0xc0002b52 0xc0003aa0 0xc0003b40 0xc00025a2 0xc00023ca 0xc0000b4a
[1580547964928000.001000] sched_dumpstack: backtrace| 3: 0xc0000b08
[1597727834112000.001000] sched_dumpstack: backtrace| 4: 0xc000271a 0xc0803fd0

addr2line on the TID 3 (init) addresses — noticeably better than before, but still broken partway through because of problem 3:

$ riscv64-unknown-elf-addr2line -f -e init 0xc000ba20 0xc08029f0 0xc0002b52 0xc0003aa0 0xc0003b40 0xc00025a2 0xc00023ca 0xc0000b4a 0xc0000b08
sys_call3
/home/huang/workspace/nuttx-fork/nuttx/include/arch/syscall.h:241
??
??:0
nsh_execute
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:599
nsh_parse_command
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:2911
nsh_parse
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:3116
nsh_session
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_session.c:249
nsh_consolemain
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_consolemain.c:81
main
/home/huang/workspace/nuttx-fork/apps/system/nsh/nsh_main.c:82
_start
/home/huang/workspace/nuttx-fork/nuttx/arch/risc-v/src/common/crt0.c:201

And on TID 4 (dumpstack itself):

$ riscv64-unknown-elf-addr2line -f -e dumpstack 0xc000271a 0xc0803fd0
sys_call4
/home/huang/workspace/nuttx-fork/nuttx/include/arch/syscall.h:271
??
??:0

After fix 3 (all three fixes applied)

NuttShell (NSH) NuttX-13.0.0
nsh> uname -a
NuttX 13.0.0 358a66b7c1-dirty Jul 15 2026 11:49:15 risc-v rv-virt
nsh> ps
  TID   PID  PPID PRI POLICY   TYPE    NPX STATE    EVENT     SIGMASK            STACK COMMAND
    0     0     0   0 FIFO     Kthread   - Ready              0000000000000000 0003040 Idle_Task
    1     0     0 100 RR       Kthread   - Waiting  Semaphore 0000000000000000 0001968 lpwork 0x80600010 0x80600060
    3     3     0 100 RR       Task      - Running            0000000000000000 0003008 /system/bin/init
nsh> dumpstack 0 10
[4247722655744000.001000] sched_dumpstack: backtrace| 0: 0x8020bd02 0x806086f0 0x8020119a 0x8020004a
[4290672328704000.001000] sched_dumpstack: backtrace| 1: 0x8020b8c4 0x802170c6 0x8020844e 0x80208472 0x80202e16 0x80202798
[25769803776000.001000] sched_dumpstack: backtrace| 3: 0xc000bab6 0xc0005312 0xc0002b76 0xc0003ac4 0xc0003b64 0xc00025c6 0xc00023ee 0xc0000b4a
[60129542144000.001000] sched_dumpstack: backtrace| 3: 0xc0000b08
[73014444032000.001000] sched_dumpstack: backtrace| 4: 0xc0002738 0xc0000b44 0xc0000aca 0xc0000a8c

This time we finally get a full, correct backtrace. addr2line on TID 3 (init):

$ riscv64-unknown-elf-addr2line -f -e init 0xc000bab6 0xc0005312 0xc0002b76 0xc0003ac4 0xc0003b64 0xc00025c6 0xc00023ee 0xc0000b4a 0xc0000b08
sys_call3
/home/huang/workspace/nuttx-fork/nuttx/include/arch/syscall.h:258
nsh_fileapp
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_fileapps.c:307
nsh_execute
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:599
nsh_parse_command
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:2911
nsh_parse
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_parse.c:3116
nsh_session
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_session.c:249
nsh_consolemain
/home/huang/workspace/nuttx-fork/apps/nshlib/nsh_consolemain.c:81
main
/home/huang/workspace/nuttx-fork/apps/system/nsh/nsh_main.c:82
_start
/home/huang/workspace/nuttx-fork/nuttx/arch/risc-v/src/common/crt0.c:201

And on TID 4 (dumpstack itself), also complete now:

$ riscv64-unknown-elf-addr2line -f -e dumpstack 0xc0002738 0xc0000b44 0xc0000aca 0xc0000a8c
sys_call4
/home/huang/workspace/nuttx-fork/nuttx/include/arch/syscall.h:288
sched_dumpstack
/home/huang/workspace/nuttx-fork/nuttx/libs/libc/sched/sched_dumpstack.c:71
main
/home/huang/workspace/nuttx-fork/apps/system/dumpstack/dumpstack.c:77 (discriminator 1)
_start
/home/huang/workspace/nuttx-fork/nuttx/arch/risc-v/src/common/crt0.c:201

Also re-verified okay on nsh and knsh64_romfs.

hitHuang added 3 commits July 15, 2026 12:12
xcp.ustkptr is only assigned once in up_initial_state() when a task is
created and, since the syscall fast path was optimized in
e6973c7, is never updated afterwards. up_backtrace() used
"ustkptr != NULL" to decide whether a task is currently blocked
inside a syscall, and *(ustkptr + 1) as the frame pointer to resume
tracing from. Since ustkptr is now a dead value fixed at task
creation time, the check is always true and the "frame pointer" it
derives points at stale data near the initial stack top, unrelated
to where the task is actually blocked.

Use rtcb->flags & TCB_FLAG_SYSCALL together with xcp.sregs, which
dispatch_syscall() maintains precisely across the entire syscall
execution window (including any nested context switches caused by
blocking), to locate the frame pointer/return address saved at
syscall entry instead.

Signed-off-by: liang.huang <[email protected]>
up_backtrace() on a different tcb dereferences that task's own stack
to walk its frame pointer chain, but never selected that task's
address environment first. Under CONFIG_ARCH_ADDRENV each task's
stack lives behind its own page tables mapped at the same fixed
virtual range, so reading tcb->stack_base_ptr without first switching
to that task's addrenv reads whatever physical page the caller's own
mapping of that virtual range happens to point to, not the target
task's real stack. A cross-tid dumpstack of a task running in a
different address environment therefore returns garbage or an
all-zero backtrace instead of failing cleanly or resolving the real
call chain.

Add an addrenv parameter to backtrace() and select the target tcb's
addrenv_own only around the two dereferences that read the target's
saved ra/fp (ra = *(fp - 1), next_fp = *(fp - 2)), then restore the
caller's own addrenv before writing the result into buffer. buffer
belongs to the caller, not the target tcb, so it must always be
written back in the caller's own address environment; writing it
while the target's addrenv is still selected would corrupt the
access instead of fixing it.

Signed-off-by: liang.huang <[email protected]>
sys_callN() wraps a bare ecall and calls no other function, so the
compiler treats it as a leaf function: with frame pointers enabled,
it only needs to spill the caller's s0, which it places in what
up_backtrace()'s fp-chain walk assumes is ra's stack slot, while the
real ra slot is never written. sched_backtrace() then misreads that
slot as the return address for this frame, either resolving to a
bogus symbol or, if the adjacent garbage happens to look
out-of-range, terminating the backtrace early.

Add "ra" to the ecall clobber list so the compiler spills/reloads ra
around the ecall like a normal call site, keeping ra and the saved
s0 in their expected slots. Gate this on
CONFIG_FRAME_POINTER && CONFIG_SCHED_BACKTRACE, the only combination
where up_backtrace()'s fp-chain walk is both valid (FRAME_POINTER)
and actually exercised (SCHED_BACKTRACE); other configurations keep
the original "memory"-only clobber and pay no extra cost.

This only fixes the syscall boundary. Leaf functions that do not
cross a syscall (e.g. up_idle()) can still lose their ra slot the
same way and are not addressed here.

Signed-off-by: liang.huang <[email protected]>
@github-actions github-actions Bot added Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Size: M The size of the change in this PR is medium labels Jul 15, 2026
@github-actions

Copy link
Copy Markdown

MemBrowse Memory Report

No memory changes detected for:

@linguini1 linguini1 changed the title Fix/riscv backtrace arch/riscv: Fix RISC-V backtrace Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Arch: risc-v Issues related to the RISC-V (32-bit or 64-bit) architecture Size: M The size of the change in this PR is medium

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants