这篇文章来源于DevicePlus.com英语网站的翻译稿。
在适用于ROHM传感器评估套件的轻量级Arduino库中,我介绍了RohmMultiSensor——帮您轻松连接ROHM传感器评估套件多个传感器的Arduino库。该库的核心特征之一就是通过仅编译与所需传感器相关的库部分,显著减小程序的大小。这意味着当您使用较少的传感器时,整体程序大小和内存使用量会减小。但是,这究竟是如何实现的呢?当您#include一个库然后按下“Upload”(上传)按钮之后,幕后究竟会发生什么?
硬件Arduino UNO
软件
Arduino IDE
几乎所有用过Arduino的人都使用过库。这就是Arduino编程对初学者来说如此简单的原因之一——您无需深入了解传感器的工作原理;库会替您完成大部分工作。将代码分成单独的文件也是一种很好的编程习惯。组织、调试和维护单个文件要比处理一大堆代码容易得多。
想必Arduino初学者都已经熟悉了将库添加到主程序中的#include命令。要了解这是如何实现的,我们首先应快速了解C/C++源代码如何编译成程序。别担心,这听起来比较复杂,其实很简单。我们来看一下编译的工作原理。
按“上传”之后我们先做一个快速实验:启动Arduino IDE,打开其中一个示例代码(比如“Blink”),然后按“Verify”按钮。假设程序中没有语法错误,底部的控制台应该会打印出有关程序大小和内存的一些信息。嗯,刚才我们成功地将C++源代码编译成了二进制文件。在编译过程中发生了以下几件事:
Arduino IDE执行了一种名为“语法检查”的操作,以确保您编写的程序是真正的C/C++源代码。此时,如果发生函数拼写错误或忘记分号,那么编译就会停止。
语法检查之后,Arduino IDE会启动另一个名为preprocessor(预处理器)的程序。这是一个非常简单的程序,如果文件是C/C++源代码,它不会怎么样。我们稍后会详细讨论这一步骤。那么现在我们假设结果是一个名为“扩展源代码”的文件——一个文本文件。
然后,该扩展源代码被移交给另一个名为compiler(编译器)的程序。该编译器(在Arduino IDE中是avr-gcc)接收文本源,并生成汇编文件。汇编一种人类可读的低级编程语言,但是更接近机器代码——适用于特定处理器的指令。这里就是您编写程序之前必须选择正确Arduino板的原因——不同的开发板具有不同的处理器,而处理器又具有不同的指令集。
处理您Arduino程序下一个的系统程序叫做assembler(汇编程序)。该程序会生成一个“目标文件”。该文件主要是机器代码,但也可以包含针对其他目标文件对象的“引用”。这允许Arduino IDE“预编译”一些编写Arduino程序时会始终用到的库,从而使整个过程更快。
最后一个阶段称为链接,由另一个名为linker(链接器,显而易见)的程序完成。链接器获取目标文件并添加缺少的内容——主要是来自其他目标文件的符号,以生产可执行文件。在此之后,程序完全转换为机器代码,并可以上传到电路板。
现在,我们对Arduino程序编译有了一个基本的了解。但是在上述所有编译阶段中,我们将只关注第二个阶段:预处理器。
预处理器基本知识
在上本中,我提到预处理器本质上非常简单:接收文本输入,搜索关键字,根据找到的内容进行一些操作,然后输出不同的文本。它非常简单,同时也非常强大,因为它允许你用普通C/C++语言完成一些本来会非常复杂的事情(如果可能)。
预处理器会搜索以井号(#)开头且后面有文本的行。这种语句叫做预处理器指令,是预处理器的一种“命令”。预处理器指令的完整列表以及详细文档的地址如下所示:
https://gcc.gnu.org/onlinedocs/cpp/Index-of-Directives.html#Index-of-Directives。
接下来,我将主要关注#include、#define和条件指令,因为这是Arduino最有用的指令。如果您想了解一些更“奇异”的指令,比如#assert 或 #pragma, 请参阅上述地址,以获取官方信息。
添加额外代码:#include 指令
这可能是最著名的预处理器指令,不仅Arduino爱好者都知道,而且C/C++编程人员也都了解。原因很简单:该指令的作用是包含库。但是,这究竟是如何实现的呢?确切的语法如下所示:
#include <file>
或
#include "file"
两者的区别比较小,主要在于预处理器搜索file(文件)的确切位置。如果是第一句,预处理器仅搜索IDE指定的目录。如果是第二句,预处理器首先查看包含源文件的文件夹,且仅当没有在该目录下找到file(文件) 时, 它才会搜索第一句的搜索目录。由于包含库的文件夹是在Arduino IDE中指定的,因此在包含库时两者之间没有重大区别。
当预处理器找到文件时,它只是将其内容复制粘贴到源代码中,以替代程序中的#include指令。但是,如果在任何目录中都找不到此文件,就会引发致命错误,编译停止。
要记住,预处理器只处理文本——无法理解那些特殊字母和数字的含义。最重要的是,它对所包含的内容和包含次数绝对不会进行更高级别的检查。让我们来看一下使用编写不正确的库会发生什么。
#include <ExampleLibrary.h> void setup() { } #include <ExampleLibrary.h> void loop() { }
这个Arduino程序中没有多少内容。请注意我们包含了一个名为“ExampleLibrary.h”的文件,而且我们包含了两次。
//This is an example library int a = 0; //End of example library
“ExampleLibrary.h”的内容如下所示。同样,除了一个整数变量之外,没有多少内容。那么当我们编译这个Arduino程序时会发生什么呢?
错误信息显示变量a声明了两次,这导致编译失败。这是预处理器完成后源代码的样子。
//This is an example library int a = 0; //End of example library void setup() { } //This is an example library int a = 0; //End of example library void loop() { }
显而易见,不应该多次包含库,但是如何在不依赖用户的情况下实现这一目标?标准解决方案是将整个库包含在以下结构中:
#ifndef _EXAMPLE_LIBRARY_H #define _EXAMPLE_LIBRARY_H //This is an example library int a = 0; //End of example library #endif
现在,第一次包含库时,预处理器会检查是否存在用“_EXAMPLE_LIBRARY_H”定义的内容。由于没有类似的东西存在,预处理器继续下一行并定义一个名为“_EXAMPLE_LIBRARY_H”的常量。然后,库代码被复制到程序中。
当第二次包含库时,预处理器会再次检查是否存在名为“_EXAMPLE_LIBRARY_H”的常量。这次,由于上一个#include命令已经定义了该常量,所以预处理器不会向程序中添加任何内容。于是,编译成功完成。#ifdef 和 #endif是条件指令,我们稍后将对此进行讨论。
定义事物:#define 指令
在上一个例子中,我们用#define指令创建了一个常量,以决定是否包含一个库。在官方文档中,任何由#define指令定义的东西都被称为macro(宏), 因此本文中我会一直沿用这个术语。该指令的语法如下:
#define macro_name macro_body
大多数Arduino初学者可能会对宏感到困惑。如果我定义一个宏:
#define X 10
那么这与以下变量声明有什么区别呢?
int Y = 10;
同样,这一切都归结为预处理器仅处理文本。遇到#define指令时,预处理器会搜索其余的源代码并将所有出现的“X”替换为“10”。这意味着与变量不同,宏的值永远不会改变。此外,您必须牢记预处理器只搜索以#define开头的源代码。让我们看一下使用尚未定义的宏会发生什么情况。
int Y = X; #define X 10 int Z = X; void setup() { } void loop() { }
编译上述代码会发生以下错误:
预处理后的代码如下所示:
int Y = X; int Z = 10; void setup() { } void loop() { }
第一行包含X,它被看作一个变量。但是,该变量从未声明,因此编译停止。
尽管#define指令最常见的用途是创建带名称的常量,但是它可以做的远不止这些。例如,假设您想知道两个给定数字中哪一个较小。您可以编写一个实现此功能的函数。
int min(int a, int b) { if(a < b) { return(a); } return(b); }
或者使用更简单的三元运算符:
int min(int a, int b) { return((a < b) ? a : b); }
但是,这两个函数都将被编译并占用宝贵的程序存储空间。我们可以使用以下类似函数的宏来实现相同效果,但是占用的程序空间却会变少。
#ifndef MIN #define MIN(A, B) (((A) < (B)) ? (A) : (B)) #endif
现在,每个“MIN(A, B)”都会被替换为“(((A) < (B)) ? (A) : (B))”,其“A”和“B”可以是数字,也可以是变量。请注意,#define包含在相同的保护性结构中,以防止用户重复定义宏。
创建宏时,您必须记住,系统将宏作为文本进行处理。这就是为什么在上面的定义中,几乎所有内容都包含在括号中。请猜测以下运算的结果。
#ifndef MULTIPLY #define MULTIPLY(A, B) A * B #endif //some code... int result = MULTIPLY(2 - 0, 3);
结果应该是6,因为2–0=2,然后2x3=6,对吧?如果我告诉你结果是2呢?实际编译的内容如下:
int result = 2 - 0 * 3;
由于乘法优先于减法,因此很明显结果肯定是2,因为3x0=0,然后2-0=2。正确的版本如下所示:
#ifndef MULTIPLY #define MULTIPLY(A, B) ((A) * (B)) #endif
条件编译:#if指令
在前面的例子中,我使用了#ifndef指令,于是我可以检查是否已经包含了库。该指令可用于实现仅用C/C++语言不可能实现的内容:条件语句。这些指令的语法如下所示:
#if expression //compile this code #elif different_expression //compile this different code #else //compile this entirely different code #endif
条件语句的常用功能是检查一个宏是否已定义。为此,您可以使用几个专门的指令:
#ifndef macro_name //compile this code if macro_name does not exist #endif
我们已经熟悉了上述内容,因为我们之前使用此指令来检查是否已包含库。您也可以使用这个条件:
#ifdef macro_name //compile this code if macro_name exists #endif
以上语句只是#if defined的简写,可根据单个条件测试多个宏。请注意,每个条件都必须用#endif 指令结束,从而指定代码的哪些部分受条件影响,哪些部分不受条件影响。
我们来看一个实际的例子。假设您编写了一个库,并且希望它在Arduino UNO和Arduino Mega上都能正常工作。这主意不错,对吧?便携代码总比为另一块电路板修改库更容易。但是,如果您的库使用了SPI总线呢?该总线在Arduino UNO上用的是11-13引脚,但是在Mega上却是50-52引脚。
那么您如何告诉编译器根据不同开发板使用相应的引脚呢?您猜对了——条件语法!根据您在Arduino IDE中选择(“Tools” > “Board”菜单)的开发板,IDE将定义不同的宏,从而仅编译与所选开发板相关的代码部分!这非常强大,因为您可以实现以下功能:
#if defined(__AVR_ATmega168__) || defined(__AVR_ATmega328P__) //this will compile for Arduino UNO, Pro and older boards int _sck = 13; int _miso = 12; int _mosi = 11; #elif defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__) //this will compile for Arduino Mega int _sck = 52; int _miso = 50; int _mosi = 51; #endif
怎么样,漂亮吧?仅用三行代码,我们就制作了一个多平台便携库!另外,这正是RohmMultiSensor库(适用于ROHM传感器评估套件的轻量级Arduino库)如何知道应该为所选传感器编译哪些代码。如果您看一下头文件RohmMultiSensor.h里面的内容,您只会看到几个#ifdef和几个#include指令。由于所有特定传感器代码都存储在单独的.cpp文件中,因此将新传感器添加到库中很容易——只需创建另一个文件,然后创建与其他传感器相同的#ifdef – #include – #endif结构即可。完成!
提供反馈:#warning 和 #error 指令
我们最后要介绍的指令是#warning 和 #error。两者但是不言自明,语法如下:
#warning "message"
和
#error "message"
预处理器遇到这些指令时,它会将message打印到Arduino IDE控制台中。两者之间的区别在于,发生#warning之后,编译正常进行,而#error则会完全停止编译。
我们可以在前文的例子中使用这两个语句:
#if defined(__AVR_ATmega168__) || defined(__AVR_ATmega328P__) //this will compile for Arduino UNO, Pro and older boards int _sck = 13; int _miso = 12; int _mosi = 11; #elif defined(__AVR_ATmega1280__) || defined(__AVR_ATmega2560__) //this will compile for Arduino Mega int _sck = 52; int _miso = 50; int _mosi = 51; #else #error “Unsupported board selected!” #endif
这样,当用户尝试为其他Arduino开发板(比如Yún、LilyPad等)编译该库时,编译会失败,与没有定义SPI引脚没有任何关系。
结论
在本文中,我们介绍了C/C++预处理器的相关知识。希望您看过本文之后,就不会再害怕编译、预处理器、或指令等术语了。我总结一下本文描述的最重要的几点内容:
编写库时,请务必将其放在 #ifndef – #define – #endif结构中。这个结构我们已经见过多次了。这可能会为您省去一些麻烦。定义类似函数的宏时同样应该这样做。
编写代码时,应确保程序易于移植到其他Arduino板上。相信我,未雨绸缪要比出现不兼容问题之后再想法解决要容易得多。
分而治之!几个较小的文件总比一个1000多行的大文件要好得多。