linux操作系统基础概念(二) 操作系统总体介绍
Linux 作业系统 --- 简介转自 http://ccckmit.wikidot.com/lk:introductionLinux作业系统是Linus Torvalds于芬兰赫尔辛基大学当学生时,希望在IBM PC 个人电脑上实作出类似UNIX系统的一个专案。在Linux 刚发展时主要参考的对象是荷兰阿姆斯特丹大学教授Andrew S. Tanenbaum 的M
Linux 作业系统 --- 简介
转自 http://ccckmit.wikidot.com/lk:introduction
Linux作业系统是Linus Torvalds于芬兰赫尔辛基大学当学生时,希望在IBM PC 个人电脑上实作出类似UNIX系统的一个专案。在Linux 刚发展时主要参考的对象是荷兰阿姆斯特丹大学教授Andrew S. Tanenbaum 的Minix 系统,后来Torvalds 决定利用GNU 工具全面改写,于是发展出一个全新的作业系统,后来该作业系统被称为Linux。
Linux的系统架构大致分为『硬体』、『核心』、『函式库』、『使用者程式』等四层,
硬体层主要包含许多硬体装置的驱动程式、核心层乃是由Linus Torvalds所维护的Linux作业系统,而函式库层则对作业系统的功能进行封装后,提供给使用者程式呼叫使用。
图10.12 Linux 的基本架构
当行程需要作业系统服务(例如读取档案) 时,可以利用『系统呼叫』请求作业系统介入,此时处理器会由使用者模式(User Mode) 切换到核心模式(Kernel Mode),核心模式具有最高的权限,可以执行任何的动作。图10.12中的系统呼叫界面所扮演的,正是这样一个中介的角色。
Linux 所支援的硬体模组众多,这些模组必须被挂载到作业系统当中,当然不可能由Torvalds 一个人包办写出所有的驱动程式。所以Linux 定订了一整套输出入介面规格,透过注册机制与反向呼叫函数,让驱动程式得以挂载到作业系统中。作业系统会在适当的时机呼叫这些驱动函数,以便取得输出入资料。而这正是硬体模组介面的功能。这个介面可以载入Loadable Kernel Module (大部分都是驱动程式),以进行装置输出入的动作。
Linux 2.6 版的核心包含『行程』、『记忆体』、『档案』、『输出入』、『网路』等五大子系统。行程系统是支援行程与执行绪等功能,实作了排程、行程切换等程式。记忆体系统可利用硬体的MMU 单元支援虚拟记忆体等机制。档案系统的最上层称为虚拟档案系统(Virtual File System: VFS) ,VFS 是一组档案操作的抽象介面,我们可以将任何的真实档案系统,像是FAT32, EXT2, JFS 等,透过VFS 挂载到Linux 中。真实档案系统是在档案结构的组织下,利用区块装置驱动模组所建构出来的。网路系统也是透过网路装置驱动模组与TCP/IP 相关程式码所堆叠出来的。而输出入系统则统合了『区块、字元、网路』等三类装置,以支援档案、网路与虚拟记忆体等子系统。
Linux 是一个注重速度与实用性的系统,因此没有采用微核心的技术,以避免因为行程切换次数过多而减慢执行速度。目前围绕着Linux 作业系统已经形成了一个庞大的产业,几乎没有任何一家公司能主导Linux 的发展方向,因为Linux 是开放原始码社群被工业化后的结果。
由于开放原始码的影响,Linux拥有众多的版本,像是Red Hat、Ubuntu、Fedora、Debian 等,但是这些版本几乎都利用Tovarlds 所维护的核心,整合其他开放原始码软体后所形成的,因此虽然版本众多却有统一的特性。
由于Linux 原本是以GNU 工具所开发的,因此也被称为GNU/Linux。由于GNU工具支援IEEE 所制定的POSIX 标准,该标准对UNIX 平台的函式库进行了基本的统一动作,因此Linux 自然也就属于POSIX 标准的成员之一。
虽然Tovarlds 最早是利用IBM PC 开发Linux 作业系统的,但是目前Linux 已经被移值到各种平台上。因此Linux 所支援的处理器非常众多,包含IA32、MIPS、ARM、Power PC 等。当您想要将Linux 移植到新的处理器上时,必须重新编译Linux 核心,您可以利用GNU 的gcc, make 等工具编译Linux 核心与大部分的Linux 程式。
Linux 并不是一个小型的作业系统,因此在启动时通常必须透过启动载入器载入Linux 。在桌上型电脑中,常被使用的Linux启动载入器有GRUB、LILO 等。但是在高阶的嵌入式系统当中,Linux 最常用的启动程式则是U-Boot,这是因为U-Boot 所支援的处理器非常众多,因此成为目前最广为使用的嵌入式启动载入器。
在本系列的文章中,我们将分别就Linux 中的行程、记忆体、输出入、档案等四大子系统,分别进行说明,以便让读者能更进一步的理解Linux 作业系统。
=============================================================================
Linux 作业系统 --- 中断机制
转自 http://ccckmit.wikidot.com/lk:int
一直认为,理解中断是理解内核的开始。中断已经远远超过仅仅为外围设备服务的范畴,它是现代体系结构的重要组成部分。
1、基本输入输出方式
现代体系结构的基本输入输出方式有三种:
(1)程序查询:
CPU周期性询问外部设备是否准备就绪。该方式的明显的缺点就是浪费CPU资源,效率低下。
但是,不要轻易的就认为该方式是一种不好的方式(漂亮的女人不一定好,不漂亮的女人通常很可爱),通常效率低下是由于CPU在大部分时间没事可做造成的,这种轮询方式自有应用它的地方。例如,在网络驱动中,通常接口(Interface)每接收一个报文,就发出一个中断。而对于高速网络,每秒就能接收几千个报文,在这样的负载下,系统性能会受到极大的损害。
为了提高系统性能,内核开发者已经为网络子系统开发了一种可选的基于查询的接口NAPI(代表new API)。当系统拥有一个高流量的高速接口时,系统通常会收集足够多的报文,而不是马上中断CPU。
(2)中断方式
这是现代CPU最常用的与外围设备通信方式。相对于轮询,该方式不用浪费稀缺的CPU资源,所以高效而灵活。中断处理方式的缺点是每传送一个字符都要进行中断,启动中断控制器,还要保留和恢复现场以便能继续原程序的执行,花费的工作量很大,这样如果需要大量数据交换,系统的性能会很低。
(3)DMA方式
通常用于高速设备,设备请求直接访问内存,不用CPU干涉。但是这种方式需要DMA控制器,增加了硬件成本。在进行DMA数据传送之前,DMA控制器会向CPU申请总线控制 权,CPU如果允许,则将控制权交出,因此,在数据交换时,总线控制权由DMA控制器掌握,在传输结束后,DMA控制器将总线控制权交还给CPU。
2、中断概述
2.1、中断向量
X86支持256个中断向量,依次编号为0~255。它们分为两类:
(1)异常,由CPU内部引起的,所以也叫同步中断,不能被CPU屏蔽;它又分为Faults(可更正异常,恢复后重新执行),Traps(返回后执行发生trap指令的后一条指令)和Aborts(无法恢复,系统只能停机);
(2)中断,由外部设备引起的。它又分为可屏蔽中断(INTR)和非可屏蔽中断(NMI)。
Linux对256个中断向量分配如下:
(1)0~31为异常和非屏蔽中断,它实际上被Intel保留。
(2)32~47为可屏蔽中断。
(3)余下的48~255用来标识软中断;Linux只用了其中一个,即128(0x80),用来实现系统调用。当用户程序执行一条int 0x80时,就会陷入内核态,并执行内核函数system_call(),该函数与具体的架构相关。
2.2、可屏蔽中断
X86通过两个级连的8259A中断控制器芯片来管理15个外部中断源,如图所示:
外部设备要使用中断线,首先要申请中断号(IRQ),每条中断线的中断号IRQn对应的中断向量为n+32,IRQ和向量之间的映射可以通过中断控制器商端口来修改。X86下8259A的初始化工作及IRQ与向量的映射是在函数init_8259A()(位于arch/i386/kernel/i8259.c)完成的。
CPU通过INTR引脚来接收8259A发出的中断请求,而且CPU可以通过清除EFLAG的中断标志位(IF)来屏蔽外部中断。当IF=0时,禁止任何外部I/O请求,即关中断(对应指令cli)。另外,中断控制器有一个8位的中断屏蔽寄存器(IMR),每位对应8259A中的一条中断线,如果要禁用某条中断线,相应的位置1即可,要启用,则置0。
IF标志位可以使用指令STI和CLI来设置或清除。并且只有当程序的CPL<=IOPL时才可执行这两条指令,否则将引起一般保护性异常(通常来说,in,ins,out,outs,cli,sti只有在CPL<=IOPL时才能执行,这些指令称为I/O敏感指令)。
以下一些操作也会影响IF标志位:
(1)PUSHF指令将EFLAGS内容存入堆栈,且可以在那里修改。POPF可将已经修改过的内容写入EFLAGS寄存器。
(2)任务切换和IRET指令会加载EFLAGS寄存器。因此,可修改IF标志。
(3)通过中断门处理一个中断时,IF标志位被自动清除,从而禁止可尽屏蔽中断。但是,陷阱门不会复位IF。
2.3、异常及非屏蔽中断
异常就是CPU内部出现的中断,也就是说,在CPU执行特定指令时出现的非法情况。非屏蔽中断就是计算机内部硬件出错时引起的异常情况。从上图可以看出,二者与外部I/O接口没有任何关系。Intel把非屏蔽中断作为异常的一种来处理,因此,后面所提到的异常也包括了非屏蔽中断。在CPU执行一个异常处理程序时,就不再为其他异常或可屏蔽中断请求服务,也就是说,当某个异常被响应后,CPU清除EFLAG的中IF位,禁止任何可屏蔽中断(IF不能禁止异常和非可屏蔽中断)。但如果又有异常产生,则由CPU锁存(CPU具有缓冲异常的能力),待这个异常处理完后,才响应被锁存的异常。我们这里讨论的异常中断向量在0~31之间,不包括系统调用(中断向量为0x80)。
2.4、中断描述符表
2.4.1、中断描述符
在实地址模式中,CPU把内存中从0开始的1K字节作为一个中断向量表。表中的每个表项占四个字节,由两个字节的段地址和两个字节的偏移量组成,这样构成的地址便是相应中断处理程序的入口地址。但是,在保护模式下,由四字节的表项构成的中断向量表显然满足不了要求。这是因为,除了两个字节的段描述符,偏移量必用四字节来表示;要有反映模式切换的信息。因此,在保护模式下,中断向量表中的表项由8个字节组成,中断向量表也改叫做中断描述符表IDT(Interrupt Descriptor Table)。其中的每个表项叫做一个门描述符(gate descriptor),“门”的含义是当中断发生时必须先通过这些门,然后才能进入相应的处理程序。门描述符的一般格式如下:
中断描述符表中可放三类门描述符:
(1)中断门(Interrupt gate)
其类型码为110,它包含一个中断或异常处理程序所在的段选择符和段内偏移。控制权通过中断门进入中断处理程序时,处理器清IF标志,即关中断,以避免嵌套中断的发生。中断门中的DPL(Descriptor Privilege Level)为0,因此,用户态的进程不能访问Intel的中断门。所有的中断处理程序都由中断门激活,并全部限制在内核态。设置中断门的代码如下:
- //n为中断向量号,addr为中断处理程序地址,位于arch/i386/kernel/traps.c
- void set_intr_gate(unsigned int n, void *addr)
- { //type=14,dpl=0,selector=__KERNEL_CS
- _set_gate(idt_table+n,14,0,addr,__KERNEL_CS);
- }
Idt_table为中断描述符表,其定义位于arch/i386/kernel/traps.c中,如下:
- //中断描述符表
- struct desc_struct idt_table[256] __attribute__((__section__(".data.idt"))) = { {0, 0}, };
- //描述符结构
- struct desc_struct {
- unsigned long a,b;
- };
其类型码为111,与中断门类似,其唯一的区别是,控制权通过陷阱门进入处理程序时维持IF标志位不变,也就是说,不关中断。其设置代码如下:
- static void __init set_trap_gate(unsigned int n, void *addr)
- {
- _set_gate(idt_table+n,15,0,addr,__KERNEL_CS);
- }
IDT中的任务门描述符格式与GDT和LDT中的任务门格式相同,含有一个任务TSS段的选择符,该任务用于处理异常或中断,Linux用于处理Double fault。其设置代码如下:
- static void __init set_task_gate(unsigned int n, unsigned int gdt_entry)
- {
- _set_gate(idt_table+n,5,0,0,(gdt_entry<<3));
- }
此外,在Linux中还有系统门(System gate),用于处理用户态下的异常overflow,bound以及系统调用int 0x80;以及系统中断门(system interrupt gate),用来处理int3,这样汇编指令int3就能在用户态下调用。
- static void __init set_system_gate(unsigned int n, void *addr)
- {
- _set_gate(idt_table+n,15,3,addr,__KERNEL_CS);
- }
- //设置系统调用门描述符,在trap.c中被trap_init()调用
- set_system_gate(SYSCALL_VECTOR,&system_call);
- //设置系统中断门
- static inline void set_system_intr_gate(unsigned int n, void *addr)
- {
- _set_gate(idt_table+n, 14, 3, addr, __KERNEL_CS);
- }
- //位于arch/i386/kernel/traps.c
- void __init trap_init(void)
- {
- set_trap_gate(0,÷_error);
- set_intr_gate(1,&debug);
- set_intr_gate(2,&nmi);
- //系统中断门
- set_system_intr_gate(3, &int3); /* int3-5 can be called from all */
- //系统门
- set_system_gate(4,&overflow);
- set_system_gate(5,&bounds);
- set_trap_gate(6,&invalid_op);
- set_trap_gate(7,&device_not_available);
- set_task_gate(8,GDT_ENTRY_DOUBLEFAULT_TSS);
- set_trap_gate(9,&coprocessor_segment_overrun);
- set_trap_gate(10,&invalid_TSS);
- set_trap_gate(11,&segment_not_present);
- set_trap_gate(12,&stack_segment);
- set_trap_gate(13,&general_protection);
- set_intr_gate(14,&page_fault);
- set_trap_gate(15,&spurious_interrupt_bug);
- set_trap_gate(16,&coprocessor_error);
- set_trap_gate(17,&alignment_check);
- #ifdef CONFIG_X86_MCE
- set_trap_gate(18,&machine_check);
- #endif
- set_trap_gate(19,&simd_coprocessor_error);
- set_system_gate(SYSCALL_VECTOR,&system_call);
- }
中断描述表的最终初始化是init/main.c中的start_kernel()中完成的
- asmlinkage void __init start_kernel(void)
- {
- //陷阱门初始化
- trap_init();
- //中断门初始化
- init_IRQ();
- //软中断初始化
- softirq_init();
- }
- //位于arch/i386/kernel/i8259.c
- void __init init_IRQ(void)
- {
- //调用init_ISA_irqs
- pre_intr_init_hook();
- //设置中断门
- for (i = 0; i < (NR_VECTORS - FIRST_EXTERNAL_VECTOR); i++) {
- int vector = FIRST_EXTERNAL_VECTOR + i;
- if (i >= NR_IRQS)
- break;
- //跳过系统调用的向量
- if (vector != SYSCALL_VECTOR)
- set_intr_gate(vector, interrupt[i]);
- }
- }
3、内核的中断处理
3.1、中断处理入口
由上节可知,中断向量的对应的处理程序位于interrupt数组中,下面来看看interrupt:
- 341 .data #数据段
- 342 ENTRY(interrupt)
- 343 .text
- 344
- 345 vector=0
- 346 ENTRY(irq_entries_start)
- 347 .rept NR_IRQS #348-354行重复NR_IRQS次
- 348 ALIGN
- 349 1: pushl $vector-256 #vector在354行递增
- 350 jmp common_interrupt #所有的外部中断处理函数的统一部分,以后再讲述
- 351 .data
- 352 .long 1b #存储着指向349行的地址,但是随着348行-354被gcc展开,每次的值都不同
- 353 .text
- 354 vector=vector+1
- 355 .endr #与347行呼应
- 356
- 357 ALIGN
- #公共处理函数
- common_interrupt:
- SAVE_ALL /*寄存器值入栈*/
- movl %esp,%eax /*栈顶指针保存到eax*/
- call do_IRQ /*处理中断*/
- jmp ret_from_intr /*从中断返回*/
首先342行和352行都处于.data段,虽然看起来它们是隔开的,但实际上被gcc安排在了连续的数据段内存 中,同理在代码段内存中,354行与350行的指令序列也是连续存储的。另外,348-354行会被gcc展开NR_IRQS次,因此每次352行都会存 储一个新的指针,该指针指向每个349行展开的新对象。最后在代码段内存中连续存储了NR_IRQS个代码片断,首地址由 irq_entries_start指向。而在数据段内存中连续存储了NR_IRQS个指针,首址存储在interrupt这个全局变量中。这样,例如 IRQ号是0 (从init_IRQ()调用,它对应的中断向量是FIRST_EXTERNAL_VECTOR)的中断通过中断门后会触发 interrput[0],从而执行:
pushl 0-256
jmp common_interrupt
的代码片断,进入到Linux内核安排好的中断入口路径。
3.2、数据结构
3.2.1、IRQ描述符
Linux支持多个外设共享一个IRQ,同时,为了维护中断向量和中断服务例程(ISR)之间的映射关系,Linux用一个irq_desc_t数据结构来描述,叫做IRQ描述符。除了分配给异常的
32个向量外,其余224(NR_IRQS)个中断向量对应的IRQ构成一个数组irq_desc[],定义如下:
//位于linux/irq.h
typedef struct irq_desc {
unsigned int status;
hw_irq_controller *handler;
struct irqaction *action;
unsigned int depth;
unsigned int irq_count;
unsigned int irqs_unhandled;
spinlock_t lock;
} ____cacheline_aligned irq_desc_t;
//IRQ描述符表
extern irq_desc_t irq_desc [NR_IRQS];
“____cacheline_aligned”表示这个数据结构的存放按32字节(高速缓存行的大小)进行对齐,以便于将来存放在高速缓存并容易存取。
status:描述IRQ中断线状态,在irq.h中定义。如下:
#define IRQ_INPROGRESS 1
#define IRQ_DISABLED 2
#define IRQ_PENDING 4
#define IRQ_REPLAY 8
#define IRQ_AUTODETECT 16
#define IRQ_WAITING 32
#define IRQ_LEVEL 64
#define IRQ_MASKED 128
#define IRQ_PER_CPU 256
action:指向一个单向链表的指针,这个链表就是对中断服务例程进行描述的irqaction结构。下面有描述。
depth:如果启用这条IRQ中断线,depth则为0,如果禁用这条IRQ中断线不止一次,则为一个正数。每当调用一次disable_irq(),该函数就对这个域的值加1;如果depth等于0,该函数就禁用这条IRQ中断线。相反,每当调用enable_irq()函数时,该函数就对这个域的值减1;如果depth变为0,该函数就启用这条IRQ中断线。
lock:保护该数据结构的自旋锁。
IRQ描述符的初始化:
//位于arch/i386/kernel/i8259.c
void __init init_ISA_irqs (void)
{
int i;
#ifdef CONFIG_X86_LOCAL_APIC
init_bsp_APIC();
#endif
//初始化8259A
init_8259A(0);
//IRQ描述符的初始化
for (i = 0; i < NR_IRQS; i++) {
irq_desc[i].status = IRQ_DISABLED;
irq_desc[i].action = NULL;
irq_desc[i].depth = 1;
if (i < 16) {
irq_desc[i].handler = &i8259A_irq_type;
} else {
irq_desc[i].handler = &no_irq_type;
}
}
}
从这段程序可以看出,初始化时,让所有的中断线都处于禁用状态;每条中断线上还没有任何中断服务例程(action为0);因为中断线被禁用,因此depth为1;对中断控制器的描述分为两种情况,一种就是通常所说的8259A,另一种是其它控制器。
这个描述符包含一组指针,指向与特定的可编程中断控制器电路(PIC)打交道的低级I/O例程,定义如下:
//位于linux/irq.h
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, cpumask_t dest);
};
typedef struct hw_interrupt_type hw_irq_controller;
//位于arch/i386/kernel/i8259.c
static struct hw_interrupt_type i8259A_irq_type = {
"XT-PIC",
startup_8259A_irq,
shutdown_8259A_irq,
enable_8259A_irq,
disable_8259A_irq,
mask_and_ack_8259A,
end_8259A_irq,
NULL
};
3.2.3、中断服务例程描述符irqaction
为了处理多个设备共享一个IRQ,Linux中引入了irqaction数据结构。定义如下:
//位于linux/interrupt.h
struct irqaction {
irqreturn_t (*handler)(int, void *, struct pt_regs *);
unsigned long flags;
cpumask_t mask;
const char *name;
void *dev_id;
struct irqaction *next;
int irq;
struct proc_dir_entry *dir;
};
flags:用一组标志描述中断线与I/O设备之间的关系。
SA_INTERRUPT
中断处理程序必须以禁用中断来执行
SA_SHIRQ
该设备允许其中断线与其他设备共享。
SA_SAMPLE_RANDOM
可以把这个设备看作是随机事件发生源;因此,内核可以用它做随机数产生器。(用户可以从/dev/random 和/dev/urandom设备文件中取得随机数而访问这种特征)
SA_PROBE
内核在执行硬件设备探测时正在使用这条中断线。
name:I/O设备名(通过读取/proc/interrupts文件,可以看到,在列出中断号时也显示设备名。)
dev_id:指定I/O设备的主设备号和次设备号。
next:指向irqaction描述符链表的下一个元素。共享同一中断线的每个硬件设备都有其对应的中断服务例程,链表中的每个元素就是对相应设备及中断服务例程的描述。
irq:IRQ线。
3.2.4、中断服务例程(Interrupt Service Routine)
在Linux中,中断服务例程和中断处理程序(Interrupt Handler)是两个不同的概念。可以这样认为,中断处理程序相当于某个中断向量的总处理程序,它与中断描述表(IDT)相关;中断服务例程(ISR)在中断处理过程被调用,它与IRQ描述符相关,一般来说,它是设备驱动的一部分。
(1) 注册中断服务例程
中断服务例程是硬件驱动的组成部分,如果设备要使用中断,相应的驱动程序在初始化的过程中可以通过调用request_irq函数注册中断服务例程。
//位于kernel/irq/manage.c
int request_irq(unsigned int irq,
irqreturn_t (*handler)(int, void *, struct pt_regs *),
unsigned long irqflags, const char * devname, void *dev_id)
{
struct irqaction * action;
int retval;
if ((irqflags & SA_SHIRQ) && !dev_id)
return -EINVAL;
if (irq >= NR_IRQS)
return -EINVAL;
if (!handler)
return -EINVAL;
//分配数据结构空间
action = kmalloc(sizeof(struct irqaction), GFP_ATOMIC);
if (!action)
return -ENOMEM;
action->handler = handler;
action->flags = irqflags;
cpus_clear(action->mask);
action->name = devname;
action->next = NULL;
action->dev_id = dev_id;
//调用setup_irq完成真正的注册,驱动程序也可以调用它来完成注册
retval = setup_irq(irq, action);
if (retval)
kfree(action);
return retval;
}
//位于driver/char/rtc.c
static int __init rtc_init(void)
{
request_irq(RTC_IRQ, rtc_int_handler_ptr, SA_INTERRUPT, "rtc", NULL);
}
//位于arch/i386/mach_default/setup.c
static struct irqaction irq0 = { timer_interrupt, SA_INTERRUPT, CPU_MASK_NONE, "timer", NULL, NULL};
//由time_init()调用
void __init time_init_hook(void)
{
setup_irq(0, &irq0);
}
整个流程如下:
所有I/O中断处理函数的过程如下:
(1)把IRQ值和所有寄存器值压入内核栈;
(2) 给与IRQ中断线相连的中断控制器发送一个应答,这将允许在这条中断线上进一步发出中断请求;
(3)执行共享这个IRQ的所有设备的中断服务例程(ISR);
(4)跳到ret_from_intr()处结束。
3.3.1、保存现场与恢复现场
中断处理程序做的第一件事就是保存现场,由宏SAVE_ALL(位于entry.S中)完成:
#define SAVE_ALL \
cld; \
pushl %es; \
pushl %ds; \
pushl �x; \
pushl �p; \
pushl �i; \
pushl %esi; \
pushl �x; \
pushl �x; \
pushl �x; \
movl $(__USER_DS), �x; \
movl �x, %ds; \
movl �x, %es;
恢复现场由宏RESTORE_ALL完成
#define RESTORE_ALL \
RESTORE_REGS \
addl $4, %esp; \
1: iret; \
.section .fixup,"ax"; \
2: sti; \
movl $(__USER_DS), �x; \
movl �x, %ds; \
movl �x, %es; \
movl $11,�x; \
call do_exit; \
.previous; \
.section __ex_table,"a";\
.align 4; \
.long 1b,2b; \
.previous
该函数的大致内容如下:
//arch/i386/kernel/irq.c
fastcall unsigned int do_IRQ(struct pt_regs *regs)
{
//取得中断号
int irq = regs->orig_eax & 0xff;
//增加代表嵌套中断数量的计数器的值,该值保存在current->thread_info->preempt_count
irq_enter();
__do_IRQ(irq, regs);
//减中断计数器preempt_count的值,检查是否有软中断要处理
irq_exit();
}
struct pt_regs {
long ebx;
long ecx;
long edx;
long esi;
long edi;
long ebp;
long eax;
int xds;
int xes;
long orig_eax;
long eip;
int xcs;
long eflags;
long esp;
int xss;
};
与内核栈相比,是内核栈中内容的一致。
该函数的内容如下:
//位于kernel/irq/handle.c
fastcall unsigned int __do_IRQ(unsigned int irq, struct pt_regs *regs)
{
irq_desc_t *desc = irq_desc + irq;
struct irqaction * action;
unsigned int status;
kstat_this_cpu.irqs[irq]++;
if (desc->status & IRQ_PER_CPU) {
irqreturn_t action_ret;
//确认中断
desc->handler->ack(irq);
action_ret = handle_IRQ_event(irq, regs, desc->action);
if (!noirqdebug)
note_interrupt(irq, desc, action_ret);
desc->handler->end(irq);
return 1;
}
spin_lock(&desc->lock);
desc->handler->ack(irq);
//清除IRQ_REPLAY和IRQ_WAITING标志
status = desc->status & ~(IRQ_REPLAY | IRQ_WAITING);
status |= IRQ_PENDING;
action = NULL;
if (likely(!(status & (IRQ_DISABLED | IRQ_INPROGRESS)))) {
action = desc->action;
//清除IRQ_PENDING标志
status &= ~IRQ_PENDING;
status |= IRQ_INPROGRESS;
}
desc->status = status;
if (unlikely(!action))
goto out;
for (;;) {
irqreturn_t action_ret;
//释放自旋锁
spin_unlock(&desc->lock);
action_ret = handle_IRQ_event(irq, regs, action);
//加自旋锁
spin_lock(&desc->lock);
if (!noirqdebug)
note_interrupt(irq, desc, action_ret);
if (likely(!(desc->status & IRQ_PENDING)))
break;
desc->status &= ~IRQ_PENDING;
}
desc->status &= ~IRQ_INPROGRESS;
out:
//结束中断处理.对end_8259A_irq()仅仅是重新激活中断线.
desc->handler->end(irq);
//最后,释放自旋锁,
spin_unlock(&desc->lock);
return 1;
}
//kernel/irq/handle.c
fastcall int handle_IRQ_event(unsigned int irq, struct pt_regs *regs,
struct irqaction *action)
{
int ret, retval = 0, status = 0;
//开启本地中断,对于单CPU,仅仅是sti指令
if (!(action->flags & SA_INTERRUPT))
local_irq_enable();
//依次调用共享该中断向量的服务例程
do {
//调用中断服务例程
ret = action->handler(irq, action->dev_id, regs);
if (ret == IRQ_HANDLED)
status |= action->flags;
retval |= ret;
action = action->next;
} while (action);
if (status & SA_SAMPLE_RANDOM)
add_interrupt_randomness(irq);
//关本地中断,对于单CPU,为cli指令
local_irq_disable();
return retval;
}
4、下半部
在中断处理过程中,不能睡眠。另外,它运行的时候,会把当前中断线在所有处理器上都屏蔽(在ack中完成屏蔽);更糟糕的情况是,如果一个处理程序是SA_INTERRUPT类型,它执行的时候会禁上所有本地中断(通过cli指令完成),所以,中断处理应该尽可能快的完成。所以Linux把中断处理分为上半部和下半部。
上半部由中断处理程序完成,它通常完成一些和硬件相关的操作,比如对中断的到达的确认。有时它还会从硬件拷贝数据,这些工作对时间非常敏感,只能靠中断处理程序自己完成。而把其它工作放到下半部实现。
下半部的执行不需要一个确切的时间,它会在稍后系统不太繁忙时执行。下半部执行的关键在于运行的时候允许响应所有的中断。最早,Linux用”bottom half”实现下半部,这种机制简称BH,但是即使属于不同的处理器,也不允许任何两个bottom half同时执行,这种机制简单,但是却有性能瓶颈。不久,又引入任务队列(task queue)机制来实现下半部,但该机制仍不够灵活,没法代替整个BH接口。
从2.3开始,内核引入软中断(softirqs)和tasklet,并完全取代了BH。2.5中,BH最终舍去,在2.6中,内核用有三种机制实现下半部:软中断,tasklet和工作队列。Tasklet是基于软中断实现的。软中断可以在多个CPU上同时执行,即使它们是同一类型的,所以,软中断处理程序必须是可重入的,或者显示的用自旋锁保护相应的数据结构。而相同的tasklet不能同时在多个CPU上执行,所以tasklet不必是可重入的;但是,不同类型的tasklet可以在多个CPU上同时执行。一般来说,tasklet比较常用,它可以处理绝大部分的问题;而软中断用得比较少,但是对于时间要求较高的地方,比如网络子系统,常用软中断处理下半部工作。
4.1、软中断
内核2.6中定义了6种软中断:
下标越低,优先级越高。
4.1.1、数据结构
(1)软中断向量
- //linux/interrupt.h
- struct softirq_action
- {
- void (*action)(struct softirq_action *); //待执行的函数
- void *data; //传给函数的参数
- };
- //kernel/softirq.c
- //软中断向量数组
- static struct softirq_action softirq_vec[32] __cacheline_aligned_in_smp;
(2) preempt_count字段
位于任务描述符的preempt_count是用来跟踪内核抢占和内核控制路径嵌套关键数据。其各个位的含义如下:
位 描述
0——7 preemption counter,内核抢占计数器(最大值255)
8——15 softirq counter,软中断计数器(最大值255)
16——27 hardirq counter,硬件中断计数器(最大值4096)
28 PREEMPT_ACTIVE标志
第一个计数用来表示内核抢占被关闭的次数,0表示可以抢占。第二个计数器表示推迟函数(下半部)被关闭的次数,0表示推迟函数打开。第三个计数器表示本地CPU中断嵌套的层数,irq_enter()增加该值,irq_exit减该值。
宏in_interrupt()检查current_thread_info->preempt_count的hardirq和softirq来断定是否处于中断上下文。如果这两个计数器之一为正,则返回非零。
(3) 软中断控制/状态结构
softirq_vec是个全局量,系统中每个CPU所看到的是同一个数组。但是,每个CPU各有其自己的“软中断控制/状态”结构,这些数据结构形成一个以CPU编号为下标的数组irq_stat[](定义在include/asm-i386/hardirq.h中)
- typedef struct {
- unsigned int __softirq_pending;
- unsigned long idle_timestamp;
- unsigned int __nmi_count; /* arch dependent */
- unsigned int apic_timer_irqs; /* arch dependent */
- } ____cacheline_aligned irq_cpustat_t;
- //位于kernel/softirq.c
- irq_cpustat_t irq_stat[NR_CPUS] ____cacheline_aligned;
可以通过open_softirq注册软中断处理程序:
//位于kernel/softirq.c
//nr:软中断的索引号
// softirq_action:处理函数
//data:传递给处理函数的参数值
void open_softirq(int nr, void (*action)(struct softirq_action*), void *data)
{
softirq_vec[nr].data = data;
softirq_vec[nr].action = action;
}
//软中断初始化
void __init softirq_init(void)
{
open_softirq(TASKLET_SOFTIRQ, tasklet_action, NULL);
open_softirq(HI_SOFTIRQ, tasklet_hi_action, NULL);
}
软中断执行时,允许响应中断,但它自己不能睡眠,4.1.3、触发软中断raise_softirq会将软中断设置为挂起状态,并在下一次运行do_softirq中投入运行。
//位于kernel/softirq.c
void fastcall raise_softirq(unsigned int nr)
{
unsigned long flags;
//保存IF值,并关中断
local_irq_save(flags);
//调用wakeup_softirqd()
raise_softirq_irqoff(nr);
//恢复IF值
local_irq_restore(flags);
}
inline fastcall void raise_softirq_irqoff(unsigned int nr)
{
//把软中断设置为挂起状态
__raise_softirq_irqoff(nr);
//唤醒内核线程
if (!in_interrupt())
wakeup_softirqd();
}
该函数触发软中断前,先要关闭中断,之后再恢复;如果之前中断已经关闭,可以直接调用raise_softirq_irqoff()触发软中断。
在中断服务例程中触发软中断是最常见的形式。而中断服务例程通常作为设备驱动的一部分。例如,对于网络设备,当接口收到数据时,会产生一个中断,在中断服务例程中,最终会调用netif_rx函数处理接到的数据,而netif_rx作相应处理,最终以触发一个软中断结束处理。之后,内核在执行中断处理任务后,会调用do_softirq()。于是软中断就通过软中断处理函数去处理留给它的任务。
4.1.4、软中断执行
(1) do_softirq()函数
//处理软中断,位于arch/i386/kernel/irq.c
asmlinkage void do_softirq(void)
{
//处于中断上下文,表明软中断是在中断上下文中触发的,或者软中断被关闭
/*这个宏限制了软中断服务例程既不能在一个硬中断服务例程内部执行,
*也不能在一个软中断服务例程内部执行(即嵌套)。但这个函数并没有对中断服务例程的执行
*进行“串行化”限制。这也就是说,不同的CPU可以同时进入对软中断服务例程的执行,每个CPU
*分别执行各自所请求的软中断服务。从这个意义上说,软中断服务例程的执行是“并发的”、多序的。
*但是,这些软中断服务例程的设计和实现必须十分小心,不能让它们相互干扰(例如通过共享的全局变量)。
*/
if (in_interrupt())
return;
//保存IF值,并关中断
local_irq_save(flags);
//调用__do_softirq
asm volatile(
" xchgl %%ebx,%%esp \n"
" call __do_softirq \n"
" movl %%ebx,%%esp \n"
: "=b"(isp)
: "0"(isp)
: "memory", "cc", "edx", "ecx", "eax"
);
//恢复IF值
local_irq_restore(flags);
(2)__do_softirq()函数
//执行软中断,位于kernel/softirq.c
asmlinkage void __do_softirq(void)
{
struct softirq_action *h;
__u32 pending;
/*最多迭代执行10次.在执行软中断的过程中,由于允许中断,所以新的软中断可能产生.为了使推迟函数能够在
*较短的时间延迟内执行,__do_softirq会执行所有挂起的软中断,这可能会执行太长的时间而大大延迟返回用户
*空间的时间.所以,__do_softirq最多允许10次迭代.剩下的软中断在软中断内核线程ksoftirqd中处理.
*/
int max_restart = MAX_SOFTIRQ_RESTART;
int cpu;
//用局部变量保存软件中断位图
pending = local_softirq_pending();
/*增加softirq计数器的值.由于执行软中断时允许中断,当do_IRQ调用irq_exit时,另一个__do_softirq实例可能
*开始执行.这是不允许的,推迟函数必须在CPU上串行执行.
*/
local_bh_disable();
cpu = smp_processor_id();
restart:
/* Reset the pending bitmask before enabling irqs */
//重置软中断位图,使得新的软中断可以发生
local_softirq_pending() = 0;
//开启本地中断,执行软中断时,允许中断的发生
local_irq_enable();
h = softirq_vec;
do {
if (pending & 1) {
//执行软中断处理函数
h->action(h);
rcu_bh_qsctr_inc(cpu);
}
h++;
pending >>= 1;
} while (pending);
//关闭中断
local_irq_disable();
//再一次检查软中断位图,因为在执行软中断处理函数时,新的软中断可能产生.
pending = local_softirq_pending();
if (pending && --max_restart)
goto restart;
/*如果还有多的软中断没有处理,通过wakeup_softirqd唤醒内核线程处理本地CPU余下的软中断.
*/
if (pending)
wakeup_softirqd();
//减softirq counter的值
__local_bh_enable();
}
(3)软中断执行点
内核会周期性的检查是否有挂起的软中断,它们位于内核代码的以下几个点:
(1)内核调用local_bh_enable()函数打开本地CPU的软中断:
//位于kernel/softirq.c
void local_bh_enable(void)
{
preempt_count() -= SOFTIRQ_OFFSET - 1;
if (unlikely(!in_interrupt() && local_softirq_pending()))
do_softirq(); //软中断处理
//……
}
====================================================================================
Linux 作业系统 --- 行程管理
转自 http://ccckmit.wikidot.com/lk:process
Linux 中的行程(Process) 被称为任务(Task),其资料结构是一个称为task_struct 的C 语言结构,该结构所记录的栏位相当多,在Linux 2.6.29.4 版当中光是该结构的宣告就占了306 行的程式码,范例1 显示了该结构的开头与结束部分,由此可见要实作一个作业系统是相当不容易的工程。
范例1 Linux 中的Task 之结构
行号Linux 2.6.29.4版核心原始码include/linux/sched.h档案
… …
1115 struct task_struct {
1116 volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
1117 void *stack;
1118 atomic_t usage;
1119 unsigned int flags; /* per process flags, defined below */
1120 unsigned int ptrace;
… …
1263 /* CPU-specific state of this task */
1264 struct thread_struct thread;
1265 /* filesystem information */
1266 struct fs_struct *fs;
1267 /* open file information */
1268 struct files_struct *files;
1269 /* namespaces */
1270 struct nsproxy *nsproxy;
1271 /* signal handlers */
1272 struct signal_struct *signal;
1273 struct sighand_struct *sighand;
… …
1417 #ifdef CONFIG_TRACING
1418 /* state flags for use by tracers */
1419 unsigned long trace;
1420 #endif
1421 };
… …
Linux 的行程结构中包含了一个thread 栏位(1264 行),该栏位用来储存与CPU 相关的暂存器、分段表等资讯,由于暂存器资讯是与处理器密切相关的,所以每一种CPU 都会拥有不同的执行绪结构,IA32 (x86) 的thread_struct之程式片段如范例10.3所示。
范例2. Linux 中的IA32 (x86) 处理器的Thread 之结构
行号Linux 2.6.29.4版核心原始码arch/x86/include/asm/processor.h档案
… …
391 struct thread_struct {
392 /* Cached TLS descriptors: */
393 struct desc_struct tls_array[GDT_ENTRY_TLS_ENTRIES];
394 unsigned long sp0;
395 unsigned long sp;
… …
行程状态
Linux 行程的状态有Running, Interruptible, Uninterruptible, Zombie, Stopped 等五种,但后来又增加了Traced, EXIT_DEAD, TASK_DEAD, TASK_WAKEKILL 等四种,形成了九种状态(2.6.29.4 版),如范例10.4所示。
范例3. Linux 中的行程状态
行号Linux 2.6.29.4版核心原始码include/linux/sched.h档案
… …
174 #define TASK_RUNNING 0
175 #define TASK_INTERRUPTIBLE 1
176 #define TASK_UNINTERRUPTIBLE 2
177 #define __TASK_STOPPED 4
178 #define __TASK_TRACED 8
179 /* in tsk ->exit_state */
180 #define EXIT_ZOMBIE 16
181 #define EXIT_DEAD 32
182 /* in tsk->state again */
183 #define TASK_DEAD 64
184 #define TASK_WAKEKILL 128
… …
行程切换
行程切换(内文切换) 是与处理器密切相关的程式码,每个处理器的实作方式均有相当大的差异,但基本原理都是将上一个行程(prev, 以下称为旧行程)的暂存器保存后,再将程式计数器设定给下一个行程(next, 以下称为新行程)。在IA32 (x86) 的处理器中,Linux 的行程切换程式码如范例4 所示,该行程切换函数switch_to(prev, next, last) 是一个内嵌于C 语言的组合语言巨集,采用GNU 的内嵌组合语言语法。
首先,switch_to 最外层是一个do { … } while (0) 的回圈,这个语法很奇怪,因为该回圈根本永远都只会执行一次,那又为何要用回圈呢?这纯粹只是为了要把中间的组合语言用区块结构{…} 包起来而已,但却很容易误导读者,以为那是一个无穷回圈。
范例4. Linux在IA32 (x86) 处理器上的行程切换程式码
行号Linux 2.6.29.4档案arch/x86/include/asm/system.h
… …
30 #define switch_to(prev, next, last) \
31 do {\
32 /* \
33 * Context-switching clobbers all registers, so we clobber\
34 * them explicitly, via unused output variables.\
35 * (EAX and EBP is not listed because EBP is saved/restored \
36 * explicitly for wchan access and EAX is the return value of \
37 * __switch_to()) \
38 */ \
39 unsigned long ebx, ecx, edx, esi, edi; \
40 \
41 asm volatile("pushfl\n\t" /* save flags */ \
42 "pushl %%ebp\n\t" /* save EBP */ \
43 "movl %%esp,%[prev_sp]\n\t"/* save ESP */ \
44 "movl %[next_sp],%%esp\n\t"/* restore ESP */ \
45 "movl $1f,%[prev_ip]\n\t" /* save EIP */ \
46 "pushl %[next_ip]\n\t" /* restore EIP */ \
47 "jmp __switch_to\n " /* regparm call */ \
48 "1:\t" \
49 "popl %%ebp\n\t" /* restore EBP */ \
50 "popfl\n" /* restore flags */ \
51 \
52 /* output parameters */ \
53 [prev_sp] "=m" (prev->thread.sp), \
54 [prev_ip] "=m" (prev->thread.ip), \
55 "=a" (last ), \
56 \
57 /* clobbered output registers: */ \
58 "=b" (ebx), "=c" (ecx), "=d" (edx), \
59 "=S" (esi), "=D" (edi) \
60 \
61 /* input parameters: */ \
62 : [next_sp] "m" (next->thread.sp), \
63 [next_ip] "m" (next->thread. ip), \
64 \
65 /* regparm parameters for __switch_to(): */ \
66 [prev] "a" (prev), \
67 [next] "d" (next) \
68 \
69 : /* reloaded segment registers */ \
70 "memory"); \
71 } while (0)
… …
第41行开始才是行程切换的动作,指令pushfl 用来储存旗标暂存器到堆叠中,pushl %%ebp 用来储存框架暂存器(ebp) 到堆叠中,movl %%esp, %[ prev_sp] 则用来储存旧行程的堆叠暂存器(esp) 到(prev->thread.sp) 栏位中,而pushl %[next_sp], %%esp 则是将新行程的堆叠暂存器( next->thread.sp) 取出后,放到CPU的esp暂存器中,于是建构好新行程的堆叠环境。接着,第45行的movl $1f, %[prev_ip] 将标记1 的位址放入旧行程的prev->thread.ip 栏位中。接着46行用指令pushl %[next_ip] 将新行程的程式计数器next->thread.ip 推入堆叠中,然后利用指令jmp __switch_to跳入C语言的switch_to() 函数中,当该函数的返回指令被执行时,将会发生一个奇妙的结果。
由于switch_to() 函数是一个C语言函数,原本应该被其他C语言函数呼叫的,呼叫前原本上层函数会先将下一个指令的位址存入堆叠中,然后才进行呼叫。C语言函数在返回前会从堆叠中取出返回点,以返回上一层函数继续执行。虽然我们是利用组合语言指令jmp __switch_to 跳入该函数的,但C语言的编译器仍然会以同样的方式编译,于是返回时仍然会从堆叠中取出pushl %[next_ip] 指令所推入的位址,因而在switch_to() 函数返回时,就会将程式计数器设为next->thread.ip,于是透过函数返回的过程,间接的完成了行程切换的动作。
既然新行程已经在switch_to() 函数返回时就开始执行了,那么内文切换的动作不就已经完成了吗?既然如此为何又需要第49-50 两行的程式呢?我们必须进一步回答这个问题。
第45行之所以将标记1放入prev->thread.ip中,是为了让旧行程在下次被唤醒时,可以回到标记1 的位置。当下次旧行程被唤醒后,就会从标记1 的位址开始执行,旧行程可以利用第49-50行的popl %%ebp; popfl 两个指令,恢复其ebp (框架指标) 与旗标暂存器,然后再度透过switch_to(),切换回旧行程(只不过这次旧行程变成了函数switch_to(prev, next, last) 中的next 角色,不再是『旧行程』了。
或许我们可以说switch_to() 函数其实并不负责切换行程,因为该函数会将处理器中各种需要保存的值存入旧行程prev 的task_struct 结构中,以便下次prev 行程被唤醒前可以回存这些暂存器值,其实并没有切换或执行新行程的功能,但因为jmp __switch_to 指令前的pushl %[next_ip] 指令,导致该函数在返回时顺便做了行程切换的动作,这种隐含性是作业系统设计时一种相当吊诡的技巧,也是学习Linux 时对程式人员最大的挑战之一。
排程
解说完行程切换的原理后,我们就可以来看看排程系统了。Linux 采用的排程机制较为复杂,该排程式建构在一个称为goodness 的行程(或执行绪) 评估值上,goodness 是一个从0 到1999 之间的整数。一般执行绪的goodness 小于1000,但即时执行绪的goodness 则会从1000 开始,因此保证了任何即时执行绪的goodness 都会高于一般执行绪。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int spawn(char *prog, char **arg_list) { // Spawn为生育的意思
pid_t child;
child = fork(); //用fork()函数分枝出子行程
if (child != 0) { //如果不成功
return child; //传回失败的行程代码
} else { //否则
execvp(prog, arg_list); //将prog参数所指定的
fprintf(stderr, "spawn error\n"); //程式载入到子行程中
return -1;
}
}
int main() { //主程式开始
char *arg_list[] = { "ls", "-l", "/etc", NULL }; // ls -l /etc
spawn("ls", arg_list); //开始分支
printf(" The end of program.\n"); //印出主程式结束讯息
return 0;
}
执行过程与结果
$ gcc fork.c -o fork
$ ./fork
The end of program.
$ total 94
-rwxr-x--- 1 ccc Users 2810 Jun 13 2008 DIR_COLORS
drwxrwx---+ 2 ccc Users 0 Oct 7 2008 alternatives
-rwxr -x--- 1 ccc Users 28 Jun 13 2008 bash.bashrc
drwxrwx---+ 4 ccc Users 0 Oct 7 2008 defaults
-rw-rw-rw- 1 ccc Users 716 Oct 7 2008 group
….
执行绪
POSIX标准中支援的执行绪函式库称为pthread,我们可以透过pthread 结构与pthread_create() 函数执行某个函数指标,以建立新的执行绪。范例6 显示了一个利用pthread 建立两个执行绪,分别不断印出George 与Mary 的程式。该程式中总共有三个执行主体,第一个是print_george()、第二个是print_mary()、第三个是主程式本身。由于每隔1 秒印一次George ,但每隔0.5 秒就印一次Mary,因此执行结果会以George, Mary, George, George,Mary 的形式印出。
范例6 利用pthread 函式库建立执行绪的范例
#include <pthread.h> //引用pthread函式库
#include <stdio.h>
void *print_george(void *argu) { //每隔一秒钟印出一次George的函数
while (1) {
printf( "George\n");
sleep(1);
}
return NULL;
}
void *print_mary(void *argu) { //每隔一秒钟印出一次Mary的函数
while (1) {
printf("Mary\n ");
sleep(2);
}
return NULL;
}
int main() { //主程式开始
pthread_t thread1, thread2; //宣告两个执行绪
pthread_create(&thread1, NULL, &print_george, NULL); //执行绪print_george
pthread_create(&thread2, NULL, &print_mary, NULL); //执行绪print_mary
while (1) { //主程式每隔一秒钟
printf("---------------- \n"); //就印出分隔行
sleep(1); //停止一秒钟
}
return 0;
}
执行过程与结果
$ gcc thread.c -o thread
$ ./thread
George
Mary
-------------------------
George
---------- ---------------
George
Mary
-------------------------
George
------- ------------------
…
==========================================================================
Linux 作业系统 --- 记忆体管理
转自 http://ccckmit.wikidot.com/lk:memory
简介
Linux 作业系统原本是在IA32 (x86) 处理器上设计的,由于IA32 具有MMU 单元,因此大部分的Linux 都预设支援虚拟记忆体机制。然而,在许多的嵌入式处理器中,并没有MMU 单元,于是有Jeff Dionne 等人于1998 年开始将Linux 中的MMU 机制去除并改写,后来释出了不具有MMU 的µClinux 版本。
曾经有一段时间,嵌入式的系统开发者必须决定应使用具有MMU 的Linux 或不具MMU 的µClinux,但是后来在2.5.46版的Linux 中,决定将µClinux 纳入核心中,所以后来的Linux 核心已经包含了µClinux 的功能,可以选择是否要支援MMU 单元。
由于Tovarlds 最早是在IA32 (x86) 中发展出Linux 作业系统的,因此Linux 的记忆体管理机制深受x86 处理器的影响。要了解Linux 的记忆体管理机制,首先必须先理解x86 的MMU 记忆体管理单元。
IA32 (x86) 的记忆体管理单元
Intel 的IA32 (Pentium处理器) 采用了GDT 与LDT 两种表格,其中的LDT 分段表(Local Descriptor Table) 是给一般行程使用的,而GDT 分段表(Global Descriptor Table) 则包含各行程共享的分段,通常由作业系统使用。
IA32 同时具有分段与分页单元,可以支援『纯粹分段』、『单层分页式分段』与『双层分页式分段』等三种组合。其『逻辑位址』 (Logical Address) 经过分段单位转换后,称为『线性位址』 (Linear Address),再经过分页单位转换后,称为真实位址(Physical Address)。其简要的转换过程如图1 所示。
图1. IA32的两阶段位址转换过程
IA32 逻辑位址(虚拟位址) 的长度是48 位元,分为选择器S (selector : 16 bits) 与偏移量D (offset : 32bits) 两部分。其中的选择器栏位中的pr两个位元用来记录保护属性,g位元记录表格代码(可指定目标表格为GDT 或LDT),另外13个位元则记录分段码(s)。
IA32的分段表LDT与GDT各自包含4096个项目,每个项目都含有『分段起始位址』 (base)、『分段长度』 (limit) 与数个『辅助位元』 (aux)等三种栏位。其中base 与limit 的功能与一般分段表相同,而辅助位元则用来记录分段属性。这两个表格都可以用来将逻辑位址转换成线性位址,是IA32 中的分段单元。详细的转换过程如图2 所示。
图2. IA32分段模式
除了选择器(S) 的分段转换过程之外,位移(D) 部分会经过分页表转换成真实位址。位移(D) 可再细分为三段(p1, p2, d),其中的p1 式分页目录代号,p2式分页表代号,而d 则是分页内位移。IA32 可以不采用分页机制,或采用单层分页机制,甚至可以采用双层分页机制。其中的分页目录PD 是第一层分页,分页表PT 则是第二层分页。当采用单层分页时,每页的大小为4-MB。而采用双层分页时,每页的大小为4-KB。图3 显示了IA32 的分页机制之转换过程。
图3. IA32的分页模式
IA32 可以不使用分页机制,直接将分段后的线性位址输出,此时相当于一般的分段模式,其输出位址如图4 中的L 所示。如果使用单层分页机制,则相当于一般的分段式分页,其输出位址如图4 中的M1 所示。如果采用两层分页机制,则形成双层分页式分段体系,其输出位址如图4 中的M2所示。图4 显示了IA32 MMU单元完整的转换过程。
图4. IA32的分页式分段模式
从IA32 的MMU 单元设计中,我们可以看到IA32 处理器留下了相当大的选择空间给作业系统,作业系统可以自行选择使用哪一种分段分页机制,这与作业系统的设计有密切的关系。
Linux 的记忆体管理机制
X86 版本的Linux 利用GDT 指向核心的分页,然后用LDT 指向使用者行程的分页。LDT 中所记载的是各个行程的分段表,以及行程的状态段(Task State Segment : TSS)。而GDT 中则会记载这些分段表的起始点,TSS 起始点,以及核心的各分段起点。图5 显示了x86 版的Linux 的分段记忆体管理机制。
图5. Linux 的记忆体管理机制
透过图5 的分段机制,Linux 可为每一个行程的段落分配一块大小不等的区段。其中每一个区段又可能占据许多个分页,x86 版本的Linux 采用IA32 的延伸分页模式,利用IA32『分段+双层分页』的延伸记忆体管理模式,因此每个页框的大小为4KB。
当需要进行分段配置(例如载入行程) 时,Linux会使用对偶式记忆体管理演算法(Buddy System Algorithm) 配置分页,在Buddy 系统中,一个页框代表一段连续的分页,该演算法将的页框区分为十种区块大小,分别包含了1, 2, 4, 8, 16, 32, 64, 128, 256, 512 个连续的页框,其中每个区块的第一的页框位置一定是区块大小的倍数。举例而言,一个包含32 个页框的区块之起始位址一定是32 * 4KB 的倍数。
Buddy 页框分配系统
Buddy 系统的运作方法,乃是利用一个名为free_area[10] 的阵列,该阵列中储存了对应大小的位元映像图(bitmap),以记录区块的配置状况。当有分页配置需求时,Linux 会寻找大小足够的最小区块,举例而言,如果需要13 个页框,则Linux 会从大小为16 的页框区中取出一个可用页框,分配给需求者。但是如果大小为16 的页框区没有可用页框,则会从大小为32 的页框区取得,然后分成两半,一半分配给需求者,另一半则放入大小为16 的可用页框区中。
当某页框被释放时,Buddy 系统会试图检查其兄弟页框是否也处于可用状态,若是则将两个页框合并以形成一个更大的可用页框,放入可用页框串列中。
Slab 记忆体配置器
当Linux 需要配置的是小量的记忆体(像是malloc 所需的记忆体) 时,采用的是一种称为Slab Allocator 的配置器,其中被配置的资料称为物件(Object)。Slab中的物件会被储存在Buddy 系统所分配的页框中,假如要分配一个大小为30 bytes 的物件时,Slab 会先向Buddy 系统要求取得一个最小的分页(大小为4KB),然后分配个Slab 配置器。然后Slab 配置器会保留一些位元以记录配置资讯,然后将剩下的空间均分为大小30 的物件。于是当未来再有类似的配置请求时,就可以直接将这些空的物件配置出去。
===========================================================================================
Linux 作业系统 --- 档案系统
转自 http://ccckmit.wikidot.com/lk:file
Linux 的档案系统简介
在UNIX/Linux 的使用者的脑海中,档案系统是一种逻辑概念,而非实体的装置。这种逻辑概念包含『档案』、『目录』、『路径』、『档案属性』等等。我们可以用物件导向的方式将这些逻辑概念视为物件,表格1 就显示了这些物件的范例与意义。
表格1. 档案系统中的基本逻辑概念
概念 | 范例 | 说明 |
---|---|---|
路径 | /home/ccc/hello.txt | 档案在目录结构中的位置 |
目录 | /home/ccc/ | 资料夹中所容纳的项目索引(包含子目录或档案之属性与连结) |
档案 | Hello!\n… | 档案的内容 |
属性 | -rwxr-xr— …ccc None 61 Jun 25 12:17 README.txt | 档案的名称、权限、拥有者、修改日期等资讯 |
Linux 档案系统的第一层目录如表格2 所示,认识这些目录才能有效的运用Linux 的档案系统,通常使用者从命令列登入后会到达个人用户的主目录,像是使用者ccc 登入后就会到/home/ccc 目录当中。目录/dev 所代表的是装置(device),其中每一个子目录通常代表一个装置,这些装置可以被挂载到/mount 资料夹下,形成一颗逻辑目录树。
表格2. Linux 档案系统的第一层目录
目录 | 全名 | 说明 |
---|---|---|
/bin | Binary | 存放二进位的可执行档案 |
/dev | Device | 代表设备,存放装置相关档案 |
/etc | Etc… | 存放系统管理与配置档案,像是服务程式httpd 与host.conf 等档案。 |
/home | Home | 用户的主目录,每个使用者在其中都会有一个子资料夹,例如用户ccc 的资料夹为/home/ccc/ |
/lib | Library | 包含系统函式库与动态连结函式库 |
/sbin | System binary | 系统管理程式,通常由系统管理员使用 |
/tmp | Temp | 暂存档案 |
/root | Root directory | 系统的根目录,通常由系统管理员使用 |
/mnt | Mount | 用户所挂载上去的档案系统,通常放在此目录下 |
/proc | Process | 一个虚拟的目录,代表整个记忆体空间的映射区,可以透过存取此目录取得系统资讯。 |
/var | Variable | 存放各种服务的日志等档案 |
/usr | User | 庞大的目录,所有的使用者程式与档案都放在底下,像是/usr/src 中就存放了Linux 核心的原始码, 而/usr/bin 则存放所有的开发工具环境,像是javac, java , gcc, perl 等。(若类比到MS. Windows,此资料夹就像是C:\Program Files) |
然而,档案毕竟是储存在区块装置中的资料,要如何将这些概念化为区块资料的组合,必须依赖某些资料结构。为了能将目录、档案、属性、路径这些物件储存在区块当中。这些区块必须被进一步组织成更巨大的单元,这种巨型单元称为分割(Partition)。
在MS. Windows 中,分割是以A: B: C: D: 这样的概念形式呈现的。一个分割概念再Windows 中通常称为『槽』。由于历史的因素,通常A: B: 槽代表软碟机,而C:槽代表第一颗硬碟,D: E: …. 槽则可能是光碟、硬碟、或随身碟等等。
但是并非一个槽就代表单一的装置,有时一个装置会包含好几个分割,像是许多人都会将主硬碟进一步分割成两个Partition,形成C: D: 两个槽,但实际上却储存在同一个硬碟装置中,Linux 中的Partition 的概念也与Windows 类似,但是命名方式却有所不同。
在Linux 中并没有槽的概念,而是直接将装置映射到/dev 资料夹的某个档案路径中。举例而言,第一颗硬碟的第一个分割通常称为/dev/hda1,第二个分割则为/dev/hda2, …。而第二颗硬碟的第一个分割区则为/dev/hdb1,…。软碟则被映射到/dev/sda1, /dev/sda2, …./dev/sdb1, ….,以此类推。
在Linux 中,我们可以利用mount 这个指令,将某个分割(槽) 挂载到档案系统的某个节点中,这样就不需要知道某个资料夹(像是/mnt) 到底是何种档案系统,整个档案系统形成一颗与硬体无关的树状结构。举例而言,mount -t ext2 /dev/hda3 /mnt 这个指令可以将Ext2格式的硬碟分割区/dev/hda3 挂载到/mnt 目录下。而mount -t iso9600 -o ro /dev/cdrom /mnt/cdrom 这样的指令则可将iso9600 格式的光碟/dev/cdrom 以唯读的方式挂载到/mnt/cdrom路径当中。当然,我们也可以用unmount 指令将这些挂载上去的装置移除。
当我们使用ls -all 这样的指令以列出资料夹中的目录结构时,看到的就是这些概念所反映出的资讯。图1 就显示了ls 指令所呈现出的资讯与所对应的概念。
图1 UNIX/Linux 中的档案与目录概念
图1中的权限栏位,第一个字元代表项目标记,目录会以d标记,而档案会以-标记,后面九个字元分为三群,分别是档案拥有者权限、群组成员权限、一般人权限等等。例如,档案README.txt的权限栏为-rwxr-xr ,可以被分为三个一组,改写为owner(rwx)+group(rx)+others(r )。这代表该档案的使用者具有读取Read (r)、写入Write (w)与执行eXecute (x)的权限,群组成员则只能读取与执行,而其他人则只能读取。
拥有权限者可以修改档案的属性,像是chown 指令可以改变档案的拥有者,chgrp 指令可以改变档案的所属群组,chmod 可以改变档案的存取权限等。
Linux 的档案程式设计
而对于Linux 程式设计师而言,关心的则是如何用程式对这些『物件』进行操作,像是开档、读档、关档、改变属性等动作。表格3 显示了如何利用程式对这些物件进行操作的一些基本方法。
表格3. 程式对档案系统的基本操作
物件 | 范例 | 说明 |
---|---|---|
档案 | fd = open(“/myfile”…) | 开关档案 |
档案 | write, read, lseek | 读写档案 |
属性 | stat(“/myfile”, &mybuf) | 修改属性 |
目录 | DIR *dh = opendir(“/mydir”) | 开启目录 |
目录 | struct dirent *ent = readdir(dh) | 读取目录 |
作业系统必须支援程式设计师对这些物件的操作,程式可以透过系统呼叫(像是open(), read(), write(), lseek(), stat(), opendir(), readdir(), 等函数) 操控档案系统。而档案系统的主要任务也就是支持这些操作,让应用程式得以不需要处理区块的问题,而是处理『路径』、『目录』与『档案』。
早期的Linux 直接将档案系统(像是Ext2档案系统) 放入核心当中,但是在这样的方式下,Linux Kernel 与档案系统绑得太紧密了,会造成无法抽换档案系统的困境,因此在后来的Linux 增加了一个称为VFS 的虚拟档案系统,以便让档案系统可以很容易的抽换。
接着,我们先看看Linux 真实档案系统的一个经典范例- Ext2档案系统,然后再来介绍这个虚拟档案系统VFS 的运作方式。
Ext2 档案系统
Ext2 档案系统的结构如图2 所示,一个Ext2 档案系统可被分为数个区块群(Block Group),每个区块群都由超级区块(superblock) 所引导,超级区块会透过索引结构(inode) 连结到资料区块,Ext2 档案系统的任务就是有效的组织inode,让寻找与更新的动作得以快速的进行。
图2. Ext2 档案系统的储存结构
索引节点inode 是从UNIX 早期就使用的档案系统组织结构,Ext2 也使用inode组织区块,以形成树状的区块结构。inode 是一种相当奇特的树状结构,除了记录目录的相关资讯之外,其直接连结会连接到目标的资料区块,而间接连结则可连结到其他的inode,包含双层与三层的连结区域,因此可以很快速的扩展开来。有利于减少磁碟的读取次数。图3 显示了inode 的索引方式,而图4 则显示了inode 索引节点的内部结构。
图3 使用inode对装置中的区块进行索引
图4 索引节点inode的内部结构
Ext2 利用inode 建构出磁碟的目录结构与索引系统,于是可以从超级区块连接到装置的资料区块。在Linux 原始码中,Ext2档案系统的函数都撰写成ext2_xxx() 的格式,因此很容易辨认。Ext2的资料结构定义在include/linux/ext2_fs.h 标头档中,而其函数则定义在include/linux/ext2.h 可以在Linux 原始码中的fs/ext2/ 资料夹中找到。
Ext2 的主要物件有区块群(block group), 超级区块(super_block), 索引结构(inode) 与目录项(dir_entry) 等。您可以在Linux 的原始码中找到这些资料结构的定义,表格4 显示了这些物件与其在Linux 原始码中的位置,有兴趣的读者可以自行参考。
表格4 Ext2 档案系统中的重要物件
物件 | 资料结构 | 宣告档案(Linux 2.6.29.4原始码) |
---|---|---|
区块群 | struct ext2_group_desc {…} | /include/linux/ext2_fs.h |
超级区块 | struct ext2_super_block {…} struct ext2_sb_info {…} | /include/linux/ext2_fs.h /include/linux/ext2_fs_sb.h |
索引结构 | struct ext2_inode {…} struct ext2_inode_info {…} | /include/linux/ext2_fs.h /fs/ext2/ext2.h |
目录项 | struct ext2_dir_entry {…} struct ext2_dir_entry2 {…} | /include/linux/ext2_fs.h /include/linux/ext2_fs.h |
Linux 当中的VFS 虚拟档案系统
在传统的UNIX系统中,档案系统是固定的实体档案系统。但在Linux当中,为了统合各个档案系统,因而加上了一层虚拟档案系统(Virtual File System: VFS) ,VFS 是一组档案操作的抽象介面,我们可以将任何的真实档案系统,透过VFS 挂载到Linux 中。
由于VFS只是个虚拟的档案系统,并不负责组织磁碟结构,因此,所有的组织动作都交由真实的档案系统处理。VFS 所负责的操作都是在记忆体当中的部分,包含目录结构的快取等功能,这样就能增快存取的速度。
VFS 是一个软体层的介面,负责处理档案的处理请求,像是读取、写入、复制、删除等。由于各种档案系统的格式并不相同,所以VFS 并不直接处理档案格式,而是规定这些处理请求的介面及操作语意,然后交由真实的档案系统(像是EXT2) 去处理。图5 显示了Linux 核心、VFS 与真实档案系统之间的架构关系。
图5 Linux 的档案系统结构
真实档案系统是在档案结构的组织下,利用区块装置驱动模组所建构出来的。在Linux 作业系统中,允许安装各式各样的档案系统,像是BSD、FAT32、NTFS、EXT2、EXT3、JFS、JFS2、ReiserFS 等,这些档案系统透过统一的虚拟档案系统介面(Virtual File System : VFS),就能被挂载到Linux 作业系统中。
甚至,有些非实体档案系统,也可以透过VFS 挂载进来,像是根档案系统(rootfs), 记忆体对应档案系统(proc), 网路档案系统(sockfs) 等,都可以透过VFS挂载进来,由VFS 进行管理与操控。
为了要将档案系统挂载到Linux 当中,Linux 仍然利用『注册与反向呼叫机制』 (Register and Callback) 作为VFS 的主要设计方式。实体档案系统(像是Ext2) 会向VFS 进行注册,以便将反向呼叫用的函数指标传递给Linux 系统,接着Linux 系统就可以在是当的时机,透过这些函数指标呼叫实体档案系统的函数。
为了组织VFS 的这些注册函数,Linux 采用了一个类似物件导向的方式,将函数以物件的方式组织起来。由于VFS 的设计深受UNIX与Ext2 的影响,因此在使用的术语及结构安排上都使用由UNIX/Ext2 所遗留下来的档案系统之术语。
受到UNIX/Ext2 的影'响,VFS系统也是由超级区块与inode 物件所构成的,另外还有目录项(dentry) 与档案(file) 等物件,分别对应到目录中的子项与档案等物件。
超级区块(Superblock) 在逻辑上对应到一个档案系统分割区(Partition),而索引节点(inode) 则对应到目录结构(directory structure),目录中包含很多子项目,可能是档案或资料夹,这些子项目称为目录项(dentry),这些项目其中有些代表档案(file),透过inode 与dentry 就可以取得磁碟中的资料区块(data block),这些区块就是档案的内容。
于是,VFS 可以再inode 中新增、修改、删除、查询dentry (mkdir, rmdir, …),然后取得或设定dentry 的属性(chmod, chowner, …),或者利用inode找到档案(open),然后再对档案进行操作(read, write, …)。表格5 显示了VFS 系统中的重要函数原型,读者应可看出这些物件间的关系。
表格5. VFS 中的代表性函数原型
操作 | 函数原型 |
---|---|
开档 | int (*open) (struct inode *, struct file *); |
建立资料夹 | int (*mkdir) (struct inode *,struct dentry *,int); |
设定属性 | int (*setattr) (struct dentry *, struct iattr *); |
追踪连结 | void * (*follow_link) (struct dentry *, struct nameidata *); |
所有挂载后的分割都会被放在vfsmntlist 这个Linux Kernel 的串列变数中,串列中的每一个元素为vfsmount结构,该结构代表一个分割,其中的mnt_sb 栏即是该分割的超级区块( super_block),超级区块中有个s_inodes 栏位指向inode 节点串列,inode 也透过i_sb 栏位指回超级区块。
当档案系统想要挂载到Linux 当中时,会将一个称为read_super 的函数,传递给VFS,于是VFS 就可以透过下列的结构,将档案系统串接起来,形成档案系统串列。在需要使用该档案系统时,再透过read_super 将super_block载入记忆体中,
super_block, inode, file, dentry 结构中都有个op 栏位,该栏位储存了一堆反向呼叫函数,可以用来呼叫真实档案系统(像是Ext2) 中的操作函数,以便取得硬碟中的资料,或者将资料写回硬碟当中。范例1 显示了Linux 原始码当中这些物件的结构与操作函数,详细阅读有助于进一步理解VFS 档案系统的设计方式。
范例1. 虚拟档案系统VFS的Linux 原始码节录
档案:Linux 2.6.29.4 原始档include/linux/fs.h
649 struct inode { 索引节点inode结构
650 struct hlist_node i_hash; 杂凑表格
651 struct list_head i_list; inode 串列
652 struct list_head i_sb_list; 超级区块串列
653 struct list_head i_dentry; 目录项串列
… …
675 const struct inode_operations *i_op; inode 的操作函数
676 const struct file_operations *i_fop; 档案的操作函数
677 struct super_block *i_sb; 指向超级区块
… …
714 };
… …
839 struct file { 档案物件的结构
… …
847 struct path f_path; 路径
848 #define f_dentry f_path.dentry
849 #define f_vfsmnt f_path.mnt
850 const struct file_operations *f_op; 档案的操作函数
… …
875 };
… …
1132 struct super_block { 超级区块的结构
1133 struct list_head s_list; 区块串列
1134 dev_t s_dev; 设备代号
1135 unsigned long s_blocksize; 区块大小
… … …
1139 struct file_system_type *s_type; 真实档案系统
1140 const struct super_operations *s_op; 超级区块操作函数
… … …
1157 struct list_head s_inodes; 整个inode串列
1158 struct list_head s_dirty; 修改过的inode串列
… …
1206 };
… …
1310 struct file_operations { 档案的操作函数
1311 struct module *owner; 档案拥有者
… …包含一群档案操作的函数指标,列举如下…
… llseek(), read(), write(), aio_read(), aio_write(),
… readdir(), poll(), ioctl(), unlocked_ioctl(),
… compat_ioctl(), mmap(), open(), flush(), release(),
… fsync(), aio_fsync(), lock(), sendpage(),
… get_unmapped_area(), check_flags(), flock(),
… splice_write(), splice_read(), setlease()
1337 };
1338
1339 struct inode_operations { inode 的操作函数
… …包含一群inode操作的函数指标,列举如下… …
… create(), lookup(), link(), unlink(), symlink(), mkdir(),
… rmdir(), mknod(), rename(), readlink(), follow_link(),
… put_link(), truncate(), permission(), setattr(),
… setxattr(), getxattr(), listxattr(), removexattr(),
… trunctate_range(), fallocate(), filemap()
1366 };
1382 struct super_operations { 超级区块操作函数
… …包含一群超级区块操作的函数指标,列举如下…
… alloc_inode(), destroy_inode(), dirty_inode(),
… write_inode(), drop_inode(), delete_inode(),
… put_super(), write_super(), sunc_fs(), freeze_fs(),
… unfreeze_fs(), statfs(), remount_fs(), clear_inode(),
… unmount_begin(), show_options(), show_stats(),
… quota_read(),quota_write(),bdev_try_to_free_page()
1407 };
… ….
1565 struct file_system_type { 档案系统物件
1566 const char *name; 档案系统名称
1567 int fs_flags; 旗标
1568 int (*get_sb) (struct file_system_type *, int, 超级区块取得函数
1569 const char *, void *, struct vfsmount *);
1570 void (*kill_sb) (struct super_block *); 超级区块删除函数
1571 struct module *owner;
1572 struct file_system_type * next; 下一个档案系统
1573 struct list_head fs_supers; 超级区块串列
… …
1582 };
以下显示了dentry 的操作函数的相关档案结构
… 档案:Linux 2.6.29.4 原始档include/linux/fs.h …
89 struct dentry { 目录项dentry物件
… …
94 struct inode *d_inode; 指向inode
… …
100 struct hlist_node d_hash; 杂凑表
101 struct dentry *d_parent; 父目录
102 struct qstr d_name; 目录项名称
103
104 struct list_head d_lru; /* LRU list */ 最近最少使用串列
… … (快取的取代策略)
115 struct dentry_operations *d_op; dentry 的操作函数
116 struct super_block *d_sb; 指向超级区块
… …
120 };
… …
134 struct dentry_operations { dentry的操作函数
… …包含一群dentry 操作的函数指标,列举如下…
… d_revalidate(), d_hash(), d_compare(), d_delete(),
… d_release(), d_iput(), ddname()
142 };
VFS 如何接上真实档案系统
由于VFS 的设计与Ext2 相当类似,因此在实作对应上相当容易,像是在ext2 档案系统中,就直接将超级区块相关的所有函数指标,全数塞入到ext2_sops 这个型态为super_operations 的变数中。然后在ext2_fill_super 这个函数中,将些函数塞入到Linux VFS 超级区块的s_op 栏位中,就完成了连接的动作,于是Linux 核心就可以透过超级区块中的这些函数指标,操作Ext2 档案系统的超级区块了。
范例2. 将Ext2 连接到VFS 上的程式原始码片段
档案:Linux 2.6.29.4 原始档fs/ext2/super.c
… …
300 static const struct super_operations ext2_sops = {
301 .alloc_inode = ext2_alloc_inode,
302 .destroy_inode = ext2_destroy_inode,
303 .write_inode = ext2_write_inode,
304 .delete_inode = ext2_delete_inode,
305 .put_super = ext2_put_super,
306 .write_super = ext2_write_super,
307 .statfs = ext2_statfs,
308 .remount_fs = ext2_remount,
309 .clear_inode = ext2_clear_inode,
310 .show_options = ext2_show_options,
311 #ifdef CONFIG_QUOTA
312 .quota_read = ext2_quota_read,
313 .quota_write = ext2_quota_write,
314 #endif
315 };
… …
739 static int ext2_fill_super(struct super_block *sb, void *data, int silent)
… …
… sb->s_op = &ext2_sops;
1050
于是,Linux 就可以利用VFS 中的物件结构,对任何的档案系统(像是Ext2, NTFS等) 进行操作。必须注意的是,挂载到Linux VFS 上的档案系统未必像Ext2 这样与VFS 在设计理念上完全吻合,但是只要经过适当的封装之后,仍然可以顺利的挂载上去。像是NTFS 就使用了B+ Tree 的结构,并没有使用inode 结构,但是仍然可以被挂载到VFS 中。
透过VFS中的物件,Linux 可以呼叫真实档案系统(像是Ext2) 的VFS 介面函数,以便完成档案操作的功能。举例而言,Linux 中的系统呼叫sys_stat() 函数,其实作方法如范例3 所示,但是为了简短起见,该函数的资料宣告部分已被去除,程式也被简化了。
范例3. Linux 使用VFS 操作档案系统的范例
sys_stat(path, buf) { 取得档案属性
dentry = namei(path); 透过路径取得目录项
if ( dentry == NULL ) return -ENOENT;
inode = dentry->d_inode; 透过目录项取得inode
rc =inode->i_op->i_permission(inode); 执行i_permission() 操作
if ( rc ) return -EPERM; 取得操作权
rc = inode->i_op->i_getattr(inode, buf); 执行i_getattr()
dput(dentry); 取得目录项属性传回
return rc;
}
Linux 作业系统 --- 輸出入系統
转自 http://ccckmit.wikidot.com/lk:io
Linux 的输出入系统会透过硬体模组介面,以管理各式各样的驱动程式。Linux 将硬体装置分为『区块、字元、网路』等三种类型,这三种类型的驱动程式都必须支援档案存取的介面,因为在Linux 当中装置是以档案的方式呈现的。像是/dev/hda1, /dev/sda1, /dev/tty1 等,程式可以透过开档open()、读档read()、写档write() 的方式存取装置,就像存取一个档案一样。
因此,所有的驱动程式必须支援档案(file) 的操作(file_operations),以便将装置伪装成档案,以供作业系统与应用程式进行呼叫。这种将装置伪装成档案的的方式,是从UNIX 所承袭下来的一种相当成功的模式。
字元类的装置(Character Device) 是较为简单的,常见的字元装置有键盘、滑鼠、印表机等,这些装置所传递的并非一定要是字元讯息,只要可以用串流型式表式即可。因此字元装置又被称为串流装置(Stream Device)。字元装置必须支援基本的档案操作,像是open(), read(), ioctl() 等。
区块装置式形成档案系统的基础,除了基本的档案操作外,区块装置还必须支援区块性的操作(block_device_operations)。而网路装置由于必须支援网路定址等特性,因此成为一类独立的装置。举例而言,网路装置通常必须支援TCP/IP,以形成网路子系统,因此具有独特且复杂的操作。像是必须支援封包传送机制、网路位址的ARP, RARP 协定、MAC Address 等,所以网路装置的驱动程式也是最复杂的。
由于2.6 版的Linux 采用模组(module) 的方式挂载驱动程式,因此必须先透过module_init(xxx_init_module) 与module_exit(xxx_cleanup_module) 的方式,将驱动程式挂载到Linux 中。这种挂载的机制仍然是一种『注册-反向呼叫机制』,但由于核心程式乃是先编译好的,因此必须透过一个动态载入器将模组挂载到核心中,原理并不困难,但细节却很繁琐。范例1 显示了一个最基本的模组HelloModule.c,该模组透过module_init(hello_init) 与module_exit(hello_exit) 两行巨集,将hello_init() 与hello_exit() 函数包装成linux 可使用的模组介面形式,让模组载入器得以顺利载入该模组。
范例1. 最基本的模组(Module) 程式范例– HelloModule.c
#include <linux/init.h> 引用档案
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void) { 模组起始函数
printk(KERN_ALERT "hello_init()"); 印出hello_init()
return 0;
}
static void __exit hello_exit(void) { 模组结束函数
printk(KERN_ALERT "hello_exit()"); 印出hello_exit()
}
module_init(hello_init); 模组挂载(巨集)
module_exit(hello_exit); 模组清除(巨集)
当您在Linux 中撰写了一个模组织之后,可以利用gcc 编译该模组,编译成功后再利用insmod指令将该模组挂载到核心中,最后透过rmmod 指令将该模组从核心中移除。范例2 显示了这个操作过程。
范例2 模组的编译、挂载与清除过程
gcc -c -O -W -DMODULE -D__KERNEL__ HelloModule.c -o HelloModule.ko
insmod ./helloModule.ko
hello_init()
rmmod ./helloModule
hello_exit()
Linux 的驱动程式是一个具有特定结构的模组,必需将装置的存取函数包装成档案存取的形式,让装置的存取就像档案的存取一般。在驱动程式的内部,仍然必须采用『注册-反向呼叫』机制,将这些存取函数挂载到档案系统当中,然后就可以透过档案操作的方式读取(read) 或写入(write) 这些装置,或者透过ioctl() 函数操控这些装置。范例3 显示了Linux 当中装置驱动程式的结构,该范例是一个字元装置device1 的驱动程式片段,其中的Linux 中的cdev 是字元装置结构(struct),该驱动程式必需实作出device1_read(), device1_write(), device1_ioctl(), device1_open(), device1_release() 等档案操作函数,然后封装在file_operations 结构的device1_fops 变数中,透过指令cdev_init(&dev->cdev, &device1_fops) 向Linux 注册,以将这些实作函数挂载到Linux 系统中。
范例3 Linux 中装置驱动程式的结构范例
int device1_init_module(void) { 模组挂载函数
register_chrdev_region(dev, 1, "device1"); 设定装置代号
…
device1_devices = kmalloc(…); 分配记忆体(slab)
…
cdev_init(&dev->cdev, &device1_fops); 注册档案操作函数群fops
dev->cdev.owner = THIS_MODULE;
dev->cdev.ops = &device1_fops;
err = cdev_add (&dev->cdev, devno, 1);
…
fail:
device1_cleanup_module(); 若挂载失败则执行清除函数
}
int device1_open(struct inode *inode, struct file *filp) {...} 装置开启函数
int device1_release(struct inode *inode, struct file *filp) {...} 装置释放函数
ssize_t device1_read(struct file *filp, …){...} 装置读取函数
ssize_t device1_write(struct file *filp,…) {...} 装置写入函数
int device1_ioctl(struct inode *inode, struct file *filp…) {…} 装置控制函数
struct file_operations device1_fops = { 装置的档案操作函数群,
.owner = THIS_MODULE, 包含read(), write(), ioctl(),
.read = device1_read, open(), release() 等。
.write = device1_write,
.ioctl = device1_ioctl,
.open = device1_open,
.release = device1_release,
};
module_init(device1_init_module); 模组挂载(巨集)
module_exit(device1_cleanup_module); 模组清除(巨集)
走笔至此,我们已经介绍完Linux 中的行程管理、记忆体管理、输出入系统与档案系统,完成了我们对Linux 作业系统的介绍。但是Linux 是个庞大的系统,笔者无法进行太详细与深入的介绍,有兴趣的读者请进一步参考相关书籍。
==========================================================================
Linux 作业系统 --- 目的档格式- a.out 与ELF 之设计原理
转自 http://ccckmit.wikidot.com/lk:objfile
Linux 的目的档
为了效能的缘故,目的档通常不会储存为文字格式,而是储存为二进位格式。像是DOS 当中的.com 档案,Windows 当中的.exe 档案,与Linux 早期的a.out格式,还有近期的ELF格式,都是常见的目的档格式。
为了让读者更清楚目的档的格式,在本节中,我们将以Linux 早期的a.out ,与近期的ELF 格式,作为范例,以便详细说明目的档的格式。
图1. 两种目的档的格式– a.out 与ELF 之比较
目的档格式a.out
在早期的Linux,像是Linux 0.12 版,采用的是较简单的目的档格式,称为a.out。这是由于UNIX与Linux 的预设编译执行档名称为a.out 的原因。图1 显示了a.out 的档案格式,并且与目前Linux 所使用的ELF 格式进行对比。
目的档a.out 的格式相当简单,总共分为7 的段落,包含三种主要的资料结构,也就是1. 档头结构(exec)、2. 重定位结构(relocation_info)、3. 字串表结构(nlist)。这三个结构的定义如下程式所示。
程式片段1 : 目的档a.out 的资料结构(以C语言定义)
struct exec { // a.out的档头结构
unsigned long a_magic; //执行档魔数
// OMAGIC:0407, NMAGIC:0410,ZMAGIC:0413
unsigned a_text; //程式段长度
unsigned a_data; //资料段长度
unsigned a_bss; //档案中的未初始化资料区长度
unsigned a_syms; //档案中的符号表长度
unsigned a_entry; //执行起始位址
unsigned a_trsize; //程式重定位资讯长度
unsigned a_drsize; //资料重定位资讯长度
};
struct relocation_info { // a.out的重定位结构
int r_address; //段内需要重定位的位址
unsigned int r_symbolnum:24; // r_extern=1时:符号的序号值,
// r_extern=0时:段内需要重定位的位址
unsigned int r_pcrel:1; // PC相关旗标
unsigned int r_length:2; //被重定位的栏位长度(2的次方)
// 2^0=1,2^1=2,2^2=4,2^3=8 bytes)。
unsigned i4nt r_extern:1; //外部引用旗标。1-以外部符号重定位
// 0-以段的地址重定位。
unsigned int r_pad:4; //最后补满的4个位元(没有使用到的部分)。
};
struct nlist { // a.out的符号表结构
union { //
char *n_name; //字串指标,
struct nlist *n_next; //或者是指向另一个符号项结构的指标,
long n_strx; / /或者是符号名称在字串表中的位元组偏移值。
} n_un; //
unsigned char n_type; //符号类型N_ABS/N_TEXT/N_DATA/N_BSS等。
char n_other; //通常不用
short n_desc; //保留给除错程式用
unsigned long n_value; //含有符号的值,对于代码、资料和BSS符号,
//通常是一个位址。
};
请读者对照图1 与程式片段1,很容易看出『档头部分』使用的是exec 结构。『程式码部分』与『资料部分』则直接存放二进位目的码,不需定义特殊的资料结构。而在『程式码重定位的部分』与『资料重定位的部分』,使用的是relocation_info 的结构。最后,在『符号表』与『字串表』的部分,使用的是nlist 的结构。图2 显示了此种对照关系,读者可以很容易的辨认各分段的资料结构。
图2. 目的档a.out 各区段所对应的资料结构
当a.out 档案被载入时,载入器首先会读取档头部分,接着根据档头保留适当的大小的记忆体空间(包含程式段、资料段、BSS段、堆叠段、堆积段等)。然后,载入器会读取程式段与资料段的目的码,放入对应的记忆体中。接着,利用重定位资讯修改对应的记忆体资料。最后,才把程式计数器设定为exec.a_entry所对应的位址,开始执行该程式,图3 显示了a.out 档被载入的过程。
图3. 目的档a.out 的载入过程
目的档格式a.out 是一种比较简单而直接的格式,但其缺点是档案格式太过固定,因此无法支援较为进阶的功能,像是动态连结与载入等。目前,UNIX/Linux 普遍都已改用ELF 格式作为标准的目的档格式,在Linux 2.6 当中就支援了动态载入的功能。在下一节当中,我们将介绍较为先进的目的档格式- ELF。
目的档格式– ELF
目的档ELF 格式(Executable and Linking Format) 是UNIX/Linux 系统中较先进的目的档格式。这种格式是AT&T 公司在设计第五代UNIX (UNIX System V) 时所发展出来的。因此,ELF格式的主要文件被放在规格书-『System V Application Binary Interface』的第四章的Object Files当中,该文件详细的介绍了UNIX System V 中二进位档案格式的存放方式。并且在第五章的Program Loading and Dynamic Linking 当中,说明了动态连结与载入的设计方法。
虽然该规格书当中并没有介绍与机器结构相关的部分,但由于各家CPU厂商都会自行撰写与处理器有关的规格书,以补充该文件的不足之处,因此,若要查看与处理器相关的部分,可以查看各个厂商的补充文件。
ELF可用来记录目的档(object file)、执行档(executable file)、动态连结档(share object)、与核心倾印(core dump) 档等格式,并且支援较先进的动态连结与载入等功能。因此,ELF 格式在UNIX/Linux 的设计上具有相当关键性的地位。
为了支援连结与执行等两种时期的不同用途,ELF 格式可以分为两种不同观点,第一种是连结时期观点(Linking View),第二种是执行时期观点(Execution View)。图4 显示了这两种不同观点的结构图。在连结时期,是以分段(Section) 为主的结构,如图4 (a) 所示,但在执行时期,则是以分区(Segment) 为主的结构,如图4 (b) 所示。其中,一个区通常是数个分段的组合体,像是与程式有关的段落,包含程式段、程式重定位等,在执行时期会被组合为一个分区。
图4. 目的档ELF 的两种不同观点
因此,ELF 档案有两个不同用途的表头,第一个是程式表头(Program Header Table),这个表头记载了分区资讯,因此也可称为分区表头(Segment Header Table)。程式表头是执行时期的主要结构。而第二个表头是分段表头(Section Header Table),记载了的分段资讯,是连结时期的主要结构。
程式片段2 显示了ELF 的档头结构Elf32_Ehdr,其中的e_phoff 指向程式表头,而e_shoff 指向分段表头,透过这两个栏位,我们可以取得两种表头资讯。
程式片段2 : 目的档ELF 的档头结构(Elf32_Ehdr)
typedef struct {
unsigned char e_ident[EI_NIDENT]; // ELF辨识代号区
Elf32_Half e_type; //档案类型代号
Elf32_Half e_machine; //机器平台代号
Elf32_Word e_version; //版本资讯
Elf32_Addr e_entry; //程式的起始位址
Elf32_Off e_phoff; //程式表头的位址
Elf32_Off e_shoff; //分段表头的位址
Elf32_Word e_flags; //与处理器有关的旗标值
Elf32_Half e_ehsize; // ELF档头的长度
Elf32_Half e_phentsize; / /程式表头的记录长度
Elf32_Half e_phnum; //程式表头的记录个数
Elf32_Half e_shentsize; //分段表头的记录长度
Elf32_Half e_shnum; //分段表头的记录个数
Elf32_Half e_shstrndx; //分段字串表.shstrtab的分段代号
} Elf32_Ehdr;
在连结时期,连结器会以ELF 的分段结构为主,利用分段表头读出各个分段。ELF 档可支援任意数目的分段(当然有上限,必须可以用16 位元整数表达)。而且,每个分段可以具有不同的结构,常见的分段有程式段(.text) , 资料段(.data), 唯读资料段(.rodata) , 未设定变数段(.bss), 字串表(.strtab), 符号表(.symtab)等。但是,ELF为了支援较先进的连结载入方式,还包含了许多其他类型的段落,像是动态连结相关的区段等,表格1 显示了ELF 中的常见分段名称与其用途。
表格1. 目的档ELF 中的常见分段列表
分段名称 | 说明 |
.text | 程式段 |
.data | |
.data1 | 资料段 |
.bss | 未设初值的全域变数 |
.rodata | |
.rodata1 | 唯读资料段 |
.dynamic | 动态连结资讯 |
.dynstr | 动态连结用字串表 |
.dynsym | 动态连结用符号表 |
.got | 动态连结用的全域位移表(Global Offset Table) |
.plt | 动态连结用的程序连结表(Porcedure Linkage Table) |
.interp | 记录程式解译器的路径(program interpreter file) |
.ctors | 物件导向中的建构函数(constructor) (C++可用) |
.dtors | 物件导向中的解构函数(destructor) (C++可用) |
.hash | 杂凑表 |
.init | 在主程式执行前会执行此段落 |
.fini | 在主程式执行后会执行此段落 |
.rel<name> | |
.rela<name> | 重定位资讯,例如: rel.text 是程式段的重定位资讯,rel.data 则是资料段的重定位资讯。 |
.shstrtab | 储存分段(Section) 名称 |
.strtab | 字串表 |
.symtab | 符号表 |
.debug | 除错资讯(保留给未来用) |
.line | 除错时的行号资讯 |
.comment | 版本控制讯息 |
.note | 附注资讯 |
由于ELF 的分段众多,我们将不详细介绍每的段落的资料结构,只针对较重要或常见的资料结构进行说明。图5 显示了ELF 档案的分段与对应的资料结构,其中,档头结构是Elf32_Ehdr、程式表头结构是Elf32_Phdr、分段表头结构是Elf32_Shdr。而在分段中,符号记录(Elf32_Sym) 、重定位记录(Elf32_Rel、Elf32_Rela)、与动态连结记录(Elf32_Dyn),是较重要的结构。
图5. 目的档ELF的资料结构
分段表头记录了各分段(Section) 的基本资讯,包含分段起始位址等,因此可以透过分段表头读取各分段,图6 显示了如何透过分段表头读取分段的方法。程式片段3 则显示了分段表头的结构定义程式。
图6. 目的档ELF的分段表头
程式片段3 : ELF 的分段表头记录
typedef struct {
Elf32_Word sh_name; //分段名称代号
Elf32_Word sh_type; //分段类型
Elf32_Word sh_flags; //分段旗标
Elf32_Addr sh_addr; //分段位址(在记忆体中的位址)
Elf32_Off sh_offset; //分段位移(在目的档中的位址)
Elf32_Word sh_size; //分段大小
Elf32_Word sh_link; //连结指标(依据分段类型而定)
Elf32_Word sh_info; //分段资讯
Elf32_Word sh_addralign; //对齐资讯
Elf32_Word sh_entsize; //分段中的结构大小(分段包含子结构时使用)
} Elf32_Shdr;
程式表头指向各个分区(Segment) ,包含分区的起始位址,因此可以透过程式表头取得各分区的详细内容,
图7显示了如何透过程式表头取得各分区的方法。程式片段4则显示了程式表头的结构定义程式。
图7. 目的档ELF的程式表头
程式片段4. 目的档ELF 的程式表头结构
typedef struct {
Elf32_Word p_type; //分区类型
Elf32_Off p_offset; //分区位址(在目的档中)
Elf32_Addr p_vaddr; //分区的虚拟记忆体位址
Elf32_Addr p_paddr; //分区的实体记忆体位址
Elf32_Word p_filesz; / /分区在档案中的大小
Elf32_Word p_memsz; //分区在记忆体中的大小
Elf32_Word p_flags; //分区旗标
Elf32_Word p_align; //分区的对齐资讯
} Elf32_Phdr;
在静态连结的情况之下,ELF的连结器同样会合并.text, .data, .bss 等段落,也会利用修改记录Elf32_Rel 与Elf32_Rela,进行合并后的修正动作。而且,不同类型的分段会被组合成分区,像是.text, .rodata, .hash, .dynsym, .dynstr, .plt, .rel.got 等分段会被并入到内文区(Text Segment) 当中。而.data, .dynamic, .got, .bss 等分段则会被并入到资料区(Data Segemnt) 当中。
Elf32_Sym 储存了符号记录,包含名称(st_name)、值(st_value)、大小(st_size)、资讯(st_info)、其他(st_other)、分段代号(st_shndx) 等,其中st_info 栏位又可细分为两个子栏位,前四个位元是bind 栏,用来记录符号的属性,后四个位元是type栏,用来记录符号的类型。
程式片段5. 目的档ELF的符号记录
typedef struct
{
Elf32_Word st_name; //符号名称的代号
Elf32_Addr st_value; //符号的值,通常是位址
Elf32_Word st_size; //符号的大小,以byte为单位
unsigned char st_info; //细分为bind与type两栏位
unsigned char st_other; //目前为0,保留未来使用
Elf32_Half st_shndx; //符号所在的分段(Section)代号
} Elf32_Sym;
#define ELF32_ST_BIND(i) ((i) >> 4) //取出st_info中的bind栏位
#define ELF32_ST_TYPE(i) ((i)&0xf) //取出st_info中的type栏位
#define ELF32_ST_INFO(b,t) (((b)<<4)+((t)&0xf) ) //将bind与type组成info
Elf32_Rel 与Elf32_Rela 是ELF 档的两种重定位记录,两者均包含位址栏(r_offset) 与资讯栏(r_info),其中资讯栏又可分为两个子栏位,前面的byte 是符号代号,后面的byte 记录符号类型。另外,在Elf32_Rela 中,多了一个外加的数值栏位(r_addend),可用来储存重定位的位移值。
程式片段6. 目的档ELF的重定位记录
typedef struct
{
Elf32_Addr r_offset; //符号的位址
Elf32_Word r_info; // r_info可分为sym与type两栏
} Elf32_Rel;
typedef struct
{
Elf32_Addr r_offset; //符号的位址
Elf32_Word r_info; // r_info可分为sym与type两栏
Elf32_Sword r_addend; //外加的数值
} Elf32_Rela;
#define ELF32_R_SYM(i) ((i)>>8)
#define ELF32_R_TYPE(i) ((unsigned char) (i))
#define ELF32_R_INFO(s ,t) (((s)<<8) + (unsigned char) (t))
重定位记录Elf32_rel 的r_info 栏中的sym 子栏位,会储存符号表的索引值,因此,程式可以透过sym 子栏位取得符号记录。然后,在符号记录Elf32_Sym 中的st_name 栏位,会储存字串表中的索引值,因此,可以透过st_name 取得符号的名称。透过sym 与st_name 栏位,可将重定位表、符号表与字串表关连起来,图8 显示了这三个表格的关连状况图。
图8. 目的档ELF中的重定位表、符号表与字串表的关连性
虽然分段结构主要式为了连结时使用的,但是,如果不考虑动态连结的情况,载入器也可以利用分段结构直接进行载入。只要载入.text, .data, .data2, .bss等区段,然后利用.rel.text, .rel.data, .rel.data2, .rela.text, .rela.data, .rela.data2 等分段进行修改的动作,就能载入ELF目的档了。
但是,为了支援动态连结与载入的技术,ELF 当中多了许多相关的分段,包含解译段(.interp)、动态连结段(.dynamic)、全域位移表(Global Offset Table : .got) 、程序连结表(Porcedure Linkage Table : .plt) 等,另外还有动态连结专用的字串表(.dynstr) 、符号表(.dynsym)、映射表(.hash)、全域修改记录(rel.got ) 等作为辅助。
执行ELF载入动作时,使用的是以区块为主的执行时期观点,常见的区块包含程式表头(PHDR)、解译区块(INTERP)、载入区块(LOAD)、动态区块(DYNAMIC)、注解区块(NOTE)、共用函式库区块(SHLIB) 等。其中,载入区块通常有两个以上,如此才能容纳程式区块(TEXT) 与资料区块(DATA) 等不同属性的区域。
表格2 目的档ELF 的常见区块列表
Segment (区块型态) | Sections (分段) | 说明 |
PT_PHDR | Program Header | 表头段,用来计算基底位址(base address) |
PT_INTERP | .interp | 动态载入区段。 |
PT_LOAD | .interp .note .hash .dynsym .dynstr .rel.dyn .rel.plt .init .plt .text .fini .rodata … | 载入器将此区块载入程式段。 |
PT_LOAD | .data .dynamic .ctors .dtors .jcr .got .bss | 载入器将此区块载入资料段。 |
PT_DYNAMIC | .dynamic | 由动态载入器处理 |
在Linux 当中,一般目的档的附档名是.o (Object File),而动态连结函式库的附档名是.so (Shared Object)。当程式被编译为.so 档时,ELF目的档中才会有INTERP 区块,这个区块中记录了动态载入器的相关资讯,ELF载入器可透过这些资讯找到动态载入器(ELF文件中称为Program Interpreter,但若称为Dynamic Loader 或许更恰当)。然后,当目的档载入完成后,就可以开始执行,一但需要使用到动态函数时,才能利用动态载入器将动态函式库载入。
通常,载入的动作是由作业系统的核心(Kernel) 所负责的,载入器是作业系统的一部分。例如,Linux 作业系统的核心就会负责载入ELF 格式的档案,ELF 档案的载入过程大致如下所示:
1. Kernel将ELF档案中的所有PT_LOAD型态的区块载入到记忆体,这些区块包含程式区块与资料区块。
2. Kernel将载入的区块映射到该行程的虚拟位址空间中(例如使用linux的mmap系统呼叫)。
3. Kernel找到PT_INTERP型态的区块,并根据区块内的资讯找到动态连结器(Dynamic Linker )的ELF档。
4. Kernel将动态连结器载入到记忆体,并将其映射到该行程的虚拟位址空间中,然后启动『动态连结器』。
5.目的程式开始执行,在呼叫动态函数时,『动态连结器』根据需要,决定出正确的连结顺序,然后对该程式与动态函数进行重定位的动作,再将控制权转移到动态函数中。
ELF 档案的载入过程,会因CPU 的结构不同而有差异,因此,在ELF 文件中这些与CPU 有关的主题都被分离出来,由各家CPU 厂商自行撰写。举例而言,动态函数的呼叫就是一个与CPU 有关的主题,不同的CPU实作方法会有所不同。
程式片段7. IA32 处理器中的静态函数呼叫与动态函数呼叫方式
C语言程式静态函数呼叫动态函数呼叫
extern int var; pushl var movl var@GOT(%ebx)
extern int func(int); call func pushl (%eax)
call func@PLT
int call_func(void) {
return func( var);
}
程式片段8. IA32 处理器中的动态连结函数区(Stub) 的程式
.PLT0: pushl 4(%ebx)
Jmp *8(%ebx)
nop
nop
.PLT1: jmp *name1@GOT(%ebx)
pushl $offset1
jmp .PLT0@PC
.PLT2 jmp *name2@GOT(%ebx)
pushl $offset2
jmp .PLT0@PC
程式片段9 显示了ELF目的档的动态连结记录Elf32_Dyn,这些记录会被储存在一个名为_DYNAMIC[] 的阵列中,以便让动态连结器使用。
程式片段9. 目的档ELF的动态连结(重定位) 记录
typedef struct {
Elf32_Sword d_tag; //标记
union {
Elf32_Word d_val; //值(用途很多样)
Elf32_Addr d_ptr; //指标(程式的虚拟位址)
} d_un;
} Elf32_Dyn;
extern Elf32_Dyn _DYNAMIC[]; //动态连结阵列
有关ELF 目的档的进一步资讯,有兴趣的读者可以参考规格书System V Application Binary Interface 中的第四章与第五章。
说明与参考文献
- 读者可于下列网址下载到System V的二进位应用介面规格书『System V Application Binary Interface』, http://www.caldera.com/developers/devspecs/gabi41.pdf,其中的第四章(Chapter 4 ) Object Files即是ELF档案格式的规格书。
- 在维基百科的ELF主题中,列有ELF的详细相关资讯,包含System V的规格书与各家处理器厂商的补充文件,读者可参阅http://en.wikipedia.org/wiki/Executable_and_Linkable_Format,其中IA32处理器的补充文件位于http://www.caldera.com/developers/devspecs/abi386-4.pdf,而ARM的补充文件位于http://infocenter.arm.com/help/topic/com.arm .doc.ihi0044b/IHI0044B_aaelf.pdf
- 有关Linux的动态连结技术可以参考Ulrich Drepper , How To Write Shared Libraries, Red Hat, Inc., August 20, 2006,其网址为http://people.redhat.com/drepper/dsohowto.pdf。
- Jollen's Blog, Program Loading观念介绍, last update 2007/03/13, http://www.jollen.org/EmbeddedLinux/Program_Loading.html
- 动态连结器的英文名称为Dynamic Linker,但在ELF 文件中被称为Program Interpreter。
- 动态连结器(Dynamic Linker ) 的ELF档,在Linux 中通常被储存的档名为ld.so。
- a.out执行文件格式— http://www.cnscn.org/htm_data/56/0903/21669.html
=====================================================================================
Linux 作业系统 --- 动态连结与载入(Dynamic Linking)
转自 http://ccckmit.wikidot.com/lk:dynamiclinking
简介
传统的连结器会将所有程式连结成单一的执行档,在执行时只需要该执行档就能顺利执行。但是,使用动态连结机制时,函式库可以先不需要被连结进来,而是在需要的时候才透过动态连结器(Dynamic Linker) 寻找并连结函式库,这种方式可以不用载入全部的程式,因此可以节省记忆体。当很多执行中的程式共用到函式库时,动态连结所节省的记忆体数量就相当可观。
除了节省记忆体之外,动态连结技术还可以节省编译、组译、连结所花费的时间。这是因为动态连结函式库(Dynamic Linking Libraries: DLLs) 可以单独被编译、组译与连结,程式设计师不需要在改了某个函式库后就重新连结所有程式。因此,对程式开发人员而言,动态连结技术可以节省程式开发的时间,因为程式设计人员使用编译、组译与连结器的次数往往非常频繁,有些人甚至不到一分钟就会编译一次。
除此之外,动态连结函式库由于可以单独重新编译,因此,一但编译完新版本后,就可以直接取代旧版本。这让旧程式得以不需重新编译就可以连结到新函式库,因此,只要我们将动态连结函式库换掉,即使功能不完全相同,只要函式库名称一样,旧程式仍可顺利执行该新版的函数,这让动态函式库变成可任意抽换的。这种可抽换性对程式开发人员而言,同时具有优点与缺点。
动态连结器的任务,就是在需要的时候才载入动态函式库,并且进行连结(linking) 与重新定位(relocation) 的动作。然后再执行该函式库中的函数。
当程式第一次执行到动态函数时,动态连结器会搜寻看看该函数是否已经在记忆体中,如果有则会跳到该函数执行,如果没有则会呼叫载入器,动态的将该函式库载入到记忆体,然后才执行该函数。这种函式库被称为动态连结函式库(Dynamic Linking Library),在MS. Windows 中,这种函式库的附档名为.dll ,而在Linux 中,这种函式库的附档名通常为.so (Shared Object)。
使用动态连结机制呼叫函数时,通常会利用间接跳转的方式,先跳入一个称为Stub 的程式中,然后在第一次呼叫时,该Stub 会呼叫动态载入器载入该函数,而在第二次以后,则会直接跳入该函数。
图1 动态连结机制的实作方式
图1 所显示的动态跳转机制,其关键是利用动态连函数区(Stub) 作为跳转点。在主程式中,呼叫动态函数是透过Stub 区中的f1, f2, f3 等函数标记,但是,这些标记区域包含了一段段的Stub小程式,这些小程式会决定是要直接跳转,或者是呼叫动态载入器。
在程式刚载入之时,Ptr_f1,Ptr_f2,Ptr_f3 等用来储存动态函数位址的变数,会被填入DL_f1, DL_f2, DL_f3 等位址。当主程式执行CALL f2@PLT 指令时,会跳到Stub区的f2 标记,此时,会执行LD PC, Ptr_f2@GOT 这个指令,但是由于Ptr_f2 当中储存的是DL_f2 的位址,因此,该LD跳转指令相当于没有作用,于是会继续呼叫动态连结器(Dlinker) 去载入f2 对应的函数到记忆体中,f2_in_memory 显示了载入完成后的动态函数。
一但f2 的动态函数f2_in_memory被载入后,Dlinker 会将f2_in_memory 填入到Ptr_f2 当中。于是,当下一次主程式再呼叫CALL f2@PLT 时,Stub 区中的LD PC, Ptr_f2@GOT 就会直接跳入动态函数f2_in_memory 中,而不会再透过载入器了。
动态连结的优缺点
动态连结函式库通常是与位置无关的程式码(Position Independent Code),使用相对定址的方式。否则,如果在动态连结时利用修改记录修正函式库的记忆体内容,会造成每个程式都在修正函式库,就可能造成不一致的窘境。
动态函式库的优点是可任意抽换,不需刻意更新旧程式,系统随时保持最新状态。但是,这也可能造成『动态连结地狱』 (DLL hell) 的困境,因为,如果新的函式库有错,或者与旧的主程式不相容,那么,原本执行正常的程式会突然出现错误,甚至无法使用。
一但有了『动态连结技术』,就能很容易的实作出『动态载入技术』。所位的动态载入技术,是在程式中再决定要载入哪些函数的方法。举例而言,我们可以让使用者在程式中输入某个参数,然后立刻用『动态载入技术』载入该函式库执行。这会使得程式具有较大的弹性,因为,我们可以在必要的时候呼叫动态载入器,让使用者决定要载入哪些函式库。
必须提醒读者的是,虽然动态连结已经是相当常见的功能,但是在UNIX/Linux 与Windows中却有不同的称呼,在Windows 中直接称为DLLs (Dynamic Linking Libraries),其附档名通常为. dll,而在UNIX/Linux 中的动态连结函式库则被称为Share Objects,其附档名通常是.so。
动态载入技术
动态连结虽然不需要事先载入函式库,但是在编译时就已经知道动态函数的名称与参数类型,因此,编译器可以事先检查函数型态的相容性。但是,有一种称为动态载入的技术,允许程式设计人员在程式执行的过程中,动态决定要载入哪个函式库,要执行哪个函数,这种技术比动态连结更具有弹性,灵活度也更高。其方法是让程式可以呼叫载入器,以便动态的载入程式,因此才被称为动态载入技术。
举例而言,Linux 当中的系统呼叫execve(),就是动态载入技术的一个简单范例。当我们呼叫execve() 以载入程式时,就是利用了载入器将某个程式载入记忆体当中执行。范例1 就显示了一个使用execve() 载入ls 档案的程式,该程式会显示etc资料夹中passwd的档案属性。
范例1. 使用execve 呼叫载入器的范例
#include <unistd.h>
int main()
{
char *argv[]={"ls","-al","/etc/passwd",(char *)0};
char *envp[]={"PATH=/bin",0};
execve("/bin/ls",argv,envp);
}
然而,范例1 只是一个简单的动态载入功能,execve 函数能做到的功能相当的有限,如果我们使用UNIX/Linux 中的libdl.so 这的函式库,那可以做到较为先进的动态载入功能,像是连续呼叫同一个函式库,或取得函式库中的变数值等等。
表格1 显示了这两个平台的动态载入函式库对照表。在UNIX/Linux 当中,『dl』 函式库可支援动态载入功能,其引用档为dlfcn.h,函式库的目的档为libdl.so,可以用dlopen() 载入动态函式库,然后用dlsym() 取得函数指标,最后用dlclose() 函数关闭函式库。
而在MS. Windows 当中,动态函式库直接内建在核心当中,其引用档为windows.h,动态连结的目的档为Kernel32.dll,可以使用LoadLibrary() 与LoadLibraryEx() 等函数载入动态函式库,然后用GetProcAddress 取得函数位址,最后用FreeLibrary() 关闭动态函式库。
表格1 Linux 与MS. Windows 中动态载入函式库的对照比较表
使用方法 | ~ UNIX/Linux | ~Windows |
引入档 | #include <dlfcn.h> | #include <windows.h> |
函式库档 | libdl.so | Kernel32.dll |
载入功能 | dlopen | LoadLibrary, LoadLibraryEx |
取得函数 | dlsym | GetProcAddress |
关闭功能 | dlclose | FreeLibrary |
范例2 显示了Linux 当中使用动态载入函式库的程式范例,该程式使用dlopen 载入数学函式库libm.so 目的档,然后用dlsym 取得cos() 函数的指标,接着呼叫该函数印出cos(2.0) 的值,最后用dlclose() 关闭函式库。
范例2. Linux 动态载入函式库的使用范例
// 程式:dlcall.c , 编译指令:gcc -o dl_call dl_call.c –ldl
#include <dlfcn.h> // 引用dlfcn.h动态函式库
int main(void) {
void *handle = dlopen ("libm.so", RTLD_LAZY); // 开启shared library 'libm'
double (*cosine)(double); // 宣告cos()函数的变数
cosine = dlsym(handle, "cos"); // 找出cos()函数的记忆体位址
printf ("%f\n", (*cosine)(2.0)); // 呼叫cos()函数
dlclose(handle); // 关闭函式库
return 0;
}
为了支援『动态连结』与『动态载入』的功能,动态函式库的目的档当中,通常不会使用绝对定址等方式,而会使用与位置无关的编码方式(Position-independent code),对于支援动态重定位(具有虚拟位址) 的机器而言,可以使用基底暂存器(base register) 作为定址基底,然后利用基底定址法(base addressing mode)达成与位置无关的编码方式。透过适当的分段,以及虚拟位址技术,还可以保护这些区段不被窜改,由于虚拟位址的主题与作业系统的设计密切相关,有兴趣者请进一步阅读作业系统的主题。
更多推荐
所有评论(0)