PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿
# PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿
前面几篇,我们已经实现了启动保持、停止优先和双速切换。这节课几乎就是工厂运行的实际真机,看着逻辑多烧脑,而工厂就是这么多的逻辑:
正反转互锁:正转和反转不能同时给,不然电机烧了 200ms 死区:换向要等一下,不能直接反 速度切换:慢 80 / 快 160,慢到快要等 100ms 确认,快到慢要立刻降
- 这一篇继续在原来的台架上增加一个功能:电机正反转。
正反转不只是多接两路信号,还要把几个问题处理清楚:
- 正转、反转同时被选中时,听谁的?
- 电机正在正转,能不能直接切成反转?
- 方向冲突解除之后,电机会不会自己重新启动?
这次的处理规则是:两个方向互锁;换向先停止输出,等待 **200ms**;冲突、停止或取消后,不自动恢复运行。
需要先说明:这里的 200ms 是教学用的换向间隔,不是电机停稳反馈。本台架也是普通控制链,不能代替硬件安全急停。
## 一、本次台架与功能分工
使用的硬件不变:
- 西门子 S7-200 SMART CR40
- Arduino Uno
- 4 路光耦隔离模块
- DFRobot DRI0042 直流电机驱动板
- 直流电机PLC 继续处理启动保持、停止、取消和启动延时,本次增加方向请求、方向互锁和冲突保护。
Arduino 接收 PLC 送来的运行许可、速度和方向信号,负责方向确认、速度切换以及换向停顿,最后输出驱动信号。
整体关系如下:
```
PLC
├─ 运行许可
├─ 慢速选择
├─ 正转命令
└─ 反转命令
↓
4 路光耦隔离
↓
Arduino
├─ 判断许可与方向
├─ 方向稳定确认
├─ 换向停顿
└─ 输出方向与 PWM
↓
DRI0042
↓
直流电机
```本次没有增加方向按钮,正反转和速度仍通过 PLC 状态表选择,`I0.4` 继续作为取消运行输入。
## 二、先分清:许可、方向、速度
调试时最容易混淆的,就是把“有方向”当成“应该转”。
实际上,这三个信号各管一件事:
| 信号 | 负责什么 |
|---|---|
| 运行许可 | 当前是否允许运行 |
| 方向命令 | 要求正转还是反转 |
| 速度选择 | 使用慢速还是快速 |因此,有正转命令,但没有运行许可,电机不转;有运行许可,但没有有效方向,电机同样不转。
本次使用的方向和速度地址如下:
| 地址 | 含义 |
|---|---|
| M11.0 | 慢速标志,1 为慢速,0 为快速 |
| M11.1 | 正转请求 |
| M11.2 | 反转请求 |
| M11.3 | 方向冲突标志 |
两个方向请求有四种组合:
| M11.1 | M11.2 | 结果 |
|---:|---:|---|
| 0 | 0 | 无方向 |
| 1 | 0 | 正转 |
| 0 | 1 | 反转 |
| 1 | 1 | 方向冲突,停止保护 |最后一种情况不做优先级选择,而是停止运行。这样不会出现“两个方向都选了,程序替操作者猜一个”的情况。
## 三、接线:四路命令,全部低有效
PLC 到 Arduino 的信号分配如下:
| PLC 输出 | 光耦通道 | Arduino 引脚 | 含义 |
|---|---|---|---|
| Q1.5 | O1 | D10 | 运行许可 |
| Q1.4 | O2 | D11 | 慢速选择 |
| Q1.6 | O3 | D12 | 正转命令 |
| Q1.7 | O4 | D7 | 反转命令 |这四路 PLC 输出都在 4L 组,按本台架接法,4L 公共端接 24V。
Arduino 侧的四路输入均为低有效:
- 低电平:对应命令有效。
- 高电平:对应命令无效。
- 慢速选择有效时,使用 `PWM80`。
- 慢速选择无效时,使用 `PWM160`。驱动板接线沿用前篇:
| Arduino | DRI0042 |
|---|---|
| D5 | IN1 |
| D4 | IN2 |
| D6 | PWM |
| GND | COM |这里要注意“输入电平”和“日志状态”的区别。虽然输入是低有效,但串口打印的是转换后的逻辑状态:`1` 表示有效,`0` 表示无效。
例如,D12 读到低电平,表示正转命令有效,日志里显示的是 `F1`。
## 四、PLC:沿用许可链,增加方向互锁
本次 PLC 程序共 11 个网络,重点变化集中在首扫描初始化、冲突判断、运行保持和方向输出。

### 1. 首扫描:默认慢速,清掉方向请求
N1 中执行:
```
S M11.0, 1
R M11.1, 2
```第一条把 `M11.0` 置 1,默认选择慢速。
第二条从 `M11.1` 开始,连续复位两个位,也就是同时清掉:
```
M11.1 正转请求
M11.2 反转请求
```因此,初始化后的状态是“慢速已选、方向未选”,而不是默认正转。
### 2. 冲突判断:两个请求同时成立
N2 的逻辑是:
```
M11.3 = M11.1 AND M11.2
```只要两个方向请求同时为 1,`M11.3` 就变为 1。
这个冲突标志不锁存。清掉其中一个方向请求,`M11.3` 就会回到 0。
但冲突标志恢复为 0,不代表恢复运行,因为运行保持已经被冲突条件清掉了。
### 3. 运行保持:冲突后必须重新启动
N4 延续前篇的运行保持逻辑,停止、取消和方向冲突都会让运行保持退出。
许可链仍然是:
```
按启动
↓
M15.2 运行保持
↓
T37 等待 3 秒
↓
M15.3
↓
Q1.5 运行许可
```这里保留了一个很重要的行为:冲突解除后,不能自动接着转。必须重新按启动,再等待 3 秒。
### 4. 方向输出:互相串入对方的常闭条件
N9、N10 的逻辑分别为:
```
Q1.6 = M11.1 AND NOT M11.2
Q1.7 = M11.2 AND NOT M11.1
```也就是:
- 正转请求有效,并且反转请求无效,才输出正转命令。
- 反转请求有效,并且正转请求无效,才输出反转命令。对应结果如下:
| 正转请求 | 反转请求 | Q1.6 正转输出 | Q1.7 反转输出 |
|---:|---:|---:|---:|
| 0 | 0 | OFF | OFF |
| 1 | 0 | ON | OFF |
| 0 | 1 | OFF | ON |
| 1 | 1 | OFF | OFF |两个方向请求同时出现时,两个方向输出反而都关闭,这就是本次的**方向互锁**。
另外,`Q1.6/Q1.7` 只跟方向请求有关,不经过 `M15.2、T37、Q1.5`。
所以,即使没有按启动,也可能看到 `Q1.6` 或 `Q1.7` 为 ON。这不代表电机会转,Arduino 还要检查运行许可。
## 五、Arduino:用状态机处理方向和换向
Arduino 不直接把输入原样送给驱动板,而是先判断当前状态。

本次使用 `ST0~ST6` 七个状态:
| 状态 | 含义 |
|---|---|
| ST0 | 停止,例如无运行许可或无有效方向 |
| ST1 | 上电或冲突后的阻止状态,需要先撤销许可至少 200ms |
| ST2 | Arduino 输入侧检测到正反方向同时有效 |
| ST3 | 方向确认,等待方向请求稳定 50ms |
| ST4 | 换向停顿,停止输出并等待 200ms |
| ST5 | 正转输出 |
| ST6 | 反转输出 |其中有三个时间需要分清:
| 时间 | 用途 |
|---|---|
| 50ms | 方向请求稳定确认 |
| 100ms | 慢速切快速的等待时间 |
| 200ms | 换向停顿 |快速切慢速立即降速;慢速切快速需要等待 100ms。
换向时,重点看 `ST4`:
```
正转 ST5
↓
换向停顿 ST4,PWM0
↓
反转 ST6
```
反转切回正转也是一样:
```text
反转 ST6
↓
换向停顿 ST4,PWM0
↓
正转 ST5
```这里的“停”,指程序停止驱动输出,不是已经检测到电机机械停稳。200ms 只是本次台架使用的教学参数。
## 六、串口日志怎么看?
#include <stdio.h>
// All inputs assume active-low isolation outputs compatible with 5V pull-ups.
const byte RUN_PIN=10, SLOW_PIN=11, FWD_PIN=12, REV_PIN=7;
const byte IN1_PIN=5, IN2_PIN=4, PWM_PIN=6;
const byte ACTIVE_LEVEL=LOW;
const bool USE_INTERNAL_PULLUP=true;
const byte LOW_PWM=80, HIGH_PWM=160;
const unsigned long OFF_ARM_MS=200, CMD_CONFIRM_MS=50;
const unsigned long FAST_CONFIRM_MS=100, DIRECTION_STOP_MS=200;
bool armed=false, offTiming=false, fastTiming=false;
unsigned long offSince=0, requestSince=0, fastSince=0, stoppedAt=0, lastLog=0;
int movingDir=0, lastDrivenDir=0, requestDir=0, appliedPWM=0;
int state=0;
long previousCode=-1;
// States: 0 STOP, 1 BOOT_BLOCK, 2 CONFLICT, 3 CONFIRM, 4 DEADTIME, 5 FWD, 6 REV.
void motorStop(unsigned long now) {
analogWrite(PWM_PIN,0);
digitalWrite(IN1_PIN,LOW);
digitalWrite(IN2_PIN,LOW);
if (movingDir!=0) {
lastDrivenDir=movingDir;
stoppedAt=now;
}
movingDir=0;
appliedPWM=0;
}
void motorDrive(int direction, byte pwm) {
if (movingDir!=direction) {
analogWrite(PWM_PIN,0);
digitalWrite(IN1_PIN,LOW);
digitalWrite(IN2_PIN,LOW);
digitalWrite(direction==1 ? IN1_PIN : IN2_PIN,HIGH);
}
analogWrite(PWM_PIN,pwm);
movingDir=direction;
lastDrivenDir=direction;
appliedPWM=pwm;
}
void setup() {
digitalWrite(IN1_PIN,LOW); digitalWrite(IN2_PIN,LOW); digitalWrite(PWM_PIN,LOW);
pinMode(IN1_PIN,OUTPUT); pinMode(IN2_PIN,OUTPUT); pinMode(PWM_PIN,OUTPUT);
motorStop(millis());
pinMode(RUN_PIN,USE_INTERNAL_PULLUP ? INPUT_PULLUP : INPUT);
pinMode(SLOW_PIN,USE_INTERNAL_PULLUP ? INPUT_PULLUP : INPUT);
pinMode(FWD_PIN,USE_INTERNAL_PULLUP ? INPUT_PULLUP : INPUT);
pinMode(REV_PIN,USE_INTERNAL_PULLUP ? INPUT_PULLUP : INPUT);
Serial.begin(9600);
}
void loop() {
unsigned long now=millis();
bool run=digitalRead(RUN_PIN)==ACTIVE_LEVEL;
bool slow=digitalRead(SLOW_PIN)==ACTIVE_LEVEL;
bool fwd=digitalRead(FWD_PIN)==ACTIVE_LEVEL;
bool rev=digitalRead(REV_PIN)==ACTIVE_LEVEL;
bool conflict=fwd && rev;
int desired=fwd==rev ? 0 : (fwd ? 1 : -1);
if (!run) {
motorStop(now);
requestDir=0;
fastTiming=false;
if (!offTiming) { offTiming=true; offSince=now; }
if (now-offSince>=OFF_ARM_MS) armed=true;
state=conflict ? 2 : 0;
} else if (conflict) {
motorStop(now);
armed=false;
offTiming=false;
requestDir=0;
fastTiming=false;
state=2;
} else if (!armed) {
motorStop(now);
offTiming=false;
requestDir=0;
fastTiming=false;
state=1;
} else if (desired==0) {
motorStop(now);
offTiming=false;
requestDir=0;
fastTiming=false;
state=0;
} else {
offTiming=false;
if (desired!=requestDir) {
requestDir=desired;
requestSince=now;
fastTiming=false;
}
if (movingDir!=0 && movingDir!=desired) motorStop(now);
bool confirming=now-requestSince<CMD_CONFIRM_MS;
bool deadtime=lastDrivenDir!=0 && lastDrivenDir!=desired && now-stoppedAt<DIRECTION_STOP_MS;
if (confirming || deadtime) {
motorStop(now);
fastTiming=false;
state=deadtime ? 4 : 3;
} else {
byte target=LOW_PWM;
if (slow) fastTiming=false;
else {
if (!fastTiming) { fastTiming=true; fastSince=now; }
if (now-fastSince>=FAST_CONFIRM_MS) target=HIGH_PWM;
}
if (movingDir!=desired || appliedPWM!=target) motorDrive(desired,target);
state=desired==1 ? 5 : 6;
}
}
long code=long(run)+2L*int(slow)+4L*int(fwd)+8L*int(rev)+16L*int(armed)+32L*state+256L*appliedPWM;
if ((code!=previousCode && now-lastLog>=100) || now-lastLog>=1000) {
char line[62];
int n=snprintf(line,sizeof(line),"L15 R%d S%d F%d V%d A%d ST%d PWM%d\r\n",int(run),int(slow),int(fwd),int(rev),int(armed),state,appliedPWM);
if (n>0 && n<int(sizeof(line)) && Serial.availableForWrite()>=n) {
Serial.write((const uint8_t*)line,(size_t)n);
lastLog=now;
previousCode=code;
}
}
}
本次日志格式为:
```
L15 R%d S%d F%d V%d A%d ST%d PWM%d
```各字段含义如下:
| 字段 | 含义 |
|---|---|
| L15 | 本篇程序的日志标记 |
| R | 运行许可 |
| S | 慢速选择 |
| F | 正转命令 |
| V | 反转命令 |
| A | 是否已解锁 |
| ST | 当前状态 |
| PWM | 当前 PWM 输出值 |`R、S、F、V、A` 都是逻辑值,`1` 表示有效,`0` 表示无效。
例如:
```
L15 R1 S1 F1 V0 A1 ST5 PWM80
```可以读成:
> 运行许可有效,选择慢速,正转有效、反转无效,已经解锁,当前正转,PWM 输出为 80。
调试时可以按这个顺序看:
1. 看 `R`,有没有运行许可。
2. 看 `F/V`,方向是否正确。
3. 看 `A`,是否已经解锁。
4. 看 `ST`,当前卡在哪个状态。
5. 看 `PWM`,最终有没有输出。这样比只看电机“转不转”更容易定位问题。
## 七、台架验证:从默认状态到冲突保护
先在 PLC 状态表中加入需要观察的地址,打开监控:
```
M11.0
M11.1
M11.2
M11.3
M15.2
I0.1
I0.4
Q1.4
Q1.5
Q1.6
Q1.7
```修改状态表时,记得完成“输入新值 → 点击写入”。只在新值栏敲数字,还不算写入。
### 1. 上电默认:慢速已选,电机不转
初始化及解锁条件完成后,不写方向,不按启动。

预期:
- `M11.0=1`。
- 两个方向请求均为 0。
- `Q1.4=ON`。
- `Q1.6/Q1.7=OFF`。
- 电机停止。稳定后的参考日志:
```
L15 R0 S1 F0 V0 A1 ST0 PWM0
```
### 2. 只给正转请求,不给许可
写入:
```
M11.1 = 1
M11.2 = 0
```此时 `Q1.6=ON`,日志出现 `F1`,但由于没有运行许可,仍然是:
```
R0 ST0 PWM0
```电机不转。
这一步验证的是:方向命令不能代替运行许可。
### 3. 按启动,等待 3 秒
确认 `I0.1=ON`,按启动,等待 3 秒。
预期进入正转慢速:
```
L15 R1 S1 F1 V0 A1 ST5 PWM80
```### 4. 正转过程中切快速

写入:
```
M11.0 = 0
```经过 100ms 的慢转快等待后,PWM 切换为 160,方向不变,也不需要停机。
```
L15 R1 S0 F1 V0 A1 ST5 PWM160
```### 5. 快速正转切反转
把方向请求改为:
```
M11.1 = 0
M11.2 = 1
```如果逐项写入,先清掉原方向,再写入新方向,避免中间出现两个请求同时为 1。
换向过程中,应先看到:
```
ST4 PWM0
```停顿约 200ms 后,再进入反转:
```
L15 R1 S0 F0 V1 A1 ST6 PWM160
```这一步不能只看最后有没有反转,还要确认中间经过了换向停顿。
### 6. 反转切回正转
再把方向改回:
```
M11.1 = 1
M11.2 = 0
```同样应经过:
```
ST4 PWM0
```然后进入:
```
L15 R1 S0 F1 V0 A1 ST5 PWM160
```正转切反转、反转切正转都要测一次。
### 7. 两个方向请求同时为 1
运行中写入:
```
M11.1 = 1
M11.2 = 1
```预期:
- `M11.3=1`。
- 运行保持退出。
- `Q1.5/Q1.6/Q1.7` 全部 OFF。
- 电机停止。
- 日志为 `R0 F0 V0 ST0 PWM0`。
- 不出现 `ST2`。为什么这里不是 ST2?
因为 PLC 已经先把冲突挡住。虽然 PLC 内部两个方向请求都是 1,但实际送给 Arduino 的两个方向命令都是无效,运行许可也被撤销了。
所以,这项测试要同时看两个地方:PLC 的 `M11.3=1`,Arduino 不出现 `ST2`。
还要区分一下:这里说的是运行许可和两个方向输出关闭,不能笼统写成“四路 PLC 输出全关”。`Q1.4` 是速度选择,不能把它等同于运行许可。
### 8. 清掉冲突,不按启动
保留正转请求,把反转请求清零:
```
M11.1 = 1
M11.2 = 0
```此时:
- `M11.3` 回到 0。
- 正转命令可以重新有效。
- 电机仍然停止。
- 日志保持 `ST0 PWM0`。重新按启动并等待 3 秒后,才恢复正转。
这一步验证的就是**不自动恢复**。
### 9. 取消运行或撤销允许
分别验证两种情况:
- 运行中按取消 `I0.4`。
- 运行中断开允许 `I0.1`。预期都是撤销 `Q1.5`,进入 `ST0`,PWM 变为 0。
输入条件恢复后,电机不应自行重新运行。
### 10. 许可有效时重启 Arduino
保持 PLC 运行许可有效,重启 Arduino。
预期 Arduino 进入:
```
ST1 PWM0
```即使外部许可仍然有效,也不能直接恢复输出。
先撤销运行许可,保持至少 200ms,再重新按启动、等待 3 秒,才能恢复运行。
## 八、几个容易踩的坑
### 状态表是空的
新工程需要自己添加地址,再打开监控。没有地址、没有监控,就无法判断请求有没有真正变化。
### 数值输入了,却没有生效
检查是否点击了写入。新值栏里有数字,不代表 PLC 当前值已经改变。
### 有许可,电机却不转
先看 `F/V`。
如果两个方向都是 0,就没有有效方向;再看 `A` 和 `ST`,确认是否处于阻止或确认状态。
### 有方向输出,电机却不转
先看 `R`。
本次方向输出不经过启动延时链,所以没按启动时也能看到方向输出 ON,但电机仍应停止。
### 把冲突当成程序故障
`M11.3=1` 表示两个方向请求同时成立,保护正在工作。
正确处理是清掉冲突,再重新按启动,而不是绕过冲突条件。
### 日志方向与预期机械方向不一致
停机断电,检查 D5、D4 到 IN1、IN2 的接线,以及当前采用的正反转定义。需要对调时,换线后正转、反转都要重新验证。
如果 `ST5` 和 `ST6` 都出现了,但电机实际始终朝同一方向转,不要只凭这一现象就认定是两根线接反,应先核对方向信号是否分别正确接入。
## 九、继续动手:把换向停顿改成 500ms
完成基本验证后,可以把换向停顿从 200ms 改成 500ms,再分别测试:
```
正转 → 停顿 → 反转
反转 → 停顿 → 正转
```修改时只改换向停顿参数,不要把方向确认的 50ms、慢转快的 100ms,以及许可撤销解锁的时间一起改掉。
然后再做两个小验证:
| 操作 | 应看到的结果 |
|---|---|
| 无运行许可时,只写 `M11.2=1`,保持 `M11.1=0` | Q1.7 ON,但电机不转 |
| 运行中把两个方向请求都写成 1 | M11.3=1,Q1.5 撤销,电机停止 |
| 清掉冲突,但不按启动 | 电机不自动恢复 |能解释这三种结果,就基本分清了方向请求、运行许可和运行保持之间的关系。
## 十、本篇小结
这次增加的不是单纯的“反转功能”,而是一套有约束的方向控制:
- PLC 用方向互锁避免同时输出正反转命令。
- 方向冲突会清掉运行保持,解除冲突后仍需重新启动。
- Arduino 在换向时先停止输出,等待设定间隔,再输出新方向。
- Arduino 在许可有效时重启,不直接恢复运行。最后再提醒一次:固定停顿不等于停稳检测,普通软件控制也不等于安全急停。台架测试全程应有人看守,并保留能迅速切断动力的措施。




