三相异步电机变频驱动与欠压降速控制(Cortex-M0 / 无 RTOS)
做这块板子的起因很简单:我想弄明白“变频器到底是怎么把 50 Hz 的市电变成任意频率的三相电”, 顺便解决一个真实的小需求——加注计量设备上的泵在低频启动时容易转矩不足、在母线电压被拉低时又容易停机。 于是我用一颗 Cortex-M0 画了一块驱动板:主循环是一个状态调度,实时性交给三个定时器中断, 用一张正弦表加相位偏移生成三相互补 PWM,用位置式 PI 做电流闭环, 再把“欠压”和“高温”做成两级降速策略。结论是功能都实现了、泵能按预期调频调压, 但这套代码里有几处我当时没意识到的问题,其中“目录名与实际功能不符”这一条, 让我后来养成了先核算、再相信名字的习惯。
一、这块板要驱动什么
被驱动的对象是一台三相异步电机,它带着一台泵,负载属于典型的“平方转矩”类型: 转速降一半,轴功率大约只剩八分之一。这类负载对变频器其实相当友好, 因为它不需要低速大转矩,但有两个要求是硬的:
- 频率和电压都要能连续调:泵的流量靠转速调,转速靠频率调;频率一降,电压也得跟着降, 否则电机磁通饱和、电流白白发热。
- 电流不能失控:泵在管路背压大的时候会憋住,这时候频率指令再往上加, 电流会直接顶到堵转电流。板子必须有闭环把电流拉回来。
供电是 220 V 交流输入,先整流成直流母线,再由三相逆变桥输出。 这也决定了后面所有“电压”相关逻辑的量纲:代码里看到的电压数值, 说的是整流后的母线电压,不是电机的相电压,更不是输入的交流有效值。 这一点后来坑了我一次,细节写在第六节。
不跑 RTOS,实时性交给三个中断
这颗 Cortex-M0 跑在 48 MHz,没有 FPU,RAM 也不宽裕,我一开始就没打算上 RTOS。 整个固件的时间结构是这样的:
| 时间基准 | 承担什么 | 为什么放在这里 |
|---|---|---|
| 高级定时器 TIM1 的周期中断 | 查表刷新三相占空比、频率斜坡推进 | 它和 PWM 本身同一个定时器,节拍天然与载波同步,抖动最小 |
| 一个慢节拍定时器 | 各种计时:故障确认、降速允许时间、指示灯 | 这些量都是百毫秒级的,放在快中断里纯属浪费 |
| 另一个定时器中断 | ADC 采集与电流环计算 | 电流环要求等间隔执行,周期必须稳定 |
| 主循环 | 一个 switch 状态调度:初始化 → 等待 → 运行 → 故障 → 参数设置 |
没有复杂任务依赖,一条直线排下去就够,多一层调度器反而是负担 |
这种“硬节拍 + 主循环软处理”的写法我在 《不用 RTOS 的固件怎么写:时间片轮询 + 消息队列》 里单独整理过。这里只强调一点:中断里做的事情越少,控制周期的抖动就越小, 而电流环最怕的就是周期抖动。可惜我在这块板子上没有完全做到,见第八节。
二、三相波形怎么产生:正弦表、相位偏移与分数分频
三相 SPWM 的核心问题是:怎么用一颗没有 FPU 的 M0,在每个 PWM 周期里算出三个相位相差 120° 的正弦值,还要能随时改频率。我的答案是三件东西:一张常量正弦表、 一个索引偏移、一个分数分频。
为什么是查表,不是算 sin()
在 48 MHz 的 M0 上调用一次浮点 sin(),开销是几百个周期起步,
而 PWM 周期中断每隔几十微秒就要进来一次。三个相位各算一次,
光是三角函数就能把中断占满,还不算浮点库带来的代码体积和周期抖动。
查表就完全不一样:一次数组下标访问,时间是常数,
而且占空比的后续运算全部可以用整数乘除完成。
表本身是“一整个电周期等分成 N 份”的正弦值常量数组,N 取多少属于工程参数, 本文不列出具体点数。这里只说方法层面我确认过的三件事:
- 三相不是三张表。三相相差 120°,在表里就是索引相差表长的三分之一。 所以我只有一张表,取三次下标、加一个偏移就得到三个相位。
- 变频靠“份数”而不是“改表”。当前频率决定一个电周期里要输出多少个 PWM 周期, 这个总份数再折算成表索引的步进量。频率低的时候一个周期分成很多份、索引走得慢; 频率高的时候份数少、索引走得快。这就是所谓的分数分频: 步进量不是整数,靠一个累加量取整来逼近。
- 调压靠一个乘系数。表里存的是归一化的正弦形状, 实际占空比 = 表值 × 当前的电压系数 ÷ 归一化基数。电压系数变了,波形形状不变、幅值变。
/* 伪代码骨架:只保留控制流形状,表长、系数、归一化基数全部用占位符,不含真实取值 */
u16 idx_u = phase; /* 当前相的表索引 */
u16 idx_v = (phase + TABLE_LEN / 3) % TABLE_LEN; /* 滞后 120° */
u16 idx_w = (phase + TABLE_LEN * 2 / 3) % TABLE_LEN; /* 滞后 240° */
duty_u = (u32)sine_tab[idx_u] * volt_coef / NORM_BASE; /* 先乘后除,减少整数截断 */
duty_v = (u32)sine_tab[idx_v] * volt_coef / NORM_BASE;
duty_w = (u32)sine_tab[idx_w] * volt_coef / NORM_BASE;
pwm_set_duty(PHASE_U, duty_u); /* 三相占空比写进三个比较寄存器 */
pwm_set_duty(PHASE_V, duty_v); /* 每相的下桥由定时器硬件按互补方式生成, */
pwm_set_duty(PHASE_W, duty_w); /* 不需要软件去翻转两个 IO */下面这张表是我对这三件东西的对照理解,也是我后来给别人讲这段代码时的顺序:
| 要素 | 做法 | 解决的是哪个问题 |
|---|---|---|
| 正弦表 | 把一个电周期的正弦形状固化成常量数组,取值归一化 | 把三角函数从“每个中断算一次”变成“查一次表”,时间可预期 |
| 索引偏移 | 三相共用一张表,第二、第三相分别偏移表长的 1/3、2/3 | 省掉两张表,也保证三相严格相差 120°,不会因为三份代码写得不一致而失真 |
| 分数分频 | 用当前的频率换算出“一个电周期分多少份”,再由份数求索引步进 | 让频率连续可调,而不是只能输出表长的整数分频那几个频点 |
| 电压系数 | 占空比 = 表值 × 电压系数 ÷ 归一化基数 | 把“调频”和“调压”解耦:改频率不动幅值,改电压不动频率 |
互补输出与上电状态
三路半桥的六路门极由高级定时器的互补通道驱动。我的工程里能直接确认的两组是
U 相上桥 PA8 / 下桥 PA7、V 相上桥 PA9 / 下桥 PB0,
第三相用同一个高级定时器的其余互补通道。互补输出的意义在于:
上下桥的开关由硬件保证互斥,软件只需要管一个占空比,
不用担心自己写出“上下桥同时导通”这种直接炸管的组合。
上电时我给三相一个相同的、居中的固定占空比,让电机在启动前处于“三相电压对称”的状态。 这一点看起来很琐碎,但如果上电时三个比较寄存器的初值不一致, 电机会在还没收到启动指令时就受到一个直流分量,表现为轻微抖动甚至哼声。 这个经验是我在示波器上看三相波形时发现的。
三、变频与调压:V/f 补偿为什么必须做
异步电机的转矩来自气隙磁通,而磁通近似正比于“定子电压 ÷ 频率”。 所以变频器最基本的控制律就是 V/f = 常数:频率降一半,电压也降一半, 磁通保持不变,转矩能力就不变。
但这条直线在低频段会失效。原因不复杂:定子绕组有电阻, 低频时反电动势很小,定子电阻上的压降在总电压里的占比变得不可忽略, 真正用来建立磁通的电压被吃掉了;同时低频时漏抗变小、励磁电流增大。 结果就是:按纯 V/f 直线走,电机在低频段转矩不足、启动困难、电流反而偏大。
我的对策是在 V/f 直线上叠加一个低频抬压量:频率越低,抬得越多; 随着频率升高,抬压量线性衰减,到某个频率以上就完全回到纯 V/f 直线。 抬多少、从哪个频率开始衰减,是按实测定的—— 我在测试台上从最低频往上慢慢加,一边看电流一边看泵能不能带起来, 取一个“带得动、电流又不过分”的点。这个量本质上是 用电流换转矩,抬过头电机会发热,所以我不建议照抄任何人的经验值。
另一端还有一个容易被忽略的问题:母线电压偏高时输出会过压。 调制比是“输出相电压 ÷ 母线电压”,母线一高,同样的占空比对应的实际输出电压就上去了。 我的做法是让占空比上限随母线电压反比压低:母线越高,允许的最大占空比越小。 这样即使输入电压在宽范围内变化,输出到电机的电压也不会跟着一起飘。
| 机制 | 解决什么问题 | 我的取值依据 |
|---|---|---|
| 基准 V/f 直线 | 让磁通在额定频率以下基本恒定 | 按电机的额定电压与额定频率确定,属于电机参数 |
| 低频抬压 | 补偿定子电阻压降,让低频段带得动负载 | 按实测定:从最低频往上加,看到电流与带载能力都合适为止 |
| 母线电压反比限幅 | 输入电压偏高时不让输出过压 | 按母线电压范围定,上限曲线由输入电压的允许区间反推 |
四、电流环:位置式 PI 与抗积分饱和
电流环是这个工程里唯一一个“闭环”,也是我花了最多时间才想明白的一段。 它要解决的问题是:频率指令是由上层给的(比如泵需要多大流量), 但真正决定电机能不能承受的是电流。所以中间必须有一层: 拿额定电流当目标,拿实测电流当反馈,用 PI 算出一个“当前允许的频率”, 再把这个频率当上限去限制频率指令。
为什么用位置式,而不是增量式
位置式 PI 的输出就是控制量本身(在这里是目标频率), 每一项都由当前的偏差和累积的积分项重新算出来;增量式输出的是“这一次比上一次加多少”。 我选位置式的理由很实际:
- 输出直接是频率量纲,后面接限幅和归一化都很自然,不用再维护一个累加器;
- 停机、切换工作模式的时候,可以直接把输出置 0 或置成某个固定值,不用担心“上一次的增量还留着”;
- 缺点是积分项会一直累积,必须自己做抗积分饱和——这也是这段代码里最值得写下来的部分。
抗积分饱和:夹两次
积分饱和是这样发生的:只要偏差一直存在(比如电机堵转,实测电流始终到不了目标), 积分项就会一路累加。等偏差终于反号的时候,积分项已经大得离谱, 输出要过很久才能退回来——表现就是“反应迟钝、超调、来回振荡”。
我用了两段限幅:先把积分项本身夹在一个上下限之间,再对最终输出夹一次。 第一段限制“记忆”能有多深,第二段限制“输出”能有多大。两段都必要,缺一段都还会留一个饱和的入口。
/* 伪代码骨架:位置式 PI 的形状,系数与上下限全部是占位符,不含真实取值 */
err = current_set - current_measured; /* 偏差 = 额定电流 - 实测电流 */
integral += err; /* 位置式:积分项持续累积 */
if (integral < INTEGRAL_MIN) integral = INTEGRAL_MIN; /* 抗积分饱和第一段:夹积分项 */
if (integral > INTEGRAL_MAX) integral = INTEGRAL_MAX;
out = KP * err + (KI * integral) / NORM_BASE; /* 比例项 + 积分项 */
if (out < OUT_MIN) out = OUT_MIN; /* 抗积分饱和第二段:夹输出 */
if (out > OUT_MAX) out = OUT_MAX;
target_freq = out * MAX_FREQ / current_set; /* 归一化到频率量纲 */
if (target_freq > freq_limit) target_freq = freq_limit; /* 上层给出的频率上限 */
if (target_freq < MIN_RUN_FREQ) target_freq = MIN_RUN_FREQ;
if (freq_limit == 0) target_freq = 0; /* 上限为 0 就是要停机 */
这段骨架里我特意保留了三个“下限”的存在,因为它们各自防的是不同的东西:
INTEGRAL_MIN 防积分项往负方向跑飞,
OUT_MIN 防输出出现无意义的负频率,
MIN_RUN_FREQ 则是工艺下限——
频率低到一定程度泵就带不动了,与其让它半死不活地转,不如给一个明确的最低运行频率。
至于这三个下限具体取多少,全部按电机与泵的实测特性定,本文不给数值。
五、加减速自适应:把冲击电流压下去
频率指令不会一步跳到目标值。原因很直接:电机是一个惯性系统, 频率突变相当于给转子一个阶跃,转差瞬间变大,电流会冲到一个很高的值, 轻则过流保护动作,重则把逆变桥的余量吃光。
我用的方法是频率斜坡:在每个 PWM 周期中断里,让“当前频率”朝“目标频率” 走一小步,升频和降频分开处理,步长由斜坡时间换算而来。 真正有意思的是这个斜坡时间不是常数,而是随电流偏差自适应的:
- 算出“实测电流与额定电流的偏差”,并把它折算成一个正数(只看偏差大小,不看方向);
- 偏差越大,说明离目标越远,斜坡给得越慢;偏差越小,斜坡可以给快一点;
- 于是加速过程是这样的:起步慢(偏差大)→ 中间快(偏差小)→ 接近目标时再收敛。
这一步的收益在示波器上能直接看到:没有斜坡的时候,升频瞬间的直流母线电流有一个很尖的峰; 加上斜坡之后,同样的负载下这个峰明显变矮、变宽。斜坡时间的具体区间按实测定, 我的经验是“能压住冲击电流”和“响应别太肉”这两头都要试, 太慢的话负载需求上来时频率跟不上,泵会先掉速再补上,感觉更差。
| 环节 | 做法 | 不做会怎样 |
|---|---|---|
| 频率斜坡 | 当前频率按步长逼近目标频率,升频降频分别处理 | 升频瞬间电流冲击,容易触发过流保护;降频太猛会出现母线泵升电压 |
| 自适应斜坡时间 | 斜坡时间随电流偏差变化,偏差大就慢、偏差小就快 | 固定斜坡时间只能在某一个负载点上合适,换个工况不是太慢就是太冲 |
| 堵转保护计数 | 对“电流持续顶在额定值附近”的状态计数,超时判定为憋住并停机 | 泵憋住时电流会一直顶在保护边缘,长时间运行就是发热和损耗 |
顺带说一句堵转保护:它的判据不是“电流超了”,而是“电流长时间贴着上限下不来”。 因为堵转那一瞬间电流确实会超,但很快会被电流环压回去; 真正要防的是“压回去以后还是下不来”这个持续状态。 这个“看持续时间而不是看瞬时值”的思路,和后面欠压那节的计时器是同一套逻辑。
六、欠压降速:区分“软启动被拉低”与“母线真的撑不住”
这一节是这块板子上我认为最值得写的东西。所谓欠压降速,就是 母线电压低了,主动把输出频率降下来,减小功率需求,让母线电压回升。 听起来很简单,但我在调试时遇到了一个把人绕进去的问题:同样是母线电压低,原因完全可能不同。
两种完全不同的“电压被拉低”
| 工况 | 现象 | 我的判据 | 对策 |
|---|---|---|---|
| 情况一:母线真的撑不住 | 长时间运行后电压缓慢下滑,越跑越低 | 电压低于降速点,并且当前频率已经达到了设定频率(说明系统已经进入稳态) | 按线性降速曲线限制最高频率:电压越低,允许的最高频率越低,并保留一个地板频率 |
| 情况二:软启动阶段被拉低 | 刚上电、电机一起转,电压瞬间塌一下,随后回升 | 电压低于降速点,但尚未脱离上电初始阶段,且不属于情况一 | 只把上限压到“当前实际频率”(只降不升),等电压回升再自然放开,不套用降速曲线 |
为什么必须分开?因为我第一版是“一欠压就套降速曲线”,结果每次上电都被坑: 电机刚起转、母线电容还在充电、软启动电流又大,电压瞬间被拉低一下, 程序立刻按降速曲线把频率压到一个很低的点;等电压回升了,频率却要从很低的地方慢慢爬回去, 表现为“每次开机都要磨蹭很久才上得来”。而真正的母线耗尽完全不是这个形状—— 它是缓慢的、持续的、不会自己回升的。
区分这两个工况,我用的是一个“是否已经脱离上电初始阶段”的标记加上 “当前频率是否已达到设定频率”的组合判断。第一个条件把“上电那一小段时间”圈出来, 第二个条件保证情况一只在稳态下才成立。两个条件都不涉及具体电压数值, 所以换一个电源系统也不用重新推。
降速曲线:门槛 + 斜率 + 地板
情况一用的是一条线性曲线,形状是“某个电压门槛以上不限制,门槛以下每低一点、允许频率就低一点, 但有一个地板频率”。地板的存在很重要:降到零就等于停机, 而欠压降速的目的恰恰是“别停、先保命运行”。同时我用的是跟踪到的最低电压 而不是瞬时电压去查这条曲线,这样曲线只会往下走、不会因为电压抖动来回弹, 避免频率跟着噪声上下摆。
生效方式上,我把欠压降速的优先级放在电流环之上:只要欠压状态非零, 就用降速曲线算出的上限直接覆盖 PI 的输出。理由是这两个环的目标本来就冲突—— 电流环想“把频率顶上去把电流压下来”,欠压逻辑想“把频率降下来把电压抬起来”, 同时生效会互相打架。让保护类的逻辑优先,是我在这块板子上定下的规矩。
这件事的教训不是“要小心”,而是三条可以执行的动作: 一是任何名字(目录名、文件名、变量名、注释)都只能当线索,不能当事实; 二是判断一个量的真实含义,只能从它的换算链反推; 三是把核算结果写下来,否则过两周自己也会重新被名字骗一次。 这两篇笔记里我把这套动作写得更细: 《接手陌生代码的前 30 分钟》里“文档与代码冲突时以谁为准”, 以及《定时器与单位换算的五个坑》里“注释里的时间与实算时间不一致”。
七、高温降速与分级允许运行时间
欠压之外还有一条降速来源:温度。功率器件和电机的温度上来了, 继续满频运行只会越来越热,所以需要“温度越高、允许的频率越低”。 我在这块板子上把温度和欠压做成了同一套结构:分档 + 限频 + 允许时间。
用历史最高温做判据
温度判据我用的是采样以来的历史最高温,而不是当前瞬时温度。 原因是温度有惯性:负载一轻,散热器温度会立刻开始回落, 如果按瞬时值判断,就会出现“降速 → 温度掉一点 → 恢复满频 → 温度又上去”的来回振荡。 用历史最高温,降速只会越降越多,直到温度真正稳定下来。代价是恢复慢, 需要有一个明确的复位条件(我放在停机/卸载的时刻)。
分档降速与“允许运行时间”
温度每升高一档就降一档频率,超过某一档之后直接把最高频率钉在更低的水平上; 再往上则按温区给出不同的允许维持时间:温度越高,允许维持的时间越短, 到点后放开到一个固定的低频保命运行。每一档的起点、档距、允许时间都按实测定, 本文不列出这些数值。
欠压这一侧也有类似的“允许运行时间阶梯”,但它分的是电压等级: 把母线电压分成若干级,每一级给一个允许运行时长,电压越低,允许的时间越短; 任何一级超时,就强制把频率降到地板频率保命运行。 计时本身由一个慢节拍定时器累加,不在快中断里做。
| 降速来源 | 分档依据 | 到点动作 | 复位条件 |
|---|---|---|---|
| 高温 | 历史最高温,按温区划分档位,温度越高允许维持时间越短 | 超时后放开到一个固定的低频运行点 | 停机或负载卸载后清除历史最高温 |
| 欠压 | 母线电压等级,每一级一个允许运行时长,电压越低时间越短 | 任一级超时,强制降到地板频率 | 泵停止工作(阀门全关)时清零全部计时器与欠压状态 |
为什么要做“允许时间”而不是一超限就停机?因为这是产线上的设备, 一次误停机的代价可能比多跑两分钟高得多。分级允许时间是一个折中: 短暂的低电压或高温,允许设备继续把当前这一罐料处理完; 但如果迟迟不恢复,就必须牺牲产量换设备安全。 这句话说起来很顺,真正难的是每一级到底给多长时间—— 这个只能按母线的放电特性、泵的允许工况和自己的风险偏好来定。
复位条件是我在调试后期才补上的。最早的版本里,计时器和降速状态一旦置起来就只等超时, 结果出现了一个很隐蔽的现象:上一轮的欠压记忆会带到下一次工作循环里, 明明这次电压正常,频率上限却已经被压住了。现在的做法是泵一停(阀门全关), 所有计时器、欠压状态、跟踪到的最低电压和算出来的频率上限全部清零。
八、采样与滤波:为什么要去极值
这块板子一共采四路模拟量:母线电压、两路温度(散热器与电机)、直流母线电流。 采集放在一个定时器中断里,每次采一轮,连续采若干轮之后做滤波。 滤波用的是最朴素也最有效的办法:排序后去掉最大和最小,对中间几点求平均。
为什么不去掉最大的,或者干脆取平均
- 直接取平均:逆变桥的开关动作会在采样线上耦合出尖峰。 哪怕只有一轮采样撞上尖峰,均值也会被明显拉偏,而这个偏差会直接进电流环。
- 只丢最大值:噪声是双向的,也会有偶发的偏低值(比如转换被中断打断)。 只丢一头,另一头照样能穿过去。
- 去极值平均:丢掉两端各若干个,既保留了平均的平滑性, 又对脉冲型干扰有抵抗力。代价是参与平均的点变少,平滑程度略降。
/* 去极值平均的通用写法(教科书级算法,与任何具体产品判据无关)
n = 一个通道连续采样的轮数,drop = 每一端丢掉的个数,两个值都按需要的
滤波深度与响应速度现场定,因此这里只作为参数传入,不写死任何数字 */
static u16 trim_mean(u16 *buf, u8 n, u8 drop)
{
u16 t; u8 i, j; u32 sum = 0;
for (i = 0; i < n - 1; i++) { /* 点数不多,冒泡排序的开销可以接受 */
for (j = 0; j < n - 1 - i; j++) {
if (buf[j] > buf[j + 1]) {
t = buf[j]; buf[j] = buf[j + 1]; buf[j + 1] = t;
}
}
}
for (i = drop; i < n - drop; i++) sum += buf[i]; /* 只累加中间的一段 */
return (u16)(sum / (n - 2 * drop));
}采样轮数与电流环的带宽是一对矛盾:轮数越多,滤波越平,但这个通道的更新就越慢。 电流环拿到的是一份“迟到”的测量值,环路稳定性会变差。 我在调试时把轮数改大过,波形确实更干净,但泵在负载突变时会有一下明显的顿挫, 最后还是把轮数调回了一个折中值。这个折中值按实测的噪声水平和环路响应定, 没有一个可以套用的数字。
电流换算链:这是一条标定链
直流母线电流不是直接测出来的,而是经过一条换算链估出来的。这条链的每一步都值得写清楚, 但每一步的系数都属于标定参数,本文只讲方法:
- ADC 码值 → 引脚电压:按 ADC 位数与参考电压换算,得到采样点上的电压。
- 引脚电压 → 传感器输出电流:电流经传感器/变压器转换后落到采样电阻上, 这一步的系数是在实验台上打点拟合出来的线性关系,不是算出来的。
- 由频率折算电机电压:既然输出是 V/f 控制,那么当前频率就对应一个电机端电压, 可以按额定点比例折算。
- 按功率守恒估算电机电流:母线侧功率除以电机电压,得到电机电流的估计值。
- 加一项损耗修正:逆变桥和线路上有损耗,用一个小修正项补偿, 让估计值在高频段更接近实测。
- 小信号截零:低于某个码值时直接当 0,避免把噪声放大成假的电流读数。
九、参数存储与三重授权校验
这块板子的参数(设定频率、额定电流、最大频率、堵转超时等)需要掉电保存, 但硬件上没有外挂 EEPROM,所以我把片内 Flash 的最后一页当 EEPROM 用。 参数区的起始地址由分区方案决定,与代码区严格分开,具体地址属于工程里的分区设定, 本文不列出。
写一页参数的完整流程
- 判断目标地址当前是不是空白(读出全 1)——是空白就说明这是首次上电,需要写入出厂默认值;
- 解锁 Flash 控制器;
- 关闭中断:整页擦除期间如果被中断打断去取指令,会拿到非法数据;
- 整页擦除;
- 按字把参数逐个编程写入;
- 重新上锁,恢复中断。
这套流程本身没什么特别的,值得写的是我在批量写函数上犯的一个边界错误: 循环条件写成了闭区间,于是“写 N 个参数”实际会写 N+1 个。
/* 错误示范:批量写入的循环边界写错,会多写一个元素 —— 这是错误写法,不要照抄 */
static void flash_write_multi(u32 addr, const u32 *buf, u32 size)
{
u32 i;
for (i = 0; i <= size; i++) { /* ← 问题在这里:用了闭区间 */
flash_program_word(addr + i * 4, buf[i]);
}
}
/* 正确写法是把条件改成 i < size,或者由调用方把 size 减一;
但更稳妥的是让函数自己校验边界,不要依赖调用者的习惯。 */
这个 bug 的可怕之处在于它不会报错。多写的那一个元素落在参数区的下一个字段上,
程序照常运行,直到某一天你读到那个字段,发现它莫名其妙变成了别的值。
我的做法是给批量写函数加了两道保障:入口先检查
addr 与 size 是否落在参数区范围内,
结束时再回读校验一次。多花几十微秒,换来的是“写坏了能立刻知道”。
三重授权校验:只讲机制
这块板子最终要装到设备上使用,所以固件里做了一层授权校验,由三个互相独立的判据组成, 三者全部通过才算授权有效:
| 校验项 | 数据来源 | 通过条件 | 失败后果 |
|---|---|---|---|
| 管脚电压判据 | 某个专用引脚上的 ADC 读数 | 读数落在预先标定的区间内(区间按实测定,本文不给数值) | 授权标志不置位 |
| 自定义口令比对 | 参数区里保存的一段口令值 | 与固件内的期望值一致(口令本身不写进本文) | 授权标志不置位 |
| 芯片唯一 ID 比对 | MCU 出厂固化的 96 位唯一 ID 与参数区里预先写入的三段副本 | 三段逐段一致 | 授权标志不置位 |
三段比对的意义在于:授权是绑在这一颗芯片上的, 把固件拷到另一块板子上,第三项就过不了。前面的管脚电压与口令是“防随手拷贝”的软门槛, 最后这一项才是真正的绑定。这套机制的完整讨论, 包括它挡不住什么、以及产线换板时有多麻烦, 我在《用 MCU 唯一 ID 做程序绑定:能防什么、防不了什么》里写过。
校验不通过时:停机,而不是死循环
这是我在这块板子上做的一个明确取舍。校验不通过时,我把目标频率置 0—— 电机不转,但程序继续跑:通信还活着、状态还能读、故障码还能显示, 产线或售后有重新写入授权数据的机会。
/* 伪代码骨架:授权校验失败时的处置,只保留控制流形状,不含任何真实判据 */
if (!authorized) {
target_freq = 0; /* 明确停机:设备不转,但通信与状态显示仍然可用 */
}
/* 我不采用的写法(反例,只作为对照写在注释里):
while (!authorized) { } ← 原地等待会让看门狗、通信和诊断一起失效 */为什么“停机”比“死循环”好?我在 那篇笔记的第三节里对比过三种处理方式, 结论很清楚:死循环看起来最“安全”(什么功能都不启动),但它的代价是 现场完全无法判断发生了什么——屏幕不亮、通信不应答, 和“程序烧坏了”“电源坏了”表现一模一样; 更糟的是如果校验代码跑在通信初始化之前,想通过总线把授权数据重新写进去都没有机会, 只能拆机用编程器处理。而“置 0 停机”虽然也是不能用,但设备是“救得回来”的。
十、问题与解决:现象、原因、办法
这一节把我在这个工程里实际处理过的问题逐条列出来。前六条是控制逻辑上的, 后四条更多是“读代码和写代码的习惯”问题——但后面这几条给我的教训反而更深。
| 现象 | 原因 | 办法 |
|---|---|---|
| 每次上电,电机刚起转就被判定欠压,频率被压得很低,很久才爬上来 | 把“上电软启动阶段母线被拉低”和“长期运行后母线真的撑不住”当成了同一种工况,都套了降速曲线 | 用一个“是否已脱离上电初始阶段”的标记把两种工况分开;软启动阶段只按当前实际频率限幅,不套曲线 |
| 低频段电机转矩不足、启动困难,电流反而偏大 | 定子电阻压降在低频段占比变大,纯 V/f 直线建立不起足够磁通 | 在 V/f 直线上叠加低频抬压量,随频率升高线性衰减到零;抬压量按实测电流与带载能力定 |
| 输入电压偏高时,输出到电机的电压跟着一起高 | 调制比固定时,母线电压直接决定输出电压 | 让占空比上限随母线电压反比压低:母线越高,允许的最大占空比越小 |
| 升频瞬间母线电流有很尖的冲击峰 | 频率指令阶跃,转差瞬间变大 | 加频率斜坡,并让斜坡时间随电流偏差自适应:偏差大就慢,偏差小就快 |
| 泵被管路憋住时电流长时间贴着保护边缘 | 只看瞬时电流判定,堵转瞬间会被电流环压回去,但持续状态没人管 | 用“电流持续顶在额定值附近”的计数超时来判定憋住,而不是看瞬时值 |
| 电流读数偶发跳变,进电流环后引起频率抖动 | 逆变桥开关噪声耦合进采样线,偶发尖峰被均值滤波放大 | 连续多轮采样后排序去极值,只对中间一段求平均;采样轮数按噪声与环路响应的折中定 |
| 欠压逻辑怎么都找不到,怀疑功能没实现 | 目录名里标着一个很低的电压数值,与代码里实际的量纲(整流母线电压)完全不是一回事 | 不信名字,从电压换算链反推满量程与量纲;把核算结果写下来,避免自己再被骗一次 |
| 注释里写的载波频率,和按定时器参数实算出来的对不上 | 注释是在早期版本写的,后来改了定时器参数却没人改注释 | 注释只写“为什么”,具体数值一律从参数现场核算;定期用清单核对定时器换算 |
| 参数批量写入后,相邻字段偶发变成莫名其妙的值 | 批量写函数的循环条件用了闭区间,实际多写了一个元素 | 循环条件改成半开区间,函数入口校验地址与长度范围,写完回读校验一次 |
| 改一个 PI 系数要重新编译、下载、上电试 | PI 系数在函数里直接赋值,没有放进参数区 | 计划把系数挪进参数区并加“仅停机可改”的限制(还没改完) |
这十条里,我认为最值得反复讲的是第七条和第八条。它们的共同点是: 错误信息来自“我以为的事实”,而不是来自代码本身。 目录名和注释都是人写的,人写的东西会过期、会写错、会被复制粘贴带走; 而换算链和定时器参数是代码里真正生效的东西。 从那以后我给自己定了一条规矩:凡是涉及数值的判断,先算一遍再说。
十一、局限与还没做完的部分
这个工程的功能是实现了的,泵能调频、电流能被限制住、欠压和高温都会降速。 但下面这些我都清楚是欠的,只是还没动手:
| 问题 | 现状 | 我打算怎么改 |
|---|---|---|
| 中断里做 ADC 轮询 | 启动转换和等待完成都在定时器中断里,中断执行时间不可控 | 改成“中断只启动转换、主循环或完成回调取结果”,或者直接用 DMA 搬运 |
| PI 系数不可在线整定 | 系数在函数里直接赋值,每次调用重写一遍 | 挪进参数区,和别的参数一起保存,加“仅停机可改”的限制 |
| 参数区没有备份 | 只有一份、只有一个起始地址,没有双区备份,也没有版本迁移 | 按《嵌入式参数存储设计:Flash 分区、双区备份与 CRC 校验》的思路做双区 + 版本号 |
| 硬件抽象层名存实亡 | 抽象层目录里有一批模块文件,其中两个文件里只有一行 #include,实际逻辑仍写在功能文件里 |
要么把这两个文件删掉(承认没有抽象),要么把对应逻辑真的挪进去;我倾向后者 |
| 电流是估算的 | 用母线电流、频率和功率守恒折算电机电流,没有真正测相电流 | 先做一轮对比实验:用钳形表在同一工况下逐点核对估算值与实测值,量化这条链的误差 |
| 欠压降速只有定性观察 | 我只在台架上看了几次波形,没有做长时间老化,也没有覆盖电源波动的边界 | 按可靠性测试那个实验里的做法,先定义“什么叫做稳定”,再设计能必然触发的用例 |
| 互补输出的死区没有系统测过 | 死区由定时器配置决定,我只在示波器上抽查了几处波形 | 把死区时间、母线电压、负载电流做成一张对照表,逐个工况记录上下桥波形 |
| 状态调度写在主循环的 switch 里 | 初始化 / 等待 / 运行 / 故障 / 参数设置五个状态平铺在一个函数里 | 抽成显式的状态机表,让每个状态的进入条件、退出条件、错误出口都能单独看 |
写“还没做完”比写“已经实现”要难,因为前者需要承认自己在哪些地方是含糊过去的。 但这块板子给我的最大收获恰好就在这些含糊的地方:功能跑通只需要几天, 把每个数值的来龙去脉弄清楚要久得多。上面这八条,我打算先从“中断里的 ADC 轮询”开始改, 因为它影响的是控制周期的确定性,这比参数存储的优雅程度重要得多。
参考资料与说明
- Cortex-M0 主控芯片参考手册(高级定时器 TIM1 的互补输出、死区与重复计数器章节;ADC 章节), 用于确认互补 PWM 的配置方式与 ADC 转换时序。
- Cortex-M0 内核技术参考手册(NVIC 与中断优先级章节),用于确认中断嵌套与优先级安排。
- 三相异步电机 V/f 恒磁通控制与低频转矩补偿的通用公开资料,用于确认低频抬压的作用机理。
- 三相电压型逆变桥与 SPWM 调制的通用教材资料,用于确认查表法与调制比的定义。
- IPC 关于印制板设计的通用规范(Generic Standard on Printed Board Design) 中电气间隙与爬电距离的一般要求,用于规划强电区域的布局间距; 本文不展开 PCB 布局细节,仅作为公开参考列出。
- STMicroelectronics STM32CubeMX 与 HAL 库公开文档,用于确认工程配置与外设初始化流程。
- 文中涉及的芯片型号、工具与封装名称仅用于说明技术方案,与相关厂商无隶属或授权关系。
- 本文不列出任何产品特定参数:电压/温度/频率阈值、PI 系数、斜坡时间、载波频率、 PWM 周期值、参数区地址与出厂默认值均已省略,只保留方法与结构。
- 示例代码为按个人理解重写的最小片段,不代表任何产品或交付代码; 其中标注为“错误示范”的片段仅用于说明问题,请勿照抄。
- 出于对个人项目实现细节的保护,本文只公开方法与思路; 涉及核心实现的部分以步骤与字段说明代替代码,示例代码为按个人理解重写的通用片段。