程式概念
解決問題的方法稱為演算法 (algorithm
),它是解決問題的一種「思路、方案」
表達問題的解決方法稱為 程式 (program
),它是將人類的思路與方案,透過電腦表達出來的一種「方式」
低階語言概述
● 以組合語言(也可稱為彙編語言)來說它必須符合兩個規定
A. 一對一性:不同程式皆是針對於不同的電腦設備,因為不同的處理器有不同的指令
,不可兼容於其它的處理器
B. 不可攜帶性(portable
):基於不可兼容
,攜帶到其他設備也沒有用
C. 編譯是透過「組譯器」
高階語言概述
● C
、C
、Java
或是 Kotlin
它不需依賴於處理器(CPU)的不同作編譯不同的程式,是一種可攜帶性語言
A. 不再關注特定電腦的體系結構,不依賴於指令集
B. 語法的標準化,在不同電腦上很少需要修改即可運行
C. 編譯是透過「編譯器、直譯器」
作業系統 & 編譯器 & 直譯器
作業系統 OS
● 控制電腦系統的程式,所有給予電腦的命令,都需要透過作業系統分配資源
and 引導
才能正常執行
● Unix 主要就是用 C 語言編寫,並對電腦架構做了很少的假設(抽象化作的很好),所以可以成功的移植到不同電腦系統中
編譯器 - compiler
● 編譯器及是 翻譯高階語法 給處理器知道的程式
● 副檔名 ?
副檔名是
.c
,這只是一個協定(讓電腦知道它是 C 程式),並不是要求副檔名是
.out
,,這是 Unix 系統下的執行檔
直譯器 - interpreter
● JavaScript
、BASIC
、Python
& 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 編譯(組合):一般副檔名為 .s
;1. 檢查語法、語意是否正確、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):一般副檔名為 .out
;1. 搜尋 Library 函式庫的程式,與程式碼連接、2. 連接上其他相關被編譯的檔案、3. 將編譯的程式碼編譯成可執行檔案
## 指令
gcc Hello.o -o Hello.out
執行 Hello.out
檔案
./Hello.out
● 各家編譯器會有不同的編譯流程,下圖是瑞薩 RL 系列 的 CCRL
GCC 常見擴展名
擴展名 | 含意 |
---|---|
.c | C Source Code |
.C /.cpp | C++ Source Code |
.m | Objective-C Source Code |
.h | C 或 C++ 的頭文件 |
.i | C 已經預處理過的檔案 |
.ii | C++ 已經預處理過的檔案 |
.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 語言與系統開發
● C 語言與系統開發:在這裡會說明 C 語言的實際應用,以及系統為 C 語言所提供的一些函數、庫... 等等工具,看它們是如何實現、應用