进程眼中的线性地址空间 and 线程眼中的线性地址空间

从文章的题目我们就知道今天是以一个进程的角度来看待自身的运行环境。我们先提出第一个问题,什么是进程?对于这个问题,各种参考资料上给出的定义都显得过于抽象而难以理解,下面是我自己的定义:

进程是一个动态的概念,它是静态的可执行文件执行过程的描述,其包含了一个静态程序运行时的状态和其所占据的系统资源的总和。

还是很抽象吗?那么,我们可以这样比喻,如果说菜谱是程序代码,厨具是硬件的话,那么炒菜的整个过程就是一个进程。这下理解了吧?那我们继续。

每个程序在启动之后都会拥有自己的虚拟地址空间(Virtual Address Space),这个虚拟地址空间的大小由计算机平台决定,具体一点说由操作系统的位数和CPU的地址总线宽度所决定,其中CPU的地址总线宽度决定了地址空间的理论上限(先不考虑主板…)。

比如32位的硬件平台可编址范围就是0x00000000~0xFFFFFFFF,即就是4GB。而64位的硬件平台达到了理论上0x0000000000000000~0xFFFFFFFFFFFFFFFF的寻址空间,即就是17179869184GB的大小(事实上我自己的64位 Intel Core i3 处理器也仅有36位地址总线而已,因为暂时用不到那么大的物理地址范围)。

为了行文的简单,我就以32位硬件平台来描述吧(事实上我对64位所知甚少,不敢信口开河…),同时指定环境为32位的Linux操作系统。

可能看到这里你反而更迷惑了,我一直在说一个进程拥有4GB的线性地址空间(以下只讨论32位),可是操作系统上同时在运行着N个进程,难不成每个都有4GB的线性地址空间不成?没错,每个都有。我们一直在使用术语“线性地址空间”而非“主存储器(内存)”,因为线性地址空间并非和主存等价。我们平时只要一提到“地址”这个概念,想必大家自然而然的就想到了主存储器。但事实上并非线性地址就一定指向主存储器的物理地址,如果你对“线性地址空间”不理解的话,我建议你先去看看我的另一篇博文《基于Intel 80×86 CPU的IBM PC及其兼容计算机的启动流程》。

其实说到线性地址空间,就不得不提到Intel CPU保护模式下的内存分段和分页,但这偏离了文章的主旨。我们暂时只需要知道,之所以进程拥有独立的4GB的虚拟地址,是因为CPU和操作系统提供了一种虚拟地址到实际物理地址的映射机制,在页映射模式下,CPU发出的是虚拟地址,即进程看到的虚拟的地址,经过MMU(Memory Management Unit)部件转换之后就成了物理地址。

好了,下文中我将假定读者理解了线性地址空间的概念,并认可了每个进程拥有4GB线性地址空间这一事实(物理地址扩展(PAE:Physical Address Extension)技术后面再说)。那么,这4GB的线性地址空间里都有些什么呢?我们画一张图来说明一下。

内存高地址区域是被操作系统内核所占据的,Linux操作系统占据了高地址区域的1GB内存(Windows系统默认保留2GB给操作系统,但是可以配置为保留1GB)。如果我们想知道一个进程具体的内存空间布局的话,可以去/proc目录找以进程的pid所命名的目录下一个叫maps的文件,使用cat命令查看即可(需要root权限)。

我们从图中可以看到,32位Linux系统中,代码段总是从地址0x08048000处开始的。数据段一般是在下一个4KB(分页机制默认选择4KB一个内存页)对齐的地址处开始。运行时堆是在数据段之后又一个4KB对齐处开始的,并通过malloc()函数调用向上增长(Linux下的malloc()一般依靠调用brk()或者mmap()系统调用实现)。再接着跳过动态链接库的区域就是进程的运行时栈了,需要注意的是栈是由高地址向着低地址增长的。栈空间再往上就是操作系统保留区域了,用于驻留内核的代码和数据。即就是在一个进程的眼里,只有它和操作系统在一起。

也许你会问,那么一个进程如何修改另外一个进程的运行时数据呢?比如所谓的外挂程序。我们想想,一个进程不知道另一个进程,那谁知道所有的进程呢?操作系统呗,没错,操作系统提供了这种抽象,它也就拥有访问所有进程地址空间的能力。答案就是,一个进程倘若要修改不属于自己的进程空间的数据,就需要操作系统提供相关的系统调用(或API函数)的支持来实现。

我们具体来看看代码段,以C语言为例,程序代码段的入口_start地址处的启动代码(startup code)是在目标文件ctr1.o(属于C运行时库的部分)中定义的,对于特定平台上的C程序都一样。其执行流程如下:

1
2
3
4
5
6
7
0x080480c0 <_start>:

调用 __libc_init_first 函数
调用 _init 函数
调用 atexit 函数
调用 main 函数
调用 _exit 函数

而我们平时写的main函数只是整个C程序运行过程中所调用的一环而已。

我们给出一段代码来看看一个C语言程序编译链接之后如何安排各个元素的内存位置吧,代码和注释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
int a = 1; // 栈区
static int b = 2; // 全局静态区(读/写段)
const int c = 3; // 只读段
char *str1 = "Hello"; // str1指针在栈区,"hello"字符串在只读段
char str2[] = "world"; // 只在栈区(字符串)
char *str3 = (char *)malloc(20); // str3变量在栈区,指针指向堆区
   
return EXIT_SUCCESS;
}

注释中我们看到了各个元素所在内存段的位置。而编译好的main函数本身是存在于代码区的(一般代码段也是只读段)。我们这个程序运行后如果是动态链接的C语言运行时库的话,动态库会存在图示的动态库映射区。其实无论使用C语言运行时库的程序无论有多少,运行时库的代码在内存里只会有一份。对于不同的程序,进行地址映射即可。

接下来我们简单说说栈(stack),关于栈的基本概念到处都是,如果大家不明白可以自己去查查。其实这里的栈就是把一段位于用户线性地址空间最高处的一段连续内存以栈的思想来使用罢了。大家不要觉得线性空间有4GB,栈占据了很大。其实栈大小默认就几MB罢了。Linux可以在终端下执行 ulimit -a命令查看限制。如图所示:

我这里不过也就默认8192KB(8MB)大小,不过可以使用ulimit命令调整(调整只在本次bash执行过程中有效,下次需要重新设置)。

栈也经常被叫做栈帧(Stack Frame)或者活动记录(Activate Record)。栈里通常存储以下内容:

函数的临时变量;
函数的返回地址和参数;
函数调用过程中保存的上下文。

在i386中,使用esp和ebp寄存器划定范围。esp寄存器始终指向栈顶,随着压栈和出栈操作而改变值。ebp寄存器随着调用过程,暂时的指向一个固定的栈位置,便于寻址操作的进行。

我们画一张图来看看吧:

这里照抄网上的函数调用流程:

  1. 把所有的参数压入栈(有时候是一部分参数,剩余参数通过寄存器传递)
  2. 把当前指令的下一条指令的地址压入栈
  3. 跳转到函数体执行

我继续续上后面的操作:

  1. 在栈里继续创建该函数的临时变量和其他数据
  2. 函数代码执行完之后栈后退到局部变量之上的位置
  3. 恢复之前保存的所有寄存器
  4. 取出原先保存的返回地址,跳转回去
  5. eax寄存器保存了函数的返回值(浮点数是把返回值放在第一个浮点寄存器上%st(0) )

为了不让大家变的过于纠结,我就不贴出相关的汇编代码了,有兴趣的同学可以自己研究编译器生成的汇编语言。具体方法在《编译和链接那点事》和《浅谈缓冲区溢出之栈溢出》中有详细的描述。

好了,本篇暂时结束,下文以后再说。

 

Linux 线程的前世今生

最近在重新翻阅《Unix环境高级编程》的时候,被书上的一段例程所困扰,那段代码是分别在主线程和子线程中使用 getpid() 函数打印进程标识符PID,书上告诉我们是不同的值,但是测试结果是主线程和子线程中打印出了相同的值。

在我的印象中《Linux内核设计与实现》这本书曾经谈到线程时如是说:从内核的角度来说,它并没有线程这个概念。Linux内核把所有的线程都当成进程来实现……在内核中,线程看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,比如地址空间)。

《Unix环境高级编程》第二版著书时的测试内核是2.4.22,而《Linux内核设计与实现》这本书是针对2.6.34内核而言的(兼顾2.6.32),而我的内核是3.9.11,难道是内核发展过程中线程的实现发生了较大的变化?百度一番之后发现资料乱七八糟不成系统,索性翻阅诸多文档和网页,整理如下。如有偏差,烦请大家指正。

在 Linux 创建的初期,内核一直就没有实现“线程”这个东西。后来因为实际的需求,便逐步产生了LinuxThreads 这个项目,其主要的贡献者是Xavier Leroy。LinuxThreads项目使用了 clone() 这个系统调用对线程进行了模拟,按照《Linux内核设计与实现》的说法,调用 clone() 函数参数是clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0),即创建一个新的进程,同时让父子进程共享地址空间、文件系统资源、文件描述符、信号处理程序以及被阻断的信号等内容。也就是说,此时的所谓“线程”模型符合以上两本经典巨著的描述,即在内核看来,没有所谓的“线程”,我们所谓的“线程”其实在内核看来不过是和其他进程共享了一些资源的进程罢了。

通过以上的描述,我们可以得到以下结论:

  1. 此时的内核确实不区分进程与线程,内核没有“线程”这个意识。
  2. 在不同的“线程”内调用 getpid() 函数,打印的肯定是不同的值,因为它们在内核的进程链表中有不同的 task_struct 结构体来表示,有各自不同的进程标识符PID。

值得一提的是,内核不区分线程,那么在用户态的实现就必须予以区分和处理。所以 LinuxThreads 有一个非常出名的特性就是管理线程(manager thread)(这也是为什么实际创建的线程数比程序自己创建的多一个的原因)。管理线程必须满足以下要求:

  • 系统必须能够响应终止信号并杀死整个进程。
  • 以堆栈形式使用的内存回收必须在线程完成之后进行。因此,线程无法自行完成这个过程。终止线程必须进行等待,这样它们才不会进入僵尸状态。
  • 线程本地数据的回收需要对所有线程进行遍历;这必须由管理线程来进行。
  • ……

LinuxThreads 这个项目固然在一定程度上模拟出了“线程”,而且看起来实现也是如此的优雅。所以常常有人说,Linux 内核没有进程线程之分,其实就是这个意思。但这个方法也有问题,尤其是在信号处理、调度和进程间同步原语方面都存在问题。而且, 一组线程并不仅仅是引用同一组资源就够了, 它们还必须被视为一个整体。

对此,POSIX标准提出了如下要求:

  1. 查看进程列表的时候,相关的一组 task_struct 应当被展现为列表中的一个节点;
  2. 发送给这个”进程”的信号(对应 kill 系统调用),将被对应的这一组 task_struct 所共享, 并且被其中的任意一个”线程”处理;
  3. 发送给某个”线程”的信号(对应 pthread_kill ),将只被对应的一个 task_struct 接收,并且由它自己来处理;
  4. 当”进程”被停止或继续时(对应 SIGSTOP/SIGCONT 信号), 对应的这一组 task_struct 状态将改变;
  5. 当”进程”收到一个致命信号(比如由于段错误收到 SIGSEGV 信号),对应的这一组 task_struct 将全部退出;
  6. ……

另外还有好多好多的问题,我们不一一列举,只引用 IBM 的相关论文作为补充:http://www.ibm.com/developerworks/cn/linux/kernel/l-thread/

有问题自然就有人在尝试解决问题,活跃的开源社区自然不会放任问题继续下去,后来就有了各种各样的尝试,其中既包括用户级线程库,也包括核心级和用户级配合改进的线程库。知名的有 RedHat 公司牵头研发的 NPTL(Native Posix Thread Library),另一个则是IBM投资开发的 NGPT(Next Generation Posix Threading),二者都是围绕完全兼容POSIX 1003.1c,同时在核内和核外做工作以而实现多对多线程模型。这两种模型都在一定程度上弥补了 LinuxThreads 的缺点,且都是重起炉灶全新设计的。

在开始下文之前,我们在终端上执行这个命令getconf GNU_LIBPTHREAD_VERSION来检查自己机器所使用的线程库。在我的 fedora 18 上得到了如下的输出结果:

Fedora是RedHat系的,没理由不使用NPTL(开个玩笑)。按照维基百科的说法是,是NPTL赢得了今天附带绝大多数的Linux系统的支持,原文是:NPTL won out and is today shipped with the vast majority of Linux systems. 后来IBM貌似就慢慢放弃了,随着IBM的放弃,RedHat 的 Native POSIX Thread Library(NPTL)就成唯一的解决方案了。随着 NPTL 的崛起,Linux2.6 以及以上版本的内核中基本上很少能再看到 LinuxThreads 的身影了。

与 LinuxThreads 相比,NPTL 具有很多优点:

NPTL 就没有使用管理线程。因为管理线程的一些需求,例如向作为进程一部分的所有线程发送终止信号,是并不需要的,因为内核本身就可以实现这些功能。内核还会处理每个线程堆栈所使用的内存的回收工作。它甚至还通过在清除父线程之前进行等待,从而实现对所有线程结束的管理,这样可以避免僵尸进程的问题。

还有好多的优势和相关的比较,详见这里:

http://www.ibm.com/developerworks/cn/linux/l-threading.html

现在,我们关心的是在 NPTL 对内核作出改动之后,现在的线程模型大概是怎么一回事,内核是否依旧不区分进程与线程呢?getpid() 函数返回的为何是一样的数值?别急,我们继续往下看。

传言在2002年8、9月份,一直不肯松劲的 Linus Torvalds 先生终于被说服了,Ingo Molnar 把一些重要特性加入到2.5开发版官方内核中。这些特性大体包括:新的clone系统调用,TLS系统调用,posix 线程间信号,exit_group (exit的一个变体 )等内容。此时有了OS的支持,Ingo Molnar 先生同 Ulrich Drepper(GLIBC的LinuxThreads库的维护者,NPTL 的设计者与维护者,现工作于 RedHat 公司)和其他一些 Hackers 开始 NPTL 的完善工作。

所以说 NPTL 并不是完全在用户态实现的线程库,事实上内核也进行了一定程度的支持。既然getpid()函数返回了不一样的值,那我们就从这个函数的实现开始研究。因为现代的Linux内核引入了 “Container” 的概念。Container 类似于虚拟机的概念,每个 Container 都会有自己的 namespace。说了这么多,其实意思就是内核中两个 PID namespace 中可以有 PID 相同的进程;一个轻量级进程可以同时出现在两个 namespace 中,这就意味着该轻量级进程具有两个或以上的 PID。而 task_struct(进程控制块PCB) 结构体中还有 group->leader 域来标记该轻量级进程所在组的领头进程。我们今天就先看早前的实现,避免引入太多影响我们偏离主题。现代的实现方法有兴趣的的童鞋访问下面链接研究吧。

http://blog.csdn.net/fengtaocat/article/details/7001527

早些的实现是这样:

对,你没看错,返回的是 TGID 这个成员,而 current 是一个宏,代表当前的程序。这个 TGID 又是何许人也?这个东西的全称是”Thread Group ID”的意思,即线程组ID的意思,其值等于进程的 PID。所以在一个进程的各个线程中调用getpid()函数的话得到的值是一样的。NPTL 通过这样的一个途径实现了之前的线程库没有解决的线程组的问题。

实质上到今天,Linux 内核依旧没有区分进程与线程。这和 Microsoft Windows、或是Sun Solaris等操作系统的实现差异非常大。那么,此时 Linux 内核里那个 task_struct 组成的双向循环链表此时又是什么情景呢?

揣测了一会没有答案,我们还是写个内核模块来看访问下进程表看看。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <linux/kernel.h> 
#include <linux/module.h>
#include <linux/proc_fs.h>
#include <linux/init.h>
#include <linux/sched.h>

static int __init print_init(void)
{
struct task_struct *task;

printk("process info:n");
for_each_process(task)
printk("%s pid:%d tgid:%d father pid:%dn",
thread->comm,
thread->pid,
thread->tgid,
thread->parent->pid);
return 0;
}

static void __exit print_exit(void)
{
printk("Goodbye, process_print!n");
}

module_init(print_init);
module_exit(print_exit);
MODULE_AUTHOR("hurley");
MODULE_DESCRIPTION("Print Process Info.");
MODULE_LICENSE("GPL");

Makefile如下:

但是光有这个程序是不够的,我们再写一个用户态的创建线程的程序,它将创建两个线程,而线程会一直睡眠不退出。代码很简单,就不贴了。我们编译这个程序 thread_id,执行它,然后我们编译内核模块,载入,然后卸载。最后执行dmesg命令查看内核输出:

我们在众多的输出最后找到了我们的程序,可是,只有一项结果,没有多个来自task_struct的输出。这…难道?内核的管理方式发生了改变?等等,我们在内核头文件里我们使用的for_each_process宏下面发现了这样一组宏:

通过继续对宏的展开分析,我们发现原来同一个线程组的线程只有主线程在那个大循环里,而每一个进程的线程在自己的一个小循环里(这里的循环的实现是双向循环链表)。

示意图如下:

我们将遍历部分的代码如下修改:

然后重新执行上面的测试,果然,我们得到了来自三个task_struct结构体的输出:

我们知道,在线程里调用 getpid() 函数,获取的是TGID,那么如果我们需要获得线程真实的 PID 怎么办呢?有一个系统调用是 sys_gettid() 可以帮助我们,不过 GLIBC 并没有提供包装函数,所以我们干脆直接使用 syscall() 函数加系统调用号 224 来实现(另外支持在日志里打线程的 tid,proc 里就能查到相关信息,也便于后期追查)。

结果如下:

我们简单介绍下规则。如果这个 task 是一个”主线程”, 则它的 TGID 等于 PID, 否则 TGID 等于进程的PID(即主线程的PID)。在 clone 系统调用中, 传递 CLONE_THREAD 参数就可以把新进程的 TGID 设置为父进程的 TGID (否则新进程的 TGID 会设为其自身的 PID)。

时间不早了,就此打住。当然了,NPTL 其他的改变和设计还有很多,我就不一一列举了,姑且留下其作者自己写的一篇文章供有兴趣继续深究的同学研究吧。

地址在此:http://www.akkadia.org/drepper/nptl-design.pdf

 

线程眼中的线性地址空间

以前写过一篇《进程眼中的线性地址空间》,这是她的姊妹篇线程篇。而且和以前一样我们只谈32位Linux下的实现。另外读者可能还需要之前的一篇文章《Linux线程的前世今生》作为前期的辅助资料。

如果读者已经看过这两篇文章,那么我们就可以继续往下说了。

我简单列出上述文章中的几个要点:

  1. 32位操作系统下的每个进程拥有4GB的线性地址空间。

  2. 从Linux内核的角度来说,它并没有线程这个概念。在内核中,线程看起来就像是一个普通的进程(只是线程和其他一些进程共享某些资源,比如地址空间)。

暂时有这两点就可以了。我们直接就能从第二点中看出来,一个进程创建的所有线程实际上是都是在它的线性地址空间里运行的。也就是说,一个进程所创建的所有线程没有创建新的地址空间,而是共享着进程所拥有的4G的线性空间罢了。除了地址空间还共享什么呢?大致还有文件系统资源、文件描述符、信号处理程序以及被阻断的信号等内容。不过即便是共享地址空间,但是每个线程还是有自己的私有数据的,比如线程的运行时栈。

线程真的是共享这4G的地址空间吗?口说无凭,咱们来给出实证。我们给出验证代码1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <unistd.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

void *thread_func(void *args)
{
printf("tid: %u pid: %u thread id: %un", getpid(), syscall(224), pthread_self());

while(1) {
sleep(10);
}
}

int main(int argc, char *argv[])
{
pthread_t thread;
int count = 0;

while (pthread_create(&thread, NULL, thread_func, NULL) == 0) {
sleep(1);
count++;
}

perror("Create Error:");
printf("Max Count:%dn", count);

return EXIT_SUCCESS;
}

从代码中我们能看出主线程每休眠一秒就创建一个新的线程,子线程始终睡眠不会退出。

我们在其创建了10来个线程后在终端按下Ctrl+Z键将其放到后台休眠,然后进入/proc目录下用这个进程PID命令的目录,查看maps文件。

这里只是部分输出,我们看到,子线程创建的所有的私有栈(stack:后面的即是线程在内核中拥有的实际PID值)就在其所属进程所拥有的这4G的线性地址空间里。

也许你已经猜到,倘若我们注释掉代码中主函数的sleep()函数,这个程序终将输出32位Linux在默认情况下一个进程所能创建出的线程的总数。注意不要注释掉线程中的sleep()函数,因为我们需要子线程一直存在而且不要占用太多的CPU资源。我们修改代码然后编译执行,结果如下:

我们看到,最后因为内存资源不足无法再创建线程了,总数是381(不过在我的机器上偶尔也会是380),再加上主线程就是382个。我们在《进程眼中的线性地址空间》中就知道一个线程默认的栈大小是8MB,8MB*382就是3056MB,因为其它诸如代码和全局数据也会占据一些空间,抛开内核占据的1GB,所以这些差不多就是用户空间所有的内存了。

P.S. 如果你要问,线程的私有栈在进程的地址空间里在何处分配?如何分配?我的答案是,请自行研究……maps里指明了地址范围的数值,结合进程的地址空间可以分析出来。另外在《Linux线程的前世今生》这篇文章的最后,我给出了NPTL库的两位作者写的文档,你可以参考阅读其中的章节。

上文中我们提到32位Linux默认线程创建的数量是382左右,那么我们想尝试创建更多的线程怎么办呢?修改默认栈大小就可以,我们既可以在代码中设置线程创建时的属性来设置,也可以在终端下使用ulimit命令来设置。

好了,我们继续。既然所有的线程在一个地址空间里,那….A线程在栈里创建的变量能否被B线程修改呢?答案是能,我们看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <unistd.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

int *p_num;

void *thread_1(void *args)
{
int test_num = 1;

printf("test_num: %dn", test_num);

p_num = &test_num;

sleep(2);

printf("test_num: %dn", test_num);
}

void *thread_2(void *args)
{
sleep(1);

if (p_num != NULL) {
*p_num = 2;
}
}

int main(int argc, char *argv[])
{
pthread_t thread1, thread2;

pthread_create(&thread1, NULL, thread_1, NULL);
pthread_create(&thread2, NULL, thread_2, NULL);

pthread_join(thread1, NULL);
pthread_join(thread2, NULL);

return EXIT_SUCCESS;
}

简单起见我没有使用什么条件变量之类的同步手段而是简单的采用sleep()函数来演示,大家明白就好。

编译运行,结果如我们所料。

其实站在共享的角度看,这篇到这里就差不多了,因为在《进程眼中的线性地址空间》中,其他的东西已经有了。虽然我觉得还是没多少干货,但确实也不知道再说些什么了。姑且先发布,以后有补充的再说。

posted @ 2021-10-31 18:12  CharyGao  阅读(48)  评论(0)    收藏  举报