創作內容

5 GP

【程式】C/C++的static保留字

作者:Shark│2018-09-02 04:03:44│巴幣:10│人氣:2683
另一篇程式心得,從底層知識看static的用法。

C/C++把static保留字用在五個地方
  1. 函式裡的static變數。
  2. class、struct裡的static成員變數。
  3. class、struct裡的static成員函式。
  4. 放在global區塊(在函式、class或struct外面)的static變數。
  5. 放在global區塊的static函式。

其中1~3是有關聯的,4和5有關聯,但兩組之間卻沒什麼關聯。把同一個保留字用在兩個不同的地方,個人認為C/C++這樣設計不太好,會造成混淆。



1.和2.各個教學有不同解釋,有的說是讓變數只被初始化一次,有的說是讓變數被所有instance共用,但我覺得最貼切的講法是:把變數變成類似全域變數。

在「記憶體配置技巧」裡有提到一些,程式開始執行後,作業系統便會為程式配置以下幾種空間。
去查program memory layout的資料都可以看到類似的圖。


函式內的區域變數是用stack管理(還記得stack這個資料結構嗎?),新增區域變數時把stack增長一些,return時就把區域變數pop掉回到上一層。

C語言的main()其實不是.exe裡一開始就執行的,compiler會在.exe裡加一些內容,做一些前置處理再呼叫main()。

static變數則放在全域變數的空間,程式執行期間會一直存在,不會隨著函式return而消失。

例如這個程式
#include<stdio.h>
#include<stdint.h>


void addToStaticVar(int value){
  static uint64_t s1=4;
  s1+=value;
  printf("s1=%u\n",s1);
}

int main(int argc, const char* argv[]){
  addToStaticVar(argc);
  addToStaticVar(argc+2);
  return 0;
}

編譯成組合語言可看到這些內容。
;將s1存在data段並設初值為4
_DATA  SEGMENT
?s1@?1??addToStaticVar@@9@9 DQ 0000000000000004H
_DATA  ENDS

;printf用到的格式字串,也是放在global區域
CONST  SEGMENT
??_C@_06BGLNFPDF@s1?$DN?$CFu?6?$AA@ DB 's1=%u', 0aH, 00H
CONST  ENDS


;函式定義
addToStaticVar PROC
  ;依照x64 Microsoft calling convention,第一參數value位於rcx暫存器
  mov rdx, QWORD PTR ?s1@?1??addToStaticVar@@9@9 ;把s1載入rdx暫存器
  add rdx, rcx ;rdx=rdx+rcx

  lea rcx, OFFSET FLAT:??_C@_06BGLNFPDF@s1?$DN?$CFu?6?$AA@
    ;把第一參數:字串的位址放進rcx
  mov QWORD PTR ?s1@?1??addToStaticVar@@9@9, rdx ;把rdx存回s1
    ;第二參數要放rdx,但s1的值已經在rdx所以可以直接用
  rex_jmp QWORD PTR __imp_printf ;呼叫printf
addToStaticVar ENDP

s1變成很奇怪的名稱是因為編譯過程需要暫時的名稱,又不能跟程式裡現有的變數、函式衝突,compiler便自動產生正常寫程式不可能用的名稱。
另外可看到addToStaticVar()把區域變數都放在暫存器,所以沒有修改stack的操作,compiler會儘量把變數放在暫存器提升效能。


附帶一提輸出組合語言的方法,如果檔名是function_static.c,打這個命令列。
VC:
  cl /c function_static.c /Fafunction_static.asm /O2 /MD
GCC,只輸出組合語言:
  gcc -m64 -S function_static.c -o function_static.s -Os
GCC,同時列出C程式碼做對照:
  gcc>function_static.s -m64 -c -g function_static.c -Os -Wa,-a,-ad

/c:只做到編譯成obj這一步,不要產生exe檔。
/Fa:指定組合語言檔名。

-m64:編譯成64 bit架構。
-S:不要編譯成obj,只輸出組合語言。
-o:指定輸出檔名。

-g:在程式裡加入debug資訊。
-Wa,-a,-ad:傳參數給assembler,叫它在命令列顯示一些資訊,本例用「>function_static.s」將命令列輸出寫到檔案。

————————

2.class裡的static變數也是類似全域變數,如這個程式

#include<stdio.h>
#include<stdint.h>


struct Class{
  static uint64_t instanceCount;
  uint64_t value;
  uint64_t valueDouble;

  Class(int startValue){
    this->value=startValue;
    this->valueDouble=startValue*2;
    this->instanceCount++;
  }

  ~Class(){
    this->instanceCount--;
  }

  void printValue(){
    printf("valueDouble=%u instanceCount=%u\n",
      this->valueDouble,this->instanceCount);
  }

  static void printInstanceCount(){
    printf("instanceCount=%u\n",this->instanceCount);
  }
};

uint64_t Class::instanceCount=0;

int main(int argc, const char* argv[]){
  Class obj1(argc);
  Class obj2(argc+5);
  Class obj3(argc+10);
  obj1.printValue();
  obj2.printValue();
}

執行時會如下儲存變數(如果argc=1)

程式裡寫Class::instanceCount、obj1.instanceCount、obj2.instanceCount和obj3.instanceCount都是相同的變數。


或許有人注意到上面寫的是struct Class而不是class Class,順便介紹一下,有人以為struct裡不能寫method,但struct與class其實只有一個差別:struct的成員預設是public,class的成員預設是private。struct裡面也可以寫constructor、destructor和method,上面的struct改成這個寫法效果是一樣的。
class Class{
public:
  static uint64_t
instanceCount;
  uint64_t value;

  ……
};

————————

那3.是什麼呢?要先看看呼叫member function是怎麼回事。

上面程式裡的void printValue()不是static成員,編譯後會轉換成這樣的型式
void printValue(Class* this);
多一個隱藏參數this。

如果用「obj2.printValue();」呼叫,那編譯後變成如下
printValue(&obj2);
函式裡this=obj2的位址,利用這個指標可存取obj2的成員,C++裡的this保留字就是這麼回事。

printInstanceCount()宣告成static是表示沒有this參數,因此static函式不能存取非static成員,但可以在沒有物件instance的時候用Class::printInstanceCount()呼叫。
跟全域函式存取全域變數差不多。
uint64_t Class_instanceCount=0;

void Class_printInstanceCount(){
  printf("instanceCount=%u\n",Class_instanceCount);
}

用2.的程式做例子,觀察產生的組合語言
;Class::instanceCount存在bss段
_BSS  SEGMENT
?instanceCount@Class@@2_KA DQ 01H DUP (?)
_BSS  ENDS

;printf用到的格式字串
CONST SEGMENT
??_C@_0CB@KMIDPBFM@valueDouble?$DN?$CFu?5instanceCount?$DN?$CFu?6@ DB
  'valueDouble=%u instanceCount=%u', 0aH, 00H
CONST ENDS


;printValue()定義
?printValue@Class@@QEAAXXZ PROC
  ;第一參數放在rcx,所以此時rcx=this
  mov rdx, QWORD PTR [rcx+8] ;將this->valueDouble放進rdx
  mov r8, QWORD PTR ?instanceCount@Class@@2_KA
    ;將Class::instanceCount放進r8

  lea rcx, OFFSET FLAT:
    ??_C@_0CB@KMIDPBFM@valueDouble?$DN?$CFu?5instanceCount?$DN?$CFu?6@
    ;將格式字串的位址放在rcx
    ;此時要給printf的三個參數分別放在rcx,rdx,r8
  rex_jmp QWORD PTR __imp_printf ;呼叫printf
?printValue@Class@@QEAAXXZ ENDP

可以看到valueDouble用[rcx+8]取值,這個符號是將this往後數8 byte(因為前一個成員this->value佔用8 byte),再從這個位址讀取,但是instanceCount是讀取一個global變數。

另外也可看到呼叫函式的過程,先把參數放在指定的暫存器或stack,此例把三個參數分別放在rcx,rdx,r8,再用call或jmp跳躍到printf函式所在位址,函式內從這三個暫存器取得參數,這個規則是calling convention的一部分。
如果是在Linux x64做實驗,由於calling convention不同,使用的暫存器會不一樣。



4.和5.則是跟上面不同的東西,是讓變數或函式只能在一個c/cpp檔裡使用。

程式碼要分成兩個檔案才看得出效果。

static_global1.c
#include<stdio.h>

extern int counter;
extern void addCounter(int value);
extern void multiplyCounter(int value);

int main(){
  addCounter(5);
  printf("counter=%d\n",counter);
  multiplyCounter(3);
  printf("counter=%d\n",counter);
  return 0;
}

static_global2.c
int counter=0;

void addCounter(int value){
  counter+=value;
}

void multiplyCounter(int value){
  counter*=value;
}

compile和link要分開,因為obj檔等一下要用。
(本篇的GCC是在Linux測試,所以可執行檔沒有附檔名,如果用Windows版的GCC請自行修改命令列)
VC:
cl /c static_global1.c /Fostatic_global1.obj /O2 /MD
cl /c static_global2.c /Fostatic_global2.obj /O2 /MD
cl static_global1.obj static_global2.obj /Festatic_global.exe

GCC:
gcc -m64 -c static_global1.c -o static_global1.o -Os
gcc -m64 -c static_global2.c -o static_global2.o -Os
gcc static_global1.o static_global2.o -o static_global -s
程式分成兩個檔案,但linker可以找到變數和函式輸出可執行檔。
檔案1的變數和函式有加extern保留字,告訴compiler有這個變數和函式但實際定義在另一個地方,可能在這個.c下面,或是其他的.c檔。
檔案2的同名變數和函式沒有extern,表示是實際的定義。

函式的extern可以省略,檔案1像這樣寫也對。
void addCounter(int value);
void multiplyCounter(int value);
因為compiler可以用其他方法判斷是extern還是定義,如果沒有寫函式內容就是extern,如果後面有大括號+函式內容就是定義。

再寫一個檔案如下
static_global3.c
static int counter=0;

static void addCounter(int value){
  counter+=value;
}

void multiplyCounter(int value){
  counter*=value;
}

連結時輸入檔指定1和3,而不是1和2。
VC:
cl /c static_global3.c /Fostatic_global3.obj /O2 /MD
cl static_global1.obj static_global3.obj /Festatic_global.exe

GCC:
gcc -m64 -c static_global3.c -o static_global3.o -Os
gcc static_global1.o static_global3.o -o static_global -s

counter和addCounter加上static,規定這兩個只能在static_global3.c裡使用,不能讓其他obj檔連結,就會跳這樣的錯誤。
static_global1.obj : error LNK2019: 無法解析的外部符號 addCounter 在函式 main 中被參考
static_global1.obj : error LNK2019: 無法解析的外部符號 counter 在函式 main 中被參考

————————

查看obj檔的內容看發生什麼事,VC和GCC都有命令列工具可以檢查obj檔。

VC:dumpbin /symbols static_global2.obj
會出現這些(巴哈姆特的空間不夠寬,有些行被強制換行)
Dump of file static_global2.obj

File Type: COFF OBJECT

COFF SYMBOL TABLE
000 00E09EB5 ABS    notype       Static       | @comp.id
001 80000190 ABS    notype       Static       | @feat.00
002 00000000 SECT1  notype       Static       | .drectve
    Section length   2F, #relocs    0, #linenums    0, checksum        0
004 00000000 SECT2  notype       Static       | .debug$S
    Section length   84, #relocs    0, #linenums    0, checksum        0
006 00000000 SECT3  notype       Static       | .bss
    Section length    4, #relocs    0, #linenums    0, checksum        0
008 00000000 SECT3  notype       External     | counter
009 00000000 SECT4  notype       Static       | .text$mn
    Section length    7, #relocs    1, #linenums    0, checksum 18848B60, selection    1 (pick no duplicates)
00B 00000000 SECT5  notype       Static       | .text$mn
    Section length   10, #relocs    2, #linenums    0, checksum  6958443, selection    1 (pick no duplicates)
00D 00000000 SECT4  notype ()    External     | addCounter
00E 00000000 SECT5  notype ()    External     | multiplyCounter

String Table Size = 0x1F bytes

  Summary

           4 .bss
          84 .debug$S
          2F .drectve
          17 .text$mn
這裡要看的是三個External的項目,就是程式裡的global變數和函式。編譯時會把程式用到的符號記錄在obj裡,連結時再尋找實際的定義。
SECT3、SECT4和SECT5代表這個obj裡有定義。
其他項目的意義在此不介紹,請自己查dumpbin.exe的說明。

(因為很長,以下只列出重點部分)
查看static_global1.obj會看到這些
008 00000000 UNDEF  notype       External     | __imp_printf
009 00000000 UNDEF  notype ()    External     | addCounter
00A 00000000 UNDEF  notype ()    External     | multiplyCounter
013 00000000 UNDEF  notype       External     | counter
UNDEF代表這個obj裡沒有定義,要連結時去其他obj找。

再看static_global3.obj,會發現counter變成Static,External只剩一個。
008 00000000 SECT3  notype       Static       | counter
00B 00000000 SECT4  notype ()    External     | multiplyCounter
變數和函式宣告成static就是叫compiler不要把它放到符號表的External,所以外部無法連結到它。
至於addCounter(),「只能在static_global3.c裡用」且「static_global3.c沒用到」就表示它完全沒被使用,compiler就做最佳化把它刪除了。


GCC:objdump -t static_global2.o
輸出格式不一樣但觀念類似。
static_global2.o:     檔案格式 elf64-x86-64

SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 static_global2.c
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000000 l    d  .data  0000000000000000 .data
0000000000000000 l    d  .bss   0000000000000000 .bss
0000000000000000 l    d  .note.GNU-stack  0000000000000000 .note.GNU-stack
0000000000000000 l    d  .eh_frame  0000000000000000 .eh_frame
0000000000000000 l    d  .comment   0000000000000000 .comment
0000000000000000 g     F .text  0000000000000007 addCounter
0000000000000000 g     O .bss   0000000000000004 counter
0000000000000007 g     F .text  000000000000000e multiplyCounter
重點是有g的三項,g=global,l=local,g表示外部可以連結到這個符號。
objdump的詳細用法在此也不介紹,可以在命令列打man objdump或去網路上查。

(同樣另外兩個檔案只列重點)
static_global1.o
0000000000000000         *UND*  0000000000000000 addCounter
0000000000000000         *UND*  0000000000000000 counter
0000000000000000         *UND*  0000000000000000 printf
0000000000000000         *UND*  0000000000000000 multiplyCounter
*UND*代表這個obj裡沒有定義,要去其他obj找。

static_global3.o
0000000000000000 l     O .bss   0000000000000004 counter
0000000000000000 g     F .text  000000000000000e multiplyCounter
counter變成local,g只有一項,addCounter()被刪除。


把外部不會用到的變數、函式確實設成static有以下好處。
  1. 避免其他檔案意外地有同名符號而連結出錯。
  2. 把函式做public、private的區分,避免外部修改到模組內部。
    (這個比class的private成員函式好用,也讓compiler比較有空間調效能,我一般都用static函式代替private成員)
  3. 在「改造calling convention」也有提到,compiler能確定它全部被用在哪些地方就可以做最佳化,會看情況把它inline、使用非標準calling convention、或把用不到的東西刪除。

「非標準calling convention」要寫個夠長的程式才看得出來,一時想不到例子,我是拿自製引擎的程式碼做實驗發現的。



雖然上面又是組合語言又是dumpbin的,但寫程式遇到的一些奇特現象,特意避開技術用語就不好解釋,從底層運作的角度來看反而比較清楚易懂。
引用網址:https://home.gamer.com.tw/TrackBack.php?sn=4116131
All rights reserved. 版權所有,保留一切權利

相關創作

同標籤作品搜尋:程式|C|C++

留言共 0 篇留言

我要留言提醒:您尚未登入,請先登入再留言

5喜歡★shark0r 可決定是否刪除您的留言,請勿發表違反站規文字。

前一篇:【程式】鍵盤碼與按鈕名稱... 後一篇:程式語言擬人化的射擊遊戲...

追蹤私訊切換新版閱覽

作品資料夾

lucky74181大家
潛水員上岸了 YT - Yumi - Till the end (audio)看更多我要大聲說5小時前


face基於日前微軟官方表示 Internet Explorer 不再支援新的網路標準,可能無法使用新的應用程式來呈現網站內容,在瀏覽器支援度及網站安全性的雙重考量下,為了讓巴友們有更好的使用體驗,巴哈姆特即將於 2019年9月2日 停止支援 Internet Explorer 瀏覽器的頁面呈現和功能。
屆時建議您使用下述瀏覽器來瀏覽巴哈姆特:
。Google Chrome(推薦)
。Mozilla Firefox
。Microsoft Edge(Windows10以上的作業系統版本才可使用)

face我們了解您不想看到廣告的心情⋯ 若您願意支持巴哈姆特永續經營,請將 gamer.com.tw 加入廣告阻擋工具的白名單中,謝謝 !【教學】