嵌入式linux_01

嵌入式linux

第 0 章 嵌入式linux概述

image-20260815094414197

image-20260815094538775

image-20260815094641992

image-20260815094726714

image-20260815094842973

第 1 章 Linux下的c 语言开发

1.1 GCC、glibc和GNU C的关系

1.1.1 GCC

GCC全称GNU Compiler Collection,是GNU项目的一部分,主要是一套编译器工具集,支持多种编程语言,包括C、C++、Objective-C、Fortran、Ada、Go和D等。GCC最初作为GNU操作系统的官方编译器,用于编译GNU/Linux系统和应用程序。它是自由软件,遵循GNU General Public License(GNU GPL)发布。

GCC的主要作用是将源代码编译成机器语言,生成可执行文件或库文件。它也提供了一些优化选项,可以在编译过程中优化代码,提高程序运行的效率。

1.1.2 glibc

glibc,全称GNU C Library,是GNU项目的一部分,是C语言标准库的一个实现版本,为C语言提供了标准的API,包括输入输出处理、字符串操作、内存管理等。glibc是Linux系统上最常用的C标准库实现之一,它实现了C标准规定的所有标准库函数以及POSIX(可移植操作系统接口)的扩展。

glibc对于Linux系统和基于Linux的应用程序至关重要,因为它提供了与操作系统交互的基本接口和运行时环境。应用程序通过调用glibc提供的函数来执行文件操作、内存管理、进程控制等操作。

1.1.3 GNU C

GNU C通常指的是GNU项目的C语言编程标准,特别是在GCC中实现的C语言的扩展和特性。GNU C包括ANSI C(现在通常指C89或C90)的所有特性,以及对C99、C11等更现代C标准的支持和一些GNU特有的扩展。

1.1.4 三者之间的关系

GCC使用glibc作为其C语言程序的标准库。当GCC编译C语言程序时,程序中使用的标准库函数(如printf或malloc)是通过glibc提供的。

GNU C是GCC中实现的C语言的一个版本,包含了对C语言标准的支持以及GNU特有的扩展。这些扩展可以在使用GCC编译程序时通过特定的编译选项启用。

总的来说,GCC是编译器,负责将源代码转换为可执行代码;glibc是运行时库,提供程序运行所需的标准函数和操作系统服务的接口;而GNU C则定义了GCC支持的C语言的标准和扩展。

这三者共同构成了GNU/Linux系统下开发和运行C语言程序的基础。

1.2 POSIX

POSIX,全称为“可移植操作系统接口”(Portable Operating System Interface),是一组标准,用来确保各种不同的操作系统能够提供相同的应用编程接口(API)。这套标准由 IEEE(电气和电子工程师协会)制定,标识符为IEEE 1003。

POSIX标准的主要目的是促进应用软件与多种类型的操作系统之间的兼容性。通过遵循POSIX标准,开发人员可以编写能够在各种不同系统上运行的程序,而无需对程序进行大量修改。这包括Unix、Linux、MacOS以及其他类Unix系统。

主要内容包括:

(1)系统调用和库:定义了操作系统应提供的核心服务,如文件系统操作、进程管理和线程控制。

(2)Shell和工具:规定了标准命令行接口和一系列基本工具,如awk、echo等。

(3)程序接口:包括语言、函数库等接口规范,使程序能够在任何遵循POSIX的操作系统上运行。

POSIX标准的遵循,有助于提高软件的可移植性和灵活性,是现代操作系统开发的重要基石。

1.3 安装IDE编程环境

众所周知,没有真正的大佬会在记事本里面写代码,一定会使用一款趁手的开发工具。所以首先我们需要安装一款合适的代码开发工具。C语言开发比较经典的IDE有VC、VS(VC扩展版)、Clion(付费,和IDEA一个公司)等。这里我们选择VS。

#使用命令安装vs

atguigu@ubuntu:~/桌面$ sudo snap install code --classic

1.4安装gcc

GCC是C语言的编译器,VS只是代替了记事本,编译器需要根据自己的操作系统额外安装。执行命令:

atguigu@ubuntu:~$ sudo apt install gcc

根据提示输入y即可。

测试使用GCC

atguigu@ubuntu:~$ gcc

gcc: fatal error: no input files

compilation terminated.

1.5 C语言编译过程

Linux的C语言开发,一般选择GCC工具链进行编译,通过下面的例子来演示GCC如何使用:

atguigu@ubuntu:~$ mkdir helloworld

atguigu@ubuntu:~$ cd helloworld

在这个目录中新建两个文件

1)main.c

#include "hello.h"

int main()
{
    say_hello();
    return 0;
}
  1. hello.h
#ifndef __HELLO_H__
#define __HELLO_H__

void say_hello();

#endif

  1. hello.c
#include "hello.h"
#include <stdio.h>

void say_hello()
{
    printf("Hello world!\n");
}

我们可以采用如下命令编译可执行文件并执行:

atguigu@ubuntu:~/helloworld$ gcc main.c hello.c -o main

atguigu@ubuntu:~/helloworld$ ./main

helloworld!

Ø -o:output的缩写,表示输出,用于指定输出文件名。

编译过程被浓缩成一步命令。但实际上,c语言的编译是有一个多步骤流程的。

image-20260816121718426

// hello.h

#ifndef __HELLO_H__
#define __HELLO_H__

void say_hello();

#endif

image-20260816121804549

#include "hello.h"
#include <stdio.h>

void say_hello(){
    printf("hello, linux");
}

image-20260816121903252

#include "hello.h"

int main()
{
    /* code */
    say_hello();
    return 0;
}

直接运行 main.c 会报错, 默认 gcc 编译只编译当前的执行文件, 报 hello.c 未找到, 需要修改 .vscode/tasks.json 中的一个参数, 如下图, 将 "${file}" 改成了 "*.c"。

image-20260816120735538

{
    "tasks": [
        {
            "type": "cppbuild",
            "label": "C/C++: gcc 生成活动文件",
            "command": "/usr/bin/gcc",
            "args": [
                "-fdiagnostics-color=always",
                "-g",
                // "${file}",
                "*.c",            // 这里是修改的内容
                "-o",
                "${fileDirname}/${fileBasenameNoExtension}"
            ],
            "options": {
                "cwd": "${fileDirname}"
            },
            "problemMatcher": [
                "$gcc"
            ],
            "group": {
                "kind": "build",
                "isDefault": true
            },
            "detail": "调试器生成的任务。"
        }
    ],
    "version": "2.0.0"
}

使用命令行 gcc 编译 c 代码

image-20260816122804967

mpx@mpx-VMware-Virtual-Platform:~/code$ ls
hello.c  hello.h  main.c
mpx@mpx-VMware-Virtual-Platform:~/code$ gcc main.c hello.c -o main
mpx@mpx-VMware-Virtual-Platform:~/code$ ls
hello.c  hello.h  main  main.c
mpx@mpx-VMware-Virtual-Platform:~/code$ ./main
hello, linuxmpx@mpx-VMware-Virtual-Platform:~/code$ 

1.5.1 预处理

  1. 预处理命令

在C语言编译过程中,预处理是其中的第一个阶段,它的主要目的是处理源代码文件中的预处理指令,将它们转换成编译器可以识别的形式。预处理主要包含宏替换、文件包含、条件编译、注释移除等几种任务。预处理的输出通常是经过预处理后的源代码文件,它会被保存成一个临时文件,并作为编译器的输入。预处理器处理后的文件通常会比原始源文件大,因为它会展开宏和包含其他文件的内容。

用下面的命令对两个源文件进行预处理:

atguigu@ubuntu:~/helloworld$ gcc -E hello.c -o hello.i
atguigu@ubuntu:~/helloworld$ gcc -E main.c -o main.i

Ø -E:Expand(展开)的缩写,该参数指定gcc执行预处理操作。

Ø .i:intermediate(中间的)的缩写,预处理后的源文件通常以.i作为后缀。

image-20260816123353658

image-20260816123728485

得到的hello.i和main.i就是预处理之后的文件。我们可以查看它们的内容,例如:

main.i

# 0 "main.c"
# 0 "<built-in>"
# 0 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 0 "<command-line>" 2
# 1 "main.c"
# 1 "hello.h" 1



void say_hello();
# 2 "main.c" 2

int main()
{
    say_hello();
    return 0;
}

可以看到hello.h的内容已经被展开了。

  1. .i 文件内容解读

与处理后的.i文件包含了经过C预处理器处理的源代码及行控制指令等内容。

源代码不必多说,此处对行控制指令做简要介绍。

.i文件中以#开头的是预处理器插入的行控制指令,用于标示从下一行起的代码来源,格式大致为

# 行号 "文件名" 标志

行号和文件名表示从下一行开始的源代码来源于哪个文件的哪一行。

标志可以是数字1,2,3,4,每个数字的含义如下:

Ø 1: 表示接下来的内容开始于一个新的文件。

Ø 2: 表示控制权从被包含的文件返回。这用于当预处理器完成一个包含文件的读取,回到包含它的文件继续处理时。

Ø 3: 指示接下来的内容来自系统头文件。

Ø 4: 表明接下来的内容应被视为被extern "C"包围,这主要用于C++中,以指示C链接约定。extern C是C++中的关键字组合,我们不必关注。

注:行号为0通常是预处理器的一种特殊标记用法,并不指向源代码中的实际行号。它可能用于初始化或特殊标记,比如标识文件的开始,而不直接对应于源代码中的行。我们只需要知道.i文件是将源码中宏定义处理之后的源文件,其它内容了解即可。

1.5.2 编译

编译阶段,编译器会将经过预处理的源代码文件转换成汇编代码。在这个阶段,编译器会将源代码翻译成机器能够理解的中间代码,包括词法分析、语法分析、语义分析和优化等过程。编译器会检查代码的语法和语义,生成对应的汇编代码。编译阶段是整个编译过程中最复杂和耗时的阶段之一,它对源代码进行了深入的分析和转换,确保了程序的正确性和性能。

执行下面的命令对刚刚生成的预处理文件进行编译:

atguigu@ubuntu:~/helloworld$ gcc -S hello.i -o hello.s
atguigu@ubuntu:~/helloworld$ gcc -S main.i -o main.s

-S:Source(源代码)的缩写,该参数指定gcc将预处理后的源码编译为汇编语言。

.s:Assembly Source(汇编源码)的缩写,通常编译后的汇编文件以.s作为后缀.

image-20260816124035678

我们同样可以观察编译器生成的汇编文件,例如main.s

        .file   "main.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
        movl    $0, %eax
        call    say_hello@PLT
        movl    $0, %eax
        popq    %rbp
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (Ubuntu 11.4.0-1ubuntu1~22.04) 11.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:

上述文件中记录了汇编代码,此外还有编译器版本表示等元数据,了解即可。

1.5.3 汇编

  1. 汇编命令

汇编阶段是C语言编译过程中的重要阶段,它将编译器生成的中间代码或汇编代码转换成目标机器的机器语言代码,也就是目标代码。这个阶段由汇编器(Assembler)完成,其主要任务是将汇编指令翻译成目标机器的二进制形式。主要包含以下几个任务:符号解析、指令翻译、地址关联、重定位、代码优化。最终,汇编器会将翻译和处理后的目标代码输出到目标文件中,用于后续的链接和生成可执行程序或共享库文件。

执行下面的指令对刚刚生成的汇编文件进行汇编:

atguigu@ubuntu:~/helloworld$ gcc -c main.s -o main.o
atguigu@ubuntu:~/helloworld$ gcc -c hello.s -o hello.o

Ø -c:可以被理解为Compile or Assemble(编译或汇编),该参数可以指定gcc将汇编代码翻译为机器码,但不做链接。此外,该参数也可以用于将.c文件直接处理为机器码,同样不做链接。

Ø -o:Object的缩写,通常汇编得到的机器码文件以.o为后缀。

image-20260816124445244

  1. .o 文件内容解读

这次生成的文件已经是二进制文件了,我们不能用文本编辑器直接查看该文件。可以用下面的指令查看main.o文件实质的内容:

image-20260816124624096

atguigu@ubuntu:~/helloworld$ objdump -s main.o

main.o:     文件格式 elf64-x86-64-64

Contents of section .text:
 0000 f30f1efa 554889e5 b8000000 00e80000  ....UH..........
 0010 0000b800 0000005d c3                 .......].
Contents of section .comment:
 0000 00474343 3a202855 62756e74 75203131  .GCC: (Ubuntu 11
 0010 2e342e30 2d317562 756e7475 317e3232  .4.0-1ubuntu1~22
 0020 2e303429 2031312e 342e3000           .04) 11.4.0.
Contents of section .note.gnu.property:
 0000 04000000 10000000 05000000 474e5500  ............GNU.
 0010 020000c0 04000000 03000000 00000000  ................
Contents of section .eh_frame:
 0000 14000000 00000000 017a5200 01781001  .........zR..x..
 0010 1b0c0708 90010000 1c000000 1c000000  ................
 0020 00000000 19000000 00450e10 8602430d  .........E....C.
 0030 06500c07 08000000                    .P......

文件大致可以分为五个部分:

(1)文件格式

最上面的行标明了文件的格式为:elf64-x86-64-64,为x86-64-64架构设计的64位ELF文件格式。ELF英文全称为Executable and Linkable Format,即可执行链接格式,了解即可。

(2).text节

从Contents of section .text开始到下一个Contents之间的属于.text节。这部分包含了程序的机器代码或指令,是程序实际执行的代码。

① 列之间是以空格分隔的,左侧第一列为四位16进制数,用于表示当前行的地址偏移量,上述文件中,.text节第一列第一行为0x0000,表示这一行的地址偏移量是从0开始的,第一列第二行为0x0010,表示这一行的偏移量是从十进制的16开始的。

② 从第二至第五列共4列均为16进制数表示的机器码,一行写满,刚好占用16个字节,因此,第一行的地址从0开始,第二行从16开始。可以看到,main函数源码处理之后得到的机器码共占用了25个字节的空间。

③ 第六列即最后一列是机器码的ASCII码表示,和②中的16进制表示相对应。对于ASCII码无法表示的字符,全部用.表示,对于.text节,这部分是无意义的,因为机器码的意义和作用与ASCII码表示无关。

(3).comment节

从Contents of section .comment至下一个Contents之间的属于.comment节。这部分包含编译器和编译选项的信息,用于记录编译这个文件的环境。

这一节及后续两节的列布局与.text相同,即第一列为地址偏移量,最后一列为机器码的ASCII码表示,其余列为机器码的16进制表示,不再赘述。

本节记录的信息可以从ASCII码表示部分读取:编译器版本为GCC:(Ubuntu 11.4.0-1ubuntu1~22.04)11.4.0。

(4).note.gnu.property节

从Contents of section .note.gnu.property到下一个Contents之间的部分属于本节。通常包含了一些GNU特定的属性。这部分内容与.text节相同,无法通过ASCII码解读。了解即可。

(5).eh_frame节

从Contents of section .eh_frame到下一个Contents之间的部分属于本节。包含了用于异常处理的元数据,如每个函数的堆栈信息,可用于异常处理和调试。同样无法通过ASCII码解读。

  1. .o文件section介绍

除了上面的四个section,.o文件中还可能存在其它section。通过以下方式查看更多的section类型。

(1)获得可执行文件

可执行文件需要经过链接才可以获得,关于链接的内容会在下文介绍。

atguigu@ubuntu:~/helloworld$ gcc main.o hello.o -o main

(2)查看可执行文件main中的所有section及其头部信息

atguigu@ubuntu:~/helloworld$ objdump -h main

输出如下:

main:     文件格式 elf64-x86-64-64

节:
Idx Name          Size      VMA               LMA               File off  Algn
  0 .interp       0000001c  0000000000000318  0000000000000318  00000318  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  1 .note.gnu.property 00000030  0000000000000338  0000000000000338  00000338  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  2 .note.gnu.build-id 00000024  0000000000000368  0000000000000368  00000368  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  3 .note.ABI-tag 00000020  000000000000038c  000000000000038c  0000038c  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  4 .gnu.hash     00000024  00000000000003b0  00000000000003b0  000003b0  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  5 .dynsym       000000a8  00000000000003d8  00000000000003d8  000003d8  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  6 .dynstr       0000008d  0000000000000480  0000000000000480  00000480  2**0
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  7 .gnu.version  0000000e  000000000000050e  000000000000050e  0000050e  2**1
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  8 .gnu.version_r 00000030  0000000000000520  0000000000000520  00000520  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
  9 .rela.dyn     000000c0  0000000000000550  0000000000000550  00000550  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 10 .rela.plt     00000018  0000000000000610  0000000000000610  00000610  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 11 .init         0000001b  0000000000001000  0000000000001000  00001000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 12 .plt          00000020  0000000000001020  0000000000001020  00001020  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .plt.got      00000010  0000000000001040  0000000000001040  00001040  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 14 .plt.sec      00000010  0000000000001050  0000000000001050  00001050  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 15 .text         00000116  0000000000001060  0000000000001060  00001060  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 16 .fini         0000000d  0000000000001178  0000000000001178  00001178  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
 17 .rodata       00000014  0000000000002000  0000000000002000  00002000  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 18 .eh_frame_hdr 00000034  0000000000002014  0000000000002014  00002014  2**2
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 19 .eh_frame     000000ac  0000000000002048  0000000000002048  00002048  2**3
                  CONTENTS, ALLOC, LOAD, READONLY, DATA
 20 .init_array   00000008  0000000000003db8  0000000000003db8  00002db8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 21 .fini_array   00000008  0000000000003dc0  0000000000003dc0  00002dc0  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 22 .dynamic      000001f0  0000000000003dc8  0000000000003dc8  00002dc8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 23 .got          00000048  0000000000003fb8  0000000000003fb8  00002fb8  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 24 .data         00000010  0000000000004000  0000000000004000  00003000  2**3
                  CONTENTS, ALLOC, LOAD, DATA
 25 .bss          00000008  0000000000004010  0000000000004010  00003010  2**0
                  ALLOC
 26 .comment      0000002b  0000000000000000  0000000000000000  00003010  2**0
                  CONTENTS, READONLY

(3)列

每个section对应两行,第一行为section的布局信息,如下。

image-20260815150655128

这些信息以空格分隔,共有七列,如下。

① Idx (Index): 节的索引号,这是一个简单的序列号,用于唯一标识每个section。

② Name: 节的名称,如.text、.data、.bss等。每个名称代表了该节的用途或包含的数据类型。

③ Size: 节的大小,以十六进制表示。这表示节在文件中占用的字节数。

④ VMA (Virtual Memory Address): 虚拟内存地址,指出当程序加载到内存中时,该节的内容应放置在内存的什么位置。这个地址是虚拟的,用于运行时。了解即可。

⑤ LMA (Load Memory Address): 加载内存地址,指出节内容在可执行文件中的实际位置。通常与VMA相同,除非创建位置独立的代码。了解即可。

⑥ File off (File Offset): 文件偏移,以十六进制表示,指出该节在文件中的起始位置。

⑦ Algn (Alignment): 对齐,表示该节在内存中的对齐要求。例如,2**3表示该节的数据在内存中的地址应该是8的倍数。

以.section为例,改节的索引为15,名称为.text,大小为0x116,虚拟内存地址和加载内存地址均为0x1060,文件偏移为0x1060,内存中的地址是以2的四次方即16字节对齐的,与objdump -s main.o看到的内容一致。

(4)属性

第二行为该节的标志或属性信息,如下。

image-20260815150737202

Ø CONTENTS: 该section在文件中包含实际的数据或代码。

Ø ALLOC: 在程序执行时,该section需要被分配内存空间。

Ø LOAD: 该section的内容需要从磁盘加载到内存中。

Ø READONLY: 该section是只读的,不能被程序修改。

Ø DATA: 指示该section包含数据而非执行代码,这通常用于区分包含变量和常量的section。

Ø CODE: 指示该section包含可执行代码。

Ø WRITE: 该section在运行时可以被写入,通常与DATA标志一起出现,表明这是一个包含可修改数据的section,如全局变量。

Ø EXECINSTR: 该section包含可执行指令。

(5)常见的section

除了执行objdump -s main.o看到的几种section,常见的还有:.data,.bss和.rodata

① .data:包含初始化了的全局变量和静态变量。这些变量在程序开始执行前就已经被赋予了初始值。

② .bss(Block Started by Symbol):包含未初始化的全局变量和静态变量。对于ELF文件,.bss节并不真正占用文件空间,它仅仅是一个占位符,指示程序启动时需要分配多少空间并将其清零。

③ .rodata(Read-Only Data):包含只读数据,比如字符串常量和其他程序中用到的不可修改的数据。

(6)删除可执行文件main

atguigu@ubuntu:~/helloworld$ rm main
  1. 反汇编

可以执行下面的指令对main.o内容进行反汇编

image-20260816125633750

atguigu@ubuntu:~/helloworld$ objdump -d main.o

main.o:     文件格式 elf64-x86-64-64


Disassembly of section .text:

0000000000000000 <main>:
   0:   f3 0f 1e fa             endbr64
   4:   55                      push   %rbp
   5:   48 89 e5                mov    %rsp,%rbp
   8:   b8 00 00 00 00          mov    $0x0,%eax
   d:   e8 00 00 00 00          call   12 <main+0x12>
  12:   b8 00 00 00 00          mov    $0x0,%eax
  17:   5d                      pop    %rbp
  18:   c3                      ret

反汇编内容保留了objdump -s main.o看到的前两节内容,主要是将.text节的内容反汇编为汇编代码。从000000..(若干0) <main>:下一行起的内容均为汇编代码。同样地,汇编代码可以按照空格分隔,第一列为地址偏移量,第二列至第六列共5列为汇编指令对应的机器码,从第七列开始的均为汇编指令。

1.5.4 链接

链接阶段,由链接器完成。链接器将各个目标文件以及可能用到的库文件进行链接,生成最终的可执行程序。在这个阶段,链接器会解析目标文件中的符号引用,并将它们与符号定义进行匹配,以解决符号的地址关联问题。链接器还会处理全局变量的定义和声明,解决重定位问题,最终生成可执行文件或共享库文件。

  1. 链接方式

我们在say_hello()函数中调用了printf()函数,这个函数是在stdio.h中声明的,后者来源于glibc库,printf()的实现在glibc的二进制组件中,通常是在共享库(如libc.so)或静态库(如libc.a)文件中。因此,我们除了要链接main.o、hello.o,还需要和glibc库的文件链接。通常,C语言的链接共有三种方式:静态链接、动态链接和混合链接。三者的区别就在于链接器在链接过程中对程序中库函数调用的解析。

(1)静态链接

将所有目标文件和所需的库在编译时一并打包进最终的可执行文件。库的代码被复制到最终的可执行文件中,使得可执行文件变得自包含,不需要在运行时查找或加载外部库。

atguigu@ubuntu:~/helloworld$ gcc -static main.o hello.o -o main

-static:该参数指示编译器进行静态链接,而不是默认的动态链接。使用这个参数,GCC会尝试将所有用到的库函数直接链接到最终生成的可执行文件中,包括C标准库(libc)、数学库(libm)和其他任何通过代码引用的外部库。

image-20260816130238474

(2)动态链接

库在运行时被加载,可执行文件包含了需要加载的库的路径和符号信息。动态链接的可执行文件比静态链接的小,因为它们共享系统级的库代码。与静态链接不同,库代码不包含在可执行文件中。

① 方式一

atguigu@ubuntu:~/helloworld$ gcc main.o hello.o -o main

没有添加-static关键字,gcc默认执行动态链接,即glibc库文件没有包含到可执行文件中。

image-20260816130419721

② 方式二

我们也可以将自己编写的部分代码处理为动态库。

执行下面的指令将hello.o编译为动态链接库libhello.so。

atguigu@ubuntu:~/helloworld$ gcc -fPIC -shared -o libhello.so hello.o

Ø -fPIC:这个选项告诉编译器为“位置无关代码(Position Independent Code)”生成输出。在创建共享库时使用这个选项是非常重要的,因为它允许共享库被加载到内存中的任何位置,而不影响其执行。这是因为位置无关代码使用相对地址而非绝对地址进行数据访问和函数调用,使得库在被不同程序加载时能够灵活地映射到不同的地址空间。

Ø -shared:这个选项指示GCC生成一个共享库而不是一个可执行文件。共享库可以被多个程序同时使用,节省了内存和磁盘空间。

Ø -o libhello.so这部分指定了输出文件的名称。-o选项后面跟着的是输出文件的名字,这里命名为libhello.so。按照惯例,Linux下的共享库名称以lib开头,扩展名为.so(表示共享对象)。

Ø hello.o:这是命令的输入文件,即之前编译生成的目标文件。在这个例子中,GCC会将hello.o中的代码和数据打包进最终的共享库libhello.so中。

上述命令的作用是:使用GCC,采用位置无关代码的方式,从hello.o目标文件创建一个名为libhello.so的动态共享库文件。

image-20260816130802855

编译完成后查看刚刚编译的动态链接库:

atguigu@ubuntu:~/helloworld$ ll
总计 80
drwxrwxr-x  3 atguigu atguigu  4096  3月 27 20:38 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 27 19:12 ../
-rw-rw-r--  1 atguigu atguigu    91  3月 27 15:17 hello.c
-rw-rw-r--  1 atguigu atguigu    68  3月 27 15:17 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu  1496  3月 27 17:27 hello.o
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rwxrwxr-x  1 atguigu atguigu 15576  3月 27 20:23 libhello.so*
-rw-rw-r--  1 atguigu atguigu    67  3月 27 15:17 main.c
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu  1360  3月 27 15:18 main.o
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/

使用动态链接库编译新的可执行文件:

atguigu@ubuntu:~/helloworld$ gcc main.o -L ./ -l hello -o main_d

Ø -L ./:指定了库文件搜索路径。-L选项告诉链接器在哪些目录下查找库文件,./表示当前目录。这意味着在链接过程中,链接器将会在当前目录下搜索指定的库文件。

Ø -l hello:指定了要链接的库。-l选项后面跟库的名称,这里是hello。根据约定,链接器会搜索名为libhello.so(动态库)或libhello.a(静态库)的文件来链接。链接器会根据-L选项指定的路径列表查找这个库。

当前目录下只有libhello.so而没有libhello.a,因此,这条命令的最终效果是动态链接当前目录下的libhello.so库以及默认的glibc库,生成可执行文件main_d。

image-20260816131045546

编译后查看结果:

atguigu@ubuntu:~/helloworld$ ll
总计 96
drwxrwxr-x  3 atguigu atguigu  4096  3月 27 20:40 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 27 19:12 ../
-rw-rw-r--  1 atguigu atguigu    91  3月 27 15:17 hello.c
-rw-rw-r--  1 atguigu atguigu    68  3月 27 15:17 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu  1496  3月 27 17:27 hello.o
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rwxrwxr-x  1 atguigu atguigu 15576  3月 27 20:23 libhello.so*
-rw-rw-r--  1 atguigu atguigu    67  3月 27 15:17 main.c
-rwxrwxr-x  1 atguigu atguigu 15952  3月 27 20:40 main_d*
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu  1360  3月 27 15:18 main.o
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/

这时如果我们直接执行main_d文件,会收到以下报错:

atguigu@ubuntu:~/helloworld$ ./main_d
./main_d: error while loading shared libraries: libhello.so: cannot open shared object file: No such file or directory

这句报错的意思时main_d在执行过程中,没有找到动态链接库文件libhello.so文件,链接失败无法执行。Linux的默认动态链接库文件夹是/lib 和/usr/lib,而我们的libhello.so不在其中,所以我们需要在执行的时候指明额外的动态链接库文件夹。

atguigu@ubuntu:~/helloworld$ LD_LIBRARY_PATH=/home/atguigu/helloworld ./main_d
Hello world!

image-20260816131552977

(3)混合链接

某些库静态链接,而其他库动态链接。这种方式结合了静态链接和动态链接的优点。

执行下面的指令可以将hello.o编译为静态链接库libhello.a

atguigu@ubuntu:~/helloworld$ ar crv libhello.a hello.o

Ø ar:归档命令,用于处理静态库文件。

Ø crv:ar命令的选项,由三个字符组成,每个字符代表一个选项:

Ø c:创建归档文件。如果指定的归档文件不存在,ar会创建它。

Ø r:替换归档文件中现有的文件或者向归档文件中添加新文件。如果hello.o已经在libhello.a中,它会被新版本替换;如果不存在,则会被添加。

Ø v:详细模式(verbose mode),在处理文件时显示详细信息。使用这个选项,ar会列出它正在执行的操作,包括哪些文件被添加或替换。

Ø libhello.a:要创建或更新的静态库文件的名称。按照惯例,Linux下的静态库文件名以lib开头,并以.a作为文件扩展名。

Ø hello.o:输入文件,即要添加到静态库libhello.a中的目标文件。此处只有一个目标文件hello.o,但ar命令支持同时指定多个文件。

查看生成的静态库文件:

image-20260816132211865

上图 main3 执行错误,当一个目录下既有静态库 又有动态库,链接时会默认选择动态链接。

atguigu@ubuntu:~/helloworld$ ll
总计 100
drwxrwxr-x  3 atguigu atguigu  4096  3月 27 20:41 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 27 19:12 ../
-rw-rw-r--  1 atguigu atguigu    91  3月 27 15:17 hello.c
-rw-rw-r--  1 atguigu atguigu    68  3月 27 15:17 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu  1496  3月 27 17:27 hello.o
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rw-rw-r--  1 atguigu atguigu  1642  3月 27 20:41 libhello.a
-rwxrwxr-x  1 atguigu atguigu 15576  3月 27 20:23 libhello.so*
-rw-rw-r--  1 atguigu atguigu    67  3月 27 15:17 main.c
-rwxrwxr-x  1 atguigu atguigu 15952  3月 27 20:40 main_d*
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu  1360  3月 27 15:18 main.o
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/

删除动态链接库,如果相同目录下同时存在hello的静态库和动态库文件,链接时会默认选择动态链接。

atguigu@ubuntu:~/helloworld$ rm libhello.so

利用静态库文件生成可执行的main文件:

atguigu@ubuntu:~/helloworld$ gcc main.o -L ./ -lhello -o main

Ø -L ./表示额外的库文件位置为当前目录;

Ø -lhello表示链接libhello.a文件。注意这里要去掉开头的lib前缀和结尾的.a后缀。

编译完成后的main文件同样可以执行,并且不依赖于静态库libhello.a。

查看静态链接生成的可执行文件

image-20260816132644567

atguigu@ubuntu:~/helloworld$ ll
总计 100
drwxrwxr-x  3 atguigu atguigu  4096  3月 27 20:54 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 27 19:12 ../
-rw-rw-r--  1 atguigu atguigu    91  3月 27 15:17 hello.c
-rw-rw-r--  1 atguigu atguigu    68  3月 27 15:17 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu  1496  3月 27 17:27 hello.o
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rw-rw-r--  1 atguigu atguigu  1642  3月 27 20:41 libhello.a
-rwxrwxr-x  1 atguigu atguigu 16024  3月 27 20:54 main*
-rw-rw-r--  1 atguigu atguigu    67  3月 27 15:17 main.c
-rwxrwxr-x  1 atguigu atguigu 15952  3月 27 20:40 main_d*
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu  1360  3月 27 15:18 main.o
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/

可以看到,静态链接libhello.a生成的main比main_d文件要大一些,这是因为hello库的代码被复制到了可执行文件main中,和动态链接相比,执行速度略高,但是二进制代码的复用性差,略微增加了二进制文件的体积。

需要注意的是,虽然我们静态链接了libhello.a库,但是main文件在执行时依然需要动态链接glibc的库。因此,这种方式实质上并非静态链接,而是混合链接。

2)gblic的动态库和静态库

glibc的动态库和静态库分别位于/usr/lib/x86-64_64-linux-gnu/目录下的libc.so和libc.a文件中。

image-20260816133125602

1.6 Makefile基础

Makefile是一种用于管理和自动化软件编译过程的文本文件。它通常包含了一系列规则,这些规则描述了如何根据源代码文件生成可执行文件或者其他目标文件。Makefile的核心概念是规则和依赖关系,规则定义了如何生成一个或多个目标文件,而依赖关系则指定了生成目标文件所需要的源文件或其他依赖文件。下面我们通过一步一步编写Makefile来学习Makefile规则。

1)安装build-essential工具包

mpx@mpx-VMware-Virtual-Platform:/home$ sudo apt install -y build-essential

2)为main.c和hello.c编写基本Makefile

(1)编写Makefile

atguigu@ubuntu:~/helloworld$ vim Makefile

image-20260816142036065

文件内容如下:

# Makefile内容通常由以下部分组成
# <目标>: <前置依赖>
#        <需要执行的命令>

# 放在第一个的是默认目标
# 目标为编译出main文件,依赖main.o和hello.o文件
# 编译的命令为 gcc -o main hello.o main.o
main: hello.o main.o
    gcc -o main hello.o main.o

# main.o目标依赖main.c hello.h
# 编译命令为gcc -c main.c
main.o: main.c hello.h
    gcc -c main.c 
#hello.hello.c hello.h
# 编译命令为gcc -c hello.c
hello.o: hello.c hello.h
    gcc -c hello.c

# clean目标可以清理编译的临时文件
clean:
    rm main main.o hello.o

保存退出。

(2)文件内容解读

规则是Makefile的构建单元,Make工具通过解析这些规则来执行构建过程。

① 规则的基本结构

我们用空行将Makefile的不同规则划分开来。规则有两行构成,第一行为目标和前置依赖,二者通过冒号区分开来,目标在前,前置依赖在后。

# <目标>: <前置依赖>
#     <需要执行的命令>

② 目标:本条规则需要生成的目标文件名。

③ 前置依赖:生成目标文件需要的依赖文件列表。

④ 命令:一系列将被Shell执行的命令,用于从前置依赖构建目标。

需要注意的是,Makefile中每个规则的命令必须以一个制表符(tab)开始,而不能是空格。否则会提示“缺失分隔符”。

上文提到,gcc的-c参数不仅可以将汇编代码转换为机器码,还可以直接将C语言源文件转换为机器码,gcc -c main.c就是第二种用法,这里省略了-o main.o。默认情况下,在指定-c参数时,gcc会将与源文件名去掉扩展名再加上后缀.o作为目标文件的名称。

(3)测试

执行make命令:

atguigu@ubuntu:~/helloworld$ make 

make: "main"已是最新。

提示我们“main”已是最新,这是因为上面的操作已经生成了最终的可执行文件“main”,要看到make的作用,需要先将之前编译好的文件删除

atguigu@ubuntu:~/helloworld$ rm main.o hello.o main

② 重新执行make

atguigu@ubuntu:~/helloworld$ make

gcc -c hello.c

gcc -c main.c

gcc -o main hello.o main.o

可以看到make首先将hello.c转换为hello.o,然后将main.c转换为main.o,最后生成main可执行文件。

③ 同理,执行make clean可以执行clean目标:

atguigu@ubuntu:~/helloworld$ make clean

rm -f main main.o hello.o

这个目标我们定义了如何清理编译的残留文件和结果。执行这个目标后,我们的编译结果和临时文件就都被清理了。

这就是Makefile,可以批处理进行一键编译,大大提高了编译效率。

3)引入变量

Makefile中为了方便,可以引入临时变量:

# 定义变量objects
objects :=  hello.o\
            main.o

# 在目标中引入变量
main: $(objects)
    gcc -o main $(objects)

main.o: main.c hello.h
    gcc -c main.c

hello.o: hello.c hello.h
    gcc -c hello.c

# clean目标中也可以引入变量
clean:
    rm main $(objects)

Ø objects为变量名

Ø :=的组合相当于C语言中的=,表示赋值

Ø :=后面为变量的值

Ø \为续行符,表示命令或定义延续到下一行。此处的作用是将hello.o和main.o合并为一行,此处的定义等价于objects := hello.o main.o

Ø $(变量名)表示获取变量的值

4)引入make自动推导:

(1)更改Makefile

make可以根据目标自动加入所需的依赖文件和命令。例如main.o目标,会默认将main.c作为依赖加入,同时也可以自动推导出编译main.o的命令,于是我们的Makefile就可以改成以下内容

objects :=  hello.o\
            main.o

main: $(objects)
    gcc -o main $(objects)

# 利用make的自动推导

clean:
    rm main $(objects)

(2)依赖文件的作用

要注意的是,虽然这种方式精简Makefile的内容,但是当没有显式声明的依赖文件发生更改时Make无法追踪。

① 执行make命令,确保控制台输出如下

atguigu@ubuntu:~/helloworld$ make
make: “main”已是最新。

② 更改hello.h,在其中任意位置加入空行

③ 重新执行

atguigu@ubuntu:~/helloworld$ make

make: “main”已是最新。

此时Make工具没有检测到hello.h的更新。

④ 将Makefile恢复为以下内容

# Makefile内容通常由以下3部分组成
# <目标名称>:<前置依赖>
#   \t<需要执行的命令>

# 定义变量 objects
objects := hello.o \
main.o
# 放在第一个的是默认目标
# 目标是编译出main文件  依赖hello.o和main.o文件
# 编译的命令是gcc hello.o main.o -o main
main: $(objects)
    gcc $(objects) -o main 
#目标是main.o 依赖main.c和hello.h
#编译的命令是gcc -c main.c 
# main.o: main.c hello.h
#   gcc -c main.c 
main.o: hello.h
#目标是hello.o 依赖hello.c和hello.h
#编译的命令是gcc -c hello.c
# hello.o: hello.c hello.h
#   gcc -c hello.c
hello.o: hello.h
clean:
    rm main $(objects)

⑤ 执行make

atguigu@ubuntu:~/helloworld$ make

make: “main”已是最新。

⑥ 更改hello.h,删除任意空行

⑦ 重新执行

atguigu@ubuntu:~/helloworld$ make

gcc -c main.c

gcc -o main hello.o main.o

⑧ 总结:只有在Makefile中显式声明依赖的头文件才会被追踪,当它们发生更改时,重新执行make命令,会再次执行相应规则的命令

5)引入伪目标

(1)伪目标

伪目标并不代表实际的文件名,它们更多的是行为或动作的标识符。伪目标并不生成具体文件。

(2).PHONY目标

① .PHONY是Makefile中一个特殊的目标,用于声明其它目标是伪目标。

语法:.PHONY:<伪目标名称>

③ 细心的同学可能发现,目标为clean的规则没有前置依赖,这是因为它是用来执行清理操作的,并不是要生成名为clean的文件,因此不需要前置依赖。我们可以将clean声明为伪目标。

④ 修改Makefile,如下。

# 定义变量objects
objects :=  hello.o\
            main.o
# 在目标中引入变量
main: $(objects)
    gcc -o main $(objects)

main.o: hello.h

hello.o: hello.h

# 声明伪目标
.PHONY: clean

# clean目标中也可以引入变量
clean:
    rm main $(objects)

保存退出。

⑤ 执行

atguigu@ubuntu:~/helloworld$ make clean

rm main hello.o main.o

(1)为什么需要声明伪目标

我们看到,将clean声明为伪目标后执行make clean的结果与之前并无二致。那么声明伪目标的意义何在?

执行以下操作。

① 在helloworld目录下创建名为clean的文件

atguigu@ubuntu:~/helloworld$ touch clean
atguigu@ubuntu:~/helloworld$ ll
总计 96
drwxrwxr-x  3 atguigu atguigu  4096  3月 28 15:24 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 28 15:00 ../
-rw-rw-r--  1 atguigu atguigu     0  3月 28 15:24 clean
-rw-rw-r--  1 atguigu atguigu    92  3月 28 14:45 hello.c
-rw-rw-r--  1 atguigu atguigu    69  3月 28 14:45 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rw-rw-r--  1 atguigu atguigu  1642  3月 27 20:41 libhello.a
-rwxrwxr-x  1 atguigu atguigu 16024  3月 28 14:30 main123*
-rw-rw-r--  1 atguigu atguigu    67  3月 28 14:53 main.c
-rwxrwxr-x  1 atguigu atguigu 15952  3月 27 20:40 main_d*
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
-rw-rw-r--  1 atguigu atguigu   279  3月 28 14:52 Makefile
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/
atguigu@ubuntu:~/helloworld$

② 删除Makefile中的.PHONY: clean,保存退出

③ 重新执行make

atguigu@ubuntu:~/helloworld$ make

gcc -c hello.c

gcc -c main.c

gcc -o main hello.o main.o

④ 重新执行make clean

atguigu@ubuntu:~/helloworld$ make clean

make: “clean”已是最新。

我们发现,执行make clean并没有像我们预想的那样删除文件,而是告诉我们“clean”已是最新。这是因为,make将clean作为普通目标处理,它先检查clean的依赖(不存在),然后发现clean文件已存在且没有依赖更新(因为不存在,自然不需要更新),因此不会执行规则下的命令,并在控制台输出以上内容。显然,这不是我们期望的行为。

在Makefile中添加.PHONY: clean将clean声明为伪目标,并保存退出

⑥ 重新执行make clean

atguigu@ubuntu:~/helloworld$ make clean

rm main hello.o main.o

可以看到,虽然目录下有名为clean的文件,但make仍执行了clean所在规则的命令。这是我们期望的行为。

⑦ 总结:将某些不生成目标文件的行为或动作(如清理、安装)声明为伪目标可以确保无条件执行规则下的命令。即便执行make命令时当前目录下存在与目标同名的文件,依然可以得到我们期望的效果。

6)忽略错误

如果我们在没有编译时执行make clean目标,会收到以下报错:

atguigu@ubuntu:~/helloworld$ make clean
rm main hello.o main.o
rm: 无法删除 'main': 没有那个文件或目录
rm: 无法删除 'hello.o': 没有那个文件或目录
rm: 无法删除 'main.o': 没有那个文件或目录
make: *** [Makefile:20:clean] 错误 1

make命令最终以返回码1推出了。我们可以将Makefile改为下面的形式:

# 定义变量objects
objects :=  hello.o\
            main.o

# 在目标中引入变量
main: $(objects)
    gcc -o main $(objects)

main.o: hello.h

hello.o: hello.h

# 声明伪目标
.PHONY: clean

# clean目标中也可以引入变量
clean:
    -rm main $(objects)

rm前面的-告诉make,如果该命令执行失败,不要停止执行剩余的过程,即忽略错误。

再次执行make clean目标,提示信息会变为:

atguigu@ubuntu:~/helloworld$ make clean
rm main hello.o main.o
rm: 无法删除 'main': 没有那个文件或目录
rm: 无法删除 'hello.o': 没有那个文件或目录
rm: 无法删除 'main.o': 没有那个文件或目录
make: [Makefile:20:clean] 错误 1 (已忽略)

错误已被忽略。

7)目标名和命令中输出文件名的关系

(1)修改Makefile

# 定义变量objects
objects :=  hello.o\
            main.o

# 在目标中引入变量
main: $(objects)
    gcc -o main123 $(objects)

main.o: hello.h

hello.o: hello.h

# 声明伪目标
.PHONY: clean

# clean目标中也可以引入变量
clean:
    -rm main $(objects)

将命令中最终输出的文件名由main更改为main123。

保存退出。

(2)执行make

上一步已经删除了目录下的main.o,hello.o和main。

atguigu@ubuntu:~/helloworld$ make 

gcc -c hello.c

gcc -c main.c

gcc -o main123 hello.o main.o

(3)可以看到make的日志中,最终生成的文件为main123,查看当前目录

atguigu@ubuntu:~/helloworld$ ll
总计 104
drwxrwxr-x  3 atguigu atguigu  4096  3月 28 15:49 ./
drwxr-x--- 23 atguigu atguigu  4096  3月 28 15:49 ../
-rw-rw-r--  1 atguigu atguigu     0  3月 28 15:24 clean
-rw-rw-r--  1 atguigu atguigu    92  3月 28 14:45 hello.c
-rw-rw-r--  1 atguigu atguigu    69  3月 28 14:45 hello.h
-rw-rw-r--  1 atguigu atguigu 18009  3月 27 17:27 hello.i
-rw-rw-r--  1 atguigu atguigu  1496  3月 28 15:49 hello.o
-rw-rw-r--  1 atguigu atguigu   680  3月 27 17:27 hello.s
-rw-rw-r--  1 atguigu atguigu  1642  3月 27 20:41 libhello.a
-rwxrwxr-x  1 atguigu atguigu 16024  3月 28 15:49 main123*
-rw-rw-r--  1 atguigu atguigu    67  3月 28 14:53 main.c
-rwxrwxr-x  1 atguigu atguigu 15952  3月 27 20:40 main_d*
-rw-rw-r--  1 atguigu atguigu   225  3月 27 15:18 main.i
-rw-rw-r--  1 atguigu atguigu  1360  3月 28 15:49 main.o
-rw-rw-r--  1 atguigu atguigu   589  3月 27 15:18 main.s
-rw-rw-r--  1 atguigu atguigu   316  3月 28 15:49 Makefile
drwxrwxr-x  2 atguigu atguigu  4096  3月 27 14:24 .vscode/

(4)分析

当前目录下不存在名为main的文件,只有名为mian123的文件,可以得出结论:规则中的命令决定了生成目标文件的名称。目标名并不影响目标文件名。

(5)再次执行make

atguigu@ubuntu:~/helloworld$ make
gcc -o main123 hello.o main.o

我们发现,make没有提示目标文件已是最新,而是重新执行了gcc -o main123 hello.o main.o。这是因为,make会按照目标名称在当前目录下追踪目标文件,如果不存在与目标同名的文件,会再次执行规则下的命令。

(6)总结

make输出的文件名取决于规则下的命令,而目标名称决定make追踪的目标文件名。如果二者不一致,make就会认为目标文件不存在而不断执行命令。我们应确保命令生成的目标文件名和目标名一致。

8)安装vscode-makefile-term插件

在插件市场搜索并安装vscode-makefile-term插件,完成后,在VScode中打开Makefile文件,可以看到每个target上方都出现了执行按钮。

image-20260816141010228

点击这些按钮vscode-makefile-term插件就会帮助我们执行相应的target。

第 2 章 文件 I/O

2.1 C标准I/O库函数回顾

2.1.1 打开/关闭文件

1)fopen

(1)新建fopen_test.c,写入以下内容。

#include <stdio.h>

int main()
{
    /* 打开文件
    char *__restrict __filename: 字符串表示要打开文件的路径和名称
    char *__restrict __modes: 字符串表示访问模式
        (1)"r": 只读模式 没有文件打开失败
        (2)"w": 只写模式 存在文件写入会清空文件,不存在文件则创建新文件
        (3)"a": 只追加写模式 不会覆盖原有内容 新内容写到末尾,如果文件不存在则创建
        (4)"r+": 读写模式 文件必须存在 写入是从头一个一个覆盖
        (5)"w+": 读写模式 可读取,写入同样会清空文件内容,不存在则创建新文件
        (6)"a+": 读写追加模式 可读取,写入从文件末尾开始,如果文件不存在则创建
    return: FILE * 结构体指针 表示一个文件
    FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)
    */
    char *filename = "io.txt";
    FILE *ioFile = fopen(filename, "a+");
    if (ioFile == NULL)
    {
        printf("FAILED,a+不能打开不存在的文件\n");
    }
    else
    {
        printf("SUCCESS,a+能打开不存在的文件\n");
    }
}

(2)新建Makefile,写入以下内容

CC:=gcc

fopen_test: fopen_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

image-20260816153301775

说明:

① 有时编译器不只是gcc,我们将编译器定义为变量CC,当切换编译器时只需要更改该变量的定义,而无须更改整个Makefile。

② $@相当于当前target目标文件的名称,此处为fopen_test。

③ $^相当于当前target所有依赖文件列表,此处为fopen_test.c

④ ./$@的作用是执行目标文件

⑤ rm ./$@的作用是在执行完毕后删除目标文件,如果没有这个操作,当源文件fopen_test.c未更改时就无法重复执行,会提示:make:“fopen_test”已是最新。此处删除目标文件,使得我们在不更改源文件的情况下可以多次执行。

⑥ 所有命令前都添加了“-”符号以忽略错误,确保即便上面的命令执行失败,仍然会向下执行。这样做是为了在发生错误时,确保删除目标文件,使得再次执行相同target时不会提示:make:“fopen_test”已是最新,可以重新执行target下的命令。

(3)执行结果如下

image-20260816144538891

2)fclose

(1)创建fclose_test.c文件,写入以下内容。

#include <stdio.h>
int main()
{
    
    /* 打开文件
    char *__restrict __filename: 字符串表示要打开文件的路径和名称
    char *__restrict __modes: 字符串表示访问模式
        (1)"r": 只读模式 没有文件打开失败
        (2)"w": 只写模式 存在文件写入会清空文件,不存在文件则创建新文件
        (3)"a": 只追加写模式 不会覆盖原有内容 新内容写到末尾
        (4)"r+": 读写模式 文件必须存在 写入是从头一个一个覆盖
        (5)"w+": 读写模式 可读取,写入同样会清空文件内容或创建新文件
        (6)"a+": 读写追加模式 可读取,写入从文件末尾开始
    return: FILE * 结构体指针 表示一个文件 出错返回NULL
    FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)
    */
    char *filename = "io1.txt";
    FILE *ioFile = fopen(filename,"r");
    if (ioFile == NULL)
    {
        printf("r不能打开不存在的文件\n");
    }else{
        printf("r能打开不存在的文件\n");
    }
    
    /*
    FILE *__stream: 需要关闭的文件
    return: 成功返回0 失败返回EOF(负数) 通常失败会造成系统崩溃
    int fclose (FILE *__stream)
    */
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    
    return 0;
}

(2)Makefile中补充以下内容

fclose_test: fclose_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行结果如下

image-20260816144757414

2.1.2 向文件中写入数据

1)fputc 函数

(1)创建fputc_test.c文件,写入以下内容

#include <stdio.h>
int main()
{
    
    /* 打开文件
    char *__restrict __filename: 字符串表示要打开文件的路径和名称
    char *__restrict __modes: 字符串表示访问模式
        (1)"r": 只读模式 没有文件打开失败
        (2)"w": 只写模式 存在文件写入会清空文件,不存在文件则创建新文件
        (3)"a": 只追加写模式 不会覆盖原有内容 新内容写到末尾
        (4)"r+": 读写模式 文件必须存在 写入是从头一个一个覆盖
        (5)"w+": 读写模式 可读取,写入同样会清空文件内容或创建新文件
        (6)"a+": 读写追加模式 可读取,写入从文件末尾开始
    return: FILE * 结构体指针 表示一个文件 出错返回NULL
    FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)
    */
    char *filename = "io.txt";
    FILE *ioFile = fopen(filename,"a+");
    if (ioFile == NULL)
    {
        printf("a+不能打开不存在的文件\n");
    }else{
        printf("a+能打开不存在的文件\n");
    }
    /*
    写入文件一个字符
    int __c: 写入的char按照AICII值写入 可提前声明一个char
    FILE *__stream: 要写入的文件,写在哪里取决于访问模式
    return: 成功返回char的值 失败返回EOF
    int fputc (int __c, FILE *__stream)
    */
    int putcR = fputc(97,ioFile);

    if (putcR == EOF)
    {
        printf("写入字符失败\n");
    }else{
        printf("写入字符成功:%c\n",putcR);
    }

    /*
    FILE *__stream: 需要关闭的文件
    return: 成功返回0 失败返回EOF(负数) 通常失败会造成系统崩溃
    int fclose (FILE *__stream)
    */
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    
    return 0;
}

(2)在Makefile中补充以下内容

fputc_test: fputc_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行代码查看结果

image-20260816154203741

image-20260816154220114

2)fputs 函数

(1)创建fputs_test.c文件,写入以下内容

#include <stdio.h>

int main()
{
    /* 打开文件
    char *__restrict __filename: 字符串表示要打开文件的路径和名称
    char *__restrict __modes: 字符串表示访问模式
        (1)"r": 只读模式 没有文件打开失败
        (2)"w": 只写模式 存在文件写入会清空文件,不存在文件则创建新文件
        (3)"a": 只追加写模式 不会覆盖原有内容 新内容写到末尾
        (4)"r+": 读写模式 文件必须存在 写入是从头一个一个覆盖
        (5)"w+": 读写模式 可读取,写入同样会清空文件内容或创建新文件
        (6)"a+": 读写追加模式 可读取,写入从文件末尾开始
    return: FILE * 结构体指针 表示一个文件 出错返回NULL
    FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)
    */
    char *filename = "io.txt";
    FILE *ioFile = fopen(filename,"a+");
    if (ioFile == NULL)
    {
        printf("a+不能打开不存在的文件\n");
    }else{
        printf("a+能打开不存在的文件\n");
    }
    /*
    写入文件一个字符串
    char *__restrict __s: 需要写入的字符串
    FILE *__restrict __stream: 要写入的文件,写在哪里取决于访问模式
    return: 成功返回非负整数(一般是0,1) 失败返回EOF
    int fputs (const char *__restrict __s, FILE *__restrict __stream)
    */
    int putsR = fputs(" love letter\n",ioFile);
    if (putsR == EOF)
    {
        printf("写入字符串失败\n");
    }else{
        printf("写入字符串成功:%d\n",putsR);
    }
    /*
    FILE *__stream: 需要关闭的文件
    return: 成功返回0 失败返回EOF 通常失败会造成系统崩溃
    int fclose (FILE *__stream)
    */
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    
    return 0;
}
        

(2)在Makefile中写入以下内容

fputs_test: fputs_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行代码查看结果

image-20260816154428071

image-20260816154441132

3)fprintf 函数

(1)创建fprintf_test.c文件,写入以下内容

#include <stdio.h>
int main()
{
    
    /* 打开文件
    char *__restrict __filename: 字符串表示要打开文件的路径和名称
    char *__restrict __modes: 字符串表示访问模式
        (1)"r": 只读模式 没有文件打开失败
        (2)"w": 只写模式 存在文件写入会清空文件,不存在文件则创建新文件
        (3)"a": 只追加写模式 不会覆盖原有内容 新内容写到末尾
        (4)"r+": 读写模式 文件必须存在 写入是从头一个一个覆盖
        (5)"w+": 读写模式 可读取,写入同样会清空文件内容或创建新文件
        (6)"a+": 读写追加模式 可读取,写入从文件末尾开始
    return: FILE * 结构体指针 表示一个文件 出错返回NULL
    FILE *fopen (const char *__restrict __filename,
            const char *__restrict __modes)
    */
    char *filename = "io.txt";
    FILE *ioFile = fopen(filename,"a+");
    if (ioFile == NULL)
    {
        printf("a+不能打开不存在的文件\n");
    }else{
        printf("a+能打开不存在的文件\n");
}

    /*
    FILE *__restrict __stream: 要写入的文件,写在哪里取决于访问模式
    char *__restrict __fmt: 格式化字符串
    ...: 变长参数列表
    return: 成功返回正整数(写入字符总数不包含换行符) 失败返回EOF
    fprintf (FILE *__restrict __stream, const char *__restrict __fmt, ...)
    */
    char *name   = "大海";
    int fprintfR = fprintf(ioFile, "哎呀,那边窗户透出了什么光?\n那是东方,而你则是太阳!\n升起吧,骄阳,去让忌妒的月黯然失色!\n\t\t%s", name);
    if (fprintfR == EOF) {
        printf("写入字符串失败");
    } else {
        printf("写入字符串成功:%d\n", fprintfR);
    }
    
    /*
    FILE *__stream: 需要关闭的文件
    return: 成功返回0 失败返回EOF(负数) 通常失败会造成系统崩溃
    int fclose (FILE *__stream)
    */
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        fprintf(stderr, "%s\n", filename);
        return 1;
    }
    
    return 0;
}

(2)在Makefile中写入以下内容

fprintf_test: fprintf_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行查看结果

image-20260816154634399

image-20260816154643858

2.1.3 从文件中读取数据

1)fgetc 函数

(1)创建fgetc_test.c文件,写入以下内容

#include <stdio.h>

int main()
{
    // 打开文件
    FILE *ioFile = fopen("io.txt","r");
    if (ioFile == NULL)
    {
        printf("不能读不存在的文件");
    }
    /*
    FILE *__stream: 需要读取的文件
    return: 读取的一个字节    到文件结尾或出错返回EOF
    int fgetc (FILE *__stream)
    */
    char c = fgetc(ioFile);
    while (c != EOF)
    {
        printf("%c",c);
        c = fgetc(ioFile);
    }
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    return 0;
}

(2)在Makefile中补充以下内容

fgetc_test: fgetc_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行代码查看结果

image-20260816172344270

2)fgets 函数

(1)创建fgets_test.c,写入以下内容

include <stdio.h>

int main()
{
    // 打开文件
    FILE *ioFile = fopen("io.txt","r");
    if (ioFile == NULL)
    {
        printf("不能读不存在的文件");
    }
    /*
    char *__restrict __s: 接收读取的数据字符串
    int __n: 能够接收数据的长度
    FILE *__restrict __stream: 需要读取的文件
    return: 成功返回字符串 失败返回NULL(可以直接用于while)
    fgets (char *__restrict __s, int __n, FILE *__restrict __stream)
    */
    char buffer[100];
    while (fgets(buffer, sizeof(buffer), ioFile)) {
        printf("%s", buffer);
    }
    int result = fclose(ioFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    return 0;
}

(2)Makefile中补充以下内容

fgets_test: fgets_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行代码,查看结果

image-20260816172601816

3)fscanf 函数

(1)新建文件user.txt,写入以下内容

罗密欧 18 朱丽叶
贾宝玉 14 薛宝钗
梁山伯 16 祝英台

海哥

(2)创建fscanf_test.c,写入以下内容

#include <stdio.h>

int main()
{
    /*
    FILE *__restrict __stream: 读取的文件
    char *__restrict __format: 读取的匹配表达式
    ...: 变长参数列表 用于接收匹配的数据
    return: 成功返回参数的个数  失败返回0 报错或结束返回EOF
    int fscanf (FILE *__restrict __stream,  const char *__restrict __format, ...)
    */
    FILE *userFile = fopen("user.txt", "r");
    if (userFile == NULL) {
        printf("不能打开不存在的文件");
    }
    char name[50];
    int age;
    char wife[50];
    int scanfR;
    while (fscanf(userFile, "%s %d %s\n", name, &age, wife) != EOF) {
        printf("%s在%d岁爱上了%s\n", name, age, wife);
    }
    
    int result = fclose(userFile);
    if (result != 0)
    {
        printf("关闭文件失败");
        return 1;
    }
    return 0;
}

(3)Makefile补充以下内容

fscanf_test: fscanf_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(4)运行代码,查看结果

image-20260816172752087

2.1.4 标准输入/输出/错误

读写文件通常用于代码内部操作,如果想要和用户沟通交流,就需要使用标准输入、输出和错误了。

(1)创建文件stdin_out_err_test.c,写入以下内容:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char const *argv[])
{
    // malloc动态分配内存  也可以用 char ch[100]接收数据
    char *ch = malloc(100);
    // char ch1[100];

    /*
    stdin: 标准输入FILE *
    */
    fgets(ch, 100, stdin);

    printf("你好:%s", ch);
    /*
    stdout: 标准输出FILE * 写入这个文件流会将数据输出到控制台
    printf底层就是使用的这个
    */
    fputs(ch, stdout);

    /*
    stderr: 错误输出FILE * 一般用于输出错误日志
    */
    fputs(ch, stderr);

    return 0;
}

(2)Makefile中补充以下内容

stdin_out_err_test: stdin_out_err_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)测试

① 运行代码

image-20260816173003022

② 在控制台输入海哥,会出现对应的标准输出和错误输出:

image-20260816173023674

2.2 系统调用

2.2.1 关于系统调用

系统调用是操作系统内核提供给应用程序,使其可以间接访问硬件资源的接口,关于操作系统内核、应用程序等概念,我们会在第五章详细阐述。

2.2.2 常见系统调用

1)open

open()系统调用用于打开一个标准的文件描述符。

#include <unistd.h>
#include <fcntl.h>

/*
    const char *__path: 文件路径
    int __oflag: 用于指定打开文件的方式,可以是以下选项的组合:
        (1) O_RDONLY: 以只读方式打开文件 
        (2) O_WRONLY: 以只写方式打开文件 
        (3) O_RDWR: 以读写方式打开文件 
        (4) O_CREAT: 如果文件不存在,则创建一个新文件 
        (5) O_APPEND: 将所有写入操作追加到文件的末尾 
        (6) O_TRUNC: 如果文件存在并且以写入模式打开,则截断文件长度为0 
        还有其他标志,如O_EXCL(当与O_CREAT一起使用时,只有当文件不存在时才创建新文件)、O_SYNC(同步I/O)、O_NONBLOCK(非阻塞I/O)等 
    可选参数: mode -> 仅在使用了O_CREAT标志且文件尚不存在的情况下生效,用于指定新创建文件的权限位 权限位通常由三位八进制数字组成,分别代表文件所有者、同组用户和其他用户的读写执行权限
    return: (1) 成功时返回非负的文件描述符。
            (2) 失败时返回-1,并设置全局变量errno以指示错误原因。
*/
int open (const char *__path, int __oflag, ...);

image-20260816181232217

2)read

read()系统调用用于读取已经打开的文件描述符。

#include <unistd.h>

/*
    int __fd:一个整数,表示要从中读取数据的文件描述符
    void *__buf:一个指向缓冲区的指针,读取的数据将被存放到这个缓冲区中
    size_t __nbytes:一个size_t类型的整数,表示要读取的最大字节数 系统调用将尝试读取最多这么多字节的数据,但实际读取的字节数可能会少于请求的数量
    return: (1) 成功时,read()返回实际读取的字节数 这个值可能小于__nbytes,如果遇到了文件结尾(EOF)或者因为网络读取等原因提前结束读取 
            (2) 失败时,read()将返回-1
*/
ssize_t read (int __fd, void *__buf, size_t __nbytes);

(1)ssize_t

ssize_t相关的宏定义如下

typedef __ssize_t ssize_t;

__STD_TYPE __SSIZE_T_TYPE __ssize_t;

\# define __STD_TYPE   typedef

\#define __SSIZE_T_TYPE    __SWORD_TYPE

\# define __SWORD_TYPE    long int

ssize_t是__ssize_t的别名,后者是long int的别名,long是long int的简写,因此,ssize_t实际上是long类型的别名。

(2)size_t

相关定义如下

typedef __SIZE_TYPE__ size_t;

\#define __SIZE_TYPE__ long unsigned int

unsigned long是long unsigned int的简写,size_t实质上是unsigned long。

3)write

write()系统调用用于对打开的文件描述符写入内容。

#include <unistd.h>
/*
    int __fd:一个整数,表示要写入数据的文件描述符
    void *__buf:一个指向缓冲区的指针,写入的数据需要先存放到这个缓冲区中
    size_t __n:一个size_t类型的整数,表示要写入的字节数 write()函数会尝试写入__n个字节的数据,但实际写入的字节数可能会少于请求的数量
    return: (1) 成功时,write()返回实际写入的字节数 这个值可能小于__n,如果写入操作因故提前结束,例如: 磁盘满、网络阻塞等情况 
            (2) 失败时,write()将返回-1
*/
ssize_t write (int __fd, const void *__buf, size_t __n);

4)close

close()系统调用用于在使用完成之后,关闭对文件描述符的引用。

#include <unistd.h>

/*
    int __fd:一个整数,表示要关闭的文件描述符
    return: (1) 成功关闭时 返回0
            (2) 失败时 返回-1
*/
int close (int __fd);
5)exit和_exit()

(1)系统调用_exit()

_exit()是由POSIX标准定义的系统调用,用于立即终止一个进程,定义在unistd.h中。这个调用确保进程立即退出,不执行任何清理操作。

_exit()在子进程终止时特别有用,这可以防止子进程的终止影响到父进程(比如,防止子进程意外地刷新了父进程未写入的输出缓冲区)。

_exit和_Exit功能一样。

#include <unistd.h>

/**
 * 立即终止当前进程,且不进行正常的清理操作,如关闭文件、释放内存等。这个函数通常在程序遇到严重错误需要立即退出时使用,或者在某些情况下希望避免清理工作时调用。
 * 
 * int status: 父进程可接收到的退出状态码 0表示成功 非0表示各种不同的错误
 */
void _exit(int status);
void _Exit (int __status) ;

(2)库函数exit()

exit()函数是由C标准库提供的,定义在stdlib.h中。

#include <stdlib.h>

/**
 * 终止当前进程,但是在此之前会执行3种清理操作
 * (1) 调用所有通过atexit()注册的终止处理函数(自定义)
 * (2) 刷新所有标准I/O缓冲区(刷写缓存到文件)
 * (3) 关闭所有打开的标准I/O流(比如通过fopen打开的文件)
 * 
 * int status: 父进程可接收到的退出状态码 0表示成功 非0表示各种不同的错误
 */
void exit(int status);

(1)使用场景

① 通常在父进程中使用exit(),以确保程序在退出前能执行清理操作,如关闭文件和刷新输出。

② 在子进程中,特别是在fork()之后立即调用了一个执行操作(如exec())但执行失败时,推荐使用_exit()或_Exit()来确保子进程的快速、干净地退出,避免执行标准的清理操作,这些操作可能会与父进程发生冲突或不必要的重复。

2.2.3 综合案例

使用标准的系统调用,来对第二章的文件进行简单的读写操作:

(1)创建文件system_call_test.c,写入以下内容

#include <stdio.h>
#include <stdlib.h> 
#include <unistd.h>
#include <fcntl.h>

int main(int argc, char const *argv[])
{ 
    int fd = open("io.txt", O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }
    char buffer[1024]; // 创建一个缓冲区来存放读取的数据
    ssize_t bytes_read;
    
    while ((bytes_read = read(fd, buffer, sizeof(buffer))) > 0) {
        // 将读取的数据写入标准输出
        write(STDOUT_FILENO, buffer, bytes_read);
    }
    if (bytes_read == -1) {
        perror("read");
        close(fd);
        exit(EXIT_FAILURE);
    }
    close(fd); // 使用完毕后关闭文件描述符
    return 0;
}

(2)Makefile中补充以下内容

system_call_test: system_call_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行结果如下

image-20260816180239796

2.3 文件描述符

1)定义

在Linux系统中,当我们打开或创建一个文件(或套接字)时,操作系统会提供一个文件描述符(File Descriptor,FD),这是一个非负整数,我们可以通过它来进行读写等操作。

然而,文件描述符本身只是操作系统为应用程序操作底层资源(如文件、套接字等)所提供的一个引用或“句柄”。

在Linux中,文件描述符0、1、2是有特殊含义的。

Ø 0是标准输入(stdin)的文件描述符

Ø 1是标准输出(stdout)的文件描述符

Ø 2是标准错误(stderr)的文件描述符

2)文件描述符关联的数据结构

(1)struct file

每个文件描述符都关联到内核一个struct file类型的结构体数据,结构体定义位于Linux系统的/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/fs.h文件中,从992行开始。

该结构体的部分关键字段如下。

struct file {
    ...... 
atomic_long_t f_count;       // 引用计数,管理文件对象的生命周期
    struct mutex f_pos_lock;     // 保护文件位置的互斥锁
    loff_t f_pos;                // 当前文件位置(读写位置)
    ...... 
struct path f_path;          // 记录文件路径
    struct inode *f_inode;              // 指向与文件相关联的inode对象的指针,该对象用于维护文件元数据,如文件类型、访问权限等
    const struct file_operations *f_op; // 指向文件操作函数表的指针,定义了文件支持的操作,如读、写、锁定等
    ...... 
void *private_data;                  // 存储特定驱动或模块的私有数据
    ......
} __randomize_layout
    __attribute__((aligned(4)));

这个数据结构记录了与文件相关的所有信息,其中比较关键的是f_path记录了文件的路径信息,f_inode,记录了文件的元数据。

(2)struct path

结构体定义位于Linux系统的/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/path.h文件中,从第8行开始。

struct path {
    struct vfsmount *mnt;
    struct dentry *dentry;
} __randomize_layout;

该结构体只有两个属性,这里不再通过定义展开。

Ø struct vfsmount:是虚拟文件系统挂载点的表示,存储有关挂载文件系统的信息。

Ø struct dentry:目录项结构体,代表了文件系统中的一个目录项。目录项是文件系统中的一个实体,通常对应一个文件或目录的名字。通过这个类型的属性,可以定位文件位置。

(3)struct inode

结构体定义位于Linux系统的/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/fs.h文件中,从639行开始。

struct inode结构的部分定义如下。

struct inode {
    umode_t i_mode; // 文件类型和权限。这个字段指定了文件是普通文件、目录、字符设备、块设备等,以及它的访问权限(读、写、执行)。
    unsigned short i_opflags;
    kuid_t i_uid; // 文件的用户ID,决定了文件的拥有者。
    kgid_t i_gid; // 文件的组ID,决定了文件的拥有者组。
    unsigned int i_flags;
    ...... 
unsigned long i_ino; // inode编号,是文件系统中文件的唯一标识。
    ...... 
loff_t i_size;       // 文件大小
} __randomize_layout;

3)文件描述符表关联的数据结构

(1)打开的文件表数据结构

struct files_struct是用来维护一个进程(下文介绍)中所有打开文件信息的。

结构体定义位于/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/fdtable.h文件中,从49行开始。

部分字段如下。

struct files_struct {
    ...... 
    struct fdtable __rcu *fdt;   // 指向当前使用的文件描述符表(fdtable)
    ...... 
    unsigned int next_fd;        // 存储下一个可用的最小文件描述符编号
    ...... 
    struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // struct file指针的数组,大小固定,用于快速访问。
};

fdt维护了文件描述符表,其中记录了所有打开的文件描述符和struct file的对应关系。

(2)打开文件描述符表

打开文件描述符表底层的数据结构是struct fdtable。

结构体定义位于/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/fdtable.h文件中,从27行开始。如下。

struct fdtable {
    unsigned int max_fds;   // 文件描述符数组的容量,即可用的最大文件描述符
    struct file __rcu **fd; // 指向struct file指针数组的指针
    unsigned long *close_on_exec;
    unsigned long *open_fds;
    unsigned long *full_fds_bits;
    struct rcu_head rcu;
};

(3)fd_array和fd

fd_array是一个定长数组,用于存储进程最常用的struct file。

fd是一个指针,可以指向任何大小的数组,其大小由max_fds字段控制。它可以根据需要动态扩展,以容纳更多的文件描述符。

当打开文件描述符的数量不多于NR_OPEN_DEFAULT时,fd指向的通常就是fd_array,当文件描述符的数量超过NR_OPEN_DEFAULT时,会发生动态扩容,会将fd_array的内容复制到扩容后的指针数组,fd指向扩容后的指针数组。这一过程是内核控制的。

(4)文件描述符和fd或fd_array的关系

文件描述符是一个非负整数,其值实际上就是其关联的struct file在fd指向的数组或fd_array中的下标。

4)文件描述符引用图解

image-20260816182947321

5)小结

当我们执行open()等系统调用时,内核会创建一个新的struct file,这个数据结构记录了文件的元数据(文件类型、权限等)、文件路径、支持的操作等,然后分配文件描述符,将struct file维护在文件描述符表中,最后将文件描述符返回给应用程序。我们可以通过后者对文件执行它所支持的各种函数操作,而这些函数的函数指针都维护在struct file_operations数据结构中。文件描述符实质上是底层数据结构struct file的一个引用或者句柄,它为用户提供了操作底层文件的入口。

第 3 章 进程处理

3.1 Linux进程简介

3.1.1 什么是进程

顾名思义,进程(Process)是正在运行的程序,是操作系统进行资源分配和调度的基本单位。程序是存储在硬盘或内存的一段二进制序列,是静态的,而进程是动态的。进程包括代码、数据以及分配给它的其他系统资源(如文件描述符、网络连接等)。

我们打开的VMWare、开启的浏览器都对应操作系统的一个进程。

在/home/atguigu下创建process_test目录,本章的所有文件都会放在该目录下。

3.1.2 使用system函数生成子进程

system函数是标准库中执行shell指令的函数,可以使用man 3 system命令查看其声明。

(1)创建system_test.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
int main()
{
    /*
    根据传入的命令启动一个进程
    参数: 传入可执行的shell命令
    return: 成功返回0 不支持shell返回-1 失败返回非0
    */
    int result = system("ping -c 10 www.atguigu.com");
    if (result != 0)
    {
        printf("无法执行命令");
        return 1;
    }
    
    return 0;
}

system中的shell命令表示向www.atguigu.com发送10个ICMP回声请求。-c 10是用来控制ICMP请求次数的,如果不加该参数,ping命令会无限期发送直至用户手动终止。

(2)创建Makefile,写入以下内容。

CC:=gcc

system_test: system_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

要注意,Makefile更改完成后必须保存,否则make执行的还是之前的内容。

(3)ps -ef查看子进程

① 运行程序,system会帮助我们启动一个shell子进程。

image-20260818070335002

② 在子进程执行过程中,开启一个终端执行ps -ef指令查看所有进程,可以看到下面的内容:

ps -ef展示的进程是按照进程的pid升序排列的,通常,创建越晚的进程pid越大。因此,我们从下往上找,很快就可以定位刚刚启动的进程。

image-20260818070350896

test程序的pid为6176,它启动了子进程6185(sh -c ping -c 10 www.atguigu.com),这个进程又启动了子进程6186(ping -c 10 www.atguigu.com

3.2 进程处理相关系统调用

system函数用到的系统调用为fork、execve和waitpid,下面我们就来详细介绍以下这几个系统调用。

3.2.1 C标准中的main函数声明

C语言标准提供了以下两种main函数声明。

(1)无参数形式

int main(void);

int main();

这两种形式都表示main函数不接收命令行参数。在C99标准之前,main函数没有参数的形式被写为int main(),这在某些情况下可能导致与int main(void)行为不完全相同的问题,因为int main()在老式的C语言标准中不明确指出函数是否接受参数。从C99标准开始,推荐使用int main(void)明确指明main函数不接受任何参数,以提高代码的可读性和一致性。

(2)有参数形式

int main(int argc, char *argv[]);

Ø argc:传递给程序的命令行参数的数量

Ø argv:指向字符串数组的指针,存储了命令行参数

Ø argv[0]通常是程序的名称

Ø argv[1]到argv[argc-1]是实际的命令行参数

3.2.2 fork

1)相关系统调用及数据类型
(1)pid_t

关于返回值类型pid_t:这个类型定义在头文件/usr/include/x86-64_64-linux-gnu/sys/types.h中,定义如下

// 函数定义
__pid_t fork (void)
// 类型定义
typedef __pid_t pid_t;

可以看到,pid_t是__pid_t的别名,后者定义在/usr/include/x86-64_64-linux-gnu/bits/types.h中,相关宏定义如下。

__STD_TYPE __PID_T_TYPE __pid_t;
#define __PID_T_TYPE        __S32_TYPE
#define __S32_TYPE      int
#define __STD_TYPE     typedef

__STD_TYPE预处理后被替换为typedef,__PID_T_TYPE预处理后被替换为int,因此,__pid_t实际上是这样定义的

typedef int __pid_t;

它是int的别名,所以pid_t也是int的别名。

(2)fork()
 /**
 * @brief 创建一个子进程(相当于复制,包括内存空间)
 * void: 不需要填写参数
 *  
 * @return pid_t pid_t(int值) 
 *  (1) 在父进程中 返回子进程的PID
 *  (2) 在子进程中 返回0
 *  (3) 发生错误 返回-1
 */
pid_t fork(void);


(3)getpid()
#include <sys/types.h>
#include <unistd.h>

/**
 * @brief 返回调用进程的PID
 * 
 * @return pid_t 不会失败,必然返回进程PID
 */
pid_t getpid(void);

(4)getppid()
#include <sys/types.h>
#include <unistd.h>

/**
 * @brief 返回调用进程父进程的PID
 * 
 * @return pid_t 不会失败,必然返回父进程PID,即PPID
 */
pid_t getppid(void);

2)测试例程

(1)创建文件fork_test.c,写入以下内容

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
    printf("海哥教老学员%d春暖花开!\n",getpid());
    pid_t pid =  fork();
    if (pid < 0)
    {
        // 创建新进程失败
        printf("新学员加入失败\n");
        return 1;
    }else if (pid == 0){
        // 这里往下的代码都是新的子进程的
        printf("新学员%d加入成功,他是老学员%d推荐的\n",getpid(),getppid());
    }else{
        // 这里往下继续运行父进程
        printf("老学员%d继续深造,他推荐了%d\n",getpid(),pid);
    }
    return 0;
}

(2)Makefile写入以下内容

fork_test: fork_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行

image-20260818072149808

(4)执行过程分析

image-20260818072211423

3.2.3 文件描述符的引用计数和 close()

1) 案例

(1)sleep()

函数原型

#include <unistd.h>

/**
 * 整个进程睡眠指定秒数,如果在sleep()期间进程接收到信号,且信号有相应的处理函数,则sleep()可能提前结束,并返回剩余未休眠的时间
 * 
 * int seconds: 暂停的秒数
 * return: unsigned int 如果sleep()函数正常执行且休眠时间结束,则返回0;如果sleep()由于接收到信号而被提前唤醒,则返回剩余的未休眠秒数
 */
unsigned int sleep(unsigned int seconds);

(2)测试例程

当我们执行fork()系统调用时,子进程会复制父进程的资源,包括文件描述符。思考下面的案例。

创建fork_fd_test.c,写入以下内容。

#include <sys/stat.h>
#include <stdio.h>
#include <stdlib.h> 
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
int main(int argc, char const *argv[])
{
    // fork之前
    // 打开一个文件
    int fd = open("io.txt",O_CREAT | O_WRONLY | O_APPEND ,0644);
    if (fd == -1)
    {
        perror("open");
        exit(EXIT_FAILURE);
    }
    char buffer[1024];//缓冲区存放写出的数据
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork");
        exit(EXIT_FAILURE);
    }else if (pid == 0){
        // 子进程代码
        strcpy(buffer,"这是子进程写入的数据!\n");
    }else {
        // 父进程代码
        sleep(1);
        strcpy(buffer,"这是父进程写入的数据!\n");
    }
    // 父子进程都要执行的代码
    ssize_t bytes_write = write(fd,buffer,strlen(buffer));
   
    if (bytes_write == -1)
    {
        perror("write");
        close(fd);
        exit(EXIT_FAILURE);
    }
    printf("写入数据成功\n");
    // 使用完毕之后关闭
    close(fd);
    if (pid == 0)
    {
        printf("子进程写入完毕,并释放文件描述符\n");
    }else{
        printf("父进程写入完毕,并释放文件描述符\n");
    }
    return 0;
}

我们通过open()以写入追加的模式打开了io.txt文件,如果这个文件不存在则创建。查看VScode工作目录/home/atguigu/process_test,不存在io.txt。

image-20260820202433243

上述程序的逻辑是:打开io.txt文件,获得文件描述符后,执行fork()创建子进程。分别在父子进程中向文件追加写入,并在写入完成后关闭。为了区分父子进程的操作,我们在父进程中通过sleep()休眠1s。

(3)Makefile

Makefile中写入以下内容。

fork_fd_test: fork_fd_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@
2)测试

(1)执行

image-20260820202810652

(2)查看io.txt

image-20260820202836629

(3)分析

子进程复制了父进程的文件描述符fd,二者指向的应是同一个底层文件描述(struct file结构体)。我们发现,子进程通过close()释放文件描述符之后,父进程对于相同的文件描述符执行write()操作仍然成功了。这是为什么?

struct file结构体中有一个属性为引用计数,记录的是与当前struct file绑定的文件描述符数量。close()系统调用的作用是将当前进程中的文件描述符和对应的struct file结构体解绑,使得引用计数减一。如果close()执行之后,引用计数变为0,则会释放struct file相关的所有资源。

image-20260820202908303

3.2.4 execve

1)execve单独测试

(1)创建erlou.c

exec系列函数可以在同一个进程中跳转执行另外一个程序,我们先准备一个可执行程序erlou,通过编译erlou.c获得。

#include <stdio.h>
#include <stdlib.h> 
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2) {
        printf("参数不够,上不了二楼.\n");
        return 1; // 当没有传入参数时,应返回非零值表示错误
    }
    printf("我是%s %d,我跟海哥上二楼啦!\n", argv[1], getpid());
    return 0;
}

(2)创建execve_test.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    /*exec系列函数  父进程跳转进入一个新进程
   推荐使用execve
    char *__path: 需要执行程序的完整路径名
    char *const __argv[]: 指向字符串数组的指针 需要传入多个参数
        (1) 需要执行的程序命令(同*__path)
        (2) 执行程序需要传入的参数
        (3) 最后一个参数必须是NULL
    char *const __envp[]: 指向字符串数组的指针 需要传入多个环境变量参数
        (1) 环境变量参数 固定格式 key=value
        (2) 最后一个参数必须是NULL
    return: 成功就回不来了 下面的代码都没有意义
            失败返回-1
    int execve (const char *__path, char *const __argv[], char *const __envp[]) 
    */
    char *name = "banzhang";
    printf("我是%s %d,我现在在一楼\n",name,getpid());
    // 参数没填写够也能完成跳转,错误信息会在新程序中
    // char *argv[] = {"/home/atguigu/process_test/erlou",NULL};
    char *args[] = {"/home/atguigu/process_test/erlou",name,NULL};
    // 环境变量可以不传
    // char *envp[] = {NULL};
    char *envs[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin",NULL};
    int re = execve(argv[0],args,envs);
    if (re == -1)
    {
        printf("你没机会上二楼\n");
        return -1;
    }
    
    return 0;
}

(3)Makefile

erlou: erlou.c
    -$(CC) -o $@ $^

execve_test: execve_test.c erlou
    -$(CC) -o $@ $<
    -./$@
    -rm ./$@ ./$(word 2, $^)

① $<:取依赖列表的第一个文件,此处是execve_test.c。等价于$(firstword $^)。

② $(word 2, $^):取依赖列表的第二个文件,此处是erlou,这里也可以用$(lastword $^)表示取依赖列表的最后一个文件,也是erlou。

(1)运行测试

点击运行execve_test即可,结果如下。

image-20260820204130010

image-20260820204140099

2) execve+fork

(1)测试例程

可以fork和exec共同使用,实现场景老学员推荐新学员在二楼学习,自己保持不变。

创建fork_execve_test.c,写入以下内容。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

int main(int argc, char const *argv[])
{
    char *name="老学员";
    printf("%s%d在一楼精进\n",name,getpid());
    __pid_t pid = fork();
    if (pid == -1)
    {
        printf("邀请新学员失败!\n");
    }else if (pid == 0)
    {
        // 新学员在这里
        char *newName = "ergou";
        char *args[] = {"/home/atguigu/process_test/erlou",newName,NULL};
        char *envs[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin",NULL};
        int re = execve(argv[0],args,envs);
        if (re == -1)
        {
            printf("新学员上二楼失败\n");
            return 1;
        }
        
    }else{
        // 老学员在这里
        printf("老学员%d邀请完%d之后还是在一楼学习\n",getpid(),pid);
        
    }
    return 0;
}

(2)Makefile

fork_execve_test: fork_execve_test.c erlou
    -$(CC) -o $@ $(firstword $^)
    -./$@
    -rm ./$@ ./$(lastword $^)

(3)运行复合案例

image-20260820204324899

(4)执行过程图解

image-20260820204344806

3.2.5 waitpid

Linux中父进程除了可以启动子进程,还要负责回收子进程的状态。如果子进程结束后父进程没有正常回收,那么子进程就会变成一个僵尸进程——即程序执行完成,但是进程没有完全结束,其内核中PCB结构体(下文介绍)没有释放。在上面的例子中,父进程在子进程结束前就结束了,那么其子进程的回收工作就交给了父进程的父进程的父进程(省略若干父进程)。

本节通过系统调用waitpid在父进程中等待子进程完成并执行回收工作。

(1)函数原型

执行man 2 waitpid查看手册可知声明如下:

#include <sys/types.h>
#include <sys/wait.h>

/** 等待子进程的终止并获取子进程的退出状态
*    功能简单 没有选择
*/
pid_t wait(int *wstatus);
/**
 * 功能灵活 可以设置不同的模式 可以等待特定的子进程
 * 
 * pid: 等待的模式
 *      (1) 小于-1 例如 -1 * pgid,则等待进程组ID等于pgid的所有进程终止
 *      (2) 等于-1 会等待任何子进程终止,并返回最先终止的那个子进程的进程ID -> 儿孙都算
 *      (3) 等于0 等待同一进程组中任何子进程终止(但不包括组领导进程) -> 只算儿子
 *      (4) 大于0 仅等待指定进程ID的子进程终止
 * wstatus: 整数指针,子进程返回的状态码会保存到该int
 * options: 选项的值是以下常量之一或多个的按位或(OR)运算的结果;二进制对应选项,可多选:
 *      (1) WNOHANG 如果没有子进程终止,也立即返回;用于查看子进程状态而非等待
 *      (2) WUNTRACED 收到子进程处于收到信号停止的状态,也返回。
 *      (3) WCONTINUED(自Linux 2.6.10起)如果通过发送SIGCONT信号恢复了一个已停止的子进程,则也返回。
 * return: (1) 成功等到子进程停止 返回pid
 *         (2) 没等到并且没有设置WNOHANG 一直等
 *         (3) 没等到设置WNOHANG 返回0
 *         (4) 出错返回-1
 */
pid_t waitpid(pid_t pid, int *wstatus, int options);

/*
    更加全面的子进程监控和状态报告
*/
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

(2)测试例程

创建waitpid_test.c,写入以下内容。

#include <unistd.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>

int main(int argc, char const *argv[])
{
    // fork之前
    int subprocess_status;
    printf("老学员在校区\n");
    pid_t pid = fork();
    if (pid < 0)
    {
        perror("fork");
        return 1;
    }else if (pid == 0)
    {
        // 新学员
        char *args[] = {"/usr/bin/ping","-c","50","www.atguigu.com",NULL};
        char *envs[] = {NULL};
        printf("新学员%d联系海哥10次\n",getpid());
        int exR = execve(args[0],args,envs);    
        if (exR < 0)
        {
            perror("execve");
            return 1;
        }
        
    }else {
        // 老学员
        printf("老学员%d等待新学员%d联系\n",getpid(),pid);
        waitpid(pid,&subprocess_status,0);
    }
    
    printf("老学员等待新学员联系完成\n");
    return 0;
}

waitpid(pid, &subprocess_status, 0)执行之后将会一直挂起父进程(调用者)的执行,直至子进程pid终止。

(3)Makefile

waitpid_test: waitpid_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(4)运行

image-20260821214652267

(5)在运行的过程中使用ps -ef | grep ping查看进程

image-20260821214719660

此时父进程没有结束,而是在等待子进程

(6)子进程执行完毕后,重新查看进程

image-20260821214749367

此时父进程和子进程全部销毁,父进程在子进程结束后回收了它,然后运行结束,由其父进程回收。

3.2.6 进程树

Linux的进程是通过父子关系组织起来的,所有进程之间的父子关系共同构成了进程树(Process Tree)。进程树中每个节点都是其上级节点的子进程,同时又是子结点的父进程。一个进程的父进程只能有一个,而一个进程的子进程可以不止一个。

1)创建pstree_test.c,写入以下内容

以下程序是在fork_execve_test.c的基础上做了改动,通过fgetc()阻塞父进程的执行,确保测试期间父进程不会退出。

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

int main(int argc, char const *argv[])
{
    char *name="老学员";
    printf("%s%d在一楼精进\n",name,getpid());
    __pid_t pid = fork();
    if (pid == -1)
    {
        printf("邀请新学员失败!\n");
    }else if (pid == 0)
    {
        // 新学员在这里
        char *newName = "ergou";
        char *argv[] = {"/home/atguigu/process_test/erlou",newName,NULL};
        char *envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin",NULL};
        int re = execve(argv[0],argv,envp);
        if (re == -1)
        {
            printf("新学员上二楼失败\n");
            return 1;
        }
        
    }else{
        // 老学员在这里
        printf("老学员%d邀请完%d之后还是在一楼学习\n",getpid(),pid);
        // 等待新学员二楼结束 手动输入一个字母结束等待
        char bye = fgetc(stdin);
        
    }
    return 0;
}

2)Makefile

pstree_test: pstree_test.c erlou
    -$(CC) -o $@ $<
    -./$@
    -rm ./$@ ./$(lastword $^)

3)运行测试

image-20260821214948884

(1)此时在终端命令行执行ps -ef查看进程

image-20260821215008269

此时通过fork()和execve()启动的子进程共存,我们可以追溯main进程的父级进程,上推可以看到其三级父进程的pid为11614。

(2)执行命令ps -ef | grep 11614

image-20260821215041123

(3)执行命令ps -ef | grep 11612

image-20260821215103247

(4)执行命令ps -ef | grep 10295

image-20260821215123450

(5)执行命令ps -ef | grep 3444

image-20260821215142911

(6)执行命令ps -ef | grep 3316

image-20260821215202450

(7)执行命令ps -ef | grep 1582

image-20260821215221989

可以看到pid为1582的进程,它的父进程为1号进程,这个进程是操作系统的第一个进程,是所有其它进程的父进程

(8)执行命令ps -ef

image-20260821215247976

可以看到pid为1的进程是由/sbin/init splash命令创建的。

(9)执行ll /sbin/init

image-20260821215316161

实质上,1号进程就是systemd,它由内核创建,是第一个进程,负责初始化系统,启动其他所有用户空间的服务和进程。它是所有进程的祖先。

在ps -ef的输出结果中,我们发现,CMD部分有的行带有[],而有的没有,前者属于内核线程,内核线程在内核空间执行,不占用任何用户空间资源,它们在技术上是线程,而在许多方面表现得像独立的进程,因此也会被ps命令检索到。第一个内核线程的pid为2,它是所有其它内核线程的祖先。

image-20260821215334539

此外,pid为1的进程和pid为2的内核线程,它们的父进程id均为0,这是因为二者都是由内核直接创建,都是“祖先”,不存在“辈分”更大的进程或内核线程。

(10)pstree查看进程树

image-20260821215405056

pstree命令会以树状图展示所有用户进程的依赖关系,可以看到,systemd是所有用户进程的祖先。

(11)pstree -p查看进程树 -p表示显示进程号

image-20260821215441495

image-20260821215451390

可以看到,ping进程的父子关系正如我们通过ps -ef | grep [pid]的方式逐级检索查明的那样。

3.2.7 孤儿进程

孤儿进程(Orphan Process)是指父进程已结束或终止,而它仍在运行的进程。

当父进程结束之前没有等待子进程结束,且父进程先于子进程结束时,那么子进程就会变成孤儿进程。

(1)创建erlou_block.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2)
    {
        printf("参数不够,上不了二楼.\n");
        return 1; // 当没有传入参数时,应返回非零值表示错误
    }
    printf("我是%s %d,我跟海哥上二楼啦!\n", argv[1], getpid());
    // 挂起子进程
    sleep(100);
    return 0;
}

(1)创建orphan_process_test.c,写入以下内容

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

int main(int argc, char const *argv[])
{
    char *name = "老学员";
    printf("%s%d在一楼精进\n", name, getpid());
    __pid_t pid = fork();
    if (pid == -1)
    {
        printf("邀请新学员失败!\n");
    }
    else if (pid == 0)
    {
        // 新学员在这里
        char *newName = "ergou";
        char *argv[] = {"/home/atguigu/process_test/erlou_block", newName, NULL};
        char *envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin", NULL};
        int re = execve(argv[0], argv, envp);
        if (re == -1)
        {
            printf("新学员上二楼失败\n");
            return 1;
        }
    }
    else
    {
        // 老学员在这里
        printf("老学员%d邀请完%d之后还是在一楼学习\n", getpid(), pid);
    }
    return 0;
}

(3)Makefile

erlou_block: erlou_block.c
    -$(CC) -o $@ $^

orphan_process_test: orphan_process_test.c erlou_block
    -$(CC) -o $@ $<
    -./$@
    -rm ./$@ ./$(word 2, $^)

(4)重新执行程序

image-20260821215737345

(5)在终端命令行通过ps -ef | grep 12463查看进程

image-20260821215757094

可以看到子进程已被其祖先领养,此时的进程树如下(进程树过长,省略无关紧要的部分)

image-20260821215816463

我们可以得出结论:孤儿进程会被其祖先自动领养。此时的子进程因为和终端切断了联系,所以很难再进行标准输入使其停止了,所以写代码的时候一定要注意避免出现孤儿进程。

3.3 进程间通信

进程之前的内存是隔离的,如果多个进程之间需要进行信息交换,常用的方法有以下几种:

(1)Unix Domain Socket IPC

(2)管道(有名管道、无名管道)

(3)共享内存

(4)消息队列

(5)信号量

此处提到的Unix Domain Socket IPC和信号量放在后面的章节讲解。

3.3.1 System V IPC和POSIX IPC

1)System V

System V(读作System Five)是一种基于UNIX的操作系统版本,最初由AT&T(American TelePhone and Telegraph Company,美国电话电报公司,由Bell TelePhone Company发展而来)开发。它在1983年首次发布,对UNIX操作系统的发展产生了深远的影响。SystemV引入了许多新的特性和标准,后来被许多UNIX系统和类UNIX系统(如Linux)采纳。

2)System V IPC

System V IPC(Inter-Process Communication,进程间通信)是System V操作系统引入的一组进程间通信机制,包括消息队列、信号量和共享内存。这些机制允许不同的进程以一种安全且高效的方式共享数据和同步操作。

(1)消息队列:允许进程以消息的形式交换数据,这些消息存储在队列中,直到它们被接收。

(2)信号量:主要用于进程间的同步,防止多个进程同时访问相同的资源。

(3)共享内存:允许多个进程访问同一块内存区域,提供了一种非常高效的数据共享方式。

System V IPC是UNIX和类UNIX系统中常用的IPC方法之一,它通过关键字(key)来标识和访问IPC资源。

3)POSIX IPC

POSIX IPC是POSIX标准中的一部分,提供了一种更现代和标准化的进程间通信方式,同样包括消息队列、信号量和共享内存三种方式。

(1)消息队列:类似于System V,但通常具有更简洁的API和更好的错误处理能力。

(2)信号量:提供了更多的功能和更高的性能,支持更大范围的操作。

(3)共享内存:提供了更多的控制和配置选项,以支持更复杂的应用场景。

POSIX IPC 使用名字(name)作为唯一标识。这些名字通常是以正斜杠(/)开头的字符串,用于唯一地识别资源如消息队列、信号量或共享内存对象。

4)二者的比较

System V IPC和POSIX IPC在功能上有所重叠,但它们在实现和API设计上有明显的区别。POSIX IPC通常被视为更现代、更标准化的解决方案,提供了更好的跨平台支持和更易于使用的API。然而,System V IPC在历史上更早地被大量UNIX系统所采用,因此在一些旧的或特定的环境中仍然非常重要。在选择使用哪种IPC机制时,应考虑应用程序的具体需求、目标系统的支持程度以及开发者的熟悉程度。

System V IPC和POSIX IPC各自提供了一组API,如果全部介绍未免太过冗长,他们实现的效果是类似的。本文只介绍POSIX IPC提供的API。

3.3.2 匿名管道(Pipe)

1)库函数perror()

(1)创建perror_test.c,写入以下内容。

#include <stdio.h>

int main(int argc, char const *argv[])
{
    /**
     * 输出错误信息报告到系统的错误输出
     * char *__s: 自定义的错误信息前缀 会打印在输出的前面 中间补充": " 后面跟errno
     * 隐藏参数: errno 用于保存错误信息的全局变量 系统调用和库函数出错会将信息存储到这里
     * void perror (const char *__s)
    */
    fopen("bucunzai.txt","r");
    perror("这道题我不会做! ");
    return 0;
}

(2)Makefile

perror_test: perror_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260821224300659

(4)分析

当系统调用或库函数发生错误时,通常会通过设置全局变量errno来指示错误的具体原因。errno是在C语言(及其在Unix、Linux系统下的应用)中用来存储错误号的一个全局变量。每当系统调用或某些库函数遇到错误,无法正常完成操作时,它会将一个错误代码存储到errno中。这个错误代码提供了失败的具体原因,程序可以通过检查errno的值来确定发生了什么错误,并据此进行相应的错误处理。

errno定义在头文件<errno.h>中,引入该文件即可调用全局变量errno。

perror函数用于将errno当前值对应的错误描述以人类可读的形式输出到标准错误输出(stderr)。

参数s:指向一个字符串的指针,如果s不是空指针且指向的不是\0字符,则perror会在s后添加一个冒号和空格作为前缀,输出错误信息,否则不输出前缀,直接输出错误信息。

(5)创建errno_test.c

#include <errno.h>
#include <stdio.h>

int main() {

    fopen("/opt", "a+");

    printf("errno: %d\n", errno);
    perror("文件打开出问题");

    return 0;
}

(6)Makefile

errno_test: errno_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(7)运行结果如下

image-20260821224443189

2)系统调用pipe()

匿名管道是位于内核的一块缓冲区,用于进程间通信。创建匿名管道的系统调用为pipe。执行man 2 pipe查看手册,其声明如下:

#include <unistd.h>

/**
 * 在内核空间创建管道,用于父子进程或者其他相关联的进程之间通过管道进行双向的数据传输。。
 * 
 * pipefd: 用于返回指向管道两端的两个文件描述符。pipefd[0]指向管道的读端。pipefd[1]指向管道的写端。
 * return: 成功   0
 *         不成功 -1,并且pipefd不会改变
 */
int pipe(int pipefd[2]);

3)匿名管道测试例程

(1)宏定义EXIT_FAILURE、EXIT_SUCCESS 、STDOUT_FILENO

#define EXIT_FAILURE    1   /* Failing exit status.  */
#define EXIT_SUCCESS    0   /* Successful exit status.  */

#define STDIN_FILENO    0   /* Standard input.  */
#define STDOUT_FILENO   1   /* Standard output.  */
#define STDERR_FILENO   2   /* Standard error output.  */

Ø EXIT_FAILURE:表示以失败状态退出进程

Ø EXIT_SUCCESS:表示以成功状态退出进程

Ø STDOUT_FILENO:表示标准输出

(2)创建unnamed_pipe_test.c

下面的例子展示了父进程将argv[1]写入匿名管道,子进程读取并输出到控制台的过程,例程如下。

#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main(int argc, char const *argv[])
{
    
    int pipefd[2];
    pid_t cpid;
    char buf;
    if (argc != 2)
    {
        fprintf(stderr, "%s:请填写需要传递的信息\n", argv[0]);
        exit(EXIT_FAILURE);
    }
    if (pipe(pipefd) == -1)
    {
        perror("创建管道失败\n");
        exit(EXIT_FAILURE);
    }
    cpid = fork();
    if (cpid == -1)
    {
        perror("邀请新学员失败!\n");
        exit(EXIT_FAILURE);
    }
    if (cpid == 0)
    {       
        // 新学员读数据 关闭写端              
        close(pipefd[1]); 
        char str[100]={0};
        sprintf(str,"新学员%d收到邀请\n",getpid());
        write(STDOUT_FILENO, str, sizeof(str));
        // 一直读取读端的数据 单个字节读取方便读取结尾 直到数据结束或出错
        while (read(pipefd[0], &buf, 1) > 0){
            // 将读取数据写到标准输出
            write(STDOUT_FILENO, &buf, 1);
        }
        // 输出换行
        write(STDOUT_FILENO, "\n", 1);
        close(pipefd[0]);
        _exit(EXIT_SUCCESS);
    }
    else
    { 
        // 老学员写数据 关闭读端
        close(pipefd[0]); 
        // 写入传入的参数到管道的写端
        printf("老学员%d发出邀请\n",getpid());
        write(pipefd[1], argv[1], strlen(argv[1]));
        // 写完之后关闭写端  读端会返回0
        close(pipefd[1]); 
        // 等待子进程结束
        waitpid(cpid,NULL,0);       
        exit(EXIT_SUCCESS);
    }
}

(3)Makefile

unnamed_pipe_test: unnamed_pipe_test.c
    -$(CC) -o $@ $<
    -./$@ "test"
    -rm ./$@

需要注意的是,本例中我们需要通过命令行传参程序才能正确执行,-./$@之后的"test"就是传递给程序的命令行参数。

(4)运行

image-20260821224722165

可以看到命令行传入的参数被打印到了终端,我们在上述程序中通过父进程将数据写入了管道,然后子进程从管道读出数据,写入了标准输出,日志如上则测试通过。

4)使用管道的限制:

(1)两个进程通过一个管道只能实现单向通信,比如上面的例子,父进程写子进程读,如果有时候也需要子进程写父进程读,就必须另开一个管道。

管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道文件描述符。上面的例子是父进程把文件描述符传给子进程之后父子进程之间通信,也可以父进程fork两次,把文件描述符传给两个子进程,然后两个子进程之间通信,总之需要通过fork传递文件描述符使两个进程都能访问同一管道,它们才能通信。

5)管道返回的文件描述符

管道返回的两个文件描述符分别表示读写,各自指向一个struct file结构体,然而,它们并不对应真正的文件。

struct file的私有数据指针属性private_data指向struct pipe_inode_info类型的结构体,后者声明位于/usr/src/linux-hwe-6.5-headers-6.5.0-27/include/linux/pipe_fs_i.h,定义如下。

struct pipe_inode_info {
    struct mutex mutex;                 // 互斥锁,用于同步对管道的访问
    wait_queue_head_t rd_wait, wr_wait; // 等待队列,分别用于读操作和写操作的阻塞管理
    unsigned int head;                  // 管道缓冲区中读取数据的起始位置
    unsigned int tail;                  // 管道缓冲区中写入数据的结束位置
    unsigned int max_usage;             // 管道使用过的最大容量,用于监控和调优
    unsigned int ring_size;             // 管道缓冲区的总大小(环形缓冲区的大小)
    unsigned int nr_accounted;          // 已记录的管道使用量
    unsigned int readers;               // 当前打开管道读端的文件描述符数量
    unsigned int writers;               // 当前打开管道写端的文件描述符数量
    unsigned int files;                 // 总共打开的文件描述符数量
    unsigned int r_counter;             // 读操作的计数器
    unsigned int w_counter;             // 写操作的计数器
    bool poll_usage;                    // 标志位,指示是否有进程在使用poll等机制监视管道
#ifdef CONFIG_WATCH_QUEUE
    bool note_loss; // 是否记录了通知的丢失(用于调试或特定的资源监控)
#endif
    struct page *tmp_page;                // 用于管道操作的临时内存页,通常用于优化读写性能
    struct fasync_struct *fasync_readers; // 异步读操作通知结构体,支持SIGIO信号
    struct fasync_struct *fasync_writers; // 异步写操作通知结构体,支持SIGIO信号
    struct pipe_buffer *bufs;             // 指向管道缓冲区数组的指针,每个元素管理一个缓冲块
    struct user_struct *user;             // 指向代表管道拥有者的用户结构体
#ifdef CONFIG_WATCH_QUEUE
    struct watch_queue *watch_queue; // 用于管理内核通知队列的结构体(用于高级事件监视功能)
#endif
};

当我们开启管道时,父进程会创建两个struct file结构体用于管道的读写操作,并为二者各自分配一个文件描述符。它们的private_data属性指向同一个struct pipe_inode_info的结构体,由后者管理对于管道缓冲区的读写。通过fork()创建一个子进程,后者会继承文件描述符,指向相同的struct file结构体,如下。

image-20260821224854746

6)图解

进程间通过管道通信的过程如下

image-20260821224913149

3.3.3 有名管道(FIFO)

上文介绍的Pipe是匿名管道,只能在有父子关系的进程间使用,某些场景下并不能满足需求。与匿名管道相对的是有名管道,在Linux中称为FIFO,即First In First Out,先进先出队列。

FIFO和Pipe一样,提供了双向进程间通信渠道。但要注意的是,无论是有名管道还是匿名管道,同一条管道只应用于单向通信,否则可能出现通信混乱(进程读到自己发的数据)。

有名管道可以用于任何进程之间的通信。

1)库函数mkfifo()

执行man 3 mkfifo查看文件说明

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

/**
 * @brief 用于创建有名管道。该函数可以创建一个路径为pathname的FIFO专用文件,mode指定了FIFO的权限,FIFO的权限和它绑定的文件是一致的。FIFO和pipe唯一的区别在于创建方式的差异。一旦创建了FIFO专用文件,任何进程都可以像操作文件一样打开FIFO,执行读写操作。
 * 
 * @param pathname 有名管道绑定的文件路径
 * @param mode 有名管道绑定文件的权限
 * @return int 
 */
int mkfifo(const char *pathname, mode_t mode);

2)有名管道发送端

创建fifo_write.c,写入以下内容

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int main()
{
    int fd;
    char *pipe_path = "/tmp/myfifo";

    // 创建有名管道,权限设置为 0664
    if (mkfifo(pipe_path, 0664) != 0)
    {
        perror("mkfifo failed");
        if (errno != 17)
        {
            exit(EXIT_FAILURE);
        }
    }

    // 打开有名管道用于写入
    fd = open(pipe_path, O_WRONLY);
    if (fd == -1)
    {
        perror("open failed");
        exit(EXIT_FAILURE);
    }

    char write_buf[100];
    ssize_t read_num;

    while ((read_num = read(STDIN_FILENO, write_buf, 100)) > 0) {
        write(fd, write_buf, read_num);
    }

    if (read_num < 0) {
        perror("read");
        printf("命令行数据读取异常,退出");
        close(fd);
        exit(EXIT_FAILURE);
    }
    
    printf("发送管道退出,进程终止\n");
    close(fd);

    return 0;
}

3)有名管道接收端

创建fifo_read.c,写入以下内容。

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main() {
    int fd;
    char *pipe_path = "/tmp/myfifo";
    
    // 打开有名管道用于读取
    fd = open(pipe_path, O_RDONLY);
    if (fd == -1) {
        perror("open failed");
        exit(EXIT_FAILURE);
    }
    
    char read_buff[100];
    ssize_t read_num;

    while ((read_num = read(fd, read_buff, 100)) > 0) {
        write(STDOUT_FILENO, read_buff, read_num);
    }

    if (read_num < 0) {
        perror("read");
        printf("管道数据读取异常,退出");
        exit(EXIT_FAILURE);
    }
    
    printf("接收管道退出,进程终止\n");
    close(fd);

    return 0;
}

4)Makefile

(1)声明伪目标

在文件开头补充

.PHONY: named_fifo named_fifo_clean

如下

image-20260822104959275

(2)补充编译和清除target

fifo_write: fifo_write.c
    $(CC) -o $@ $<

fifo_read: fifo_read.c
    $(CC) -o $@ $<

named_fifo: fifo_write fifo_read

named_fifo_clean:
    -rm ./fifo_write ./fifo_read

5)测试

(1)编译

image-20260822105051654

(1)在XShell开启两个标签页,右键点击“垂直分割”

image-20260822105109511

(3)在两个标签页分别开启发送端和接收端

image-20260822105128358

(4)在发送端发送数据

image-20260822105146877

接收端可以看到数据。

(5)终止进程

发送端按下Ctrl+D,终止发送端,接收端收到信号,也会退出。

image-20260822105220040

6)有名管道在文件系统的对应

(1)再次启动

image-20260822105248632

再次启动上述文件提示mkfifo失败,原因是文件已存在,那么这里的文件是指哪个文件?

(2)fifo专用文件

创建有名管道时,我们同时创建了一个fifo专用文件,在上述案例中是/tmp/myfifo,这实际上就是fifo专用文件在文件系统的路径,如下。

image-20260822105314318

程序执行完毕之后,该文件未被清除,因此会出现上述报错。

(3)更改fifo_write.c

有名管道使用完成后,应该通过unlink调用清除相关资源。这个函数只能调用一次,重复清除会提示No such file or directory。

#include <unistd.h>

/**
 * @brief 从文件系统中清除一个名称及其链接的文件
 * 
 * @param pathname 文件路径
 * @return int 成功返回0,失败返回-1,并设置errno
 */
int unlink(const char *pathname);

我们在fifo_write.c中补充资源释放操作,如下。

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int main()
{
    int fd;
    char *pipe_path = "/tmp/myfifo";

    // 创建有名管道,权限设置为 0664
    if (mkfifo(pipe_path, 0664) != 0)
    {
        perror("mkfifo failed");
        if (errno != 17)
        {
            exit(EXIT_FAILURE);
        }
    }

    // 打开有名管道用于写入
    fd = open(pipe_path, O_WRONLY);
    if (fd == -1)
    {
        perror("open failed");
        exit(EXIT_FAILURE);
    }

    char write_buf[100];
    ssize_t read_num;

    while ((read_num = read(STDIN_FILENO, write_buf, 100)) > 0) {
        write(fd, write_buf, read_num);
    }

    if (read_num < 0) {
        perror("read");
        printf("命令行数据读取异常,退出");
        close(fd);
        exit(EXIT_FAILURE);
    }
    
    printf("发送管道退出,进程终止\n");
    close(fd);

    if(unlink(pipe_path) == -1) {
        perror("fifo_write unlink");
    }

    return 0;
}

接下来,首先清除之前遗留的/tmp/myfifo,清除fifo_read和fifo_write。然后重新编译运行,再停止读写进程,可以发现fifo专用文件已被清除。

7)注意

调用open()打开有名管道时,flags设置为O_WRONLY则当前进程用于向有名管道写入数据,设置为O_RDONLY则当前进程用于从有名管道读取数据。设置为O_RDWR从技术上是可行的,但正如上文提到的,此时管道既读又写很可能导致一个进程读取到自己发送的数据,通信出现混乱。因此,打开有名管道时,flags只应为O_WRONLY或O_RDONLY。

内核为每个被进程打开的FIFO专用文件维护一个管道对象。当进程通过FIFO交换数据时,内核会在内部传递所有数据,不会将其写入文件系统。因此,/tmp/myfifo文件大小始终为0。

image-20260822105447382

需要注意的是,文件详细信息最开头的字母p表示这个是一个有名管道文件。

8)图解

image-20260822105513588

3.3.4 共享内存

1)shm_open()和shm_unlink()

shm_open可以开启一块内存共享对象,我们可以像使用一般文件描述符一般使用这块内存对象。

#include <sys/mman.h>

/**
 * const char *name: 这是共享内存对象的名称,直接写一个文件名称,本身会保存在 /dev/shm 。名称必须是唯一的,以便不同进程可以定位同一个共享内存段。
 * 命名规则:必须是以正斜杠/开头,以\0结尾的字符串,中间可以包含若干字符,但不能有正斜杠
 * int oflag: 打开模式 二进制可拼接
 *      (1) O_CREAT:如果不存在则创建新的共享内存对象
 *      (2) O_EXCL:当与 O_CREAT 一起使用时,如果共享内存对象已经存在,则返回错误(避免覆盖现有对象)
 *      (3) O_RDONLY:以只读方式打开
 *      (4) O_RDWR:以读写方式打开
 *      (5) O_TRUNC 用于截断现有对象至0长度(只有在打开模式中包含 O_RDWR 时才有效)。
 * mode_t mode: 当创建新共享内存对象时使用的权限位,类似于文件的权限模式,一般0644即可
 * return: 成功执行,它将返回一个新的描述符;发生错误,返回值为 -1
*/
int shm_open(const char *name, int oflag, mode_t mode);

/**
 * 
 * 删除一个先前由 shm_open() 创建的命名共享内存对象。尽管这个函数被称为“unlink”,但它并没有真正删除共享内存段本身,而是移除了与共享内存对象关联的名称,使得通过该名称无法再打开共享内存。当所有已打开该共享内存段的进程关闭它们的描述符后,系统才会真正释放共享内存资源
 *
 * char *name: 要删除的共享内存对象名称
 * return: 成功返回0 失败返回-1
 */
int shm_unlink(const char *name);

2)truncate()和ftruncate()

truncate和ftruncate都可以将文件缩放到指定大小,二者的行为类似:如果文件被缩小,截断部分的数据丢失,如果文件空间被放大,扩展的部分均为\0字符。缩放前后文件的偏移量不会更改。缩放成功返回0,失败返回-1。

不同的是,前者需要指定路径,而后者需要提供文件描述符;ftruncate缩放的文件描述符可以是通过shm_open()开启的内存对象,而truncate缩放的文件必须是文件系统已存在文件,若文件不存在或没有权限则会失败。

#include <unistd.h>
#include <sys/types.h>

/**
 * 将指定文件扩展或截取到指定大小
 * 
 * char *path: 文件名 指定存在的文件即可 不需要打开
 * off_t length: 指定长度 单位字节
 * return: int 成功 0
 *             失败 -1
 */
int truncate(const char *path, off_t length);
/**
 *  将指定文件描述符扩展或截取到指定大小
 * 
 * int fd: 文件描述符 需要打开并且有写权限
 * off_t length: 指定长度 单位字节
 * return: int 成功 0
 *             失败 -1
 */
int ftruncate(int fd, off_t length);

3)mmap()

mmap系统调用可以将一组设备或者文件映射到内存地址,我们在内存中寻址就相当于在读取这个文件指定地址的数据。父进程在创建一个内存共享对象并将其映射到内存区后,子进程可以正常读写该内存区,并且父进程也能看到更改。使用man 2 mmap查看该系统调用声明

#include <sys/mman.h>

/**
 * 将文件映射到内存区域,进程可以直接对内存区域进行读写操作,就像操作普通内存一样,但实际上是对文件或设备进行读写,从而实现高效的 I/O 操作
 * 
 * void *addr: 指向期望映射的内存起始地址的指针,通常设为 NULL,让系统选择合适的地址
 * size_t length: 要映射的内存区域的长度,以字节为单位
 * int prot: 内存映射区域的保护标志,可以是以下标志的组合
 *          (1) PROT_READ: 允许读取映射区域
 *          (2) PROT_WRITE: 允许写入映射区域
 *          (3) PROT_EXEC: 允许执行映射区域
 *          (4) PROT_NONE: 页面不可访问
 * int flags:映射选项标志
 *          (1) MAP_SHARED: 映射区域是共享的,对映射区域的修改会影响文件和其他映射到同一区域的进程(一般使用共享)
 *          (2) MAP_PRIVATE: 映射区域是私有的,对映射区域的修改不会影响原始文件,对文件的修改会被暂时保存在一个私有副本中
 *          (3) MAP_ANONYMOUS: 创建一个匿名映射,不与任何文件关联
 *          (4) MAP_FIXED: 强制映射到指定的地址,如果不允许映射,将返回错误
 * int fd: 文件描述符,用于指定要映射的文件或设备,如果是匿名映射,则传入无效的文件描述符(例如-1)
 * off_t offset: 从文件开头的偏移量,映射开始的位置
 * return void*: (1) 成功时,返回映射区域的起始地址,可以像操作普通内存那样使用这个地址进行读写
 *               (2) 如果出错,返回 (void *) -1,并且设置 errno 变量来表示错误原因
 */
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
/**
 * 用于取消之前通过 mmap() 函数建立的内存映射关系
 * 
 * void *addr: 这是指向之前通过 mmap() 映射的内存区域的起始地址的指针,这个地址必须是有效的,并且必须是 mmap() 返回的有效映射地址
 * size_t length: 这是要解除映射的内存区域的大小(以字节为单位),它必须与之前通过 mmap() 映射的大小一致
 * return: int 成功 0
 *             失败 -1
 */
int munmap(void *addr, size_t length);

4)测试例程

下面的程序展示了如何使用mmap在父子进程之间共享信息。

(1)创建shared_memory.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    char *share;
    pid_t pid;
    char shmName[100]={0};
    sprintf(shmName,"/letter%d",getpid());
    // 共享内存对象的文件标识符
    int fd;
    fd = shm_open(shmName, O_CREAT | O_RDWR, 0644);
    if (fd < 0)
    {
        perror("共享内存对象开启失败!\n");
        exit(EXIT_FAILURE);
    }
    // 将该区域扩充为100字节长度
    ftruncate(fd, 100);
    // 以读写方式映射该区域到内存,并开启父子共享标签 偏移量选择0从头开始
    share = mmap(NULL, 100, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    // 注意:不是p == NULL 映射失败返回的是((void *) -1)
    if (share == MAP_FAILED)
    { 
        perror("共享内存对象映射到内存失败!\n");
        exit(EXIT_FAILURE);
    }
    // 映射区建立完毕,关闭读取连接 注意不是删除
    close(fd);
    // 创建子进程
    pid = fork(); 
    if (pid == 0)
    {
        // 子进程写入数据作为回信 
        strcpy(share, "你是个好人!\n");
        printf("新学员%d完成回信!\n", getpid());
    }
    else
    {
        // 等待回信
        sleep(1);
        printf("老学员%d看到新学员%d回信的内容: %s", getpid(),pid,share);
        // 等到子进程运行结束
        wait(NULL);
        // 释放映射区
        int ret = munmap(share, 100); 
        if (ret == -1)
        {
            perror("munmap");
            exit(EXIT_FAILURE);
        }
    }
    // 删除共享内存对象
    shm_unlink(shmName);
    return 0;
}

(2)Makefile

shared_memory: shared_memory.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)运行

image-20260822111410300

5)临时文件系统

Linux的临时文件系统(tmpfs)是一种基于内存的文件系统,它将数据存储在RAM或者在需要时部分使用交换空间(swap)。tmpfs访问速度快,但因为存储在内存,重启后数据清空,通常用于存储一些临时文件。

我们可以通过df -h查看当前操作系统已挂载的文件系统。

image-20260822111438758

6)内存共享对象在临时文件系统中的表示

内存共享对象在临时文件系统中的表示位于/dev/shm目录下。

为看到共享对象在临时文件系统中的表示,我们修改程序,在创建后不销毁。

(1)创建shared_memory_block.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>

int main()
{
    char *share;
    pid_t pid;
    char shmName[100]={0};
    sprintf(shmName,"/letter%d",getpid());
    printf("shmName: %s\n", shmName);
    // 共享内存对象的文件标识符
    int fd;
    fd = shm_open(shmName, O_CREAT | O_RDWR, 0644);
    if (fd < 0)
    {
        perror("共享内存对象开启失败!\n");
        exit(EXIT_FAILURE);
    }
    while(1);
    return 0;
}

(2)Makefile

shared_memory_block: shared_memory_block.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行

image-20260822111556212

(4)查看/dev/shm目录

image-20260822111614198

3.3.5 消息队列

1)相关数据类型

(1)mqd_t

该数据类型定义在mqueue.h中,是用来记录消息队列描述符的。

typedef int mqd_t;

实质上是int类型的别名。

(2)struct mq_attr

/**
 * @brief 消息队列的属性信息
 * mq_flags 标记,对于mq_open,忽略它,因为这个标记是通过前者的调用传递的
 * mq_maxmgs 队列可以容纳的消息的最大数量
 * mq_msgsize 单条消息的最大允许大小,以字节为单位
 * mq_curmsgs 当前队列中的消息数量,对于mq_open,忽略它
 */
struct mq_attr {
long mq_flags;   /* Flags (ignored for mq_open()) */
long mq_maxmsg;  /* Max. # of messages on queue */
long mq_msgsize; /* Max. message size (bytes) */
long mq_curmsgs; /* # of messages currently in queue
                (ignored for mq_open()) */
};

(3)struct timespec

/**
 * @brief 时间结构体,提供了纳秒级的UNIX时间戳
 * tv_sec 秒
 * tv_nsec 纳秒
 */
struct timespec {
time_t tv_sec;        /* seconds */
long   tv_nsec;       /* nanoseconds */
};

2)相关系统调用

(1)mq_open()

#include <fcntl.h>    /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <mqueue.h>

/**
 * @brief 创建或打开一个已存在的POSIX消息队列,消息队列是通过名称唯一标识的。
 *
 * @param name 消息队列的名称
 * 命名规则:必须是以正斜杠/开头,以\0结尾的字符串,中间可以包含若干字符,但不能有正斜杠
 * @param oflag 指定消息队列的控制权限,必须也只能包含以下三者之一
 * O_RDONLY 打开的消息队列只用于接收消息
 * O_WRONLY 打开的消息队列只用于发送消息
 * O_RDWR 打开的消息队列可以用于收发消息
 * 可以与以下选项中的0至多个或操作之后作为oflag
 * O_CLOEXEC 设置close-on-exec标记,这个标记表示执行exec时关闭文件描述符
 * O_CREAT 当文件描述符不存在时创建它,如果指定了这一标记,需要额外提供mode和attr参数
 * O_EXCL 创建一个当前进程独占的消息队列,要同时指定O_CREAT,要求创建的消息队列不存在,否则将会失败,并提示错误EEXIST
 * O_NONBLOCK 以非阻塞模式打开消息队列,如果设置了这个选项,在默认情况下收发消息发生阻塞时,会转而失败,并提示错误EAGAIN
 * @param mode 每个消息队列在mqueue文件系统对应一个文件,mode是用来指定消息队列对应文件的权限的
 * @param attr 属性信息,如果为NULL,则队列以默认属性创建

* @return mqd_t 成功则返回消息队列描述符,失败则返回(mqd_t)-1,同时设置errno以指明错误原因
*/
mqd_t mq_open(const char *name, int oflag, mode_t mode, struct mq_attr *attr);

/**
 * @brief 当oflag没有包含O_CREAT时方可调用
 *
 * @param name 同上
 * @param oflag 同上
 * @return mqd_t 同上
 */
mqd_t mq_open(const char *name, int oflag);

(2)mq_timedsend()

#include <time.h>
#include <mqueue.h>
/**
 * @brief 将msg_ptr指向的消息追加到消息队列描述符mqdes指向的消息队列的尾部。如果消息队列已满,默认情况下,调用阻塞直至有充足的空间允许新的消息入队,或者达到abs_timeout指定的等待时间节点,或者调用被信号处理函数打断。需要注意的是,正如上文提到的,如果在mq_open时指定了O_NONBLOCK标记,则转而失败,并返回错误EAGAIN。
 * 
 * @param mqdes 消息队列描述符
 * @param msg_ptr 指向消息的指针
 * @param msg_len msg_ptr指向的消息长度,不能超过队列的mq_msgsize属性指定的队列最大容量,长度为0的消息是被允许的
 * @param msg_prio 一个非负整数,指定了消息的优先级,消息队列中的数据是按照优先级降序排列的,如果新旧消息的优先级相同,则新的消息排在后面。
 * @param abs_timeout 指向struct timespec类型的对象,指定了阻塞等待的最晚时间。如果消息队列已满,且abs_timeout指定的时间节点已过期,则调用立即返回。
 * @return int 成功返回0,失败返回-1,同时设置errno以指明错误原因。
 */
int mq_timedsend(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio, const struct timespec *abs_timeout);

(3)mq_timedreceive()

#include <time.h>
#include <mqueue.h>

/**
 * @brief 从消息队列中取走最早入队且权限最高的消息,将其放入msg_ptr指向的缓存中。如果消息队列为空,默认情况下调用阻塞,此时的行为与mq_timedsend同理。
 * 
 * @param mqdes 消息队列描述符
 * @param msg_ptr 接收消息的缓存
 * @param msg_len msg_ptr指向的缓存区的大小,必须大于等于mq_msgsize属性指定的队列单条消息最大字节数
 * @param msg_prio 如果不为NULL,则用于接收接收到的消息的优先级 
 * @param abs_timeout 阻塞时等待的最晚时间节点,同mq_timedsend
 * @return ssize_t 成功则返回接收到的消息的字节数,失败返回-1,并设置errno指明错误原因
 */
ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio, const struct timespec *abs_timeout);

(4)mq_unlink()

#include <mqueue.h>

/**
 * @brief 清除name对应的消息队列,mqueue文件系统中的对应文件被立即清除。消息队列本身的清除必须等待所有指向该消息队列的描述符全部关闭之后才会发生。
 * 
 * @param name 消息队列名称
 * @return int 成功返回0,失败返回-1,并设置errno指明错误原因
 */
int mq_unlink(const char *name);

(5)clock_gettime()

#include <time.h>

/**
 * @brief 获取以struct timespec形式表示的clockid指定的时钟
 * 
 * @param clockid 特定时钟的标识符,常用的是CLOCK_REALTIME,表示当前真实时间的时钟
 * @param tp 用于接收时间信息的缓存
 * @return int 成功返回0,失败返回-1,同时设置errno以指明错误原因
 */
int clock_gettime(clockid_t clockid, struct timespec *tp);

3)父子进程间通信测试例程

(1)创建father_son_mq_test.c

#include <fcntl.h>
#include <sys/stat.h>
#include <mqueue.h>
#include <stdio.h>
#include <time.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char const *argv[])
{
    // 创建消息队列
    struct mq_attr attr;
    // 有用的参数  表示消息队列的容量
    attr.mq_maxmsg = 10;
    attr.mq_msgsize = 100;
    // 被忽略的消息 在创建消息队列的时候用不到
    attr.mq_flags = 0;
    attr.mq_curmsgs = 0;
    
    char *mq_name = "/father_son_mq";
    mqd_t mqdes =  mq_open(mq_name,O_RDWR | O_CREAT,0664,&attr);
    if (mqdes == (mqd_t)-1)
    {
        perror("mq_open");
        exit(EXIT_FAILURE);
    }
    // 创建父子进程
    pid_t pid = fork();
    
    if (pid < 0)
    {
        perror("fork");
        exit(EXIT_FAILURE);
    }
    if (pid == 0)
    {
        // 子进程  等待接收消息队列中的信息
        char read_buf[100];
        struct timespec time_info;
        for (size_t i = 0; i < 10; i++)
        {
            // 清空接收数据的缓冲区
            memset(read_buf,0,100);
            // 设置接收数据的等待时间
            clock_gettime(0,&time_info);
            time_info.tv_sec += 15;
            // 接收消息队列的数据  打印到控制台
            if (mq_timedreceive(mqdes,read_buf,100,NULL,&time_info) == -1)
            {
                perror("mq_timedreceive");
            }
            printf("子进程接收到数据:%s\n",read_buf);
        }
        
    }else {
        // 父进程  发送消息到消息队列中
        char send_buf[100];
        struct timespec time_info;
        
        for (size_t i = 0; i < 10; i++)
        {
            // 清空处理buf
            memset(send_buf,0,100);
            sprintf(send_buf,"父进程的第%d次发送消息\n",(int)(i+1));
            // 获取当前的具体时间
            clock_gettime(0,&time_info);
            time_info.tv_sec += 5;
            // 发送消息
            if (mq_timedsend(mqdes,send_buf,strlen(send_buf),0,&time_info) == -1)
            {
                perror("mq_timedsend");
            }
            printf("父进程发送一条消息,休眠1s\n");
            sleep(1);
        }
        
       
    }
    // 最终不管是父进程还是子进程都需要释放消息队列的引用
    close(mqdes);
    // 清除消息队列只需要执行一次
    if (pid > 0)
    {
        mq_unlink(mq_name);
    }
    return 0;
}

(2)Makefile

father_son_mq_test: father_son_mq_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

(3)执行结果如下

image-20260822115021743

子进程正确接收了父进程发送的数据。

4)非父子进程通信案例

我们创建两个进程:生产者和消费者,前者从控制台接收数据并写入消息队列,后者从消息队列接收数据并打印到控制台。

(1)创建producer.c

#include <time.h>
#include <mqueue.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

int main()
{
    char *mq_name = "/p_c_mq";

    struct mq_attr attr;

    attr.mq_flags = 0;
    attr.mq_maxmsg = 10;
    attr.mq_msgsize = 100;
    attr.mq_curmsgs = 0;

    // 创建或打开消息队列
    mqd_t mqdes = mq_open(mq_name, O_CREAT | O_WRONLY, 0666, &attr);

    if (mqdes == (mqd_t)-1)
    {
        perror("mq_open");
    }

    char writeBuf[100];
    struct timespec time_info;

    while (1)
    {
        // 清空写缓冲区
        memset(writeBuf, 0, 100);

        // 从命令行标准输入读取数据
        ssize_t read_count = read(0, writeBuf, 100);
        if (read_count == -1)
        {
            perror("read");
            continue;
        }
        else if (read_count == 0)
        {
        }

        // 获取当前时间中5s之后的timespec对象
        clock_gettime(CLOCK_REALTIME, &time_info);
        time_info.tv_sec += 5;

        // 发送数据
        // 如果接收到命令行的EOF,read将返回0,此时向消费者端发送信息并退出
        if (read_count == 0)
        {
            printf("Received EOF, exit...\n");
            char eof = EOF;
            if (mq_timedsend(mqdes, &eof, 1, 0, &time_info) == -1)
            {
                perror("mq_timedsend");
            }
            break;
        }
        // 没有接收到EOF,正常发送数据
        if (mq_timedsend(mqdes, writeBuf, strlen(writeBuf), 0, &time_info) == -1)
        {
            perror("mq_timesend");
        }
        printf("从命令行接收到数据,已发送至消费者端\n");
    }

    // 关闭描述符
    close(mqdes);

    // mq_unlink 只应调用一次,我们选择在消费者中完成此操作

    return 0;
}

(2)创建consumer.c

#include <time.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>

int main()
{
    char *mq_name = "/p_c_mq";

    struct mq_attr attr;

    attr.mq_flags = 0;
    attr.mq_maxmsg = 10;
    attr.mq_msgsize = 100;
    attr.mq_curmsgs = 0;

    // 创建或打开消息队列
    mqd_t mqdes = mq_open(mq_name, O_CREAT | O_RDONLY, 0666, &attr);

    if (mqdes == -1)
    {
        perror("mq_open");
    }

    char readBuf[100];
    struct timespec time_info;

    while (1)
    {
        memset(readBuf, 0, 100);

        // 获取1天后的time_spec结构对象,目的是在测试期间使得消费者一直等待生产者发送的数据
        clock_gettime(CLOCK_REALTIME, &time_info);
        time_info.tv_sec += 86400;

        // 接收数据
        if (mq_timedreceive(mqdes, readBuf, 100, NULL, &time_info) == -1)
        {
            perror("mq_timedreceive");
        }

        // 如果收到生产者发送的EOF,则结束进程
        if (readBuf[0] == EOF)
        {
            printf("接收到生产者的终止信号,准备退出...\n");
            break;
        }
        // 如果没有收到EOF,将接收到的数据打印到标准输出
        printf("接收到来自于生产者的数据\n%s", readBuf);
    }

    // 释放消息队列描述符
    close(mqdes);

    // mq_unlink 只应调用一次
    // 清除消息队列
    mq_unlink(mq_name);
}

(3)Makefile

在伪目标中添加p_c_mq和pc_mq_clean,如下。

image-20260822115224124

补充以下内容

producer: producer.c
    $(CC) -o $@ $^

cosumer: consumer.c
    $(CC) -o $@ $^

p_c_mq: producer consumer

pc_mq_clean:
    -rm ./producer ./consumer

5)消息队列在mqueue文件系统的表示

我们启动producer和consumer,然后查看/dev/mqueue目录,如下。

image-20260822115311798

终止消费和生产者后,上述文件被清除。

6)消息队列通信方向的讨论

我们可以通过设置POSIX消息队列的模式为O_RDWR,使它可以用于收发数据,从技术上讲,单条消息队列可以用于双向通信,但是这会导致消息混乱,无法确定队列中的数据是本进程写入的还是读取的,因此,不会这么做,通常单条消息队列只用于单向通信。为了实现全双工通信,我们可以使用两条消息队列,分别负责两个方向的通信。类似于管道。

3.4 信号

3.4.1 信号简介

在Linux中,信号是一种用于通知进程发生了某种事件的机制。信号可以由内核、其他进程或者通过命令行工具发送给目标进程。Linux系统中有多种信号,每种信号都用一个唯一的整数值来表示,例如,常见的信号包括:

(1)SIGINT(2):这是当用户在终端按下Ctrl+C时发送给前台进程的信号,通常用于请求进程终止。

(2)SIGKILL(9):这是一种强制终止进程的信号,它会立即终止目标进程,且不能被捕获或忽略。

(3)SIGTERM(15):这是一种用于请求进程终止的信号,通常由系统管理员或其他进程发送给目标进程。

(4)SIGUSR1(10)和SIGUSR2(12):这两个信号是用户自定义的信号,可以由应用程序使用。

(5)SIGSEGV(11):这是一种表示进程非法内存访问的信号,通常是由于进程尝试访问未分配的内存或者试图执行非法指令而导致的。

(6)SIGALRM(14):这是一个定时器信号,通常用于在一定时间间隔后向目标进程发送信号。

每种信号都有其特定的含义和行为,进程可以通过注册信号处理函数来捕获信号并执行相应的操作,例如终止进程、忽略信号或者执行特定的处理逻辑。如果想查看所有的Linux信号,请执行kill -l指令,会得到以下反馈:

1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
21) SIGTTIN     22) SIGTTOU     23) SIGURG      24) SIGXCPU     25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF     28) SIGWINCH    29) SIGIO       30) SIGPWR
31) SIGSYS      34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

3.4.2 信号处理例程

我们可以通过signal系统调用注册信号处理函数:

#include <signal.h>

// 信号处理函数声明
typedef void (*sighandler_t)(int);

/**
 *  signal系统调用会注册某一信号对应的处理函数。如果注册成功,当进程收到这一信号时,将不会调用默认的处理函数,而是调用这里的自定义函数
 * 
 * int signum: 要处理的信号
 * sighandler_t handler: 当收到对应的signum信号时,要调用的函数
 * return: sighandler_t 返回之前的信号处理函数,如果错误会返回SEG_ERR
 */
sighandler_t signal(int signum, sighandler_t handler);

下面的例子简单演示了如何处理收到的信号。

1)创建signal_test.c,写入以下内容

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>

// 定义信号处理函数
void sigint_handler(int signum) {
    printf("\n收到%d信号,停止程序!\n",signum);
    exit(signum);
}
int main() {
    // 注册SIGINT信号处理函数 收到ctrl+c信号之后不执行默认的函数,而是执行新的注册函数
    if (signal(SIGINT, sigint_handler) == SIG_ERR) {
        perror("注册新的信号处理函数失败\n");
        return 1;
    }
   
    // 无限循环等待信号
    while (1) {
        sleep(1);
        printf("你好,在吗?\n");
    }
    return 0;
}

2)Makefile

signal_test: signal_test.c
    -$(CC) -o $@ $^
    -./$@
    -rm ./$@

3)运行

程序运行后会以1s的间隔打印“你好,在吗?”。

光标移动到终端,按下Ctrl+C,结果如下。

image-20260822124148031

信号处理函数已被替换。

posted @ 2026-08-25 07:27  Ref-brief  阅读(6)  评论(1)    收藏  举报