第3章 进程环境
进程是操作系统运行程序的一个实例,也是操作系统分配资源的单位。在Linux环境中,每个进程都有独立的进程空间,以便对不同的进程进行隔离,使之不会互相影响。深入理解Linux下的进程环境,可以帮助我们写出更健壮的代码。
3.1 main是C程序的开始吗
在编写C程序的时候,都是从main函数开始,然而main函数真的是C程序的入口吗?让我们来看看下面的程序:
#include <stdlib.h>
#include <stdio.h>
static void __attribute__ ((constructor)) before_main(void)
{
printf("Before main...\n");
}
int main(void)
{
printf("Main!\n");
return 0;
}其执行结果为:
Before main...
Main!
从运行结果中,可以发现before_main是在进入main函数之前被调用的,这点对于C语言的初学者来说似乎有点难以接受。究竟是谁调用的before_main呢?怎么还没有进入main就可以有代码被执行呢?
回忆一下第0章所讲的基础知识,在编译的过程中可以使用-v来详细地显示编译的过程。在此,截取 gcc 4_1_main_stack.c –v 输出的一部分结果,如下所示:
/usr/libexec/gcc/i686-redhat-linux/4.6.3/collect2 --build-id --no-add-needed
--eh-frame-hdr -m elf_i386 --hash-style=gnu -dynamic-linker /lib/ld-linux.
so.2 /usr/lib/gcc/i686-redhat-linux/4.6.3/../../../crt1.o /usr/lib/gcc/i686-
redhat-linux/4.6.3/../../../crti.o /usr/lib/gcc/i686-redhat-linux/4.6.3/
crtbegin.o -L/usr/lib/gcc/i686-redhat-linux/4.6.3 -L/usr/lib/gcc/i686-
redhat-linux/4.6.3/../../.. /tmp/cc3tzF7V.o -lgcc --as-needed -lgcc_s --no-
as-needed -lc -lgcc --as-needed -lgcc_s --no-as-needed /usr/lib/gcc/i686-
redhat-linux/4.6.3/crtend.o /usr/lib/gcc/i686-redhat-linux/4.6.3/../../../
crtn.o
可以看到,在链接生成最后的可执行文件时,有大量的C库二进制文件参与进来,如 crt1.o、crti.o 等。可见最终的可执行文件,除了我们编写的这个简单的C代码以外,还有大量的C库文件参与了链接,并包含在最终的可执行文件中。这个“组装”的过程,是由链接器 ld 的链接脚本来决定的。在没有指定链接脚本的情况下,会使用 ld 的默认脚本,可以通过 ld --verbose 来查看,下面截取了对我们有用的部分输出:
/* Script for -z combreloc: combine and sort reloc sections */
OUTPUT_FORMAT("elf32-i386", "elf32-i386",
"elf32-i386")
OUTPUT_ARCH(i386)
ENTRY(_start)
这里定义了输出的文件格式、目标机器的类型,以及重要的信息和程序的入口 ENTRY(_start)。
.ctors :
{
/* gcc uses crtbegin.o to find the start of
the constructors, so we make sure it is
first. Because this is a wildcard, it
doesn't matter if the user does not
actually link against crtbegin.o; the
linker won't look for a file to match a
wildcard. The wildcard also means that it
doesn't matter which directory crtbegin.o
is in. */
KEEP (*crtbegin.o(.ctors))
KEEP (*crtbegin?.o(.ctors))
/* We don't want to include the .ctor section from
the crtend.o file until after the sorted ctors.
The .ctor section from the crtend file contains the
end of ctors marker and it must be last */
KEEP (*(EXCLUDE_FILE (*crtend.o *crtend?.o ) .ctors))
KEEP (*(SORT(.ctors.*)))
KEEP (*(.ctors))
}
这里定义了 .ctors section,而我们的例子中 before_main 函数使用的 gcc 扩展属性 __attribute__((constructor)) 就是将函数对应的指令归属于 .ctors section 中。
下面我们来追溯一下 Linux 可执行程序完整的启动过程。前面的链接脚本明确了入口为 _start。在32位的 x86 平台中,_start 位于 sysdeps/i386/start.S 中。
.text
.globl _start
.type _start,@function
_start:
/* Clear the frame pointer. The ABI suggests this be done, to mark
the outermost frame obviously. */
xorl %ebp, %ebp
/* Extract the arguments as encoded on the stack and set up
the arguments for `main': argc, argv. envp will be determined
later in __libc_start_main. */
popl %esi /* Pop the argument count. */
movl %esp, %ecx /* argv starts just at the current stack top.*/
/* Before pushing the arguments align the stack to a 16-byte
(SSE needs 16-byte alignment) boundary to avoid penalties from
misaligned accesses. Thanks to Edward Seidl <seidl@janed.com>
for pointing this out. */
andl $0xfffffff0, %esp
pushl %eax /* Push garbage because we allocate
28 more bytes. */
/* Provide the highest stack address to the user code (for stacks
which grow downwards). */
pushl %esp
pushl %edx /* Push address of the shared library
termination function. */
/* Push address of our own entry points to .fini and .init. */
pushl $__libc_csu_fini
pushl $__libc_csu_init
pushl %ecx /* Push second argument: argv. */
pushl %esi /* Push first argument: argc. */
pushl $BP_SYM (main)
/* Call the user's main function, and exit with its value.
But let the libc call main. */
call BP_SYM (__libc_start_main)上面列出的虽然是汇编代码,但是每一行都有清楚的注释,这段代码主要是为程序的运行创建好运行环境,其中需要注意的是,__libc_csu_fini 和 __libc_csu_init 都被作为参数传给了 __libc_start_main。从这两个函数的名字上可以推测它们是用来处理退出和初始化阶段的函数,那么 .ctors section 中的函数很可能就是由 __libc_csu_init 来调用的。
我们先来关注 __libc_csu_init 是在何时被调用的,然后再分析其实现。上面的汇编代码将这两个函数作为参数传递给了 __libc_start_main,然后又调用了 generic_start_main 函数。这个函数初始化了C库所需要的环境,如环境变量、函数栈、多线程环境等,最后调用 main 函数——进入普通应用程序的真正入口。而在此之前,以下代码先被执行:
/* Register the destructor of the program, if any. */
if (fini)
__cxa_atexit ((void (*) (void *)) fini, NULL, NULL);
if (init)
(*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);init 即为 __libc_csu_init,上面的代码保证了 __libc_csu_init 在 main 之前被调用。那么 .ctors 的函数又是如何被 __libc_csu_init 调用的呢?篇幅所限,在此我们就不罗列代码,只给出其调用流程:
__libc_csu_init → _init → __libc_global_ctors
void
__libc_global_ctors (void)
{
/* Call constructor functions. */
run_hooks (__CTOR_LIST__);
}
static inline void
run_hooks (void (*const list[]) (void))
{
while (*++list)
(**list) ();
}
static void (*const __CTOR_LIST__[1]) (void)
__attribute__ ((used, section (".ctors")))
= { (void (*) (void)) -1 };__CTOR_LIST__ 是一个函数指针数组,数组的大小为1。该数组使用gcc的扩展属性,使 __CTOR_LIST__ 位于 .ctors section 中。因此,在上面的代码中,__libc_global_ctors 将 __CTOR_LIST__ 传递给了 run_hooks,实际上就是将 .ctors section 的起始地址传递给了 run_hooks。而 __CTOR_LIST__ 位于 .ctors 的第一个位置,其本身并不是一个真正的 .ctors 属性函数,因此 run_hooks 的 while (*++list) 先执行自增操作,即跳过了 __CTOR_LIST__。
可以通过反汇编查看二进制的可执行程序来验证:
080483e4 <before_main>:
80483e4: 55 push %ebp
80483e5: 89 e5 mov %esp,%ebp
80483e7: 83 ec 18 sub $0x18,%esp
80483ea: c7 04 24 e0 84 04 08 movl $0x80484e0,(%esp)
80483f1: e8 22 ff ff ff call 8048318 <puts@plt>
80483f6: c9 leave
80483f7: c3 ret
可以看到,函数 before_main 的地址为 0x080483e4。然后使用 objdump 来查看 .ctors section:
objdump -s -j .ctors a.out
a.out: file format elf32-i386
Contents of section .ctors:
8049f08 ffffffff e4830408 00000000 ......
可以看到,.ctors section 的第一个元素即上文中的 __CTOR_LIST__,第二个元素为 before_main —— 由于 x86 是小端 CPU,因此 0xe4830408 实际上表示的地址值为 0x080483e4。
需要注意的是,在新版本的 gcc 中,.ctors 属性的函数并不会位于 .ctors section 中,而是被 gcc 合并到了 .init_array section 中。下面来看一下这种情况下的 objdump 输出:
[fgao@fgao chapter3]#objdump -s -j .ctors a.out
a.out: file format elf32-i386
Contents of section .ctors:
8049600 ffffffff 00000000 ......
可以看到,在 .ctors section 中,没有任何有效的 .ctors 函数,然后我们来看看 .init_array section:
[fgao@fgao chapter3]#objdump -s -j .init_array a.out
a.out: file format elf32-i386
Contents of section .init_array:
80495fc b4830408 ......
保存在 .init_array section 中的函数调用机制与之前分析的 .ctors section 机制类似,在此就不再重复了。感兴趣的朋友可以自行分析。
说明
与
constructor属性对应的,还有destructor属性。拥有destructor属性的函数,会在main结束之后被调用。
3.2 “活雷锋”exit
在学习C语言时,我们被告知分配内存后若不使用 free 释放,会造成内存泄漏。同样,打开文件后若忘记 close 也会导致资源泄漏。那么,在进程退出后,这些资源是否真的泄漏了呢?
当进程正常退出时,会调用C库的 exit;而当进程崩溃或被杀掉时,exit 不会被调用,只会执行内核退出进程的操作。
首先分析C库的退出函数 exit,代码如下:
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true);
}exit 主要用来执行所有注册的退出函数,比如使用 atexit 或 on_exit 注册的函数。执行完注册的退出函数后,__run_exit_handlers 会调用 _exit,代码如下:
void
_exit (status)
int status;
{
while (1)
{
#ifdef __NR_exit_group
INLINE_SYSCALL (exit_group, 1, status);
#endif
INLINE_SYSCALL (exit, 1, status);
#ifdef ABORT_INSTRUCTION
ABORT_INSTRUCTION;
#endif
}
}上面代码很简单:当平台有 exit_group 时调用 exit_group,否则调用 exit。从Linux内核2.5.35版本以后,为了支持线程,引入了 exit_group。该系统调用不仅用于退出当前线程,还会让所有线程组的线程全部退出。
下面来看系统调用 exit_group 的实现:
SYSCALL_DEFINE1(exit_group, int, error_code)
{
/* do_group_exit 做真正的工作 */
do_group_exit((error_code & 0xff) << 8);
/* NOTREACHED */
return 0;
}
NORET_TYPE void
do_group_exit(int exit_code)
{
struct signal_struct *sig = current->signal;
BUG_ON(exit_code & 0x80); /* core dumps don't get here */
/* 检查该线程组是否正在退出,如果条件为真,则不需要设置线程组退出的条件,
直接执行本线程 task 退出流程 do_exit 即可 */
if (signal_group_exit(sig))
exit_code = sig->group_exit_code;
else if (!thread_group_empty(current)) { /* 线程组不为空 */
struct sighand_struct *const sighand = current->sighand;
spin_lock_irq(&sighand->siglock);
/* 标准的双重条件检查机制.因为第一次检查 signal_group_exit 时为假,
但是另外一个线程已经拿到锁并设置了状态.当拿到锁的时候,需要再次检查 */
if (signal_group_exit(sig)) {
/* Another thread got here before we took the lock. */
exit_code = sig->group_exit_code;
}
else {
/* 设置线程组的退出值和退出状态 */
sig->group_exit_code = exit_code;
sig->flags = SIGNAL_GROUP_EXIT;
/* 使用 SIGKILL “干掉”线程组的其他线程 */
zap_other_threads(current);
}
spin_unlock_irq(&sighand->siglock);
}
/* 真正的退出动作,退出当前线程 task */
do_exit(exit_code);
/* NOTREACHED */
}下面来看 do_exit 的实现:
NORET_TYPE void do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;
profile_task_exit(tsk);
WARN_ON(blk_needs_flush_plug(tsk));
/* 中断上下文不能使用退出,因为没有进程上下文 */
if (unlikely(in_interrupt()))
panic("Aiee, killing interrupt handler!");
/* pid 为 0,即内核的 idle 进程.这个 task 也是不应该退出的 */
if (unlikely(!tsk->pid))
panic("Attempted to kill the idle task!");
/*
* If do_exit is called because this processes oopsed, it's possible
* that get_fs() was left as KERNEL_DS, so reset it to USER_DS before
* continuing. Amongst other possible reasons, this is to prevent
* mm_release()->clear_child_tid() from writing to a user-controlled
* kernel address.
*/
set_fs(USER_DS);
/* 如果 task 正在被跟踪(如 gdb),则发送 ptrace 事件 */
ptrace_event(PTRACE_EVENT_EXIT, code);
validate_creds_for_do_exit(tsk);
/*
* We're taking recursive faults here in do_exit. Safest is to just
* leave this task alone and wait for reboot.
*/
/* 当 task 退出的时候,会被设置上 PF_EXITING 标志.如果发现此时 flags
已经设置了该标志,则说明发生了错误.此时就要按照注释所说的,最安全的方法
是什么都不做,通知并等待重启 */
if (unlikely(tsk->flags & PF_EXITING)) {
printk(KERN_ALERT
"Fixing recursive fault but reboot is needed!\n");
/*
* We can do this unlocked here. The futex code uses
* this flag just to verify whether the pi state
* cleanup has been done or not. In the worst case it
* loops once more. We pretend that the cleanup was
* done as there is no way to return. Either the
* OWNER_DIED bit is set by now or we push the blocked
* task into the wait for ever nirwana as well.
*/
tsk->flags |= PF_EXITPIDONE;
/* 将当前 task 设置为不可中断的状态,然后放弃 CPU */
set_current_state(TASK_UNINTERRUPTIBLE);
schedule();
}
/* 如果当前 task 是中断线程(即每个 CPU 中断由一个线程来处理),
则设置对应的中断停止来唤醒本线程.这是一个编译选项,默认情况下是关闭的. */
exit_irq_thread();
/* 给 task 设置退出标志 PF_EXITING */
exit_signals(tsk); /* sets PF_EXITING */
/*
* tsk->flags are checked in the futex code to protect against
* an exiting task cleaning up the robust pi futexes.
*/
smp_mb();
raw_spin_unlock_wait(&tsk->pi_lock);
if (unlikely(in_atomic()))
printk(KERN_INFO "note: %s[%d] exited with preempt_count %d\n",
current->comm, task_pid_nr(current),
preempt_count());
acct_update_integrals(tsk);
/* sync mm's RSS info before statistics gathering */
/* 该 task 有自己的内存空间 */
if (tsk->mm)
sync_mm_rss(tsk, tsk->mm); // 更新内存统计计数
/* 判断整个线程组是否都已经退出 */
group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
/* 取消高精度定时器 */
hrtimer_cancel(&tsk->signal->real_timer);
/* 删除 task 的内部定时器,对应系统调用 getitimer 和 setitimer */
exit_itimers(tsk->signal);
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
}
acct_collect(code, group_dead);
/* 如果整个线程组都已经退出,则释放授权资源 */
if (group_dead)
tty_audit_exit();
if (unlikely(tsk->audit_context))
audit_free(tsk);
/* 设置 task 的退出值 */
tsk->exit_code = code;
/* 释放任务统计资源 */
taskstats_exit(tsk, group_dead);
/*
* 释放 task 的内存空间.
* task 使用的所有内存页都由内核来维护.对于用户程序,如果忘记释放申请的内存,
* 则只会造成用户程序无法再使用该内存,因为内核认为该内存仍然在被用户程序使用.
* 当 task 退出时,内核会负责释放所有的内存地址.因此当进程退出时,
* 所有申请的内存都会被释放,不会有任何的内存泄漏.
*/
exit_mm(tsk);
if (group_dead)
acct_process();
trace_sched_process_exit(tsk);
/*
* 检查是否释放了 semphore 资源,如没有释放则执行 semphore 的 undo 操作.
* 这点用于保证在进程意外退出时,能恢复 semphore 的正确状态,
* 也可以用于预防错误的程序逻辑所导致的 semphore 释放操作遗漏.
*/
exit_sem(tsk);
/* 释放共享内存 */
exit_shm(tsk);
/*
* 如果文件资源没有被共享,则释放所有的文件资源.
* 即使用户程序有文件泄漏也不必担心,一旦 task 退出,
* 文件资源都会得到正确的释放—因为内核维护了所有的、打开的文件.
*/
exit_files(tsk);
/* 释放 task 的文件系统资源,如当前目录、根目录等 */
exit_fs(tsk);
check_stack_usage();
/* 释放 task 资源,如 TSS 段等 */
exit_thread();
/*
* Flush inherited counters to the parent - before the parent
* gets woken up by child-exit notifications.
*
* because of cgroup mode, must be called before cgroup_exit()
*/
perf_event_exit_task(tsk);
/* 从控制组退出,并释放相关资源 */
cgroup_exit(tsk, 1);
/* 如果线程组都已经退出,则断开控制终端即 tty */
if (group_dead)
disassociate_ctty(1);
/* 后面仍然是一些 task 退出的清理工作,因与本节关系不大,所以在此不再一一列出了 */
......
}从 exit 的源码可以得知,即使应用程序在应用层有内存泄漏或文件句柄泄漏也不必担心,当进程退出时,内核的 exit_group 调用将会默默地在后面做着清理工作,释放所有内存,关闭所有文件,以及其他资源。
TIP
内核会负责回收所有进程资源,因此即使忘记释放内存或关闭文件,进程退出后也不会有真正的泄漏。
3.3 atexit介绍
3.3.1 使用atexit
atexit 用于注册进程正常退出时的回调函数。若注册了多个回调函数,最后的调用顺序与注册顺序相反,与我们熟悉的栈操作类似,先入后出。
#include <stdlib.h>
int atexit(void (*function)(void));下面来看一个简单的例子:
#include <stdlib.h>
#include <stdio.h>
static void callback1(void)
{
printf("callback1\n");
}
static void callback2(void)
{
printf("callback2\n");
}
static void callback3(void)
{
printf("callback3\n");
}
int main(void)
{
atexit(callback1);
atexit(callback2);
atexit(callback3);
printf("main exit\n");
return 0;
}运行结果如下:
main exit
callback3
callback2
callback1
从上面的代码输出可以看出,我们顺序地注册 callback1、callback2 和 callback3,当进程退出时,其调用顺序为 callback3、callback2 和 callback1。
3.3.2 atexit的局限性
3.3.1节介绍 atexit 的基本用法时提到过,使用 atexit 注册的退出函数是在进程正常退出时,才会被调用。这里的正常退出是指使用 exit 退出或使用 main 中最后的 return 语句退出。若是因为收到信号而导致程序退出,atexit 注册的退出函数则不会被调用。下面我们通过一个测试程序来验证这一观点:
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
static void callback1(void)
{
printf("callback1\n");
}
int main(void)
{
atexit(callback1);
while (1) {
sleep(1);
}
printf("main exit\n");
return 0;
}然后编译运行,使用另一个控制台给其发送信号:
[fgao@fgao ik8]# killall atexit_signal
我们会发现 atexit 注册的退出函数并没有被调用:
[fgao@fgao chapter3]# ./atexit_signal;
Terminated
为什么只有在正常退出的时候,atexit 注册的退出函数才能被调用呢?下面我们来分析 atexit 的源码实现,就可以得到答案了。
3.3.3 atexit的实现机制
让我们带着疑问来分析 glibc 中的 atexit 源码:
int
#ifndef atexit
attribute_hidden
#endif
atexit (void (*func) (void))
{
/* __dso_handle 是动态共享对象的句柄,此处可以略过 */
return __cxa_atexit ((void (*) (void *)) func, NULL,
&__dso_handle == NULL ? NULL : __dso_handle);
}
int
__cxa_atexit (void (*func) (void *), void *arg, void *d)
{
/* __exit_funcs 为退出函数的链表 */
return __internal_atexit (func, arg, d, &__exit_funcs);
}
int
attribute_hidden
__internal_atexit (void (*func) (void *), void *arg, void *d,
struct exit_function_list **listp)
{
/* 在退出函数链表中,得到一个新的节点 */
struct exit_function *new = __new_exitfn (listp);
if (new == NULL)
return -1;
#ifdef PTR_MANGLE
PTR_MANGLE (func);
#endif
/* 初始化这个节点,将函数及其参数赋给这个节点 */
new->func.cxa.fn = (void (*) (void *, int)) func;
new->func.cxa.arg = arg;
new->func.cxa.dso_handle = d;
atomic_write_barrier ();
new->flavor = ef_cxa;
return 0;
}上面的代码揭示了 atexit 是如何把函数注册到退出函数链表中的。那么,这些函数又是何时被调用的呢?回忆 atexit 的介绍,退出注册函数只有在程序正常退出或调用 exit 时才会被执行。程序正常退出时,系统就会调用 exit。因此,问题的关键就在于 exit 函数了:
void
exit (int status)
{
__run_exit_handlers (status, &__exit_funcs, true);
}在这里,__run_exit_handlers 会遍历 __exit_funcs,一一调用注册的退出函数,在此就不再罗列其代码了。从 atexit 的实现机制上进行分析,我们可以得出 atexit 的实现是依赖于C库的代码的。当进程收到信号时,如果没有注册对应的信号处理函数,那么内核就会执行信号的默认动作,一般是直接终止进程。这时,进程的退出完全由内核来完成,自然不会调用到C库的 exit 函数,也就无法调用注册的退出函数了。
NOTE
atexit注册的函数仅在进程正常退出(调用exit或return从main返回)时被调用。信号终止时,内核直接清理进程,不会调用C库的退出函数。
3.4 小心使用环境变量
Linux环境下,程序在启动的时候都会从 shell 环境下继承当前的环境变量,如 PATH、HOME、TZ 等。我们也可以通过C库的接口来增加、修改或删除当前进程的环境变量,示例如下:
#include <stdlib.h>
int putenv(char *string);putenv 用于增加或修改当前的环境变量。string 的格式为 "名字=值"。如果当前环境变量没有该名称的环境变量,则增加这个新的环境变量;如果已经存在,则使用新值。看似功能很简单,但实际上使用这个接口时,却很容易犯错。请看下面的代码:
#include <stdlib.h>
#include <stdio.h>
static void set_env_string(void)
{
char test_env[] = "test_env=test";
if (0 != putenv(test_env)) {
printf("fail to putenv\n");
}
printf("1. The test_evn string is %s\n", getenv("test_env"));
}
static void show_env_string(void)
{
printf("2. The test_env string is %s\n", getenv("test_env"));
}
int main()
{
set_env_string();
show_env_string();
return 0;
}然后编译,查看输出结果:
1. The test_evn string is test
2. The test_env string is (null)
结果有点出人意料,为什么在 set_env_string 中可以得到我们设置的环境变量,而在 show_env_string 中却不行呢?
原因在于使用 putenv 添加环境变量时,参数直接被当作环境变量的一部分了。对于本例而言,set_env_string 中的 test_env 数组直接被环境变量引用了。而 test_env 是一个局部变量,在执行 在执行 set_env_string 的时候,test_env已经不存在了,对应栈上的内存会在后面的函数调用中使用,并存入其他值。因此,在进入show_env_string` 的时候,就无法得到正确的值了。
笔者曾经修改过一个因为 putenv 引起的 bug,当时也是费了很大一番力气才找到根本原因,所以颇为气愤当时的开发人员为什么在使用 putenv 的时候,不认真阅读该接口的说明。Martin Golding 曾说过一句话:“编程的时候,要总是想着那个维护你代码的人会是一个知道你住在哪儿的、有暴力倾向的精神病患者”。
如果非要用 putenv 来设置环境变量,就必须要保证参数是一个长期存在的内容。因此,只能选择全局变量、常量或动态内存等。为了避免犯错,我们应该尽量使用另外一个接口 setenv,代码如下:
#include <stdlib.h>
int setenv(const char *name, const char *value, int overwrite);参数说明:
name:要加入的环境变量名称。value:该环境变量的值。overwrite:用于指示是否覆盖已存在的重名环境变量。
还是使用上文的例子,只不过我们将 putenv 换为 setenv,代码如下:
#include <stdlib.h>
#include <stdio.h>
static void set_env_string(void)
{
setenv("test_env", "test", 1);
printf("1. The test_evn string is %s\n", getenv("test_env"));
}
static void show_env_string(void)
{
printf("2. The test_env string is %s\n", getenv("test_env"));
}
int main()
{
set_env_string();
show_env_string();
return 0;
}这次的运行结果就是我们预期的结果了:
1. The test_evn string is test
2. The test_env string is test
WARNING
putenv直接引用传入的字符串,因此必须保证字符串的生命周期足够长。推荐优先使用setenv,它会在内部复制字符串,避免悬挂指针问题。
3.5 使用动态库
在平时的编程工作中,除了C库,还会用到大量的库文件,其中绝大部分都是以动态库的方式来提供服务的。
3.5.1 动态库与静态库
一般情况下,库文件的开发者会同时提供动态库和静态库两个版本,它们都有各自的优缺点。静态库在链接阶段,会被直接链接进最终的二进制文件中,因此最终生成的二进制文件体积会比较大,但是可以不再依赖于库文件。而动态库并不是被链接到文件中的,只是保存了依赖关系,因此最终生成的二进制文件体积较小,但是在运行阶段需要加载动态库。
3.5 使用动态库
在平时的编程工作中,除了C库,还会用到大量的库文件,其中绝大部分都是以动态库的方式来提供服务的。
3.5.1 动态库与静态库
一般情况下,库文件的开发者会同时提供动态库和静态库两个版本,它们都有各自的优缺点。静态库在链接阶段,会被直接链接进最终的二进制文件中,因此最终生成的二进制文件体积会比较大,但是可以不再依赖于库文件。而动态库并不是被链接到文件中的,只是保存了依赖关系,因此最终生成的二进制文件体积较小,但是在运行阶段需要加载动态库。
动态库与静态库的区别
- 静态库:链接时嵌入可执行文件,体积大,部署时无需额外库文件。
- 动态库:运行时加载,体积小,但需要库文件位于系统搜索路径或指定路径。
3.5.2 编译生成和使用动态库
首先,我们来编译并生成一个动态库:
#include <stdlib.h>
#include <stdio.h>
void dynamic_lib_call(void)
{
printf("dynamic lib call\n");
}编译生成动态库与编译普通的可执行程序略有不同,如下所示:
gcc -Wall -shared 4_5_2_dlib.c -o libdlib.so其中多了一个 -shared 选项,该选项用于指示 gcc 生成动态库。
然后再编写一个简单例子,来使用这个动态库,代码如下:
#include <stdlib.h>
#include <stdio.h>
extern void dynamic_lib_call(void);
int main(void)
{
dynamic_lib_call();
return 0;
}下面我们利用前面的动态库来生成最终的可执行文件:
gcc -Wall 4_5_2_main.c -o test_dlib -L./ -ldlib其中,-l 用于指示生成文件依赖的库,本例依赖于 libdlib.so,因此为 -ldlib;-L 与 -I 类似,-L 用于指示 gcc 在哪个目录中查找依赖的库文件。
让我们运行这个 test_dlib 看看结果如何:
[fgao@ubuntu chapter3]# ./test_dlib
./test_dlib: error while loading shared libraries: libdlib.so: cannot open shared object file: No such file or directory为什么会报告出错,找不到这个 libdlib.so 呢?前面明明已经使用 -L 指定了库文件在当前目录中,并且这个库文件也确实存在于当前目录中啊。这是怎么回事呢?
让我们使用 ldd 来查看 test_lib 的依赖库,代码如下:
[fgao@ubuntu chapter3]# ldd test_dlib
linux-gate.so.1 => (0xb7785000)
libdlib.so => not found
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xb75ce000)
/lib/ld-linux.so.2 (0xb7786000)确实显示无法找到 libdlib.so。原因在于 -L 只是在 gcc 编译的过程中指示库的位置,而在程序运行的时候,动态库的加载路径默认为 /lib 和 /usr/lib。在 Linux 环境下,还可以通过 /etc/ld.so.conf 配置文件和环境变量 LD_LIBRARY_PATH 指示额外的动态库路径。
为简单起见,我们在这里将 libdlib.so 复制到 /usr/lib 目录下,再运行 test_dlib 试试:
[root@ubuntu lib]# cp /home/fgao/works/my_git_codes/my_books/understanding_apue/sample_codes/chapter3/libdlib.so .
[fgao@ubuntu chapter3]# ./test_dlib
dynamic lib call现在 ./test_dlib 顺利执行了,并成功调用了动态库中的 dynamic_lib_call 函数。
上面的例子中,动态库是由系统自动加载的,所以需要将动态库放在指定的目录下。然而,C 库还提供了 dlopen 等接口来支持手工加载动态库的功能,代码如下:
#include <stdlib.h>
#include <stdio.h>
#include <dlfcn.h>
int main()
{
void *dlib = dlopen("./libdlib.so", RTLD_NOW);
if (!dlib) {
printf("dlopen failed\n");
return -1;
}
void (*dfunc) (void) = dlsym(dlib, "dynamic_lib_call");
if (!dfunc) {
printf("dlsym failed\n");
return -1;
}
dfunc();
dlclose(dlib);
return 0;
}编译代码:
gcc -Wall 4_5_2_main_mlib.c -ldl -o test_mlib需要使用 -ldl 选项来指定依赖的动态库 libdl.so。
下面来看一下输出结果:
[fgao@ubuntu chapter3]# ./test_mlib
dynamic lib call可以看出,我们已经成功地使用手工来加载动态库,并完成了动态库中的函数调用。
介绍完动态库的两种加载方法,我们可以对比一下两者的优缺点。对于自动加载,处理起来比较简单;而手工加载需要编写额外的代码,但正是这些额外的代码提供了更多的动态库的可控性。
3.5.3 程序的“平滑无缝”升级
3.5.1 节中,对比了动态库和静态库的优缺点。其中动态库的一个重要优点就是,可执行程序并不包含动态库中的任何指令,而是在运行时加载动态库并完成调用。这就给我们提供了升级动态库的机会。只要保证接口不变,使用新版本的动态库替换原来的动态库,就完成了动态库的升级。更新完库文件以后启动的可执行程序都会使用新的动态库。
这样的更新方法只能够影响更新以后启动的程序,对于正在运行的程序则无法产生效果,因为程序在运行时,旧的动态库文件已经加载到内存中了。我们只能更新位于磁盘上的动态库的物理文件,而不能影响已经位于内存中的库了。
我们是否可以做得更好呢?对于服务程序来说,重启会付出很大的代价并带来糟糕的用户体验。那么,能否让运行中的服务程序也能在升级库以后使用新的指令,做到“平滑无缝”的升级呢?这就需要使用前面介绍的手工加载动态库的方法了。
下面的伪代码将给出一个比较简单的解决方案。
1)使用一个结构体来管理动态库的接口:
struct dlib_manager {
void *dlib_handle; // 保存动态库的句柄
int (*service_func)(void *);
int (*service_func2)(void *);
} g_dlib_manager;
/* g_dlib_manager 作为动态库接口的全局变量 */
struct dlib_manager *g_dlib_manager;2)利用 dlopen、dlsym 等来加载动态库,更新接口。重新申请新的内存,来保存新的动态库接口:
/* 更新动态库接口 */
struct dlib_manager *new_manager = malloc(sizeof(*new_manager));
new_manager->dlib_handle = dlopen("libupgrade.so", RTLD_LAZY);
new_manager->service_func = dlsym(g_dlib_handle, "service_call");
new_manager->service_func2 = dlsym(g_dlib_handle, "service_call2");
/* 在多核环境下,使用内存屏障,以保证在交换 new_manager 和 g_dlib_manager 时,
new_manager 已经完成了赋值 */
wmb();
/* 交换新指针与当前正在使用的接口指针
因为目前,无论是新指针还是旧指针都是有效的接口,所以并不会对业务产生影响 */
swap(new_manager, g_dlib_manager);
/* 交换完成以后,新的请求都会交由新接口来处理
由于当前旧接口仍然可能正在使用中,所以要使用推迟释放或是等待正在服务的接口完成 */
delay_free(new_manager);3)在调用服务接口时,要利用局部变量保存服务接口:
struct dlib_manager *local_dlib_manager = g_dlib_manager;
local_dlib_manager->service_func1(data);
local_dlib_manager->service_func2(data);之所以这里使用局部变量来进行接口调用,是为了避免在调用了一部分接口后,g_dlib_manager 才发生更新,从而导致前后的服务接口属于不同的动态库,造成不可预料的问题。通过临时变量来保存服务接口,能确保所有接口的一致性。
4)释放旧接口的关键在于,要保证没有旧接口正在被使用。根据自己的业务,找到一个时间点——在这个时间点上,所有的线程(准确地说是请求流程)都已经服务过一次。这时,新来的请求就会使用新的接口,于是我们也就可以安全地释放旧接口了。
其实整个实现方案是借鉴了 Linux 内核的 RCU 实现方式。通过这种方法,可以进行“平滑无缝”的升级,而不影响运行状态下的业务功能。
3.6 避免内存问题
在编程的错误中,内存问题无疑占据了很大的比例。而且内存问题比较难查,出现问题的“案发现场”与真正的“凶手”往往隔着十万八千里,甚至完全没有关系。对于初学者来说,解决这样的问题往往要浪费大量的时间。因此,我们应该在编写代码的初始阶段,就要注意避开某些代码“陷阱”。问题发现得越早,代价也就越小。
3.6.1 尴尬的 realloc
对于良好的代码风格,有一项很重要的要求是一个函数只专注于做一件事情。如果该函数像瑞士军刀一样能实现多个功能,那基本上可以断言这不是一个设计良好的函数。
C 库中的 realloc 函数就是一个典型的反面教材:
#include <stdlib.h>
void *realloc(void *ptr, size_t size);realloc 可以将 ptr 指向的内存调整为 size 大小。这个功能看上去很明确,其实则不然,其一共有三种不同的行为:
- 参数
ptr为NULL,而size不为 0,则等同于malloc(size)。 - 参数
ptr不为NULL,而size为 0,则等同于free(ptr)。 - 参数
ptr和size均不为 0,其行为类似于free(ptr); malloc(size)。
有着三种不同行为的 realloc,很容易给代码引入 bug。下面举一个例子来说明:
void *ptr = realloc(ptr, new_size);
if (!ptr) {
// 错误处理
}这里就会因为 realloc 的第三种行为引入一个 bug。当 realloc 分配内存失败的时候,ptr 会返回 NULL。但是这时 ptr 原来指向的内存并没有被释放,而 ptr 却已经被赋值为 NULL 了,这就造成了 ptr 原有内存泄漏。
正确的做法应该是:
void *new_ptr = realloc(ptr, new_size);
if (!new_ptr) {
// 错误处理
}
ptr = new_ptr;realloc 只有在分配内存成功的情况下,才会让 ptr 等于 new_ptr。这样,在分配内存失败的情况下,ptr 指向的内存并不会丢失。
realloc 使用不当还会引发其他几种 bug,在此就不一一罗列了。需要吸取的教训就是,慎用 realloc,甚至最好不用 realloc。如果真的需要使用 realloc,一定要确保在 realloc 的三种行为下代码都可以正常工作。
WARNING
realloc具有三种不同行为,极易引入 bug。使用时应先将返回值赋给临时指针,检查非空后再赋值给原指针,以避免内存泄漏。
3.6.2 如何防止内存越界
在日常的编程中,初学者往往会遇到内存越界所引发的问题。其实,通过良好的编程习惯基本上是可以避免内存越界问题的。防范的根本思想在于在对缓冲区(一般为数组)进行拷贝前,要保证复制的长度不要超过缓冲区的空间大小。比如在 memcpy 前,要检查目的地址是否有足够的空间。
使用宏或 sizeof 可保证缓冲长度的一致性:
char dst_buf[64];
memcpy(dst_buf, src_buf, 64);当缓冲大小改变为 32 的时候,需要改动两处代码。一旦忘记修改 memcpy 处的拷贝长度,就会造成内存越界。
对上面的代码进行改善:
#define BUF_SIZE 64
char dst_buf[BUF_SIZE];
memcpy(dst_buf, src_buf, BUF_SIZE);或
char dst_buf[64];
memcpy(dst_buf, src_buf, sizeof(dst_buf));这样就可以做到缓存大小和复制长度的同步修改。
使用安全的库函数也可以保证复制的长度不超过缓冲区的空间,下面来介绍 4 种库函数。
1)使用 strncat 代替 strcat,代码如下:
#include <string.h>
char *strncat(char *dest, const char *src, size_t n);从 src 中最多追加 n 个字符到 dest 字符串的后面。需要注意的是,当 src 包含 n 个以上的字符时,dest 的空间至少为 strlen(dest) + n + 1,因为该函数还会追加字符串结束符 '\0' 到 dest 后面。
下面的示例为正确的写法:
char dest[20] = "hello";
strncat(dest, src, sizeof(dest) - strlen(dest) - 1);一定要记住给 '\0' 留下空间。
2)使用 strncpy 代替 strcpy,代码如下:
#include <string.h>
char *strncpy(char *dest, const char *src, size_t n);从 src 中最多复制 n 个字符到 dest 字符串中。与 strncat 相同的是,当 src 包含 n 个以上的字符时,dest 的空间需要为 n + 1,因为该函数还会再复制一个字符串结束符 '\0'。
下面的示例为正确的写法:
char dest[20];
strncpy(dest, src, sizeof(dest) - 1);3)使用 snprintf 代替 sprintf,代码如下:
#include <stdio.h>
int snprintf(char *str, size_t size, const char *format, ...);snprintf 比前面两个函数 strncat 和 strncpy 更为友好,在往 str 中写数据时,最多会写入 size 字节,其中已包括字符串结束符 '\0'。
正确的示例代码如下:
char str[20];
snprintf(str, sizeof(str), "%s", dest0);4)使用 fgets 代替 gets,代码如下:
#include <stdio.h>
char *fgets(char *s, int size, FILE *stream);危险的 gets 函数从来不检查缓冲区的大小,并且还是从标准输入中读取数据,这是极其危险的行为。再大的缓存空间也无法满足永无终止的标准输入,因此一定要使用 fgets 代替。
fgets 最多会复制 size - 1 字节到缓存 s 中,并且会在最后一个字符后面追加 '\0'。因此如果要读取标准输入,正确的示例代码如下:
char str[20];
fgets(str, sizeof(str), stdin);由于历史原因,标准 C 库中还存在其他不安全的接口,不过后来### 3.6.3 如何定位内存问题
前文主要介绍了如何防范和避免内存问题。但是如果程序里面真的出现了内存问题,我们又该如何定位它,如何找到根本原因呢?
工欲善其事,必先利其器。valgrind作为一个免费且优秀的工具包,提供了很多有用的功能,其中最有名的就是对内存问题的检测和定位。
请看下面的代码:
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
static void mem_leak1(void)
{
char *p = malloc(1);
}
static void mem_leak2(void)
{
FILE *fp = fopen("test.txt", "w");
}
static void mem_overrun1(void)
{
char *p = malloc(1);
*(short*)p = 2;
free(p);
}
static void mem_overrun2(void)
{
char array[5];
strcpy(array, "hello");
}
static void mem_double_free(void)
{
char *p = malloc(1);
free(p);
free(p);
}
static void mem_free_wild_pointer(void)
{
char *p;
free(p);
}
int main()
{
mem_leak1();
mem_leak2();
mem_overrun1();
mem_overrun2();
mem_double_free();
mem_free_wild_pointer();
return 0;
}上面的代码中包含了六种常见的内存问题:
- 动态内存泄漏;
- 资源泄漏,代码中以文件描述符为例;
- 动态内存越界;
- 数组越界;
- 动态内存 double free;
- 使用野指针。
下面来看看怎样执行 valgrind 来检测内存错误:
valgrind --track-fds=yes --leak-check=full --undef-value-errors=yes ./mem_test这段代码中各项的具体含义,可以参看 valgrind --help,其中有些 option 默认就是打开的,不过笔者习惯于明确地使用 option,以示清晰。
下面来看看执行后的报告:
==2326== Memcheck, a memory error detector
==2326== Copyright (C) 2002-2009, and GNU GPL'd, by Julian Seward et al.
==2326== Using Valgrind-3.5.0 and LibVEX; rerun with -h for copyright info
==2326== Command: ./mem_test
==2326==
/* 此处检测到了动态内存的越界,提示 Invalid write */
==2326== Invalid write of size 2
==2326== at 0x80484B4: mem_overrun1 (in /home/fgao/works/test/a.out)
==2326== by 0x8048553: main (in /home/fgao/works/test/a.out)
==2326== Address 0x40211f0 is 0 bytes inside a block of size 1 alloc'd
==2326== at 0x4005BDC: malloc (vg_replace_malloc.c:195)
==2326== by 0x80484AD: mem_overrun1 (in /home/fgao/works/test/a.out)
==2326== by 0x8048553: main (in /home/fgao/works/test/a.out)
==2326==
/* 此处检测到了 double free 的问题,提示 Invalid Free */
==2326== Invalid free() / delete / delete[]
==2326== at 0x40057F6: free (vg_replace_malloc.c:325)
==2326== by 0x8048514: mem_double_free (in /home/fgao/works/test/a.out)
==2326== by 0x804855D: main (in /home/fgao/works/test/a.out)
==2326== Address 0x4021228 is 0 bytes inside a block of size 1 free'd
==2326== at 0x40057F6: free (vg_replace_malloc.c:325)
==2326== by 0x8048509: mem_double_free (in /home/fgao/works/test/a.out)
==2326== by 0x804855D: main (in /home/fgao/works/test/a.out)
==2326==
/* 此处检测到了未初始化变量的问题 */
==2326== Conditional jump or move depends on uninitialised value(s)
==2326== at 0x40057B6: free (vg_replace_malloc.c:325)
==2326== by 0x804853C: mem_free_wild_pointer (in /home/fgao/works/test/a.out)
==2326== by 0x8048562: main (in /home/fgao/works/test/a.out)
==2326==
/* 此处检测到了非法使用野指针 */
==2326== Invalid free() / delete / delete[]
==2326== at 0x40057F6: free (vg_replace_malloc.c:325)
==2326== by 0x804853C: mem_free_wild_pointer (in /home/fgao/works/test/a.out)
==2326== by 0x8048562: main (in /home/fgao/works/test/a.out)
==2326== Address 0x4021228 is 0 bytes inside a block of size 1 free'd
==2326== at 0x40057F6: free (vg_replace_malloc.c:325)
==2326== by 0x8048509: mem_double_free (in /home/fgao/works/test/a.out)
==2326== by 0x804855D: main (in /home/fgao/works/test/a.out)
==2326==
==2326==
/* 此处检测到了文件指针资源的泄漏,下面提示说有 4 个文件描述符在退出时仍是打开的描述符
0、1、2 无须关心,通过报告,可以发现程序中自己明确打开的文件描述符没有关闭 */
==2326== FILE DESCRIPTORS: 4 open at exit.
==2326== Open file descriptor 3: test.txt
==2326== at 0x68D613: __open_nocancel (in /lib/libc-2.12.so)
==2326== by 0x61F8EC: __fopen_internal (in /lib/libc-2.12.so)
==2326== by 0x61F94B: fopen@@GLIBC_2.1 (in /lib/libc-2.12.so)
==2326== by 0x8048496: mem_leak2 (in /home/fgao/works/test/a.out)
==2326== by 0x804854E: main (in /home/fgao/works/test/a.out)
==2326==
==2326== Open file descriptor 2: /dev/pts/4
==2326== <inherited from parent>
==2326==
==2326== Open file descriptor 1: /dev/pts/4
==2326== <inherited from parent>
==2326==
==2326== Open file descriptor 0: /dev/pts/4
==2326== <inherited from parent>
==2326==
==2326==
/* 堆信息的总结:一共调用了 4 次 alloc,4 次 free.之所以正好相等,是因为上面有一个函数少了 free,
有一个函数正好又多了一个 free */
==2326== HEAP SUMMARY:
==2326== in use at exit: 353 bytes in 2 blocks
==2326== total heap usage: 4 allocs, 4 frees, 355 bytes allocated
==2326==
/* 检测到一字节的内存泄漏 */
==2326== 1 bytes in 1 blocks are definitely lost in loss record 1 of 2
==2326== at 0x4005BDC: malloc (vg_replace_malloc.c:195)
==2326== by 0x8048475: mem_leak1 (in /home/fgao/works/test/a.out)
==2326== by 0x8048549: main (in /home/fgao/works/test/a.out)
==2326==
/* 内存泄漏的总结 */
==2326== LEAK SUMMARY:
==2326== definitely lost: 1 bytes in 1 blocks
==2326== indirectly lost: 0 bytes in 0 blocks
==2326== possibly lost: 0 bytes in 0 blocks
==2326== still reachable: 352 bytes in 1 blocks
==2326== suppressed: 0 bytes in 0 blocks
==2326== Reachable blocks (those to which a pointer was found) are not shown.
==2326== To see them, rerun with: --leak-check=full --show-reachable=yes
==2326==
==2326== For counts of detected and suppressed errors, rerun with: -v
==2326== Use --track-origins=yes to see where uninitialised values come from
==2326== ERROR SUMMARY: 5 errors from 5 contexts (suppressed: 12 from 8)
这只是一个简单的示例程序,即使没有 valgrind,我们也可以很轻易地发现问题。但是在真实项目中,当代码量达到万行、十万行甚至百万行时,由于申请的内存可能不是在一个地方被使用,它不可避免地会被传来传去。这时,如果只是靠 review 代码来检查问题,可能很难找到根本原因,而使用 valgrind 则可以很容易地发现问题所在。
3.7 “长跳转” longjmp
C语言中的 goto 语句由于可以直接跳转到函数中的任意一行,因此是一个颇受争议的语句。有人认为它给代码带来了混乱,有人则认为适当地使用 goto 语句可以让代码更简洁、清晰——比如内核代码中就充斥着 goto 语句的使用。关于这点,仁者见仁,智者见智吧。
goto 语句已经引发了这么大的争议,而C库还提供了另外一组接口,用于实现“长跳转”。对比 goto 语句只能在函数内部的“短跳转”,longjmp 可以实现跨函数的“长跳转”。下面我们来详细看看 longjmp 的使用方法。
3.7.1 setjmp 与 longjmp 的使用
我们先来看看 setjmp 的代码:
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);setjmp用于保存当前栈的上下文,将其保存到参数env中。若返回0值,则为setjmp直接返回的结果;若返回非0值,则为从longjmp恢复栈空间时返回的结果。longjmp用于将上下文恢复至env保存的状态,参数val用于作为恢复点setjmp的返回值。一般情况下,保存的jmp_buf env为全局变量。跳转一次后,保存的env上下文环境就会失效。
请看下面的示例:
#include <stdlib.h>
#include <stdio.h>
#include <setjmp.h>
static jmp_buf g_stack_env;
static void func1(void);
static void func2(void);
int main(void)
{
if (0 == setjmp(g_stack_env)) {
printf("Normal flow\n");
func1();
} else {
printf("Longjump flow\n");
}
return 0;
}
static void func1(void)
{
printf("Enter func1\n");
func2();
}
static void func2(void)
{
printf("Enter func2\n");
longjmp(g_stack_env, 1);
printf("Leave func2\n");
}其输出结果为:
Normal flow
Enter func1
Enter func2
Longjump flow
在 main 函数中,使用 setjmp 将当前的栈环境保存到 g_stack_env 中,然后调用 func1->func2,在 func2 中,使用 longjmp 来恢复保存的栈环境 g_stack_env,从而完成“长跳转”。
3.7.2 “长跳转”的实现机制
setjmp 和 longjmp 分别用于保存和恢复栈的上下文,来实现长跳转。而栈的实现肯定是与平台相关的,因此 setjmp 和 longjmp 的实现也是与平台相关的。
先看一下 struct jmp_buf 的定义:
/* Calling environment, plus possibly a saved signal mask. */
struct __jmp_buf_tag
{
/* NOTE: The machine-dependent definitions of `__sigsetjmp'
assume that a `jmp_buf' begins with a `__jmp_buf' and that `__mask_was_saved' follows it. Do not move these members or add others before it. */
__jmp_buf __jmpbuf; /* Calling environment. */
int __mask_was_saved; /* Saved the signal mask? */
__sigset_t __saved_mask; /* Saved signal mask. */
};
typedef struct __jmp_buf_tag jmp_buf[1];x86 平台的 __jmp_buf 的定义为:
# if __WORDSIZE == 64
typedef long int __jmp_buf[8];
# elif defined __x86_64__
typedef long long int __jmp_buf[8];
# else
typedef int __jmp_buf[6];
# endifx86 平台的 setjmp 和 longjmp 的实现均位于 glibc-2.17/sysdeps/i386/setjmp.S 中。
ENTRY (BP_SYM (__sigsetjmp))
ENTER
/* 将 jmpbuf 的地址赋给 eax */
movl JMPBUF(%esp), %eax
CHECK_BOUNDS_BOTH_WIDE (%eax, JMPBUF(%esp), $JB_SIZE)
/* 保存寄存器 */
movl %ebx, (JB_BX*4)(%eax)
movl %esi, (JB_SI*4)(%eax)
movl %edi, (JB_DI*4)(%eax)
leal JMPBUF(%esp), %ecx /* Save SP as it will be after we return. */
#ifdef PTR_MANGLE
PTR_MANGLE (%ecx)
#endif
movl %ecx, (JB_SP*4)(%eax)
movl PCOFF(%esp), %ecx /* Save PC we are returning to now. */
LIBC_PROBE (setjmp, 3, 4@%eax, -4@SIGMSK(%esp), 4@%ecx)
#ifdef PTR_MANGLE
PTR_MANGLE (%ecx)
#endif
movl %ecx, (JB_PC*4)(%eax)
LEAVE /* pop frame pointer to prepare for tail-call. */
movl %ebp, (JB_BP*4)(%eax) /* Save caller's frame pointer. */
#if defined NOT_IN_libc && defined IS_IN_rtld
/* In ld.so we never save the signal mask. */
xorl %eax, %eax
ret
#else
/* Make a tail call to __sigjmp_save; it takes the same args. */
jmp __sigjmp_save
#endif
END (BP_SYM (__sigsetjmp))上面的汇编代码,主要是将寄存器 EBX、ESI、EDI、ESP、PC 和 EBP 保存到 jmp_buf 中。回想前面 __jmp_buf 的定义,它在 x86 32位平台上大小为 6 的 int 型数组,正好用于保存这6个寄存器。
提示
细心的读者会发现这里的汇编是
__sigsetjmp的实现,而不是setjmp的实现。那是因为在glibc库中,setjmp是调用__sigsetjmp来实现的。
看完了 __sigsetjmp 的实现,自然就轮到 longjmp 了:
ENTRY (__longjmp)
movl 4(%esp), %ecx /* User's jmp_buf in %ecx. */
movl 8(%esp), %eax /* Second argument is return value. */
/* Save the return address now. */
movl (JB_PC*4)(%ecx), %edx
LIBC_PROBE (longjmp, 3, 4@%ecx, -4@%eax, 4@%edx)
/* 恢复保存的寄存器 */
movl (JB_BX*4)(%ecx), %ebx
movl (JB_SI*4)(%ecx), %esi
movl (JB_DI*4)(%ecx), %edi
movl (JB_BP*4)(%ecx), %ebp
movl (JB_SP*4)(%ecx), %esp
LIBC_PROBE (longjmp_target, 3, 4@%ecx, -4@%ecx, 4@%edx)
/* Jump to saved PC. */
jmp *%edx
END (__longjmp)setjmp 保存寄存器的内容,longjmp 自然是恢复寄存器的内容。上面的代码很简单,把寄存器 PC、EBX、ESI、EDI、EBP 和 ESP 的内容恢复后,将第二个参数 val 保存到 EAX 中,最后跳转到恢复的 PC 寄存器处——也就是 setjmp 的下一条指令的位置。
3.7.3 “长跳转”的陷阱
从 3.7.2 节对 setjmp 和 longjmp 实现的分析中,我们可以发现,setjmp 和 longjmp 的实现原理就是对与栈相关的寄存器的保存与恢复。那么,变量的情况又是什么样的呢?对于全局变量和 static 变量来说,由于它们都不是保存在栈上的,所以在 longjmp 跳转后,其值不会改变。局部变量的情况又如何呢?
longjmp 的 man 手册给出了如下说明:
当满足以下条件时,局部变量的值是不能确定的:
- 它们是调用
setjmp所在函数的局部变量。 - 其值在
setjmp和longjmp之间有变化。 - 它们没有被声明为
volatile变量。
我们来做一个试验:
#include <stdlib.h>
#include <stdio.h>
#include <setjmp.h>
static jmp_buf g_stack_env;
static void func1(int *a, int *b, int *c);
int main(void)
{
int a = 1;
int b = 2;
int c = 3;
int ret = setjmp(g_stack_env);
if (0 == ret) {
printf("Normal flow\n");
printf("a = %d, b = %d, c = %d\n", a, b, c);
func1(&a, &b, &c);
} else {
printf("Longjump flow\n");
printf("a = %d, b = %d, c = %d\n", a, b, c);
}
return 0;
}
static void func1(int *a, int *b, int *c)
{
printf("Enter func1\n");
++(*a);
++(*b);
++(*c);
printf("func1: a = %d, b = %d, c = %d\n", *a, *b, *c);
longjmp(g_stack_env, 1);
printf("Leave func1\n");
}然后编译运行:
[fgao@ubuntu chapter3]# gcc 4_7_3_longjmp_var.c -Wall
[fgao@ubuntu chapter3]# ./a.out
Normal flow
a = 1, b = 2, c = 3
Enter func1
func1: a = 2, b = 3, c = 4
Longjump flow
a = 2, b = 3, c = 4从结果上看,变量 a、b、c 的值均没有被恢复。这点符合我们的预期,毕竟 longjmp 只是恢复了6个寄存器的内容。
然而当我们加上编译选项 -O2 以后,结果就完全不同了。
[fgao@ubuntu chapter3]# gcc 4_7_3_longjmp_var.c -Wall -O2
[fgao@ubuntu chapter3]# ./a.out
Normal flow
a = 1, b = 2, c = 3
Enter func1
func1: a = 2, b = 3, c = 4
Longjump flow
a = 1, b = 2, c = 3在 longjmp 跳转以后,a、b 和 c 的值仍然是原来的值。
陷阱警示
由于优化可能导致局部变量的值保存在寄存器中,而
longjmp恢复的正是这些寄存器,因此变量的值可能出现“恢复”或“未恢复”的未定义行为。最佳实践:对于可能受longjmp影响的局部变量,应声明为volatile。
除了上面这个缺陷以外,如果我们的思维再开阔些,还能发现由 longjmp 实现原理引发的其他缺陷。比如因为它不能处理局部变量的问题,因此在 C++ 中局部变量的析构肯定也是有问题的。
请看下面的示例:
#include <setjmp.h>
#include <iostream>
using namespace std;
static jmp_buf g_stack_env;
static void func1(void);
class Test {
public:
Test() {
cout << "Constructor" << endl;
}
~Test() {
cout << "Destructor" << endl;
}
};
int main(void)
{
int ret = setjmp(g_stack_env);
if (0 == ret) {
cout << "Normal flow" << endl;
func1();
} else {
cout << "Longjump flow" << endl;
}
}其输出结果为:
[fgao@ubuntu chapter3]# g++ 4_7_3_longjmp_destructor.cpp -Wall
[fgao@ubuntu chapter3]# ./a.out
Normal flow
Enter func1
Constructor
Longjump flow之所以 Test 的析构函数没有被调用,是因为 longjmp 是 glibc 库中的函数,它直接恢复了栈的上下文,因此程序不会调用 Test 的析构函数。
C++ 注意事项
在 C++ 中使用
longjmp会绕过对象析构函数的调用,导致资源泄漏或状态不一致。除非你非常清楚其后果,否则应避免在 C++ 程序中使用longjmp,或者使用 C++ 的异常机制替代。
[Image 551 on Page 126]
[Image 1275 on Page 156]