PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿

2026-11-112

# PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿

 

 

前面几篇,我们已经实现了启动保持、停止优先和双速切换。这节课几乎就是工厂运行的实际真机,看着逻辑多烧脑,而工厂就是这么多的逻辑:

  • 正反转互锁:正转和反转不能同时给,不然电机烧了
    200ms 死区:换向要等一下,不能直接反
    速度切换:慢 80 / 快 160,慢到快要等 100ms 确认,快到慢要立刻降
  • PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_1.webp
  • 这一篇继续在原来的台架上增加一个功能:电机正反转。

正反转不只是多接两路信号,还要把几个问题处理清楚:

- 正转、反转同时被选中时,听谁的?
- 电机正在正转,能不能直接切成反转?
- 方向冲突解除之后,电机会不会自己重新启动?
PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_2.webp

这次的处理规则是:两个方向互锁;换向先停止输出,等待 **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 个网络,重点变化集中在首扫描初始化、冲突判断、运行保持和方向输出。

PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_3.webp

### 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 不直接把输入原样送给驱动板,而是先判断当前状态。

PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_4.webp

本次使用 `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. 上电默认:慢速已选,电机不转

初始化及解锁条件完成后,不写方向,不按启动。

PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_5.webp

预期:

- `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. 正转过程中切快速

PLC + Arduino 实战笔记(15):方向互锁,电机正反转与换向停顿_image_6.webp

写入:

```
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 在许可有效时重启,不直接恢复运行。

最后再提醒一次:固定停顿不等于停稳检测,普通软件控制也不等于安全急停。台架测试全程应有人看守,并保留能迅速切断动力的措施。

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -