C++:如何用简单的汇编指令,实现C++复杂抽象的面向对象概念?——「5、全局变量与静态变量(涉及数据段、代码段的分配)」

179 阅读12分钟

在函数中,创建的局部变量,都是在该函数的栈帧中分配的,这些局部变量会随着函数的退出而释放。

但是,全局变量和静态变量却会常驻内存,只要进程一启动,它就会被分配在内存中的某个区域一直存在,不会被释放掉,为什么会这样?编译器是如何给全局变量分配内存的,下面就来看看,其实很简单。

老规矩,先上C++代码,创建个全局变量和静态成员变量:

#include<iostream>
/**
 * 全局变量、类的静态变量
*/
int glb = 66;
class Father
{
public:
    static int stc;
    int local = 8;
    void add(int i, int j, int k){ // 不用virtual,子类只会函数隐藏,不会函数覆盖
        glb = i;
        stc = j;
        local = k;
    }
};

int Father::stc = -9;


int main(){
    Father f1;
    Father f2;

    f1.add(1, 2, 3);
    f2.add(11, 22, 33);
    // std::cout<<f1.a << ", "<< f1.a<< std::endl ;
    return 0;
}

它对应的汇编代码如下,我尽可能分析了每一句汇编代码,还是有很多东西值的看一看的:

; 当程序被加载到内存中运行时,下面这9个段会被放置在不同的逻辑内存区域中,通常遵循以下规则:

; - `.text` 段和其他包含可执行代码的段(如 `.text._ZN6Father3addEiii`)会被放置在内存中的代码区域。这个区域通常是只读的,以防止程序意外修改自己的代码。
; - `.rodata` 段会被放置在内存中的只读数据区域。这个区域也是只读的,以防止程序意外修改只读数据。
; - `.data` 段会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。
; - `.bss` 段也会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。与 `.data` 段不同,`.bss` 段中的数据在程序启动时会被自动清零。
; - `.init_array` 段会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。
; - `.note.GNU-stack` 和 `.note.gnu.property` 段不会被加载到内存中。它们只包含链接器和运行时系统所需的元数据,不包含实际的代码或数据。

	.file	"3-object-class-global-static-0.cpp"

# 这里是段1 | g++编译器生成空.text段,确保生成的汇编代码符合可执行文件的标准结构,这样可以确保链接器能够正确地识别和处理各个段,从而生成正确的可执行文件
	.text


# 这里是段2 | 只读数据段,里面的数据会放到只读内存区,无法修改。定义一个大小为 1 字节的对象,并被初始化为 0。
# 原始名称可能是 std::piecewise_construct,std::piecewise_construct 是 C++ 标准库中的一个常量,它用于在构造函数中指示使用分段构造。它通常与 std::tuple 和 std::pair 的构造函数一起使用,用于将构造函数参数分组并传递给构造函数。
	.section	.rodata
	.type	_ZStL19piecewise_construct, @object # std::piecewise_construct
	.size	_ZStL19piecewise_construct, 1
_ZStL19piecewise_construct:
	.zero	1
	.local	_ZStL8__ioinit # 表示一个 std::ios_base::Init 类型的对象,用于初始化和清理 C++ 标准库中的 I/O 流。
	.comm	_ZStL8__ioinit,1,1
	.globl	glb


# 这里是段3
	.data
	.align 4
	.type	glb, @object # @object 类型表示符号是一个数据对象。数据对象通常是全局变量或静态变量,它们在程序的整个生命周期内都存在。数据对象可以在 .data 或 .bss 段中定义,并且可以在程序中的任何位置访问。
	.size	glb, 4
glb:
	.long	66
# .type用来指定符号的类型,符号类型用于告诉链接器和运行时系统如何处理符号。
; 在 ELF 文件中,符号类型(symbol type)用于指定符号所代表的实体的类型。符号类型由符号表项(symbol table entry)中的 st_info 字段的低 4 位来表示。常见的符号类型包括:

; STT_NOTYPE:未指定类型。
; STT_OBJECT:表示符号是一个数据对象,例如全局变量或静态变量。
; STT_FUNC:表示符号是一个函数。
; STT_SECTION:表示符号与某个节相关联。
; STT_FILE:表示符号是一个源文件的名称。
; STT_COMMON:表示符号是一个未初始化的全局变量,它将被分配在 .bss 段中。
; STT_TLS:表示符号是一个线程局部存储(TLS)对象。





# 这里是段4
	.section .text._ZN6Father3addEiii,"axG",@progbits,_ZN6Father3addEiii,comdat # 该节的类型为 axG,表示它是一个可执行的代码段,可以被共享和全局链接。@progbits 表示该节包含程序代码和数据。_ZN6Father3addEiii 是该节的符号名称,它是 C++ 程序中 Father::add(int, int, int) 函数的汇编名称。comdat 表示该节是一个 COMDAT 节,可以在链接时与其他相同名称的 COMDAT 节合并。
	.align 2 # 指定数据应按 2 字节对齐
	.weak _ZN6Father3addEiii # 指定 _ZN6Father3addEiii 符号为弱符号
	.type _ZN6Father3addEiii,@function # 指定 _ZN6Father3addEiii 符号为函数类型 (todo,需要再分析关键字)
_ZN6Father3addEiii: # 定义 _ZN6Father3addEiii 函数的起始位置
.LFB1522:
	.cfi_startproc
	endbr64
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6

	movq	%rdi, -8(%rbp) # local局部变量地址
	movl	%esi, -12(%rbp) # i
	movl	%edx, -16(%rbp) # j
	movl	%ecx, -20(%rbp) # k

	movl	-12(%rbp), %eax
	movl	%eax, glb(%rip) # glb = i

	movl	-16(%rbp), %eax
	movl	%eax, _ZN6Father3stcE(%rip) # stc = j

	movq	-8(%rbp), %rax
	movl	-20(%rbp), %edx
	movl	%edx, (%rax) # local = k
	
	nop
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE1522:
	.size	_ZN6Father3addEiii, .-_ZN6Father3addEiii
	.globl	_ZN6Father3stcE


# 这里是段5 | .data 段,用于存储已初始化的全局变量和静态变量。在 C++ 中,静态类成员变量与全局变量类似,它们在整个程序中都只有一个实例。不同之处在于,静态类成员变量属于类的作用域,而全局变量属于全局作用域
	.data # 指定接下来的数据属于 .data 段
	.align 4 # 指定数据应按 4 字节对齐
	.type _ZN6Father3stcE,@object # 指定 _ZN6Father3stcE 符号为对象类型
	.size _ZN6Father3stcE,4 # 指定 _ZN6Father3stcE 对象的大小为 4 字节
_ZN6Father3stcE: # 定义 _ZN6Father3stcE 对象的起始位置
	.long -9 # 将立即数 -9 存储到 _ZN6Father3stcE 对象中




# 这里是段6
	.text # 指定接下来的代码属于 .text 段
	.globl main # 指定 main 符号为全局符号
	.type main,@function # 指定 main 符号为函数类型
main: # 定义 main 函数的起始位置
.LFB1523: # 生成的标签,用于调试信息
	.cfi_startproc # 指示调试器开始一个新的函数
	endbr64 # ENDBR64 指令,用于支持 Intel CET
	pushq %rbp # 保存基指针寄存器的值
	.cfi_def_cfa_offset 16 # 更新调用帧地址(CFA)的偏移量
	.cfi_offset 6,-16 # 保存寄存器 %rbp 的调用帧信息
	movq %rsp,%rbp # 将栈指针寄存器的值复制到基指针寄存器中
	.cfi_def_cfa_register 6 # 更新调用帧地址(CFA)的寄存器
	subq $16,%rsp # 分配栈空间
	movq %fs:40,%rax # 加载 fs 寄存器中偏移量为 40 的值到寄存器 %rax 中
	movq %rax,-8(%rbp) # 将寄存器 %rax 的值保存到栈上
	xorl	%eax, %eax # 寄存器清零

	movl	$8, -16(%rbp)
	movl	$8, -12(%rbp)

	leaq	-16(%rbp), %rax # local局部变量地址
	movl	$3, %ecx # k
	movl	$2, %edx # j
	movl	$1, %esi # i
	movq	%rax, %rdi
	call	_ZN6Father3addEiii 

	leaq	-12(%rbp), %rax
	movl	$33, %ecx
	movl	$22, %edx
	movl	$11, %esi
	movq	%rax, %rdi
	call	_ZN6Father3addEiii

	movl $0,%eax 
	movq -8(%rbp),%rdx 
	xorq %fs:40,%rdx # 对 fs 寄存器中偏移量为 40 的值和寄存器 %rdx 的值进行异或运算
	je .L4 # 如果异或运算结果为零,则跳转到标签 .L4 处
	call __stack_chk_fail@PLT # 调用函数 __stack_chk_fail
.L4: # 标签 .L4
	leave # 恢复栈指针寄存器和基指针寄存器的值
	.cfi_def_cfa 7,8 # 更新调用帧地址(CFA)的寄存器和偏移量
	ret # 返回到调用函数
	.cfi_endproc # 指示调试器结束一个函数
.LFE1523: # 生成的标签,用于调试信息
	.size main,.main # 指定 main 函数的大小
	.type _Z41__static_initialization_and_destruction_0ii,@function # 指定 _Z41__static_initialization_and_destruction_0ii 符号为函数类型
_Z41__static_initialization_and_destruction_0ii: # 定义 _Z41__static_initialization_and_destruction_0ii 函数的起始位置
.LFB2007: # 生成的标签,用于调试信息
	.cfi_startproc # 指示调试器开始一个新的函数
	endbr64 # ENDBR64 指令,用于支持 Intel CET
	pushq %rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6,-16
	movq %rsp,%rbp 
	.cfi_def_cfa_register 6
	subq $16,%rsp # 分配栈空间
	movl %edi,-4(%rbp) 
	movl %esi,-8(%rbp) 
	cmpl $1,-4(%rbp) 
	jne .L7 
	cmpl $65535,-8(%rbp) # 将栈上保存的值与立即数 65535 进行比较
	jne .L7 # 如果比较结果不相等,则跳转到标签 .L7 处
	leaq _ZStL8__ioinit(%rip),%rdi # 
	call _ZNSt8ios_base4InitC1Ev@PLT # 
	leaq __dso_handle(%rip),%rdx # 
	leaq _ZStL8__ioinit(%rip),%rsi # 用于构造 _ZStL8__ioinit 对象,并将其注册到运行时系统中,以便在程序退出时自动调用其析构函数。这样可以确保 C++ 标准库中的 I/O 流在程序退出时被正确地清理。
	movq _ZNSt8ios_base4InitD1Ev@GOTPCREL(%rip),%rax # 计算 _ZNSt8ios_base4InitD1Ev 符号的地址并将其存储到寄存器 %rax 中
	movq %rax,%rdi # 将寄存器 %rax 的值复制到寄存器 %rdi 中
	call __cxa_atexit@PLT # 调用函数 __cxa_atexit,注册析构函数
.L7: # 上下这些代码,用来 初始化一个 I/O 流对象,还将对象的析构函数注册到运行时系统中,以确保程序退出时 I/O 流被正确清理
	nop 
	leave 
	.cfi_def_cfa 7,8
	ret
	.cfi_endproc
.LFE2007: # 生成的标签,用于调试信息
	.size _Z41__static_initialization_and_destruction_0ii,.-_Z41__static_initialization_and_destruction_0ii # 指定 _Z41__static_initialization_and_destruction_0ii 函数的大小
	.type _GLOBAL__sub_I_glb,@function # 指定 _GLOBAL__sub_I_glb 符号为函数类型
_GLOBAL__sub_I_glb: # 定义 _GLOBAL__sub_I_glb 函数的起始位置
.LFB2008:
	.cfi_startproc
	endbr64
	pushq %rbp # 保存基指针寄存器的值
	.cfi_def_cfa_offset 16 # 更新调用帧地址(CFA)的偏移量
	.cfi_offset 6,-16
	movq %rsp,%rbp
	.cfi_def_cfa_register 6
	movl $65535,%esi
	movl $1,%edi
	call _Z41__static_initialization_and_destruction_0ii # 调用函数 _Z41__static_initialization_and_destruction_0ii
	popq %rbp
	.cfi_def_cfa 7,8
	ret
	.cfi_endproc
.LFE2008:
	.size _GLOBAL__sub_I_glb,.-_GLOBAL__sub_I_glb # 指定 _GLOBAL__sub_I_glb 函数的大小



# 这里是段7 | 用于存储初始化函数的地址。初始化函数是在程序启动时运行的函数,用于执行全局对象的构造和其他初始化操作。当程序启动时,运行时系统会按顺序调用 .init_array 段中存储的所有初始化函数。这些函数会在 main 函数之前运行,以确保全局对象和其他资源在程序运行之前被正确地初始化。
	.section .init_array,"aw" # 指定接下来的代码属于 .init_array 段
	.align 8 # 指定数据应按 8 字节对齐
	.quad _GLOBAL__sub_I_glb # 存储初始化函数 _GLOBAL__sub_I_glb 的地址
	.hidden __dso_handle # 指定 __dso_handle 符号为隐藏符号
	.ident "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0" # 存储编译器版本信息



# 这里是段8 | 虽然是空段,也要存在!.note.GNU-stack 段通常用于指示程序是否需要可执行堆栈。如果这个段存在且标记为不可执行(即包含 @progbits 属性),则链接器会生成一个不可执行的堆栈。如果这个段不存在或标记为可执行,则链接器会生成一个可执行的堆栈。
	.section	.note.GNU-stack,"",@progbits


# 这里是段9 | 用于存储 GNU 属性。GNU 属性是一种用于在目标文件中存储额外信息的机制,它可以用来指示链接器和运行时系统如何处理目标文件。
	.section	.note.gnu.property,"a"
	.align 8
	.long	 1f - 0f
	.long	 4f - 1f
	.long	 5
0:
	.string	 "GNU"
1:
	.align 8
	.long	 0xc0000002 # 数据 0xc00000020x3 表示目标文件需要支持 Intel CET 功能。
	.long	 3f - 2f
2:
	.long	 0x3
3:
	.align 8
4:

全局变量和静态成员变量被分配到哪里?

由上述汇编可知,分配到了.data数据段中,编译器提前预留出来数据段专门存放全局变量和静态变量,在程序加载时,.data数据段会被像代码段那样被加载到内存里,放到内存某个位置一直存在,不会被释放。

符号类型

.type用来指定符号的类型,符号类型用于告诉链接器和运行时系统如何处理符号。

观察glb变量和stc静态变量对应的2个数据段,会发现,glb和stc符号类型都被设置为了OBJECT。

.type	glb, @object
.type _ZN6Father3stcE,@object

这表明该符号是数据对象,是全局变量或静态变量,不是函数之类的。

在 ELF 文件中,符号类型(symbol type)用于指定符号所代表的实体的类型。符号类型由符号表项(symbol table entry)中的 st_info 字段的低 4 位来表示。常见的符号类型包括:

; STT_NOTYPE:未指定类型。 ; STT_OBJECT:表示符号是一个数据对象,例如全局变量或静态变量。 ; STT_FUNC:表示符号是一个函数。 ; STT_SECTION:表示符号与某个节相关联。 ; STT_FILE:表示符号是一个源文件的名称。 ; STT_COMMON:表示符号是一个未初始化的全局变量,它将被分配在 .bss 段中。 ; STT_TLS:表示符号是一个线程局部存储(TLS)对象。

上述汇编中这9个段会被加载到内存哪块区域?

上述C++代码,被编译成9个段,每个段都有自己的数据和属性等。

在汇编中,定义1个段主要用以下几个指令:.data|.rodata|.text|.bss|.section等,当用.section定义段是,可以指定这个段的属性: · a是可分配的(可分配到内存中), · x是可执行, · w是可写, · G:"G" 标志表示该节是一个小型全局数据区,它包含小型全局或静态数据项。 这些数据项通常比较小,可以被链接器放置在一个单独的节中,以便更好地优化程序的内存布局。 链接器会尽量将这些小型数据项放置在一起,以减少内存碎片和提高缓存命中率。 这样可以减少程序的内存占用,并提高程序的运行速度。 此外,将这些数据项放置在一个单独的节中还可以方便链接器对它们进行重定位和符号解析

比如下列这句定义了1个可读可执行的代码段:

.section .text._ZN6Father3addEiii,"axG",@progbits,_ZN6Father3addEiii,comdat 

# 该节的类型为 axG,表示它是一个可执行的代码段,可以被共享和全局链接。
# @progbits 表示该节包含程序代码和数据。
# _ZN6Father3addEiii 是该节的符号名称,它是 C++ 程序中 Father::add(int, int, int) 函数的汇编名称。
# comdat 表示该节是一个 COMDAT 节,可以在链接时与其他相同名称的 COMDAT 节合并。
	

当程序被加载到内存中运行时,下面这9个段会被放置在不同的逻辑内存区域中,通常遵循以下规则:

; - `.text` 段和其他包含可执行代码的段(如 `.text._ZN6Father3addEiii`)会被放置在内存中的代码区域。这个区域通常是只读的,以防止程序意外修改自己的代码。

; - `.rodata` 段会被放置在内存中的只读数据区域。这个区域也是只读的,以防止程序意外修改只读数据。

; - `.data` 段会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。

; - `.bss` 段也会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。与 `.data` 段不同,`.bss` 段中的数据在程序启动时会被自动清零。

; - `.init_array` 段会被放置在内存中的数据区域。这个区域是可读写的,程序可以在运行时修改其中的数据。

; - `.note.GNU-stack``.note.gnu.property` 段不会被加载到内存中。它们只包含链接器和运行时系统所需的元数据,不包含实际的代码或数据。

总结

经过上述分析,我终于将之前模糊的链接器知识搞清楚了,所谓的数据段、代码段、只读段啥的,就是这意思啊。就是在汇编里指定这个段的类型,属性等,然后加载器会根据段不同的属性,将它加载到不同数据区域,分析汇编是真的有意思,比单纯看书要直观舒服太多!