如图所示,给了一段函数嵌套的c代码和汇编代码,汇编代码是64位的,初始化条件是rbp和rsp都指向x。
问题1:求执行c代码过程中,x-8,x-16,x-24,x-32,x-40,x-48,x-56地址中存放的数据是什么?
问题2: main函数执行完毕后,rsp和rbp指向哪里,eax寄存器中存放的数是多少。
掌握以下命令的替换可以很快写出。
pushl %eax //eax的值压到栈顶
等价于
subl $4,%esp //esp减4
movl %eax,(%esp)//eax的值放到esp所指向的堆栈
popl %eax//栈顶取一个数,放到eax寄存器
等价于
movl (%esp),%eax //栈顶的数值放到eax寄存器里
addl $4, %esp //栈向上回退了一个存储单元的位置
call f
等价于
pushl %eip () //执行的下一条指令地址放入堆栈
movl f, %eip () //f送入eip中接使用和修改。
((last) = __switch_to_asm((prev), (next)));
ENTRY(__switch_to_asm)
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* switch stack */
movq %rsp, TASK_threadsp(%rdi) //保存旧进程的栈顶
movq TASK_threadsp(%rsi), %rsp //恢复新进程的栈顶
/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
jmp __switch_to
END(__switch_to
连续的push和pop操作是否发生在同一个进程的内核栈中
load cs:rip(entry of a specific ISR)
即跳转到中断处理程序入口。swapgs
指令保存现场,可以理解CPU通过swapgs指令给当前CPU寄存器状态做了一个快照。rsp point to kernel stack
,加载当前进程内核堆栈栈顶地址到RSP寄存器。save cs:rip/ss:rsp/rflags
:将当前CPU关键上下文压入进程X的内核堆栈,快速系统调用是由系统调用入口处的汇编代码实现的。switch_to
关键的进程上下文切换等。switch_to
调用了__switch_to_asm
做关键的进程上下文切换。将当前进程X的内核堆栈切换到进程调度算法选出来的next进程(进程Y)的内核堆栈,并完成eip的状态切换,之后运行Y。iret - pop cs:rip/ss:rsp/rflags
,从Y的内核堆栈中弹出(3)中对应的压栈内容,完成中断上下文的切换,即从Y的内核态返回到Y的用户态。 struct irq_desc irq_desc[]{
struct irq_chip *chip{
// 底层的硬件访问函数
};
struct irqaction action{
// 链表
// 用户注册的中断处理函数
}
}
设备驱动程序的主要构成(写出主要数据结构),注册以及管理运行方式。
组成
头文件声明
模块许可声明(开源许可协议 GPLv2)
初始化、清理函数声明
文件操作及其他内容(描述性定义)
注册
执行注册函数之前,内核向系统申请设备号。注册(在内核态下完成的)把驱动程序与对应的设备文件连接,使设备文件发出的系统调用由内核转化为驱动程序中对应的函数,同时分配一个新的device_driver描述符,对应到设备文件上。通过设备号(major number和minor number)来标识和管理字符设备,内核维护一个字符设备表,记录了每个字符设备驱动程序的信息,来管理设备的注册、分配和释放设备号等
注册时机
如果驱动被静态编译进内核:内核初始化阶段
作为内核模块编译: 装入模块时注册、卸载模块时销毁
注册过程
内核中存在一个数组chrdev[]来保存所有字符设备驱动程序信息
内核中每个字符设备都对应一个 cdev 结构的变量
初始化(静态分配空间、动态申请结构体空间)
字符设备注册函数:cdev_add(cdev结构指针,起始设备编号,设备编号范围)
执行注册函数之前:向系统申请设备号
register_chrdev_region()
:用于已知起始设备设备号的情况 或 alloc_chrdev_region()
:用于设备号位置,自动避开设备号重复的冲突
撤销函数: cdev_del(cdev结构指针)
撤销函数执行之后: unregister_chrdev_region():释放原先申请的设备号
设备管理
内核通过设备号(major number和minor number)来标识和管理字符设备,维护一个字符设备表,记录了每个字符设备驱动程序的信息,以此来管理设备的注册、分配和释放设备号等
访问控制
驱动程序通过检查用户的访问权限、操作类型、设备状态等对设备的访问进行控制,以确保合理的权限和安全性。
VFS(Virtual File System,虚拟文件系统)实现文件系统抽象,用于统一管理不同类型的文件系统。主要由一组标准的文件操作构成,以系统调用的形式提供给用户,使得不同类型的文件系统能够以一致的方式被访问和操作。
其中包括以下主要数据结构:
/*
超级块 super_block
索引节点 inode
文件对象 file
目录项 dentry
*/
• super_block
(超级块):用于描述文件系统的整体信息(文件系统类型、挂载点、inode表的位置等)
• inode
(索引节点):文件或目录在文件系统中的元数据。每个文件都对应一个inode结构,包含文件的属性(如权限、大小、时间戳等)和指向数据块的指针。
• dentry
(目录项):建立文件路径和inode之间的映射关系。包含目录项的名称、inode指针和目录项的状态信息。
• file
(文件对象):表示进程打开的文件或目录。包含指向相关inode和dentry的指针,以及访问模式、位置指针等。
• file_operations
(文件操作集合:函数指针结构体,使得不同类型的文件系统能够以一致的方式被访问和操作。对文件的各种操作,如读取、写入、打开、关闭等。
进程相关的文件系统的主要数据结构
struct task_struct{
struct fs_struct *fs{
struct dentry *root;
struct dentry *pwd;
}; /* 文件系统信息:进程当前目录及工作目录等 */
struct files_struct *files { /* 当前打开的文件信息 */
struct file *fd_array[NR_OPEN_DEFAULT];{/* 文件对象数组,索引是文件描述符 */
struct file_operations f_op;
};
};
};
• fs_struct
:存储与进程相关的文件系统信息,包括当前工作目录、根目录等。
• files_struct
:进程打开文件的描述符表,维护打开文件的信息,包括文件描述符号码、文件对象指针等。
• fdtable
:存储进程的files_struct的实际数据。
• file
:表示打开的文件。文件操作集合指针指向相应文件系统的操作函数。
• file_operations
:使得不同类型的文件系统能够以一致的方式被访问和操作。
// 任务结构体,又称为进程描述符
struct task_struct {
// 进程标识
int pid;
int state;
stack;
// 进程调度策略:基于动态优先权的算法
struct policy {
// 实时进程
/* SCHED_RR 时间片轮转 */
/* SCHED_FIFO 先进先出 */
// 普通进程
/* SCHED_OTHER 时间片轮转 */
}
int priority; // 静态优先级(不可见,只能通过接口修改)、动态优先级(由静态优先级调整而来)
int rt_priority; // 实时进程的优先级(1-99)
int counter; // 表示进程还可以运行多久,初始=priority; 子进程会继承父进程一半剩余时间片
}
计时体系(Timing System)用于测量时间、延迟和时间间隔。
• 更新时间:通过系统调用(如gettimeofday()、clock_gettime()等),可以获取系统的实时时钟时间、系统启动时间等。
• 进程时间统计:通过系统调用(如clock()、clock_gettime()等),可以获取进程的用户态时间、系统态时间、执行时间等。
• 性能分析:借助计时功能,可以测量代码的执行时间,并根据结果进行优化和调整。
系统调用从用户态陷入内核态时,从用户态堆栈转换到内核态堆栈,把esp、eip、标志寄存器等保存到内核堆栈,保存现场。系统调用入口会通过调用号执行内核处理函数,最后恢复现场和将esp、eip、标志寄存器等从内核堆栈中恢复到对应寄存器中,并回到用户态,继续执行下一条指令。
为用户态进程与硬件交互提供了接口。系统调用具有以下功能和特性。
• 把用户从底层的硬件编程中解放出来。
• 极大地提高系统的安全性。
• 使用户程序具有可移植性。
init_task为第一个进程(0号进程)的进程描述符结构体变量,初始化是通过硬编码方式固定下来的。其他所有进程的初始化都是通过do_fork
复制父进程的方式初始化的。do_fork
调用copy_process()
复制父进程、获得pid、调用wake_up_new_task
将子进程加入就绪队列等待调度执行等
struct task_struct init_task
= {
#ifdef CONFIG_THREAD_INFO_IN_TASK
.thread_info = INIT_THREAD_INFO(init_task),
.stack_refcount = REFCOUNT_INIT(1),
#endif
.state = 0,
.stack = init_stack,
.usage = REFCOUNT_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO - 20,
0号进程初始化最后的rest_ init
通过kernel_thread
创建了1号和2号两个内核线程,实际上是复制0号进程,修改了进程pid等,1号是kernel_init
,是所有用户进程的祖先,在0号进程的基础上,修改一些信息并加载一个init可执行程序。另一个是kthreadd
,是所有内核线程的祖先,负责管理所有内核线程。
noinline void __ref rest_init(void)
{
…
pid = kernel_thread(kernel_init, NULL, CLONE_FS);
…
pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);
…
}
_do_fork
创建进程把当前进程的描述符等相关进程资源复制一份,从而产生一个子进程,并根据子进程的需要对复制的进程描述符做一些修改,然后把创建好的子进程放入运行队列。
fork是用户态创建子进程的系统调用接口。把当前进程复制了一份,两个进程执行相同的代码,只是父进程和子进程中的返回值不同。
fork创建了一个子进程,子进程复制父进程所有的进程信息,包括内核堆栈、进程描述符等,wake_up_new_task()
将子进程添加到就绪队列,使之有机会被调度执行,进程的创建工作就完成了,子进程就可以等待调度执行,子进程从ret_from_fork
开始执行。当子进程获得CPU开始运行时,从用户态空间来看,就是fork系统调用的下一条指令。但fork系统调用在子进程当中也是返回的,父进程正常fork系统调用返回到用户态,fork出来的子进程也要从内核里返回到用户态。
内核装载可执行程序的过程,实际上是执行一个系统调用execve。
fork在陷入内核态之后有两次返回,第一次返回到原来的父进程的位置继续向下执行,所以它稍微特殊一点。在子进程中fork也返回了一次,会返回到一个特定的点——ret_from_fork,通过内核构造的堆栈环境,它可以正常系统调用返回到用户态
可执行程序执行到execve时陷入内核态,在内核里面用do_execve加载可执行文件,把当前进程的可执行程序给覆盖掉。系统调用返回时,返回的是新的可执行程序。
进程调度时机就是内核调用schedule函数的时机
• 用户进程主动调用系统调用进入中断上下文,系统调用返回用户态之前进行进程调度。
• 内核线程或可中断的中断处理程序,执行过程中发生中断,在中断返回前进行进程调度。
• 内核线程主动调用schedule函数进行进程调度。
中断处理程序执行过程和主动调用schedule函数进行进程调度。
进程执行环境的切换:一是从就绪队列中选择一个进程(pick_next_task),由进程调度算法决定一个进程作为下一个进程(next);二是完成进程上下文切换context_switch,进程上下文包含了进程执行需要的所有信息,即用户地址空间(代码、数据、用户堆栈等),控制信息(进程描述符、内核堆栈等),CPU寄存器的值
类型
可重定位文件:中间文件,保存着代码和数据,由编译器和汇编器创建,一个源代码会生成一个可重定位文件。可以和其他文件一起来创建一个可执行文件或者动态链接库文件。
可执行文件:由多个可重定位文件结合生成,完成所有重定位工作和符号解析的文件(动态链接库符号是在运行时解析的),文件中保存着一个用来执行的程序。
动态链接库文件(共享目标文件):经过链接处理可以直接加载运行的库文件,是可以被可执行文件或其他动态链接库文件加载使用的库文件。
switch_to_asm将当前任务切换到新的任务:
(1) 保存旧任务的寄存器状态:包括通用寄存器(如rax、rbx等)、控制寄存器(如cr2、cr3等)以及其他特殊寄存器。
(2) 加载新任务的页表:
(3) 切换堆栈:将新任务的堆栈指针(存储在任务的rsp)加载到rsp寄存器中,切换到新任务的堆栈。
(4) 切换rip:通过将新任务的rip中的值加载到rip寄存器以继续执行新任务的代码。
关于rip切换的详细过程如下:
struct thread_struct
数据结构最关键的是sp和ip。sp用来保存进程上下文中的ESP寄存器状态,ip用来保存进程上下文中的EIP寄存器状态;
程序从源代码到可执行文件步骤大致分为:预处理、编译、汇编、链接。
• 汇编后形成的.o格式的文件是ELF格式文件了。生成的目标文件至少.text、.data和.bss。
• 链接将.o文件或库文件组合成为一个单一文件的过程,可被加载(或被复制)到内存中并执行。链接分为静态链接和动态链接,静态链接在链接时直接将需要的执行代码复制到最终可执行文件中。 动态链接在程序运行或加载时将将需要的动态库加载到内存中。
execve系统调用接口函数将命令行参数和环境变量传递给可执行程序的main函数。
解压引导程序的任务是将压缩的内核代码解压缩到内存中合适的位置,然后跳转到解压后的内核入口点开始执行内核代码。这个过程被称为"内核自我解压"。完成自我解压后,内核开始初始化操作,包括设备检测、驱动加载、内存管理单元设置等,并最终挂载根文件系统,运行Init进程。
6. 中断描述符表的内容和作用
7. 发生中断时,内核堆栈的变化
8. 软定时器的处理过程
9. vfs的作用,文件描述符,文件结构,文件操作函数的关系
感谢往届学长提供的参考
2020中科大软件学院linux操作系统分析期末考试题
中科大软件学院《linux操作系统分析》期末考试.md