在Arduino上实现自抗扰控制(ADRC)的完整指南:从ESO离散化到参数整定
简介ADRC自抗扰控制二阶系统仿真资源适合自动化、控制类专业学生及研究人员学习ESO扩展状态观测器、控制器参数整定与频率特性分析。资源包含5个文件涵盖MATLAB脚本m、Simulink模型mdl/slx可用于搭建二阶系统仿真环境、运行阶跃响应与Bode图分析程序直观观察ADRC对扰动和不确定性的抑制效果。压缩包整体41KB轻量易用便于快速验证算法思路。已有807人学习下载内容紧凑实用既可辅助课程实验也可作为工程实践中的参考模板。1. 关于ADRC先想清楚为什么要在Arduino上做自抗扰控制Active Disturbance Rejection Control, ADRC近年在运动控制和伺服系统里被反复提起但多数示例跑在Simulink仿真里换成MCU环境就有点两难——算法要的是实时性和离散化仿真图看得再多不落到单片机上是玩不转的。把ADRC放在Arduino上做仿真其实是用一个小代价验证控制器的鲁棒性传感器有噪声、执行器有饱和、模型又不精确这些在纯仿真里被掩盖的问题在真实硬件上会快速暴露出来。适合谁呢搞运动控制、无人机、平衡车、直流电机调速的工程师以及想从PID换个思路的学生。只要能读一个反馈量编码器或IMU就能在Arduino上把二阶ADRC跑起来同时用串口把状态量和控制量发出来画图。这篇文章就按这个路径来先讲清楚二阶ADRC的结构和三个核心参数再给出Arduino上可编译的离散化代码和绘图方式最后聊一些参数整定和发散的处理办法。2. 二阶ADRC的核心结构和Arduino离散化实现2.1 ESO扩张状态观测器用差分方程逼近扰动韩京清提出的ADRC核心构成是“跟踪微分器TD 扩张状态观测器ESO 非线性状态误差反馈NLSEF”其中ESO是整个算法的心脏。二阶ADRC里ESO把被控对象当成一个积分串联型系统把未建模动态和外部扰动统一扩张成第三个状态量也就是“总扰动”。换句话说ADRC并不需要精确的对象模型它实时估算出模型偏差再在控制量里把它抵消掉。在Arduino这类单片机上ESO不能直接凑连续微分方程要转成欧拉差分格式。设采样周期为dt对象输出为y控制量为u对象增益估计值为b0那么二阶ESO的三个状态量z1、z2、z3的更新方式是float e z1 - y; // 观测误差 z1 (z2 - beta1 * e) * dt; // z1跟踪输出y z2 (z3 - beta2 * e b0 * u) * dt; // z2跟踪输出的微分 z3 (-beta3 * e) * dt; // z3估计总扰动这段代码的逻辑很直观先算观测器输出与实测输出的误差e再用e乘以不同的观测器增益beta1、beta2、beta3去修正三个状态量。z1跟上yz2跟上y的变化速度z3则把模型偏差、外部摩擦、重力矩这些东西全部“吸收”进来。实际调试时如果z3曲线能大致跟随外加的恒值扰动说明ESO参数方向是对的。2.2 TD和NLSEF在MCU上的简化TD的作用是给参考输入安排一个过渡过程避免阶跃信号直接冲击执行器。Arduino上常见做法是丢掉非线性函数直接用一个简单的二阶差分来安排参考速度和参考加速度省出来的算力留给ESO。如果你只需要做速度环或者位置环的验证TD也可以完全旁路掉把参考值直接给到NLSEF。NLSEF这部分传统ADRC用fal函数做非线性组合但在8位或ARM Cortex-M0这类MCU上幂运算会拖慢采样。工业上常见的简化方案是用线性组合代替非线性组合也就是让u0 kp * (v1 - z1) kd * (v2 - z2)。这样会损失一点抗扰性能但参数整定难度明显下降而且大部分Arduino场景里线性ESO加线性反馈已经能压住扰动。以下是核心控制量的计算float u0 kp * (v1 - z1) kd * (v2 - z2); float u (u0 - z3) / b0; if (u 255) u 255; if (u -255) u -255;u的计算逻辑是先用PD组合算一个中间量u0再减掉ESO估计的扰动z3最后除以b0。注意这里b0在分母上它如果偏大实际控制效果会变软偏小系统容易震荡甚至发散。限幅那两行不是可选项PWM输出和电机驱动都有物理上限限幅写死在高频采样循环里能防止执行器饱和后积分项继续累加。2.3 定时采样与执行器饱和的边界Arduino的delay函数不适合做控制器定时因为你无法保证循环里其他代码的执行时间恒定。常见做法是用定时器中断或者用millis()做非阻塞定时让ADRC算法固定在5ms或10ms执行一次。采样周期dt直接参与差分方程的计算dt越小离散化误差越小但对传感器噪声越敏感dt太大ESO会丢失高频扰动信息。参数含义典型调节范围调节影响dt采样周期秒0.0010.02越小跟踪越快但噪声放大b0对象增益估计值502000越大控制越柔和越小越激进beta1ESO第一阶增益1001000决定z1跟踪y的速度beta2ESO第二阶增益100010000决定z2跟踪速度的收敛beta3ESO第三阶增益1000050000决定扰动估计的响应速度kp反馈比例系数0.550越大刚度越高但易震荡kd反馈微分系数0.110增大阻尼和抑制超调这组参数不是拍脑袋想的它遵循一个基本规律beta1:beta2:beta3按带宽的1:3:3次方关系配置。比如ESO带宽取20rad/s那beta1约等于3倍的带宽beta2约等于3倍的带宽平方beta3约等于带宽立方。实际调整时如果z1迟迟跟不上下发阶跃优先加beta1不要同时动三个beta否则发散后很难定位是哪一项引起的。3. 在Arduino上跑通ADRC最小闭环3.1 最小硬件接线电位器PWM就够了验证ADRC不需要一开始就上电机。一个10k电位器当角度传感器一个LED或者直流电机当被控对象就能构成闭环。电位器中间抽头接A0电机驱动输入端接PWM引脚Arduino读取电位器电压作为反馈yADRC输出u转换成PWM占空比控制电机。这样接线之后你用手拧电位器就相当于给系统施加外部扰动观察电机转速的变化就能直观判断ADRC的抗扰能力。没有硬件的场景也不要卡住wokwi仿真平台这类在线Arduino仿真器可以直接跑代码省去焊线的中间环节。不过wokwi的模拟精度有限ESO观察到的扰动曲线和真实硬件会有差别做控制算法的验证可以做最终参数整定不行。我更建议的做法是先在Arduino里塞一个软件对象模型把ADRC和对象模型放在同一块MCU里闭环把数据通过串口发出来在PC端用matplotlib画图验证算法逻辑再接真实硬件做最后的调参。3.2 完整可编译的Arduino代码下面这个例程把二阶ADRC和软件对象模型揉在一起能直接编译上传用串口监视器或者串口绘图器就能看到结果。其中的对象模型用一阶惯性环节加扰动的形式模拟作用是让ADRC有东西可控制。// 二阶ADRC验证程序软件对象模型 扩张状态观测器 // 适用于Arduino Uno/Nano定时5ms输出CSV格式串口数据 float dt 0.005f; float z1 0.0f, z2 0.0f, z3 0.0f; float u 0.0f, y 0.0f, v1 0.0f, v2 0.0f; // 控制器参数根据2.3节表格的原则调整 float b0 800.0f; float beta1 180.0f, beta2 12000.0f, beta3 40000.0f; float kp 8.0f, kd 1.2f; float ref 30.0f; // 目标角度虚拟值 // 软件对象模型内部状态模拟一阶惯性 外部扰动 float plantOut 0.0f; float disturbance 5.0f; void setup() { Serial.begin(115200); } void loop() { static unsigned long lastTime 0; unsigned long now micros(); // 用micros()固定采样周期避免delay带来的时间漂移 if (now - lastTime (unsigned long)(dt * 1000000UL)) { lastTime now; // 步骤1软件对象模型更新模拟真实被控对象 // 一阶惯性plantOut (u * 0.5f - plantOut) * dt * 2.0f; // 这里用更简单的积分串联模型方便观察ESO效果 plantOut (u * b0 disturbance) * dt; y plantOut; // 步骤2ESO状态更新 float e z1 - y; z1 (z2 - beta1 * e) * dt; z2 (z3 - beta2 * e b0 * u) * dt; z3 (-beta3 * e) * dt; // 步骤3参考输入直接阶跃不用TD v1 ref; v2 0.0f; // 步骤4NLSEF线性组合 扰动补偿 float u0 kp * (v1 - z1) kd * (v2 - z2); u (u0 - z3) / b0; // 步骤5控制量限幅模拟PWM饱和 if (u 100.0f) u 100.0f; if (u -100.0f) u -100.0f; // 步骤6串口输出用逗号分隔方便Python解析 Serial.print(millis()); Serial.print(,); Serial.print(y); Serial.print(,); Serial.print(z1); Serial.print(,); Serial.print(z2); Serial.print(,); Serial.println(u); } }代码逻辑分成六个步骤每个步骤的边界很清楚。第一步是模拟对象它把控制量u放大b0倍后积分再加上一个恒定扰动这样ESO必须把这个扰动估计出来并抵消掉否则y对ref的跟踪会有稳态误差。第二到第四步就是标准ADRC闭环其中z3会在几次采样后逐渐逼近disturbance的值这说明观测器在工作。第六步串口输出是调试的关键后面画ADRC图全靠它。3.3 无硬件时的纯软件仿真模式上面的代码已经是不接硬件也能跑的纯软件仿真因为它连被控对象都在内部虚拟了。如果你想在PC上看实时曲线又不想焊板子可以继续用这个模式配合串口绘图器Arduino IDE内置的Serial Plotter或者等第4章的Python脚本。纯软件仿真的意义在于先把ESO参数和反馈参数调到稳定再上真实硬件减少现场试错的次数。串口绘图器适合快速看波形把波特率设为115200打开自带的Serial Plotter就能看到三条曲线y、z1、u会分别用不同颜色显示。如果z1能跟上y并且u在扰动加入后有一个明显的补偿跳变说明算法方向正确。需要注意的是Serial.print本身有耗时在高频打印时会影响采样间隔所以采样频率建议不要超过200Hz否则串口会成为瓶颈。4. 把ADRC图画出来串口协议与Python实时绘图4.1 定义串口帧格式和解析侧逻辑ADRC调试靠肉眼读串口数字不现实必须把波形画出来。我习惯用CSV格式输出每一行包含时间戳、反馈值y、ESO状态z1、z2、控制量u。Python端用pyserial按行读取以逗号分隔转成浮点数再灌进matplotlib绘图。这种方案的优点是不用像Protocol Buffers那样定义复杂帧格式CSV的解析成本对控制频率在200Hz以内的场景完全够用。这里有个细节Arduino复位时串口会输出缓冲区旧数据Python脚本启动后要先丢弃前几十行或者等数据稳定后再开始采集否则图上会有一段乱七八糟的跳变。还可以在Arduino端发一个特定字符“START”作为握手信号Python检测到之后再开始记录数据这样更可控。import serial import matplotlib.pyplot as plt ser serial.Serial(/dev/ttyUSB0, 115200, timeout1) time_vals [] y_vals [] z1_vals [] u_vals [] plt.ion() fig, (ax1, ax2) plt.subplots(2, 1, figsize(10, 6)) while True: line ser.readline().decode().strip() if not line: continue parts line.split(,) if len(parts) 5: continue t float(parts[0]) / 1000.0 # 毫秒转秒 y float(parts[1]) z1 float(parts[2]) u float(parts[4]) time_vals.append(t) y_vals.append(y) z1_vals.append(z1) u_vals.append(u) # 只保留最近500个点避免内存膨胀 if len(time_vals) 500: time_vals time_vals[-500:] y_vals y_vals[-500:] z1_vals z1_vals[-500:] u_vals u_vals[-500:] ax1.clear() ax1.plot(time_vals, y_vals, labely (feedback)) ax1.plot(time_vals, z1_vals, labelz1 (ESO estimate)) ax1.set_ylabel(angle / position) ax1.legend() ax2.clear() ax2.plot(time_vals, u_vals, labelcontrol u, colorred) ax2.set_xlabel(time (s)) ax2.set_ylabel(control output) ax2.legend() plt.pause(0.01)这段代码把整个串口数据流拆成四个列表然后放到子图里实时刷新。第一个子图画的是反馈量y和ESO的z1第二个子图画控制量u。看这个图的时候核心判断标准是z1是否贴合y不贴合就是beta1太弱u是否有高频抖动有抖动说明beta3或者kp/Kd太大系统正在边缘震荡。4.2 从图判断扰动观测是否收敛的三种特征ADRC图画出来以后不是看它好看就完事关键是要从波形里读出问题。第一个特征是ESO在阶跃响应的初期会出现一个短暂“滞后”z1比y慢一拍这是观测器带宽有限导致的如果这个滞后小于0.05秒问题不大如果明显跟不上加beta1。第二个特征是y对ref的稳态误差如果一直有一个稳定的差值说明z3没有真正估准扰动方向需要检查b0的方向和幅值。第三个特征最容易被忽略控制量u在扰动突变时有没有一个瞬时跳变。比如你用手拧一下电位器u应该立刻跳一下然后再回落这个跳变就是z3在把扰动反馈到控制量上。如果u对扰动没有反应说明ESO的输出没有正确参与控制合成大概率是u (u0 - z3) / b0这行代码里丢了z3或者b0正负号搞反了。4.3 离线分析把CSV存下来重新画静态图实时绘图适合调参数但写博客和复盘需要更精致的静态图。可以把Python脚本改成只录数据不画图运行一段结束后把CSV落盘再用pandas和matplotlib做离线分析。离线的好处是你能任意缩放时间轴对比不同参数组的波形差异。以下代码是在已有的go通过后用来存数的。import csv with open(adrc_log.csv, w, newline) as f: writer csv.writer(f) writer.writerow([time_ms, y, z1, z2, u]) # 把串口采集到的行按相同顺序写入即可对比参数效果时我会把两组beta参数分别跑30秒然后把两个CSV文件拼在一起画在同一张图上观察y到达稳态的时间和超调量有没有变化。这个办法虽然土却是在MCU平台上做控制器验证最稳妥的手段。不要指望在一行命令里完成全部调试ADRC的参数空间比较大慢工出细活。5. 参数整定技巧从仿真发散到稳定跟踪5.1 先调b0再调beta的步骤表ADRC和PID最大的不同是它多了一个b0这个参数如果错了其他参数全部白调。推荐的顺序是先固定一个大概的b0值把ESO的三个beta按带宽原则配置好然后单独给一个阶跃输入观察u和y的发散方向。如果u的方向和期望相反说明b0符号错误如果u震荡发散说明b0偏小如果u平滑但y上升太慢说明b0偏大。下表是实际调参时常用的处理动作。现象可能原因调整动作z1震荡发散beta1、beta2过大同时缩小beta1和beta2按比例1:3z1跟踪慢beta1过小优先加大beta1y有肉眼可见的稳态误差b0不准或z3没起作用重新标定b0或检查u的合成公式u高频抖动kp太大或beta3太大先降kp再看z3曲线是否毛糙阶跃响应超调过大kd太小增加kd不要动kp控制量饱和震荡限幅处被反复击穿减小ref阶跃幅度检查TD这组调整原则在Arduino上拿按钮或者外部信号发生器做手动测试很方便。每次只改一个参数记录波形后再动下一个。如果你想更系统地找参数可以在PC上跑一个离线搜索脚本用相同输入信号对比成本函数但真实硬件的非线性没法模拟最终还是要人工微调。5.2 仿真发散时的四个排查点发散的根因通常集中在四个地方。第一是采样周期不恒定millis()写法在高负载循环里会抖动ESO对dt的变化很敏感所以必须用micros()或者定时器中断。第二是限幅写在了积分环节之前有些代码先限幅再更新z3导致z3一直朝着错误方向积累最后冲出天际。第三是串口打印占用了太多时间打印放循环里会隐式拉长采样周期在采样周期进入ESO计算之前打印会让dt和实际时间严重脱节。第四是初始状态不匹配Arduino上电瞬间y和z1可能差得很远ESO的初值要么初始化为当前反馈值要么在开始控制的头几百毫秒不做控制。除了以上四点还有一个隐藏问题b0的单位和实际对象不匹配。如果控制量u是PWM占空比0~255而输出y是电位器电压那b0至少要跨越几千。可以先用开环实验测一下给定一个恒定的u记录y的斜率斜率除以u就是大致的b0值。这比瞎猜高效得多。5.3 用阶跃响应验证ADRC鲁棒性的快捷方法最后给一个验证技巧把参考值ref在代码里做成每10秒切换一次方波幅值在较小值和较大值之间来回跳再把disturbance常数也手动改成一个随机变化的值。这种情况下如果y能始终跟上ref且稳态误差接近零说明ESO对扰动的补偿是有效的。你会看到z3的曲线会跟着disturbance的规律变化而y不会出现明显的波动。这个过程用CSV记录下来对比起来最直观。如果整个系统在这个方波测试下依然表现稳定就可以把同样的参数搬到真实电机或平衡车上。移植时唯一要注意的是重新标定b0真实电机的PWM到转速模型和Arduino里的软件模型差别很大b0必须重新测一次剩下的beta参数通常可以沿用。ADRC的优势就在这里它不依赖精确模型你只要把b0的量级找准其余的参数空间是宽容的。用这套流程Arduino上从仿真发散到稳定跟踪大概率一下午就能收工。本文还有配套的精品资源点击获取