内核同步和同步方法
内核同步
防止共享资源并发访问是因为如果有多个执行线程同时访问和操作数据,可能发生各线程之间相互覆盖共享数据的情况,造成被访问数据处于不一致态。
临界区是访问和操作共享数据的代码段,为了避免临界区中并发访问,必须保证这些代码原子地执行,即执行结束前不可被打断。
避免并发和防止竞争条件称为同步。
对于单个变量,内核提供的实现原子操作的借口可以防止他们被并发访问。对于数据结构,需要确保一次有且只有一个线程对数据结构进行操作,或者当另一个线程在对临界区标记时,禁止其他访问,即锁提供的机制。锁的使用是资源的、非强制的,它是采用原子操作实现的。
用户空间要进行同步是因为用户程序会被调度程序抢占和重新调度。
内核中可能造成并发执行的原因有:中断、软中断、tasklet、内核抢占、睡眠及与用户空间的同步、对称多处理。在中断处理程序中能避免并发访问的安全代码称作中断安全代码,在对称多处理器机器中能避免并发访问的安全代码称为SMP安全代码,在内核抢占时能避免并发访问的安全代码称为抢占安全代码。
大多数的内核数据结构都需要加锁,是给数据加锁而并非代码。
简单来说,同步就是为了防止多个执行线程未结束前同时访问某个资源。
死锁
死锁是基于并发和同步产生的一种现象。它产生需要一定条件:要有一个或多个执行线程和一个或多个资源,每个线程都在等待其中的一个资源,但所有的资源都已经被占用了。
预防死锁的发生十分重要,一些规则对避免死锁有很大帮助,如:按顺序加锁、防止发生饥饿、不要重复请求同一个锁、设计力求简单。
内和同步方法
1、原子操作
原子操作是其他同步方法的基石。原子操作可以保证指令以原子方式执行且不被打断。我自己的理解就是原子操作在单处理器机器上防止了需要相同资源的操作并发,在多处理器机器上,防止需要相同资源的操作的并发以及并行。
内核提供了两组原子操作接口——一组指针对整数进行操作,另一组指针对单独的位进行操作。针对整数的原子操作只能对atomic_t类型的数据进行处理,该数据类型只能当做24位用,因为在SPARC体系结构上原子操作的32位int类型低8位嵌入了一个锁。在64位体系结构中,64位的原子变量要使用atomic64_t。
原子操作通常是内联函数,往往是通过内嵌汇编指令实现的。本来就是原子的函数往往被定义成一个宏。原子操作只保证原子性,却不保证顺序性,顺序性通过屏障指令来实施。
原子位操作函数是对普通的内存地址进行操作的,参数是一个指针和一个位号。与原子整数操作不同,代码一般无法选择是否使用位操作,它们是唯一的、具有可移植性的设置特定位的方法。
2、自旋锁
Linux内核中最常见的的锁是自旋锁。自旋锁最多只能被一个可执行进程持有,它可以防止多于一个的执行线程同时进入临界区。
自旋锁的实现和体系结构密切相关,代码往往通过汇编实现。其基本使用形式如下:
DEFINE_SPINLOCK(mr_lock);
spin_lock(&mr_lock);
/*临界区...*/
spin_unlock(&mr_lock);
自旋锁是不可递归的!显而易见,如果你试图获得一个你正持有的锁,就必须自旋,等待自己释放这个锁,而自己又正处于忙等待中,没有机会释放锁,就会产生自死锁。
在中断处理程序中使用自旋锁时,一定要在获取锁之前禁止本地中断,否则中断处理程序会打断正持有锁的内核代码,它也可能会去争用该已被持有的锁从而自旋,但锁的持有者在中断处理程序执行完之前不能运行,就会产生双重请求死锁。
3、信号量
Linux中信号量是一种睡眠锁。试图获得一个不可用的信号量时,信号量会将其推进一个等待队列,让其睡眠直到有信号量可用时被唤醒。信号量是唯一允许睡眠的锁。
自旋锁会使试图获得已被持有的锁的进程进入忙等待,信号量会使试图获得不可用的信号量的进程进入睡眠,信号量和自旋锁的明显不同使得两种同步方法适用范围不同。信号量适用于锁会被长时间持有的情况,而自旋锁适用于锁被短时间持有;执行线程在锁被争用时会睡眠,因此只能在进程上下文中才能使用,因为中断上下文中不会进行调度,使得即使锁被释放,因为争用该锁进入睡眠的进程也不会被唤醒;占用信号量时不能占用自旋锁,因为持有自旋锁时不允许睡眠和被抢占。信号量不会禁止内核抢占,持有信号量的代码可以被抢占。
4、互斥体
互斥体(mutex)是指任何可以睡眠的强制互斥锁,相当于一个不使用计数的简化的信号量。互斥体是一种信号。对于mutex:
- 任何时刻只有一个任务可以持有mutex,上锁者必须负责解锁;
- 递归的上锁和解锁是不允许的;
- 持有一个mutex的进程不可以退出;
- mutex不能在中断或者下半部中使用(跟信号量一样);
- mutex只能通过官方API 管理。
5、其他同步方法
内和同步方法还有完成变量、大内核锁、顺序锁、禁止抢占和屏障。
网络云课堂学习
本次实验是上次实验的深入,首先要给MenuOS增加4条命令,两个和time有关的,两个和getpid有关的。
这是和time有关的两个函数:
int Time(int argc, char *argv[])
{
time_t tt;
struct tm *t;
tt = time(NULL);
t = localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d
",t->tm_year+1900, t->tm_mon, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
return 0;
}
int TimeAsm(int argc, char *argv[])
{
time_t tt;
struct tm *t;
asm volatile(
"mov $0,%%ebx
"
"mov $0xd,%%eax
"
"int $0x80
"
"mov %%eax,%0
"
: "=m" (tt)
);
t = localtime(&tt);
printf("time:%d:%d:%d:%d:%d:%d
",t->tm_year+1900, t->tm_mon, t->tm_mday, t->tm_hour, t->tm_min, t->tm_sec);
return 0;
}
这是和getpid()有关的两个函数:
int Getpid(int argc, char *argv[])
{
pid_t pid;
pid = getpid();
printf("The pid of current process is: %d
", pid);
return 0;
}
int GetpidAsm(int argc, char *argv[])
{
pid_t pid;
asm volatile(
"movl $0x14, %%eax
"
"int $0x80
"
"movl %%eax, %0
"
:"=m"(pid)
);
printf("The pid of current process is: %d
", pid);
return 0;
}
编译运行之后就会在MenuOS中多出来4个命令,如下图:
在getpid对应的系统调用也就是sys_getpid处设置断点,然后我们在MenuOS中执行getpid,会发现MenuOS在执行到一半的时候停了下来:
同样的,通过汇编码写的程序也会在这里停下来:
然后通过list查看附近的代码:
然后单步执行,最后可以看到MenuOS中getpid继续往下执行了:
由于sys_call是用汇编语言写的,可能是gdb的限制,导致不能看sys_call之后的过程。只能通过查看entry_32.S文件代码学习之后的过程。
系统调用机制的初始化:在my_start_kernel里有一个trap_init,里面有一个ert_system_trap_gate,里面有系统调用的中断向量和system_call汇编代码的入口,一旦执行int 0x80,cpu会自动跳到system_call位置执行,即entry(system_call)位置,里面有save_all保护现场,然后通过call *system_table()调用系统调用表(通过eax寄存器传递系统调用号),然后调用我们要调用的系统调用,之后调用syscall_after_call()保存返回值,然后检查是否处理syscall_exit_work(),如果不需要就恢复到中断前现场。
ENTRY(system_call)
SAVE_ALL //保护现场,主要是保存一些和进程有关的寄存器的值如cs、eip等寄存器
syscall_call:
call *sys_call_table(,%eax,4) //调用实际的系统调用程序
syscall_after_call:
movl %eax,PT_EAX(%esp) //将系统调用返回值存入栈中
syscall_exit:
testl $_TIF_ALLWORK_MASK, %ecx # current->work //检查是否所有工作都已经完成
jne syscall_exit_work
restore_all:
TRACE_IRQS_IRET //从系统调用返回
irq_return:
INTERRUPT_RETURN
syscall_exit_work:
syscall_exit_work:
testl $_TIF_WORK_SYSCALL_EXIT, %ecx //测试syscall的工作完成,testl会根据它们的两个操作数的与(and)来设置零标志和负数标志,然后确定是否执行下面的跳转语句
jz work_pending
TRACE_IRQS_ON //切换中断请求响应追踪可用
ENABLE_INTERRUPTS(CLBR_ANY) # could let syscall_trace_leave() call
//schedule() instead
movl %esp, %eax
call syscall_trace_leave //停止追踪系统调用
jmp resume_userspace //返回用户空间,只需要检查need_resched
END(syscall_exit_work)
work_pending:
work_pending:
testb $_TIF_NEED_RESCHED, %cl // 判断是否需要调度
jz work_notifysig // 不需要则跳转到work_notifysig
work_resched:
call schedule // 调度进程
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
andl $_TIF_WORK_MASK, %ecx // 是否所有工作都已经做完
jz restore_all // 是则退出
testb $_TIF_NEED_RESCHED, %cl // 测试是否需要调度
jnz work_resched // 重新执行调度代码
work_notifysig: // 处理未决信号集
#ifdef CONFIG_VM86
testl $X86_EFLAGS_VM, PT_EFLAGS(%esp) // 判断是否在虚拟8086模式下
movl %esp, %eax
jne work_notifysig_v86 // 返回到内核空间
1:
#else
movl %esp, %eax
#endif
TRACE_IRQS_ON // 启动跟踪中断请求响应
ENABLE_INTERRUPTS(CLBR_NONE)
movb PT_CS(%esp), %bl
andb $SEGMENT_RPL_MASK, %bl
cmpb $USER_RPL, %bl
jb resume_kernel // 恢复内核空间
xorl %edx, %edx
call do_notify_resume // 将信号投递到进程
jmp resume_userspace // 恢复用户空间
#ifdef CONFIG_VM86
ALIGN
work_notifysig_v86:
pushl_cfi %ecx # save ti_flags for do_notify_resume
call save_v86_state // 保存VM86模式下的CPU信息
popl_cfi %ecx
movl %eax, %esp
jmp 1b
#endif
END(work_pending)
总的来说,如果代码中出现了int 0x80指令,就会立即跳转到system_call的位置,他的位置在x86/kernel/entry_32.s,里面有一个entry(system_call)这个就是我们执行int 0x80这条指令之后的下一条开始的位置,这一段代码就是系统调用处理过程。系统调用就是一个特殊一点的中断,它存在保护现场和恢复现场的过程,这里面有save_all,这段代码相对比较复杂,里面有一个sys_call_table,这是一个系统调用的表,通过eax传递的系统调用号。我们在调用它的时候就是在调用sys_getpid。然后调用syscall_after_call,先保存它的返回值,它在退出之前有一个syscall_exit_work,如果没有的话就返回用户态。一旦进入syscall_exit_work,里面有一个进程调度时机。比如当前进程有一些信号需要处理,系统需要调度, syscall_exit_work需要跳转到work_pending,里面有work_notifysig处理信号的,还有work_resched是需要重新调度,重新调度就会call schedule,调度完之后就会跳转到restore_all把他返回系统调用。
问题
这次实验由于环境没有配置成功,所以做的不是很成功。不过在这过程中也算发现一点东西。在我的理解是用gdb调试工程时候,是不管系统调用的中间过程的(最起码我没在sys_time那里成功加上断点),而在视频中可以得知在调试内核的时候,是可以在系统调用sys_time出添加断点,也就是说程序在用户态根本就不知道内核里面究竟是怎么完成系统调用的,它只负责用封装好的API或者传递中断号和系统调用号来完成调用。