start_kernel()之lock_kernel()详解

最近在看内核的源码,看着看着就想将所看到的内容写下来,这样即加深了对代码的理解,同时也为后续学习这方面知识的人做了一些铺垫。在网上讲解内核的书或者博客很多,所以也希望自己通过自己的理解和学习来写属于自己的文章。所以决定从每一个主要的函数开始,我知道这条路将会很长,内核源码那么多,这是一个长期的过程。希望自己能够坚持下来,争取能够写到内核源码的最终一个函数。

本文所讲的内容是针对linux2.6.10内核源码,和arm平台进行的,虽然代码有点老,但是却很经典。主要的参考书籍就是《嵌入式系统Linux内核开发实战指南(arm平台)》作者王洪辉,我觉得这是一本介绍内核最详细的一本书,很有阅读的价值。

话不多说了,转入正题,start_kernel()函数主要完成操作系统前期的初始化工作,并打印相关的信息,然后然后创建init()内核进程(1号进程),最后进入idle()状态。事实上init()进程之前运行的代码,包括汇编的代码和start_kernel()函数都属于0号进程,这个0号进程也叫空闲进程或者启动进程。

今天我们就主要start_kernel()函数的调用顺序开始讲解,也就是第一个调用的函数lock_kernel(),其代码如下:

void __lockfunc lock_kernel(void)
{
__int depth = current->lock_depth+1;
__if (likely(!depth))
______lock_kernel();
__current->lock_depth = depth;
}

代码看着很简单,主要调用的函数就是lock_kernel(),对于一个刚刚接触内核的人,可能看到这个函数就感觉很不习惯,比如函数名前面的__lockfunc是怎么回事,其实这只不过就是利用C语言的宏实现的一个可以给代码加上标识的方法而已,下面请看它的定义

#define __lockfunc fastcall __attribute__((section(".spinlock.text")))

而fastcall的定义其实是和编译器相关的一个参数,其定义如下。

#define fastcall        __attribute__((regparm(3)))

__attribute__((regparm(n)))指定最多可以使用n个寄存器(eax, edx, ecx)传递参数,n的范围是0~3,超过n时则将参数压入栈中(n=0表示不用寄存器传递参数),我们这里要求最多可以使用3个寄存器传递参数,如果函数参数3个寄存器放不下,就会将多余的参数压入函数栈中。那么__attribute__((section(".spinlock.test")))又有什么作用呢?

gcc通过选项__attribute__可以改变所声明或定义的函数、数据的特性。它有很多子项,用于改变作用对象的特性。比如对函数,noline将禁止进行内联扩展、noreturn表示没有返回值、pure表明函数除返回值外,不会通过其它(如全局变量、指针)对函数外部产生任何影响。但这里我们比较感兴趣的是对代码段起作用子项section。

__attribute__的section子项的使用格式为:__attribute__((section("section_name")))

其作用是将作用的函数或数据放入指定名为"section_name"输入段。

那么什么是输入段和输出段呢?

输入段和输出段是相对于要生成最终的elf或binary时的Link过程说的。

Link过程的输入大都是由源代码编绎生成的目标文件.o,那么这些.o 文件中包含的段相对link过程来说就是输入段,而Link的输出一般是可执行文件elf或库等,这些输出文件中也包含有段,这些输出文件中的段就叫做输出段。输入段和输出段本来没有什么必然的联系,是互相独立。只是在Link过程中,Link程序会根据一定的规则(这些规则其实来源于Link Script),将不同的输入段重新组合到不同的输出段中,即使是段的名字,输入段和输出段可以完全不同。注意__attribute__的section属性只指定对象的输入段,它并不能影响所指定对象最终会放在可执行文件的什么段。

所以,这段代码在链接阶段是将这个函数的代码放在.o文件的spinlock段内的,供后面链接成efi或者binarary使用。其实内核中的代码段是分为很多类型的,后面辉慢慢的接触到。现在这个函数的声明清楚是什么作用了,下面来看函数的具体实现。

代码的第一行中的current->lock_depth,是获取当前进程的lock_depth变量的状态。current变量是一个全局指针,是指向当前进程状态的结构体的一个指针。其具体定义如下:

#ifndef __ASM_GENERIC_CURRENT_H
#define __ASM_GENERIC_CURRENT_H 

#include <linux/thread_info.h>

#define get_current() (current_thread_info()->task) //获取当前进程的线程task
#define current get_current() //表示当前进程 
#endif /* __ASM_GENERIC_CURRENT_H */

上面get_current()调用了current_thread_info函数,该函数的内核路径为:
arch/arm/include/asm/thread_info.h

static inline struct thread_info *current_thread_info(void) __attribute_const__; 
static inline struct thread_info *current_thread_info(void)
{
    register unsigned long sp asm ("sp");
    return (struct thread_info *)(sp & ~(THREAD_SIZE - 1));
}

通过上面的定义获取到了当前进程的状态,也就是进程的结构体的内容,进程的结构体是在include\linux\sched.h中定义的,具体什么样子,包含那些信息这里不再详细的介绍。还是回到lock_kernel函数中,代码第二行likely也是一个宏,其定义如下:

#define likely(x)___builtin_expect(!!(x), 1)

# define __builtin_expect(x, expected_value) (x)

这里面使用了编译器的一个规则,__builtin_expect,查阅编译器相关的文档,可以看到__builtin_expect说明中给出了两示例:

if (__builtin_expect (x, 0)) foo (); 表示期望x == 0,也就是不期望不执行foo()函数;同理,if (__builtin_expect (ptr != NULL, 1)) error (); 表示期望指针prt非空,也就是不期望看到error()函数的执行。

从GCC的说明中可知,__builtin_expect的主要作用就是:帮助编译器判断条件跳转的预期值,避免因执行jmp跳转指令造成时间浪费。那么它是怎么帮助编译器进行优化的呢?

编译器优化时,根据条件跳转的预期值,按正确地顺序生成汇编代码,把“很有可能发生”的条件分支放在顺序执行指令段,而不是jmp指令段(jmp指令会打乱CPU的指令执行顺序,大大影响CPU指令执行效率)。这样就大大的提高了CPU的执行效率。和这个相对应的还有一个宏unlikely,其定义刚好与其相反,这里不做详细介绍。这两个宏如何使用呢?其实在一个条件判断语句中,当这个条件被认为是非常有可能满足时,则使用likely()宏,否则,条件非常不可能或很难满足时,则使用unlikely()宏。

这样就明白了为什么判断语句if上面为什么要加likely了,当这个条件为真的时候,执行下面的函数__lock_kernel(),这个函数才是这个函数真正要执行的实体,前面都是铺垫,下面我们来看看__lock_kernel的代码:

/*
 * These are the BKL spinlocks - we try to be polite about preemption. 
 * If SMP is not on (ie UP preemption), this all goes away because the
 * _raw_spin_trylock() will always succeed.
 */
#ifdef CONFIG_PREEMPT
static inline void __lock_kernel(void)
{
__preempt_disable();
__if (unlikely(!_raw_spin_trylock(&kernel_flag))) {
____/*
____ * If preemption was disabled even before this
____ * was called, there's nothing we can be polite
____ * about - just spin.
____ */
____if (preempt_count() > 1) {
_______raw_spin_lock(&kernel_flag);
______return;
____}

____/*
____ * Otherwise, let's wait for the kernel lock
____ * with preemption enabled..
____ */
____do {
______preempt_enable();
______while (spin_is_locked(&kernel_flag))
________cpu_relax();
______preempt_disable();
____} while (!_raw_spin_trylock(&kernel_flag));
__}
}

#else

/*
 * Non-preemption case - just get the spinlock
 */
static inline void __lock_kernel(void)
{
___raw_spin_lock(&kernel_flag);
}
#endif

从上面的代码可知,这个函数被宏CONFIG_PREEMPT包着,如果定义这个宏,函数实体就是上面的函数,如果没有定义函数实体就是下面的函数,这种实现方式是内核中常见的,在编译内核的时候,选择的对应的配置文件.config中包含了很多个宏,哪些宏被打开了,就编译对应宏包含在内的代码。大多数多核处理器都使用的是上面的第一个函数,也就是会定义宏CONFIG_PREEMPT,那么这个宏到底是干什么的呢?其是这个宏是决定内核是否支持抢占的,以前老的内核是不支持内核抢占的,现在多核处理器的出现,就要求内核需要支持抢占的功能,在Linux2.6.10中是支持抢占功能的。内核支持抢占就要设计到进程间的同步问题,资源的互斥使用的问题,为了解决这些问题,就涉及到了大内核锁(BLK),以及内核进程中的调度等问题,现在来看看内核中的关于调度的原理和BLK的实现。

早期的Linux核心是不可抢占的。它的调度方法是:一个进程可以通过schedule()函数自愿地启动一次调度。非自愿的强制性调度只能发生在 每次从系统调用返回的前夕以及每次从中断或异常处理返回到用户空间的前夕。但是,如果在系统空间发生中断或异常是不会引起调度的。这种方式使内核实现得以 简化。但常存在下面两个问题:

  • 如果这样的中断发生在内核中,本次中断返回是不会引起调度的,而要到最初使CPU从用户空间进入内核空间的那次系统调用或中断(异常)返回时才会发生调度。
  • 另外一个问题是优先级反转。在Linux中,在核心态运行的任何操作都要优先于用户态进程,这就有可能导致优先级反转问题的出现。例如,一个低优先级的用户进程由于执行软/硬中断等原因而导致一个高优先级的任务得不到及时响应。

当前的Linux内核加入了内核抢占(preempt)机制。内核抢占指用户程序在执行系统调用期间可以被抢占,该进程暂时挂起,使新唤醒的高优先 级进程能够运行。这种抢占并非可以在内核中任意位置都能安全进行,比如在临界区中的代码就不能发生抢占。临界区是指同一时间内不可以有超过一个进程在其中 执行的指令序列。在Linux内核中这些部分需要用自旋锁保护。

内核抢占要求内核中所有可能为一个以上进程共享的变量和数据结构就都要通过互斥机制加以保护,或者说都要放在临界区中。在抢占式内核中,认为如果内核不是在一个中断处理程序中,并且不在被 spinlock等互斥机制保护的临界代码中,就认为可以"安全"地进行进程切换。

Linux内核将临界代码都加了互斥机制进行保护,同时,还在运行时间过长的代码路径上插入调度检查点,打断过长的执行路径,这样,任务可快速切换进程状态,也为内核抢占做好了准备。

Linux内核抢占只有在内核正在执行例外处理程序(通常指系统调用)并且允许内核抢占时,才能进行抢占内核。禁止内核抢占的情况列出如下:

  1. 内核执行中断处理例程时不允许内核抢占,中断返回时再执行内核抢占。
  2. 当内核执行软中断或tasklet时,禁止内核抢占,软中断返回时再执行内核抢占。
  3. 在临界区禁止内核抢占,临界区保护函数通过抢占计数宏控制抢占,计数大于0,表示禁止内核抢占。

抢占式内核实现的原理是在释放自旋锁时或从中断返回时,如果当前执行进程的 need_resched 被标记,则进行抢占式调度。

内核调度器的入口为preempt_schedule(),他将当前进程标记为TASK_PREEMPTED状态再调用schedule(),在TASK_PREEMPTED状态,schedule()不会将进程从运行队列中删除。

内核抢占API函数

在中断或临界区代码中,线程需要关闭内核抢占,因此,互斥机制(如:自旋锁(spinlock)、RCU等)、中断代码、链表数据遍历等需要关闭内 核抢占,临界代码运行完时,需要开启内核抢占。关闭/开启内核抢占需要使用内核抢占API函数preempt_disable和 preempt_enable。

内核抢占API函数说明如下(在include/linux/preempt.h中):

preempt_enable() //内核抢占计数preempt_count减1

preempt_disable() //内核抢占计数preempt_count加1

preempt_enable_no_resched()  //内核抢占计数preempt_count减1,但不立即抢占式调度

preempt_check_resched () //如果必要进行调度

preempt_count() //返回抢占计数

preempt_schedule() //核抢占时的调度程序的入口点

内核抢占API函数的实现宏定义列出如下(在include/linux/preempt.h中):

#define preempt_disable() \ 
do { \
      inc_preempt_count(); \
      barrier(); \ //加内存屏障,阻止gcc编译器对内存进行优化 
   } while (0) 

#define inc_preempt_count() \
do { \
     preempt_count()++; \ 
} while (0) 

#define preempt_count() (current_thread_info()->preempt_count)

内核抢占调度的时机

Linux内核在硬中断或软中断返回时会检查执行抢占调度。分别说明如下:

  1. 硬中断返回执行抢占调度

函数preempt_schedule_irq是出中断上下文时内核抢占调度的入口点,该函数被调用和返回时中断应关闭,保护此函数从中断递归调用。该函数列出如下(在kernel/sched.c中):

asmlinkage void __sched preempt_schedule_irq(void)
{ 
  struct thread_info *ti = current_thread_info();   
  BUG_ON(ti->preempt_count || !irqs_disabled());   
  do { 
        add_preempt_count(PREEMPT_ACTIVE); 
        local_irq_enable(); 
        schedule(); 
        local_irq_disable(); 
        sub_preempt_count(PREEMPT_ACTIVE);
        barrier(); 
      } while (unlikely(test_thread_flag(TIF_NEED_RESCHED))); 
}

调度函数schedule会检测进程的 preempt_counter 是否很大,避免普通调度时又执行内核抢占调度。

  1. 软中断返回执行抢占调度

在打开页出错函数pagefault_enable和软中断下半部开启函数local_bh_enable中,会调用函数 preempt_check_resched检查是否需要执行内核抢占。如果不是并能调度,进程才可执行内核抢占调度。函数 preempt_check_resched代码如下:

#define preempt_check_resched() \
do { \
     if (unlikely(test_thread_flag(TIF_NEED_RESCHED))) \
     preempt_schedule(); \ 
   } while (0) 

大内核锁(BKL)的设计是在kernel hacker们对多处理器的同步还没有十足把握时,引入的大粒度锁。他的设计思想是,一旦某个内核路径获取了这把锁,那么其他所有的内核路径都不能再获取到这把锁。大内核锁的实现有两种方式,一种是利用自旋锁实现的,另外一种是用互斥锁实现的。
自旋锁加锁的对象一般是一个全局变量,大内核锁加锁的对象是一段代码,里面可能包含多个全局变量。那么他带来的问题是,虽然A只需要互斥访问全局变量a,但附带锁了全局变量b,从而导致B不能访问b了。
大内核锁最先的实现靠一个全局自旋锁,但大家觉得这个锁的开销太大了,影响了实时性,因此后来将自旋锁改成了mutex,但阻塞时间一般不是很长,所以加锁失败的挂起和唤醒也是非常costly 所以后来又改成了自旋锁实现。
大内核锁一般是在文件系统,驱动等中用的比较多。目前kernel hacker们仍然在努力将大内核锁从linux里铲除。
下面来分析大内核锁的实现。
我们之前说了大内核锁有两种实现,分别是自旋锁和mutex锁。
如果是mutex锁实现,自然不能在中断环境下使用大内核锁,因为中断下禁止调度是金科玉律。
那么在大内核锁内调度是否可以?我们知道,如果一个内核流程获取到资源后就应该尽快完成操作释放资源,以便下一个竞争者获取到资源。所以资源持有者不得睡眠是一个普遍共识。可是大内核锁不这么认为,持有大内核锁的用户是允许睡眠的-虽然我们并不鼓励这样,但是内核的大内核锁的设计方案里,会在进程切换时,检查当前进程是否持有大内核锁并释放,当重新获取到cpu后,再尝试抓这把大内核锁。也就是说,进程在持有大内核锁时是可以睡眠的,这就带来资源starvation。

现在回到我们最初的函数__lock_kernel(),这个函数就是大内核锁利用自旋锁实现的。

这段代码的意思是:

  • 如果当前进程如果不是重复加锁的话,就尝试去抓这把锁,并把锁深度加1.这么做的目的是避免锁重入。
  • 实际加锁的时候,先关抢占,如果尝试加锁失败,则会根据调用lock_kernel之前关抢占与否

    如果是mutex实现的大内核锁kernel_lock,则第2步直接mutex_lock,要么成功要么阻塞。
    之前我们提到,在进程发生切换时,会检查当前进程是否持有大内核锁,这是在schedule里做的。

asmlinkage void __sched schedule(void)
{
  release_kernel_lock(prev);
  context_switch();
  reacquire_kernel_lock(current);
}

代码中,release_kernel_lock会判断如果当前进程持有大内核锁,则释放锁。reacquire_kernel_lock在进程再次被调度回来后,检查当前进程在切换之前是否因为持有大内核锁。如果有的话,说明在进程切换时,当前进程的大内核锁被强行释放了,需要再次获取。总而言之,关于大内核锁,记住两点就可以了:

  1. 由spinlock或者mutex_lock锁住一个全局变量来实现。
  2. 进程切换时会检查当前进程是否持有大内核锁,而采取释放和重获的操作,以支持持有大内核锁的用户代码睡眠

现在还是回归内核代码中,lock_kernel()的主要作用就是检查内核是否支持抢占功能,如果支持我们这里将其禁止,将0号进程的init_thread_info.preempt_count加一,将内核全局自旋锁上锁,然后将0号进程的init_task.lock_depth加一。其实简单的说就是将0号进程配置成禁止抢占。

猜你喜欢

转载自blog.csdn.net/Lq19880521/article/details/83338059