Linux系统编程入门到精通学习指南

内容来自:程序员老廖的个人空间

第01章 走进Linux系统编程

学习目标:理解Linux系统编程的本质,掌握系统调用与库函数的区别,编写第一个实用工具。

引言:为什么学习Linux系统编程?

假设你需要开发一个日志分析工具,每天处理数GB的服务器日志文件。你会发现:

  • ❓ 如何高效读取大文件?
  • ❓ 如何同时处理多个文件?
  • ❓ 如何控制程序的资源占用?
  • ❓ 普通的C语言知识够用吗?

这些问题的答案都指向同一个方向:Linux系统编程。

什么是系统编程?

系统编程是编写直接与操作系统交互的软件:

本教程聚焦于Linux系统编程,帮你掌握开发服务器程序、系统工具所需的核心技能。

1.1 系统调用:与内核对话的唯一通道

1.1.1 什么是系统调用?

想象你的程序是一个普通员工,而Linux内核是公司老板。员工不能直接访问公司保险柜(硬件资源),必须向老板申请:

你的程序:      "我要读取 data.txt 文件"
                    ↓ (系统调用)
Linux内核:     "好的,我帮你读取,返回给你"
                    ↓
你的程序:      "收到文件内容!"

系统调用(System Call) 就是这个申请通道。它是Linux内核提供给应用程序的API接口,所有涉及硬件访问的操作都必须通过它。

1.1.2 系统调用的完整工作流程

下图展示了一个 open() 系统调用的完整执行过程:

图 1.1.1 系统调用完整执行流程

关键过程解析:

1.用户态准备(蓝色区域)

  • 应用调用 open() → glibc包装 → 准备系统调用参数

2.特权级切换(橙色区域)

  • 执行 syscall 指令 → CPU从Ring 3切换到Ring 0
  • 保存用户态寄存器状态 → 切换到内核栈

3.内核处理(绿色区域)

  • 查系统调用表 → 执行 sys_open() 处理函数
  • 文件路径解析 → 权限检查 → 查找inode
  • 分配文件描述符 → 创建文件表项

4.硬件交互(紫色区域)

  • 从磁盘读取文件元数据(inode信息)

5.返回用户态

  • 恢复寄存器 → 切回Ring 3 → 返回 fd=3

性能开销: 每次系统调用涉及两次特权级切换(用户态→内核态→用户态),这就是为什么要尽量减少系统调用次数。

1.1.3 核心系统调用详解

open() - 打开文件

函数原型:

#include <fcntl.h>
​
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

参数说明:

常用flags组合:

// 只读打开(文件必须存在)
int fd = open("data.txt", O_RDONLY);
​
// 只写打开,不存在则创建
int fd = open("output.txt", O_WRONLY | O_CREAT, 0644);
​
// 读写打开,存在则清空内容
int fd = open("temp.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
​
// 追加写入(数据写到文件末尾)
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);

返回值:

  • ✅ 成功:返回文件描述符(非负整数,通常≥3)
  • ❌ 失败:返回 -1,错误码存储在全局变量 errno 中

文件描述符的特殊值:

权限模式说明(mode):

// 权限位组成:0644 = 所有者可读写,其他人只读
// 采用八进制表示,每3位二进制对应一位八进制
​
Owner  Group  Others
 rw-    r--    r--
 110    100    100  (二进制)
  6      4      4   (八进制)
​
// 使用宏定义(更清晰)
mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;  // rw-r--r--
//            所有者读  所有者写   组读      其他读

实战示例:

int fd = open("config.txt", O_RDONLY);
if (fd == -1) {
    perror("打开文件失败");  // 自动显示errno对应的错误信息
    return 1;
}
printf("成功打开文件,文件描述符: %d\n", fd);

read() - 读取数据

函数原型:

#include <unistd.h>
​
ssize_t read(int fd, void *buf, size_t count);

参数说明:

返回值:

  • > 0:实际读取的字节数(可能小于count)
  • = 0:已到文件末尾(EOF)
  • = -1:读取失败,检查errno

为什么读到的可能少于请求的字节数?

场景1: 文件剩余 500 字节,请求 1024 字节 → 返回 500
场景2: 文件剩余 2000 字节,请求 1024 字节 → 返回 1024  
场景3: 网络数据未到齐 → 返回已接收的部分(非阻塞模式)

典型用法:

char buffer[1024];
int n;

// 循环读取直到文件结束
while ((n = read(fd, buffer, 1024)) > 0) {
    // 处理读取到的n个字节
    printf("读取了 %d 字节\n", n);
}

if (n == -1) {
    perror("读取错误");
}

write() - 写入数据

函数原型:

#include <unistd.h>

ssize_t write(int fd, const void *buf, size_t count);

参数说明:

返回值:

  • > 0:实际写入的字节数
  • = -1:写入失败

注意事项:

  • 写入操作可能被信号中断,返回值小于count
  • 磁盘空间不足时会失败
  • 如果打开时使用了 O_APPEND,数据会追加到文件末尾

close() - 关闭文件

函数原型:

#include <unistd.h>

int close(int fd);

返回值:

  • 0:成功关闭
  • -1:失败(通常不会发生)

为什么必须关闭文件?

  1. 刷新缓冲区 - 确保数据真正写入磁盘
  2. 释放资源 - 文件描述符是有限的(默认进程最多1024个)
  3. 解除锁定 - 其他进程才能访问该文件
  4. 避免泄漏 - 长期运行的程序会耗尽资源
close(fd);
// fd失效,不能再使用!

1.1.4 实战:使用系统调用实现文件复制

让我们编写一个完整的文件复制工具,理解系统调用的实际应用:

代码文件: src/chapter01/syscall_copy.c

/*
 * 文件: syscall_copy.c
 * 功能: 使用系统调用实现文件复制
 * 
 * 演示内容:
 *   - open() 打开文件
 *   - read() 读取数据
 *   - write() 写入数据
 *   - close() 关闭文件
 * 
 * 编译: gcc -o syscall_copy syscall_copy.c
 * 运行: ./syscall_copy source.txt target.txt
 */

#include <fcntl.h>   // open()
#include <unistd.h>  // read(), write(), close()
#include <stdio.h>   // printf(), perror()

int main(int argc, char *argv[]) {
    // 1. 检查命令行参数
    if (argc != 3) {
        printf("用法: %s <源文件> <目标文件>\n", argv[0]);
        printf("示例: %s input.txt output.txt\n", argv[0]);
        return 1;
    }
    
    // 2. 打开源文件(只读模式)
    int src_fd = open(argv[1], O_RDONLY);
    if (src_fd == -1) {
        perror("打开源文件失败");
        return 1;
    }
    printf("✓ 源文件打开成功,fd=%d\n", src_fd);
    
    // 3. 创建目标文件(只写模式,存在则截断)
    //    权限设置为 rw-r--r-- (0644)
    int dst_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dst_fd == -1) {
        perror("创建目标文件失败");
        close(src_fd);  // 记得关闭已打开的文件
        return 1;
    }
    printf("✓ 目标文件创建成功,fd=%d\n", dst_fd);
    
    // 4. 循环读取并写入数据
    char buffer[1024];  // 1KB缓冲区
    int bytes_read;
    int total_bytes = 0;
    
    while ((bytes_read = read(src_fd, buffer, sizeof(buffer))) > 0) {
        // 将读取到的数据写入目标文件
        int bytes_written = write(dst_fd, buffer, bytes_read);
        if (bytes_written == -1) {
            perror("写入失败");
            close(src_fd);
            close(dst_fd);
            return 1;
        }
        total_bytes += bytes_written;
    }
    
    if (bytes_read == -1) {
        perror("读取失败");
        close(src_fd);
        close(dst_fd);
        return 1;
    }
    
    // 5. 关闭文件
    close(src_fd);
    close(dst_fd);
    
    printf("✓ 文件复制完成!共复制 %d 字节\n", total_bytes);
    return 0;
}

代码逐行解析:

1.参数检查(第18-22行)

  • 确保用户提供了源文件和目标文件名
  • 友好的错误提示

2.打开源文件(第25-30行)

  • O_RDONLY 表示只读模式
  • 失败时打印错误并退出

3.创建目标文件(第33-40行)

  • O_WRONLY 只写模式
  • O_CREAT 不存在则创建
  • O_TRUNC 存在则清空
  • 0644 设置权限为 rw-r--r--

4.循环复制(第43-57行)

  • 每次读取1KB数据
  • 立即写入目标文件
  • 累计复制的字节数

5.清理资源(第60-64行)

  • 关闭两个文件描述符
  • 显示复制结果

编译运行:

# 编译
gcc -o syscall_copy 01_syscall_copy.c

# 测试(先创建一个测试文件)
echo "Hello, System Call!" > test.txt

# 运行
./syscall_copy test.txt copy.txt

# 验证结果
cat copy.txt
# 输出: Hello, System Call!

1.2 库函数:系统调用的高级封装

1.2.1 为什么需要库函数?

直接使用系统调用有性能问题:

场景:读取16KB文件,缓冲区4KB

系统调用方式:
read(fd, buf, 4KB)  ← 陷入内核
read(fd, buf, 4KB)  ← 陷入内核
read(fd, buf, 4KB)  ← 陷入内核
read(fd, buf, 4KB)  ← 陷入内核
共4次系统调用,8次特权级切换!

标准C库的解决方案: 在用户空间增加缓冲层

库函数方式:
fread(buf, 4KB, 1, fp)  ← 库内部处理,不陷入内核
fread(buf, 4KB, 1, fp)  ← 库内部处理,不陷入内核
  ↓ 库缓冲区空了,触发一次系统调用
read(fd, lib_buf, 16KB)  ← 陷入内核读取更多数据
fread(buf, 4KB, 1, fp)  ← 从库缓冲区返回
fread(buf, 4KB, 1, fp)  ← 从库缓冲区返回
只需1-2次系统调用!

1.2.2 系统调用 vs 库函数对比

图 1.2.1 系统调用与库函数性能对比

1.2.3 标准IO库函数

fopen() - 打开文件流

#include <stdio.h>

FILE *fopen(const char *pathname, const char *mode);

模式字符串:

返回值:

  • 成功:返回 FILE* 指针
  • 失败:返回 NULL

fread() / fwrite() - 读写数据

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);

参数说明:

  • ptr:数据缓冲区
  • size:每个元素的字节数
  • nmemb:元素个数
  • stream:文件流指针

返回值: 实际读/写的元素个数

fclose() - 关闭文件流

int fclose(FILE *stream);

重要: fclose() 会自动刷新缓冲区,确保数据写入磁盘。

1.2.4 实战:使用标准IO实现文件复制

代码文件: src/chapter01/stdio_copy.c

/*
 * 文件: stdio_copy.c
 * 功能: 使用标准IO库实现文件复制
 * 
 * 演示内容:
 *   - fopen() 打开文件流
 *   - fread() 读取数据(带缓冲)
 *   - fwrite() 写入数据(带缓冲)
 *   - fclose() 关闭文件流
 * 
 * 编译: gcc -o stdio_copy stdio_copy.c
 * 运行: ./stdio_copy source.txt target.txt
 */

#include <stdio.h>

int main(int argc, char *argv[]) {
    // 1. 检查命令行参数
    if (argc != 3) {
        printf("用法: %s <源文件> <目标文件>\n", argv[0]);
        return 1;
    }
    
    // 2. 打开源文件(二进制只读)
    //    "rb" 模式适用于任何文件类型
    FILE *src = fopen(argv[1], "rb");
    if (src == NULL) {
        perror("打开源文件失败");
        return 1;
    }
    printf("✓ 源文件打开成功\n");
    
    // 3. 创建目标文件(二进制只写)
    FILE *dst = fopen(argv[2], "wb");
    if (dst == NULL) {
        perror("创建目标文件失败");
        fclose(src);
        return 1;
    }
    printf("✓ 目标文件创建成功\n");
    
    // 4. 循环读写数据
    char buffer[1024];
    size_t bytes_read;
    size_t total_bytes = 0;
    
    while ((bytes_read = fread(buffer, 1, sizeof(buffer), src)) > 0) {
        size_t bytes_written = fwrite(buffer, 1, bytes_read, dst);
        if (bytes_written != bytes_read) {
            printf("写入错误\n");
            fclose(src);
            fclose(dst);
            return 1;
        }
        total_bytes += bytes_written;
    }
    
    // 5. 关闭文件(自动刷新缓冲区)
    fclose(src);
    fclose(dst);
    
    printf("✓ 文件复制完成!共复制 %zu 字节\n", total_bytes);
    return 0;
}

对比两种方式:

1.3 命令行参数处理

1.3.1 argc 和 argv 详解

每个C程序的 main 函数可以接收命令行参数:

int main(int argc, char *argv[]) {
    // argc: 参数个数(argument count)
    // argv: 参数数组(argument vector)
}

示例: 当执行 ./program hello world 时:

argc = 3
argv[0] = "./program"  (程序名)
argv[1] = "hello"      (第1个参数)
argv[2] = "world"      (第2个参数)
argv[3] = NULL         (数组结束标记)

1.3.2 实战:命令行参数解析

代码文件: src/chapter01/print_args.c

/*
 * 文件: print_args.c
 * 功能: 演示命令行参数的获取和处理
 * 
 * 编译: gcc -o print_args print_args.c
 * 运行: ./print_args arg1 arg2 "arg with spaces"
 */

#include <stdio.h>

int main(int argc, char *argv[]) {
    printf("=== 命令行参数解析 ===\n\n");
    
    // 1. 显示程序名
    printf("程序名称: %s\n", argv[0]);
    
    // 2. 显示参数个数(不包括程序名)
    printf("参数个数: %d\n", argc - 1);
    
    // 3. 遍历并显示所有参数
    if (argc > 1) {
        printf("\n参数列表:\n");
        for (int i = 1; i < argc; i++) {
            printf("  [%d] %s\n", i, argv[i]);
        }
    } else {
        printf("\n未提供任何参数\n");
        printf("用法: %s <参数1> <参数2> ...\n", argv[0]);
    }
    
    return 0;
}

运行示例:

$ ./print_args hello world "Linux programming"
=== 命令行参数解析 ===

程序名称: ./print_args
参数个数: 3

参数列表:
  [1] hello
  [2] world
  [3] Linux programming

1.4 实战项目:文本统计工具

让我们编写一个实用的工具,统计文件的行数、字节数(类似Linux的 wc 命令)。

代码文件: src/chapter01/count_lines.c

/*
 * 文件: count_lines.c
 * 功能: 统计文件的行数和字节数
 * 
 * 编译: gcc -o count_lines count_lines.c
 * 运行: ./count_lines file1.txt file2.txt
 */

#include <stdio.h>

int main(int argc, char *argv[]) {
    // 1. 检查参数
    if (argc < 2) {
        printf("用法: %s <文件1> [文件2] ...\n", argv[0]);
        return 1;
    }
    
    // 2. 遍历每个文件
    for (int i = 1; i < argc; i++) {
        FILE *fp = fopen(argv[i], "r");
        if (fp == NULL) {
            printf("无法打开文件: %s\n", argv[i]);
            continue;
        }
        
        // 3. 统计行数和字节数
        int lines = 0;
        int bytes = 0;
        int ch;
        
        while ((ch = fgetc(fp)) != EOF) {
            bytes++;
            if (ch == '\n') {
                lines++;
            }
        }
        
        // 4. 显示结果
        printf("%8d %8d %s\n", lines, bytes, argv[i]);
        
        fclose(fp);
    }
    
    return 0;
}

运行示例:

$ ./count_lines test.txt README.md
      10      256 test.txt
      45     1024 README.md

1.5 开发环境快速搭建

1.5.1 安装GCC编译器

# Ubuntu/Debian系统
sudo apt update
sudo apt install build-essential

# 验证安装
gcc --version

1.5.2 编译C程序

# 基本编译
gcc -o program source.c

# 带调试信息(用于gdb调试)
gcc -g -o program source.c

# 启用警告信息
gcc -Wall -o program source.c

# 优化编译
gcc -O2 -o program source.c

1.5.3 GDB调试基础

# 编译时加-g选项
gcc -g -o program source.c

# 启动gdb
gdb ./program

# 常用gdb命令
(gdb) break main      # 在main函数设置断点
(gdb) run             # 运行程序
(gdb) next            # 单步执行(不进入函数)
(gdb) step            # 单步执行(进入函数)
(gdb) print var       # 打印变量值
(gdb) continue        # 继续执行
(gdb) quit            # 退出gdb

1.5.4 使用CMake构建项目

本教程的所有代码都支持CMake构建:

# 在项目根目录
mkdir build && cd build

# 生成Makefile
cmake ..

# 编译
make

# 运行
./chapter01/syscall_copy test.txt copy.txt

1.6 API快速参考

系统调用函数

标准库函数

open() flags 常用标志

文件权限模式(mode)

常用组合:

0644  →  rw-r--r--  →  S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH
0755  →  rwxr-xr-x  →  S_IRWXU | S_IRGRP | S_IXGRP | S_IROTH | S_IXOTH

1.7 本章总结

核心要点

1.系统调用是与内核交互的唯一方式

  • 涉及特权级切换(Ring 3 → Ring 0 → Ring 3)
  • 每次系统调用都有性能开销

2.标准库函数通过缓冲减少系统调用

  • 在用户空间维护缓冲区
  • 批量调用系统调用
  • 一般应用推荐使用

3.基本文件操作流程

open() → read()/write() → close()

4.命令行参数处理

  • argc:参数个数
  • argv:参数数组
  • argv[0]是程序名

学习建议

1.动手实践

  • 运行本章所有代码示例
  • 尝试修改参数观察效果
  • 用GDB单步调试理解流程

2.深入理解

  • 系统调用的完整流程(13个步骤)
  • 缓冲的作用和原理
  • 文件描述符的本质

3.扩展阅读

  • man 2 open - 查看系统调用手册
  • man 3 fopen - 查看C库函数手册
  • Linux内核文档(kernel.org)

1.8 知识扩展

什么是“Ring”

你提到的 “Ring 3” 和 “Ring 0” 中的 “Ring”,指的是 CPU 的特权级(Privilege Level),也叫 保护环(Protection Ring)。它是现代 CPU(尤其是 x86 架构)中用于实现操作系统安全与隔离的一种硬件机制。

Ring简介

“Ring” 字面意思是“环”,它把计算机系统的权限划分为多个层级(通常是 0 到 3),形成一个由内到外的环形结构:

        +------------------+
        |     Ring 0       |  ← 最高权限(内核态)
        |   (内核空间)     |
        +------------------+
        |     Ring 1       |  (较少使用)
        +------------------+
        |     Ring 2       |  (较少使用)
        +------------------+
        |     Ring 3       |  ← 最低权限(用户态)
        |   (用户空间)     |
        +------------------+
  • Ring 0:最靠近硬件的核心层,拥有最高权限,可以访问所有硬件资源(如内存、磁盘、网络、CPU 控制寄存器等)。
  • Ring 3:最外层,权限最低,只能访问自己的内存空间,不能直接操作硬件。

💡 类比:就像一个城堡,Ring 0 是国王和禁卫军住的核心区域,Ring 3 是普通百姓居住的外围区域。想进入内层,必须经过严格验证。

为什么需要 Ring?

主要目的:保护系统安全和稳定

1.防止用户程序乱来

  • 比如一个浏览器崩溃了,不能让整个系统死机。
  • 如果用户程序可以直接操作内存或关机指令,那太危险了。

2.实现“最小权限原则”

  • 普通程序只需要做自己的事(比如计算、读文件),不需要控制硬件。
  • 只有操作系统内核才需要这些高权限操作。

3.支持多任务和内存隔离

  • 每个用户进程运行在 Ring 3,彼此隔离。
  • 内核统一管理资源,在 Ring 0 运行。

Ring 0 vs Ring 3 对比

用户程序如何进入 Ring 0?

用户程序不能直接跳到 Ring 0,必须通过系统调用(System Call) 或 中断(Interrupt) 来“请求”内核帮忙。

典型流程(比如 open() 文件)

  1. 用户程序在 Ring 3 调用 open("file.txt", O_RDONLY)
  2. glibc 封装函数准备参数
  3. 执行 syscall 指令(x86-64)或 int 0x80(旧版)
  4. CPU 切换到 Ring 0,跳转到内核的系统调用处理函数
  5. 内核在 Ring 0 执行真正的文件打开操作
  6. 完成后,通过 sysret 或 iret 返回 Ring 3
  7. 用户程序继续执行

🔁 这个过程叫做 “模式切换”(Mode Switch),不是进程切换,而是特权级切换。

实际例子

为什么是 4 个 Ring?(0~3)

Intel 的 x86 架构设计了 4 个特权级(CPL: Current Privilege Level),编号 0~3。

但现代操作系统(如 Linux、Windows)通常只用两个:

  • Ring 0:内核
  • Ring 3:用户程序

Ring 1 和 Ring 2 很少使用,有些系统曾用于:

  • 虚拟机监控器(VMM)
  • 特权较高的服务程序

但最终被简化了。

💡 所以你现在看到的“Ring 3 → Ring 0”,其实是“用户态 → 内核态”的代名词。

总结:一句话解释“Ring”

“Ring” 是 CPU 提供的硬件级权限隔离机制,Ring 0 是最高权限(内核),Ring 3 是最低权限(用户程序),通过系统调用可以从 Ring 3 进入 Ring 0,但不能反向随意跳转。

如果你画图,可以用这个比喻:

      +----------------------------------+
      |           用户程序               |
      |         (运行在 Ring 3)          |
      |    不能碰硬件,不能乱改内存       |
      +------------------+-------------+
                         |
                         v
              系统调用(syscall)
                         |
                         v
      +------------------+-------------+
      |           内核                   |
      |         (运行在 Ring 0)          |
      |    可以操作一切:磁盘、内存、CPU |
      +----------------------------------+

内容太多,需要以下章节内容的可以观看以下视频自行领取完整的学习文档

C++少走弯路系列3-Linux系统编程学些什么?要学到什么程度?

第02章 系统文件IO进阶

第03章 深入理解I/O缓冲机制

第04章 文件与目录操作

第05章 进程控制

第06章 信号机制

第07章 进程间通信(IPC)

第08章 线程编程

#c++##牛客解忧铺##校招##秋招##牛客在线求职答疑中心#
全部评论

相关推荐

点赞 评论 收藏
分享
评论
点赞
1
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务