2026年11月2日 星期一

ESP32-S3 Stepper Balancing Robot: Dual-Core OTA and Step-by-Step Tuning | ESP32-S3 步進平衡車:雙核 OTA 架構與調適步驟

手邊有一台步進馬達平衡車(HJ-R3 主板:兩顆 A4988、MPU6050、2 顆 18650),原本接的是傳統 ESP32。這次把控制板換成 ESP32-S3 N16R8,改成 core 0 跑 WiFi/OTA/調參網頁、core 1 跑 200 Hz 平衡控制 的雙核架構。這篇把從燒錄到第一次站穩一分鐘的調適步驟、每一步的實測數據和踩到的坑整理下來。

一、架構:兩顆核心各做各的

核心 負責
Core 1 MPU6050 data-ready 中斷每 5 ms 觸發 → 互補濾波算傾角 → 角度環(200 Hz)+速度環(40 Hz)→ FastAccelStepper 用 MCPWM 硬體產生步進脈衝
Core 0 WiFi、ArduinoOTA、調參網頁(即時看角度、改 PID 不用重燒、存 NVS)

OTA 的安全設計:寫 Flash 時兩顆核心都會停,所以平衡中不收 OTA;OTA 一開始先關 A4988 的 ENABLE;新韌體開機後 IMU 正常跑 10 秒才確認,讀不到 IMU 就自動回滾到舊版。實測 OTA 寫入期間控制迴圈最長被卡住 426 ms,證明這個設計是必要的。

二、腳位:S3 有一半不能照搬

ESP32-S3 沒有 GPIO 22~25,N16R8 模組的 26~32 接 Flash、33~37 接 octal PSRAM,原本的腳位幾乎都要換。主板是用杜邦線接到開發板,所以只要改線、不用動主板。

訊號 原 ESP32 ESP32-S3
MPU6050 SDA / SCL / INT 22 / 21 / 18 8 / 9 / 10
左輪 STEP / DIR 32 / 33 4 / 5
右輪 STEP / DIR 14 / 12 6 / 7
A4988 ENABLE 25 15
電池電壓(新加) — 1(ADC1)

三、程式架構

韌體是 Arduino sketch(esp32 core 3.3.12+FastAccelStepper 2.0.1),約 700 行,依「誰在哪顆核心跑」切檔案:

檔案 跑在哪 內容
config.h — 腳位、MPU 安裝方向、迴圈頻率、PID 預設值(Params 結構)
shared.h 兩核共用 狀態列舉、遙測(Telemetry)、指令(Command)、spinlock
imu.cpp core 1 MPU6050 直接讀暫存器:±4g、±500°/s、DLPF 42 Hz、取樣 200 Hz、data-ready 中斷;讀不到時印診斷
control.cpp core 1 平衡控制任務、狀態機、步進輸出、OTA 健康檢查
net.cpp core 0 WiFi、mDNS、ArduinoOTA、調參網頁與 REST、參數存 NVS
balance_car_s3.ino core 1(loop) 開機順序、每秒量電池+低電量保護、序列埠輸出

任務與優先權

核心 任務(優先權) 做什麼
Core 0 WiFi 協定堆疊(系統) ESP32 的 WiFi 本來就跑在 core 0
net(2) WebServer(/status、/set…)、ArduinoOTA.handle()、斷線重連,每 5 ms 一輪
Core 1 control(configMAX_PRIORITIES − 2) MPU6050 INT 每 5 ms 喚醒一次,跑完整個控制迴圈
FastAccelStepper 引擎 管理速度斜坡,實際脈衝由 MCPWM/PCNT 硬體產生
Arduino loop(最低) 每秒量電池、低電量保護、序列埠輸出

兩顆核心之間只透過 g_params/g_cmd/g_tel 三個結構交換資料,用 portMUX spinlock 保護。

重點有三個:

  • 控制迴圈由硬體中斷驅動:MPU6050 每 5 ms 拉一次 INT,ISR 只做 vTaskNotifyGiveFromISR(),控制任務用 ulTaskNotifyTake() 等它。這樣取樣和控制完全同步,不靠 delay()。中斷 10 ms 沒來會退回輪詢並記一筆 imuErr。
  • 步進脈衝不佔 CPU:FastAccelStepper 用 S3 的 MCPWM+PCNT 硬體產生脈衝,控制任務每 5 ms 只更新一次目標速度,WiFi 中斷再忙也不會讓步進抖。
  • 兩核之間只交換三個小結構,進出都用 portENTER_CRITICAL(&g_mux) 整份複製,臨界區只有幾十個 byte 的 memcpy:
Params    g_params;  // net 寫(/set、NVS),control 讀:kp kd ksp ksi trim alpha vmax amax
Command   g_cmd;     // net 寫(/cmd、/test),control 讀:speed turn enabled testL testR
Telemetry g_tel;     // control 寫,net/loop 讀:state angle gyro speed acc[3] loops overruns …
volatile bool g_otaRequested;  // OTA 開始時 net 設 true,control 看到就關馬達

控制迴圈(每 5 ms)

等 INT → 複製 Params/Command → 讀 MPU6050
→ 互補濾波:angle = α·(angle + gyro·dt) + (1−α)·atan2(accY, accZ),α = 0.98
→ 狀態機決定要不要出力
→ 速度環(每 5 次=40 Hz):targetAngle = −(ksp·(v − v設定) + ksi·∫)
→ 角度環(200 Hz):acc = kp·(angle − trim − targetAngle) + kd·gyro
                    speed += acc·dt(限制在 ±vmax,acc 限制在 ±amax)
→ 左輪 = speed + turn、右輪 = speed − turn → FastAccelStepper
→ 寫回遙測(含這圈花了幾 µs、有沒有超過 5 ms)

狀態機

狀態 進入條件 馬達
idle 開機、網頁按「停用馬達」、馬達測試結束 斷電(EN 高)
running 允許平衡,且扶正到 trim ±5° 內維持 0.5 秒 平衡控制
fallen 傾角超過 ±45° 立刻 forceStop+斷電;再扶正會自動回 running
test /test 指令,非平衡中才准 照指定速度轉到時間到
ota OTA 開始(g_otaRequested) 斷電,直到重開機
fault MPU6050 或步進引擎初始化失敗 斷電,網路照開方便看狀態、重新 OTA

網頁與 API(core 0)

路徑 用途
/ 調參頁:每 200 ms 更新狀態、改參數、允許/停用平衡、馬達測試按鈕
/status JSON:state、angle、gyro、target、speed、batt、battLow、acc[3]、gyro3[3]、loops、imuErr、overruns、maxLoopUs、rssi、reset(重開原因)、uptime、params
/set?kp=..&kd=.. 即時改參數(不寫 Flash,平衡中也能改)
/save 參數存 NVS;寫 Flash 會讓兩核暫停,所以平衡中回 409 拒絕
/cmd?en=1&speed=&turn= 允許平衡、前進速度、轉向差速
/test?l=400&r=0&ms=1000 馬達測試(最多 3 秒、±1600 steps/s)

OTA 流程

espota 送韌體
 → net:狀態是 running 就不呼叫 ArduinoOTA.handle()(上傳逾時,等於拒絕)
 → onStart:g_otaRequested = true,等 control 關馬達、進 ota 狀態(最多 0.5 秒)
 → 寫入另一個 app 分割區(app3M_fat9M_16MB:兩個 3 MB app 分割區)→ 重開機
 → 新韌體狀態是 PENDING_VERIFY(verifyRollbackLater() 回 true)
 → control:IMU 和中斷連續正常 10 秒 → esp_ota_mark_app_valid_cancel_rollback()
            IMU 讀不到              → esp_ota_mark_app_invalid_rollback_and_reboot()

參數調整的流程因此變成:網頁 /set 即時改 → 站穩後停用馬達 → /save 存 NVS;只有改程式才需要 OTA。

四、調適步驟

步驟 1:先確認 MPU6050 讀得到

第一次燒進去序列埠顯示「MPU6050 讀不到」。韌體裡加了診斷:讀 SDA/SCL 閒置電位、把其他腳開下拉找「被外部拉高的腳」、正反兩種接法各掃一次 I2C。結果兩條線都是 0、沒有任何腳被拉高——代表 MPU6050 模組根本沒電。原因是主板整個沒上電(S3 只吃 USB 的電)。上電後迴圈剛好每秒 200 次、單次最長 2.7 ms。

步驟 2:馬達測試(輪子架空)

網頁加了「左輪前/後、右輪前/後、兩輪前」按鈕,每次 400 steps/s 轉 1 秒。順便確認微步:1 秒轉 1/8 圈 → 400 ÷ (200 × 16) = 1/8,主板跳線是 16 微步。

步驟 3:確認傾角的軸和正負號

一開始前後傾只看到幾度的變化,懷疑軸設錯。在 /status 加上三軸原始加速度後,請人扶著車子前傾、後傾,錄到:

動作 acc X acc Y acc Z
直立 -0.03 +0.02 1.01
前傾 -0.01 -0.73~-0.89 0.48~0.69
後傾 -0.02 +0.85 0.53

前後傾是 Y 軸在變(原本讀的 X 其實是左右傾)。改成:傾角=atan2(acc Y, acc Z)、陀螺儀用繞 X 軸,兩者都乘 -1,讓前傾為正。

步驟 4:第一次平衡——控制方式要對步進馬達

Pi 4 上跑一支小程式每秒抓 20 次 /status,把每次測試錄下來。下圖是四個階段的傾角曲線:

① 角度 PD 直接給輪速:最長站了 38 秒,但每次都會慢慢偏向一邊,輪速一路加到上限 4000 steps/s 追不回來而倒下。原因是步進馬達本身是「照指定速度轉」,要把傾斜 θ 的車身撐回來需要約 g·tanθ 的加速度;只給速度,輪子只是跟著傾角跑。

改法:角度環輸出加速度,再積分成輪速:

acc = kp × (angle − trim − targetAngle) + kd × gyroRate   // steps/s²
speed += acc × dt                                          // steps/s

步驟 5:kd 調到阻尼夠

② kd=80:前後擺動約 1 秒一次、越擺越大,輪速在 ±4000 之間甩,矯枉過正倒向另一邊。kd/kp 只有 0.03 秒,阻尼太小。③ kd=250(kd/kp ≈ 0.1 秒):第一次站超過一分鐘。

步驟 6:一直往後跑 → trim + 速度環

kd=250 時平均傾角是 -3.9°:重心偏後,要往後傾才站得住,車子就一直往後開。處理方式:

  • 速度環 PI:車速越快,目標傾角越往反方向(ksp=0.002 → 1000 steps/s 往回傾 2°;ksi=0.0001 消除長時間偏移)
  • 開了 I 項之後實際平衡點穩定在約 -1.7°,trim 設 -1.8°

步驟 7:站 30 秒後「突然沒力」→ 失步

倒下那一刻的數據很清楚:程式在 0.15 秒內把輪速加到 4000,車身卻以 428°/s 自由倒下——指令有送出去,輪子沒跟上,是步進馬達失步。步進馬達轉越快扭力越小,電池只有 7~8V 更明顯。把輪速和加速度上限改成可調參數,降到 vmax=3000 steps/s、amax=30000 steps/s² 之後,④ 可以站到 68 秒。

步驟 8:加電池電壓監測

電池+(開關之後)─ 8.3kΩ ─ GPIO1 ─ 1.5kΩ ─ GND,滿電 8.4V 時 GPIO1 只有約 1.3V。和三用電表對照:電表 7.48V、韌體讀 6.90V,乘上 1.084 校正後讀 7.41~7.45V。一開始設低於 6.8V 自動停止平衡,後來發現平衡中馬達抽電會把電壓拉低、誤停車,現在改成只偵測、不切馬達(低於 6.0V 只在網頁標 battLow 警告)。

⚠️ 8.3k 一定接電池端;接反 GPIO1 會吃到約 7V,S3 直接燒掉。

量出來才發現電池只有 7.48V(每顆 3.74V,約半電),已經低於 A4988 馬達電源規格的 8V——這也是失步的主因之一。

步驟 9:充飽電後反而大幅前後擺 → 速度環減弱

電池充到 8.09V 再測,3 次最久只站 29.6 秒,前後擺動變成每秒約 0.45 次的大慢擺,速度環把目標傾角一路推到 ±10° 上限。把速度環 ksp 0.002→0.0012、ksi 0.0001→0.00004,同時 vmax 3000→3500、amax 30000→45000,擺動回到每秒約 1 次,角度標準差降到 3.6°。

回頭把 bal4~bal7 全部 16 次倒下對一遍,模式完全一樣:車身晃到 7~15° → 輪速頂到 vmax → 0.2~1.5 秒內倒下。「突然沒力」其實多半是輪子已經全速、沒有餘裕再追。

步驟 10:角度環加硬 → 站 59 秒、晃 ±2°

kp 2500→3000、kd 250→300、vmax 4000,這次站了 59 秒、角度標準差 2.0°(目前最好),而且不是倒下,是被當時 6.5V 的低電壓保護停掉——平衡中電壓被拉低。參數已經存進 NVS。

再往下測,倒下後 /status 的 reset 變成 brownout:電池出力時電壓垮到 ESP32 都撐不住而重開,接著程式叫輪子全速、車子卻不動。閒置時讀 7.7V 看起來還好,負載下已經沒力,電池是現在的瓶頸。

五、實測影片與調參網頁

kp 3000/kd 300 這組參數的實測:站穩後用手推一下,車子會往前追、擺幾下再回到原地。

調參網頁在 core 0 跑,手機連同一個 WiFi 打開 http://車子IP/ 就能看。上半部每 200 ms 更新一次 /status:

  • state/reset/uptime:目前狀態、上次重開原因(poweron、sw、brownout…)、開機秒數
  • angle/gyro/target/speed:傾角、傾倒速度、速度環要的目標傾角、輪速
  • batt/battLow:電池電壓和低電量警告
  • loops/imuErr/overruns/maxLoopUs:控制迴圈跑了幾次、IMU 讀取錯誤、逾時次數、單次最長耗時(200 Hz 的預算是 5000 µs,實測約 2300 µs)
  • params:目前生效的參數

下半部可以直接改參數按「套用」即時生效,「存進 NVS」寫進 Flash(平衡中會被拒絕,避免寫 Flash 時兩顆核心停住讓車倒),還有停用馬達、允許平衡和單輪馬達測試按鈕。

圖說:手機上的調參網頁

六、目前的參數

參數 值 意思
kp 3000 每 1° 傾角給多少加速度(steps/s²)
kd 300 每 1°/s 傾倒速度給多少加速度
ksp / ksi 0.0012 / 0.00004 速度環 PI,輸出目標傾角
trim -1.8° 機械平衡點
vmax / amax 4000 / 45000 輪速、加速度上限(16 微步:1.25 轉/s)

七、踩到的坑

  • OTA 回滾沒作用:Arduino core 的 verifyRollbackLater() 是 C 函式,在 .ino 覆寫要寫 extern "C",不然新韌體一開機就被標成有效,永遠不會回滾。
  • S3 開發板接 CH343 時,FQBN 不要加 CDCOnBoot=cdc,Serial 才會走 UART0 出到 CH343。
  • 開機預設不平衡:要在網頁按「允許平衡」才會動馬達,避免一接上車子剛好直立就自己衝出去。
  • 倒下後 S3 會重開機:reset reason 是 poweron 時懷疑電池座接點彈開,是 brownout 時就是電池出力時電壓垮掉。

八、下一步

  • 換能放大電流的動力型 18650(或確認電池內阻),避免出力時 brownout
  • 考慮 3S 電池讓 A4988 吃到 11V 以上(要先確認主板降壓電路吃得了 12.6V),或調高 A4988 電流
  • 站穩後再加前進/轉向遙控

沒有留言:

張貼留言