C-语言裸金属指南-全-

C 语言裸金属指南(全)

原文:zh.annas-archive.org/md5/e1207336210383ec7f90fdf971135589

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书名为裸机 C,因为它是为那些接近硬件的人准备的。它不适合那些购买现成电脑并从未看到过内部结构的人。本书使用的计算机甚至没有外壳。如果你想使用它,你必须将某些东西连接到电路板的“裸金属”上。

本书教授嵌入式编程。嵌入式计算机是指那种嵌入机器内部且你永远不知道它存在的设备。它是驱动你的车库门开关、微波炉、汽车,甚至贺卡的设备。但在它能够执行这些任务之前,需要进行编程。这就是本书教给你的内容:如何编程嵌入式系统。

为什么选择 C?C 语言让你能够精确控制程序的行为,而其他语言,如 C++,可能会在你不知情的情况下做很多事情。考虑以下语句:

a = b;

在 C++中,这可能调用类的赋值操作符函数,导致堆内存被分配和释放,并且可能抛出异常。现在这些是什么意思并不重要;关键是你无法完全知道将会发生什么。

在 C 语言中,这个语句将值b赋给变量a,没有副作用;它只是一个赋值操作,仅此而已。这个例子很简单,但你会在本书中看到 C 语言如何按照你的指令做事。

精确控制非常重要,因为我们使用 C 语言来编程一个基于 STM32F030x4 处理器的低端系统单芯片(SOC)系统(这是一个廉价的基于 ARM Cortex-M0 的系统,配备 8KB RAM)。在有限的 RAM 下,内存管理至关重要,因此我们不能允许像 C++这样的高级语言在背后操控内存。精确控制同样重要,因为嵌入式系统没有操作系统,你需要直接告诉硬件该做什么。高级语言并不总是允许你与硬件直接交互,而 C 语言则可以。

本书适用于那些具备基本计算机和硬件知识,但对编程了解有限的人。它是为那些希望将新硬件连接到微控制器并首次使用的硬件设计师而设计的。它也适用于那些对低级编程感兴趣并希望充分利用 38 美分芯片的程序员。

为了充分发挥程序的性能,你需要了解程序背后发生的事情。这本书不仅教你如何编写程序,还会展示你的程序是如何被翻译成 ARM 芯片使用的机器码的。这一点对于最大化效率至关重要。例如,你将了解到如果将程序中的 16 位整数改为 32 位整数,会带来多大的性能影响。令人惊讶的是,32 位整数更高效更快(32 位是 ARM 的自然数据大小,如果被迫进行 16 位运算,它会先进行 32 位计算,然后丢弃 16 位)。

要编程和调试 ARM 芯片,你需要一些额外的工具:闪存编程器(用于将代码加载到机器中)、USB 到串行转换器(因为我们使用串行线进行调试)和 JTAG 调试器。由于几乎所有开发者都需要这种工具组合,意法半导体(STMicroelectronics)生产了一款提供所有所需硬件的开发板,名为 NUCLEO-F030R8。目前,由于芯片短缺,某些开发板可能很难找到。请参考nostarch.com/bare-metal-c以获取替代开发板。你还需要一根迷你 USB 数据线(那种不适用于你手机的线),以便将开发板连接到计算机上。

你的第一项任务是订购一块 NUCLEO-F030R8 开发板。然后开始阅读第一章。当开发板到货时,你就准备好了。

第一部分

嵌入式编程

让我描述一个“简单”的嵌入式系统。它是一个电池供电的处理器,放置在一个佩戴在某人脖子上的挂坠中。当最终用户遇到紧急情况时,他们按下按钮,计算机会向接收器发送一个无线电信号,从而发出紧急呼叫。

听起来很简单……除了你必须向无线电发送一组精确的脉冲,以便它能生成正确的信号。系统还必须定期检查电池并将电池信息发送给基站,这有两个目的。首先,当电池电量开始降低时,报警公司会收到通知,并向最终用户发送新的挂坠。其次,如果基站没有接收到定期信号,报警公司会知道挂坠出了问题。

这种类型的程序在嵌入式世界中是典型的。它小巧、必须精确,并且不依赖太多外部资源。

在本书的这一部分,你将学习基本的 C 语言语法和编程技巧。我们还会详细讲解 C 语言编译器的工作原理,以便你能精确控制程序的执行。要实现这种精确控制,你需要了解编译器在你不注意时做了什么。

嵌入式编程带来了独特的调试挑战。幸运的是,像 JTAG 调试接口这样的工具使得事情变得更容易,但即便如此,调试嵌入式系统仍然可能相当困难。

最基本且常见的调试方法之一是将printf语句放入代码中。这在嵌入式编程中有些困难,因为没有地方可以发送打印输出。我们将介绍如何使用串行输入/输出将打印数据从嵌入式系统中取出,用于调试和日志记录。

最后,在本书的这一部分,你将学习中断编程。中断使得你可以高效地进行输入/输出操作,但如果操作不当,它也可能导致竞态条件和其他随机的错误。在这里,设计至关重要,因为中断问题可能相当难以调试。

欢迎来到嵌入式编程的世界。祝你玩得开心。

第一章:Hello World

在本章中,你将创建并执行第一个程序——“Hello World”。这是几乎所有 C 语言书籍中最简单的程序,也是你能做的最简单的程序。但你不仅仅是创建它:你将学习在它的创建过程中,幕后究竟发生了什么。

你将使用的工具旨在使开发过程快速便捷,这对常规编程有利,但对嵌入式编程可能不太合适。编译器 GCC 实际上是一个包装器,它运行了许多其他工具。我们将逐一查看每个工具的作用,以便将程序从代码转化为执行。在这个过程中,你会发现 GCC 的优化器给我们带来了一个惊喜。尽管我们的程序非常简单,但优化器会决定重写其中的一部分,以提高效率——而且它不会告诉我们重写的内容! 事实上,如果我们不查看背后的原理,我们永远也不会知道它在做什么。(我不会告诉你它会对我们做什么;你得继续读下去才能知道。)

安装 GCC

为了在本章中运行程序,你需要在系统上下载并安装 GNU C 编译器(GCC)以及相关工具。具体安装说明根据你的操作系统不同而有所差异。

在 Windows 上,安装适用于 Windows 的 Minimalist GNU(MinGW),可在www.mingw.org找到。详细说明请见nostarch.com/bare-metal-c

在 macOS 上,GCC 编译器是开发者包的一部分,可以通过以下命令访问:

$ **xcode-select --install**

选择命令行工具选项进行安装。

Linux 安装说明取决于你使用的发行版。对于 Debian 系统(如 Ubuntu 和 Linux Mint),使用以下命令:

$ **sudo apt-get install build-essential**
$ **sudo apt-get install manpages-dev**

对于基于 Red Hat 的系统(如 Fedora 或 CentOS),使用以下命令:

$ **dnf groupinstall "Development Tools"**

对于其他任何基于 Linux 的系统,请使用随系统附带的软件包管理器,或在线搜索找到安装所需的命令。

安装软件后,打开终端窗口并输入命令gcc。如果出现“no input files”错误,说明安装成功。

$ **gcc**
gcc: fatal error: no input files
compilation terminated.

下载 STM32 的 System Workbench

System Workbench for STM32 是我们将用来为嵌入式设备编写 C 程序的 IDE。我们将在第二章开始使用它,但下载需要一些时间,所以我建议你现在就开始下载。等你读完本章时,下载应该就完成了。

访问openstm32.org/HomePage,找到 System Workbench for STM32 的下载链接并点击它。注册(免费),如果你已有账号,则登录,然后按照链接进入安装说明。通过安装程序而不是 Eclipse 安装 IDE。下载开始后,返回此处继续阅读。

工具和安装程序可能会随时间变化。如果遇到任何问题,请访问 nostarch.com/bare-metal-c 查阅更新的说明。

我们的第一个程序

我们的第一个程序叫做 hello.c。首先创建一个目录来存放这个程序,然后进入该目录。导航到你的工作区根目录,打开命令行窗口,输入以下命令:

$ **mkdir hello**
$ **cd hello**

使用文本编辑器(如 Notepad、Vim 或 Gedit),创建一个名为 hello.c 的文件,并输入以下代码:

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

我们将在接下来的章节中详细分析这个程序。但首先,我们需要先运行它。

编译程序

你刚刚创建的文件被称为 源文件,其中包含人类可读的代码。 (是的,真的是可读的。) 它是我们将要生成的所有其他文件的来源。该文件的内容称为 源代码。计算机无法理解源代码;它只能理解 机器代码,一种数字格式的指令集。因此,我们需要将源代码转化为机器代码,这个过程叫做 编译

为此,我们在 macOS 或 Linux 上执行以下编译命令:

$ **gcc -o hello hello.c**

在 Windows 上,我们执行以下命令:

$ **gcc -o hello.exe hello.c**

如果没有输出,只有命令提示符,那么命令执行成功。否则,你将看到错误信息。

这个命令告诉程序 GCC编译链接 程序,并将输出放在 macOS 和 Linux 上的 hello 文件中,或在 Windows 上的 hello.exe 文件中。现在,我们可以使用以下命令在 macOS 或 Linux 上运行我们的程序:

$ **./hello**
Hello World!

在 Windows 上,运行以下命令:

$ **hello**
Hello World!

犯错

让我们故意引入一个错误,看看会发生什么。将第二行改成如下所示:

**intxxx main()**

现在让我们尝试编译这个程序:

$ **gcc -o hello hello.c**
hello.c:2:1: error: unknown type name 'intxxx'
 intxxx main()
 ^

输出告诉我们程序第 2 行有问题,错误发生在字符位置 1。此时,编译器原本期望的是一个类型,却得到了不同的东西——也就是我们故意放进去的垃圾。通过将这一行恢复,修复这个程序。

接下来让我们移除一些东西——特别是第四行的分号:

**printf("Hello World!\n")**

这将给我们带来一个不同的错误信息:

$ **gcc -o hello hello.c**
hello.c: In function 'main':
hello.c:5:5: error: expected ';' before 'return'
 return (0);
 ^

你会注意到,编译器在发出错误信息时指向了第 5 行。这是因为尽管我们在第 4 行犯了错误,但编译器直到查看到第 5 行时才检测到错误。

有时前一行的错误可能不会在一行或多行后被检测到,因此不要只看错误提示指定的行;也要检查它上面的行。

理解程序

现在让我们逐行分析程序,看看它在做什么。首先,看看第一行:

#include <stdio.h>

为了构建我们的程序,我们使用了编译器自带的组件——即标准输入/输出(I/O)包。这个包中的函数在/usr/include/stdio.h文件中定义。(Windows 可能使用稍有不同的目录。)具体来说,我们在程序后面将使用标准 I/O 函数printf

接下来,我们定义程序的起始点:

int main()

main这个名字是特殊的,表示程序的主函数。所有程序都从main开始。接下来是一组被大括号包围的语句:

{
...
}

大括号表示main的主体。换句话说,它们用来将接下来的语句分组。为了提高可读性,我们将大括号内的语句缩进四个空格,但你也可以使用其他缩进方式。实际上,C 语言编译器并不在乎我们使用了多少空白字符。我们甚至可以完全不使用缩进,但没有缩进会让程序很难阅读,因此大多数 C 程序员会对代码进行缩进。

大括号内是我们第一个可执行语句:

 printf("Hello World!\n");

这告诉程序使用标准 I/O 函数printf将字符串输出到标准输出位置(我们的终端)。\n是这个字符串中的一个特殊字符。反斜杠(\)被称为转义字符。它告诉 C 语言接下来的字符应该被当作代码处理。在这种情况下,n告诉 C 语言输出一个“换行符”,这意味着下一个字符将会在新的一行打印。一些常见的转义字符显示在表 1-1 中。

表 1-1:常见的转义字符

转义字符 结果
\n 换行(也叫做换行符
\t 制表符
\" "
\\ \
\r 回车符

最后,程序以这条语句结束:

 return (0);

这会导致程序停止并退出,返回一个退出代码 0 给操作系统,表示程序正常终止。非零退出代码表示出错。

添加注释

到目前为止,我们一直局限于编写代码。换句话说,我们所看到的所有内容都是为了让计算机读取和处理的。程序还可以包含注释,这些注释不会被编译器看到;相反,它们是为了被查看程序的人阅读。注释通常以/*开始,*/结束。例如,下面就是一条注释:

/* Hello World – A nothing program */

它告诉你写这段程序的程序员是怎么想的。让我们在程序的开头加上一些注释:

/*
 * Hello World -- not the most complicated program in
 *      the universe but useful as a starting point.
 *
 * Usage:
 *      1\. Run the program.
 *      2\. See the world.
 */

另一种风格的注释以//开头,直到行尾结束。当你看到更多的程序时,你会自行判断哪种方式更适合使用。

在编写程序时,始终为代码添加注释,因为那时你知道自己在做什么。五分钟后,你可能会忘记。五天后,你一定会忘记。例如,我曾经需要做一次复杂的位图转换,将一个光栅图像转换为喷墨喷嘴的喷射命令。这个转换过程涉及将一个横向光栅图像,转化为喷嘴所需的列数据,然后,由于喷嘴有偏移,需要将数据向左移动以匹配喷嘴的位置。我写了一页注释,描述了影响喷射顺序的所有因素。接着,我加了一页半的 ASCII 艺术,图解我刚刚描述的内容。只有在这样做并确保我理解了问题后,我才开始写代码。而且因为我必须整理思路来记录它们,所以程序第一次就成功了。

在创建本书中编程问题的答案时,养成写注释的习惯。真正优秀的程序员都是狂热的注释写作者。

改进程序和构建过程

对于我们的“小小的‘Hello World’程序”,手动编译并不成问题。但对于一个包含数千个模块的程序,跟踪哪些需要编译,哪些不需要编译,会变得相当困难。我们需要自动化这个过程,以提高效率并避免人为错误。

在这一节中,我们将调整程序以改进并自动化构建过程。理想情况下,你应该能够通过一个命令和没有任何参数来构建程序,这意味着你有一个一致且精确的构建过程。

make 程序

我们的构建过程有一个问题,那就是每次构建程序时都需要输入编译命令。对于一个包含数千个文件的程序来说,这将是一个繁琐的工作,每个文件都需要编译。为了自动化构建过程,我们将使用 make 程序。它的输入是一个叫做 makefile 的文件,告诉 make 如何构建程序。

在 macOS 或 Linux 上创建一个名为Makefile的文件,内容如下:

CFLAGS=-ggdb -Wall -Wextra

all: hello

hello: hello.c
       gcc $(CFLAGS) -o hello hello.c

在 Windows 上,makefile 应包含以下内容:

CFLAGS=-ggdb -Wall -Wextra

all: hello.exe

hello.exe: hello.c
       gcc $(CFLAGS) -o hello.exe hello.c

确保缩进的行以制表符(Tab)开头。八个空格是无效的。(尽管文件设计很糟糕,但我们只能将就。)第一行定义了一个宏。由于这个定义,每当我们在 makefile 中指定 $(CFLAGS) 时,make 程序将把它替换为 -ggdb -Wall -Wextra。接下来,我们定义了目标 all,这是根据约定的默认目标。当没有参数时运行 make 时,它会尝试构建它看到的第一个目标。这个目标的定义 all: hello 告诉 make 程序:“当你尝试构建 all 时,你需要构建 hello。” makefile 的最后两行是针对 hello(或者 Windows 上的 hello.exe)的说明。这些告诉 makehello 是通过执行命令 gcc $(CFLAGS) -o hello hello.chello.c 构建而成的。这个命令包含了我们定义的宏 $(CFLAGS),它展开为 -ggdb -Wall -Wextra。你会注意到,我们为编译添加了几个额外的标志,我们将在下一节讨论这些标志。

现在让我们使用 make 命令来构建程序:

$ **make**
gcc -ggdb -Wall -Wextra -o hello hello.c

正如你所看到的,程序运行了构建可执行文件的命令。make 程序很聪明。它知道 hello 是由 hello.c 构建的,因此它会检查这两个文件的修改日期。如果 hello 更新了,就不需要重新编译。因此,如果你尝试两次构建程序,你会看到以下消息:

make: Nothing to be done for 'all'.

这并不总是正确的行为。如果我们在 makefile 中更改了标志,那么我们就改变了编译过程,应该重新构建我们的程序。然而,make 并不知道这个变化,除非我们编辑并保存 hello.c 文件,或者删除输出文件,否则它不会重新构建程序。

编译器标志

GCC 编译器有很多选项。实际上,这个编译器的选项列表超过了八页。幸运的是,我们不需要关注所有的选项。我们来看一下我们为程序使用的那些选项:

  1. -ggdb 编译程序以便我们能够调试它。主要是将调试信息添加到输出文件中,允许调试器理解发生了什么。

  2. -Wall 打开一组警告,标记那些正确但值得怀疑的代码。(本书将教你如何避免写出值得怀疑的代码。)

  3. -Wextra 打开额外的警告,旨在使我们的代码更加精确。

  4. -o hello 将程序的输出放入文件 hello 中。(对于 Windows 用户,这个选项是 -o hello.exe。)

编译器背后的工作原理

为了更好地利用编译器,你需要理解在你运行编译器时背后发生了什么。这是因为在你为嵌入式设备编写软件时,你经常需要绕过编译器自动执行的一些操作,这些操作包括几个步骤:

  1. 源代码会经过一个预处理器,它处理所有以#开头的行,这些行被称为指令。在我们原始的源文件中,这就是#include语句。稍后你将学习其他指令。

  2. 编译器本身处理预处理过的源代码,并将其转换成汇编语言代码。C 语言应该是与机器无关的,可以在多个平台上进行编译和运行。而汇编语言是与机器相关的,只能在一种类型的机器上运行。(当然,也可以编写只能在某台机器上运行的 C 代码。C 语言试图将底层机器隐藏起来,但并不阻止你直接访问它。)

  3. 汇编语言文件会传递给汇编器,它将其转换成目标文件。目标文件仅包含我们的代码。然而,程序需要额外的代码才能工作。在我们的例子中,hello.c的目标文件需要包含printf函数的副本。

  4. 链接器将目标文件中的目标代码与计算机上已有的有用代码结合(链接)起来。在这个例子中,它是printf以及所有支持它的代码。

图 1-1 展示了这个过程。所有这些步骤都被gcc命令隐藏起来。

f01001

图 1-1:生成程序所需的步骤

你会注意到,gcc命令既充当编译器,也充当链接器。实际上,gcc被设计成一种执行程序。它查看参数,并决定需要运行哪些其他程序来完成它的工作。这可能包括预处理器(cpp)、C 编译器(cc1)、汇编器(as)、链接器(ld)或根据需要的其他程序。让我们更详细地了解这些组件。

预处理器

第一个运行的程序是预处理器,它是一个宏处理器(一种自动文本编辑器),用于处理所有以#开头的行。在我们的程序中,它处理#include这一行。我们可以通过以下命令获得预处理器的输出:

$ **gcc -E hello.c >hello.i**

这个命令的输出被存储在hello.i文件中。如果我们查看这个文件,我们会看到它超过 850 行。这是因为#include <stdio.h>这一行导致整个stdio.h文件被复制进我们的程序,并且由于stdio.h文件中有自己的#include指令,所以stdio.h所包含的文件也会被复制进来。

我们需要stdio.h来使用printf函数,如果查看hello.i,我们可以找到该函数的定义,它现在已经被包含进我们的程序中:

extern int printf (const char *__restrict __format, ...);

extern int sprintf (char *__restrict __s,
      const char *__restrict __format, ...) __attribute__ ((__nothrow__));

预处理器还会移除所有注释,并在文本中注释上正在处理的是哪个文件的信息。

编译器

接下来,编译器将 C 语言代码转换成汇编语言。我们可以通过以下命令查看生成的内容:

$ **gcc -S hello.c**

这应该会生成一个以以下行开始的文件:

 .file   "hello.c"
        .section        .rodata
.LC0:
        .string "Hello World!"

注意,编译器将 C 字符串 "Hello World!\n" 转换为汇编语言中的 .string 命令。如果你眼尖的话,你还会注意到 \n 缺失了。稍后我们将揭晓原因。

汇编器

汇编语言文件进入汇编器,在那里它被转换成机器代码。gcc 命令有一个选项(-Wa),让我们可以将标志传递给汇编器。因为除非你是机器,否则无法理解机器代码,所以我们将使用以下命令请求一个汇编语言列表,以人类可读的格式打印机器代码,并显示生成该代码的对应汇编语言语句:

$ **gcc -Wall -Wextra -g -Wextra -Wa,-a=hello.lst -c hello.c**

-Wa 选项告诉 GCC 后面的内容将传递给汇编器。-a=hello.lst 选项告诉汇编器生成一个名为 hello.lst 的列表。我们来看一下那个文件,它的开头如下:

4                            .section        .rodata
5                    .LC0:
6 0000 48656C6C              .string "Hello World!"
6      6F20776F
6      726C6421
6      00

汇编语言在不同机器上有所不同。在这个文件中,你看到的是 x86 汇编语言。与其他汇编语言相比,它可能看起来像一团乱麻。你可能不会完全理解它,这没关系;本章只是让你对汇编语言有一个初步的了解。之后的章节中,当我们讨论 ARM 处理器时,你会看到更加理智且易于理解的汇编语言。

第一列是汇编语言文件中的行号。第二列(如果存在)表示存储数据的地址。所有计算机内存插槽都有一个数字地址。在这种情况下,字符串 "Hello World!" 被存储在相对于当前使用的部分(在此例中为 .rodata 部分)地址 0000 处。当我们在下一部分讨论链接器时,我们将看到如何将这个相对地址转换为绝对地址。

下一列包含以十六进制格式存储在内存中的数值。接下来是汇编语言代码本身。在文件中,我们可以看到 .string 指令告诉汇编器生成文本字符串的代码。

在文件的后面,我们找到了 main 的代码:

15 0000 55                    pushq   %rbp
16                            .cfi_def_cfa_offset 16
17                            .cfi_offset 6, -16
18 0001 4889E5                movq    %rsp, %rbp
19                            .cfi_def_cfa_register 6
12:hello.c       ****     printf("Hello World!\n");
20                            .loc 1 12 0
21 0004 BF000000              movl    $.LC0, %edi
21      00
22 0009 E8000000              call    puts
22      00

在第 15 行,我们可以看到汇编语言指令 55,它将被存储在该部分的地址 0 位置。此指令对应 pushq %rbp,它在程序开始时做了一些账务处理。同样注意到,一些机器指令只有 1 字节长,而其他指令则长达 5 字节。第 21 行的指令就是一个 5 字节的指令。你可以看到这条指令正在处理 .LC0。如果我们查看列表的顶部,会看到 .LC0 是我们的字符串。

作为 C 程序员,你不需要完全理解汇编语言的功能。要完全理解它,你需要阅读几千页的参考资料。但我们可以在某种程度上理解第 22 行的指令,它调用了puts函数。到这里,事情变得有趣了。记住,我们的 C 程序并没有调用puts——它调用了printf

看起来我们的代码在后台已经进行了优化。在嵌入式编程中,“优化”可能是一个敏感词,因此理解这里发生了什么非常重要。本质上,C 编译器查看了printf("Hello World!\n");这一行,并决定它与以下代码完全相同:

puts("Hello World!");

事实上,这些函数并不完全相同:puts是一个简单且高效的函数,而printf是一个庞大且复杂的函数。但是程序员没有使用printf的任何高级功能,因此优化器决定重写代码以提升性能。因此,我们的printf调用变成了puts,并且行尾字符(\n)被从字符串中移除,因为puts会自动添加这个字符。当你接近硬件时,像这样的细节可能会带来很大差异,因此了解如何查看和理解汇编代码非常重要。

汇编器的输出是一个包含我们所写代码的对象文件,没有其他内容。特别是,它不包含我们需要的puts函数。puts函数与其他数百个函数一起存在于 C 标准库(libc)中。

链接器

我们的对象文件和libc的一些组件需要合并才能构成我们的程序。链接器的任务是将构成程序所需的文件合并在一起,并为每个组件分配真实的内存地址。正如我们在汇编器中所做的那样,我们可以告诉gcc命令通过以下命令将标志传递给链接器:

$ **gcc -Wall -Wextra -static -Wl,-Map=hello.map -o hello hello.o**

-Wl告诉 GCC 将后面的选项(-Map=hello.map)传递给链接器。映射告诉我们链接器将各个部分放置在内存中的位置。(稍后我们将详细讨论这一点。)我们还添加了-static指令,这会将可执行文件从动态链接改为静态链接,这样内存映射看起来会更像我们在嵌入式系统中看到的那样。通过这种方式,我们可以避免讨论动态链接的复杂性。

对象文件,例如hello.o,是可重定位的。也就是说,它们可以放置在内存中的任何位置。链接器的工作就是决定它们在内存中的具体位置。链接器的另一项任务是遍历程序使用的库,提取所需的对象文件,并将其包含在最终的程序中。链接器映射告诉我们各个组件的位置,以及哪些库组件被包含在我们的程序中。例如,一个典型的链接器条目可能看起来像这样:

 .text          0x000000000040fa90      0x1c8 /usr/lib/gcc/x86_64-linux-gnu/5/../../../                x86_64-linux-gnu/libc.a(ioputs.o)
                0x000000000040fa90                puts
                0x000000000040fa90                _IO_puts
 *fill*         0x000000000040fc58        0x8

记住我们没有编写puts,即使它出现在这个链接器条目中。如前所述,它来自标准 C 库文件(libc.a)。我们可以看到,这个函数的代码位于0x000000000040fa90。如果我们的程序在0x40fa900x40fc58之间崩溃,这些信息将很有用。在这种情况下,我们就知道是puts导致了崩溃。

我们还知道puts占用了0x1c8字节(40fc58–40fa90)。这相当于 456 十进制字节,约为 0.5K 内存。当我们开始编程我们的微处理器时,这个内存大小会成为一个问题,因为微处理器的内存是有限的。

现在你应该对 C 程序的每个元素以及这些不同部分的作用有了一个清晰的了解。大多数时候,你可以让编译器处理这些细节,而不必担心其背后的工作原理。但当你在编程资源有限的小型芯片时,你确实需要关注内部的运行情况。

添加到你的 Makefile

通过修改你的 makefile 来生成上一节中描述的所有文件,独立探索 GCC 编译器、汇编器和链接器的各个方面:

CFLAGS=-Wall -Wextra -ggdb

all: hello hello.i hello.s

hello.o: hello.c
        gcc $(CFLAGS) -Wa,-a=hello.lst -c hello.c

hello: hello.o
        gcc $(CFLAGS) -static -Wl,-Map=hello.map -o hello hello.o

hello.i: hello.c
        gcc -E hello.c >hello.i

hello.s: hello.c
        gcc -S hello.c

# Type "make verbose" to see the whole command line
verbose:
        gcc -v $(CFLAGS) -Wextra -c hello.c

clean:
        rm -f hello hello.i hello.s hello.o

如前所述,第一行非空行定义了一个宏,告诉make$(CFLAGS)替换为-Wall -Wextra -ggdb,并应用到文件中的其他地方。接下来,我们定义了一个目标(需要构建的项目)叫做all。由于这是文件中的第一个目标,它也是默认目标,这意味着你只需输入以下命令就可以构建它:

$ **make**

这个目标被称为虚拟目标,因为它不会生成一个名为all的文件。相反,每次你执行make all命令时,make都会检查是否需要重新创建其依赖项。你可以在 makefile 中的 all 关键字和冒号后看到这些依赖项。为了生成目标 all,我们需要生成目标 hellohello.ihello.s。以下行将明确说明如何生成这些目标。例如,要生成目标 hello.i,我们必须使用目标 hello.c。如果 hello.ihello.c 更新,那么 make 将不执行任何操作。如果 hello.c 最近有改动,而 hello.i 没有更新,那么 make 会使用以下命令生成 hello.i

gcc -E hello.c >hello.i

因此,如果你编辑了 hello.c,然后执行命令 make hello.i,你将看到 make 执行其任务:

$ `(Change hello.c)`
$ **make hello.i**
gcc =E hello.c > hello.i

我们 makefile 中的另一个目标 clean 用于删除所有生成的文件。要删除生成的文件,可以执行以下命令:

$ **make clean**

GNU make 是一款非常复杂的程序,其手册长达 300 多页。好消息是,你只需处理它命令的一个非常小的子集就可以提高工作效率。

总结

编写一个“Hello World”程序是 C 程序员能做的最简单的事情之一。然而,理解创建和运行这个 C 程序背后发生的一切要复杂一些。幸运的是,你不必成为专家。但尽管你不需要精通程序生成的每一条汇编语言,任何嵌入式程序员都应该理解足够的内容,能够发现潜在的问题或异常行为,比如 puts 出现在调用 printf 的程序中。关注这些细节将帮助我们最大限度地利用我们的嵌入式设备。

问题

  1. GNU make 的文档在哪里?

  2. C 代码在不同类型的机器之间是可移植的吗?

  3. 汇编语言代码在不同类型的机器之间是可移植的吗?

  4. 为什么汇编语言中的一条语句只生成一条机器指令,而 C 语言中的一条语句可能生成多条指令?

第二章:集成开发环境简介

到目前为止,我们已经使用了 GCC、make和文本编辑器等单独的工具来构建我们的程序。这使你能够看到每个工具的作用,并了解软件开发的细节。现在,你将学习如何使用集成开发环境(IDE)。IDE 是一个旨在将所有这些工具(以及其他一些工具)隐藏在一个集成界面后的程序。

这种方法的主要优点是你可以使用一个基于图形界面的工具来完成所有事情。主要的缺点是,只有当你按照 IDE 预期的方式操作时,它才会工作得很好。此外,它还隐藏了很多东西。例如,要获取链接器映射,你必须通过多个 GUI 层并在一个隐蔽的定制框中输入映射选项。

本书中我们将使用的集成开发环境(IDE)是 STM32 的 System Workbench IDE。从它的名字可以看出,它是为 STM32 微处理器创建的。作为一种非常流行的 IDE——Eclipse 的增强版本,它包括了编辑器、调试器和编译器。在调试方面,它特别强大,因为在微控制器上进行远程调试涉及许多工具,而 IDE 使这些工具能够无缝地协同工作。

为了练习使用 IDE,你将编写和第一章中相同的“Hello World”程序,只不过这次你将把过程的每一步都封装在一个统一的 GUI 中。在某种程度上,IDE 通过将编译器和其他工具隐藏起来使事情变得更简单。在其他方面,它又使事情变得更加复杂,因为访问这些工具并进行调整变得更困难。例如,如果我想在没有 IDE 的情况下将-Wextra标志添加到编译器命令行,我只需编辑 makefile。但使用 IDE 时,我必须找到可以输入此值的神秘框(剧透:它是 Project▶Properties,然后是 C/C++ Build▶Settings▶Tool Settings▶GCC Compiler▶All Options)。

使用 System Workbench for STM32

到目前为止,我们使用了一个文本编辑器、一个名为 GCC 的编译器和一个叫做make的程序来运行编译器。当我们处理更复杂的程序时,我们还需要一个调试器。

STM32 Workbench 将所有这些工具打包成一个集成开发环境,基于 Eclipse IDE 构建。事实上,它就是Eclipse,只是在其中添加了许多特定于 STM32 的内容,接下来的讨论中我会这样称呼它。我们将在第三章更深入地探讨 STM32 的相关内容。现在,让我们通过编写一个“Hello World”程序来探索 IDE。

启动 IDE

如果你遵循了第一章开头的建议,应该已经下载了 System Workbench for STM32。按照网站上的说明安装它。标准安装会创建一个桌面图标和一个启动菜单项,因此你应该可以像启动其他程序一样启动 IDE。

初次启动时,Eclipse 会询问你的工作空间位置。请输入将包含本书所有项目的目录。接下来,Eclipse 应该会显示欢迎屏幕。点击关闭图标(标签旁边的小 X)来关闭该屏幕。

系统应该会弹出一个窗口,提示系统正在下载 ARM 处理器的附加工具。下载完成后,你应该会看到一个 C/C++视图的空项目,如图 2-1 所示。

f02001

图 2-1:空项目界面

Eclipse 是很多工具的前端。如何有条理地展示它们是一个相当大的挑战。为了解决这个问题,Eclipse 使用了视图的概念。视图是为特定任务设计的窗口布局。例如,Java 程序员可能会有与 C 程序员不同的视图。同样,调试需要不同于编程的视图。

这个版本的 Eclipse 默认视图是 C/C++项目视图。(你可以随时通过使用窗口▶视图菜单来更改视图。)视图的左侧是 Project Explorer(当前为空),它允许你查看项目及其详细信息。视图的中上方是文本编辑器。右侧是一个有三个标签的窗口:Outline、Build Targets 和 Task List。我们会在涉及更复杂的项目时再讨论这些标签。

底部有一个小的宽窗口,包含标签页:Problems、Tasks、Console、Properties 和 Call Graph。Problems 窗口列出了当前项目中代码生成的错误和警告。Console 窗口显示构建过程的输出。其他标签页我们会在开始生成更复杂的程序时再讨论。

创建 Hello World

现在我们将创建另一个“Hello World”项目。每次创建一个本地 C 项目时,你必须按照特定步骤操作(本地指的是程序运行在编译它的机器上;如果你在一台机器上编译并在另一台机器上运行,那就叫做交叉编译),本章将详细讲解这些步骤。你将频繁地执行这些步骤;为了避免你记不住所有的步骤,请参见附录中的检查清单。

通过选择FileNewC Project来启动一个新项目,这将弹出 C Project 对话框。

我为我们的项目选择了名称02.hello-ide,因为它既独特又具有描述性。项目名称可以包含任何字符,但不能包含空格和特殊字符,如正斜杠(/)、反斜杠(\)、冒号(:)等文件系统中有特殊含义的字符。字母、数字、连字符、点和下划线是可以使用的。

对于项目类型,选择Hello World ANSI C Project。对于工具链,选择与你操作系统匹配的工具链,如图 2-2 所示。点击Next

f02002

图 2-2:项目创建对话框

我们现在看到基本设置对话框。保持这些设置不变,点击下一步

下一个对话框是选择配置(见图 2-3)。

f02003

图 2-3:选择配置对话框

你有很多不同的选项来构建你的项目。Eclipse 将这些选项分为项目配置。默认定义的两个配置是 Release 和 Debug。Release 生成高度优化的代码,调试几乎不可能,甚至不可能。Debug 生成未优化的、易于调试的代码并生成调试符号。因为你在学习,我们将坚持使用 Debug 配置。取消选择Release配置,只选择Debug配置,然后点击完成

IDE 创建了我们的项目并生成了一些文件。其中一个文件是我们的源代码,已经填写了它的“Hello World”程序版本(见图 2-4)。

f02004

图 2-4:创建我们的“Hello World”项目的结果

如果你自己输入任何代码,请注意,Eclipse 编辑器默认使用 4 的制表符大小,这意味着当你使用制表符来缩进源代码中的一行时,制表符的宽度为四个空格。几乎所有其他编辑器和工具都使用八个空格。你可以通过窗口▶首选项中的一个配置项来修复此问题。(告诉你如何进一步自定义 Eclipse 需要一本完整的书,而这本书不是这本书。)

到此为止我们就完成了——如果我们是在写 Java 的话。Eclipse 是为 Java 设计的。C 是一个附加功能,几乎完全适用。我们还需要做最后一个修复。

首先,通过选择项目构建项目来编译项目。然后选择运行运行配置,这将弹出运行配置对话框。接下来,点击左侧的C/C++ 应用程序,然后点击图标行左侧的小图标来创建一个新配置。最后,在 C/C++ 应用程序下,点击浏览,如图 2-5 所示。

使用文件浏览器在Debug目录中找到你的可执行文件。IDE 已经为你在工作区创建了一个项目目录(该目录的位置取决于系统),其名称与你的项目相同。所有项目文件都在这个目录中。在项目目录中,一个Debug目录包含所有作为 Debug 构建的一部分构建的文件(这是我们正在进行的唯一构建类型)。在该目录中,你会找到 macOS 和 Linux 上的02.hello-ide或 Windows 上的02.hello-ide.exe。选择这个文件,如图 2-6 所示,然后点击确定

f02005

图 2-5:运行配置对话框

f02006

图 2-6:应用程序选择对话框

接下来,点击应用关闭以完成运行配置。此设置告诉 IDE 你的程序实际所在的位置。(既然它已经决定了放置的位置,你可能会认为它会知道文件的去向,但不知为何它并不知道。)

现在让我们实际运行程序。选择运行运行。结果应该会出现在控制台窗口中,如图 2-7 所示。

f02007

图 2-7:我们程序的结果

调试程序

接下来简单介绍一下调试器,它可以监控我们的程序执行并让我们看到程序内部发生了什么。首先,让我们通过复制第 15 行(puts("!!!Hello World!!!");)来生成一些更多的代码进行调试,然后选择文件全部保存来保存项目。

编辑后每次都选择文件▶全部保存是非常重要的。如果此时你运行程序,未保存所有文件,编译器会看到磁盘上的旧文件并编译它。生成的程序将只会打印一次!!!Hello World!!!,而不是两次,这可能会造成很大的困扰。我们面前的代码是正确的;我们正在运行的代码则不是。直到选择文件▶全部保存,文件才是相同的。(结束说教模式。)

现在让我们通过运行调试来启动调试器(见图 2-8)。

f02008

图 2-8:启动调试器

IDE 即将切换到调试模式,这会将视角从开发模式切换到调试模式。这意味着窗口布局会发生变化。系统会提醒你即将发生这种变化,如图 2-9 所示。(记住,你总是可以通过命令窗口▶视角▶C/C++窗口▶视角▶调试来切换视角。)

f02009

图 2-9:调试视角警告

在警告中点击。调试视角应该打开,如图 2-10 所示。

f02010

图 2-10:调试视角

左上方是堆栈跟踪窗口,它显示正在执行的程序及其执行的进度。这个信息在第七章讨论堆栈使用时会变得更有用。

紧挨着的是变量/断点/寄存器/输入输出寄存器/模块窗口,其中包含以下内容:

  1. 变量 程序变量的信息。(从第四章开始将进一步介绍。)

  2. 断点 断点是程序中的一个位置,程序在此处停下来,调试器可以检查它。你可以通过双击程序中可执行行的行号来设置断点。我们将在第三章开始使用它们。

  3. 寄存器 当前处理器寄存器状态的信息。(在第十章讨论。)

  4. 模块 动态链接模块。由于嵌入式程序员无法使用此功能,我们将不讨论它。

屏幕中间的源窗口显示了我们的程序。高亮的代码行表示调试器已经运行到这行并暂停了。

源窗口旁边是大纲面板。它类似于目录,指示哪些文件包含在我们的程序中。我已将文件stdio.hstdlib.h包括在内,因此它们会显示在这里。

底部是控制台/任务/问题/可执行文件/内存窗口。控制台窗口显示了程序的输出,其他标签包含我们不感兴趣的信息。

现在我们将单步调试程序,意味着我们将使用调试器逐行执行语句。点击屏幕顶部的步过图标(见图 2-11),或者按 F6 键来跳过当前行。

f02011

图 2-11:步过(F6)

源窗口中高亮的行向前推进了一行,!!!Hello World!!!出现在控制台窗口中(见图 2-12)。

f02012

图 2-12:单步执行结果

如果你继续逐步调试,你将看到第二个puts被执行,随后是return语句。之后,程序进入系统库进行清理工作。由于我们没有该库的源代码,调试器无法显示任何关于它的信息。

工具栏上还有两个重要的图标(见图 2-13)。恢复图标(或 F8 键)运行程序直到结束或遇到断点。调试图标重新启动调试。

f02013

图 2-13:调试命令

在接下来的章节中,我们将大量使用调试器。它将为我们提供从正在运行的程序中获取信息并查看发生了什么的非常有用的方式。为了回到原始的 C/C++视角,请选择窗口视角打开视角C++

IDE 为我们做了什么

IDE 生成了 C 源文件,其中包括puts函数,用于打印“Hello World”。它还生成了一个名为Debug/makefile的文件,该文件作为make程序的输入。清单 2-1 包含了该文件的摘录。

###########################################################################
# Automatically-generated file. Do not edit!
###########################################################################

-include ../makefile.init

RM := rm -rf

# All of the sources participating in the build are defined here
-include 1 sources.mk
-include src/subdir.mk
-include subdir.mk
-include 2 objects.mk

ifneq ($(MAKECMDGOALS),clean)
ifneq ($(strip $(C_DEPS)),)
-include $(C_DEPS)
endif
endif

-include ../makefile.defs

# Add inputs and outputs from these tool invocations to the build variables

# All Target
all: 02.hello-ide

清单 2-1:Debug/makefile的摘录

这个 makefile 位于Debug目录中。IDE 支持多种构建配置,并为每种配置生成不同目录中的 makefile。(对于本项目,我们仅创建了一个 Debug 配置,其他项目可能还会使用 Release 配置。)

这个 makefile 比我们在第一章中自己生成的更加复杂,因为 IDE 使用了大量高级的make语法。IDE 还生成了文件sources.mk 1 和objects.mk 2,并将它们包含在 makefile 中。我们从这些文件中看到,计算机生成的内容设计得非常灵活,但代价是几乎无法阅读。

就目前而言,IDE 不会生成或下载大量数据。但当我们开始进行嵌入式编程时,这种情况将发生剧变。

导入本书的编程示例

本书中使用的编程示例可以从nostarch.com/bare-metal-c.下载。要使用下载的编程示例,你需要将它们导入。 (你不能仅仅将文件放入工作区;那样太简单了。)要执行导入,请按照以下步骤操作:

  1. 选择文件导入

  2. 在导入对话框中,选择常规将现有项目导入工作区

  3. 点击下一步

  4. 选择单选按钮选择归档文件,然后点击空白处后的浏览,选择包含项目的文件(即你从网站下载的文件)。

  5. 点击完成

总结

IDE 是一个双刃剑。一方面,你不需要担心创建程序所需的所有工具。你不必自己创建 makefile、手动执行构建或运行调试器。

但这种放手的方式是有代价的。要在第一章的程序中添加一个编译时标志,你只需将标志添加到 makefile 中。在 IDE 中,你无法这样做,因为 IDE 会自动生成 makefile。你必须在 IDE 中找到正确的配置项来完成这个操作,而正如我们将要发现的那样,IDE 有很多选项。

本书中,我尽力通过使用清单(如附录中的清单)和标准程序将内容保持尽可能简单。Eclipse 尽量处理所有事情,但你偶尔还是需要在幕后进行一些调整。

编程问题

  1. 查找将\t放入打印字符串中时会发生什么。

  2. 在第一章中,我们使用了printf来打印消息。在本章中,Eclipse 使用puts。查阅这些函数的文档,了解它们的不同之处。

问题

  1. 什么是 IDE?

  2. 我们的 IDE 生成了哪些文件,它们包含了什么?

  3. 你可以从哪里获得关于使用 C 语言和 Eclipse 的帮助?

第三章:微控制器编程

现在我们已经在 IDE 中编写并运行了一个“Hello World”程序,我们将在 STM32 NUCLEO-F030R8 开发板上做同样的事情,该开发板包含 STM32F030R8 处理器以及使用该处理器所需的其他多个组件。在嵌入式系统中,“Hello World”的等效程序是使 LED 闪烁的程序。通过让 LED 闪烁,你将学习如何在较小的规模上进行复杂程序的开发步骤。

在这个过程中,你将学习如何使用 STM32 的系统工作台(我们在上一章中探讨过)来创建嵌入式程序。为了帮助我们,我们将使用 STMicroelectronics 的软件——硬件抽象层(HAL),它隐藏了硬件的一些繁琐细节。(然而,这些细节并没有被隐藏得很深,你可以查看源代码了解实现的内容。)我们还将详细解释 IDE 在幕后执行的操作,并解释它使用的编译选项。

最后,像我们在第二章所做的那样,我们将运行调试器,逐行执行程序,这对于我们开始制作越来越大的程序时非常有用。

NUCLEO-F030R8 开发板

开发板是包含处理器芯片和开发该处理器应用所需的各种组件的电路板,包含许多有助于开发程序和电路的有用元件。除了编程和调试支持,开发板还包括多个连接器,让你可以连接原型硬件。它还包括一些外设,如串行端口、按钮开关和 LED,尽管一些更高级的开发板会包含更多外设。

因此,开发板为你提供了一个即时原型,用于开发具有面包板硬件的初始软件。微处理器制造商通常会出售包含所有这些设备的开发板,以便让人们使用他们的芯片。

STM32 NUCLEO-F030R8 开发板将 STM32F030R8 芯片与时钟电路、电源以及一些外部设备(包括 LED、按钮和串行 I/O 设备)打包在一起。图 3-1 展示了我们处理器板的基本组成部分。

f03001

图 3-1:处理器板

电源和时钟驱动 CPU,重置按钮重启 CPU,用户 LED 和按钮用于用户交互,串行端口和连接器用于编程和调试。

编程与调试开发板

开发板包含三个有助于编程和调试芯片的设备——一个闪存编程器,一个 JTAG 调试器和一个串行 I/O 设备——所有这些设备通过一个 USB 电缆连接到计算机。(一根电缆,三种设备。)

要对芯片进行编程,我们使用闪存编程器,这是一种允许 PC 重新编程芯片内存的设备。重新编程内存就是将程序加载到设备中。

为了方便调试,芯片上有一个 JTAG 端口。JTAG,代表联合测试行动小组(Joint Test Action Group),是一种标准的调试接口。在这个标准发布之前,每个人都创建了自己的调试接口,或者更常见的是直接不做接口,这导致程序员在调试程序时必须非常有创意。要通过 JTAG 端口进行调试,我们需要将其连接到计算机。这是通过一个调试盒完成的,调试盒一端连接到开发板上的 JTAG 端口,另一端连接到计算机的 USB 端口。

另一个非常有用的调试和维护工具是打印诊断信息。嵌入式程序的一个问题是哪里打印信息。因为没有显示屏,所以无法将信息打印到屏幕上。将信息打印到日志文件中也很困难,因为没有文件系统。大多数设备设计师做的是在板上放置一个串口,这是一个简单的三线通信接口。第九章将详细介绍该设备。

设置开发板

Nucleo 开发板的下半部分包含芯片及其支持电路,板的两侧有许多引脚连接到连接器(用于连接外部设备)。其上方是包含编程器、调试器、串口转 USB 设备和 USB 存储设备的支持板。

图 3-2 展示了开发板的组成。

f03002

图 3-2:NUCLEO-F030R8 开发板

板上还包含了多个跳线和 LED。跳线是小型塑料元件,用于将两个引脚短接在一起。它们用于选择硬件选项,如启用板载调试器(ST-LINK),并应按照图 3-3 所示进行安装。请按照以下步骤操作:

  1. 安装带有两个跳线(CN2)的 ST-LINK。这样做会将板载调试盒(ST-LINK)配置为调试板载微控制器。如果移除这两个跳线,您可以使用 ST-LINK 调试其他开发板。

  2. 不要安装电源跳线(JP1)。这种配置允许 Nucleo 开发板通过 USB 端口最多吸取 300mA 的电流,从而使用 USB 端口为设备供电。如果您将许多高功耗外设连接到开发板,可以使用 JP1 启用外部电源。本书不使用任何外部硬件,因此请不要安装 JP1。

  3. 不要安装 RX-TX 跳线,这是一种将串口的输入和输出短接在一起的调试选项。我们稍后会将串口用作实际的串口,因此请不要安装这个跳线。

  4. 将 JP5 跳线安装到正确的位置(U5V)。这样做可以确保开发板通过 USB 端口供电,而不是通过外部电源。

  5. 打开测量跳线(JP6)。这是一个低功耗设备。JP6 短接的两个引脚为芯片供电。移除跳线并连接安培计来测量功耗。

CN11 和 CN12 是不使用时存放跳线的位置。将跳线安装到那里不会影响电路。

f03003

图 3-3:跳线和 LED 位置

现在使用迷你 USB 电缆将设备插入您的计算机。LD1 应该变红,表示编程器已通电。LD2 应该闪烁,因为板子自带一个预安装的程序,该程序会使 LD2 闪烁。(如果您购买了全新的开发板,情况是这样的。如果像我一样从楼道里的朋友那里拿到了第一块开发板,它将包含您朋友上次的实验。)LD3 也应该变红,表示芯片已通电。

设置嵌入式项目

在开始编程之前,关闭 STM32 系统工作台中所有打开的编辑窗口。编辑窗口显示的是文件名,而不是项目名,这会导致问题;我们所有的项目都会有一个main.c文件,如果有半打main.c编辑窗口打开,事情会变得相当混乱。

接下来,通过选择文件新建C 项目来创建一个嵌入式项目。(这些步骤的详细清单可以在附录中找到。)C 项目对话框应出现(见图 3-4)。

f03004

图 3-4:C 项目对话框

在项目名称中输入03.blink。在项目类型中选择Ac6 STM32 MCU 项目。首次启动时,IDE 会将 GCC ARM 工具链下载到您安装 IDE 的目录,并下载整个 STM32 固件库,其中一部分将被复制到您的项目中。如果您想进一步探索这个库的代码和示例,它使用的缓存目录在 Linux 和 macOS 上是~/.ac6,在 Windows 上是C:\Users<username>\AppData\Roaming\Ac6。但请注意,这些示例是为了展示 STM 芯片的功能,初学者可能不容易理解。

点击下一步。选择配置对话框,如图 3-5 所示,应会出现。

f03005

图 3-5:选择配置对话框

保持调试选中,并取消选中发布。为了简化操作,我们只执行一种类型的构建。点击下一步

接下来是目标配置对话框(见图 3-6)。

f03006

图 3-6:目标配置对话框

对于系列,选择STM32F0,对于板卡,选择NUCLEO-F030R8。点击下一步

这将带我们进入项目固件配置对话框(见图 3-7)。

f03007

图 3-7:项目固件配置对话框

项目固件配置选项让我们可以使用来自 STMicroelectronics 和其他供应商的免费标准代码。既然其他人已经写好了大部分复杂的代码,那就使用他们的成果吧。选择 硬件抽象层(Cube HAL),然后在出现的界面中点击标有 下载目标固件 的按钮。接受许可协议,IDE 将下载固件库。

下载完成后,系统会显示其他选项。保持默认值,然后点击 完成

返回到 C/C++ 项目视图,你应该能在项目列表中看到 blink 的条目。点击 blink 旁边的三角形,可以看到组成项目的目录列表,再点击 src 旁边的三角形展开该目录。双击 main.c 使其在编辑窗口中显示,如 图 3-8 所示。

f03008

图 3-8:编辑窗口,显示 main.c

你的第一个嵌入式程序

IDE 方便地为你提供了一个主文件,其中填写了程序的最小功能:注释、Nucleo 板的代码库和一个通用的 main 函数。第 3 行旁边的 + 图标表示某些程序行已被 折叠,或者被隐藏。点击 + 图标以展开描述文件的长注释:

1 /**
2   ************************************************************************
3  * @file    main.c
4  * @author  Ac6
5  * @version V1.0
6  * @date    01-December-2013
7  * @brief   Default main function.
8  *************************************************************************
9 */

你可能希望更新此注释,添加你的名字和信息。以 @ 开头的关键字是为 Doxygen 设计的,这是一个复杂且功能全面的系统,用于从大型程序中提取文档。我们不会在小型程序中使用这个工具,所以你可以根据自己的需要编辑该注释。

main 函数没有 return 语句,因为 return 语句将控制权从程序返回给操作系统,但裸机系统没有操作系统。操作系统的一个任务是启动和停止程序(以及其他功能)。由于我们没有操作系统,每当我们的程序停止时,处理器就会停下来,什么也不做。因此,我们不会停止。永远不会。要了解我们是如何实现这一点的,注意第 19 行的 for(;;);。这是 C 语言中的“永远循环”(for(;;))和“什么也不做”(分号)的代码。

但是没有操作系统,我们该如何启动呢?当处理器开启或复位时,我们的程序就开始运行(因此板子上有一个大黑色复位按钮)。

按目前的情况,我们的程序什么也不做,并且要花很长时间才能做到这一点。让我们添加一些代码来做点什么。

初始化硬件

首先,我们需要初始化硬件。为此,我们将首次使用 HAL 库。HAL 软件层的设计目的是隐藏与芯片工作相关的所有复杂细节。例如,我们必须在使用芯片来定时 LED 的闪烁之前,初始化片上时钟。自己完成这一工作将需要编程特定的 I/O 寄存器,它们直接控制 I/O 设备的行为。这些是硬件的一部分。

尽管我们可以通过查看芯片的 700 页参考手册来确定需要编程的寄存器,然后进行所有计算以确定应该编程的值,但这将会非常繁琐。

相反,我们可以使用 HAL 软件,特别是 HAL_Init 函数,来为我们完成所有这些工作。HAL_Init 函数会编程系统时钟,这样我们以后就可以用它来控制 LED 的定时。请在 main 函数的第一个大括号后插入对 HAL_Init 的调用,如下所示:

int main(void)
{
    HAL_Init();

一般来说,最佳实践是每使用一对大括号就缩进四个空格。C 语言并不要求这样做,但这样可以让程序更易于理解。(四个空格并没有什么神奇之处。有些程序使用两个空格,有些使用八个空格,还有一些奇怪的人使用三个空格。)

这就解决了基本硬件的问题。

编程 GPIO 引脚

芯片有多个 通用输入/输出引脚,简称 GPIO 引脚,我们可以编程使其接收输入或发送输出,用于各种功能。例如,我们可以将一个引脚编程为输出,并将其连接到一个 LED(这正是我们在本程序中要做的)。另外,我们可以将一个引脚编程为输入,并将其连接到开关(这将在下一章中进行)。

一些微控制器的引脚可以用作模拟输入或输出。大多数 GPIO 引脚只能是开或关。模拟引脚可以处理开和关之间的电压,例如 32765/65536 开。其他引脚可以连接到 USART(串行 I/O 控制器)或 I2C 总线(简单 I/O 总线)来与 I2C 外设芯片进行通信。好消息是这些引脚可以做很多事情。坏消息是我们必须编程告诉芯片,“不要做那些复杂的事情。当我想让你开时就开,想让你关时就关。”

我们将编程连接到用户 LED(LED2)的 GPIO 引脚。我们需要告诉芯片,我们将此引脚用作输出;然后我们必须告诉它关于如何使用这个引脚的详细信息。这包括设置 GPIO 时钟,它控制引脚响应的速度。HAL 固件可以完成大部分工作,但我们需要通过将配置结构信息传递给 HAL_GPIO_Init 函数来告诉 HAL 要做什么(C 语言的结构概念将在第七章中详细介绍):

// LED clock initialization
LED2_GPIO_CLK_ENABLE();

// Initialize LED
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.Pin = LED2_PIN;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
HAL_GPIO_Init(LED2_GPIO_PORT, &GPIO_InitStruct);

我们将引脚设置为开启状态,以便向LED2_PIN传输数据,该引脚连接到用户 LED。接下来,我们指定该引脚将用于输出,因为我们是向 LED 发送数据,而不是获取数据,并将模式设置为推挽模式。这个模式由你连接到输出引脚的硬件决定。在这种情况下,我们的电路需要推挽模式。此选项控制用于驱动 GPIO 引脚的内部硬件。STM 芯片参考资料展示了电路是如何组织的(或者说,它展示了给硬件工程师看芯片如何组织,然后他们会告诉你应该使用哪种模式)。

上拉标志配置 GPIO 引脚,使其在输入模式下有一个上拉电阻。对于输出引脚而言,这个设置无关紧要,但仍然需要设置。我们将其设置为GPIO_PULLUP,实际上这并没有任何意义。最后,我们通过GPIO_SPEED_FREQ_HIGH将速度设置为高。

切换 LED

现在去掉for(;;)语句后的最后一个;。记住,这个分号基本上意味着“什么也不做”。为了引入for循环应该执行的代码,添加以下新行:

for(;;) {
    // Toggle LED2
    HAL_GPIO_TogglePin(LED2_GPIO_PORT, LED2_PIN);
    HAL_Delay(400); // Delay 400 ms
}

HAL_GPIO_TogglePin函数切换LED2 GPIO 引脚。在我们的芯片中,GPIO 引脚按 32 位一组组织,统称为GPIO 寄存器。我们的引脚位于寄存器LED2_GPIO_PORT中。为了告诉函数切换哪一个 32 个 GPIO 引脚,我们指定了LED2_PIN

切换引脚后,我们需要暂停一段时间;否则,LED 会闪烁得太快,以至于我们看不见。我们使用HAL_Delay函数延迟 400 毫秒(ms)。

构建完整的程序

我们的完整程序如下所示:

/*
 * Blink the user LED on the board.
 *
 * A simple program to write, but getting it
 * working is going to require learning a
 * lot of new tools.
 */

#include "stm32f0xx.h"
#include "stm32f0xx_nucleo.h"

int main(void)
{
    HAL_Init();
    // LED clock initialization
    LED2_GPIO_CLK_ENABLE();

    // Initialize LED
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.Pin = LED2_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(LED2_GPIO_PORT, &GPIO_InitStruct);

    for(;;) {
        // Toggle LED2
        HAL_GPIO_TogglePin(LED2_GPIO_PORT, LED2_PIN);
        HAL_Delay(400); // Delay 400 ms
    }
}

现在通过选择项目构建项目来构建项目。如果一切顺利,你应该在问题窗口中看不到任何问题。如果有问题,修复它们然后再试一次。

在控制台窗口中,你会看到 IDE 调用了make,然后调用了名为arm-none-eabi-gcc的 GCC 编译器。这是我们嵌入式芯片的编译器。

通过选择运行运行来启动程序。(确保在主菜单上点击运行。你也可以右键点击项目,但那样会运行一个稍有不同的命令。)运行命令会隐藏许多操作。首先,IDE 检查项目是否需要构建。然后,它运行一个程序,该程序获取程序文件并与开发板上的闪存编程器通信,将程序烧录到内存中。最后,编程器告诉芯片重置并启动我们的程序。

结果,你应该看到绿色 LED 缓慢闪烁。

探索构建过程

如图 3-9 所示的控制台窗口包含了构建过程的输出。(如果该窗口为空,你可以通过项目清理,然后项目构建项目来重新创建内容。)

f03009

图 3-9:控制台窗口

让我们向上滚动,看看构建过程中的一行,这是 GCC 编译器的典型调用:

arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -mfloat-abi=soft \
-DSTM32 -DSTM32F0 -DSTM32F030R8Tx -DNUCLEOF030R8 -DDEBUG
-DSTM32F030x8 \ 
-DUSEHALDRIVER \
-I"/home/sdo/bare/workspace/blink/HALDriver/Inc/Legacy" \
-I"/home/sdo/bare/workspace/blink/Utilities/STM32F0xx-Nucleo" \
-I"/home/sdo/bare/workspace/blink/inc" \
-I"/home/sdo/bare/workspace/blink/CMSIS/device" \
-I"/home/sdo/bare/workspace/blink/CMSIS/core" \
-I"/home/sdo/bare/workspace/blink/HALDriver/Inc" \
-O0 -g3 -Wall -fmessage-length=0 -ffunction-sections \
-c -MMD -MP -MF"HALDriver/Src/stm32f0xxlltim.d" \
-MT"HALDriver/Src/stm32f0xxlltim.o" \
-o "HALDriver/Src/stm32f0xxlltim.o" "../HALDriver/Src/stm32f0xxll_tim.c"

这是控制台窗口中的一行,为了格式化被拆分开来。如你所见,编译器被提供了许多额外的选项。以下是此命令行中的关键项目:

  1. arm-none-eabi-gcc 这是一个 GCC 编译器,但与本地 GCC 不同,本地 GCC 为计算机编译,而它是一个交叉编译器,为 ARM 处理器生成代码。没有底层操作系统(因此使用 none 选项),该系统为嵌入式应用程序二进制接口(eabi)设计,定义了程序各部分如何与彼此及外部世界进行通信。

  2. -mcpu=cortex-m0 生成适用于 cortex-m0 版本 CPU 的代码。ARM 有多个处理器版本,这个标志告诉 GCC 使用哪一个版本。

  3. -mthumb 一些 ARM 处理器可以执行两种不同的指令集。一个是完整的 32 位 RISC 指令集,执行速度非常快,但占用大量内存;另一个是 thumb 指令集,执行速度较慢,但更加紧凑。此指令告诉 GCC 我们需要使用 thumb 代码(如果你使用的是内存有限的廉价芯片,这通常是个好主意,而我们正是使用这种芯片)。

  4. -mfloat-abi=soft 我们的处理器没有浮点硬件,因此这个标志告诉 GCC 用软件模拟浮点运算。(有关浮点运算的更多内容,请参见第十六章。)

  5. -O0 指定优化级别为 0(即不优化)。这会关闭编译器的一个功能,避免编译器分析代码并执行各种加速代码的技巧。这些技巧使得底层代码更难以理解和调试。

  6. -g3 启用调试功能。

  7. -Wall 启用名为 all 的警告集合,其中包含几乎所有有用的警告。

  8. -c 将单个源文件编译为单个目标文件。

  9. -o"HALDriver/Src/stm32f0xxll_tim.o" 将目标文件存储在指定文件中。

  10. "../HALDriver/Src/stm32f0xxll_tim.c" 指定源文件的名称。

其他选项告诉编译器库的包含文件在哪里以及这些文件应如何配置。(第十二章中我们将讨论 -D 指令。)-I 指令告诉编译器除标准包含文件目录外,还要在指定的目录中搜索包含文件。

除了编译命令外,我们还可以看到链接器命令:

arm-none-eabi-gcc -mcpu=cortex-m0 -mthumb -mfloat-abi=soft \
-T"/home/sdo/bare/workspace/blink/LinkerScript.ld" \
-Wl,-Map=output.map -Wl,--gc-sections \
-o "blink.elf" @"objects.list" -lm '''

关键指令 -T"/home/sdo/bare/workspace/blink/LinkerScript.ld" 告诉链接器使用 LinkerScript.ld 来指定程序各部分的位置。(第十一章中将详细讨论此内容。)

构建过程以以下两个命令结束:

arm-none-eabi-objcopy -O binary "blink.elf" "blink.bin"
arm-none-eabi-size "blink.elf"
   text       data	    bss	    dec	    hex	filename
   2620       1088	   1604	   5312	   14c0	blink.elf

arm-none-eabi-objcopy 命令将 .elf 文件转换为原始二进制映像。ELF 是一种复杂的文件格式,它告诉加载器在哪里放置各种内容。原始二进制映像就是将写入闪存的内容。

最后,arm-none-eabi-size 打印出最终程序的大小(表 3-1)。

表 3-1:程序内存段大小

描述
text 只读数据的大小(进入闪存)
data 需要初始化的读/写数据的大小(进入 RAM)
bss 初始化为零的读/写数据的大小(进入 RAM)
dec 十进制总大小
hex 十六进制总大小

我们将在后续章节中探索不同类型的内存,如闪存和 RAM。目前,理解这一点:此步骤是为了回答问题,“如果我继续编程,什么时候内存会用尽?”

探索项目文件

STM32 的系统工作台已经为我们的项目创建并下载了许多文件。让我们来查看这些关键文件。

我们可以通过点击目录名称旁边的三角形来查看我们的 src 目录。它包含了 表 3-2 中列出的文件。

表 3-2:src 目录文件

文件 描述
main.c 主程序,我们的所有代码都在这里。
stm32f0xxit.c 中断服务例程。你将在第十章学习关于中断的知识。对于这个简单的程序,我们关心的唯一中断是系统时钟,即便如此,我们也不会直接看到它的细节。它被 HAL_Delay 使用。
syscalls.c 不使用的虚拟函数。
Systemstm32f0xx.c 支持系统时钟的代码(将在后面的章节中解释)。

startup 目录包含一个文件:startup_stm32f030x8.S。这是一个汇编语言文件,执行足够的初始化操作,使得处理器可以运行 C 代码;然后跳转到 C 启动代码。这是按下复位按钮时执行的第一条指令。

inc 目录包含一个文件 stm32f0xx_it.h,用于告诉其他程序关于 stm32f0xx_it.c 中的中断处理程序。这是一个非常小且无聊的文件。

现在我们来看 HAL_Driver 目录。这个目录包含大约 130 个文件,这些文件提供了一个 HAL 库,可供程序使用。HAL 隐藏了不同 ARM CPU 具有不同能力的事实。例如,HAL_Init 函数将初始化所有硬件。如果你使用的是 Cortex-M0 处理器,Cortex-M0 版本将初始化所有 Cortex-M0 硬件。如果你使用的是 Cortex-M4 处理器,所有 Cortex-M4 硬件将被设置。这个目录中有这么多文件,因为我们使用的板子有许多硬件。(而且这是系统的简化版本。)

CMSIS 目录包含了旨在支持 HAL 层的低级代码。

最后,Debug 目录包含所有与调试构建相关的文件。特别是,它包含一个名为 Makefilemake 输入文件和一些生成的文件(参见 表 3-3)。

表 3-3:Debug 目录中的生成文件

文件 描述
blink.elf 我们的 ELF 格式程序(可执行文件格式)
blink.bin 我们的程序的内存映像(原始代码)
output.map 程序的内存映射

我们列表中的最后一个文件是顶层文件:LinkerScript.ld。它告诉链接器我们的芯片内存布局是什么样的,以及在哪里加载程序的各个部分。更多内容将在第十一章介绍。

调试应用程序

我们的闪烁程序很简单且能正常工作,但以后我们会写更复杂的程序,而这些程序中会有 bug。由于我们正在编程的板子有一个如此强大的调试器,不妨从现在开始学习如何使用它。通过选择运行调试来启动调试器,如图 3-10 所示。

f03010

图 3-10:启动调试器

然后,IDE 会询问您要运行什么类型的调试器,如图 3-11 所示。请选择Ac6 STM32 C/C++ 应用程序

f03011

图 3-11:调试器选择

系统会询问是否要“切换到调试视图”。请选择。系统随后会自动执行若干步骤:

  1. 它构建了软件。

  2. IDE 通过闪存编程器将程序下载到芯片。

  3. 调试器通过 JTAG 接口附加到设备。

  4. 调试器在main的第一行设置了断点。

  5. 断点告诉芯片在执行main的第一行之前停止。

  6. 微处理器复位,程序运行到main

  7. 当程序执行到main的断点时,调试器重新获得控制权。

一旦调试器到达断点,您就准备好调试程序,如图 3-12 所示。此时,程序已经执行到main函数的第一条语句,并在调用HAL_Init之前暂停。

f03012

图 3-12:调试程序

现在我们已经掌控了控制权,接下来就用它。使用命令运行单步跳过来逐行执行程序。我们会执行多次,所以记得快捷键是 F6。继续使用 F6 单步跳过,直到进入for循环。

请注意,每次执行HAL_GPIO_TogglePin(LED2_GPIO_PORT, LED2_PIN)函数时,LED 会开或关。由于您处于for循环中,您会在切换和延迟之间不断来回。如果您足够细心,您会注意到执行HAL_Delay调用大约需要 400 毫秒(即两分之五秒)。如果您想更清楚地观察延迟,可以将该值更改为一个更大的值。

单步执行程序

现在我们将深入了解这个程序的一些细节。大多数概念将在后面的章节中更深入地讲解,但我现在会给你一个初步的了解。首先,让我们用运行终止来中止当前的调试会话。现在,我们重新开始,选择运行调试。你应该会回到调用HAL_Init的那一行。要逐步执行程序,使用另一个命令,运行逐过程执行(或快捷键 F5)。

突然,文件stm32f0xx_hal.c出现在我们的编辑窗口中(参见图 3-13)。这个文件是从哪里来的?

f03013

图 3-13:调试stm32f0xx_hal.c

我们调用的过程是HAL_Init。该过程在stm32f0xx_hal.c中定义,因此当我们进入HAL_Init调用时,调试器会自动打开这个文件。或者,使用“Step Over”命令会将该语句(在本例中为HAL_INIT();)视为一个整体并跳过该函数,隐藏所有细节。

“Step Into”命令知道我们正在调用一个函数,并进入它的代码。如你所见,支持我们这个小程序需要大量额外的代码。当你在 PC 上编程时,代码对你是隐藏的,很难获取其源代码。STM32 工作台在HAL_Driver/Src目录中为你提供了所有这些代码。

除了显示函数内部的代码,调试器还可以显示我们程序中所有变量的状态。要看到这一点,选择运行逐过程执行(或按 F6 键)大约六次,直到你回到main.c中的那一行,选择要使用的引脚。在屏幕的右上角,你会看到一个标题为“变量”的面板(参见图 3-14)。

f03014

图 3-14:变量面板

在我们的程序中,我们定义了一个名为GPIO_InitStruct的变量。在变量面板中,名称前面的+号表示GPIO_InitStruct是一个复杂变量,这意味着它不仅仅包含一个简单的整数、布尔值或其他单一的值。要查看其中的所有组件,可以通过点击+图标来展开它(参见图 3-15)。

f03015

图 3-15:展开的变量

你将在后面的章节中学习到GPIO_InitStruct的各个组件,以及如何自己创建变量。GPIO_InitStruct变量是由一位程序员创建的,他阅读了我们芯片的 700 页参考手册,并设计了一个变量来存储这些信息。不管你信不信,这个变量大大简化了手册中呈现的内容:仅关于 GPIO 子系统的技术信息就压缩了大约 30 页。

现在逐步执行接下来的几条语句,以查看该变量各个组件的值。

总结

我尽量使这个程序尽可能简单,但正如你所看到的,今天的复杂芯片即使是最简单的操作也需要一些工作。让程序运行需要大量的支持。

在第一章中,我们的“Hello World”程序需要的文件数量与这里提到的差不多,但它们是隐式存在的。例如,初始化文件作为 GCC 包的一部分安装。在我们的闪烁项目中,文件startup_stm32f030x8.S必须显式包含。

本章给你带来了大量的新概念。如果你还不完全理解它们,别担心,我们将在后续章节深入探讨。

编程问题

  1. 尝试更改Hal_Delay();语句中的延迟,以使闪烁频率变长或变短。

  2. 查看LinkerScript.ld以找到以下问题的答案:

    1. 你有多少闪存(只读存储)?

    2. 你有多少 RAM(读/写内存)?

  3. 查看文件output.map并确定Reset_Handler的实际地址。

  4. 对于中级读者:修改程序,使其先将 LED 打开一段短时间,然后再长时间关闭。

问题

  1. IDE 生成了哪些文件,它们包含了什么?

  2. 在你的系统中,IDE 将编译器存放在哪里?

  3. 商用 JTAG 调试器是什么样子的?它多少钱?如何将其连接到典型的开发板上?(而且,庆幸你得到了一个集成系统!)

第四章:数字与变量

现在我们已经写了一个简单的程序,是时候让机器做一些实际的工作了。在这一章中,你将学习如何操作数字。

作为嵌入式程序员,我们非常关心这些数字到底在做什么。例如,数字 32 可以表示一个牧场里的羊的数量,也可以表示 GPIO 引脚#4 的电平,控制开启大红色警告灯。更糟糕的是,我们的 STM32 将最多 32 个 GPIO 引脚组合成一个数字,因此,32 可能表示“开启大红灯”,而 34 可能表示“开启大红灯并发出警报”。为了控制我们的设备,我们需要精确地知道这些数字到底在做什么。因此,我们将深入探讨计算机如何看待这些数字。

一旦你知道了一个数字是什么,你就可以学会如何使用变量来存储程序中的信息。接下来,你将练习操作硬件的 I/O 寄存器中的位,来开启或关闭各种功能。在此过程中,你将看到第三章中的程序是如何在后台工作的。

操作整数

我们将从整数开始,或者说是从整数开始。整数是没有小数点的数字,例如 37、45、-8 和 256。

表 4-1 列出了你可以在 C 语言中对数字进行的操作。

表 4-1:C 语言中的数字操作符

操作符 描述
+
-
*
/ 除(截断为整数)
% 取模(返回除法后的余数)

以下的列表展示了这些操作符是如何工作的:

#include <stdio.h>

int main()
{
    printf("3 + 2 is %d\n", 3 + 2);
    printf("3 - 2 is %d\n", 3 - 2);
    printf("3 * 2 is %d\n", 3 * 2);
    printf("3 / 2 is %d\n", 3 / 2);
    printf("10 / 9 is %d\n", 10 / 9);
    printf("3 %% 2 is %d\n", 3 % 2);
    return (0);
}

我们在printf语句中展示每一个操作,它会打印出结果。要使用printf打印计算结果,请在字符串中放置一个%d,表示你想要显示一个数字,然后将计算结果作为第二个参数传给printf。请注意,如果你想打印出%表示取模操作,你需要将其指定两次。

要查看此程序的输出,我们需要将其导入到我们的 IDE 中。启动 STM32 的 System Workbench,然后按照第二章中的步骤创建一个程序。(附录中的检查表总结了这些步骤。)不过这次,我们不是创建一个“Hello World”程序,而是创建一个空的本地 C/C++项目,因此选择C 托管构建作为模板。

在项目类型下,选择可执行文件空项目。接下来,通过选择文件新建源文件来创建程序文件。

将程序文本输入编辑窗口,然后保存文件。像第二章中那样构建二进制文件并运行它。程序应在控制台窗口的底部显示其输出(见图 4-1)。

f04001

图 4-1:运行结果

如你所见,程序应该打印出它执行的每一个计算结果。

声明变量以存储整数

我们的程序对固定的数字进行了操作,但我们也可以使用变量来存储可以变化的信息。在任何变量可以使用之前,必须先声明它。变量声明的格式如下:

`type variable_name``; //` `Comment explaining what this variable does`

例如,使用int作为类型表示该变量是一个整数。准确来说,它是计算机最容易处理的整数类型。我们将在本章稍后讨论其他类型的整数。

变量名以字母开头,且只能包含字母、数字和下划线。STM32 固件库采用驼峰命名法,变量名中的单词首字母大写,因此为了兼容,我们在本书中贯穿使用驼峰命名法:

startTime   currentStation  area

虽然变量名可以以下划线开头,但这种命名方式被视为系统函数保留名,不应在普通编程中使用。此外,绝不要将l(小写L)或O(大写O)用作变量名。如果原因不明显,请考虑以下代码:

O = l + 1 + O * 0;  // This sort of programming will get you shot.

从技术上讲,你可以省略变量声明中的注释。然而,包含注释能够让之后与代码一起工作的人了解你声明该变量的原因及其功能。换句话说,它帮助你创建一个迷你词典或术语表。

为变量赋值

一旦声明了一个变量,就可以通过赋值语句为其赋值。赋值语句的一般形式如下:

`variable` = `expression`;

这告诉计算机计算表达式的值并将其存储在变量中。然后,可以在任何需要整数的地方使用这些变量,例如printf语句。以下程序演示了变量声明、赋值和使用:

var.c

/*  
 * A program to sum two variables
 */  
#include <stdio.h>

int main()
{
    int aNumber;        // Some number
    int otherNumber;    // Some other number

    aNumber = 5;
    otherNumber = 7;

    printf("Sum is %d\n", aNumber + otherNumber);
    return (0);
}

该程序创建了两个变量,aNumberotherNumber,然后为它们赋值并打印它们的和。现在将此程序输入到 System Workbench for STM32 中。

初始化变量

当你在程序中声明一个变量时,你告诉 C 编译器为一个整数(int)分配内存空间。但在你为它指定值之前,该变量被认为是未初始化的;它可能包含任何来自上次内存使用的随机垃圾值。

要查看我们刚刚编写的程序中的工作情况,可以打开调试器,在程序逐步执行时查看变量面板。在赋值之前,我们的两个变量,aNumberotherNumber,的值是零。但未初始化的变量可能会有任何值;它们在这里是零,纯粹是运气使然。

我们可以通过在声明时为变量添加赋值来初始化它:

int aNumber = 5;           // Some number

在大多数情况下,这是一个好主意,因为它确保我们的程序使用的是预期的值。让我们重写程序,添加这些初始化器:

/*  
 * A program to see if we can sum two variables
 */  
#include <stdio.h>

int main()
{
    int aNumber = 5;        // Some number
    int otherNumber = 7;    // Some other number

    printf("Sum is %d\n", aNumber + otherNumber);
    return (0);
}

一旦我们做出更改,就可以删除程序中稍后初始化变量的行。

整数大小和表示

C 语言有其他整数类型,除了 int,它们用于表示不同大小的数字。

随着计算机的发展,人们发现最有效的内存组织方式是将内存分为 8 位一组,称为 字节。计算机允许你将多个字节组合成 2 字节、4 字节和 8 字节的值,int 类型告诉 C 语言使用适合你所使用计算机的最有效字节数来定义一个整数。这可以是一个 16 位(2 字节)整数,或者 32 位(4 字节)整数,取决于系统。我们芯片的编译器,即 ARM Cortex-M0 CPU,使用的是 32 位整数。

为了使程序更加高效,C 语言让你选择所需的整数类型。例如,你可能只需要存储范围在 0 到 100 之间的数字。对于这种情况,你不需要使用完整大小的整数,因此可以使用 short int,它像整数一样,但占用更少的空间。(严格来说,C 标准只规定 short int 不大于常规的 int,但在大多数实现中,它通常较小。)

以下声明了一个 short int

short int shortNumber;  // A shorter-than-normal integer

一个比正常整数更长的整数可以使用修饰符 long 来声明:

long int longNumber;    // A longer-than-normal integer

当计算机获得了高效处理更长数字的能力时,人们需要一种能够容纳比 long 更多位数的整数类型。结果就是(有些愚蠢的)long long 整数:

long long int veryLongNumber;   // An even longer integer

C 标准并未定义每种整数类型的大小。它们可能都具有相同的大小,你仍然可以使用标准编译器。然而,它确实保证了以下几点:

sizeof(short int) <= sizeof(int) <= sizeof(long int) <= sizeof(long long int)

sizeof 运算符返回存储一个变量或类型所需的字节数。

让我们通过一个简短的程序来打印不同整数类型的大小,看看在我们系统上的编译器中每种整数类型占用多少空间:

size.c

/*
 * Show different number types.
 */
#include <stdio.h>

int main()
{
    short int aShortInt;        // Short integer
    int aInteger;               // Default integer
    long int aLongInt;          // Long integer
    long long int aLongLongInt; // Long long integer

    printf("Size of (short int) = %ld (bytes) %ld bits\n",
            sizeof(aShortInt), sizeof(aShortInt)*8);

    printf("Size of (int) = %ld (bytes) %ld bits\n",
            sizeof(aInteger), sizeof(aInteger)*8);

    printf("Size of (long int) = %ld (bytes) %ld bits\n",
            sizeof(aLongInt), sizeof(aLongInt)*8);

    printf("Size of (long long int) = %ld (bytes) %ld bits\n",
            sizeof(aLongLongInt), sizeof(aLongLongInt)*8);

    return (0);
}

之前,我们用 %d 打印一个数字。在这个程序中,我们使用 %ld,因为 sizeof 返回的是一个 long int,而 %ld 用来打印 long int 类型的数字。

该程序在我的系统上产生以下输出:

Size of (short int) = 2 (bytes) 16 bits
Size of (int) = 4 (bytes) 32 bits
Size of (long int) = 8 (bytes) 64 bits
Size of (long long int) = 8 (bytes) 64 bits

从这里我们可以看出,long int 的大小与 long long int 的大小相同。然而,这只适用于这个编译器和系统(在 x86_64 处理器上的 GNU GCC)。不同的编译器可能会有不同的实现方式。

数字表示法

假设我们有五头牛。在英语中,我们可以将这个数字表示为“five”,“5”或“V”。同样,在 C 语言中,你可以使用四种数字表示法:十进制、二进制、八进制和十六进制。

人们通常使用十进制(基数 10),但计算机存储数字是用二进制(基数 2),因为二进制电路便宜且容易制造。例如,我们可以使用十进制写出以下赋值语句:

aNumber = 5;

这个相同的语句可以用二进制这样写:

aNumber = 0b101;    // 5 in binary

前缀 0b 表示接下来是二进制数。(我们也可以使用 0B,但它更难阅读。)

或者,我们可以使用八进制(基数 8):

aNumber = 05;   // 5 in octal

最后,我们可以使用十六进制(基数 16):

aNumber = 0x5;  // 5 in hexadecimal

二进制数字占用很多空间,因此为了使其更加紧凑,在编程中我们常常使用十六进制表示法来表示精确的二进制值。每一个十六进制数字对应四个二进制位,如表 4-2 所示。

表 4-2:二进制与十六进制的转换

二进制 十六进制 二进制 十六进制
0000 0 1000 8
0001 1 1001 9
0010 2 1010 A
0011 3 1011 B
0100 4 1100 C
0101 5 1101 D
0110 6 1110 E
0111 7 1111 F

如你所见,1111 1100b 等同于十六进制值 0xFC。同样地,0xA5 是 1010 0101b。通过使用这个表格,你可以快速且轻松地在二进制和十六进制之间转换。

尽管计算机将数字存储为一组位,但这些位的含义完全由我们决定。例如,位模式 0000 0101 如果我们将其解释为二进制数字,则表示 5。但 0000 0101 也可以表示 10,005。是怎么得到这个数字的?我随便编的。在这种情况下,我随意选择了一个奇怪的值。其他任意的含义可能包括“五月”、字母“E”或“LED0+LED2”。

一种不太常见但仍然有用的位模式映射显示在表 4-3 中。

表 4-3:位模式到数字的映射

位模式 含义
000 0
001 1
011 2
010 3
110 4
111 5
101 6
100 7

初看之下,它看起来是随机的。但如果仔细观察,你会发现每个数字之间只有一位发生变化。这使得它非常适合用在编码器中(参见图 4-2)。它实际上是一种标准的位模式编码,称为格雷码

f04002

图 4-2:一种格雷编码器

记住,C 语言在你告诉它如何解释位模式之前并不知道如何处理这些位模式。

标准整数

整数类型的一个大问题是,标准并没有告诉你它们的大小,只告诉你它们的相对大小。如果你想写入一个 32 位的设备,你就得猜测哪种int类型的大小符合你的要求。

猜测和编程并不容易结合在一起,因此人们发明了使用名为条件编译(参见第十二章)和其他技巧的系统,用来定义包含精确位数的新类型:int8_tint16_tint32_tint64_t。类型的名称指定了整数的大小。例如,int32_t类型无论int的大小如何,始终包含 32 位。像大多数好主意一样,这些新类型被广泛使用——如此广泛,以至于 C 标准委员会决定将它们添加到语言中,并通过stdint库实现。它们并不是 C 的内建类型,所以你需要通过以下声明来包含它们:

#include <stdint.h>

清单 4-1 展示了我们新整数类型的实际应用。

/*
 * Demonstrate different sizes of integers.
 */
#include <stdio.h>
#include <stdint.h>

int main()
{
    int8_t   has8bits = 0x12;               // 8-bit integer
    int16_t has16bits = 0x1234;             // 16-bit integer
    int32_t has32bits = 0x12345678;         // 32-bit integer
    int64_t has64bits = 0x123456789abcdef0; // 64-bit integer

    printf(" 8 bits %x\n", has8bits);
    printf("16 bits %x\n", has16bits);
    printf("32 bits %x\n", has32bits);
    printf("64 bits %lx\n", has64bits);
    return (0);
}

清单 4-1:整数演示

在这个程序中,我们使用格式字符%x以十六进制打印数字。具体来说,%x格式字符打印一个int的十六进制值,但由于一些幕后操作,我们也可以对int8_tint16_tint32_t使用它,这种操作被称为参数提升

C 语言是一种有点懒惰的语言。因为在处理器是 32 位寄存器的情况下,传递一个 16 位整数给printf很困难,C 语言将int16_t转换或提升int32_t进行这一次操作,这让我们可以使用%x来处理int16_t。类似地,我们也可以对int8_t使用%x,因为它也会被提升为int32_t。(严格来说,C 语言标准指出提升可能发生,而不是强制要求它。这个代码在我们的 x86_64 机器和这个编译器上能正常工作,但在其他系统上可能不适用。)

现在我们来讨论int64_t。如果 C 语言将其转换为intint32_t),我们将失去一半的数值。C 语言对此没有办法,所以它将int64_t参数作为int64_t传递。格式也需要从%xint)更改为%lxint64_t)以打印更长的值。

读者的调查:尝试在示例中将%lx更改为%x,看看会得到什么结果。

无符号整数类型

在上一节中,我们使用了有符号整数类型,它们可以是正数或负数。无符号整数类型只保存正值,并且比有符号整数类型更简单。标准的无符号类型有uint8_tuint16_tuint32_tuint64_tuint8_t是一个无符号的 8 位整数,可以存储从 0(0000 0000b)到 255(1111 1111b)的数字。无符号整数类型的范围如表 4-4 所示。

表 4-4:无符号整数类型的范围

类型 最小值 最大值
uint8_t 0000 0000 (0) 1111 1111 (255)
uint16_t 0000 0000 0000 0000 (0) 1111 1111 1111 1111 (65,535)
uint32_t 0000 0000 0000 0000 0000 0000 0000 0000 (0) 1111 1111 1111 1111 1111 1111 1111 1111 (4,294,967,295)
uint64_t 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 (0) 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 (18,446,744,073,709,551,615)

这是一个简单的例子,演示如何使用uint8_t变量。这个程序打印smallNumber的三种不同表示,而不改变它的值:

/*
 * Simple use of uint8_t
 */

#include <stdio.h>
#include <stdint.h>

int main()
{
    uint8_t smallNumber = 0x12; // A small number

    printf("0x12 is %u decimal\n", smallNumber);
    printf("0x12 is %o octal\n", smallNumber);
    printf("0x12 is %x hex\n", smallNumber);
    return(0);
}

%u printf格式说明告诉 C 语言我们想打印一个无符号的int(默认的无符号整数类型)。我们使用格式说明符%o来打印八进制,使用%x来打印十六进制。

这个程序的输出如下:

0x12 is 18 decimal
0x12 is 22 octal
0x12 is 12 hex

溢出

现在我们将探索我们机器的极限。实际上,我们将超越这些极限。最大的uint8_t数值是 255(0b1111 1111)。当我们超过这个数值并尝试打印 255 + 1 时会发生什么?让我们试试看:

/*
 * See what happens when we exceed the maximum number.
 * (Contains a mistake)
 */

#include <stdio.h>
#include <stdint.h>

int main()
{
    // Very small integer, set to the maximum
    uint8_t smallNumber = 255;

  1 printf("255+1 is %u\n", smallNumber + 1);
    return (0);
}

根据此程序,将 8 位无符号整数 255 加 1 的结果是 256,但是 256 的二进制表示为 0b1 0000 0000,或者 0x100,在 8 位中无法容纳。要么我们扭曲了宇宙的法则,要么我们的程序出了问题。

要了解正在发生的事情,让我们看看打印语句 1。smallNumber的类型是uint8_t;然而,在大多数 32 位计算机上,添加两个 8 位整数是困难的,如果不是不可能的话,这是因为计算机的构造方式,你必须添加两个 32 位数。因此,为了计算表达式,C 编译器执行以下操作:

  1. shortNumber转换为unit32_t

  2. 将结果(类型为uint32_t)加 1

  3. 打印结果(256)作为uint32_t

结果是一个可以容纳值为 256 的uint32_t,这就是打印出来的内容。因此,我们没有引起 8 位溢出(我们本想演示的)。相反,我们演示了自动提升,这在本章前面讨论过。

为了得到我们想要的结果,我们需要稍微调整程序,将结果存储在uint8_t值中(我已经用粗体突出了程序的更改):

/*
 * See what happens when we exceed the maximum number.
 */

#include <stdio.h>
#include <stdint.h>

int main()
{
    uint8_t smallNumber;
 **uint8_t result;**

    smallNumber = 255;
    **result = smallNumber + 1;**
    printf("255+1 is %d\n", **result**);
    return (0);
}

现在结果是 0。为什么?因为我们的计算返回了 0b1111 1111 + 0b000 0001 = 0b1 0000 0000。由于变量的存储空间有限,只存储了粗体部分。

溢出发生在结果对机器处理过大的情况下。在这种情况下,9 位结果无法适应 8 位值。想象一下汽车的里程表。它可以显示 999,999 英里的里程。如果有人开了一百万英里会发生什么?

了解编译器如何操作数字是编写良好的嵌入式程序的关键。例如,我曾经有过一个将高度作为无符号数的 GPS(它不是设计用于潜艇)。我把它带到了死亡谷,它死了。那是因为当我到达 Badwater Basin 时,高程为-282 英尺时,它无法处理负高程。GPS 的设计者假设所有高度都大于零。毕竟,GPS 不是设计用于水下工作的。因此,对于高度使用无符号整数并不是一个不合理的决定——除了像 Badwater Basin 这样的地方的用户,高度是负数,导致 GPS 死机。这个错误显示了为什么知道你的编号系统的限制是很重要的。

有符号整数类型的二进制补码表示法

有符号数使用一个位(最左边的位)作为符号位:如果该位为 1,则该数为负数。因此,8 位有符号整数(int8_t)可以表示从 127 到-128 的数字。

几乎所有现代计算机使用二进制补码表示负值。二进制补码表示法将一个数存储为从 0 中减去该数的值。

例如,-1 可以通过以下计算确定:

 0000 0000
-0000 0001
 ---------
 1111 1111

之所以有效,是因为计算机会在数字左边添加一个神奇的“借位”位,使得算术运算看起来是这样的:

 1 0000 0000
-  0000 0001
   ---------
   1111 1111

二的补码类似于机械汽车的里程表。假设你买了一辆崭新的车,里程表显示为 000,000。如果你倒车,里程表会显示 999,999,这是 –1 的十补码。

你可能已经注意到,uint8_t 能表示的最大值是 255,而 int8_t 只能存储最大为 127 的值(是其一半)。这是因为一个比特被用作符号位,剩下的七个比特用于存储数字部分。

当我们超出 8 位有符号数的边界时会发生什么?我让你自己去探索这个问题。看看 127 + 1 和 –128 – 1 会发生什么。还要看看 –(–128) 会发生什么,即 –128 的取反操作。

简写操作符

你已经了解了整数以及可以对它们执行的简单操作,但为了让你更快地进行算术运算,C 语言提供了一些简写操作符。

例如,考虑将一个值加到一个数字上,如下所示:

aNumber = aNumber + 5;

你可以将这个操作缩写为以下形式:

aNumber += 5;

你可以对所有其他算术操作符执行类似的简化。

此外,你可以将将 1 加到数字上的操作简化为:

aNumber += 1;

它可以简化为这样:

++aNumber;

要将数字减去 1,可以使用 --(减减)运算符。

有一个警告。C 语言允许你将递增(++)和递减(--)操作与其他语句结合使用:

result = ++aNumber;   // Don't do this.

请不要这样做,因为这可能会导致程序出现未定义的行为。例如,考虑以下语句:

aNumber = 2;
result = ++aNumber * ++aNumber + ++aNumber;

第二个语句告诉 C 语言递增 aNumber,然后再递增一次。接着它将 aNumber 与自身相乘,并再次递增 aNumber。最后,它将结果加到最终结果中。

不幸的是,没有任何东西告诉 C 语言这些操作必须按照我在这里列出的顺序进行。例如,所有的递增操作可以放在开头,导致结果为 (5 × 5 + 5) = 30。或者它们可以一次一个地执行,那么结果将是 (3 × 4 + 5) = 17。因此,务必将 ++-- 独立放在一行。

还有一点:递增和递减操作有两种形式。你可以将操作符放在你想递增的变量之前或之后:

aNumber = 5;
result = ++aNumber;
aNumber = 5;
result = aNumber++;

这些会做些略有不同的事情。我留给读者写一个小程序,打印前述代码的结果,并弄明白它们的作用——然后请永远不要再将 ++ 与其他语句结合使用。

使用位操作控制内存映射 I/O 寄存器

我们可以将八个位组织成一个单一的数字,但这些位也可以表示八个不同的东西。例如,它们可以连接到八个不同的 LED 灯上。事实上,当你将值放入特殊的内存位置——称为内存映射 I/O 寄存器——时,这些值会打开或关闭 I/O 引脚。由于寄存器的字节有八个位,一个寄存器可以控制八个 LED 灯。(或者,在我们的例子中,控制一个 LED 和七个引脚,我们可以在这些引脚上连接更多的 LED 灯。)

位通常从 7 到 0 编号,7 是最左边或最重要的位。假设我们的 LED 寄存器设置如下:

位 7 位 6 位 5 位 4 位 3 位 2 位 1 位 0
输出 7 输出 6 输出 5 输出 4 输出 3 输出 2 输出 1 LED 0

假设我们要打开 LED #0。由于每个 LED 都是关闭的,我们的寄存器的值是 0000 0000。要打开 LED #0,我们需要将最后一位翻转为 1。为此,我们只需要将寄存器加 1,得到 0000 0001。LED #0 亮起,其他 LED 保持关闭。

但如果 LED 已经亮了呢?那我们的寄存器会包含 0000 0001,当我们加 1 时,得到的结果是 2,二进制表示为 0000 0010。这样,LED #0 会关闭,输出#1 会亮起。这不是我们想要的结果。

这里的问题是我们使用的算术运算符将我们的 8 位整数视为单一的整数,而按位操作符则将数字视为一组独立的位,每个位都可以独立开关和测试。

第一个按位操作符是|)。OR 的单比特版本,如果两个操作数中有任何一个是 1,则结果为真(或 1)。我将通过一个真值表来展示它的运作方式。这就像你在一年级时使用的加法和乘法表,只不过它展示了像 OR 这样的布尔操作符的运算。

OR 的真值表如下所示:

或(&#124; 0 1
0 0 1
1 1 1

OR 是一个按位操作符,这意味着为了将两个 8 位值进行“或”运算,你需要对两个值中的每一对位执行操作。例如:

 0010 0101
| 0000 1001
  ---------
  0010 1101

要设置位 0(即打开 LED #0),我们可以使用以下 C 代码:

ledRegister = ledRegister | 0x01;

另外,我们可以使用以下简写操作符:

ledRegister |= 0x01;

&)操作符只有在两个操作数都为真时才返回真(1)。以下是 AND 的真值表:

与(& 0 1
0 0 0
1 0 1

像 OR 操作符一样,AND 操作符作用于每一对位:

 0010 0101
& 0000 1001
  ---------
  0000 0001

要关闭 LED #0,我们可以使用以下操作将位 0 设置为 0:

ledRegister &= 0b11111110;

这个命令将我们的寄存器与一个位模式进行“与”操作,该模式将除了位 0 以外的所有位都设为 1,因此位 0 将被清除,其他所有位保持不变。(将一个位与 1 进行“与”操作可以保持其值。)

取反

取反NOT)操作符(~)接受一个操作数并对其进行取反。因此,如果该位为 0,它变为 1;如果该位为 1,它变为 0。NOT 操作符的真值表相当简单:

0 1
~ 1 0

以下示例演示了该操作符的运作方式:

~ 0000 0001
  ---------
  1111 1110

使用我们到目前为止学过的按位操作符,我们已经可以编写代码来关闭所有寄存器,然后打开和关闭 LED:

const uint8_t LED_BIT = 0b0000001;

// Turn off everything.
ledRegister = 0;

// Turn on the LED.
ledRegister |= LED_BIT;

// Wait a while.
sleep(5);

// Turn off the LED.
ledRegister &= ~LED_BIT;

这正是第三章中的闪烁程序所做的,只不过 STM 库将这些细节隐藏了起来。

异或

按位操作符异或^)的结果为真,当且仅当两个对应的位中有一个为 1,而另一个为 0。下面是它的真值表:

异或(^ 0 1
0 0 1
1 1 0

为了看看它是如何工作的,请考虑以下示例:

 0010 0101
^ 0000 1001
  ---------
  0010 1100

异或在我们想要反转 ledRegister 中 LED 的值时非常有用,像这样:

ledRegister ^= LED_BIT; // Toggle the LED bit.

反转 LED 会让它慢慢闪烁。

移位

左移操作符(<<)将变量的内容向左移动指定的位数,缺失的位填充为零。例如,考虑以下操作:

uint8_t result = 0xA5 << 2

这会导致计算机将位向左移动两个位置,结果如下:

1010 0101

变为如下:

1001 0100

右移操作符(>>)稍微复杂一些。对于无符号数,它的工作方式与左移相似,只不过它将位向右移动。再次强调,计算机会为缺失的位填充零。因此,uint8_t result = 0xA5 >> 2; 将被计算为如下:

1010 0101

变为如下:

0010 1001

但是当数字是有符号的时,计算机会使用符号位来补充缺失的位。例如,考虑以下操作:

int8_t result = 0xA5 >> 2;  // Note the lack of "u"

这将被计算为如下:

1010 0101

变为如下:

1110 1001

因为这是一个有符号数字向右移位,右侧缺失的位会用符号位的副本来填充,因此结果是 0xE9,即 -23。

定义位的含义

硬件人员喜欢用位来定义事物。这是因为当信号从芯片输出时,它们通过硬件上的单个引脚离开,这些引脚有像 GPIO A-3 这样的名称(表示 GPIO 寄存器 A,位 3)。由于单个引脚上的信号要么是(1),要么是(0),因此可以用一个单独的位来表示它。

但当程序员看到信号时,它已经与其他信号一起被打包成一个 8 位、16 位或 32 位寄存器。因此,我们需要一种方法来轻松地将硬件语言(例如“位 3”)转换成软件语言(如“0x04”)。适当使用移位操作符可以在这方面提供很大的帮助。

例如,假设我们有一个硬件规格如下的灯光板:

+----+----+----+----++----+----+----+----+
|  7 |  6 |  5 |  4 ||  3 |  2 |  1 |  0 |
| MF | DF | OL | OP || PW | PF | AP | CF |
+----+----+----+----++----+----+----+----+
  1. MF(位 7)主故障:当任何其他故障灯亮起时,主故障灯也会亮起。

  2. DF(位 6)数据故障:传入数据不一致或损坏。

  3. OL(位 5)油位低:蓄能器中的油位过低。

  4. OP(位 4)油压:蓄能器油压过低。

  5. PW(位 3)电源故障:主电源已故障。

  6. PF(位 2)位置故障:位置框架已触及限位开关,当前位置不正确。

  7. AP(位 1)气压:空气压缩机故障。

  8. CF(位 0)清洁过滤器:空气压缩机的过滤器需要清洁。

每个比特都连接到一个指示灯。灯电路连接到我们控制器的 GPIO 引脚。例如,如果我们设置 GPIO 设备的第 0 个比特,"清洁滤网"指示灯将亮起:

ledRegister = 1;  // Turn on clean filter.
                  // (Turn all others off.)

即便如此,记住哪个数字对应哪个比特仍然不容易。比特 0 是第一个值,比特 1 是第二个值,以此类推。快速回答:第 6 个值代表哪个比特?有一种很好的方法可以简化这个问题。比特 0 的值为 1,等同于表达式(1 << 0)。比特 1 的值为 2,等于(1 << 1),比特 3 是(1 << 3),依此类推。从中我们可以很容易看出比特 5 是(1 << 5)。利用这个系统,我们可以定义常量来表示每个比特:

const uint8_t MASTER_FAIL       = (1 << 7);
const uint8_t DATA_FAIL         = (1 << 6);
const uint8_t OIL_LOW           = (1 << 5);
const uint8_t OIL_PRESSURE      = (1 << 4);
const uint8_t POWER_FAILURE     = (1 << 3);
const uint8_t POSITION_FAULT    = (1 << 2);
const uint8_t AIR_PRESSURE      = (1 << 1);
const uint8_t CLEAN_FILTER      = (1 << 0);

让我们再次打开CLEAN_FILTER LED,并且不管其他指示灯,这次我们使用新的常量来引用相关比特:

ledRegister |= CLEAN_FILTER; // Turn on clean filter.

请注意,这里我们还使用了本章之前介绍的|=简写操作符。

同时设置两个比特的值

现在假设我们想要设置POWER_FAILUREMASTER_FAIL的值。我们可以通过以下语句来实现:

ledRegister |= MASTER_FAIL | POWER_FAILURE;

由于MASTER_FAIL在第 7 位的值为 1,任何非零值都会导致该比特值为 1,因此MASTER_FAIL比特将会在面板上被设置。

关闭一个比特

我们使用以下模式来关闭一个比特:

bitSet &= ~bitToTurnOff;

为了理解这个操作是如何工作的,让我们通过以下语句详细解析:

ledRegister &= ~(MASTER_FAIL | POWER_FAILURE);

让我们从(MASTER_FAIL | POWER_FAIL)的结果开始:

1000 1000 (MASTER_FAIL | POWER_FAIL)

现在我们应用反转或 NOT(~)操作符:

0111 0111 ~(MASTER_FAIL|POWER_FAIL)

接下来,我们看看现有的ledRegister值。在这个例子中,它设置了MASTER_FAILCLEAN_FILTER

1000 0001 (ledRegister: MASTER_FAIL, CLEAN_FILTER)

现在我们将结果进行“AND”操作:

0111 0111 ~(MASTER_FAIL|POWER_FAIL)
1000 0001 (ledRegister: MASTER_FAIL, CLEAN_FILTER)
Result:  0000 0001 (CLEAN_FILTER)

检查比特的值

以下程序展示了位操作(bit-banging)的典型用法,这种技术用于单独打开和关闭比特。它还包含了检查不同比特值的逻辑:

/*
 * Program to demonstrate the use of bit operations
 */
#include <stdio.h>
#include <stdint.h>

//< Master fail -- shows if any other error is present.
const uint8_t MASTER_FAIL       = (1 << 7);
//< Indicates that inconsistent data was received.
const uint8_t DATA_FAIL         = (1 << 6);
//< Oil container is low.
const uint8_t OIL_LOW           = (1 << 5);
//< Oil pressure is low.
const uint8_t OIL_PRESSURE      = (1 << 4);
//< Main power supply failed.
const uint8_t POWER_FAILURE     = (1 << 3);
//< We told the position to go to x and it didn't.
const uint8_t POSITION_FAULT    = (1 << 2);
//< Air compressor stopped.
const uint8_t AIR_PRESSURE      = (1 << 1);
//< Air filter has reached end of life.
const uint8_t CLEAN_FILTER      = (1 << 0);
/*!
 * Prints the state of the bits
 * (Substitutes for a real LCD panel)
 *
 * \param ledRegister Register containing the LED bits
 */
static void printLED(const uint8_t ledRegister)
{
    printf("Leds: ");
    if ((MASTER_FAIL & ledRegister) != 0)
        printf("MASTER_FAIL ");
    if ((DATA_FAIL & ledRegister) != 0)
        printf("DATA_FAIL ");
    if ((OIL_LOW & ledRegister) != 0)
        printf("OIL_LOW ");
    if ((OIL_PRESSURE & ledRegister) != 0)
        printf("OIL_PRESSURE ");
    if ((POWER_FAILURE & ledRegister) != 0)
        printf("POWER_FAILURE ");
    if ((POSITION_FAULT & ledRegister) != 0)
        printf("POSITION_FAULT ");
    if ((AIR_PRESSURE & ledRegister) != 0)
        printf("AIR_PRESSURE ");
    if ((CLEAN_FILTER & ledRegister) != 0)
        printf("CLEAN_FILTER ");
    printf("\n");
}

int main()
{
    uint8_t ledRegister = 0x00;         // Start with all off.

    printLED(ledRegister);

    // Power went out.
 ledRegister |= POWER_FAILURE | MASTER_FAIL;
    printLED(ledRegister);

    // Now the air went out.
    ledRegister |= AIR_PRESSURE;
    printLED(ledRegister);

    // Power back, air out, so master is on.
    ledRegister &= ~POWER_FAILURE;
    printLED(ledRegister);
    return (0);
}

让我们从printLED函数开始,里面包含了多行测试每个比特并在其被设置时打印消息的代码。(你将在第五章中了解用于执行此操作的if语句。)要理解测试的逻辑,看看以下语句:

if ((MASTER_FAIL & ledRegister) != 0)
    printf("MASTER_FAIL ");

如果第一行中的表达式不等于 0,消息将被打印出来。因为表达式使用了 AND 操作符,ledRegister中的每个比特必须与MASTER_FAIL中对应的比特匹配,才能使该比特的值为 1。如果至少有一组比特都为 1,则会执行printf。换句话说,在幕后,操作类似于以下方式:

 1000 0000 (MASTER_FAIL)
& 1000 0001 (ledRegister with MASTER_FAIL and CLEAN_FILTER set)
  ---------
= 1000 0000 (Since this is not zero, print "MASTER FAIL")

整个函数将执行类似的测试,并打印出寄存器中所有被设置的比特。这里使用此函数是因为我们没有硬件指示灯面板,我们希望查看发生了什么。

程序后续部分,我们会操作比特。例如,通过打开POWER_FAILURE和 _MASTER_FAIL比特来模拟电力故障。因此,当我们现在打印 LED 时,得到如下消息:

Leds: MASTER_FAIL POWER_FAILURE

程序的其余部分设置和清除各种位,以生成以下消息:

Leds:
Leds: MASTER_FAIL POWER_FAILURE
Leds: MASTER_FAIL POWER_FAILURE AIR_PRESSURE
Leds: MASTER_FAIL AIR_PRESSURE

总结

本章涵盖了你可以对简单整数执行的操作。包括常见的加、减、乘、除,但你还学到了计算机如何存储数据,以及最重要的,当遇到溢出等问题时会发生什么。

你还学习了位操作,它将整数视为 8 位、16 位或 32 位的组合。这非常重要,因为嵌入式程序员经常处理位映射寄存器。例如,我们用来开关 LED 的 GPIO 寄存器包含了其他 31 个 GPIO 的位。其他 31 个引脚与我们的 LED 完全无关,并具有其他功能(或者如果我们将它们接线到其他设备,它们将有其他功能)。

在下一章中,你将学习如何根据这些计算做出决策。

编程问题

  1. int16_t 的最大值是 32,767。编写程序找出 32,767 + 1 的 int16_t 值。

  2. 有一个串行 I/O 寄存器,包含一个 2 位的校验值,如下所示:

    +----+----+----+----++----+----+----+----+
    |  7 |  6 |  5 |  4 ||  3 .  2 |  1 .  0 |
    | IE | TE | RD | BR || Parity  | X Bits  |
    +----+----+----+----++----+----+----+----+
    
    1. 编写表达式从寄存器中提取校验和数字(范围为 0 到 3)。校验位存储在第 2 位和第 3 位,作为一个 2 位无符号二进制数。

    2. 编写代码将值设置为 2(二进制 0x10)。

第五章:决策和控制语句

计算机是一种强大的工具,因为它可以根据接收到的数据做出决策。例如,计算机可以在按钮被按下时打开 LED,并在按钮没有被按下时关闭 LED。在这一章中,我们将了解 C 语言中的各种决策和控制语句如何工作。然后,我们将它们应用到嵌入式编程中,使我们的设备对按钮按下做出响应。

if 语句

我们使用if语句来仅在某个条件为真时执行某段代码。以下是该语句的一般形式:

if (`condition`)
    `statement`;

要有条件地执行多个语句,可以将受条件影响的语句块放在大括号({})中,如下所示。C 会将这些语句视为一个整体:

if (`condition`)  {
    `statement`;
    `statement`;
    `statement`;
    // ...
}

C 语言认为任何非零值为真,零值为假。因此,如果条件为非零值,语句将被执行;如果条件为零值,语句将不会执行。

表 5-1 列出了其他比较运算符。

表 5-1:比较运算符

运算符 描述
== 等于
< 小于
<= 小于或等于
!= 不等于
> 大于
>= 大于或等于

例如,如果你希望某段代码仅在一个变量的值为 5 时才执行,你可以使用等于(==)运算符,如下所示:

if (aNumber == 5) {
    printf("The number is 5\n");
}

提个警告:C 语言允许在条件语句中进行赋值。例如,下面的代码,在if语句中给变量赋值为 7,是合法的:

if (aNumber = 7) {
    printf("Something happened\n");
}

这等同于以下代码,它在赋值后测试变量是否等于零:

aNumber = 7;        // Assignment
if (aNumber != 0) { // Test against zero
    printf("Something happened\n");
}

它与下面的条件不同,后者测试一个变量是否等于 7:

if (aNumber == 7)

这个问题在 C 语言的早期很麻烦,当时编译器技术还不像今天这么先进。你可能会犯错误,偶然写出像以下这样的代码,虽然代码会更复杂:

aNumber = 5;
if (aNumber = 7) {  // Notice the missing '=' character.
    printf("Something happened\n");
}

if语句中的代码会被执行,因为变量会被重新赋值为 7,这个非零值会立即使条件为真,尽管你本意是让aNumber等于 5,而不是 7。使用现代的 GCC 编译器时,在条件中进行赋值会产生警告:

equal.c:14:5: warning: suggest parentheses around assignment used as truth value [-Wparentheses]
 if (aNumber = 7) {

这里,GCC 正在告诉你,如果你想抑制警告,因为你确实希望将赋值语句和if语句合并,你应该按照如下方式编写代码:

if ((aNumber = 7)) {   // Very very lousy programming

我添加了这个注释,因为我认为将语句合并在一起是一种不好的编程习惯。让每个语句做一件事。例如,当你需要做赋值和测试时,先进行赋值,然后再做测试。

if/else 语句

当我们想要根据条件判断是否执行某些语句时,使用if/else语句。例如,考虑以下代码:

if ((number % 2) == 0) {
    printf("Number is even\n);
} else {
    printf("Number is odd\n);
}

如果 number 变量的值在除以 2 后余数为 0,则这段代码会打印一条信息,说明该数字是偶数;否则,它会打印一条信息,说明该数字是奇数。

现在我们来看看 C 语言中的另一个不太明显的地方:在 ifelse 后面,如果只有一个语句,你不需要使用大括号({})。请考虑下面的代码,故意将缩进写得不正确:

if (a == 1)
    if (b == 2)
        printf("Condition orange\n");
  else
    printf("Condition pink\n");

else 应该和哪个 if 语句配对,第一个 if 还是第二个 if

a. 第一个 ifif (a == 1)

b. 第二个 ifif (b == 2)

c. 如果你不写这样的代码,就不必担心这些愚蠢的问题。

让我们选择答案 C 并重写代码。在以下代码中,else 应该与哪个 if 配对?

if (a == 1) {
    if (b == 2) {
        printf("Condition orange\n");
    } else {
        printf("Condition pink\n");
    }
}

在这里,你可以判断它与第二个 if 配对。这也是前一个问题的“官方”答案,但通过清晰地编写代码,你可以得出答案,而无需仔细研读 C 语言标准。

需要注意的是,一些编码风格指南要求你在 if 语句的主体部分始终使用大括号包裹;然而,这一决定最好由程序员自己做出。

循环语句

循环 是一种编程特性,只要满足某个条件,就会重复执行某段代码。C 语言有三种循环语句:whilefordo/while。我们将从 while 循环开始,因为它是最简单的,然后再讲解 for 循环。由于 do/while 很少使用,我们不会讨论它。

while 循环

while 语句的一般形式如下:

while (`condition`)
    `statement`;

请记住,statement 可以是一个单独的 C 语句,也可以是由 {} 括起来的一系列语句。为了展示 while 循环的实用性,让我们编写一个程序,测试从 1 到 10 的数字,看看哪些是偶数,哪些是奇数,如 列表 5-1 所示。

odd.c

/*
 * Test to see if the numbers 1 through 10 are even
 * or odd.
 *
#include <stdio.h>

int main()
{
    int aNumber;  // Number to test for oddness
    aNumber = 1;
    while (aNumber <= 10) {
        if ((aNumber % 2) == 1) {
            printf("%d is odd\n", aNumber);
        } else {
            printf("%d is even\n", aNumber);
        }
        ++aNumber;
    }
    return (0);
}

列表 5-1:测试奇偶性

main 函数中,我们声明一个变量 aNumber,用于存储在 while 循环中要测试的值。然后我们将该变量设置为 1

接下来,我们设置 while 循环,使其在 aNumber 小于或等于 10 时运行。在循环内部(即在大括号内),我们使用前面章节中介绍的 if/else 语句,检查 aNumber 除以 2 的余数。这样我们就能知道它是偶数还是奇数。

在循环结束之前,我们通过 ++aNumber;aNumber 增加 1。因此,下次循环时,aNumber 的值将变为 2,依此类推。当 aNumber 的值达到 11 时,循环结束,程序以返回值 0 退出。

当这个程序运行时,输出结果如下所示:

1 is odd
2 is even
3 is odd
4 is even
5 is odd
6 is even
7 is odd
8 is even
9 is odd
10 is even

for 循环

我们的 while 循环有三个主要部分:初始化语句(aNumber = 1),测试语句(检查 aNumber 是否大于或等于 10),以及在循环执行后递增变量的语句(++aNumber)。

这种设计模式(初始化、条件和递增)非常常见,以至于它有了自己的语句:for 语句。我们这样写这个语句:

for (`initialization`; `condition`; `increment`)

为了查看它如何工作,我们将把我们的 while 循环转换为 for 循环。以下代码展示了使用 for 语句的相同奇偶程序:

/*
 * Test to see if the numbers 1 through 10 are even
 * or odd.
 */
#include <stdio.h>

int main()
{
    int aNumber;  // Number to test for oddness
    for (aNumber = 1; aNumber <= 10; ++aNumber) {
        if ((aNumber % 2) == 1) {
            printf("%d is odd\n", aNumber);
        } else {
            printf("%d is even\n", aNumber);
        }
    }
    return (0);
}

注意 for 子句包括我们的三个语句,它们由分号分隔。

for 循环中的任意一条语句都可以被省略。例如,我们本可以通过在进入循环之前初始化 aNumber 来编写程序:

aNumber = 1;
for (; aNumber <= 10; ++aNumber) {

另外,我们可以在循环体内递增变量的值,而不是在 for 子句中:

for (aNumber = 1; aNumber <= 10;) {
    // Oddness test
    ++aNumber;

然而,如果条件被省略,循环将永远不会终止。这就是为什么以下语句会永远循环的原因:

for (;;)

我们在嵌入式程序中使用这种“永远循环”的方式,因为程序应该永远不会退出。

使用按钮

现在我们知道了如何做决策,我们将编写一个程序,基于我们开发板默认的唯一输入源——蓝色按钮来做决策。我们的程序将利用我们知道如何控制的唯一输出:LED。让我们把开发板变成一个小型的计算机灯。

启动 STM32 的系统工作台并开始一个新的嵌入式项目。main.c 文件应该如下所示:

/**
  **************************************************************
  * @file    main.c
  * @author  Steve Oualline
  * @version V1.0
  * @date    11-April-2018
  * @brief   Push the button -- flash the LED
  **************************************************************
*/

#include "stm32f0xx.h"
#include "stm32f0xx_nucleo.h"

int main(void)
{
  1 GPIO_InitTypeDef GPIO_LedInit; // Init. for the LED
    GPIO_InitTypeDef GPIO_ButtonInit;  // Init. for push button
    GPIO_PinState result; // The result of reading the pin

    HAL_Init();

    // LED clock initialization
  2 LED2_GPIO_CLK_ENABLE();

    // Initialize LED.
  3 GPIO_LedInit.Pin = LED2_PIN;
    GPIO_LedInit.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_LedInit.Pull = GPIO_PULLUP;
    GPIO_LedInit.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(LED2_GPIO_PORT, &GPIO_LedInit);

    // Push button clock initialization
    USER_BUTTON_GPIO_CLK_ENABLE();

 /* Configure GPIO pin : For button */
    GPIO_ButtonInit.Pin = USER_BUTTON_PIN;
    GPIO_ButtonInit.Mode = GPIO_MODE_INPUT;
  4 GPIO_ButtonInit.Pull = GPIO_PULLDOWN;
    GPIO_ButtonInit.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(USER_BUTTON_GPIO_PORT, &GPIO_ButtonInit);

    for(;;) {
        // Get the current state of the push button
        result = HAL_GPIO_ReadPin(USER_BUTTON_GPIO_PORT,
                                  USER_BUTTON_PIN);
        if (result == GPIO_PIN_SET)
            HAL_GPIO_WritePin(LED2_GPIO_PORT,
                              LED2_PIN, GPIO_PIN_SET);
        else
            HAL_GPIO_WritePin(LED2_GPIO_PORT,
                              LED2_PIN,GPIO_PIN_RESET);
    }
}

让我们详细解析这段代码。

初始化

为了开始我们的程序,我们将使用硬件抽象层(HAL)定义的大量代码。在接下来的几章中,你将了解这些部分的每一个。

首先,我们定义一个名为 GPIO_LedInit 的新变量,其类型为 GPIO_InitTypeDef 1。GPIO_InitTypeDef 类型不是标准的 C 类型:它是由在程序顶部引入的 HAL 包含文件定义的。此时,这个类型的细节不重要。我们需要这个变量来定义 LED 引脚的配置方式。(你将在后面的章节中了解如何定义变量类型。)

同样地,我们定义了另一个变量 GPIO_ButtonInit,用于定义按钮 GPIO 引脚的配置方式,并定义一个变量来保存按钮引脚的状态(GPIO_PinState)。

main 函数内,我们首先调用 HAL_Init 来设置硬件,就像我们在第三章的闪烁程序中所做的那样。你需要在每个 STM32 程序的顶部调用 HAL_Init

接下来,我们为 LED2(用户 LED)打开时钟 2。时钟 控制我们写入 GPIO 引脚的数据如何到达实际的引脚。如果没有这一行,写入 LED 将不起作用。虽然它看起来像是一个调用名为 LED2_GPIO_CLK_ENABLE 的函数,实际上它是一个预处理器宏,我们稍后会学习。

现在我们来到了为 GPIO_LedInit 变量 3 赋值的部分,它是一个结构类型,包含我们需要单独赋值的一些元素。稍后你会了解这里发生的详细情况。

类似的代码初始化用于按钮的引脚,唯一不同的是引脚模式设置为 GPIO_MODE_INPUT,因为我们要读取引脚的状态来获取按钮的状态,而不是写入它。

选择下拉电路

请注意,我们将 Pull 字段设置为 GPIO_PULLDOWN 4,而不是 GPIO_PULLUP

Pull 字段告诉 CPU 使用哪种上拉/下拉电路。一个输入引脚可以有三种状态:浮空、上拉和下拉。图 5-1 显示了一个 浮空 输入电路。

f05001

图 5-1:一个浮空电路

当开关 SW1 打开时,User_Button_Pin 上没有电压。因此,它的电压可能是高电平(大约 3 伏特或更高)、低电平(低于大约 3 伏特)或介于两者之间。它可能会被周围的任何杂散电噪声所影响。关键是,除非该信号实际接地或接电源,否则无法确定这个信号的值。

现在让我们看看一个带有 上拉 电路的输入(参见 图 5-2)。

f05002

图 5-2:上拉电路

当 SW1 打开时,电压通过电阻 R1 流动,将 User_Button_Pin 提升(或 拉高)到 VCC 或正电平。当 SW1 关闭时,引脚被短接到地(Gnd)。由于 R1 是一个非常大的电阻,因此流过它的电流可以忽略不计,且引脚的电压为零。

下拉 电路类似,只不过 R1 连接到地,SW1 连接到 VCC,因此当 SW1 打开时,User_Button_Pin 被拉到地(即拉低到零),参见 图 5-3。

f05003

图 5-3:下拉电路

在 STM32 芯片上,电路便宜,而引脚昂贵。因此,芯片的设计者希望最大限度地利用每个引脚。对于每个 GPIO 引脚,都有上拉电阻、下拉电阻以及连接这些电阻的晶体管,这取决于引脚的配置方式。这使得事情变得简单,因为我们不需要自己在板上放置这些电阻。然而,这也使得事情变得复杂,因为我们需要编程来控制它们。图 5-4 显示了 STM32 上单个 GPIO 引脚的内部接线。(这甚至是一个简化版。)需要注意的是,有上拉(R[PU])和下拉(R[PD])的内部电阻,可以打开或关闭。

f05004

图 5-4:STM32 GPIO 引脚的内部接线

我们选择使用下拉电路,因为按钮的另一端连接到 +5 V,所以当按钮未按下且开关打开时,下拉电阻会生效,GPIO 引脚的值为 0。当按钮被按下时,按钮提供的 5 V 使得 GPIO 引脚的值为 1。(一些电流会通过电阻流动,但这个电流量可以忽略不计。)

获取按钮的状态

接下来,我们进入主循环。for语句会无限循环,或者直到我们重置机器。在循环内,第一条语句初始化了一个名为result的变量,类型为GPIO_PinState(由 HAL 头文件定义的非标准类型),并赋值为调用HAL_GPIO_ReadPin函数的结果。HAL_GPIO_ReadPin读取连接到按钮的 GPIO 引脚。更具体地说,它读取 32 位的 GPIO 端口USER_BUTTON_GPIO_PORT,然后测试USER_BUTTON_PIN的值。(我们在上一章中涉及的许多位操作都在HAL_GPIO_ReadPin函数内部进行。)

现在我们通过将result与符号GPIO_PIN_SET(由 HAL 代码定义的常量)进行比较来测试引脚是否已设置,然后如果按钮引脚已设置,我们打开 LED 引脚。否则,我们关闭 LED 引脚。(实现这一点的代码在第三章中已涵盖。)

运行程序

当我们运行程序时,LED 灯会亮。按下用户按钮,LED 灯熄灭。松开按钮,LED 灯会重新亮起,如此循环。虽然这是一个简单的操作,但我们花了很多时间学习才做到这一点。

不幸的是,我们做了一个非常复杂的手电筒,它的按钮会把灯关掉,而不是打开。好消息是,它是计算机控制的,所以我们可以通过软件来修复它。我会把这个问题留给你去解决。

循环控制

我们的编程示例使用了基本的循环语句,但 C 语言提供了几种方法来为你的循环增加额外的控制。两种主要的控制语句是breakcontinue

break 语句

break语句允许你提前退出循环(也就是说,跳出循环)。例如,考虑以下简短程序,它在一个数组中查找一个关键数字。如果找到了这个数字,程序会打印它:

/*
 * Find the key number in an array.
 */
#include <stdio.h>
#include <stdbool.h>

#define ARRAY_SIZE  7   // Size of the array to search

int main()
{
    // Array to search
    int array[ARRAY_SIZE] = {4, 5, 23, 56, 79, 0, -5};
    static const int KEY = 56; // Key to search for

    for (unsigned int index = 0; index < ARRAY_SIZE; ++index) {
        if (array[index] == KEY) {
            printf("Key (%d) found at index %d\n",
                   KEY, index);
          1 break;
        }
    }
    return (0);
}

这个程序在一个数组中查找一个关键值。一旦我们找到了这个关键值,任务就完成了。我们不想继续执行整个循环的其余部分,因此我们使用break语句 1 来退出。

continue 语句

另一个循环控制语句continue会从循环的顶部开始执行。以下程序打印一个命令列表,跳过以点开头的命令。当我们遇到这些命令时,使用continue命令跳到循环顶部:

/*
 * Find the key number in an array.
 */
#include <stdio.h>

#define COMMAND_COUNT 5 // Number of commands

// Commands, ones beginning with . are secret
static const char commands[COMMAND_COUNT][4] = {
    "help",
    "exec",
    ".adm",
    "quit"
};
int main()
{
    // Print the help text
    for (unsigned int index = 0;
         index < COMMAND_COUNT;
         ++index) {
      1 if (commands[index][0] == '.') {
            // Hidden command
          2 continue;
        }
        printf("%s\n", commands[index]);
    }
    return (0);
}

这个程序的关键是测试是否有点命令 1,然后continue 2 来重新开始(从而跳过循环的其余部分和printf)。

反模式

在学习如何使用循环时,你还应该学会如何使用循环。一些编程模式已经渗透到编程行业,这些模式不仅没有促进良好的编程,反而让事情变得更复杂,因此它们被称为反模式。我要警告你两种。

空的 while 循环

第一个反模式是空的while循环。考虑以下代码:

while (GPIO_PIN_SET == HAL_GPIO_ReadPin(USER_BUTTON_GPIO_PORT, USER_BUTTON_PIN))**;**
{
    // ... do something
}

你可能会认为这段代码会在用户按下按钮时重复某个动作。但事实并非如此。原因在于while循环只影响一个语句。你可能会认为while循环中的语句是被大括号括起来的那个,但在大括号前还有一个语句。这个语句非常简短,非常容易忽视,因为它是一个空语句。我们可以通过语句后的分号来知道它的存在:

while (GPIO_PIN_SET == HAL_GPIO_ReadPin(USER_BUTTON_GPIO_PORT, USER_BUTTON_PIN))**;**
{
    // ... do something
}

分号很容易被忽视。这就是为什么我必须将其加粗的原因。这也是为什么这种编码方式被认为是糟糕的,十分糟糕。

continue语句帮助我们解决了问题。我们可以将这个while循环重写如下:

while (GPIO_PIN_SET == HAL_GPIO_ReadPin(BTN_PORT, BTN_PIN))
    continue;
{
    // ... do something
}

现在我们可以看到,while循环仅控制continue语句,别无他物。缩进和明显的大语句告诉我们这一点。

在 while 中赋值

第二个反模式是while循环中的赋值操作:

while ((result = ReadPin(BTN_PORT, BTN_PIN)) == GPIO_PIN_SET) {
    // ... statements
}

这条语句同时做了两件事。首先,它调用ReadPin并将结果赋值给result。其次,它测试result是否已设置。

如果程序一次只做小而简单的事情,它们会更容易理解和维护。这个快捷方式节省了几行新代码,但却以程序的可读性为代价。它本可以像这样写:

while (1) {
    result = ReadPin(BTN_PORT, BUTTON_PIN);
    if (result != GPIO_PIN_SET)
        break;
    // ... statements
}

我们的目标应该是让程序尽可能简单且易于阅读,而不是尽可能简洁和巧妙。

总结

现在我们已经掌握了计算中的两个关键方面:数字和如何基于这些数字做出决策。单一的决策可以通过if语句做出,而whilefor语句则让我们能够做出重复性的决策。breakcontinue关键字则赋予我们更多的控制权。

决策语句让我们能够编写一个在按下按钮时闪烁 LED 的小程序。尽管程序很简单,但我们接受了输入,处理了它,并产生了输出,这构成了大量嵌入式程序的基础。在接下来的几章中,你将学习如何处理更复杂的数据和更复杂的处理方式,这一切都建立在你在这里学到的基础上。

编程问题

  1. 编写一个程序,生成从 0 × 0 到 9 × 9 的乘法表。

  2. 编写一个程序,统计uint32_t整数中被设置的比特位数。例如,数字 0x0000A0 中有两个比特位被设置。

  3. 编写一个程序,在 LED 上闪烁一个图案。使用一个整数数组来控制 LED 点亮和熄灭的延时。重复该图案。

  4. 编写一个程序,使用 LED 灯以摩尔斯电码闪烁字母“H”。按下按钮时,它会闪烁字母“E”。如果继续按下按钮,将会显示“HELLO WORLD”全部的摩尔斯电码。

  5. 编写一个程序,计算前 10 个质数。

  6. 编写一个程序,找到集合中最大的和最小的元素。

  7. 编写一个程序,遍历字符串并仅打印元音字母。

第六章:数组、指针和字符串

到目前为止,我们使用了非常简单的整数来表示数据。但是,面对现实吧,并不是所有的东西都能用一个整数来描述。在这一章中,你将学习一些组织数据的基础知识。

首先,你将学习数组,它是一个数据结构,可以通过整数索引选择多个项。我还会稍微超越简单的数组,展示编译器如何实现数组,以及数组如何实现像 "Hello World!\n" 这样的字符串。过程中,你将学习如何使用 C 的 char 类型。

你将学习如何使用内存指针,它保存内存位置的地址,然后了解数组和指针在相似之处和不同之处。

你还将学习如何使用 const 修饰符创建一个不能被修改的变量,也就是常量。常量通过防止对数据的无意修改,帮助你组织数据。

数组

你已经看到如何声明一个基本变量,例如这样:

int aNumber;        // A number

这个变量一次只能存储一个值。然而,我们也可以声明一个变量来存储一组值,使用数组声明,它在方括号中指定数组包含的项数:

int anArray[5];     // An array of numbers

这段代码声明了一个包含五个整数的数组,编号为 0、1、2、3 和 4。数组的元素编号叫做索引,而第一个索引是 0,而不是 1。要访问数组中的单个元素,我们使用方括号并包含索引。例如,下面这一行将 99 的值赋给数组中的第四个元素(索引为 3 的那个):

anArray[3] = 99;    // Store an element in the array.

if (anArray[3] == 98) {

C 语言中没有任何东西阻止你使用不存在的数组元素进行索引,但尽管可以使用非法索引,结果是未定义的(这意味着很可能会发生一些错误)。例如,anArray 的最后一个元素是 4,所以以下声明是合法的:

anArray[4] = 0;     // Legal

然而,这个例子并不是:

anArray[5] = 9;     // Illegal, 5 is too big

这个语句尝试访问一个不存在的数组元素。

让我们看看数组如何实际工作。查看示例 6-1,这是一个将数组元素求和并输出总和的程序。

/*
 * Sum up a series of numbers.
 */
#include <stdio.h>

int main()
{
    const int NUMBER_ELEMENTS = 5;      // Number of elements
    int numbers[NUMBER_ELEMENTS];       // The numbers
    int sum;                            // The sum so far
    int current;                        // The current number we are adding

    numbers[0] = 5;
    numbers[1] = 8;
 numbers[2] = 9;
    numbers[3] = -5;
    numbers[4] = 22;

    sum = 0;
    // Loop over each element and add them up.
    for (current = 0; current < NUMBER_ELEMENTS; ++current)
    {
        sum += numbers[current];
    }
    printf("Total is %d\n", sum);
    return (0);
}

示例 6-1:基本数组使用

我们从定义一个变量 NUMBER_ELEMENTS 开始,它保存数组中元素的数量。关键字 const 告诉 C 这个变量不能被改变(稍后会详细介绍)。

我们在两个地方使用了这个常量。第一个用于声明数组。第二个用于循环遍历数组的每个元素。虽然我们本可以在这两个地方直接使用值5,但这样做会在代码中引入一个魔法数字。魔法数字是指出现在程序中的多个位置,但与代码的关系不明确的数字。使用魔法数字是有风险的;在这种情况下,如果我们更改了数组声明中的5,我们还需要记得更改循环中的5。通过使用常量声明,我们只需在一个地方定义数组的大小。如果我们将常量更改为14,那么它被使用的每个地方都会自动更新。

回到代码。我们需要在数组中放一些数字,所以我们通过为每个索引赋值来实现。接下来,我们使用for循环访问数组的每个元素。for循环语句展示了 C 语言中常用的遍历数组的方式。循环从零开始,只要索引小于(<)数组的大小,循环就会继续。索引必须小于 5,因为number[5]是一个不存在的元素。

数组可以在声明时初始化,就像简单的变量一样,通过在花括号中列出所有元素:

// Define some numbers to sum.
int numbers[5] = {5 8, 9, -5, 22};

在这种情况下,元素的数量必须与数组的大小匹配,否则会收到警告消息。

C 是一种智能语言。它可以根据元素的数量推断数组的大小,因此这个声明也是有效的:

// Define some numbers to sum.
int numbers[] = {5 8, 9, -5, 22};

底层原理:指针

我的父亲 C. M. Oualline 曾经教导我,“有东西,也有指向东西的指针。”有关这个含义的详细示意图,请参见图 6-1。尽管看起来很简单,理解这张图是极其重要的。

f06001

图 6-1:事物与指向事物的指针

整数是一个东西。实际上,它是一个包含整数的东西。指针是一个东西的地址。

事物有不同的大小。uint64_t整数是一个相对较大的东西,而uint8_t是一个较小的东西。关键在于事物有不同的大小。指针是一个固定的大小。它所指向的东西可以大或小,但指针的大小始终是一样的。

指针对于快速访问数据结构和将数据结构连接在一起非常有用。在嵌入式编程中,指针用于指向内存映射的 I/O 设备,这使得程序能够控制这些设备。

指针的最大优点是它们可以指向任何东西。最大的缺点是它们可以指向不该指向的东西。当这种情况发生时,程序会开始执行奇怪和意外的操作,因此在使用指针时必须非常小心。

要声明一个指针,在声明中使用星号(*)来表示该变量是一个指针,而不是一个东西:

uint8_t* thingPtr;      // A pointer to an integer

地址运算符&)将一个东西转换为指向该东西的指针:

uint8_t thing = 5;      // A thing
thingPtr = &thing;      // thingPtr points to 5.

现在thingPtr指向thing解引用*)操作符将指针转换回一个值:

otherThing = *thingPtr; // Get what's pointed to by thingPtr.

这将把otherThing赋值给thingPtr指向的值。

以下程序展示了这些操作是如何工作的。在这个程序中,我们引入了一种新的printf转换符,%p,它用于打印指针:

/*
 * Demonstrate pointers.
 */
#include <stdio.h>
#include <stdint.h>

int main()
{
    uint8_t smallThing = 5;     // Something small
    uint8_t smallThing2 = 6;    // Something else small
    uint64_t largeThing = 987654321; // Something large

    uint8_t* smallPtr;          // Pointer to small thing
    uint64_t* largePtr;         // Pointer to large thing
  1 printf("smallThing %d\n", smallThing);
    printf("sizeof(smallThing) %lu\n", sizeof(smallThing));
    printf("largeThing %ld\n", largeThing);
    printf("sizeof(largeThing) %lu\n", sizeof(largeThing));

    // smallPtr points to smallThing.
    smallPtr = &smallThing;

  2 printf("smallPtr %p\n", smallPtr);
    printf("sizeof(smallPtr) %lu\n", sizeof(smallPtr));
    printf("*smallPtr %d\n", *smallPtr);

    // smallPtr points to smallThing2.
    smallPtr = &smallThing2;    
    printf("*smallPtr %d\n", *smallPtr);

    largePtr = &largeThing;
    printf("largePtr %p\n", largePtr);
    printf("sizeof(largePtr) %lu\n", sizeof(largePtr));
    printf("*largePtr %ld\n", *largePtr);

    return (0);
}

让我们详细分析一下。我们首先声明了三个变量和两个指针。在命名所有指针时,我们使用了后缀Ptr,使它们非常显眼。此时,smallPtr并没有指向任何特定的对象。

在使用指针之前,让我们先使用我们的smallThing。通过两次调用printf,我们打印了smallThing的值和大小 1。输出将如下所示:

smallThing 5
sizeof(smallThing) 1

现在让我们来看一下指针 2。首先,我们打印指针的值,它是一个内存地址。我们使用的是一台具有 64 位指针的 x86 类型机器,因此指针的值是一个 64 位数字。实际的数字值来自内存的布局方式,具体内容将在第十一章中详细讨论。当我们打印sizeof(smallPtr)时,我们会看到它确实是 8 字节或 64 位长,且smallPtr指向的值是5。总的来说,这三次printf调用将打印以下内容:

smallPtr 0x7fffc3935dee
sizeof(smallPtr) 8
*smallPtr 5

我们对largePtr做了类似的操作。注意,尽管被指向对象的大小不同,但指针的大小始终不变。指针的大小取决于处理器类型,而不是所指向数据的类型。在我们的 STM32 处理器上,地址是 32 位的,因此指针将是 32 位值。而在具有 64 位地址的 x64 机器上,指针的大小是 4 字节:

largeThing 987654321
sizeof(largeThing) 8

largePtr 0x7fffc3935df0
sizeof(largePtr) 8
*largePtr 987654321

要查看指针实际指向的内容,将此程序输入 STM32 Workbench,并使用调试器运行它。在所有内容分配完毕后,设置一个断点并运行程序直到该断点。

打开“变量”面板后,我们可以看到所有变量及其值(参见图 6-2)。

f06002

图 6-2:显示指针的变量面板

通常,指针的值并不是特别有趣。更有趣的是它指向的内容。点击 + 图标可以展开smallPtr条目,我们可以看到smallPtr指向6(也就是字符'\006')。类似地,我们可以看到largePtr指向987654321

数组和指针运算

C 将数组变量和指针视为非常相似。请看以下代码:

int array[5] = {1,2,3,4,5};
int* arrayPtr = array;

我们将arrayPtr赋值为array,而不是&array,因为 C 会在数组像指针一样使用时自动将其转换为指针。事实上,数组和指针几乎是可以互换的,只不过它们的声明方式不同。

现在让我们访问数组的一个元素:

int i = array[1];

这种语法与以下内容相同,它表示获取arrayPtr的值,向其添加 1(按指向的数据类型大小进行缩放),并返回该表达式结果所指向的数据:

int i = *(arrayPtr+1);

以下程序更详细地演示了数组与指针之间的关系:

/*
 * Demonstrate the relationship between arrays and pointers.
 */
#include <stdio.h>
int main()
{
    int array[] = {1,2,3,4,-1}; // Array
    int* arrayPtr = array;      // Pointer to array

    // Print array using array.
    for (int index = 0; array[index] >= 0; ++index) {
        printf("Address %p Value %d\n",
               &array[index], array[index]);
    }
    printf("--------------\n");
    // Same thing with a pointer
    for (int index = 0; *(arrayPtr +index) >= 0; ++index) {
        printf("Address %p Value %d\n",
                arrayPtr + index, *(arrayPtr + index));
    }
    printf("--------------\n");
    // Same thing using an incrementing pointer
    for (int* current = array; *current >= 0; ++current) {
 printf("Address %p Value %d\n", current, *current);
    }

}

该程序首先按常规方式打印每个数组元素的地址和内容:通过使用for循环依次访问每个索引。

在下一个循环中,我们使用指针运算进行打印。现在,我们需要明确理解我们正在处理的内容。变量array是一个数组,表达式array[index]是一个整数,而&(取地址)运算符将一个整数转换为指针,因此&array[index]是一个指针。因此,这段代码会打印数组中每个元素的以下内存地址:

Address 0x7fffa22e0610 Value 1
Address 0x7fffa22e0614 Value 2
Address 0x7fffa22e0618 Value 3
Address 0x7fffa22e061c Value 4

每次指针值增加 4,即一个整数的大小,因此array[0]位于地址0x7fffa22e0610,而array[1]位于比它大 4 字节的内存位置,地址为0x7fffa22e0614

这种方法使用了指针运算。(我们实际上在第一种方法中也使用了指针运算,但 C 隐藏了这一切。)通过这个循环,你可以看到arrayPtr + 10x7fffa22e0614,这与&array[1]完全相同。再次注意,使用指针运算时,所有操作都会自动按所指向项的大小进行缩放。在这种情况下,所指向的数据类型是int,因此表达式arrayPtr + 1实际上是arrayPtr + 1 * sizeof(int),所以0x7fffa22e0610 + 1实际上是0x7fffa22e0610 + 1 * sizeof(int),即0x7fffa22e0614

最后,我们使用递增指针以第三种方式执行相同的操作。

使用指针访问数组很常见,因为许多人认为这样比使用数组索引更高效。毕竟,计算array[index]涉及地址计算,但编译器技术多年来已经取得了很大进步。如今的编译器非常擅长生成更高效的代码,因此使用指针进行数组索引并不比直接使用数组索引更高效。

然而,使用地址逻辑会更为混乱。因为不清楚指向的是何物,数组的边界在哪里,所以第二种和第三种方法应该避免。我将它们包含在示例中是因为许多遗留代码使用指针运算来访问数组,目的是告诉你应该避免这样做。

数组溢出

C 不进行边界检查,即它不会检查你是否尝试访问数组边界外的元素。一个五元素数组(int a[5])的合法元素是a[0]a[1]a[2]a[3]a[4],但没有任何东西阻止你使用非法值,如a[5]a[6]a[7],甚至是a[932343]。非法值的问题在于,它们可能是内存中其他变量或数据的位置。位于列表 6-2 中的程序演示了当你越过数组末尾时会发生什么(即数组溢出)。

array.bad.c

/*
 * Demonstrate what happens
 * when you overflow an array.
 */
#include <stdio.h>

int main()
{
    int numbers1[5] = {11,12,13,14,15};   // Some numbers
    int numbers2[5] = {21,22,23,24,25};   // Variable to be overwritten

  1 printf("numbers2[0] %d\n", numbers2[0]);

  2 numbers1[8] = 99;   // <------------ Illegal

    // Illegal -- loops past the end
    for (int i = 0; i < 9; ++i)
        printf("numbers1[%d] %p\n", i, &numbers1[i]);

    printf("numbers2[%d] %p\n", 0, &numbers2[0]);
  3 printf("numbers2[0] %d\n", numbers2[0]);
    return (0);
} 

列表 6-2:数组溢出

关键点是观察numbers2[0],它在初始化时被设置为 21。当我们第一次打印它时,它的值实际上是 21。然而,当我们稍后再次打印它时,值变成了 99。这是怎么回事呢?

让我们看看这个程序的输出:

numbers2[0] 21
numbers1[0] 0x7ffc5e94ff00
numbers1[1] 0x7ffc5e94ff04
numbers1[2] 0x7ffc5e94ff08
numbers1[3] 0x7ffc5e94ff0c
numbers1[4] 0x7ffc5e94ff10
numbers1[5] 0x7ffc5e94ff14
numbers1[6] 0x7ffc5e94ff18
numbers1[7] 0x7ffc5e94ff1c
numbers1[8] 0x7ffc5e94ff20
numbers2[0] 0x7ffc5e94ff20
numbers2[0] 99

从中我们可以看到,numbers1的内存分配从0x7ffc5e94ff000x7ffc5e94ff13。变量numbers2的内存分配从0x7ffc5e94ff200x7ffc5e94ff33。这种内存布局在表 6-1 中有直观的表示。

表 6-1:内存布局

变量 地址 内容
numbers1 0x7ffc5e94ff00 11
0x7ffc5e94ff04 12
0x7ffc5e94ff08 13
0x7ffc5e94ff0c 14
0x7ffc5e94ff10 15
numbers2 0x7ffc5e94ff20 21
0x7ffc5e94ff24 22
0x7ffc5e94ff28 23
0x7ffc5e94ff2c 24
0x7ffc5e94ff30 25

清单 6-2 中的第 2 行使用了非法索引,因为numbers1只有五个元素。那么,这个操作会覆盖什么内存呢?通过程序的输出,我们看到该值的地址是0x7ffc5e94ff20。巧合的是,这也是numbers2[0]的地址。当我们第二次打印numbers2[0]的内容时,我们的示例程序会立刻显示出内存损坏。

这个程序简单地展示了当数组溢出时可能出现的问题。在实际情况中,识别这种问题要困难得多。通常这些错误表现为程序行为异常,而且通常发生在索引错误之后很久,所以调试起来非常复杂。避免犯这种错误。

初学 C 语言的程序员最常犯的错误是忘记 C 语言数组从 0 开始,到size-1。例如,你可能会写出以下代码:

int array[5];
// Wrong
for (int i = 1; i <= 5; ++i)
    array[i] = 0;

如果你在 Linux 机器上编程,像 Valgrind 和 GCC 地址清理工具这样的工具会在运行时检查数组溢出。在嵌入式世界里,我们没有这些工具,所以只能更加小心。

字符和字符串

我们已经讨论了如何处理数字,但有时候你可能希望在程序中包含其他类型的数据,比如文本。为此,我们引入了一种新的变量类型,char,它表示一个用单引号(')括起来的单个字符。例如,下面的代码创建了一个名为stopchar变量,用于存储字符'S'

char stop = 'S'; // Character to indicate stop

字符串是一个以字符串结束符(\0)字符结尾的字符数组。字符\0也叫做 NUL 字符(一个L),因为在最初的串行通信中,它表示“无”。

为了练习使用字符串,下面我们来看看这个程序,它打印字符串“Hello World”:

/*
 * Hello World using string variable
 */
#include <stdio.h>

// The characters to print
const char hello[] = {'H', 'e', 'l', 'l', 'o', ' ',
                      'W', 'o', 'r', 'l', 'd', '\0'};

int main()
{
    puts(hello); // Write string and newline
    return (0);
}

我们首先定义一个名为hello的字符串,值为"Hello World"。这个初始化显式地定义了字符串的每个元素。在现实生活中你几乎看不到这样的初始化,因为 C 提供了一种简便方式,使得事情变得更加容易。(我们很快就会看到。)这个版本让一切都变得很明显,这对学习有好处,但不利于简洁。

稍后,我们使用标准的 C 函数puts打印字符串。puts函数打印单个字符串,简单易用,而printf可以进行格式化,并且是一个庞大复杂的函数。puts函数还会添加换行符,因此我们在原始字符串中没有加上换行符。

C 语言有一种简写方式来初始化字符串,允许我们像这样写相同的声明:

const char hello[] = "Hello World";   // The characters to print

两个语句都创建了一个包含 12 个字符的数组并初始化它。("Hello World"包含 11 个字符,第 12 个是字符串结束符'\0',当你使用简写时,C 会自动提供这个字符。)

因为数组和指针非常相似,你也可以将字符串声明为指针:

const char* const hello = "Hello World";   // The characters to print

你会注意到我们现在有了两个const关键字。事情变得有点复杂了。第一个const影响指针,第二个const影响被指向的数据。以下程序展示了这些是如何工作的:

/**
 * @brief Program to demonstrate the use of const
 * with pointers
 */

char theData[5] = "1234";             // Some data to play with

      char*       allChange;          // Pointer and value can change
const char*       dataConst = "abc"   // Char const, pointer not
      char* const ptrConst = theData; // Char var, ptr not
const char* const allConst = "abc";   // Nobody change nothing

int main()
{
    char otherData[5] = "abcd";   // Some other data

    allChange = otherData;        // Change pointer
    *allChange = 'x';             // Change data

    dataConst = otherData;        // Change pointer
    // *dataConst = 'x';          // Illegal to change data

    // ptrConst = otherData;      // Illegal to change pointer
    *ptrConst = 'x';              // Change data

    // allConst = otherData;      // Illegal to change pointer
    // *allConst = 'x';           // Illegal to change data
    return (0);
}

这个程序展示了const可以用来定义字符指针的每一种可能方式。然后我们尝试修改指针和被指向的数据。根据const修饰符的位置,某些语句将失败,而某些语句则会成功。

总结

我们在本书的开头处理了可以存储单一值的变量。数组让我们能够处理一组数据。在组织数据方面,这给了我们更大的能力。

字符串是一种特殊类型的数组。它们包含字符,并且有一个字符串结束标记来指示结束。

指针和数组在某些方面相似,它们都可以用来访问一段内存。数组受其大小限制(尽管它们可能会溢出),而指针则不受此限制。C 语言不限制指针的使用,这赋予了语言强大的能力。这种能力可以被用来做有益的事情,例如处理内存映射的 I/O,也可以用来做坏事,例如意外地破坏随机内存。

正如我们所看到的,C 语言赋予了程序员完全使用计算机的能力。但这种能力是有代价的。C 语言不会阻止你做傻事。C 语言为你提供了像数组和指针这样的工具来组织数据。是否明智地使用它们,取决于你自己。

编程问题

  1. 编写一个程序,查找整数数组中最低和最高编号的元素。

  2. 编写一个程序,扫描数组中的重复数字。重复的数字将出现在连续的元素中。

  3. 编写一个程序,扫描数组中的重复数字,这些重复数字可能出现在数组的任何位置。

  4. 创建一个程序,只打印数组中的奇数。

  5. 编写一个程序,遍历字符串并将每个单词的首字母转换为大写。你需要查阅标准 C 函数isalphatoupper。*

第七章:局部变量和过程

到目前为止,我们一直在使用一种名为“杂乱无章”的设计模式。所有代码都被放入main中,所有变量都在程序开始时定义。当你的程序只有 100 行代码时,这种方法可以很好地工作,但当你处理一个 50 万行的程序时,你就需要一些组织结构。本章讨论了如何限制变量和指令的作用域,以便使长且难以管理的代码块更容易理解、管理和维护。

例如,你可以在程序的任何地方使用全局变量。然而,要知道它在一个 50 万行的程序中如何使用,你必须扫描所有 50 万行代码。局部变量的作用域有限。要理解一个局部变量的使用位置和方式,你只需检查它有效的、例如 50 到 200 行代码的区域。

随着程序变得越来越长,你将学会如何将代码划分为易于理解的部分,称为过程。全局变量在每个过程都可用,但你可以定义只在单个过程内有效的局部变量。你还将学习局部变量是如何在内部组织成堆栈帧的。鉴于我们 STM 微控制器的内存有限,了解我们使用了多少堆栈内存是非常重要的。

最后,你将学习递归,即过程调用自身。递归在功能上很复杂,但如果你理解规则并遵循它们,它就很简单。

局部变量

到目前为止,我们只使用了在程序中任何地方都可用的全局变量,从它们声明的那一行开始,一直到程序的结束。局部变量只在程序的一个较小的、本地的区域内有效。变量有效的区域称为它的作用域。清单 7-1 展示了局部变量的声明。

local.c

/*
 * Useless program to demonstrate local variables
 */
#include <stdio.h>

int global = 5;    // A global variable

int main()
{
      int localToProcedure = 3;
      // ... do something
      {
         1 int local = 6; // A local variable

          {
              int veryLocal = 7;  // An even more local variable
              // ... do something
        2 }
          // veryLocal is no longer valid.
    3 }
      // local is no longer valid.
      return (0);
  }

清单 7-1:局部变量

局部变量的作用域从声明开始,一直到封闭的大括号({})的结束。变量localToProcedure在整个main函数中有效。

现在,让我们看看更小的作用域,从local变量 1 的声明开始。这个变量的作用域并不是在下一个关闭的大括号 2 处结束,该大括号是用于另一个代码块(用大括号括起来的代码段)。相反,它延伸到在声明local之前开始的那个代码块的结束括号 3。veryLocal变量的作用域更小。它从声明int veryLocal = 7;开始,到代码块结束时 2 结束。

当变量的作用域结束时,程序就不能再使用该变量。例如,尝试在main的末尾使用return(veryLocal);语句返回veryLocal的值将不起作用。

隐藏的变量

在前面的例子中,所有的局部变量除了具有不同的作用域外,还具有不同的名称。然而,变量也可以在不同的作用域中使用相同的名称。如果多个变量具有相同的名称,C 语言会使用当前作用域中变量的值并隐藏其他变量。(请不要这样做,因为它会使代码变得混乱。这里提到它是为了让你知道应该避免这样做。)

让我们看一下列表 7-2,它展示了一个写得非常糟糕的程序。

hidden.c

/*
 * Useless program to demonstrate hidden variables
 */
#include <stdio.h>

1 int var = 7;            // A variable

int main()
{
    // ... do something
    {
      2 int var = 13;   // Hides var = 7

        {
          3 int var = 16;     // Hides var = 7, var = 13

            // ... do something
        }
        // ... do something
    }
    // ... do something
    return (0);
}

列表 7-2:隐藏的变量

在这个程序中,我们定义了三个变量,都是命名为var。当第二个变量被定义时 2,它会隐藏第一个变量 1。同样,int var = 16;的声明会隐藏第二个var变量 2,而它又会隐藏第一个var变量 1。

假设我们在第三个声明后添加以下语句:

var = 42;

我们要赋值给哪个var?是第 1 行、第 2 行还是第 3 行声明的?我们不得不问这个问题,说明这段代码很混乱。我不会把这个问题留给读者去找答案,因为正确的解决方法是根本不要这么做。

过程

过程是定义代码的一种方式,以便可以重复使用。让我们看一下列表 7-3,它提供了一个简单的示例。

hello3.c

/**
 * Print hello, hello, hello, world.
 */
#include <stdio.h>

/**
 * Tell the world hello.
 */
1 void sayHello(void)
{
  2 puts("Hello");
}

int main()
{
  3 sayHello();
    sayHello();
    sayHello();
    puts("World!");
    return (0);
}

列表 7-3:过程演示

这个程序会打印Hello三次,然后是World!。过程从一个注释块开始,这个注释块并非严格必要,但如果你要编写高质量代码,应该在每个过程前加上注释块。注释块的开始部分(/**)表示 Doxygen 文档工具应该处理它。为了与 STM 库的格式兼容,我们使用了相同的注释约定。

语句void sayHello(void) 1 告诉 C 语言我们的过程名是sayHello。它不返回任何值(第一个void)且不接受任何参数(第二个void)。紧随其后的{}块定义了过程的主体,并包含了过程执行的所有指令 2。三个sayHello();行 3 是调用sayHello过程的地方。它们告诉处理器保存下一个语句的位置(可能是另一个sayHello的调用,或是调用puts),然后从sayHello的第一行开始执行。当过程结束(或遇到return语句)时,执行会继续在调用时保存的那个位置。

栈帧

过程有它们自己的局部变量。编译器的工作是组织内存,以便可以容纳这些变量。对于全局变量(不在过程中的),编译器会说类似于:“我需要 4 字节来存储名为Total的整数。”链接器看到这一点后,会为该变量分配内存中的物理位置(例如,0xffffec04)。全局变量在编译时静态分配,这意味着编译器为变量分配了空间,然后就完成了。变量永远不会被销毁,并且它们的内存不会被重新分配。

局部变量更为复杂。它们必须在运行时动态分配。当一个过程开始时,该过程的所有局部变量都会被分配。(注意:有一个static局部变量会在编译时分配,但我们还没有涉及这一点。)当过程结束时,局部变量会被回收。编译器通过在过程开始时创建一个栈帧,并在过程结束时销毁它来完成这一过程。栈帧保存了过程所需的所有临时信息。

让我们看看清单 7-4,它展示了一个示例程序。

proc.c

/**
 * @brief Program to demonstrate procedures and local variables
 */

/**
 * Function that is called from another function
 */
void inner(void) {
    int i = 5;     // A variable
    int k = 3;     // Another variable
  1 i = i + k;     // Do something with variables
}
/**
 * Outer-level function
 */
void outer(void) {
    int i = 6;     // A variable
    int j = 2;     // Another variable
    i = j + i;     // Use variables
    inner();
}

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

清单 7-4:栈帧演示

让我们为这个程序创建一个项目并开始调试。通过调试器运行程序,然后使用命令运行单步进入 (F5) 步骤执行,直到你到达第 1 行。你的屏幕应该像图 7-1 一样显示。

f07001

图 7-1:调试proc.c

当程序加载时,所有静态分配的变量会获得它们各自的内存位置。在 STM32 芯片中,它们被分配到随机存取内存(RAM)的较低部分。剩余的内存则保留用于动态分配。具体而言,有两个内存区域被动态使用:,用于存储局部变量,和。我们暂时不关心堆;我们的微处理器没有足够的内存来使用它。(我们将在第十三章讨论堆,届时将讨论为更大系统编程的内容。)

这个名字来源于数据在内存中堆叠的方式。当程序启动时,main函数为其局部变量和临时值分配一个栈帧。当调用outer时,它会在main的栈帧之上分配另一个栈帧。调用inner会向栈中添加第三个栈帧。

要查看每个过程中的栈的位置,请点击右上角面板中的寄存器标签,并向下滚动直到看到rsp寄存器。图 7-2 显示它包含0x7fffffffd0e0

f07002

图 7-2:显示寄存器

根据机器的不同,栈可能从低内存地址开始并向上增长,或者从高内存地址开始并向下增长。在这台机器(x86)上,栈是从高地址开始并向下增长的。

outer 堆栈帧位于 0x7fffffffd0f0。由于我们的堆栈是向下增长的,这个地址比 main 的堆栈帧地址要低。inner 堆栈帧位于 0x7fffffffd110(参见 表 7-1)。

表 7-1:堆栈使用情况

地址 过程 内容 备注
0x7fffffffd110 main 堆栈底部

| 0x7fffffffd0f0 | outer | i

j | |

| 0x7fffffffd0e0 | inner | i

k | 堆栈顶部 |

需要理解的一个关键概念是,堆栈帧是以“后进先出”(LIFO)的顺序分配的。当我们完成 inner 的执行时,它的堆栈帧将被销毁,然后 outer 的堆栈帧会被销毁。

变量面板(如 图 7-1 中右上角所示)显示了 ik 变量。调试器显示的是 inner 堆栈帧中的变量,这可以通过调试面板(左上角)中高亮显示的 inner 堆栈帧来确认。点击调试面板中的 outer 堆栈帧,你会看到变量面板发生变化,并显示 outer 中的变量,正如 图 7-3 所示。

f07003

图 7-3:outer 堆栈帧

让我们继续通过调试程序,跳过 inner 的最后一条指令。当我们退出 inner 时,该函数的堆栈帧将消失,因为我们不再执行 inner,也不再需要存储它的变量。

图 7-4 显示的是我们退出 inner 堆栈帧后的堆栈情况。

f07004

图 7-4:退出 inner 堆栈帧后的堆栈

请注意,现在堆栈上只剩下两个堆栈帧。

递归

到目前为止,我们一直在处理基本的过程调用;每个过程都有不同的名称,且调用过程简单。现在,我们将专注于 递归,即一个函数调用自身。递归可以是一个强大的工具,但如果你不了解规则,使用起来会很棘手。

经典的递归问题是计算阶乘。阶乘函数的定义如下:

  1. f(n) = 1,当 n 为 1 时

  2. 否则,f(n) = n × f(n – 1)

将此转化为代码,我们得到 清单 7-5。

factor.c

/**
 * Compute factorial recursively
 * (the basic recursive example)
 */

#include <stdio.h>

/**
 * Compute factorial
 *
 * @param x The number to compute the factorial of
 * @returns the factorial
 */
int factor(const int x) {
    if (x == 1)
        return (1);
    return (x * factor(x-1));
}

int main()
{
    int result = factor(5);
    printf("5! is %d\n", result);
    return (0);
}

清单 7-5:计算阶乘的程序

首先,我们调用 factor(5) 来计算 5 的阶乘。为此,我们需要 factor(4),所以我们暂停 factor(5),同时调用 factor(4)。但 factor(4) 需要 factor(3),于是我们暂停工作并调用 factor(3)。现在 factor(3) 需要 factor(2),同样,factor(2) 需要 factor(1)。最后,factor(1) 不需要任何操作,于是它返回 1 给调用者 factor(2)。函数 factor(2) 正在运行,因此它计算 2 × 1 并返回 2 给调用者 factor(3)。接着,factor(3) 获取返回值(2),计算 2 × 3 并返回 6 给调用者 factor(4)。接近尾声时,factor(4) 计算 6 × 4 并返回 24。最后,factor(5) 计算 24 × 5 并返回 120。

当你在调试器中执行这个程序时,你应该看到堆栈在程序计算阶乘时不断增长和缩小。你还应该看到为 factor 过程分配了五个堆栈帧,每个实例都有一个堆栈帧:factor(1)factor(2)factor(3)factor(4)factor(5)

两条规则决定了何时可以使用递归:

  1. 每次调用该过程都必须使问题变得更简单。

  2. 必须有一个终点。

让我们看看这些规则如何在我们的阶乘程序中运作。为了计算 factor(5),我们需要计算 factor(4)。第一个规则被满足,因为 factor(4)factor(5) 更简单。迟早,我们会到达 factor(1),这就是终点,满足第二个规则。

让我们违反规则看看会发生什么;我们将修改程序并尝试计算 factor(-1)

这符合两个规则吗?嗯,factor(-1) 需要 factor(-2),而 factor(-2) 又需要 factor(-3),依此类推,直到我们到达 1。但没有办法通过减法从 -1 到达 1,所以我们没有办法结束程序。

当我在我的小型 Linux 机器上运行这个程序时,我看到以下内容:

 $ **./06.factor-m1**
Segmentation fault (core dumped)

系统耗尽了堆栈内存,程序中止,因为它违反了 x86 处理器的内存保护约束。在其他系统上,结果可能会有所不同。例如,在 ARM 处理器上,堆栈可能会碰到堆并破坏它(更多关于堆的内容见第十三章),或者其他某些内容可能会被覆盖。无论如何,堆栈耗尽都不是一个好现象。

顺便提一下,程序在中止之前一直运行到 x=-262007

编程风格

在本书中,我们尽量在可能的情况下使用良好的编程风格。例如,我们确保在每个过程的顶部包含注释块,并且在每个变量声明后都包含注释。良好的编程风格有两个目的:让后续的程序员清楚地了解你做了什么,并且使错误更难发生。

我们在阶乘例子中违反了其中一个规则。问题出在这一行:

int factor(const int x) {

这有什么问题?int 类型是有符号的,但你只能计算正数的阶乘。我们本可以将我们的函数写成以下形式:

unsigned int factor(const unsigned int x) {

这样编写会导致无法传递负数。注意,编译器会在没有警告的情况下将 -1 自动转换为无符号数(4294967295),除非你包含编译器开关 -Wconversion。GCC 有成百上千的选项,找出需要使用哪个选项本身就是一门艺术。不过,这行代码的第一版确实有两个优点;它是一个不良风格的好例子,而且它让我们能够通过 factor(-1) 演示堆栈溢出。

总结

你可能已经注意到这本书的一个特点,它被分成了多个章节。为什么?当然是为了让阅读更加方便。每一章提供了一个读者可以一次性理解的信息单元。

计算机程序也需要被划分为易于处理的小块。一个包含 750,000 行的程序几乎无法跟踪,而一个 300 行的过程却能让人理解所有内容。局部变量有助于这种组织。如果某个变量只在 300 行的过程中使用,你可以确保它只会在这 300 行中使用。另一方面,全球变量可以在一个 750,000 行的程序中随时被使用。

编写优质代码的关键是使其易于理解且简单。过程有助于将程序分解成简单、易懂的单元,从而帮助你编写更可靠、更易于维护的代码。

编程问题

  1. 编写一个函数,计算三角形的面积,并编写一个小的主程序,用三个不同的数值集来测试该函数。

  2. 编写一个名为 max 的过程,返回两个数字中的最大值。

  3. 编写一个程序,计算第五个斐波那契数。若能使用递归方式实现,则加分。

  4. 创建一个函数,用于求一个数字的各位数字之和。例如,123 的结果是 6(换句话说,1 + 2 + 3)。如果结果大于或等于 10,应该重复这个过程,直到结果是一个单一的数字。例如,987 是 9 + 8 + 7 = 24。24 大于 10,所以 24 是 2 + 4,结果是 6。

第八章:复杂数据类型

在本章中,我们将超越数组和简单类型,创建更复杂的数据类型。我们将从一个简单的enum开始,它定义了一个命名的项列表。然后我们将研究结构体和联合体,它们包含不同类型的值,并通过名称访问(不同于数组,它包含一个类型的值,并通过数字或索引访问)。为了创建一个自定义数据类型,我们将组合使用枚举、结构体和联合体。此外,我们还将探讨结构体在嵌入式编程中的应用。最后,我们将介绍typedef,它允许我们从现有类型定义自己的数据类型。

枚举

枚举类型,或称enum,是一种数据类型,允许我们定义一个命名项的列表。例如,如果我们想在变量中存储一个有限的颜色集合,可以这样写:

const uint8_t COLOR_RED = 0;
const uint8_t COLOR_BLUE = 1;
const uint8_t COLOR_GREEN = 2;

#define colorType uint8_t;

colorType colorIWant = COLOR_RED

虽然这会有效,但我们仍然需要跟踪各种颜色。幸运的是,如果我们使用enum,C 语言会为我们完成这项工作:

enum colorType {
    COLOR_RED,
    COLOR_BLUE,
    COLOR_GREEN
};

enum colorType colorIWant = COLOR_RED;

使用enum,C 语言为我们做了记录工作。如果我们只有三种颜色,那问题不大。然而,X Window 系统有超过 750 种命名颜色。追踪这些数字是一个不小的任务。

C 语言在类型方面比较宽松。内部,C 会分别将COLOR_REDCOLOR_BLUECOLOR_GREEN赋值为整数 0、1 和 2。我们通常不在意这一点,但有时这种赋值会浮现出来。例如,以下代码:

enum colorType fgColor = COLOR_GREEN;
printf("The foreground color is %d\n", fgColor);

将打印出以下内容:

The foreground color is 2

此外,C 语言不会对enum赋值进行类型检查。例如,以下语句不会生成错误或警告:

colorIWant = 33;

我们的enum定义了三种颜色,所以合法的颜色数字是 0、1 和 2——而不是 33。这可能会成为一个问题。

假设我们写一个函数来打印存储在colorIWant中的颜色。当用户看到以下输出时,他们会怎么想?

Your box color is 33.

像这样打印一个错误的答案会让用户很好地意识到你的程序出了问题。如果你使用enum值来索引一个数组,就可以给出更好的结果。以下是一个例子:

static const char* const colorNames = {"Red", "Blue", "Green"};
*--snip--*
printf("Your box color is %s\n", colorNames[colorIWant]);

现在,如果colorIWant是 33,程序将打印:

Your box color is @ @@@�HH   pp�-�=�=�px�-�=�=�888 XXXDDS�td888 P�td

你的结果可能会有所不同,这取决于三元素数组中的元素 33 的内容。

预处理器技巧与枚举

在本节中,您将学习如何使用一些高级预处理器指令,使得处理枚举变得更容易。首先,我想说的是,千百次中,使用巧妙的技巧带来的麻烦通常多于它的好处。简单明了几乎总比复杂巧妙要好。这种情况是少数几个例外之一。

让我们来看一段代码,它定义了颜色和颜色名称。

// WARNING: Do not change this without changing colorNames.
enum colorType {
    COLOR_RED,
    COLOR_BLUE,
    COLOR_GREEN
};

// WARNING: Do not change this without changing colorType.
static const char* const colorNames = {
    "COLOR_RED", "COLOR_BLUE", "COLOR_GREEN"};

这个示例有两个相互依赖的项:colorTypecolorNames。编写这段代码的程序员很贴心地加了注释,指明这两个项是相关的,而且它们实际上是紧挨着定义的。(有时候,两个相互依赖的项可能在不同的文件中,而没有任何指示它们关联的注释。)

作为程序员,我们希望代码尽可能简单。需要同时更新的两个不同项并不理想。我们可以通过巧妙使用预处理器来解决这个问题:

// This is the beginning of a clever trick to define both the values and
// the names for the enum colorType. The list below will be used twice,
// once to generate the value and once to generate the names.

#define COLOR_LIST                      \
 DEFINE_ITEM(COLOR_RED),         \
        DEFINE_ITEM(COLOR_BLUE),        \
        DEFINE_ITEM(COLOR_GREEN)

// Define DEFINE_ITEM so it generates the actual values for the enum.
#define DEFINE_ITEM(X) X
enum colorType {
   COLOR_LIST
};
#undef DEFINE_ITEM

// Define DEFINE_ITEM so it generates the names for the enum.
#define DEFINE_ITEM(X) #X
static const char* colorNames[] = {
   COLOR_LIST
};
#undef DEFINE_ITEM

我们从第一个定义开始:

#define COLOR_LIST                      \
        DEFINE_ITEM(COLOR_RED),         \
        DEFINE_ITEM(COLOR_BLUE),        \
        DEFINE_ITEM(COLOR_GREEN)

反斜杠(\)告诉 C 预处理器该行是继续的。我们将它们放在同一列中,这样如果我们不小心漏掉一个就很容易发现。

现在,每当我们使用COLOR_LIST时,C 预处理器会将其转换为以下内容:

DEFINE_ITEM(COLOR_RED), DEFINE_ITEM(COLOR_BLUE), DEFINE_ITEM(COLOR_GREEN)

当我们定义enum时,我们需要我们的列表如下:

COLOR_RED, COLOR_BLUE, COLOR_GREEN

通过定义DEFINE_ITEM只输出项名称,我们得到这个:

#define DEFINE_ITEM(X) X

这意味着下面的代码:

enum colorType {
   COLOR_LIST
};

变成了这个:

enum colorType {
    COLOR_RED, COLOR_BLUE, COLOR_GREEN
};

现在我们删除DEFINE_ITEM的定义,因为我们不再需要它来进行enum定义:

#undef DEFINE_ITEM

接下来,我们通过重新定义DEFINE_ITEM宏来定义colorNames列表:

#define DEFINE_ITEM(X) #X

井号(#)告诉预处理器将后续的标记转换为字符串,因此现在COLOR_LIST将展开为以下内容:

"COLOR_RED","COLOR_BLUE","COLOR_GREEN"

这是colorNames的完整定义:

#define DEFINE_ITEM(X) #X
static const char* colorNames[] = {
   COLOR_LIST
};
#undef DEFINE_ITEM

注释是这个定义的重要部分。每当你使用这样的巧妙技巧时,要做好文档,以便维护此代码的可怜的人能知道你做了什么。

结构体

C 语言的结构体struct)允许我们将多种不同类型的项组合在一起。这些项被称为字段,并通过名称进行标识。它与定义包含相同类型项的数据结构的数组不同,数组中的项称为元素,并通过数字索引。例如,考虑以下将描述房屋信息的结构体:

struct house {
    uint8_t stories;        // Number of stories in the house
    uint8_t bedrooms;       // Number of bedrooms
    uint32_t squareFeet;    // Size of the house
};

要访问结构体的元素,可以使用变量.字段的格式,中间用点号分隔。例如:

struct house myHouse;
--snip--
myHouse.stories = 2;
myHouse.bedrooms = 4;
myHouse.squareFeet = 5000;

以下程序展示了如何将这一切结合在一起:

struct.c

/**
 * Demonstrate the use of a structure.
 */

#include <stdio.h>
#include <stdint.h>

struct house {
    uint8_t stories;        // Number of stories in the house
    uint8_t bedrooms;       // Number of bedrooms
    uint32_t squareFeet;    // Size of the house
};

int main() {
    struct house myHouse;   // The house for this demo

    myHouse.stories = 2;
    myHouse.bedrooms = 4;
    myHouse.squareFeet = 5000;
    printf("House -- Stories: %d Bedrooms %d Square Feet %d\n",
        myHouse.stories, myHouse.bedrooms, myHouse.squareFeet);
    printf("Size of the structure %ld\n", sizeof(myHouse));
    return (0);
}

让我们在 STM32 工作台中调试这个程序(参见图 8-1)。

f08001

图 8-1:struct变量显示

在第 20 行停止,我们现在可以在变量列表中看到结构体。点击 + 图标可以展开结构体,显示其内容。

内存中的结构体

让我们来看看 C 编译器将如何在内存中布局这个结构体。编译器需要为storiesuint8_t)分配 1 个字节,为bedroomsuint8_t)分配 1 个字节,为squareFeetuint32_t)分配 4 个字节。逻辑上,布局应该像表 8-1 所示。

表 8-1:结构体布局

偏移量 类型 字段
0 uint8_t stories
1 uint8_t bedrooms
2 uint32_t squareFeet
3
4
5

从 表 8-1 中,我们可以看到该结构占用了 6 字节。然而,当我们运行程序时,看到以下输出:

Size of the structure 8

另外 2 个字节来自哪里?

问题出在内存设计上。在 ARM 芯片(以及许多其他芯片)中,内存是按一系列 32 位整数进行组织的,并且这些整数是对齐在 4 字节边界上的,像这样:

0x10000 32 位
0x10004 32 位
0x10008 32 位
. . .

假设我们需要一个 8 位字节,位于 0x10001。计算机从 0x10000 获取 32 位数据,然后丢弃 24 位数据,这样做很浪费,因为额外的数据被读取了,尽管没有性能损失。

假设我们需要一个从 0x10002 开始的 32 位整数。直接获取这些数据会导致对齐异常,进而中止程序。计算机必须执行以下操作:

  1. 0x10000 读取 16 位数据。

  2. 0x10004 读取 16 位数据。

  3. 将它们组合起来。

内部 ARM 电路并不完成这些步骤。相反,编译器必须生成多条指令来完成这项工作,这对于性能来说并不好。(我们将在本章稍后部分详细讨论这个问题。)

如果 squareFeet 能够对齐到 4 字节边界,而不是 2 字节边界,那就好了,这样编译器可以通过添加 2 字节的填充来优化结构布局。这使得结构变大了,但处理起来要容易得多。表 8-2 显示了结构的实际调整后的布局。

表 8-2:填充结构布局

偏移量 类型 字段
0 uint8_t stories
1 uint8_t bedrooms
2 uint8_t (填充)
3 uint8_t (填充)
4 uint32_t squareFeet
5
6
7

这种额外的填充有时会成为一个问题。例如,如果你有很多房屋而且内存非常有限,那么每个房屋结构中的填充就会累积成大量浪费的空间。

另一个例子是在嵌入式编程中。我有一款旧的、iPod 发布之前的音乐设备,叫做 Rio,它没有配备 Linux 工具来将音乐加载到设备上,所以我自己写了一些工具。每个数据块都有一个像这样的头部:

struct dataBlockHeader {
    uint32_t nextBlock;      // Number of the next block in this song
    uint16_t timeStamp;      // Time in seconds of this section of the song
    uint32_t previousBlock;  // Number of the previous block in the song
};

当我第一次在 Rio 上加载歌曲时,它们播放得很好。但当我按下倒带按钮倒回几秒钟时,设备就会疯掉并重新开始播放歌曲。问题在于 GCC 在结构中添加了填充:

struct dataBlockHeader {
    uint32_t nextBlock;      // Number of the next block in this song
    uint16_t timeStamp;      // Time in seconds of this section of the song
    **uint16_t padding;        // Automatically added**
    uint32_t previousBlock;  // Number of the previous block in the song
};

结果,Rio 认为前一个块是之前的一个块,实际上那只是一些填充和前一个块的一半值。难怪设备会变得混乱。

解决方法是告诉编译器不要通过 packed 属性添加填充:

struct dataBlockHeader {
    uint32_t nextBlock;      // Number of the next block in this song
    uint16_t timeStamp;      // Time in seconds of this section of the song
 uint32_t previousBlock;  // Number of the previous block in the song
}  **__attribute__((packed));**

在这个例子中,__attribute__((packed)) 是 C 语言的一个 GNU 扩展,它可能在其他编译器上无法使用。

访问未对齐的数据

默认情况下,编译器会“调整”结构体元素的对齐方式,以便高效的内存访问。正如我们所见,硬件设计师有时会有不同的想法,为了让结构体与硬件匹配,我们必须包含__attribute__((packed))指令。

为了理解编译器为什么做出这样的调整,让我们编写一个程序,它同时执行对齐和未对齐的 32 位访问。打包结构体更加紧凑,但需要更多的代码来访问 32 位值。未打包结构体访问更高效,但占用更多内存。

以下程序展示了如何访问打包和未打包结构体:

/*
 * A demonstration of packed and unpacked.
 * This program does nothing useful except
 * generate an assembly listing showing
 * how hard it is to access squareFeet
 * in a packed structure.
 *
 * To run -- don't. Compile and look at the
 * assembly listing instead.
 */

#include "stm32f0xx.h"
#include "stm32f0xx_nucleo.h"

// An example of an unpacked structure
struct unpackedHouse {
    uint8_t stories;     // Number of stories in the house
    uint8_t bedrooms;    // Number of bedrooms
    uint32_t squareFeet; // Size of the house
    uint8_t doors;       // Number of doors
    uint8_t windows;     // Number of windows
};

// An example of a packed structure
struct packedHouse {
    uint8_t stories;     // Number of stories in the house
    uint8_t bedrooms;    // Number of bedrooms
    uint32_t squareFeet; // Size of the house
 uint8_t doors;       // Number of doors
    uint8_t windows;     // Number of windows
} __attribute__((packed));

// A place to dump squareFeet for unpackedHouse
volatile uint32_t unpackedFeet;
volatile uint32_t packedFeet;   // A place to dump squareFeet for packedHouse

// An example unpackedHouse -- values chosen to make demonstration easier
struct unpackedHouse theUnpackedHouse = {0x01, 0x02, 0x11223344, 0x03, 0x04};

// An example packedHouse -- values chosen to make demonstration easier
struct   packedHouse thePackedHouse = {0x01, 0x02, 0x11223344, 0x03, 0x04};

int main(void)
{
  1 unpackedFeet = theUnpackedHouse.squareFeet;
  2 packedFeet = thePackedHouse.squareFeet;

    for(;;);
}

首先,让我们来看一下获取对齐的uint32_t 1 时生成的代码(已添加注释):

;unpackedFeet = theUnpackedHouse.squareFeet;
    ldr     r3, .L3       ; Get address of theUnpackedHouse.
    ldr     r2, [r3, #4]  ; Get data at offset 4
                          ; (theUnpackedHouse.squareFeet).
`--snip--`
L3: theUnpackedHouse

它使用一条指令获取结构体的地址,并用一条指令获取值。

现在让我们来看一下未对齐的取值 2:

;  packedFeet = thePackedHouse.squareFeet;
    ldr     r3, .L3+8     ; Get address of thePackedHouse.
    ldrh    r2, [r3, #2]  ; Get uint16_t at offset 2 (0x3344).
                          ; (Byte order puts the low-order bytes first.)

    ldrh    r3, [r3, #4]  ; Get uint16_t at offset 4 (0x1122).
                          ; (High bytes come after low.)

    lsls    r3, r3, #16   ; r3 contains the top 1/2 of squareFeet
                          ; in the bottom 16 bits of r3.
                          ; Shift it left into the top half.

    orrs    r3, r2        ; Combine the two halves.

.L3: theUnpackedHouse
     thePackedHouse

未对齐的取值需要四条指令,而对齐的取值只需要一条指令。程序必须使用两条加载指令来获取数字的两个部分:一条移位指令将高半部分移到寄存器的顶部,另一条逻辑或指令将这两个数合并。

每次加载或存储未对齐的uint32_t时,必须使用像这样的代码。你可以理解为什么编译器可能会避免这样做,并且添加填充。

结构体初始化

我们可以通过将初始化列表放在花括号({})中来初始化结构体。例如,下面的语句通过一个语句声明并初始化myHouse

struct house {
    uint8_t stories;        // Number of stories in the house
    uint8_t bedrooms;       // Number of bedrooms
    uint32_t squareFeet;    // Size of the house
};

// 2 stories
// 5 bedrooms
// 2500 square feet
struct house myHouse = {2, 5, 2500};

在语言的早期版本中,这是初始化结构体的唯一方法。后来,当 C99(1999 年最终确定的 C 语言规范)发布时,增加了一种名为指定初始化器的新特性,允许通过字段名称初始化。这里有一个例子:

struct house myHouse = {
  stories: 2,
  bedrooms: 5,
  squareFeet: 2500
};

字段必须按照声明时的顺序排列。GCC 编译器有一个扩展,允许你使用指定初始化器,采用不同的方式:

struct house myHouse = {
    .stories: 2,
    .squareFeet: 2500,
    .bedrooms: 5
};

在这种情况下,字段的顺序不需要与声明时的顺序匹配。

结构体赋值

C 语言不允许将一个数组赋值给另一个数组,但它允许将一个结构体赋值给另一个结构体。这里有一个例子:

int array1[5];           // An array
int array2[5];           // Another array

array1 = array2;         // Illegal

struct example {
    int array[5];        // Array inside a structure
};
struct example struct1;  // A structure
struct example struct2;  // Another structure

// Initialize structure 2

struct1 = struct2;       // Structure assignment allowed

如果这些是数组,赋值将是非法的,但由于它们是结构体,因此可以正常工作。

结构体指针

C 语言的参数传递机制使用按值传递,意味着如果我们将参数传递给一个过程,它会将参数的值复制到栈上。当我们传递的是像 2 字节整数这样的小型数据时,这种做法没有问题,但大多数结构体并不小,实际上可能非常大。当一个结构体作为参数传递时,整个结构体都会被复制到栈上,这使得操作变得昂贵。这里有一个例子:

// A rectangle
struct rectangle {
    unsigned int width;  // Width of the rectangle
    unsigned int height; // Height of a rectangle
};

// Inefficient parameter passing
unsigned int area(const struct rectangle aRectangle)
{
    return (aRectangle.width * aRectangle.height);
}

这里发生的事情是,为了执行“按值传递”参数传递,编译器必须生成代码,将整个aRectangle复制到栈上。对于较大的结构体,这可能会占用大量栈空间,并且复制数据时耗时较长。

通过指针传递结构体更高效:

// Efficient parameter passing
unsigned int area(const struct rectangle* const aRectangle)
{
    return ((*aRectangle).rectangle * (*aRectangle).height);
}

在这种情况下,只有指针(一个小项)作为参数传递。以 ARM 编译器为例,它是通过将其放入寄存器来实现的:快速、简便且不占用栈空间。

按值传递的一个优点是,参数的任何更改都不会传回给调用者。但我们并没有做任何更改,所以这不是问题。

当我们按指针传递参数时,我们使用const来表明不允许修改参数。

使用(*rectangle).height语法来访问结构体指针的成员有点别扭。为此,C 添加了一些语法糖,使我们可以使用简便方式——->操作符:

// Efficient parameter passing
unsigned int area(const struct rectangle* const aRectangle)
{
    return (aRectangle->rectangle * aRectangle->height);
}

C 在处理数组时可以自由地把它当作指针来使用,而指针也可以当作数组来使用。当数组作为参数传递时,数组会自动转换为指针。当数组被指定为过程参数时,它会在你不知情的情况下自动转换为指针。

说 C 是“按值传递”并不完全正确。更精确的说法是,“C 按值传递,除了数组是按指针值传递。”

结构体命名

像 C 中的许多事物一样,结构体的命名并不简单。这是因为在一个 C 结构体声明中,我们可以定义结构体名称(也可以不定义)和变量(也可以不定义)。下面是结构体定义的一般语法:

struct [`struct-name`] {
    `field1`;
    `field2`;
*--snip--*
} [`var-name(s)`];

让我们考虑一下没有结构体名称的例子:

// A box to put our stuff into
struct {
    uint32_t width;     // Width of the box
    uint32_t height;    // Height of the box
} aBox;

这定义了aBox变量,但aBox是什么类型呢?它是一个没有名称的结构体,或者是一个匿名结构体。匿名结构体只能在结构体定义时用于定义变量。它们不指定结构体名称,因此在后续声明中无法使用。

现在让我们考虑省略变量名的情况:

struct box {
    uint32_t width;     // Width of the box
    uint32_t height;    // Height of the box
};

这定义了一个结构类型,但没有定义变量。它可以在后续用来定义一个变量:

struct box aBox; // Box to put stuff into

我们可以在同一个声明中同时定义结构体名称和变量名称:

struct box {
    uint32_t width;     // Width of the box
    uint32_t height;    // Height of the box
} aBox;

这定义了一个box结构和一个变量aBox

C 还有一个技巧——我们可以定义没有结构体名称和变量名称的结构体:

// Silly definition
struct {
    uint32_t width;     // Width of the box
    uint32_t height;    // Height of the box
};

因为没有结构体名称,我们只能用它来访问这里定义的变量。但这里并没有定义任何变量,所以我们无法访问任何内容,这意味着虽然它完全合法,但也是完全没用的。

联合体

union类似于struct,不同之处在于每个字段不分配不同的位置,而是所有字段都存储在同一个位置。以下是一个示例:

union value {
    uint32_t anInteger;
    float aFloat;
};

编译器为uint32_t分配 4 字节,并且同样分配 4 字节float。让我们看一下它的实际表现:

union value theValue;   // Define the value.

theValue.anInteger = 5; // anInteger is 5.
theValue.aFloat = 1.0;  // Assign the field aFloat/wipe out anInteger.

第二个赋值实际上将 anInteger 更改为 1065353216 (0x3f800000)。这是一个非常奇怪的整数,但作为浮点数,它是 1.0。

良好的编程实践是使用相同的字段名称来存储和取出联合体中的值。例如:

 theValue.aFloat = 1.2;
    float someFloat = theValue.aFloat;   // Assigns someFloat 1.2

当你使用不同的字段时,结果会在不同的机器上有所不同。

 theValue.aFloat = 1.2;
    int someInt = theValue.anInteger;   // Results machine-dependent

在这种情况下,someInt 的值将取决于整数的大小、浮点数的大小、浮点格式和字节顺序,而这些都依赖于处理器。

现在让我们讨论字节顺序问题。假设你手上有四张卡片,编号为 1、2、3 和 4。你想把它们放进你面前的四个盒子里。所以,你把最上面的卡片放进最左边的盒子,把下一张卡片放进右边的盒子,以此类推。现在你的盒子里包含以下内容:

1 2 3 4

当你拿起卡片时,你从右边开始,并将每张卡片放在堆叠的最上面。结果是,你会按顺序拿到 1、2、3 和 4。

现在另一个人过来了,把卡片从右边开始放入盒子,再向左放。他们的盒子看起来是这样的:

4 3 2 1

他们从左边开始拿起卡片,依次向右移动。同样,这个人最终会拿到 1、2、3 和 4,顺序完全一致。

我刚才描述的是两种不同的 CPU 架构如何在内存中存储数字。在某些情况下,它会是 1、2、3 和 4,而在其他情况下,它会是 4、3、2 和 1。只要存储和读取的是相同大小的数字,字节顺序就无关紧要。

现在假设你想把四张卡片存入盒子里,但只取出两张,这意味着你的盒子看起来是这样的:

1 2 3 4

当你拿起你的卡片时,你只会得到 1 和 2。

然而,另一个人的存储方式是这样的:

4 3 2 1

最左边的 n 个盒子在取卡片时总是被使用,所以这个人将从左边开始,拿到 3 和 4,这意味着他们会得到不同的结果。

这个差异是因为你们两个人在存储和取出卡片时使用了不同的顺序。计算机中也发生了同样的事情。不同的计算机以不同的顺序存储数据。因此,如果你尝试存储一种数据类型并取出另一种数据类型,你将在不同的机器上得到不同的结果。

因此,如果你将某个值放入 theValue.anInteger,唯一能保证获得一致结果的方式就是仅通过 theValue.anInteger 字段取出它。

创建自定义类型

我们现在将结合我们新创建的三种数据类型——structunionenum——将它们合并成一个大数据类型,用来在屏幕上绘制形状。这个形状可以是正方形、矩形、圆形或三角形。每种形状的描述方式不同。

我们只需要描述一个正方形的单个边长:

struct square {
    unsigned int side; // Size of the square
};

要描述一个矩形,我们需要宽度和高度:

struct rectangle {
    unsigned int width;   // Width of the rectangle
    unsigned int height;  // Height of the rectangle
};

我们可以仅用半径画一个圆:

struct circle {
    unsigned int radius;  // Radius of the circle
};

最后,要绘制一个三角形,我们需要描述它的底边和高度:

struct triangle {
 unsigned int base;    // Base of the triangle
    unsigned int height;  // How high is it?
};

一个通用的形状类型应该包含这些中的任何一个,这意味着我们需要一个union。但是,为了绘制一个形状,我们不仅需要知道它的描述,还需要知道它是什么类型的形状。enum数据类型是为有限的简单值列表而设计的:

enum shapeType {
    SHAPE_SQUARE, SHAPE_RECTANGLE, SHAPE_CIRCLE, SHAPE_TRIANGLE
};

现在我们可以定义我们的数据结构了:

struct shape {
    enum shapeType type;   // The type of the shape
    union {
       struct square theSquare;
       struct rectangle theRectangle;
       struct circle theCircle;
       struct triangle theTriangle;
    } dimensions;
};

第一个字段是type,它包含结构体中所包含的形状类型。第二个字段包含形状的dimensions。它是一个union,因为不同的形状有不同的维度。

绘制形状的代码看起来像如下所示:

void drawShape(const shape* const theShape) {
    switch (theShape->type) {
        case SHAPE_SQUARE:
            drawSquare(theShape->dimensions.theSquare.side);
            break;
        case SHAPE_RECTANGLE:
            drawSquare(theShape->dimensions.theRectangle.width,
                       theShape->dimensions.theRectangle.height);
            // ... other shapes

这种设计模式在 C 编程中相当常见:一个可以容纳多种不同类型数据的union和一个指示我们实际拥有哪种类型的enum

结构体与嵌入式编程

在本节中,我们将硬件规范转换为 C 结构体,运用到我们目前所学的结构体和对齐的知识。

小型计算机系统接口(SCSI)旨在提供一种标准的方式,用于在设备之间传输数据。它始于 1986 年,后来经过了许多扩展和增强。它通过向设备发送一个称为命令块的结构体,并返回数据和状态信息的方式来工作。

在最初,SCSI 标准定义了READ (6)命令,该命令将块地址限制为 16 位,允许的最大磁盘大小为 16MB,这是当时相对较大的磁盘。当然,磁盘制造商很快就生产出了更大的磁盘,因此 SCSI 团队不得不创建一个新命令,以支持更大的驱动器。这就是READ (10)命令,之后是READ (12)READ (16)READ (32)命令。READ (32)命令使用 64 位块地址。希望磁盘制造商能花点时间赶上,生产出 8 泽比字节的磁盘。

图 8-2 显示了READ (10)命令的命令块。如果我们想从磁盘读取数据,我们需要一个 C 结构体来包含这些信息并将其发送到设备。

f08002

图 8-2:READ (10)命令块

一开始,它看起来像是一个简单的翻译:

struct read10 {
    uint8_t opCode;    // Op code for read
    uint8_t flags;     // Flag bits
    **uint32_t lba;      // Logical block address**
    uint8_t group;     // Command group
    uint16_t transferLength;  // Length of the data to read
    uint8_t control;   // Control bits, the NACA being the only one defined
};
#include <assert.h>

int main() {
    assert(sizeof(struct read10) == 10);

现在,因为我们比较谨慎,程序中做的第一件事就是插入一个assert语句,确保我们的定义与硬件匹配。如果assert语句的条件不为真,它将终止程序。如果我们期望read10控制块包含 10 个字节,但它并没有,那么我们的程序就会有大问题。我们也有问题,因为assert会失败。

那么发生了什么呢?检查结构体后,我们看到lba字段(一个uint32)被对齐到了 2 字节边界。编译器希望将其对齐到 4 字节边界,因此它添加了 2 字节的填充。我们需要打包这个结构体:

struct read10 {
    uint8_t opCode;    // Op code for read
    uint8_t flags;     // Flag bits
    uint32_t lba;      // Logical block address
    uint8_t group;     // Command group
    uint16_t transferLength;  // Length of the data to read
    uint8_t control;   //  Control bits, the NACA being the only one defined
} __attribute__((packed));

packed 属性告诉 GCC 不要添加任何填充。因此,我们的结构不够高效,但它与硬件匹配。此外,我们的 assert 也没有失败,因此我们做得对。

typedef

我们可以使用 typedef 语句定义我们自己的类型。例如,以下语句定义了一个新的 dimension 类型:

typedef unsigned int dimension;  // Dimension for use in the plans

该类型等同于 unsigned int,可以像其他类型一样使用:

dimension width;   // Width of the thing in furlongs

typedef 的语法类似于变量声明。它包含 typedef 关键字和初始类型的名称,以及定义类型的名称:

typedef `initialtype` `newtypename`;  // A type definition

typedef 的一个例子可以在 stdint.h 文件中找到,该文件在许多程序中都被包含:

// These typedefs are system-dependent.
typedef signed char        int8_t;
typedef unsigned char      uint8_t;
typedef signed short int   int16_t;
typedef unsigned short int uint16_t;
typedef signed int         int32_t;
typedef unsigned int       uint32_t;

在 C 语言的早期,int 可能是 16 位或 32 位,这取决于处理器。在编程的早期,如果用户想使用 16 位整数(旧的 C 标准不支持),他们必须在代码中写出类似以下的内容:

#ifdef ON_16_BIT_CPU
typedef signed int   int16_t;
#else // ON_32_BIT_CPU
typedef signed short int   int16_t;
#endif

多年来,由于需要自己定义精确的数据类型,C 标准委员会创建了 stdint.h 头文件,并将其纳入语言的一部分。

函数指针与 typedef

C 语言允许使用函数指针,这在进行回调时非常有用。例如,我们可能会告诉图形系统在按下按钮时调用给定的函数。实现这功能的代码可能如下所示:

registerButtonPressHandler(functionToHandleButtonPress);

functionToHandleButtonPress 参数是一个指向返回整数并接受常量事件指针作为唯一参数的函数的指针。这个句子很复杂,翻译成 C 代码后也没有变得更容易:

int (*ButtonCallback)(const struct event* const theEvent);

第一个括号是必需的,因为如果没有它,我们定义的是一个返回整数指针的函数:

// Define function that returns int*
int* getPointer(...)

与其记住这些复杂的语法规则,不如使用 typedef 来简化语法:

// Function type for callback function
typedef int ButtonCallbackType(const struct event* const theEvent);

// Pointer to callback function
typedef ButtonCallbackType* ButtonCallbackPointer;

这将 registerButtonPressHandler 的定义更改为:

void registerButtonPressHandler(int (*callbackPointer)
     (const struct event* const theEvent));

到这里:

void registerButtonPressHandler(ButtonCallbackPointer callbackPointer);

typedef 提供了一种组织类型的方式,以简化代码并使其更清晰。

typedefstruct

我们已经看到如何使用 struct 来定义结构化数据类型。

struct rectangle {
    uint32_t width;  // Width of the rectangle
    uint32_t height; // Height of the rectangle
};

使用此结构时,我们必须使用 struct 关键字:

struct rectangle bigRectangle;   // A big rectangle

typedef 语句使我们能够避免使用 struct 关键字:

typedef struct{
    uint32_t width;       // Width of the rectangle
    uint32_t height;      // Height of the rectangle
} rectangle;

rectangle bigRectangle;   // A big rectangle

在这种情况下,typedef 告诉 C 我们想要定义一个新的 rectangle 类型。

有些人认为使用 typedef 来定义一个新的结构类型可以使代码更简洁、更清晰。也有人喜欢使用 struct,因为它能明确指出一个变量是 struct 类型。语法是可选的,因此可以根据自己的需求选择最适合的方式。

总结

本章主要讲述如何组织数据。enum类型允许你组织简单的名称列表,而不必担心哪个字段对应什么值。结构体为组织不同类型的数据提供了强大的工具。对于嵌入式程序员来说,它们还在与实际硬件设备通信时非常有用。然而,请记住,硬件设计师对于结构体布局的理解可能与 C 语言对其的理解有所不同。

虽然结构体只能存储一组固定的数据,但联合体可以存储多个数据集(只不过不能同时存储)。通过它们,我们可以很好地控制数据存储方式。

另一个组织数据的工具是typedef指令,它允许我们定义自己的数据类型。它让我们可以使用熟悉的类型来表示数据,而不必使用基本的 C 语言类型。

存在许多复杂的数据类型,C 语言提供了一整套很好的工具来管理这些类型。

编程问题

  1. 创建一个结构体来存储分数。然后创建程序来执行加法、减法、乘法和除法操作。分数应存储为标准化形式。换句话说,2/4 应该存储为 1/2。

  2. 创建一个名为car的结构体,包含电动和燃油汽车共有的属性。为其添加一个包含两个字段electricgas的联合体,这两个字段是分别存储电动和燃油汽车特有属性的结构体。例如,numberOfPassengers是所有汽车共有的字段,而chargingTime仅适用于电动汽车。

  3. 编写一个结构体来描述一个学生(单一班级)。数据应包含学生的姓名和学号,以及一个包含学生成绩的数组。

  4. 编写一个结构体来处理图 8-3 中显示的数据。f08003

    图 8-3:IPv4 头格式

  5. 南加州铁路是唯一一个在交叉口设置了 Acme 交通信号灯(带有臂和灯)的地方。要将信号从 STOP 更改为 GO,控制器必须执行以下操作:

    1. 关闭 STOP 灯。

    2. 打开 GO 灯。

    3. 打开 DIRECTION 继电器。

    4. 启动 ARM 继电器。

    5. 等待 3 秒钟。

    6. 关闭 ARM 继电器。

    7. 关闭 DIRECTION 继电器。

    我们有以下可用命令:(1)将灯光x的状态更改为y,其中x为 STOP 或 GO,y为 ON 或 OFF;(2)将 DIRECTION 继电器电源设置为x,其中x为 ON 或 OFF;(3)休眠n秒。为每个命令编写一个结构体。然后编写一个联合体,包含一个enum来标识命令和相应命令的结构体。

第九章:STM 的串行输出

我们现在回到“Hello World”,这一次我们将使用我们的 Nucleo 开发板,这带来了一些挑战。第一个挑战是在哪里写入信息。板上没有显示屏。幸运的是,芯片上有一个串行端口,且与开发板上部的 USB/串行端口连接良好。

下一个挑战是编写程序本身。我们需要初始化设备,并创建一个实际写入字符的过程。设备设计为一次接受一个字符,在编写程序时,我们必须牢记这一限制。

我们将在与设备交互之前模拟这个过程。C 语言提供了很多标准函数,例如puts,使得输出数据变得非常简单。然而,Nucleo 开发板没有这么方便的功能,所以我们必须编写自己的输出函数。为了过渡到适用于 Nucleo 的低级编码,我们将一次写入一个字符,输出“Hello World”。

一次写入一个字符的字符串

当 C 程序调用标准的puts函数时,它会启动一个涉及内核调用、内部缓冲、Interrupt 调度和设备驱动的长过程(下一章会详细介绍这些内容)。最终,它会到达一个阶段,开始一次发送一个字符到设备。为了模拟这一过程,我们将一次发送一个字符到操作系统。换句话说,我们将限制自己只使用标准的putchar函数来写入输出。

Listing 9-1 包含了一个以“艰难的方式”写出"Hello World\n"的程序。再次强调,我们这样做是因为在接下来的 Nucleo 板使用中,我们将不得不以真正艰难的方式来做。

putchar.c

/*
 * Print a string one character at a time.
 */
#include <stdio.h>

char hello[] = "Hello World\n"; // The characters to print
int curChar;    // Character number we are printing

int main()
{
  1 for (curChar = 0; hello[curChar] != '\0'; ++curChar)
        putchar(hello[curChar]);
    return (0);
}

Listing 9-1: 一次写入一个字符的字符串

这个程序中唯一有趣的部分是for循环 1,它不会在达到一定字符数后停止。相反,它会在程序遇到字符串结尾字符('\0')时停止。这样,程序就可以输出任意长度的字符串。

定义我们自己的 putchar

为了改进这个程序,首先我们将把curChar定义为局部变量。然后我们将定义一个名为myPutchar的函数,将字符发送到标准输出(参见 Listing 9-2)。

my_putchar.c

/**
 * Print a string one character at a time
 * using our own function.
 */
#include <stdio.h>

char hello[] = "Hello World\n"; // The characters to print

1 /**
 * Reimplementation of putchar
 *
 * @param ch The character to send
 *
 * @note Not as apparently useless as it seems
 */
2 void myPutchar(const char ch)
{
  3 putchar(ch);
}

int main()
{
    int curChar;        // Index of the current character we
                        // are printing
    for (curChar = 0; hello[curChar] != '\0'; ++curChar)
      4 myPutchar(hello[curChar]);
    return (0);
}

Listing 9-2: 使用我们自己的输出函数一次写一个字符

myPutchar的开始部分,我们向注释块中添加了一些附加元素。关键字@param表示参数,@note关键字定义了一个注释。你可以在 Doxygen 风格的注释中使用许多其他关键字,但目前我们只使用基础内容,以便与现有的 STM 代码兼容。

实际的函数从void myPutchar(const char ch)声明开始 2,这表明myPutchar过程不返回任何值,并且接受一个类型为char的参数。const修饰符表示我们在过程内部不会修改它。(实际上,我们不能修改它,因为如果我们尝试,编译器会报错。)

当执行该过程时 4,程序将执行以下步骤:

  1. 它计算hello[curChar]的值。

  2. 它将这个值放置在myPutchar可以找到的位置。

  3. 它记录下下一个指令的地址(for循环的结束)。

  4. 它开始执行myPutchar。(ch变量将在步骤 2 中初始化。)

当我们调用putchar 3 时,会执行一组相似的步骤。唯一的不同之处在于我们必须写出myPutchar,而编写标准 C 库的人则提供了putchar

创建一个什么也不做只调用另一个(putchar)的函数(myPutchar)并不是特别有用。Nucleo 开发板没有putchar函数,因此我们将在本章稍后自己编写。但在此之前,让我们先了解串行设备的细节。

串行输出

串行输出是从嵌入式系统中获取数据的最简单方式之一。电气接口由发送线(TX)、接收线(RX)和地线(GND)组成。大多数嵌入式系统将这些接口隐藏起来,仅供愿意拆开机箱并连接串行端口的开发者使用。

我们的芯片有一个串行设备可以写入。我们所需要做的就是连接开发板下半部分的微控制器(TX、RX、GND)和开发板上半部分的 USB/串行设备。

以下表格展示了我们需要的连接:

微控制器 USB/串行及其他支持设备
RX CN9-1
TX CN9-2
GND CN6-5

如果我们有一个没有内建串行控制器的 Raspberry Pi 或其他嵌入式系统,我们需要进行这些连接。图 9-1 展示了这些组件的布局和 STM 提供的内部接线。

STM 已经为我们做好了连接。无需跳线。

f09001

图 9-1:Nucleo 开发板上的串行通信

串行通信简史

串行通信的历史可以追溯到很久以前,甚至可以追溯到公元前(即计算机出现之前)。电报是那个时代的互联网,它允许通过电线传输远距离的信息。发送方由电报键组成,当按下时,接收方会发出“点击”声。这些点击声音通过一种叫做摩尔斯电码的系统进行编码(今天仍在使用)。这一发明彻底改变了通信方式。你可以将信息发送到下一个城市,并在当天收到回应。比起 Pony Express,这更快。

然而,问题是;你需要在电报两端有熟练的操作员,他们懂得摩尔斯电码。没有技能的人无法发送或接收信息,而培训操作员非常昂贵。一个解决方案是使用两个时钟:一个用于发射器,一个用于接收器。在时钟表盘上有字母,从AZ。例如,要发送一个S,发送方会等到时钟上的单一指针指向S,然后按下电报键。接收方看到指针指向S,然后记录下这个字母。

然而,保持时钟同步几乎是不可能的,因此一位非常聪明的发明家决定,每个时钟的指针都停在顶部位置。当发送方想要发送一个字母时,他们会按下电报键作为启动信号。时钟会足够精确地保持时间,确保指针正确地转动一圈。然后,发送方按下字母信号。当指针到达顶部时,一个短暂的暂停时间,称为停止时间,会给较慢的时钟一个赶上的机会。事件的顺序是这样的:启动信号,字母信号,停止时间。

现在让我们快速回到电传打字机的发明,它能够通过类似电报线路的方式发送文本。电传打字机并不是发送一个单一的字母脉冲,而是将字符编码成一系列八个脉冲(七个数据脉冲和一个用于基本错误检查的脉冲)。它使用由杠杆组成的键盘编码器,将按键转换成一个 8 位的代码,这个代码传递给一个机械移位寄存器,移位寄存器看起来像一个分配器帽。这个装置通过电线发送脉冲,另一个电传打字机会将脉冲转换为一个单一的打印字母。

电传打字机的顺序是这样的:发送方按下一个键,机械发送器会发送一个 10 位信号(1 个起始位,8 个数据位和 1 个停止位)。当接收方接收到起始位时,它会启动其移位寄存器(另一个带有分配器帽的电动机),并使用传入的脉冲来转动打印头,以便正确的字母被打印出来。在 8 个数据位发送完毕后,两台机器至少暂停 1 个位时间(即停止位),以保持同步。

大多数电传打字机的传输速率为 110 波特(比特/秒),即每秒 10 个字符。虽然在如今的兆比特互联网连接时代听起来不算什么,但在当时这是一项革命性的通信改进。

如今的计算机仍然使用电传打字机所使用的串行通信方式。速度有所提升,但基本协议保持不变。

行结束符

事实上,我们仍在处理另一个电传打字机遗留下来的问题:行结束符。在输入 80 个字符后,你可以发送一个叫做回车的字符,让机器返回到位置 1。问题在于,移动打印头需要两十分之一秒的时间。如果你在回车后立即发送一个字符,打印头在移动时尝试打印,会导致一团模糊的印刷痕迹出现在行的中间。

打字机的人通过将行结束符设为两个字符来解决了这个问题。第一个字符,回车符,将打印头移动到第 1 位。第二个字符,换行符,将纸张上移一行。由于换行符不会在纸上打印任何内容,因此它是在打印头移动到左边时完成的,这并不重要。

然而,计算机出现时,存储的成本非常高(每字节数百美元),因此存储两个字符作为行结束符非常昂贵。创建 Unix 的人,Linux 的灵感来源,决定只使用换行符 (\n) 字符。苹果决定只使用回车符 (\r),而微软决定使用回车符和换行符 (\r\n) 作为行结束符。

C 语言自动处理系统库中的不同类型的换行符,但仅在使用系统库时才会这样做。如果你自己编写代码,就像我们即将做的那样,你必须写出完整的行结束序列(\r\n)。

今天的串行通信

今天,几乎每个嵌入式处理器都有一个串行接口。串行设备简单且便宜。今天的接口与 1800 年代的区别在于,速度提高了(从 110 比特/秒到 115,200 比特/秒),电压也发生了变化。在 1800 年代,他们使用 -15 到 -3 作为零位,+3 到 +15 作为一位。这仍然是“标准”,但大多数计算机使用 0(表示零)和 3(表示一)作为电压标准。

处理串行 I/O 的设备被称为 通用异步接收器-发送器 (UART)。串行通信有两种主要类型:异步同步。在同步通信中,发送方和接收方的时钟必须通过让发送方持续发送字符来进行同步。接收方然后查看传入的字符,并从中推测时钟的时序。发送方必须始终发送字符,即使它只是一个“空闲”字符(表示没有数据)。在异步通信中,没有共享的时钟。起始位触发接收方启动其时钟并等待字符。异步通信假定发送方和接收方的时钟可以保持足够接近,以保证一个字符的时间。由于不需要持续的传输来保持时钟同步,因此没有空闲字符。当空闲时,发送方只是停止发送任何内容。

STM 芯片有一个端口,支持同步和异步通信,因此在 STM 文档中,你会看到它被称为 通用同步/异步接收器-发送器 (USART)。本程序使用 UART 这一术语,以兼容 STM HAL 库。

串行 Hello World!

让我们为main.c创建一个新项目,这是一个比较长的“Hello World”,但它必须完成操作系统为我们隐藏的所有任务。首先,我们包含定义 UART 信息(以及许多其他设备信息)的头文件:

#include "stm32f0xx.h"
#include "stm32f0xx_nucleo.h"

对于代码,我们将从main函数开始:

int main(void)
{
  1 HAL_Init(); // Initialize hardware.
    led2_Init();
    uart2_Init();

    // Keep sending the message for a long time.
  2 for (;;) {
        // Send character by character.
        for(current = 0; hello[current] != '\0'; ++current) {
          3 myPutchar(hello[current]);
        }
      4 HAL_Delay(500);
    }
}

main函数看起来与列表 9-1 和 9-2 中的一样。唯一的不同是它必须初始化我们将要使用的所有设备 1,包括硬件库(HAL_Init)、红色 LED(led2_Init)和 UART(uart2_Init)。我们的嵌入式程序不能停止,因此我们有一个无限循环 2,发送字符串 3,然后睡眠半秒 4。

接下来需要做的事情之一是创建一个ErrorHandler函数,HAL 库会在出现问题时调用它。我们不能打印错误信息,因为我们的打印代码已经崩溃了,所以我们转而使用红色指示灯闪烁。确实,这是一个非常有限的错误指示,但就像你车上的检查引擎灯一样。在这两种情况下,设计师们都在尽最大努力。我们在这里不会详细介绍Error_Handler函数;它与第三章中的“blink”函数相同,只是换了个名字。

UART 初始化

串行设备应该是简单易编程的。然而,意法半导体(STMicroelectronics)的工程师们决定通过提供额外的功能来改进简单的 UART。因此,我们的简单串行设备现在需要 45 页的参考手册来描述。我们只想用它来发送字符,甚至不需要接收它们。

幸运的是,HAL 库提供了一个名为HAL_UART_Init的函数,它隐藏了很多复杂的细节。不幸的是,它并没有隐藏调用HAL_UART_Init时的复杂细节,但你不能要求所有东西都完美。在uart2_Init函数中,我们必须设置一个初始化结构,然后调用HAL_UART_Init

void uart2_Init(void)
{
    // UART initialization
    // UART2 -- one connected to ST-LINK USB
  1 uartHandle.Instance = USART2;
  2 uartHandle.Init.BaudRate = 9600;                   // Speed 9600
  3 uartHandle.Init.WordLength = UART_WORDLENGTH_8B;   // 8 bits/character
  4 uartHandle.Init.StopBits = UART_STOPBITS_1;        // 1 stop bit
  5 uartHandle.Init.Parity = UART_PARITY_NONE;         // No parity
  6 uartHandle.Init.Mode = UART_MODE_TX_RX;            // Transmit & receive
  7 uartHandle.Init.HwFlowCtl = UART_HWCONTROL_NONE;   // No hw control

    // Oversample the incoming stream.
    uartHandle.Init.OverSampling = UART_OVERSAMPLING_16;

    // Do not use one-bit sampling.
  8 uartHandle.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;

    // Nothing advanced
  9 uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
    /*
     * For those of you connecting a terminal emulator, the above parameters
     * translate to 9600,8,N,1.
     */

    if (HAL_UART_Init(&uartHandle) != HAL_OK)
    {
        Error_Handler();
    }
}

我们首先告诉系统使用哪个 UART 1。我们的芯片有多个 UART,第二个 UART 连接到 USB 串行接口。接下来,我们将速度设置为 9,600 波特(位/秒)2,也就是每秒 960 个字符。为什么是 10 比 1 的比例?我们有 1 个位用于起始位,8 个位用于数据位,1 个位用于停止位。每个字符的位数是 8,因为 C 语言将字符存储在 8 位单元中。确实也可以有每个字符 5、6、7 或 9 位的系统,但几乎所有人都使用 8 位,除了用于 TDD 聋人通信设备的系统,它使用 5 位。我们需要告诉系统我们使用的是 8 位 3。

下一行配置停止位的数量 4,即字符之间的时间(以位为单位)。大多数人使用 1 个停止位。(如果发送方使用 2 个停止位而接收方使用 1 个,仍然可以正常工作。额外的位将被解释为字符之间的空闲时间。)

早期的串行设备使用 7 位字符和 1 位校验位。校验位提供了一种简单、原始的错误检查方法。我们不使用这个功能,所以我们关闭了校验 5。然后我们启用发射器和接收器 6。

原始串行接口(RS-232 标准)有许多硬件流控制线。在我们的电路板上,它们并没有接线,我们也没有使用它们 7。

这个 UART 的一个高级特性是过采样,它允许接收端在决定一个比特是 1 还是 0 之前,多次检查传入比特的状态。当电气环境噪声较大或者串行电缆长度较长时,这个特性非常有用。我们的串行“电缆”由两条走线组成,从电路板底部延伸到顶部,约 3 英寸。我们不需要过采样,但我们确实需要关闭它 8。最后,我们没有使用任何高级特性。

接下来,我们调用HAL_UART_Init来初始化 UART 9,这需要一些帮助来完成任务。我们处理器上的通用输入/输出(GPIO)引脚可以做很多不同的事情,包括作为 GPIO 引脚。大多数引脚有“替代功能”,意味着你可以将它们编程为不同的设备(GPIO 引脚、USART 设备、SPI 总线、I2C 总线、PWM 引脚等)。请注意,并不是所有引脚都支持所有设备。最后,HAL_UART_Init调用HAL_UART_MspInit,它为 UART 设置引脚:

HAL_StatusTypeDef HAL_UART_Init(UART_HandleTypeDef *huart)
{
  /* Check the UART handle allocation. */
  if(huart == NULL)
  {
    return HAL_ERROR;
  }
  // ...
  if(huart->gState == HAL_UART_STATE_RESET)
  {
    /* Allocate lock resource and initialize it. */
    huart->Lock = HAL_UNLOCKED;

    /* Initialize the low-level hardware: GPIO, CLOCK. */
    HAL_UART_MspInit(huart);
  }

我们需要提供HAL_UART_MspInit。请记住,引脚是昂贵的,而驱动它们的晶体管很便宜。默认情况下,驱动我们串行设备的两个引脚,分别命名为 PA2 和 PA3,是 GPIO 引脚。我们需要告诉系统使用这些引脚的替代功能,并将它们转变为串行设备引脚。

我们的HAL_UART_MspInit函数看起来与我们为“闪烁”程序使用的 GPIO 引脚初始化代码非常相似,但有一些细微的不同:

void HAL_UART_MspInit(UART_HandleTypeDef* uart)
{
    GPIO_InitTypeDef GPIO_InitStruct;
  1 if(uart->Instance == USART2)
    {
        /* Peripheral clock enable */
      2 __HAL_RCC_USART2_CLK_ENABLE();

 /*
         * USART2 GPIO Configuration
         * PA2     ------> USART2_TX
         * PA3     ------> USART2_RX
         */
        GPIO_InitStruct.Pin = GPIO_PIN_2|GPIO_PIN_3;
        GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
        // Alternate function -- that of UART
      3 GPIO_InitStruct.Alternate = GPIO_AF1_USART2;
        HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    }
}

这个函数首先检查我们使用的是哪个 USART。在这段代码中我们只设置了 USART2 1。然后,我们启用设备的时钟 2。接下来,我们配置 GPIO 引脚 3(在之前的闪烁程序中我们做过),这告诉芯片 PA2/PA3 不是 GPIO 引脚,而应该连接到 USART2。

传输一个字符

我们将使用myPutchar函数通过串行设备传输字符。USART 是一个内存映射的 I/O 设备。要发送一个字符,我们必须将它分配(写入)到神奇的内存位置(一个寄存器),然后它就会通过电缆发送出去:

uartHandle.Instance->TDR = ch;     // Send character to the UART.

我们还需要正确地计算字符的时机,这需要一些额外的代码:

void myPutchar(const char ch)
{
    // This line gets and saves the value of UART_FLAG_TXE at call
    // time. This value changes so if you stop the program on the "if"
    // line below, the value will be set to zero because it goes away
    // faster than you can look at it.
    int result __attribute__((unused)) =
        (uartHandle.Instance->ISR & UART_FLAG_TXE);

    // Block until the transmit empty (TXE) flag is set.
    while ((uartHandle.Instance->ISR & UART_FLAG_TXE) == 0)
        continue;

    uartHandle.Instance->TDR = ch;     // Send character to the UART.
}

我们正在编写的寄存器叫做传输数据寄存器(TDR)。如果在传输字符的同时写入此寄存器,新字符会覆盖旧字符,导致错误和混乱。要发送abc,我们会写出类似这样的代码:

uartHandle.Instance->TDR = 'a';
sleep_1_960_second();
uartHandle.Instance->TDR = 'b';
sleep_1_960_second();
uartHandle.Instance->TDR = 'c';
sleep_1_960_second();

这种定时非常棘手,尤其是当我们想在字符之间执行代码时。STM32 芯片有为每个操作分配的位,包括“TDR 空,可以写入另一个字符”。

这个位位于一个叫做中断和状态寄存器(ISR)的寄存器中,该寄存器包含多个位,用于指示设备的状态。图 9-2 展示了来自 STM32F030R8 参考手册(“RM0360 参考手册/STM32F030x4/x6/x8/xC 和 STM32F070x6/xB”)中该寄存器的示意图。

f09002

图 9-2:中断和状态寄存器内容

我们关心的是名为 TXE 的位(图中的第 0 位)。HAL 使用UART_FLAG_TXE这个名称定义了 TXE 位。我们必须等待 TXE 位被清除(变为零),然后才能将数据发送到 TDR,而不会覆盖正在传输的字符。代码中没有任何操作会改变uartHandle.Instance->ISR

然而,uartHandle.Instance->ISR是一个神奇的内存位置,电气上与设备连接。当像字符传输完成这样的事件发生时,设备的状态会发生变化,而uartHandle.Instance->ISR的内容也会发生变化。

现在,如果你尝试通过调试器检查uartHandle.Instance->ISRUART_FLAG_TXE标志将始终显示为已设置。这是因为它在字符传输完成时会被清除(在 1/960 秒内),对于计算机而言,这是很长的时间,但对于人类打字命令来说却是非常短的时间。

为了帮助显示正在发生的事情,我们添加了一条无用的语句:

int result __attribute__((unused)) =
    (uartHandle.Instance->ISR & UART_FLAG_TXE);

这个语句测试了UART_FLAG_TXE的值,并将其存储在result中。现在,(uartHandle.Instance->ISR & UART_FLAG_TXE)的值可能会神奇地变化,但result的值将在整个过程生命周期内保持不变。

你可以在调试器中查看result,并看到循环开始时位的值。你会注意到代码中有一个奇怪的短语:

__attribute__((unused))

这是 C 语言的一个 GCC 扩展。它告诉编译器我们知道这个变量未被使用,因此不会生成警告。(实际上,它未被程序使用,但调试器可能会使用它。编译器看不到程序外部的内容。)

我们为“Hello World”发送的字符串以\r\n(回车,换行)结尾。在我们第一章的原始“Hello World”程序中,操作系统编辑了输出流并将\n转换为\r\n。但我们没有操作系统,所以必须自己完成这一切。

清单 9-3 包含了我们“Hello World”程序的完整串行版本。

/**
 * @brief Write hello world on the serial port.
 */
#include <stdbool.h>
#include "stm32f0xx_nucleo.h"
#include "stm32f0xx.h"

const char hello[] = "Hello World!\r\n";   // The message to send
int current; // The character in the message we are sending

UART_HandleTypeDef uartHandle;      // UART initialization

/**
  * @brief This function is executed in case of error occurrence.
  *
  * All it does is blink the LED.
  */
void Error_Handler(void)
{
 /* Turn LED2 on. */
    HAL_GPIO_WritePin(LED2_GPIO_PORT, LED2_PIN, GPIO_PIN_SET);

    while (true)
    {
        // Toggle the state of LED2.
        HAL_GPIO_TogglePin(LED2_GPIO_PORT, LED2_PIN);
        HAL_Delay(1000);        // Wait one second.
    }
}
/**
 * Send character to the UART.
 *
 * @param ch The character to send
 */
void myPutchar(const char ch)
{
    // This line gets and saves the value of UART_FLAG_TXE at call
    // time. This value changes so if you stop the program on the "if"
    // line below, the value will be set to zero because it goes away
    // faster than you can look at it.
    int result __attribute__((unused)) =
        (uartHandle.Instance->ISR & UART_FLAG_TXE);

    // Block until the transmit empty (TXE) flag is set.
    while ((uartHandle.Instance->ISR & UART_FLAG_TXE) == 0)
        continue;

    uartHandle.Instance->TDR = ch;     // Send character to the UART.
}

/**
 * Initialize LED2 (so we can blink red for error).
 */
void led2_Init(void)
{
    // LED clock initialization
    LED2_GPIO_CLK_ENABLE();

    GPIO_InitTypeDef GPIO_LedInit;      // Initialization for the LED
    // Initialize LED.
    GPIO_LedInit.Pin = LED2_PIN;
    GPIO_LedInit.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_LedInit.Pull = GPIO_PULLUP;
    GPIO_LedInit.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(LED2_GPIO_PORT, &GPIO_LedInit);
}

/**
 * Initialize UART2 for output.
 */
void uart2_Init(void)
{
    // UART initialization
    // UART2 -- one connected to ST-LINK USB
 uartHandle.Instance = USART2;
    uartHandle.Init.BaudRate = 9600;                    // Speed 9600
    uartHandle.Init.WordLength = UART_WORDLENGTH_8B;    // 8 bits/character
    uartHandle.Init.StopBits = UART_STOPBITS_1;         // 1 stop bit
    uartHandle.Init.Parity = UART_PARITY_NONE;          // No parity
    uartHandle.Init.Mode = UART_MODE_TX_RX;             // Transmit & receive
    uartHandle.Init.HwFlowCtl = UART_HWCONTROL_NONE;    // No hw control

    // Oversample the incoming stream.
    uartHandle.Init.OverSampling = UART_OVERSAMPLING_16;

    // Do not use one-bit sampling.
    uartHandle.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;

    // Nothing advanced
    uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
    /*
     * For those of you connecting a terminal emulator, the above parameters
     * translate to 9600,8,N,1.
     */

    if (HAL_UART_Init(&uartHandle) != HAL_OK)
    {
        Error_Handler();
    }
}

int main(void)
{
    HAL_Init(); // Initialize hardware.
    led2_Init();
    uart2_Init();

    // Keep sending the message for a long time.
    for (;;) {
        // Send character by character.
        for(current = 0; hello[current] != '\0'; ++current) {
            myPutchar(hello[current]);
        }
        HAL_Delay(500);
    }
}

/**
 * Magic function that's called by the HAL layer to actually
 * initialize the UART. In this case we need to
 * put the UART pins in alternate mode so they act as
 * UART pins and not like GPIO pins.
 *
 * @note: Only works for UART2, the one connected to the USB serial
 * converter
 *
 * @param uart The UART information
 */
void HAL_UART_MspInit(UART_HandleTypeDef* uart)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    if(uart->Instance == USART2)
    {
        /* Peripheral clock enable */
        __HAL_RCC_USART2_CLK_ENABLE();

        /*
         * USART2 GPIO Configuration
         * PA2     ------> USART2_TX
         * PA3     ------> USART2_RX
         */
        GPIO_InitStruct.Pin = GPIO_PIN_2|GPIO_PIN_3;
        GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
        // Alternate function -- that of UART
        GPIO_InitStruct.Alternate = GPIO_AF1_USART2;
        HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    }

}

/**
 * Magic function called by HAL layer to de-initialize the
 * UART hardware. It's something we never do, but we put it
 * in here for the sake of completeness.
 *
 * @note: Only works for UART2, the one connected to the USB serial
 * converter
 *
 * @param uart The UART information
 */
void HAL_UART_MspDeInit(UART_HandleTypeDef* uart)
{
    if(uart->Instance == USART2)
    {
        /* Peripheral clock disable */
        __HAL_RCC_USART2_CLK_DISABLE();

        /*
         * USART2 GPIO Configuration
         * PA2     ------> USART2_TX
         * PA3     ------> USART2_RX
         */
        HAL_GPIO_DeInit(GPIOA, GPIO_PIN_2|GPIO_PIN_3);
    }
}

清单 9-3:程序 08.serial

与设备通信

现在我们有一个程序,可以通过串行线发送“Hello World”消息。串行线连接到板上的 USB/串行设备,该设备已插入你的计算机。要查看消息,你需要在计算机上运行一个终端仿真器。图 9-3 展示了这个设置。

f09003

图 9-3:串口通信

每个操作系统都有不同的终端仿真程序,某些情况下可能不止一个。这里提到的程序都是常见的、免费的并且易于使用。

Windows

在 Windows 上,我们将使用 PuTTY 程序(putty.org)。下载并安装到你的系统中,选择所有选项的默认设置,然后按照以下步骤操作:

  1. 确保 Nucleo 开发板没有连接到你的电脑。打开控制面板,进入设备管理器界面(参见图 9-4)。列表中没有串口设备,因此也没有“端口”部分。f09004

    图 9-4:未安装串口设备

  2. 插入 Nucleo 开发板。设备列表将发生变化,如图 9-5 所示。f09005

    图 9-5:新的 USB 串口设备

    你应该会看到一个名为 COM3\ 的新 USB 串口设备。(Windows 有一个为串口设备分配 COM 端口的系统,但没有人知道具体是怎样的。你可能会在你的电脑上看到不同的 COM 端口。)

  3. 启动 PuTTY。在主窗口中,如图 9-6 所示,选择串口单选按钮。在串口线路下,选择你刚在设备管理器中找到的新 COM 端口。速度应该默认设置为 9600。f09006

    图 9-6:启动 PuTTY

  4. 点击打开。应该会出现一个终端窗口,设备应开始向你打招呼。

Linux 和 macOS

在像 Linux 和 macOS 这样的 Unix 系统上,screen 程序效果很好。(minicom 程序也能完成这项工作。)要使用 screen,你需要知道串口设备的名称,不同操作系统中该名称不同。在 Linux 上,该设备很可能是 /dev/ttyACM0,但是如果你插入了其他串口设备,它可能是 /dev/ttyACM1/dev/ttyACM2 或类似的名称。在 macOS 上,该名称可能是 /dev/tty.usbmodem001,但也可能是 /dev/tty.usbmodem002/dev/tty.usbmodem003 或类似名称。

要找到设备名称,确保 Nucleo 开发板没有连接到你的电脑,然后在终端中执行以下命令之一:

$ **ls /dev/ttyACM***          (Linux)
$ **ls /dev/tty.usbmodem***    (macOS)

插入设备并再次执行相同的命令。你应该会在列表中看到一个新的设备。使用那个设备。现在执行以下命令:

$ **screen /dev/ttyACM0 9600**

你应该会看到“Hello World”出现在屏幕上。要退出程序,按 CTRL-A-\。

总结

本书的编程内容在第九章介绍了“Hello World”。为什么?因为我们必须自己做所有的事情。编写一个简单的程序逐字符地发送我们的信息,涉及到初始化 UART(这是一个复杂的过程),告诉 GPIO 引脚它现在是串口引脚,并使用硬件寄存器(那些神秘的内存位置,根据设备的状态会发生变化)来查看 UART 是否准备好发送字符。

我们已经取得了巨大的进步。首先,我们编程了一个中等复杂的设备,在这个过程中学到了很多关于直接低级 I/O 的知识。其次,串口是许多嵌入式设备中隐藏的主要诊断和维护工具。尽管过去 60 年计算机技术取得了巨大的进步,但最常用的调试技术仍然是printf输出到串口。串口是一个非常简单且稳健的设备,制造成本低且易于连接,现在我们知道如何使用它来调试嵌入式系统。

编程问题

  1. 对学生而言:当你让程序正常运行后,试试看去掉\r会发生什么。然后再试一下将\r恢复,但去掉\n

  2. 中等难度的谜题:尝试更改配置,使你发送 7 个数据位并使用偶校验(而不是 8 个数据位,无校验)。不要更改终端仿真器的配置。一些字符将会被改变。检查字符的位模式,找出哪些字符发生了变化,并解释为什么。

  3. 高级:如目前程序所写,我们没有使用流控制。无论你是否喜欢,程序都会输出“Hello World”。更改初始化代码以使用软件流控制。这意味着,当你输入 XOFF 字符(CTRL-S)时,输出应该停止。当你输入 XON(CTRL-Q)时,输出应该恢复。

  4. 高级:为 Nucleo 板编写一个读取字符的函数。它看起来与myPutchar函数非常相似,只是它会检查不同的位,并读取 I/O 端口而不是写入它。你需要阅读微控制器的文档,了解 RDR 位的作用。

第十章:中断

处理 I/O 的两种主要方法是 轮询,即反复询问设备是否有数据准备好,和 中断,即设备打断正常工作流程来告诉你它已经准备好了。本章描述了轮询和中断之间的区别,并解释了中断是如何工作的,以便你能够利用它们更高效地向串口写入字符串(是的,还是“Hello World”)。

轮询与中断

让我们考虑一下轮询和中断在电话中的应用场景。使用轮询时,铃声被关闭,你必须每 10 秒检查一次电话是否有来电。你必须坐在电话旁,不容易感到无聊。这种方法正是我们在第九章的串行程序中使用过的,基本上是这样的对话:

  1. “你忙吗?” “忙。”

  2. “你忙吗?” “忙。”

  3. “你忙吗?” “忙。”

  4. “你忙吗?” “不忙。” “这是下一个字符。”

计算机被卡在一个轮询循环中,等待 UART 状态寄存器指示 UART 准备好接受下一个字符。此时,计算机没有其他任务要做,也不会感到无聊。轮询的主要优点是它容易理解和实现。

让我们再回到电话的例子,但这次我们使用中断方法。你不会一直坐在电话旁检查是否有电话进来。相反,你继续进行正常的事务,直到电话响起(中断发生)。然后你抛下手头的一切,飞奔到电话旁接起电话——结果发现又是一个销售电话,卖的是你一辈子都不会买的东西。

在中断场景中的关键事件顺序如下:

  1. 我们继续进行正常工作。

  2. 我们收到一个中断(电话响了)。

  3. 我们接起电话(服务中断),大声说“不是,我不想买一把组合剃须刷和钢笔”,然后挂断电话。

  4. 我们继续从上次中断的地方恢复正常工作。

串行 I/O 的中断

我们只有在发送数据寄存器(TDR)为空时才能向 UART 发送字符。图 10-1 显示了 UART 部分的框图,说明了 TDR 的工作原理。

f10001

图 10-1:UART 传输硬件

当我们想发送一个字符时,我们将其放入 TDR,TDR 存储 8 位。然后字符被放入 传输移位寄存器(TSR),TSR 存储 10 位。额外的 2 位是字符开始时的起始位和字符结束时的停止位。然后,TSR 会将数据按位发送到 传输串行线(TX)

当数据从 TDR 移动到 TSR 时,TDR 变为空,准备接收另一个字符。

我们使用的轮询循环如下所示:

// Block until the transmit empty (TXE) flag is set.
while ((uartHandle.Instance->ISR & UART_FLAG_TXE) == 0)
    continue;

用英文来说就是:“你空了吗?你空了吗?你空了吗?”在 C 代码中,它也一样令人烦躁。再次说明,轮询的主要优点是简单。

传输字符的另一种方式是告诉系统,当 UART 准备接收下一个字符时我们希望触发中断。发生某些事件时,中断函数会自动被调用。在我们的例子中,我们希望在 TDR 为空时触发中断。

使用中断时,我们告诉处理器,“我要去做有用的工作。当 TDR 为空时,我希望你中断正常流程并调用中断例程函数,这样我可以给你下一个字符。”

中断例程

当中断发生时,CPU 会调用一个位于固定地址的中断例程函数,该地址由 CPU 的设计决定。早期的 CPU 为所有中断定义了一个地址,因此代码必须进行多次检查来确认是哪一个中断发生了:

if (diskInterrupt)      { handleDiskInterrupt(); return;}
if (serialInterrupt)    { handleSerialInterrupt(); return;}
if (keyboardInterrupt)  { handleKeyboardInterrupt(); return;}
if (mouseInterrupt)     { handleMouseInterrupt(); return;}
logUnknownInterrupt();

如今,即便是简单的芯片也可能有许多不同的设备。检查它们所有的中断来源是一个耗时的过程。因此,芯片(包括我们的 ARM 芯片)现在使用向量中断,这意味着每个外设都有自己的中断地址。来自 UART1 的中断会调用一个地址的中断例程,而来自 UART2 的中断会跳转到另一个地址(特别是USART2_IRQHandler),其他外设也是如此。

中断向量在startup/startup_stm32f030x8.S文件中定义:

g_pfnVectors:
  .word  _estack
  .word  Reset_Handler
  .word  NMI_Handler
  .word  HardFault_Handler
# Many more handlers
  .word  USART1_IRQHandler              /* USART1 */
  .word  USART2_IRQHandler              /* USART2 */

后续代码定义了USART2_IRQHandler符号:

 .weak      USART2_IRQHandler
  .thumb_set USART2_IRQHandler,Default_Handler

第二个指令(.thumb_set)定义了将USART2_IRQHandlerDefault_Handler相同的过程。

第一个.weak指令将其定义为弱符号。如果它是一个常规符号,并且我们尝试定义我们自己的USART2_IRQHandler,链接器将因重复符号错误消息而中止。然而,由于该符号是弱符号,链接器会丢弃弱定义并使用我们提供的符号。

startup/startup_stm32f030x8.S文件稍后定义了Default_Handler

 .section .text.Default_Handler,"ax",%progbits
Default_Handler:
Infinite_Loop:
  b Infinite_Loop

对中断的默认响应是无限循环,使得机器几乎完全无用(我说“几乎完全无用”,因为机器仍然会响应调试器和复位)。

我们将编写我们自己的USART2_IRQHandler来响应 TDR 为空时,从而用更有用的内容替代默认处理程序。

使用中断写字符串

现在,让我们将第九章中的串行 I/O 程序改为使用中断而不是轮询来写字符串。在上层(主程序)和下层(中断例程)之间传递的唯一信息是一个全局变量:

const char* volatile usart2String = NULL;     // The string we are sending

const限定符告诉 C 语言该字符数据是常量,我们永远不会尝试更改它。volatile限定符告诉 C 语言这个变量可能会随时被正常 C 程序流之外的东西改变,比如一个中断函数。

为了澄清,因为在这一点上 C 语言的语法有点复杂,const 出现在 char 声明之前,表示字符数据是常量。它不会在指针运算符 (*) 之后出现,因此指针不是常量。volatile 修饰符出现在指针运算符之后,表示指针可能会被更改。指针运算符之后缺少 const 修饰符意味着程序可以更改该值。

我们需要小心处理两个层次共享的任何变量。幸运的是,在此示例中,只有一个变量 usart2String。以下列表显示了该变量的工作流程:

上层 (主程序)

  1. 等待 usart2String 变为 NULL

  2. 将其指向我们要发送到输出的字符串。

  3. 发送第一个字符。

  4. 增加指针。

  5. 启用 UART 中断。

底层 (中断)

  1. 如果已到达字符串末尾,则将 usart2String 设置为 NULL

  2. 确认 UART 接收到中断。

  3. 发送指向的字符。

  4. 增加指针。

上层和下层都会增加指针。在启用中断时,我们需要非常小心,以确保两个层次不同时尝试使用指针。只有当 usart2String == NULL 时,上层才不会执行任何操作,而下层仅在数据耗尽并且禁用了 UART2 中断时才将 usart2String 设置为 NULL。上层通过在执行增量之后再启用中断来保护自己。因此,中断程序无法改变代码。

这种分析非常重要。如果未进行或未正确进行,则程序将失败,并且故障将在随机时间产生随机结果。这些结果构成了一个非常棘手、难以调试的问题。

实际上,我花了大约三年的时间找到其中一个漏洞。这个问题只发生在一个客户身上,每两个月才会发生一次。我们完全无法在实验室复现这个问题。幸运的是,客户非常冷静,并愿意与我们合作找到解决方案。本章后面,我们将探讨在没有进行此分析或未正确进行此分析时会发生什么,并考虑一些诊断与中断相关的 bug 的技术。

代码清单 10-1 包含基于中断驱动的串行 I/O 程序。

/**
  * @brief   Write Hello World to the serial I/O.
  * Use interrupts instead of polling.
*/

#include <stdbool.h>
#include "stm32f0xx_nucleo.h"
#include "stm32f0xx.h"

const char hello[] = "Hello World!\r\n";   // The message to send
int current; // The character in the message we are sending

UART_HandleTypeDef uartHandle;      // UART initialization

`... Error_Handler same as Listing 9-3 ...`

const char* volatile usart2String = NULL;       // The string we are sending
/**
 * Handle the USART2 interrupt.
 *
 * Magically called by the chip's interrupt system.
 * Name is fixed because of the startup code that
 * populates the interrupt vector.
 */
void USART2_IRQHandler(void)
{
    if ((uartHandle.Instance->ISR & USART_ISR_TXE) != 0) {
        // This should never happen, but we don't want to crash if it does.
        if (usart2String == NULL) {
            // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
        if (*usart2String == '\0') {
            usart2String = NULL;        // We're done with the string.
            // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
        uartHandle.Instance->TDR = *usart2String; // Send character to the UART.
        ++usart2String;              // Point to next character.
        return;
    }
    // Since the only interrupt we enabled was TXE, we should never
    // get here. When we do enable other interrupts, we need to put
    // code to handle them here.
}
/**
 * Our version of puts
 *
 * Outputs the exact string given to the output
 *
 * @param str String to send
 *
 * @note Assumes that str is not null and not
 * pointing to the empty string
 */
void myPuts(const char* str)
{
    // If someone is sending a string, wait for it.
    while (usart2String != NULL)
        continue;

    // Tell the interrupt route what string to use.
    usart2String = str;

    uartHandle.Instance->TDR = *usart2String;  // Send character to the UART.
    ++usart2String;             // Point to next character.
    // Enable the interrupt.
    uartHandle.Instance->CR1 |= USART_CR1_TXEIE;
}

`... led2_Init and uart2_Init, same as Listing 9-3 ...`

int main(void)
{
    HAL_Init(); // Initialize hardware.
    led2_Init();
    uart2_Init();
    // Tell the chip that we want the interrupt vector
    // for USART2 to be enabled.
    NVIC_EnableIRQ(USART2_IRQn);

    // Keep sending the message for a long time.
    for (;;) {
        myPuts(hello);
        HAL_Delay(500);
    }
}

`... HAL_UART_MspInit and HAL_UART_MspDeInit, same as Listing 9-3 ...`

代码清单 10-1: 10.serial.int/main.c

程序详细信息

代码清单 10-1 看起来很像第九章的串行 I/O 程序,因为设置 I/O 系统是相同的,只是增加了许多额外的细节。但在这种情况下,我们添加了新的东西:

int main(void)
{
    HAL_Init(); // Initialize hardware
    led2_Init();
    uart2_Init();
    // Tell the chip that we want the interrupt vector
    // for USART2 to be enabled.
    NVIC_EnableIRQ(USART2_IRQn);

NVIC_EnableIRQ 函数初始化了嵌套向量中断控制器 (NVIC),这是一个硬件部件,决定处理器在接收中断时的操作,并启用了 USART2 中断。处理器复位时会关闭所有中断,因此我们需要告诉它我们希望 USART2 中断它。

现在让我们看看myPuts函数,它将一个字符串(而不是像第九章中的myPutchar那样的单个字符)发送到串行设备:

void myPuts(const char* str)
{
    // If someone is sending a string, wait for it.
  1 while (usart2String != NULL)
        continue;

    // Tell the interrupt route what string to use.
  2 usart2String = str;

  3 uartHandle.Instance->TDR = *usart2String;  // Send character to the UART.
    ++usart2String;             // Point to next character.
    // Enable the interrupt.
  4 uartHandle.Instance->CR1 |= USART_CR1_TXEIE;
}

我们首先要做的是等待前一个字符串传输完成 1。我们知道,如果usart2String不为NULL,中断例程正在活动,我们应该等待直到前一个字符串传输完毕。当它变为NULL时,中断例程不再活动,我们就可以开始传输。

当轮到我们时,我们会告诉中断函数我们正在传输哪个字符串 2,然后传输第一个字符 3。最后一步,我们启用传输数据缓冲区空中断 4。

有几个符号控制着哪些中断被使能。USART_CR1_TXNEIE位告诉 UART 在传输数据缓冲区为空时产生中断。这里还有一些其他需要注意的符号:

  1. USART_CR1_IDLEIE 空闲中断使能

  2. USART_CR1_RXNEIE 接收中断使能

  3. USART_CR1_TCIE 传输完成中断使能(在字符传输完毕时触发中断,而不是当我们第一次将字符加载到传输寄存器时)

  4. USART_CR1_PEIE 奇偶校验错误中断使能

一旦我们发送第一个字符,TDR 会被填充。当它被传输到 TSR 时,TDR 将变为空,我们会得到一个中断。从此之后,中断例程将完成工作。

实际的中断例程如下:

1 void USART2_IRQHandler(void)
{
  2 if ((uartHandle.Instance->ISR & USART_ISR_TXE) != 0) {
        // This should never happen, but we don't want to crash if it does.
      3 if (usart2String == NULL) {
 // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
      4 if (*usart2String == '\0') {
            usart2String = NULL;        // We're done with the string.
            // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
      5 uartHandle.Instance->TDR = *usart2String; // Send character to the UART.
      6 ++usart2String;         // Point to next character.
        return;
    }
    // Since the only interrupt we enabled was TXE, we should never
    // get here. When we do enable other interrupts, we need to put
    // code to handle them here.
}

函数声明使用了一个魔法名称,标识它为中断例程 1。

如果函数被调用,我们知道我们收到了来自 USART2 的中断,但我们不知道是哪种类型的中断,因为 USART 有多种中断类型:

  1. USART_ISR_TXE TDR 为空

  2. USART_ISR_CTSIF CTS 中断

  3. USART_ISR_TC 传输完成

  4. USART_ISR_RXNE 接收数据寄存器不为空(数据已准备好读取)

  5. USART_ISR_ORE 溢出错误检测到

  6. USART_ISR_IDLE 空闲线路检测到

  7. USART_ISR_FE 帧错误

  8. USART_ISR_PE 奇偶校验错误

  9. USART_ISR_NE 噪声标志

  10. USART_ISR_CMF 字符匹配

  11. USART_ISR_TXE 接收超时

所有这些中断将导致调用USART2_IRQHandler

首先,我们需要检查是否有传输缓冲区空中断 2。我们的中断函数不应该在usart2StringNULL时被调用,但“应该”与“现实”有很大差距,所以我们加入了一些谨慎,以确保在出现问题时不会崩溃 3。此时变量usart2String不应该为NULL,但如果它是NULL,我们也不想引发问题。

如果没有那个检查,我们可能会尝试解引用一个NULL指针 4。解引用NULL指针是非法的,而 STM32 足够智能,具备硬件来检查这种情况。当这种情况发生时,STM32 会生成一个内存故障中断。换句话说,中断处理程序正在被中断,控制权转移到内存故障中断处理程序。然而,我们还没有编写处理程序,因此执行的是默认的中断处理程序。如前所述,默认处理程序会锁死系统,直到你重置它。为了保护自己免受不当的usart2String影响,当我们看到它时,我们采取最安全的做法,即关闭中断并什么都不做 3。

接下来,我们检查是否已经发送完字符串。如果已经发送完,我们将字符串设为NULL,以便向上层信号表示我们完成了任务并关闭中断。否则,我们知道 UART 还有数据,而 TDR 为空,因此我们将一个字符写入其中 5。发送完字符后,我们需要指向下一个字符,为下次中断做好准备,然后返回主程序 6。

此时,TDR 已满,UART 正在发送字符。中断路径没有更多的任务,因此它将返回,正常执行将恢复。当字符发送完毕并且 TDR 为空时,我们会收到另一个中断,直到字符串发送完毕并关闭中断。

STMicroelectronics 的硬件工程师通过一张图帮助解释了这个过程(见图 10-2)。

f10002

图 10-2:USART 中断映射

这张图显示了如果 TCIE(传输字符中断使能)位为 1,并且一个字符已经被传输(TC)2,AND 门的输出为真 3。这个结果与另外三个中断的输出结合,如果有任何一个为真(OR 门),结果 4 为真。然后,结果与另一个 OR 门的输出 5 结合,处理所有其他信号,最后的 OR 门输出即为 USART 中断信号。请注意,这张图旨在简化此过程。如果你想了解输入信号的具体含义,可以阅读这款处理器的 800 页参考手册。

中断地狱

中断在控制硬件以及处理实时事件时是非常强大的工具。然而,使用中断可能会导致一些独特且难以解决的问题。

首先,它们可以随时中断正常的程序流程。例如,考虑以下代码:

i = 5;
if (i == 5) {

执行此语句后,i的值是多少?答案显然是 5,除非中断程序刚刚执行并修改了它:

i = 5;
←----- interrupt triggers, i set to 6.
if (i == 5) { // No longer true

其次,中断例程是异步执行的,这意味着它们会在任何需要的时候执行,因此,由于编写不当的中断例程引起的错误可能很难复现。我曾经见过一种情况,错误只有在经过两周的测试后偶尔发生,因为中断必须在两条指令之一执行时恰好发生——这只是成千上万条指令中的两条。需要大量的测试才能偶然发现这个问题。

由于中断例程固有的困难,值得对它们给予高度重视。处理中断例程时最重要的设计原则是保持其简短和简单,因为例程做的越少,出错的可能性也就越小。最好将“思考”留给高层代码,在那里调试工具可以很好地工作,并且可复现性不是问题。

中断例程也需要快速执行,因为在中断例程执行时,其他中断会被暂时挂起,直到该例程执行完成。如果你在一个读取 UART1 字符的中断例程中花费了很长时间,另一个设备(如 UART2)可能会丢失数据,因为它的中断没有及时得到处理。

使用缓冲区提高速度

我们刚刚使用的系统有一些限制。它一次只能传输一条消息。假设我们想要输出多条短消息。每条消息必须等待前一条完成。这里是一个示例:

myPuts("There are ");
if (messageCount == 0)
   myPuts(" no ");              // Blocks waiting on previous message
else
   myPuts(" some ");            // Blocks waiting on previous message
myPuts("messages waiting\r\n"); // Blocks waiting on previous message

解决这个问题的一种方法是创建一个缓冲区,将字符数据保存,直到中断例程能够处理它。这样做增加了应用程序的复杂性,但它提高了你的顶层程序发送数据的速度。

使用串行 I/O 意味着要考虑速度与简单性的一般趋势。轮询版本非常简单且慢。单一字符串中断版本较快,但更复杂。我们现在使用的缓冲区系统则更快且更加复杂。这个趋势对大多数程序来说都是成立的。

为了解决这个问题,我们回到缓冲区的概念。我们将使用一个循环缓冲区,它具有以下基本结构:

struct circularBuffer {
    uint32_t putIndex;      // Where we will put the next character
    uint32_t getIndex;      // Where to get the next character
    uint32_t nCharacters;   // Number of characters in the buffer
    char data[BUFFER_SIZE]; // The data in the buffer
}

它被称为循环缓冲区,因为索引会回绕。换句话说,当一个字符被放入数据的最后一个元素时,putIndex会从 7(BUFFER_SIZE-1)回绕到 0。

从图形上看,这像是图 10-3。

f10003

图 10-3:循环缓冲区的工作原理

我们的实现将包含一个故意的错误,这样我们可以在一个小的、可控的程序中查看查找此类错误的技术和方法。我们将在程序中展示错误的症状、用于定位错误的诊断技术以及修复方法。

我第一次遇到这种问题是在大约 30 年前。它并不是出现在一个 200 行的演示程序中,而是在一个有着数万行代码的 BSD Unix 内核中的串行模块。它大约每三到七天随机发生一次,三个工程师花了两个月才找到了它。更糟糕的是,它依赖于处理器,并且在运行原始 BSD 的 VAX 处理器上不会发生。

顶层程序(发送方)将数据放入缓冲区。低级中断例程(接收方)从缓冲区中移除数据。

用伪代码表示,发送方的工作如下:

If the buffer is full, wait.
Put a character in the buffer at putIndex.
Move putIndex up one, wrapping if needed.
Increment the number of characters in the buffer.

接收方的工作如下:

Get a character from the buffer at getIndex.
Increment getIndex, wrapping as needed.
Decrement the number of characters in the buffer.

发送函数

为了使用循环缓冲区,我们必须回到一次发送一个字符,使用另一个版本的 myPutchar。让我们看看执行发送的代码:

void myPutchar(const char ch)
{
    // Wait until there is room.
  1 while (buffer.nCharacters == BUFFER_SIZE)
        continue;
  2 buffer.data[buffer.putIndex] = ch;
  3 ++buffer.putIndex;
    if (buffer.putIndex == BUFFER_SIZE)
        buffer.putIndex = 0;

    // We've added another character.
  4 ++buffer.nCharacters;
    // Now we're done.

    // Enable the interrupt (or reenable it).
  5 uartHandle.Instance->CR1 |= USART_CR1_TXEIE;
}

一开始,我们等待缓冲区中至少有一个字符的位置。然后我们将字符倒入缓冲区。putIndex 前进一个位置,并在必要时回绕到 0。缓冲区中的字符数量增加,因此我们在缓冲区结构中增加 nCharacters 的计数。最后,我们启用中断。如果我们正在传输,可能中断已经启用,再次启用也不会造成问题。

中断例程

中断例程从缓冲区读取数据并将其传输到 UART。如果缓冲区中有字符,例程将其移除并发送到 UART。如果缓冲区中没有数据,例程会关闭中断。

这是这个过程的代码:

void USART2_IRQHandler(void)
{
  1 if ((uartHandle.Instance->ISR & USART_ISR_TXE) != 0) {
        if (buffer.nCharacters == 0) {
            // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
      2 // Send to UART.
        uartHandle.Instance->TDR = buffer.data[buffer.getIndex];
      3 ++buffer.getIndex;
        if (buffer.getIndex == BUFFER_SIZE)
            buffer.getIndex = 0;

      4 --buffer.nCharacters;

      5 if (buffer.nCharacters == 0)
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
        return;
    }
    // Since the only interrupt we enabled was TXE, we should
    // never get here. When we do enable other interrupts,
    // we need to put the code to handle them here.
}

首先,我们检查设备是否已没有数据。如果没有,我们关闭中断,直到从上层获取更多数据。然后我们发送字符,getIndex 上移一位,并在需要时回绕。接下来,我们让上层知道我们少了一个字符。最后,如果这是最后一个字符,我们关闭中断。

完整程序

清单 10-2 显示了完整的程序。

/**
  * @brief   Write Hello World to the serial I/O
  * using a circular buffer.
  *
  * @note Contains a race condition to demonstrate
  * how not do do this program
  */

#include <stdbool.h>
#include "stm32f0xx_nucleo.h"
#include "stm32f0xx.h"

const char hello[] = "Hello World!\r\n";   // The message to send
int current; // The character in the message we are sending

UART_HandleTypeDef uartHandle;      // UART initialization

#define BUFFER_SIZE 8   // The data buffer size

struct circularBuffer {
    uint32_t putIndex;      // Where we will put the next character
    uint32_t getIndex;      // Where to get the next character
    uint32_t nCharacters;   // Number of characters in the buffer
    char data[BUFFER_SIZE]; // The data in the buffer
};

// A simple, classic circular buffer for USART2
volatile struct circularBuffer buffer = {0,0,0, {'\0'}};

`... Error_Handler from Listing 9-3` `...`

/**
 * Handle the USART2 interrupt.
 *
 * Magically called by the chip's interrupt system.
 * Name is fixed because of the startup code that
 * populates the interrupt vector.
 */
void USART2_IRQHandler(void)
{
    if ((uartHandle.Instance->ISR & USART_ISR_TXE) != 0) {
        if (buffer.nCharacters == 0) {
            // Turn off interrupt.
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
            return;
        }
        // Send to UART.
        uartHandle.Instance->TDR = buffer.data[buffer.getIndex];
        ++buffer.getIndex;
        if (buffer.getIndex == BUFFER_SIZE)
            buffer.getIndex = 0;

        --buffer.nCharacters;

        if (buffer.nCharacters == 0)
            uartHandle.Instance->CR1 &= ~(USART_CR1_TXEIE);
        return;
    }
    // Since the only interrupt we enabled was TXE, we should never
    // get here. When we do enable other interrupts, we need to put
    // code to handle them here.
}

/**
 * Put a character in the serial buffer.
 *
 * @param ch The character to send
 */
void myPutchar(const char ch)
{
    // Wait until there is room.
    while (buffer.nCharacters == BUFFER_SIZE)
        continue;
    buffer.data[buffer.putIndex] = ch;
    ++buffer.putIndex;
    if (buffer.putIndex == BUFFER_SIZE)
        buffer.putIndex = 0;

    // We've added another character.
    ++buffer.nCharacters;
    // Now we're done.

    // Enable the interrupt (or reenable it).
    uartHandle.Instance->CR1 |= USART_CR1_TXEIE;
}

/**
 * Our version of puts
 *
 * Outputs the exact string given to the output
 *
 * @param str String to send
 *
 * @note Assumes that str is not null
 */
void myPuts(const char* str)
{
    for (/* str set */; *str != '\0'; ++str)
        myPutchar(*str);
}

`... led2_init and uart2_init same as Listing 9-3 ...`

int main(void)
{
    HAL_Init(); // Initialize hardware.
    led2_Init();
    uart2_Init();
    // Tell the chip that we want the interrupt vector
    // for USART2 to be enabled.
    NVIC_EnableIRQ(USART2_IRQn);

    // Keep sending the message for a long time.
    for (;;) {
        myPuts(hello);
        HAL_Delay(500);
    }
}

`... HAL_UART_MspInit and HAL_UART_MspDeInit same as Listing 9-3 ...`

清单 10-2:10.serial.buffer.bad/src/main.c

问题

当我们运行程序时,我们期望看到以下内容:

Hello World!
Hello World!
Hello World!

结果是这样的:

Hello World!
ello World!
Hello Wold!
Hello World!

此问题的发生没有固定的模式,除了我们发送到缓冲区的数据越多,发生问题的可能性就越大。

使用这个小程序,在现场复制该问题会很困难,因为它需要非常精确的时序。更有可能的是,在实现并测试了这段代码后,我们会将模块纳入另一个程序,这样时序问题的发生概率会更大。时序错误通常很难按需触发,但这段代码中确实存在一个问题。

我们知道,系统越忙,问题发生的可能性就越大。此外,我们还有另一个线索——我们一直没能在发生时抓住它,但事后我们查看了调试器,发现了以下内容:

nCharacters == 0
getIndex != putIndex

在一个正常工作的程序中,这两个条件永远不可能同时成立。解决这个问题有两种方法。第一种是对代码进行插桩,尝试找出发生了什么。第二种方法是对上下层之间共享的数据和操作这些数据的代码进行非常详细的分析。我们两者都做一下。

对代码进行插桩

对代码进行插桩意味着插入临时的调试语句,这将有助于发现问题。对于大多数代码来说,这意味着写printf语句输出中间数据,供我们在程序执行过程中进行检查。有时,数据会被打印到日志文件中,供问题发生后分析。对于我们的嵌入式程序,这两种方式都不可行。我们不能使用printf,因为输出会发送到串口控制台,而控制台本身就包含了错误的代码。我们也不能写日志文件,因为我们没有文件系统来写入日志信息。

我们需要使用一个日志缓冲区来存储最后 100 个事件。当我们遇到问题时,可以回溯查看这些事件,以了解问题的发生经过。日志记录相关数据(getIndexputIndexnCharacters)和事件记录代码的行号。

当问题发生并且我们有机会在调试器中暂停程序时,我们可以查看日志。如果幸运的话,我们应该能够找到一些日志条目,其中在X行时buffer信息是一致的,而在Y行时buffer出现了问题,这揭示了问题发生在X行和Y行之间。

列表 10-3 展示了记录事件的代码。将此代码添加到其他定义和变量声明之后。

#define N_EVENTS 100                   // Store 100 events.
uint32_t nextEvent = 0;                // Where to put the next event
struct logEvent debugEvents[N_EVENTS]; // The log data

void debugEvent(const uint32_t line)
{
    debugEvents[nextEvent].line = line;
    debugEvents[nextEvent].putIndex = buffer.putIndex;
    debugEvents[nextEvent].getIndex = buffer.getIndex;
    debugEvents[nextEvent].nCharacters = buffer.nCharacters;
    ++nextEvent;
    if (nextEvent == N_NEVETS)
        nextEvent = 0;
}

列表 10-3:事件记录器

这个事件记录器包含了我们正在尝试找出的同样类型的错误,但目前我们假设它足够有效,可以帮助我们定位问题。

现在我们需要插入一些debugEvent函数调用,看看能否发现错误。由于nCharacters让我们抓狂,我们在每次操作nCharacters之前和之后都插入了debugEvent调用:

void USART2_IRQHandler(void)
*--snip--*
      debugEvent(__LINE__);
      --buffer.nCharacters;
      debugEvent(__LINE__);

void myPutchar(const char ch)
*--snip--*
      debugEvent(__LINE__);
      ++buffer.nCharacters;
      debugEvent(__LINE__);

我们还在myPutchar函数的开头增加了一致性检查,以确保buffer是正常的。具体来说,如果我们发现buffer不一致的情况(nCharacters == 0getIndex != putIndex),我们调用Error_Handler函数停止程序:

void myPutchar(const char ch)
{
    if ((buffer.nCharacters == 0) && (buffer.getIndex != buffer.putIndex))
        Error_Handler();

让我们在调试器中启动程序,并在Error_Handler处设置断点,看看能否捕捉到错误。最终,我们触发了断点,并通过调试器检查了debugEvents。回顾调试器中的追踪信息,我们发现了以下内容:

  • 第 119 行:nCharacters == 3

  • 第 89 行:nCharacters == 3

  • 第 91 行:nCharacters == 2

  • 第 121 行:nCharacters == 4

为什么nCharacters在最后两次事件之间跳了 2?

相关的代码如下:

77 void USART2_IRQHandler(void)
*--snip--*
89      debugEvent(__LINE__);
90      --buffer.nCharacters;
91      debugEvent(__LINE__);

以及:

106 void myPutchar(const char ch)
*--snip--*
119     debugEvent(__LINE__);
120     ++buffer.nCharacters;
121     debugEvent(__LINE__);

第 90 行或第 120 行出了问题,这也告诉我们一些重要的信息。在第 119 行和第 121 行之间发生了中断。我们现在已将错误定位到几行代码和一个中断。让我们转变思路,使用代码分析得出相同的结论。我们同时探索这两种方法,因为有时候一种方法有效,另一种却无效。

分析代码

另一个找出问题的方法是分析正在发生的事情,并尝试识别潜在的问题点。分析从识别上下层之间共享的数据开始——换句话说,就是缓冲区:

struct circularBuffer {
    uint32_t putIndex;      // Where we will put the next character
    uint32_t getIndex;      // Where to get the next character
    uint32_t nCharacters;   // Number of characters in the buffer
    char data[BUFFER_SIZE]; // The data in the buffer
};

putIndexgetIndex不应该导致问题,因为它们只被上下两层各自使用。data数组是由两层共享的,但由上层写入,下层读取,因此每层在处理该数组时有不同的职责。此外,putIndex控制上层使用的数组部分,而getIndex控制下层使用的数组部分。它们指向数组的不同元素,进入或离开data的内容不会影响索引或字符计数器。data数组不是问题所在。

剩下的只有nCharacters,上层递增,下层递减,因此有两个潜在的问题行。一个出现在中断例程中:

--buffer.nCharacters;

另一个问题出现在myPutchar中:

++buffer.nCharacters;

这些是我们的仪器化代码指出可能存在问题的同两行。

细致检查代码

让我们看看执行以下代码行时究竟发生了什么:

++buffer.nCharacters;

这是这一行的汇编代码(已添加注释):

120:../src/main.c ***  ++buffer.nCharacters;
404               loc 2 118 0
405 005c 094B     ldr  r3, .L22     ; Load r3 with the address in .L22,
                                    ; which happens to be "buffer".
406 005e 9B68     ldr  r3, [r3, #8] ; Get the value of  
                                    ; buffer.nCharacters.
407 0060 5A1C   1 adds r2, r3, #1   ; Add 1 to r3 and store the result in r2.
408 0062 084B     ldr  r3, .L22     ; Get the address again.
409 0064 9A60     str  r2, [r3, #8] ; Store buffer.nCharacters.

这段代码将nCharacters的值加载到寄存器r3,递增它,并将其存回nCharacters。中断可以随时发生,比如就在值被加载到r3中之后,这导致以下情况发生(假设nCharacters为 3):

  1. 在第 406 行,寄存器r3获得了nCharacters的值(r3 == 3)。

  2. 在第 407 行指令之前发生了一个中断。

  3. 中断例程读取nCharacters(它是 3)。

  4. 它将其递减,所以nCharacters的值现在是 2。

  5. 中断例程完成并将控制权返回到第 407 行。

  6. 第 407 行的指令将 1 加到寄存器r3并将结果存入r2r3的值是 3,r2的值是 4)。

  7. 第 409 行将r2的值存入nCharacters,它应该是 3,但现在是 4。

  8. 在第 407 行,程序假设r3具有正确的nCharacters值,但它没有。

寄存器r3没有正确的nCharacters值,因为在恰当的时刻发生了中断,变量被修改了。我们未能保护共享数据的一致性。上层在修改nCharacters时,下层也在同时修改它*。

如果中断发生在其他指令之间,这个问题不会发生。这个问题是随机的,发生得很少,因此是较难解决的问题之一。

解决问题

解决方案是防止中断例程在我们修改nCharacters时进行修改。为此,我们在增加之前关闭中断,之后再开启它们:

119      __disable_irq();
120      ++buffer.nCharacters;
121      __enable_irq();

保持中断关闭的时间尽可能短。如果中断关闭时间过长,可能会错过一个中断并丢失数据。

在中断例程中,我们减少了nCharacters,那么我们不需要用__disable_irq__enable_irq来保护它吗?我们不需要,因为当发生中断时,系统会自动执行以下步骤:

  1. 它禁用该级别及以下的中断。更高级别的中断可以打断我们的中断例程,但较低级别的中断不能。

  2. 它保存了机器的状态,包括所有通用寄存器和状态寄存器。

  3. 它调用中断函数。

当中断例程返回时,系统会执行以下步骤:

  1. 它恢复机器的状态。

  2. 它重新开启中断。

  3. 它将控制权返回给上层代码。

在中断例程的开始和结束,需要做很多记录工作。幸运的是,ARM 处理器系列的设计者决定在硬件中完成这一切。其他处理器可能没有这么友好。

概述

中断使你能够实时响应输入和输出请求。它们也允许你以奇怪和随机的方式弄乱你的程序。务必保持中断例程和访问共享数据的代码尽可能简单和清晰。花额外的时间确保中断相关代码编写正确,后续会节省大量调试时间。

编程问题

  1. 创建一个中断例程,从串行端口读取字符。

  2. 添加一个中断例程来处理按钮按下事件,并在发生时更改消息。

  3. 浏览 HAL 库,找出__disable_irq是如何实现的。

第十一章:链接器

本章极为详细地探讨了链接过程的工作原理。链接器的工作是将构成程序的所有目标文件组合在一起。链接器必须准确知道设备的内存布局,以便能够将程序装入内存。它还负责将一个文件中的外部符号与另一个文件中的实际定义连接起来。这个过程称为链接符号

正是链接器知道事物的具体位置。在拥有数 GB 内存的大型系统中,这没什么大不了的,但在具有 16KB RAM 的微控制器上,了解每个字节的用途非常重要。

让我们来看一个典型问题,看看更好地理解链接器如何帮助解决问题。假设你在现场有一个系统发生崩溃。当它崩溃时,它会打印出一个堆栈跟踪,显示出导致问题的调用堆栈(见示例 11-1)。

**#0  0x0000000000001136 in ?? ()**
**#1  0x0000000000001150 in ?? ()**
#2  0x0000000000001165 in ?? ()
#3  0x000000000000117a in ?? ()
#4  0x00007ffff7de50b3 in __libc_main (main=0x555555555168) at ../csu/libc-start.c:308
#5  0x000000000000106e in ?? ()

示例 11-1:一个示例堆栈跟踪

这告诉你故障发生在地址为0x0000000000001136的函数中。

由于你没有使用绝对地址编写程序,因此函数的名称对你来说更有用。链接器映射就是在这种情况下发挥作用的。

示例 11-2 显示了该程序映射的一个片段。

.text          0x0000000000001129       0x58 /tmp/cctwz0VM.o
               **0x0000000000001129                three**
 **0x000000000000113e                two**
               0x0000000000001153                one
               0x0000000000001168                main

示例 11-2:来自示例 11-1 程序映射的片段

我们在示例 11-1 中的0x1136处中止了。在示例 11-2 中,函数three0x1129开始,一直到下一个函数0x113e。实际上,我们已经进入了函数three的 13 个字节,所以我们离函数的开始位置很近。

示例 11-1 显示了函数three是由位于地址0x1150的某个地方调用的。示例 11-2 显示函数two0x113e0x1153,因此它调用了three。通过类似的分析,我们可以看出two是由one调用的,而one又是由main调用的。

链接器的工作

链接器的工作是将组成程序的目标文件组合在一起,形成一个单一的程序文件。目标文件包含代码和数据,这些代码和数据按名称组织成不同的部分。(部分的实际名称依赖于编译器。高级程序员甚至可以自定义部分名称。)

目标文件中的部分没有固定地址。它们被称为可重定位的,这意味着它们几乎可以放置在任何地方,但链接器会将它们放置在内存中的特定位置。

ARM 芯片包含两种类型的内存:随机存取内存(RAM)和闪存。RAM 用于存储变量。这种类型内存的问题之一是,当电源关闭时,所有数据都会丢失。闪存,在实际应用中,类似于只读存储器。(如果你在 I/O 系统上非常聪明,当然也可以写入它。)闪存中的数据不会在系统断电时丢失。

链接器从所有对象文件中提取数据,并将其打包进 RAM 中。然后,它将剩余的 RAM 分配给堆栈和堆。代码和只读数据存放在闪存中。这个描述有些过于简化,但我们会在本章后面详细讨论这些细节。

链接器的最终任务是写出一个映射文件,告诉你它将每个部分放在哪里。为什么我们关心链接器把东西放在哪里?毕竟,最重要的事情是程序被加载到内存中。然而,在现场调试时,我们需要知道各个部分的位置。此外,有时我们可能需要定义特定的内存区域或为系统附加额外的内存芯片。

然后是一个重要原因:固件升级。据说硬件人员必须第一次就把硬件做对。软件人员唯一需要做对的事情就是固件升级。但如何使用正在运行的软件来替换正在运行的软件呢?更重要的是,如何做到这一点而不会把系统弄成“砖头”?(砖头是指固件升级失败后,系统变得和砖头一样毫无用处。)这涉及一些复杂的编程,我将在本章末尾解释。

编译和链接的内存模型

内存模型描述了系统中内存的配置方式。基本上,内存被划分为命名的区域。C 标准、对象文件和 ARM 芯片使用不同的名称来描述它们的内存。更糟糕的是,还可以通过 C 语言扩展来定义自定义名称。链接器必须知道如何处理这些自定义区域。

理想的 C 模型

理想情况下,C 程序中的所有内容都会放入标准部分之一:textdatabss

只读指令和只读数据存放在text部分。这里,main的代码和文本字符串(只读)都放在text部分:

int main() {
   doIt("this goes in text too");
   return();
}

已初始化的数据(已初始化的全局变量)存放在data部分:

int anExample = 5;

未初始化的数据(未初始化的全局变量)存放在bss部分:

int uninitialized;

从技术上讲,bss根据标准是未初始化的。然而,在我见过的每一个 C 编程系统的实现中,bss部分都会被初始化为零。

这些部分的数据在编译时分配。C 编译器会输出一个对象文件,表示:“我需要这么多text,这里是内容。我需要这么多data,这里是内容。我需要这么多bss,但是没有指定内容。”

size命令显示程序在每个部分使用了多少空间。

$ **size example.o**
   text   data    bss    dec    hex    filename
    481      4      4    489    1e9    example.o

对象文件使用了 481 字节的text,4 字节的data,以及另外 4 字节的bss。这三者总共占用 489 字节,或在十六进制下为 1e9。

理想的 C 模型还有两个其他内存区块。然而,这些区块并不是由编译器分配的,而是由链接器分配的。它们分别是 。栈用于局部变量,并在调用过程时动态分配。堆是一个可以动态分配和释放的内存池(有关堆的更多内容,请参见第十三章)。

编译器会将我们的变量定义分配到内存区块中。这些区块使用的命名空间与理想的 C 内存区块名称不同。在某些情况下,名称相似,而在其他情况下,名称完全不同。不同的编译器,甚至同一编译器的不同版本,可能会为这些区块使用不同的名称。

清单 11-3 显示了一个包含我们讨论的所有类型数据的程序。

/**
 * A program to demonstrate various types of variable
 * storage, so we can see what the linker does with them
 */
int uninitializedGlobal;   // An uninitialized global (section bss)
int initializedGlobal = 1; // An initialized global (section data)
int initializedToZero = 0; // An initialized global (section bss)

// aString -- initialized variable (section bss)
// "A string." -- constant (section text)
const char* aString = "A string."; // String (pointing to ready-only data)
static int uninitializedModule;    // An uninitialized module-only symbol
                                   // (section bss)
static int initializedModule = 2;  // An initialized module-only symbol
                                   // (section data)

int main()
{
    int uninitializedLocal;      // A local variable (section stack)
    int initializedLocal = 1234; // An initialized local (section stack)

 static int uninitializedStatic;      // "Uninitialized" static (section bss)
    static int initializedStatic = 5678; // Initialized static (section data)

    while (1)
        continue; // Not much logic here
}

清单 11-3:数据类型示例

让我们看看我们的 GNU GCC 编译器如何处理 清单 11-3 中的示例程序——具体来说,它是如何为不同类型的变量和数据分配内存的。

首先,这是来自 清单 11-3 的 initializedGlobal

int initializedGlobal = 1; // An initialized global (section data)

  16                            .global initializedGlobal
  17                            .data
  18                            .align  2
  21                    initializedGlobal:
  22 0000 01000000              .word   1

.global 指令告诉汇编器这是一个全局符号,其他目标文件可以引用它。.data 指令告诉汇编器接下来的内容应该放入 .data 区块。到目前为止,我们遵循了理想的 C 内存模型命名规范。

.align 指令告诉汇编器接下来的数据应当按 4 字节对齐。(地址的最后两位必须为零,因此使用 .align 2。)最后,有 initializedGlobal 标签和 .word 1 数据。

当一个变量被初始化为零(清单 11-3 中的 initializedToZero)时,我们会看到略有不同的代码:

int initializedToZero = 0; // An initialized global (section bss)

  23                            .global initializedToZero
  24                            .bss
  25                            .align  2
  28                    initializedToZero:
  29 0000 00000000              .space  4

在这里,编译器使用 .bss 指令将变量放入 bss 区块。它还使用 .space 指令而不是 .word,告诉汇编器该变量占用 4 字节空间,并将这些字节初始化为零。

现在让我们处理一个未初始化的全局变量(清单 11-3 中的 uninitializedGlobal):

int uninitializedGlobal; // An uninitialized global (section bss)

  15                            .comm   uninitializedGlobal,4,4

.comm 区块告诉汇编器定义一个 4 字节长且按 4 字节对齐的符号。该符号会被放入名为 COMMON 的内存区块中。在这种情况下,区块名称并不遵循理想的 C 内存模型命名规范。

在 清单 11-3 中定义 aString 的语句同时定义了一个字符串常量("A string.")。字符串常量是只读的,而指针(aString)是读写的。以下是生成的代码:

const char* aString = "A string."; // String (pointing to read-only data)

  30                            .global aString
  31                            .section        .rodata
  32                            .align  2
  33                    .LC0:
  34 0000 41207374              .ascii  "A string.\000"
  34      72696E67
  34      2E00
  35                            .data
  36                            .align  2
  39                    aString:
  40 0004 00000000              .word   .LC0

首先,编译器必须为 "A string." 生成常量。它为该常量生成一个内部名称(.LC0),并通过 .ascii 汇编指令生成该常量的内容。.section .rodata 指令将常量放入名为 .rodata 的链接器区块中。(理想的 C 内存模型将其称为 text。)

现在我们来看看变量本身的定义,aString.data指令将其放入data区域。由于它是一个指针,因此它被初始化为字符串的地址(即.LC0)。

最后的主要区域是包含代码的区域。理想的 C 语言内存模型将其称为text。以下是main函数开始的汇编代码:

int main()

  52                            .section        .text.main,"ax",%progbits
  53                            .align  1
  54                            .global main
  60                    main:
  67 0000 80B5                  push    {r7, lr}

这个区域的名称是text.main。在这种情况下,编译器决定将text前缀和模块名(main)组合成区域名。

我们已经覆盖了编译器知道的主要内存区域,接下来让我们看看由其他类型声明生成的代码。static关键字用于任何过程之外时,表示该变量只能在当前模块内使用。

下面是从清单 11-3 中创建initializedModule变量的代码:

static int initializedModule = 2; // An initialized module-only symbol
                                  // (section data)

  46                            .data
  47                            .align  2
  50                    initializedModule:
  51 0008 02000000              .word  2

它看起来与initializedGlobal非常相似,唯一的区别是缺少.global指令。

同样,来自清单 11-3 的uninitializedModule变量看起来与uninitializedGlobal非常相似,只不过我们再次缺少.global指令:

static int uninitializedModule; // An uninitialized module-only symbol
                                // (section bss)

  41                            .bss
  42                            .align  2
  43                    uninitializedModule:
  44 0004 00000000              .space  4

现在我们来讲解在过程内声明为static的变量。这些变量在编译时分配到主内存中,但它们的作用域仅限于它们定义的过程内。

让我们从清单 11-3 中的uninitializedStatic变量开始:

static int uninitializedStatic; // "Uninitialized" static (section bss)

  94                            .bss
  95                            .align  2
  96                    uninitializedStatic.4108:
  97 0008 00000000              .space  4

它看起来像任何未初始化的局部变量,只不过编译器将变量名从uninitializedStatic改为uninitializedStatic.4108。为什么?每个用大括号({})括起来的代码块可以有自己的uninitializedStatic变量。C 语言变量名的作用域局限于定义它的代码块。而汇编语言的作用域是整个文件,因此编译器通过在变量声明的末尾附加一个唯一的随机数来使得变量名唯一。

同样,initializedStatic变量看起来也与它的全局变量版本非常相似:

static int initializedStatic = 5678; // Initialized static (section data)

  88                            .data
  89                            .align  2
  92                    initializedStatic.4109:
  93 000c 2E160000              .word  5678

在这种情况下,.global缺失,并且通过添加后缀,变量名发生了变化。

非标准区域

我们已经讨论了 GNU 工具链生成的标准内存区域。STM32 芯片使用一个名为.isr_vector的自定义区域,它必须是写入闪存的第一个数据,因为 ARM 硬件使用这部分内存来处理中断和其他硬件相关功能。表 11-1,来自 STM32F030x4 手册,描述了中断向量。

表 11-1:中断向量文档(截断)

位置 优先级 优先级类型 缩写 描述 地址
保留 0x0000 0000
–3 固定 重置 重置 0x0000 0004
–2 固定 NMI 不可屏蔽中断。RCC 时钟安全系统(CSS)链接到 NMI 向量。 0x0000 0008
–1 固定 HardFault 所有类型的故障 0x0000 000C
3 可设置 SVCall 通过 SWI 指令调用系统服务 0x0000 002C
5 可设置 PendSV 可挂起的系统服务请求 0x0000 0038
6 可设置 SysTick 系统滴答定时器 0x0000 003C
0 7 可设置 WWDG 窗口看门狗中断 0x0000 0040
1 保留 0x0000 0044
2 9 可设置 RTC RTC 中断(组合的 EXTI 线路 17、19 和 20) 0x0000 0048

STM 固件文件 startup_stm32f030x8.s(汇编语言文件)包含定义此表的代码。以下是一个摘录:

131                       .section .isr_vector,"a",%progbits
134                    
135                    
136                    g_pfnVectors:
137 0000 00000000        .word  _estack
138 0004 00000000        .word  Reset_Handler
139 0008 00000000        .word  NMI_Handler
140 000c 00000000        .word  HardFault_Handler

第一行告诉链接器,该表将放在一个名为 .isr_vector 的段中。这个段是高度硬件特定的,定义非常精确,必须放在正确的位置,否则系统将无法正常工作。

该代码定义了一个名为 g_pfnVectors 的数组,其中包含以下内容:

  • 初始栈的地址

  • 复位处理程序的地址

  • 不可屏蔽中断(NMI)处理程序的地址

  • 其他中断向量,详见 表 11-1

我们将在下一节看到链接器如何处理这段代码。

链接过程

编译器和汇编器生成了一组目标文件,将代码和数据划分为以下几个部分:

  1. text. <name> 只读数据和代码

  2. rodata 只读数据

  3. data 已初始化数据

  4. bss 初始化为零的数据(与理想的 C 内存模型略有不同的定义)

  5. COMMON 未初始化数据

  6. .isr_vector 中断和复位处理程序,必须放在特定位置

链接器由名为 LinkerScript.ld 的脚本控制,该脚本是每个 STM32 工作台项目的一部分。脚本告诉链接器,系统的内存由两个部分组成:

  1. 闪存,从 0x8000000 开始,长度 64KB

  2. RAM,从 0x20000000 开始,长度 8KB

链接器的工作是将目标文件中的数据通过以下步骤打包到内存中:

  1. .isr_vector 段放置在闪存的开头。

  2. .text.* 段中的所有数据放入闪存中。

  3. .rodata 段放入闪存。

  4. .data 段放入 RAM 中,但 .data 段的初始化器需要放入闪存中(我们稍后会详细讨论)。

  5. .bss 段放入 RAM。

  6. 最后,将 COMMON 段加载到 RAM 中。

.data 段是比较复杂的部分。考虑以下声明:

int initializedGlobal = 1234;

链接器为 initializedGlobal 在 RAM 中分配空间。初始化器(1234)放入闪存中。在启动时,初始化器会作为一个块复制到 RAM 中,以初始化 .data 段。

链接器定义的符号

在链接过程中,链接器会定义一些重要的符号,包括以下内容:

  1. _sidata 闪存中 .data 段初始化器的起始位置

  2. _sdata .data 段在 RAM 中的起始位置

  3. _edata .data 段在 RAM 中的结束位置

  4. _sbss .bssCOMMON 段在 RAM 中的起始位置

  5. _ebss .bssCOMMON 段在 RAM 中的结束位置

  6. _estack RAM 的最后地址

在复位时,startup_stm32f030x8.S 中的代码会执行,并完成以下步骤:

  1. 使用 _estack 加载堆栈寄存器,堆栈将向下增长。

  2. 将从 _sdata_edata 之间的内存区域填充为从 _sidata 开始存储的初始化值。

  3. _sbss_ebss 之间的内存清零。

  4. 调用 SystemInit 函数来初始化 STM32 芯片。

  5. 调用 __libc_init_array 函数来初始化 C 库。

  6. 调用 main

  7. 永久循环。

重定位和链接目标文件

目标文件有两种类型:绝对可重定位。绝对文件将所有内容定义为固定(绝对)地址。换句话说,符号 main 位于 0x7B0,且不能由链接器或其他任何工具设置为其他地址。

可重定位目标文件设计为其数据的位置可以变动(重定位)。例如,main.c 源文件会生成 main.o 目标文件。如果查看汇编列表,我们会看到符号 main 被定义在 0000

52                  .section    .text.main,"ax",%progbits
`--snip--`
60                  main:
61                  .LFB0:
`--snip--`
67 0000 80B5            push    {r7, lr}

这个符号是相对于它所在的段(即 text.main)而言的。由于目标文件是可重定位的,text.main 可以位于内存中的任何地方。在这种情况下,链接器决定将其放置在闪存中的 0x00000000080007b0 位置。(我们通过链接器映射找到了这个值,接下来的章节会详细讨论。)由于 main 位于该段的开始位置,因此它被赋值为 0x00000000080007b0

作为链接器过程的一部分,链接器会将可重定位目标文件分配到内存中的位置。最终结果是一个程序文件,每个目标文件都有绝对地址。

链接器还会将目标文件链接在一起。例如,startup_stm32f030x8.S 文件会调用 main。问题在于,这段代码并不知道 main 位于哪里。它在另一个模块(main.o)中定义,因此在链接时,链接器会看到 startup_stm32f030x8.S 需要知道 main 符号的定义位置,并会执行从 startup_stm32f030x8.S 中调用 mainmain 的绝对地址(0x7B0)的链接操作。

库是以归档格式(类似 .zip,但不如其复杂)收集的目标文件(.o)。链接器脚本会告诉链接器包括 libc.alibm.alibgcc.a 库。例如,libm.a 库包含以下内容:

s_sin.o
s_tan.o
s_tanh.o
s_fpclassify.o
s_trunc.o
s_remquo.o
`--snip--`

在处理库时,链接器只会加载定义了你的程序所需符号的目标文件。例如,如果你的程序使用了 sin 函数,它将链接包含该函数定义的目标文件 s_sin.o。如果你没有使用 sin 函数,则链接器知道你不需要 s_sin.o 中的代码,因此不会将该文件链接进来。

链接器映射

当链接器将数据加载到程序中时,它会生成一个映射文件(Debug/output.map),其中包含有关我们代码和数据位置的信息。此映射文件非常完整,包含许多有用信息以及我们不关心的许多内容。例如,它告诉我们我们的内存配置是什么样的,显示处理器的各种类型和位置:

Memory Configuration

Name             Origin             Length             Attributes
FLASH            0x0000000008000000 0x0000000000010000 xr
RAM              0x0000000020000000 0x0000000000002000 xrw
*default*        0x0000000000000000 0xffffffffffffffff

在这种情况下,我们的芯片具有FLASH存储器,其具有设置了读取(r)和执行(x)属性。它从0x8000000开始,延伸至0x10000字节。RAM部分从0x20000000开始,仅延伸至0x2000字节。它可读(r)、可写(w)和可执行(x)。

如前所述,.isr_vector部分首先加载。链接器映射告诉我们它的位置:

.isr_vector     0x0000000008000000       0xc0

地址0x8000000是 Flash 的起始地址。硬件期望中断向量位于该地址,这是一个好消息。另一点信息是,此部分长度为0xc0字节。

main符号定义在src/main.o中。它是.text.main段的一部分,并位于0x0000000008000138处:

.text.main     0x0000000008000138       0x60 src/main.o
                0x0000000008000138                main

它还包含一些代码(0x60字节,考虑到清单 11-3 只是一个无用的程序)。

我们还可以看到全局变量的位置。例如,这是uninitializedGlobal的位置:

 COMMON         0x0000000020000464        0x4 src/main.o
                0x0000000020000464        uninitializedGlobal

链接器映射提供了此程序中每个变量和函数的绝对地址。这有什么用呢?当我们在现场调试时(没有 JTAG 调试器),我们经常只有绝对地址,因此如果您的程序遇到致命错误并看到

FATAL ERROR: Address  0x0000000008000158

在调试控制台上,您会知道错误发生在main0x20字节处。

我们一直在使用一个外部调试器与我们的 STM 板。该系统由运行调试器的主机计算机、一个 JTAG 调试探针和一个目标机组成。主机计算机上的调试器可以访问源代码和符号表(来自链接器)。当它检测到0x8000158处的错误时,它可以查看符号表,查看错误发生在程序的0x20字节处,找出错误发生的行,并在源文件中显示一个大红箭头指向错误发生的位置。

有些系统有内部调试器,在其中调试器和所有需要的文件都在目标系统上。一些内部调试器提供基于绝对地址转储内存的能力。这些调试器虽然小而愚笨,但在现场调试时非常有用。

假设您有这样一个调试器,并且需要知道uninitializedGlobal的值。愚笨的调试器不知道符号名称。它只基于地址转储内存,就这样。

另一方面,您确实知道符号名称。您拥有链接器映射,因此可以告诉调试器在0x20000464位置显示 4 字节值:

D> x/4 20000464
0x20000464:   1234    0x4D2

这种调试方式原始且困难,但在嵌入式系统中,有时它是唯一可以进行调试的方法。

也许你会想知道为什么我们不直接告诉调试器 uninitializedGlobal 的位置,这样会更简单。问题在于符号表占用了大量空间,而我们空间有限。而且,符号表存放在系统本身上是一个安全隐患。(黑客会很喜欢知道 passwordCheckingFunction 的地址!)

高级链接器使用

到目前为止,我们仅使用了默认设置的链接器。然而,在某些时候,你可能会想执行一些比默认设置更高级的功能。

闪存用于“永久”存储

默认的 C 内存模型存在一个问题,就是程序启动时所有数据都会被重置。在 STM32 中,这意味着重置设备会导致其丢失所有数据。假设你希望在重启之间保留一些配置信息。默认的设置无法实现这一点。我们该怎么做呢?

让我们从第九章的串口 “Hello World” 程序开始。我们将添加一个计数器,记录系统已经启动了多少次,然后将重置计数信息通过串口设备发送出去。

我们的设计很简单。我们将使用闪存的顶部 4KB 来存储配置信息。我们给它取了个富有创意的名字 CONFIG,并定义了一个新的内存区域 .config,在其中存放我们的重置变量。

这是完成这一操作的 C 代码:

static uint32_t resetCount __attribute__((section(.config)) = 0;

现在我们需要修改链接脚本,以处理我们的新区域。我们首先将闪存内存分为两个区域。第一个是我们之前讨论过的传统闪存内存。第二个,CONFIG,将存储我们的配置信息,这意味着我们需要编辑 LinkerScript.ld 并替换以下内容:

MEMORY
{
    FLASH (rx)     : ORIGIN = 0x8000000, LENGTH = 64K
    RAM (xrw)      : ORIGIN = 0x20000000, LENGTH = 8K
}

用这个:

MEMORY
{
    FLASH (rx)     : ORIGIN = 0x8000000,       LENGTH = 60K
    CONFIG (rw)    : ORIGIN = 0x8000000 + 60K, LENGTH = 4K
    RAM (xrw)      : ORIGIN = 0x20000000,      LENGTH = 8K
}

这将 FLASH 的大小减少 4KB,然后将这 4KB 用作名为 CONFIG 的内存区域。

闪存与普通内存不同,它只能在写入一次之后才能擦除。擦除必须一次进行一页。在 STM32 的情况下,这意味着我们的 CONFIG 区域必须至少有 1KB 长,并且必须是 1KB 的倍数。我们选择了 4KB,因为我们可能希望以后存储更多的配置信息。

现在我们需要告诉链接器将 .config 区域放入名为 CONFIG 的内存块中。这可以通过在 LinkerScript.ld 文件的 SECTIONS 部分添加以下内容来完成:

{
   . = ALIGN(4);
   *(.config*)
} >CONFIG

更改这个变量并不像简单地写下以下代码那样容易:

++resetCount;

编程芯片需要一系列的步骤。我们把所有步骤都放在了一个名为 updateCounter 的函数中,见 列表 11-4。

/**
 * Update the resetCounter.
 *
 * In C this would be ++resetCount. Because we are dealing
 * with flash, this is a much more difficult operation.
 */
static HAL_StatusTypeDef updateCounter(void) {
  1 HAL_FLASH_Unlock(); // Allow flash to be modified.
 2 uint32_t newResetCount = resetCount + 1;  // Next value for reset count

    uint32_t pageError = 0;     // Error indication from the erase operation

    // Tell the flash system to erase resetCounter (and the rest of the page).
  3 FLASH_EraseInitTypeDef eraseInfo = {  
        .TypeErase = FLASH_TYPEERASE_PAGES,     // Going to erase one page
        .PageAddress = (uint32_t)&resetCount,   // The start of the page
        .NbPages = 1                            // One page to erase
    };

    // Erase the page and get the result.
  4 HAL_StatusTypeDef result = HAL_FLASHEx_Erase(&eraseInfo, &pageError);
    if (result != HAL_OK) {
        HAL_FLASH_Lock();
        return (result);
    }

    // Program the new reset counter into flash.
    result = 5 HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD,
            (uint32_t)&resetCount, newResetCount);

    HAL_FLASH_Lock();
    return (result);
}

列表 11-4:updateCounter 程序

STM32 芯片上的闪存是受保护的,因此我们需要通过调用 HAL_FLASH_Unlock 1 来解锁它。此函数将两个密码值写入闪存保护系统,以启用闪存的写入操作。然而,我们仍然不能直接将 resetCount 写入闪存,因此我们将 resetCount(一个闪存值)赋值给 newResetCount(一个常规变量)2,这样我们就可以对其进行递增。

在写入闪存之前,我们必须先擦除闪存,而擦除的最小单位是一个页面。我们首先需要初始化一个结构 3,指定要擦除的页面数量及地址,然后将其作为参数传递给 HAL_FLASHEx_Erase 来擦除内存 4。

现在,存储 resetCount 的内存已被清除,我们可以进行写入。不幸的是,我们有一个 32 位的值,而闪存一次只能写入 16 位,因此我们使用另一个 HAL 函数 HAL_FLASH_Program 5 来完成这项任务。

列表 11-5 显示了完整的程序。

/**
 * @brief Write the number of times the system reset to the serial device.
 */
#include <stdbool.h>
#include "stm32f0xx_nucleo.h"
#include "stm32f0xx.h"

const char message1[] = "This system has been reset ";   // Part 1 of message
const char message2[] = " times\r\n";                    // Part 2 of message
const char many[] = "many";         // The word many
// Number of times reset has been performed
uint32_t resetCount __attribute__((section(".config.keep"))) = 0;
int current; // The character in the message we are sending

UART_HandleTypeDef uartHandle;      // UART initialization

/**
  * @brief This function is executed in case of error occurrence.
  *
  * All it does is blink the LED.
  */
void Error_Handler(void)
{
    /* Turn ED3 on. */
    HAL_GPIO_WritePin(LED2_GPIO_PORT, LED2_PIN, GPIO_PIN_SET);

    while (true)
    {
    // Toggle the state of LED2.
        HAL_GPIO_TogglePin(LED2_GPIO_PORT, LED2_PIN);
        HAL_Delay(1000);        // Wait one second.
    }
}
/**
 * Send character to the UART.
 *
 * @param ch The character to send
 */
void myPutchar(const char ch)
{
    // This line gets and saves the value of UART_FLAG_TXE at call
    // time. This value changes, so if you stop the program on the "if"
    // line below, the value will be set to zero because it goes away
    // faster than you can look at it.
    int result __attribute__((unused)) =
        (uartHandle.Instance->ISR & UART_FLAG_TXE);

    // Block until the transmit empty (TXE) flag is set.
    while ((uartHandle.Instance->ISR & UART_FLAG_TXE) == 0)
        continue;

    uartHandle.Instance->TDR = ch;     // Send character to the UART.
}

/**
 * Send string to the UART.
 *
 * @param msg Message to send
 */
static void myPuts(const char* const msg)
{
    for (unsigned int i = 0; msg[i] != '\0'; ++i) {
        myPutchar(msg[i]);
    }
}

/**
 * Initialize LED2 (so we can blink red for error).
 */
void led2_Init(void)
{
    // LED clock initialization
    LED2_GPIO_CLK_ENABLE();

    GPIO_InitTypeDef GPIO_LedInit;      // Initialization for the LED
    // Initialize LED.
    GPIO_LedInit.Pin = LED2_PIN;
    GPIO_LedInit.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_LedInit.Pull = GPIO_PULLUP;
    GPIO_LedInit.Speed = GPIO_SPEED_FREQ_HIGH;
    HAL_GPIO_Init(LED2_GPIO_PORT, &GPIO_LedInit);
}

/**
 * Initialize UART2 for output.
 */
void uart2_Init(void)
{
    // UART initialization
    // UART2 -- one connected to ST-LINK USB
    uartHandle.Instance = USART2;
    uartHandle.Init.BaudRate = 9600;                    // Speed 9600
    uartHandle.Init.WordLength = UART_WORDLENGTH_8B;    // 8 bits/character
    uartHandle.Init.StopBits = UART_STOPBITS_1;         // One stop bit
    uartHandle.Init.Parity = UART_PARITY_NONE;          // No parity
    uartHandle.Init.Mode = UART_MODE_TX_RX;             // Transmit & receive
    uartHandle.Init.HwFlowCtl = UART_HWCONTROL_NONE;    // No hw control

    // Oversample the incoming stream.
    uartHandle.Init.OverSampling = UART_OVERSAMPLING_16;

    // Do not use one-bit sampling.
    uartHandle.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_DISABLE;

    // Nothing advanced
    uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_NO_INIT;
    /*
     * For those of you connecting a terminal emulator, the above parameters
     * translate to 9600,8,N,1.
     */

    if (HAL_UART_Init(&uartHandle) != HAL_OK)
    {
        Error_Handler();
    }
}
/**
 * Update the resetCounter.
 *
 * In C, this would be ++resetCounter. Because we are dealing
 * with flash, this is a much more difficult operation.
 */
static HAL_StatusTypeDef updateCounter(void) {
    HAL_FLASH_Unlock(); // Allow flash to be modified.
    uint32_t newResetCount = resetCount + 1;    // Next value for reset count

    uint32_t pageError = 0;     // Error indication from the erase operation
    // Tell the flash system to erase resetCounter (and the rest of the page).
    FLASH_EraseInitTypeDef eraseInfo = {
        .TypeErase = FLASH_TYPEERASE_PAGES,     // Going to erase 1 page
        .PageAddress = (uint32_t)&resetCount,   // The start of the page
        .NbPages = 1                            // One page to erase
    };

    // Erase the page and get the result.
    HAL_StatusTypeDef result = HAL_FLASHEx_Erase(&eraseInfo, &pageError);
    if (result != HAL_OK) {
        HAL_FLASH_Lock();
        return (result);
    }

    // Program the new reset counter into flash.
    result = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD,
            (uint32_t)&resetCount, newResetCount);

    HAL_FLASH_Lock();
    return (result);
}

int main(void)
{
    HAL_Init(); // Initialize hardware.
    led2_Init();
    uart2_Init();

    myPuts(message1);

    HAL_StatusTypeDef status = updateCounter();

    switch (status) {
        case HAL_FLASH_ERROR_NONE:
            // Nothing, this is correct.
            break;
        case HAL_FLASH_ERROR_PROG:
            myPuts("HAL_FLASH_ERROR_PROG");
            break;
        case HAL_FLASH_ERROR_WRP:
            myPuts("HAL_FLASH_ERROR_WRP");
            break;
        default:
            myPuts("**unknown error code**");
            break;
    }
    // A copout to avoid writing an integer to an ASCII function
 if (resetCount < 10)
        myPutchar('0'+ resetCount);
    else
        myPuts("many");

    myPuts(message2);

    for (;;) {
        continue;       // Do nothing.
    }
}

/**
 * Magic function that's called by the HAL layer to actually
 * initialize the UART. In this case, we need to put the UART pins in
 * alternate mode so they act as UART pins and not like GPIO pins.
 *
 * @note: Only works for UART2, the one connected to the USB serial
 * converter
 *
 * @param uart The UART information
 */
void HAL_UART_MspInit(UART_HandleTypeDef* uart)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    if(uart->Instance == USART2)
    {
        /* Peripheral clock enable */
        __HAL_RCC_USART2_CLK_ENABLE();

        /*
         * USART2 GPIO Configuration
         * PA2     ------> USART2_TX
         * PA3     ------> USART2_RX
         */
        GPIO_InitStruct.Pin = GPIO_PIN_2|GPIO_PIN_3;
        GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
        GPIO_InitStruct.Pull = GPIO_NOPULL;
        GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
        // Alternate function -- that of UART
        GPIO_InitStruct.Alternate = GPIO_AF1_USART2;
        HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    }

}

/**
 * Magic function called by HAL layer to de-initialize the
 * UART hardware. Something we never do, but we put this
 * in here for the sake of completeness.
 *
 * @note: Only works for UART2, the one connected to the USB serial
 * converter
 *
 * @param uart The UART information
 */
void HAL_UART_MspDeInit(UART_HandleTypeDef* uart)
{
    if(uart->Instance == USART2)
    {
        /* Peripheral clock disable */
        __HAL_RCC_USART2_CLK_DISABLE();

        /*
         * USART2 GPIO Configuration
         * PA2     ------> USART2_TX
         * PA3     ------> USART2_RX
         */
        HAL_GPIO_DeInit(GPIOA, GPIO_PIN_2|GPIO_PIN_3);
    }
}

列表 11-5:重置计数程序

多个配置项

假设我们想在闪存中保持多个配置变量。问题在于,闪存并不是普通内存。当你将一个值存储到闪存变量中后,除非擦除包含该变量的整个内存页面,否则无法更改该值。

当每页存储一个变量时(这种做法非常浪费),这能正常工作,但如果我们想在内存中存储多个配置变量并更新其中一个怎么办呢?这需要一点工作。下面是处理过程:

  1. 将所有配置变量保存在 RAM 中。

  2. 在 RAM 中更新你需要更改的值。

  3. 擦除闪存中的所有配置变量。(擦除闪存页面。)

  4. 将 RAM 版本复制回闪存。

列表 11-6 显示了在 .config 部分声明配置结构并更新 struct 中值的代码框架。

struct config {
    char name[16];    // Name of the unit
    uint16_t sensors[10]; // The type of sensor connected to each input
    uint32_t reportTime;  // Seconds between reports
    // ... Lots of other stuff
};
struct config theConfig __attribute__((section ".config")); // The configuration

static void updateReportTime(const uint32_t newReportTime) {

    // <Prepare flash>

    struct config currentConfig = config;
    currentConfig.reportTime = newReportTime;

 // <Erase flash>
    writeFlash(&config, &currentConfig, sizeof(currentConfig));

    // <Lock flash>
}

列表 11-6:更新闪存中的配置

闪存有许多问题。如前所述,第一个问题是必须擦除整个页面才能写入一个字。这需要时间来写入页面到闪存,并且在写入过程中系统可能会断电或重启。如果发生这种情况,写入将不完整,且你的配置数据将会损坏。

解决此问题的方法是拥有两个配置区,一个主配置区和一个备份配置区,每个配置区都包含一个校验和。程序首先尝试读取主配置,如果校验和不正确,则读取第二个配置。因为每次只写入一个配置,所以你可以相当确定主配置或备份配置中的一个是正确的。

另一个关于闪存的问题是,它存在内存磨损。你只能进行有限次数的编程/擦除周期,之后内存会变得损坏。根据使用的闪存类型,这个周期可以在 100,000 到 1,000,000 次之间。所以,使用闪存存储一个预期每月更改一次的配置是可以的。但如果用它来存储每秒更改几次的内容,闪存很快就会磨损。

也有一些方法可以绕过闪存的限制进行编程。你还可以为你的系统添加外部内存芯片,这些芯片没有闪存的设计限制。

现场定制示例

假设我们在一家制造报警器的公司工作。这些报警器会发送给报警服务公司,由它们在最终用户的现场进行安装。现在,如果 Joe 的报警公司和钓具店安装的报警面板在启动时显示的是 Acme 报警制造商的标志,他会不高兴的。Joe 注重品牌,他希望显示自己的标志,这意味着我们需要给客户提供一种定制标志的方法。我们可以为标志预留一块内存空间:

MEMORY
{
    FLASH (rx)     : ORIGIN = 0x8000000,       LENGTH = 52K
    LOGO (r)       : ORIGIN = 0x8000000 + 52K, LENGTH = 8K
    CONFIG (rw)    : ORIGIN = 0x8000000 + 60K, LENGTH = 4K
    RAM (xrw)      : ORIGIN = 0x20000000,      LENGTH = 8K
}

现在问题是,我们如何将标志导入系统?我们可以在工厂进行编程,但这意味着每次我们发货时,都需要有人打开盒子,插入设备,编程标志,然后再放回盒子里,这是一项昂贵的操作。

但是,我们可以让客户自己做这件事。我们可以给他们一根电缆和一些软件,让他们自己编程设置标志。我们可以将这个功能作为一项特性来出售,允许客户在需要时更新设备的标志。

编程可以通过我们用来将代码加载到闪存的相同硬件和软件来完成,或者我们可以编写一个板载程序,从串行线获取数据并将其编程到LOGO内存中。

更换标志是一个简单的定制操作。而且,如果更换过程出错,坏的标志不会影响系统的正常运行。然而,更换固件则是另外一回事。

固件升级

在运行软件的同时升级软件是有点棘手的,但有几种方法可以做到这一点。最简单的方法之一是将闪存划分为三个部分:

  1. 引导加载程序

  2. 程序部分 1

  3. 程序部分 2

引导加载程序是一个非常小的程序,永远不会被升级。它的工作相对简单,希望我们能第一次就做对。程序部分包含程序的完整版本,它们还包含程序版本号和校验和。

引导加载程序的任务是决定应该使用哪个程序部分。它会验证两个部分的校验和,然后根据以下计算决定使用哪个部分:

if ((bad checksum1) and (good checksum2)) use section2
if ((good checksum1) and (bad checksum2)) use section1
if (both good) use the section with the highest version number
if (both bad) blink the emergency light; we're bricked

这是总体思路,但我们跳过了一些记录步骤。例如,.isr_vector部分中的中断表需要进行修改,以确保所有的中断都能正确地指向相应的位置。

总结

内存是有限资源,尤其是在进行嵌入式编程时。你需要确切知道你的内存位置以及如何最大限度地利用它。

链接器的工作是将你的程序的各个部分连接起来,生成一个可以加载到内存中的程序。对于简单的程序,默认配置效果很好。然而,随着你深入更高级的系统,你将需要更精确地控制有限内存资源的使用,因此理解链接器对于成为一名有效的嵌入式程序员至关重要。

编程问题

  1. 修改配置程序(Listing 11-6),使得CONFIG段不再从页面边界开始。会发生什么?

  2. 修改配置程序,使其打印一个完整的数字,而不是打印一个单一的重置数字。

  3. 链接器脚本定义了多个符号,用于指示内存区域的起始和结束。检查链接器脚本或链接器映射,以找到定义文本区域起始和结束的符号。使用这些符号,打印文本区域的大小。使用arm-none-eabi-size命令验证你的结果。

  4. 使用相同的技术打印分配的栈空间量。

  5. 高级:打印剩余的栈空间。这需要使用asm关键字将栈寄存器的当前值读取到变量中。

  6. 了解二进制文件中的内容非常有用,GNU 工具链提供了多个程序来实现这一点。检查以下命令的文档:

    1. objdump,用于转储目标文件信息

    2. nm,用于列出文件中的符号

    3. ar,用于创建库或从中提取信息和文件

    4. readelf,用于显示 elf(程序)文件的信息

第十二章:预处理器

基本的 C 编译器具有许多强大的功能,但有些事情它就是做不到。为了克服这些限制,语言中增加了一个预处理器。预处理器主要是一个宏处理器,它是一个用其他文本替换文本的程序,但它也可以根据某些条件包含或排除文本并执行其他操作。这个概念是让一个程序(预处理器)完成一个小而简单的文本编辑任务,然后将其输入到真正的编译器中。由于这两个步骤(以及其他几个步骤)是隐藏在gcc命令后面的,你几乎不会去考虑它们,但它们的确存在。

例如,让我们看看以下代码:

#define SIZE 20    // Size of the array
int array[SIZE];   // The array
`--snip--`
    for (unsigned int i = 0; i < SIZE; ++i) {

SIZE被定义为20时,预处理器实际上会对SIZE进行全局搜索并替换为20

我们与 STM 微处理器一起使用的 HAL 库在几个方面广泛使用了预处理器。首先,头文件包含每个可读取和可设置的处理器位的#define,而且这些位相当多。其次,STMicroelectronics 并不只生产一种芯片;它生产各种各样的芯片。与其拥有 20 个不同的头文件来包含 20 个芯片的信息,不如使用一种叫做条件编译的过程,只编译需要的头文件部分。

简单宏

让我们从简单的宏开始。一个基本上是一个模式(在此例中是SIZE),它被替换成其他内容(在此例中是20)。#define预处理指令用来定义这个模式和替换内容:

size.c

#define SIZE 20
The size is SIZE

这不是一个 C 程序。预处理器可以处理任何内容,包括纯英文文本。让我们使用-E标志将其传递给预处理器,这个标志告诉gcc仅通过预处理器处理程序并停止:

$ **gcc -E size.c**

以下是预处理后的结果:

# 1 "size.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
1 # 1 "size.c"

2 The size is 20

以井号(#)开头的行叫做行标记。它们由一个井号、行号和文件名(以及一些其他信息)组成。由于预处理器可能会添加或删除行,没有它们,编译器无法知道它在原始输入文件中的位置。

很多事情发生在第一行处理之前,但最终我们会到达第二次出现的位置 1,并且输出 2 显示SIZE已经被替换为定义的值。

预处理器按字面意思处理事物,这可能会让你陷入麻烦,正如这里所示:

square.c

#include <stdio.h>

1 #define SIDE 10 + 2   // Size + margin

int main()
{
  2 printf("Area %d\n", SIDE * SIDE);
    return (0);
} 

这个例子计算一个正方形的面积。它包含了一些边距,因此正方形的边长定义为 1。为了计算面积,我们将边长相乘并打印结果 2。然而,这个程序包含一个 bug:SIZE不是12,而是10 + 2。预处理器只是一个简单的文本编辑器,它不理解 C 语法或算术。

通过将程序传递给预处理器,我们可以看到我们在哪里犯了错误:

square.i

# 5 "square.c"
int main()
{
    printf("Area %d\n", 10 + 2 * 10 + 2);
    return (0);
}

如前所述,预处理器并不理解 C 语言。当我们使用以下语句时,它会将SIZE定义为字面意义上的10 + 2,而不是12

#define SIDE 10 + 2   // Size + margin

正如你所看到的,12 * 1210 + 2 * 10 + 2是不同的数字。

当使用#define来定义比简单数字更复杂的常量时,我们将整个表达式用括号括起来,如下所示:

#define SIDE (10 + 2)   // Size + margin

遵循这种风格规则可以避免在替换后因操作顺序不确定而导致的错误结果。

为了避免在#define的目的是在一个地方设置或计算一个值并在程序中使用时出现宏计算错误,建议使用const,在可能的情况下,const应优于#define。这里是一个例子:

const unsigned int SIDE = 10 + 2;       // This works.

这个规则的主要原因是const修饰符是 C 语言的一部分,编译器会计算分配给const变量的表达式,所以SIDE实际上是12

当 C 语言最初设计时,并没有const修饰符,因此每个人都必须使用#define语句,这也是为什么即使const已经使用了一段时间,#define依然如此广泛使用的原因。

带参数的宏

带参数的 允许我们为宏提供参数。这里是一个例子:

#define DOUBLE(x) (2 * (x))
`--snip--`
    printf("Twice %d is %d\n", 32, DOUBLE(32);

在这种情况下,我们不需要在展开时将括号括起来。我们可以按如下方式编写宏:

#define DOUBLE_BAD(x) (2 * x)

为什么这样不好呢?想想看当我们使用这个宏与一个表达式时会发生什么:

 value = DOUBLE_BAD(1 + 2)

风格规则是为带参数的宏的参数加上括号。如果没有括号,DOUBLE(1+2)将展开成如下内容:

DOUBLE(1+2) = (2 * 1 + 2) = 4   // Wrong

使用括号后,我们得到如下结果:

DOUBLE(1+2) = (2 * (1 + 2)) = 6

我们已经有一个规则,规定除了独立的一行外不要使用++--。让我们看看当我们违反这个规则,使用带参数的宏时会发生什么:

#define CUBE(x) ((x) * (x) * (x))

    int x = 5;

    int y = CUBE(x++);

执行完这段代码后,x的值是多少?是8而不是预期的6。更糟的是,y的值可以是任何值,因为在 C 语言中,当混合使用乘法(*)和递增(++)操作时,执行顺序规则是不明确的。

如果你要编写这样的代码,考虑使用inline函数,它会用函数体替代函数调用:

static inline int CUBE_INLINE(const int x) {
    return (x * x * x);
}

即使使用以下语句,它也能正常工作:

y = CUBE_INLINE(x++);

但是,再次强调,你不应该编写这样的代码。相反,应该像这样编写代码:

x++;
y = CUBE_INLINE(x);

尽可能使用inline函数代替带参数的宏。因为inline函数是 C 语言的一部分,编译器可以确保它们被正确使用(与预处理器不同,预处理器只是盲目地替换文本)。

代码宏

到目前为止,我们一直在编写宏来定义常量和简单表达式。我们也可以使用#define来定义代码。这里是一个例子:

#define FOR_EACH_VALUE for (unsigned int i = 0; i < VALUE_SIZE; ++i)
`--snip--`
    int sum = 0;
    FOR_EACH_VALUE
        sum += value[i]

然而,这段代码存在一些问题。首先,变量i的来源不明确。我们还隐去了递增它的部分,这也是这种宏很少见的原因。

一个更常见的宏是模拟短函数的宏。让我们定义一个叫DIE的宏,它输出一条消息,然后终止程序:

// Defined badly
#define DIE(why)              \
    printf("Die: %s\n", why); \
    exit(99);

我们使用反斜杠(\)来将宏扩展到多行。我们可以像这样使用这个宏:

void functionYetToBeImplemented(void) {
    DIE("Function has not been written yet");
}

在这种情况下,它有效,这更多是运气而非设计的结果。问题是DIE看起来像一个函数,因此我们可以将其视为函数。让我们把它放入if语句中:

// Problem code
if (index < 0)
    DIE("Illegal index");

为了理解为什么这是个问题,让我们看看这段代码的展开结果:

if (index < 0)
   printf("Die %s\n", "Illegal index");
   exit(99); 

这是正确缩进后的代码:

if (index < 0)
    printf("Die %s\n", "Illegal index");
exit(99); 

换句话说,它总是会退出,即使索引是正确的。

让我们看看是否可以通过在语句周围加上花括号({})来解决这个问题:

// Defined not as badly
#define DIE(why) {            \
    printf("Die: %s\n", why); \
    exit(99);                 \
}

现在在以下情况下它能正常工作:

// Problem code
if (index < 0)
    DIE("Illegal index");

然而,在这种情况下它不起作用:

if (index < 0)
    DIE("Illegal index");
else
    printf("Did not die\n");

这段代码会产生一个错误信息:else without previous if。然而,我们这里确实有一个if。让我们看看展开后的结果:

if (index < 0)
{
    printf("Die: %s\n", why); \
    exit(99);                 \
};                 // <=== Notice two characters here.
else
    print("Did not die\n");

这里的问题是,在else之前,C 语言要求一个以分号(;)结尾的语句,或者 一组被花括号({})包围的语句。它不知道如何处理一组以分号结尾、且被花括号包围的语句。

解决这个问题的方法是使用一个叫做do/while的 C 语言语句。它的样子是这样的:

do {
   // Statements
}
while (`condition`);

do后面的语句总是执行一次,然后只要condition为真,就会继续执行。虽然它是 C 语言标准的一部分,但我只在实际应用中见过两次,而且其中一次还是作为笑话的结尾。

然而,它用于代码宏的场景:

#define DIE(why)
do {            \
    printf("Die: %s\n", why); \
    exit(99);                 \
} while (0)

它能正常工作,因为我们可以在后面加一个分号:

if (index < 0)
    DIE("Illegal index");   // Note semicolon at the end of the statement.
else
    printf("Did not die\n");

这段代码展开后的结果是:

if (index < 0)
    do {
        printf("Die: %s\n", "Illegal index");
        exit(99);
    } while (0);
else
    printf("Did not die\n");

从语法上讲,do/while是一个单一语句,我们可以在它后面加一个分号而不会有问题。花括号(printfexit)中的代码被安全地封装在do/while内部。花括号外的代码是一条语句,这正是我们想要的。现在编译器会接受这个代码宏。

条件编译

条件编译使我们能够在编译时改变代码内容。这个功能的经典用途是拥有一个调试版本和一个生产版本的程序。

#ifdef/#endif指令对会在定义了某个符号的情况下编译两个指令之间的代码。这里是一个例子:

int main()
{
#ifdef DEBUG
    printf("Debug version\n");
#endif // DEBUG

严格来说,// DEBUG注释并不是必需的,但请确保包含它,因为匹配#ifdef/#endif对非常困难。

如果你的程序看起来像这样:

#define DEBUG   // Debug version

int main()
{
#ifdef DEBUG
    printf("Debug version\n");
#endif // DEBUG

那么,预处理后的结果将是如下:

int main()
{
    printf("Debug version\n");

另一方面,如果你的程序看起来像这样:

//#define DEBUG         // Release version

int main()
{
#ifdef DEBUG
    printf("Debug version\n");
#endif // DEBUG

那么,预处理后的结果将是如下:

int main()
{
    // Nothing

由于DEBUG没有定义,代码没有生成。

一个问题是,所有的#ifdef语句会使得程序看起来很杂乱。考虑下面的代码:

int main()
{
#ifdef DEBUG
    printf("Debug version\n");
#endif // DEBUG

#ifdef DEBUG
    printf("Starting main loop\n");
#endif // DEBUG

    while (1) {
#ifdef DEBUG
        printf("Before process file \n");
#endif // DEBUG
        processFile();
#ifdef DEBUG
        printf("After process file \n");
#endif // DEBUG

我们可以用更少的代码做同样的事情:

#ifdef DEBUG
#define debug(msg) printf(msg)
#else // DEBUG
#define debug(msg) /* nothing */
#endif // DEBUG

int main()
{
    debug("Debug version\n");
    debug("Starting main loop\n");

    while (1) {
        debug("Before process file \n");
        processFile();
        debug("After process file \n");

注意,我们使用了#else指令来告诉预处理器反转#if的判断逻辑。如果定义了DEBUG,则调用debug会被替换为调用printf;否则,它们将被替换为空白空间。在这种情况下,我们不需要do/while技巧,因为代码宏包含的是一个单独的函数调用(没有分号)。

另一个指令#ifndef在符号未定义时为真,其他情况下与#ifdef指令的用法相同。

符号的定义位置

我们可以通过三种方式定义符号:

  1. 在程序内部通过#define

  2. 从命令行

  3. 预定义在预处理器内部

我们已经描述了在程序内部定义的符号,接下来我们来看看另外两种选项。

命令行符号

要在命令行中定义符号,请使用-D选项:

**$ gcc -Wall -Wextra -DDEBUG -o prog prog.c**

-DDEBUG参数定义了DEBUG符号,以便预处理器可以使用它。在这个例子中,它会在程序开始之前执行#define DEBUG 1。我们在前面的代码中使用了这个符号来控制是否编译debug语句。

除了符号之外,我们还需要手动添加到编译命令中,STM32 工作台会生成一个 makefile 来编译一个在命令行上定义了多个符号的程序。最重要的是通过-DSTM32F030x8选项定义的。CMSIS/device/stm32f0xx.h文件使用STM32F030x8符号来包含特定于板卡的文件:

#if defined(STM32F030x6)
  #include "stm32f030x6.h"
#elif defined(STM32F030x8)
  #include "stm32f030x8.h"
#elif defined(STM32F031x6)
  #include "stm32f031x6.h"
#elif defined(STM32F038xx)

STM 固件支持多种板卡,其中之一是 NUCLEO-F030R8。每个芯片的 I/O 设备位置不同。你不需要担心它们的位置,因为固件会使用前面的代码找到正确的位置。此文件的意思是:“如果我是一块 STM32F030x6 板卡,包含该板卡的头文件;如果我是一块 STM32F030x8 板卡,包含该板卡的头文件”,以此类推。

使用的指令是#if#elif#if用于测试后面的表达式是否为真(在这种情况下,测试STM32F030x6是否已定义)。如果为真,紧随其后的代码将被编译。#elif#else#if的组合,表示如果表达式不为真,则测试另一个表达式。另一个指令defined在符号已定义时为真。

预定义符号

最后,预处理器本身定义了多个符号,如__VERSION__(指定编译器版本)和__linux(在 Linux 系统中)。要查看系统中预定义的符号,可以使用以下命令:

$ **gcc -dM -E - < /dev/null**

__cplusplus符号仅在你编译 C++程序时定义。通常,你会在文件中看到类似如下的内容:

#ifdef   __cplusplus
extern "C"
{
#endif

这是 C++所需的一部分舞步,以便它可以使用 C 程序。现在可以忽略它。

包含文件

#include指令告诉预处理器将整个文件引入,仿佛它是原始文件的一部分。该指令有两种形式:

#include <file.h>
#include "file.h"

第一种形式引入系统头文件(即你使用的编译器或系统库附带的文件)。第二种形式引入你自己创建的文件。

头文件的一个问题是它们可能会被包含两次。如果发生这种情况,你会遇到很多重复定义的符号和其他问题。解决这个问题的方法是通过使用以下设计模式添加一个哨兵

#ifndef __FILE_NAME_H__
#define __FILE_NAME_H__
// Body of the file
#endif __FILE_NAME_H__

第一次执行时,__FILE_NAME_H__符号(哨兵)没有被定义,因此整个头文件会被包含。这是好的,因为我们想要它被包含——一次。下次执行时,__FILE_NAME_H__已经定义,#ifndef会阻止其下方的代码被包含,直到文件末尾的#endif被执行。因此,尽管头文件被包含了两次,但文件的内容只会出现一次。

其他预处理器指令

一些小的预处理器指令也很有用,比如#warning#error#pragma

#warning指令会在出现时显示编译器警告:

#ifndef PROCESSOR
#define PROCESSOR DEFAULT_PROCESSOR
#warning "No processor -- taking default"
#endif // PROCESSOR

相关的#error指令会发出错误,并停止程序的编译:

#ifndef RELEASE_VERSION
#error "No release version defined. It must be defined."
#endif // RELEASE_VERSION

#pragma指令定义了与编译器相关的控制。这里是一个例子:

// I wish they would fix this include file.
#pragma GCC diagnostic ignored "-Wmissing-prototypes"
#include "buggy.h"
#pragma GCC diagnostic warning "-Wmissing-prototypes"

这个 GCC 特定的#pragma会关闭缺失原型的警告,包含一个有问题的头文件,并重新打开警告。

预处理器技巧

预处理器是一个愚蠢的宏处理器,因此我们必须采用前面描述的一些样式规则,以避免出现问题。预处理器的强大功能还使我们能够执行一些有趣的技巧,来让我们的工作更加轻松。其中一个技巧是enum技巧,我们在第八章中讨论过。在这一节中,我们将讨论如何注释掉代码。

有时,我们需要禁用某些代码以进行测试。一种方法是注释掉代码。例如,假设审计过程有问题;我们可以禁用它,直到审计组修复问题为止。

这是原始代码:

int processFile(void) {
    readFile();
    connectToAuditServer();
    if (!audit()) {
        printf("ERROR: Audit failed\n");
        return;
    }
    crunchData();
    writeReport();
} 

这里是移除审计后的代码:

int processFile(void) {
    readFile();
//    connectToAuditServer();
//    if (!audit()) {
//        printf("ERROR: Audit failed\n");
//        return;
//    }
    crunchData();
    writeReport();
}

我们希望移除的每一行现在都以注释(//)标记开始。

然而,注释掉每一行是非常繁琐的。相反,我们可以使用条件编译来移除代码。我们所需要做的就是用#ifdef UNDEF#endif // UNDEF语句将代码包围起来,像这样:

int processFile(void) {
    readFile();
#ifdef UNDEF
    connectToAuditServer();
    if (!audit()) {
        printf("ERROR: Audit failed\n");
        return;
    }
#endif // UNDEF
    crunchData();
    writeReport();
}

#ifdef/#endif块中的代码只有在定义了UNDEF时才会被编译,而没有理智的程序员会这么做。使用#if 0 / #endif做同样的事情,而不依赖其他程序员的理智。

总结

C 预处理器是一个简单而强大的自动化文本编辑器。如果使用得当,它可以大大简化编程。它允许你定义简单的数值宏以及小的代码宏。(实际上,你也可以定义大的代码宏,但你真的不想那样做。)

它的一个主要特性是#include指令,它方便了模块之间接口的共享。此外,#ifdef功能使你能够通过条件编译编写一个具有多种功能的程序。

然而,你必须记住,预处理器并不理解 C 语法。因此,你必须记住一些样式规则和编程模式,才能有效地使用该系统。

尽管有很多限制和怪癖,预处理器在创建 C 程序时仍然是一个强大的工具。

编程问题

  1. 编写一个宏来交换两个整数。

  2. 高级:编写一个宏来交换任意类型的两个整数。(在做这件事之前,先阅读 GCC 的typeof关键字文档。)

  3. 创建一个名为islower(x)的宏,如果x是小写字母,则返回 true。

  4. 疯狂高级:弄清楚程序zsmall.c是如何工作的(www.cise.ufl.edu/~manuel/obfuscate/zsmall.hint)。这个程序是模糊 C 竞赛的获奖作品(它获得了“最佳滥用预处理器”奖)。它所做的仅仅是打印一个素数列表,但所有的计算和循环都是通过预处理器完成的。

第二部分

大型机器的 C 语言

到目前为止,我们集中讨论了嵌入式编程。在嵌入式系统中,你有有限的内存和资源。然而,C 语言是为有操作系统的大型机器设计的(这些操作系统我们不需要自己编程),它具有许多在这些大型机器上有用的功能。

例如,有一个称为的内存区域,它允许你根据需要分配和释放内存来存储复杂的对象。像网页浏览器和 XML 解析器广泛使用堆。

我们之前没有讨论过这个,因为我们几乎没有足够的内存来处理堆栈——将内存划分为堆栈和堆就像将一滴水分到两个杯子里。虽然可能,但非常棘手且不太有用。

我们也没有讨论过 C 语言的 I/O 系统。我们必须自己处理 I/O,直接访问硬件。而在具有操作系统的大型机器上,C 语言的 I/O 系统和操作系统会将所有这些细节隐藏起来。

让我们来看一下嵌入式编程和非嵌入式编程之间的区别。

在嵌入式编程中,当你写入设备时,你是直接写入设备。这意味着你必须了解你所使用的设备的详细信息。而在非嵌入式编程中,当你调用write写入设备时,你是告诉操作系统来完成这项工作,包括缓冲区处理,以提高 I/O 效率,并处理实际的设备。

在嵌入式编程中,你的内存是有限的。你需要知道每一个字节的位置以及它是如何使用的。而在非嵌入式编程中,你有操作系统和内存映射系统,这使你可以访问大量内存。大多数程序可以浪费一些内存,而且许多程序确实会这样做。

嵌入式程序通过外部加载器加载到闪存中。在我们的例子中,它被称为 ST-LINK,并且隐藏在 IDE 内部,但它确实存在。程序永远驻留在闪存中,在系统正常运行期间永远不会被卸载或替换。而非嵌入式系统则有一个操作系统,根据需要加载和卸载程序。

嵌入式系统运行一个程序。你几乎没有足够的内存来运行其他程序。然而,非嵌入式系统可以并且确实能同时运行多个程序。我现在使用的系统正在运行 341 个程序,而且它是一个小型系统。

嵌入式程序永远不会停止,而非嵌入式程序可以退出并将控制权返回给操作系统。

嵌入式系统将所有数据存储在内存中。非嵌入式系统有一个文件系统,可以读取和写入文件数据,以及屏幕、网络和其他外设。

最后,嵌入式系统中的错误必须由你的程序来处理。对于非嵌入式系统,你有一个操作系统,它会捕捉程序未处理的错误并打印警告或停止程序。操作系统可以防止坏程序损坏系统中的其他资源。相比之下,如果嵌入式程序出现问题,你很容易就会把系统“砖化”。

C++ 在大系统上运行得很好,因为在大多数情况下,开销不会对事情产生显著影响。例如,假设你想编写一个程序从数据库中读取一堆数据并生成报告。对于一天运行一次的报告,谁在乎程序使用了 0.5 秒 CPU 时间还是 0.2 秒?

然而,如果你从事高性能计算,如游戏、动画或视频编辑,你需要 C 语言的性能和精度。尽管它是一个较老的语言,C 语言仍然在大型机上占有一席之地。

在本节中,你将学习如何使用堆,它是可以随时分配或释放的动态内存。你还将学习如何处理操作系统的 I/O 系统——实际上有两个 I/O 系统:缓冲 I/O 系统和原始 I/O 系统。

最后,你将发现如何使用浮动点数。大多数廉价的嵌入式处理器没有浮点单元,因此我们无法在嵌入式程序中使用浮动点数。此外,尽管大型机有专用的浮点硬件,你必须小心使用此功能;否则,可能会得到意外的结果。

第十三章:动态内存

嵌入式系统的随机存取内存(RAM)非常有限。到目前为止,我们将空闲内存划分为一个小栈,没有空间容纳其他内容。当处理更大的系统时,我们有了数 GB 的内存,这使得我们可以更轻松地将内存划分为两个部分:栈和堆。

我们在第七章讨论了栈。栈是程序为每个过程分配局部变量和临时值的地方。堆则有点不同。你可以决定何时从堆中分配内存,以及何时将内存返回堆中。使用堆,你可以创建非常复杂和大型的数据结构。例如,网页浏览器使用堆来存储构成网页的结构元素。

本章描述了如何分配和释放内存。此外,我们还将探讨如何实现一个链表数据结构,以展示常见的动态内存操作,并讨论如何调试常见的内存问题。

基本的堆内存分配与释放

我们使用 malloc 函数从堆中获取内存。以下是该函数的一般形式:

`pointer` = malloc(`number-of-bytes`);

这个函数从堆中获取 number-of-bytes 字节并返回指向它们的指针。内存是未初始化的,因此它包含随机值。如果程序堆内存耗尽,函数将返回 NULL 指针。

清单 13-1 中的程序为堆上的一个结构分配内存,然后对其什么也不做。

simple.c

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

// Singly linked list with name as payload
struct aList {
     struct aList* next; // Next node on the list
     char name[50];      // Name for this node
};

int main() {
    struct aList* listPtr = malloc(sizeof(*listPtr));
    if (listPtr == NULL) {
        printf("ERROR: Ran out of memory\n");
        exit(8);
    }
    return (0);
}

清单 13-1:一个简单的指针分配

为了使程序更可靠,我们使用 sizeof(*listPtr) 来确定分配多少字节,这是一个常见的设计模式:

`pointer` = malloc(sizeof(*`pointer`));

一个常见的设计错误是省略了星号,像这样:

struct aList* listPtr = malloc(sizeof(listPtr));

有事物和指向事物的指针。listPtr 变量是一个指针,而 *listPtr 表达式是一个事物。指针是小的:在 64 位系统上为 8 字节。事物的大小,在这个例子中,是 56 字节。设计模式确保你为变量分配了正确的字节数,因为变量在 malloc 的参数中会重复出现。

你经常会看到结构本身,而不是指向结构的指针,用在 sizeof 中:

struct aList* listPtr = malloc(sizeof(struct aList));

这样做是可行的,但有些危险。假设有人改变了 listPtr 的类型。例如,以下是不正确的:

struct aListImproved* listPtr = malloc(sizeof(struct aList));

那么发生了什么呢?一开始我们有以下正确但危险的声明:

struct aList* listPtr = malloc(sizeof(struct aList));

一切都正常,因为 listPtr 是指向 struct aList 的指针。只要类型匹配,一切都没有问题。现在假设有人决定修改代码,使得 listPtr 指向新的、改进版的 aList,称为 aListImproved但是他们没有在 malloc 函数中更改类型。更糟糕的是,假设代码不再是简单的、显而易见的一行代码,而是像这样:

struct aListImproved* listPtr;
// 3,000 lines of dense code

// WRONG
listPtr = malloc(sizeof(struct aList));

这段代码没有为新字段分配足够的空间,因此每次有人使用这些新字段时,随机的内存就会被覆盖。

一个好的做法是检查 malloc 是否返回了 NULL 指针,以此判断是否耗尽了内存:

if (listPtr == NULL) {
    printf("ERROR: Ran out of memory\n");
    exit(8);
}

即使你认为 malloc 永远不会失败,这一点也是至关重要的。

我们的程序有 内存泄漏,这意味着它没有回收所使用的内存。当程序回收内存时,它会被返回到堆中,以便之后的 malloc 可以重新使用。为了做到这一点,我们使用 free 函数:

free(listPtr);
listPtr = NULL;

listPtr 设置为 NULL 是一种设计模式,它确保在内存被释放后,不会再尝试使用这块内存。C 语言并没有强制要求这样做。

如果我们在没有先将释放的 listPtr 设置为 NULL 的情况下使用它,我们将写入本不该写入的内存。下面是一个示例:

free(listPtr);
listPtr->name[0] = '\0'; // Wrong, but will execute and
                         // possibly create a strange error
                         // much later in the program

当我们向已释放的内存写入数据时,可能会发生一些程序错误,调试起来会很困难,因为错误和之前的错误之间的关系不容易发现。

如果我们在明显的方式中犯错,那就很好,比如这样:

free(listPtr);
listPtr = NULL;
listPtr->name[0] = '\0';  // Program crashes with a good
                          // indication of where and why

这是一种偏执编程的形式。其思路是将一个微妙且难以发现的错误转化为一个能够崩溃整个程序的错误,这样更容易找到问题。

链表

现在我们有了堆并且可以在其中存储数据,我们将使用一种叫做 单向链表 的原始数据结构,这比数组有多个优势。它没有固定大小,而且在使用它时,插入和删除操作比数组要快得多。(数组的优势在于查找速度较快。)

假设我们需要为电话簿存储多个姓名。问题在于我们不知道会有多少个姓名,而且姓名可能随时被添加或删除。对于嵌入式系统来说,这个问题很简单。我们创建一个数组来存储姓名。如果数组空间用完,我们告诉用户无法再存储更多姓名。如果我们有内存并且有堆,使用链表会更好。在极其有限的嵌入式系统中,我们两者都没有。

我们链表中的每个元素,称为 节点,都是从堆中分配的。为了跟踪这些元素,我们有一个指向第一个节点的指针。第一个节点有一个指向第二个节点的指针,依此类推,直到我们到达最后一个节点。它的指针是 NULL,表示链表的结束。节点的数量没有固定限制。如果我们需要更多的节点,只需从堆中分配一个新的节点。

这是链表的结构:

#define NAME_SIZE 20    // Max number of characters in a name
/**
 * A node in the linked list
 */
struct linkedList {
    struct linkedList* next;    // Next node
    char name[NAME_SIZE];       // Name of the node
};

next 指针指向下一个节点(或 NULL),而 name 数组最多存储 20 个字符。图 13-1 是该链表的示意图。

f13001

图 13-1:单向链表

单向链表提供了一种非常简单的方式,将不确定数量的项存储在堆中。

添加一个节点

要将一个节点(例如“Fred”)添加到列表中,我们必须先创建一个。在代码中,我们让newNode变量指向新创建的节点。此时内存布局如图 13-2 所示。

f13002

图 13-2:创建新节点

图 13-2 展示了我们的链表(不包括“Fred”)以及我们为“Fred”分配的新节点。接下来,我们让新节点的next指针指向列表的起始位置(参见图 13-3)。

f13003

图 13-3:新节点的next指针指向列表的起始位置。

最后一步是将theList = newNode,将指针移动到我们的列表头部,指向新的第一个节点(参见图 13-4)。

f13004

图 13-4:将新节点移动到列表头部

清单 13-2 展示了将新节点添加到列表开头的代码。

static void addName(void)
{
    printf("Enter word to add: ");

    char line[NAME_SIZE];       // Input line

    if (fgets(line, sizeof(line), stdin) == NULL)
        return;

    if (line[strlen(line)-1] == '\n')
        line[strlen(line)-1] = '\0';

    // Get a new node.
    struct linkedList* newNode = malloc(sizeof(*newNode));

    strncpy(newNode->name, line, sizeof(newNode->name)-1);
    newNode->name[sizeof(newNode->name)-1] = '\0';
    newNode->next = theList;
    theList = newNode;
}

清单 13-2:将单词添加到链表中

我们从函数声明开始,static关键字表示该函数仅对当前文件中的代码可见。我们首先请求要添加的单词,并使用fgets函数获取它,其通用形式如下:

fgets(`array`, `size`, `file`)

该函数从file读取一行并将其放入array中。size是要放入数组的字节数,包括字符串结束符(\0)。在此例中,数组是line(输入行),文件是stdin(标准输入,换句话说,就是终端)。如果fgets返回NULL,表示我们因为错误或数据读取完毕无法读取stdin。此时,我们放弃并返回,因为没有获取到单词。

fgets函数最多读取size-1 个字符,因为它总是将字符串结束符(\0)放入数组中。如果输入的行短于size,整个行都会被放入缓冲区,包括换行符。如果输入的行较长,则输入会被截断。

我们不能依赖缓冲区中有换行符,也不希望有。如果字符串中的最后一个字符(通过strlen函数找到,该函数返回字符串的字符数)是换行符,我们通过将其改为空字符('\0')来删除它。接着,我们为新节点分配内存,并通过将line复制到节点的名称中来填充它。

strncpy函数将第二个参数(line)复制到第一个参数(newNode->name)中,但只复制第三个参数指定的字符数。如果要复制的数据(line)比size参数更多,函数会限制复制的字符数,并且不会在末尾插入字符串结束符(\0),因此为了安全起见,我们手动在name数组的末尾添加一个结束符。

我们让newNode指向第一个节点,然后将theList指向新节点,如图 13-3 和 13-4 所示。

打印链表

打印链表的规则很简单。这里是一个示例:

for (const struct linkedList* curNode = 1 theList;
   2 curNode != NULL;
   3 curNode = curNode->next){
    printf("%s, ", curNode->name);
}

我们从第一个节点 1 开始,打印它,然后转到下一个节点 3。我们继续执行,直到链表 2 遍历完为止。在这个示例中,for 循环的初始化、结束条件和迭代语句分布在三行代码中。代码确实会在列表末尾添加一个额外的逗号,但我相信你能弄清楚如何修复这个问题。图 13-5 展示了它的工作原理。

f13005

图 13-5:打印列表

由于我们的链表是一个简单的数据结构,打印过程也很简单,而 C 语言的 for 循环的灵活性使得遍历链表变得容易。

删除节点

要删除一个节点,我们首先遍历链表并找到我们想要的节点。接下来,我们移除该节点,然后将前一个节点连接到下一个节点。遍历链表的代码如下:

static void deleteWord(void)
{
    printf("Enter word to delete: ");

    char line[NAME_SIZE];       // Input line

    if (fgets(line, sizeof(line), stdin) == NULL)
        return;

    if (line[strlen(line)-1] == '\n')
        line[strlen(line)-1] = '\0';

    struct linkedList* prevNode = NULL; // Pointer to previous node
  1 for (struct linkedList* curNode = theList;
         curNode != NULL;
         curNode = curNode->next) {
       2 if (strcmp(curNode->name, line) == 0) {
            if (prevNode == NULL) {
              3 theList = curNode->next;
            } else {
              4 prevNode->next = curNode->next;
            }
          5 free(curNode);
            curNode = NULL;
            return;
        }
      6 prevNode = curNode;
    }
    printf("WARNING: Node not found %s\n", line);
}

我们使用一个 for 循环,和打印时的方式差不多 1,但不是打印节点,而是使用 strcmp 函数 2 来检查它是否是我们想要的节点,该函数如果字符串相同则返回 0。如果它不是我们想要的节点,我们更新指向前一个节点的指针 6(这是删除时需要的),然后使用 for 循环进入下一个节点。

如果我们确实找到了节点(假设是“Joe”),prevNode 会指向“Sam”,curNode 会指向“Joe”,如 图 13-6 所示。

接下来,我们让“Sam”节点指向“Mac”节点,跳过“Joe”节点 4。然后我们通过释放它 并且 将指针设置为 NULL 5 来删除该节点,这在 prevNode 设置时是有效的。如果我们想删除第一个节点“Sam”,我们需要更改链表的指针,跳过已删除的节点 3。

f13006

图 13-6:删除节点 “Joe”

将一切整合在一起

清单 13-3 是一个小型命令行程序,旨在交互式编辑和打印链表。

linked.c

/**
 * Demonstrate a singly linked list.
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>

#define NAME_SIZE 20    // Max number of characters in a name
/**
 * A node in the linked list
 */
struct linkedList {
    struct linkedList* next;    // Next node
    char name[NAME_SIZE];       // Name of the node
};
// The linked list of words
static struct linkedList* theList = NULL;

/**
 * Add a name to the linked list.
 */
static void addName(void)
{
    printf("Enter word to add: ");

    char line[NAME_SIZE];       // Input line

    if (fgets(line, sizeof(line), stdin) == NULL)
        return;

    if (line[strlen(line)-1] == '\n')
        line[strlen(line)-1] = '\0';

    // Get a new node.
    struct linkedList* newNode = malloc(sizeof(*newNode));

 strncpy(newNode->name, line, sizeof(newNode->name)-1);
    newNode->name[sizeof(newNode->name)-1] = '\0';
    newNode->next = theList;
    theList = newNode;
}

/**
 * Delete a word from the list.
 */
static void deleteWord(void)
{
    printf("Enter word to delete: ");

    char line[NAME_SIZE];       // Input line

    if (fgets(line, sizeof(line), stdin) == NULL)
        return;

    if (line[strlen(line)-1] == '\n')
        line[strlen(line)-1] = '\0';

    struct linkedList* prevNode = NULL; // Pointer to the previous node
    for (struct linkedList* curNode = theList;
         curNode != NULL;
         curNode = curNode->next) {
        if (strcmp(curNode->name, line) == 0) {
            if (prevNode == NULL) {
                theList = curNode->next;
            } else {
                prevNode->next = curNode->next;
            }
            free(curNode);
            curNode = NULL;
            return;
        }
        prevNode = curNode;
    }
    printf("WARNING: Node not found %s\n", line);
}

/**
 * Print the linked list.
 */
static void printList(void)
{
    // Loop over each node in the list.
    for (const struct linkedList* curNode = theList;
         curNode != NULL;
         curNode = curNode->next) {
        printf("%s, ", curNode->name);
    }
    printf("\n");
}

int main()
{

    while (true) {
        printf("a-add, d-delete, p-print, q-quit: ");
        char line[100]; // An input line
        if (fgets(line, sizeof(line), stdin) == NULL)
            break;

        switch (line[0]) {
            case 'a':
                addName();
                break;
            case 'd':
                deleteWord();
                break;
            case 'p':
                printList();
                break;
            case 'q':
                exit(8);
            default:
                printf(
                    "ERROR: Unknown command %c\n", line[0]);
                break;
        }
    }
}

清单 13-3:实现链表的程序

用户通过输入命令来添加或删除节点(按名称)、打印列表或退出程序。当用户添加或删除节点时,程序会动态分配或释放内存。

动态内存问题

使用动态内存时,可能会出现几种常见错误,例如内存泄漏、在释放内存后仍使用指针,以及在结构体末尾写入数据并破坏随机内存。让我们看看每个错误以及如何避免它。

内存泄漏 发生在内存被分配后从未释放的情况下。这里有一个示例:

{
    int* dynamicArray;    // A dynamic array
    // Allocate 100 elements.
    dynamicArray = malloc(sizeof(int) * 100);
}

每次程序执行这段代码时,它都会分配 400 字节的内存。如果程序运行足够长时间,它将消耗所有可用内存并崩溃。(实际上,它会消耗足够的内存资源,导致其他程序变得非常慢,然后才使用大量内存,使得计算机完全无法使用,运行一段时间后,最终内存耗尽。)

在释放内存后使用指针(通常称为 释放后使用)可能导致随机结果或覆盖随机内存。让我们看一个例子:

free(nodePtr);
nextPtr = nodePtr->Next;   // Illegal

在这种情况下,free 函数可能会在节点中写入书籍记录或其他数据,结果 nextPtr 变为未定义。

正如本章早些时候提到的,简单的设计模式将限制这种代码造成的损害。我们总是在释放指针后将其设置为 NULL

free(nodePtr);
nodePtr = NULL;
nextPtr = nodePtr->Next;   // Crashes the program

我们将未定义的、随机的行为替换为可重现的、可预测的行为。崩溃的原因容易找到。

我们将要考虑的最后一个动态内存问题是写入结构体末尾之外的数据。正如你之前看到的,没什么能阻止你写入数组末尾之外的内容。你可以对分配的内存做同样的事:

int* theData;   // An array of data
*theData = malloc(sizeof(*theData)*10);
theData[0] = 0;
theData[10] = 10; // Error

使用 C 语言没有好的方法来防止或检测这些类型的错误。需要外部工具或增强型编译。

Valgrind 与 GCC 地址清理器

内存错误已经成为一个严重问题,许多工具被创建出来以尝试检测它们,包括 Valgrind 和 GCC 地址清理器。

Valgrind 是开源的,并且可以免费在 valgrind.org 上获取,适用于 Linux 和 macOS。它的设计目标是发现以下问题:内存泄漏、写入数组或分配的内存块的末尾、在释放内存后使用指针,以及基于未初始化内存的值做出决策。

Valgrind 是一个运行时工具。你不需要重新编译代码来使用它;相反,你正常编译程序,然后将程序作为参数与 Valgrind 一起运行。

列表 13-4 显示了一个泄漏内存的程序。

/**
 * Leaks memory and uses it badly.
 * Generates warnings when compiled.
 * Generates errors when run.
 *
 * Please don't program like this.
 */
#include <stdlib.h>

static void leak(void)
{
    char* data = malloc(100);
}

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

列表 13-4:一个有内存泄漏的程序

列表 13-5 显示了在 Valgrind 下运行此程序,且泄漏检查设置为最大时的结果。

$ **valgrind --leak-check=full ./leaker**
`--snip--`
==14500== 100 bytes in 1 blocks are definitely lost in loss record 1 of 1
==14500==    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)
==14500==    by 0x10865B: leak (leaker.c:12) 1
==14500==    by 0x10866B: main (leaker.c:17)
==14500==
==14500== LEAK SUMMARY:
==14500==    definitely lost: 100 bytes in 1 blocks
`--snip--`

列表 13-5:Valgrind 结果

从此输出中,我们可以看到第 12 行正在泄漏 1。

GCC 地址清理器旨在仅检测内存泄漏和写入数组或分配的内存块末尾之外的操作。与 Valgrind 不同,它是一个编译时工具,因此你需要使用 –fsanitize=address 标志编译代码以使用它。之后,当你运行程序时,它会自动生成报告,如 列表 13-6 所示。

$ **/leaker**

=================================================================
==14427==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 100 byte(s) in 1 object(s) allocated from:
    #0 0x7f07c712cb50 in __interceptor_malloc (/usr/lib/x86_64-linux-gnu/libasan.so.4+0xdeb50)
    #1 0x5607aef0b7fb in leak /home/sdo/bare/xx.leaker/leaker.c:15
    #2 0x5607aef0b80b in main /home/sdo/bare/xx.leaker/leaker.c:17
    #3 0x7f07c6c7eb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)

SUMMARY: AddressSanitizer: 100 byte(s) leaked in 1 allocation(s).

列表 13-6:地址清理器结果

内存问题自第一台计算机时代以来就困扰着程序,它们很难找到。地址清理器是一个能帮助我们找到这些问题的工具。

总结

堆允许你根据需要为程序添加和移除额外的内存。它使你能够创建大型、复杂和精妙的数据结构。各种数据结构和数据结构设计的描述可能足以填满一本书。

本章介绍了单链表,它是数据结构的“Hello World”。随着你的进展,你可以学习如何使用堆来存储更复杂的数据。现在,你已经掌握了基础,接下来你将如何使用这些知识完全取决于你自己。

编程问题

  1. 修改示例 13-3 中的程序,它实现了一个链表,使得链表中的节点始终保持顺序。

  2. 给定两个有序的链表,创建一个函数返回它们的公共节点列表。你可以创建一个新的列表,也可以创建一个列表,其主体只是指向另一个列表中某个节点的指针。

  3. 修改示例 13-3 中的程序,使用双向链表。每个节点将有一个指向下一个节点的next指针,以及一个指向前一个节点的previous指针。

  4. 编写一个函数,反转单链表的顺序。

  5. 编写一个函数,删除链表中的重复节点。

第十四章:缓冲区文件 I/O

在本书的第一部分,我们连最简单的控制台输出都很困难。但在这一部分,我们有了操作系统,这让处理输入输出变得更加简单。因为操作系统隐藏了大量的复杂性:你只需写 "Hello World\n",操作系统就会将数据发送到合适的地方。

在这一章中,你将了解 C 语言的输入/输出系统,这不仅包括 printf 函数,还包括以高效且灵活的方式读写磁盘文件的函数。

printf 函数

我们已经使用了几次 printf 函数进行简单的输出。该函数的基本格式是:

printf(`format-string`, `argument`, ...)

格式字符串告诉 printf 打印什么内容。除百分号(%)外的任何字符都会被打印。% 字符开始一个字段说明,告诉 printf 去参数列表中取下一个参数,并按照随后的字段说明进行打印。例如:

printf("Number: ->%d<-\n", 1234);   // Prints  ->1234<-

%d 字段说明可以通过数字进行修改:

printf("Number: ->%3d<-\n", 12);    // Prints  ->.12<- (using . for space)
printf("Number: ->%-3d<-\n", 12);   // Prints  ->12.<- (using . for space)
printf("Number: ->%3d<-\n", 1234);  // Prints  ->1234<-(at least 3 characters)

在这些例子中,%3d 告诉 printf 至少使用三个字符来打印数字。%-3d 字段告诉 printf 至少用三个字符打印数字,并左对齐。

到目前为止,我们只讨论了 d 转换字符,它用于将整数参数转换为文本以进行打印。表 14-1 列出了主要的转换字符。

表 14-1:主要的 C 语言转换字符

转换字符 参数类型 备注
%d 整数 charshort int 类型在作为参数传递时会被提升为 int,因此该格式同样适用于这三种类型。
%c 字符 由于提升,这个转换字符实际上接受一个整数参数,并将其打印为字符。
%o 整数 以八进制打印。
%x 整数 以十六进制打印。
%f 双精度浮点数 适用于 floatdouble 类型,因为所有 float 类型的参数在传递时会提升为 double
%l 长整型 long int 类型需要自己的转换,因为 int 类型不会自动提升为 long int

编写 ASCII 表

让我们写一个简短的程序来创建一个表格,包含可打印字符及其十六进制和八进制值,这将展示格式化字符串的实际应用。这个程序(列表 14-1)让我们有机会以四种不同的方式表达相同的数据,并在 printf 语句中尝试不同的格式。

ascii.c

/**
 * Print ASCII character table (only printable characters).
 */

#include <stdio.h>

int main()
{
    for (char curChar = ' '; curChar <= '~'; ++curChar) {
        printf("Char: %c Decimal %3d Hex 0x%02x Octal 0%03o\n",
               curChar, curChar, curChar, curChar);
    }
    return (0);
}

列表 14-1:一个创建 ASCII 表的程序

首先,%c格式字符串以字符形式打印字符。接下来,我们以三位十进制数(%3d)打印字符。准确地说,参数的类型是字符,它被提升为整数。由于参数规范中的3,这个数字将是三位的。之后,我们使用%02x格式以十六进制打印。零(0)告诉printf如果需要填充零以匹配所需的宽度(当然,宽度是2)。最后,我们使用%03o格式以八进制打印。

Listing 14-2 显示了这个程序的输出。

Char:   Decimal  32 Hex 0x20 Octal 0040
Char: ! Decimal  33 Hex 0x21 Octal 0041
Char: " Decimal  34 Hex 0x22 Octal 0042
Char: # Decimal  35 Hex 0x23 Octal 0043
Char: $ Decimal  36 Hex 0x24 Octal 0044
Char: % Decimal  37 Hex 0x25 Octal 0045
Char: & Decimal  38 Hex 0x26 Octal 0046
Char: ' Decimal  39 Hex 0x27 Octal 0047
Char: ( Decimal  40 Hex 0x28 Octal 0050
`--snip--`

Listing 14-1 的输出(ascii.c

printf函数是 C 语言 I/O 系统的核心工具。它帮助我们将各种不同类型的数据打印到控制台。但这不是我们唯一可以写入的地方,正如我们接下来几节所见。

写入预定义文件

当程序启动时,操作系统会打开三个预定义文件:

  1. stdin 标准输入,程序的正常输入

  2. stdout 标准输出,用于程序的正常输出

  3. stderr 标准错误,用于错误输出

默认情况下,这些文件连接到控制台,但你的命令行解释器可以将它们连接到磁盘文件、管道或其他地方。

fprintf函数将数据发送到指定的文件。例如:

fprintf(stdout, "Everything is OK\n");
fprintf(stderr, "ERROR: Something bad happened\n");

printf函数只是一个便利函数,它替代了fprintf(stdout, ...)

读取数据

读取数据的函数设计得很简单,但不幸的是,它们并非如此。printf函数有一个对应的函数叫做scanf,用于读取数据。例如:

// Reads two numbers (do not use this code)
scanf("%d %d", &aInteger, &anotherInteger);

首先,注意在参数前面的符号(&),这是因为scanf需要修改参数;因此,参数必须通过地址传递。

传递给scanf的格式字符串看起来与printf的格式字符串非常相似,但scanf有一个大问题:除非你是一个极其专业的专家,否则你永远不知道它如何处理空白字符。所以,我们不使用它。

相反,我们使用fgets函数从输入中获取一行,然后使用sscanf解析得到的字符串:

fgets(line, sizeof(line), stdin);   // Read a line
sscanf(line, "%d %d", &aInteger, &anotherInteger);

fgets的一般形式是:

char* `result` = fgets(`buffer`, `size`, `file`);

其中,result是指向刚读取的字符串(buffer)的指针,或者当我们到达文件末尾(EOF)时是NULLbuffer是一个字符数组,用来存储读取的行,file是一个文件句柄,指示要读取的文件(此时我们只知道stdin)。

buffer将始终以 null 字符(\0)结尾,因此最多会将size-1 个字符放入buffer中。(即使buffer不够大,也会读取整行。)

sscanf函数与scanf函数非常相似,不同之处在于第一个参数现在是字符串。其余的参数相同。sscanf函数返回它转换的项数。

上述代码假设一切正常。让我们重写它,这次检查错误:

if (fgets(line, sizeof(line), stdin) == NULL) {
    fprintf(stderr, "ERROR: Expected two integers, got EOF\ n");
    return (ERROR);
}
if (sscanf(line, "%d %d", &aInteger, &anotherInteger) != 2) {
    fprintf(stderr, "ERROR: Expected two integers.\n");
    return (ERROR)
}

如果第一次调用fgets返回NULL,说明出了问题。然后我们会将错误信息打印到预定义的错误文件(stderr)并返回错误代码给调用者。接着,我们执行sscanf,它应该能找到两个整数。如果没有,我们再次打印错误信息并返回错误代码。

恶魔的gets函数

fgets函数有一个对应的简写函数来从stdin读取数据。它叫做gets,一般形式是:

`result` = gets(`buffer`);

gets函数读取一行数据并将其放入buffer中,无论 buffer 是否能容纳它。

当前的 GCC 编译器使得gets变得难以使用。首先,stdio.h不会定义它,除非你正确地定义一个条件编译宏。当你编译程序时,编译器会给出警告,接着当程序链接时,链接器也会给出警告。

示例 14-3 展示了使用gets编译程序时发生的情况。

$ **gcc -Wall -Wextra -o gets gets.c**
Agets.c: In function 'main':
gets.c:17:5: warning: 'gets' is deprecated [-Wdeprecated-declarations]
     gets(line);
 ^~~~
In file included from gets.c:11:0:
/usr/include/stdio.h:577:14: note: declared here
 extern char *gets (char *__s) __wur __attribute_deprecated__;
              ^~~~
/tmp/cc5H1KMF.o: In function `main':
gets.c:(.text+0x1f): warning: the `gets' function is dangerous and should not be used.

示例 14-3: 尝试使用gets

从输出量可以看出,GCC 编译器为了劝说你不要使用gets,付出了多大的努力。

现在我们已经看了一些不应该使用的东西,接下来让我们看一下应该使用的东西。

打开文件

预定义文件stdinstdoutstdout是文件句柄。fopen函数允许你创建文件句柄。示例 14-4 展示了一个简单的例子。

file.c

#include <stdio.h>

int main()
{
  1 FILE* outFile = 2 fopen("hello.txt", "w");
    if (outFile == NULL) {
        fprintf(stderr, "ERROR: Unable to open 'hello.txt'\n");
        exit(8);
    }
    if (fprintf(outFile, "Hello World!\n") <= 0) {
        fprintf(stderr, "ERROR: Unable to write to 'hello.txt'\n");
        exit(8);
    }
    if (fclose(outFile) != 0) {
        fprintf(outfile, “ERROR: Unable to close 'hello.txt'\n");
        exit(8);
    }
    return (0);
}

示例 14-4: 一个文件版本的“Hello World”

首先,FILE*声明 1 声明一个新的文件句柄。所有文件操作都需要文件句柄。接下来是fopen调用 2,它的一般形式是:

`result` = fopen(`filename`, `mode`);

mode可以是以下之一:

  1. r 只读

  2. w 仅写

  3. r+ 读写

  4. a 追加(写入但从文件末尾开始)

  5. b 与其他模式结合使用,用于二进制文件(将在下一节讨论)

现在我们已经打开文件,可以进行读写操作了。文本可以通过fprintf写入,通过fgets读取。接下来,让我们看看另一种类型的文件:二进制文件。

二进制 I/O

到目前为止,我们只限制在文本文件,但 C I/O 系统可以通过使用freadfwrite函数处理二进制文件。fread函数的一般形式是:

`result` = fread(`buffer`, `elementSize`, `size`, `inFile`);

这里,buffer是指向数据缓冲区的指针,数据将被存放在该缓冲区中。elementSize始终为1(请参见下面的框进行解释)。size是缓冲区的大小,通常是sizeof(``buffer``)inFile是要读取的文件。

该函数返回读取的项目数,由于elementSize1,因此是读取的字节数。文件结束时返回0,如果发生 I/O 错误,则返回负数。

fwrite函数有一个类似的结构:

`result` = fwrite(`buffer`, `elementSize`, `size`, `inFile`);

一切都是一样的,只是写入数据而不是读取。

复制文件

我们将使用freadfwrite调用来复制一个文件。由于我们还不知道如何在命令行中传递参数(见第十五章),文件名被硬编码为infile.binoutfile.bin。示例 14-5 包含了相关代码。

copy.c

/**
 * Copy infile.bin to outfile.bin.
 */

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
 int main()
{
    // The input file
  1 FILE* inFile = fopen("infile.bin", "rb");
    if (inFile == NULL) {
        fprintf(stderr, "ERROR: Could not open infile.bin\n");
        exit(8);
    }
    // The output file
    FILE* outFile = fopen("outfile.bin", "wb");
    if (outFile == NULL) {
        fprintf(stderr, "ERROR: Could not create outfile.bin\n");
        exit(8);
    }
    char buffer[512];   // A data buffer

    while (true) {
        // Read data, collect size
      2 ssize_t readSize = fread(buffer, 1, sizeof(buffer), inFile);
        if (readSize < 0) {
            fprintf(stderr, "ERROR: Read error seen\n");
            exit(8);
        }
      3 if (readSize == 0) {
            break;
        }
      4 if (fwrite(buffer, 1, readSize, outFile) !=(size_t)readSize) {
            fprintf(stderr, "ERROR: Write error seen\n");
            exit(8);
        }
    }
    fclose(inFile);
    fclose(outFile);
    return (0);
}

示例 14-5:复制一个文件

首先,注意fopen调用 1。我们使用rb模式打开文件,这告诉系统我们将读取文件(r)并且文件是二进制的(b)。

接下来,我们来看看fread调用 2。这个函数的返回值是ssize_t,它是一个标准类型,足够大可以存储可能存在的最大对象(结构体、数组、联合体)的大小。它还可以存储-1来表示错误条件。

如果我们已经从文件中读取了所有数据,fread会返回0。当发生这种情况时,表示我们已经完成,因此退出主循环 3。

现在我们来看看fwrite调用 4,它返回一个size_t值。这个是一个无符号类型,可以存储程序中能容纳的最大对象的大小,但由于它是无符号的,它不能存储错误值。当fwrite在写入时遇到错误时会发生什么?它会尽可能多地写入,并返回已写入的字节数,因此它永远不会返回错误代码,只会返回部分写入的字节数。

请注意,fread返回一个ssize_t类型的结果,而fwrite返回一个size_t类型的结果。这样做有其合理的原因,但也意味着,如果我们检查试图写入的字节数是否与实际要求fwrite写入的字节数相同,编译器会发出警告:

35          if (fwrite(`buffer`, 1, `readSize`, `outFile`) != `readSize`) {
                                      Warning: signed vs. unsigned compare

为了消除警告,我们需要插入一个类型转换,从而告诉 C 语言:“是的,我知道我们正在混合有符号和无符号类型,但我们必须这么做,因为freadfwrite的定义很笨拙”:

if (fwrite(`buffer`, 1, `readSize`, `outFile`) != (size_t)`readSize`) {

另外请注意,在最后一次读取时,我们可能不会读取到完整的 512 字节。这就是为什么在fwrite语句中我们使用了readSize而不是sizeof(``buffer``)的原因。

缓冲区和刷新

C 的 I/O 系统使用缓冲 I/O,这意味着当你执行printffwrite时,数据可能不会立即发送到输出设备。相反,它将被存储在内存中,直到系统有足够的数据来提高效率。

发送到控制台的数据是行缓冲的,这意味着如果你只打印了一行的部分内容,它可能不会立即显示,直到该行的其他部分也发送出去。让我们看看这个程序是如何在示例 14-6 中给我们带来麻烦的。

/**
 * Demonstrate how buffering can fool
 * us with a divide-by-zero bug.
 */

#include <stdio.h>

int main()
{
    int zero = 0;    // The constant zero, to trick the
                     // compiler into letting us divide by 0
    int result;      // Something to put a result in

    printf("Before divide ");
    result = 5 / zero;
    printf("Divide done\n");
    printf("Result is %d\n", result);
    return (0);
}

示例 14-6:除以零

运行这个程序时,你会期望看到以下输出:

Before divide Floating point exception (core dumped)

但你实际看到的是:

Floating point exception (core dumped)

你最初可能认为printf没有执行,但实际上它执行了。数据进入了缓冲区,并在程序中止时停留在缓冲区,导致误导性的显示printf没有起作用。

为了解决这个问题,我们需要告诉 I/O 系统“现在写入缓冲区数据”,这可以通过fflush函数来完成:

 printf("Before divide ");   fflush(stdout);

刷新数据可以确保我们能够看到它。另一方面,我们不想在每次写入后都刷新,因为那样会违背缓冲区的目的,缓冲区的目的是提高 I/O 效率。

关闭文件

最后,在我们完成文件操作后,需要告诉 C 语言我们已经处理完该文件。我们通过使用fclose函数来完成:

int `result` = fclose(`file`);

其中,file是要关闭的FILE*result如果成功返回0,如果失败则返回非零值。

总结

在嵌入式世界中,I/O 操作比较困难,因为你必须编写代码直接与设备交互,并且需要为每种不同类型的设备编写不同的代码。

C 语言的 I/O 系统设计是为了将这些细节隐藏在你背后。它还提供了许多优秀的功能,如格式化、缓冲和设备独立性。缓冲 I/O 系统对于大多数一般应用程序来说非常有效。

编程问题

  1. 看看当你在printf语句中放入过多或过少的参数时会发生什么。如果你放入了错误的类型(例如,double而不是int)会怎样?

  2. 编写一个程序,要求用户输入摄氏温度并将其转换为华氏温度。

  3. 编写一个程序,计算文件中单词的数量。请确保你对“单词”的定义进行文档说明,因为不同的人对“单词”的理解可能与你不同。

  4. 编写一个程序,逐行比较两个文件,并输出不同的行。

第十五章:命令行参数和原始 I/O

在本章中,我们探讨了命令行参数如何允许操作系统在程序被调用时将信息传递给程序。我们还将了解一个与操作系统紧密相关的功能:原始输入/输出(I/O)系统。这个系统使我们能够精确控制程序如何执行 I/O 操作。如果正确使用,它可以成为程序的一项巨大资产。

我们将使用原始 I/O 系统执行高速文件复制。这个程序还将使用命令行参数来指定源文件和目标文件,这样我们就不需要将它们硬编码到程序中了。

命令行参数

操作系统允许用户在程序运行时通过命令行选项向程序提供多个参数:

$ ./`prog` `argument1` `argument2` `argument3`

C 通过两个参数 argcargv 将这些参数传递给 main

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

第一个参数 argc 包含参数的数量。由于历史原因,它是一个整数,而不是无符号整数。第二个参数 argv 是一个字符串数组,表示实际的参数。

如果你运行一个像这样的程序:

./`prog` `first` `second third`

argvargc 参数将包含以下内容:

argc	4
argv[0]    ./`prog`
argv[1]    `first`
argv[2]    `second`
argv[3]    `third`

第一个参数是程序的名称。下一个参数是命令行上的 first 参数,依此类推。

示例 15-1 包含了一个简短的程序,旨在打印命令行参数。

echo.c

/**
 * Echo the command line arguments.
 */
#include <stdio.h>

int main(const int argc, const char* argv[])
{
    for (int i = 0; i < argc; ++i) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    return (0);
}

示例 15-1:打印命令行参数

你不一定需要将参数数量命名为 argc,将参数向量命名为 argv,也不需要声明 argvargc const,但这样做是惯例。

原始 I/O

C 程序员可以使用的两种主要文件 I/O 系统是缓冲 I/O无缓冲 I/O。我们在第十四章讨论的标准 I/O 系统(printf)使用了缓冲区。在本章中,我们将使用无缓冲 I/O。为了展示这两者之间的区别,我们来考虑一个例子。假设你想整理衣橱,并且有 500 根旧电源线需要丢弃。你可以这样做:

  1. 拿起一根电源线。

  2. 走到户外的垃圾桶旁。

  3. 丢掉它。

  4. 重复 500 次。

这种方法就像使用无缓冲 I/O 丢弃电源线一样,吞吐量(你完成工作的速度)非常低。

让我们添加一个缓冲区——在这个例子中,就是一个垃圾袋。现在程序的步骤如下:

  1. 把电源线放进袋子里。

  2. 一直往袋子里放电源线,直到它满了。(它能装下 100 根电源线。)

  3. 走到户外的垃圾桶旁。

  4. 丢掉袋子。

  5. 重复五次。

缓冲会使重复的过程更高效,那么什么时候你会选择使用无缓冲 I/O 呢?你会在某些情况下使用它,这些情况下单独丢弃每个物品会更高效。假设你要丢掉五台冰箱。你不会把五台冰箱放进垃圾袋然后一起丢掉。相反,你会把每一台冰箱单独丢掉。

使用原始 I/O

如果我们想复制一个文件,可以使用缓冲 I/O 系统来实现,但那样的话,我们需要让缓冲 I/O 系统选择缓冲区的大小。相反,我们希望设置自己的缓冲区大小。在这种情况下,我们知道 1,024 字节的大小是适用于我们设备的最佳大小,因此我们创建了在 Listing 15-2 中显示的程序,使用原始 I/O 来复制文件,缓冲区大小为 1,024 字节。

copy.c

/**
 * Copy one file to another.
 *
 * Usage:
 *     copy <from> <to>
 */

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

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

#ifndef O_BINARY
#define O_BINARY 0      // Define O_BINARY if not defined.
#endif // O_BINARY

int main(int argc, char* argv[])
{
  1 if (argc != 3) {
        fprintf(stderr, "Usage is %s <infile> <outfile>\n", argv[0]);
        exit(8);
    }

    // The fd of the input file
  2 int inFd = open(argv[1], O_RDONLY|O_BINARY);

  3 if (inFd < 0) {
        fprintf(stderr, "ERROR: Could not open %s for input\n", argv[1]);
        exit(8);
    }

    // The fd of the output file
  4 int outFd = open(argv[2], O_WRONLY|O_CREAT|O_BINARY, 0666);
    if (outFd < 0) {
        fprintf(stderr, "ERROR: Could not open %s for writing\n", argv[2]);
        exit(8);
    }

    while (true)
    {
        char buffer[1024];      // Buffer to read and write
        size_t readSize;        // Size of the last read

      5 readSize = read(inFd, buffer, sizeof(buffer));
      6 if (readSize < 0) {
            fprintf(stderr, "ERROR: Read error for file %s\n", argv[1]);
            exit(8);
        }
      7 if (readSize == 0)
            break;

      8 if (write(outFd, buffer, readSize) != readSize) {
            fprintf(stderr, "ERROR: Write error for %s\n", argv[2]);
            exit(8);
        }
    }
  9 close(inFd);
    close(outFd);
    return (0);
}

Listing 15-2:使用原始 I/O 复制一个文件到另一个文件的程序

要使用 Listing 15-2 中的程序,我们必须指定一个输入文件和一个输出文件:

$ ./copy `input-file output-file`

程序首先检查是否提供了正确数量的参数 1。接着,它打开输入文件 2。open 函数的一般形式是 file-descriptor = open(``filename``, flags``)。标志指示文件如何打开。O_RDONLY 标志表示文件以只读模式打开,O_BINARY 标志表示文件是二进制的。O_BINARY 标志是一个有趣的标志(我将在下一节中解释)。

open 命令返回一个称为 文件描述符 的数字。如果发生错误,它返回一个负数,这意味着程序的下一步是检查错误 3。

然后我们使用 O_WRONLY(仅写模式)和 O_CREAT(如果需要则创建文件)标志打开输出文件 4。

额外的 0666 参数表示如果文件被创建,它会处于保护模式。这是一个八进制数字,每一位代表一个保护用户集,每一位代表一种保护类型:

  1. 4 读取

  2. 2 写入

  3. 1 执行

数字的顺序如下:0666 参数告诉系统创建文件,使得用户可以读取和写入它(6),使得与用户同组的账户可以读写(6),并且其他任何人也拥有相同的读写权限(6)。

一旦文件被打开,我们就进行复制 5。read 函数的一般形式是:

`bytes_read` `= read(``fd``,` `buffer``,` `size``);`

其中 fd 是文件描述符,buffer 是接收数据的缓冲区,size 是读取的最大字符数。该函数返回读取的字节数(bytes read),0 表示文件结束(EOF),或者返回负数表示发生错误。

读取后,我们检查是否有错误 6。然后我们检查是否已到达文件末尾 7。如果是,我们就完成了数据传输。

在这一点上,我们肯定已经有一些数据了,因此我们开始写入 8。write 函数的一般形式是:

`bytes_written` `= write(``fd``,` `buffer``,` `size``);`

其中 fd 是文件描述符,buffer 是包含数据的缓冲区,size 是要写入的字符数。该函数返回写入的字节数或返回负数表示发生错误。一旦写入完成,我们关闭文件描述符 9。

使用二进制模式

不幸的是,文本文件在操作系统之间不可移植,因为不同的操作系统使用不同的字符来表示行结束。C 最初是为 Unix 编写的,而 Unix 又启发了 Linux。这两个操作系统都使用换行符(字符编号0x0a)作为行结束符。

假设你打开一个没有O_BINARY标志的文本文件,并且想向其中写入数据。如果你使用以下方式将字符串写入文件:

// Bad style; 3 should be a named constant.
write(fd, "Hi\n", 3);

在 Linux 上,你将得到一个包含三个字符的文件:

48  69  0a
 H   i  \n

其他操作系统必须将行尾序列转换为其本地的行结束符。表 14-1 列出了各种行结束符。

表 15-1:文件行结束符

操作系统 行结束符 字符 转换
Linux 换行符 \n
macOS 回车符 \r 在输出时将\n替换为\r
Windows 回车符、换行符 \r\n 在每个\n前插入\r

如果你在 Windows 上运行 C 程序并执行以下操作:

// Bad style; 3 should be a named constant.
write(fd, "Hi\n", 3);

这与之前的代码相同,写入了四个字符:

48  69  0d  0a
 H   i  \r  \n

然而,有时你会在写入二进制文件时,希望字节0a0a的形式原样写入,而不做任何转换。在 Linux 上,这很简单,因为 Linux 永远不会进行转换。然而,其他操作系统会进行转换,因此它们添加了一个新的O_BINARY标志,告诉库正在使用二进制文件,并跳过文件转换。

Linux 没有O_BINARY标志,因为它不区分二进制文件和文本文件。实际上,你可以拥有一个半二进制/半文本的文件。(我不知道为什么你会想这么做,但 Linux 会允许你这么做。)

我在清单 15-2 中包含了O_BINARY标志,因为我希望复制程序具有可移植性。我们需要在使用 Apple 和 Microsoft 系统时提供O_BINARY模式,但如果我们在 Linux 系统上编译程序,则O_BINARY未定义。

因此,解决方法是如果操作系统的头文件中没有定义该标志,则自己定义它:

#ifndef O_BINARY
#define O_BINARY 0      // Define O_BINARY if not defined.
#endif // O_BINARY

如果操作系统已经定义了O_BINARY,则#define将不会被编译。如果我们使用的是没有O_BINARY的类 Linux 操作系统,#define O_BINARY 0将被编译,并且O_BINARY将被赋值为0,这样就什么都不做——而在 Linux 上,正是“不做任何事情”是我们需要的。

ioctl

除了读取和写入之外,原始 I/O 系统还提供了一个名为ioctl的函数,用于执行 I/O 控制。它的一般形式是:

`result` `= ioctl(``fd``,` `request``,` `parameter``);`

其中fd是文件描述符,request是设备特定的控制请求,parameter是请求的参数。对于大多数请求,如果请求成功,函数返回0,否则返回非零值(某些ioctl调用返回不同的值)。

你可以使用ioctl来弹出可移动媒体、倒带或快进磁带驱动器、设置串口设备的速度和其他参数,以及设置网络设备的地址信息。由于ioctl规范是开放式的,许多功能已经被压缩到这个接口中。

总结

原始 I/O 系统提供了对 I/O 操作的最佳控制。操作系统的编辑或干预最小,但这种控制是有代价的。缓冲 I/O 系统有助于限制你的错误,而原始 I/O 系统则没有。不过,如果你知道自己在做什么,它可以成为一个巨大的资产。

编程问题

  1. 编写一个程序,接受一个参数:运行该程序的人的名字。然后打印Hello <name>。例如:

    ./hello Fred
    Hello Fred
    
    ./hello
    Hello stranger
    
  2. 编写一个程序,扫描参数列表,如果-d是一个参数,则打印调试模式。如果-d缺失,则打印发布模式。还可以添加其他选项。

  3. 测量列表 15-2 中的复制程序复制大文件所花费的时间。现在将缓冲区大小更改为 1。查看程序运行速度。将缓冲区大小更改为 16384。查看程序运行速度。尝试 17000。注意:几乎每个磁盘都是以 512 字节块进行读写的。这个事实如何解释你所看到的时间?

  4. 研究getopt函数并使用它解析你为问题 1 发明的命令行参数。

第十六章:16

浮点数

在本书的这一部分,我们将讨论一些通常在嵌入式编程中不常用的 C 特性,但你可能会在大型机编程中遇到。浮点数在嵌入式编程中并不常见,因为许多低端处理器芯片无法处理它们。即使你使用的 CPU 支持浮点数,浮点运算依然很慢、不精确,而且使用起来比较复杂。

然而,由于你在科学或 3D 图形程序中偶尔会遇到这些数字,因此你应该有所准备。本章涵盖了浮点数的基础知识,为什么浮点运算如此昂贵,以及在使用它们时可能出现的一些错误。

什么是浮点数?

浮点数 是小数点可以浮动的数字。它可以出现在数字的不同位置,例如 1.00.10.00011000.0。严格来说,小数点后面有数字并不是必须的。例如,1.01. 是相同的数字。然而,如果浮点数在小数点两侧都有数字,它更容易阅读和理解。

我们也可以使用指数表示法来书写浮点数,例如 1.0e33,表示数字 1.0 × 10³³。 (你可以使用大写的 E 或小写的 e,但小写版本更易于阅读。)

浮点类型

在 C 中,浮点类型有 floatdoublelong doubledouble 类型的精度和范围是 float(单精度)类型的两倍。long double 的精度和范围比其他两种类型更大。

所有浮点常量默认都是 double 类型,除非你明确告诉 C 其他类型。在数字末尾添加 F 后缀将其变为单精度 float,而在末尾添加 L 会将其变为 long double

浮点数需要小数点。考虑以下代码:

float f1 = 1/3;
float f2 = 1.0 / 3.0;

第一行赋值并不会将 f1 的值赋为 0.3333\。相反,它将其赋值为 0.0,因为 1 和 3 是整数。C 执行了一个 整数除法(结果为整数 0),将其提升为浮点数后再进行赋值。第二行则按我们希望的方式,赋值为 0.3333。

自动转换

C 会在你不注意的情况下进行一些自动转换。如果表达式的一个操作数是浮点数,C 会自动将另一个操作数转换为浮点数。下面是一个例子:

f = 1.0 / 3;    // Bad form

在这种情况下,数字 3 会在除法操作之前被转换为 3.0。这个例子被认为是不好的一种写法,因为如果可以的话,你不应该混合整数和浮点常量。而且,如果你将浮点数赋给一个整数,它会被转换为整数。

浮点数的问题

浮点数的一个问题是它们不精确。例如,1/3 在十进制浮点数中是 0.333333。无论你使用多少位数,它仍然不精确。我们这里不展示二进制浮点数(计算机使用的),而是使用十进制浮点数(人类熟悉的)。所有在十进制浮点数中可能出错的地方,也会在二进制版本中出错。唯一的区别是,十进制浮点数的例子更容易理解。

十进制浮点数是科学记数法的一种简化版本。下面是一个例子:

+1.234e+56

这个数字有符号(+)、一个小数部分(四位)和一个指数。对人类来说这不是问题,但在计算机中表示这样的数字是有难度的。

计算机使用类似的格式,只不过指数和小数部分是二进制的。此外,它们还会将顺序混淆,存储的顺序是符号、指数和小数部分。有关更多细节,请参阅几乎所有计算机当前使用的 IEEE-754 浮点数规范。

四舍五入误差

你知道 1 + 1 是 2,但 1/3 + 1/3 并不是 2/3。让我们来看看这是如何工作的。首先,我们来加上这两个数字:

+3.333e-01    // 1/3 in our notation
+3.333e-01    // 1/3 in our notation
+6.666e-01

然而,2/3 是以下这个:

+6.667e-01

这是一个四舍五入误差的例子。+3.333e-01和 1/3 之间有一个小误差。由于我们使用的标准四舍五入规则,我们将结果向下取整。当我们计算 2/3 时,得到6.67e-1。在这种情况下,四舍五入规则使我们向上取整,因此虽然 1 + 1 = 2(整数),但 1/3 + 1/3 != 2/3(浮点数)。

我们可以使用一些技巧来最小化四舍五入误差。大多数计算机使用的一个技巧是在计算过程中加入保护位。保护位是在进行计算时,给数字加上的一个额外位数。当计算结果出来后,保护位会被丢弃。

精度位数

单精度浮点数(float)应该能提供大约 6.5 位的精度,但这并不总是准确的。你能信任多少位呢?在前面的例子中,我们可能会倾向于认为我们的十进制浮点数的前三位是准确的,但我们不能依赖这一点。

让我们计算 2/3 – 1/3 – 1/3:

+6.667e-01    // 2/3
-3.333e-01    // 1/3
-3.333e-01    // 1/3
+0.001e-01    // Result unnormalized
+1.000e-04    // Result normalized

有多少位是正确的?我们结果的第一个数字是 1(规范化意味着我们将数字改变为使得第一个位置有一个数字。除了少数边缘情况外,所有浮点数都存储为规范化形式,我们稍后会讲解这些边缘情况)。正确的第一个数字应该是 0。

浮点运算的设计中固有许多问题。主要归结为大多数数字都是不精确的,这可能导致计算错误和精确比较时的问题。

如果你只进行了有限量的浮点数运算,它们可能不会咬你,但你应该意识到它们。如果你进行了大量的浮点数运算,你应该查阅计算机科学的一个分支,称为数值分析,专门处理浮点数问题以及如何从中获得稳定的结果,但这超出了本书的范围。

无穷大、NaN 和子规范数

IEEE 浮点格式有一些位模式是没有意义的数字。例如,考虑数字 0*10⁵。由于 0 乘以任何东西都是 0,我们可以在这种情况下使用指数来表示特殊值。在本节中,我们将查看其中的一些以及浮点格式的边缘情况。

考虑以下表达式:

float f = 1.0 / 0.0;

如果这是一个整数,将其除以零会中止你的程序。然而,因为它是浮点数,结果是f被赋予了值INFINITY(这个常量在#include <math.h>头文件中定义)。

同样,该语句:

float f = -1.0 / 0.0;

分配f值为-INFINITY

数字INFINITY-INFINITY不是浮点数(它们没有数字和小数点),但 IEEE 浮点规范已定义了几种这样的特殊数字。由于你可能会遇到这些类型的数字(特别是如果你的程序包含错误),知道它们是什么很重要。

你也可能遇到NaN(不是一个数字),当一个操作无法产生结果时生成。这里是一个例子:

#include <math.h>
float f = sqrt(-1.0);

C 标准的新版本包括复数,但sqrt函数始终返回double,因此sqrt(-1.0)始终返回NaN

现在,我们能在我们的浮点方案中表示的最小数字是多少?你可能会说它是以下内容:

+1.0000e-99

分数 1.0000 是我们可以创建的最小分数。(如果我们使用 0.5000,它将被规范化为 5.0000。)而且,-99 是我们可以得到的最小指数,只用了两位数字。

然而,我们可以变得更小:

+0.1000e-99   // -99 is the limit on the exponent.

而且还要更小:

+0.0001e-99

到目前为止,我们讨论的数字都是规范化的,这意味着一个数字始终位于第一位。这些数字被认为是子规范的。我们还失去了一些有效位数。我们有五个有效位数的数字+1.2345e-99,但只有一个有效位数的+0.0001e-99

在 C 中,isnormal宏在数字规范化时返回 true,并且issubnormal宏在数字是子规范化时返回 true。

如果你遇到了子规范化的数字,你已经进入了 C 浮点的最黑暗的角落。到目前为止,我还没有看到任何真正使用它们的程序,但它们确实存在,你应该意识到它们。

实施

浮点数可以用多种方式实现。让我们从我们一直在使用的 STM 芯片开始。实现很简单:你不能有浮点数。硬件不支持,而且机器没有足够的能力在软件中实现它。

低端芯片通常没有浮点单元。因此,浮点运算是通过使用软件库来实现的,这会带来一定的代价。通常,浮点运算的时间大约是整数运算的 1,000 倍。

一旦你使用了更高端的芯片,你会发现有原生的浮点数支持。虽然这些运算依然昂贵;一个浮点运算大约需要比整数运算长 10 倍的时间。

替代方案

处理浮点数的最佳方法之一是根本不使用它。如前所述,举个例子,当处理货币时。如果你将货币存储为浮点数,四舍五入误差最终会导致你得出错误的总额。如果你将货币存储为整数形式的分数,你就能避免浮点数及其所有问题。

让我们定义一个简单的定点数,规定小数点后有 2 位数字。以下是一些示例和整数实现:

Fixed point    Implementation
12.34          1234
00.01             1
12.00          1200

要加减定点数,只需加减底层实现即可:

 12.34         1234
+22.22        +2222
------        -----
 34.56         2346

 98.76         9876
-11.11        -1111
------         ------
 87.65         8765 

要乘以定点数,先将两个数相乘,然后除以 100 以修正小数点的位置:

 12.00           1200
x 00.50         x 0050
                  60000 (Uncorrected)
 ------          ------
x 06.00            0600 (After 100 correction)

要进行除法,你需要做相反的操作:除以底层数字,并乘以一个修正值。

列表 15-1 包含了一个演示定点数使用的程序。

fixed.c

/**
 * Demonstrate fixed-point numbers.
 */
#include <stdio.h>

/**
 * Our fixed-point numbers have the form
 * of xxxxx.xx with two digits to the right
 * of the decimal place.
 */
typedef long int fixedPoint;            // Fixed-point data type
static const int FIXED_FACTOR = 100;    // Adjustment factor for fixed point
/**
 * Add two fixed-point numbers.
 *
 * @param f1 First number to add
 * @param f2 Second number to add
 * @returns f1+f2
 */
static inline fixedPoint fixedAdd(const fixedPoint f1, const fixedPoint f2)
{
    return (f1+f2);
}
/**
 * Subtract two fixed-point numbers.
 *
 * @param f1 First number to subtract
 * @param f2 Second number to subtract
 * @returns f1-f2
 */
static inline fixedPoint fixedSubtract(
    const fixedPoint f1, 
    const fixedPoint f2)
{
    return (f1-f2);
}
/**
 * Multiply two fixed-point numbers.
 *
 * @param f1 First number to multiply
 * @param f2 Second number to multiply
 * @returns f1*f2
 */
static inline fixedPoint fixedMultiply(
    const fixedPoint f1,
    const fixedPoint f2)
{
    return ((f1*f2)/FIXED_FACTOR);
}
/**
 * Divide two fixed-point numbers.
 *
 * @param f1 First number to divide
 * @param f2 Second number to divide
 * @returns f1/f2
 */
static inline fixedPoint fixedDivide(
    const fixedPoint f1,
    const fixedPoint f2)
{
    return ((f1*FIXED_FACTOR) / f2);
}
/**
 * Turn a fixed-point number into a floating one (for printing).
 *
 * @param f1 Fixed-point number
 * @returns Floating-point number
 */
static inline double fixedToFloat(const fixedPoint f1)
{
    return (((double)f1) / ((double)FIXED_FACTOR));
}
/**
 * Turn a floating-point number into a fixed one.
 *
 * @param f1 Floating-point number
 * @returns Fixed-point number
 */
static inline fixedPoint floatToFixed(const double f1)
{
    return (f1 * ((double)FIXED_FACTOR));
}

int main()
{
    fixedPoint f1 = floatToFixed(1.2);  // A fixed-point number
    fixedPoint f2 = floatToFixed(3.4);  // Another fixed-point number

    printf("f1 = %.2f\n", fixedToFloat(f1));
    printf("f2 = %.2f\n", fixedToFloat(f2));
    printf("f1+f2 = %.2f\n", fixedToFloat(fixedAdd(f1, f2)));
    printf("f2-f1 = %.2f\n", fixedToFloat(fixedSubtract(f2, f1)));
    printf("f1*f2 = %.2f\n", fixedToFloat(fixedMultiply(f1, f2)));
    printf("f2/f1 = %.2f\n", fixedToFloat(fixedDivide(f1, f2)));
    return (0);
}

列表 16-1:使用定点数

这不是一个完美的实现。在某些地方,例如乘法和除法运算,可能会出现四舍五入误差,但如果你真的精通定点数,你应该能够轻松发现它们。

总结

理解浮点数的底层实现和限制非常重要。正如之前提到的,你永远不应该将浮点数用于货币。会计人员需要精确的数字,而四舍五入误差可能导致错误的结果。计算机科学中的数值分析分支负责分析如何进行计算,并找出如何最小化误差。本章向你展示了基础知识。如果你要广泛使用浮点数,你应该具备一定的数值分析知识。然而,使用浮点数的最佳方式是完全避免使用它,因此请确保你理解,浮点数有替代方案,例如定点数。

维基百科有一篇关于 IEEE 浮点标准的好文章,并提供了大量在线参考材料:en.wikipedia.org/wiki/IEEE_754

编程问题

  1. 编写一个计算角度sin值的函数。为了得到准确的结果,你需要计算多少个因子?

  2. 使用float类型,计算π的尽可能多的数字。如果你将数据类型改为double,你能得到多少位数字?long double呢?

  3. 假设你想找出浮点数小数部分的位数。编写一个程序,从x = 1 开始,并不断将x除以 2,直到(1.0 + x = 1.0)。你除以 2 的次数就是浮点计算中位数的数量。

第十七章:模块化编程

到目前为止,我们一直在处理简单的小型单文件程序,如果你只是为了写书中的示例程序,这样是可以的。然而,在现实世界中,你可能会遇到包含超过 50 行代码的程序。

Linux 内核有 33,000 个文件和 2800 万行代码(而且这些数字还在不断增加,随着你阅读本文,它们会继续增加)。你不可能在没有将代码组织成模块的情况下处理这么大量的信息。

理想的模块是一个包含数据和函数集合的单一文件,它能够做好一件事,并且与其他模块的交互最小化。我们之前在本书中使用过 STM HAL 模块集合,包括包含HAL_Init函数的模块。它在内部做了大量的工作,但我们从未看到它。我们只看到了一个做一件事的简单模块:它初始化所有需要的硬件,使其能够工作。

简单模块

让我们创建一个使用两个文件的程序。主程序将被命名为main.c(见清单 17-1),并将调用func.c文件中的一个函数(见清单 17-2)。我们将使用一个 makefile(见清单 17-3)将这两个文件编译成一个程序。

main.c

/**
 * Demonstrate the use of extern.
 * @note: Oversimplifies things.
 */
#include <stdio.h>

1 extern void funct(void);        // An external function

int main()
{
    printf("In main()\n");
    funct();
    return (0);
}

清单 17-1:主程序

第一个需要注意的点是funct函数在main.c中的声明。extern关键字告诉 C 语言这个函数在另一个名为func.c的文件中定义。清单 17-2 包含了该文件。

func.c

/**
 * Demonstration of a function module
 */
#include <stdio.h>
/**
 * Demonstration function
 */
1 void funct(void)
{
    printf("In funct()\n");
}

清单 17-2:定义该函数的文件

funct函数在func.c文件中定义,并且清单 17-3 中的 makefile 处理编译。

main: main.c func.c
      gcc -g -Wall -Wextra -o main main.c func.c

清单 17-3:simple/Makefile

makefile 的第一行告诉make,如果main.cfunc.c发生变化,目标main必须重新构建。第二行告诉make,当其中一个文件发生变化时,它应该编译两个文件并用它们来生成程序。

在这个例子中,我们将一个函数和一个主程序放在了两个不同的文件中。然后我们告诉make让编译器将它们组合成一个程序。这是模块化编程的一个过于简化的版本,但这些基本原则在更复杂的程序中也有应用,尤其是那些包含更多和更复杂模块的程序。

简单模块的问题

前面的例子有一些问题。第一个问题是相同的信息被重复了两次。

main.c中,我们有以下内容:

extern void funct(void);        // An external function

func.c中,我们有以下内容:

void funct(void)

这意味着如果我们更改一个文件,我们必须更改另一个文件。

更糟糕的是,C 不检查跨文件的类型,这意味着可能会有如下代码:

// File a.c
extern uint32_t flag;    // A flag

并且:

// File b.c
int16_t flag;        // A flag

在两个不同的文件中。假设文件 a.c 决定将 flag 设置为零。程序将会把 32 位的零存储到 flag 中,而 flag 在文件 b.c 中定义为只有 16 位长。实际发生的情况是,16 位将被存储到 flag 中,另 16 位将存储到其他地方。结果是,程序将发生意外的、令人惊讶的并且难以调试的问题。

在一个文件中可以声明一个变量为 extern,并在之后的地方不再使用 extern 声明。C 会检查确保 extern 定义中的类型与实际声明中的类型匹配:

#include <stdint.h>
extern uint32_t flag;     // A flag
int16_t flag;             // A flag

编译此代码将导致错误:

16.bad.c:3:9: error: conflicting types for 'flag'
 int16_t flag;  // A flag

16.bad.c:2:17: note: previous declaration of 'flag' was here
 extern uint32_t flag; // A flag

关于第二个问题,假设我们想要在多个文件中使用我们的外部函数 funct。我们是否希望在每个文件中都添加一个 extern 声明?那样的话,funct 的定义将在多个地方重复出现(而且不会被编译器检查)。

解决方法是创建一个头文件来保存 extern 定义。清单 17-4 包含了这个文件。

func.h

#ifndef __FUNC_H__
#define __FUNC_H__
1 extern void funct(void);
#endif // __FUNC_H__

清单 17-4:定义函数的文件

除了函数定义 1,这个文件还包含了 双重包含保护#findef/#endif 对可以防止程序出现类似以下的情况:

#include "func.h"
#include "func.h"

这将导致 func.h 中的定义被定义两次,这对于 extern 声明并不是问题,但如果涉及到多个 #define 实例,编译器会感到困扰。

这个例子看起来有些傻,因为在实际程序中,问题并不是那么显而易见。你可能会遇到类似以下的情况:

#include "database.h"
#include "service.h"

但是 database.h 文件包含了 direct_io.h 文件,后者又包含了 func.h,而 service.h 文件包含了 network.h 文件,后者也包含了 func.h。你会发现 func.h 被包含了两次,尽管你是通过绕远路去做的。

这些示例中 #include 语句的格式略有变化,不再是:

#include <file.h>

它是这样的:

#include "file.h"

引号表示要包含的文件是用户生成的文件。编译器将会在当前目录中查找它,而不是通过系统文件进行查找。

清单 17-5 包含了改进版的 main.c,它使用包含文件来引入 extern 声明。

good/main.c

/**
 * Demonstrate the use of extern.
 */
#include <stdio.h>
 #include "func.h"

int main()
{
    printf("In main()\n");
    funct();
    return (0);
}

清单 17-5:改进版 main.c

清单 17-6 包含改进版的 func.c,该文件包括 func.hexternfunc.h 中定义的函数实际上并不需要用来编译 func.c,但是通过引入它们,我们可以确保 extern 与实际的函数声明相匹配。

good/func.c

/**
 * Demonstration of a function module
 */
#include <stdio.h>
#include "func.h"
/**
 * Demonstration function
 */
void funct(void)
{
    printf("In funct()\n");
}

清单 17-6:改进版 func.c

通过将 func.h 文件包含两次,我们解决了 extern 与实际声明不匹配时可能发生的问题。在 func.c 中包含它可以让编译器检查函数定义,而在 main.c 中包含它则为我们提供了函数的定义。

构建模块

这个程序的 makefile 也发生了变化(见 Listing 17-7)。

CFLAGS = -g -Wall -Wextra

OBJS = main.o func.o

1 main: $(OBJS)
        gcc -g -Wall -Wextra -o main $(OBJS)

main.o: main.c func.h

func.o: func.c func.h

Listing 17-7: 改进版Makefile

第一行定义了一个名为CFLAGS的宏,这是编译 C 程序时使用的特定名称。下一行定义了另一个名为OBJS的宏(这个名称没有特殊含义),它包含了我们用来生成程序的对象列表。在这个例子中,我们将编译main.cmain.o目标文件,并将func.c编译为func.o目标文件。

我们在这里使用宏是为了避免在下一个规则 1 中重复写出列表,这个规则告诉makemain.cfunc.h创建main.o。然而,这个规则后面并没有跟着一个规则来告诉make如何做。当make没有规则时,它会回退到内置规则的列表中。当我们从.c文件创建.o(或.obj)文件时,那个内置规则是:

$(CC) $(CFLAGS) -c `file.c`

其中CC是包含 C 编译器名称的宏(在这里是cc,它是gcc的别名)。

这个例子展示了一个简单的模块化程序,但当程序有更多模块时,设计模式同样适用。

什么构成好的模块

以下列表列出了制作好模块的一些规则:

  • 每个模块应该有一个与模块同名的头文件。该文件应包含该模块中公共类型、变量和函数的定义(并且没有其他内容)。

  • 每个模块应包含自己的头文件,以便 C 可以检查头文件和实现是否匹配。

  • 模块应包含用于共同目的的代码,并且它们应该向外界暴露最少的信息。它们通过extern声明暴露的信息是全局的(程序中的所有部分都可以看到),正如下一节所述,这有时会成为一个问题。

命名空间

C 语言的一个问题是它没有命名空间。例如,在 C++中,你可以告诉编译器某个模块中的所有符号都属于db命名空间,这样你就可以创建一个模块,其中的条目如insertdeletequery,在其他人看来分别就是db::insertdb::deletedb::query

在 C 中,如果你定义了一个名为Init的公共函数,其他人不能在任何模块中再定义一个名为Init的函数。如果发生这种情况,链接器会抱怨重复的符号。由于可能有多个项目需要初始化,这可能会成为一个问题。

大多数程序员通过为每个公共函数、类型或变量添加模块前缀来解决这个问题。你可以在 Nucleo 项目中自动添加的 HAL 库中看到这一点。例如,如 Listing 17-8 所示,所有操作 UART 的函数都以UART_前缀开头。

HAL_StatusTypeDef UART_CheckIdleState(UART_HandleTypeDef *huart);
HAL_StatusTypeDef UART_SetConfig(UART_HandleTypeDef *huart);
HAL_StatusTypeDef UART_Transmit_IT(UART_HandleTypeDef *huart);
HAL_StatusTypeDef UART_EndTransmit_IT(UART_HandleTypeDef *huart);
HAL_StatusTypeDef UART_Receive_IT(UART_HandleTypeDef *huart);

Listing 17-8: stm32f0xx_hal_uart.h的摘录

这里的关键是 HAL 库中的公共符号以 HAL_ 开头,这使得我们可以轻松判断一个函数是否属于该库。它还确保你不会不小心使用已经被 HAL 库占用的名称。

当程序中包含的文件少于 20 个时,列出每个文件还不算太麻烦。但一旦文件数量超过 20 个,就会显得有些繁琐,不过还是能管理的,直到数量变得非常庞大。我们一直在编写的主机程序使用的是标准 C 库函数。C 库有超过 1,600 个文件。幸运的是,我们在编译程序时不需要列出所有这些文件。

标准 C 库是一个名为 libc.a 的文件,在程序链接时会自动加载。这个库是由多个目标文件组成的,采用简单的归档格式(因此有 .a 后缀)。

让我们创建一个包含多个模块的库,用来计算不同类型数字的平方。列表 17-9 展示了一个计算浮点数平方的函数。

square_float.c

#include "square_float.h"

/**
 * Square a floating-point number.
 *
 * @param number Number to square
 * @returns The square of the number
 */
float square_float(const float number) {
    return (number * number);
}

列表 17-9:一个用于计算浮点数平方的函数

列表 17-10 是该模块的头文件。

square_float.h

#ifndef __SQUARE_FLOAT_H__
#define __SQUARE_FLOAT_H__
extern float square_float(const float number);
#endif // __SQUARE_FLOAT_H__

列表 17-10:square_float.c 模块的头文件

列表 17-11 定义了一个用于计算整数平方的函数。

square_int.c

#include "square_int.h"

/**
 * Square an integer.
 *
 * @param number Number to square
 * @returns The square of the number
 */
int square_int(const int number) {
    return (number * number);
}

列表 17-11:一个用于计算整数平方的函数

列表 17-12 定义了它的头文件。

square_int.h

#ifndef __SQUARE_INT_H__
#define __SQUARE_INT_H__
extern int square_int(const int number);
#endif // __SQUARE_INT_H__

列表 17-12:square_int.c 的头文件

接下来,列表 17-13 是一个类似的函数,用于计算无符号整数的平方。

square_unsigned.c

#include "square_unsigned.h"

/**
 * Square an unsigned integer.
 *
 * @param number Number to square
 * @returns The square of the number
 */
unsigned int square_unsigned(const unsigned int number) {
    return (number * number);
}

列表 17-13:一个用于计算无符号整数平方的函数

列表 17-14 定义了该头文件。

square_unsigned.h

#ifndef __SQUARE_UNSIGNED_H__
#define __SQUARE_UNSIGNED_H__
extern unsigned int square_unsigned(const unsigned int number);
#endif // __SQUARE_UNSIGNED_H__

列表 17-14:square_unsigned.c 的头文件

我们将把这三个函数放入一个库中。如果用户想使用这个库,他们需要包含所有这些头文件。这将是一项繁重的工作。

为了简化操作,我们将为这个库创建一个名为 square.h 的头文件。这个文件整合了前面各个库组件(模块)的独立头文件。因此,使用这个库的人只需要包含 square.h(请参见 列表 17-15),而不需要包含一堆单独的头文件。

square.h

#ifndef __SQUARE_H__
#define __SQUARE_H__
#include "square_float.h"
#include "square_int.h"
#include "square_unsigned.h"
#endif // __SQUARE_H__

列表 17-15:库的头文件

我们现在遵循了每个程序文件一个头文件的风格规则,以及库接口应尽可能简单的风格规则。

接下来,让我们为库创建一个小的测试程序(请参见 列表 17-16)。

square.c

/**
 * Test the square library.
 */
#include <stdio.h>

#include "square.h"

int main()
{
   printf("5 squared is %d\n", square_int(5));
   printf("5.3 squared is %f\n", square_float(5.3));
   return (0);
}

列表 17-16:库的测试程序

请注意,我们并没有测试库中的所有成员(这一点稍后会很重要)。

现在我们已经有了库的源文件,接下来需要将它们转化为实际的库。如前所述,库是一个归档格式的目标文件集合,类似于一个 .zip 文件,只不过没有压缩。

在这种情况下,我们将通过 square_float.osquare_int.osquare_unsigned.o 文件创建 libsquare.a 文件(即库文件本身)。

make 程序非常智能,能够更新归档文件的组件。例如,libsquare.a 的一个组件是 square_int.o。以下规则将其作为库的一个组件:

libsquare.a(square_int.o): square_int.o
        ar crU libsquare.a square_int.o

第一行告诉 make 我们正在创建或更新 libsquare.a 库中的 square_int.o 组件。此组件依赖于 square_int.o 目标文件。

第二行是实际添加库的命令。c 选项告诉 ar 在归档文件不存在时创建它。r 使得 ar 在归档文件中创建或替换 square_int.o 组件。U 标志告诉 ar 以非确定性模式运行,这样会将文件的创建时间存储在归档文件中(我们将在本章后面讨论确定性模式和非确定性模式)。该命令之后是库的名称 (libsquare.a) 和要添加或替换的组件名称 (square_int.o)。链接器设置了命名规范,库文件名必须以 lib 开头,以 .a 结尾(更多关于命名规范的内容将在后面讨论)。

接下来,使用以下指令,我们告诉 make 应该用哪些组件来构成 libsquare.a 库:

libsquare.a: libsquare.a(square_int.o) \
        libsquare.a(square_float.o)

libsquare.a(square_unsigned.o)
        ranlib libsquare.a

前两行告诉 make 用哪些组件来创建 libsquare.a。第三行,ranlib libsquare.a,告诉 make 在安装完所有组件后运行名为 ranlib 的程序,生成归档文件的目录表。

ranlib 和库链接

我们使用 ranlib 的原因是早期的链接器。假设我们有一个包含 a.o(定义了 a_funct)、b.o(定义了 b_funct)和 c.o(定义了 c_funct)的归档文件,而程序需要 b.o 中的某个函数。链接器将打开归档文件,并按顺序检查每个成员是否需要,决策过程如下:

  1. 查看未定义符号的列表(程序使用了 b_funct,所以它未定义)。

  2. 打开归档文件。

  3. 查看 a.o。它定义了需要的符号吗?没有。不要加载它。

  4. 查看 b.o。它定义了需要的符号吗?是的。加载它。

  5. 查看 c.o。它定义了需要的符号吗?没有。不要加载它。

现在假设 b.o 需要 a_funct 函数。链接器不会回过头重新检查归档文件,而是继续查看 c.o。由于 c.o 没有定义该符号,因此不会被加载。链接器会到达归档文件的末尾并中止,因为它没有找到满足 a_funct 需求的目标文件。

由于链接器的工作方式,有时你需要指定同一个库两次或三次。为了解决这个问题,归档文件中增加了目录表,以便组件可以随机顺序加载(因此有了 ranlib 的名字)。

现在加载组件的算法如下:

  1. 查看未定义符号的列表(程序使用了b_funct,所以它是未定义的)。

  2. 打开档案。

  3. 我们是否有一个未定义的符号在目录中?

  4. 如果有,加载它。

  5. 重复此过程,直到我们没有更多可以由此库满足的符号。

这个过程解决了排序问题,因为目录使得一切都能被访问。

以下命令实际上将库与我们的程序链接在一起:

square: square.o libsquare.a
        $(CC) $(CFLAGS) -o square square.o -L. -lsquare

-L.选项告诉链接器在当前目录( .)中查找库文件。否则,它只会搜索系统库目录。库本身通过-lsquare指令来指定。链接器首先在当前目录(因为有-L.)查找名为libsquare.a的库,然后在系统目录中查找。

清单 17-17 显示了这个项目的完整 makefile。

CFLAGS=-g -Wall -Wextra

all: square

square: square.o libsquare.a
        $(CC) $(CFLAGS) -o square square.o -L. -lsquare

libsquare.a: libsquare.a(square_int.o) \
        libsquare.a(square_float.o) libsquare.a(square_unsigned.o)
        ranlib libsquare.a

libsquare.a(square_int.o): square_int.o
       ar crU libsquare.a square_int.o

libsquare.a(square_float.o): square_float.o
        ar crU libsquare.a square_float.o

libsquare.a(square_unsigned.o): square_unsigned.o
        ar crU libsquare.a square_unsigned.o

square_int.o: square_int.h square_int.c

square_float.o: square_float.h square_float.c

square_unsigned.o: square_unsigned.h square_unsigned.c

square.o: square_float.h square.h square_int.h square_unsigned.h square.c

清单 17-17:完整的 makefile

因为我们的测试程序没有调用square_unsigned,所以square_unsigned.o模块将不会被链接到我们的程序中。(为了演示链接器如何不链接不需要的目标文件,省略了对square_unsigned的测试。)

确定性库与非确定性库

理想情况下,如果你运行make命令,生成的二进制文件应该是相同的,无论命令在什么时候执行。出于这个原因,最初库文件并没有存储有关谁创建了组件或何时创建的信息。

然而,这给make程序带来了一些困难。如果档案不存储修改日期,make程序如何确定档案中的square_int.o版本是比你刚编译的版本更新还是更旧呢?

ar命令被修改以存储这些信息。因为这个功能破坏了传统功能,ar的维护者决定让存储这些信息成为可选项。如果你指定D选项,修改时间不会被存储,并且你将得到一个确定性档案(每次都是相同的二进制文件)。如果你指定U选项表示非确定性,你每次都会得到一个不同的二进制文件,但这是make程序更喜欢的。默认值是D,即传统格式。

弱符号

到目前为止,我们已经定义了总是被加载的具有函数和变量的模块。换句话说,如果一个模块定义了一个doIt函数,那就是唯一被加载的函数定义。GCC 和大多数其他编译器提供的 C 语言扩展允许使用弱符号。弱符号告诉链接器,“如果没有其他人定义这个符号,就使用我。”

一个使用弱符号的例子是 STM 中断表。你必须为每个可能的中断定义要调用的函数;硬件要求这样做。因此,你必须为那些从不发生的中断编写中断处理程序。由于该函数永远不会被调用,这应该让事情变得简单。

然而,STM 固件的设计理念是,尽管禁用中断的中断路由应该永远不会被调用,但这并不意味着它们永远不会被调用。STM 固件为所有会让系统崩溃的中断定义了中断处理程序。如果它们真的被调用,您的系统会停止,您有机会使用调试器进行分析并尝试找出原因。

唯一的方式使默认的中断处理程序被调用是如果您开启了中断并且没有提供自己的中断处理程序。在这种情况下,默认的中断处理程序会知道出了问题,并且静静地等待您来找出问题所在。

STM 的 USART2 中断处理程序是USART2_IRQHandler,它的定义如下:

void USART2_IRQHandler(void) {
    while(true)
        continue;
}

然而,如果我们自己定义了该函数,固件库中的sub2函数将消失,尽管同一模块中的其他约 40 个中断函数仍然会被加载。

让我们通过自己的代码来看这个过程。在清单 17-18 和 17-19 中,我们有sub1sub2,而sub2被定义了两次(一次在main.c中,另一次在sub.c中)。当链接器查看这两个文件时,它会说:“这里有两个sub2函数。我应该抛出错误吗?不,一个是弱符号,我可以把它丢弃。”在main.c中的sub2将被链接,而在sub.c中的sub2则不会。

首先让我们定义一个主程序,它的任务是调用我们的两个子例程(参见清单 17-18)。

main.c

#include "sub.h"

int main()
{
    sub1();
    sub2();
    return (0);
}

清单 17-18:调用两个子例程的主程序

在清单 17-19 中,我们通过 GCC 扩展告诉编译器sub2sub.c中是弱符号。

sub.c

#include "sub.h"

void sub2(void) __attribute__((weak));

void sub1(void) {}
void sub2(void) {} 

清单 17-19:告诉编译器sub2是弱符号

接下来,我们需要一个头文件,因此让我们创建一个(参见清单 17-20)。

sub.h

#ifndef __SUB_H__
#define __SUB_H__
extern void sub1(void);
extern void sub2(void);
#endif // __SUB_H__

清单 17-20:头文件

最后,我们在清单 17-21 中定义了我们自己的sub2函数。

sub2.c

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

void sub2(void) {
    printf("The non-weak sub2\n");
}

清单 17-21:定义sub2函数

如果我们链接main.csub.c,弱符号sub2将被链接进来。如果我们链接main.csub.csub2.c,则会使用在sub2.c中定义的非弱版本。

这对于像中断例程这样的情况非常有用,您必须定义一个,无论是否使用它。这让您能够提供一个回退或默认版本。

总结

模块使您能够将大型程序拆分成可管理的单元。良好的设计意味着大型程序不需要包含庞大的部分。多个模块可以组织成一个库。库的优势在于它可以包含大量的专用模块,链接器只会链接需要的模块。

良好的编程就是组织信息,而模块和库使您能够将庞大的程序乱象整理成可管理的小单元。

编程问题

  1. 编写一个库来计算几何形状的面积(rectangle_areatriangle_area 等)。每个函数应在自己的目标文件中,所有面积计算函数应合并成一个单独的库。编写一个主程序对这些函数进行单元测试。

  2. 重写前面章节中创建的一个串行输出程序,使所有与 UART 相关的代码都在一个独立的模块中。

  3. 测试一下发生了什么,当:

    1. 你定义了两个弱符号和一个强符号。

    2. 你定义了两个弱符号,没有强符号。

    3. 你定义了两个强符号。

第十八章:后记

本书应该能帮助你顺利入门嵌入式 C 编程,但在裸金属 C之外还有一个全新的世界等着你去探索。最后,以下是一些开始走向专业编程道路的有用建议。

学会如何写作

到目前为止,专业程序员最重要的技能是能够写得好,因为编程过程包括撰写几种类型的文档,如提案、需求、设计文档、用户文档和销售资料。

参加一个创意写作课程,在课程中你的作品会被同学们审阅。我从圣地亚哥写作工作坊获得的反馈是我一生中最宝贵的训练之一。

这里有一个专业小贴士:如果你正在为一个项目编写设计或需求文档,你将拥有引导项目方向的巨大权力。

学会如何阅读

你可以通过“阅读”技术文档学到很多东西。我把“阅读”放在引号里,是因为你不会完全阅读大多数技术文档。你只是浏览它们,找到你当时需要的信息。

以下是我为本书准备过程中所使用的文档列表:

  • ISO/IEC9899:2017,“编程语言:C”(500 页)

  • RM0360 参考手册,“STM32F030x4/x6/x8/xC 和 STM32F070x6/xB 高级 ARM®-基于 32 位 MCU”(800 页)

  • ARM®开发者套件汇编指南版本 1.2, developer.arm.com/documentation/dui0068/b(400 页)

  • “使用 GNU 编译器集合”(GCC 文档,1,000 页)

  • GNU Make 手册, www.gnu.org/software/make/manual(225 页)

我并没有从头到尾阅读这些文档。我可能只用了其中的 0.5%到 15%。我已经熟练使用find命令来定位我感兴趣的文档部分。

合作与创意盗用

无论你多么有经验,你始终只是一个人。通过与他人合作并互相审查工作,你可以扩展自己的知识并帮助他人提升。Linux 内核就是一个很好的例子。成千上万的人分享了他们的代码,收到了反馈,改进了代码,最终将其汇集成世界上最强大的操作系统。

浏览公开的代码,看看其他程序员是如何做的,这也很有帮助。一个例子是 STM32 工作台附带的 HAL 固件。它是专业的代码,并提供了很好的内部文档。(见接下来的“Doxygen”部分。)坏消息是,它设计用于一整套不同的芯片,因此你需要应对 200 个#ifdef的代码块。许多优质源代码是公开的。浏览一下,看看你能学到什么。

有用的开源工具

开源社区有着悠久的历史,开发了许多优质工具来帮助程序的创建和编辑。这些免费工具是由想要使用它们的人开发的,而不是那些添加有助于销售程序但可能不实用的功能的商业软件。此节描述了几款有用的开源工具。

Cppcheck

Cppcheck 程序是一个静态程序检查器。换句话说,它会查找编译器没有发现的程序问题,而现在编译器变得相当复杂,这让检查变得更具挑战性。

这个程序帮助我找到了我最喜欢的一段 C 代码。这是错误信息:

Detect matching 'if' and 'else if' conditions

换句话说,if语句和else语句的主体包含了相同的代码。如果条件成立或不成立时计算机执行相同的操作,那么这个条件是没有必要的。

下面是相关代码:

if (flag) {
    //(removed) processMessage()
} else {
    //(removed) processMessage()
}

没错,if语句的主体是一个被注释掉的过程调用,而else语句的主体则是相同的被注释掉的过程调用!

我更倾向于使用 Cppcheck,而不是大多数商业静态程序分析器,因为它生成的假阳性最少。如果 Cppcheck 标记了你代码中的某一行,最好检查一下那段代码。

Doxygen

自从第一段程序的代码超过 50 行以来,人们就一直尝试开发能够自我生成文档的编程语言。到目前为止,他们还没有成功。Doxygen 的开发者创建了一个系统,通过在程序中添加结构化注释,你可以为程序生成文档(至少在理论上是如此)。

第一个困难是你必须在代码中添加注释,而许多工程师根本没有为他们的代码添加注释。另一个问题是,文档看起来像是自动生成的。尽管自动生成的文档其实很好,但它终究是自动生成的文档。

上一节中提到的 Cppcheck 程序使用 Doxygen 进行其内部文档生成。

Valgrind

Valgrind 项目是一套工具,旨在动态检查你的程序中的错误。该套件中最受欢迎的工具之一是 memcheck,它用于查找诸如数组溢出、已释放的指针被再次使用等内存错误。它在检测大多数简单的运行时错误时非常有效。

SQLite

SQLite 是一个嵌入式数据库库,适用于小型数据库(最多 10 万条记录)。它不是世界上最快的数据库,但对于不想使用大型数据库所带来开销的小型嵌入式系统来说,它运行良好。你需要学习 SQL 来使用它,但这是一项值得学习的技能。

永不停止学习

计算机技术正在以前所未有的速度变化。我们在本书中使用的那款 38¢的 STM 微芯片,其性能超过了 1950 年世界上所有计算机的总和。技术永远不会停止变化,因此你应该不断学习。你永远不知道转角处会有什么新发现等着你。

附录

项目创建检查表

本地 C 项目

对于在 PC 上运行的程序,启动 STM32 工作台,如有需要,选择工作区,然后完成以下步骤:

  1. 在主窗口中,选择文件新建C/C++ 项目

  2. 在 C/C++ 项目窗口中,选择C 管理构建并点击下一步

  3. 在 C 项目窗口:

    1. 填写名称(不要有空格或特殊字符)。

    2. 在项目类型列中,选择可执行文件空项目

    3. 在工具链列中,选择适合你本地系统的工具链。例如,如果你使用的是 Windows 和 Visual C++,则选择 Visual C++。如果你使用的是 Linux,则选择 Linux C 编译器 GNU gcc。

    4. 点击下一步

  4. 在配置窗口:

    1. 取消勾选发布

    2. 调试应保持勾选状态。

    3. 点击完成

  5. 在主窗口:

    1. 在项目资源管理器(左列)中,选择项目。

    *** 选择文件新建源文件

*** 在源文件窗口:

1.  在源文件字段中,输入你的程序文件名(以 *.c* 结尾)。

1.  点击**完成**。*   返回主窗口(文件显示在编辑窗格中):

1.  输入程序。

1.  选择**文件**▶**全部保存**。

1.  选择**项目**▶**构建项目**。

1.  选择**运行**▶**运行配置**。*   在创建、管理和运行配置窗口:

1.  在左列中,选择**C/C++ 应用程序**。

1.  点击**新建**。

1.  在 C/C++ 应用程序下,点击**浏览**。*   在文件对话框中:

1.  进入你的工作区并打开项目文件夹。

1.  在项目文件夹中,打开*调试*目录。

1.  选择你的程序可执行文件。

1.  点击**确定**。*   返回到创建、管理和运行配置窗口:

1.  点击**应用**。

1.  点击**关闭**。**

现在你可以运行和调试你的程序。

STM32 工作台嵌入式项目

对于在 Nucleo 板上运行的程序,启动 STM32 工作台,如有需要,选择工作区,然后完成以下步骤:

  1. 在主窗口中,选择文件新建C 项目

  2. 在 C 项目窗口:

    1. 填写名称(不要有空格或特殊字符)。

    2. 在项目类型列中,选择可执行文件空项目

    3. 在工具链列中,选择Ac6 STM32 MCU GCC

    4. 点击下一步

  3. 在配置窗口:

    1. 取消勾选发布

    2. 调试应保持勾选状态。

    3. 点击完成

  4. 在目标配置窗口:

    1. 对于系列,选择STM32F0

    2. 对于板卡,选择NUCLEO-F030R8

    3. 点击下一步

  5. 在项目固件配置窗口:

    1. 点击硬件抽象层(Cube HAL)

    2. 下载目标固件(仅需一次)。

    3. 点击完成

  6. 回到主窗口:

    1. 在项目资源管理器(左列)中,选择项目。

    *** 选择项目属性

*** 在属性窗口:

1.  展开**C++ 构建**。

1.  进入**设置**。

1.  进入**工具设置**标签。

1.  选择**MCU GCC 编译器**▶**调试**。

1.  调试级别:**最大(-g3)**。

1.  选择**MCU GCC 编译器**▶**其他**。

1.  其他标志:-`fmessage-length=0 0Wa,adhls=$(@:%.o=%.lst)`。

1.  打开**Listings**。

1.  点击**应用**。

1.  点击**确定**。*   在主窗口:

1.  在项目资源管理器(左侧栏)中,选择项目。

***展开项目。*   展开*src*目录。*   点击*main.c*进行编辑。*   输入程序。*   选择**文件**▶**全部保存**。*   选择**项目**▶**构建项目**。*   选择**项目**▶**运行**或**项目**▶**调试**。******
posted @ 2025-11-25 17:06  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报