跳轉到內容

Khepera III 工具箱/程式設計提示

來自華夏公益教科書

為 Khepera III 機器人編寫程式非常簡單。但是,良好的程式結構和第一次就做好事情會節省您的時間 - 大量的時間!因此,在本章中,我們將討論在實現此類程式時的一些設計選擇。

程式的一般結構

[編輯 | 編輯原始碼]

一個簡單程式的單一狀態

[編輯 | 編輯原始碼]

一個簡單的程式將具有以下結構

// Program initialization
commandline_init();
...
commandline_parse(argc, argv);
...
khepera3_init();

// Algorithm initialization
algorithm.configuration.wall_threshold = 1000;
...
khepera3_drive_start();

// Main loop
while (true) {
    // Read sensors
    khepera3_infrared_proximity();
    ...

    // Calculate actuator response
    speed_left = ...
    speed_right = ...

    // Termination condition
    if (...) {
        break;
    }

    // Set actuators
    khepera3_drive_set_speed(speed_left, speed_right);
    ...

    // Sleep for a while
    usleep(algorithm.configuration.wait_us);
}

// Algorithm termination/cleanup if necessary
khepera3_drive_set_speed(0, 0)

大多數程式由一個主迴圈組成,該迴圈在初始化階段後執行。主迴圈實現了所謂的感知-動作迴圈 - 讀取感測器值、推匯出要採取的動作並相應地設定執行器(電機)的迴圈。主迴圈有時還包含一個終止條件,該條件在達到目標後立即退出程式。

如果您檢視現有程式的程式碼(例如 motion_followline),您會注意到上述結構在不同的函式中實現。通常,該結構會分解成以下函式

  • help:列印幫助文字。
  • algorithm_init:初始化演算法。
  • algorithm_run:執行主迴圈。
  • main:初始化程式並呼叫上述函式。

此外,還定義了一個結構來儲存演算法的配置和狀態(如果需要)。

// Algorithm configuration and state variables
struct sAlgorithm {
    struct {
        int wall_threshold;
        ...
        int verbosity;
    } configuration;
    struct {
        int remaining_targets;
        ...
    } state;
};

// Declare an instance of that structure
struct sAlgorithm algorithm;

// Access to the variables
algorithm.configuration.wall_threshold = ...
algorithm.state.remaining_targets = ...

即使乍一看這似乎很複雜,使用這種巢狀結構也有兩個優點。首先,所有演算法變數都在一個地方宣告。如果您想在其他地方重用該演算法,您一目瞭然地看到需要哪些變數。其次,將配置變數和狀態變數分類將幫助您以乾淨的方式實現演算法。配置變數只會在初始化階段寫入,並且只應該在主迴圈中讀取。然而,狀態變數在主迴圈中讀寫。

具有多個狀態的程式

[編輯 | 編輯原始碼]

通常,程式不止一個狀態。例如,一個參與搜尋任務的機器人可能會有一個探索狀態,在該狀態下它會隨機四處走動,以及一個梯度跟蹤狀態,在該狀態下它會嘗試向目標移動。

實現這一點的最簡單方法是使用不同的主迴圈,即每個狀態一個主迴圈。程式碼骨架如下所示(請注意,由 k3-create-program 指令碼 建立的模板程式使用的是這個骨架)。

// Algorithm variables
struct sAlgorithm {
    ...
    struct {
        void (*hook)(); // Pointer to the current state function
        ...
    } state;
};

// Forward declaration of states
void state_exploration();
void state_gradient_follow();
void state_success();

// Algorithm initialization
void algorithm_init() {
    ...

    // Set the initial state
    algorithm.state.hook = state_exploration;
}

// The algorithm just switches from state to state by calling the current state function
void algorithm_run() {
    while (1) {
        algorithm.state.hook();
    }
}

void state_exploration() {
    // State initialization
    ...

    // Main loop
    while (1) {
        // Read sensors
        ...
    
        // State change condition
        if (...) {
            algorithm.state.hook = state_gradient_follow;  // Switch to the gradient follow state
            return;
        }

        // Set actuators
        ...

        // Sleep for a while
        usleep(algorithm.configuration.wait_us);
    }
}

void state_gradient_follow() {
    // State initialization
    ...

    // Main loop
    while (1) {
        // Read sensors
        ...
    
        // State change condition
        if (...) {
            algorithm.state.hook = state_exploration;  // Switch to the exploration state
            return;
        }

        // Termination condition
        if (...) {
            algorithm.state.hook = state_success;  // Switch to the success state, which terminates the program
            return;
        }

        // Set actuators
        ...

        // Sleep for a while
        usleep(algorithm.configuration.wait_us);
    }
}

void state_success() {
    // We are done
    exit(0);
}

不要害怕函式指標(algorithm.state.hook)!它的宣告可能看起來很複雜,但它非常易於使用,如您在上面的狀態函式中看到的。新增、移動或刪除狀態變得極其容易,因為您可以將狀態視為一個連續的程式碼塊。如果機器人在一個特定狀態下沒有按預期工作,您可以修改其程式碼,而不會影響其他狀態(如果您將所有內容都放在一個帶有if串聯的主迴圈中,通常會發生這種情況)。

此外,如果您先在紙上繪製您的演算法作為狀態機,您就可以立即按照您在紙上的圖表實現程式碼。每個狀態將對應於一個函式,每個箭頭將對應於一個狀態變化條件。

穩定性和執行速度

[編輯 | 編輯原始碼]

從您的 控制理論 課程中,您可能知道感知-動作迴圈必須以一定的最小頻率執行,以滿足穩定性標準。另一方面,以更高的速度執行該迴圈也會消耗更多能量。

上面程式碼示例中顯示的感知-動作迴圈的速度由三個因素決定

  • 通訊開銷,即讀取感測器和設定執行器所需的時間。
  • 處理開銷,即從當前感知推匯出動作所需的時間。
  • 等待時間(usleep(algorithm.configuration.wait_us)),可以隨意調整。

通訊開銷只能在一定程度上最佳化。您需要在機器人和 Korebot 板(處理器板)之間傳輸的位元組數有一個硬性限制,才能獲得一定的資訊,而通訊通道(I2C 匯流排)的固定速度約為 10 KB/s。因此,呼叫khepera3_infrared_proximity 函式來獲取紅外接近感測器值將在大約 3.1 毫秒後返回(如果 I2C 匯流排空閒),因為它必須從微控制器傳輸 31 個位元組到微控制器。每個函式傳輸的位元組數可以在 khepera3 模組.h 檔案中找到。

類似地,為了在給定感測器讀數和當前狀態的情況下推匯出動作,需要進行一定數量的操作。與通訊開銷相比,基本數學運算速度很快,但大量使用logexp 或三角函式會減慢主迴圈速度。此外,所有必須寫入或從磁碟讀取的內容也會涉及大量的開銷。最後但並非最不重要的是,printf會嚴重減慢您的程式速度,具體取決於輸出的重定向位置。

雖然可以準確計算通訊開銷,但處理開銷需要在程式執行的環境中進行測量(例如,將 stdout 重定向到將在真實實驗中重定向到的位置)。然後可以調整等待時間以實現所需的感知-動作迴圈頻率。

是否使用執行緒

[編輯 | 編輯原始碼]

是否使用執行緒是計算機科學中的一場重大爭論。Khepera III 工具箱不會強迫您使用或不使用執行緒,但它是在大多數程式不會使用多執行緒的想法下編寫的。

如果您使用的是上面提供的程式碼骨架,那麼您自然不會使用執行緒(或者如果您想的話,可以使用一個執行緒)。考慮到沒有太多可並行化的地方,這是有道理的:機器人一次只能處於一種狀態,並且必須以精確的順序執行感知-動作迴圈的內部。

非同步 IO 和 select 系統呼叫

[編輯 | 編輯原始碼]

在編寫等待某些資料到達開啟的檔案控制代碼的程式時,select 系統呼叫 將很有用。此係統呼叫獲取檔案控制代碼列表,並在其中一個檔案控制代碼上可用資料或超時發生時返回。select 系統呼叫是解決大多數問題的方案,在這些問題中,程式設計師認為他們需要執行緒。

作為示例,請檢視 motion_arrowkeys 程式,該程式(以非阻塞方式)等待標準輸入上的使用者輸入,並同時執行主迴圈。

無論如何使用執行緒

[編輯 | 編輯原始碼]

如果您出於某種原因需要使用多執行緒,則在訪問機器人感測器和執行器時需要謹慎。基本上,您不允許以併發方式訪問khepera3 模組或i2cal 模組,因為這些模組使用靜態分配的變數,因此不是執行緒安全的。對此有兩個解決方案

  1. 以這樣一種方式設計您的程式,即只有主執行緒(或另一個專用執行緒)訪問這些模組。在某些情況下,這很簡單,但在其他情況下可能會變得非常複雜。
  2. 同步對khepera3 模組的所有呼叫以及所有i2cal 事務。它們需要使用相同的互斥鎖進行同步,因為khepera3 模組使用i2cal 模組與感測器通訊,即大多數khepera3 函式是 I2C 總線上的事務。

如果您想知道:當前的實現有意不是執行緒安全的,因為這會使函式呼叫對單執行緒應用程式來說更加複雜和容易出錯。但是,將來可能會新增一個執行緒安全實現(例如,作為單獨的模組)。

將演算法拆分為獨立的部分

[編輯 | 編輯原始碼]

就像我們將演算法的實現分解為每個實現一個狀態的函式一樣,我們也可以將它們拆分為不同的程式,每個程式實現一個狀態子集,並按正確的順序執行它們。這行得通,實際上在許多情況下都是一種非常好的技術!

迷宮遍歷示例

[編輯 | 編輯原始碼]

假設你想要測試不同的演算法來遍歷一個迷宮。迷宮入口在你的競技場的左側,出口在右側。由於你想要多次執行實驗,所以你想要你的機器人一旦到達出口就自動回到入口,例如透過沿著地面的一條線。當然,你可以為所有演算法新增一個新的狀態(state_go_back_to_entrance),並在到達出口時切換到該狀態。但是,你也可以將其實現為一個單獨的程式(maze_go_back_to_entrance),在你的任何迷宮遍歷程式之後立即呼叫它。

開銷和速度考慮

[編輯 | 編輯原始碼]

啟動和退出程式會產生一些處理開銷,但從作業系統的角度來看,這種開銷很小。不過,你應該小心你的初始化階段。例如,如果你必須在程式啟動時載入大檔案(例如地圖等),這可能會產生一些明顯的延遲。此外,如果你透過 WLAN 連線遠端啟動程式,需要考慮一個小的傳輸延遲(~ 10 毫秒)。

拆分規則

[編輯 | 編輯原始碼]

關於何時拆分以及在同一程式中實現多少內容,並沒有明確的答案。但是,這裡有一些想法

  • 如果這有助於你測試和除錯演算法的各個部分,請進行拆分。
  • 如果這避免了在多個程式中包含完全相同的程式碼片段(如上面的迷宮遍歷示例),請進行拆分。
  • 如果你認為可以將至少一部分程式碼重用於其他地方,請進行拆分。
  • 如果兩個部分之間沒有任何共同之處,請進行拆分。
  • 如果轉換很複雜(例如,多個目標狀態可能),請不要進行拆分。
  • 如果精確的時間或低延遲是一個問題,請不要進行拆分。
  • 如果兩個部分共享狀態變數或大量資料,請不要進行拆分。

此外,通常建議將演算法實現為僅執行一次,然後退出。如果你想在多次執行中測試你的演算法,只需多次啟動該程式(例如使用指令碼)並將輸出重定向到每次執行的不同檔案。這種方法更加靈活和健壯。

程式碼示例

[編輯 | 編輯原始碼]

measure_real_speed 示例使用指令碼多次運行同一個程式,但使用不同的引數。

華夏公益教科書