进程模型4-系统调用
内核版本 | 架构 | 作者 | GitHub | CSDN |
---|---|---|---|---|
Linux-4.1.5 | armv7-A | Lux1206 |
在介绍系统调用前先考虑如下问题:
- 系统调用是什么?
- 为什么需要系统调用?
- 从用户空间如何进入内核?
- 从内核如何返回用户空间?
- 用户空间的上下文是什么?
- 用户空间的上下文保存在哪里?
Linux 中将整个系统分为了用户空间和内核空间,内核拥有绝对的权限负责进行系统调度和硬件操作(USB、内存设备等),而用户空间无法直接访问到硬件,内核也不会直接将内核管理下的东西向应用层直接暴露。例如读取文件,看似在用户空间使用 read 很方便的就读取到了需要的数据,和普通函数调用没有什么区别,但是系统在真正执行时是完全不同的,调用耗时有几十倍差距(软中断方式)。
用户空间和内核的交互必须通过系统调用的方式来进行,例如 read 调用后通过 glibc 中程序的层层调用,最终会触发软中断(x86 是 0x80 中断,ARM是svc中断),携带系统调用号进入** swi** 异常处理函数,也就进入了内核态,在内核态保存用户态的上下文用于之后返回,再根据系统调用号找到 read 对应的系统调用函数,并执行完成,最后使用保存的用户态上下文,返回用户空间。
EABI和OABI
ABI 是应用程序二进制接口,每个操作系统都会为运行在该系统下的应用程序提供应用程序二进制接口(Application Binary Interface,ABI)。ABI包含了应用程序在这个系统下运行时必须遵守的编程约定,对于 arm 的函数调用而言,它定义了函数调用约定,系统调用形式以及目标文件的格式等。
在 arm 平台架构中,存在两种不同的 ABI 形式,OABI 和 EABI,OABI 中是旧的 ABI,而 EABI 是基于 OABI 上的改进,OABI 和 EABI 的区别主要在于浮点的处理和系统调用,对于系统调用而言,OABI 和 EABI 的区别在于,OABI 的系统调用指令需要传递参数来指定系统调用号,而 EABI 中将系统调用号保存在 r7 中进行传递。
大多数情况下都是使用 EABI 的系统调用方式,所以 CONFIG_OABI_COMPAT 并未定义。
系统调用
用户空间和内核之间通过系统调用进行交互,所以内核需要将提供的系统调用组织起来暴露给用户空间,所有的系统调用函数被放置数组 sys_call_table 中,为 NR_syscalls 个。
系统调用表
系统调用表是在 entry-common.S 中完成 sys_call_table 和 NR_syscalls 的初始化,主要代码如下:
.equ NR_syscalls,0 @ 初始化 NR_syscalls 为0
#define CALL(x) .equ NR_syscalls,NR_syscalls+1 @ 定义CALL函数,每次CALL展开就将 NR_syscalls+1
#include "calls.S" @ 引用 calls.S 中所有的CALL宏,此处是根据系统调用函数个数计算出NR_syscalls的值
/*
* Ensure that the system call table is equal to __NR_syscalls,
* which is the value the rest of the system sees
*/
.ifne NR_syscalls - __NR_syscalls @ __NR_syscalls 定义的系统调用函数的总个数,此处是进行个数检查
.error "__NR_syscalls is not equal to the size of the syscall table"
.endif
#undef CALL @ 取消CALL宏定义
#define CALL(x) .long x @ 重新定义CALL, 在汇编中会生成一个 x 函数的地址
...
/*
* This is the syscall table declaration for native ABI syscalls.
* With EABI a couple syscalls are obsolete and defined as sys_ni_syscall.
*/
#define ABI(native, compat) native @ 定义ABI宏为取参数 native
#ifdef CONFIG_AEABI
#define OBSOLETE(syscall) sys_ni_syscall @ 定义OBSOLETE宏为空函数sys_ni_syscall表示已启用
#else
#define OBSOLETE(syscall) syscall
#endif
.type sys_call_table, #object @ 定义sys_call_table对象数组
ENTRY(sys_call_table)
#include "calls.S" @ 展开calls.S中所有的CALL,将所有的系统调用按照顺序放置在sys_call_table下
#undef ABI
#undef OBSOLETE
第一次包含 calls.S 文件时,每个 CALL 宏会增加 NR_syscalls 的值,统计系统调用数量。第二次包含 calls.S 文件时,每个 CALL 宏会生成对应系统调用处理函数的地址,并按照顺序组织在 sys_call_table 下。当用户态传递系统调用号进入内核后根据系统调用号从 sys_call_table 下找到对应的系统调用函数,系统调用号是glibc 和 内核的默认约定,在内核中的 arch/arm/include/uapi/asm/unistd.h 下定义,例如 open 的调用号如下,__NR_SYSCALL_BASE
在ARM 下为 0。
#define __NR_open (__NR_SYSCALL_BASE + 5)
系统调用函数
在上面的 "calls.S" 中所有的系统调用函数都是以 sys_
开头,但是只有申明缺搜不到函数实现,例如 sys_open 申明为
asmlinkage long sys_open(const char __user *filename, int flags, umode_t mode);
其中 asmlinkage 关键子是用来申明函数参数从栈上取(内核栈上),而不是从寄存器,但是在 ARM 架构下为空。再次搜索 open 发现有他的函数定义如下
SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINEx(x, sname, ...) \
SYSCALL_METADATA(sname, x, __VA_ARGS__) // ftrace未开启时为空 \
__SYSCALL_DEFINEx(x, sname, __VA_ARGS__)
#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...) \
asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__)) \
__attribute__((alias(__stringify(SyS##name)))); \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)); \
asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__)) \
{ \
long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__)); \
__MAP(x,__SC_TEST,__VA_ARGS__); \
__PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__)); \
return ret; \
} \
static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__))
以上宏解释较为繁琐,直接ChatGPT给出宏转换结果:
asmlinkage long sys_open(const char *filename, int flags, mode_t mode)
__attribute__((alias("SyS_open")));
static inline long SYSC_open(const char *filename, int flags, mode_t mode);
asmlinkage long SyS_open(long filename, long flags, long mode);
asmlinkage long SyS_open(long filename, long flags, long mode) {
long ret = SYSC_open((const char *) filename, (int) flags, (mode_t) mode);
// __SC_TEST checks
__PROTECT(3, ret, filename, flags, mode); // ARM 下为空
return ret;
}
static inline long SYSC_open(const char *filename, int flags, mode_t mode) {
if (force_o_largefile())
flags |= O_LARGEFILE;
return do_sys_open(AT_FDCWD, filename, flags, mode);
}
其实就是先通过别名的方式申明 sys_open 的别名为 SyS_open,然后在 SyS_open 函数中调用真正的函数体 SYSC_open。sys_open 只是用于系统调用函数表初始化化时使用。
系统调用产生
在应用空间,在使用函数时不太会关注系统调用的问题,这些系统调用被 glibc 封装在内部,当我们使用open,read,write,close函数时经过层层调用最后会触发软中断,从而陷入内核,开始真正调用系统函数。glibc 中的 __close
函数如下:
int __close (int fd)
{
return SYSCALL_CANCEL (close, fd);
}
SYSCALL_CANCEL 在经过层层宏调用会最后在 INTERNAL_SYSCALL_RAW 中,执行了以下swi 0 指令触发软中断陷入内核。
# define INTERNAL_SYSCALL_RAW(name, err, nr, args...)
({
register int _a1 asm ("r0"), _nr asm ("r7");
LOAD_ARGS_##nr (args)
_nr = name;
asm volatile ("swi 0x0 @ syscall " #name
: "=r" (_a1)
: "r" (_nr) ASM_ARGS_##nr
: "memory");
_a1; })
在ARM 下 swi 就是 svc 中断,同时在 EABI 下通过 r7 寄存器将系统调用号传递到 svc 中断处理函数,对于参数传递区别于普通函数调用,普通函数调用时参数超过4个时,将前3个保存在** r0~r3** 寄存器,其余参数入栈通过栈进行传递,而对于系统调用在 ARM 下最多允许传递6个参数,并通过 r0~r5 进行传递,主要原因是在用户空间处理器工作在 user模式,在内核空间时处于 svc模式,这两个模式下他们的 sp寄存器是不同的,且使用的栈空间也是不同,所以无法通过栈进行传递参数,只能将所有参数放在两个模式下共享的寄存器中传递到内核,然后在返回时通过 r0 将结果返回。
系统调用流程的大致调用过程为:
user_func --> open --> __libc_open() --> svc 0 --> vector_swi --> SYSC_open --> sys_open -->user_func
反汇编代码查看
00020a80 <__libc_open>:
20a80: f8df c04a ldr.w ip, [pc, #74] ; 20ace <__libc_open+0x4e>
20a84: 44fc add ip, pc
20a86: f8dc c000 ldr.w ip, [ip]
20a8a: f09c 0f00 teq ip, #0
20a8e: b480 push {r7}
20a90: d108 bne.n 20aa4 <__libc_open+0x24>
20a92: 2705 movs r7, #5
20a94: df00 svc 0
20a96: bc80 pop {r7}
20a98: f510 5f80 cmn.w r0, #4096 ; 0x1000
20a9c: bf38 it cc
20a9e: 4770 bxcc lr
20aa0: f002 baf6 b.w 23090 <__syscall_error>
20aa4: b50f push {r0, r1, r2, r3, lr}
20aa6: f001 faa9 bl 21ffc <__libc_enable_asynccancel>
20aaa: 4684 mov ip, r0
20aac: bc0f pop {r0, r1, r2, r3}
20aae: 2705 movs r7, #5 // sys_open的系统调用号是5通过r7传递
20ab0: df00 svc 0 // 产生软中断
20ab2: 4607 mov r7, r0
20ab4: 4660 mov r0, ip
20ab6: f001 fae5 bl 22084 <__libc_disable_asynccancel>
20aba: 4638 mov r0, r7
20abc: f85d eb04 ldr.w lr, [sp], #4
20ac0: bc80 pop {r7}
20ac2: f510 5f80 cmn.w r0, #4096 ; 0x1000
20ac6: bf38 it cc
20ac8: 4770 bxcc lr
20aca: f002 bae1 b.w 23090 <__syscall_error>
20ace: 00058288 andeq r8, r5, r8, lsl #5
20ad2: 0000bf00 andeq fp, r0, r0, lsl #30
通过以上汇编可以发现指令长度为 2,说明当前函数是 Thumb 指令模式,在通过 svc 0 进入内核函数 vector_switch 之前会将用户态的返回地址 20ab2 地址保存到用户态的** lr寄存器**中,用于系统调用后返回用户态,并通过 r7 找到系统调用号。
异常向量表
在 ARM 下 软中断的触发实际上就是触发 SVC中断,也是进入内核的唯一方式,用户空间下cpu处于 user 模式,而在进入异常中断后会自动切换到 svc 管理特权模式,并且在执行其他内核代码时保持 svc 管理特权模式,在返回用户空间是重新切换会 user 模式。
完整的异常向量表如下,定义在 arch/arm/kernel/entry-armv.S 中
.section .vectors, "ax", %progbits
__vectors_start:
W(b) vector_rst
W(b) vector_und
W(ldr) pc, __vectors_start + 0x1000
W(b) vector_pabt
W(b) vector_dabt
W(b) vector_addrexcptn
W(b) vector_irq
W(b) vector_fiq
在向量表中的第三项是将 pc 设置到 __vectors_start + 0x1000
下进行执行,显然 __vectors_start + 0x1000
的位置应该是一段代码,搜索 __vectors_start
发现在 __vectors_start
位置存放了异常向量表,而在他后面偏移 0x1000 的地方放置 .stubs 类型的段。
__vectors_start = .;
.vectors 0 : AT(__vectors_start) {
*(.vectors)
}
. = __vectors_start + SIZEOF(.vectors);
__vectors_end = .;
__stubs_start = .;
.stubs 0x1000 : AT(__stubs_start) {
*(.stubs)
}
. = __stubs_start + SIZEOF(.stubs);
__stubs_end = .;
继续搜索 .stubs 发现在 arch/arm/kernel/entry-armv.S 下有如下定义,这就是软中断的处理函数跳转地址 vector_swi,不理解为啥要搞这么复杂。。。。
.section .stubs, "ax", %progbits
__stubs_start:
@ This must be the first word
.word vector_swi
vector_swi 处理
知识铺垫
vector_swi 中的宏:
- CONFIG_CPU_V7M :用于 armv7-M 架构,未定义
- CONFIG_THUMB2_KERNEL:指定编译时使用 thumb2指令集还是 arm 指令集,未定义
- CONFIG_TRACE_xxx:用于程序追踪相关,未定义
- CONFIG_OABI_COMPAT:兼容 OABI 宏,当前平台上不兼容,未定义
- CONFIG_AEABI:是否使用 EABI 宏,定义
- CONFIG_ARM_THUMB:是否同时支持使用 thumb 和 arm 指令集,定义
部分ARM 指令
tst r8, #PSR_T_BIT @ tst 指令执行位与运算,并更新状态寄存器
stmia sp, {r0 - r12} @ 参数是从"左"开始入栈,在入栈每个寄存器前先将地址+4
stmdb sp!, {r4, r5} @ 参数是从"右"开始入栈,先入栈每个寄存器后再将地址+4,并且反映到sp上
ldmia r0!, {r4-r11} @ 从r0 位置开始出栈到r4-r11寄存器,并且每次出栈r0+4
mrs r8, spsr @ 专门用于保存CPSR / SPSR 寄存器到指定寄存器的操作
ldr scno, [lr, #-4] @ 从 lr-4 的地址载入值到scno寄存
str lr, [sp, #S_PC] @ 保存 lr 值到 sp+S_PC 的位置
str r0, [sp, #S_R0+S_OFF]! @将r0 的值存储到内存地址 [sp + #S_R0 + S_OFF],sp指向新地址[sp + #S_R0 + S_OFF]
user/system 模式和 svc 模式的区别
上图中可以直观的看到,在 user 和 sys 模式下他们共用所有的通用寄存器,但是在 svc 模式下拥有独立的 sp_svc 和 lr_svc 寄存器,svc 模式与 user 模式共享 r0~r12 寄存器,同时在 svc 模式下还拥有一个 spsr_svc 寄存器,用于在进入异常处理函数之前自动将 user 模式下的 APSR 保存到 spsr_svc 寄存器中,在中断退出时会自动将 spsr_svc 寄存器重新写入 user 模式下的 APSR 寄存器。
用户空间上下文
struct pt_regs {
unsigned long uregs[18];
};
/* 对应uregs[]中对应的下标,18个通用寄存器 */
#define ARM_cpsr uregs[16]
#define ARM_pc uregs[15]
#define ARM_lr uregs[14]
#define ARM_sp uregs[13]
#define ARM_ip uregs[12]
#define ARM_fp uregs[11]
#define ARM_r10 uregs[10]
#define ARM_r9 uregs[9]
#define ARM_r8 uregs[8]
#define ARM_r7 uregs[7]
#define ARM_r6 uregs[6]
#define ARM_r5 uregs[5]
#define ARM_r4 uregs[4]
#define ARM_r3 uregs[3]
#define ARM_r2 uregs[2]
#define ARM_r1 uregs[1]
#define ARM_r0 uregs[0]
#define ARM_ORIG_r0 uregs[17]
整个系统的调用最重要的一步就是进行用户空间上下文的入栈和出栈,只有正确的将用户空间上下文正确出栈,才能还原到进程陷入内核前的用户空间状态。
代码分析
在异常触发后,硬件会自动完成一些工作,之后才会进入 vector_swi 软中断异常处理函数:
- 将 user 模式下的 CPSR 保存到 svc 模式下的 SPSR_svc 寄存器,将用户空间中执行 swi 0 指令后的下一条指令地址保存到 svc 模式下的 lr_svc,用于重新返回用户态时使用。
- 重新设置 CPSR 寄存器的值,用于进入 svc 模式,具体为
CPSR.M = '10011'
(svc mode),CPSR.I = '1'
(disable IRQ),CPSR.IT = '00000000'
(TODO),CPSR.J = '0'
,CPSR.T = SCTLR.TE
,CPSR.E = SCTLR.EE
。 - PC 赋值为 swi 异常向量的地址,用于进入 vector_swi 处理函数。
/*=============================================================================
* SWI handler
* 软中断函数流程:
* 1. 完成用户态的上下文保存到进程内核栈; 2.获取系统调用号; 3.准备参数进行系统调用; 4. 调用完成在ret_fast_syscall进行上下文还原,返回用户态
* 内核栈指针 sp_svc 的设置:
* 1. 刚fork的进程在 copy_thread 中通过 *childregs = *current_pt_regs(),将父进程的内核栈寄存器区域复制给新进程,并将新进程的堆栈指针 sp_svc 设置到内核进程合适位置 thread->cpu_context.sp = (unsigned long)childregs,所以等到新进程被调度时从restore_user_regs 中会返回到父进程调用 fork()的下一条语句,因为寄存器区域中的 LR 寄存器的值来自父进程,这也是为什么fork 一次返回两次的原因。
* 2. 如果是已经经过多次调度的进程,那么他主动陷入内核说明他一定是在当前cpu上运行的进程,sp_svc 是在进程切换时从内核栈上获取值设置到 sp_svc 寄存器的,其次在每次从 restore_user_regs 返回时会重新设置 sp_svc 到合适位置,方便当前进程在没有被切换的情况下再次陷入内核时可以使用到正确的sp_svc。
*-----------------------------------------------------------------------------
*/
.align 5
ENTRY(vector_swi)
/****************************1.用户态现场保存*********************************/
#ifdef CONFIG_CPU_V7M
v7m_exception_entry
#else
sub sp, sp, #S_FRAME_SIZE @先在栈上开辟sizeof(struct pt_regs)=18*4大小的空间存储寄存器值
stmia sp, {r0 - r12} @ Calling r0 - r12. 将r0-r12从当前sp位置向高地址入栈(注意stmdb是从"右"开始入栈,stmia是从"左"开始入栈)
@ 在sys和svc模式下拥有各种独立的sp和lr寄存器,当前是svc模式,但是在ARM和thumb指令集下读取sys模式(用户态模式)下的sp和lr的方式是不一样的,
@ ARM指令集下直接使用 {sp, lr}^ 就能获取sys模式下的sp和lr,thumb指令集下必须要切换到sys模式才能获取对应的sp和lr
ARM( add r8, sp, #S_PC ) @ 将sp的值增加 offsetof(struct pt_regs, ARM_pc) 找到pc入栈位置,将地址值赋值给r8
ARM( stmdb r8, {sp, lr}^ ) @ Calling sp, lr. 先将r8-4后将lr,sp向下入栈,位置严格遵循 struct pt_regs 结构体(注意此处的sp和上面的sp不是同一寄存器)
THUMB( mov r8, sp )
THUMB( store_user_sp_lr r8, r10, S_SP ) @ calling sp, lr. 通过自定义的宏先切换到sys模式获取用户态sp和lr后压栈,然后在切换回svc模式
mrs r8, spsr @ called from non-FIQ mode, so ok. 将spsr存入r8,spsr保存了进入异常前的CPSR寄存器的值
str lr, [sp, #S_PC] @ Save calling PC. 将 svc_lr存入栈到 sp+S_PC 位置, svc_lr中保存了进入内核前的用户态返回地址(硬件自动完成)
str r8, [sp, #S_PSR] @ Save CPSR. 将CPSR寄存器值入栈到 sp+S_PSR 位置
str r0, [sp, #S_OLD_R0] @ Save OLD_R0. 将r0再次入栈到 sp+S_OLD_R0 位置, S_R0位置的r0在返回时可能会被用作返回值, S_OLD_R0处的r0 保存系统调第一个参数系统调用号, 用于ptrace功能
#endif
zero_fp @ 清除fp(r11),为0表示这是内核最外层函数
alignment_trap r10, ip, __cr_alignment
enable_irq @ 执行 cpsie i 指令,打开IRQ(普通)中断,因为在进入vector_swi前硬件自动禁止了IRQ(普通)中断
ct_user_exit
get_thread_info tsk @ tsk 是r9寄存器的别名, 该宏将当前进程的 thread_info 基地址放置到r9中
/****************************2.提取系统调用号*********************************/
/*
* Get the system call number.
* OABI 和 EABI 的区别主要在于浮点的处理和系统调用,对于系统调用而言,OABI 和 EABI 最大的区别在于,OABI 的系统调用指令需要传递参数来指定系统调用号,
* 而 EABI 中将系统调用号保存在 r7 中.大多数情况下使用 EABI 的系统调用方式,也会保持对 OABI 的兼容,而 CONFIG_OABI_COMPAT 设置表示对 OABI 的兼容
*/
#if defined(CONFIG_OABI_COMPAT)
/*
* If we have CONFIG_OABI_COMPAT then we need to look at the swi
* value to determine if it is an EABI or an old ABI call.
*/
#ifdef CONFIG_ARM_THUMB
tst r8, #PSR_T_BIT
movne r10, #0 @ no thumb OABI emulation
USER( ldreq r10, [lr, #-4] ) @ get SWI instruction
#else
USER( ldr r10, [lr, #-4] ) @ get SWI instruction
#endif
ARM_BE8(rev r10, r10) @ little endian instruction
#elif defined(CONFIG_AEABI)
/*
* Pure EABI user space always put syscall number into scno (r7).
*/
#elif defined(CONFIG_ARM_THUMB)
/* Legacy ABI only, possibly thumb mode. */
tst r8, #PSR_T_BIT @ this is SPSR from save_user_regs. 检查进入内核前的用户态的指令集是 0:ARM 还是 1:thumb
addne scno, r7, #__NR_SYSCALL_BASE @ put OS number in. 如果是thumb指令集:将 r7+调用号基数 存入系统调用号寄存器(scno(r7)存放调用号)
USER( ldreq scno, [lr, #-4] ) @ 如果是ARM指令集:直接将svc指令存放到scno系统调用号寄存器, ARM模式下系统调用号包括在svc指令中低24bits中
#else
/* Legacy ABI only. */
USER( ldr scno, [lr, #-4] ) @ get SWI instruction. 如果是ARM指令集:直接将svc指令存放到scno系统调用号寄存器, ARM模式下系统调用号包括在svc指令中低24bits中
#endif
adr tbl, sys_call_table @ load syscall table pointer. sys_call_table 是系统调用函数数组,将其赋值给tbl(r8)
#if defined(CONFIG_OABI_COMPAT)
/*
* If the swi argument is zero, this is an EABI call and we do nothing.
*
* If this is an old ABI call, get the syscall number into scno and
* get the old ABI syscall table address.
*/
bics r10, r10, #0xff000000
eorne scno, r10, #__NR_OABI_SYSCALL_BASE
ldrne tbl, =sys_oabi_call_table
#elif !defined(CONFIG_AEABI)
bic scno, scno, #0xff000000 @ mask off SWI op-code. 获取swi 指令低24位
eor scno, scno, #__NR_SYSCALL_BASE @ check OS number. 获取系统调用号
#endif
/******************************3.进行系统调用*********************************/
local_restart:
ldr r10, [tsk, #TI_FLAGS] @ check for syscall tracing. TI_FLAGS 偏移值对应 thread_info->flags 成员,保存在 r10 中
/*
* {r4, r5}为何入栈后再次入栈:第一次为了保存完整用户上下文用于还原用户态使用;第二次是为了在内核态下调用系统函数时通过栈来传递额外的 r4 r5 参数
* 1.系统调用与函数调用不同,函数调用超过4个参数时其他参数入栈进行传递,而系统调用涉及模式切换,user和svc下的sp是不同的,所以只能使用两个模式下共享的r0-r5来传递;
* 2.此时已经进入svc模式只需要将r4-r5入栈到sp位置,就能按照普通函数调用使用栈进行参数传递了
*/
stmdb sp!, {r4, r5} @ push fifth and sixth args. 额外的参数 r4 r5 入栈(在栈顶-8-sizeof(struct pt_regs))开始向下压栈
tst r10, #_TIF_SYSCALL_WORK @ are we tracing syscalls? 检查 thread_info->flags 中 _TIF_SYSCALL_WORK 位是否设置
bne __sys_trace @ 如果非0置位则进行跟踪然后供其他地方返回用户态
cmp scno, #NR_syscalls @ check upper syscall limit. 检查系统调用号是否超过系统允许的最大调用号
adr lr, BSYM(ret_fast_syscall) @ return address. 获取 ret_fast_syscall 地址给到 lr, 用于从系统调用函数完成后返回用户态
ldrcc pc, [tbl, scno, lsl #2] @ call sys_* routine. 上面(scno<NR_syscalls)时将tbl中的系统调用号赋值给pc,scno,lsl #2 表示系统调用号乘以4,
@ 因为每个系统调用函数占4字节,然后跳转到系统调用函数执行,完成后的返回值存默认存放在r0,跳转到lr寄存器指向的ret_fast_syscall中
/********************以下是处理异系统调用号的情况****************************/
add r1, sp, #S_OFF
2: cmp scno, #(__ARM_NR_BASE - __NR_SYSCALL_BASE)
eor r0, scno, #__NR_SYSCALL_BASE @ put OS number back
bcs arm_syscall
mov why, #0 @ no longer a real syscall
b sys_ni_syscall @ not private func
#if defined(CONFIG_OABI_COMPAT) || !defined(CONFIG_AEABI)
/*
* We failed to handle a fault trying to access the page
* containing the swi instruction, but we're not really in a
* position to return -EFAULT. Instead, return back to the
* instruction and re-enter the user fault handling path trying
* to page it in. This will likely result in sending SEGV to the
* current task.
*/
9001:
sub lr, lr, #4
str lr, [sp, #S_PC]
b ret_fast_syscall
#endif
ENDPROC(vector_swi)
vector_swi 函数中主要是分为如下六步:
用户空间现场保存
进程用户空间保存时先将内核栈指针向下移动 S_FRAME_SIZE 大小,S_FRAME_SIZE 是 sizeof(struct pt_regs)
在 arm 32bit 下大小为 18*4 Bytes
,目的是存放 18 个用户空间的通用寄存器,空间开辟好之后 就按照顺序将 r0 - r12、sp、lr、pc、CPSR、ORIG_r0 向高地址进行压栈保存。
这里可以看到 r0 寄存器在栈上 r0 和 ORIG_r0 位置存在了两份,此时的 r0 是用户空间进行系统调用时传递的第1个参数:
第一次保存 r0寄存器到栈上的** r0 位置**是进入异常时 ABI 规范要求将上下文都进行入栈,r0寄存器在系统调用函数执行后会被赋值为系统调用的返回值,在有信号和调度要处理时栈上 r0 位置也会被修改为系统调用返回值。
第二次将 r0 寄存器入栈到 ORIG_r0 位置是和 ptrace 调试 system call restart 相关,因为在调试时会使用到传入的参数,所以需要将其保存到 ORIG_r0 位置,如果只保存到栈上 r0 位置,则有可能被系统调用返回值覆盖。
注: 内核栈的 pc 位置保存的是 svc 模式下的 lr ,存放了返回用户态的地址。
获取系统调用号
二进制接口使用了 EABI 格式,并且设置 CONFIG_ARM_THUMB 宏同时支持 arm 和 thumb指令集,所以在获取系统调用号之前需要先判断当前是 thumb 指令集还是 arm 指令集,thumb 指令集下指令大小为 2Bytes,系统调用号以直接获取 r7 寄存器值即可,如果是arm指令集则获取 swi 0 指令(4Byets)并解析出系统调用号。
注:在纯 EABI 格式下总是使用 r7 保存系统调用号。
执行系统调用
系统调用是通过在用户空间传递系统调用号,然后在内核空间从系统调用表 sys_call_table 中找到对应的系统调用函数,在执行系统调用前需要进行参数准备,可以看到通过 stmdb sp!, {r4, r5}
再次将 r5,r4 从 sp-S_FRAME_SIZE 开始的地方向低地址方向依次入栈。
问题是在一开始的时候已经将所有的通用寄存器全部入栈,为何需要将 r4,r5在次入栈?
主要原因有两个:
- 在ARM体系下所有的普通函数调用和系统调用都遵循一个原则,参数不超过4个时使用寄存器 r0~r3 进行传递,如果超过4个,则需要将额外的参数入栈通过栈进行传递。
- 系统调用有别于普通函数调用,普通函数调用是所处的模式没有发生改变,sp 寄存器和堆栈在函数调用前后使用的都是同一个,所以可以用额外参数压栈的方式进行传递,而系统调用需要从 user 模式切换到 svc 模式,这两种模式下 sp 寄存器不是同一个,使用的栈空间也不是同一片,所以没法直接压栈传递额外参数,只能使用两个模式下共享的 r0~r12 中的 r4~r5 进行额外参数的传递,此时已经进入 svc 模式,遵循普通函数调用,所以在调用前需要将 r4~r5 压栈通过栈传递额外参数,其他参数依然使用用户空间传递下来的 r0~r3 寄存器中的值,来进行系统函数调用。
总结就是第一次为了保存完整用户上下文用于还原用户空间上下文使用;第二次是为了在内核态下调用系统函数额外参数压站传递。
返回前检查
.align 5
/*
* This is the fast syscall return path. We do as little as
* possible here, and this includes saving r0 back into the SVC
* stack.
*/
ret_fast_syscall:
UNWIND(.fnstart )
UNWIND(.cantunwind )
disable_irq @ disable interrupts 禁止irq普通中断
ldr r1, [tsk, #TI_FLAGS] @ re-check for syscall tracing. @ 获取当前进程 thread_info->flags 到r1中
tst r1, #_TIF_SYSCALL_WORK @ 检查flag是否有追踪相关标志,有则执行__sys_trace_return并从其他地方返回用户态
bne __sys_trace_return
tst r1, #_TIF_WORK_MASK @ 检查flag是否有 _TIF_NEED_RESCHED | _TIF_SIGPENDING | _TIF_NOTIFY_RESUME | _TIF_UPROBE
bne fast_work_pending @ 开始处理完信号和抢占之后,不在返回这里,从fast_work_pending中返回用户空间
asm_trace_hardirqs_on
/* perform architecture specific actions before user return */
arch_ret_to_user r1, lr @ 架构相关的返回代码, arm 中为空
ct_user_enter
restore_user_regs fast = 1, offset = S_OFF @ 还原用户态的上下文,跳转回用户态, fast=1表示r0可以直接用做返回值返回用户态
UNWIND(.fnend )
/*
* Ok, we need to do extra processing, enter the slow path.
*/
fast_work_pending:
str r0, [sp, #S_R0+S_OFF]! @ returned r0. 系统调用函数返回值存放在r0中,此时需要将其入栈保存,因为需要调用其他函数r0会被使用到,并将sp设置到#S_R0+S_OFF位置
work_pending:
mov r0, sp @ 'regs'. 获取进程内核栈上的通用寄存器首地址,位置为(内核栈顶-8-sizeof(struct pt_regs))
mov r2, why @ 'syscall'. 获取系统调用函数数组 sys_call_table
bl do_work_pending @ 有进程需要调度, 进行调度 schedule
cmp r0, #0 @ 检查do_work_pending是否正确执行,返回0时为OK
beq no_work_pending @ 正确执行时返回用户态(不在返回这里)
movlt scno, #(__NR_restart_syscall - __NR_SYSCALL_BASE) @ 设置系统调用号为0对应 sys_restart_syscall,用于继续执行系统调用
ldmia sp, {r0 - r6} @ have to reload r0 - r6 @ 将栈上r0-r6出栈寄存器中
b local_restart @ ... and off we go @ 执行 sys_restart_syscall 系统调用
/*
* "slow" syscall return path. "why" tells us if this was a real syscall.
*/
ENTRY(ret_to_user)
ret_slow_syscall:
disable_irq @ disable interrupts 禁止IRQ普通中断
ENTRY(ret_to_user_from_irq)
ldr r1, [tsk, #TI_FLAGS]
tst r1, #_TIF_WORK_MASK @ 断该进程是否有调度和信号要处理
bne work_pending @ yes 进入 work_pending处理
no_work_pending:
asm_trace_hardirqs_on
/* perform architecture specific actions before user return */
arch_ret_to_user r1, lr @ 架构相关的返回代码, arm 中为空
ct_user_enter save = 0
restore_user_regs fast = 0, offset = 0 @ 还原用户态的上下文, 跳转回用户态, fast=表示系统调用的返回值存放在内核栈S_R0位置,需要出栈
ENDPROC(ret_to_user_from_irq)
ENDPROC(ret_to_user)
系统调用完成后的返回地址通过 adr lr, BSYM(ret_fast_syscall)
在 vector_swi 中指定,主要是根据 thread_info.flag 中设置的标志位确定是否有其他工作需要处理,如果没有则 restore_user_regs fast = 1, offset = S_OFF
返回用户空间,否则需要完成标志位中指定的工作才能通过 restore_user_regs fast = 0, offset = 0
返回用户空间,因为需要处理其他事情,调用函数时必然会破坏 r0 中存储的 系统调用返回值,所以会想将其入栈保存到内核栈上的 r0 位置。此处最重要的事情是对 _TIF_NEED_RESCHED
标志位的检查,确认在返回用户空间前是否需要进行调度。
返回用户空间
@ 还原用户态的上下文,跳转回用户态, 如果是一个do_fork()后的进程首次被调度,从ret_from_fork进入用户态时,
@ 用户上下文是在copy_thread中被设置,基本与他的父进程相同,所以返回到父进程调用fork()的下一条语句
.macro restore_user_regs, fast = 0, offset = 0
mov r2, sp @ 获取SVC模式下内核栈指针sp, 此时栈指针指向 [栈顶-sizeof(struct pt_regs)-8]
load_user_sp_lr r2, r3, \offset + S_SP @ calling sp, lr. 将内核栈上存储的用户态sp和lr 还原到 user/sys模式下的 sp 和 lr寄存器中
ldr r1, [sp, #\offset + S_PSR] @ get calling cpsr. 从栈上存储通用寄存器最低的地址位置找到 PSR存入r1
ldr lr, [sp, #\offset + S_PC] @ get pc. 从栈上存储通用寄存器最低的地址位置找到 PC, 存入lr_svc用于返回用户态
add sp, sp, #\offset + S_SP @ 移动栈指针,将 svc_sp 指向用户栈指针指向的地址,进行(r0, r1 - r12)出栈
msr spsr_cxsf, r1 @ save in spsr_svc. 将之前用户态的APSR存放到SVC模式下的SPSR中,退出时硬件自动将svc模式下的spsr加载到用户态下的APSR中
@ We must avoid clrex due to Cortex-A15 erratum #830321
strex r1, r2, [sp] @ clear the exclusive monitor
.if \fast @ 检查是快速返回,还是慢速返回
ldmdb sp, {r1 - r12} @ get calling r1 - r12. 将进程用户态保存的sp出栈到 r12-r1, 过程中r0 在系统调用返回时被赋值, r0没有被修改, 直接使用进行返回
.else
ldmdb sp, {r0 - r12} @ get calling r0 - r12. 将进程用户态保存的sp出栈到 r12-r0, r0 在之前的函数中被污染前进行了压栈, 此时也需要出栈
.endif
add sp, sp, #S_FRAME_SIZE - S_SP @ 进程用户态现场已经恢复,将进程内核态svc mode的sp回到栈顶, 当前进程没有被调度情况下再次陷入内核态时可以使用到正确的sp_svc
movs pc, lr @ return & move spsr_svc into cpsr. 返回用户空间
.endm
在所有检查工作结束后,将从 restore_user_regs 宏中把之前存放的用户空间上下文还原,还原之后就会根据 pc 跳转到触发陷入内核的下一条语句中执行。在这里用户态的 CPSR 值只需要从栈上还原到 svc 模式下的 spsr 中,在返回用户态的时候硬件会自动设置到 user 模式下的 CPSR 寄存器中。
在用户态上下文进行出栈的时候分为两种情况:
- 在没有 pending work 的情况下(fast等于1),r0 已经保存了系统调用函数的返回值,所以不需要出栈了。此时
offset = 8
,因为在系统函数调用前将 r4~r5进行了入栈,sp 向下偏移了8个字节,此时在计算位置是需要补偿会来。 - 在有 pending work 的情况下(fast等于0),其他函数会用到 r0 寄存器,r0寄存器内容已经被修改,但是在修改前已经将系统调用的返回值 r0 保存到内核栈 r0 位置,所以此时需要重新将返回值出栈到 r0 寄存器。此时offset = 0,因为在系统调用结果r0入栈时顺便将 sp 设置到了栈上的 r0 位置,所以偏移量位0。
- 最后在跳转用户空间之前,执行了
add sp, sp, #S_FRAME_SIZE - S_SP
操作,这里其实是将当前进程使用的 svc_sp 设置到当前进程的(task->stack)+8K - 8
(内核栈顶-8) 的位置,这么做的主要原因是方便在当前进程没有被调度的情况下,再次从用户空间陷入内核空间时不需要再调整内核栈指针 sp 的位置。
vector_swi 前的sp问题
在进入 vector_swi 函数前没有看到 sp_svc 堆栈指针调整直接向下偏移了 S_FRAME_SIZE 大小,然后将用户空间上下文向上入栈,那么这个内核空间的 sp_svc 是在什么时候调整的呢??
在说明问题前明确两个问题,
- 系统调用一定是在用户空间调用系统函数时发生的。
- 系统调用期间进程是运行状态独自持有 sp_svc,其他进程使用时需要重新设置是sp_svc。
当一个新用户进程从 fork() 创建时,创建过程中的 copy_thread() 函数中会进行进程内核栈的初始化,新进程栈上用于保存用户空间上下文的区域基本和父进程完全一样,这意味着它将和父进程返回用户空间中的相同位置,这也是为什么一次 fork() 两次返回点都在一个地方的本质原因。
但是将新进程内核通用寄存器也做了一些修改:
- 设置新进程 fork()的返回值为0:
childregs->ARM_r0 = 0
; - 设置新进程在内核态下的执行点:
thread->cpu_context.pc = (unsigned long)ret_from_fork
; - 设置新进程的内核栈指针:
thread->cpu_context.sp = (unsigned long)childregs
;
设置完以上之后等到调度器切换到新进程时 thread->cpu_context.sp 会出栈到 sp_svc,然后 thread->cpu_context.pc 设置到 pc 寄存器,然后跳转到 ret_from_fork 函数,最终在 ret_to_user 中调用宏函数 。
restore_user_regs fast = 0, offset = 0
返回用户空间,在分析 restore_user_regs 的时候我们知道在最后将 sp_svc 设置到了 栈顶-8 的位置,如果当前进程进入用户空间后没有被抢占,之后触发系统调用,那么他在返回用户空间之前已经将 sp_svc 设置好了,所以在进入 vector_swi 函数可以直接使用 sp_svc。
当一个进程经过多次抢占调度处于运行态在用户空间触发系统调用进入内核,那么在进程进入用于空间之前被需要被调度器调度进行任务切换,切换完成后也是要调用 ret_to_user 函数返回用户空间,同样会在进入用户空间前将 sp_svc 设置到了 栈顶-8 的位置,这个进程系统调用后进入 vector_swi 函数可以直接使用 sp_svc。
🌀路西法 的CSDN博客拥有更多美文等你来读。