公司动态

TIA博途SCL实现PLC循环队列FIFO算法FB库文件详解

📅 2026/8/29 5:53:07
TIA博途SCL实现PLC循环队列FIFO算法FB库文件详解
简介在工业自动化控制中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计数最直观循环队列判空判满有两大流派。一种是不记录元素个数只靠head和tail的关系判断但这样空和满状态会撞车因为两者都满足head tail。为了解决这个问题有人会牺牲一个存储单元让队列实际可用长度比数组小1。另一种更直观就是用变量count记录当前有效元素个数。入队count1出队count-1count0就是空count长度就是满。我推荐这种方案因为PLC程序调试的时候你直接在监控表里看oCount输出比看两个指针自己心算快得多。而且还能直接把count映射成“队列占用率”上位机要显示实时缓存状态也方便。下面这张表模拟了一次完整的写入和读取过程数组长度取8便于演示操作headtailcount数组内容初始000空写入A011buf[0]A写入B022buf[0]A, buf[1]B读取121读出Ahead指向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]Ctail回绕到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]Htail从7→8判定8后回绕到0读取0/01/07读出buf[0]A写入I1/01/18buf[8]不合法实际写入buf[0]Itail从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/18tail0写入buf[0]Itail自增为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_VAR3.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_BLOCK3.3 这段代码为什么这样写先说边沿检测。SCL功能块不像梯形图有现成的R_TRIG但我们可以用两个布尔变量自己实现上升沿。口诀是“输入信号先判断、再缓存”在读取iWrite之前先把上一次的值存在wEdge里iWrite AND NOT wEdge成立就说明刚来一个上升沿。判断完以后再执行#wEdge : #iWrite这就完成了记忆刷新。再说写入逻辑。为什么写入时用count 128判断而不是tail和head的关系因为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那当tail127的时候就会提前归零导致128这个位置永远写不到数组最后一个元素就浪费了。我吃过这个亏。后来统一改成并且把数组上限定在一个地方。SCL里数组长度写在声明处但代码里的判断条件还是散落各处我就在块顶部用注释标注“长度改动需要同步修改4处”提醒自己和后来人。5.2 在中断OB里调用时的数据一致性S7-1500支持多优先级OB。如果你在OB1里调用这个队列做常规处理又想着“读取响应要快一点”把读取逻辑也放进了OB82这样的中断块里那就要小心了。OB1执行到一半被中断此时队列里的head、tail、count正处于一个中间状态中断里的代码一读可能拿到错乱的数据。这不是循环队列算法本身的错而是“异步访问”带来的竞态问题。硬件工程师听到异步FIFO肯定不陌生——多时钟域下读写指针需要同步和格雷码防抖PLC里虽然没有真正的多时钟域但多个OB的抢占执行本质上就是一种异步访问。我的处理办法是队列的所有操作只放在一个周期OB里中断块只负责置标志位由主OB去轮询这个标志再执行入队或出队。这样队列的读写就永远在一个“时钟域”内完成不会出现半个状态被中断偷看的情况。如果你的CPU支持也可以考虑用DIS_IRT暂时屏蔽中断但不建议在实时性要求高的OB里这么干副作用太大。5.3 队列满和队列空时返回错误码可能不够我在第一版里把队列满和空都只输出一个错误码。调试的时候发现调用侧往往根本不去看oErrCode该写还是写该读还是读。这就导致一个很隐蔽的现象队列满了以后新的数据被静默丢弃但业务侧完全不知情以为数据已经排队了。后来我在调用侧加了强制处理。比如上位机下发指令时如果检测到oFullTRUE就直接在HMI上弹报警提示“指令队列已满请降低下发频率”如果检测到oEmptyTRUE且下位机还在请求数据就返回一个特定数值代表“暂无数据”。另外我还做了一个策略开关在有些场合队列满时宁可丢弃最旧的数据也要保证新数据能进来因为上位机要的是实时状态而不是历史备份。这个开关我用一个内部参数控制默认是“满则拒写”用户需要覆盖模式时自己改。6. 我后来在这个FB上做的几处优化前面说的都是基础版本真正在项目里用得顺手我还做了一些小优化在这里一并分享。一是增加了oVersion输出把FB版本号暴露出去。现场维护的时候打开监控表就能看到当前运行的是哪个版本免得图纸上标的和实际固化的不是一版。二是把缓冲区深度从固定数值改成了可参数化输入。用VAR_IN_OUT传入一个数组或者在FB里定义一个UDT结构体让用户实例化时自己指定数组大小。这种方式更灵活但对调用者要求更高容易误配数组上界。我的建议是库文件里保留固定深度版作为默认再提供一个参数化版本给高级用户。三是针对“偶尔要读中间数据”的场景增加了一个peek功能。只需要一个oPeekData输出和一个iPeekReq输入读出buffer[head]但不移动指针和count。这在配方管理、工单排序这些“先看一眼再决定取不取”的场合很实用。最后是监控画面。我把oCount接到了HMI的柱状图实时显示队列占用率。设备调试的时候这个画面比任何诊断工具都直观——你能直接看到数据是不是在中间环节积压了积压到什么程度。有一次现场说“通信老断”我一看柱状图长期顶满马上判断是数据消费端处理不过来根本不是通信故障。这套循环队列FIFO算法FB库文件说到底是把一个非常经典的数据结构搬到了PLC里。它的代码量不大难度也不高但解决的是自动化现场一个非常常见的痛点数据来了你得先接住再慢慢处理。希望这篇拆解能帮你少走点弯路。本文还有配套的精品资源点击获取