Blog

mm_struct中控制内存区域位置的成员

Content #

用户态虚拟空间里面有几类数据,例如代码、全局变量、堆、栈、内存映射区等。在 struct mm_struct 里面,有下面这些变量定义了这些区域的统计信息和位置。

unsigned long mmap_base;  /* base of mmap area */
unsigned long total_vm;    /* Total pages mapped */
unsigned long locked_vm;  /* Pages that have PG_mlocked set */
unsigned long pinned_vm;  /* Refcount permanently increased */
unsigned long data_vm;    /* VM_WRITE & ~VM_SHARED & ~VM_STACK */
unsigned long exec_vm;    /* VM_EXEC & ~VM_WRITE & ~VM_STACK */
unsigned long stack_vm;    /* VM_STACK */
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;

total_vm #

总共映射的页的数目。这么大的虚拟地址空间,不可能都有真实内存对应,所以这里是映射的数目。当内存吃紧的时候,有些页可以换出到硬盘上,有的页因为比较重要,不能换出。

...

mm_struct中的task_size

Content #

在 struct mm_struct 里面,有这样一个成员变量:

unsigned long task_size;    /* size of task vm space */

整个虚拟内存空间要一分为二,一部分是用户态地址空间,一部分是内核态地址空间,那这两部分的分界线就由 task_size 来定义。

对于 32 位的系统,内核里面是这样定义 TASK_SIZE 的:

#ifdef CONFIG_X86_32
/*
 * User space process size: 3GB (default).
 */
#define TASK_SIZE    PAGE_OFFSET
#define TASK_SIZE_MAX    TASK_SIZE
#else
/*
 * User space process size. 47bits minus one guard page.
*/
#define TASK_SIZE_MAX  ((1UL << 47) - PAGE_SIZE)
#define TASK_SIZE    (test_thread_flag(TIF_ADDR32) ? \
          IA32_PAGE_OFFSET : TASK_SIZE_MAX)
......

当执行一个新的进程的时候,会做以下的设置:

...

查看进程虚拟地址空间分布

Content #

在Linux下,我们可以通过查看“/proc”来查看进程的虚拟空间分布:

$ ./SectionMapping.elf &
[1] 21963
$ cat /proc/21963/maps
08048000-080b9000 r-xp 00000000 08:01 2801887    ./SectionMapping.elf
080b9000-080bb000 rwxp 00070000 08:01 2801887    ./SectionMapping.elf
080bb000-080de000 rwxp 080bb000 00:00 0          [heap]
bf7ec000-bf802000 rw-p bf7ec000 00:00 0          [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0          [vdso]

第一列是VMA的地址范围;第二列是VMA的权限,“r”表示可读,“w”表示可写,“x”表示可执行,“p”表示私有(COW, Copy on Write),“s”表示共享。第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。

进程中有5个VMA,只有前两个是映射到可执行文件中的两个Segment。

另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。

我们可以看到有两个区域分别是堆(Heap)和栈(Stack),它们的大小分别为 140 KB和88 KB。这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理。栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。

另外有一个很特殊的VMA叫做“vdso”,它的地址已经位于内核空间了(即大于 0xC0000000的地址),事实上它是一个内核的模块,进程可以通过访问这个VMA 来跟内核进行一些通信。

From #

程序员的自我修养

ELF程序头表结构(Elf32_Phdr)

Content #

ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存“Segment”的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:

typedef struct
{
  Elf32_Word	p_type;		/* Segment type */
  Elf32_Off	p_offset;	/* Segment file offset */
  Elf32_Addr	p_vaddr;	/* Segment virtual address */
  Elf32_Addr	p_paddr;	/* Segment physical address */
  Elf32_Word	p_filesz;	/* Segment size in file */
  Elf32_Word	p_memsz;	/* Segment size in memory */
  Elf32_Word	p_flags;	/* Segment flags */
  Elf32_Word	p_align;	/* Segment alignment */
} Elf32_Phdr;

Elf32_Phdr结构体的几个成员与使用“readelf –l”打印文件头表显示的结果一一对应。

如果p_memsz大于p_filesz,就表示该“Segment”在内存中所分配的空间大小超过文件中实际的大小,这部分“多余”的部分则全部填充为“0”,那些额外的部分就是BSS。因为数据段和BSS的唯一区别就是:数据段从文件中初始化内容,而BSS段的内容全都初始化为0。

p_type #

#define PT_LOAD		1	/* Loadable program segment */
#define PT_DYNAMIC	2	/* Dynamic linking information */
#define PT_INTERP	3	/* Program interpreter */
#define PT_NOTE		4	/* Auxiliary information */
#define PT_SHLIB	5	/* Reserved */
#define PT_PHDR		6	/* Entry for header table itself */
#define PT_TLS		7	/* Thread-local storage segment */

p_vaddr #

Segment在进程虚拟地址空间的起始位置。

...

ld默认的链接脚本

Content #

ld 在用户没有指定链接脚本的时候会使用默认链接脚本。我们可以使用下面的命令行来查看ld默认的链接脚本:

$ ld -verbose

默认的ld链接脚本存放在/usr/lib/ldscripts/下,不同的机器平台、输出文件格式都有相应的链接脚本。比如Intel IA32下的普通可执行ELF文件链接脚本文件为elf_i386.x;IA32下共享库的链接脚本文件为elf_i386.xs等。具体可以看每个文件的注释。ld会根据命令行要求使用相应的链接脚本文件来控制链接过程,当我们使用ld来链接生成一个可执行文件的时候,它就会使用elf_i386.x作为链接控制脚本;当我们使用ld来生成一个共享目标文件的时候,它就会使用 elf_i386.xs作为链接控制脚本。

当然,为了更加精确地控制链接过程,我们可以自己写一个脚本,然后指定该脚本为链接控制脚本。比如可以使用-T参数:

$ ld –T link.script

From #

程序员的自我修养

控制链接过程的三种方法

Content #

链接器一般都提供多种控制整个链接过程的方法,以用来产生用户所须要的文件。一般有如下三种方法。

  1. 使用命令行来给链接器指定参数,我们前面所使用的ld的-o、-e参数就属于这类。

  2. 将链接指令存放在目标文件里面,编译器经常会通过这种方法向链接器传递指令。方法也比较常见,只是我们平时很少关注,比如VISUAL C++编译器会把链接参数放在PE目标文件的.drectve段以用来传递参数。

  3. 使用链接控制脚本,使用链接控制脚本方法就是本节要介绍的,也是最为灵活、最为强大的链接控制方法。

From #

程序员的自我修养

静态运行库中一个目标文件只包含一个函数

Content #

为什么静态运行库里面一个目标文件只包含一个函数?比如libc.a里面printf.o 只有printf()函数、strlen.o只有strlen()函数,为什么要这样组织?

链接器在链接静态库的时候是以目标文件为单位的。比如我们引用了静态库中的 printf()函数,那么链接器就会把库中包含printf()函数的那个目标文件链接进来,如果很多函数都放在一个目标文件中,很可能很多没用的函数都被一起链接进了输出结果中。由于运行库有成百上千个函数,数量非常庞大,每个函数独立地放在一个目标文件中可以尽量减少空间的浪费,那些没有被用到的目标文件(函数)就不要链接到最终的输出文件中。

From #

程序员的自我修养

-fno-builtin

Content #

假设“Hello World”程序的源代码为“hello.c”,使用如下方法编译: $gcc –c –fno-builtin hello.c 我们得到了目标文件为“hello.o”,为什么这里要使用“-fno-builtin”参数是因为默认情况下,GCC会自作聪明地将“Hello World”程序中只使用了一个字符串参数的“printf”替换成“puts”函数,以提高运行速度,我们要使用“-fno-builtin”关闭这个内置函数优化选项。

From #

程序员的自我修养

C++的二进制兼容性不好

Content #

C++一直为人诟病的一大原因是它的二进制兼容性不好,或者说比起C语言来更为不易。不仅不同的编译器编译的二进制代码之间无法相互兼容,有时候连同一个编译器的不同版本之间兼容性也不好。比如我有一个库A是公司Company A用 Compiler A编译的,我有另外一个库B是公司Company B用Compiler B编译的,当我想写一个C++程序来同时使用库A和B将会很是棘手。有人说,那么我每次只要用同一个编译器编译所有的源代码就能解决问题了。不错,对于小型项目来说这个方法的确可行,但是考虑到一些大型的项目,以上的方法实际上并不可行。

很多时候,库厂商往往不希望库用户看到库的源代码,所以一般是以二进制的方式提供给用户。这样,当用户的编译器型号与版本与编译库所用的编译器型号和版本不同时,就可能产生不兼容。如果让库的厂商提供所有的编译器型号和版本编译出来的库给用户,这基本上不现实,特别是厂商对库已经停止了维护后,使用这样陈年老“库”实在是一件令人头痛的事。以上的情况对于系统中已经存在的静态库或动态库须要被多个应用程序使用的情况也几乎相同,或者一个程序由多个公司或多个部门一起开发,也有类似的问题。

From #

程序员的自我修养

ABI(Application Binary Interface)

Content #

如果要使两个编译器编译出来的目标文件能够相互链接,那么这两个目标文件必须满足下面这些条件:采用同样的目标文件格式、拥有同样的符号修饰标准、变量的内存分布方式相同、函数的调用方式相同,等等。其中我们把符号修饰标准、变量内存布局、函数调用方式等这些跟可执行代码二进制兼容性相关的内容称为 ABI(Application Binary Interface)。

From #

程序员的自我修养