用户态和内核态
intel x86 CPU有4种不同的执行级别,分别为0,1,2,3
按照intel的设想,内核运行在Ring0级别,驱动运行在Ring1和Ring2级别,应用运行在Ring3级别
linux系统中,只使用了0和3两个级别,分别对应内核态和用户态,使用寄存器CS:EIP
的指向范围区分
- 用户态下,只能访问
0x00000000
~0xBFFFFFFF
的地址空间 - 内核态下,可以指向任意地址,
0xC0000000
只能在内核态下访问
在32位x86机器上有4GB进程地址空间,MMU负责逻辑地址和物理地址的转换
| |内核空间1GB
| |<- 0xc0000000
| |
| |用户空间3GB
| |<- 0x00000000
中断
int
指令触发中断机制(包括系统调用)
中断触发时,会在堆栈上保存寄存器的值,保存用户态栈顶地址、当时的状态字和CS:EIP
值
同时将内核态的栈顶地址、内核态的状态字载入CPU寄存器中,并将ES:EIP
寄存器指向中断处理程序入口(对系统调用来说是system_call
)
int
指令触发后,进入中断处理程序,开始执行内核代码SAVE_ALL
保存现场
中断处理程序结束后,执行恢复现场操作,在3.18.6的x86-32内核中,restore_all
和INTERRUPT_RETURN
(iret
)负责将中断时保存的用户态寄存器值恢复到当前CPU
系统调用
操作系统管理硬件,提高系统安全性,使得用户程序具有可移植性
-
Linux下系统调用通过触发
int 0x80
中断完成
中断保存了用户态CS:EIP
的值,及当前堆栈段寄存器的栈顶,将EFLAGS
寄存器的值保存到内核堆栈中
同时将当前的中断信号或系统调用和终端服务例程的入口加载在CS:EIP
中,将当前的堆栈段SS:ESP
也加载到CPU中 -
触发系统调用及参数传递方式
当Linux通过执行int 0x80
触发系统调用时(Intel Pentium II还引入sysenter
指令,Linux2.6后支持),进入内核,开始执行中断向量128
对应的中断服务例程system_call
用户态进程需要指明调用哪个系统调用,通过EAX
寄存器传递系统调用号参数来区分
除了系统调用号外,系统调用可能需要传递其他参数,由于系统调用从用户态切换到内核态,使用不同的堆栈空间,无法通过压栈的方式传递参数,而是通过寄存器传递参数,参数个数若超过寄存器数量,可将某个寄存器作为指针指向内存,此时可以通过内存传递更多参数
使用库函数libc和C嵌入汇编代码触发同一个系统调用
使用库函数libc
#include<stdio.c>
#include<time.c>
int main(){
time_t tt; // int
struct tm* t;
tt= time(NULL);
t= localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d\n",
t->tm_year+1900, t->tm_mon, t->tm_mda,
t->tm_hour, t->tm_min, t->tm_sec);
return 0;
}
编译gcc -m32 time.c -o time
使用C嵌入汇编代码
#include<stdio.h>
#include<time.h>
int main(){
time_t tt;
struct tm* t;
// 使用汇编代替 time(NULL)
asm volatile(
"mov $0, %%ebx\n\t"
"mov $0xd, %%eax\n\t" // 传递系统调用号 13
"int $0x80\n\t" // 触发系统调用
"mov %%eax, %0\n\t"
: "=m" (tt)
);
t= localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d\n",
t->tm_year+1900, t->tm_mon, t->tm_mda,
t->tm_hour, t->tm_min, t->tm_sec);
return 0;
}
编译gcc time-asm.c -o time-asm -m32
含有两个参数的系统调用示例
重命名函数rename()
,在内核中对应的系统调用内核处理函数sys_rename()
,系统调用号为38
asmlinkage long sys_rename(const char __user* oldname, const char __user *newname);
使用库函数libc
#include<stdio.h>
int main(){
int ret;
char* oldname= "hello.c";
char* newname= "newhello.c";
ret= rename(oldname, newname);
if(ret==0){
printf("renamed successfully\n");
}else{
printf("unable to rename the file\n");
}
return 0;
}
使用C嵌入汇编代码
#include<stdio.h>
int main(){
int ret;
char* oldname= "hello.c";
char* newname= "newhello.c";
asm volatile(
"movl %2, %%ecx\n\t"
"movl %1, %%ebx\n\t"
"movl $0x26, %%eax\n\t"
"int $0x80"
: "=a" (ret)
: "b" (oldname), "c" (newname)
);
if(ret==0){
printf("renamed successfully\n");
}else{
printf("unable to rename the file\n");
}
return 0;
}
EAX用来传递系统调用号,其他参数按顺序赋给EBX,ECX,EDX,ESI,EDI,EBP
将系统调用号38存入EAX寄存器,将oldname存入EBX寄存器,将newname存入ECX寄存器,由于参数是字符串,实际传递的是指针变量
通过执行int $0x80
指令来执行系统调用,进入内核态,system_call()
根据系统调用号在系统调用列表中查找对应的系统调用内核函数sys_rename()
,执行完将结果存入EAX寄存器,再将EAX寄存器的值传给ret
使用gdb跟踪MenuOS系统调用过程
将time
和time-asm
命令集成到MenuOS中
rm -rf menu
git clone https://github.com/mengning/menu.git
make rootfs
通过在test.c:main()
中增加两行代码,来给MenuOS增加两个命令
MenuConfig("time");
MenuConfig("time-asm");
使用gdb跟踪系统调用内核函数
调试time命令所用到的系统调用内核处理函数
cd ..
qemu-system-i386 -kernel linux-3.18.6/arch/x86/boot/bzImage -initrd rootfs.img -S -s
启动内核后,先启动gdb,再加载内核
(gdb) file vmlinux # cd linux-3.18.6
(gdb) target remote:1234
time()
系统调用是系统调用号13
对应的内核处理函数,即sys_time
(gdb) b sys_time
(gdb) c
此时,在已经启动的 MenuOS 中执行time-asm
命令,程序会停在sys_time
处
sys_time
位于kernel/time/time.c,使用宏实现,所以无法直接看到sys_time
单步执行,会进入get_seconds()
,位于kernel/time/timekeeping.c
使用gdb的finish
命令将该函数执行完,再单步执行,直到return i
,即获取到系统时间
若继续单步调试,会出现cannot find bounds of current function
这里的代码比较特殊,不好调试,因为这时会返回到system_call位置的汇编代码,完成恢复现场并返回到用户态
当执行int 0x80
时,实际上会跳转到system_call()
,可以直接将断点设在该处
该函数是汇编代码,位于arch/x86/kernel/entry_32.S#490
当执行time-asm
命令时,并不能在system_call()
处停下,该函数不是一个正常的C函数,gdb不支持跟踪汇编代码
ENTRY(system_call)
system_call还有一个函数原型声明,是一段汇编代码的起点,内部没有遵守函数调用堆栈机制,所有gdb不能跟踪
该段代码是理解Linux运作过程的关键,系统调用作为一种特殊的中断,其执行过程可以类推到其他中断信号触发的中断服务处理过程,下面分析:系统调用time在内核代码中的处理过程
time -> system_call -> sys_time
system_call还涉及一个进程调度时机
中断向量0x80和system_call中断服务程序入口
start_kernel()
调用了trap_init()
,该函数调用了set_system_trap_gate()
#ifdef CONFIG_X86_32
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
set_bit(SYSCALL_VECTOR, used_vectors);
#endif
这是trap_init()
中的一段代码,位于arch/x86/kernel/traps.c#838
其中,system_call
被声明为一个函数,是汇编代码的入口
通过set_system_trap_gate()
绑定中断向量0x80和system_call
中断服务程序入口,一旦执行int 0x80
则自动跳转到system_call
SYSCALL_VECTOR
时系统调用中断向量0x80,位于arch/x86/include/asm/irq_vectors.h#49
#define IA32_SYSCALL_VECTOR 0x80
#ifdef CONFIG_X86_32
#define SYSCALL_VECTOR 0x80
#endif
后面再分析int
指令执行或中断信号发生时,CPU的具体行为
system_call汇编代码和系统调用内核处理函数
system_call和其他中断一样,也有保存现场SAVE_CALL和恢复现场restore_all
代码中的sys_call_table
是一个系统调用表,EAX寄存器传递系统调用号,在调用时会根据该值调用对应的系统调用内核处理函数,在退出时会进入syscall_exit_work,此时有一个进程调度时机
system_call
ENTRY(system_call)
RING0_INT_FRAME
ASM_CLAC
pushl_cfi %eax # 保存系统调用号
SAVE_ALL # 保存现场,将寄存器值压栈
GET_THREAD_INFO(%ebp) # ebp用于存放当前进程thread_info结构的地址
testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(nr_syscalls), %eax # 检查系统调用号,系统调用号应小于NR_syscalls
jae syscall_badsys # 不合法,跳转到异常处理
syscall_call:
call *sys_call_table(, %eax, 4) # 通过系统调用号在表中找到对应的系统调用内核处理函数
movl %eax, PT_EAX(%esp) # 保存返回值到栈
syscall_exit:
testl $_TIF_ALLWORK_MASK,%ecx # 检查是否有任务需要处理
jne syscall_exit_work # 若需要,进入syscall_exit_work,这里是最常见的进程调度时机
restore_all:
TRACE_ITQS_IRET # 恢复现场
irq_return:
INTERRUPT_RETURN # iret
在syscall_call中判断当前任务是否需要处理,若需要,进入syscall_exit_work,这里是最常见的进程调度时机
sys_call_table(,%eax,4) 通过系统调用号在表中找到对应的系统调用内核处理函数
系统调用表中每个表项占4字节,所以先将系统调用号(EAX寄存器)乘4,再加上表起始地址,即得到对应的系统调用内核处理函数指针
sys_call_table分派表由一段脚本根据arch/x86/syscalls/syscall_32.tbl自动生成
整体上理解系统调用内核处理过程
ENTRY(system_call) SAVE_ALL cmpl $(nr_syscalls),%eax 否-> syscall_badsys 是 call*sys_call_table(,%eax,4) movl %eax,PT_EAX(%esp) syscall_exit 否-> restore_all -> iret 是 syscall_exit_work work_pending work_resched restore_all iret流程图中涉及system_call_exit
内部处理,大致过程是需要跳转到work_pending
,里面有work_notifysig
处理信号,还有work_resched
需要重新调度,这里是进程调度时机点call_schedule
,调度结束会跳转到restore_all
恢复现场返回系统调用到用户态,位于arch/x86/kernel/entry_32.S#593
ENTRY(system_call)
RING0_INT_FRAME # can't unwind into user space anyway
ASM_CLAC
pushl_cfi %eax # save orig_eax
SAVE_ALL
GET_THREAD_INFO(%ebp)
# system call tracing in operation / emulation
testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
jnz syscall_trace_entry
cmpl $(NR_syscalls), %eax
jae syscall_badsys
syscall_call:
call *sys_call_table(,%eax,4)
syscall_after_call:
movl %eax,PT_EAX(%esp) # store the return value
syscall_exit:
LOCKDEP_SYS_EXIT
DISABLE_INTERRUPTS(CLBR_ANY) # make sure we don't miss an interrupt
# setting need_resched or sigpending
# between sampling and the iret
TRACE_IRQS_OFF
movl TI_flags(%ebp), %ecx
testl $_TIF_ALLWORK_MASK, %ecx # current->work
jne syscall_exit_work
restore_all:
TRACE_IRQS_IRET
restore_all_notrace:
#ifdef CONFIG_X86_ESPFIX32
movl PT_EFLAGS(%esp), %eax # mix EFLAGS, SS and CS
# Warning: PT_OLDSS(%esp) contains the wrong/random values if we
# are returning to the kernel.
# See comments in process.c:copy_thread() for details.
movb PT_OLDSS(%esp), %ah
movb PT_CS(%esp), %al
andl $(X86_EFLAGS_VM | (SEGMENT_TI_MASK << 8) | SEGMENT_RPL_MASK), %eax
cmpl $((SEGMENT_LDT << 8) | USER_RPL), %eax
CFI_REMEMBER_STATE
je ldt_ss # returning to user-space with LDT SS
#endif
restore_nocheck:
RESTORE_REGS 4 # skip orig_eax/error_code
irq_return:
INTERRUPT_RETURN
从系统调用处理过程的入口开始,SAVE_ALL保存现场,然后找到syscall_badsys和sys_call_table
call*sys_call_table(,%eax,4)就是调用了系统调用的内核处理函数,之后restore_all和INTERRUPT_RETURN(iret)用于恢复现场并返回系统调用到用户态,这个过程中可能会执行syscall_exit_work,里面有work_pending,其中的work_notifysig用来处理信号,work_pending还有可能调用schedule