先说结论:FPGA上用Verilog写PID调节器这事儿,属于典型的“看着不难,上手才知道坑全在哪儿”的活儿。你既要处理控制算法的数值问题,又要顾及硬件时序、数据宽度、饱和限幅这类MCU时代根本不会碰的东西。但一旦真跑通了,尤其是看到示波器上那条收敛曲线从震荡到平滑,那种由硬件级确定性带来的掌控感,确实让人上瘾。这篇文就是把我最近折腾的经验直接摊开,适合正在或打算在FPGA上做电机控制、电源控制、机器人关节驱动的朋友,也适合那些被定点数和时序搞到怀疑人生的Verilog初学者。我不讲教科书理论,直接给代码、讲取舍、说踩坑。
1. 为什么要在FPGA上写PID控制器
1.1 FPGA控制和单片机控制的本质差异
很多人第一反应是:PID用单片机写不香吗?STM32跑个10kHz控制环轻轻松松,折腾FPGA图什么?这个想法对了一半。MCU做PID确实成熟稳定,尤其对那种采样率在几kHz到十几kHz的控制场景,资源完全够用。但FPGA的存在价值不在于“能不能跑”,而在于“延迟可控、并行不冲突、采样确定”。
我举个生活化的例子:单片机像一个业务熟练的厨师,一个人掌勺整个后厨。他手艺再好,也得排队做菜——切完土豆才能切肉,炒完A菜才能炒B菜,每个任务占用的CPU周期是共享的。哪怕你用中断,中断来了也得等当前指令执行完。FPGA不是这样,它在硬件层面就是一百个厨师同时开工,每一个PID环都有自己独立的逻辑门和寄存器,互不抢占。你给2个电机做PID,和给8个电机做PID,每个环的运算延迟几乎不变。
这个特性在控制领域意味着什么?意味着控制周期是严格确定的。你在Verilog里用时钟沿驱动整个计算流水线,一旦综合时序收敛,每一个控制周期从采样到输出之间隔多少个时钟周期是恒定的,没有Jitter。而MCU上的中断延迟、任务切换、Cache miss都会造成时间上的不确定性,这对某些高性能控制场景(比如高转速电机FOC、快速响应的开关电源)是难以容忍的。
1.2 什么场景才真正值得用FPGA
不是所有控制都应无脑上FPGA。我个人的判断标准很简单:系统里是否有多个需要同步采样的控制环,或者控制周期是否已经快到MCU的中断处理跟不上。
举三个典型的FPGA-PID应用场景:
- 多轴运动控制:一台设备里同时跑3个以上伺服电机,每个电机需要独立的电流环和速度环,采样点必须严格同步。FPGA里的多个PID模块可以放在同一个always块下用同一个时钟沿触发,天然同步。MCU即便有多个定时器,也很难做到完全一致的中断响应。
- 开关电源数字控制:开关频率动辄几百kHz,控制环需要在1-2微秒内完成一次完整的采样-计算-输出。这个速度MCU很难稳定支撑,但FPGA用流水线处理,时钟跑个100MHz,一个周期10ns,几百纳秒就能算完一轮PID。
- 需要灵活扩展的专用控制器:PID只是整个系统的一小部分,旁边还要挂编码器解码、坐标变换、PWM生成、硬件保护逻辑。这些功能用FPGA集成在一起,可以避免MCU和外部电路之间大量的通信开销和外设瓶颈。
反过来,如果只是单个温控回路、低频位置控制、或者调试期验证算法,老老实实上单片机,别折腾FPGA。否则你会发现整个下午都在跟仿真时序较劲,而不是在调参数。
2. 精简版PID核心代码,先让它跑起来
2.1 完整可综合的Verilog代码
先把代码甩出来。这个版本是我精简过的,适合做功能验证和初步上板,省略了滤波器、参数自适应这些锦上添花的东西,但保留了位置式PID的完整计算主链和两个关键的限幅处理。
// 位置式PID控制器,默认使用Q16定点格式(低16位为小数部分) // 32位有符号数作为统一数据类型,乘加内部使用64位防止溢出 module pid_controller ( input wire clk, // 工作时钟 input wire rst_n, // 异步复位,低有效 input wire signed [31:0] setpoint, // 目标值 Q16格式 input wire signed [31:0] feedback, // 反馈值 Q16格式 input wire signed [31:0] kp, // 比例系数 Q16格式 input wire signed [31:0] ki, // 积分系数 Q16格式 input wire signed [31:0] kd, // 微分系数 Q16格式 output reg signed [31:0] control_out // 控制输出 Q16格式,带饱和限幅 ); // 积分项限幅 localparam signed [31:0] INT_MAX = 32'd500000; // 根据实际被控对象设定 localparam signed [31:0] INT_MIN = -32'sd500000; // 输出限幅 localparam signed [31:0] OUT_MAX = 32'd32767; // 对应16位有符号DAC范围 localparam signed [31:0] OUT_MIN = -32'sd32768; reg signed [31:0] error_reg; // 当前误差 reg signed [31:0] integral_reg; // 积分累积值 reg signed [31:0] prev_error; // 上一次误差,用于微分项 reg signed [63:0] p_term; // 比例项运算缓存 reg signed [63:0] i_term; // 积分项运算缓存 reg signed [63:0] d_term; // 微分项运算缓存 reg signed [63:0] pid_sum; // 三项求和后的原始值 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin error_reg <= 32'sd0; integral_reg <= 32'sd0; prev_error <= 32'sd0; p_term <= 64'sd0; i_term <= 64'sd0; d_term <= 64'sd0; pid_sum <= 64'sd0; control_out <= 32'sd0; end else begin // 第一步:计算当前误差 error_reg <= setpoint - feedback; // 第二步:积分累加,带抗积分饱和限幅 if (integral_reg + error_reg > INT_MAX) integral_reg <= INT_MAX; else if (integral_reg + error_reg < INT_MIN) integral_reg <= INT_MIN; else integral_reg <= integral_reg + error_reg; // 第三步:并行计算P、I、D三项 p_term <= kp * error_reg; i_term <= ki * integral_reg; d_term <= kd * (error_reg - prev_error); // 第四步:汇总三项 pid_sum <= p_term + i_term + d_term; // 第五步:更新误差缓存,用于下一拍的微分计算 prev_error <= error_reg; end end // 输出限幅单独用一个always块,避免和内部计算逻辑混在一起 always @(posedge clk or negedge rst_n) begin if (!rst_n) control_out <= 32'sd0; else if (pid_sum > OUT_MAX) control_out <= OUT_MAX; else if (pid_sum < OUT_MIN) control_out <= OUT_MIN; else control_out <= pid_sum[31:0]; end endmodule这个代码可以直接综合,不需要额外IP核,支持任意主流的FPGA平台。我测试时跑在100MHz时钟下,逻辑资源消耗大概几百个LUT,占资源极少,主要成本集中在乘法器上。
2.2 代码背后的硬件时序结构
这段代码看着简单,但理解它的硬件执行方式才是关键。它不是C语言那种“顺序执行”,而是每一个always块中的赋值在时钟沿到达时并行完成。
我把计算拆成了五个步骤,每个步骤占一个时钟周期,形成了一个五级流水线结构的雏形。实际运行中,第N个时钟周期算的误差,会在第N+5个周期成为控制输出,但整个流水线是持续运转的,每个周期都能吸收一组新的采样数据,等效吞吐量仍然是每个周期完成一次PID运算。这种设计的好处是:采样率的上限由时钟频率和流水线级数决定,100MHz时钟下,理论上5拍后出结果,最大可持续计算频率就是20MHz,实际再放宽到1MHz采样率绰绰有余,完全跑得起绝大多数控制环。
再注意一个细节:p_term、i_term、d_term我声明成了64位,原因很简单,两个32位的有符号数相乘,结果最大需要64位才能完整表示,如果直接赋值给32位寄存器,综合时语法能过,但高位会被截掉,轻则精度损失,重则出现未知的X态。FPGA仿真里X态是最恶心的,信号一旦变成X,整个仿真结果就废了,而且这种问题还特别难查。
输出限幅我单独拆了一个always块,这个也是有意为之。因为pid_sum是64位寄存器,如果我在同一个always块里直接对64位数值做32位限幅再赋值,一方面逻辑综合时会产生较长的组合路径,另一方面把比较、赋值、计算混在一起会让时序约束和仿真排查都变得混乱。拆开之后,计算链路和输出链路各管各的,逻辑清晰。
2.3 定点数和Q格式,绕不过去的必修课
如果只做浮点仿真,你永远感受不到FPGA控制的真正痛点。FPGA上的浮点运算不是不能做,但代价是成倍的DSP资源和延迟,而且很多中低端芯片根本没有足够多的浮点单元。所以工程实践里基本都用定点数,也就是用整数的位宽去表达小数。
Q格式标记法是业界最通用的约定。Q16表示低16位是小数位,高16位是整数位和符号位。换算关系很简单:一个Q16数实际代表的十进制值 = 寄存器里的整数值 / 65536。
举个例子,如果我要表达kp = 0.5,在Q16下应该给的寄存器值是0.5 * 65536 = 32768。如果我要表达ki = 0.02,则是0.02 * 65536,约等于1311。这个换算在参数初始化时特别容易出错,我的建议是直接用十六进制值或十进制Q16值来赋值,不要用浮点数直接写进代码,避免综合工具提示类型不匹配。
定点运算的乘法也有讲究。两个Q16数相乘,结果的小数位数是16 + 16 = 32位,也就是说乘积的低32位是小数部分。所以要拿到最终Q16结果,需要对64位乘积右移16位再截取低32位。我在上面代码里没有显式做这个移位,原因是控制对象接口的精度和数据的对齐方式得跟随整体系统设计来定,硬写在模块内部反而限制了复用性。如果你在调试中发现控制量总是差一个数量级,先检查这里,大概率是被乘数的小数位对齐搞错了。
3. 从能跑到好用,六个关键细节处理
3.1 积分饱和是第一个绕不过去的坎
积分饱和这个词,搞控制的人应该都熟,但真正在FPGA上体会它的威力,我觉得比在C语言里深刻得多。MCU上你写个if判断积分数值范围,几分钟就搞定;FPGA上,你要在硬件层面为这个if设计专用比较逻辑,并确保它不影响整体时序。
我见过不少第一次在FPGA上写PID的人,觉得积分项就是累加,结果系统一上电,误差还很大的时候积分值一路冲到顶,等被控量接近目标值,积分项却因为之前的积累一直降不下来,超调直接打满,然后又开始反向积分,整个系统陷入低频震荡。我代码里把积分限幅做得比输出限幅更严格,就是这个原因——宁可积分项贡献的力度到达上限就停,也不能让它给输出级制造额外的延迟恢复时间。
一个进阶的技巧是条件积分:只有当前误差在某个阈值以内时,才允许积分项继续累加。误差很大时积分完全冻结,这样系统从大扰动恢复时不会因为历史误差导致过冲。这个逻辑在硬件上加一个比较器就能实现,性价比非常高。
3.2 微分项是放大器也是噪声放大器
微分项的作用是预测误差变化趋势,加快系统对扰动的响应,但它在FPGA实现时特别容易出问题。核心原因是AD采样信号普遍带有量化噪声和模拟噪声,微分项对一阶差分非常敏感,一个微小的噪声尖峰经过微分放大后,可能直接导致控制输出跳变几个数量级。
我的处理方式有两种,按实际情况选:
- 如果控制对象对噪声容忍度低,直接缩减微分系数,或者干脆只用PI控制,很多工业场景PI已经够了。
- 如果确实需要微分抑制超调,给微分项加一阶低通滤波,等效于数字控制里的“不完全微分PID”。
不完全微分的Verilog实现也不复杂,就是在计算出error_reg - prev_error之后,先乘一个滤波系数低通一次,再乘kd。代价是多一个寄存器和两次乘法,换来的是微分项不再毛刺乱飞,这笔交易很划算。
3.3 输出限幅和PWM对接时最容易栽的坑
输出限幅不只是保护执行机构,更关键的是防止PID中间计算超范围导致后续模块出现X态。很多初学者只在最后输出级做限幅,但内部积分项、PID求和都已经溢出变形了,最后限幅出来的值也不是正确值。所以我在代码里分别在积分级和输出级做了两层限幅,这就是所谓的内外双重保护。
PWM对接时还有一个偏置问题。很多电机驱动板用的是单极性PWM,占空比范围0到100%,对应的寄存器是0到某个正数,根本没有负半轴。而PID输出是有正有负的,直接怼过去必然出问题。常见做法有两种:
- 把PID输出当成增量,叠加到当前的PWM基准值上,然后对最终值限幅。
- 把PID输出的零点映射到50%占空比,正输出增加占空比,负输出减小占空比。
选哪种取决于执行机构的特性。如果是H桥驱动,我偏向第一种,直接把PID输出转换为中心偏移的PWM值;如果是单向泵或者加热器,则用第二种,负输出就输出最小占空比。
3.4 字长扩展,调试三天可能就卡在这
FPGA控制的精度刺客不是算法,是数据宽度。一个32位信号在仿真里看着挺好,上板之后跟ADC数据一接,截位截得不当,系统稳态误差大得离谱。
我给出一个保险的经验法则:ADC数据如果是12位,先把它扩展到32位有符号形式再进PID模块;DAC如果是16位,PID输出截断到高16位时要做饱和处理,而不是单纯丢弃低位。中间乘法器如果直接用芯片DSP硬核,通常支持18位或者27位乘加,这时候要让综合工具自动推断宽度,千万别手动把信号宽度压到乘法器输入范围以下,否则综合报告里一堆“宽度截断警告”,上板之后表现的都是一些莫名其妙的零偏。
说到底,FPGA的位宽就像容器的口径,口径不够,水压再大也流不过去,只能溢出来。
3.5 参数整定思路,不要上来就凑
FPGA上跑PID最大的好处就是参数调整可以极其系统化。我个人建议用Ziegler-Nichols的升阶法打底,先只保留比例项,逐步增大Kp直到系统出现持续等幅振荡,记录此时的临界增益和振荡周期,然后查表算出Ki和Kd的初值。这个思路在FPGA上实现并不复杂,只需要在仿真阶段把Kp设成寄存器可调,并做成UART或按键在线修改即可。
有了初值之后,再根据阶跃响应微调。我一般看三个指标:上升时间、超调量、稳态误差。超调大就加Kd,稳态误差消不掉就加Ki,震荡持续就适当削Kp。切忌三个参数同时乱调,一次只动一个,记录对比曲线。
3.6 采样率的规划,不是越快越好
既然FPGA计算速度这么快,很多人第一反应是把采样率拉到最大。但实际上,采样率过高会导致微分项几乎测不到误差变化,反而带来数值噪声;采样率过低则会丢失系统的动态信息,表现为控制器反应迟钝。
我的一般原则是:采样频率设置在系统开环截止频率的10到20倍左右。比如电机控制系统机械振荡频率在50Hz附近,采样率放到1kHz到2kHz就足够了。如果你做的是电源数字控制,开关频率已经很高,那么采样率应该对齐到开关频率的整数分频,保证每次采样都落在相同的PWM相位上,避免因采样点移动造成的波动。
4. 上板调试实录,那些仿真发现不了的坑
4.1 仿真通过,上板震荡,差在哪
我这次压测的第一个版本,Modelsim仿真一切正常,阶跃响应干净利落,上板之后系统却开始自激振荡,PWM输出高频抖动,电机像要罢工一样。排查了很久,最后定位到根因是ADC采样数据进PID模块前少了一级同步器。
FPGA内部跨时钟域传输是高频问题。ADC如果用的是独立时钟域,直接把采样数据接入PID模块的时钟域,由于亚稳态的存在,某些时刻采样值会跳变成完全无关的数。MCU时代不会有人关心这种问题,但FPGA上这属于基本功。解决办法是在模块入口加两级D触发器做同步,把异步数据打两拍再使用。代码加了两行,世界瞬间清净了。
4.2 数据溢出导致的隐性自激
另一个案例是积分限幅我一开始设得太大,几乎等于不限制。结果系统接近稳态时,积分项不断微调,但由于被控对象的执行机构存在死区,误差永远降不到零,积分项就一直拉到限幅值,然后被限幅值硬顶回来,形成一个低频率的振荡循环。示波器上看控制输出像是锯齿波,还以为是调参没调好。
这个问题只有真正上板才能暴露。因为仿真模型里执行机构是理想的,没有死区和非线性,不会触发这种极限循环。解决方式有两个方向:一是把积分限幅设小一点,让积分力度上限足够低;二是在输出侧加一个“误差死区”,误差很小的时候干脆不更新积分项。我后来两者都做了,系统稳定性明显提升。
4.3 综合之后的时序违例排查
PID模块看似简单,但乘法器和比较器组合在一起,数据路径可能非常长。我第一次跑到150MHz时,综合报告直接报出时序违例,负裕量有几十皮秒。解决方法是调整综合工具的优化策略,或者在中间加上流水线寄存器。PID的反馈环本身允许有几拍延迟,所以把乘法结果寄存一拍、求和结果再寄存一拍,对控制性能几乎没有影响,但时序压力瞬间下来了。
排查时序问题还有一个通用技巧:看综合报告的延迟瓶颈路径。如果长时间是某一条乘法链路,就在这条链路中间插寄存器;如果所有路径都有问题,优先检查时钟约束是否设定正确。
4.4 在线调参的便利性
FPGA上跑PID一个很麻烦的地方是,参数改一次要重新综合一次,几个小时就过去了。后来我把Kp、Ki、Kd都接到了寄存器组,通过串口在上位机上随时改写,配合示波器观察阶跃响应,十分钟完成一轮参数整定。这个改动对调试效率的提升是数量级的。我强烈建议你在写完PID模块之后,第一件事就是把这几个参数设计成外部可调,否则后面每一次调参都是灾难。
5. 常见问题速查表
| 问题现象 | 可能原因 | 排查建议 |
|---|---|---|
| 控制输出一直为0 | 复位未释放,或使能信号未拉高 | 检查rst_n和使能信号,用逻辑分析仪观察内部寄存器 |
| 输出跳变到极大值 | 乘法结果截断,数据宽度不足 | 检查P/I/D项寄存器位宽,乘积位宽是否满足64位 |
| 系统低频震荡 | 积分饱和,或积分限幅过宽 | 收紧INT_MAX/INT_MIN,考虑条件积分 |
| 高频毛刺,PWM抖动 | ADC数据跨时钟域未同步 | 入口加二级同步器,打两拍再使用 |
| 稳态误差偏大 | 定点数小数位丢失,或Ki太小 | 检查Q格式对齐,确认Ki数值真实大小 |
| 仿真正常但上板超调 | 被控对象非线性,死区或饱和 | 增加输出限幅,考虑误差死区处理 |
| 时序违例 | 乘法链路过长,时钟频率过高 | 中间插入流水线寄存器,降频测试 |
还有一个通用建议:FPGA控制的调试,不能依赖“感觉”。无论你用的是逻辑分析仪、虚拟示波器还是串口回传波形,一定要把每次参数调整前后的曲线记录下来。我之前很多次自以为找到了问题,后来回看数据才发现是另一个变量引起的。控制算法的调试本质上是在跟噪声、延迟和量化误差斗智斗勇,有数据才能有判断力。
6. 后面的扩展方向
PID只是FPGA自动控制的第一步,但在工程里,很少有人真的只用一个PID走天下。我接下来打算在这套代码基础上继续扩展三个方向:首先是前馈补偿,把系统的模型前馈项加进去,能显著改善大惯性对象的跟踪性能,代价是多一个乘法和加法;其次是自适应增益,根据实际误差自动调整Kp,这个在硬件上需要额外的状态机和比较器,稍微复杂一点;再就是把波特图和阶跃响应的在线观测做进系统里,调试的时候直接显示曲线,比逻辑分析仪直观得多。
我个人这段时间最大的体会是:FPGA控制并不难,难的是养成“硬件思维”。一开始你可能习惯像写C语言那样去写Verilog,后来你会发现,每一步都要问自己“这个变量的位宽是多少”“这条数据路径在哪个时钟沿走”“两个不同时钟域的信号有没有握手机制”,一旦这些问号都变成了句号,FPGA控制其实是个很有安全感的事。
最后再分享一个小技巧:写PID代码时,把所有的localparam都放在模块顶部,并且把INT_MAX那些限幅值取成接近实际物理极限的数字,而不是随便填一个大数。这样你在仿真里看到的每一个波形,都更接近真实的物理过程。也就是这一步小习惯,让我少踩了非常多“仿真正常上板乱飞”的坑。