c语言 - 未初始化变量的值从何而来,实际上在实际 CPU 上



我想知道变量的初始化方式:

#include <stdio.h>
int main( void )
{
int ghosts[3];
for(int i =0 ; i < 3 ; i++)
printf("%dn",ghosts[i]);
return 0;
}

这让我得到随机值,例如 -12 2631 131 .. 它们来自哪里?

例如,在x86-64 Linux上使用GCC:https://godbolt.org/z/MooEE3ncc

我有一个猜测来回答我的问题,无论如何它可能是错误的:
内存的寄存器在"清空"后获得 0 到 1 之间的随机电压,这些值被"四舍五入"为 0 或 1,这些随机值取决于某些东西?!也许是制作寄存器的方式?也许内存的容量以某种方式发挥作用?甚至可能是温度?!!

每次运行新程序时,计算机都不会重新启动或重新启动电源。 您的程序可以使用的内存或寄存器中的每个存储位都有一个值,该值由以前的某些指令留下,无论是在此程序中还是在此程序启动此程序之前的操作系统中。

如果是这种情况,例如对于微控制器,是的,在通电的电压波动期间,每个存储位都可能进入 0 或 1 状态,除非在设计为在某种状态下上电的存储。 (DRAM在上电时更有可能为0,因为它的电容器已经放电)。 但是,您还希望存在内部CPU逻辑,在从重置向量(内存地址)获取和执行代码的第一条指令之前,会执行一些归零或设置以保证状态;系统设计人员通常安排在该物理地址而不是RAM上放置ROM,因此他们可以在那里放置非随机字节的机器代码。 在该地址执行的代码可能应该假定所有寄存器的随机值。

但是您正在编写一个简单的用户空间程序,该程序在操作系统下运行,而不是微控制器、嵌入式系统或主流主板的固件,因此当任何程序加载时,上电随机性早已过去。


现代操作系统在进程启动时为零寄存器,将零内存页分配给用户空间(包括堆栈空间),以避免内核数据和其他进程数据的信息泄露。 因此,这些值必须来自进程前面发生的事情,可能来自在main之前运行并使用一些堆栈空间的动态链接器代码。

读取从未初始化或赋值的局部变量的值实际上并不是未定义的行为(在这种情况下,因为它不能被声明为register int ghosts[3],这是一个错误(Godbolt),因为ghosts[i]有效地使用了地址)请参阅(为什么)正在使用未初始化的变量未定义的行为?在这种情况下,C 标准必须说的是值是不确定的。因此,正如您所期望的那样,它确实归结为实现细节。

当您在没有优化的情况下进行编译时,编译器甚至不会注意到 UB,因为它们不会跨 C 语句跟踪使用情况。 (这意味着一切都有点像volatile,只在语句需要时将值加载到寄存器中,然后再次存储。

在我添加到您的问题的示例 Godbolt 链接中,请注意,-Wall不会在-O0处产生任何警告,而只是从它为数组选择的堆栈内存中读取,而无需写入它。因此,您的代码正在观察函数启动时内存中的任何过时值。(但正如我所说,这肯定是在这个程序的早期,通过C启动代码或动态链接编写的。

使用gcc -O2 -Wall,我们得到我们期望的警告:warning: 'ghosts' is used uninitialized [-Wuninitialized],但它仍然从堆栈空间读取而不写入它。

有时 GCC 会发明一个0而不是读取未初始化的堆栈空间,但在这种情况下不会发生。 关于它如何编译的保证为零,编译器看到未初始化的"错误",并且可以发明它想要的任何值,例如读取一些它从未写入的寄存器而不是该内存。 例如,由于您正在调用 printf,GCC 可能只是在printf调用之间保持 ESI 未初始化,因为这是 x86-64 System V 调用约定中ghost[i]作为第二个参数传递的地方。


包括 x86 在内的大多数现代 CPU 都没有任何会导致添加指令错误的"陷阱表示",即使有,C 标准也不能保证不确定的值不是陷阱表示。 但是IA-64确实有一个来自不良投机负载的Not A Thing寄存器结果,如果您尝试读取它,则会陷入困境。请参阅陷阱表示问答的评论 - Raymond Chen 的文章:ia64 上未初始化的垃圾可能是致命的。

ISO C 规则关于读取作为register候选变量的未初始化变量是 UB 的,可能针对此,但如果稍后发生地址获取,您仍然可能遇到这种情况,除非编译器采取措施避免它。 但ISO C缺陷报告N1208建议说,即使对于没有陷阱表示的类型,不确定的值也可以是"表现得好像是陷阱表示的值"。 因此,似乎该标准的一部分并没有完全涵盖像IA-64这样的ISA,这是真正的编译器的工作方式。

另一种不完全是"陷阱表示"的情况:请注意,只有一些对象表示(位模式)对主流 ABI 中的_Bool有效,违反该表示可能会导致程序崩溃:C++ 标准是否允许未初始化的布尔值使程序崩溃?

这是一个C++的问题,但我验证了如果您编写 GCC_Bool b[2],GCC 将返回垃圾而不会将其布尔化为 0/1;return b[0];https://godbolt.org/z/jMr98547o。 我认为ISO C只要求未初始化的对象具有一些对象表示(位模式),而不是该对象的有效对象(否则这将是编译器错误)。 对于大多数整数类型,每个位模式都是有效的,并表示一个整数值。 除了读取未初始化的内存外,您还可以使用(unsigned char*)memcpy将坏字节写入_Bool引起同样的问题。


未初始化的本地没有"值">

如下面的问答所示,在使用优化进行编译时,对同一个未初始化变量的多次读取会产生不同的结果:

  • 未初始化的局部变量是最快的随机数生成器吗?
  • C 中声明的、未初始化的变量会发生什么情况?它有价值吗?

这个答案的其他部分主要是关于未优化的代码中值的来源,当编译器并没有真正"注意到"UB时。

存储器的寄存器在"清空"后获得 0 到 1 之间的随机电压,

没有什么那么神秘的。您只是看到上次使用这些内存位置时写入的内容。

释放内存时,不会清除或清空内存。系统只知道它是免费的,下次有人需要内存时,它就会被移交,旧内容仍然存在。这就像买了一辆旧车,在手套箱里看,里面的东西并不神秘,只是发现一个打火机和一只袜子是一个惊喜。

有时在调试环境中,释放的内存被清除到某个可识别的值,以便很容易识别您正在处理未初始化的内存。例如0xccccccccccc或可能0xdeadbeefDeadBeef

也许是一个更好的类比。你在一家从不清洗盘子的自助餐厅吃饭,当顾客吃完后,他们会把盘子放回"免费"堆上。当你去为自己服务时,你从免费的堆里拿起顶板。您应该清洁盘子,否则您会得到以前的客户留下的东西

我将使用一个易于查看正在发生的事情的平台。 编译器和平台的工作方式相同,独立于体系结构、操作系统等。 当然也有例外...

主要我将调用此函数:

test();

这是:

extern void hexstring ( unsigned int );
void test ( void )
{
unsigned int x[3];
hexstring(x[0]);
hexstring(x[1]);
hexstring(x[2]);
}

十六进制只是一个printf("%008Xn",x)

Build it (not using x86, using something that is overall easier to read for this demonstration)
test.c: In function ‘test’:
test.c:7:2: warning: ‘x[0]’ is used uninitialized in this function [-Wuninitialized]
7 |  hexstring(x[0]);
|  ^~~~~~~~~~~~~~~
test.c:8:2: warning: ‘x[1]’ is used uninitialized in this function [-Wuninitialized]
8 |  hexstring(x[1]);
|  ^~~~~~~~~~~~~~~
test.c:9:2: warning: ‘x[2]’ is used uninitialized in this function [-Wuninitialized]
9 |  hexstring(x[2]);
|  ^~~~~~~~~~~~~~~

编译器输出的反汇编显示

00010134 <test>:
10134:   e52de004    push    {lr}        ; (str lr, [sp, #-4]!)
10138:   e24dd014    sub sp, sp, #20
1013c:   e59d0004    ldr r0, [sp, #4]
10140:   ebffffdc    bl  100b8 <hexstring>
10144:   e59d0008    ldr r0, [sp, #8]
10148:   ebffffda    bl  100b8 <hexstring>
1014c:   e59d000c    ldr r0, [sp, #12]
10150:   e28dd014    add sp, sp, #20
10154:   e49de004    pop {lr}        ; (ldr lr, [sp], #4)
10158:   eaffffd6    b   100b8 <hexstring>

我们可以看到堆栈区域已分配:

10138:   e24dd014    sub sp, sp, #20

但随后我们直接进入阅读和打印:

1013c:   e59d0004    ldr r0, [sp, #4]
10140:   ebffffdc    bl  100b8 <hexstring>

所以无论堆栈上有什么。 堆栈只是带有特殊硬件指针的内存。

我们可以看到数组中的其他两个项目也被读取(加载)和打印。

因此,此时记忆中存在的东西就是被打印出来的东西。 现在,我所处的环境可能在我们到达那里之前将内存(包括堆栈)归零:

00000000 
00000000 
00000000 

现在我正在优化此代码以使其更易于阅读,这增加了一些挑战。

那么如果我们这样做呢:

test2();
test();

在主要和:

void test2 ( void )
{
unsigned int y[3];
y[0]=1;
y[1]=2;
y[2]=3;
}
test2.c: In function ‘test2’:
test2.c:5:15: warning: variable ‘y’ set but not used [-Wunused-but-set-variable]
5 |  unsigned int y[3];
|  

我们得到:

00000000 
00000000 
00000000 

但我们可以看到为什么:

00010124 <test>:
10124:   e52de004    push    {lr}        ; (str lr, [sp, #-4]!)
10128:   e24dd014    sub sp, sp, #20
1012c:   e59d0004    ldr r0, [sp, #4]
10130:   ebffffe0    bl  100b8 <hexstring>
10134:   e59d0008    ldr r0, [sp, #8]
10138:   ebffffde    bl  100b8 <hexstring>
1013c:   e59d000c    ldr r0, [sp, #12]
10140:   e28dd014    add sp, sp, #20
10144:   e49de004    pop {lr}        ; (ldr lr, [sp], #4)
10148:   eaffffda    b   100b8 <hexstring>
0001014c <test2>:
1014c:   e12fff1e    bx  lr

测试没有改变,但test2是人们在优化时所期望的死代码,所以它实际上并没有触及堆栈。 但是,如果我们:

测试2.c

void test3 ( unsigned int * );
void test2 ( void )
{
unsigned int y[3];
y[0]=1;
y[1]=2;
y[2]=3;
test3(y);
}

测试3.c

void test3 ( unsigned int *x )
{
}

现在

0001014c <test2>:
1014c:   e3a01001    mov r1, #1
10150:   e3a02002    mov r2, #2
10154:   e3a03003    mov r3, #3
10158:   e52de004    push    {lr}        ; (str lr, [sp, #-4]!)
1015c:   e24dd014    sub sp, sp, #20
10160:   e28d0004    add r0, sp, #4
10164:   e98d000e    stmib   sp, {r1, r2, r3}
10168:   eb000001    bl  10174 <test3>
1016c:   e28dd014    add sp, sp, #20
10170:   e49df004    pop {pc}        ; (ldr pc, [sp], #4)
00010174 <test3>:
10174:   e12fff1e    bx  lr

test2 实际上是把东西放在堆栈上。 现在,调用约定通常要求堆栈指针回到被调用时开始的位置,这意味着函数 a 可能会移动指针并在该空间中读取/写入一些数据,调用函数 b 移动指针,在该空间中读取/写入一些数据,依此类推。 然后,当每个函数返回时,通常清理没有意义,您只需将指针移回并返回您写入该内存的任何数据。

因此,如果测试 2 将一些内容写入堆栈内存空间,然后返回,则在与 test2 相同的级别调用另一个函数。 然后,在本例中,调用 test() 时堆栈指针与调用 test2() 时位于同一地址。 那么会发生什么呢?

00000001 
00000002 
00000003 

我们已经设法控制了 test() 正在打印的内容。 不是魔法。

现在倒回 1960 年代,然后向前推进到现在,特别是 1980 年代及以后。

在程序运行之前,内存并不总是被清理。 正如这里的一些人所暗示的那样,如果您在电子表格上进行银行业务,那么您关闭了该程序并打开了该程序......想当年。。。您几乎希望看到来自该电子表格程序的一些数据,也许是二进制文件,也许是数据,也许是其他东西,由于操作系统使用内存的性质,它可能是您运行的最后一个程序的片段,以及之前程序的片段,以及仍在运行的程序片段,刚刚做了一个free(), 等等。

当然,一旦我们开始相互连接并且黑客想要接管并发送您的信息或做其他坏事,您就可以看到编写程序来查找密码或银行帐户或其他任何东西是多么微不足道。

因此,我们今天不仅有保护措施来防止一个程序在另一个程序空间中嗅探,而且我们通常假设,今天,在我们的程序获得其他程序使用的一些内存之前,它会被擦除。

但是如果你反汇编一个简单的hello world printf程序,你会看到在调用main()之前发生了相当数量的引导代码。 就操作系统而言,所有这些代码都是我们一个程序的一部分,因此即使(假设)在操作系统加载和启动我们的程序之前内存被清零或清理。 在main之前,在我们的程序中,我们使用堆栈内存来做一些事情,留下像test()这样的函数会看到的值。

您可能会发现,每次运行相同的二进制文件时,一个编译多个运行,"随机"数据是相同的。 现在你可能会发现,如果你向整个程序添加一些其他共享库调用或其他东西,那么也许,也许,共享库的东西会导致额外的代码premain发生,试图能够调用共享代码,或者也许随着程序的运行,它现在采取不同的路径,因为对整个二进制文件进行更改的副作用,现在随机值不同但一致。

有一些解释可以解释为什么每次来自同一二进制的值也可能不同。

不过机器里没有鬼。 堆栈只是内存,当计算机启动以擦除该内存一次时,如果没有其他原因,除了设置 ecc 位之外,堆栈并不少见。 之后,该内存被重用,重用,重用和重用。 并且取决于操作系统的整体架构。 编译器如何生成应用程序和共享库。 等因素。 当你的程序运行时,堆栈指针指向的内存中碰巧发生了什么,你在写之前读取(通常永远不会在你写之前读取,而且编译器现在抛出警告很好)不一定是随机的,碰巧到达这一点的特定事件列表不仅是随机的,而且是受控的, 不是您作为程序员可能已经预测的值。 特别是如果你在main()级别这样做。 但是,无论是主函数调用还是十七级嵌套函数调用,它仍然只是一些内存,可能包含也可能不包含您到达那里之前的一些内容。 即使引导加载程序将内存归零,那仍然是您之前的其他程序留下的写入零。

毫无疑问,编译器具有与堆栈相关的功能,这些功能可能会做更多的工作,例如调用结束时的零或前面的零或任何出于安全或其他原因。

我今天假设,当像Windows,Linux或macOS这样的操作系统运行您的程序时,它不会让您访问之前其他程序的一些过时的内存值(带有我的银行信息,电子邮件,密码等的电子表格)。 但是你可以简单地编写一个程序来尝试(只是malloc()和打印或做你做过的同样的事情,但更大的东西来查看堆栈)。 我还假设程序 A 没有办法进入并发运行的程序 B 的内存。 至少不是在应用程序级别。没有黑客(malloc() 和打印在我使用该术语时不是黑客)。

数组ghosts是未初始化的,并且因为它是在函数内部声明的并且不是static的(形式上,它具有自动存储持续时间),因此其值是不确定的。

这意味着您可以读取任何值,并且无法保证任何特定值。

最新更新