第十八章 调试

内核级开发的调试工作远比用户级开发艰难的多。

一、准备开始

准备工作需要的是:

  • 一个bug
  • 一个藏匿bug的内核版本
  • 相关内核代码的知识和运气

在这一章里,调试的主要思想是让bug重现,但是在内核中这并不是很容易做到的。因此,在跟踪bug的时候,掌握的信息越多越好。

二、内核中的bug

内核bug的原因可能有:

- 错误代码
- 同步时发生的错误,例如共享变量锁定不当
- 错误的管理硬件
- ……

内核bug发作的症状可能有:

- 降低所有程序的运行性能
- 毁坏数据
- 使得系统处于死锁状态
- ……

内核开发比起用户开发要多考虑一些独特的问题,比如:

- 定时限制
- 竞争条件
- ……
原因是允许多个线程在内核中同时运行。

三、通过打印来调试

这里说的打印是指的内核的格式化打印函数printk(),因为它有自己的一些特殊的功能:

1.健壮性

健壮性的意思是,在任何时候,任何地方都能调用它。

- 在中断上下文和进程上下文中被调用
- 在任何持有锁时被调用
- 在多处理器上同时被调用,并且不必使用锁。

弹性极佳
唯一的例外是【在系统启动过程的初期就要输出】这种情况,需要用early_printk()代替,两者功能完全相同。

2.日志等级

printk()和printf()在使用上最主要的区别就是前者可以指定一个日志级别,内核根据这个级别来判断是否在终端上打印消息。内核把级别比某个特定值低的所有消息显示在终端上。
enter description here
如果没有特别特别指定,函数会选用默认的DEFAULT_MESSAGE_LOGLEVEL,在当前来看是KERN_WARNING,即一个警告。最好还是给自己的消息指定一个记录等级。
内核会把这些记录等级转化为"",n指等级,从0-7,对应表中从上到下,数字越小越重要,也就是说:

0   KERN_EMERG  最重要
……
7   KERN_DEBUG  最不重要

对于调试信息, 有两种赋予记录等级的方法:

  1. 保持终端的默认记录等级不变,给所有调试信息KERN_CRIT或更低的等级。
  2. 给所有调试信息KERN_DEBUG等级,调整终端的默认记录等级。

3.记录缓冲区

内核消息是保存在一个环形队列中,这个环形队列就是它的记录缓冲区。
大小是可以在编译时进行调整的,但是在单处理器的系统上默认值是16kb。
也就是说内核在同一时间只能保存16kb的内核消息,再多的话新消息就会覆盖老消息,读写都是按照环形队列方式操作的。

优点:

  1. 健壮性:在中断上下文中也可以方便的使用。
  2. 简单性:使记录维护起来更容易。

缺点:
可能会丢失消息。

4.syslogd和klogd

这是两个用户空间的守护进程,klogd从记录缓冲区中获取内核消息,再通过syslogd守护进程将他们保存在系统日志文件中。

(1)klogd
既可以从/proc/kmsg文件中,也可以通过syslog()系统调用读取这些消息。
默认是/proc方式。
两种情况klogd都会阻塞,知道有新的内核消息可供读出,唤醒之后默认处理是将消息传给syslogd。
可以通过-c标志来改变终端的记录等级

(2)syslogd
将它接收到的所有消息添加到一个文件中,默认是/var/log/messages。

四、oops

oops是内核告知用户有不幸发生的最常用的方式
内核很难自我修复,也不能将自己杀死,只能发布oops,过程包括:

- 向终端上输出错误消息
- 输出寄存器中保存的信息
- 输出可供跟踪的回溯线索

通常发送完oops之后,内核会处于一种不稳定状态。

关于oops发生的时机:

  1. 发生在中断上下文:内核无法继续,会陷入混乱,导致系统死机
  2. 发生在idle进程或init进程(0号进程和1号进程),同上
  3. 发生在其他进程运行时,内核会杀死该进程并尝试着继续执行

oops发生的可能原因:

  1. 内存访问越界
  2. 非法的指令
  3. ……

oops中包含的重要信息:寄存器上下文和回溯线索

  1. 回溯线索:显示了导致错误发生的函数调用链。
  2. 寄存器上下文信息也很有用,比如帮助冲进引发问题的现场

1.ksymoops

回溯线索中的地址需要转化成有意义的符号名称
——需要调用ksymoops命令。
并且还必须提供编译内核时产生的System.map。如果用的是模块,还需要一些模块信息。

kysmoop saved_oops.txt

2.kallsyms

现在的版本中不需要使用sysmoops这个工具,因为可能会发生很多问题,新版本中引入了kallsyms疼,可以通过定义CONFIG_KALLSYMS配置选项启用。

五、内核调试配置选项

位于内核配置编辑器的内核开发菜单项中,都依赖于CONFIG_DEBUG_KERNEL。

  • slab layer debugging slab层调试选项
  • high-memory debugging 高端内存调试选项
  • I/O mapping debugging I/O映射调试选项
  • spin-lock debugging 自旋锁调试选项
  • stack-overflow debugging 栈溢出检查选项
  • sleep-inside-spinlock checking 自旋锁内睡眠选项
  • ……

原子操作:指那些能够不分隔执行的东西;在执行时不能中断否则就
是完不成的代码。
例如;正在使用一个自旋锁或禁止抢占的代码。
使用锁时睡眠是引发死锁的元凶。

六、引发bug并打印信息

1.BUG()和BUG_ON()

被调用时会引发oops,导致栈的回溯和错误信息的打印。
可以把这些调用当做断言使用,想要断言某种情况不该发生:

if (bad_thing)
    BUG();
或:
BUG_ON(bad_thing);

2.BUILD_BUG_ON()

与BUG_ON()作用相同,仅在编译时调用。

3.panic()

可以引发更严重的错误,不但会打印错误信息,还会挂起整个系统。

4.dump_stack()

只在终端上打印寄存器上下文和函数的跟踪线索。

七、神奇的系统请求键

这个功能可以通过定义CONFIG_MAGIC_SYSRQ配置选项来启用。SysRq(系统请求)键在大多数键盘上都是标准键。
该功能被启用时,无论内核出于什么状态,都可以通过特殊的组合键和内核进行通信。
除了配置选项以外,还要通过一个sysctl用来标记该特性的开或关,启动命令如下:

echo 1 > /proc/sys/kernel/sysrq

Sysrq的几个命令:
SysRq-s:将“脏”缓冲区跟硬盘交换分区同步
SysRq-u:卸载所有的文件系统
SysRq-b:重启设备
……参见以下表格:
enter description here

在一行内发送这三个键的组合可以重新启动濒临死亡的系统。

内核代码中的Documentation/sysrq.txt有更详细说明,实际的实现在drivers/char/sysrq.c中。

八、内核调试器的传奇

1.gdb

可以使用标准的GNU调试器对正在运行的内核进行查看。
针对内核启动调试器的方法与针对进程的方法大致相同:

gdb vmlinux /proc/kcore

vmlinx:未经压缩的内核映像,区别于zImage或bImage,它存放于源代码树的根目录上。
/proc/kcore作为一个参数选项,是作为core文件来用的,通过它能够访问到内核驻留的高端内存。只有超级用户才能读取此文件的数据

可以使用gdb的所有命令来获取信息。例如:

打印一个变量的值:
p global_variable

反汇编一个函数:
disassemble function

-g参数还可以提供更多的信息。

局限性:

  1. 没有办法修改内核数据
  2. 不能单步执行内核代码

2.kgdb

是一个补丁 ,可以让我们在远程主机上通过串口利用gdb的所有功能对内核进行调试。
需要两台计算机:仪态运行带有kgdb补丁的内核,第二胎通过串行线使用gdb对第一台进行调试。
通过kgdb,gdb的所有功能都能使用:

- 读取和修改变量值
- 设置断点
- 设置关注变量
- 单步执行

九、探测系统

1.使用uid作为选择条件

一般情况下,加入特性时,只要保留原有的算法而把新算法加入到其他位置上,基本就能保证安全。

可以把用户id(UID)作为选择条件来实现这种功能:
通过某种选择条件,安排到底执行哪种算法。
例如:

if (current-> uid !=7777) {
    /* 老算法…… */
} else {
    /* 新算法…… */
}

即,除了uid=7777的用户以外,其他所有的用户都是用的老算法,所以这个7777用户可以专门用来测试新算法。

2.使用条件变量

如果代码与进程无关,或者希望有一个针对所有情况都能使用的机制来控制某个特性,可以使用条件变量
这种方式比使用UID更简单,只需要创建一个全局变量作为一个条件选择开关:

  • 如果该变量为0,就使用某一个分支上的代码;
  • 否则,选择另外一个分支。

操控方式:某种接口,或者调试器。

3.使用统计量

这种方法常用于使用者需要掌握某个特定事件的发生规律的时候。
方法是创建统计量,并提供某种机制访问其统计结果。

定义全局变量

在/proc目录中创建一个文件
or新建一个系统调用
or通过调试器直接访问(最直接)

注:不是SMP安全的,更好的方式是用原子操作。

4.重复频率限制

当系统的调试信息过多的时候,有两种方式可以防止这类问题发生:

  • 重复频率限制
  • 发生次数限制

(1)重复频率限制
就是限制调试信息,最多几秒打印一次,可以根据自己的需要调节频率。
例如printk()函数的调节频率,可以用printk_ratelimit()函数限制。
(2)发生次数限制
这种方法是要调试信息至多输出几次,超过次数限制后就不能再输出。
这种方法可以用来确认在特定情况下某段代码的确被执行了。

※用到的变量需要是**静态的、局部的。

注:不是SMP安全,不是抢占安全,更好的方式是用院子操作。

SMP(Symmetric Multi-Processing),对称多处理结构的简称,是指在一个计算机上汇集了一组处理器(多CPU),各CPU之间共享内存子系统以及总线结构。在这种技术的支持下,一个服务器系统可以同时运行多个处理器,并共享内存和其他的主机资源。

十、二分搜索的妙用

很多时候,内核的更新会带来bug,那么是哪一个内核版本带来的bug?可以使用二分法搜索。
我们可以利用GIT来实现这一步骤。

git bisect start    # 告知git要进行二分搜索
git bisect bad <revision>   # 已知出现问题的最早内核版本
git bisect bad  # 当前版本就是引发bug的最初版本的情况下使用这条命令
git bisect good <revision>  # 最新的可正常运行的内核版本

这之后,git就会利用二分搜索法在Linux源码树中,自动检测正常的版本内核和有bug的内核版本之间那个版本有隐患,然后再编译、运行以及测试正被检测的版本。

如果这个版本正常:
git bisect good

如果这个版本运行有异常:
git bisect bad

对于每一个命令,GIT将在吗诶一个版本的基础上反复二分搜索源码树,并且返回所查的下一个内核版本,一直到不能再进行二分搜索位置,最终git会打印出有问题的版本号。

 

 

 

第三章 进程管理

一、进程

1.进程

进程就是处于执行期的程序。
进程就是正在执行的程序代码的实时结果。
进程是处于执行期的程序以及相关的资源的总称。
进程包括代码段和其他资源。

2.线程

执行线程,简称线程,是在进程中活动的对象。
内核调度的对象是线程而不是进程。
Linux对线程并不特别区分,视其为特殊的进程。

3.虚拟处理器和虚拟内存

在现代操作系统中,进程提供两种虚拟机制:虚拟处理器虚拟内存
包含在同一个进程中的线程可以共享虚拟内存,但是每个都拥有各自的虚拟处理器。

4.几个函数

  • fork():创建新进程
  • exec():创建新的地址空间并把新的程序载入其中
  • clone():fork实际由clone实现
  • exit():退出执行
  • wait4():父进程查询子进程是否终结
  • wait()、waitpid():程序退出执行后变为僵死状态,调用这两个消灭掉。

二、进程描述符及任务结构

内核把进程的列表存放在叫做任务队列的双向循环链表中。
链表中的每一项都是进程描述符。
进程描述符的类型为task_struct,里面包含的数据有:
enter description here

1.分配进程描述符

Linux通过slab分配器分配task_struct结构——能达到对象复用缓存着色的目的。
slab分配器——动态生成,只需在栈底或者栈顶创建一个新的结构struct thread_info。
enter description here
每个任务的thread_info结构在它的内核栈的尾端分配。
结构中task域中存放的是指向该任务实际task_struct的指针。

2.进程描述符的存放

内核通过一个唯一的进程标识值PID来标识每个进程。
pid类型为pid_t,实际上就是一个int类型,最大值默认设置为32768,上限私改/proc/sys/kernel/pid_max。
pid存放在各自进程描述符中。

怎么找到进程描述符?
通过current宏查找到当前正在运行进程的进程描述符。
x86中,在内核栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。
current通过current_thread_info()把栈指针的后13个有效位屏蔽掉,再从thread_info的task域中提取并返回task_struct的地址。

3.进程状态

进程描述符中的state域是用来描述进程当前状态的。共有五种状态,标志如下:

  • TASK_RUNNING(运行):进程是可执行的,或者正在执行,或者在运行队列中等待执行
  • TASK_INTERRUPTIBLE(可中断):进程正在睡眠/被阻塞
  • TASK_UNINTERRUPTIBLE(不可中断):睡眠/被阻塞进程不被信号唤醒
  • TASK_TRACED:被其他进程跟踪的进程
  • TASK_STOPPED(停止):进程停止执行;进程没有投入运行也不能投入运行。
    接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号时,或者调试时收到任何信号,都可以进入这种状态。
    enter description here

4.设置当前进程状态

用set_task_state(task,state)函数。

set_task_state(task,state); //将任务task的状态设置为state

set_current_state(state)
和下面等价
set_task_state(current,state)

5.进程上下文

程序执行系统调用或者触发异常后,会陷入内核空间,这时候内核代表进程执行,并且处于进程上下文中。
进程对内核的访问必须通过接口:系统调用和异常处理程序

6.进程家族树

所有的进程都是pid为1的init进程的后代。
内核在系统启动的最后阶段启动init进程。

系统中的每一个进程必有一个父进程,可以拥有0个或多个子进程,拥有同一个父进程的进程叫做兄弟。
这种关系存放在进程描述符中,parent指针指向父进程task_struct,children是子进程链表。

获得父进程的进程描述符:

struct task_struct *my_parent = current->parent;

访问子进程:

struct task_struct *task;
struct list_head *list;

list_for_each(list, &current->children){
    task = list_entry(list, struct task_struct, sibling);
    /* task现在指向当前的某个子进程 */
}

init进程的进程描述符是作为init_task静态分配的。

获取链表中的下一个进程:

list_entry(task->tasks.next, struct task_struct, tasks);

获取链表中的上一个进程:

list_entry(task->tasks.prev, struct task_struct, tasks);

以上依赖于next_task(task)和prev_task(task)这两个宏实现。
for_each_process(task)宏,依次访问整个任务队列,每次访问任务指针都指向链表中的下一个元素。

struct task_struct *task;

for_each_process(task){
    /* 它打印出每一个任务的名称和PID */
    printk("%s[%d]\n",task->comm, task->pid);
}

三、进程创建

一般操作系统产生进程的机制:

1. 在新的地址空间创建进程
2. 读入可执行文件
3. 执行

Unix的机制:
fork()和exec()。

  • fork():
    通过拷贝当前进程创建一个子进程。

    子进程与父进程的区别仅在于PID,PPID和某些资源和统计量

  • exec():
    读取可执行文件并将其载入地址空间开始运行。

1.写时拷贝

写时拷贝是一种可以推迟甚至免除拷贝数据的技术,内核不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝。
资源的复制只有在需要写入时才会进行,在此之前以只读方式读取。
fork的实际开销就是复制父进程的页表以及给子进程创建唯一的进程描述符。

2.fork()

Linux通过clone()系统调用实现fork()。
创建进程的大概步骤如下:

  1. fork()、vfork()、__clone()都根据各自需要的参数标志调用clone()。
  2. 由clone()去调用do_fork()。
  3. do_fork()调用copy_process()函数,然后让进程开始运行。
  4. 返回do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行。

※一般内核会选择子进程首先执行
why?
一般子进程会马上调用exec()函数,避免写时拷贝的额外开销。

3.vfork()

除了不拷贝父进程的页表项之外,vfork()系统调用和fork()的功能相同。理想情况下不要调用vfork()

子进程作为父进程的一个单独的线程在它的地址空间里运行 ,父进程被阻塞,直到子进程退出或执行exec()。子进程不能向地址空间写入。

vfork()系统调用的实现是通过向clone()传递一个特殊标志来进行的。

  1. 调用copy_process()是,task_struct的vfor_done成员被设置为NULL。
  2. 执行do_fork()时,如果给定特定标志,则vfor_done会指向一个特定地址。
  3. 子进程先开始执行后,父进程不是马上恢复执行,而是一直等待,知道子进程通过vfor_done指针向它发送信号。
  4. 在调用mm_release()时,该函数用于进程退出内存地址空间,并且检查vfor_done是否为空,如果不为空,则会向父进程发送信号。
  5. 回到do_fork(),父进程醒来并返回。

四、线程在Linux中的实现

线程机制是现代编程技术中常用的一种抽象概念,该机制提供了在同一程序内共享内存地址空间运行的一组线程,可以共享打开的文件和其他资源,支持并发程序设计,在多处理器系统上可以保证真正的并行处理。

Linux内核的角度来看并没有线程这个概念,它把所有线程都当做进程来实现,线程仅仅被视为一个与其他进程共享某些资源的进程。
对于Linux来说,线程只是一种进程间共享资源的手段。

1.创建线程

线程的创建和普通进程的创建类似,只不过在调用clone()时需要传递一些参数标志来指明需要共享的资源:

clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
共享地址空间、文件系统资源、文件描述符和信号处理程序。

而普通的fork:

clone(SIGCHLD, 0);

vfork():

clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);

传递给clone()的参数标志决定了新创建进程的行为方式和父子进程之间共享的资源种类。
enter description here
enter description here

2.内核线程

内核线程:独立运行在内核空间的标准进程。
内核线程没有独立的地址空间只在内核空间运行,从来不切换到用户空间,可以被调度被抢占

内核线程只能由其他内核线程创建:
kthread_create()通过clone()系统调用创建内核线程后,处于不可运行状态,如果不通过wake_up_process()明确唤醒它,不会主动运行。
创建一个进程并让它运行起来 ,可以调用kthread_run()。
内核线程启动后就一直运行直到调用do_exit()退出,或者内核的其他部分调用kthread_stop()退出,传递给kthread_stop()的参数为kthread_create()函数返回的task_struct结构的地址。

五、进程终结

进程终结时,内核必须释放它所占有的资源并告知父进程。

进程终结的原因:一般是来自自身,发生在调用exit()系统调用时。

  • 显式的调用
  • 隐式的从某个程序的主函数返回

大部分依赖于do_exit()来完成。其中有几个重点:

……
给子进程重新找养父(线程组中的其他线程或者init进程)
调用schedule()切换到新的进程
……

这之后,进程不可运行并处于EXIT_ZONBIE退出状态,占用的所有内存就是内核栈、thread_info结构和task_struct结构。此时进程存在的唯一目的就是向它的父进程提供信息。

1.删除进程描述符

释放task_struct结构发生在父进程获得已终结的子进程信息并且通知内核不关注后,需要的系统调用是wait4():

挂起调用它的进程,直到其中的一个子进程退出,此时函数返回该子进程的PID。

释放进程描述符时,需要调用release_task()

2.孤儿进程

如果父进程在子进程之前退出,必须有机制来保证子进程能找到一个新的父亲,否则这些成为孤儿的进程就会在退出时永远处于僵死状态。
解决方法如下——

  1. 在当前进程组找一个线程作为养父
  2. 让init成为它们的父进程。

具体实现:
do_exit()中会调用exit_notify()
exit_notify()会调用forget_original_parent()
forget_original_parent()会调用find_new_reaper()
然后遍历所有子进程并为它们设置新的父进程。
然后调用ptrace_exit_finish()同样进行新的寻父过程,是给ptraced的子进程寻找父亲。
最后init进程会例行调用wait()来检查其子进程,清除所有与其相关的僵死进程。