Linux-系统编程技巧-全-

Linux 系统编程技巧(全)

原文:zh.annas-archive.org/md5/450F8760AE780F24827DDA7979D9DDE8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Linux 系统编程就是为 Linux 操作系统开发系统程序。Linux 是世界上最流行的开源操作系统,可以运行在从大型服务器到小型物联网设备的各种设备上。了解如何为 Linux 编写系统程序将使您能够扩展操作系统并将其与其他程序和系统连接起来。

我们将从学习如何使我们的程序易于脚本化和易于与其他程序交互开始。当我们为 Linux 编写系统程序时,我们应该始终努力使它们小巧并且只做一件事,并且做得很好。这是 Linux 中的一个关键概念:创建可以以简单方式相互交换数据的小程序。

随着我们的进展,我们将深入研究 C 语言,了解编译器的工作原理,链接器的功能,如何编写 Makefile 等等。

然后,我们将学习关于 forking 和守护进程的所有知识。我们还将创建自己的守护进程,并将其置于 systemd 的控制之下。这将使我们能够使用内置的 Linux 工具启动、停止和重新启动守护进程。

我们还将学习如何使用不同类型的进程间通信(IPC)使我们的进程交换信息。我们还将学习如何编写多线程程序。

在本书的结尾,我们将介绍如何使用 GNU 调试器(GDB)和 Valgrind 调试我们的程序。

在本书结束时,您将能够为 Linux 编写各种系统程序,从过滤器到守护程序。

本书适合对象

本书适用于任何希望为 Linux 开发系统程序并深入了解 Linux 系统的人。任何面临与 Linux 系统编程特定部分相关的问题并寻找特定的解决方案的人都可以从本书中受益。

本书内容

[第一章]《获取必要的工具并编写我们的第一个 Linux 程序》向您展示了如何安装本书中需要的工具。本章还介绍了我们的第一个程序。

[第二章]《使您的程序易于脚本化》介绍了我们应该如何以及为什么要使我们的程序易于脚本化,并且易于其他系统上的程序使用。

[第三章]《深入了解 Linux 中的 C 语言》带领我们深入了解 Linux 中 C 编程的内部工作原理。我们将学习如何使用系统调用,编译器的工作原理,使用 Make 工具,指定不同的 C 标准等。

[第四章]《处理程序中的错误》教会我们如何优雅地处理错误。

[第五章]《使用文件 I/O 和文件系统操作》介绍了如何使用文件描述符和流读写文件。本章还介绍了如何创建和删除文件以及使用系统调用读取文件权限。

[第六章]《生成进程并使用作业控制》介绍了 forking 的工作原理,如何创建守护进程,父进程是什么,以及如何将作业发送到后台和前台。

[第七章]《使用 systemd 处理您的守护程序》向我们展示了如何将上一章中的守护程序置于 systemd 的控制之下。本章还教会我们如何将日志写入 systemd 的日志记录中,并且如何读取这些日志。

[第八章]《创建共享库》教会我们什么是共享库,它们为何重要,以及如何制作我们自己的共享库。

第九章终端 I/O 和更改终端行为,介绍了如何以不同方式修改终端,例如如何禁用密码提示的回显。

第十章使用不同类型的 IPC,介绍了 IPC,即如何使进程在系统上相互通信。本章涵盖了 FIFO、Unix 套接字、消息队列、管道和共享内存。

第十一章在程序中使用线程,解释了线程是什么,如何编写多线程程序,如何避免竞争条件,以及如何优化多线程程序。

第十二章调试您的程序,介绍了使用 GDB 和 Valgrind 进行调试。

充分利用本书

要充分利用本书,您需要对 Linux 有基本的了解,熟悉一些基本命令,熟悉在文件系统中移动和安装新程序。最好还具备对编程的基本了解,最好是 C 语言。

您需要一台具有 root 访问权限的 Linux 计算机,可以通过 su 或 sudo 完成所有的操作。您还需要安装 GCC 编译器、Make 工具、GDB、Valgrind 和其他一些较小的工具。具体的 Linux 发行版并不那么重要。本书中有这些程序的 Debian、Ubuntu、CentOS、Fedora 和 Red Hat 的安装说明。

如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制和粘贴代码相关的潜在错误。

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件,链接为github.com/PacktPublishing/Linux-System-Programming-Techniques。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!

代码演示

本书的代码演示视频可在 https://bit.ly/39ovGd6 上观看。

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789951288_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码词、目录、文件名、文件扩展名、路径名、虚拟 URL、用户输入等。以下是一个示例:“将libprime.so.1文件复制到/usr/local/lib。”

代码块设置如下:

#include <stdio.h>
int main(void)
{
    printf("Hello, world!\n");
    return 0;
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

#include <stdio.h>
int main(void)
{
    printf("Hello, world!\n");
    return 0;
}

任何命令行输入或输出都以如下形式书写:

$> mkdir cube
$> cd cube

在编号列表中,命令行输入以粗体显示。$>字符表示提示符,不是您应该写的内容。

  1. 这是一个带编号的列表示例:
\ character. This is the same character as you use to break long lines in the Linux shell. The line under it has a > character to indicate that the line is a continuation of the previous line. The > character is *not* something you should write; the Linux shell will automatically put this character on a new line where the last line was broken up with a \ character. For example:

$> ./exist.sh /asdf &> /dev/null; \

如果[ $? -eq 3 ]; then echo "不存在"; fi

不存在


Key combinations are written in italics. Here is an example: "Press *Ctrl* + *C* to exit the program."`customercare@packtpub.com`.`copyright@packt.com` with a link to the material.**If you are interested in becoming an author**: If there is a topic that you have expertise in and you are interested in either writing or contributing to a book, please visit [authors.packtpub.com](http://authors.packtpub.com).ReviewsPlease leave a review. Once you have read and used this book, why not leave a review on the site that you purchased it from? Potential readers can then see and use your unbiased opinion to make purchase decisions, we at Packt can understand what you think about our products, and our authors can see your feedback on their book. Thank you!For more information about Packt, please visit [packt.com](http://packt.com).

第一章:获取必要的工具并编写我们的第一个 Linux 程序

在本章中,我们将在我们的 Linux 系统上安装必要的工具,如 GCC,GNU Make,GDB 和 Valgrind。我们还将尝试它们,并看看它们是如何工作的。知道如何使用这些工具是成为快速高效的开发人员的关键。然后,我们将编写我们的第一个程序——Linux 风格。通过了解 C 程序的不同部分,您可以以最佳实践方式轻松地与系统的其余部分进行交互。之后,我们将学习如何使用内置手册页(简称 man 页)查找命令、库和系统调用——这是我们在整本书中需要大量使用的技能。知道如何在相关的内置手册页中查找信息通常比在互联网上搜索答案更快、更精确。

在本章中,我们将涵盖以下内容:

  • 安装 GCC 和 GNU Make

  • 安装 GDB 和 Valgrind

  • 为 Linux 编写一个简单的 C 程序

  • 编写一个解析命令行选项的程序

  • 在内置手册页中查找信息

  • 搜索手册以获取信息

让我们开始吧!

技术要求

本章,您需要一台已经设置好 Linux 的计算机。无论是本地机器还是远程机器都没关系。您使用的特定发行版也不太重要。我们将看看如何在基于 Debian 的发行版以及基于 Fedora 的发行版中安装必要的程序。大多数主要的 Linux 发行版要么是基于 Debian 的,要么是基于 Fedora 的。

您还将使用vinano,它们几乎在任何地方都可以使用。不过在本书中我们不会介绍如何使用文本编辑器。

本章的 C 文件可以从github.com/PacktPublishing/Linux-System-Programming-Techniquesch1目录中下载。GitHub 上的文件名对应本书中的文件名。

您还可以将整个存储库克隆到您的计算机上。本章的文件位于ch1目录中。您可以使用以下命令克隆存储库:

$> git clone https://github.com/PacktPublishing/Linux-System-Programming-Techniques.git

如果您的计算机上没有安装 Git,您将需要根据您的发行版遵循一些安装说明。

查看以下链接以查看“代码实战”视频:bit.ly/3wdEoV6

安装 Git 以下载代码存储库

只有在您想要克隆(下载)整个代码存储库到您的计算机上时,才需要安装 Git。这里列出的步骤假定您的用户具有sudo权限。如果不是这种情况,您可以首先运行su切换到 root 用户,然后跳过sudo(假设您知道 root 密码)。

基于 Debian 的发行版

这些说明适用于大多数基于 Debian 的发行版,如 Ubuntu:

  1. 首先,更新存储库缓存:
$> sudo apt update
  1. 然后,使用apt安装 Git:
$> sudo apt install git

基于 Fedora 的发行版

这些说明适用于所有较新的基于 Fedora 的发行版,如 CentOS 和 Red Hat(如果您使用的是旧版本,您可能需要用yum替换dnf):

  • 使用dnf安装 Git 包:
$> sudo dnf install git

安装 GCC 和 GNU Make

在本节中,我们将安装本书中将需要的基本工具;即,编译器 GCC。它是将 C 源代码转换为可以在系统上运行的二进制程序的编译器。我们编写的所有 C 代码都需要编译。

我们还将安装 GNU Make,这是一个我们以后将用来自动化包含多个源文件的项目编译的工具。

做好准备

由于我们要在系统上安装软件,我们需要使用sudo权限。在本教程中,我将使用sudo,但如果您在没有sudo的系统上,您可以在输入命令之前切换到 root 用户使用su(然后省略sudo)。

如何做…

我们将安装一个称为元包或组的软件包,该软件包包含其他软件包的集合。这个元包包括 GCC、GNU Make、几个手册页面和其他在开发时很有用的程序和库。

基于 Debian 的系统

这些步骤适用于所有基于 Debian 的系统,如 Debian、UbuntuLinux Mint

  1. 更新存储库缓存,以在下一步中获取最新版本:
$> sudo apt-get update
  1. 安装build-essential软件包,并在提示时回答y
$> sudo apt-get install build-essential

基于 Fedora 的系统

这对所有基于 Fedora 的系统都适用,如 Fedora、CentOSRed Hat

  • 安装一个名为Development Tools的软件组:
$> sudo dnf group install 'Development Tools'

验证安装(Debian 和 Fedora)

这些步骤对 Debian 和 Fedora 都适用:

  1. 通过列出安装的版本来验证安装。请注意,确切的版本会因系统而异;这是正常的:
$> gcc --version
gcc (Debian 8.3.0-6) 8.3.0
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$> make --version
GNU Make 4.2.1
Built for x86_64-pc-linux-gnu
Copyright (C) 1988-2016 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later http://gnu.org/licenses/gpl.html
This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law.
  1. 现在,是时候尝试使用 GCC 编译器编译一个最小的 C 程序了。请将源代码输入编辑器并保存为first-example.c。该程序将在终端上打印文本“Hello, world!”:
#include <stdio.h>
int main(void)
{
    printf("Hello, world!\n");
    return 0;
}
  1. 现在,使用 GCC 编译它。这个命令会产生一个名为a.out的文件:
$> gcc first-example.c
  1. 现在,让我们尝试运行程序。在 Linux 中运行不在通常的二进制目录(/bin/sbin/usr/bin等)中的程序时,您需要在文件名前输入特殊的./序列。这会从当前路径执行程序:
$> ./a.out
Hello, world!
  1. 现在,重新编译程序。这次,我们将使用-o选项(-o代表output)指定程序的名称。这次,程序文件将被命名为first-example
$> gcc first-example.c -o first-example
  1. 让我们重新运行程序,这次使用新名称first-example
$> ./first-example
Hello world!
  1. 现在,让我们尝试使用 Make 来编译它:
$> rm first-example
$> make first-example
cc     first-example.c   -o first-example
  1. 最后,重新运行程序:
$> ./first-example
Hello, world!

工作原理…

在系统上安装软件始终需要 root 权限,可以通过常规 root 用户或sudo来获取。例如,Ubuntu 使用sudo,并禁用了常规 root 用户。另一方面,Debian 在默认安装中根本不使用sudo。要使用它,您必须自己设置。

Debian 和 Ubuntu 在安装软件包之前使用apt-get update命令。

基于 Fedora 的系统在较新版本上使用dnf。如果您使用的是旧版本,可能需要用yum替换dnf

在这两种情况下,我们安装了一组包,其中包含我们在本书中将需要的实用程序、手册页面和编译器。

安装完成后,在尝试编译任何内容之前,我们列出了 GCC 版本和 Make 版本。

最后,我们编译了一个简单的 C 程序,首先直接使用 GCC,然后使用 Make。使用 GCC 的第一个示例生成了一个名为a.out的程序,它代表assembler output。这个名字有着悠久的历史,可以追溯到 1971 年 Unix 的第一版。尽管文件格式a.out不再使用,但这个名字今天仍然存在。

然后,我们使用-o选项指定了程序名称,其中-o代表output。这将生成一个我们选择的程序名称。我们给程序取名为first-example

使用 Make 时,我们不需要输入源代码的文件名。我们只需写出编译器生成的二进制程序的名称。Make 程序足够聪明,可以推断出源代码具有相同的文件名,但以.c结尾。

当我们执行程序时,我们将其作为./first-example运行。./序列告诉 shell 我们要从当前目录运行程序。如果我们省略./,它将不会使用$PATH变量——通常是/bin/usr/bin/sbin/usr/sbin

安装 GDB 和 Valgrind

GDB 和 Valgrind 是两个有用的调试工具,我们将在本书中稍后使用。

GDB 是 GNU 调试器,这是一个我们可以用来逐步执行程序并查看其内部发生情况的工具。我们可以监视变量,查看它们在运行时如何变化,设置我们希望程序暂停的断点,甚至更改变量。错误是不可避免的,但是有了 GDB,我们可以找到这些错误。

Valgrind 也是一个我们可以用来查找错误的工具,尽管它是专门用于查找内存泄漏的。没有像 Valgrind 这样的程序,内存泄漏可能很难找到。您的程序可能在几周内按预期工作,但突然之间,事情可能开始出错。这可能是内存泄漏。

知道如何使用这些工具将使您成为更好的开发人员,使您的程序更加安全。

准备工作

由于我们将在这里安装软件,所以我们需要以 root 权限执行这些命令。如果我们的系统有传统的 root 用户,我们可以通过切换到 root 用户来使用su。如果我们在一个具有sudo的系统上,并且我们的常规用户具有管理权限,您可以使用sudo来执行这些命令。在这里,我将使用sudo

如何做…

如果您使用的是 Debian 或 Ubuntu,您将需要使用apt-get工具。另一方面,如果您使用的是基于 Fedora 的发行版,您将需要使用dnf工具。

基于 Debian 的系统

这些步骤适用于 Debian、Ubuntu 和 Linux Mint:

  1. 在安装软件之前更新存储库缓存:
$> sudo apt-get update
  1. 使用apt-get安装 GDB 和 Valgrind。在提示时回答y
$> sudo apt-get install gdb valgrind

基于 Fedora 的系统

这一步适用于所有基于 Fedora 的系统,如 CentOS 和 Red Hat。如果您使用的是较旧的系统,您可能需要用yum替换dnf

  • 使用dnf安装 GDB 和 Valgrind。在提示时回答y
$> sudo dnf install gdb valgrind

验证安装

这一步对于基于 Debian 和基于 Fedora 的系统是相同的:

  • 验证 GDB 和 Valgrind 的安装:
$> gdb --version
GNU gdb (Debian 8.2.1-2+b3) 8.2.1
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later http://gnu.org/licenses/gpl.html
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
$> valgrind --version
valgrind-3.14.0

它是如何工作的…

GDB 和 Valgrind 是两个调试工具,它们不包括在我们在上一个教程中安装的组包中。这就是为什么我们需要将它们作为单独的步骤安装。在基于 Debian 的发行版上安装软件的工具是apt-get,而在 Fedora 上是dnf。由于我们正在系统上安装软件,所以我们需要以 root 权限执行这些命令。这就是为什么我们需要使用sudo。请记住,如果您的用户或系统不使用sudo,您可以使用su来成为 root。

最后,我们通过列出安装的版本来验证了安装。确切的版本可能因系统而异。

版本不同的原因是每个 Linux 发行版都有自己的软件仓库,并且每个 Linux 发行版都维护自己的软件版本作为“最新版本”。这意味着特定 Linux 发行版中程序的最新版本不一定是该程序的最新版本。

为 Linux 编写一个简单的 C 程序

在这个教程中,我们将构建一个小的 C 程序,该程序将对作为参数传递给程序的值进行求和。C 程序将包含一些我们在 Linux 编程时需要了解的基本元素。这些元素包括返回值、参数和帮助文本。随着我们在这本书中的进展,这些元素将一次又一次地出现,还会有一些我们在学习过程中会了解到的新元素。

掌握这些元素是为 Linux 编写优秀软件的第一步。

准备工作

这个教程唯一需要的是 C 源代码sum.c和 GCC 编译器。您可以选择自己输入代码,也可以从 GitHub 上下载。自己输入代码可以让您学会如何编写代码。

如何做…

按照以下步骤在 Linux 中编写您的第一个程序:

  1. 打开一个文本编辑器,输入以下代码,将文件命名为sum.c。该程序将对输入到程序中的所有数字进行求和。程序的参数包含在argv数组中。要将参数转换为整数,我们可以使用atoi()函数:
#include <stdio.h>
#include <stdlib.h>
void printhelp(char progname[]);
int main(int argc, char *argv[])
{
    int i;
    int sum = 0;
    /* Simple sanity check */
    if (argc == 1)
    {
        printhelp(argv[0]);
        return 1;
    }
    for (i=1; i<argc; i++)
    {
        sum = sum + atoi(argv[i]);
    }
    printf("Total sum: %i\n", sum);
    return 0;
}
void printhelp(char progname[])
{
    printf("%s integer ...\n", progname);
    printf("This program takes any number of " 
        "integer values and sums them up\n");
}
  1. 现在,是时候使用 GCC 编译源代码了:
$> gcc sum.c -o sum
  1. 运行程序。不要忘记在文件名前加上./
$> ./sum
./sum integer …
This program takes any number of integer values and sums them up
  1. 现在,让我们在做其他事情之前检查程序的退出码
$> echo $?
1
  1. 让我们重新运行程序,这次使用一些整数,让程序为我们求和:
$> ./sum 45 55 12
Total sum: 112
  1. 再次检查程序的退出码:
$> echo $?
0

它是如何工作的...

让我们从探索代码的基础知识开始,以便我们理解不同部分的作用以及它们为什么重要。

源代码

首先,我们包含了一个stdio.h。这个文件是printf()所需要的。stdio代表printf()在屏幕上打印字符,它被归类为stdio函数。

我们包含的另一个头文件是stdlib.h,它代表atoi()函数,我们可以用它将字符串字符转换为整数。

在此之后,我们有一个printhelp()。关于这个函数没有什么特别要说的;在 C 语言中,将main()和函数原型放在最开始是一个很好的实践。函数原型告诉程序的其余部分函数需要什么参数,以及它返回什么类型的值。

然后,我们声明了main() int main(int argc, char *argv[])

两个变量argcargv有特殊的含义。第一个argc是一个整数,包含了传递给程序的参数数量。它至少为 1,即使没有参数传递给程序;第一个参数是程序本身的名称。

下一个变量是argv,它包含了传递给程序的所有参数,argv[0]包含了程序的名称,也就是执行程序时的命令行。如果程序被执行为./sum,那么argv[0]包含字符串./sum。如果程序被执行为/home/jack/sum,那么argv[0]包含字符串/home/jack/sum

正是这个参数,或者说是程序名称,我们将其传递给printhelp()函数,以便它打印程序的名称和帮助文本。在 Linux 和 Unix 环境中这样做是一个很好的实践。

在那之后,我们执行了一个我们构建的简单的printhelp()函数。紧接着,我们用代码 1 从main()return,告诉main()使用return,将代码发送到 shell 并退出程序。这些代码有特殊的含义,我们将在本书的后面更深入地探讨。简单来说,0 表示一切正常,而非 0 表示错误代码。在 Linux 中使用返回值是必须的;这是其他程序和 shell 得知执行情况的方式。

稍微往下一点,我们有for() argc来遍历参数列表。我们从i=1开始。我们不能从 0 开始,因为argv[]数组中的索引 0 是程序名称。索引 1 是第一个参数;也就是我们可以传递给程序的整数。

for()循环中,我们有sum = sum + atoi(argv[i]);。这里我们要重点关注的是atoi(argv[i])。我们通过命令行传递给程序的所有参数都被作为字符串传递。为了能够对它们进行计算,我们需要将它们转换为整数,而atoi()函数就是为我们做这个工作的。atoi()的名称代表to integer

一旦结果被用printf()打印到屏幕上,我们用 0 从main()return,表示一切正常。当我们从main()返回时,我们从整个进程返回到 shell;换句话说,是父进程

执行和返回值

当我们在$PATH环境变量中未提及的目录中执行程序时,需要在文件名前加上./

当程序结束时,它将返回值传递给 shell,shell 将其保存到一个名为?的变量中。当另一个程序结束时,该变量将被该程序的最新返回值覆盖。我们打印echo的值,这是一个从 shell 直接在屏幕上打印文本和变量的小型实用程序。要打印环境变量,我们需要在变量名前加上$符号,例如$?

还有更多…

还有另外三个类似于atoi()的函数,分别是atol()atoll()atof()。以下是它们的简短描述:

  • atoi()将一个字符串转换为整型。

  • atol()将一个字符串转换为长整型。

  • atoll()将一个字符串转换为长长整型。

  • atof()将一个字符串转换为浮点数(双精度类型)。

如果你想探索其他程序的返回值,你可以执行诸如ls之类的程序,指定一个存在的目录,并用echo $?打印变量。然后,你可以尝试列出一个不存在的目录,并再次打印$?的值。

提示

在本章中,我们已经多次提到了$PATH环境变量。如果你想知道该变量包含什么,你可以用echo $PATH打印它。如果你想临时向$PATH变量添加一个新目录,比如/home/jack/bin,你可以执行PATH=${PATH}:/home/jack/bin命令。

编写解析命令行选项的程序

在这个示例中,我们将创建一个更高级的程序——一个解析命令行argcargv的程序。我们也会在这里使用这些变量,但是用于选项。选项是带有连字符的字母,比如-a-v

这个程序与上一个程序类似,不同之处在于这个程序可以同时进行加法和乘法运算;-s代表“求和”,-m代表“乘法”。

在 Linux 中,几乎所有的程序都有不同的选项。了解如何解析程序的选项是必须的;这是用户改变程序行为的方式。

准备工作

你需要的只是一个文本编辑器、GCC 编译器和 Make。

操作步骤…

由于这个源代码会比较长,它将被分成三部分。但整个代码都放在同一个文件中。完整的程序可以从 GitHub 上下载:github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch1/new-sum.c。让我们开始吧:

  1. 打开一个文本编辑器,输入以下代码,并将其命名为new-sum.c。这一部分与上一个示例非常相似,只是有一些额外的变量和顶部的
#define _XOPEN_SOURCE 500
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void printhelp(char progname[]);
int main(int argc, char *argv[])
{
    int i, opt, sum;
    /* Simple sanity check */
    if (argc == 1)
    {
        printhelp(argv[0]);
        return 1;
    }
  1. 现在,继续在同一个文件中输入。这部分是用于解析命令行选项、进行计算和打印结果。我们使用getopt()switch语句来解析选项。注意,这次我们还可以进行乘法运算:
    /* Parse command-line options */
    while ((opt = getopt(argc, argv, "smh")) != -1)
    {
        switch (opt)
        {
           case 's': /* sum the integers */
               sum = 0;
               for (i=2; i<argc; i++)
                   sum = sum + atoi(argv[i]);
               break;
           case 'm': /* multiply the integers */
               sum = 1;
               for (i=2; i<argc; i++)
                   sum = sum * atoi(argv[i]);
               break;
           case 'h': /* -h for help */
               printhelp(argv[0]);
               return 0;
           default: /* in case of invalid options*/
               printhelp(argv[0]);
               return 1;
        }
    }
    printf("Total: %i\n", sum);
    return 0;
}
  1. 最后,在同一个文件中,将printhelp()函数添加到底部。这个函数打印一个帮助消息,有时被称为用法消息。当用户使用-h选项或发生某种形式的错误时,例如没有给出参数时,将显示此消息:
void printhelp(char progname[])
{
    printf("%s [-s] [-m] integer ...\n", progname);
    printf("-s sums all the integers\n"
        "-m multiplies all the integers\n"
        "This program takes any number of integer "
        "values and either add or multiply them.\n"
        "For example: %s -m 5 5 5\n", progname);
} 
  1. 保存并关闭文件。

  2. 现在,是时候编译程序了。这次,我们将尝试使用 Make:

$> make new-sum
cc     new-sum.c   -o new-sum
  1. 测试程序:
$> ./new-sum
./new-sum [-s] [-m] integer ...
-s sums all the integers
-m multiplies all the integers
This program takes any number of integer values and either add or multiply them.
For example: ./new-sum -m 5 5 5
$> ./new-sum -s 5 5 5
Total: 15
$> ./new-sum -m 5 5 5
Total: 125

工作原理…

第一部分与上一个示例非常相似,只是我们有一些更多的变量unistd.h,这是getopt()函数所需的,我们用它来解析程序的选项。

还有一个新的看起来有点奇怪的部分;那就是第一行:

#define _XOPEN_SOURCE 500

我们将在本书的后面详细介绍这个。但现在,只需知道它是一个特性宏,我们用它来遵循getopt(),我们将在下一个食谱中详细介绍。

getopt()函数

这个食谱的下一步——第二步——是令人兴奋的部分。在这里,我们使用getopt()函数解析选项,该函数代表获取选项

使用getopt()的方法是在while循环中循环遍历参数,并使用switch语句捕获选项。让我们更仔细地看看while循环,并将其分解成较小的部分:

while ((opt = getopt(argc, argv, "smh")) != -1)

getopt()函数返回它解析的选项的实际字母。这意味着第一部分opt = getopt将选项保存到opt变量中,但只保存实际的字母。因此,例如,-h保存为h

然后,我们有必须传递给getopt()函数的参数,即argc(参数计数)、argv(实际参数)和最后应该接受的选项(这里是smh,它被翻译为-s-m-h)。

最后一部分!= -1是为了while循环。当getopt()没有更多选项返回时,它返回-1,表示它已经完成了选项解析。这时while循环应该结束。

在 while 循环内

在循环内,我们使用switch语句对每个选项执行特定的操作。在每个case下,我们执行计算并在完成后break出该case。就像在上一个食谱中一样,我们使用atoi()将参数字符串转换为整数。

h情况下(帮助的-h选项),我们打印帮助消息并返回代码 0。我们请求帮助,因此这不是一个错误。但在此之下,我们有默认情况,即如果没有其他选项匹配,则捕获的情况;也就是说,用户输入了一个不被接受的选项。这确实是一个错误,所以在这里,我们返回代码 1,表示错误。

帮助消息函数

帮助消息应该显示程序接受的各种选项、其参数和一个简单的使用示例。

使用printf(),我们可以在代码中将长行分成多个较小的行,就像我们在这里做的一样。独特的字符序列\n是一个换行字符。行将在放置这个字符的地方断开。

编译和运行程序

在这个食谱中,我们使用 Make 编译了程序。Make 实用程序又使用cc,它只是gcc的符号链接。本书后面,我们将学习如何通过在 Makefile 中编写规则来改变 Make 的行为。

然后我们尝试了这个程序。首先,我们没有使用任何选项或参数来运行它,导致程序退出并显示帮助文本(返回值为 1)。

然后我们尝试了两个选项:-s用于总结所有整数和-m用于将所有整数相乘。

在内置手册页中查找信息

在这个食谱中,我们将学习如何在内置手册页中查找信息。我们将学习如何查找从命令、系统调用到标准库函数的所有内容。一旦习惯使用它们,手册页就非常强大。与在互联网上搜索答案相比,查看手册通常更快、更准确。

准备工作

一些手册页(库调用和系统调用)作为 Debian 和 Ubuntu 的build-essential软件包的一部分安装。在基于 Fedora 的发行版(如 CentOS)中,这些通常已经作为man pages软件包的一部分安装在基本系统中。如果您缺少一些手册页,请确保已安装这些软件包。查看本章中的第一个食谱,了解更多关于安装软件包的信息。

如果你使用的是最小化或精简安装,可能没有安装man命令。如果是这种情况,你需要使用发行版包管理器安装两个软件包。软件包名称为man-db(几乎所有发行版都是一样的)和manpages(在基于 Debian 的系统中),或者man-pages(在基于 Fedora 的系统中)用于实际的手册页。在基于 Debian 的系统中,你还需要安装build-essential软件包。

操作方法...

让我们逐步探索手册页,如下所示:

  1. 在控制台中键入man ls。你会看到ls命令的手册页。

  2. 使用箭头键或Enter键逐行滚动手册页。

  3. 按下空格键以一次滚动一个完整页面(窗口)。

  4. 按下字母b来向上滚动整个页面。一直按b直到达到顶部。

  5. 现在,按/打开搜索提示。

  6. 在搜索提示中键入human-readable,然后按Enter。手册页现在会自动向前滚动到该单词的第一次出现。

  7. 现在你可以按n键跳转到下一个单词的出现位置 - 如果有的话。

  8. q退出手册。

调查不同的部分

有时,同名但在不同部分的手册页可能有多个。在这里,我们将调查这些部分,并学习如何指定我们感兴趣的部分:

  1. 在命令提示符中键入man printf。你将看到printf命令的手册页,而不是同名的 C 函数。

  2. q退出手册。

  3. 现在,在控制台中键入man 3 printf。这是printf()C 函数的手册页。3表示手册的第三部分。查看手册页的标题,你会看到你现在所在的部分。此刻应该会显示PRINTF(3)

  4. 让我们列出所有的部分。退出你正在查看的手册页,然后在控制台中键入man man。向下滚动一点,直到找到列出所有部分的表格。在那里,你还会找到每个部分的简短描述。正如你所看到的,第三部分是用于库调用的,这就是printf()所在的部分。

  5. 通过在控制台中键入man 2 unlink来查找unlink()系统调用的手册。

  6. 退出手册页,然后在控制台中键入man unlink。这次,你会看到unlink命令的手册。

工作原理...

手册总是从第一部分开始,并打开它找到的第一个手册。这就是为什么当我们不指定部分号码时,你会得到printfunlink命令,而不是 C 函数和系统调用。查看打开的手册页的标题总是一个好主意,以确保你正在阅读正确的手册页。

还有更多...

从上一个步骤中记住,我“只是知道”当没有更多选项需要解析时,getopt()返回-1?其实不是,这都在手册中有。通过在控制台中键入man 3 getopt来打开getopt()的手册。向下滚动到Return value标题。在那里,你可以阅读有关getopt()返回值的所有信息。几乎所有涵盖库函数和系统调用的手册页都有以下标题:名称、概要、描述、返回值、环境、属性、符合、注释、示例和参见。

概要标题列出了我们需要包含的特定函数的头文件。这真的很有用,因为我们无法记住每个函数及其对应的头文件。

提示

手册中有很多关于手册本身的有用信息 - man man - 所以至少浏览一下。在本书中,我们将经常使用手册页来查找有关库函数和系统调用的信息。

搜索手册以获取信息

如果我们不知道特定命令、函数或系统调用的确切名称,我们可以搜索系统中所有手册以找到正确的。在这个示例中,我们将学习如何使用 apropos 命令搜索手册页。

准备工作

这里适用于前一个示例的相同要求。

操作方法…

让我们搜索不同的单词,每一步都缩小我们的结果:

  1. 输入 apropos directory。会出现一个手册页的长列表。在每个手册页后面,都有括号里的一个数字。这个数字是手册页所在的章节。

  2. 要将搜索范围缩小到只有第三章节(库调用),输入 apropos -s 3 directory

  3. 让我们进一步缩小搜索范围。输入 apropos -s 3 -a remove directory-a 选项代表 and

工作原理…

apropos 命令搜索手册页描述和关键字。当我们用 apropos -s 3 -a remove directory 缩小搜索范围时,-a 选项代表 and,表示 removedirectory 必须同时存在。如果我们省略 -a 选项,它会搜索这两个关键字,而不管它们是否都存在。

关于 apropos 如何工作的更多信息,请参阅其手册页(man apropos)。

更多内容…

如果我们只想知道特定命令或函数的作用,可以使用 whatis 命令查找它的简短描述,如下所示:

$> whatis getopt 
getopt (1)           - parse command options (enhanced) 
getopt (3)           - Parse command-line options
$> whatis creat 
creat (2)            - open and possibly create a file
$> whatis opendir 
opendir (3)          - open a directory

第二章:使您的程序易于脚本化

Linux 和其他Unix系统具有强大的脚本支持。Unix 的整个理念,从一开始就是使系统易于开发。其中一个特性是将一个程序的输出作为另一个程序的输入——因此利用现有程序构建新工具。在为 Linux 创建程序时,我们应该始终牢记这一点。Unix 哲学是制作只做一件事情的小程序——并且做得很好。通过拥有许多只做一件事情的小程序,我们可以自由选择如何组合它们。通过组合小程序,我们可以编写 shell 脚本——这是 Unix 和 Linux 中的常见任务。

本章将教会我们如何制作易于脚本化和易于与其他程序交互的程序。这样,其他人会发现它们更有用。甚至可能会发现我们甚至没有想到的使用我们的程序的新方法,使程序更受欢迎和易于使用。

在本章中,我们将涵盖以下示例:

  • 返回值及其读取方法

  • 使用相关的返回值退出程序

  • 重定向 stdin、stdout 和 stderr

  • 使用管道连接程序

  • 写入 stdout 和 stderr

  • 从 stdin 读取

  • 编写一个友好的管道程序

  • 将结果重定向到文件

  • 读取环境变量

让我们开始吧!

技术要求

本章所需的仅为安装有 GCC 和 Make 的 Linux 计算机,最好是通过第一章中提到的元包或组安装之一安装。最好使用Bash shell以获得最佳兼容性。大多数示例也适用于其他 shell,但不能保证在所有可能的 shell 上都能正常工作。您可以通过在终端中运行echo $SHELL来检查您正在使用哪种 shell。如果您使用的是 Bash,它会显示/bin/bash

您可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch2下载本章的所有代码。

查看以下链接,观看代码演示视频:bit.ly/3u5VItw

返回值及其读取方法

return。这就是我们用来从main()返回值到 shell 的return语句。最初的 Unix 操作系统和 C 编程语言同时出现并且来自同一个地方。C 语言在 20 世纪 70 年代初完成后,Unix 就被重写成了 C。以前,它只是用汇编语言编写的。因此,C 和 Unix 紧密结合在一起。

Linux 中返回值如此重要的原因是我们可以构建 shell 脚本。这些 shell 脚本使用其他程序,希望也包括我们的程序,作为其部分。为了能够检查程序是否成功,shell 脚本会读取该程序的返回值。

在这个示例中,我们将编写一个程序,告诉用户文件或目录是否存在。

准备工作

建议您在此示例中使用 Bash。我不能保证与其他 shell 的兼容性。

如何做…

在这个示例中,我们将编写一个小的shell 脚本,演示返回值的目的,如何读取它们以及如何解释它们。让我们开始吧:

  1. 在编写代码之前,我们必须调查程序使用的返回值,这些返回值将在我们的脚本中使用。执行以下命令,并记录我们得到的返回值。test命令是一个测试特定条件的小型实用程序。在这个示例中,我们将使用它来确定文件或目录是否存在。-e选项代表存在test命令不会给我们任何输出;它只是以一个返回值退出:
$> test -e /
$> echo $?
0
$> test -e /asdfasdf
$> echo $?
1
  1. 现在我们知道test程序给我们什么返回值(文件或目录存在时为 0,否则为 1),我们可以继续编写我们的脚本。在文件中写入以下代码,并将其保存为exist.sh。您还可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/exist.sh下载。这个 shell 脚本使用test命令来确定指定的文件或目录是否存在:
#!/bin/bash 
# Check if the user supplied exactly one argument 
if [ "$#" -ne 1 ]; then 
    echo "You must supply exactly one argument." 
    echo "Example: $0 /etc" 
    exit 1 # Return with value 1 
fi 
# Check if the file/directory exists 
test -e "$1" # Perform the actual test
if [ "$?" -eq 0 ]; then 
    echo "File or directory exists" 
elif [ "$?" -eq 1 ]; then 
    echo "File or directory does not exist" 
    exit 3 # Return with a special code so other
           # programs can use the value to see if a 
           # file dosen't exist
else 
    echo "Unknown return value from test..."
    exit 1 # Unknown error occured, so exit with 1
fi 
exit 0 # If the file or directory exists, we exit 
       # with 
  1. 然后,您需要使用以下命令使其可执行
$> chmod +x exist.sh
  1. 现在,是时候尝试我们的脚本了。我们尝试存在的目录和不存在的目录。我们还在每次运行后检查退出代码:
$> ./exist.sh  
You must supply exactly one argument. 
Example: ./exist.sh /etc 
$> echo $?
1
$> ./exist.sh /etc 
File or directory exists 
$> echo $?
0
$> ./exist.sh /asdfasdf 
File or directory does not exist
$> echo $?
3
  1. 现在我们知道它正在工作并且离开了正确的退出代码,我们可以编写echo来打印一条文本,说明文件或目录是否存在:
$> ./exist.sh / && echo "Nice, that one exists"
File or directory exists
Nice, that one exists
$> ./exist.sh /asdf && echo "Nice, that one exists"
File or directory does not exist
  1. 我们还可以编写一个更复杂的一行命令,利用我们在脚本中分配给“文件未找到”的独特错误代码 3。请注意,您不应在第二行开头键入>。当您用反斜杠结束第一行以指示长行的继续时,shell 会自动插入此字符:
$> ./exist.sh /asdf &> /dev/null; \
> if [ $? -eq 3 ]; then echo "That doesn't exist"; fi
That doesn't exist

工作原理...

test程序是一个小型实用程序,用于测试文件和目录,比较值等。在我们的情况下,我们用它来测试指定的文件或目录是否存在(-e表示存在)。

test程序不会打印任何内容;它只是默默退出。但是,它会留下一个返回值。我们使用$?变量来检查该返回值。这也是我们在脚本的if语句中检查的相同变量。

脚本中还有一些我们使用的特殊变量。第一个是$#,它包含 C 中的argc数量。在脚本的开头,我们比较了$#是否不等于1(-ne表示不等于)。如果$#不等于 1,则打印错误消息,并且脚本以代码 1 中止。

$#放在引号中的原因只是一种安全机制。如果在某种意外事件中,$#包含空格,我们仍希望内容被评估为单个值,而不是两个值。脚本中其他变量周围的引号也是同样的道理。

下一个特殊变量是$0。此变量包含参数 0,即程序的名称,就像我们在第一章中看到的 C 中的argv[0]一样,获取必要的工具并编写我们的第一个 Linux 程序。

程序的第一个参数存储在$1中,就像test案例中所示的那样。在我们的情况下,第一个参数是我们要测试的提供的文件名或目录。

与我们的 C 程序一样,我们希望我们的脚本以相关的返回值退出(或使用exit离开脚本并设置返回值)。如果用户没有提供精确的一个参数,我们以代码 1 退出,这是一个一般的错误代码。如果脚本按预期执行,并且文件或目录存在,我们以代码 0 退出。如果脚本按预期执行,但文件或目录不存在,我们以代码 3 退出,这并没有为特定用途保留,但仍然表示错误(所有非零代码都是错误代码)。这样,其他脚本可以获取我们的脚本的返回值并对其进行操作。

步骤 5中,我们就是这样—使用以下命令对我们的脚本的退出代码进行操作:

$> ./exist.sh / && echo "Nice, that one exists"

&&表示“和”。我们可以将整行读作if语句。如果exist.sh为真,即退出代码为 0,则执行echo命令。如果退出代码不为 0,则echo命令永远不会被执行。

步骤 6中,我们将脚本的所有输出重定向到/dev/null,然后使用完整的if语句来检查错误代码 3。如果遇到错误代码 3,我们使用echo打印一条消息。

还有更多...

我们可以用test程序做更多的测试和比较。它们都列在手册中;也就是说,man 1 test

如果你对 Bash 和 shell 脚本不熟悉,在手册页man 1 bash中有很多有用的信息。

&&的反义是||,发音是“或”。因此,我们在这个示例中所做的相反操作如下:

$> ./exist.sh / || echo "That doesn't exist"
File or directory exists
$> ./exist.sh /asdf || echo "That doesn't exist"
File or directory does not exist
That doesn't exist

另请参阅

如果你想深入了解 Bash 和 shell 脚本的世界,在Linux 文档项目有一个很好的指南:https://tldp.org/LDP/Bash-Beginners-Guide/html/index.html。

使用相关的返回值退出程序

在这个示例中,我们将学习如何使用相关的return退出 C 程序,以及从更广泛的角度来看系统是如何配合的。我们还将学习一些常见的返回值的含义。

准备工作

对于这个示例,我们只需要 GCC 编译器和 Make 工具。

如何做...

我们将在这里编写两个不同版本的程序,以展示两种不同的退出方法。让我们开始吧:

  1. 我们将首先编写使用return的第一个版本,这是我们之前见过的。但这一次,我们将用它从main()和最终functions_ver1.c中返回。以下代码中所有的返回语句都被突出显示:
#include <stdio.h>
int func1(void);
int func2(void);
int main(int argc, char *argv[])
{
   printf("Inside main\n");
   printf("Calling function one\n");
   if (func1())
   {
      printf("Everything ok from function one\n");
      printf("Return with 0 from main - all ok\n");
      return 0;
   }
   else
   {
      printf("Caught an error from function one\n");
      printf("Return with 1 from main - error\n");
      return 1;
   }
   return 0; /* We shouldn't reach this, but 
                just in case */
}
int func1(void)
{
   printf("Inside function one\n");
   printf("Calling function two\n");
   if (func2())
   {
      printf("Everything ok from function two\n");
      return 1;
   }
   else
   {
      printf("Caught an error from function two\n");
      return 0;
   }
}
int func2(void)
{
   printf("Inside function two\n");
   printf("Returning with 0 (error) from "
      "function two\n");
   return 0;
}
  1. 现在,编译它:
$> gcc functions_ver1.c -o functions_ver1
  1. 然后运行它。试着跟着看,看哪些函数调用并返回到哪些其他函数:
$> ./functions-ver1
Inside main 
Calling function one 
Inside function one 
Calling function two 
Inside function two 
Returning with 0 (error) from function two 
Caught an error from function two 
Caught an error from function one 
Return with 1 from main – error
  1. 检查返回值:
$> echo $?
1
  1. 现在,我们将重写前面的程序,使用exit()来代替函数内部的return。那么当exit()被调用时,程序将立即退出。如果exit()在另一个函数中被调用,那个函数将不会首先返回到main()。将以下程序保存在一个新文件中,命名为functions_ver2.c。以下代码中所有的returnexit语句都被突出显示:
#include <stdio.h>
#include <stdlib.h>
int func1(void);
int func2(void);
int main(int argc, char *argv[])
{
   printf("Inside main\n");
   printf("Calling function one\n");
   if (func1())
   {
      printf("Everything ok from function one\n");
      printf("Return with 0 from main - all ok\n");
      return 0;
   }
   else
   {
      printf("Caught an error from funtcion one\n");
      printf("Return with 1 from main - error\n");
      return 1;
   }
   return 0; /* We shouldn't reach this, but just 
                in case */
}
int func1(void)
{
   printf("Inside function one\n");
   printf("Calling function two\n");
   if (func2())
   {
      printf("Everything ok from function two\n");
      exit(0);
   }
   else
   {
      printf("Caught an error from function two\n");
      exit(1);
   }
}
  1. 现在,编译这个版本:
$> gcc functions_ver2.c -o functions_ver2
  1. 然后运行它,看看会发生什么(并比较前一个程序的输出):
$> ./functions_ver2
Inside main
Calling function one
Inside function one
Calling function two
Inside function two
Returning with (error) from function two
  1. 最后,检查返回值:
$> echo $?
1

它是如何工作的...

请注意,在 C 中,0 被视为false或错误,而其他任何值都被视为true(或正确)。这与 shell 的返回值相反。这一点起初可能有点令人困惑。然而,就 shell 而言,0 表示“一切正常”,而其他任何值表示错误。

两个版本之间的区别在于函数和整个程序的返回方式。在第一个版本中,每个函数都返回到调用函数中——按照它们被调用的顺序。在第二个版本中,每个函数都使用exit()函数退出。这意味着程序将直接退出并将指定的值返回给 shell。第二个版本不是一个好的做法;最好是返回到调用函数。如果其他人在另一个程序中使用你的函数,而它突然退出整个程序,那将是一个大惊喜。通常我们不是这样做的。但是,我想在这里演示exit()return之间的区别。

我还想演示另一点。就像函数用return返回到它的调用函数一样,程序也以同样的方式返回到它的父进程(通常是 shell)。因此,在 Linux 中,程序就像是程序中的函数一样对待。

下图显示了 Bash 如何调用程序(上箭头),然后程序在main()中启动,然后调用下一个函数(右箭头),依此类推。返回到左边的箭头显示了每个函数如何返回到调用函数,最终返回到 Bash:

图 2.1 - 调用和返回

还有更多...

我们可以使用更多的返回代码。最常见的是我们在这里看到的0表示ok1表示error。然而,除了0之外的所有其他代码都表示某种形式的错误。代码1是一般错误,而其他错误代码更具体。虽然没有确切的标准,但有一些常用的代码。以下是一些最常见的代码:

图 2.2 - Linux 和其他类 UNIX 系统中的常见错误代码

除了这些代码,还有一些其他代码列在/usr/include/sysexit.h的末尾。该文件中列出的代码范围从6478,涉及数据格式错误、服务不可用、I/O 错误等错误。

重定向 stdin、stdout 和 stderr

在这个食谱中,我们将学习如何将标准输入、标准输出和标准错误重定向到文件中。将数据重定向到文件是 Linux 和其他 Unix 系统的基本原则之一。

stdin标准输入的简写。stdoutstderr分别是标准输出标准错误的简写。

准备工作

最好使用 Bash shell 进行此操作,以确保兼容性。

如何做…

为了熟悉重定向,我们将在这里进行一系列实验。我们将扭转重定向,看到 stdout、stderr 和 stdin 以各种方式运行。让我们开始吧:

  1. 让我们从保存顶级根目录中的文件和目录列表开始。我们可以通过将ls命令的标准输出(stdout)重定向到一个文件中来实现这一点:
$> cd
$> ls / > root-directory.txt
  1. 现在,用cat命令查看文件:
$> cat root-directory.txt
  1. 现在,让我们尝试wc命令来计算行数、单词数和字符数。记得在输入消息后按下Ctrl + D
$> wc
hello,
how are you?
*Ctrl+D*
     2       4      20
  1. 现在我们知道了wc是如何工作的,我们可以重定向它的输入来自一个文件 - 我们用文件列表创建的文件:
$> wc < root-directory.txt
29  29 177
  1. 标准错误呢?标准错误是它自己的输出流,与标准输出分开。如果我们重定向标准输出并生成错误,我们仍然会在屏幕上看到错误消息。让我们试一试:
$> ls /asdfasdf > non-existent.txt
ls: cannot access '/asdfasdf': No such file or directory
  1. 就像标准输出一样,我们也可以重定向标准错误。请注意,这里我们没有收到任何错误消息:
$> ls /asdfasdf 2> errors.txt
  1. 错误消息保存在errors.txt中:
$> cat errors.txt
ls: cannot access '/asdfasdf': No such file or directory
  1. 我们甚至可以同时重定向标准输出和标准错误到不同的文件中:
$> ls /asdfasdf > root-directory.txt 2> errors.txt
  1. 我们还可以将标准输出和错误重定向到同一个文件中,以方便操作:
$> ls /asdfasdf &> all-output.txt
  1. 我们甚至可以同时重定向所有三个(stdin、stdout 和 stderr):
$> wc < all-output.txt > wc-output.txt 2> \
> wc-errors.txt
  1. 我们还可以从 shell 向标准错误写入自己的错误消息:
$> echo hello > /dev/stderr
hello
  1. 从 Bash 中将消息打印到 stderr 的另一种方法是这样的:
$> echo hello 1>&2
hello
  1. 然而,这并没有证明我们的 hello 消息被打印到标准错误。我们可以通过将标准输出重定向到一个文件来证明这一点。如果我们仍然看到错误消息,那么它是打印在标准错误上的。当我们这样做时,我们需要将第一个语句用括号括起来,以便与最后的重定向分开:
$> (echo hello > /dev/stderr) > hello.txt
hello
$> (echo hello 1>&2) > hello.txt
hello
  1. 标准输入、标准输出和标准错误在/dev目录中用文件表示。这意味着我们甚至可以从文件中重定向 stdin。这个实验并没有做任何有用的事情 - 我们本可以只输入wc,但这证明了一个观点:
$> wc < /dev/stdin
hello, world!
*Ctrl+D*
     1       2      14
  1. 所有这些意味着我们甚至可以将标准错误消息重定向回标准输出:
$> (ls /asdfasdf 2> /dev/stdout) > \ 
> error-msg-from-stdout.txt
$> cat error-msg-from-stdout.txt 
ls: cannot access '/asdfasdf': No such file or directory

工作原理…

标准输出,或者 stdout,是程序的正常输出打印的地方。Stdout 也被称为文件描述符 1。

标准错误,或者 stderr,是所有错误消息被打印的地方。Stderr 也被称为文件描述符 2。这就是为什么我们在将 stderr 重定向到文件时使用了2>。如果我们愿意,为了清晰起见,我们可以将stdout重定向为1>,而不仅仅是>。但是,默认的重定向是 stdout,所以没有必要这样做。

步骤 9中,当我们重定向了标准输出和标准错误时,我们使用了一个&符号。这意味着“标准输出标准错误”。

标准输入,或stdin,是所有输入数据被读取的地方。Stdin 也被称为文件描述符 0。Stdin 可以通过<重定向,但就像标准输出和标准错误一样,我们也可以写成0<

分开标准输出和标准错误的原因是,当我们将程序的输出重定向到文件时,我们仍然应该能够在屏幕上看到错误消息。我们也不希望文件被错误消息淹没。

拥有单独的输出也使得可以有一个文件用于实际输出,另一个文件用作错误消息的日志文件。这在脚本中特别方便。

你可能听说过短语“Linux 中的一切都是文件或进程”。这句话是真的。在 Linux 中没有其他东西,除了文件或进程。我们对/dev/stdout/dev/stderr/dev/stdin的实验证明了这一点。文件甚至代表了程序的输入和输出。

步骤 11中,我们将输出重定向到了/dev/stderr文件,这是标准错误。因此,消息被打印到了标准错误。

步骤 12中,我们基本上做了同样的事情,但没有使用实际的设备文件。看起来有点奇怪的1>&2重定向的意思是“将标准输出发送到标准错误”。

还有更多...

例如,我们可以使用/dev/fd/2来代替使用/dev/stderr,其中/dev/fd/1和标准输入,即/dev/fd/0。因此,例如,以下命令将列表打印到标准错误:

$> ls / > /dev/fd/2

就像我们可以使用1>&2将标准输出发送到标准错误一样,我们也可以使用2>&1将标准错误发送到标准输出。

使用管道连接程序

在这个教程中,我们将学习如何使用管道连接程序。当我们编写 C 程序时,我们总是希望努力使它们易于与其他程序一起使用管道连接。这样,我们的程序将更加有用。有时,使用管道连接的程序被称为过滤器。原因是,通常当我们使用管道连接程序时,是为了过滤或转换一些数据。

准备工作

就像在上一个教程中一样,建议使用 Bash shell。

如何做...

按照以下步骤来探索 Linux 中的管道:

  1. 我们已经熟悉了上一个教程中的wcls。在这里,我们将它们与管道一起使用,来计算系统根目录中文件和目录的数量。管道是垂直线符号:
$> ls / | wc -l
29
  1. 让我们让事情变得更有趣一点。这一次,我们想要列出根目录中的符号链接(使用两个程序和一个管道)。结果会因系统而异:
$> ls -l / | grep lrwx
lrwxrwxrwx   1 root root    31 okt 21 06:53 initrd.img -> boot/initrd.img-4.19.0-12-amd64
lrwxrwxrwx   1 root root    31 okt 21 06:53 initrd.img.old -> boot/initrd.img-4.19.0-11-amd64
lrwxrwxrwx   1 root root    28 okt 21 06:53 vmlinuz -> boot/vmlinuz-4.19.0-12-amd64
lrwxrwxrwx   1 root root    28 okt 21 06:53 vmlinuz.old -> boot/vmlinuz-4.19.0-11-amd64
  1. 现在,我们只想要实际的文件名,而不是关于它们的信息。所以,这一次,我们将在最后添加另一个名为awk的程序。在这个例子中,我们告诉awk打印第九个字段。一个或多个空格分隔每个字段:
$> ls -l / | grep lrwx | awk '{ print $9 }'
initrd.img
initrd.img.old
vmlinuz
vmlinuz.old
  1. 我们可以添加另一个"sed - s意思是替换。然后,我们可以告诉sed我们想要用文本This is a link:替换行的开头(^):
$> ls -l / | grep lrwx | awk '{ print $9 }' \
> | sed 's/^/This is a link: /'
This is a link: initrd.img
This is a link: initrd.img.old
This is a link: vmlinuz
This is a link: vmlinuz.old

它是如何工作的...

这里有很多事情正在进行,但如果你不明白所有的事情,不要感到沮丧。这个教程的重要性在于演示如何使用管道(垂直线符号,|)。

在第一步中,我们使用wc计算了文件系统根目录中的文件和目录数量。当我们交互式运行ls时,我们会得到一个漂亮的列表,它跨越了我们终端的宽度。输出很可能也是彩色的。但是当我们通过管道重定向ls的输出时,ls没有一个真正的终端来输出,所以它会回退到每行输出一个文件或目录的文本,而没有任何颜色。如果你愿意,你可以自己尝试一下,运行以下命令:

$> ls / | cat

由于ls每行输出一个文件或目录,我们可以使用wc-l选项)来计算行数。

在下一步(步骤 2)中,我们使用grep仅列出了从ls -l的输出中的链接。ls -l的输出中的链接以行首的字母l开头。之后是访问权限,对于链接来说是rwx。这就是我们用grep搜索的内容。

然后,我们只想要实际的文件名,所以我们添加了一个名为awk的程序。awk工具让我们单独提取输出中的特定列或字段。我们提取了第九列($9),也就是文件名。

通过将ls的输出通过另外两个工具,我们创建了一个仅包含根目录中链接的列表。

步骤 3中,我们添加了另一个工具,或者有时称为过滤器。这个工具是sed,一个流编辑器。使用这个程序,我们可以对文本进行更改。在这种情况下,我们在每个链接前面添加了文本This is a link:。以下是该行的简短解释:

sed 's/^/This is a link: /'

s表示"替换";也就是说,我们希望修改一些文本。在两个第一个斜杠(/)内是应该匹配我们想要修改的文本或表达式。这里,我们有行首^。然后,在第二个斜杠之后,我们有要用匹配的文本替换的文本,一直到最后一个斜杠。这里,我们有文本This is a link:

更多内容...

小心不必要的管道处理;很容易陷入无休止的管道处理中。一个愚蠢但有教育意义的例子是:

$> ls / | cat | grep tmp
tmp

我们可以省略cat,仍然可以得到相同的结果:

$> ls / | grep tmp
tmp

对于这个(我自己有时也会犯的错误)也是一样的:

$> cat /etc/passwd | grep root
root:x:0:0:root:/root:/bin/bash

没有理由对前面的示例进行管道处理。grep实用程序可以接受文件名参数,如下所示:

$> grep root /etc/passwd
root:x:0:0:root:/root:/bin/bash

另请参阅

对于任何对 Unix 的历史以及管道的起源感兴趣的人,YouTube 上有一个令人兴奋的 1982 年的视频,由 AT&T 上传:www.youtube.com/watch?v=tc4ROCJYbm0

写入 stdout 和 stderr

在这个配方中,我们将学习如何在 C 程序中将文本打印到stdoutstderr。在前两个配方中,我们学习了 stdout 和 stderr 是什么,它们为什么存在,以及如何重定向它们。现在,轮到我们编写正确的程序,在标准错误上输出错误消息,并在标准输出上输出常规消息了。

如何做...

按照以下步骤学习如何在 C 程序中将输出写入 stdout 和 stderr:

  1. 在名为output.c的文件中写入以下代码并保存。在这个程序中,我们将使用三个不同的函数来写入输出:printf()fprintf()dprintf()。使用fprintf(),我们可以指定文件流,如 stdout 或 stderr,而使用dprintf(),我们可以指定文件描述符(1 表示 stdout,2 表示 stderr,就像我们之前看到的那样):
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
int main(void)
{
   printf("A regular message on stdout\n");
   /* Using streams with fprintf() */
   fprintf(stdout, "Also a regular message on " 
     	 "stdout\n");
   fprintf(stderr, "An error message on stderr\n");
   /* Using file descriptors with dprintf().
    * This requires _POSIX_C_SOURCE 200809L 
    * (man 3 dprintf)*/
   dprintf(1, "A regular message, printed to "
      	  "fd 1\n");
   dprintf(2, "An error message, printed to "
      	   "fd 2\n");
   return 0;
}
  1. 编译程序:
$> gcc output.c -o output
  1. 像通常一样运行程序:
$> ./output 
A regular message on stdout
Also a regular message on stdout
An error message on stderr
A regular message, printed to fd 1
An error message, printed to fd 2
  1. 为了证明常规消息是打印到 stdout 的,我们可以将错误消息发送到/dev/null,这是 Linux 系统中的一个黑洞。这样做将只显示打印到 stdout 的消息:
$> ./output 2> /dev/null 
A regular message on stdout
Also a regular message on stdout
A regular message, printed to fd 1
  1. 现在,我们将做相反的操作;我们将把打印到 stdout 的消息发送到/dev/null,只显示打印到 stderr 的错误消息:
$> ./output > /dev/null
An error message on stderr
An error message, printed to fd 2
  1. 最后,让我们将所有消息,包括 stdout 和 stderr,发送到/dev/null。这将不显示任何内容:
$> ./output &> /dev/null

工作原理...

第一个示例中,我们使用printf(),没有包含任何新的或独特的内容。使用常规的printf()函数打印的所有输出都会打印到 stdout。

然后,我们看到了一些新的示例,包括我们使用fprintf()的两行。fprintf()函数允许我们指定stdio.h)。

然后,我们看了一些使用dprintf()的例子。这个函数允许我们指定要打印到的文件描述符。我们在本章的先前示例中涵盖了文件描述符,但我们将在本书的后面更深入地讨论它们。三个文件描述符始终是打开的——0(stdin)、1(stdout)和 2(stderr)——在我们在 Linux 上编写的每个程序中。在这里,我们将常规消息打印到文件描述符(fd简称)1,将错误消息打印到文件描述符 2。

为了在我们的代码中正确,我们需要包括第一行(#define行)以支持dprintf()。我们可以在手册页(man 3 dprintf)中找到有关它的所有信息,包括特性测试宏要求_POSIX_C_SOURCE是用于POSIX标准和兼容性。我们将在本书的后面更深入地讨论这个问题。

当我们测试程序时,我们验证了常规消息通过将错误消息重定向到名为/dev/null的文件来打印到标准输出,仅显示打印到标准输出的消息。然后,我们进行了相反的操作,以验证错误消息是否被打印到标准错误。

特殊文件/dev/null在 Linux 和其他 Unix 系统中充当黑洞。我们发送到该文件的所有内容都会消失。例如,尝试使用ls / &> /dev/null。不会显示任何输出,因为一切都被重定向到黑洞中。

还有更多...

我提到程序中打开了三个文件流,假设它包括stdio.h,以及三个文件描述符。这三个文件描述符始终是打开的,即使没有包括stdio.h。如果我们包括unistd.h,我们还可以使用三个文件描述符的宏名称。

以下表格显示了这些文件描述符、它们的宏名称和文件流,这对以后的参考很有用:

图 2.3 – Linux 中的文件描述符和文件流

图 2.3 – Linux 中的文件描述符和文件流

从标准输入读取

在这个示例中,我们将学习如何用 C 语言编写一个从标准输入读取的程序。这样做可以使您的程序通过管道从其他程序接收输入,使它们更容易用作过滤器,从而使它们在长期内更有用。

准备工作

您将需要 GCC 编译器,最好是 Bash shell 来完成这个示例,尽管它应该适用于任何 shell。

要完全理解我们即将编写的程序,您应该查看 ASCII 表,可以在以下 URL 找到示例:github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/ascii-table.md

如何做...

在这个示例中,我们将编写一个程序,它接受单词作为输入,将它们的大小写转换(大写转换为小写,小写转换为大写),并将结果打印到标准输出。让我们开始吧:

  1. 将以下代码写入文件并保存为case-changer.c。在这个程序中,我们使用fgets()从 stdin 读取字符。然后我们使用for循环逐个字符地循环输入。在我们开始下一个循环之前,我们必须使用memset()将数组清零:
#include <stdio.h>
#include <string.h>
int main(void)
{
    char c[20] = { 0 };
    char newcase[20] = { 0 };
    int i;
    while(fgets(c, sizeof(c), stdin) != NULL)
    {
        for(i=0; i<=sizeof(c); i++)
        {
            /* Upper case to lower case */
            if ( (c[i] >= 65) && (c[i] <= 90) )
            {
                newcase[i] = c[i] + 32;
            }
            /* Lower case to upper case */
            if ( (c[i] >= 97 && c[i] <= 122) )
            {
                newcase[i] = c[i] - 32;
            }
        }
        printf("%s\n", newcase);
        /* zero out the arrays so there are no
           left-overs in the next run */
        memset(c, 0, sizeof(c));
        memset(newcase, 0, sizeof(newcase));
    }
    return 0;
}
  1. 编译程序:
$> gcc case-changer.c -o case-changer
  1. 通过在其中输入一些单词来尝试它。按Ctrl + D退出程序:
$> ./case-changer
hello
HELLO
AbCdEf
aBcDeF
  1. 现在,试着将一些输入管道到它,例如,从ls中的前五行:
$> ls / | head -n 5 | ./case-changer
BIN
BOOT
DEV
ETC
HOME
  1. 让我们尝试从手册页中将一些大写单词管道到它中:
$> man ls | egrep '^[A-Z]+$' | ./case-changer 
name
synopsis
description
author
copyrigh

它是如何工作的...

首先,我们创建了两个 20 字节的字符数组,并将它们初始化为 0。

然后,我们使用fgets(),包装在while循环中,从标准输入读取字符。fgets()函数读取字符,直到它达到一个换行字符或一个c数组,并且也返回。

要读取更多输入——也就是说,不止一个单词——我们继续使用while循环来读取输入。while循环直到我们按下Ctrl + D或输入流为空为止。

fgets()函数在成功时返回读取的字符,在错误或在没有读取任何字符的情况下发生 EOF 时返回NULL(也就是说,没有更多的输入)。让我们分解fgets()函数,以便更好地理解它:

fgets(c, sizeof(c), stdin)

第一个参数c是我们存储数据的地方。在这种情况下,它是我们的字符数组。

第二个参数sizeof(c)是我们想要读取的最大大小。fgets()函数在这里是安全的;它读取比我们指定的大小少一个。在我们的情况下,它只会读取 19 个字符,留出空字符的空间。

最终的第三个参数stdin是我们想要从中读取的流——在我们的情况下是标准输入。

while循环内是发生大小写转换的地方,逐个字符在for循环中进行。在第一个if语句中,我们检查当前字符是否是大写的。如果是,我们加上 32 个字符。例如,如果字符是A,那么在ASCII 表中表示为 65。当我们加上 32 时,我们得到 97,即a。对于整个字母表都是这样的。大写和小写版本之间始终相差 32 个字符。

接下来的if语句执行相反的操作。如果字符是小写的,我们减去 32 并得到大写版本。

由于我们只检查 65 到 90 和 97 到 122 之间的字符,所有其他字符都被忽略。

一旦我们在屏幕上打印出结果,我们就用memset()将字符数组重置为全零。如果我们不这样做,下一次运行时会有剩余的字符。

使用该程序

我们通过交互式运行程序并向其输入单词来尝试该程序。每次按下Enter键时,单词都会被转换;大写字母将变成小写,反之亦然。

然后,我们从ls命令向其传递数据。该输出被转换为大写字母。

然后,我们尝试从手册页(标题)中将其管道化为大写单词。手册页中的所有标题都是大写的,并且从行的开头开始。这就是我们用egrep进行“grep”搜索的内容,然后将其管道化到我们的case-changer程序中。

还有更多内容...

有关fgets()的更多信息,请参阅手册页man 3 fgets

您可以编写一个小程序来打印字母a-zA-Z的最小 ASCII 表。这个小程序还演示了每个字符都是由一个数字表示的:

ascii-table.c

#include <stdio.h>
int main(void)
{
    char c;
    for (c = 65; c<=90; c++)
    {
        printf("%c = %d    ", c, c); /* upper case */
        printf("%c = %d\n", c+32, c+32); /* lower case */
    }
    return 0;
}

编写一个管道友好的程序

在这个示例中,我们将学习如何编写一个管道友好的程序。它将从标准输入接收输入,并将结果输出到标准输出。任何错误消息都将被打印到标准错误。

准备工作

对于这个示例,我们需要 GCC 编译器,GNU Make,最好是Bash shell。

如何做...

在这个示例中,我们将编写一个程序,将每小时英里转换为每小时公里。作为测试,我们将从一个包含汽车试验平均速度测量的文本文件中向其管道数据。文本文件是以每小时英里mph)为单位的,但我们希望将其转换为每小时公里kph)。让我们开始吧:

  1. 首先创建以下文本文件,或者从 GitHub 下载它github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/avg.txt。如果您自己创建,请命名为avg.txt。这个文本将被用作我们将要编写的程序的输入。这个文本模拟了汽车试验的测量数值:
10-minute average: 61 mph
30-minute average: 55 mph
45-minute average: 54 mph
60-minute average: 52 mph
90-minute average: 52 mph
99-minute average: nn mph
  1. 现在,创建实际的程序。输入以下代码并将其保存为mph-to-kph.c,或者从 GitHub 上下载它:github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/mph-to-kph.c。该程序将把每小时英里转换为每小时公里。这个转换是在printf()语句中执行的:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
    char mph[10] = { 0 };
    while(fgets(mph, sizeof(mph), stdin) != NULL)
    {
        /* Check if mph is numeric 
         * (and do conversion) */
        if( strspn(mph, "0123456789.-\n") == 
            strlen(mph) )
        {
            printf("%.1f\n", (atof(mph)*1.60934) );
        }
        /* If mph is NOT numeric, print error 
         * and return */
        else
        {
            fprintf(stderr, "Found non-numeric" 
                " value\n");
            return 1;
        }
    }
    return 0;
}
  1. 编译程序:
$> gcc mph-to-kph.c -o mph-to-kph
  1. 通过交互式运行程序来测试程序。输入一些每小时英里的值,并在每个值后按Enter。程序将打印出相应的每小时公里值:
$> ./mph-to-kph 
50
80.5
60
96.6
100
160.9
hello
Found non-numeric value
$> echo $?
1
$> ./mph-to-kph
50
80.5
*Ctrl+D*
$> echo $?
0
  1. 现在,是时候将我们的程序作为过滤器使用,将包含每小时英里的表格转换为每小时公里。但首先,我们必须筛选出只有 mph 值。我们可以使用awk来实现这一点:
$> cat avg.txt | awk '{ print $3 }'
61
55
54
52
52
nn
  1. 现在我们有了一个仅包含数字的列表,我们可以在最后添加我们的mph-to-kph程序来转换数值:
$> cat avg.txt | awk '{ print $3 }' | ./mph-to-kph 
98.2
88.5
86.9
83.7
83.7
Found non-numeric value
  1. 由于最后一个值是nn,一个非数字值,这是测量中的错误,我们不想在输出中显示错误消息。因此,我们将 stderr 重定向到/dev/null。请注意,在重定向之前,表达式周围有括号:
$> (cat avg.txt | awk '{ print $3 }' | \ 
> ./mph-to-kph) 2> /dev/null
98.2
88.5
86.9
83.7
83.7
  1. 这样看起来漂亮多了!但是,我们还想在每一行的末尾添加km/h,以便知道数值是多少。我们可以使用sed来实现这一点:
$> (cat avg.txt | awk '{ print $3 }' | \ 
> ./mph-to-kph) 2> /dev/null | sed 's/$/ km\/h/'
98.2 km/h
88.5 km/h
86.9 km/h
83.7 km/h
83.7 km/h

工作原理...

这个程序与上一个配方中的程序类似。我们在这里添加的功能检查输入数据是否是数字,如果不是,程序将中止,并打印错误消息到 stderr。正常输出仍然打印到 stdout,只要没有错误发生。

该程序只打印数值,没有其他信息。这使得它更适合作为过滤器,因为km/h文本可以由用户使用其他程序添加。这样,该程序可以用于我们尚未考虑到的许多其他情况。

检查数字输入的行可能需要一些解释:

if( strspn(mph, "0123456789.-\n") == strlen(mph) )

strspn()函数只读取我们在函数的第二个参数中指定的字符,然后返回读取的字符数。然后我们可以将strspn()读取的字符数与我们使用strlen()获得的字符串的整个长度进行比较。如果它们匹配,我们就知道每个字符都是数字、句点、减号或换行符。如果它们不匹配,这意味着在字符串中找到了非法字符。

为了使strspn()strlen()工作,我们包含了string.h。为了使atof()工作,我们包含了stdlib.h

将数据传送到程序

步骤 5中,我们使用awk程序仅选择了第三个字段——mph 值。awk 的$3变量表示第 3 个字段。每个字段都是一个新单词,由空格分隔。

步骤 6中,我们将awk程序的输出——mph 值——重定向到我们的mph-to-kph程序中。结果,我们的程序在屏幕上打印出了 km/h 值。

步骤 7中,我们将错误消息重定向到/dev/null,以便程序的输出是干净的。

最后,在步骤 8中,我们在输出中添加了文本km/h在 kph 值之后。我们使用了sed程序来实现这一点。sed程序可能看起来有点神秘,所以让我们来分解一下:

sed 's/$/ km\/h/'

这个sed脚本与我们之前看到的类似。但是这一次,我们用$符号替换了行尾,而不是用^替换行首。所以,我们在这里做的是用文本"km/h"替换行尾。不过,请注意,我们需要用反斜杠转义“km/h”中的斜杠。

还有更多...

关于strlen()strspn()有很多有用的信息在各自的手册页中。您可以使用man 3 strlenman 3 strspn来阅读它们。

将结果重定向到文件

在这个食谱中,我们将学习如何将程序的输出重定向到两个不同的文件。我们还将学习一些在编写过滤器时的最佳实践,过滤器是专门用于与其他程序通过管道连接的程序。

在这个食谱中,我们将构建一个新版本的上一个食谱中的程序。在上一个食谱中的mph-to-kph程序有一个缺点:它总是在找到非数字字符时停止。通常,当我们在长输入数据上运行过滤器时,我们希望程序继续运行,即使它已经检测到一些错误的数据。这就是我们要在这个版本中修复的问题。

我们将保持默认行为与之前一样;也就是说,当遇到非数字值时,它将中止程序。然而,我们将添加一个选项(-c),以便即使检测到非数字值,它也可以继续运行程序。然后,由最终用户决定如何运行它。

准备好

本章的技术要求部分列出的所有要求都适用于这里(GCC 编译器、Make 工具和 Bash shell)。

如何做…

这个程序会有点长,但如果你愿意,你可以从 GitHub 上下载它github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/mph-to-kph_v2.c。由于代码有点长,我将把它分成几个步骤。不过,所有的代码仍然放在一个名为mph-to-kph_v2.c的单个文件中。让我们开始吧:

  1. 让我们从特征宏和所需的头文件开始。由于我们将使用getopt(),我们需要_XOPEN_SOURCE宏,以及unistd.h头文件:
#define _XOPEN_SOURCE 500
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h
  1. 接下来,我们将为help函数添加函数原型。我们还将开始编写main()函数体:
void printHelp(FILE *stream, char progname[]);
int main(int argc, char *argv[])
{
   char mph[10] = { 0 };
   int opt;
   int cont = 0; 
  1. 然后,我们将在while循环中添加getopt()函数。这类似于第一章中的编写解析命令行选项的程序食谱,获取必要的工具并编写我们的第一个 Linux 程序:
/* Parse command-line options */    
   while ((opt = getopt(argc, argv, "ch")) != -1)
   {
      switch(opt)
      {
         case 'h':
            printHelp(stdout, argv[0]);
            return 0;
         case 'c':
            cont = 1;
            break;
         default:
            printHelp(stderr, argv[0]);
            return 1;
      }
   }
  1. 然后,我们必须创建另一个while循环,在其中我们将使用fgets()从 stdin 获取数据:
while(fgets(mph, sizeof(mph), stdin) != NULL)
   {
      /* Check if mph is numeric 
       * (and do conversion) */
      if( strspn(mph, "0123456789.-\n") == 
            strlen(mph) )
      {
         printf("%.1f\n", (atof(mph)*1.60934) );
      }
      /* If mph is NOT numeric, print error 
       * and return */
      else
      {
         fprintf(stderr, "Found non-numeric " 
            "value\n");
         if (cont == 1) /* Check if -c is set */
         {
            continue; /* Skip and continue if 
                       * -c is set */
         }
         else
         {
            return 1; /* Abort if -c is not set */
         }
      }
   }
   return 0;
}
  1. 最后,我们必须为help函数编写函数体:
void printHelp(FILE *stream, char progname[])
{
   fprintf(stream, "%s [-c] [-h]\n", progname);
   fprintf(stream, " -c continues even though a non" 
      "-numeric value was detected in the input\n"
      " -h print help\n");
} 
  1. 使用 Make 编译程序:
$> make mph-to-kph_v2
cc     mph-to-kph_v2.c   -o mph-to-kph_v2
  1. 让我们尝试一下,不带任何选项,给它一些数字值和一个非数字值。结果应该与我们之前收到的相同:
$> ./mph-to-kph_v2 
60
96.6
40
64.4
hello
Found non-numeric value
  1. 现在,让我们尝试使用-c选项,以便即使检测到非数字值,我们也可以继续运行程序。在程序中输入一些数字和非数字值:
$> ./mph-to-kph_v2 -c
50
80.5
90
144.8
hello
Found non-numeric value
10
16.1
20
32.2
  1. 这很好!现在,让我们向avg.txt文件添加一些数据,并将其保存为avg-with-garbage.txt。这一次,将会有更多行包含非数字值。您也可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/avg-with-garbage.txt下载该文件:
10-minute average: 61 mph
30-minute average: 55 mph
45-minute average: 54 mph
60-minute average: 52 mph
90-minute average: 52 mph
99-minute average: nn mph
120-minute average: 49 mph
160-minute average: 47 mph
180-minute average: nn mph
error reading data from interface
200-minute average: 43 mph
  1. 现在,让我们再次在该文件上运行awk,只看到值:
$> cat avg-with-garbage.txt | awk '{ print $3 }'
61
55
54
52
52
nn
49
47
nn
data
43
  1. 现在是真相的时刻。让我们在最后添加mph-to-kph_v2程序,并使用-c选项。这应该将所有的 mph 值转换为 kph 值并继续运行,即使找到非数字值:
$> cat avg-with-garbage.txt | awk '{ print $3 }' \
> | ./mph-to-kph_v2 -c
98.2
88.5
86.9
83.7
83.7
Found non-numeric value
78.9
75.6
Found non-numeric value
Found non-numeric value
69.2
  1. 成功了!程序继续运行,即使有非数字值。由于错误消息被打印到 stderr,值被打印到 stdout,我们可以将输出重定向到两个不同的文件。这样我们就得到了一个干净的输出文件和一个单独的错误文件:
$> (cat avg-with-garbage.txt | awk '{ print $3 }' \
> | ./mph-to-kph_v2 -c) 2> errors.txt 1> output.txt
  1. 让我们看看这两个文件:
$> cat output.txt 
98.2
88.5
86.9
83.7
83.7
78.9
75.6
69.2
$> cat errors.txt 
Found non-numeric value
Found non-numeric value
Found non-numeric value

工作原理…

代码本身与我们在上一个配方中的内容类似,只是增加了getopt()和帮助函数。我们在第一章中详细介绍了getopt(),因此这里没有必要再次介绍它。

在使用-c选项时,当发现非数字值时,我们使用continue从 stdin 继续读取数据,以跳过循环的一次迭代。我们不会中止程序,而是向 stderr 打印错误消息,然后继续进行下一次迭代,使程序继续运行。

还要注意,我们向printHelp()函数传递了两个参数。第一个参数是FILE 指针。我们使用这个指针将stderrstdout传递给函数。Stdout 和 stderr 是,可以通过它们的FILE指针访问。这样,我们可以选择帮助消息是应该打印到 stdout(如果用户要求帮助)还是打印到 stderr(如果出现错误)。

第二个参数是程序的名称,我们已经见过了。

然后我们编译并测试了程序。没有-c选项,它的工作方式与以前一样。

之后,我们尝试使用包含一些垃圾的文件中的数据运行程序。这通常是数据的外观;它通常不是“完美”的。这就是为什么我们即使找到非数字值,也添加了继续的选项。

就像在上一个配方中一样,我们使用awk从文件中选择第三个字段(print $3)。

令人兴奋的部分是第 12 步,我们重定向了stderrstdout。我们将两个输出分开到两个不同的文件中。这样,我们就有了一个干净的输出文件,只包含 km/h 值。然后,我们可以使用该文件进行进一步处理,因为它不包含任何错误消息。

我们本可以编写程序来为我们执行所有步骤,例如从文本文件中过滤出值,进行转换,然后将结果写入新文件。但这在 Linux 和 Unix 中是一种反模式。相反,我们希望编写只做一件事情的小工具,并且做得很好。这样,该程序可以用于具有不同结构的其他文件,或者用于完全不同的目的。我们甚至可以直接从设备或调制解调器中获取数据并将其传输到我们的程序中。从文件(或设备)中提取正确字段的工具已经创建;没有必要重新发明轮子。

请注意,我们需要在重定向输出和错误消息之前将整个命令及其所有管道括起来。

还有更多...

Eric S. Raymond 在为 Linux 和 Unix 开发软件时制定了一些出色的规则。这些规则都可以在他的书《Unix 编程艺术》中找到。在本配方中适用于我们的规则包括模块化规则,该规则指出我们应该编写简单的部分,并使用清晰的接口连接它们。适用于我们的另一条规则是组合规则,该规则指出要编写将连接到其他程序的程序。

他的书可以在www.catb.org/~esr/writings/taoup/html/免费在线阅读。

读取环境变量

与 shell 和配置程序进行通信的另一种方法是通过环境变量。默认情况下,已经设置了许多环境变量。这些变量包含有关用户和设置的几乎所有信息。一些示例包括用户名,您正在使用的终端类型,我们在以前的配方中讨论过的路径变量,您首选的编辑器,首选的区域设置和语言,以及其他信息。

了解如何读取这些变量将使您更容易地调整程序以适应用户的环境。

在本配方中,我们将编写一个程序,该程序读取环境变量,调整其输出,并打印有关用户和会话的一些信息。

准备工作

对于这个示例,我们可以使用几乎任何 shell。除了 shell,我们还需要 GCC 编译器。

如何做…

按照以下步骤编写一个读取环境变量的程序:

  1. 将以下代码保存到名为env-var.c的文件中。您还可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch2/env-var.c下载整个程序。该程序将使用getenv()函数从您的 shell 中读取一些常见的环境变量。看起来奇怪的数字序列(\033[0;31)用于给输出着色:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
   /* Using getenv() to fetch env. variables */
   printf("Your username is %s\n", getenv("USER"));
   printf("Your home directory is %s\n", 
      getenv("HOME"));
   printf("Your preferred editor is %s\n", 
      getenv("EDITOR"));
   printf("Your shell is %s\n", getenv("SHELL"));
   /* Check if the current terminal support colors*/
   if ( strstr(getenv("TERM"), "256color")  )
   {
      /* Color the output with \033 + colorcode */
      printf("\033[0;31mYour \033[0;32mterminal "
         "\033[0;35msupport "
         "\033[0;33mcolors\033[0m\n");
   }
   else
   {
      printf("Your terminal doesn't support" 
         " colors\n");
   }
   return 0;
}
  1. 使用 GCC 编译程序:
$> gcc env-var.c -o env-var
  1. 运行程序。将为您打印的信息与我的不同。如果您的终端支持,最后一行也将是彩色的。如果不支持,它会告诉您您的终端不支持颜色:
$> ./env-var 
Your username is jake
Your home directory is /home/jake
Your preferred editor is vim
Your shell is /bin/bash
Your terminal support colors
  1. 让我们使用echo来调查我们使用的环境变量。记下$TERM变量。美元符号($)告诉 shell 我们要打印TERM变量,而不是单词TERM
$> echo $USER
jake
$> echo $HOME
/home/jake
$> echo $EDITOR
vim
$> echo $SHELL
/bin/bash
$> echo $TERM
screen-256color
  1. 如果我们将$TERM变量更改为普通的xterm,不支持颜色,我们将从程序中获得不同的输出:
$> export TERM=xterm
$> ./env-var 
Your username is jake
Your home directory is /home/jake
Your preferred editor is vim
Your shell is /bin/bash
Your terminal doesn't support colors
  1. 在继续之前,我们应该将我们的终端重置为更改之前的值。这在您的计算机上可能是其他内容:
$> export TERM=screen-256color
  1. 还可以在程序运行期间临时设置环境变量。我们可以通过设置变量并在同一行上执行程序来实现这一点。请注意,当程序结束时,变量仍然与以前相同。当程序执行时,我们只是覆盖变量:
$> echo $TERM
xterm-256color
$> TERM=xterm ./env-var
Your username is jake
Your home directory is /home/jake
Your preferred editor is vim
Your shell is /bin/bash
Your terminal doesn't support colors
$> echo $TERM
xterm-256colo
  1. 我们还可以使用env命令打印所有环境变量的完整列表。列表可能会有几页长。可以使用getenv() C 函数访问所有这些变量:
$> env

工作原理…

我们使用getenv()函数从 shell 的环境变量中获取值。我们将这些变量打印到屏幕上。

然后,在程序结束时,我们检查当前终端是否支持颜色。这通常由诸如xterm-256colorscreen-256color等表示。然后,我们使用strstr()函数(来自string.h)来检查$TERM变量是否包含256color子字符串。如果是,终端支持颜色,我们在屏幕上打印一个带颜色的消息。但是,如果不支持,我们会打印终端不支持颜色,而不使用任何颜色。

所有这些变量都是 shell 的环境变量,可以使用echo命令打印;例如,echo $TERM。我们还可以在 shell 中设置自己的环境变量;例如,export FULLNAME=Jack-Benny。同样,我们可以通过覆盖它们来更改现有的变量,就像我们用$TERM变量一样。我们还可以通过在运行时设置它们来覆盖它们,就像我们用TERM=xterm ./env-var一样。

使用FULLNAME=Jack-Benny语法设置的常规变量仅对当前 shell 可用,因此称为export命令,它们成为全局变量环境变量,这是一个更常见的名称,可供子 shell和子进程使用。

还有更多…

我们还可以使用setenv()函数在 C 程序中更改环境变量并创建新变量。但是,当我们这样做时,这些变量将不会在启动程序的 shell 中可用。我们运行的程序是 shell 的子进程,因此它无法更改 shell 的变量;也就是说,它的父进程。但是从我们自己的程序内部启动的任何其他程序都将能够看到这些变量。我们将在本书的后面更深入地讨论父进程和子进程。

以下是如何使用setenv()的简短示例。setenv()的第三个参数中的1表示如果变量已经存在,我们想要覆盖它。如果我们将其改为0,则可以防止覆盖:

env-var-set.c

#define _POSIX_C_SOURCE 200112L
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    setenv("FULLNAME", "Jack-Benny", 1);
    printf("Your full name is %s\n", getenv("FULLNAME"));
    return 0;
}

如果我们编译并运行程序,然后尝试从 shell 中读取$FULLNAME,我们会注意到它不存在:

$> gcc env-var-set.c -o env-var-set
$> ./env-var-set 
Your full name is Jack-Benny
$> echo $FULLNAME

第三章:深入学习 Linux 中的 C

现在是时候深入了解 Linux 中的 C 编程了。在这里,我们将更多地了解编译器,从源代码到二进制程序的四个阶段,如何使用 Make 工具,以及系统调用和标准库函数之间的区别。我们还将看一些关于 Linux 的基本头文件,并查看一些 C 和便携操作系统接口(POSIX)标准。C 与 Linux 紧密集成,掌握 C 将帮助您了解 Linux。

在本章中,我们将为 Linux 开发程序和库。我们还将编写一个通用的 Makefile 和更复杂的 Makefile,用于更大的项目。在这样做的同时,我们还将了解不同的 C 标准,它们为什么重要,以及它们如何影响您的程序。

本章将涵盖以下示例:

  • 使用 GNU 编译器集合(GCC)链接库

  • 更改 C 标准

  • 使用系统调用

  • 何时使用它们,何时不使用它们

  • 获取有关 Linux 和 Unix 特定头文件的信息

  • 定义特性测试宏

  • 查看编译的四个阶段

  • 使用 Make 进行编译

  • 使用 GCC 选项编写通用 Makefile

  • 编写一个简单的 Makefile

  • 编写一个更高级的 Makefile

技术要求

在本章中,您将需要 Make 工具和 GCC 编译器,最好是通过第一章中提到的元包或组安装来安装。

本章的所有源代码都可以在github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch3上找到。

查看以下链接以查看“代码实战”视频:bit.ly/3sElIvu

使用 GCC 链接库

在这个示例中,我们将学习如何将程序链接到外部库,包括系统范围内安装的库和位于我们家目录中的库。然而,在我们可以链接到库之前,我们需要创建它。这也是我们将在这个示例中涵盖的内容。了解如何链接到库将使您能够使用各种各样的现成函数。您可以使用已经可用的库,而不是自己编写所有内容。通常情况下,没有必要重新发明轮子,从而节省大量时间。

准备工作

对于本示例,您只需要本章的技术要求部分中列出的内容。

如何做…

在这里,我们将学习如何链接到系统上安装的共享库和家目录中的库。我们将从系统上已有的库开始:数学库。

链接到数学库

在这里,我们将制作一个小程序,用于计算银行账户上的复利。为此,我们需要数学库中包含的pow()函数。

  1. 编写以下代码,并将其保存在名为interest.c的文件中。请注意,我们在顶部包含了math.hpow()函数的第一个参数是底数;第二个参数是指数:
#include <stdio.h>
#include <math.h>
int main(void)
{
    int years = 15; /* The number of years you will 
                     * keep the money in the bank 
                     * account */
    int savings = 99000; /* The inital amount */
    float interest = 1.5; /* The interest in % */
    printf("The total savings after %d years " 
        "is %.2f\n", years, 
        savings * pow(1+(interest/100), years));
    return 0;
}
  1. 现在,编译和-l,库的名称是m(有关更多信息,请参阅man 3 pow手册页):
$> gcc -lm interest.c -o interest
  1. 最后,让我们尝试一下程序:
$> ./interest
The total savings after 15 years is 123772.95

创建我们自己的库

在这里,我们将创建我们自己的共享库。在本示例的下一部分中,我们将将程序链接到此库。我们在这里创建的库用于查找一个数字是否是质数。

  1. 让我们从创建一个简单的头文件开始。这个文件只包含一行内容——函数原型。在文件中写入以下内容,并将其命名为prime.h
int isprime(long int number);
  1. 现在,是时候编写实际的函数,该函数将被包含在库中。在文件中写入以下代码,并将其保存为primc.c
int isprime(long int number)
{
   long int j;
   int prime = 1;

   /* Test if the number is divisible, starting 
    * from 2 */
   for(j=2; j<number; j++)
   {
      /* Use the modulo operator to test if the 
       * number is evenly divisible, i.e., a 
       * prime number */
      if(number%j == 0)
      {
         prime = 0;
      }
   }
   if(prime == 1)
   {
      return 1;
   }
   else
   {
      return 0;
   }
}
  1. 我们需要以某种方式将其转换为库。第一步是将其编译为一个称为对象文件的东西。我们还需要解析一些额外的参数给编译器,使其在库中工作。更具体地说,我们需要使它成为prime.o,我们将在ls -l命令中看到。我们将在本章后面学习更多关于对象文件的知识:
$> gcc -Wall -Wextra -pedantic -fPIC -c prime.c
$> ls -l prime.o 
-rw-r--r-- 1 jake jake 1296 nov 28 19:18 prime.o
  1. 现在,我们必须将对象文件打包成一个库。在下面的命令中,-shared选项就是它听起来的样子:它创建了一个-Wl,-soname,libprime.so选项是为了链接器。这告诉链接器共享库的名称(soname)将是libprime.so-o选项指定输出文件名,即libprime.so。这是so结尾的标准命名约定代表shared object。当库要在系统范围内使用时,通常会添加一个数字来表示版本。在命令的最后,我们有prime.o对象文件,它包含在这个库中:
$> gcc -shared -Wl,-soname,libprime.so -o \
> libprime.so prime.o

链接到主目录中的库

有时,您可能有一个共享库,您希望链接到您的主目录(或其他目录)。也许它是您从互联网上下载的库,或者是您自己构建的库,就像在这种情况下一样。我们将在本书的后面章节中了解更多关于制作自己的库的知识。在这里,我们使用我们刚刚制作的小样本库,名为libprime.so

  1. 将以下源代码写入文件并命名为is-it-a-prime.c。这个程序将使用我们刚刚下载的库。我们还必须包含我们创建的头文件prime.h。注意包含本地头文件(而不是系统范围的头文件)的不同语法:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "prime.h"
int main(int argc, char *argv[])
{
   long int num;
   /* Only one argument is accepted */
   if (argc != 2)
   {
      fprintf(stderr, "Usage: %s number\n", 
         argv[0]);
      return 1;
   }
   /* Only numbers 0-9 are accepted */
   if ( strspn(argv[1], "0123456789") != 
      strlen(argv[1]) )
   {
      fprintf(stderr, "Only numeric values are "
         "accepted\n");
      return 1;
   }
   num = atol(argv[1]); /* String to long */
   if (isprime(num)) /* Check if num is a prime */
   {
      printf("%ld is a prime\n", num);
   }
   else
   {
      printf("%ld is not a prime\n", num);
   }

   return 0;
}
  1. 现在,编译并将其链接到libprime.so。由于库位于我们的主目录中,我们需要指定路径:
$> gcc -L${PWD} -lprime is-it-a-prime.c \
> -o is-it-a-prime
  1. 在运行程序之前,我们需要将$LD_LIBRARY_PATH 环境变量设置为我们当前的目录(库所在的位置)。这样做的原因是,该库是动态链接的,不在通常的系统库路径上:
$> export LD_LIBRARY_PATH=${PWD}:${LD_LIBRARY_PATH}
  1. 现在,我们终于可以运行程序了。用一些不同的数字测试它,看看它们是不是质数:
ldd program. If we examine the is-it-a-prime program, we'll see that it depends upon our libprime.so library. There are also other dependencies, such as libc.so.6, which is the standard C library:

$> ldd is-it-a-prime

linux-vdso.so.1 (0x00007ffc3c9f2000)

libprime.so => /home/jake/libprime.so (0x00007fd8b1e48000)

libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd8b1c4c000)

/lib64/ld-linux-x86-64.so.2 (0x00007fd8b1e54000)


它是如何工作的…

我们在链接到数学库部分中使用的pow()函数需要我们链接到数学库libm.so。您可以在系统的库位置之一找到此文件,通常在/usr/lib/usr/lib64中。在 Debian 和 Ubuntu 上,它通常是/usr/lib/x86_64-linux-gnu(对于 64 位系统)。由于文件位于系统的默认库位置,我们只需使用-l选项即可包含它。库文件的完整名称是libm.so,但是当我们指定要链接的库时,我们只指定m部分(也就是说,我们去掉lib部分和.so扩展名)。-lm部分之间不应该有空格,因此要链接到它,我们输入-lm

我们需要链接到库以使用pow()函数的原因是,数学库与标准 C 库libc.so是分开的。我们之前使用的所有函数都是标准库的一部分,这是libc.so文件。这个库默认被链接,所以不需要指定它。如果我们真的想在编译时指定链接到libc.so,我们可以使用gcc -lc some-program.c -o some-program

pow()函数接受两个参数,xy,如pow(x,y)。然后函数返回xy次方的值。例如,pow(2,8)将返回 256。返回的值是双精度浮点数xy都是双精度浮点数。

计算复利的公式如下所示:

在这里,P是您放入账户的起始资本,r是百分比利率,y是资金应该在账户中保持不变的年数。

链接到主目录中的库

is-it-a-prime.c的 C 程序中,我们需要包含prime.h头文件。头文件只包含一行:isprime()函数的函数原型。实际的isprime()函数包含在我们从prime.c创建的prime.o中,我们从prime.o创建的libprime.so库中。.so文件是一个共享库共享对象文件。共享库包含函数的已编译对象文件。我们将在本章后面介绍对象文件是什么。

当我们想要链接到一个我们下载或自己创建的库,而该库未安装在系统默认的库位置时,事情就会变得有点复杂。

首先,我们需要指定库的名称和库所在的路径。路径是用-L选项指定的。在这里,我们将路径设置为我们创建库的当前目录。${PWD}是一个 shell 环境变量,它包含当前目录的完整路径。您可以使用echo ${PWD}来尝试它。

但是,为了能够运行程序,我们需要设置一个名为$LD_LIBRARY_PATH的环境变量到我们的当前目录(以及它已经包含的内容)。这样做的原因是程序是$LD_LIBRARY_PATH。我们也不想覆盖$LD_LIBRARY_PATH变量中已经存在的内容;这就是为什么我们还包括了该变量的内容。如果我们没有设置这个环境变量,当执行程序时会收到一个错误消息,说“error while loading shared libraries: libprime.so”。当我们用ldd列出依赖项时,我们看到libprime.so位于主目录中,而不是系统的库位置。

还有更多…

如果您对标准 C 库感兴趣,可以阅读man libc。要了解有关pow()函数的更多信息,可以阅读man 3 pow

我还鼓励您阅读man ldd的手册页。还可以使用ldd检查一些程序的依赖项,例如我们在本示例中编写的interest程序。在这样做时,您将看到libm.so及其在系统中的位置。您还可以尝试在系统二进制文件上使用ldd,例如/bin/ls

更改 C 标准

在这个示例中,我们将学习和探索不同的C 标准,它们是什么,为什么它们很重要,以及它们如何影响我们的程序。我们还将学习如何在编译时设置 C 标准。

今天最常用的 C 标准是C89C99C11(C89 代表 1989 年,C11 代表 2011 年,依此类推)。许多编译器仍然默认使用 C89,因为它是最兼容、最广泛和最完整的实现。然而,C99 是一种更灵活和现代的实现。通常,在较新版本的 Linux 下,默认是C18,还有一些 POSIX 标准。

我们将编写两个程序,并用 C89 和 C99 编译它们,看看它们的区别。

准备工作

您需要的只是一台安装了 GCC 的 Linux 计算机,最好是通过第一章中描述的元包或软件包组来安装。

如何做…

跟着来探索 C 标准之间的差异。

  1. 编写这里显示的小型 C 程序,并将其保存为no-return.c。注意缺少return语句:
#include <stdio.h>
int main(void)
{
    printf("Hello, world\n");
}
  1. 现在,使用 C89 标准编译它:
$> gcc -std=c89 no-return.c -o no-return
  1. 运行程序并检查退出代码:
$> ./no-return 
Hello, world
$> echo $?
13
  1. 现在,使用相同的 C 标准重新编译程序,但启用所有警告额外警告严格检查-W是警告的选项,all是哪些警告,因此是-Wall)。注意我们从 GCC 得到的错误消息:
$> gcc -Wall -Wextra -pedantic -std=c89 \
> no-return.c -o no-return
no-return.c: In function 'main':
no-return.c:6:1: warning: control reaches end of non-void function [-Wreturn-type]
 }
 ^
  1. 现在,重新使用 C99 标准编译程序,并启用所有警告和严格检查。这次不应该显示错误:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> no-return.c -o no-return
  1. 重新运行程序并检查退出代码。注意区别:
$> ./no-return 
Hello, world
$> echo $?
0
  1. 编写以下程序并将其命名为for-test.c。该程序在for循环内部创建了一个i整数变量。这只在 C99 中允许:
#include <stdio.h>
int main(void)
{
    for (int i = 10; i>0; i--)
    {
        printf("%d\n", i);
    }
    return 0;
}
  1. 使用 C99 标准编译它:
$> gcc -std=c99 for-test.c -o for-test
  1. 然后运行它。一切应该正常工作:
$> ./for-test 
10
9
8
7
6
5
4
3
2
1
  1. 现在,尝试使用 C89 标准编译它。请注意,错误消息清楚地解释了这只适用于 C99 或更高版本。GCC 的错误消息很有用,所以一定要确保阅读它们。它们可以节省您很多时间:
$> gcc -std=c89 for-test.c -o for-test
for-test.c: In function 'main':
for-test.c:5:5: error: 'for' loop initial declarations are only allowed in C99 or C11 mode
     for (int i = 10; i>0; i--)
     ^~~
  1. 现在,编写以下小程序并将其命名为comments.c。在这个程序中,我们使用 C99 注释(也称为 C++注释):
#include <stdio.h>
int main(void)
{
    // A C99 comment
    printf("hello, world\n");
    return 0;
}
  1. 使用 C99 编译它:
$> gcc -std=c99 comments.c -o comments
  1. 现在,尝试使用 C89 编译它。请注意,这个错误消息也很有帮助:
$> gcc -std=c89 comments.c -o comments
comments.c: In function 'main':
comments.c:5:5: error: C++ style comments are not allowed in ISO C90
     // A C99 comment
     ^
comments.c:5:5: error: (this will be reported only once per input file)

工作原理…

这些是 C89 和 C99 之间一些更常见的差异。在 Linux 使用 GCC 时,还有其他一些差异是不明显的。我们将在本示例的还有更多…部分讨论一些看不见的差异。

我们使用 GCC 的-std选项来改变 C 标准。在这个示例中,我们尝试了两种标准,C89 和 C99。

步骤 1-6中,我们看到了当我们忘记返回值时会发生什么的区别。在 C99 中,假定返回值为 0,因为没有指定其他值。另一方面,在 C89 中,忘记返回值是不可以的。程序仍然会编译,但程序将返回值 13(错误代码),这是错误的,因为我们的程序没有发生错误。实际返回的代码可能会有所不同,但它总是大于 0。当我们启用所有警告额外警告严格检查代码(-Wall -Wextra -pedantic)时,我们还看到编译器发出了警告消息,这意味着忘记返回值是不合法的。因此,在 C89 中,始终使用return返回一个值。

然后,在步骤 7-10中,我们看到在 C99 中,在for循环内部声明一个新变量是可以的,而在 C89 中是不可以的。

步骤 11-13中,我们看到了一种使用注释的新方法,即两条斜杠//。这在 C89 中是不合法的。

还有更多…

C89 和 C99 之外还有更多的 C 标准和方言。还有C11GNU99(GNU 的 C99 方言)、GNU11(GNU 的 C11 方言)等等,但今天最常用的是 C89、C99 和 C11。C18 正在成为一些编译器和发行版的默认标准。

实际上,C89 和 C99 之间的差异比我们在这里看到的要多。在 Linux 中使用 GCC,一些差异无法演示,因为 GCC 已经为这些差异实施了解决方法。其他一些编译器也是如此。但是在 C89 中,例如,long long int类型没有被指定;它是在 C99 中指定的。但尽管如此,一些编译器(包括 GCC)在 C89 中支持long long int,但在 C89 中使用它时应该小心,因为并非所有编译器都支持它。如果要使用long long int,最好使用 C99、C11 或 C18。

我建议您始终使用-Wall-Wextra-pedantic选项编译您的程序。这些选项将警告您各种可能被忽略的问题。

使用系统调用-以及何时不使用它们

printf()fgets()putc()等。在它们下面,最低级别是系统调用,比如creat()write()等:

图 3.1-高级函数和低级函数

当我在这本书中谈论系统调用时,我指的是内核提供的 C 函数,而不是实际的系统调用表。我们在这里使用的系统调用函数驻留在用户空间,但函数本身在内核空间中执行。

许多标准的 C 库函数,比如putc(),在幕后使用一个或多个系统调用函数。putc()函数是一个很好的例子;它使用write()在屏幕上打印一个字符(这是一个系统调用)。还有一些标准的 C 库函数根本不使用任何系统调用,比如atoi(),它完全驻留在用户空间。没有必要涉及内核来将字符串转换为数字。

一般来说,如果有标准的 C 库函数可用,我们应该使用它,而不是系统调用。系统调用通常更难处理,更原始。将系统调用视为低级操作,将标准 C 函数视为高级操作。

然而,有些情况下,我们需要使用系统调用,或者它们更容易使用或更有益。学会何时以及为什么使用系统调用将使你成为一个更好的系统程序员。例如,在 Linux 上,我们可以通过系统调用执行许多文件系统操作,而这些操作在其他地方是不可用的。另一个需要使用系统调用的例子是当我们想要fork()一个进程时,这是我们稍后将更详细讨论的事情。换句话说,当我们需要执行某种形式的系统操作时,我们需要使用系统调用。

准备工作

在这个示例中,我们将使用一个特定于 Linux 的系统调用,所以你需要一台 Linux 计算机(你很可能已经有了,因为你正在阅读这本书)。但请注意,sysinfo()系统调用在 FreeBSD 或 macOS 下不起作用。

操作步骤:

实际上,在使用标准 C 库函数和使用系统调用函数之间并没有太大的区别。Linux 中的系统调用在unistd.h中声明,因此在使用系统调用时需要包含这个文件。

  1. 编写以下小程序,并将其命名为sys-write.c。它使用write()系统调用。请注意,我们这里没有包含stdio.h。因为我们没有使用任何printf()函数或任何 stdin、stdout 或 stderr 文件流,所以我们这里不需要stdio.h。我们直接打印到文件描述符 1,这是标准输出。三个标准文件描述符总是打开的:
#include <unistd.h>
int main(void)
{
    write(1, "hello, world\n", 13);
    return 0;
}
  1. 编译它。从现在开始,我们将始终包括-Wall-Wextra-pedantic来编写更清洁、更好的代码:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> sys-write.c -o sys-write
  1. 运行程序:
$> ./sys-write 
hello, world
  1. 现在,编写相同的程序,但使用fputs()函数——一个更高级的函数。请注意,我们在这里包含了stdio.h,而不是unistd.h。将程序命名为write-chars.c
#include <stdio.h>
int main(void)
{
    fputs("hello, world\n", stdout);
    return 0;
}
  1. 编译它:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> write-chars.c -o write-chars
  1. 然后运行它:
$> ./write-chars 
hello, world
  1. 现在是时候编写一个程序,读取一些用户和系统信息。将程序保存为my-sys.c。程序中的所有系统调用都已经突出显示。这个程序获取你的用户 ID、当前工作目录、机器的总和空闲的随机存取内存RAM),以及当前的进程 IDPID):
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/sysinfo.h>
int main(void)
{
   char cwd[100] = { 0 }; /* for current dir */
   struct sysinfo si; /* for system information */
   getcwd(cwd, 100); /* get current working dir */
   sysinfo(&si); /* get system information 
                  * (linux only) */

   printf("Your user ID is %d\n", getuid());
   printf("Your effective user ID is %d\n", 
      geteuid());
   printf("Your current working directory is %s\n", 
      cwd);
   printf("Your machine has %ld megabytes of " 
      "total RAM\n", si.totalram / 1024  / 1024);
   printf("Your machine has %ld megabytes of "
      "free RAM\n", si.freeram / 1024 / 1024);
   printf("Currently, there are %d processes "
      "running\n", si.procs);
   printf("This process ID is %d\n", getpid());
   printf("The parent process ID is %d\n", 
      getppid());
   return 0;
}
  1. 编译程序:
$> gcc -Wall -Wextra -pedantic -std=c99 my-sys.c -o \
> my-sys
  1. 然后运行程序。现在你应该能看到有关你的用户和你正在使用的机器的一些信息:
$> ./my-sys 
Your user ID is 1000
Your effective user ID is 1000
Your current working directory is /mnt/localnas_disk2/linux-sys/ch3/code
Your machine has 31033 megabytes of total RAM
Your machine has 6117 megabytes of free RAM
Currently, there are 2496 processes running
This process ID is 30421
The parent process ID is 11101

它是如何工作的…

步骤 1-6中,我们探讨了write()fputs()之间的区别。区别可能并不那么明显,但write()这个系统调用使用了printf()

步骤 7-9中,我们编写了一个获取一些系统和用户信息的程序。在这里,我们包含了三个特定于系统调用的头文件:unistd.hsys/types.hsys/sysinfo.h

我们已经看到了unistd.h,这是 Unix 和 Linux 系统中系统调用的一个常见头文件。sys/types.h头文件是另一个常见的系统调用头文件,特别是在从系统获取值时。这个头文件包含特殊的变量类型;例如,uid_tgid_t用于int。其他的是ino_t用于pid_t用于 PIDs,等等。

sys/sysinfo.h 头文件专门用于 sysinfo() 函数,这是一个专门为 Linux 设计的系统调用,因此在其他类 Unix 系统(如 macOS、Solaris 或 FreeBSD/OpenBSD/NetBSD)下不起作用。这个头文件声明了 sysinfo 结构,我们通过调用 sysinfo() 函数来填充它的信息。

我们在程序中使用的第一个系统调用是 getcwd(),用于获取当前工作目录。该函数有两个参数:一个缓冲区,用于保存路径,以及该缓冲区的长度。

下一个系统调用是特定于 Linux 的 sysinfo() 函数。这个函数给了我们很多信息。当函数执行时,所有数据都保存在结构 sysinfo 中。这些信息包括 man 2 sysinfo 中,您可以找到关于结构 sysinfo 中变量和它们的数据类型的信息。在代码的后面,我们使用 printf() 打印了其中一些值,例如 si.totalram,它包含了系统内存的大小。

其余的系统调用直接从 printf() 中调用,并返回整数值。

还有更多…

手册中有关 Linux 系统调用的详细信息。一个很好的起点是 man 2 introman 2 syscalls

提示

大多数系统调用在发生错误时会返回 -1。通常最好检查这个值以检测错误。

获取关于 Linux 和 Unix 特定头文件的信息

有很多特定的函数和 sysinfo()。在前面的示例中,我们已经看到了两个 POSIX 文件:unistd.hsys/types.h。由于它们是 POSIX 文件,它们在所有类 Unix 系统(如 Linux、FreeBSD、OpenBSD、macOS 和 Solaris)中都可用。

在这个示例中,我们将学习更多关于这些 POSIX 头文件的知识,它们的作用,以及何时以及如何使用它们。我们还将学习如何在手册页中查找有关这些文件的信息。

准备工作

在这个示例中,我们将在手册中查找头文件。如果您使用的是基于 Fedora 的系统,如 dnf install man-pages 作为 root 用户,或者使用 sudo

另一方面,如果您使用的是基于 Debian 的系统,如 UbuntuDebian,您需要先安装这些手册页。按照这里的说明安装此示例所需的手册页。

Debian

Debian 对不包括非自由软件更严格,因此我们需要采取一些额外的步骤。

  1. 以 root 身份在编辑器中打开 /etc/apt/sources.list

  2. 在这些行的末尾(main 之后,用一个空格隔开),在它们后面加上单词 non-free

  3. 保存文件。

  4. 以 root 身份运行 apt update

  5. 以 root 用户身份运行 apt install manpages-posix-dev 安装手册页。

Ubuntu

基于 Ubuntu 和其他基于 Ubuntu 的发行版对非自由软件不那么严格,因此我们可以立即安装正确的软件包。

只需运行 sudo apt install manpages-posix-dev

如何做…

有许多头文件需要涵盖,因此更重要的是学习如何知道我们应该使用哪些头文件,以及如何找到有关它们的信息,阅读它们的手册页,并知道如何列出它们。我们将在这里涵盖所有这些内容。

在前面的示例中,我们使用了 sysinfo()getpid() 函数。在这里,我们将学习如何找到与这些系统调用和所需的头文件相关的每一个可能的信息。

  1. 首先,我们从阅读 sysinfo() 的手册页开始:
$> man 2 sysinfo

SYNOPSIS 标题下,我们找到了以下两行:

#include <sys/sysinfo.h>
int sysinfo(struct sysinfo *info);
  1. 这些信息意味着我们需要包含 sys/sysinfo.h 来使用 sysinfo()。它还显示该函数以一个名为 sysinfo 的结构作为参数。sysinfo 结构是什么样子呢?

  2. 现在,让我们查找 getpid()。这是一个 POSIX 函数,因此有更多的信息可用:

sys/types.h and unistd.h. We also see that the function returns a value of type pid_t.
  1. 让我们继续调查。打开 sys/types.h 的手册页:
pid_t data type is used for *process IDs* and *process group IDs*, but that doesn't tell us what kind of data type it actually is. So, let's continue to scroll down until we find a subheading saying blksize_t, pid_t, and ssize_t shall be signed integer types." Mission accomplished—now, we know that it's a signed integer type and that we can use the %d formatting operator to print it.
  1. 但让我们进一步调查。让我们阅读 unistd.h 的手册页:
$> man unistd.h
  1. 现在,在这个手册页中搜索pid_t这个词,我们会找到更多关于它的信息。

输入/字符,然后输入pid_t,按Enter进行搜索。在键盘上按下字母n以搜索单词的下一个出现位置。您会发现其他函数也返回pid_t类型,例如fork()getpgrp()getsid()等。

  1. 当您阅读unistd.h的手册页时,您还可以看到在此头文件中声明的所有函数。如果找不到,请搜索Declarations。按下/,输入Declarations,然后按Enter

工作原理…

手册页在7posix0p特殊部分中,取决于您的 Linux 发行版,来自一个称为POSIX 程序员手册的东西。例如,如果您打开man unistd.h,您会看到POSIX 程序员手册,而不是man 2 write,它说Linux 程序员手册POSIX 程序员手册来自电气和电子工程师学会IEEE)和开放组织,而不是GNU 项目或 Linux 社区。

由于POSIX 程序员手册不是免费的(开源的),Debian 选择不将其包含在其主要存储库中。这就是为什么我们需要将非自由存储库添加到 Debian 中。

POSIX 是由 IEEE 指定的一组标准。标准的目的是在所有 POSIX 操作系统(大多数 Unix 和类 Unix 系统)之间具有一个共同的编程接口。如果您的程序只使用 POSIX 函数和 POSIX 头文件,它将与所有其他 Unix 和类 Unix 系统兼容。实际的实现可能因系统而异,但总体功能应该是相同的。

有时,当我们需要一些特定的信息(比如pid_t是哪种类型),我们需要阅读多个手册页,就像在这个示例中所做的那样。

这里的主要要点是使用函数的手册页来查找相应的头文件,然后使用头文件的手册页来查找更具体的信息。

还有更多…

POSIX 头文件的手册页位于手册页的特殊部分中,不在man man中列出。在 Fedora 和 CentOS 下,该部分称为0p,在 Debian 和 Ubuntu 下,称为7posix

提示

可以使用apropos命令加上一个点(点表示匹配所有)来列出特定部分中所有可用的手册页。

例如,要列出Section 2中的所有手册页,输入apropos -s 2.(包括点号—它是命令的一部分)。要列出 Ubuntu 下7posix特殊部分中的所有手册页,输入apropos -s 7posix.

定义特性测试宏

在这个示例中,我们将学习一些常见的 POSIX 标准,以及如何以及为什么使用它们,以及如何使用特性测试宏来指定它们。

我们已经看到了几个例子,当我们包含了 POSIX 标准或一些特定的 C 标准时。例如,当我们使用getopt()时,我们在源代码文件的顶部定义了_XOPEN_SOURCE 500(来自第二章使您的程序易于脚本化)。

特性测试宏控制了系统头文件中暴露的定义。我们可以以两种方式利用它。一种是通过使用特性测试宏来创建可移植的应用程序,从而防止我们使用非标准定义,另一种是相反,允许我们使用非标准定义。

准备工作

在这个配方中,我们将编写两个小程序,str-posix.cwhich-c.c。您可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch3下载它们,或者跟着编写它们。您还需要我们在第一章中安装的 GCC 编译器。还有一个好主意是要有访问所有手册页的权限,包括上一个配方中涵盖的POSIX 程序员手册中的手册页。

如何做…

在这里,我们将探索特性测试宏、POSIX 和 C 标准以及其他相关事物的内部工作的黑暗角落。

  1. 编写以下代码并将其保存在名为str-posix.c的文件中。该程序将简单地使用strdup()复制一个字符串,然后打印它。请注意,我们在这里包括string.h
#include <string.h>
#include <stdio.h>
int main(void)
{
    char a[] = "Hello";
    char *b;
    b = strdup(a);
    printf("b = %s\n", b);
    return 0;
}
  1. 现在,我们开始使用 C99 标准编译它,看看会发生什么。将打印出多个错误消息:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> str-posix.c -o str-posix
str-posix.c: In function 'main':
str-posix.c:8:9: warning: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration]
     b = strdup(a);
         ^~~~~~
         strcmp
str-posix.c:8:7: warning: assignment to 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
     b = strdup(a);
  1. 这产生了一个相当严重的警告。尽管编译成功了。如果我们尝试在一些发行版上运行程序,它会失败,但在其他发行版上不会。这就是所谓的未定义行为
$> ./str-posix 
Segmentation fault

在另一个 Linux 发行版上,我们可能会看到以下内容:

$> ./str-posix
b = Hello
  1. 现在是迷人的——有些令人困惑的——部分。这个程序有时会崩溃的原因有一个,但是有几种可能的解决方案。我们将在这里涵盖它们。但首先,它失败的原因是strdup()不是 C99 的一部分(我们将在它是如何工作的…部分解释为什么它有时会工作)。最直接的解决方案是查看手册页,手册页清楚地说明我们需要将_XOPEN_SOURCE特性测试宏设置为500或更高。为了这个实验,让我们将其设置为700(稍后我会解释为什么)。在str-posix.c的最顶部添加以下行。它需要在任何include语句之前的第一行;否则,它将不起作用:
#define _XOPEN_SOURCE 700
  1. 现在您已经添加了前面的行,让我们尝试重新编译程序:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> str-posix.c -o str-posix
  1. 这次没有警告,所以让我们运行程序:
$> ./str-posix 
b = Hello
  1. 所以,这是可能的解决方案之一,也是最明显的解决方案。现在,再次删除第一行(整个#define行)。

  2. 一旦您删除了#define行,我们将重新编译程序,但这次我们在命令行上设置特性测试宏。我们使用 GCC 中的-D标志来实现这一点:

$> gcc -Wall -Wextra -pedantic -std=c99 \
> -D_XOPEN_SOURCE=700 str-posix.c -o str-posix
  1. 让我们尝试运行它:
$> ./str-posix 
b = Hello
  1. 这是第二种解决方案。但是,如果我们阅读特性测试宏的手册页man feature_test_macros,我们会发现_XOPEN_SOURCE的值为 700 或更高时具有与将_POSIX_C_SOURCE的值定义为 200809L 或更高相同的效果。因此,让我们尝试使用_POSIX_C_SOURCE重新编译程序:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> -D_POSIX_C_SOURCE=200809L str-posix.c -o str-posix
  1. 这样做得很好。现在,进行最终的——可能危险的——解决方案。这一次,我们将重新编译程序,而不设置任何 C 标准或任何特性测试宏:
$> gcc -Wall -Wextra -pedantic str-posix.c \ 
> -o str-posix
  1. 没有警告,所以让我们尝试运行它:
$> ./str-posix 
b = Hello
  1. 当我们刚刚定义了所有这些宏和标准时,它怎么可能会工作呢?嗯,事实证明,当我们不设置任何 C 标准或特性测试宏时,编译器会设置一些自己的标准。为了证明这一点,并了解您的编译器是如何工作的,让我们编写以下程序。将其命名为which-c.c。该程序将打印正在使用的 C 标准和任何常见定义的特性测试宏:
#include <stdio.h>
int main(void)
{
   #ifdef __STDC_VERSION__
      printf("Standard C version: %ld\n", 
         __STDC_VERSION__);
   #endif
   #ifdef _XOPEN_SOURCE
      printf("XOPEN_SOURCE: %d\n", 
         _XOPEN_SOURCE);
   #endif
   #ifdef _POSIX_C_SOURCE
      printf("POSIX_C_SOURCE: %ld\n", 
         _POSIX_C_SOURCE);
   #endif
   #ifdef _GNU_SOURCE
      printf("GNU_SOURCE: %d\n", 
         _GNU_SOURCE);
   #endif
   #ifdef _BSD_SOURCE
      printf("BSD_SOURCE: %d\n", _BSD_SOURCE);
   #endif
   #ifdef _DEFAULT_SOURCE
      printf("DEFAULT_SOURCE: %d\n", 
         _DEFAULT_SOURCE);
   #endif
   return 0;
}
  1. 让我们编译并运行这个程序,而不设置任何 C 标准或特性测试宏:
$> gcc -Wall -Wextra -pedantic which-c.c -o which-c
$> ./which-c 
Standard C version: 201710
POSIX_C_SOURCE: 200809
DEFAULT_SOURCE: 1
  1. 让我们尝试指定我们要使用 C 标准 C99,并重新编译which.c。这里会发生的是编译器将强制执行严格的 C 标准模式,并禁用它可能设置的默认特性测试宏:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> which-c.c -o which-c
$> ./which-c 
Standard C version: 199901
  1. 让我们看看当我们将_XOPEN_SOURCE设置为600时会发生什么:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> -D_XOPEN_SOURCE=600 which-c.c -o which-c
$> ./which-c 
Standard C version: 199901
XOPEN_SOURCE: 600
POSIX_C_SOURCE: 200112

它是如何工作的…

步骤 1-10中,我们看到了当我们使用不同的标准和特性测试宏时,我们的程序发生了什么。我们还注意到,即使没有指定任何 C 标准或特性测试宏,它也奇迹般地工作了。这是因为 GCC 和其他编译器会默认设置许多这些特性和标准。但我们不能依赖它。最安全的方式是自己指定;这样,我们就知道它会工作。

步骤 13中,我们编写了一个程序来打印编译时使用的特性测试宏。为了防止编译器在没有设置特性测试宏时生成错误,我们将所有的printf()行包裹在#ifdef#endif语句中。这些语句是编译器的if语句,而不是最终的程序。例如,让我们看下面这行:

#ifdef _XOPEN_SOURCE
    printf("XOPEN_SOURCE: %d\n", _XOPEN_SOURCE);
#endif

如果_XOPEN_SOURCE没有定义,那么printf()行就不会被包含;另一方面,如果定义了_XOPEN_SOURCE,它就会被包含。我们将在下一个步骤中介绍预处理是什么。

步骤 14中,我们看到在我的系统上,编译器将_POSIX_C_SOURCE设置为200809。但手册中说我们应该将_XOPEN_SOURCE设置为500或更高。但它仍然有效——为什么呢?

如果我们阅读特性测试宏的手册页(man feature_test_macros),我们会发现大于700_XOPEN_SOURCE的值具有与将_POSIX_C_STANARD设置为200809或更高相同的效果。而且由于 GCC 已经为我们设置了_POSIX_C_STANDARD200809,这与_XOPEN_SOURCE 700具有相同的影响。

步骤 15中,我们了解到当我们指定一个标准时,比如-std=c99,编译器会强制执行严格的 C 标准。这就是为什么str-posix.c无法运行(并在编译过程中收到警告消息)。strdup()函数不是标准的 C 函数;它是一个 POSIX 函数。这就是为什么我们需要包含一些 POSIX 标准来使用它。当编译器使用严格的 C 标准时,不会启用其他特性。这使我们能够编写可在所有支持 C99 的 C 编译器的系统上运行的代码。

步骤 16中,我们在编译程序时指定了_XOPEN_SOURCE 600,这也将_POSIX_C_STANDARD设置为200112。我们可以在手册页(man feature_test_macros)中了解这一点。从手册中得知:“[当] _XOPEN_SOURCE 被定义为大于或等于 500 的值时,以下宏会被隐式定义,_POSIX_C_SOURCE[...]”。

那么特性宏到底是做什么的?它们如何修改代码?

系统的头文件中充满了#ifdef语句,根据设置的特性测试宏启用或禁用各种函数和特性。例如,在我们的情况下,对于strdup()string.h头文件中将strdup()函数包裹在#ifdef语句中。这些语句检查是否定义了_XOPEN_SOURCE或其他一些 POSIX 标准。如果没有指定这些标准,那么strdup()就不可见。这就是特性测试宏的工作原理。

但是为什么在步骤 3中,程序在某些 Linux 发行版上以分段错误结束,而在其他发行版上却没有?如前所述,strdup()函数是存在的,但是没有特性测试宏,它就没有声明。那么会发生未定义的情况。它可能会因为某些特定的实现细节而工作,但也可能不工作。当我们编程时,应该始终避免未定义的行为。仅仅因为某些东西在这台特定的计算机上工作,在这个 Linux 发行版上,在这个编译器版本上,在这个特定的月夜上工作,这并不保证它会在别人的计算机上在其他的夜晚上工作。因此,我们应该始终努力编写正确的代码,遵循特定的标准。这样,我们就可以避免未定义的行为。

还有更多...

我们定义的所有这些特性测试宏都对应于某种 POSIX 或其他标准。这些标准背后的想法是在不同的 Unix 版本和类 Unix 系统之间创建统一的编程接口。

对于那些想深入了解标准和特性测试宏的人,有一些优秀的手册页面可供阅读。只是举几个例子:

  • man 7 feature_test_macros(在这里,您可以阅读有关哪些特性测试宏对应于哪些标准的所有信息,例如 POSIX、Single Unix 规范、XPG(X/Open 可移植性指南)等等。)

  • man 7 standards(有关标准的更多信息)

  • man unistd.h

  • man 7 libc

  • man 7 posixoptions

查看编译的四个阶段

当我们通常谈论编译时,我们指的是将代码转换为运行的二进制程序的整个过程。但实际上,在将源代码文件编译为运行的二进制程序时涉及四个步骤,而只有一个步骤被称为编译。

了解这四个步骤,以及如何提取中间文件,使我们能够从编写高效的 Makefile 到编写共享库等所有事情。

准备完成

对于这个示例,我们将编写三个小的 C 源代码文件。您也可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch3下载它们。您还需要我们在第一章中安装的 GCC 编译器,获取必要的工具并编写我们的第一个 Linux 程序

如何做…

在这个示例中,我们将创建一个小程序,然后通过逐个执行每个步骤来手动编译它,使用编译器的标志。我们还将查看从每个步骤生成的文件。我们将编写的程序故意很小,以便我们可以在没有太多混乱的情况下查看生成的代码。我们将编写的程序将简单地返回一个立方数——在我们的例子中是 4 的立方。

  1. 这个示例的第一个源代码文件是一个名为cube-prog.c的文件。这将是带有main()函数的源代码文件:
#include "cube.h"
#define NUMBER 4
int main(void)
{
    return cube(NUMBER);
}
  1. 现在,我们在一个名为cubed-func.c的文件中编写cube()的函数:
int cube(int n)
{
    return n*n*n;
}
  1. 最后,我们编写头文件cube.h。这只是函数原型:
int cube(int n);
  1. 在我们逐步构建程序之前,我们首先像往常一样编译它,因为我们还没有涵盖如何编译由多个文件组成的程序。要编译由多个源文件组成的程序,我们只需在 GCC 命令行中列出它们。但是请注意,我们在这里不列出头文件。由于头文件包含了#include行,编译器已经知道它。

这就是我们如何编译一个由几个文件组成的程序:

$> gcc -Wall -Wextra -pedantic -std=c99 \
> cube-prog.c cube-func.c -o cube
  1. 然后,让我们运行它,并检查返回值:
$> ./cube 
$> echo $?
64
  1. 现在,我们开始逐步构建程序。首先,我们删除已经生成的二进制文件:
$> rm cube
  1. 现在,让我们逐步开始编译程序。第一步是在程序本身中所谓的#include文件:
$> gcc -E -P cube-prog.c -o cube-prog.i
$> gcc -E -P cube-func.c -o cube-func.i
  1. 现在,我们有两个预处理文件(cube-prog.icube-func.i)。让我们用cat或编辑器来看看它们。我已经在下面的代码片段中突出显示了更改。请注意#include语句已被替换为头文件中的代码,以及NUMBER宏已被替换为4

首先,我们来看看cube-prog.i

cube-func.i. Nothing has changed here:

int cube(int n)

{

return nnn;

}


  1. 第二步是编译。在这里,我们的预处理文件被翻译成汇编语言。生成的汇编文件在不同的机器和架构上看起来会有所不同:
$> gcc -S cube-prog.i -o cube-prog.s
$> gcc -S cube-func.i -o cube-func.s
  1. 让我们也看看这些文件,但请注意,这些文件在您的机器上可能会有所不同。

首先,我们来看看cube-prog.s

	.file	"cube-prog.i"
	.text
	.globl	main
	.type	main, @function
main:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	$4, %edi
	call	cube@PLT
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	main, .-main
	.ident	"GCC: (Debian 8.3.0-6) 8.3.0"
	.section	.note.GNU-stack,"",@progbits

现在,我们来看看cube-func.s

	.file	"cube-func.i"
	.text
	.globl	cube
	.type	cube, @function
cube:
.LFB0:
	.cfi_startproc
	pushq	%rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq	%rsp, %rbp
	.cfi_def_cfa_register 6
	movl	%edi, -4(%rbp)
	movl	-4(%rbp), %eax
	imull	-4(%rbp), %eax
	imull	-4(%rbp), %eax
	popq	%rbp
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc
.LFE0:
	.size	cube, .-cube
	.ident	"GCC: (Debian 8.3.0-6) 8.3.0"
	.section	.note.GNU-stack,"",@progbits
  1. 第三步称为汇编。这一步是将汇编源代码文件构建为所谓的目标文件的过程:
$> gcc -c cube-prog.s -o cube-prog.o
$> gcc -c cube-func.s -o cube-func.o
  1. 现在,我们有两个目标文件。我们无法查看它们,因为它们是二进制文件,但我们可以使用file命令来查看它们是什么。这里的描述也可能因不同的架构而有所不同——例如,32 位 x86 机器,ARM64 等等:
$> file cube-prog.o cube-prog.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped $> file cube-func.o cube-func.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  1. 现在,我们来到了第四个也是最后一个步骤。这是将所有目标文件合并成单个二进制文件的过程。这一步被称为链接器
$> gcc cube-prog.o cube-func.o -o cube
  1. 现在,我们有一个名为cube的准备好的二进制文件。让我们看看file对它的评价:
$> file cube
cube: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=53054824b4a495b7941cbbc95b550e7670481943, not stripped
  1. 最后,让我们运行它来验证它是否工作:
$> ./cube 
$> echo $?
64

它是如何工作的…

步骤 7(过程中的第一步),我们使用了-E-P选项来生成预处理文件-E选项使 GCC 在预处理文件后停止——也就是说,创建预处理文件。-P选项是预处理器不在预处理文件中包含行标记的选项。我们需要干净的输出文件。

所有的#include语句都会在预处理文件中包含这些文件的内容。同样,任何宏—比如NUMBERS—都会被实际数字替换。预处理文件通常具有.i扩展名。

步骤 9(过程中的第二步),我们编译了预处理文件。编译步骤创建了汇编语言文件。对于这一步,我们使用了-S选项,告诉 GCC 在编译过程完成后停止。汇编文件通常具有.s扩展名。

步骤 11(过程中的第三步),我们汇编了文件。这一步也称为汇编阶段。这一步将汇编语言文件转换为目标文件。在本书的后面,当我们创建库时,我们将使用目标文件。-c选项告诉 GCC 在汇编阶段(或编译完成后)停止。目标文件通常具有.o扩展名。

然后,在步骤 13(过程中的第四个也是最后一个步骤),我们链接了文件,创建了一个可以执行的单个二进制文件。这一步不需要任何选项,因为 GCC 的默认操作是运行所有步骤,最后将文件链接到一个单个的二进制文件中。在我们链接文件之后,我们得到了一个名为cube的运行中的二进制文件:

图 3.2 - 编译的四个阶段

图 3.2 - 编译的四个阶段

使用 Make 进行编译

我们已经看到了一些使用Make的示例。在这里,我们将回顾一下 Make 是什么,以及我们如何使用它来编译程序,这样我们就不必输入 GCC 命令了。

准备工作

对于这个步骤,你所需要的只是 GCC 编译器和 Make。如果你遵循第一章获取必要的工具并编写我们的第一个 Linux 程序,那么你已经安装了这些工具。

操作步骤…

我们将编写一个小程序,用于计算给定半径的圆的周长。然后我们将使用 Make 工具来编译它。Make 工具足够智能,可以找出源代码文件的名称。

  1. 编写以下代码,并将其保存为circumference.c。这个程序是建立在上一章的mph-to-kph.c代码的基础上的:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define PI 3.14159
int main(void)
{
   char radius[20] = { 0 };
   while(fgets(radius, sizeof(radius), stdin) 
      != NULL)
   {
      /* Check if radius is numeric 
       * (and do conversion) */
      if( strspn(radius,"0123456789.\n") == 
         strlen(radius) )
      {
         printf("%.5f\n", PI*(atof(radius)*2) );
      }
      /* If radius is NOT numeric, print error 
       * and return */
      else
      {
         fprintf(stderr, "Found non-numeric "
            "value\n");
         return 1;
      }
   }
   return 0;
}
  1. 现在,让我们用 Make 来编译它:
$> make circumference
cc     circumference.c   -o circumference
  1. 如果我们尝试重新编译它,它只会告诉我们程序是最新的:
$> make circumference
make: 'circumference' is up to date
  1. PI宏的小数位数增加到 8 位,变为 3.14159265。代码中的第四行现在应该是这样的:
#define PI 3.14159265

在进行更改后保存文件。

  1. 如果我们现在尝试重新编译程序,它会这样做,因为它注意到代码已经改变了:
$> make circumference
cc     circumference.c   -o circumference
  1. 让我们试试这个程序:
$> ./circumference 
5
31.41593
10
62.83185
103.3
649.05304
*Ctrl*+*D*

它是如何工作的…

Make 工具是一个用于简化大型项目编译的工具,但即使对于像这样的小程序也很有用。

当我们执行make circumference时,它假设我们想要构建一个名为circumference的程序,其源代码文件是circumference.c。它还假设我们的编译器命令是cc(在大多数 Linux 系统上,ccgcc的链接),并使用cc circumference.c -o circumference命令编译程序。这个命令与我们自己编译程序时运行的命令相同,只是我们使用了真实的名字—gcc—而不是cc。在下一个示例中,我们将学习如何更改这个默认命令。

Make 工具还足够智能,不会重新编译程序,除非有必要。这个功能在大型项目中非常有用,因为重新编译可能需要几个小时。只重新编译已更改的文件可以节省大量时间。

使用 GCC 选项编写通用的 Makefile

在上一个示例中,我们学习了 Make 使用cc prog.c -o prog命令编译程序。在这个示例中,我们将学习如何更改默认命令。为了控制默认命令,我们编写一个Makefile并将该文件放在与源文件相同的目录中。

为所有项目编写通用的 Makefile 是一个很好的主意,因为你可以为所有编译的文件启用-Wall-Wextra-pedantic。启用这三个选项后,GCC 会警告你的代码中更多的错误和不规范之处,使你的程序更加完善。这就是我们将在这个示例中做的事情。

准备工作

在这个示例中,我们将使用我们在上一个示例中编写的circumference.c源代码文件。如果你的计算机上还没有这个文件,你可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch3/circumference.c下载它。

操作步骤…

在这里,我们将编写一个通用的 Makefile,你可以用它来确保你的所有项目都遵循 C99 标准,并且不包含任何明显的错误。

  1. 写下以下代码,并将其保存为一个名为Makefile的文件,放在与circumference.c相同的目录中。这个 Makefile 设置了你的默认编译器和一些常见的编译器选项:
CC=gcc
CFLAGS=-Wall -Wextra -pedantic -std=c99
  1. 现在,如果你还有上一个示例中的circumference二进制文件,就把它删除掉。如果你没有,就跳过这一步。

  2. 现在,使用 Make 编译circumference程序,并注意编译命令如何与上一个示例中的不同。我们刚刚在 Makefile 中指定的选项现在应该已经生效了:

$> make circumference
gcc -Wall -Wextra -pedantic -std=c99    circumference.c   -o circumference
  1. 运行程序以确保它能正常工作:
$> ./circumference 
5
31.41590
10
62.83180
15
94.24770
*Ctrl*+*D*

工作原理…

我们创建的 Makefile 控制了 Make 的行为。由于这个 Makefile 并不是为任何特定的项目编写的,它适用于同一目录中的所有程序。

在 Makefile 的第一行,我们使用特殊的CC变量将编译器设置为gcc。在第二行,我们使用特殊的CFLAGS变量将标志设置给编译器。我们将这个变量设置为-Wall -Wextra -pedantic -std=c99

当我们执行make时,它会组合CC变量和CFLAGS变量,得到一个gcc -Wall -Wextra -pedantic -std=c99的命令。正如我们在上一个示例中学到的,Make 假设我们希望使用的二进制文件名与我们给定的名字相同。它还假设源代码文件具有相同的名字,只是以.c结尾。

即使在一个只有一个文件的小项目中,Make 也可以帮我们节省每次重新编译时输入长长的 GCC 命令的时间。这就是 Make 的全部意义:节省我们的时间和精力。

还有更多…

如果你想了解更多关于 Make 的信息,你可以阅读man 1 make。在info make中还有更详细的信息。如果你没有info命令,你需要首先以 root 身份使用你的包管理器安装它。在大多数 Linux 发行版中,这个包叫做info

编写一个简单的 Makefile

在这个示例中,我们将学习如何为一个特定项目编写 Makefile。我们在上一个示例中编写的 Makefile 是通用的,但这将只针对一个项目。了解如何为你的项目编写 Makefile 将为你节省大量时间和精力,因为你开始制作更复杂的程序。

此外,在项目中包含一个 Makefile 被认为是一种良好的习惯。下载你的项目的人通常不知道如何构建它。那个人只想使用你的程序,而不是被迫理解如何将事物组合在一起以及如何编译它。例如,在下载了一个开源项目之后,他们希望只需输入makemake install(或者可能还有一些形式的配置脚本),程序就应该准备好运行了。

准备工作

对于这个示例,我们将使用本章中“查看编译的四个阶段”示例中制作的cube程序。我们将使用的源代码文件是cube-prog.ccube-func.ccube.h。它们都可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch3下载。

将这三个文件保存在一个新的独立目录中,最好叫做cube。确保你在创建 Makefile 时在该目录中。

如何做…

在我们开始编写代码之前,请确保你在保存了cube程序源代码文件的目录中。

  1. 让我们为cube程序创建 Makefile。将文件保存为Makefile。在这个 Makefile 中,我们只有一个目标cube。在目标下面,我们有编译程序的命令:
CC=gcc
CFLAGS=-Wall -Wextra -pedantic -std=c99
cube: cube.h cube-prog.c cube-func.c
    $(CC) $(CFLAGS) -o cube cube-prog.c cube-func.c
  1. 现在,是时候尝试使用 Make 构建程序了:
$> make
gcc -Wall -Wextra -pedantic -std=c99 -o cube cube-prog.c cube-func.c
  1. 最后,我们执行程序。不要忘记也检查返回值:
$> ./cube 
$> echo $?
64
  1. 如果我们现在尝试重新构建程序,它会说一切都是最新的,这是正确的。让我们试试:
$> make
make: 'cube' is up to date.
  1. 但是,如果我们更改了一个源代码文件中的内容,它将重新构建程序。让我们将NUMBER宏更改为2cube-prog.c文件中的第二行现在应该是这样的:
#define NUMBER 2
  1. 现在,我们可以使用 Make 重新编译程序了:
$> make
gcc -Wall -Wextra -pedantic -std=c99 -o cube cube-prog.c cube-func.c
  1. 然后,让我们查看对我们的程序所做的更改:
$> ./cube 
$> echo $?
8
  1. 现在,删除cube程序,以便我们可以尝试在下一步中重新编译它:
$> rm cube
  1. 将源代码文件中的一个文件重命名,例如将cube.h重命名为cube.p
$> mv cube.h cube.p
  1. 如果我们现在尝试重新编译它,Make 会抱怨缺少cube.h并拒绝继续进行:
$> make
make: *** No rule to make target 'cube.h', needed by 'cube'.  Stop.

它是如何工作的…

我们已经在 Makefile 中看到了前两行。第一行CC将默认的 C 编译器设置为gcc。第二行CFLAGS设置了我们想要传递给编译器的标志。

下一行——以cube:开头的那一行——被称为目标。紧接着目标,在同一行上,我们列出了这个目标所依赖的所有文件,这些文件都是源代码文件和头文件。

在目标下面,我们有一行缩进的内容:

$(CC) $(CFLAGS) -o cube cube-prog.c cube-func.c

这一行是编译程序的命令。$(CC)$(CFLAGS)将被替换为这些变量的内容,即gcc-Wall -Wextra -pedantic -std=c99。基本上,我们只是在 Makefile 中写了我们通常在命令行中写的内容。

在下一个示例中,我们将学习如何利用 Make 中的一些更智能的功能。

编写更高级的 Makefile

在上一个示例中,我们编写了一个基本的 Makefile,没有使用任何更高级的功能。然而,在这个示例中,我们将编写一个更高级的 Makefile,使用对象文件、更多的变量、依赖关系和其他花哨的东西。

在这里,我们将创建一个新的程序。该程序将计算三种不同对象的面积:圆、三角形和矩形。每个计算将在其自己的函数中执行,每个函数都将驻留在自己的文件中。此外,我们将在一个单独的文件中拥有一个帮助文本的函数。还将有一个包含所有函数原型的头文件。

准备工作

这个项目将包括总共七个文件。如果你愿意,你可以选择从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch3/area目录下载所有文件。

由于我们将为这个项目创建一个 Makefile,我真的建议您将所有项目文件放在一个新的独立目录中。

您还需要在第一章中安装 Make 工具和 GCC 编译器,获取必要的工具并编写我们的第一个 Linux 程序

如何做…

首先,我们编写了这个程序所需的所有代码文件。然后,我们尝试使用 Make 编译程序,最后,我们尝试运行它。跟着做。

  1. 让我们从编写一个名为area.c的主程序文件开始。这是程序的主要部分,包含main()函数:
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <unistd.h>
#include "area.h"
int main(int argc, char *argv[])
{
    int opt;
    /* Sanity check number of options */
    if (argc != 2)
    {
        printHelp(stderr, argv[0]);
        return 1;
    }
    /* Parse command-line options */    
    while ((opt = getopt(argc, argv, "crth")) != -1)
    {
        switch(opt)
        {
            case 'c':
                if (circle() == -1)
                {
                    printHelp(stderr, argv[0]);
                    return 1;
                }
                break;
            case 'r':
                if (rectangle() == -1)
                {
                    printHelp(stderr, argv[0]);
                    return 1;
                }
                break;
            case 't':
                if (triangle() == -1)
                {
                    printHelp(stderr, argv[0]);
                    return 1;
                }
                break;
           case 'h':
                printHelp(stdout, argv[0]);
                return 0;
           default:
                printHelp(stderr, argv[0]);
                return 1;
        }
  1. 接下来,我们编写名为area.h的头文件。该文件包含所有函数原型:
void printHelp(FILE *stream, char progname[]);
int circle(void);
int rectangle(void);
int triangle(void);
  1. 现在,在其自己的文件中添加help函数,名为help.c:(Shankar)
#include <stdio.h>void printHelp(FILE *stream, char progname[ ])
{
      fprintf(stream, "\nUsage: %s [-c] [-t] [-r] "
      "[-h]\n"
      "-c calculates the area of a circle\n"
      "-t calculates the area of a triangle\n"
      "-r calculates the area of a rectangle\n"
      "-h shows this help\n"
      "Example: %s -t\n"
      "Enter the height and width of the "
      "triangle: 5 9\n"
      "22.500\n", progname, progname);
}
  1. 现在,让我们编写一个用于计算圆面积的函数。我们将其写在一个名为circle.c的文件中:
#define _XOPEN_SOURCE 700
#include <math.h>
#include <stdio.h>
int circle(void)
{
    float radius;
    printf("Enter the radius of the circle: ");
    if (scanf("%f", &radius))
    {
        printf("%.3f\n", M_PI*pow(radius, 2));
        return 1;
    }
    else
    {
        return -1;
    }  
}
  1. 接下来是一个用于计算矩形面积的函数。我们将这个文件命名为rectangle.c
#include <stdio.h>
int rectangle(void)
{
    float length, width;
    printf("Enter the length and width of "
        "the rectangle: ");
    if (scanf("%f %f", &length, &width))
    {
        printf("%.3f\n", length*width);
        return 1;
    }
    else
    {
        return -1;
    }
}
  1. 最后一个函数是用于计算三角形面积的函数。我们将这个文件命名为triangle.c
#include <stdio.h>
int triangle(void)
{
    float height, width;
    printf("Enter the height and width of "
        "the triangle: ");
    if (scanf("%f %f", &height, &width))
    {
        printf("%.3f\n", height*width/2);
        return 1;
    }
    else
    {
        return -1;
    }
}
  1. 现在是令人兴奋的部分:Makefile。请注意,Makefile 中的缩进必须精确为一个制表符。请注意,area目标使用OBJS变量列出所有对象文件。此目标的命令$(CC) -o area $(OBJS) $(LIBS)将所有对象文件链接成一个单一的二进制文件,使用所谓的链接器。但由于链接器依赖于所有对象文件,因此在链接之前,Make 会先构建它们:
CC=gcc
CFLAGS=-std=c99 -Wall -Wextra -pedantic
LIBS=-lm
OBJS=area.o help.o rectangle.o triangle.o circle.o
DEPS=area.h
bindir=/usr/local/bin
area: $(OBJS)
	$(CC) -o area $(OBJS) $(LIBS)
area.o: $(DEPS)
clean:
	rm area $(OBJS)
install: area
	install -g root -o root area $(bindir)/area
uninstall: $(bindir)/area
	rm $(bindir)/area
  1. 最后,我们可以尝试通过输入make来编译整个程序。请注意,您必须在与源代码文件和 Makefile 相同的目录中。请注意,所有的对象文件都会先被编译,然后它们会在最后一步被链接:
$> make
gcc -std=c99 -Wall -Wextra -pedantic   -c -o area.o area.c
gcc -std=c99 -Wall -Wextra -pedantic   -c -o help.o help.c
gcc -std=c99 -Wall -Wextra -pedantic   -c -o rectangle.o rectangle.c
gcc -std=c99 -Wall -Wextra -pedantic   -c -o triangle.o triangle.c
gcc -std=c99 -Wall -Wextra -pedantic   -c -o circle.o circle.c
gcc -o area area.o help.o rectangle.o triangle.o circle.o -lm
  1. 现在,让我们尝试运行程序。测试所有不同的函数:
$> ./area -c
Enter the radius of the circle: 9
254.469
$> ./area -t
Enter the height and width of the triangle: 9 4
18.000
$> ./area -r
Enter the length and width of the rectangle: 5.5 4.9
26.950
$> ./area -r
Enter the length and width of the rectangle: abcde 
Usage: ./area [-c] [-t] [-r] [-h]
-c calculates the area of a circle
-t calculates the area of a triangle
-r calculates the area of a rectangle
-h shows this help
Example: ./area -t
Enter the height and width of the triangle: 5 9
22.500
  1. 现在,让我们假设我们已经通过更新时间戳来更改了circle.c文件的某些部分。我们可以通过在文件上运行touch来更新文件的时间戳:
$> touch circle.c
  1. 现在,我们重新构建程序。比较步骤 8的输出,那里所有的对象文件都被编译。这一次,唯一重新编译的文件是circle.o。在circle.o重新编译之后,二进制文件被重新链接成一个单一的二进制文件:
$> make
gcc -std=c99 -Wall -Wextra -pedantic   -c -o circle.o circle.c
gcc -o area area.o help.o rectangle.o triangle.o circle.o -lm
  1. 现在,让我们尝试使用install目标将程序安装到系统上。为了成功,您需要以 root 身份运行它,可以使用susudo
$> sudo make install
install -g root -o root area /usr/local/bin/area
  1. 让我们从系统中卸载程序。包括一个uninstall目标是个好习惯,特别是如果install目标在系统上安装了大量文件:
$> sudo make uninstall
rm /usr/local/bin/area
  1. 让我们也尝试一下名为clean的目标。这将删除所有对象文件和二进制文件。包括一个用于清理对象文件和其他临时文件的目标是个好习惯:
$> make clean
rm area area.o help.o rectangle.o triangle.o circle.o

它是如何工作的…

尽管这个配方的程序示例相当大,但它是一个非常直接的程序。然而,其中一些部分值得评论。

所有的 C 文件都会独立地编译成对象文件。这就是为什么我们需要在每个使用printf()scanf()的文件中包含stdio.h的原因。

circle.c文件中,我们包含了math.h头文件。这个头文件是为了pow()函数。我们还定义了_XOPEN_SOURCE,值为700。原因是M_PI宏,它保存了 Pi 的值,没有包含在 C 标准中,但是,另一方面,它包含在X/Open标准中。

Makefile

现在,是时候更详细地讨论 Makefile 了。我们已经在之前的示例中看到了前两个变量CCCFLAGS,但请注意,我们在代码中没有使用CFLAGS变量。我们不需要。在编译目标文件时,CFLAGS会自动应用。如果我们在area目标的命令中手动应用了CC变量后的CFLAGS变量,那些标志也会被用于链接过程。换句话说,我们为名为area的目标指定的命令只是用于链接阶段。目标文件的编译会自动发生。由于目标文件是一个依赖项,Make 会尝试自行找出如何构建它们。

当我们运行 Make 而没有指定目标时,Make 会运行 Makefile 中的第一个目标。这就是为什么我们将area目标放在文件中的第一位的原因,这样当我们简单地输入make时,程序就会被构建。

然后,我们有LIBS=-lm。这个变量被添加到area目标的末尾,以链接到数学库,但请注意,只有链接器才会使用它。看一下步骤 8中的输出。所有目标文件都像往常一样被编译,但在最后阶段,当链接器将所有目标文件组装成一个单一的二进制文件时,-lm被添加到末尾。

然后,我们有以下一行:

OBJS=area.o help.o rectangle.o triangle.o circle.o

这个变量列出了所有的目标文件。这就是 Make 变得非常智能的地方。我们第一次使用OBJS的地方是area目标的依赖项。为了组合area二进制程序,我们需要所有的目标文件。

我们下一个使用OBJS的地方是area二进制构建命令。请注意,我们这里没有指定 C 文件,只有目标文件(通过OBJS)。Make 足够智能,可以找出构建二进制文件所需的首先是目标文件,而要编译目标文件,我们需要与目标文件同名的 C 文件。因此,我们不需要详细列出包含所有源代码文件的整个命令。Make 会自行找出这一切。

下一个新变量是DEPS。在这个变量中,我们列出了构建area.o目标文件所需的头文件。我们在area.o: $(DEPS)行上指定了这个依赖项。这个目标不包含任何命令;我们只是用它来验证依赖项。

最后一个变量是bindir,它包含了二进制文件应该安装的完整路径。这个变量在installuninstall目标中使用,接下来我们将讨论这些目标。

我们已经在关于变量的讨论中涵盖了areaarea.o目标。所以,让我们继续讨论cleaninstalluninstall目标。这些目标在大多数项目中都很常见。包含它们被认为是礼貌的。它们与编译和构建程序无关,但它们帮助最终用户在系统上安装和卸载软件。clean目标帮助最终用户保持源代码目录干净,不包含目标文件等临时文件。每个目标下的命令都是典型的 Linux 命令,结合了我们已经涵盖的变量。

install目标中使用的install命令将area文件复制到bindir指向的位置(在我们的例子中是/usr/local/bin)。它还为安装的文件设置用户和组。

请注意,我们已经为installuninstall目标指定了依赖项(依赖项是要安装或移除的文件)。这是有道理的;如果文件不存在,就没有必要运行这些命令。但对于clean目标,我们没有指定任何依赖关系。用户可能已经自己删除了一些目标文件。当他们运行make clean时,他们不希望整个目标失败,而是希望继续删除任何剩余的文件。

第四章:处理程序中的错误

在本章中,我们将学习在 Linux 中的 C 程序中的错误处理,具体来说是如何捕获错误并打印相关信息。我们还将学习如何将这些知识与我们之前学到的关于stdinstdoutstderr的知识结合起来。

我们将继续学习系统调用的路径,并了解一个特定的变量称为errno。大多数系统调用在发生错误时使用这个变量保存特定的错误值。

在程序中处理错误将使它们更加稳定。错误确实会发生;只是要正确处理它们。一个良好处理的错误对最终用户来说不会看起来像是错误。例如,不要让你的程序在硬盘已满时以某种神秘的方式崩溃,最好是捕获错误并打印一个人类可读且友好的消息。这样,对最终用户来说,它只是信息而不是错误。这反过来会使你的程序看起来更友好,最重要的是更加稳定。

在本章中,我们将涵盖以下食谱:

  • 为什么错误处理在系统编程中很重要

  • 处理一些常见错误

  • 错误处理和errno

  • 处理更多的errno

  • 使用strerror()errno

  • 使用perror()errno

  • 返回一个错误值

让我们开始吧!

技术要求

对于本章,你将需要 GCC 编译器、Make 工具以及所有手册页(dev 和 POSIX)已安装。我们在第一章**中介绍了如何安装 GCC 和 Make,以及在第三章中介绍了手册页,深入 Linux 中的 C。你还需要我们在*第三章中创建的通用 Makefile。将该文件放在你为本章编写代码的同一目录中。你将在 GitHub 文件夹中找到该文件的副本,以及我们在这里编写的所有其他源代码文件,网址为github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch4

点击以下链接查看代码演示视频:bit.ly/39rJIdQ

为什么错误处理在系统编程中很重要

这个食谱是对错误处理是什么的一个简短介绍。我们还将看到一个常见错误的例子:权限不足。掌握这些基本技能将使你在长远的道路上成为一个更好的程序员。

准备工作

对于这个食谱,你只需要 GCC 编译器,最好是通过元包或组安装,就像我们在第一章**中介绍的那样,获取必要的工具并编写我们的第一个 Linux 程序。确保在本食谱的源代码所在的同一目录中放置技术要求*部分提到的 Makefile。

如何做…

按照以下步骤来探索一个常见错误以及如何处理它:

  1. 首先,我们将编写没有任何simple-touch-v1.c的程序。该程序将创建一个用户指定的空文件作为参数。PATH_MAX宏对我们来说是新的。它包含我们在 Linux 系统上可以在路径中使用的最大字符数。它在linux/limits.h头文件中定义:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
          "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], PATH_MAX-1);
   creat(filename, 00644);
   return 0;
}
  1. 编译程序:
$> make simple-touch-v1
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v1.c   -o simple-touch-v1
  1. 现在,让我们尝试运行程序并看看会发生什么。如果我们不给它任何参数,它将打印一个错误消息并返回1。当我们给它一个不存在的文件时,它将以权限 644 创建它(我们将在下一章中介绍权限):
$> ./simple-touch-v1 
You must supply a filename as an argument
$> ./simple-touch-v1 my-test-file
$> ls -l my-test-file 
-rw-r--r-- 1 jake jake 0 okt 12 22:46 my-test-file
  1. 让我们看看如果我们尝试在我们的家目录之外创建一个文件会发生什么;也就是说,在一个我们没有写权限的目录:
$> ./simple-touch-v1 /abcd1234
  1. 这似乎已经起作用,因为它没有抱怨,但实际上并没有。让我们尝试检查文件:
$> ls -l /abcd1234
ls: cannot access '/abcd1234': No such file or directory
  1. 让我们重写文件,以便在creat()无法创建文件时向 stderr 打印错误消息——无法创建文件。为了实现这一点,我们将整个对creat()的调用包装在一个if语句中。将新版本命名为simple-touch-v2.c。与上一个版本的更改在这里突出显示:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], PATH_MAX-1);
   if ( creat(filename, 00644) == -1 )
   {
fprintf(stderr, "Can't create file %s\n", 
         filename);
      return 1;
   }
   return 0;
}
  1. 编译新版本:
$> make simple-touch-v2
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v2.c   -o simple-touch-v2
  1. 最后,让我们重新运行它,既使用我们可以创建的文件,又使用我们无法创建的文件。当我们尝试创建一个我们没有权限创建的文件时,我们将收到一个错误消息,指出无法创建文件
$> ./simple-touch-v2 hello123
$> ./simple-touch-v2 /abcd1234
Couldn't create file /abcd1234

它是如何工作的...

在这个示例中,我们使用了一个系统调用creat(),它在文件系统上创建一个文件。该函数有两个参数:第一个是要创建的文件,第二个是新创建的文件应该具有的文件访问模式。在这种情况下,我们设置了文件的644,这是用户拥有文件的读写权限,对所有者的组和其他所有人的读权限。我们将在第五章**,使用文件 I/O 和文件系统操作中更深入地介绍文件访问模式。

如果它无法创建我们要求创建的文件,不会发生任何“坏事”。它只是将-1 返回给调用函数(在这种情况下是main())。这意味着在我们的程序的第一个版本中,似乎一切都很顺利,文件已经创建了,而实际上并没有。作为程序员,我们需要捕获返回代码并对其进行操作。我们可以在函数的手册页man 2 creat中找到函数的返回值。

在程序的第二个版本中,我们添加了一个if语句来检查-1。如果函数返回-1,则会向 stderr 打印错误消息,并返回 1 给 shell。我们现在已经通知了用户和可能依赖于该程序来创建文件的任何程序。

获取函数的返回值是检查错误的最常见和最直接的方法。我们都应该养成这个习惯。一旦我们使用了某个函数,我们就应该检查它的返回值(当然,只要合理)。

处理一些常见的错误

在这个示例中,我们将看一些常见的错误,我们可以处理。知道要寻找哪些错误是掌握错误处理的第一步。如果警察不知道要寻找哪些犯罪,他们就无法抓到坏人。

我们将看看由于计算机资源限制、权限错误和数学错误可能发生的错误。但重要的是要记住,大多数函数在发生错误时会返回一个特殊值(通常是-1 或一些预定义的值)。当没有错误发生时,实际数据被返回。

我们还将简要涉及处理缓冲区溢出的主题。缓冲区溢出是一个值得一本书的广泛主题,但一些简短的例子可以帮助。

准备工作

在这个示例中,我们将编写更短的代码示例,并使用 GCC 和 Make 进行编译。我们还将阅读POSIX 程序员手册中的一些 man 页面。如果您使用的是 Debian 或 Ubuntu,您必须首先安装这些手册页面,我们在第三章**,深入 Linux 中的 C中的获取有关 Linux 和 Unix 特定头文件的信息部分中已经安装了这些手册页面。

如何做...

查找使用特定函数时最有可能发生的错误的最简单方法是阅读函数手册页的返回值部分。在这里,我们将看一些例子:

  1. 大多数creat()open()write()。查看errno以获取更具体的信息。我们将在本章后面介绍errno

  2. 现在,查看幂函数pow()的手册页面。滚动到pow()函数返回计算的答案,如果出现错误,它不能返回 0 或-1;这可能是某些计算的答案。相反,定义了一些特殊的数字,称为HUGE_VALHUGE_VALFHUGE_VALL。但是,在大多数系统中,这些被定义为无穷大。然而,我们仍然可以使用这些宏来进行测试,如下例所示。将文件命名为huge-test.c

#include <stdio.h>
#include <math.h>
int main(void)
{
   int number = 9999;
   double answer;
   if ( (answer = pow(number, number)) == HUGE_VAL )
   {
      fprintf(stderr, "A huge value\n");
      return 1;
   }
   else
   {
      printf("%lf\n", answer);
   }
   return 0;
}
  1. 编译程序并测试它。记得链接到math库使用-lm
$> gcc -Wall -Wextra -pedantic -lm huge-test.c \
> -o huge-test
$> ./huge-test 
A huge value
  1. 其他可能发生的错误,不会给我们返回值的大多是溢出错误。这在处理strcat()strncat()strdup()等函数时尤其如此。尽可能使用这些函数。编写以下程序并将其命名为str-unsafe.c
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
    char buf[10] = { 0 };
    strcat(buf, argv[1]);
    printf("Text: %s\n", buf);
    return 0;
}
  1. 现在,使用 Make(以及我们放在此目录中的 Makefile)编译它。请注意,我们将从编译器这里得到一个警告,因为我们没有使用argc变量。这个警告来自于 GCC 的-Wextra选项。然而,这只是一个警告,说明我们在代码中从未使用过argc,所以我们可以忽略这条消息。始终阅读警告消息;有时,事情可能更严重:
$> make str-unsafe
gcc -Wall -Wextra -pedantic -std=c99    str-unsafe.c   -o str-unsafe
str-unsafe.c: In function 'main':
str-unsafe.c:4:14: warning: unused parameter 'argc' [-Wunused-parameter]
 int main(int argc, char *argv[])
          ~~~~^~~~
  1. 现在,用不同的输入长度来测试。如果我们根本不提供任何输入,或者提供太多的输入(超过 9 个字符),就会发生分段错误:
$> ./str-unsafe 
Segmentation fault
$> ./str-unsafe hello
Text: hello
$> ./str-unsafe "hello! how are you doing?"
Text: hello! how are you doing?
Segmentation fault
  1. 让我们重写程序。首先,我们必须确保用户输入了一个参数;其次,我们必须用strncat()替换strcat()。将新版本命名为str-safe.c
#include <stdio.h>
#include <string.h>
int main(int argc, char *argv[])
{
   if (argc != 2)
   {
      fprintf(stderr, "Supply exactly one "
         "argument\n");
      return 1;
   }
   char buf[10] = { 0 };
   strncat(buf, argv[1], sizeof(buf)-1);
   printf("Test: %s\n", buf);
   return 0;
}
  1. 编译它。这次,我们不会收到关于argc的警告,因为我们在代码中使用了它:
$> make str-safe
gcc -Wall -Wextra -pedantic -std=c99    str-safe.c   -o str-safe
  1. 让我们用不同的输入长度运行它。注意长文本在第九个字符处被截断,从而防止分段错误。还要注意,我们通过要求精确一个参数来处理空输入的分段错误:
$> ./str-safe 
Supply exactly one argument
$> ./str-safe hello
Text: hello
$> ./str-safe "hello, how are you doing?"
Text: hello, ho
$> ./str-safe asdfasdfasdfasdfasdfasdfasdfasdf
Text: asdfasdfa

它是如何工作的...

步骤 2中,我们查看了一些手册页面,以了解在处理它们时可以期望处理什么样的错误。在这里,我们了解到大多数系统调用在出现错误时返回-1,并且大多数错误都与权限或系统资源有关。

步骤 23中,我们看到数学函数在出现错误时可以返回特殊的数字(因为通常的数字——0、1 和-1——可能是计算的有效答案)。

步骤 49中,我们简要涉及了处理用户输入和strcat()strcpy()strdup()是不安全的,因为它们复制它们得到的任何东西,即使目标缓冲区没有足够的空间。当我们给程序一个长于 10 个字符的字符串(实际上是九个,因为NULL字符占用一个位置)时,程序会崩溃并显示分段错误

这些str函数有相应的带有n字符的函数名称;例如,strncat()。这些函数只会复制作为第三个参数给定的大小。在我们的示例中,我们将大小指定为sizeof(buf)-1,在我们的程序中为 9。我们使用比buf实际大小少一个的原因是为了为末尾的空终止字符(\0)腾出空间。最好使用sizeof(buf)而不是使用一个字面数。如果我们在这里使用了字面数 9,然后将缓冲区的大小更改为 5,我们很可能会忘记更新strncat()的数字。

错误处理和 errno

Linux 和其他类 UNIX 系统中的大多数系统调用函数设置了一个名为errno的特殊变量。

在这个示例中,我们将学习errno是什么,如何从中读取值,以及何时设置它。我们还将看到errno的一个示例用例。了解errno对于系统编程至关重要,主要是因为它与系统调用一起使用。

本章中接下来的几个食谱与本食谱密切相关。 在本食谱中,我们将学习关于errno;在接下来的三个食谱中,我们将学习如何解释我们从errno得到的错误代码并打印人类可读的错误消息。

准备工作

您将需要本食谱中与上一个食谱相同的组件;也就是说,我们已经安装的 GCC 编译器、Make 工具和POSIX 程序员手册。 如果没有,请参阅第一章**,获取必要的工具并编写我们的第一个 Linux 程序,以及第三章**,深入了解 Linux 中的 C中的获取有关 Linux 和 UNIX 特定头文件的信息部分。

如何做…

在这个食谱中,我们将继续构建本章第一个食谱中的simple-touch-v2.c。 在这里,我们将扩展它,以便在无法创建文件时打印一些更有用的信息:

  1. 将以下代码写入文件并保存为simple-touch-v3.c。 在这个版本中,我们将使用errno变量来检查错误是否是由权限错误(EACCES)或其他未知错误引起的。 更改的代码已在这里突出显示:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], sizeof(filename)-1);
   if ( creat(filename, 00644) == -1 )
   {
      fprintf(stderr, "Can't create file %s\n", 
         filename);
      if (errno == EACCES)
      {
         fprintf(stderr, "Permission denied\n");
      }
      else
      {
         fprintf(stderr, "Unknown error\n");
      }
      return 1;
   }
   return 0;
}
  1. 让我们编译这个版本:
$> make simple-touch-v3
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v3.c   -o simple-touch-v3
  1. 最后,让我们运行新版本。 这一次,程序会给我们更多关于出错原因的信息。 如果是权限错误,它会告诉我们。 否则,它会打印“未知错误”:
$> ./simple-touch-v3 asdf
$> ls -l asdf
-rw-r--r-- 1 jake jake 0 okt 13 23:30 asdf
$> ./simple-touch-v3 /asdf
Can't create file /asdf
Permission denied
$> ./simple-touch-v3 /non-existent-dir/hello
Can't create file /non-existent-dir/hello
Unknown error

它是如何工作的…

我们将注意到这个版本的第一个区别是我们现在包括一个名为errno.h的头文件。 如果我们希望使用errno变量和我们在新版本中使用的许多错误EACCES,则需要此文件。

下一个区别是,我们现在使用sizeof(filename)-1而不是PATH_MAX-1作为strncpy()的大小参数。 这是我们在上一个食谱中学到的东西。

然后,我们有if (errno == EACCES)行,它检查errno变量是否为EACCES。 我们可以在man errno.hman 2 creat中阅读关于这些宏的信息,比如EACCES。 这个特定的宏意味着权限被拒绝

当我们使用errno时,我们应该首先检查函数或系统调用的返回值,就像我们在这里使用creat()周围的if语句一样。 errno变量就像任何其他变量一样,这意味着在系统调用之后它不会被清除。 如果我们在检查函数的返回值之前直接检查errnoerrno可能包含来自先前错误的错误代码。

在我们的touch版本中,我们只处理了这个特定的错误。 接下来,我们有一个else语句,它捕获所有其他错误并打印一个“未知错误”消息。

步骤 3中,我们通过尝试在我们系统上不存在的目录中创建文件来生成了一个“未知错误”消息。 在下一个食谱中,我们将扩展我们的程序,以便它可以考虑更多的宏。

处理更多的 errno 宏

在本食谱中,我们将继续处理我们的touch版本中的更多errno宏。 在上一个食谱中,我们设法引发了一个“未知错误”消息,因为我们只处理了权限被拒绝的错误。 在这里,我们将找出到底是什么导致了那个错误以及它叫什么。 然后,我们将实现另一个if语句来处理它。 知道如何找到正确的errno宏将帮助您更深入地了解计算、Linux、系统调用和错误处理。

准备工作

再次,我们将查看手册页面,找到我们正在寻找的信息。 这个食谱所需的唯一东西是手册页面,GCC 编译器和 Make 工具。

如何做…

按照以下步骤完成本食谱:

  1. 首先阅读creat()的手册页面,使用man 2 creat。 向下滚动到ENOENT(缩写为错误无条目)。

  2. 让我们实现一个新的if语句来处理ENOENT。将新版本命名为simple-touch-v4.c。完整的程序如下。以下是与上一个版本的更改。还要注意,我们已经删除了突出显示的代码中一些if语句的括号:

#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], sizeof(filename)-1);
   if ( creat(filename, 00644) == -1 )
   {
fprintf(stderr, "Can't create file %s\n", 
         filename);
      if (errno == EACCES)
         fprintf(stderr, "Permission denied\n");
      else if (errno == ENOENT)
         fprintf(stderr, "Parent directories does "
            "not exist\n");
      else
         fprintf(stderr, "Unknown error\n");
      return 1;
   }
   return 0;
}
  1. 编译新版本:
$> make simple-touch-v4
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v4.c   -o simple-touch-v4
  1. 让我们运行它并生成一些错误。这次,当目录不存在时,它将打印出一个错误消息:
$> ./simple-touch-v4 asdf123
$> ./simple-touch-v4 /hello
Can't create file /hello
Permission denied
$> ./simple-touch-v4 /non-existent/hello
Can't create file /non-existent/hello
Parent directories do not exist

它是如何工作的...

在这个版本中,我从内部的ifelse ifelse语句中删除了括号,以节省空间。如果每个ifelse ifelse下只有一个语句,这是有效的代码。然而,这样做可能是危险的,因为很容易出错。如果我们在其中一个if语句中写更多的语句,那些语句将不属于if语句,尽管看起来正确且没有错误编译。这种情况称为误导性缩进。缩进会让大脑误以为是正确的。

代码中的新内容是else if (errno == ENOENT)行及其下面的行。这是我们处理ENOENT错误宏的地方。

还有更多...

man 2 syscalls中列出的几乎所有函数都设置了errno变量。查看一些这些函数的手册页,并滚动到不同函数设置的errno宏。

还要阅读man errno.h,其中包含有关这些宏的有用信息。

使用 errno 和 strerror()

与查找每个可能的 errno 宏并弄清楚哪些适用以及它们的含义相比,使用一个名为strerror()的函数更容易。这个函数将errno代码转换为可读的消息。使用strerror()比自己实现所有内容要快得多。这样做也更安全,因为我们犯错的风险更小。每当有一个函数可以帮助我们减轻手动工作时,我们都应该使用它。

请注意,这个函数的目的是将errno宏转换为可读的错误消息。如果我们想以某种特定的方式处理特定的错误,我们仍然需要使用实际的errno值。

准备工作

上一个配方的要求也适用于这个配方。这意味着我们需要 GCC 编译器、Make 工具(以及 Makefile)和手册页。

如何做...

在这个配方中,我们将继续开发我们自己的touch版本。我们将从上一个版本继续。这次,我们将重写我们为不同的宏制作的if语句,并使用strerror()代替。让我们开始吧:

  1. 编写以下代码,并将其保存为simple-touch-v5.c。注意,现在代码已经变得更小,因为我们用strerror()替换了if语句。这个版本更加清晰。以下是与上一个版本的更改:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   int errornum;
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], sizeof(filename)-1);
   if ( creat(filename, 00644) == -1 )
   {
      errornum = errno;
      fprintf(stderr, "Can't create file %s\n", 
         filename);
      fprintf(stderr, "%s\n", strerror(errornum));
      return 1;
   }
   return 0;
}
  1. 编译这个新版本:
$> make simple-touch-v5
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v5.c   -o simple-touch-v5
  1. 让我们试一下。注意程序现在打印出描述出了什么问题的错误消息。我们甚至不需要检查errno变量是否存在可能的错误:
$> ./simple-touch-v5 hello123 
$> ls hello123
hello123
$> ./simple-touch-v5 /asdf123
Can't create file /asdf123
Permission denied
$> ./simple-touch-v5 /asdf123/hello
Can't create file /asdf123/hello
No such file or directory
How it works…

所有的ifelse ifelse语句现在都被一行代码替换了:

fprintf(stderr, "%s\n", strerror(error));

我们还将errno的值保存在一个名为errornum的新变量中。我们这样做是因为在下一个发生错误时,errno中的值将被新的错误代码覆盖。为了防止在errno被覆盖时显示错误的消息,将其保存到一个新变量中更安全。

然后,我们使用存储在errornum中的错误代码作为strerror()的参数。这个函数将错误代码转换为可读的错误消息,并将该消息作为字符串返回。这样,我们就不必为可能发生的每个错误创建if语句。

步骤 3中,我们看到strerror()如何将EACCES宏翻译成Permission denied,将ENOENT翻译成No such file or directory

还有更多...

man 3 strerror手册页面中,您会找到一个类似的函数,可以以用户首选的语言打印错误消息。

使用 perror()和 errno

在上一个步骤中,我们使用strerror()errno获取包含人类可读错误消息的字符串。还有一个类似于strerr()的函数叫做perror()。它的名字代表打印错误,这就是它的作用;它直接将错误消息打印到stderr

在这个步骤中,我们将编写我们的 simple touch 程序的第六个版本。这次,我们将用perror()替换两个fprintf()行。

准备工作

这个步骤所需的程序只有 GCC 编译器和 Make 工具(以及通用的 Makefile)。

如何做…

按照以下步骤创建一个更短更好的simple-touch版本:

  1. 将以下代码写入文件并保存为simple-touch-v6.c。这次,程序更小了。我们删除了两个fprintf()语句,并用perror()替换了它们。与上一个版本相比的变化在这里突出显示:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], sizeof(filename)-1);
   if ( creat(filename, 00644) == -1 )
   {
      perror("Can't create file");
      return 1;
   }
   return 0;
}
  1. 使用 Make 编译它:
$> make simple-touch-v6
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v6.c   -o simple-touch-v6
  1. 运行并观察错误消息输出的变化:
$> ./simple-touch-v6 abc123
$> ./simple-touch-v6 /asdf123
Can't create file: Permission denied
$> ./simple-touch-v6 /asdf123/hello
Can't create file: No such file or directory
How it works…

这次,我们用一行替换了两个fprintf()行:

perror("Can't create file");

perror()函数接受一个参数,一个包含描述或函数名称的字符串。在这种情况下,我选择给它一个通用的错误消息无法创建文件。当perror()打印错误消息时,它会抓取errno中的最后一个错误代码(注意我们没有指定任何错误代码变量),并在文本无法创建文件之后应用该错误消息。因此,我们不再需要fprintf()行。

尽管在调用perror()时没有明确说明errno,但它仍然使用它。如果发生另一个错误,那么下一次调用perror()将打印该错误消息。perror()函数总是打印最后的错误。

还有更多…

手册页面man 3 perror中有一些很好的提示。例如,包含导致错误的函数名称是个好主意。这样在用户报告错误时更容易调试程序。

返回错误值

尽管可读的错误消息很重要,但我们不能忘记返回一个指示错误的值给 shell。我们已经知道返回 0 表示一切正常,而返回其他值(大多数情况下是 1)表示发生了某种错误。然而,如果需要,我们可以返回更具体的值,以便依赖我们程序的其他程序可以读取这些数字。例如,我们实际上可以返回errno变量,因为它只是一个整数。我们已经看到的所有宏,如EACCESENOENT,都是整数(分别为EACCESENOENT的 13 和 2)。

在这个步骤中,我们将学习如何将errno数字返回给 shell,以提供更具体的信息。

准备工作

与上一个步骤提到的相同的程序集适用于这个步骤。

如何做…

在这个步骤中,我们将制作我们的simple-touch程序的第七个版本。让我们开始吧:

  1. 在这个版本中,我们只会改变一个单独的行。打开simple-touch-v6.c,将perror()行下面的return语句改为return errno;。将新文件保存为simple-touch-v7.c。最新版本如下,突出显示了更改的行:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <linux/limits.h>
int main(int argc, char *argv[])
{
   char filename[PATH_MAX] = { 0 };
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncpy(filename, argv[1], sizeof(filename)-1);
   if ( creat(filename, 00644) == -1 )
   {
      perror("Can't create file");
      return errno;
   }
   return 0;
}
  1. 编译新版本:
$> make simple-touch-v7
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v7.c   -o simple-touch-v7
  1. 运行并检查退出代码:
$> ./simple-touch-v7 asdf
$> echo $
0
$> ./simple-touch-v7 /asdf
Can't create file: Permission denied
$> echo $?
13
$> ./simple-touch-v7 /asdf/hello123
Can't create file: No such file or directory
$> echo $?
2

工作原理…

errno.h中定义的错误宏是常规整数。因此,例如,如果我们返回EACCES,我们返回数字 13。因此,在发生错误时,首先在幕后设置了errno。然后,perror()使用存储在errno中的值打印人类可读的错误消息。最后,程序返回到 shell,并使用存储在errno中的整数指示其他程序出了什么问题。不过,我们应该对此略加小心,因为有一些保留的返回值。例如,在 shell 中,返回值2通常表示Missuse of shell builtins。但是,在errno中,返回值2表示No such file or directory (ENOENT)。这不应该给您带来太多麻烦,但以防万一请记住这一点。

还有更多...

有一个名为errno的小程序,可以打印所有宏及其整数。不过,默认情况下并未安装此工具。软件包的名称是moreutils

安装完成后,您可以通过运行errno -l命令打印所有宏的列表,其中l选项代表list

DebianUbuntu中安装软件包,作为 root 用户输入apt install moreutils

Fedora中安装软件包,请以 root 身份使用dnf install moreutils

CentOSRed Hat中,您必须首先使用dnf install epel-release添加epel-release存储库,然后以 root 身份使用dnf install moreutils安装软件包。在撰写本文时,关于 CentOS 8 存在一些关于moreutils的依赖性问题,因此可能无法正常工作。

第五章:使用文件 I/O 和文件系统操作

文件 I/O 是系统编程的重要部分,因为大多数程序必须从文件中读取或写入数据。进行文件 I/O 还要求开发人员对文件系统有所了解。

精通文件 I/O 和文件系统操作不仅会使您成为更好的程序员,还会使您成为更好的系统管理员。

在本章中,我们将学习 Linux 文件系统和 inode。我们还将学习如何使用流和文件描述符在系统上读取和写入文件。我们还将查看系统调用以创建和删除文件,并更改文件权限和所有权。在本章末尾,我们将学习如何获取有关文件的信息。

在本章中,我们将涵盖以下内容:

  • 阅读 inode 信息并学习文件系统

  • 创建软链接和硬链接

  • 创建文件并更新时间戳

  • 删除文件

  • 获取访问权限和所有权

  • 设置访问权限和所有权

  • 使用文件描述符写入文件

  • 使用文件描述符从文件中读取

  • 使用流写入文件

  • 使用流从文件中读取

  • 使用流读取和写入二进制数据

  • 使用lseek()在文件内部移动

  • 使用fseek()在文件内部移动

技术要求

对于本章,您将需要 GCC 编译器、Make 工具以及我们在第三章中的使用 GCC 选项编写通用 Makefile食谱中制作的通用 Makefile。第一章中有关安装编译器和 Make 工具的内容。

通用的 Makefile 以及本章的所有源代码示例可以从 GitHub 的以下 URL 下载:github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch5

我们将在 Linux 的内置手册中查找函数和头文件。如果您使用的是 Debian 或 Ubuntu,Linux 程序员手册将作为build-essentials元包的一部分安装,该元包在第一章中有所涵盖,获取必要的工具并编写我们的第一个 Linux 程序。您还需要安装POSIX 程序员手册,该手册在第三章中的获取有关 Linux 和 Unix 特定头文件的信息食谱中有所涵盖,深入研究 Linux 中的 C 语言。如果您使用的是 CentOS 或 Fedora,这些手册很可能已经安装。否则,请查看我提到的第三章中的食谱,深入研究 Linux 中的 C 语言

查看以下链接以查看代码演示视频:bit.ly/3u4OuWz

阅读 inode 信息并学习文件系统

理解 inode 是深入了解 Linux 文件系统的关键。在 Linux 或 Unix 系统中,文件名并不是实际的文件,它只是指向 inode 的指针。inode 包含有关实际数据存储位置的信息,以及有关文件的大量元数据,例如文件模式、最后修改日期和所有者。

在这个食谱中,我们将对文件系统有一个一般的了解,以及 inode 如何适应其中。我们还将查看 inode 信息,并学习一些相关命令。我们还将编写一个小的 C 程序,从文件名中读取 inode 信息。

准备工作

在这个食谱中,我们将使用命令和 C 程序来探索 inode 的概念。您需要的一切都在本章的技术要求部分中有所涵盖。

操作方法…

在这个配方中,我们将首先探索系统上已经存在的命令,以查看 inode 信息。然后,我们将创建一个小的 C 程序来打印 inode 信息:

  1. 我们将首先创建一个小的文本文件,我们将在整个配方中使用它:
$> echo "This is just a small file we'll use" \
> > testfile1
$> cat testfile1 
This is just a small file we'll use
  1. 现在,让我们查看此文件的inode 编号,以及其大小、块计数和其他信息。每个系统和每个文件的 inode 编号都是不同的:
$> stat testfile1 
  File: testfile1
  Size: 36              Blocks: 8          IO Block: 262144 regular file
Device: 35h/53d Inode: 19374124    Links: 1
Access: (0644/-rw-r--r--)  Uid: ( 1000/    jake)   Gid: ( 1000/    jake)
Access: 2020-10-16 22:19:02.770945984 +0200
Modify: 2020-10-16 22:19:02.774945969 +0200
Change: 2020-10-16 22:19:02.774945969 +0200
 Birth: -
  1. 大小以字节为单位,为 36 字节。由于文本中未使用特殊字符,因此这与文件包含的字符数相同。我们可以使用wc来计算字符数:
$> wc -c testfile1 
36 testfile1
  1. 现在,让我们构建一个小程序,提取其中一些信息;inode 编号、文件大小和my-stat-v1.c的链接数。我们将用于提取信息的系统调用函数与命令行工具stat具有相同的名称。代码中突出显示了系统调用函数:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
int main(int argc, char *argv[])
{
   struct stat filestat;
   if ( argc != 2 )
   {
      fprintf(stderr, "Usage: %s <file>\n", 
         argv[0]);
      return 1;
   }
   if ( stat(argv[1], &filestat) == -1 )
   {
      fprintf(stderr, "Can't read file %s: %s\n", 
         argv[1], strerror(errno));
      return errno;
   }
   printf("Inode: %lu\n", filestat.st_ino);
   printf("Size: %zd\n", filestat.st_size);
   printf("Links: %lu\n", filestat.st_nlink);
   return 0;
}
  1. 现在使用 Make 和通用的Makefile编译此程序:
$> make my-stat-v1
gcc -Wall -Wextra -pedantic -std=c99    my-stat-v1.c   -o my-stat-v1
  1. 让我们在testfile1上尝试这个程序。比较 inode 编号、大小和链接数。这些数字应该与我们使用stat程序时相同:
$> ./my-stat-v1 testfile1 
Inode: 19374124
Size: 36
Links: 1
  1. 如果我们不输入参数,将会得到一个使用消息:
$> ./my-stat-v1
Usage: ./my-stat-v1 <file>
  1. 如果我们尝试对一个不存在的文件进行操作,将会得到一个错误消息:
$> ./my-stat-v1 hello123
Can't read file hello123: No such file or directory

工作原理…

文件的文件名并不是数据或文件。文件名只是指向 inode 的链接。而该 inode 又包含有关实际数据存储在文件系统上的位置的信息。正如我们将在下一篇文章中看到的,一个 inode 可以有多个名称或链接。有时文件名也被称为链接。下图说明了指向 inode 的文件名和 inode 包含有关数据块存储位置的信息的概念:

图 5.1 – Inodes 和文件名

图 5.1 – Inodes 和文件名

一个 inode 还包含stat命令。

在第 4 步中,我们创建了一个小的 C 程序,使用与命令相同名称的系统调用函数stat()读取此元数据。stat()系统调用提取的数据比我们在此处打印的要多得多。我们将在本章中打印更多此类信息。所有这些信息都存储在一个名为stat的结构体中。我们在man 2 stat手册页中找到了关于此结构体的所有所需信息。在该手册页中,我们还看到了变量的数据类型(ino_toff_tnlink_t)。然后,在man sys_types.h中,我们在另外下找到了这些类型是什么类型。

我们在这里使用的字段是st_ino表示 inode 编号,st_size表示文件大小,st_nlink表示文件的链接数。

在第 6 步中,我们看到我们使用 C 程序提取的信息与stat命令的信息相同。

我们还在程序中实现了错误处理。stat()函数包装在一个if语句中,检查其返回值是否为-1。如果发生错误,我们将使用stderr打印出带有文件名和errno的错误消息。程序还将errno变量返回给 shell。我们在第四章**中学习了有关错误处理和errno的所有内容,处理程序中的错误

创建软链接和硬链接

在上一篇文章中,我们提到了链接的主题。在这篇文章中,我们将更多地了解链接以及它们对 inode 的影响。我们还将调查软链接硬链接之间的区别。简而言之,硬链接是一个文件名,软链接就像是一个文件名的快捷方式。

此外,我们将编写两个程序,一个创建硬链接,一个创建软链接。然后,我们将使用前一篇文章中创建的程序来检查链接计数。

准备工作

除了本章开头列出的要求,您还需要我们在上一个示例中创建的程序my-stat-v1.c。您还需要我们在上一个示例中创建的测试文件,名为testfile1。如果您还没有创建这些文件,也可以从 GitHub 上下载它们github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch5

您还需要使用 Make 编译my-stat-v1.c程序,以便能够执行它,如果您还没有这样做的话。您可以使用make my-stat-v1来编译它。

如何做…

我们将创建软链接和硬链接,使用内置命令和编写简单的 C 程序来完成:

  1. 我们将首先创建一个新的硬链接到我们的测试文件testfile1。我们将新的硬链接命名为my-file
$> ln testfile1 my-file
  1. 现在让我们调查这个新文件名。请注意链接已增加到2,但其余部分与testfile1相同:
$> cat my-file 
This is just a small file we'll use
$> ls -l my-file 
-rw-r--r-- 3 jake jake 36 okt 16 22:19 my-file
$> ./my-stat-v1 my-file 
Inode: 19374124
Size: 36
Links: 2
  1. 现在将这些数字与testfile1文件进行比较。它们应该都是相同的:
$> ls -l testfile1 
-rw-r--r-- 3 jake jake 36 okt 16 22:19 testfile1
$> ./my-stat-v1 testfile1 
Inode: 19374124
Size: 36
Links: 2
  1. 让我们创建另一个名为another-name的硬链接。我们使用名称my-file作为目标创建此链接:
$> ln my-file another-name
  1. 我们也将调查这个文件:
$> ls -l another-name 
-rw-r--r-- 2 jake jake 36 okt 16 22:19 another-name
$> ./my-stat-v1 another-name 
Inode: 19374124
Size: 36
Links: 3
  1. 现在让我们删除testfile1文件名:
$> rm testfile1
  1. 现在我们已经删除了我们创建的第一个文件名,我们将调查另外两个名称:
$> cat my-file 
This is just a small file we'll use
$> ls -l my-file 
-rw-r--r-- 2 jake jake 36 okt 16 22:19 my-file
$> ./my-stat-v1 my-file 
Inode: 19374124
Size: 36
Links: 2
$> cat another-name 
This is just a small file we'll use
$> ls -l another-name 
-rw-r--r-- 2 jake jake 36 okt 16 22:19 another-name
$> ./my-stat-v1 another-name 
Inode: 19374124
Size: 36
Links: 2
  1. 是时候创建一个软链接了。我们创建一个名为my-soft-link的软链接到名称another-name
$> ln -s another-name my-soft-link
  1. 软链接是一种特殊的文件类型,可以使用ls命令查看。请注意,我们在这里得到了一个新的时间戳。还要注意,它是一个特殊文件,可以通过文件模式字段中的第一个字母l来看到:
$> ls -l my-soft-link 
lrwxrwxrwx 1 jake jake 12 okt 17 01:49 my-soft-link -> another-name
  1. 现在让我们检查another-name的链接计数。请注意,软链接的计数器没有增加:
$> ./my-stat-v1 another-name 
Inode: 19374124
Size: 36
Links: 2
  1. 是时候编写我们自己的程序来创建硬链接了。存在一个易于使用的link(),我们将使用它。将以下代码写入文件并保存为new-name.c。代码中突出显示了link()系统调用:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        fprintf(stderr, "Usage: %s [target] " 
            "[new-name]\n", argv[0]);
        return 1;
    }
    if (link(argv[1], argv[2]) == -1)
    {
        perror("Can't create link");
        return 1;
    }
    return 0;
}
  1. 编译程序:
$> make new-name
gcc -Wall -Wextra -pedantic -std=c99    new-name.c   -o new-name
  1. 为我们之前的my-file文件创建一个新名称。将新文件命名为third-name。我们还尝试生成一些错误,以查看程序是否打印了正确的错误消息。请注意,third-name的 inode 信息与my-file的相同:
$> ./new-name 
Usage: ./new-name [target][new-name]
$> ./new-name my-file third-name
$> ./my-stat-v1 third-name
Inode: 19374124
Size: 36
Links: 3
$> ./new-name my-file /home/carl/hello
Can't create link: Permission denied
$> ./new-name my-file /mnt/localnas_disk2/
Can't create link: File exists
$> ./new-name my-file /mnt/localnas_disk2/third-name
Can't create link: Invalid cross-device link
  1. 现在让我们创建一个创建软链接的程序。这也有一个易于使用的系统调用,称为symlink(),用于new-symlink.c。代码中突出显示了symlink()系统调用。注意所有这些系统调用函数有多么相似:
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        fprintf(stderr, "Usage: %s [target] " 
            "[link]\n", argv[0]);
        return 1;
    }
    if (symlink(argv[1], argv[2]) == -1)
    {
        perror("Can't create link");
        return 1;
    }
    return 0;
}
  1. 编译它:
$> make new-symlink
gcc -Wall -Wextra -pedantic -std=c99    new-symlink.c   -o new-symlink
  1. 让我们试一试,创建一个新的软链接,名为new-soft-link,指向third-name。此外,让我们尝试生成一些错误,以便我们可以验证错误处理是否正常工作:
$> ./new-symlink third-name new-soft-link
$> ls -l new-soft-link 
lrwxrwxrwx 1 jake jake 10 okt 18 00:31 new-soft-link -> third-name
$> ./new-symlink third-name new-soft-link
Can't create link: File exists
$> ./new-symlink third-name /etc/new-soft-link
Can't create link: Permission denied

它是如何工作的…

这里发生了很多事情,所以让我们从头开始。

在步骤 1 到 7 中,我们创建了两个新的硬链接到testfile1文件。但正如我们注意到的,硬链接没有什么特别之处;它只是 inode 的另一个名称。所有文件名都是硬链接。文件名只是 inode 的一个链接。当我们删除testfile1文件名时,我们看到了这一点。剩下的两个名称链接到相同的 inode,并且包含相同的文本。第一个文件名或链接没有什么特别之处。无法告诉哪个硬链接是首先创建的。它们是相等的;它们甚至共享相同的日期,尽管其他链接是在稍后的时间创建的。日期是为了 inode,而不是文件名。

当我们创建和删除硬链接时,我们看到链接计数增加和减少。这是 inode 保持计算它有多少链接或名称的计数。

直到最后一个名称被删除,即链接计数达到零时,inode 才会被删除。

步骤 8 到 10中,我们看到软链接,另一方面,是一种特殊的文件类型。软链接不计入 inode 的链接计数。文件在ls -l输出的开头用l表示。我们还可以在ls -l输出中看到软链接指向的文件。把软链接想象成一个快捷方式。

步骤 11 到 13中,我们编写了一个创建硬链接(现有文件的新名称)的 C 程序。在这里,我们了解到创建新名称的系统调用称为link(),并且接受两个参数,目标和新名称。

步骤 13中,我们见证了硬链接的一个有趣特性。它们不能跨设备。当我们考虑这一点时,这是有道理的。文件名不能保留在与 inode 分开的设备上。如果设备被移除,可能就没有更多的名称指向 inode,使其无法访问。

在剩下的步骤中,我们编写了一个 C 程序,用于创建指向现有文件的软链接。这个系统调用类似于link(),但是被称为symlink()

还有更多...

请查看我们在本食谱中涵盖的系统调用的手册页面;它们包含了硬链接和软链接的一些很好的解释。手册页面是man 2 linkman 2 symlink

创建文件和更新时间戳

现在我们了解了文件系统、inode 和硬链接,我们将学习如何通过在 C 中编写我们自己的touch版本来创建文件。我们已经开始在第四章**,处理程序中的错误中编写touch的一个版本,那里我们学习了错误处理。我们将继续使用该程序的最新版本,我们将其命名为simple-touch-v7.c。真正的touch版本会在文件存在时更新文件的修改和访问时间戳。在这个食谱中,我们将在我们的新版本中添加这个功能。

准备工作

您在本章的技术要求部分中列出了此食谱所需的一切。虽然我们将添加simple-touch的最新版本,但我们将在本食谱中编写整个代码。但为了完全理解程序,最好先阅读第四章**,处理程序中的错误

如何做...

在这个simple-touch的第八个版本中,我们将添加更新文件的访问和修改日期的功能:

  1. 在文件中写入以下代码,并将其保存为simple-touch-v8.c。在这里,我们将使用utime()系统调用来更新文件的访问和修改时间戳。代码中突出显示了与上一个版本的更改(除了添加的注释)。还要注意creat()系统调用如何移入了一个if语句。只有在文件不存在时才会调用creat()系统调用:
#include <stdio.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <utime.h>
#define MAX_LENGTH 100
int main(int argc, char *argv[])
{
   char filename[MAX_LENGTH] = { 0 };
   /* Check number of arguments */
   if (argc != 2)
   {
      fprintf(stderr, "You must supply a filename "
         "as an argument\n");
      return 1;
   }
   strncat(filename, argv[1], sizeof(filename)-1);
   /* Update the access and modification time */
   if ( utime(filename, NULL) == -1 )
   {
      /* If the file doesn't exist, create it */
      if (errno == ENOENT)
      {
         if ( creat(filename, 00644) == -1 )
         {
            perror("Can't create file");
            return errno;
         }
      }
      /* If we can't update the timestamp,
         something is wrong */
      else
      {
         perror("Can't update timestamp");
         return errno;
      }
   }
   return 0;
}
  1. 使用 Make 编译程序:
$> make simple-touch-v8
gcc -Wall -Wextra -pedantic -std=c99    simple-touch-v8.c   -o simple-touch-v8
  1. 让我们尝试一下,看看它是如何工作的。我们将在上一个食谱中创建的文件名上尝试,并看看每个文件名如何获得相同的时间戳,因为它们都指向相同的 inode:
$> ./simple-touch-v8 a-new-file
$> ls -l a-new-file 
-rw-r--r-- 1 jake jake 0 okt 18 19:57 a-new-file
$> ls -l my-file 
-rw-r--r-- 3 jake jake 36 okt 16 22:19 my-file
$> ls -l third-name 
-rw-r--r-- 3 jake jake 36 okt 16 22:19 third-name
$> ./simple-touch-v8 third-name
$> ls -l my-file 
-rw-r--r-- 3 jake jake 36 okt 18 19:58 my-file
$> ls -l third-name 
-rw-r--r-- 3 jake jake 36 okt 18 19:58 third-name
$> ./simple-touch-v8 /etc/passwd
Can't change filename: Permission denied
$> ./simple-touch-v8 /etc/hello123
Can't create file: Permission denied

它是如何工作的...

在这个食谱中,我们添加了更新文件或 inode 的时间戳的功能。

要更新访问和修改时间,我们使用utime()系统调用。utime()系统调用接受两个参数,一个文件名和一个时间戳。但是如果我们将NULL作为第二个参数传递给函数,它将使用当前的时间和日期。

调用utime()的语句被包裹在一个if语句中,检查返回值是否为-1。如果是,那么出现了问题,errno被设置(参见第四章**,处理程序中的错误,对errno的深入解释)。然后我们使用errno来检查是否是文件未找到错误(ENOTENT)。如果文件不存在,我们使用creat()系统调用来创建它。对creat()的调用也被包裹在一个if语句中。如果在创建文件时出现问题,程序将打印错误消息并返回errno值。如果程序成功创建了文件,它将继续执行return 0

如果utime()errno值不是ENOENT,它将继续到else语句,打印错误消息,并返回errno

当我们尝试运行程序时,我们注意到当我们更新其中一个文件时,my-filethird-name都会获得更新的时间戳。这是因为这些文件名只是指向相同 inode 的链接。时间戳是 inode 中的元数据。

还有更多...

man 2 creatman 2 utime中有很多有用的信息。如果你有兴趣了解 Linux 中的时间和日期,我建议你阅读man 2 timeman 3 asctimeman time.h

删除文件

在这个食谱中,我们将学习如何使用unlink()函数。这个食谱将增强你对链接的理解,并闭合循环。这将提高你对 Linux 及其文件系统的整体知识。知道如何使用系统调用删除文件将使你能够直接从程序中删除文件。

在这里,我们将编写我们自己的版本的rm,我们将其称为remove。在这个食谱之后,我们知道如何创建和删除文件以及如何创建链接。这些是一些最常见的文件系统操作。

准备就绪

在这个食谱中,我们将使用我们在读取 inode 信息和学习文件系统食谱中编写的my-stat-v1程序。我们还将继续对我们在之前的食谱中创建的文件名进行实验,my-fileanother-namethird-name。除此之外,你还需要本章列出的技术要求,即 GCC 编译器,Make 工具和通用 Makefile。

如何做...

跟着这里写一个简单版本的rm

  1. 将以下代码写入一个文件并保存为remove.c。这个程序使用unlink()系统调用来删除一个文件。代码中突出显示了系统调用:
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main(int argc, char *argv[])
{
    if (argc != 2)
    {
        fprintf(stderr, "Usage: %s [path]\n",
            argv[0]);
        return 1;
    }
    if ( unlink(argv[1]) == -1 )
    {
        perror("Can't remove file");
        return errno;
    }
    return 0;
}
  1. 使用Make工具编译它:
$> make remove
gcc -Wall -Wextra -pedantic -std=c99    remove.c   -o remove
  1. 让我们试一试:
$> ./my-stat-v1 my-file 
Inode: 19374124
Size: 36
Links: 3
$> ./remove another-name 
$> ./my-stat-v1 my-file 
Inode: 19374124
Size: 36
Links: 2

它是如何工作的...

用于删除文件的系统调用称为unlink()。这个名字来自于当我们删除一个文件名时,我们只是删除了指向该 inode 的硬链接;因此我们unlink了一个文件名。如果它恰好是指向 inode 的最后一个文件名,那么该 inode 也将被删除。

unlink()系统调用只接受一个参数:我们要删除的文件名。

获取访问权限和所有权

在这个食谱中,我们将编写一个程序,使用我们在本章中之前看到的stat()系统调用来读取文件的访问权限和所有权。我们将继续构建在本章第一个食谱中构建的my-stat-v1程序的基础上。在这里,我们将添加显示所有权和访问权限的功能。知道如何以编程方式获取所有者和访问权限对于处理文件和目录至关重要。它将使你能够检查用户是否具有适当的权限,并在他们没有权限时打印错误消息。

我们还将学习在 Linux 中如何解释访问权限以及如何在数字表示和字母表示之间进行转换。了解 Linux 中的访问权限对于成为 Linux 系统程序员至关重要。整个系统上的每个文件和目录都有访问权限以及分配给它们的所有者和组。无论是日志文件、系统文件还是用户拥有的文本文件,都有访问权限。

准备工作

对于这个示例,您只需要本章技术要求部分中列出的内容。

如何做…

我们将在这个示例中编写my-stat-v1的新版本。我们将在这里编写整个程序,因此您不需要之前的版本:

  1. 在文件中写入以下代码并将其保存为my-stat-v2.c。在这个版本中,我们将获取有关文件所有者和组以及文件模式的信息。要翻译getpwuid()。要获取getgrgid()的组名。更改在代码中突出显示:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <pwd.h>
#include <grp.h>
int main(int argc, char *argv[])
{
    struct stat filestat;
    struct passwd *userinfo;
    struct group *groupinfo;
    if ( argc != 2 )
    {
        fprintf(stderr, "Usage: %s <file>\n",
            argv[0]);
        return 1;
    }
    if ( stat(argv[1], &filestat) == -1 )
    {
        fprintf(stderr, "Can't read file %s: %s\n", 
            argv[1], strerror(errno));
        return errno;
    }
    if ( (userinfo = getpwuid(filestat.st_uid)) ==
        NULL )
    {
        perror("Can't get username");
        return errno;
    }
    if ( (groupinfo = getgrgid(filestat.st_gid)) ==
        NULL )
    {
        perror("Can't get groupname");
        return errno;
    }
    printf("Inode: %lu\n", filestat.st_ino);
    printf("Size: %zd\n", filestat.st_size);
    printf("Links: %lu\n", filestat.st_nlink);
printf("Owner: %d (%s)\n", filestat.st_uid, 
        userinfo->pw_name);
printf("Group: %d (%s)\n", filestat.st_gid, 
        groupinfo->gr_name);
    printf("File mode: %o\n", filestat.st_mode);
    return 0;
}
  1. 编译程序:
$> make my-stat-v2
gcc -Wall -Wextra -pedantic -std=c99    my-stat-v2.c   -o my-stat-v2
  1. 在一些不同的文件上尝试该程序:
$> ./my-stat-v2 third-name 
Inode: 19374124
Size: 36
Links: 2
Owner: 1000 (jake)
Group: 1000 (jake)
File mode: 100644
$> ./my-stat-v2 /etc/passwd
Inode: 4721815
Size: 2620
Links: 1
Owner: 0 (root)
Group: 0 (root)
File mode: 100644
$> ./my-stat-v2 /bin/ls
Inode: 3540019
Size: 138856
Links: 1
Owner: 0 (root)
Group: 0 (root)
File mode: 100755

工作原理…

在这个my-stat版本中,我们添加了检索文件访问模式或实际上是文件模式的功能。文件的完整文件模式由六个八进制数字组成。前两个(左侧)是文件类型。在这种情况下,它是一个常规文件(10 等于常规文件)。第四个八进制数字是设置用户 ID 位设置组 ID 位粘性位。最后三个八进制数字是访问模式

ls -l的输出中,所有这些位都代表为字母。但是当我们编写程序时,我们必须将其设置和读取为数字。在继续之前,让我们检查文件模式的字母版本,以便真正理解它:

!图 5.2 - 文件访问模式

图 5.2 - 文件访问模式

设置用户 ID 位是一个允许进程以二进制文件的所有者身份运行的位,即使它以不同的用户身份执行。设置用户 ID 位可能是危险的,是我们应该在程序上设置的东西。使用设置用户 ID 位的一个程序是passwd程序。passwd程序必须在用户更改密码时更新/etc/passwd/etc/shadow文件,即使这些文件是由 root 拥有的。在正常情况下,我们甚至不能以常规用户的身份读取/etc/shadow文件,但是通过在passwd程序上设置设置用户 ID 位,它甚至可以写入它。如果设置了设置用户 ID 位,则在用户的访问模式的第三个位置上用s表示。

设置组 ID 具有类似的效果。当程序被执行并且设置了组 ID 位时,它将作为该组执行。当设置了组 ID 时,它在组的访问模式的第三个位置上用s表示。

粘性位在历史上用于将程序到交换空间,以加快加载时间。现在,它的用途完全不同。现在,名称以及含义都已更改为受限删除标志。当目录设置了粘性位时,只有文件的所有者、目录所有者或 root 用户可以删除文件,即使目录可被任何人写入。例如,/tmp目录通常设置了粘性位。粘性位在最后一组的最后一个位置上用t表示。

文件访问模式

当我们在文件上运行ls -l时,我们总是看到两个名称。第一个名称是用户(所有者),第二个名称是拥有文件的组。例如:

$> ls -l Makefile 
-rw-r--r-- 1 jake devops 134 okt 27 23:39 Makefile

在这种情况下,jake是用户(所有者),devops是组。

文件访问模式比我们刚刚讨论的特殊标志更容易理解。看一下图 5.2。前三个字母是用户的访问模式(文件的所有者)。这个特定的示例有rw-,这意味着用户可以读取和写入文件,但不能执行它。如果用户能够执行它,那将在最后一个位置上用x表示。

中间的三个字母是组访问模式(拥有文件的组)。在这种情况下,由于组缺少写入和执行的wx,组只能读取文件。

最后的三个字母是所有其他人(不是所有者,也不在所有者组中)。在这种情况下,其他人只能读取文件。

完整的权限集将是rwxrwxrwx

在字母和数字之间转换访问模式

八进制数表示文件访问模式。在我们习惯之前,从字母转换为八进制的最简单方法是使用纸和笔。我们在每个设置了访问位的组中将所有数字相加。如果没有设置(破折号),那么我们就不添加那个数字。当我们完成每个组的添加时,我们就得到了访问模式:

rw- r-- r—
421 421 421
 6   4   4

因此,前面的八进制访问模式是 644。让我们再举一个例子:

rwx rwx r-x
421 421 421
 7   7   5

前面的访问模式结果是 775。让我们再举一个例子:

rw- --- ---
421 421 421
 6   0   0

这个访问模式是 600。

也可以使用纸和笔来做相反的事情。假设我们有访问模式 750,我们想把它转换成字母:

 7   5   0
421 401 000
rwx r-x ---

因此,750 变成了rwxr-x---

当你做了一段时间后,你会学会最常用的访问模式,不再需要纸和笔。

八进制文件模式

与文件访问模式一样,这里也适用相同的原则。记住,用户 ID 由用户的执行位置上的s表示,组 ID 由组的执行位上的s表示。t字符表示最后一个执行位位置(“其他”)的粘性位。如果我们把它写在一行上,就会得到这样:

s s t
4 2 1

因此,如果只设置了用户 ID 位,我们得到 4。如果同时设置了用户 ID 和组 ID,我们得到4+2=6。如果只设置了组 ID 位,我们得到 2。如果只设置了粘性位,我们得到 1,依此类推。如果所有位都设置了,我们得到7(4+2+1)

这些文件模式由文件访问模式之前的数字表示。例如,八进制文件模式4755设置了用户 ID 位(4)。

当我们在 Linux 下编程时,我们甚至可能会遇到另外两个数字,就像我们从my-stat-v2程序的输出中看到的那样。在那里,我们有这样的:

File mode: 100755

前两个数字,在这个例子中是10,是文件类型。这两个数字的确切含义是我们需要在man 7 inode手册页中查找的。那里有一个很好的表告诉我们它的含义。我在这里列出了一个简化的列表,只显示我们感兴趣的前两个数字以及它代表的文件类型:

14   socket
12   symbolic link
10   regular file
06   block device
04   directory
02   character device
01   FIFO

这意味着我们的示例文件是一个普通文件(10)。

如果我们把刚刚学到的所有东西加起来,并将前面示例输出的my-stat-v2中的文件模式100755转换成数字,我们得到这样:

10  = a regular file
0   = no set-user-ID, set-group-ID or sticky bit is set
755 = the user can read, write, and execute it. The group can read and execute it, and all others can also read and execute it.

文件类型也由第一个位置的字母表示(见图 5.2)。这些字母如下:

s   socket
l   symbolic link
-   regular file
b   block device
d   directory
c   character device
p   FIFO

设置访问权限和所有权

在上一个配方中,我们学习了如何读取chmod命令和chmod()系统调用。我们还将学习如何改变文件的所有者和组,使用chown命令和chown()系统调用。

知道如何正确设置访问权限将有助于保护您的系统和文件安全。

准备工作

对于这个配方,你只需要本章技术要求部分列出的内容。阅读上一个配方以理解 Linux 中的权限也是一个好主意。你还需要上一个配方中的my-stat-v2程序。

如何做…

这些步骤将教会我们如何更改文件和目录的访问权限和所有权。

访问权限

我们将首先使用chmod命令设置文件的访问权限。然后,我们将编写chmod命令的简单 C 版本,使用chmod()系统调用:

  1. 让我们首先使用chmod命令从我们的my-stat-v2程序中删除执行权限。以下命令中的-x表示删除执行
$> chmod -x my-stat-v2
  1. 现在让我们尝试执行程序。这次应该因为权限被拒绝而失败:
$> ./my-stat-v2
bash: ./my-stat-v2: Permission denied
  1. 现在我们再次改回来,但这次我们使用八进制数字设置绝对权限。可执行文件的适当权限是 755,对应rwxr-xr-x。这意味着用户有完全权限,组可以读取和执行文件。其他所有人也一样;他们可以读取和执行它:
$> chmod 755 my-stat-v2
  1. 在这个命令之后,我们可以再次执行程序:
./my-stat-v2 
Usage: ./my-stat-v2 <file>
  1. 现在是时候编写chmod命令的简单版本,使用chmod()系统调用。将以下代码写入文件并保存为my-chmod.cchmod()系统调用接受两个参数,文件或目录的路径和以八进制数表示的文件权限。在进行chmod()系统调用之前,我们进行一些检查,以确保权限看起来合理(一个三位或四位数的八进制数)。检查后,我们使用strtol()将数字转换为八进制数。strtol()的第三个参数是基数,这里是8
#include <stdio.h>
#include <sys/stat.h>
#include <string.h>
#include <stdlib.h>
void printUsage(FILE *stream, char progname[]);
int main(int argc, char *argv[])
{
   long int accessmode; /*To hold the access mode*/
   /* Check that the user supplied two arguments */
   if (argc != 3)
   {
      printUsage(stderr, argv[0]);
      return 1;
   }
   /* Simple check for octal numbers and 
      correct length */
   if( strspn(argv[1], "01234567\n") 
         != strlen(argv[1]) 
         || ( strlen(argv[1]) != 3 && 
              strlen(argv[1]) != 4 ) )
   {
      printUsage(stderr, argv[0]);
      return 1;
   }
   /* Convert to octal and set the permissions */
   accessmode = strtol(argv[1], NULL, 8);
   if (chmod(argv[2], accessmode) == -1)
   {
      perror("Can't change permissions");
   }
   return 0;
}
void printUsage(FILE *stream, char progname[])
{
    fprintf(stream, "Usage: %s <numerical "
        "permissions> <path>\n", progname);
}
  1. 现在编译程序:
$> make my-chmod
gcc -Wall -Wextra -pedantic -std=c99    my-chmod.c   -o my-chmod
  1. 使用不同的权限测试程序。不要忘记使用ls -l检查结果:
$> ./my-chmod 
Usage: ./my-chmod <numerical permissions> <path>
$> ./my-chmod 700 my-stat-v2
$> ls -l my-stat-v2
-rwx------ 1 jake jake 17072 Nov  1 07:29 my-stat-v2
$> ./my-chmod 750 my-stat-v2
$> ls -l my-stat-v2
-rwxr-x--- 1 jake jake 17072 Nov  1 07:29 my-stat-v2
  1. 让我们也尝试设置设置用户 ID 位。这里的设置用户 ID 位(以及设置组 ID 位和粘性位)是访问模式前面的第四位数字。这里的4设置了设置用户 ID 位。请注意用户字段中的s(在下面的代码中突出显示):
$> chmod 4755 my-stat-v2
$> ls -l my-stat-v2
-rwsr-xr-x 1 jake jake 17072 Nov  1 07:29 my-stat-v2
  1. 让我们尝试设置所有位(设置用户 ID、设置组 ID、粘性位和所有权限):
$> chmod 7777 my-stat-v2
$> ls -l my-stat-v2
-rwsrwsrwt 1 jake jake 17072 Nov  1 07:29 my-stat-v2
  1. 最后,将其改回更合理的东西:
$> chmod 755 my-stat-v2
$> ls -l my-stat-v2
-rwxr-xr-x 1 jake jake 17072 Nov  1 07:29 my-stat-v2

所有权

但我们也需要知道如何改变chown命令或chown()系统调用:

  1. 要改变文件的所有者,我们必须是 root。普通用户不能放弃对他们的文件的所有权。同样,他们也不能声明对别人的文件的所有权。让我们尝试使用chown命令将my-stat-v2的所有者更改为 root:
$> sudo chown root my-stat-v2
$> ls -l my-stat-v2
-rwxr-xr-x 1 root jake 17072 Nov  1 07:29 my-stat-v2
  1. 如果我们想要改变所有者和组,我们使用冒号分隔用户和组。第一个字段是所有者,第二个字段是组:
$> sudo chown root:root my-stat-v2
$> ls -l my-stat-v2
-rwxr-xr-x 1 root root 17072 Nov  1 07:29 my-stat-v2
  1. 现在轮到我们编写一个简化版本的chown,使用chown()系统调用。chown()系统调用只接受用户 ID 作为数值。为了能够使用名称,我们必须首先使用getpwnam()查找用户名。这将在passwd结构中的pw_uid字段中给我们数值。对于组也是一样。我们必须使用getgrnam()系统调用使用其名称获取数值组 ID。现在我们知道了所有的系统调用,让我们写程序。将其命名为my-chown.c。这个程序有点长,所以我把它分成了几个步骤。请记住,所有步骤都应该放在一个文件(my-chown.c)中。如果愿意,您也可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch5/my-chown.c下载整个代码。让我们从所有的头文件、变量和参数检查开始:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <pwd.h>
#include <grp.h>
#include <string.h>
#include <errno.h>
int main(int argc, char *argv[])
{
   struct passwd *user; /* struct for getpwnam */
   struct group *grp; /* struct for getgrnam */
   char *username = { 0 }; /* extracted username */
   char *groupname = { 0 }; /*extracted groupname*/
   unsigned int uid, gid; /* extracted UID/GID */
   /* Check that the user supplied two arguments 
      (filename and user or user:group) */
   if (argc != 3)
   {
      fprintf(stderr, "Usage: %s [user][:group]" 
         " [path]\n", argv[0]);
      return 1;
   }
  1. 由于我们将用户名和组写为username:group在参数中,我们需要提取用户名部分和组部分。我们使用一个名为strtok()的字符串函数来做到这一点。在第一次调用strtok()时,我们只提供第一个参数(字符串)。之后,我们得到user结构和grp结构。我们还检查用户和组是否存在:
 /* Extract username and groupname */
   username = strtok(argv[1], ":");
   groupname = strtok(NULL, ":");

   if ( (user = getpwnam(username)) == NULL )
   {
      fprintf(stderr, "Invalid username\n");
      return 1;
   }
   uid = user->pw_uid; /* get the UID */
   if (groupname != NULL) /* if we typed a group */
   {
      if ( (grp = getgrnam(groupname)) == NULL )
      {
         fprintf(stderr, "Invalid groupname\n");
         return 1;
      }
      gid = grp->gr_gid; /* get the GID */
   }
   else
   {
      /* if no group is specifed, -1 won't change 
         it (man 2 chown) */
      gid = -1;
   }
  1. 最后,我们使用chown()系统调用来更新文件的用户和组:
   /* update user/group (argv[2] is the filename)*/
   if ( chown(argv[2], uid, gid) == -1 )
   {
      perror("Can't change owner/group");
      return 1;
   }
   return 0;
}
  1. 让我们编译程序,这样我们就可以尝试它:
$> make my-chown
gcc -Wall -Wextra -pedantic -std=c99    my-chown.c   -o my-chown
  1. 现在我们在一个文件上测试程序。请记住,我们需要以 root 身份更改文件的所有者和组:
$> ls -l my-stat-v2 
-rwxr-xr-x 1 root root 17072 nov  7 19:59 my-stat-v2
$> sudo ./my-chown jake my-stat-v2 
$> ls -l my-stat-v2 
-rwxr-xr-x 1 jake root 17072 nov  7 19:59 my-stat-v2
$> sudo ./my-chown carl:carl my-stat-v2 
$> ls -l my-stat-v2 
-rwxr-xr-x 1 carl carl 17072 nov  7 19:59 my-stat-v2

它是如何工作的...

系统上的每个文件和目录都有访问权限和一个所有者/组对。访问权限可以使用chmod命令或chmod()系统调用来更改。该名称是更改模式位的缩写。在上一个示例中,我们介绍了如何在更人类可读的文本格式和数字八进制格式之间转换访问权限。在这个示例中,我们编写了一个使用chmod()系统调用使用数字形式更改模式位的程序。

为了将数字形式转换为八进制数,我们使用strtol()8作为第三个参数,这是数字系统的基数。基数 8 是八进制;基数 10 是我们在日常生活中使用的常规十进制系统;基数 16 是十六进制,依此类推。

我们编写了程序,以便用户可以选择他们想要设置的任何内容,无论是只有访问模式位(三位数)还是特殊位,如设置用户 ID、设置组 ID 和粘性位(四位数)。为了确定用户输入的数字位数,我们使用strlen()

在下一个程序中,我们使用chown()来更新文件或目录的所有者和组。由于我们想要使用名称而不是数字 UID 和 GID 来更新用户和组,程序变得更加复杂。chown()系统调用只接受 UID 和 GID,而不是名称。这意味着我们需要在调用chown()之前查找 UID 和 GID。为了查找 UID 和 GID,我们使用getpwnam()getgrnam()。这些函数中的每一个都给我们一个包含相应用户或组的所有可用信息的struct。从这些结构中,我们提取 UID 和 GID,然后在调用chown()时使用它们。

为了从命令行中分离用户名和组部分(冒号),我们使用strtok()函数。在对函数的第一次调用中,我们将字符串指定为第一个参数(在本例中为argv[1]),并指定分隔符(冒号)。在对strtok()的下一次调用中,我们将字符串设置为NULL,但仍然指定分隔符。第一次调用给我们用户名,第二次调用给我们组名。

之后,当我们调用getpwnam()getgrnam()时,我们检查用户名和组名是否存在。如果用户名或组名不存在,函数将返回NULL

还有更多...

有几个类似的函数可以使用getpwnam()getgrnam(),具体取决于您拥有的信息和您拥有的信息。如果您有 UID,您可以使用getpwuid()。同样,如果您有 GID,您可以使用getgrgid()。如果您阅读man 3 getpwnamman 3 getgrnam手册页面,将会有更多的信息和更多的函数。

使用文件描述符写入文件

在之前的章节中,我们已经看到了文件描述符的一些用法,例如 0、1 和 2(stdinstdoutstderr)。但在这个示例中,我们将使用文件描述符从程序中写入文本到文件。

了解如何使用文件描述符来写入文件既可以让您更深入地了解系统,也可以让您做一些底层的事情。

准备工作

对于这个示例,您只需要在技术要求部分列出的内容。

如何做...

在这里,我们将编写一个小程序来向文件写入文本:

  1. 在文件中写入以下代码,并将其保存为fd-write.c。该程序接受两个参数:一个字符串和一个文件名。要使用文件描述符写入文件,我们必须首先使用open()系统调用打开文件。open()系统调用返回一个文件描述符,这是一个整数。然后我们使用该文件描述符(整数)与write()系统调用。我们已经在*第三章**中看到了write(),在那一章中,我们使用write()将一个小文本写入标准输出。这一次,我们使用write()将文本写入文件。请注意,open()系统调用接受三个参数:文件的路径,文件应该以哪种模式打开(在这种情况下,如果文件不存在则创建文件,并以读写模式打开),以及0644):
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
int main(int argc, char *argv[])
{
   int fd; /* for the file descriptor */
   if (argc != 3)
   {
      fprintf(stderr, "Usage: %s [path] [string]\n",
         argv[0]);
      return 1;
   }
   /* Open the file (argv[1]) and create it if it 
      doesn't exist and set it in read-write mode. 
      Set the access mode to 644 */
   if ( (fd = open(argv[1], O_CREAT|O_RDWR, 00644)) 
      == -1 )
   {
      perror("Can't open file for writing");
      return 1;
   }
   /* write content to file */
   if ( (write(fd, argv[2], strlen(argv[2]))) 
      == -1 )
   {
      perror("Can't write to file");
      return 1;
   }
   return 0;
}
  1. 让我们编译这个程序:
$> make fd-write
gcc -Wall -Wextra -pedantic -std=c99    fd-write.c   -o fd-write
  1. 让我们尝试向文件中写入一些文本。请记住,如果文件已经存在,内容将被覆盖!如果新文本比文件的旧内容小,那么只有开头会被覆盖。还要注意,如果文本不包含换行符,那么文件中的文本也不会包含换行符:
$> ./fd-write testfile1.txt "Hello! How are you doing?"
$> cat testfile1.txt 
Hello! How are you doing?$>*Enter*
$> ls -l testfile1.txt 
-rw-r--r-- 1 jake jake 2048 nov  8 16:34 testfile1.txt
$> ./fd-write testfile1.txt "A new text"
$> cat testfile1.txt 
A new text are you doing?$>
  1. 我们甚至可以从另一个文件中输入内容,如果我们使用xargs,这是一个允许我们将程序的输出解析为另一个程序的命令行参数的程序。请注意,这一次,testfile1将在末尾有一个换行符。xargs-0选项使其忽略换行符,而是使用空字符来表示参数的结尾:
$> head -n 3 /etc/passwd | xargs -0 \
> ./fd-write testfile1.txt 
$> cat testfile1.txt 
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin

工作原理…

open()系统调用返回一个文件描述符,我们将其保存在fd变量中。文件描述符只是一个整数,就像 0、1 和 3 是stdinstdoutstderr一样。

我们给open()的第二个参数是使用按位或组合在一起的模式位的宏。在我们的情况下,我们同时使用O_CREATO_RDWR。第一个O_CREAT表示如果文件不存在,则创建文件。第二个O_RDWR表示文件应该同时用于读取和写入。

要将字符串写入文件,我们将文件描述符作为第一个参数传递给write()。作为第二个参数,我们给它argv[2],其中包含我们要写入文件描述符的字符串。最后一个参数是我们要写入的内容的大小。在我们的情况下,我们使用strlen来获取argv[2]的大小,这是string.h中的一个函数,用于获取字符串的长度。

就像在以前的食谱中一样,我们检查所有系统调用是否返回-1。如果它们返回-1,则表示出现了问题,我们使用perror()打印错误消息,然后返回1

还有更多…

当程序正常返回时,所有打开的文件描述符都会自动关闭。但是,如果我们想显式关闭文件描述符,我们可以使用close()系统调用,并将文件描述符作为其参数。在我们的情况下,我们可以在返回之前添加close(fd)

手册页面中有关open()close()write()的很多有用信息。我建议您阅读它们以获取更深入的信息。您可以使用以下命令阅读它们:

  • man 2 open

  • man 2 close

  • man 2 write

使用文件描述符从文件中读取

在上一个食谱中,我们学会了如何使用文件描述符写入文件。在这个食谱中,我们将学习如何使用文件描述符从文件中读取。因此,我们将编写一个类似于cat的小程序。它接受一个参数——文件名,并将其内容打印到标准输出。

了解如何读取和使用文件描述符使您不仅可以读取文件,还可以读取通过文件描述符传输的各种数据。文件描述符是在 Unix 和 Linux 中读取和写入数据的通用方式。

准备工作

这个食谱所需的唯一物品在本章的技术要求部分列出。

如何做…

使用文件描述符读取文件与写入文件类似。我们将使用read()系统调用,而不是使用write()系统调用。在我们读取内容之前,我们必须先找出文件的大小。我们可以使用fstat()系统调用来获取这个信息,它会给我们关于文件描述符的信息:

  1. 将以下代码写入一个文件,并将其命名为fd-read.c。注意我们如何使用fstat()获取文件信息,然后使用read()读取数据。我们仍然使用open()系统调用,但这次我们已经移除了O_CREATE并将O_RDRW更改为O_RDONLY以只允许读取。我们将在这里使用缓冲区大小为 4,096,以便能够读取一些更大的文件。这个程序有点长,所以我把它分成了几个步骤。所有步骤中的代码都放在一个文件中。首先,我们从编写所有的include行、变量和参数检查开始:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#define MAXSIZE 4096
int main(int argc, char *argv[])
{
   int fd; /* for the file descriptor */
   int maxread; /* the maximum we want to read*/
   off_t filesize; /* for the file size */
   struct stat fileinfo; /* struct for fstat */
   char rbuf[MAXSIZE] = { 0 }; /* the read buffer*/

   if (argc != 2)
   {
      fprintf(stderr, "Usage: %s [path]\n",
         argv[0]);
      return 1;
   }
  1. 现在,我们编写打开文件描述符的代码,使用open()系统调用。我们还添加了一些错误处理,将其包装在一个if语句中:
   /* open the file in read-only mode and get
      the file size */
   if ( (fd = open(argv[1], O_RDONLY)) == -1 )
   {
      perror("Can't open file for reading");
      return 1;
   }
  1. 现在,我们编写代码,使用fstat()系统调用获取文件的大小。在这里,我们还检查文件的大小是否大于MAXSIZE,如果是,我们将maxread设置为MAXSIZE-1。否则,我们将其设置为文件的大小。然后,我们使用read()系统调用读取文件。最后,我们使用printf()打印内容:
   fstat(fd, &fileinfo);
   filesize = fileinfo.st_size;
   /* determine the max size we want to read
      so we don't overflow the read buffer */
   if ( filesize >= MAXSIZE )
      maxread = MAXSIZE-1;
   else
      maxread = filesize;

   /* read the content and print it */
   if ( (read(fd, rbuf, maxread)) == -1 )
   {
      perror("Can't read file");
      return 1;
   }
   printf("%s", rbuf);
   return 0;
}
  1. 让我们编译程序:
$> make fd-read
gcc -Wall -Wextra -pedantic -std=c99    fd-read.c   -o fd-read
  1. 让我们尝试在一些文件上运行它,看看我们是否可以读取它们:
$> ./fd-read testfile1.txt 
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
$> ./fd-read Makefile 
CC=gcc
CFLAGS=-Wall -Wextra -pedantic -std=c99
$> ./fd-read /etc/shadow
Can't open file for reading: Permission denied
$> ./fd-read asdfasdf
Can't open file for reading: No such file or directory

工作原理...

当我们从文件描述符中读取数据时,我们必须指定要读取多少个字符。在这里,我们必须小心不要溢出缓冲区。我们也不想读取比文件实际包含的更多内容。为了解决所有这些问题,我们首先使用fstat()找出文件的大小。该函数给我们提供了与我们之前在my-stat-v2程序中使用stat()看到的相同的信息。这两个函数stat()fstat()做着相同的事情,但它们作用于不同的对象。stat()函数直接作用于文件,而fstat()作用于文件描述符。由于我们已经打开了正确文件的文件描述符,因此使用它是有意义的。这两个函数都将它们的信息保存到一个名为stat的结构体中。

为了不溢出缓冲区,我们检查文件大小和MAXSIZE哪个更大。如果文件大小大于或等于MAXSIZE,我们使用MAXSIZE-1作为要读取的最大字符数。否则,我们使用文件的大小作为最大值。

read()系统调用和write()接受相同的参数,即文件描述符、缓冲区和要读取的大小(或者在write()的情况下是要写入的大小)。

由于我们从文件中读取的是一堆字符,我们可以使用常规的printf()将整个缓冲区打印到 stdout。

还有更多...

如果您查阅man 2 fstat,您会注意到它与man 2 stat是同一个手册页。

使用文件流写入文件

在本篇中,我们将使用文件流而不是文件描述符来写入文件,就像我们在之前的篇章中所做的那样。

与之前我们已经看到的文件描述符 1、2 和 3 以及它们的一些系统调用一样,我们也已经看到了文件流,比如我们创建的一些printUsage()函数。我们创建的一些函数接受两个参数,第一个声明为FILE *stream。我们提供的参数是 stderr 或 stdout。

但是我们也可以使用文件流来写入文件,这就是本篇中要做的事情。

您可能已经注意到,一些东西一遍又一遍地出现,比如文件描述符和文件流。

使用文件流而不是文件描述符有一些优势。例如,使用文件流,我们可以使用fprintf()等函数来写入文件。这意味着有更多和更强大的函数来读写数据。

准备工作

对于这个示例,我们只需要本章节“技术要求”部分列出的内容。

如何做…

在这里,我们编写一个将文本写入文件的程序。该程序将类似于我们之前使用文件描述符编写的内容。但这次,我们将从标准输入而不是从命令行读取文本。我们还将使用文件流而不是文件描述符来写入文本:

  1. 将以下代码写入文件并命名为stream-write.c。请注意,尽管我们已经添加了一个while循环来从标准输入读取所有内容,但这个程序要小得多。由于我们可以使用在流上操作的所有 C 函数,因此我们不需要使用任何特殊的系统调用来读取、写入等。我们甚至没有包含任何特殊的头文件,除了我们总是包含的stdio.h。我们使用fprintf()将文本写入文件,就像我们在写入 stdout 或 stderr 时已经看到的那样:
#include <stdio.h>
int main(int argc, char *argv[])
{
   FILE *fp; /* pointer to a file stream */
   char linebuf[1024] = { 0 }; /* line buffer */
   if ( argc != 2 )
   {
      fprintf(stderr, "Usage: %s [path]\n", 
         argv[0]);
      return 1;
   }
   /* open file with write mode */
   if ( (fp = fopen(argv[1], "w")) == NULL )
   {
      perror("Can't open file for writing");
      return 1;
   } 

   /*loop over each line and write it to the file*/
   while(fgets(linebuf, sizeof(linebuf), stdin) 
      != NULL)
   {
      fprintf(fp, linebuf);
   }
   fclose(fp); /* close the stream */
   return 0;
}
  1. 让我们编译程序:
$> make stream-write
gcc -Wall -Wextra -pedantic -std=c99    stream-write.c   -o stream-write
  1. 现在让我们尝试该程序,一种是通过向其输入数据,另一种是通过使用管道重定向数据。在我们使用程序将整个密码文件重定向到新文件后,我们使用diff检查它们是否相同,它们应该是相同的。我们还尝试向一个没有权限的目录中写入新文件。当我们按下Ctrl + D时,我们向程序发送EOF,表示不再接收更多数据:
$> ./stream-write my-test-file.txt
Hello! How are you doing?
I'm doing just fine, thank you. 
*Ctrl*+*D*
$> cat my-test-file.txt 
Hello! How are you doing?
I'm doing just fine, thank you.
$> cat /etc/passwd | ./stream-write my-test-file.txt
$> tail -n 3 my-test-file.txt 
telegraf:x:999:999::/etc/telegraf:/bin/false
_rpc:x:103:65534::/run/rpcbind:/usr/sbin/nologin
systemd-coredump:x:997:997:systemd Core Dumper:/:/usr/sbin/nologin
$> diff /etc/passwd my-test-file.txt
$> ./stream-write /a-new-file.txt
Can't open file for writing: Permission denied

工作原理…

您可能已经注意到,尽管我们在本章的前面编写的相应文件描述符版本要添加一个while循环来从标准输入读取所有内容,但这个程序要比那个版本简短得多。

我们首先创建一个指向文件流的指针,使用FILE *fp。然后我们创建一个用于每行的缓冲区。

然后,我们使用fopen()打开文件流。该函数需要两个参数,文件名和模式。这里的模式也更容易设置,只需使用"w"表示写入。

之后,我们使用while循环来循环处理来自标准输入的每一行输入。在每次迭代中,我们使用fprintf()将当前行写入文件。作为fprintf()的第一个参数,我们使用文件流指针,就像我们在程序顶部的if语句中使用 stderr 一样。

在程序返回之前,我们使用fclose()关闭文件流。关闭流并不是严格必要的,但以防万一做这件事是件好事。

另请参阅

如果您想深入了解,可以在man 3 fopen中找到大量信息。

有关文件描述符和文件流之间区别的更深入解释,请参阅 GNU libc 手册:www.gnu.org/software/libc/manual/html_node/Streams-and-File-Descriptors.html

流的另一个重要方面是它们是有缓冲的。有关流缓冲的更多信息,请参阅 GNU libc 手册的以下网址:www.gnu.org/software/libc/manual/html_node/Buffering-Concepts.html

使用流从文件中读取

现在我们知道如何使用流写入文件,我们将学习如何使用流读取文件。在这个示例中,我们将编写一个类似于上一个示例的程序。但这次,我们将逐行从文件中读取并将其打印到标准输出。

掌握流的写入和读取将使您能够在 Linux 中做很多事情。

准备工作

您只需要本章节“技术要求”部分列出的内容。

如何做…

在这里,我们将编写一个与上一个示例非常相似的程序,但它将从文件中读取文本。该程序的原理与上一个示例相同:

  1. 在文件中写入以下代码,并将其保存为stream-read.c。注意这个程序是多么相似。我们已经改变了写入模式("w")为读取模式("r"),当使用fopen()打开流时。在while循环中,我们从文件指针fp而不是标准输入中读取。在while循环中,我们打印缓冲区中的内容,也就是当前行:
#include <stdio.h>
int main(int argc, char *argv[])
{
   FILE *fp; /* pointer to a file stream */
   char linebuf[1024] = { 0 }; /* line buffer */
   if ( argc != 2 )
   {
      fprintf(stderr, "Usage: %s [path]\n", 
         argv[0]);
      return 1;
   }
   /* open file with read mode */
   if ( (fp = fopen(argv[1], "r")) == NULL )
   {
      perror("Can't open file for reading");
      return 1;
   } 

   /* loop over each line and write it to stdout */
   while(fgets(linebuf, sizeof(linebuf), fp) 
      != NULL)
   {
      printf("%s", linebuf);
   }
   fclose(fp); /* close the stream */
   return 0;
}
  1. 编译程序:
$> make stream-read
gcc -Wall -Wextra -pedantic -std=c99    stream-read.c   -o stream-read
  1. 现在我们可以在一些文件上尝试这个程序。这里我在之前创建的测试文件和 Makefile 上尝试它:
$> ./stream-read testfile1.txt 
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
$> ./stream-read Makefile 
CC=gcc
CFLAGS=-Wall -Wextra -pedantic -std=c99

它是如何工作的…

正如你可能已经注意到的,这个程序与上一个配方非常相似。但是,我们不是以写入模式("w")打开文件,而是以读取模式("r")打开文件。文件指针看起来一样,以及行缓冲区和错误处理。

为了读取每一行,我们使用fgets()循环遍历文件流。正如你可能已经注意到的,在这个和上一个配方中,我们没有使用sizeof(linebuf)-1,只使用了sizeof(linebuf)。这是因为fgets()只读取比我们给它的大小少一个

还有更多…

有很多类似的函数,比如fgets()。你可以通过阅读它的手册页man 3 fgets找到所有这些函数。

使用流读取和写入二进制数据

有时候我们需要将程序中的变量或数组保存到文件中。例如,如果我们为仓库制作一个库存管理程序,我们不希望每次启动程序时都重新编写整个仓库库存。这将违背程序的初衷。使用流,可以轻松地将变量保存为二进制数据文件以供以后检索。

在本章中,我们将编写两个小程序:一个要求用户输入两个浮点数,将它们保存在一个数组中,并将它们写入文件,另一个程序重新读取该数组。

准备工作

对于这个配方,你只需要 GCC 编译器、Make 工具和通用 Makefile。

如何做…

在这个配方中,我们将编写两个小程序:一个用于写入,一个用于读取二进制数据。数据是一个浮点数数组:

  1. 在文件中写入以下代码,并将其保存为binary-write.c。注意我们以写入模式和二进制模式打开文件,这由fopen()的第二个参数"wb"表示。在二进制模式下,我们可以将变量、数组和结构写入文件。这个程序中的数组将被写入到当前工作目录中名为my-binary-file的文件中。当我们使用fwrite()写入二进制数据时,我们必须指定单个元素的大小(在这种情况下是float)以及我们想要写入的元素数量。fwrite()的第二个参数是单个元素的大小,第三个参数是元素的数量:
#include <stdio.h>
int main(void)
{
   FILE *fp;
   float x[2];
   if ( (fp = fopen("my-binary-file", "wb")) == 0 )
   {
      fprintf(stderr, "Can't open file for "
         "writing\n");
      return 1;
   }
   printf("Type two floating point numbers, "
      "separated by a space: ");
   scanf("%f %f", &x[0], &x[1]);
   fwrite(&x, sizeof(float), 
      sizeof(x) / sizeof(float), fp);
   fclose(fp);
   return 0;
}
  1. 在继续之前,让我们编译这个程序:
$> make binary-write
gcc -Wall -Wextra -pedantic -std=c99    binary-write.c   -o binary-write
  1. 让我们尝试运行程序,并验证它是否写入了二进制文件。由于它是一个二进制文件,我们无法使用more等程序来读取它。但是,我们可以使用一个名为hexdump的程序来查看它:
$> ./binary-write 
Type two floating point numbers, separated by a space: 3.14159 2.71828
$> file my-binary-file 
my-binary-file: data
$> hexdump -C my-binary-file 
00000000  d0 0f 49 40 4d f8 2d 40            |..I@M.-@|
00000008
  1. 现在是时候编写从文件中重新读取数组的程序了。在文件中写入以下代码,并将其保存为binary-ready.c。请注意,我们在这里使用了"rb",表示读取二进制fread()的参数与fwrite()相同。另外,请注意我们需要在这里创建一个相同类型和长度的数组。我们将从二进制文件中读取数据到该数组中:
#include <stdio.h>
int main(void)
{
   FILE *fp;
   float x[2];
   if ( (fp = fopen("my-binary-file", "rb")) == 0 )
   {
      fprintf(stderr, "Can't open file for "
         "reading\n");
      return 1;
   }
   fread(&x, sizeof(float), 
      sizeof(x) / sizeof(float), fp);
   printf("The first number was: %f\n", x[0]);
   printf("The second number was: %f\n", x[1]);
   fclose(fp);
   return 0;
}
  1. 现在,让我们编译这个程序:
$> make binary-read
gcc -Wall -Wextra -pedantic -std=c99    binary-read.c   -o binary-read
  1. 最后,让我们运行程序。请注意,这里打印的数字与我们给binary-write的数字相同:
$> ./binary-read 
The first number was: 3.141590
The second number was: 2.718280

它是如何工作的…

重要的是fwrite()fread(),更具体地说是我们指定的大小:

fwrite(&x, sizeof(float), sizeof(x) / sizeof(float), fp);

首先,我们有x数组。接下来,我们指定单个元素或项目的大小。在这种情况下,我们使用sizeof(float)来获取大小。然后,作为第三个参数,我们指定这些元素或项目的数量。在这里,我们不只是输入一个字面上的2,而是通过取数组的完整大小并除以一个浮点数的大小来计算项目的数量。这是通过sizeof(x) / sizeof(float)完成的。在这种情况下,这给了我们 2。

更好地计算项目而不只是设置一个数字的原因是为了避免在将来更新代码时出现错误。如果我们在几个月内将数组更改为 6 个项目,很可能会忘记更新fread()fwrite()的参数。

还有更多…

如果我们事先不知道数组包含多少个浮点数,我们可以用以下代码行来计算出来。我们将在本章后面学习更多关于fseek()的知识:

fseek(fp, 0, SEEK_END); /* move to the end of the file */
bytes = ftell(fp); /* the total number of bytes */
rewind(fp); /* go back to the start of the file */
items = bytes / sizeof(float); /*number of items (floats)*/

使用lseek()在文件内移动

在这个食谱中,我们将学习如何使用lseek()在文件内移动。这个函数操作lseek(),我们可以在文件描述符内自由移动(或寻找)。这样做可以很方便,如果我们只想读取文件的特定部分,或者我们想返回并读取一些数据两次等。

在这个食谱中,我们将修改我们之前的程序,名为fd-read.c,以指定我们想要开始阅读的位置。我们还使用户可以指定从该位置读取多少个字符。

准备工作

为了更容易理解这个食谱,我鼓励你在阅读这个之前,先阅读本章中名为使用文件描述符从文件中读取的食谱。

操作步骤…

我们将在这里编写的程序将使用文件描述符读取文件。用户还必顶一个读取应该从哪里开始的起始位置。用户还可以选择指定从该位置读取多少个字符:

  1. 写下以下代码并保存在一个名为fd-seek.c的文件中。注意在我们进行read()之前添加了lseek()。我们还添加了一个额外的检查(else if)来检查用户是否读取的字符数超过了缓冲区的容量。当我们将文件打印到标准输出时,在printf()中添加了一个换行符。否则,当我们指定要读取多少个字符时,不会有新的一行,提示符会停留在同一行上。这个程序也相当长,所以我把它分成了几个步骤。请记住,所有步骤都放在同一个文件中。让我们从变量开始并检查参数的数量:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <stdlib.h>
#define MAXSIZE 4096
int main(int argc, char *argv[])
{
   int fd; /* for the file descriptor */
   int maxread; /* the maximum we want to read*/
   off_t filesize; /* for the file size */
   struct stat fileinfo; /* struct for fstat */
   char rbuf[MAXSIZE] = { 0 }; /* the read buffer */
   if (argc < 3 || argc > 4)
   {
      fprintf(stderr, "Usage: %s [path] [from pos] "
         "[bytes to read]\n", argv[0]);
      return 1;
   }
  1. 现在我们使用open()系统调用打开文件。就像以前一样,我们通过将其包装在if语句中来检查系统调用是否出错:
   /* open the file in read-only mode and get
      the file size */
   if ( (fd = open(argv[1], O_RDONLY)) == -1 )
   {
      perror("Can't open file for reading");
      return 1;
   }
  1. 现在,我们使用fstat()系统调用获取文件的大小。在这里,我们还检查文件是否大于MAXSIZE,如果是,我们将maxread设置为MAXSIZE-1。在else if中,我们检查用户是否提供了第三个参数(要读取多少),并将maxread设置为用户输入的值:
   fstat(fd, &fileinfo);
   filesize = fileinfo.st_size;
   /* determine the max size we want to read
      so we don't overflow the read buffer */
   if ( filesize >= MAXSIZE )
   {
      maxread = MAXSIZE-1;
   }
   else if ( argv[3] != NULL )
   {
      if ( atoi(argv[3]) >= MAXSIZE )
      {
         fprintf(stderr, "To big size specified\n");
         return 1;
      }
      maxread = atoi(argv[3]);
   }
   else
   {
      maxread = filesize;
   }
  1. 最后,我们编写代码使用lseek()移动读取位置。然后,我们使用read()读取内容并用printf()打印出来:
   /* move the read position */
   lseek(fd, atoi(argv[2]), SEEK_SET);
   /* read the content and print it */
   if ( (read(fd, rbuf, maxread)) == -1 )
   {
      perror("Can't read file");
      return 1;
   }
   printf("%s\n", rbuf);
   return 0;
}
  1. 现在编译程序:
$> make fd-seek
gcc -Wall -Wextra -pedantic -std=c99    fd-seek.c   -o fd-seek
  1. 让我们尝试一下这个程序。在这里,我们读取当前目录中的密码文件和通用 Makefile:
$> ./fd-seek /etc/passwd 40 100
:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr
$> ./fd-seek Makefile 10
AGS=-Wall -Wextra -pedantic -std=c99
$> ./fd-seek Makefile
Usage: ./fd-seek [path] [from pos] [bytes to read]

工作原理…

lseek()函数将读取头(有时称为光标)移动到我们指定的位置。然后光标保持在那个位置,直到我们开始read()。为了只读取我们指定的第三个参数作为字符数,我们将该参数赋值给maxread。由于read()不会读取超过maxreadread()的第三个参数)的字符,只有这些字符会被读取。如果我们没有给程序第三个参数,maxread将设置为文件的大小或MAXSIZE,以较小者为准。

lseek()的第三个参数SEEK_SET是光标应该相对于我们给出的第二个参数的位置。在这种情况下,使用SEEK_SET意味着位置应该设置为我们指定的第二个参数。如果我们想要相对于当前位置移动位置,我们将使用SEEK_CUR。如果我们想要相对于文件末尾移动光标,我们将使用SEEK_END

使用fseek()在文件中移动

现在我们已经看到了如何使用lseek(),我们可以看看如何在文件流中使用fseek()。在这个示例中,我们将编写一个类似于上一个示例的程序,但现在我们将使用文件流。这里还有另一个区别,即我们如何指定要读取多长时间。在上一个示例中,我们将第三个参数指定为要读取的字符或字节数。但在这个示例中,我们将指定一个位置,即起始位置结束位置

准备工作

我建议您在阅读本章前面的使用流从文件中读取示例之前阅读本节。这将让您更好地理解这里发生了什么。

如何做…

我们将编写一个程序,从给定位置读取文件,可选地到达结束位置。如果没有给出结束位置,则读取文件直到结束:

  1. 在文件中写入以下代码,并将其保存为stream-seek.c。这个程序类似于stream-read.c,但增加了指定起始位置和可选的结束位置的能力。请注意,我们已经添加了fseek()来设置起始位置。为了中止读取,当我们达到结束位置时,我们使用ftell()告诉我们当前位置。如果到达结束位置,我们就跳出while循环。此外,我们不再读取整行,而是读取单个字符。我们使用fgetc()来实现这一点。我们还打印单个字符而不是整个字符串(行)。我们使用putchar()来实现这一点。循环结束后,我们打印一个换行字符,这样提示就不会出现在与输出相同的行上:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
   int ch; /* for each character */
   FILE *fp; /* pointer to a file stream */
   if ( argc < 3 || argc > 4 )
   {
      fprintf(stderr, "Usage: %s [path] [from pos]"
         " [to pos]\n", argv[0]);
      return 1;
   }

   /* open file with read mode */
   if ( (fp = fopen(argv[1], "r")) == NULL )
   {
      perror("Can't open file for reading");
      return 1;
   } 

   fseek(fp, atoi(argv[2]), SEEK_SET);
   /* loop over each line and write it to stdout */
   while( (ch = fgetc(fp)) != EOF )
   {
      if ( argv[3] != NULL)
      {
         if ( ftell(fp) >= atoi(argv[3]) )
         {
            break;
         }
      }
      putchar(ch);
   }
   printf("\n");
   fclose(fp); /* close the stream */
   return 0;
}
  1. 现在让我们来编译它:
$> make stream-seek
gcc -Wall -Wextra -pedantic -std=c99    stream-seek.c   -o stream-seek
  1. 让我们在一些文件上试一试。我们尝试两种可能的组合:只有起始位置,以及起始和结束位置:
$> ./stream-seek /etc/passwd 2000 2100
24:Libvirt Qemu,,,:/var/lib/libvirt:/bin/false
Debian-exim:x:120:126::/var/spool/exim4:/bin/false
s
$> ./stream-seek Makefile 20
-Wextra -pedantic -std=c99

工作原理…

fseek()函数的工作方式与我们在上一个示例中看到的lseek()类似。我们指定SEEK_SET来告诉fseek()寻找绝对位置,并将位置指定为第二个参数。

该程序类似于stream-read.c,但我们已经改变了程序的读取方式。我们不再读取整行,而是读取单个字符。这样我们就可以在指定的结束位置停止读取。如果我们逐行读取,这是不可能的。因为我们改变了按字符读取文件的行为,所以我们也改变了打印文件的方式。现在我们使用putchar()逐个打印每个字符。

每个字符后,我们检查是否在指定的结束位置上或以上。如果是,我们就跳出循环并结束整个读取。

还有更多…

存在一整套与fseek()相关的函数。您可以通过阅读man 3 fseek手册页面找到它们。

第六章:生成进程和使用作业控制

在本章中,我们将了解系统上如何创建进程,哪个进程是第一个进程,以及所有进程如何相互关联。然后,我们将学习 Linux 中涉及进程和进程管理的许多术语。之后,我们将学习如何分叉新进程以及僵尸孤儿是什么。在本章结束时,我们将学习守护进程是什么以及如何创建它,然后学习信号是什么以及如何实现它们。

了解系统上如何创建进程对于实现良好的守护进程、处理安全性和创建高效的程序至关重要。它还将让您更好地了解整个系统。在本章中,我们将涵盖以下示例:

  • 探索进程是如何创建的

  • 在 Bash 中使用作业控制

  • 使用信号控制和终止进程

  • execl()替换进程中的程序

  • 分叉进程

  • 在分叉进程中执行新程序

  • 使用system()启动新进程

  • 创建僵尸进程

  • 了解孤儿进程是什么

  • 创建守护进程

  • 实现信号处理程序

让我们开始吧!

技术要求

在本章中,您将需要 GCC 编译器和 Make 工具。我们在[第一章](B13043_01_Final_SK_ePub.xhtml#_idTextAnchor020)中安装了这些工具,获取必要的工具并编写我们的第一个 Linux 程序。

您还需要一个名为pstree的新程序来完成本章。您可以使用软件包管理器安装它。如果您使用的是 Debian 或 Ubuntu,可以使用sudo apt install psmisc进行安装。另一方面,如果您使用的是 Fedora 或 CentOS,可以使用sudo dnf install psmisc进行安装。

您还需要我们在[第三章](B13043_03_Final_SK_ePub.xhtml#_idTextAnchor097)中编写的通用Makefile。Makefile 也可以在 GitHub 上找到,本章的所有代码示例也可以在github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch6找到。

查看以下链接以查看代码演示视频:bit.ly/3cxY0eQ

探索进程是如何创建的

在深入了解如何创建进程和守护进程之前,我们需要对进程有一个基本的理解。获得这种理解的最佳方法是查看系统上已经运行的进程,这就是我们将在本示例中要做的事情。

系统上的每个进程都是通过从另一个进程生成(forked)而开始其生命周期的。在 Unix 和 Linux 系统上使用的第一个进程历史上一直是init进程,现代 Linux 发行版已将其替换为systemd。它们都具有相同的目的;启动系统的其余部分。

典型的进程树可能如下所示,其中用户通过终端登录(即,如果我们跳过 X Window 登录的复杂性):

|- systemd (1)
  \- login (6384)
    \- bash (6669)
      \- more testfile.txt (7184)

进程 ID 是括号中的数字。systemd(或一些旧系统上的init)有一个init,即使使用的是systemd。在这种情况下,init只是指向systemd的链接。仍然有一些使用init的 Linux 系统。

当涉及编写系统程序时,深入了解进程生成是至关重要的。例如,当我们想要创建一个守护进程时,我们经常会生成一个新进程。还有许多其他用例,我们必须生成进程或从现有进程执行新程序。

准备就绪

对于这个示例,您将需要pstreepstree的安装说明列在本章的技术要求部分中。

如何做…

在这个示例中,我们将查看我们的系统和它运行的进程。我们将使用pstree来获得这些进程的可视化表示。让我们开始吧:

  1. 首先,我们需要一种方法来获取我们当前的进程 ID。$$环境变量包含当前 shell 的PID。请注意,PID 在每个系统上以及从一次到另一次都会有所不同:
$> echo $$
18817
  1. 现在,让我们用pstree来查看我们当前的进程,以及它的父进程和子进程。父进程是启动该进程的进程,而子进程是其下的任何进程:
$> pstree -A -p -s $$
systemd(1)---tmux (4050)---bash(18817)---pstree(18845)
  1. pstree命令的输出在您的计算机上很可能会有所不同。您可能有xtermkonsolemate-terminal或类似的东西,而不是tmux-A选项表示使用 ASCII 字符打印行,-p选项表示打印 PID 号,-s选项表示我们要显示所选进程的父进程(在我们的情况下是$$)。在我的例子中,tmuxsystemd的子进程,bashtmux的子进程,pstreebash的子进程。

  2. 一个进程也可以有多个子进程。例如,我们可以在 Bash 中启动多个进程。在这里,我们将启动三个sleep进程。每个sleep进程将休眠 120 秒。然后我们将打印另一个pstree。在这个例子中,pstree和三个sleep进程都是bash的子进程:

$> sleep 120 &
[1] 21902
$> sleep 120 &
[2] 21907
$> sleep 120 &
[3] 21913
$> pstree -A -p -s $$
systemd(1)---tmux (4050)---bash(18817)-+-pstree(21919)
                                       |-sleep(21902)
                                       |-sleep(21907)
                                       `-sleep(21913)
  1. 在本章的开头,我们提供了一个显示名为login的进程的示例进程树。该进程最初是作为管理系统 TTY 的进程getty启动的。getty/login的概念,切换到 TTY3,使用Ctrl+Alt+F3进行激活。然后,返回到 X(通常在Ctrl+Alt+F7Ctrl+Alt+F1)。在这里,我们将使用grepps来查找 TTY3 并记录其 PID。ps程序用于查找和列出系统上的进程。然后,我们将使用用户在 TTY3 上登录(Ctrl+Alt+F3)。之后,我们需要再次返回到我们的 X Window 会话(和我们的终端),并使用grep来找到我们从 TTY3 中记录的 PID。该进程中的程序现在已被替换为login。换句话说,一个进程可以替换其程序:
Ctrl+Alt+F3
login: 
Ctrl+Alt+F7
$> ps ax | grep tty3
9124 tty3     Ss+    0:00 /sbin/agetty -o -p -- \u --
noclear tty3 linux
Ctrl+Alt+F3
login: jake
Password: 
$> 
Ctrl+Alt+F7
$> ps ax | grep 9124
9124 tty3     Ss     0:00 /bin/login -p –

工作原理…

在这个教程中,我们学习了关于 Linux 系统上进程的几个重要概念。我们将需要这些知识继续前进。首先,我们了解到所有进程都是从现有进程中生成的。第一个进程是init。在较新的 Linux 发行版中,这是指向systemd的符号链接。然后,systemd在系统上生成几个进程,比如getty,来处理终端。当用户开始在 TTY 上登录时,getty会被login替换,这个程序处理登录。当用户最终登录时,login进程为用户生成一个 shell,比如 Bash。然后,每当用户执行一个程序时,Bash 会生成一个自身的副本,并用用户执行的程序替换它。

为了澄清一下进程/程序术语:getty/login示例。

在这个教程中使用 TTY3 的原因是,我们可以通过getty/login获得一个真正的登录过程,而在通过 X Window 会话或 SSH 登录时我们无法获得。

进程 ID 表示为 PID。父进程 ID 表示为1)。

我们还了解到一个进程可以有多个子进程,就像sleep进程的示例一样。我们在sleep进程的末尾使用&符号启动了sleep进程。这个&符号告诉 shell 我们要在后台启动该进程。

还有更多…

TTY 的首字母缩写来自于过去的实际电传打字机连接到机器上。电传打字机是一种看起来像打字机的终端。您在打字机上输入命令,然后在纸上读取响应。对于任何对电传打字机感兴趣的人,哥伦比亚大学在www.columbia.edu/cu/computinghistory/teletype.html上有一些令人兴奋的图片和信息。

在 Bash 中使用作业控制

作业控制不仅能让你更好地理解前台和后台进程,还能让你在终端上工作时更加高效。能够将一个进程放到后台可以让你的终端做其他任务。

准备工作

这个教程不需要特别的要求,除了 Bash shell。Bash 通常是默认的 shell,所以你很可能已经安装了它。

操作方法…

在这个教程中,我们将启动和停止几个进程,将它们发送到后台,并将它们带回前台。这将让我们了解后台和前台进程。让我们开始吧:

  1. 之前,我们已经看到如何使用&在后台启动一个进程。我们将在这里重复这个步骤,但我们还将列出当前正在运行的作业,并将其中一个带到前台。我们将在这里启动的第一个后台进程是sleep,而另一个是手册页面:
$> sleep 300 &
[1] 30200
$> man ls &
[2] 30210
  1. 现在我们在jobs中有两个进程:
$> jobs
[1]-  Running                 sleep 300 &
[2]+  Stopped                 man ls
  1. sleep进程处于运行状态,这意味着程序中的秒数正在减少。man ls命令已经停止了。man命令正在等待你对它做一些事情,因为它需要一个终端。所以,现在它什么也不做。我们可以使用fg命令(fg命令是jobs列表中的作业 ID)将它带到前台:
$> fg 2
  1. Q退出手册页面。man ls将出现在屏幕上。

  2. 现在,使用fg 1sleep进程带到前台。它只显示sleep 300,没有更多的信息。但现在,程序在前台运行。这意味着我们现在可以按下Ctrl+Z来停止程序:

sleep 300
Ctrl+Z
[1]+  Stopped                 sleep 300
  1. 程序已经停止,这意味着它不再倒计时。我们现在可以再次用fg 1将其带回前台并让它完成。

  2. 现在上一个进程已经完成,让我们开始一个新的sleep进程。这次,我们可以在前台启动它(省略了&)。然后,我们可以按下Ctrl+Z来停止程序。列出作业并注意程序处于停止状态:

$> sleep 300
Ctrl+Z
[1]+  Stopped                 sleep 300
$> jobs
[1]+  Stopped                 sleep 300
  1. 现在,我们可以使用bg命令在后台继续运行程序(bg代表background):
$> bg 1
[1]+ sleep 300 &
$> jobs
[1]+  Running                 sleep 300 &
  1. 我们还可以使用一个叫做pgrep的命令来找到程序的 PID。pgrep的名称代表Process Grep-f选项允许我们指定完整的命令,包括它的选项,以便我们得到正确的 PID:
$> pgrep -f "sleep 300"
4822
  1. 现在我们知道了 PID,我们可以使用kill来终止程序:
$> kill 4822
$> Enter
[1]+  Terminated              sleep 300
  1. 我们也可以使用pkill来终止一个程序。在这里,我们将启动另一个进程,并使用pkill来终止它。这个命令和pgrep使用相同的选项:
$> sleep 300 &
[1] 6526
$> pkill -f "sleep 300"
[1]+  Terminated              sleep 300

工作原理…

在这个教程中,我们学习了后台进程、前台进程、停止和运行的作业、终止进程等基本概念。这些是 Linux 作业控制中使用的一些基本概念。

当我们用kill杀死进程时,kill向后台进程发送了一个信号。kill的默认信号是TERM信号。TERM信号是 15 号信号。一个无法处理的信号——总是终止程序的信号是 9 号信号,或者KILL信号。我们将在下一个教程中更深入地介绍信号处理。

使用信号来控制和终止进程

现在我们对进程有了一些了解,是时候转向信号并学习如何使用信号来终止和控制进程了。在这个教程中,我们还将编写我们的第一个 C 程序,其中将包含一个信号处理程序。

准备工作

对于这个教程,你只需要本章节技术要求部分列出的内容。

操作方法…

在这个教程中,我们将探讨如何使用信号来控制和终止进程。让我们开始吧:

  1. 让我们首先列出我们可以使用kill命令发送给进程的信号。从这个命令得到的列表相当长,所以这里没有包含。最有趣和使用的信号是前 31 个:
$> kill -L
  1. 让我们看看这些信号是如何工作的。我们可以向一个进程发送STOP信号(编号 19),这与我们在sleep中按下Ctrl+Z看到的效果相同。但是这里,我们直接向一个后台进程发送STOP信号:
$> sleep 120 &
[1] 16392
$> kill -19 16392
 [1]+  Stopped                 sleep 120
$> jobs
[1]+  Stopped                 sleep 120
  1. 现在,我们可以通过发送CONT信号(continue的缩写)来继续进程。如果愿意,我们也可以输入信号的名称,而不是它的编号:
$> kill -CONT 16392
$> jobs
[1]+  Running                 sleep 120 &
  1. 现在,我们可以通过发送KILL信号(编号 9)来终止进程:
$> kill -9 16392
$> Enter
[1]+  Killed                  sleep 120
  1. 现在,让我们创建一个根据不同信号执行操作并忽略(或阻塞)Ctrl+C(中断信号)的小程序。USR1USR2信号非常适合这个目的。将以下代码写入一个文件并保存为signals.c。这里将这段代码分成了多个步骤,但所有代码都放在这个文件中。要在程序中注册信号处理程序,我们可以使用sigaction()系统调用。由于sigaction()及其相关函数不包含在严格的 C99 中,我们需要定义_POSIX_C_SOURCE。我们还需要包含必要的头文件,编写处理程序函数原型,并开始main()函数:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
void sigHandler(int sig);
int main(void)
{
  1. 现在,让我们创建一些我们需要的变量和结构。我们将创建的sigaction结构action是为了sigaction()系统调用。在代码中稍后一点,我们设置它的成员。首先,我们必须将sa_handler设置为我们的函数,当接收到信号时将执行该函数。其次,我们使用sigfillset()sa_mask设置为所有信号。这将在执行我们的信号处理程序时忽略所有信号,防止它被中断。第三,我们将sa_flags设置为SA_RESTART,这意味着任何中断的系统调用将被重新启动:
    pid_t pid; /* to store our pid in */
    pid = getpid(); /* get the pid */
    struct sigaction action; /* for sigaction */
    sigset_t set; /* signals we want to ignore */
    printf("Program running with PID %d\n", pid);
    /* prepare sigaction() */
    action.sa_handler = sigHandler;
    sigfillset(&action.sa_mask);
    action.sa_flags = SA_RESTART;
  1. 现在,是时候使用sigaction()注册信号处理程序了。sigaction()的第一个参数是我们想要捕获的信号,第二个参数是新操作的结构,第三个参数给出了旧操作。如果我们对旧操作不感兴趣,我们将其设置为NULL。操作必须是sigaction结构:
    /* register two signal handlers, one for USR1
       and one for USR2 */
    sigaction(SIGUSR1, &action, NULL);
    sigaction(SIGUSR2, &action, NULL);
  1. 记住我们希望程序忽略Ctrl+C(中断信号)吗?这可以通过在应该忽略信号的代码之前调用sigprocmask()来实现。但首先,我们必须创建一个包含所有应该忽略/阻塞的信号的信号集。首先,我们将使用sigemptyset()清空集合,然后使用sigaddset()添加所需的信号。sigaddset()函数可以多次调用以添加更多的信号。sigprocmask()的第一个参数是行为,这里是SIG_BLOCK。第二个参数是信号集,而第三个参数可以用于检索旧集。但是,在这里,我们将其设置为NULL。之后,我们开始无限的for循环。循环结束后,我们再次解除信号集的阻塞。在这种情况下,这是不必要的,因为我们将退出程序,但在其他情况下,建议在我们已经过了应该忽略它们的代码部分后解除信号的阻塞:
    /* create a "signal set" for sigprocmask() */
    sigemptyset(&set);
    sigaddset(&set, SIGINT);
    /* block SIGINT and run an infinite loop */
    sigprocmask(SIG_BLOCK, &set, NULL);
    /* infinite loop to keep the program running */
    for (;;)
    {
        sleep(10);
    }
    sigprocmask(SIG_UNBLOCK, &set, NULL);
    return 0;
}
  1. 最后,让我们编写将在SIGUSR1SIGUSR2上执行的函数。该函数将打印接收到的信号:
void sigHandler(int sig)
{
    if (sig == SIGUSR1)
    {
        printf("Received USR1 signal\n");
    }
    else if (sig == SIGUSR2)
    {
        printf("Received USR2 signal\n");
    }
}
  1. 让我们编译程序:
$> make signals
gcc -Wall -Wextra -pedantic -std=c99    signals.c   -o
 signals
  1. 运行程序,可以在单独的终端或者在同一个终端的后台运行。请注意,我们在这里使用kill命令的信号名称;这比跟踪数字要容易一些:
$> ./signals &
[1] 25831
$> Program running with PID 25831
$> kill -USR1 25831
Received USR1 signal
$> kill -USR1 25831
Received USR1 signal
$> kill -USR2 25831
$> kill -USR2 25831
Received USR2 signal
$> Ctrl+C
^C
$> kill -USR1 25831
Received USR1 signal
$> kill -TERM 25831
$> ENTER 
[1]+  Terminated              ./signals

工作原理…

首先,我们探索了许多TERMKILLQUITSTOPHUPINTSTOPCONT,就像我们在这里看到的那样。

然后,我们使用STOPCONT信号来实现与上一个示例相同的效果;也就是说,停止和继续运行后台进程。在上一个示例中,我们使用bg来继续在后台运行进程,而要停止进程,我们按下Ctrl+Z。这一次,我们不需要将程序打开在前台来停止它;我们只需用kill发送STOP信号。

之后,我们继续编写了一个 C 程序,捕获了两个信号USR1USR2,并阻止了SIGINT信号(Ctrl+C)。根据我们发送给程序的信号,将打印不同的文本。我们通过实现信号处理程序来实现这一点。一个sigaction()函数。

在调用sigaction()系统调用之前,我们必须使用有关处理程序函数的信息填充sigaction结构,该结构在处理程序执行期间忽略的信号,以及它应该具有的行为。

信号集,无论是 sigaction 的sa_mask还是sigprocmask(),都是使用sigset_t类型创建的,并通过以下函数调用进行操作(在这里,我们假设使用了名为ssigset_t变量:

  • sigemptyset(&s);清除s中的所有信号

  • sigaddset(&s, SIGUSR1);SIGUSR1信号添加到s

  • sigdelset(&s, SIGUSR1);s中删除SIGUSR信号

  • sigfillset(&s);设置s中的所有信号

  • sigismember(&s, SIGUSR1);找出SIGUSR1是否是s的成员(在我们的示例代码中未使用)

要在进程启动时打印进程的 PID,我们必须使用getpid()系统调用来获取 PID。我们将 PID 存储在pid_t类型的变量中,就像我们之前看到的那样。

另请参阅

killpkillsigprocmask()sigaction()系统调用的手册页中有很多有用的信息。我建议您使用以下命令阅读它们:

  • man 1 kill

  • man 1 pkill

  • man 2 sigprocmask

  • man 2 sigaction

还有一个更简单的系统调用,称为signal(),也用于信号处理。如今,这个系统调用基本上被认为是不推荐使用的。但如果您感兴趣,可以在man 2 signal中阅读相关信息。

使用 execl()在进程中替换程序

在本章的开头,我们看到当用户登录时,gettylogin替换。在这个示例中,我们将编写一个小程序,正好可以做到这一点——用新程序替换其程序。这个系统调用被称为execl()

了解如何使用execl()使您能够编写在现有进程内执行新程序的程序。它还使您能够在生成的进程中启动新程序。当我们启动一个新进程时,我们可能希望用新程序替换该副本。因此,理解execl()是至关重要的。

准备就绪

您需要阅读本章的前三个示例,才能充分理解这个示例。本示例的其他要求在本章的技术要求部分中提到;例如,您将需要pstree工具。

您还需要两个终端或两个终端窗口。在其中一个终端中,我们将运行程序,而在另一个终端中,我们将查看pstree以查看进程。

如何做…

在这个示例中,我们将编写一个小程序,用它替换进程中正在运行的程序。让我们开始吧:

  1. 在文件中编写以下代码并将其保存为execdemo.c
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
int main(void)
{
   printf("My PID is %d\n", getpid());
   printf("Hit enter to continue ");
   getchar(); /* wait for enter key */
   printf("Executing /usr/bin/less...\n");
   /* execute less using execl and error check it */
   if ( execl("/usr/bin/less", "less", 
      "/etc/passwd", (char*)NULL) == -1 )
   {
      perror("Can't execute program");
      return 1;
   }
   return 0;
}
  1. 使用 Make 编译程序:
$> make execdemo
gcc -Wall -Wextra -pedantic -std=c99    execdemo.c   -o execdemo
  1. 现在,在当前终端中运行程序:
$> ./execdemo
My PID is 920
Hit enter to continue
  1. 现在,启动一个终端,并使用execdemo的 PID 执行pstree
$> pstree -A -p -s 920
systemd(1)---tmux(4050)---bash(18817)---execdemo(920)
  1. 现在,回到运行execdemo的第一个终端,并按Enter。这将使用less打印密码文件。

  2. 最后,回到第二个终端——您运行pstree的终端。重新运行相同的pstree命令。请注意,即使 PID 仍然相同,execdemo已被替换为less

$> pstree -A -p -s 920
systemd(1)---tmux(4050)---bash(18817)---less(920)

它是如何工作的…

execl()函数执行一个新程序,并在同一个进程中替换旧程序。为了让程序暂停执行,以便我们有时间在pstree中查看它,我们使用了getchar()

execl()函数有四个必需的参数。第一个是我们想要执行的程序的路径。第二个参数是程序的名称,就像从argv[0]中打印出来的那样。最后,第三个和之后的参数是我们想要传递给即将执行的程序的参数。为了终止我们想要传递给程序的参数列表,我们必须以NULL的指针结束,并将其转换为char类型。

另一种看待一个进程的方式是把它看作一个执行环境。在这个环境中运行的程序可以被替换。这就是为什么我们谈论进程,为什么我们称它们为Process IDs,而不是 Program IDs。

另请参阅

还有其他几个exec()函数可以使用,每个函数都有自己独特的特性和特点。这些通常被称为"exec() family"。你可以使用man 3 execl命令来了解它们的所有信息。

fork 一个进程

之前,我们一直在说当一个程序创建一个新的进程时使用spawned。正确的术语是fork一个进程。发生的情况是一个进程创建了自己的一个副本——它forks

在之前的教程中,我们学习了如何使用execl()在一个进程中执行一个新程序。在这个教程中,我们将学习如何使用fork()来 fork 一个进程。被 fork 的进程——子进程——是调用进程——父进程——的一个副本。

知道如何 fork 一个进程使我们能够在系统中以编程方式创建新的进程。如果不能 fork,我们只能限制在一个进程中。例如,如果我们想要从一个现有的程序中启动一个新程序并保留原始程序,我们必须 fork。

准备工作

就像在之前的教程中一样,你需要pstree工具。技术要求部分介绍了如何安装它。你还需要 GCC 编译器和 Make 工具。你还需要两个终端;一个终端用来执行程序,另一个用来用pstree查看进程树。

如何做...

在这个教程中,我们将使用fork()来 fork 一个进程。我们还将查看一个进程树,以便我们可以看到发生了什么。让我们开始吧:

  1. 在一个程序中写下以下代码并保存为forkdemo.c。这段代码中突出显示了fork()系统调用。在我们fork()之前,我们打印出进程的 PID:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
   pid_t pid;
   printf("My PID is %d\n", getpid());
   /* fork, save the PID, and check for errors */
   if ( (pid = fork()) == -1 )
   { 
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* if pid is 0 we are in the child process */
      printf("Hello from the child process!\n");
      sleep(120);
   }

   else if(pid > 0)
   {
      /* if pid is greater than 0 we are in 
       * the parent */
      printf("Hello from the parent process! "
         "My child has PID %d\n", pid);
      sleep(120);
   }
   else
   {
      fprintf(stderr, "Something went wrong "
         "forking\n");
      return 1;
   }
   return 0;
}
  1. 现在,编译程序:
$> make forkdemo
gcc -Wall -Wextra -pedantic -std=c99    forkdemo.c   
-o forkdemo
  1. 在你当前的终端中运行程序并注意 PID:
$> ./forkdemo 
My PID is 21764
Hello from the parent process! My child has PID 21765
Hello from the child process!
  1. 现在,在一个新的终端中,用forkdemo的 PID 运行pstree。在这里,我们可以看到forkdemo已经 fork 了,而我们在 fork 之前从程序中得到的 PID 是父进程。fork 的进程是正在运行的forkdemo
$> pstree -A -p -s 21764
systemd(1)---tmux(4050)---bash(18817)---
forkdemo(21764)---forkdemo(21765)

它是如何工作的...

当一个进程 fork 时,它创建了自己的一个副本。这个副本成为调用fork()的进程的子进程——fork()返回子进程的 PID。在子进程中,返回0。这就是为什么父进程可以打印出子进程的 PID。

两个进程包含相同的程序代码,两个进程都在运行,但只有if语句中的特定部分会被执行,这取决于进程是父进程还是子进程。

还有更多...

一般来说,父进程和子进程是相同的,除了 PID。然而,还有一些其他的差异;例如,子进程中的 CPU 计数器会被重置。还有其他一些微小的差异,你可以在man 2 fork中了解到。然而,整个程序代码是相同的。

在一个 forked 进程中执行一个新程序

在上一个示例中,我们学习了如何使用fork()系统调用分叉进程。在之前的示例中,我们学习了如何用execl()替换进程中的程序。在这个示例中,我们将结合这两个,fork()execl(),在一个分叉的进程中执行一个新程序。这就是每次在 Bash 中运行程序时发生的事情。Bash 分叉自身并执行我们输入的程序。

了解如何使用fork()execl()使您能够编写启动新程序的程序。例如,您可以使用这些知识编写自己的 shell。

准备工作

对于这个示例,您需要pstree工具、GCC 编译器和 Make 工具。您可以在本章的技术要求部分找到这些程序的安装说明。

操作步骤…

在这个示例中,我们将编写一个程序,fork()并在子进程中执行一个新程序。让我们开始吧:

  1. 在文件中写入以下程序代码,并将其保存为my-fork.c。当我们在子进程中执行一个新程序时,我们应该等待子进程完成。这就是我们使用waitpid()的方式。waitpid()调用还有另一个重要的功能,即从子进程获取返回状态:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
#include <sys/wait.h>
int main(void)
{
   pid_t pid;
   int status;
   /* Get and print my own pid, then fork
      and check for errors */
   printf("My PID is %d\n", getpid());
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* If pid is 0 we are in the child process,
         from here we execute 'man ls' */
      if ( execl("/usr/bin/man", "man", "ls",
         (char*)NULL) == -1 )
      {
         perror("Can't exec");
         return 1;
      }
   }
   else if(pid > 0)
   {
      /* In the parent we must wait for the child
         to exit with waitpid(). Afterward, the
         child exit status is written to 'status' */
      waitpid(pid, &status, 0);
      printf("Child executed with PID %d\n", pid);
      printf("Its return status was %d\n", status);
      printf("Its return status was %d\n", status);
   }
   else
   {
      fprintf(stderr, "Something went wrong "
         "forking\n");
      return 1;
   }
   return 0;
}
  1. 使用 Make 编译程序:
$> make my-fork
gcc -Wall -Wextra -pedantic -std=c99    my-fork.c   -o
my-fork
  1. 在当前的终端中,找到当前 shell 的 PID 并做个记录:
$> echo $$
18817
  1. 现在,使用./my-fork执行我们编译的程序。这将显示ls的手册页。

  2. 打开一个新的终端,查看另一个终端中 shell 的进程树。注意,my-fork已经分叉并用man替换了其内容,man又分叉并用pager替换了其内容(以显示内容):

$> pstree -A -p -s 18817
systemd(1)---tmux(4050)---bash(18817)---my-fork(5849)-
--man(5850)---pager(5861)
  1. 通过按下Q退出第一个终端中的手册页。这将产生以下文本。比较pstree中父进程和子进程的 PID。注意子进程是5850,这是man命令。它最初是my-fork的副本,但后来用man替换了其程序:
My PID is 5849
Child executed with PID 5850
Its return status was 0

它是如何工作的…

fork()系统调用负责在 Linux 和 Unix 系统上分叉进程。然后,execl()(或其他exec()函数之一)负责执行并用新程序替换自己的程序。这基本上是系统上任何程序启动的方式。

请注意,我们需要告诉父进程使用waitpid()等待子进程。如果我们需要运行一个不需要终端的程序,我们可以不使用waitpid()。但是,我们应该始终等待子进程。如果不等待,子进程将最终成为孤儿。这是我们将在本章后面详细讨论的内容,在学习孤儿是什么这个示例中。

但在这种特殊情况下,我们执行需要终端的man命令,我们需要等待子进程才能让一切正常工作。waitpid()调用还使我们能够获取子进程的返回状态。我们还防止子进程变成孤儿。

当我们运行程序并用pstree查看进程树时,我们发现my-fork进程已经分叉并用man替换了其程序。我们可以看到这一点,因为man命令的 PID 与my-fork的子进程的 PID 相同。我们还注意到man命令反过来又分叉并用pager替换了其子进程。pager命令负责在屏幕上显示实际文本,通常是less

使用 system()启动一个新进程

我们刚刚讨论的使用fork()waitpid()execl()在分叉的进程中启动新程序的内容是理解 Linux 和进程更深层次的关键。这种理解是成为优秀系统开发人员的关键。但是,有一个捷径。我们可以使用system()来代替手动处理分叉、等待和执行。system()函数为我们完成所有这些步骤。

准备工作

对于这个示例,你只需要本章节技术要求部分中列出的内容。

如何做…

在这个示例中,我们将使用system()函数重写前一个程序my-fork。你会注意到这个程序与前一个程序相比要短得多。让我们开始吧:

  1. 将以下代码写入文件并保存为sysdemo.c。注意这个程序有多小(和简单)。system()函数为我们完成了所有复杂的工作:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
   if ( (system("man ls")) == -1 )
   {
      fprintf(stderr, "Error forking or reading "
         "status\n");
      return 1;
   }
   return 0;
}
  1. 编译程序:
$> make sysdemo
gcc -Wall -Wextra -pedantic -std=c99    sysdemo.c   -o
sysdemo
  1. 使用$$变量记录 shell 的 PID:
$> echo $$
957
  1. 现在在当前终端中运行程序。这将显示ls命令的手册页。让它继续运行:
$> ./sysdemo
  1. 在新终端中启动并对步骤 3中的 PID 执行pstree。请注意,这里有一个额外的名为sh的进程。这是因为system()函数从sh(基本的 Bourne Shell)执行man命令:
$> pstree -A -p -s 957
systemd(1)---tmux(4050)---bash(957)---sysdemo(28274)--
-sh(28275)---man(28276)---pager(28287)

它是如何工作的…

这个程序要小得多,编写起来也更容易。然而,正如我们在pstree中看到的那样,与上一个示例相比,有一个额外的进程:sh(shell)。system()函数通过从sh执行man命令来工作。手册页(man 3 system)清楚地说明了这一点。它通过以下execl()调用执行我们指定的命令:

execl("/bin/sh", "sh", "-c", command, (char *) 0);

结果是一样的。它执行fork(),然后是execl()调用,并且使用waitpid()等待子进程。这也是一个使用低级系统调用的高级函数的很好的例子。

创建一个僵尸进程

要完全理解 Linux 中的进程,我们还需要看看什么是僵尸进程。为了完全理解这一点,我们需要自己创建一个。

僵尸进程是指子进程在父进程之前退出,而父进程没有等待子进程的状态。"僵尸进程"这个名字来源于这个事实,即进程是不死的。进程已经退出,但在系统进程表中仍然有一个条目。

了解什么是僵尸进程以及它是如何创建的将有助于你避免编写在系统上创建僵尸进程的糟糕程序。

准备工作

对于这个示例,你只需要本章节技术要求部分中列出的内容。

如何做…

在这个示例中,我们将编写一个小程序,在系统上创建一个僵尸进程。我们还将使用ps命令查看僵尸进程。为了证明我们可以通过等待子进程来避免僵尸进程,我们还将使用waitpid()编写第二个版本。让我们开始吧:

  1. 将以下代码写入文件并命名为create-zombie.c。这个程序与我们在forkdemo.c文件中看到的程序相同,只是子进程在父进程退出之前使用exit(0)退出。父进程在子进程退出后睡眠 2 分钟,而不等待子进程使用waitpid(),从而创建一个僵尸进程。这里突出显示了exit()的调用:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
   pid_t pid;
   printf("My PID is %d\n", getpid());
   /* fork, save the PID, and check for errors */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* if pid is 0 we are in the child process */
      printf("Hello and goodbye from the child!\n");
      exit(0);
      /* if pid is greater than 0 we are in 
       * the parent */
      printf("Hello from the parent process! "
         "My child had PID %d\n", pid);
      sleep(120);
   }
   else 
   {
      fprintf(stderr, "Something went wrong "
         "forking\n");
      return 1;
   }
   return 0;
}
  1. 编译程序:
$> make create-zombie
gcc -Wall -Wextra -pedantic -std=c99    create-
zombie.c   -o create-zombie
  1. 在当前终端中运行程序。程序(父进程)将保持活动状态 2 分钟。与此同时,子进程是僵尸的,因为父进程没有等待它或它的状态:
$> ./create-zombie
My PID is 2429
Hello from the parent process! My child had PID 2430
Hello and goodbye from the child!
  1. 当程序正在运行时,打开另一个终端并使用ps检查子进程的 PID。你可以从create-zombie之前的输出中得到子进程的 PID。在这里,我们可以看到进程是僵尸的,因为它的状态是Z+,并且在进程名后面有<defunct>这个词:
$> ps a | grep 2430
  2430 pts/18   Z+     0:00 [create-zombie] <defunct>
  2824 pts/34   S+     0:00 grep 2430
  1. 2 分钟后——当父进程执行完毕时——使用相同的 PID 重新运行ps命令。僵尸进程现在将不复存在:
$> ps a | grep 2430
  3364 pts/34   S+     0:00 grep 2430
  1. 现在,重写程序,使其如下所示。将新版本命名为no-zombie.c。这里突出显示了添加的代码:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main(void)
{
   pid_t pid;
   int status;
   printf("My PID is %d\n", getpid());
   /* fork, save the PID, and check for errors */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* if pid is 0 we are in the child process */
      printf("Hello and goodbye from the child!\n");
      exit(0);
   }
   else if(pid > 0)
   {
      /* if pid is greater than 0 we are in 
       * the parent */
      printf("Hello from the parent process! "
         "My child had PID %d\n", pid);
      waitpid(pid, &status, 0); /* wait for child */
      sleep(120);
   }
   else
   {
      fprintf(stderr, "Something went wrong "
         "forking\n");
      return 1;
   }
   return 0;
}
  1. 编译这个新版本:
$> make no-zombie
gcc -Wall -Wextra -pedantic -std=c99    no-zombie.c  
-o no-zombie
  1. 在当前终端中运行程序。就像以前一样,它将创建一个子进程,该子进程将立即退出。父进程将继续运行 2 分钟,给我们足够的时间来搜索子进程的 PID:
$> ./no-zombie
My PID is 22101
Hello from the parent process! My child had PID 22102
Hello and goodbye from the child!
  1. no-zombie程序正在运行时,在新的终端中使用psgrep搜索子进程的 PID。正如你所看到的,没有与子进程的 PID 匹配的进程。因此,由于父进程等待其状态,子进程已正确退出:
$> ps a | grep 22102
22221 pts/34   S+     0:00 grep 22102

工作原理…

我们始终希望避免在系统上创建僵尸进程,而最好的方法是等待子进程完成。

步骤 1 到 5中,我们编写了一个创建僵尸进程的程序。由于父进程没有使用waitpid()系统调用等待子进程,因此创建了僵尸进程。子进程确实退出了,但它仍然留在系统进程表中。当我们使用psgrep搜索进程时,我们看到子进程的状态为Z+,表示僵尸。该进程不存在,因为它已经使用exit()系统调用退出。但是,根据系统进程表,它仍然存在;因此,它是不死不活的—一个僵尸。

步骤 6 到 9中,我们使用waitpid()系统调用重写了程序以等待子进程。子进程仍然在父进程之前存在,但这次父进程获得了子进程的状态。

僵尸进程不会占用任何系统资源,因为进程已经终止。它只驻留在系统进程表中。但是,系统上的每个进程—包括僵尸进程—都占用一个 PID 号。由于系统可用的 PID 号是有限的,如果死进程占用 PID 号,就有耗尽 PID 号的风险。

还有更多…

在 Linux 的waitpid()手册页中有关于子进程及其状态变化的许多细节。实际上,在 Linux 中有三个可用的wait()函数。你可以使用man 2 wait命令阅读有关它们的所有内容。

了解孤儿的含义

了解 Linux 系统中孤儿的含义就像了解僵尸一样重要。这将使你更深入地了解整个系统以及进程如何被systemd继承。

一个systemd,它是系统上的第一个进程—PID 为1

在本食谱中,我们将编写一个小程序,该程序分叉,从而创建一个子进程。然后父进程将退出,将子进程留下来作为孤儿。

准备就绪

本章的技术要求部分列出了本食谱所需的一切。

如何做…

在本食谱中,我们将编写一个创建孤儿进程的简短程序,该进程将由systemd继承。让我们开始吧:

  1. 在文件中编写以下代码并将其保存为orphan.c。该程序将创建一个在后台运行 5 分钟的子进程。当我们按下Enter时,父进程将退出。这给了我们时间在父进程退出之前和之后使用pstree调查子进程:
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
   pid_t pid;
   printf("Parent PID is %d\n", getpid());
   /* fork, save the PID, and check for errors */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* if pid is 0 we are in the child process */
      printf("I am the child and will run for "
         "5 minutes\n");
      sleep(300);
      exit(0);
   }
   else if(pid > 0)
   {
      /* if pid is greater than 0 we are in 
       * the parent */
      printf("My child has PID %d\n" 
         "I, the parent, will exit when you "
         "press enter\n", pid);
      getchar();
      return 0;
   }
   else
   {
      fprintf(stderr, "Something went wrong "
         "forking\n");
      return 1;
   }
   return 0;
}
  1. 编译此程序:
$> make orphan
gcc -Wall -Wextra -pedantic -std=c99    orphan.c   -o
 orphan
  1. 在当前终端中运行程序并让程序继续运行。暂时不要按Enter
$> ./orphan
My PID is 13893
My child has PID 13894
I, the parent, will exit when you press enter
I am the child and will run for 2 minutes
  1. 现在,在一个新的终端中,使用子进程的 PID 运行pstree。在这里,我们将看到它看起来就像在之前的食谱中一样。进程已经被分叉,从而创建了一个具有相同内容的子进程:
$> pstree -A -p -s 13894
systemd(1)---tmux(4050)---bash(18817)---orphan(13893)-
--orphan(13894)
  1. 现在,是时候结束父进程了。回到orphan仍在运行的终端并按下Enter。这将结束父进程。

  2. 现在,在第二个终端中再次运行pstree。这与刚刚运行的命令相同。正如你所看到的,子进程现在已被systemd继承,因为其父进程已经死亡。5 分钟后,子进程将退出:

$> pstree -A -p -s 13894
systemd(1)---orphan(13894)
  1. 我们可以使用其他更标准化的工具来查看ps。运行以下ps命令以查看有关子进程的更详细信息。在这里,我们将看到更多信息。对我们来说最重要的是 PPID、PID 和会话 IDSID)。我们还将在这里看到用户 IDUID),它指定了谁拥有该进程:
$> ps jp 13894
PPID PID PGID  SID   TTY  TPGID STAT UID TIME COMMAND
1  13894 13893 18817 pts/18 18817 S 1000 0:00 ./orphan

工作原理…

每个进程都需要一个父进程。这就是为什么systemd会继承系统上任何成为孤儿的进程的原因。

if (pid == 0)中的代码继续运行了 5 分钟。这给了我们足够的时间来检查子进程是否已被systemd继承。

在最后一步,我们使用ps查看了有关子进程的更多详细信息。在这里,我们看到了 PPID、PID、PGID 和 SID。这里提到了一些重要的新名称。我们已经知道 PPID 和 PID,但 PGID 和 SID 还没有被介绍过。

PGID代表进程组 ID,是系统对进程进行分组的一种方式。子进程的 PGID 是父进程的 PID。换句话说,这个 PGID 是为了将父进程和子进程分组在一起而创建的。系统将 PGID 设置为创建该组的父进程的 PID。我们不需要自己创建这些组;这是系统为我们做的事情。

18817,这是 Bash shell 的 PID。这里也适用相同的规则;SID 号将与启动会话的进程的 PID 相同。这个会话包括我的用户 shell 和我从中启动的所有程序。这样,系统就可以在我注销系统时终止属于该会话的所有进程。

另请参阅

使用ps可以获得很多信息。我建议你至少浏览一下man 1 ps的手册。

创建守护进程

在系统编程中常见的任务是创建各种守护进程。守护进程是在系统上运行并执行一些任务的后台进程。SSH 守护进程就是一个很好的例子。另一个很好的例子是 NTP 守护进程,它负责同步计算机时钟,有时甚至分发时间给其他计算机。

了解如何创建守护进程将使您能够创建服务器软件;例如,Web 服务器、聊天服务器等。

在本教程中,我们将创建一个简单的守护进程来演示一些重要的概念。

准备工作

你只需要本章节技术要求部分列出的组件。

操作方法

在本教程中,我们将编写一个在我们的系统中后台运行的小型守护进程。守护进程唯一的“工作”是将当前日期和时间写入文件。这证明了守护进程是活着的。让我们开始吧:

  1. 与我们以前的示例相比,守护进程的代码相当长。因此,代码已分成几个步骤。这里还有一些我们还没有涉及的新东西。将代码写入一个文件并将其保存为my-daemon.c。请记住,所有步骤中的所有代码都放入这个文件中。我们将从我们需要的所有include文件、我们需要的变量和我们的fork()开始,就像我们以前看到的那样。这个fork()将是两个中的第一个:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <time.h>
#include <fcntl.h>
int main(void)
{
   pid_t pid;
   FILE *fp;
   time_t now; /* for the current time */
   const char pidfile[] = "/var/run/my-daemon.pid";
   const char daemonfile[] = 
      "/tmp/my-daemon-is-alive.txt";
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
  1. 现在我们已经 forked,我们希望父进程退出。一旦父进程退出,我们将处于子进程中。在子进程中,我们将使用setsid()创建一个新的会话。创建一个新的会话将释放进程的控制终端:
   else if ( (pid != 0) )
   {
      exit(0);
   }
   /* the parent process has exited, so this is the
    * child. create a new session to lose the 
    * controlling terminal */
   setsid();
  1. 现在,我们想再次fork()。这第二次 fork 将创建一个新的进程,就像以前一样,但由于它是一个已经存在的会话中的新进程,它不会成为会话领导者,从而阻止它获取一个新的控制终端。新的子进程被称为孙子。再一次,我们退出父进程(子进程)。然而,在退出子进程之前,我们将孙子的 PID 写入PID 文件。这个 PID 文件用于跟踪守护进程:
   /* fork again, creating a grandchild, 
    * the actual daemon */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   /* the child process which will exit */
   else if ( pid > 0 )
   {
      /* open pid-file for writing and error 
       * check it */
      if ( (fp = fopen(pidfile, "w")) == NULL )
      {
         perror("Can't open file for writing");
         return 1;
      }
      /* write pid to file */
      fprintf(fp, "%d\n", pid); 
      fclose(fp); /* close the file pointer */
      exit(0);
   }
  1. 现在,将默认模式(umask)设置为守护进程的合理值。我们还必须将当前工作目录更改为/,以便守护进程不会阻止文件系统卸载或目录被删除。然后,我们必须打开守护进程文件,这是我们将写入消息的地方。消息将包含当前日期和时间,并告诉我们一切是否正常。通常,这将是一个日志文件:
   umask(022); /* set the umask to something ok */
   chdir("/"); /* change working directory to / */
   /* open the "daemonfile" for writing */
   if ( (fp = fopen(daemonfile, "w")) == NULL )
   {
      perror("Can't open daemonfile");
      return 1;
   }
  1. 由于守护进程只会在后台运行,我们不需要 stdin、stdout 和 stderr,所以让我们将它们全部关闭。但是,将它们关闭是不安全的。如果代码中的某些部分稍后打开文件描述符,它将获得文件描述符 0,通常是 stdin。文件描述符是按顺序分配的。如果没有打开的文件描述符,第一次调用open()将获得描述符0;第二次调用将获得描述符1。另一个问题可能是,某些部分可能尝试写入 stdout,但 stdout 已经不存在,这会导致程序崩溃。因此,我们必须重新打开它们全部,但是重新打开到/dev/null(黑洞):
   /* from here, we don't need stdin, stdout or, 
    * stderr anymore, so let's close them all, 
    * then re-open them to /dev/null */
   close(STDIN_FILENO);
   close(STDOUT_FILENO);
   close(STDERR_FILENO);
   open("/dev/null", O_RDONLY); /* 0 = stdin */
   open("/dev/null", O_WRONLY); /* 1 = stdout */
   open("/dev/null", O_RDWR); /* 2 = stderr */
  1. 最后,我们可以开始守护进程的工作。这只是一个for循环,向守护进程文件写入一条消息,说明守护进程仍然存活。请注意,我们必须在每次fprintf()后使用fflush()刷新文件指针。通常,在 Linux 中,事情是行缓冲的,这意味着在写入之前只缓冲一行。但由于这是一个文件而不是 stdout,它实际上是完全缓冲的,这意味着它会缓冲所有数据,直到缓冲区满或文件流关闭。如果没有fflush(),我们在填满缓冲区之前将看不到文件中的任何文本。通过在每次fprintf()后使用fflush(),我们可以在文件中实时看到文本:
   /* here we start the daemons "work" */
   for (;;)
   {
      /* get the current time and write it to the
         "daemonfile" that we opened above */
      time(&now);
      fprintf(fp, "Daemon alive at %s", 
         ctime(&now));
      fflush(fp); /* flush the stream */
      sleep(30);
   }
   return 0;
}
  1. 现在,是时候编译整个守护进程了:
$> make my-daemon
gcc -Wall -Wextra -pedantic -std=c99    my-daemon.c  
-o my-daemon
  1. 现在,我们可以启动守护进程。由于我们将 PID 文件写入/var/run,我们需要以 root 身份执行守护进程。我们不会从守护进程中得到任何输出;它将悄悄地与终端分离:
$> sudo ./my-daemon
  1. 现在守护进程正在运行,让我们检查已写入/var/run/my-daemon.pid的 PID 号码:
$> cat /var/run/my-daemon.pid 
5508
  1. 让我们使用pspstree来调查守护进程。如果一切都按照预期进行,它的父进程应该是systemd,并且它应该在自己的会话中(SID 应该与进程 ID 相同):
$> ps jp 5508
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
1   5508 5508 5508?   -1    Ss    0  0:00 ./my-daemon
$> pstree -A -p -s 5508
systemd(1)---my-daemon(5508)
  1. 让我们还看看/tmp/my-daemon-is-alive.txt文件。这个文件应该包含一些指定日期和时间的行,相隔 30 秒:
$> cat /tmp/my-daemon-is-alive.txt 
Daemon alive at Sun Nov 22 23:25:45 2020
Daemon alive at Sun Nov 22 23:26:15 2020
Daemon alive at Sun Nov 22 23:26:45 2020
Daemon alive at Sun Nov 22 23:27:15 2020
Daemon alive at Sun Nov 22 23:27:45 2020
Daemon alive at Sun Nov 22 23:28:15 2020
Daemon alive at Sun Nov 22 23:28:45 2020
  1. 最后,让我们杀死守护进程,以防止它继续写入文件:
$> sudo kill 5508

工作原理…

我们刚刚编写的守护进程是一个基本的传统守护进程,但它演示了我们需要充分理解的所有概念。其中一个新的重要概念是如何使用setsid()启动一个新会话。如果我们不创建一个新会话,守护进程仍将是用户登录会话的一部分,并在用户注销时终止。但由于我们为守护进程创建了一个新会话,并且它被systemd继承,它现在独立存在,不受启动它的用户和进程的影响。

第二次分叉的原因是,会话领导者——也就是我们在setsid()调用后的第一个子进程——如果打开终端设备,可以获取一个新的控制终端。当我们进行第二次分叉时,新的子进程只是第一个子进程创建的会话的成员,而不是领导者,因此它不再能获取控制终端。避免控制终端的原因是,如果该终端退出,守护进程也会退出。在创建守护进程时进行两次分叉通常被称为双重分叉技术。

需要以 root 身份启动守护进程的原因是它需要写入/var/run/。如果我们改变目录,或者完全跳过它,守护进程将作为普通用户正常运行。然而,大多数守护进程确实以 root 身份运行。然而,也有一些以普通用户身份运行的守护进程;例如,处理与用户相关的事务的守护进程,比如tmux(一个终端复用器)。

我们还将工作目录更改为/。这样守护进程就不会锁定目录。顶级根目录不会被删除或卸载,这使其成为守护进程的安全工作目录。

还有更多...

我们在这里编写的是传统的 Linux/Unix 守护进程。这些类型的守护进程今天仍在使用,例如,用于像这样的小型和快速守护进程。然而,自从systemd出现以来,我们不再需要像刚才那样“使守护进程成为守护进程”。例如,建议保留 stdout 和 stderr 打开,并将所有日志消息发送到那里。然后这些消息将显示在journal中。我们将在第七章**,使用 systemd 处理您的守护进程中更深入地介绍 systemd 和 journal。

我们在这里编写的守护进程类型在 systemd 语言中被称为forking,我们以后会更多地了解它。

就像system()在执行新程序时为我们简化了事情一样,还有一个名为daemon()的函数可以为我们创建守护进程。这个函数将为我们做所有繁重的工作,比如分叉、关闭和重新打开文件描述符、更改工作目录等。然而,请注意,这个函数不使用我们在本篇中用于守护进程的双重分叉技术。这一事实在man 3 daemon手册页的 BUGS 部分中明确说明。

实现信号处理程序

在上一篇中,我们编写了一个简单但功能齐全的守护进程。然而,它也存在一些问题;例如,当守护进程被终止时,PID 文件没有被删除。同样,当守护进程被终止时,打开的文件流(/tmp/my-daemon-is-alive.txt)也没有被关闭。一个合适的守护进程在退出时应该进行清理。

为了能够在退出时进行清理,我们需要实现一个信号处理程序。然后信号处理程序应该在守护进程终止之前处理所有的清理工作。在本章中,我们已经看到了信号处理程序的例子,所以这个概念并不新鲜。

然而,并不只有守护进程使用信号处理程序。这是一种常见的控制进程的方式,特别是那些没有控制终端的进程。

准备工作

在阅读本篇之前,您应该先阅读上一篇,以便了解守护进程的功能。除此之外,您还需要本章技术要求部分列出的程序。

操作方法

在本篇中,我们将为上一篇中编写的守护进程添加信号处理程序。由于代码会有点长,我将其分成几个步骤。不过,请记住,所有的代码都在同一个文件中。让我们开始吧:

  1. 将以下代码写入文件并命名为my-daemon-v2.c。我们将从#include文件和变量开始,就像之前一样。但是请注意,这一次我们已经将一些变量移到了全局空间。我们这样做是为了让信号处理程序可以访问它们。没有办法向信号处理程序传递额外的参数,所以这是访问它们的最佳方式。在这里,我们还必须为sigaction()定义_POSIX_C_SOURCE。我们还必须在这里创建我们的信号处理程序的原型,称为sigHandler()。另外,请注意新的sigaction结构:
#include <sys/types.h>
#include <sys/stat.h>
#include <time.h>
#include <fcntl.h>
#include <signal.h>
void sigHandler(int sig);
/* moved these variables to the global scope
   since they need to be access/deleted/closed
   from the signal handler */
FILE *fp;
const char pidfile[] = "/var/run/my-daemon.pid";
int main(void)
{
   pid_t pid;
   time_t now; /* for the current time */
   struct sigaction action; /* for sigaction */
   const char daemonfile[] = 
      "/tmp/my-daemon-is-alive.txt";
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   else if ( (pid != 0) )
   {
      exit(0);
   }
  1. 就像之前一样,我们必须在第一次分叉后创建一个新会话。之后,我们必须进行第二次分叉,以确保它不再是一个会话领导者:
   /* the parent process has exited, which makes 
    * the rest of the code the child process */
   setsid(); /* create a new session to lose the 
                controlling terminal */

   /* fork again, creating a grandchild, the 
    * actual daemon */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   /* the child process which will exit */
   else if ( pid > 0 )
   {
      /* open pid-file for writing and check it */
      if ( (fp = fopen(pidfile, "w")) == NULL )
      {
         perror("Can't open file for writing");
         return 1;
      }
      /* write pid to file */
      fprintf(fp, "%d\n", pid); 
      fclose(fp); /* close the file pointer */
      exit(0);
   }
  1. 与之前一样,我们必须更改 umask、当前工作目录,并使用fopen()打开守护进程文件。接下来,我们必须关闭并重新打开 stdin、stdout 和 stderr:
   umask(022); /* set the umask to something ok */
   chdir("/"); /* change working directory to / */
   /* open the "daemonfile" for writing */
   if ( (fp = fopen(daemonfile, "w")) == NULL )
   {
      perror("Can't open daemonfile");
      return 1;
   }
   /* from here, we don't need stdin, stdout or, 
    * stderr anymore, so let's close them all, 
    * then re-open them to /dev/null */
   close(STDIN_FILENO);
   close(STDOUT_FILENO);
   close(STDERR_FILENO);
   open("/dev/null", O_RDONLY); /* 0 = stdin */
   open("/dev/null", O_WRONLY); /* 1 = stdout */
   open("/dev/null", O_RDWR); /* 2 = stderr */
  1. 现在,终于是时候准备并注册信号处理程序了。这正是我们在本章前面讨论过的内容,只是在这里,我们为所有常见的退出信号注册处理程序,比如终止、中断、退出和中止。一旦我们处理了信号处理程序,我们将开始守护进程的工作;也就是,将消息写入守护进程文件的for循环:
/* prepare for sigaction */
   action.sa_handler = sigHandler;
   sigfillset(&action.sa_mask);
   action.sa_flags = SA_RESTART;
   /* register the signals we want to handle */
   sigaction(SIGTERM, &action, NULL);
   sigaction(SIGINT, &action, NULL);
   sigaction(SIGQUIT, &action, NULL);
   sigaction(SIGABRT, &action, NULL);
   /* here we start the daemons "work" */
   for (;;)
   {
      /* get the current time and write it to the
         "daemonfile" that we opened above */
      time(&now);
      fprintf(fp, "Daemon alive at %s", 
         ctime(&now));
      fflush(fp); /* flush the stream */
      sleep(30);
   }
   return 0;
}
  1. 最后,我们必须实现信号处理程序的函数。在这里,我们通过在退出之前删除 PID 文件来清理守护进程。我们还关闭了打开的文件流到守护进程文件:
void sigHandler(int sig)
{
    int status = 0;
    if ( sig == SIGTERM || sig == SIGINT 
        || sig == SIGQUIT 
        || sig == SIGABRT )
    {
        /* remove the pid-file */
        if ( (unlink(pidfile)) == -1 )
            status = 1;
        if ( (fclose(fp)) == EOF )
            status = 1;
        exit(status); /* exit with the status set*/
    }
    else /* some other signal */
    {
        exit(1);
    }
}
  1. 编译守护进程的新版本:
$> make my-daemon-v2
gcc -Wall -Wextra -pedantic -std=c99    my-daemon-v2.c
-o my-daemon-v2
  1. 以 root 身份启动守护进程,就像我们之前做的那样:
$> sudo ./my-daemon-v2 
  1. 查看 PID 文件中的 PID 并做好记录:
$> cat /var/run/my-daemon.pid 
22845
  1. 使用ps命令查看它是否按预期运行:
$> ps jp 22845
  PPID   PID  PGID   SID TTY TPGID STAT UID TIME
COMMAND
    1 22845 22845 22845 ?      -1 Ss     0 0:00 ./my
daemon-v2
  1. 用默认信号TERM杀死守护进程:
$> sudo kill 22845
  1. 如果一切按计划进行,PID 文件将被删除。尝试使用cat命令访问 PID 文件:
$> cat /var/run/my-daemon.pid 
cat: /var/run/my-daemon.pid: No such file or directory

工作原理…

在这个示例中,我们实现了一个信号处理程序,负责所有清理工作。它会删除 PID 文件并关闭打开的文件流。为了处理最常见的“退出”信号,我们使用四个不同的信号注册了处理程序:终止中断退出中止。当守护进程接收到其中一个信号时,它会触发sigHandler()函数。该函数然后会删除 PID 文件并关闭文件流。最后,该函数通过调用exit()退出整个守护进程。

然而,由于我们无法将文件名或文件流作为参数传递给信号处理程序,我们将这些变量放在全局范围内。这样一来,main()sigHandler()都可以访问它们。

更多内容…

记得我们之前必须刷新流才能在/tmp/my-daemon-is-alive.txt中显示时间和日期吗?由于现在守护进程退出时关闭文件流,我们不再需要fflush()。数据在关闭时被写入文件。然而,这样一来,我们就无法在守护进程运行时“实时”看到时间和日期。这就是为什么我们在代码中仍然保留了fflush()

第七章:使用 systemd 处理您的守护进程

现在我们知道如何构建我们自己的守护进程,是时候看看我们如何使用systemd让 Linux 来处理它们了。在本章中,我们将学习 systemd 是什么,如何启动和停止服务,什么是单元文件,以及如何创建它们。我们还将学习守护进程如何记录到 systemd 中以及如何读取这些日志。

然后,我们将了解 systemd 可以处理的不同类型的服务和守护进程,并将上一章的守护进程放到 systemd 控制下。

在本章中,我们将涵盖以下示例:

  • 了解 systemd

  • 为守护进程编写一个单元文件

  • 启用和禁用服务,以及启动和停止它

  • 为 systemd 创建一个更现代的守护进程

  • 使新的守护进程成为 systemd 服务

  • 阅读日志

技术要求

对于这个示例,您需要一台使用 systemd 的 Linux 发行版的计算机——今天几乎每个发行版都是如此,只有一些少见的例外。

您还需要 GCC 编译器和 Make 工具。这些工具的安装说明在第一章中有涵盖。您还需要本章的通用 Makefile,在本章的 GitHub 存储库中可以找到,以及本章的所有代码示例。本章的 GitHub 存储库文件夹的 URL 是github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch7

查看以下链接以查看“代码实战”视频:bit.ly/3cxmXab

了解 systemd

在这个示例中,我们将探讨 systemd 是什么,它如何处理系统以及所有系统的服务。

从历史上看,Linux 一直由几个较小的部分管理。例如,init是系统上的第一个进程,它启动其他进程和守护进程来启动系统。系统守护进程由 shell 脚本处理,也称为init 脚本。日志记录是通过守护进程自己通过文件或syslog来完成的。网络也是由多个脚本处理的(在一些 Linux 发行版中仍然是这样)。

然而,现在整个系统都由 systemd 处理。例如,系统上的第一个进程现在是systemd(我们在之前的章节中已经看到了)。守护进程由称为单元文件的东西处理,它在系统上创建了一种统一的控制守护进程的方式。日志记录由journald处理,它是 systemd 的日志记录守护进程。但请注意,syslog仍然被许多守护进程用于额外的日志记录。在本章的使新的守护进程成为 systemd 服务部分中,我们将重新编写第六章中的守护进程,以记录到日志中。

了解 systemd 的工作原理将使您能够在编写守护进程的单元文件时正确使用它。它还将帮助您以“新”的方式编写守护进程,以利用 systemd 的日志记录功能。您将成为一个更好的系统管理员,也将成为一个更好的 Linux 开发人员。

准备工作

对于这个示例,您只需要一个使用 systemd 的 Linux 发行版,大多数发行版今天都使用 systemd。

如何做…

在这个示例中,我们将看一下 systemd 涉及的一些组件。这将让我们俯瞰 systemd、journald、它的命令和单元文件。所有的细节将在本章的后续示例中介绍:

  1. 在控制台窗口中键入systemctl并按Enter。这将显示您机器上当前所有活动的单元。如果您浏览列表,您会注意到一个单元可以是任何东西——硬盘、声卡、挂载的网络驱动器、各种服务、定时器等等。

  2. 我们在上一步看到的所有服务都作为单元文件存储在/lib/systemd/system/etc/systemd/system中。转到这些目录并查看文件。这些都是典型的单元文件。

  3. 现在是时候来看一下日志,即 systemd 的日志。我们需要以sudo journalctl命令运行此命令,或者首先切换到 root 用户,然后输入journalctl。这将显示 systemd 和其所有服务的整个日志。按Spacebar键几次以在日志中向下滚动。要转到日志的末尾,在日志显示时输入大写G

它是如何工作的...

这三个步骤让我们对 systemd 有了一个概述。在接下来的教程中,我们将更深入地介绍细节。

已安装的软件包将其单元文件放在/lib/systemd/system中,如果是 Debian/Ubuntu 系统,则放在/usr/lib/systemd/system中,如果是 CentOS/Fedora 系统。但是,在 CentOS/Fedora 上,/lib是指向/usr/lib的符号链接,因此/lib/systemd/system是通用的。

所谓的local单元文件放在/etc/systemd/system中。本地单元文件意味着特定于此系统的单元文件,例如,由管理员修改或手动添加的某些程序。

还有更多...

在 systemd 之前,Linux 有其他的初始化系统。我们已经简要提到了第一个init。那个初始化系统init通常被称为Sys-V-style init,来自 UNIX 版本五(V)。

在 Sys-V-style init 之后,出现了 Upstart,这是 Ubuntu 开发的init的完全替代品。Upstart 也被 CentOS 6 和 Red Hat Enterprise Linux 6 使用。

然而,如今,大多数主要的 Linux 发行版都使用 systemd。由于 systemd 是 Linux 的一个重要组成部分,这使得所有发行版几乎都是相似的。十五年前,从一个发行版跳到另一个发行版并不容易。如今,这变得更容易了。

另请参阅

系统上有多个手册页面,我们可以阅读以更深入地了解 systemd、其命令和日志:

  • man systemd

  • man systemctl

  • man journalctl

  • man systemd.unit

为守护进程编写单元文件

在这个教程中,我们将把我们在第六章中编写的守护程序,生成进程和使用作业控制,变成 systemd 下的一个服务。这个守护程序是 systemd 称之为forking daemon的,因为它就是这样。它分叉。这通常是守护程序的工作方式,它们仍然被广泛使用。在本章的将新守护程序变成 systemd 服务部分中,我们将稍微修改它以记录到 systemd 的日志中。但首先,让我们将我们现有的守护程序变成一个服务。

准备工作

在这个教程中,您将需要我们在第六章中编写的文件my-daemon-v2.c生成进程和使用作业控制。如果您没有该文件,在 GitHub 的本章目录中有一份副本,网址为github.com/PacktPublishing/Linux-System-Programming-Techniques/blob/master/ch7/my-daemon-v2.c

除了my-daemon-v2.c,您还需要 GCC 编译器、Make 工具和本章技术要求部分中涵盖的通用 Makefile。

如何做...

在这里,我们将把我们的守护程序置于 systemd 的控制之下:

  1. 如果您还没有编译my-daemon-v2,我们需要从那里开始。像我们迄今为止制作的任何其他程序一样编译它:
$> make my-daemon-v2
gcc -Wall -Wextra -pedantic -std=c99    my-daemon-v2.c   -o my-daemon-v2
  1. 为了使其成为系统守护程序,我们应该将其放在其中一个专门用于此目的的目录中。一个很好的地方是/usr/local/sbin/usr/local 目录通常是我们想要放置我们自己添加到系统中的东西的地方,也就是第三方的东西。sbin子目录用于系统二进制文件或超级用户二进制文件(因此在bin之前有一个s)。要将我们的守护程序移到这里,我们需要成为 root 用户:
$> sudo mv my-daemon-v2 /usr/local/sbin/
  1. 现在来写守护程序的单元文件,这才是令人兴奋的部分。以 root 身份创建文件/etc/systemd/system/my-daemon.service。使用sudosu成为 root。在文件中写入下面显示的内容并保存。单元文件分为几个部分。在这个文件中,部分是[Unit][Service][Install][Unit]部分包含有关单元的信息,例如我们的描述。[Service]部分包含有关此服务应如何工作和行为的信息。在这里,我们有ExecStart,其中包含守护程序的路径。我们还有Restart=on-failure。这告诉 systemd 如果守护程序崩溃,应重新启动它。然后我们有Type指令,在我们的情况下是 forking。请记住,我们的守护程序创建了一个自己的分支,父进程退出。这就是forking类型的含义。我们告诉 systemd 类型,以便它知道如何处理守护程序。然后我们有PIDFile,其中包含我们的WantedBy设置为multi-user.target。这意味着当系统进入多用户阶段时,此守护程序应该启动:
[Unit]
Description=A small daemon for testing
[Service]
ExecStart=/usr/local/sbin/my-daemon-v2
Restart=on-failure
Type=forking
PIDFile=/var/run/my-daemon.pid
[Install]
WantedBy=multi-user.target
  1. 为了让系统识别我们的新单元文件,我们需要重新加载systemd 守护程序本身。这将读取我们的新文件。这必须以 root 身份完成:
$> sudo systemctl daemon-reload
  1. 我们现在可以使用systemctlstatus命令来查看 systemd 是否识别我们的新守护程序。请注意,我们在这里从单元文件中看到了描述,以及实际使用的单元文件。我们还看到守护程序当前是禁用未激活的:
$> sudo systemctl status my-daemon
. my-daemon.service - A small daemon for testing
   Loaded: loaded (/etc/systemd/system/my-daemon.service; disabled; vendor preset: enabled)
   Active: inactive (dead)

它是如何工作的...

为守护程序创建一个 systemd 服务并不比这更难。一旦我们学会了 systemd 和单元文件,就比在旧日写init 脚本更容易。只用了九行,我们就将守护程序置于 systemd 的控制之下。

单元文件大部分都是不言自明的。在我们的情况下,对于一个传统的分叉守护程序,我们将类型设置为forking并指定一个 PID 文件。然后 systemd 使用 PID 文件中的 PID 号来跟踪守护程序的状态。这样,如果 systemd 注意到 PID 从系统中消失,它就可以重新启动守护程序。

在状态消息中,我们看到服务被禁用未激活禁用意味着系统启动时不会自动启动。未激活意味着它还没有启动。

还有更多...

如果您为使用网络的守护程序编写一个单元文件,例如互联网守护程序,您可以明确告诉 systemd 等待直到网络准备就绪。为了实现这一点,我们在[Unit]部分下添加以下行:

After=network-online.target
Wants=network-online.target

当然,您也可以为其他依赖关系使用AfterWants。还有另一个依赖语句可以使用,称为Requires

它们之间的区别在于After指定了单元的顺序。具有After的单元将在所需单元启动后等待启动。然而,WantsRequires只指定了依赖关系,而不是顺序。使用Wants,即使其他所需单元未成功启动,单元仍将启动。但是使用Requires,如果所需单元未启动,单元将无法启动。

另请参阅

man systemd.unit中有关于单元文件的不同部分以及我们可以在每个部分中使用的指令的大量信息。

启用和禁用服务 - 以及启动和停止它

在上一个教程中,我们使用一个单元文件将我们的守护程序添加为 systemd 的一个服务。在这个教程中,我们将学习如何启用、启动、停止和禁用它。启用和启动以及禁用和停止服务之间有区别。

启用服务意味着系统启动时将自动启动。启动服务意味着它将立即启动,无论它是否已启用。禁用服务意味着它将不再在系统启动时启动。停止服务会立即停止它,无论它是否已启用或禁用。

了解如何做所有这些可以让你控制系统的服务。

准备工作

为了使这个教程起作用,你首先需要完成前面的教程,为守护进程编写一个单元文件

如何做...

  1. 让我们首先再次检查守护进程的状态。它应该是禁用和未激活的:
$> systemctl status my-daemon
. my-daemon.service - A small daemon for testing
   Loaded: loaded (/etc/systemd/system/my-daemon.service; disabled; vendor preset: enabled)
   Active: inactive (dead)
  1. 现在我们将启用它,这意味着它将在启动时自动启动(当系统进入多用户模式时)。由于这是一个修改系统的命令,我们必须以 root 身份发出此命令。还要注意当我们启用它时发生了什么。没有什么神秘的事情发生;它只是从我们的单元文件创建一个符号链接到/etc/systemd/system/multi-user.target.wants/my-daemon.service。请记住,multi-user.target是我们在单元文件中指定的目标。因此,当系统达到多用户级别时,systemd 将启动该目录中的所有服务:
$> sudo systemctl enable my-daemon
Created symlink /etc/systemd/system/multi-user.target.wants/my-daemon.service → /etc/systemd/system/my-daemon.service.
  1. 现在让我们检查一下守护进程的状态,因为我们已经启用了它。现在它应该显示已启用而不是已禁用。但是,它仍然是未激活(未启动):
$> sudo systemctl status my-daemon
. my-daemon.service - A small daemon for testing
   Loaded: loaded (/etc/systemd/system/my-daemon.service; enabled; vendor preset: enabled)
   Active: inactive (dead)
  1. 现在是启动守护进程的时候了:
$> sudo systemctl start my-daemon
  1. 让我们再次检查状态。它应该是启用和活动的(也就是已启动)。这一次,我们将获得比以前更多关于守护进程的信息。我们将看到它的 PID、状态、内存使用情况等。我们还将在最后看到日志的片段:
$> sudo systemctl status my-daemon
. my-daemon.service - A small daemon for testing
   Loaded: loaded (/etc/systemd/system/my-daemon.service; enabled; vendor preset: enabled)
   Active: active (running) since Sun 2020-12-06 14:50:35 CET; 9s ago
  Process: 29708 ExecStart=/usr/local/sbin/my-daemon-v2 (code=exited, status=0/SUCCESS)
 Main PID: 29709 (my-daemon-v2)
    Tasks: 1 (limit: 4915)
   Memory: 152.0K
   CGroup: /system.slice/my-daemon.service
           └─29709 /usr/local/sbin/my-daemon-v2
dec 06 14:50:35 red-dwarf systemd[1]: Starting A small daemon for testing...
dec 06 14:50:35 red-dwarf systemd[1]: my-daemon.service: Can't open PID file /run/my-daemon.pid (yet?) after start
dec 06 14:50:35 red-dwarf systemd[1]: Started A small daemon for testing.
  1. 让我们验证一下,如果守护进程崩溃或被杀死,systemd 是否会重新启动它。首先,我们用ps查看进程。然后我们用KILL信号杀死它,所以它没有机会正常退出。然后我们再次用ps查看它,并注意到它有一个新的 PID,因为它是一个新的进程。旧的进程被杀死了,systemd 启动了一个新的实例:
$> ps ax | grep my-daemon-v2
923 pts/12   S+     0:00 grep my-daemon-v2
29709 ?        S      0:00 /usr/local/sbin/my-daemon-v2
$> sudo kill -KILL 29709
$> ps ax | grep my-daemon-v2
 1103 ?        S      0:00 /usr/local/sbin/my-daemon-v2
 1109 pts/12   S+     0:00 grep my-daemon-v2
  1. 我们还可以查看守护进程在/tmp目录中写入的文件:
$> tail -n 5 /tmp/my-daemon-is-alive.txt 
Daemon alive at Sun Dec  6 15:24:11 2020
Daemon alive at Sun Dec  6 15:24:41 2020
Daemon alive at Sun Dec  6 15:25:11 2020
Daemon alive at Sun Dec  6 15:25:41 2020
Daemon alive at Sun Dec  6 15:26:11 2020
  1. 最后,让我们停止守护进程。我们还将检查它的状态,并检查进程是否已经消失了ps
$> sudo systemctl stop my-daemon
$> sudo systemctl status my-daemon
. my-daemon.service - A small daemon for testing
   Loaded: loaded (/etc/systemd/system/my-daemon.service; enabled; vendor preset: enabled)
   Active: inactive (dead) since Sun 2020-12-06 15:27:49 CET; 7s ago
  Process: 1102 ExecStart=/usr/local/sbin/my-daemon-v2 (code=exited, status=0/SUCCESS)
 Main PID: 1103 (code=killed, signal=TERM)
dec 06 15:18:41 red-dwarf systemd[1]: Starting A small daemon for testing...
dec 06 14:50:35 red-dwarf systemd[1]: my-daemon.service: Can't open PID file /run/my-daemon.pid (yet?) after start
dec 06 15:18:41 red-dwarf systemd[1]: Started A small daemon for testing.
dec 06 15:27:49 red-dwarf systemd[1]: Stopping A small daemon for testing...
dec 06 15:27:49 red-dwarf systemd[1]: my-daemon.service: Succeeded.
dec 06 15:27:49 red-dwarf systemd[1]: Stopped A small daemon for testing.
$> ps ax | grep my-daemon-v2
 2769 pts/12   S+     0:00 grep my-daemon-v2
  1. 为了防止守护进程在系统重新启动时启动,我们还必须禁用该服务。请注意这里发生了什么。当我们启用服务时创建的符号链接现在被删除了:
$> sudo systemctl disable my-daemon
Removed /etc/systemd/system/multi-user.target.wants/my-daemon.service.

它是如何工作的...

当我们启用或禁用一个服务时,systemd 会在target目录中创建一个符号链接。在我们的情况下,目标是multi-user,也就是当系统达到多用户级别时。

在第五步,当我们启动守护进程时,我们在状态输出中看到了Main PID。这个 PID 与守护进程创建的/var/run/my-daemon.pid文件中的 PID 匹配。这就是 systemd 如何跟踪forking守护进程的方式。在下一个教程中,我们将看到如何在 systemd 中创建一个不需要 fork 的守护进程。

为 systemd 创建一个更现代的守护进程

由 systemd 处理的守护进程不需要 fork 或关闭它们的文件描述符。相反,建议使用标准输出和标准错误将守护进程的日志写入日志。日志是 systemd 的日志记录设施。

在这个教程中,我们将编写一个新的守护进程,一个不会 fork 并留下/tmp/my-daemon-is-alive.txt文件的守护进程(与之前一样)。这种类型的守护进程有时被称为my-daemon-v2.c,被称为SysV 风格守护进程SysV是 systemd 之前的 init 系统的名称。

准备工作

对于这个教程,你只需要本章节技术要求部分列出的内容。

如何做...

在这个教程中,我们将编写一个新式守护进程

  1. 这个程序有点长,所以我把它分成了几个步骤。将代码写入文件并保存为new-style-daemon.c。所有代码都放在一个文件中,即使有几个步骤。我们将首先编写所有的include语句,信号处理程序的函数原型和main()函数体。请注意,我们这里不进行 fork。我们也不关闭任何文件描述符或流。相反,我们将“守护程序活着”文本写入标准输出。请注意,我们需要在这里刷新stdout。通常,流是行缓冲的,这意味着它们在每个新行上都会被刷新。但是当 stdout 被重定向到其他地方时,比如使用 systemd,它会被完全缓冲。为了能够看到打印的文本,我们需要刷新它;否则,在停止守护程序或缓冲区填满之前,我们将看不到日志中的任何内容:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <time.h>
void sigHandler(int sig);
int main(void)
{
    time_t now; /* for the current time */
    struct sigaction action; /* for sigaction */
    /* prepare for sigaction */
    action.sa_handler = sigHandler;
    sigfillset(&action.sa_mask);
    action.sa_flags = SA_RESTART;
    /* register the signal handler */
    sigaction(SIGTERM, &action, NULL);
    sigaction(SIGUSR1, &action, NULL);
    sigaction(SIGHUP, &action, NULL);
    for (;;) /* main loop */
    {
        time(&now); /* get current date & time */
        printf("Daemon alive at %s", ctime(&now));
        fflush(stdout);
        sleep(30);
    }
    return 0;
}
  1. 现在我们将编写信号处理程序的函数。请注意,我们在这里捕获了SIGHUPSIGTERMSIGHUP经常用于重新加载任何配置文件,而无需重新启动整个守护程序。捕获SIGTERM是为了让守护程序在自己之后进行清理(关闭所有打开的文件描述符或流并删除任何临时文件)。我们这里没有任何配置文件或临时文件,所以我们将消息打印到标准输出:
void sigHandler(int sig)
{
    if (sig == SIGUSR1)
    {
        printf("Hello world!\n");
    }
    else if (sig == SIGTERM)
    {
        printf("Doing some cleanup...\n");
        printf("Bye bye...\n");
        exit(0);
    }
    else if (sig == SIGHUP)
    {
        printf("HUP is used to reload any " 
            "configuration files\n");
    }
} 
  1. 现在是时候编译守护程序,这样我们就可以使用它了:
$> make new-style-daemon
gcc -Wall -Wextra -pedantic -std=c99    new-style-daemon.c   -o new-style-daemon
  1. 我们可以交互式运行它以验证它是否正常工作:
$> ./new-style-daemon 
Daemon alive at Sun Dec  6 18:51:47 2020
Ctrl+C

它是如何工作的...

这个守护程序的工作方式几乎与我们编写的任何其他程序一样。无需进行任何 forking、更改工作目录、关闭文件描述符或流,或者其他任何操作。它只是一个常规程序。

请注意,我们不在信号处理程序中刷新 stdout 缓冲区。每次程序接收到信号并打印消息时,程序都会回到for循环中,打印另一条“守护程序活着”消息,然后在for循环中的fflush(stdout)处刷新。如果信号是SIGTERM,则在exit(0)时刷新所有缓冲区,因此我们这里也不需要刷新。

在下一个食谱中,我们将使这个程序成为 systemd 服务。

另请参阅

您可以在man 7 daemon中获取更多深入的信息。

使新守护程序成为 systemd 服务

现在我们已经在上一个食谱中制作了一个新式守护程序,我们将看到为这个守护程序制作一个单元文件更容易。

了解如何编写单元文件以适应新式守护程序非常重要,因为越来越多的守护程序是以这种方式编写的。在为 Linux 制作新的守护程序时,我们应该以这种新的方式制作它们。

准备工作

对于这个食谱,您需要完成上一个食谱。我们将在这里使用那个食谱中的守护程序。

如何做...

在这里,我们将使新式守护程序成为 systemd 服务:

  1. 让我们首先将守护程序移动到/usr/local/sbin,就像我们对传统守护程序所做的那样。请记住,您需要以 root 身份进行操作:
$> sudo mv new-style-daemon /usr/local/sbin/
  1. 现在我们将编写新的单元文件。创建/etc/systemd/system/new-style-daemon.service文件,并给它以下内容。请注意,我们不需要在这里指定任何 PID 文件。另外,请注意,我们已将Type=forking更改为Type=simple。Simple 是 systemd 服务的默认类型:
[Unit]
Description=A new-style daemon for testing
[Service]
ExecStart=/usr/local/sbin/new-style-daemon
Restart=on-failure
Type=simple
[Install]
WantedBy=multi-user.target
  1. 重新加载 systemd 守护程序,以便识别新的单元文件:
$> sudo systemctl daemon-reload
  1. 启动守护程序,并检查其状态。请注意,我们也会在这里看到一个“守护程序活着”消息。这是日志中的一个片段。请注意,这次我们不会启用服务。除非我们希望它自动启动,否则我们不需要启用服务:
$> sudo systemctl start new-style-daemon
$> sudo systemctl status new-style-daemon
. new-style-daemon.service - A new-style daemon for testing
   Loaded: loaded (/etc/systemd/system/new-style-daemon.service; disabled; vendor preset: enabled
   Active: active (running) since Sun 2020-12-06 19:51:25 CET; 7s ago
 Main PID: 8421 (new-style-daemo)
    Tasks: 1 (limit: 4915)
   Memory: 244.0K
   CGroup: /system.slice/new-style-daemon.service
           └─8421 /usr/local/sbin/new-style-daemon
dec 06 19:51:25 red-dwarf systemd[1]: Started A new-style daemon for testing.
dec 06 19:51:25 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 19:51:25 2020
  1. 让守护程序运行,并在下一个食谱中查看日志。

它是如何工作的...

由于这个守护程序没有 forking,systemd 可以在没有 PID 文件的情况下跟踪它。对于这个守护程序,我们使用了Type=simple,这是 systemd 中的默认类型。

当我们在Step 4中启动守护进程并检查其状态时,我们看到了“守护进程活动”消息的第一行。我们可以在不使用sudo的情况下查看守护进程的状态,但是我们就看不到日志的片段(因为它可能包含敏感数据)。

由于我们在for循环中的每个printf()后刷新了标准输出缓冲区,因此每次写入新条目时,日志都会实时更新。

在下一个步骤中,我们将查看日志。

阅读日志

在这个步骤中,我们将学习如何阅读日志。日志是 systemd 的日志记录设施。守护进程打印到标准输出或标准错误的所有消息都会添加到日志中。但是我们在这里可以找到的不仅仅是系统守护进程的日志。还有系统的引导消息,等等。

了解如何阅读日志可以让您更轻松地找到系统和守护进程中的错误。

准备工作

对于这个步骤,您需要new-style-daemon服务正在运行。如果您的系统上没有运行它,请返回到上一个步骤,了解如何启动它。

如何做...

在这个步骤中,我们将探讨如何阅读日志以及我们可以在其中找到什么样的信息。我们还将学习如何跟踪特定服务的日志:

  1. 我们将首先检查来自我们的服务new-style-daemon的日志。 -u选项代表单元
$> sudo journalctl -u new-style-daemon

现在日志可能已经很长了,所以您可以通过按Spacebar向下滚动日志。要退出日志,请按Q

  1. 请记住,我们为SIGUSR1实现了一个信号处理程序?让我们尝试向我们的守护进程发送该信号,然后再次查看日志。但是这次,我们将使用--lines 5仅显示日志中的最后五行。通过使用systemctl status找到进程的 PID。注意“Hello world”消息(在以下代码中已突出显示):
$> systemctl status new-style-daemon
. new-style-daemon.service - A new-style daemon for testing
   Loaded: loaded (/etc/systemd/system/new-style-daemon.service; disabled; vendor preset: enabled
   Active: active (running) since Sun 2020-12-06 19:51:25 CET; 31min ago
 Main PID: 8421 (new-style-daemo)
    Tasks: 1 (limit: 4915)
   Memory: 412.0K
   CGroup: /system.slice/new-style-daemon.service
           └─8421 /usr/local/sbin/new-style-daemon
$> sudo kill -USR1 8421
$> sudo journalctl -u new-style-daemon --lines 5
-- Logs begin at Mon 2020-11-30 18:05:24 CET, end at Sun 2020-12-06 20:24:46 CET. --
dec 06 20:23:31 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:23:31 2020
dec 06 20:24:01 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:24:01 2020
dec 06 20:24:31 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:24:31 2020
dec 06 20:24:42 red-dwarf new-style-daemon[8421]: Hello world!
dec 06 20:24:42 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:24:42 2020
  1. 还可以跟踪服务的日志,即“实时”查看。打开第二个终端并运行以下命令。-f代表跟踪
$> sudo journalctl -u new-style-daemon -f
  1. 现在,在第一个终端中,使用sudo kill -USR1 8421发送另一个USR1信号。您会立即在第二个终端中看到“Hello world”消息,而不会有任何延迟。要退出跟踪模式,只需按Ctrl + C

  2. journalctl命令提供了广泛的过滤功能。例如,可以使用--since--until仅选择两个日期之间的日志条目。也可以省略其中一个来查看自特定日期以来或直到特定日期的所有消息。在这里,我们展示了两个日期之间的所有消息:

$> sudo journalctl -u new-style-daemon \
> --since "2020-12-06 20:32:00" \
> --until "2020-12-06 20:33:00"
-- Logs begin at Mon 2020-11-30 18:05:24 CET, end at Sun 2020-12-06 20:37:01 CET. --
dec 06 20:32:12 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:32:12 2020
dec 06 20:32:42 red-dwarf new-style-daemon[8421]: Daemon alive at Sun Dec  6 20:32:42 2020
  1. 通过省略-u选项和单元名称,我们可以查看所有服务的所有日志条目。试一下,用Spacebar滚动浏览。您还可以尝试只查看最后 10 行,就像我们之前用--line 10一样。

现在是时候停止new-style-daemon服务了。在停止服务后,我们还将查看日志中的最后五行。注意来自守护进程的告别消息。这是我们为SIGTERM信号制作的信号处理程序。当我们在 systemd 中停止服务时,它会发送一个SIGTERM信号给服务:

$> sudo systemctl stop new-style-daemon
$> sudo journalctl -u new-style-daemon --lines 5
-- Logs begin at Mon 2020-11-30 18:05:24 CET, end at Sun 2020-12-06 20:47:02 CET. --
dec 06 20:46:44 red-dwarf systemd[1]: Stopping A new-style daemon for testing...
dec 06 20:46:44 red-dwarf new-style-daemon[8421]: Doing some cleanup...
dec 06 20:46:44 red-dwarf new-style-daemon[8421]: Bye bye...
dec 06 20:46:44 red-dwarf systemd[1]: new-style-daemon.service: Succeeded.
dec 06 20:46:44 red-dwarf systemd[1]: Stopped A new-style daemon for testing.

工作原理...

由于日志负责处理所有发送到标准输出和标准错误的消息,我们不需要自己处理日志记录。这使得编写由 systemd 处理的 Linux 守护进程变得更容易。正如我们在查看日志时看到的那样,每条消息都有一个时间戳。这使得在寻找错误时可以轻松地过滤出特定的日期或时间。

使用-f选项跟踪特定服务的日志在尝试新的或未知服务时很常见。

另请参阅

man journalctl的手册页面上甚至有更多关于如何过滤日志的技巧和提示。

第八章:创建共享库

在本章中,我们将学习库是什么,以及为什么它们是 Linux 的重要组成部分。我们还将了解静态库和动态库之间的区别。当我们知道库是什么时,我们开始编写我们自己的库——静态和动态的。我们还快速查看动态库的内部。

使用库有许多好处,例如,开发人员不需要一遍又一遍地重新发明功能,因为通常库中已经存在一个现有的功能。动态库的一个重要优势是,生成的程序大小要小得多,并且即使在程序编译完成后,库也是可升级的。

在本章中,我们将学习如何制作具有有用功能的自己的库,并将其安装到系统上。知道如何制作和安装库使您能够以标准化的方式与他人共享您的功能。

在本章中,我们将涵盖以下配方:

  • 库的作用和意义

  • 创建静态库

  • 使用静态库

  • 创建动态库

  • 在系统上安装动态库

  • 在程序中使用动态库

  • 编译一个静态链接的程序

技术要求

在本章中,我们将需要GNU 编译器集合GCC)编译器和 Make 工具。您可以在第一章中找到这些工具的安装说明,获取必要的工具并编写我们的第一个 Linux 程序。本章的所有代码示例都可以在本章的 GitHub 目录中找到,网址为github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch8

点击以下链接查看《代码实战》视频:bit.ly/3fygqOm

库的作用和意义

在我们深入了解库的细节之前,了解它们是什么以及它们对我们的重要性是至关重要的。了解静态库和动态库之间的区别也很重要:

这些知识将使您在制作自己的库时能够做出更明智的选择。

动态库是动态链接到使用它的二进制文件的。这意味着库代码不包含在二进制文件中。库驻留在二进制文件之外。这有几个优点。首先,由于库代码不包含在其中,生成的二进制文件大小会更小。其次,库可以在不需要重新编译二进制文件的情况下进行更新。缺点是我们不能将动态库从系统中移动或删除。如果这样做,二进制文件将不再起作用。

另一方面,静态库包含在二进制文件中。这样做的优点是一旦编译完成,二进制文件将完全独立于库。缺点是二进制文件会更大,并且库不能在不重新编译二进制文件的情况下更新。

我们已经在第三章**中看到了一个动态库的简短示例,在 Linux 中深入 C

在这个配方中,我们将看一些常见的库。我们还将通过包管理器在系统上安装一个新的库,然后在程序中使用它。

准备工作

对于这个配方,您将需要 GCC 编译器。您还需要通过susudo以 root 访问系统。

操作方法…

在这个配方中,我们将探索一些常见的库,看看它们在系统上的位置,然后安装一个新的库并查看库的内部。在这个配方中,我们只处理动态库。

  1. 让我们首先看看您系统中已经存在的许多库。这些库将驻留在一个或多个这些目录中,具体取决于您的发行版:
/usr/lib
/usr/lib64
/usr/lib32
  1. 现在,我们将使用 Linux 发行版软件包管理器在系统上安装一个新的库。我们将安装的库是用于cURL的,这是一个从互联网上获取文件或数据的应用程序和库,例如通过超文本传输协议HTTP)。根据您的发行版,按照以下说明进行操作:
  • Debian/Ubuntu:
   $> sudo apt install libcurl4-openssl-dev
  • Fedora/CentOS/Red Hat:
   $> sudo dnf install libcurl-devel
  1. 现在,让我们使用nm来查看库的内部。但首先,我们需要使用whereis找到它。不同发行版的库路径是不同的。这个示例来自 Debian 10 系统。我们要找的文件是.so文件。请注意,我们使用grepnm一起使用,只列出带有T的行。这些是库提供的函数。如果我们去掉grep部分,我们还会看到这个库依赖的函数。我们还在命令中添加了head,因为函数列表很长。如果您想看到所有函数,请省略head
$> whereis libcurl
libcurl: /usr/lib/x86_64-linux-gnu/libcurl.la
/usr/lib/x86_64-linux-gnu/libcurl.a /usr/lib/x86_64
linux-gnu/libcurl.so
$> nm -D /usr/lib/x86_64-linux-gnu/libcurl.so \
> | grep " T " | head -n 7
000000000002f750 T curl_easy_cleanup
000000000002f840 T curl_easy_duphandle
00000000000279b0 T curl_easy_escape
000000000002f7e0 T curl_easy_getinfo
000000000002f470 T curl_easy_init
000000000002fc60 T curl_easy_pause
000000000002f4e0 T curl_easy_perform
  1. 现在我们对库有了更多了解,我们可以在程序中使用它。在文件中编写以下代码,并将其保存为get-public-ip.c。该程序将向位于ifconfig.me的 Web 服务器发送请求,并给出您的公共Internet ProtocolIP)地址。cURL 库的完整手册可以在curl.se/libcurl/c/上找到。请注意,我们不从 cURL 打印任何内容。库将自动打印从服务器接收到的内容:
#include <stdio.h>
#include <curl/curl.h>
int main(void)
{
    CURL *curl;
    curl = curl_easy_init();
    if(curl) 
    {
        curl_easy_setopt(curl, CURLOPT_URL, 
            "https://ifconfig.me"); 
        curl_easy_perform(curl); 
        curl_easy_cleanup(curl);
    }
    else
    {
        fprintf(stderr, "Cannot initialize curl\n");
        return 1;
    }
    return 0;
}
  1. 编译代码。请注意,我们还必须使用-l选项链接到 cURL 库:
$> gcc -Wall -Wextra -pedantic -std=c99 -lcurl \
> get-public-ip.c -o get-public-ip
  1. 现在,最后,我们可以运行程序来获取我们的公共 IP 地址。我的 IP 地址在下面的输出中被掩盖了:
$> ./get-public-ip 
158.174.xxx.xxx

工作原理…

在这里,我们已经看到了使用库添加新功能所涉及的所有步骤。我们使用软件包管理器在系统上安装了库。我们使用whereis找到了它的位置,使用nm调查了它包含的函数,最后在程序中使用了它。

nm程序提供了一种快速查看库包含哪些函数的方法。我们在这个示例中使用的-D选项是用于动态库的。我们使用grep只查看库提供的函数;否则,我们还会看到这个库依赖的函数(这些行以U开头)。

由于这个库不是libc的一部分,我们需要使用-l选项将其链接到gcc。库的名称应该紧跟在l后面,没有任何空格。

ifconfig.me 网站是一个返回请求该站点的客户端的公共 IP 的站点和服务。

还有更多…

cURL 也是一个程序。许多 Linux 发行版都预装了它。cURL 库提供了一种方便的方式,在您自己的程序中使用 cURL 函数。

您可以运行curl ifconfig.me来获得与我们编写的程序相同的结果,假设您已经安装了 cURL。

创建一个静态库

第三章中,深入 Linux 中的 C 编程,我们看到了如何创建动态库以及如何从当前工作目录链接它。在这个示例中,我们将创建一个静态库

静态库在编译过程中包含在二进制文件中。优点是二进制文件更具可移植性和独立性。我们可以在编译后删除静态库,程序仍然可以正常工作。

缺点是二进制文件会稍微变大,而且在将库编译到程序中后无法更新库。

了解如何创建静态库将使在新程序中分发和重用您的函数变得更加容易。

准备工作

对于这个示例,我们将需要 GCC 编译器。我们还将在这个示例中使用一个名为ar的工具。ar程序几乎总是默认安装的。

如何做…

在这个教程中,我们将制作一个小的静态库。该库将包含两个函数:一个用于将摄氏度转换为华氏度,另一个用于将摄氏度转换为开尔文:

  1. 让我们从编写库函数开始。在文件中写入以下代码,并将其保存为convert.c。该文件包含我们的两个函数:
float c_to_f(float celsius)
{
    return (celsius*9/5+32);
}
float c_to_k(float celsius)
{
    return (celsius + 273.15);
}
  1. 我们还需要一个包含这些函数原型的头文件。创建另一个文件,并在其中写入以下代码。将其保存为convert.h
float c_to_f(float celsius);
float c_to_k(float celsius);
  1. 制作库的第一步是将convert.c编译成 GCC 的-c选项:
$> gcc -Wall -Wextra -pedantic -std=c99 -c convert.c
  1. 我们现在应该在当前目录中有一个名为convert.o的文件。我们可以使用file命令来验证这一点,它还会告诉我们文件的类型:
$> file convert.o
convert.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  1. 使其成为静态库的最后一步是使用ar命令将其打包。-c选项表示创建存档;-v选项表示详细输出;-r选项表示替换具有相同名称的成员。名称libconvert.a是我们的库将得到的结果文件名:
$> ar -cvr libconvert.a convert.o 
a - convert.o
  1. 在继续之前,让我们用nm查看我们的静态库:
$> nm libconvert.a 
convert.o:
0000000000000000 T c_to_f
0000000000000037 T c_to_k

它是如何工作的…

正如我们在这里看到的,静态库只是存档中的一个对象文件。

当我们用file命令查看对象文件时,我们注意到它说not stripped,这意味着所有的符号仍然在文件中。符号是暴露函数的东西,使得程序可以访问和使用它们。在下一个教程中,我们将回到符号和strippednot stripped的含义。

参见

在其手册页man 1 ar中有关ar的大量有用信息,例如,可以修改和删除已经存在的静态库。

使用静态库

在这个教程中,我们将在程序中使用上一个教程中创建的静态库。使用静态库比使用动态库要容易一些。我们只需将静态库(存档文件)添加到将编译为最终二进制文件的文件列表中。

知道如何使用静态库将使您能够使用其他人的库并重用自己的代码作为静态库。

准备工作

对于这个教程,您将需要convert.h文件和静态库文件libconvert.a。您还需要 GCC 编译器。

如何做…

在这里,我们将编写一个小程序,该程序使用我们在上一个教程中创建的库中的函数:

  1. 在文件中写入以下代码,并将其保存为temperature.c。注意从当前目录包含头文件的语法。

该程序接受两个参数:一个选项(-f-k,分别表示华氏度或开尔文)和一个摄氏度作为浮点值。然后程序将根据所选的选项将摄氏度转换为华氏度或开尔文:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "convert.h"
void printUsage(FILE *stream, char progname[]);
int main(int argc, char *argv[])
{
    if ( argc != 3 )
    {
        printUsage(stderr, argv[0]);
        return 1;
    }
    if ( strcmp(argv[1], "-f") == 0 )
    {
        printf("%.1f C = %.1f F\n", 
            atof(argv[2]), c_to_f(atof(argv[2])));
    }
    else if ( strcmp(argv[1], "-k") == 0  )
    {
        printf("%.1f C = %.1f F\n", 
            atof(argv[2]), c_to_k(atof(argv[2])));
    }
    else
    {
        printUsage(stderr, argv[0]);
        return 1;
    }

    return 0;
}
void printUsage(FILE *stream, char progname[])
{
    fprintf(stream, "%s [-f] [-k] [temperature]\n"
        "Example: %s -f 25\n", progname, progname);
}
  1. 让我们编译这个程序。要包含静态库,我们只需将其添加到 GCC 的文件列表中。还要确保convert.h头文件在您当前的工作目录中:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> temperature.c libconvert.a -o temperature
  1. 现在我们可以用一些不同的温度测试程序:
$> ./temperature -f 30
30.0 C = 86.0 F
$> ./temperature -k 15
15.0 C = 288.1 F
  1. 最后,使用nm查看生成的temperature二进制文件:
c_to_f, c_to_k, printUsage, and main (the Ts). We also see which functions from dynamic libraries the program is depending on—for example, printf (preceded by a U). What we see here are called *symbols*. 
  1. 由于该二进制文件将用作独立程序,我们不需要符号。可以使用strip命令从二进制文件中strip符号。这会使程序的大小变小一点。一旦我们从二进制文件中删除了符号,让我们再次用nm查看它:
$> strip temperature
$> nm temperature
nm: temperature: no symbols
  1. 我们可以用file命令查看程序或库是否被剥离。请记住,静态库不能被剥离;否则,链接器将无法看到函数,链接将失败:
$> file temperature
temperature: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter/lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=95f583af98ff899c657ac33d6a014493c44c362b, stripped
$> file convert.o
convert.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

它是如何工作的…

当我们想在程序中使用静态库时,我们将存档文件的文件名和程序的c文件提供给 GCC,从而生成一个包含静态库的二进制文件。

在最后几个步骤中,我们使用nm检查了二进制文件,显示了所有符号。然后我们使用strip命令剥离 - 移除 - 这些符号。如果我们使用file命令查看lsmoresleep等程序,我们会注意到这些程序也被剥离。这意味着程序已经删除了其符号。

静态库必须保持其符号不变。如果它们被移除 - 剥离 - 链接器将找不到函数,链接过程将失败。因此,我们永远不应该剥离我们的静态库。

创建一个动态库

虽然静态库方便且易于创建和使用,动态库更常见。正如我们在本章开头看到的那样,许多开发人员选择提供库而不仅仅是程序 - 例如,cURL。

在这个配方中,我们将重新制作本章前面介绍的“创建静态库”配方中的库,使其成为一个动态库。

了解如何创建动态库使您能够将代码分发为其他开发人员易于实现的库。

准备工作

对于这个配方,您将需要本章前面的“创建静态库”中的两个convert.cconvert.h文件。您还需要 GCC 编译器。

如何做…

在这里,我们从本章前面的“创建静态库”中的convert.c创建一个动态库:

  1. 首先,让我们删除之前创建的对象文件和旧的静态库。这样可以确保我们不会错误地使用错误的对象文件或错误的库:
$> rm convert.o libconvert.a
  1. 我们需要做的第一件事是从c文件创建一个新的对象文件。-c选项创建一个对象文件,而不是最终的二进制文件。-fPIC选项告诉 GCC 生成所谓的file
$> gcc -Wall -Wextra -pedantic -std=c99 -c -fPIC \
> convert.c
$> file convert.o 
convert.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
  1. 下一步是创建一个.so文件,-shared选项做了它说的 - 它创建了一个共享对象。-Wl选项意味着我们想要将所有逗号分隔的选项传递给链接器。在这种情况下,传递给链接器的选项是-soname,参数是libconvert.so,它将动态库的名称设置为libconvert.so。最后,-o选项指定了输出文件的名称。然后,我们使用nm列出了这个共享库提供的符号。由T前缀的符号是这个库提供的符号:
$> gcc -shared -Wl,-soname,libconvert.so -o \
> libconvert.so.1 convert.o
$> nm -D libconvert.so.1
00000000000010f5 T c_to_f
000000000000112c T c_to_k
                 w __cxa_finalize
                 w __gmon_start__
                 w _ITM_deregisterTMCloneTable
                 w _ITM_registerTMCloneTable

工作原理…

创建动态库涉及两个步骤:创建一个位置无关的对象文件,并将该文件打包成一个.so文件。

共享库中的代码在运行时加载。由于它无法预测自己将在内存中的何处结束,因此需要是位置无关的。这样,代码将在内存中的任何位置正确工作。

-Wl,-soname,libconvert.so GCC 选项可能需要进一步解释。-Wl选项告诉 GCC 将逗号分隔的单词视为链接器的选项。由于我们不能使用空格 - 那将被视为一个新的选项 - 我们用逗号代替-sonamelibconvert.so。然而,链接器将其视为-soname libconvert.so

soname共享对象名称的缩写,它是库中的内部名称。在引用库时使用这个名称。

使用-o选项指定的实际文件名有时被称为库的真实名称。使用包含库版本号的真实名称是一个标准约定,例如在这个例子中使用1。也可以包括一个次要版本 - 例如,1.3。在我们的例子中,它看起来像这样:libconvert.so.1.3真实名称soname都必须以lib开头,缩写为。总的来说,这给我们提供了真实名称的五个部分:

  • lib(库的缩写)

  • convert(库的名称)

  • .so(扩展名,缩写为共享对象

  • .1(库的主要版本)

  • .3(库的次要版本,可选)

还有更多…

与静态库相反,动态库可以被剥离并且仍然可以工作。但是请注意,剥离必须在创建.so文件的动态库之后进行。如果我们剥离对象(.o)文件,那么我们将丢失所有符号,使其无法链接。但是.so文件将符号保留在一个称为.dynsym的特殊表中,strip命令不会触及。可以使用readelf命令的--symbols选项在剥离的动态库上查看此表。因此,如果nm命令在动态库上回复no symbols,可以尝试使用readelf --symbols

另请参阅

GCC是一个庞大的软件,有很多选项。GNU 的网站上提供了每个 GCC 版本的 PDF 手册。这些手册大约有 1000 页,可以从 https://gcc.gnu.org/onlinedocs/下载。

在系统上安装动态库

我们现在已经看到如何创建静态库和动态库,在第三章**,深入 Linux 中的 C 编程中,我们甚至看到了如何从我们的主目录中使用动态库。但现在,是时候将动态库系统范围内安装,以便计算机上的任何用户都可以使用它了。

知道如何在系统上安装动态库将使您能够为任何用户添加系统范围的库。

准备工作

对于这个步骤,您将需要在上一个步骤中创建的libconvert.so.1动态库。您还需要 root 访问系统,可以通过sudosu来获取。

如何做...

安装动态库只是将库文件和头文件移动到正确的目录并运行命令的问题。但是,我们应该遵循一些约定:

  1. 我们需要做的第一件事是将库文件复制到系统的正确位置。用户安装的库的常见目录是/usr/local/lib,我们将在这里使用。由于我们将文件复制到家目录之外的地方,我们需要以 root 用户的身份执行该命令。我们将在这里使用install来设置用户、组和模式,因为它是系统范围的安装,我们希望它由 root 拥有。它还应该是可执行的,因为它将在运行时被包含和执行:
$> sudo install -o root -g root -m 755 \
> libconvert.so.1 /usr/local/lib/libconvert.so.1
  1. 现在,我们必须运行ldconfig命令,它将创建必要的链接并更新缓存。
$> sudo ldconfig
$> cd /usr/local/lib/
$> ls -og libconvert*
lrwxrwxrwx 1 15 dec 27 19:12 libconvert.so ->
libconvert.so.1
-rwxr-xr-x 1 15864 dec 27 18:16 libconvert.so.1
  1. 我们还必须将头文件复制到系统目录;否则,用户将不得不手动下载并跟踪头文件,这不太理想。用户安装的头文件的一个好地方是/usr/local/include。单词include来自 C 语言的#include行:
$> sudo install -o root -g root -m 644 convert.h \
> /usr/local/include/convert.h
  1. 由于我们已经在整个系统中安装了库和头文件,我们可以继续从当前工作目录中删除它们。这样做将确保我们在下一个步骤中使用正确的文件:
$> rm libconvert.so.1 convert.h

它是如何工作的...

我们使用install程序安装了库文件和头文件。这个程序非常适合这样的任务,因为它可以在单个命令中设置用户(-o选项)、组(-g选项)和模式(-m选项)。如果我们使用cp来复制文件,它将由创建它的用户拥有。我们总是希望系统范围内的二进制文件、库和头文件由 root 用户拥有,以确保安全。

/usr/local目录是用户创建的东西的一个好地方。我们将库放在/usr/local/lib下,将头文件放在/usr/local/include下。系统库和头文件通常放在/usr/lib/usr/include中。

当我们稍后使用库时,系统将在以.so结尾的文件中查找它,因此我们需要一个指向库的符号链接,名称为libconvert.so。但我们不需要自己创建该链接;ldconfig已经为我们处理了。

另外,由于我们已经将头文件放在/usr/local/include中,我们不再需要在当前工作目录中拥有该文件。现在我们可以像包含任何其他系统头文件一样使用相同的语法。我们将在下一个示例中看到这一点。

在程序中使用动态库

现在我们已经创建了一个动态库并将其安装在系统上,现在是时候在程序中尝试它了。实际上,自从本书的开头以来,我们一直在使用动态库而不自知。诸如printf()等函数都是标准库的一部分。在本章前面的库的作用和原因示例中,我们使用了另一个名为 cURL 的动态库。在这个示例中,我们将使用我们在上一个示例中安装的自己的库。

了解如何使用自定义库将使您能够使用其他开发人员的代码,这将加快开发过程。通常没有必要重新发明轮子。

准备工作

对于这个示例,我们将需要本章前面的使用静态库示例中的temperature.c代码。该程序将使用动态库。在尝试此示例之前,您还需要完成上一个示例。

如何做...

在这个示例中,我们将使用temperature.c代码来利用我们在上一个示例中安装的库:

  1. 由于我们将使用/usr/local/include,我们必须修改temperature.c中的#include行。temperature.c中的第 4 行当前显示为:
#include "convert.h"

将前面的代码更改为:

#include <convert.h>

然后,将其保存为temperature-v2.c

  1. 现在我们可以继续编译程序了。GCC 将使用系统范围的头文件和库文件。请记住,我们需要使用-l选项链接到库。这样做时,我们必须省略lib部分和.so结尾:
$> gcc -Wall -Wextra -pedantic -std=c99 \
> -lconvert temperature-v2.c -o temperature-v2
  1. 然后,让我们尝试一些不同的温度:
$> ./temperature-v2 -f 34
34.0 C = 93.2 F
$> ./temperature-v2 -k 21
21.0 C = 294.1 F
  1. 我们可以使用ldd验证动态链接的库。当我们在我们的程序上运行此工具时,我们会看到我们的libconvert.so库,libc和称为vdso虚拟动态共享对象)的东西:
$> ldd temperature-v2
        linux-vdso.so.1 (0x00007fff4376c000)
        libconvert.so => /usr/local/lib/libconvert.so (0x00007faaeefe2000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007faaeee21000)
        /lib64/ld-linux-x86-64.so.2 (0x00007faaef029000)

它是如何工作的...

当我们从当前目录包含本地头文件时,语法是#include "file.h"。但对于系统范围的头文件,语法是#include <file.h>

由于库现在安装在系统目录之一中,我们不需要指定路径。仅需使用-lconvert链接到库即可。这样做时,所有常见的系统范围目录都会搜索该库。当我们使用-l进行链接时,我们省略了文件名的lib部分和.so结尾——链接器会自行解决这个问题。

在最后一步中,我们使用ldd验证了我们正在使用libconvert.so的系统范围安装。在这里,我们还看到了标准 C 库libc和称为vdso的东西。标准 C 库具有我们一次又一次使用的所有常用函数,例如printf()。然而,vdso库有点更加神秘,这不是我们要在这里讨论的内容。简而言之,它将一小部分经常使用的系统调用导出到用户空间,以避免过多的上下文切换,这将影响性能。

还有更多...

在本章中,我们已经谈论了很多关于ld的内容。为了更深入地了解链接器,我建议您阅读其手册页,使用man 1 ld

另请参阅

有关ldd的更多信息,请参阅man 1 ldd

对于好奇的人,可以在man 7 vdso中找到有关vdso的详细解释。

编译静态链接程序

现在我们对库和链接有了如此深刻的理解,我们可以创建一个静态链接程序——也就是说,一个将所有依赖项编译到其中的程序。这使得程序基本上不依赖于其他库。制作静态链接程序并不常见,但有时可能是可取的——例如,如果由于某种原因需要将单个预编译的二进制文件分发到许多计算机而不必担心安装所有的库。但请注意:并不总是可能创建完全不依赖于其他程序的程序。如果一个程序使用了依赖于另一个库的库,这就不容易实现。

制作和使用静态链接程序的缺点是它们的大小变得更大。此外,不再能够更新程序的库而不重新编译整个程序。因此,请记住这只在极少数情况下使用。

但是,通过了解如何编译静态链接程序,你不仅可以增强你的知识,还可以将预编译的二进制文件分发到没有必要的库的系统上,而且可以在许多不同的发行版上实现。

准备工作

对于这个示例,你需要完成前两个示例——换句话说,你需要在系统上安装libconvert.so.1库,并且需要编译temperature-v2.c。像往常一样,你还需要 GCC 编译器。

如何做…

在这个示例中,我们将编译temperature-v2.c的静态链接版本。然后,我们将从系统中删除库,并注意到静态链接的程序仍然可以工作,而另一个则不能:

重要提示

在 Fedora 和 CentOS 上,默认情况下不包括libc的静态库。要安装它,运行sudo dnf install glibc-static

  1. 为了静态链接到库,我们需要所有库的静态版本。这意味着我们必须重新创建库的存档(.a)版本,并将其安装。这些步骤与本章前面的创建静态库示例中的步骤相同。首先,如果我们仍然有对象文件,我们将删除它。然后,我们创建一个新的对象文件,并从中创建一个存档:
$> rm convert.o
$> gcc -Wall -Wextra -pedantic -std=c99 -c convert.c
$> ar -cvr libconvert.a convert.o 
a - convert.o
  1. 接下来,我们必须在系统上安装静态库,最好与动态库放在同一个位置。静态库不需要可执行文件,因为它是在编译时包含的,而不是在运行时包含的:
$> sudo install -o root -g root -m 644 \
> libconvert.a /usr/local/lib/libconvert.a
  1. 现在,编译temperature-v2.c的静态链接版本。-static选项使二进制文件静态链接,这意味着它将在二进制文件中包含库代码:
$> gcc -Wall -Wextra -pedantic -std=c99 -static \
> temperature-v2.c -lconvert -o temperature-static
  1. 在我们尝试这个程序之前,让我们用ldd来检查它,并用du来查看它的大小。请注意,在我的系统上,二进制文件现在几乎有 800 千字节(在另一个系统上,它有 1.6 兆字节)。与动态版本相比,动态版本只有大约 20 千字节:
$> du -sh temperature-static 
788K    temperature-static
$> du -sh temperature-v2
20K     temperature-v2
$> ldd temperature-static 
        not a dynamic executable
  1. 现在,让我们尝试这个程序:
$> ./temperature-static -f 20
20.0 C = 68.0 F
  1. 让我们从系统中删除静态和动态库:
$> sudo rm /usr/local/lib/libconvert.a \
> /usr/local/lib/libconvert.so \ 
> /usr/local/lib/libconvert.so.1
  1. 现在,让我们尝试动态链接的二进制文件,由于我们已经删除了它所依赖的库,所以它不应该工作:
$> ./temperature-v2 -f 25
./temperature-v2: error while loading shared
libraries: libconvert.so: cannot open shared object
file: No such file or directory
  1. 最后,让我们尝试静态链接的二进制文件,它应该和以前一样正常工作:
$> ./temperature-static -f 25
25.0 C = 77.0 F

工作原理…

静态链接的程序包括所有库的所有代码,这就是为什么在这个示例中我们的二进制文件变得如此庞大。要构建一个静态链接的程序,我们需要程序所有库的静态版本。这就是为什么我们需要重新创建静态库并将其放在系统目录中的原因。我们还需要标准 C 库的静态版本,如果我们使用的是 CentOS 或 Fedora 机器,我们会安装它。在 Debian/Ubuntu 上,它已经安装好了。

第九章:终端 I/O 和更改终端行为

在本章中,我们将学习TTYTeleTYpewriter的缩写)和PTYPseudo-TeletYpewriter的缩写)是什么,以及如何获取有关它们的信息。我们还将学习如何设置它们的属性。然后,我们编写一个接收输入但不回显文本的小程序——非常适合密码提示。我们还编写一个检查当前终端大小的程序。

终端可以采用多种形式——例如,在 X 中的终端窗口(图形前端);通过Ctrl + Alt + F1F7访问的七个终端;旧的串行终端;拨号终端;或者远程终端,比如Secure ShellSSH)。

TTY是硬件终端,比如通过Ctrl + Alt + F1F7访问的控制台,或者串行控制台。

一个xtermrxvttmux。也可以是远程终端,比如 SSH。

由于我们在日常生活中都使用 Linux 终端,了解如何获取有关它们的信息并控制它们可以帮助我们编写更好的软件。一个例子是在密码提示中隐藏密码。

在本章中,我们将涵盖以下内容:

  • 查看终端信息

  • 使用stty更改终端设置

  • 调查 TTY 和 PTY 并向它们写入

  • 检查它是否是 TTY

  • 创建一个 PTY

  • 禁用密码提示的回显

  • 读取终端大小

技术要求

在本章中,我们将需要所有常用的工具,比如screen。如果您还没有安装,可以使用您发行版的软件包管理器进行安装——例如,对于 Debian/Ubuntu,可以使用sudo apt-get install screen,对于 CentOS/Fedora,可以使用sudo dnf install screen

本章的所有代码示例都可以从github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch9下载。

查看以下链接以查看实际操作视频:bit.ly/2O8j7Lu

查看终端信息

在这个配方中,我们将学习更多关于 TTY 和 PTY 是什么,以及如何读取它们的属性和信息。这将有助于我们在本章中继续了解 TTY。在这里,我们将学习如何找出我们正在使用的 TTY 或 PTY,它在文件系统中的位置,以及如何读取它的属性。

准备工作

这个配方没有特殊要求。我们只会使用已经安装的标准程序。

如何做…

在这个配方中,我们将探讨如何找到自己的 TTY,它具有什么属性,它的对应文件在哪里,以及它是什么类型的 TTY:

  1. 首先在终端中输入tty。这将告诉您在系统上使用的 TTY。在单个系统上可以有许多 TTY 和 PTY。它们每个都由系统上的一个文件表示:
$> tty
/dev/pts/24
  1. 现在,让我们检查一下那个文件。正如我们在这里看到的,这是一种特殊的文件类型,称为字符特殊
$> ls -l /dev/pts/24
crw--w---- 1 jake tty 136, 24 jan  3 23:19 /dev/pts/24
$> file /dev/pts/24 
/dev/pts/24: character special (136/24)
  1. 现在,让我们使用一个名为stty的程序来检查终端的属性。-a选项告诉stty显示所有属性。我们得到的信息,例如终端的大小(行数和列数);它的速度(只在串行终端、拨号等上重要);用于-parenbCtrl键组合。所有没有减号的值,比如cs8,都是启用的:
$> stty -a
speed 38400 baud; rows 14; columns 88; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?;
swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W; lnext = ^V;
discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 hupcl -cstopb cread -clocal -crtscts
-ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc
ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl
echoke -flusho -extproc
  1. 还可以查看另一个终端的属性,假设您拥有它,这意味着已登录用户必须是您。如果我们尝试查看另一个用户的终端,将会收到权限被拒绝的错误:
$> stty -F /dev/pts/33 
speed 38400 baud; line = 0;
lnext = <undef>; discard = <undef>; min = 1; time = 0; -brkint -icrnl ixoff -imaxbel iutf8
-icanon -echo
$> stty -F /dev/tty2
stty: /dev/tty2: Permission denied

工作原理…

单个 Linux 系统可以有数百或数千个已登录用户。每个用户都通过 TTY 或 PTY 连接。在过去,这通常是硬件终端(TTY)通过串行线连接到计算机。如今,硬件终端相当罕见;相反,我们通过SSH登录或使用终端程序。

在我们的例子中,当前用户登录在/dev/pts/24上;那是pts,而不是pty。PTY 有两个部分,一个主部分和一个从属部分。PTS代表伪终端从属,我们连接的就是这部分。主部分打开/创建伪终端,但我们使用的是从属部分。我们将在本章稍后深入探讨这个概念。

步骤 3中我们使用的设置(-parenbcs8)意味着parenb被禁用,因为它有一个减号,而cs8被启用。parenb选项将生成一个奇偶校验位,并期望在输入中返回一个。奇偶校验位在拨号连接和串行通信中被广泛使用。cs8选项将字符大小设置为 8 位。

stty程序可以用来查看和设置终端的属性。在下一个食谱中,我们将返回到stty来更改一些值。

只要我们是终端设备的所有者,我们就可以读写它,就像我们在食谱的最后一步中看到的那样。

另请参阅

man 1 ttyman 1 stty中有很多有用的信息。

使用 stty 更改终端设置

在这个食谱中,我们将学习如何更改终端的设置(或属性)。在上一个食谱中,我们用stty -a列出了我们当前的设置。在这个食谱中,我们将改变其中一些设置,使用相同的stty程序。

了解如何更改终端设置将使您能够根据自己的喜好进行调整。

准备好

这个食谱没有特殊要求。

如何做…

在这里,我们将更改当前终端的一些设置:

  1. 让我们首先关闭whoami,并得到一个答案。请注意,当您输入时,您看不到whoami命令:
$> stty -echo
$> *whoami* jake 
$> 
  1. 要再次打开回显,我们再次输入相同的命令,但不带减号。请注意,当您输入时,您看不到stty命令:
$> *stty echo*
$> whoami
jake
  1. 我们还可以更改特殊的键序列——例如,通常情况下,EOF 字符是Ctrl + D。如果需要,我们可以将其重新绑定为一个单点(.):
$> stty eof .
  1. 现在输入一个单点(.),您当前的终端将退出或注销。当您启动一个新终端或重新登录时,设置将恢复正常。

  2. 为了保存设置以便以后重用,我们首先进行必要的更改——例如,将 EOF 设置为一个点。然后,我们使用stty --save。该选项将打印一长串十六进制数字——这些数字就是设置。因此,为了保存它们,我们可以将stty --save的输出重定向到一个文件中:

$> stty eof .
$> stty --save
5500:5:bf:8a3b:3:1c:7f:15:2e:0:1:0:11:13:1a:0:12:f:17:16:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0:0
$> stty --save > my-tty-settings
  1. 现在,按下一个点来注销。

  2. 重新登录(或重新打开终端窗口)。尝试输入一个点,什么也不会发生。为了重新加载我们的设置,我们使用上一步的my-tty-settings文件。$()序列展开括号内的命令,然后用作stty的参数:

$> stty $(cat my-tty-settings)
  1. 现在,我们可以再次尝试按下一个点来注销。

它是如何工作的…

终端通常是一个“愚蠢”的设备,因此需要大量的设置才能使其正常工作。这也是旧硬件电传打字机的遗留物之一。stty程序用于在终端设备上设置属性。

带有减号的选项被否定,即被禁用。没有减号的选项是启用的。在我们的例子中,我们首先关闭了回显,这是密码提示的常见做法,等等。

没有真正的方法可以保存 TTY 的设置,除了我们在这里看到的通过将其保存到文件并稍后重新读取它。

调查 TTY 和 PTY 并向它们写入

在这个食谱中,我们将学习如何列出当前登录的用户,他们使用的 TTY 以及他们正在运行的程序。我们还将学习如何向这些用户和终端写入。正如我们将在这个食谱中看到的,我们可以像写入文件一样向终端设备写入,假设我们有正确的权限。

知道如何写入其他终端会加深对终端工作原理和终端的理解。它还使您能够编写一些有趣的软件,并且最重要的是,它将使您成为一个更好的系统管理员。它还教会您有关终端安全的知识。

如何做…

我们将首先调查已登录用户;然后,我们将学习如何向他们发送消息:

  1. 为了使事情变得更有趣,打开三到四个终端窗口。如果您没有使用X-Window System,请在多个 TTY 上登录。或者,如果您正在使用远程服务器,请多次登录。

  2. 现在,在其中一个终端中键入who命令。您将获得所有已登录用户的列表,他们正在使用的 TTY/PTY,以及他们登录的日期和时间。在我的例子中,我通过 SSH 登录了多次。如果您正在使用具有多个xterm应用程序的本地计算机,则将看到(:0)而不是Internet ProtocolIP)地址:

$> who
root     tty1         Jan  5 16:03
jake     pts/0        Jan  5 16:04 (192.168.0.34)
jake     pts/1        Jan  5 16:04 (192.168.0.34)
jake     pts/2        Jan  5 16:04 (192.168.0.34)
  1. 还有一个类似的命令w,甚至显示每个终端上的用户当前正在使用的程序:
$> w
 16:09:33 up 7 min,  4 users,  load average: 0.00, 0.16, 0.13
USER  TTY    FROM          LOGIN@  IDLE  JCPU   PCPU WHAT
root  tty1   -             16:03   6:05  0.07s  0.07s -bash
jake  pts/0  192.168.0.34  16:04   5:25  0.01s  0.01s -bash
jake  pts/1  192.168.0.34  16:04   0.00s 0.04s  0.01s w
jake  pts/2  192.168.0.34  16:04   5:02  0.02s  0.02s -bash
  1. 让我们找出我们正在使用哪个终端:
$> tty
/dev/pts/1
  1. 现在我们知道我们正在使用哪个终端,让我们向另一个用户和终端发送消息。在本书的开头,我提到一切都只是一个文件或一个进程。即使对于终端也是如此。这意味着我们可以使用常规重定向向终端发送数据:
$> echo "Hello" > /dev/pts/2

文本Hello现在将出现在 PTS2 终端上。

  1. 仅当发送消息的用户与另一个终端上已登录的用户相同时,使用echo向终端发送消息才有效。例如,如果我尝试向 root 已登录的 TTY1 发送消息,它不起作用——有一个很好的原因:
$> echo "Hello" > /dev/tty1
-bash: /dev/tty1: Permission denied
  1. 然而,存在一个允许用户向彼此终端写入的程序,假设他们已经允许。该程序称为write。要允许或禁止消息,我们使用mesg程序。如果您可以在终端上以 root(或其他用户)登录,请这样做,然后允许消息(字母y代表yes):
#> tty
/dev/tty1
#> whoami
root
#> mesg y
  1. 现在,从另一个用户,我们可以向该用户和终端写入:
$> write root /dev/tty1
Hello! How are you doing?
*Ctrl*+*D*

该消息现在将出现在 TTY1 上,其中 root 已登录。

  1. 还有另一个命令允许用户在所有终端上写入。但是,root 是唯一可以向关闭消息的用户发送消息的用户。当以 root 身份登录时,请发出以下命令,向所有已登录用户写入有关即将重新启动的消息:
#> wall "The machine will be rebooted later tonight"

这将在所有用户的终端上显示一个消息,如下所示:

Broadcast message from root (tty1) (Tue Jan  5 16:59:33)
The machine will be rebooted later tonight

工作原理…

由于所有终端都由文件表示在文件系统上,因此向它们发送消息很容易。然而,常规权限也适用,以防止用户向其他用户写入或窥视其终端。

使用write程序,用户可以快速地向彼此写入消息,而无需任何第三方软件。

还有更多…

wall程序用于警告用户即将重新启动或关闭计算机。例如,如果 root 发出shutdown -h +5命令以安排在 5 分钟内关闭计算机,所有用户都将收到警告。使用wall程序会自动发送该警告。

另请参阅

有关本配方中涵盖的命令的更多信息,请参阅以下手册页面:

  • man 1 write

  • man 1 wall

  • man 1 mesg

检查它是否是 TTY

在这个配方中,我们将开始查看一些 C 函数来检查 TTY。在这里,我们指的是 TTY 的广义,即 TTY 和 PTY。

我们将在这里编写的程序将检查 stdout 是否是终端。如果不是,它将打印错误消息。

知道如何检查 stdin、stdout 或 stderr 是否是终端设备将使您能够为需要终端才能工作的程序编写错误检查。

准备工作

对于这个配方,我们需要 GCC 编译器,Make 工具和通用 Makefile。通用 Makefile 可以从本章的 GitHub 文件夹下载,网址为 https://github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch9。

如何做…

在这里,我们将编写一个小程序,如果 stdout 不是终端,则打印错误消息:

  1. 在文件中编写以下小程序并将其保存为ttyinfo.c。我们在这里使用了两个新函数。第一个是isatty(),它检查一个ttyname(),它打印连接到 stdout(或实际上是路径)的终端的名称:
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
int main(void)
{
    if ( (isatty(STDOUT_FILENO) == 1) )
    {
        printf("It's a TTY with the name %s\n",
            ttyname(STDOUT_FILENO));
    }
    else
    {
        perror("isatty");
    }
    printf("Hello world\n");
    return 0;
}
  1. 编译程序:
$> make ttyinfo
gcc -Wall -Wextra -pedantic -std=c99    ttyinfo.c   -o ttyinfo
  1. 让我们尝试一下这个程序。首先,我们不使用任何重定向来运行它。程序将打印终端的名称和文本Hello world
$> ./ttyinfo 
It's a TTY with the name /dev/pts/10
Hello world
  1. 但是,如果我们将文件描述符 1 重定向到文件,它就不再是终端(因为那个文件描述符指向文件而不是终端)。这将打印一个错误消息,但Hello world消息仍然被重定向到文件:
$> ./ttyinfo > my-file
isatty: Inappropriate ioctl for device
$> cat my-file 
Hello world
  1. 为了证明这一点,我们可以将文件描述符 1“重定向”到/dev/stdout。然后一切将像往常一样工作,因为文件描述符 1 再次成为 stdout:
$> ./ttyinfo > /dev/stdout
It's a TTY with the name /dev/pts/10
Hello world
  1. 另一个证明这一点的步骤是重定向到我们自己的终端设备。这将类似于我们在上一个配方中看到的,当我们使用echo将文本打印到终端时:
$> tty
/dev/pts/10
$> ./ttyinfo > /dev/pts/10 
It's a TTY with the name /dev/pts/10
Hello world
  1. 为了进行实验,让我们打开第二个终端。使用tty命令找到新终端的 TTY 名称(在我的情况下是/dev/pts/26)。然后,从第一个终端再次运行ttyinfo程序,但将文件描述符 1(stdout)重定向到第二个终端:
$> ./ttyinfo > /dev/pts/26

当前终端上不会显示任何输出。但是,在第二终端上,我们可以看到程序的输出,以及第二个终端的名称:

It's a TTY with the name /dev/pts/26
Hello world

工作原理...

我们使用STDOUT_FILENO宏,它与isatty()ttyname()一起使用,只是整数 1-也就是文件描述符 1。

请记住,当我们用>符号重定向 stdout 时,我们重定向文件描述符 1。

通常,文件描述符 1 是 stdout,它连接到您的终端。如果我们使用>字符将文件描述符 1 重定向到文件,它将指向该文件。由于常规文件不是终端,我们会从程序(从isatty()函数的errno变量)得到一个错误消息。

当我们将文件描述符 1 重新重定向回/dev/stdout时,它再次成为 stdout,不会打印错误消息。

在最后一步中,当我们将程序的输出重定向到另一个终端时,所有文本都被重定向到该终端。不仅如此-程序打印的 TTY 名称确实是第二个终端的。原因是连接到文件描述符 1 的终端设备确实是那个终端(在我的情况下是/dev/pts/26)。

另请参阅

有关我们在配方中使用的函数的更多信息,我建议您阅读man 3 isattyman 3 ttyname

创建一个 PTY

在这个配方中,我们将创建一个screen并开始输入,字符将被打印到主设备和从设备上。从设备是screen程序连接的地方,在这种情况下是我们的终端。主设备通常是静默的并在后台运行,但为了演示目的,我们也会在主设备上打印字符。

了解如何创建 PTY 使您能够编写自己的终端应用程序,如xterm,Gnome 终端,tmux等。

准备工作

对于这个配方,您将需要 GCC 编译器,Make 工具和screen程序。有关screen的安装说明,请参阅本章的技术要求部分。

如何做...

在这里,我们将编写一个创建 PTY 的小程序。然后我们将使用screen连接到这个 PTY 的从端口-PTS。然后我们可以输入字符,它们会被打印回 PTS 上:

  1. 我们将首先为这个配方编写程序。这里有很多新概念,所以代码被分成了几个步骤。将所有代码写在一个名为my-pty.c的单个文件中。我们将首先定义_XOPEN_SOURCE(用于posix_openpt()),并包括我们需要的所有头文件:
#define _XOPEN_SOURCE 600
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h> 
  1. 接下来,我们将开始main()函数并定义一些我们需要的变量:
int main(void)
{
   char rxbuf[1];
   char txbuf[3];
   int master; /* for the pts master fd */
   int c; /* to catch read's return value */ 
  1. 现在,是时候使用posix_openpt()创建 PTY 设备了。这将返回一个文件描述符,我们将保存在master中。然后,我们运行grantpt(),它将把设备的所有者设置为当前用户,组设置为tty,并将设备的模式更改为620。在使用之前,我们还必须使用unlockpt()进行解锁。为了知道我们应该连接到哪里,我们还使用ptsname()打印从属设备的路径:
   master = posix_openpt(O_RDWR);
   grantpt(master);
   unlockpt(master);
   printf("Slave: %s\n", ptsname(master));
  1. 接下来,我们创建程序的主循环。在循环中,我们从 PTS 中读取一个字符,然后再次将其写回 PTS。在这里,我们还将字符打印到主设备上,以便我们知道它是主/从设备对。由于终端设备相当原始,我们必须手动检查回车字符(Enter键),并且打印换行和回车以换行:
  while(1) /* main loop */
   {
      /* read from the master file descriptor */
      c = read(master, rxbuf, 1);
      if (c == 1)
      {
         /* convert carriage return to '\n\r' */
         if (rxbuf[0] == '\r')
         {
            printf("\n\r"); /* on master */
            sprintf(txbuf, "\n\r"); /* on slave */
         }
         else
         { 
            printf("%c", rxbuf[0]); 
            sprintf(txbuf, "%c", rxbuf[0]);
         }
         fflush(stdout);
         write(master, txbuf, strlen(txbuf));
      }
  1. 如果没有收到任何字符,则连接到从属设备的设备已断开。如果是这种情况,我们将返回,因此退出程序:
      else /* if c is not 1, it has disconnected */
      {
         printf("Disconnected\n\r");
         return 0;
      } 
   }
   return 0;
}
  1. 现在,是时候编译程序以便我们可以运行它了:
$> make my-pty
gcc -Wall -Wextra -pedantic -std=c99    my-pty.c   -o my-pty
  1. 现在,在当前终端中运行程序并记下从主设备获得的从属路径:
$> ./my-pty
Slave: /dev/pts/31
  1. 在继续连接之前,让我们检查一下设备。在这里,我们将看到我的用户拥有它,它确实是一个字符特殊设备,对终端来说很常见:
$> ls -l /dev/pts/31
crw--w---- 1 jake tty 136, 31 jan  3 20:32 /dev/pts/31
$> file /dev/pts/31
/dev/pts/31: character special (136/31)
  1. 现在,打开一个新的终端并连接到您从主设备获得的从属路径。在我的情况下,它是/dev/pts/31。要连接到它,我们将使用screen
$> screen /dev/pts/31
  1. 现在,我们可以随意输入,所有字符都将被打印回给我们。它们也将出现在主设备上。要断开并退出screen,首先按下Ctrl + A,然后输入一个单独的K,如 kill。然后会出现一个问题(真的要杀死这个窗口吗[y/n]);在这里输入Y。现在您将在启动my-pty的终端中看到已断开,程序将退出。

它是如何工作的...

我们使用posix_openpt()函数打开一个新的 PTY。我们使用O_RDWR设置为读和写。通过打开一个新的 PTY,在/dev/pts/中创建了一个新的字符设备。这就是我们后来使用screen连接的字符设备。

由于posix_openpt()返回一个文件描述符,我们可以使用所有常规的文件描述符系统调用来读取和写入数据,比如readwrite

终端设备,比如我们在这里创建的设备,相当原始。如果我们按下Enter,光标将返回到行的开头。首先不会创建新行。这实际上是Enter键以前的工作方式。为了解决这个问题,我们在程序中检查读取的字符是否是回车(Enter键发送的内容),如果是,我们将首先打印一个换行字符,然后是一个回车。

如果我们只打印换行符,我们只会得到一个新行,就在当前光标下面。这种行为是从旧式电传打字机设备留下的。在打印当前字符(或换行和回车)后,我们使用fflush()。原因是在主端打印的字符(my-pty程序运行的地方)后面没有新行。Stdout 是行缓冲的,这意味着它只在换行时刷新。但是由于我们希望在输入每个字符时都能看到它,我们必须在每个字符上刷新它,使用fflush()

另请参阅

手册页面中有很多有用的信息。我特别建议您阅读以下手册页面:man 3 posix_openptman 3 grantptman 3 unlockptman 4 ptsman 4 tty

禁用密码提示的回显

为了防止用户的密码被肩窥,最好隐藏他们输入的内容。隐藏密码不被显示的方法是禁用回显。在这个示例中,我们将编写一个简单的密码程序,其中禁用了回显。

在编写需要某种秘密输入的程序(如密码或密钥)时,了解如何禁用回显是关键。

准备工作

对于这个示例,你需要 GCC 编译器、Make 工具和通用的 Makefile。

如何做...

在这个示例中,我们将构建一个带有密码提示的小程序

  1. 由于本示例中的代码将会相当长,有些部分有点晦涩,我已经将代码分成了几个步骤。但请注意,所有的代码都应该放在一个文件中。将文件命名为passprompt.c。让我们从include行、main()函数和我们需要的变量开始。名为termtermios类型的结构是一个特殊的结构,它保存了终端的属性:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <termios.h>
int main(void)
{
    char mypass[] = "super-secret";
    char buffer[80];
    struct termios term;
  1. 接下来,我们将首先禁用回显,但首先需要使用tcgetattr()获取终端的所有当前设置。一旦我们获得了所有设置,我们就修改它们以禁用回显。我们这样做的方式是使用ECHO~符号否定一个值。稍后在它是如何工作...部分会详细介绍:
    /* get the current settings */
    tcgetattr(STDIN_FILENO, &term);
    /* disable echoing */
    term.c_lflag = term.c_lflag & ~ECHO;
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &term);
  1. 然后,我们编写密码提示的代码;这里没有什么新鲜的,我们已经知道了:
    printf("Enter password: ");
    scanf("%s", buffer);
    if ( (strcmp(mypass, buffer) == 0) )
    {
        printf("\nCorrect password, welcome!\n");
    }
    else
    {
        printf("\nIncorrect password, go away!\n");
    }    
  1. 然后,在退出程序之前,我们必须再次打开回显;否则,即使程序退出后,回显也将保持关闭。这样做的方法是ECHO。这将撤销我们之前所做的事情:
    /* re-enable echoing */
    term.c_lflag = term.c_lflag | ECHO;
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &term);
    return 0;
}
  1. 现在,让我们编译程序:
$> make passprompt
gcc -Wall -Wextra -pedantic -std=c99    passprompt.c   -o passprompt
  1. 现在,我们可以尝试这个程序,我们会注意到我们看不到自己输入的内容:
$> ./passprompt 
Enter password: *test*+*Enter*
Incorrect password, go away!
$> ./passprompt 
Enter password: *super-secret*+*Enter*
Correct password, welcome!

它是如何工作的...

使用tcsetattr()对终端进行更改的方法是使用tcgetattr()获取当前属性,然后修改它们,最后将这些更改后的属性应用到终端上。

tcgetattr()tcsetattr()的第一个参数都是我们要更改的文件描述符。在我们的情况下,是 stdin。

tcgetattr()的第二个参数是属性将被保存的结构。

tcsetattr()的第二个参数确定更改何时生效。在这里,我们使用TCSAFLUSH,这意味着更改发生在所有输出被写入后,所有接收但未读取的输入将被丢弃。

tcsetattr()的第三个参数是包含属性的结构。

为了保存和设置属性,我们需要一个名为termios的结构(与我们使用的头文件同名)。该结构包含五个成员,其中四个是模式。这些是输入模式(c_iflag)、输出模式(c_oflag)、控制模式(c_cflag)和本地模式(c_lflag)。我们在这里改变的是本地模式。

首先,我们在c_lflag成员中有当前的属性,它是一个无符号整数,由一堆位组成。这些位就是属性。

然后,要关闭一个设置,例如,在我们的情况下关闭回显,我们对ECHO宏进行否定("反转"它),然后使用按位与(&符号)将其添加回c_lflag

ECHO宏是010(八进制 10),或者十进制 8,二进制中是00001000(8 位)。取反后是11110111。然后对这些位与原始设置的其他位进行按位与操作。

按位与操作的结果然后应用到终端上,使用tcsetattr()关闭回显。

在结束程序之前,我们通过对新值进行按位或操作来逆转这个过程,然后使用tcsetattr()应用该值,再次打开回显。

还有更多...

我们可以通过这种方式设置很多属性,例如,可以禁用中断和退出信号的刷新等。man 3 tcsetattr()手册页中列出了每种模式使用的宏的完整列表。

读取终端大小

在这个示例中,我们将继续深入研究我们的终端。在这里,我们编写一个有趣的小程序,实时报告终端的大小。当你调整终端窗口的大小时(假设你正在使用 X 控制台应用程序),你会立即看到新的大小被报告。

为了使这个工作,我们将使用一个特殊的ioctl()函数。

了解如何使用这两个工具、转义序列和ioctl()将使您能够在终端上做一些有趣的事情。

准备工作

为了充分利用这个配方,最好使用xtermrxvtKonsoleGnome Terminal等。

您还需要 GCC 编译器,Make 工具和通用 Makefile。

如何做…

在这里,我们将编写一个程序,首先使用特殊的转义序列清除屏幕,然后获取终端的大小并打印到屏幕上:

  1. 在文件中写入以下代码并将其保存为terminal-size.c。程序使用一个无限循环,因此要退出程序,我们必须使用Ctrl + C。在循环的每次迭代中,我们首先通过打印特殊的转义序列来清除屏幕。然后,我们使用ioctl()获取终端大小并在屏幕上打印大小:
#include <stdio.h>
#include <unistd.h>
#include <termios.h>
#include <sys/ioctl.h>
int main(void)
{
   struct winsize termsize;
   while(1)
   {
      printf("\033[1;1H\033[2J");
      ioctl(STDOUT_FILENO, TIOCGWINSZ, &termsize);
      printf("Height: %d rows\n", 
         termsize.ws_row);
      printf("Width: %d columns\n", 
         termsize.ws_col);
      sleep(0.1);
   }
   return 0;
} 
  1. 编译程序:
$> make terminal-size
gcc -Wall -Wextra -pedantic -std=c99    terminal-size.c   -o terminal-size
  1. 现在,在终端窗口中运行程序。当程序正在运行时,调整窗口大小。您会注意到大小会立即更新。使用Ctrl + C退出程序:
$> ./terminal-size
Height: 20 rows
Width: 97 columns
*Ctrl*+*C*

它是如何工作的…

首先,我们定义一个名为termsize的结构,类型为winsize。我们将在这个结构中保存终端大小。该结构有两个成员(实际上有四个,但只使用了两个)。成员是ws_row表示行数和wc_col表示列数。

然后,为了清除屏幕,我们使用printf()打印一个特殊的转义序列,\033[1;1H\033[2J\033序列是转义码。在转义码之后,我们有一个[字符,然后我们有实际的代码告诉终端要做什么。第一个1;1H将光标移动到位置 1,1(第一行和第一列)。然后,我们再次使用\033转义码,以便我们可以使用另一个代码。首先,我们有[字符,就像以前一样。然后,我们有[2J代码,这意味着擦除整个显示。

一旦我们清除了屏幕并移动了光标,我们使用ioctl()来获取终端大小。第一个参数是文件描述符;在这里,我们使用 stdout。第二个参数是要发送的命令;在这里,它是TIOCGWINSZ以获取终端大小。这些宏/命令可以在man 2 ioctl_tty手册页中找到。第三个参数是winsize结构。

一旦我们在winsize结构中有了尺寸,我们就使用printf()打印值。

为了避免耗尽系统资源,我们在下一次迭代之前睡眠 0.1 秒。

还有更多…

man 4 console_codes手册页中,有许多其他代码可以使用。您可以做任何事情,从使用颜色到粗体字体,到移动光标,到响铃终端等等。

例如,要以闪烁的品红色打印Hello,然后重置为默认值,您可以使用以下命令:

printf("\033[35;5mHello!\033[0m\n");

但请注意,并非所有终端都能闪烁。

另请参阅

有关ioctl()的更多信息,请参阅man 2 ioctlman 2 ioctl_tty手册页。后者包含有关winsize结构和宏/命令的信息。

第十章:使用不同类型的 IPC

在本章中,我们将学习通过所谓的进程间通信IPC)的各种方式。我们将编写使用不同类型的 IPC 的各种程序,从信号和管道到 FIFO、消息队列、共享内存和套接字。

进程有时需要交换信息-例如,在同一台计算机上运行的客户端和服务器程序的情况下。也可能是一个分叉成两个进程的进程,它们需要以某种方式进行通信。

这种 IPC 可以以多种方式进行。在本章中,我们将学习一些最常见的方式。

如果您想编写不仅仅是最基本程序的程序,了解 IPC 是必不可少的。迟早,您将拥有由多个部分或多个程序组成的程序,需要共享信息。

在本章中,我们将介绍以下配方:

  • 使用信号进行 IPC-为守护程序构建客户端

  • 使用管道进行通信

  • FIFO-在 shell 中使用它

  • FIFO-构建发送方

  • FIFO-构建接收方

  • 消息队列-创建发送方

  • 消息队列-创建接收方

  • 使用共享内存在子进程和父进程之间进行通信

  • 在不相关的进程之间使用共享内存

  • Unix 套接字-创建服务器

  • Unix 套接字-创建客户端

让我们开始吧!

技术要求

对于本章,您将需要第三章中的 GCC 编译器,Make 工具和通用的 Makefile,深入 Linux 中的 C 语言。如果您尚未安装这些工具,请参阅第一章获取必要的工具并编写我们的第一个 Linux 程序,以获取安装说明。

本章的所有代码示例和通用的 Makefile 都可以从 GitHub 上下载,网址为github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch10

查看以下链接以查看代码演示视频:bit.ly/3u3y1C0

使用信号进行 IPC-为守护程序构建客户端

在本书中,我们已经多次使用了信号。但是,当我们这样做时,我们总是使用kill命令来发送my-daemon-v2,来自第六章生成进程和使用作业控制

这是使用信号进行IPC的典型示例。守护程序有一个小的“客户端程序”来控制它,以便可以停止它,重新启动它,重新加载其配置文件等。

知道如何使用信号进行 IPC 是编写可以相互通信的程序的坚实起点。

准备工作

对于这个配方,你需要 GCC 编译器,Make 工具和通用的 Makefile。您还需要第六章中的my-daemon-v2.c文件,生成进程和使用作业控制。在本章的 GitHub 目录中有该文件的副本,网址为github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch10

如何做…

在这个配方中,我们将向第六章中的守护程序添加一个小的客户端程序,生成进程和使用作业控制。这个程序将向守护程序发送信号,就像kill命令一样。但是,这个程序只会向守护程序发送信号,不会发送给其他进程:

  1. 在文件中编写以下代码并将其保存为my-daemon-ctl.c。这个程序有点长,所以它分成了几个步骤。不过所有的代码都放在同一个文件中。我们将从包含行、使用函数的原型和我们需要的所有变量开始:
#define _XOPEN_SOURCE 500
#include <stdio.h>
#include <sys/types.h>
#include <signal.h>
#include <getopt.h>
#include <string.h>
#include <linux/limits.h>
void printUsage(char progname[], FILE *fp);
int main(int argc, char *argv[])
{
   FILE *fp;
   FILE *procfp;
   int pid, opt;
   int killit = 0;
   char procpath[PATH_MAX] = { 0 };
   char cmdline[PATH_MAX] = { 0 };
   const char pidfile[] = "/var/run/my-daemon.pid";
   const char daemonPath[] = 
      "/usr/local/sbin/my-daemon-v2";
  1. 然后,我们希望能够解析命令行选项。我们只需要两个选项;即,-h用于帮助,-k用于杀死守护进程。默认情况下是显示守护进程的状态:
   /* Parse command-line options */
   while ((opt = getopt(argc, argv, "kh")) != -1)
   {
      switch (opt)
      {
         case 'k': /* kill the daemon */
            killit = 1;
            break;
         case 'h': /* help */
            printUsage(argv[0], stdout);
            return 0;
         default: /* in case of invalid options */
            printUsage(argv[0], stderr);
            return 1;
      }
   }
  1. 现在,让我们打开/proc中的cmdline文件。然后,我们必须打开该文件并从中读取完整的命令行路径:
   if ( (fp = fopen(pidfile, "r")) == NULL )
   {
      perror("Can't open PID-file (daemon isn't "
         "running?)");
   }
   /* read the pid (and check if we could read an 
    * integer) */
   if ( (fscanf(fp, "%d", &pid)) != 1 )
   {
      fprintf(stderr, "Can't read PID from %s\n", 
         pidfile);
      return 1;
   }
   /* build the /proc path */
   sprintf(procpath, "/proc/%d/cmdline", pid);
   /* open the /proc path */
   if ( (procfp = fopen(procpath, "r")) == NULL )
   {
      perror("Can't open /proc path"
         " (no /proc or wrong PID?)");
      return 1;
   }
   /* read the cmd line path from proc */
   fscanf(procfp, "%s", cmdline); 
  1. 既然我们既有 PID 又有完整的命令行,我们可以再次检查 PID 是否属于/usr/local/sbin/my-daemon-v2而不是其他进程:
   /* check that the PID matches the cmdline */
   if ( (strncmp(cmdline, daemonPath, PATH_MAX)) 
      != 0 )
   {
      fprintf(stderr, "PID %d doesn't belong "
         "to %s\n", pid, daemonPath);
      return 1;
   }
  1. 如果我们给程序加上-k选项,我们必须将killit变量设置为 1。因此,在这一点上,我们必须杀死进程。否则,我们只是打印一条消息,说明守护进程正在运行:
   if ( killit == 1 )
   {
      if ( (kill(pid, SIGTERM)) == 0 )
      {
         printf("Successfully terminated " 
            "my-daemon-v2\n");
      }
      else
      {
         perror("Couldn't terminate my-daemon-v2");
         return 1;
      }        
   }
   else
   {
      printf("The daemon is running with PID %d\n", 
         pid);
   }
   return 0;
}
  1. 最后,我们为printUsage()函数创建函数:
void printUsage(char progname[], FILE *fp)
{
   fprintf(fp, "Usage: %s [-k] [-h]\n", progname);
   fprintf(fp, "If no options are given, a status "
      "message is displayed.\n"
      "-k will terminate the daemon.\n"
      "-h will display this usage help.\n");       
}
  1. 现在,我们可以编译程序了:
$> make my-daemon-ctl
gcc -Wall -Wextra -pedantic -std=c99    my-daemon ctl.c   -o my-daemon-ctl
  1. 在继续之前,请确保你已经禁用并停止了第七章**,使用 systemd 管理守护进程中的systemd服务:
$> sudo systemctl disable my-daemon
$> sudo systemctl stop my-daemon
  1. 现在编译守护进程(my-daemon-v2.c),如果你还没有这样做的话:
$> make my-daemon-v2
gcc -Wall -Wextra -pedantic -std=c99    my-daemon-v2.c   -o my-daemon-v2
  1. 然后,手动启动守护进程(这次没有systemd服务):
$> sudo ./my-daemon-v2
  1. 现在,我们可以尝试使用我们的新程序来控制守护进程。请注意,我们不能像普通用户一样杀死守护进程:
$> ./my-daemon-ctl 
The daemon is running with PID 17802 and cmdline ./my-daemon-v2
$> ./my-daemon-ctl -k
Couldn't terminate daemon: Operation not permitted
$> sudo ./my-daemon-ctl -k
Successfully terminated daemon
  1. 如果守护进程被杀死后我们重新运行程序,它会告诉我们没有 PID 文件,因此守护进程没有运行:
$> ./my-daemon-ctl 
Can't open PID-file (daemon isn't running?): No such file or directory

工作原理…

由于守护进程创建了 PID 文件,我们可以使用该文件获取正在运行的守护进程的 PID。当守护进程终止时,它会删除 PID 文件,因此如果没有 PID 文件,我们可以假设守护进程没有运行。

如果 PID 文件存在,首先我们从文件中读取 PID。然后,我们使用 PID 来组装该 PID 的/proc文件系统中的cmdline文件的路径。Linux 系统上的每个进程都在/proc文件系统中有一个目录。在每个进程的目录中,有一个名为cmdline的文件。该文件包含进程的完整命令行。例如,如果守护进程是从当前目录启动的,它包含./my-daemon-v2,而如果它是从/usr/local/sbin/my-daemon-v2启动的,它包含完整路径。

例如,如果守护进程的 PID 是12345,那么cmdline的完整路径是/proc/12345/cmdline。这就是我们用sprintf()组装的内容。

然后,我们读取cmdline的内容。稍后,我们使用该文件的内容来验证 PID 是否与名称为my-daemon-v2的进程匹配。这是一项安全措施,以免误杀错误的进程。如果使用KILL信号杀死守护进程,它就没有机会删除 PID 文件。如果将来另一个进程获得相同的 PID,我们就有可能误杀该进程。PID 号最终会被重用。

当我们有了守护进程的 PID 并验证它确实属于正确的进程时,我们将根据-k选项指定的内容获取其状态或将其杀死。

这就是许多用于控制复杂守护进程的控制程序的工作方式。

另请参阅

有关kill()系统调用的更多信息,请参阅man 2 kill手册页。

使用管道进行通信

在这个示例中,我们将创建一个程序,进行分叉,然后使用管道在两个进程之间进行通信。有时,当我们分叉一个进程时,父进程子进程需要一种通信方式。管道通常是实现这一目的的简单方法。

当你编写更复杂的程序时,了解如何在父进程和子进程之间进行通信和交换数据是很重要的。

准备工作

对于这个示例,我们只需要 GCC 编译器、Make 工具和通用 Makefile。

如何做…

让我们编写一个简单的分叉程序:

  1. 将以下代码写入一个文件中,并将其命名为pipe-example.c。我们将逐步介绍代码。请记住,所有代码都在同一个文件中。

我们将从包含行和main()函数开始。然后,我们将创建一个大小为 2 的整数数组。管道将在以后使用该数组。数组中的第一个整数(0)是管道读端的文件描述符。第二个整数(1)是管道的写端:

#define _POSIX_C_SOURCE  200809L
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#define MAX 128
int main(void)
{
   int pipefd[2] = { 0 };
   pid_t pid;
   char line[MAX];
  1. 现在,我们将使用pipe()系统调用创建管道。我们将把整数数组作为参数传递给它。之后,我们将使用fork()系统调用进行分叉:
   if ( (pipe(pipefd)) == -1 )
   {
      perror("Can't create pipe");
      return 1;
   }   
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
  1. 如果我们在父进程中,我们关闭读端(因为我们只想从父进程中写入)。然后,我们使用dprintf()向管道的文件描述符(写端)写入消息:
   if (pid > 0)
   {
      /* inside the parent */
      close(pipefd[0]); /* close the read end */
      dprintf(pipefd[1], "Hello from parent");
   }
  1. 在子进程中,我们做相反的操作;也就是说,我们关闭管道的写端。然后,我们使用read()系统调用从管道中读取数据。最后,我们使用printf()打印消息:
   else
   {
      /* inside the child */
      close(pipefd[1]); /* close the write end */
      read(pipefd[0], line, MAX-1);
      printf("%s\n", line); /* print message from
                             * the parent */
   }
   return 0;
}
  1. 现在编译程序,以便我们可以运行它:
$> make pipe-example
gcc -Wall -Wextra -pedantic -std=c99    pipe-example.c   -o pipe-example
  1. 让我们运行程序。父进程使用管道向子进程发送消息Hello from parent。然后,子进程在屏幕上打印该消息:
$> ./pipe-example 
Hello from parent

工作原理…

pipe()系统调用将两个文件描述符返回给整数数组。第一个,pipefd[0],是管道的读端,而另一个,pipefd[1],是管道的写端。在父进程中,我们向管道的写端写入消息。然后,在子进程中,我们从管道的读端读取数据。但在进行任何读写操作之前,我们关闭在各自进程中没有使用的管道端。

管道是一种比较常见的 IPC 技术。但是它们有一个缺点,即它们只能在相关进程之间使用;也就是说,具有共同父进程(或父进程和子进程)的进程。

还有另一种形式的管道可以克服这个限制:所谓的命名管道。命名管道的另一个名称是 FIFO。这是我们将在下一个示例中介绍的内容。

另请参阅

有关pipe()系统调用的更多信息可以在man 2 pipe手册页中找到。

FIFO - 在 shell 中使用它

在上一个示例中,我提到pipe()系统调用有一个缺点——它只能在相关进程之间使用。但是,我们可以使用另一种类型的管道,称为命名管道。另一个名称是先进先出FIFO)。命名管道可以在任何进程之间使用,无论是否相关。

命名管道,或者 FIFO,实际上是一种特殊类型的文件。mkfifo()函数在文件系统上创建该文件,就像创建任何其他文件一样。然后,我们可以使用该文件在进程之间读取和写入数据。

还有一个名为mkfifo的命令,我们可以直接从 shell 中使用它来创建命名管道。我们可以使用它在不相关的命令之间传输数据。

在这个命名管道的介绍中,我们将介绍mkfifo命令。在接下来的两个示例中,我们将编写一个使用mkfifo()函数的 C 程序,然后再编写另一个程序来读取管道的数据。

了解如何使用命名管道将为您作为用户、系统管理员和开发人员提供更多的灵活性。您不再只能在相关进程之间使用管道。您可以自由地在系统上的任何进程或命令之间传输数据,甚至可以在不同的用户之间传输数据。

准备工作

在这个示例中,我们不会编写任何程序,因此没有特殊要求。

操作步骤…

在这个示例中,我们将探讨mkfifo命令,并学习如何使用它在不相关的进程之间传输数据:

  1. 我们将首先创建一个命名管道——一个 FIFO 文件。我们将在/tmp目录中创建它,这是临时文件的常见位置。但是,您可以在任何您喜欢的地方创建它:
$> mkfifo /tmp/my-fifo
  1. 让我们通过使用filels命令来确认这确实是一个 FIFO。请注意我的 FIFO 的当前权限模式。它可以被所有人读取。但是在您的umask取决于您的系统,这可能会有所不同。但是,如果我们要传输敏感数据,我们应该对此保持警惕。在这种情况下,我们可以使用chmod命令进行更改:
$> file /tmp/my-fifo 
/tmp/my-fifo: fifo (named pipe)
$> ls -l /tmp/my-fifo 
prw-r--r-- 1 jake jake 0 jan 10 20:03 /tmp/my-fifo
  1. 现在,我们可以尝试向管道发送数据。由于管道是一个文件,我们将在这里使用重定向而不是管道符号。换句话说,我们将数据重定向到管道。在这里,我们将uptime命令的输出重定向到管道。一旦我们将数据重定向到管道,进程将挂起,这是正常的,因为没有人在另一端接收数据。它实际上并不挂起;它阻塞
$> uptime -p > /tmp/my-fifo
  1. 打开一个新的终端并输入以下命令以从管道接收数据。请注意,第一个终端中的进程现在将结束:
$> cat < /tmp/my-fifo 
up 5 weeks, 6 days, 2 hours, 11 minutes
  1. 我们也可以做相反的事情;也就是说,我们可以首先打开接收端,然后向管道发送数据。这将阻塞接收进程,直到获得一些数据。运行以下命令设置接收端,并让其运行:
$> cat < /tmp/my-fifo
  1. 现在,我们使用相同的uptime命令向管道发送数据。请注意,一旦数据被接收,第一个进程将结束:
$> uptime -p > /tmp/my-fifo
  1. 还可以从多个进程向 FIFO 发送数据。打开三个新的终端。在每个终端中,输入以下命令,但将第二个终端替换为 2,第三个终端替换为 3:
$> echo "Hello from terminal 1" > /tmp/my-fifo
  1. 现在,打开另一个终端并输入以下命令。这将接收所有消息:
$> cat < /tmp/my-fifo
Hello from terminal 3
Hello from terminal 1
Hello from terminal 2

它是如何工作的…

FIFO 只是文件系统上的一个文件,尽管是一个特殊的文件。一旦我们将数据重定向到 FIFO,该进程将阻塞(或“挂起”),直到另一端接收到数据。

同样,如果我们首先启动接收进程,该进程将阻塞,直到获取管道的数据。这种行为的原因是 FIFO 不是我们可以保存数据的常规文件。我们只能用它重定向数据;也就是说,它只是一个管道。因此,如果我们向其发送数据,但另一端没有任何东西,进程将在那里等待,直到有人在另一端接收它。数据在管道中无处可去,直到有人连接到接收端。

还有更多...

如果系统上有多个用户,您可以尝试使用 FIFO 向它们发送消息。这样做为我们提供了一种在用户之间复制和粘贴数据的简单方法。请注意,FIFO 的权限模式必须允许其他用户读取它(如果需要,还可以写入它)。可以在创建 FIFO 时直接设置所需的权限模式,使用-m选项。例如,mkfifo /tmp/shared-fifo -m 666将允许任何用户读取和写入 FIFO。

另请参阅

man 1 mkfifo手册页中有关于mkfifo命令的更多信息。有关 FIFO 的更深入解释,请参阅man 7 fifo手册页。

FIFO - 构建发送方

现在我们知道了 FIFO 是什么,我们将继续编写一个可以创建和使用 FIFO 的程序。在这个示例中,我们将编写一个创建 FIFO 然后向其发送消息的程序。在下一个示例中,我们将编写一个接收该消息的程序。

了解如何在程序中使用 FIFO 将使您能够编写可以直接使用 FIFO 进行通信的程序,而无需通过 shell 重定向数据。

准备工作

我们需要常规工具;即 GCC 编译器、Make 工具和通用 Makefile。

如何做…

在这个示例中,我们将编写一个创建 FIFO 并向其发送消息的程序:

  1. 在文件中写入以下代码并将其保存为fifo-sender.c。这段代码有点长,所以我们将在这里逐步介绍它。请记住,所有代码都放在同一个文件中。让我们从#include行、信号处理程序的原型和一些全局变量开始:
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <stdlib.h>
#include <errno.h>
void cleanUp(int signum);
int fd; /* the FIFO file descriptor */
const char fifoname[] = "/tmp/my-2nd-fifo";
  1. 现在,我们可以开始编写main()函数。首先,我们将为sigaction()函数创建结构体。然后,我们将检查用户是否提供了消息作为参数:
int main(int argc, char *argv[])
{
   struct sigaction action; /* for sigaction */
   if ( argc != 2 )
   {
      fprintf(stderr, "Usage: %s 'the message'\n",
         argv[0]);
      return 1;
   }
  1. 现在,我们必须为我们想要捕获的所有信号注册信号处理程序。我们这样做是为了在程序退出时删除 FIFO。请注意,我们还注册了SIGPIPE信号——关于这一点,我们将在它是如何工作的…部分详细说明:
   /* prepare for sigaction and register signals
    * (for cleanup when we exit) */
   action.sa_handler = cleanUp;
   sigfillset(&action.sa_mask);
   action.sa_flags = SA_RESTART;
   sigaction(SIGTERM, &action, NULL);
   sigaction(SIGINT, &action, NULL);
   sigaction(SIGQUIT, &action, NULL);
   sigaction(SIGABRT, &action, NULL);
   sigaction(SIGPIPE, &action, NULL);
  1. 现在,让我们使用模式644创建 FIFO。由于模式644是八进制的,我们需要在 C 代码中写为0644;否则,它将被解释为 644 十进制(在 C 中以 0 开头的任何数字都是八进制数)。之后,我们必须使用open()系统调用打开 FIFO——与我们用于打开常规文件的系统调用相同:
   if ( (mkfifo(fifoname, 0644)) != 0 )
   {
      perror("Can't create FIFO");
      return 1;
   }
   if ( (fd = open(fifoname, O_WRONLY)) == -1)
   {
      perror("Can't open FIFO");
      return 1;
   }
  1. 现在,我们必须创建一个无限循环。在这个循环内,我们将每秒打印一次用户提供的消息。循环结束后,我们将关闭文件描述符并删除 FIFO 文件。不过在正常情况下,我们不应该达到这一步:
   while(1)
   {
      dprintf(fd, "%s\n", argv[1]);
      sleep(1);
   }
   /* just in case, but we shouldn't reach this */
   close(fd);
   unlink(fifoname);
   return 0;
}
  1. 最后,我们必须创建cleanUp()函数,这是我们注册为信号处理程序的函数。我们使用这个函数在程序退出之前进行清理。然后,我们必须关闭文件描述符并删除 FIFO 文件:
void cleanUp(int signum)
{
   if (signum == SIGPIPE)
      printf("The receiver stopped receiving\n");
   else
      printf("Aborting...\n");
   if ( (close(fd)) == -1 )
      perror("Can't close file descriptor");
   if ( (unlink(fifoname)) == -1)
   {
      perror("Can't remove FIFO");
      exit(1);
   }
   exit(0);
}
  1. 让我们编译程序:
$> make fifo-sender
gcc -Wall -Wextra -pedantic -std=c99    fifo-sender.c   -o fifo-sender
  1. 让我们运行程序:
$> ./fifo-sender 'Hello everyone, how are you?'
  1. 现在,启动另一个终端,以便使用cat接收消息。我们在程序中使用的文件名是/tmp/my-2nd-fifo。消息将每秒重复一次。几秒钟后,按下Ctrl + C退出cat
$> cat < /tmp/my-2nd-fifo 
Hello everyone, how are you?
Hello everyone, how are you?
Hello everyone, how are you?
*Ctrl**+**P* 
  1. 现在,返回到第一个终端。您会注意到它显示接收器停止接收

  2. 在第一个终端中再次启动fifo-sender程序。

  3. 再次转到第二个终端,并重新启动cat程序以接收消息。让cat程序继续运行:

$> cat < /tmp/my-2nd-fifo
  1. 当第二个终端上的 cat 程序正在运行时,返回到第一个终端,并通过按下Ctrl + C中止fifo-sender程序。请注意,这次它显示Aborting
Ctrl+C
^CAborting...

第二个终端中的cat程序现在已退出。

它是如何工作的…

在这个程序中,我们注册了一个之前没有见过的额外信号:SIGPIPE信号。当另一端终止时,在我们的情况下是cat程序,我们的程序将收到一个SIGPIPE信号。如果我们没有捕获该信号,我们的程序将以信号 141 退出,并且不会发生清理。从这个退出代码,我们可以推断出这是由于SIGPIPE信号引起的,因为 141-128 = 13;信号 13 是SIGPIPE。有关保留返回值的解释,请参见第二章中的图 2.2使您的程序易于脚本化

cleanUp()函数中,我们使用该信号号(SIGPIPE,它是 13 的宏)在接收器停止接收数据时打印特殊消息。

如果我们改为通过按下Ctrl + C中止fifo-sender程序,我们会得到另一条消息;即Aborted

mkfifo()函数为我们创建了一个指定模式的 FIFO 文件。在这里,我们将模式指定为一个八进制数。在 C 中,任何以 0 开头的数字都是八进制数。

由于我们使用open()系统调用打开 FIFO,我们得到了一个dprintf()来将用户的消息打印到管道中。程序的第一个参数—argv[1]—是用户的消息。

只要 FIFO 在程序中保持打开状态,cat也将继续监听。这就是为什么我们可以在循环中每秒重复一次消息。

另请参阅

有关mkfifo()函数的深入解释,请参阅man 3 mkfifo

有关可能信号的列表,请参阅kill -L

要了解有关dprintf()的更多信息,请参阅man 3 dprintf手册页。

FIFO – 构建接收器

在上一个示例中,我们编写了一个创建 FIFO 并向其写入消息的程序。我们还使用cat进行了测试以接收消息。在这个示例中,我们将编写一个 C 程序,从 FIFO 中读取。

从 FIFO 中读取与从常规文件或标准输入读取没有任何不同。

准备工作

在开始本教程之前,最好先完成上一个教程。我们将使用上一个教程中的程序将数据写入我们将在本教程中接收的 FIFO 中。

您还需要常规工具;即 GCC 编译器、Make 工具和通用 Makefile。

操作步骤如下...

在本教程中,我们将为前一个教程中编写的发送程序编写一个接收程序。让我们开始:

  1. 将以下代码写入文件并保存为fifo-receiver.c。我们将使用文件流打开 FIFO,然后在循环中逐个字符读取,直到我们得到文件结束EOF):
#include <stdio.h>
int main(void)
{
    FILE *fp;
    signed char c;
    const char fifoname[] = "/tmp/my-2nd-fifo";
    if ( (fp = fopen(fifoname, "r")) == NULL )
    {
        perror("Can't open FIFO");
        return 1;
    }
    while ( (c = getc(fp)) != EOF )
        putchar(c);
    fclose(fp);
    return 0;
}
  1. 编译程序:
$> make fifo-receiver
gcc -Wall -Wextra -pedantic -std=c99    fifo-receiver.c   -o fifo-receiver
  1. 从上一个教程中启动fifo-sender并让其运行:
$> ./fifo-sender 'Hello from the sender'
  1. 打开第二个终端并运行我们刚刚编译的fifo-receiver。在几秒钟后按Ctrl + C中止它:
fifo-sender will also abort, just like when we used the cat command to receive the data.

工作原理...

由于 FIFO 是文件系统上的一个文件,我们可以使用 C 中的常规函数(如文件流、getc()putchar()等)从中接收数据。

这个程序类似于第五章中的stream-read.c程序,使用文件 I/O 和文件系统操作,只是这里我们逐个字符读取而不是逐行读取。

另请参阅

有关getc()putchar()的更多信息,请参阅man 3 getcman 3 putchar手册页。

消息队列 - 创建发送程序

另一种流行的 IPC 技术是消息队列。这基本上就是名字所暗示的。一个进程将消息留在队列中,另一个进程读取它们。

Linux 上有两种类型的消息队列:mq_函数,如mq_open()mq_send()等。

了解如何使用消息队列使您能够从各种 IPC 技术中进行选择。

准备工作

对于本教程,我们只需要 GCC 编译器和 Make 工具。

操作步骤如下...

在本教程中,我们将创建发送程序。这个程序将创建一个新的消息队列并向其中添加一些消息。在下一个教程中,我们将接收这些消息:

  1. 将以下代码写入文件并保存为msg-sender.c。由于代码中有一些新内容,我已将其分解为几个步骤。所有代码都放在一个文件中,名为msg-sender.c

让我们从所需的头文件开始。我们还为最大消息大小定义了一个宏。然后,我们将创建一个名为msgattrmq_attr类型的结构。然后设置它的成员;也就是说,我们将mq_maxmsg设置为 10,mq_msgsize设置为MAX_MSG_SIZE。第一个mq_maxmsg指定队列中的消息总数。第二个mq_msgsize指定消息的最大大小:

#include <stdio.h>
#include <mqueue.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <string.h>
#define MAX_MSG_SIZE 2048
int main(int argc, char *argv[])
{
   int md; /* msg queue descriptor */
   /* attributes for the message queue */
   struct mq_attr msgattr;
   msgattr.mq_maxmsg = 10;
   msgattr.mq_msgsize = MAX_MSG_SIZE;
  1. 我们将把程序的第一个参数作为消息。因此,在这里,我们将检查用户是否输入了参数:
   if ( argc != 2)
   {
      fprintf(stderr, "Usage: %s 'my message'\n",
         argv[0]);
      return 1;
   }
  1. 现在,是时候用mq_open()打开并创建消息队列了。第一个参数是队列的名称;在这里,它是/my_queue。第二个参数是标志,我们的情况下是O_CREATEO_RDWR。这些是我们之前见过的相同标志,例如open()。第三个参数是权限模式;再次,这与文件相同。第四个和最后一个参数是我们之前创建的结构。mq_open()函数然后将消息队列描述符返回给md变量。

最后,我们使用mq_send()将消息发送到队列。这里,首先,我们给它md描述符。然后,我们有要发送的消息,在本例中是程序的第一个参数。然后,作为第三个参数,我们必须指定消息的大小。最后,我们必须为消息设置一个优先级;在这种情况下,我们将选择 1。它可以是任何正数(无符号整数)。

在退出程序之前,我们将做的最后一件事是使用mq_close()关闭消息队列描述符:

   md = mq_open("/my_queue", O_CREAT|O_RDWR, 0644, 
      &msgattr); 
   if ( md == -1 )
   {
      perror("Creating message queue");
      return 1;
   }
   if ( (mq_send(md, argv[1], strlen(argv[1]), 1))
      == -1 )
   {
      perror("Message queue send");
      return 1;
   }
   mq_close(md);
   return 0;
}
  1. 编译程序。请注意,我们必须链接rt库,该库代表实时扩展库
$> gcc -Wall -Wextra -pedantic -std=c99 -lrt \
> msg-sender.c -o msg-sender
  1. 现在,运行程序并向队列发送三到四条消息:
$> ./msg-sender "The first message to the queue"
$> ./msg-sender "The second message"
$> ./msg-sender "And another message"

工作原理…

在这个食谱中,我们使用了 POSIX 消息队列函数来创建一个新队列,然后向其发送消息。当我们创建队列时,我们指定该队列可以包含最多 10 条消息,使用msgattrmq_maxmsg成员。

我们还使用mq_msgsize成员将每条消息的最大长度设置为 2,048 个字符。

当我们调用mq_open()时,我们将队列命名为/my_queue。消息队列必须以斜杠开头。

队列创建后,我们使用mq_send()向其发送消息。

在这个食谱的最后,我们向队列发送了三条消息。这些消息现在已排队,等待接收。在下一个食谱中,我们将学习如何编写一个接收这些消息并在屏幕上打印它们的程序。

另请参阅

在 Linux 的man 7 mq_overview手册页中有关于 POSIX 消息队列功能的很好的概述。

消息队列 - 创建接收器

在上一个食谱中,我们构建了一个程序,创建了一个名为/my_queue的消息队列,然后向其发送了三条消息。在这个食谱中,我们将创建一个接收来自该队列的消息的程序。

准备工作

在开始这个食谱之前,您需要完成上一个食谱。否则,我们将收不到任何消息。

您还需要 GCC 编译器和 Make 工具来完成这个食谱。

操作步骤…

在这个食谱中,我们将接收上一个食谱中发送的消息:

  1. 在文件中写入以下代码,并将其保存为msg-receiver.c。这段代码比发送程序的代码要长一些,因此它被分成了几个步骤,每个步骤都解释了一部分代码。不过,请记住,所有代码都放在同一个文件中。我们将从头文件、变量、结构和名为buffer的字符指针开始。稍后我们将使用它来分配内存:
#include <stdio.h>
#include <mqueue.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
   int md; /* msg queue descriptor */
   char *buffer;
   struct mq_attr msgattr;
  1. 下一步是使用mq_open()打开消息队列。这次,我们只需要提供两个参数;队列的名称和标志。在这种情况下,我们只想从队列中读取:
   md = mq_open("/my_queue", O_RDONLY);
   if (md == -1 )
   {
      perror("Open message queue");
      return 1;
   }
  1. 现在,我们还想使用mq_getattr()获取消息队列的属性。一旦我们有了队列的属性,我们就可以使用其mq_msgsize成员使用calloc()为该大小的消息分配内存。在本书中,我们之前没有看到calloc()。第一个参数是我们要为其分配内存的元素数,而第二个参数是每个元素的大小。然后,calloc()函数返回指向该内存的指针(在我们的情况下,就是buffer):
   if ( (mq_getattr(md, &msgattr)) == -1 )
   {
      perror("Get message attribute");
      return 1;
   }
   buffer = calloc(msgattr.mq_msgsize, 
      sizeof(char));
   if (buffer == NULL)
   {
      fprintf(stderr, "Couldn't allocate memory");
      return 1;
   }
  1. 接下来,我们将使用mq_attr结构的另一个成员mq_curmsgs,它包含队列中当前的消息数。首先,我们将打印消息数。然后,我们将使用for循环遍历所有消息。在循环内部,首先使用mq_receive接收消息。然后,我们使用printf()打印消息。最后,在迭代下一条消息之前,我们使用memset()将整个内存重置为 NULL 字符。

mq_receive的第一个参数是描述符,第二个参数是消息所在的缓冲区,第三个参数是消息的大小,第四个参数是消息的优先级,在这种情况下是 NULL,表示我们首先接收所有最高优先级的消息:

   printf("%ld messages in queue\n", 
      msgattr.mq_curmsgs);
   for (int i = 0; i<msgattr.mq_curmsgs; i++)
   {
      if ( (mq_receive(md, buffer, 
      msgattr.mq_msgsize, NULL)) == -1 )
      {
         perror("Message receive");
         return 1;
      }
      printf("%s\n", buffer);
      memset(buffer, '\0', msgattr.mq_msgsize);
   }
  1. 最后,我们有一些清理工作要做。首先,我们必须使用free()释放缓冲区指向的内存。然后,我们必须关闭md队列描述符,然后使用mq_unlink()从系统中删除队列:
   free(buffer);
   mq_close(md);
   mq_unlink("/my_queue");
   return 0;
}
  1. 现在,是时候编译程序了:
$> gcc -Wall -Wextra -pedantic -std=c99 -lrt \
> msg-reveiver.c -o msg-reveiver
  1. 最后,让我们使用我们的新程序接收消息:
$> ./msg-reveiver 
3 messages in queue
The first message to the queue
The second message
And another message
  1. 如果我们现在尝试重新运行程序,它将简单地指出没有这样的文件或目录存在。这是因为我们使用mq_unlink()删除了消息队列:
$> ./msg-reveiver 
Open message queue: No such file or directory

工作原理…

在上一个示例中,我们向/my_queue发送了三条消息。使用本示例中创建的程序,我们接收了这些消息。

要打开队列,我们使用了创建队列时使用的相同函数;也就是mq_open()。但这一次——因为我们正在打开一个已经存在的队列——我们只需要提供两个参数;即队列的名称和标志。

mq_函数的每次调用都进行错误检查。如果发生错误,我们将使用perror()打印错误消息,并返回到 shell 并返回 1。

在从队列中读取实际消息之前,我们使用mq_getattr()获取队列的属性。通过这个函数调用,我们填充了mq_attr结构。对于读取消息来说,最重要的两个成员是mq_msgsize,它是队列中每条消息的最大大小,以及mq_curmsgs,它是当前队列中的消息数。

我们使用mq_msgsize中的最大消息大小来使用calloc()为消息缓冲区分配内存。calloc()函数返回“零化”的内存,而它的对应函数malloc()则不会。

要分配内存,我们需要创建一个指向我们想要的类型的指针。这就是我们在程序开始时使用char *buffer所做的。calloc()函数接受两个参数:要分配的元素数量和每个元素的大小。在这里,我们希望元素的数量与mq_msgsize值包含的相同。而每个元素都是char,所以每个元素的大小应该是sizeof(char)。然后函数返回一个指向内存的指针,在我们的情况下保存在char指针的buffer中。

然后,当我们接收队列消息时,我们在循环的每次迭代中将它们保存在这个缓冲区中。

循环遍历所有消息。我们从mq_curmsgs成员中得到消息的数量。

最后,一旦我们读完了所有的消息,我们关闭并删除了队列。

另请参阅

关于mq_attr结构的更多信息,我建议你阅读man 3 mq_open手册页面。

我们在这个和上一个示例中涵盖的每个函数都有自己的手册页面;例如,man 3 mq_sendman 3 mq_recevieman 3 mq_getattr等等。

如果你对calloc()malloc()函数不熟悉,我建议你阅读man 3 calloc。这个手册页面涵盖了malloc()calloc()free()和一些其他相关函数。

memset()函数也有自己的手册页面;即man 3 memset

使用共享内存在子进程和父进程之间通信

在这个示例中,我们将学习如何在两个相关的进程——父进程和子进程之间使用共享内存。共享内存以各种形式存在,并且可以以不同的方式使用。在本书中,我们将专注于 POSIX 共享内存函数。

Linux 中的共享内存可以在相关进程之间使用,正如我们将在本示例中探讨的那样,还可以在无关的进程之间使用/dev/shm目录。我们将在下一个示例中看到这一点。

在这个示例中,我们将使用匿名共享内存——即不由文件支持的内存。

共享内存就像它听起来的那样——一块在进程之间共享的内存。

了解如何使用共享内存将使您能够编写更高级的程序。

准备工作

对于这个示例,您只需要 GCC 编译器和 Make 工具。

如何做…

在这个示例中,我们将编写一个使用共享内存的程序。首先,在分叉之前,进程将向共享内存写入一条消息。然后,在分叉之后,子进程将替换共享内存中的消息。最后,父进程将再次替换共享内存的内容。让我们开始吧:

  1. 将以下代码写入一个文件中,并将其命名为shm-parent-child.c。像往常一样,我将把代码分成几个较小的步骤。尽管所有的代码都放在同一个文件中。首先,我们将写入所有的头文件。这里有相当多的头文件。我们还将为我们的内存大小定义一个宏。然后,我们将我们的三条消息写成字符数组常量:
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define DATASIZE 128
int main(void)
{
   char *addr;
   int status;
   pid_t pid;
   const char startmsg[] = "Hello, we are running";
   const char childmsg[] = "Hello from child";
   const char parentmsg[] = "New msg from parent";
  1. 现在来到令人兴奋的部分——映射共享内存空间。我们需要向内存映射函数mmap()提供总共六个参数。

第一个参数是内存地址,我们将其设置为 NULL——这意味着内核会为我们处理它。

第二个参数是内存区域的大小。

第三个参数是内存应该具有的保护。在这里,我们将其设置为可写和可读。

第四个参数是我们的标志,我们将其设置为共享和匿名——这意味着它可以在进程之间共享,并且不会由文件支持。

第五个参数是文件描述符。但在我们的情况下,我们使用的是匿名的,这意味着这块内存不会由文件支持。因此,出于兼容性原因,我们将其设置为-1。

最后一个参数是偏移量,我们将其设置为 0:

   addr = mmap(NULL, DATASIZE, 
      PROT_WRITE | PROT_READ, 
      MAP_SHARED | MAP_ANONYMOUS, -1, 0);
   if (addr == MAP_FAILED)
   {
      perror("Memory mapping failed");
      return 1;
   }
  1. 现在内存已经准备好了,我们将使用memcpy()将我们的第一条消息复制到其中。memcpy()的第一个参数是指向内存的指针,在我们的例子中是addr字符指针。第二个参数是我们要从中复制的数据或消息,在我们的例子中是startmsg。最后一个参数是我们要复制的数据的大小,在这种情况下是startmsg中字符串的长度+1。strlen()函数不包括终止的空字符;这就是为什么我们需要加 1。

然后,我们打印进程的 PID 和共享内存中的消息。之后,我们进行分叉:

   memcpy(addr, startmsg, strlen(startmsg) + 1);
   printf("Parent PID is %d\n", getpid());
   printf("Original message: %s\n", addr);
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
  1. 如果我们在子进程中,我们将子进程的消息复制到共享内存中。如果我们在父进程中,我们将等待子进程。然后,我们可以将父进程的消息复制到内存中,并打印两条消息。最后,我们将通过取消映射共享内存来清理。尽管这并不是严格要求的:
   if (pid == 0)
   {
      /* child */
      memcpy(addr, childmsg, strlen(childmsg) + 1);
   }
   else if(pid > 0)
   {
      /* parent */
      waitpid(pid, &status, 0);
      printf("Child executed with PID %d\n", pid);
      printf("Message from child: %s\n", addr);
      memcpy(addr, parentmsg, 
         strlen(parentmsg) + 1);
      printf("Parent message: %s\n", addr);
   }
   munmap(addr, DATASIZE);
   return 0;
}
  1. 编译程序,以便我们可以试一试。请注意,我们在这里使用了另一个 C 标准——MAP_ANONYMOUS宏,但GNU11有。GNU11C11标准,带有一些额外的 GNU 扩展。还要注意,我们链接了实时扩展库:
$> gcc -Wall -Wextra -std=gnu11 -lrt \
> shm-parent-child.c -o shm-parent-child
  1. 现在,我们可以测试程序了:
$> ./shm-parent-child 
Parent PID is 9683
Original message: Hello, we are running
Child executed with PID 9684
Message from child: Hello from child
Parent message: New msg from parent

工作原理…

共享内存是不相关进程、相关进程和线程之间的常见 IPC 技术。在这个示例中,我们看到了如何在父进程和子进程之间使用共享内存。

使用mmap()映射内存区域。这个函数返回映射内存的地址。如果发生错误,它将返回MAP_FAILED宏。一旦我们映射了内存,我们就检查指针变量是否为MAP_FAILED,并在出现错误时中止它。

一旦我们映射了内存并获得了指向它的指针,我们就使用memcpy()将数据复制到其中。

最后,我们使用munmap()取消映射内存。这并不是严格必要的,因为当最后一个进程退出时,它将被取消映射。但是,不这样做是一个不好的习惯。您应该始终在使用后进行清理,并释放任何分配的内存。

另请参阅

有关mmap()munmap()的更详细解释,请参见man 2 mmap手册页。有关memcpy()的详细解释,请参见man 3 memcpy手册页。

有关各种 C 标准及 GNU 扩展的更详细解释,请参见gcc.gnu.org/onlinedocs/gcc/Standards.html

在不相关进程之间使用共享内存

在之前的示例中,我们在子进程和父进程之间使用了共享内存。在这个示例中,我们将学习如何使用文件描述符将映射内存共享给两个不相关的进程。以这种方式使用共享内存会自动在/dev/shm目录中创建内存的底层文件,其中shm代表共享内存

了解如何在不相关的进程之间使用共享内存扩大了您使用这种 IPC 技术的范围。

准备工作

对于这个示例,您只需要 GCC 编译器和 Make 工具。

操作步骤…

首先,我们将编写一个程序,打开并创建一个共享内存的文件描述符,并映射内存。然后,我们将编写另一个程序来读取内存区域。与之前的示例不同,这次我们将在这里写入和检索一个由三个浮点数组成的数组,而不仅仅是一个消息。

创建写入程序

首先让我们创建写入程序:

  1. 第一步是创建一个程序,用于创建共享内存并向其写入一些数据。将以下代码写入文件并保存为write-memory.c。和往常一样,代码将被分成几个步骤,但所有代码都放在一个文件中。

就像在之前的示例中一样,我们将有一堆头文件。然后,我们将创建所有需要的变量。在这里,我们需要一个文件描述符变量。请注意,即使我在这里称其为文件描述符,它实际上是一个内存区域的描述符。memid包含内存映射描述符的名称。然后,我们必须使用shm_open()来打开和创建“文件描述符”:

#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define DATASIZE 128
int main(void)
{
   int fd;
   float *addr;
   const char memid[] = "/my_memory";
   const float numbers[3] = { 3.14, 2.718, 1.202};
   /* create shared memory file descriptor */
   if ( (fd = shm_open(memid, 
      O_RDWR | O_CREAT, 0600)) == -1)
   {
      perror("Can't open memory fd");
      return 1;
   }
  1. 文件支持的内存最初大小为 0 字节。要将其扩展到我们的 128 字节,我们必须使用ftruncate()进行截断。
   /* truncate memory to DATASIZE */
   if ( (ftruncate(fd, DATASIZE)) == -1 )
   {
      perror("Can't truncate memory");
      return 1;
   }
  1. 现在,我们必须映射内存,就像我们在之前的示例中所做的那样。但是这次,我们将给它fd文件描述符,而不是-1。我们还省略了MAP_ANONYMOUS部分,从而使这个内存由文件支持。然后,我们必须使用memcpy()将我们的浮点数数组复制到内存中。为了让读取程序有机会读取内存,我们必须暂停程序,并使用getchar()等待Enter键。然后,只需要清理工作,取消映射内存,并使用shm_unlink()删除文件描述符和底层文件:
   /* map memory using our file descriptor */
   addr = mmap(NULL, DATASIZE, PROT_WRITE, 
      MAP_SHARED, fd, 0);
   if (addr == MAP_FAILED)
   {
      perror("Memory mapping failed");
      return 1;
   }
   /* copy data to memory */
   memcpy(addr, numbers, sizeof(numbers));
   /* wait for enter */
   printf("Hit enter when finished ");
   getchar();
   /* clean up */
   munmap(addr, DATASIZE);
   shm_unlink(memid);
   return 0;
}
  1. 现在,让我们编译这个程序:
$> gcc -Wall -Wextra -std=gnu11 -lrt write-memory.c \
> -o write-memory

创建读取程序

现在,让我们创建读取程序:

  1. 现在,我们将编写一个程序,用于读取内存区域并打印数组中的数字。编写以下程序并将其保存为read-memory.c。这个程序类似于write-memory.c,但不是向内存写入,而是从内存读取:
#include <stdio.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#define DATASIZE 128
int main(void)
{
   int fd;
   float *addr;
   const char memid[] = "/my_memory";
   float numbers[3];
   /* open memory file descriptor */
   fd = shm_open(memid, O_RDONLY, 0600);
   if (fd == -1)
   {
      perror("Can't open file descriptor");
      return 1;
   }
   /* map shared memory */
   addr = mmap(NULL, DATASIZE, PROT_READ, 
      MAP_SHARED, fd, 0);
   if (addr == MAP_FAILED)
   {
      perror("Memory mapping failed");
      return 1;
   }
   /* read the memory and print the numbers */
   memcpy(numbers, addr, sizeof(numbers));
   for (int i = 0; i<3; i++)
   {
      printf("Number %d: %.3f\n", i, numbers[i]);
   }
   return 0;
}
  1. 现在,编译这个程序:
$> gcc -Wall -Wextra -std=gnu11 -lrt read-memory.c \
> -o read-memory

测试一切

按照以下步骤进行:

  1. 现在,是时候尝试一切了。打开终端并运行我们编译的write-memory程序。让程序保持运行:
$> ./write-memory 
Hit enter when finished
  1. 打开另一个终端,查看/dev/shm中的文件:
$> ls -l /dev/shm/my_memory 
-rw------- 1 jake jake 128 jan 18 19:19 /dev/shm/my_memory
  1. 现在运行我们刚刚编译的read-memory程序。这将从共享内存中检索三个数字并将它们打印在屏幕上:
$> ./read-memory 
Number 0: 3.140
Number 1: 2.718
Number 2: 1.202
  1. 返回运行write-memory程序的终端,然后按Enter。这样做将清理并删除文件。完成后,让我们看看文件是否仍然在/dev/shm中:
./write-memory 
Hit enter when finished Enter
$> ls -l /dev/shm/my_memory
ls: cannot access '/dev/shm/my_memory': No such file or directory

工作原理…

使用非匿名共享内存与我们在之前的示例中所做的类似。唯一的例外是,我们首先使用shm_open()打开一个特殊的文件描述符。正如您可能已经注意到的,标志与常规的open()调用相似;即,O_RDWR用于读取和写入,O_CREATE用于在文件不存在时创建文件。以这种方式使用shm_open()会在/dev/shm目录中创建一个文件,文件名由第一个参数指定。甚至权限模式设置方式与常规文件相同——在我们的情况下,0600用于用户读写,其他人没有权限。

我们从shm_open()获得的文件描述符然后传递给mmap()调用。我们还在mmap()调用中省略了MAP_ANONYMOUS宏,就像我们在前面的示例中看到的那样。跳过MAP_ANONYMOUS意味着内存将不再是匿名的,这意味着它将由文件支持。我们使用ls -l检查了这个文件,并看到它确实有我们给它的名称和正确的权限。

我们编写的下一个程序使用shm_open()打开了相同的共享内存文件描述符。在mmap()之后,我们循环遍历了内存区域中的浮点数。

最后,一旦我们在write-memory程序中按下Enter/dev/shm中的文件将使用shm_unlink()被删除。

另请参阅

man 3 shm_open手册页中有关于shm_open()shm_unlink()的更多信息。

Unix 套接字-创建服务器

Unix 套接字类似于TCP/IP套接字,但它们只是本地的,并且由文件系统上的套接字文件表示。但是与 Unix 套接字一起使用的整体函数与 TCP/IP 套接字的几乎相同。Unix 套接字的完整名称是Unix 域套接字

Unix 套接字是程序在本地机器上进行通信的常见方式。

了解如何使用 Unix 套接字将使编写需要在它们之间通信的程序变得更容易。

准备工作

在这个示例中,您只需要 GCC 编译器、Make 工具和通用 Makefile。

如何做…

在这个示例中,我们将编写一个充当服务器的程序。它将从客户端接收消息,并在每次接收到消息时回复“消息已收到”。当服务器或客户端退出时,它还会自行清理。让我们开始吧:

  1. 将以下代码写入文件并保存为unix-server.c。这段代码比我们以前的大多数示例都要长,因此它被分成了几个步骤。不过所有的代码都在同一个文件中。

这里有相当多的头文件。我们还将为我们将接受的最大消息长度定义一个宏。然后,我们将为cleanUp()函数编写原型,该函数将用于清理文件。这个函数也将被用作信号处理程序。然后,我们将声明一些全局变量(以便它们可以从cleanUp()中访问):

#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <errno.h>
#define MAXLEN 128
void cleanUp(int signum);
const char sockname[] = "/tmp/my_1st_socket";
int connfd;
int datafd;
  1. 现在,是时候开始编写main()函数并声明一些变量了。到目前为止,这大部分对您来说应该是熟悉的。我们还将在这里为所有信号注册信号处理程序。新的是sockaddr_un结构。这将包含套接字类型和文件路径:
int main(void)
{
   int ret;
   struct sockaddr_un addr;
   char buffer[MAXLEN];
   struct sigaction action;
   /* prepare for sigaction */
   action.sa_handler = cleanUp;
   sigfillset(&action.sa_mask);
   action.sa_flags = SA_RESTART;
   /* register the signals we want to handle */
   sigaction(SIGTERM, &action, NULL);
   sigaction(SIGINT, &action, NULL);
   sigaction(SIGQUIT, &action, NULL);
   sigaction(SIGABRT, &action, NULL);
   sigaction(SIGPIPE, &action, NULL);
  1. 现在我们已经准备好了所有的信号处理程序、变量和结构,我们可以使用socket()函数创建一个套接字文件描述符。一旦处理好了这个问题,我们将设置连接的类型(family类型)和套接字文件的路径。然后,我们将调用bind(),这将为我们绑定套接字,以便我们可以使用它:
   /* create socket file descriptor */
   connfd = socket(AF_UNIX, SOCK_SEQPACKET, 0);
   if ( connfd == -1 )
   {
      perror("Create socket failed");
      return 1;
   }
   /* set address family and socket path */
   addr.sun_family = AF_UNIX;
   strcpy(addr.sun_path, sockname);
   /* bind the socket (we must cast our sockaddr_un
    * to sockaddr) */
   if ( (bind(connfd, (const struct sockaddr*)&addr, 
      sizeof(struct sockaddr_un))) == -1 )
   {
      perror("Binding socket failed");
      return 1;
   }
  1. 现在,我们将通过调用listen()准备好连接的套接字文件描述符。第一个参数是套接字文件描述符,而第二个参数是我们想要的后备大小。一旦我们做到了这一点,我们将使用accept()接受一个连接。这将给我们一个新的套接字(datafd),我们将在发送和接收数据时使用它。一旦连接被接受,我们可以在本地终端上打印客户端已连接
   /* prepare for accepting connections */
   if ( (listen(connfd, 20)) == -1 )
   {
      perror("Listen error");
      return 1;
   }
   /* accept connection and create new file desc */
   datafd = accept(connfd, NULL, NULL);
   if (datafd == -1 )
   {
      perror("Accept error");
      return 1;
   }
   printf("Client connected\n");
  1. 现在,我们将开始程序的主循环。在外部循环中,我们只会在接收到消息时写一个确认消息。在内部循环中,我们将从新的套接字文件描述符中读取数据,将其保存在buffer中,然后在我们的终端上打印出来。如果read()返回-1,那么出现了问题,我们必须跳出内部循环读取下一行。如果read()返回 0,那么客户端已断开连接,我们必须运行cleanUp()并退出:
   while(1) /* main loop */
   {
      while(1) /* receive message, line by line */
      {
         ret = read(datafd, buffer, MAXLEN);
         if ( ret == -1 )
         {
            perror("Error reading line");
            cleanUp(1);
         }
         else if ( ret == 0 )
         {
            printf("Client disconnected\n");
            cleanUp(1);
         }
         else
         {
            printf("Message: %s\n", buffer);
            break;
         }
      }
   /* write a confirmation message */
   write(datafd, "Message received\n", 18);
   }
   return 0;
}
  1. 最后,我们必须创建cleanUp()函数的主体:
void cleanUp(int signum)
{
   printf("Quitting and cleaning up\n");
   close(connfd);
   close(datafd);
   unlink(sockname);
   exit(0);
}
  1. 现在编译程序。这次,我们将从 GCC 得到一个关于cleanUp()函数中未使用的变量signum的警告。这是因为我们从未在cleanUp()内部使用过signum变量,所以我们可以安全地忽略这个警告:
$> make unix-server
gcc -Wall -Wextra -pedantic -std=c99    unix-server.c   -o unix-server
unix-server.c: In function 'cleanUp':
unix-server.c:94:18: warning: unused parameter 'signum' [-Wunused-parameter]
 void cleanUp(int signum)
              ~~~~^~~~~~
  1. 运行程序。由于我们没有客户端,它暂时不会说或做任何事情。但是它确实创建了套接字文件。将程序保持不变:
$> ./unix-server
  1. 打开一个新的终端并查看套接字文件。在这里,我们可以看到它是一个套接字文件:
$> ls -l /tmp/my_1st_socket 
srwxr-xr-x 1 jake jake 0 jan 19 18:35 /tmp/my_1st_socket
$> file /tmp/my_1st_socket 
/tmp/my_1st_socket: socket
  1. 现在,回到运行服务器程序的终端,并使用Ctrl + C中止它。然后,看看文件是否还在那里(不应该在那里):
./unix-server
Ctrl+C
Quitting and cleaning up
$> file /tmp/my_1st_socket 
/tmp/my_1st_socket: cannot open `/tmp/my_1st_socket' (No such file or directory)

它是如何工作的…

sockaddr_un结构是 Unix 域套接字的特殊结构。还有一个称为sockaddr_in的结构,用于 TCP/IP 套接字。_un结尾代表 Unix 套接字,而_in代表互联网家族套接字。

我们用来创建套接字文件描述符的socket()函数需要三个参数:地址族(AF_UNIX),类型(SOCK_SEQPACKET,提供双向通信),和协议。我们将协议指定为 0,因为在套接字中没有可以选择的协议。

还有一个称为sockaddr的一般结构。当我们将我们的sockaddr_un结构作为bind()的参数传递时,我们需要将其强制转换为一般类型sockaddr,因为这是函数期望的——更确切地说,是sockaddr指针。我们为bind()提供的最后一个参数是结构的大小;也就是sockaddr_un

一旦我们创建了套接字并用bind()绑定了它,我们就用listen()准备好接受传入的连接。

最后,我们使用accept()接受传入的连接。这给了我们一个新的套接字文件描述符,然后我们用它来发送和接收消息。

另请参阅

在这个示例中,我们使用的函数的手册页中有一些更深入的信息。我建议你把它们都看一遍:

  • man 2 socket

  • man 2 bind

  • man 2 listen

  • man 2 accept

Unix 套接字 - 创建客户端

在上一个示例中,我们创建了一个 Unix 域套接字服务器。在这个示例中,我们将为该套接字创建一个客户端,然后在客户端和服务器之间进行通信。

在这个示例中,我们将看到如何使用套接字在服务器和客户端之间进行通信。了解如何在套接字上进行通信对于使用套接字是至关重要的。

准备工作

在做这个示例之前,你应该已经完成了上一个示例;否则,你就没有服务器可以交谈了。

对于这个示例,你还需要 GCC 编译器、Make 工具和通用的 Makefile。

如何做…

在这个示例中,我们将为上一个示例中编写的服务器编写一个客户端。一旦它们连接,客户端就可以向服务器发送消息,服务器将以收到消息作出回应。让我们开始吧:

  1. 在文件中写入以下代码并将其保存为unix-client.c。由于这段代码也有点长,它被分成了几个步骤。但所有的代码都在unix-client.c文件中。这个程序的前半部分与服务器的前半部分类似,只是我们有两个缓冲区而不是一个,而且没有信号处理:
#define _XOPEN_SOURCE 700
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <errno.h>
#define MAXLEN 128
int main(void)
{
   const char sockname[] = "/tmp/my_1st_socket";
   int fd;
   struct sockaddr_un addr;
   char sendbuffer[MAXLEN];
   char recvbuffer[MAXLEN];
   /* create socket file descriptor */
   fd = socket(AF_UNIX, SOCK_SEQPACKET, 0);
   if ( fd == -1 )
   {
      perror("Create socket failed");
      return 1;
   }
   /* set address family and socket path */
   addr.sun_family = AF_UNIX;
   strcpy(addr.sun_path, sockname);
  1. 现在,我们将使用connect()来初始化与服务器的连接,而不是使用bind()listen()accept()connect()函数接受与bind()相同的参数:
   /* connect to the server */
   if ( (connect(fd, (const struct sockaddr*) &addr, 
      sizeof(struct sockaddr_un))) == -1 )
   {
      perror("Can't connect");
      fprintf(stderr, "The server is down?\n");
      return 1;
   }
  1. 现在我们已经连接到服务器,我们可以使用write()来通过套接字文件描述符发送消息。在这里,我们将使用fgets()将用户的消息读入缓冲区,将换行符转换为空字符,然后将缓冲区写入文件描述符:
   while(1) /* main loop */
   {
      /* send message to server */
      printf("Message to send: ");
      fgets(sendbuffer, sizeof(sendbuffer), stdin);
      sendbuffer[strcspn(sendbuffer, "\n")] = '\0';
      if ( (write(fd, sendbuffer, 
         strlen(sendbuffer) + 1)) == -1 )
      {
         perror("Couldn't write");
         break;
      }
      /* read response from server */
      if ( (read(fd, recvbuffer, MAXLEN)) == -1 )
      {
         perror("Can't read");
         return 1;
      }
      printf("Server said: %s\n", recvbuffer);
   }
   return 0;
}
  1. 编译程序:
$> make unix-client
gcc -Wall -Wextra -pedantic -std=c99    unix-client.c   -o unix-client
  1. 现在让我们尝试运行程序。由于服务器尚未启动,它不会工作:
$> ./unix-client 
Can't connect: No such file or directory
The server is down?
  1. 在一个单独的终端中启动服务器并让它保持运行:
$> ./unix-server
  1. 返回到具有客户端的终端并重新运行它:
$> ./unix-client 
Message to send:

现在你应该在服务器上看到一条消息,上面写着客户端已连接

  1. 在客户端程序中写一些消息。当您按下Enter键时,您应该会在服务器上看到它们出现。发送几条消息后,按下Ctrl + C
$> ./unix-client 
Message to send: Hello, how are you?
Server said: Message received
Message to send: Testing 123           
Server said: Message received
Message to send: Ctrl+C
  1. 切换到带有服务器的终端。您应该会看到类似于这样的内容:
Client connected
Message: Hello, how are you?
Message: Testing 123
Client disconnected
Quitting and cleaning up

工作原理…

在上一个示例中,我们编写了一个套接字服务器。在这个示例中,我们编写了一个客户端,使用connect()系统调用连接到该服务器。这个系统调用接受与bind()相同的参数。一旦连接建立,服务器和客户端都可以使用write()read()从套接字文件描述符中写入和读取(双向通信)。

因此,实质上,一旦连接建立,它与使用文件描述符读写文件并没有太大不同。

另请参阅

有关connect()系统调用的更多信息,请参阅man 2 connect手册页。

第十一章:在程序中使用线程

在本章中,我们将学习什么是线程以及如何在 Linux 中使用它们。 我们将使用POSIX 线程(也称为pthreads)编写几个程序。 我们还将学习什么是竞争条件,以及如何使用互斥锁来防止它们。 然后,我们将学习如何使互斥程序更高效。 最后,我们将学习什么是条件变量。

知道如何编写多线程程序将使它们更快,更高效。

在本章中,我们将涵盖以下示例:

  • 编写你的第一个多线程程序

  • 从线程读取返回值

  • 引发竞争条件

  • 使用互斥锁避免竞争条件

  • 使互斥程序更高效

  • 使用条件变量

让我们开始吧!

技术要求

对于本章,您将需要 GCC 编译器,Make 工具和通用 Makefile。 如果您尚未安装这些工具,请参考[第一章](B13043_01_Final_SK_ePub.xhtml#_idTextAnchor020),获取必要的工具并编写我们的第一个 Linux 程序,以获取安装说明。

您还需要一个名为htop的程序来查看 CPU 负载。 您可以使用发行版的软件包管理器安装它。 所有发行版都称该程序为htop

本章的所有代码示例都可以从 GitHub 下载,网址如下:github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch11

查看以下链接以查看代码演示视频:bit.ly/2O4dnlN

编写你的第一个多线程程序

在这个第一个示例中,我们将编写一个小程序,检查两个数字是否为质数-并行进行。 在检查这两个数字时,每个数字都在自己的线程中,另一个线程将在终端中写入点以指示程序仍在运行。 该程序将运行三个线程。 每个线程将打印自己的结果,因此在此程序中不需要保存和返回值。

了解线程的基础知识将为进一步学习更高级的程序打下基础。

做好准备

对于这个示例,您将需要htop程序,以便您可以看到两个 CPU 核心的CPU负载增加。 当然,其他类似的程序也可以工作,例如KDEK Desktop EnvironmentKDE)的 KSysGuard。 如果您的计算机有多个 CPU core,那就更好了。 大多数计算机今天都有多个核心,即使是树莓派和类似的小型计算机,所以这不应该是一个问题。 即使您只有单核 CPU,该程序仍然可以工作,但是很难可视化线程。

你还需要 GCC 编译器和 Make 工具。

如何做…

在本章中,我们将使用Makefile。 注意添加的-lpthread,这是通用 Makefile 中没有的东西:

CC=gcc
CFLAGS=-Wall -Wextra -pedantic -std=c99 -lpthread

现在,让我们继续编写程序。 代码有点长,所以它被分成了几个步骤。 尽管所有的代码都放在一个文件中。 将代码保存为first-threaded.c

  1. 让我们从头文件开始,一些函数原型,main()函数和一些必要的变量。 注意新的头文件pthread.h。 我们还有一个新类型,称为pthread_t。 此类型用于线程 ID。 还有一个pthread_attr_t类型,用于线程的属性。 我们还执行检查,以查看用户是否输入了两个参数(将检查这些参数是否为质数)。 然后,我们将使用atoll()将第一个和第二个参数转换为long long整数:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
void *isprime(void *arg);
void *progress(void *arg);
int main(int argc, char *argv[])
{
   long long number1;
   long long number2;
   pthread_t tid_prime1;
   pthread_t tid_prime2;
   pthread_t tid_progress;
   pthread_attr_t threadattr;
   if ( argc != 3 )
   {
      fprintf(stderr, "Please supply two numbers.\n"
         "Example: %s 9 7\n", argv[0]);
      return 1;
   }
   number1 = atoll(argv[1]);
   number2 = atoll(argv[2]);
  1. 接下来,我们将使用pthread_attr_init()初始化线程属性结构threadattr,并使用一些默认设置。

然后,我们将使用pthread_create()创建三个线程。pthread_create()函数有四个参数。第一个参数是线程 ID 变量;第二个参数是线程的属性;第三个参数是将在线程中执行的函数;第四个参数是该函数的参数。我们还将使用pthread_detach()将"进度条"线程标记为分离状态,这样当线程终止时,线程的资源将自动释放:

   pthread_attr_init(&threadattr);
   pthread_create(&tid_progress, &threadattr, 
      progress, NULL); 
   pthread_detach(tid_progress);
   pthread_create(&tid_prime1, &threadattr, 
      isprime, &number1);
   pthread_create(&tid_prime2, &threadattr, 
      isprime, &number2);
  1. 为了使程序等待所有线程完成,我们必须为每个线程使用pthread_join()。请注意,我们不等待进度线程,但我们确实将其标记为分离状态。在这里,我们将在退出程序之前取消进度线程,使用pthread_cancel()
   pthread_join(tid_prime1, NULL);
   pthread_join(tid_prime2, NULL);
   pthread_attr_destroy(&threadattr);
   if ( pthread_cancel(tid_progress) != 0 )
      fprintf(stderr, 
         "Couldn't cancel progress thread\n");
   printf("Done!\n");
   return 0;
}
  1. 现在是时候编写将计算给定数字是否为质数的函数体了。请注意,函数的返回类型是 void 指针。参数也是 void 指针。这是pthread_create()要求的。由于参数是 void 指针,而我们希望它是long long int,因此我们必须先进行转换。我们通过将 void 指针转换为long long int并将其指向的内容保存在一个新变量中来实现这一点(有关更详细的选项,请参阅参见部分)。请注意,在这个函数中我们返回NULL。这是因为我们必须返回something,所以在这里使用NULL就可以了:
void *isprime(void *arg)
{
   long long int number = *((long long*)arg);
   long long int j;
   int prime = 1;

   /* Test if the number is divisible, starting 
    * from 2 */
   for(j=2; j<number; j++)
   {
      /* Use the modulo operator to test if the 
       * number is evenly divisible, i.e., a 
       * prime number */
      if(number%j == 0)
      {
         prime = 0;
      }
   }
   if(prime == 1)
   {
      printf("\n%lld is a prime number\n", 
         number);
      return NULL;
   }
   else
   {
      printf("\n%lld is not a prime number\n", 
         number);
      return NULL;
   }
}
  1. 最后,我们编写进度表的函数。它并不是真正的进度表;它只是每秒打印一个点,以向用户显示程序仍在运行。在调用printf()后,我们必须使用fflush(),因为我们没有打印任何换行符(请记住 stdout 是行缓冲的):
void *progress(void *arg)
{
   while(1)
   {
      sleep(1);
      printf(".");
      fflush(stdout);
   }
   return NULL;
}
  1. 现在是时候使用我们的新 Makefile 编译程序了。请注意,我们收到了一个关于未使用的变量的警告。这是进度函数的arg变量。我们可以放心地忽略这个警告,因为我们知道我们没有使用它。
$> make first-threaded
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    first-threaded.c   -o first-threaded
first-threaded.c: In function 'progress':
first-threaded.c:71:22: warning: unused parameter 'arg' [-Wunused-parameter]
 void *progress(void *arg)
  1. 现在,在运行程序之前,打开一个新的终端并在其中启动htop。将它放在一个可以看到的地方。

  2. 现在在第一个终端中运行程序。选择两个数字,不要太小,以至于程序会立即完成,但也不要太大,以至于程序会永远运行。对我来说,以下数字足够大,可以使程序运行大约一分半钟。这将取决于 CPU。在运行程序时,检查htop程序。您会注意到两个核心将使用 100%,直到计算第一个数字,然后它将只使用一个核心以 100%:

$> ./first-threaded 990233331 9902343047
..........
990233331 is not a prime number
...............................................................................
9902343047 is a prime number
Done!

工作原理...

两个数字分别在各自的线程中进行检查。与非线程化程序相比,这加快了进程。非线程化程序将依次检查每个数字。也就是说,第二个数字必须等到第一个数字完成后才能进行检查。但是使用线程化程序,就像我们在这里做的一样,可以同时检查两个数字。

isprime()函数是进行计算的地方。相同的函数用于两个线程。我们还为两个线程使用相同的默认属性。

我们通过为每个数字调用pthread_create()在线程中执行函数。请注意,在pthread_create()参数中的isprime()函数后面没有括号。在函数名后面加上括号会执行该函数。但是,我们希望pthread_create()函数执行该函数。

由于我们不会调用pthread_cancel(),我们将其标记为分离状态,以便在线程终止时释放其资源。我们使用pthread_detach()将其标记为分离状态。

默认情况下,线程具有其自己的sleep()函数是其中之一;因此,进度线程将在执行sleep()后取消。可取消类型可以更改为异步,这意味着它可以随时取消。

main()函数的末尾,我们对两个线程 ID(执行isprime()的线程)调用了pthread_join()。这是必要的,以使进程等待线程完成;否则,它会立即结束。pthread_join()的第一个参数是线程 ID。第二个参数是一个变量,可以保存线程的返回值。但由于我们对返回值不感兴趣——它只返回NULL——我们将其设置为NULL,以忽略它。

还有更多…

要更改线程的可取消性状态,您可以使用pthread_setcancelstate()。有关更多信息,请参阅man 3 pthread_setcancelstate

要更改线程的可取消性类型,您可以使用pthread_setcanceltype()。有关更多信息,请参阅man 3 pthread_setcanceltype

要查看哪些函数是man 7 pthreads,并在该手册页面中搜索取消点

从 void 指针转换为long long int可能看起来有点神秘。与我们在这里所做的一样,不要一行搞定:

long long int number = *((long long*)arg);

我们可以分两步写,这样会更详细一些,就像这样:

long long int *number_ptr = (long long*)arg;
long long int number = *number_ptr;

另请参阅

pthread_create()pthread_join()的手册页面中有很多有用的信息。您可以使用man 3 pthread_createman 3 pthread_join来阅读它们。

有关pthread_detach()的更多信息,请参阅man 3 pthread_detach

有关pthread_cancel()的信息,请参阅man 3 pthread_cancel

从线程中读取返回值

在这个配方中,我们将继续上一个配方。在这里,我们将从线程中获取答案作为返回值,而不是让它们自己打印结果。这就像从函数中返回值一样。

知道如何从线程中获取返回值使您能够用线程做更复杂的事情。

准备工作

为了使这个配方有意义,建议您先完成上一个配方。

您还需要我们在上一个配方中编写的 Makefile。

操作方法…

这个程序与上一个配方类似,但是每个线程不是打印自己的结果,而是将结果返回给main()。这类似于函数将值返回给main(),只是这里我们需要来回进行一些转换。这种方法的缺点是,除非我们有意将最小的数字给第一个线程,否则在两个线程都完成之前我们看不到结果。如果第一个线程有最大的数字,那么在第二个线程完成之前,即使它已经完成,我们也看不到第二个线程的结果。然而,即使我们看不到结果立即打印出来,它们仍然在两个独立的线程中进行处理,就像以前一样:

  1. 代码很长,因此被分成了几个步骤。将代码写在名为second-threaded.c的单个文件中。和往常一样,我们从头文件、函数原型和main()函数的开头开始。请注意,这里有一个额外的头文件,名为stdint.h。这是为了uintptr_t类型,我们将把返回值转换为该类型。这比转换为int更安全,因为这保证与我们转换的指针大小相同。我们还创建了两个 void 指针(prime1Returnprime2Return),我们将保存返回值。除了这些更改,其余代码都是一样的:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <stdint.h>
void *isprime(void *arg);
void *progress(void *arg);
int main(int argc, char *argv[])
{
   long long number1;
   long long number2;
   pthread_t tid_prime1;
   pthread_t tid_prime2;
   pthread_t tid_progress;
   pthread_attr_t threadattr;
   void *prime1Return;
   void *prime2Return;
   if ( argc != 3 )
   {
      fprintf(stderr, "Please supply two numbers.\n"
         "Example: %s 9 7\n", argv[0]);
      return 1;
   }
   number1 = atoll(argv[1]);
   number2 = atoll(argv[2]);
   pthread_attr_init(&threadattr);
   pthread_create(&tid_progress, &threadattr, 
      progress, NULL);  
   pthread_detach(tid_progress);
   pthread_create(&tid_prime1, &threadattr, 
      isprime, &number1);
   pthread_create(&tid_prime2, &threadattr, 
      isprime, &number2);
  1. 在下一部分中,我们将之前创建的 void 指针作为pthread_join()的第二个参数,或者实际上是这些变量的地址。这将把线程的返回值保存在这些变量中。然后,我们检查这些返回值,看看这些数字是否是质数。但由于变量是 void 指针,我们必须首先将其转换为unitptr_t类型:
   pthread_join(tid_prime1, &prime1Return);
   if (  (uintptr_t)prime1Return == 1 )
      printf("\n%lld is a prime number\n", 
         number1);
   else
      printf("\n%lld is not a prime number\n", 
         number1);

   pthread_join(tid_prime2, &prime2Return);   
   if ( (uintptr_t)prime2Return == 1 )
      printf("\n%lld is a prime number\n", 
         number2);
   else
      printf("\n%lld is not a prime number\n", 
         number2);

   pthread_attr_destroy(&threadattr);
   if ( pthread_cancel(tid_progress) != 0 )
      fprintf(stderr, 
         "Couldn't cancel progress thread\n");
   return 0;
}
  1. 然后我们像以前一样有函数。但是这次,我们返回 0 或 1,转换为 void 指针(因为函数声明的就是这样,我们不能违反):
void *isprime(void *arg)
{
   long long int number = *((long long*)arg);
   long long int j;
   int prime = 1;

   /* Test if the number is divisible, starting 
    * from 2 */
   for(j=2; j<number; j++)
   {
      /* Use the modulo operator to test if the 
       * number is evenly divisible, i.e., a 
       * prime number */
      if(number%j == 0)
         prime = 0;
   }
   if(prime == 1)
      return (void*)1;
   else
      return (void*)0;
}
void *progress(void *arg)
{
   while(1)
   {
      sleep(1);
      printf(".");
      fflush(stdout);
   }
   return NULL;
}
  1. 现在,让我们编译程序。我们仍然会收到关于未使用变量的相同警告,但这是安全的。我们知道我们没有用它做任何事情。
$> make second-threaded
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    second-threaded.c   -o second-threaded
second-threaded.c: In function 'progress':
second-threaded.c:79:22: warning: unused parameter 'arg' [-Wunused-parameter]
 void *progress(void *arg)
                ~~~~~~^~~
  1. 现在让我们尝试运行程序,首先使用更大的数字作为第一个参数,然后使用较小的数字作为第一个参数:
$> ./second-threaded 9902343047 99023117
......................................................................................
9902343047 is a prime number
99023117 is not a prime number
$> ./second-threaded 99023117 9902343047
.
99023117 is not a prime number
.......................................................................................
9902343047 is a prime number

工作原理…

这个程序的基本原理与上一个教程中的相同。不同之处在于,我们将计算结果从线程返回到main(),就像一个函数一样。但由于我们isprime()函数的返回值是一个 void 指针,我们还必须返回这种类型。为了保存返回值,我们将一个变量的地址作为pthread_join()的第二个参数传递。

由于每次调用pthread_join()都会阻塞,直到其线程完成,我们在两个线程都完成之前不会得到结果(除非我们首先给出最小的数字)。

我们在本教程中使用的新类型uintptr_t是一个特殊类型,它与无符号整数指针的大小匹配。使用常规的int可能也可以,但不能保证。

导致竞争条件

竞争条件是指多个线程(或进程)同时尝试写入同一变量的情况。由于我们不知道哪个线程会首先访问该变量,我们无法安全地预测会发生什么。两个线程都会尝试首先访问它;它们会争先访问该变量。

了解是什么导致了竞争条件将有助于避免它们,使您的程序更安全。

准备工作

在本教程中,您只需要本章第一个教程中编写的 Makefile,以及 GCC 编译器和 Make 工具。

如何做…

在本教程中,我们将编写一个导致竞争条件的程序。如果程序能正常工作,它应该在每次运行时将 1 添加到i变量,最终达到 5,000,000,000。有五个线程,每个线程都将 1 添加到 1,000,000,000。但由于所有线程几乎同时访问i变量,它永远不会达到 5,000,000,000。每次线程访问它时,它都会获取当前值并添加 1。但在此期间,另一个线程可能也读取当前值并添加 1,然后覆盖另一个线程添加的 1。换句话说,线程正在覆盖彼此的工作:

  1. 代码分为几个步骤。请注意,所有代码都放在一个文件中。将文件命名为race.c。我们将从头文件开始,i的类型为long long int。然后编写main()函数,这是相当简单的。它使用pthread_create()创建五个线程,然后使用pthread_join()等待它们完成。最后,它打印出结果变量i
#include <stdio.h>
#include <pthread.h>
void *add(void *arg);
long long int i = 0;
int main(void)
{
   pthread_attr_t threadattr;
   pthread_attr_init(&threadattr);
   pthread_t tid_add1, tid_add2, tid_add3, 
     tid_add4, tid_add5;
   pthread_create(&tid_add1, &threadattr, 
      add, NULL);
   pthread_create(&tid_add2, &threadattr, 
      add, NULL);
   pthread_create(&tid_add3, &threadattr, 
      add, NULL);
   pthread_create(&tid_add4, &threadattr, 
      add, NULL);
   pthread_create(&tid_add5, &threadattr, 
      add, NULL);
   pthread_join(tid_add1, NULL);
   pthread_join(tid_add2, NULL);
   pthread_join(tid_add3, NULL);
   pthread_join(tid_add4, NULL);
   pthread_join(tid_add5, NULL);
   printf("Sum is %lld\n", i);
   return 0;
}
  1. 现在我们编写add()函数,该函数将在线程内运行:
void *add(void *arg)
{
   for (long long int j = 1; j <= 1000000000; j++)
   {
      i = i + 1;
   }
   return NULL;
}
  1. 让我们编译程序。再次忽略警告是安全的:
$> make race
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    race.c   -o race
race.c: In function 'add':
race.c:35:17: warning: unused parameter 'arg' [-Wunused-parameter]
 void *add(void *arg)
           ~~~~~~^~~
  1. 现在,让我们尝试运行程序。我们将运行它多次。请注意,每次运行时,我们都会得到不同的值。这是因为无法预测线程的时间。但最有可能的是,它永远不会达到 5,000,000,000,这应该是正确的值。请注意,程序将需要几秒钟才能完成:
$> ./race 
Sum is 1207835374
$> ./race 
Sum is 1132939275
$> ./race 
Sum is 1204521570
  1. 目前,这个程序效率相当低。在继续使用time命令之前,我们将对程序进行计时。完成所需的时间在不同的计算机上会有所不同。在以后的教程中,我们将使程序更加高效,使互斥程序更加高效:
$> time ./race
Sum is 1188433970
real    0m20,195s
user    1m31,989s
sys     0m0,020s

工作原理…

由于所有线程同时读写同一变量,它们都会撤消彼此的工作。如果它们都按顺序运行,就像非线程化程序一样,结果将是 5,000,000,000,这正是我们想要的。

为了更好地理解这里发生了什么,让我们一步一步地来。请注意,这只是一个粗略的估计;确切的值和线程会因时间而异。

第一个线程读取i的值;假设它是 1。第二个线程也读取i,仍然是 1,因为第一个线程还没有增加值。现在第一个线程将值增加到 2 并保存到i。第二个线程也这样做;它也将值增加到 2(1+1=2)。现在,第三个线程开始并将变量i读取为 2 并将其增加到 3(2+1=3)。结果现在是 3,而不是 4。这将在程序执行过程中继续进行,并且无法预测结果将会是什么。每次程序运行时,线程的时间都会略有不同。以下图表包含了可能出现的问题的简化示例:

图 11.1 - 竞争条件的示例

图 11.1 - 竞争条件的示例

使用互斥锁避免竞争条件

互斥锁是一种锁定机制,它防止对共享变量的访问,以便不超过一个线程可以同时访问它。这可以防止竞争条件。使用互斥锁,我们只锁定代码的关键部分,例如共享变量的更新。这将确保程序的所有其他部分可以并行运行(如果这在锁定机制中是可能的)。

然而,如果我们在编写程序时不小心,互斥锁可能会大大减慢程序的速度,这将在这个食谱中看到。在下一个食谱中,我们将解决这个问题。

了解如何使用互斥锁将有助于您克服许多与竞争条件相关的问题,使您的程序更安全、更好。

准备工作

为了使这个食谱有意义,建议您先完成上一个食谱。您还需要我们在本章第一个食谱中编写的 Makefile,GCC 编译器和 Make 工具。

如何做…

这个程序建立在前一个食谱的基础上,但完整的代码在这里显示。代码分为几个步骤。但是,请记住所有的代码都放在同一个文件中。将文件命名为locking.c

  1. 我们将像往常一样从顶部开始。添加的代码已经高亮显示。首先,我们创建一个名为mutex的新变量,类型为pthread_mutex_t。这是用于锁定的变量。我们将这个变量放在全局区域,以便从main()add()都可以访问到。第二个添加的部分是初始化互斥变量,使用pthread_mutex_init()。第二个参数使用NULL表示我们希望互斥锁使用默认属性:
#include <stdio.h>
#include <pthread.h>
void *add(void *arg);
long long int i = 0;
pthread_mutex_t i_mutex;
int main(void)
{
   pthread_attr_t threadattr;
   pthread_attr_init(&threadattr);
   pthread_t tid_add1, tid_add2, tid_add3, 
     tid_add4, tid_add5;
   if ( (pthread_mutex_init(&i_mutex, NULL)) != 0 )
   {
fprintf(stderr, 
         "Couldn't initialize mutex\n");
      return 1;
   }
   pthread_create(&tid_add1, &threadattr, 
      add, NULL);
   pthread_create(&tid_add2, &threadattr, 
      add, NULL);
   pthread_create(&tid_add3, &threadattr, 
      add, NULL);
   pthread_create(&tid_add4, &threadattr, 
      add, NULL);
   pthread_create(&tid_add5, &threadattr, 
      add, NULL);
   pthread_join(tid_add1, NULL);
   pthread_join(tid_add2, NULL);
   pthread_join(tid_add3, NULL);
   pthread_join(tid_add4, NULL);
   pthread_join(tid_add5, NULL);
  1. 在我们完成计算后,我们使用pthread_mutex_destroy()销毁mutex变量:
   printf("Sum is %lld\n", i);
   if ( (pthread_mutex_destroy(&i_mutex)) != 0 )
   {
      fprintf(stderr, "Couldn't destroy mutex\n");
      return 1;
   }
   return 0;
}
  1. 最后,我们在add()函数中使用锁定和解锁机制。我们锁定更新i变量的部分,并在更新完成后解锁。这样,变量在更新进行中被锁定,以便其他线程在更新完成之前无法访问它:
void *add(void *arg)
{
   for (long long int j = 1; j <= 1000000000; j++)
   {
      pthread_mutex_lock(&i_mutex);
      i = i + 1;
      pthread_mutex_unlock(&i_mutex);
   }
   return NULL;
}
  1. 现在,让我们编译程序。像往常一样,我们可以忽略关于未使用变量的警告:
$> make locking
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    locking.c   -o locking
locking.c: In function 'add':
locking.c:47:17: warning: unused parameter 'arg' [-Wunused-parameter]
 void *add(void *arg)
           ~~~~~~^~~
  1. 现在是时候运行程序了。就像在上一个食谱中一样,我们将使用time命令计时执行。这次,计算将是正确的;最终结果将是 5,000,000,000。然而,程序将需要很长时间才能完成。在我的电脑上,需要超过 5 分钟才能完成:
$> time ./locking 
Sum is 5000000000
real    5m23,647s
user    8m24,596s
sys     16m11,407s
  1. 让我们将这个结果与一个简单的非线程程序进行比较,它使用相同的基本算法实现相同的结果。让我们将这个程序命名为non-threaded.c
#include <stdio.h>
int main(void)
{
   long long int i = 0;
   for (int x = 1; x <= 5; x++)
   {
      for (long long int j = 1; j <= 1000000000; j++)
      {
         i = i + 1;
      }
   }
   printf("Sum is %lld\n", i);
   return 0;
}
  1. 让我们编译这个程序并计时。注意这个程序执行的速度有多快,同时又获得了相同的结果:
$> make non-threaded
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    non-threaded.c   -o non-threaded
$> time ./non-threaded 
Sum is 5000000000
real    0m10,345s
user    0m10,341s
sys     0m0,000s

它是如何工作的…

线程化程序并不会自动比非线程化程序更快。我们在步骤 7中运行的非线程化程序甚至比前一个食谱中的线程化程序更快,尽管该程序甚至没有使用任何互斥锁。

那么,为什么会这样呢?

我们编写的多线程程序存在一些效率低下的问题。我们将从上一个示例中的race.c程序开始讨论问题。该程序比非多线程版本慢的原因是因为有许多小问题。例如,启动每个线程都需要一些时间(虽然很少,但仍然需要)。然后,每次仅更新全局的i变量一步也是低效的。所有线程同时访问同一个全局变量也是低效的。我们有五个线程,每个线程将其本地的j变量递增一次。每次这种情况发生时,线程都会更新全局的i变量。由于所有这些都发生了 50 亿次,所以比在单个线程中顺序运行要花费更长的时间。

然后,在本示例中的locking.c程序中,我们添加了一个互斥锁来锁定i = i + 1部分。由于这确保只有一个线程可以同时访问i变量,这使整个程序再次变成了顺序执行。而不是所有线程并行运行,以下情况发生:

  1. 运行一个线程。

  2. 锁定i = i + 1部分。

  3. 运行i = i + 1以更新i

  4. 然后解锁i = i + 1

  5. 运行下一个线程。

  6. 锁定i = i + 1部分。

  7. 运行i = i + 1以更新i

  8. 然后解锁i = i + 1

这些步骤将重复 5,000,000,000 次。每次线程启动都需要时间。然后需要额外的时间来锁定和解锁互斥锁,还需要时间来递增i变量。切换到另一个线程并重新开始整个锁定/解锁过程也需要时间。

在下一个示例中,我们将解决这些问题,使程序运行得更快。

另请参阅

有关互斥锁的更多信息,请参阅手册页man 3 pthread_mutex_initman 3 phtread_mutex_lockman 3 phthread_mutex_unlockman 3 pthread_mutex_destroy

使互斥程序更高效

在上一个示例中,我们看到多线程程序并不一定比非多线程程序快。我们还看到,当我们引入互斥锁时,程序变得非常慢。这种缓慢主要是由于来回切换、锁定和解锁数十亿次造成的。

解决所有这些锁定、解锁和来回切换的方法是尽可能少地锁定和解锁。而且,尽可能少地更新i变量,并在每个线程中尽可能多地完成工作。

在本示例中,我们将使我们的多线程程序运行得更快,更高效。

知道如何编写高效的多线程程序将帮助您避免许多线程问题。

准备工作

为了使本示例有意义,建议您完成本章中的前两个示例。除此之外,这里也有相同的要求;我们需要 Makefile、GCC 编译器和 Make 工具。

如何做…

这个程序是基于上一个示例中的locking.c程序构建的。唯一的区别是add()函数。因此,这里只显示add()函数;其余部分与locking.c相同。完整的程序可以从本章的 GitHub 目录中下载。文件名为efficient.c

  1. 复制locking.c并将新文件命名为efficient.c

  2. 重写add()函数,使其看起来像下面的代码。请注意,我们已经删除了for循环。相反,我们在while循环中递增一个本地的j变量,直到达到 10 亿。然后,我们将本地的j变量添加到全局的i变量中。这减少了我们必须锁定和解锁互斥锁的次数(从 50 亿次减少到 5 次):

void *add(void *arg)
{
   long long int j = 1;
   while(j < 1000000000)
   {
      j = j + 1;
   }
   pthread_mutex_lock(&i_mutex);
   i = i + j;
   pthread_mutex_unlock(&i_mutex);
   return NULL;
}
  1. 编译程序:
$> make efficient
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    efficient.c   -o efficient
efficient.c: In function 'add':
efficient.c:47:17: warning: unused parameter 'arg' [-Wunused-parameter]
 void *add(void *arg)
           ~~~~~~^~~
  1. 现在,让我们运行程序并使用time命令计时。请注意,这个程序运行得多快:
$ time ./efficient 
Sum is 5000000000
real    0m1,954s
user    0m8,858s
sys     0m0,004s

它是如何工作的…

这个程序比非线程化版本和第一个锁定版本都要快得多。作为执行时间的提醒,非线程化版本大约需要 10 秒才能完成;第一个线程化版本(race.c)大约需要 20 秒才能完成;第一个互斥版本(locking.c)需要超过 5 分钟才能完成。最终版本(efficient.c)只需要不到 2 秒就能完成——这是一个巨大的改进。

这个程序之所以快得多,有两个主要原因。首先,这个程序只锁定和解锁互斥锁 5 次(与上一个示例中的 5,000,000,000 次相比)。其次,每个线程现在可以在向全局变量写入任何内容之前完全完成其工作(while循环)。

简而言之,每个线程现在可以在没有任何中断的情况下完成其工作,使其真正成为线程化。只有当线程完成其工作后,它们才会将结果写入全局变量。

使用条件变量

main()使用一个条件变量来表示它已经完成,然后与该线程连接。

了解如何使用条件变量将有助于使您的线程程序更加灵活。

准备工作

为了使这个示例有意义,建议您先完成从线程中读取返回值示例。您还需要 GCC 编译器,我们在编写您的第一个线程化程序示例中编写的 Makefile 以及 Make 工具。

如何做...

在这个示例中,我们将从从线程中读取返回值示例中重新编写素数程序,以使用条件变量。完整的程序将在这里显示,但我们只讨论了这个示例的新增部分。

由于代码很长,它已经被分成了几个步骤。将代码保存在一个名为cond-var.c的文件中:

  1. 我们将像往常一样从顶部开始。在这里,我们添加了三个新变量,一个我们称为lock的互斥锁,一个我们称为ready的条件变量,以及一个用于素数线程的线程 ID,我们称为primeidprimeid变量将用于从已完成的线程发送线程 ID:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <stdint.h>
void *isprime(void *arg);
void *progress(void *arg);
pthread_mutex_t lock;
pthread_cond_t ready;
pthread_t primeid = 0;
int main(int argc, char *argv[])
{
   long long number1;
   long long number2;
   pthread_t tid_prime1;
   pthread_t tid_prime2;
   pthread_t tid_progress;
   pthread_attr_t threadattr;
   void *prime1Return;
   void *prime2Return;
  1. 然后我们必须初始化互斥锁条件变量
   if ( (pthread_mutex_init(&lock, NULL)) != 0 )
   {
      fprintf(stderr, 
         "Couldn't initialize mutex\n");
      return 1;
   }
   if ( (pthread_cond_init(&ready, NULL)) != 0 )
   {
      fprintf(stderr, 
        "Couldn't initialize condition variable\n");
      return 1;
   }
  1. 之后,我们检查参数的数量,就像以前一样。如果参数计数正确,我们就用pthread_create()启动线程,也和以前一样:
   if ( argc != 3 )
   {
      fprintf(stderr, "Please supply two numbers.\n"
         "Example: %s 9 7\n", argv[0]);
      return 1;
   }
   number1 = atoll(argv[1]);
   number2 = atoll(argv[2]);
   pthread_attr_init(&threadattr);
   pthread_create(&tid_progress, &threadattr, 
      progress, NULL);  
   pthread_detach(tid_progress);
   pthread_create(&tid_prime1, &threadattr, 
      isprime, &number1);
   pthread_create(&tid_prime2, &threadattr, 
      isprime, &number2);
  1. 现在是有趣的部分。我们将从锁定互斥锁开始,以保护primeid变量。然后,我们使用pthread_cond_wait()等待条件变量的信号。这将释放互斥锁,以便线程可以写入primeid。请注意,我们还在while循环中循环pthread_cond_wait()调用。我们这样做是因为我们只想在primeid仍然为 0 时等待信号。由于pthread_cond_wait()将阻塞,它不会使用任何 CPU 周期。当我们收到信号时,我们移动到if语句。这将检查哪个线程已经完成并加入它。然后我们回去并使用for循环重新开始。每当ifelse语句完成时——当一个线程已经加入时——primeid变量将被重置为 0。这将使下一次迭代再次等待pthread_cond_wait()
   pthread_mutex_lock(&lock);
   for (int i = 0; i < 2; i++)
   {
      while (primeid == 0)
         pthread_cond_wait(&ready, &lock);
      if (primeid == tid_prime1)
      {
         pthread_join(tid_prime1, &prime1Return);
         if (  (uintptr_t)prime1Return == 1 )
            printf("\n%lld is a prime number\n", 
               number1);
         else
            printf("\n%lld is not a prime number\n", 
               number1);
         primeid = 0;
      }
      else
      {
         pthread_join(tid_prime2, &prime2Return);   
         if ( (uintptr_t)prime2Return == 1 )
            printf("\n%lld is a prime number\n", 
               number2);
         else
            printf("\n%lld is not a prime number\n", 
               number2);
         primeid = 0;
      }
   }
   pthread_mutex_unlock(&lock);
   pthread_attr_destroy(&threadattr);
   if ( pthread_cancel(tid_progress) != 0 )
      fprintf(stderr, 
         "Couldn't cancel progress thread\n");

   return 0;
}
  1. 接下来,我们有isprime()函数。这里有一些新的行。一旦函数计算完数字,我们就锁定互斥锁以保护primeid变量。然后我们将primeid变量设置为线程的 ID。然后,我们发出条件变量(ready)的信号并释放互斥锁。这将唤醒main()函数,因为它现在正在等待pthread_cond_wait()
void *isprime(void *arg)
{
   long long int number = *((long long*)arg);
   long long int j;
   int prime = 1;

   for(j=2; j<number; j++)
   {
      if(number%j == 0)
         prime = 0;
   }
   pthread_mutex_lock(&lock);
   primeid = pthread_self();
   pthread_cond_signal(&ready);
   pthread_mutex_unlock(&lock);
   if(prime == 1)
      return (void*)1;
   else
      return (void*)0;
}
  1. 最后,我们有progress()函数。这里没有改变:
void *progress(void *arg)
{
   while(1)
   {
      sleep(1);
      printf(".");
      fflush(stdout);
   }
   return NULL;
}
  1. 现在,让我们编译程序:
$> make cond-var
gcc -Wall -Wextra -pedantic -std=c99 -lpthread    cond-var.c   -o cond-var
cond-var.c: In function 'progress':
cond-var.c:114:22: warning: unused parameter 'arg' [-Wunused-parameter]
 void *progress(void *arg)
  1. 现在让我们尝试一下这个程序。我们将用较小的数字作为第一个参数和第二个参数来测试它。无论如何,最快的计算数字都将立即显示出来,而不需要等待其他线程加入:
$> ./cond-var 990231117 9902343047
........
990231117 is not a prime number
................................................................................
9902343047 is a prime number
$> ./cond-var 9902343047 990231117
........
990231117 is not a prime number
...............................................................................
9902343047 is a prime number

它是如何工作的...

当我们在while循环中使用pthread_cond_wait()等待时,我们同时使用条件变量(ready)和互斥锁(lock)进行调用。这样,它就知道释放哪个互斥锁,等待哪个信号。就是在等待时释放互斥锁。

在等待期间,其他线程可以写入primeid变量。其他线程在写入变量之前会先用互斥锁锁定变量。一旦他们写入变量,就会发出条件变量的信号并释放互斥锁。这会唤醒main()函数,它目前正在使用pthread_cond_wait()等待。main()函数然后检查哪个线程完成了,并使用pthread_join()加入它。然后,main()函数将primeid变量重置为 0,并使用pthread_cond_wait()再次等待,直到下一个线程发出完成的信号。我们正在等待两个线程,所以main()中的for循环将运行两次。

每个线程都使用pthread_self()获得自己的线程 ID。

另请参阅

有关条件变量的更多信息,请参阅以下手册页面。

  • man 3 pthread_cond_init()

  • man 3 pthread_cond_wait()

  • man 3 pthread_cond_signal()

第十二章:调试您的程序

没有一个程序在第一次尝试时就是完美的。在本章中,我们将学习如何使用 GDB 和 Valgrind 来调试我们的程序。使用 Valgrind 这个工具,我们可以找到程序中的内存泄漏。

我们还将看看内存泄漏是什么,它们可能引起什么问题,以及如何防止它们。调试程序并查看内存是理解系统编程的重要步骤。

在本章中,我们将涵盖以下内容:

  • 启动 GDB

  • 使用 GDB 进入函数

  • 使用 GDB 调查内存

  • 在运行时修改变量

  • 在分叉程序上使用 GDB

  • 使用多线程调试程序

  • 使用 Valgrind 找到一个简单的内存泄漏

  • 使用 Valgrind 查找缓冲区溢出

技术要求

在本章中,您将需要 GBD 工具、Valgrind、GCC 编译器、通用 Makefile 和 Make 工具。

如果您还没有安装 GDB 和 Valgrind,现在可以这样做。根据您的发行版,按照以下说明进行操作。如果您没有安装sudo或没有sudo权限,您可以使用su切换到 root 用户(并省略sudo部分)。

对于 Debian 和 Ubuntu 系统,请运行以下命令:

$> sudo apt-get install gdb valgrind

对于 CentOS、Fedora 和 Red Hat 系统,请运行以下命令:

$> sudo dnf install gdb valgrind

本章的所有代码示例都可以在 GitHub 上找到:github.com/PacktPublishing/Linux-System-Programming-Techniques/tree/master/ch12

查看以下链接以查看“代码实战”视频:bit.ly/3rvAvqZ

启动 GDB

在这个教程中,我们将学习GDB,即GNU 调试器的基础知识。我们将学习如何启动 GDB,如何设置断点,以及如何逐步执行程序。我们还将学习调试符号是什么以及如何启用它们。

GDB 是 Linux 和其他类 Unix 系统中最流行的调试器。它允许您在程序运行时检查和更改变量,逐步执行指令,查看程序运行时的代码,读取返回值等等。

了解如何使用调试器可以节省您很多时间。您可以跟踪 GDB 的执行并发现错误,而不是猜测程序的问题。这可以节省您很多时间。

准备工作

对于这个教程,您将需要 GCC 编译器、Make 工具和 GDB 工具。有关 GDB 的安装说明,请参阅本章的技术要求部分。

如何做…

在这个教程中,我们将在一个正常工作的程序上使用 GDB。这里没有错误。相反,我们想专注于如何在 GDB 中做一些基本的事情:

  1. 在一个文件中编写以下简单程序,并将其保存为loop.c。稍后,我们将使用 GDB 检查程序:
#include <stdio.h>
int main(void)
{
   int x;
   int y = 5;
   char text[20] = "Hello, world";
   for (x = 1; y < 100; x++)
   {
      y = (y*3)-x;
   }
   printf("%s\n", text);
   printf("y = %d\n", y);
   return 0;
}
  1. 在我们充分利用 GDB 之前,我们需要在与 loop.c 程序相同的目录中启用Makefile。请注意,我们在CFLAGS中添加了-g选项。这些调试符号使我们能够在 GDB 中执行代码时看到代码:
CC=gcc
CFLAGS=-g -Wall -Wextra -pedantic -std=c99
  1. 现在,是时候使用我们的新 Makefile 编译程序了:
$> make loop
gcc -g -Wall -Wextra -pedantic -std=c99    loop.c   -o loop
  1. 在继续之前,让我们尝试一下程序:
$> ./loop 
Hello, world
y = 117
  1. 从与looploop.c相同的目录中,通过输入以下内容启动 GDB 并使用 loop 程序(需要源代码loop.c以在 GBD 中显示代码):
$> gdb ./loop
  1. 现在您看到了一些版权文本和版本信息。在底部,有一个提示写着(gdb)。这是我们输入命令的地方。让我们运行程序看看会发生什么。我们只需输入run并按Enter
(gdb) run
Starting program: /home/jack/ch12/code/loop 
Hello, world
y = 117
[Inferior 1 (process 10467) exited normally]
  1. 这并没有告诉我们太多;我们本可以直接从终端运行程序。所以,这次我们设置了一个include行。相反,GDB 会自动将其设置在第一个有实际代码的逻辑位置。断点是执行应该停止的代码位置,这样我们就有机会对其进行调查。
(gdb) break 1
Breakpoint 1 at 0x55555555514d: file loop.c, line 6.
  1. 现在我们可以重新运行程序。这次执行将在第 6 行(断点处)停止:
$> (gdb) run
Starting program: /home/jack/ch12/code/loop
Breakpoint 1, main () at loop.c:6
6          int y = 5;
  1. 我们可以使用watch命令开始监视y变量。GDB 会告诉我们每次y被更新时:
$> (gdb) watch y
Hardware watchpoint 2: y
  1. 现在我们可以使用next命令执行代码中的下一条语句。为了避免每次向前移动代码时都要输入next,我们可以直接按Enter。这样做会让 GDB 执行上一条命令。注意更新的y变量。还要注意到我们每走一步都能看到我们正在执行的代码:
(gdb) next
Hardware watchpoint 2: y
Old value = 0
New value = 5
main () at loop.c:7
7          char text[20] = "Hello, world";
(gdb) next
8          for (x = 1; y < 100; x++)
(gdb) next
10            y = (y*3)-x;
  1. 显示的代码行是下一个要执行的语句。所以,从上一步开始,我们看到下一个要执行的是第 10 行,即y = (y*3)-x。所以让我们在这里按Enter,这将更新y变量,并且watchpoint会告诉我们这一点:
(gdb) next
Hardware watchpoint 2: y
Old value = 5
New value = 14
main () at loop.c:8
8          for (x = 1; y < 100; x++)
(gdb) next
10            y = (y*3)-x;
(gdb) next
Hardware watchpoint 2: y
Old value = 14
New value = 40
main () at loop.c:8
8          for (x = 1; y < 100; x++)
(gdb) next
10            y = (y*3)-x;
(gdb) next
Hardware watchpoint 2: y
Old value = 40
New value = 117
8          for (x = 1; y < 100; x++)
  1. 在继续之前,让我们检查一下text字符数组和x变量的内容。我们用print命令打印变量和数组的内容。在这里我们看到text数组在实际文本之后填满了空字符
(gdb) print text
$1 = "Hello, world\000\000\000\000\000\000\000"
(gdb) print x
$2 = 3
  1. 让我们继续执行。在上一步中进程退出后,我们可以使用quit退出 GDB:
(gdb) next
12         printf("%s\n", text);
(gdb) next
Hello, world
13         printf("y = %d\n", y);
(gdb) next
y = 117
14         return 0;
(gdb) next
15      }
(gdb) next
Watchpoint 2 deleted because the program has left the block in which its expression is valid.
__libc_start_main (main=0x555555555145 <main>, argc=1, argv=0x7fffffffdbe8, 
    init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, 
    stack_end=0x7fffffffdbd8) at ../csu/libc-start.c:342
342     ../csu/libc-start.c: No such file or directory.
(gdb) next
[Inferior 1 (process 14779) exited normally]
(gdb) quit

工作原理

我们刚刚学会了 GDB 的所有基础知识。使用这些命令,我们可以进行大量的调试。还有一些东西要学,但我们已经走了很长的路。

我们使用loop程序启动了 GDB 程序。为了防止 GDB 在不调查情况下运行整个程序,我们使用break命令设置了一个断点。在我们的示例中,我们使用break 1在一行上设置了断点。也可以在特定函数上设置断点,比如main()。我们可以使用break main命令来做到这一点。

一旦断点设置好了,我们就可以用run命令运行程序。然后我们用watch监视y变量。我们使用next命令逐条执行语句。我们还学会了如何使用print命令打印变量和数组。

为了使所有这些成为可能,我们必须使用 GCC 的-g选项编译程序。这样可以启用调试符号。但是,为了在 GDB 中看到实际的代码,我们还需要源代码文件。

还有更多内容…

GDB 有一些很好的内置帮助。启动 GDB 而不加载程序。然后在(gdb)提示符下键入help。这将给您一个不同类别命令的列表。如果我们想要了解更多关于断点的信息,我们可以键入help breakpoints。这将给您一个很长的断点命令列表,例如break。要了解更多关于break命令的信息,键入help break

使用 GDB 进入函数内部

当我们在具有函数的程序中使用next命令时,它将简单地执行该函数并继续。但是,还有另一个命令叫做step,它将进入函数,逐步执行它,然后返回到main()。在这个示例中,我们将检查nextstep之间的区别。

了解如何使用 GDB 进入函数将帮助您调试整个程序,包括其函数。

准备工作

对于这个示例,您将需要 GDB 工具、GCC 编译器、本章中Starting GDB示例中编写的 Makefile 以及 Make 工具。

操作步骤

在这个示例中,我们将编写一个包含函数的小程序。然后,我们将使用step命令在 GDB 中进入该函数:

  1. 将以下代码写入文件并保存为area-of-circle.c。该程序以圆的半径作为参数,并打印其面积:
#include <stdio.h>
#include <stdlib.h>
float area(float radius);
int main(int argc, char *argv[])
{
   float number;
   float answer;
   if (argc != 2)
   {
      fprintf(stderr, "Type the radius of a "
         "circle\n");
      return 1;
   }
   number = atof(argv[1]);
   answer = area(number);
   printf("The area of a circle with a radius of "
      "%.2f is %.2f\n", number, answer);
   return 0;
}
float area(float radius)
{
   static float pi = 3.14159;
   return pi*radius*radius;
}
  1. 使用Starting GDB示例中的 Makefile 编译程序:
$> make area-of-circle
gcc -g -Wall -Wextra -pedantic -std=c99    area-of-circle.c   -o area-of-circle
  1. 在使用 GDB 逐步调试之前,让我们尝试一下:
$> ./area-of-circle 9
The area of a circle with a radius of 9.00 is 254.47
  1. 现在是时候使用 GDB 逐步执行程序了。使用area-of-circle程序启动 GDB:
$> gdb ./area-of-circle
  1. 我们首先在main()函数处设置断点:
(gdb) break main
Breakpoint 1 at 0x1164: file area-of-circle.c, line 9.
  1. 现在运行程序。在 GDB 中为程序指定参数,我们在run命令中设置参数:
(gdb) run 9
Starting program: /home/jack/ch12/code/area-of-circle 9
Breakpoint 1, main (argc=2, argv=0x7fffffffdbd8) at area-of-circle.c:9
9          if (argc != 2)
  1. 使用next命令向前移动一步:
(gdb) next
15         number = atof(argv[1]);
  1. 从上一步可以看出,要执行的下一个语句将是atof()函数。这是一个标准库函数,所以我们没有任何调试符号或源代码。因此,我们无法看到函数内部的任何东西。但是,我们仍然可以步进到它内部。一旦我们进入函数内部,我们可以让它执行并使用finish命令完成。这将告诉我们函数的返回值,这可能非常方便:
(gdb) step
atof (nptr=0x7fffffffdfed "9") at atof.c:27
27      atof.c: No such file or directory.
(gdb) finish
Run till exit from #0  atof (nptr=0x7fffffffdfed "9") at atof.c:27
main (argc=2, argv=0x7fffffffdbd8) at area-of-circle.c:15
15         number = atof(argv[1]);
Value returned is $1 = 9
  1. 现在我们再次使用next,这将带我们到我们的area函数。我们想要步进到area函数内部,所以我们在这里使用step。这将告诉我们它被调用的值是 9。由于在area函数内部没有太多要做的,只需要返回,我们可以输入finish来得到它的返回值:
(gdb) next
16         answer = area(number);
(gdb) step
area (radius=9) at area-of-circle.c:25
25         return pi*radius*radius;
(gdb) finish
Run till exit from #0  area (radius=9) at area-of-circle.c:25
0x00005555555551b7 in main (argc=2, argv=0x7fffffffdbd8) at area-of-circle.c:16
16         answer = area(number);
Value returned is $2 = 254.468796
  1. 现在,我们可以使用next来遍历程序的其余部分:
(gdb) next
17         printf("The area of a circle with a radius of "
(gdb) next
The area of a circle with a radius of 9.00 is 254.47
19         return 0;
(gdb) next
20      }
(gdb) next
__libc_start_main (main=0x555555555155 <main>, argc=2, argv=0x7fffffffdbd8, 
    init=<optimized out>, fini=<optimized out>, rtld_fini=<optimized out>, 
    stack_end=0x7fffffffdbc8) at ../csu/libc-start.c:342
342     ../csu/libc-start.c: No such file or directory.
(gdb) next
[Inferior 1 (process 2034) exited normally]
(gdb) quit

它是如何工作的...

使用step命令,我们可以步进到一个函数内部。但是,标准库中的函数没有任何调试符号或可用的源代码;因此,我们无法看到它们内部发生了什么。如果我们想要,我们可以获取源代码并使用调试符号进行编译;毕竟,Linux 是开源的。

但即使我们看不到函数内部发生了什么,步进到函数内部仍然是有价值的,因为我们可以使用finish得到它们的返回值。

使用 GDB 调查内存

使用 GDB,我们可以更多地了解事情在幕后是如何工作的,例如字符串。字符串是由空字符终止的字符数组。在这个示例中,我们将使用 GDB 调查一个字符数组,并看看空字符是如何结束一个字符串的。

了解如何使用 GDB 检查内存,如果遇到奇怪的错误,这将非常方便。我们可以直接在 GDB 中检查它们,而不是在 C 中猜测或循环遍历每个字符。

做好准备

对于这个示例,您将需要我们在开始 GDB示例中编写的 Makefile。您还需要 GCC 编译器和 Make 工具。

如何做...

在这个示例中,我们将编写一个简单的程序,用字符x填充一个字符数组。然后我们将一个新的、较短的字符串复制到上面,最后打印字符串。只有新复制的字符串被打印出来,即使所有的x字符仍然存在。使用 GDB,我们可以确认这一事实:

  1. 在文件中写入以下代码并将其保存为memtest.c
#include <stdio.h>
#include <string.h>
int main(void)
{
    char text[20];
    memset(text, 'x', 20);
    strcpy(text, "Hello");
    printf("%s\n", text);
    return 0;
}
  1. 使用开始 GDB示例中的 Makefile 编译程序:
$> make memtest
gcc -g -Wall -Wextra -pedantic -std=c99    memtest.c   -o memtest
  1. 让我们像运行其他程序一样运行它:
$> ./memtest 
Hello
  1. 让我们用我们的memtest程序启动 GDB:
$> gdb ./memtest
  1. 现在,让我们使用 GDB 检查text数组内部的内容。首先,在main()上设置一个断点,然后运行程序,并使用nextstrcpy()函数执行后向前步进。然后,在 GDB 中使用x命令进行检查(x表示检查)。我们还必须告诉 GDB 检查 20 个字节,并使用十进制表示打印内容。因此,x命令将是x/20bd text。要将十进制数解释为字符,请参阅我们在第二章中讨论的 ASCII 表,使您的程序易于脚本化,网址为github.com/PacktPublishing/B13043-Linux-System-Programming-Cookbook/blob/master/ch2/ascii-table.md
(gdb) break main
Breakpoint 1 at 0x114d: file memtest.c, line 6.
(gdb) run
Starting program: /mnt/localnas_disk2/linux-sys/ch12/code/memtest 
Breakpoint 1, main () at memtest.c:6
warning: Source file is more recent than executable.
6           memset(text, 'x', 20);
(gdb) next
7           strcpy(text, "Hello");
(gdb) next
8           printf("%s\n", text);
(gdb) x/20bd text
0x7fffffffdae0: 72   101  108  108  111  0    120  120
0x7fffffffdae8: 120  120  120  120  120  120  120  120
0x7fffffffdaf0: 120  120  120  120

它是如何工作的...

使用 GDB 检查内存时,我们使用了x命令。20bd表示我们要读取的大小为 20,我们要以字节组的形式(b)呈现它,并使用十进制表示打印内容(d)。使用这个命令,我们得到了一个漂亮的表格,显示了数组中的每个字符作为一个十进制数打印出来。

内存的内容——当转换为字符时是Hello\0xxxxxxxxxxxxxx。空字符将Hello字符串与所有x字符分隔开。通过使用 GDB 并在运行时检查内存,我们可以学到很多东西。

还有更多...

除了以十进制表示形式打印内容之外,还可以以常规字符(c)、十六进制表示形式(x)、浮点数(f)等形式打印。这些字母与printf()的用法相同。

另请参阅

您可以在 GDB 中键入help x来了解如何使用x命令。

在运行时修改变量

使用 GDB 甚至可以在运行时修改变量。这对实验非常方便。您可以使用 GDB 更改变量,而不是更改源代码并重新编译程序,然后查看发生了什么。

知道如何在运行时更改变量和数组可以加快调试和实验阶段的速度。

准备工作

对于这个配方,您需要上一节中的memtest.c程序。您还需要本章中开始使用 GDB配方中的 Makefile,Make 工具和 GCC 编译器。

如何做…

在本节中,我们将继续使用上一节的程序。在这里,我们将用另一个字符替换第六个位置的空字符,并用一个空字符替换最后一个字符:

  1. 如果您尚未编译上一节中的memtest程序,请立即这样做:
$> make memtest
gcc -g -Wall -Wextra -pedantic -std=c99    memtest.c   -o memtest
  1. 使用您刚刚编译的memtest程序启动 GDB:
$> gdb ./memtest
  1. 首先在main()处设置断点,然后运行程序。使用next向前步进到strcpy()函数之后:
(gdb) break main
Breakpoint 1 at 0x114d: file memtest.c, line 6.
(gdb) run
Starting program: /home/jack/ch12/code/memtest 
Breakpoint 1, main () at memtest.c:6
6           memset(text, 'x', 20);
(gdb) next
7           strcpy(text, "Hello");
(gdb) next
8           printf("%s\n", text);
  1. 在更改数组之前,让我们首先使用x命令打印它,就像在上一节中一样:
(gdb) x/20bd text
0x7fffffffdae0: 72   101  108  108  111  0    120  120
0x7fffffffdae8: 120  120  120  120  120  120  120  120
0x7fffffffdaf0: 120  120  120  120
  1. 现在我们知道内容是什么样的,我们可以用y替换第六个位置的空字符(实际上是第五个,我们从 0 开始计数)。我们还将最后一个位置替换为一个空字符。设置set命令:
(gdb) set text[5] = 'y'
(gdb) set text[19] = '\0'
(gdb) x/20bd text
0x7fffffffdae0: 72   101  108  108  111  121  120  120
0x7fffffffdae8: 120  120  120  120  120  120  120  120
0x7fffffffdaf0: 120  120  120  0
  1. 让我们继续运行程序的其余部分。我们可以使用continue命令让程序一直运行到结束,而不是使用next命令一步步向前。请注意,printf()函数现在将打印字符串Helloyxxxxxxxxxxxxxx
(gdb) continue
Continuing.
Helloyxxxxxxxxxxxxx
[Inferior 1 (process 4967) exited normally]
(gdb) quit

它是如何工作的…

使用 GDB 中的set命令,我们成功在运行时更改了text数组的内容。使用set命令,我们删除了第一个空字符,并在末尾插入了一个新的字符,使其成为一个长有效的字符串。由于我们在Hello后删除了空字符,printf()然后打印了整个字符串。

在分叉程序上使用 GDB

使用 GDB 调试分叉程序将自动跟踪父进程,就像普通的非分叉程序一样。但是也可以跟踪子进程,这就是我们将在本节中学习的内容。

能够跟踪子进程在调试中很重要,因为许多程序会产生子进程。我们不想局限于只有非分叉程序。

准备工作

对于这个配方,您需要本章中开始使用 GDB配方中的 Makefile,Make 工具和 GCC 编译器。

如何做…

在本节中,我们将编写一个小程序进行分叉。我们将在子进程中放置一个for循环,以确认我们是在子进程还是父进程中。在 GDB 中的第一次运行中,我们将像通常一样运行程序。这将使 GDB 跟踪父进程。然后,在下一次运行中,我们将跟踪子进程:

  1. 在文件中写入以下代码,并将其保存为forking.c。该代码类似于我们在第六章中编写的forkdemo.c程序,生成进程和使用作业控制
#include <sys/types.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
   pid_t pid;
   printf("My PID is %d\n", getpid());
   /* fork, save the PID, and check for errors */
   if ( (pid = fork()) == -1 )
   {
      perror("Can't fork");
      return 1;
   }
   if (pid == 0)
   {
      /* if pid is 0 we are in the child process */
      printf("Hello from the child process!\n");
      for(int i = 0; i<10; i++)
      {
          printf("Counter in child: %d\n", i);
      }
   }
   else if(pid > 0)
   {
      /* parent process */
      printf("My child has PID %d\n", pid);
      wait(&pid);
   }
   return 0;
}
  1. 编译程序:
$> make forking
gcc -g -Wall -Wextra -pedantic -std=c99    forking.c   -o forking
  1. 在我们在 GDB 中运行程序之前,让我们先尝试一下:
$> ./forking 
My PID is 9868
My child has PID 9869
Hello from the child process!
Counter in child: 0
Counter in child: 1
Counter in child: 2
Counter in child: 3
Counter in child: 4
Counter in child: 5
Counter in child: 6
Counter in child: 7
Counter in child: 8
Counter in child: 9
  1. 在第一次通过 GDB 运行时,我们将像通常一样运行它。这将使 GDB 自动跟踪父进程。首先使用forking程序启动 GDB:
$> gdb ./forking
  1. 像往常一样,在main()设置断点并运行。然后,我们将使用next命令向前一步,直到看到Counter in child文本。这将证明我们确实在父进程中,因为我们从未通过for循环。还要注意,GDB 告诉我们程序已经 fork 并且从子进程中分离(意味着我们在父进程中)。GDB 还打印了子进程的 PID:
(gdb) break main
Breakpoint 1 at 0x118d: file forking.c, line 9.
(gdb) run
Starting program: /home/jack/ch12/code/forking 
Breakpoint 1, main () at forking.c:9
9          printf("My PID is %d\n", getpid());
(gdb) next
My PID is 10568
11         if ( (pid = fork()) == -1 )
(gdb) next
[Detaching after fork from child process 10577]
Hello from the child process!
Counter in child: 0
Counter in child: 1
Counter in child: 2
Counter in child: 3
Counter in child: 4
Counter in child: 5
Counter in child: 6
Counter in child: 7
Counter in child: 8
Counter in child: 9
16         if (pid == 0)
(gdb) continue
Continuing.
My child has PID 10577
[Inferior 1 (process 10568) exited normally]
(gdb) quit
  1. 现在,让我们再次运行程序。但是这次,我们会告诉 GDB 跟随子进程。像之前一样用forking程序启动 GDB:
$> gdb ./forking
  1. 像之前一样,在main()设置断点。之后,我们告诉 GDB 使用set命令跟随子进程,就像之前看到的那样。只是这次,我们设置了一个叫做follow-fork-mode的东西。我们将它设置为child。然后像往常一样运行程序:
(gdb) break main
Breakpoint 1 at 0x118d: file forking.c, line 9.
(gdb) set follow-fork-mode child
(gdb) run
Starting program: /home/jack/ch12/code/forking 
Breakpoint 1, main () at forking.c:9
9          printf("My PID is %d\n", getpid());
  1. 现在,使用next命令向前移动一步两次。程序现在会 fork,并且 GDB 会告诉我们它正在附加到子进程并且从父进程中分离。这意味着我们现在在子进程中:
(gdb) next
My PID is 11561
11         if ( (pid = fork()) == -1 )
(gdb) next
[Attaching after process 11561 fork to child process 11689]
[New inferior 2 (process 11689)]
[Detaching after fork from parent process 11561]
[Inferior 1 (process 11561) detached]
My child has PID 11689
[Switching to process 11689]
main () at forking.c:11
11         if ( (pid = fork()) == -1 )
  1. 让我们再向前移动一点,看看我们最终进入了子进程中的for循环:
(gdb) next
16         if (pid == 0)
(gdb) next
19            printf("Hello from the child process!\n");
(gdb) next
Hello from the child process!
20            for(int i = 0; i<10; i++)
(gdb) next
22                printf("Counter in child: %d\n", i);
(gdb) next
Counter in child: 0
20            for(int i = 0; i<10; i++)
(gdb) next
22                printf("Counter in child: %d\n", i);
(gdb) next
Counter in child: 1
20            for(int i = 0; i<10; i++)
(gdb) next
22                printf("Counter in child: %d\n", i);
(gdb) continue
Continuing.
Counter in child: 2
Counter in child: 3
Counter in child: 4
Counter in child: 5
Counter in child: 6
Counter in child: 7
Counter in child: 8
Counter in child: 9
[Inferior 2 (process 11689) exited normally]

操作步骤如下…

使用set follow-fork-mode,我们可以告诉 GDB 在程序 fork 时跟随哪个进程。这对于调试 fork 的守护进程很方便。您可以将follow-fork-mode设置为parentchild。默认值是parent。我们不跟随的进程将继续像往常一样运行。

还有更多…

还有follow-exec-mode,它告诉 GDB 如果程序调用exec()函数要跟随哪个进程。

有关follow-exec-modefollow-fork-mode的更多信息,您可以在 GDB 中使用help set follow-exec-modehelp set follow-fork-mode命令。

使用多线程调试程序

使用 GBD 可以查看程序中的线程,并且可以在线程之间跳转。了解如何在程序中跳转线程将使多线程程序更容易调试。编写多线程程序可能很困难,但使用 GDB 可以更容易地确保它们正常工作。

准备工作

在这个示例中,我们将使用第十一章中的first-threaded.c程序,在程序中使用线程。本章的 GitHub 目录中有源代码的副本。

你还需要 GCC 编译器。

操作步骤如下…

在这个示例中,我们将使用 GDB 查看first-threaded.c程序中的线程:

  1. 让我们从编译程序开始:
$> gcc -g -Wall -Wextra -pedantic -std=c99 \
> -lpthread first-threaded.c -o first-threaded
  1. 在通过调试器运行程序之前,让我们先运行一下,回顾一下程序的工作方式:
$> ./first-threaded 990233331 9902343047
........
990233331 is not a prime number
...............................................................................
9902343047 is a prime number
Done!
  1. 现在我们知道程序如何工作,让我们在 GDB 中启动它:
$> gdb ./first-threaded
  1. 让我们像之前一样在main()设置断点。然后用相同的两个数字运行它:
(gdb) break main
Breakpoint 1 at 0x11e4: file first-threaded.c, line 17.
(gdb) run 990233331 9902343047
Starting program: /home/jack/ch12/code/first-threaded 990233331 9902343047
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
Breakpoint 1, main (argc=3, argv=0x7fffffffdbb8) at first-threaded.c:17
17         if ( argc != 3 )
  1. 现在我们使用next命令向前移动。一旦线程启动,GDB 会用文本New thread通知我们:
(gdb) next
23         number1 = atoll(argv[1]);
(gdb) next
24         number2 = atoll(argv[2]);
(gdb) next
25         pthread_attr_init(&threadattr);
(gdb) next
26         pthread_create(&tid_progress, &threadattr, 
(gdb) next
[New Thread 0x7ffff7dad700 (LWP 19182)]
28         pthread_create(&tid_prime1, &threadattr, 
(gdb) next
[New Thread 0x7ffff75ac700 (LWP 19183)]
30         pthread_create(&tid_prime2, &threadattr,
  1. 现在我们可以使用info threads命令打印当前线程的信息。注意这也会告诉我们线程当前正在执行的函数。每行上单词Thread前面的数字是 GDB 的线程 ID:
(gdb) info threads
  Id   Target Id                                          Frame 
* 1    Thread 0x7ffff7dae740 (LWP 19175) "first-threaded" main (argc=3, argv=0x7fffffffdbb8)
    at first-threaded.c:30
  2    Thread 0x7ffff7dad700 (LWP 19182) "first-threaded" 0x00007ffff7e77720 in __GI___nanosleep
    (requested_time=requested_time@entry=0x7ffff7dacea0, 
    remaining=remaining@entry=0x7ffff7dacea0) at ../sysdeps/unix/sysv/linux/nanosleep.c:28
  3    Thread 0x7ffff75ac700 (LWP 19183) "first-threaded" 0x000055555555531b in isprime (
    arg=0x7fffffffdac8) at first-threaded.c:52
  1. 现在,让我们切换到当前执行isprime函数的第 3 个线程。我们使用thread命令切换线程:
(gdb) thread 3
[Switching to thread 3 (Thread 0x7ffff75ac700 (LWP 19183))]
#0  0x000055555555531b in isprime (arg=0x7fffffffdac8) at first-threaded.c:52
52            if(number%j == 0)
  1. 在线程内部,我们可以打印变量的内容,使用next命令向前移动等。在这里我们还看到另一个线程正在启动:
(gdb) print number
$1 = 990233331
(gdb) print j
$2 = 13046
(gdb) next
.[New Thread 0x7ffff6dab700 (LWP 19978)]
47         for(j=2; j<number; j++)
(gdb) next
.52           if(number%j == 0)
(gdb) next 
.47        for(j=2; j<number; j++)
(gdb) continue
Continuing.
.........
990233331 is not a prime number
[Thread 0x7ffff75ac700 (LWP 19183) exited]
...............................................................................
9902343047 is a prime number
Done!
[Thread 0x7ffff6dab700 (LWP 19978) exited]
[Thread 0x7ffff7dad700 (LWP 19182) exited]
[Inferior 1 (process 19175) exited normally]

操作步骤如下…

就像我们可以跟踪子进程一样,我们也可以跟踪线程。虽然处理线程的方法有些不同,但仍然可以。每个线程启动后,GDB 会通知我们。然后我们可以使用info threads命令打印有关当前运行线程的信息。该命令为每个线程提供了一个线程 ID、其地址以及当前所在的帧或函数。然后我们使用thread命令跳转到线程 3。一旦我们进入线程,我们就可以打印numberj变量的内容,向代码中前进等等。

还有更多...

在 GDB 中,还有更多关于线程的操作。要查找有关线程的更多命令,可以在 GDB 中使用以下命令:

  • help thread

  • help info threads

另请参阅

关于 GDB 还有很多信息在www.gnu.org/software/gdb,所以可以查看更深入的信息。

使用 Valgrind 查找简单的内存泄漏

Valgrind是一个很棒的程序,可以找到内存泄漏和其他与内存相关的错误。它甚至可以告诉你是否在分配的内存区域中放入了太多数据。这些都是很难在没有 Valgrind 这样的工具的情况下找到的错误。即使程序泄漏内存或者在内存区域中放入了太多数据,它仍然可以长时间正常运行。这就是这些错误如此难以找到的原因。但是有了 Valgrind,我们可以检查程序是否存在各种与内存相关的问题。

入门

对于这个示例,您需要在计算机上安装 Valgrind 工具。如果您还没有安装它,可以按照本章的技术要求部分中列出的说明进行操作。

您还需要 Make 工具、GCC 编译器和开始使用 GDB示例中的 Makefile。

如何做...

在这个示例中,我们将编写一个使用calloc()分配内存但从未使用free()释放的程序。然后我们通过 Valgrind 运行程序,看看它对此有何说法:

  1. 编写以下程序,并将其保存为leak.c。首先,我们创建一个指向字符的指针。然后,我们使用calloc()分配了 20 个字节的内存,并将其地址返回给c。然后我们将一个字符串复制到该内存中,并使用printf()打印内容。但是,我们从未使用free()释放内存,这是我们应该始终要做的:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(void)
{
    char *c;
    c = calloc(sizeof(char), 20);
    strcpy(c, "Hello!");
    printf("%s\n", c);
    return 0;
}
  1. 编译程序:
$> make leak
gcc -g -Wall -Wextra -pedantic -std=c99    leak.c   -o leak
  1. 首先,我们像平常一样运行程序。一切都很顺利:
$> ./leak 
Hello!
  1. 现在,我们通过 Valgrind 运行程序。在HEAP SUMMARY下,它会告诉我们程序退出时仍有 20 个字节被分配。在LEAK SUMMARY下,我们还看到有 20 个字节明确丢失。这意味着我们忘记使用free()释放内存:
$> valgrind ./leak 
==9541== Memcheck, a memory error detector
==9541== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==9541== Using Valgrind-3.14.0 and LibVEX; rerun with -h for copyright info
==9541== Command: ./leak
==9541== 
Hello!
==9541== 
==9541== HEAP SUMMARY:
==9541==     in use at exit: 20 bytes in 1 blocks
==9541==   total heap usage: 2 allocs, 1 frees, 1,044 bytes allocated
==9541== 
==9541== LEAK SUMMARY:
==9541==    definitely lost: 20 bytes in 1 blocks
==9541==    indirectly lost: 0 bytes in 0 blocks
==9541==      possibly lost: 0 bytes in 0 blocks
==9541==    still reachable: 0 bytes in 0 blocks
==9541==         suppressed: 0 bytes in 0 blocks
==9541== Rerun with --leak-check=full to see details of leaked memory
==9541== 
==9541== For counts of detected and suppressed errors, rerun with: -v
==9541== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)
  1. 打开leak.c,在return 0;之前添加free(c);。然后重新编译程序。

  2. 在 Valgrind 中重新运行程序。这次,程序退出时不会有任何丢失或使用的字节。我们还看到有两个分配,并且它们都已被释放:

$>  valgrind ./leak 
==10354== Memcheck, a memory error detector
==10354== Copyright (C) 2002-2017, and GNU GPL'd, by Julian Seward et al.
==10354== Using Valgrind-3.14.0 and LibVEX; rerun with -h for copyright info
==10354== Command: ./leak
==10354== 
Hello!
==10354== 
==10354== HEAP SUMMARY:
==10354==     in use at exit: 0 bytes in 0 blocks
==10354==   total heap usage: 2 allocs, 2 frees, 1,044 bytes allocated
==10354== 
==10354== All heap blocks were freed -- no leaks are possible
==10354== 
==10354== For counts of detected and suppressed errors, rerun with: -v
==10354== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

它是如何工作的...

Valgrind 说我们有两个分配的原因是,尽管我们只分配了一个内存块,程序中的其他函数也分配了内存。

在 Valgrind 的输出末尾,我们还看到了文本所有堆块都已被释放,这意味着我们已经使用free()释放了所有内存。

Valgrind 并不严格要求调试符号;我们可以测试几乎任何程序是否存在内存泄漏。例如,我们可以运行valgrind cat leak.c,Valgrind 将检查cat是否存在内存泄漏。

另请参阅

Valgrind 还有很多其他用途。查看其手册页面,使用man valgrind。还有很多有用的信息在www.valgrind.org上。

使用 Valgrind 查找缓冲区溢出

Valgrind 还可以帮助我们找到缓冲区溢出。当我们在缓冲区中放入的数据超过其容量时,就会发生缓冲区溢出。缓冲区溢出是许多安全漏洞的原因,很难检测到。但是有了 Valgrind,情况会变得稍微容易一些。它可能并非始终 100%准确,但在一路上确实是一个很好的帮助。

知道如何找到缓冲区溢出将使您的程序更加安全。

准备工作

对于这个示例,您将需要 GCC 编译器,Make 工具以及本章中开始 GDB示例中的 Makefile。

如何做…

在这个示例中,我们将编写一个小程序,将过多的数据复制到缓冲区中。然后我们将通过 Valgrind 运行程序,看看它如何指出问题:

  1. 在文件中写入以下代码,并将其保存为overflow.c。程序使用calloc()分配了 20 个字节,然后将一个 26 个字节的字符串复制到该缓冲区中。然后使用free()释放内存:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(void)
{
    char *c;
    c = calloc(sizeof(char), 20);
    strcpy(c, "Hello, how are you doing?");
    printf("%s\n", c);
    free(c);
    return 0;
}
  1. 编译程序:
$> make overflow
gcc -g -Wall -Wextra -pedantic -std=c99    overflow.c   -o overflow
  1. 首先,我们像平常一样运行程序。很可能,我们不会看到任何问题。它会正常工作。这就是为什么这种类型的错误很难找到的原因:
$> ./overflow 
Hello, how are you doing
  1. 现在,让我们通过 Valgrind 运行程序,看看它对此有何看法:
c buffer, especially the text *4 bytes after a block of size 20 alloc'd*. That means that we have written 4 bytes of data *after* the 20 bytes we allocated. There are more lines like these, and they all point us toward the overflow.

它是如何工作的…

由于程序在分配的内存之外写入数据,Valgrind 将检测到它为无效写入和无效读取。我们甚至可以跟踪分配内存后写入了多少字节及其地址。这将使在代码中找到问题变得更容易。我们可能已经分配了几个缓冲区,但在这里我们清楚地看到,溢出的是 20 个字节的缓冲区。

还有更多...

为了获得更详细的输出,您可以在 Valgrind 中添加-v,例如,valgrind -v ./overflow。这将输出几页详细的输出。

posted @ 2024-05-16 19:42  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报