13 第十三章 压力
雪沫乳花浮午盏,蓼茸蒿笋试春盘。人间有味是清欢。
13.1 感受
通常来说,当人们谈论感知世界时,一般都是在强调眼睛、耳朵和鼻子接收到的信息,对应的分别是光、声音和味道。而皮肤被提及的频率则少得多,皮肤是我们人体最大的器官,也是大多数动物最大的器官。
人体皮肤具有相当敏锐的感知能力,在最敏感的指尖,人类可以感受到0.1克左右的重量,这比很多小飞虫都要轻,其他大部分皮肤感知的能力也在0.5~1克之间,正因如此我们才可以感觉到轻微风的吹过,绵密又抓不住。关于风的描写其实有很多,宋玉在《风赋》中写到:“夫风生于地,起于青萍之末”,这里的风是指微风,青萍指的是河边长出来的浅浅的水草,人类的感知赋予我们独特的理解世界的能力,也成就了很多诗情画意。电路上同样有可以感受的器件,它们被称为传感器。传感器可以感受外界的变化,并把这种变化反映在自身特性的改变上。
13.2 力敏电阻
力敏电阻(Force-Sensitive Resistor,FSR)是一种其电阻值会随着压力增大而变小的器件,它能承受较大的垂直压力,但无法抗弯曲,弯曲可能会损坏内部结构,力敏电阻外形如图所示:
两个引脚连接内部形成电流通路,圆形区域是压力施加区域,除了圆形,常见的还有方形,它的压力-阻值曲线如下:
没有施加压力或重量时,具有较高的阻值(具体多少可以通过万用表测量出来),随着压力变大,阻值并不会线性下降,而是先快后慢(力敏电阻可以承受超过10千克的重量)。
13.3 力敏电阻方波发生器
力敏电阻有什么用呢?用它我们可以很容易地在Arduino上实现一个输出频率随着压力增大而增大的方波信号发生器。
基本思路是串联一个力敏电阻和一个普通电阻,然后测量分压电压值,因为ADC的输出在0~1023之间,再把0~1023映射到某个频率范围,然后输出这个频率的方波。
连接方式如下:
标有“FSR”字样的电阻就是力敏电阻,分压电压接入到Arduino的A0号引脚,方波从9号引脚输出,Arduino有内置的方波函数tone(),它接收输出引脚编号和输出频率这两个参数。map()函数可以实现数值映射,语法为:
// map(int 输入值, int 输入最小值, int 输入最大值, int 输出最小值, int 输出最大值)
int freq = map(500, 0, 1023, 200, 2000);
tone(9, freq);
在每次调用tone()函数时,9号端口输出都会重新开始输出,因此即使频率保持不变,也可能出现与上个时刻衔接不好的情况,这样会造成听觉上的杂音。为了解决这个问题,程序中定义了一个current_freq的变量,它保存了当前的输出频率,然后会与ADC计算出的新的频率相比较,如果两者相同就不会重复调用tone(),以维持听感的连续。
要注意,Arduino输出的方波不能直接接喇叭,因为引脚能提供的电流是很有限的,驱动不了低输入阻抗的喇叭,所以得连接到功放再输出到喇叭。
13.4 正弦波输出
Arduino有sin()函数,但它只是数学上一个计算用的函数,输出正弦波的方法是用DAC输出一个查找表,查找表里有正弦波一个周期内的某些关键点,DAC一段一段地输出这些关键点电压,就可以近似出正弦波的形状:
为此我们得先创造出一个数组序列,数组是一系列数有序的集合,C语言中创建数组的方法和创建变量相似:
int array1[10];
int array2[7];
float array3[3] = {12.8, 11.0, 5.2};但array1、array2、array3不再称为变量,而是数组,数组后面方括号里的数字是数组的长度,以array1为例,数组存储的数据类型必须与声明的类型相同,array1里面必须存放int型数据,array3里面也只能存放float型数据,array1[10]表明数组长度为10,因为每个int型变量在Arduino里占据2个字节,因此内存会连续开辟2 * 10 = 20个字节的内存空间,用于存放array1的数据:
没有在定义时赋值的数组默认全体成员(全体数组元素)初始化为0,要访问数组的某个元素,使用数组名方括号加下标:
int x = array[i];//访问第i+1个元素因为数字元素下标默认从0开始,因此array1[0]代表array1第1个元素,array1[9]代表array1的第10个元素(注意在定义时的array[i]和定义好后访问元素时的array[j]含义是不一样的)。
MCP4725是12位的DAC,0~5伏之间振荡的正弦波查找表如下:
int sineTable[64] = {
2048, 2447, 2831, 3185, 3495, 3750, 3939, 4056,
4095, 4056, 3939, 3750, 3495, 3185, 2831, 2447,
2048, 1649, 1265, 911, 601, 346, 157, 40,
0, 40, 157, 346, 601, 911, 1265, 1649,
2048, 2447, 2831, 3185, 3495, 3750, 3939, 4056,
4095, 4056, 3939, 3750, 3495, 3185, 2831, 2447,
2048, 1649, 1265, 911, 601, 346, 157, 40,
0, 40, 157, 346, 601, 911, 1265, 1649
};然后让DAC输出这些数组元素,就模拟出了一个周期的正弦波信号:
#include <Wire.h>
#include <Adafruit_MCP4725.h>
Adafruit_MCP4725 dac;
int sineTable[64] = {
2048, 2447, 2831, 3185, 3495, 3750, 3939, 4056,
4095, 4056, 3939, 3750, 3495, 3185, 2831, 2447,
2048, 1649, 1265, 911, 601, 346, 157, 40,
0, 40, 157, 346, 601, 911, 1265, 1649,
2048, 2447, 2831, 3185, 3495, 3750, 3939, 4056,
4095, 4056, 3939, 3750, 3495, 3185, 2831, 2447,
2048, 1649, 1265, 911, 601, 346, 157, 40,
0, 40, 157, 346, 601, 911, 1265, 1649
};
void setup() {
Wire.begin();
dac.begin(0x60);
}
void loop() {
for (int i = 0; i <= 63; i++) {
dac.setVoltage(sineTable[i], false);
delay(10);
}
}for循环输出完一个周期后,loop()循环重复执行,这样连续输出正弦波,因为一个周期有64个点,每个点延迟10毫秒,一个周期就是64 * 10 = 640毫秒,正弦波频率为\(\frac{1}{0.64} \approx 1.56\,\text{Hz}\)。
delay()不支持delay(0.1)之类的用法,想要实现更小的延迟,可以使用Arduino内置的delayMicroseconds()函数,它以微秒为基本单位计时。
但问题还没完呢,按照上述的方式,一个Arduino开发板就只能执行输出正弦波的任务了,因为程序是按顺序执行的,在for循环后加入其他语句,输出就不连续了。
为了解决这个问题,我们得引入中断的概念,简单来说,中断就是被打断,程序在正常运行过程中,如果遇到中断,程序可以暂停当前正在执行的任务,转而先去处理中断,处理完成后再退出中断,衔接上中断来之前正在执行的任务:
如果使用中断,我们就可以在Arduino执行其他任务时,也能连续输出正弦波,只要每个计时周期到来后,让中断输出一个查找表信号。
为此我们得引入Arduino的定时器,定时器是具有物理实体的一个硬件结构,Arduino里有3个定时器,Timer0、Timer1和Timer2。
Timer0 用于 delay() 等函数,Timer2 用于输出 PWM 和 tone() 等函数,因此尽量不要去使用它俩,剩下一个可以使用的就是Timer1。
定时器可以设定计数周期(计数周期可以转化成时间周期),当周期到来时,定时器会发出一个中断信号,然后我们就能编写程序,在中断到来时去输出一个查找表元素,输出完成后结束这次的中断返回原本主程序,等待下一个中断的到来再去输出一个新的查找表元素。
这样Arduino既能执行其他任务,又可以不间断地输出正弦波信号了。设置Timer1涉及到对Arduino底层操作,但正如DAC那样,有人已经写好了现成的库函数,让我们得以只关注逻辑实现,忽略底层设置。
Timer1的专用库叫TimerOne.h(也得先去下载后才能使用),同样先导入到程序中:
#include <TimerOne.h>然后就可以使用Timer1了,Timer1.initialize()用于初始化Timer1的计时周期,它以微秒为单位,例如:
#include <TimerOne.h>
void setup() {
Timer1.initialize(100); // 初始化 100 微秒发出一个中断信号
}为了让中断逻辑顺利运行,还需要注册Timer1的中断程序,注册Timer1中断程序就相当于告诉Arduino当中断来时要转跳到哪个函数,使用Timer1.attachInterrupt(要执行的中断函数的函数名)注册中断函数:
void setup() {
Timer1.initialize(100);
Timer1.attachInterrupt(DAC_out); // 中断到来后转跳到 DAC_out() 函数
}和前面一样,DAC_out()会输出查找表元素,我们得先在程序中定义DAC_out():
#include <TimerOne.h>
#include <Wire.h>
#include <Adafruit_MCP4725.h>
Adafruit_MCP4725 dac;
volatile int i = 0;
void setup() {
Wire.begin();
dac.begin(0x60);
Timer1.initialize(100);
Timer1.attachInterrupt(DAC_out);
}
void loop() {
}
void DAC_out() { // 在这儿定义 DAC_out() 函数
dac.setVoltage(sineTable[i], false); // sineTable 和上文一样
if (i < 63) {
i++;
} else {
i = 0;
}
}volatile关键字说明i这个变量可以被中断函数访问(否则可能会被编译器优化导致无法访问)。这样我们就可以在loop()里写其他逻辑了,想要修改Timer1的计时周期,可以使用Timer1.setPeriod(),可以把这个函数放到loop()里去动态修改Timer1计时时间。
想要关闭Timer1,使用Timer1.stop(),再次开启使用Timer1.start()(停止之后重新开启,其实运行Timer1得先开启它,但Timer1.initialize()函数会自动运行一次Timer1.start(),所以在上面程序中没有显式使用这个函数)或Timer1.restart()(重启Timer1)。
13.5 弯曲传感器
力敏电阻不是不抗弯曲吗?但是弯曲传感器(flex sensor)就是专门用来弯曲的,它越弯电阻越大:
平直时电阻约在\(10\,\text{k}\Omega\)级别,当弯曲时,想象传感器内部材料因弯曲而有撕裂,电流流过变得更困难,从而电阻变大。