建智能小車閉環(huán)控制系統(tǒng):編碼器測速與PID避障)
在這個章節(jié)之前你大概率已經(jīng)完成過 Arduino 基礎(chǔ)項目點燈、按鍵輸入、用 PWM 控制電機(jī)轉(zhuǎn)速??雌饋硇≤囈呀?jīng)能“動”起來了但只要你把它放到地上問題立刻就會出現(xiàn)兩個電機(jī)明明寫了同樣的占空比車卻總往一邊偏前面放一把椅子它不知道減速想讓它走一條直線幾乎不可能。很多人到這里的第一反應(yīng)是“換一套更好的電機(jī)”或者“去抄一段避障代碼”但真正的瓶頸不在硬件而在系統(tǒng)沒有形成反饋閉環(huán)。本章會用 Arduino Uno R3 這個非?;A(chǔ)的平臺把一個項目拆成一條完整的控制鏈路編碼器測速、中斷計數(shù)、定時采樣、濾波、PID 閉環(huán)、超聲波避障。最終小車不再只是“執(zhí)行你下發(fā)的轉(zhuǎn)速”而是“實時感知自己實際跑了多快、前方有沒有障礙再決定下一步怎么走”。這才是“導(dǎo)航”的開端。先說判斷做 Arduino 小車真正拉開差距的不是某個單獨(dú)模塊而是你是否把數(shù)據(jù)采集、控制周期、執(zhí)行輸出當(dāng)成一個整體來看。很多教程單獨(dú)講編碼器、單獨(dú)講 PID讀者照著接好線卻發(fā)現(xiàn)車還是不走直線因為每個環(huán)節(jié)的時間基準(zhǔn)不一致中斷計數(shù)是一套邏輯PID 循環(huán)又用 delay 卡住超聲波一測距就阻塞整個主循環(huán)。這一章不僅要講每一個模塊還會把模塊之間的“時序關(guān)系”串起來。讀完后你可以照著完成一輛能直行、能繞障的小型差速車也基本理解了嵌入式控制項目的典型結(jié)構(gòu)。1. 從“能跑”到“會自己走”差在哪里先明確這個項目的定位它不是一個只做“紅外避障”的玩具。很多人做過那種帶紅外對管的小車遇到障礙就隨便拐一下方向完全隨機(jī)。那種小車本質(zhì)上沒有“感知模型”只有條件判斷。真正的自主導(dǎo)航至少要形成一條鏈路。這條鏈路可以用一句話概括測量當(dāng)前狀態(tài) - 與期望狀態(tài)比較 - 計算修正量 - 輸出給電機(jī) - 再測量當(dāng)前狀態(tài)。缺少其中任何一環(huán)小車的行為就會變得不可預(yù)測。對照這個鏈路初學(xué)者最容易犯的錯誤有三個。第一把傳感器和電機(jī)當(dāng)成兩個獨(dú)立任務(wù)。編碼器只負(fù)責(zé)“給串口打印數(shù)字”電機(jī)控制只負(fù)責(zé)“執(zhí)行 PWM 命令”。兩者之間沒有任何關(guān)系。這就導(dǎo)致小車到底跑沒跑直、輪子是不是打滑、電池電壓下降后轉(zhuǎn)速有沒有降低主控一概不知道。第二時間基準(zhǔn)混亂。Arduino 本體沒有操作系統(tǒng)main loop 里的代碼全部線性執(zhí)行。如果為了讀取超聲波用了delay()那么編碼器計數(shù)、PID 計算都會停擺。測速采樣周期漂移太大PID 算出來的值就沒有工程意義。第三沒有先讓數(shù)據(jù)穩(wěn)定就直接上控制算法。PID 的輸入是編碼器速度可編碼器原始數(shù)據(jù)一抖一抖的。新手看到讀數(shù)跳動第一反應(yīng)是加大 PID 參數(shù)結(jié)果小車越調(diào)越晃。先做濾波讓反饋數(shù)據(jù)穩(wěn)定再談控制參數(shù)才是正確順序。所以本章的技術(shù)路線是一條典型的工程路徑編碼器 外部中斷 - 周期性采樣 - 濾波 - PID 速度閉環(huán) - 超聲波/舵機(jī)感知 - 避障狀態(tài)機(jī)。從輸入信息看這一章涉及的技術(shù)熱詞集中在編碼器、中斷、PID、超聲波、舵機(jī)、濾波、定時器這些點說明讀者最關(guān)心的正是這條鏈路中的關(guān)鍵環(huán)節(jié)。做好這件事Arduino Uno R3 這顆 16MHz 芯片的性能完全夠用。它不是算不過來而是開發(fā)者的“軟件節(jié)奏”經(jīng)常不對。2. 硬件組成與接線先確認(rèn)每個模塊的角色本章項目不是一個模塊實驗而是一輛完整小車。為了讓后面代碼不跑偏接線這一步要提前規(guī)劃好。常見敗因是邊寫代碼邊加傳感器到最后引腳沖突、定時器打架。2.1 部件清單一個典型配置如下Arduino Uno R3 主控板。帶 AB 相霍爾編碼器的直流減速電機(jī)左右各一個常見的是 TT 馬達(dá)類底盤。H 橋電機(jī)驅(qū)動模塊例如 L298N 或 TB6612FNG也可用其他基于 H 橋的驅(qū)動板。HC-SR04 超聲波模塊用來測前方障礙距離。SG90 型小舵機(jī)用來改變超聲波探頭的朝向。電池組和穩(wěn)壓部分。電機(jī)供電必須獨(dú)立考慮不能指望 Arduino 的 5V 引腳為舵機(jī)和電機(jī)同時提供大電流。這個組合足夠跑通“局部導(dǎo)航”鏈路。需要提醒的是全局導(dǎo)航通常還需要陀螺儀或里程計模型本章先不引入。2.2 接線規(guī)劃Arduino Uno R3 只有一個重要的稀缺資源需要提前考慮外部中斷引腳只有 D2、D3分別對應(yīng)中斷 0 和中斷 1。編碼器需要實時響應(yīng)所以左右編碼器的 A 相必須接到這兩個引腳。為了留出 PWM 引腳并避免與串口沖突建議按下面的表格接線。模塊信號Arduino Uno R3 引腳左編碼器A 相D2左編碼器B 相A2右編碼器A 相D3右編碼器B 相A3電機(jī)驅(qū)動IN1D4電機(jī)驅(qū)動IN2D5電機(jī)驅(qū)動IN3D6電機(jī)驅(qū)動IN4D7電機(jī)驅(qū)動左使能 PWMD9電機(jī)驅(qū)動右使能 PWMD10超聲波TrigD11超聲波EchoD12舵機(jī)信號線D8接線時做到“先電源后信號”。電池正負(fù)極接電機(jī)驅(qū)動板的 VM 和 GND同時把驅(qū)動板 GND、Arduino GND、升壓模塊 GND 連接到同一個公共地。很多小車第一次上電后串口讀數(shù)亂跳甚至電機(jī)不轉(zhuǎn)一大半是因為共地沒有做。電源方面舵機(jī) SG90 和 HC-SR04 最好單獨(dú)從具有足夠電流輸出能力的 5V 電源取電。USB 口給 Arduino 供電用于下載和調(diào)試沒問題但如果靠著 USB 5V 驅(qū)動兩個電機(jī)和一個舵機(jī)電壓跌落會非常嚴(yán)重。可靠的供電方案是電池組直供驅(qū)動模塊的高壓輸入端Arduino 通過 Vin 或獨(dú)立降壓模塊供電所有地線連在一起。2.3 接線后先做最小驗證接完線不要立刻燒寫完整避障程序否則出了問題根本不知道是接線還是算法。先做最小的靜態(tài)檢查把電機(jī)程序里的 PWM 占空比固定為一個不會直接撞車的低值讓車輪緩慢轉(zhuǎn)動再用示波器、邏輯分析儀或者普通串口觀察編碼器是否產(chǎn)生脈沖。沒有條件時把手轉(zhuǎn)動車輪也可以觀察計數(shù)變化。3. 編碼器與中斷整個控制系統(tǒng)測量數(shù)據(jù)的來源許多剛接觸編碼器的讀者會有一個疑問編碼器不就是兩個引腳輸出脈沖嗎慢慢輪詢讀取不也行嗎對小功率電機(jī)確實可以嵌入式領(lǐng)域的核心技術(shù)難點在于實時性。Arduino Uno R3 的主循環(huán)如果正在執(zhí)行超聲波測距、LCD 刷新或其他耗時邏輯處理器根本沒有時間回頭讀取編碼器引腳就可能丟失計數(shù)。這就是中斷存在的意義。3.1 編碼器輸出兩路什么信號編碼器是一種位置或速度傳感器。電機(jī)轉(zhuǎn)動時內(nèi)部的碼盤會讓霍爾傳感器輸出脈沖信號。常見配置有兩路輸出標(biāo)記為 A 相和 B 相。A、B 兩路信號之間有固定的相位差電機(jī)正轉(zhuǎn)時 A 相超前 B 相 90 度反轉(zhuǎn)時 B 相超前 A 相 90 度。從一個脈沖邊沿看只要判斷另一路的電平就能知道方向。需要區(qū)分兩個概念有些傳感器叫“正交解碼編碼器”有些電機(jī)模塊則只提供“測速接口”。如果電機(jī)模塊只輸出單路脈沖你只能測速度不能判斷方向。本章使用 AB 雙路輸出的編碼器除了測速還能為后續(xù)里程計計算打基礎(chǔ)。3.2 為什么必須掛中斷Arduino Uno R3 的外部中斷引腳只有 D2、D3。使用中斷后每次 A 相出現(xiàn)上升沿CPU 都會立刻進(jìn)入對應(yīng)中斷服務(wù)函數(shù)在中斷函數(shù)里對計數(shù)變量加一。整個過程不需要主循環(huán)參與即使主循環(huán)正在執(zhí)行其他代碼也不會丟脈沖。如果采用“循環(huán)讀取”方式主循環(huán)里一個稍長的delay(50)就可能造成計數(shù)遺漏。尤其當(dāng)系統(tǒng)還要處理超聲波、PID、舵機(jī)時中斷方案幾乎是必須的。設(shè)計中斷程序有一個鐵律中斷服務(wù)函數(shù)內(nèi)部盡量只做簡短操作不要調(diào)用Serial.print()、delay()這類耗時函數(shù)。計數(shù)代碼足夠短正好適合放在中斷里。3.3 計數(shù)與方向判斷的示例下面是一個最小可用程序用于驗證左右編碼器。把兩個編碼器的 A 相接到 D2、D3B 相接到 A2、A3。程序啟動后手動轉(zhuǎn)動車輪串口監(jiān)視器可以看到左右計數(shù)變化數(shù)值增減也能反映轉(zhuǎn)向方向。// 文件路徑02_encoder/02_encoder.ino #define ENC_LEFT_A 2 #define ENC_LEFT_B A2 #define ENC_RIGHT_A 3 #define ENC_RIGHT_B A3 volatile long leftTicks 0; volatile long rightTicks 0; void leftEncoderISR() { if (digitalRead(ENC_LEFT_B) HIGH) { leftTicks; } else { leftTicks--; } } void rightEncoderISR() { if (digitalRead(ENC_RIGHT_B) HIGH) { rightTicks; } else { rightTicks--; } } void setup() { Serial.begin(9600); pinMode(ENC_LEFT_A, INPUT_PULLUP); pinMode(ENC_LEFT_B, INPUT_PULLUP); pinMode(ENC_RIGHT_A, INPUT_PULLUP); pinMode(ENC_RIGHT_B, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(ENC_LEFT_A), leftEncoderISR, RISING); attachInterrupt(digitalPinToInterrupt(ENC_RIGHT_A), rightEncoderISR, RISING); } void loop() { Serial.print(L); Serial.print(leftTicks); Serial.print( R); Serial.println(rightTicks); delay(200); }代碼邏輯是使用volatile修飾中斷里修改的計數(shù)變量避免編譯器優(yōu)化導(dǎo)致主循環(huán)讀到過期副本。attachInterrupt(digitalPinToInterrupt(引腳), 中斷函數(shù), RISING)是在 A 相上升沿觸發(fā)。中斷函數(shù)內(nèi)通過讀取 B 相電平判斷方向。這里的正負(fù)與電機(jī)安裝方向有關(guān)如果發(fā)現(xiàn)轉(zhuǎn)輪時計數(shù)方向相反調(diào)整接線或者對計數(shù)值取反即可。這段程序先不驅(qū)動電機(jī)只做測試。成功標(biāo)志是輪子每轉(zhuǎn)過一圈串口數(shù)字持續(xù)變化且正反轉(zhuǎn)時計數(shù)分別增大和減小。如果發(fā)現(xiàn)只有一只輪子有計數(shù)不要急著懷疑程序優(yōu)先檢查對應(yīng)編碼器的供電、B 相上拉電阻和信號線是否接觸良好?;魻柧幋a器需要供電電壓沒有供電時信號線是懸空的。4. 采樣節(jié)拍與速度換算把原始計數(shù)變成可用速度有了中斷計數(shù)下一個問題是計數(shù)值不能直接用于 PID因為leftTicks雖然越來越大但沒法知道輪子“轉(zhuǎn)得多快”。速度的定義是一段時間內(nèi)位置的變化量。要得到速度必須定時讀取左右編碼器的計數(shù)差值再做換算。這一步的關(guān)鍵是“采樣節(jié)拍”必須穩(wěn)定。4.1 不要用 delay 做采樣用下面的方式寫會很自然while (true) { delay(100); speed leftTicks - lastLeftTicks; }但它有一個缺點程序在delay(100)期間完全阻塞。如果此刻需要同時讀取另一個傳感器整個系統(tǒng)就卡住了。超聲波測距時pulseIn又會阻塞一段時間PID 更新就會被延遲。采樣周期忽長忽短PID 的積分項和微分項都會變得不穩(wěn)定。正確做法是用millis()做非阻塞時間判斷。millis()返回開機(jī)至今的毫秒數(shù)我們可以不斷檢查“現(xiàn)在距離上次采樣是否已經(jīng)超過 100 毫秒”。4.2 速度采樣的完整實現(xiàn)下面的代碼就是把上一節(jié)的計數(shù)值轉(zhuǎn)換為每秒脈沖數(shù)。這個量在后續(xù) PID 里作為“反饋速度”使用。// 文件路徑03_speed_sampling/03_speed_sampling.ino #define SAMPLE_MS 100 unsigned long lastSampleTime 0; long lastLeftTicks 0; long lastRightTicks 0; void updateSpeedSampling() { unsigned long now millis(); if (now - lastSampleTime SAMPLE_MS) { unsigned long dt now - lastSampleTime; if (dt 0) { dt 1; } long deltaL leftTicks - lastLeftTicks; long deltaR rightTicks - lastRightTicks; float leftSpeed deltaL * (1000.0 / dt); // 單位脈沖數(shù)/秒 float rightSpeed deltaR * (1000.0 / dt); lastSampleTime now; lastLeftTicks leftTicks; lastRightTicks rightTicks; Serial.print(L); Serial.print(leftSpeed); Serial.print( R); Serial.println(rightSpeed); } }這里沒有用固定 100 作為分母而是用實際間隔dt即使主循環(huán)偶爾被其他代碼阻塞速度計算也不會因此出現(xiàn)明顯跳變。4.3 從脈沖速度換算到轉(zhuǎn)速“每秒脈沖數(shù)”能夠直接用于左右輪對比但不夠直觀。假如你想知道電機(jī)每分鐘轉(zhuǎn)多少圈需要一個關(guān)鍵參數(shù)每圈產(chǎn)生多少個脈沖。這個參數(shù)通常和編碼器線數(shù)、減速比有關(guān)。如果減速電機(jī)銘牌給出“每圈輸出多少個計數(shù)脈沖”那可以將脈沖速度換算成 RPMrpm (脈沖每秒 / 每圈總脈沖數(shù)) * 60如果你的編碼器安裝在電機(jī)軸上而輪子通過減速箱輸出實際輪子轉(zhuǎn)速還要再除以減速比。不同廠家的電機(jī)不一致所以代碼里最好把這個參數(shù)定義為一個常量方便校準(zhǔn)時修改。需要注意M 法測速在低速時誤差偏大。它的原理是“在一段固定時間內(nèi)數(shù)脈沖數(shù)”如果時間窗口內(nèi)只到來 1 到 2 個脈沖誤差就會非常明顯。讓小車以較慢速度行駛時就會看到速度值在高頻抖動。解決辦法是適當(dāng)延長采樣窗口例如從 50ms 改為 100ms。若速度極低則更適合使用 T 法也就是測量單個脈沖的時間寬度。本章項目把采樣周期控制在 50 到 100ms 比較合適。4.4 外部中斷、定時器與主循環(huán)的時間關(guān)系A(chǔ)rduino Uno R3 的定時器資源不止一個。millis()本身依賴定時器 0PWM 功能依賴定時器 0、1、2。外部中斷則是由引腳電平邊沿直接觸發(fā)的不依賴某個定時器。這帶來一個很好的工程結(jié)構(gòu)外部中斷負(fù)責(zé)對編碼器事件計數(shù)。定時器 0 溢出驅(qū)動millis()產(chǎn)生系統(tǒng)時間戳。主循環(huán)控制 100ms 采樣周期。同一段主循環(huán)還會執(zhí)行 PID 和避障邏輯。這個結(jié)構(gòu)的價值在于編碼器的每一個邊沿都不會因為測距代碼而丟失同時控制邏輯有確定的時間節(jié)拍。對 Uno 這種小內(nèi)存芯片來說不用上 RTOS只要把任務(wù)按時間片劃分清楚也能獲得很穩(wěn)定的控制效果。5. 濾波讓速度反饋先穩(wěn)定下來現(xiàn)在的問題是速度數(shù)據(jù)并不漂亮。哪怕車輪穩(wěn)定勻速轉(zhuǎn)采樣得到的脈沖速度也可能在 150 和 200 之間跳動。原因包括機(jī)械震動、采樣周期開始時編碼器剛好落在邊沿附近、以及 M 法低速誤差。如果把這種數(shù)據(jù)直接給 PID控制量會跟著反饋一起抖動結(jié)果就是電機(jī)聲音發(fā)悶、電流波動、小車行駛不平順。5.1 低通濾波的直觀理解低通濾波器不是嵌入式領(lǐng)域的高深數(shù)學(xué)概念。一句話理解它允許緩慢變化的速度信號通過抑制突然跳變的高頻噪聲。用公式寫出來是filtered filtered alpha * (raw - filtered)其中alpha是一個 0 到 1 之間的系數(shù)。alpha越接近 1濾波器越“激進(jìn)”響應(yīng)快但平滑能力弱alpha越接近 0輸出越平滑但需要更長時間才能跟隨真實速度變化。另一種常見寫法是滑動窗口平均保存最近 N 次采樣值求平均。它的缺點是每次需要存儲多個數(shù)據(jù)并且在速度快速變化時會產(chǎn)生滯后。一階低通濾波只需要一個變量占用資源少適合 Arduino Uno R3。5.2 一階低通濾波代碼把上一節(jié)的leftSpeed、rightSpeed換成smoothLeft即可。下面代碼放在主循環(huán)中頻率為 100ms 的采樣塊里。// 文件路徑04_filter/04_filter.ino float smoothLeft 0; float smoothRight 0; const float ALPHA 0.35f; // 在采樣塊中調(diào)用 // smoothLeft lowPassFilter(rawLeftSpeed, smoothLeft, ALPHA); // smoothRight lowPassFilter(rawRightSpeed, smoothRight, ALPHA); float lowPassFilter(float rawValue, float lastFiltered, float alpha) { return lastFiltered alpha * (rawValue - lastFiltered); }第一次運(yùn)行時如果電機(jī)尚未啟動smoothLeft從 0 開始。當(dāng)實際速度上升時濾波后的曲線會有一段時間的爬升過程。如果想要啟動階段響應(yīng)更快可以在初始化時直接把第一次采樣值賦給濾波器變量。5.3 alpha 參數(shù)怎么選alpha不能盲目亂調(diào)。從控制工程角度看alpha 與采樣周期 Ts 和濾波器時間常數(shù) tau 有關(guān)alpha Ts / (Ts tau)假設(shè)采樣周期是 0.1 秒希望濾波平滑時間常數(shù)約為 0.2 秒那么近似估算alpha 0.1 / (0.1 0.2) 0.33這意味著濾波后的速度值大約要經(jīng)過幾分鐘不是是經(jīng)過若干個采樣周期才能完全跟上真實值。對速度控制來說不需要完全達(dá)到真實值只要穩(wěn)定且誤差在可控范圍即可。實際調(diào)試時你可以先設(shè) alpha 為 0.5觀察串口曲線如果數(shù)據(jù)仍然跳動明顯把 alpha 調(diào)小到 0.2 或 0.3如果響應(yīng)太慢、直行時轉(zhuǎn)彎反應(yīng)遲鈍把 alpha 調(diào)大。要注意濾波是雙刃劍。濾波會帶來延遲這是控制系統(tǒng)中常常被忽略的代價。速度環(huán)信號可以濾波但避障的距離判斷如果同樣用很重的一階低通就可能使小車在發(fā)現(xiàn)障礙后仍然晚剎車。距離傳感器更適合用“多次采樣 取有效值 超時剔除”的思路而不是一味低通。6. PID 控制與自動糾偏速度環(huán)和方向環(huán)前面幾節(jié)解決了“測得準(zhǔn)”的問題現(xiàn)在要進(jìn)入“自動糾偏”。PID 是本章最容易被誤解的部分。不少人把 PID 當(dāng)成一個神秘的“黑盒”直接去 Arduino 庫搜索 PID 調(diào)用然后復(fù)制參數(shù)。這不是項目該有的方式因為不懂原理就不知道怎么調(diào)參數(shù)。6.1 PID 要解決的控制目標(biāo)回到小車的直接問題現(xiàn)在程序給兩個電機(jī)各發(fā)送一個 PWM 占空比比如左邊 150、右邊 150。結(jié)果小車往右偏因為左右電機(jī)在相同 PWM 下實際轉(zhuǎn)速不同。如果手動糾正你可以說把右邊 PWM 減到 140。但這只是開環(huán)補(bǔ)償。隨著電池電壓變化、地面摩擦不同、負(fù)載不同誤差會不斷變化。PID 的作用就是給定一個目標(biāo)速度它根據(jù)實際速度與目標(biāo)速度的差值自動計算一個合適的 PWM 輸出。6.2 P、I、D 三個詞的直觀意義比例項P是基礎(chǔ)。它認(rèn)為誤差越大修正越用力。例如當(dāng)前速度比目標(biāo)低 50P 項提供 2 倍的比例增益輸出增加 100。積分項I用來消除長期誤差。比例控制有一個特點當(dāng)誤差小到一定程度后輸出可能剛好抵消阻力誤差就永遠(yuǎn)剩一點這叫穩(wěn)態(tài)誤差。積分項把歷史誤差累積起來只要誤差持續(xù)存在積分輸出就會逐漸增大直到把誤差推回零。微分項D看誤差變化趨勢。它用來抑制過沖。目標(biāo)速度到達(dá)后誤差在快速變小微分項會給一個反向修正避免輪子一下子沖過頭??梢赃@樣理解P 處理“現(xiàn)在差多少”I 處理“過去差了多少”D 預(yù)測“接下來會差多少”。6.3 位置式 PID 的輕量實現(xiàn)Arduino 平臺有 PID 庫可用但對學(xué)習(xí)項目來說自己寫一個小型 PID 函數(shù)反而更清楚也更容易調(diào)整限制條件。下面代碼采用位置式 PID每次更新都把目標(biāo)速度、實際速度作為輸入輸出一個 0 到 255 的控制量直接對應(yīng) PWM。// 文件路徑05_pid_controller/pid_controller.ino struct PidController { float kp; float ki; float kd; float integral; float lastError; float outMin; float outMax; }; float pidUpdate(PidController* pid, float target, float current, float dt) { float error target - current; pid-integral error * dt; if (pid-integral pid-outMax) { pid-integral pid-outMax; } if (pid-integral pid