免费注册 查看新帖 |

Chinaunix

  平台 论坛 博客 文库
123下一页
最近访问板块 发新帖
查看: 15849 | 回复: 27
打印 上一主题 下一主题

Linux系统下init进程的前世今生 [复制链接]

论坛徽章:
0
跳转到指定楼层
1 [收藏(0)] [报告]
发表于 2012-03-15 12:49 |只看该作者 |倒序浏览

Linux系统中的init进程(pid=1)是除了idle进程(pid=0,也就是init_task)之外另一个比较特殊的进程,它是Linux内核开始建立起进程概念时第一个通过kernel_thread产生的进程,其开始在内核态执行,然后通过一个系统调用,开始执行用户空间的/sbin/init程序,期间Linux内核也经历了从内核态到用户态的特权级转变,/sbin/init极有可能产生出了shell,然后所有的用户进程都有该进程派生出来(目前尚未阅读过/sbin/init的源码)...

目前我们至少知道在内核空间执行用户空间的一段应用程序有两种方法:
1. call_usermodehelper
2. kernel_execve

它们最终都通过int $0x80在内核空间发起一个系统调用来完成,这个过程我在《深入Linux设备驱动程序内核机制》第9章有过详细的描述,对它的讨论最终结束在 sys_execve函数那里,后者被用来执行一个新的程序。现在一个有趣的问题是,在内核空间发起的系统调用,最终通过sys_execve来执行用户 空间的一个程序,比如/sbin/myhotplug,那么该应用程序执行时是在内核态呢还是用户态呢?直觉上肯定是用户态,不过因为cpu在执行 sys_execve时cs寄存器还是__KERNEL_CS,如果前面我们的猜测是真的话,必然会有个cs寄存器的值从__KERNEL_CS到 __USER_CS的转变过程,这个过程是如何发生的呢?下面我以kernel_execve为例,来具体讨论一下其间所发生的一些有趣的事情。

start_kernel在其最后一个函数rest_init的调用中,会通过kernel_thread来生成一个内核进程,后者则会在新进程环境下调 用kernel_init函数,kernel_init一个让人感兴趣的地方在于它会调用run_init_process来执行根文件系统下的 /sbin/init等程序:

<init/mian.c>
  1. static noinline int init_post(void)
  2.     {
  3.             ...
  4.             run_init_process("/sbin/init");
  5.             run_init_process("/etc/init");
  6.             run_init_process("/bin/init");
  7.             run_init_process("/bin/sh");
  8.             panic("No init found. Try passing init= option to kernel. "
  9.                   "See Linux Documentation/init.txt for guidance.");
  10.     }
复制代码
run_init_process的核心调用就是kernel_execve,后者的实现代码是:
<arch/x86/kernel/sys_i386_32.c>
  1. int kernel_execve(const char *filename,
  2.                       const char *const argv[],
  3.                       const char *const envp[])
  4.     {
  5.             long __res;
  6.             asm volatile ("int $0x80"
  7.             : "=a" (__res)
  8.             : "0" (__NR_execve), "b" (filename), "c" (argv), "d" (envp) : "memory");
  9.             return __res;
  10.     }
复制代码
里面是段内嵌的汇编代码,代码相对比较简单,核心代码是int $0x80,执行系统调用,系统调用号__NR_execve放在AX里,当然系统调用的返回值也是在AX中,要执行的用户空间应用程序路径名称保存在 BX中。int $0x80的执行导致代码向__KERNEL_CS:system_call转移(具体过程可参考x86处理器中的特权级检查及Linux系统调用的实现一帖). 此处用bx,cx以及dx来保存filename, argv以及envp参数是有讲究的,它对应着struct pt_regs中寄存器在栈中的布局,因为接下来就会涉及从汇编到调用C函数过程,所以汇编程序在调用C之前,应该把要传递给C的参数在栈中准备好。

system_call是一段纯汇编代码:
  1. <arch/x86/kernel/entry_32.s>
  2.     ENTRY(system_call)
  3.             RING0_INT_FRAME # can't unwind into user space anyway
  4.             pushl_cfi %eax # save orig_eax
  5.             SAVE_ALL
  6.             GET_THREAD_INFO(%ebp)
  7.                                             # system call tracing in operation / emulation
  8.             testl $_TIF_WORK_SYSCALL_ENTRY,TI_flags(%ebp)
  9.             jnz syscall_trace_entry
  10.             cmpl $(nr_syscalls), %eax
  11.             jae syscall_badsys
  12.     syscall_call:
  13.             call *sys_call_table(,%eax,4)
  14.             movl %eax,PT_EAX(%esp) # store the return value
  15.     syscall_exit:
  16.             ...
  17.     restore_nocheck:
  18.             RESTORE_REGS 4 # skip orig_eax/error_code
  19.     irq_return:
  20.             INTERRUPT_RETURN #iret instruction for x86_32
复制代码
system_call首先会为后续的C函数的调用在当前堆栈中建立参数传递的环境(x86_64的实现要相对复杂一点,它会将系统调用切换到内核栈 movq PER_CPU_VAR(kernel_stack),%rsp),尤其是接下来对C函数sys_execve调用中的struct pt_regs *regs参数,我在上面代码中同时列出了系统调用之后的后续操作syscall_exit,从代码中可以看到系统调用int $0x80最终通过iret指令返回,而后者会从当前栈中弹出cs与ip,然后跳转到cs:ip处执行代码。正常情况下,x86架构上的int n指 令会将其下条指令的cs:ip压入堆栈,所以当通过iret指令返回时,原来的代码将从int n的下条指令继续执行,不过如果我们能在后续的C代码中改变regs->cs与regs->ip(也就是int n执行时压入栈中的cs与ip),那么就可以控制下一步代码执行的走向,而 sys_execve函数的调用链正好利用了这一点,接下来我们很快就会看到。SAVE_ALL宏的最后为将ds, es, fs都设置为__USER_DS,但是此时cs还是__KERNEL_CS.

核心的调用发生在call *sys_call_table(,%eax,4)这条指令上,sys_call_table是个系统调用表,本质上就是一个函数指针数组,我们这里的系 统调用号是__NR_execve=11, 所以在sys_call_table中对应的函数为:

<arch/x86/kernel/syscall_table_32.s>
  1. ENTRY(sys_call_table)
  2.             .long sys_restart_syscall /* 0 - old "setup()" system call, used for restarting */
  3.             .long sys_exit
  4.             .long ptregs_fork
  5.             .long sys_read
  6.             .long sys_write
  7.             .long sys_open /* 5 */
  8.             .long sys_close
  9.             ...
  10.             .long sys_unlink /* 10 */
  11.             .long ptregs_execve //__NR_execve
  12.             ...
复制代码
ptregs_execve其实就是sys_execve函数:
<arch/x86/um/sys_call_table_32.s>
  1. #define ptregs_execve sys_execve
复制代码
而sys_execve函数的代码实现则是:
<arch/x86/kernel/process.c>
  1. /*
  2.      * sys_execve() executes a new program.
  3.      */
  4.     long sys_execve(const char __user *name,
  5.                     const char __user *const __user *argv,
  6.                     const char __user *const __user *envp, struct pt_regs *regs)
  7.     {
  8.             long error;
  9.             char *filename;
  10.             filename = getname(name);
  11.             error = PTR_ERR(filename);
  12.             if (IS_ERR(filename))
  13.                     return error;
  14.             error = do_execve(filename, argv, envp, regs);
  15.     #ifdef CONFIG_X86_32
  16.             if (error == 0) {
  17.                     /* Make sure we don't return using sysenter.. */
  18.                     set_thread_flag(TIF_IRET);
  19.             }
  20.     #endif
  21.             putname(filename);
  22.             return error;
  23.     }
复制代码
注意这里的参数传递机制!其中的核心调用是do_execve,后者调用do_execve_common来干执行一个新程序的活,在我们这个例子中要执 行的新程序来自/sbin/init,如果用file命令看一下会发现它其实是个ELF格式的动态链接库,而不是那种普通的可执行文件,所以 do_execve_common会负责打开、解析这个文件并找到其可执行入口点,这个过程相当繁琐,我们不妨直接看那些跟我们问题密切相关的代 码,do_execve_common会调用search_binary_handler去查找所谓的binary formats handler,ELF显然是最常见的一种格式:

<fs/exec.c>
  1. int search_binary_handler(struct linux_binprm *bprm,struct pt_regs *regs)
  2.     {
  3.            ...
  4.            for (try=0; try<2; try++) {
  5.                     read_lock(&binfmt_lock);
  6.                     list_for_each_entry(fmt, &formats, lh) {
  7.                             int (*fn)(struct linux_binprm *, struct pt_regs *) = fmt->load_binary;
  8.                             ...
  9.                             retval = fn(bprm, regs);
  10.                             ...
  11.                    }
  12.                    ...
  13.            }
  14.     }
复制代码
代码中针对ELF格式的 fmt->load_binary即为load_elf_binary, 所以fn=load_elf_binary, 后续对fn的调用即是调用load_elf_binary,这是个非常长的函数,直到其最后,我们才找到所需要的答案:

<fs/binfmt_elf.c>
  1. static int load_elf_binary(struct linux_binprm *bprm, struct pt_regs *regs)
  2.     {
  3.             ...
  4.             start_thread(regs, elf_entry, bprm->p);
  5.             ...
  6.     }
复制代码
上述代码中的elf_entry即为/sbin/init中的执行入口点, bprm->p为应用程序新栈(应该已经在用户空间了),start_thread的实现为:

<arch/x86/kernel/process_32.c>
  1. void
  2.     start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
  3.     {
  4.             set_user_gs(regs, 0);
  5.             regs->fs = 0;
  6.             regs->ds = __USER_DS;
  7.             regs->es = __USER_DS;
  8.             regs->ss = __USER_DS;
  9.             regs->cs = __USER_CS;
  10.             regs->ip = new_ip;
  11.             regs->sp = new_sp;
  12.             /*
  13.              * Free the old FP and other extended state
  14.              */
  15.             free_thread_xstate(current);
  16.     }
复制代码
在这里,我们看到了__USER_CS的身影,在x86 64位系统架构下,该值为0x33. start_thread函数最关键的地方在于修改了regs->cs= __USER_CS, regs->ip= new_ip,其实就是人为地改变了系统调用int $0x80指令压入堆栈的下条指令的地址,这样当系统调用结束通过iret指令返回时,代码将从这里的__USER_CS:elf_entry处开始执 行,也就是/sbin/init中的入口点。start_thread的代码与kernel_thread非常神似,不过它不需要象 kernel_thread那样在最后调用do_fork来产生一个task_struct实例出来了,因为目前只需要在当前进程上下文中执行代码,而不是创建一个新进程。关于kernel_thread,我在本版曾有一篇帖子分析过,当时基于的是ARM架构。

所以我们看到,start_kernel在最后调用rest_init,而后者通过对kernel_thread的调用产生一个新进程(pid=1),新进程在其kernel_init()-->init_post()调用链中将通过run_init_process来执行用户空间的/sbin /init,run_init_process的核心是个系统调用,当系统调用返回时代码将从/sbin/init的入口点处开始执行,所以虽然我们知道 post_init中有如下几个run_init_process的调用:
  1. run_init_process("/sbin/init");
  2.     run_init_process("/etc/init");
  3.     run_init_process("/bin/init");
  4.     run_init_process("/bin/sh");
复制代码
但是只要比如/sbin/init被成功调用,run_init_process中的kernel_execve函数将无法返回,因为它执行int $0x80时压入堆栈中回家的路径被后续的C函数调用链给改写了,这样4个run_init_process只会有一个有机会被成功执行,如果这4个函数都失败 了,那么内核将会panic. 所以内核设计时必须确保用来改写int $0x80压入栈中的cs和ip的start_thread函数之后不会再有其他额外的代码导致整个调用链的失败,否则代码将执行非预期的指令,内核进入不稳定状态。

最后,我们来验证一下,所谓眼见为实,耳听为虚。再者,如果验证达到预期,也是很鼓舞人好奇心的极佳方法。验证的方法我打算采用“Linux设备驱动模型中的热插拔机制及实验” 中的路线,通过call_usermodehelper来做,因为它和kernel_execve本质上都是一样的。我们自己写个应用程序,在这个应用程序里读取cs寄存器的值,程序很简单:

<main.c>
  1. #include <stdio.h>
  2.     #include <fcntl.h>
  3.     #include <unistd.h>
  4.     #include <syslog.h>
  5.     int main()
  6.     {
  7.         unsigned short ucs;
  8.         asm(
  9.             "movw %%cs, %0\n"
  10.             :"=r"(ucs)
  11.             ::"memory");
  12.         syslog(LOG_INFO, "ucs = 0x%x\n", ucs);
  13.         return 0;
  14.     }
复制代码
然后把这个程序打到/sys/kernel/uevent_help上面(参照Linux设备驱动模型中的热插拔机制及实验一文),之后我们往电脑里插个U盘,然后到/var/log/syslog文件里看输出(在某些distribution上,syslog的输出可能会到/var/log/messages中):

Mar 10 14:20:23 build-server main: ucs = 0x33

0x33正好就是x86 64位系统(我实验用的环境)下的__USER_CS.

所以第一个内核进程(pid=1)通过执行用户空间程序,期间通过cs的转变(从__KERNEL_CS到__USER_CS)来达到特权级的更替。

评分

参与人数 1可用积分 +8 收起 理由
Godbach + 8 赞一个!

查看全部评分

论坛徽章:
2
CU十二周年纪念徽章
日期:2013-10-24 15:41:34处女座
日期:2013-12-27 22:22:41
2 [报告]
发表于 2012-03-15 13:29 |只看该作者
其实系统启动时,首先是从initramfs或者initrd作为文件系统,根据具体情况,会先启rdinit或init,而这个两个东西往往是脚本。不过对内核来说,是脚本还是ELF文件没什么区别。

这里态势转换或者说执行另一个程序的关键是在内核态伪或者说操纵造用户态环境,理解这个就可以理解为什么fork在父子进程会返回不同的值了。当然反过说,理解fork的手法,用这个手段做其它类似的事情都是很好理解的。我记得Linux 0.01 里第一次到用户态的方法更加赤裸。

论坛徽章:
0
3 [报告]
发表于 2012-03-15 14:26 |只看该作者
其实系统启动时,首先是从initramfs或者initrd作为文件系统,根据具体情况,会先启rdinit或init,而这个两个东西往往是脚本。不过对内核来说,是脚本还是ELF文件没什么区别。

这里态势转换或者说执行另一个程序的关键是在内核态伪或者说操纵造用户态环境,理解这个就可以理解为什么fork在父子进程会返回不同的值了。当然反过说,理解fork的手法,用这个手段做其它类似的事情都是很好理解的。我记得Linux 0.01 里第一次到用户态的方法更加赤裸。

==============================
恰空有时间可以谈谈initramfs的事情, 也就是initramfs和我前面写到的/sbin/init的关系。。。。

论坛徽章:
0
4 [报告]
发表于 2012-03-15 14:49 |只看该作者
有次在centos上,把它initrd解出来看看,还直接执行了里面的init,然后整个系统就坏了,起不起来了

论坛徽章:
2
CU十二周年纪念徽章
日期:2013-10-24 15:41:34处女座
日期:2013-12-27 22:22:41
5 [报告]
发表于 2012-03-15 15:28 |只看该作者
回复 3# MagicBoy2010

其实只是在前面拦了一步,大抵的步骤好像是:

1. 内核里总有一个initramfs。你指定源文件夹,它就把源文件夹的东西编进去。没有指定,则编译几个特殊的文件(夹)进去。
2. 启动时把initramfs解压,这个作为初始的文件系统。如果根目录有init(或者cmdline上指定了rdinit),就去指行它。第二阶段挂什么根文件系统也不管了,后面的事与内核完全无关。
3. 如果从上面滑下来,就走常规流程,找真正的根文件系统,挂载,找init,就像这个帖子里引用的代码那样。

其实就是传统的initrd,后来升级为initramfs,但现在同时被支持。这个东西还真挺有用。参加过的第一个项目里,双核CPU同时跑两个不同的操作系统,两个OS对NAND操作时要上锁,有一段时间,这部分代码出现BUG,总是死锁。后来我怒了,把Linux这边对MTD的支持整个卸掉,然后把一个小的文件系统编进内核,跑到initramfs里的init时控制权就完全到用户态了,避免去挂载真正的根文件系统。

现在的操作系统,印象中ubuntu是在initrd里放一个init,那个init就是脚本。里面可能是加载一些驱动,挂载真正根文件系统,然后exec那个文件系统里的某个init。

另外,曾经因混淆initrd和rdinit这两cmdline参数的作用而调试半天…………


   

论坛徽章:
0
6 [报告]
发表于 2012-03-15 15:54 |只看该作者
其实只是在前面拦了一步,大抵的步骤好像是:

1. 内核里总有一个initramfs。你指定源文件夹,它就把源文件夹的东西编进去。没有指定,则编译几个特殊的文件(夹)进去。
2. 启动时把initramfs解压,这个作为初始的文件系统。如果根目录有init(或者cmdline上指定了rdinit),就去指行它。第二阶段挂什么根文件系统也不管了,后面的事与内核完全无关。
3. 如果从上面滑下来,就走常规流程,找真正的根文件系统,挂载,找init,就像这个帖子里引用的代码那样。

其实就是传统的initrd,后来升级为initramfs,但现在同时被支持。这个东西还真挺有用。参加过的第一个项目里,双核CPU同时跑两个不同的操作系统,两个OS对NAND操作时要上锁,有一段时间,这部分代码出现BUG,总是死锁。后来我怒了,把Linux这边对MTD的支持整个卸掉,然后把一个小的文件系统编进内核,跑到initramfs里的init时控制权就完全到用户态了,避免去挂载真正的根文件系统。

现在的操作系统,印象中ubuntu是在initrd里放一个init,那个init就是脚本。里面可能是加载一些驱动,挂载真正根文件系统,然后exec那个文件系统里的某个init。

另外,曾经因混淆initrd和rdinit这两cmdline参数的作用而调试半天…………

=================================================
没仔细看过initramfs这个东西,我知道内核编完后有个比如/boot/initrd.img-3.1.7,这个跟initramfs是什么关系?你的意思是内核在post_init里执行的/sbin/init这个东西其实是在initramfs文件系统下的,/sbin/init执行时负责挂载真正的文件根文件系统,然后再在新挂载的这个最终的文件系统下运行/sbin/init吗?”从上面滑下来“是skip过去,还是从里面执行出来再进入到随后的常规流程? 咱现在讨论的是普通PC x86平台的

论坛徽章:
2
CU十二周年纪念徽章
日期:2013-10-24 15:41:34处女座
日期:2013-12-27 22:22:41
7 [报告]
发表于 2012-03-15 17:12 |只看该作者
回复 6# MagicBoy2010

那个就是initrd,原始initrd和initramfs底层机制不同。

我的理解是这样的,原始initrd的情况下,内核将initrd作为虚拟块设备,挂载起来,然后将控制权将给上面的某个init。这个过程看起来与挂载真实根文件系统无异,因此要涉及到Block层。而initramfs则是基于ramfs。

普通文件读写,最终都是通过底层文件系统代码把数据带到page cache,然后VFS的代码读写page cache。而ramfs则直接建立在page cache上,不用与block层交互。

这样带来的改变是:原来的叫作initrd的东西是某种文件系统镜像,现在的叫作initramfs的东西是一个CPIO包;原来要涉及到某个真实存在物理文件系统驱动与基于内存的块设备驱动,现在只要基于page cache的ramfs文件系统驱动;原来的initrd要在在命令行参数中指定,现在的initramfs可以编译进内核,也可以在命令行参数中指定,但用的是"initrd=",和以前一样!!

现在的启动流程变为:一开始的时候根文件系统就有了(空的ramfs),把initramfs(编进内核的或用initrd指定的)解压到根文件系统,如果其中有/init(或rdinit指定的程序),则把控制权交给它,不然("滑下来",文件不存在或者运行失败)尝试挂载物理根文件系统,然后按主楼所引用的代码执行某个init。

具体到代码,在kernel_init,如果发现initramfs里有可运行的init,则ramdisk_execute_command会有值,否着它为NULL。在init_post里,如果ramdisk_execute_command有值,则直接执行它了。滑下来的话才会去执行指定的init,再滑下来才会run_init_process("/sbin/init");      run_init_process("/etc/init");     run_init_process("/bin/init");   run_init_process("/bin/sh");

论坛徽章:
0
8 [报告]
发表于 2012-03-15 17:30 |只看该作者
tempname2 发表于 2012-03-15 17:12
回复 6# MagicBoy2010

那个就是initrd,原始initrd和initramfs底层机制不同。


挺好,回头有时间好好看看这块的东西,让内核启动时挂载我U盘中的文件系统,这样貌似有机会可以直接围观内核panic一下,呵呵,还有,可以在那个start_thread改动一下,看看会发生啥情况,猜一下看看系统挂掉还是init最终成了ring 0...
  1. void
  2. start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
  3. {
  4.         set_user_gs(regs, 0);
  5.         regs->fs                = 0;
  6.         regs->ds                = __USER_DS;
  7.         regs->es                = __USER_DS;
  8.         regs->ss                = __USER_DS;
  9.         if(current->pid != 1)
  10.                  regs->cs                = __USER_CS;
  11.         regs->ip                = new_ip;
  12.         regs->sp                = new_sp;
  13.         /*
  14.          * Free the old FP and other extended state
  15.          */
  16.         free_thread_xstate(current);
  17. }
复制代码

论坛徽章:
0
9 [报告]
发表于 2012-03-16 11:20 |只看该作者
没有同学去做实验啊,看来得亲自动手了....

估计得挂吧

论坛徽章:
0
10 [报告]
发表于 2012-03-17 00:53 |只看该作者
点灯熬油到现在,终于把8楼的帖子中修改的代码给验证完了。。。。。。。。。。。。。结果太tmd的出人意料了,太困了,先睡了,明天还要早起买小米手机呢
您需要登录后才可以回帖 登录 | 注册

本版积分规则 发表回复

  

北京盛拓优讯信息技术有限公司. 版权所有 京ICP备16024965号-6 北京市公安局海淀分局网监中心备案编号:11010802020122 niuxiaotong@pcpop.com 17352615567
未成年举报专区
中国互联网协会会员  联系我们:huangweiwei@itpub.net
感谢所有关心和支持过ChinaUnix的朋友们 转载本站内容请注明原作者名及出处

清除 Cookies - ChinaUnix - Archiver - WAP - TOP