电源设计技术信息网站

订阅电子杂志   English   繁體中文   日本語   한국어

下载中心

TECH INFOArduino入门指南

Arduino预处理器指令教程

这篇文章来源于DevicePlus.com英语网站的翻译稿。

arduino preprocessor

适用于ROHM传感器评估套件的轻量级Arduino库中,我介绍了RohmMultiSensor——帮您轻松连接ROHM传感器评估套件多个传感器的Arduino库。该库的核心特征之一就是通过仅编译与所需传感器相关的库部分,显著减小程序的大小。这意味着当您使用较少的传感器时,整体程序大小和内存使用量会减小。但是,这究竟是如何实现的呢?当您#include一个库然后按下“Upload”(上传)按钮之后,幕后究竟会发生什么?

硬件

  • Arduino UNO

 

软件

  • Arduino IDE

几乎所有用过Arduino的人都使用过库。这就是Arduino编程对初学者来说如此简单的原因之一——您无需深入了解传感器的工作原理;库会替您完成大部分工作。将代码分成单独的文件也是一种很好的编程习惯。组织、调试和维护单个文件要比处理一大堆代码容易得多。

想必Arduino初学者都已经熟悉了将库添加到主程序中的#include命令。要了解这是如何实现的,我们首先应快速了解C/C++源代码如何编译成程序。别担心,这听起来比较复杂,其实很简单。我们来看一下编译的工作原理。

按“上传”之后

我们先做一个快速实验:启动Arduino IDE,打开其中一个示例代码(比如“Blink”),然后按“Verify”按钮。假设程序中没有语法错误,底部的控制台应该会打印出有关程序大小和内存的一些信息。嗯,刚才我们成功地将C++源代码编译成了二进制文件。在编译过程中发生了以下几件事:

  1. Arduino IDE执行了一种名为“语法检查”的操作,以确保您编写的程序是真正的C/C++源代码。此时,如果发生函数拼写错误或忘记分号,那么编译就会停止。
  2. 语法检查之后,Arduino IDE会启动另一个名为preprocessor(预处理器)的程序。这是一个非常简单的程序,如果文件是C/C++源代码,它不会怎么样。我们稍后会详细讨论这一步骤。那么现在我们假设结果是一个名为“扩展源代码”的文件——一个文本文件。
  3. 然后,该扩展源代码被移交给另一个名为compiler(编译器)的程序。该编译器(在Arduino IDE中是avr-gcc)接收文本源,并生成汇编文件。汇编一种人类可读的低级编程语言,但是更接近机器代码——适用于特定处理器的指令。这里就是您编写程序之前必须选择正确Arduino板的原因——不同的开发板具有不同的处理器,而处理器又具有不同的指令集。
  4. 处理您Arduino程序下一个的系统程序叫做assembler(汇编程序)。该程序会生成一个“目标文件”。该文件主要是机器代码,但也可以包含针对其他目标文件对象的“引用”。这允许Arduino IDE“预编译”一些编写Arduino程序时会始终用到的库,从而使整个过程更快。
  5. 最后一个阶段称为链接,由另一个名为linker(链接器,显而易见)的程序完成。链接器获取目标文件并添加缺少的内容——主要是来自其他目标文件的符号,以生产可执行文件。在此之后,程序完全转换为机器代码,并可以上传到电路板。

arduino preprocessor

现在,我们对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程序时会发生什么呢?

arduino preprocessor

错误信息显示变量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() {
 
}

 

编译上述代码会发生以下错误:

arduino preprocessor

预处理后的代码如下所示:

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++预处理器的相关知识。希望您看过本文之后,就不会再害怕编译预处理器、或指令等术语了。我总结一下本文描述的最重要的几点内容:

  1. 编写库时,请务必将其放在 #ifndef – #define – #endif结构中。这个结构我们已经见过多次了。这可能会为您省去一些麻烦。定义类似函数的宏时同样应该这样做。
  2. 编写代码时,应确保程序易于移植到其他Arduino板上。相信我,未雨绸缪要比出现不兼容问题之后再想法解决要容易得多。
  3. 分而治之!几个较小的文件总比一个1000多行的大文件要好得多。
DevicePlus 编辑团队
Jan Gromes

Jan目前在布尔诺理工大学学习电气工程。他拥有多年使用Arduino和其他微控制器构建项目的经验。他的特殊兴趣在于机器人系统的机械设计。

分享到社交媒体