系列文章目录
Linux 内核设计与实现
深入理解 Linux 内核
Linux 设备驱动程序
Linux设备驱动开发详解
深入理解Linux虚拟内存管理
Linux 内核源代码情景分析(一)
文章目录
- 系列文章目录
- 一、存储管理
- 1、外部设备存储空间的地址映射
- (1)ioremap
- (2)get_vm_area
- (3)remap_area_pages
- (3)remap_area_pte
- 2、系统调用brk()
- 二、中断、异常和系统调用
- 1、中断请求队列的初始化
- (1)irq_desc_t
- (2)hw_interrupt_type
- (3)irqaction
- (3)request_irq
- (4)setup_irq
- 2、中断的响应和服务
- (1)do_IRQ
- (2)handle_IRQ_event
- (3)ret_from_intr
- 3、软中断与 Bottom Half
- (1)softirq_init
- (2)open_softirq
- (3)tasklet_schedule 和 tasklet_hi_schedule
- (4)do_softirq
- (5)tasklet_action
一、存储管理
1、外部设备存储空间的地址映射
任何系统都免不了要有输入/ 输出,所以对外部设备的访问是 CPU 设计中的一个重要问题。一般来说,对外部设备的访问有两种不同的形式,一中叫内存映射式 (memory mapped),另一种叫 I/O 映射式 (I/O mapped)。在采用内存映射方式的 CPU 中,外部设备的存储单元,如控制寄存器、状态寄存器、数据寄存器等等,去作为内存的一部分出现在系统中的。CPU 可以像访问一个内存单元一样地访问外部设备的存储单元,所以不需要专门设立用于外设 I/O 的指令。从前的 PDP-11、后来的 M68K、Power PC 等 CPU 都采用这种方式。而在采用 I/O 映射方式的系统中则不同,外部设备的存储单元与内存分属两个不同的体系。访问内存的指令不能用来访问外部设备的存储单元,所以在 X86 CPU 中设立了专门的 IN 和 OUT 指令,但是用于 I/O 指令的 “地址主间” 相对来说是很小的。事实上,现在 X86 的 I/O 地址空间已经非常拥挤。
但是,随着计算机技术的发展,人们发现单纯的 I/O 映射方式是不能满足要求的。此种方式只适合于早期的计算机技术,那时候一个外设通常都只有几个寄存器,通过这几个寄存器就可以完成对外设的所有操作了。而现在的情况却大不一样。例如,在 PC 机上可以插上一块图像卡,带有 2MB 的存储器,甚至还可能带有一块 ROM,里面装有可执行代码。自从 PCI 总线出现以后,这个问题就更突出了。所以,不管 CPU 的设计采用 I/O 映射或是存储器映射,都必须要有将外设卡上的存储器映射到内存空间,实际上是虚存空间的手段。在 Linux 内核中,这样的映射是通过函数 ioremap() 来建立的。
对于内存页面的管理,通常我们都是先在虚存空间分配一个虚存区间,然后为此区间分配相应的物理内存页面并建立起映射。而且这样的映射也并不是一次就建立完毕,可以在访问这些虚存页面引起页面异常时逐步地建立。但是,ioremap() 则不同,首先,我们先有一个物理存储区间,其地址就是外设卡上的存储器出现在总线上的地址。这地址未必就是这些存储单元在外设卡上局部的物理地址,而是在总线上由 CPU 所 “看到” 的地址,这中间很可能已经经历了一次地址映射,但这种映射对于 CPU 来说是透明的。所以有时把这种地址称为 “总线地址” 。举例来说,如果有一块 “智能图形卡” ,卡上有个微处理器。对于卡上的微处理器来说,卡上的存储器是从地址 0 开始的,这就是卡上局部的物理地址。但是将这块图形卡插到 PC 的一个 PCI 总线插槽上时,由 PC 的 CPU 所看到的这片物理存储区间的地址可能是从 0x0000 f000 0000 0000 开始的,这中间已经有了一次映射。可是,从系统 (PC) 的 CPU 的角度来说,它只知道这片物理存储区间是从 0x0000 f000 0000 0000 开始的,这就是该区间的物理地址,或者说 “总线地址” 。在 Linux 系统中,CPU 不能按物理地址来访问存储中间,而必须使用虚拟地址,所以必需 “反向” 地从物理地址出发找到一片虚存空间并建立起映射。其次,这样的需求只发生于对外部设备的操作,而这是内核的事,所以相应的虚存区间是在系统空间 (3GB 以上) 。在以前的 Linux 内核版本中,这个函数称为 vremap(),后来改成了 ioremap(),也突出地反映了这一点。还有。这样的页面当然不服从动态的物理内存页面分配,也不服从 kswapd 的换出。
(1)ioremap
// include/asm-i386/io.h
extern inline void * ioremap (unsigned long offset, unsigned long size)
{
return __ioremap(offset, size, 0);
}
// arch/i386/mm/ioremap.c
/*
* Remap an arbitrary physical address space into the kernel virtual
* address space. Needed when the kernel wants to access high addresses
* directly.
*
* NOTE! We need to allow non-page-aligned mappings too: we will obviously
* have to convert them into an offset in a page-aligned mapping, but the
* caller shouldn't need to know that small detail.
*/
void * __ioremap(unsigned long phys_addr, unsigned long size, unsigned long flags)
{
void * addr;
struct vm_struct * area;
unsigned long offset, last_addr;
/* Don't allow wraparound or zero size */
last_addr = phys_addr + size - 1;
if (!size || last_addr < phys_addr)
return NULL;
/*
* Don't remap the low PCI/ISA area, it's always mapped..
*/
if (phys_addr >= 0xA0000 && last_addr < 0x100000)
return phys_to_virt(phys_addr);
/*
* Don't allow anybody to remap normal RAM that we're using..
*/
if (phys_addr < virt_to_phys(high_memory)) {
char *t_addr, *t_end;
struct page *page;
t_addr = __va(phys_addr);
t_end = t_addr + (size - 1);
for(page = virt_to_page(t_addr); page <= virt_to_page(t_end); page++)
if(!PageReserved(page))
return NULL;
}
/*
* Mappings have to be page-aligned
*/
offset = phys_addr & ~PAGE_MASK;
phys_addr &= PAGE_MASK;
size = PAGE_ALIGN(last_addr) - phys_addr;
/*
* Ok, go for it..
*/
area = get_vm_area(size, VM_IOREMAP);
if (!area)
return NULL;
addr = area->addr;
if (remap_area_pages(VMALLOC_VMADDR(addr), phys_addr, size, flags)) {
vfree(addr);
return NULL;
}
return (void *) (offset + (char *)addr);
}
首先是一些例行检查,常常称为 “sanity check”,或者说 “健康检查”、“卫生检查”。其中 109 行检查的是区间的大小既不为 0,也不能太大而越出了 32 位地址空间的限制。物理地址 0xa0000 至 0x100000 用于 VGA 卡和 BIOS,这是在系统初始化时就映射好了的,不能侵犯到这个区间中去。121 行中的 high_memory 是在系统初始化时,根据检测到的物理内存大小设置的物理内存地址的上限 (所对应的虚拟地址)。如果所要求的 phys_addr 小于这个上限的话,就表示与系统的物理内存有冲突了,除非相应的物理页面原来就是保留着的空洞。在通过这些检查以后,还要保证该物理地址是按页面边界对齐的 (136~138 行)。
完成了这些准备以后,这才 “言归正传” 。首先是要找到一片虚存地址区间。前面讲过,这片区间属于内核,而不属于任何一个特定的进程,所以不是在某个进程的 mm_struct 结构中的虚存区间队列中去寻找,而是从属于内核的虚存区间队列中去寻找。
(2)get_vm_area
// mm/vmalloc.c
struct vm_struct * get_vm_area(unsigned long size, unsigned long flags)
{
unsigned long addr;
struct vm_struct **p, *tmp, *area;
area = (struct vm_struct *) kmalloc(sizeof(*area), GFP_KERNEL);
if (!area)
return NULL;
size += PAGE_SIZE;
addr = VMALLOC_START;
write_lock(&vmlist_lock);
for (p = &vmlist; (tmp = *p) ; p = &tmp->next) {
if ((size + addr) < addr) {
write_unlock(&vmlist_lock);
kfree(area);
return NULL;
}
if (size + addr < (unsigned long) tmp->addr)
break;
addr = tmp->size + (unsigned long) tmp->addr;
if (addr > VMALLOC_END-size) {
write_unlock(&vmlist_lock);
kfree(area);
return NULL;
}
}
area->flags = flags;
area->addr = (void *)addr;
area->size = size;
area->next = *p;
*p = area;
write_unlock(&vmlist_lock);
return area;
}
内核为自己保持一个虚存区间队列 vmlist,这是由一串 vm_struct 数据结构组成的一个单链队列。这里的 vm_struct 和 vmlist 都是由内核专用的。 vm_struct 从概念上说类似于供进程使用的 vm_area_struct,但要简单得多,定义于 include/linux/vmalloc.h 和 mm/vmalloc.c 中:
// include/linux/vmalloc.h
struct vm_struct {
unsigned long flags;
void * addr;
unsigned long size;
struct vm_struct * next;
};
以前讲过,内核使用的系统空间虚拟地址与物理地址间存在着一种简单的映射关系,只要在物理地址上加上一个 3GB 的偏移量就得到了内核的虚拟地址。而变量 high_memory 标志着具体物理内存的上限所对应的虚拟地址,这是在系统初始化时设置好的。当内核需要一片虚存地址空间时,就从这个地址以下 8MB 处分配。为此,在 include/asm-i386/pgtable.h 中定义了 VMALLOC_START 等有关的常数:
// include/asm-i386/pgtable.h
/* Just any arbitrary offset to the start of the vmalloc VM area: the
* current 8MB value just means that there will be a 8MB "hole" after the
* physical memory until the kernel virtual memory starts. That means that
* any out-of-bounds memory accesses will hopefully be caught.
* The vmalloc() routines leaves a hole of 4kB between each vmalloced
* area for the same reason. ;)
*/
#define VMALLOC_OFFSET (8*1024*1024)
#define VMALLOC_START (((unsigned long) high_memory + 2*VMALLOC_OFFSET-1) & \
~(VMALLOC_OFFSET-1))
#define VMALLOC_VMADDR(x) ((unsigned long)(x))
#define VMALLOC_END (FIXADDR_START)
源代码中的注解对于为什么要留一个 8MB 的空洞,以及在每次分配虚存区间时也要留下一个页面的空洞 (见 132 行) 解释得很清楚:是为了便于捕捉可能的越界访问。
这里读者可能会有个问题,185 行的 if 语句检查的是当前的起始地址加上区间大小须小于下一个区间的起始地址,这是很好理解的。可是176行在区间大小上又加了一个页面作为空洞。这个空洞页面难道不可能与下一个区间的起始地址冲突吗?这里的奥妙在于185行判定的条件是 “<” 而不是 “<=” , 并且 size 和 addr 都是按页面边界对齐的,所以 185 行的条件已经隐含着其中有一个页面的空洞。从 get_vm_area() 成功返回时,就标志着所需要的一片虚存空间已经分配好了,从返回的数据结构可以得到这片空间的起始地址。下面就是建立映射的事了。
宏定义 VMALLOC_VMADDR 我们已经在前面看到过了,实际上不做什么事情,只是类型转换。 函数 remap_area_pages() 的代码也在 arch/i386/mm/ioremap.c 中:
(3)remap_area_pages
// arch/i386/mm/ioremap.c
static int remap_area_pages(unsigned long address, unsigned long phys_addr,
unsigned long size, unsigned long flags)
{
pgd_t * dir;
unsigned long end = address + size;
phys_addr -= address;
dir = pgd_offset(&init_mm, address);
flush_cache_all();
if (address >= end)
BUG();
do {
pmd_t *pmd;
pmd = pmd_alloc_kernel(dir, address);
if (!pmd)
return -ENOMEM;
if (remap_area_pmd(pmd, address, end - address,
phys_addr + address, flags))
return -ENOMEM;
address = (address + PGDIR_SIZE) & PGDIR_MASK;
dir++;
} while (address && (address < end));
flush_tlb_all();
return 0;
}
我们讲过,每个进程的 task_struct 结构中都有一个指针指向 mm_strcuct 结构,从中可以找到相应的页面目录。但是,内核空间不属于任何一个特定的进程,所以单独设置了一个内核专用的 mm_strcuct , 称为 init_mm。当然,内核也没有代表它的 task_struct 结构,所以69行根据起始地址从 init_mm 中找到所属的目录项,然后就根据区间的大小走遍所有涉及的目录项。这里的68行看似奇怪。从物理地址中减去虚拟地址得出一个负的位移量,这个位移量在78〜79行又与虚拟地址相加,仍旧得到物理地址。 由于在循环中虚拟地址 address 在变 (见81行),物理地址也就相应而变。第75行的 pmd_alloc_kemel() 对于 i386 CPU 就是 pmd_alloc()。
// include/asm-i386/pgalloc.h
#define pmd_alloc_kernel pmd_alloc
// ==============================================================================
// include/asm-i386/pgalloc-2level.h
extern inline pmd_t * pmd_alloc(pgd_t *pgd, unsigned long address)
{
if (!pgd)
BUG();
return (pmd_t *) pgd;
}
可见,对于i386的二级页式映射,只是把页面目录项当成中间目录项而已,与“分配”实际上毫无关系。即使对于采用了物理地址扩充(PAE)的Pentium CPU,虽然实现三级映射,其作用也只是“找到”中间目录项而已,只有在中间目录项为空时才真的分配一个。
这样,remap_area_pages() 中从73行开始的do_while循环,对涉及到的每个页面目录表项调用 remap_area_pmd( )。而 remap_area_pmd() 几乎完全一样,对涉及到的每个页面表 (对i386的二级映射, 每个中间目录项实际上就是一个页面表项,也可以理解为中间目录表的大小为1)
(3)remap_area_pte
// arch/i386/mm/ioremap.c
static inline void remap_area_pte(pte_t * pte, unsigned long address, unsigned long size,
unsigned long phys_addr, unsigned long flags)
{
unsigned long end;
address &= ~PMD_MASK;
end = address + size;
if (end > PMD_SIZE)
end = PMD_SIZE;
if (address >= end)
BUG();
do {
if (!pte_none(*pte)) {
printk("remap_area_pte: page already exists\n");
BUG();
}
set_pte(pte, mk_pte_phys(phys_addr, __pgprot(_PAGE_PRESENT | _PAGE_RW |
_PAGE_DIRTY | _PAGE_DIRTY | flags)));
address += PAGE_SIZE;
phys_addr += PAGE_SIZE;
pte++;
} while (address && (address < end));
}
这里只是简单地在循环中设置页面表中所有涉及的页面表项(31行)。每个表项都被预设成 _PAGE_DIRTY 、_PAGE_DIRTY 和 _PAGE_PRESENT。
在 kswapd 换出页面的情景中,我们已经看到 kswapd 定期地、循环地、依次地从 task 结构队列中找出占用内存页面最多的进程,然后就对该进程调用 swap_out_mm() 换出一些页面。而内核的 mm_struct 结构 init_mm 是单独的,从任何一个进程的 task 结构中都到达不了 init_mm 。 所以,kswapd 根本就看不到 init_mm 中的虚存区间,这些区间的页面就自然不会被换出而长驻于内存。
2、系统调用brk()
尽管“可见度”不高,brk() 也许是最常使用的系统调用了,用户进程通过它向内核申请空间。人们常常并不意识到在调用 brk(),原因在于很少有人会直接使用系统调用 brk() 向系统申请空间,而总是通过像 malloc() 一类的C语言库函数 (或语言成分,如 C++ 中的 new ) 间接地用到 brk()。如果把 malloc() 想像成零售,brk() 则是批发。库函数 malloc() 为用户进程 (malloc本身就是该进程的一部分) 维持一个小仓库,当进程需要使用更多的内存空问时就向小仓库要,小仓库中存量不足时就通过 brk() 向内核批发。
前面讲过,每个进程拥有 3G 字节的用户虚存空间。但是,这并不意味着用户进程在这 3G 字节的范围里可以任意使用,因为虚存空间最终得映射到某个物理存储空间 (内存或磁盘空间) ,才真正可以使用,而这种映射的建立和管理则由内核处理。所谓向内核申请一块空间,是指请求内核分配一块虚存区间和相应的若干物理页面,并建立起映射关系。由于每个进程的虚存空间都很大 (3G),而实际需要使用的又很小,内核不可能在创建进程时就为整个虚存空间都分配好相应的物理空间并建立映射, 而只能是需要用多少才“分配”多少。
那么,内核怎样管理每个进程的3G字节虚存空间呢?粗略地说,用户程序经过编译、连接形成的映象文件中有一个代码段和一个数据段 (包括 data 段和 bss 段),其中代码段在下,数据段在上。数据段中包括了所有静态分配的数据空间,包括全局变量和说明为 static 的局部变量。这些空间是进程所必须的基本要求,所以内核在建立一个进程的运行映象时就分配好这些空间,包括虚存地址区间和物理页面,并建立好二者间的映射。除此之外,堆栈使用的空间也属于基本要求,所以也是在建立进程时就分配好的(但可以扩充)。所不同的足,堆栈空间安置在虚存空间的顶部,运行时由顶向下延伸;代码段和数据段则在底部 (注意,不要与X86系统结构中由段寄存器建立的“代码段”及“数据段”相混淆),在运行时并不向上伸展。而从数据段的顶部 end-data 到堆栈段地址的下沿这个中间区域则是一个巨大的空洞,这就是可以在运行时动态分配的空间。最初,这个动态分配空间是从进程的 end_data 开始的,这个地址为内核和进程所共知。以后,每次动态分配一块“内存”,这个边界就往上推进一段 距离,同时内核和进程都要记下当前的边界在哪里。在进程这一边由 malloc() 或类似的库函数管理, 而在内核中则将当前的边界记录在进程的 mm_struct 结构中。具体地说,mm_struct 结构中有一个成分 brk,表示动态分配区当前的底部。当个进程需要分配内存时,将要求的大小与其当前的动态分配区底部边界相加,所得的就是所要求的新边界,也就是 brk() 调用时的参数 brk。当内核能满足要求时, 系统调用 brk() 返回 0 ,此后新旧两个边界之间的虚存地址就都可以使用了。当内核发现无法满足要求 (例如物理空间已经分配完),或者发现新的边界已经过于逼近设于顶部的堆栈时,就拒绝分配而返回 -1 。
系统调用 brk() 在内核中的实现为 sys_brk(),其代码在 mm/mmap.c 中。这个函数既可以用来分配空间,即把动态分配区底部的边界往上推;也可以用来释放,即归还空间。因此,它的代码也大致上可以分成两部分。
// mm/mmap.c
/*
* sys_brk() for the most part doesn't need the global kernel
* lock, except when an application is doing something nasty
* like trying to un-brk an area that has already been mapped
* to a regular file. in this case, the unmapping will need
* to invoke file system routines that need the global lock.
*/
asmlinkage unsigned long sys_brk(unsigned long brk)
{
unsigned long rlim, retval;
unsigned long newbrk, oldbrk;
struct mm_struct *mm = current->mm;
down(&mm->mmap_sem);
if (brk < mm->end_code)
goto out;
newbrk = PAGE_ALIGN(brk);
oldbrk = PAGE_ALIGN(mm->brk);
if (oldbrk == newbrk)
goto set_brk;
/* Always allow shrinking brk. */
if (brk <= mm->brk) {
if (!do_munmap(mm, newbrk, oldbrk-newbrk))
goto set_brk;
goto out;
}
/* Check against rlimit.. */
rlim = current->rlim[RLIMIT_DATA].rlim_cur;
if (rlim < RLIM_INFINITY && brk - mm->start_data > rlim)
goto out;
/* Check against existing mmap mappings. */
if (find_vma_intersection(mm, oldbrk, newbrk+PAGE_SIZE))
goto out;
/* Check if we have enough memory.. */
if (!vm_enough_memory((newbrk-oldbrk) >> PAGE_SHIFT))
goto out;
/* Ok, looks good - let it rip. */
if (do_brk(oldbrk, newbrk-oldbrk) != oldbrk)
goto out;
set_brk:
mm->brk = brk;
out:
retval = mm->brk;
up(&mm->mmap_sem);
return retval;
}
参数 brk 表示所要求的新边界,这个边界不能低于代码段的终点,并且必须与页面大小对齐。如 果新边界低于老边界,那就不是申请分配空间,而是释放空间,所以通过 do_munmap() 解除一部分区间的映射。
首先检查对进程的资源限制,如果所要求的新边界使数据段的大小超过了对当前进程的限制,就拒绝执行。此外,还要通过 find_vma_intersection(),检查所要求的那部分空间是否与已经存在的某一区间相冲突。
二、中断、异常和系统调用
1、中断请求队列的初始化
在前一节中,我们讲到中断向量表(更准确地,应该说“中断描述表”)IDT 中有两种表项,一种是为保留专用于 CPU 本身的中断门,主要用于由CPU产生的异常,如“除数为0”、“页面错”等等, 以及由用户程序通过 INT 指令产生的中断(或称“陷阱”),主要用来产生系统调用(另外还有个用于 debug 的 INT3)。这些中断门的向量除用于系统调用的 0x80 外都在 0x20 以下。从 0x20 开始就是第 2 种表项,共 224 项,都是用于外设的通用中断门。这二者的区别在于通用中断门可以为多个中断源所共享,而专用中断门则是为特定的中断源所专用。
由于通用中断门是让多个中断源共用的,而且允许这种共用的结构在系统运行的过程中动态地变化,所以在 IDT 的初始化阶段只是为每个中断向量,也即每个表项准备下一个“中断请求队列”,从而形成一个中断请求队列的数组,这就是数组 irq_desc 。
(1)irq_desc_t
// include/linux/irq.h
/*
* Interrupt controller descriptor. This is all we need
* to describe about the low-level hardware.
*/
struct hw_interrupt_type {
const char * typename;
unsigned int (*startup)(unsigned int irq);
void (*shutdown)(unsigned int irq);
void (*enable)(unsigned int irq);
void (*disable)(unsigned int irq);
void (*ack)(unsigned int irq);
void (*end)(unsigned int irq);
void (*set_affinity)(unsigned int irq, unsigned long mask);
};
typedef struct hw_interrupt_type hw_irq_controller;
/*
* This is the "IRQ descriptor", which contains various information
* about the irq, including what kind of hardware handling it has,
* whether it is disabled etc etc.
*
* Pad this out to 32 bytes for cache and indexing reasons.
*/
typedef struct {
unsigned int status; /* IRQ status */
hw_irq_controller *handler;
struct irqaction *action; /* IRQ action list */
unsigned int depth; /* nested irq disables */
spinlock_t lock;
} ____cacheline_aligned irq_desc_t;
extern irq_desc_t irq_desc [NR_IRQS];
(2)hw_interrupt_type
每个队列头部中除指针 action 用来维持一个由中断服务程序描述项构成的单链队列外,还有个指针 handler 指向另一个数据结构,即 hw_interrupt_type 数据结构。那里主要是一些函数指针,用于该队列,或者说该共用“中断通道”的控制(而并不是对具体中断源的服务)。具体的函数则取决于所用的中断控制器(通常是i8259A)。例如,函数指针 enable 和 disable 用来开启和关断其所属的通道,ack 用于对中断控制器的响应,而 end 则用于每次中断服务返回的前夕。这些函数都是在 init_IRQ() 中调用 init_ISA_irqs() 设置好的。
// arch/i386/kernel/i8259.c
void __init init_ISA_irqs (void)
{
int i;
init_8259A(0);
for (i = 0; i < NR_IRQS; i++) {
irq_desc[i].status = IRQ_DISABLED;
irq_desc[i].action = 0;
irq_desc[i].depth = 1;
if (i < 16) {
/*
* 16 old-style INTA-cycle interrupts:
*/
irq_desc[i].handler = &i8259A_irq_type;
} else {
/*
* 'high' PCI IRQs filled in on demand
*/
irq_desc[i].handler = &no_irq_type;
}
}
}
void __init init_IRQ(void)
{
// ...
init_ISA_irqs();
// ...
}
(3)irqaction
用于具体中断服务程序描述项的数据结构 irqaction, 则是在 include/linux/interrupt.h 中定义的:
// include/linux/interrupt.h
struct irqaction {
void (*handler)(int, void *, struct pt_regs *);
unsigned long flags;
unsigned long mask;
const char *name;
void *dev_id;
struct irqaction *next;
};
其中最主要的就是函数指针 handler,指向具体的中断服务程序。
在 IDT 表的初始化完成之初,每个中断服务队列都是空的。此时即使打开中断并且某个外设中断真的发生了,也得不到实际的服务。虽然从中断源的硬件以及中断控制器的角度来看似乎已经得到服务了,因为形式上 CPU 确实通过中断门进入了某个中断向量的总服务程序,例如IRQ0x01_interrupt() , 并且按要求执行了对中断控制器的 ack() 以及 end() ,然后执行iret 指令从中断返网。但是,从逻辑的角度、功能的角度来看,则其实并没有得到实质的服务,因为并没有执行具体的中断服务程序。所以, 真正的中断服务要到具体设备的初始化程序将其中断服务程序通过 request_irq() 向系统 “登记”,挂入某个中断请求队列以后才会发生。
(3)request_irq
函数 request_irq() 的代码在 arch/i386/kemel/irq.c 中:
// arch/i386/kernel/irq.c
/**
* request_irq - allocate an interrupt line
* @irq: Interrupt line to allocate
* @handler: Function to be called when the IRQ occurs
* @irqflags: Interrupt type flags
* @devname: An ascii name for the claiming device
* @dev_id: A cookie passed back to the handler function
*
* This call allocates interrupt resources and enables the
* interrupt line and IRQ handling. From the point this
* call is made your handler function may be invoked. Since
* your handler function must clear any interrupt the board
* raises, you must take care both to initialise your hardware
* and to set up the interrupt handler in the right order.
*
* Dev_id must be globally unique. Normally the address of the
* device data structure is used as the cookie. Since the handler
* receives this value it makes sense to use it.
*
* If your interrupt is shared you must pass a non NULL dev_id
* as this is required when freeing the interrupt.
*
* Flags:
*
* SA_SHIRQ Interrupt is shared
*
* SA_INTERRUPT Disable local interrupts while processing
*
* SA_SAMPLE_RANDOM The interrupt can be used for entropy
*
*/
int request_irq(unsigned int irq,
void (*handler)(int, void *, struct pt_regs *),
unsigned long irqflags,
const char * devname,
void *dev_id)
{
int retval;
struct irqaction * action;
#if 1
/*
* Sanity-check: shared interrupts should REALLY pass in
* a real dev-ID, otherwise we'll have trouble later trying
* to figure out which interrupt is which (messes up the
* interrupt freeing logic etc).
*/
if (irqflags & SA_SHIRQ) {
if (!dev_id)
printk("Bad boy: %s (at 0x%x) called us without a dev_id!\n", devname, (&irq)[-1]);
}
#endif
if (irq >= NR_IRQS)
return -EINVAL;
if (!handler)
return -EINVAL;
action = (struct irqaction *)
kmalloc(sizeof(struct irqaction), GFP_KERNEL);
if (!action)
return -ENOMEM;
action->handler = handler;
action->flags = irqflags;
action->mask = 0;
action->name = devname;
action->next = NULL;
action->dev_id = dev_id;
retval = setup_irq(irq, action);
if (retval)
kfree(action);
return retval;
}
参数 irq 为中断请求队列的序号,也就是人们通常所说的 “中断请求号”,对应于中断控制器中的 一个通道,有时候要在接口卡上通过微型开关或跳线来设置。但是要注意,这样的中断请求号与 CPU 所用的 “中断号” 或 “中断向量” 是不同的,中断请求号 IRQ0 相当于中断向量0x20。也许,可以把这种中断请求号看成“逻辑”中断向量,而后者则为“物理”中断向量。通常,前 16 个中断请求通道 IRQ0 至 IRQ15 是由中断控制器 i8259A 控制的。参数 irqflags 是一些标志位,其中的 SA_SHIRQ 标志表示与其他中断源公用该中断请求通道。此时必须提供一个非零的 dev_id 以供区别。当中断发生时, 参数 dev_id 会被作为调用参数传回所指定的服务程序。至于这 dev_id 到底是什么,request_irq() 和中断服务的总控并不在乎,只要各个具体的中断服务程序自己能够辨识和使用即可,所以这里 dev_id 的类型为 void * 。而 request_irq() 中则对此进行检查。顺便提一下,printk() 产生一个出错信息,通常是写入文件 /var/log/messages 或者在屏幕上显示,取决于"守护神" syslogd 和 klogd 是否已经在运行。这里有趣的是语句中的参数 (&irq)[-1]。这里irq 是第一个调用参数,所以是最后压入堆栈的,&irq 就是参数 irq 在堆栈中的位置。那么,在 &irq 下面的是什么呢?那就是函数的返回地址。所以,这个 printk() 语句显示该 request_irq() 函数是从什么地方调用的,使程序员可以根据这个地址发现是在哪个函数中调用的。
(4)setup_irq
在分配并设置了一个 irqaclion 数据结构 action 以后,便调用 setup_irq() ,将其链入相应的中断请求队列。
/* this was setup_x86_irq but it seems pretty generic */
int setup_irq(unsigned int irq, struct irqaction * new)
{
int shared = 0;
unsigned long flags;
struct irqaction *old, **p;
irq_desc_t *desc = irq_desc + irq;
/*
* Some drivers like serial.c use request_irq() heavily,
* so we have to be careful not to interfere with a
* running system.
*/
if (new->flags & SA_SAMPLE_RANDOM) {
/*
* This function might sleep, we want to call it first,
* outside of the atomic block.
* Yes, this might clear the entropy pool if the wrong
* driver is attempted to be loaded, without actually
* installing a new handler, but is this really a problem,
* only the sysadmin is able to do this.
*/
rand_initialize_irq(irq);
}
/*
* The following block of code has to be executed atomically
*/
spin_lock_irqsave(&desc->lock,flags);
p = &desc->action;
if ((old = *p) != NULL) {
/* Can't share interrupts unless both agree to */
if (!(old->flags & new->flags & SA_SHIRQ)) {
spin_unlock_irqrestore(&desc->lock,flags);
return -EBUSY;
}
/* add new interrupt at end of irq queue */
do {
p = &old->next;
old = *p;
} while (old);
shared = 1;
}
*p = new;
if (!shared) {
desc->depth = 0;
desc->status &= ~(IRQ_DISABLED | IRQ_AUTODETECT | IRQ_WAITING);
desc->handler->startup(irq);
}
spin_unlock_irqrestore(&desc->lock,flags);
register_irq_proc(irq);
return 0;
}
计算机系统在使用中常常有产生随机数的要求,但是要产生真正的随机数是不可能的 (所以由计算机产生的随机数称为“伪随机数为了达到尽可能的随机,需要在系统的运行中引入一些随机的因素,称为“熵”(entropy)。由各种中断源产生的中断请求在时间上大多是相当随机的,可以用来作为这样的随机因素。所以Linux内核提供了一种手段,使得可以根据中断发生的时间来引入一点随机 性。需要在某个中断请求队列,或者说中断请求通道中引入这种随机性时,可以在调用参数 irqflags 中将标志位 SA_SAMPLE_RANDOM 设成1。而这里调用的 rand_initialize_irq() 就据此为该中断请求队列初始化一个数据结构,用来记录该中断的时序。
可想而知,对于中断请求队列的操作当然不允许受到干扰,必须要在临界区内进行,不光中断要关闭,还要防止可能来自其他处理器的干扰。代码中986行的 spin_lock_irqsave() 就使 CPU 进入了这样的临界区。我们将在本书下册“多处理器SMP结构”一章中介绍和讨论 spin_lock_irqsave() ,与之相对的 spin_unlock_irqrestore() 则是临界区的出口。
对第一个加入队列的 irqaction 结构的处理比较简单(1003行),不过此时要对队列的头部进行一些初始化(1006〜1008行),包括调用本队列的 startup 函数。对于后来加入队列的irqaction 结构则要稍加检查,检查的内容为是否允许共用一个中断通道,只有在新加入的结构以及队列中的第一个结构都允许共用时才将其链入队列的尾部。
在内核中,设备驱动程序一般都要通过 request_irq() 向系统登记其中断服务程序。
2、中断的响应和服务
搞清了 i386 CPU的中断机制和内核中有关的初始化以后,我们就可以从中断请求的发生到 CPU 的响应,再到中断服务程序的调用与返回,沿着 CPU 所经过的路线走一遍。这样,既可以弄清和理解 Linux 内核对中断响应和服务的总体的格局和安排,还可以顺着这个过程介绍内核中的一些相关的“基础设施”。对此二者的了解和理解,有助于读者对整个内核的理解。
这里,我们假定外设的驱动程序都已经完成了初始化,并且已把相应的中断服务程序挂入到特定的中断请求队列中,系统正在用户空间正常运行 (所以中断必然是开着的),并且某个外设已经产生了 一次中断请求。该请求通过中断控制器 i8259A 到达了 CPU 的“中断请求”引线 INTR 。由于中断是开着的,所以 CPU 在执行完当前指令后就来响应该次中断请求。
CPU 从中断控制器取得中断向量,然后根据具体的中断向量从中断向量表 IDT 中找到相应的表项, 而该表项应该是一个中断门。这样,CPU 就根据中断门的设置而到达了该通道的总服务程序的入口, 假定为 IRQ0x03_interrupt。由于中断是当 CPU 在用户空间中运行时发生的,当前的运行级别 CPL 为 3; 而中断服务程序属于内核,其运行级别 DPL 为 0, 二者不同。所以,CPU 要从寄存器 TR 所指的当前 TSS 中取出用于内核 (0级) 的堆栈指针,并把堆栈切换到内核堆栈,即当前进程的系统空间堆栈。应该指山,CPU 每次使用内核堆栈时对堆栈所作的操作总是均衡的,所以每次从系统空间返回到用户空间时堆栈指针一定回到其原点,或曰 “堆栈底部”。也就是说,当 CPU 从 TSS 中取出内核堆栈指针并切换到内核堆栈时,这个堆栈一定是空的。这样,当 CPU 进入IRQ0x03_interrupt 时,堆栈中除寄存器 EFLAGS 的内容以及返回地址外就一无所有了。另外,由于所穿过的是中断门(而不是陷阱门),所以中断已被关断,在重新开启中断之前再没有其他的中断可以发生了。
中断服务的总入口 IRQ0xYY_interrupt 的代码以前已经见到过了,但为方便起见再把它列出在这里。再说,我们现在的认识也可以更深入一些了。
如前所述,所有公用中断请求的服务程序总入口是由 gcc 的预处理阶段生成的,全部都具有相同的模式:
__asm__ ( \
"\n" \
"IRQ0x03_interrupt: \n\t" \
"pushl $0x03 - 256 \n\t" \
"jmp common_interrupt");
这段程序的目的在于将一个与中断请求号相关的数值压入堆栈,使得在 common_interrupt 中可以通过这个数值来确定该次中断的来源。可是为什么要从中断请求号 0x03 中减去 256 使其变成负数呢? 就用数值0x03不是更直截了当吗?这是因为,系统堆栈中的这个位置在因系统调用而进入内核时要用来存放系统调用号,而系统调用又与中断服务共用一部分子程序。这样,就要有个手段来加以区分。 当然,耍区分系统调用号和中断请求号并不非得把其中之一变成负数不可。例如,在中断请求号上加上个常数,比方说 0x1000,也可以达到目的。但是,如果考虑到运行时的效率,那么把其中之一变成负数无疑是效率最高的。将一个整数装入到一个通用寄存器之后,要判断它是否大于等于0是很方便的,只要一条寄存器指令就可以了,如 "orl%%eax,%%eax” 或 “testl %%ecx, %%ecx” 都可以达到目的。而如果要与另一个常数相比较,那就至少要多访问一次内存。从这个例子也可以看出l,内核中的有些代码看似简单,好像只是作者随意的决定,但实际上却是经过精心推敲的。
公共的跳转目标 common_interrupt() 是在 include/asm-i386/hw_irq.h 中定义的:
// include/asm-i386/hw_irq.h
#define BUILD_COMMON_IRQ() \
asmlinkage void call_do_IRQ(void); \
__asm__( \
"\n" __ALIGN_STR"\n" \
"common_interrupt:\n\t" \
SAVE_ALL \
"pushl $ret_from_intr\n\t" \
SYMBOL_NAME_STR(call_do_IRQ)":\n\t" \
"jmp "SYMBOL_NAME_STR(do_IRQ));
这里主要的操作是宏操作 SAVE_ALL ,就是所谓 “保存现场”,把中断发生前夕所有寄存器的内容都保存在堆栈中,待中断服务完毕要返回之前再来“恢复现场"。 SAVE_ALL 的定义在arch/i386/kernel/entry.S 中。
回到 common_interrupt 的代码。在 SAVE_ALL 以后,又将一个程序标号 (入口) ret_from_intr 压入堆栈,并通过 jmp 指令转入另一段程序 do_IRQ() 。 读者可能已注意到,IRQ0x03_interrupt 和 common_interrupt 本质上都不是函数,它们都没有与 return 相当的指令,所以从 common_interrupt 不能返回到 IRQ0x03_interrupt,而从 IRQ0x03_interrupt 也不能执行中断返回。可是,do_IRQ() 却是一个函数。所以,在通过 jmp 指令转入 do_IRQ() 之前将返回地址ret_from_intr 压入堆栈就模拟了一次函数调用,仿佛对 do_IRQ() 的调用就发生在 CPU 进入ret_from_intr 的第一条指令前夕一样。这样,当从 do_IRQ() 返回时就会“返回”到 ret_from_intr 继续执行。do_IRQ() 是在 arch/i386/kernel/irq.c 中定义的, 我们先来看开头几行:
(1)do_IRQ
// arch/i386/kernel/irq.c
/*
* do_IRQ handles all normal device IRQ's (the special
* SMP cross-CPU interrupts have their own specific
* handlers).
*/
asmlinkage unsigned int do_IRQ(struct pt_regs regs)
{
/*
* We ack quickly, we don't want the irq controller
* thinking we're snobs just because some other CPU has
* disabled global interrupts (we have already done the
* INT_ACK cycles, it's too late to try to pretend to the
* controller that we aren't taking the interrupt).
*
* 0 return value means that this irq is already being
* handled by some other CPU. (or is disabled)
*/
int irq = regs.orig_eax & 0xff; /* high bits used in ret_from_ code */
int cpu = smp_processor_id();
irq_desc_t *desc = irq_desc + irq;
struct irqaction * action;
unsigned int status;
kstat.irqs[cpu][irq]++;
spin_lock(&desc->lock);
desc->handler->ack(irq);
/*
REPLAY is when Linux resends an IRQ that was dropped earlier
WAITING is used by probe to mark irqs that are being tested
*/
status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING);
status |= IRQ_PENDING; /* we _want_ to handle it */
/*
* If the IRQ is disabled for whatever reason, we cannot
* use the action we have.
*/
action = NULL;
if (!(status & (IRQ_DISABLED | IRQ_INPROGRESS))) {
action = desc->action;
status &= ~IRQ_PENDING; /* we commit to handling */
status |= IRQ_INPROGRESS; /* we are handling it */
}
desc->status = status;
/*
* If there is no IRQ handler or it was disabled, exit early.
Since we set PENDING, if another processor is handling
a different instance of this same irq, the other processor
will take care of it.
*/
if (!action)
goto out;
/*
* Edge triggered interrupts need to remember
* pending events.
* This applies to any hw interrupts that allow a second
* instance of the same irq to arrive while we are in do_IRQ
* or in the handler. But the code here only handles the _second_
* instance of the irq, not the third or fourth. So it is mostly
* useful for irq hardware that does not mask cleanly in an
* SMP environment.
*/
for (;;) {
spin_unlock(&desc->lock);
handle_IRQ_event(irq, ®s, action);
spin_lock(&desc->lock);
if (!(desc->status & IRQ_PENDING))
break;
desc->status &= ~IRQ_PENDING;
}
desc->status &= ~IRQ_INPROGRESS;
out:
/*
* The ->end() handler has to deal with interrupts which got
* disabled while the handler was running.
*/
desc->handler->end(irq);
spin_unlock(&desc->lock);
if (softirq_active(cpu) & softirq_mask(cpu))
do_softirq();
return 1;
}
函数的调用参数是一个 pt_regs 数据结构。注意,这是一个数据结构,而不是指向数据结构的指针。
也就是说,在堆栈中的返回地址以上的位置上应该是一个数据结构的映象。数据结构 struct pt_regs 是 include/asm-i386/ptrace.h 中定义的。
当通过中断门进入中断服务时,CPU 的中断响应机制就自动被关断了。既然已经关闭中断,为什么567行还耍调用 spin_lock() 加锁呢?这是为多处理器的情况而设置的,我们将在"多处理器SMP系统结构”一章中讲述,这里暂且只考虑单处理器结构。
中断处理器 (如18259A) 在将中断请求“上报”到 CPU 以后,期待 CPU 给它一个确认 (ACK), 表示“我已经在处理”,这里的568行就是做这件事。对函数指针 desc->handler->ack 的设置前面已经讲过。从569行至586行主要是对 desc->status,即中断通道状态的处理和设置,关键在于将其 IRQ_INPROGRESS 标志位设成 1,而将 IRQ_PENDING 标志位清 0 。其中 IRQ_INPROGRESS 主要是为多处理器设置的,而 IRQ_PENDING 的作用则下面就会看到:
如果某一个中断请求队列的服务是关闭着的( IRQ_DISABLED 标志位为1 ),或者 IRQ_INPROGRESS 标志位为1,或者队列是空的,那么指针 action 为 NULL (见580和582行),无法往下执行了,所以只好返回。但是,在这几种情况下 desc->status 中的 IRQ_PENDING 标志为 1 (见574 和583行)。这样,以后当 CPU (在多处理器系统结构中有可能是另一个 CPU )开启该队列的服务时, 会看到这个标志位而补上一次中断服务,称为 “IRQ_REPLAY”。而如果队列是空的,那么整个通道也必然是关着的,因为这是在将第一个服务程序挂入队列时才开启的。所以,这两种情形实际上相同。 最后一种情况是服务已经开启,队列也不是空的,可是 IRQ_INPROGRESS 标志为 1。这只有在两种情形下才会发生。一种情形是在多处理器 SMP 系统结构中,一个 CPU 正在中断服务,而另一个 CPU 又进入了 do_IRQ() ,这时候由于队列的 IRQ_INPROGRESS 标志为 1 而经595行返回,此时 desc->status 中的 IRQ_PENDING 标志位也是 1。第 2 种情形是在单处理器系统中 CPU 已经在中断服务程序中,但是因某种原因又将中断开启了,而且在同一个中断通道中又产生了一次中断。在这种情形下后面发生的那次中断也会因为 IRQ_INPROGRESS 标志为1而经595行返回,但也是将 desc->status 的 IRQ_PENDING 设置成为 1。总之,这两种情形下最后的结果也是一样的,即 desc->status 中的 IRQ_PENDING 标志位为 1。
那么,IRQ_PENDING 标志位到底是怎样起作用的呢?请看612和613两行。这是在一个无限for 循环中,具体的中断服务是在609行的 handle_IRQ_event() 中进行的。在进入609行时,desc->status 中的 IRQ_PENDING 标志必然为0。当CPU完成了具体的中断服务返回到610行以后,如果这个标志位仍然为0,那么循环就在613行结束了。而如果变成了 1,那就说明已经发生过前述的某种情况,所以又循环回到609行再服务一次。这样,就把本来可能发生的在同一通道上 (甚至可能来自同一中断源) 的中断嵌套化解成为一个循环。
这样,同一个中断通道上的中断处理就得到了严格的“串行化”。也就是说,对于同一个CPU 而言不允许中断服务嵌套,而对于不同的 CPU 则不允许并发地进入同一个中断服务程序。如果不是这样处理的话,那就要求所有的中断服务程序都必需是“可重入”的“纯代码”,那样就使中断服务程序的设计和实现复杂化了。这么一套机制的设计和实现,不能不说是非常周到、非常巧妙的。而 Linux 的稳定性和可靠性也正是植根于这种从 Unix 时代继承下来、并经过时间考验的设计中。当然,在极端的情况下,也有可能会发生这样的情景:中断服务程序中总是把中断打开,而中断源又不断地产生中断请求,使得 CPU 每次从 handle_IRQ_event() 返回时 IRQ_PENDING 标志永远是 1,从而使607行的 for 循环变成一个真正的“无限”循环。如果真的发生这种情况而得不到纠正的话,那么该中断服务程序的作者应该另请高就了。
还要指出,对 desc->status 的任何改变都是在加锁的情况下进行的,这也是出于对多处理器 SMP 系统结构的考虑。
最后,在循环结束以后,只要本队列的中断服务还是开着的,就要对中断控制器执行一次“结束中断服务”操作(622行),具体取决于中断控制器硬件的要求,所调用的函数也是在队列初始化时设置好的。
再看上面 for 循环中调用的 handle_IRQ_event() ,这个函数依次执行队列中的各个中断服务程序, 让它们辨认本次中断清求是否来自各自的服务对象,即中断源,如果是就进而提供相应的服务。
(2)handle_IRQ_event
// arch/i386/kernel/irq.c
/*
* This should really return information about whether
* we should do bottom half handling etc. Right now we
* end up _always_ checking the bottom half, which is a
* waste of time and is not what some drivers would
* prefer.
*/
int handle_IRQ_event(unsigned int irq, struct pt_regs * regs, struct irqaction * action)
{
int status;
int cpu = smp_processor_id();
irq_enter(cpu, irq);
status = 1; /* Force the "do bottom halves" bit */
if (!(action->flags & SA_INTERRUPT))
__sti();
do {
status |= action->flags;
action->handler(irq, action->dev_id, regs);
action = action->next;
} while (action);
if (status & SA_SAMPLE_RANDOM)
add_interrupt_randomness(irq);
__cli();
irq_exit(cpu, irq);
return status;
}
其中 430 行的 irq_enter() 和 446 行的 irq_exit() 只是对一个计数器进行操作,二者均定义于 include/asm-i386/hardirq.h:
// include/asm-i386/hardirq.h
#define irq_enter(cpu, irq) (local_irq_count(cpu)++)
#define irq_exit(cpu, irq) (local_irq_count(cpu)--)
当这个计数器的值为非 0 时就表示 CPU 正处于具体的中断服务程序中,以后读者会看到有些操作是不允许在此期间进行的。
一般来说,中断服务程序都是在关闭中断 (不包括“不可屏蔽中断” NMI) 的条件下执行的,这也是 CPU 在穿越中断门时自动关中断的原因。但是,关中断是个既不可不用,又不可滥用的手段,特别是当中断服务程序较长,操作比较复杂时,就有可能因关闭中断的时间持续太长而丢失其他的中断。 经验表明,允许中断在同一个中断源或同一个中断通道嵌套是应该避免的,因此内核在 do_IRQ() 中通过 IRQ_PENDING 标志位的运用来保证了这一点。可是,允许中断在不同的通道上嵌套,则只要处理得当就还是可行的。当然,必须十分小心。所以,在调用 request_irq() 将一个中断服务程序挂入某个中断服务队列时,允许将参数 irqflags 中的一个标志位 SA_INTERRUPT 置成 0 ,表示该服务程序应该在开启中断的情况下执行。这里的434〜435行和444行就是为此而设的( _sti() 为开中断,_cli() 为关中断)。
然后,从437行至441行的 do_while 循环就是实质性的操作了。它依次调用队列中的每一个中断服务程序。调用的参数有二:irq 为中断请求号;action->dev_id 是一个 void 指针,由具体的服务程序自行解释和运用,这是由设备驱动程序在调用 request_irq() 时自己规定的;最后一个就是前述的 pt_regs 数据结构指针 regs 了。至于具体的中断服务程序,那是设备驱动范畴内的东西,这里就不讨论了。
读者或许会问,如果中断请求队列中有多个服务程序存在,每次有来自这个通道的中断请求时就要依次把队列中所有的服务程序依次都执行一遍,岂非使效率大降?回答是:确实会有所下降,但不会严重。首先,在每个具体的中断服务程序中都应该 (通常都确实是) 一开始就检查各自的中断源, 一般是读相应设备 (接口卡上) 的中断状态寄存器,看是否有来自该设备的中断请求,如没有就马上返回了,这个过程一般只需要几条机器指令;其次,每个队列中服务程序的数量一般也不会太大。所以,实际上不会有显著的影响。
最后,在442至443行,如果队列中的某个服务程序要为系统引入一些随机性的话,就调用 add_interrupt_randomness() 来实现。有关详情在设备驱动一章中还会讲到。
从 handle_IRQ_event() 返回的 status 的最低位必然为 1,这是在432行设置的。代码中还为此加了些注解(418〜424行),其作用在看了下面这一段以后就会明白。
// arch/i386/kernel/irq.c
asmlinkage unsigned int do_IRQ(struct pt_regs regs)
{
// ...
if (softirq_active(cpu) & softirq_mask(cpu))
do_softirq();
return 1;
}
从逻辑的角度说对中断请求的服务似乎已经完毕,可以返回了。可是 Linux 内核在这里有个特殊的考虑,这就是所谓 softirq,即 “(在时间上)软性的中断请求”,以前称为 “bottomhalf" 。 在 Linux 中,设备驱动程序的设计人员可以将中断服务分成两“半”,其实是两“部分”,而并不 一定是两“半”。第一部分是必须立即执行,一般是在关中断条件下执行的,并且必须是对每次请求都单独执行的。而另一部分,即“后半”部分,是可以稍后在开中条件下执行的,并且往往可以将若干次中断服务中剩下来的部分合并起来执行。这些操作往往是比较费时的,因而不适宜在关中断条件下执行, 或者不适宜一次占据 CPU 时间太长而影响对其他中断请求的服务。这就是所谓的 “后半”(bottom half),在内核代码中常简称为 bh。作为一个比喻,读者不妨想像在 “cooked mode” 下从键盘输入字符串的过程 (详见设备驱动),每当按一个键的时候,首先要把字符读进来,这要放在“前半”中执行;而进一步检查所按的是否“回车”键,从而决定是否完成了一个字符串的输入,并进一步把睡眠中的进程唤醒,则可以放在“后半”中执行。
执行 bh 的机制是内核中的一项“基础设施”,所以我们在下一节单独加以介绍。这里,读者暂且只要知道有这么个事就行了。
(3)ret_from_intr
在 do_softirq() 中执行完相关的 bh 函数 (如果有的话) 以后,就到了从 do_IRQ() 返回的时候了 。 返回到哪里? entry.S 中的标号 ret_from_intr 处,这是内核中处心积虑安排好了的。其代码在 arch/i386/kernel/entry.S 中 。
# arch/i386/kernel/entry.S
ENTRY(ret_from_intr)
GET_CURRENT(%ebx)
movl EFLAGS(%esp),%eax # mix EFLAGS and CS
movb CS(%esp),%al
testl $(VM_MASK | 3),%eax # return to VM86 mode or non-supervisor?
jne ret_with_reschedule
jmp restore_all
这里的 GET_CURRENT(%ebx) 将指向当前进程的 task_struct 结构的指针置入寄存器 EBX。275行和276行则在寄存器 EAX 中拼凑起由中断前夕寄存器 EFLAGS 的商16位和代码段寄存器CS的(16 位)内容构成的32位长整数。其目的是要检验:
- 中断前夕CPU是否运行于VM86模式。
- 中断前夕CPU运行于用户空间还是系统空间。
VM86 模式是为在i386保护模式下模拟运行 DOS 软件而设置的。在寄存器 EFLAGS 的高 16 位中有个标志位表示 CPU 正在 VM86 模式中运行,我们对 VM86 模式不感兴趣,所以不予深究。而 CS 的最低两位,那就有文章了。这两位代表着中断发生时CPU的运行级别CPL 。我们知道 Linux 只采用两种运行级别,系统为0,用户为3。所以,若是 CS 的最低两位为非 0 ,那就说明中断发生于用户空间。
顺便说下一下,275行的 EFLAGS (%esp) 表示地址为堆栈指针 %esp 的当前值加上常数 EFLAGS 处的内容,这就是保存在堆栈中的中断前夕寄存器 %eflags 的内容。常数 EFLAGS 我们已经在前面介绍过,其值为 0x30。276行中的 CS(%esp) 也是一样。
如果中断发生于系统空间,控制就直接转移到 restore_all ,而如果发生于用户空间 (或 VM86 模式) 则转移到 ret_with_reschedule。这里我们假定中断发生于用户空间,因为从ret_with_reschedule 最终还会到达 restore_all。
# arch/i386/kernel/entry.S
ret_with_reschedule:
cmpl $0,need_resched(%ebx)
jne reschedule
cmpl $0,sigpending(%ebx)
jne signal_return
restore_all:
RESTORE_ALL
ALIGN
signal_return:
sti # we can get here from an interrupt handler
testl $(VM_MASK),EFLAGS(%esp)
movl %esp,%eax
jne v86_signal_return
xorl %edx,%edx
call SYMBOL_NAME(do_signal)
jmp restore_all
这里,首先检查是否需要进行一次进程调度。上面我们已经看到,寄存器 EBX 中的内容就是当前进程的 task_struct 结构指针,而 need_resched(%ebx) 就表示该 task_struct 结构中位移为 need_resched 处的内容。220 行的 sigpending(%ebx)也是一样。常数 need_resched 和 sigpending 的定义为:(见 entry.S)
/*
* these are offsets into the task-struct.
*/
state = 0
flags = 4
sigpending = 8
addr_limit = 12
exec_domain = 16
need_resched = 20
tsk_ptrace = 24
processor = 52
ENOSYS = 38
3、软中断与 Bottom Half
中断服务一般都是在将中断请求关闭的条件下执行的,以避免嵌套而使控制复杂化。可是,如果关中断的时间持续太长就可能因为 CPU 不能及时响应其他的中断请求而使中断(请求)丢失,为此, 内核允许在将具体的中断服务程序挂入中断请求队列时将 SA_INTERRUPT 标志置成 0,使这个中断服务程序在开中的条件下执行。然而,实际的情况往往是:若在服务的全过程关中断则“扩大打击面”, 而全程开中则又造成“不安定因素”,很难取舍。一般来说,一次中断服务的过程常常可以分成两部分。 开头的部分往往是必须在关中断条件下执行的。这样才能在不受干扰的条件下“原子”地完成一些关键性操作。同时,这部分操作的时间性又往往很强,必须在中断请求发生后“立即”或至少是在一定的时间限制中完成,而且相继的多次中断请求也不能合并在一起来处理。而后半部分,则通常可以, 而且应该在开中条件下执行,这样才不至于因将中断关闭过久而造成其他中断的丢失。同时,这些操作常常允许延迟到稍后才来执行,而且有可能将多次中断服务中的相关部分合并在一起处理。这些不同的性质常常使中断服务的前后两半明显地区分开来,可以、而且应该分别加以不同的实现。这里的后半部分就称为“bottom half”,在内核代码中常常缩写为 bh。这个概念在相当程度上来自 RISC 系统结构。在 RISC 的 CPU 中,通常都有大量的寄存器。当中断发生时,要将所有这些寄存器的内容都压入堆栈,并在返回时加以恢复,为此而付出很高的代价。所以,在 RISC 结构的系统中往往把中断服务分成两部分。第一部分只保存为数不多的寄存器(内容),并利用这为数不多的寄存器来完成有限的关键性的操作,称为“轻量级中断“。而另一部分,那就相当于这里的 bh 了。虽然 i386 的结构主要是 CISC 的,面临的问题不尽相同,但前述的问题已经使 bh 的必要性在许多情况下变得很明显了。
Linux 内核为将中断服务分成两半提供了方便,并设立了相应的机制。在以前的内核中,这个机制就称为 bh。但是,在2.4版(确切地说是 2.3.43 )中有了新的发展和推广。
以前的内核中设置了一个函数指针数组 bh_base[],其大小为 32,数组中的每个指针可以用来指向一个具体的 bh 函数。同时,又设置了两个 32 位无符号整数 bh_active 和 bh_mask ,每个无符号整数中的 32 位对应着数组 bh_base[] 中的 32 个元素。
我们可以在中断与 bh 二者之间建立起一种类比。
- 数组 bh_base[] 相当于硬件中断机制中的数组 irq_desc[]。不过 irq_desc[] 中的每个元素代表着一个中断通道,所以是一个中断服务程序队列。而 bh_base[] 中的每个元素却最多只能代表一个 bh 函数。但是,尽管如此,二者在概念上还是相同的。
- 无符号整数 bh_active 在概念上相当于硬件的“中断请求寄存器”,而 bh_mask 则相当于“中断屏蔽寄存器”。
- 需要执行一个 bh 函数时,就通过一个函数 mark_bh() 将 bh_active 中的某一位设成 1,相当于中断源发出了中断请求,而所设置的具体标志位则类似于“中断向量”。
- 如果相当于“中断屏蔽寄存器”的 bh_mask 中的相应位也是1,即系统允许执行这个 bh 函数, 那么就会在每次执行完 do_IRQ() 中的中断服务程序以后,以及每次系统调用结束之时,在一个函数do_bottom_half() 中执行相应的 bh 函数。而 do_bottom_half(),则类似于 do_IRQ()。
为了简化 bh 函数的设计,在 do_bottom_half() 中也像 do_IRQ() 中一样,把 bh 函数的执行严格地 “串行化” 了。这种串行化有两方面的考虑和措施:
一方面,bh 函数的执行不允许嵌套。如果在执行 bh 函数的过程中发生中断,那么由于每次中断服务以后在 do_IRQ() 中都要检查和处理 bh 函数的执行,就有可能嵌套。为此,在 do_bottom_half() 中针对同一 CPU 上的嵌套执行加了锁。这样,如果进入 do_bottom_half() 以后发现已经上了锁,就立即返回。因为这说明 CPU 在本次中断发生之前已经在这个函数中了。
另一方面,是在多 CPU 系统中,在同一时间内最多只允许一个 CPU 执行 bh 函数,以防有两个甚至更多个 CPU 同时来执行 bh 函数而互相干扰。为此在 do_bottom_half() 中针对不同 CPU 同时执行 bh 函数也加了锁。这样,如果进入 do_bottom_half() 以后发现这个锁已经锁上,就说明已经有 CPU 在执行 bh 函数,所以也立即返回。
这两条措施,特别是第二条措施,保证了从单 CPU 结构到多 CPU SMP 结构的平稳过渡。可是, 在当时的 Linux 内核可以在多 CPU SMP 结构上稳定运行以后,就慢慢发现这样的处理对于多 CPU SMP 结构的性能有不利的影响。原因就在于上述的第二条措施使 bh 函数的执行完全串行化了。当系统中有很多 bh 函数需要执行时,虽然系统中有多个 CPU 存在,却只存一个 CPU 这么一个“独木桥”。跟 do_IRQ() 作一比较就可以发现,在 do_IRQ() 中的串行化只是针对一个具体中断通道的,而 bh 函数的串行化却是全局性的,所以是“防卫过当” 了。既然如此,就应该考虑放宽上述的第二条措施。但是,如果放宽了这一条,就要对 bh 函数本身的设计和实现有更高的要求 (例如对使用全局量的互斥),而原来已经存在的 bh 函数显然不符合这些要求。所以,比较好的办法是保留 bh ,另外再增设一种或几种机制,并把它们纳入一个统一的框架中。这就是 2.4 版中的“软中断” (softirq) 机制。
从字面上说 softirq 就是软中断,可是“软中断”这个词 (尤其是在中文里) 已经被用作“信号” (signal)的代名词,因为信号实际上就是 “以软件手段实现的中断机制”。但是,另一方面,把类似于 bh 的机制称为“软中断”又确实很贴切。这一方面反映了上述 bh 函数与中断之间的类比,另一方面也反映了这是一种在时间要求上更为软性的中断请求。实际上,这里所体现的是层次的不同。如果说“硬中断”通常是外部设备对 CPU 的中断,那么 softirq 通常是 “硬中断服务程序” 对内核的中断, 而“信号”则是由内核(或其他进程)对某个进程的中断。后面这二者都是由软件产生的“软中断”。 所以,对“软中断”这个词的含意要根据上下文加以区分。
(1)softirq_init
下面,我们以 bh 函数为主线,通过阅读代码来叙述 2.4 版内核的软中断 (softirq) 机制。
系统在初始化时通过函数 softirq_init() 对内核的软中断机制进行初始化。其代码在 kemel/softirq.c
// kernel/softirq.c
void __init softirq_init()
{
int i;
for (i=0; i<32; i++)
tasklet_init(bh_task_vec+i, bh_action, i);
open_softirq(TASKLET_SOFTIRQ, tasklet_action, NULL);
open_softirq(HI_SOFTIRQ, tasklet_hi_action, NULL);
}
软中断本身是一种机制,同时也是一个框架。在这个框架里有 bh 机制,这是一种特殊的软中断, 也可以说是设计最保守的,但却是最简单、最安全的软中断。除此之外,还有其他的软中断,定义于 include/linux/interrupt.h :
// include/linux/interrupt.h
enum
{
HI_SOFTIRQ=0,
NET_TX_SOFTIRQ,
NET_RX_SOFTIRQ,
TASKLET_SOFTIRQ
};
这里最值得注意的是 TASKLET_SOFTIRQ,代表着一种称为 tasklet 的机制。也许采用 tasklet 这个词的原意在于表示这是一片小小的“任务”,但是这个词容易使人联想到 “task” 即进程而引起误会, 其实这二者毫无关系。显然,NET_TX_SOFTIRQ 和 NET_RX_SOFTIRQ 两种软中断是专为网络操作而设的,所以在 softirq_init() 中只对 TASKLET_SOFTIRQ 和 HI_SOFTIRQ 两种软中断进行初始化。
先看 bh 机制的初始化。内核中为 bh 机制设置了一个结构数组 bh_task_vec[],这是 tasklet_struct 数据结构的数组。这种数据结构的定义也在 interrupt.h中:
// include/linux/interrupt.h
/* Tasklets --- multithreaded analogue of BHs.
Main feature differing them of generic softirqs: tasklet
is running only on one CPU simultaneously.
Main feature differing them of BHs: different tasklets
may be run simultaneously on different CPUs.
Properties:
* If tasklet_schedule() is called, then tasklet is guaranteed
to be executed on some cpu at least once after this.
* If the tasklet is already scheduled, but its excecution is still not
started, it will be executed only once.
* If this tasklet is already running on another CPU (or schedule is called
from tasklet itself), it is rescheduled for later.
* Tasklet is strictly serialized wrt itself, but not
wrt another tasklets. If client needs some intertask synchronization,
he makes it with spinlocks.
*/
struct tasklet_struct
{
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
void (*func)(unsigned long);
unsigned long data;
};
代码的作者加了详细的注释,说 tasklet 是“多序” (不是“多进程”或“多线程”!) 的 bh 函数。 为什么这么说呢?因为对 tasklet 的串行化不像对 bh 函数那样严格,所以允许在不同的 CPU 上同时执行 tasklet ,但必须是不同的 tasklet 。一个 tasklet_struct 数据结构就代表着一个 tasklet ,结构中的函数指针 func 指向其服务程序。那么,为什么在 bh 机制中要使用这种数据结构呢?这是因为 bh 函数的执行 (并不是bh函数本身) 就是作为一个 tasklet 来实现的,在此基础上再加上更严格的限制,就成了 bh。
(2)open_softirq
// kernel/softirq.c
void open_softirq(int nr, void (*action)(struct softirq_action*), void *data)
{
unsigned long flags;
int i;
spin_lock_irqsave(&softirq_mask_lock, flags);
softirq_vec[nr].data = data;
softirq_vec[nr].action = action;
for (i=0; i<NR_CPUS; i++)
softirq_mask(i) |= (1<<nr);
spin_unlock_irqrestore(&softirq_mask_lock, flags);
}
内核中为软中断设置了一个以“软中断号”为下标的数组 softirq_vec[],类似于中断机制中的 irq_desc[] 。
// include/linux/interrupt.h
/* softirq mask and active fields moved to irq_cpustat_t in
* asm/hardirq.h to get better cache usage. KAO
*/
struct softirq_action
{
void (*action)(struct softirq_action *);
void *data;
};
数组 softirq_vec 是个全局量,系统中的各个 CPU 所看到的是同一个数组。但是,每个 CPU 各有其自己的“软中断控制/状况结构”,所以这些数据结构形成一个以 CPU 编号为下标的数组 irq_ 这个数组也是全局量,但是各个CPU可以按其自身的编号访问相应的数据结构。我们把有关的定义列出于下,供读者自己阅读:
// include/asm-i386/hardirq.h
/* entry.S is sensitive to the offsets of these fields */
typedef struct {
unsigned int __softirq_active;
unsigned int __softirq_mask;
unsigned int __local_irq_count;
unsigned int __local_bh_count;
unsigned int __syscall_count;
unsigned int __nmi_count; /* arch dependent */
} ____cacheline_aligned irq_cpustat_t;
// ===========================================================
// kernel/softirq.c
irq_cpustat_t irq_stat[NR_CPUS];
struct tasklet_head tasklet_vec[NR_CPUS] __cacheline_aligned;
struct tasklet_head tasklet_hi_vec[NR_CPUS] __cacheline_aligned;
// ===========================================================
// include/linux/irq_cpustat.h
#ifdef CONFIG_SMP
#define __IRQ_STAT(cpu, member) (irq_stat[cpu].member)
#else
#define __IRQ_STAT(cpu, member) ((void)(cpu), irq_stat[0].member)
#endif
/* arch independent irq_stat fields */
#define softirq_active(cpu) __IRQ_STAT((cpu), __softirq_active)
#define softirq_mask(cpu) __IRQ_STAT((cpu), __softirq_mask)
(3)tasklet_schedule 和 tasklet_hi_schedule
应用参考 (1)tasklet
// include/linux/interrupt.h
static inline void tasklet_schedule(struct tasklet_struct *t)
{
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state)) {
int cpu = smp_processor_id();
unsigned long flags;
local_irq_save(flags);
t->next = tasklet_vec[cpu].list;
tasklet_vec[cpu].list = t;
__cpu_raise_softirq(cpu, TASKLET_SOFTIRQ);
local_irq_restore(flags);
}
}
static inline void tasklet_hi_schedule(struct tasklet_struct *t)
{
if (!test_and_set_bit(TASKLET_STATE_SCHED, &t->state)) {
int cpu = smp_processor_id();
unsigned long flags;
local_irq_save(flags);
t->next = tasklet_hi_vec[cpu].list;
tasklet_hi_vec[cpu].list = t;
__cpu_raise_softirq(cpu, HI_SOFTIRQ);
local_irq_restore(flags);
}
}
这里的 smp_processor_id() 返回当前进程所在 CPU 的编号,然后以此为下标从 tasklet_hi_vec[] 中找到该 CPU 的队列头,把参数 t 所指的 tasklet_struct 数据结构链入这个队列。由此可见,对执行 bh 函数的要求是在哪一个 CPU 上提出的,就把它“调度”在哪一个 CPU 上执行,函数名中的 “schedule" 就是这个意思,而与“进程调度”毫无关系。另一方面,一个 tasklet_struct代表着对 bh 函数的一次执行,在同一时间内只能把它链入一个队列中,而不可能同时出现在多个队列中。对于同一个 tasklet_struct 数据结构,如果已经对其调用了 tasklet_hi_schedule() ,而尚未得到执行,就不允许再将其链入队列, 所以在数据结构中设置了一个标志位 TASKLET_STATE_SCHED 来保证这一点。最后,还要通过 __cpu_raise_softirq() 正式发出软中断请求。
(4)do_softirq
内核每当在 do_IRQ() 中执行完一个通道中的中断服务程序以后,以及每当从系统调用返回时,都要检查是否有软中断请求在等待执行。下面是 do_IRQ() 中的一个片段:
// arch/i386/kernel/irq.c
asmlinkage unsigned int do_IRQ(struct pt_regs regs)
{
// ...
if (softirq_active(cpu) & softirq_mask(cpu))
do_softirq();
return 1;
}
另一段代码取自 arch/i386/entry.S,这是在从系统调用返回时执行的:
// arch/i386/kernel/entry.S
ENTRY(ret_from_sys_call)
#ifdef CONFIG_SMP
movl processor(%ebx),%eax
shll $CONFIG_X86_L1_CACHE_SHIFT,%eax
movl SYMBOL_NAME(irq_stat)(,%eax),%ecx # softirq_active
testl SYMBOL_NAME(irq_stat)+4(,%eax),%ecx # softirq_mask
#else
movl SYMBOL_NAME(irq_stat),%ecx # softirq_active
testl SYMBOL_NAME(irq_stat)+4,%ecx # softirq_mask
#endif
jne handle_softirq
handle_softirq:
call SYMBOL_NAME(do_softirq)
jmp ret_from_intr
注意,这里的 processor 表示 task_struct 数据结构中该字段的位移,所以207行是从当前进程的 task_struct 数据结构中取当前 CPU 的编号。而 SYMBOL_NAME(irq_stat)(,%eax)则相当于 irq_stat[cpu], 并且是其中第一个字段;相应地,SYMBOL_NAME(irq_stat)+4(,%eax)相当这个数据结构中的第二个字段,并且第一个字段必须是 32 位。读者不妨回过去看一下 irq_cpustat_t 的定义,在那里有个注释, 说 entry.S 中的代码对这个数据结构中的字段位置敏感,就是这个意思。所以,这些汇编代码实际上与上面 do_IRQ() 中的两行C代码是一样的。
检测到软中断请求以后,就要通过 do_softirq() 加以执行了。其代码在 kemel/softirq.c中:
// kernel/softirq.c
asmlinkage void do_softirq()
{
int cpu = smp_processor_id();
__u32 active, mask;
if (in_interrupt())
return;
local_bh_disable();
local_irq_disable();
mask = softirq_mask(cpu);
active = softirq_active(cpu) & mask;
if (active) {
struct softirq_action *h;
restart:
/* Reset active bitmask before enabling irqs */
softirq_active(cpu) &= ~active;
local_irq_enable();
h = softirq_vec;
mask &= ~active;
do {
if (active & 1)
h->action(h);
h++;
active >>= 1;
} while (active);
local_irq_disable();
active = softirq_active(cpu);
if ((active &= mask) != 0)
goto retry;
}
local_bh_enable();
/* Leave with locally disabled hard irqs. It is critical to close
* window for infinite recursion, while we help local bh count,
* it protected us. Now we are defenceless.
*/
return;
retry:
goto restart;
}
软中断服务程序既不允许在一个硬中断服务程序内部执行,也不允许在一个软中断服务程序内部执行,所以要通过 1 个宏操作 in_interrupt() 加以检测,这是在 include/asm-i386/hardirq.h 中定义的。
在 2.4 版本中 h->action,执行的服务程序有 bh_action()、tasklet_action()、tasklet_hi_action()。后续版本 bh_action() 已去掉。
(5)tasklet_action
// kernel/softirq.c
static void tasklet_action(struct softirq_action *a)
{
int cpu = smp_processor_id();
struct tasklet_struct *list;
local_irq_disable();
list = tasklet_vec[cpu].list;
tasklet_vec[cpu].list = NULL;
local_irq_enable();
while (list != NULL) {
struct tasklet_struct *t = list;
list = list->next;
if (tasklet_trylock(t)) {
if (atomic_read(&t->count) == 0) {
clear_bit(TASKLET_STATE_SCHED, &t->state);
t->func(t->data);
/*
* talklet_trylock() uses test_and_set_bit that imply
* an mb when it returns zero, thus we need the explicit
* mb only here: while closing the critical section.
*/
#ifdef CONFIG_SMP
smp_mb__before_clear_bit();
#endif
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}
local_irq_disable();
t->next = tasklet_vec[cpu].list;
tasklet_vec[cpu].list = t;
__cpu_raise_softirq(cpu, TASKLET_SOFTIRQ);
local_irq_enable();
}
}
应用参考 (1)tasklet
这里 t->func(t->data) 调用使用 DECLARE_TASKLET(name, function, data) 定义的 tasklet_struct 结构中的 function 函数。
// include/linux/interrupt.h
/* Tasklets --- multithreaded analogue of BHs.
Main feature differing them of generic softirqs: tasklet
is running only on one CPU simultaneously.
Main feature differing them of BHs: different tasklets
may be run simultaneously on different CPUs.
Properties:
* If tasklet_schedule() is called, then tasklet is guaranteed
to be executed on some cpu at least once after this.
* If the tasklet is already scheduled, but its excecution is still not
started, it will be executed only once.
* If this tasklet is already running on another CPU (or schedule is called
from tasklet itself), it is rescheduled for later.
* Tasklet is strictly serialized wrt itself, but not
wrt another tasklets. If client needs some intertask synchronization,
he makes it with spinlocks.
*/
struct tasklet_struct
{
struct tasklet_struct *next;
unsigned long state;
atomic_t count;
void (*func)(unsigned long);
unsigned long data;
};
#define DECLARE_TASKLET(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }
#define DECLARE_TASKLET_DISABLED(name, func, data) \
struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(1), func, data }