基于自动变量的绝对最坏情况堆栈大小

Posted

技术标签:

【中文标题】基于自动变量的绝对最坏情况堆栈大小【英文标题】:Absolute worst case stack size based on automatic varaibles 【发布时间】:2021-03-10 05:59:49 【问题描述】:

在 C99 程序中,在(理论上)假设我没有使用可变长度数组,并且我的每个自动变量在整个堆栈中一次只能存在一次(通过禁止循环函数调用和显式递归),如果我总结了它们消耗的所有空间,我可以声明这是可能发生的最大堆栈大小吗?

这里有一点上下文:我告诉朋友我写了一个程序,不使用动态内存分配(“malloc”)并分配所有内存静态(通过在结构中建模我的所有状态变量,然后我声明为全局) .然后他告诉我,如果我使用自动变量,我仍然会使用动态内存。我认为我的自动变量不是状态变量而是控制变量,所以我的程序仍然被认为是静态的。然后我们讨论了必须有一种方法来说明我的程序的绝对最坏情况行为,所以我提出了上述问题。

额外问题:如果上述假设成立,我可以简单地将所有自动变量声明为静态并最终得到一个“真正的”静态程序?

【问题讨论】:

是的,两者都可以。这就是递归发明之前的工作原理。 您所描述的内容在某些微控制器上可能是必须的。像 8 位 PIC(来自 Microchip)这样的架构通常没有堆栈,也没有实现像 malloc() 这样的功能。 (他们的栈只能存储返回地址,而且只有8个左右,我不认为这是一个合适的栈)。 这不是 C99 功能或 C 2018 功能。它依赖于您正在使用的特定 C 实现的属性。此外,函数中自动对象的大小不是其堆栈帧(或堆栈使用)的大小。在评估表达式时,它可能会更多地用于临时工作区。它使用更多的返回地址和 ABI 所需的其他数据。它可能使用较少,因为一些自动对象保存在寄存器中或被优化掉。 【参考方案1】:

即使数组大小是恒定的,C 实现也可以动态分配数组甚至结构。我不知道有任何人(任何人)这样做,而且看起来毫无帮助。但是 C 标准并没有做出这样的保证。

在堆栈帧中(几乎可以肯定)还有一些额外的开销(数据在调用时添加到堆栈并在返回时释放)。 您需要将所有函数声明为不带参数并返回 void 以确保堆栈中没有程序变量。最后,在将 return 压入堆栈后(至少在逻辑上),函数将继续执行的“返回地址”。

因此,在删除了所有参数、自动变量和返回值给您“状态”struct 之后,堆栈中仍然会有一些事情发生 - 可能。

我说可能是因为我知道一个(非标准)嵌入式 C 编译器禁止递归,它可以通过检查整个程序的调用树并识别调用链来确定 stack 的最大大小达到堆栈的 peek 大小。

你可以用一大堆goto 语句来实现这一点(有些条件是从两个地方逻辑调用函数或通过复制代码。

在内存很小的设备上的嵌入式代码中,避免任何动态内存分配并知道任何“堆栈空间”永远不会溢出,这通常很重要。

我很高兴这是一个理论上的讨论。您的建议是一种疯狂的代码编写方式,并且会丢弃 C 提供给程序编码基础设施的大部分(最终有限的)服务(几乎是调用堆栈)

脚注:请参阅下面有关 8 位 PIC 架构的评论。

【讨论】:

有些架构,如 8 位 PIC,不使用完整堆栈,但只能保存返回地址。这不允许递归。所有需要的内存在编译结束时都知道。 感谢您的引用。我只有通过有一个使用过此类嵌入式设备的朋友才知道它们。很可能是PIC。在一些古老的 BASIC 方言中,GOSUB\RETURN 并不遥远。 该程序实际上是为嵌入式设备(esp32)编写的。我们在学校了解到嵌入式设备上的动态分配“不好”,因此我和我的朋友开始讨论自动变量与动态内存分配的关系。最终,自动变量难道不是另一种动态,我们应该尽量避免它吗?我可以说我的程序不使用动态内存,即使自动变量似乎是动态的?我指的不是动态堆内存,而是“更通用的动态内存”。 在某些层面上,自动变量是动态分配的。但是它们是在堆栈上分配的。当我们谈论动态内存分配时,我们通常谈论堆分配malloc()free()。它在嵌入式中不是首选,因为它有开销并且通常很难证明当一切都“最大化”时可能会耗尽内存。大多数嵌入式应用程序都以固定大小构建(多少传感器、气缸、喷气发动机!)需要或需要多少“先前”读数。 ... @Eric 看到这个问题***.com/questions/6387614/…【参考方案2】:

额外问题:如果上述假设成立,我可以简单地声明 所有自动变量都是静态的,最终会得到一个“真正的”静态 程序?

没有。这将改变程序的功能。 static 变量只初始化一次。 比较这两个函数:

int canReturn0Or1(void)

  static unsigned a=0;
  a++;
  if(a>1)
  
    return 1;
  
  return 0;


int willAlwaysReturn0(void)

  unsigned a=0;
  a++;
  if(a>1)
  
    return 1;
  
  return 0;

【讨论】:

好点...但是如果我写“静态无符号a=0;a=0;”?所以在第二次调用时明确将其设置为 0? @Eric Thin 是一样的,当你有一个访问相同函数的中断时,你使用了多个线程或者你有递归。【参考方案3】:

在 C99 程序中,在(理论上)假设我没有使用可变长度数组,并且我的每个自动变量在整个堆栈中一次只能存在一次(通过禁止循环函数调用和显式递归),如果我总结了它们消耗的所有空间,我可以声明这是可能发生的最大堆栈大小吗?

不,因为函数指针.....阅读n1570。

考虑以下代码,其中rand(3) 是一些伪随机数生成器(也可能是来自传感器的一些输入):

typedef int foosig(int);
int foo(int x) 
   foosig* fptr = (x>rand())?&foo:NULL;
   if (fptr) 
     return (*fptr)(x);
   else
     return x+rand();

优化编译器(例如最近的一些GCC 适当地调用了足够的优化)会为(*fptr)(x) 调用tail-recursive。其他一些编译器不会。

根据您编译该代码的方式,它会使用bounded stack 或生成stack overflow。 使用一些ABI 和calling conventions,参数和结果都可以通过processor register 并且不会消耗任何堆栈空间。

gcc -O2 -fverbose-asm -S foo.c 调用最近的GCC(例如在 Linux/x86-64 上,一些GCC 10 在 2020 年进行实验)然后查看foo.s 内部。将-O2 更改为-O0

请注意,可以使用足够好的 C 编译器和优化器将朴素的递归阶乘函数编译成一些迭代机器代码。在 Linux 上实践 GCC 10 编译以下代码:

int fact(int n)

  if (n<1) return 1;
  else return n*fact(n-1);

gcc -O3 -fverbose-asm tmp/fact.c -S -o tmp/fact.s 产生以下汇编代码:

    .type   fact, @function
   fact:
   .LFB0:
    .cfi_startproc
    endbr64 
   # tmp/fact.c:3:   if (n<1) return 1;
    movl    $1, %eax    #, <retval>
    testl   %edi, %edi  # n
    jle .L1 #,
    .p2align 4,,10
    .p2align 3
   .L2:
    imull   %edi, %eax  # n, <retval>
    subl    $1, %edi    #, n
    jne .L2 #,
   .L1:
   # tmp/fact.c:5: 
    ret 
    .cfi_endproc
   .LFE0:
    .size   fact, .-fact
    .ident  "GCC: (Ubuntu 10.2.0-5ubuntu1~20.04) 10.2.0"

您可以观察到call stack 没有在上面增加。

如果您有针对 GCC 的严肃且有据可查的论据,请提交bug report。

顺便说一句,您可以编写自己的GCC plugin,它会选择随机应用或不应用这样的优化。我相信它始终符合 C 标准。

上述优化对于许多生成C代码的编译器来说是必不可少的,例如Chicken/Scheme或Bigloo。

一个相关的定理是Rice's theorem。另请参阅由CHARIOT 项目资助的this draft 报告。

另请参阅Compcert 项目。

【讨论】:

以上是关于基于自动变量的绝对最坏情况堆栈大小的主要内容,如果未能解决你的问题,请参考以下文章

js堆栈的理解

栈和堆

如何禁用堆栈视图自动调整大小

在不停止 stackView 自动调整大小的情况下对齐图像视图

使用具有自动调整大小的默认整数数组在java中实现堆栈

在垂直堆栈视图中具有自动项目大小的水平分页 UICollectionView?