|
我在寒假看 UNIX 源代码时领悟了进程管理部分,顺便也将其他的知识联系起来了,总结了一下写了一篇文章。只是我第一次尝试介绍大系统的一部分,难免说得不清楚。欢迎提出建议。
对于计算机来说,程序只不过是一连串的二进制数据。逻辑上,程序是这样被执行的:首先,计算机把程序的内容读取到内存中,并分为三个段:文本段、数据段和堆栈段。文本段包含了程序中的实际指令,程序计数器 pc 将被初始化为这个段中的某个地址。数据段包含程序中静态的和全局的变量。堆栈段用于保存程序运行时的信息,堆栈指针 sp 被初始化为栈底。然后,计算机把 pc 指向的地址中的指令读到 CPU 的寄存器中,执行它,并将 pc 的值作适当的改变使它指向下一条指令。这个过程被循环直到程序所有的指令全部被执行。特别地,我们关心函数(或者过程)调用时发生的事情。
当一个函数被调用时,我们会这样做:首先,将函数需要的参数压入到堆栈中。压入的顺序并不重要,但调用者和被调用者要有一致的约定。然后,压入被调用的函数完成后将要返回的地址。每次压入的过程都会伴随 sp 的值的改变。最后,将 pc 作适当改变来使它指向被调用函数的第一条指令。这样,CPU 就将执行被调用的函数。这时堆栈中可能还会被压入新的数据,它们是被调用函数中的非静态变量(我称为本地变量)。执行完成后,我们先把函数准备返回的结果放入 CPU 的一个寄存器中,然后从堆栈中取出所有的本地变量,并将这时的栈顶数据(它是函数的返回地址)写入 pc,再取出所有因为调用这个函数而压入到堆栈中的数据。这样,执行就可以从调用函数之前的地方继续。值得说明的是,有时除了参数和返回地址,一些寄存器的值也要被压入堆栈保存起来以便恢复。
然而,我们可以修改上面的过程。当调用一个函数时,我们把要返回的地址和堆栈指针的当前值记录下来,并在将来的某个时刻用这些保存的值修改 pc 和 sp。这样,就可以实现所谓的"非本地跳转"。ISO C 中提供了实现这些操作的函数,它们是 setjmp 和 longjmp;任何一本 C 的参考手册都解释了这两个函数。非本地跳转通常用来实现错误的恢复。介绍这个内容的材料有很多,这里就不赘述了。我们要看看这个特性的其他两个应用,而且可能会有些变形。前一个应用是操作系统中的,后一个则是 C++ 和 Java 对非本地跳转的包装(这继续了非本地跳转的初衷:错误恢复)。
1. 创建新进程 - fork
在这里,我们先来了解一下进程调度;之后我们将会知道 fork 这个令人迷惑的系统调用最初是如何实现的。
在 UNIX 中,进程被定义为程序的可执行映像。在某一时刻,一台计算机上可以有许多进程在运行,这些进程共享由操作系统管理的资源。对于每个进程来说,它都好像在一台计算机上运行,因为操作系统对它隐藏了不必要的细节。这些隐藏的一部分是通过分页来实现的。通常计算机都具有地址转换机制,可以将寄存器中的地址(称为虚地址)映射为实际的地址(称为物理地址)。在不同的环境下,同一个虚地址可以映射为不同的物理地址。虚地址是连续的,比如 0 ~ 4GB(对 Linux 来说),而与连续的一段虚地址对应的物理地址却不一定是连续的。我们把所有虚地址的集合称为虚地址空间,把所有物理地址的集合称为物理地址空间。虚地址空间可以比物理地址空间大。可以把虚地址空间等分成许多段,每一段称为一页。进程访问的每一个地址实际上都是虚地址空间中的一个地址,然后计算机将会把这个虚地址转换为物理地址。通过这种机制,对于进程来说,地址就好像是从 0 开始连续编址的,从而造成了计算机上只有它本身的假象。
我们以早期的 DEC PDP11/40 系统为例,在这个系统上运行的是 UNIX 的第 6 版内核。PDP11/40 的内存大小通常是 256KB;它的 CPU 支持两种状态:内核态和用户态,并且堆栈指针 sp 在这两种状态下可以具有不同的值。PDP11/40 中虚地址空间为 0 ~ 64KB-1,被分为 8 页(即 0 ~ 8KB-1,8KB ~ 16KB-1,......,56KB ~ 64KB-1),并且内核态和用户态下同一页对应于不同的物理地址。在内核态下,第 8 页被用于保存设备的寄存器地址,每个设备的每个寄存器,甚至 CPU 里的寄存器都可以用这些地址访问到。这样,内核态下实际上只有 7 页可用,但却可以利用一页的内存访问所有的设备。在这些寄存器中,有 32 个寄存器用来保存内存的分页信息。它们被分成两组,每组 16 个,分别用来保存内核态和用户态的分页信息。每组的 16 个又被分成 8 个小组,每小组 2 个。其中一个用来保存与某一页首地址对应的物理地址,另一个保存了这一页的其他信息(这里并不重要)。
一个进程的文本段具有最低的虚地址,数据段紧随其后,而堆栈段具有最高的虚地址(为了方便,我们省略了 bss 段)。同时,可执行文件中包含了文本段、数据段的大小信息。创建一个进程时,系统首先读取这些信息,然后根据它们计算与这个进程对应的分页信息,得到每个段开始的虚地址。同时,内核保存了一个进程虚地址 0 对应的物理地址 a0。这样,一旦将一个进程的 a0 与它每个段(文本段、数据段和堆栈段)起始地址相加的结果放入用户态的保存分页信息的寄存器并适当设置 pc 和用户态下的 sp 的值,CPU通过 pc 和 ps 访问的就是这个进程地址空间内的数据,从而使这个进程成为当前活动的进程。
为了理解 UNIX v6 内核是如何调度的,先来分析一下下面三个重要的函数,它们构成了进程创建和调度的关键。
- _savu:
- bis $340,PS
- mov (sp)+,r1
- mov (sp),r0
- mov sp,(r0)+
- mov r5,(r0)+
- bic $340,PS
- jmp (r1)
- _aretu:
- bis $340,PS
- mov (sp)+,r1
- mov (sp),r0
- br 1f
- _retu:
- bis $340,PS
- mov (sp)+,r1
- mov (sp),KISA6
- mov $_u,r0
- 1:
- mov (r0)+,sp
- mov (r0)+,r5
- bic $340,PS
- jmp (r1)
复制代码
这三个函数都是用汇编语言完成的,savu 用来将 r5 和 sp 这两个寄存器的值保存到其参数指定的连续内存中;aretu 和 retu 用来恢复 r5 和 sp 的值。aretu 使用它的参数指定的地址中的内容。而 retu 使用它的参数修改 KISA6,这会使内核态下第 7 页(6+1=7)的起始地址发生变化,从而使与第 7 页对应的物理内存发生变化。u 是操作系统保存进程信息的结构,它的前两个字保存了 r5 和 sp。在这里,_u 是一个常量(140000,或 48KB)。对于每个进程来说,u 这个结构总是位于内核态下的第 7 页。这样,retu 就可以恢复每个进程的 r5 和 sp。读者可能会联想到,在一些情况下,调用 savu 时的参数就是 u 的地址(140000)。
下面就来看看 swtch 这个调度器是如何工作的。我们列出它的代码但不做全部解释:
- /*
- * This routine is called to reschedule the CPU.
- * if the calling process is not in RUN state,
- * arrangements for it to restart must have
- * been made elsewhere, usually by calling via sleep.
- */
- swtch()
- {
- static struct proc *p;
- register i, n;
- register struct proc *rp;
- if(p == NULL)
- p = &proc[0];
- /*
- * Remember stack of caller
- */
- savu(u.u_rsav);
- /*
- * Switch to scheduler's stack
- */
- retu(proc[0].p_addr);
- loop:
- runrun = 0;
- rp = p;
- p = NULL;
- n = 128;
- /*
- * Search for highest-priority runnable process
- */
- i = NPROC;
- do {
- rp++;
- if(rp >= &proc[NPROC])
- rp = &proc[0];
- if(rp->p_stat==SRUN && (rp->p_flag&SLOAD)!=0) {
- if(rp->p_pri < n) {
- p = rp;
- n = rp->p_pri;
- }
- }
- } while(--i);
- /*
- * If no process is runnable, idle.
- */
- if(p == NULL) {
- p = rp;
- idle();
- goto loop;
- }
- rp = p;
- curpri = n;
- /* Switch to stack of the new process and set up
- * his segmentation registers.
- */
- retu(rp->p_addr);
- sureg();
- /*
- * If the new process paused because it was
- * swapped out, set the stack level to the last call
- * to savu(u_ssav). This means that the return
- * which is executed immediately after the call to aretu
- * actually returns from the last routine which did
- * the savu.
- *
- * You are not expected to understand this.
- */
- if(rp->p_flag&SSWAP) {
- rp->p_flag =& ~SSWAP;
- aretu(u.u_ssav);
- }
- /* The value returned here has many subtle implications.
- * See the newproc comments.
- */
- return(1);
- }
复制代码
u_rsav 正是 u 的前两个字。我们看到,swtch 首先保存了调用它的进程的 r5 和 sp(以便将来恢复),然后恢复了 0 号进程(proc[0])的 r5 和 sp(proc[0].p_addr 就是 proc[0] 的 u 的起始地址)。下面的工作是找到另一个进程,它将被转为活动进程。找到这个进程之后,就可以用 retu 来恢复它的 r5 和 sp 了。通过调用 sureg,swtch 设置了将成为活动进程的进程的用户态页面地址寄存器,这样当 CPU 回到用户态时就可以执行这个进程的代码了。
在继续之前,让我们回顾一下 swtch 做的工作:除了找到需要的进程之外,swtch 首先保存了一个进程的 sp(我们不谈 r5 了);而找到一个进程之后,swtch 又恢复了它的 sp。这就是实现 fork 的关键。在这个版本的内核中,fork 是这样写的:
- fork()
- {
- register struct proc *p1, *p2;
- p1 = u.u_procp;
- for(p2 = &proc[0]; p2 < &proc[NPROC]; p2++)
- if(p2->p_stat == NULL)
- goto found;
- u.u_error = EAGAIN;
- goto out;
- found:
- if(newproc()) {
- u.u_ar0[R0] = p1->p_pid;
- u.u_cstime[0] = 0;
- u.u_cstime[1] = 0;
- u.u_stime = 0;
- u.u_cutime[0] = 0;
- u.u_cutime[1] = 0;
- u.u_utime = 0;
- return;
- }
- u.u_ar0[R0] = p2->p_pid;
- out:
- u.u_ar0[R7] =+ 2;
- }
复制代码
fork 首先在进程数组 proc 里寻找“空槽”以判断是否可以创建,然后便调用 newproc 来完成创建工作。下面是 newproc 的代码:
- /*
- * Create a new process-- the internal version of
- * sys fork.
- * It returns 1 in the new process.
- * How this happens is rather hard to understand.
- * The essential fact is that the new process is created
- * in such a way that appears to have started executing
- * in the same call to newproc as the parent;
- * but in fact the code that runs is that of swtch.
- * The subtle implication of the returned value of swtch
- * (see above) is that this is the value that newproc's
- * caller in the new process sees.
- */
- newproc()
- {
- int a1, a2;
- struct proc *p, *up;
- register struct proc *rpp;
- register *rip, n;
- p = NULL;
- /*
- * First, just locate a slot for a process
- * and copy the useful info from this process into it.
- * The panic "cannot happen" because fork has already
- * checked for the existence of a slot.
- */
- retry:
- mpid++;
- if(mpid < 0) {
- mpid = 0;
- goto retry;
- }
- for(rpp = &proc[0]; rpp < &proc[NPROC]; rpp++) {
- if(rpp->p_stat == NULL && p==NULL)
- p = rpp;
- if (rpp->p_pid==mpid)
- goto retry;
- }
- if ((rpp = p)==NULL)
- panic("no procs");
- /*
- * make proc entry for new proc
- */
- rip = u.u_procp;
- up = rip;
- rpp->p_stat = SRUN;
- rpp->p_flag = SLOAD;
- rpp->p_uid = rip->p_uid;
- rpp->p_ttyp = rip->p_ttyp;
- rpp->p_nice = rip->p_nice;
- rpp->p_textp = rip->p_textp;
- rpp->p_pid = mpid;
- rpp->p_ppid = rip->p_pid;
- rpp->p_time = 0;
- /*
- * make duplicate entries
- * where needed
- */
- for(rip = &u.u_ofile[0]; rip < &u.u_ofile[NOFILE];)
- if((rpp = *rip++) != NULL)
- rpp->f_count++;
- if((rpp=up->p_textp) != NULL) {
- rpp->x_count++;
- rpp->x_ccount++;
- }
- u.u_cdir->i_count++;
- /*
- * Partially simulate the environment
- * of the new process so that when it is actually
- * created (by copying) it will look right.
- */
- savu(u.u_rsav);
- rpp = p;
- u.u_procp = rpp;
- rip = up;
- n = rip->p_size;
- a1 = rip->p_addr;
- rpp->p_size = n;
- a2 = malloc(coremap, n);
- /*
- * If there is not enough core for the
- * new process, swap out the current process to generate the
- * copy.
- */
- if(a2 == NULL) {
- rip->p_stat = SIDL;
- rpp->p_addr = a1;
- savu(u.u_ssav);
- xswap(rpp, 0, 0);
- rpp->p_flag =| SSWAP;
- rip->p_stat = SRUN;
- } else {
- /*
- * There is core, so just copy.
- */
- rpp->p_addr = a2;
- while(n--)
- copyseg(a1++, a2++);
- }
- u.u_procp = rip;
- return(0);
- }
复制代码
我们看到,newproc 所作的大部分工作是初始化 proc 这个数组里的某一项,这是初始化进程的工作。在所有的初始化完成之后,它便调用 savu(u.u_rsav) 将 sp 保存起来了。接下来,newproc 返回 0--这是父进程得到的值。同时,系统和父进程都没有对子进程做任何操作,子进程被搁置了。当 swtch 准备使子进程成为活动进程时,就会先恢复先前由 newproc 保存起来的堆栈指针 sp,当时的栈顶是调用 newproc 后应该返回的地址。完成必需的操作之后,swtch 将返回到那里,不过返回的是 1--这就是子进程得到的值。
虽然这个 fork 的行为和我们知道的还不一样,但原理是一样的;非本地跳转在这里起到了关键作用。
2. C++ 的异常处理机制
作为面向应用的高级语言,C++ 在 C 的基础上作了许多扩充,最主要的是面向对象的机制。为了完善这个机制,就需要加入新的异常处理方法。在 C 中,一部分处理是通过调用 setjmp 和 longjmp 用非本地跳转来实现的。C++ (和 Java) 都包装了这个方法,使用 try、throw 和 catch 来完成类似的功能。实现这个功能有许多方法,当然可以基于 setjmp 和 longjmp。这里,我们就用 GCC 中的 C++ 编译器 G++ 的实现来作为例子。不过需要补充的是,我不会 C++,对于程序执行的例外情况并不是很清楚,所以我只描述了程序正常执行时的过程。欢迎读者补充。此外,由于篇幅限制我只会介绍与非本地跳转有关的内容,读者还可能需要查看 gcc 的源代码和最后的参考资料来获得对 C++ 中异常处理的完整认识。
在 C++ 里,异常的抛出和捕获是需要异常处理系统的支持的。作为“GNU 编译器集合”的 GCC 为了简化开发,实现了这个系统。这些代码是通用的,gnat、g++ 都调用了这个系统中的函数。要阅读它们,可以下载 GCC 的源代码,然后查看 gcc 子目录下以 unwind 开头的文件。
我们从下面这段简单的 C++ 程序开始:
- namespace Error {
- struct Exception1 { };
- struct Exception2 { };
- }
- void f(int n)
- {
- if (n >= 0)
- throw Error::Exception1();
- else
- throw Error::Exception2();
- }
- int main(void)
- {
- try {
- f(87);
- } catch (Error::Exception1 e) {
- } catch (Error::Exception2 e) {
- }
- return 0;
- }
复制代码
这段程序没有什么功能,但 g++ 编译产生的代码仍然很复杂。为了展示基于 setjmp 和 longjmp 的实现,我在 Windows 下用 Cygwin 编译了上面的程序。下面就是编译的结果,希望没有吓到读者。
- .file "x.cpp"
- .text
- .align 2
- .globl __Z1fi
- .def __Z1fi; .scl 2; .type 32; .endef
- __Z1fi:
- pushl %ebp
- movl %esp, %ebp
- subl $24, %esp
- cmpl $0, 8(%ebp)
- js L2
- movl $1, (%esp)
- call ___cxa_allocate_exception
- L3:
- movl $0, 8(%esp)
- movl $__ZTIN5Error10Exception1E, 4(%esp)
- movl %eax, (%esp)
- call ___cxa_throw
- L2:
- movl $1, (%esp)
- call ___cxa_allocate_exception
- L6:
- movl $0, 8(%esp)
- movl $__ZTIN5Error10Exception2E, 4(%esp)
- movl %eax, (%esp)
- call ___cxa_throw
- L1:
- .def ___main; .scl 2; .type 32; .endef
- .def __Unwind_SjLj_Resume; .scl 2; .type 32; .endef
- .def ___gxx_personality_sj0; .scl 2; .type 32; .endef
- .def __Unwind_SjLj_Register; .scl 2; .type 32; .endef
- .def __Unwind_SjLj_Unregister; .scl 2; .type 32; .endef
- .align 2
- .globl _main
- .def _main; .scl 2; .type 32; .endef
- _main:
- pushl %ebp
- movl %esp, %ebp
- pushl %edi
- pushl %esi
- pushl %ebx
- subl $92, %esp
- andl $-16, %esp
- movl $0, %eax
- movl %eax, -68(%ebp)
- movl -68(%ebp), %eax
- call __alloca
- movl $___gxx_personality_sj0, -36(%ebp)
- movl $LLSDA5, -32(%ebp)
- leal -28(%ebp), %eax
- leal -12(%ebp), %edx
- movl %edx, (%eax)
- movl $L17, %edx
- movl %edx, 4(%eax)
- movl %esp, 8(%eax)
- leal -60(%ebp), %eax
- movl %eax, (%esp)
- call __Unwind_SjLj_Register
- call ___main
- movl $87, (%esp)
- movl $1, -56(%ebp)
- call __Z1fi
- jmp L9
- L17:
- leal 12(%ebp), %ebp
- movl -52(%ebp), %eax
- movl %eax, -72(%ebp)
- movl -48(%ebp), %edx
- movl %edx, -76(%ebp)
- cmpl $2, -76(%ebp)
- je L10
- cmpl $1, -76(%ebp)
- je L13
- movl -72(%ebp), %eax
- movl %eax, (%esp)
- movl $-1, -56(%ebp)
- call __Unwind_SjLj_Resume
- L10:
- movl -72(%ebp), %edx
- movl %edx, (%esp)
- call ___cxa_begin_catch
- L11:
- call ___cxa_end_catch
- jmp L9
- L13:
- movl -72(%ebp), %eax
- movl %eax, (%esp)
- call ___cxa_begin_catch
- L14:
- call ___cxa_end_catch
- L9:
- movl $0, -64(%ebp)
- L8:
- leal -60(%ebp), %eax
- movl %eax, (%esp)
- call __Unwind_SjLj_Unregister
- movl -64(%ebp), %eax
- leal -12(%ebp), %esp
- popl %ebx
- popl %esi
- popl %edi
- popl %ebp
- ret
- .section .gcc_except_table,""
- .align 4
- LLSDA5:
- .byte 0xff
- .byte 0x0
- .uleb128 LLSDATT5-LLSDATTD5
- LLSDATTD5:
- .byte 0x1
- .uleb128 LLSDACSE5-LLSDACSB5
- LLSDACSB5:
- .uleb128 0x0
- .uleb128 0x3
- LLSDACSE5:
- .byte 0x1
- .byte 0x0
- .byte 0x2
- .byte 0x7d
- .align 4
- .long __ZTIN5Error10Exception1E
- .long __ZTIN5Error10Exception2E
- LLSDATT5:
- .text
- .globl __ZTIN5Error10Exception1E
- .section .rdata$_ZTIN5Error10Exception1E,""
- .linkonce same_size
- .align 4
- __ZTIN5Error10Exception1E:
- .long __ZTVN10__cxxabiv117__class_type_infoE+8
- .long __ZTSN5Error10Exception1E
- .globl __ZTIN5Error10Exception2E
- .section .rdata$_ZTIN5Error10Exception2E,""
- .linkonce same_size
- .align 4
- __ZTIN5Error10Exception2E:
- .long __ZTVN10__cxxabiv117__class_type_infoE+8
- .long __ZTSN5Error10Exception2E
- .globl __ZTSN5Error10Exception2E
- .section .rdata$_ZTSN5Error10Exception2E,""
- .linkonce same_size
- __ZTSN5Error10Exception2E:
- .ascii "N5Error10Exception2E\0"
- .globl __ZTSN5Error10Exception1E
- .section .rdata$_ZTSN5Error10Exception1E,""
- .linkonce same_size
- __ZTSN5Error10Exception1E:
- .ascii "N5Error10Exception1E\0"
- .def ___cxa_end_catch; .scl 3; .type 32; .endef
- .def ___cxa_begin_catch; .scl 3; .type 32; .endef
- .def ___cxa_throw; .scl 3; .type 32; .endef
- .def ___cxa_allocate_exception; .scl 3; .type 32; .endef
复制代码
首先要说明的是,由于 C++ 的名字破坏机制,大多数名字都被修改了。我们的 f 被改成了_Z1fi,Exception1 被改成了 _ZTIN5Error10Exception1E。
注意到函数 main (_main) 在编译后调用了两个函数:_Unwind_SjLj_Register 和 _Unwind_SjLj_Unregister。这两个函数属于 gcc 的基于 setjmp 和 longjmp 的异常处理系统,可以在 gcc 源代码的 gcc/unwind-sjlj.c 里找到。我们只列出 _Unwind_SjLj_Register 的代码:
- void
- _Unwind_SjLj_Register (struct SjLj_Function_Context *fc)
- {
- #if __GTHREADS
- if (use_fc_key < 0)
- fc_key_init_once ();
- if (use_fc_key)
- {
- fc->prev = __gthread_getspecific (fc_key);
- __gthread_setspecific (fc_key, fc);
- }
- else
- #endif
- {
- fc->prev = fc_static;
- fc_static = fc;
- }
- }
复制代码
除去宏定义中的代码,函数 _Unwind_Register 的功能很明显:fc_static 是一个静态的变量,它保存了进程执行时的堆栈帧的上下文信息。这是一个单链表,每当调用一个 C++ 函数时,都会调用 _Unwind_SjLj_Register 向这个链表中添加一项。调用完成准备从函数返回时,就调用 _Unwind_SjLj_Unregister 除去链表中的一项。这些信息将在尝试捕捉异常时有用。
fc_static 是一个指向 SjLj_Function_Context 结构的指针,而 SjLj_Function_Context 结构的定义如下:
- struct SjLj_Function_Context
- {
- /* This is the chain through all registered contexts. It is
- filled in by _Unwind_SjLj_Register. */
- struct SjLj_Function_Context *prev;
- /* This is assigned in by the target function before every call
- to the index of the call site in the lsda. It is assigned by
- the personality routine to the landing pad index. */
- int call_site;
- /* This is how data is returned from the personality routine to
- the target function's handler. */
- _Unwind_Word data[4];
- /* These are filled in once by the target function before any
- exceptions are expected to be handled. */
- _Unwind_Personality_Fn personality;
- #ifdef DONT_USE_BUILTIN_SETJMP
- /* We don't know what sort of alignment requirements the system
- jmp_buf has. We over estimated in except.c, and now we have
- to match that here just in case the system *didn't* have more
- restrictive requirements. */
- jmp_buf jbuf __attribute__((aligned));
- #else
- void *jbuf[];
- #endif
- };
复制代码
这里我们关心的只有 prev 和 jbuf 这两个成员。prev 指向链表的前一个元素,jbuf 保存了与 ISO C 中的 jmp_buf 相同的内容。
仔细研究抛出异常的动作就可以知道 gcc 的异常处理系统是如何工作的。注意上面的 f 的汇编代码(_Z1fi),它先调用 __cxa_allocate_exception 生成了一个异常对象,然后初始化这个对象(这里是 _ZTIN5Error10Exception1E,如果对象是一个类则会调用它的初始化方法),最后调用 __cxa_throw。
__cxa_throw 的代码位于 libstdc++-v3/libsupc++/eh_throw.cc:
- extern "C" void
- __cxa_throw (void *obj, std::type_info *tinfo, void (*dest) (void *))
- {
- __cxa_exception *header = __get_exception_header_from_obj (obj);
- header->exceptionType = tinfo;
- header->exceptionDestructor = dest;
- header->unexpectedHandler = __unexpected_handler;
- header->terminateHandler = __terminate_handler;
- header->unwindHeader.exception_class = __gxx_exception_class;
- header->unwindHeader.exception_cleanup = __gxx_exception_cleanup;
- __cxa_eh_globals *globals = __cxa_get_globals ();
- globals->uncaughtExceptions += 1;
- #ifdef _GLIBCXX_SJLJ_EXCEPTIONS
- _Unwind_SjLj_RaiseException (&header->unwindHeader);
- #else
- _Unwind_RaiseException (&header->unwindHeader);
- #endif
- // Some sort of unwinding error. Note that terminate is a handler.
- __cxa_begin_catch (&header->unwindHeader);
- std::terminate ();
- }
复制代码
可以看到,__cxa_throw 先为异常对象中的某些项赋了值使它可以使用,然后就调用 _Unwind_SjLj_RaiseException 或 _Unwind_RaiseException 来抛出和捕获一个异常。如果执行正常,这两个函数都不返回,直接执行源程序中与 catch 对应的代码。_Uwind_RaiseException 和 _Unwind_SjLj_RaiseException 实际上是一个函数,只是使用了宏定义的方式而已。_Unwind_RaiseException 在 gcc/unwind.inc 里。这里我只做简单的说明,完整的说明可以在最后的参考资料中找到。
总的来说,抛出和捕获异常分为两个阶段:
- 在查找阶段,异常处理系统会尝试找到可以处理被抛出异常的处理者。利用每次调用函数时建立的链表,系统会找到每次函数调用时的堆栈。如果在那里找到了处理者,就进入第二个阶段。
- 在清理阶段,系统每次都会向回跳跃一个栈帧,并执行可能的清理工作。当跳跃到包含处理者的栈帧时,系统就恢复寄存器的状态,控制就跳回到由用户写的处理代码了。
从 _Unwind_RaiseException 的代码中我们可以看到 gcc 的实现:
- _Unwind_Reason_Code
- _Unwind_RaiseException(struct _Unwind_Exception *exc)
- {
- struct _Unwind_Context this_context, cur_context;
- _Unwind_Reason_Code code;
- /* Set up this_context to describe the current stack frame. */
- uw_init_context (&this_context);
- cur_context = this_context;
- /* Phase 1: Search. Unwind the stack, calling the personality routine
- with the _UA_SEARCH_PHASE flag set. Do not modify the stack yet. */
- while (1)
- {
- _Unwind_FrameState fs;
- /* Set up fs to describe the FDE for the caller of cur_context. The
- first time through the loop, that means __cxa_throw. */
- code = uw_frame_state_for (&cur_context, &fs);
- if (code == _URC_END_OF_STACK)
- /* Hit end of stack with no handler found. */
- return _URC_END_OF_STACK;
- if (code != _URC_NO_REASON)
- /* Some error encountered. Ususally the unwinder doesn't
- diagnose these and merely crashes. */
- return _URC_FATAL_PHASE1_ERROR;
- /* Unwind successful. Run the personality routine, if any. */
- if (fs.personality)
- {
- code = (*fs.personality) (1, _UA_SEARCH_PHASE, exc->exception_class,
- exc, &cur_context);
- if (code == _URC_HANDLER_FOUND)
- break;
- else if (code != _URC_CONTINUE_UNWIND)
- return _URC_FATAL_PHASE1_ERROR;
- }
-
- /* Update cur_context to describe the same frame as fs. */
- uw_update_context (&cur_context, &fs);
- }
- /* Indicate to _Unwind_Resume and associated subroutines that this
- is not a forced unwind. Further, note where we found a handler. */
- exc->private_1 = 0;
- exc->private_2 = uw_identify_context (&cur_context);
- cur_context = this_context;
- code = _Unwind_RaiseException_Phase2 (exc, &cur_context);
- if (code != _URC_INSTALL_CONTEXT)
- return code;
- uw_install_context (&this_context, &cur_context);
- }
复制代码
uw_install_context 简单地返回当前的上下文信息 (fc_static)。对 personality 函数的调用则会查找处理者。最后, uw_update_context 使栈帧向上跳跃一次。如果查找成功,_Unwind_RaiseException_Phase2 就会返回 _URC_INSTALL_CONTEXT,而调用 uw_install_context 就会跳跃到用户写的异常处理代码:
- #define uw_install_context(CURRENT, TARGET) \
- do \
- { \
- _Unwind_SjLj_SetContext ((TARGET)->fc); \
- longjmp ((TARGET)->fc->jbuf, 1); \
- } \
- while (0)
复制代码
现在回到我们最初的程序编译出来的汇编代码。一旦捕获了一个异常,就会执行 .L17 中的代码。这里有一个明显的比较动作:
- movl %edx, -76(%ebp)
- cmpl $2, -76(%ebp)
- je L10
- cmpl $1, -76(%ebp)
- je L13
- movl -72(%ebp), %eax
- movl %eax, (%esp)
- movl $-1, -56(%ebp)
- call __Unwind_SjLj_Resume
复制代码
1 和 2 是 g++ 为 Exception1 和 Exception2 产生的不同的标识,通过它,就可以对捕获到的 Exception1 类型的和 Exception2 类型的异常作出不同的处理。此外,如果没有可以处理的例程,就调用 _Unwind_SjLj_Resume,而 _Unwind_SjLj_Resume 会调用 std::terminate 来使程序异常终止。
至于正常执行的路径,汇编代码非常清楚,这里就不说明了。
我们来看看 C++ ABI for Itanium: Exception Handling 举出的 C++ 异常实现的例子,它可以加深对 g++ 产生的汇编代码的理解。下面的 try-catch 块:
- try { foo(); }
- catch (TYPE1) { ... }
- catch (TYPE2) { buz(); }
- bar();
复制代码
可以翻译成这样:
- // In "Normal" area:
- foo(); // Call Attributes: Landing Pad L1, Action Record A1
- goto E1;
- ...
- E1: // End Label
- bar();
- // In "Exception" area;
- L1: // Landing Pad label
- [Back-end generated ompensationcode]
- goto C1;
- C1: // Cleanup label
- [Front-end generated cleanup code, destructors, etc]
- [corresponding to exit of try { } block]
- goto S1;
- S1: // Switch label
- switch(SWITCH_VALUE_PAD_ARGUMENT)
- {
- case 1: goto H1; // For TYPE1
- case 2: goto H2; // For TYPE2
- //...
- default: goto X1;
- }
- X1:
- [Cleanup code corresponding to exit of scope]
- [enclosing the try block]
- _Unwind_Resume();
- H1: // Handler label
- [Initialize catch parameter]
- __cxa_begin_catch(exception);
- [User code]
- goto R1;
- H2:
- [Initialize catch parameter]
- __cxa_begin_catch(exception);
- [User code]
- buz(); // Call attributes: Landing pad L2, action record A2
- goto R1;
- R1: // Resume label:
- __cxa_end_catch();
- goto E1;
- L2:
- C2:
- // Make sure we cleanup the current exception
- __cxa_end_catch();
- X2:
- [Cleanup code corresponding to exit of scope]
- [enclosing the try block]
- _Unwind_Resume();
复制代码
如果读者用 -static 来编译程序并反汇编得到的可执行文件,将会发现凡是 C++ 例程都会在进入以后先调用 _Unwind_Register,并在返回之前调用 _Unwind_Unregister。这样,就保证异常处理系统可以正常工作。这也就是 C 编译器和 C++ 编译器的不同之处。为了方便应用层程序员编写程序,C++ 确实在背后做了许多工作。而异常处理这一部分,是可以用非本地跳转的方式来实现的。
上面我们看到了应用非本地跳转的两个例子。这是一个非常底层的功能:我们需要理解计算机如何执行程序才能用上这个功能,并且要使用它们必须使用汇编语言。但是,它也因此而具有强大的功能,这个功能被聪明的人利用以后成为了许多灾难的来源。大多数程序的漏洞──缓冲区溢出──也是因为返回地址被修改,这也算是非本地跳转的一个鲜明的例子吧。
参考资料
我对应用非本地跳转的理解来自 UNIX 的源代码。有一本非常不错的书,John Lions 的 Commentary on UNIX 6th Edition with Source Code,介绍了第 6 版 UNIX 内核。虽然它已经很老了,但它包含了最初的结构,后来的许多改进都没有摆脱它的影响;此外,通过阅读 UNIX v6 的源代码,一个人可以在很短的时间内对一个完整的操作系统有比较深入的理解。
关于 C++ 的异常处理,我的理解来自 C++ ABI for Itanium: Exception Handling (http://www.codesourcery.com/cxx-abi/abi-eh.html)。我不会 C++,对 C++ 的资料也不熟悉,但希望这个网址对 C++ 的爱好者有帮助。
另一本书是 Randal E. Bryant 和 David O'Hallaron 的 Computer Systems: A Programmer's Perspective。它有中文版,由龚奕利、雷迎春翻译,名为深入理解计算机系统。这本书对计算机系统做了详细和完备的介绍,值得一看。
最后,自由软件带来的开放源代码运动是最好的教室。要更深入地理解 gcc 的异常处理系统,可以看一下 gcc 源代码中 gcc 子目录里 unwind 开头的文件和 libstdc++-v3/libsupc++ 子目录下 eh 开头的文件。 |
|