控制舵机

控制舵机旋转

舵机是一种电机,它使用一个反馈系统来控制电机的位置。可以很好掌握电机角度。大多数舵机是可以最大旋转180°的。也有一些能转更大角度,甚至360°。舵机比较多的用于对角度有要求的场合,比如摄像头,智能小车前置探测器,需要在某个范围内进行监测的移动平台。又或者把舵机放到玩具,让玩具动起来。还可以用多个舵机,做个小型机器人,舵机就可以作为机器人的关节部分。所以,舵机的用处很多。

由于avr-hal没有像C那样提供9G舵机的类库,所以,在本示例中,我们需要手动编写一个控制它。本例使用的9G舵机可以在旋转180度。根据舵机数据表,该舵机使用PWM控制,位置“0”(1.5 ms 脉冲)位于中间,“90”(~2ms 脉冲)位于一直向右的位置,“-90”(~1ms 脉冲)一直向左,信号频率为50Hz。

而因为,UNO缺乏数模转换(DAC)输出模块,所以,我们需要自定义的PWM,通过使用定时器Timer1调用中断来自定义波形生成模式WGM来生成控制舵机的波形进行实现。

硬件要求

  • Arduino板卡
  • Micro Servo 9G舵机
  • 连接线

电路

按照舵机的数据表,舵机有棕红橙三根不同颜色的线,分别对应GND、+5V和PWM控制信号,控制信号我们选引脚9。

按照舵机数据表的说明,我们需要在引脚9上输出一个50Hz的变动占空比,即PWM信号来控制不同时间点舵机的旋转角度。

关于Arduino Uno的PWM的详细教程,可以参见Secrets of Arduino PWM

关于计时器说明可以参考ATMega328P数据表

定时器是一种中断。 它就像一个简单的时钟,可以测量事件的时间间隔。 每个微控制器都有一个时钟(振荡器),比如在 Arduino Uno 中它是 16Mhz。 这对速度负责。 时钟频率越高,处理速度越高。 定时器使用计数器,该计数器根据时钟频率以一定的速度进行计数。 Arduino UNO时钟以16MHz运行。计数器的一个刻度值表示1 / 16,000,000秒(~63ns),跑完1s需要计数值16,000,000。Arduino UNO有3种功能不同的定时器:

  1. Timer0:8位定时器。用来执行delay(), millis()这些函数。
  2. Timer1:16位定时器。用来运行本例的舵机控制。
  3. Timer2:8位定时器。比如用来运行C类库中的tone()函数。

定时器寄存器

定时器寄存器用来修改寄存器的配置。

  1. 定时器/计数器控制寄存器(TCCRnA/B):

该寄存器保存定时器的主要控制位,用于控制定时器的预分频器。它还允许使用 WGM 位控制定时器的模式。

帧格式:

TCCR1A76543210
COM1A1COM1A0COM1B1COM1B0COM1C1COM1C0WGM11WGM10
TCCR1B76543210
--------------
ICNC1ICES1-WGM13WGM12CS12CS11CS10

预分频器:

TCCR1B 中的 CS12、CS11、CS10 位设置预分频器值。预分频器用于设置定时器的时钟速度。 Arduino Uno 的预分频器为 1、8、64、256、1024。

CS12CS11CS10描述
000没有时钟源(定时器/计数器停止)
001未分频
0108预分频
01164预分频
100256预分频
1011024预分频
110T1 引脚上的外部时钟源。时钟在下降沿。
111T1 引脚上的外部时钟源。时钟在上升沿。
  1. 定时器/计数器寄存器(TCNTn):

该寄存器用于控制计数器值并设置预加载器值。

所需时间(以秒为单位)的预加载器值的公式: \[ TCNTn = 65535 –(16 \times 10^{10} \times 秒数/预分频器值)\] 要计算 2 秒时间内定时器 1 的预加载器值: \[TCNT1 = 65535 – (16 \times 10^{10} \times 2/1024) = 34285 \]

定时器中断

输出比较寄存器(OCRnA/B):

当输出比较匹配中断发生时,中断服务ISR (TIMERx_COMPy_vect)被调用,并且TIFRx寄存器中的OCFxy标志位将被设置。 该ISR通过设置TIMSKx寄存器中 OCIExy中的启用位来启用。 其中TIMSKx是定时器中断屏蔽寄存器。

当定时器达到比较寄存器值时,相应的输出被切换。

定时器中断捕获:

接下来,当定时器输入捕捉中断发生时,将调用中断服务 ISR (TIMERx_CAPT_vect),并且 TIFRx(定时器中断标志寄存器)中的 ICFx 标志位将被置位。 通过设置 TIMSKx 寄存器中 ICIEx 中的使能位来启用此 ISR。

定时器可以在溢出和/或与任一输出比较寄存器匹配时生成中断。

电路图

控制舵机

代码

这里我们需要利用定时器来触发定时中断以向舵机发送转角数据。

按照舵机数据表,我们首先需要得到一个50Hz的中断脉冲,通过改变每个中断期间的占空比,方波的宽度来控制舵机的角度。我们可以在每次方波发出后等待1/50s,即20ms来接受下一次的脉冲信号。我们可以通过定时器中断寄存器来配置64预分频,波形生成模式为0b11,表示此时使用Fast PWM模式,生成的是锯齿波,每个duty cycle可以有0-255种值,即占用255个tick,因此,波形频率为16M/64/256=976.56Hz。分频后的时钟频率为250kHz,每个tick为4µs。那么,按照舵机的数据表,我们需要设置输出的tick值在100-600之间,即0.4-2.4ms之间。0-180度的对应的具体的测试值,最终为120-600。如果我们选择的分频数高于这个值,比如256分频,那么每个tick就会上升到16µs,输出的tick值就在50-150之间了。而我们需要180个角度值,缺乏足够的分辨率。

tc1.tccr1b
    .write(|w| w.wgm1().bits(0b11).cs1().prescale_64());

而因为simple pwm的set_duty只能接受u8类型的值,即0-255,而我们需要输入的值为u16类型,按照手册,我们只能选用可以输出16位值的寄存器,也就是Timer1(Timer0是8位寄存器,Timer2也是8位寄存器但是带有异步模式)。而因为Timer1的输出比较匹配A输出到数字引脚9上,而Timer1对应的另一个引脚是引脚10是外部源的输入捕获输入引脚。因此,我们只能将舵机连接到引脚9上。

pins.d9.into_output();

上述代码中,wgm1().bits(0b11)代表,设置了tccr1b中的WGM的模式配置位3,2,为1,WGM在AVR中有15种工作模式,因此用4个位表示,另外两个位在tccr1a中,为模式配置位1和0,在下面这段代码中,我们设置这两位的值为1和0,那么,WGM工作模式就设置为了1110,即14。14表示Fast PWM,输出的是锯齿波。而wgm和com1a的配置,共同决定了波形生成器的工作模式,所以我们还需要设置com1a的值。match_clear()设置了com1a的值为2,这个时候在匹配前波形为低电平,匹配后,波形为高电平,正好符合我们驱动舵机的需要。

tc1.tccr1a
    .write(|w| w.wgm1().bits(0b10).com1a().match_clear());

我们需要把触发中断输出的频率变为50Hz,即需要976.56/50=19.53,差不多每20个波触发一次比较输出中断。因此,根据"减一原则"我们需要设置输入捕获寄存器(Input Capture Register)的值为20*256-1=5119。这个值设置了在波形生成器在14的工作模式下设置了定时器计数器的TOP值,这是OC1A的一个特性,这个值决定了波形生成的周期,这样我们就得到了一个50Hz的Fast PWM锯齿波。

tc1.icr1.write(|w| w.bits(5119));

按照上面的说明,我们需要设置一个宽度为在1ms-2ms之间并且被等分为180份的duty序列,来控制舵机的运动角度,序列的每个值对应1度。而输出序列值,我们要通过设置定时器输出比较寄存器OCR1A来写入当前输出的duty值。

for degree in (0..=180).chain((0..179).rev()) {
    let duty = degree as f32 * ((600.0 - 120.0) / 180.0) + 120.0;
    ufmt::uwriteln!(
        &mut serial,
        "degree:{},duty:{}",
        degree,
        ufmt_float::uFmt_f32::Two(duty.clone())
    )
    .unwrap_infallible();
    tc1.ocr1a.write(|w| w.bits(duty as u16));
    delay_ms(20);
}

编译并运行示例

cargo build
cargo run

此时,我们可以看到,舵机开始移动到0度,之后每次移动∼1度 完整代码如下:

src/main.rs

/*!
 * Servo Control
 * 
 * Sweep a standard SG90 compatible servo from its left limit all the way to its right limit and back.
 *
 * Because avr-hal does not have a dedicated servo driver library yet, we do this manually using
 * timer TC1.  The servo should be connected to D9 (AND D9 ONLY!  THIS DOES NOT WORK ON OTHER PINS
 * AS IT IS).
 *
 * As the limits are not precisely defined, we undershoot the datasheets 1ms left limit and
 * overshoot the 2ms right limit by a bit - you can figure out where exactly the limits are for
 * your model by experimentation.
 *
 */
#![no_std]
#![no_main]

use arduino_hal::{
    default_serial, delay_ms, pins, prelude::_unwrap_infallible_UnwrapInfallible, Peripherals,
};
use avr_device::entry;
use panic_halt as _;

#[entry]
fn main() -> ! {
    let dp = Peripherals::take().unwrap();
    let pins = pins!(dp);
    let mut serial = default_serial!(dp, pins, 57600);

    // Important because this sets the bit in the DDR register!
    pins.d9.into_output();

    // - TC1 runs off a 250kHz clock, with 5000 counts per overflow => 50 Hz signal.
    // - Each count increases the duty-cycle by 4us.
    // - Use OC1A which is connected to D9 of the Arduino Uno.
    let tc1 = dp.TC1;
    tc1.icr1.write(|w| w.bits(5119));
    tc1.tccr1a
        .write(|w| w.wgm1().bits(0b10).com1a().match_clear());
    tc1.tccr1b
        .write(|w| w.wgm1().bits(0b11).cs1().prescale_64());

    loop {
        for degree in (0..=180).chain((0..179).rev()) {
            let duty = degree as f32 * ((600.0 - 120.0) / 180.0) + 120.0;
            ufmt::uwriteln!(
                &mut serial,
                "degree:{},duty:{}",
                degree,
                ufmt_float::uFmt_f32::Two(duty.clone())
            )
            .unwrap_infallible();
            tc1.ocr1a.write(|w| w.bits(duty as u16));
            delay_ms(20);
        }
    }
}