Keil C51 和標準 C 主要的差異



Keil 的 C51 是以 ANSI C90 為其設計基礎, 即便是如此, 它和標準 C 語言 (ANSI C90) 之間還是有幾個滿大的差異:

  • Keil C51 因應 8051 的特性多了 bit, sbit, sfr, sfr16 等四種資料型態. 一般我們只會用到 bit, 其他三個是定義 CPU 的特殊功能暫存器用的.
  • Keil C51 在定義變數時多了儲存空間修飾字, 用來修改變數使用記憶體空間. 詳細可參考這一篇
  • 多了定義中斷服務常式 ISR 的方法及進入 ISR 時切換暫存器區段 (register bank) 的語法, 例如: static void UART0_ISR(void) interrupt 4 using 2
  • 標準函數庫沒有依標準來實作, 如: printf()
  • 在 Keil C51 中, 函數預設是 non-reentrant, 這點是最嚴重的差異. 我們在使用時要有所警覺才好, 才不致於掉到陷阱裡而不自知.

什麼是 reentrant


在此先來了解一下 reentrant 的定義, Wiki 網站上給 reentrancy 下的定義是

In computing, a computer program or subroutine is called reentrant if it can be interrupted in the middle of its execution and then safely called again ("re-entered") before its previous invocations complete execution.

意思是 reentrant function 可以在執行當中被中斷, 並且在前一次被調用 (invocation) 執行結束之前, 可以安全的再被呼叫. 我想這裡所謂的安全的再被呼叫應該是指產出的執行結果和沒有被中斷完全一致並且也不會附帶產生任何不預期的結果. Wiki 還說這個問題原始是起因於在單一執行緒的程式環境中 (意即:沒有使用 OS; 或者不是多工環境; 或者只有單一個執行緒和這個 ISR 相關), 程式的執行可能隨時被硬體中斷 (hardware interrupt) 打斷, 然後跳到中斷服務程式 (ISR) 執行. 因此 ISR 當中使用的任何副程式只要主程式中也有使用話都會引發 reentrant 問題. (換句話說, 任何 ISR 和主程式共用的副程式都會引發 reentrant 問題)

reentrant function 一般具備下列條件:

  • 不能持有靜態 (或全域) 非常數資料.
  • 不可修改自身的程式碼 (會影響自己下一次呼叫時的行為)
  • 不可呼叫不可重入 non-reentrant 的函數

其中第一項是因為這二種資料經過編譯及連結之後都會佔用在 "固定位址" 上 (相對的自動變數應該要使用堆疊區, 不見得每次執行時都使用同樣的位址), 一旦發生 "中斷-再進入", 這些資料將無法被保證和中斷前一致. 因此這也意味著, 不能用靜態 (或全域) 變數回傳資料給呼叫者.

另外要注意不要把 reentrant 和 thread-safe 搞混了. 二者狀況近似, 但講的是不同的概念. 請參考: 可重入與執行緒安全 (reentrant vs thread-safe) Part1

為什麼 Keil C51 預設是 non-reentrant


回到主題, 我們知道 8051 的 SP (Stack Pointer register) 只有 8 Bits 大小 (只能記錄 256 個地址, 意思是: stack 區最大只能是 256 bytes). 而實際上 8051 CPU 在不擴充外部記憶體的情況下, 內部只有 128 byte 記憶體可用. 如果再扣除 Register Bank0 ~ Bank3 (32Bytes), 還有 Bit Addressable Area (16Bytes), 就只剩 80 Bytes 可用 (128-32-16=80). 而 8052 CPU 則稍微好一點, 又多了 128 Bytes 可用, 但是 208 (80+128) Bytes 幾乎已經是上限了.

對於應用 8051 CPU 的專案來說, 需要用到以 C 語言作為開發工具的, 一般都是中型以上的專案了. 或許對於這樣的專案來說, 208 Bytes 即要當全域變數區, 又要當堆疊區, 函數的參數傳遞區以及區域變數區真的太難了, 所以一般都會有擴充的外部記憶體可用. 正因為如此, Keil C51 就把這 208 Bytes 儘量留給堆疊區 (這樣可以放寬大型專案的副程式呼叫深度). 然後把全域變數, 參數傳遞區和區域變數等等放到另一個區域去. 但是對 C 語言來說, 區域變數和部份參數的傳遞也都需要依靠堆疊區, 所以 Keil C51 提出二種方法來因應:

  • 方法一: 實作一個軟體堆疊區來因應參數傳遞及區域變數的需求. 但畢竟軟體堆疊無法像硬體堆疊的操作一樣快速簡潔, 如果每個函數都使用軟體堆疊就會拖慢系統的執行速度.
  • 方法二: 把無法用暫存器傳遞的函數參數和區域變數都配置在固定位置上, 並且執行呼叫樹分析 (call tree analysis, 這個是 Keil C51 特有的) 來決定可重疊區域的大小, 用以降低記憶體的需求. 所以優點是速度較快, 缺點是記憶體需求大, 而負作用就是函數變成 non-reentrant.

Keil C51 把方法二是當作預設值, 而且允許混用方法一. 需要使用方法一時, 只需在函數定義後面加上 keyword reentrant, 例如:

void myFunc (long arg1, long arg2, long arg3) reentrant
{
}

還可以指定是哪一種記憶體模型 (samll, compact, large)

void myFunc (long arg1, long arg2, long arg3) large reentrant
{
}

不過一旦用了方法一, 另外還需要在 STARTUP.A51 中針對用到的軟體堆疊區進行規劃

;==============================================================
; Reentrant Stack Initilization
; The following EQU statements define the stack pointer for
; reentrant functions and initialized it:
;
IBPSTACK    EQU 0        ; set to 1 if SMALL model is used.
IBPSTACKTOP EQU 0FFH+1   ; set to highest location+1.
;
XBPSTACK    EQU 0        ; set to 1 if LARGE model is used.
XBPSTACKTOP EQU 0FFFFH+1 ; set to highest location+1.
;
PBPSTACK    EQU 0        ; set to 1 if COMPACT model is used.
PBPSTACKTOP EQU 0FFFFH+1 ; set to highest location+1.
;==============================================================

詳細資料可以參考 Keil C51 Application Notes 129: Function Pointers in C51

我們由下表可以看出三種記憶體模型的函數參數及自動變數預設全體變數都被放在相同的記憶體區間, 並且除了 SMALL MODEL 是依然是放在內部記憶體 (data) 之外, COMPACT MODEL 及 LARGE MODEL 都是放在外部記憶體 (pdata, xdata).

Keil C51 支援三種記憶體模型, 預設使用的記憶體空間
model 函數參數及自動變數 預設全體變數 預設常數 預設指標佔用空間
small data data data 3 Bytes
compact pdata pdata pdata 3 Bytes
large xdata xdata xdata 3 Bytes

要注意的是 Keil C51 函數預設是 non-reentrant 真正的原因是 "函數參數及區域變數都被安放在固定位置,而不再是動態使用堆疊區了".

修正 Keil C51 造成的 non-reentrant 問題


接著我們來看一下該如何解決原本 reentrant 函數被 Keil C51 變成 non-reentrant 的問題呢?

一般常見的方法有下列二種:

  • 加上 keyword reentrant 把共用的函數改回 reentrant, 參看上面的例子.
  • 不要有共用函數 (直接在名稱上區別用途)
    • 這是懶人法: 直接把需要共用的函數再複製一份, 改個名字. 一個給主程式用, 一個給 ISR用.
    • 必需確定函數原本真的是 reentrant function.
    • 缺點是程式膨脹了, 另外在維護上也需要多一點心力
      • 需要注意命名法則, 以及在 ISR 及主程式中各自呼叫正確的副程式 (這是最常出錯的部份).
      • 有 bug 要一起改, 二份 code 要一致
      • 在多工環境中可能需要不只二份

如何將 non-reentrant function 改為 reentrant


至於如何把原本是 non-reentrant 的函數改為 reentrant 函數呢? 方向如下:

  1. 將靜態 (或全域) 變數, 改為使用區域變數.
  2. 需要將暫存的資料回傳給呼叫者時,
    • 應改成由呼叫者提供回傳所需的暫存區 (所以函數的原型介面會有所更動, 可以對照標準函數庫 strtok(), strtok_r() 的差異).
    • 如果呼叫者用 malloc() 動態取得暫存區, 也要記得 free().
    • 如果函數的原型介面不可以被更動, 也可以將 malloc() 改由函數本身來呼叫, 但這樣子程式會變得不易維護. 主要是因為呼叫 malloc()free() 在不同一個函數內, 容易犯錯而造成 memory leak, 故非不得已不建議採用.
  3. 真的有困難時 (只有 library, 沒有 source code), 請改用類似 thread-safe wrapper 的方法來避免發生 reentrancy: 重寫另一個函數來取代它, 內容為禁止中斷 (是中斷不是 mutex); 呼叫該函數; 取出回傳資料; 復原中斷的原先設定. (要注意:是復原中斷的原先設定而不是啟用中斷; 可能會有其他負作用)

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