摇摇棒,理工男的择偶权(上)

前言

摇摇棒是载有一列LED的棒,通过适当的程序控制,在摇动起来时,由于人眼有视觉暂留现象(persistence of vision,POV),会形成一幅图像。你可以上淘宝搜索,关注一下摇摇棒的核心参数(卖点)与显示效果。

一年多前,我做了一根摇摇棒,16个粉红色LED,在520那天送给了女朋友。她很喜欢,她的同学和我的同学都很好奇。

那时候我做了两根,当然不是因为我是渣男。另一根我带去了高考(高二等级考)考场,内置了“全员A+”的字样,本来想交给老师来给我们应援的,但是在烈日之下我只能很勉强地看见摇摇棒显示的字,于是就不了了之了。

我不服,又设计了摇摇棒2.0。制作完硬件以后,它就一直堆在我的书桌旁。

一年过去了,女朋友丢了,体重增加了,唯一不变的是我还是什么降分约都没有——唉,又要参加等级考了(写作之时已经考完了)。

我想起了摇摇棒。

这一回,摇摇棒是我在高考前夕唯一的乐趣,是我在老师心中瓜皮形象的转折点,是我作为一个理工男的择偶权。

系列概述

本系列文章分为三篇:上篇介绍单机的摇摇棒,中篇介绍联机的摇摇棒,下篇介绍图灵完全的摇摇棒。

本文为上篇。目前进度大概到中篇的一半,但我觉得只有完成了后续(最好是所有)才能更好地审视前面的工作,用没有回溯的思路整理成一篇博客。

写文章要照应标题,不过这简直就是做梦,我还是好好介绍摇摇棒吧,不去想那些有的没的。

先放个效果图吧(曝光时间0.2s):

核心原理

人们对摇摇棒有所好奇,无非是好奇它的核心原理,至于细节与实现,我说出来也没有人要听。这也是我开通博客的原因。

首先,棒上所有的输入输出设备都由程序控制,运行程序的是一块单片机。

摇动周期是任意的(自适应的),别太夸张就行,所以摇摇棒需要检测运动周期。用于检测的硬件是位于棒顶端的水银开关:

真空、密封的玻璃管中有一滴水银,一个引脚始终与水银接触,另一个只有当水银位于一端时才接触。接触时两引脚导通,用一个很简单的电路就可以把导通与否转换成高低电平被单片机读取。

改变水银位置需要施力,摇摇棒运动过程中有加速度,提供了惯性力。然而,水银开关只能指示加速度的方向,而不是更容易使用的加速度、速度、位置;加速度的方向也不能简单地认为是一个周期内翻转两次——这就需要一个精巧的程序来控制。

我写的程序能让单片机知道(意会,别跟我杠什么单片机没有意识)它在一个周期中的相对位置,从而知道每一时刻该亮起图像的哪一部分。哦对了,字符是转换成点阵图像存储的,每一个像素点都是摇摇棒亮灯的依据。

于是,在一个周期中,图像的每一列都被在对应的位置显示了一会。人眼有视觉暂留现象,这些列一起组成了一幅图像,它的内容是字符。当然,简单的图案也是可以的。

硬件

以上为摇摇棒的原理图,可以分为以下几个部分:

  • 供电:18650电池座、电源开关、SX1308(B628)升压、AMS1117-3.3稳压;

    摇摇棒1.0直接用3.7V锂离子电池供电,但实际电压为2.7V到4.2V,亮度差异很大;2.0的供电部分先升压到5V,为了便于在高亮度下控制亮度。

    蓝牙模块需要3.3V电源,所以加了个LDO。

  • 控制:ATMega328P单片机、晶振、ISP下载接口;

    单片机选择的是我最擅长的AVR系列中的ATmega328P,与烂大街的Arduino相同(但我没从那边抄过哪怕一行代码)。晶振是20MHz的,官方允许的最高频率,为了获得更好的性能。

    下载器接口是我自己定义的ISP接口,比标准的占用更少空间,但毕竟是非标准的,这是个历史遗留问题。

  • 输入:电池电压检测、水银开关、光敏电阻、按键×2;

    水银开关接通时,SWC为低电平;断开时,由于没有负载,SWC为高电平;R05称为上拉电阻。这就是那个很简单的电路。电容C04本来想用于滤波的,实测反而碍事,拿掉了。两个按键同理,上拉电阻在单片机内部配置。

    光敏电阻R06阻值与光强负相关,与定值电阻R07分压后的输出电压与光强正相关,接到单片机的ADC(模-数转换器)上,从而检测环境光强度并调整亮度,深夜写代码与阳光下展(liào)示(mèi)都能适配。

  • 输出:5片74HC595、2个N沟道MOS管、32个蓝绿双色LED、2个RGBW LED;

    595是串行转并行芯片,MOS是一种三极管,详见AVR单片机教程——矩阵键盘。595输出串联排阻后接LED再接到MOS管,连接方式下面细说。

    单片机上DAT1DAT3DAT4CLKSTO引脚控制595,前3个是数据信号。设计3个数据信号是为了加速输出,不过最快的输出方式是用SPI,没有用它是设计上的失误。

  • 蓝牙:蓝牙模块、简单的电平转换电路。

    中篇内容,跳过。

两个RGBW共8个灯,刚好对应595的8个输出。不幸的是,595位于下方RGBW的背面,而另一个RGBW位于顶端,在狭窄的PCB中避开其他元器件和信号线走4根线并不容易,这是PCB布线的难点。也许还有别的难点,只是时间太久远,我已经忘了。

595输出串联电阻后接LED,输出低电平时LED不亮,高电平时有电流因而亮,电阻起到限流作用。不同颜色的灯串联不同阻值的电阻是为了平衡亮度,在RGB都点亮时颜色接近白色。

4片595输出LED0LED31,越上方的编号越小。每个蓝绿双色LED的两个阳极共同连接一个LEDx信号,绿、蓝阴极分别连接到GRNBLU,是两个MOS管的漏极。当Q的栅极GRNC为高电平时,漏极和连接到GND的源极之间导通,电阻忽略不计,如果此时LEDx为高电平则对应绿灯亮起;低电平时不导通,无论LEDx如何,绿灯一个都不会亮——这段时间留给蓝灯。

简而言之,GRNC为高电平时595控制绿灯,BLUC为高电平时595控制蓝灯。如果GRNCBLUC的电平转换非常快,快到电平变化的一个周期内LED只移动了很小一段距离,看起来就是天蓝色的。而事实上,GRNCBLUC的电平变化还没那么简单。

PCB渲染图如上。大致布局是,正面最上方水银开关和光敏电阻,往下一个RGBW、32个蓝绿、一个RGBW,IC和电阻等贴片器件都在反面对应的位置。然后是下载器接口、电源开关、两个按键、电感、晶振,最后是电池,反面有升压电路、单片机、蓝牙模块等。在手握摇摇棒时这些元器件会被碰到,影响正常工作,所以全部被我盖了一层热熔胶:

毕竟图吧签到12级。

硬件设计决定了摇摇棒功能的上限。比如,它不可能显示红色的图像(除非你能摇得快到红移)。

本篇中摇摇棒能实现的功能有:

  • 以任意的蓝绿组合颜色呈现图像,包括渐变色;

  • 自动根据环境光强调整显示亮度;

  • 用按键切换显示图像、复位周期检测、调整亮度等。

驱动

这个项目不算简单,所以我要加上驱动层,把底层的寄存器操作封装成C语言函数,在适当的地方提供回调接口。后面将看到驱动层之上并非直接是应用程序,驱动负责到哪一步也是一个问题。我的想法是,应用程序不需要插入代码的地方就封装,否则就留给上层解决;明显的异步操作用回调。

驱动层主要包括以下接口:

  1. LED,规定数据格式,提供以一定亮度亮灯的函数;

  2. 水银开关,检测加速度方向,附带滤波;

  3. 按键,封装按键双击、长按等高级事件;

  4. ADC,检测电源电压与光强,后者可以异步;

  5. 定时器,程序结构的核心,定时回调与全局时钟;

  6. 蓝牙,依旧跳过。

详解一下奇数编号的驱动。

LED

32个双色LED加上2个RGBW的模式可以用5个字节表示,我规定第[0]字节的最低位对应最上方的LED,第[3]字节的最高位对应最下方,第[4]字节最低位对应上方RGBW的红色,最高位对应下方RGBW的白色。这样就不难写出驱动5片595的代码:

uint8_t d0, d1, d2;
d0 = data[0];
d1 = data[2];
for (uint8_t i = 0; i != 8; ++i)
{
    cond_bit(read_bit(d0, 0), PORTC, 0);
    cond_bit(read_bit(d1, 0), PORTC, 1);
    d0 >>= 1;
    d1 >>= 1;
    clock_bit(PORTC, 3);
}
d0 = data[1];
d1 = data[3];
d2 = data[4];
for (uint8_t i = 0; i != 8; ++i)
{
    cond_bit(read_bit(d0, 0), PORTC, 0);
    cond_bit(read_bit(d1, 0), PORTC, 1);
    cond_bit(read_bit(d2, 0), PORTC, 2);
    d0 >>= 1;
    d1 >>= 1;
    d2 >>= 1;
    clock_bit(PORTC, 3);
}
clock_bit(PORTC, 4);

其中的位操作宏定义为:

#define set_bit(r, b) ((r) |= (1u << (b)))
#define reset_bit(r ,b) ((r) &= ~(1u << (b)))
#define read_bit(r, b) ((r) & (1u << (b)))
#define cond_bit(c, r, b) ((c) ? set_bit(r, b) : reset_bit(r, b))
#define flip_bit(r, b) ((r) ^= (1u << (b)))
#define clock_bit(r, b) (flip_bit(r, b), flip_bit(r, b)
#define bit_mask(n, b) (((1u << (n)) - 1) << (b))

配合GRNCBLUC的高低电平可以显示出绿、蓝和天蓝色,但这还不算完。GRNCBLUC连接到单片机的OC0AOC0B引脚,它们是定时器0的波形输出引脚,可以产生PWM波。一个PWM周期内一段时间高电平,对应LED亮,低电平时暗,切换快到人眼完全看不出来,从而感觉到亮度是均匀的,与PWM占空比正相关的。一会让GRNC输出PWM波,BLUC保持低电平,一会相反,切换依然快到看不出来,于是就实现了任意的蓝绿亮度组合。

原先这种设计只是为了解决蓝绿亮度不相同的问题,后来渐渐地发展出了渐变色的功能。

typedef enum
{
    COLOR_NONE, COLOR_GREEN, COLOR_BLUE
} color_t;

void led_set(color_t color, uint8_t duty, const uint8_t data[5])
{
    TCCR0A &= ~(bit_mask(2, COM0A0) | bit_mask(2, COM0B0));
    // ...
    uint8_t com0x;
    volatile uint8_t* ocr0x;
    switch (color)
    {
    case COLOR_GREEN:
        com0x = 0b10 << COM0A0;
        ocr0x = &OCR0A;
        break;
    case COLOR_BLUE:
        com0x = 0b10 << COM0B0;
        ocr0x = &OCR0B;
        break;
    default:
        return;
    }
    if (duty == 0)
        return;
    TCCR0A |= com0x;
    *ocr0x = duty - 1;
    TCNT0 = 0xFF;
}

中间省略的是上面那段代码。

按键

我一直想写一个能处理长按、双击等事件的按键库,这次正是一个机会。至少在这一篇中,按键是控制好摇摇棒的唯一方式。而按键一共只有两个,为了使输入方式更丰富,就只能在每个按键的事件种类上动手脚。

首先要消抖。按键在被按下和抬起的过程中,电平并不是直上直下的,可能存在抖动。如果把每一次跳变都算一个事件的话,随意按一下可能就被算作双击了,所以需要消抖。我用的是最简单的消抖方法:用一个变量记录按键的状态,当按键的电平与原状态不同且保持10ms不变时,才认为此时按键进入新的状态。水银开关的消抖也是类似的。

#include <avr/io.h>

#define BUTTON_COUNT 2

static bool pin[BUTTON_COUNT];
static uint8_t filter[BUTTON_COUNT] = {0};

static inline bool button_read(uint8_t which)
{
    switch (which)
    {
    case 0:
        return read_bit(PINB, 1);
    case 1:
        return read_bit(PINB, 2);
    }
    return false;
}

static inline button_event_t button_filter(uint8_t which)
{
    if (which >= BUTTON_COUNT)
        return false;
    bool now = button_read(which);
    if (now == pin[which])
        filter[which] = 0;
    else if (++filter[which] == 50)
    {
        pin[which] = now;
        filter[which] = 0;
        return now ? BUTTON_LEFT_RELEASED : BUTTON_LEFT_PRESSED;
    }
    return BUTTON_NONE;
}

void button_init()
{
    set_bit(PORTB, 1);
    set_bit(PORTB, 2);
    for (uint8_t i = 0; i != BUTTON_COUNT; ++i)
        pin[i] = button_read(i);
}

定义三种模式,最复杂的模式中包括以下事件:

typedef enum
{
    MODE_NONE, MODE_SIMPLE, MODE_ADVANCED
} button_mode_t;

typedef enum
{
    BUTTON_NONE,
    BUTTON_LEFT_PRESSED, BUTTON_LEFT_RELEASED,
    BUTTON_LEFT_SHORT, BUTTON_LEFT_LONG, BUTTON_LEFT_CONT,
    BUTTON_LEFT_DOUBLE,
    BUTTON_RIGHT_PRESSED, BUTTON_RIGHT_RELEASED,
    BUTTON_RIGHT_SHORT, BUTTON_RIGHT_LONG, BUTTON_RIGHT_CONT,
    BUTTON_RIGHT_DOUBLE,
    BUTTON_BOTH
} button_event_t;

BUTTON_LEFT_CONT指左按键长按以后保持按下的事件,每100毫秒触发一次;BUTTON_BOTH是两个按键同时按下的事件。

函数button_get()返回一个button_event_t变量。每次调用只更新一个按键,因此不会有多个返回值。该函数需要客户轮询。

同时处理这么多事件的方法是用状态机:

BOTHFREE的转移条件为另一个按键也处于BOTH状态。具体timeout值见下面的代码,代码中数值除以5得到毫秒数。

比如,0ms时按下,200ms时抬起,400ms时按下,600ms时抬起,状态转移过程为:

  1. 0ms,FREEBOTH

  2. 100ms,BOTHSHORT,事件PRESSED

  3. 200ms,SHORTDOUBLE

  4. 400ms,DOUBLEFREE,事件DOUBLE

typedef enum
{
    STATE_FREE, STATE_BOTH, STATE_SHORT, STATE_DOUBLE, STATE_LONG
} state_t;

static button_mode_t mode = MODE_NONE;
static const button_event_t base[BUTTON_COUNT] = {0, BUTTON_RIGHT_PRESSED - BUTTON_LEFT_PRESSED};
static state_t state[BUTTON_COUNT];
static uint16_t count[BUTTON_COUNT];
static uint8_t turn = 0;

void button_mode(button_mode_t m)
{
    if (mode == m)
        return;
    mode = m;
    if (m == MODE_ADVANCED)
        for (uint8_t i = 0; i != BUTTON_COUNT; ++i)
            state[i] = STATE_FREE;
}

button_event_t button_get()
{
    button_event_t result = BUTTON_NONE;
    button_event_t filter = button_filter(turn);
    if (mode == MODE_SIMPLE)
        result = filter;
    else if (mode == MODE_ADVANCED)
    {
        switch (state[turn])
        {
        case STATE_FREE:
            if (filter == BUTTON_LEFT_PRESSED)
            {
                state[turn] = STATE_BOTH;
                count[turn] = 0;
            }
            break;
        case STATE_BOTH:
        {
            uint8_t other = 1 - turn;
            if (state[other] == STATE_BOTH)
            {
                result = BUTTON_BOTH;
                state[turn] = STATE_FREE;
                state[other] = STATE_FREE;
            }
            else if (filter == BUTTON_LEFT_RELEASED)
            {
                result = BUTTON_LEFT_PRESSED;
                state[turn] = STATE_DOUBLE;
                count[turn] = 0;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_PRESSED;
                state[turn] = STATE_SHORT;
                count[turn] = 0;
            }
            break;
        }
        case STATE_SHORT:
            if (filter == BUTTON_LEFT_RELEASED)
            {
                state[turn] = STATE_DOUBLE;
                count[turn] = 0;
            }
            else if (++count[turn] == 2500)
            {
                result = BUTTON_LEFT_LONG;
                state[turn] = STATE_LONG;
                count[turn] = 0;
            }
            break;
        case STATE_DOUBLE:
            if (filter == BUTTON_LEFT_PRESSED)
            {
                result = BUTTON_LEFT_DOUBLE;
                state[turn] = STATE_FREE;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_SHORT;
                state[turn] = STATE_FREE;
            }
            break;
        case STATE_LONG:
            if (filter == BUTTON_LEFT_RELEASED)
            {
                result = BUTTON_LEFT_RELEASED;
                state[turn] = STATE_FREE;
            }
            else if (++count[turn] == 500)
            {
                result = BUTTON_LEFT_CONT;
                count[turn] = 0;
            }
            break;
        }
    }
    if (result != BUTTON_NONE && result != BUTTON_BOTH)
        result += base[turn];
    if (++turn == BUTTON_COUNT)
        turn = 0;
    return result;
}

废话两句。以前上课的时候有人问我单片机按键双击怎么写,当时我心里还没底,因为没写过,就让他多加一个按键。这时我们老师说,注册一个回调就可以了呀!

嗯,算你懂得回调。

定时器

程序中主循环的周期为0.1ms,但是一个周期中执行指令的时间相比于周期长度而言已经不可忽略,为了精准地控制时间,需要使用定时器。没错,这里的定时器和之前提到的用于产生PWM波的是同一类东西,不同的是之前用的是定时器0,这里用的是定时器2,两者互不干扰。

设置定时器2分频系数为8,匹配值为250,则每2000个CPU时钟周期产生一个中断。CPU时钟频率为20MHz,因此定时器中断的间隔为0.1ms。客户须在每次中断中调用button_get,这就是除以5得到毫秒数的原理。

定时器中断有两项职责,一是维护一个时钟,每一周期增加1,可重置,主要用于水银开关周期检测;二是调用上层的回调函数timer_handler,驱动中仅声明为extern(另一种方法是通过函数指针注册回调)。

#include <avr/io.h>
#include <avr/interrupt.h>

static uint16_t tick = 0;

ISR(TIMER2_COMPA_vect)
{
    ++tick;
    timer_handler();
}

void timer_init()
{
    if (0)
        TIMER2_COMPA_vect();
    TCCR2A = 0b10 << WGM20;
    TCCR2B = 0 << WGM22 | 0b010 << CS20;
    OCR2A = 249;
    TIMSK2 = 1 << OCIE2A;
    sei();
}

void clock_reset()
{
    tick = 0;
}

uint16_t clock_get()
{
    return tick;
}

应用程序

驱动封装了硬件操作,而用户只想关心显示什么内容,两者之间还需要插入一层,这一层主要实现运动周期检测,并在周期中合适的时刻根据用户提供的数据进行显示。

两层之间用回调函数和配置信息耦合。回调函数包括定时器回调、按键事件回调与图像更新回调;配置信息定义如下:

typedef struct
{
    uint8_t width;
    uint8_t height_byte;
    const uint8_t* display;
    uint8_t in_flash : 1;
    uint8_t bright;
    uint8_t color;
    uint8_t rgbw;
} Config;

display指向点阵数据,共width * height_byte字节,每height_byte字节表示一列,RGBW另存。

客户通过set_config函数更新配置,Config参数被立即拷贝到一个特定的位置,但不会立即应用于显示,而是等待当前显示周期(即运动周期)结束,在下次更新中应用,简而言之配置被缓冲了。

在C语言中,即使一个数组声明为const,它也存放在RAM中,但是ATmega328P只有2k字节RAM,显示的字数很多的话会放不下。AVR编程中可以用PROGMEM宏指定数据存放在flash中,in_flash即表示点阵是否存储在flash中。

#include <stdint.h>
#include <avr/pgmspace.h>

static const uint8_t jiayou[] PROGMEM =
{
    0x00, 0x00, 0x00, 0x40, 0x00, 0x40, 0x40, 0x00, 0x20, 0x40, 0x00, 0x18,
    0x40, 0x00, 0x07, 0x40, 0xF8, 0x09, 0xFE, 0x1F, 0x08, 0x40, 0x00, 0x10,
    0x40, 0x00, 0x30, 0x40, 0x00, 0x18, 0xC0, 0xFF, 0x0F, 0xC0, 0x07, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0xFF, 0x7F, 0x40, 0x00, 0x08,
    0x40, 0x00, 0x08, 0x40, 0x00, 0x08, 0x40, 0x00, 0x08, 0x40, 0x00, 0x08,
    0xC0, 0xFF, 0x3F, 0x40, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x80, 0x00, 0x00, 0x00, 0x01, 0x02, 0x00, 0x07, 0x72,
    0x08, 0x06, 0x7F, 0x18, 0xE0, 0x01, 0x10, 0x18, 0x00, 0x00, 0x07, 0x00,
    0xC0, 0x00, 0x00, 0xC0, 0xFF, 0x7F, 0xC0, 0xFF, 0x7F, 0x40, 0x20, 0x10,
    0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0xFE, 0xFF, 0x1F, 0xFE, 0xFF, 0x1F,
    0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0x40, 0x20, 0x10, 0xC0, 0xFF, 0x7F,
    0xC0, 0xFF, 0x3F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};

(点阵数据可用PCtoLCD2002生成;原谅我用拼音命名变量。)

指向flash中数据的指针与普通指针相同,但是不能直接解引用,要先用memcpy_P函数拷贝到RAM中:

memcpy_P(display.current, display.ptr + display.phase * display.height_byte, display.height_byte);

并不是所有点阵数据都放在flash中,比如程序还可以通过蓝牙接收数据,把它写进flash就太麻烦了。

程序结构为,先执行初始化,包括硬件与变量,然后进入死循环,保持程序运行。初始化的最后是启动定时器,随后定时器会每0.1ms产生一次中断,所有实际工作都在中断中完成。

int main()
{
    startup();
    while (1)
        ;
}

周期检测

那时做完第一版发了个朋友圈,就有人问这个问题:

的确,周期检测是摇摇棒的难点(对于那些问我“把摇摇棒放在桌上不动能不能显示”的人就不是了),是我写第一版甚至第二版的程序时唯一心慌的地方。虽然免去了为MPU6050写I²C驱动的烦恼,但5毛钱的水银开关也自有麻烦之处。让我们来一探究竟吧!

水银开关电路的输出信号首先要经过滤波,这是驱动层封装好的:

typedef enum
{
    MERCURY_NONE, MERCURY_LEFT, MERCURY_RIGHT
} mercury_event_t;

static bool status;
static uint16_t count = 0;

static inline bool mercury_read()
{
    return !read_bit(PINB, 0);
}

void mercury_init()
{
    status = mercury_read();
}

mercury_event_t mercury_get()
{
    bool now = mercury_read();
    if (now == status)
        count = 0;
    else if (++count == 100)
    {
        status = now;
        count = 0;
        return now ? MERCURY_RIGHT : MERCURY_LEFT;
    }
    return MERCURY_NONE;
}

然后就是算法的主体部分。算法可以用状态机描述,只有稳定与不稳定两个状态,用stable变量表示,初始值为falseperiod为上一周期的长度,单位为定时器周期即0.1ms,是两个状态共用的;计数器count在两个状态中有不同的含义,但共用一个变量。

算法只监听水银珠从右到左这一事件,大致上是棒从右到左经过中点。定义局部变量uint16_t clock = clock_get();,表示当前周期已经持续的时间。大多数分支都会调用clock_reset复位时钟,并在使用完clock后把它写为0,标志着新的周期开始。

在不稳定状态中,要想进入稳定状态,必须连续若干次满足以下条件:本次周期长度大于前一周期的0.5倍并且小于1.5倍。count记录这一条件成立的次数,一旦某一次条件不成立则清零,并把period更新为当前周期长度。目标次数被设置为2。

在稳定状态中,根据周期长度分3类讨论:

  1. 周期长度大于等于前一周期的0.75倍并且小于1.5倍,这意味着当前周期和上一周期差不多长,用户在稳定地摇动。把period设为两个周期的平均值,这样可以允许周期缓慢变化。

  2. 周期长度小于0.75倍,这可能是噪音导致的,应该忽略,不复位时钟。但是这种情况连续出现很多次就不对了,用count记录次数,达到一定值时要进入不稳定状态。这个值被设置为2。

  3. 周期长度大于等于1.5倍,用户停止了摇动,直接进入不稳定状态。事实上停止摇动后LED还会闪一下,因为不免存在抖动,导致程序又判定出一个周期。

测试过程中发现,如果突然把摇动频率翻倍,由于有第2个分支的存在,算法会把两个周期判定为一个;有时刚开始摇动就会这样。解决这个问题需要在分支1和2中动点手脚:用half_flag表示分支1中clock是否是period的一半,具体来讲是3/8到5/8;half_count表示连续出现“一周期中进入分支1一次且half_flag为真”的次数。当half_count达到2时就可以认为算法进入了错误的状态,需要减半period以恢复正常。

bool stable;
uint16_t period;
uint8_t count;
bool half_flag;
uint8_t half_count;

void timer_handler()
{
    // ...
    uint16_t clock = clock_get();
    if (mercury_get() == MERCURY_LEFT)
    {
        if (stable)
        {
            if (clock < period * 3 / 4)
            {
                if (++count == 2)
                {
                    stable = false;
                    count = 0;
                }
                if (period * 3 / 8 < clock && clock < period * 5 / 8)
                {
                    half_flag = true;
                }
            }
            else if (clock < period * 3 / 2)
            {
                clock_reset();
                if (count == 1 && half_flag)
                {
                    if (++half_count == 2)
                    {
                        half_count = 0;
                        clock = 0;
                    }
                }
                else
                {
                    half_count = 0;
                }
                period = (period + clock) / 2;
                count = 0;
                half_flag = false;
                clock = 0;
            }
            else
            {
                stable = false;
                count = 0;
            }
        }
        else
        {
            clock_reset();
            if (period / 2 < clock && clock < period * 3 / 2)
            {
                if (++count == 2)
                {
                    stable = true;
                    period = (period + clock) / 2;
                    count = 0;
                    half_flag = false;
                    half_count = 0;
                    clock = 0;
                }
            }
            else
            {
                period = clock;
                count = 0;
            }
        }
    }
    // ...
}

知道了周期长度与起始时刻,也就知道了每一时刻在周期中的位置。一个周期的3/8到5/8,也就是从左到右中间的部分,可以显示图像,显示的列随clock均匀变化,由于中间段接近匀速,显示的图像是比较均匀的。

为什么不在从右到左过程中显示呢?因为周期起始的位置并不精确地是正中间,还受周期、重力和手的影响,取3/8到5/8而不是1/4到3/4就包含对这些因素的考量。如果在相差半个周期的位置也显示的话,两幅图像肯定无法重合,即使动态调整位置也无济于事。

性能优化

也许你已经注意到,上面的代码中从未出现过int,只有uint8_tuint16_t等确定长度的整数类型。这样做可以带来可移植性,更重要的是AVR作为8位单片机对整数长度十分敏感,能用8位就不要用16位。

mega系列有双周期硬件乘法器,但没有硬件除法器,除数确定的除法编译器会转化为乘法来计算,不确定的就只能调用除法路径了。这种除法偶尔算一次还行,每个定时器周期都算就会严重拖慢速度,比如这句判断是否该切换列的语句:

if (clock == period * 3 / 8 + (uint32_t)period * phase / width / 4)
    // ...

要加uint32_t转换是因为perioduint16_t类型,整数提升成unsigned intint是16位整型),计算结果为unsigned类型,但实际乘积会溢出,就不得不转换成更长的long。这下可好,每周期计算32位整数除法,同时触犯两条禁忌。

我的性能优化就从这里入手,逐渐扩展到所有计算过程不太简单但不常变化的量,它们都存储在结构体compute中:

struct
{
    uint16_t threshold_low;
    uint16_t threshold_high;
    uint16_t half_low;
    uint16_t half_high;
    uint16_t clock_base;
    uint16_t clock_step;
    uint16_t clock_compare;
    uint16_t green_step;
    uint16_t blue_step;
    uint8_t rgbw_duty;
} compute;

clock开头的三个变量就是用来优化前述语句的。在显示周期开始,即clock == 0时,先计算:

compute.clock_base = motion.period * 3 / 8;
compute.clock_step = motion.period / display.width;
compute.clock_compare = compute.clock_base;

compute.clock_compare就是if中与clock比较的值。在display.phase增加后,需要重新计算compute.clock_compare的值,其中除以4是可以接受的计算:

compute.clock_compare = compute.clock_base + compute.clock_step * display.phase / 4;

你也许会问,为什么不把除以4放进compute.clock_step的计算中?考虑误差较大的情况:motion.period == 2047, display.width == 128compute.clock_step比理想值小了6.2%,图像的宽度将压缩为原来的93.8%;如果把除以4放进去,误差会达到25.0%,这就比较严重了。

转换为uint32_t先乘后除无疑是更加精准的,优化后由于整数除法只能得到整数结果而产生了更大的误差,因此这里的性能优化与编译器优化还不同:编译器要遵守“as-if”规则,而我是在用可接受的精度下降换取可观的速度提升。

利用超纲的手段(蓝牙),我得知优化前定时器中断的执行时间超过了定时器周期的80%,优化后下降到了40%以下(都是-O3),性能提升十分明显。挤出来的计算资源将会在下篇中派上用场。

一个相似的例子是渐变色模式中LED亮度(对应PWM占空比)的计算。原来的计算式为:

duty = led.green * phase / width;

优化以后为:

compute.green_step = (led.green << 8) / (display.width - 1);
duty = (compute.green_step * phase) >> 8;

如果不左移8位直接除,因为有整数除法的误差,显示效果将是瞬变而不是渐变,所以我要先左移8位再右移8位,这与上面的除以4是类似的,只是更加显式。

我的重点不在移位的艺术性上。请你看看优化后的第一个语句有什么问题,已知三个变量的类型分别为uint16_tuint8_tuint8_t

点击展开答案

led.green在移位运算中被提升为int(而不是unsigned),移位运算结果为int类型,除法运算结果亦为int类型。当led.green >= 128时,除法结果为负数,赋给无符号的compute.green_step,变成无符号数与phase相乘再移位。我搞不清楚结果是个什么东西,反正显示效果不是渐变色。

解决方法很简单,把led.green转换成uint16_t再参与运算即可。发现这个问题花了我一个小时,真是成也抠门败也抠门啊!

下期预告

另一端的效果图见文首

后记

本文中的周期检测算法能实现其功能,并具有一定容错与自恢复能力,但是还不完美。

即使算法能从双倍周期中恢复出来,半速显示仍会持续至少2个理论周期,或4个实际周期。从观赏者的角度上看,半速显示是相当丑陋的——翻转、拉伸、边缘畸变、交叠,可谓集大成者。有趣的是,这种自恢复是我在写作本文期间才想到并应用的;此前我给用户提供的对策是按下按键以重置算法,然而矛盾的是用户如果要给别人展示,自己就看不到显示效果,也就无从得知这种错误,只会让观赏者觉得我是个逊仔。

这个问题也许可以归结于性能与容错性的权衡:要允许噪音,就必须接受短暂的半速显示。

权衡归权衡,真正的缺陷依然存在:算法允许稳定以后周期内出现噪音,但是如果每个周期内都有噪音,也就无法进入稳定状态,但是信号的周期仍客观存在。其实噪音很大程度上来源于滤波没有滤干净,但滤波中的时间阈值也不能设置地太高,如果要把这种噪音留到滤波后级去解决,我就不知道该怎么办了。

和摇摇棒一样利用POV原理的还有旋转灯,你可以在淘宝用“旋转 POV”关键字搜索。旋转灯可以说是升级版的摇摇棒,电机代替了手,无线输电代替了电池,显示效果也上了有一个档次,甚至可以柱面、球面显示。不过作为灵魂的水银开关被磁传感器替代了,所以我感觉旋转灯的编码难度不会高于摇摇棒,难度更偏向于硬件设计。

前两天看到一篇微信推送,视频里出现4根棒组成的便携式旋转灯,甚至有旋转灯阵列组成的屏幕,评论区直呼看不懂,我直呼羡慕。

旋转灯局限于面显示(球面也是面),而光立方能增加一个维度,是真正的立体显示。光立方是静态的,唯一动的部分大概就是动态扫描了,没有一点难度,只是焊接太累了。正因工作量大且效果花哨,送给女朋友非常合适,这一点我已经验证过了。

光立方的致命缺陷在于分辨率低,难以提高LED数量的根本原因在于它是三维的。摇摇棒是一维的,动起来以后成为二维,不难想象二维的运动起来可以变成三维——我还真在网上见过把光立方的一个面转起来的,分辨率与维数兼得。

这些东西记在这里,给读者拓宽眼界,也给我自己种棵草。

我没有仔细看过别人的摇摇棒设计,在第二版的设计、装配、编程过程中甚至没有以“摇摇棒”为关键字搜索过,一方面因为网上大多都是我不会的51,另一方面我不喜欢读别人的单片机代码,这与51的扩展语法脱不了干系,更重要的是我觉得那些都是上个世代的代码——我的第一版摇摇棒的程序竟然是用C++14写的!更夸张的是,回调用的是std::vector<std::function<void()>>,后来还逐渐演化为C#中event的类似物。事实上,AVR工具链并没有C++的标准库,这两个类模板是我自己实现的。

那时年幼无知,不懂得谦虚,包括对人与对单片机。

文章写完了。除了前言和后记差强人意以外,中间的技术介绍完全就是半吊子——有所涉及,却无法深入。譬如LED的电路,我本应详细介绍595与PWM及其背后的思想;又譬如周期检测,我本应带领读者一步一步实现这个算法。所以我只能更改本文的目标,把完整清晰地介绍摇摇棒下调为仅供读者观赏(如果你有意深入了解我的摇摇棒,可以后台联系我),甚至连这个小目标都达不到。

或许摇摇棒的材料更适合用于讲座或视频,文字这一形式对表达有所限制,然而高手不应该被表达形式限制,所以归根结底是我太菜了。

不知怎的,完成了码量是前一版几倍的项目外加一篇博客,收获感甚至比不上许久以前就着别人的博客实现出一个std::function。可能是这个项目对于目前的我过于简单,这当然是件值得欣喜的事;或者,

是因为高考临近了吧。

猜你喜欢

转载自www.cnblogs.com/jerry-fuyi/p/shake_led_1.html