Skip to content

Linux 系统编程入门:gcc、库、Makefile 与文件 IO ​

Linux 系统编程从写代码到跑起来要过四道坎:编译、链接库、构建管理、调试;程序运行起来之后,最先打交道的就是文件。本文写给刚从 Windows/IDE 转到 Linux 命令行开发的 C/C++ 程序员,把这条路径上的基础串成一篇:gcc 的分步编译选项、静态库与动态库的制作和加载、Makefile 的规则写法、GDB 常用命令,以及文件 IO 的核心模型——文件描述符、缓冲区层次和 open/read/write/lseek/stat 一族接口。进程创建与进程间通信(fork、exec、管道、信号、共享内存)不在本文范围,见文末系列链接。

gcc 编译 ​

一个 C 源文件到可执行文件要经历预处理、编译、汇编、链接四步,gcc 用选项控制停在哪一步:

bash
gcc [源文件] [-o 输出名]

-E  预处理,输出预处理后的代码,不编译
-S  编译,生成汇编代码
-c  汇编,生成目标文件(.o),不链接
-I directory  指定头文件搜索目录
-g  生成调试信息,供调试器使用
-D  编译时定义一个宏
-l  链接指定的库

g++ 内部同样调用 gcc 后端,但 gcc 编译 C++ 时不会自动链接 C++ 标准库,需要显式加 -lstdc++。

静态库与动态库 ​

库的命名规则:Linux 上静态库 libxxx.a、动态库 libxxx.so;Windows 上对应 libxxx.lib 与 xxx.dll。

制作 ​

静态库:先把源文件编译成目标文件,再用 ar 打包:

bash
gcc -c a.c b.c          # 生成 a.o b.o
ar rcs libxxx.a a.o b.o # 打包静态库

动态库:编译时加 -fPIC(位置无关码),再链接为共享对象:

bash
gcc -c -fPIC a.c b.c
gcc -shared a.o b.o -o libxxx.so

使用与加载 ​

链接时用 -l 指定库名(xxx,不带 lib 前缀和扩展名),可以让 gcc 自动找到 libxxx.a/libxxx.so:

bash
gcc main.c -lxxx -L/path/to/lib -o app

静态库在链接期就被复制进可执行文件;动态库在运行期由动态载入器按路径查找加载。运行时找不到动态库是最常见的问题——载入器需要知道库的绝对路径,查找顺序依次是:

  1. 环境变量 LD_LIBRARY_PATH(临时:在当前终端 export;用户级:写入 ~/.bashrc;注意等号两侧不能有空格)
  2. 配置文件 /etc/ld.so.conf 中列出的目录,修改后执行 ldconfig 刷新 /etc/ld.so.cache
  3. 默认目录 /lib、/usr/lib
bash
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/lib

两者取舍:静态库加载快、发布时不依赖外部库文件,但浪费内存且库更新需要重新发布程序;动态库支持进程间共享一份代码、更新部署简单,代价是加载稍慢、发布时要带着依赖。

Makefile ​

工程源文件多了以后,用 Makefile 描述编译规则实现自动化构建。Makefile 的规则三要素:

makefile
目标... : 依赖...
	命令

从最直接的写法起步(一条规则列全所有源文件),逐步演进到变量、模式规则和函数,是理解 Makefile 的自然路径:

makefile
# 演进一:按目标拆分,修改一个文件只重编它
app: sub.o add.o main.o
	gcc sub.o add.o main.o -o app
sub.o: sub.c
	gcc -c sub.c -o sub.o
# ...每个 .o 一条规则

# 演进二:自定义变量 + 模式规则,一条规则覆盖所有 .o
src=$(wildcard ./*.c)
objs=$(patsubst %.c, %.o, $(src))
target=app
$(target):$(objs)
	$(CC) $(objs) -o $(target)
%.o:%.c
	$(CC) -c $< -o $@

.PHONY:clean
clean:
	rm $(objs) -f

用到的语法:$(wildcard ./*.c) 收集目录下全部 .c 文件;$(patsubst %.c, %.o, ...) 把列表中的 .c 替换成 .o;$< 是规则中的第一个依赖,$@ 是目标;.PHONY 声明 clean 是伪目标,不与同名文件冲突。

GDB 调试 ​

编译时加 -g 生成调试信息,gdb [可执行文件] 启动。常用命令按调试流程排列:

text
start            停在 main 第一行
run              运行到第一个断点
b/break 行号|函数名|文件:行号   设置断点
b 10 if i==5     条件断点(循环中常用)
i/info b         查看断点
d/del 编号        删除断点
dis/enable 编号   使断点无效 / en/enable 使生效

n/next           下一行,不进入函数体
s/step           单步,进入函数体
finish           跳出函数体
until            跳出循环
c/continue       继续到下一个断点

p/print 变量      打印变量值
ptype 变量        打印变量类型
display 变量      每步自动打印
set var 变量=值   修改变量值

一个省力细节:不输入命令直接回车,默认重复执行上一条命令(比如连续 next)。

文件 IO ​

两层缓冲模型 ​

标准 C 库的 IO 函数(fopen/fread/fprintf 一族)调用操作系统的底层接口,可跨平台,且自带用户态缓冲区;Linux 系统调用(open/read/write)不带这层缓冲,数据直接进内核。完整的写入路径:

理解这个层次能解释很多现象:printf 的输出不一定立刻出现在终端(还在用户态缓冲区里);进程崩掉时内核缓冲的数据通常还在(由内核负责写回);而 exit 与 _exit 的区别——前者退出前刷新 stdio 缓冲区,后者直接进系统调用不刷新,不带换行的未输出内容会丢失。

虚拟地址空间与文件描述符 ​

每个进程运行在自己的虚拟地址空间里。32 位 Linux 下低 3G 是用户空间、高 1G 是内核空间,用户空间从低到高依次是代码段、数据段、堆、共享库加载区、栈:

区域 内容
.text 代码段 程序指令
.data 已初始化数据段 初始化过的全局/静态变量
.bss 未初始化数据段 未初始化的全局/静态变量
堆 heap malloc/new 动态分配,向高地址增长
共享库加载区 动态库映射
栈 stack 局部变量、函数参数,向低地址增长
内核空间(高 1G) 所有进程共享,用户态不可直接访问

文件 IO 的桥梁是文件描述符:一个非负整数,进程用它来引用打开的文件。每个进程有一张文件描述符表,0、1、2 默认被标准输入、标准输出、标准错误占用;表项指向内核中统一的文件表(记录打开状态与文件偏移量),文件表再关联到具体的文件:

文件描述符表默认大小为 1024。fork 出的子进程会复制父进程的描述符表,两个进程的描述符指向同一文件表项,因此共享文件偏移量。

open / read / write / close ​

c
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 成功返回新的文件描述符,失败返回 -1 并设置 errno

int close(int fd);

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
// 返回实际读/写的字节数;read 返回 0 表示读到文件末尾;失败返回 -1

flags 必选其一:O_RDONLY、O_WRONLY、O_RDWR(互斥);常用可选:O_CREAT(不存在则创建,此时传第三个参数 mode 指定权限,八进制如 0664,最终权限是 mode & ~umask)、O_APPEND(追加)、O_TRUNC(清空)。

C 没有函数重载,两个 open 实际是同一个可变参数原型 open(pathname, flags, ...)。

read/write 的标准用法是循环拷贝:

c
char buf[1024];
int len;
while ((len = read(srcfd, buf, sizeof(buf))) > 0) {
    write(destfd, buf, len);
}

注意 write 的第三个参数用 read 的返回值 len 而不是缓冲区大小——最后一次读往往不足 1024 字节。

出错时用 perror 打印 errno 对应的描述:

c
if (fd == -1) {
    perror("open");  // 输出形如 open: No such file or directory
}

lseek 与文件空洞 ​

c
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
// 返回当前偏移量(相对文件开头,字节),失败返回 -1

whence 三个取值:SEEK_SET(文件开头 + offset)、SEEK_CUR(当前 + offset)、SEEK_END(文件末尾 + offset)。三个惯用法:lseek(fd, 0, SEEK_SET) 移到文件头;lseek(fd, 0, SEEK_CUR) 获取当前位置;lseek(fd, 0, SEEK_END) 获取文件长度。

lseek 允许把偏移量移到超过文件末尾的位置,下次在该位置写入时,原文件末尾与写入点之间就形成文件空洞:逻辑上是文件大小的一部分,读取时得到 \0,但物理上不占磁盘空间( du 显示的实际占用远小于 ls 显示的逻辑大小),直到真正写入时才分配块。

lseek 与 truncate(path, length) 的分工:lseek 移动的是读写位置,不改变文件长度——只 lseek 不写入,文件长度不变;truncate 直接把文件长度截断或扩展到指定值。

stat 获取文件信息 ​

c
#include <sys/stat.h>
int stat(const char *pathname, struct stat *statbuf);
int lstat(const char *pathname, struct stat *statbuf);
// 成功返回 0,失败返回 -1;statbuf 为传出参数

两者区别只在符号链接:stat 返回链接指向文件的信息,lstat 返回链接本身的信息。stat 结构体的常用字段:

c
struct stat {
    dev_t     st_dev;      // 文件所在设备
    ino_t     st_ino;      // inode 号
    mode_t    st_mode;     // 文件类型与权限
    nlink_t   st_nlink;    // 硬链接数
    uid_t     st_uid;      // 属主
    gid_t     st_gid;      // 属组
    off_t     st_size;     // 文件大小(字节)
    blksize_t st_blksize;  // 最优 IO 块大小
    blkcnt_t  st_blocks;   // 占用块数(以 512B 计)
    time_t    st_atime;    // 最后访问时间
    time_t    st_mtime;    // 最后修改时间
    time_t    st_ctime;    // 最后状态改变时间
};

st_mode 同时编码文件类型与权限,低 16 位分四段:

位段 含义
bit 15-12 文件类型(用 S_IFMT 掩码提取,与 S_IFREG/S_IFDIR 等比较,或直接用 S_ISREG()/S_ISDIR() 宏)
bit 11-9 附加位(set-uid、set-gid、sticky)
bit 8-6 属主权限 rwx
bit 5-3 属组权限 rwx
bit 2-0 其他用户权限 rwx

配套的属性操作函数:access(path, R_OK|W_OK|X_OK|F_OK) 判断权限或存在性、chmod/chown 改权限与属主、truncate 调整长度。

目录操作 ​

c
#include <dirent.h>
DIR *opendir(const char *name);          // 打开目录流
struct dirent *readdir(DIR *dirp);       // 逐项读取,末尾或出错返回 NULL
int closedir(DIR *dirp);
#include <sys/stat.h>
int mkdir(const char *path, mode_t mode);
#include <unistd.h>
int chdir(const char *path);             // 修改进程工作目录
char *getcwd(char *buf, size_t size);    // 获取当前工作目录
int rename(const char *old, const char *new);

readdir 返回的 struct dirent 中,d_name 是文件名,d_type 是类型(DT_REG 普通文件、DT_DIR 目录、DT_LNK 符号链接等)。递归统计目录下普通文件数量的骨架:

c
int count_files(const char *path) {
    DIR *dir = opendir(path);
    if (dir == NULL) { perror("opendir"); return 0; }
    struct dirent *ptr;
    int total = 0;
    while ((ptr = readdir(dir)) != NULL) {
        char *name = ptr->d_name;
        if (!strcmp(name, ".") || !strcmp(name, "..")) continue;
        if (ptr->d_type == DT_DIR) {
            char sub[256];
            sprintf(sub, "%s/%s", path, name);
            total += count_files(sub);   // 目录:递归
        } else if (ptr->d_type == DT_REG) {
            total++;                     // 普通文件:计数
        }
    }
    closedir(dir);
    return total;
}

注意跳过 . 和 ..,否则递归无法终止。

dup / dup2 / fcntl ​

c
#include <unistd.h>
int dup(int oldfd);               // 复制描述符,返回最小可用的新 fd,两者指向同一文件表项
int dup2(int oldfd, int newfd);   // 复制到指定 newfd(必要时先关闭 newfd),用于重定向
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);  // F_DUPFD 复制 / F_GETFL 取状态 / F_SETFL 设状态

dup2 是 shell 重定向的原理:dup2(fd, STDOUT_FILENO) 之后,写标准输出实际写到 fd 指向的文件。

fcntl 常用于运行期改打开状态,比如给已打开的文件追加 O_APPEND 或设置非阻塞:

c
int flag = fcntl(fd, F_GETFL);
flag |= O_APPEND;
fcntl(fd, F_SETFL, flag);

访问模式(O_RDONLY/O_WRONLY/O_RDWR)不能通过 F_SETFL 修改。

要点 ​

  • 编译分四步,-E/-S/-c 分别停在各步;链接库用 -l 加库名(不含 lib 前缀与扩展名)
  • 动态库运行期加载失败找 LD_LIBRARY_PATH、ld.so.conf + ldconfig、默认目录三条路
  • C 库 IO 有用户态缓冲、系统调用直通内核缓冲;exit 刷缓冲、_exit 不刷
  • 文件描述符是进程内的小整数,经文件描述符表 → 文件表 → inode 三层间接引用到文件;fork 后父子进程共享文件偏移量
  • read 返回 0 即文件末尾;write 长度用实际读到的字节数;lseek 只移位置不改长度,truncate 改长度
  • 进程创建与 IPC 的配套内容:fork/exec/进程回收见 Linux 多进程开发:fork、exec 与进程回收,管道、信号、共享内存、守护进程见各篇(文末目录可索引)
最近更新

基于 VitePress 构建