為什么你的程序總是 stack overflow?


前言

在刷 leetcode 時,遇到了 stack-buffer-overflow, 這個問題比較常見,干脆總結一下原因。本文是在 linux 下操作的,需要使用一些相關的命令。

stack 是什么

一般 stack 這個詞有兩個意思,即 stack 這種數據結構,和虛擬內存中 stack 這個段。

為什么虛擬內存中 stack 段會叫這個名字,我們先來看一下 stack 這種數據結構。stack 是線性表中的一種,元素間一對一,而且只能從 stack top 增加或刪除元素,遵循 LIFO(last in, fisrt out) 原則。

如圖,在 stack top 入 stack(push),出 stack(pop)。 LIFO 原則就是先入棧的,后出棧,看圖就容易理解

第一個入棧的元素 1, 需要其他元素的出棧后才能出棧,而最后一個進棧的元素 4, 則第一個出棧。stack 在日常生活中也非常常見,如平放一些書,就是一個典型的 stack 結構。

stack 是一種邏輯結構,主要講求的是元素間的邏輯關系,具體的實現可以順序存儲,或者鏈式存儲,這里就不展開了。

進程空間/進程內存結構

我們首先需要知道程序是怎么從源碼到執行的。c 語言需要進過這幾個過程,編寫源碼 -> 預處理 -> 編譯 -> 鏈接 成二進制文件,在 linux 下即 elf 文件,可以是可執行文件,或者是庫文件。

如果是可執行文件,鏈接完成后,需要裝載到內存中,操作系統給這個程序分配一定的內存空間,然后程序變成進程就開始執行了。

操作系統給程序分配的空間,這塊內存有固定的結構,如圖

第一段是這塊內存的一些上下文信息,如地址,環境變量等。

剩余的幾段是我們需要掌握的,即 stack, heap, BSS, data, text

text 段是存代碼,字面量,在運行時只讀

data 段存已經初始化的靜態變量,全局變量

BSS 段存沒有初始化的靜態變量,全局變量

heap 段存程序員自己分配的變量

stack 段存操作系統分配的局部變量,如參數,返回值等

BSS/data 段即一般說的靜態區,全局區等。

代碼組成

代碼是由語句,變量,常量,字面量組成的。

語句即關鍵字/標識符等,字面量則是變量或常量的值,值整數 1,字符串 "hello" 等。

#include <stdio.h>

int main(void) {
    int a = 1;
    char c[] = {"hello"};
    printf("%d, %s\n", a, c);

    return 0;
}

如語句

#include <stdio.h>
main()
printf
return

程序內存分配方式

有了上面基礎知識后,后面的知識更容易理解。現在我們舉實例來說明

我們先來編寫個最第一個程序

#include <stdio.h>

int main(void) {
	
    return 0;
}

使用 gcc 編譯, gcc 是一個常用的 c 編譯器

gcc -o main main.c

得到可執行文件 main,可以執行一下試試

$ ./main 

然后我們使用 size 命令,man 是這樣解釋這個命令的 list section sizes and total size of binary files,我們可以看到二進制文件的結構,裝載到內存中運行時,也是根據此來分配內存的。

$ size main
   text    data     bss     dec     hex filename
   1282     512       8    1802     70a main

dec 是十進制(Decimal),hex 是十六進制,dec 即文件大小,hex 是對應的十六進制,filename 是文件名,這三個都不是我們需要關注的。

我們可以看到 text,data,bss 這三段,現在我們的程序還沒有運行,所以這三段編譯結束后就已經固定了。

text

text 段是代碼段,這段用來存語句和字面量的。

#include <stdio.h>

int main(void) {
    int a = 1;
    char c[] = {"hello"};
    printf("%d, %s\n", a, c);

    return 0;
}

#include <stdio.h>
main()
=
printf
return

等語句就是存在 text 區的,而

1
"hellow"

等字面量也是存在 text 區的。這個區不是我們想說的重點,繼續。

data/bss

現在我們加幾個未初始化的全局變量試試

#include <stdio.h>

int a;
int b;

int main(void) {

    return 0;
}
$ size main
   text    data     bss     dec     hex filename
   1282     512      16    1810     712 main

可以看到 BSS 段增加了,現在我們再試試加幾個已經初始化的全局變量

#include <stdio.h>

int a = 1;
int b = 2;

int main(void) {

    return 0;
}
$ size main
   text    data     bss     dec     hex filename
   1282     520       8    1810     712 main

可以看到 data 段增加了

現在我們再加幾個沒有初始化的靜態變量試試(局部/全局都一樣)

#include <stdio.h>

int main(void) {
    static int a;
    static int b;

    return 0;
}
#include <stdio.h>

static int a;
static int b;

int main(void) {

    return 0;
}
$ size main
   text    data     bss     dec     hex filename
   1282     512      16    1810     712 main

可以看到 BSS 段增加了

現在我們再加幾個已經初始化的靜態變量試試(局部/全局都一樣)

#include <stdio.h>

int main(void) {
    static int a = 1;
    static int b = 2;

    return 0;
}
#include <stdio.h>

static int a = 1;
static int b = 2;

int main(void) {

    return 0;
}
$ size main
   text    data     bss     dec     hex filename
   1282     520       8    1810     712 main

可以看到,data 段增加了。

通過上面的分析,我們發現未初始化的全局變量,未初始化的靜態變量存在 bss 段,而已經初始化的全局變量,已經初始化的靜態變量存在 data 段。

存在 data/bss 兩段的變量,一般就叫靜態內存分配,所謂靜態就是編譯前就已經分配好了內存,這我們從上面也可以得知。

除了這兩種變量,還有 const 修飾的變量,register 修飾的變量等,但這都不是我們現在要討論的重點,這里就不展開了。

heap

我們在上面討論的 bss/data 區的變量,即靜態分配的變量,在編譯前就分配好了內存,程序結束才釋放,所以在程序允許過程中,這些內存里的數據是不會丟失的。所以我們可以把這些變量作為返回值,賦給調用的函數里的變量。或者我們多次調用同一個函數中聲明的靜態分配的變量,值是接着上一次調用的值的。

#include <stdio.h>

int StaticAlloc() {
    static int a = 123;
    return a;
}

int main(void) {
    printf("%d", StaticAlloc());
    return 0;
}

這里成調用值成功了,同樣的

#include <stdio.h>

void StaticAlloc() {
    static int a = 123;
    a++;
    printf("%d\n", a);
}

int main(void) {
    StaticAlloc();
    StaticAlloc();
    return 0;
}

如果這里沒看懂函數調用的原理,可以先看下面 stack 的部分,再重新看。

類似靜態分配變量的生命期是整個程序,還有一種變量也是這樣。

再來看一下內存結構

bss 區上面有一個 heap 區,我們使用 size 命令時沒有看到這個區,這是因為這個區和 stack 區一樣,是在程序裝載到內存中,開始運行后才分配的。圖里的箭頭就是這個意思。

heap 這個區和 stack 區一樣,程序會分配一個最大的內存限制,具體的值要看不同的操作系統。而在程序中實際使用了對於的變量,就是分配對應大小的值。在圖中就是 heap 向上擴張,而 stack 向下擴張。

普通的大小,能查看到返回的地址

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *a = (int *) malloc(4);
    printf("%p\n", a); // 0x5626852372a0

    return 0;
}

分配的內存過大,操作 heap 的最大值,所以沒有分配內存成功,直接返回 nil 了

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *a = (int *) malloc(9999999999999999);
    printf("%p\n", a); // nil

    return 0;
}

在 c 語言中,要在 heap 中分配內存,需要使用 stdlib.c 庫中的 malloc 函數,傳入分配的大小,返回那塊內存的地址。

#include <stdlib.h>

int main(void) {
    int *a = (int *) malloc(sizeof(int));

    return 0;
}

如這個代碼就是傳了 int 占據的 byte 大小,然后分配這么多內存,再返回首地址,轉換為 int 指針。這就是在 heap 上分配的,然后如果不釋放這塊內存的話,在程序結束前,這塊內存都會被操作系統認為你在使用,但是實際上你沒用,所以這塊內存就會被浪費,這就是內存泄漏。釋放的函數是 free。

#include <stdio.h>
#include <stdlib.h>

int DynamicAlloc() {
    int *a = (int *) malloc(sizeof(int));
    *a = 123;
    return *a;
}

int main(void) {
    printf("%d\n", DynamicAlloc()); // 123

    return 0;
}

在子函數中分配 heap內存,即使子函數結束了,里面分配的 heap 內存還在,數據也在。要釋放的話

#include <stdio.h>
#include <stdlib.h>

int DynamicAlloc() {
    int *a = (int *) malloc(sizeof(int));
    *a = 123;
    free(a);
    return *a;
}

int main(void) {
    printf("%p\n", DynamicAlloc()); // nil

    return 0;
}

釋放后,哪塊內存就不能用了,就變成了 nil。

從上面我們可以發現,heap 是運行期間自己分配的內存,需要自己釋放,不然的話就不會釋放。這種分配在 heap 上的方式就叫動態內存分配。

stack

看了上面的你可能還是有些不理解 heap 和靜態分配變量,那種子函數調用后返回地址,內存還能夠使用,這種性質。現在我們繼續來看 stack ,對比着你就能明白了。

繼續來看內存結構

stack 和 heap 一樣,在程序運行時才分配內存,運行后可以在 /proc 目錄對應進程文件查看,stack 同樣有一個 os 分配的最大值,具體分配多少,需要看實際的代碼。

stack 這個區域用來存 auto 變量,也就是局部作用域的變量,是我們平時使用最多的變量,包括形參,返回值,函數中的局部變量等。

我們知道 c 是由需要函數組成的,而局部變量也是按照函數來划分的,那么在 stack 中,又是怎么划分區域來存局部變量的呢?

下面的知識可能有一些錯誤,我沒有深入到匯編,查看寄存器的使用情況,這里只是為了解釋 stack 的分配問題。

編譯器會計算一個函數中局部變量的個數和大小,然后分配一定的內存空間作為一幀。函數名就是在一幀的入口地址,調用函數的時候,就會跳轉到這一幀然后使用這一幀里的內存。

首先是返回值入 stack,然后是普通的局部變量,最后是行參。按照我們上面講解 stack 這種數據結構,這里的 stack 區域也是這種性質,所以行參先出 stack,然后是普通的局部變量,最后是返回值。

當返回值出 stack 后,這個函數就調用結束了,然后返回到調用函數的地址,繼續執行,而這個函數分配的一幀地址就被操作系統回收了。

所以在函數中分配的局部變量,然后返回這個變量的地址,而在其他函數中是無法使用這塊地址的內存的,因為它已經被釋放了

#include <stdio.h>

int *AutoAlloc() {
    int a[] = {1, 2, 3};
    
    return a;
}

int main(void) {
    printf("%p", AutoAlloc()); // nil

    return 0;
}

如這個函數中,在 AutoAlloc 函數中分配的數組,存了 1,2, 3,然后把首地址作為返回值,在 main 中打印這個地址時,這個地址卻變成了 nil,這就是因為 AutoAlloc 分配的 stack 區域已經釋放了。

這種分配在 stack 上的方式也被稱為自動分配方式,也就是 auto,即局部變量分配。

再談 stack overflow

經過上面的分析,我想現在 stack 溢出這個問題已經很明顯了,就是 stack 已經滿了,而還要分配。如無限遞歸

void AutoAlloc() {
    AutoAlloc();
}

int main(void) {
    AutoAlloc();

    return 0;
}

這個函數就一直調用 AutoAlloc,就一直在 stack 區分配內存,最后 stack 區滿了,就溢出了。

除了無限遞歸的問題外,使用已經釋放的內存也是常遇到的問題,如在 leetcode 上刷數據結構時,有的題會需要你返回一個數組,然后數組中存數據。如果你在函數中用的 array 來存數組,而返回 array 的首地址的話,那么對方得到的地址就是 nil,就會報錯,這時候使用 heap 來存數據更合適。

總結

我們經過分析內存結構,代碼組成,然后引入變量的常用內存分配方式,從底層說明了 stack 溢出的原理。

從這個過程中,我也遇到許多問題,如內存結構,c 的匯編實現等底層知識,這再一次讓我意識到底層知識的重要性。

參考

Difference between static memory allocation and dynamic memory allocation

自動變量

函數內存分配

數據段、代碼段、堆棧段、BSS 段的區別

函數的返回值保存在內存的什么區域

實例分析 C 程序運行時的內存結構


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM