基于MIT6.828 分析 linux 从用户态到内核态用户栈和
基于MIT6.828课程的Lab3,我们来分析一下程序从用户态到内核态中用户栈内核栈切换的过程。
你需要具备:
- MIT6.828 课程的基本了解,环境搭建和代码运行
- 汇编基本知识
- gdb 基本调试
首先函数调用栈是这样的:
i386_init --> env_run --> env_pop_tf,env_po_tf 中 iret 模拟中断返回进入用户态,执行 hello.c 代码,
umain --> lib/cprintf --> vcprintf --> lib/systemcall/sys_cputs --> syscall,systemcall 中使用 int 0x30 陷入内核态,注意这里系统调用号使用的是 0x30 而不是linux中的 0x80,这里对内存管理,程序加载不做分析。
查看 hello.asm 反汇编代码,找到中断最后一条指令位置 0x800aae , 并记住下一条指令的位置 0x800ab0,等会会用到这个值,使用gdb打断点
(gdb) b *0x800aae
然后 (gdb) c
继续运行到断点位置。
void
sys_cputs(const char *s, size_t len)
{
800a97: 55 push %ebp
800a98: 89 e5 mov %esp,%ebp
800a9a: 57 push %edi
800a9b: 56 push %esi
800a9c: 53 push %ebx
//
// The last clause tells the assembler that this can
// potentially change the condition codes and arbitrary
// memory locations.
asm volatile("int %1\n"
800a9d: b8 00 00 00 00 mov $0x0,%eax
800aa2: 8b 4d 0c mov 0xc(%ebp),%ecx
800aa5: 8b 55 08 mov 0x8(%ebp),%edx
800aa8: 89 c3 mov %eax,%ebx
800aaa: 89 c7 mov %eax,%edi
800aac: 89 c6 mov %eax,%esi
800aae: cd 30 int $0x30
void
sys_cputs(const char *s, size_t len)
{
syscall(SYS_cputs, 0, (uint32_t)s, len, 0, 0, 0);
}
800ab0: 5b pop %ebx
800ab1: 5e pop %esi
800ab2: 5f pop %edi
800ab3: 5d pop %ebp
800ab4: c3 ret
00800ab5 <sys_cgetc>:
此时使用 (gdb) i r
查看各寄存器的值
eax 0x0 0
ecx 0xd 13 <-- 此时保存的是 “hello,world\n” 字符串长度
edx 0xeebfde88 -289415544 <-- 此时保存的是 “hello,world\n” 字符串
ebx 0x0 0
esp 0xeebfde54 0xeebfde54 <-- 用户栈
ebp 0xeebfde60 0xeebfde60 <-- 用户栈
esi 0x0 0
edi 0x0 0
eip 0x800aae 0x800aae <-- eip 在用户代码
eflags 0x92 [ AF SF ]
cs 0x1b 27 <--- 用户态 代码段
ss 0x23 35 <--- 用户态 数据段
ds 0x23 35 <--- 用户态 数据段
es 0x23 35
fs 0x23 35
gs 0x23 35
系统调用最多能够传递5个参数,分别是 ecx,edx,ebx,edi,esi,因为函数调用参数是从右到左压栈的,所以 sys_cputs 中 0x8(%ebp) 保存的是 “hello,world\n” 字符串,0xc(%ebp) 保存了字符串的长度 13,通过 gdb 可以验证
(gdb) p (char *)0xeebfde88 $1 = 0xeebfde88 "hello, world\n"
使用 (gdb) si
继续运行 int 0x30 指令,进入内核态。再使用 (gdb) i r
查看各寄存器的值,然后我们一个一个分析各寄存器变化情况。
=> 0xf01036aa <t_syscall+2>: push $0x30
0xf01036aa in t_syscall () at kern/trapentry.S:68
68 TRAPHANDLER_NOEC(t_syscall, T_SYSCALL)
eax 0x0 0
ecx 0x1a 26
edx 0xeebfde88 -289415544
ebx 0x0 0
esp 0xefffffe8 0xefffffe8 <——- 内核栈
ebp 0xeebfde60 0xeebfde60
esi 0x0 0
edi 0x0 0
eip 0xf01036aa 0xf01036aa <t_syscall+2> <——— 跳转到内核代码
eflags 0x92 [ AF SF ]
cs 0x8 8 <——— 内核态数据段
ss 0x10 16 <——— 内核态代码段
ds 0x23 35
es 0x23 35
fs 0x23 35
gs 0x23 35
发现此时栈的地址已经变成内核栈的地址了,可通过 memlayout.h 定义的内存分布查看内核栈使用地址 KSTACKTOP (0xeffffffc) ,栈的地址是向下增长的, (gdb) x/10x 0xefffffe8
查看内核栈的数据
(gdb) x/10x 0xefffffe8
0xefffffe8: 0x00000000 0x00800ab0 0x0000001b 0x00000092
0xeffffff8: 0xeebfde54 0x00000023 0xf000ff53 0xf000ff53
0xf0000008: 0xf000e2c3 0xf000ff53
0xeffffffc 为内核栈的栈底,进入内核后,cpu自动将用户态的ss (0x23) 和用户栈esp (0xeebfde54) 压入内核栈,
再将 eflags (0x92),cs (0x1b) ,eip 的下一条指令(0x800ab0) 入栈(在上文中提到过这个地址)才能实现从内核态返回到用户态会从这个地址继续执行,最后把错误码入栈,系统调用没有错误码,所以入栈 0x0。到此从用户态到内核态的过程已经完成。
接下来分析一下进入内核态后如何保存各寄存器值的。
进入内核后会跳转到 trapentry.S 文件去执行,中断向量的设置这里不做讨论,查看 trapentry.S 关键代码,之后栈的操作都是内核栈
TRAPHANDLER_NOEC(t_syscall, T_SYSCALL) <--- 进入后执行这句代码
#define TRAPHANDLER(name, num) \
.globl name; /* define global symbol for 'name' */ \
.type name, @function; /* symbol type is function */ \
.align 2; /* align function definition */ \
name: /* function starts here */ \
pushl $(num); \ <--- 1. 将调用号 0x30 压入内核栈
jmp _alltraps
/*
* Lab 3: Your code here for _alltraps
*/
// tf_ss,tf_esp,tf_eflags,tf_cs,tf_eip,tf_err 在中断发生时由处理器压入,所以现在只需要压入剩下寄存器(%ds,%es,通用寄存器)
_alltraps:
pushl %ds
pushl %es
pushal
movl $GD_KD, %eax <-- 修改内核数据段
movw %ax, %ds
movw %ax, %es
push %esp // 压入trap()的参数tf,%esp 指向 Trapframe 结构的起始地址
call trap // 去统一处理
首先调用c函数,参数入栈所以压入 %esp ,为什么 %esp 指向 Trapframe 结构的起始地址呢。查看 Trampframe 定义的结构
struct PushRegs {
/* registers as pushed by pusha */
uint32_t reg_edi;
uint32_t reg_esi;
uint32_t reg_ebp;
uint32_t reg_oesp; /* Useless */
uint32_t reg_ebx;
uint32_t reg_edx;
uint32_t reg_ecx;
uint32_t reg_eax;
} __attribute__((packed));
struct Trapframe {
struct PushRegs tf_regs;
uint16_t tf_es;
uint16_t tf_padding1;
uint16_t tf_ds;
uint16_t tf_padding2;
uint32_t tf_trapno;
/* below here defined by x86 hardware */
uint32_t tf_err;
uintptr_t tf_eip;
uint16_t tf_cs;
uint16_t tf_padding3;
uint32_t tf_eflags;
/* below here only when crossing rings, such as from user to kernel */
uintptr_t tf_esp;
uint16_t tf_ss;
uint16_t tf_padding4;
} __attribute__((packed));
我们从 Trapframe 结构体最后一个变量 uint16_t tf_padding4; 从后往前看,可以发现从后往前看其实就是程序进入内核态压栈的顺序,因为栈是向下增长的,所以压栈完后 %esp 是较低的地址,将这个地址指针转换为 Trapframe 结构体的指针,Tramfram 各变量即从栈顶到栈底的顺序,之后就可以使用 Trapframe 方便的使用各个参数了。
之后 trap() 函数中会把 Tramframe 中的值保存在 curenv->env_tf = *tf;
并且 last_tf 指向 curenv->env_tf ,当trap_dispatch()返回后,trap() 会调用 env_run(curenv);
将 curenv->env_tf 结构中保存的寄存器快照重新恢复到寄存器中,这样又会回到用户程序系统调用之后的那条指令运行,并且寄存器 eax 中保存了系统调用返回值。