分类 软件开发 下的文章

进行单元测试可以提高代码质量,并且它不会打断你的工作流。

 title=

本文是 使用 CMake 和 VSCodium 设置一个构建系统 的后续文章。

在上一篇文章中我介绍了基于 VSCodiumCMake 配置构建系统。本文我将介绍如何通过 GoogleTestCTest 将单元测试集成到这个构建系统中。

首先克隆 这个仓库,用 VSCodium 打开,切换到 devops_2 标签。你可以通过点击 main 分支符号(红框处),然后选择 devops_2 标签(黄框处)来进行切换:

 title=

或者你可以通过命令行来切换:

$ git checkout tags/devops_2

GoogleTest

GoogleTest 是一个平台无关的开源 C++ 测试框架。单元测试是用来验证单个逻辑单元的行为的。尽管 GoogleTest 并不是专门用于单元测试的,我将用它对 Generator 库进行单元测试。

在 GoogleTest 中,测试用例是通过断言宏来定义的。断言可能产生以下结果:

  • 成功: 测试通过。
  • 非致命失败: 测试失败,但测试继续。
  • 致命失败: 测试失败,且测试终止。

致命断言和非致命断言通过不同的宏来区分:

  • ASSERT_*: 致命断言,失败时终止。
  • EXPECT_*: 非致命断言,失败时不终止。

谷歌推荐使用 EXPECT_* 宏,因为当测试中包含多个的断言时,它允许继续执行。断言有两个参数:第一个参数是测试分组的名称,第二个参数是测试自己的名称。Generator 只定义了 generate(...) 函数,所以本文中所有的测试都属于同一个测试组:GeneratorTest

针对 generate(...) 函数的测试可以从 GeneratorTest.cpp 中找到。

引用一致性检查

generate(...) 函数有一个 std::stringstream 的引用作为输入参数,并且它也将这个引用作为返回值。第一个测试就是检查输入的引用和返回的引用是否一致。

TEST(GeneratorTest, ReferenceCheck){
    const int NumberOfElements = 10;
    std::stringstream buffer;
    EXPECT_EQ(
        std::addressof(buffer),
        std::addressof(Generator::generate(buffer, NumberOfElements))
    );
}

在这个测试中我使用 std::addressof 来获取对象的地址,并用 EXPECT_EQ 来比较输入对象和返回对象是否是同一个。

检查元素个数

本测试检查作为输入的 std::stringstream 引用中的元素个数与输入参数中指定的个数是否相同。

TEST(GeneratorTest, NumberOfElements){
    const int NumberOfElements = 50;
    int nCalcNoElements = 0;

    std::stringstream buffer;

    Generator::generate(buffer, NumberOfElements);
    std::string s_no;

    while(std::getline(buffer, s_no, ' ')) {
        nCalcNoElements++;
    }

    EXPECT_EQ(nCalcNoElements, NumberOfElements);
}

乱序重排

本测试检查随机化引擎是否工作正常。如果连续调用两次 generate 函数,应该得到的是两个不同的结果。

TEST(GeneratorTest, Shuffle){

    const int NumberOfElements = 50;

    std::stringstream buffer_A;
    std::stringstream buffer_B;

    Generator::generate(buffer_A, NumberOfElements);
    Generator::generate(buffer_B, NumberOfElements);

    EXPECT_NE(buffer_A.str(), buffer_B.str());
}

求和校验

与前面的测试相比,这是一个大体量的测试。它检查 1 到 n 的数值序列的和与乱序重排后的序列的和是否相等。 generate(...) 函数应该生成一个 1 到 n 的乱序的序列,这个序列的和应当是不变的。

TEST(GeneratorTest, CheckSum){

    const int NumberOfElements = 50;
    int nChecksum_in = 0;
    int nChecksum_out = 0;

    std::vector<int> vNumbersRef(NumberOfElements); // Input vector
    std::iota(vNumbersRef.begin(), vNumbersRef.end(), 1); // Populate vector

    // Calculate reference checksum
    for(const int n : vNumbersRef){
        nChecksum_in += n;
    }

    std::stringstream buffer;
    Generator::generate(buffer, NumberOfElements);

    std::vector<int> vNumbersGen; // Output vector
    std::string s_no;

    // Read the buffer back back to the output vector
    while(std::getline(buffer, s_no, ' ')) {
        vNumbersGen.push_back(std::stoi(s_no));
    }

    // Calculate output checksum
    for(const int n : vNumbersGen){
        nChecksum_out += n;
    }

    EXPECT_EQ(nChecksum_in, nChecksum_out);
}

你可以像对一般 C++ 程序一样调试这些测试。

CTest

除了嵌入到代码中的测试之外,CTest 提供了可执行程序的测试方式。简而言之就是通过给可执行程序传入特定的参数,然后用 正则表达式 对它的输出进行匹配检查。通过这种方式可以很容易检查程序对于不正确的命令行参数的反应。这些测试定义在顶层的 CMakeLists.txt 文件中。下面我详细介绍 3 个测试用例:

参数正常

如果输入参数是一个正整数,程序应该输出应该是一个数列:

add_test(NAME RegularUsage COMMAND Producer 10)
set_tests_properties(RegularUsage
    PROPERTIES PASS_REGULAR_EXPRESSION "^[0-9 ]+"
)

没有提供参数

如果没有传入参数,程序应该立即退出并提示错误原因:

add_test(NAME NoArg COMMAND Producer)
set_tests_properties(NoArg
    PROPERTIES PASS_REGULAR_EXPRESSION "^Enter the number of elements as argument"
)

参数错误

当传入的参数不是整数时,程序应该退出并报错。比如给 Producer 传入参数 ABC

add_test(NAME WrongArg COMMAND Producer ABC)
set_tests_properties(WrongArg
    PROPERTIES PASS_REGULAR_EXPRESSION "^Error: Cannot parse"
)

执行测试

可以使用 ctest -R Usage -VV 命令来执行测试。这里给 ctest 的命令行参数:

  • -R <测试名称> : 执行单个测试
  • -VV:打印详细输出

测试执行结果如下:

$ ctest -R Usage -VV
UpdatecTest Configuration from :/home/stephan/Documents/cpp_testing sample/build/DartConfiguration.tcl
UpdateCTestConfiguration from :/home/stephan/Documents/cpp_testing sample/build/DartConfiguration.tcl
Test project /home/stephan/Documents/cpp_testing sample/build
Constructing a list of tests
Done constructing a list of tests
Updating test list for fixtures
Added 0 tests to meet fixture requirements
Checking test dependency graph...
Checking test dependency graph end

在这里我执行了名为 Usage 的测试。

它以无参数的方式调用 Producer

test 3
    Start 3: Usage
3: Test command: /home/stephan/Documents/cpp testing sample/build/Producer

输出不匹配 [^[0-9]+] 的正则模式,测试未通过。

3: Enter the number of elements as argument
1/1 test #3. Usage ................

Failed Required regular expression not found.
Regex=[^[0-9]+]

0.00 sec round.

0% tests passed, 1 tests failed out of 1
Total Test time (real) =
0.00 sec
The following tests FAILED:
3 - Usage (Failed)
Errors while running CTest
$

如果想要执行所有测试(包括那些用 GoogleTest 生成的),切换到 build 目录中,然后运行 ctest 即可:

 title=

在 VSCodium 中可以通过点击信息栏的黄框处来调用 CTest。如果所有测试都通过了,你会看到如下输出:

 title=

使用 Git 钩子进行自动化测试

目前为止,运行测试是开发者需要额外执行的步骤,那些不能通过测试的代码仍然可能被提交和推送到代码仓库中。利用 Git 钩子 可以自动执行测试,从而防止有瑕疵的代码被提交。

切换到 .git/hooks 目录,创建 pre-commit 文件,复制粘贴下面的代码:

#!/usr/bin/sh

(cd build; ctest --output-on-failure -j6)

然后,给文件增加可执行权限:

$ chmod +x pre-commit

这个脚本会在提交之前调用 CTest 进行测试。如果有测试未通过,提交过程就会被终止:

 title=

只有所有测试都通过了,提交过程才会完成:

 title=

这个机制也有一个漏洞:可以通过 git commit --no-verify 命令绕过测试。解决办法是配置构建服务器,这能保证只有正常工作的代码才能被提交,但这又是另一个话题了。

总结

本文提到的技术实施简单,并且能够帮你快速发现代码中的问题。做单元测试可以提高代码质量,同时也不会打断你的工作流。GoogleTest 框架提供了丰富的特性以应对各种测试场景,文中我所提到的只是一小部分而已。如果你想进一步了解 GoogleTest,我推荐你阅读 GoogleTest Primer

(题图:MJ/f212ce43-b60b-4005-b70d-8384f2ba5860)


via: https://opensource.com/article/22/1/unit-testing-googletest-ctest

作者:Stephan Avenwedde 选题:lujun9972 译者:toknow-gh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

本文章向你介绍了 Shell 脚本的基础知识以及其在日常生活中的重要性。

当我们登录到一个 UNIX/Linux 系统时,我们首先注意到的是闪烁的光标和 $ 符号之间的空格。这就是 Shell(交互界面)。多年来,它一直是一种无处不在(有时甚至是唯一的)与计算机交互的界面。在图形用户界面(GUI)出现和流行之前,终端和 Shell 是唯一的机制,可以让计算机按照我们的意图进行操作。乍一看,我们可能会想知道 Shell 的作用,除了将命令传递给底层操作系统以进行执行之外。我们中的大多数人熟悉像 ls(用于列出目录内容),cd(用于更改当前目录)等命令。通过 Shell,我们可以执行这些命令。Shell 理解我们输入的文本 - 将其转换为标记 - 然后在操作系统上执行这些标记。

不同的 Shell 变种

最初,终端使用了朴素的 Bourne Shell(即 Sh)。多年来,许多不同的 Shell 变种被开发出来和使用。其中一些流行的包括 C Shell(Csh) 和 Korn Shell(Ksh)。Sh 在一段时间内不再受欢迎,但通过其最新的化身 —— Bourne Again Shell(Bash),它再次流行起来。

Shell 实际上是做什么的?

Shell 是操作系统(OS)和用户之间的直接接口。通过使用命令和应用程序来使用计算机上安装的工具,我们可以使计算机按照我们的意愿工作。一些命令是安装在操作系统上的应用程序,而某些命令则是直接内置在 Shell 中的。在 Bash 中内置的一些命令包括 clearcdevalexec,还有 lsmkdir 这样的应用程序。内置在 Shell 中的命令因 Shell 而异。

在本文中,我们将涵盖与 Bash 相关的几个方面。

更多关于 Shell 的内容

我们中的大多数人都用过像 lscdmkdir 这样的命令。当我们在一个目录上运行 ls -l 命令时,该目录中的所有子目录和文件都会在屏幕上列出。如果数量很大,屏幕会滚动。如果终端不支持滚动条(在很多年里都是如此),则无法查看已滚动过的条目。为了克服这个问题,我们使用像 moreless 这样的命令。它们允许我们逐页查看输出。通常使用的命令是:

ls -l | less

在这里 Shell 是在做什么?看起来像是单个命令,实际上是 lsless 两个命令依次执行。管道符(|)将这两个程序连接起来,但连接由 Shell 管理。由于有了管道符,Shell 连接了这两个程序——它将 ls 命令的标准输出连接到 less 的标准输入(stdin)。管道功能使我们能够将任何程序的输出作为另一个程序的输入提供,而无需对程序进行任何更改。这是许多 UNIX/Linux 应用程序的理念——保持应用程序简单,然后将许多应用程序组合在一起以实现最终结果,而不是让一个程序做很多事情。

如果需要,我们可以将 ls 的输出重定向到文件中,然后使用 vi 查看它。为此,我们使用以下命令:

ls -l > /tmp/my_file.txt
vi /tmp/my_file.txt

在这种情况下,ls 的输出被重定向到一个文件中。这由 Shell 进行管理,它理解 > 符号表示重定向。它将其后面的标记视为文件。

使用 Shell 自动化

结合命令的能力是使用 Shell 命令创建自动化脚本的关键要素之一。在我最近的项目中,我们使用集群模式执行 Python/Spark(PySpark)应用程序。每个应用程序执行了许多结构化查询语言(SQL)语句 - SparkSQL。为了跟踪应用程序的进展,我们会打印有关正在执行的 SQL 的详细信息。这样可以让我们保留应用程序中正在发生的情况的日志。由于应用程序在集群模式下执行,要查看日志,我们必须使用以下 yarn 命令:

yarn log –applicationId [application_id]

在大多数情况下,应用程序生成的日志非常大。因此,我们通常将日志导入到 less 中,或将其重定向到一个文件中。我们使用的命令是:

yarn log –aplicationId [application_id] | less

我们的开发团队有 40 人。每个人都必须记住这个命令。为了简化操作,我将这个命令转换成了一个 Bash 脚本。为此,我创建了一个以 .sh 为扩展名的文件。在 UNIX 和 Linux 系统上,文件扩展名并不重要。只要文件是可执行的,它就能工作。扩展名在 MS Windows 上有意义。

需要记住的重要事项

Shell 是一个解释器。这意味着它会逐行读取程序并执行它。这种方法的限制在于错误(如果有)在事先无法被识别。直到解释器读取和执行它们时,错误才会被识别出来。简而言之,假如我们有一个在前 20 行完美执行,但在第 21 行由于语法错误而失败的 Shell 程序。当脚本在第 21 行失败时,Shell 不会回滚/撤销之前的步骤。当发生这样的情况时,我们必须纠正脚本并从第一行开始执行。因此,例如,如果在遇到错误之前删除了几个文件,脚本的执行将停止,而文件将永远消失。

我创建的脚本是:

#!/bin/bash
yarn log –applicationId 123 | less

…其中 123 是应用程序的 ID。

第一行的前两个字符是特殊字符(“释伴”)。它们告诉脚本这是一个可执行文件,并且该行包含要用于执行的程序的名称。脚本的其余行传递给所提到的程序。在这个例子中,我们将执行 Bash。即使包含了第一行,我们仍然必须使用以下命令对文件应用执行权限:

chmod +x my_file.sh

在给文件设置了执行权限之后,我们可以如下执行它:

./my_file.sh

如果我们没有给文件设置执行权限,我们可以使用以下命令执行该脚本:

sh ./my_file.sh

传递参数

你很快就会意识到,这样的脚本很方便,但立即变得无用。每次执行 Python/Spark 应用程序时,都会生成一个新的 ID。因此,对于每次运行,我们都必须编辑文件并添加新的应用程序 ID。这无疑降低了脚本的可用性。为了提高其可用性,我们应该将应用程序 ID 作为参数传递:

#!/bin/bash
yarn –log -applicationId ${1} | less

我们需要这样执行脚本:

./show_log.sh 123

脚本将执行 yarn 命令,获取应用程序的日志并允许我们查看它。

如果我们想将输出重定向到一个文件中怎么办?没问题。我们可以将输出重定向到一个文件而不是发送给 less

#!/bin/bash
ls –l ${1} > ${2}
view ${2}

要运行脚本,我们需要提供两个参数,命令变为:

./my_file.sh /tmp /tmp/listing.txt

当执行时,$1 将绑定到 /tmp$2 将绑定到 /tmp/listing.txt。对于 Shell,参数从一到九命名。这并不意味着我们不能将超过九个参数传递给脚本。我们可以,但这是另一篇文章的主题。你会注意到,我将参数命名为 ${1}${2},而不是 $1$2。将参数名称封闭在花括号中是一个好习惯,因为它使我们能够无歧义地将参数作为较长变量的一部分组合起来。例如,我们可以要求用户将文件名作为参数,并使用它来形成一个更大的文件名。例如,我们可以将 $1 作为参数,创建一个新的文件名为 ${1}_student_names.txt

使脚本更健壮

如果用户忘记提供参数怎么办?Shell 允许我们检查这种情况。我们将脚本修改如下:

#!/bin/bash
if [ -z "${2}" ]; then
  echo "file name not provided"
  exit 1
fi
if [ -z "${1}" ]; then
  echo "directory name not provided"
  exit 1
fi
DIR_NAME=${1}
FILE_NAME=${2}
ls -l ${DIR_NAME} > /tmp/${FILE_NAME}
view /tmp/${FILE_NAME}

在这个程序中,我们检查是否传递了正确的参数。如果未传递参数,我们将退出脚本。你会注意到,我以相反的顺序检查参数。如果我们在检查第一个参数存在之前检查第二个参数的存在,如果只传递了一个参数,脚本将进行到下一步。虽然可以按递增顺序检查参数的存在,但我最近意识到,从九到一检查会更好,因为我们可以提供适当的错误消息。你还会注意到,参数已分配给变量。参数一到九是位置参数。将位置参数分配给具名参数可以在出现问题时更容易调试脚本。

自动化备份

我自动化的另一个任务是备份。在开发的初期阶段,我们没有使用版本控制系统。但我们需要有一个机制来定期备份。因此,最好的方法是编写一个 Shell 脚本,在执行时将所有代码文件复制到一个单独的目录中,将它们压缩,并使用日期和时间作为后缀来上传到 HDFS。我知道,这种方法不如使用版本控制系统那样清晰,因为我们存储了完整的文件,查找差异仍然需要使用像 diff 这样的程序;但它总比没有好。尽管我们最终没有删除代码文件,但团队确实删除了存储助手脚本的 bin 目录!!!而且对于这个目录,我没有备份。我别无选择,只能重新创建所有的脚本。

一旦建立了源代码控制系统,我很容易将备份脚本扩展到除了之前上传到 HDFS 的方法之外,还可以将文件上传到版本控制系统。

总结

如今,像 Python、Spark、Scala 和 Java 这样的编程语言很受欢迎,因为它们用于开发与人工智能和机器学习相关的应用程序。尽管与 Shell 相比,这些语言更强大,但“不起眼”的 Shell 提供了一个即用即得的平台,让我们能够创建辅助脚本来简化我们的日常任务。Shell 是相当强大的,尤其是因为我们可以结合操作系统上安装的所有应用程序的功能。正如我在我的项目中发现的那样,即使经过了几十年,Shell 脚本仍然非常强大。我希望我已经说服你尝试一下了。

最后一个例子

Shell 脚本确实非常方便。考虑以下命令:

spark3-submit --queue pyspark --conf "[email protected]" --conf "spark.yarn.keytab=/keytabs/abcd.keytab" --jars /opt/custom_jars/abcd_1.jar --deploy-mode cluster --master yarn $*

我们要求在执行 Python/Spark 应用程序时使用此命令。现在想象一下,这个命令必须每天被一个由 40 个人组成的团队多次使用。大多数人会在记事本中复制这个命令,每次需要使用时,会将其从记事本中复制并粘贴到终端中。如果复制粘贴过程中出现错误怎么办?如果有人错误使用了参数怎么办?我们如何调试使用的是哪个命令?查看历史记录并没有太多帮助。

为了让团队能够简单地执行 Python/Spark 应用程序,我们可以创建一个 Bash Shell 脚本,如下所示:

#!/bin/bash
[email protected]
KEYTAB_PATH=/keytabs/abcd.keytab
MY_JARS=/opt/custom_jars/abcd_1.jar
MAX_RETRIES=128
QUEUE=pyspark
MASTER=yarn
MODE=cluster

spark3-submit --queue ${QUEUE} --conf "spark.yarn.principal=${SERVICE_PRINCIPAL}" --conf "spark.yarn.keytab=${KEYTAB_PATH}" --jars ${MY_JARS} --deploy-mode ${MODE} --master ${MASTER} $*

这展示了一个 Shell 脚本的强大之处,让我们的生活变得简单。根据你的需求,你可以尝试更多的命令和脚本,并进一步探索。

(题图:MJ/f32880e8-0cdc-4897-8a1c-242c131111bf)


via: https://www.opensourceforu.com/2022/05/shell-scripting-is-still-going-strong/

作者:Bipin Patwardhan 选题:lkxed 译者:ChatGPT 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在大多数编程语言中,你都会找到字符串数据类型。字符串基本上是一组字符。

但 Bash Shell 有所不同。字符串没有单独的数据类型。这里一切都是变量。

但这并不意味着你不能像在 C 和其他编程语言中那样处理字符串。

在 Bash Shell 中可以查找子字符串、替换子字符串、连接字符串以及更多字符串操作。

在 Bash 基础知识系列的这一部分中,你将学习基本的字符串操作。

在 Bash 中获取字符串长度

让我们从最简单的选项开始。也就是获取字符串的长度。这很简单:

${#string}

让我们在示例中使用它。

Example of getting string length in bash

正如你所看到的,第二个示例中有两个单词,但由于它用引号引起来,因此它被视为单个单词。连空格都算作一个字符。

在 Bash 中连接字符串

用技术术语来说是字符串 连接 concatenation ,这是 Bash 中最简单的字符串操作之一。

你只需像这样一个接一个地使用字符串变量:

str3=$str1$str2

还能比这更简单吗?我觉得不能。

让我们看一个例子。这是我的示例脚本,名为 join.sh

#!/bin/bash

read -p "Enter first string: " str1
read -p "Enter second string: " str2

joined=$str1$str2

echo "The joined string is: $joined"

以下是该脚本的运行示例:

Join two strings in bash

在 Bash 中提取子字符串

假设你有一个包含多个字符的大字符串,并且你想要提取其中的一部分。

要提取子字符串,需要指定主字符串、子字符串的起始位置和子字符串的长度,如下所示:

${string:$pos:$len}
? 和数组一样,字符串中的定位也是从 0 开始。

这是一个例子:

Extracting substring in bash

即使你指定的子字符串长度大于字符串长度,它也只会到达字符串末尾。

替换 Bash 中的子字符串

假设你有一个大字符串,并且你想用另一个字符串替换其中的一部分。

在这种情况下,你可以使用这种语法:

${string/substr1/substr2}
✋ 只有第一次出现的子字符串才会以这种方式替换。如果要替换所有出现的地方,请使用 ${string//substr1/substr2}

这是一个例子:

Replace substring in bash

正如你在上面看到的,“good” 一词被替换为 “best”。我将替换的字符串保存到同一字符串中以更改原始字符串。

? 如果未找到子字符串,则不会替换任何内容。它不会导致错误。

在 Bash 中删除子字符串

我们来谈谈删除子字符串。假设你要删除字符串的一部分。在这种情况下,只需将子字符串提供给主字符串,如下所示:

${string/substring}
✋ 通过这种方式,仅删除第一次出现的子字符串。如果要删除所有出现的内容,请使用 ${string//substr}

如果找到子字符串,则将从字符串中删除它。

让我们通过一个例子来看看。

Delete substring in bash

不用说,如果没有找到子字符串,则不会删除它。它不会导致错误。

?️ 练习时间

现在是你通过简单练习来实践字符串操作的时候了。

练习 1:声明一个字符串 “I am all wet”。现在通过用 “set” 替换单词 “wet” 来更改此字符串。

练习 2:创建一个字符串,以 112-123-1234 格式保存电话号码。现在,你必须删除所有 -

这应该会给你一些在 Bash 中使用字符串的不错的练习。在下一章中,你将学习如何在 Bash 中使用 if-else 语句。敬请关注。

(题图:MJ/aa73b2c9-6d2f-42e2-972d-94fab56d30cc)


via: https://itsfoss.com/bash-strings/

作者:Abhishek Prakash 选题:lkxed 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

从调试器中获取函数调用关系。

在我的 上一篇文章 中,我展示了如何使用 debuginfo 在当前指令指针(IP)和包含它的函数或行之间进行映射。该信息对于显示 CPU 当前正在执行的代码很有帮助。不过,如果能显示更多的有关当前函数调用栈及其正在执行语句的上下文对我们定位问题来说也是十分有助的。

例如,将空指针作为参数传递到函数中而导致非法内存访问的问题,只需查看当前执行函数行,即可发现该错误是由尝试通过空指针进行访问而触发的。但是,你真正想知道的是导致空指针访问的函数调用的完整上下文,以便确定该空指针最初是如何传递到该函数中的。此上下文信息由回溯提供,可以让你确定哪些函数可能对空指针参数负责。

有一点是肯定的:确定当前活动的函数调用栈不是一项简单的操作。

函数激活记录

现代编程语言具有局部变量,并允许函数可以调用自身的递归。此外,并发程序具有多个线程,这些线程可能同时运行相同的函数。在这些情况下,局部变量不能存储在全局位置。对于函数的每次调用,局部变量的位置必须是唯一的。它的工作原理如下:

  • 每次调用函数时,编译器都会生成函数激活记录,以将局部变量存储在唯一位置。
  • 为了提高效率,处理器堆栈用于存储函数激活记录。
  • 当函数被调用时,会在处理器堆栈的顶部为该函数创建一条新的函数激活记录。
  • 如果该函数调用另一个函数,则新的函数激活记录将放置在现有函数激活记录之上。
  • 每次函数返回时,其函数激活记录都会从堆栈中删除。

函数激活记录的创建是由函数中称为“ 序言 prologue ”的代码创建的。函数激活记录的删除由函数“ 尾声 epilogue ”处理。函数体可以利用堆栈上为其预留的内存来存储临时值和局部变量。

函数激活记录的大小可以是可变的。对于某些函数,不需要空间来存储局部变量。理想情况下,函数激活记录只需要存储调用 函数的函数的返回地址。对于其他函数,除了返回地址之外,可能还需要大量空间来存储函数的本地数据结构。帧大小的可变导致编译器使用帧指针来跟踪函数激活帧的开始。函数序言代码具有在为当前函数创建新帧指针之前存储旧帧指针的额外任务,并且函数尾声必须恢复旧帧指针值。

函数激活记录的布局方式、调用函数的返回地址和旧帧指针是相对于当前帧指针的恒定偏移量。通过旧的帧指针,可以定位堆栈上下一个函数的激活帧。重复此过程,直到检查完所有函数激活记录为止。

优化复杂性

在代码中使用显式帧指针有几个缺点。在某些处理器上,可用的寄存器相对较少。具有显式帧指针会导致使用更多内存操作。生成的代码速度较慢,因为帧指针必须位于寄存器中。具有显式帧指针可能会限制编译器可以生成的代码,因为编译器可能不会将函数序言和尾声代码与函数体混合。

编译器的目标是尽可能生成快速代码,因此编译器通常会从生成的代码中省略帧指针。正如 Phoronix 的基准测试 所示,保留帧指针会显着降低性能。不过省略帧指针也有缺点,查找前一个调用函数的激活帧和返回地址不再是相对于帧指针的简单偏移。

调用帧信息

为了帮助生成函数回溯,编译器包含 DWARF 调用帧信息(CFI)来重建帧指针并查找返回地址。此补充信息存储在执行的 .eh_frame 部分中。与传统的函数和行位置信息的 debuginfo 不同,即使生成的可执行文件没有调试信息,或者调试信息已从文件中删除,.eh_frame 部分也位于可执行文件中。 调用帧信息对于 C++ 中的 throw-catch 等语言结构的操作至关重要。

CFI 的每个功能都有一个帧描述条目(FDE)。作为其步骤之一,回溯生成过程为当前正在检查的激活帧找到适当的 FDE。将 FDE 视为一张表,每一行代表一个或多个指令,并具有以下列:

  • 规范帧地址(CFA),帧指针指向的位置
  • 返回地址
  • 有关其他寄存器的信息

FDE 的编码旨在最大限度地减少所需的空间量。FDE 描述了行之间的变化,而不是完全指定每一行。为了进一步压缩数据,多个 FDE 共有的起始信息被分解出来并放置在通用信息条目(CIE)中。 这使得 FDE 更加紧凑,但也需要更多的工作来计算实际的 CFA 并找到返回地址位置。该工具必须从未初始化状态启动。它逐步遍历 CIE 中的条目以获取函数条目的初始状态,然后从 FDE 的第一个条目开始继续处理 FDE,并处理操作,直到到达覆盖当前正在分析的指令指针的行。

调用帧信息使用实例

从一个简单的示例开始,其中包含将华氏温度转换为摄氏度的函数。 内联函数在 CFI 中没有条目,因此 f2c 函数的 __attribute__((noinline)) 确保编译器将 f2c 保留为真实函数。

#include <stdio.h>

int __attribute__ ((noinline)) f2c(int f)
{
    int c;
    printf("converting\n");
    c = (f-32.0) * 5.0 /9.0;
    return c;
}

int main (int argc, char *argv[])
{
    int f;
    scanf("%d", &f);
    printf ("%d Fahrenheit = %d Celsius\n",
            f, f2c(f));
    return 0;
}

编译代码:

$ gcc -O2 -g -o f2c f2c.c

.eh_frame 部分展示如下:

$ eu-readelf -S f2c |grep eh_frame
[17] .eh_frame_hdr  PROGBITS   0000000000402058 00002058 00000034  0 A  0   0  4
[18] .eh_frame      PROGBITS   0000000000402090 00002090 000000a0  0 A  0   0  8

我们可以通过以下方式获取 CFI 信息:

$ readelf --debug-dump=frames  f2c > f2c.cfi

生成 f2c 可执行文件的反汇编代码,这样你可以查找 f2cmain 函数:

$ objdump -d f2c > f2c.dis

f2c.dis 中找到以下信息来看看 f2cmain 函数的执行位置:

0000000000401060 <main>:
0000000000401190 <f2c>:

在许多情况下,二进制文件中的所有函数在执行函数的第一条指令之前都使用相同的 CIE 来定义初始条件。 在此示例中, f2cmain 都使用以下 CIE:

00000000 0000000000000014 00000000 CIE
  Version:                   1
  Augmentation:              "zR"
  Code alignment factor: 1
  Data alignment factor: -8
  Return address column: 16
  Augmentation data:         1b
  DW_CFA_def_cfa: r7 (rsp) ofs 8
  DW_CFA_offset: r16 (rip) at cfa-8
  DW_CFA_nop
  DW_CFA_nop

本示例中,不必担心增强或增强数据条目。由于 x86\_64 处理器具有 1 到 15 字节大小的可变长度指令,因此 “代码对齐因子” 设置为 1。在只有 32 位(4 字节指令)的处理器上,“代码对齐因子” 设置为 4,并且允许对一行状态信息适用的字节数进行更紧凑的编码。类似地,还有 “数据对齐因子” 来使 CFA 所在位置的调整更加紧凑。在 x86\_64 上,堆栈槽的大小为 8 个字节。

虚拟表中保存返回地址的列是 16。这在 CIE 尾部的指令中使用。 有四个 DW_CFA 指令。第一条指令 DW_CFA_def_cfa 描述了如果代码具有帧指针,如何计算帧指针将指向的规范帧地址(CFA)。 在这种情况下,CFA 是根据 r7 (rsp)CFA=rsp+8 计算的。

第二条指令 DW_CFA_offset 定义从哪里获取返回地址 CFA-8 。在这种情况下,返回地址当前由堆栈指针 (rsp+8)-8 指向。CFA 从堆栈返回地址的正上方开始。

CIE 末尾的 DW_CFA_nop 进行填充以保持 DWARF 信息的对齐。 FDE 还可以在末尾添加填充以进行对齐。

f2c.cfi 中找到 main 的 FDE,它涵盖了从 0x40160 到(但不包括)0x401097main 函数:

00000084 0000000000000014 00000088 FDE cie=00000000 pc=0000000000401060..0000000000401097
  DW_CFA_advance_loc: 4 to 0000000000401064
  DW_CFA_def_cfa_offset: 32
  DW_CFA_advance_loc: 50 to 0000000000401096
  DW_CFA_def_cfa_offset: 8
  DW_CFA_nop

在执行函数中的第一条指令之前,CIE 描述调用帧状态。然而,当处理器执行函数中的指令时,细节将会改变。 首先,指令 DW_CFA_advance_locDW_CFA_def_cfa_offsetmain401060 处的第一条指令匹配。 这会将堆栈指针向下调整 0x18(24 个字节)。 CFA 没有改变位置,但堆栈指针改变了,因此 CFA 在 401064 处的正确计算是 rsp+32。 这就是这段代码中序言指令的范围。 以下是 main 中的前几条指令:

0000000000401060 <main>:
  401060:    48 83 ec 18      sub        $0x18,%rsp
  401064:    bf 1b 20 40 00   mov        $0x40201b,%edi

DW_CFA_advance_loc 使当前行应用于函数中接下来的 50 个字节的代码,直到 401096。CFA 位于 rsp+32,直到 401092 处的堆栈调整指令完成执行。DW_CFA_def_cfa_offset 将 CFA 的计算更新为与函数入口相同。这是预期之中的,因为 401096 处的下一条指令是返回指令 ret,并将返回值从堆栈中弹出。

  401090:    31 c0        xor        %eax,%eax
  401092:    48 83 c4 18  add        $0x18,%rsp
  401096:    c3           ret

f2c 函数的 FDE 使用与 main 函数相同的 CIE,并覆盖 0x411900x4011c3 的范围:

00000068 0000000000000018 0000006c FDE cie=00000000 pc=0000000000401190..00000000004011c3
  DW_CFA_advance_loc: 1 to 0000000000401191
  DW_CFA_def_cfa_offset: 16
  DW_CFA_offset: r3 (rbx) at cfa-16
  DW_CFA_advance_loc: 29 to 00000000004011ae
  DW_CFA_def_cfa_offset: 8
  DW_CFA_nop
  DW_CFA_nop
  DW_CFA_nop

可执行文件中 f2c 函数的 objdump 输出:

0000000000401190 <f2c>:
  401190:    53                       push   %rbx
  401191:    89 fb                    mov    %edi,%ebx
  401193:    bf 10 20 40 00           mov    $0x402010,%edi
  401198:    e8 93 fe ff ff           call   401030 <puts@plt>
  40119d:    66 0f ef c0              pxor   %xmm0,%xmm0
  4011a1:    f2 0f 2a c3              cvtsi2sd %ebx,%xmm0
  4011a5:    f2 0f 5c 05 93 0e 00     subsd  0xe93(%rip),%xmm0        # 402040 <__dso_handle+0x38>
  4011ac:    00 
  4011ad:    5b                       pop    %rbx
  4011ae:    f2 0f 59 05 92 0e 00     mulsd  0xe92(%rip),%xmm0        # 402048 <__dso_handle+0x40>
  4011b5:    00 
  4011b6:    f2 0f 5e 05 92 0e 00     divsd  0xe92(%rip),%xmm0        # 402050 <__dso_handle+0x48>
  4011bd:    00 
  4011be:    f2 0f 2c c0              cvttsd2si %xmm0,%eax
  4011c2:    c3                       ret

f2c 的 FDE 中,函数开头有一个带有 DW_CFA_advance_loc 的单字节指令。在高级操作之后,还有两个附加操作。DW_CFA_def_cfa_offset 将 CFA 更改为 %rsp+16DW_CFA_offset 表示 %rbx 中的初始值现在位于 CFA-16(堆栈顶部)。

查看这个 fc2 反汇编代码,可以看到 push 用于将 %rbx 保存到堆栈中。 在代码生成中省略帧指针的优点之一是可以使用 pushpop 等紧凑指令在堆栈中存储和检索值。 在这种情况下,保存 %rbx 是因为 %rbx 用于向 printf 函数传递参数(实际上转换为 puts 调用),但需要保存传递到函数中的 f 初始值以供后面的计算使用。4011aeDW_CFA_advance_loc 29字节显示了 pop %rbx 之后的下一个状态变化,它恢复了 %rbx 的原始值。 DW_CFA_def_cfa_offset 指出 pop 将 CFA 更改为 %rsp+8

GDB 使用调用帧信息

有了 CFI 信息,GNU 调试器(GDB) 和其他工具就可以生成准确的回溯。如果没有 CFI 信息,GDB 将很难找到返回地址。如果在 f2c.c 的第 7 行设置断点,可以看到 GDB 使用此信息。GDB在 f2c 函数中的 pop %rbx 完成且返回值不在栈顶之前放置了断点。

GDB 能够展开堆栈,并且作为额外收获还能够获取当前保存在堆栈上的参数 f

$ gdb f2c
[...]
(gdb) break f2c.c:7
Breakpoint 1 at 0x40119d: file f2c.c, line 7.
(gdb) run
Starting program: /home/wcohen/present/202207youarehere/f2c
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib64/libthread_db.so.1".
98
converting

Breakpoint 1, f2c (f=98) at f2c.c:8
8            return c;
(gdb) where
#0  f2c (f=98) at f2c.c:8
#1  0x000000000040107e in main (argc=<optimized out>, argv=<optimized out>)
        at f2c.c:15

调用帧信息

DWARF 调用帧信息为编译器提供了一种灵活的方式来包含用于准确展开堆栈的信息。这使得可以确定当前活动的函数调用。我在本文中提供了简要介绍,但有关 DWARF 如何实现此机制的更多详细信息,请参阅 DWARF 规范

(题图:MJ/4004d7c7-8407-40bd-8aa8-92404601dba0)


via: https://opensource.com/article/23/3/gdb-debugger-call-frame-active-function-calls

作者:Will Cohen 选题:lkxed 译者:jrglinux 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

本章将介绍如何在 Bash Shell 脚本中使用数组。学习添加元素、删除元素和获取数组长度。

在本系列的前面部分中,你了解了变量。变量中可以有单个值。

数组内部可以有多个值。当你必须一次处理多个变量时,这会使事情变得更容易。你不必将各个值存储在新变量中。

因此,不要像这样声明五个变量:

distro1=Ubuntu
distro2=Fedora
distro3=SUSE
distro4=Arch Linux
distro5=Nix

你可以在单个数组中初始化它们所有:

distros=(Ubuntu Fedora SUSE "Arch Linux" Nix)

与其他一些编程语言不同,你不使用逗号作为数组元素分隔符。

那挺好的。让我们看看如何访问数组元素。

在 Bash 中访问数组元素

使用索引(数组中的位置)访问数组元素。要访问索引 N 处的数组元素,请使用:

${array_name[N]}
? 与大多数其他编程语言一样,Bash Shell 中的数组从索引 0 开始。这意味着第一个元素的索引为 0,第二个元素的索引为 1,第 n 个元素的索引为 n-1

因此,如果你想打印 SUSE,你将使用:

echo ${distros[2]}

Example of accessing array elements in bash shell

? ${ 之后或 } 之前不能有任何空格。你不能像 ${ array[n] } 那样使用它。

一次访问所有数组元素

假设你要打印数组的所有元素。

你可以一一使用 echo ${array[n]} 但这确实没有必要。有一个更好更简单的方法:

${array[*]}

这将为你提供所有数组元素。

Accessing all array elements at once in bash shell

在 Bash 中获取数组长度

如何知道数组中有多少个元素? 有一个专门的方法 在 Bash 中获取数组长度

${#array_name[@]}

就这么简单,对吧?

Get array length in bash

在 Bash 中添加数组元素

如果必须向数组添加其他元素,请使用 += 运算符 将元素追加到 Bash 中的现有数组

array_name+=("new_value")

这是一个例子:

Append new element to array

? 追加元素时使用 () 很重要。

你还可以使用索引将元素设置在任何位置。

array_name[N]=new_value

但请记住使用正确的索引编号。 如果在现有索引上使用它,新值将替换该元素。

如果你使用“越界”索引,它仍会添加到最后一个元素之后。例如,如果数组长度为 6,并且你尝试在索引 9 处设置新值,则该值仍将作为最后一个元素添加到第 7 个位置(索引 6)。

删除数组元素

你可以使用 Shell 内置的 unset 通过提供索引号来删除数组元素:

unset array_name[N]

这是一个示例,我删除了数组的第四个元素。

Delete array element in bash

你还可以通过 unset 来删除整个数组:

unset array_name
? Bash 中没有严格的数据类型规则。你可以创建一个同时包含整数和字符串的数组。

?️ 练习时间

让我们练习一下你所学到的有关 Bash 数组的知识。

练习 1:创建一个 Bash 脚本,其中包含五个最佳 Linux 发行版的数组。全部打印出来。

现在,用 “Hannah Montanna Linux” 替换中间的选择。

练习 2:创建一个 Bash 脚本,该脚本接受用户提供的三个数字,然后以相反的顺序打印它们。

预期输出:

Enter three numbers and press enter
12 23 44
Numbers in reverse order are: 44 23 12

我希望你喜欢通过本系列学习 Bash Shell 脚本。在下一章中,你将学习如何使用 if-else。敬请关注。

(题图:MJ/09477e2f-2bf9-4fdf-bc1e-c894a068adf2)


via: https://itsfoss.com/bash-arrays/

作者:Abhishek Prakash 选题:lkxed 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

getline() 提供了一种更灵活的方法,可以在不破坏系统的情况下将用户数据读入程序。

在 C 语言中读取字符串是一件非常危险的事情。当读取用户输入时,程序员可能会尝试使用 C 标准库中的 gets 函数。它的用法非常简单:

char *gets(char *string);

gets() 从标准输入读取数据,然后将结果存储在一个字符串变量中。它会返回一个指向字符串的指针,如果没有读取到内容,返回 NULL 值。

举一个简单的例子,我们可能会问用户一个问题,然后将结果读入字符串中:

#include <stdio.h>
#include <string.h>

int main()
{
  char city[10]; // 例如 "Chicago"

  // 这种方法很糟糕 .. 不要使用 gets

  puts("Where do you live?");
  gets(city);

  printf("<%s> is length %ld\n", city, strlen(city));

  return 0;
}

输入一个相对较短的值就可以:

Where do you live?
Chicago
<Chicago> is length 7

然而,gets() 函数非常简单,它会天真地读取数据,直到它认为用户完成为止。但是它不会检查字符串是否足够容纳用户的输入。输入一个非常长的值会导致 gets() 存储的数据超出字符串变量长度,从而导致覆盖其他部分内存。

Where do you live?
Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch
<Llanfairpwllgwyngyllgogerychwyrndrobwllllantysiliogogogoch> is length 58
Segmentation fault (core dumped)

最好的情况是,覆盖部分只会破坏程序。最坏的情况是,这会引入一个严重的安全漏洞,恶意用户可以通过你的程序将任意数据插入计算机的内存中。

这就是为什么在程序中使用 gets() 函数是危险的。使用 gets(),你无法控制程序尝试从用户读取多少数据,这通常会导致缓冲区溢出。

安全的方法

fgets() 函数历来是安全读取字符串的推荐方法。此版本的 gets() 提供了一个安全检查,通过仅读取作为函数参数传递的特定数量的字符:

char *fgets(char *string, int size, FILE *stream);

fgets() 函数会从文件指针读取数据,然后将数据存储到字符串变量中,但最多只能达到 size 指定的长度。我们可以更新示例程序来测试这一点,使用 fgets() 而不是 gets()

#include <stdio.h>
#include <string.h>

int main()
{
    char city[10]; // 例如 "Chicago"

    puts("Where do you live?");

    // fgets 虽好但是并不完美
    fgets(city, 10, stdin);

    printf("<%s> is length %ld\n", city, strlen(city));

    return 0;
}

如果编译运行,你可以在提示符后输入任意长的城市名称。但是,程序只会读取 size = 10 数据存储到字符串变量中。因为 C 语言在字符串末尾会添加一个空(\0)字符,这意味着 fgets() 只会读取 9 个字符到字符串中。

Where do you live?
Minneapolis
<Minneapol> is length 9

虽然这肯定比 fgets() 读取用户输入更安全,但代价是如果用户输入过长,它会“切断”用户输入。

新的安全方法

更灵活的解决方案是,如果用户输入的数据比变量可能容纳的数据多,则允许字符串读取函数为字符串分配更多内存。根据需要调整字符串变量大小,确保程序始终有足够的空间来存储用户输入。

getline() 函数正是这样。它从输入流读取输入,例如键盘或文件,然后将数据存储在字符串变量中。但与 fgets()gets() 不同,getline() 使用 realloc() 调整字符串大小,确保有足够的内存来存储完整输入。

ssize_t getline(char **pstring, size_t *size, FILE *stream);

getline() 实际上是一个名为 getdelim() 的类似函数的装饰器,它会读取数据一直到特殊分隔符停止。本例中,getline() 使用换行符(\n)作为分隔符,因为当从键盘或文件读取用户输入时,数据行由换行符分隔。

结果证明这是一种更安全的方法读取任意数据,一次一行。要使用 getline(),首先定义一个字符串指针并将其设置为 NULL ,表示还没有预留内存,再定义一个 size_t 类型的“字符串大小” 的变量,并给它一个零值。当你调用 getline() 时,你需要传入字符串和字符串大小变量的指针,以及从何处读取数据。对于示例程序,我们可以从标准输入中读取:

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

int main()
{
  char *string = NULL;
  size_t size = 0;
  ssize_t chars_read;

  // 使用 getline 读取长字符串

  puts("Enter a really long string:");

  chars_read = getline(&string, &size, stdin);
  printf("getline returned %ld\n", chars_read);

  // 检查错误

  if (chars_read < 0) {
    puts("couldn't read the input");
    free(string);
    return 1;
  }

  // 打印字符串

  printf("<%s> is length %ld\n", string, strlen(string));

  // 释放字符串使用的内存

  free(string);

  return 0;
}

使用 getline() 读取数据时,它将根据需要自动为字符串变量重新分配内存。当函数读取一行的所有数据时,它通过指针更新字符串的大小,并返回读取的字符数,包括分隔符。

Enter a really long string:
Supercalifragilisticexpialidocious
getline returned 35
<Supercalifragilisticexpialidocious
> is length 35

注意,字符串包含分隔符。对于 getline(),分隔符是换行符,这就是为什么输出中有换行符的原因。 如果你不想在字符串值中使用分隔符,可以使用另一个函数将字符串中的分隔符更改为空字符。

通过 getline(),程序员可以安全地避免 C 编程的一个常见陷阱:你永远无法知道用户可能会输入哪些数据。这就是为什么使用 gets() 不安全,而 fgets() 又太笨拙的原因。相反,getline() 提供了一种更灵活的方法,可以在不破坏系统的情况下将用户数据读入程序。

(题图:MJ/4b23132f-8916-42ae-b2da-06fd2812bea8)


via: https://opensource.com/article/22/5/safely-read-user-input-getline

作者:Jim Hall 选题:lkxed 译者:MjSeven 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出