C-极简编程-全-

C 极简编程(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

在新的 JavaScript 框架几乎每天都在涌现的世界里,为什么你会深入研究像 C 这样老旧、基础的语言呢?好吧,首先,如果你希望跟上所有这些框架的潮流(哎呀,意见警告),你可能需要了解一些提供了许多“现代”语言基础的老旧、基础技术。你是否在像TIOBE这样的网站上查看过流行的编程语言,发现 C 语言一直稳居榜首?也许你对于先进的视频卡感兴趣,并想了解驱动它们的软件如何工作。或者你正在探索像 Arduino 这样的新型、小型设备,并听说 C 语言是最适合的工具。

不管原因是什么,很高兴你能在这里。顺便说一句,所有这些理由都是合理的。C 语言是一门基础性语言,了解其语法和特性将使你拥有非常长久的计算机语言素养,有助于更轻松地掌握新的语言和风格。在撰写设备驱动程序或操作系统的低级代码时,C 语言(以及它的姐妹语言 C++)仍然广泛使用。而物联网正为资源有限的微控制器注入新生命。因此,C 语言非常适合在这些小型环境中发挥作用。

尽管我将专注于为这些小型、资源有限的机器编写干净、紧凑的代码,但我仍将从计算机编程的基础开始,并介绍适用于 C 语言的各种规则和模式。

如何使用本书

本书旨在覆盖所有适用于上述任何情况的良好 C 编程基础。我们将研究控制结构、运算符、函数以及 C 语法的其他元素,以及能够减少编译程序大小的替代模式示例。我们还将探讨 Arduino 环境作为精简 C 代码的理想应用。为了最好地享受 Arduino 部分,你应该具备一些基本的电路构建经验,并且能够使用 LED 和电阻等元件。

这里是各章的预览:

第一章,C 语言的 ABC

对 C 语言历史的简要回顾以及设置开发环境的步骤。

第二章,存储和声明

介绍了 C 语言中的语句,包括基本 I/O、变量和运算符。

第三章,控制流

在这里,我将介绍分支和循环语句,并深入探讨变量及其作用域。

第四章,位和(许多)字节

快速回顾数据存储。我会展示 C 语言用于操作单个位和在数组中存储大量更大数据的设施。

第五章,函数

我会看如何将你的代码分解成可管理的块。

第六章,指针和引用

进一步进行,我创建更复杂的数据结构,并学习如何将它们传递给函数并从函数中返回。

第七章,

学习如何找到并使用可以帮助您完成常见或复杂任务的流行代码片段。

第八章,使用 Arduino 的真实世界 C

真正的乐趣开始了!我们将设置 Arduino 开发环境并让一些 LED 闪烁。

第九章,更小系统

通过完整的 Arduino 项目尝试几个电子外设,包括传感器、按钮和 LCD 显示器。

第十章,更快的代码

学习一些编写代码的技巧,特别设计以帮助小处理器充分利用其资源。

第十一章,自定义库

借助编写友好且文档完备的库的技巧和技巧,构建您的 C 库技能,与 Arduino IDE 兼容。

第十二章,下一步

尝试一个快速的物联网项目,附带一些思考和一些关于在继续改进您的精益编码技能时尝试的想法。

附录包括一系列方便的链接,指向我使用的硬件和软件,以及有关下载和配置本书中显示的 C 和 Arduino 示例的信息。

本书使用的约定

本书中使用以下排版约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

常量宽度

用于程序清单,以及在段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

常量宽度粗体

显示用户应按字面意义键入的命令或其他文本。

常量宽度斜体

显示应由用户提供值或由上下文确定的值替换的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般说明。

警告

此元素表示警告或注意事项。

使用代码示例

本书中的许多代码示例非常简洁,您经常会受益于手动输入它们。但这并不总是有趣,有时您希望从已知的工作副本开始并修改内容。您可以从 GitHub 上获取所有示例的源代码,网址为https://github.com/l0y/smallerc。附录 A 提供了有关下载代码和设置文件以供开发环境使用的详细说明。

如果您有技术问题或使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,您可以在程序和文档中使用本书提供的示例代码,无需征得我们的许可,除非您复制了大量代码。例如,编写一个使用本书多个代码片段的程序并不需要许可。销售或分发来自 O’Reilly 图书的示例则需要许可。引用本书并引用示例代码回答问题则不需要许可。将本书大量示例代码整合到产品文档中则需要许可。

我们感谢,但通常不需要署名。署名通常包括标题、作者、出版商和 ISBN。例如:“Smaller C by Marc Loy (O’Reilly). Copyright 2021 Marc Loy, 978-1-098-10033-9.”

如果您认为您使用的代码示例超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com 联系我们。

O’Reilly 在线学习

注意

超过 40 年来,O’Reilly Media 提供技术和商业培训,为企业成功提供知识和见解。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、互动编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • O’Reilly Media, Inc.

  • 1005 Gravenstein Highway North

  • Sebastopol, CA 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或当地)

  • 707-829-0104(传真)

我们为本书设有一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/smaller-c

发送电子邮件至bookquestions@oreilly.com 评论或询问有关本书的技术问题。

有关我们图书和课程的新闻和信息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://youtube.com/oreillymedia

致谢

我要感谢艾米莉亚·布莱文斯(Amelia Blevins)在出版过程中的悉心引导。她的项目管理技能仅次于她通过巧妙建议改进我的写作。同样感谢阿曼达·奎因(Amanda Quinn)和苏珊·麦克奎德(Suzanne McQuade)在一开始就帮助我推动项目,以及丹尼·埃尔方鲍姆(Danny Elfanbaum)提供的出色技术支持。O’Reilly 的整个团队无与伦比。

我的技术审阅者们带来了广泛的专业知识,我感到非常感激他们提供的反馈。托尼·克劳福德(Tony Crawford)调整了我的 C 语言讨论,我衷心推荐您阅读他的书籍:C in a Nutshell。亚历克斯·法伯(Alex Faber)在多个平台上运行了书中的每个示例,并确保我牢记新程序员的需求。埃里克·范胡斯(Eric Van Hoose)使我的写作更加清晰,并帮助集中整本书的内容流程。海姆·克劳斯(Chaim Krause)在最后一刻提供了帮助,并指出了一些需要填补的空白。

特别感谢我的丈夫罗恩(Ron)在言辞建议和全面道义支持方面的贡献。雷格·戴克(Reg Dyck)也给予了我一些宝贵的鼓励。如果你真的想要深入学习一个主题,试着向像雷格和罗恩这样的朋友和家人解释它。尽管两位先生对编程或电子技术并不感兴趣,但他们友好的提问帮助我梳理了许多困难话题的核心内容。

第一章《C 语言的基础知识》

C 是一种强大的语言。它是过程式的(意味着你大部分编码工作是通过过程完成的),并且是编译的(意味着你编写的代码必须通过编译器翻译才能被计算机使用)。你可以在任何能够编辑文本文件的地方编写你的过程,也可以将这些过程编译成可以运行在从超级计算机到最小的嵌入式控制器的任何设备上的代码。这是一门出色的、成熟的语言——我很高兴你正在学习它!

C 已经存在了相当长的时间:它是在 20 世纪 70 年代初由贝尔实验室的丹尼斯·里奇开发的。你可能听说过他,他是与布莱恩·克尼根(Pearson)合著的经典 C 编程书籍《C 程序设计语言》的作者之一。(如果在编程界看到或听到或阅读到“K&R”这个词组,那就是在提到这本书。)作为一种通用的过程式语言,C 旨在使程序员能够连接他们的程序将要运行的硬件,因此在贝尔实验室之外的学术和工业机构中流行起来,用于运行越来越多的计算机,并且仍然是一种可行的系统编程语言。

像所有语言一样,C 是不静态的。凭借将近 50 年的发展历程,C 经历了许多变化,并衍生出大量其他语言。你可以看到它在诸如 Java 和 Perl 等语言的语法中的影响。事实上,C 的一些元素如此普遍,以至于你会在旨在代表“任何”语言的伪代码示例中看到它的身影。

随着 C 的流行,有必要组织和标准化其语法和特性。本书的第一部分将关注由国际标准化组织(ISO)定义的标准 C,我们编写的代码将可移植到任何平台上的任何 C 编译器。本书的后半部分将专注于将 C 与特定硬件(如 Arduino 微控制器)结合使用。

优缺点

当今,要用计算机解决实际问题,使用高级语言是必须的。C 在编译为实际硬件时,能够在代码可读性和性能之间取得良好平衡。C 具有直观的代码结构和丰富的有用运算符。(这些特性已经传播到许多后续语言中,并使其成为微控制器上精简代码的良好选择。)C 还允许你将问题分解为较小的子问题。你可以像人类一样推理代码(及其不可避免的错误),这非常方便。

C 也有其不足之处。例如,C 并不具备像 Java 那样的自动内存垃圾收集等一些现代语言中的花哨特性。许多现代语言为了降低程序员的编程负担,把大部分细节都隐藏起来,虽然会稍稍牺牲一些性能。C 要求你在分配和管理内存等资源时更加谨慎。有时这种要求会感觉很烦人。

C 语言还允许你编写一些相当引人注目的错误。它没有类型安全性,实际上没有任何安全检查。同样,作为程序员,这种无干扰的方法意味着你可以编写聪明高效的代码,非常适合硬件运行。但这也意味着,如果你出错了,你就需要找到并解决问题。(像 linter 和 debugger 这样的工具会有所帮助;我们在学习过程中一定会仔细研究它们。)

开始

那么我们如何开始呢?和任何编译语言一样,我们首先需要一个包含一些有效 C 指令的文件。然后,我们需要一个可以翻译这些指令的编译器。只要文件中有正确的内容,并且有适合你电脑硬件的编译器,你可以在几分钟内运行一个 C 程序。

如果你花了一些时间学习任何计算机语言,你可能熟悉“Hello, World”程序的概念。这是一个令人钦佩的简单想法:创建一个小程序,一举证明几件事情。它证明你可以用该语言编写有效的代码。它证明你的编译器或解释器有效。它还证明你可以生成可见的输出,这对人类来说非常方便。让我们开始吧!

所需工具

如今,人们使用计算机进行各种各样的任务。娱乐,比如游戏和流媒体视频,占用了与商业生产工作或甚至应用程序开发一样多(如果不是更多)的 CPU 周期。由于计算机用于消费和生产,几乎没有系统预装了用于应用程序开发等任务所需的工具。幸运的是,这些工具是免费提供的,但你必须亲自获取并设置它们以在你的系统上运行。

如我之前所提到的,本书专注于编写干净高效的 C 代码。在我们的示例中,我尽力避免使用过于巧妙的模式。我也努力确保示例不依赖于特定的编译器或特定的开发平台。为此,我将使用任何软件开发所需的最小配置:一个好的编辑器和一个好的编译器。¹

如果你习惯在网上寻找软件并且想要立即开始,我们将安装Visual Studio Code(通常简称为“VS Code”)作为我们的编辑器,以及从 GNU 基金会获取的GNU 开发者工具来处理编译。更多链接和详细信息请参考,但安装完这些工具后,随时可以跳转到“创建一个 C 的‘Hello, World’”,无论是你自己安装还是已经有了自己喜欢使用的编辑器和编译器。

Windows

Microsoft Windows 占据了桌面市场的大部分份额。如果您只为一个系统编写程序,Windows 能为您提供最大的效益。但这意味着您将在编写这些程序的软件中面临更多竞争。对于 Windows,有比任何其他平台都多的商业开发应用程序。幸运的是,这些应用程序中的许多都有免费或“社区”版本,足以满足我们的需求。(当我们在本书的第二部分讨论 Arduino 重点时,我们将查看一些包括编译器的 Arduino 特定工具。)

谈论 Windows 和软件开发时,不能不提微软的 Visual Studio 集成开发环境(IDE)。如果您想要为 Windows 本身构建应用程序,那么 Visual Studio 几乎无可匹敌。他们甚至为学生和个人开发者提供社区版本。虽然在本书的示例中我不会讨论任何版本,但 Visual Studio 对于 Windows 用户来说是一个很棒的 IDE,并且可以轻松处理我们的代码。(然而,在所有三个主要平台上,我将使用一个名为 Visual Studio Code 的近亲作为我们的编辑器。)

注意

另一个流行的商业 IDE 是来自 Jetbrains 的CLion。CLion 也是跨平台的,因此您可以轻松地在不同操作系统之间切换,并保持高效。如果您有使用 Jetbrains 其他优质应用程序的经验,CLion 可以成为一个熟悉的开始编写和调试 C 代码的方式。

存在无数其他文本编辑器,每种都有其优缺点。甚至可以使用像内置的记事本应用这样的工具,尽管专门用于编程的编辑器将具有一些便捷功能,可以使阅读和调试代码变得更容易。

在 Windows 上的 GNU 工具

在 Windows 上,安装 GNU 的 GCC 工具可能会有些繁琐。没有快捷友好的安装程序。² 您可以找到多种二进制包,提供我们所需的大部分内容,但仍需注意下载 GNU 编译器的子包,并配置您的 Windows 环境。

我们将安装 Cygwin 环境来获取我们的 Windows 版本的 GCC。Cygwin 是一个更大的工具和实用程序集合,为 Windows 用户提供了一个不错的 Unix shell 环境。但“不错”相当主观,如果您不了解 Unix 或其衍生产品,如 Linux 或现代 macOS,那么您可能不会使用该集合的其他功能。

获取Cygwin 设置可执行文件。下载完成后,立即启动它。可能需要“允许此来自未知发布者的应用程序更改设备”。你可以尝试“从互联网安装”选项,但如果遇到问题,可以返回并使用“下载而不安装”选项。下载完成后,可以再次运行此安装程序,并选择“从本地目录安装”选项,并使用下载所有软件包的文件夹。

前往安装程序询问时接受任何默认选项。当到达镜像选择页面时,如果你能识别到你附近的大学或企业,请选择一个物理位置接近的镜像。否则,任何镜像都可以——但如果下载遇到问题,可以返回并选择另一个镜像。

在“选择包”屏幕上,你需要做一个额外的选择,因为gcc不是默认包含的。将视图下拉菜单切换到“完整”,然后输入gcc作为搜索词。你需要的是如图 1-1 所示高亮显示的“gcc-core”包。在撰写本文时,我们选择了最新的 gcc-core 版本,即 10.2.0-1。

smac 0101

图 1-1. 选择 Cygwin GCC 包

在审核页面确认你的选择并开始下载!可能需要一些时间来下载和安装所有内容,但最终会出现完成屏幕。如果想玩一下类 Unix 命令提示符,可以添加桌面图标,但不是我们将要进行的工作所必需的。不过,必须执行的是额外的步骤,将 Cygwin 工具添加到 Microsoft 命令提示符中。

你可能希望在线搜索一个有关创建和编辑 Windows 环境变量的导览,但这里是基本内容。(如果你以前做过这种事情,可以直接跳到 Cygwin 文件夹选择并将其添加到你的路径中。)

从开始菜单搜索“env”,你应该很快看到一个选项,可以在顶部编辑系统环境变量,如图 1-2 所示。

应该打开系统属性对话框,然后点击右下角附近的“环境变量…”按钮,如图 1-3 所示。

smac 0102

图 1-2. 在 Windows 中找到环境变量编辑器

smac 0103

图 1-3. Windows 中的系统属性对话框

您可以仅设置您的路径或设置系统范围。突出显示要更新的 PATH 条目,然后单击编辑。接下来,单击“编辑环境变量”对话框上的“新建”按钮,然后单击“浏览”按钮以导航到 Cygwin 的bin文件夹,如图 1-4 所示。(当然,如果您记得您选择的用于将所有内容放入的 Cygwin 安装程序的根文件夹,您也可以直接键入。)

smac 0104

图 1-4. 将 Cygwin 的bin文件夹添加到 Path 环境变量中

选择“确定”按钮以关闭每个对话框,然后您就可以开始了!

对于编辑器,您可以在 Visual Studio 网站上找到VS Code。根据您的系统,您可能需要 64 位或 32 位用户安装程序版本。³

使用图 1-5 中显示的扩展视图来获取 C/C++扩展。您可以搜索简单的字母“c”,但您也可能立即在“热门”列表中看到该扩展。继续点击扩展的小绿色安装按钮。

smac 0105

图 1-5. VS Code 中的 C 扩展

让我们从 Cygwin 工具中测试 GCC。 (您可能需要重新启动 Visual Studio Code 才能识别您的 Cygwin 工具。)从视图菜单中,选择终端选项。终端选项卡应在底部打开。您可能需要按 Enter 键获取提示符。在提示符处运行gcc --version。希望您能看到与图 1-6 类似的输出。

您应该看到与您安装 Cygwin 时选择的包版本匹配的版本号。如果是这样,太棒了!跳到“创建 C 的‘Hello, World’”并开始您的第一个 C 程序。如果您没有看到任何输出或出现“未识别”错误,请查看设置 Windows 环境变量的步骤。如常,在线搜索特定错误可以帮助您解决大多数安装和设置问题。

smac 0106

图 1-6. 在终端选项卡中测试 GCC

macOS

如果您主要使用图形应用程序和工具,您可能不了解 macOS 的 Unix 基础知识。虽然您可以大部分时间忽略这些基础知识,但了解如何从命令提示符中浏览世界还是很有用的。我们将使用Terminal应用程序来下载和安装 GCC,但与 Windows 一样,值得注意的是苹果的官方开发工具 Xcode 可以用来编写和编译 C 代码。幸运的是,我们不需要全部安装 Xcode 才能开始使用 C,所以我们将坚持最少的安装。

应用程序→实用工具文件夹中有终端应用程序。继续启动它。你应该看到类似于图 1-7 的内容。

smac 0107

图 1-7. 基本的 macOS 终端窗口

如果您已经安装了苹果的主要编程应用程序 Xcode,您可以快速检查 GCC 是否也可用。尝试运行gcc -v

$ gcc -v
Configured with: --prefix=/Library/Developer/CommandLineTools/usr --with...
Apple clang version 11.0.3 (clang-1103.0.32.62)
Target: x86_64-apple-darwin19.6.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin

具体版本并不那么重要;我们只想确保 GCC 确实可用。如果没有,您需要安装xcode-select命令行工具,它将带来 GCC。键入xcode-select --install并按照提示操作。将显示一个对话框询问是否要安装命令行工具;选择是,然后开始操作。

安装完成后,请运行gcc -v命令以确保您已安装编译器。如果没有得到良好的响应,您可能需要访问Apple 的开发者支持站点,并搜索“命令行工具”。

在 macOS 上安装 VS Code 要简单得多。访问 Visual Studio 网站上的相同VS Code 下载页面。选择 macOS 下载。您应该会在标准下载文件夹中收到一个 ZIP 文件。双击该文件解压缩,然后将结果的Visual Studio Code.app文件拖到应用程序文件夹中。如果提示输入密码以将应用程序移动到应用程序,请立即提供。

安装完成后,请打开 VS Code。我们要添加 C/C++扩展并检查是否可以从终端选项卡访问 GCC。

通过点击图 1-8 中显示的“方块”图标,在 VS Code 中拉出扩展面板。您可以搜索简单的字母“C”,很可能会在结果顶部找到正确的扩展。

smac 0108

图 1-8. VS Code 扩展

要尝试终端选项卡,请从查看 → 终端菜单项中打开它。您应该会在编辑器空间底部看到一个新的部分。继续尝试在那个新区域运行我们的 GCC 检查命令(gcc -v)。您应该会看到类似于图 1-9 的结果。

smac 0109

图 1-9. 在 macOS 上尝试 GCC

如果运行gcc命令未获得预期结果,请查看 Apple 的开发者网站。您也可以在网上找到几个视频教程,可能会帮助您完成特定设置。

Linux

许多 Linux 系统适合喜欢折腾的人。您可能已经有了 GCC 可用。您可以通过启动终端应用程序并运行其他操作系统上使用的相同检查来快速检查。如果gcc -v返回一个答案——当然不是“命令未找到”——那么您可以安装 VS Code 了。如果需要安装 GCC,可以使用平台上的软件包管理器。您可能已经有一个漂亮的图形应用程序来做这些事情;搜索“开发者工具”或“软件开发”,然后阅读描述以查看是否包含 GCC 或 GNU 实用工具。

对于 Debian/Ubuntu 系统,你可以获取包含 GCC 和许多其他有用(或必需)库和工具的build-essential元包:

$ sudo apt install build-essential

对于 Redhat/Fedora/CentOS 系统,可以使用 Dandified Yum (dnf) 工具。我们在本书中只需要 GCC:

$ su -
# dnf install gcc

虽然如果你对软件开发有兴趣的话,你可能想获取“开发工具”组包,其中包括 GCC 以及许多其他有用的东西:

$ su -
# dnf groupinstall "Development Tools"

Manjaro 是另一个基于 Arch Linux 的流行 Linux 发行版。你可以在这里使用pacman工具:

$ su -
# pacman -S gcc

如果你使用的是不使用aptdnfpacman的其他 Linux 版本,可以轻松搜索“install gcc my-linux”,或使用系统的软件包管理器的搜索选项查找“gcc”或“gnu”。

作为 Linux 用户,你可能已经对用于编写 shell 脚本或其他语言的文本编辑器有些经验。如果你已经熟悉你的编辑器和终端,你可以跳过这部分。但如果你是编程新手或者没有喜欢的编辑器,可以安装 VS Code。访问与其他操作系统相同的VS Code 下载页面。获取适合你系统的正确捆绑包。(如果你的 Linux 版本不使用 .deb.rpm 文件,可以获取 .tar.gz 版本。)

双击下载的文件,应提示你进行标准安装。如果你要为所有用户安装 VS Code,则可能会要求输入管理密码。不同的发行版会将 VS Code 安装在不同的位置,不同的桌面有不同的应用启动器。你也可以使用code命令从命令行启动 VS Code。

与其他操作系统一样,我们希望添加 C/C++ 扩展,然后检查我们是否可以从终端选项卡访问 GCC。

通过点击 图 1-10 中显示的“boxes”图标,打开 VS Code 中的扩展面板。你可以搜索简单的字母“C”,很可能在搜索结果的顶部找到正确的扩展。

smac 0110

图 1-10. Linux 上的 VS Code 扩展

要尝试终端选项卡,请从“视图”→“终端”菜单项打开它。你应该在编辑器底部看到一个新的部分。尝试在那个新区域运行我们的 GCC 检查命令(gcc -v)。你应该看到类似 图 1-11 的详细且稍显混乱的结果。

smac 0111

图 1-11. 在 Linux 上的 VS Code 中尝试 GCC

好极了,好极了。希望你已经有一个简单的 C 开发环境在运行中。让我们开始写一些代码吧!

创建一个 C 的“Hello, World”

在你的编辑器和编译器就绪后,我们可以尝试编写许多开发人员在任何新语言中都写的著名的第一个程序:“Hello, World” 程序。它旨在展示你可以在新语言中编写有效的代码,并能够输出信息。

C 作为一种语言,可以说是简洁的。我们将深入研究分号、花括号、反斜杠和其他奇怪的符号在这个第一个程序中的细节,但目前,完全复制这一小段代码即可。你可以在 VS Code 的资源管理器中右键点击创建新文件,或者使用文件 → 新建文件菜单项,或者按 Ctrl+N。

#include <stdio.h>

int main() {
  printf("Hello, world\n");
}

现在保存文件并命名为hello.c。接着在 VS Code 中打开终端(View → Terminal 菜单项或 Ctrl+`)。你应该会看到类似于图 1-12 的内容。

smac 0112

图 1-12. “Hello, World” 和我们的终端选项卡

如果你已经了解其他语言,你可能能猜到发生了什么。无论如何,让我们花一点时间来回顾每一行。但如果其中某些解释让你感到模糊,不要担心。学习编程需要大量的实践和耐心。后面的章节将帮助你巩固这两个技能。

#include <stdio.h>

这行代码加载了“标准输入/输出”头文件。库(大致而言)是可以在运行gcc时附加到你自己的代码中的外部代码片段。头文件是对这些外部实体的简明描述。这是一个非常常见的行,用于非常普遍的一个非常常见的库的一部分。除了其他功能,这个头文件还包括了我们用来输出实际信息的printf()函数的定义。几乎每一个你写的 C 程序都会使用它。尽管我们将在第六章中看到,你通常会使用多个库,每个库都有自己的头文件#include行。

int main() {

复杂的程序可能包含数十个(甚至数百或数千个)单独的 C 文件。将大问题分解为更小的部分是成为一名优秀程序员的基本技能之一。这些更小的“部分”更容易调试和维护。它们也有助于找到重复任务的时机,以便重复使用已编写的代码。但无论你有一个大型复杂的程序还是一个小而简单的程序,你都需要一个起点。这一行就是起点。main() 函数总是必需的,尽管它偶尔看起来有些不同。我们将在第二章详细讨论像在行首看到的int这样的类型,并且在第五章更仔细地查看函数。但请注意行尾的 { 符号。这个字符开启了一个代码块

  printf("Hello, world\n");

这个语句是我们程序的核心。不那么浪漫地说,它代表了我们 main() 函数块的 主体。块包含一个或多个代码行(在 C 中)由花括号界定,并且我们经常称任何块的内容为其主体。这个特定的主体只做一件事:使用 printf() 函数(再次在 stdio.h 中定义)生成一个友好的全局问候语。我们将在“printf() 和 scanf()”中详细讨论 printf() 和像 "Hello, world\n" 这样的片段。

我还想快速强调一下行尾的分号。这个标点符号告诉 C 编译器您何时完成了一条语句。在我们的代码块中只有一条语句时,这个标记意义不大,但当我们有更多语句和跨越多行的混乱语句时,它将在未来有所帮助。

最后但肯定不是最不重要的,这是“结束”花括号,与上面两行的“开始”花括号相匹配:

}

每个块都会有这些开放/关闭花括号。编程中最常见的错误之一是有太多的开放或关闭花括号。幸运的是,大多数现代编辑器都有复杂的语法高亮,可以帮助您匹配任何一对花括号(因此识别出没有伴侣的任何花括号)。

编译您的代码

现在我们终于可以利用所有这些软件安装头痛的工作了!在终端选项卡中,运行以下命令:

gcc hello.c

如果一切顺利,您将看不到任何输出,只会出现一个新的命令提示符。如果出现了问题,您将收到一个错误消息(或多个消息),希望能指出需要修正的问题。在我们遇到更多示例时,我们将看到调试技巧,但现在,请回顾您的代码和上面的示例,看看是否能发现任何差异。

提示

如果您在处理第一个文件时仍然遇到问题,请不要放弃!查看附录 A,从 GitHub 下载本书的示例代码。您可以按原样编译和运行代码,或者以我们的示例作为您自己调整和修改的起点。

运行您的代码

成功编译我们的第一个 C 程序后,我们如何测试它?如果列出目录中的文件,您会注意到在 Linux 和 macOS 系统上出现了名为 a.out 的新文件,在 Windows 系统上出现了 a.exe。要运行它,只需键入其名称。在许多 Linux 和 macOS 系统上,您的可执行路径可能不包括您的工作目录。在这种情况下,请使用本地路径前缀“./”。(句点表示当前目录;斜杠只是标准路径分隔符字符。)图 1-13 显示了输出。

smac 0113

图 1-13. 在 macOS 和 Linux 上说“Hello”

图 1-14 显示了在 Windows 上的输出。

smac 0114

图 1-14. 在 Windows 上说“Hello”
注意

在 Windows 上,.exe后缀表示文件可执行。但是在运行程序时不需要包含后缀。你只需输入a。根据使用的命令提示应用程序(例如cmdPowerShell),你可能还需要使用类似于 macOS 或 Linux 的本地目录前缀(.\)。

但作为名称,“a”实在是太无聊了,而且绝对不能告诉我们程序做了什么。如果你愿意,可以使用gcc命令的-o(输出)选项来指定程序更好的名称。

在 Linux 和 macOS 上:

$ gcc hello.c -o hello

在 Windows 上:

C:\> gcc hello.c -o hello.exe

尝试该命令,然后查看文件夹中的文件。你应该有一个新创建的hello(或hello.exe)文件,可以运行它。非常好。

下一步

哇……让你的计算机说“嗨”真是需要很多努力!如果能帮到你,人类花了无数年时间才让第一台计算机做到了你刚才的事情。 😃 不过现在我们有了一个工作的开发环境,接下来的章节将探讨 C 语言的细节,并向你展示如何编写、调试和维护更有趣的程序。有了我们的微控制器,这些受欢迎的小型计算机通常用于专用任务,比如报告当前温度或计数等待在传送带上的箱子数量,我们将把这些有趣的程序变成有趣的物理创作!

¹ 嗯,“任何”确实太广泛了;如果你的语言是解释性语言,那么当然你需要一个好的解释器而不是一个好的编译器!

² 然而,J. M. Eubank 已经为单文件安装程序做了大量工作,如果一般的完整设置步骤看起来令人生畏,你可能想要查看一下:tdm-gcc

³ 如果你不确定你的 Windows 是 64 位还是 32 位版本,请查看Microsoft FAQ

第二章:存储与状态

编程的本质是数据的操作。编程语言为人类提供了一个接口,告诉计算机这些数据是什么,以及你想对这些数据做什么。为强大的机器设计的语言可能会隐藏(或推断)关于存储数据的许多细节,但是在这方面,C 语言仍然相对简单。也许简单这个词不太恰当,但它在数据存储方面的处理方法是直接的,同时仍然允许复杂的操作。正如我们将在第六章中看到的,C 语言还为程序员提供了一个窗口,可以看到数据存储在计算机内存中的低级别方面。当我们在本书后半部分直接与微控制器工作时,这种访问将变得更加重要。

现在,我想先了解一些 C 语法的基础,这样我们就可以开始撰写原创程序,而不仅仅是从书本上复制代码行。本章包含大量这样的代码行,你在阅读时可以放心地复制它们!但希望到达这样一个阶段,你可以为自己的编程挑战创建新颖的答案。

注意

如果你已经对从其他语言中的编程经验感到满意,可以跳过本章。你应该阅读“printf() and scanf()”关于printf()scanf()函数的部分,但其他部分可能会比较熟悉。

C 语言中的语句

编程中的另一个基本概念是算法的概念。算法是一组处理数据的指令,通常在计算机上完成任务。一个经典的算法类比是厨房食谱。给定一组原料,这里是你执行的每一个步骤,将这些原料转化为像蛋糕之类的东西。在编程中,这些“每一个步骤”就是语句。

在 C 语言中,语句有多种形式。在本章中,我将讨论声明语句、初始化语句、函数调用和注释。后续章节将涉及控制语句以及像创建自己的函数和预处理器命令等近似语句。

语句分隔符

语句之间使用分号分隔。在 C 语言中,分号的作用类似于英语中的句号。英语中的长句可能跨越多行,但你知道要一直读到句号为止。同样地,你可能会在同一行上有几个短句,但你可以根据这些句号轻松区分它们。很容易忘记语句末尾的分号。如果每个语句都放在自己的行上,你可能会假设编译器“看到”的结构与人类能够轻松理解的结构相同。不幸的是,编译器不能这样做。即使是我们的第一个非常简单的程序,来自“创建一个 C‘Hello, World’”,我们用来在终端窗口打印文本的语句也需要以分号结尾。如果你感兴趣,试着删除那个分号,保存你的文件,然后重新编译它。你会得到像这样的结果:

$ gcc hello.c
hello.c:4:27: error: expected ';' after expression
  printf("Hello, world\n")
                          ^
                          ;
1 error generated.

哎呀,出错了。但至少错误消息很有用。它告诉我们两件关键的事情:出了什么问题(“expected ';' after expression”)和编译器在哪里遇到了问题(“hello.c:4:27”,或者hello.c文件,第 4 行,第 27 列)。我不想在你探索 C 语言的早期阶段就用一个错误消息吓到你,但你肯定会经常遇到它们。幸运的是,这意味着你需要更仔细地查看你的源代码,然后再试一次。

语句流程

分隔符告诉编译器语句在哪里结束和下一条语句从哪里开始。顺序也很重要。语句的流程是自上而下,如果多个语句在同一行上,则是从左到右。而且确实允许有多个语句!我们可以迅速将我们简单的“Hello, World”程序扩展得更加冗长。

提示

当你有时间和精力时,我强烈建议手动转录源代码。这会让你对 C 语言的语法更加熟练。你经常会犯一两个错误。发现和修正这些错误是学习的一个很好方式!即使有时候这些错误可能会让人有点沮丧。

考虑下面的程序,ch02/verbose.c

#include <stdio.h>

int main() {
  printf("Ahem!\n");                                 ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  printf("May I have your attention, please?\n");    ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  printf("I would like to extend the warmest of\n"); ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  printf("greetings to the world.\n");
  printf("Thank you.\n");
}

1

我们从与hello.c中使用的语句非常相似的语句开始。唯一真正的区别是我们打印的文本。请注意,我们用分号分隔符结束了这行。

2

我们有第二个printf()语句与第一个类似。它确实会第二次执行。

3

为了更加突出这一点,这第三个语句将在前两个之后被调用。最后两个调用将在此之后进行。

下面是我们简单多行升级的输出结果:

$ gcc verbose.c
$ ./a.out
Ahem!
May I have your attention, please?
I would like to extend the warmest of
greetings to the world.
Thank you.

不错。你可以看到输出如何精确地按照程序中语句的顺序进行。尝试调换它们的顺序,并确认程序的流程是自上而下的。或者尝试在同一行上放置两个printf()调用。这不是要搞难题。我只是希望你尽可能经常地练习编写、运行和编译代码。你尝试的例子越多,你就越能避免简单的错误,也越容易跟上新代码示例的步伐。

变量和类型

当然,我们不仅可以打印文本,还可以在实施算法或执行任务时存储和操作数据。在 C 语言(以及大多数语言中),你将数据存储在变量中,这是解决问题的强大工具。这些变量有类型,它们决定你可以存储哪些类型的数据。这两个概念在我提到的声明和初始化语句中起着重要作用。

变量是值的占位符。变量可以保存简单的值,如数字(班级中有多少学生?我的购物车中物品的总成本是多少?)或更复杂的事物(这个特定学生的名字是什么?每个学生的成绩是什么?甚至像-1 的平方根这样的复杂值)。变量可以存储用户收到的数据,并允许你编写能够解决一般问题而不必重新编写程序本身的程序。

获取用户输入

我们很快将探讨定义和初始化变量的详细内容,但首先让我们运行一下这个想法:让用户输入一些内容,以便在不每次重新编译程序的情况下生成动态输出。我们将返回到我们的“Hello, World”程序,并稍作升级。我们可以要求用户告诉我们他们的名字,然后个性化地问候他们!

到目前为止,你已经看到了一个输出语句,即我们用来问候地球的printf()函数调用。还有一个对应的输入函数:scanf()。你可以使用打印/扫描配对来提示用户,然后等待他们输入答案。我们将把这个答案存储在一个变量中。如果你在其他语言中做过一些编程,下一个程序应该看起来很熟悉。如果你是编程和 C 语言的新手,这个列表可能有点密集或奇怪——没关系!输入这些程序并在修复任何拼写错误后使其运行,是学习的一种有效方式。

提示

很多编程只是有思想的剽窃。这有点开玩笑,但也只是一点点。你开始的方式很像人类开始使用口语的方式:重复你看到(或听到)的东西,而不一定完全理解它的所有内容。如果你重复这种行为足够多次,你就会发现语言中固有的模式,并学会在哪里可以做出有用的更改。做出足够多的有用更改,你就会发现如何从头开始创建新的有意义的事物。这是我们的目标。

这个 ch02/hello2.c 程序只是另一个代码片段,当你开始探索编程时可以复制:

#include <stdio.h>

int main() {
  char name[20];

  printf("Enter your name: ");
  scanf("%s", name);
  printf("Well hello, %s!\n", name);
}

希望这个程序的结构看起来很熟悉。我们包括我们的标准 I/O 库,我们有一个 main() 函数,并且该函数有一个包含多个语句的主体,都在一对花括号内。不过,该主体包含几个新项目。让我们逐行看一下。

  char name[20];

这是我们第一个变量声明的例子。变量的名字是,“name”。它的类型是 char,这是 C 用来指代单个(ASCII)字符的类型。¹ 它还是一个 数组,意味着它按顺序存储多个 char 值。在我们的情况下,可以存储 20 个这样的值。关于数组的更多信息,请参阅第四章。现在,只需注意这个变量可以保存一个人的名字,只要它少于 20 个字符。

  printf("Enter your name: ");

这是一个相当标准的 printf() 调用,与我们在第一个程序中使用的非常相似,详见“创建 C‘Hello, World’”。唯一有意义的区别是双引号标记集合内的最后字符。如果你看一下 hello.cverbose.c,你会注意到最后两个字符是反斜杠和字母“n”。这两个字符的组合(\n)表示一个单独的“换行”字符。如果在末尾添加 \n,则会打印一行,并且任何后续的 printf() 调用将在下一行进行。相反,如果省略 \n,终端中的光标将保持在当前行。如果你想要逐个单元格打印表格或者在我们的情况下,如果你想要提示用户输入一些内容,然后允许他们在同一行上输入响应,这将会很方便。

  scanf("%s", name);

这是我在本节开头提到的新功能。scanf() 函数“扫描”字符并可以将它们转换为 C 数据类型,比如数字,或者在这种情况下,一组字符数组。一旦转换完成,scanf() 函数期望将每个“东西”存储在一个变量中。在这一行中,我们正在扫描一堆字符,并将它们存储在我们的 name 变量中。我们将查看括号内的内容的非常奇怪的语法,详见“printf() 和 scanf()”。

  printf("Well hello, %s!\n", name);

最后,我们想要打印我们的问候语。同样,这看起来应该很熟悉,但现在我们有更多奇怪的语法。如果 %s 让你感觉到与 scanf() 调用中的同样奇怪的事物相同,恭喜你!你刚刚发现了一个非常有用的模式。这一对字符正是 C 在打印或扫描字符数组时使用的内容。字符数组在 C 中是如此常见的一种类型,以至于它有一个更简单的名称:字符串。因此,在这对字符中使用“s”。

那么name发生了什么?scanf()调用获取了你输入的任何名字(不包括你按下的回车键²)并将其存储在内存中。我们的name变量包含了那些字符的内存位置。当我们使用printf()调用时,我们的第一个参数("Well hello, %s!\n"部分)包含了一些字面上的字符,比如“Well”中的那些字符,以及一个字符串的占位符(%s部分)。变量非常适合填充占位符。无论你输入了什么名字,现在都会显示回来给你看!

还要注意,我们在打印问候语时包含了特殊的\n换行符。这意味着我们将打印问候语,然后“按下回车键”,以便终端中显示的任何其他内容都将显示在下一行。

让我们继续运行程序,看看事情是如何运作的。你可以使用 VS Code 底部的终端选项卡,或者你平台的 Terminal 或 Command 应用程序。你需要先用gcc编译,然后运行a.out或者使用-o选项选择的任何名字。你应该会得到类似于 Figure 2-1 的结果。

注意,在你输入名字时,它会出现在要求你输入的提示的同一行。当我们省略换行符(\n)时,这正是我们想要的效果。但再试一次,输入一个不同的名字。你得到了期望的结果吗?再试第三次。这种对用户输入响应的动态行为使得变量在计算机编程中非常宝贵。同一个程序可以根据不同的输入产生不同的输出,而无需重新编译。这种能力反过来又帮助使计算机程序对我们日常生活变得非常宝贵。

smac 0201

图 2-1. 我们定制的 Hello World 输出

字符串和字符

让我们更仔细地看一看char类型以及它的近亲char[]——更为人所知的字符串。在 C 中声明变量时,你为它指定了名称和类型。最简单的声明看起来像这样:

char response;

在这里,我们创建了一个名为response的变量,类型为charchar类型可以容纳一个字符。例如,我们可以存储一个“y”或“n”。第五章将详细介绍内存地址和引用细节,但现在,只需记住变量声明会在内存中留出足够空间来存储你指定类型的内容。如果我们有一系列问题要问,那么我们可以创建一系列变量:

char response1;
char response2;
char finalanswer;

每一个变量都可以容纳一个字符。但是,当你使用变量时,你不需要预测或决定那个字符将在先。内容可以变化。(变化……变量……明白吗? 😃

C 编译器确定你的源字符使用哪种编码。旧的编译器使用旧的 ASCII³ 格式,而较新的编译器通常使用 UTF-8。这两种编码都包括大小写字母、数字和大多数键盘上看到的符号。要讨论特定的字符而不是 char 类型的变量,你可以使用单引号将其界定。例如,'a''A''8''@' 都是有效的。

特殊字符

字符也可以是特殊的。C 语言支持诸如制表符和换行符之类的东西。我们已经看到了换行符(\n),但还有其他几个特殊字符列在了表 2-1 中。这些特殊字符使用“转义序列”编码,反斜杠被称为“转义字符”。

表 2-1. C 中的转义序列

Char ASCII Name Description
\a 7 BEL 打印时使终端“响铃”
\n 10 LF 换行符(在 Mac 和 Linux 上的标准行结束符)
\r 13 CR 回车符(与 \n 一起使用时,在 Windows 上通常的行结束符)
\t 15 HT (水平) 制表符
\ 92 用于在字符串或字符中放置字面反斜杠
' 39 用于在字符中放置字面单引号(在字符串中不需要转义)
\” 34 用于在字符串中放置字面双引号(在字符中不需要转义)
这不是详尽的列表,但涵盖了本书中将使用的字符。

这些命名的快捷方式只涵盖了最流行的字符。如果你必须使用其他特殊字符,比如来自调制解调器的传输结束(EOT,ASCII 值 4)信号,你可以用反斜杠加八进制的 ASCII 值表示该字符。因此我们的 EOT 字符将是 '\4',有时你会看到三位数字表示:'\004'。(由于 ASCII 是一个 7 位编码,三位八进制数字涵盖了最高的 ASCII 字符。如果你好奇的话,删除符(DEL,ASCII 127)或 '\177' 作为八进制转义序列。有些人更喜欢总是看到三位数字的一致性。)

你可能不需要这些快捷方式,但由于 Windows 路径名使用反斜杠字符,重要的是要记住某些字符需要这种特殊前缀。当然,换行符将会继续出现在我们的许多打印语句中。正如你在八进制转义序列中所见,前缀反斜杠被包含在单引号内。因此制表符是 '\t',反斜杠是 '\\'

字符串

字符串是一系列的 char,但是非常正式的一系列。许多编程语言支持这样的系列,称为数组。第四章将更详细地介绍数组,但 char[] 类型的字符数组在 C 语法中非常常见,我想单独提一下。

我们在处理字符串时,并没有过多明确说明它们。在我们的第一个 hello 程序中,我们调用了 printf() 并传递了一个字符串参数。在 C 中,字符串是一个由零个或多个 char 组成的集合,最后以特殊的“空”字符 \0 结尾(ASCII 值为 0)。通常在双引号中包含代码中的字符,例如我们的 "Hello, world!\n" 参数。幸运的是,当你使用这些双引号时,你不必自己添加 \0。这在字符串字面量的定义中是隐含的。

声明字符串变量就像声明 char 变量一样简单:

char firstname[20];
char lastname[20];
char jobtitle[50];

这些变量可以存储简单的东西,如姓名,或者更复杂的东西,如多部分标题,例如,“高级代码和美味派开发人员”。字符串也可以为空:“”。这看起来可能有点傻,但想想那些你输入姓名之类信息的表格。如果你碰巧是一个非常成功的流行歌星,只有一个名字,那么上面的 lastname 变量可以被赋予有效值 ""(即只是终止符 '\0'),表明 Drake 和 Cher 也没有姓氏也没问题。

数字

毫不奇怪,C 也有可以存储数字值的类型。更准确地说,C 拥有用于存储比 char 类型变量能容纳的数字更大的类型。(尽管本章迄今的示例中使用 char 存储实际字符,但它仍然是数值类型,并且适合存储与字符编码无关的小数字。)C 将这些数值类型分为两个子类别:整数和浮点数(即小数)。

整数类型

整数类型存储简单的数字。主要类型称为 int,但有许多变体。这些变体的主要区别在于能够存储在给定类型变量中的最大数字的大小。表 2-2 总结了这些类型及其存储容量。

表 2-2. 整数类型及其典型大小

类型 字节 范围 备注
char 1 –127 到 +127 或 0 到 255 通常用于字母;也可以存储小数字
short 2 –32,767 到 +32,767
int 2 或 4 –32,767 到 +32,767 或 –2,147,483,647 到 +2,147,483,647 实现有所不同
long 4 –2,147,483,647 到 +2,147,483,647
long long 8 –9,223,372,036,854,775,807 到 +9,223,372,036,854,775,807 C99 引入
虽然 char 被定义为一个字节,其他大小都是依赖于系统的。

大多数上述类型是有符号类型,⁴ 这意味着它们可以存储小于零的值。这五种类型还都有显式的无符号变体(例如,unsigned intunsigned char),它们的位/字节大小相同,但不存储负值。它们的范围从零开始,大致到达有符号范围的两倍,如 表 2-3 所示。

表 2-3。无符号整数类型及其典型大小

类型 字节 范围
无符号字符 1 0 到 255
无符号短整型 2 0 到 65535
无符号整型 2 或 4 0 到 65535 或 0 到 4,294,967,295
无符号长整型 4 0 到 4,294,967,295
无符号长长整型 8 0 到 18,446,744,073,709,551,615

这里是一些整数类型声明的示例。注意xy变量的声明。你经常会在网格或图表上看到坐标“x 和 y”讨论。C 允许你使用逗号分隔的方式声明多个相同类型的变量名。这种格式没有什么特别之处,但如果你有一些简短相关的变量名,这可能是一个不错的选择。

int studentcount;
long total;
int x, y;
short volume, chapter, page;
unsigned long long nationaldebt;

如果你要存储小值,比如“最多一打”或“前 100”,请记住可以使用char类型。它只有 1 字节长度,编译器不会在乎你是否将该值打印为实际字符还是简单数字。

浮点类型

如果你要存储分数或财务数字,可以使用floatdouble类型。这两种都是浮点类型,其中小数点不固定(可以浮动),能够存储像 999.9 或 3.14 这样的值。但因为我们在讨论以 1 和 0 编码的离散块思考的计算机,浮点类型存储的是值的近似值,就像int一样。float类型是一种 32 位编码,可以存储从非常小的分数到非常大的指数的各种值。但在大约-32k 到 32k 之间,小数点后的六个有效位数时,float是最准确的。

double类型比float类型精度“翻倍”。⁵ 这意味着大约可以精确表示 15 个十进制数字。我们将看到一些近似值可能会导致问题的地方,但对于像收据总额或从温度传感器读取的数据这样的一般用途来说,这些类型是足够的。

与其他类型一样,你需要在名字之前放置类型:

float balance;
float average;
double microns;
注意

因为普通的十进制数字也可以存储像 6 这样的整数值(如 6.0),所以可能会倾向于将float用作默认的数字类型。但在像 Arduino 这样的微型 CPU 上操作带有小数点的数字可能会很昂贵。即使在大芯片上,与简单整数相比,它仍然更昂贵。出于性能和精度的原因,大多数 C 程序员都坚持使用int,除非有明确的理由不使用。

变量名

无论变量是什么类型,它都有一个名字。大多数情况下,你可以自由选择任何你想要的名字,但是有一些规则你必须遵循。

在 C 语言中,变量名可以以任何字母或下划线字符(“_”)开头。在初始字符之后,名称可以有更多字母、下划线或数字。变量名区分大小写(totalTotal不是同一个变量),通常长度限制为 31 个字符长,⁶ 虽然惯例上它们更短。

C 语言还有几个 关键字 是为 C 语言本身保留的。因为 表格 2-4 中的关键字已经对 C 有意义,所以它们不能用作变量名。某些实现可能会保留其他单词(例如asmtypeofinline),但大多数备用关键字都以一个或两个下划线开头,以减少与您自己的变量名冲突的可能性。

表格 2-4. C 关键字

保留字
_Bool
_Complex
_Imaginary
auto
break
case
char
const
continue

如果在声明变量时遇到关键字冲突,你将会看到类似于使用无效变量名(例如以数字开头)时的错误:

badname.c: In function ‘main’:
badname.c:4:9: error: expected identifier or ‘(’ before ‘do’
    4 |   float do;
      |         ^~
badname.c:5:7: error: expected identifier or ‘(’ before numeric constant
    5 |   int 5r;
      |       ^~

那个“expected identifier”短语是表明您的变量是错误原因的强烈指示器。编译器期望一个变量名,但找到一个关键字。

变量赋值

在我们的 hello2.c 示例中,我们依赖对name变量的相当隐式的赋值。作为scanf()函数的参数,用户输入的任何内容都存储在该变量中。但我们可以(而且经常)直接对变量进行赋值。您可以使用等号(“=”)来指示这样的赋值操作:

int total;
total = 7;

恭喜!您已成功将值7存储在变量total中。

你也可以随时覆盖该值:

int total;
total = 7;
total = 42;

尽管连续的赋值有点浪费,但这段 C 代码没有任何问题。变量total只会保留一个整数值,所以最近的赋值会胜出,在本例中是42

通常看到变量定义并同时分配初始值(在程序员的说法中称为初始化):

int total = 7;
char answer = 'y';

现在totalanswer都有可以使用的值,但都可以根据需要更改。这正是变量的作用。

字面值

我们在这些示例中插入变量的那些简单值称为 字面值。字面值只是一个不需要解释的值。数字、单引号内的字符或双引号内的字符串都算是字面值:

int count = 12;
char suffix = 's';
char label[] = "Description";

希望这前两个变量定义看起来很熟悉。但请注意,当我们初始化名为label的字符串时,我们没有给数组指定长度。C 编译器会根据我们在初始化中使用的文字推断大小。在这种情况下,label有 12 个字符长;“Description”单词中的 11 个字母加上一个用于终止的'\0'。如果你知道以后在代码中会需要更多空间,可以给字符串变量更多空间,但不应指定太少的空间。

char automatic[] = "A string variable with just the right length";
char jobtitle[50] = "Chief Acceptable Length Officer";
char warning[5] = "This is a bad idea.";

如果您试图为其char[]变量分配一个过长的字符串文字,您可能会从编译器看到一个警告:

toolong.c: In function ‘main’:
toolong.c:6:21: warning: initializer-string for array of chars is too long
    6 |   char warning[5] = "This is a bad idea.";
      |                     ^~~~~~~~~~~~~~~~~~~~~

这是一个相当具体的错误,所以希望您会发现很容易修复。顺便说一句,你的程序仍然会运行。请注意,编译器给了你一个警告而不是我们在之前一些例子中看到的错误。警告通常意味着编译器认为您在犯错,但您得到了怀疑的好处。通常最好去处理警告,但这不是必需的。

printf()和 scanf()

我们已经看到如何使用printf()打印信息,以及如何使用scanf()接受用户输入,但我忽略了这两个函数的许多细节。现在让我们来看看其中一些细节。

printf()格式

printf()函数是 C 的主要输出函数。我们已经用它来打印简单的字符串,如"Hello, world\n"。我们还窥视了如何在“获取用户输入”中打印变量。它可以打印所有变量类型,您只需要提供正确的格式字符串

当我们调用printf()时,我们通常首先提供一个字符串文字。第一个参数称为格式字符串。您可以使用简单的字符串将其“原样”回显到终端,也可以打印(和格式化)变量的值。您使用格式字符串让printf()知道接下来会发生什么。您通过包括格式说明符,如我们从ch02/hello2.c中的%s来做到这一点。让我们打印一些在讨论声明和赋值时创建的那些变量。考虑ch02/hello3.c

#include <stdio.h>

int main() {
  int count = 12;
  int total = 7;
  char answer = 'y';
  char jobtitle[50] = "Chief Acceptable Length Officer";
  // char warning[5] = "This is a bad idea.";

  printf("You can have %d, you currently have %d.\n", count, total);
  printf("You answered: %c\n", answer);
  printf("Please welcome our newest %s!\n", jobtitle);
}

这就是结果:

ch02$ gcc hello3.c
ch02$ ./a.out
You can have 12, you currently have 7.
You answered: y
Please welcome our newest Chief Acceptable Length Officer!

将输出与源代码进行比较。你可以看到,我们大多数打印出格式字符串中的字符。但是当我们遇到格式说明符时,我们会用跟随格式字符串的参数之一的值来替换它。仔细看看我们对printf()的第一次调用。我们的格式字符串中有两个格式说明符。在该字符串之后,我们提供了两个变量。这些变量按顺序填充格式说明符,从左到右。如果您检查输出,可以看到第一行输出确实首先包括count的值,然后是total的值。很整洁。我们还得到了char和字符串变量的输出。

如果您注意到每种类型使用了不同的说明符,恭喜您!您正在找出这些语句中的重要差异。(如果这一切看起来像胡言乱语,不要放弃!随着您的阅读和实践,模式以及与模式不符合的事物将开始显现。)事实上,printf() 拥有相当多的格式说明符,如 表 2-5 所示。一些说明符显而易见,并且明确与特定类型相关联。其他一些则有点玄学,但这就是书籍存在的意义。您会记住您经常使用的几个说明符,并且在需要时可以随时查找不那么常用的那些。

表 2-5. printf() 的常见格式说明符类型

说明符 类型 描述
%c char 打印单个字符
%d int, short, long 打印十进制整数值
%f float, double 打印浮点数值
%i int, short 打印十进制整数值
%li, %lli long, long long 打印长整数值
%s char[](字符串) 打印字符数组作为文本

还有其他格式,但我会将它们留给以后需要打印出奇怪或特殊数据的场合。这些格式将涵盖您日常所需的绝大部分内容。附录 B 包括了本书中使用的所有格式的更详细讨论。

定制化输出

那么如何格式化这些值呢?毕竟,C 使用了“格式字符串”和“格式说明符”这些术语。您需要向格式说明符添加信息以达到这个目标。这其中最常见的一个例子就是打印像银行账户余额或模拟传感器读数之类的浮点数。让我们给自己一些有趣的小数并尝试打印它们出来。

#include <stdio.h>

int main() {
  float one_half = 0.5;
  double two_thirds = 0.666666667;
  double pi = 3.1415926535897932384626433;

  printf("1/2: %f\n", one_half);
  printf("2/3: %f\n", two_thirds);
  printf("pi:  %f\n", pi);
}

我们声明了三个变量,一个是 float 类型,另外两个是 double 类型。我们在 printf() 语句中使用了 %f 格式说明符。太好了!在编译和运行程序后,我们得到了以下结果:

1/2: 0.500000
2/3: 0.666667
pi:  3.141593

Hmm,它们都有六位小数,即使我们没有指定想要多少位数,我们的变量中也没有确切有六位小数。为了获得恰到好处的信息量,您需要给格式说明符提供一些额外的细节。所有的说明符都可以接受宽度和精度参数。这两者都是可选的,您可以提供其中一个或两个。额外的细节看起来像一个十进制数:宽度.精度,这些细节位于百分号和类型字符之间,如 图 2-2 所示。

smac 0202

图 2-2. 隐式类型转换层次

对于浮点数来说,同时使用这两个选项非常有意义。现在我们可以请求更多或更少的数字位数。尝试像这样更改 ch02/floats.c 中的三个 printf() 调用:

  printf("1/2: |%5.2f|\n", one_half);
  printf("2/3: |%12f|\n", two_thirds);
  printf("pi:  |%12.10f|\n", pi);

我在扩展的格式说明符之前和之后添加了竖线或管道字符(|),以便你看到宽度元素如何影响输出。看看新的结果:

1/2: | 0.50|         ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
2/3: |    0.666667|  ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
pi:  |3.1415926536|  ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)

1

我们的值0.5以五个字符的总字段宽度显示,精确到小数点后两位。因为我们不需要所有五个位置,所以在开头添加了一个空格字符。

2

较长的十进制数字被打印在 12 个位置内。请注意,我们得到的六位小数与未指定任何宽度或精度时相同。

3

更长的十进制数在 12 个位置内显示,但包括 10 位精度。请注意,这里的 12 是宽度,包括小数点后的数字所占用的位置。

注意

对于printf(),如果给定,你请求的精度和实际打印的值优先于宽度。你经常会看到像“%0.2f”“%.1f”这样的浮点格式,它们在需要的确切位置内给出正确数量的小数位数。例如,将这两个示例格式应用于π,结果分别为3.143.1

对于其他类型,如字符串或整数,宽度选项相对来说比较直接。例如,你可以像在ch02/tabular.c中展示的那样,使用相同的宽度打印表格数据,而不管所打印的值是什么。

float root2 = 1.4142;
float phi = 1.618034;
float pi = 3.1415926;
printf("     %10s%10s%10s\n", "Root 2", "phi", "pi");
printf(" 1x  %10.4f%10.4f%10.4f\n", root2, phi, pi);
printf(" 2x  %10.4f%10.4f%10.4f\n", 2 * root2, 2 * phi, 2 * pi);

带有精彩的列结果:

         Root 2       phi        pi
 1x      1.4142    1.6180    3.1416
 2x      2.8284    3.2361    6.2832

很好。请注意我如何处理列标签。我使用了格式说明符和字符串字面量,而不是单个手动空格分隔的字符串。我这样做是为了突出输出宽度的使用,即使手动操作也不难。事实上,手动将标签居中于这几列将更容易。如果你愿意做个小练习,打开tabular.c文件,尝试调整第一个printf(),看看是否能使标签居中。

虽然对于所有类型来说,宽度选项都很直观,但对于非浮点格式,添加精度选项的影响可能不那么直观。对于字符串,指定精度会导致截断文本以适应给定的字段宽度。(对于intchar类型,通常没有影响,但你的编译器可能会警告你不要依赖这种“典型”行为。)

使用scanf()和解析输入

输出的另一面是输入。在本章开头,我们看了一眼如何使用scanf()函数来进行这样的操作“获取用户输入”。到现在你可能已经认识到我们在那个简单程序中使用的%s作为格式说明符。这种熟悉程度更深:你可以在scanf()中使用在表格 2-5 中列出的所有格式说明符来获取用户输入的这些类型的值。

有一点很重要,关于你在 scanf() 中使用的变量。在我们的第一个示例中,扫描字符串有点幸运。如果你还记得,C 中的字符串实际上只是 char 类型的数组。我们将在第四章和第六章看到更多关于这个主题的内容,但在这里,我只想指出数组在 C 中是指针的一种特殊情况。指针是特殊的值,指向内存中的地址(位置)。scanf() 函数使用变量的地址,而不是其值。实际上,scanf() 的目的是将一个值放入一个变量中。由于数组实际上是指针,你可以直接使用 char 数组变量。但是,要使用数字和单个 char 变量与 scanf() 一起使用,你必须在变量名上使用一个特殊的前缀,即和号(&)。

我将在第六章中更详细地讨论 & 前缀,但它告诉编译器使用变量的地址——非常适合 scanf()。看一下这个小片段:

char name[20];
int  age;

printf("Please enter your first name and age, separated by a space: ");
scanf("%s %d", name, &age);

注意在 scanf() 行中使用 name 变量和使用 &age 变量的区别。这完全取决于 name 是一个数组,而 age 是一个简单的整数。这是一个容易忘记的事情。不过,幸运的是,这很容易修复,如果你忘记了,编译器会提醒你:

warning: format '%d' expects argument of type 'int *',
         but argument 3 has type 'int' [-Wformat=]
   15 |   scanf("%s %d", name, age);
      |             ~^         ~~~
      |              |         |
      |              |         int
      |              int *

当你看到这个“期望类型”错误时,只需记住,intfloatchar 和类似的非数组变量在与 scanf() 一起使用时总是需要 & 前缀。

运算符和表达式

现在,通过变量和 I/O 语句,我们在编程工具箱中有了一些非常强大的构建模块。但是,存储和打印值在编码中相当无聊。我们想要开始对这些变量的内容进行一些工作。在代码复杂性阶梯的第一个阶段,你可以计算新值。在 C(以及许多其他语言)中,你可以借助运算符执行计算,这些符号允许你执行诸如加法、减法、乘法或比较(即执行“操作”)等操作。

C 包含几个预定义的运算符,用于进行基本的数学和逻辑工作。(高级数学和逻辑可以通过编写自己的函数来完成,我们将在第五章中进行讨论。)除了一个特殊的三元运算符(?:,在“三元运算符和条件赋值”中讨论),C 的运算符可以使用一个或两个值。图 2-3 展示了这些一元和二元运算符如何与值和表达式配合使用。

smac 0203

图 2-3。二元运算符语法

请注意,你可以在一个序列上使用多个值的操作符,但在底层,C 将会将该序列视为一系列的对。一般来说,操作符可以与表达式一起使用。术语“表达式”是相当广泛的。一个表达式可以很简单,比如一个字面值或一个单一变量。它也可以非常复杂,以至于需要多行代码来编写。当你看到有关表达式的讨论时,要记住的关键点是它们具有(或将产生)一个值。

算术操作符

在 C 语言中,也许最直观的操作符是用于数学计算的那些。表格 2-6 展示了内置于 C 语言中的操作符。

表格 2-6. 算术操作符

操作符 操作 描述
+ 加法 将两个值相加
- 减法 从第一个值中减去第二个值
* 乘法 两个值相乘
/ 除法 将第一个值除以第二个值
% 取余 求第一个(整数)值除以第二个值的余数

你可以对字面值、变量或表达式进行数学计算,或者它们的某种组合。让我们试一个简单的程序,要求用户输入两个整数,然后使用这些值进行一些计算。

#include <stdio.h>

int main() {
  int num1, num2;
  printf("Please enter two numbers, separated by a space: ");
  scanf("%d %d", &num1, &num2);
  printf("%d + %d is %d\n", num1, num2, num1 + num2);
  printf("%d - %d is %d\n", num1, num2, num1 - num2);
  printf("%d * %d is %d\n", num1, num2, num1 * num2);
  printf("%d / %d is %d\n", num1, num2, num1 / num2);
  printf("%d %% %d is %d\n", num1, num2, num1 % num2);
}

请自己尝试运行这个简短的程序。你可以自己输入它,或者打开ch02/calcs.c 文件。编译并运行,你应该会得到类似以下的输出:

ch02$ gcc calcs.c
ch02$ ./a.out
Please enter two numbers, separated by a space: 233 17
233 + 17 is 250
233 - 17 is 216
233 * 17 is 3961
233 / 17 is 13
233 % 17 is 12

希望大部分答案都能让你感到合理并符合你的期望。有些看似奇怪的结果可能是我们尝试对两个数字进行除法的结果。与其说是得到了类似 8.33333 这样的浮点近似值,不如说我们直接得到了 8。请记住,int 类型不支持分数。如果你将两个 int 数相除,结果总是另一个 int,而任何小数部分都会被简单地舍弃。我说的是被舍弃,而不是四舍五入。例如,除法结果为 8.995 时,将会返回 8,而负数答案,比如–7.89,将会返回–7。

运算顺序

但是,如果我们使用两个(或更多)操作符创建一个更复杂的表达式会怎样?我们可以稍微升级我们的程序,让它接受三个整数,并以不同的方式组合它们。查看ch02/calcs2.c

#include <stdio.h>

int main() {
  int num1, num2, num3;
  printf("Please enter three numbers, separated by a space: ");
  scanf("%d %d %d", &num1, &num2, &num3);
  printf("%d + %d + %d is %d\n", num1, num2, num3, num1 + num2 + num3);
  printf("%d + %d - %d is %d\n", num1, num2, num3, num1 + num2 - num3);
  printf("%d * %d / %d is %d\n", num1, num2, num3, num1 * num2 / num3);
  printf("%d + %d / %d is %d\n", num1, num2, num3, num1 + num2 / num3);
  printf("%d * %d %% %d is %d\n", num1, num2, num3, num1 * num2 % num3);
}

如果你愿意,可以随意调整代码来尝试其他组合。如现在的情况,你可以编译并运行此程序以获得以下输出:

ch02$ gcc calcs2.c
ch02$ ./a.out
Please enter three numbers, separated by a space: 36 19 7
36 + 19 + 7 is 62
36 + 19 - 7 is 48
36 * 19 / 7 is 97
36 + 19 / 7 is 38
36 * 19 % 7 is 5

这些答案是否符合你的预期?如果不符合,很可能是由于不同操作符的优先级。C 语言并不简单地按照从左到右的方式处理大表达式。一些操作符比其他操作符更重要——它们具有优先于较低级操作符的优先级。C 将首先执行最重要的操作,无论它们在表达式中的位置如何,然后再进行其余的操作。在讨论包含混合操作符的表达式时,你经常会看到“运算顺序”的术语被使用。

乘法、除法和取余(*, /, %)操作将在加法和减法(+, -)操作之前执行。当你有一系列相同或等效的操作符时,这些计算将从左向右进行。通常情况下这是没问题的,通过仔细安排表达式的部分我们可以得到需要的答案。当我们不能依赖简单的排列顺序时,我们可以使用括号创建特定的自定义操作顺序。考虑以下代码片段:

int average1 = 14 + 20 / 2;    // or 14 + 10 which is 24
int average2 = 14 / 2 + 20;    // or  7 + 20 which is 27
int average3 = (14 + 20) / 2;  // or 34 /  2 which is 17, yay!

这里有三种排列方式,但只有最后一个average3是正确的。括号表达式14 + 20首先被计算。可以这样思考,括号比算术操作有更高的优先级。顺便说一句,你可以随意使用括号,即使它只是为了增加对本来就正确排序的表达式的视觉清晰度。

小贴士

“视觉清晰度”的概念非常主观。如果括号在计算正确答案时是必需的,那么当然你需要使用它们。如果它们并不是绝对必需的,只要能帮助你更轻松地阅读表达式,你就可以随处使用。有时候括号太多反而会增加代码的阅读难度。最重要的是,在使用中保持一致性。

如果你有特别混乱的表达式,括号也可以嵌套,类似于其中的一些情况:

int messy1 = 6 * 7 / ((4 + 5) / 2);
int messy2 = ((((1 + 2) * 3) + 4) / 5);

在这些表达式中,最内层的括号表达式(1 + 2)首先被计算,然后你逐步向外处理。

类型转换

我们在本章中已经讨论了变量类型,但表达式也有类型,有时这会给未经培训的人带来惊喜。考虑以下代码片段:

double one_third = 1 / 3;
int x = 5;
int y = 12;
float average = (x + y) / 2;

你猜得出如果我们打印出one_thirdaverage会显示什么吗?试着创建一个小的 C 程序来测试你的理论。你的结果应该像这样:

One third: 0.000000
Average: 8.000000

但是“one third”应该是 0.333333,我们 12 和 5 的平均数应该是 8.5。发生了什么?嗯,编译器看到了一堆整数,并进行了整数运算。如果你回想起小学时学的长除法,你可能知道,“3 除以 1 得 0,余数是 3。”对于 C 语言来说,这意味着整数 1 除以整数 3 得到整数 0。(回忆一下,如果需要,%运算符会给出余数值。)

有没有办法获得我们想要的浮点数答案呢?是的!实际上,有许多方法可以得到正确的答案。也许在我们的虚构示例中最简单的方法是在初始化表达式中使用浮点字面量:

double one_third = 1.0 / 3.0;
int x = 5;
int y = 12;
float average = (x + y) / 2.0;

尝试更改你的程序,希望你能得到新的正确输出:

One third: 0.333333
Average: 8.500000

但是如果我们不使用字面量呢?如果我们在代码片段中改变平均计算以使用第三个int变量呢?

int x = 5;
int y = 12;
int count = 2;
float average = x + y / count;

在这种情况下,我们如何使平均值正确出现?C 支持类型转换,允许你告诉编译器将一个值视为具有其他某种类型。对于这种情况非常方便。我们可以像这样将我们的count变量转换float

float average = x + y / (float)count;

在想要转换为“更小”类型的值或表达式之前,你需要将所需类型放在括号中。现在我们的计算中有一个浮点数值,剩下的计算将会“升级”为浮点表达式,从而得到正确的答案。这种升级过程不仅仅是一种偶然。编译器是有意这样做的,这个过程甚至有一个名字,隐式类型转换。⁷ 图 2-4 展示了我们讨论过的许多数值类型的升级路径。

在任何涉及不同类型的表达式中,“最大”的类型将获胜,其他人将被提升为该类型。请注意,这样的转换有时会丢失一些重要信息。如果一个负数被提升为无符号类型,它将失去它的符号。或者,如果将一个长整数提升为float甚至double,可能会得到非常糟糕的近似值。

smac 0204

图 2-4. 隐式类型转换层级结构

就像括号可以增加清晰度而不改变计算一样,如果它有助于你理解表达式正在做什么,你总是可以使用显式转换。但请注意,操作顺序仍然有效。例如,以下语句并不都是相同的:

float average1 = (x + y) / (float)count;
float average2 = (float)(x + y) / count;
float average3 = (float)((x + y)/ count);

如果你将这些行添加到你的测试程序中,然后打印出三个平均值,你会注意到前两个没问题,但第三个不行。你看出为什么了吗?第三个计算中的括号导致先进行了带有所有整数类型的原始错误平均值,然后才将这个错误答案提升为float类型。

警告

我还应该指出,任何时候你想要向“更小”的类型转换,都必须使用显式转换。幸运的是,编译器通常可以捕捉到这些情况并警告你。

下一步

语句是任何计算机语言的核心,我们已经看到了 C 语言如何使用它们来赋值、执行计算和输出结果的基本语法。你可能需要习惯在语句末尾加上分号,但不久之后这将开始感觉自然。输入这些示例并运行它们是你获得这种愉悦感的最佳途径。

如果你尝试过任何计算演示程序,可能会诱惑你为其中一个除数输入零。(如果你没有尝试过,请现在尝试!)然而,C 语言无法除以零并放弃计算。你将收到像“Floating point exception (core dumped)”这样的错误,或者像“NaN”这样的结果,表示“非数值”。我们如何避免这样的崩溃?下一章将讨论比较操作和控制语句,使我们能够做到这一点。

¹ 虽然 C 语言在 1990 年代增加了对宽字符的某些支持,但通常不太适用于更流行的 UTF 字符编码,如 UTF-8、UTF-16 等。这些编码允许多字节字符,而 C 的char类型则设计为单字节。 (更多关于类型的信息请参见“字符串和字符”)。如果你处理国际化或本地化文本,你可能需要研究一些库来帮助。虽然我不会详细介绍本地化,但我在第七章中对库进行了更深入的探讨。

² 你可能仍然会在网上看到关于是否包含或排除“回车符”的讨论,这只是旧编码人员对行尾标记的俚语。这个术语继承自早期的打字机,其具有将纸张车辆返回到起始位置的字面机制,以便你可以开始下一行文字。

³ 美国信息交换标准代码(American Standard Code for Information Interchange),最初是为电传打字机而设计的 7 位编码。现在有 8 位变体,仍然基于英语。其他更具可扩展性的编码,如 Unicode 及其 UTF-8 选项,已成为常态。

char 类型实际上可以是有符号的或无符号的,这取决于你的编译器。

⁵ 这些格式由 IEEE(电气和电子工程师协会)规定。32 位版本称为“单精度”,64 位版本称为“双精度”。还存在更高精度,规范(IEEE 754)仍在发展中。

⁶ 例如,GNU C 编译器并没有强加任何限制。但为了兼容性和一致性,仍然建议保持远低于 31 个字符。

⁷ 有时您可能会听到“类型提升”或“自动类型转换”这样的术语。

第三章:控制流

现在您已经看到了 C 语言语句的基本格式,是时候开始展开了……打趣一下。在代码中,做出决策然后选择要运行的特定代码而不是其他代码片段的概念通常称为分支条件分支。而重复则经常以循环迭代的术语来讨论。总体来说,分支和循环语句组成了语言中的控制流

一些问题可以通过一系列简单的线性步骤来解决。许多自动化各种计算机任务的程序就是这样工作的,将一个繁琐的例行程序简化成你需要时可以运行的单个应用程序。但程序可以做的远不止处理一批命令。它们可以根据变量的值或传感器的状态做出决策。它们可以重复任务,例如打开一串灯中的每个 LED 或处理日志文件中的每一行。它们还可以以复杂、嵌套的方式结合决策和重复,使您作为程序员能够解决几乎任何可以想到的问题。在本章中,我们将探讨 C 语言如何实现这些概念。

布尔值

要在 C 语言中提出问题,通常需要比较两个(或更多)事物。C 语言有几个专门用于此任务的运算符。您可以检查两个事物是否相同。您可以检查两个事物是否不同。您可以查看某个值是否小于或大于另一个值。

当您像“x 是否与 y 相同”这样提出问题时,您会得到一个是或否、真或假的答案。在计算机科学中,这些称为布尔值,源自乔治·布尔(George Boole),他致力于形式化逻辑操作和结果的系统。一些语言具有布尔值和变量的实际类型,但 C 语言主要使用整数:0 表示假/否,1 表示真/是。¹

注意

在 C 语言中,任何非 0 的值都表示为真。因此,1 是真,2 是真,-18 是真,等等。我会指出每当我执行依赖于这一事实的检查时。这可能很方便,在现实世界中您肯定会看到它被使用,但我会集中精力在尽可能进行显式比较上。

比较运算符

数学,当然,并不是计算机擅长的唯一领域。当我们开始编写更复杂的程序时,我们将需要能够对系统状态做出决策。我们需要比较变量与期望值,并防范错误条件。我们需要检测列表的末尾和其他数据结构。幸运的是,所有这些需求都可以通过 C 语言的比较运算符来实现。

C 定义了六个运算符(如表 3-1 所示),可用于比较值。我们使用这些运算符就像我们使用表 2-6 中的数学运算符一样。你在左边有一个变量、值或表达式,在右边有一个变量、值或表达式。这里的区别是使用比较运算符的结果始终是一个布尔int,意味着它总是10

表 3-1. 比较运算符

操作符 比较
== 等于
!= 不等于
< 小于
> 大于
<= 小于或等于
>= 大于或等于

在 C 语言中,比较运算符适用于字符、整数和浮点数。一些语言支持可以处理更复杂数据位如数组(我将在第四章中介绍),记录或对象的运算符,但是 C 语言使用函数(在第五章中介绍)来完成这类工作。

当比较两个相同类型的表达式时,你可以毫不费力地使用表 3-1 中的运算符(见表 3-1)。如果你比较不同类型的表达式,比如一个float变量和一个int值,那么隐式转换的概念(见图 2-4)将适用,并且较低的类型值在比较之前会被提升。

我们马上会在“分支”和“循环语句”中使用这些比较运算符,但是我们可以快速转到简单的打印语句,展示 0 或 1 的结果。考虑ch03/booleans.c

#include <stdio.h>

int main() {
  printf(" 1 == 1  : %d\n", 1 == 1);
  printf(" 1 != 1  : %d\n", 1 != 1);
  printf(" 5 < 10  : %d\n", 5 < 10);
  printf(" 5 > 10  : %d\n", 5 > 10);
  printf("12 <= 10 : %d\n", 12 <= 10);
  printf("12 >= 10 : %d\n", 12 >= 10);
}

继续编译该文件并运行它。你应该看到类似于这样的输出:

ch03$ gcc booleans.c
ch03$ ./a.out
 1 == 1  : 1
 1 != 1  : 0
 5 < 10  : 1
 5 > 10  : 0
12 <= 10 : 0
12 >= 10 : 1

当你看到“真”比较的结果为1时,如我之前所述。相反,“假”在幕后为0

逻辑运算符

我们在代码中想要询问的一些问题不能简化为单个比较。一个非常常见的问题,例如,是询问一个变量是否在某些值的范围内。我们需要知道所讨论的变量是否大于某个最小值小于某个最大值。C 语言没有创建范围或测试成员资格的运算符。但是 C 支持逻辑运算符(有时你会听说布尔运算符),以帮助你构建相当复杂的逻辑表达式。

要开始,请查看表 3-2 中的运算符。

表 3-2. 布尔运算符

操作符 操作 注释
! 产生其操作数的逻辑相反的一元运算符
&& 连接;两个操作数必须都为真才返回真
|| 析取;如果至少一个操作数为真则为真

这些运算符可能看起来有点奇怪,你可能不熟悉逻辑运算,所以给自己一些时间来熟悉这些符号。如果现在还不太舒服也不要担心。布尔代数并不是一个常见的小学课题!但你肯定会在在线代码中遇到这些运算符,所以让我们确保你了解它们的工作原理。

提示

在讨论编程语言时称之为“逻辑”或“布尔代数”是有用的,但你可能从人类语言(比如我这里使用的英语)中已经有了这些概念的经验:这些运算符形成连词。语法课上的经典“and”、“but”和“or”大致相当于 C 语言中的&&!||。将这些布尔表达式翻译成英语甚至可以帮助你理解它们的意图。考虑“x > 0 && x < 100”。试着大声朗读这个表达式:“x 大于零且 x 小于 100。”如果通过拼写这些表达式有助于理解,那么当遇到新代码时,这是一个简单的技巧。

在逻辑中,这些运算符的最佳描述是它们的结果。这些结果通常在真值表中显示,列举出所有可能的输入组合及其结果。幸运的是,只有两个可能的值,真和假,这些组合是可管理的。每个运算符都有自己的真值表。表 3-3 列出了&&运算符的输入和结果。让我们从这里开始。

表 3-3. && (与)运算符

a b a && b
true true true
true false false
false true false
false false false

正如表格所示,这是一个相当严格的运算符。两个输入必须同时为真才能得到真。根据之前的提示,在英语连词方面可以很有用:“我们在 Reg 和 Kaori 都准备好之前不能去派对。”如果 Reg 没有准备好,我们必须等待。如果 Reg 准备好了,但 Kaori 没有准备好,我们也必须等待。当然,如果两者都没准备好,我们就得等。事实上,Reg 和 Kaori 都是相当守时的人,等待很少成为问题。😉

表 3-4 显示了使用||时相同输入组合的结果。

表 3-4. || (或)运算符

a b a || b
true true true
true false true
false true true
false false false

这是一个更宽松的运算符。回到我们的聚会旅行隐喻,也许它在工作日晚上举行,我们不能指望我们的朋友们立刻放下一切加入。对于这种变体,如果任何一个RegKaori 可以加入,那么我们将与一个好的晚餐伴侣共度美好时光。与&&运算符类似,如果两者都能加入,那太棒了!我们依然可以度过一个愉快的晚上。³ 然而,如果两个输入都为 false,总体答案仍然是 false,我们将独自一人。

最后一个运算符 C 用于构建逻辑表达式的支持是!。它是一个一元运算符,意味着它仅操作一个事物,而不是像数学或比较运算符需要的两个事物那样进行二元操作。这意味着它的表格,表 3-5,稍微简单一些。

表 3-5. !(非)运算符

a !a
true false
false true

在编码中,这个“非”操作经常用于在继续之前防止错误。我们最后的聚会例子:只要我们遇到交通阻碍,我们将准时到达聚会。这个运算符创建了一个相反的结果。所以“交通很糟糕”与“没有交通很好”。将这个转换成英语并不是那么直接,但希望仍然能说明问题,即你可以谈论执行的逻辑。

分支

现在我们知道如何将逻辑问题翻译成有效的 C 语法,那么我们如何利用这些问题呢?我们将从条件语句或分支的概念开始。我们可以提出一个问题,然后根据答案执行一些语句组(或不执行)。

if 语句

最简单的条件语句是 if 语句。它有三种形式,其中最简单的是“做或不做”的配置。这个语句的语法非常简单。你提供 if 关键字,括号内的测试,然后是一个语句或代码(用大括号括起来的一个或多个语句的组合),如下所示:

// For single statements, like a printf():
if (test)
  printf("Test returned true!\n");

// or for multiple statements:
if (test) {
  // body goes here
}

如果我们使用的布尔表达式为 true,则我们将执行if行后面的语句或块。如果表达式为 false,则我们将跳过该语句或块。

考虑一个简单的程序,询问用户一个数字输入。您可能希望在出现不常见的输入时通知用户,以防他们输入错误。例如,我们可以允许负数,但也许这不是通常的方式。我们仍然希望程序运行,但我们警告用户可能会得到意外的结果。ch03/warnings.c 中的程序是一个简单的示例:

#include <stdio.h>

int main() {
  int units = 0;
  printf("Please enter the number of units found: ");
  scanf("%d", &units);
  if (units < 0) { // start of our "if" code block
    printf("  *** Warning: possible lost items ***\n");
  } // end of our "if" code block
  printf("%d units received.\n", units);
}

如果我们用几个不同的输入运行这个程序,你可以看到 if 语句的效果。只有最后一次运行显示了警告:

ch03$ gcc warnings.c
ch03$ ./a.out
Please enter the number of units found: 12
12 units received.

ch03$ ./a.out
Please enter the number of units found: 7
7 units received.

ch03$ ./a.out
Please enter the number of units found: -4
  *** Warning: possible lost items ***
-4 units received.

尝试输入程序,然后自行编译和运行它。尝试更改测试以查找其他内容,如偶数或奇数,或在范围内或范围外的数字。

我们还可以使用if语句从布尔值中获得更人性化的响应。我们可以把测试放入if语句中,然后根据真实情况打印出任何真实响应。这里是我们更新的示例;我们将其称为ch03/booleans2.c

#include <stdio.h>

int main() {
  if (1 == 1) {
    printf(" 1 == 1\n");
  }
  if (1 != 1) {
    printf(" 1 != 1\n");
  }
  if (5 < 10) {
    printf(" 5 < 10\n");
  }
  if (5 > 10) {
    printf(" 5 > 10\n");
  }
  if (12 <= 10) {
    printf("12 <= 10\n");
  }
  if (12 >= 10) {
    printf("12 >= 10\n");
  }
}

给这个新程序一个尝试,你应该会得到类似于这样的输出:

ch03$ gcc booleans2.c
ch03$ ./a.out
 1 == 1
 5 < 10
12 >= 10

很棒!只有返回 true 的测试才会打印。这样更易读。这种带有printf()if结合是一种常见的调试技巧。每当你遇到一个有趣(或令人担忧)的条件时,打印一个警告,并可能包含相关的变量以帮助你解决问题。

否则

使用简单的if,我们可以看到返回 true 的测试的漂亮输出。但是如果我们还想知道测试何时为 false 怎么办?这就是if语句的第二种形式的作用;它包括一个else子句。你总是和if一起使用else。(单独使用else是语法错误,程序无法编译。)if/else语句最终有两个分支:一个在测试为真时执行,另一个在测试为假时执行。让我们构建ch03/booleans3.c,为每个测试得到一个大拇指或一个大拇指向下的答案:

#include <stdio.h>

int main() {
  if (1 == 1) {
    printf(" 1 == 1\n");
  } else {
    printf(" *** Yikes! 1 == 1 returned false\n");
  }
  if (1 != 1) {
    printf(" *** Yikes! 1 != 1 returned true\n");
  } else {
    printf(" 1 != 1  is false\n");
  }
  if (5 < 10) {
    printf(" 5 < 10\n");
  } else {
    printf(" *** Yikes! 5 < 10 returned false\n");
  }
  if (5 > 10) {
    printf(" *** Yikes! 5 > 10 returned true\n");
  } else {
    printf(" 5 > 10  is false\n");
  }
  if (12 <= 10) {
    printf(" *** Yikes! 12 <= 10 returned false\n");
  } else {
    printf("12 <= 10 is false\n");
  }
  if (12 >= 10) {
    printf("12 >= 10\n");
  } else {
    printf(" *** Yikes! 12 >= 10 returned false\n");
  }
}

如果我们使用之前相同的输入运行它,我们将看到答案的令人满意的扩展:

ch03$ gcc booleans3.c
ch03$ ./a.out
 1 == 1
 1 != 1  is false
 5 < 10
 5 > 10  is false
12 <= 10 is false
12 >= 10

完美。我们对每个测试都有可读的答案。现在我们不必担心测试是否运行失败或被跳过。每次都能得到有用的响应。试着升级warnings.c文件,以便在数字“异常”时仍然收到警告,同时向用户提供友好的消息,表明其输入处于预期范围内。

else if 链

现在我们的工具包中有一些非常强大的决策语句。我们可以做某事或跳过它。我们可以做一件事或做另一种选择。如果我们需要在三个语句之间做出决定呢?或四个?或更多?这种情况的一种可能模式是第三种if的变体:if/else if/else组合。

C 语言允许你“链式”使用if/else来实现从多个选项中选择一个。考虑游戏得分根据表现分为一星、二星或三星的情况。使用else if块可以实现这种类型的答案。这里是ch03/stars.c的示例:

#include <stdio.h>

int main() {
  int score = 0;
  printf("Enter your score (1 - 100): ");
  scanf("%d", &score);
  if (score > 100) {
    printf("Bad score, must be between 1 and 100.\n");
  } else if (score >= 85) {
    printf("Great! 3 stars!\n");
  } else if (score >= 50) {
    printf("Good score! 2 stars.\n");
  } else if (score >= 1) {
    printf("You completed the game. 1 star.\n");
  } else {
    // Only here because we have a negative score
    printf("Impossible score, must be positive.\n");
  }
}

这里是一些示例运行:

ch03$ gcc stars.c
ch03$ ./a.out
Enter your score (1 - 100): 72
Good score! 2 stars.

ch03$ ./a.out
Enter your score (1 - 100): 99
Great! 3 stars!

ch03$ ./a.out
Enter your score (1 - 100): 4567
Bad score, must be between 1 and 100.

ch03$ ./a.out
Enter your score (1 - 100): 42
You completed the game. 1 star.

ch03$ ./a.out
Enter your score (1 - 100): -42
Impossible score, must be positive.

但也许我们的游戏很特别,有四星表现。(哇!)文件ch03/stars2.c展示了如何使用额外的else if子句来实现更多选择!

#include <stdio.h>

int main() {
  int score = 0;
  printf("Enter your score (1 - 100): ");
  scanf("%d", &score);
  if (score > 100) {
    printf("Bad score, must be between 1 and 100.\n");
  } else if (score == 100) {
    printf("Perfect score!! 4 stars!!\n");
  } else if (score >= 85) {
    printf("Great! 3 stars!\n");
  } else if (score >= 50) {
    printf("Good score! 2 stars.\n");
  } else if (score >= 1) {
    printf("You completed the game. 1 star.\n");
  } else {
    // Only here because we have a negative score
    printf("Impossible score, must be positive.\n");
  }
}

还有更多示例输出,以验证我们的新最高分运行正常:

ch03$ gcc stars2.c
ch03$ ./a.out
Enter your score (1 - 100): 100
Perfect score!! 4 stars!!

ch03$ ./a.out
Enter your score (1 - 100): 64
Good score! 2 stars.

ch03$ ./a.out
Enter your score (1 - 100): 101
Bad score, must be between 1 and 100.

你可以无限继续这些链条。嗯,合理范围内的无限。最终你会受到内存限制,超过一些从句之后,跟随这样的链条流程就会变得困难。如果感觉你的else/if块太多了,也许花一点时间检查你的算法,看看是否有其他方法来分解你的测试会更值得。

if gotchas

这些else/if链的语法提示了我之前简要提到的 C 语法细节。如果从句中确切地只有一个语句,ifelse块就不需要花括号。例如,我们的booleans3.c可以像这样编写(ch03/booleans3_alt.c):

#include <stdio.h>

int main() {
  if (1 == 1)
    printf(" 1 == 1\n");
  else
    printf(" *** Yikes! 1 == 1 returned false\n");
  if (1 != 1)
    printf(" *** Yikes! 1 != 1 returned true\n");
  else
    printf(" 1 != 1  is false\n");
  // ...
}

在线上你肯定会碰到类似这样的代码。这样做可以节省一点输入,并使测试和语句更加简洁。你可以使用花括号来创建一个只有一个语句的块,就像我们在原始的booleans3.c代码中所做的那样。这与在数学运算中使用额外的括号类似:虽然不是必需的,但对于可读性是有用的。当你只需执行一个操作时,这主要是一种风格问题。然而,如果要执行两个或更多操作,则始终需要使用花括号,因此我会坚持使用花括号来未来保护我们的代码。(并且在风格上,我更喜欢看到括号的一致性。)如果以后回来更新某些示例并需要添加另一个打印语句,比如说,我们就不必记住添加花括号;它们已经准备好等待使用了。

if语句中使用的测试也可能会带来问题,如果不小心就会出错。记住关于 C 语言将零视为假,而任何其他数字视为真的注释?一些程序员依赖于这一事实来编写非常紧凑的测试。考虑以下代码片段:

int x;
printf("Please enter an integer: ");
scanf("%i", x);
if (x) {
  printf("Thanks, you gave us a great number!\n");
} else {
  printf("Oh. A zero. Well, thanks for \"nothing\"! ;)\n");
}

if从句将为任何正数或负数执行,就好像我们构建了一个真正的测试,比如x != 0或者甚至是一个更复杂的逻辑表达式,比如(x < 0 || x > 0)。这种模式被用作(有时是懒惰的)检查“这个变量是否有任何值”的捷径,其中零被假定为无效可能性。这是一个相当常见的模式,尽管我通常更喜欢编写明确的测试。

C 语言中另一个大问题是使用整数作为布尔值的代理:有一个非常微妙的拼写错误可能会导致真正的麻烦。看看下面的代码片段:

int first_card = 10;
int second_card = 7;
int total = first_card + second_card;

if (total = 21) {
  printf("Blackjack! %d\n", total);
}

如果你感兴趣,请继续创建一个程序来尝试这个陷阱。当你运行它时,你会看到你总是得到“Blackjack!21”输出。发生了什么?仔细看看if语句中的测试条件。我们本意是写成total == 21,使用双等号比较运算符。但是我们使用了单等号,实际上在if测试条件中为我们的total变量赋值了 21!在 C 语言中,赋值也是表达式,就像我们的数学计算一样。赋值表达式的值与被赋的新值相同。总之,这个测试类似于if (21) ...,因为 21 不是 0,所以总是为真。这种情况非常容易出错。只要注意那些似乎无论你如何改变输入都会执行的if语句。这种行为提示你重新检查你正在使用的测试条件。

switch语句

我在“else if 链”中指出,如果if/else if链中有太多的测试条件链接在一起,会变得难以跟踪。不过,有时确实需要检查一堆特定情况,比如基于你的尺码检查你喜爱的在线商店里有什么衬衫。如果这些情况都涉及同一个变量,并且所有情况都使用简单的等式(==)作为测试条件,那么在 C 语言中可以使用switch语句作为一个不错的选择。

switch语句接受一个表达式(控制表达式),通常是一个变量或简单计算,然后系统地将该表达式的值与一个或多个常量值使用case标签进行比较。如果控制表达式的值与某个case匹配,那么从该值后面的代码开始执行,并持续执行到switch语句的结束(总是一个大括号块)或程序遇到break命令为止。ch03/medals.c文件包含了一个简单的例子:

#include <stdio.h>

int main() {
  int place;
  printf("Enter your place: ");
  scanf("%i", &place);
  switch (place) {
  case 1:
    printf("1st place! Gold!\n");
    break;
  case 2:
    printf("2nd place! Silver!\n");
    break;
  case 3:
    printf("3rd place! Bronze!\n");
    break;
  }
}

如果你编译并多次运行这个程序,每次使用三种可能的输入,你应该会看到像这样的结果:

ch03$ gcc medals.c
ch03$ ./a.out
Enter your place: 2
2nd place! Silver!

ch03$ ./a.out
Enter your place: 1
1st place! Gold!

ch03$ ./a.out
Enter your place: 3
3rd place! Bronze!

太好了!正是我们预期的。但是如果我们将那些break行注释掉会怎么样?现在让我们试试,因为这展示了switch的一个关键怪癖,可能会让新程序员困惑。这是我们修改过的程序,ch03/medals2.c

#include <stdio.h>

int main() {
  int place;
  printf("Enter your place: ");
  scanf("%i", &place);
  switch (place) {
  case 1:
    printf("1st place! Gold!\n");
  case 2:
    printf("2nd place! Silver!\n");
  case 3:
    printf("3rd place! Bronze!\n");
  }
}

这是使用第一次使用的相同输入系列的新输出:

ch03$ gcc medals2.c
ch03$ ./a.out
Enter your place: 2
2nd place! Silver!
3rd place! Bronze!

ch03$ ./a.out
Enter your place: 1
1st place! Gold!
2nd place! Silver!
3rd place! Bronze!

ch03$ ./a.out
Enter your place: 3
3rd place! Bronze!

哎呀,这真是太奇怪了。一旦开始运行,程序会继续执行switch语句中的语句,即使它们属于不同的case。虽然这看起来可能不是一个好主意,但这是switch的一个特性,而不是一个 bug。这种设计允许您为多个值执行相同的操作。考虑下面的片段,描述了 1 到 10 之间任意数字的偶数、奇数和质数情况:

printf("Describing %d:\n", someNumber);
switch(someNumber) {
  case 2:
    printf("  only even prime\n");
    break;
  case 3:
  case 5:
  case 7:
    printf("  prime\n");
  case 1:
  case 9:
    // 1 isn't often described as prime, so we'll just let it be odd
    printf("  odd\n");
    break;
  case 4:
  case 6:
  case 8:
  case 10:
    printf("  even\n");
    break;
}

我们可以将案例安排得如此之巧,以至于流动到break开关特性正好给我们提供了完全正确的输出。尽管这种特性通常用于收集一系列相关但不同的值(比如我们的偶数),然后给它们相同的执行块,但打印“主要”限定词然后继续添加“奇数”标识的流程也是有效的,有时候也很方便。

处理默认值

switch中还有一个与if语句的else子句类似的特性。有时候,你希望你的switch语句处理每一个可能的输入。但列出几千个整数,甚至只是字母表中的每一个字母,无论如何都会非常乏味。通常情况下,您并不对所有这些成千上万个选项都有独特的动作。在这些情况下,您可以使用default标签作为最后的“case”,它将执行控制表达式值的任何情况。

注意

从技术上讲,default可以出现在案例列表的任何位置,而不仅仅是作为最后一个选项。然而,由于在遇到时default情况总是运行,包括后续特定案例并没有意义。

例如,使用我们的medals.c程序,那些没有进入领奖台的参赛者怎么样?尝试再次运行它,使用一些大于三的数字。你得到了什么?什么也没有。没有错误,没有输出,什么都没有。让我们写一个ch03/medals3.c,并使用default选项来打印一个消息,至少证明我们看到了输入:

#include <stdio.h>

int main() {
  int place;
  printf("Enter your place: ");
  scanf("%i", &place);
  switch (place) {
  case 1:
    printf("1st place! Gold!\n");
    break;
  case 2:
    printf("2nd place! Silver!\n");
    break;
  case 3:
    printf("3rd place! Bronze!\n");
    break;
  default:
    printf("Sorry, you didn't make the podium.\n");
  }
}

编译并运行这个新程序,并尝试一些大于三的值:

ch03$ gcc medals3.c
ch03$ ./a.out
Enter your place: 8
Sorry, you didn't make the podium.

ch03$ ./a.out
Enter your place: 88
Sorry, you didn't make the podium.

ch03$ ./a.out
Enter your place: 5792384
Sorry, you didn't make the podium.

很棒!无论我们输入什么大于三的数字,我们都会得到一些反馈,表明我们已经处理了该输入。正是我们想要的。并且我们甚至可以在包含多个案例每个块安排的switch语句中使用default。让我们为我们的奖牌描述程序添加一个“Top 10”级别,ch03/medals4.c

#include <stdio.h>

int main() {
  int place;
  printf("Enter your place: ");
  scanf("%i", &place);
  switch (place) {
  case 1:
    printf("1st place! Gold!\n");
    break;
  case 2:
    printf("2nd place! Silver!\n");
    break;
  case 3:
    printf("3rd place! Bronze!\n");
    break;
  case 4:
  case 5:
  case 6:
  case 7:
  case 8:
  case 9:
  case 10:
    printf("Top 10! Congrats!\n");
    break;
  default:
    printf("Sorry, you didn't make the podium.\n");
  }
}

再编译一次,然后用几个输入运行它:

ch03$ gcc medals4.c
ch03$ ./a.out
Enter your place: 4
Top 10! Congrats!

ch03$ ./a.out
Enter your place: 1
1st place! Gold!

ch03$ ./a.out
Enter your place: 20
Sorry, you didn't make the podium.

ch03$ ./a.out
Enter your place: 7
Top 10! Congrats!

很好。这里有一个快速的家庭作业给你。修改medals4.c,以便如果你获得第四或第五名,你会被标记为“亚军”。第 6 到 10 名仍然应列为前十名。(这是一个小改变。你可以查看你的答案和我的答案ch03/medals5.c来对比。)

三元运算符和条件赋值

Lean 代码中另一个经常使用的条件主题是条件赋值的概念。C 语言包含一个三元运算符,?:,它接受三个操作数。它允许您在非常紧凑的语法中使用两个值之一。这个三元表达式的结果确实是 C 语言中任何其他表达式一样的值,所以您可以在任何值合法的地方使用?:

?:的语法使用布尔表达式作为第一个操作数,然后是问号,然后是要评估的表达式(如果布尔为真),然后是冒号,最后是另一个要评估的表达式(如果布尔为假)。

使用三元运算符的一个很好的例子是获取两个值中较小的一个。考虑一个简单的程序,处理两个对某些图形设计工作的竞标。预算遗憾地成为主导因素,所以你需要接受最低的竞标价。

int winner = (bid1 < bid2) ? bid1 : bid2;

非常密集!即使只是阅读这些三元表达式也需要一些练习,但一旦掌握,我认为你会发现它是一个非常方便的运算符。另一种方法是相对冗长的if/else

int winner;
if (bid1 < bid2) {
  winner = bid1;
} else {
  winner = bid2;
}

当然,这并不是一个糟糕的替代方案,但它确实更冗长。而且在使用三元方法简化事情的时候确实有时候。还记得第一个布尔表达式程序,booleans.c,在“比较运算符”中吗?我们必须接受将 1 解释为“true”和 0 解释为“false”。最终我们在booleans3.c中打印了漂亮的陈述,但我们必须使用相当冗长的if/else模式。然而,使用?:,我们可以直接在printf()语句中生成友好的输出。尝试ch03/booleans4.c,看看你的感觉:

#include <stdio.h>

int main() {
  printf(" 1 == 1  : %s\n", 1 == 1 ? "true" : "false");
  printf(" 1 != 1  : %s\n", 1 != 1 ? "true" : "false");
  printf(" 5 < 10  : %s\n", 5 < 10 ? "true" : "false");
  printf(" 5 > 10  : %s\n", 5 > 10 ? "true" : "false");
  printf("12 <= 10 : %s\n", 12 <= 10 ? "true" : "false");
  printf("12 >= 10 : %s\n", 12 >= 10 ? "true" : "false");
}

这是我们更新后的输出:

ch03$ gcc booleans4.c
ch03$ ./a.out
 1 == 1  : true
 1 != 1  : false
 5 < 10  : true
 5 > 10  : false
12 <= 10 : false
12 >= 10 : true

好多了。

小贴士

booleans3.c中将每个打印调用都包裹在if/else块中有些麻烦。不仅令人恼火,打印文本中的共享部分如果有任何更改可能会失去同步。例如,如果在一行的开头发现了一个拼写错误,你需要确保在if子句的printf()开头和else子句中再次修复它。很容易忘记其中之一。

每当可以通过使用不同的条件语句或操作符避免这种重复代码时,都值得考虑。但不要过于热衷;如果你的if/else链感觉可读且能产生正确的输出,那仍然是一个不错的选择。

循环语句

你可以只用变量和我们迄今为止涵盖的输入、输出和分支语句解决一些有趣的问题。但计算机真正擅长的一个领域是当你需要重复测试或一批语句时。为了执行重复操作,你可以使用 C 语言的一种循环语句。你的程序将执行所有(可选的)语句,并在这些语句结束时“循环”回到起点并再次执行它们。通常情况下,你不希望该循环永远运行,因此每个循环语句都有一个条件来检查并确定何时应该停止循环。

for语句

编程中经常遇到的一种重复是为特定次数重复执行某个代码块。例如,每周做某件事情,或者处理输入的前 5 行,甚至只是简单地数到 10。事实上,让我们来看看计数到 10 的for循环,如图 3-1 所示,我在循环的各个部分做了标记。(随意输入或打开ch03/ten.c文件。)起初可能看起来有点凌乱,但随着时间的推移,它会变得更加熟悉。

smac 0301

图 3-1. 一个带注释的 for 循环

在我们查看循环的详细信息之前,这里是输出:

ch03$ gcc ten.c
ch03$ ./a.out
Loop iteration: 1
Loop iteration: 2
Loop iteration: 3
Loop iteration: 4
Loop iteration: 5
Loop iteration: 6
Loop iteration: 7
Loop iteration: 8
Loop iteration: 9
Loop iteration: 10

1

(int i = 1) 这是我们的循环变量。我们使用与普通变量相同的声明和初始化语法。循环的这一部分总是首先执行,并且只在循环开始时执行一次。

2

(i <= 10) 这里是测试循环何时结束的条件。只要此测试返回 true,循环就会运行。如果此条件为 false —— 即使是在第一次检查时也是如此 —— 循环将结束。

3

接下来执行循环的主体,假设 2 中的测试返回 true。

4

(i = i + 1) 完成主体后,会评估这个调整表达式。这个表达式通常会增加或减少我们的循环变量一次。在这一步之后,控制会跳回到 2,以查看循环是否应该继续。

初始化、结束条件的检查和调整都非常灵活。你可以使用任何你喜欢的名称,并且可以以任意数量进行递增或递减。甚至可以使用 char 类型的变量,如果你有任何原因需要顺序字符。

让我们尝试一些更简单的 for 循环,来练习它的语法和流程。我们将初始化我们的循环变量,检查是否应该开始循环,执行主体中的语句,进行调整,然后检查是否应该继续。重复。我们将尝试一些带有不同调整的循环,包括一个可以用来向后计数的递减,ch03/more_for.c

#include <stdio.h>

int main() {
  printf("Print only even values from 2 to 10:\n");
  for (int i = 2; i <= 10; i = i + 2) {
    printf("  %i\n", i);
  }
  printf("\nCount down from 5 to 1:\n");
  for (int j = 5; j > 0; j = j - 1) {
    printf("  %i\n", j);
  }
}

这是我们的输出:

ch03$ gcc more_for.c
ch03$ ./a.out
Print only even values from 2 to 10:
  2
  4
  6
  8
  10

Count down from 5 to 1:
  5
  4
  3
  2
  1

尝试调整一些循环中的值并重新编译。你能逆向计数两个吗?你能从 1 数到 100 吗?你能从 1 数到 1,024 并且每次都加倍吗?

递增快捷方式

像我们在这些调整表达式中所做的那样对变量进行递增或递减是一个非常常见的任务(即使在循环之外也是如此),因此 C 支持多种用于这种类型变化的快捷方式。考虑以下形式的语句:

var = var op value

// Examples
i = i + 1
y = y * 5
total = total - 1

其中 var 是某个变量,op 是来自 Table 2-6 的算术运算符之一。如果你在代码中使用了这种模式,可以使用复合赋值代替:

var op= value

// Converted examples
i += 1
y *= 5
total -= 1

进一步说,每当你对变量加减 1 时,你可以使用更简洁的变体:

var++ or var--

// Further converted examples
i++
total--
注意

你可能会看到“前缀”版本的递增和递减快捷方式,例如 ++i--total。这些变体是合法的,并且在我们使用的 for 循环中有微妙的区别,不会起作用。

你不一定要使用这些简洁的选项,但它们很受欢迎,你在编码网站如 Stack Overflow 或 Arduino 示例中肯定会遇到它们。

用于捕捉疏忽的注意事项

在我们讨论 C 中的其他循环选项之前,我想指出关于for循环的一些细节可能会让你犯错。

for循环语法中可能最重要的元素是循环设置中间的条件。你需要确保条件允许循环开始以及更明显地需要循环停止的能力。考虑这个循环片段:

  for (int x = 1; x == 11; x++) {
    // ....
  }

循环的明显目的是计数到 10——当x等于 11 时停止。但是条件必须为真才能运行循环,所以你不能只是等待结束。

你还需要确保你的条件和调整表达式是同步的。我最喜欢的错误之一是创建一个用于倒计数或逆向计数的循环,但我忘记使用递减操作:

  for (int countdown = 10; countdown > 0; countdown++) {
    // ....
  }

显然,在这个设置的最后一部分我应该写countdown--,但递增是如此常见,几乎已成为肌肉记忆。看看这个循环。你能看出会发生什么吗?这个循环不会朝着停止条件前进,而是会继续前进相当长的时间。不幸的是,编译器无法真正帮助我们,因为这种语法是完全合法的。错误是一个逻辑错误,因此作为程序员,你需要捕捉它。

另一个容易犯的大错误与for循环设置的语法有关。请注意,表达式是由分号而不是逗号分隔的:

  for (int bad = 1, bad < 10, bad++) {
    // ....
  }

  for (int good = 1; good < 10; good++) {
    // ....
  }

那个细节很容易被忽略,你可能会至少犯这个错误一次。在这里,编译器会捉住你,尽管:

ch03$ gcc bad_ten.c
ten.c: In function ‘main’:
ten.c:4:23: error: expected ‘=’, ‘,’, ‘;’, ‘asm’ before ‘<=’ token
    4 |   for (int bad = 1, bad <= 10, bad++) {
      |                     ^~
ten.c:7:1: error: expected expression before ‘}’ token
    7 | }
      | ^
ten.c:7:1: error: expected expression before ‘}’ token
ten.c:7:1: error: expected expression before ‘}’ token

当然很容易修复,但在学习过程中要注意这一点。这些类型的错误在直接输入代码而不是从在线源粘贴时更容易遇到(然后修复!)。出于这个原因,我真的建议手动输入本书中的一些程序清单。

while 语句

在计算机编程中,执行特定次数的迭代无疑是一个流行的任务。但是,循环直到满足某些更一般的条件同样常见。在 C 中,更通用的循环是while循环。它只有一个简单的条件作为其唯一的语法元素。如果条件为真,则执行循环体。跳回去检查条件……并重复。

这种类型的循环非常适合需要扫描多少信息不可预测的输入。让我们尝试一个简单的程序来计算一些数字的平均值。关键是,我们允许用户输入他们想要的任意数量(或任意少量)的数字。我们将要求他们输入一个标志值来指示他们已经完成了给我们新数字的输入。标志可以是任何与预期值不同的值。我们在我们的条件中使用它,所以我们知道何时停止。例如,让我们要求用户输入 1 到 100 之间的数字。然后我们可以使用 0 作为标志。这里是ch03/average.c

#include <stdio.h>

int main() {
  int grade;
  float total = 0.0;
  int count = 0;
  printf("Please enter a grade between 1 and 100\. Enter 0 to quit: ");
  scanf("%i", &grade);
  while (grade != 0) {
    total += grade;
    count++;
    printf("Enter another grade (0 to quit): ");
    scanf("%i", &grade);
  }
  if (count > 0) {
    printf("\nThe final average is %.2f\n", total / count);
  } else {
    printf("\nNo grades were entered.\n");
  }
}

这里有两个不同输入的样本运行:

ch03$ gcc average.c
ch03$ ./a.out
Please enter a grade between 1 and 100\. Enter 0 to quit: 82
Enter another grade (0 to quit): 91
Enter another grade (0 to quit): 77
Enter another grade (0 to quit): 43
Enter another grade (0 to quit): 14
Enter another grade (0 to quit): 97
Enter another grade (0 to quit): 0

The final average is 67.33

ch03$ ./a.out
Please enter a grade between 1 and 100\. Enter 0 to quit: 0

No grades were entered.

我们开始询问用户第一个数字。然后我们在while语句中使用这个响应。如果他们第一次输入了 0,我们就完成了。与for循环不同,while循环可能根本不执行是很常见的。有合理的情况,你可能需要迭代一个可选任务,比如关闭智能家居中的所有灯光。但是因为是可选的,有时这意味着你根本不执行;如果灯光已经关闭,那就什么也不需要做。

假设他们给了我们一个有效的数字,我们就开始循环。我们将他们的输入添加到一个单独的变量中,用于保存运行的总数(在编程中,这有时被称为累加器)。我们还递增第三个变量count,以跟踪用户给我们的数字数量。

我们提示用户输入下一个数字(或者输入 0 退出)。我们获取他们的输入,并且再次将该值用于while循环的条件。如果最近的成绩有效,将其添加到总数并重复。

一旦我们完成循环,我们就打印结果。我们使用if/else语句来将最终结果包装在一个友好的人类句子中。如果他们在开始时输入了 0,我们指出没有平均值可打印。否则(else),我们以两位小数的精度打印平均值。

do/while变体

C 语言中最后一种循环语句是do/while(有时简称为do循环)。从名称就可以猜到,它与while循环类似,但有一个重要的不同。do循环自动保证至少执行一次循环体。它在执行循环体后而不是之前检查循环条件。这在你知道至少需要一次循环时非常有用。我们的成绩平均程序实际上是一个完美的例子。我们至少需要一次询问用户的成绩。如果他们一开始给了我们一个 0,我们就结束了,这是可以接受的。如果他们给了我们一个有效的数字,我们就累加总数并再次询问。使用do循环以及对我们计数的小调整,我们可以避免在ch03/average2.c中重复调用scanf()

#include <stdio.h>

int main() {
  int grade;
  float total = 0.0;
  int count = 0;
  do {
    printf("Enter a grade between 1 and 100 (0 to quit): ");
    scanf("%i", &grade);
    total += grade;
    count++;
  } while (grade != 0);
  // We end up counting the sentinel as a grade, so undo that
  count--;

  if (count > 0) {
    printf("\nThe final average is %.2f\n", total / count);
  } else {
    printf("\nNo grades were entered.\n");
  }
}

输出基本上是一样的:

ch03$ gcc average2.c
ch03$ ./a.out
Enter a grade between 1 and 100 (0 to quit): 82
Enter a grade between 1 and 100 (0 to quit): 91
Enter a grade between 1 and 100 (0 to quit): 77
Enter a grade between 1 and 100 (0 to quit): 43
Enter a grade between 1 and 100 (0 to quit): 14
Enter a grade between 1 and 100 (0 to quit): 97
Enter a grade between 1 and 100 (0 to quit): 0

The final average is 67.33

没有太大的区别——实际上结果没有任何区别——但是每当你可以删除不影响功能的代码行时,你就在减少出错的机会。这总是件好事!

嵌套

将循环和条件语句添加到你的工具箱中,极大地扩展了你能解决的问题范围。但更好的是:你可以在循环内部嵌套if语句以监视错误条件,在if内部放置while以等待传感器的信号,或者在一个for循环内部再次使用for循环以遍历表格数据。记住,所有这些控制语句仍然只是语句,它们可以在允许更简单的语句的任何地方使用。

让我们利用这种嵌套能力进一步改进我们的平均程序。我们知道零是“完成”值,但我们说我们希望值在 1 到 100 之间。如果用户给我们一个负数会发生什么?或者一个大于 100 的数字?如果你仔细看average2.c中的代码,你会发现我们对此没有做太多处理。我们不退出或丢弃它。如果我们在循环内部使用if/else语句,就像ch03/average3.c中那样,我们可以做得更好:

#include <stdio.h>

int main() {
  int grade;
  float total = 0.0;
  int count = 0;
  do {
    printf("Enter a grade between 1 and 100 (0 to quit): ");
    scanf("%i", &grade);
    if (grade >= 1 && grade <= 100) {
      // Valid! Count it.
      total += grade;
      count++;
    } else if (grade != 0) {
      // Not valid, and not our sentinel, so print an error and continue.
      printf("   *** %d is not a valid grade. Skipping.\n", grade);
    }
  } while (grade != 0);

  if (count > 0) {
    printf("\nThe final average is %.2f\n", total / count);
  } else {
    printf("\nNo grades were entered.\n");
  }
}

很棒。我们甚至修复了average2.ccount变量的小问题,我们必须将count减 1,因为即使第一个条目为 0,我们也执行了整个do/while循环的主体。非常好的升级!

让我们用一些简单的输入测试这个程序,以便验证坏值没有包含在平均值中:

ch03$ gcc average3.c
ch03$ ./a.out
Enter a grade between 1 and 100 (0 to quit): 82
Enter a grade between 1 and 100 (0 to quit): -82
   *** -82 is not a valid grade. Skipping.
Enter a grade between 1 and 100 (0 to quit): 43
Enter a grade between 1 and 100 (0 to quit): 14
Enter a grade between 1 and 100 (0 to quit): 9101
   *** 9101 is not a valid grade. Skipping.
Enter a grade between 1 and 100 (0 to quit): 97
Enter a grade between 1 and 100 (0 to quit): 0

The final average is 59.00

我们可以检查数学:82 + 43 + 14 + 97 = 236。236 ÷ 4 = 59。这与我们的结果相符,所以我们的嵌套if/else是有效的。万岁!

提示

随着您使用嵌套控制语句构建更复杂的程序,您可能会遇到需要在循环通常完成之前退出循环的情况。令人高兴的是,您在switch语句讨论中看到的break命令可用于立即退出循环。一些程序员试图避免这种“作弊”,但有时我认为这实际上使代码更易读。

一个常见的用例是在循环中遇到来自用户输入的错误。与其尝试向循环条件添加额外的逻辑,不如使用if语句测试错误,如果确实出现错误,就break

嵌套循环和表格

让我们尝试另一个例子。我提到使用嵌套的for循环处理表格数据。我们可以利用这个想法在ch03/multiplication.c中生成小学经典的乘法表:

#include <stdio.h>

int main() {
  int tableSize = 10;
  for (int row = 1; row <= tableSize; row++) {
    for (int col = 1; col <= tableSize; col++) {
      printf("%4d", row * col);
    }
    printf("\n"); // final newline to move to the next row
  }
}

这很小。这是程序可以非常高效解决的重复性任务。而且得到的表格如下:

ch03$ gcc multiplication.c
ch03$ ./a.out
   1   2   3   4   5   6   7   8   9  10
   2   4   6   8  10  12  14  16  18  20
   3   6   9  12  15  18  21  24  27  30
   4   8  12  16  20  24  28  32  36  40
   5  10  15  20  25  30  35  40  45  50
   6  12  18  24  30  36  42  48  54  60
   7  14  21  28  35  42  49  56  63  70
   8  16  24  32  40  48  56  64  72  80
   9  18  27  36  45  54  63  72  81  90
  10  20  30  40  50  60  70  80  90 100

非常令人满意!而且你不仅限于只有两个循环。你可以使用三个循环处理三维数据,就像这个片段中所示:

  for (int x = -5; x <= 5; x++) {
    for (int y = -5; y <= 5; y++) {
      for (int z = -5; z <= 5; z++) {
        // Do something with your 3D (x, y, z) coordinate
        // or use even more nested elements like checking for the origin
        if (x == 0 && y == 0 && z == 0) {
          printf("We found the origin!\n");
        }
      }
    }
  }

几乎没有结束你可以在代码中包装的复杂性,以解决即使是最棘手的问题。

变量作用域

在 C 语言中嵌套语句的一个重要事项是,语言在其块中强制执行变量作用域。例如,如果你创建一个用于for循环的变量,那么该变量在循环完成就不能再使用。这对于在块内(例如,在一对花括号内)或在for循环的设置中声明的任何变量都是适用的。一旦块结束,变量就不再可访问。(有时你会听到程序员谈论变量的可见性,这是相同的概念。)

大多数时候,你不必过多考虑这个话题,因为你通常会自然地在声明变量的地方使用它们,这很好。但在复杂的代码结构中,你可能会忘记变量的声明位置,这可能会导致问题。

让我们升级我们的乘法表程序,询问用户想要生成多大的表格(合理范围内!)。我们将允许任何从 1 到 20 的表格大小。我们将用户的响应存储在一个变量中,可以供两个循环使用。尝试以下程序(ch03/multiplication2.c),并注意突出显示一些潜在问题区域的评论,其中一个变量不可见。

#include <stdio.h>

int main() {
  int tableSize;
  printf("Please enter a size for your table (1 - 20): ");
  scanf("%i", &tableSize);
  if (tableSize < 1 || tableSize > 20) {
    printf("We can't make a table that size. Sorry!\n");
    printf("We'll use the default size of 10 instead.\n");
    tableSize = 10;
  }
  for (int row = 1; row <= tableSize; row++) {      ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
    // row and tableSize are both in scope
    for (int col = 1; col <= tableSize; col++) {    ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
      // row, col, and tableSize are all in scope
      printf("%4d", row * col);                     ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
    }
    // col is now _out_ of scope ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
    printf("\n"); // final newline to move to the next row
  }
  // row is out of scope now, too, but tableSize remains available }

1

你可以看到我们的tableSize变量在两个循环中都是可见的。

2

显然,在由col变量驱动的循环内部,row变量是可见的。

3

但是一旦内部的for循环完成了给定行的值的打印,col变量就会“超出范围”,不能再使用。

但是,如果你尝试访问已经超出范围的东西会发生什么呢?幸运的是,编译器通常会警告你。例如,如果我们尝试在当前打印换行符结束行时打印col的最终值,我们将会得到如下错误:

ch03$ gcc multiplication2.c
multiplication2.c: In function ‘main’:
multiplication2.c:19:20: error: ‘col’ undeclared (first use in this function)
   19 |     printf("%d\n", col); // final newline to move to the next row
      |                    ^~~

犯这些错误从来不会致命。你只需阅读错误消息并找出导致问题的代码部分即可。如果你在循环或块结束后确实需要使用特定的变量,你必须在块之前定义该变量。例如,我们可以在与tableSize声明相同的位置声明我们的两个循环变量rowcol,以便它们在main()函数的任何地方都可见。我们在for循环的初始化步骤中不会声明这些变量为int类型,而只是赋予起始值,就像在ch03/multiplication3.c中一样。

#include <stdio.h>

int main() {
  int tableSize, row, col;
  printf("Please enter a size for your table (1 - 20): ");
  scanf("%i", &tableSize);
  if (tableSize < 1 || tableSize > 20) {
    printf("We can't make a table that size. Sorry!\n");
    printf("We'll use the default size of 10 instead.\n");
    tableSize = 10;
  }
  // Notice that since we declared row and col above, we do not
  // include the "int" type declaration inside the for loops below
  for (row = 1; row <= tableSize; row++) {
    for (col = 1; col <= tableSize; col++) {
      printf("%4d", row * col);
    }
    printf("\n");
  }
  printf("\nFinal variable values:\n");
  printf("  row == %d\n col == %d\n tableSize == %d\n", row, col, tableSize);
}

如果我们以宽度为 5 运行我们的新版本,这是我们的输出:

ch03$ gcc multiplication3.c
ch03$ ./a.out
Please enter a size for your table (1 - 20): 5
   1   2   3   4   5
   2   4   6   8  10
   3   6   9  12  15
   4   8  12  16  20
   5  10  15  20  25

Final variable values:
  row == 6
  col == 6
  tableSize == 5

因此,我们可以看到导致循环停止的rowcol的最终值。看起来很整洁,但也容易引起问题。因为这些潜在问题,不建议使用具有广泛或全局范围的变量。如果你有充分理由并且需要在不同的块中使用特定的变量,那是可以的,只是确保你有意识地声明这些变量,而不是仅仅为了让程序编译通过。

练习

在本章中,我们已经看到了几种不同的控制流结构的结构。希望你在阅读过程中已经尝试并调整了这些示例。但没有什么比反复使用新语言或新语句更能帮助你熟悉它了。为此,在继续阅读之前,这里有一些练习供你尝试。

  1. 打印出一个三角形模式。你可以硬编码尺寸或者询问用户,就像我们在乘法表中所做的那样。例如:

    exercises$ ./a.out
    Please enter a size for your triangle (1 - 20): 5
    *
    **
    ***
    ****
    *****
    
  2. 打印出一个金字塔模式的图案,其中星号的行是居中的,就像这样:

    exercises$ ./a.out
    Please enter a size for your triangle (1 - 20): 5
        *
       * *
      * * *
     * * * *
    * * * * *
    
  3. 将行和列标签添加到我们的乘法表中,如下所示:

    exercises$ ./a.out
    Please enter a size for your table (1 - 20): 5
         1   2   3   4   5
     1   1   2   3   4   5
     2   2   4   6   8  10
     3   3   6   9  12  15
     4   4   8  12  16  20
     5   5  10  15  20  25
    
  4. 编写一个猜数字游戏。目前,只需自己选择一个数字并将其存储在变量如secret中即可。(我们将在第七章中讨论让计算机为我们选择随机数字的方法。)告诉用户范围的界限,并在他们猜测时给予关于他们的猜测是低于还是高于秘密数字的线索。玩游戏可能看起来像这样:

    exercises$ ./a.out
    Guess a number between 1 and 50: 25
    Too low! Try again.
    Guess a number between 1 and 50: 38
    Too low! Try again.
    Guess a number between 1 and 50: 44
    Too high! Try again.
    Guess a number between 1 and 50: 42
    *** Congratulations! You got it right! ***
    
  5. 尝试实现欧几里德算法,用于找出两个数的最大公约数。在伪代码(英语语句排列如代码,并偶尔使用像“=”这样的操作符;它意味着描述某个程序步骤的一种方式,而不需要真正的代码)中,该算法如下所示:

    Start with two positive numbers, a and b
    While b is not zero:
      Is a greather than b?
        Yes: a = a - b
        No: b = b - a
    Print a
    

你可以在程序中设置这两个值,或者要求用户输入它们。要检查你的程序,比如 3,456 和 1,234 的最大公约数是 2,而 432 和 729 的最大公约数是 27。

如果你想看看我是如何解决这些问题的,你可以查看ch03/exercises文件夹中的各种答案。但我鼓励你在查看我的解决方案之前尝试自己解决它们。每个练习都有许多种解决方法,比较你自己的方法和我的方法可以帮助巩固我们所学过的语句的语法和用途。

接下来的步骤

这一章涵盖的分支和重复语句是计算机程序解决问题能力的核心。它们使得将现实世界的算法转换为代码成为可能。了解 C 语言的控制语句还带来一个额外的好处,即为其他编程语言做好准备,这些语言通常借用了 C 语言的一些语法。

当然,还有更多的语法需要覆盖。在下一章中,我们将看看 C 语言如何处理存储大量东西的最流行工具之一:数组。并且,我们将了解 C 如何用于操作计算机中最小的东西:位。

¹ C99 引入了一个新类型,_Bool,但在我们的精简代码中我们不会使用它。如果你在自己的编码中遇到布尔逻辑,请务必查看stdbool.h头文件。你可以在 Prinz 和 Crawford 的C in a Nutshell(O’Reilly)中找到关于 C 的更多细节。

² 许多语言,包括 C 语言,都足够聪明,意识到如果 Reg 尚未准备好,我们甚至无需打扰 Kaori。这种行为通常称为“短路评估”。当涉及的测试计算昂贵时,短路比较非常有用。

³ 就像&&运算符一样,C 编译器通过优化 Reg 可以连接的情况,根本不询问 Kaori。

⁴ 你知道吗,许多洗发水瓶子都带有一个洗头发的算法?但不要太过拘泥于算法:很多时候指令真的就是那么简单的“涂抹、冲洗、重复”,这是一个无限循环!没有检查何时重复次数足够的内容。

⁵ 如果你好奇的话,有一个快速的技术细节。前缀运算符出现在它们所操作的值或表达式之前。i--表达式包含了一个后缀运算符的示例—后缀运算符出现在值或表达式之后。在 C 语言中,所有的二元运算符如+、或==都是中缀*运算符,出现在操作数“之间”。

第四章:位与(大量的)字节

在我们开始使用像函数这样的更复杂程序之前,例如第五章,我们应该涵盖 C 语言中另外两种有用的存储类型:数组和单个位。这些不像intdouble那样是真正的独立类型,但在处理微小事物或大量事物时非常有用。事实上,数组的概念,即项目的顺序列表,非常有用,我们在“获取用户输入”时不得不有所作弊,没有进行详细解释即使用它来存储字符串形式的用户输入。

我们还讨论了布尔值的概念,即是或否,真或假,1 或 0。特别是在处理微控制器时,您将定期使用一小组传感器或开关提供的开/关值。使用 C 的正常存储选项意味着将整个char(8 位)或int(16 位)用于跟踪这些微小值。这感觉有点浪费,确实如此。C 有一些技巧可以更有效地存储这类信息。在本章中,我们将通过声明数组来处理大事物,并访问和操作其内容,以及如何处理最小的位(咳咳)。 (我保证不再开更多位字谐音笑话。大部分时间。)

使用数组存储多个项

几乎不可能找到一个不使用数组解决真实世界问题的 C 程序。如果您必须处理任何类型的任何值的集合,这些值几乎肯定会最终进入数组中。成绩列表,学生列表,美国州缩写列表等等。即使是我们的微小机器也可以使用数组来跟踪 LED 条上的颜色。可以毫不夸张地说,数组在 C 中是无处不在的,因此让我们更仔细地看看如何使用它们。

创建和操作数组

正如我之前提到的,我们在第二章(在“获取用户输入”中)使用了数组以允许用户输入。让我们重新审视那段代码(ch04/hello2.c)并更加关注字符数组:

#include <stdio.h>

int main() {
  char name[20];

  printf("Enter your name: ");
  scanf("%s", name);
  printf("Well hello, %s!\n", name);
}

那么,char name[20]声明究竟做了什么呢?它创建了一个名为“name”的变量,基本类型为char,但它是一个数组,因此您可以获得存储多个char的空间。在这种情况下,我们请求了 20 个字节,如图 4-1 所示。

smac 0401

图 4-1. 名为namechar类型空数组

当我们运行程序时,这个数组变量会发生什么情况?当你在键盘上输入一个名称并按回车键时,你输入的字符会被放置在数组中。由于我们使用了scanf()及其字符串(%s)格式字段,我们将自动获得一个标记字符串结尾的尾随空字符('\0'或有时'\000')。在内存中,name变量现在看起来像图 4-2。

smac 0402

图 4-2. 带有字符串的 char 数组
注意

数组末尾的空字符是字符串的一个特殊之处;这并不是其他类型数组的管理方式。字符串通常存储在在字符串长度之前未知的数组中,并使用这个'\0'哨兵,就像我们在“while 语句”中标记有用输入的结尾一样。C 中的所有字符串处理函数都希望看到这个终止字符,并且在你自己处理字符串时可以依赖它的存在。

现在当我们在后续的printf()调用中再次使用name变量时,我们可以回显存储的所有字母,空字符告诉printf()何时停止,即使名称没有占据整个数组。相反,打印没有终止字符的字符串将导致printf()在数组结束后继续进行,并可能导致崩溃。

长度与容量

我们难道不是分配了 20 个字符的空间吗?如果我们的名称(如“Grace”)没有占据所有空间,它们会做什么?幸运的是,最后的空字符相当巧妙地解决了这个问题。确实,我们确实有足够的空间来存储更长的名称,如“Alexander”或者甚至“Grace Hopper”;无论数组有多大,空字符始终标志着字符串的结尾。

警告

如果你之前没有在 C 或其他语言中使用过字符,那么空字符的概念可能会让人感到困惑。它是具有数字值 0(零)的字符。这与空格字符(ASCII 32)或数字 0(ASCII 48)或换行('\n' ASCII 10)不同。通常情况下,你不必担心手动添加或放置这些空字符,但重要的是要记住它们出现在字符串的末尾,即使它们从不被打印。

但是如果名称对分配的数组来说太长怎么办?让我们找出来!再次运行程序并输入一个较长的名称:

ch04$ ./a.out
Enter your name: @AdmiralGraceMurrayHopper
Well hello, @AdmiralGraceMurrayHopper!
*** stack smashing detected ***: terminated
Aborted (core dumped)

有趣。因此,我们声明的容量是一个相当严格的限制——如果我们溢出数组,事情就会出错。¹ 好好知道!在使用之前,我们总是需要保留足够的空间。²

如果我们不知道数组中有多少个槽位会怎样?C 的 sizeof 运算符可以帮助解决这个问题。它可以告诉你(以字节为单位)变量或类型的大小。对于简单类型,这是 intchardouble 的长度。对于数组,它是分配的总内存。这意味着只要知道其基本类型,我们就可以知道数组中有多少个槽位。让我们尝试创建一个 double 值数组,比如说,用于会计分类账。我们假装不知道能存储多少个值,并使用 sizeof 找出来。看一下 ch04/capacity.c

#include <stdio.h>

int main() {
  double ledger[100];
  printf("Size of a double: %li\n", sizeof (double));
  printf("Size of ledger: %li\n", sizeof ledger);
  printf("Calculated ledger capacity: %li\n", sizeof ledger / (sizeof (double)));
}

注意,在询问类型的大小时,需要括号。编译器需要这额外的上下文来将关键字视为表达式。对于像 ledger 这样已经符合表达式定义的变量,我们可以省略它们。让我们运行我们的小程序。这是输出:

ch04$ gcc capacity.c
ch04$ ./a.out
Size of a double: 8
Size of ledger: 800
Calculated ledger capacity: 100

不错。因为我们确实知道我们的数组有多大,所以我们可以直接将选择的大小与我们计算的结果进行比较。它们匹配。(哎呀!)但是有些情况下,你从独立的源获取信息,不会总是知道数组的大小。请记住像 sizeof 这样的工具存在并且可以帮助你理解这些信息。

初始化数组

到目前为止,我们已经创建了空数组或在运行时从用户输入中加载了 char 数组。与更简单的变量类型一样,C 允许在定义它们时初始化数组。

对于任何数组,你可以在一对花括号内提供一个由逗号分隔的值列表。以下是一些例子:

int days_in_month[12] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
char vowels[6] = { 'a', 'e', 'i', 'o', 'u', 'y' };
float readings[7] = { 8.9, 8.6, 8.5, 8.7, 8.9, 8.8, 8.5 };

注意声明的数组大小与初始化数组时提供的值的数量匹配。在这种情况下,C 允许一个好用的简写:你可以在方括号之间省略显式大小。编译器将分配正确数量的内存以完全适应初始化列表。这意味着我们可以像这样重新编写我们之前的片段:

int days_in_month[] = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
char vowels[] = { 'a', 'e', 'i', 'o', 'u', 'y' };
float readings[] = { 8.9, 8.6, 8.5, 8.7, 8.9, 8.8, 8.5 };

字符串是一个特殊情况。C 支持 字符串字面量 的概念。这意味着你可以将双引号之间的字符序列作为值使用。你可以使用字符串字面量来初始化一个 char[] 变量。你几乎可以在任何允许使用字符串变量的地方使用它。(我们在 “三元操作符和条件赋值” 中看到了这一点,我们使用了三元操作符 (?:) 将 true 和 false 值打印为单词,而不是作为 1 或 0。)

// Special initialization of a char array with a string literal
char secret[] = "password1";

// The printf() format string is usually a string literal
printf("Hello, world!\n");

// And we can print literals, too
printf("The value stored in %s is '%s'\n", "secret", secret);

你也可以通过在花括号内提供单个字符来初始化字符串,但这通常更难阅读。你必须记得包括终止的空字符,而这种冗长的选项并没有提供其他真正的优势,超过了使用字符串字面量。

访问数组元素

一旦您创建了一个数组,您可以使用方括号访问数组内部的单个元素。在方括号内给出一个索引数字,其中第一个元素的索引值为 0。例如,要打印出先前数组中的第二个元音字母或七月份的天数:

  printf("The second vowel is: %c\n", vowels[1]);
  printf("July has %d days.\n", days_in_month[6]);

如果将这些语句捆绑到一个完整的程序中,它们将产生以下输出:

The second vowel is: e
July has 31 days.

但我们在方括号内提供的值不需要是一个固定的数字。它可以是任何导致整数的表达式(如果内存足够,它可以是一个long或其他更大的整数类型)。这意味着您可以将计算或变量用作您的索引。例如,如果我们将“当前月份”存储在一个变量中,并使用月份的典型值——1 表示一月,2 表示二月,依此类推——那么我们可以使用以下代码打印出七月的天数:

  int month = 7;
  printf("July (month %d) has %d days.", month, days_in_month[month - 1]);

访问这些成员的便利性和灵活性是数组如此受欢迎的一部分。经过一些实践,您会发现它们是不可或缺的!

警告

方括号内的值需要“在边界内”,否则您将在运行时收到错误消息。例如,如果您尝试打印第 15 个月的天数,就像我们尝试七月那样,您会看到类似“无效(第 15 个月)有-1574633234 天”的消息。C 不会阻止您——请注意我们没有导致崩溃——但我们也没有得到可用的值。而赋值无效数组中的值(接下来我们将讨论的内容)是如何导致缓冲区溢出的。这种经典的安全漏洞因数组作为存储缓冲区的概念而得名。您“溢出”它正是通过给数组分配超出实际数组范围之外的值。如果您运气好(或非常狡猾),您可以编写可执行代码,并欺骗计算机运行您的命令而不是预期的程序。

更改数组元素

您还可以使用方括号表示法更改给定数组位置的值。例如,我们可以改变二月的天数以适应闰年:

if (year % 4 == 0) {
  // Forgive the naive leap year calculation :)
  days_in_month[1] = 29;
}

当您有更加动态的数据时,这种后声明的赋值非常方便(甚至经常是必需的)。例如,对于稍后要讨论的 Arduino 项目,您可能希望保留最近的 10 次传感器读数。当您声明数组时,并没有这些读数。因此,您可以设置 10 个槽位,并稍后填入:

float readings[10];
// ... interesting stuff goes here to set up the sensor and read it
readings[7] = latest_reading;

只需确保您提供与数组相同类型的值(或至少是兼容的)。例如,我们的readings数组期望浮点数。如果我们将字符分配给其中一个槽位,它将“适合”在该槽位中,但会产生一个奇怪的答案。例如,将字母*x*分配给readings[8]将会将小写 x 的 ASCII 值(120)作为 120.0 的float值放入槽位中。

通过数组迭代

使用变量作为索引的能力使得处理整个数组成为一个简单的循环任务。例如,我们可以使用for循环打印出所有days_in_month的计数:

for (int m = 0; m < 12; m++) {
  // remember the array starts at 0, but humans start at 1
  printf("Days in month %d is %d.\n", m + 1, days_in_month[m]);
}

这个代码片段产生了以下输出。我们可以感受到数组和循环组合的强大潜力。仅仅几行代码,我们就得到了一些相当有趣的输出:

Days in month 1 is 31.
Days in month 2 is 28.
Days in month 3 is 31.
Days in month 4 is 30.
Days in month 5 is 31.
Days in month 6 is 30.
Days in month 7 is 31.
Days in month 8 is 31.
Days in month 9 is 30.
Days in month 10 is 31.
Days in month 11 is 30.
Days in month 12 is 31.

你可以自由地使用数组的元素,以任何你需要的方式。你并不仅仅局限于打印它们出来。例如,我们可以计算我们的readings数组的平均阅读值,如下所示:

float readings[] = { 8.9, 8.6, 8.5, 8.7, 8.9, 8.8, 8.5 };

// Use our sizeof trick to get the number of elements
int count = sizeof readings / sizeof (float);
float total = 0.0;
float average;
for (int r = 0; r < count; r++) {
  total += readings[r];
}
average = total / count;
printf("The average reading is %0.2f\n", average);

此示例突显了你在几章中学到的 C 语言有多少!如果你想要更多的练习,将这个代码片段构建成一个完整的程序。编译并运行它以确保它能正常工作。(顺便说一句,平均值应为 8.70。)然后添加更多的变量来捕获最高和最低的读数。你将需要一些if语句来帮助你。你可以在本章的示例arrays.c中看到一个可能的解决方案。

字符串回顾

我已经指出字符串实际上只是一些由语言本身支持的额外特性组成的char类型的数组,例如字面量。但由于字符串代表了与用户交流的最简单方式,我想更强调在 C 语言中你可以用字符串做些什么。

初始化字符串

我们已经看到如何声明和初始化字符串。如果你事先知道字符串的值,你可以使用字面量。如果你不知道值,你仍然可以声明变量,然后使用scanf()询问用户要存储的文本是什么。但如果你两者都想做怎么办?分配一个初始默认值,然后让用户提供一个可选的新值来覆盖默认值?

幸运的是,你可以做到,但你确实需要提前计划一下。当你首次声明变量时,可能会诱人地使用默认值,然后在运行时让用户提供不同的值。这样做是有效的,但它需要向用户提出额外的问题(“您想更改背景颜色吗,是或否?”),并且还假设用户将提供一个有效的替代值。在学习新语言时,这种假设通常是安全的,因为在那时你可能是唯一的用户。但在与他人共享的程序中,最好不要假设用户会做什么。

字符串字面量也让人很容易认为你可以像对intfloat变量那样简单地覆盖现有的字符串。但字符串实际上只是一个char[],而数组在声明时以外的情况下是不可分配的。

所有这些限制都可以通过使用诸如函数之类的东西来克服,我们将在第五章中探讨这些内容。事实上,使运行时操作字符串成为可能的函数的需求如此之大,它们已经被打包成了它们自己的库,我在“stdlib.h”中介绍了这一点。

现在,我希望你记住,字符串文字可以使字符数组的初始化简单和易读,但在其核心,C 中的字符串与数字和个别字符不同。

访问个别字符

但我确实想再次强调,字符串只是数组。你可以使用与访问任何其他数组成员相同的语法访问字符串中的个别字符。例如,我们可以通过查看短语中的每个字符来找出给定短语是否包含逗号。这里是ch04/comma.c

#include <stdio.h>

int main() {
  char phrase[] = "Hello, world!";
  int i = 0;
  // keep looping until the end of the string
  while (phrase[i] != '\0') {
    if (phrase[i] == ',') {
      printf("Found a comma at position %d.\n", i);
      break;
    }
    // try the next character
    i++;
  }
  if (phrase[i] == '\0') {
    // Rats. Made it to the end of the string without a match.
    printf("No comma found in %s\n", phrase);
  }
}

该程序实际上多次使用了字符串的数组性质。我们的循环条件取决于访问字符串中的单个字符,就像帮助回答我们最初问题的if条件一样。最后,我们测试单个字符以查看是否找到了某些内容。在第七章中,我们将查看几个与字符串相关的函数,但希望您能看到如何使用循环和方括号一次处理一个字符来完成诸如复制或比较字符串之类的任务。

多维数组

由于字符串已经是数组,可能并不明显,但你可以在 C 中存储字符串数组。但是因为在声明这种数组时没有“字符串类型”可供使用,你该如何做呢?事实证明,C 支持多维数组的概念,因此你可以创建一个char[]数组,就像创建其他数组一样:

char month_names[][];

看起来很公平。但在声明中不明显的是方括号对是指什么。在声明这样一个二维数组时,第一个方括号对可以被理解为行索引,第二个是列。另一种思考方式是,第一个索引告诉你有多少个字符数组我们将存储,第二个索引告诉你每个数组可以有多长

我们知道有多少个月份,并且一些研究告诉我们,最长的名称是九个字母的“九月”。再加上一个终止的空字符,我们可以像这样精确地定义我们的month_names数组:

char month_names[12][11];

由于我们知道月份的名称并且不需要用户输入,您也可以初始化这个二维数组:

char month_names[12][11] = {
  "January", "February", "March", "April", "May", "June", "July",
  "August", "September", "October", "November", "December"
};

但在初始化时,我使用了字符串文字,所以month_names数组的第二维度不太明显。第一维是月份,第二个(隐藏的)维度是构成月份名称的个别字符。如果你使用其他没有这种字符串文字快捷方式的数据类型,可以像这样使用嵌套的花括号列表:

int multiplication[5][5] = {
  { 0, 0, 0,  0,  0 },
  { 0, 1, 2,  3,  4 },
  { 0, 2, 4,  6,  8 },
  { 0, 3, 6,  9, 12 },
  { 0, 4, 8, 12, 16 }
};

或许你会认为编译器可以确定多维结构的大小,但遗憾的是,你必须为除第一维以外的每个维度提供容量。例如,对于我们的月份名称,我们可以开始时没有“12”表示名称数量,但不能没有“11”表示任何单个名称的最大长度:

// This shortcut is ok
char month_names[][11] = { "January", "February" /* ... */ };

// This shortcut is NOT
char month_names[][] = { "January", "February" /* ... */ };

你最终会内化这些规则,但编译器(以及许多编辑器)将始终在那里捕捉你如果你犯了一个小错误。

访问多维数组中的元素

对于我们的月份名称数组,获取任何特定月份的访问是直接的。它看起来就像访问任何其他一维数组的元素一样:

printf("The name of the first month is: %s\n", month_names[0]);

// Output: The name of the first month is: January

但是我们如何访问multiplication二维数组中的元素呢?我们使用两个索引:

printf("Looking up 3 x 4: %d\n", multiplication[3][4]);

// Output: Looking up 3 x 4: 12

注意,在这个乘法表中,将零作为第一个索引值的潜在奇怪用法实际上是一个有用的元素。索引“0”给我们一个有效的乘法答案行或列。

而使用两个索引,如果你想打印出所有数据,你将需要两个循环。我们可以利用我们在“嵌套循环和表格”中所做的工作,用它来访问我们存储的值,而不是直接生成数字。这是来自ch04/print2d.c的打印片段:

  for (int row = 0; row < 5; row++) {
    for (int col = 0; col < 5; col++) {
      printf("%3d", multiplication[row][col]);
    }
    printf("\n");
  }

这是我们精美格式化的表格:

ch04$ gcc print2d.c
ch04$ ./a.out
  0  0  0  0  0
  0  1  2  3  4
  0  2  4  6  8
  0  3  6  9 12
  0  4  8 12 16

我们将在第六章中看到一些其他选项,用于更加定制的多维存储。在短期内,只需记住你可以用更多的方括号对创建更多维度。虽然你大部分时间可能会使用一维数组,但表格是足够常见的,空间数据通常适合三维“立方体”。很少有程序员会需要它,尤其是那些专注于微控制器的人,但 C 语言支持更高阶的数组。

存储位

数组使我们能够相对轻松地存储大量数据。在另一端,C 语言有几个运算符可以用来操作非常少量的数据。事实上,你可以处理绝对最小的数据单元:个别位。

当 C 语言在 20 世纪 70 年代开发时,每个字节的内存都很昂贵,因此宝贵。正如我在本章开头提到的,如果你有一个特定的变量存储布尔答案,使用 16 位的int或甚至只有 8 位的char会有点浪费。如果你有一个这样的变量数组,那就会变得非常浪费。现在的台式电脑可以轻松处理这种浪费,但我们的微控制器通常需要尽可能节约存储空间。

二进制、八进制、十六进制

在我们讨论 C 语言中访问和操作位的操作符之前,让我们回顾一些讨论二进制值的符号。如果我们有一个单独的位,0 或 1 足以,这很简单。然而,如果我们想在一个int变量中存储十二个位,我们需要一种描述该int值的方法。技术上,int将具有十进制(十进制)表示,但十进制不能清晰地映射到单独的位。为此,八进制和十六进制表示法要清晰得多。(二进制,或者二进制 2 进制,表示法显然最清晰,但是大数在二进制中变得非常长。八进制和十六进制——通常只是“hex”——是一个很好的折衷方案。)

当我们谈论数字时,我们经常隐含地使用十进制,这要归功于我们手上的数字(哦,明白了吗?)。计算机没有手(当然不包括机器人),不使用十进制计数。它们使用二进制。两个数字,0 和 1,构成了它们整个世界的基础。如果将三个二进制数字分组,可以表示从 0 到 7 的十进制数,总共八个数字,因此这是八进制,或八进制。添加第四位,可以表示 0 到 15,这覆盖了十六进制中的单个“数字”。表 4-1 显示了这些四个基数中的前 16 个值。

表 4-1. 十进制、二进制、八进制和十六进制的数字

十进制 二进制 八进制 十六进制 十进制 二进制 八进制 十六进制
 0 0000 0000 000 0x00  8 0000 1000 010 0x08
 1 0000 0001 001 0x01  9 0000 1001 011 0x09
 2 0000 0010 002 0x02 10 0000 1010 012 0x0A / 0x0a
 3 0000 0011 003 0x03 11 0000 1011 013 0x0B / 0x0b
 4 0000 0100 004 0x04 12 0000 1100 014 0x0C / 0x0c
 5 0000 0101 005 0x05 13 0000 1101 015 0x0D / 0x0d
 6 0000 0110 006 0x06 14 0000 1110 016 0x0E / 0x0e
 7 0000 0111 007 0x07 15 0000 1111 017 0x0F / 0x0f

你可能注意到,我总是在二进制列中显示八个数字,八进制列中显示三个数字,十六进制列中显示两个数字。字节(8 位)是在 C 语言中常用的单位。二进制数字通常以四个一组显示,组数取决于所讨论的最大数。因此,对于完整的 8 位字节,可以存储 0 到 255 的任何值,例如,您会看到一个二进制值,其中包含两组四位数字。同样,三位数的八进制值可以显示任何字节的值,十六进制数需要两位数。还要注意,十六进制文字不区分大小写。(十六进制前缀中的“x”也不区分大小写,但大写的“X”可能较难辨认。)

在本书后半部分与微控制器一起工作时,我们会偶尔使用二进制表示法,但如果你写过任何 HTML、CSS 或类似的标记语言中的样式文本,你可能已经遇到过十六进制数。这些文档中的颜色通常用十六进制的字节值表示红色、绿色、蓝色以及偶尔的 alpha 通道(透明度)。因此,一个忽略 alpha 通道的全红色将是FF0000。现在你知道两个十六进制数字可以表示一个字节,读取这样的颜色值可能会更容易。

为了帮助你适应这些不同的进制,试着填写表 4-2 中缺失的数值。(你可以通过章节末的表 4-4 表来检查你的答案。顺便说一句,这些数字并没有特定的顺序,我想让你保持警觉!)

表 4-2. 各进制间的转换

十进制 二进制 八进制 十六进制
14 016
0010 0000
021 11
50 32
052
13
167
1111 1001

现代浏览器可以在搜索栏中为您转换进制,因此您可能不需要记住字节中可能的 256 个值的全部内容。但是,如果您可以估算十六进制值的大小或确定八进制 ASCII 码可能是字母还是数字,这仍然会很有用。

C 语言中的八进制和十六进制文字

C 语言有特殊选项用于表示八进制和十六进制的数值文字。八进制文字以简单的 0 作为前缀开始,尽管如果你保持所有值的宽度相同,你可以有多个零,就像我们在基础表中所做的那样。对于十六进制值,你使用前缀0x0X。通常情况下,你匹配‘X’字符的大小写与十六进制值中的任何A-F数字的大小写相匹配,但这只是一种约定。

这是一个演示如何使用一些这些前缀的片段:

int line_feed = 012;
int carriage_return = 015;
int red = 0xff;
int blue = 0x7f;

一些编译器支持用于表示二进制文字的非标准前缀或后缀,但正如“非标准”修饰符所示,它们不是官方 C 语言的一部分。

八进制和十六进制值的输入输出

printf()函数具有内置的格式说明符,帮助你生成八进制或十六进制输出。八进制值可以用%o说明符打印,十六进制可以用%x%X显示,取决于你想要小写还是大写输出。这些说明符可以与任何整数类型的变量或表达式一起使用,在任何基数中,这使得printf()成为从十进制到八进制或十六进制的一种相当简单的转换方式。我们可以很容易地通过循环和单个printf()生成类似于表 4-1(没有二进制列)的表格。我们可以利用格式说明符的宽度和填充选项来获取我们想要的三个八进制数字和两个十六进制数字。看看ch04/dec_oct_hex.c

#include <stdio.h>

int main() {
  printf(" Dec  Oct  Hex\n");
  for (int i = 0; i < 16; i++) {
    printf(" %3d  %03o  0x%02X\n", i, i, i);
  }
}

请注意,我们在每个三列中都重复使用了完全相同的变量。还请注意,在打印十六进制版本时,我手动添加了“0x”前缀——它不包括在%x%X格式中。这里有几行的第一行和最后一行:

ch04$ gcc dec_oct_hex.c
ch04$ ./a.out
 Dec  Oct  Hex
   0  000  0x00
   1  001  0x01
   2  002  0x02
   3  003  0x03
 ...
  13  015  0x0D
  14  016  0x0E
  15  017  0x0F

不错。正是我们想要的输出。在输入方面,使用scanf(),格式说明符以有趣的方式工作。它们仍然用于从用户那里获取数字输入。现在不同的说明符将在你输入的数字上执行基数转换。如果指定了十进制输入(%d),则不能使用十六进制值。相反,如果指定了十六进制输入(%x%X),并且只输入数字(即,不使用任何A-F数字),该数字仍将从十六进制转换。

注意

说明符%d%i通常是可以互换的。在printf()调用中,它们将产生相同的输出。但是,在scanf()调用中,%d选项要求你输入一个简单的十进制数。%i说明符允许你使用各种 C 文字前缀来输入不同基数的值,例如0x来输入十六进制数。

我们可以通过一个简单的转换程序来说明这一点,ch04/rosetta.c,它将不同的输入转换为所有三个输出基。我们可以在程序中设置我们期望的输入类型,但是使用if/else if/else块使其易于调整。(尽管仍然需要重新编译。)

#include <stdio.h>

int main() {
  char base;
  int input;

  printf("Convert from? (d)ecimal, (o)ctal, he(x): ");
  scanf("%c", &base);

  if (base == 'o') {
    // Get octal input
    printf("Please enter a number in octal: ");
    scanf("%o", &input);
  } else if (base == 'x') {
    // Get hex input
    printf("Please enter a number in hexadecimal: ");
    scanf("%x", &input);
  } else {
    // assume decimal input
    printf("Please enter a number in decimal: ");
    scanf("%d", &input);
  }
  printf("Dec: %d,  Oct: %o,  Hex: %x\n", input, input, input);
}

这里有几个示例运行:

ch04$ gcc rosetta.c

ch04$ ./a.out
Convert from? (d)ecimal, (o)ctal, he(x): d
Please enter a number in decimal: 55
Dec: 55,  Oct: 67,  Hex: 37

ch04$ ./a.out
Convert from? (d)ecimal, (o)ctal, he(x): x
Please enter a number in hexadecimal: 37
Dec: 55,  Oct: 67,  Hex: 37

ch04$ ./a.out
Convert from? (d)ecimal, (o)ctal, he(x): d
Please enter a number in decimal: 0x37
Dec: 0,  Oct: 0,  Hex: 0

有趣。前两次运行按计划进行了。第三次运行没有出现错误,但实际上也没有真正工作。这里发生的是scanf()的一种“特性”。它非常努力地尝试引入一个十进制数。它在我们的输入中找到了字符0,这是一个有效的十进制数字,因此它开始解析该字符。但接下来它遇到了x字符,这对于十进制数不是有效的。所以这就是解析的结束,我们的程序将值 0 转换为这三个基数。

尝试自己运行这个程序,然后几次切换模式。你得到了你期望的行为吗?你能引发任何错误吗?

知道了 %iscanf() 中其他数值规范之间的差异,你能看到如何简化这个程序吗?可以接受任何三种输入基数而不需要大量的 if 语句。我把这个问题留给你做为练习,但是你可以在本章的代码示例中的 rosetta2.c 文件中找到一个可能的解决方案。

位操作符

在像 C 语言这样的有限硬件上开始工作意味着偶尔需要在位级别上处理数据,与打印或读取二进制数据完全不同。C 语言通过位操作符支持此类工作。这些操作符允许你在 int 变量(当然也包括 charlong)内部调整单个位。我们将在第十章中看到这些特性在 Arduino 微控制器中的一些有趣用途。

表格 4-3 描述了这些操作符,并展示了使用以下两个变量的一些示例:

char a = 0xD; // 1101 in binary
char b = 0x7; // 0111 in binary

表格 4-3. C 语言中的位操作符

操作符 名称 描述 示例
& 按位与 两个位都必须为 1 才能返回 1 a & b == 0101
| 按位或 任一位可以为 1 以返回 1 a | b == 1111
! 按位非 返回输入位的相反值 ~a == 0010
^ 按位异或 异或,不匹配的位返回 1 a ^ b == 1010
<< 左移 将位向左移动若干位 a << 3 == 0110 1000
>> 右移 将位向右移动若干位 b >> 2 == 0001

技术上,你可以对任何变量类型应用位操作符以调整特定位。尽管如此,它们很少用于浮点类型。通常选择一个足够大的整数类型来保存所需的每个单独位。因为它们在给定变量的位上“编辑”,所以你经常会看到它们与复合赋值操作符 (op=) 一起使用。例如,如果你有五个 LED 灯,你可以用单个 char 类型变量来跟踪它们的开关状态,就像这个片段中一样:

char leds = 0;  // Start with everyone off, 0000 0000

leds |= 8;    // Turn on the 4th led from the right, 0000 1000
leds ^= 0x1f; // Toggle all lights, 0001 0111
leds &= 0x0f; // Turn off 5th led, leave others as is, 0000 0111

即使在只有一两千字节内存的微控制器上运行或存储程序时,五个 intchar 值可能不会影响到你,但是这些小的存储需求确实会累积起来。如果你正在跟踪拥有数百或数千个灯的面板的状态,那么你存储它们的状态有多紧密就很重要了。一个尺寸很少适合所有情况,所以请记住你的选择,并选择一个在使用便捷性和任何资源限制之间取得平衡的选项。

混合位和字节

现在我们已经掌握了足够多的 C 语言元素,可以开始编写一些非常有趣的代码了。我们可以结合之前讨论过的位、数组、类型、循环和分支来处理一种流行的将二进制数据编码为文本的方式。一种通过设备网络传输二进制数据并且资源可能有限的格式是将其转换为简单的文本行。这被称为“base64”编码,仍然被用于诸如内联电子邮件附件中的图像等情况。64 表示此编码使用 6 位块,2 的 6 次方等于 64。我们使用了数字、小写字母、大写字母和其他字符,通常是加号(+)和正斜杠(/)。³

对于这种编码,值从 0 到 25 是大写字母 A 到 Z。值从 26 到 51 是小写字母 a 到 z。值从 52 到 61 是数字 0 到 9,最后,值 62 是加号,值 63 是正斜杠。

但是字节不是 8 位长吗?是的,它们是。这正是我们最近所有主题的应用之处!我们可以利用这些新知识将这些 8 位块转换为 6 位块。

图 4-3 展示了将三个字节转换为 base64 文本字符串的简单示例。这些恰好是有效 JPEG 文件的前几个字节,但你可以使用任何源来进行工作。当然,这只是一个相对简单的二进制数据示例,但它将验证我们的算法。

smac 0403

图 4-3. 将 8 位转换为 6 位块进行编码

在我们的示例中,总共有九个字节需要编码,但实际上我们只需每次处理三个字节,就像插图中所示,并重复此过程。听起来像是循环的工作!我们可以使用任何循环结构,但我们将使用 for 循环,因为我们知道从哪里开始和结束,并且可以每次增加三个字节。我们将从源数组中取出三个字节到三个变量中,这只是为了讨论方便。

unsigned char source[9] = { 0xd8,0xff,0xe0,0xff,0x10,0x00,0x46,0x4a,0x46 };
char buffer[4] = { 0, 0, 0, 0 };

for (int i = 0; i < 9; i += 3) {
  unsigned char byte1 = source[i];
  unsigned char byte2 = source[i + 1];
  unsigned char byte3 = source[i + 2];
  // ...
}

下一个重要步骤是将四个 6 位块放入我们的 buffer 中。我们可以使用位操作符来获取我们需要的内容。回顾一下 表 4-3。byte1 的最左边的六位组成了我们的第一个 6 位块。在这种情况下,我们可以将这六位向右移动两个空位:

  buffer[0] = byte1 >> 2;

不错!一个完成,还剩三个。然而,第二个 6 位块有点混乱,因为它使用了 byte1 的剩余两位和 byte2 的四位。有几种方法可以解决这个问题,但我们将按顺序处理位,并将分配到buffer的下一个插槽分为两个步骤:

  buffer[1] = (byte1 & 0x03) << 4;   ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  buffer[1] |= (byte2 & 0xf0) >> 4;  ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)

1

首先,从 byte1 中取出右两位,并向左移动四个空位,为我们的 6 位块腾出空间。

2

现在,从byte2的左四位取出,将其右移四个空间,并将其放入buffer[1]中,而不干扰该变量的上半部分。

过半啦!我们可以对第三个 6 位块做类似的事情:

  buffer[2] = (byte2 & 0x0f) << 2;
  buffer[2] |= (byte3 & 0xc0) >> 6;

在这种情况下,我们取出并将byte2的右四位向右移动两个槽位,以为byte3的左两位空出空间。但与之前一样,我们首先必须将这两位向右移动到最右边。我们的最后一个 6 位块又是一个简单的情况。我们只需要byte4的右六位,无需移动:

  buffer[3] = byte3 & 0x3f;

欢呼!我们成功完成了 3x8 位到 4x6 位的转换!现在我们只需要打印出我们buffer数组中的每个值。听起来又像是一个循环。如果你还记得我们有五个基于 64 进制的“数字”范围,那就需要某种条件判断。我们可以在switch语句中列出所有 64 种情况,但那感觉很乏味。(至少它会很自我说明。)一个if/else if链应该很好用。在任何特定分支内部,我们将进行一些字符数学运算以获得正确的值。在阅读下面的片段时,看看你能否弄清楚这些字符数学是如何发挥魔力的:

  for (int b = 0; b < 4; b++) {
    if (buffer[b] < 26) {
      // value 0 - 25, so uppercase letter
      printf("%c", 'A' + buffer[b]);
    } else if (buffer[b] < 52) {
      // value 26 - 51, so lowercase letter
      printf("%c", 'a' + (buffer[b] - 26));
    } else if (buffer[b] < 62) {
      // value 52 - 61, so a digit
      printf("%c", '0' + (buffer[b] - 52));
    } else if (buffer[b] == 62) {
      // our "+" case, no need for math, just print it
      printf("+");
    } else if (buffer[b] == 63) {
      // our "/" case, no need for math, just print it
      printf("/");
    } else {
      // Yikes! Error. We should never get here.
      printf("\n\n Error! Bad 6-bit value: %c\n", buffer[b]);
    }
  }

字符数学有意义吗?由于char是一种整数类型,你可以对字符“加法”。如果我们对字符A加一,我们得到B。对A加二,我们得到C,依此类推。对于小写字母和数字,我们首先必须重新调整我们缓冲的值,使其处于从零开始的范围内。最后两种情况很简单,因为我们有一个值直接映射到一个字符。希望我们永远不会触发我们的else子句,但这些子句确实是为了处理这种情况的。如果我们做错了什么,打印一个警告!

哇!这些都是一些令人印象深刻的动作部件。如果你想要制造与其他微小设备或云端通信的微小设备,比如一台小型安全摄像头将图片发送到你的手机,这些正是你将会遇到的动作部件。

让我们把它们汇集在一个列表中(ch04/encode64.c),与我们需要的其他 C 程序位一起:

#include <stdio.h>

int main() {
  // Manually specify a few bytes to encode for now
  unsigned char source[9] = { 0xd8,0xff,0xe0,0xff,0x10,0x00,0x46,0x4a,0x46 };
  char buffer[4] = { 0, 0, 0, 0 };

  // sizeof(char) == 1 byte, so the array's size in bytes is also its length
  int source_length = sizeof(source);
  for (int i = 0; i < source_length; i++) {
    printf("0x%02x ", source[i]);
  }
  printf("==> ");
  for (int i = 0; i < source_length; i += 3) {
    unsigned char byte1 = source[i];
    unsigned char byte2 = source[i + 1];
    unsigned char byte3 = source[i + 2];

    // Now move the appropriate bits into our buffer
    buffer[0] = byte1 >> 2;
    buffer[1] = (byte1 & 0x03) << 4;
    buffer[1] |= (byte2 & 0xf0) >> 4;
    buffer[2] = (byte2 & 0x0f) << 2;
    buffer[2] |= (byte3 & 0xc0) >> 6;
    buffer[3] = byte3 & 0x3f;

    for (int b = 0; b < 4; b++) {
      if (buffer[b] < 26) {
        // value 0 - 25, so uppercase letter
        printf("%c", 'A' + buffer[b]);
      } else if (buffer[b] < 52) {
        // value 26 - 51, so lowercase letter
        printf("%c", 'a' + (buffer[b] - 26));
      } else if (buffer[b] < 62) {
        // value 52 - 61, so a digit
        printf("%c", '0' + (buffer[b] - 52));
      } else if (buffer[b] == 62) {
        // our "+" case, no need for math, just print it
        printf("+");
      } else if (buffer[b] == 63) {
        // our "/" case, no need for math, just print it
        printf("/");
      } else {
        // Yikes! Error. We should never get here.
        printf("\n\n Error! Bad 6-bit value: %c\n", buffer[b]);
      }
    }
  }
  printf("\n");
}

正如我一直鼓励的那样,你可以自己输入程序,做任何你想要的调整或添加任何注释以帮助你记住你学到的东西。你也可以编译encode64.c文件,然后运行它。这里是输出:

ch04$ gcc encode64.c
ch04$ ./a.out
0xd8 0xff 0xe0 0xff 0x10 0x00 0x46 0x4a 0x46  ==> 2P/g/xAARkpG

非常、非常酷。顺便说一句,恭喜!那是一小段不平凡的代码。你应该感到自豪。但是如果你想真正测试一下自己的技能,请尝试编写自己的解码器来反转这个过程。如果你从上面的输出开始,你能得到原始的九个字节吗?(你可以将你的答案与我的对比:ch04/decode64.c。)

转换答案

无论你是否尝试解码 base64 编码的字符串,希望你尝试自己转换 Table 4-2 中的值。你可以在这里比较你的答案。或者使用rosetta.c程序!

表 4-4. 基数转换答案

Decimal Binary Octal Hexadecimal
14 0000 1110 016 0E
32 0010 0000 040 20
17 0001 0001 021 11
50 0011 0010 062 32
42 0010 1010 052 2A
35 0001 0011 023 13
167 1010 0111 247 A7
249 1111 1001 371 F9

下一步

C 语言对简单数组的支持为几乎任何类型的数据提供了广泛的存储和检索选项。但是你必须注意你希望使用的元素数量,但在这些边界内,C 语言的数组非常高效。如果你只存储小的、是或否、开或关类型的值,C 语言有几个操作符使得可能将这些值挤入像int这样的大数据类型的各个位中。现代桌面计算机很少需要那么详细的关注,但我们在本书后半部分的一些 Arduino 选项非常重视这些细节!

那接下来呢?嗯,我们的程序变得足够有趣,我们将希望开始将逻辑分解成可管理的片段。举个例子,想想这本书。它不是由一个过长的跑题句组成的。它被分成章节。这些章节又分成小节。小节被分成段落。通常来说,讨论一个单独的段落比讨论整本书更容易。C 语言允许你对自己的逻辑进行这种类型的分解。一旦你把逻辑分解成易消化的块,你就可以像我们一直在用printf()scanf()函数一样使用这些块。让我们深入吧!

¹ 究竟如何出错可能各不相同。你的操作系统或版本、编译器版本,甚至是运行时系统的条件都会影响输出。关键是要小心不要溢出你的数组。

² gccstack-protector选项可以用于检测一些缓冲区溢出,并在溢出被恶意使用之前终止程序。这是一个默认关闭的编译时标志。

³ 作为额外字符对的一个示例,base64url 变体使用减号(“-”)和下划线(“_”)。

第五章:函数

通过我们迄今为止看到的各种赋值语句和控制流选项,您现在已经准备好解决几乎任何计算机问题了。但解决问题只是问题的一半。无论您是为工作还是为娱乐编码,您都不可避免地需要回到您已经编写的代码。您可能正在修复一个小错误或添加一个缺失的功能。您可能正在使用以前的项目作为新项目的起点。在所有这些时刻,代码的可维护性几乎与最初努力使代码工作一样重要。在解决问题时将问题分解以使其易于管理可能对您最终编写的代码产生有益影响——这也对其可读性和可维护性产生有益影响。

这个想法的核心是在解决整个问题的过程中解决较小的问题,这就是使用函数过程。函数帮助您封装逻辑——您正在学习编码的语句和控制结构。在 C 中,您可以编写和调用您需要的任意多个函数。¹ C 实际上并不区分“函数”和“过程”这两个词,尽管有些语言区分。在这些语言中,区别通常在于一段代码是否返回一个值或仅执行一组语句。我将主要使用术语函数,但如果您在这里或在任何其他阅读中看到有关过程(或例程,同样的概念)的讨论,它仍然指的是一块代码,您可以从其他代码块中调用。

熟悉的函数

实际上,我们一直在使用函数。main()代码块就是一个函数。在我们的第一个“Hello, World”程序中,我们使用printf()函数生成一些输出。我们使用scanf()函数从用户那里获取输入。这两个函数都来自我们在程序中包含的stdio.h库。

函数流程

这些函数内部发生了什么?“调用”它们意味着什么?函数和过程是另一种流程控制形式。它们允许您以有序的方式在代码块之间跳转,并在完成后返回到您来自的位置。图 5-1 更正式地说明了这种流程。

smac 0501

图 5-1。跟随函数的控制流

这种流程就是我所说的调用函数。您从当前语句到函数的第一条语句。您按照函数的方式工作(顺便说一句,函数可以包含对其他函数的调用),然后返回。在返回的过程中,您可以带上一个结果,但这是可选的。例如,我们不使用printf()scanf()调用的任何返回值(虽然有一个,但我们可以安全地忽略它)。但是,我们确实依赖许多函数的返回值,例如判断两个字符串是否匹配,或者字符是否为数字,或者某个数字的平方根是多少。

我们将研究构成 C“标准库”的许多函数,在第七章中。但我们也不必仅依赖于标准函数。C 允许我们创建自己的函数。图 5-2 显示了函数的基本结构。

smac 0502

图 5-2. C 函数的基本部分

在本章中,我们将详细讨论函数这些关键部分的各种变化。

简单函数

C 函数的最简单形式是我们只跳转到函数,执行其语句,然后跳回。我们不传递任何信息,也不期望任何信息返回。这听起来可能有点无聊甚至浪费,但它可以非常有用,用于将大型程序分解为可管理的部分。它还可以使得重复使用常见代码块成为可能。例如,您的程序可能带有一些有用的说明。无论用户遇到问题时,您都可以将这些打印到屏幕上以帮助他们解决问题。您可以将这些说明放入一个函数中:

void print_help() {
  printf("This program prints a friendly greeting.\n");
  printf("When prompted, you can type in a name \n");
  printf("and hit the return key. Max length is 24.\n");
}

注意我们函数的类型;它是一个新类型。这种void类型告诉编译器此函数没有返回值。C 的默认行为是返回一个像我们的main()函数一样的int,但函数可以返回 C 支持的任何类型的值,包括我们在这里做的无值返回。

C 中的函数名遵循与变量名相同的规则。必须以字母或下划线开头,然后可以有任意数量的字母、数字或下划线。与变量一样,也不能使用来自表 2-4 的任何保留字。

然后,我们可以在需要时调用此函数,来提醒用户或在他们请求帮助时。以下是程序的其余部分,ch05/help_demo.c。当程序启动时,我们将打印帮助信息,如果用户仅按下 Return 键时要求输入名称,我们将再次打印帮助信息。

#include <stdio.h>

void print_help() {
  printf("This program prints a friendly greeting.\n");
  printf("When prompted, you can type in a name \n");
  printf("and hit the return key. Max length is 24.\n");
}

int main() {
  char name[25];

  do {
    // Call our newly minted help function!
    print_help();

    // Now prompt the user, but if they enter an 'h',
    // start over with the help message
    printf("Please enter a name: ");
    scanf("%s", name);
  } while (name[0] == 'h' && name[1] == '\0');

  // Ok, we must have a name to greet!
  printf("Hello, %s!\n", name);
}

这里是输出:

ch05$ gcc help_demo.c
ch05$ ./a.out
This program prints a friendly greeting.
When prompted, you can type in a name
and hit the return key. Max length is 24.
Please enter a name: h
This program prints a friendly greeting.
When prompted, you can type in a name
and hit the return key. Max length is 24.
Please enter a name: joe
Hello, joe!

注意,在重用我们的简单print_help()函数时,我们在代码行数方面并没有节省太多。有时,使用函数更多是为了保持一致性,而不是减少空间或复杂性。如果我们最终改变了程序的工作方式,比如说,询问用户他们的姓名和地址,我们只需更新这一个函数,所有使用它的地方将自动受益于新内容。

向函数发送信息

尽管我们的print_help()之类的简单函数有意外地多次变得有用,但更常见的情况是,您需要传递一些信息给函数,以便它完成其工作。回顾一下我们对用户说 hello 的第二次迭代。我们提示他们输入他们的名字,然后打印个性化的问候语。我们可以创建一个具有相同定制能力的函数。为此,我们将指定一个函数参数

参数放在括号内,看起来很像变量声明。从实质上讲,它们确实是变量声明。但参数和变量之间有一些关键的区别。首先,你必须为每个参数提供一个类型。即使第二个类型相同,也不能“依赖”另一个参数的类型。其次,你不能初始化参数。参数的初始值来自于你调用函数时提供的参数。以下是一些有效和无效的例子:

// Correct and valid parameter declarations:
void average(double v1, double v2, double v3) { ...
void plot(int x, int y) { ...
void printUser(char *name, long id) { ...

// Incorrect declarations:
void bad_average(double v1, v2, v3) { // every parameter needs a type
void bad_plot(int x; int y) { // separate parameters with commas
void bad_print(char *name, long id = 0) { // do not initialize a parameter

名称“参数”和“参数”只是程序员的术语,用来表示变量和值。但在与其他开发人员讨论程序结构时,具有明确的名称是有用的。当你说“参数”时,其他程序员知道你在讨论定义函数及其输入。相比之下,当你谈论参数时,清楚地表明你指的是传递给已定义函数的值。了解这个术语也可以帮助你在搜索在线帮助时提出更好的问题。

传递简单类型

让我们尝试向函数传递一些东西并使用它们。带有参数的经典函数是计算数字平均值的函数。我们可以定义一个接受两个浮点数并打印平均值的函数,就像这样:

void print_average(float a, float b) {
  float average = (a + b) / 2;
  printf("The average of %.2f and %.2f is %.2f\n", a, b, average);
}

现在我们可以从程序的其他部分像这样调用print_average()

  float num1, num2;
  printf("Please enter two numbers separated by a space: ");
  scanf("%f %f", &num1, &num2);
  print_average(num1, num2);

注意,我们的参数ab与我们用作参数的变量num1num2的名称不同。将参数与参数绑定的不是它们的名称,而是它们的位置。第一个参数,无论是文字值、变量,甚至是表达式,必须与第一个参数的类型相匹配,并用于给该第一个参数提供其起始值。第二个参数与第二个参数配对,依此类推。所有以下对print_average()的调用都是有效的:

  float x = 17.17;
  float y = 6.2;
  print_average(3.1415, 2.71828);
  print_average(x, y);
  print_average(x * x, y * y);
  print_average(x, 3.1415);

将参数传递给函数是 C 编程的基础。我们不会在这里详细讨论输出结果,但可以查看ch05/averages.c。运行它,看看是否得到预期的输出。尝试添加一些自己的变量或使用scanf()来获取更多输入,然后再打印一些平均值。这确实是一个练习会带来回报的案例!

将字符串传递给函数

但是我们的个性化问候函数呢?我们可以像传递其他类型一样传递字符串(实际上只是char数组)。与其他参数一样,我们不给数组参数一个初始值,所以方括号总是空的:

void greet(char name[]) {
  printf("Hello, %s\n", name);
}

当我们调用greet()时,我们将整个数组作为参数,类似于将字符串变量传递给scanf()函数的方式。我们重新使用变量name是因为它对我们的程序和我们的greet()函数有意义。参数和参数匹配并非必须如此。事实上,这样的对齐是罕见的。我们将在“变量作用域”中查看函数参数与传递给它的参数之间的区别。

注意

您经常会看到使用“*”前缀而不是“[]”后缀来声明数组参数(例如void greet(char *name))。这是关于指针使用的有效表示法。我们将在第六章中处理指针,讨论数组变量的工作原理更详细,包括它们的内存分配以及在函数中的使用方式。

这里有一个完整的程序,ch05/greeting.c,定义并使用了greet()

#include <stdio.h>

void print_help() {
  printf("This program prints a friendly greeting.\n");
  printf("When prompted, you can type in a name \n");
  printf("and hit the return key. Max length is 24.\n");
}

void greet(char name[]) {
  printf("Hello, %s\n", name);
}

int main() {
  char name[25];

  // First, tell them how to use the program
  print_help();

  // Now, prompt them for a name (just the once)
  printf("Please enter your name: ");
  scanf("%s", name);

  // Finally, call our new greeting function with our name argument
  greet(name);
}

这里是几次运行的输出结果:

ch05$ gcc greeting.c
ch05$ ./a.out
This program prints a friendly greeting.
When prompted, you can type in a name
and hit the return key. Max length is 24.
Please enter your name: Brian
Hello, Brian
ch05$ ./a.out
This program prints a friendly greeting.
When prompted, you can type in a name
and hit the return key. Max length is 24.
Please enter your name: Vivienne
Hello, Vivienne

希望这里没有什么令人惊讶的地方。如上所述,我们将在第六章重新讨论将数组作为参数传递的问题。在这个例子中,我们指定char[]参数的方式并没有问题,但并非唯一的方法。

多种类型

这可能很明显,但我想指出函数定义中的参数列表可以混合不同类型。您不限于一种类型。例如,我们可以编写一个repeat()函数,该函数接受要打印的字符串和要打印该字符串的次数count

void repeat(char thing[], int count) {
  for (int i = 0; i < count; i++) {
    printf("%d: %s\n", i, thing);
  }
}

很棒!如果我们用单词“Dennis”和数字 5 调用repeat(),我们将得到以下输出:

// repeat("Dennis", 5);
0: Dennis
1: Dennis
2: Dennis
3: Dennis
4: Dennis
提示

嗯,这个小测验的答案是一个提示,至少是这样。 😃 您能想到一种方法,使上述输出中的索引号从 1 开始,而不是当前不太友好的从 0 到 4 吗?

退出函数

每个程序员都面临的一个常见问题是确保函数的输入合适。例如,对于我们精妙的repeat()函数,我们希望count是一个正数,这样我们才能得到一些输出。如果我们得到一个不良数字,而且不想完成函数的其余部分,我们该怎么办呢?幸运的是,C 提供了一个在任何时候退出函数的方法:return语句。

我们可以升级repeat()来在尝试运行打印循环之前检查一个良好的count

void repeat(char thing[], int count) {
  if (count < 1) {
    printf("Invalid count: %d. Skipping.\n", count);
    return;
  }
  for (int i = 0; i < count; i++) {
    printf("%d: %s\n", i, thing);
  }
}

更好了。第一个版本的repeat()如果提供了负数计数,不会崩溃或者发生任何问题,但是用户将看不到任何输出,也不知道原因。测试合法或者预期值通常是个好主意——尤其是当你写的代码可能被其他人使用时。

返回信息

函数也可以返回信息。你在定义时指定一个数据类型,比如int或者float,然后使用return语句发送一个实际的值回去。当你调用这样的函数时,你可以把返回的值存储在一个变量中,或者在任何允许值或表达式的地方使用它。

例如,我们可以把print_average()改成一个计算平均值并简单返回它的函数,而不是打印任何内容。这样你可以自由地用自定义消息打印平均值。或者你可以在某些其他计算中使用这个平均值。

下面是这样一个函数的简单版本:

float calc_average(float a, float b) {
  float average = (a + b) / 2;
  return average;
}

现在不是用void而是用了float作为类型。因此我们的return语句应该包含一个 float 值、变量或者表达式。在这个例子中,我们计算平均值并将其存储在一个名为average的临时变量中,然后使用return返回它。重要的是要注意返回的是一个average变量在函数结束时消失,但它的最终值被发送了回去。

由于我们返回一个值,像calc_average()这样的函数通常会跳过临时变量。你可以像这样直接使用return进行简单的计算:

float calc_average(float a, float b) {
  return (a + b) / 2;
}

在这里你不会失去任何可读性,但这可能是因为这是一个如此直接的计算。对于更大或更复杂的函数,随意使用更舒适或者看起来更易维护的方法。

使用返回的值

要捕获那个平均值,我们把对calc_average()函数的调用放在我们通常会看到文字或表达式的地方。我们可以把它赋给一个变量。我们可以在printf()语句中使用它。我们可以把它包含在一个更大的计算中。它的类型是float,所以任何地方你能用浮点值或变量,你都可以调用calc_average()

这里有几个来自ch05/averages2.c的例子:

float avg = calc_average(12.34, 56.78);
float triple = 3 * calc_average(3.14, 1.414);
printf("The first average is %.2f\n", avg);
printf("Our tripled average is %.2f\n", triple);
printf("A direct average: %.2f\n", calc_average(8, 12));

在这些语句中的每一个中,您可以看到calc_average()如何在float值的位置使用。第 5-3 图说明了第一次赋值语句的流程。

smac 0503

第 5-3 图。调用calc_average()的流程

1

调用calc_average()将控制权转移到函数;其参数从参数中初始化。

2

函数完成其工作后,返回avg中存储的结果,并将控制权返回到主函数。

3

恢复处理原始函数中的语句。

如果您使用calc_average()函数和上述片段构建自己的程序,您应该看到如下输出:

ch05$ gcc averages2.c
ch05$ ./a.out
The first average is 34.56
Our tripled average is 6.83
A direct average: 10.00

如果您想尝试这些示例,可以创建自己的文件,或者编译和运行averages2.c。作为练习,您如何扩展calc_average()函数以产生三个输入的平均值?

忽略返回值

如果在 C 中返回值没有用处,您无需使用。我在介绍printf()函数时没有提到这一点,但它实际上返回一个int:写出的字节数计数。不相信?试试看!如果您不想自己编写,我将此片段放在ch05/printf_bytes.c中:

printf("This is a typical print statement.\n");
int total_bytes = printf("This is also a print statement.\n");
printf("The previous printf displayed %d bytes.\n", total_bytes);

这个片段会产生以下输出:

ch05$ gcc printf_bytes.c
ch05$ ./a.out
This is a typical print statement.
This is also a print statement.
The previous printf displayed 32 bytes.

所有这三次对printf()的调用 C 都很高兴。第一次和第三次调用也返回一个计数,但我们忽略了它(没有不良影响)。我们从第二次调用中获取计数,只是为了表明printf()确实返回一个值。通常,您调用返回值的函数是因为您想要那个返回的值。然而,一些函数的副作用才是真正的目标,而不是返回的值。printf()就是这样一个函数。偶尔跟踪程序写了多少字节可能是有用的(例如,将传感器读数报告给云服务的微控制器可能有每日或每月的限制,不能超过)。但您可能使用printf()只是因为您想在屏幕上显示一些文本。

嵌套调用和递归

如果您查看本章的任何完整程序文件,如ch05/greeting.cch05/averages2.c,您可能会注意到我们遵循了一个简单的模式:定义一个函数,定义main()函数,并从main()内部调用我们的第一个函数。但这并不是唯一有效的安排方式。正如我将在第十一章中向您展示的那样,只需稍作调整,您就可以交换main()calc_average()的位置。

我们也有自由从其他函数内部调用我们的函数。我们可以创建一个新程序,用averages2.c中的calc_average()函数来获取实际的平均值,以复制与averages.c中原始print_average()函数完全相同的输出。

这是完整的ch05/averages3.c,这样你就可以看到我们放置不同函数的位置以及这些函数的调用:

#include <stdio.h>

float calc_average(float a, float b) {
  return (a + b) / 2;
}

void print_average(float a, float b) {
  float average = calc_average(a, b);
  printf("The average of %.2f and %.2f is %.2f\n", a, b, average);
}

int main() {
  float num1, num2;
  printf("Please enter two numbers separated by a space: ");
  scanf("%f %f", &num1, &num2);
  print_average(num1, num2);

  float x = 17.17;
  float y = 6.2;
  print_average(3.1415, 2.71828);
  print_average(x, y);
  print_average(x * x, y * y);
  print_average(x, 3.1415);
}

如果你运行它,输出将类似于“传递简单类型”中的第一个示例:

ch05$ gcc averages3.c
ch05$ ./a.out
Please enter two numbers separated by a space: 12.34 56.78
The average of 12.34 and 56.78 is 34.56
The average of 3.14 and 2.72 is 2.93
The average of 17.17 and 6.20 is 11.68
The average of 294.81 and 38.44 is 166.62
The average of 17.17 and 3.14 is 10.16

聪明。实际上,我们一直依赖这个功能。在我们的第一个“Hello, World”程序中,我们调用printf()函数——这确实是一个真正的函数,只是由内置的标准 I/O 库定义——在我们的main()函数内部。

所有解决真实世界问题的 C 程序都将使用这种基本模式。函数被编写来解决更大问题的某一小部分。其他函数调用这些函数将小答案组装成一个更大的整体。有些问题如此复杂,以至于你将有几层函数相互调用。但我们预先了解。我们将继续练习更简单的函数。当你习惯于定义和调用它们时,你自然会在解决更复杂的问题时开始构建更复杂的层次结构。

递归函数

除非你已经使用过其他语言,否则可能不明显,但 C 函数也允许调用自身。这称为递归,这样一个自我调用的函数称为递归函数。如果你在程序员周围花费了一些时间,也许你已经听过关于递归定义的惊人准确的笑话:“我在字典中查找递归。它说:‘参见递归。’”谁说书呆子没有幽默感? 😉

但是笑话的定义确实暗示了如何在 C 语言中编写递归函数。只有一个重要的警告:你需要有一种方法来停止递归。如果笑话中的主题是计算机,它将处于一个无限循环的状态,不断查找这个词只是被告知再次查找这个词,依此类推,无穷无尽。如果你在 C 中编写这样的函数,最终程序将消耗掉计算机中的所有内存并崩溃。

为了避免这种崩溃,递归函数至少有两个分支。其中一个分支是基本情况,终止。它产生一个具体的值并完成。另一个分支进行某种计算并递归。这种“某种计算”最终必须导致基本情况。如果这听起来有点混乱,不要惊慌!² 我们可以通过实际代码更好地说明这个过程。

或许最著名的递归算法之一是计算斐波那契数的算法。你可能还记得这些来自高中数学。以一位 13 世纪的意大利数学家命名,它们是从一个简单的起点两个数字构建起来的序列的一部分,要么是零和一个,要么是两个一。你将这两个数字相加以产生第三个数字。你将第二个和第三个相加以产生第四个数字,依此类推。因此第 n 个斐波那契数是前一个数和前前一个数的和。更正式地说,可以这样说:

F(n) = F(n - 1) + F(n - 2)

在这里,函数F()是以函数F()的形式定义的。啊哈!递归!那么在 C 语言中是什么样子呢?让我们来看看。

我们将从定义一个以一个int为参数并返回一个int的函数开始。如果传递给我们的值是零或一个,我们将分别作为序列的一部分返回零或一个。这听起来相当简单:

int fibonacci(int n) {
  // Base case 0
  // We'll cheat and return zero for negative numbers as well
  if (n <= 0) {
    return 0;
  }
  // Base case 1
  if (n == 1) {
    return 1;
  }
  // recursive call will go here
}

我们有关键部分:基本情况(或像我们的 0 和 1 这样的情况)有一个明确的答案。如果我们得到大于 1 的整数,我们将陷入递归调用中。那看起来是什么样子呢?就像任何其他函数调用一样。使它特别的是我们调用正在定义中的函数,即我们的情况下的fibonacci()。我们在介绍递归时提到的“某种计算”是来自我们正式定义中的n - 1n - 2元素:

  // recursive call
  return fibonacci(n - 1) + fibonacci(n - 2);

让我们把所有这些放在一个完整的程序中(ch05/fib.c),该程序打印出几个示例斐波那契数:

#include <stdio.h>

int fibonacci(int n) {
  // Base case 0
  // We'll lazily return zero for negative numbers as well
  if (n <= 0) {
    return 0;
  }
  // Base case 1
  if (n == 1) {
    return 1;
  }

  // recurring call
  return (fibonacci(n-1) + fibonacci(n-2));
}

int main() {
  printf("The 6th Fibonnaci number is: %d\n", fibonacci(6));
  printf("The 42nd Fibonnaci number is: %d\n", fibonacci(42));
  printf("The first 10 Fibonacci numbers are:\n");
  for (int f = 0; f < 10; f++) {
    printf("  %d", fibonacci(f));
  }
  printf("\n");
}

如果我们运行它,我们将得到以下输出:

ch05$ gcc fib.c
ch05$ ./a.out
The 6th Fibonnaci number is: 8
The 42nd Fibonnaci number is: 267914296
The first 10 Fibonacci numbers are:
  0  1  1  2  3  5  8  13  21  34

非常酷。但它是如何工作的呢?看起来好像不可能将来自需要同一个函数来计算值的函数的值分配过去!图 5-4展示了在fibonacci()中使用 4 的小值时发生的情况。

smac 0504

图 5-4。递归调用栈

如果这个过程看起来还有些复杂,给它一些时间。随着你更多地使用函数,一般来说,阅读(和创建!)像我们的递归斐波那契例子那样更有趣的函数将变得更容易。

但是对计算机来说确实有些复杂。递归可能会太深,导致计算机内存耗尽。即使你的递归代码没有那么深,处理起来仍可能需要相当长的时间。试着修改程序,让它显示第 50 个斐波那契数而不是第 42 个。注意到它在那一步暂停了吗?如果没有,恭喜你拥有强大的系统!试着提高到 60 或 70。你最终会调得足够高,以至于大量的函数调用将使你的 CPU 负载。只要记住递归适度使用。

注意

还值得指出的是,大多数递归算法都有使用更普通的技巧如循环的对应算法。但有时使用循环比递归选项更复杂。在适当的情况下,递归通过将问题分解成较小的问题,使得解决某些问题更简单。例如,广泛用于处理音频和视频流的快速傅立叶变换(FFT)是一个相当复杂的算法,它有一个递归解法更易于理解和实现。

变量作用域

我没有明确强调我们计算平均值的函数中的这个细节,但你可以在函数内部声明任何类型的变量。这些通常被称为局部变量,因为它们位于函数内部,在函数结束时被移除。让我们重新访问我们在“传递简单类型”中编写的那个print_average()函数:

void print_average(float a, float b) {
  float average = (a + b) / 2;
  printf("The average of %.2f and %.2f is %.2f\n", a, b, average);
}

在这里,变量ab是函数的参数,average是一个局部变量。局部变量并没有什么特别之处,但由于它们被保留在定义它们的函数内部,你可以在不同的函数之间重复使用名称。考虑两个函数,分别计算两个和三个参数的平均值:

void print_average_2(float a, float b) {
  float average = (a + b) / 2;
  printf("The two numbers average out to %.2f\n", average);
}

void print_average_3(float a, float b, float c) {
  float average = (a + b + c) / 3;
  printf("The three numbers average out to %.2f\n", average);
}

这两个函数都声明了一个名为average的局部变量,但它们是两个完全独立的变量。即使它们共享一个名称,编译器也不会混淆它们。事实上,即使调用函数也有一个average变量,它们也不会混淆。每个局部变量完全包含在其函数内部:

float calc_average_2(float a, float b) {
  float average = (a + b) / 2;
  return average;
}

int main() {
  float avg1 = calc_average_2(18.5, 21.1);
  float avg2 = calc_average_2(16.3, 19.4);
  float average = calc_average_2(avg1, avg2);
  printf("The average of the two averages is: %.2f\n", average);
}

很好。这意味着我们可以专注于在给定函数中使用适当的名称来进行工作。我们不必记住其他函数或者甚至main()中使用过的变量。这让我们作为程序员的工作变得更加容易。

全局变量

作为一个程序员,你肯定会遇到全局变量以及局部变量。全局变量有点像局部变量的反面。局部变量被包含在函数或循环块内部,全局变量则在任何地方可见。局部变量在循环或函数结束时消失,而全局变量则会持久存在。

这种可见性和持久性使得全局变量对于在多个函数中共享或重复使用的任何值非常有吸引力。但正因为任何函数都可以看到全局变量并对其进行修改,这很容易导致全局变量的破坏。这里有一个示例(ch05/globals.c),其中使用了一个全局变量,我们在一个函数内部和main()内部使用它:

#include <stdio.h>

char buffer[30];

void all_caps() {
  char diff = 'a' - 'A';
  for (int b = 0; b < 30 && buffer[b] != 0; b++) {
    if (buffer[b] >= 'a' && buffer[b] <= 'z') {
      // We have a lowercase letter, so change this slot
      // in the char array to its uppercase cousin
      buffer[b] -= diff;
    }
  }
}

int main() {
  printf("Please enter a name or phrase: ");
  scanf("%[^\n]s", buffer);
  printf("Before all_caps(): %s\n", buffer);
  all_caps();
  printf("After all_caps(): %s\n", buffer);
}

下面是运行程序的输出:

ch05$ gcc globals.c
ch05$ ./a.out
Please enter a name or phrase: This is a test.
Before all_caps(): This is a test.
After all_caps(): THIS IS A TEST.

注意,我们从未改变main()中变量的值,但我们看到(并可以打印)在all_caps()函数内部进行的更改。

提示

我在globals.c中使用的格式化字符串可能看起来有些奇怪。单独使用scanf("%s", buffer)会在第一个空格处停止扫描字符串。在样本输出中,这意味着只有单词“This”会被捕获到buffer中。[^\n]限定符借用了一些来自正则表达式世界的语法,表示“除了换行符之外的任何字符”。这使我们可以输入带有空格的短语,并捕获直到换行符的每个单词作为单个字符串。

有时候,使用全局变量确实是有用的。特别是在像 Arduino 这样的小系统上,这种安排偶尔可以节省一些字节。但你真的必须小心。如果太多函数使用和改变一个全局变量,当出现问题时调试变得非常混乱。如果没有强烈的理由使用全局变量,我建议将共享值作为参数传递给任何需要它们的函数。

遮蔽全局变量

关于全局变量的另一个重要注意事项是,你仍然可以在函数内部声明与全局变量同名的局部变量。这样的局部变量被称为遮蔽全局变量。在函数内部进行的任何打印、计算或操作只会影响局部变量。如果你还需要访问全局变量,那就没戏了。看看ch05/globals2.c

#include <stdio.h>

char buffer[30];

void all_caps() {
  char buffer[30] = "This is a local buffer!";
  char diff = 'a' - 'A';
  for (int b = 0; b < 30 && buffer[b] != 0; b++) {
    if (buffer[b] >= 'a' && buffer[b] <= 'z') {
      // We have a lowercase letter, so change this slot
      // in the char array to its uppercase cousin
      buffer[b] -= diff;
    }
  }
  printf("Inside all_caps(): %s\n", buffer);
}

int main() {
  printf("Please enter a name or phrase: ");
  scanf("%[^\n]s", buffer);
  printf("Before all_caps(): %s\n", buffer);
  all_caps();
  printf("After all_caps(): %s\n", buffer);
}

并比较前一个globals.c的输出与此输出:

ch05$ gcc globals2.c
ch05$ ./a.out
Please enter a name or phrase: A second global test.
Before all_caps(): A second global test.
Inside all_caps(): THIS IS A LOCAL BUFFER!
After all_caps(): A second global test.

你可以在这里看到,在main()方法中,全局变量buffer没有被更新,尽管这可能是我们想要的。再次强调,除非必要,否则我不建议使用全局变量。有时它们的方便性可能会让你心动,这没问题。只是要保持警惕和深思熟虑。

main()函数

我们在本章中多次提到了main()函数,随着我们对 C 函数的知识的扩展,我们已经了解到main()确实是一个真正的、常规的 C 函数,它有一个主要(哈!)区别,即它是一个执行 C 程序的函数。因为它只是一个函数,我们能从它返回一个值吗?我们能传递参数给它吗?如果可以,那么用来填充这些参数的参数从哪里来?你确实可以返回值并声明参数。如果你感兴足,最后一节将更详细地介绍main()。幸运的是,我们迄今为止使用的简单main()将继续适用于我们的精简示例。

返回值和 main()

但我们还没有真正深入到我们的main()声明的细节中来。你可能已经想知道为什么我们给main()函数一个类型(int),尽管我们从未在该函数中写过return语句。但事实证明我们是可以的!

大多数操作系统都使用某种机制来确定您运行的程序是否成功完成或因某些原因失败。Unix 及其衍生版本以及 MS DOS 使用数值来完成此目的。返回值为零通常被认为是成功的,而其他任何值则表示失败。“其他任何值”范围广泛,某些程序确实使用它们。如果您编写 shell 脚本或 DOS 批处理文件,您可能已经使用这些返回值来确定特定命令失败的确切原因,并尽可能解决问题。

到目前为止,在我们的所有示例中,我都没有在 main() 函数中包含 return。所以到底发生了什么?编译器简单地构建了一个程序,隐式地提供 0 作为 int 返回值。让我们通过检查我们的第一个程序 hello.c 的退出状态来看一下。

首先,让我们编译并运行程序。现在,我们可以跟进并询问操作系统关于返回值的情况。在 Unix/Linux 和 macOS 系统上,您可以检查$?特殊变量:

ch01$ gcc -o hello hello.c
ch01$ ./hello
Hello, world
ch01$ echo $?
0

在 Windows 系统上,您可以检查 %ERRORLEVEL% 变量:

C:\Users\marc\Documents\smallerc> gcc -o hello.exe hello.c

C:\Users\marc\Documents\smallerc> hello
Hello world

C:\Users\marc\Documents\smallerc>echo %ERRORLEVEL%
0

但是“0”可能感觉有点令人不信服,因为这是未定义或未初始化变量的常见值。让我们编写一个新程序,ch05/exitcode.c,返回一个明确的非零值以证明正在返回某些内容。

我们将提示用户,看看他们是否希望成功或失败。这是一个愚蠢的提示,但它允许您尝试两个选项而无需重新编译:

#include <stdio.h>

int main() {
  char answer;
  printf("Would you like to succeed (s) or fail (f)? ");
  scanf("%c", &answer);
  if (answer == 's') {
    return 0;
  } else if (answer == 'f') {
    return 1;
  } else {
    printf("You supplied an unsupported answer: %c\n", answer);
    return 2;
  }
}

让我们编译并运行此程序,并尝试几个不同的答案,以查看在通过操作系统检查退出代码时会得到什么结果。(出于简洁起见,我只展示 Linux 输出,但 macOS 和 Windows 类似。)

ch05$ gcc exitcode.c
ch05$ ./a.out
Would you like to succeed (s) or fail (f)? s
ch05$ echo $?
0
ch05$ ./a.out
Would you like to succeed (s) or fail (f)? f
ch05$ echo $?
1
ch05$ ./a.out
Would you like to succeed (s) or fail (f)? invalid
You supplied an unsupported answer: i
ch05$ echo $?
2

这个简单的程序暗示了更复杂的程序可能如何使用这些退出代码来提供关于发生了什么的更多细节。请注意,尽管最终程序退出了,但这些值是可选的,但如果您计划编写最终会出现在脚本中的实用程序,则可能会很有用。

命令行参数和 main() 函数

那么如何将参数传递给 main()?幸运的是,您可以使用命令行以及一个定义 main 的第二个选项来帮助完成这个任务。这个备选版本看起来像这样:

int main(int argc, char *argv[]) { // ...

argc 参数是“参数计数”,argv 字符串数组是“参数值”列表。argv 类型中的星号可能有点令人惊讶。argv 变量确实是字符数组的数组,类似于“多维数组”中的二维 char 数组,但这是一个更灵活的版本。它是字符串的数组。(由那个星号表示的)我们将在第六章中详细讨论指针。现在,将 argv 视为字符串数组。

当你启动程序时,从命令行中将argv数组进行填充。所有内容都作为字符串输入,但如果你需要的话,可以将它们转换成其他类型(如数字或字符)。这里有一个简短的程序,ch05/argv.c,用来演示如何访问参数,并常见地检查“帮助标志”。如果第一个命令行参数是-h,我们将打印一个帮助消息并忽略其余的参数。否则,我们将逐行列出所有参数:

#include <stdio.h>

void print_help(char *program_name) {
  printf("You can enter several command-line arguments like this:\n");
  printf("%s this is four words\n", program_name);
}

int main(int argc, char *argv[]) {
  if (argc == 1) {
    printf("Only the name of the program '%s' was given.\n", argv[0]);
  } else if (argc == 2) {
    // Might be a request for help
    int len = sizeof(argv[1]);
    if (len >= 2 && argv[1][0] == '-' && argv[1][1] == 'h') {
      print_help(argv[0]);
    } else {
      printf("Found one, non-help argument: %s\n", argv[1]);
    }
  } else {
    printf("Found %c command-line arguments:\n", argc);
    for (int i = 0; i < argc; i++) {
      printf("  %s\n", argv[i]);
    }
  }
}

当你用几个随机词运行argv.c时,你应该能看到它们逐行列出:

ch05$ gcc argv.c
ch05$ ./a.out this is a test!
Found  command-line arguments:
  ./a.out
  this
  is
  a
  test!

但如果你只使用特殊的-h参数,你应该会得到我们的帮助信息:

ch05$ ./a.out -h
You can enter several command-line arguments like this:
./a.out this is four words
ch05$ gcc -o argv argv.c
ch05$ ./argv -h
You can enter several command-line arguments like this:
./argv this is four words

尝试多次运行它。如果你想尝试一个相当高级的练习,可以创建一个函数,将一个数字字符串转换为整数。然后使用该函数来将传递给命令行的所有数字相加。这里是预期输出的示例:

./sum 22 154 6 73
The sum of these 4 numbers is 255

如果你想查看我是如何处理的,可以在sum.c文件中检查我的解决方案。

注意

你可能会认为将字符串转换为数字是一个常见的任务,而 C 语言应该已经有了相应的函数,这种想法大部分是正确的。标准库中有一个叫做atoi()(ascii 到整数)的函数,它已经包含在stdlib.h中。我们将在第七章中讨论库,但这个小小的补充可以节省大量的手动工作。如果你还想尝试一个快速的练习,可以尝试包含stdlib.h头文件,并使用atoi()函数来完成一个替代版本。或者,随意查看我在sum2.c中的解决方案。

下一步

现在我们已经搞定了所有的基本构建块。你可以开始使用前几章节中介绍的各种控制结构和我们在这里讲解的函数来构建一些真正有趣的程序,以解决现实世界中的问题。但是 C 语言可以做得更多,当我们开始准备在微控制器上工作时,“更多”的一些内容将变得至关重要。

在接下来的两章中,我们将学习指针和使用库来完善我们的 C 语言技能。然后我们可以深入 Arduino 的世界,玩得开心!

¹ 当然,也要在合理范围内考虑。或者说,要在计算机资源限制内进行。现在的桌面系统有很多内存,写太多函数确实有些困难。但是在我们的微控制器上,我们必须更加小心。

² 如果你想体验一下极客幽默的巅峰,不妨看看 Douglas Adams 的银河系漫游指南。里面大大的友好字体写着“别慌”。

第六章:指针与引用

对内存的合理直接访问是 C 语言的一个最大特点,适合那些处理低级问题如设备驱动或嵌入式系统的开发者。C 语言为你提供了微管理字节的工具。当你需要关注每一位可用内存时,这可以是一个真正的好处,但当你需要关注你使用的每一位内存时,也可能是一个真正的麻烦。然而,当你需要这种控制时,拥有这样的选项是很棒的。本章将涵盖如何找出事物在内存中的位置(它们的地址),以及如何使用和存储这些位置的指针,即存储其他变量地址的变量。

C 语言中的地址

当我们讨论使用scanf()读取基本类型如整数和浮点数与读取字符串作为字符数组时,我们已经涉及了指针的概念。你可能还记得对于数字,我提到了所需的&前缀。这个前缀可以被认为是一个“地址”的运算符或函数。它返回一个数字值,告诉你&后面的变量在内存中的位置。我们实际上可以打印出那个位置。看看 ch06/address.c

#include <stdio.h>

int main() {
  int answer = 42;
  double pi = 3.1415926;
  printf("answer's value: %d\n", answer);
  printf("answer's address: %p\n", &answer);
  printf("pi's value: %0.4f\n", pi);
  printf("pi's address: %p\n", &pi);
}

在这个简单的程序中,我们创建了两个变量并对它们进行初始化。我们使用了一些printf()语句来显示它们的值和在内存中的位置。如果我们编译并运行这个例子,以下是我们将看到的内容:

ch06$ gcc address.c
ch06$ ./a.out
answer's value: 42
answer's address: 0x7fff2970ee0c
pi's value: 3.1416
pi's address: 0x7fff2970ee10
注意

我在这里要说的是大致的内容;你的设置可能会与你的不同,因此地址可能不会完全匹配。实际上,仅仅连续运行这个程序几乎肯定会得到不同的地址。程序加载到内存中的位置取决于无数因素。如果这些因素不同,地址很可能也会不同。

在接 在接下来的所有例子中,关注哪些地址与其他地址接近会更加有用。具体的数值并不重要。

获取存储在answerpi中的值是很直接的,这也是我们从第 2 章开始一直在做的事情。但操作变量的地址是新的。我们甚至需要一个新的printf()格式说明符,%p,来打印它们!该格式说明符的助记符是“pointer”,这与“address”紧密相关。通常,指针指的是存储地址的变量,尽管你会看到人们谈论特定的值作为指针。你也会遇到术语引用,它与指针同义,但在谈论函数参数时更常用。例如,网上的教程会说诸如“当你传递一个引用给这个函数……。”他们的意思是你将变量的地址传递给函数,而不是变量的值。

但是回到我们的例子。那些打印出来的指针值看起来确实像是很大的数!这种情况并不总是出现,但是在使用逻辑地址来帮助分离和管理多个程序的拥有几十亿甚至几万亿字节 RAM 的系统上,这并不罕见。这些值代表什么?它们是我们进程内存中变量值的存放位置。图 6-1 展示了我们简单示例中内存的基本设置。

即使不计算地址的确切十进制值,你也可以看到它们很接近。事实上,pi的地址比answer的地址大四个字节。在我的机器上,一个int是四个字节,所以希望你能看到这种联系。在我的系统上,double占据八个字节。如果我们向我们的示例中添加第三个变量,你能猜到它的地址吗?

smac 0601

图 6-1. 变量值和地址

让我们一起尝试一下。程序ch06/address2.c添加了另一个int变量,然后打印其值和地址:

#include <stdio.h>

int main() {
  int answer = 42;
  double pi = 3.1415926;
  int extra = 1234;
  printf("answer's value: %d\n", answer);
  printf("answer's address: %p\n", &answer);
  printf("pi's value: %0.4f\n", pi);
  printf("pi's address: %p\n", &pi);
  printf("extra's value: %d\n", extra);
  printf("extra's address: %p\n", &extra);
}

这是我们三变量版本的输出:

ch06$ gcc address2.c
ch06$ ./a.out
answer's value: 42
answer's address: 0x7fff9c827498
pi's value: 3.1416
pi's address: 0x7fff9c8274a0
extra's value: 1234
extra's address: 0x7fff9c82749c

Hmm,实际上,变量并不按照我们声明的顺序存储。多么奇怪!仔细观察,你会发现answer仍然被首先存储(地址 0x…498),然后是extra四个字节之后(0x…49c),再之后是pi又四个字节(0x…4a0)。编译器通常会以一种它认为高效的方式排列事物——而这种高效的顺序并不总是与我们的源代码一致。所以,尽管顺序有些令人意外,我们仍然可以看到变量都按照它们的类型规定的空间堆叠在一起。

NULL 值和指针错误

stdio.h头文件包含一个方便的值,NULL,每当我们需要谈论“空”或未初始化的指针时,我们可以使用它。你可以将NULL赋给一个指针变量或在比较中使用它,以查看特定指针是否有效。如果你喜欢在声明变量时始终赋予一个初始值,NULL就是与指针一起使用的值。例如,我们可以声明两个变量,一个是double,另一个是指向double的指针。我们将它们初始化为“无”,然后稍后再填充它们:

double pi = 0.0;
double *pi_ptr = NULL;
// ...
pi = 3.14156;
pi_ptr = &pi;

任何时候不能确定指针的来源时,都应检查NULL指针。例如,在传递给你的函数内部:

double messyAreaCalculator(double radius, double *pi_ptr) {
  if (pi_ptr == NULL) {
    printf("Could not calculate area with a reference to pi!\n");
    return 0.0;
  }
  return radius * radius * (*pi_ptr);
}

当然,这并不是计算圆形面积的最简单方法,但是开始的if语句是一个常见的模式。这是一个简单的保证,确保你有东西可以处理。如果你忘记检查你的指针并尝试解引用它,你的程序通常会停止,并且你可能会看到这样的错误:

Segmentation fault (core dumped)

即使你无法处理空指针,如果在使用之前检查它,你可以给用户一个更友好的错误消息并避免崩溃。

数组

数组和字符串又是怎么样的呢?它们会像简单类型一样放在栈上吗?它们在内存的同一一般部分会有地址吗?让我们创建一些数组变量,看看它们会落在哪里,占用多少空间。ch06/address3.c 就有我们的数组。我添加了一个大小的打印输出,这样我们可以轻松验证分配了多少空间:

#include <stdio.h>

int main() {
  char title[30] = "Address Example 3";
  int page_counts[5] = { 14, 78, 49, 18, 50 };
  printf("title's value: %s\n", title);
  printf("title's address: %p\n", &title);
  printf("title's size: %lu\n", sizeof(title));
  printf("page_counts' value: {");
  for (int p = 0; p < 5; p++) {
    printf(" %d", page_counts[p]);
  }
  printf(" }\n");
  printf("page_counts's address: %p\n", &page_counts);
  printf("page_counts's size: %lu\n", sizeof(page_counts));
}

这是我们的输出:

title's value: Address Example 3
title's address: 0x7ffe971a5dc0
title's size: 30
page_counts' value: { 14 78 49 18 50 }
page_counts's address: 0x7ffe971a5da0
page_counts's size: 20

编译器再次重新排列了我们的变量,但我们可以看到page_counts数组占用了 20 字节(每个int 5 x 4 字节),而titlepage_counts之后 32 字节的位置。(你可以忽略地址的共同部分并做一些简单的数学运算:0xc0 – 0xa0 == 0x20 == 32。)那多出来的 12 字节是什么?这是数组的一些开销,编译器已经为它留出了空间。令人高兴的是,我们(作为程序员或用户)不必担心这种额外开销。作为程序员,我们可以看到编译器确实为数组本身预留了足够的空间。

局部变量和栈

那么这个“空间”到底是在哪里设置的呢?在最广义的术语中,这个空间是从计算机的内存(RAM)中分配的。对于在函数中定义的变量(还记得从“主函数(main())”中了解到main()是一个函数),这些空间是在上分配的。这是内存中的一个术语,用于创建和保存所有局部变量,当您进行各种函数调用时。组织和维护这些内存分配是操作系统的主要工作之一。

考虑下一个小程序,ch06/do_stuff.c。我们有通常的main()函数,还有另一个函数do_stuff(),它做一些事情。不是花哨的事情,但它仍然创建并打印了一个int变量的详细信息。即使是无聊的函数也会使用栈,并帮助说明函数调用在内存中如何组合在一起!

#include <stdio.h>

void do_stuff() {
  int local = 12;
  printf("Our local variable has a value of %d\n", local);
  printf("local's address: %p\n", &local);
}

int main() {
  int count = 1;
  printf("Starting count at %d\n", count);
  printf("count's address: %p\n", &count);
  do_stuff();
}

这里是输出:

ch06$ gcc do_stuff.c
ch06$ ./a.out
Starting count at 1
count's address: 0x7fff30f1b644
Our local variable has a value of 12
local's address: 0x7fff30f1b624

您可以看到main()count的地址和do_stuff()local的地址是相邻的。它们都在栈上。图 6-2 显示了栈及其一些更多的上下文。

smac 0602

图 6-2. 栈上的局部变量

这就是“栈”这个名字的来源:函数调用会堆叠起来。如果do_stuff()调用了其他函数,那么该函数的变量会叠加在local之上。当任何函数完成时,其变量将从栈上弹出。这种堆叠可以进行很长时间,但不是永远。如果你没有为递归函数提供适当的基本情况,比如在“递归函数”中所述的那些情况下,这种失控的栈分配最终会导致程序崩溃。

您可能注意到图 6-2 中的地址实际上是递减的。栈的开始可以是程序分配的内存的开始,地址将递增,或者是分配空间的结尾,地址将递减。您所看到的版本取决于体系结构和操作系统。但栈及其增长的概念保持不变。

栈还存储传递给函数的任何参数,以及稍后在函数中声明的任何循环或其他变量。考虑以下片段:

float average(float a, float b) {
  float sum = a + b;
  if (sum < 0) {
    for (int i = 0; i < 5; i++) {
      printf("Warning!\n");
    }
    printf("Negative average. Be careful!\n");
  }
  return sum / 2;
}

在这段代码中,栈将包括以下元素的空间:

  • average() 函数本身的 float 返回值

  • float 类型的参数 a

  • float 类型的参数 b

  • float 类型的局部变量 sum

  • 用于循环的 int 变量 i(仅在 sum < 0 时)

栈非常灵活!几乎与特定函数有关的任何事情都会从栈中获取其内存。

全局变量和堆

那么不与任何特定函数相关联的全局变量如何处理?它们会分配在称为的内存的单独部分。如果“堆”听起来有点混乱,那是因为确实如此。您的程序需要的任何内存片段,如果不是栈的一部分,就会在堆中。图 6-3 说明了如何思考栈和堆。

smac 0603

图 6-3. 栈与堆内存

栈和堆共享一个逻辑上分配给您的程序的内存块。随着函数调用的增多,栈会增长(从“顶部”向下)。当函数完成调用时,栈会收缩。全局变量使堆增长(从“底部”向上)。大型数组或其他结构也可能在堆中分配。本章中的“使用数组管理内存”将介绍如何在这个空间中手动使用内存。您可以释放堆中的部分空间以使其收缩,但全局变量在程序执行期间保持不变。

我们将更详细地研究这两种内存如何交互在“栈与堆”。随着栈和堆的增长,中间的空闲空间变得越来越小。如果它们相遇,那就麻烦了。如果栈不能再增长,您将无法调用更多函数。如果您尝试调用函数,可能会导致程序崩溃。同样,如果堆没有剩余空间可供增长,但您尝试请求空间,计算机将不得不终止您的程序。

作为程序员,避免这些麻烦是你的工作。C 语言不会阻止你犯错,但反过来,它给了你在特定情况下非常聪明的发挥空间。第十章 将探讨微控制器上的几种情况,并讨论一些应对方法。

指针算术

无论变量存储其内容的位置如何,C 语言允许您以一种强大(但潜在危险的)方式直接处理地址。我们不仅限于简单检查变量的地址。我们可以将其存储在另一个变量中。然后,我们可以使用该其他变量来访问相同的数据位并对其进行操作。

查看ch06/pointer.c示例,了解使用指向另一个变量的变量的示例。我在处理指针时指出了一些关键概念:

#include <stdio.h>

int main() {
  double total = 500.0;                ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  int count = 34;
  double average = total / count;
  printf("The average of %d units totaling %.1f is %.2f\n",
     count, total, average);

  // Now let's reproduce some of that work with pointers
  double *total_ptr = &total;          ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  int *count_ptr = &count;
  printf("total_ptr is the same as the address of total:\n");
  printf(" total_ptr %p == %p &total\n", total_ptr, &total);

  // We can manipulate the value at the end of a pointer
  // with the '*' prefix (dereferencing)
  printf("The current total is: %.1f\n", *total_ptr);
  // Let's pretend we forgot two units and correct our count:
  *count_ptr += 2;                     ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  average = *total_ptr / *count_ptr;
  printf("The corrected average of %d units totaling %.1f is %.2f\n",
     count, total, average);           ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
}

1

我们从一组普通变量开始执行简单的计算。

2

接下来,我们创建了具有相应指针类型的新变量。例如,我们创建了total_ptr,类型为double *,作为指向类型为doubletotal变量的指针。

3

您可以取消引用指针以使用或更改它们指向的内容。

4

最后,我们证明了通过我们对其指针对应物进行的工作而实际更改了原始的非指针变量。

这是输出:

ch06$ gcc pointer.c
ch06$ ./a.out
The average of 34 units totaling 500.0 is 14.71
total_ptr is the same as the address of total:
  total_ptr 0x7ffdfdc079c8 == 0x7ffdfdc079c8 &total
The current total is: 500.0
The corrected average of 36 units totaling 500.0 is 13.89

这个输出并不是很令人兴奋,但再次证明我们能够通过count_ptr指针编辑像count这样的变量的值。通过指针操作数据是相当高级的事情。如果这个话题让你感到有些不知所措,请不要担心。继续尝试示例,您将更加熟悉语法,这反过来将帮助您考虑如何在您自己的未来项目中使用指针。

数组指针

实际上,我们已经使用过指针,尽管它被巧妙地伪装成数组。回想一下我们在“scanf() and Parsing Inputs”中对scanf()函数的扩展使用。当我们想要扫描一个数字时,我们必须在数字变量的名称前使用&。但扫描字符串不需要那种语法——我们只需给出数组的名称。这是因为在 C 语言中,数组已经是指针,只是具有期望的结构,以便更容易读取和写入数组元素。

原来,您可以在使用方括号的情况下处理数组的内容。您可以使用我们刚才在上一个示例中看到的完全相同的取消引用。通过取消引用,您可以添加和减去简单的整数来访问该数组变量中的单个元素。但最好在代码中详细讨论这类事情。请查看ch06/direct_edit.c

#include <stdio.h>

int main() {
  char name[] = "a.c. Programmer";             ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  printf("Before manipulation: %s\n", name);
  *name = 'A';                                 ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  *(name + 2) = 'C';                           ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  printf("After manipulation: %s\n", name);    ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
}

1

我们像往常一样声明和初始化我们的字符串(char数组)。

2

我们可以取消引用数组变量以读取或更改第一个字符。这相当于name[0] = *A*

3

我们还可以解引用涉及我们的数组变量的表达式。我们可以添加或减去 int 值,这相当于在数组中向前或向后移动一个元素。在我们的代码中,这行代码等同于 name[2] = *C*

4

而且您可以看到数组变量本身是“未受影响”的,尽管我们成功地编辑了字符串。

继续编译和运行程序。这是输出:

ch06$ gcc direct_edit.c
ch06$ ./a.out
Before manipulation: a.c. Programmer
After manipulation: A.C. Programmer

这种数学和解引用方法也适用于其他类型的数组。例如,在处理数组的循环中可能会看到指针算术,其中增加数组指针相当于移动到数组中的下一个元素。这种指针的使用可以非常高效。但是虽然在 direct_edit.c 中的简单操作在历史上可能更快,现代的 C 编译器非常(非常!)擅长优化您的代码。

提示

我建议在担心性能之前,集中精力获得您想要的答案。第十章 探讨了在 Arduino 平台上内存和其他资源的情况,这种担忧更加合理。即使在那里,优化也不是您的首要关注点。

函数和指针

当您将指针附加到函数的参数或返回值时,指针真正开始在程序员日常生活中产生差异。这个特性允许您创建一个可共享的内存块而不使其成为全局的。考虑以下来自 ch06/increment.c 的函数:

void increment_me(int me, int amount) {
  // increment "me" by the "amount"
  me += amount;
  printf("  Inside increment_me: %d\n", me);
}

void increment_me_too(int *me, int amount) {
  // increment the variable pointed to by "me" by the "amount"
  *me += amount;
  printf("  Inside increment_me_too: %d\n", *me);
}

第一个函数 increment_me() 应该感觉很熟悉。我们之前已经将值传递给函数。在 increment_me() 内部,我们可以将 amount 添加到 me 上并得到正确的答案。然而,我们确实只从 main() 方法传递了 count。这意味着原始的 count 变量将保持不变。

increment_me_too() 使用了一个指针。现在我们可以传递给 count 的是一个 引用 而不是简单的值。采用这种方法,我们应该发现一旦返回到 main()count 已经被更新了。让我们测试一下这个期望。这是一个尝试两个函数的简单的 main() 方法:

int main() {
  int count = 1;
  printf("Initial count: %d\n", count);
  increment_me(count, 5);
  printf("Count after increment_me: %d\n", count);
  increment_me_too(&count, 5);
  printf("Count after increment_me_too: %d\n", count);
}

下面是我们得到的输出:

ch06$ gcc increment.c
ch06$ ./a.out
Initial count: 1
  Inside increment_me: 6
Count after increment_me: 1
  Inside increment_me_too: 6
Count after increment_me_too: 6

很好。我们得到了我们想要的行为。increment_me() 函数不会影响从 main() 传入的 count 的值,但 increment_me_too() 会影响它。您经常会看到术语“传值”和“传引用”,用来区分函数处理传递给它的参数的方式。请注意,在 increment_me_too() 的情况下,我们有一个引用参数和一个值参数。混合类型没有限制。作为程序员,您只需确保正确使用您的函数。

函数也可以返回它们在堆中创建的东西的指针。这是外部库中常见的技巧,我们将在第 9 和第十一章中看到。

使用数组管理内存

如果您事先知道需要大块内存,比如用于存储图像或音频数据,可以分配自己的数组(和结构体;参见“定义结构体”)。分配的结果是一个指针,您可以将其传递给可能需要处理您数据的任何函数。这样做不会复制任何存储空间,而且您可以在必须使用它之前检查确保获得了所需的所有内存。当处理来自未知来源的内容时,这是一个明显的优势。如果没有足够的内存可用,可以提供一个礼貌的错误消息,并要求用户再试一次,而不是不加说明地崩溃。

使用 malloc()分配内存

虽然我们通常将堆工作保留给较大的数组,但您可以在那里分配任何您想要的内容。为此,您可以使用malloc()函数,并提供所需的字节数量。malloc()函数在另一个头文件stdlib.h中定义,因此我们必须包含该头文件,类似于我们包含stdio.h的方式。我们将在stdio.h中看到stdlib.h提供的更多函数,但目前,只需在顶部添加以下行,就在我们通常的include下面:

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

// ...

包含了这个头文件后,我们可以创建一个简单的程序,演示全局变量和局部变量的内存分配,以及我们自己在堆中的自定义内存。请看ch06/memory.c

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

int result_code = 404;
char result_msg[20] = "File Not Found";

int main() {
  char temp[20] = "Loading ...";
  int success = 200;

  char *buffer = (char *)malloc(20 * sizeof (char));

  // We won't do anything with these various variables,
  // but we can print out their addresses
  printf("Address of result_code:   %p\n", &result_code);
  printf("Address of result_msg:    %p\n", &result_msg);
  printf("Address of temp:          %p\n", &temp);
  printf("Address of success:       %p\n", &success);
  printf("Address of buffer (heap): %p\n", buffer);
}

全局声明的result_coderesult_msg以及局部变量tempsuccess应该是熟悉的。但请看我们如何声明buffer。您可以看到malloc()在一个真实程序中的使用。我们请求了 20 个字符的空间。如果您愿意,可以指定一个简单的字节数,但通常更安全(确实经常需要)使用sizeof,就像这个例子中显示的那样。不同的系统将有不同的类型大小和内存分配规则,sizeof提供了对无意中的错误的简单防范。

让我们来看一下输出中变量的地址:

ch06$ gcc memory.c
ch06$ ./a.out
Address of result_code:   0x55c4f49c8010
Address of result_msg:    0x55c4f49c8020
Address of temp:          0x7fffc84f1840
Address of success:       0x7fffc84f1834
Address of buffer (heap): 0x55c4f542e2a0

再次,不用担心这些地址的确切值。我们在这里寻找的是它们的大致位置。希望您能看到全局变量和我们使用malloc()手动在堆中创建的buffer指针大致处于相同的位置。同样,main()中的两个局部变量也类似地分组在一个单独的位置。

因此,malloc()在堆中为你的数据提供了空间。我们将在“指向结构体的指针”中利用这个分配的空间,但首先我们需要看看一个密切相关的函数free()。当你使用malloc()分配内存时,你有责任在完成后返回该空间。

使用free()进行释放

正如你在图 6-3 的讨论中可能记得的那样,如果堆栈或堆使用过多——或者两者都使用了足够多——你将会耗尽内存并导致程序崩溃。使用堆的好处之一是你可以控制何时以及如何从堆中分配和返回内存。当然,正如我刚才提到的,这个好处的另一面是你必须记得自己做“归还”部分。许多较新的语言试图减轻程序员的这一负担,因为很容易忘记在自己完成后清理。也许你甚至听说过这个问题的准官方术语:内存泄漏。

为了在 C 语言中返回内存并避免这种泄漏,你可以使用free()函数(也来自stdlib.h)。使用起来非常简单——你只需传递从相应的malloc()调用返回的指针。例如,当你完成使用buffer后释放它:

  free(buffer);

简单!但是,要记得使用free()才是难点。这可能看起来并不是什么大问题,但是当你开始使用函数来创建和删除数据片段时,情况就会变得越来越复杂。你调用创建函数的次数有多少次?每次都调用了对应的删除函数吗?如果尝试删除从未分配的内容会怎样?所有这些问题使得追踪内存使用既困难又重要。

C 语言的结构体

随着你解决更加有趣的问题,你的数据存储需求将变得更加复杂。例如,如果你正在处理 LCD 显示器,你将会处理需要颜色和位置的像素。这个位置本身将由xy坐标组成。虽然你可以创建三个单独的数组(一个用于所有颜色,一个用于所有x坐标,最后一个用于所有y坐标),但这样的集合会很难传递给和从函数中传递,并且容易引入多种错误——比如添加了颜色却忘记了其中一个坐标。幸运的是,C 语言包含了struct机制来为你的新数据需求创建更好的容器。

引用 K&R 的话说:“结构体是一个或多个可能是不同类型的变量的集合,它们被组合在一起,以便于方便处理。”¹ 他们继续指出,其他语言支持这个想法作为记录。今天在网上搜索你也会遇到术语复合类型。不管你如何称呼它,这种变量组合特性非常强大。让我们看看它是如何工作的。

定义结构体

要创建自己的结构,你使用 struct 关键字和名称,后面跟着大括号内部的变量列表。然后,你可以像访问数组的元素一样通过名称访问这些变量。下面是一个我们可以在银行账户程序中使用的快速示例:

struct transaction {
  double amount;
  int day, month, year;
};

现在我们有了一个新的“类型”,我们可以在变量中使用它。不再使用 intchar[],我们有了 struct transaction

int main() {
  int count;
  char message[] = "Your money is safe with us!";
  struct transaction bill, deposit;
  // ...
}

countmessage 的声明应该很熟悉。接下来的一行声明了另外两个变量 billdeposit,它们共享新的 struct transaction 类型。你可以在任何使用 int 等原生类型的地方使用这种新类型。你可以创建局部或全局变量使用 struct 类型。你可以将结构体传递给函数或从函数返回。在处理结构和函数时更倾向于使用指针,但我们将在“函数和结构”中详细讨论这些细节。

你的结构定义可以非常复杂。它们可以包含多少个变量几乎没有真正的限制。一个结构体甚至可以包含嵌套的 struct 定义!当然,你不想过度使用,但你确实可以自由地创建几乎任何类型的记录。

分配和访问结构成员

一旦定义了你的结构类型,你就可以使用类似处理数组的语法声明和初始化该类型的变量。例如,如果你提前知道结构的值,你可以使用花括号初始化你的变量:

  struct transaction deposit = { 200.00, 6, 20, 2021 };

大括号内的值的顺序需要与你在 struct 定义中列出的变量顺序相匹配。但是你也可以创建一个结构变量,并在事后填充它。要指示你要分配的字段,你使用“点”运算符。你给出结构变量的名称(在我们当前的例子中是 billdeposit),一个句点,然后你感兴趣的结构成员,比如 dayamount。使用这种方法,你可以按任何顺序进行分配:

  bill.day = 15;
  bill.month = 7;
  bill.year = 2021;
  bill.amount = 56.75;

无论如何填充结构,你都可以使用相同的点表示法随时访问结构的内容。例如,要打印交易的任何细节,我们指定交易变量(在我们的例子中是 billdeposit),点号,以及我们想要的字段,就像这样:

  printf("Your deposit of $%0.2f was accepted.\n", deposit.amount);
  printf("Your bill is due on %d/%02d\n", bill.month, bill.day);

我们可以将这些内部元素打印到屏幕上。我们可以为它们分配新值。我们可以在计算中使用它们。你可以用结构中的内部元素做任何其他变量可以做的事情。结构的目的只是为了更轻松地将相关数据片段放在一起。但是这些结构也保持数据独立。考虑在我们的billdeposit中分配amount变量:

  deposit.amount = 200.00;
  bill.amount = 56.75;

在这两个赋值中我们使用了 amount 这个名字,但是我们从来不会混淆你指的是哪个 amount。例如,如果我们在设定了我们的 bill 后给它加一些税,那并不会影响我们在 deposit 中包含的金额:

  bill.amount = bill.amount + bill.amount * 0.05;

  printf("Our final bill: $%0.2f\n", bill.amount); // $59.59
  printf("Our deposit: $%0.2f\n", )                // $200.00

希望这种分离有意义。使用结构体,您可以将账单和存款作为独立的实体来讨论,同时理解任何单个账单或存款的详细信息仍然是独特的。

结构体指针

如果您构建了一个良好的组合类型,它封装了恰到好处的数据,您很可能会开始在越来越多的地方使用这些类型。您可以将它们用作全局和局部变量,或作为参数类型甚至函数返回类型。然而,在实际应用中,您更常见的是程序员使用结构体指针而不是结构体本身。

要创建(或销毁)结构体指针,您可以使用与简单类型相同的操作符和函数。例如,如果您已经有一个 struct 变量,您可以使用 & 操作符获取它的地址。如果您使用 malloc() 创建了结构体的实例,您可以使用 free() 将该内存返回到堆中。以下是几个使用这些特性和函数的示例,我们的类型是 struct transaction

struct transaction tmp = { 68.91, 8, 1, 2020 };
struct transaction *payment;
struct transaction *withdrawal;

payment = &tmp;
withdrawal = malloc(sizeof(struct transaction));

在这里,tmp 是一个普通的 struct transaction 变量,我们使用花括号初始化它。paymentwithdrawal 都声明为指针。我们可以像对待 payment 那样,将一个 struct transaction 变量的地址赋给它,或者像对待 withdrawal 那样,在堆上分配内存(以便稍后填写)。

然而,当我们填写 withdrawal 时,我们必须记住我们有一个指针,因此在应用点之前我们需要解引用 withdrawal。不仅如此,点操作符的优先级比解引用操作符高,因此您必须使用括号正确应用操作符。这可能有点繁琐,因此我们经常使用一种替代符号来访问结构体指针的成员。箭头操作符 -> 允许我们在不解引用的情况下使用结构体指针。您将箭头放置在结构体变量名称和预期成员名称之间,就像使用点操作符一样:

// With dereferencing:
(*withdrawal).amount = -20.0;

// With the arrow operator:
withdrawal->day = 3;
withdrawal->month = 8;
withdrawal->year = 2021;

这种差异可能有点令人沮丧,但最终您会习惯的。结构体指针提供了一种有效的方式,在程序的不同部分之间共享相关信息。它们最大的优势是指针没有移动或复制其结构体内部所有部分的开销。当您开始将结构体与函数一起使用时,这种优势就会显现出来。

函数和结构体

考虑编写一个函数,以漂亮的格式打印交易内容。我们可以将结构体作为参数传递给函数。我们在参数列表中使用struct transaction类型,然后在调用时传递一个普通变量:

void printTransaction1(struct transaction tx) {
  printf("%2d/%02d/%4d: %10.2f\n", tx.month, tx.day, tx.year, tx.amount);
}
// ...
printTransaction1(bill);
printTransaction1(deposit);

非常简单,但请回顾我们关于函数调用与堆栈的讨论。在这个例子中,当我们调用printTransaction1()时,billdeposit的所有字段都必须放在堆栈上。这会消耗额外的时间和空间。事实上,在 C 的最早版本中,这甚至是不允许的!现在显然不再如此,但是通过指针传递和从函数返回指针仍然更快。以下是我们printTransaction1()函数的指针版本:

void printTransaction2(struct transaction *ptr) {
  printf("%2d/%02d/%4d: %10.2f\n",
      ptr->month, ptr->day, ptr->year, ptr->amount);
}
// ...
printTransaction2(&tmp);
printTransaction2(payment)
printTransaction2(withdrawal);

唯一需要放在堆栈上的是一个struct transaction对象的地址。更加清晰。

通过这种方式传递指针具有一个有趣且预期的特性:我们可以在函数中更改结构体的内容。回忆一下 “传递简单类型”,没有指针时,我们通过堆栈传递初始化函数参数的值。我们在函数内对这些参数所做的任何操作都不会影响从函数被调用的地方传递的原始参数。

然而,如果我们传递一个指针,我们可以使用该指针来更改结构体的内部。这些更改是持久的,因为我们正在处理实际的结构体,而不是其值的副本。例如,我们可以创建一个函数来给任何交易添加税:

void addTax(struct transaction *ptr, double rate) {
  double tax = ptr->amount * rate;
  ptr->amount += tax;
}

// ... back in main
  printf("Our bill amount before tax: $%.2f\n", bill.amount);
  addTax(&bill, 0.05);
  printf("Our bill amount after tax: $%.2f\n", bill.amount);
// ...

注意,在main()函数中我们没有改变bill.amount。我们只是将其地址与税率一起传递给addTax()函数。以下是那些printf()语句的输出:

Our bill amount before tax: $56.75
Our bill amount after tax: $59.59

正是我们所期望的。由于其功能强大,通过引用传递结构体在大型程序中非常常见。并非所有内容都需要放在结构体中,也不是每个结构体都必须通过引用传递,但是组织和效率确实非常吸引人。

警告

使用指针改变结构体内容的能力通常是可取的。但如果出于某些原因,你不想在使用指针时改变某个成员,确保不对该成员赋值。当然,你可以先将该成员的值复制到一个临时变量中,然后再处理该临时变量。

指针语法回顾

在本章中,我介绍了足够多新奇且有些难懂的 C 语法,因此我想在这里做个简要回顾:

  • 我们使用struct关键字定义新的数据类型。

  • 我们使用“点”操作符(.)来访问结构体的内容。

  • 我们使用“箭头”操作符(->)来通过指针访问结构体的内容。

  • 我们使用malloc()为数据分配了自己的空间。

  • 我们使用&(“取地址”)和*(“解引用”)操作符处理该空间。

  • 当我们处理完数据后,可以使用free()释放其空间。

让我们看看这些新概念和定义在实际中的应用。考虑以下程序,ch06/structure.c。在这个稍长的清单中,我添加了几条内联注释来突出关键点,而不是在这里使用调用注释。这样你可以快速查找书中的细节,或者在你自己的代码编辑器中查找:

// Include the usual stdio, but also stdlib for access
// to the malloc() and free() functions, and NULL
#include <stdio.h>
#include <stdlib.h>

// We can use the struct keyword to define new, composite types
struct transaction {
  double amount;
  int month, day, year;
};

// That new type can be used with function parameters
void printTransaction1(struct transaction tx) {
  printf("%2d/%02d/%4d: %10.2f\n", tx.month, tx.day, tx.year, tx.amount);
}

// We can also use a pointer to that type with parameters
void printTransaction2(struct transaction *ptr) {
  // Check to make sure our pointer isn't empty
  if (ptr == NULL) {
    printf("Invalid transaction.\n");
  } else {
    // Yay! We have a transaction, print out its details with ->
    printf("%2d/%02d/%4d: %10.2f\n", ptr->month, ptr->day, ptr->year,
        ptr->amount);
  }
}

// Passing a structure pointer to a function means we can alter
// the contents of the structure if necessary
void addTax(struct transaction *ptr, double rate) {
  double tax = ptr->amount * rate;
  ptr->amount += tax;
}

int main() {
  // We can declare local (or global) variables with our new type
  struct transaction bill;

  // We can assign initial values inside curly braces
  struct transaction deposit = { 200.00, 6, 20, 2021 };

  // Or we can assign values at any time after with the dot operator
  bill.amount = 56.75;
  bill.month = 7;
  bill.day = 15;
  bill.year = 2021;

  // We can pass structure variables to functions just like other variables
  printTransaction1(deposit);
  printTransaction1(bill);

  // We can also create pointers to structures and use them with malloc()
  struct transaction tmp = { 68.91, 8, 1, 2020 };
  struct transaction *payment = NULL;
  struct transaction *withdrawal;
  payment = &tmp;
  withdrawal = malloc(sizeof(struct transaction));

  // With a pointer, we either have to carefully dereference it
  (*withdrawal).amount = -20.0;
  // Or use the arrow operator
  withdrawal->day = 3;
  withdrawal->month = 8;
  withdrawal->year = 2021;

  // And we are free to pass structure pointers to functions
  printTransaction2(payment);
  printTransaction2(withdrawal);

  // Add tax to our bill using a function and a pointer
  printf("Our bill amount before tax: $%.2f\n", bill.amount);
  addTax(&bill, 0.05);
  printf("Our bill amount after tax: $%.2f\n", bill.amount);

  // Before we go, release the memory we allocated to withdrawal:
  free(withdrawal);
}

就像大多数新概念和语法片段一样,你在自己的程序中越多地使用指针和malloc(),就会越熟悉它们。从头开始创建一个解决你感兴趣的问题的程序总是有助于巩固你对新主题的理解。我正式允许你去玩弄指针!

下一步

在这一章中,我们涵盖了一些相当高级的内容。我们探讨了在程序运行时数据存储在内存中的位置,以及帮助你处理这些数据地址的运算符(&*.->)和函数(malloc()free())。许多中级和高级编程书籍会花费多章节来讲解这些概念,所以如果你需要多次阅读这些材料,不要感到泄气。像往常一样,运行代码并进行一些自己的修改是练习理解的好方法。

现在我们的 C 工具包中有了许多强大的工具!我们可以开始解决复杂的问题,并且有很大的机会解决它们。但在许多情况下,我们的问题实际上并不是新问题。事实上,许多问题(或者至少我们将真实任务分解为可管理的子任务时遇到的许多子问题)已经被其他程序员遇到并解决了。下一章将探讨如何利用这些外部解决方案。

¹ 那种方便的处理方式确实非常便利。Kernighan 和 Ritchie 在《C 程序设计语言》中专门为这个主题撰写了一整章。显然,他们在这里的详细内容比我能提供的要多,所以我再次推荐你阅读这个经典著作。

第七章:图书馆

C 语言的一大优点是其编译代码中的最小装饰。对于一些更现代的语言如 Java,有一个经典的笑话是关于“Hello, World”程序的大小。我们在“创建 C 语言的‘Hello, World’”中的第一个程序在我的 Linux 机器上占用了略多于 16Kb 的空间,没有进行任何优化。而在同一系统上使用 Java 来实现相同的输出,需要数十兆字节的空间,并且需要更多的工作量来构建。这并不是一个完全公平的比较,因为 Java 的 Hello 应用程序需要整个 Java 运行时嵌入到可执行文件中,但这也正是重点所在:对于特定系统,C 语言可以轻松创建精简的代码。

当我们处理小东西如“Hello, World”或者过去章节中的大多数示例时,这种简易性非常棒。但当我们准备跳入微控制器和 Arduino 的世界时,我们开始担心是否要重新创建一些相当乏味的问题的解决方案。例如,我们编写了一些自己的函数来比较字符串。我们编写了一个更复杂的程序来编码 base64 内容。这些都很有趣,但我们是否总是需要从头开始做这种工作呢?

幸运的是,对于这个问题的答案是:不需要。C 语言支持使用的概念,可以快速友好地扩展其功能,而不会影响最终可执行文件的精简性。库是一组代码的集合,可以导入到您的项目中以添加新功能,比如处理字符串或与无线网络通信。但使用库的关键在于,您只需要添加包含您需要功能的那一个库。相比之下,Java 的 Hello 应用程序可能会包含整个图形界面和网络连接的支持,尽管这些功能在终端窗口中只是打印文本时不会被使用。

例如,使用 Arduino 时,您会发现为大多数流行的传感器如温度组件或光电阻器以及输出如 LED 和 LCD 显示器提供了库。您不需要编写自己的设备驱动程序来使用电子纸或更改 RGB LED 的颜色。您可以加载一个库,并专注于在电子纸上显示什么,而不必担心如何实现。

C 标准库

在书中我们到达目前位置的路上已经使用了几个库。即使是我们的第一个程序也需要stdio.h头文件来访问printf()函数。而我们在第六章关于指针的最新工作需要stdlib.h头文件中的malloc()函数。我们不需要做太多事情就能访问到这些功能。事实上,我们只需在程序顶部写上一个#include语句,然后就可以开始工作了!

这些函数之所以如此易于集成,是因为它们属于 C 标准库。每个 C 编译器或开发环境都会提供此库。它在不同平台上的打包方式可能有所不同(例如包含或排除数学函数),但你始终可以指望它的整体内容可以被包含进来。我无法涵盖库中的所有内容,但我确实想要强调一些有用的函数和提供它们的头文件。在 “将其放在一起” 中,我还将介绍在哪里查找处理更广泛功能的其他库。

stdio.h

从一开始我们就一直在使用 stdio.h 头文件。我们已经使用了两个最有用的函数(对我们的目的而言):printf()scanf()。该头文件中的其他函数主要围绕文件访问展开。在接下来的章节中,我们将使用的微控制器有时会有文件系统,但我们将编写的程序类型不需要该特定功能。不过,如果你确实想在桌面或高性能微控制器上处理文件,这个头文件是一个很好的起点!

stdlib.h

我们还看到了 stdlib.h 中的几个函数,即 malloc()free()。但是这个头文件还有一些更有用的技巧值得一提。

atoi()

在 “命令行参数和 main()” 中,我给了你一个将字符串转换为数字的练习。 “额外积分”注释提到使用 stdlib.h 来访问 C 标准的转换函数:atoi()。还有另外两个转换函数用于其他基本类型:atol() 转换为 long 值,而 atof() 转换为浮点类型,但与函数名称中的最后一个字母相反,atof() 返回一个 double 值。(如果需要,你可以将其转换为低精度的 float 类型。)

那个额外练习的解决方案,ch07/sum2.c,突显了如果包含必要的头文件,转换就有多么简单:

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

int main(int argc, char *argv[]) {
  int total = 0;
  for (int i = 1; i < argc; i++) {
    total += atoi(argv[i]);
  }
  printf("The sum of these %d numbers is %d\n", argc - 1, total);
}

非常简单!当然,这也是使用库函数的希望所在。你可以自己编写这个转换代码,但如果你能找到一个合适的库函数来使用,你可以节省大量时间(和相当数量的调试)。

警告

在使用这些函数时要小心一些。它们在遇到非数字字符时会停止解析字符串。例如,如果尝试将单词“one”转换为数字,解析将立即停止,并且 atoi()(或其他函数)会返回 0 而没有任何错误。如果 0 可能是字符串中的一个合法值,你在调用它们之前需要添加自己的有效性检查。

rand() 和 srand()

随机值在许多情况下都起到了有趣的作用。想要改变 LED 灯的颜色吗?想要洗牌一副虚拟的扑克牌?需要模拟潜在的通信延迟吗?随机数来拯救!

rand()函数返回一个 0 到一个常量(技术上是一个;更多关于这些内容详见“特殊值”),即RAND_MAX之间的伪随机数。我说“伪随机”是因为你得到的“随机”数是算法的产物。¹

相关的函数srand()可以用来种子伪随机数生成算法。种子值是算法在跳跃产生各种值之前的起始点。你可以使用srand()来每次程序运行时提供新的值——例如当前时间戳——或者你可以使用种子来产生已知的数列。这可能看起来是一个奇怪的需求,但在测试中会很有用。

让我们试用这两个函数来感受它们的用法。看看ch07/random.c

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

int main() {
  printf("RAND_MAX: %d\n", RAND_MAX);
  unsigned int r1 = rand();
  printf("First random number: %d\n", r1);
  srand(5);
  printf("Second random number: %d\n", rand());
  srand(time(NULL));
  printf("Third random number: %d\n", rand());
  unsigned int pin = rand() % 9000 + 1000;
  printf("Random four digit number: %d\n", pin);
}

让我们编译并运行它来看看输出:

ch07$ gcc random.c
ch07$ ./a.out
RAND_MAX: 2147483647
First random number: 1804289383
Second random number: 590011675
Third random number: 1205842387
Random four digit number: 7783

ch07$ ./a.out
RAND_MAX: 2147483647
First random number: 1804289383
Second random number: 590011675
Third random number: 612877372
Random four digit number: 5454

在我的系统上,rand()返回的最大值是 2147483647。我们生成的第一个数应该在 0 到 2147483647 之间,确实如此。我们生成的第二个数将在相同的范围内,但在我们为srand()提供一个新的种子值后,希望它与r1不同,结果确实如此。

但是看看我们第二次运行的输出中的前两个“随机”数。它们完全一样!几乎不随机。正如我所提到的,rand()是一个伪随机生成器。如果你从未调用过srand(),生成算法的默认种子是 1。但是,如果你使用像 5 这样的常量调用它,情况也不会好转。它会生成不同的数列,但每次运行程序时都会是相同的“不同”数列。

因此,为了获得不同的伪随机数,你需要提供一个在每次运行程序时都会改变的种子。最常见的技巧是像我一样包含另一个头文件time.h(见“time.h”)并引入当前时间戳(自 1970 年 1 月 1 日以来的秒数)。只要我们不在一秒内两次启动程序,我们将在每次运行时得到新的序列。你可以在上述两次运行中看到,这种种子效果良好,因为第三个数字在它们之间确实是不同的。

使用了更好的种子²之后,对rand()的后续调用应该在每次执行时看起来都是随机的。我们可以通过生成的最终随机数的 PIN 来看到这种好处。使用了一种获取范围内随机数的流行技巧来限制 PIN。你可以使用取余运算符来确保你得到一个适当限制的范围,并且加上一个基值。为了 PIN 正好有四位数,我们使用了基数 1000 和范围 9000(从 0 到 8999 包括)。

退出()

我要突出的stdlib.h中的最后一个函数是exit()函数。在“返回值和 main()”中,我们看过使用return语句来结束程序,并可选地从main()函数返回一个值,以向操作系统提供一些状态信息。

此外还有一个单独的exit()函数,它接受一个int参数,用于与main()方法中的return语句相同的退出码值。使用exit()和从main()返回的区别在于,exit()可以从任何函数调用,并立即退出应用程序。例如,我们可以编写一个“确认”函数,询问用户是否确定要退出。如果他们回答*y*,那么我们可以在那一点上使用exit(),而不是返回一些特殊值给main(),然后使用return。查看ch07/areyousure.c

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

void confirm() {
  char answer;
  printf("Are you sure you want to exit? (y/n) ");
  scanf("%c", &answer);
  if (answer == 'y' || answer == 'Y') {
    printf("Bye\n\n");
    exit(0);
    printf("This will never be printed.\n");
  }
}

int main() {
  printf("In main... let's try exiting.\n");
  confirm();
  printf("Glad you decided not to leave.\n");
}

下面是两次运行的输出:

ch07$ gcc areyousure.c
ch07$ ./a.out
In main... let's try exiting.
Are you sure you want to exit? (y/n) y
Bye

ch07$ ./a.out
In main... let's try exiting.
Are you sure you want to exit? (y/n) n
Glad you decided not to leave.

注意当我们使用exit()时,我们不会返回到main()函数,甚至不会完成我们confirm()函数本身中的代码。我们真的退出程序并向操作系统提供退出码。

顺便说一下,在main()内部,无论您使用return还是exit(),大部分情况下都不会有太大差别,尽管前者更为“礼貌”。(例如,如果使用return,则从完成main()函数时的任何清理仍将运行。如果使用exit(),则会跳过同样的清理。)还值得注意的是,只要像我们一直在做的那样到达main()体的末尾是完成程序的一种很好且流行的方式,当然,在没有错误时完成程序时,这种方式会更好。

string.h

字符串如此常见且如此有用,甚至有自己的头文件。string.h头文件可以添加到任何需要比简单存储和打印字符串更多地比较或操作字符串的程序中。这个头文件描述了比我们这里有时间涵盖的更多函数,但我们想要突出显示一些重要的实用程序在表 7-1 中。

表 7-1. 有用的字符串函数

函数 描述
strlen(char *s) 计算字符串的长度(不包括最后的空字符)
strcmp(char *s1, char *s2) 比较两个字符串。如果 s1 < s2,则返回-1,如果 s1 == s2,则返回 0,如果 s1 > s2,则返回 1
strncmp(char *s1, char *s2, int n) 比较 s1 和 s2 的最多 n 个字节(结果类似于 strcmp)
strcpy(char *dest, char *src) 将 src 复制到 dest
strncpy(char *dest, char *src, int n) 将 src 的最多 n 个字节复制到 dest
strcat(char *dest, char *src) 将 src 追加到 dest
strncat(char *dest, char *src, int n) 将最多 n 个字节的 src 附加到 dest

我们可以在一个简单的程序中演示所有这些功能,ch07/fullname.c,通过请求用户提供其全名的各个部分(安全地!)在最后拼接起来。如果我们发现在与丹尼斯·里奇交互时,我们会感谢他编写了 C 语言。

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

int main() {
  char first[20];
  char middle[20];
  char last[20];
  char full[60];
  char spacer[2] = " ";

  printf("Please enter your first name: ");
  scanf("%s", first);
  printf("Please enter your middle name or initial: ");
  scanf("%s", middle);
  printf("Please enter your last name: ");
  scanf("%s", last);

  // First, assemble the full name
  strncpy(full, first, 20);
  strncat(full, spacer, 40);
  strncat(full, middle, 39);
  strncat(full, spacer, 20);
  strncat(full, last, 19);

  printf("Well hello, %s!\n", full);

  int dennislen = 17;  // length of "Dennis M. Ritchie"
  if (strlen(full) == dennislen &&
      strncmp("Dennis M. Ritchie", full, dennislen) == 0)
  {
    printf("Thanks for writing C!\n");
  }
}

以下是一个示例运行:

ch07$ gcc fullname.c
ch07$ ./a.out
Please enter your first name: Alice
Please enter your middle name or initial: B.
Please enter your last name: Toklas
Well hello, Alice B. Toklas!

让自己尝试这个程序。如果您输入了丹尼斯的姓名(包括他的中间名后面的句点:“M.”),您是否像预期的那样收到感谢消息?

警告

将在strncat()中要连接的最大字符数设置为目标中剩余的最大字符数,而不是源字符串的长度。您的编译器可能会因此错误而发出“指定的边界 X 等于源长度”的警告消息。当然,X 将是您在调用strncat()时指定的边界。这只是一个警告,你可能确实有与你的源长度完全相同的长度。但如果您看到这个警告,请再次检查,确保您没有意外使用源长度。

作为另一个例子,我们可以重新审视默认值的概念和从“初始化字符串”中覆盖数组。您可以延迟字符数组的初始化,直到您知道用户做了什么。我们可以声明但不初始化一个字符串,用scanf()使用它,然后如果用户没有给出良好的替代值,我们可以返回到默认值。

让我们尝试一个关于未来某个惊人应用程序背景颜色的问题。我们可能假设一个黑色背景的暗色主题。我们可以提示用户提供一个不同的值,或者如果他们想保留默认值,他们可以简单地按回车键。这里是ch07/background.c

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

int main() {
  char background[20];                                     ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  printf("Enter a background color or return for the default: ");
  scanf("%[^\n]s", background);                            ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  if (strlen(background) == 0) {                           ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
    strcpy(background, "black");
  }
  printf("The background color is now %s.\n", background); ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
}

1

声明一个具有足够容量的字符串,但不设置任何内容。

2

从用户处获取输入并将其存储在我们的数组中。

3

如果在提示用户后数组为空,请存储我们的默认值。

4

展示最终值,可以是用户提供的,也可以是默认值。

这里是一些示例运行,包括保留黑色背景的示例:

ch07$ gcc background.c
ch07$ ./a.out
Enter a background color or return for the default: blue
The background color is now blue.
ch07$ ./a.out
Enter a background color or return for the default: white
The background color is now white.
ch07$ ./a.out
Enter a background color or return for the default:
The background color is now black.

如果您邀请用户提供一个值,请记住在数组中分配足够的空间以容纳用户可能提供的任何响应。如果您不能信任您的用户,scanf()有另一个技巧可以部署。就像在printf()中的格式说明符一样,您可以在scanf()中的任何输入字段中添加一个宽度。对于我们之前的例子,我们可以改变为显式的限制为 19(为最终的'\0'字符节省空间):

  scanf("%19[^\n]s", background);

非常简单。它看起来很密集,但对于可能无法为冗长用户提供大量额外空间的有限设备来说,这是一个不错的选择。

math.h

math.h头文件声明了多个有用的函数,用于执行各种算术和三角函数计算。表 7-2 包含了几个比较流行的函数。所有这些函数都返回double值。

表 7-2. math.h中的便利函数

函数 描述
三角函数
cos(double rad) 余弦
sin(double rad) 正弦
atan(double rad) 反正切
atan2(double y, double x) 双参数反正切(正 X 轴与点(x,y)之间的角度)
根和指数
exp(double x) e^x
log(double x) x 的自然对数(以 e 为底)
log10(double x) x 的常用对数(以 10 为底)
pow(double x, double y) x^y
sqrt(double x) x 的平方根
四舍五入
ceil(double x) ceiling 函数,返回比 x 大的最小整数
floor(double x) floor 函数,返回比 x 小的最大整数
符号
fabs(double x)^(a) 返回 x 的绝对值
^(a) 奇怪的是,整数类型的绝对值函数abs()stdlib.h中声明。

为了任何需要intlong答案的情况,你只需进行类型转换。例如,我们可以编写一个简单的程序(ch07/rounding.c)来对多个整数求平均,并将结果四舍五入为最接近的int值,像这样:

#include <stdio.h>
#include <math.h>

int main() {
  int grades[6] = { 86, 97, 77, 76, 85, 90 };
  int total = 0;
  int average;

  for (int g = 0; g < 6; g++) {
    total += grades[g];
  }
  printf("Raw average: %0.2f\n", total / 6.0);
  average = (int)floor(total / 6.0 + 0.5);
  printf("Rounded average: %d\n", average);
}

由于我们可能需要帮助编译器使用这个库,让我们看一下编译命令:

gcc rounding.c -lm

同样,math.h声明了 C 标准库中的函数,但这些函数不一定实现在同一位置。包含大多数我们讨论的函数的二进制文件是libc(或 GNU 版本的glibc)。然而,在许多系统上,数学函数位于单独的二进制文件libm中,需要添加尾随的 -lm 标志来确保编译器知道要链接数学库。

你的系统可能不同。尝试不使用 -lm 选项进行编译不会有任何损害,以查看系统是否自动包含libm(或已经在libc中包含了所有函数)。如果尝试编译而不使用该标志,并且没有收到任何错误消息,那就表示一切正常!如果确实需要该库标志,则会看到类似以下的信息:

ch07$ gcc rounding.c
/usr/bin/ld: /tmp/ccP1MUC7.o: in function `main':
rounding.c:(.text+0xaf): undefined reference to `floor'
collect2: error: ld returned 1 exit status

自己试试(根据需要使用或不使用库标志)。你应该得到 85 作为答案。如果经常需要进行四舍五入,可以编写自己的函数来简化操作,避免在代码中添加稍微繁琐的floor()调用和类型转换前加 0.5 值的麻烦事务。

time.h

这个头文件为你提供了一些工具,帮助确定和显示时间。它使用两种类型的存储来处理日期和时间:一个简单的时间戳(使用类型别名 time_t,代表自 1970 年 1 月 1 日以来的秒数,UTC 时间)和一个更详细的结构体 struct tm,其定义如下:

struct tm {
  int tm_sec;   // seconds (0 - 60; allow for leap second)
  int tm_min;   // minutes (0 - 59)
  int tm_hour;  // hours (0 - 23)
  int tm_mday;  // day of month (1 - 31)
  int tm_mon;   // month (0 - 11; WARNING! NOT 1 - 12)
  int tm_year;  // year (since 1900)
  int tm_wday;  // day of week (0 - 6)
  int tm_yday;  // day of year (0 - 365)
  int tm_isdst; // Daylight Saving Time flag
                // This flag can be in one of three states:
                // -1 == unavailable, 0 == standard time, 1 == DST.
}

我不会使用这个漂亮的结构来处理所有分离的字段,但如果你正在处理日期和时间,比如在日历应用程序中可能会用到,它可能会很有用。我会不时使用时间戳,正如我们在“rand() and srand()” 中已经看到的,用于向 srand() 函数提供一个变化的种子。表 7-3 展示了一些处理这些简单值的函数:

表 7-3. 时间戳处理

功能 描述
char *ctime(time_t *t) 返回本地时间的字符串
struct tm *localtime(time_t *t) 将时间戳展开为详细结构
time_t mktime(struct tm *t) 将结构体转换为时间戳
time_t time(time_t *t) 返回当前时间作为时间戳

最后一个函数 time() 的定义可能看起来有点奇怪。它既接受又返回一个 time_t 指针。你可以用 NULL 值或有效的 time_t 类型变量的指针来调用 time()。如果使用 NULL,则简单地返回当前时间。如果提供指针,则返回当前时间,并更新指向的变量为当前时间。我们在处理随机数时只需要 NULL 选项,但你会碰到一些使用这种模式的实用函数。如果你在处理堆内存时,这可能会很有用。

ctype.h

许多处理用户输入的情况都要求你验证输入是否符合某种期望的类型或值。例如,邮政编码应为五位数字,美国州的缩写应为两个大写字母。ctype.h 头文件声明了几个检查单个字符的便利函数。它还有两个辅助函数,用于大小写转换。表 7-4 强调了几个这样的函数。

表 7-4. 使用 ctype.h 处理字符

功能 描述
测试
isalnum(int c) 判断 c 是否为数字字符或字母
isalpha(int c) 判断 c 是否为字母
isdigit(int c) 判断 c 是否为十进制数字
isxdigit(int c) 判断 c 是否为十六进制数字(不区分大小写)
islower(int c) 判断 c 是否为小写字母
isupper(int c) 判断 c 是否为大写字母
isspace(int c) 判断 c 是否为空格、制表符、换行符、回车符、垂直制表符或换页符
转换
tolower(int c) 返回 c 的小写版本
toupper(int c) 返回 c 的大写版本

不要忘记你的布尔运算符!你可以轻松地扩展这些测试来询问诸如“不是空格”的问题,使用!运算符:

  if (!isspace(answer)) {
    // Not a blank character, so go ahead
    ...
  }
注意

与需要int的地方获取double结果的数学函数一样,ctype.h中的转换函数返回int,但你可以根据需要轻松地将其强制转换为char

组装

让我们引入一些新的头文件,并使用前几章的一些主题来制作一个更加完整的示例。我们将创建一个结构来存储一个简单银行账户的信息。我们可以使用新的string.h工具向每个账户添加一个名字字段。而且我们将使用math.h函数来计算账户余额上的一个样本复利支付。这个示例将需要以下的包含文件:

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

准备好我们的头文件后,让我们深入进入并启动程序本身。

填充字符串

让我们通过创建包含一个字符串“name”字段的账户类型来开始我们的示例。新的struct非常简单:

struct account {
  char name[50];
  double balance;
};

现在我们可以使用我们的字符串函数在创建结构之后用实际内容填充name。我们还可以使用malloc()在函数中创建该结构并返回我们账户的地址。以下是新函数,为了可读性省略了一些安全检查:

struct account *create(char *name, double initial) {
  struct account *acct = malloc(sizeof(struct account));
  strncpy(acct->name, name, 49);
  acct->balance = initial;
  return acct;
}

注意我选择在这里使用strncpy()。我的想法是我不能保证传入的name参数能够适应。当然,由于我写了整个程序,我当然能保证这个细节,但这不是重点。我想要确保如果我允许用户输入,比如通过提示用户输入详细信息,我的create()函数有一些安全措施。

让我们继续创建一个函数来打印我们的账户详细信息。希望这段代码看起来从我们在第六章的工作中很熟悉。我们还可以开始我们的main()函数来尝试我们到目前为止编写的一切:

void print(struct account *a) {
  printf("Account: %s\n", a->name);
  printf("Balance: $%.2f\n", a->balance);
}

int main() {
  struct account *checking;
  checking = create("Bank of Earth (checking)", 200.0);
  print(checking);
  free(checking);
}

让我们编译并运行ch07/account1.c。这是我们的输出:

ch07$ gcc account1.c
ch07$ ./a.out
Account: Bank of Earth (checking)
Balance: $200.00

好极了!到目前为止,一切顺利。接下来是计算利息支付的问题。

找到我们的利息

使用math.h库中的pow()函数,我们可以在一个表达式中计算每月复利。我知道这个公式是在高中教的,但每次真正需要用到时,我还是得上网查一下。然后我们将更新main()以添加一年(5%利率)的利息到我们的账户,并再次打印出详细信息。以下是来自ch07/account2.c的新部分:

void add_interest(struct account *acct, double rate, int months) {
  // Put our current balance in a local var for easier use
  double principal = acct->balance;
  // Convert our annual rate to a monthly percentage value
  rate /= 1200;
  // Use the interest formula to calculate our new balance
  acct->balance = principal * pow(1 + rate, months);
}

int main() {
  struct account *checking;
  checking = create("Bank of Earth (checking)", 200.0);
  print(checking);

  add_interest(checking, 5.0, 12);
  print(checking);

  free(checking);
}

看起来非常不错!让我们编译并运行account2.c。如果你的系统在编译时需要-lm数学库标志,请确保在编译时添加它:

ch07$ gcc account2.c -lm
ch07$ ./a.out
Account: Bank of Earth (checking)
Balance: $200.00
Account: Bank of Earth (checking)
Balance: $210.23

一切都运行正常!尽管你现在是在我修复了写作过程中的各种小错误之后阅读最终输出的代码。例如,我在strncpy()中颠倒了源和目标字符串的顺序。第一次就把所有事情做对是很罕见的。编译器通常会告诉你哪里出错了。你只需回到编辑器中修复它。熟悉错误——以及修复它们!——是我鼓励你输入这些示例的原因之一。实际编写代码是提高编程能力的最佳方法之一。

查找新的库

在这里,有比我在此提及的更多可供使用的库。事实上,在 C 标准库中,就有更多的函数;你可以通过在线查阅GNU C 库的文档深入挖掘。

除了标准库之外,还有其他库可以帮助你的项目。对于那些需要专门库的情况,你最好的选择是在线搜索。例如,如果你想直接与 USB 连接的设备交互,可以搜索“C USB 库”,最终可能找到很棒的libusb,它来自https://libusb.info

你也可以找到一些流行库的列表,但这些列表的质量和维护情况各不相同。遗憾的是,没有像某些语言那样的“所有 C 语言事物”的中央存储库。我的建议是浏览搜索结果,寻找指向像GitHubgnu.org这样的信誉网站的链接。并且不要害怕仅仅阅读库的源代码。如果你看到任何引起注意的地方,要多加关注。大多数情况下,你将得到你所期望的结果,但在使用在线资源时,始终保持一点谨慎是明智的。

下一步

帮助你提升编写代码能力当然是本书的目标之一。我们在本章节中涵盖了一些更常见和流行的库(以及我们必须包含的头文件,以便使用它们的函数)。当然还有更多的库在那里等着你!希望你能看到库和头文件如何与你自己的代码互动。

接下来我们将处理微控制器,并且在此过程中开始着眼于编写更紧凑的代码。良好的代码并不一定是进行优化工作的前提条件,但它确实有帮助。在继续前进之前,可以回顾一些过去章节的例子。试着做出一些改变。试着破坏一些东西。试着修复你破坏的东西。每一次成功的编译都应该成为你程序员生涯中的一个里程碑。

¹ 该算法是确定性的,虽然对大多数开发者来说这没问题,但它并非真正的随机。

² 我没有足够的空间来介绍好的生成器,但是在网上搜索“C 随机生成器”将为您带来一些有趣的选项。有更好的算法,比如 Blum Blum Shub 或者 Mersenne Twister,但您也可以找到硬件相关的生成器,它们可能更好。

第八章:与 Arduino 一起的真实世界 C 语言

我们的 C 语言技能从编译简单语句的短列表到传递指向带有嵌套控制流的函数指针都有所成长。但到目前为止,我们一直在终端窗口中打印结果。这对于验证我们的逻辑是否正确以及我们的程序是否按预期运行非常好,但最终我们会希望代码能在除终端外的其他地方运行,以利用所有出色的硬件。在本书的其余部分,我们将编写针对微控制器的代码。而选择与 Arduino 一起开始再好不过了。

Arduino 家族的微控制器已经存在 15 多年了。从专门设计用于学习和创新的8 位 Atmel AVR控制器开始,这些设备已经大受欢迎。如今,您可以找到预装各种传感器和连接的开发板。WiFi、GPS、蓝牙,甚至是无线电选项都可以轻松添加。输入、输出和容器的生态系统确实令人惊叹。对我们来说,这使得这个平台成为一个完美的目标。您可以购买一个廉价的控制器和 LED 灯开始,然后扩展到机器人技术、气象站、无线电控制或其他任何您感兴趣的电子领域。¹

“获取硬件:Adafruit”包含了本书其余部分将要使用的所有微控制器和外设的信息。但任何兼容 Arduino 的微控制器都可以与我们大多数示例兼容。

Arduino 集成开发环境(Win,Mac,Linux)

在“编译您的代码”一章中,我们学习了如何将 C 源代码编译成在我们的操作系统上运行的可执行文件。尽管可能可以在 Arduino 控制器上运行 C 编译器,但我们可以使用交叉编译器的概念,让我们的笔记本电脑和台式电脑来完成编译的繁重工作,同时生成适用于 Arduino 的二进制文件。

您可以像我们使用gcc一样从命令行运行gcc-avr等工具,但幸运的是有一个方便的 IDE 可以实现其上标注的功能。Arduino IDE 是一个集成开发环境,您可以在其中编辑源代码、编译、加载到微控制器,并观察串行控制台以帮助调试。在控制台看到错误?修复源代码,重新编译和加载。而且它在所有三个主要平台上都可以运行。

无论您使用的是哪个平台,请访问Arduino 软件页面(参见图 8-1),并下载适当版本。如果您想了解 IDE 的功能特点,可以在线查阅IDE 环境指南

smac 0801

图 8-1。Arduino IDE 下载站点

让我们看看 Windows、macOS 和 Linux 的安装详细信息。尽管 Arudino IDE 大多数时候是一个成熟的工具,具有典型的安装程序,但我想指出一些特定于平台的步骤和要点。

在 Windows 上安装

从下载页面,请确保获取来自 arduino.cc 直接提供的下载之一,可以是 ZIP 文件或 Windows 7 及更高版本的安装程序。

警告

如果您使用 Microsoft Store 下载应用程序,可能会注意到其中也有 Arduino IDE。遗憾的是,有许多关于使用该版本 IDE 的困难报告。它是较老的版本,并且商店中的列表似乎没有得到很好的维护。尽管下载页面提供了商店的链接,我们建议避免使用这个版本。

在线指南 提供了通过您下载的 .exe 文件安装 Arduino IDE 的详细说明。这是一个相当标准的 Windows 安装程序;我们唯一的建议是在提示时安装所有可用的组件。(如果您不想要桌面或开始菜单上的快捷方式,当然可以取消勾选。)您可能还会提示安装一些端口和驱动程序,我们建议您也安装这些。如果一切顺利,您可以启动 IDE 并看到一个空文档,如 图 8-2 所示。

smac 0802

图 8-2. Arduino IDE 在 Windows 10 上的运行状态

一旦您的 IDE 启动,请尝试 “您的第一个 Arduino 项目” 中的第一个项目。

在 macOS 上安装

macOS 版本的 Arduino IDE 以一个简单的 .zip 文件形式提供。许多浏览器会自动解压下载,但您也可以自己双击文件解压。.zip 文件中唯一的内容就是 macOS 应用程序。请将该应用程序拖到您的 应用程序 文件夹中。(这可能需要您输入管理员密码。)就是这样!如果成功了,您应该能够看到标准的启动界面,如 图 8-3 所示。

smac 0803

图 8-3. Arduino IDE 在 macOS 上的运行状态

一旦您的 IDE 启动,请尝试 “您的第一个 Arduino 项目” 中的第一个项目。

在 Linux 上安装

对于 Linux,您还可以将应用程序作为一个简单的存档文件,.tar.xz。大多数发行版都有一个存档管理器应用程序,可以通过双击愉快地解压您的下载。如果您还没有这样的方便的应用程序,可以尝试使用您的 tar 版本,因为它可以自动解压大多数类型的存档文件:

$ tar xf arduino-1.8.13-linux64.tar.xz

(当然,根据您的平台和当前发布版本的应用程序本身,您的文件名可能会有所不同。)

将解压后的文件夹(名为 arduino-1.8.13,根据你下载的版本不同而异)放在你想要的任何位置保存该应用程序。这可能是一个共享位置,也可能只是你自己的用户目录中的某个位置。一旦把它放在你喜欢的地方,切换到那个 arduino-1.8.13 文件夹并运行 ./install.sh。该脚本会尽力将快捷方式添加到你的启动菜单和桌面。继续启动应用程序,确保安装成功。你应该会看到类似 图 8-4 的界面,与其他操作系统类似。

smac 0804

图 8-4. 运行在 Linux 上的 Arduino IDE(带有 Gnome)

万岁!让我们在我们的微控制器上运行第一个程序。

你的第一个 Arduino 项目

当然,对于像 Arduino 这样的微控制器,IDE 只是问题的一半。你需要一个真正的 Arduino 控制器!或者至少是它的许多衍生产品之一。你可以从各种卖家和制造商那里获得这些板子。我要为 Adafruit 隆重推荐一下,因为他们有非常出色的各种板子和外围设备,还有所有用于构建实际电子项目的其他配件。他们的 Trinkets 和 Feathers 以及超小巧的 QT Py 为一些小型设备提供了很多出色的功能。

选择你的板子

无论你选择哪种微控制器,你都需要在 Arduino IDE 中指定它。在工具菜单下找到“板子:”选项。然后会列出大量支持的板子供你使用,如 图 8-5 所示。你可以看到我选择了“Adafruit ESP32 Feather”板子。那只是我最近处理的一个项目——一个 ESP32 WiFi 可控 LED 项目。现在微控制器能做到的真是令人惊讶!如果你在列表中找不到匹配的板子,可以返回顶部找“板子管理器…”选项。该选项打开一个对话框,你可以浏览其他支持的板子。

smac 0805

图 8-5. 支持的开发板

在本书的大多数示例中,我将使用来自 Adafruit 的 Metro Mini,如 图 8-6 所示。它配备了一个 16MHz 的 ATmega328P 芯片,拥有 2K 的 RAM 和 32K 的 Flash 存储器。具有大量的 I/O 引脚,我们可以自由地处理从传感器和开关获取的输入,并通过 LED、LCD 和伺服电机提供输出。

smac 0806

图 8-6. Adafruit 公司的 Metro Mini 微控制器

Metro Mini 与 Arduino UNO 兼容引脚,因此让我们选择它作为我们的开发板选项。图 8-7 再次显示了我们选择 UNO 的开发板列表。顺便说一句,“引脚”是你称呼从微控制器伸出并插入到面包板(工程术语,用于连接元件的方便、穿孔基座)的东西。即使你使用鳄鱼夹或直接焊接导线连接 Arduino, “引脚”仍然是指连接到微控制器的具名或编号连接。 “引脚布局图”是一种匹配这些名称和数字到设备实际连接点的备忘单。

smac 0807

图 8-7 选择 UNO 开发板
提示

很可能你会有一个不同的微控制器。对于制造商和其他电子爱好者来说,有很多精彩的选择。希望你在列表中能找到你的开发板,并像我们选用 UNO 一样简单地选择它。遗憾的是,我们无法涵盖每一个选项,甚至无法预测最流行的选项。Arduino 帮助中心提供了一些出色的文档和常见问题解答。他们的社区也是一流的。

你好,LED!

在 Arduinoland,让 LED 闪烁相当于我们“Hello, World”程序的电子版,详见“创建 C 'Hello, World'”。我们将尽力描述我们构建的电路和连接,但你需要根据自己的控制器和组件进行必要的调整。

许多开发板上都集成了一个 LED 和适当的电阻器。我们将从我们的 Metro Mini 开始让 LED 闪烁,以确保我们的 C 代码可以上传并在我们的微控制器上运行。回到 Arduino IDE,选择 文件 → 新建 选项。现在应该有一个新的草图标签。“草图”是指用于在微控制器上执行的编译包。 (更多关于草图的信息请见“C++对象和变量”。)你会看到两个空函数,setup()loop()。这两个函数是微控制器版本的我们桌面 C 程序的main()函数。

setup()函数是我们代码的入口点,通常在首次给板子供电或用户按下复位按钮时运行(如果板子有这样的按钮)。在这里,我们设置任何需要的全局信息或运行硬件初始化,例如重置舵机位置或指定如何使用 I/O 引脚。

然后loop()函数接管并无限重复您的程序。微控制器通常用于一遍又一遍地执行一个任务(或者可能是少量任务),只要它们有电源。它们可以持续读取传感器。它们可以驱动 LED 动画。它们可以读取传感器并使用该值来改变 LED 动画。它们可以推动时钟手指向前。但它们都会重复某些流程,直到您切断电源,因此loop()是一个恰当命名的函数。

虽然幕后发生了更多事情,但是可以合理地想象 Arduino 项目的标准main()函数定义如下:

int main() {
  setup();
  while (1) {
    loop();
  }
}

注意我们的while循环“条件”仅为值 1。请记住在这些布尔上下文中,“非零”被视为真。因此这个while循环将永远运行。这正是 Arduino 所需要的。

对于我们的闪烁 hello 程序,我们将使用setup()告诉板子我们要使用内置 LED 作为输出(这意味着我们将在与 LED 相关联的引脚上“写入”开和关的值)。然后我们将使用loop()进行该写入,并加入一些小延迟,以便让闪烁对人类更加可见。这是我们在Arduino 文档中描述的常量的第一次迭代:

void setup() {
  // put your setup code here, to run once:
  // Tell our board we want to write to the built-in LED
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  // put your main code here, to run repeatedly:
  // A high value is 'on' for an LED
  digitalWrite(LED_BUILTIN, HIGH);
  // Now wait for 500 milliseconds
  delay(500);
  // And write a low value to turn our LED off
  digitalWrite(LED_BUILTIN, LOW);
  // and wait another 500ms
  delay(500);
}
注意

LED_BUILTINHIGH这样的全大写名称是在 Arduino IDE 自动包含的头文件中定义的。它们在技术上是预处理器宏,我们将在“预处理指令”中更详细地讨论它们。它们非常方便,在您自己的代码中非常容易使用:#define PIN 5定义了PIN为值 5。这有点像变量或常量。不同之处在于预处理器将在编译器之前(因此有“预-”前缀)通过您的代码,并且在找到每个PIN的地方替换为字面数字5。典型的变量或常量将在内存中保留一个槽,并且可以在运行时初始化,也许在您从用户那里收集了一些必要信息之后。

接着,输入这个简单的程序。您还可以直接在 IDE 中打开ch08/blink1/blink1.ino项目。

在将其尝试在您的板子上运行之前,您可以使用 IDE 的验证按钮(如图 8-8 所示)来确保代码编译通过。验证您的代码还会检查您的最终程序是否适合您选择的控制器。如果您使用了太多变量或仅仅有太多逻辑,您将在底部状态区看到警告和错误。尝试在某些语句上留下一个分号,就像我们在“C 语言语句”中所做的那样。再次点击验证,您可以看到您在未来编写自己代码时可能会遇到的消息类型。

smac 0808

图 8-8。上传前验证您的草图

在确认您的代码没有问题后,您可以使用上传快捷按钮将其发送到微控制器,该按钮位于验证旁边,或从 Sketch 菜单中选择适当的项目。上传将编译代码(即使您最近已验证过),然后将其写入您的板子。Arduino IDE 在这一步与 Metro Mini 配合得很好——这一切都是令人愉快的自动化。某些开发板需要手动配置上传。再次提醒,Arduino 帮助中心 在这里是您的朋友。

一旦上传完成,您应该看到您的 LED 以半秒间隔开始闪烁。虽然在纸上印刷时不那么显眼,但 Figure 8-9 显示了我们时髦 LED 的开关状态。

smac 0809

图 8-9. 我们的“Hello, World” LED 闪烁

外部 LED 升级

我们专注于 Arduino 项目的软件部分,但使用 Arduino 而不使用 一些 外部组件是不可能的。让我们将我们简单的闪烁器升级为使用外部 LED。

Figure 8-10 显示了我们使用的简单电路。我们有我们的控制器、一个 LED 和一个电阻。对于这个设置,我们可以依赖 USB 提供的电力。

smac 0810

图 8-10. 外部 LED 的简单电路

我们可以选择一个引脚,并通过阅读我们微控制器规格的电压来学习更多信息。在我们的 Metro Mini 的情况下,我们查看了 Adafruit 网站上的 Pinouts 页面。详细信息告诉我们,板子上的哪些引脚映射到 UNO 的引脚。在板的“顶部”(芯片上的微小文字是正向的)有几个数字 I/O 引脚,特别是引脚 2 到 12 正是我们需要的。我们将从 2 开始,因为这个数字很好。不同的板子可能有不同的配置,但对于我们的板子,引脚 0 到 13 直接映射到数字引脚 0 到 13。因此,我们可以使用自己的 #define 并附加一个好名字(耶!)或者只是在我们的 pinMode()digitalWrite() 调用中使用值 2

Metro Mini 在其数字引脚上提供 5V。根据 LED 制造商提供的规格,我们知道我们的蓝色 LED 具有 2.5V 的正向电压降。如果我们想要提供 30mA 的电流以获得明亮的光,欧姆定律 告诉我们,100Ω 电阻器效果很好。一切都连接好后,我们可以制作一个新的草图(或者只是微调第一个)。这里是 ch08/blink2/blink2.ino 的当前状态:

#define D2 2

void setup() {
  // put your setup code here, to run once:
  // Tell our board we want to write to digital pin 2
  pinMode(D2, OUTPUT);
}

void loop() {
  digitalWrite(D2, HIGH);
  delay(2000);
  digitalWrite(D2, LOW);
  delay(1000);
}

请注意,我使用预处理器 #define 功能来指定我们与 LED (D2) 使用的数字引脚。您可以在 Figure 8-11 中看到这个简单的配置运行。太棒了!

smac 0811

图 8-11. 我们的外部 LED 闪烁

在这些小型物理项目中有一种额外的满足感。这些“Hello, World”程序的设计目的是证明您的开发环境正常工作,并且您可以产生一些输出。这就是我们在这里做的一切,但是看到 LED 灯亮起真是太有趣了。每当我在一个新项目上投入开关时,就感觉有点像弗兰肯斯坦博士在实验室里尖叫,“它活了!” 😃

Arduino 库

尽管您可以立即使用这些微控制器做大量工作,但通常情况下,您将会构建一些带有一些有趣附件的项目,如多色 LED、LCD 屏幕、电子墨水、传感器、舵机、键盘甚至游戏控制器。这些组件中的许多都已经写好了一些代码块。这些代码块被收集到一个库中,您可以将其添加到 Arduino IDE 中。其中一些库是“官方”的,来自组件制造商;其他则是由同好制作的。不管来源如何,库都可以加快项目的开发速度。

让我们来看看如何通过 IDE 查找和管理库。正确地做这件事将有助于确保您的草图不包含任何未使用的库。

管理库

Arduino IDE 的“工具”菜单中有一个“管理库…”的入口,打开对话框以搜索和安装库。我们将添加一个 Adafruit 库,并尝试点亮其中一个他们的神奇 NeoPixel——单独可寻址的三色 LED,可用于各种各样的形态。它们甚至可以串联在一起以构建更复杂的装置。不过,在这个例子中,我们将继续使用其中一个最简单的形态因子:Flora

在库管理器对话框中,在顶部的搜索框中输入“neopixel”。您应该会得到几个结果;我们需要简单的“Adafruit NeoPixel”条目。点击安装按钮(或者如果您已经安装了较旧版本的此库,如我们在 Figure 8-12 中所示,请点击更新),就这样!IDE 将下载库并在幕后进行适当的工作以使其可用。

smac 0812

图 8-12. 寻找 NeoPixel 库

使用 NeoPixels 的物理电路类似于我们用于简单 LED 的电路,但它们有三根导线而不是两根。您有标准的V+和Ground连接器用于基本电源需求,第三根线提供给像素或条的“数据”。数据在特定方向流动,因此,如果您决定尝试此电路,请注意连接数据输入的位置。与我们的简单 LED 一样,数据信号到达 NeoPixel 之前我们也需要一个小电阻器(在我们的情况下是 470Ω)。您可以在 Figure 8-13 中看到完整的设置。

smac 0813

图 8-13. 简单的 NeoPixel 设置
注意

你可以顺便学习一下这个项目,而不用 NeoPixels。你可以使用任何其他可寻址的 LED。如果你有自己的 WS281x 或 APA102 或其他灯光,它们很可能可以与优秀的FastLED库一起使用。你需要做一些更多的独立阅读,但所有的概念都是相同的。FastLED 在 GitHub 上有很好的文档。例如,我们将在下一节中使用 NeoPixels 进行的工作在 FastLED 的Basic Usage页面中有详细介绍。

使用 Arduino 库

那么我们如何将 Arduino 库引入我们的 sketch 中呢?我们使用熟悉的#include预处理命令,就像我们在前几章中使用 C 标准库的各种头文件一样。对于 NeoPixels,我们的包含看起来像这样:

#include <Adafruit_NeoPixel.h>

IDE 甚至会通过加粗和着色头文件的名称来确认你已安装好库。看一下图 8-14 中的比较,我们在拼写 NeoPixel 时使用了小写的“p”。漂亮的、粗体的颜色消失了。所以,如果你的库安装正确并且名称在包含行中突出显示,你就准备好了!

smac 0814

图 8-14. 注意库名称的错误
注意

你可以在任何一个 sketch 中使用多个库。像 LEDs 的库以及伺服马达或 LCD 屏幕的库都是完全合理的。唯一的真正限制是内存——在使用微控制器时是一个无处不在的关注点。

当你验证一个 sketch 时,检查 IDE 下部分的消息。你将得到有关已使用内存以及剩余内存的报告(假设你选择了正确的开发板)。幸运的是,大多数为 Arduino 编写库的人和公司都非常清楚内存有多有限。例如,添加这个 NeoPixel 库使我们的闪烁 sketch 从接近 1K(964 字节)增加到大约 2.5K(2636 字节)。虽然可以说是需要存储的 Flash 量增加了三倍,但为了在少于 2K 的情况下获得库的所有好处似乎是一个公平的交换!

Arduino Sketches 和 C++

要使用这个 NeoPixel 库,我们需要稍微涉足一下 C++,这是 C 的后继版本,具有面向对象的特性(与 C 的过程化特性相比)。Sketches 实际上是 C++项目。幸运的是,由于 C++起源于 C,C 代码也是合法的 C++代码。作为程序员,如果不想学习太多 C++也是可以的。

但请注意上文中的“much”修饰语。许多库——包括我们的 NeoPixel 库——都是用 C++类写成的(class 是面向对象语言中的组织单元)。这些库经常利用 C++的一些巧妙特性。特别是,你会发现构造函数和方法在各处被广泛应用。构造函数是初始化一个对象的函数。而对象本身则封装了数据和用于访问和操作该数据的函数。那些为对象定义的函数被称为对象的方法。

要查看构造函数和方法在 Arduino 库中的显示位置,请继续完成我们闪烁灯光的下一个迭代。回顾图 8-13 中显示的设置。我们可以编写一个名为 blink3 的新程序,通过其主要颜色:红色、绿色和蓝色,循环 NeoPixel。以下是完整代码,包括(无意打趣!)适当的#include行,ch08/blink3/blink3.ino

#include <Adafruit_NeoPixel.h>

#define PIXEL_PIN   4
#define PIXEL_COUNT 1

// Declare our NeoPixel strip object per documentation from Adafruit // https://learn.adafruit.com/adafruit-neopixel-uberguide/arduino-library-use 
Adafruit_NeoPixel strip(PIXEL_COUNT, PIXEL_PIN);            ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)

void setup() {
  strip.begin();            // Get things ready ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  strip.setBrightness(128); // Set a comfortable brightness ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  strip.show();             // Start with all pixels off ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
}

void loop() {
  // Show red for 1 second on the first pixel (start counting at 0)
  strip.setPixelColor(0, 255, 0, 0); ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
  strip.show();                      ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
  delay(1000);
  // Show green for 1 second
  strip.setPixelColor(0, 0, 255, 0);
  strip.show();
  delay(1000);
  // Show blue for 1 second
  strip.setPixelColor(0, 0, 0, 255);
  strip.show();
  delay(1000);
}

1

我们创建的变量是strip。它的类(粗略地类似于类型)是Adafruit_NeoPixel。这里的名称strip很常见,但对于我们的单个 Flora 来说有点不太合适。但从技术上讲,我们正在分配一个长度仅为一个像素的条。

2

一个方法的示例:begin()是一个应用于strip的函数。begin()方法通过填充默认值和执行其他杂项启动任务,使我们的灯带准备就绪。

3

setBrightness() 方法控制strip上的预乘最大亮度。

4

另一个方法的示例。show()会使存储在内存中的当前颜色显示在strip的实际 LED 上。

5

setPixelColor() 方法接受四个参数:要设置的strip上的哪个像素(从 0 开始),以及要应用的红色、绿色和蓝色值。颜色值从 0(关闭)到 255(全亮度),尽管最终值会受到我们在setup()中调用的setBrightness()的影响。

6

要查看我们在strip上的新像素颜色,我们重复调用show()

尝试使用连接了 NeoPixel 的设备上传这个程序。希望你能看到它以红色、绿色和蓝色依次运行,就像图 8-15 中展示的那样。

smac 0815

图 8-15. 我们闪烁的 NeoPixel

很棒!随意尝试改变颜色或闪烁模式。调整到恰到好处的色调可以令人惊喜。这确实是魔法。现在,你也知道了正确的咒语!

C++对象和变量

当您创建一个对象变量时,声明和初始化看起来有点奇怪。在 C 中,我们可以创建一个变量并为其赋予一些初始值,如下所示:

int counter = 1;

如果我们有一个名为Integer的 C++类,尝试相同类型的设置可能会像这样:

Integer counter(1);

括号给了你一个线索,说明正在调用一个函数。那就是构造函数。如果你决定继续学习 C++,你会学到构造函数中可以完成的所有聪明技巧。不过现在,我们只是希望你了解语法,以便能够轻松创建引用对象的变量。

我们称之为strip.begin()strip.setPixelColor()的行是调用对象函数的示例(再次说明,面向对象的语言使用术语“方法”)。这个想法是strip是我们要操作的对象,而begin()setPixelColor()代表要完成的工作。

关于这种语法的一种思考方式是,它是一种转换的方式。在纯 C 中,我们可以想象为begin()setPixelColor()编写普通函数。但是我们必须告诉这些函数我们想要设置或更改哪个 NeoPixels 带。所以我们需要额外的参数来传递对正确带的引用,如下所示:

void setup() {
  begin(&strip);
  // ...
}

void loop() {
  // ...
  setPixelColor(&strip, 0, 255, 0, 0);
}

但是在本书的工作中,你大多数时候只需要熟悉从库中创建新对象的语句,然后记住使用对象的方法遵循object.method()的模式。

更多关于对象的实践

在我们继续为微控制器编码的其他方面之前,让我们再做一个眨眼应用程序,着重加强对象语法。我们将尝试使用一些实际的 LED 带,这些带上不止一个 LED。特别是,我们将使用一个包含 8 个 NeoPixels 的小和一个包含 24 个 NeoPixels 的。为了让事情更有趣,我们将同时使用它们!

为了保持代码简洁,我们将制作一个眨眼程序,每次在每条带上显示一个像素。这样做还能降低功率需求,以便我们可以继续使用从 USB 连接获取的电力。(如果您熟悉更大的 LED 设置,并且已经知道如何添加外部电源,请随意创建您自己的安排。)图 8-16 展示了我们的新设置。我们移除了蓝色 LED 和之前的“条”孤独 NeoPixel Flora。

smac 0816

图 8-16. 一个更有趣的 NeoPixel 设置

我们将重复使用相同的输出引脚作为数据线。在这个布置中,条使用引脚 2,环使用引脚 4。

不多说了,这里是我们两条带眨眼盛会blink4的代码。我们将在片段后深入讨论这些步骤,确保我们在这里采取的步骤是有意义的。在阅读这些调用之前,尝试去ch08/blink4/blink4.ino并看看您能否猜到对象的工作原理。

#include <Adafruit_NeoPixel.h>

#define STICK_PIN   2
#define STICK_COUNT 8
#define RING_PIN    4
#define RING_COUNT 24

// Declare our NeoPixel strip object per documentation from Adafruit // https://learn.adafruit.com/adafruit-neopixel-uberguide/arduino-library-use 
Adafruit_NeoPixel stick(STICK_COUNT, STICK_PIN);        ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
Adafruit_NeoPixel ring(RING_COUNT, RING_PIN, NEO_GRBW); ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)

void setup() {
  stick.begin();            // Initialize our stick ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  stick.setBrightness(128);
  stick.show();
  ring.begin();             // Initialize our ring ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
  ring.setBrightness(128);
  ring.show();
}

void loop() {
  // our stick and ring have different LED counts, so we have
  // to be a little clever with our loop. There are several
  // ways to do this. We'll use modulus (remainder) math, but
  // can you think of other solutions that would achieve
  // the same pattern?
  for (int p = 0; p < RING_COUNT; p++) {
    stick.clear();
    stick.setPixelColor(p % STICK_COUNT, 0, 0, 255);    ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
    ring.clear();
    ring.setPixelColor(p, 0, 255, 0, 0);                ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
    stick.show();                                       ![7](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/7.png)
    ring.show();
  }
}

1

在这里,我们创建了一个名为stick的 Adafruit_NeoPixel 对象,类似于我们在“Arduino Sketches and C++”中创建strip的方式。

2

现在我们创建一个名为ring的第二个独立对象。(该环采用更复杂的 LED 配置,带有白色元件,因此我们在构造函数中添加了第三个参数。你可以在 NeoPixel 文档中找到这个值。)

3

我们像之前使用strip一样初始化了我们的stick

4

我们还初始化了我们的ring;请注意,我们在两个对象上都使用了begin()方法。

5

现在我们设置了我们stick上一个像素的颜色。

6

我们使用了类似的方法,并通过第五个参数设置了我们的ring的颜色。(这里的参数是像素位置、红色、绿色、蓝色和白色。环将闪烁为绿色。)

7

最后但同样重要,展示两个变化。

希望看到并行使用两个对象有助于说明面向对象的语法是如何工作的。再次强调,我们的for循环纯粹是 C。我们根据需要使用 C++语法。

C++注意事项

在 Arduino 开发中,面向对象编程在对象数量众多的物理对象环境中非常自然。C++还提供了几个功能,非常适合用来打包代码与他人分享。如果你主要通过 Arduino IDE 与你的微控制器交互,花一些时间研究 C++是值得的。

深入学习 C++将教会你关于类、成员和方法。你将创建构造函数和析构函数。随着你对对象的理解加深,你可能会开始按照对象的方式而不是功能的方式来分解你的项目。你肯定会发现 C++中有一些你喜欢的东西,也可能会有一些你不喜欢的东西。如果你理解了 C++,使用一些库会更容易,但即使你从未打开过正式的 C++书籍,也不会遇到完全无法解决的问题。

在 Arduino 编程中,C 语言本身仍然是一个强大的核心,我将继续把剩余的章节重点放在使用函数和其他基本的 C 特性来编写我们的项目上。在使用任何第三方库来处理特定外设时,我会尽量减少对象表示法的使用。我也会尝试突出显示 C++语法显著的地方。

为了重申在接下来的章节中你将看到的最常见的面向对象模式,这里是我们在“Arduino Sketches and C++”中 NeoPixel 示例的总结:

// Using a library written in C++ still requires the same C "#include"
// directive to bring in the associated header file.
#include <Adafruit_NeoPixel.h>

#define PIXEL_PIN   4
#define PIXEL_COUNT 1

// Common example of a C++ constructor call that creates an object.
// Our NeoPixel "strip" is the created object in this case.
Adafruit_NeoPixel strip(PIXEL_COUNT, PIXEL_PIN);

void setup() {
  // Common example of using the method "begin()" from our object "strip".
  strip.begin();

// ...

希望您对这些小小的 C++ 尝试更加自如。我也希望这种舒适感能引发您对 C++ 更多的好奇心!但如果您真的不感兴趣,或者甚至不感到舒适,也不用担心。我编写微控制器代码最喜欢的一点是,少量的代码也能大有作为。即使我的项目简要概述显示,大多数时间我都使用 C,尽管这些项目中每一个都使用了用 C++ 编写的库。

对象作业

如果您想要更多练习我们已经见过并且在接下来的章节中偶尔会遇到的对象表示法,请尝试创建以下一些想法:

  • 交替闪烁棒子上的每一个像素,使偶数像素亮起,然后是奇数像素,来回交替。

  • 对于每个棒子上的像素,像计数器一样在环上闪烁一次。 (即,保持棒子上的一个像素显示,然后绕环行进。然后移到棒子上的下一个像素,再次绕环行进。重复!)

  • 仅使用棒子,尝试从左到右“填充”它。然后清除所有像素,再次填充它。

  • 查看 NeoPixels(或 FastLED,或者您使用的任何库)的文档,看看是否有任何方法可以一次调用将整个条带变成一种颜色。 使用这种方法将整个棒子变成红色,然后绿色,然后蓝色,类似于我们使用单个 Flora 的 blink3 程序。

接下来的步骤

我们现在已经完成了 Arduino 项目的基础工作。我们使用了 Arduino IDE,并且看到了 C++ 在我们的代码中的应用。把这些结合起来,我们成功地点亮了一个 LED!虽然这很令人兴奋,但还有更多有趣的东西等着我们。

在下一章中,我们将探索许多可用于与微控制器配合使用的输入和输出。 我们当然无法涵盖每个传感器、按钮、扬声器或显示器,但我们可以(而且将会!)看几个这些外围设备的好例子。 我们将集中精力使这些不同的小工具能够一起工作,这样您就可以在未来处理自己的项目时有一个可靠的基础。

¹ 欲了解更多关于 Arduino 的细节信息,请参阅 J. M. Hughes(O’Reilly)的 Arduino: A Technical Reference

第九章:较小系统

现在我们已经准备好使用 Arduino IDE,我们可以进入写 C 代码来控制各种设备的令人满足的世界了!LED 设备。传感器设备。按钮设备。太多设备了!我们还将涉足物联网(IoT),详见“物联网和 Arduino”。

在本章中,我将讨论几个 Arduino 的特殊之处(大多数是有帮助的,但有些让人沮丧),并构建一些小而完整的项目,你可以自己尝试。“获取硬件:Adafruit”包含了我使用的各种组件和微控制器的链接,以便你精确复制任何项目。

Arduino 环境

我相信你注意到了,在第八章中,我们没有编写“完整”的 C 程序。我们没有main()函数,而在早期的示例中,我们甚至没有导入通常的头文件。然而,我们显然可以访问新的函数和像HIGHLOW这样的值,用来闪烁我们的第一个 LED。

那些额外的东西是从哪里来的?有时候感觉就像是 IDE 提供了一些魔法。当然,实际上并非如此,但在幕后它确实做了大量工作,希望能让你更高效。我想指出其中一些隐藏的工作,以便你更好地理解 C 语言本身与 Arduino IDE 提供的支持元素之间的区别。随着你构建更多自己的项目,你必然会上网搜索新主题的示例。了解语言和工具之间的区别可以使这些搜索更加富有成效。

Arduino IDE 悄悄地为你包含了几个头文件,这些文件可以说是构成“Arduino 语言”的一部分。它并不像 Python 那样是一种独立的语言,但它确实感觉上不仅仅是 C 语言加上头文件和库文件。Arduino 语言更像是一组有用的部件(数值和函数),使得微控制器的编程更加容易。我将向你展示一些最有用的部分,但你可以在网上找到完整的列表。Arduino 网站上的语言参考包含了一个简单的索引和详细示例的链接。

特殊数值

我们依赖了一些这些“语言”扩展,只是为了让我们的第一个 LED 闪烁。让我们回顾一下那段代码,但更多地讨论命名值(Arduino 语言参考称这些为常量),这些值是特定于 Arduino 环境的。¹

void setup() {
  // put your setup code here, to run once:
  // Tell our board we want to write to the built-in LED
  pinMode(LED_BUILTIN, OUTPUT);          ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png) ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
}

void loop() {
  // put your main code here, to run repeatedly:
  // A high value is 'on' for an LED
  digitalWrite(LED_BUILTIN, HIGH);       ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  // Now wait for 500 milliseconds
  delay(500);
  // And write a low value to turn our LED off
  digitalWrite(LED_BUILTIN, LOW);        ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
  // and wait another 500ms
  delay(500);
}

1

LED_BUILTIN 常量代表连接在大多数开发板上的 LED 的引脚号。对于每个控制器来说,这个数字不一定相同,但 IDE 根据你选择的开发板自动获取正确的值。

2

OUTPUT是我们用来指示我们将向诸如 LED 或电机之类的设备发送信息的值。当我们处理传感器和按钮时,我们将看到类似的INPUTINPUT_PULLUP常量。

3

HIGH是增加电压的参考,用于“打开”连接到引脚的设备。什么是“打开”取决于该设备。对于 LED 来说,这相当容易理解。😃

4

LOWHIGH的降低电压对应物,关闭 LED。

这些命名值不是变量。它们在技术上是预处理器宏。预处理器是您的代码在编译之前经历的一步²。您可以使用define指令创建这些实体。(前缀可能看起来与#include相似,并且应该是。这两个“命令”都由预处理器处理。)我们将在“预处理器指令”中更深入地讨论此指令,但其语法很简单:

#define LED_BUILTIN 13
#define HIGH 1
#define LOW  0

C 预处理器简单地捕捉您代码中宏名称的每个实例,并用定义的值替换该名称。例如,如果我们有一个新的控制器,它的引脚更少,我们可以将我们的#define更改为8。然后,我们无需更改任何其他部分的程序,其中我们打开或关闭板载 LED。

并且要明确,#define C 的一部分(通过预处理器)。无论您是为微控制器还是桌面编写代码,您都可以在自己的代码中使用它。像OUTPUT这样的特定常量是 Arduino 设置的一部分。表 9-1 显示了我们项目中将使用的一些常量。

表 9-1. 为 Arduino 定义的有用常量

名称 描述
LED_BUILTIN 如果选定的板上有内置 LED,则表示该 LED 的引脚号码
INPUT 用于既可以执行输入又可以执行输出的引脚,预期输入
INPUT_PULLUP 与 INPUT 类似,但使用内部上拉电阻报告HIGH,例如未按下的按钮,并且在按下时为LOW
OUTPUT 用于既可以执行输入又可以执行输出的引脚,预期输出
HIGH 1 的友好名称,用于数字读写
LOW 0 的友好名称,用于数字读写

您可以在官方Arduino 参考页面上获取有关这些常量的更多详细信息。

特殊类型

除了这些常量之外,加载到您的 Arduino 草图中的标题还包括一些其他数据类型,我想强调一下,因为您可能会发现它们很有用。这些不是真正的新类型,甚至不限于在 Arduino 中使用,但再次,您的草图可以访问这些类型,并且您可能会在在线示例中看到它们的使用。

表格 9-2 列出了几种类型及其大小和简要描述。

表格 9-2. Arduino 中定义的有用常量

类型 描述
bool 布尔类型;bool 变量可以赋值为 truefalse
byte 无符号 8 位整数类型
size_t 对应于所选板上对象的最大大小(以字节为单位)的整数类型。例如,从 sizeof 得到的值就是 size_t 类型。
String 以面向对象的方式处理字符串(注意类型中的大写“S”),提供几个便利函数
int8_t, int16_t, int32_t 具有显式大小的有符号整数类型(分别为 8、16 和 32 位)
uint8_t, uint16_t, uint32_t 具有显式大小的无符号整数类型(分别为 8、16 和 32 位)

除了 String 外,这些类型实际上都是其他类型的 别名。这是通过 C 的 typedef 实现的,非常简单。例如,byte 类型是 unsigned char 的别名,可以这样定义:

typedef unsigned char byte;

我们将在 “预处理器宏” 中更详细地介绍 typedef,但其中几种类型确实非常方便。在我自己的许多项目中,特别是使用 byte 更合理(并且键入更少)比 unsigned char,但这只是个人喜好。这两种类型都定义了一个能够存储从 0 到 255 的 8 位槽。

“内建”函数

Arduino 环境包括几个头文件,使一些流行的功能可供使用。您可以在不需要在草稿中显式 #include 的情况下使用 表格 9-3 中显示的函数。

表格 9-3. Arduino 中可用的函数

函数 描述
输入/输出
void pinMode(pin, mode) 将指定引脚设置为输入或输出模式
int digitalRead(pin) 返回值为 HIGH 或 LOW
void digitalWrite(pin, value) 值应为 HIGH 或 LOW
int analogRead(pin) 返回 0–1023(某些板提供 0–4095)
void analogWrite(pin, value) 值为 0–255,必须使用支持 PWM 的引脚
时间
void delay(ms) 暂停指定毫秒数的执行
void delayMicroseconds(micros) 暂停指定微秒数的执行
unsigned long micros() 返回程序启动以来的微秒数
unsigned long millis() 返回程序启动以来的毫秒数
数学(未列出的返回类型取决于参数类型)
abs(x) 返回 x 的绝对值(整数或浮点数)
constrain(x, min, max) 返回 x,但限制在 min 和 max 范围内
map(x, fromLow, fromHigh, toLow, toHigh) 将 x 从“from”范围转换到“to”范围
max(x, y) 返回 x 和 y 中较大的值
min(x, y) 返回 x 和 y 中较小的值
double pow(base, exp) 返回 base 的 exp 次幂
double sq(x) 返回 x 的平方
double sqrt(x) 返回 x 的平方根
double cos(rad) 返回给定弧度的余弦值
double sin(rad) 返回给定弧度的正弦值
double tan(rad) 返回给定弧度的正切值
随机数
void randomSeed(seed) 初始化生成器;seed 是一个无符号长整型数
long random(max) 返回 0 到 max - 1 之间的随机长整型数
long random(min, max) 返回 min 到 max - 1 之间的随机长整型数

ctype.h 中的许多字符测试函数,如 isdigit()isupper(),也可以自动使用。³ 请参阅 Table 7-4 获取完整列表。

尝试 Arduino 的 “Stuff”

让我们把所有这些新想法融入一个项目中,看看它们是如何工作的(以及它们如何一起工作)。为此,我们将创建一个更有趣的 LED 草图。我们将使用 analogWrite() 函数和一点数学让 LED “呼吸”起来。

注意

所讨论的 LED 实际上不是模拟设备。它仍然只有开和关的状态。但许多输出设备如 LED 可以通过一种称为 脉宽调制(PWM)的技术来模拟 “亮度”。这个想法是您可以以一种使 LED 看起来更暗的方式快速地开关 LED。(或者与像电机这样的设备一起工作,它可能看起来转得更慢。)

需要注意,并非所有控制器上的所有引脚都支持 PWM 输出。您需要查阅您控制器的数据表或引脚定义图。⁴ 例如,在我迄今为止使用的 Metro Mini 上,只有引脚 3、5、6、9、10 和 11 支持 PWM 输出。

这次我们将使用不同的 RGB LED。它有四个引脚:一个地线,分别为红色、绿色和蓝色通道的一个引脚。每种颜色都需要连接到控制器,因此我们将为这些引脚定义一些常量。我们还将为呼吸率和最大弧度数(2 * π)定义一些值。Figure 9-1 显示了我在此示例中使用的接线图。请注意,使用 analogWrite() 需要注意连接到哪些引脚。(对于这个项目,我不会为其添加图片;快乐的在运行中自行查看亮度变化吧!)

smac 0901

Figure 9-1. 我们呼吸 LED 示例的接线图

现在我们可以开始编码了!像往常一样,我鼓励您开始一个新的草图,并自己输入代码,但您也可以打开 breathe.ino 并跟着做。

对于我们的 setup(),我们将设置颜色引脚模式为 OUTPUT,并为 LED 选择一个随机颜色。在开始动画之前,我们将在 LED 上显示该颜色几秒钟。

我们的loop()函数将驱动动画。我们可以使用millis()函数获取一个不断增加的数值。我们将使用我们的呼吸速率和最大弧度值将这些毫秒转换为弧度。有了弧度,我们将使用sin()函数获得一个漂亮的分数亮度,这种亮度会增加和衰减。最后,我们将这种亮度应用于 LED,并在动画下一步之前暂停几毫秒。这是ch09/breathe/breathe.ino的完整清单:

// Output pins, have to make sure they support PWM
#define RED    5
#define GREEN  6
#define BLUE   9

// Some helper values
#define RATE 5000
#define PI_2 6.283185

// Color channel values for our LED
byte red;
byte green;
byte blue;

void setup() {
  // Set our output pins
  pinMode(RED, OUTPUT);
  pinMode(GREEN, OUTPUT);
  pinMode(BLUE, OUTPUT);

  // Start the LED "off"
  digitalWrite(RED, 0);
  digitalWrite(GREEN, 0);
  digitalWrite(BLUE, 0);

  // Get our PRNG ready, then pick our random colors
  randomSeed(analogRead(0));

  // And pick our random color, but make sure it's relatively bright
  red = random(128,255);
  green = random(128,255);
  blue = random(128,255);

  // Finally show the LED for a few seconds before starting the animation
  analogWrite(RED, red);
  analogWrite(GREEN, green);
  analogWrite(BLUE, blue);
  delay(RATE);
}

void loop() {
  double ms_in_radians = (millis() % RATE) * PI_2 / RATE;
  double breath = (sin(ms_in_radians) + 1.0) / 2.0;
  analogWrite(RED, red * breath);
  analogWrite(GREEN, green * breath);
  analogWrite(BLUE, blue * breath);
  delay(10);
}
注意

如果您没有 RGB LED,不用担心!您可以使用普通的 LED,只需写入 LED 的一个引脚,而不是写入三个单独的颜色引脚。您也不需要选择一个随机值;只需使用 255(全亮度)。即使您有多彩 LED,也可以尝试将示例改为单色 LED 作为练习。

您可以看到,尽管我们使用了几个不属于 C 本身的函数,但我们不需要手动#include任何东西。这全靠 Arduino IDE 的魔力。它确实简化了这些小板的开发。

微控制器 I/O

还有哪些其他额外功能我们的 IDE 提供的?很多!让我们从 LED 扩展到尝试一些输入和其他类型的输出。

传感器和模拟输入

从迄今为止构建的简单草图中轻松迈出的一步是添加传感器。传感器有各种类型:光、声音、温度、空气质量、湿度等等。它们通常价格不贵(尽管更复杂的传感器可能带有更昂贵的价格标签)。例如,TMP36 模拟温度传感器在 Adafruit 只需$1.50。让我们将该传感器放入一个简单的电路中,就像图 9-2 中所示的那样,看看接线是如何工作的。

smac 0902

图 9-2. 我们温度示例的接线图

相当简单!这是一个相当常见的配置。传感器需要电源。它们可以有一个单独的电源引脚,例如我们的 TMP36,或者许多传感器可以直接从您连接的数据引脚中绘制足够的电流(例如光敏电阻)。我们使用analogRead()函数来获取传感器的当前值。不同的开发板和传感器支持不同的范围,但是 10 位(0-1023)范围是常见的。当然,这些值的确切含义取决于传感器。例如,我们的 TMP36 范围从-50°C(读数为 0)到 125°C(读数为 1023)。

串行监视器

虽然您可能不会长时间将您的 Arduino 项目连接到主计算机,但是在连接时,我们可以利用大多数微控制器非常方便的一个特性:串行端口。Arduino IDE 有一个可以启动的串行端口监视器,如图 9-3 所示。在开发过程中,这是一个非常好的调试工具,通常用来查看事物的运行情况。

smac 0903

图 9-3. 访问 Arduino IDE 串行监视器

端口(通过工具菜单选择,也显示在图 9-3 中)和速度设置(在监视器窗口底部选择)会根据多个因素而变化,包括你的操作系统、其他可能连接的设备以及你使用的具体 Arduino 板。例如,我的 Metro Mini 在 Linux 桌面上以 115200 波特率(串行通信速率的经典单位;还记得调制解调器吗?)在端口/dev/ttyUSB0(“设备”连接的文件系统路径)上进行通信,但一个漂亮的Trinket M0微控制器使用端口/dev/ttyACM0。同样的 Trinket 在我有的旧 Windows 系统上仍然使用 COM 端口。

这里是不是很热?

让我们把这两个新主题用于一个项目中。我们将使用图 9-2 中显示的电路。你可以开始一个新的草图或者打开ch09/temp_serial/temp_serial.ino并跟着做。代码相当简单。我们设置一个输入引脚。然后我们在一个循环中读取该引脚的数据并在串行监视器中打印结果。让我们看看代码:

// TMP36 is a 10-bit (0 - 1023) analog sensor
// 10mV / C with 500mV offset for temps below 0
#define TMP36_PIN 0

void setup() {
  Serial.begin(115200);
}

void loop() {
  int raw = analogRead(TMP36_PIN);
  float asVolts = raw * 5.0 / 1024;  // Connected to 5V
  float asC = (asVolts - 0.5) * 100;
  Serial.print(asC);
  Serial.println(" degrees C");
  float asF = (asC * 1.8) + 32;
  Serial.print(asF);
  Serial.println(" degrees F");
  delay(5000);
}

看起来相当不错!读数的跳动性质并不罕见。如果我们需要更稳定的读数,比如为了防止误报警响,我们可以采用一些电子选项,比如添加电阻和电容。我们还可以多次读取传感器的数据并取平均值。或者我们可以更进一步,使用统计学方法排除任何真正的异常值,然后取平均值。但我们大多数时候只是想证明传感器能工作,并且我们可以在串行监视器中看到读数。如果你想确保传感器工作正常,试着轻轻用手指触摸它——你的手指应该比室温更暖,你应该看到读数趋势上升。

提示

为了一点乐趣,试着从工具菜单中打开“串行绘图仪”(就在串行监视器下面)。它可以跟踪通过Serial.println()打印的简单值作为图表。它甚至可以将多个值作为单独的线路跟踪;只需在同一行中的值之间打印一个空格。

但正如我所说的,你可能不会一直将你的 Arduino 插入 USB 端口。让我们探讨一个更好的输出选项。

分段显示

LCD 和分段 LED 显示器有丰富的尺寸和价格选择。您可以获得邮票大小的高分辨率 LCD,类似于手机的触摸屏,或用于文本或数字输出的分段 LED 显示器。我买了一个简单的 4 位数 LED 显示器(Velleman VMA425,显示在图 9-4 中),带有内置的驱动芯片(因此您无需为每个单独的段连接引脚),在当地的 Micro Center 不到 7 美元。我们可以使用这样的显示器显示我们的 TMP36 读数(经过适当转换为华氏或摄氏度),而不必使用串行监视器。

smac 0904

图 9-4。一个 4 位数的 7 段显示器组件示例

不幸的是,这些外设通常需要一些帮助才能运行。幸运的是,这种帮助几乎总是以库的形式随时可得。我们将在第十一章中更详细地讨论库,但现在我们可以稍作停顿,获取我们 4 位数 LED 显示器所需的内容。

我提到的驱动芯片是我的特定显示器上配备的 TM1637。找到这个名字并非偶然——它标注在包装上,更明显地标在芯片本身上。使用 Arduino IDE 库管理器,我输入“TM1637”作为搜索词。⁵ 返回了几个结果,我选择了一个看起来简单而稳定的库(由 Avishay Orpaz 编写)。点击安装按钮后,我只需包含库的唯一头文件,就可以立即开始显示数字了!⁶

#include <TM1637Display.h>

没有比这更容易的了。您经常会按照这个过程添加新的外设,包括传感器和其他输出。您也可能会认为现有的东西都不太合适,然后自己编写代码。同样,我们将在第十一章中探讨创建自己的库的机制。

安装完库后,我不能立即开始工作。我确实需要连接显示器。图 9-5 展示了所需的连接。我还需要阅读库的文档,我是通过点击库管理器列表中的“更多信息”链接找到的。

smac 0905

图 9-5。LED 显示屏上的温度接线

暂时忽略 TMP36 传感器,ch09/display_test/display_test.ino是对 4 位数显示器的简单测试。我们将显示“1234”,以证明我们的连接正常工作,并且我们理解了文档中的库函数。

// Our 4-digit display uses a TM1637 chip and I2C #include <TM1637Display.h> ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)

// Name our pins #define CLK       2 ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
#define DIO       3

// Create our 4-segment display object TM1637Display display(CLK, DIO);            ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)

void setup() {
  // Get our display ready and set a medium brightness
  display.clear();                          ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
  display.setBrightness(0x0f);
  display.showNumberDec(1234);
}

void loop() {                               ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
}

1

我选择的库只有一个头文件,所以包含它就可以开始了。

2

显示器除了电源和接地外,还需要两个引脚,因此为了方便使用,请为其命名。

3

创建一个全局的display变量,类似我们创建 NeoPixel 对象的方式。

4

使用我们的display对象和文档中描述的函数初始化我们的显示器,并在这种情况下显示一个简单的测试数字 1234。

5

什么也不变,显示器将保持上次发送的任何数字,所以我们可以将loop()函数保留为空。

太棒了!如果一切顺利,你会看到类似图 9-4 的东西。如果你选择了不同的显示器或库,但并未看到你希望看到的东西,请看看是否可以找到其他在线使用硬件或库的示例。通常有人会发布有用的、简洁的代码示例,你可以轻松复制并自己尝试。

按钮和数字输入

但是我们还没有完成!我们可以添加另一个外围设备,以增加我们温度显示草图的功能,同时扩展我们的编程技能。让我们附加一个非常常见的输入:按键。我们将使用它来在华氏度和摄氏度之间切换显示输出。

我从 Adafruit 抓取了一个Tactile Button,它简单且适合面包板。图 9-6 显示了 TMP36 传感器、4 位数字显示器和我们新添加的按钮的最终连接。对按钮的对角连接是故意的。任何对角线都可以;如果你查看按钮的规格,还有其他安排可能,但这个选择保证了我们获得所需的功能。

smac 0906

图 9-6. 我们传感器、显示器和按钮的接线

要使用按钮,我们需要将一个引脚设置为输入,然后在该引脚上使用digitalRead()函数。特别是,此按钮将使用INPUT_PULLUP常量。这种常见的方法会导致引脚的默认状态(当按钮未按下时)返回HIGH。当按下按钮时,引脚将读取LOW。我们可以监视该LOW值并使用它来触发更改,比如我们的华氏度/摄氏度选择。

但要小心!仅仅因为我们使用了digitalRead()函数并不意味着按钮是数字的。需要时间才能完全按下物理机制。完全释放也需要一点时间。总之,人类按下按钮所需时间比 Arduino 注册变化所需时间长得多。考虑这个简单的读取和更改循环片段:

bool useC = false; // display temp in Celsius?

void loop() {
  // ...
  int toggle = digitalRead(BUTTON);
  if (toggle == LOW) {
    useC = !useC;
  }
  // ...
}

在最快的按压期间,引脚将低电平读取数十毫秒。我们的微控制器可以读取引脚并更改显示速度比我们放开按钮快得多,导致显示器在我们的 F 和 C 温度之间快速跳动。我们想要停止这种闪烁,因此在我们的代码中需要更聪明一些。我们需要去抖动按钮。去抖动的概念在很多用户界面工作中已经得到了推广——通常意味着确保在太短的时间内不报告多次按下(或点击或轻拍或其他什么)。

我将向您展示一些方法,可以用来实现去抖动行为。通常涉及保留一些额外的状态信息。对于第一种去抖动技术,我只需保持一个跟踪按钮状态第一次变化的bool标志。如果该标志为true,我们只需暂停一秒钟。(实际上,在“究竟有多热?”中,我们确实暂停了一秒钟,但您当然可以选择不同的延迟。)之后,我们可以再次读取另一个变化。

究竟有多热?

现在,让我们把所有这些新主题联系起来,为我们在图 9-6 中连接的组件创建代码。我们将在设置中初始化我们的显示器。在循环中,我们将读取温度,在串行监视器中打印一些调试语句,将温度以正确的单位显示在显示器上,然后观察按钮,看看我们是否需要更改这些单位。您可以打开ch09/temp_display/temp_display.ino或键入以下代码:

// TMP36 is a 10-bit (0 - 1023) analog sensor
// 10mV / C with 500mV offset for temps below 0
// Our 4-digit display uses a TM1637 chip and I2C
#include <TM1637Display.h>

// Name our pins
#define TMP36_PIN 0
#define CLK       2
#define DIO       3
#define BUTTON    8

// Create our 4-segment display object
TM1637Display display(CLK, DIO);

// Build the letters "F" and "C"
// Segment bits run clockwise from top (bit 1) to center (64)
uint8_t segmentF[] = { 1 | 32 | 64 | 16 };
uint8_t segmentC[] = { 1 | 32 | 16 | 8 };

// Keep track of scale
bool useC = false;

// Manage button at human time
bool debounce = false;

void setup() {
  Serial.begin(115200);
  display.clear();
  display.setBrightness(0x0f);
  pinMode(BUTTON, INPUT_PULLUP);
}

void loop() {
  int raw = analogRead(TMP36_PIN);
  float asVolts = raw * 5.0 / 1024;  // Connected to 5V
  float asC = (asVolts - 0.5) * 100;
  int wholeC = (int)(asC + 0.5);
  int wholeF = (int)((asC * 1.8) + 32 + 0.5);
  Serial.print(raw);
  Serial.print(" ");
  Serial.println(asC);
  if (useC) {
    display.showNumberDec(wholeC, false, 3, 0);
    display.setSegments(segmentC, 1, 3);
  } else {
    display.showNumberDec(wholeF, false, 3, 0);
    display.setSegments(segmentF, 1, 3);
  }
  if (debounce) {
    debounce = false;
    delay(1000);
  } else {
    for (int i =0; i < 1000; i += 10) {
      int toggle = digitalRead(BUTTON);
      if (toggle == LOW) {
        useC = !useC;
        debounce = true;
        break;
      }
      delay(10);
    }
  }
}

注意我使用了 TM1637 库中的新函数:setSegments()。该函数允许您打开任何模式的 LED 段。您可以制作可爱的动画或呈现任何英文字母的粗略版本。您可以在图 9-7 中看到我的结果。

smac 0907

图 9-7。我们 LED 显示器上的温度读数

给这个更大的示例尝试一下,使用你自己的设置。该项目位于ch09文件夹中,名为temp_display。你可以调整去抖动暂停时间,或尝试将“C”模式做成小写版本。调整现有项目是建立对新概念理解的好方法!说到新概念,有两个我想在 Arduino 平台上介绍的重要内容:内存管理和中断。

Arduino 上的内存管理

在小型设备上,内存管理更加重要,因此我想重点介绍 Arduino 这样的微控制器上的内存工作原理。Arduino 有三种类型的内存。Flash 内存是存储程序的地方。SRAM 是 Arduino 有电源时程序运行的地方。最后,EEPROM 允许您在电源循环之间读写少量数据。让我们更详细地查看每种类型的内存,并看看如何在我们的代码中使用它们。

Flash(PROGMEM)

如果“闪存”一词听起来很熟悉,那可能是因为确实如此。这与闪存(或拇指)驱动器中找到的存储器类型相同。它比像 RAM 这样的存储器速度慢得多,但通常与硬盘等存储器速度相当。它也是持久性的,不需要电源来保持其信息。这使其非常适合存储我们编译的草图。

在微控制器术语中,您可能还会听到一个不太熟悉的术语:PROGMEM或“程序存储器”。它是同一段存储器,但后一个术语告诉您更多关于我们如何使用该存储器的信息。

尽管这种闪存技术与你在拇指驱动器中找到的技术相同,但在我们的程序运行时,我们没有对这段内存的写访问权限。写入操作保留在 IDE 中的“上传”步骤中。芯片被置于修改模式,然后加载新程序。上传完成后,芯片重新启动,从闪存中读取新程序,然后开始运行。不过,我们确实有读取访问权限。

大多数 Arduino 芯片的闪存存储比编译程序所需的要多。您可以利用剩余空间来减少运行程序所需的 RAM 量。由于 RAM 几乎总是更有限的,这个功能可以带来真正的好处。您可以存储数组、字符串或单个值。在程序运行时,您可以使用特殊函数来获取这些存储的值。

在闪存中存储值

要将特定值放入闪存以供代码使用,可以在声明和初始化变量时使用特殊的PROGMEM修饰符。例如,我们可以存储适用于 RGBW NeoPixel 环的 32 位颜色数组,这些信息来自“C++ Considerations”:

const PROGMEM uint32_t colors[] = {
  0xCC000000, 0x00CC0000, 0x0000CC00, 0x000000CC,
  0xCC336699, 0xCC663399, 0xCC339966, 0xCC996633
};

此时,colors数组不再是 32 位值的简单列表。它现在包含这些值在闪存中的位置。您需要一个特殊函数来访问此数组的内容。

从闪存中读取值

这些特殊函数在pgmspace.h头文件中定义。在 Arduino IDE 的最新版本中,该头文件是自动处理的“幕后”元素之一。有几个函数可用于读取 Arduino 支持的每种数据类型。Table 9-4 列出了我们项目中将使用的几个函数。

Table 9-4. 程序存储器(闪存)读取函数

名称 描述
pgm_read_byte() 读取一个字节
pgm_read_word() 读取一个字(两个字节,类似于许多微控制器上的int
pgm_read_dword() 读取一个双字(四个字节,类似于long
pgm_read_float() 读取四个字节作为floatdouble

如果我们想要从我们的colors数组中获取第一个条目以进行实际使用,我们可以使用pgm_read_dword()函数,如下所示:

uint32_t firstColor = pgm_read_dword(&colors[0]);

这显然有点复杂。然而,当你的 RAM 不足时,复杂往往是一个公平的权衡。对于八种颜色的 32 字节来说并不多,但对于 256 色调色板呢?每种颜色占用四个字节,总共是一整个千字节。像我们的 Metro Mini 这样的一些微控制器只有微小的 2K 操作内存,因此将这样的调色板移到闪存中是一个很大的优势。

从闪存中读取字符串

将信息打印到串行监视器是调试程序的好方法,甚至只是作为一种廉价的状态指示器观察正在进行的操作。然而,你打印的每一个字符串都会消耗一些宝贵的运行时内存。将这些字符串移到闪存中是回收一些空间的好方法。你只需在需要的时候从闪存中提取你需要的字符串。如果将它放入一个通用的、可重复使用的缓冲区中,那么在运行时我们只需要为该缓冲区腾出内存。

这是一种非常常见的节省内存的技术,Arduino 环境中包含一个特殊的宏来简化这个回路:F()。 (关于宏和#define更多信息请见“预处理器宏”。)F()的使用非常简单,并且能够立即节省空间。假设我们有一些像这样的调试语句:

setup() {
  Serial.begin(115200);
  Serial.println("Initializing...");
  // ...
  Serial.println("Setting pin modes...");
  // ...
  Serial.println("Ready");
}

在你的程序中可能还有其他变量等。在 Arduino IDE 中验证你的代码可能会产生类似于这样的输出:

Sketch uses 4548 bytes (14%) of program storage space. Maximum is 32256 bytes.
Global variables use 275 bytes (13%) of dynamic memory,
leaving 1773 bytes for local variables. Maximum is 2048 bytes.

很好。目前我们有足够的空间,但 1773 字节并不多!现在让我们使用F()宏将这些字符串移到闪存中:

setup() {
  Serial.begin(115200);
  Serial.println(F("Initializing..."));
  // ...
  Serial.println(F("Setting pin modes..."));
  // ...
  Serial.println(F("Ready"));
}

非常简单地整合进去,对吧?现在如果我们验证我们的程序,我们可以看到一个小但有利的变化:

Sketch uses 4608 bytes (14%) of program storage space. Maximum is 32256 bytes.
Global variables use 225 bytes (10%) of dynamic memory,
leaving 1823 bytes for local variables. Maximum is 2048 bytes.

我们的新草图在闪存中占用了更多的空间,但在运行时占用的空间少了一些。这正是我们所追求的。显然,完全删除这些调试语句可以节省两种内存中的空间,但肯定有时你会拥有像迷你 LCD 显示器这样的漂亮外设。使用F()可以让你更轻松地获得更多的可操作空间。

SRAM

我已经在“在运行时”和“操作内存”等术语中徘徊,还有其他一些术语。这些术语指的是一种叫做SRAM的内存类型。静态随机访问存储器是 Arduino 中等效于通常应用于更大系统的泛用 RAM 术语。⁷ Flash 是我们程序存储的地方,SRAM 是我们程序运行的地方。在你的程序运行时,堆栈和堆如图 6-3 所示都在 SRAM 中。你的程序的运行大小由你拥有的 SRAM 量限制。让我们来看看这个限制的一些影响。

堆栈和堆

回想一下关于全局变量和堆的讨论来自“本地变量和堆”。我提到如果你有太多变量或进行了太多嵌套函数调用,你可能会因此耗尽内存。如果你有像现代桌面系统那样的几千兆字节甚至几千兆字节内存,这是一个很大程度上的理论讨论。但 2K 呢?我们的 Metro Mini 和它微薄的 2K SRAM 怎么办?堆和栈——在运行时活跃,所以不是闪存的一部分——必须适应这个有限的空间,当我们运行我们的 Arduino 草图时。

想象一下重做来自图 6-3 的地址,以适应 2K 的情况。中间部分现在小得多了。现在很容易想象太多函数调用或太多全局变量或malloc()分配。如果你每行写出 32 字节(64 个十六进制字符),只需 64 行即可表示某些微控制器上 SRAM 的全部内容。这就是来自高中笔记本的一张双面纸!这意味着一个粗心的循环或大数组可能会超出我们的 SRAM,并导致程序崩溃。

例如,我们的递归斐波那契计算函数在几十次调用后就可以轻松填满可用内存——特别是因为我们仍然需要内存来控制 LED、传感器库等。在与微控制器一起工作时使用递归并不是被禁止的,但确实需要你在细节上多加注意。

Arduino 中的全局变量

与桌面应用程序不同,其中全局变量(分配在堆上)几乎总是可选的(如果方便的话),Arduino 环境则经常使用它们。Arduino IDE 在我们创建一个可行的可执行程序时为我们做了很多工作。例如,请记住我们不需要编写自己的main()函数。因此,如果我们需要在setup()函数中初始化一个变量,然后在loop()函数中引用该变量,我们必须使用一个全局声明的变量。

这个事实并不是非常有争议。许多在线示例,当然还有这本书,都依赖于全局变量。但考虑到我们有限的空间,确实需要更多的细节注意力。例如,我经常使用int来表示任何我知道不会存储十亿数量级数字的数值变量。像int count = 0;这样的输入几乎已经成了肌肉记忆。好吧,如果我要计数连续的按钮按下,以便我可以区分单击、双击(甚至三击),这个计数很容易适合一个byte中。记住使用最小适当的数据类型是一个很好的习惯。

实际上,如果内存真的不够用,记住我们在“位操作符”中讨论过的操作符,你可以读取和操作单个位。如果你有两个按钮并且需要追踪可能的三次点击,这些计数可以都存储在一个byte变量中。事实上,你甚至可以在这个变量中存储四个按钮的计数。这肯定有点极端,但当你需要时,每个字节都很重要。我们不再处于桌面环境了,托托。

EEPROM

如果你是从桌面计算机的领域转到 Arduino 的,你可能也注意到缺少文件系统的讨论。你可能不会对你的微控制器没有连接物理的 3.5 英寸硬盘感到惊讶,但长期读写存储的缺失可能让你措手不及。重启你的 Arduino,每个变量都会重新从头开始。许多令人满意的项目根本不需要这样的存储,但有些项目需要。幸运的是,许多控制器具有一定(有限)的电子可擦写程序只读存储器(EEPROM)存储值的能力。

并非每个微控制器都包含 EEPROM。事实上,有足够多的微控制器不包含 EEPROM,以至于 IDE 并不期望你使用这种类型的存储器。你必须手动包含EEPROM.h头文件来从这个区域存储和检索值。我们只需要这个库中的两个函数:get()put(),但你可以在EEPROM 库文档中看到其他可用的函数。

这两个函数都接受两个参数:EEPROM 中的偏移量(文档中的“地址”)和一些“数据”,可以是get()的变量或结构体,或者是put()的文字值。例如,放入和取出一个float看起来可能像这样:

#include <EEPROM.h>

float temperature;

void setup() {
  EEPROM.get(0, temperature);
  // ... other initialization stuff
}

void loop() {
  // ... things happen, temperature changes
  EEPROM.put(0, temperature);
  // things continue to happen ...
}

请注意,与我们用来接受用户输入的scanf()函数不同的是,在调用get()时,我没有在temperature变量前使用&。这个库会为你将值分配到正确的位置。通常在setup()期间从 EEPROM 读取,所以希望能小心一点并记住使用简单的变量而不是它们的地址。在上面的代码段中,EEPROM.get()会像我们期望的那样将 EEPROM 中存储的值填充到我们的temperature变量中。

使用get()put(),并记住你在 EEPROM 中存储持久值的确切字节偏移量可能看起来很繁琐,我同意这一点。然而,作为回报,你完全控制了存入什么和如何检索它。只需确保正确管理地址。如果你要存储两个float数和一个byte,按顺序,你需要确保第二个float存储在地址 4,而byte存储在地址 8。或者更好的办法是使用sizeof来确保一个运行位置变量按照恰当的数量增加。

重要的是要知道,读写 EEPROM 是“昂贵”的,因为它不是一个快速的操作。EEPROM 还有读写次数的限制。你不太可能达到这些读写限制,而且速度对于初始化我们的小型项目来说是可以接受的,但 EEPROM 绝对不是 SRAM 的简单扩展。

记住选择

所有这些内存相关的东西确实很深奥。我觉得是时候再来一个厨房水槽示例了!让我们重新接线那个漂亮的 LED 环并添加一个触觉按钮来改变其颜色。我们还会将选择的颜色存储在 EEPROM 中,以便如果我们关闭 Arduino 并稍后重新打开它,环将以我们最近的选择点亮。这个项目只使用环和按钮,如图 9-8 所示。

smac 0908

图 9-8。我们 EEPROM 演示的布线,包括 LED 环和按钮

在此过程中,我们可以使用一种新的技术来去抖动按钮,甚至借用去抖动概念来减少对 EEPROM 的写入次数。当用户改变事物时,他们通常会接受你的提议并频繁更改它们。如果他们按下按钮更改颜色,我们将等待几秒钟再将该更改提交到 EEPROM,以防他们只是快速循环查看颜色选项。

如果你愿意接受挑战,在查看这里的代码之前,请尝试自己草绘(get it?)一个解决方案。但这是一个相当艰巨的挑战。如果你宁愿只是享受更改 LED 颜色的乐趣,请随意输入此代码或编译并上传ch09/ring_eeprom/ring_eeprom.ino

#include <Adafruit_NeoPixel.h>
#include <EEPROM.h>

#define RING_PIN    3
#define RING_COUNT 24
#define BUTTON_PIN  2

int previousState = HIGH;
int pause = 250;
int countdown = -1;

const PROGMEM uint32_t colors[] = {
  0xCC000000, 0x00CC0000, 0x0000CC00, 0x000000CC,
  0xCC336699, 0xCC663399, 0xCC339966, 0xCC996633
};
const byte colorCount = 8;
byte colorIndex;

Adafruit_NeoPixel ring(RING_COUNT, RING_PIN, NEO_GRBW);

void setup() {
  Serial.begin(115200);
  pinMode(BUTTON_PIN, INPUT_PULLUP);
  retrieveIndex();
  ring.begin();             // Initialize our ring
  ring.setBrightness(128);  // Set a comfortable mid-level brightness
  ring.fill(pgm_read_dword(&colors[colorIndex]));
  ring.show();
}

void loop() {
  int toggle = digitalRead(BUTTON_PIN);
  if (toggle != previousState) {
    if (toggle == LOW) {
      // "falling" state, so do our work
      previousState = LOW;
      colorIndex++;
      if (colorIndex >= colorCount) {
        colorIndex = 0;
      }
      ring.fill(pgm_read_dword(&colors[colorIndex]));
      ring.show();
      countdown = 10;
    } else {
      // "rising", just record the new state
      previousState = HIGH;
    }
  }
  if (countdown > 0) {
    countdown--;
  } else if (countdown == 0) {
    // Time's up! Record the current color index to EEPROM
    countdown = -1; // stop counting down
    storeIndex();
  }
  delay(100);
}

void retrieveIndex() {
  Serial.print(F("RETRIEVE ... "));
  EEPROM.get(0, colorIndex);
  if (colorIndex >= colorCount) {
    Serial.println(F("ERROR, using default"));
    // Got a bad value from EEPROM, use default of 0
    colorIndex = 0;
    // And try to store this good value
    storeIndex();
  } else {
    Serial.print(colorIndex);
    Serial.println(F(" OK"));
  }
}

void storeIndex() {
  Serial.print(F("STORE ... "));
  Serial.print(colorIndex);
  EEPROM.put(0, colorIndex);
  Serial.println(F(" OK"));
}

这个程序有三个部分我特别想强调。第一个是使用previousState变量来跟踪我们按钮的状态。我不使用布尔值来知道我们是否处于去抖动周期中,而是仅在我注意到它从HIGH状态变为LOW状态时才执行按钮按下操作。工作量大致相同,但我想向你展示一个替代方法。

另外两个有趣的部分是底部的函数retrieveIndex()storeIndex()。在这里你可以看到 EEPROM 函数的使用。存储索引很简单,但我在读取索引时添加了一个安全检查,以确保它是一个有效的值。

中断

还有一个很酷的功能可以简化处理像我们的触觉按钮输入的代码。虽然不是 Arduino 特有的,但中断的使用对许多桌面或 Web 开发者来说不再陌生。中断是硬件信号,可以触发软件响应。中断可以让你知道某些网络数据已到达,或者 USB 设备已连接,或者可能有键被按下。它们得名于它们“中断”了程序的正常流程并将控制传输到其他地方的有利事实。

我说有利,因为中断可以显著简化担心异步、不可靠事件的过程。想想在键盘上打字。您的操作系统可以通过运行一个大循环并逐个检查每个键来“监听”是否有键被按下。多么乏味的任务。即使我们稍微抽象一下,让操作系统询问是否有任何键被按下,我们也需要询问每个输入设备。每个硬盘驱动器,每个闪存驱动器,鼠标,麦克风,每个 USB 端口等等。这种轮询的方式并不是我们想担心的事情。中断消除了这种担忧。当按下键时,会发送一个信号告知您的计算机去检查键盘。这是一个按需系统。

当出现这种需求时,计算机通常会调用您提供的函数,以处理相关的中断。您注册一个处理程序,操作系统管理停止任何其他正在进行的操作,并切换到该处理程序。

在 Arduino 项目中,您可以为各种输入设备(如我们的触摸按钮)使用中断。与以往的一些项目中轮询按钮不同,我们可以注册一个函数来处理按钮按下事件。我们的循环中不再提及按钮。没有轮询,没有去抖动标志或计时器,什么都没有。微控制器正在进行内部工作,监视其每个引脚,当其中一个引脚发生变化时(例如连接到我们的按钮的引脚),触发中断并跳转到我们注册的函数。

中断服务程序

中断服务程序(ISR)实际上只是一个函数。但是您确实希望遵守一些规则和准则:

  • ISR 不能有任何参数(规则)

  • ISR 不应返回任何值(准则)

  • delay()millis()这样的定时函数本身使用中断,因此您不能在 ISR 内部使用它们(规则)⁸

  • 因为您在 ISR 内部“阻塞了线路”,所以这些函数应尽可能快地运行(准则)

要在 Arduino 中注册中断服务程序(ISR),您需要使用attachInterrupt()函数。该函数接受三个参数:

  • 要监听的中断:使用函数digitalPinToInterrupt(pin)作为此参数

  • ISR:只需提供您想使用的函数名称

  • 模式之一:

    • LOW:在引脚为 LOW 时触发

    • CHANGE:在引脚值发生任何变化时触发

    • RISING:在引脚从LOWHIGH时触发

    • FALLING:在引脚从HIGHLOW时触发

    • HIGH:一些——但不是所有——板支持在引脚为HIGH时触发中断

如果不再需要处理中断,可以使用detachInterrupt()。该函数接受一个参数,即与attachInterrupt()的第一个参数相同的digitalPinToInterrupt(pin)。(这个辅助函数正确地将您的引脚号转换为必要的中断号。不建议直接提供中断号。)

中断驱动编程

让我们再深入一个项目,尝试利用中断。我们将拿出 LED 环,并依次点亮一个 LED,形成循环动画。我们将使用一个按钮来改变循环速度。我们确实可以编写这种类型的程序而不使用中断,但我认为您会喜欢这个项目比我们轮询按钮改变 LED 环颜色的示例要干净得多。实际上,我们将使用与该项目相同的硬件设置。如果需要重新创建,可以回顾图 9-8。

如往常一样,随意获取这个示例(ch09/ring_interrupt/ring_interrupt.ino),或者自己输入。本项目唯一的接线是将 NeoPixel 环的电源和地线连接到微控制器上的适当引脚以及数据线。您需要查看您的开发板文档,了解支持中断的引脚。对于我们的 Metro Mini(兼容 Arduino Uno),我们可以使用引脚 2 或引脚 3:

#include <Adafruit_NeoPixel.h>

#define RING_PIN    3
#define RING_COUNT 24
#define BUTTON_PIN  2

int pause = 1000;                                            ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)

Adafruit_NeoPixel ring(RING_COUNT, RING_PIN, NEO_GRBW);

void nextPause() {                                           ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
  if (pause == 250) {
    pause = 1000;
  } else {
    pause /= 2;
  }
}

void setup() {
  pinMode(BUTTON_PIN, INPUT_PULLUP);                         ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  attachInterrupt(digitalPinToInterrupt(BUTTON_PIN),         ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
      nextPause, FALLING);
  ring.begin();             // Initialize our ring ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
  ring.setBrightness(128);  // Set a comfortable brightness
  ring.show();              // Start with all pixels off }

void loop() {
  for (int p = 0; p < RING_COUNT; p++) {                     ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
    ring.clear();
    ring.setPixelColor(p, 0, 255, 0, 0);
    ring.show();
    delay(pause);
  }
}

1

为我们的环形动画设置初始暂停持续时间为 1 秒。

2

创建一个简洁的函数来响应按钮按下事件,通过不同的暂停持续时间进行循环。

3

将我们的按钮引脚设置为INPUT_PULLUP,就像以前一样。

4

配置nextPause()以处理按钮按下的事件。

5

像以前一样设置我们的 LED 环。

6

我们的动画循环不必包含任何按钮轮询逻辑(万岁!)。

希望这比我们包含按钮的其他项目更简单。我们的loop()函数专门用于驱动动画像素绕环运动。尽管我使用FALLING模式触发中断,但对于这个例子,我们同样可以轻松使用RISING。如果你对效果感到好奇,改变这种模式是一个很好的调整。

练习

现在我们已经看到几个 Sketch 在 Arduino 环境中运行,并利用了一些有趣的外设,这里有几个小项目可以测试您的新技能。我包含了我的设置的接线图,但您当然可以根据需要安排组件,并使用适合您微控制器的引脚。解决方案在ch09/exercises文件夹中。

  1. 自动夜灯。使用光敏电阻和 LED(参见图 9-9),创建一个根据光线减少增加 LED 亮度的夜灯。尝试使用map()函数将传感器读数转换为适当的 LED 值。(可以使用 NeoPixel 或带有 PWM 的常规 LED。)

    smac 0909

    图 9-9. 自动夜灯的接线图
  2. 秒表。使用我们的 4 位数显示器和一个按钮(参见图 9-10),创建一个秒表。第一次按下按钮时,秒表开始并跟踪经过的秒数(最多 99:99 秒)。再次按下按钮将停止计数。第三次按下将秒表重置为 0:00。

    smac 0910

    图 9-10. 简易秒表的接线图
  3. 计分板。使用四个按钮和一个 4 位数显示器(参见图 9-11),为两支队伍运行一个小型计分板。显示器的左两位数字为队伍 1 的得分,右两位数字为队伍 2 的得分。每支队伍使用两个按钮:一个增加得分,一个减少得分。从小处着手,逐步构建。先让一个按钮起作用。然后让一个队伍运作。最后让两支队伍都能运行。您可能需要查阅分段显示库的文档,以确保您可以更新一支队伍的分数而不干扰另一支队伍的分数。

    smac 0911

    图 9-11. 计分板的接线图

下一步

天哪,那真是一大堆代码。但我真诚地希望您喜欢我们在 Arduino 编程可用功能和特性之旅中的表演。我们尝试了几个新的外设,并介绍了 Arduino 程序员如何处理有限内存的方法。我们还介绍了中断的主题。您完全可以感到不知所措!但希望不要灰心。如果有任何示例不清楚,请让它们静置一两天,然后再试一次。

下一章不会像这一章那样紧张。在讨论内存时,我们看到在处理微控制器时有时候需要有些技巧。我们将通过一个简单的例子来看看如何集中精力优化一些常见的 Arduino 编程模式。这些优化在台式机上当然也是有效的,只是可能影响不会那么大。但现在,我们仍然专注于 Arduino,所以继续阅读,看看几个小改变可以产生多大的影响!

¹ 通用 C 语言称这些命名值为符号常量。我将使用未限定的“常量”来与 Arduino 文档匹配。

² 对于 GCC 而言是正确的,但某些编译器使用完全分开的可执行文件进行预处理和编译。

³ Arduino 语言为这些函数提供了一些具有稍微不同大小写的替代名称,可能更易读,例如isDigit()isUpperCase()

⁴ 在典型的引脚布局图上,可以进行 PWM 的数字引脚通常有一个~前缀或其他明显标记。

⁵ 其他流行的驱动芯片,如 MAX7219,会有类似的搜索结果。

⁶ 如果你正在使用类似的显示器,它在GitHub上有一个很好文档化的存储库。

⁷ 动态随机存取存储器,或称DRAM,是你可以购买并物理插入老旧的 Windows 7 机箱以延长一年寿命的内存类型。“动态”一词表明此 RAM 需要定期刷新一小段电源来维持—与不需要的 SRAM 相对。然而,两种类型都需要一些电源,因此被称为挥发性,因为在电源循环期间它们的内容将被重置。

micros()函数可以工作,但只能持续一两毫秒。delayMicroseconds()使用了不同的机制来暂停,所以实际上可以使用。但如果可能的话,你真的不想在 ISR 内部延迟。

第十章:快速代码

我在第一章的一开始就提到,C 语言是为了那些有限资源的机器设计的——至少按照今天的标准来看是这样。微控制器也有许多相同的限制,这使得 C 语言成为一个相当自然的开发语言选择。事实上,如果你想从一个微小的芯片中获得最大可能的性能,C 语言直接操作内存地址的能力,正如我们在“C 中的地址”中所看到的那样,是无与伦比的,尽管有些乏味。¹

我很高兴地告诉你,即使没有深入研究数据表(由组件和微控制器制造商编制的严肃技术规格书),你也可以采用几个简单的技巧来加速你的代码。但请记住,有时候“足够好”就已经足够了!首先尝试使用你知道的模式来使你的代码运行。你的程序是否运行?它是否做你需要的事情?如果是,我在本章中强调的有趣的选项,如使用整数而不是浮点数或展开循环,仅仅是有趣的。它们并不一定“更好”,当然也不是必需的。通常情况下。Arduino 及其同类产品显然比桌面电脑更有限。有时你的程序可能无法运行或没有完全按照你的需求工作。在这些情况下,考虑采用以下优化方法。

设置

我不打算向你介绍一堆新的小工具、配置和接线图,而是会专注于类似我们在第八章中进行的首个 Arduino 项目的硬件设置。我们将使用一个 NeoPixel 条。图 10-1 展示了通常的接线图和我连接(和供电)好的 Metro Mini。

smac 1001

图 10-1 我们简单的 LED 设置

接下来,我们将从“尝试 Arduino 的‘东西’”中借鉴“呼吸”逻辑,并将其应用到条中的每个像素。与其从随机颜色开始,我们将简单地分配一些漂亮的彩虹色。顺便说一句,随意调整颜色。选择一个你喜欢盯着看的调色板;我们将使用这个草图作为本章中每个优化的基础。

打开ch10/optimize/optimize.ino,并在你自己的设置上试一试。如有需要,请调整LED_PINLED_COUNT的值。

#include <Adafruit_NeoPixel.h>

#define LED_PIN     4
#define LED_COUNT   8
#define RATE     5000
#define PI_2 6.283185

Adafruit_NeoPixel stick(LED_COUNT, LED_PIN, NEO_GRB);
uint32_t colors[] = {
  0xFF0000, 0x00FF00, 0x0000FF, 0x3377FF,
  0x00FFFF, 0xFF00FF, 0xFFFF00, 0xFF7733
};

void setup() {
  Serial.begin(115200);
  stick.begin();             // Initialize our LEDs
  stick.setBrightness(128);  // Set a comfortable brightness
  // Show our colors for a few seconds before animating
  for (byte p = 0; p < LED_COUNT; p++) {
    stick.setPixelColor(p, colors[p]);
  }
  stick.show();
  delay(RATE);
}

void loop() {
  double ms_in_radians = (millis() % RATE) * PI_2 / RATE;
  double breath = (sin(ms_in_radians) + 1.0) / 2.0;
  for (byte p = 0; p < LED_COUNT; p++) {
    byte red   = (colors[p] & 0xFF0000) >> 16;
    byte green = (colors[p] & 0x00FF00) >> 8;
    byte blue  = colors[p] & 0x0000FF;
    red = (byte)(red * breath);
    green = (byte)(green * breath);
    blue = (byte)(blue * breath);
    stick.setPixelColor(p, red, green, blue);
  }
  stick.show();
  delay(10);
}

有了这个最小的示例,我们可以看看一些提升性能的流行技术。其中许多技术都位于权衡的领域。许多技术会在 flash 存储器或 SRAM 中占用一些额外的存储空间,以换取在loop()函数中所需执行工作的加速。然而,有些则是涉及到作为程序员的你的时间和精力的权衡。但再次强调,如果你的程序已经按照你的要求正常运行,后续的调整并没有什么特别优越之处。 😃

浮点数与整数运算

当今,很多计算机硬件媒体都在讨论各种厂商提供的越来越强大的 GPU(图形处理单元,专注于显示和操作图形的令人印象深刻的芯片)。但不久之前,你也可以谈论单独的 FPU,即浮点运算单元(专注于执行和加速浮点运算)。² 浮点数计算需要强大的处理能力,而电脑的普遍性能足够强大,才能集成这样的精细计算。

令人高兴的是,我们的计算机在性能上取得了长足的进步(同时体积也在缩小),Arduino 项目确实可以访问良好的浮点支持和使用浮点数学如三角函数的更高级功能。但进行这样的数学计算仍然需要比纯整数计算更多的处理能力。如果你经常浏览Arduino 论坛,你会看到关于浮点数计算所需时间是整数操作数计算两倍甚至更多的轶事。

注意

值得指出的是,在微控制器上,floatdouble并不总是像在台式机上那样的 4 字节和 8 字节类型。例如,在 Metro Mini 上(使用 16MHz 的 ATmega328P 芯片),这两种类型都是 4 字节。这个事实可能不会造成太多麻烦,但万一你需要真正高精度的浮点数,可能需要找一个库来帮助解决。

浮点数计算替代方案

许多时候,程序员在使用浮点数时并没有真正考虑到成本。十进制数和分数无处不在:汽车油表,油价,税率,小费百分比等等。在某些情况下使用它们是有意义的,特别是在输出供人类阅读的信息时。(例如,我们将 TMP36 传感器的原始电压读数从“分段显示”转换为浮点度数。)

但是,如果我们只是在内部进行一些工作,而不向用户展示这些结果,有时候我们可以用整数来得到相同的结果。考虑这两个计算:

int dozen = 12;
int six = dozen * 0.5;
int half_a_dozen = dozen / 2;

sixhalf_a_dozen都包含整数值 6,但乘以 0.5 会更耗费计算资源。这个例子显然是虚构的,但只是略有变通。让我们来看看我们的breath计算,并考虑我们到底想做什么:

  double ms_in_radians = (millis() % RATE) * PI_2 / RATE;
  double breath = (sin(ms_in_radians) + 1.0) / 2.0;
  // ...
  red = (byte)(red * breath);

我们正在将一个不断增长的计数转换为介于 0.0 和 1.0 之间的值。然后我们将这个值乘以来给我们的各种颜色一个“部分”。然而,最终结果仍然是一个byte值。我们从不使用 140.7 个单位的红色,我们只使用 140。我们真正想做的是将一个值从范围(0 到RATE)转换为范围(0 到 255)中的值,沿着波浪形曲线。

恰好这项任务在 LED 应用中非常常见——正如我们所见,它可用于漂亮的淡入淡出动画。NeoPixel 库中有一个非常漂亮的 sine8() 函数,它使用 uint8_t 值作为输入和输出,近似计算正弦函数。 sine8() 将输入范围(0 到 255)视为经典弧度范围(0 到 2π),然后将其输出值视为正弦波的经典(-1 到 1)范围内的值,介于 0 和 255 之间。

那听起来可能有点数学化,但关键是我们可以通过将(递增的)毫秒限制在(0 到 255)范围内,并使用 sine8() 函数来获取在 0 到 255 之间循环的值来实现亮度动画。然后,我们可以将 breath / 255 视为一个带有所有整数部分的分数。这使我们能够应用我们的 half_a_dozen 技巧。与其乘以 0.0 到 1.0 之间的浮点值,不如乘以 breath,然后除以 255:

  uint8_t ms = (millis() % RATE) / 20; // close enough :)
  uint8_t breath = stick.sine8(ms);
  // ...
  red = red * breath / 255;

棒极了!但要小心不要在我们的“分数” breath / 255 周围使用括号。虽然这可能读起来更好,并突出显示我们所追求的比例值,在整数运算中,将一个较小的数(介于 0 和 255 之间的值)除以一个较大的数(始终为 255)将只会得到 0,除了 255/255 的最后一种情况,这将确实得到 1。

整数运算与非数学运算

你知道什么比整数运算更好吗?根本不需要数学!有时候,一点点规划可以产生很大的差别。看看我们如何使用 colors 数组。我们在 setup() 中仅使用实际的完整 32 位值一次。然而,在 loop() 中,我们将这些颜色分解为它们各自的红色、绿色和蓝色部分。而我们每 10 毫秒都这样做。天哪!所以,与其存储单个 32 位值,为什么不从一开始就存储单独的字节?如果我们想的话,我们可以使用一个二维 byte 数组:

---
byte colors[8][3] = {
  { 0xFF, 0x00, 0x00 }, { 0x00, 0xFF, 0x00 },
  { 0x00, 0x00, 0xFF }, { 0x33, 0x77, 0xFF },
  { 0x00, 0xFF, 0xFF }, { 0xFF, 0x00, 0xFF },
  { 0xFF, 0xFF, 0x00 }, { 0xFF, 0x77, 0x33 }
};
---

我们也可以将它们存储在一个简单的单一数组中,并根据需要执行少量的数学运算来获取绿色和蓝色索引值:

---
byte colors[] = {
  0xFF, 0x00, 0x00,   0x00, 0xFF, 0x00,
  0x00, 0x00, 0xFF,   0x33, 0x77, 0xFF,
  0x00, 0xFF, 0xFF,   0xFF, 0x00, 0xFF,
  0xFF, 0xFF, 0x00,   0xFF, 0x77, 0x33
};
---

而且,这两种选项都节省了八个字节的存储空间!由于这两个选项都要求使用第二个索引或对单个索引进行一些数学处理,所以你可以选择哪种更容易。以下是我们如何在 setup() 中修改初始显示和在 loop() 中更有趣的用法,采用二维方法:

void setup() {
  // ...
  for (byte p = 0; p < LED_COUNT; p++) {
    stick.setPixelColor(p, colors[p][0], colors[p][1], colors[p][2]);
  }
  // ...
}

void loop() {
  // ...
  for (byte p = 0; p < LED_COUNT; p++) {
    byte red   = (byte)(colors[p][0] * breath);
    byte green = (byte)(colors[p][1] * breath);
    byte blue  = (byte)(colors[p][2] * breath);
    stick.setPixelColor(p, red, green, blue);
  }
  // ...
}

这绝对感觉更简单。虽然这并不总是正确的,但我喜欢 C 语言的一点是看起来很少欺骗人。C 语言确实能在小设备上发挥奇迹,但这种奇迹通常是公开的。因此,编写干净、简单的代码通常会提高可读性 性能。

查找表

尽管不及无数学计算好,但仅执行一次常见计算,然后存储答案以便重复使用,几乎可以媲美。如果我们查看loop()函数内执行的计算,基本上有两个:一个是将当前的millis()值转换为分数,然后是将该函数应用于我们的颜色通道(虽然对于每个通道是分开的)。摆脱这些计算将是极好的。

当存储空间比处理能力多时(另一种权衡),一个流行的技巧是使用查找表。您可以提前运行您需要的每个计算,并将答案存储在数组中。然后,当您需要其中一个答案时,只需从数组中取出正确的条目即可。

取决于您想存储的计算的费用如何,创建查找表有两种选择。如果不太昂贵,您可以在需要之前在运行时构建表格。(例如,在 Arduino 项目中,我们可以使用setup()函数来完成此工作。然后在loop()函数中从数组中读取。)如果计算实在太昂贵,您可以在“离线”完成所有工作,然后只需将结果转录到您的程序中,并在声明时使用大量的文字值初始化数组。

对于有限的内存,这种优化并不总是有意义。填充一个大型全局数组可能会对您的程序的其余部分造成太大的压力。但是如果计算足够昂贵,甚至从稍慢的闪存中读取也变得可行,您可以将查找表存储在那里。在后一种情况下,当然,您必须在声明时进行离线计算并在PROGMEM中初始化数组。

到目前为止的项目

让我们把我们的查找表(我们将在setup()中构建)和我们的简单数学方法投入使用。当您开始优化项目时,最好逐步尝试您的想法。这种渐进式方法使您不太可能破坏任何东西。但是,如果确实出现了问题,渐进式方法应该会使修复或者重新开始变得更容易。这里是ch10/optimize2/optimize2.ino

#include <Adafruit_NeoPixel.h>

#define LED_PIN     4
#define LED_COUNT   8
#define RATE     5000

Adafruit_NeoPixel stick(LED_COUNT, LED_PIN, NEO_GRB);
byte colors[8][3] = {                                    ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
  { 0xFF, 0x00, 0x00 }, { 0x00, 0xFF, 0x00 },
  { 0x00, 0x00, 0xFF }, { 0x33, 0x77, 0xFF },
  { 0x00, 0xFF, 0xFF }, { 0xFF, 0x00, 0xFF },
  { 0xFF, 0xFF, 0x00 }, { 0xFF, 0x77, 0x33 }
};

uint8_t breaths[256];                                    ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)

void setup() {
  Serial.begin(115200);
  stick.begin();            // Initialize our LEDs
  stick.setBrightness(80);  // Set a comfortable brightness
  // Show our colors for a few seconds before animating
  for (byte p = 0; p < LED_COUNT; p++) {
    stick.setPixelColor(p,                               ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
        colors[p][0], colors[p][1], colors[p][2]);
  }
  stick.show();
  // Now initialize our sine lookup table
  for (int s = 0; s <= 255; s++) {                       ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
    breaths[s] = stick.sine8(s);
  }
  delay(2000);
}

void loop() {
  uint8_t ms = (millis() % RATE) / 20;                   ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
  uint8_t breath = breaths[ms];                          ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
  for (byte p = 0; p < LED_COUNT; p++) {
    byte red   = colors[p][0] * breath / 255;
    byte green = colors[p][1] * breath / 255;
    byte blue  = colors[p][2] * breath / 255;
    stick.setPixelColor(p, red, green, blue);
  }
  stick.show();
  delay(10);
}

1

将我们的像素颜色分解成二维数组(以便在loop()中更容易进行计算)。

2

创建一个全局变量用于我们的查找表,这样我们可以在setup()中初始化它并在loop()中引用它。

3

使用一个替代函数设置像素颜色,该函数接受单独的红色、绿色和蓝色值。

4

使用 NeoPixel 库中方便(且快速)的sine8()函数填充我们的正弦值查找表。

5

简化我们从毫秒到查找索引的转换。

6

现在把我们的查找表值用于计算当前亮度。

您肯定可以在您的控制器上尝试一下,但希望您的 LED 灯带的行为是相同的。我们在处理更多调整之前,确保您理解我们所做的更改的意图。

警告

注意,我在breaths数组初始化时使用了一个int变量。由于我们需要到达byte可以存储的边缘(即,我们需要使用 255),我们不能使用字节大小的变量作为索引值。在增加s时,调整步骤将在s等于 255 时多执行一次,将其推到 256。但是对于byte变量,这种推动会强制变量回滚到 0。回滚后,我们检查循环条件。由于 0 小于或等于 255,我们继续进行。第一次编写初始化循环时,我犯了这个错误。花了我几分钟的时间弄清楚为什么setup()函数永远不会结束!

2 的力量

在你的技巧包中保留一个与数学相关的优化:使用位操作而不是乘法和除法。乘法不便宜。除法非常昂贵。在微控制器上,这些操作可能非常昂贵,除非你乘以或除以 2 或 2 的幂次方(即 4、8、1024 等)。余数运算(%)实际上也是一种除法运算,因此也很昂贵,除非可以使用 2 的幂次方。

我们已经从我们的呼吸循环中删除了许多昂贵的步骤,但我们仍然有一些余数和除法操作。让我们看看 2 的幂次方如何在这些计算中起作用。

当我们将颜色乘以我们的breath变量并除以 255 来获取正确的颜色深度时,我们试图确保该颜色深度在 0 到 255 的范围内。我们创建的这个分数的分母与 2 的幂非常接近,即 255 几乎等于 256。例如,考虑我们最亮的红色。理想情况下,我们希望在 LED 上显示值 255。我们目前使用的计算如下所示:

  red = red * breath / 255;
  //  = 255 *   255  / 255;
  //  = 65025 / 255;
  //  = 255

这个计算结果是 255,这正是我们想要的。但我说过,通过 256 进行除法会加快速度。我很快会向您展示,但首先让我们探讨一个重要的问题:通过 256 而不是 255 进行除法是否可以接受?好吧,65025 / 256 约等于 254.004。对于 LED 来说,这绝对足够接近。那么,通过 256 进行除法比通过 255 进行除法更快的原因是什么呢?

结果表明,对于具有二进制智能的计算机来说,除以 2 的幂次方等同于使用右移运算符>>。与除法相比,这速度非常快。因此,我们的red近似可以这样计算:

  red = (red * breath) >> 8;

你只需按照你的 2 的幂次数进行移位;例如,除以 2(2¹)意味着向右移动 1 位。除以 256(2⁸)意味着向右移动 8 位。同样,乘以 2 的幂次数也是一样的;你只需左移。需要将一个值乘以四倍?将其左移 2 位(2² == 4)。棒极了!而且更重要的是,速度快。计算机只需移动一些位,这种工作与乘除算法相比非常简单,尽管结果相同。

那么余数呢?它们和除法一样昂贵,因为你在找到剩余量时执行了除法。但如果你使用%和二的幂次,那可以用按位&操作和正确的掩码来表示。你经常会听说比特“掩码”,它们只是保留某些比特位但隐藏(或掩盖)其他位的数字。要找到除以 64(2⁶)的余数,比如说,你创建一个 6 位的掩码:0x3f。(结果为 63,或者 64-1。)如果我们将我们的呼吸速率调整为约四秒(4096 毫秒,或 2¹²,更精确一些),我们可以像这样重新编写我们的毫秒转换:

  uint8_t ms = (millis() & 0x0fff) / 20;  // Bit mask with 12 bits

而且因为我们真的希望ms值位于 0 到 255 的范围内,这样才能作为我们查找表的适当索引,我们可以将该除法转换为另一个带有对除数的小调整的移位操作(5000 / 20 约等于 4095 / 16):

  uint8_t ms = (millis() & 0x0fff) >> 4;

不错。一点也不错。不过,我们确实需要调整我们的呼吸速率。记住本章开头的警告:如果能运行,那就是好的!如果你能得到一个满意的动画,包括所有浮点数运算和像 5 秒这样的整数时间,那么你的项目就是成功的。但如果你没有得到想要的结果,考虑调整以利用这些优化技巧。

循环优化

我们还可以对我们的呼吸 LED 草图进行一些聪明的改动,让它运行得更快。循环部分通常为了可读性和可重用性而牺牲了一些性能。当性能是最重要的目标时,你可以通过减少可重用性来夺回一点速度。

为了乐趣和利润展开

这种优化的动机在于管理循环需要一点时间。在我们的loop()函数中,我们有一个for循环,为我们的小棒上的每个 LED 设置颜色。更新一个 LED 后,我们必须增加p变量,然后测试是否还有更多 LED 需要处理。这些步骤虽小,但并非无关紧要。如果你在计算微秒,这些时间可能就不能轻易浪费了。

为了节省这些微秒,你可以展开解开你的循环。基本上,你将循环体简单地复制一次,每次需要迭代,然后硬编码控制变量(在我们的例子中是p)。我希望你尝试一些这样的优化作为良好的实践,所以我不会完全展开for循环。我们还可以使用>>技巧来替换计算红/蓝/绿值时所需的除法。更新前几个 LED 将会像这样:

void loop() {
  uint8_t ms = (millis() & 0x0fff) >> 4;
  uint8_t breath = breaths[ms];

  byte red, green, blue;

  // Pixel 0
  red   = (colors[0][0] * breath) >> 8;
  green = (colors[0][1] * breath) >> 8;
  blue  = (colors[0][2] * breath) >> 8;
  stick.setPixelColor(0, red, green, blue);

  // Pixel 1
  red   = (colors[1][0] * breath) >> 8;
  green = (colors[1][1] * breath) >> 8;
  blue  = (colors[1][2] * breath) >> 8;
  stick.setPixelColor(1, red, green, blue);

  // Pixel 2
  // ...

  stick.show();
  delay(10);
}

和其他优化一样,这里也存在一个折衷:我们将用未展开的循环消耗更多的程序空间。但是,我们的目标是性能——只要我们有空间。确保在展开循环之前正确地使循环工作。隐藏在未展开循环中的任何错误都将需要繁琐的复制和粘贴来修复每个扩展块。

递归与迭代

还有另一种循环选项,这不完全是一种优化,但值得一提。在内存有限的设备上,递归算法很快就会失控。幸运的是,每个递归算法也可以用迭代的方式编写。有时循环方法并不直观,或者看起来笨拙,但它确实有效。如果你担心代码中的递归调用,考虑将它们转换为带有额外变量的循环来帮助解决问题。

作为一个快速演示,让我们来看看找到第 n 个斐波那契数的循环方法(以替换我们之前在“递归函数”中编码的递归算法)。我们可以使用一个for循环和三个变量:

int find = 8; // we want the 8th Fibonacci number in this example
int antepenultimate = 0; // F(n - 2)
int penultimate = 0;     // F(n - 1)
int ultimate = 1;        // F(n)

for (int f = 1; f < find; f++) {
  antepenultimate = penultimate;
  penultimate = ultimate;
  ultimate = penultimate + antepenultimate;
}
// After the loop completes, ultimate contains the answer, 21

你可能还记得,递归算法在处理较大数字时会变得很慢。这种情况在这里不会发生。数字最终会变得非常大,所以你可能需要类似long long来存储结果,但算法将继续快速运行。那么为什么我们不总是使用迭代选项呢?对于斐波那契数列来说,这并不明显,但有时递归算法只是简单(有时非常简单),更容易理解并转化为代码。

再次面临一个折衷:复杂性与性能。不过,在这种情况下,这个折衷可能是显而易见的。如果你无法完成计算,使用一个可能更复杂的算法,能够完成计算,会更好。

String 与 char[]

你可以在 Arduino IDE 中使用的String类来存储和处理文本,这也是另一个可以进行优化的候选方案。我们的简单 LED 项目并没有真正使用任何文本,但如果你处理任何需要文本输出的项目,比如在迷你 LCD 屏幕上或通过 WiFi 连接,你可能会考虑使用String,因为它有一些方便的特性。不过,如果空间不够,请考虑使用(和重复使用)老式的char[]变量。

在线上你会发现许多例子都使用String,因为它们具有方便的附加功能,比如将数字转换为给定基数的文本,或者像toLowerCase()这样的函数,或者使用+操作符将String对象连接在一起(你可以在String 文档中了解所有这些额外功能)。使用String的例子阅读起来很顺畅,而涉及的文本通常与项目无关。

但是,如果你在处理诸如驱动 LED 或 LCD 显示器或将 JSON 块发送到 Web 服务之类的文本方面做更严肃的事情(我们将在"IoT and Arduino"中做类似的事情),那么String对象的方便性可能会开始消耗你的 SRAM。使用你控制的字符数组可以将内存消耗降至最低。至少,你会准确地知道完成目标所需的空间。

不要忘记,你可以像我们在"Flash (PROGMEM)"中看到的那样将文本存储在闪存中以供按需使用。有时候,F()宏就足够了。但依赖闪存会使你的程序稍微慢一点,并且你无法以编程方式修改存储在闪存中的这些消息。使用char[]可以在各方面都是一个胜利;在这里的权衡是你的时间和精力。

我们的最终提议

即使对于这样一个简单的项目,也有令人惊讶的优化方式可用。这是我们项目的最终版本,ch10/optimize3/optimize3.ino

#include <Adafruit_NeoPixel.h>

#define LED_PIN     4
#define LED_COUNT   8

Adafruit_NeoPixel stick(LED_COUNT, LED_PIN, NEO_GRB);
byte colors[8][3] = {
  { 0xFF, 0x00, 0x00 }, { 0x00, 0xFF, 0x00 },
  { 0x00, 0x00, 0xFF }, { 0x33, 0x77, 0xFF },
  { 0x00, 0xFF, 0xFF }, { 0xFF, 0x00, 0xFF },
  { 0xFF, 0xFF, 0x00 }, { 0xFF, 0x77, 0x33 }
};

uint8_t breaths[256];

void setup() {
  Serial.begin(115200);
  stick.begin();            // Initialize our LEDs
  stick.setBrightness(80);  // Set a comfortable brightness
  // Show our colors for a few seconds before animating
  for (byte p = 0; p < LED_COUNT; p++) {
    stick.setPixelColor(p, colors[p][0], colors[p][1], colors[p][2]);
  }
  stick.show();
  // Now initialize our sine lookup table
  for (int s = 0; s <= 255; s++) {
    breaths[s] = stick.sine8(s);
  }
  delay(2000);
}

void loop() {
  uint8_t ms = (millis() & 0x0fff) >> 4;
  uint8_t breath = breaths[ms];
  for (byte p = 0; p < LED_COUNT; p++) {
    byte red   = (colors[p][0] * breath) >> 8;
    byte green = (colors[p][1] * breath) >> 8;
    byte blue  = (colors[p][2] * breath) >> 8;
    stick.setPixelColor(p, red, green, blue);
  }
  stick.show();
  delay(10);
}

与我们之前版本唯一的改变是在我们的loop()函数中使用整数数学和位操作。我们将当前毫秒计数转换为我们可以与正弦查找表一起使用的索引,并简化我们红色、绿色和蓝色值的当前阴影计算。总的来说,这是一系列改进!额外的效率留出空间做其他事情。现在我们可以处理更多的 LED 或者制作更复杂的动画。或者我们可以包含一些传感器并将它们的读数整合到我们的输出中。所有这些在 2K RAM 中完成。你的程序员祖先会感到骄傲的。 😃

但是我不应该忘记我提到的实践。如果你想尝试自己的优化,可以像我们在"Unrolling for Fun and Profit"中开始的那样展开for循环,并确保项目仍然如预期般运行。

下一步

在本章中,我们看到了许多优化技巧。还有其他更加深奥的技巧,但它们需要更多了解你计划使用的具体硬件的知识。这次我没有具体的练习,但希望你在启动自己的 Arduino 项目后能再次回顾这一章。

如果你最终得到了任何优化并调整得完美无缺的有用函数,你可以将它们放入一个自定义库中,以便将来的项目中重复使用。你甚至可以发布它们供他人使用!无论如何,在 Arduino 上制作库相对容易。我们将在下一章看看它是如何做到的。

¹ 要利用这一功能,需要理解芯片的真正低级细节。这种理解有一个非常陡峭的学习曲线,远远超出了本书的目标范围。即使是像 Adafruit 的 Trinket M0 这样的微控制器的核心 SAM D21,也有一本 1000 多页的数据手册供您阅读!

² 我有幸将我的 8086 CPU 升级为一个令人惊叹的8087 FPU 协处理器。我留给你,亲爱的读者,来查找这段美好怀旧的时间吧。

³ NeoPixel 文档使用的是uint8_t类型而不是byte,所以我将照此来做临时变量。

⁴ “跑得更快”的一个重要副作用是,你可以在同样的时间内运行更多的计算,或者运行更复杂的计算。因此,我们可以支持更长的 LED 条或者具有不止红色、绿色和蓝色通道的 LED,而不会损失性能。

第十一章:自定义库

我们已经看到如何包含 Arduino IDE 自带的有用库的头文件,以及如何为一些更有趣的外设和传感器添加第三方库。但是在构建您的微控制器项目目录时,您可能会创建一些重复使用的代码。我经常使用的格言是宇宙中只有三个数字:0、1 和许多。如果您发现一段代码您第二次使用,您就属于“许多”类别,现在可能是时候考虑制作自己的库了。

这种说法可能听起来有些夸张。它确实听起来很宏伟。幸运的是,这是一个相当简单的过程,确实可以使未来的重复使用变得轻而易举。

我确实想承认,本章项目在各个方面都比我们以往的项目都大。如果遥控机器人车不引起您的兴趣,可以放心地跳过本章大部分内容。我仍然建议阅读“多文件项目” 和“创建库” 了解在 Arduino IDE 中为自己使用编写库的步骤。您可以安全地跳过本章,转而探索第十二章 中的物联网项目。

创建您自己的库

要开始创建自定义库,您需要一些可重用的代码。找到可重用代码的最佳方法是首先创建一些可用的代码。我们可以启动一个普通项目,然后提取那些看似在其他项目中可能效果良好的部分。

对于这个项目,我们将创建一个可以用简单的前进、后退、左右按钮驱动的电动车。一旦一切正常运行,我们可以将各种“驱动”功能拆分到一个单独的文件中,以突显 Arduino IDE 如何处理多个文件。最后,通过一点多文件的经验,我们将迈向无线电控制,并看看如何将该通信封装在一个库中,以便我们的车辆和单独的导航项目共享使用。

图 11-1 展示了我们将要使用的设置。我从 Adafruit 购买了这些零件,但您也可以轻松地从其他零件组装类似的小车。(见附录 B 获取我使用的确切零件号码。)物理小车相当简单,但确实需要一些组装。我不得不去本地五金店买一些小机械螺丝,但 Adafruit 的预制底盘确实简化了事情。底盘有完美的孔和插座,可引导后部电机和前部活动轮的固定。我只是把面包板和电池放在顶部,但有足够的地方可以用夹子、螺丝或拉链带固定它们。

smac 1101

图 11-1 我们的机器人小车

图 11-2 显示了我们微控制器、导航摇杆和 DRV8833 电机驱动板的接线。在这个项目中还有更多的连接,但希望没有太多让你感到不知所措的地方。

smac 1102

图 11-2. 机器人车的接线图

机器人技术总体上是我非常感兴趣的一个领域,但其中的机械元素(远远)超出了我的专业领域。第一次尝试这个项目时需要额外的学习,这既是挑战,也是乐趣——至少比沮丧更有趣。我以前从未与电机打过交道,因此将它们安装、供电和正确连接以便通过软件控制它们,当然需要一些试验和不少的沮丧的叹息。但如果这个特定项目对你不太感兴趣,随时可以跟随我们的代码拆解,并看看如何将这些部分组合成一个库。但我可以说,第一次通过按下按钮让轮子旋转时,你会觉得自己可以征服世界。 😃

提示

如果看到 图 11-2 的图表让你感到不安,你可以寻找配备所有必要零件以及详细组装说明书的机器人车套件。这些套件通常也有它们自己的编码说明。随意先让套件“原样”运行,并先熟悉电子设备。然后再回到这里,通过我的代码示例并应用它们(可能需要一些加强我们这里工作的修改)到你的完全运行的车辆上。

预处理指令

我们已经看到了几个预处理指令:#include#define 都由预处理器处理。而“预处理器”这个名称可能已经让你对其在编译代码中的作用有了一些了解。这些指令在编译你的代码之前被处理。

#include 指令用于引入定义在另一个文件中的代码。引入后,编译器会将其视为你在自己的代码中键入了该外部文件的内容。

正如我们一直使用的那样,#define 指令将一个友好的名称放在某个字面值上。然后我们可以在我们的代码中使用这个名称,而不是每次记住正确的字面值。而且,如果我们需要更改这个值,比如将 LED 连接移动到控制器上的其他引脚,我们只需要更改一次。与#include 一样,预处理器将每个 #define 名称的实例替换为其字面值,就像你直接键入字面值一样。

对于我们的车辆,让我们像我们连接的其他外设一样使用 #define 来定义导航摇杆上的引脚:

#define LEFT_BTN  12
#define RIGHT_BTN  9
#define FWD_BTN   10
#define BKWD_BTN  11

我应该指出,你可以使用#define来定义除了数字以外的其他值。也许你有一个标准的错误消息或文本响应。这些也可以定义:

#define GOOD_STATUS  "OK"
#define BAD_STATUS   "ERROR"
#define NO_STATUS    "UNKNOWN"

知道#define如何与预处理器一起工作也解释了为什么我们不在末尾放置分号。毕竟,我们不希望分号出现在我们的代码中。

预处理器宏

你甚至可以进一步使用#define。它不仅可以处理字符串,还可以处理小段逻辑,几乎像一个函数一样。这些片段通常被称为,以区别于实际的函数。宏(或宏指令)将一些输入转换为相应的输出,通常通过替换实现。宏不是函数调用。宏不会被压入或弹出堆栈。

宏在你有一段重复的代码,但不需要定义一个函数的情况下非常有用。当你希望代码片段保持数据类型无关时,宏也很棒。例如,考虑一个简单的宏来确定两个值的最小值。这里是定义和如何使用的示例:

#define MIN (x,y) x < y ? x : y

int main() {
  int smaller1 = MIN(9, 5);
  float smaller2 = MIN(1.414, 3.1415);
  // ...
}

要创建宏,我们使用#define和一个名称,就像之前一样,然后在括号中提供一个(或多个)变量。你传递给宏的任何参数都会替换宏片段中的变量。然后,该片段替换调用它的位置。就好像你键入了以下内容:

int main() {
  int smaller1 = 9 < 5 ? 9 : 5;
  float smaller2 = 1.414 < 3.1415 ? 1.414 : 3.1415;
}

这种简单的替换过程非常强大。但要小心。由于替换过程非常简单,如果将复杂表达式传递给宏,可能会出现问题。例如,如果你的表达式使用的运算符优先级低于宏中使用的运算符,那么预期的结果可能是错误的,甚至无法编译。通过合理使用括号可以避免其中一些问题,像这样:

#define MIN (x,y) (x) < (y) ? (x) : (y)

即便如此,通过传递适当的(嗯,错误的)表达式,你仍然可以生成一些奇怪的代码。GNU 关于 C 和 C 预处理器的文档甚至专门有一个章节讨论宏的陷阱

目前我们还不需要任何宏,但它们很常见,如果你在其他地方找到它们,我希望你能识别出来。事实上,C 预处理器本身非常有趣。在完成本书后,它是独立研究的一个很好的目标!

自定义类型定义

除了常量和宏之外,库通常还利用 C 的另一个特性:typedef操作符。你可以使用typedef为某种其他类型分配一个别名。这听起来可能是不必要的,而且从技术上讲确实如此,但在某些情况下非常方便,可以导致更易读、易于维护的代码。

我们在第十章中看到了一些typedef别名的使用。byteuint8_tuint32_t的指定类型都是通过typedef创建的。如果 Arduino 环境没有为你提供这些类型,你也可以像这样自己创建:

typedef unsigned char byte;
typedef unsigned char uint8_t;
typedef unsigned long int uint32_t;
注意

"_t"后缀对这些别名非常流行。这是突出显示名称是使用typedef构建的别名的简单方式。

你也可以在struct关键字中使用typedef来为你的自定义丰富数据类型取一个更易读的名称。例如,在“定义结构”中我们可以使用typedef定义我们的事务如下:

typedef struct transaction {
  double amount;
  int day, month, year;
} transaction_t;

// Transaction variables can now be declared like this:
transaction_t bill;
transaction_t deposit;

这个特性对于我们简单的库并不是必需的,但许多库确实使用typedef来为在库上下文中更合理的名称提供类型。让我们继续定义一个类型,该类型可以存储我们的方向常量之一的变量:

typedef signed char direction_t;

我们将继续使用char的有符号版本,因为将来可能会用到负值。例如,如果你只期望正数,负数可以作为很好的错误代码。现在让我们使用我们的新类型创建一些类型化常量:

const direction_t STOP     = 0;
const direction_t LEFT     = 1;
const direction_t RIGHT    = 2;
const direction_t FORWARD  = 3;
const direction_t BACKWARD = 4;

回想一下在“常量:const versus #define”中const#define的讨论。这是一个我们并没有真正需要选择其一的地方,但const方法确实为我们的代码添加了一些固有的文档说明,对其他读者可能会有用。我应该说,90%的情况下,看你的代码的第一个“其他读者”是你自己,但那是在几周或几个月之后。像direction_t类型这样关于你意图的提示在唤醒你自己的记忆时非常有用。

我们的汽车项目

准备好了吗!这将是我们的“第一个版本”项目,有些额外的抽象应该有助于我们将这个项目拆分为可重复使用的部分。(如果你想从一个简单的功能验证开始,可以查看版本 0。)当你处理自己的项目时,可能会发现自己的电机布线与我的不完全相同。你的导航输入(按钮或摇杆)可能连接方式稍有不同。测试一下你的设置,不要害怕在各种驾驶函数中更改设置为HIGHLOW的引脚。幸运的是,所有这些都可以在软件中调整。最终目标只是在你推动摇杆向上时让你的车向前滚动。

这是我们的汽车构建的第一版。如往常一样,你可以自己输入这些内容,或者直接打开ch11/car1/car1.ino

// Define the pins we're using for the joystick and the motor
#define LEFT_BTN  12
#define RIGHT_BTN  9
#define FWD_BTN   10
#define BKWD_BTN  11

#define AIN1 4
#define AIN2 5
#define BIN1 6
#define BIN2 7

// Define our direction type
typedef char direction_t;

// Define our direction constants
const direction_t STOP     = 0;
const direction_t LEFT     = 1;
const direction_t RIGHT    = 2;
const direction_t FORWARD  = 3;
const direction_t BACKWARD = 4;

void setup() {
  // Tell our board we want to write to the built-in LED
  pinMode(LED_BUILTIN, OUTPUT);

  // Accept input from the joystick pins
  pinMode(LEFT_BTN, INPUT_PULLUP);
  pinMode(RIGHT_BTN, INPUT_PULLUP);
  pinMode(FWD_BTN, INPUT_PULLUP);
  pinMode(BKWD_BTN, INPUT_PULLUP);

  // Send output to the motor pins
  pinMode(AIN1, OUTPUT);
  pinMode(AIN2, OUTPUT);
  pinMode(BIN1, OUTPUT);
  pinMode(BIN2, OUTPUT);

  // And make sure our LED is off
  digitalWrite(LED_BUILTIN, LOW);
}

void allstop() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, LOW);
}

void forward() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, HIGH);
  digitalWrite(BIN1, HIGH);
  digitalWrite(BIN2, LOW);
}

void backward() {
  digitalWrite(AIN1, HIGH);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, HIGH);
}

void left() {
  digitalWrite(AIN1, HIGH);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, LOW);
}

void right() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, HIGH);
}

direction_t readDirection() {
  if (digitalRead(FWD_BTN) == LOW) {
    return FORWARD;
  }
  if (digitalRead(BKWD_BTN) == LOW) {
    return BACKWARD;
  }
  if (digitalRead(LEFT_BTN) == LOW) {
    return LEFT;
  }
  if (digitalRead(RIGHT_BTN) == LOW) {
    return RIGHT;
  }
  // No buttons were pressed, so return STOP
  return STOP;
}

void loop() {
  direction_t dir = readDirection();
  if (dir > 0) { // Driving!
    digitalWrite(LED_BUILTIN, HIGH);
    switch (dir) {
      case FORWARD:
        forward();
        break;
      case BACKWARD:
        backward();
        break;
      case LEFT:
        left();
        break;
      case RIGHT:
        right();
        break;
    }
  } else {
    // Stopping, or eventually we could handle errors, too
    digitalWrite(LED_BUILTIN, LOW);
    allstop();
  }
}

在这一点上,随时从书本中休息一下,放松一下吧。 😃 你可以前后驾驶吗?当你把摇杆向左或向右移动时,车会按照你的意愿转弯吗?你能在两个填充动物障碍物之间平行停放吗?用绳子连接的摇杆跟随你的车可能会感觉有点尴尬,但我们很快就会解决这个问题。

多文件项目

欢迎回来!希望你成功地将新跑车平行停放好。有了一个工作程序作为我们的基线,让我们把它拆分成一些可重复使用的部分。

作为一种语言,C 并不关心代码的存放位置。只要gcc能找到您代码中提到的所有源文件、头文件和库,它就会生成可用的输出。但在 Arduino IDE 中创建多文件项目略有不同。IDE 管理一些整合步骤,这些步骤通常由桌面版您自行处理。由于我们目前专注于微控制器,因此我们将专注于 Arduino IDE 中的操作。如果您对在 Arduino 以外构建更大项目感兴趣,我会再次推荐普林兹和克劳福德的C in a Nutshell

我们将从将当前项目转换为具有相同功能的多文件项目开始。然后,我们将扩展我们的机器人车以支持远程无线电控制,并看看共享代码的强大之处。

在我们的小车示例中,我们有几个函数专门用于让汽车运动。这些相关函数非常适合分离到自己的文件中。它们都为相似的目的服务。尽管将相关功能放在单独的文件中并非必需,但这是组织更大项目片段的流行方式。少量文件,每个文件中有少量函数,可能比一个包含大量函数的巨大文件更易于维护和调试。但如果随意拆分函数,将很难记住哪些文件包含哪些函数。

Arduino IDE 为我们提供了几种选项来分解项目:我们可以添加新的.ino文件,可以包含自定义头文件,或者可以创建并导入自定义库。本章的其余部分将讨论这三种机制。

代码(.ino)文件

首先,让我们将此项目另存为新名称,以便在出现问题并希望查看工作正常的项目时备用。在 Arduino IDE 的“文件”菜单中,选择“另存为…”选项。我选择了极具创意和原创性的名称car2。如果灵感来临,您可以更加创意无限。

现在让我们将我们所有五个驾驶函数移动到它们自己的文件中。要添加新文件,请使用右侧顶部附近的向下箭头按钮。该按钮将打开一个小菜单,如图 11-3 所示。从该菜单中选择“新建标签”。

smac 1103

图 11-3. 在 Arduino IDE 中创建新标签

接下来,将提示您命名标签,如图 11-4 所示。在字段中输入名称drive.ino,然后单击“确定”按钮。

smac 1104

图 11-4. 给我们的新文件命名

现在你应该有一个名为“drive”的新选项卡(没有显示后缀)。继续从“car2”选项卡中剪切五个驾驶函数(包括allstop()),然后粘贴到我们的新“drive”选项卡中。该选项卡最终将具有以下代码(ch11/car2/drive.ino):

void allstop() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, LOW);
}

void forward() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, HIGH);
  digitalWrite(BIN1, HIGH);
  digitalWrite(BIN2, LOW);
}

void backward() {
  digitalWrite(AIN1, HIGH);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, HIGH);
}

void left() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, HIGH);
  digitalWrite(BIN1, LOW);
  digitalWrite(BIN2, LOW);
}

void right() {
  digitalWrite(AIN1, LOW);
  digitalWrite(AIN2, LOW);
  digitalWrite(BIN1, HIGH);
  digitalWrite(BIN2, LOW);
}

这实际上是分离这些代码片段所需的所有工作!现在你有了你的第一个多文件 Arduino 项目。点击验证(对号)按钮,确保你的项目在新的两个文件配置中仍然能够编译通过。一切应该还能正常工作。你甚至可以将其上传到你的控制器并继续驾驶你的车。

如果项目无法验证或上传,请检查确保你没有丢掉花括号或者可能从原始文件中多拿了一行。您还应该确保您为新分离的文件选择的名称以 .ino 扩展名结尾。

Arduino IDE 在 .ino 文件中为我们执行了一点魔法。首先准备我们的主项目文件(在本例中是 car2.inocar2 文件夹中)。然后按字母顺序包含任何其他 .ino 文件。您可能已经注意到我们的 drive.ino 文件没有 #include 语句。然而,我们明显使用了在主文件中定义的引脚常量。对于编译器而言,只有一个大的 .ino 文件需要编译,因此后续的 .ino 文件可以看到前面文件中的所有函数、#defines 和全局变量。目前还没有办法改变单独的 .ino 文件的顺序;它们总是按字母顺序合并。

头文件

那么所有这些单独的文件是如何如此无缝地共同工作的呢?在加载这些单独文件之前,IDE 添加了一个魔法步骤。它创建了一个带有前向声明的头文件,其中包括你 .ino 文件中所有函数和全局变量的简要描述。前向声明是关于你的函数命名、参数和返回值类型的简短描述。它们允许单独的文件在没有完整实现的情况下使用函数。每个头文件依次自动包含在你的主项目文件中。

您可以在我们简单的两个选项卡项目中看到这一效果。drive.ino 文件不需要包含任何额外的信息来使用我们的 #define 引脚条目。而且在我们的主 car2.ino 文件中的代码可以调用 drive.ino 中定义的函数,而无需担心函数的顺序或特定位置。最终,这两个文件完美地结合在一起完成了我们的项目。

您还可以创建自己的头文件。这对于整理很有用。例如,如果有许多#define语句,您可以将它们放在自己的头文件中。或者,如果您希望以低技术手段在项目之间共享一些指令,您可以复制一个头文件并将其放在另一个项目中。对于您自己的项目来说,最合理的方法很大程度上取决于您自己。许多成功的制作者有数十甚至数百个项目,每个项目只有一个单独的.ino文件。我只是想确保您知道,如果那一个大文件开始让您不知所措,如何将其分割成更易管理的部分。

为了实现这个目标,让我们再次稍微拆分我们的主项目。让我们尝试将一些#define指令放入它们自己的头文件中。我们将移动这八个引脚常量。像以前一样创建一个新的标签,并在提示时将其命名为pins.h。新的标签应显示文件的完整名称pins.h,以帮助区分它与隐藏扩展名的.ino文件。

car2中剪切八个#define行和相关的注释,并将它们粘贴到pins.h中。结果应如ch11/car2/pins.h所示:

// Define the pins we're using for the joystick and the motor

#ifndef PINS_H
#define PINS_H

#define LEFT_BTN  12
#define RIGHT_BTN  9
#define FWD_BTN   10
#define BKWD_BTN  11

#define AIN1 4
#define AIN2 5
#define BIN1 6
#define BIN2 7

#endif /* PINS_H */

现在,我们只需在我们的文件顶部的car2标签中添加一个包含语句:

#include "pins.h"

您可以根据我的版本 2检查您的工作。您的项目应该像以前一样进行验证(和上传)。请随意尝试,并确保您仍然可以驾驶您的汽车。

警告

请注意我们pins.h头文件名周围的双引号。先前的#include语句使用的是尖括号(<>:小于、大于)。这种区别是有意的。尖括号告诉编译器在标准包含路径中查找头文件。通常情况下,这意味着您正在从已知库中引入一个头文件。

引号告诉编译器,要包含的文件与包含它的文件位于同一文件夹中。通常情况下,这意味着你在为这个项目专门编写的头文件。

再次强调,将项目分割并非强制要求,也不是在处理大文件时总是要做的事情,但这样做可能很有帮助。这样可以让你集中精力在代码的一部分上,避免意外改变另一部分。如果与其他程序员合作,使用单独的文件也可以更轻松地在最后将你们的工作结合起来。但最终,这完全取决于你自己和你感觉舒适的方式。

导入自定义库

除了多个.ino.h文件外,您还可以构建自己的 Arduino IDE 库。如果您有代码想要在多个项目中使用,或者通过 GitHub 等公共代码站点与他人共享,库是一个很好的选择。

令人高兴的是,创建自定义库并不需要太多的工作量。您至少需要一个.cpp文件和一个匹配的头文件(.h)。如果需要的话,您还可以拥有更多文件,以及我们将在下一节讨论的一些细微之处。

促进沟通

我们的机器人车看起来很棒,但是用有线操纵杆跟在后面有点笨重。使用无线电控制的机器人车会更加酷!我们可以做到这一点,使用一个库来管理无线电通信是确保我们不会发生信号冲突的好方法——从字面上来说。我们可以使用库来确保多方可以访问共同的定义(比如“向前驾驶”的值)。我们还可以把协议的规则放入库的函数中。这有点像确保每个人都在说同一种语言。

库可以提供的不仅仅是这种假设语言的词汇表。它们还可以强制执行对话的规则。谁先说话?接下来谁说?需要回复吗?可以有多个听众吗?通过你在库中编写的函数来回答这些问题。只要两个(或更多)项目使用同一个库,你在库函数中编码的细节将确保所有人都和谐相处。

让我们创建一个库来发送和接收无线电信号。我们将创建两个分开的项目,两者都使用这个库。我们将首先用一个无线电元件替换当前连接到我们车上的操纵杆。然后,我们将创建一个控制器项目,将我们新获得的操纵杆与类似的无线电配对。顺便说一句,这意味着我们需要两个微控制器。我将使用另一个 Metro Mini,但它们不必完全相同。如果你有其他与我们的无线电兼容并可以使用我们的库的控制器闲置,任何控制器组合都应该可以工作。

我们的车改装计划

让我们把操纵杆换成无线电收发器。我正在使用 Adafruit 提供的精美包装的RFM69HCW高功率断口。它售价约为 10 美元,连接起来相对简单。此外,它还具有一些不错的功能,比如只有使用相同加密密钥的类似芯片才能解密的加密传输。图 11-5 显示了我们的微控制器、电机驱动器和无线电的布线图。由于 RFM69HCW 需要在我们的 Metro Mini 微控制器上使用特定引脚,我不得不重新定位几个 DRV8833 的连接(在“我们的无线电控制库标头”中有更多信息)。

smac 1105

图 11-5. 带有无线电收发器的机器人车的布线图

当然,电源和地线引脚也应该连接好。我为微控制器使用了 9V 电源(该电源又供电给无线电),并为 DRV8833 使用了独立的电源。连接到 RFM69HCW 顶部的那根绿色孤单电线只是一个三英寸长的简单天线的一部分。¹

图 11-6 展示了装配好的组件,已经准备好滚动,没有任何连接线!

smac 1106

图 11-6. 我们的无线无线电小车

好吧,没有连接到操纵杆的电线。面包板上有很多电线。这个项目比我们迄今为止处理的项目要大。如果无线电控制的小车不是您的菜,可以跳到下一章。但在您离开之前,请查看“创建库”关于创建库代码及其头文件的部分。

我使用两个单独的电源供应来保持电机与微控制器和无线电分开。如果您有更多经验用 Arduino 项目供电并想使用不同的配置,那就尽管去做!重要的是我们的无线电已准备好接收驾驶指令。

创建一个控制器

我们还需要一个新项目来接收来自我们操纵杆的输入并将该信息发送到无线电上。图 11-7 显示了接线情况。控制器只需要一个电池;无线电可以安全地从我们的微控制器的 5V 引脚供电。

smac 1107

图 11-7. 无线电控制器的接线

我用 USB 电源包将控制器连接到了 Metro Mini。图 11-8 展示了最终结果。

smac 1108

图 11-8. 我们的无线控制器

不是最引人注目的小工具,但它确实发送无线电信号!至少,一旦我们添加一点代码,它就会发送。

创建库

我们的小车和控制器的代码都需要我们的无线电库,所以让我们从这里开始。我们将创建一个头文件和一个.cpp文件,以适应 Arduino IDE 的 C++中心性质。实际代码仍将是(大部分)纯 C,只是需要放在具有.cpp扩展名的文件中。

如何编写这段代码完全取决于您。您可以在一个文件中编写所有内容,然后将要放入头文件的部分分离出来(就像我们在本章前面所做的那样)。您还可以将头文件用作大纲或计划。填写头文件中的常量和函数名称,然后创建.cpp文件来实现这些函数。无论哪种路径听起来更好,我们都需要将文件放在特定位置,以便 IDE 能够识别它们。

库文件夹

我们将库中的所有文件放在一个文件夹中,该文件夹位于您的 Arduino 草图所在的libraries文件夹中。在我的 Linux 系统中,这是我主目录中的Arduino文件夹。如果您不确定系统中该文件夹的位置,可以在 Arduino IDE 的首选项中查看。从“文件”菜单中,选择“首选项”选项。您应该看到类似于图 11-9 的对话框。注意顶部的“草图位置”。libraries文件夹需要放在那里。如果那里还没有这样的文件夹,请立即创建。

smac 1109

图 11-9. Sketchbook 位置首选项设置

现在看这个文件夹实际上很有用,因为我们需要手动安装我们的无线电模块库。它将放在同一个文件夹中。我使用由 Adafruit 团队编写的无线电库。²从绿色的“代码”下拉按钮下载 ZIP 存档。解压文件并将生成的文件夹重命名为RadioHead。将此RadioHead文件夹放入libraries文件夹中,就完成了。

嗯,这就是关于无线电库的全部内容。我们仍然需要为我们尚未编写的库创建一个文件夹。在libraries文件夹内,创建一个新文件夹并为您的自定义库选择一个名称。由于这是一个用于robot car 的radio control 库,并且本书的标题以这两个字母结尾,我选择将其命名为SmalleRC。顺便说一句,您无需压力使用这样的有趣、书呆子风格的名称来命名您的库。这就是“自定义”形容词的作用。按照您的喜好自定义您的库!

我们的无线电控制库标头

在你的新库文件夹里,让我们创建我们的文件。我会采用第二种方法,从头文件SmalleRC.h开始。

我们将加载我们在无线电工作中需要的标头,以及Arduino.h标头,以防我们的库代码依赖于任何 Arduino 特定的函数。我们将定义几个常量,然后提供一些函数原型:

#ifndef SMALLERC_H ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
#define SMALLERC_H

#include "Arduino.h" ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
#include <SPI.h> ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
#include <RH_RF69.h> ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)

#define RF69_FREQ 915.0 ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
#define RFM69_CS      4
#define RFM69_INT     3
#define RFM69_RST     2
#define LED          13

#define rc_INIT_SUCCESS  1 ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
#define rc_INIT_FAILED  -1
#define rc_FREQ_FAILED  -2

// Define our direction type typedef signed char direction_t;      ![7](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/7.png)

// Define our directions const direction_t rc_STOP     = 0;
const direction_t rc_LEFT     = 1;
const direction_t rc_RIGHT    = 2;
const direction_t rc_FORWARD  = 3;
const direction_t rc_BACKWARD = 4;

char rc_start();                      ![8](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/8.png)
void rc_send(int d);
int  rc_receive();

#endif /* SMALLERC_H */

1

我们将像pins.h一样使用一个头文件保护。

2

我们的库代码可能需要一些 Arduino 特定的类型或函数,因此我们包含了这个标头。这个标头由 IDE 自动包含在我们的主项目中,这就是为什么我们之前没有看到这个#include

3

SPI(串行外围接口)标头允许我们使用只有几根线的外设执行复杂通信(即不是HIGHLOW或单个值)。我们将使用这种类型的连接与我们的无线电模块板。我们的微控制器有非常特定的 SPI 引脚,因此我们不必指定要使用哪些引脚。图 11-7 展示了正确的连接方式。

4

我们需要刚刚安装的 RH_RF69 库来与无线电通信。

5

虽然 SPI 处理了大多数通信需求,但这些define条目填补了 RH_RF69 库操作我们的无线电所需的一些细节,包括要使用的频率(RF69_FREQ;在欧洲使用 433 MHz,在美洲使用 868 或 915 MHz)以及处理中断和复位的引脚。

6

我们将定义一些自己的常量来帮助协调我们的无线电的初始化。我们将以一种可以帮助我们调试任何问题的方式区分故障。

7

我们可以在这里放置我们的typedef,以便每个导入这个库的人都可以访问direction_t类型别名。我们还将包含我们的方向。

8

这些是我们库的前向声明(也称为函数原型)。我们需要在我们的.cpp文件中编写完整的函数,并且这些函数将与此处声明的名称和参数相同。

一个头文件中包含了相当多的细节!但这就是头文件的作用。在没有任何其他文档的情况下,阅读头文件应该告诉您几乎所有您需要知道的东西来使用库。

警告

对于这个头文件,我稍微作弊了。对于打算与他人分享的 Arduino 库,您通常不会指定连接外围设备的引脚。我们有能力使这个头文件与我们的物理项目匹配,但其他用户可能没有相同的控制器或相同的空闲引脚。查看“在线分享”以获取一些有关深入了解可共享库创建的提示。不过,对于专门用于自己项目的库,您可以允许几个捷径。

我们的无线电控制库代码

要完成我们的库,我们需要编写一些代码并实现在我们的头文件中声明的函数。这些代码并不是非常复杂,但它确实有几个与启用和与我们的无线电通信相关的新颖部分。您可以自己键入它,也可以在您的编辑器中打开SmalleRC.cpp

#include "SmalleRC.h" ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)

RH_RF69 rf69(RFM69_CS, RFM69_INT);                    ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)

char rc_start() {                                     ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
  pinMode(LED, OUTPUT);
  pinMode(RFM69_RST, OUTPUT);
  digitalWrite(RFM69_RST, LOW);

  // manual reset
  digitalWrite(RFM69_RST, HIGH);
  delay(10);
  digitalWrite(RFM69_RST, LOW);
  delay(10);

  if (!rf69.init()) {
    return rc_INIT_FAILED;
  }

  if (!rf69.setFrequency(RF69_FREQ)) {
    return rc_FREQ_FAILED;
  }

  // range from 14-20 for power
  // 2nd arg must be true for 69HCW
  rf69.setTxPower(17, true);

  // The encryption key is up to you, but must be
  // the same for both the car and the controller
  uint8_t key[] = {                                   ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
    0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
    0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08
  };
  rf69.setEncryptionKey(key);

  pinMode(LED, OUTPUT);
  return rc_INIT_SUCCESS;
}

void rc_send(direction_t d) {                         ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
  uint8_t packet[1] = { d };
  rf69.send(packet, 1);
  rf69.waitPacketSent();
}

direction_t rc_receive() {                            ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
  uint8_t buf[RH_RF69_MAX_MESSAGE_LEN];
  uint8_t len = sizeof(buf);
  if (rf69.recv(buf, &len)) {
    if (len == 0) {
      return -1;
    }
    buf[len] = 0;
    return (direction_t)buf[0];
  }
  return STOP;
}

1

包含我们最近构建的包含所有引脚、方向和无线电配置信息的头文件。

2

创建一个类似于前期项目中的 NeoPixel 对象的无线电控制对象。

3

初始化无线电。此代码基于我们安装的库中包含的示例。查看“在线分享”以获取有关示例和其他库文档的更多详细信息。

4

无线电初始化的一部分是设置一个加密密钥,确保只有使用相同密钥的其他无线电设备可以与我们通信。这些值正是示例中的那些。如果愿意,可以随意更改它们,只需确保密钥为 16 字节。

5

一个简单的函数来广播一个方向。无线电库期望一个uint8_t值的数据包,因此我们创建一个单元素数组来匹配。当然,该库可以发送更长的消息,但我们只需要发送这一个单值。

6

接收函数用于读取从控制器传来的任何方向指令。同样,无线电库可以处理更长的消息,但我们只需要第一个字节,它应该包含我们的方向。如果根本没有消息,返回-1以告知调用者没有准备好的消息。否则,返回我们收到的方向,或者默认返回STOP

有了我们的自定义库,我们可以开始编写汽车和控制器的实际项目。但我们确实在这段代码中快速穿过了!如果您对无线电通信感兴趣,我建议您尝试一些例子来更好地了解可能性和限制。

更新汽车项目

现在我们需要编写汽车的代码。(随意查看汽车的物理设置和“改装我们的汽车”中的打破点。)基本上,我们将用一个从无线电接收数据的调用来替换轮询操纵杆的逻辑。我们会暂停几毫秒,以避免电机快速启停。否则,我们将运行一个相当紧密的循环,使汽车对我们的遥控器反应灵敏。

我基于car2项目创建了这个版本(car3),该项目有单独的pins.hdrive.ino文件。在这个项目中,我们不再需要操纵杆的引脚,所以头文件变得稍微短一些:

#ifndef PINS_H
#define PINS_H

#define AIN1 9
#define AIN2 8
#define BIN1 6
#define BIN2 7

#endif /* PINS_H */

驾驶功能完全没有改变,所以我将它们略过,但如果您愿意,您可以查看代码(“代码(.ino)文件”)。主要的car3.ino文件的代码应该感觉很熟悉,但显然我们需要包括我们新的无线电库的头文件:

#include "pins.h"
#include "SmalleRC.h"

void setup() {
  Serial.begin(115200);
  // Send output to the motor pins
  pinMode(AIN1, OUTPUT);
  pinMode(AIN2, OUTPUT);
  pinMode(BIN1, OUTPUT);
  pinMode(BIN2, OUTPUT);

  if (rc_start() != rc_INIT_SUCCESS) {
    Serial.println("Failed to initialize radio.");
  }
}

void loop() {
  direction_t dir = rc_receive();
  if (dir > 0) { // Driving!
    switch (dir) {
      case rc_FORWARD:
        forward();
        break;
      case rc_BACKWARD:
        backward();
        break;
      case rc_LEFT:
        left();
        break;
      case rc_RIGHT:
        right();
        break;
    }
    delay(20);
  } else {
    // Stopping, or eventually we could handle errors, too
    allstop();
  }
}

注意,我正在使用SmalleRC.h文件中定义的新导航常量(如rc_LEFT)。但这就是我们现在驾驶汽车所需的所有代码!这是通过分离共享代码块的众多好处之一。通过在这些共享代码基础上构建,您可以更快地创建一些非常有趣的项目。

目前还没有很好的方法来测试这个新的car3项目,但请继续将其上传到您的微控制器上。如果没有其他问题,您可以使用串行监视器工具确保无线电启动没有错误。对于setup()函数中的错误,我采取了“没有消息就是好消息”的方法,但如果您愿意,可以稍微修改以生成成功消息。

提示

现在 Arduino IDE 已经知道我们的SmalleRC库,您实际上可以直接在该库的源文件上进行编辑,然后重新验证或重新上传项目。例如,如果您在启动无线电时遇到问题,请在SmalleRC.cpp中添加一些调试调用到Serial.println()。一旦您已经找到并解决了问题,您可以删除调试语句并重新上传一次。

把控制权在手中

接下来要做的是编程控制器。(如果你还需要构建物理遥控器,请回顾一下“创建控制器”。)在这里,我们对摇杆轮询进行了处理,而不是将结果发送给电机,而是通过无线电广播任何方向信息。多亏了这个库,这是一个相当小的项目,所以我把它放在了一个单独的 ch11/controller/controller.ino 文件中:

#include "SmalleRC.h"

#define LEFT_BTN  9
#define RIGHT_BTN 7
#define FWD_BTN   8
#define BKWD_BTN  6

void setup() {
  Serial.begin(115200);
  // Accept input from the joystick pins
  pinMode(LEFT_BTN, INPUT_PULLUP);
  pinMode(RIGHT_BTN, INPUT_PULLUP);
  pinMode(FWD_BTN, INPUT_PULLUP);
  pinMode(BKWD_BTN, INPUT_PULLUP);

  if (rc_start() != rc_INIT_SUCCESS) {
    Serial.println("Failed to initialize radio.");
  }
}

direction_t readDirection() {
  if (digitalRead(FWD_BTN) == LOW) {
    return rc_FORWARD;
  }
  if (digitalRead(BKWD_BTN) == LOW) {
    return rc_BACKWARD;
  }
  if (digitalRead(LEFT_BTN) == LOW) {
    return rc_LEFT;
  }
  if (digitalRead(RIGHT_BTN) == LOW) {
    return rc_RIGHT;
  }
  // No buttons were pressed, so return STOP
  return rc_STOP;
}

void loop() {
  direction_t dir = readDirection();
  rc_send(dir);
  delay(10);
}

我们本可以把 readDirection() 函数的逻辑直接放在我们的 loop() 函数中,但我喜欢这种用小抽象使 loop() 函数变得简洁的方式。

尝试验证这个新项目,如果遇到任何问题,添加几个 Serial.println() 语句。并记住,如果需要的话,你也可以将它们添加到你的库代码中。

小贴士

对于像这样大量使用库的项目(不仅仅是我们的自定义库,还有像 RF_RH69 这样的库),println() 调用可能并不能帮助解决每一个问题。下载的库中确实会出现 bug,但这种情况相当罕见。我发现许多问题是由于我错接了一些线路引起的。所以,如果事情仍然没有解决,请尝试再次检查你的微控制器与各种外围设备之间的连接。

开车吧!

没有代码。没有图表。没有指导。在这一章中,我完全鼓励你去玩耍。 😃 尝试同时启动两个项目,看看当你移动摇杆时会发生什么。肯定会出现一些问题!例如,如果接线不太对,车可能会移动,但不是你想要的方向。(例如,当我把项目移到全尺寸面包板时,我意外地交换了右侧电机输入引脚。右轮转动了,但方向错了。)或者如果我们连接摇杆的引脚错了,可能会完全没有发送任何信号。

如果汽车一动不动,又到了展示你调试技能的时候了。顺便提一下,你可以同时连接两个项目到你的电脑上。它们只是连接到不同的串行端口上。 (记住,在 Arduino IDE 的工具菜单中可以设置你的微控制器使用哪个端口。)你可以使用 Serial.println() 语句来确保你的输入、发送、接收和驱动都按照你的预期工作。只需关注成功!当你把事情搞定时,很容易就能把你的车开到桌子上,留下一串电子设备悬挂在 USB 线上。或者,你懂的,就是这样告诉我的。

文档和分发

一旦你的库可以工作了,并且你已经在房间里充分享受了足够的乐趣,那么是时候考虑为你的项目添加一些文档了。文档非常重要。不仅仅是为了其他可能会使用你的库的程序员。即使你离开一个项目只有几天,你写的任何文档也会出人意料地对帮助你自己快速理清思路很有用。

关键词

您可以添加到用于 Arduino IDE 的非常简单的文档之一是称为keywords.txt的单个文本文件。对于自定义库,它应该包含两列,用制表符分隔。第一列包含库中定义的函数、常量和数据类型。第二列应包含表 11-1 中的一个条目,指示第一列中名称的类别。

表 11-1. 用于文档化 Arduino 库的关键字类别

类别名称 目的 外观
KEYWORD1 数据类型 橙色, 粗体
KEYWORD2 函数 橙色, 普通
LITERAL1 常量 蓝色, 普通

尽管有限,这几个类别仍然可以帮助依赖 IDE 提示的程序员,比如他们是否正确拼写了函数名。

因此,对于我们的库,我们可以在我们自己的keywords.txt文件中创建以下条目(再次用制表符分隔):

rc_INIT_SUCCESS LITERAL1
rc_INIT_FAILED  LITERAL1
rc_FREQ_FAILED  LITERAL1

direction_t KEYWORD1

rc_STOP LITERAL1
rc_LEFT LITERAL1
rc_RIGHT    LITERAL1
rc_FORWARD  LITERAL1
rc_BACKWARD LITERAL1

rc_start    KEYWORD2
rc_send KEYWORD2
rc_receive  KEYWORD2

基本上,该列表包含了我们在SmalleRC.h文件中定义的所有内容,减去仅由无线电库使用的少量常量。如果您此时重新启动您的 IDE,文件中列出的函数和其他名称将与核心语言使用的语法高亮显示相同!非常酷。

警告

请确保在keywords.txt中使用真正的制表符来分隔列。空格是不起作用的。许多编辑器(例如 VS Code)有一个合理的设置,当保存文件时,将所有制表符转换为适当数量的空格。在源文件中,这种静默更改可能有很多有用的原因,但在这里我们不想要它。

如果您无法在您选择的编辑器中暂时禁用此功能,keywords.txt确实只是一个文本文件。您可以使用任何文本编辑器创建或编辑它,包括像 Windows 10 中的记事本或 macOS 中的 TextEdit 这样非常简单的编辑器。

包括示例

包括几个示例项目与您的库一起是另一个很好的补充,而且不需要太多的工作。您只需在包含库代码和keywords.txt文件的文件夹中创建一个名为examples的文件夹。然后,在examples文件夹中,您可以放置几个项目文件夹(使用整个文件夹,而不仅仅是内部的.ino文件)。

示例项目应该简短而精炼。如果可能的话,不要包含不使用该库的不必要功能。您希望新用户能看到库的重要部分以及它们在草图中的使用方式。如果您的库相当丰富,不要害怕提供几个小示例,每个示例都侧重于库的特定方面。

当然,你会在野外发现那种“更小、更专注”的反面。有时一个单一的示例包含了库中每一个特性的演示。虽然这些广泛的示例确实突出了库的使用,但对于外部人员来提取详细信息可能会更加困难。如果你只是想了解库中的一个或两个功能,那么大型示例可能会令人不知所措。

但任何例子总比没有例子好!如果你只有精力进行单一全面的方法,那就包括它。如果你将它托管在像 GitHub 这样的公共位置,甚至可以邀请其他用户从他们自己的项目中贡献一些专注的示例。

在线分享

如果你真的打算分享你的代码,你可能需要查阅官方的库指南,以及优秀的库规范文档。如果你希望使你的库更加完善,可以向库文件夹中添加一些内容。你甚至可以让你的库在 IDE 中的库管理器中工作起来。不过,请注意:这些文档(合理地)使用 C++。C++有许多更多的设施可以用来分享代码的适当部分,同时隐藏实现细节。肯定会有一些语法细节对你来说是新的,但希望不会太难以理解。

作为发布库的第一步,请查看来自 Arduino 团队的常见问题解答

下一步

即使你从未发布过库,我们也看到如何通过包括预处理器宏、类型别名以及在 Arduino IDE 中使用多个选项卡等多种技巧来管理更大的项目。我们还介绍了创建简单库的方法,这些库可以手动安装在系统上,以便在自己的项目之间共享。

需要记住的是,选项卡和库文件这些东西是 Arduino IDE 特有的。其他 IDE 或环境可能有它们自己的怪癖,但通常情况下,你可以找到使用多个文件的方法。主要目标是让你能够在选择的任何工具上保持高效率。

我提到如果你要发布任何库,可能需要了解一些 C++。总体来说,C++是一个在阅读本书后探索的极好主题。在下一章中,我们将看到一个更高级的项目,作为进入更广阔世界的一个台阶。我还会建议你继续扩展你的 C 和 Arduino 技能时考虑的一些其他主题。

¹ 如果你倾向于或者需要在更长的距离上进行通信,肯定有更多的高级选项可供选择。

² 分叉编写 更合适。Adafruit 库基于 Mike McCauley 编写的AirSpayce RadioHead库。

第十二章:接下来的步骤

首先,恭喜你走到了这一步!我们已经游览了 C 编程语言和 Arduino 微控制器生态系统的广阔领域。还记得那个第一个“Hello, World”程序或者第一个闪烁的 LED 吗?现在你对这两个世界的了解更加深入了,我希望你渴望继续扩展你的技能。

在这最后一章中,我们将看看最后一个项目,将你的 Arduino 技能与物联网连接起来。物联网的世界每天都在增长,将提供丰富的机会让你尝试新事物,但我们也将涵盖一些你可能感兴趣的其他话题。

中级和高级话题

从这里开始,你可以选择很多不同的路径。如今可用的传感器和显示设备的种类真是令人惊讶。去探索吧!你会发现自己的灵感和要完成的项目,这将带来更多的探索和灵感。我最享受的冒险来自于特定的项目想法。我想要一个在万圣节服装中使用的动态 LED 沙漏,所以我找到了一个适合的可穿戴微控制器和一些密集的 LED 条[¹]。我在那个项目中与 LED 玩得很开心,所以我决定为我的后院创造一些防水照明。由于像我们这样预算有限的制造商可以使用 WiFi,我甚至可以让客人选择颜色和其他效果。WiFi 功能的成功反过来推动我创建了一个迷你气象站来满足我内心的气象学家。

所有这些项目成功的关键是选择一个相对集中的目标。例如,在下一节中,我们将通过一个简单的项目进入物联网的世界,从我们已经使用过的 TMP36 组件获取温度读数,并通过 WiFi 报告到云服务。如果你真的想通过本书中的项目和示例所获得的新知识和技能,来巩固你的学习成果,那就选择自己的小项目,并将其变为现实!

物联网和 Arduino

如果没有代码就不算是一章,所以让我们来看看最后一个项目,介绍一些非常有趣的路径,你可以探索一下,然后开始制作自己的小工具。物联网正在蓬勃发展,而 Arduino 正好适合在这个领域中发挥作用。让我们来看看我的简化版气象站项目。我们将使用一个支持 WiFi 的微控制器来向云端 API 报告传感器数据。

此项目的电路非常简单。我们需要一个支持 WiFi 的微控制器或 WiFi 断开连接器,您可以像我们在“导入自定义库”中使用 RF 断开连接器一样将其连接到控制器。我选择了来自 Adafruit 的HUZZAH32 Feather。除了集成的 WiFi 支持外,它还具有一些令人印象深刻的规格,如超过 500KB 的 SRAM 和 4MB 的闪存。传感器与我们在“传感器和模拟输入”中使用的 TMP36 相同。我还添加了一个 OLED 显示屏,这样我就可以在不被绑定到计算机访问串行监视器的情况下观看输出,但这个显示屏绝对是可选的。图 12-1 显示了接线图和我的实际“站点”在面包板上运行的情况。

smac 1201

图 12-1. 连接 HUZZAH32、TMP36 和 OLED

OLED 使用 Adafruit 提供的库,您可以通过 IDE 的“管理库”对话框导入。在搜索字段中输入SSD1306,然后查找“Adafruit SSD1306”库。它应该在列表的靠前位置。

我们还需要选择一个云服务提供商并找到与他们通信的库。我在这类项目中使用Adafruit.io,但任何物联网云服务都可能适用。例如,AWS、Google 和 Azure 都有物联网解决方案。

对于 Adafruit.io,我们可以使用库管理器找到我们的通信库。搜索“adafruit io arduino”,然后向下滚动一点找到名为“Adafruit IO Arduino”的实际库。安装此库需要安装一些依赖项,如 HTTP 和消息队列库,但库管理器会自动处理并提示您安装这些依赖项。您可能已经安装了列出的某些依赖项,例如 NeoPixel 库,但库管理器还不够智能,无法仅显示缺少的依赖项。但是,当您安装依赖项时,只会添加缺少的部分。

我不会详细介绍注册的细节,但是一旦您在所选提供商那里有了帐户,几乎肯定需要一些凭据来配置库。例如,Adafruit.io 需要一个唯一的用户名和访问密钥。让我们将这些云服务信息放在一个单独的config.h文件中,我们还可以在其中包含我们的 WiFi 详细信息:

#include "AdafruitIO_WiFi.h"

#define IO_USERNAME  "userNameGoesHere"
#define IO_KEY       "ioKeyGoesHere"
#define WIFI_SSID    "HomeWifi"
#define WIFI_PASS    "password"

幸运的是,该库还包含一个更通用的 WiFi 库作为依赖项。对我们来说,这种双重用途非常好—我们不必分开配置 WiFi 和云访问。但我们仍然需要做一些设置工作,以确保可以与云进行通信。我们将在setup()函数中添加该代码,以及用于使用我们的精美 OLED 显示器所需的内容。像往常一样,随意自己输入此内容,或者获取ch12/temp_web/temp_web.ino

#include <SPI.h> ![1](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/1.png)
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// Use credentials from config.h to set up our feed #include "config.h" ![2](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/2.png)
AdafruitIO_WiFi io(IO_USERNAME, IO_KEY, WIFI_SSID, WIFI_PASS);
AdafruitIO_Feed *smallerc = io.feed("smallerc");

// Set up our OLED #define SCREEN_WIDTH 128 // OLED width, in pixels #define SCREEN_HEIGHT 32 // OLED height, in pixels #define OLED_RESET     4 // Reset pin # #define SCREEN_ADDRESS 0x3C // 128x32 screen Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT,  ![3](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/3.png)
    &Wire, OLED_RESET);
char statusline[22] = "Starting...";

// A few things for keeping an average temperature reading #define ADJUST 3.33 /* my office reads about 3 degrees C cold */
float total = 0.0;
int   count = 0;

void setup() {
  Serial.begin(115200);
  // SSD1306_SWITCHCAPVCC = generate voltage from 3.3V internally
  if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) {
    Serial.println(F("SSD1306 allocation failed"));    ![4](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/4.png)
    for(;;); // Don't proceed, loop forever
  }

  // Show Adafruit splash screen initialized by the display library
  display.display();

  // Now set up the connection to adafruit.io
  Serial.print("Connecting to Adafruit IO");
  io.connect();                                        ![5](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/5.png)
  // wait for a connection
  while(io.status() < AIO_CONNECTED) {
    Serial.print(".");
    delay(500);
  }

  // we are connected
  Serial.println();
  Serial.println(io.statusText());

  // Set up our display for simple (if small) text
  display.clearDisplay();
  display.setTextSize(1);      // Normal 1:1 pixel scale
  display.setTextColor(SSD1306_WHITE); // Draw white text
  display.setCursor(0, 0);     // Start at top-left corner
  display.cp437(true);         // Use 'Code Page 437' font
  display.println(statusline); // Show our starting status
  display.display();           // Update the actual display }

void loop() {
  // put your main code here, to run repeatedly:
  int reading = analogRead(A2);                        ![6](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/6.png)
  float voltage = reading / 1024.0;
  if (count == 0) {
    total = voltage;
  } else {
    total += voltage;
  }
  count++;
  float avg = total / count;
  float tempC = (avg - 0.5) * 100;
  float tempF = tempC * 1.8 + 32;
  if (count % 100 == 0) {
    // Update our display every 10 seconds ![7](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/7.png)
    display.clearDisplay();
    display.setCursor(0, 0);
    display.println(statusline);
    display.print(reading);
    display.print("  ");
    display.println(voltage);
    display.print(tempC);
    display.println("\370 C");
    display.print(tempF);
    display.println("\370 F");
    display.display();
    strcpy(statusline, "Reading...");
  }
  if (count % 600 == 0) {
    // Update our IoT feed every minute
    smallerc->save(tempF);                             ![8](https://gitee.com/OpenDocCN/ibooker-c-cpp-zh/raw/master/docs/sml-c/img/8.png)
    strcpy(statusline, "Feed updated");
  }
  delay(100);
}

1

包括与我们的 OLED 通信所需的各种头文件。

2

使用config.h中的凭据创建一个 I/O 对象(io),并建立到 Adafruit IO 服务的连接,然后指定我们将更新的 feed。

3

使用常量和引用Wire库来实例化我们的display对象。

4

尝试连接显示器。如果连接失败,请打印错误消息并停止执行。如果在此处卡住,可能是电路连接问题。请再次检查连接,并确保显示器有电源。

5

确保我们可以使用2的 feed。等待(可能永远)直到准备就绪。如果无法连接,可以尝试在浏览器中使用您的凭据来验证用户和密钥的组合是否有效。您也可以使用单独的项目测试 WiFi 连接。在 IDE 的文件菜单下,查找示例子菜单,找到您的板子,并选择像HTTPClient这样简单的示例。

6

从 TMP36 传感器读取当前的模拟值,并更新平均运行温度。

7

使用与我们用于Serial的函数类似的 API,在我们的显示器上更新一些关于温度和当前云状态的好文本。

8

每隔一分钟,向我们的 feed 报告华氏温度。通过loop()的下一次,我们将在我们的 OLED 状态行中注意到更新。

您当然可以将像 HUZZAH32 这样的控制器变成自己的 Web 服务器,并直接在浏览器中获取读数,但 Adafuit.io 等服务使得获取更复杂的报告变得简单,例如在几分钟内显示的温度小图表(见图 12-2)。

smac 1202

图 12-2. 我们报告的温度图表

这个小项目只是对物联网的极简介绍。说实话,仅仅通过 IFTTT 可能就可以填写一本书了。物联网书籍和博客文章随处可见,涵盖了从小设备用户界面设计到企业网格配置的所有内容。这是一个有趣、充满活力的领域,如果你对更多了解感兴趣,你当然有能力深入研究。

Arduino 源代码

在这里有许多精彩的 Arduino 项目,它们的作者们大多出于创造的乐趣在进行。他们将硬件规格和源代码放在网上,并积极参与Arduino 论坛。我真诚地鼓励你阅读其中一些源代码。你现在有能力理解这些项目中的代码,看看其他程序员是如何解决问题的,这可以帮助你学到新的技巧。

你还可以访问 Arduino IDE 支持的许多板子的源代码。各种 ArduinoCore 软件包涵盖了我们在“Arduino 环境”中讨论的涉及 C 和 C++内容的内容。确实,这将是密集的阅读,但你可能会惊讶地发现你可以学到多少基础知识。

其他单片机

当然,Arduino 并不是唯一的单片机游戏。Mitchel Davis 在 YouTube 上有一个非常有趣的系列,记录了他从 Arduino 编程到像 STM8 这样的更受限制的控制器的旅程。他的示例通常是用 C 语言编写的,你可以看到我们讨论过的像位操作符这样更为难懂的主题。

向更强大的控制器方向发展,Raspberry Pi 平台也值得一提。这些小型计算机是完整的桌面系统,能够运行完整的 Linux 发行版,包括运行所有的开发者工具,如gcc。更重要的是,Pi 具有与我们在本书中使用的微控制器相同类型的通用 I/O 连接(GPIO)。你可以使用相同类型的传感器和输出,并编写 C 程序来驱动它们。而且你可以直接在连接外设的硬件上编译这些程序!你可以实现一些非常聪明的项目,比如魔镜,并添加运动检测器,这样只有有人靠近时镜子才会点亮,使其变得更加神奇。

如果没有别的,我希望这本书能让你有信心去尝试处理这些类型的项目。这是一个令人满足的世界,适合精通自己的技能。与遍布全球的企业工程项目不同,你可以专注于从我们众多的示例中真正学习诸如 Metro Mini 控制器之类的细节。你不需要八种不同的工具链来让 LED 闪烁。你也不需要十几名程序员来调试光敏电阻夜灯。正如本书的一位评论者 Alex Faber 所说,没有多余的东西妨碍你的技艺。我完全同意他的观点。

工业中的 C/C++

你也不仅仅在家里摆弄 C 语言。亚瑟·C·克拉克的许多未来(《2001:太空奥德赛》,《2010:奥德赛二》)现在已经成为我们的过去,但计算机和人工智能在我们的现实中占据了重要位置。如果你有兴趣将 C 编程作为职业发展,搜索任何技术职位网站,你会发现从初级职位到资深架构师的数百个 C 程序员职位。你可以在 Linux 内核组实习,或者帮助程序嵌入式控制器。你可以在制造全球最大工厂的传感器程序中找到一份工作。

维护遗留代码仍然需要优秀的 C 程序员,并且这些程序员能够通过这项工作为他们的后代创造出丰厚的财富。游戏系统需要非常快速的代码,无论是游戏引擎还是运行在上面的主机。

超级计算机和微控制器在各种环境中都使用 C 语言。虽然微控制器需要高效的代码更加明显,但大型超级计算机希望每一个 CPU(或者现在的 GPU)的周期都能用来完成它们的计算任务。C 语言在提供这种级别的控制方面表现突出,公司知道他们需要擅长让昂贵的机器快速运行的程序员。如今几乎所有领域都是计算机化的,无论是在推动硬件极限的领域(最小的、最深的、最冷的、最快的等),你都可以找到 C 程序员帮助推动这些界限。

回到未来

自从上世纪 70 年代 C 语言首次开发以来,许多语言已经出现。未来的年代必然会出现更多的语言。C 语言之所以依然重要,正是因为它提供了额外的控制和速度。像 Java 这样的“先进”语言也能够加载像我们为 Arduino 编写的相同类型的 C 编写的本地库。Go 语言也可以调用 C 函数。在使用 Rust 处理嵌入式系统时,如果只有一个组件支持 C,Rust 也可以引入 C。

当今计算机编程领域无处不在的 C 语言,从其无处不在的控制语句到通过本地库的集成,了解 C 语言将使您与这个世界的更多内容联系起来,这可能超出了您的想象。在结束时,我只能说希望您继续想象。想象新的项目。想象新的库。然后将那种想象力应用到硬件上。C 语言是实现这些数字梦想的强大工具。希望通过这本书的学习,您能自信地使用它。

¹ 再次来自 Adafruit,我使用了Gemma M0并切割了他们的每米 144 个 LED RGBW 灯条之一,将沙漏缝在衬衫上。我用USB 电池包为整个装置供电,在连续运行四个多小时后,电量几乎没怎么用。

附录 A. 硬件和软件

我尝试指出我首次使用任何特定的硬件部件或软件包,但我也想给你一个快速的各种组件列表,以便参考。我没有因提到任何产品而得到报酬,各个所有者和制造商也不认可我的书。这里我表达的赞赏意见完全是我自己的。 😃

获取代码

C 示例和 Arduino 示例都可以在https://github.com/l0y/smallerc上找到。 大多数示例都有链接到其特定文件,但您也可以使用 Figure A-1 中显示的下拉菜单下载存档。

对于 C 示例,实际上没有其他事情要做。 您可以在所选编辑器中打开任何示例。 您可以进行更改并保存更改,然后在同一文件夹中立即编译示例。

对于各种示意图,您可能希望在工作时将每个示意图文件夹拖到 Arduino Sketchbook 位置。 (此位置设置在 Arduino IDE 首选项中,如来自“The libraries folder”的 Figure 11-9 所示。)这将确保您可以访问安装的任何库。 这也意味着您完成书后可以在“通常的位置”查看这些项目。

smac aa01

图 A-1. 从 GitHub 下载示例存档

获取硬件:Adafruit

我在本书中的许多示例中使用的大部分实体设备来自 Adafruit。 他们拥有令人惊叹的控制器和组件选择,以及一些在网上可以找到的最有趣、最全面和最“书呆子”的教程。 我大部分时间都在他们的网站直接购物,但您也可以通过AmazonDigi-Key找到他们的许多零件。 Table A-1 列出了我在许多 Arduino 项目中使用的微控制器。 Table A-2 列出了按章节使用的外围设备和组件。 如果我在多个项目中使用一个组件,我会在每个出现的章节中列出它。

表 A-1. 微控制器和原型

微控制器 规格
Metro Mini (2x) ATmega328,32KB 闪存,2KB SRAM
Trinket M0 32 位 ATSAMD21E18,256KB 闪存,32KB SRAM
HUZZAH32 Feather Tensilica LX6,WROOM32,HT40 WiFi,4MB 闪存,520KB SRAM,蓝牙
原型设备
全尺寸面包板 带电源轨的标准面包板
半尺寸面包板 带电源轨的紧凑面包板
连接线 包含各种长度和 Molex 插头的捆绑线

表 A-2. 外围设备和组件

组件 规格
第八章
100Ω电阻 碳纤维穿孔
470Ω电阻 碳纤维穿孔
蓝色 LED 直径 3mm,前向电压约 3.0V
NeoPixel Flora 单个 RGB NeoPixel LED
NeoPixel 棒 8 RGB NeoPixel LEDs,棒状安装
NeoPixel 环 24 RGB NeoPixel LEDs,环形安装
第九章
RGB LED 5mm,公共阴极
470Ω电阻 碳纤维穿孔
TMP36 温度传感器 模拟,范围-50 到 125 摄氏度
四位数码显示器 红色,MAX7219 驱动器(VMA425 的替代品)
触摸按钮 简单按键
NeoPixel 环 24 RGB NeoPixel LEDs,环形安装
第十章
NeoPixel 棒 8 RGB NeoPixel LEDs,棒状安装
第十一章
RFM69HCW 收发器 (2x) 900MHz(美洲地区)
导航开关 穿孔式,5 向
橡胶小钮帽 适合导航摇杆
TT 电机轮 (2x) 橙色辐条,透明轮胎
TT 电机齿轮箱 (2x) 200 RPM,3 - 6VDC
电机驱动板 DRV8833 芯片
TT 电机底盘 铝制,紫色
第十二章
OLED 显示器 0.91 英寸,128x32 像素,I2C
TMP36 温度传感器 模拟,范围-50 到 125 摄氏度

总的来说,我尽量坚持使用容易设置和修改的面包板项目。如果你建造了稳定的东西,并想通过摆脱面包板来体现这种稳定性,我强烈推荐Adafruit 卓越焊接指南。他们提供了一些关于硬件和技术的极好建议,专业的业余爱好者用来创建更加持久的项目。我会在他们出色指南中增加一点强调:在焊接铁章节中,“最佳铁”确实是最好的。它们确实更昂贵,所以不适合每个人,但如果你能负担得起,像Hakko FX-888D这样的铁将焊接引脱颖而出,使之成为一种冥想艺术。

VS Code

我的非 Arduino 编码是使用来自 Microsoft 的 Visual Studio Code 进行的。虽然它由 Microsoft 编写,但 VS Code 也适用于 Linux 和 macOS。它高度可配置,并且有一个充满活力的扩展生态系统,几乎覆盖了所有可能的编程语言和 Web 开发框架。如果什么都不是的话,“C/C++”扩展非常适合处理 C 语言。

Arduino IDE

在整本书中,我依赖 Arduino IDE 来编译和上传微控制器项目。Arduino IDE 跨平台,并且对来自许多不同供应商的广泛微控制器有出色的支持。

Arduino 网站还有一个有用的 Language Reference 和几个 tutorials,涵盖从简单的“入门”主题到更高级的深入研究 Arduino 平台的技术。

注意

对于真正喜欢 VS Code 环境的人来说,需要指出,对于 PlatformIO 的热情正在增长。根据他们的关于页面:“PlatformIO 是一款跨平台、跨架构、支持多框架的专业工具,面向嵌入式系统工程师和为嵌入式产品编写应用程序的软件开发者。”

它有独立选项,但也有成熟的 VS Code 扩展。你可以在他们的 VS Code Integration 页面找到更多详细信息。

Fritzing

你可能已经注意到我们的布线图中用可爱的电路图案来代替字体的“fritzing”这个词。如果你建立了任何项目并决定与他人分享,你可以自己创建这些类型的图表。 Fritzing 的好人们创建了我使用的软件。这款设计应用程序跨平台,并且许多第三方为控制器和组件库创建了非常可观的视觉贡献。特别是如果你有使用其他设计和布局工具(如 OmniGraffleInkscape)的经验,它也非常直观。他们要求(非常!)适度的费用,如果你有能力支付,我觉得非常值得。

你也可以在线找到丰富的与 Fritzing 兼容的组件。他们的论坛包含了一些出色的高质量贡献,例如由 Desnot4000 贡献的用于 Figure 9-5 的 4 位数 7 段显示器。如果需要,你还可以导入 SVG 文件创建自定义组件。

如果你真的开始进行自己的电子项目,Fritzing 的软件也可以用来制作定制电路板。你的业余爱好从未显得如此专业!致力于使硬件开放和更多人可访问,我对这个团队及其更广泛的用户社区印象深刻。

GNU 编译器集合

最后但绝对不是最不重要的,我使用了来自极为实用的GNU 编译器套件,即 GNU C 编译器(Arduino IDE 也使用它)。正如你可能在“所需工具”中注意到的那样,在某些平台上安装这些工具可能需要一些努力,但是这些编译器的广度和质量是无与伦比的。再加上它们的开源精神,GNU 软件确实难以在任何可用的地方被超越。

附录 B. printf() 格式说明符细节

printf() 函数支持的格式几乎构成了它们自己的语言。 虽然不是详尽的列表,本附录详细描述了我在本书中使用的所有选项。 我还描述了这些选项如何与不同类型的输出配合工作,即使我没有使用某个组合。 就像编程中的许多内容一样,自己尝试一下是很有用的,以便了解各部分如何配合。

代码示例包括一个简单的 C 程序,演示了更流行的标志、宽度、精度和类型组合。 您可以按原样编译和运行 popular_formats.c,或者您可以编辑它以调整某些行并测试自己的组合。

如果您想更多了解您可以在 printf() 中指定的事项,包括非标准和特定于实现的选项,我建议查看专门介绍此主题的维基百科页面。

说明符语法

我在本书中使用的说明符包含三个可选元素和一个必需的类型,排列如下:

% flag(s) width . precision type

再次强调,并不需要标志(或标志)、宽度和精度。

说明符类型

当使用 printf() 时,打印给定值的解释取决于您使用的类型说明符。 例如,值 65 使用 %c(字符)会打印为字母“A”,但使用 %x(十六进制整数)会打印为“41”。 表 B-1 概述了本书中使用的类型,尽管这不是详尽的列表。

表 B-1. printf() 的格式说明符类型

说明符 类型 描述
%c char 打印单个字符
%d char, int, short, long 以十进制(基数 10)打印有符号整数值
%f float, double 打印浮点数值
%i char, int, short 以十进制(与 %d 相同的输出)打印整数值
%o int, short, long 以八进制(基数 8)打印整数值
%p 地址 打印指针(作为十六进制地址)
%s char[] 将字符串(char 数组)打印为文本
%u unsigned (char, int, short) 以十进制打印无符号整数值
%x char, int, short, long 以十六进制(基数 16)打印整数值

%i%u 整数类型可以使用长度修饰符。 lll(例如 %li%llu)告诉 printf() 期望 longlong long 长度的参数。 对于浮点类型,可以使用 L(例如 %Ld)来指示 long double 参数。

说明符标志

您指定的每种类型都可以使用一个或多个标志进行修改。 表 B-2 列出了说明符标志。 并非所有标志对每种类型都有效,所有标志都是可选的。

表 B-2. printf() 的格式说明符标志

说明符 描述
- 在其字段内左对齐输出
+ 强制在正数值前加上加号(+)前缀
(space) 强制在正数值前加空格前缀(与完全没有前缀相对应)。
0 在数值左侧用 0 填充(如果它们没有填满指定宽度的字段)。
# 在与 oxX 类型一起使用时打印前缀(00x0X)。

当你有数值、列式输出时,更常见使用这些标志,尽管像“-”这样的标志也可以用在字符串上。

宽度和精度

对于任何说明符,你可以为输出字段提供一个最小宽度。(最小限定符意味着对于大于给定宽度的值不会发生截断。)默认是右对齐输出,但可以通过在表 B-2 中提到的-标志进行更改。

你也可以提供一个精度,这会影响输出的最大宽度。对于浮点类型,它指定小数点右侧的数字位数。对于字符串,它会截断过长的值。对于整数类型,它会被忽略。

常见格式

若要查看一些更常见或流行的格式的实际应用,请看一下appB/popular_formats.c。这只是一个大批量的 printf() 调用,但它包含了使用不同格式说明符的各种示例。我在这里不会列出源代码,但输出可供快速参考:

appB$ gcc popular_formats.c
appB$ ./a.out
char Examples
  Simple char:             %c       |y|
  In a 9-char field:       %9c      |        y|
  Left, 9-char field:      %-9c     |y        |
  The percent sign:        %%       |%|

int Examples
  Simple int:              %i       |76|
  Simple decimal int:      %d       |76|
  Simple octal int:        %o       |114|
  Prefixed octal int:      %#o      |0114|
  Simple hexadecimal int:  %x       |4c|
  Uppercase hexadecimal:   %X       |4C|
  Prefixed hexadecimal:    %#x      |0x4c|
  Prefixed uppercase:      %#X      |0X4C|
  In a 9-column field:     %9i      |       76|
  Left, 9-column field:    %-9i     |76       |
  Zeros, 9-column field:   %09i     |000000076|
  With plus prefix:        %+i      |+76|
    negative value:                 |-12|
  With space prefix:       % i      | 76|
     negative value:                |-12|
  Huge number:             %llu     |28054511505742|
  (Ignored) precision:     %1.1d    |76|

float Examples
  Simple float:            %f       |216.289993|
  2 decimal places:        %.2f     |216.29|
  1 decimal place:         %.1f     |216.3|
  No decimal places:       %.0f     |216|
  In a 12-column field:    %12f     |  216.289993|
  2 decimals, 12 columns:  %12.2f   |      216.29|
  Left, 12-column field:   %-12.2f  |216.29      |

string (char[]) Examples
  Simple string:           %s       |Ada Lovelace|
  In a 20-column field:    %20s     |        Ada Lovelace|
  Left, 20-column field:   %-20s    |Ada Lovelace        |
  6-column field:          %6s      |Ada Lovelace|
  6-columns, truncated:    %6.6s    |Ada Lo|

And last but not least, a blank line (\n):

Wikipedia 页面上关于printf 格式字符串有一个详尽的选项概述。

posted @ 2024-06-18 17:34  绝不原创的飞龙  阅读(35)  评论(0编辑  收藏  举报