連結


上一篇: 嵌入式系統之軟體架構-1 (Software Architecture of Embedded System)

下一篇: 嵌入式系統之軟體架構-3 按鍵掃描

進化一: 定期執行 IO



上一篇在說明無窮迴圈時, 我們提到了使用 LED 和 keypad 的例子:

void do_processes(void)
{
    if (isKeyPressed(key_A))
        do_processA();
    if (isKeyPressed(key_B))
        do_processB();
    ...
}

void main(void)
{   // Init 的順序不一定要和迴圈中的執行順序一樣.
    init_outPort();  // Relay & LEDs
    init_2X16LCM();
    init_keypad();
    init_processes();

    while(1) {
        // 執行順序通常是:輸入->處理->輸出
        // 輸入 input
        read_keys();

        // 處理 process
        do_processes();

        // 輸出 output
        out_Relay();
        out_LED();
        out_2X16LCM();
    }
}

接下來我們要討論 LED 輸出及 Keypad 輸入在實作時的一些變化, 先來看 LED 輸出的部份:

如果要控制的 LED 只是少量幾顆, 以往一般都是使用 MCU 的 GPIO 接腳(註一)來控制外部電晶體的 '開' 和 '關', 間接的達成控制 LED 的開和關(註二). 但是如果 LED 的數量多了, 像是用來顯示 '數字' 的七段顯示器: 一個數字就有 8 顆/組 LED 要控制 (如果是 '米字型' 或者 '點矩陣型' 則需要控制的 LED 就更多了). 這些七段顯示器不用多, 3~4 個數字可能就會讓 MCU 的 GPIO 不夠用了(註三). 這時可以因應的方式之一就是改用軟體掃描 (scan) 的方式(註四).

LED 以 '掃描' (scan) 方式點亮, 主要是利用人類視覺暫留的現象, 一般靜態畫面和變動不大的畫面以 30 Hz 左右來掃描都可以得到還不錯的效果. 但是不可以低於 25 Hz, 否則很容易就被查覺有快速閃爍的現象(註五). 因此, 實作時我們必需:

  • 先將 LED 分成 N 個群組. 通常是依據 LED 模組的外觀, 需要控制的 LED 總數量以及 CPU 對 IO 的控制能力來分組: 一般是 8 個一群或者 16 個一群, 當然也可能是 32 個一群. (如果是其他數字其實也不必太驚訝啦!)
  • 然後依據 LED 的群組數 N, 以 (大於) 25~30Hz x N 的頻率, 一次點亮一個群組, 來 '輪流點亮' 這些 LED (~30ms 之內每一群 LED 都要輪流亮一次).

所以掃描式的 LED 驅動電路中每一顆 LED 的開和關是被二個 GPIO (外加二顆電晶體) 控制. 其中一個 GPIO 控制要點亮這一群 LED, 另一個 GPIO 則是控制點亮一群其中的一顆 LED.(註六) 或者你也可以用另一個觀點來看: 其中一個 GPIO 控制供電的電晶體 (如下圖的 seg.A ~ seg.P 控制接腳), 另一個 GPIO 控制接地的電晶體 (如下圖的 digit0 ~ digit7 控制接腳).

Scan 7 Seg LED drive

共陰七段顯示器之掃描驅動電路

LED 如此, 按鍵也有同樣的狀況: 數量少, 直接用 GPIO 讀取, 數量多的時候也是要分群利用掃描的方式來讀取. 也因此我們可以很合理的把這二個需要結合在一起: 假設我們總共要點亮 6 個七段顯示器, 那就可以每 5ms 同時切換一組 LED 和 Keypad 群組 (6 x 5ms = 30ms 合乎視覺暫留的要求). 下面這張二圖是一顆很古老的晶片 (三十幾年前, 1987) intel 8279 的內部方塊圖以及外部介接應用的情形, 它就是利用 '掃描' 方式來擴充 MCU 可以控制的 LED 及按鍵的總數, 並且將 LED 和 keypad 的掃描控制結合在一起. 不過和我們下面要介紹的軟體掃描不同的是: 它是以硬體來實作 '掃描' 的能力, CPU 完全不必介入掃描的過程 (但是原理是一樣的).

8279_func_block

8279 內部方塊圖

8279_Application

8279 的應用圖例

註一: GPIO 是 General Purpose Input/Output 的縮寫, 翻譯成中文是通用輸出輸入, 意思是該接腳可以設定成輸出或者也可以設定成輸入. 更有些 MCU 的 GPIO 可以設成三態 (主要是用來延伸 Address Bus 和 Data Bus 至 IC 外部).

註二: 由於以前的 MCU 為了降低功耗大都是用 CMOS 技術製作的, IO 接點上無法提供太大的電流, 因此都會多使用一顆可以趨動較大電流的電晶體. 多用一顆電晶體不只可以讓 MCU 用比較小的電流來控制外部需要較大電流才能趨動的元件 (例如: 繼電器 Relay). 還有另外一個好處是大電流不必流經 MCU, 相對的 MCU 就可以避開不小心燒燬的可能. 另外像是需要使用較高 (或是不同) 電壓驅動的元件也是必需使用電晶體的. 現代則因為半導體技術的進步, IO 接點設計觀念的進步, 還有 LED 也因新的製作技術耐壓提高了, 同時也更省電了...所以現在需不需要多一顆電晶體也就見人見智了.

註三: 早期常見的 MCU 包裝大多是 40 pin 的 DIP (Dual In-line Package) 包裝. 扣除電源, 接地, 石英振盪, 外部中斷...等等必要的接腳之後, 能留下 32 支 GPIO 的接腳算是很厲害的 MCU 設計了.

註四: 除了使用掃描的方式之外, 也可以使用 IO 擴充 IC. 不過兩相比較, 使用掃描 (scan) 的方式來實作控制大量 LED (以及 Keypad) 的需求具有成本低廉的優勢 (至少減少了一顆以上的 IC, PCB 面積較小以及佈線容易, 加工成本較低...等等). 其實許多控制 LED 顯示用的 IO 擴充 IC, 它們的內部也是使用硬體掃描的方式, 例如: MAX 7219/7221 這顆 SPI 介面的 8x8 LED 驅動 IC, 它也是使用掃描的方式控制 8 群 (每群 8 顆) LED 的掃描, 同時也控制 LED 的整體亮度.

註五: 有一點要特別提醒的, 以'掃描' (scan) 的方式點亮 LED, 一定要植基於定期執行, 否則你的 LED 就會有變動 (或者固定) 的明暗不一的閃爍現象. 這個閃爍現象和掃描頻率低於 25 Hz 的現象是不一樣的.

註六: 因為 SCAN 的關係, 每一群 LED 點亮的時間會變成原本的 N 分之一, LED 的平均亮度自然也跟著變小, 因此 N 不能太大. 對於亮度變小, 一般簡易的因應方式是把 LED 的限流電阻變小 (可以有所補救, 但是不會和不使用 SCAN 的狀況完全一樣). 另外不能直接把限流電阻省略, 省略了限流電阻很容易因為其他元件的故障就讓 LED 燒燬.

掃描 (定期執行): 修改無窮迴圈


好啦, 說了半天其實要說的重點是: 我們需要定期 (上述的 5ms) 執行 LED 和 keypad 的描掃動作. 但是要如何實作呢? 有人可能會想到用迴圈計數的方法, 迴圈計數可以應用在單純的小延遲, 但是在這裡卻是不正確的作法. 理由很簡單: 你無從得知原本 while(1) 迴圈中的輸入-處理-輸出副程式一共用了多少時間執行? 剩下的時間又應該設定迴圈計數多少次? 所以, 正確的作法應該是直接使用硬體計時器: 把計時器設定成每隔一段時間 (我們需要的時間區間) 告知我們時間到了, 並且重新開始計時. 目前 MCU 內建的計時器模組大部份都可以設定成 "計時終了時, 通知我們時間到了並自動重載原本設定的計數值", 不需要我們一再重覆的重新設定. 所以作法是在 while(1) 迴圈的前一行設定並啟動這個計時器, 等到輸入-處理-輸出都處理完了, 最後在 while(1) 迴圈的最後等待計時器發生計時終了. 如果你所使用的計時器並沒有提供自動重載的功能, 那就把設定並啟動計時器的 setTimer() 移到 while(1) 迴圈裡的第一行也行, 但是有一點需要注意一下: 這樣作會使得整個計時的區間稍微多了一些些, 如果你需要精確的 5ms, 請你修正一下載入的數值. 所以我們的無窮迴圈就會修改成這個樣子: (注意: 我們還沒有要用中斷的方法)

    setTimer(5000, true);    // 5ms, looping
    while(1) {
        // 執行順序通常是:輸入->處理->輸出
        // 輸入 input
        read_keys();

        // 處理 process
        do_processes();

        // 輸出 output
        out_Relay();
        out_LED();
        out_2X16LCM();
        while (! isTimeout());
        clearTimeout();
    }

當中的 setTimer(), isTimeout()clearTimeout() 這三個函數內容大概是長這樣:(註七).

void setTimer(int timeout, bool bLoop) {
    TM1.timeout  = timeout;   // setup timeout value (us)
    TM1.loopFlag = bLoop;     // setup looping flag
    TM1.Start();              // start timer
    return;
}

inline bool isTimeout() {
    return TM1.timeoutFlag;
}

inline void clearTimeout() {
    TM1.timeoutFlag = false;
    return;
}

這樣子改 CPU 運作時間軸應該是下面這張圖的樣子. 另外要特別注意計時器設定的時間 (5ms) 一定要比整個輸入-處理-輸出所需要的執行時間長一些才行, 不然也是會忽長忽短 (後面我們再來看萬一有些動作的執行時間真的很長要如何處理).

infinite_loop_timeout

時間軸: 無窮迴圈的執行對比定期中斷執行.

註七: 我們暫時只用 C. 同時 TM1 是某個硬體計時器的控制暫存器組. 真實設上去的 timeout 數值必需依據計時器 clock source 的頻率和 pre-scaler 的設定數值計算調整.
有些計時器讀取 timeout 位元時會自動將它清掉, 有些則不會. 不會自動清除的我們才需要呼叫 clearTimeout() 來把timeout 位元清除.
另外也有些計時器需要清除中斷位元才能正常繼續運作.

For Arduino

有關上面所述的無窮迴圈的改法, 初學 Arduino 的人可能認為只要在 loop() 裡加上一行, 呼叫 delay() 就好了. (因為有很多書本或網站的範例都這樣子寫, 例如: 這一篇的前幾個例子). 使用 delay() (或者類似的函數) 以目前的狀況來說並不是什麼大錯, 但是我強列的建議你不要使用 delay(). 為什麼呢? 因為 Arduion 的 delay() 是一但進去了, 非要到指定的時間計數結束了才會出來 (這一段時間除了執行中斷的副程式, CPU 是其他什麼事也幹不了). 下面就是一個無法用 delay() 來完成的例子:

  • LED1 亮/暗 各 100ms 循環 10 次, 一共是二秒鐘.
  • 同時 LED2 亮/暗 各 250ms 循環 4 次, 也是二秒鐘.

腦筋轉得快一點的人或許會想到: 簡單, 只要計時單位取二者的最大公因數 (50ms) 就可以了. 但是, 如果再加上一些限制, 例如: 二者的啟始時間不一定是同步的呢?.

還有, 如果你是在某些函數中使用了 delay(), 後來你的專案越長越大, 越來越複雜. 最後你發現整個無窮迴圈跑一趟下來所需要的時間超過可以接受的最高數值. 你需要換更快的 CPU 嗎? 不... 還有許多的 CPU 計算能力被你浪費在什麼事也沒幹的 delay() 裡啊!

另外一個問題是我們在 loop() 裡還呼叫了有關輸入-處理-輸出的函數啊, 它們的執行時間必需要扣掉, 不然整個無窮迴圈的執行時間就會忽長忽短.

掃描 (定期執行): 修改 LED 輸出


定期開始執行主迴圈內的副程式搞定了之後, 再來是修改 LED 的顯示副程式 out_LED(). 因為 LED 要一組一組的掃描, 我們需要多一組變數來記錄目前掃描到第幾組了, 並且每次執行時加一, 然後更新 LED 掃描輸出和 LED 資料輸出.

#define MAX_SCANCNT 6
#define SCAN_PORT   GIOPA
#define LED_PORT    GIOPB

uint8_t buffLEDs[MAX_SCANCNT];

void out_LED(void) {
    static int8_t scanCnt = -1;   // 我們想要從 0 開始掃描

    scanCnt += 1;
    if (scanCnt >= MAX_SCANCNT)
        scanCnt = 0;

    outPort8(LED_PORT, LED_OFF);    // 少了這一行 LED 會漏光
    outPort8(SCAN_PORT, scanCnt);
    outPort8(LED_PORT, buffLEDs[scanCnt]);
}

這裡有幾個重點:

  1. 變數 scanCnt:
    • 我把它放在函數的內部, 因為我不想它被外部的其他程式更動了.
    • 還有, 我把它設成 static, 因為它只需要啟始設定一次就夠了, 而不是每次呼叫 out_LED() 時重新啟始設定一次.
    • 它的啟始值是 -1, 這樣子第一次執行後才會變成 0
  2. 更動 SCAN_PORT 的內容之前要把 LED 都關掉, 不然上一組 LED port 的內容會有一小瞬間的漏光到這一次要點亮的群組裡. 當然如果你的硬體計設可以同時更新 SCAN_PORTLED_PORT 的內容就不用這麼麻煩了. 其實這種同時更新的動作就是我們前一篇所提到的輸出同步化概念的一個應用.

上面的 out_LED() 副程式對應的是 SCAN_PORT 輸出到選組的電晶體之間有一組解碼器(註八) 把 2 進位的數值轉換成選擇其中一組 LED (圖 1 中的 digit n 接腳). 如果你的 GPIO 腳位夠多, 可以讓你把這一顆解碼器拿掉的話, 那就可以直接在內部用查表的方式把原本 SCAN_PORT 的輸出轉換成解碼後的輸出. 當然這裡列出的數據 0x01, 0x02, 0x04... 只是展示觀念用的 psuedo code 而已, 你必需依照電路的邏輯極性 (正邏輯或負邏輯) 改變內容. 還有, 這裡修改的這幾行裡我們多加了一些小防呆:

  • 雖然目前只用了 6 個輸出, 3-to-8 對照表還是填滿 8 個.
  • 查表時多作一次 & 運算把查表的索引值限制在 0~7 之間.

這些動作看似多餘, 其實很重要: 如果日後擴充了掃描組數, 但又沒有修改到這個對照表, 那你多出來的部份就會查表查到垃圾資料. 雖然這個 3-to-8 對照表只是 SCAN_PORT 的輸出值影響不大, 但如果查表的內容是 function pointer 呢? 那可是會當機的啊! 所以請你養成好習慣.

#define MAX_SCANCNT 6
#define SCAN_PORT   GIOPA
#define LED_PORT    GIOPB

const uint8_t scanPattern[] = {   // 3-to-8 解碼的對照表
    0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80
}
uint8_t buffLEDs[MAX_SCANCNT];

void out_LED(void) {
    static int8_t scanCnt = -1;

    scanCnt += 1;
    if (scanCnt >= MAX_SCANCNT)
        scanCnt = 0;

    outPort(LED_PORT, LED_OFF);
    // SCAN_PORT 的輸出改成下面這個樣子
    outPort(SCAN_PORT, scanPattern[scanCnt & 0x07]);
    outPort(LED_PORT, buffLEDs[scanCnt]);
}

註八: 一般它是一顆 74HC138 3-to-8 解碼器, 74HC42 BCD 解碼器 (4-line BCD to 10 line decoder) 或是 74HC154 4-to-16 解碼器.

另外如果需要 scan 的組數 SCAN_PORT 是 2, 4, 8...這種 2 指數邊界的話, 我們可以在變數 scanCnt 加一之後直接用 & 運算把多餘的數值進位歸零, 而不需要後面的判斷式和指定運算.


由於 keypad 的 scan 稍微複雜一些, 我們下一篇再來討論我會將它拆成二部份: 一部份是講有關如何定期執行的變化, 另一個則是有關 scan key 的實作細節及相關的原理和原則. 下一篇我們要討論的是有關定期執行 key can 的變化, 先這樣了.

連結


上一篇: 嵌入式系統之軟體架構-1 (Software Architecture of Embedded System)

下一篇: 嵌入式系統之軟體架構-3 按鍵掃描

arrow
arrow

    MagicJackTing 發表在 痞客邦 留言(1) 人氣()