目錄
與Java的差異化
編程范式
跨平臺
編譯過程
包管理
基本類型
內存結構
重點掌握
進制、字節與計算
指針
結構體
關鍵詞
動態內存
模塊化
高級特性
動態鏈接
虛擬內存
打包編譯
并發編程
現在需要參與到存儲軟件開發工作,存儲層比較接近OS系統和硬件,基本都是用C實現,比如數據庫、分布式存儲ceph,之前參與過PostgreSQL內核源碼的學習和改造,也對大型C項目有一定認識,準備系統性的重拾C語言,以此記錄,其實已經學了1周了,這里按照幾個方面來總結加固;
與Java的差異化
C與Java語言及開發應用方面的差異,就好比城市中市政工程和鴿子屋小區工程的差異,首先兩者的目的不同,市政工程是為打造便捷、高效、質量上乘的公共服務(如高速公路、隧道、大橋、電站、商場),鴿子屋小區主要是提供民用的家庭住房,兩者使用的材料就是開發語言的差異,前者需要用穩固/靠譜/昂貴的一流基礎原材料,后者使用較為普通但能快速使用的民用材料,其次前者多為非統一性需求所以可套用的模版很少,后者可以使用大量現成工具(List,Map)和模版(SpringBoot等框架)批量開發,所以C開發的組件都有偏底層/輕量化/高效/穩定/靠譜的代名詞,如nginx,sqlite,redis,PostgreSQL等數據庫,Java開發的組件較重且偏上層,如netty,tomcat,flink,hadoop,kafka等。
編程范式
Java和C有面向過程和面向對象的基本編程范式差異,面向過程基本上都是函數封裝和函數調用的過程,面向對象是針對對象的封裝和抽象,以及啟動后對象被實例化后的互相調用,面向對象其原生就是多作用域的(每個對象是一個作用域),想調用函數必須是持有實例化后的某對象Bean的調用來實現,所以面向對象才有嚴格的設計模式,設計模式最大程度的規范了不同場景下如何針對調用主體需求來組織代碼,保證代碼有序可讀。
而面向過程的代碼基本上就是縷清函數調用鏈的過程了,c大型工程中的調用鏈是非常長的,其注重的是算法的封裝和整合。
跨平臺
Java源代碼編譯為中間字節碼(.class文件)后再基于JVM虛擬機來解決跨平臺問題,在執行的時候才在OS上的JVM內編譯,這時候已經知道運行的底層OS了,JVM也是C寫的,其也被其他語言如scala、kotlin等直接使用。
編譯過程
C開發者必須要關注編譯過程,因為在這個過程還有很多的優化空間,比如內存對齊配置優化,比如靜態庫和動態庫配置等,但Java開發者無須關注其語言的編譯過程,只知道java -c A.java指令會把A.java編譯成A.class,然后啟動時候交給JVM虛擬機,JVM虛擬機在運行時候的加載->驗證->準備->初始化才是比較關注的重要點,因為這里涉及到某個對象的單例和初始化過程,很多框架要在這里做文章。
C的編譯過程包括:預編譯-->編譯-->匯編-->鏈接四個過程,以test.c為例,預編譯完成crtl +c 和crtl+v的代碼替換把頭文件和注釋處理掉并生成test.i文件,編譯就是把test.i編譯成匯編語言test.s,匯編代碼本質是低級的編程語言,還是人類可讀但機器不可讀的語言,匯編把匯編代碼test.s匯編成機器可讀的test.o文件,但其中的函數和變量的內存地址是0000...沒有替換成實際的地址,且每個.c會生成一個.o,不是單個程序入口,鏈接就是完成最后一步的合并和鏈接,比如PostgreSQL編譯后是50M左右大小的二進制文件,這就是其源碼編譯后的大小了。
C的編譯過程是把test.c直接編譯成0/1機器碼,運行時候直接加載機器碼到內存段,所以啟動表較快,Java啟動時候完成的工作比較多所以啟動很慢。
包管理
Java 確實通過中心化包管理器(如 Maven、Gradle)管理依賴,可直接通過配置文件(如pom.xml)引用遠程倉庫(如 Maven Central)中的工具包(如 Hutool),無需手動拷貝源碼,依賴管理高度標準化,這種模式依賴中央倉庫統一維護包版本,支持自動解析依賴鏈,大幅降低集成成本。
C 語言沒有官方統一的中心化包管理器,傳統依賴管理以源碼集成或靜態 / 動態庫引用為主(如拷貝.c/.h源碼,或編譯為.o、.a、.so文件后通過頭文件引用),在編譯時C 語言需手動處理頭文件路徑、庫文件路徑,比如顯式指定鏈接參數(如-lxxx),依賴管理的自動化程度較低。
聽說C現在也有了一些包管理工具;
基本類型
從基本類型設計上的比較能看出兩者語言的不同和設計之別,拋開最最基本的數據類型,Java提供大量可變(String)、封裝性好的容器(List)來支持開發者隨取隨用,C的設計保守出于一個宗旨就是把內存分配和回收的決定權交給開發者,對開發者要求很高;
且Java因為有JVM兜底來實現跨硬件特性其所有類型定長,但C在不同硬件上定義的類型可能不定長。
內存結構
Java的內存管理全權交給JVM,重點關注的是堆Heap區域,這個區域在啟動時候就已經指定并限制了其最大空間,除非是非堆內存溢出,不然不會超過XMx大小,其內的對象通過一些特定的算法來回收內存(引用計算),所以最大的風險來源于實例化的bean是否有可能持續增大,不釋放后造成堆內存溢出,或者線程池溢出造成的非堆內存溢出;
┌───────────────────────────────────────────────────────────┐
│ JVM進程內存空間 │
├───────────────────────┬───────────────────────────────────┤
│ 線程私有區域 │ 線程共享區域 │
├────────────┬──────────┼──────────┬───────────────────────────┤
│ 程序計數器 │ 本地方法棧 │ 虛擬機棧 │ 堆 │
│ (PC Register) │ (Native Stack) │ (Java Stack) │ (Heap) │
│ 記錄當前指令地址 │ 調用本地方法時使用 │ 存儲棧幀(局部變量等) │ 對象實例存儲區 │
└────────────┴──────────┼──────────┴───────────────────────────┤│ ↑│ │├─────────────────┼───────────────────────┤│ 方法區/元空間 │ 直接內存 ││ (Method Area/Metaspace) │ (Direct Memory) ││ 存儲類信息、常量等 │ NIO等操作使用的堆外內存│└───────────────────────────────────────────┘
C語言的內存模型可以分為四個部分:代碼段、數據段、堆和棧,代碼段是只讀的空間,存儲加載到內存的源代碼,數據段存儲全局可共享的靜態變量,內部又分為bss和數據段,堆就是os內存了,棧是局部方法使用的空間。
┌───────────────────────────────────────────────────────────┐
│ 進程虛擬地址空間 │
├───────────────────────┬───────────────────────────────────┤
│ 高地址區域 │ 低地址區域 │
├────────────┬──────────┼──────────┬───────────────────────────┤
│ 內核空間 │ 命令行參數/環境變量 │ │
│ (Kernel) │ (Command Line Args) │ 代碼段(.text) │
│ │ │ 存儲可執行代碼和只讀數據 │
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ 只讀數據段(.rodata) │
│ │ │ │ 存儲字符串常量等只讀數據 │
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ 數據段(.data) │
│ │ │ │ 存儲已初始化全局/靜態變量 │
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ BSS段(.bss) │
│ │ │ │ 存儲未初始化全局/靜態變量 │
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ 堆(Heap) │
│ │ │ │ 動態內存分配區域(向上增長)│
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ 棧(Stack) │
│ │ │ │ 存儲局部變量和函數調用 │
│ │ │ │ (向下增長) │
├────────────┼──────────┼──────────┼───────────────────────────┤
│ │ │ │ 棧保護區域(Guard Page) │
│ │ │ │ 防止棧溢出攻擊 │
└────────────┴──────────┴──────────┴───────────────────────────┘
這里要理解為啥Java內存模型中有針對線程內存和共享的規劃而C沒有呢,因為C在語言層面不支持多線程,需要引用外部庫來實現,但Java從設計之初就支持多線程:包括JVM層面的線程資源和區域共享規劃、包括語言層面的線程間通信與主內存同步(如volatile、synchronized)、還有一些線程間通信的高級用法?,Java多線程是在OS基礎上進行了一些高度抽象并提供給語言API使用,隔離了底層如Windows和Linux的差異化細節,所以這里C內存模型只有主進程的模型,而沒有多線程下的規劃,因為每個OS的線程庫本身是不同的。
重點掌握
上面從Java與C比較來理解C的某些特性,單從編程語言的語法分析C是比Java簡單的,因為其基本類型少、高級API少、依賴的成熟框架少,熟悉其基本語法使用后就可以針對某個特定領域開發了,但是特定領域與其他領域之間的差距是非常大的。下面是需要重點掌握的一些其他特性。
進制、字節與計算
計算機CPU只認0/1所以計算機中數據/代碼都是二進制存儲的,進制(Base)是指計數系統中使用的基數,即每個數位的權重由該基數的冪次決定,常見的進制包括:二進制基數為2(0b或0B開頭),使用0和1表示,八進制基數為8(0開頭)使用0-7表示,十進制基數為10使用0-9表示,十六進制基數為16(0x開頭)使用0-9和A-F(或a-f)表示。
字節是最小的尋址單元,每個字節都對應一個存儲器地址,稱為字節地址,CPU 可通過該地址對字節進行讀寫等操作。
基本數據類型的字節大小并非固定不變,而是取決于編譯器和目標平臺(如 32 位或 64 位系統),但有相對大小關系(如short <= int <= long),具體實現由編譯器決定:
char:1 字節(固定為 1,由sizeof(char)定義)
short:至少 2 字節(常見 2 字節)
int:至少 2 字節(32 位系統通常 4 字節,64 位系統可能 4 或 8 字節)
long:至少 4 字節(32 位系統 4 字節,64 位系統 8 字節)
除了最小的字節單位,還有很多其他大小的單位,這里要記住1G是1字節乘以2的30次方,所以要理解為啥32位系統只能使用最大2的2次方也就是4G內存。
1KB(Kilobyte,千字節) = 1024B
1MB(Megabyte,兆字節) = 1024KB
1GB(Gigabyte,吉字節) = 1024MB
1TB(Terabyte,太字節) ?= 1024GB
1PB(Petabyte,拍字節) = 1024TB
?計算機中的計算參考:計算機中的計算
指針
指針提供了直接操作內存地址的可能,指針的本質是變量,它在通過int *p = &a的定義時候就已經說明自己是個指向int類型的指針,無論指向什么類型的指針都是默認也是占4個字節(32系統中)
系統架構 指針大小 示例平臺
32 位系統 4 字節 Windows 98/XP、嵌入式系統(如 ARM Cortex-M3)
64 位系統 8 字節 主流桌面系統(Windows 10/11、Linux、macOS)
16 位系統 2 字節 歷史系統(如 DOS、早期單片機)
我們做個測試我本機是64位的OS那么:
#include <stdio.h>
int main() {printf("char*: %zu 字節\n", sizeof(char*));printf("int*: %zu 字節\n", sizeof(int*));printf("void*: %zu 字節\n", sizeof(void*)); // 通用指針類型return 0;
}
輸出
char*: 8 字節
int*: 8 字節
void*: 8 字節
?以下為例:
#include <stdio.h>int main() {// 定義一個整型變量int a = 10;// 定義一個指向整型的指針變量,指向 a 的地址int *p = &a;// 打印 a 的地址和值printf("a 的地址是: %p\n", (void*)&a);printf("a 的值是: %d\n", a);// 打印指針 p 的地址、值(即 a 的地址)、以及 *p 的值(即 a 的值)printf("p 的地址是: %p\n", (void*)&p);printf("p 的值(即 a 的地址)是: %p\n", (void*)p);printf("*p 的值(即 a 的值)是: %d\n", *p);// 修改 a 的值a = 20;printf("修改后,*p 的值是: %d\n", *p);// 修改 *p 的值(等價于修改 a 的值)*p = 30;printf("修改后,a 的值是: %d\n", a);// 字符串示例char str[] = "abc"; // 字符數組char *ptr = str; // 指針指向數組第一個元素printf("\n字符串示例:\n");printf("str 的地址是: %p\n", (void*)str);printf("ptr 的地址是: %p\n", (void*)&ptr);printf("ptr 的值(即 str 的地址)是: %p\n", (void*)ptr);printf("*ptr 的值(即第一個字符 'a')是: %c\n", *ptr);// 遍歷字符串printf("字符串內容: ");for (int i = 0; i < 3; i++) {printf("%c ", *(ptr + i));}printf("\n");// 二級指針int x = 10;int *y = &x;int **zz = &y; // pp是二級指針printf("%d", **zz); // 輸出10return 0;
}
輸出:
a 的地址是: 00000000005FFE8C
a 的值是: 10
p 的地址是: 00000000005FFE80
p 的值(即 a 的地址)是: 00000000005FFE8C
*p 的值(即 a 的值)是: 10
修改后,*p 的值是: 20
修改后,a 的值是: 30字符串示例:
str 的地址是: 00000000005FFE7C
ptr 的地址是: 00000000005FFE70
ptr 的值(即 str 的地址)是: 00000000005FFE7C
*ptr 的值(即第一個字符 'a')是: a
字符串內容: a b c
10
結構體
把結構體理解成Java中只有屬性沒有方法的類定義是合理的,不過在Java中最佳時間是把屬性設置成private,并暴露public的方法,而C中結構體是沒有類似約束的,結構一般是全局定義,每個.c文件都能使用它,一般結構體是結合指針使用的,也就是內存中傳遞的并不是實例化后的結構體,而是實例化后的結構體對應的指針地址,并通過struct->a來訪問a字段,用個簡單的程序測試用法,沒啥說的:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>// 1. 基礎結構體定義
struct Point {int x;int y;
};// 2. 使用typedef創建結構體別名
typedef struct {char name[20];int age;float score;
} Student;// 3. 嵌套結構體
typedef struct {int year;int month;int day;
} Date;typedef struct {Student student; // 嵌套Student結構體Date birthday; // 嵌套Date結構體char *address; // 指針成員
} Person;// 測試函數:打印Point結構體信息
void printPoint(struct Point p) {printf("Point: (%d, %d)\n", p.x, p.y);
}// 測試函數:通過指針修改Student信息
void updateStudent(Student *s, float newScore) {s->score = newScore;
}int main() {printf("===== C語言結構體特性測試 =====\n\n");// ===== 1. 結構體初始化方式 =====printf("=== 1. 結構體初始化 ===\n");// 1.1 基礎結構體初始化struct Point p1 = {10, 20}; // 順序初始化struct Point p2 = {.y=50, .x=30}; // 指定成員初始化(C99)struct Point p3;p3.x = 100; p3.y = 200; // 成員賦值初始化printf("p1: (%d, %d)\n", p1.x, p1.y);printf("p2: (%d, %d)\n", p2.x, p2.y);printf("p3: (%d, %d)\n", p3.x, p3.y);// 1.2 嵌套結構體初始化Person person = {.student = {"Alice", 20, 95.5f},.birthday = {2003, 5, 15},.address = "北京市朝陽區"};printf("Person: %s, %d歲, 分數=%.1f\n", person.student.name, person.student.age, person.student.score);printf("生日: %d-%d-%d\n", person.birthday.year, person.birthday.month, person.birthday.day);printf("地址: %s\n", person.address);// ===== 2. 結構體指針與尋址 =====printf("\n=== 2. 結構體指針與尋址 ===\n");// 2.1 結構體變量的地址printf("p1的地址: %p\n", &p1);printf("p1.x的地址: %p\n", &p1.x);printf("p1.y的地址: %p\n", &p1.y);// 2.2 指針訪問結構體成員struct Point *pPtr = &p1;printf("通過指針訪問: (%d, %d)\n", pPtr->x, pPtr->y);// 2.3 動態分配結構體Student *sPtr = (Student*)malloc(sizeof(Student));if (sPtr != NULL) {strcpy(sPtr->name, "Bob");sPtr->age = 18;sPtr->score = 89.0f;printf("動態分配的Student: %s, %.1f分\n", sPtr->name, sPtr->score);free(sPtr); // 釋放內存}// ===== 3. 結構體作為函數參數 =====printf("\n=== 3. 結構體作為函數參數 ===\n");printPoint(p2); // 值傳遞(拷貝結構體)Student s = {"Charlie", 22, 92.5f};printf("更新前分數: %.1f\n", s.score);updateStudent(&s, 96.0f); // 指針傳遞(修改原結構體)printf("更新后分數: %.1f\n", s.score);// ===== 4. 結構體數組 =====printf("\n=== 4. 結構體數組 ===\n");Student class[3] = {{"Alice", 20, 95.5f},{"Bob", 18, 89.0f},{"Charlie", 22, 92.5f}};for (int i = 0; i < 3; i++) {printf("學生%d: %s, %d歲\n", i+1, class[i].name, class[i].age);}// ===== 5. 內存對齊 =====printf("\n=== 5. 內存對齊 ===\n");// 5.2 內存對齊測試struct {char a; // 1字節int b; // 4字節(需4字節對齊)short c; // 2字節} alignTest;struct {int b; // 4字節(需4字節對齊)char a; // 1字節short c; // 2字節} alignTest2;printf("對齊測試結構體大小: %zu字節\n", sizeof(alignTest)); // 通常為12字節printf("對齊測試結構體大小: %zu字節\n", sizeof(alignTest2)); // 通常為12字節return 0;
}
關鍵詞
C語言默認是全局作用域,所以它的關鍵詞并不多,其中static extern 與作用域需要重點掌握,static可以用于修飾局部變量和全局變量,被static修飾的局部變量有以下特性:
存儲在靜態存儲區(而非棧上),程序運行期間僅初始化一次;
函數調用結束后變量值不會丟失,下次調用時保留上次的值;
但其作用域沒變仍限于函數內部,外部不可訪問。
static修復的全局變量僅在定義的文件內可見,無法通過extern在其他文件中引用,避免命名沖突(不同文件可定義同名的static全局變量)通過一個例子掌握:
# include <stdio.h>
static int global_var = 10; // 僅在當前的.c可見
static void helper() { // 僅在當前.c內部可見printf("Helper function\n");
}
// 其他的.c文件
//extern int global_var; // 錯誤!無法引用當前.c的static變量
//extern void helper(); // 錯誤!無法引用當前.c的static函數
void counter() {static int count = 0; // 僅初始化一次count++;printf("Count: %d\n", count);
}
int main() {counter(); // 輸出1counter(); // 輸出2(保留上次的值)helper();return 0;
}
再說extern關鍵詞,extern用于告訴編譯器變量 / 函數在其他文件中定義,無需分配內存,鏈接時會查找實際定義的位置,被extern修飾的變量不能初始化(extern int x = 10;是錯誤的,這會變成定義),extern允許多個源文件訪問同一全局變量或函數,因為默認情況下,函數聲明隱式包含extern,這從某個層面來說C語言是不是最早的函數第一公民的編程語言呢?
// extern-1.c 中
#include <stdio.h>
int global_x = 10; // 定義全局變量
int main()
{int local_x = 20; // 定義局部變量printf("local_x = %d, global_x = %d\n", local_x, global_x);return 0;
}// extern_2.c 中
#include <stdio.h>
extern int global_x; // 聲明外部變量
void print_global() {printf("global_x: %d\n", global_x);
}
extern int add(int a, int b); // 顯式聲明外部函數
int add2(int a, int b); // 默認情況下,函數聲明隱式包含extern
int main() {print_global();return 0;
}
再說static和extern搭配的最佳實踐:
1、static extern 不可以修飾同一變量,因為static 要求變量僅在文件內可見,而 extern 要求變量在外部可見,二者矛盾;
2、文件內部的static 變量被同一文件的 extern 函數訪問(外部可調用該函數間接操作 static 變量),來實現類似于面向對象的private屬性+public方法的效果
// utils.c
static int counter = 0; // 僅在utils.c可見
static extern int x; // 錯誤!沖突的修飾符extern void increment() { // 外部可調用counter++;
}// main.c
extern void increment(); // 聲明外部函數
int main() {increment(); // 間接操作utils.c的static變量return 0;
}
動態內存
動態內存也就是把內存的動態開辟和管理交給開發者,主要是幾個函數:
- malloc函數分配未初始化的內存塊,比如malloc(5 * sizeof(int)) 申請 5 個 int 的空間。
- calloc函數分配并自動初始化為 0 的內存塊,適合需要初始化的場景(如數組清零),比如calloc(5, sizeof(int)) 申請并初始化 5 個 int 的空間。
- realloc函數調整已分配內存的大小,如果原內存后有足夠的空間,直接擴展;否則會分配新內存并復制數據,必須先檢查返回值(可能失敗),并將新指針賦給原指針前先保存到臨時變量。
- free函數釋放動態分配的內存,釋放后必須將指針置為 NULL,避免懸空指針(dangling pointer)。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>int main() {// 1. 動態開辟初始內存(使用 malloc)int *arr = (int *)malloc(5 * sizeof(int)); // 申請5個int的空間if (arr == NULL) {printf("Memory allocation failed!\n");return 1; // 處理內存分配失敗}// 2. 初始化內存(使用 calloc 或手動初始化)// 選項1: 使用 calloc 自動初始化為0// int *arr = (int *)calloc(5, sizeof(int));// 選項2: 手動初始化(使用 malloc 后)for (int i = 0; i < 5; i++) {arr[i] = i + 1; // 填充數據}// 3. 打印初始數組printf("Initial array:\n");for (int i = 0; i < 5; i++) {printf("%d ", arr[i]);}printf("\n");// 4. 調整內存大小(使用 realloc)int newSize = 10;int *newArr = (int *)realloc(arr, newSize * sizeof(int)); // 擴展到10個intif (newArr == NULL) {printf("Memory reallocation failed!\n");free(arr); // 保留原內存以防萬一return 1;}arr = newArr; // 更新指針// 5. 填充新擴展的內存for (int i = 5; i < newSize; i++) {arr[i] = i + 1; // 繼續填充新元素}// 6. 打印調整后的數組printf("Resized array:\n");for (int i = 0; i < newSize; i++) {printf("%d ", arr[i]);}printf("\n");// 7. 釋放內存并防止野指針free(arr);arr = NULL; // 置空指針,避免懸空指針return 0;
}
模塊化
C 語言模塊化的核心思想是分離接口與實現,通過頭文件暴露公共接口,源文件封裝內部細節,這就比較抽象了,類似于Java的接口和實現類的編程思想,同名的.c文件最好有個.h文件,兩者是聲明和實現的關系,同時入口main.c依賴于其他的.c源文件。
頭文件(.h)和源文件(.c)是實現模塊化編程的核心,合理劃分和組織它們能顯著提高代碼的可維護性、可復用性和可擴展性。
.h頭文件用于暴露模塊的公共接口,包括:函數原型、全局變量聲明(使用extern)、結構體 / 枚舉 / 聯合體定義、宏定義和類型別名(typedef),相對應的.c源文件包含模塊的具體實現,包括函數體、全局變量定義、靜態變量 / 函數(模塊內部使用)
math_utils.h 內容
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
// 函數原型
int add(int a, int b);
int subtract(int a, int b);
// 結構體定義
typedef struct {int x;int y;
} Point;
// 宏定義
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#endif // MATH_UTILS_Hmath_utils.c 內容
#include "math_utils.h"
// 函數實現
int add(int a, int b) {return a + b;
}
int subtract(int a, int b) {return a - b;
}
// 靜態函數(僅在本文件可見)
static int multiply(int a, int b) {return a * b;
}
多個源文件的設計標準是遵從單一職責原則,避免出現Java那種循環依賴問題,不是太大的C工程一般是多個源文件放在一起,很大的項目才會在src中分目錄,如果分目錄可能涉及到Makefile文件的配置了,后續可以注意下:
project/
├── include/ # 存放所有頭文件
│ └── math_utils.h
├── src/ # 存放所有源文件
│ └── math_utils.c
│ └── main.c
└── Makefile # 編譯腳本
高級特性
高級特性涉及到一些優化和運行態的更底層原理,以及一些高級API調用,其中有些概念是比較抽象但是腦海中一定要有的概念,比如虛擬內存,
動態鏈接
動態鏈接相對于靜態鏈接說的,Windows下的dll和Linux下的.so文件都是動態鏈接庫,可以熱更新而不影響主程序,比如升級linux下的libgp.so肯定不也不會影響PostgreSQL數據庫進程。
動態鏈接是在運行時才將程序與共享庫(.so/.dll)鏈接,可執行文件main僅包含對共享庫的引用,不包含庫的實際代碼。
# 編譯源文件
gcc -c main.c -o main.o
# 動態鏈接(假設libmylib.so是動態庫)
gcc main.o -L. -lmylib -o myapp # 與靜態鏈接命令相同
# 運行時需確保系統能找到libmylib.so
export LD_LIBRARY_PATH=./:$LD_LIBRARY_PATH # Linux臨時設置庫路徑
./myapp
靜態鏈接是指在編譯時就將所有依賴的庫代碼直接復制到最終的可執行文件中,鏈接完成后不依賴外部庫運行;
# 編譯源文件
gcc -c main.c -o main.o
gcc -c utils.c -o utils.o
# 靜態鏈接(假設libmylib.a是靜態庫)
gcc main.o utils.o -L. -lmylib -o myapp # -L指定庫路徑,-l指定庫名
如何創建靜態和動態庫:
# 編譯源文件為目標文件
gcc -c utils.c -o utils.o
# 創建靜態庫
ar rcs libutils.a utils.o # rcs參數:替換、創建、寫入索引# 編譯時添加-fPIC(生成位置無關代碼)
gcc -fPIC -c utils.c -o utils.o
# 創建動態庫
gcc -shared -o libutils.so utils.o # -shared指定生成共享庫
PostgreSQL 的主程序 postgres 是一個靜態鏈接的可執行文件,通過靜態鏈接包含數據庫引擎的核心邏輯(如查詢解析、事務管理、存儲引擎),但PostgreSQL 的擴展功能(如全文搜索、JSON 函數)通過 .so 文件實現;
虛擬內存
當C程序被編譯時,編譯器會為每個函數和全局變量生成一個邏輯地址(虛擬地址)。這些地址基于一個假設的“起始地址0”的連續內存空間,但程序實際加載到物理內存時,操作系統會將其放置到任意的物理地址(取決于當時內存的空閑情況)。
虛擬內存是操作系統提供的一種內存管理技術,它為每個進程都提供一個獨立的、連續的虛擬地址空間,使程序可以使用比實際物理內存更大的地址范圍,虛擬內存的必要性:
問題 虛擬內存的解決方案
物理內存碎片化 虛擬地址連續映射到任意物理頁,避免外部碎片
多進程內存沖突 進程間地址空間隔離,互不可見
程序大于物理內存 通過分頁交換(Paging)擴展可用內存
代碼/數據位置不確定 虛擬地址固定,運行時動態重定位
內存安全漏洞 權限控制+隔離,防止非法訪問內核或其他進程
操作系統上的程序(無論使用何種編程語言)都間接依賴虛擬內存,但由于我們可以直接操作虛擬內存指針所以需要了解其基本原理,C 指針存儲的是虛擬地址,需通過操作系統和 MMU 轉換為物理地址。若映射失效(如內存釋放后),訪問該地址會觸發段錯誤。
虛擬地址 → MMU(內存管理單元) → 頁表(Page Table) → 物理地址
待深入挖掘;?
打包編譯
打包編譯需要通過configure和Makefile來完成,都是autotools工具鏈,configure是configure.ac生成,而configure.ac
(或configure.in
)使用的是M4 宏語言(GNU M4)來編寫的,configure完成環境監測和配置,并根據Makefile.in或者Makefile.am模版來生成Makefile文件,Makefile直接進行編譯安裝:
用戶命令 系統狀態
┌───────────────────┐ ┌─────────────────────┐
│ ./configure │ │ 1. 檢測編譯器 │
│ --prefix=/usr │ ─────? │ 2. 檢查依賴庫 │
│ --enable-feature │ │ 3. 生成config.h │
└───────────────────┘ │ 4. 生成Makefile │└─────────────────────┘│▼
┌───────────────────┐ ┌─────────────────────┐
│ make │ │ 1. 編譯源文件 │
│ │ ─────? │ 2. 鏈接目標文件 │
└───────────────────┘ │ 3. 生成可執行文件 │└─────────────────────┘│▼
┌───────────────────┐ ┌─────────────────────┐
│ make install │ │ 1. 復制文件到指定路徑│
│ │ ─────? │ 2. 創建目錄結構 │
└───────────────────┘ │ 3. 設置文件權限 │└─────────────────────┘
C 語言大型項目中Makefile 是自動化構建的核心工具,其設計質量直接影響開發效率和項目可維護性,那么其最佳實踐是啥呢,首先項目必然是按照功能的多模塊拆分,使用清晰的目錄結構:
project/
├── src/ # 源代碼(按模塊拆分:core/, network/, utils/)
├── include/ # 公共頭文件(模塊私有頭文件可放在子目錄)
├── build/ # 編譯輸出(.o、.a、.so)
├── third_party/ # 第三方庫(如 Boost、OpenSSL)
├── tests/ # 單元測試代碼
├── Makefile # 根 Makefile
└── modules/ # 模塊級 Makefile(可選)
一般大型項目又包括根Makefile和子模塊的Makefile,根 Makefile統籌全局并調用子模塊 Makefile,子模塊 Makefile完成獨立模塊編譯并生成靜態庫(.a)或動態庫(.so),比如根模塊一般有以下用法:
# 根 Makefile 示例
SUBDIRS = src/network src/utils tests
.PHONY: all clean
all: $(SUBDIRS)$(SUBDIRS):$(MAKE) -C $@clean:for dir in $(SUBDIRS); do $(MAKE) -C $$dir clean; done
大型C語言會有以.in 結尾的 Makefile 模版文件(如 Makefile.in),它是 GNU Autotools 工具鏈中的核心組成部分,主要用于自動化生成跨平臺兼容的 Makefile,configure 腳本會替換 Makefile.in 中的占位符(如 @CC@ 替換為 gcc),生成適配當前系統的 Makefile;
Makefile.in 中的變量(如 @CC@、@CFLAGS@)由 configure 根據系統環境動態填充,比如CC = @CC@ ?# 在 Linux 上可能是 "gcc",在 macOS 上可能是 "clang",
通過變量替換支持不同系統的路徑差異,比如prefix = @prefix@ ?# 默認值為 "/usr/local"
# Makefile.in 的簡化示例
# 自動生成的注釋
# @configure_input@# 變量替換(由 configure 填充)
CC = @CC@
CFLAGS = @CFLAGS@
LDFLAGS = @LDFLAGS@# 項目規則
bin_PROGRAMS = myprogram
myprogram_SOURCES = main.c utils.c
myprogram_LDADD = libmylib.a# 安裝路徑
prefix = @prefix@
exec_prefix = @exec_prefix@
bindir = $(exec_prefix)/bin
一般大型項目都采用cmake了,CMake 比 Autotools(configure/Makefile)簡化了很多?,但需要編寫?CMakeLists.txt,CMake 通過抽象層直接支持多平臺,同一套 CMakeLists.txt 可生成 Windows(Visual Studio)、Linux(Makefile)、macOS(Xcode)的構建文件,是更加現代化的構建工具。
并發編程
在 Linux 下使用 C 語言進行多線程編程,通常依賴于 POSIX 線程庫(pthread),以下示例演示兩個線程對一個全局變量進行遞增操作,并通過互斥鎖確保數據一致性。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>// 共享變量
int number = 0;
// 互斥鎖
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;// 線程函數
void* increment_thread(void* arg) {int thread_id = *(int*)arg;for (int i = 0; i < 5; i++) {// 加鎖pthread_mutex_lock(&mutex);number++;printf("Thread %d: number = %d\n", thread_id, number);// 解鎖pthread_mutex_unlock(&mutex);sleep(1); // 模擬工作時間}return NULL;
}int main() {pthread_t thread1, thread2;int tid1 = 1, tid2 = 2;// 創建線程if (pthread_create(&thread1, NULL, increment_thread, &tid1) != 0) {perror("Failed to create thread 1");return 1;}if (pthread_create(&thread2, NULL, increment_thread, &tid2) != 0) {perror("Failed to create thread 2");return 1;}// 等待線程結束pthread_join(thread1, NULL);pthread_join(thread2, NULL);printf("Final number: %d\n", number);return 0;
}