12  第十二章 To 数字……

最是人间留不住,朱颜辞镜花辞树。

问题就是我们不可能完全抛开数字的东西只去谈模拟,后者当然很重要,直到今天它也是通讯、汽车电子、航空工业等一系列科技领域的基石,但不得不承认,我们今天所使用的大部分电子设备,是基于数字信号的,即使从上一章控制信号的角度出发,也有必要介绍数字信号和微型控制器,所以接下来我们将会逐步了解并使用到Arduino,用它来实现更多的功能。

12.1 进制

我们已经知道了数字系统表征信息只用到了“0”和“1”(也就是二进制),原因很简单,这样我们只需要高低电平就能代表它们(否则想象一下十进制计算机,要用十个不同的电压值代表数字0到9)。进制之间可以相互转换,人们常用到的有二进制、八进制、十进制和十六进制,想要实现转换,就得建立一个一一对应的规则。

例如对拥有4个位的二进制数而言,从0000到1111共有\(2^4\)种不同取值,我们可以把这些不同的值也分别对应某进制下不同的数,如 表 12.1 所示:

表 12.1: 常见进制对照表
二进制 八进制 十进制 十六进制
0000 0 0 0
0001 1 1 1
0010 2 2 2
0011 3 3 3
0100 4 4 4
0101 5 5 5
0110 6 6 6
0111 7 7 7
1000 10 8 8
1001 11 9 9
1010 12 10 A
1011 13 11 B
1100 14 12 C
1101 15 13 D
1110 16 14 E
1111 17 15 F

从上表我们会看到十六进制在超过9之后并不是“10”,因为十六进制此时还没有发生进位,A也只是代表一个“单”的“数字”而已,直到F过后,十六进制才会进为变成“10”。

基于这套对应方法,人们总结出了进制转换的一般性规则,十进制数要转换成二进制数,就把这个数不断除以2并列出余数,直到商0,然后从下到上排列余数。 图 12.1 展示了 3 个十进制数转换为二进制的过程:

图 12.1: 十进制转二进制

二进制转成十进制只需要上述过程反过来,例如要把二进制\((1001011)_2\) 转为十进制(数字下标表示进制):

\[ \begin{aligned} (1001011)_2 &= 1 \times 2^6 + 0 \times 2^5 + 0 \times 2^4 + 1 \times 2^3 \\ &\quad + 0 \times 2^2 + 1 \times 2^1 + 1 \times 2^0 \\ &= 64 + 8 + 2 + 1 \\ &= (75)_{10} \end{aligned} \tag{12.1}\]

十六进制转换成二进制则更简单一些,因为十六进制数的每个位都可以由4位二进制数表达,例如\((1\text{AF})_{16}\) 转化成二进制就是\((0001\_1010\_1111)_2\) ,同样二进制数要想转为十六进制数,只需要从右到左按照每4位为一组排列(最左边不足4位可向最高位添加0),然后根据 表 12.1 找到对应的十六进制数。

12.2 字节

一个8位(8 bits)的二进制数被称为一个字节,(1 Byte),字节是数字系统的基本单位。

类似地\(2^{10}\) = 1024个字节(1024 bytes)表示为\(1\,\text{KB}\),\(1024\,\text{KB}\) = \(1\,\text{MB}\)、\(1024\,\text{MB}\) = \(1\,\text{GB}\),“GB”也就是我们常说的手机有多少个“G”的容量,如果以字节为基本单位,\(1\,\text{GB}\)表示可以存储\(2^{30}\)个字节:

图 12.2: 容量单位

12.3 逻辑门

逻辑门用于实现基本的逻辑运算(就像十进制的加减乘除),它可以有一个或多个输入并只有一个输出。数字电路和数字系统是基于逻辑门构建的,即便我们不需要自己去构建一个数字系统,学习逻辑门也可以让我们更好地了解数字电路如何一步步实现更加复杂的功能。

首先要了解的是非门(NOT Gate),非门表示对当前逻辑取反,把0变成1或把1变成0,它的符号如下图所示:

图 12.3: 非门

非门符号像一个运放,但顶端有一个小圆圈(小圆圈也叫bubble,表示对信号取反), 图 12.3 右侧是真值表,真值表会列出当前逻辑电路所有可能的输入以及相对应的输出。

然后是与门(AND Gate),与门接收两个输入,并计算它俩的乘积,与门符号如下:

图 12.4: 与门

与门当且仅当输入全都为1时,输出才为1,其余时刻输出都是0,如果在与门后面加上一个泡泡(bubble),它会对与门的输出取反,组成一个与非门:

图 12.5: 与非门

和与门类似,或门(OR Gate)当两个输入有一个为1时,输出就会为1,只有输入全为0输出才是0(注意或门的Y = A + B不是数学意义上的加法,因为二进制下1 + 1 = 10,这里加号表示至少一个为1):

图 12.6: 或门

同样或门加个泡泡就变成了或非门,最后还剩下一个比较有意思的逻辑门,叫异或门(XOR Gate):

图 12.7: 异或门

从真值表可以看到,异或门可以判断输入为1的奇偶个数,奇数个1输入,输出则为1,偶数个(包括0个)1输入,输出为0。

除了非门,上述所有逻辑门都可以拓展到多个输入,例如3输入与门,4输入异或门等等:

图 12.8: 多输入逻辑门

值得一提的是,这些逻辑门在电路上都是通过NMOS和PMOS实现的,可以说半导体技术深刻影响了我们生活的方方面面。

12.4 Arduino UNO 开发板

Arduino UNO R3(Arduino UNO R4已经发行了)诞生于2010年,是一款以ATmega328为核心的开发板,一枚孤零零的ATmega328是没办法工作起来的(就像一块独立的显卡要插在主板上才有用),开发板会在芯片周围布置让它能够正常启动的外围设备,得以让我们专注于逻辑实现而不用花费很多时间在电路细节上。相比于现代高性能的处理器,ATmega328的性能可以说是很差了,它无法胜任对计算能力要求很高的场景,但因为编程上的便利和丰富的社区资源,它依然是我们进入数字系统很好的选择。

在此之前,我们还需要对微型处理器有一定的了解。微型处理器也可以叫微型控制器或微型计算机,计算机这一概念可以追溯到1936年阿兰·图灵发表的一篇论文中,他在文中解释了什么是“计算”和具有“可计算性”的东西,并提出了著名的图灵机模型。

顾名思义,计算机是可以帮助人们进行重复计算的机器,这里的计算和我们进行1 + 1等于几的计算本质上没有不同,都是通过事先定制好的规则,把输入数值的集合变成另一组输出数值集合的过程:

图 12.9: “计算”过程

为了让计算机实现这种“计算”的能力,1945年冯诺伊曼提出了现代计算机的基础架构:包括输入设备和输出设备、内存(用于存储数据和操作指令)、计算单元(执行数学运算)以及控制单元(沟通内存和计算单元,用于实现操作指令)。

冯诺伊曼架构把数据和操作指令放在了同一个内存片上,如果把它俩分开单独存储,就构成了所谓的哈佛架构,ATmega328就是一款基于哈佛架构的微型控制器:

图 12.10: 哈佛架构示意图

了解这些之后,我们再来看到Arduino UNO开发板:

图 12.11: UNO开发板示意图

在 图 12.11 中,ATmega328 位于右下角,左上方银白色接口可以连接电脑用于给开发板供电和下载程序(也就是操作指令),左上方的红色按钮是复位按钮,就像电脑开关机键一样,它可以让开发板重新运行程序以消除某些意外情况,然后最重要的是围绕开发板上下两侧的输入输出口,标有“POWER”字样的接口可以在开发板上电后对外供电,例如供电5伏或3.3伏以及0伏(GND),A0~A5上写有“ANALOG IN”,表明它们都是输入接口,可以测量外部模拟信号的电压(ATmega328内部有ADC(模拟-数字转换器),模拟信号输入到A0~A5会被转化成数字信号)。最上方0~13号引脚则是通用输入输出引脚,根据配置可以分别用于输入和输出数字信号,不支持模拟电压输入(输出要么是高电平要么是低电平,输入要么解读为逻辑1或逻辑0),其中带有“~”符号的表示该引脚可以输出PWM波。

12.5 Arduino IDE

IDE是Integrated Development Environment的缩写,Arduino IDE的意思就是Arduino集成开发环境,安装方式非常简单,首先在浏览器搜索Arduino,然后进入官网找到下载页面:

图 12.12: IDE下载页面

下载完成后安装软件并打开,左上角可以选择开发板型号,找到Arduino UNO,右侧再选择好连接端口,点击确认:

图 12.13: 开发板型号选择

12.6 Arduino 编程基础

Arduino编程基于C语言和C++,风格上更接近C语言,本小节只会对一些重要的概念进行介绍,因此无法作为详尽的编程参考。

I.数据类型

C语言是一门静态类型语言,静态类型表明我们在使用一个变量之前,必须先明确地定义它是什么,Arduino里数据类型常用的有int型(整型),int在UNO开发板里占据2个字节(16位)的内存大小,它能够表示正数和负数,范围从−32768~32767之间,可以通过以下方式来定义一个int变量:

int a;
a = 13;

上面代码中int表明数据类型是整型,a是变量名,a=13是一条赋值语句,赋值语句会把等式右边的值赋给等式左边的变量,C语言每条语句结束后要以分号结尾(英文输入格式下),否则会报错。除了定义变量之后赋值,在定义变量的过程中也可以先给变量赋予一个默认值:

int a = 13;
int b;

如果在定义时没有赋值,则默认为0,因此b的值此时为0。关于变量命名,需要遵守几点规则:1.变量名称只能包含字母、数字和下划线,不允许出现空格,2.只能以字母和下划线开头,不能以数字开头,3.变量区分大小写,例如“person”和“Person”是两个不同的变量,4.不能和C语言关键字(比如int)和函数名等重合,下面列出了几个不符合命名规则的变量:

int 3case;//数字开头
int int = 5;//变量名与关键字重合
int max speed = 120;//包含空格

包含“//”的内容被称为注释,注释的内容在编译器编译代码时会被忽略,因此它唯一的作用是帮助人们理解某段代码表示的意思。

除了int外,其他常用的还包括unsigned int(无符号整形),它同样占据2字节大小,但不能表示一个负数:

unsigned int neg_num = -13;//编译器会报错

要表示值更大的整数,要使用long和unsigned long(占据4个字节),涉及带有小数点的小数(也叫浮点数)时,可以使用float型(浮点型)。

除了数值类型外,变量也能表示真或假,这种类型变量被称为boolean型变量,它可以被赋值为true或者false:

boolean is_true = false;
boolean are_you_ok = true;

boolean型变量多用于逻辑判断,当条件满足什么时执行某些操作。Arduino里面的boolean型变量也可以用0或1来替代,0表示false,1表示true。

变量还可以被定义成一个字母或一个特殊的字符,使用关键字char(char类型能且只能存储一个字符):

char letter = 'A';// 存储字符 A
char digit = '5';// 存储字符 5

注意它们等式右边的值都被一对单引号(英文输入格式下)括起来了,因此这些变量代表的都是一个字符(5和字符’5’是不一样的),想要存储更长的一段内容,需要使用String类型。

表 12.2: Arduino 常用数据类型
数据类型 占用字节 范围 描述
boolean/bool 1 true,false 布尔值
byte 1 0~255 8位整型
int 2 -32768~32767 16位整型
unsigned int 2 0~65535 16位无符号整型
long 4 -2\(^{31}\) ~2\(^{31}\)-1 32位整型
unsigned long 4 0~2\(^{32}\)-1 32位无符号整型
float 4 ±3.4E38 32位浮点型
char 1 不适用 存储单个字符
String 由输入长度决定 不适用 存储字符

II.算数运算符

变量可以看成是一个容器,我们可以在这个容器里重新装不同的东西,方法就是对变量重新赋值,若只考虑数值类型的变量,变量和变量之间也可以进行数学运算,这需要用算数运算符连接两个或多个变量:

表 12.3: 常用算术运算符
算术运算符 含义 例子 计算结果
+ 加法 3+2 5
- 减法 10-7 3
* 乘法 8*10 80
/ 除法 32/5 6
% 求余 111%7 6

对于除法,如果两个数都是int型,结果会舍弃小数点只保留整数,想要得到带有小数点的值,要定义为float型:

int result_1 = 32/5;//结果是6
float result_2 = 32.0/5.0;//结果是6.4

除了表12.3的符号外,还有一些特殊的算数运算符号:

float a = 1;
float b = 10;
a++;//自加1,等价于a = a+1,此时a变为2
a--;//自减1,a重新变为1
b+=5;//简写,等价于b = b+5,执行后b变为15
b-=3;//b = b-3,b变为12

再次提醒一下,编程里面“=”是赋值操作,会把等式右边的数值赋值给等式左边的变量,而程序是从上往下按顺序执行的,所以变量b是在先加5后再减3。

III.比较运算符

比较运算符常用于比较两个变量大小,其比较结果是boolean型,要么为1(true),要么为0(false),比较运算符有:

表 12.4: 常用比较运算符
比较运算符 含义 例子 计算结果
== 等于 13 == 10 0
!= 不等于 1 != 2 1
> 大于 7 > 5 1
< 小于 7 < 5 0
>= 大于或等于 16 >= 16 1
<= 小于或等于 16 <= 16 1

注意“==”符号和“=”的区别,前者用于逻辑比较,后者用于赋值,比较运算通常用于条件判断,如if语句、for语句等,这些在后面会介绍到。

IV.逻辑运算符

逻辑运算符的操作对象是boolean型变量或数据,有&&(逻辑与,AND)、||(逻辑或,OR)、!(取反,NOT)。

boolean is_finished = 0;
boolean state;//默认初始化为0
state = is_finished && (13>2);//(13>2)的结果与is_finished相与,等于0

V.if语句

if语句可以根据条件为真或假执行不同的操作,基本形式为:

if(条件表达式){
//条件为1(为真)时执行
}
else{
//不满足条件时执行
}

例如对于变量x,如果它大于5,就让x等于0,否则x加1:

int x = 2;
if(x>5){
x = 0;
}
else{
x++;
}

C语言里面是通过花括号来判断哪些语句属于某个控制结构,在使用if语句的时候,不要忘记用花括号把要执行的语句框起来。

除了if…else…结构外,if语句还支持多重判断,形式为:

if(条件1){
//条件1满足后执行
}
else if(条件2){
//条件1不满足但条件2满足后执行
}
…
…
…
else{
//上述所有条件都不满足后执行
}

if语句在使用过程中还需要注意一点,当某个条件满足并执行了之后,程序会跳出if语句,例如当else if(条件2)满足并被执行后,程序会直接跳出if语句不会再去检验else if(条件3),如果不注意到这点,有时候写出来的if语句可能会出现逻辑错误。

VI.while循环

循环就是重复做某件事情,while循环就是只要条件满足,就不断执行while循环体里的操作,直到条件不满足:

while(条件表达式){
//与if语句不同的是,while循环执行后会重新判断条件,只要条件满足,就会一直执行
//而if语句执行一次后就执行后面的代码了
}

例如对变量x不断加1,直到x大于1000才停止:

int x = 0;
while(x<1000){
x = x + 1;
}

当x加到999时,x满足while循环条件,x加1到1000,此时程序重新回到while循环开头判断条件是否满足,x=1000不满足条件,程序跳过while循环执行后续操作。

VII.for循环

和while循环类似,for循环也是通过检测条件来重复执行操作,但相比于while循环,for循环更适合已知循环次数的重复操作,for循环基本语法为:

for(初始语句; 条件表达式; 更新){
//循环体
}

假设我们想要执行10次点亮LED灯的操作,用for循环可以这样写:

for (int i = 0; i < 10; i++) {
    light_LED();
}

首先我们在for循环里定义了一个初始条件i=0,它在整个for循环过程中只会被执行一次,然后根据条件表达式判断是否执行for循环花括号里的指令,由于现在i等于0,满足\(i < 10\),因此light_LED()函数被执行,执行完成后for循环会执行括号里的更新操作,更新是让i自加1,此时i就变为了1,然后这个i会重新被条件表达式检验,结果为真继续执行light_LED()和i++,直到i等于9时,\(i < 10\)为真,LED灯再次被点亮,i更新变成10,这次不再满足\(i < 10\),for循环结束,程序执行for循环之后的内容。

在for循环和while循环里,还有两个重要的关键字,break和continue,当程序执行到break时,会跳出当前的while或for循环,遇到continue则会停止本次循环,重新跳到while或for循环开头,检验是否满足循环条件。

VIII.函数

函数是一段可以重复使用的代码块,就像前面的light_LED()函数一样,它里面可能封装了很多条语句,如果直接把这些语句放到循环体内,当然程序可以正常运行,毕竟编译器在编译程序时就是会自动地把函数替换成函数体内的代码。

但从人类的角度来说,函数的一个优势就是,清晰和便于维护,大部分函数从函数名就能知道它功能是什么,而一行行的代码却并不容易判断它们的目的。

其次如果某个程序要在好几个不同的地方实现相同的逻辑,把这个逻辑封装在函数里,可以便于后期修改。

如何定义一个函数?方法如下:

返回的数据类型 函数名(需要传递的参数){
//没有参数传递也需要在函数名后加上括号
//函数体
//return语句
}

return语句用于返回函数计算出的结果,return返回的变量类型要与函数定义时的数据类型相匹配。当不返回什么东西的时候可以用void关键字定义函数。

例如定义一个函数sum(),它接收两个int类型输入,返回int类型的输出:

int sum(int num_1, int num_2){ //int sum()表明函数返回类型为int型
int result;// int
num_1表明输入的参数名叫num_1,类型为int型,num_2同理
result = num_1 + num_2;
//函数内部定义了个叫result的变量,计算输入参数的和
return result; //返回result
}

这样我们就定义好了sum()函数,要注意函数定义时所用到的变量都是函数的内部变量(包括num_1、num_2、result),外界无法访问内部变量,它们在函数执行完毕后会被销毁(所以一要用return传递出来)。

而函数调用方法非常简单,只需要写出函数名并传递参数:

int result_1 = sum(13,0); //sum()函数返回13,并赋值给result_1
int result_2 = sum(100,7); //sum()函数返回107,并赋值给result_2
int a = 1;
int b = 15;
int c = sum(a,b); //sum()函数返回16,并赋值给变量c

sum(a,b)表示把函数外面定义的变量a的值传递给函数内部变量num_1,把外部b的值传递给内部变量num_2。上面代码如果a是float型,那编译器会报错,因为在定义函数时,要求输入参数的类型为int型。

12.7 Arduino 常用函数

关于C语言更多的细节,大家可以去查找相关专业书籍,上一小节只是起到一个快速入门的作用,便于本小节Arduino一些常用函数介绍。

打开Arduino IDE,会默认出现一个编辑界面,它里面包含有两个函数(其实这里是函数的定义,但这两个函数不需要自己调用):

void setup() {
}
void loop() {
}

这样一个界面被称为一个sketch(草图),setup()用于初始化Arduino开发板,例如定义引脚信息,通信速率等,它在程序运行过程中只执行一次(上电或复位后)。

而loop()会在setup()执行完毕后不断循环,用于控制Arduino和外围设备交互。

I.pinMode(pin, mode)

pinMode()用于配置2~13号(0号和1号通常不会使用)、A0~A5引脚的输入输出模式,这些引脚既可以用于数字输入(Arduino从引脚读取0或1),也能用于数字输出(向外提供高电平或低电平),而A0~A5还可以读取模拟信号输入。

因为这些引脚具有复数的功能,所以在使用它们之前,必须先规定好某个引脚到底是用于输出还是用于输入,pin用于选择引脚编号,mode则配置输入输出模式,常见模式有INPUT、OUTPUT和INPUT_PULLUP。

例如把A0和8号引脚配置成输出模式,使用pinMode():

int LED_out_pin = 8;
void setup() {
pinMode(A0, OUTPUT);
pinMode(LED_out_pin, OUTPUT);
}

上面代码中,8号引脚被设置成了一个变量传递到pinMode()中去(定义引脚变量最好定义在setup()之外,否则loop()无法访问这些变量),这是可以的,因为在函数输入参数中传递的是变量的值,int可以替换为const int,const int代表申明的对象是个常量,常量在赋值后不能被修改,二次赋值编译器会报错。

同样引脚也可以被配置为输入模式,那INPUT和INPUT_PULLUP有什么区别呢?

当设置为输入模式时,引脚的输入阻抗可以看成无穷大(就像一个buffer),芯片会测量电压高低以判断输入是数字0还是数字1,但之前提到过,电压不会连续地从数字0过度到数字1,例如5伏供电下,低于1.5伏的输入电压被解释为数字0,高于3伏的电压被解释为1,那中间1.5~3伏的电压是不允许被输入的,否则芯片可以会随机地识别为数字0或1。

现在假设Arduino的5号引脚配置为输入模式,并连接到一个开关,当开关按下时,5号引脚和GND连接,这是芯片很容易可以判断输入是数字0,但是当开关断开的时候,5号引脚往外看去连接到的都是断路,断路意味着5号引脚被悬空了,此时没有一条路径可以良好地定义出5号引脚的电压,因此5号引脚可能会被随机地读取为数字0或1,导致我们设计失效。

而INPUT_PULLUP模式会芯片内部设置上拉电阻连接到5伏电源,这样当引脚外部连接断开时,引脚电压也处于5伏,输入就确定为数字1:

图 12.14: 输入和输入上拉模式

那该如何判断选择哪种模式的输入呢?这就要看输入电路是否在所有情况下都有确定的输入电压,例如 图 12.14 左侧,开关断开时电压不确定,但如果有手动在外部配置了上拉电阻,那设置成INPUT模式是可以的。

II.digitalWrite(pin, value)、digitalRead(pin)

看函数名就能知道这两个函数是用于写和读取高低电平的,digitalWrite()接收两个输入参数,一个是引脚编号(引脚先得设置为输出模式),另一个是电压值,分别可以输入LOW和HIGH。而digitalRead()只是读取引脚电压值,无法写入电压。

void loop(){
digitalWrite(LED_out_pin, HIGH);
delay(1000);
digitalWrite(LED_out_pin, LOW);
delay(1000);
}

上面代码放入到了loop()内,因此会不断重复执行,delay()是Arduino内置的一个函数,用于延时,它以毫秒为单位,1000毫秒也就是1秒,延时就是什么也不做,等待延时结束再执行下一条语句。因此这段代码分别对8号引脚输出为期1秒的高电平和低电平,连接到LED就会看到LED不断亮灭。

如果手里有Arduino开发板,已经可以尝试搭建这样一个LED亮灭电路了,没有也没关系,Thinkercad提供了在线模拟的平台:

图 12.15: LED点亮在线模拟

它里面有Arduino UNO板和很多其他电子元件,可以让我们快速验证代码逻辑是否正确。

电子元件可以通过面包板来连接电线和Arduino,还记得面包板怎么用的吗?标有“+”和“−”符号的是水平金属导条,中间部分的是垂直金属导条,每个金属导条被视为导线,电压相同,LED连接到一个200欧的电阻,是为了限制电流大小,过大的电流会损坏LED。

网站提供了查看原理图的功能,当用线连接好后,最好转到原理图模式检查一下导线是否连接正确:

图 12.16: 连接原理图

III.analogRead(pin)

analogRead()用于读取A0~A5引脚的模拟电压输入(引脚先得设置为输入模式),因为只有这6个引脚连接到了ADC,才能读取模拟电压。

Arduino UNO R3开发板的ADC是10位的:

图 12.17: 模拟-数字转换器

其中10位表示从二进制0000000000到1111111111之间有\(2^{10}\) = 1024种不同状态,若0~5伏范围的模拟输入电压被这1024个状态均分,那两个状态之间的分辨率为\(\frac{5}{1023} \approx 4.9\,\text{mV}\),所以0000000000代表了0~4.9毫伏的模拟电压输入,以此类推,想要得到更高的分辨率,ADC就得有更多的位数,但只要不是无穷多位,理论上ADC在转化过程中就会存在信号的损失。

如果手里面有Arduino UNO R4,它的ADC是12位的,3.3伏(由芯片供电电压决定)最高输入电压下分辨率为0.8毫伏。

analogRead()读取的数值可以赋值给一个int型变量(int型Arduino开发板有16位存储空间,当然能存下10位的ADC输出),然后把这个变量乘以0.0049,得到的结果就是Arduino读取到的输入的模拟电压值:

int ADC_value = analogRead(A0);
float voltage = ADC_value * 0.0049;

IV.Serial

Serial是Arduino封装好了的一个数据结构,用于通过USB向电脑,或0号和1号引脚向其他设备通信。

通信的两个设备首先要约定好通信速率,也叫波特率,Arduino IDE上可以调整电脑端的波特率(连接开发板后点击IDE右上角放大镜的图标),一般是9600,因此开发板上也得先设置好同样的波特率,可以使用Serial.begin():

void setup(){
Serial.begin(9600);
}

统一好了波特率之后,就可以向电脑发送数据了,使用Serial.print(),它可以把发送出去的数据打印在Arduino IDE串口监视窗口上。而Serial.write()则可以发送原始二进制数据,这将在后续与MIDI设备通信时更详细地介绍到。

关于更多的Arduino可用的函数,可以去Arduino官方文档查看。

12.8 模拟电压输出

首先Arduino UNO R3是没有DAC(数字-模拟转换器)的,但UNO R4有,它的A0~A5引脚也可以模拟输出,方法很简单,例如想要输出3伏:

void setup(){
analogWriteResolution(12); // 设置为12位分辨率
float aim_voltage = 3.0;
int DAC_value = (aim_voltage /3.3)*2^(12);
analogWrite(A0, DAC_value);
}

这差不多只是把ADC的过程反过来,但UNO R3没有DAC,它虽然也可以使用analogWrite()函数,但输出模式是PWM波,PWM波根据占空比的不同来平均出一个电压值,比如100%的占空比,则全程高电平,50%占空比则输出是方波,一个周期内平均电压是2.5伏,只要PWM波频率够快,那看起来就是一个模拟电压输出,UNO R3的3号、5号、6号、9号、10号、11号引脚可以用来输出这种PWM波,每个引脚有固定的输出频率,在网上可以找到。

但这种方法只能应对那些精度要求不高的场景,想要在UNO R3上真正实现模拟电压输出,需要外接扩展板,常用的DAC板是MCP4725板,MCP4725是一款12位的DAC芯片,我们可以把DAC_value发送给它,让它输出模拟电压。

图 12.18: MCP4725扩展板

MCP4725通过I²C(一种串行通信协议)与Arduino通信,抛开那些底层协议,有人已经写好了驱动库让我们可以很方便地使用它。

MCP4725与Arduino有特定的连接方式,\(V_{\text{CC}}\) 和GND分别连接到开发板5伏和GND,SCL要连接到开发板A5引脚,SDA连接到A4引脚(此时这两个引脚不要用pinMode()去配置,因为INPUT或OUTPUT不适用于I²C)。

连线完成后,打开Arduino IDE,使用MCP4725要用到两个库,一个是Wire.h,另一个是Adafruit_MCP4725.h。

后者是外部库,要先安装,安装位置如下:

图 12.19: 安装外部驱动库

安装完成后按照以下方法初始化:

#include <Wire.h>
#include <Adafruit_MCP4725.h>//导入驱动库
Adafruit_MCP4725 dac;//创造一个dac实例,类似于从蓝图到实际的东西
void setup(){
Wire.begin();//初始化A4、A5引脚为I2C模式
dac.begin(0x60);//初始化dac模块
}

关于实例化一个dac,可以理解为得先拓展出一块叫dac的内存空间,才能使用dac.begin()等函数。

初始化完成后,就可以用dac.setVoltage()使MCP4725输出模拟电压,dac.setVoltage()接收两个参数,第一个是value,范围从0到4095(二进制000000000000~111111111111,12位,转化为0~5伏模拟输出),第二个参数是boolean型输入,一般填false:

dac.setVoltage(1000, false); //1000约为1.2伏输出