查看原文
其他

工程师深度:学通Linux内核(含详细代码)

李肖遥 嵌入式ARM 2021-01-31

内核探索工具类


Linux的具体概述这里就不多说了,今天主要讲的是Linux内核中常用的数据结构和语法的使用,并简述一些工具和实用程序,从而获取理解内核内幕所需要的信息,还会介绍一下在每个内核子系统所涉及的基本概念,并且粗略分析下子系统功能的必要代码。

关于内核方面内容也可参见:

深度:关于Linux内核最硬核的文章
解读:一文看懂Linux内核

内核中常见的数据类型有链表、查找、树等等。链表可以说贯穿整个Linux内核,在Linux中,链表才常常以循环双向链表的形式出现,所以给定链表中的任一结点,均可以找到下一结点和前一结点,链表是个很重要的知识点,需要学学数据结构才能掌握,这里不多阐释,有关链表定义的所有代码可以在头文件include/linux/list.h中查看。至于查找算法,主要表现在大O表示法,它代表对于一个定值n在最坏情况下所花费的查找时间,另外就是数,数被用在Linux的内存管理中,能够有效访问并操作数据,前几天恶补了一下树的相关知识以及代码,收获颇丰,树在Linux中通常以链表和数组的形式实现,其中二叉树和红黑树在Linux中使用最多,这里提出来有助大家理解Linux的内核,这样可以更好的理解复杂的操作。
 
作为一个操作系统,处理器当然是不可获取的知识点了,Linux的某些部分和它密切相关,汇编是最好理解底层的语言,因为在之前大二学过微机原理,在此就不介绍x86和PowerPC体系结构的汇编语言了。但是需要简述几个特殊的C语言用法,这里着眼Linux2.6内核中的常见C语言规范,澄清一下几个C语言知识。

asmlinkage

asmlinkage要使用局部堆栈来传递参数,这就涉及到了宏FASTCALL,它通知汇编程序使用通用寄存器来传递参数,下面举一个asmlinkage的例子:

asmlinkage long sys_gettimeofday(struct timeval *tv, struct timezone __user *tz)

UL


UL其实就是unsigned long啦,它告诉编译程序将这个数值当作long型数值处理,使用UL有助于编写出和体系结构无关的代码,内核中有些这样的例子,例如include/linux.h中

#define ULONG_MAX (~0UL)

inline

inline 表明要优化 函数的可执行代码,Linux内核中时用的 inline函数大多被定义为static类型(static inline),,这样的申明意味着直接将它加入调用程序中,优点是可以免除函数调用的任何开销,不足之处在于使用inline会增加二进制映象的大小,因此会降低访问CPU高速缓存的速度,所以不能始终使用inline。

const和volatile

这两个真的很容易混淆并且不是很好懂,const不一定只代表常数有时会是“只读”的意思,这里提问一下“const int *x”和“int const * x”有什么区别??一下是一个const的例子

static inline void prefetch(const void *x){ __asm__ __volatile__ ("dcbt 0,%0" : : "r" (x));}

关键字volatile表明变量无需警告就可以被修改,它通知编译程序每次使用该变量时候都要重新加载其值,一下是include/linux/spinlock.h关于如何使用volatile的一个例子

typedef struct {...volatile unsigned int lock;...} spinlock_t

那么内核在运行当中有什么内部的奥秘呢,接下来介绍内核中常用的探究各种内核文件的工具。


objdump/readelf

objdump/readelf可以分别用于显示目标文件和ELF文件的任何信息,可以在终端上借助命令行参数使用命令来查看给定的目标文件的头文件、文件大小和结构,

hexdump

hexdump可以显示给定的十六进制/ASCII/八进制文件的内容。这些需要一些命令来举例,大家可以在实践的 时候自己去掌握如何使用hexdump这个命令

nm

nm可以列出指定目标文件的符号,能够显示符号的值,类型和名字。

objcopy

objcopy,看半个单词就知道是复制的意思,当你想复制一个目标文件而忽略或改变其某方面的内容时候,就可以使用该命令。

ar

ar命令最常用在Make文件中,它将一些常用的函数连接到单个库文件中,也可以将单个程序库中的目标文件分离出来,有助于维护链接程序时用的索引库函数库。
 
当Linux系统更新后正在运行时,内核会记录一些消息,并提供整个操作过程系统的状态信息,那么我们怎么“倾听”内核的消息呢?下面介绍最常用的几种内核和终端对话的方式。

printk()

大家都知道printf()函数,那么为什么Linux为什么不使用呢,这是因为内核中没有链接标准C函数库,但是两者的接口是一样的 ,printk是在内核中运行的向控制台输出显示的函数,Linux内核首先在内核空间分配一个静态缓冲区,作为显示用的空间,然后调用sprintf,格式化显示字符串,最后调用tty_write向终端进行信息的显示。

dmesg

dmesg内核有多种方式可以用在存储日志和信息上,dmesg是一种程序,用于检测和控制内核环缓冲,可以用于显示存储在/proc/kmsg中的缓冲内容,并能够根据消息级别来选择是否过滤这个缓冲区,并且这个程序用来帮助用户了解系统的启动信息。

/var/log/messages

Linux系统的  /var/log/messages下存储的大都是已经登录系统的消息。messages 日志是核心系统日志文件。它包含了系统启动时的引导消息,以及系统运行时的其他状态消息。IO 错误、网络错误和其他系统错误都会记录到这个文件中。其他信息,比如某个人的身份切换为 root,也在这里列出。如果服务正在运行,比如 DHCP 服务器,您可以在 messages 文件中观察它的活动。通常,/var/log/messages 是您在做故障诊断时首先要查看的文件。
  
小结:

在探索Linux内核之前应该具备的基础知识和背景,对上述讲到的数据结构的基本了解有助于理解以后会讲到的进程和分页机制,关于内核、的工具集也是粗了点讲解了一下,但是需要在实际操作中多运用才能体会才能更加深刻的了解,有哪些问题希望大家指正。


进程



都说这个主题不错,连我自己都觉得有点过大了,不过我想我还是得坚持下去,努力在有限的时间里学习到Linux内核的奥秘,也希望大家多指点,让我更有进步。今天讲的全是进程,这点在大二的时候就困惑了我,结果那个时候我就止步不前了,这里主要讲的是为何引入进程、进程在Linux空间是如何实现的,并且描述了所有与进程执行相关的数据结构,最后还会讲到异常和中断等异步执行流程,它们是如何和Linux内核进行交互的,下面我就来具体介绍一下进程的奥妙。

首先我们要明确一个概念,我们说的程序是指由一组函数组成的可执行文件,而进程则是特定程序的个体化实例,进程是对硬件所提供资源进行操作的基本单位。在我们继续讨论进程之前,得明白一个几个命名习惯,通常说的“任务“和”进程“就是一回事。

事实上,进程都有一个生命周期,进程从创建过后会经历各种状态后死亡,下面的例子帮助大家理解一下程序是如何实例化进程的。

#include <stdio.h>#include <sys/types.h>#include <sys/stat.h>#include <fcnt1.h>
int main(int argc, char *argv[]){int fd;int pid;
pid = fork();if(pid == 0) { execle("/bin/ls", NULL);exit(2); }
if(waitpid(pid) <0 )printf("wait error\n");
pid = fork();if(pid == 0) { fd = open("Chapter_2.txt",O_RDONLY); close(fd); }
if(waitpid(pid)<0)printf("wait error\n");
exit(0);}
creat_process

一个进程包括了很多属性,使进程彼此互不相同,在内核中,进程描述符是一个task_struct的结构体,用来保存进程的属性和相关信息,内核使用循环双向链表task_list存放所有进程描述符,同时借助全局变量current保存当前运行进程的task_struct。至于task_struct的定义大家可以参见include/Linux/sched.h这里我讲不了辣么多,不过我得说明一下进程和线程的区别,进程由一个或者多个线程组成,每个线程对应一个task_struct,其中包含一个唯一的线程ID。线程作为调度和分配的基本单位,而进程作为拥有资源的基本单位;不仅进程之间可以并发执行,同一个进程的多个线程之间也可以并发执行;进程是拥有资源的一个独立单位,线程不拥有系统资源,但可以访问隶属于进程的资源。

进程描述符(task_struct)某些字段含义,这里有太多的与进程相关的域,我罗列一些如下,,假设进程为P。
  • state:P进程状态,用set_task_state和set_current_state宏更改之,或直接赋值。
  • thread_info:指向thread_info结构的指针。
  • run_list:假设P状态为TASK_RUNNING,优先级为k,run_list将P连接到优先级为k的可运行进程链表中。
  • tasks:将P连接到进程链表中。
  • ptrace_children:链表头,链表中的所有元素是被调试器程序跟踪的P的子进程。
  • ptrace_list:P被调试时,链表中的所有元素是被调试器程序跟踪的P的子进程。
  • pid:P进程标识(PID)。
  • tgid:P所在的线程组的领头进程的PID。
  • real_parent:P的真实的父进程的进程描述符指针。
  • parent:P的父进程的进程描述符指针,当被调试时就是调试器进程的描述符指针。
  • children:P的子进程链表。
  • sibling:将P连接到P的兄弟进程链表。
  • group_leader:P所在的线程组的领头进程的描述符指针。
 
我们了解到,任何进程都是由别的进程创建的,操作系统通过fork()、vfork()、clone()系统调用来完成进程的创建。进程创建的系统调用如下图:


这三个系统最终都调用了do_fork()函数,do_fork()是内核函数,它完成与进程创建有关的大部分工作,下面 我来粗略介绍一下fork()、vfork()、clone()函数。

fork()函数

fork()函数返回两次,一次是子进程,返回值为0;一次是父进程,将返回子进程的PID,

vfork()函数

和fork()函数类似,但是前者的父进程一直阻塞,直到子进程调用exit()或exec()后。

clone()函数

clone()函数接受一个指向函数的指针和该函数的参数,由do_fork()创建的子进程一诞生就调用这个库函数。

三者的唯一区别
,在最终调用do_fork()函数设置的那些标志不一样,如下表。


fork()
vfork()
clone
SIGCHLD
X
X

CLONE_VFORK

X

CLONE_VM

X


do_fork()函数利用辅助函数copy_process()来创建进程描述符以及子进程执行所需要的所有其他内核数据结构,在 Linux 内核中,供用户创建进程的系统调用fork()函数的响应函数是 sys_fork()、sys_clone()、sys_vfork()。这三个函数都是通过调用内核函数 do_fork() 来实现的。下面就具体的 do_fork() 函数程序代码进行分析(该代码位于 kernel/fork.c 文件中)

fork.c
int do_fork(unsigned long clone_flags,unsigned long stack_start, struct pt_regs *regs, unsigned long stack_size){ int retval; struct task_struct *p; struct completion vfork;
retval = -EPERM ;
if ( clone_flags & CLONE_PID ) {if ( current->pid )goto fork_out; }
reval = -ENOMEM ;
p = alloc_task_struct(); // 分配内存建立新进程的 task_struct 结构if ( !p )goto fork_out;
*p = *current ; //将当前进程的 task_struct 结构的内容复制给新进程的 PCB结构
retval = -EAGAIN;
//下面代码对父、子进程 task_struct 结构中不同值的数据成员进行赋值
if ( atomic_read ( &p->user->processes ) >= p->rlim[RLIMIT_NPROC].rlim_cur && !capable( CAP_SYS_ADMIN ) && !capable( CAP_SYS_RESOURCE ))goto bad_fork_free;
atomic_inc ( &p->user->__count); //count 计数器加 1 atomic_inc ( &p->user->processes); //进程数加 1
if ( nr_threads >= max_threads )goto bad_fork_cleanup_count ;
get_exec_domain( p->exec_domain );
if ( p->binfmt && p->binfmt->module ) __MOD_INC_USE_COUNT( p->binfmt->module ); //可执行文件 binfmt 结构共享计数 + 1 p->did_exec = 0 ; //进程未执行 p->swappable = 0 ; //进程不可换出 p->state = TASK_UNINTERRUPTIBLE ; //置进程状态 copy_flags( clone_flags,p ); //拷贝进程标志位 p->pid = get_pid( clone_flags ); //为新进程分配进程标志号 p->run_list.next = NULL ; p->run_list.prev = NULL ; p->run_list.cptr = NULL ;
init_waitqueue_head( &p->wait_childexit ); //初始化 wait_childexit 队列
p->vfork_done = NULL ;
if ( clone_flags & CLONE_VFORK ) { p->vfork_done = &vfork ; init_completion(&vfork) ; }
spin_lock_init( &p->alloc_lock );
p->sigpending = 0 ;
init_sigpending( &p->pending ); p->it_real_value = p->it_virt_value = p->it_prof_value = 0 ; //初始化时间数据成员 p->it_real_incr = p->it_virt_incr = p->it_prof_incr = 0 ; //初始化定时器结构 init_timer( &p->real_timer ); p->real_timer.data = (unsigned long)p; p->leader = 0 ; p->tty_old_pgrp = 0 ; p->times.tms_utime = p->times.tms_stime = 0 ; //初始化进程的各种运行时间 p->times.tms_cutime = p->times.tms_cstime = 0 ;#ifdef CONFIG_SMP //初始化对称处理器成员{ int i; p->cpus_runnable = ~0UL; p->processor = current->processor ;for( i = 0 ; i < smp_num_cpus ; i++ ) p->per_cpu_utime[ i ] = p->per_cpu_stime[ i ] = 0; spin_lock_init ( &p->sigmask_lock );}
#endif p->lock_depth = -1 ; // 注意:这里 -1 代表 no ,表示在上下文切换时,内核不上锁 p->start_time = jiffies ; // 设置进程的起始时间
INIT_LIST_HEAD ( &p->local_pages ); retval = -ENOMEM ;
if ( copy_files ( clone_flags , p )) //拷贝父进程的 files 指针,共享父进程已打开的文件goto bad_fork_cleanup ;
if ( copy_fs ( clone_flags , p )) //拷贝父进程的 fs 指针,共享父进程文件系统goto bad_fork_cleanup_files ;
if ( copy_sighand ( clone_flags , p )) //子进程共享父进程的信号处理函数指针goto bad_fork_cleanup_fs ;
if ( copy_mm ( clone_flags , p ))goto bad_fork_cleanup_mm ; //拷贝父进程的 mm 信息,共享存储管理信息
retval = copy_thread( 0 , clone_flags , stack_start, stack_size , p regs );//初始化 TSS、LDT以及GDT项
if ( retval )goto bad_fork_cleanup_mm ;
p->semundo = NULL ; //初始化信号量成员
p->prent_exec_id = p-self_exec_id ;
p->swappable = 1 ; //进程占用的内存页面可换出
p->exit_signal = clone_flag & CSIGNAL ;
p->pdeatch_signal = 0 ; //注意:这里是父进程消亡后发送的信号
p->counter = (current->counter + 1) >> 1 ;//进程动态优先级,这里设置成父进程的一半,应注意的是,这里是采用位操作来实现的。
current->counter >> =1;
if ( !current->counter ) current->need_resched = 1 ; //置位重新调度标记,实际上从这个地方开始,分裂成了父子两个进程。
retval = p->pid ;
p->tpid = retval ; INIT_LIST_HEAD( &p->thread_group );
write_lock_irq( &tasklist_lock );
p->p_opptr = current->p_opptr ; p->p_pptr = current->p_pptr ;
if ( !( clone_flags & (CLONE_PARENT | CLONE_THREAD ))) { p->opptr = current ;if ( !(p->ptrace & PT_PTRACED) ) p->p_pptr = current ; }
if ( clone_flags & CLONE_THREAD ){ p->tpid = current->tpid ; list_add ( &p->thread_group,&current->thread_group ); }
SET_LINKS(p);
hash_pid(p); nr_threads++;
write_unlock_irq( &tasklist_lock );if ( p->ptrace & PT_PTRACED ) send_sig( SIGSTOP , p ,1 ); wake_up_process(p); //把新进程加入运行队列,并启动调度程序重新调度,使新进程获得运行机会 ++total_forks ;if ( clone_flags & CLONE_VFRK ) wait_for_completion(&vfork);
//以下是出错处理部分 fork_out:return retval; bad_fork_cleanup_mm: exit_mm(p); bad_fork_cleanup_sighand: exit_sighand(p); bad_fork_cleanup_fs: exit_fs(p); bad_fork_cleanup_files: exit_files(p);
bad_fork_cleanup: put_exec_domain( p->exec_domain );
if ( p->binfmt && p->binfmt->module ) __MOD_DEC_USE_COUNT( p->binfmt->module ); bad_fork_cleanup_count: atomic_dec( &p->user->processes ); free_uid ( p->user ); bad_fork_free: free_task_struct(p);goto fork_out;}
fork

Linux中的进程有7种状态,进程的task_struct结构的state字段指明了该进程的状态。下图形象的形容了各个状态之间的转换,这里不多加阐释,大家看图体会。

  • 可运行状态(TASK_RUNNING)
  • 可中断的等待(TASK_INTERRUPTIBLE)
  • 不可中断的等待(TASK_UNINTERRUPTIBLE)
  • 暂停状态(TASK_STOPPED)
  • 跟踪状态(TASK_TRACED):进程被调试器暂停或监视。
  • 僵死状态(EXIT_ZOMBIE):进程被终止,但父进程未调用wait类系统调用。
  • 僵死撤销状态(TASK_DEAD):父进程发起wait类系统调用,进程由系统删除。

至于进程的终止,上文已经提到过了exit()函数,进程终止有三种方式:明确而自愿的终止,隐含但也是自愿终止,自然而然的运行终止,这些可以通过sys_exit()函数、do_exit()函数来实现,这里不多说了,都很好懂的,到此,我们应该对进程在生命周期中所经历的各种状态,完成状态转换的大部分函数等等等有了了解了,有需要补充的或者不懂再借阅i些资料就应该能够对进程的相关知识有了很好的掌握了,希望大家能够理解,那么我的任务也算完成了一半了。
 
了解了以进程为中心的状态和转换但是要真正完成进程的运行和终止,那么内核的基本框架是必须要掌握的,现在我们来介绍调度程序的基础知识,调度程序的对象是一个称为运行队列的结构,下图说明了队列中的优先权数组,其定义以及相关分析如下:

struct prio_array {int nr_active;  //计数器,记录优先权数组中的进程数unsigned long bitmap[BITMAP_SIZE];  //bitmap是记录数组中的优先权,实际长度取决于系统无符号长整型的大小struct list_head queue[MAX_PRIO];  //queue存储进程链表的数组,且每个链表含有特定优先权的进程};

  
最后讲到的是异步执行流程,我们说过,进程能够通过终端中断一个状态转换到另一个状态,获得这种转换的唯一途径就包括异常和中断在内的异步。

异常:


  • 处理器产生的(Fault,Trap,Abort)异常
  • programmed exceptions(软中断):由程序员通过INT或INT3指令触发,通常当做trap处理,用处:实现系统调用。

异常也叫做同步中断,是发生在整个处理器硬件内部的事件。异常通常发生在指令执行之后。大多数现代 处理器允许程序员通过执行某些指令来产生一个异常。其中一个例子就是系统调用。


系统调用:

用户态的程序调用的许多C库例程,就是把代码和一个或者多个系统调用捆绑在一起形成一个单独的函数。当用户进程调用其中一个函数的时候,某个值被放入适当的处理器寄存器中,并产生一个软中断irp(异常)。然后这个软中断调用内核入口点。系统调用能够在用户空间和内核空间之间传递数据,由两个内核函数来完成这个任务:copy_to_user()和copy_from_user()。系统调用号和所有的参数都先被存入处理器的寄存器中,当x86的异常处理程序处理软中断0x80时,它对系统调用表进行索引。


中断:

  • 可屏蔽中断:所有有I/O设备请求的中断都是,被屏蔽的中断会一直被CPU 忽略,直到屏蔽位被重置。
  • 不可屏蔽中断:非常危险的事件引起(如硬件失败)

中断对处理器的执行是异步的,就是说中断能够早指令之间发生。一般要发生中断,中断控制器是必须的(x86用的是8259中断处理器)。当中断处理器有有一个待处理的中断时,它就触发连接到处理器的相应INT线,然后处理器通过触发线来确认这个信号,确认线连接到INTA线上。这时候,中断处理器就可以把IRQ数据传到处理器上了,这就是一个中断确认周期。具体的例子就不好列举了,需要太大篇幅,也需要更多的知识才能去深刻了解。

  
IRQ结构:

  • 硬件设备控制器通过IRQ线向CPU发出中断,可以通过禁用某条IRQ线来屏蔽中断。
  • 被禁止的中断不会丢失,激活IRQ后,中断还会被发到CPU 
  • 激活/禁止IRQ线 != 可屏蔽中断的 全局屏蔽/非屏蔽
 
小结:

本结主要是解释了为何引入进程,简单讨论了用户空间与内核空间的控制流,并且讨论了进程在内核中是如何实现的,里面涉及到队列的知识,本问没有讲到,就需要读者自己去学习数据结构,总之Linux内核需要很好的数据结构知识,最后还粗略涵盖了终端异常,总之,感觉进程是个大骨头,讲的很笼统,还需要大量时间去学习,并且分析Linux内核源代码,总之,继续加油~
 

内存管理方式


Linux如何追踪和管理用户空间进程的可用内存和内核的可用内存?内核对内存分类的方式以及如何决定分配和释放内存?,内存管理是应用程序通过软硬件协助来访问内存的一种方式,这里我们主要是介绍操作系统正常运行对内存的管理


在深入了解内存管理的实现之前一些有关内存管理的高级概念我们有必要了解一下,先说虚拟内存,怎么产生的呢?现在操作系统要求能够使多个程序共享操作系统资源,并且还要求内存对程序的开发透明,有了虚拟内存之后,依靠透明的使用磁盘空间,就可以使系统物理内存大得多,而且使得多个程序共享更加容易方便。


然后再说说虚拟地址,当一个程序从内存中存取数据时,会使用地址来指出需要访问的内存地址,这就是虚拟地址,它组成了进程虚拟地址空间,其大小取决于体系结构的字宽。内存管理在操作系统中负责维护虚拟地址和物理地址之间的关系并且实现分页机制(将页从内存到磁盘之间调入调出的机制), 内核把物理页作为内存管理的基本单位;内存管理单元(MMU)把虚拟地址转换为物理地址,通常以页为单位进行处理。如:

32位系统:页大小4KB
64位系统:页大小8KB  


上述这些数据都会在页面载入内存时候得以更新,下面来看看内核是如何利用页来实现内存管理的。
 
作为内存管理的基本单元,页有许多属性需要维护,下面的结构体描述了页描述符的各种域以及内存管理是如何使用它们的,在include/linux/mm.h中可以查看到定义。

struct page{unsigned long flags;  //flags用来存放页的状态,每一位代表一种状态atomic_t count; //count记录了该页被引用了多少次unsigned int mapcount;unsigned long private; struct address_space *mapping; //mapping指向与该页相关的address_space对象 pgoff_t index; struct list_head lru; //存放的next和prev指针,指向最近使用(LRU)链表中的相应结点 union { struct pte_chain; pte_addr_t; } void *virtual; //virtual是页的虚拟地址,它就是页在虚拟内存中的地址}

要理解的一点是page结构与物理页相关,而并非与虚拟页相关。因此,该结构对页的描述是短暂的。内核仅仅用这个结构来描述当前时刻在相关的物理页中存放的东西。这种数据结构的目的在于描述物理内存本身,而不是描述包含在其中的数据。

 
在linux中,内核也不是对所有的也都一视同仁,内核而是把页分为不同的区,使用区来对具有相似特性的页进行分组。Linux必须处理如下两种硬件存在缺陷而引起的内存寻址问题:

  • 一些硬件只能用某些特定的内存地址来执行DMA
  • 一些体系结构其内存的物理寻址范围比虚拟寻址范围大的多。这样,就有一些内存不能永久地映射在内核空间上。


为了解决这些制约条件,Linux系统使用了三种区:

  • ZONE_DMA:这个区包含的页用来执行DMA操作。
  • ZONE_NOMAL:这个区包含的都是能正常映射的页(用于映射非DMA)
  • ZONE_HIGHEM:这个区包"高端内存",其中的页能不永久地映射到内核地址空间。


每个内存区都有一个对应的描述符号zone,zone结构被定义在/linux/mmzone.h中,接下来浏览一下该结构的一些域:

struct zone {spinlock_t lock;  //lock域是一个自旋锁,这个域只保护结构,而不是保护驻留在这个区中的所有页unsigned long free_pages;  //持有该内存区中所剩余的空闲页链表unsigned long pages_min, pages_low, pages_high;  //持有内存区的水位值unsigned long protection[MAX_NR_ZONES];spinlock_t lru_lock; //持有保护空闲页链表的自旋锁struct list_head active_list;  在页面回收处理时,处于活动状态的页链表struct list_head inactive_list;  //在页面回收处理时,是可以被回收的页链表unsigned long nr_scan_active;unsigned long nr_scan_inactive;unsigned long nr_active;unsigned long nr_inactive;int all_unreclaimable;   //内存的所有页锁住时,此值置1unsigned long pages_scanned; //用于页面回收处理中struct free_area free_area[MAX_ORDER];wait_queue_head_t * wait_table;unsigned long wait_table_size;unsigned long wait_table_bits;  //用于处理该内存区页上的进程等待struct per_cpu_pageset pageset[NR_CPUS];struct pglist_data *zone_pgdat;struct page *zone_mem_map;unsigned long zone_start_pfn;
char *name;unsigned long spanned_pages;unsigned long present_pages;};

内核提供了一种请求内层的底层机制,并提供了对它进行访问的几个接口。所有这些接口都是以页为单位进行操作的页面是物理内存存储页的基本单元,只要有进程申请内存,内核便会请求一个页面给它,同理,如果页面不再使用,那么内核将其释放,以便其他进程可以使用,下面介绍一下这些函数。


alloc_page() 用于请求单页,不需要描述请求内存大小的order参数
alloc_pages() 可以请求页面组

#define alloc_pages(gfp_mask,order)  alloc_pages_node(numa_node_id(),gfp_mask,order)#define alloc_page(gfp_mask)  alloc_pages_node(numa_node_id(),gfp_mask,0)

__get_free_page() 请求单页面操作的简化版本

include/linux/gfp.h#define __get_dma_pages(gfp_mask,order) \__get_free_pages((gfp_mask)|GFP_DMA,(order))

__get_dma_pages() 用于从ZONE_DMA区请求页面

include/linux/gfp.h#define __get_dma_pages(gfp_mask,order) \__get_free_pages((gfp_mask)|GFP_DMA,(order))

当你不再需要页时可以用下列函数释放它们,只是提醒:仅能释放属于你的页,否则可能导致系统崩溃。内核是完全信任自己的,如果有非法操作,内核会开心的把自己挂起来,停止运行。

extern void __free_pages(struct page *page, unsigned int order);extern void free_pages(unsigned long addr, unsigned int order);

上面提到都是以页为单位的分配方式,那么对于常用的以字节为单位的分配来说,内核通供的函数是kmalloc(),和mallloc很像吧,其实还真是这样,只不过多了一个flags参数。用它可以获得以字节为单位的一块内核内存。


kmalloc

kmalloc()函数与用户空间malloc一组函数类似,获得以字节为单位的一块内核内存。

void *kmalloc(size_t size, gfp_t flags)void kfree(const void *objp)


分配内存物理上连续。
gfp_t标志:表明分配内存的方式。如:
GFP_ATOMIC:分配内存优先级高,不会睡眠
GFP_KERNEL:常用的方式,可能会阻塞。
 
vmalloc  
 
void *vmalloc(unsigned long size)void vfree(const void *addr)

vmalloc()与kmalloc方式类似,vmalloc分配的内存虚拟地址是连续的,而物理地址则无需连续,与用户空间分配函数一致。

vmalloc通过分配非连续的物理内存块,在修正页表,把内存映射到逻辑地址空间的连续区域中,虚拟地址是连续的。 是否必须要连续的物理地址和具体使用场景有关。在不理解虚拟地址的硬件设备中,内存区都必须是连续的。通过建立页表转换成虚拟地址空间上连续,肯定存在一些消耗,带来性能上影响。所以通常内核使用kmalloc来申请内存,在需要大块内存时使用vmalloc来分配。
 
进程往往会以字节为单位请求小块内存,为了满足这种小内存的请求,内核特别实现了Slab分配器,Slab分配器使用三个主要结构维护对象信息,分别如下:

kmem_cache的缓存描述符
cache_sizes的通用缓存描述符
slab的slab描述符


在最高层是 cache_chain,这是一个 slab 缓存的链接列表。可以用来查找最适合所需要的分配大小的缓存。cache_chain 的每个元素都是一个 kmem_cache 结构的引用。一个kmem_cache中的所有object大小都相同。这里我们首先看看缓存描述符中各个域以及他们的含义。

struct kmem_cache_s{
struct kmen_list3 lists;  //lists域中包含三个链表头,每个链表头均对应了slab所处的三种状态(满,未满,空闲)之一,
unsigned int objsize;  //objsize域中持有缓存中对象的大小unsigned int flags;  //flags持有标志掩码,其描述了缓存固有特性unsigned int num;  //num域中持有缓存中每个slab所包含的对象数目
unsigned int gfporder;  //缓存中每个slab所占连续页面数的幂,该值默认0
size_t color;  
unsigned int color_off;unsigned int color_next;kmem_cache_t *slabp_cache;  //可存储在自身缓存中也可以存在外部其他缓存中unsigned int dflags;
void (*ctor) (void *,kmem_cache_t*,unsigened long);
void (*dtor)(void*,kmem_cache_t *,unsigend long);
const char *name;  //name持有易于理解的名称struct list_head next;  //next域指向下个单向缓存描述符链表上的缓存描述符
};

如我们所讲,作为通用目的的缓存大小都是被定义好的,且成对出现,一个为从DMA内存分配对象,另一个从普通内存中分配,结构cache_sizes包含了有关通用缓存大小的所有信息。代码解释如下:

struct cache_sizes{size_t cs_size;  //持有该缓存中容纳的内存对象大小kmem_cache_t *cs_cachep;  //持有指向普通内存缓存描述符飞指针kmem_cache_t *cs_dmacachep;  //持有指向DMA内存缓存描述符的指针,分配自ZONE_DMA};

最后介绍一下Slab状态和描述符域的值,如下表(N=slab中的对象数目,X=某一变量的正数)


Free
Partial
Full
Slab->inuse
0
X
N
Slab->free
0
X
N
 
 




现在我们再内核运行的整个生命周期范围内观察缓存和slab分配器第如何交互的,内核需要某些特殊结构以支持进程的内存请求和动态可加载模块来创建特定缓存,内核函数 kmem_cache_create 用来创建一个新缓存。这通常是在内核初始化时执行的,或者在首次加载内核模块时执行.

struct kmem_cache *kmem_cache_create (
  const char *name,  //定义了缓存名称
  size_t size,  //指定了为这个缓存创建的对象的大小
  size_t align,  //定义了每个对象必需的对齐。
  unsigned long flags,  //指定了为缓存启用的选项
  void (*ctor)(void *))  //定义了一个可选的对象构造器和析构器。构造器和析构器是用户提供的回调函数。当从缓存中分配新对象时,可以通过构造器进行初始化。

当缓存被创建之后,其中的slab都是空的,事实上slab在请求对象前都不会分配,当我们在创建slab时,不仅仅分配和初始化其描述符,而且还需要和伙伴系统交互请求页面。从一个命名的缓存中分配一个对象,可以使用 kmem_cache_alloc 函数,这个函数从缓存中返回一个对象。注意如果缓存目前为空,那么这个函数就会调用 cache_alloc_refill 向缓存中增加内存。

void kmem_cache_alloc( struct kmem_cache *cachep, gfp_t flags );//cachep是需要扩充的缓存描述符//flags这些标志将用于创建slab

缓存和slab都可被销毁,其步骤与创建相逆,但是对齐问题在销毁缓存时候不需要关心,只需要删除缓存描述符和释放内存即可,其步骤有三如下:

  • 从缓存链表中删除缓存
  • 删除slab描述符
  • 删除缓存描述符


mm/slab.c
int kmem_cache_destroy(kmem_cache_t *cachep){int i;
if(!cache || in_interrupt()) BUG();  //完成健全性检查
down(&cache_chain_sem);
list_del(&cachep->next); up(&cache_chain_sem);  //获得cache_chain信号量从缓存中删除指定缓存,释放cache_chain信号量
if(_cache_shrink(cachep)){ slab_error(cachep,"Can't free all objects"); down(&cache_chain_sem); list_add(&cache->next,&cache_chain); up(&cache_chain_sem);return 1;    //该段负责释放为使用slab } ... kmem_cache_free(&cache_cache,cachep);  //释放缓存描述符
return 0;}

目前为止,我们讨论完了slab分配器,那么实际的内存请求是怎么样的呢,slab分配器是如何被调用的呢?这里我粗略讲解一下。

当内核必须获得字节大小的内存块时,就需要使用函数kmalloc(),它实际上会调用函数kmem_getpages完成实际分配,调用路径如下:

kmalloc()->__cache_alloc()->kmem_cache_grow()->kmem_getpages().kmalloc和get_free_page申请的内存位于物理内存映射区域,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因此存在较简单的转换关系,virt_to_phys()可以实现内核虚拟地址转化为物理地址:

#define __pa(x) ((unsigned long)(x)-PAGE_OFFSET)extern inline unsigned long virt_to_phys(volatile void * address){return __pa(address);}

那么内核是如何管理它们使用内存的呢,用户进程一旦创建便要分配一个虚拟地址空间,其地址范围可以通过增加或者删除线性地址间隔得以扩大或者缩减,在内核中进程地址空间的所有信息都被保存在mm_struct结构中,mm_struct和vm_area_struct结构之间的关系如下图:



struct mm_struct {
  struct vm_area_struct * mmap; /* 指向虚拟区间(VMA)链表 */
  rb_root_t mm_rb; /*指向red_black树*/
  struct vm_area_struct * mmap_cache; /* 指向最近找到的虚拟区间*/
  pgd_t * pgd; /*指向进程的页目录*/ 
  atomic_t mm_users; /* 用户空间中的有多少用户*/
  atomic_t mm_count; /* 对"struct mm_struct"有多少引用*/
  int map_count; /* 虚拟区间的个数*/
  struct rw_semaphore mmap_sem;
  spinlock_t page_table_lock; /* 保护任务页表和 mm->rss */
  struct list_head mmlist; /*所有活动(active)mm的链表 */
  unsigned long start_code, end_code, start_data, end_data; /*start_code 代码段起始地址,end_code 代码段结束地址,start_data 数据段起始地址, start_end 数据段结束地址*/
  unsigned long start_brk, brk, start_stack; /*start_brk 和brk记录有关堆的信息, start_brk是用户虚拟地址空间初始化时,堆的结束地址, brk 是当前堆的结束地址, start_stack 是栈的起始地址*/
  unsigned long arg_start, arg_end, env_start, env_end; /*arg_start 参数段的起始地址, arg_end 参数段的结束地址, env_start 环境段的起始地址, env_end 环境段的结束地址*/
  unsigned long rss, total_vm, locked_vm;
  unsigned long def_flags;
  unsigned long cpu_vm_mask;
  unsigned long swap_address;....};

最后简单讲一下进程映象分布于线性地址空间的相关重点,当用户程序被载入内存之后,便被赋予 了自己的线性空间,并且被映射到进程地址空间,下面需要注意:


永久映射:可能会阻塞映射一个给定的page结构到内核地址空间:
void *kmap(struct page *page)

解除映射:
void kunmap(struct page *page)

临时映射:不会阻塞     
void *kmap_atomic(struct page *page)

小结:

这次讲了内存管理的大部分内容,介绍了页是如何在内核中被跟踪,然后讨论了内存区,之后讨论了小于一页的小块内存分配,即slab分配器管理。在内核管理结构和众多代码分析完了之后,继续讨论了用户空间进程管理特殊方式,最后简单介绍了进程映象分布于线性地址空间的相关重点。里面肯定有些内容比较散乱,代码有补全的状况,希望大家能够多家批评改正,一起讨论,共勉。


向内核添加代码


先来熟悉一下文件系统,通过/dev可以访问Linux的设备,我们以men设备驱动程序为例来看看随机数是如何产生的,源代码在dirvers/char/mem.c上可以查看


static int memory_open(struct inode * inode * inode,struct file * filp){switch (iminor(inode)) {  //switch语句根据从设备号来初始化驱动程序的数据结构case 1: ...case 8: filp->f_op = &random_fops;break;case 9: filp->f_op = &urandom_fops;break;
  
那么上述程序的filps和fop是什么呢?实际上filp只是一个文件结构指针,而fop是一个file_operations结构指针,内核通过file_operations结构来确定操作文件时要调用的函数,下面的file_operations结构用于随机设备驱动的部分内容,代码在include/linux/fs.h上可以查看到:


struct file {struct list_head f_list;struct dentry *f_dentry;struct vfsmount *f_vfsmnt;struct file_operations *f_op;atomic_t f_count;unsigned int f_flags;...struct address_space *f_mapping;};
  
驱动程序所实现的函数必须符合file_operations结构中所列出的函数原型,代码在dirvers/char/random.c上可以查看:


struct file_operations random_fops = { .read = random_read, .write = random_write, .poll = random_poll,  //poll操作允许某种操作之前查看该操作是否阻塞 .ioctl = random_ioctl,};  //随机设备提供的操作有以上
struct file_operations urandom_fops = { .read = random_read, .write = random_write, .ioctl = random_ioctl,}; //urandom设备提供的操作有以上
  
如果设备驱动程序在内核空间运行,但是缓冲区却位于用户空间,那我们该如何才能安全访问buf中的数据呢,下面来说下数据在用户空间和内核空间之间的奥秘,Linux提供的copy_to_user()和copy_from_user()使得驱动程序可以在内核空间和用户空间上传递数据,在read_random()中,通过extract_entropy()函数来实现这个功能,下面代码在dirvers/char/random.c上可以查看


static ssize_t extract_entropy(struct entract_syore *r,void *buf,size_t nbytes,int flags){...{static ssize_t extract_entropy(struct entropy_store *r,void *buf,size_t nbytes,int flags){
‍ 
内核空间和用户空间的程序可能都需要使用已经获得的随机数,内核空间的程序可以通过不设置标志位来避免函数copyto_user()带来的额外开销。除了通过设备驱动程序向内核添加代码之外,还有别的方式 的,用户空间可以通过系统调用来访问内核服务程序和系统硬件,这里不多阐释,都知道有这回事就行了。


下面我们来介绍怎么去编写源代码,当我们去编写一个复杂的设备驱动程序时,也许要输出驱动程序中定义的某些符合,以便让内核其它模块使用,这些通常被用在低级的驱动程序中,以便根据这些基本的函数来构建更高级的驱动程序,在Linux2.6内核中,code monkey可以用如下两个宏输出符号,代码在include/linux/module.h中查看:


#define EXPORT_SYMBOL(sym) __EXPORT_SYMBOL(sym, "")#define EXPORT_SYMBOL_GPL(sym) __EXPORT_SYMBOL(sym, "_gpl")
  
目前为止,我们介绍的设备 驱动程序都是主动操作,或者对设备的数据进行读写操作,那么它的功能不止这些的时候会怎么样呢?在Linux中,设备驱动程序解决这些问题的典型方式就是使用ioctl。ioctl是设备驱动程序中对设备的I/O通道进行管理的函数。所谓对I/O通道进行管理,就是对设备的一些特性进行控制,例如串口的传输波特率、马达的转速等等。
调用个数如下:


int ioctl(int fd, ind cmd, …);

其中fd就是用户程序打开设备时使用open函数返回的文件标示符,cmd就是用户程序对设备的控制命令,至于后面的省略号,那是一些补充参数,一般最多一个,有或没有是和cmd的意义相关的。ioctl函数是文件结构中的一个属性分量,就是说如果你的驱动程序提供了对ioctl的支持,用户就可以在用户程序中使用ioctl函数控制设备的I/O通道。


ioctl命令号:
dir:

代表数据传输的方向,占2位,可以是_IOC_NONE(无数据传输,0U),_IOC_WRITE(向设备写数据,1U)或_IOC_READ(从设备读数据,2U)或他们的逻辑或组合,当然只有_IOC_WRITE和_IOC_READ的逻辑或才有意义。


type:
    描述了ioctl命令的类型,8位。每种设备或系统都可以指定自己的一个类型号,ioctl用这个类型来表示ioctl命令所属的设备或驱动。一般用ASCII码字符来表示,如 'a'。


nr:

ioctl命令序号,一般8位。对于一个指定的设备驱动,可以对它的ioctl命令做一个顺序编码,一般从零开始,这个编码就是ioctl命令的序号。


size:

ioctl命令的参数大小,一般14位。ioctl命令号的这个数据成员不是强制使用的,你可以不使用它,但是我们建议你指定这个数据成员,通过它我们可以检查用户空间数据的大小以避免错误的数据操作,也可以实现兼容旧版本的ioctl命令。


ioctl返回值:

ioctl函数的返回值是一个整数类型的值,如果命令执行成功,ioctl返回零,如果出现错误,ioctl函数应该返回一个负值。这个负值会作为errno值反馈给调用此ioctl的用户空间程序。关于返回值的具体含义,请参考<linux/errno.h>和<asm/errno.h>头文件。
ioctl参数:


首先要说明这个参数是有用户空间的程序传递过来的,因此这个指针指向的地址是用户空间地址,在Linux中,用户空间地址是一个虚拟地址,在内核空间是无法直接使用它的。为了解决在内核空间使用用户空间地址的数据,Linux内核提供了以下函数,它们用于在内核空间访问用户空间的数据,定义在<asm/uaccess.h>头文件中:


unsigned long __must_check copy_to_user(void __user *to,const void *from, unsigned long n);unsigned long __must_check copy_from_user(void *to,const void __user *from, unsigned long n);

copy_from_user和copy_to_user一般用于复杂的或大数据交换,对于简单的数据类型,如int或char,内核提供了简单的宏来实现这个功能:

#define get_user(x,ptr)#define put_user(x,ptr)//x是内核空间的简单数据类型地址,ptr是用户空间地址指针。


cmd参数如何得出:

一个cmd参数被分为4段,每段都有其特殊的含义,cmd参数在用户程序端由一些宏根据设备类型、序列号、传送方向、数据尺寸等生成,这个整数通过系统调用传递到内核中的驱动程序,再由驱动程序使用解码宏从这个整数中得到设备的类型、序列号、传送方向、数据尺寸等信息,然后通过switch{case}结构进行相应的操作。解释一下四部分,全部都在<asm-generic/ioctl.h>和ioctl-number.txt这两个文档有说明的 。


1)幻数:说得再好听的名字也只不过是个0~0xff的数,占8bit(_IOC_TYPEBITS)。这个数是用来区分不同的驱动的,像设备号申请的时候一样,内核有一个文档给出一些推荐的或者已经被使用的幻数


2)序数:用这个数来给自己的命令编号,占8bit(_IOC_NRBITS),我的程序从1开始排序。


3)数据传输方向:占2bit(_IOC_DIRBITS)。如果涉及到要传参,内核要求描述一下传输的方向,传输的方向是以应用层的角度来描述的。
  • _IOC_NONE:值为0,无数据传输。
  • _IOC_READ:值为1,从设备驱动读取数据。
  • _IOC_WRITE:值为2,往设备驱动写入数据。
  • _IOC_READ|_IOC_WRITE:双向数据传输。


4)数据大小:与体系结构相关,ARM下占14bit(_IOC_SIZEBITS),如果数据是int,内核给这个赋的值就是sizeof(int)。


 ioctl如何实现:

在驱动程序中实现的ioctl函数体内,实际上是有一个switch{case}结构,每一个case对应一个命令码,做出一些相应的操作。怎么实现这些操作,这是每一个程序员自己的事情,因为设备都是特定的,这里也没法说,关键在于怎么样组织命令码,因为在ioctl中命令码是唯一联系用户程序命令和驱动程序支持的途径。


命令码的组织是有一些讲究的,因为我们一定要做到命令和设备是一一对应的,这样才不会将正确的命令发给错误的设备,或者是把错误的命令发给正确的设备,或者是把错误的命令发给错误的设备。这些错误都会导致不可预料的事情发生,而当程序员发现了这些奇怪的事情的时候,再来调试程序查找错误,那将是非常困难的事情。

所以在Linux核心中是这样定义一个命令码的:

| 设备类型 | 序列号 | 方向 |数据尺寸|
|----------|---------|------|---------|
| 8 bit      |  8 bit    |2 bit |8~14 bit|
|----------|---------|------|---------|
  
这样一来,一个命令就变成了一个整数形式的命令码。但是命令码非常的不直观,所以Linux Kernel中提供了一些宏,这些宏可根据便于理解的字符串生成命令码,或者是从命令码得到一些用户可以理解的字符串以标明这个命令对应的设备类型、设备序列号、数据传送方向和数据传输尺寸。
  
在内核中是无法直接访问用户空间地址数据的。因此凡是从用户空间传递过来的指针数据,务必使用内核提供的函数来访问它们。这里有必要再一次强调的是,在内核模块或驱动程序的编写中,我们强烈建议你使用内核提供的接口来生成并操作ioctl命令号,这样可以对命令号赋予特定的含义,使我们的程序更加的健壮;另一方面也可以提高程序的可移植性。
  
最后我们来介绍一下添加代码后的编译和调试,在内核中添加代码后就需要不断运行,修复错误,我们知道当对/proc文件系统进行读写操作时,它的每一个结点都链接到一个内核函数,在Linux2.6内核中,要想你的设备能够被访问,首先就要在/proc文件系统中创建一个入口,这个可以通过creat_proc_read_entry()来实现,代码在include/linux/proc_fs.h上查看:


static inline struct proc_dir_entry *create_proc_read_entry(const char *name, mode_t mode,struct proc_dir_entry *base, read_proc_t *read_proc,void * data)

*name是结点在/proc文件系统的入口,*base指向设置proc文件的目标路径,如果它的值为NULL,表示该文件就在/proc目录下,读取该文件可以调用*read_proc指向的函数。这里也不多加阐释了,整个也是很简单的过程。


小结

今天的重点是iotcl函数了,其中还有很多向内核中添加代码的细节没有讲到,主要是这些都涉及到过多的操作,需要大家多看源代码并且多动手在Linux上操作才能完全掌握,,今天写的一些也借鉴了一些大牛的文章,总之 收获很多,最后几天了,真的是很开心啦,和大家一起分享真的很快乐的~~



声明,作者cnblog:
http://www.cnblogs.com/lihuidashen/p/4236635.html
http://www.cnblogs.com/lihuidashen/p/4239672.html
http://www.cnblogs.com/lihuidashen/p/4242645.html

来源:技术让梦想更伟大

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存