馬達控制教學:從 LEGO EV3 到 Arduino

前言

在機器人製作和自動化專案中,精準的馬達控制是核心關鍵之一。LEGO Mindstorms EV3 提供了一個高度整合、易於使用的馬達系統,而 Arduino 則提供了一個更具彈性、成本更低但需要更多電子知識的平台。

本教學將分為兩部分:
1. 分析 LEGO EV3 馬達的特色與功能
2. 說明如何使用 Arduino Uno 搭配相應硬體,來實現與 EV3 馬達類似的強大功能


第一部分:LEGO EV3 馬達的特色與功能

LEGO EV3 的馬達之所以強大且適合教學,主要歸功於其內建的整合式設計。使用者無需處理複雜的電路和底層程式碼,即可實現精準控制。

主要特色:

1. 整合式旋轉感測器 (內建編碼器)

這是 EV3 馬達最核心的功能。每個大型和中型馬達都內建一個高精度的旋轉感測器(Encoder),能夠:
* 測量旋轉角度:可以精確知道馬達轉了幾度,精度高達 1 度。
* 計算旋轉圈數:可以累計馬達總共轉了多少圈。
* 提供即時回饋:程式可以隨時讀取馬達的當前位置,實現閉環控制(Closed-loop Control)。

2. 伺服控制 (Servo Control)

EV3 馬達是「伺服馬達」,意味著它們不僅僅是轉動,更能:
* 維持特定速度:你可以命令馬達以設定的功率(速度)穩定轉動,EV3 主機會自動調整功率以應對負載變化。
* 轉到指定位置:可以命令馬達轉到某個絕對角度(例如:90度位置)並保持不動。
* 鎖定位置:在停止時,馬達會主動施加力量抵抗外力,以維持目前的位置。


第二部分:Arduino 實現類似 EV3 的馬達控制

使用 Arduino 要達到 EV3 馬達的功能,我們無法使用單一元件,而需要組合一個系統。核心思想是「馬達 + 感測器 + 驅動器」

核心組件 (Hardware Requirements)

Arduino 可搭配的馬達種類

Arduino 可以控制多種馬達,每種都有其獨特的應用場景。

1. 直流馬達 (DC Motor)

2. 伺服馬達 (Servo Motor)

3. 步進馬達 (Stepper Motor)

深入探討:編碼器與馬達的配合

要讓一個普通的直流馬達(DC Motor)變得像 EV3 馬達一樣「聰明」,關鍵就在於編碼器(Encoder)

為什麼需要編碼器?

一個基本的直流馬達是開環的——你給它電,它就轉,但你不知道它轉了多快、轉了多少圈。編碼器提供了一個回饋迴路,把馬達的物理運動轉換成 Arduino 能讀懂的電子訊號,從而實現閉環控制(Closed-loop Control)。它回答了兩個關鍵問題:
1. 我轉了多遠? (位置)
2. 我轉了多快? (速度)

常用編碼器硬體

市面上常見的編碼器通常已和小型直流減速馬達整合在一起,方便直接使用。
* 霍爾效應編碼器 (Hall Effect Encoder):通常與黃色的 TT 減速馬達配套,在馬達尾部有一個小磁環和兩個霍爾感測器。精度相對較低,但便宜耐用,適合入門。
* 光學編碼器 (Optical Encoder):如 JGA25-370、Pololu 等品牌的馬達,內建了光學碼盤,精度較高,反應更靈敏,適合需要精準控制的場合。

硬體辨識:如何判斷 TT 馬達是否有編碼器?

這是在選購或使用零件時非常關鍵的一點。

1. 看接線數量(最準確的方法)

2. 看外觀

結論:一個只有兩條線的 TT 馬達,絕對沒有編碼器。

如何在 Arduino 中高效讀取編碼器?

馬達轉速可能很快,每秒產生數千個脈衝。如果使用傳統的 digitalRead() 在主迴圈 loop() 中讀取,極易錯過脈衝,導致計數不準。正確的方法是使用「外部中斷」(External Interrupts)。

範例程式碼:讀取編碼器數值

這是一個只讀取編碼器數值並透過序列埠輸出的基礎範例。

// --- 硬體定義 ---
// 將編碼器的A相和B相輸出分別接到Arduino的2號和3號腳位
// 這兩個腳位在Arduino Uno上支援外部中斷
#define ENCODER_A_PIN 2
#define ENCODER_B_PIN 3

// --- 全域變數 ---
// 使用 volatile 關鍵字,因為這個變數會在中斷函式中被修改
// 確保主程式能讀取到最新的值
volatile long encoder_pos = 0;

void setup() {
  // 初始化序列埠,用於顯示結果
  Serial.begin(9600);

  // 設定編碼器腳位為輸入模式,並啟用內部上拉電阻
  // 這樣可以避免腳位懸空時的訊號干擾
  pinMode(ENCODER_A_PIN, INPUT_PULLUP);
  pinMode(ENCOER_B_PIN, INPUT_PULLUP);

  // 設定中斷
  // 當 ENCODER_A_PIN 的電位發生變化 (RISING, FALLING, CHANGE) 時,呼叫 do_encoder 函式
  attachInterrupt(digitalPinToInterrupt(ENCODER_A_PIN), do_encoder, CHANGE);
}

void loop() {
  // 在主迴圈中,每50毫秒讀取一次計數值並輸出
  // 注意這裡並沒有做任何複雜操作,以確保能即時反應中斷的結果
  Serial.print("Encoder Position: ");
  Serial.println(encoder_pos);
  delay(50);
}

// --- 中斷服務函式 (ISR) ---
// 每次 ENCODER_A_PIN 腳位的電位改變時,此函式就會被觸發
void do_encoder() {
  // 讀取A相和B相的目前狀態
  int state_a = digitalRead(ENCODER_A_PIN);
  int state_b = digitalRead(ENCODER_B_PIN);

  // 根據A相和B相的電位組合來判斷方向
  // 這是標準的正交編碼器解碼邏輯
  if (state_a == state_b) {
    encoder_pos++; // 順時針轉動
  } else {
    encoder_pos--; // 逆時針轉動
  }
}

進階控制:PID 演算法實現精準調速

僅有編碼器我們只能「知道」馬達的狀態,但要「控制」它穩定在某個速度或位置,就需要閉環控制演算法,其中最經典的就是 PID 控制

什麼是 PID 控制?

PID 是一種回饋控制演算法,它會持續計算一個「誤差值」(Error,即 目標值 - 目前值),並試圖最小化這個誤差。它由三個部分組成:

  1. P - 比例 (Proportional)

    • 作用:對「現在」的誤差做出反應。誤差越大,控制力道越強。
    • 公式P_output = Kp * error
    • 行為:反應最快,但單獨使用 P 控制時,馬達很難精確停在目標點,常會出現「穩態誤差」(Steady-state Error),或在目標點附近震盪。
  2. I - 積分 (Integral)

    • 作用:消除「過去」累積的誤差。如果系統長時間存在一個微小的穩態誤差,積分項會不斷累積,直到產生足夠的力道來消除這個誤差。
    • 公式I_output = Ki * (累積誤差)
    • 行為:能有效消除穩態誤差,但反應較慢。如果 Ki 值太大,可能導致系統震盪(Overshoot)。
  3. D - 微分 (Derivative)

    • 作用:預測「未來」的誤差趨勢。它觀察誤差變化的速率,如果誤差正在快速減小(代表系統正衝向目標),微分項會產生一個反向的力道來「踩剎車」,防止衝過頭。
    • 公式D_output = Kd * (目前誤差 - 上次誤差)
    • 行為:能有效抑制震盪,增加系統的穩定性。但對訊號中的雜訊非常敏感。

三者結合,最終輸出 = P_output + I_output + D_output,就能達到快速、穩定且精準的控制效果。Kp, Ki, Kd 這三個常數需要根據實際的馬達和負載進行「調參」(Tuning),以達到最佳效果。

範例程式碼:使用 PID 控制馬達速度

這個範例整合了編碼器讀取和 PID 速度控制。

// --- 硬體定義 ---
#define ENCODER_A_PIN 2
#define ENCODER_B_PIN 3
#define MOTOR_PWM_PIN 5   // 馬達速度控制 PWM 腳位
#define MOTOR_DIR_PIN 4   // 馬達方向控制腳位

// --- PID 控制參數 ---
// !!注意!! 這些值需要根據你的馬達和負載進行調整 (Tuning)
double Kp = 2.0;
double Ki = 5.0;
double Kd = 1.0;

// --- PID 運算變數 ---
double target_speed = 100; // 目標速度 (單位: 脈衝數/取樣週期)
double current_speed = 0;
double error = 0;
double last_error = 0;
double integral_error = 0;
double derivative_error = 0;
int motor_output = 0;

// --- 編碼器變數 ---
volatile long encoder_pos = 0;
long last_encoder_pos = 0;

// --- 時間控制變數 ---
long last_time = 0;

void setup() {
  Serial.begin(9600);

  // 編碼器設定
  pinMode(ENCODER_A_PIN, INPUT_PULLUP);
  pinMode(ENCODER_B_PIN, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(ENCODER_A_PIN), do_encoder, CHANGE);

  // 馬達驅動設定
  pinMode(MOTOR_PWM_PIN, OUTPUT);
  pinMode(MOTOR_DIR_PIN, OUTPUT);
  digitalWrite(MOTOR_DIR_PIN, HIGH); // 設定一個固定方向

  last_time = millis();
}

void loop() {
  // --- 計算當前速度 ---
  long current_time = millis();
  long dt = current_time - last_time;

  // 每隔 10ms 計算一次速度和PID
  if (dt >= 10) {
    // 速度 = (目前位置 - 上次位置) / 時間差
    current_speed = (encoder_pos - last_encoder_pos) / (double)dt;

    // --- PID 計算 ---
    error = target_speed - current_speed;
    integral_error += error * dt;
    derivative_error = (error - last_error) / dt;

    // 計算PID總輸出
    double pid_output = (Kp * error) + (Ki * integral_error) + (Kd * derivative_error);

    // --- 驅動馬達 ---
    // 限制輸出範圍在 0-255 之間
    motor_output = constrain(pid_output, 0, 255);
    analogWrite(MOTOR_PWM_PIN, motor_output);

    // --- 更新變數 ---
    last_encoder_pos = encoder_pos;
    last_error = error;
    last_time = current_time;

    // --- 監控輸出 ---
    Serial.print("Target: "); Serial.print(target_speed);
    Serial.print(" | Current: "); Serial.print(current_speed);
    Serial.print(" | Output: "); Serial.println(motor_output);
  }
}

// --- 中斷服務函式 (ISR) ---
void do_encoder() {
  if (digitalRead(ENCODER_A_PIN) == digitalRead(ENCODER_B_PIN)) {
    encoder_pos++;
  } else {
    encoder_pos--;
  }
}

硬體接線示意

  1. 電源 -> 馬達驅動板:將獨立電源的正負極接到 L298N 的 VCCGND
  2. 馬達驅動板 -> 馬達:將 L298N 的輸出端(如 OUT1, OUT2)接到直流馬達的兩個接腳。
  3. Arduino -> 馬達驅動板
    • 將 Arduino 的 GND 與 L298N 的 GND 相連(共地)。
    • 將 Arduino 的數位 I/O 接腳(如 ~5, 4)接到 L298N 的 ENA, IN1 等對應腳位。
  4. 編碼器 -> Arduino
    • 將編碼器的 VCCGND 接到 Arduino 的 5VGND
    • 將編碼器的 A/B 相訊號輸出接腳,接到 Arduino 的外部中斷接腳(在 Uno 上是 D2D3)。

功能對比與 Arduino 實現方式

EV3 功能 Arduino 實現方式
讀取旋轉角度/圈數 使用中斷讀取編碼器脈衝數,再透過數學換算得到。
以特定速度轉動 結合編碼器回饋和 PID 控制演算法,動態調整 PWM 輸出,實現精準的閉環速度控制。
轉到指定位置 結合編碼器回饋和 PID 位置控制演算法,計算與目標位置的誤差,並驅動馬達消除誤差。

實作範例:Arduino 雙輪機器人控制

本章節將前面討論的零件整合起來,建立一台可以前進、後退、轉彎和調速的雙輪機器人。我們將使用最常見的 L298N 馬達驅動板和兩個標準的 TT 馬達(無編碼器版本,做開環控制)。

1. 所需硬體 (Required Hardware)

2. 硬體接線說明

重要:接線前請確保所有電源都已斷開!

  1. L298N 電源設定:

    • 移除 ENA 和 ENB 上的黃色跳線帽。這是為了讓 Arduino 能透過 PWM 控制馬達速度。如果跳線帽插著,馬達只會全速運轉。
    • 將 4顆AA電池盒 的正極 (+) 連接到 L298N 的 +12V 端子。
    • 將 4顆AA電池盒 的負極 (-) 連接到 L298N 的 GND 端子。
    • 將 L298N 的 GND 端子用一條杜邦線連接到 Arduino 的任意一個 GND 腳位。(此為共地,非常重要!)
  2. 連接左輪馬達 (L298N 的 Motor A):

    • 將左邊 TT 馬達的兩條線,連接到 L298N 的 OUT1OUT2 端子。
    • L298N IN1 <--> Arduino D7
    • L298N IN2 <--> Arduino D6
    • L298N ENA <--> Arduino D5 (注意:D5 是 PWM 腳位)
  3. 連接右輪馬達 (L298N 的 Motor B):

    • 將右邊 TT 馬達的兩條線,連接到 L298N 的 OUT3OUT4 端子。
    • L298N IN3 <--> Arduino D4
    • L298N IN4 <--> Arduino D3 (注意:D3 是 PWM 腳位)
    • L298N ENB <--> Arduino D9 (注意:D9 是 PWM 腳位)
  4. 為 Arduino 供電:

    • 完成上述接線後,用 USB 線將電腦連接到 Arduino 為其供電和上傳程式。
    • 若要脫離電腦運作,可以將 9V 電池接到 Arduino 的 DC 電源孔。

3. 範例程式碼:基本運動控制

這個程式提供了控制機器人基本動作的函式,並在主迴圈中進行簡單的展示。

// --- 左輪馬達 (Motor A) 的控制腳位 ---
#define ENA 5  // 速度控制 (PWM)
#define IN1 7  // 方向控制
#define IN2 6  // 方向控制

// --- 右輪馬達 (Motor B) 的控制腳位 ---
#define ENB 9  // 速度控制 (PWM)
#define IN3 4  // 方向控制
#define IN4 3  // 方向控制

void setup() {
  // 將所有控制腳位設定為輸出模式
  pinMode(ENA, OUTPUT);
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(ENB, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);

  // 初始狀態讓馬達停止
  stop_motors();
}

void loop() {
  // --- 動作展示 ---

  // 全速前進 2 秒
  move_forward(255); // 速度值 0-255
  delay(2000);

  // 停止 1 秒
  stop_motors();
  delay(1000);

  // 半速後退 2 秒
  move_backward(150); // 速度值 0-255
  delay(2000);

  // 停止 1 秒
  stop_motors();
  delay(1000);

  // 全速原地右轉 1 秒
  turn_right(255);
  delay(1000);

  // 停止 1 秒
  stop_motors();
  delay(1000);

  // 全速原地左轉 1 秒
  turn_left(255);
  delay(1000);

  // 停止 1 秒
  stop_motors();
  delay(1000);
}

// --- 運動控制函式 ---

// 前進
void move_forward(int speed) {
  // 左輪前轉
  digitalWrite(IN1, HIGH);
  digitalWrite(IN2, LOW);
  analogWrite(ENA, speed);

  // 右輪前轉
  digitalWrite(IN3, HIGH);
  digitalWrite(IN4, LOW);
  analogWrite(ENB, speed);
}

// 後退
void move_backward(int speed) {
  // 左輪後轉
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, HIGH);
  analogWrite(ENA, speed);

  // 右輪後轉
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, HIGH);
  analogWrite(ENB, speed);
}

// 原地右轉
void turn_right(int speed) {
  // 左輪前轉
  digitalWrite(IN1, HIGH);
  digitalWrite(IN2, LOW);
  analogWrite(ENA, speed);

  // 右輪後轉
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, HIGH);
  analogWrite(ENB, speed);
}

// 原地左轉
void turn_left(int speed) {
  // 左輪後轉
  digitalWrite(IN1, LOW);
  digitalWrite(IN2, HIGH);
  analogWrite(ENA, speed);

  // 右輪前轉
  digitalWrite(IN3, HIGH);
  digitalWrite(IN4, LOW);
  analogWrite(ENB, speed);
}

// 停止
void stop_motors() {
  // 左輪停止
  digitalWrite(I1, LOW);
  digitalWrite(IN2, LOW);
  analogWrite(ENA, 0);

  // 右輪停止
  digitalWrite(IN3, LOW);
  digitalWrite(IN4, LOW);
  analogWrite(ENB, 0);
}

進階實作:使用編碼器實現精準運動控制

上一章的開環控制(Open-loop Control)很簡單,但有明顯缺點:
* 無法走直線:兩個馬達的實際速度幾乎總有微小差異,導致機器人會慢慢偏向一邊。
* 無法走指定距離delay(2000) 只能控制「時間」,無法控制「距離」。地面摩擦力、電池電壓變化都會影響實際行走的距離。

本章節將引入編碼器,實現閉環控制(Closed-loop Control),解決上述問題。

1. 更新硬體清單 (Updated Hardware List)

大部分硬體與上一章相同,只有馬達需要替換。

2. 更新硬體接線 (Updated Wiring)

馬達驅動部分的接線與上一章完全相同,我們只需要新增編碼器的接線

3. 範例程式碼:行走指定距離並保持直線

這個程式碼會定義一個目標脈衝數,讓機器人走到該距離後停止。並在行進中,不斷比較左右輪的脈衝數,微調速度以保持直線。

// --- 馬達控制腳位定義 (與上一版相同) ---
#define ENA 5
#define IN1 7
#define IN2 6
#define ENB 9
#define IN3 4
#define IN4 3

// --- 編碼器腳位定義 ---
#define LEFT_ENCODER_A 2  // 中斷 0
#define LEFT_ENCODER_B 8
#define RIGHT_ENCODER_A 3 // 中斷 1
#define RIGHT_ENCODER_B 10

// --- 全域變數 ---
volatile long left_pos = 0;
volatile long right_pos = 0;

void setup() {
  Serial.begin(9600);

  // 設定馬達腳位為輸出
  pinMode(ENA, OUTPUT);
  pinMode(IN1, OUTPUT);
  pinMode(IN2, OUTPUT);
  pinMode(ENB, OUTPUT);
  pinMode(IN3, OUTPUT);
  pinMode(IN4, OUTPUT);

  // 設定編碼器腳位為輸入 (啟用上拉電阻)
  pinMode(LEFT_ENCODER_A, INPUT_PULLUP);
  pinMode(LEFT_ENCODER_B, INPUT_PULLUP);
  pinMode(RIGHT_ENCODER_A, INPUT_PULLUP);
  pinMode(RIGHT_ENCODER_B, INPUT_PULLUP);

  // 設定中斷
  attachInterrupt(digitalPinToInterrupt(LEFT_ENCODER_A), read_left_encoder, RISING);
  attachInterrupt(digitalPinToInterrupt(RIGHT_ENCODER_A), read_right_encoder, RISING);

  stop_motors();
}

void loop() {
  Serial.println("準備前進 1000 脈衝...");
  move_forward_distance(1000, 200); // 目標脈衝數, 基礎速度
  stop_motors();
  Serial.println("已停止.");
  Serial.print("左輪最終位置: "); Serial.println(left_pos);
  Serial.print("右輪最終位置: "); Serial.println(right_pos);

  delay(5000); // 等待5秒後重複
}

// --- 運動控制函式 ---

// 走指定距離 (脈衝數)
void move_forward_distance(long target_pos, int base_speed) {
  // 重置計數器
  left_pos = 0;
  right_pos = 0;

  // 簡易 P 控制器 (比例控制器) 參數
  float Kp = 0.5; 

  while (left_pos < target_pos && right_pos < target_pos) {
    // 計算左右輪誤差
    long error = left_pos - right_pos;

    // 根據誤差調整速度
    int left_speed = base_speed - (error * Kp);
    int right_speed = base_speed + (error * Kp);

    // 限制速度範圍
    left_speed = constrain(left_speed, 0, 255);
    right_speed = constrain(right_speed, 0, 255);

    // 驅動馬達
    set_motor(IN1, IN2, ENA, left_speed, 1); // 1 代表前進
    set_motor(IN3, IN4, ENB, right_speed, 1); // 1 代表前進

    // 透過序列埠監控
    Serial.print("L: "); Serial.print(left_pos);
    Serial.print(" R: "); Serial.print(right_pos);
    Serial.print(" Error: "); Serial.print(error);
    Serial.print(" L_Spd: "); Serial.print(left_speed);
    Serial.print(" R_Spd: "); Serial.println(right_speed);
  }
}

// 停止
void stop_motors() {
  set_motor(IN1, IN2, ENA, 0, 0);
  set_motor(IN3, IN4, ENB, 0, 0);
}

// 泛用馬達設定函式
// direction: 1=前, 0=停, -1=後
void set_motor(int in_a, int in_b, int en, int speed, int direction) {
  if (direction == 1) {
    digitalWrite(in_a, HIGH);
    digitalWrite(in_b, LOW);
  } else if (direction == -1) {
    digitalWrite(in_a, LOW);
    digitalWrite(in_b, HIGH);
  } else {
    digitalWrite(in_a, LOW);
    digitalWrite(in_b, LOW);
  }
  analogWrite(en, speed);
}


// --- 中斷服務函式 (ISRs) ---

void read_left_encoder() {
  // 簡單的計數,如果需要判斷方向,則需讀取 B 相
  left_pos++;
}

void read_right_encoder() {
  right_pos++;
}

4. 校正與說明