硬件机制
X86
在IA上,会用TR(Task Register)寄存器指向一个名为任务状态段TSS的结构:
该结构存储了一个任务的部分上下文信息,如下:
在执行到Call/Jmp/IRET或遇到Exception/Interrupt时,会发生任务切换,X86下的TSS的描述符如下(64位的类似只是扩展了地址长度):
它和其他段描述符类似,只简单介绍下几个域:
- B(busy):表示当前任务处于Busy状态,当任务被调入后它将变为Busy状态,并在(Call/Exception/Interrupt)下被换出时保持该标志,只有非Busy的任务可以被调入,这可以防止任务出现递归调用。由于IA32任务使用Previous Task Link保存上一个任务,出现递归时只能覆盖上一次的内容从而导致错误,因此任务是无法递归的。
- G:为0时表示TSS必须大于等于0x67而小于TSS的最大大小减1。
X86下的TSS基础结构如下,操作系统可在此基础上进行扩展:
它里面存放的是任务的上下文,它们在任务被切出时刷新保存现场,在任务被调入时读出恢复现场,里面的数据可分为两类,动态域与静态域,前者由硬件自动刷新,后者通常由软件修改,图里的大都易懂,这里简单介绍几个:
- Previous Task Link:当出现嵌套任务时(Call/Interrupt/Exception)记录被换出的任务的TSS的段选择子,否则应该置0。还有个和嵌套任务有关的域位于EFLAGS,即NT(Nested Task),当进入嵌套任务时它将被置位,IRET返回时会判断它并加载上一个任务。
- I/O Map Base Address: 当存在IO映射表时,记录其偏移基址,通过此可实现为非特权任务赋予访问IO的权限。
- T: 调试标志,设置后当调入该任务会产生debug异常。
- SSP(Shadow Stack Pointer):存储任务的影子栈指针寄存器,影子栈是程序栈的影子,但它只保存返回地址且只有控制流转移指令和特殊指令能读写,程序返回时会判断程序栈和影子栈的值,通过这实现更好的控制流防护。
- SS0-2/ESP0-2:指向0-2特权级的栈,为什么没有SS3呢?不公平!!
任务门的描述符如下:
如下三种方式可切换任务,它们都是指向了任务描述符:
通过任务门的方式比较好理解,而通过TR寄存器调用时,其实就是把TR当CS使用,根据它的Type域CPU能识别出是TS,于是就可以进行任务切换:
如使用如下代码跳转:
mov WORD [tss_pointer + 4], <TSS_selector>
jmp FAR [tss_pointer]
X64
x64不支持上述的任务切换方式,任务切换完全由软件实现,但是也需要至少一个TSS结构,如下图:
Linux任务结构
Linux并没有使用CPU提供的任务切换机制,但也逃不过硬件制约,因此它的实现为每CPU一个TSS,这样每次切换任务时通过软件完成上下文的保存(X86和X64再次统一,不过也不知道是谁先来的),据悉原因是软硬件速度差别不大但软件更灵活能实现更多检查...
Linux使用task_struct结构来表示一个任务,使用thread_info来存储线程信息,相关结构各版本都有变化,2.6.39的如下:
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
};
// https://elixir.bootlin.com/linux/v2.6.39/source/arch/x86/include/asm/thread_info.h#L26
struct thread_info {
struct task_struct *task; /* main task structure */
struct exec_domain *exec_domain; /* execution domain */
__u32 flags; /* low level flags */
__u32 status; /* thread synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable,
<0 => BUG */
mm_segment_t addr_limit;
struct restart_block restart_block;
void __user *sysenter_return;
#ifdef CONFIG_X86_32
unsigned long previous_esp; /* ESP of the previous stack in
case of nested (IRQ) stacks
*/
__u8 supervisor_stack[0];
#endif
int uaccess_err;
};
https://elixir.bootlin.com/linux/v2.6.39/source/include/linux/sched.h#L1193
struct task_struct {
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
void *stack;
atomic_t usage;
unsigned int flags; /* per process flags, defined below */
unsigned int ptrace;
int lock_depth; /* BKL lock depth */
...
}
#define get_current() (current_thread_info()->task)
#define current get_current()
在这种情况下,三者关系如下图:
Linux任务切换
Linux任务调度系统有很多算法用于决定切换哪个任务,此处只关注怎么切换的,linux-2.6.32\arch\x86\kernel\process_32.c
:
__notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
struct thread_struct *prev = &prev_p->thread,
*next = &next_p->thread;
// 从每CPU区域获取当前CPU的TSS
int cpu = smp_processor_id();
struct tss_struct *tss = &per_cpu(init_tss, cpu);
bool preload_fpu;
// 若下一个任务使用MATH并且使用过fpu至少5次说明它很大概率不久会再使用fpu,为了避免到时候trap的切换开销,这里就直接装载它
preload_fpu = tsk_used_math(next_p) && next_p->fpu_counter > 5;
// 通过上一个任务的status判断是否使用了fpu,若使用则将当前的fpu保存到prev的上下文,并设置ts,重置status:TS_USEDFPU
__unlazy_fpu(prev_p);
// 装载下一个的任务的fpu,否则它将会在第一次使用事由异常处理去装入
if (preload_fpu)
prefetch(next->xstate);
// 将next的sp0存入tss的sp0
load_sp0(tss, next);
// 将当前的gs保存到prev的gs域里
lazy_save_gs(prev->gs);
// 将next的三个TLS段加载到当前CPU的GDT里
load_TLS(next, cpu);
// 若内核不运行在ring0(某些虚拟化环境),popf不会处理这些,此时若下一个任务和上一个任务的iopl不同,则手动装载下一个任务的iopl
if (get_kernel_rpl() && unlikely(prev->iopl != next->iopl))
set_iopl_mask(next->iopl);
// 在必要时处理调试寄存器
if (unlikely(task_thread_info(prev_p)->flags & _TIF_WORK_CTXSW_PREV ||
task_thread_info(next_p)->flags & _TIF_WORK_CTXSW_NEXT))
__switch_to_xtra(prev_p, next_p, tss);
// 若已经加载了FPU则清除TS(Task Switch)标志,否则它会在再次使用FPU陷入时被清除
if (preload_fpu)
clts();
// 虚拟化时可做的hook
arch_end_context_switch(next_p);
// 若已装载FPU则恢复下一个任务的status:TS_USEDFPU并增加fpu_counter计数
if (preload_fpu)
__math_state_restore();
// 若有一个任务使用了gs则加载下一个任务的gs
if (prev->gs | next->gs)
lazy_load_gs(next->gs);
// 将next写入当前CPU的current_task处
percpu_write(current_task, next_p);
return prev_p;
}
64位类似,但是在处理段寄存器时存在差异,如下linux-2.6.32\arch\x86\kernel\process_64.c
:
__notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
...
savesegment(es, prev->es);
if (unlikely(next->es | prev->es))
loadsegment(es, next->es);
savesegment(ds, prev->ds);
if (unlikely(next->ds | prev->ds))
loadsegment(ds, next->ds);
savesegment(fs, fsindex); // 此处处理的是索引,选择子
savesegment(gs, gsindex);
if (unlikely(fsindex | next->fsindex | prev->fs)) {
loadsegment(fs, next->fsindex);
if (fsindex)
prev->fs = 0;
}
if (next->fs)
wrmsrl(MSR_FS_BASE, next->fs); // 此处设置的是描述符结构
prev->fsindex = fsindex;
if (unlikely(gsindex | next->gsindex | prev->gs)) {
load_gs_index(next->gsindex);
if (gsindex)
prev->gs = 0;
}
if (next->gs)
wrmsrl(MSR_KERNEL_GS_BASE, next->gs);
prev->gsindex = gsindex;
prev->usersp = percpu_read(old_rsp);
percpu_write(old_rsp, next->usersp);
percpu_write(current_task, next_p);
percpu_write(kernel_stack,
(unsigned long)task_stack_page(next_p) +
THREAD_SIZE - KERNEL_STACK_OFFSET);
...
}
系统调用
系统调用的ABI可通过man指令查看,而具体调用号需要看对应版本的syscall_table_32.S
文件:
betamao@DESKTOP: ~ $ man syscall
Arch/ABI Instruction System Ret Ret Error Notes
call # val val2
───────────────────────────────────────────────────────────────────
i386 int $0x80 eax eax edx -
x86-64 syscall rax rax rdx - 5
x32 syscall rax rax rdx - 5
Arch/ABI arg1 arg2 arg3 arg4 arg5 arg6 arg7 Notes
──────────────────────────────────────────────────────────────
i386 ebx ecx edx esi edi ebp -
x86-64 rdi rsi rdx r10 r8 r9 -
x32 rdi rsi rdx r10 r8 r9 -
注:X32 ABI与X86-64共用系统调用表,为了区别在使用X32时需要在系统调用号上或上__X32_SYSCALL_BIT(0x40000000),另外有些结构长度不能单纯减半,因此它有些独有的版本;用这个可以绕一些沙盒...
int80
它在trap_init
中初始化系统调用的:
void __init trap_init(void)
{
...
# define SYSCALL_VECTOR 0x80
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
...
}
其实现如下(删除了所有CFI信息):
ENTRY(system_call)
SWAPGS_UNSAFE_STACK ;; SWAPGS_UNSAFE_STACK=swapgs 切换GS
ENTRY(system_call_after_swapgs)
movq %rsp,PER_CPU_VAR(old_rsp) ;; 保存RSP
movq PER_CPU_VAR(kernel_stack),%rsp ;; 切换到内核态的栈
ENABLE_INTERRUPTS(CLBR_NONE) ;; ENABLE_INTERRUPTS(x)=sti 启用中断
SAVE_ARGS 8,1 ;; 把参数(RDI,RSI,RDX,<RCX>,RAX,R8,R9,R10,R11)保存在栈里,不保存RCX,并留一个空用于保存orgi_RAX
movq %rax,ORIG_RAX-ARGOFFSET(%rsp) ;; 保存系统调用号
movq %rcx,RIP-ARGOFFSET(%rsp) ;; 保存用户态要执行的下一条指令的地址
GET_THREAD_INFO(%rcx) ;; 将thread_info地址存入RCX
testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%rcx) ;; 若有跟踪进入标志则先跳转到tracesys执行
jnz tracesys
system_call_fastpath:
cmpq $__NR_syscall_max,%rax ;; 判断系统调用号是否合法
ja badsys
movq %r10,%rcx
call *sys_call_table(,%rax,8) ;; 调用对应的系统调用实现
movq %rax,RAX-ARGOFFSET(%rsp) ;; 将返回值存入
ret_from_sys_call:
movl $_TIF_ALLWORK_MASK,%edi ;; 在返回用户空间前还需要的做的事的掩码
;; 不能直接返回,需要判断一些标志,此处检测是否还有任务需要处理,没有再返回
sysret_check:
LOCKDEP_SYS_EXIT ;; 检测死锁
GET_THREAD_INFO(%rcx) ;;
DISABLE_INTERRUPTS(CLBR_NONE) ;; 禁用中断
TRACE_IRQS_OFF
movl TI_flags(%rcx),%edx ;; 获取线程信息标志,存于EDX
andl %edi,%edx
jnz sysret_careful ;; 说明在返回前还有其他事件需要处理,不能直接返回,判断的点见下文
TRACE_IRQS_ON
movq RIP-ARGOFFSET(%rsp),%rcx ;; 恢复RCX,这里记录了用户态的指令地址
RESTORE_ARGS 0,-ARG_SKIP,1 ;; 恢复之前保存的所有参数
movq PER_CPU_VAR(old_rsp), %rsp ;; 恢复RSP
USERGS_SYSRET64 ;; USERGS_SYSRET64 = swapgs; sysretq; 返回用户空间
sysret_careful:
bt $TIF_NEED_RESCHED,%edx ;; 是否需要进行调度
jnc sysret_signal
TRACE_IRQS_ON
ENABLE_INTERRUPTS(CLBR_NONE)
pushq %rdi
call schedule ;; 进入调度
popq %rdi
jmp sysret_check
sysret_signal:
TRACE_IRQS_ON
ENABLE_INTERRUPTS(CLBR_NONE)
FIXUP_TOP_OF_STACK %r11, -ARGOFFSET
jmp int_check_syscall_exit_work
badsys:
movq $-ENOSYS,RAX-ARGOFFSET(%rsp) ;; 直接设置RAX为ENOSYS并退出
jmp ret_from_sys_call
tracesys:
SAVE_REST
movq $-ENOSYS,RAX(%rsp) /* ptrace can change this for a bad syscall */
FIXUP_TOP_OF_STACK %rdi
movq %rsp,%rdi
call syscall_trace_enter
LOAD_ARGS ARGOFFSET, 1
RESTORE_REST
cmpq $__NR_syscall_max,%rax
ja int_ret_from_sys_call /* RAX(%rsp) set to -ENOSYS above */
movq %r10,%rcx /* fixup for C */
call *sys_call_table(,%rax,8)
movq %rax,RAX-ARGOFFSET(%rsp)
/* Use IRET because user could have changed frame */
GLOBAL(int_ret_from_sys_call)
DISABLE_INTERRUPTS(CLBR_NONE)
TRACE_IRQS_OFF
testl $3,CS-ARGOFFSET(%rsp)
je retint_restore_args
movl $_TIF_ALLWORK_MASK,%edi
GLOBAL(int_with_check)
LOCKDEP_SYS_EXIT_IRQ
GET_THREAD_INFO(%rcx)
movl TI_flags(%rcx),%edx
andl %edi,%edx
jnz int_careful
andl $~TS_COMPAT,TI_status(%rcx)
jmp retint_swapgs
int_careful:
bt $TIF_NEED_RESCHED,%edx
jnc int_very_careful
TRACE_IRQS_ON
ENABLE_INTERRUPTS(CLBR_NONE)
pushq %rdi
CFI_ADJUST_CFA_OFFSET 8
call schedule
popq %rdi
CFI_ADJUST_CFA_OFFSET -8
DISABLE_INTERRUPTS(CLBR_NONE)
TRACE_IRQS_OFF
jmp int_with_check
int_very_careful:
TRACE_IRQS_ON
ENABLE_INTERRUPTS(CLBR_NONE)
int_check_syscall_exit_work:
SAVE_REST
testl $_TIF_WORK_SYSCALL_EXIT,%edx
jz int_signal
pushq %rdi
leaq 8(%rsp),%rdi # &ptregs -> arg1
call syscall_trace_leave
popq %rdi
andl $~(_TIF_WORK_SYSCALL_EXIT|_TIF_SYSCALL_EMU),%edi
jmp int_restore_rest
int_signal:
testl $_TIF_DO_NOTIFY_MASK,%edx
jz 1f
movq %rsp,%rdi # &ptregs -> arg1
xorl %esi,%esi # oldset -> arg2
call do_notify_resume
1: movl $_TIF_WORK_MASK,%edi
int_restore_rest:
RESTORE_REST
DISABLE_INTERRUPTS(CLBR_NONE)
TRACE_IRQS_OFF
jmp int_with_check
END(system_call)
retint_swapgs:
DISABLE_INTERRUPTS(CLBR_ANY)
TRACE_IRQS_IRETQ
SWAPGS
jmp restore_args
restore_args:
RESTORE_ARGS 0,8,0 ;; 恢复参数
irq_return:
INTERRUPT_RETURN ;; INTERRUPT_RETURN=iretq 返回
其中的SAVE_ARS
和SAVE_REST
用于保存如下结构:
struct pt_regs {
/* SAVE_REST_END */
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long bp;
unsigned long bx;
/* arguments: non interrupts/non tracing syscalls only save upto here SAVE_ARGS_END*/
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long ax;
unsigned long cx;
unsigned long dx;
unsigned long si;
unsigned long di;
unsigned long orig_ax;
/* end of arguments SAVE_ARGS_START */
/* cpu exception frame or undefined */
unsigned long ip;
unsigned long cs;
unsigned long flags;
unsigned long sp;
unsigned long ss;
/* top of stack page */
};
快速系统调用
系统调用是高频的操作,通过中断进行系统调用无疑会有一些没必要的损耗,因此处理器添加了专用功能加速系统调用,在Intel下相关的有两对指令syscall/sysret与sysenter与sysexit,它们都用于从Ring3快速进入Ring0.
syscall/sysret
它只支持IA-32e的64bit模式,且激活该功能:
IF (CS.L ≠ 1 ) or (IA32_EFER.LMA ≠ 1) or (IA32_EFER.SCE ≠ 1)
(* Not in 64-Bit Mode or SYSCALL/SYSRET not enabled in IA32_EFER *)
THEN #UD; FI;
使用前需要先设置如下MSRs,IA32_STAR的47:32存放内核态的CS/SS(堆栈段描述符需要紧接着代码段描述符放置,即&SS=&CS+8),63:48存放用户态的CS/SS,IA32_LSTAR存放入口点的地址,IA32_FMASK存放RFLAGS的掩码:
在执行syscall时,硬件会把用户态的下一条指令的地址存入RCX,RFLAGS存入R11,并从MSRs中加载RIP/CS/SS,计算RFLAGS,不过注意64位并不会从GDT/LDT中加载CS/SS对应的段描述符,CS/SS中隐藏部分的数据是固定值(0x00:0xFFFFF...)。另外可见此处并没有存储RSP的值,因此若需要则由软件自行保存。 返回时使用sysret指令,它从RCX与R11恢复RFLAGS和RIP,注意尽管它本身只能在64Bit模式下执行,但它支持返回到兼容模式,此时寄存器只赋值低位,并设置段寄存器的对应标志。
sysenter/sysexit
这对指令在开启保护模式后即可使用(保护模式/虚拟8086模式/兼容模式/64Bit模式),且在切换时不改变位数,可通过如下方式判断是否处理器支持:
IF CPUID SEP bit is set
THEN IF (Family = 6) and (Model < 3) and (Stepping < 3)
THEN
SYSENTER/SYSEXIT_Not_Supported; FI;
ELSE
SYSENTER/SYSEXIT_Supported; FI;
FI;
使用前也需要设置一些MSRs,如下表:
IA32_SYSENTER_EIP为系统调用的入口地址,IA32_SYSENTER_ESP保存内核栈的栈顶(实际上不一定是栈顶,只要内核代码能通过它获取到栈顶即可),IA32_SYSENTER_CS的15:0为内核态的CS,此时内核态与用户态的CS与SS描述符是连续存放的,该值不能为0:
KernelCS.Selector := IA32_SYSENTER_CS[15:0]
KernelSS.Selector := KernelCS.Selector + 8;
IF operand size is 64-bit (* Operating system provides CS; RPL forced to 3 *)
THEN UserCS.Selector := IA32_SYSENTER_CS[15:0] + 32;
ELSE UserCS.Selector := IA32_SYSENTER_CS[15:0] + 16;
FI;
UserSS.Selector := UserCS.Selector + 8;
在执行sysenter时,会从MSRs加载对应值,并把FLAGS的VM与IF位清除,来确保处于保护模式并禁用中断,注意它不会自动保存任何值,若需要软件则由软件实现。在返回时,它和进入并不对称,它会从RCX和RDX分别加载RSP和RIP,并根据操作位数从IA32_SYSENTER_CS加载用户态的CS与SS。
注:这里的操作数大小是根据REX指令前缀来识别的,如REX.W置位表示64位操作数。
vsyscall/vdso
int80速度慢,而sysenter/syscall又不一定支持,似乎libc只能在兼容性与效率间做取舍,但实际操作系统知道后者是否可用,因此它向用户态导出了syscall(),libc直接调用此处的syscall就可知道,之前的实现是vsyscall,它作为一个地址固定的内存页被直接映射到用户空间:
root@bm:~# cat /proc/1/maps | grep vsyscall
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
该页里实现了3个函数:
#define VSYSCALL_ADDR_vgettimeofday 0xffffffffff600000
#define VSYSCALL_ADDR_vtime 0xffffffffff600400
#define VSYSCALL_ADDR_vgetcpu 0xffffffffff600800
显然这个固定的地址,且里面还有syscall指令用来做rop的gadget很胖,于是它就被废弃了,要用的话还是模拟的方式运行...
(gdb) x/10i 0xffffffffff600000
0xffffffffff600000: mov $0x60,%rax
0xffffffffff600007: syscall
0xffffffffff600009: retq
而vdso
是更现代的方式,它会以动态库的形式被注入到用户进程的内存空间,且地址是随机的:
root@bm:~# cat /proc/self/maps | grep vdso
7ffca2272000-7ffca2273000 r-xp 00000000 00:00 0 [vdso]
root@bm:~# cat /proc/1/maps | grep vdso
7fff46d9c000-7fff46d9d000 r-xp 00000000 00:00 0 [vdso]
它在sysenter_setup
中初始化:
int __init sysenter_setup(void)
{
void *syscall_page = (void *)get_zeroed_page(GFP_ATOMIC);
const void *vsyscall;
size_t vsyscall_len;
vdso32_pages[0] = virt_to_page(syscall_page);
#ifdef CONFIG_X86_32
gate_vma_init();
#endif
/* 如下根据CPUID判断是否支持如下指令,并指定对应的镜像
X86_FEATURE_SYSCALL32 (3*32+14)
X86_FEATURE_SYSENTER32 (3*32+15)
*/
if (vdso32_syscall()) {
vsyscall = &vdso32_syscall_start;
vsyscall_len = &vdso32_syscall_end - &vdso32_syscall_start;
} else if (vdso32_sysenter()){
vsyscall = &vdso32_sysenter_start;
vsyscall_len = &vdso32_sysenter_end - &vdso32_sysenter_start;
} else {
vsyscall = &vdso32_int80_start;
vsyscall_len = &vdso32_int80_end - &vdso32_int80_start;
}
// 将要加载的镜像拷贝到syscall_page区域后,对该区域进行ELF解析,此时会把它加载到固定位置
memcpy(syscall_page, vsyscall, vsyscall_len);
relocate_vdso(syscall_page);
return 0;
}
并在程序初始化load_elf_binary
中被插入:
int arch_setup_additional_pages(struct linux_binprm *bprm, int uses_interp)
{
if (vdso_enabled == VDSO_DISABLED)
return 0;
compat = (vdso_enabled == VDSO_COMPAT);
map_compat_vdso(compat);
// 获取要被装入的线性区
if (compat)
addr = VDSO_HIGH_BASE;
else {
addr = get_unmapped_area(NULL, 0, PAGE_SIZE, 0, 0);
}
current->mm->context.vdso = (void *)addr;
if (compat_uses_vma || !compat) {
// 安装线性区,将vdso32_pages映射到该区域
ret = install_special_mapping(mm, addr, PAGE_SIZE,
VM_READ|VM_EXEC|
VM_MAYREAD|VM_MAYWRITE|VM_MAYEXEC|
VM_ALWAYSDUMP,
vdso32_pages);
}
// 设置返回地址
current_thread_info()->sysenter_return = VDSO32_SYMBOL(addr, SYSENTER_RETURN);
}
其在支持syscall时如下:
__kernel_vsyscall:
push %ebp
movl %ecx, %ebp
syscall
movl $__USER32_DS, %ecx
movl %ecx, %ss
movl %ebp, %ecx
popl %ebp
ret
再看syscall_init,它设置了进入的入口,和中断方式一致,不再赘述:
void syscall_init(void)
{
wrmsrl(MSR_STAR, ((u64)__USER32_CS)<<48 | ((u64)__KERNEL_CS)<<32);
wrmsrl(MSR_LSTAR, system_call);
wrmsrl(MSR_CSTAR, ignore_sysret);
#ifdef CONFIG_IA32_EMULATION
syscall32_cpu_init();
#endif
wrmsrl(MSR_SYSCALL_MASK, X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|X86_EFLAGS_IOPL);
}
参考
- 从用户态到内核态的切换 -- JasonLe
- Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide
- Linux 内核系统调用 第三节 vsyscalls 和 vDSO -- 0xA0