Linux内核学习笔记(五)
内核的调试比用户级程序困难的多,并且风险高——内核中的一个错误可能导致系统立即崩溃。驾驭内核调试的能力很大程度上取决于经验和对内核的深刻理解。
内核提供的打印函数 printk() 与对应的C库函数功能几乎相同,但是它有一些特殊的特性:
printk()函数是个弹性极佳的函数,可以在中断上下文、进程上下文、持有任何锁时、多处理器环境下使用。
尽管如此,还是有极少部分的地方不能使用该函数,比如系统启动最开始的时候,终端尚未初始化。这种情况下可以使用已经工作的设备,例如串口,与外界通信,或者使用printk()的变体 early_printk() ,这个函数仅仅是能够更早的在终端上打印数据而已。
printk()支持指定日志等级,以便在不同调试级别下打印不同的信息:
1 2 3 |
printk(KERN_WARNING "This is a warning!\n"); printk(KERN_DEBUG "This is a debug notice!\n"); printk("I did not specify a loglevel!\n"); |
内核会根据调用时指定的记录等级,和当前终端配置的console_loglevel来决定是否向终端上打印。所有可用的记录等级定义在 linux/kernel.h ,如下表:
记录等级 | 说明 |
KERN_EMERG | 值为0,紧急情况,系统可能崩溃 |
KERN_ALERT | 值为1,必须立即注意的问题 |
KERN_CRIT | 值为2,关键状况 |
KERN_ERR | 值为3,错误 |
KERN_WARNING | 值为4,警告 |
KERN_NOTICE | 值为5,一般的,可能值得注意的情况 |
KERN_INFO | 值为6,一般性信息 |
KERN_DEBUG | 值为7,调试信息 |
如果调用printk()时不指定记录等级,默认使用 DEFAULT_MESSAGE_LOGLEVEL ,当前该宏的值等于KERN_WARNING。
内核消息全部被保存在一个大小为 LOG_BUF_LEN 的环形缓冲区内,该长度在编译时可以通过配置项 CONFIG_LOG_BUF_SHIFT 定制。对于单处理器机器,默认值是16KB,也就是说,内核同时能存储16KB的内核消息。如果缓冲区满了时发起一个新的printk()调用,那么最老的消息被冲掉。环形缓冲区有多个好处,例如可以同时读写,便于维护,甚至可以在中断上下文中使用;环形缓冲区的缺点是可能丢失消息。
在标准的Linux系统上,用户空间程序klogd负责读取环形缓冲,并通过syslogd守护程序写入到系统日志文件。klogd可以读取 /proc/kmsg 或者调用 syslog() 系统调用来获取环形缓冲中的内核消息,其中前者是默认值,klogd会一直阻塞直到一个新的内核消息可读。
oops是内核通知用户坏事情发生的常用手段,由于内核掌管整个系统,它不能自我修复或杀死自己——向对付用户空间程序那样,内核的做法是发起oops:在控制台打印错误信息 、dump出寄存器的内容、保留栈信息。通常发生oops后内核处于不一致状态(例如正在处理重要数据的时候、持有锁的时候以外oops),内核会尽力恢复对系统的控制,但是很多情况无法做到:
- 如果oops发生在中断上下文,内核无法继续运行,导致Panics(死机)
- 如果oops发生在idle进程(PID:0)或者init进程(PID:1),也会导致系统陷入混乱,因为内核缺少这两个重要进程无法工作
- 如果oops发生在其它进程中,内核会杀该进程并尝试继续执行
oops发生的原因很多,例如内存访问越界、非法指令等。
下面是一个PPC平台上oops的示例(已经解码):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
Oops: Exception in kernel mode, sig: 4 #这里显示了Oops的原因,无法处理空指针的解引用 Unable to handle kernel NULL pointer dereference at virtual address 00000001 NIP: C013A7F0 LR: C013A7F0 SP: C0685E00 REGS: c0905d10 TRAP: 0700 Not tainted MSR: 00089037 EE: 1 PR: 0 FP: 0 ME: 1 IR/DR: 11 TASK = c0712530[0] 'swapper' Last syscall: 120 #下面是32个寄存器,配合函数的汇编代码,寄存器中的值可以帮助解决问题 GPR00: C013A7C0 C0295E00 C0231530 0000002F 00000001 C0380CB8 C0291B80 C02D0000 GPR08: 000012A0 00000000 00000000 C0292AA0 4020A088 00000000 00000000 00000000 GPR16: 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 GPR24: 00000000 00000005 00000000 00001032 C3F7C000 00000032 FFFFFFFF C3F7C1C0 #下面的调用树显示了导致问题的完整的函数调用链,下面的行调用上面的行 Call trace: [c013ab30] tulip_timer+0x128/0x1c4 #根据后面的偏移量可以找到精确的代码行 [c0020744] run_timer_softirq+0x10c/0x164 [c001b864] do_softirq+0x88/0x104 [c0007e80] timer_interrupt+0x284/0x298 [c00033c4] ret_from_except+0x0/0x34 [c0007b84] default_idle+0x20/0x60 [c0007bf8] cpu_idle+0x34/0x38 [c0003ae8] rest_init+0x24/0x34 |
在2.5以前,oops打印的是未解码的信息,调用跟踪仅仅显示函数地址,可以通过下面的命令解码:
1 2 |
#该命令配置编译时生成的system.map一起使用,如果使用模块,则还需要模块的一些信息 ksymoops saved_oops.txt |
在2.5以后,内核引入kallsyms特性,ksymoops命令不再需要了,该特性通过配置选项 CONFIG_KALLSYMS 激活。该特性在内核镜像中存储内核函数地址和符号名称的映射关系,因此内核可以打印解码后的调用跟踪。启用该选项的缺点是内核的尺寸变大。配置选项 CONFIG_KALLSYMS_ALL 存储所有符号名称,不仅仅是函数的,一般供特殊debugger使用。
有若干配置选项可以辅助内核调试,在内核配置编辑器的“Kernel Hacking”菜单中可以看到,这些配置项都依赖于 CONFIG_DEBUG_KERNEL ,不防打开全部这些选项。这些选项中常用的包括:
- enabling slab layer debugging 启用slab调试
- high-memory debugging 高端内存调试
- I/O mapping debugging I/O映射调试
- spin-lock debugging 自旋锁调试
- stack-overflow checking 栈溢出检查
- sleep-inside-spinlock checking 在自旋锁内睡眠检查
一些内核调用可用来方便的标记bug,提供断言并输出信息,其中最常用的是: BUG() 和 BUG_ON() 。当它们被调用的时候会引起oops,下面是这两个调用的示例:
1 2 3 |
if (bad_thing) BUG(); BUG_ON(bad_thing); //一般认为该调用更加清晰可读 |
可以使用panic()引起严重错误,导致内核挂起:
1 2 |
if (terrible_thing) panic("terrible_thing is %ld!\n", terrible_thing); |
如果仅仅向查看调用栈以辅助调试,可以调用:
1 2 3 4 |
if (!debug_check) { printk(KERN_DEBUG "provide some information...\n"); dump_stack(); //打印寄存器和调用栈信息 } |
使用配置选项 CONFIG_MAGIC_SYSRQ 可以激活魔法系统请求键,无论内核处于什么状态,它都可以和请求键进行通信。在运行时,还需要使用下面的命令启用它:
1 |
echo 1 > /proc/sys/kernel/sysrq |
系统请求键(SysRq)存在于大部分标准键盘,在x86上,可以按Alt + Print Screen启用。内核支持的请求键如下表:
组合键 | 说明 |
SysRq-b | 重启机器 |
SysRq-e | 发送信号SIGTERM给所有进程(init除外) |
SysRq-h | 显示帮助 |
SysRq-i | 发送信号SIGKILL给所有进程(init除外) |
SysRq-k | 安全访问:杀死该控制台上所有程序 |
SysRq-l | 发送信号SIGKILL给所有进程 |
SysRq-m | 将内存信息Dump到控制台 |
SysRq-o | 关机 |
SysRq-p | Dump寄存器信息到控制台 |
SysRq-r | 关闭键盘原始模式 |
SysRq-s | 把所有已安装的文件系统刷出到磁盘 |
SysRq-t | 把任务信息打印到控制台 |
SysRq-u | 卸载所有文件系统 |
很多第三方补丁为Linux内核提供了调试器的支持。
使用标准的GNU调试器gdb,可以对正在运行的内核进行查看:
1 2 3 4 5 6 7 |
# vmlinux是未压缩的内核镜像 # 可选参数/proc/kcore作为一个core文件,允许gdb查看正在运行内核的内存 gdb vmlinux /proc/kcore #下面可以使用标准的gdb命令来读取信息,例如 p global_variable_name #读取全局变量 disassemble function #反汇编一个函数 |
如果使用-g选项(添加在CFLAGS后)编译内核,gdb可以提供更多的信息,例如可以dump出结构体的内容、跟踪指针,但是这样编译会让内核变大很多。
gdb有很多局限性,它不能修改任何内核数据,特别是不支持单步跟踪。
这是一个补丁,利用它可以在远端主机上通过串口,并利用gdb的全部功能对内核进行调试。要使用kgdb需要两台机器,一台是待遇kgdb补丁的被调试机器;另外一条是执行调试的远程机器,通过串口线直连到被调试器。通过kgdb,包括修改变量值、设置断点、单步执行在内的全部gdb功能均可用,某些gdb版本甚至支持执行函数。
一旦 Linux 内核控制了系统(内核在由启动加载引导程序加载后获得控制权),它就会准备好内存结构和驱动程序。然后它将控制交给应用程序(通常是 init),其任务是进一步准备系统并确保在引导过程结束时,所有必要的服务正在运行且用户能够登录。该 init 应用程序通过启动 udev 守护程序来执行此操作,该守护程序将根据检测到的设备进一步加载和准备系统。启动 udev 时,将挂载尚未挂载的所有剩余文件系统,并启动其余服务。
对于那些所有必需的文件和工具驻留在同一个文件系统中的系统, init 应用程序可以完全控制进一步的引导过程。但当有多个文件系统被定义(或拥有更多的外来设备)时,情况可能变得更棘手些︰
- 当 /usr 分区位于单独的文件系统上时,除非 /usr 可用,否则无法使用存储在 /usr 中的文件的工具和驱动程序。如果需要这些工具来使 /usr 可用,那么我们就无法启动系统
- 如果根文件系统被加密,那么 Linux 内核将无法找到 init 程序,导致系统无法启动。
这个问题的解决方案长期以来一直使用 initrd(初始根磁盘)。
initrd 是一个内存中的磁盘结构(ramdisk),其中包含必要的工具和脚本,用于在将控制权交给根文件系统上的 init 应用程序之前挂载所需的文件系统。 Linux 内核在此根磁盘上触发安装脚本(通常称为 linuxrc,但该名称不是必需的),linuxrc的工作是准备系统、切换到真正的根文件系统,然后调用 init。
虽然使用initrd是必要的,但是它有一些缺点:
- 如果initrd太小了,所需要的脚本不适用。让它过大的话,就会浪费内存
- 因为它是一个真实的、 静态的设备,它消耗 Linux 内核中的缓存内存,这使得 initrd 有更大的内存消耗
initramfs 的诞生解决了这些的问题。
Initramfs基于tmpfs(大小灵活、 内存中的轻量级文件系统),和initrd一样,它包含的工具和脚本在被称为真正的根文件系统上的二进制文件 init启动之前被挂载 。这些工具包括解密抽象层 (用于加密的文件系统),逻辑卷管理器,软件 raid等。
Initramfs的全部内容,由一个.cpio归档文件提供,这是一种容易实现的归档格式。所有文件、工具、库、配置设置,都被归档到.cpio文件中,此文件随后被gzip压缩,和Linux内核存放在一起。
BootLoader在启动期间,会将Initramfs提供给内核,内核因而知晓需要使用哪个Initramfs。
内核会创建一个tmpfs,将Initramfs的内容解压到此tmpfs上,然后执行此文件系统上的init脚本。该脚本负责真实的根文件系统的加载,以及其它文件系统的加载。
文件系统加载完毕后,init脚本将自动将根文件系统切换到真实的,并调用/sbin/init继续启动流程。
对于OS来说,可移植性是指代码从一种体系结构移植到另外一种体系结构的容易程度。Linux的可移植性非常好,它广泛支持多种不同的体系结构。Linux综合考虑可移植性和性能的权衡,并把体系结构特殊的代码存放在arch目录中。2.6版本的内核支持多达21种体系结构
能够由机器一次完成处理的数据称为字,该计量单位类似于字节(byte)和页。字指明了整数的位数,通常说某个机器是64位的时候,其实就是说该机器是的字长是64位,即8字节。
处理器的通用寄存器(General-purpose registers)的大小和处理器的字长一致。对于一个体系结构,它各部件的宽度——例如内存总线,至少要和字长一样大。物理地址空间有时会比字长小,但是虚拟地址空间一般等于字长。
此外,C语言定义的long的长度总是等于字长,而int有时比字长小。例如对于64位x86,long为8字节,而int为4字节。
某些操作系统(例如Windows)和处理器不把机器的标准字称为字,相反,处于历史原因,它们用字表示固定长度的数据类型,例如字节(byte)为8位;字(word)为16位;双字(dword)为32位;四字为(qword)为64位,而真实的字长为32位。但是对于Linux,一般提到“字”,就是指CPU的字长。
对于每一个支持的体系结构,Linux都要将 asm/types.h 中的 BITS_PER_LONG 设置的符合C语言规定。
虽然C语言规定了变量的最小长度,但是标准长度却可以根据实现来变化,这导致编程时不能对数据类型的长度进行假设。但是以下规则目前是适用的:
- ANSI C规定,char总是1字节长
- 尽管没有规定int的长度,但是目前Linux支持的所有体系结构中,它都是32位
- short类似, 目前都是16位的
- 绝不应该假设long和指针的长度,随着体系结构的不同,它们可以在32-64位之间变化
- 类似的,不要假设指针长度和int相同
这类数据类型隐藏其内部格式或结构,在C语言中可以使用typedef声明不透明类型,在定义一套接口的时候经常会用到这种技巧,开发者不希望其他人将重新转换为对应的标准C类型。不透明数据类型的例子包括:pid_t,在老的Unix它是short类型的;atomic_t,用于存放可以支持原子操作的整数值,尽管它就是int,但是变量类型提示该类型仅仅在原子操作相关的函数中才使用。
使用不透明数据类型时应该注意:
- 不要假设其长度,称为一个“长度不可知论者”
- 不要尝试将其转换为标准C类型
很多地方必须使用明确长度的类型,例如一个网络包有一个16位字段;一块声卡可能具有32位寄存器;一个可执行文件有8位cookie。这时候,可以使用内核定义的长度明确的类型,这些类型声明在 linux/types.h 中,如下表:
类型 | 说明 |
s8 | 带符号字节 |
u8 | 无符号字节 |
s16 | 带符号16位整数 |
u16 | 无符号16位整数 |
s32 | 带符号32位整数 |
u32 | 无符号32位整数 |
s64 | 带符号64位整数 |
u64 | 无符号64位整数 |
上述类型只能在内核中使用,这是为了避免污染用户空间的名字空间。Linux提供了用户空间可用的对应版本,只需要在这些类型前面加两个下划线即可,例如 __u64 。
C标准表示的char类型可以带符号,也可以不带,这取决于具体编译器和CPU,或者两者共同决定。在大部分体系结构上char是带符号的其范围是-128~127,但是在ARM上它是不带符号的,范围0~255。如果要明确使用不但符号的字符类型,可以声明 unsigned char 。
对齐是跟数据块在内存中位置相关的话题,如果一个变量的地址刚好是它长度的整数倍,则称为自然对齐。例如,对于32位整数,如果其地址可以被4整除(最低2位为0)则它是自然对齐的。对于2^n字节的数据,它地址最低有效n位都是0,则自然对齐。
某些体系结构的对齐要求非常严格,例如对于sparc,载入未对齐的数据会导致错误;另外一些体系结构支持访问未对齐的数据,只不过性能会下降。性能下降和CPU访问内存的特性有关(比如某些体系结构要求必须从偶数地址访问内存,虽然理论上可以从任何地址访问),不对齐的数据可能导致不必要的多次访问内存。编写可移植性代码要避免对齐问题,保证所有类型都能够自然对齐。
编译器通常会让所有数据自然对齐。但是,编程时如果使用特殊的方式访问指针,就会引发对齐问题:
1 2 3 |
char str[] = "Hello, World!"; char *p = &str[0]; int i = *(int *)p; |
上面的例子中,把指向char的指针强制当做int*指针使用,如果变量的内存地址不能被4整除,那么就存在对齐问题。
复合数据类型按照以下规则对齐:
- 数组:只要按照基本数据类型进行对齐即可
- 联合体:只要长度最大的数据类型能够对齐即可
- 结构体:需要每个元素都能正确的对齐
结构体的对齐需要引入填补机制,才能满足每个元素都对齐的要求,利于对于下面的结构定义:
1 2 3 4 5 6 7 |
struct animal_struct { char dog; /* 1 byte */ unsigned long cat; /* 4 bytes */ unsigned short pig; /* 2 bytes */ char fox; /* 1 byte */ }; |
编译器会在内存中创建这样的结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct animal_struct { char dog; /* 1 byte */ u8 __pad0[3]; /* 填充三字节,确保cat字段按照四字节对齐 */ unsigned long cat; /* 4 bytes */ unsigned short pig; /* 2 bytes */ char fox; /* 1 byte */ u8 __pad1; /* 额外的填充,使结构体本身的长度能够被4整除 */ }; //使用GCC选项 __attribute__ ((aligned (n)));可以强制结构体按n字节对齐,默认是4字节,n=1字节对齐就不会填充 //使用伪指令可以改变缺省的对齐规则 #pragma pack (n) //下面的伪指令则取消自定义对齐规则 #pragma pack () |
sizeof 操作符返回填充后的结构体大小,可以通过重新排列结构体的成员,来避免填充,这样可以得到较小的结构体(从而节约空间)。 注意:ANSI C明确规定,编译器不得改变结构体成员对象的次序,重新排序必须手工完成。
内核开发者需要注意结构体填充带来的问题,由于不同体系结构使用的填补方式不同,对结构体进行逐字接比较可能没有意义,这也是为何C语言没有提供结构体的比较操作符的原因。
字节顺序是指一个字中各个字节的排列顺序。处理器可能将:
- 低位字节排放在内存的低地址端,高位字节排放在内存的高地址端,此字节顺序称为低位优先(小端,little-endian)
- 高位字节排放在内存的低地址端,低位字节排放在内存的高地址端,此字节顺序称为高位优先(大端,little-endian)
一个32位数 0x12345678 在两种字节序中,内存布局如下:
内存地址 | 小端 | 大端 |
0x0000 | 0x78 | 0x12 |
0x0001 | 0x56 | 0x34 |
0x0002 | 0x34 | 0x56 |
0x0003 | 0x12 | 0x78 |
x86体系结构都是使用小端,其它大部分体系结构则使用大端。
下面的代码可能很方便的判断当前机器的字节序:
1 2 3 4 5 |
int x = 1; if (*(char *)&x == 1) /* 小端,作为char看待后,会取最低内存的一字节,如果它是1,说明是地位优先 */ else /* 大端 */ |
对于Linux支持的每一种体系结构, asm/byteorder.h 中要么定义了宏 __BIG_ENDIAN 要么定义了 __LITTLE_ENDIAN ,以反应机器的字节序。同时,该头文件中还提供了字节序转换的宏:
1 2 3 4 |
u23 __cpu_to_be32( u32 ); /* 将CPU字节序转换为大端 */ u32 __cpu_to_le32( u32 ); /* 将CPU字节序转换为小端 */ u32 __be32_to_cpu( u32 ); /* 将大端转换为CPU字节序 */ u32 __le32_to_cpus( u32 ); /* 将小端转换为CPU字节序 */ |
绝不要假设时钟中断的发生频率 HZ ——也就是每秒中 jiffies 的增量——为某个固定的数值,因为不同体系结构、不同内核版本的HZ值不同。计量时间的正确方法是对HZ进行算术运算,将结果和jiffies增量比较:
1 2 3 4 5 |
HZ /* one second */ (2*HZ) /* two seconds */ (HZ/2) /* half a second */ (HZ/100) /* 10 ms */ (2*HZ/100) /* 20 ms */ |
当处理用页管理的内存时,绝对不要假设页的长度。x86-32程序员习惯性的将其假设为4KB,但是其它体系结构上可能不是这个值,有些体系结构还支持多种不同长度的页。应当使用 PAGE_SIZE 表示页长度。
有些CPU严格限制指令排序,代码指定的load、store指令的顺序都不能重新排列;而另外一些CPU则会自行排序指令序列。在排序要求最弱的的CPU上,必须使用内存屏障来保证CPU以正确的顺序提交load、store指令。
记住下面几条:
- 假设你的代码在SMP上运行, 要正确的选择和使用锁
- 假设你的代码会在支持内核抢占的情况下运行,正确的选择和使用锁,启停内核抢占
- 假设你的代码会运行在高端内存上,必要时使用kmap()
Leave a Reply