分类目录归档:读核笔记

抢占(preemption)是如何发生的

进程切换有自愿(Voluntary)和强制(Involuntary)之分,在前文中详细解释了两者的不同,简单来说,自愿切换意味着进程需要等待某种资源,强制切换则与抢占(Preemption)有关。

抢占(Preemption)是指内核强行切换正在CPU上运行的进程,在抢占的过程中并不需要得到进程的配合,在随后的某个时刻被抢占的进程还可以恢复运行。发生抢占的原因主要有:进程的时间片用完了,或者优先级更高的进程来争夺CPU了。

抢占的过程分两步,第一步触发抢占,第二步执行抢占,这两步中间不一定是连续的,有些特殊情况下甚至会间隔相当长的时间:

  1. 触发抢占:给正在CPU上运行的当前进程设置一个请求重新调度的标志(TIF_NEED_RESCHED),仅此而已,此时进程并没有切换。
  2. 执行抢占:在随后的某个时刻,内核会检查TIF_NEED_RESCHED标志并调用schedule()执行抢占。

抢占只在某些特定的时机发生,这是内核的代码决定的。

触发抢占的时机

每个进程都包含一个TIF_NEED_RESCHED标志,内核根据这个标志判断该进程是否应该被抢占,设置TIF_NEED_RESCHED标志就意味着触发抢占。

直接设置TIF_NEED_RESCHED标志的函数是 set_tsk_need_resched();
触发抢占的函数是resched_task()。

TIF_NEED_RESCHED标志什么时候被设置呢?在以下时刻:

  • 周期性的时钟中断

时钟中断处理函数会调用scheduler_tick(),这是调度器核心层(scheduler core)的函数,它通过调度类(scheduling class)的task_tick方法 检查进程的时间片是否耗尽,如果耗尽则触发抢占:

Linux的进程调度是模块化的,不同的调度策略比如CFS、Real-Time被封装成不同的调度类,每个调度类都可以实现自己的task_tick方法,调度器核心层根据进程所属的调度类调用对应的方法,比如CFS对应的是task_tick_fair,Real-Time对应的是task_tick_rt,每个调度类对进程的时间片都有不同的定义。

  • 唤醒进程的时候

当进程被唤醒的时候,如果优先级高于CPU上的当前进程,就会触发抢占。相应的内核代码中,try_to_wake_up()最终通过check_preempt_curr()检查是否触发抢占。

  • 新进程创建的时候

如果新进程的优先级高于CPU上的当前进程,会触发抢占。相应的调度器核心层代码是sched_fork(),它再通过调度类的 task_fork方法触发抢占:

  • 进程修改nice值的时候

如果进程修改nice值导致优先级高于CPU上的当前进程,也会触发抢占。内核代码参见 set_user_nice()。

  • 进行负载均衡的时候

在多CPU的系统上,进程调度器尽量使各个CPU之间的负载保持均衡,而负载均衡操作可能会需要触发抢占。

不同的调度类有不同的负载均衡算法,涉及的核心代码也不一样,比如CFS类在load_balance()中触发抢占:

RT类的负载均衡基于overload,如果当前运行队列中的RT进程超过一个,就调用push_rt_task()把进程推给别的CPU,在这里会触发抢占。

执行抢占的时机

触发抢占通过设置进程的TIF_NEED_RESCHED标志告诉调度器需要进行抢占操作了,但是真正执行抢占还要等内核代码发现这个标志才行,而内核代码只在设定的几个点上检查TIF_NEED_RESCHED标志,这也就是执行抢占的时机。

抢占如果发生在进程处于用户态的时候,称为User Preemption(用户态抢占);如果发生在进程处于内核态的时候,则称为Kernel Preemption(内核态抢占)。

执行User Preemption(用户态抢占)的时机

  1. 从系统调用(syscall)返回用户态时;
  2. 从中断返回用户态时。

执行Kernel Preemption(内核态抢占)的时机

Linux在2.6版本之后就支持内核抢占了,但是请注意,具体取决于内核编译时的选项:

  • CONFIG_PREEMPT_NONE=y
    不允许内核抢占。这是SLES的默认选项。
  • CONFIG_PREEMPT_VOLUNTARY=y
    在一些耗时较长的内核代码中主动调用cond_resched()让出CPU。这是RHEL的默认选项。
  • CONFIG_PREEMPT=y
    允许完全内核抢占。

在 CONFIG_PREEMPT=y 的前提下,内核态抢占的时机是:

  1. 中断处理程序返回内核空间之前会检查TIF_NEED_RESCHED标志,如果置位则调用preempt_schedule_irq()执行抢占。preempt_schedule_irq()是对schedule()的包装。
  2. 当内核从non-preemptible(禁止抢占)状态变成preemptible(允许抢占)的时候;
    在preempt_enable()中,会最终调用 preempt_schedule 来执行抢占。preempt_schedule()是对schedule()的包装。

 

进程切换:自愿(voluntary)与强制(involuntary)

从进程的角度看,CPU是共享资源,由所有的进程按特定的策略轮番使用。一个进程离开CPU、另一个进程占据CPU的过程,称为进程切换(process switch)。进程切换是在内核中通过调用schedule()完成的。

发生进程切换的场景有以下三种:

  1. 进程运行不下去了:
    比如因为要等待IO完成,或者等待某个资源、某个事件,典型的内核代码如下:
  2. 进程还在运行,但内核不让它继续使用CPU了:
    比如进程的时间片用完了,或者优先级更高的进程来了,所以该进程必须把CPU的使用权交出来;
  3. 进程还可以运行,但它自己的算法决定主动交出CPU给别的进程:
    用户程序可以通过系统调用sched_yield()来交出CPU,内核则可以通过函数cond_resched()或者yield()来做到。

进程切换分为自愿切换(Voluntary)和强制切换(Involuntary),以上场景1属于自愿切换,场景2和3属于强制切换。如何分辨自愿切换和强制切换呢?

  • 自愿切换发生的时候,进程不再处于运行状态,比如由于等待IO而阻塞(TASK_UNINTERRUPTIBLE),或者因等待资源和特定事件而休眠(TASK_INTERRUPTIBLE),又或者被debug/trace设置为TASK_STOPPED/TASK_TRACED状态;
  • 强制切换发生的时候,进程仍然处于运行状态(TASK_RUNNING),通常是由于被优先级更高的进程抢占(preempt),或者进程的时间片用完了。

注:实际情况更复杂一些,由于Linux内核支持抢占,kernel preemption有可能发生在自愿切换的过程之中,比如进程正进入休眠,本来如果顺利完成的话就属于自愿切换,但休眠的过程并不是原子操作,进程状态先被置成TASK_INTERRUPTIBLE,然后进程切换,如果Kernel Preemption恰好发生在两者之间,那就打断了休眠过程,自愿切换尚未完成,转而进入了强制切换的过程(虽然是强制切换,但此时的进程状态已经不是运行状态了),下一次进程恢复运行之后会继续完成休眠的过程。所以判断进程切换属于自愿还是强制的算法要考虑进程在切换时是否正处于被抢占(preempt)的过程中,参见以下内核代码:

最后,澄清几个容易产生误解的场景:

  • 进程可以通过调用sched_yield()主动交出CPU,这不是自愿切换,而是属于强制切换,因为进程仍然处于运行状态。
  • 有时候内核代码会在耗时较长的循环体内通过调用 cond_resched()或yield() ,主动让出CPU,以免CPU被内核代码占据太久,给其它进程运行机会。这也属于强制切换,因为进程仍然处于运行状态。

进程自愿切换(Voluntary)和强制切换(Involuntary)的次数被统计在 /proc/<pid>/status 中,其中voluntary_ctxt_switches表示自愿切换的次数,nonvoluntary_ctxt_switches表示强制切换的次数,两者都是自进程启动以来的累计值。

也可以用 pidstat -w 命令查看进程切换的每秒统计值:

自愿切换和强制切换的统计值在实践中有什么意义呢?
大致而言,如果一个进程的自愿切换占多数,意味着它对CPU资源的需求不高。如果一个进程的强制切换占多数,意味着对它来说CPU资源可能是个瓶颈,这里需要排除进程频繁调用sched_yield()导致强制切换的情况。

临时端口号(ephemeral port)的动态分配

网络端口号是如何分配的?除了给常用服务保留的Well-known Port numbers之外,给客户端的端口号通常是动态分配的,称为ephemeral port(临时端口),在Linux系统上临时端口号的取值范围是通过这个内核参数定义的:net.ipv4.ip_local_port_range (/proc/sys/net/ipv4/ip_local_port_range),端口号动态分配时并不是从小到大依次选取的,而是按照特定的算法随机分配的。

临时端口号的分配发生在以下两处:
– bind();
– connect()。

bind()通过inet_csk_get_port()获取端口号,利用了net_random()产生的随机数 :

connect()通过inet_hash_connect()分配端口号。核心的代码是:
port = low + (i + offset) % remaining;
其中 offset 是随机数。

为以上代码生成随机数port_offset的函数是:

 

综上,临时端口号是这样产生的:

生成一个随机数,利用随机数在ip_local_port_range范围内取值,如果取到的值在ip_local_reserved_ports范围内 ,那就再依次取下一个值,直到不在ip_local_reserved_ports范围内为止。

注:
/proc/sys/net/ipv4/ip_local_reserved_ports (net.ipv4.ip_local_reserved_ports) 是应用程序保留的端口号,不会参与内核动态分配。有些软件比如SAP通常会保留大量的端口号,如果导致剩下的临时端口数量太少的话,动态分配的随机算法往往会产生重复的端口号,造成新分配的端口号总是相同的现象。

内核如何检测soft lockup与hard lockup?

所谓lockup,是指某段内核代码占着CPU不放。Lockup严重的情况下会导致整个系统失去响应。Lockup有几个特点:

  • 首先只有内核代码才能引起lockup,因为用户代码是可以被抢占的,不可能形成lockup(只有一种情况例外,就是SCHED_FIFO优先级为99的实时进程即使在用户态也可能使[watchdog/x]内核线程抢不到CPU而形成soft lock,参见《Real-Time进程会导致系统Lockup吗?》)
  • 其次内核代码必须处于禁止内核抢占的状态(preemption disabled),因为Linux是可抢占式的内核,只在某些特定的代码区才禁止抢占,在这些代码区才有可能形成lockup。

Lockup分为两种:soft lockup 和 hard lockup,它们的区别是 hard lockup 发生在CPU屏蔽中断的情况下。

  • Soft lockup是指CPU被内核代码占据,以至于无法执行其它进程。检测soft lockup的原理是给每个CPU分配一个定时执行的内核线程[watchdog/x],如果该线程在设定的期限内没有得到执行的话就意味着发生了soft lockup,[watchdog/x]是SCHED_FIFO实时进程,优先级为最高的99,拥有优先运行的特权。
  • Hard lockup比soft lockup更加严重,CPU不仅无法执行其它进程,而且不再响应中断。检测hard lockup的原理利用了PMU的NMI perf event,因为NMI中断是不可屏蔽的,在CPU不再响应中断的情况下仍然可以得到执行,它再去检查时钟中断的计数器hrtimer_interrupts是否在保持递增,如果停滞就意味着时钟中断未得到响应,也就是发生了hard lockup。

Linux kernel设计了一个检测lockup的机制,称为NMI Watchdog,是利用NMI中断实现的,用NMI是因为lockup有可能发生在中断被屏蔽的状态下,这时唯一能把CPU抢下来的方法就是通过NMI,因为NMI中断是不可屏蔽的。NMI Watchdog 中包含 soft lockup detector 和 hard lockup detector,2.6之后的内核的实现方法如下。

NMI Watchdog 的触发机制包括两部分:

  1. 一个高精度计时器(hrtimer),对应的中断处理例程是kernel/watchdog.c: watchdog_timer_fn(),在该例程中:
    • 要递增计数器hrtimer_interrupts,这个计数器供hard lockup detector用于判断CPU是否响应中断;
    • 还要唤醒[watchdog/x]内核线程,该线程的任务是更新一个时间戳;
    • soft lock detector检查时间戳,如果超过soft lockup threshold一直未更新,说明[watchdog/x]未得到运行机会,意味着CPU被霸占,也就是发生了soft lockup。
  2. 基于PMU的NMI perf event,当PMU的计数器溢出时会触发NMI中断,对应的中断处理例程是 kernel/watchdog.c: watchdog_overflow_callback(),hard lockup detector就在其中,它会检查上述hrtimer的中断次数(hrtimer_interrupts)是否在保持递增,如果停滞则表明hrtimer中断未得到响应,也就是发生了hard lockup。

hrtimer的周期是:softlockup_thresh/5。
注:

  • 在2.6内核中:
    softlockup_thresh的值等于内核参数kernel.watchdog_thresh,默认60秒;
  • 而到3.10内核中:
    内核参数kernel.watchdog_thresh名称未变,但含义变成了hard lockup threshold,默认10秒;
    soft lockup threshold则等于(2*kernel.watchdog_thresh),即默认20秒。

NMI perf event是基于PMU的,触发周期(hard lockup threshold)在2.6内核里是固定的60秒,不可手工调整;在3.10内核里可以手工调整,因为直接对应着内核参数kernel.watchdog_thresh,默认值10秒。

检测到 lockup 之后怎么办?可以自动panic,也可输出条信息就算完了,这是可以通过内核参数来定义的:

  • kernel.softlockup_panic: 决定了检测到soft lockup时是否自动panic,缺省值是0;
  • kernel.nmi_watchdog: 定义是否开启nmi watchdog、以及hard lockup是否导致panic,该内核参数的格式是”=[panic,][nopanic,][num]”.
    (注:最新的kernel引入了新的内核参数kernel.hardlockup_panic,可以通过检查是否存在 /proc/sys/kernel/hardlockup_panic来判断你的内核是否支持。)

参考资料:

Softlockup detector and hardlockup detector (aka nmi_watchdog)

kernel/watchdog.c:
设置PMU NMI perf event的代码 wachdog_nmi_enable()
响应NMI perf overflow中断的代码 watchdog_overflow_callback()
[watchdog/x]内核线程 watchdog()
响应hrtimer中断的代码 watchdog_timer_fn()

Real-Time进程会导致系统lockup吗?

Linux kernel支持两种实时(real-time)调度策略(scheduling policy):SCHED_FIFO和SCHED_RR,无论是哪一种,实时进程的优先级范围[0~99]都高于普通进程[100~139],始终优先于普通进程得到运行。如果实时进程是CPU消耗型的,会不会导致其它进程得不到运行机会,造成系统lockup呢?

这实际上是两个问题,不能混为一谈,第一个问题是会不会造成系统lockup,第二个问题是会不会导致其它进程得不到运行机会。我们一个一个分别来谈。

实时进程会不会造成系统lockup?

Lockup分为soft lockup和hard lockup,我在《内核如何检测SOFT LOCKUP与HARD LOCKUP》一文中解释了Linux kernel检测lockup的原理。

Hard lockup发生在CPU中断被屏蔽的情况下,因为实时进程本身并不会屏蔽CPU中断,hrtimer时钟中断是可以得到响应的,所以不会导致hard lockup

Soft lockup发生在内核线程[watchdog/x]得不到运行的情况下,理论上如果实时进程占着CPU不放,确实有可能导致[watchdog/x]得不到运行而发生soft lockup,然而这个可能性并不大,因为[watchdog/x]本身也是实时进程,调度策略为SCHED_FIFO,优先级已经是最高的99:

如果占着CPU不放的实时进程也是SCHED_FIFO并且优先级为99,就有可能导致soft lockup。为什么呢?我们看一下实时进程的调度策略就明白了:

  • 在多个实时进程之间,优先级更高的会抢先运行
    (注:实时进程的优先级数字越大则优先级越高,99最高,0最低;而普通进程正好相反,优先级数字越大则优先级越低,139最低,100最高);
  • 优先级相同的实时进程之间,不会互相抢占,只能等对方主动释放CPU;
  • SCHED_FIFO调度策略的特点是,进程会一直保持运行直到发生以下情况之一:
    1. 进程主动调用sched_yield(2)放弃运行,自动排到运行队列的队尾,等到相同优先级的其它进程运行之后才有机会再运行;
    2. 进程进入睡眠状态(比如由于等待I/O的原因),唤醒后自动排到运行队列的队尾,等到相同优先级的其它进程运行之后才有机会再运行;
    3. 被优先级更高的实时进程抢占,这种情况下会自动排到运行队列的队首,下次运行的机会排在相同优先级的其它进程的前面。
  • SCHED_RR进程与SCHED_FIFO唯一不同的是,实时进程的运行时间是分为一段一段的,在相同优先级的进程之间轮流运行,每个进程运行完一个时间段之后,必须让给下一个进程(强调:仅对相同优先级而言,不同优先级的进程之间仍然会互相抢占)。

所以,如果占着CPU不放的实时进程的调度策略是SCHED_FIFO,并且优先级为与[watchdog/x]相同的99,SCHED_FIFO的调度策略决定了只要它不放手,[watchdog/x]就无法运行,结果是会导致soft lockup。

接下来第二个问题是:

实时进程会不会导致其它进程得不到运行机会?

如果实时进程占着CPU不放,会不会导致其它进程得不到运行机会,包括管理员的shell也无法运行、连基本的管理任务也进行不了,最终造成整个系统失去控制?

通常不会。因为Linux kernel有一个RealTime Throttling机制,就是为了防止CPU消耗型的实时进程霸占所有的CPU资源而造成整个系统失去控制。它的原理很简单,就是保证无论如何普通进程都能得到一定比例(默认5%)的CPU时间,可以通过两个内核参数来控制:

  • /proc/sys/kernel/sched_rt_period_us
    缺省值是1,000,000 μs (1秒),表示实时进程的运行粒度为1秒。(注:修改这个参数请谨慎,太大或太小都可能带来问题)。
  • /proc/sys/kernel/sched_rt_runtime_us
    缺省值是 950,000 μs (0.95秒),表示在1秒的运行周期里所有的实时进程一起最多可以占用0.95秒的CPU时间。
    如果sched_rt_runtime_us=-1,表示取消限制,意味着实时进程可以占用100%的CPU时间(慎用,有可能使系统失去控制)。

所以,Linux kernel默认情况下保证了普通进程无论如何都可以得到5%的CPU时间,尽管系统可能会慢如蜗牛,但管理员仍然可以利用这5%的时间设法恢复系统,比如停掉失控的实时进程,或者给自己的shell进程赋予更高的实时优先级以便执行管理任务,等等。

Real-time Throttling支持cgroup,详见https://www.kernel.org/doc/Documentation/scheduler/sched-rt-group.txt

参考资料:
https://lwn.net/Articles/296419/

Linux kernel spinlock使用不当的后果

spinlock(自旋锁)是内核中最常见的锁,它的特点是:等待锁的过程中不休眠,而是占着CPU空转,优点是避免了上下文切换的开销,缺点是该CPU空转属于浪费,spinlock适合用来保护快进快出的临界区。

spinlock有很多限制条件,其中最重要的是,持有spinlock的CPU不能被抢占,持有spinlock的代码不能休眠。如果违反,会发生死锁,后果很严重。持有spinlock的代码不能休眠,这一条是开发者编写内核程序使用spinlock的时候要人工保证的。而持有spinlock的CPU不能被抢占是由spinlock的API本身提供保证,出于效率的考虑,spinlock的API提供了多种选择,对抢占的防止程度也不一样,开发者在选用的时候需要谨慎,下文对此详细展开。

Linux内核提供了多种spinlock的API,其中最常用的是:
spin_lock/spin_unlock — 禁止内核抢占
spin_lock_irq/spin_unlock_irq — 禁止内核抢占并屏蔽中断
spin_lock_irqsave/spin_unlock_irqrestore — 禁止内核抢占并屏蔽中断,事先保存中断屏蔽位并事后恢复原状

spin_lock()禁止了内核抢占,但是没有屏蔽中断,意味着持有该spinlock的CPU有可能被中断抢占。如果你的某段内核代码选用了spin_lock(),就必须保证这段代码不会被任何中断处理程序调用,否则就会发生死锁(参见后文的一个实际发生的案例)。如果某段内核代码有可能被中断处理程序调用,那就只能选择spin_lock_irq或spin_lock_irqsave。

下面是一个刚发生的实际案例,SLES11 SP4的系统失去响应,kdump生成了vmcore,分析过程中发现以下backtraces揭示了原因:

我来解释一下,上面的backtraces意思是:CPU 2上正在运行的进程是”kswapd0″(kswapd0是负责swapping的内核线程),它正在压缩dcache以便腾出一些空闲内存,当它执行到__shrink_dcache_sb()的时候被一个中断抢占了CPU,(注意被中断抢占的进程不会离开当前CPU,不会有机会到其它CPU上运行,只能等中断处理结束之后把CPU交还给它),中断处理程序是bnx2x驱动模块(注意看[],表示的是内核模块),它发现内存不够,于是自动清理内存,最终也走到了压缩dcache这一步,也去调用__shrink_dcache_sb(),但是__shrink_dcache_sb()的临界区受到spinlock保护,见下面源代码第0823行,这个名为dcache_lru_lock的spinlock刚才已经被”kswapd0″进程持有了,所以中断处理程序不可能抢到,问题是持有dcache_lru_lock的”kswapd0″进程又被中断抢占了CPU,不可能继续运行,也就没机会释放掉dcache_lru_lock,这就陷入了死锁状态。

根本原因在于,既然__shrink_dcache_sb()选用了spin_lock(),就意味着设计者认为它不会被中断处理程序调用,因为spin_lock()不屏蔽中断,是不能防止中断抢占的,只要中断处理程序不调用__shrink_dcache_sb(),死锁就不会发生;如果要让__shrink_dcache_sb()可以被中断处理程序调用,那就不能选用spin_lock(),而应该用spin_lock_irq或spin_lock_irqsave。这个案例中的问题出在bnx2x驱动程序中,它在bnx2x_alloc_rx_sge() 中调用alloc_pages()时不恰当地使用了GFP_KERNEL标志,实际上应该使用GFP_ATOMIC标志,这样alloc_pages()就不会试图去主动回收内存、也就不会最终调用__shrink_dcache_sb()了。此bug记载在SUSE的bsc#975358中,在kernel 3.0.101-77中得以修复。

内核引导参数iommu与intel_iommu有何不同?

前文介绍过IOMMU是提供DMA Remapping功能的硬件模块,可以把DMA地址从虚拟地址翻译成物理地址。Linux kernel有两个引导参数(boot parameter)与iommu有关:iommu=[on/off] 和 intel_iommu=[on/off],它们有什么区别呢?答案是:参数iommu控制的是GART iommu功能,参数intel_iommu控制的是基于Intel VT-d的iommu功能。

下面的代码表明:CONFIG_IOMMU控制的是GART iommu,CONFIG_DMAR控制的是intel_iommu。(CONFIG_IOMMU对应的是引导参数iommu,CONFIG_DMAR对应的是引导参数intel_iommu,注意:CONFIG_DMAR名称中没有Intel字样,这里比较容易误导,但你看它下面对应的函数intel_iommu_init就很明显了):

 

引导参数iommu控制的是GART iommu前文介绍过GART (Graphics Address Remapping Table),最初是为了方便图形芯片直接读取内存而设计的:使用地址转译功能将收集到内存中的数据映射到一个图形芯片可以“看”到的地址。这个地址转译功能自然也可以充当IOMMU,于是GART被Linux kernel用来帮助传统的32位PCI设备访问可寻址范围之外的内存区域。GART iommu有局限性(比如仅限于显存范围内),不具备Intel IOMMU的完整功能。

“iommu”参数默认是打开的,以2.6.18 kernel为例,

注:GART iommu功能是按需激活的,并有前提条件,比如系统内存必须在3GB以上、而且只对有限的设备,参见:

引导参数intel_iommu控制的是基于Intel VT-d的iommu,该参数默认是关闭的,在config文件中对应的配置如下(注意:名称中用的是DMAR而不是iommu,不留意的话容易错过):

从以下的代码中我们看到:[默认情况]与[显式设置intel_iommu=off]的效果是一样的,结果都是”dmar_disabled=1″,所以 intel_iommu=off 设不设置其实都一样:

注:启动参数intel_iommu有点复杂的是:存在两个与DMAR有关的配置,除了上面看到的CONFIG_DMAR_DEFAULT_ON之外,另外还有一个是CONFIG_DMAR,默认是打开的:

CONFIG_DMAR告诉内核在编译时要准备好支持DMA Remapping设备(见注一);
而CONFIG_DMAR_DEFAULT_ON 是告诉内核在引导时是否激活DMAR设备。
也就是说,默认情况下(CONFIG_DMAR=y)内核已经具备了支持DMAR的功能,设置内核引导参数intel_iommu=off(等效于缺省情况:即CONFIG_DMAR_DEFAULT_ON未设置)并不是关闭内核的DMAR功能,仅仅是告诉内核在引导过程中不要把DMAR设备激活而已。
这两个配置参数的详细解释参见以下文件。

(注一)事实上更准确地说,内核不仅仅是在编译时具备了支持DMAR设备的功能,而且在引导过程中始终会根据ACPI table把DMAR设备有关的数据结构都初始化好–无论是否加了引导参数intel_iommu=off。

内核怎么知道哪些设备需要DMA Remapping呢?是通过ACPI table知道的,因为需要DMA Remapping的设备必须在firmware/BIOS中登记。以下是内核初始化DMAR的代码,可以看到无论是否dmar_disabled,都会调用dmar_table_init和dmar_dev_scope_init:

这就是为什么只要BIOS中打开了Intel VT-d,我们就总会在kernel messages中看到类似下面的初始化DMAR table的信息,无论intel_iommu参数是on还是off。

如果你想让kernel中与Intel VT-d有关的软件模块完全关闭,仅仅使用启动参数intel_iommu=off是不够的,而必须重新编译内核–在config中配置CONFIG_DMAR=n,或者用另一种方法:在BIOS中关闭Intel VT-d。

 

PREEMPT_ACTIVE标志在内核抢占中的作用

Linux从2.6开始支持内核抢占,意味着即使进程运行在内核态也可以被抢占。为了支持内核抢占,代码中为每个进程的 thread_info 引入了 preempt_count 计数器,数值为0的时候表示可以内核抢占,每当进程持有内核锁的时候把 preempt_count 计数器加1,表示禁止内核抢占。此外还有一些其它禁止内核抢占的场景,也通过 preempt_counter 字段反应出来:preempt_counter 字段是32位的,除了抢占计数器之外还包括其他标志位,只要 preempt_counter 整体不为0,就不能进行内核抢占,这个设计一下子简化了对众多不能抢占的情况的检测:

  • Bit  0- 7: 就是上文中所讲的抢占计数器,表示显式禁用内核抢占的次数;
  • Bit  8-15: 软中断计数器,记录可延迟函数被禁用的次数;
  • Bit 16-27: 硬中断计数器,表示中断处理程序的嵌套数,irq_enter()递增它,irq_exit()递减它;
  • Bit    28: PREEMPT_ACTIVE 标志。

这篇笔记要讲的是 PREEMPT_ACTIVE 标志。PREEMPT_ACTIVE标志的本意是表明正在进行内核抢占,设置了之后preempt_counter就不再为0,从而达到禁止内核抢占的效果,使得执行抢占工作的代码不会被再抢占。它的一个重要用途是防止非Running状态的进程被抢占过程错误地从Run Queue中移除。这句话令人十分费解,已经不处于Running状态的进程本来就不应该留在Run Queue中,为什么要防止它被移除?我用了很长时间才琢磨出来,要回答这个问题,首先要理解为什么非Running状态的进程会被抢占?所谓抢占,就是从一个正在运行的进程手上把CPU抢过来,可是既然进程已经不是Running状态了,怎么会还在CPU上,还被抢占?
这是因为进程从Running变成非Running要经过几个步骤:在把自己放进Wait Queue、状态置成非Running之后,最后调用schedule()把自己从Run Queue中移除、并把CPU交给其他进程。设想一下,一个进程恰好在调用schedule()之前就被抢占了,此时它仍然还在CPU上运行。这就是为什么非Running状态的进程也会被抢占的原因。对这样的进程,抢占流程不能擅自将之从Run Queue中移除,因为它的切换过程没有完成,应该让它有机会自己回头接着做完。比如以下的代码,是一个典型的休眠过程:

如果在第2行被抢占,刚把进程状态设置为TASK_UNINTERRUPTIBLE,本来马上就要测试条件是否满足了,这时被抢占,而抢占过程必定包含调用schedule()的步骤,导致该进程被移出运行队列,失去了运行机会,随后的条件判断语句就无法执行了,假如此时condition条件是满足的,它本来会跳出for循环、而不会去调用schedule()进入休眠,然而却被抢占过程错误地调用schedule()导致它休眠了,也因此错过了那个条件判断语句,也许就永远没有被唤醒的机会了。正确的做法是:进程被抢占后还留在Run Queue中,下次还有机会继续运行,恢复运行后继续判断condition,如果条件不满足,在随后主动调用的schedule()中会被移出运行队列,这是不能由抢占代劳的。

下面我们详细看看PREEMPT_ACTIVE是如何帮助实现上述的正确做法的。在内核里,进程从运行态进入休眠态的最后一步是呼叫调度器schedule()——把自己从Run Queue中移除,把CPU交给其他进程,这在不支持内核抢占的时代没有问题,因为整个过程不会被打断,然而内核抢占的出现使情况变复杂了,现在从运行态进入休眠态的过程可能会被抢占所打断,而抢占过程中会调用schedule(),导致schedule()的调用提前发生,有可能形成race condition。为了避免这种情况,内核抢占过程中不能直接呼叫schedule()调度器,而是呼叫preempt_schedule(),再通过它来调用schedule(),preempt_schedule()会在调用schedule()之前设置PREEMPT_ACTIVE标志,调用之后再清除这个标志。而schedule()会检查这个标志,如果设置了PREEMPT_ACTIVE标志,意味着这是从抢占过程中进入schedule(),对于不是TASK_RUNNING(state != 0)的进程,就不会调用deactivate_task()把进程从Run Queue移除。源码如下:

这段代码的逻辑含义是这样的:
如果设置了PREEMPT_ACTIVE,说明该进程是由于内核抢占而被调离CPU的,这时不把它从Run Queue里移除;如果PREEMPT_ACTIVE没被设置(进程不是由于内核抢占而被调离),还要看一下它有没有未处理的信号,如果有的话,也不把它从Run Queue移除。
总之,只要不是主动呼叫schedule(),而是因被抢占而调离CPU的,进程就还在运行队列中,还有机会运行。

 

 

dmar 与 IOMMU

在分析Linux kernel dump的时候经常会看到一个叫做dmar的东西,查看中断信息的时候也时常见到一个名称为dmar0的设备,到底什么是dmar呢?

大家知道,I/O设备可以直接存取内存,称为DMA(Direct Memory Access);DMA要存取的内存地址称为DMA地址(也可称为BUS address)。在DMA技术刚出现的时候,DMA地址都是物理内存地址,简单直接,但缺点是不灵活,比如要求物理内存必须是连续的一整块而且不能是高位地址等等,也不能充分满足虚拟机的需要。后来dmar就出现了。 dmar意为DMA remapping,是Intel为支持虚拟机而设计的I/O虚拟化技术,I/O设备访问的DMA地址不再是物理内存地址,而要通过DMA remapping硬件进行转译,DMA remapping硬件会把DMA地址翻译成物理内存地址,并检查访问权限等等。负责DMA remapping操作的硬件称为IOMMU。做个类比:大家都知道MMU是支持内存地址虚拟化的硬件,MMU是为CPU服务的;而IOMMU是为I/O设备服务的,是将DMA地址进行虚拟化的硬件。   IOMMUIOMMA不仅将DMA地址虚拟化,还起到隔离、保护等作用,如下图所示意,详细请参阅Intel Virtualization Technology for Directed I/O DMA remapping现在我们知道了dmar的概念,那么Linux中断信息中出现的dmar0又是什么呢? 还是用MMU作类比吧,便于理解:当CPU访问一个在地址翻译表中不存在的地址时,就会触发一个fault,Linux kernel的fault处理例程会判断这是合法地址还是非法地址,如果是合法地址,就分配相应的物理内存页面并建立从物理地址到虚拟地址的翻译表项,如果是非法地址,就给进程发个signal,产生core dump。IOMMU也类似,当I/O设备进行DMA访问也可能触发fault,有些fault是recoverable的,有些是non-recoverable的,这些fault都需要Linux kernel进行处理,所以IOMMU就利用中断(interrupt)的方式呼唤内核,这就是我们在/proc/interrupts中看到的dmar0那一行的意思。 我们看到的中断号48,据此还可以进一步发掘更多的信息:

上面最有意思的信息是ACTION的handler,表示IOMMU发生fault之后的中断处理例程,我们看到的例程名是dmar_fault,源代码如下:

请注意行号1343,dmar_fault_do_one()会报告fault的具体信息,包括对应设备的物理位置。由于一个dmar对应着很多个I/O设备,这条信息可以帮助定位到具体哪一个设备。源代码如下:

 

dmar的初始化是kernel根据ACPI中的dmar table进行的,每一个表项对应一个dmar设备,名称从dmar0开始依次递增,涉及取名的代码如下:

上面也揭示了boot过程留在dmesg中信息的来历:

附注: 过去的AMD64芯片也提供一个功能有限的地址转译模块——GART (Graphics Address Remapping Table),有时候它也可以充当IOMMU,这导致了人们对GART和新的IOMMU的混淆。最初设计GART是为了方便图形芯片直接读取内存:使用地址转译功能将收集到内存中的数据映射到一个图形芯片可以“看”到的地址。后来GART被Linux kernel用来帮助传统的32位PCI设备访问可寻址范围之外的内存区域。这件事新的IOMMU当然也可以做到,而且没有GART的局限性(它仅限于显存的范围之内),IOMMU可以将I/O设备的任何DMA地址转换为物理内存地址。


参考资料:
Linux Kernel的Intel-IOMMU.txt
Intel’s Virtualization for Directed I/O (a.k.a IOMMU)
Wikipedia IOMMU
理解IOMMU、北桥、MMIO和ioremap
Intel Virtualization Technology for Directed I/O