搖搖棒,理工男的擇偶權(上)
-
搖搖棒,理工男的擇偶權(中)
-
搖搖棒,理工男的擇偶權(下)
前言
搖搖棒是載有一列LED的棒,通過適當的程序控制,在搖動起來時,由於人眼有視覺暫留現象(persistence of vision,POV),會形成一幅圖像。你可以上淘寶搜索,關注一下搖搖棒的核心參數(賣點)與顯示效果。
一年多前,我做了一根搖搖棒,16個粉紅色LED,在520那天送給了女朋友。她很喜歡,她的同學和我的同學都很好奇。
那時候我做了兩根,當然不是因為我是渣男。另一根我帶去了高考(高二等級考)考場,內置了「全員A+」的字樣,本來想交給老師來給我們應援的,但是在烈日之下我只能很勉強地看見搖搖棒顯示的字,於是就不了了之了。
我不服,又設計了搖搖棒2.0。製作完硬件以後,它就一直堆在我的書桌旁。
一年過去了,女朋友丟了,體重增加了,唯一不變的是我還是什麼降分約都沒有——唉,又要參加等級考了(寫作之時已經考完了)。
我想起了搖搖棒。
這一回,搖搖棒是我在高考前夕唯一的樂趣,是我在老師心中瓜皮形象的轉折點,是我作為一個理工男的擇偶權。
系列概述
本系列文章分為三篇:上篇介紹單機的搖搖棒,中篇介紹聯機的搖搖棒,下篇介紹圖靈完全的搖搖棒。
本文為上篇。目前進度大概到中篇的一半,但我覺得只有完成了後續(最好是所有)才能更好地審視前面的工作,用沒有回溯的思路整理成一篇博客。
寫文章要照應標題,不過這簡直就是做夢,我還是好好介紹搖搖棒吧,不去想那些有的沒的。
核心原理
人們對搖搖棒有所好奇,無非是好奇它的核心原理,至於細節與實現,我說出來也沒有人要聽。這也是我開通博客的原因。
首先,棒上所有的輸入輸出設備都由程序控制,運行程序的是一塊單片機。
搖動周期是任意的(自適應的),別太誇張就行,所以搖搖棒需要檢測運動周期。用於檢測的硬件是位於棒頂端的水銀開關:
真空、密封的玻璃管中有一滴水銀,一個引腳始終與水銀接觸,另一個只有當水銀位於一端時才接觸。接觸時兩引腳導通,用一個很簡單的電路就可以把導通與否轉換成高低電平被單片機讀取。
改變水銀位置需要施力,搖搖棒運動過程中有加速度,提供了慣性力。然而,水銀開關只能指示加速度的方向,而不是更容易使用的加速度、速度、位置;加速度的方向也不能簡單地認為是一個周期內翻轉兩次——這就需要一個精巧的程序來控制。
我寫的程序能讓單片機知道(意會,別跟我杠什麼單片機沒有意識)它在一個周期中的相對位置,從而知道每一時刻該亮起圖像的哪一部分。哦對了,字符是轉換成點陣圖像存儲的,每一個像素點都是搖搖棒亮燈的依據。
於是,在一個周期中,圖像的每一列都被在對應的位置顯示了一會。人眼有視覺暫留現象,這些列一起組成了一幅圖像,它的內容是字符。當然,簡單的圖案也是可以的。
硬件
以上為搖搖棒的原理圖,可以分為以下幾個部分:
-
供電: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管,連接方式下面細說。
單片機上
DAT1
、DAT3
、DAT4
、CLK
、STO
引腳控制595,前3個是數據信號。設計3個數據信號是為了加速輸出,不過最快的輸出方式是用SPI,沒有用它是設計上的失誤。 -
藍牙:藍牙模塊、簡單的電平轉換電路。
中篇內容,跳過。
兩個RGBW共8個燈,剛好對應595的8個輸出。不幸的是,595位於下方RGBW的背面,而另一個RGBW位於頂端,在狹窄的PCB中避開其他元器件和信號線走4根線並不容易,這是PCB布線的難點。也許還有別的難點,只是時間太久遠,我已經忘了。
595輸出串聯電阻後接LED,輸出低電平時LED不亮,高電平時有電流因而亮,電阻起到限流作用。不同顏色的燈串聯不同阻值的電阻是為了平衡亮度,在RGB都點亮時顏色接近白色。
4片595輸出LED0
到LED31
,越上方的編號越小。每個藍綠雙色LED的兩個陽極共同連接一個LEDx
信號,綠、藍陰極分別連接到GRN
和BLU
,是兩個MOS管的漏極。當Q
的柵極GRNC
為高電平時,漏極和連接到GND
的源極之間導通,電阻忽略不計,如果此時LEDx
為高電平則對應綠燈亮起;低電平時不導通,無論LEDx
如何,綠燈一個都不會亮——這段時間留給藍燈。
簡而言之,GRNC
為高電平時595控制綠燈,BLUC
為高電平時595控制藍燈。如果GRNC
和BLUC
的電平轉換非常快,快到電平變化的一個周期內LED只移動了很小一段距離,看起來就是天藍色的。而事實上,GRNC
和BLUC
的電平變化還沒那麼簡單。
PCB渲染圖如上。大致布局是,正面最上方水銀開關和光敏電阻,往下一個RGBW、32個藍綠、一個RGBW,IC和電阻等貼片器件都在反面對應的位置。然後是下載器接口、電源開關、兩個按鍵、電感、晶振,最後是電池,反面有升壓電路、單片機、藍牙模塊等。在手握搖搖棒時這些元器件會被碰到,影響正常工作,所以全部被我蓋了一層熱熔膠:
畢竟圖吧簽到12級。
硬件設計決定了搖搖棒功能的上限。比如,它不可能顯示紅色的圖像(除非你能搖得快到紅移)。
本篇中搖搖棒能實現的功能有:
-
以任意的藍綠組合顏色呈現圖像,包括漸變色;
-
自動根據環境光強調整顯示亮度;
-
用按鍵切換顯示圖像、複位周期檢測、調整亮度等。
驅動
這個項目不算簡單,所以我要加上驅動層,把底層的寄存器操作封裝成C語言函數,在適當的地方提供回調接口。後面將看到驅動層之上並非直接是應用程序,驅動負責到哪一步也是一個問題。我的想法是,應用程序不需要插入代碼的地方就封裝,否則就留給上層解決;明顯的異步操作用回調。
驅動層主要包括以下接口:
-
LED,規定數據格式,提供以一定亮度亮燈的函數;
-
水銀開關,檢測加速度方向,附帶濾波;
-
按鍵,封裝按鍵雙擊、長按等高級事件;
-
ADC,檢測電源電壓與光強,後者可以異步;
-
定時器,程序結構的核心,定時回調與全局時鐘;
-
藍牙,依舊跳過。
詳解一下奇數編號的驅動。
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))
配合GRNC
和BLUC
的高低電平可以顯示出綠、藍和天藍色,但這還不算完。GRNC
和BLUC
連接到單片機的OC0A
和OC0B
引腳,它們是定時器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
變量。每次調用只更新一個按鍵,因此不會有多個返回值。該函數需要客戶輪詢。
同時處理這麼多事件的方法是用狀態機:
BOTH
向FREE
的轉移條件為另一個按鍵也處於BOTH
狀態。具體timeout
值見下面的代碼,代碼中數值除以5得到毫秒數。
比如,0ms時按下,200ms時抬起,400ms時按下,600ms時抬起,狀態轉移過程為:
-
0ms,
FREE
→BOTH
; -
100ms,
BOTH
→SHORT
,事件PRESSED
; -
200ms,
SHORT
→DOUBLE
; -
400ms,
DOUBLE
→FREE
,事件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
變量表示,初始值為false
。period
為上一周期的長度,單位為定時器周期即0.1ms,是兩個狀態共用的;計數器count
在兩個狀態中有不同的含義,但共用一個變量。
算法只監聽水銀珠從右到左這一事件,大致上是棒從右到左經過中點。定義局部變量uint16_t clock = clock_get();
,表示當前周期已經持續的時間。大多數分支都會調用clock_reset
複位時鐘,並在使用完clock
後把它寫為0,標誌着新的周期開始。
在不穩定狀態中,要想進入穩定狀態,必須連續若干次滿足以下條件:本次周期長度大於前一周期的0.5倍並且小於1.5倍。count
記錄這一條件成立的次數,一旦某一次條件不成立則清零,並把period
更新為當前周期長度。目標次數被設置為2。
在穩定狀態中,根據周期長度分3類討論:
-
周期長度大於等於前一周期的0.75倍並且小於1.5倍,這意味着當前周期和上一周期差不多長,用戶在穩定地搖動。把
period
設為兩個周期的平均值,這樣可以允許周期緩慢變化。 -
周期長度小於0.75倍,這可能是噪音導致的,應該忽略,不複位時鐘。但是這種情況連續出現很多次就不對了,用
count
記錄次數,達到一定值時要進入不穩定狀態。這個值被設置為2。 -
周期長度大於等於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_t
和uint16_t
等確定長度的整數類型。這樣做可以帶來可移植性,更重要的是AVR作為8位單片機對整數長度十分敏感,能用8位就不要用16位。
mega系列有雙周期硬件乘法器,但沒有硬件除法器,除數確定的除法編譯器會轉化為乘法來計算,不確定的就只能調用除法路徑了。這種除法偶爾算一次還行,每個定時器周期都算就會嚴重拖慢速度,比如這句判斷是否該切換列的語句:
if (clock == period * 3 / 8 + (uint32_t)period * phase / width / 4)
// ...
要加uint32_t
轉換是因為period
是uint16_t
類型,整數提升成unsigned int
(int
是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 == 128
,compute.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_t
、uint8_t
和uint8_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
。可能是這個項目對於目前的我過於簡單,這當然是件值得欣喜的事;或者,
是因為高考臨近了吧。