硬件
- 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"
要记住,预处理器只处理文本——无法理解那些特殊字母和数字的含义。最重要的是,它对所包含的内容和包含次数绝对不会进行更高级别的检查。让我们来看一下使用编写不正确的库会发生什么。
- #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
错误信息显示变量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”的常量。这次,由于上一个#include命令已经定义了该常量,所以预处理器不会向程序中添加任何内容。于是,编译成功完成。#ifdef 和 #endif是条件指令,我们稍后将对此进行讨论。
定义事物:#define 指令
在上一个例子中,我们用#define指令创建了一个常量,以决定是否包含一个库。在官方文档中,任何由#define指令定义的东西都被称为macro(宏), 因此本文中我会一直沿用这个术语。该指令的语法如下:
- #define macro_name macro_body
- #define X 10
- int Y = 10;
- int Y = X;
- #define X 10
- int Z = X;
- void setup() {
- }
- void loop() {
- }
预处理后的代码如下所示:
- int Y = X;
- int Z = 10;
- void setup() {
- }
- void loop() {
- }
尽管#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
创建宏时,您必须记住,系统将宏作为文本进行处理。这就是为什么在上面的定义中,几乎所有内容都包含在括号中。请猜测以下运算的结果。
- #ifndef MULTIPLY
- #define MULTIPLY(A, B) A * B
- #endif
- //some code...
- int result = MULTIPLY(2 - 0, 3);
- int result = 2 - 0 * 3;
- #ifndef MULTIPLY
- #define MULTIPLY(A, B) ((A) * (B))
- #endif
在前面的例子中,我使用了#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
我们来看一个实际的例子。假设您编写了一个库,并且希望它在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
提供反馈:#warning 和 #error 指令
我们最后要介绍的指令是#warning 和 #error。两者但是不言自明,语法如下:
- #warning "message"
- #error "message"
我们可以在前文的例子中使用这两个语句:
- #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
结论
在本文中,我们介绍了C/C++预处理器的相关知识。希望您看过本文之后,就不会再害怕编译、预处理器、或指令等术语了。我总结一下本文描述的最重要的几点内容:
- 编写库时,请务必将其放在 #ifndef – #define – #endif结构中。这个结构我们已经见过多次了。这可能会为您省去一些麻烦。定义类似函数的宏时同样应该这样做。
- 编写代码时,应确保程序易于移植到其他Arduino板上。相信我,未雨绸缪要比出现不兼容问题之后再想法解决要容易得多。
- 分而治之!几个较小的文件总比一个1000多行的大文件要好得多。
Jan Gromes
Jan目前在布尔诺理工大学学习电气工程。他拥有多年使用Arduino和其他微控制器构建项目的经验。他的特殊兴趣在于机器人系统的机械设计。
来源:techclass.rohm