当前位置: 首页 > article >正文

计算机系统原理:一些断言

0 虚拟机和解释器

在Java中,JVM既充当了一个虚拟机的角色,也包含了用于执行字节码的解释器。同样地,Python的CPython实现也是先将源代码编译成字节码,然后由Python虚拟机执行。

1 从源代码中提取token的过程就是词法分析

词法分析是编译过程的第一个阶段,它的主要职责是从源代码中读取字符序列,并根据语言的词法规则将它们组合成具有独立意义的最小语法单元——Token。词法分析器会去除无关紧要的空白字符、注释等,并为每一个识别出的Token赋予一个类型标识符,例如关键字、标识符、运算符、分隔符或字面量等。这个过程可以使用正则表达式来定义每种Token的模式,从而实现高效的模式匹配。在某些情况下,词法分析器还可以执行初步的错误检测,如非法字符的报告。

2 扫描token后生成语法树的过程就是语法分析

一旦词法分析完成并生成了Token流,接下来就是语法分析阶段。语法分析器的任务是按照编程语言的语法规则,将Token序列组织成语法结构,如表达式、语句、函数定义等。这通常涉及到构建一棵抽象语法树(AST),它是一种层次化的数据结构,用于表示程序的语法构造。语法分析可以通过多种方法实现,包括自顶向下解析(如递归下降解析)和自底向上解析(如LR解析)。在这个过程中,如果发现Token序列不符合预期的语法结构,则会触发语法错误。

3 检查语法树是否合理的过程就是语义分析

语义分析是在语法分析之后进行的一个重要步骤,它的目的是确保程序不仅在语法上是正确的,而且在逻辑上也是合理的。具体来说,语义分析器会检查变量是否已被声明、类型是否匹配、作用域是否正确等问题。此外,它还会收集有关标识符的信息,比如它们的类型、位置以及与其他实体的关系。通过这些操作,编译器能够验证程序的行为是否符合预期,并为后续的优化和代码生成做好准备。值得注意的是,语义分析并不直接生成目标代码,而是作为中间步骤帮助编译器更好地理解源代码的意义。

4 确保被引用的内容存在的过程就是符号决议

符号决议是指在整个编译过程中,确保所有被引用的名字都指向有效的定义。这意味着当编译器遇到一个标识符时,它需要确定该标识符指的是哪个具体的实体,例如变量、函数或类成员。这一过程通常涉及到查找符号表,这是一个包含所有已知标识符及其属性的数据结构。符号决议对于处理跨文件或模块间的依赖关系尤为重要,因为它保证了即使是在不同的编译单元之间,也能正确地解析和链接符号。

5 修正符号内存地址的过程以及将声明转换为实现的过程就是重定位

重定位是一个与链接紧密相关的概念,它发生在程序的不同部分被组装成最终可执行文件的过程中。简单地说,重定位就是调整指令和数据项的地址,使得它们可以在运行时正确地访问内存中的资源。对于静态链接而言,重定位是在编译期间完成的;而对于动态链接,则是在加载时由操作系统负责。在某些情况下,重定位还可能涉及到修正符号的绝对地址,以适应特定的内存布局或共享库的位置变化。

6 编译器编译出的目标文件包含十二个部分。编译器每当遇到一个不能确定最终运行时的内存地址的变量时就会记录下来,指令相关的放在.rel.text,数据相关的放在.rel.data,这两个东西都包含在目标文件中,里面记录了相对于起始地址的偏移量,链接器会在生成可执行文件时替换相对地址为绝对地址。

在这里插入图片描述
这张图描述了一个典型的 ELF 可重定位目标文件的格式,ELF头以一个 16 字节的序列开始,这个序列描述了生成该文件的系统的字的大小和字节顺序。ELF 头剩下的部分包含帮助链接器语法分析和解释目标文件的信息。其中包括 ELF 头的大小、目标文件的类型(如可重定位、可执行或者共享的)、机器类型(如 x86-64)、节头部表(section header table)的文件偏移,以及节头部表中条目的大小和数量。不同节的位置和大小是由节头部表描述的,其中目标文件中每个节都有一个固定大小的条目(entry)。
夹在 ELF 头和节头部表之间的都是节。一个典型的 ELF 可重定位目标文件包含下面几个节:

1. .text:已编译程序的机器代码

.text段用于存放程序的执行代码,即经过编译后的机器指令。这部分数据在程序加载到内存后通常是只读的,以防止意外修改。这意味着一旦程序开始运行,.text段的内容不应该被改变,从而保证了程序执行的一致性和安全性。此外,.text段还可以包含一些只读的常数变量,例如字符串常量等,这取决于具体的编译器实现。

2. .rodata:只读数据

.rodata段专门用于存储只读数据,如printf语句中的格式串、开关语句的跳转表以及其他任何形式的常量数据。这类数据的特点是在程序运行期间不会发生改变,因此可以安全地放置在只读存储区域中。这样做不仅提高了程序的安全性,还能够优化内存使用,因为多个进程可以共享同一份只读数据。

3. .data:已初始化的全局和静态C变量

.data段用来保存那些已经在源代码中赋予了初始值的全局变量和静态局部变量。与.bss段不同的是,.data段中的变量在编译时就已经确定了它们的具体值,并且这些值会被直接写入目标文件中。当程序启动时,操作系统会将.data段的内容从磁盘加载到内存中,确保变量具有正确的初始状态。

4. .bss:未初始化的全局和静态C变量

.bss段负责管理那些声明但未赋初值的全局变量和静态局部变量,以及所有被初始化为0的全局或静态变量。值得注意的是,尽管这些变量在程序中占有一定的空间,但在目标文件中它们并不占用实际的磁盘空间,而只是作为一个占位符存在。这是因为未初始化的变量默认值为零,所以不需要显式地存储它们的值。当程序加载到内存时,操作系统会自动为这些变量分配相应的内存并设置其初始值为零。

5. .symtab:符号表

符号表(Symbol Table),即.symtab段,记录了程序中定义的所有符号(如函数名、变量名等)及其相关信息,包括符号的地址偏移量、大小、类型(函数还是数据)、绑定属性(局部还是全局)等。每个可重定位目标文件都包含一个.symtab段,除非通过strip命令将其移除。符号表对于链接器来说非常重要,因为它允许不同模块之间的符号引用得以正确解析和连接。然而,.symtab不包含局部变量的信息,因为局部变量的作用范围仅限于单个函数内部,不会影响到其他部分。

6. .rel.text:文本段的重定位信息

.rel.text段包含了针对.text段中需要修改的位置列表,主要用于支持链接过程中的符号解析。具体来说,每当程序中调用外部函数或引用全局变量时,对应的指令地址就需要在链接时进行调整,以指向正确的内存位置。而对于本地函数调用,则不需要这样的重定位处理,因为它们的相对地址可以在编译时确定。

7. .rel.data:数据段的重定位信息

类似于.rel.text.rel.data段提供了关于.data段中需要重定位的信息,特别是那些初始值依赖于全局变量地址或外部定义函数地址的已初始化全局变量。这种重定位机制确保了即使在不同的编译单元之间,变量和函数之间的相对关系也能得到正确的维护。

8. .debug:调试符号表

调试符号表(Debug Symbol Table),即.debug段,包含了详细的调试信息,如局部变量、类型定义、全局变量和原始C源文件的映射关系等。这些信息对于开发人员在调试过程中追踪问题非常有用。不过,.debug段只有在编译时指定了调试选项(如GCC中的-g)才会生成,否则为了减小文件大小,默认情况下不会包含此类数据。

9. .line:行号映射表

.line段建立了原始C源程序中的行号与.text节中机器指令之间的对应关系,这对于调试工具来说至关重要。通过这种方式,开发者可以在调试时准确地找到某条机器指令对应的源代码位置,从而更方便地理解程序的行为。同样地,.line段也仅在启用调试选项时才会出现在目标文件中。

10. .strtab:字符串表

字符串表(String Table),即.strtab段,是一个由以null结尾的字符串组成的序列,主要用于存储符号表和调试信息中的字符串内容,如符号名称、节名称等。此外,它还可能包含其他需要作为字符串处理的数据。字符串表的存在有助于减少重复字符串的存储,进而节省空间。

11. 重定位过程

在链接阶段,链接器会读取所有输入的目标文件,并根据它们各自的符号表和重定位条目来构建最终的可执行文件。首先,链接器会合并来自不同文件的相同类型的节(section),例如所有的 .text 节会被合并成一个新的 .text 节,所有的 .data 节也会被合并。然后,链接器会为每个合并后的节分配运行时地址,并为每个符号确定唯一的地址。最后,链接器会遍历每一个重定位条目,按照指定的方式修改原始地址,使其指向正确的运行时位置。对于绝对地址引用,链接器会直接用符号的实际地址替换原有的占位符;而对于相对地址引用,则需要考虑当前指令或数据项与目标符号之间的距离差。

7 链接器只关心全局变量,链接器要确保所有目标文件引用的外部符号都有定义

链接器在编译和链接过程中扮演着至关重要的角色,它负责将多个目标文件组合成一个完整的可执行文件或库。在这个过程中,链接器只关心全局变量和函数等外部符号,确保所有目标文件中引用的外部符号都有唯一的定义。具体来说,链接器会检查每个目标文件中的符号表,以确定是否存在未解析的外部符号,并尝试通过其他目标文件或库来解决这些引用。如果某个符号在多个地方被定义,则链接器需要遵循一定的规则来选择正确的定义,例如强符号优先于弱符号。

8 静态库在Windows下是.lib,Linux下是.a;动态库在Windows下是.dll,Linux下是.so

静态库(Static Library)和动态库(Dynamic Library)是两种不同类型的库文件,它们的主要区别在于链接方式和最终生成的可执行文件特性。静态库在Windows下通常以.lib为扩展名,在Linux下则使用.a作为后缀。静态库实际上是一个或多个目标文件(.o或.obj)的集合,它们已经被编译但尚未链接。当静态库被链接到项目中时,链接器会将静态库中的代码直接复制到最终的可执行文件中,这意味着生成的可执行文件包含了静态库的所有内容,因此体积较大,但独立性强,不依赖外部库文件。

相比之下,动态库在Windows下以.dll为扩展名,在Linux下则是.so。动态库允许程序在运行时加载所需的库,而不是在编译时将其嵌入到可执行文件中。这不仅减少了可执行文件的大小,还使得库可以被多个应用程序共享,从而节省内存资源。此外,动态库还可以独立更新,无需重新编译依赖它的应用程序。然而,这也意味着程序在运行时必须能够找到并正确加载所需的动态库,否则可能会导致“找不到DLL”之类的错误。

9 可执行文件就是二进制文件,可执行文件中会完整的包含静态库

可执行文件是指那些可以直接由操作系统加载并执行的文件。这类文件通常是二进制格式,包含了CPU可以直接执行的机器指令以及必要的数据段。对于Windows系统而言,常见的可执行文件扩展名包括.exe、.dll、.sys等;而在Linux系统中,可执行文件可能没有明确的扩展名,但通常会有一个ELF(Executable and Linkable Format)文件头,并且设置了适当的执行权限。值得注意的是,可执行文件不仅仅包含编译后的机器码,还包括了诸如导入表、导出表、重定位信息等元数据,这些都是为了支持程序的正确加载和执行16。

当静态库被链接到可执行文件时,其所有的代码和数据都会成为可执行文件的一部分,这意味着生成的文件中已经完整地包含了静态库的内容。而当涉及到动态库时,情况就有所不同了。可执行文件中只会包含对动态库中函数和变量的引用信息,即所谓的导入表。这些引用会在程序启动时由加载器解析,或者在程序运行期间通过显式的API调用(如dlopen、dlsym等)来动态加载。因此,动态链接的过程实际上是被推迟到了程序运行时,这也就是为什么我们说动态链接是在运行时发生的20。
链接器在编译和链接过程中扮演着至关重要的角色,它负责将多个目标文件组合成一个完整的可执行文件或库。在这个过程中,链接器确实只关心全局变量和函数等外部符号,确保所有目标文件中引用的外部符号都有唯一的定义。具体来说,链接器会检查每个目标文件中的符号表,以确定是否存在未解析的外部符号,并尝试通过其他目标文件或库来解决这些引用。如果某个符号在多个地方被定义,则链接器需要遵循一定的规则来选择正确的定义,例如强符号优先于弱符号。

静态库与动态库

静态库(Static Library)和动态库(Dynamic Library)是两种不同类型的库文件,它们的主要区别在于链接方式和最终生成的可执行文件特性。静态库在Windows下通常以.lib为扩展名,在Linux下则使用.a作为后缀。静态库实际上是一个或多个目标文件(.o.obj)的集合,它们已经被编译但尚未链接。当静态库被链接到项目中时,链接器会将静态库中的代码直接复制到最终的可执行文件中,这意味着生成的可执行文件包含了静态库的所有内容,因此体积较大,但独立性强,不依赖外部库文件。

相比之下,动态库在Windows下以.dll为扩展名,在Linux下则是.so。动态库允许程序在运行时加载所需的库,而不是在编译时将其嵌入到可执行文件中。这不仅减少了可执行文件的大小,还使得库可以被多个应用程序共享,从而节省内存资源。此外,动态库还可以独立更新,无需重新编译依赖它的应用程序。然而,这也意味着程序在运行时必须能够找到并正确加载所需的动态库,否则可能会导致“找不到DLL”之类的错误。

可执行文件

可执行文件是指那些可以直接由操作系统加载并执行的文件。这类文件通常是二进制格式,包含了CPU可以直接执行的机器指令以及必要的数据段。对于Windows系统而言,常见的可执行文件扩展名包括.exe.dll.sys等;而在Linux系统中,可执行文件可能没有明确的扩展名,但通常会有一个ELF(Executable and Linkable Format)文件头,并且设置了适当的执行权限。值得注意的是,可执行文件不仅仅包含编译后的机器码,还包括了诸如导入表、导出表、重定位信息等元数据,这些都是为了支持程序的正确加载和执行。

当静态库被链接到可执行文件时,其所有的代码和数据都会成为可执行文件的一部分,这意味着生成的文件中已经完整地包含了静态库的内容。而当涉及到动态库时,情况就有所不同了。可执行文件中只会包含对动态库中函数和变量的引用信息,即所谓的导入表。这些引用会在程序启动时由加载器解析,或者在程序运行期间通过显式的API调用(如dlopendlsym等)来动态加载。因此,动态链接的过程实际上是被推迟到了程序运行时,这也就是为什么我们说动态链接是在运行时发生的。

10 可执行文件中只会包含所引用动态库的必要信息,因此动态链接被推迟到了程序运行时

确实,可执行文件中只会包含所引用动态库的必要信息,这意味着动态链接的过程被推迟到了程序运行时。具体来说,当一个程序在编译和链接阶段使用了动态库时,链接器不会将动态库中的所有代码和数据直接复制到可执行文件中,而是仅记录下对这些库中函数和变量的引用信息。这种设计使得最终生成的可执行文件体积更小,并且允许多个程序共享同一个动态库的副本,从而节省内存资源。

动态链接的延迟特性

动态链接的延迟特性主要体现在以下几个方面:

  1. 符号解析:在静态链接中,所有的符号解析都在编译和链接阶段完成,即链接器会确保每个外部符号都有对应的定义,并将这些符号的实际地址嵌入到可执行文件中。而在动态链接中,符号解析被推迟到了程序加载或运行时。此时,动态链接器(如Linux下的ld.so)会根据可执行文件中的导入表信息,查找并加载所需的动态库,然后解析这些库中的符号,将其绑定到正确的地址上。这个过程称为符号解析符号绑定

  2. 地址重定位:由于动态库的加载地址在编译时是未知的,因此需要在程序加载时进行地址重定位。对于每个动态库中的全局变量和函数,动态链接器会在加载时计算它们相对于进程虚拟地址空间的实际位置,并更新可执行文件中的相应引用。这一过程被称为地址重定位。为了提高效率,现代操作系统通常采用延迟绑定(Lazy Binding)技术,只有当某个符号第一次被调用时才会进行绑定,从而减少了启动时间。

  3. 共享库的加载:在程序启动时,动态链接器会根据可执行文件中的依赖项列表,依次加载所有必要的动态库。如果某个库已经被其他进程加载到内存中,则可以直接使用现有的副本,而无需再次加载。这不仅提高了系统的整体性能,还减少了物理内存的占用。此外,动态链接器还会处理库之间的依赖关系,确保所有间接依赖的库也能够正确加载。

  4. 初始化与终止:一些动态库可能包含初始化和终止代码段(如.init.fini),用于执行库级别的设置和清理工作。在程序启动时,动态链接器会按照一定的顺序执行这些初始化代码;而在程序结束前,则会调用相应的终止代码。需要注意的是,可执行文件本身也可以包含类似的初始化和终止代码,但这些代码是由程序本身的启动例程负责执行的。

延迟绑定(Lazy Binding)

延迟绑定是一种优化技术,它允许系统在首次调用某个函数时才进行符号解析和地址绑定。这样做的好处是可以显著减少程序启动时的加载时间和内存消耗,因为并不是所有的动态库函数都会在程序运行期间被实际调用。例如,在Linux系统中,可以通过设置环境变量LD_BIND_NOW来控制是否启用延迟绑定。默认情况下,延迟绑定是开启的,但如果设置了LD_BIND_NOW,则会在程序启动时立即解析所有符号,确保后续调用时不发生额外的开销。

11 运行时指的是从程序开始被CPU执行到程序执行完成退出这段时间。动态链接的两种场景,一种是程序被加载器从磁盘加载到内存中,另一种是程序在运行时人为的进行动态链接

运行时

运行时指的是从程序开始被CPU执行直到程序执行完成退出这段时间。在这段时间内,程序可以访问操作系统提供的各种资源和服务,如文件系统、网络接口、图形界面等。对于动态链接而言,运行时还特别指代了动态库的加载和卸载过程。有两种主要的动态链接场景:

  1. 程序加载时:这是最常见的动态链接形式,发生在程序从磁盘加载到内存的过程中。此时,操作系统或加载器会根据可执行文件中的导入表信息,自动查找并加载所需的动态库。一旦所有依赖项都被成功解析,程序就可以正常启动并执行。这种链接方式相对透明,用户通常不需要额外的操作即可完成。

  2. 程序运行时:另一种动态链接场景是在程序已经启动并且正在运行的情况下进行的。开发人员可以通过编程接口(如POSIX标准下的dlopendlclose等函数)手动加载和卸载动态库,甚至可以在运行时动态地获取库中的符号地址。这种方式提供了更大的灵活性,允许程序根据实际需求动态调整其行为,比如加载插件或扩展功能。不过,这也增加了复杂性,因为开发者需要确保正确处理库的生命周期管理,避免出现内存泄漏或其他潜在问题。

12 可执行文件中有个特殊的符号_start,CPU从这个地址开始执行机器指令,经过一系列的准备工作后正式从程序的main函数开始运行

可执行文件中的_start符号

在Linux和其他类Unix系统中,可执行文件中包含了一个名为_start的特殊符号,它是程序真正开始执行的地方。当操作系统启动一个新进程时,它会将控制权交给这个_start函数,而不是直接进入main函数。_start函数负责执行一系列初始化工作,包括但不限于:

  • 设置栈帧和环境变量
  • 解析命令行参数
  • 初始化C运行时库(如glibc),这一步骤可能涉及调用构造函数、设置全局变量等
  • 最终调用main函数,传递适当的参数给它

一旦main函数返回,_start还会负责清理工作,如调用析构函数、释放资源等,最后通过exit系统调用告知操作系统程序已经结束。因此,虽然我们在编写C或C++程序时通常只关心main函数,但实际上,程序的入口点是由编译器和运行时系统共同决定的_start

13 栈区保存函数运行时的信息,堆区掌管动态内存分配,malloc就是从堆区分配的内存,堆区下面依次是数据区和代码区,在64位地址中代码区永远都是从0x400000,栈区永远位于内存的最高地址,虚拟内存让每个程序在运行时都会觉得自己独占内存,有了这种固定的内存布局,链接器就可以提前确定符号运行时的地址了

栈区的作用

栈区(Stack)是程序运行时用来保存函数调用信息的一个区域,包括但不限于函数参数、局部变量、返回地址等。每当一个函数被调用时,操作系统会在栈上创建一个新的栈帧(stack frame),用于存储该次调用相关的所有数据。栈的操作遵循后进先出(LIFO, Last In First Out)的原则,意味着最后进入栈的数据会最先被移除。当函数执行完毕后,对应的栈帧会被销毁,释放其所占用的空间。

  • 函数调用过程中栈的变化:在C++中,每当发生函数调用时,编译器会在栈上为新函数创建一个栈帧,其中包括传递给函数的参数、返回地址、以及函数内部定义的所有局部变量。此外,还会保存当前函数的状态,以便在子函数返回后能够正确恢复。这个过程涉及到寄存器ESP(栈指针)和EBP(基址指针)的操作,其中ESP指向栈顶,而EBP则指向当前栈帧的底部。通过调整这两个寄存器的值,可以有效地管理多个嵌套函数调用之间的上下文切换。

堆区与动态内存分配

堆区(Heap)是一个由程序员直接控制的内存区域,主要用于动态内存分配。与栈不同的是,堆上的内存分配并不依赖于函数调用的生命周期,而是根据程序的实际需求进行申请和释放。在C/C++语言中,malloccallocreallocfree等函数提供了对堆内存的操作接口,允许开发者灵活地管理内存资源。需要注意的是,不当使用这些函数可能导致内存泄漏或非法访问等问题,因此需要谨慎处理。

  • malloc的工作原理:当调用malloc时,它实际上是从操作系统的虚拟地址空间中请求一段连续的内存块,并将其返回给调用者。对于较小的内存请求(通常小于128KB),malloc会通过调整brk指针来扩展堆的大小;而对于较大的请求,则可能使用mmap系统调用来映射新的虚拟内存页。无论哪种方式,malloc都会确保返回的内存块是适当对齐的,并且可以在后续调用free时安全地回收。

64位系统中的内存布局特点

在64位Linux系统中,进程的虚拟地址空间被划分为用户空间(user space)和内核空间(kernel space)。用户空间占据较低的地址范围(0x0000000000000000 - 0x00007FFFFFFFFFFF),而内核空间则位于较高的地址范围内(0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF)。这种设计使得每个进程都能拥有独立且完整的虚拟地址空间,从而避免了不同进程之间的相互干扰。

  • 代码区的位置:在64位系统中,代码区(Code Segment)通常从固定地址0x400000开始,这是为了方便链接器在编译时确定符号的绝对地址。由于每个进程都有自己的虚拟地址空间,因此即使多个进程同时运行相同的可执行文件,它们各自的代码区也不会发生冲突。

  • 栈区的位置:栈区通常位于用户空间的最高地址处,并且随着函数调用的发生向低地址方向增长。这样的安排有助于防止栈溢出时覆盖到其他重要数据结构,同时也便于实现栈保护机制(如栈金丝雀)。值得注意的是,虽然栈区的位置相对固定,但其实际大小可以根据具体平台和编译选项有所差异。

  • 虚拟内存的作用:虚拟内存技术使得每个进程都认为自己独占了一整块连续的物理内存,但实际上这些地址可能分散在整个系统的物理RAM中。通过页表(Page Table)将虚拟地址映射到物理地址,操作系统能够在不改变应用程序逻辑的情况下有效地管理内存资源。此外,虚拟内存还支持按需加载(Demand Paging)、内存共享(Memory Sharing)等功能,进一步提升了系统的性能和安全性。

14 页表记录了虚拟内存地址与真实物理地址的映射关系,每个进程都有独属于自己的页表,线程共享进程地址空间中除栈区外的所有内容,即任何函数都可以放入线程中执行,任何线程都可以访问数据区变量

页表确实记录了虚拟内存地址与物理地址之间的映射关系,它是操作系统和硬件(如MMU)协作管理内存的关键数据结构。每个进程都有独属于自己的页表,这确保了不同进程之间的内存空间相互隔离,避免了潜在的安全风险和冲突。当一个进程启动时,操作系统会为它创建一个或多个页表,这些页表定义了该进程的虚拟地址空间如何映射到实际的物理内存。

页表的作用

  • 地址转换:页表的主要功能是将进程的虚拟地址转换为对应的物理地址。在32位系统中,虚拟地址通常被分为三个部分:页目录索引、页表索引和页内偏移。通过这两级索引,可以定位到具体的页表项,进而找到物理页面的起始地址,再加上页内偏移即可得到完整的物理地址。对于64位系统,地址转换的过程更加复杂,可能涉及多级页表,但基本原理相同。

  • 权限控制:除了提供地址映射外,页表还包含了访问权限信息,例如读/写/执行标志。这意味着即使两个虚拟地址映射到了同一个物理地址,它们也可能有不同的访问权限。这种机制有助于保护关键数据不受未授权访问的影响,并且可以在一定程度上防止恶意代码的执行。

  • 内存保护:由于每个进程都有自己独立的页表,因此即使两个进程使用相同的虚拟地址,它们实际上指向的是不同的物理位置。这样就实现了进程间的内存隔离,防止了一个进程对另一个进程内存的非法访问。此外,操作系统还可以利用页表来实现内存共享,即允许多个进程共享某些特定的物理页面,而不会影响各自的私有数据。

线程与进程地址空间

在同一进程中创建的线程共享了几乎所有的资源,包括但不限于代码段、数据段、堆区以及打开的文件描述符等。具体来说:

  • 共享内容:所有线程都可以访问同一进程中的全局变量、静态变量以及动态分配的内存(如通过malloc获得的堆区)。这是因为它们都运行在同一个虚拟地址空间内,共享同一个页表。这种设计使得线程间通信变得非常简单,可以直接通过共享内存进行数据交换,而无需额外的同步机制。

  • 独立栈区:尽管线程共享了大部分资源,但每个线程仍然拥有自己独立的栈区。栈区用于存储函数调用时的局部变量、参数以及返回地址等信息。由于每个线程的执行路径可能不同,因此它们需要各自维护一套栈帧,以保证线程间的独立性和并发性。

  • 线程切换效率:因为线程共享了进程的地址空间,所以在进行线程上下文切换时,不需要像进程切换那样重新加载页表和其他相关状态信息。这大大减少了切换开销,提高了系统的整体性能。

线程执行与数据访问

  • 函数执行:任何函数都可以放入线程中执行,只要该函数不依赖于特定线程的状态或环境。这意味着开发者可以根据任务的需求灵活地创建多个线程来并行处理不同的工作负载。然而,在多线程环境中,必须特别注意对共享资源的访问,以避免竞争条件(Race Condition)的发生。例如,如果多个线程同时修改同一个全局变量,可能会导致数据不一致或其他不可预测的行为。

  • 数据区变量访问:正如前面提到的,所有线程都可以访问同一进程中的数据区变量。但是,为了确保线程安全,应该采取适当的同步措施,如使用互斥锁(Mutex)、信号量(Semaphore)或者原子操作(Atomic Operations)。这些机制可以帮助协调多个线程对共享资源的访问,确保每次只有一个线程能够修改数据,从而维持数据的一致性和完整性。

  • 线程本地存储(TLS, Thread Local Storage):有时我们希望每个线程都有自己独立的一份数据副本,即使这些数据位于共享的地址空间中。为此,Linux提供了线程本地存储的概念,允许程序员为每个线程分配私有的存储空间。TLS中的数据只对该线程可见,其他线程无法直接访问,这对于实现线程级别的个性化配置非常有用。

15 如果一个线程能拿到另一个线程栈帧上的指针,那么这个线程就可以直接读写另一个线程的栈区,也就是说这个线程可以任意修改属于另一个线程栈区的变量,这也会导致极其难以排查的bug,因为你的代码可能本身并没有任何问题,只是其他线程的问题导致你的函数栈帧数据被破坏,这种bug通常需要你对整个项目的代码很熟悉

如果一个线程能够获取到另一个线程栈帧上的指针,那么这个线程就有可能直接读写另一个线程的栈区。这种情况虽然在理论上是可能的,但在实际编程实践中并不常见,因为大多数操作系统和编程语言的设计都尽量避免了这种潜在的危险。然而,当它发生时,确实可能导致极其难以排查的bug,正如你所提到的,问题往往不在于你的代码本身,而是由于其他线程的行为间接影响了你的函数栈帧数据。这种类型的错误通常被称为“隐蔽的并发错误”(latent concurrency bugs),它们不仅难以发现,而且一旦出现,定位和修复也非常困难。

栈区访问的安全性

从安全性和稳定性角度来看,每个线程都有自己独立的栈区是非常重要的。栈区主要用于存储局部变量、函数参数以及返回地址等信息,这些数据对于线程的正确执行至关重要。如果允许一个线程随意访问或修改另一个线程的栈区,将会破坏线程之间的隔离性,进而引发一系列不可预测的问题,如程序崩溃、数据损坏或者逻辑错误。为了防止这类问题的发生,操作系统和编译器通常会对栈区实施严格的保护措施,确保只有当前线程能够合法地访问其自己的栈区。

但是,在某些特殊情况下,例如通过指针传递的方式,一个线程确实可以获得指向另一个线程栈区内存区域的引用。这通常是开发者有意为之的结果,比如在线程间传递复杂的数据结构时,可能会将包含栈上分配对象的指针作为参数传递给新创建的线程。尽管这种方式可以提高性能并简化编程模型,但它也引入了额外的风险,即接收方线程可能会意外地修改发送方线程的栈数据,尤其是在多线程环境下进行异步操作时。

难以排查的bug

当一个线程的栈数据被另一个线程无意中修改后,受影响的线程可能会表现出异常行为,但这些症状往往是间接的,并且与直接导致问题的代码位置相距甚远。例如,某个函数可能在调用链中的较早阶段就已经开始出现问题,但由于错误的影响是在后续调用中才显现出来的,因此很难立即确定问题的根源所在。此外,由于栈数据的破坏可能会改变函数的返回地址或其他关键控制信息,程序可能会在完全不同的地方抛出异常或进入无限循环,进一步增加了调试难度。

更糟糕的是,这类bug往往具有高度的随机性和不确定性,它们可能只会在特定条件下触发,而在其他时候则表现正常。这意味着即使你在测试环境中重现了问题,也很难保证修复后的版本不会再次遇到同样的情况。为了解决这个问题,开发人员需要对整个项目的代码有深入的理解,不仅要熟悉各个模块的功能实现,还要清楚不同线程之间的交互模式和依赖关系。同时,使用一些高级的调试工具和技术,如内存检查器(如Valgrind)、线程分析器(如ThreadSanitizer)以及分布式追踪系统(如Jaeger),可以帮助更快地找到问题的根本原因。

预防措施

为了避免上述问题的发生,建议采取以下几种预防措施:

  • 避免跨线程传递栈上分配的对象:尽量不要将栈上分配的对象或其指针传递给其他线程。相反,应该优先考虑使用堆上分配的内存,这样可以确保所有线程都能安全地访问共享资源,而不会意外地修改彼此的栈数据。

  • 使用同步机制:当必须在线程之间共享数据时,务必使用适当的同步机制来保护这些数据,如互斥锁、条件变量或读写锁等。这不仅可以防止多个线程同时修改同一块内存,还可以确保数据的一致性和完整性。

  • 采用线程本地存储(TLS):对于那些确实需要在线程之间传递的数据,可以考虑使用线程本地存储(TLS)。TLS允许每个线程拥有自己独立的一份数据副本,从而避免了竞争条件的发生。

  • 严格控制指针的生命周期:确保传递给其他线程的指针在其生命周期内始终有效,并且不会被提前释放或覆盖。特别是在处理动态分配的内存时,要注意管理好内存的分配和回收,以防止出现悬空指针等问题。

16 动态链接的代码和数据放在进程地址空间中的堆区和栈区之间的空白区域

动态链接的代码和数据通常被放置在进程地址空间中的堆区和栈区之间的空白区域,这一区域也被称为“共享库区”或“内存映射段(mmap)”。这种布局设计不仅是为了充分利用进程地址空间,也是为了确保不同类型的内存区域能够高效且安全地共存。

在Linux系统中,当一个可执行文件被加载到内存中时,操作系统会根据程序头部表(Program Header Table, PHT)来确定哪些部分需要映射到内存,并为这些部分分配虚拟地址。对于动态链接库而言,它们并不直接包含在可执行文件内,而是在程序启动时由动态链接器(Dynamic Linker)负责加载。动态链接器本身是一个特殊的共享对象,其路径保存在可执行文件的.interp段中。当程序开始执行时,控制权首先交给动态链接器,它负责解析并加载所有必要的共享库,然后将控制权传递给程序的入口点。

动态链接库的加载位置并非固定不变,而是根据实际情况动态决定的。为了支持位置无关代码(Position Independent Code, PIC),现代操作系统采用了一种称为“按需分页”的机制,这意味着只有当程序真正访问某一段代码或数据时,对应的页面才会被加载到物理内存中。此外,为了提高安全性,许多操作系统还会启用地址空间布局随机化(Address Space Layout Randomization, ASLR),使得每次运行程序时,动态链接库的具体加载地址都会有所不同。

具体来说,在32位Linux系统中,早期版本(如2.4内核)会为共享库预留从0x40000000开始的一段地址空间。然而,随着技术的发展,特别是在引入ASLR之后,共享库的起始地址被调整到了更接近栈的位置,即位于0xBFxxxxxx附近。这样做可以避免堆与共享库之间的碎片化问题,从而允许更大的连续堆空间。而在64位系统中,由于地址空间更大,共享库的加载地址范围更加灵活,通常位于较高的地址范围内。

因此,当提到动态链接的代码和数据放在堆区和栈区之间的空白区域时,实际上是指这部分内存是专门用于映射动态链接库以及其他通过mmap()系统调用创建的匿名映射区域。这些区域既不是堆也不是栈,而是介于两者之间的一个独立部分。它们的特点是可以读写,但与堆不同的是,它们是由操作系统管理和分配的,程序员不需要直接操作这些区域的内存管理。

需要注意的是,虽然理论上堆和栈是可以无限增长的,但在实际应用中,它们的增长会受到多种因素的限制,比如操作系统设置的最大堆大小、栈大小限制(可通过ulimit -s查看),以及整个用户地址空间的可用性。如果堆和栈相遇,则可能导致内存分配失败,进而引发程序崩溃或其他异常行为。为了避免这种情况,操作系统通常会对堆和栈的增长进行适当的限制,并通过ASLR等措施来减少恶意攻击的风险。

17 线程局部存储技术(tls)让变量可以被所有线程访问,但是该变量在每个线程中都有一个副本,一个线程对该变量的修改不会影响到另一个线程

线程局部存储(Thread Local Storage, TLS)技术让变量可以在所有线程中访问,但是每个线程拥有该变量的一个独立副本。这意味着一个线程对该变量的修改不会影响到另一个线程中的副本。这种机制有效地避免了多线程环境下的数据竞争问题,减少了对同步机制的需求,从而提高了程序的性能和可维护性。

线程局部存储的工作原理

TLS的核心思想是为每个线程提供一个独立的存储空间,使得相同名称的变量在不同的线程中有不同的值。这通过操作系统或运行时环境提供的支持来实现。具体来说,当一个线程创建时,系统会为其分配一段专门用于TLS的内存区域。这段内存区域中的每个位置都与一个全局索引相关联,这个索引由TlsAlloc()函数分配,并且在整个进程中唯一标识一个TLS槽位。每个线程都有自己的TLS数组,数组中的元素数量由系统定义(例如,在Windows上,默认情况下每个线程有64个TLS槽位)。线程可以通过这个索引来存取自己的TLS变量。

动态TLS

动态TLS允许程序员在运行时动态地分配TLS索引,并通过一组API函数(如TlsAlloc()TlsSetValue()TlsGetValue()TlsFree())来管理这些索引及其关联的数据。这种方式特别适合于DLL(动态链接库),因为DLL无法预知它将被加载到的应用程序结构以及其中有多少个线程。以下是一个简单的例子,展示了如何使用动态TLS:

#include <windows.h>
#include <iostream>

DWORD TlsIndex = 0;

void InitTime() {
    DWORD Start = GetTickCount();
    BOOL IsOk = TlsSetValue(TlsIndex, (LPVOID)(DWORD_PTR)Start);
    if (!IsOk) {
        MessageBoxW(NULL, L"TlsSetValue Failed", L"Error", NULL);
    }
}

DWORD GetLostTime() {
    DWORD Temp = GetTickCount();
    PVOID v1 = TlsGetValue(TlsIndex);
    if (v1 == NULL) {
        MessageBoxW(NULL, L"TlsGetValue Failed", L"Error", NULL);
    }
    return Temp - (DWORD)v1;
}

DWORD WINAPI ThreadProcedure(LPVOID ParameterData) {
    InitTime();
    Sleep(1000); // Simulate work
    std::wcout << L"Thread " << GetCurrentThreadId() << L" ended, time spent: " << GetLostTime() << L" ms\n";
    return 0;
}

int main() {
    TlsIndex = TlsAlloc();
    for (int i = 0; i < 4; ++i) {
        CreateThread(NULL, 0, ThreadProcedure, NULL, 0, NULL);
    }
    Sleep(2000); // Wait for all threads to finish
    TlsFree(TlsIndex);
    return 0;
}
静态TLS

静态TLS则是通过编译器和链接器的支持,在编译时预先为TLS变量分配空间。这种方式适用于那些在编译时就可以确定TLS需求的情况,比如静态加载的映像文件。对于C/C++语言,可以使用__thread关键字(GCC/Clang)或_declspec(thread)修饰符(MSVC)来声明TLS变量。下面是一个使用静态TLS的例子:

#include <iostream>
#include <pthread.h>

__thread int threadLocalVar = 0;

void* ThreadFunc(void* arg) {
    threadLocalVar += 1;
    std::cout << "Thread ID: " << pthread_self() << ", Value: " << threadLocalVar << std::endl;
    return nullptr;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, ThreadFunc, NULL);
    pthread_create(&t2, NULL, ThreadFunc, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

在这个例子中,threadLocalVar是每个线程独有的,即使两个线程同时执行相同的代码路径,它们也不会互相干扰。

TLS的优势

  • 避免数据竞争:由于每个线程都有自己的一份变量副本,因此不需要担心多个线程同时访问同一个变量导致的数据竞争问题。
  • 减少同步开销:相比于传统的锁机制,TLS几乎不需要额外的同步操作,从而降低了系统的复杂性和性能损耗。
  • 提高并发性能:TLS使得多个线程可以并行工作而不必担心彼此之间的干扰,这对于高性能计算尤其重要。

TLS的应用场景

TLS非常适合用于那些需要在线程内部保持状态但又不想与其他线程共享的状态信息。例如,数据库连接池中的连接对象、日志记录器的上下文信息、事务ID等都可以利用TLS来简化设计并提高效率。此外,TLS还可以用来保存线程特定的错误码、信号屏蔽码等系统级信息,确保每个线程都能正确处理自己的异常情况。

18 实现线程安全无非是围绕着线程私有资源和线程共享资源进行的,首先你需要识别出哪些资源是线程私有的,哪些是线程间共享的

实现线程安全主要围绕着线程私有资源和线程共享资源来进行。为了确保多线程环境下的程序能够正确运行,开发者必须清楚地识别哪些资源是线程私有的,哪些资源是线程间共享的,并据此采取适当的措施来保护这些资源,防止数据竞争(data race)和其他并发问题的发生。

线程私有资源

线程私有资源是指那些只能被创建它的线程访问的资源,其他线程无法直接访问或修改这些资源。这类资源包括但不限于:

  • 栈空间:每个线程都有自己的栈,用于存储局部变量、函数参数、返回地址等信息。由于每个线程的栈是独立的,因此栈上的局部变量不会被其他线程直接访问,这使得它们天生就是线程安全的。
  • 寄存器:线程拥有自己的一组寄存器,用于保存线程的状态信息,如程序计数器(PC),它指向当前正在执行的指令地址。当操作系统在不同线程之间切换时,会保存当前线程的寄存器状态,并恢复下一个要执行的线程的寄存器状态。
  • 线程局部存储(Thread Local Storage, TLS):TLS是一种特殊的存储机制,允许每个线程拥有一个独立的副本,即使它们引用的是同一个全局变量。这种方式可以有效地避免多个线程之间的数据竞争,同时保持了全局变量的语义。
  • 错误返回码:由于同一个进程中可能有多个线程同时运行,如果一个线程设置了全局错误码(如errno),可能会覆盖另一个线程设置的值。因此,每个线程应该拥有自己的错误返回码变量。
  • 信号屏蔽码:每个线程可以根据自身的需求设置不同的信号屏蔽码,以决定哪些信号应该被阻塞,哪些信号应该被处理。尽管所有线程共享同一套信号处理器,但它们各自的信号屏蔽码是独立的。

线程共享资源

线程共享资源则是指那些可以被同一进程内的多个线程共同访问和修改的资源。为了保证对这些资源的操作是线程安全的,通常需要引入同步机制来协调各个线程的行为。常见的线程共享资源包括:

  • 全局变量:定义在函数外部的变量,无论是静态还是动态分配的,都是全局可见的,因此可以在多个线程之间共享。对于这样的变量,如果存在写操作,则必须使用适当的同步手段来保护。
  • 堆内存:通过malloc()new等操作符分配的内存位于堆上,这部分内存也是由进程内的所有线程共享的。任何线程都可以分配、释放或修改堆上的对象,因此需要特别小心处理。
  • 文件描述符:当一个线程打开文件时,它实际上是在进程中创建了一个文件描述符,这个描述符可以被其他线程用来读取或写入该文件。为了确保文件操作的安全性,通常需要使用锁或其他同步机制来控制对文件描述符的访问。
  • 代码段:进程中的代码段包含了程序的所有可执行指令,这部分内容是只读的,因此天然就是线程安全的。然而,如果涉及到对代码段中某些位置的间接调用(如函数指针),则需要注意潜在的竞争条件。
  • 静态变量:与全局变量类似,静态变量也存在于整个程序的生命周期内,可以在多个线程之间共享。特别是当静态变量位于函数内部时,虽然它们看起来像是局部变量,但实际上它们的行为更接近于全局变量。

实现线程安全的方法

一旦明确了哪些资源是线程私有的,哪些是线程共享的,接下来就可以根据具体情况选择合适的策略来实现线程安全。以下是几种常用的实现方法:

  • 互斥锁(Mutex):这是最常见的一种同步机制,通过加锁和解锁操作来确保同一时刻只有一个线程能够访问临界区(Critical Section)。Java提供了synchronized关键字和ReentrantLock类来实现互斥锁的功能。
  • 读写锁(ReadWriteLock):适用于读多写少的情况,允许多个线程同时读取共享资源,但在写入时则要求独占访问。这样可以在一定程度上提高并发性能。
  • 原子操作(Atomic Operations):利用硬件提供的原子指令来执行不可分割的操作,例如递增、递减、交换等。Java中有专门的java.util.concurrent.atomic包来支持原子变量。
  • volatile关键字:确保变量的可见性和有序性,即当一个线程修改了volatile修饰的变量后,其他线程能够立即看到最新的值,并且编译器不会对该变量进行重排序优化。
  • 线程局部存储(ThreadLocal):为每个线程提供独立的变量副本,从而避免了线程间的直接竞争。这种方法特别适合用于存储线程上下文信息,如事务ID、用户身份等。
  • 无锁编程(Lock-Free Programming):通过巧妙地设计算法和数据结构,使得多个线程可以在不使用锁的情况下安全地协作。虽然实现起来较为复杂,但在某些场景下可以获得更好的性能和响应速度。

http://www.kler.cn/a/511992.html

相关文章:

  • 回归预测 | MATLAB基于TCN-BiGRU时间卷积神经网络结合双向门控循环单元多输入单输出回归预测
  • C++ 模拟真人鼠标轨迹算法 - 防止游戏检测
  • QLineEdit 在文本发生变更时触发事件几种方式详细说明
  • 数字艺术类专业人才供需数据获取和分析研究
  • SQL Server查询计划操作符——查询计划相关操作符(4)
  • 大模型GUI系列论文阅读 DAY1:《基于大型语言模型的图形用户界面智能体:综述》
  • Transformer详解:Attention机制原理
  • Vue2:el-tree用scope slot为每一个节点添加一个鼠标悬浮时出现的右对齐的按钮
  • C# 事件(Event)详解
  • C++和OpenGL实现3D游戏编程【连载21】——父物体和子物体模式实现
  • PyTorch框架——基于WebUI:Gradio深度学习ShuffleNetv2神经网络蔬菜图像识别分类系统
  • 【深度学习】傅里叶变换
  • WPS不登录无法使用基本功能的解决方案
  • MySQL快速入门——库的操作
  • Golang学习笔记_28——工厂方法模式
  • Arcgis Pro安装完成后启动失败的解决办法
  • 亚马逊库存管理sql记录
  • JUnit单元测试
  • OpenAI进军实体机器人:GPT赋能的智能未来
  • 成就与远见:2024年技术与思维的升华
  • [Unity]【游戏开发】 脚本创建物体的实践与技巧
  • C语言--深入printf
  • 阿里云安装mikrotik7配置内网互通
  • 欧拉(Euler 22.03)安装安装proxysql
  • 解决Spring+Vue的跨域问题
  • 精选了几道MySQL的大厂面试题,被提问的几率很高!