目录
现在需要参与到存储软件开发工作,存储层比较接近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); // 输出10
return 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(); // 输出1
counter(); // 输出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个int
if (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_H
math_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;
}