用 C 语言编程 PLC:指针注入实现数字 I/O 结构化处理

这是系列文章的第一篇,介绍如何用C语言为PLC编程。适合希望提升编程技能的中级程序员,重点关注机器控制与安全性。如图1所示的Arduino Opta将作为开发平台使用。购买信息及相关TechForum文章列表请参阅本页面

主要目标是通过减少程序冗余来简化逻辑结构。此代码片段展示了一个示例。if语句会评估选择开关、红色按钮和绿色按钮的状态。根据状态结果,它将设置运行状态及红绿面板灯的颜色。该代码技术可描述为:遵循传统PLC程序扫描方式(在超级循环顶部读取输入输出),通过指针注入进行增强。

代码下载:DemoIOUtil.zip (2.0 KB)

 if (localSelectorSwitch.value && localStartPB.value && !localStopPB.value) {
    runState = true;
    localPLRed.value = false;
    localPLGreen.value = true;
  }

技术提示 :关于嵌入式系统该用C还是C++的争论持续存在。Arduino代码库大部分采用C++编写。但这并非绝对共识,因为C语言的速度与低开销,与C++的额外保护、抽象性和优雅特性需要权衡。具体适用性取决于嵌入式项目的实现方式。

本文面向正从易用的Arduino转向嵌入式编程挑战的学员。无论优劣,文中采用了类C结构。建议您进一步将例程转换为C++,自行判断哪种方法更适合您的嵌入式项目。

图1 :安装在DigiKey训练器上的Arduino Opta图示。它支持IEC 61131-3标准的C或C++语言编程。

技术提示 :建议从IEC 61131-3梯形图语言开始学习PLC编程。您将发现PLC的精髓不仅在于编程,更在于其与物理世界的实时交互能力。梯形图能有效培养对PLC主要控制与接口条件开关特性的思维方式。

突破 Arduino 舒适区

图1所示的Opta是个被低估的竞争者。虽然Opta本身只有8个输入和4个继电器输出,但请注意它具有扩展性。例如可添加数字和模拟扩展模块。设备还配备Modbus通信端口和以太网端口,通过外接硬件可大幅扩展总输入输出数量。

技术提示 :切勿将Opta视为普通小型PLC或"智能继电器"。当我们利用Modbus和以太网连接时,搭载32位双ARM处理器(Cortex-M7和Cortex-M4)的多语言编程能力将得到充分释放。结合外部配套硬件使用,Arduino Opta为现代边缘计算提供了独特的社区支持解决方案。但首先需要建立编程结构来简化代码库。

本文撰写时已考虑到这种扩展需求。为Opta编程实现实时响应并非易事,尤其当涉及数十个输入输出连接时。当复杂度达到某个临界点时,抽象化和编程结构就成为了必需品。缺乏这些要素会导致代码难以管理且几乎无法排错。

接下来请做好准备,我们将深入软件开发领域,学习如何创建结构和抽象化方法。本文如同桥梁,连接着Arduino草图的平静水域与模块化编程的结构化世界,并展望安全关键系统中代码安全性、可移植性和可靠性的未来。

结构体是程序优化的关键组件

如我前文所述,结构体是程序组织架构的核心要素。前文中我们使用结构体整合了RC机械臂中各伺服电机的相关变量和常量。虽然整合过程可能需要适应,但最终效果证明其价值。最终我们能将伺服电机作为单一实体处理,这使得HomeAllServos()等函数的实现变得异常简单。

1) 结构体描述

现在我们可以将这个封装理念扩展到Opta的输入输出引脚,使用LocalInputPin和LocalOutputPin结构体。

typedef struct {
    uint8_t pin;         // Arduino physical pin number
    const char *name;    // Descriptive name (e.g., "Start Pushbutton")
    bool inverted;       // True for invert e.g., a normally closed pushbutton
    bool value;          // Current state (updated by LocalPinUtil.cpp)
} LocalInputPin;

typedef struct {
    uint8_t pin;         // Arduino pin number
    const char *name;    // Descriptive name (e.g., "Motor Enable")
    bool value;          // Current state
} LocalOutputPin;

2) 为每个输入输出实例化结构体

当我们在主.ino文件中为输入输出引脚实例化结构体时,魔法就发生了。这段代码显示所有I/O引脚相关变量和常量都被添加到结构体中。包括物理Arduino I/O引脚和描述I/O功能的人类可读字符串。还设有参数可反转输入引脚逻辑,自然适配停止按钮等常闭开关。输出引脚结构体与输入结构体类似。您可以选择添加额外代码来反转输出,以处理负逻辑情况。

LocalInputPin localSelectorSwitch = { A0, "Local Switch", 0, 0 };
LocalInputPin localStopPB = { A1, "Stop Switch", 1, 0 };
LocalInputPin localStartPB = { A2, "Start Switch", 0, 0 };
LocalInputPin localProxSensor = { A3, "Prox Sensor", 0, 0 };

LocalOutputPin localPLGreen = { 0, "Green Panel Lamp", 0 };
LocalOutputPin localPLRed = { 1, "Red Panel Lamp", 1 };
LocalOutputPin localCR1 = { 2, "Control Relay", 0 };
LocalOutputPin localFan = { 3, "Cooling Fan", 0 };

技术提示 :常闭停止按钮是防止断线的传统方法。一般而言,启动PLC的设备应使用常开开关。停止机器的设备应为常闭状态。

务必评估机器的安全性能。根据需要添加冗余电路,以保护机械设备,更重要的是保护操作人员和技术人员。

3) 构建指针数组

接下来,我们构建一个指针数组,其中包含一个用于输入引脚和一个用于输出引脚的数组。

LocalInputPin* inputPinList[] = {
  &localSelectorSwitch,
  &localStopPB,
  &localStartPB,
  &localProxSensor,
};

LocalOutputPin* outputPinList[] = {
  &localPLGreen,
  &localPLRed,
  &localCR1,
  &localFan,
};

4) 自动确定指针数组的大小

通过将结构体的总大小(字节)除以结构体中的元素数量,自动计算每个结构体中输入和输出引脚的数量。

static const uint8_t numInputPins = sizeof(inputPinList) / sizeof(inputPinList[0]);
static const uint8_t numOutputPins = sizeof(outputPinList) / sizeof(outputPinList[0]);

技术提示 :我们可以手动硬编码输入和输出指针的数量。但是,当输入和输出的数量发生变化时,这可能会导致错误。

5) 注册结构体数组并初始化 I/O

下一步是将结构体数组注册到LocalPinUtil.cpp中。这种“指针注入”是简化代码的关键步骤。它允许主.ino文件拥有原始的输入和输出结构体。然而,它将责任转移到LocalPinUtil.c以对这些结构体进行操作。例如,执行pinMode()、digitalWrite()和digitalRead()操作。
整个系统依赖于LocalPinUtil.c维护指向主.ino文件中结构体的静态指针。这些指针处理了困难的部分,而主.ino文件中的程序员可以愉快地访问输入和输出结构体的成员。

void setup() {
  registerLocalInputPins(inputPinList, numInputPins);
  registerLocalOutputPins(outputPinList, numOutputPins);
  initLocalInOut();
}

6) 每次循环迭代调用更新函数

最后一步是简单地请求LocalPinUtil.cpp在每次循环迭代时更新I/O。这种方法类似于PLC的程序扫描。回想一下,PLC程序扫描由几个阶段组成:

  • 内务处理活动

  • 读取输入

  • 执行用户程序

  • 设置输出

用户程序从不直接操作I/O引脚。而是执行内存到内存的操作,直接修改关联结构体的value成员。

void loop() {
  LocalInOutUpdate();

    // My program

}

技术提示 :传统PLC程序扫描确实会引入延迟,因为输入输出不会立即更新。这种实时性能表现已被现场数百万台PLC广泛采用。

对于需要更快响应时间的应用,可采用专用硬件或支持中断的PLC。

7) 使用变量

完成基础工作后,现在可以在程序中使用这些变量。本示例中,我们根据运行状态和接近传感器的值控制冷却风扇。回到最初的结构体定义,可见每个输入输出结构体都有名为value的成员。我们使用点标记法访问目标value成员。

这是处理PLC程序逻辑近乎理想的方式。最大优势在于清晰性——digitalRead()和digitalWrite()函数已被归入utilities.c文件。另请注意,Arduino特有功能已移至LocalPinUtil.c。若决定将代码移植到其他环境,这可能成为优势。

  if (runState && localProxSensor.value) {  //Turn selector switch off and press the red button to clear fault state.
    localFan.value = true;
  } else {
    localFan.value = false;
  }

结语

该代码清单采用C语言编程风格,而非C++面向对象风格。指针注入过程相对简单。速度也很快,因为直接访问内存位置,而非使用缓冲区堆栈传递数值。

接下来何去何从?

很简单,全文都聚焦于本地I/O。我们需要讨论Modbus工具文件等本地I/O。若精心构建程序,应能无缝集成远程与本地I/O。

请分享您对这个主题的看法。我们很期待听到您使用这种指针注入技术的编程经验。或者,您有更高效的方法吗?

欢迎对C语言优于C++或反之的原因发表评论——需附带说明。

相关资料

请访问以下链接获取关联实用信息:

学习一下PLC的C语言编程

文章只做了数字量 I/O,如果扩展模拟量(ADC/DAC),这套结构体 + 指针框架能直接兼容吗,还是要重新设计一套结构?

PLC编程的C语言化,是为了对接基于C语言的系统增加扩展性吗? 个人觉得原先的PLC编程语言就比C语言容易学些,通过树的方式,很直观,易上手。

“结构化编程”不仅仅是写代码,而是在构建一个微型的运行时环境,极大地简化了主程序逻辑,提高了代码的可读性和可维护性,非常适合中大型的 Arduino/PLC 项目。下一步如果能将 Modbus 通信也集成进这个结构体框架,那么这套代码将具备工业级应用的潜力。

用结构体封装 I/O + 指针注入 + 统一扫描刷新,完美模拟 PLC 工作机制,代码干净、易维护、可移植性强,非常适合机器控制和安全场景。