《程序员的自我修养》第十一章读书笔记

本章正式开始介绍运行库,十分之难的一章,我能给大家分析多少就是多少吧。现在十分佩服这三位写书的大神,同样是研究生,水平差距太多了。这里免不了要提一句题外话,感觉周围人对操作系统原理感兴趣的不多。也许是本人闭门造车,对现在的国内外研究现状了解不深,乱说的几句,还希望大家不要喷我。

好了正式开始今天的主题,本章的一开始先从三个例子出发,我就直接给大家揭晓谜底吧,程序并不是从main函数开始的,其实在前面的章节中就已经提到过,没有main的程序一样能运行,就是得自己写个链接脚本。

接下来看看程序到底是怎么开始的,在glibc的程序入口为_start,这一函数已经被链接到了可执行程序中,通过objdump这一命令可以查看。书中提到_start函数位于start.S这个汇编文件中,不过在书中提到的位置下我并没有找到文件,应该说书中提到的文件夹我就没有找到。所以就采用了个笨办法,在glibc-2.21下搜索所有的start.S,结果发现“/glibc-2.21/sysdeps/x86_64”文件夹下的start.S 就是我要找的(别问我是怎么找到的,一个一个对出来的抓狂)。把这个汇编给大家贴出来一点吧:

/* This is the canonical entry point, usually the first thing in the text
   segment.  The SVR4/i386 ABI (pages 3-31, 3-32) says that when the entry
   point runs, most registers' values are unspecified, except for:

   %rdx        Contains a function pointer to be registered with `atexit'.
        This is how the dynamic linker arranges to have DT_FINI
        functions called for shared libraries that have been loaded
        before this code runs.

   %rsp        The stack contains the arguments and environment:
        0(%rsp)                argc
        LP_SIZE(%rsp)            argv[0]
        ...
        (LP_SIZE*argc)(%rsp)        NULL
        (LP_SIZE*(argc+1))(%rsp)    envp[0]
        ...
                        NULL
*/
以上注释比较关键,清晰的描述了start函数运行前,栈的内存分布。

ENTRY (_start)
	/* Clearing frame pointer is insufficient, use CFI.  */
	cfi_undefined (rip)
	/* Clear the frame pointer.  The ABI suggests this be done, to mark
	   the outermost frame obviously.  */
	xorl %ebp, %ebp //对ebp清零,

	/* Extract the arguments as encoded on the stack and set up
	   the arguments for __libc_start_main (int (*main) (int, char **, char **),
		   int argc, char *argv,
		   void (*init) (void), void (*fini) (void),
		   void (*rtld_fini) (void), void *stack_end).
	   The arguments are passed via registers and on the stack:
	main:		%rdi
	argc:		%rsi
	argv:		%rdx
	init:		%rcx
	fini:		%r8
	rtld_fini:	%r9
	stack_end:	stack.	*/ 通过这一句注释可以知道__libc_start_main函数参数的传递方式。

	mov %RDX_LP, %R9_LP	/* Address of the shared library termination 
				   function.  */ 有关于RDX_LP等的定义,请见同文件夹下的sysdep.h,rdx 寄存器中首先存储的是动态链接库的终止函数,将这一地址传给r9寄存器
#ifdef __ILP32__
	mov (%rsp), %esi	/* Simulate popping 4-byte argument count.  */ 这一句在反汇编没有找到,可见没有定义__ILP32__
	add $4, %esp
#else
	popq %rsi		/* Pop the argument count.  */ rsi指向argc
#endif
	/* argv starts just at the current stack top.  */
	mov %RSP_LP, %RDX_LP //弹出argc后当前栈顶指向argv,将这一地址赋给rdx
	/* Align the stack to a 16 byte boundary to follow the ABI.  */
	and  $~15, %RSP_LP

	/* Push garbage because we push 8 more bytes.  */
	pushq %rax //貌似rax中的数据是没有用的

	/* Provide the highest stack address to the user code (for stacks
	   which grow downwards).  */
	pushq %rsp //经过以上步骤后rsp可能已经指向实际的栈顶了(具体rsp实际指向的值我也不清楚),将rsp的值也压入栈中

#ifdef SHARED
	/* Pass address of our own entry points to .fini and .init.  */
	mov __libc_csu_fini@GOTPCREL(%rip), %R8_LP //__libc_csu_fini函数的地址赋给r8
	mov __libc_csu_init@GOTPCREL(%rip), %RCX_LP //在SHARED情况下,出现了got,估计动态链接器已经完成自举、装载、初始化等工作。因为如果动态链接器没有完成
                                                                                                                    以上工作,那么got中的符号如何重定位

	mov main@GOTPCREL(%rip), %RDI_LP //main函数的地址存放在rdi中,至此__libc_start_main函数的准备工作已经完成。

	/* Call the user's main function, and exit with its value.
	   But let the libc call main.	  */
	call __libc_start_main@PLT //调用函数
#else
	/* Pass address of our own entry points to .fini and .init.  */
	mov $__libc_csu_fini, %R8_LP
	mov $__libc_csu_init, %RCX_LP

	mov $main, %RDI_LP

	/* Call the user's main function, and exit with its value.
	   But let the libc call main.	  */
	call __libc_start_main
#endif

	hlt			/* Crash if somehow `exit' does return.	 */
END (_start)

/* Define a symbol for the first piece of initialized data.  */
	.data
	.globl __data_start
__data_start:
	.long 0
	.weak data_start
	data_start = __data_start
通过对上述程序的分析,可以发现_start函数其实就是为__libc_start_main做准备的函数,一个函数在调用前,需要传递参数,同时根据我们上一章的分析,最基本的两个操作就是将ebp的值压栈(此处,仅将ebp的值清零,并没有将ebp压栈,可能是由于用于不会返回到_start中,因此不需要保存ebp的值以进行清栈操作。若ebp为0并压栈,则根本无法进行清栈操作)。其中还存在一点问题就是程序中并没有将用户参数与环境参数压入堆栈,根据书中所写,这一部分工作是由装载器完成的。这里还要说一点的就是动态链接器的启动要早于用户程序的启动,解释请见以下这篇blog,有些内容还要参考linker & loaders。

http://blog.csdn.net/tigerscorpio/article/details/6227730

今早把rsp的变化过程使用gdb看了看:

rsp 的初始值为0x7fffffffde60,经过popq %rsi后,rsp的值变为0x7fffffffde68。

经过and  $~15, %RSP_LP后,15的二进制为0x1111,其反码为0xfffffffffffffff0,如此与之进行逻辑与运算,则最低位清零,因此rsp的值又变为0x7fffffffde60。

经过pushq %rax后,rsp的值变为0x7fffffffde58。这一步的作用不太明确。

经过pushq %rsp后,rsp的值再向前一部,变为0x7fffffffde50。具体作用还待分析。

好了,让我们看看__libc_start_main,其源文件实现位于:/glibc/csu/libc-start.c

__libc_start_main的主要作用就是完成初始化工作,给大家列一些我看懂了的函数:

__cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);//注册rtld_fini,rtld_fini是与动态加载有关的收尾工作
__libc_init_first (argc, argv, __environ); //根据它的注释,这是libc的准备工作
__cxa_atexit ((void (*) (void *)) fini, NULL, NULL); //注册fini函数,main结束后的收尾工作,就是objdump中的__libc_csu_fini
(*init) (argc, argv, __environ MAIN_AUXVEC_PARAM); //main调用前的初始化工作,就是objdump中的__libc_csu_init
/* Nothing fancy, just call the function.  */
  result = main (argc, argv, __environ MAIN_AUXVEC_PARAM); //特意将它的注释也搬上来了,此处开始调用main函数,等待其返回
exit (result); //执行函数清理工作
再来把exit函数给大家贴出来简单分析一下,它位于:/glibc-2.21/stdlib

void
exit (int status)
{
  __run_exit_handlers (status, &__exit_funcs, true);
}

void
attribute_hidden
__run_exit_handlers (int status, struct exit_function_list **listp,
             bool run_list_atexit)
{
  /* First, call the TLS destructors.  */
#ifndef SHARED
  if (&__call_tls_dtors != NULL)
#endif
    __call_tls_dtors ();

  /* We do it this way to handle recursive calls to exit () made by
     the functions registered with `atexit' and `on_exit'. We call
     everyone on the list and use the status value in the last
     exit (). */
  while (*listp != NULL)
    {
      struct exit_function_list *cur = *listp; //通过其命名就可以知道退出函数列表

      while (cur->idx > 0)
    {
      const struct exit_function *const f =
        &cur->fns[--cur->idx];
      switch (f->flavor)
        {
          void (*atfct) (void);
          void (*onfct) (int status, void *arg);
          void (*cxafct) (void *arg, int status);

        case ef_free:
        case ef_us:
          break;
        case ef_on:
          onfct = f->func.on.fn;
#ifdef PTR_DEMANGLE
          PTR_DEMANGLE (onfct);
#endif
          onfct (status, f->func.on.arg);
          break;
        case ef_at:
          atfct = f->func.at;
#ifdef PTR_DEMANGLE
          PTR_DEMANGLE (atfct);
#endif
          atfct ();
          break;
        case ef_cxa:
          cxafct = f->func.cxa.fn;
#ifdef PTR_DEMANGLE
          PTR_DEMANGLE (cxafct);
#endif
          cxafct (f->func.cxa.arg, status);
          break;
        }
    }

      *listp = cur->next; //指向下一个元素
      if (*listp != NULL)
    /* Don't free the last element in the chain, this is the statically
       allocate element.  */
    free (cur); //执行一个,销毁一个
    }

  if (run_list_atexit) //true,总是执行
    RUN_HOOK (__libc_atexit, ());  根据RUN_HOOK宏,__libc_atexit()函数原型如下,不过我没有找到函数的实现

  _exit (status); //__exit好像是系统调用,不属于glibc
}
至此函数调用的过程就给大家分析到这里,再来给大家总结一下:

_start -> __libc_main_start                                                                           -> exit                            -> _exit

                -> __pthread_initialize_minimal ();                                                  ->__libc_atexit()

                -> __cxa_atexit ((void (*) (void *)) rtld_fini, NULL, NULL);

                -> __libc_init_first (argc, argv, __environ);

                -> __cxa_atexit ((void (*) (void *)) fini, NULL, NULL);

                -> (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM); 


11.2 主要讲解C语言的运行库

所谓运行时库是指程序运行所需要的入口函数、及其所依赖的函数所构成的函数集合。C语言运行库又被称为C运行库(CRT)。

一个C语言运行库大致包含了如下功能:

  1. 启动与退出:包括入口函数及入口函数所依赖的其他函数等。
  2. 标准函数:由C语言标准规定的C语言标准库所拥有的函数实现(像绕口令一样)
  3. I/O:I/O功能的封装和实现,主要是标准输入、标准输出、标准错误的初始化工作
  4. 堆:堆的封装和实现。
  5. 语言实现:语言中一些特殊功能的实现。
  6. 调试:实现调试功能的代码。

glibc 对程序的启动与退出的支持主要是通过 “.init”、".finit"两个段实现的,以上两个段的的主要功能是执行全局/静态对象的构造和析构,运行库会保证所有位于这两个段中的代码会先于/后于main函数执行。链接器在进行链接时,会把所有输入目标文件中的 “.init”和“.finit”按照顺序收集起来,然后将它们合并成输出文件中的 “.init”和“.finit”。但以上两个段还需要一些辅助的代码来帮助他们启动(比如计算GOT之类的),于是引入了两个目标文件分别用来帮助实现初始化函数的crti.o 与  crtn.o。

最终输出文件中的“.init” 与 “.finit”段实际上分别包含_init()与_finit() 这两个函数,让我们先来看看crti.o与crtn.o的内容,命令如下:

 objdump -dr crti.o 

crti.o:     文件格式 elf64-x86-64


Disassembly of section .init:

0000000000000000 <_init>:
   0:	48 83 ec 08          	sub    $0x8,%rsp 
   4:	48 8b 05 00 00 00 00 	mov    0x0(%rip),%rax        # b <_init+0xb>
			7: R_X86_64_GOTPCREL	__gmon_start__-0x4
   b:	48 85 c0             	test   %rax,%rax
   e:	74 05                	je     15 <_init+0x15>
  10:	e8 00 00 00 00       	callq  15 <_init+0x15>
			11: R_X86_64_PLT32	__gmon_start__-0x4

Disassembly of section .fini:

0000000000000000 <_fini>:
   0:	48 83 ec 08          	sub    $0x8,%rsp

 objdump -dr crtn.o 

crtn.o:     文件格式 elf64-x86-64


Disassembly of section .init:

0000000000000000 <.init>:
   0:    48 83 c4 08              add    $0x8,%rsp
   4:    c3                       retq   

Disassembly of section .fini:

0000000000000000 <.fini>:
   0:    48 83 c4 08              add    $0x8,%rsp
   4:    c3                       retq  
通过对以上两个目标文件的初步分析可以发现,crti.o与crtn.o组成了“.init” 与 “.finit”段的开始和结尾部分,也就是_init()与_finit()的开头和结尾部分。好了第一个问题来了,为什么glibc要大费周章,一个_init()与_finit()还要分为几个不同的部分?我个人觉得,由于每个程序的初始化与析构情况均不相同,因此在初始化时先运行crti.o中.init 段中的代码,而后运行具体的初始化函数,最后运行crtn.o中.init 段中的程序:先回收栈空间,然后返回。在析构时程序运行的流程类似,先运行crti.o中.finit 段中的代码,然后运行具体的析构函数,最后运行crtn.o中.finit 段中的程序。好了,第二个问题实际上也得到了解决,为使程序可以对不同的对象进行初始化与析构操作,可以把以上三段程序在链接时拼接到一起:

  1. crti.o中.init 段中的代码
  2. 具体的构造函数
  3. crtn.o中.init 段中的代码

析构的情况与之类似。

书中还提到了与c++全局/静态对象初始化与析构相关的crtbeginT.o 与crtend.o,这两个程序在我的机器上位于/usr/lib/gcc/x86_64-linux-gnu/4.9/文件夹下。这两个程序的存在是配合glibc实现C++的全局构造与析构,由于glibc只是一个c语言运行库,它对c++的实现并不了解。实际上,crti.o 与 crtn.o 中的.init 和 .finit 提供了一个在main之前和之后运行代码的机制,而真正的全局构造和析构则由crtbeginT.o 与crtend.o来实现。

这里要注意一点,crtbeginT.o 与crtend.o是c++初始化与析构过程中所使用的函数,与c语言无关,通过一个简单的实验验证以下,程序源码如下:

#include <stdio.h>

int glo = 1;

int main()
{
	printf("%d\n",glo);
	return 0;
}

程序很简单,但包括一个全局变量的初始化,再来看看init段的内容:

Disassembly of section .init:

00000000004003e0 <_init>:
  4003e0:	48 83 ec 08          	sub    $0x8,%rsp
  4003e4:	48 8b 05 0d 0c 20 00 	mov    0x200c0d(%rip),%rax        # 600ff8 <_DYNAMIC+0x1d0>
  4003eb:	48 85 c0             	test   %rax,%rax
  4003ee:	74 05                	je     4003f5 <_init+0x15>
  4003f0:	e8 3b 00 00 00       	callq  400430 <__gmon_start__@plt>
  4003f5:	48 83 c4 08          	add    $0x8,%rsp
  4003f9:	c3                   	retq   

前五条指令的内容与crti.o中init段的内容相同,最后两条指令与crtn.o中init段的内容相符。再来看看finit段的内容:

Disassembly of section .fini:

00000000004005d4 <_fini>:
  4005d4:	48 83 ec 08          	sub    $0x8,%rsp
  4005d8:	48 83 c4 08          	add    $0x8,%rsp
  4005dc:	c3                   	retq   

前一条指令的内容与crti.o中init段的内容相同,最后两条指令与crtn.o中init段的内容相符。最后通过gdb跟踪调试程序,发现__gmon_start__函数也没有被调用。

好了,问题来了,既然c语言程序实际上并没有执行什么初始化与析构操作,那么c语言全局数据的初始化工作是什么时候进行的?我们已经知道的是data数据段存放的是已经初始化的全局变量与局部静态变量,所以我们可以通过一个简单的实验来验证以下,代码如下:

#include <stdio.h>

int glo = 3;


int main()
{
	glo = 2;
	printf("%d\n",glo);
	return 0;
}

编译,反汇编结果如下:

gcc -c -o test_crt test_crt.c
objdump -s test_crt
Contents of section .data:
 0000 03000000                             ....           

可以看到,在编译阶段,全局变量的初始化工作已经完成。

11.3 主要介绍运行库与多线程之间的关系

首先来看看哪些资源被栈所共有,哪些资源被进程所拥有:

1)线程私有资源

  1. 栈,从C语言程序员的角度来看,就是栈上的局部变量;
  2. 寄存器,由于函数参数保存在寄存器中,所以寄存器同样是线程私有资源;
  3. 线程局部存储(Thread Local Storage,TLS)。线程局部存储是某些操作系统为线程单独提供的私有空间,但通常只具有很有限的容量。

2)进程所有资源

  1. 全局变量
  2. 堆上的数据
  3. 函数里的静态变量
  4. 程序代码,任何线程都有权利读取并执行任何代码
  5. 打开文件,A线程打开的文件可以由B线程读写。

多线程运行库应该具备两方面的功能,一方面是提供多线程操作的接口,如线程创建、退出,设置线程优先级等函数接口;另一方面,C运行库本身要能够在多线程环境下运行。

因此为解决以上第二点问题,运行库一般使用的改进方法是:

  1. 使用TLS,书中给出的例子是errno,每个线程有其独立的errno。如此,就可以在多个线程同时调用一个函数时,相互之间errno不会互相影响。
  2. 加锁,给出一个例子就是malloc函数。
  3. 改进函数调用方式。这个方法虽然可以解决线程安全问题,但会带来程序的兼容性问题,不同操作系统下,所使用的线程安全函数可能不同,因此改进函数调用方式并不是一个解决线程安全问题的好方法。

给大家分享一些有关于线程局部存储的有关内容:

http://blog.csdn.net/absurd/article/details/886506

http://blog.csdn.net/cywosp/article/details/26469435

接下来简单看看Linux下TLS的实现(这部分内容书中没有,我也就是参考着书中的分析方法进行一些简单分析,欢迎大家补充)。

首先来看看程序源码:

#include <stdio.h>

__thread int number;

int main()
{
	return 0;
}

GCC使用__thread关键字声明线程局部变量。编译器会将这部分变量放入tbss段,使用“objdump -h”命令查看内容,结果如下:

17 .tbss         00000004  0000000000600e0c  0000000000600e0c  00000e0c  2**2
                  ALLOC, THREAD_LOCAL
通过观察其属性可以发现,这是一个“THREAD_LOCAL”类型的变量,基本可以印证TLS变量位于这个段中。段的大小为4,正好与数据的总长度相吻合。对于这个段的命名我还想谈一点我的猜测,number是没有初始化的全局变量,所以放在tbss段,那么初始化的变量是否放在tdata段中?可以通过实验进行验证,源码如下:

#include <stdio.h>

__thread int number=1;

int main()
{
	return 0;
}

objdump的运行结果也印证了我的猜测。

 17 .tdata        00000004  0000000000600e0c  0000000000600e0c  00000e0c  2**2
                  CONTENTS, ALLOC, LOAD, DATA, THREAD_LOCAL

为了了解TLS的实现方法,我们将上述程序做一些修改,源码如下:

#include <stdio.h>

__thread int number;
__thread int number2=2;

int main()
{
	number = 2;
	number2 = 2;
	return 0;
}

反汇编结果如下:

0000000000400526 <main>:
  400526:	55                   	push   %rbp
  400527:	48 89 e5             	mov    %rsp,%rbp
  40052a:	64 c7 04 25 f8 ff ff 	movl   $0x2,%fs:0xfffffffffffffff8
  400531:	ff 02 00 00 00 
  400536:	64 c7 04 25 fc ff ff 	movl   $0x2,%fs:0xfffffffffffffffc
  40053d:	ff 02 00 00 00 
  400542:	b8 00 00 00 00       	mov    $0x0,%eax
  400547:	5d                   	pop    %rbp
  400548:	c3                   	retq   
  400549:	0f 1f 80 00 00 00 00 	nopl   0x0(%rax)

可以看到2这个值,分别赋值到了“%fs:0xfffffffffffffff8”与“%fs:0xfffffffffffffffc”这两个地址处,感觉上“fffffffffffffff8”与“fffffffffffffffc”是补码形式,算过来就是“-8”与“-4”,分别代表第一个元素与第二个元素,所以我猜测此处fs应该是TEB(Thread Environment Block)的地址,而TEB偏移量为0的地方保存着TLS数组的首地址(这一点是我自己的推测,我也没想到什么方法验证)。TLS数组的第二项分别是number与number2。

接下来看看glibc全局构造与析构

这一节我能分析多少就分析多少吧,我现在的实验环境与书中所写的情况差距比较大,

根据前几小节的分析,_start 首先调用 __libc_main_start,然后调用(*init) (argc, argv, __environ MAIN_AUXVEC_PARAM),这个函数实际上就是__libc_csu_init,__libc_csu_init由调用_init(),这个函数实际上就是“.init”段中的代码,可以通过一个实验简单验证以下,程序源码如下:

class HelloWorld
{
public:
    HelloWorld();
    ~HelloWorld();
};

class Hello
{
public:
    Hello();
    ~Hello();
};

HelloWorld HW;
HelloWorld HW1;

Hello H;
Hello H1;

HelloWorld::HelloWorld()
{
    
}

HelloWorld::~HelloWorld()
{

}

Hello::Hello()
{

}

Hello::~Hello()
{

}


int main()
{
    return 0;
}

编译并调试,通过stepi逐步跟踪程序的运行,可以确认_init()函数确实调用的就是“.init”段的代码,“.init”段的反汇编代码如下:

00000000004003e8 <_init>:
  4003e8:	48 83 ec 08          	sub    $0x8,%rsp
  4003ec:	48 8b 05 05 0c 20 00 	mov    0x200c05(%rip),%rax        # 600ff8 <_DYNAMIC+0x1d0>
  4003f3:	48 85 c0             	test   %rax,%rax
  4003f6:	74 05                	je     4003fd <_init+0x15>
  4003f8:	e8 33 00 00 00       	callq  400430 <__gmon_start__@plt> //通过跟踪运行,发现__gmon_start__并没有被调用。__gmon_start__仅调用一次,具体在什么时候
调用就不得而知了。
  4003fd:	48 83 c4 08          	add    $0x8,%rsp
  400401:	c3                   	retq   

__gmon_start__的源代码就不给大家贴出来,没有什么作用,位于/glibc-2.21/csu/gmon-start.c中。到这一步与书中的内容还是一致的。

接着研究glibc的全局构造与析构,书中给出的__do_global_ctors_aux函数,我并没有找到,但发现helloworld类的全局构造函数:<_GLOBAL__sub_I_HW>。同时“.ctor”段我也没有找到。但继续跟踪执行<_GLOBAL__sub_I_HW>会发现,<_GLOBAL__sub_I_HW>首先调用__static_initialization_and_destruction_0 (__initialize_p=1, __priority=65535)函数,这个函数位于helloworld.cpp(也就是我们所编写的函数),由这个函数负责调用全局变量的初始化函数。所以我推测,glibc2.21全局变量的初始化策略可能是由某个程序对于需要初始化的全局变量进行收集,而后由__static_initialization_and_destruction_0函数负责依次调用这些初始化函数。

给大家再分享两篇有点关系的文章:

http://www.tuicool.com/articles/QRBF3qN

http://blog.csdn.net/dadoneo/article/details/8201452

好了,初始化的过程就给大家简单分析到了这里,我手上的线索也不是很多。在给大家简单分析一下析构的过程。

全局变量的析构工作通过“__cxa_atexit”函数进行注册,同时该函数还可以保证全局对象先构造后析构的顺序。

11.5 节主要分析fread的实现,但书中主要分析的是windows下的实现,有时间再给大家详细分析linux下fread的实现。




  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值