1. fork系统调用从内核返回两次: 一次返回到子进程,一次返回到父进程
2. task_struct结构是用slab分配器分配的,2.6以前的是放在内核栈的栈底的;所有进程的task_struct连在一起组成了一个双向链表
3. 2.6内核的内核栈底放的是thread_info结构,其中有指向task_struct的指针;
4. current宏可以找到当前进程的task_struct;X86是通过先找到thread_info结构,而PPC是有专门的寄存器存当前task_struct(R2)
5. 内核栈大小一般是8KB
6. 进程的五种状态:TASK_RUNNING, TASK_INTERRUPTABLE, TASK_UNINTERRUPTABLE(处于改状态的进程不能被kill,因为它可能正在等待很关键的数据,或者持有了信号量等), TASK_TRACED(被其他进程跟踪状态,具体状态表现不明), TASK_STOPPED(收到SIG_STOP信号,停止进程,相当于暂停进程,但是也可以恢复)
7. 运行上下文分为“进程上下文”和“中断上下文”。系统调用时内核代表进程在进程上下文中执行代码,这时current宏是有效的,指向进程的task_struct,而且系统调用时内核使用的页表是用户态进程的页表;而在中断上下文内核不代表任何进程执行代码,而是执行一个中断处理程序,不会有进程去干预这些中断上下文,所以此时不存在进程上下文。
8. 系统调用在陷入内核的瞬间应该是在中断上下文的,因为是软中断,只是陷入内核后又用了进程上下文
9. 在每个进程的task_struct结构中,有一个parent指针指向其父进程,有一个链表表示其所有的子进程,用这样的结构构成了整个系统进程关系树。
10. 内核的双向列表专用结构struct list_head
11. 进程创建分为两个步骤:fork和exec,fork用来创建进程的结构,通过写时复制,父子进程共享进程空间(页表),父进程和子进程的区别仅仅是PID,PPID,某些资源,统计量(task_struct的结构); exec读出程序代码并执行之。
通过写时复制,只有在需要写入进程地址空间时,才为子进程创建自己的进程地址空间。
*** fork的开销其实就是复制父进程的页表 和 为子进程分配task_struct 结构
fork的过程: fork() -> clone() -> do_fork() -> copy_process()
copy_process()的过程:
a) 为子进程分配内核栈,创建thread_info结构,创建一份与父进程相同的task_struct结构
b) 更改thread_info ,task_struct结构中的部分字段,将子进程与父进程区分开来
c) 将子进程的状态设置为TASK_UNINTERRUPTIBLE
d) 为子进程分配一个可用的PID(alloc_pid())
e) 拷贝或共享父进程打开的文件,信号处理函数,进程地址空间等
f) 设置子进程状态为RUNNING
g) 返回一个指向子进程的指针
一般系统会优先唤醒子进程,因为如果优先唤醒父进程,父进程就有可能会写入,这样会触发写时复制,而子进程一般会调用exec
12. vfork
vfork保证父进程在创建子进程后被阻塞,除非子进程执行了exec,或者子进程退出
vfork在fork有写时复制功能后的好处只有一个,就是:vfork不用拷贝子进程的页表
13. 线程
线程在linux内核中的实现就是一个进程,只是线程会与别的线程共享进程地址空间,共享信号等等
创建一个有四个线程的进程,会有四个进程被创建(四个内核栈和四个task_struct),只要指明这些task_struct*享同一个进程地址空间即可
线程的创建和进程的创建的代码上的对比:
线程创建: clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0)
进程创建: clone(SIG_CHILD, 0)
从线程创建的代码也可以看出:线程创建时是共享父进程的 进程地址空间(CLONE_VM), 打开的文件(CLONE_FILES),文件系统信息(CLONE_FS), 信号处理函数(CLONE_SIGHAND)
14. 内核线程
内核进程需要在后台执行一些操作,所以需要创建一些内核线程(kernel thread)
内核线程和普通线程的主要区别是: 内核线程没有独立的地址空间(task_struct中的mm指针是NULL),共享使用内核态的页表; 内核线程只在内核运行,从来不会调度到用户态;
内核线程和普通线程的相同点有: 同样有状态,被调度,也可以被抢占
有哪些内核线程:flush,ksoftirqd等, 用ps -ef可以查看,其中CMD栏是[]扩起来的都是内核线程
创建方法: kthread_create(); wake_up_process()用来唤醒创建的线程; kthread_run()可以创建并使之运行; kthread_stop()停止内核线程
15. 孤儿进程
如果父进程比子进程先执行完,父进程要在线程组中找一个新的进程作为子进程的父进程,或者找init进程作为子进程的父进程
16. 进程消亡
进程消亡时是通过exit()来实现的,进行exit之后内核依然会保留其task_struct结构,直到其父进程调用wait()或waitpid()回收
第四章 进程调度
1. linux是抢占式多任务系统
2. 通过调度程序选择一个进程来执行, 调度程序来决定什么时候挂起一个进程的运行,以便让其他进程得到允许机会,这种挂起操作叫做抢占。
3. 进程在被抢占之前能获得运行的时间叫做进程的时间片。进程的时间片是固定的,预先设置好的。
4. yield(), 进程可以通过该函数让渡被调度权
5. 调度算法
a) O(1)的调度程序
O(1)调度程序对大服务器的工作负载下应用很理想,但是在交互式场景下不理想
b) CFS完全公平调度算法
改进了linux对交互式场景的不足
6. IO消耗性进程和CPU消耗性进程
linux调度程序通常更倾向于优先调度IO消耗性进程,但是也并未忽略CPU消耗性进程
7. 进程优先级
Linux采用了两种表示进程优先级的方法:
a) nice值,nice值本来是Unix的标准做法。在linux中,nice值代表的是时间片的比例,nice值越大优先级越低,范围是-20到19
b) 实时优先级,范围是0到99,越大优先级越高
8. 时间片
时间片过长,导致对IO消耗性进程的支持不好;时间片过短,进程调度就花去了更多的时间
9. CFS调度算法实际上分给每个进程的是处理器占用比,这个占用比也会受到nice值得影响
例子:假设系统只有两个进程,一个文本编辑程序(IO消耗性),一个视频编解码程序(CPU消耗性),系统初始时他们有同样的nice值,所以在启动后给它们分配的处理时间都是一样的,都是50%,因为文本编辑器消耗很少的CPU,所以它的CPU时间占比远小于本应该分配给他的50%,而视频程序就占用了超过50%的CPU时间,所以当文本编辑程序需要运行时,调度程序发现它的CPU时间比它应得的少很多,所以马上让他抢占运行;当文本编辑器运行完毕后,又进入等待,所以它消耗的CPU时间依然少,这样系统就能不断的马上响应文本编辑程序。
CFS调度算法的主要思想是保证系统的公平使用,通过了这种方法可以自动的发现各个进程的CPU使用情况,根据这个使用情况动态的调整进程的调度和分配。
CFS为每个进程被抢占前能运行的时间片的最小值是1ms。
问题来了,一个运行了很久的IO消耗性进程和一个刚开始运行的CPU消耗性进程相比,可能会让IO消耗性进程被调度的可能性变慢,所以是不是说如果进程执行的时间过长了,要重启一下?
10. Linux有多种调度器算法
不同的进程被归入不同的调度器类中
schedule()从最高优先级的调度器中选择一个最高优先级的进程来调度
完全公平调度CFS是一种针对普通进程的调度器,linux中称为SCHED_NORMAL
还有实时进程调度器
11. Linux是何时运行调度器的?
a)
b) linux是通过need_resched这个标识来表明是否要进行执行一次调度的,哪些地方会设置这个标志:schedule_tick(), try_to_wake_up()等; need_resched标志保存在进程的thread_info里,这是因为访问current比访问全局变量更快
c) 在返回用户空间或者中断返回的时候,内核也会检查need_resched标志,如果被设置,系统会在继续运行之前调用调度程序
d) 抢占发生的时间:
d.1) 用户抢占
d.1.1) 从系统调用返回用户空间时
d.1.2) 从中断服务程序返回用户空间时
d.2) 内核抢占
d.2.1) 从中断服务程序返回内核空间时
d.2.2) 内核代码再一次具有可抢占性的时候:这里包含以下的含义: 只有进程没有持有锁就可以被抢占,如果持有了锁,系统是不可抢占的,在释放锁的时候且preempt_count减少到0的时候,说明当前可以被安全的抢占了,这时检查need_resched标志进行抢占。
d.2.3) 内核显式调用schedule()
d.2.4) 内核任务阻塞
调度器入口:schedule()函数,作用是从最高优先级的调度器中选择一个最高优先级的进程来调度
12,睡眠和唤醒
当进程要等待时将自己的进程状态改成INTERRUPTIABLE或者UNINTERRUPTIABLE状态,并把自己从调度红黑树中移出到等待队列中,再调用schedule()调度下一个进程来运行
睡眠时将进程挂到相应的等待队列上:
DEFINE_WAIT(wait);
add_wait_queue(q, &wait);
while(!condition)
{
prepare_to_wait(&q, &wait, TASK_INTERRUPTIABLE);
if(signal_pending(current))
...
schedule();
}
finish_wait(&q, &wait); //把自己移出等待队列
唤醒
wake_up()函数唤醒挂在等待队列上的所有进程,把这些进程的状态改成TASK_RUNNING,并把它加入到调度红黑树上,如果被唤醒的进程优先级比当前的优先级高,还要设置need_reschedule标志。
唤醒要注意的是:可能存在虚假唤醒,可能是收到了信号唤醒了进程。所以在等待时要用一个while循环检查是否满足了条件,如果不满足可能是虚假唤醒,必须继续wait。
13. 抢占和上下文切换
上下文切换就是从一个进程切换到另一个进程去,用context_switch()函数完成,该函数在schedule()中被调用,
该函数主要完成两个工作:
switch_mm():把进程的虚拟地址空间切换
switch_to():切换进程的处理器状态,保存、恢复栈信息和寄存器信息,还有其他任何与体系结构相关的状态信息
14. 实时调度器
两种实时调度策略:SCHED_FIFO和SCHED_RR
SCHED_FIFO:先进先出,一直执行,直到自己释放CPU或者等待,无时间片概念
SCHED_RR: 与SCHED_FIFO类似,但是有时间片概念,在耗尽预先分配给它的时间片之后就重新调度
如何设置进程是实时进程?
第五章 系统调用
1. 系统调用是什么?为什么要引入系统调用?
系统调用是用户进程和硬件设备之间的一个中间层
引入系统调用有三个原因:
a) 给用户提供一个统一的,抽象的接口和硬件设备打交道
b) 通过系统调用这个中间层,防止用户异常操作硬件设备
c) 虚拟化的思想,用户进程都是作为一个个单独的实体运行在虚拟空间中,在系统和用户进程之间提供这样的一层接口也是出于这个考虑,类似一个硬件上安装了多个虚拟机一样
2. API, POSIX, C库
POSIX是一套通用的API接口标准
C库实现了POSIX规定的绝大部分API
用户态调用流程:应用程序 -> C库 -> 系统调用
Linux系统调用也是作为C库的一部分提供
3. Unix接口设计的名言
“提供机制而不是策略”—— 含义是:系统调用抽象了用于完成某种确定的目的的函数,至于这些函数怎么用完全不需要内核关心,是应用程序和C库来关心的。
其实设计任何API都有这样的需求:只提供完成特定任务的接口,具体如何使用这个API是由使用者来关心的
区别机制和策略会简化开发,机制是“需要提供什么功能”,策略是“怎样实现这些功能”。这样可以利用相同的API来适应不同的需求。
4. syscall table
sys_call_table中保存了所有系统调用号的处理函数
5. 中断陷入
a) 通过软中断
中断号是128
int $0x80
b) sysenter指令
x86提供的新的进入系统调用的方法,更快更专业
6. 系统调用的返回值和errno
每个系统调用都会有返回值,返回值一般是long类型,为0表示成功,负数表示失败;返回值除了表示成功失败以外,根据系统调用的具体实现,可以返回功能结果,如getpid()系统调用就返回pid
errno全局变量内保存的是错误号,可以通过perror()来获得错误描述。
errno作为全局变量如何在多核上使用呢?
7. 系统调用参数和返回值的传递
系统调用时需要传递系统调用号和参数。系统调用号总是用eax传递,当参数个数小于5个时,用寄存器传递(ebx, ecx, edx, edi, esi), 当超过5个时,应该用一个单独的寄存器存放指向所有参数的用户空间地址的指针
返回值是通过eax传递的
8. 用户空间和内核空间的数据拷贝
copy_to_user(dst, src, size);
copy_from_user(dst, src, size);
其实直接拷贝也是可以的。这两个函数主要是加了一些使用检查,对用户提供的指针进行检查,不让用户空间通过系统调用来操作内核空间的地址
注意copy_to_user和copy_from_user都可能引起阻塞,当数据被交换到硬盘上时就会发生这种情况,此时,进程就会休眠,直到被唤醒后继续执行或者调用调度程序
9. 系统调用要做很多检查工作,因为输入来自用户态,不能让用户态的错误操作导致内核态数据的错误
capable()函数可以做一些权限检查
10. 系统调用是 可睡眠的 和 可抢占的
可睡眠的保证了系统调用可以使用大部分的内核接口
11. 函数可重入性
系统调用要保证实现时可重入的,因为系统调用时允许被抢占的,所以当新的进程也调用该系统调用时保证可重入才不会出错。
12. 不靠C库的支持,直接使用系统调用的方法
例如:使用open系统调用
#define NR_open 5
_syscall3(long, open, const char *, filename, int, flags, int, mode)
_syscall3是一个宏,它会设置好寄存器,并调用陷入指令。
通过这个宏就创建了一个open()函数,返回值是long,有三个参数,这时就可以直接使用 long fd = open(filename, flags, mode); 调用系统调用了
13. 最好不要新加系统调用,而是使用一些替代方案
替代方案:
a) 对于设备节点,可以使用ioctl自定义命令进行操作
b) 对于信号量这种,其实也是文件描述符,所以也可以用ioctl
c) 利用/proc或者/sysfs文件系统来和内核交互
第六章 关键内核数据结构
1. 链表,队列,映射,二叉树
2. 链表
经典的list_head循环双向链表
struct list_head
{
struct list_head *next;
struct list_head *prev;
};
container_of宏,list_entry宏,可以通过这个宏方便的找到list_head所在的结构的首地址
offset_of(type, member):获得member在type结构中的offset偏移,container_of中用到了这个宏
#define offsetof(struct_t,member) ((size_t)(char *)&((struct_t *)0)->member)
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
#define list_entry(ptr, type, member) /
container_of(ptr, type, member)
INIT_LIST_HEAD(struct list_head); // 初始化list_head
链表头:LIST_HEAD()
操作方法:
list_add(new, head);
list_add_tail(new, head);
list_del(ptr);
list_del_init(ptr); //删除并初始化该list_head
list_move(list, head); // 从一个链表中删除list,并加到head链表后面
list_move_tail(list, head); // 从一个链表中删除
list_empty(head); //判断是否为空
遍历链表:
struct fox
{
int i;
struct list_head *list;
};
struct list_head *head = ...;
struct fox *f;
struct list_head *p;
list_for_each(p, head) { // 循环遍历链表
f = list_entry(p, struct fox, list);
}
另一个宏:list_for_each_entry(f, head, list); 可以实现和上面 list_for_each{ } 块一样的功能
该宏的声明如下:
list_for_each_entry(pos, head, member);
还有一个反向遍历的:
list_for_each_entry_reverse(pos, head, member);
还有如果遍历的同时要删除的:
list_for_each_entry_safe(pos, next, head, member); // 多了一个next的struct list_head指针,用来记录next
list_for_each_entry_safe_reverse(pos, next, head, member);
如果要并发操作链表,必须使用锁。
3. 队列
FIFO。生产者消费者模型
kfifo是内核的通用实现。
创建队列:
struct kfifo fifo;
int ret;
int size = PAGE_SIZE;
ret = kfifo_alloc(&fifo, size, GFP_KERNEL); //size必须是2的幂
char buffer[PAGE_SIZE];
kfifo_init(&fifo, &buffer, size);
入队列:
unsigned int kfifo_in(struct kfifo *fifo, const void *from, unsigned int len); // 拷贝from指向的len大小的数据到fifo中
出队列:
unsigned int kfifo_out(struct kfifo *fifo, void * to, unsigned int len); //拷贝出长度为len大小的数据到to中
其他操作:见书本
4. 映射
与std::map类似,有以下操作:Add, Remove, Find
linux实现了一个专用的类似map的结构:是一个唯一的UID到一个指针的映射
5. 二叉树
rbtree (看一下红黑树的原理)
查找操作多的情况可以使用,如果查找的少,不如用链表
第七章 中断和中断处理
1. 中断上下文又称原始上下文,该上下文中不可阻塞
2. 内核在收到中断之后要设置设备的寄存器 关闭中断,设备的配置空间一般有中断位,采用level中断方式,必须把这个中断位设置
3. 上半部与下半部
例如,网卡,上半部在收到中断进入中断上下文后要设置硬件寄存器,同时把数据快速的拷贝到内核空间
4. request_irq注册中断
IRQF_SHARED标志:共享中断线
IRQF_DISABLED标志:处理中断时关闭其他中断
free_irq()释放中断
5. 中断上下文
与进程无关,不能睡眠
中断上下文的栈:有两种:每个cpu单独的中断栈;或者使用被中断进程的内核栈
一般进程的内核栈是两页,在32位机器上就是8KB,在64位的机器上就是16KB
6. /proc/interrupts
7. 中断控制
禁止中断 : local_irq_disable() local_irq_enable();
这两个函数有缺陷:不能嵌套调用,所以有下面两个函数:
local_irq_save(flags) local_irq_restore(flags) 它们会保存中断状态(就是之前是被禁止还是被启用的状态)
这些函数是当作临界区锁来用的,当软中断上下文和中断上下文有共享数据时,就要用这些函数来充当锁
local_irq_disable()禁止了所有的中断
禁止指定irq上的中断:disable_irq(irq) disable_irq_nosync(irq) enable_irq(irq) synchronized_irq(irq)
这些函数可以嵌套,所以调用多少次disable就要调用多少次enable
共享中断线的irq不能被禁止,所以这些API主要在老式的设备上采用,PCIe设备强制要求共享中断线
8. 判断当前是否在中断上下文
in_interrupt() : 内核正在执行中断处理程序或者下半部时返回非0
in_irq() : 内核正在执行中断处理程序时返回非0
9. 中断处理程序只能在一个CPU上运行
第八章 下半部
1. 下半部是什么
下半部是比中断处理程序稍缓的任务,可以在中断处理程序处理完最紧急的任务之后处理的任务
2. 下半部可以有多种实现方法
上半部只能用中断处理程序实现,而下半部可以用下列方法实现:软中断,tasklet ,工作队列
3. 软中断
系统最多能注册32个软中断,目前内核总共用的软中断有9个
一个软中断不能抢占另一个软中断。唯一可以抢占软中断的是中断处理程序。不过,其他的软中断,即使是相同类型的软中断,也可以在别的处理器同时运行。
通常,中断处理程序会在返回前标记它的软中断,使其在稍后执行。这个步骤叫触发软中断。
那么,何时会执行待处理的软中断呢?
a) 在中断返回时
b) 在ksoftirqd内核线程中
c) 在显示的检查和执行待处理的软中断的代码中,如网络子系统中
do_softirq()是唤醒软中断的函数,其简化代码如下:
目前只有两个子系统直接使用软中断:网络子系统和SCSI子系统; tasklet也是用软中断实现的
注册软中断处理程序:
open_softirq(softirq_no, softirq_handler);
raise_softirq(softirq_no);
软中断不能睡眠,能被中断处理程序抢占。如果同一个软中断在它被执行的同时再次被触发了,那么在其他CPU上可以同时执行其处理程序,这意味着要在软中断的上下文要采用锁来保护,但是如果加锁的话,使用软中断的意义就不大了。所以软中断中一般使用的是单处理器数据(仅属于某一个处理器的数据)。
所以软中断作为BH用的比较少,一般采用tasklet。
4. tasklet
tasklet也是用软中断实现的,有两个软中断和tasklet有关:HI_SOFTIRQ, TASKLET_SOFTIRQ
tasklet有两个单处理器数据结构: tasklet_vec 和 tasklet_hi_vec
tasklet 可以保证同一时间里给定类别中只有一个tasklet会被执行,不同类别的tasklet可以同时执行,所以使用tasklet可以不用过多的考虑锁的问题
tasklet的使用:
声明:(name是tasklet的类别)
DECLARE_TASKLET(name, func, data);
DECLARE_TASKLET_DISABLED(name, funct, data); //声明默认disable的tasklet
tasklet处理程序的格式:
void tasklet_handler(unsigned long data)
调度自己定义的tasklet
tasklet_schedule(&my_tasklet);
启用和禁用tasklet
tasklet_disable(&my_tasklet); // 如果指定的tasklet正在执行,则等到执行结束再返回
tasklet_enable(&my_tasklet);
一个tasklet总在调度它的CPU上执行,这是希望能更好的利用处理器的高速缓存
一个tasklet只要在一个CPU上被执行了,就不会同时在另一个CPU上执行它,(因为有可能一个tasklet在执行的时候,中断处理程序在另一个CPU上又激活了这个tasklet,这样在另一个CPU上如果发现这个tasklet正在被执行,便不会再执行了)
5. 软中断调度时机和ksoftirqd/n内核线程
由于软中断可以被自行重新触发,所以如果软中断中不断触发软中断,而且软中断立即被检查执行,那么就会导致系统的CPU被软中断占用的过多。
另一个方案是:自行重新触发的软中断并不马上被检查执行,而是在下一次中断处理程序返回后检查执行。
ksoftirqd/n是在每个cpu上都有一个的内核线程,只要有未处理的软中断,在空闲CPU上就会被调度执行。
6. 工作队列
工作队列把工作交给一个内核线程去执行,总是在进程上下文中执行。
而软中断和tasklet可能在中断上下文执行,也可能在进程上下文执行。
因为总是在进程上下文执行,所以工作队列可以重新调度甚至睡眠。
如果下半部中要允许重新调度,那么可以使用工作队列。
工作者线程: events/n
worker_thread()是核心函数:
7. 禁止下半部
1. 造成并发的原因
2. 中断安全代码,SMP安全代码,抢占安全代码(即可重入代码)
3. 只要是共享数据,就要加锁;所以尽量不要共享数据;(个人想法:在要进入IO消耗性代码时,可以考虑共享数据,例如:在要访问数据库时,因为访问数据库时肯定要有IO操作,多个线程要访问同一个数据库连接时要加锁,因为反正进程要切换,而且用一个数据库连接还可以做缓存。如果采用多个数据库连接,那么缓存也变成了问题)
4. 记住:给数据加锁,不要给代码加锁
5.
6. 死锁
7. 锁的争用和加锁粒度
1. atomic_t 原子操作
2. 自旋锁
...
...
...
3. 信号量(semaphore)
4. 互斥体(mutex)
5. 完成变量
6. 大内核锁
7. 顺序锁
8. 禁止抢占
9. 内存屏障
1. 整页的分配和释放
2. kmalloc和slab
3. vmalloc
vmalloc和kmalloc类似,都是分配物理内存,但是kmalloc分配的物理内存和虚拟内存都一定是连续的;而vmalloc分配的虚拟内存是连续的,物理内存可能不连续
4. 栈上的内存管理
5. 高端内存的映射
1. VFS文件系统抽象层
2. Unix的四种和文件系统相关的传统抽象概念
3. 超级块
4. inode
5. dentry目录项对象
6. 文件对象
文件的操作: read/write/lseek/ioctl/fsync/open/close/mmap...