RISC-V user-space function calls calling conventions are clear to me. It's also clear that a0-a5
are used to pass arguments to kernel and a7
to store system call number before an ecall
. What I can't find is some documentation about assumptions I can do after execution of ecall
instruction.
The following lines are part of the output of
objdump -d -M no-aliases libc.so.6
in the <__open64_nocancel>
(glibc 2.35 on RISC-V Ubuntu 22, but the same thing happens on 2.37 and 2.39)
...
ada12: 861a c.mv a2,t1
ada14: 00000073 ecall
ada18: 77fd c.lui a5,0xfffff
ada1a: 02a7e963 bltu a5,a0,ada4c <__open64_nocancel+0x7e>
ada1e: 2501 c.addiw a0,0
ada20: 6722 c.ldsp a4,8(sp)
ada22: 000e3783 ld a5,0(t3)
...
As you can see the last instruction uses t3
as base address to access memory without being reinitialized after the ecall
. My question is: what can I safely assume from this?
Can caller-saved registers from user-space calling conventions be safely used after ecall
without saving and restoring the value across ecall
? I know a0-a1
are used to store return values from the system call, but what about a2-a7
? May I assume they are clobbered?
I was expecting that all temporary registers would be considered clobbered, but the given example makes me wonder about argument registers. Maybe that's different since they serve a specific purpose to the kernel.
Registers other than the return-value are unmodified by system-calls on Linux, except for a few special cases on some ISAs (like x86-64 syscall
itself clobbers RCX and R11).
The kernel system-call entry points save all user-space registers so ptrace
(debugging) and other things that want a struct of the user-space regs can just work on a process that's blocked in a system call.
The syscall(2)
man page (https://man7.org/linux/man-pages/man2/syscall.2.html) has a table of register usage, with a note at the bottom:
Note that these tables don't cover the entire calling convention - some architectures may indiscriminately clobber other registers not listed here.
As far as I know, there aren't any extra clobbers for Linux's RISC-V system-call calling convention, just the return value in a0
.
Linux system-call retvals are only ever 1 register wide; I don't think there are any that return 64-bit on a 32-bit machine, so a1
is never part of the return value the way it would be in user-space. System calls like __NR_lseek
only take 32-bit off_t
on 32-bit systems (at least on i386 where I tested); you need __NR__llseek
for off64_t
64-bit file positions. Instead of returning the new file position, it takes a pointer where it stores the result.
And system calls like gettimeofday
which deal with times take pointers, although time(2)
returns time_t
. But in the kernel ABI, time_t
is still 32-bit on 32-bit systems; new system-calls were added for 64-bit times. (Is there any way to get 64-bit time_t in 32-bit programs in Linux?)
On some ISAs including MIPS and SPARC, the kernel ABI for pipe(2)
returns the two fds by value in two return-value registers, but RISC-V is not one of those ISAs. It only ever uses a single return-value register from system calls, as documented by syscall(2)
.
(Thanks to @MarcoBonelli for pointing out this bit of documentation)
Linux on some ISAs returns error status out-of-band, in a second (or third) register, like Alpha, MIPS, and PowerPC, instead of encoding it as -errno
in the main return value with values unsigned-above -4096
being negated errno codes like -EFAULT
. But RISC-V is not one of those ISAs either, so only ever a single register a0
is written as a system call return value. (Fun fact: FreeBSD / macOS use a separate error status on x86, in the carry flag, unlike Linux.)