这篇文章主要记述了Linux内核的一些知识点。
Linux内核(李林)
1.在内核里打印就不能用printf
函数了,那是用户态用的,现在在内核里要用printk
函数。
2.在内核里查看调试信息使用dmesg
命令。嫌太长可以配合dmesg | tail
命令查看尾部。
3.make出来*.ko
文件之后,使用insmod *.ko
进行安装内核文件(卸载使用rmmod
命令),这时initialize函数(使用module_init(xxxxInitialize)
宏来注册该函数)会被首先执行,类似于我们在单片机里写的那些初始化寄存器,配置IO
管脚的那些功能。
Makefile文件编写
obj-m说明有一个模块需要从目标文件PrintModule.o中构造,而该模块名为PrintModule.ko
说明PrintModule由多个目标文件构成;一个编译单元一个目标文件(.o文件)
-DTEST_DEBUG:自定义宏
-ggdb:加入调试信息
-O0:优化级别
顶层的系统内核中的makefile
里面定义了kernalrelease
,编译时需要调用顶层的这个文件所以需要kernaldir
优化与调试级别
n-O0:没有优化,默认选项
n-O1:基本优化级别
n-O2:主要是优化时间效率,不考虑生成的目标文件大小
n-O3:最高优化级别
n-Os:优化生成的目标文件大小,并且激活-O2中的不增加代码大小的优化选项
n-Og:gcc 4.8中引入的优化级别。针对快速编译和超强的调试体验,并同时提供合理的运行效率。该级别比使用-O0整体效果好。
内核开发特点
1、没有libc、标准头文件
- 不能使用printf等函数
2、应该使用GNU C
- 不完全符合ANSI C标准
- 需要关注的特性(inline[C99引入]、内联汇编[asm volatile]、分支声明[likely、unlikely])
3、没有内存保护机制(因为是在ring 0态)
4、不要轻易在内核中使用浮点数
- 用户态进程使用浮点操作时,内核会完成从整数模式到浮点数模式的转换[通常是通过捕获陷阱进行转换]
- 内核本身不能陷入,需要人工保存,恢复浮点寄存器。所以内核不能完美支持浮点操作。
5、函数调用栈很小
- 默认情况下,64Bit时,栈大小为8KB
- 不要使用局部数组、不要使用递归调用
大概率会发生的汇编代码会放在条件跳转的紧接着的地址,这样CPU一取多了就会把大概率发生的指令一块就给取了,而低概率发生的代码则放在更高的地址上也就是远离xx条件跳转的地方,因为它不太可能发生的嘛所以CPU一次也不太可能把那么远的指令给取过来。
编译内核
1.首先查看一下本系统使用的内核版本号:虚拟机输入命令 uname
Linux ubuntu 5.0.0-23-generic #24~18.04.1-Ubuntu SMP Mon Jul 29 16:12:28 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
2.然后使用apt搜一下,看看有没有对应的最新版:
apt search linux-source
3.然后,安装即可,安装后到/usr/src目录查找。
apt install linux-source-4.4.0
4.进入/usr/src/linux-source-4.4.0
目录后,解压到自己的home目录(注意:压缩包自建好了文件夹,不用建新的了):
tar xjvf linux-source-4.4.0.tar.bz2 -C ~
段寄存器和段描述符
https://blog.csdn.net/Ga4ra/article/details/103613839
段寄存器
CS/DS/ES/SS + 32位FS/GS.
分段机制涉及特权等级(PL)划分。
每个段寄存器一共96位,分为两部分:
16位可见部分,又称为选择子(selector)| 13位index | 1位TI | 2位RPL |
80位不可见部分,称为描述符高速缓存器:32位基址+32位段长度+16位属性。无法通过任何指令来操作它。
可见的选择子用来找到GDT的一个描述符,用这个描述符填充并不可见部分。
早期用途:内存管理
段寄存器是一个已经或者说即将被淘汰的CPU寄存器设计。诞生于16位CPU(8086的实模式,实 代表通过段寄寄存器*16 + 偏移得到的地址是真实的物理地址,这样做会导致数据段(可读可写)把人家代码段(只读)的数据给写了),通过使用段寄存器,16位的地址总线CPU就可以使用20位地址访问到1MB的大内存。(补充:20位是这样得来的:段寄存器向左偏移4位,也就是 *16,构成了20位地址空间)
但是到了x86时代(保护模式的到来),地址总线变成了32位,段寄存器的最大作用消失。(因为Intel考虑兼容,段寄存器还是16位的)
全局描述符表GDT
首先明确一个概念:GDT位于内存中,存储的主要是段描述符(64位)包括段基地址、段限长和段属性
事实上,在GDT中存放的不仅仅是段描述符,还有其它描述符,它们都是64-bit长,
全局描述符表GDT(Global Descriptor Table)在整个系统中,全局描述符表GDT只有一张(一个处理器对应一个GDT),GDT可以被放在内存的任何位置,但CPU必须知道GDT的入口,也就是基地址放在哪里,Intel的设计者门提供了一个寄存器GDTR用来存放GDT的入口地址,程序员将GDT设定在内存中某个位置之后,可以通过LGDT指令将GDT的入口地址装入此寄存器,从此以后,CPU就根据此寄存器中的内容作为GDT的入口来访问GDT了。GDTR中存放的是GDT在内存中的基地址和其表长界限。
基地址指定GDT表中字节0在线性地址空间中的地址,表长度指明GDT表的字节长度值。指令LGDT和SGDT分别用于加载和保存GDTR寄存器的内容。在机器刚加电或处理器复位后,基地址被默认地设置为0,而长度值被设置成0xFFFF。在保护模式初始化过程中必须给GDTR加载一个新值
Segmentation fault
所谓的段错误就是指访问的内存超过了系统所给这个程序的内存空间,通常这个值是由gdtr来保存的,他是一个48位的寄存器,其中的32位是保存由它指向的gdt表,后13位保存位于gdt表的下标(偏移),最后3位包括了程序是否在内存中以及程序的在cpu中的运行级别,指向的gdt是由以64位为一个单位的表,在这张表中就保存着程序运行的代码段以及数据段的起始地址以及相应的段的大小和页面交换还有程序运行级别和内存粒度等信息,一旦一个程序发生了越界访问,CPU就会产生相应的异常保护,于是segmentation fault就出现了。即“当程序试图访问不被允许访问的内存区域(比如,尝试写一块属于操作系统的内存),或以错误的类型访问内存区域(比如,尝试写一块只读内存)。这个描述是准确的。为了加深理解,我们再更加详细的概括一下SIGSEGV。段错误应该就是访问了不可访问的内存,这个内存要么是不存在的,要么是受系统保护的。
指针越界和SIGSEGV是最常出现的情况,经常看到有帖子把两者混淆,而这两者的关系也确实微妙。在此,我们把指针运算(加减)引起的越界、野指针、空指针都归为指针越界。SIGSEGV在很多时候是由于指针越界引起的,但并不是所有的指针越界都会引发SIGSEGV。一个越界的指针,如果不引用它,是不会引起SIGSEGV的。而即使引用了一个越界的指针,也不一定引起SIGSEGV。这听上去让人发疯,而实际情况确实如此。SIGSEGV涉及到操作系统、C库、编译器、链接器各方面的内容。
在Linux下将4GB的内存空间划分为了三个区域(zone),其中的high-mem,也就是高端的896MB内存不是像普通内存normal zone一样,给应用程序直接长期映射来用。Highmem是专门用于访问超过4GB的内存的。
既然使用大内存的技术在x86下已经有了,段寄存器在x86下就纯粹的变成了32位地址空间内的段地址安排,段地址(16位):偏移(32位),就可以直接变成一个32位的绝对地址。必要性就不是很大了(虽然实模式还有要用,但是这个模式本身也是淘汰行列)。但是这个寄存器还是被强行用上了。Linux和Windows在x86下都有继续发掘这几个段寄存器的用法(虽然意义不大),创造一套新的内存管理机制(在x64下最终被淘汰)。
实模式是指段寄存器:寄存器的寻址方式,其中段寄存器中存放的实基地址,是一个地址偏移。保护模式也是段寄存器:寄存器的寻址模式,其中段寄存器中存放的是段选择子。也就是一个GDT/LDT序号。amd64架构下的模式叫做IA-32e,这个模式下有两个子模式:兼容模式和长模式。长模式直接不使用段寻址(FS/GS除外),兼容模式下仍然可以段寻址。
新的用途:系统调用
段描述符是GDT和LDT。两级的段描述符表构成了段内存管理技术。这个技术的概念与ELF的程序段的概念遥相呼应,虽然并没有直接的关系,但是很容易想象ELF的段在内存中的映射和访问是可以用内核的段管理技术来访问的。但是实际上,段管理技术最终也只是使用的段地址(16位):偏移(32位)访问的32位的地址空间。CPU的TLB和对应的地址索引技术提供了足够的针对地址的安全,权限等功能。与GDT和LDT表中的功能重叠。这就使得GDT和LDT表的存在很尴尬。内核在X86时代同时支持两种不同的内存访问方式。段式的访问和页式的访问。两种几乎为竞争关系。结果毋庸置疑,页式的完胜,到了x64,段寄存器就基本退出了历史舞台了。但是也只是又从通用的内存管理中退出,内存管理由page机制主要完成。而GDT和LDT控制的段内存访问机制,仍然被Linux带到了x64,作为页机制的补充机制。
但是即使在x64的长模式下,段寄存器依然被Windows和Linux广泛使用。这并不是Windows和Linux设计上的问题,而是Intel/AMD的设计问题。因为系统调用的逐渐的由int 80转向了sysenter/sysexit系列,后面又逐渐进入syscall的时代。而这个系列的系统调用指令,内部直接使用了段寄存器。段寻址就这样被固化下来了,在一个人们看不到的地方发挥着作用。
GDT和LDT这个二级表的概念并不是操作系统发明的,而是CPU发明的。有Lgdt这种对应的汇编指令和GDTR这种直接指定GDT位置的硬件寄存器。Linux下轻度依赖,基本上只用到了GDT。因为段寄存器的长度只有16位,所以最多一共只有65535字节的大小的GDT和LDT表(GDT相当于CPU给操作系统用的,LDT相当于给进程用的,每个进程一个,两者的结构和功能是一样的)。每一个entry占8个字节,所以一个表一共是最多8192个entry。在非64位长模式下,各种段索引都仍然可以使用,GDT,LDT仍然可以作为编程的技法。但是在ia-32e的长模式下,除了FS,GS之外,其他段寄存器都不使用GDT,LDT的段索引了(但是系统调用发生的时候修改的CS和SS是为的啥?),在ia-32e的兼容模式下,段索引仍然可以用。
但是实际上,由于Linux对段寻址的使用很轻量,LDT直接基本不使用,GDT里面的entry的数目也很有限。都是用于特殊目的的便捷寻址的(便捷吗?)。Windows下目前的win 10已经对用户彻底隐藏LDT。在Windows下有一个WOW64模式,是兼容运行32位程序的模式。在这个模式下,32位的程序可以运行在64位的CPU上,操作系统内核运行的是长模式,但是应用运行的是兼容模式(也就是ia-32e的两种子模式)。Windows通过控制在两种模式下切换,完成32位应用程序的系统请求(系统请求都是实现在64位的长模式下)。这个切换的过程实际上是CS值的变化,WOW64应用可以自己改变CS的值来达到在32位的程序中运行64位代码的目的。这个技术叫做地狱之门。wow64看到的fs,gs分别指向32位线程的TEB和64位线程的TEB,其他的四个段寄存器都指向一个相同的偏移。该偏移的作用未知。但是因为在兼容模式下,段寻址是有效的,所以可以很容易想到这个段选择子应该是4GB内存的根VAD,也就是4GB线性地址空间的基地址位置。
几个段寄存的名字就表明了CPU当时设计他们的初始目的。CS是代码段,DS是数据段,SS是堆栈段,ES是附加段。这个与ELF的段设计很相似,可惜ELF也没有用这套硬件机制来映射,毕竟ELF不止有这么四个段。
CPU硬件上设计了GDT和LDT,但是段寄存器就只有这四个(或算fs/gs的6个),所以在使用段寄存器的时候就得在寄存器里面指定访问的到底是GDT还是LDT了。所以16位的段寄存器里面并不都是段偏移。必经一个table最多有8192个entry,只需要13个位就可以完全索引。也正是因为如此,段寄存器就只使用了15-3这13个位来作为entry的索引。2位用于指定是GDT还是LDT,1,0两位就对应我们熟悉的4个权限级别ring0-ring3。这个是保护模式的定义。
所以就引出了段寄存器的另外一个功能:权限控制。页的分级访问也有一套完整的针对每个页的权限控制机制,与段entry里面的权限类似。但是16位的段寄存器里面竟然也有权限,并且只有两个位。这两个位就被Intel充分的挖掘了。虽然Intel没有明说,你会发现这两个位和ring0-ring3在实际的执行的时候总是对应的。当CS段寄存器的这两位指定了ring0,但是却是在ring3的模式下的代码,是不能执行的。SS寄存器也是一样。在sysenter之前,指向用户程序的栈,在进入内核后,由sysenter指向了该线程对应的内核栈。两个权限位就与CS是一样的与ring3和ring0保持一致。
Sysenter是一个从ring3切换到ring0的指令,它的工作原理依赖了几个专门设计的寄存器。IA32_SYSENTER_CS (0x174) 里面存放了系统调用所用到的CS,这个CS的最后两位的值就是00,也就是ring0的权限了。由于CS只有16位,而这个0x174寄存器却有32位。所以还有一些其他的信息存储。IA32_SYSENTER_ESP (0x175)内核用到的ESP,IA32_SYSENTER_EIP (0x176)就是内核的系统调用入口,当然是相对于CS的。
所以我们惊讶的发现,即使在64位下,所有的系统调用也是经过段寄存器的。段寄存器在系统中依然被重度的使用。但是,这么做究竟有没有必要?因为是硬件直接这么固化了,软件上就算认为没有必要也没有办法。他被用于系统调用是既定事实。但是在软件层面,段寄存器也确实从内存管理中逐渐的淡出。例如x86下,段寄存器在Windows上还可以用于DEP或者PEB,在x64长模式下,无论是Windows和Linux,都在软件层面弱化甚至消除段寄存器的依赖。硬件上也直接认为段选择子无效。
有一句话,很多时候只是听一听:所有的用户空间代码都是运行在ring3,所有的内核代码都是运行在ring 0。仔细想这句话就会发现很多不可思议的地方。这意味着,不论是root用户还是普通用户,都是无差别的受到这个限制。既然所有的内核代码都是在ring0中执行,那么就必然存在一个从用户空间到内核空间的ring切换的位置。这个位置在Linux内核代码里并没有找到。显然是一个硬件机制。这就是sysenter和CS段寄存器在起到的巨大作用。
一个特殊的用法:TLS
在Windows下,情况更复杂。除了硬件sysenter使用的CS段寄存器。Windows自己更改了从用户到内核所使用的寄存器。线程运行在 RING0 下, FS 段值是 0x30 ( WindowsXP 下值,在 Windows2000 下值为 0x38 );运行在 RING3 下时, FS 段寄存器值是 0x3b 。 FS 寄存器值的改变是在程序从 Ring3 进入 Ring0 后和从 Ring0 退回到 Ring3 前完成的,也就是说:都是在 Ring0 下给 FS 赋不同值的。Windows使用FS来指向TEB(X86情况下,X64情况下GS指向TEB。可以通过检查GS是否为0来判断当前是否是纯32位系统,wow64下,FS和GS分别指向线程的32位和64位的TEB)。在ring3下,FS一直指向当前线程的TEB段。随着线程的切换就一直在切换。所以在用户空间的代码,可以放心的用FS来直接索引到TEB段的内存内容。在ring0下,Windows下的FS指向处理器控制区域(KPCR)对应的GDT段。这个区域中保存这处理器相关的一些重要数据值,如 GDT 、 IDT 表的值等等。
也就是说,Windows除了sysenter硬件使用的CS外,还额外改变使用了FS段寄存器。这个FS的使用可以用来实现TLS。在x86和x64下,windows的行为差不多。但是在Linux下区别就比较大。Linux的无论是x86还是x64,对TLS的支持都是在glibc中完成的。也就是说,虽然大家都要改CS,但是对于其他段寄存器的修改,Linux在glibc中,而Windows在内核里。并且Windows使用的是FS,glibc使用的是GS。
但是无论是GS还是FS,都是一个TLS的访问入口,指明TLS的在GDT中的位置(所有的段寄存器都是用来在GDT/LDT中充当索引的)。但是Linux下仍然需要提前设置这个位置,Windows下因为进入和设置都是在内核里,所以不需要。Linux下需要提前使用set_thread_area来将该一个线程的TLS地址设置到GDT中。因为线程上下文切换是发生在内核中,内核在上下文切换的时候就同时修改GDT中的这个线程对应的entry和GS寄存器。这样glibc中使用GS寄存器就可以直接索引到特定的GDT的entry,就可以找到对应的TLS地址了。内核保证了位于用户空间的glibc看到的GS寄存器都是指向存储TLS信息的GDT的entry(这里的指向值得是段选择子提供的GDT的index序号)。
从上述可以看到在Linux下的两点:1、TLS的数据的真实存储位置是glibc提供的,通过set_thread_area来告知内核。2、内核中存储线程TLS的位置是GDT表中的某一个entry,这个entry的index(也就是GS段寄存器在索引时需要使用的选择子),可以由用户提供,也可以由内核来选择一个可用的。Glibc下是由内核选择。
也就是说,TLS实际上还是存储在线程自己申请的内存空间中。内核只是帮忙在GDT中找到一个entry记录一下,在线程切换的时候帮用户空间设置一下这个段寄存器。从而,我们能发现,TLS的实现并不是必须要使用内核支持。可以简单的通过编译器和链接器的支持来做到。(定义线程变量的语句直接编译成一个类似.GOT的数据,lazy解析的时候再与真实的地址绑定解析)。所以TLS对于段寄存器来说,是弱需求,只是目前都在这么用。毕竟TLS是随着线程的增加而增加的,不使用内核支持实现的难度相对大一些。内核在GDT中找到的entry的index会通过set_thread_area的参数修改的方式告诉给用户。不同线程不一定一样,但是不同线程也可以共用一个entry。一个数量级是很多Linux内核里,这个可以选择的TLS entry只有三个。但是线程却有那么多个。
事实上,在Linux下,如果只是基于glibc的应用程序,所有的线程都是使用的GDT中三个entry的第一个。set_thread_area需要用户传一个user_desc结构体。
1 | struct user_desc { |
在glibc下,用户传进来的entry_number是-1,意思是让内核选择index,然后通过修改这个值告诉用户结果。
Linux下几乎没有使用LDT,GDT中预留了三个给TLS用。Glibc让用户选择,最后选择的几乎一定是三个中的第一个。因为内核是遍历查找这三个,看有没有被使用。也就是说GS寄存器的值几乎是固定的。
这里有必要解释一下三个TLS的选择问题。因为GDT表是每个CPU核一个表,所以只需要考虑单个CPU是否会发生线程选择冲突。单个CPU在同一个时刻,物理上,只能有一个线程在运行。也就是说,这个线程永远都是独占这一个GDT的entry。内核线程上下文切换的时候会同时切换GDT中TLS的内容为线程的内容,并且设置GS寄存器。所以一般情况,我们在用户空间看到的GS的值都是一样的(0x33,也就是6号entry,取段寄存器的15-3位为index)。比如Wine会使用第二个TLS,就是用来模拟Windows下exe的执行的。Windows下也有类似的TLS机制,只不过是通过FS段寄存器实现,而不是GS。Windows在64下同时使用GS和FS来支持TLS,但是连读写fs,gs寄存器的权限都不提供了(FSGSBASE指令是一个ring3的指令扩展,可以用来读取FSGS的内容,需要CPU支持)。Linux在64位还提供了arch_prctl可以用来读写段寄存器。
Linux可以给每个进程(线程)都创建一个LDT,看起来这个LDT是一个可以充分利用的段描述符表。并且提供了modify_ldt可以操作这个表。但是这个在Linux下只是一个兼容机制,Linux不建议用这个LDT表来实现TLS,或者用于替他的目的。因为会降低线程切换的效率。只用于兼容16位的程序或者32位的段寻址程序。LDT基本被Linux废弃。实际上,LDT表,不显示的调用modify_ldt,Linux就不会生成。
段寻址的性能
段寻址需要先查段表(GDT或者LDT),从段表中找到实际的目标地址内存块的初始位置,然后再跟偏移计算。由于段表是放在内存的,看起来每次都需要额外访问一次内存。实际上,CPU在设计的时候,实现了影子寄存器。影子寄存器相当于段表条目的缓存,使得段寄存器的段信息可以直接从寄存器中读取,从而达到与平台寻址(flat)相似的寻址性能。
总结
段寄存器越来越对用户空间代码封闭,所以程序代码应该尽可能的少依赖使用段寄存器的trick。CS和SS被硬件sysenter/sysexit频繁使用(这个和paper中说的长模式不使用段寻址互相冲突,我不能解释)。FS/GS在64位的Windows下被操作系统完全控制。而64位会从硬件层面直接忽略段寄存器的寻址方式。也就是说,在64位下,段寄存器作为一种寻址方式是被禁止使用的,只能由sysenter/sysexit等硬件使用。段寄存器从一个功能巨大的内存管理方式,逐渐退步到内存管理的辅助,到TLS的最后阵地,最后逐渐变成了硬件专用的内部寄存器。发展的考虑,在任何情况下,应用程序继续显示的使用段寄存器都是不被鼓励的。
Trick技巧上,如果寄存器实在不够用了,可以考虑在linux下使用fs来寻址。
#include <asm/prctl.h>
static int arch_prctl(int func, void *ptr) {
return syscall(__NR_arch_prctl, func, ptr);
}
arch_prctl(ARCH_SET_FS, (void*)fsbase);
mov rax,fs:[rcx+rdx*8]
FS寄存器指向当前活动线程的TEB结构(线程结构)
偏移 说明
000 指向SEH链指针
004 线程堆栈顶部
008 线程堆栈底部
00C SubSystemTib
010 FiberData
014 ArbitraryUserPointer
018 FS段寄存器在内存中的镜像地址
020 进程PID
024 线程ID
02C 指向线程局部存储指针
030 PEB结构地址(进程结构)
034 上个错误号