C 语言里,main 函数中 return x 和 exit(x) 到底有什么区别?

原文: http://www.zhihu.com/question/26591968

 

问题:C语言里,main 函数中 return x和 exit(x) 到底有什么区别 ?

 

最近读 APUE,APUE 7.3 节中说,main 函数 return 相当于

exit(main(argc, argv))

但是在实践程序 8-2 时候出现了问题。

 

如:

#include <stdio.h>#include <stdlib.h>#include <unistd.h>int glob = 6;intmain(void){
    int var;
    pid_t pid;
    var = 88;
    printf("before vfork\n");
    if ((pid = vfork()) < 0) {
        printf("vfork error");
        exit(-1);
    } else if (pid == 0) {
        /* 子进程 */
        glob++;
        var++;
        return 0;
        //exit(0);
    }
    printf("pid=%d, glob=%d, var=%d\n", getpid(), glob, var);
    return 0;
    //exit(0);}

编译后执行会导致 core-dump,但是将 return 改为 exit 后却不会。

#include <stdio.h>#include <stdlib.h>#include <unistd.h>int glob = 6;intmain(void){
    int var;
    pid_t pid;
    var = 88;
    printf("before vfork\n");
    if ((pid = vfork()) < 0) {
        printf("vfork error");
        exit(-1);
    } else if (pid == 0) {
        /* 子进程 */
        glob++;
        var++;
        //return 0;
        exit(0);
    }
    printf("pid=%d, glob=%d, var=%d\n", getpid(), glob, var);
    //return 0;
    exit(0);} 

本人小白,求诸位高手们解惑。

 

在此谢谢了。

 

陈皓,酷壳:http://coolshell.cn/

 

基础知识

 

首先说一下fork和vfork的差别:

 

  • fork 是 创建一个子进程,并把父进程的内存数据copy到子进程中。

  • vfork是 创建一个子进程,并和父进程的内存数据share一起用。

 

这两个的差别是,一个是copy,一个是share。


你 man vfork 一下,你可以看到,vfork是这样的工作的,

 

  1. 保证子进程先执行。

  2. 当子进程调用exit()或exec()后,父进程往下执行。

 

那么,为什么要干出一个vfork这个玩意? 原因是这样的—— 起初只有fork,但是很多程序在fork一个子进程后就exec一个外部程序,于是fork需要copy父进程的数据这个动作就变得毫无意了,而且还很重,所以,搞出了个父子进程共享的vfork。所以,vfork本就是为了exec而生。

 

为什么return会挂掉,exit()不会?

 

从上面我们知道,结束子进程的调用是exit()而不是return,如果你在vfork中return了,那么,这就意味main()函数return了,注意因为函数栈父子进程共享,所以整个程序的栈就跪了。

 

如果你在子进程中return,那么基本是下面的过程:

 

  1. 子进程的main() 函数 return了

  2. 而main()函数return后,通常会调用 exit()或相似的函数(如:exitgroup())

  3. 这时,父进程收到子进程exit(),开始从vfork返回,但是尼玛,老子的栈都被你干废掉了,你让我怎么执行?(注:栈会返回一个诡异一个栈地址,对于某些内核版本的实现,直接报“栈错误”就给跪了,然而,对于某些内核版本的实现,于是有可能会再次调用main(),于是进入了一个无限循环的结果,直到vfork 调用返回 error)

 

好了,现在再回到 return 和 exit,return会释放局部变量,并弹栈,回到上级函数执行。exit直接退掉。如果你用c++ 你就知道,return会调用局部对象的析构函数,exit不会。(注:exit不是系统调用,是glibc对系统调用 _exit()或_exitgroup()的封装)


可见,子进程调用exit() 没有修改函数栈,所以,父进程得以顺利执行。

 

————更新————

 

有人在评论中问,写时拷贝呢?还说vfork产生的原因不太对。我在这里说明一下:

 

关于写时拷贝(COW)。

 

就是fork后来采用的优化技术,这样,对于fork后并不是马上拷贝内存,而是只有你在需要改变的时候,才会从父进程中拷贝到子进程中,这样fork后立马执行exec的成本就非常小了。而vfork因为共享内存所以比较危险,所以,Linux的Man Page中并不鼓励使用vfork() ——

 

“ It is rather unfortunate that Linux revived this specter from the past. The BSD man page states: "This system call will be eliminated when proper system sharing mechanisms are implemented. Users should not depend on the memory sharing semantics of vfork() as it will, in that case, be made synonymous to fork(2)."”

 

于是,从BSD4.4开始,他们让vfork和fork变成一样的了。但在后来,NetBSD 1.3 又把传统的vfork给捡了回来,说是vfork的性能在 Pentium Pro 200MHz 的机器上有可以提高几秒钟的性能。详情见——“NetBSD Documentation: Why implement traditional vfork()”

 

关于vfork产生的原因

 

你可以看一下Linux Man page——

 


Historic Description

 

Under Linux, fork(2) is implemented using copy-on-write pages, so the only penalty incurred by fork(2) is the time and memory required to duplicate the parent’s page tables, and to create a unique task structure for the child. However, in the bad old days a fork(2) would require making a complete copy of the caller’s data space, often needlessly, since usually immediately afterwards an exec(3) is done. Thus, for greater efficiency, BSD introduced the vfork() system call, which did not fully copy the address space of the parent process, but borrowed the parent’s memory and thread of control until a call to execve(2) or an exit occurred. The parent process was suspended while the child was using its resources. The use of vfork() was tricky: for example, not modifying data in the parent process depended on knowing which variables are held in a register.

 

孙建希,linux c 程序员

 

内核代码分析!

 

linux创建子进程实际是一个复制父进程的过程。所以更贴切的说法是clone。linux一开始使用fork的原因是当时clone这个词还没有流行。 实际存在fork,clone,vfork 三个系统调用。fork是完全复制,clone则是有选择的复制,vfork则完全使用父进程的资源。可以理解vfork是创建的线程。 vfork的出现主要是为了立即就执行exec的程序考虑的。但是后来的kernel都支持copy_on_write ,所以vfork提高效率的机制也没有那么明显了。

 

内核中三个系统调用最后都是调用do_fork:

 

fork:

return do_fork(SIGCHLD, regs.esp, &regs, 0);

clone:

return do_fork(clone_flags, newsp, &regs, 0);

vfork:

return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0);
#define CLONE_VFORK 0x00004000  /* set if the parent wants the child to wake it up on mm_release*/
#define CLONE_VM 0x00000100  /* set if VM shared between processes */

上面两个宏指出:

 

vfork 要求子进程执行mm_release 后唤醒 父进程, 并且共享虚拟内存

 

为什么要求子进程先行呢?

 

拿虚拟内存做比方。 进程需要有结构管理自己的虚拟内存空间, 该结构在进程 结构体 task_struct 中就是一个mm_struct 类型的指针。fork的时候内核会新建结构体,将该mm_struct 本身以及下级结构都复制一份,并设置子进程的mm_struct 指向新的内存。而vfork则只是复制了task_struct 本身,并没有递归下去。简单说就是:fork复制了内存,vfork复制了指针。

 

do_fork:

#define DECLARE_MUTEX_LOCKED(name) __DECLARE_SEMAPHORE_GENERIC(name,0)
DECLARE_MUTEX_LOCKED(sem);
if ((clone_flags & CLONE_VFORK) && (retval > 0))
    down(&sem);

可以看到申明了信号两sem, 并初始化为0,也就是说当使用vfork时,父进程会睡眠。(需要说一下此时子进程已经进入就绪队列。并且该信号量是局部变量,子进程使用的父进程的地址空间,所以也是可以看到该局部变量的。) 子进程被调度执行时,使用的是父进程的地址空间(因为用的父进程的mm_struct 指针), 此时子进程可以该父进程的堆栈。所以此时父子进程绝对不能同时运行。 execve和exit两个系统调用是不退栈的,而是直接进入系统空间,将共享的地址空间分开,所以这两个系统调用是安全的。return是会退栈的,而子进程的退栈会导致父进程的栈也被改了(应该很好理解), 所以子进程绝对不能退到父进程当前栈顶以下的地方。

 

所以开发人员注意: 子进程绝对不允许在调用vfork的函数中return,vfork就是用来调用execve的。而且该系统调用在cow后就应该禁止使用了!

 

想看的继续:

 

execve,exit两个系统调用会在内核调用mm_release函数,该函数会调用up操作。

void mm_release(void)
{
    struct task_struct *tsk = current;
    /* notify parent sleeping on vfork() */
    if (tsk->flags & PF_VFORK) {
        tsk->flags &= ~PF_VFORK;
        up(tsk->p_opptr->vfork_sem);
    }
}

struct task_struct {
....
unsigned long flags; /* per process flags, defined below */
struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr;
...
}

p_opptr 指向父进程的task_struct 结构。分别是 生父,养父,子进程,弟弟进程,哥哥进程。

 

刘畅

 

题主你如果反汇编一下 gcc 生成的代码,然后对 core dump 的程序运行一下 gdb backtrace 就可以知道这两者的差别,以及为什么 return 0 会 core dump 了。

 

反汇编后可以发现,在 Linux+gcc+x86_64 (x86 下只要吧所有汇编指令中的 q 去掉都是一样的) 下 return 0 生成的代码最后执行了 retq, 这样控制就跳转到之前调用 main() 的那个那个 callq 指令之后,这是在函数 __libc_start_main,就是在这里 libc 调用 main() 函数的。main() 执行完后就返回这里。__libc_start_main 非常复杂,需要完成 libc 的一大堆功能。例如,如果你生成的是静态链接的 a.out,那么 __libc_start_main 会在这个函数中执行大量的操作,例如和当前的区域 LC_ALL 有关的操作(很神奇吧!)。如果是动态链接的 a.out, 那么 __libc_start_main 调用一个全局跳转表中的各个函数。所有的操作执行完后最终控制会转移到 _exit(),就是操作系统提供的系统调用,操作系统(在内核态)将进程杀掉。

 

相反,如果你调用 exit() (也是在 libc 实现的, 见 [2]),最后控制转移到 exit() 函数(也就是说不返回 __libc_start_main 了),这个函数比较简单,它只是调用一个简单的函数 __run_exit_handlers, 这个函数按顺序执行 atexit() 注册的退出函数,然后直接调用 _exit()。

 

由于你在上面 fork 子进程的时候使用的是 vfork,vfork 是没有 copy-on-write 的。这样父进程的 image 是和子进程共享的。父进程一旦退出,那么子进程就没有 image 了,这样访问父进程的数据就会导致页异常。

 

由于exit() 函数调用的 __run_exit_handlers 一) 比较简单 (看 [2] 中的代码),二) 空指针不是强行报错而是默默的忽略(看代码),这样做没有造成问题,__libc_start_main 就不一样了。

 

当动态链接 a.out 时 gdb backtrace 返回的结果是:

#0  0x00007ffff7a6b967 in raise () from /usr/lib/libc.so.6
#1  0x00007ffff7a6cd3a in abort () from /usr/lib/libc.so.6
#2  0x00007ffff7a648ad in __assert_fail_base () from /usr/lib/libc.so.6
#3  0x00007ffff7a64962 in __assert_fail () from /usr/lib/libc.so.6
#4  0x00007ffff7a6e4ca in __new_exitfn () from /usr/lib/libc.so.6
#5  0x00007ffff7a6e549 in __cxa_atexit_internal () from /usr/lib/libc.so.6
#6  0x00007ffff7a57fa3 in __libc_start_main () from /usr/lib/libc.so.6
#7  0x0000000000400559 in _start ()

结合 glibc 的代码 [1], 可以看到错误发生在 __libc_start_main 试图执行在 atexit() 中注册的函数。事实上你可以在代码的前面加入 atexit() 注册一个 exit callback function, 这时你可以看到这个函数只被执行了一次。而如果你使用 fork() 这个函数被执行两次。这表明错误就是在 __libc_start_main 试图执行 atexit 注册的函数时发生的。

 

运行 a.out 提示的错误

a.out: cxa_atexit.c:100: __new_exitfn: Assertion `l != ((void *)0)' failed.

是在上面代码的 90 行产生的(我机器里的glibc版本不一样,所以显示的位置是100行,都是差不多的)。

 

当静态链接 a.out 时 gdb backtrace 返回的结果是:

#0  0x000000000043f6a7 in raise ()
#1  0x000000000040609a in abort ()
#2  0x000000000040978f in __libc_message ()
#3  0x00000000004097ac in __libc_fatal ()
#4  0x0000000000400f21 in __libc_start_main ()
#5  0x0000000000400c1c in _start ()

这次错误发生的更靠前,在 __libc_start_main 中就发生了错误。我没有去查代码,题主有兴趣可以去查一查具体是哪一行出错了。

 

求赞。。。

 

[1] fxr.watson.org: GLIBC27 sys/stdlib/cxa_atexit.c

[2] exit.c [glibc/stdlib/exit.c]

 

徐丽,Unix世界的妹子

 

前面的答题很好了,但是不容易理解,简单点说:

 

每个C程序的入口点_start处的代码用伪代码表示为

 

_start:

 

call __libc_init_first // 一些初始化

call _init
call atexit
call main
call _exit

 

从伪代码就看出来了,每个C程序都要在执行一些初始化函数后对main调用,若main末尾为return语句,那么控制返回,最终会call _exit,把控制返回系统。若省略return,那么也将会call _exit。如果代码中有exit函数,那么会先执行atexit注册的函数,进而执行_exit()把控制还给操作系统。

 

总之,这些情况下,当main返回,控制会传给系统

 

SCrip,业余IT

 

exit是操作系统的,return是c语言函数的,不在一个层面上。

 

posted @ 2016-03-25 16:20  lcyw163  阅读(688)  评论(0编辑  收藏  举报
扫码关注 【音视频开发训练营】 音视频开发训练营