編譯器的角度看程式 | 低階與高階、作業系統、編譯器、直譯器、預處理 | C語言函數探討

編譯器的角度看程式 | 低階與高階、作業系統、編譯器、直譯器、預處理 | C語言函數探討

Overview of Content

這篇文章廣泛的探討程式概念,包括低階語言與高階語言的概述,並探討作業系統、編譯器、直譯器等相關主題。

內容涵蓋了作業系統的基本知識,編譯器和直譯器的區別,以及C語言的編譯過程。此外,預處理的主題也被細分為 cpp、define 與 typedef、include、宏定義等,並實作、展現每種不同的編譯階段所產出的內容。

最後,以編譯的角度,我們探討了C語言中函數的聲明、定義、調用,以及函數在堆疊中的運作。旨在幫助讀者更深入理解程式語言的核心概念和工具


程式概念

解決問題的方法稱為演算法 (algorithm),它是解決問題的一種「思路、方案

表達問題的解決方法稱為 程式 (program),它是將人類的思路與方案,透過電腦表達出來的一種「方式

低階語言概述

● 以組合語言(也可稱為彙編語言)來說它必須符合兩個規定

A. 一對一性不同程式皆是針對於不同的電腦設備,因為不同的處理器有不同的指令,不可兼容於其它的處理器

B. 不可攜帶性(portable):基於不可兼容,攜帶到其他設備也沒有用

C. 編譯是透過「組譯器」

高階語言概述

CCJava 或是 Kotlin 它不需依賴於處理器(CPU)的不同作編譯不同的程式,是一種可攜帶性語言

A. 不再關注特定電腦的體系結構,不依賴於指令集

B. 語法的標準化,在不同電腦上很少需要修改即可運行

C. 編譯是透過「編譯器、直譯器」


作業系統 & 編譯器 & 直譯器

作業系統 OS

● 控制電腦系統的程式,所有給予電腦的命令,都需要透過作業系統分配資源 and 引導才能正常執行

Unix 主要就是用 C 語言編寫,並對電腦架構做了很少的假設(抽象化作的很好),所以可以成功的移植到不同電腦系統中

編譯器 - compiler

● 編譯器及是 翻譯高階語法 給處理器知道的程式

● 副檔名 ?

副檔名是.c,這只是一個協定(讓電腦知道它是 C 程式),並不是要求

副檔名是.out,,這是 Unix 系統下的執行檔

直譯器 - interpreter

JavaScriptBASICPython & Unix's shell

● 不需經過編譯即可執行,運行時同時 分析執行

● 速度較慢,因為不會轉換成低階型式

C 語言的編譯器

● 常見的 C 語言編譯器:

A. gcc 編譯器:使用GNU通用公共許可證(GPL)等自由軟件許可證發布


sudo apt install -y gcc

B. clang 編譯器:用一個更寬鬆的許可證(University of Illinois / NCSA Open Source License)發布,並它基於 LLVM


sudo apt install -y clang

LLVM (Low-Level Virtual Machine)? 它是一個開源的編譯器基礎建設項目

LLVM 的主要目標是提供一個靈活、高效、模塊化的編譯器基礎架構,以便用於各種不同的編譯和代碼優化任務

一般來說 Clang 的編譯速度較快

它的架構允許前端(負責解析源代碼並生成中間表示)和後端(負責將中間表示轉換為目標平台的機器代碼)能夠獨立地進行擴展和優化

相較之下 GCC 則是前、後端共同開發

由於切開維護使得拓展變得容易並清晰,也使得在不同的語言和目標平台上進行編譯變得更加容易

編譯的過程 - 概述

編譯指令 gcc 的選項可以透過 man gcc 查看

C 是關注程式 的語言(命令範式),重順序性、演算法

● C 編譯的過程 (Building) 一般來說會經過幾個階段:預編譯、編譯、匯編、連結


// Hello.c

#include <stdio.h>

int main(void) {

    printf("Hello C");

    return 0;
}

A. cpp 預編譯:一般副檔名為 .i;編譯 #define、#if、#ifndef、#endif...等等預編譯指令


## 指令

gcc -E Hello.c -o Hello.i

cpp 並不是說 C++ 檔案,它的意思是 C Preprocessor

預編譯結果較長,這裡只擷取部分,其中可以看到 #include <stdio.h> 被替換為具體的 stdio.h 檔案


... 省略一大部分

extern int __uflow (FILE *);
extern int __overflow (FILE *, int);

# 902 "/usr/include/stdio.h" 3 4

# 2 "Hello.c" 2


# 3 "Hello.c"
int main(void) {

 printf("Hello C");

 return 0;
}

B. cc 編譯(組合):一般副檔名為 .s1. 檢查語法、語意是否正確、2. 將高階語言翻譯成低階語言(指令集、機械指令)


## 以下兩個指令都可以運行

gcc -S Hello.c -o Hello.s

gcc -S Hello.i -o Hello.s

編譯結果如下


    .file	"Hello.c"
    .text
    .section	.rodata
.LC0:
    .string	"Hello C"
    .text
    .globl	main
    .type	main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq	%rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq	%rsp, %rbp
    .cfi_def_cfa_register 6
    leaq	.LC0(%rip), %rdi
    movl	$0, %eax
    call	printf@PLT
    movl	$0, %eax
    popq	%rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size	main, .-main
    .ident	"GCC: (Ubuntu 9.4.0-5ubuntu1) 9.4.0"
    .section	.note.GNU-stack,"",@progbits
    .section	.note.gnu.property,"a"
    .align 8
    .long	 1f - 0f
    .long	 4f - 1f
    .long	 5
0:
    .string	 "GNU"
1:
    .align 8
    .long	 0xc0000002
    .long	 3f - 2f
2:
    .long	 0x3
3:
    .align 8
4:

C. as 匯編:一般副檔名為 .o,將上一個步驟中的組合語言轉為轉為 CPU 可了解的二進制碼


## 指令

gcc -c Hello.s -o Hello.o

由於編譯出來的是二進位文件,所以一般人是看不懂的

● 目標檔案(.o 擴展名)通常都是以二進制形式表示的,而不是純文本;這些二進制檔案的內容對於人類而言可能不是可讀的字符

D. ld 連結 (Linked):一般副檔名為 .out1. 搜尋 Library 函式庫的程式,與程式碼連接、2. 連接上其他相關被編譯的檔案、3. 將編譯的程式碼編譯成可執行檔案


## 指令

gcc Hello.o -o Hello.out

執行 Hello.out 檔案


./Hello.out

● 各家編譯器會有不同的編譯流程,下圖是瑞薩 RL 系列 的 CCRL

GCC 常見擴展名

擴展名含意
.cC Source Code
.C/.cppC++ Source Code
.mObjective-C Source Code
.hC 或 C++ 的頭文件
.iC 已經預處理過的檔案
.iiC++ 已經預處理過的檔案
.s編譯後的文件檔,之後編譯不再進行預處理操作
.S編譯後的文件檔,之後編譯可以再進行預處理操作
.o匯譯後的文件檔
out最後鏈結,變成一個平台可執行檔案
a靜態 Library
.so動態 Library

預處理 cpp

cpp( C Preprocessor )檔案

A. 頭文件替換 #include.h 檔案的內容會被原封不動的替換進 .c 檔案

B. 宏定義替換 #define

C. 條件替換 #if#else#elif#endif#ifndef#ifdef ... 等等

D. keep 特殊處理 #pragma ... 等等

E. 移除注釋


預編譯 define & typedef

● 要證明 define & typedef 是否都在預編譯處理,使用以下程式進行預編譯,測試他們是否都是預編譯時會處理的關鍵字


// Typedef_Test.c

#define	dChar_t	char*

typedef char* 	tChar_t;

int main(void) {

    dChar_t	c1, c2;
    tChar_t c3, c4;

    return 0;
}

● 對上面程式進行 cc、選項 -i 進行預編譯:


gcc -E Typedef_Test.c -o Typedef_Test.i

從結果來看 可以知道 typedef 是在編譯時期處理而不是在預編譯時期 ( #define 才在預編譯時處理 )

預處理 - include

● 有關於 include 會使用到兩個符號

A. 尖括號 <>:編譯器直接去系統指定目錄尋找;


# 在編譯時也可以使用 `-I` 選項來指定目錄

cc -c -I <指定目錄> 源碼.c

像是 Unix 就會去 /usr/include 目錄尋找

B. 雙引號 ""1. 編譯器會去 當前文件目錄下尋找2. 找不到才去系統目錄找

● include 進來的頭文件會 直接替換 進源檔案中

A. H_Test.h:宣告三個變數


int apple;

short book;

char name;

B. H_Test.c:引用 H_Test.h


#include <stdio.h>
#include "H_Test.h"

int main(void) {
    int c = a + b;

    printf("%d", c);

    return 0;
}

下圖省略 stdio.h

可以看到 H_Test.h 是直接被替換上 Source code

宏定義 - 解析

宏也就是 #define,可以用來 減少函數的開銷

#define 只是 原封不動的替換;但要注意它是可以遞迴進行替換的,直到不末端不再是宏為止

#define可以帶參數每個參數在宏中都必須括號,最後整體再括號 (括號相當重要)

A. 無參數宏:


#define SEC_YEAR    (365*24*60*60UL)    // UL: unsigned long

B. 有參數宏:


#define MAX(a, b)    (((a) > (b)) ? (a) : (b))    // 括號相當重要,不使用會容易出錯

● 以下範例就是一個有參宏缺少括號造成的問題,導致跟原來要表達的意思完全不同


// 缺少 括號
#define ADD_1(X, Y)	X+Y

// 每個參數都有括號
#define ADD_2(X, Y)	((X)+(Y))

int main(void) {

    int a = 10, b = 30;

    int c = 3 * ADD_1(a, b);

    int d = 3 * ADD_2(a, b);

    return 0;
}

預編譯


gcc -E Define_Test.c -o Define_Test.i

預編譯 結果


# 1 "Define_Test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "Define_Test.c"



int main(void) {

 int a = 10, b = 30;

 int c = 3 * a+b;    ## 錯誤

 int d = 3 * ((a)+(b));    ## 正確

 return 0;
}

宏定義 v.s inline 內聯

\一般函數宏定義inline 內聯函數
優點編譯器會進行類型檢查在預編譯時期就解決,不會消耗內存使用時進行替換,沒有 Stack開銷
缺點耗費 Stack 空間,效率較低不進行類型檢查,並建議不要進行 ++/-- 操作耗費內存,如果有循環就更耗費時間

● inline 內聯特色

A. 必須定義時使用,加在函數宣告是沒有用的

B. 編譯時會進行參數類型檢查(靜態語言的特性)


// 在宣告使用時沒用
inline int add(int a, int b);

int add(int a, int b) {
    return a + b;
}

// 必須在定義時使用
inline int subtraction(int a, int b) {
    return a - b;
}

條件編譯

● 條件宏 #if#else#elif#endif#ifndef#ifdef ... 等等


#define 	NUM_1

#define		NUM_2	1	
#define 	NUM_3	1


int main(void) {

    int a = 0, b = 0;
    
#ifdef	NUM_1
    a = 100;    // 有定義,預編譯後會顯示
#endif

#undef	NUM_1    // 取消定義

#ifndef NUM_1
    a = 200    // 預編譯後會顯示
#endif

#if	NUM_1
    a = 300;
#endif

#if	(NUM_1 && NUM_2)    // 多定義判斷,由於 NUM_1 沒有定義,所以條件不符合
    b = 300;
#elif	NUM_2
    b = 301;            // 預編譯後會顯示
#elif	NUM_3
    b = 302;
#else
    b = 303;
#endif

    return 0;
}

with no expression 錯誤 !?

#if#elif 後面不但會檢查是否有定義還會可以檢查 定義值,如果沒有定義值就會報這個錯誤

但像是 #define 就不要求需要定義值

預編譯後結果,可以看到 不符合宏判斷條的原始碼就會被忽略,無法進入下一個編譯階段


int main(void) {

 int a = 0, b = 0;

 a = 100;

 a = 200;
 
 b = 301;

 return 0;
}

編譯時加入定義

● 預編譯的條件不一定要寫在源碼內,可以透過編譯時指令指定要使用的巨集;格式如下


# 添加以下選項,動態決定巨集

-D<聚集名>[=數值] 

範例如下


#include <stdio.h>

int main(void) {

// 源碼內沒有定義 HELLO 巨集
#ifdef HELLO
    printf("Hello~\n");
#else
    printf("Hi~\n");
#endif

    return 0;
}

A. 尚未添加 -D 選項去指定巨集


cc main.c -o mainWithOutD.o

B. 添加 -D 選項去指定巨集


cc main.c -DHELLO=1 -o mainWithD.o


從編譯器的角度看函數

● 函數有幾個特點

A. 入參建議不要超過 4 個,超過建議使用 struct 包裹起來(或是使用指標)

否則可能會造成 stack 的負擔

B. 傳入參數大小也不建議過大,避免超過 Stack Size,較大參數建議使用 Pointer 傳遞

C. 編譯完後函數 會存在 elf 中的 .text(共享區)

● 可以使用 readelf 指令查看

readelf -S Hello.out

函數 - 聲明、定義、調用

● 函數宣告

● 首先要先知道 編譯器在編譯程式時,是以 文件為單位,所以在哪個文件裡面調用,就要在哪個文件內聲明

編譯器在編譯時會按照文件中的先後順序進行編譯,所以 如果沒有宣告該函數,就必須按照順序進行撰寫

● 宣告主要是告訴編譯器函數的原型

大多數都聲明在 .h 標頭檔案內

函數聲明可以重複! 但函數定義不可重複

● 函數定義:

● 當函數定義出來後,就 表明了該函數的地址在哪

● 可以不用宣告就直接定義,但是要注意順序;函數定義在後面,前面的函數就無法使用


int main(void) {
    int result = subtraction(3, 5);    //    Error  呼叫不到 subtraction 函數
}

int subtraction(int a, int b) {
    return a - b;
}

● 函數呼叫:


// function.c    

#include <stdio.h>

int add(int, int);    // 函數可多次聲明
int add(int, int);
int add(int, int);

int main(void) {

    int result = add(3, 5);    // 使用函數名呼叫

    printf("result: %d\n", result);

    return 0;
}

int add(int a, int b) {    // 但只能定義一次
    return a + b;
}

編譯


gcc function.c -o function.out

函數 - 入棧 Stack

● 函數的入棧是按照順序的,如下面程式的入棧順序就是

● Stack 棧是由核心棧存器 SP 來管控,相關的核心棧存器還有 鏈結 LR、計數器 PC

A. main 函數返回地址

B. main 函數

C. 遇到 subtraction 函數,保存 subtraction 函數完成後的返回地址

D. subtraction 函數入棧,這裡又細分為,參數由右到左入棧


int subtraction(int a, int b) {
    return a - b;
}

int main(void) {
    int result = subtraction(3, 5);    //    Error  呼叫不到 subtraction 函數
}

Stack 概念圖如下


更多的 C 語言相關文章

關於 C 語言的應用、研究其實涉及的層面也很廣闊,但主要是有關於到系統層面的應用(所以 C 語言又稱之為系統語言),為了避免文章過長導致混淆重點,所以將文章係分成如下章節來幫助讀者更好地從不同的層面去學習 C 語言

編譯器、系統開念

編譯器、系統開念:是學習完 C 語言的基礎(或是有一定的程度)之後,從編譯器以及系統的角度重新檢視 C 語言的一些細節

C 語言與系統開發

C 語言與系統開發:在這裡會說明 C 語言的實際應用,以及系統為 C 語言所提供的一些函數、庫... 等等工具,看它們是如何實現、應用

Leave a Comment

Comments

No comments yet. Why don’t you start the discussion?

發表迴響