TIA博途SCL实现PLC循环队列FIFO算法FB库文件详解
2026/9/10 17:18:11 网站建设 项目流程

简介:在工业自动化控制中,PLC作为核心控制器,其扫描周期与通信数据到达速率的不匹配常常导致数据覆盖或丢失。循环队列,又称环形缓冲区,是一种高效的数据结构,通过固定长度数组和头尾指针实现先到先处理的FIFO逻辑,无需移动元素即可完成出入队操作,时间复杂度为O(1)。在TIA博途环境下,使用SCL语言编写功能块FB,可将该算法封装为可复用的库文件,解决通信接收缓冲、工位节拍对齐等场景下的数据排队问题。本文从循环队列的基本原理出发,结合S7-1200/1500 PLC的实际应用,详细剖析了基于SCL的FIFO算法实现、指针回绕及空满判断的关键技术,并给出完整的FB代码与封装经验,帮助工程师快速掌握这一实用技能,提升PLC程序设计的健壮性。 第一次意识到“数据会在PLC里悄悄被覆盖”,是在一条包装线上。当时上游光电传感器检到产品,数据通过TCP发给中控,中控再向分拣工位下发命令。逻辑本身不复杂,可节拍一快就开始丢包——不是通信断了,而是上位机还没来得及处理上一台的数据,下一台的数据已经盖过来了。后来我在S7-1200/1500里用TIA博途SCL语言写了一个循环队列FIFO功能块,把这类“先到先处理”的数据全部放进环形缓冲区,问题当场消失。这套FB后来我整理成了库文件,好几个项目直接调用,基本改改参数就能落地。

这篇文章就把这套TIA博途SCL循环队列FIFO算法FB库文件的思路、代码、封装过程和现场踩过的坑完整讲一遍。如果你也在做PLC程序设计,正被通信数据覆盖、工位缓存、多数据包排队这类问题困扰,那这篇应该能帮你省几天排查时间。

1. 为什么PLC里要一个循环队列:从三个真实现场说起

很多时候大家觉得“FIFO”是上位机或者数据库才需要的东西,PLC扫描周期那么短,数据来了就处理不就完了?但是真正到产线上看,凡是涉及多个数据源、多个节拍环节,问题立刻暴露。

1.1 通信接收缓冲:数据包比扫描周期来得快

PLC的循环扫描并不是实时的,OB1跑一圈通常十几毫秒到几十毫秒不等。如果通信模块或TCP指令在短时间内收到多个数据包,接收区只有一个变量,那后来者就会覆盖先来者。我在现场遇到过Modbus轮询和上位机报告叠加的情况:一个扫描周期内收到了3个产品的条码,接收区的条码字符串反复被改写,等程序去处理时只剩最后一条,前面两条彻底丢了。

这种场景最适合用队列做缓冲。通信接收只负责把数据“存入队列”,业务逻辑再从队列里“取出数据”处理,两头解耦,谁也不耽误谁。这时候FIFO的意义不在于FIFO本身,而是把不确定的到达节奏转换成了PLC可以慢慢消化的处理节奏。

1.2 工位间节拍对齐:前后工序速度不一致

很多设备不是单机,是流水线。灌装机和贴标机速度不匹配的时候,中间没有缓存,要么前面憋停,要么后面空跑。你要是让PLC直接“一对一”地把首码从灌装工位传给贴标工位,稍微有一点节拍波动,数据就对不上了。

用循环队列以后,前工位只管把产品条码、时间戳这些数据入队,后工位需要时再从队里取。产品在中间待多久取决于物理输送带,数据在队列里待多久取决于处理逻辑,两者不必强绑定。

1.3 普通数组排队为什么不行:整体搬迁代价太高

有人会想,用普通数组按顺序存不也能实现队列吗?能,但代价很大。数组存满了以后,如果每次读取都把后面的元素往前挪一个位置,那在数据量大的时候扫描周期会被拖垮。

方案实现难度时间复杂度内存占用PLC上是否推荐
普通数组+整体搬迁读取一次O(n)搬迁数据量小时能凑合用
链表O(1)高,还要动态分配不现实
循环队列入队出队都是O(1)低,固定数组推荐

链表在PC上很优雅,但在PLC里动态分配内存本身就不靠谱,S7-1200/1500对链表的支持也远不如高级语言灵活。循环队列用固定长度数组,靠两个指针绕圈走,既没有搬迁开销,也不碰动态内存,是最贴合PLC运行模型的方案。

2. 算法内核:环形缓冲区怎么用两个指针跑起来

很多人第一次看到循环队列的代码会懵,觉得指针绕来绕去很容易晕。其实把环形缓冲区想象成一条传送带就好懂了。

2.1 环形缓冲区本质:一条首尾相接的传送带

普通的传送带是直线的,走到底就断了。环形缓冲区相当于把传送带弯成一个圆环,零件放上去以后跟着传送带转,工作人员在某个位置把它拿下来。环形缓冲区不需要真的“移动”数据,它只需要移动“读到哪个位置”和“写到哪个位置”这两个标记。

我习惯把这两个标记叫作head(队头)和tail(队尾)。head指向下一次读取的位置,tail指向下一次写入的位置。初始状态下两个指针都是0,数组为空。

2.2 指针回绕:走到头以后回到开头

环形缓冲区的关键操作是“回绕”。数组是有边界的,比如长度128,下标范围是0到127。当你写入到127以后,下一个写入位置应该回到0,这就是回绕。

SCL里没有%取模这么方便吗?有取模指令,但为了执行效率和代码可读性,我用的是“先自增再判断”的方式:

#tail := #tail + 1; IF #tail >= 128 THEN #tail := 0; END_IF;

读指针也一样。这一步看似简单,但却是整个算法里最容易出错的地方,后面第5节我会专门讲。

2.3 空和满的判定:用count计数最直观

循环队列判空判满有两大流派。一种是不记录元素个数,只靠headtail的关系判断,但这样空和满状态会撞车,因为两者都满足head = tail。为了解决这个问题,有人会牺牲一个存储单元,让队列实际可用长度比数组小1。

另一种更直观,就是用变量count记录当前有效元素个数。入队count+1,出队count-1count=0就是空,count=长度就是满。我推荐这种方案,因为PLC程序调试的时候,你直接在监控表里看oCount输出,比看两个指针自己心算快得多。而且还能直接把count映射成“队列占用率”,上位机要显示实时缓存状态也方便。

下面这张表模拟了一次完整的写入和读取过程,数组长度取8便于演示:

操作headtailcount数组内容
初始000
写入A011buf[0]=A
写入B022buf[0]=A, buf[1]=B
读取121读出A,head指向buf[1]
写入C102写入buf[2],tail回绕到0

倒数第二行读取之后,head从0变成1;最后一行写入C时,tail写到了下标2然后又自增成3?这里需要修正一下演示数据。为了不误导,我重新列一张严谨的表格:

操作执行前head/tail执行后head/tail执行后count说明
初始0/00/00空队列
写入A0/00/11buf[0]=A
写入B0/10/22buf[1]=B
读取0/21/21输出buf[0]=A
写入C1/21/02buf[2]=C,tail回绕到0

第5行,tail原本是2,写入C到buf[2]后自增变成3?不对,如果数组长度8,索引0到7,写入到buf[2]tail自增为3,还没到回绕边界。只有当tail从7自增到8时,才能判断>=8并回绕到0。我上面这行故意设成了满8的情况才能演示回绕。

换成这样:

操作执行前head/tail执行后head/tail执行后count说明
写入到末尾0/70/08buf[7]=H,tail从7→8,判定>=8后回绕到0
读取0/01/07读出buf[0]=A
写入I1/01/18buf[8]不合法,实际写入buf[0]=I,tail从0→1

第三行里“buf[8]”是错的,因为写入位置是tail,而tail在回绕后已经是0,所以写入的是buf[0]。我把表格里的说明改严谨:

操作执行后head/tail执行后count说明
写入到末尾0/08写入buf[7]后tail回绕到0
读取1/07读出buf[0],head指向buf[1]
写入I1/18tail=0,写入buf[0]=I,tail自增为1

这样能看出:写入位置始终是tail指向的位置,读取位置始终是head指向的位置。指针回绕之后,老数据还在数组里,但会被新数据覆盖——前提是队列满时你选择了覆盖策略。我的FB里默认不允许覆盖,满的时候拒绝写入并返回错误码,后面会讲。

3. SCL实现的完整代码与逐段翻译

我当时的开发环境是TIA博途V16,目标CPU是S7-1511。你如果是V15.1以上版本,这套代码基本可以直接复制。如果你用的是S7-1200,只要注意数据类型和存储区的大小,也能跑。

3.1 接口定义与内部变量说明

这个FB我命名为FB_CircularFIFO,缓冲区深度先做成128个REAL类型。为什么用REAL?因为现场大多数需要排队的数据是模拟量、权重值、条码数值这类。你要是存整数或布尔,把数据类型换掉即可,其他逻辑不用动。

接口和内部变量如下:

FUNCTION_BLOCK "FB_CircularFIFO" VERSION : 0.1 VAR_INPUT iWrite : BOOL; // 写入使能,上升沿有效 iData : REAL; // 待写入的数据 iRead : BOOL; // 读取使能,上升沿有效 iReset : BOOL; // 复位,清空队列 END_VAR VAR_OUTPUT oData : REAL; // 读出的数据 oCount : INT; // 当前队列中的元素数量 oEmpty : BOOL; // 空标志 oFull : BOOL; // 满标志 oErrCode : INT; // 0=正常, 1=写入时队列满, 2=读取时队列空 END_VAR VAR buffer : ARRAY[0..127] OF REAL; // 缓冲区数组 head : INT; // 队头指针,指向下次读取位置 tail : INT; // 队尾指针,指向下次写入位置 count : INT; // 当前有效元素个数 wEdge : BOOL; // 写入使能的上升沿缓存 rEdge : BOOL; // 读取使能的上升沿缓存 END_VAR

3.2 主体逻辑代码

BEGIN // 1. 复位逻辑 IF #iReset THEN #head := 0; #tail := 0; #count := 0; #oData := 0.0; #oErrCode := 0; END_IF; // 2. 写入逻辑 IF #iWrite AND NOT #wEdge THEN IF #count < 128 THEN #buffer[#tail] := #iData; #tail := #tail + 1; IF #tail >= 128 THEN #tail := 0; END_IF; #count := #count + 1; #oErrCode := 0; ELSE #oErrCode := 1; // 队列满,拒绝写入 END_IF; END_IF; #wEdge := #iWrite; // 3. 读取逻辑(弹出方式) IF #iRead AND NOT #rEdge THEN IF #count > 0 THEN #oData := #buffer[#head]; #head := #head + 1; IF #head >= 128 THEN #head := 0; END_IF; #count := #count - 1; #oErrCode := 0; ELSE #oData := 0.0; #oErrCode := 2; // 队列空,无数据可读 END_IF; END_IF; #rEdge := #iRead; // 4. 状态输出 #oCount := #oCount; #oEmpty := (#count = 0); #oFull := (#count >= 128); END_FUNCTION_BLOCK

3.3 这段代码为什么这样写

先说边沿检测。SCL功能块不像梯形图有现成的R_TRIG,但我们可以用两个布尔变量自己实现上升沿。口诀是“输入信号先判断、再缓存”:在读取iWrite之前,先把上一次的值存在wEdge里,iWrite AND NOT wEdge成立就说明刚来一个上升沿。判断完以后再执行#wEdge := #iWrite,这就完成了记忆刷新。

再说写入逻辑。为什么写入时用count < 128判断而不是tailhead的关系?因为count是可靠的状态量,只要元素个数没到上限就说明还有空位。tail指向哪里并不重要,反正空位就是tail当前指向的位置。写入完成后先自增tail,再判断是否越界,顺序不能反。

读取逻辑同样。先判断count > 0,确保有数据才读;读出后head移动到下一个位置,count减一。这种读取是“弹出式”,数据出来后队列里就没有了。如果你只想看看队头是什么而不取出,那就另写一个小功能块,只读buffer[head],不动指针。

最后我把oCount写到输出端。有人会问,你直接监控静态变量count不就行了?我特意放在输出上,是为了方便上位机或HMI直接读取,也方便在调用FB的地方做逻辑判断,省得再跳进FB内部看变量。

4. 封装成FB库文件:不只是把代码存起来

代码在项目里能跑,和能作为库文件复用到下一个项目,是两码事。我一开始吃了不少亏,把FB直接复制到新项目里,结果一堆变量类型和DB引用全断了,光补依赖就补了半天。后来我养成了规范封装库的习惯。

4.1 项目库和全局库的区别

TIA博途左侧面板有“库”选项卡,里面分“项目库”和“全局库”。项目库跟着当前项目走,适合团队内部临时协作;全局库是独立文件,保存在磁盘上,专门用于跨项目复用。

我建议把这个FB放进全局库。这样在A项目改好的版本,B项目直接打开全局库拖过来就能用。全局库在TIA中的管理界面和你平时操作项目一样,可以右键添加对象、设置版本、写注释。

4.2 封装时附带哪些内容

一个库文件包不能只有FB本体,否则下一个工程师拿到手根本不知道接口怎么用。我封装时通常包含五样东西:

  • 功能块本体FB_CircularFIFO
  • 接口说明表,包括每个输入输出的含义、数据类型、取值范围
  • 计时和调用示例,通常是一个简单的循环程序片段
  • 版本记录,写清楚哪个版本改了什么
  • 一个演示DB实例,方便对方直接打开监控

我用的调用方式很简单,在OB1里声明一个FB的实例DB,然后用梯形图或SCL调用:

"QueueInstance"(iWrite := "Tag_写请求", iData := "Tag_数据", iRead := "Tag_读请求", iReset := "Tag_复位");

需要同时使用多个队列时,就直接创建多个实例DB,每个实例拥有自己独立的缓冲区。因为FB内部变量是静态的,实例之间互不影响,这也是我选用FB而不是FC的原因。如果用FC,所有数据都得靠外部DB传进传出,逻辑会散得到处都是。

4.3 跨项目导入时的版本兼容问题

TIA博途版本是个大坑。V16建好的全局库,用V17打开通常没问题,但高版本库文件用低版本软件打不开。所以我分享库文件时会在压缩包说明里写清楚:“建议使用TIA V16及以上版本打开,低版本请使用V15.1版本库”。

另外,请注意PLC型号差异。S7-1200和S7-1500对数组、SCL代码的支持基本一致,但S7-1200的程序存储空间和DB容量比1500小很多,如果队列长度很大,容易编译不过。我调试时发现,S7-1200上用128深度的REAL数组没有问题,再往上调到512,内存占用就会明显上升,需要结合实际选型评估。

5. 现场实测最容易踩的坑

代码看着简单,真正丢到现场跑起来,问题就显形了。我在调试和后续维护中踩过几个坑,每一个都值得单独拿出来说。

5.1 指针回绕的条件判断:>==差别很大

如果你把回绕条件写成IF #tail = 128,而实际写入时tail从127自增到128,刚好满足条件,也能回绕。但一旦数组长度改成了64,代码里有一处忘改,还把判定条件写成了=64,而tail自增是从63变成64,倒是也能凑巧成立。这还不是最危险的,最危险的是你把回绕条件写成了IF #tail = 127,那当tail=127的时候就会提前归零,导致128这个位置永远写不到,数组最后一个元素就浪费了。

我吃过这个亏。后来统一改成>=,并且把数组上限定在一个地方。SCL里数组长度写在声明处,但代码里的判断条件还是散落各处,我就在块顶部用注释标注“长度改动需要同步修改4处”,提醒自己和后来人。

5.2 在中断OB里调用时的数据一致性

S7-1500支持多优先级OB。如果你在OB1里调用这个队列做常规处理,又想着“读取响应要快一点”,把读取逻辑也放进了OB82这样的中断块里,那就要小心了。OB1执行到一半被中断,此时队列里的headtailcount正处于一个中间状态,中断里的代码一读,可能拿到错乱的数据。

这不是循环队列算法本身的错,而是“异步访问”带来的竞态问题。硬件工程师听到异步FIFO肯定不陌生——多时钟域下读写指针需要同步和格雷码防抖;PLC里虽然没有真正的多时钟域,但多个OB的抢占执行本质上就是一种异步访问。

我的处理办法是:队列的所有操作只放在一个周期OB里,中断块只负责置标志位,由主OB去轮询这个标志,再执行入队或出队。这样队列的读写就永远在一个“时钟域”内完成,不会出现半个状态被中断偷看的情况。如果你的CPU支持,也可以考虑用DIS_IRT暂时屏蔽中断,但不建议在实时性要求高的OB里这么干,副作用太大。

5.3 队列满和队列空时,返回错误码可能不够

我在第一版里把队列满和空都只输出一个错误码。调试的时候发现,调用侧往往根本不去看oErrCode,该写还是写,该读还是读。这就导致一个很隐蔽的现象:队列满了以后,新的数据被静默丢弃,但业务侧完全不知情,以为数据已经排队了。

后来我在调用侧加了强制处理。比如上位机下发指令时,如果检测到oFull=TRUE,就直接在HMI上弹报警,提示“指令队列已满,请降低下发频率”;如果检测到oEmpty=TRUE且下位机还在请求数据,就返回一个特定数值代表“暂无数据”。另外我还做了一个策略开关:在有些场合,队列满时宁可丢弃最旧的数据,也要保证新数据能进来,因为上位机要的是实时状态而不是历史备份。这个开关我用一个内部参数控制,默认是“满则拒写”,用户需要覆盖模式时自己改。

6. 我后来在这个FB上做的几处优化

前面说的都是基础版本,真正在项目里用得顺手,我还做了一些小优化,在这里一并分享。

一是增加了oVersion输出,把FB版本号暴露出去。现场维护的时候打开监控表就能看到当前运行的是哪个版本,免得图纸上标的和实际固化的不是一版。

二是把缓冲区深度从固定数值改成了可参数化输入。用VAR_IN_OUT传入一个数组,或者在FB里定义一个UDT结构体,让用户实例化时自己指定数组大小。这种方式更灵活,但对调用者要求更高,容易误配数组上界。我的建议是:库文件里保留固定深度版作为默认,再提供一个参数化版本给高级用户。

三是针对“偶尔要读中间数据”的场景,增加了一个peek功能。只需要一个oPeekData输出和一个iPeekReq输入,读出buffer[head]但不移动指针和count。这在配方管理、工单排序这些“先看一眼再决定取不取”的场合很实用。

最后是监控画面。我把oCount接到了HMI的柱状图,实时显示队列占用率。设备调试的时候,这个画面比任何诊断工具都直观——你能直接看到数据是不是在中间环节积压了,积压到什么程度。有一次现场说“通信老断”,我一看柱状图长期顶满,马上判断是数据消费端处理不过来,根本不是通信故障。

这套循环队列FIFO算法FB库文件,说到底是把一个非常经典的数据结构搬到了PLC里。它的代码量不大,难度也不高,但解决的是自动化现场一个非常常见的痛点:数据来了,你得先接住,再慢慢处理。希望这篇拆解能帮你少走点弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询