-
本书讲述了Linux操作系统以及我们如何在Linux环境中使用C++语言来管理关键资源。C++语言持续在进步,正如你将在后续章节中探索的那样。在深入了解这些内容之前,我们希望在本章中先建立对操作系统(OS)的基础理解。你将学习到一些特定技术的由来,系统调用接口以及可移植操作系统接口(POSIX)。
操作系统的选择非常重要。尽管操作系统最初被创建出来是为了单一目的,但如今它们的角色各不相同。同时,对它们的期望也很高。每个操作系统都有自己的优点和缺点,我们将简要讨论这些。Linux在多个技术领域得到了广泛应用,并拥有庞大的全球社区,因此非常适合我们的实践目的。在Linux或其他基于Unix的操作系统环境中进行编程是相当常见的。无论你的专业知识属于哪个领域——从物联网(IoT)设备和嵌入式软件开发到移动设备、超级计算或航天器——你都很有可能在某个时刻与Linux发行版有所交集。
将本章作为系统编程的入门指南。即使你已经熟悉这个主题,也请花时间回顾一下术语和细节。这些内容大多数都在大学课程中涉及,或被视为常识,但对我们来说,解释这些基础知识仍然很重要,以确保我们在接下来的章节中能够达成共识。
在本章中,我们将涵盖以下主要主题:
- 熟悉操作系统的概念
- 了解Linux内核
- 介绍系统调用接口和系统编程
- 浏览文件、进程和线程
- 使用
init
和systemd
运行服务 - 可移植操作系统接口(POSIX)
基本要求
- 能够编译和执行C++20的基于Linux的系统(例如,Linux Mint 21)
熟悉操作系统的概念
什么是操作系统(OS)?你可能至少能给出一个答案,但让我们简要讨论一下,因为理解我们的计算机系统究竟是什么,以及我们如何操纵它,是很重要的。虽然你可能已经熟悉这里提供的大部分信息,但我们使用本章来就操作系统及其用途与你达成一致。有人可能会说,操作系统的创建是为了使硬件作为一个整体工作。其他人则认为它是一组程序的集合,致力于管理整个系统资源。高效利用这些资源,如CPU和内存,是至关重要的。还有一个将操作系统视为硬件的抽象和扩展的概念。最终,我们可以安全地说,现代操作系统是一个复杂的实体。它还具有额外的功能,如统计数据的收集、多媒体处理、系统安全与安全性、总体稳定性、可靠的错误处理等。
虽然操作系统有义务执行所有这些任务,但程序员仍需关注系统的具体情况和要求。通过更高级别的抽象,例如通过虚拟机工作,并不意味着放弃了解我们的代码如何影响系统行为的需要。而且,更接近操作系统层的程序员也需要高效地管理系统资源。这就是操作系统提供应用程序编程接口(API)的原因之一。了解如何使用这些API以及它们提供的好处是一种宝贵的专业知识。
我们认为,能够与操作系统紧密合作的能力并不常见。了解操作系统和计算机架构将如何表现的知识,在软件工程的专家级别上是至关重要的。我们将讨论一些类型的操作系统,只是为了给你一个大致的画面,但本书的重点特别是符合POSIX标准的操作系统。
操作系统的类型
如果我们在网上进行快速搜索,我们会发现许多类型的操作系统,类型定义将严格基于搜索的标准。一个例子是操作系统的用途:它是通用的,如macOS和Windows,还是更具体的,如嵌入式Linux和FreeRTOS?另一个例子是PC操作系统与移动设备操作系统。同样,许可证可以将操作系统描述为开源、企业或企业开源。根据同时活跃用户的数量,Windows可以被视为单用户操作系统,因为它为当前用户会话构建了一个Win32 API。另一方面,类Unix操作系统被认为是多用户的,因为多个用户可以同时在系统上工作,每个shell或终端实例被视为单独的用户会话。
因此,系统应用程序及其约束是基本的。因此,需要了解的一个关键区别是对系统行为的限制程度。通用操作系统(GPOS)最初起源于分时操作系统。历史上,还有另一种类型的操作系统,与分时操作系统同时期起源——实时操作系统(RTOS)。预计系统程序员了解GPOS和RTOS的细节。在接下来的章节中,我们将讨论任务优先级、计时器值、外围设备速度、中断和信号处理程序、多线程和动态内存分配等属性如何导致系统行为的变化。有时这些变化是不可预测的。这就是为什么我们认识到有两种类型的RTOS:硬RTOS和软RTOS。硬RTOS通常与特定的硬件密切相关。系统开发者熟悉终端设备的要求。任务执行时间可以预先评估和编程,尽管设备的输入仍被视为异步和不可预测的。因此,我们在本书中的重点仍然是GPOS编程,以及一些软RTOS功能。
让我们这样设定舞台:用户以如此频繁的周期方式接收系统资源,以至于产生了用户是依赖这些资源的唯一人的印象。用户的工作不得中断,从操作系统预期的是快速响应时间;从理论上讲,过程越小,响应时间越短。我们将在第二章进一步讨论这一点,因为这并不完全正确。
重要说明
用户是GPOS中系统功能的主要驱动力。操作系统的主要工作是与用户和高可用性操作保持活跃对话。
在这里,每个任务和每个对操作系统的请求都必须在严格的时间间隔内快速处理。RTOS只在异常情况、错误和不可预测的行为期间期待用户输入。
重要说明
异步工作的设备和额外的外围电子设备是RTOS中系统功能的主要驱动力。操作系统的主要工作仍然是进程管理和任务调度。
正如我们所说,有两种类型的RTOS:硬RTOS和软RTOS。在硬RTOS中,实时任务保证按时执行。系统反应截止时间通常是预先定义的,关键任务数据存储在ROM中,因此无法在运行时更新。诸如虚拟内存之类的功能通常被移除。一些现代CPU核提供所谓的紧密耦合内存(TCM),在系统启动时,常用数据和代码行从非易失性内存(NVM)中加载进来。系统的行为是预先编写的。这些操作系统的角色与机器控制有关,在这里禁止用户输入。
软RTOS为关键任务提供最高优先级,直到完成且不受干扰。尽管如此,实时任务预期会及时完成,不应无休止地等待。显然,这种类型的操作系统不能用于关键任务:工厂机器人、车辆等。但它可以用来控制整体系统行为,因此这种类型的操作系统在多媒体和研究项目、人工智能、计算机图形学、虚拟现实设备等中可以找到。由于这些RTOS不与GPOS冲突,它们可以与它们集成。它们的功能也可以在一些Linux发行版中找到。一个有趣的实现是QNX。
Linux简介
这里有一些误解,让我们简要澄清一下。Linux是一种类Unix操作系统,这意味着它提供了类似(有时甚至相同)的接口,如Unix——其功能,特别是API,旨在与Unix的接口相匹配。但它不是基于Unix的操作系统。它们的功能实现方式并不相同。在理解FreeBSD与macOS的关系时,也存在类似的误解。尽管两者共享了大量的代码,但它们的方法完全不同,包括它们的内核结构方式。
牢记这些事实很重要,因为并不是所有我们将在本书中使用的功能都存在或可以在所有类Unix操作系统上访问。我们专注于Linux,只要满足每章的相应技术要求,我们的示例就会有效。
做出这个决定有几个原因。首先,Linux是开源的,你可以轻松地检查其内核代码:https://github.com/torvalds/linux。
你应该能够轻松阅读,因为它是用C语言编写的。尽管C不是面向对象的语言,Linux内核遵循许多面向对象编程(OOP)范式。操作系统本身由许多独立的设计块组成,称为模块。你可以轻松地为你的系统需求配置、集成和应用它们。Linux使我们能够处理实时系统(在本章后面描述)和执行并行代码(在第6章中讨论)。简而言之 - Linux易于适应、扩展和配置;我们可以轻松地利用这一点。但具体在哪里呢?
好吧,我们可以开发接近操作系统的应用程序,或者我们甚至可以自己制作一些模块,这些模块可以在运行时加载或卸载。例如文件系统或设备驱动程序。我们将在第2章中重新讨论这个主题,当深入研究进程实体时。目前,让我们说模块看起来非常像OOP设计:它们是可构造和可销毁的;有时,根据内核的需要,常见代码可以概括为一个模块,这些模块具有层次依赖性。尽管如此,Linux内核被认为是单体的;例如,它具有复杂的功能,但整个操作系统都在内核空间中运行。相比之下,有微内核(如QNX、MINIX或L4),它们构成了运行操作系统的最低限度。在这种情况下,通过在内核本身之外工作的模块提供额外的功能。这导致了Linux内核可能性的略显混乱但总体清晰的画面。
了解Linux内核
图1.1展示了一个Linux内核的示例。根据你的需求,系统架构可能看起来不同,但你可以观察到我们期望在任何给定的Linux系统中看到的三个主要层次。
这些是用户空间(运行进程及其线程)、内核空间(运行的内核本身,通常是它自己的一个进程)和计算机——这可以是任何类型的计算设备,如个人电脑、平板电脑、智能手机、超级计算机、IoT设备等。一个接一个,图表中观察到的所有术语将随着我们在后续章节中的解释而到位,所以如果你现在还不熟悉它们,也不必担心。
图 1.1 – Linux 内核和相邻层的概述
在前面的图表中,一些相互依赖关系可能已经给你留下了深刻印象。例如,看看设备驱动程序、相应设备和中断是如何相关的。设备驱动程序是字符设备驱动程序、块设备驱动程序和网络设备驱动程序的概括。注意中断是如何与任务的调度相关的。这是一个简单但基本的机制,在驱动程序的实现中被大量使用。它是操作系统和硬件的初始通信和控制机制。
举个例子:假设你想从磁盘(NVM)中恢复并读取一个文件,并通过某些标准编程函数请求它。底层将执行一个read()
调用,然后转换为文件系统操作。文件系统调用设备驱动程序来找到并检索给定文件描述符背后的内容,然后与文件系统已知的地址相关联。这将在第3章中进一步讨论。所需的设备(NVM)开始寻找数据片段 - 一个文件。如果调用进程是一个单线程进程并且没有其他事情可做,那么在操作完成之前,它将被停止。另一个进程将开始工作,直到设备找到并返回指针到文件的地址。然后触发一个中断,这有助于操作系统调用调度器。我们最初的进程将使用新加载的数据重新启动,而第二个进程现在将被停止。
这个任务示例展示了你如何通过一个小的、不起眼的操作影响系统的行为 - 这是你在第一次编程课上学会编写的操作之一。在大多数情况下,当然,不会发生任何不好的事情。在你的系统的生命周期内,许多进程将始终被重新调度。操作系统的工作是在不中断的情况下实现这一点。
但中断是一个繁重的操作,可能导致不必要的内存访问和无用的应用程序状态切换。我们将在第2章中讨论这一点。现在,想想如果系统过载会发生什么 - CPU使用率达到99%,或者磁盘收到了许多请求而不能及时处理。如果该系统是飞机上嵌入式设备的一部分又会怎样?当然,这在现实中极不可能,因为飞机有严格的技术要求和高质量标准。但仅就论点而言,考虑你如何防止类似情况的发生,或者如何保证在任何用户场景中代码的成功执行。
介绍系统调用接口和系统编程
当然,我们刚刚看到的例子是简化的,但它给了我们一些关于操作系统需要做的工作的想法 - 本质上,它负责管理和提供资源,但同时仍然可用于其他进程的请求。这在现代操作系统中是一项混乱的工作。我们很少能做些什么。所以,为了更好地控制和预测系统行为,程序员可能会直接使用操作系统的API,称为系统 调用接口。
重要说明
NVM数据请求是一个受益于系统调用接口的过程,因为操作系统将被迫将此请求转换为应用程序二进制接口(ABI)调用,指向相应的设备驱动程序。这样的操作被称为系统调用。使用系统调用来实现或执行操作系统提供的功能被称为系统编程。系统调用是进入内核服务的唯一入口点。它们通常由诸如glibc
之类的库包装,不直接调用。
换句话说,系统调用定义了程序员通过其使用所有内核服务的接口。操作系统可以被视为内核服务和硬件之间的更多的是一个中介。除非你喜欢玩弄硬件引脚和低级平台指令,或者你自己是一个模块架构师,你应该勇敢地将细节留给操作系统。处理特定计算机物理接口操作是操作系统的责任。使用正确的系统调用是应用程序的责任。了解它们对系统整体行为的影响是软件工程师的任务。请记住,使用系统调用是有代价的。
如在示例中所观察到的,操作系统在检索文件时会做很多事情。当动态分配内存或多个线程访问单个内存块时,会做更多的事情。我们将在接下来的章节中进一步讨论这一点,并强调在可能的情况下,有意识地、谨慎地使用系统调用,无论是自愿的还是非自愿的。简而言之,系统调用不是普通的函数调用,因为它们不是在用户空间中执行的。系统调用触发模式切换,而不是转到程序堆栈中的下一个过程,导致跳转到内核内存堆栈中的例程。从文件中读取可以如下图所示:
图1.2 - 从文件中读取的系统调用接口表示
那么,我们什么时候应该使用系统调用呢?简单来说,当我们想要非常精确地处理一些与设备管理、文件管理、进程控制或通信基础设施相关的操作系统任务时。我们将在后面的章节中展示这些角色的许多例子,但简而言之,你可以阅读更多内容,自己熟悉以下几个系统调用:
syscall()
fork()
exec()
exit()
wait()
kill()
重要链接
开始学习的正确地方是Linux man-pages项目,
链接如下:https://www.kernel.org/doc/man-pages/。
在以下链接可以找到有用的系统调用的简要列表:
https://man7.org/linux/man-pages/man2/syscalls.2.html。
我们强烈鼓励你对自己项目中使用的系统调用进行更多研究。有哪些系统调用,它们执行了什么样的工作?在你的实现中有没有替代方案?
你可能已经猜到了,使用系统调用接口也会给系统带来安全风险。如此接近内核和设备控制为恶意软件渗透你的软件提供了绝佳机会。当你的软件影响系统行为时,另一个程序可能会悄悄地收集有价值的数据。你至少可以这样设计你的代码,让用户界面与关键程序,特别是系统调用,良好隔离。不可能做到100%安全,虽然有许多关于安全问题的综合性书籍,但保护系统的艺术本身也是一个不断发展的过程。
说到进程,让我们继续进行下一个主题:Linux系统的基本实体。
浏览文件、进程和线程
如果你能读到这里 - 做得好!我们将在第2章中全面讨论进程和线程,并在第3章中讨论文件系统。同时,我们将在这里简单绕一下弯,通过定义三个重要术语:文件、进程和线程,来为你描绘一个更清晰的画面。你可能已经在之前的内核概览中注意到了其中的两个,所以我们现在将简要解释一下,以防你还不熟悉它们。
文件
简而言之,我们需要文件来表示系统上的多种资源。我们编写的程序也是文件。例如,编译后的代码,可执行二进制文件(.bin
、.exe
)和库都是文件(.o
、.so
、.lib
、.dll
等)。此外,我们还需要它们作为通信机制和存储管理。你知道Linux上可以识别哪些类型的文件吗?让我们快速为你简要介绍一下:
- 普通或常规文件:系统上存储数据的几乎所有文件都被视为常规文件:文本、媒体、代码等。
- 目录:用于构建文件系统的层次结构。它们不存储数据,而是存储其他文件的位置。
- 特殊(设备)文件:你可以在
/dev
目录下找到它们,代表你的所有硬件设备。 - 链接:我们使用它们来访问位于不同位置的另一个文件。实际上,它们是真实文件的替代品,通过它们可以直接访问这些文件。这与Windows的快捷方式不同。它们是特定的文件类型,需要应用程序支持它们 - 首先处理快捷方式的元数据,然后指向资源,所以文件不是一次性访问的。
- 套接字:这是进程交换数据的通信端点,包括与其他系统。
- 命名管道:我们使用命名管道在系统上当前运行的两个进程之间交换双向数据。
在第3章,我们将通过一些实际例子来玩转这些,你会看到除了套接字之外的每种文件类型的使用,套接字将在本书后面详细解释。现在我们需要的是一个要运行的程序。
进程和线程
进程是一个程序的实例,准确地说,是一个执行中的实例。它拥有自己的地址空间,并与其他进程隔离。这意味着每个进程都有操作系统分配给它的一系列(通常是虚拟的)地址。Linux将它们视为任务。它们不为普通用户所见。这只是内核完成其工作的方式。每个任务都通过task_struct
实体描述,定义在include/linux/sched.h
中。系统管理员和系统程序员通过进程表观察进程,通过每个进程的特定进程标识符 - pid
进行散列。这种方法用于快速查找进程 - 在终端使用ps
命令查看系统上的进程状态,然后输入以下命令查看单个进程的具体信息:
ps -p <required pid>
例如,让我们启动一个名为test
的程序,并让它运行:
$ ./test
你可以打开一个单独的终端,按照以下方式查看正在运行的进程列表中的test
:
$ ps
PID TTY TIME CMD
...
56693 ttys001 0:00.00 test
如果你已经知道PID
,那么只需执行以下操作:
$ ps –p 56693
56693 ttys001 0:00.00 test
通过复制当前进程的属性来创建一个新进程,它将属于一个进程组。一个或多个组创建一个会话。每个会话与一个终端相关联。组和会话都有进程领导者。属性的克隆主要用于资源共享。如果两个进程共享相同的虚拟内存空间,它们被视为并作为单个进程中的两个线程进行处理和管理,但它们不像进程那样重。那么,什么是线程呢?
重要说明
总的来说,我们关心四个实体:第一个是可执行文件,因为它是要执行指令的单位载体。第二个是进程 - 执行这些指令的工作单元。第三 - 我们需要这些指令作为处理和管理系统资源的工具。第四个是线程 - 最小的指令序列,由操作系统独立管理,是进程的一部分。请记住,每个操作系统对进程和线程的实现都不同,因此在使用它们之前请先做研究。
从内核的角度看,进程的主线程是任务组领导者,在代码中被标识为group_leader
。由组领导者生成的所有线程都可以通过thread_node
迭代。实际上,它们存储在一个单链表中,而thread_node
是其头部。生成的线程携带一个指向group_leader
工具的指针。进程创建者的task_struct
对象由它指向。你可能已经正确猜到,它与组领导者的task_struct
是相同的。
重要说明
如果一个进程生成另一个进程,例如通过fork()
,新创建的进程(称为子进程)通过parent
指针知道它们的创建者。它们还通过sibling
指针知道它们的兄弟姐妹,这是指向父进程的某个其他子进程的列表节点。每个父进程通过children
知道它的孩子 - 指向列表头的指针,存储孩子并提供对它们的访问。
正如我们在下图中看到的,线程不定义任何其他数据结构:
图1.3 - 通过task_struct
展示进程和线程的结构
我们已经提到过几次fork()
,但它到底是什么呢?简单来说,它是一个系统函数,用于创建调用进程的进程副本。它为父进程提供新进程的ID,并启动子进程的执行。我们将在下一章提供一些代码示例,因此你可以在那里查看更多细节。现在我们正在讨论Linux环境,有一些重要的事情需要提及。
在幕后,fork()
被clone()
替换。通过flags
提供不同的选项,但如果所有选项都设置为零,clone()
的行为就像fork()
。我们建议你在这里阅读更多:https://man7.org/linux/man-pages/man2/clone.2.html。
你可能会问自己为什么这种实现更受青睐。可以这样想:当内核在进程之间切换时,它会检查当前进程在虚拟内存中的地址,准确地说是页目录。如果它与新执行的进程相同,那么它们共享相同的地址空间。然后,切换只是一个简单的指针跳转指令,通常是到程序的入口点。这意味着可以预期更快的重新调度。但要小心 - 这些进程可能共享相同的地址空间,但不共享相同的程序栈。clone()
负责为每个进程创建不同的栈。
现在进程已经创建,我们必须看看它的运行模式。注意,这与进程状态不同。
根据运行模式分类的进程类型
一些进程需要用户交互来启动或进行交互。它们被称为前台进程。但正如你可能已经想到的,有些进程独立于我们或任何其他用户的活动运行。这种类型的进程被称为后台进程。除非另有指示,终端输入作为程序执行调用或用户命令,默认被视为前台进程。要在后台运行一个进程,只需在用于启动进程的命令行末尾添加&
。例如,让我们调用已知的test
,完成后,我们在终端看到以下内容:
$ ./test &
[1] 62934
[1] + done ./test
你可以使用它的pid
调用kill
命令轻松停止它:
$ ./test &
[1] 63388
$ kill 63388
[1] + terminated./test
如你所见,结束进程和让它自行终止是两件不同的事,结束进程可能导致不可预测的系统行为或无法访问某些资源,例如未关闭的文件或套接字。这个话题将在本书后面再次讨论。
其他进程无人看管地运行。它们被称为守护进程,在后台持续运行。它们被期望始终可用。守护进程通常通过系统的启动脚本启动,并运行到关闭为止。它们通常提供系统服务,多个用户依赖它们。因此,启动时的守护进程通常由ID为0的用户(通常是root
)初始化,并可能以root
权限运行。
重要说明
Linux系统上具有最高权限的用户被称为root用户,或简称root。这种权限级别允许执行与安全相关的任务。这个角色直接影响系统的完整性,因此所有其他用户必须设置为最低可能的权限级别,直到需要更高的权限级别。
僵尸进程是已经终止但仍通过其pid
被识别的进程。它没有地址空间。僵尸进程会一直存在,直到它们的父进程运行结束。这意味着直到我们退出主进程、关闭系统或重新启动,僵尸进程在ps
列表中仍会显示为<defunct>
:
$ ps
PID TTY TIME CMD
…
64690 ttys000 0:00.00 <defunct>
你也可以通过top
查看僵尸进程:
$ top
t–p - 07:58:26 up 100 days, 2:34, 2 users, load average: 1.20, 1.12, 1.68
Tasks: 200 total, 1 running, 197 sleeping, 1 stopped, 1 zombie
回到对后台进程的讨论,有另一种方式可以执行特定程序,而无需显式启动后台进程。更好的是 - 我们可以管理在系统启动或不同系统事件上运行的这类进程。让我们在下一节看看这个。
使用 init 和 systemd 运行服务
让我们利用这个机会来讨论init
和systemd
进程守护程序。还有其他的,但我们决定将注意力集中在这两个上。第一个是Linux系统由内核执行的初始进程,其pid
始终为1
:
$ ps -p 1
PID TTY TIME CMD
1 ? 04:53:20 systemd
它被称为系统上所有进程的父进程,因为它用于初始化、管理和跟踪其他服务和守护程序。Linux的第一个init
守护程序被称为Init
,它定义了六个系统状态。所有系统服务分别映射到这些状态。其脚本用于按预定义顺序启动进程,偶尔被系统程序员使用。使用它的一个可能原因是减少系统启动的持续时间。要创建服务或编辑脚本,你可以修改/etc/init.d
。因为这是一个目录,我们可以用ls
命令列出它,看到所有可以通过init
运行的服务。
这是我们机器上的内容:
$ ls /etc/init.d/
acpid
alsa-utils
anacron
...
ufw
uuidd
x11-common
这些脚本中的每一个都遵循相同的代码模板来执行和维护:
图1.4 – init.d脚本,表示可能的服务操作
你可以自己生成相同的模板,并通过以下命令阅读更多关于init
脚本源代码的信息:
$ man init-d-script
你可以通过以下命令列出可用服务的状态:
$ service --status-all
[ + ] acpid
[ - ] alsa-utils
[ - ] anacron
...
[ + ] ufw
[ - ] uuidd
[ - ] x11-common
我们可以停止防火墙服务 - ufw
:
$ service ufw stop
现在,让我们检查它的状态:
$ service ufw status
● ufw.service - Uncomplicated firewall
Loaded: loaded (/lib/systemd/system/ufw.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Thu 2023-04-06 14:33:31 EEST; 46s ago
Docs: man:ufw(8)
Process: 404 ExecStart=/lib/ufw/ufw-init start quiet (code=exited, status=0/SUCCESS)
Process: 3679 ExecStop=/lib/ufw/ufw-init stop (code=exited, status=0/SUCCESS)
Main PID: 404 (code=exited, status=0/SUCCESS)
Apr 06 14:33:30 oem-virtual-machine systemd[1]: Stopping Uncomplicated firewall...
Apr 06 14:33:31 oem-virtual-machine ufw-init[3679]: Skip stopping firewall: ufw (not enabled)
Apr 06 14:33:31 oem-virtual-machine systemd[1]: ufw.service: Succeeded.
Apr 06 14:33:31 oem-virtual-machine systemd[1]: Stopped Uncomplicated firewall.
现在,让我们重新启动它并再次检查其状态:
$ service ufw start
$ service ufw status
● ufw.service - Uncomplicated firewall
Loaded: loaded (/lib/systemd/system/ufw.service; enabled; vendor preset: enabled)
Active: active (exited) since Thu 2023-04-06 14:34:56 EEST; 7s ago
Docs: man:ufw(8)
Process: 3736 ExecStart=/lib/ufw/ufw-init start quiet (code=exited, status=0/SUCCESS)
Main PID: 3736 (code=exited, status=0/SUCCESS)
Apr 06 14:34:56 oem-virtual-machine systemd[1]: Starting Uncomplicated firewall...
Apr 06 14:34:56 oem-virtual-machine systemd[1]: Finished Uncomplicated firewall.
类似地,你可以创建自己的服务,并使用service
命令来启动它。一个重要的备注是,init
在现代的全规模Linux系统上被认为是一种过时的方法。尽管如此,它可以在每一个基于Unix的操作系统上找到,不像systemd,所以系统程序员会预期其作为服务的通用接口。因此,我们更多地将其作为一个简单的例子和解释服务从何而来。如果我们想使用最新的方法,我们必须转向systemd。
systemd是一个init守护程序,代表在Linux系统上运行服务的现代方法。它提供了一个并行的系统服务启动功能,这进一步加快了初始化程序。每个服务都存储在/lib/systemd/system
或/etc/systemd/system
目录下的.service
文件中。/lib
中找到的服务是系统启动服务的定义,/etc
中的是在系统运行期间启动的服务。让我们列出它们:
$ ls /lib/systemd/system
accounts-daemon.service
acpid.path
acpid.service
...
sys-kernel-config.mount
sys-kernel-debug.mount
sys-kernel-tracing.mount
syslog.socket
$ ls /etc/systemd/system
bluetooth.target.wants
display-manager.service
…
timers.target.wants
vmtoolsd.service
在我们继续示例之前,让我们在这里声明一点 - systemd
的接口比init
复杂得多。我们鼓励你花时间单独研究它,因为我们无法在这里简短地总结它。但如果你列出你的systemd
目录,你可能会观察到许多类型的文件。在守护程序的上下文中,它们被称为单元
。每个文件都提供不同的接口,因为它们各自与systemd
管理的某个实体相关。文件内的脚本描述了设置了哪些选项以及给定服务做了什么。单元
的名称是明了的。.timer
用于计时器管理,.service
用于说明给定服务将如何启动及其依赖项,.path
描述了给定服务的基于路径的激活,等等。
让我们创建一个简单的systemd
服务,其目的是监控某个文件是否被修改。一个例子是监控一些配置:我们不想限制其更新文件的权限,但我们仍然想知道是否有人更改了它。
首先,让我们通过一个简单的文本编辑器创建一些虚拟文件。让我们假设它是一个真实的配置。打印出来如下:
$ cat /etc/test_config/config
test test
让我们准备一个脚本,描述当文件被更改时我们需要执行的程序。再次,仅仅为了这个例子,让我们通过一个简单的文本编辑器创建它 - 它会是这样的:
$ cat ~/sniff_printer.sh
echo "File /etc/test_config/config changed!"
当调用脚本时,会有一个消息显示文件已更改。当然,你可以在这里放置任何程序。让我们称它为sniff_printer
,因为我们正在通过服务嗅探文件变化,并将打印一些数据。
那么这是怎么发生的呢?首先,我们通过所需的单元
- myservice_test.service
- 定义我们的新服务,实现以下脚本:
[Unit]
Description=This service is triggered through a file change
[Service]
Type=oneshot
ExecStart=bash /home/oem/sniff_printer.sh
[Install]
WantedBy=multi-user.target
其次,我们通过另一个名为myservice_test.path
的单元
描述我们正在监控的文件路径,通过以下代码实现:
[Unit]
Description=Path unit for watching for changes in "config"
[Path]
PathModified=/etc/test_config/config
Unit=myservice_test.service
[Install]
WantedBy=multi-user.target
将所有这些部分组合在一起,我们得到了一个服务,它将打印出一个简单的消息。每当提供的文件被更新时,就会触发它。让我们看看效果如何。由于我们正在向服务目录添加一个新文件,我们必须执行重载:
$ systemctl daemon-reload
现在,让我们启用并启动服务:
$ systemctl enable myservice_test
$ systemctl start myservice_test
我们需要通过一些文本编辑器更新文件,例如以下:
$ vim /etc/test_config/config
为了看到我们触发的效果,我们必须查看服务状态:
$ systemctl status myservice_test
● myservice_test.service - This service is for printing the "config".
Loaded: loaded (/etc/systemd/system/myservice_test.service; enabled; vendor preset: enabled)
Active: inactive (dead) since Thu 2023-04-06 15:37:12 EEST; 31s ago
Process: 5340 ExecStart=/bin/bash /home/oem/sniff_printer.sh (code=exited, status=0/SUCCESS)
Main PID: 5340 (code=exited, status=0/SUCCESS)
Apr 06 15:37:12 oem-virtual-machine systemd[1]: Starting This service is for printing the "config"....
Apr 06 15:37:12 oem-virtual-machine bash[5340]: File /etc/test_config/config changed!
Apr 06 15:37:12 oem-virtual-machine systemd[1]: myservice_test.service: Succeeded.
Apr 06 15:37:12 oem-virtual-machine systemd[1]: Finished This service is for printing the "config"..
你可以验证服务已被触发,因为我们的消息出现了:
Apr 06 15:37:12 oem-virtual-machine bash[5340]: File /etc/test_config/config changed!
我们还看到了执行的代码及其成功状态:
Process: 5340 ExecStart=/bin/bash /home/oem/sniff_printer.sh (code=exited, status=0/SUCCESS)
Main PID: 5340 (code=exited, status=0/SUCCESS)
但是进程不再活动,因为服务单元是oneshot
类型的,因此只有另一个文件更新才会重新触发它。我们相信这个例子提供了一个简单的解释,说明如何在系统运行时创建并启动一个守护进程。随意尝试并尝试不同的单元类型或选项。
进程守护程序和启动程序是系统管理、编程、监控和获取执行流程信息的一个大领域。这些主题以及下一节的主题都值得单独编写书籍。
可移植操作系统接口(POSIX)
POSIX标准的主要任务是维持不同操作系统之间的兼容性。因此,POSIX经常被标准应用软件开发者和系统程序员使用。如今,它不仅可以在类Unix操作系统上找到,也可以在Windows环境中找到 - 例如,Cygwin、MinGW和Windows子系统Linux(WSL)。POSIX定义了系统级和用户级的API,但有一点要注意:使用POSIX时,程序员不需要区分系统调用和库函数。
POSIX API经常在C编程语言中使用。因此,它可以与C++兼容。在系统编程的几个重要领域,向系统调用接口提供了额外的功能:文件操作、内存管理、进程和线程控制、网络和通信以及正则表达式 - 如你所见,它几乎涵盖了已有的系统调用所做的一切。只是不要混淆,认为这总是如此。
就像每一个标准一样,POSIX有多个版本,你必须知道你的系统中存在哪一个。它也可能是某些环境子系统的一部分,比如Microsoft POSIX子系统适用于Windows。这是一个关键的备注,因为环境本身可能不会向你公开全部接口。一个可能的原因是系统的安全评估。
随着POSIX的发展,已经建立了代码质量的规则。其中一些与多线程内存访问、同步机制和并发执行、安全和访问限制以及类型安全相关。POSIX软件要求中的一个著名概念是一次编写,到处适用。
标准定义并针对其应用的四个主要领域,称为卷:
- 基本定义:规范的主要定义:语法、概念、术语和服务操作
- 系统接口:接口描述和定义的可用性
- 实用程序:Shell、命令和实用程序描述
- 基本原理:版本信息和历史数据
尽管如此,在这本书中,我们主要关注POSIX作为系统调用的不同方法。在接下来的章节中,我们将看到使用对象(如消息队列、信号量、共享内存或线程)的通用模式的好处。一个显著的改进是函数调用的简单性及其命名约定。例如,shm_open()
、mq_open()
和sem_open()
分别用于创建和打开共享内存对象、消息队列和信号量。它们的相似性是显而易见的。POSIX中的类似思想受到系统程序员的欢迎。API也是公开的,并且有大量社区贡献。此外,POSIX提供了对诸如互斥锁之类的对象的接口,这在Unix上并不是容易找到和使用的。然而,在后面的章节中,我们将建议读者更多地关注C++20的特性,并且有充分的理由,所以请耐心等待。
使用POSIX允许软件工程师将他们与操作系统相关的代码泛化,并声明它们为非特定操作系统。这允许软件更容易和更快地重新集成,从而缩短上市时间。系统程序员也可以在保持编写相同类型代码的同时,轻松地从一个系统切换到另一个系统。
总结
在本章中,我们覆盖了与操作系统相关的基本概念的定义。您已经了解了Linux的主要内核结构及其对软件设计的期望。简要介绍了实时操作系统,并且我们也覆盖了系统调用、系统调用接口和POSIX的定义。我们还奠定了多处理和多线程的基础。在下一章,我们将讨论进程作为主要资源的使用者和管理者。我们将从一些C++20代码开始。通过这些内容,您将了解Linux的进程内存布局,操作系统的进程调度机制,以及多处理的操作方式及其带来的挑战。您还将了解关于原子操作的一些有趣事实。