本站为个人非经营性网站,仅用于技术学习与分享,不提供任何商品、服务、报价或收费咨询。 合规信息

多板卡控制系统:以太网做板间,RS485 做现场

这个个人学习项目里有三块角色:负责人机输入的控制面板、负责整机控制的控制内核, 以及负责显示与操作的显控 PC。板与板之间走以太网,往下到执行机构走 RS485。 我在里面完整做了一遍“帧怎么定、socket 怎么用、断了怎么办、设备怎么轮询”, 也留下了十三条能说清楚现象和根因的踩坑记录。没做完的部分我写在最后一节,不做修饰。

Cortex-M4 FreeRTOS LwIP 以太网 / UDP Modbus 已发布
已发布 · 个人学习项目

多板卡控制系统的以太网通信(Cortex-M4 + FreeRTOS + LwIP)

这个实验做的是多板卡控制系统里的通信部分:控制面板把本地的 20 个按键与 11 路 ADC 采集上来, 通过以太网送到控制内核;控制内核再以 RS485 + Modbus 驱动云台、断路器和光源三类现场设备, 同时把整机状态通过 TCP 长连接上报给显控 PC。板间帧用了自己定的 0xAA10 帧头加长度与 CRC32;板间输入走 UDP 承载的 Modbus TCP, 状态上报与下行命令走 TCP 长连接;按需 diff 上报配 2 秒兜底心跳; 连续 3 次失败判掉线、断开后 6 秒重连。主要功能已经跑通,没做完的三件事写在最后一节。

PLATFORMCortex-M4F / 1MB Flash / 192KB RAM
STACKFreeRTOS V9.0.0 + LwIP 2.1.2 + 自定义 CRC32 帧
TOOLSKeil MDK + 网络调试工具
STATUS个人学习项目 · 主要功能已完成

一、这套系统由哪几块板组成,谁跟谁说话

先把角色理清楚,后面所有通信细节才有落脚点。这个系统里一共有三个“说话的人”, 外加一组只负责执行的现场设备:

角色做什么本地资源跟谁说话
控制面板 人机输入前端:扫描按键、采集旋钮与操纵杆 20 个按键(分布在四组端口上)、11 路 ADC(9 个亮度旋钮 + 操纵杆 X / Y) 控制内核(板间输入)、显控 PC(本机状态上报)
控制内核(集控箱主板) 整机控制:上电与关机时序、急停、三类执行机构驱动 4 路 RS485 控制面板(板间输入)、显控 PC(状态上报 / 下行命令)、现场设备(RS485)
显控 PC(工控机) 显示与操作界面 — 控制内核与控制面板(TCP 长连接)
现场设备 云台(两个旋转关节 + 一个锁止推杆)、6 路断路器、18 路光源 各自的驱动板与从站地址 只与控制内核说话(RS485 / Modbus RTU)

这里有一个我一开始没意识到、后来觉得挺关键的事实: 控制面板与控制内核的硬件接口是完全一致的——同一颗主控、同一套引脚定义、同一个 PHY。 所以最初的开发顺序是“先在面板上把全部功能跑通,再拆成两个独立程序”。 这解释了很多后来看起来“奇怪”的现象:两个工程的目录结构几乎同构, 网络框架、协议帧、任务划分都长得很像,因为它们本来就是从一个工程里分出来的。

主控是 Cortex-M4F(带浮点单元),1 MB Flash、112 KB + 80 KB 共 192 KB RAM,外部晶振 12 MHz。 网络侧用的是一颗 RMII 接口的以太网 PHY(地址配置为 1),工程里还保留了另一款 PHY 的地址定义 与整套 MII 引脚注释,说明硬件上有过两种接口方案的考虑。

往下看,控制内核的 4 路 RS485 各有分工:

  • 一路接 6 路断路器(收发各用一个 DMA 流);
  • 一路接光源;
  • 一路接云台,同时兼作参数调试口,波特率有 9600 / 19200 / 115200 三档可选;
  • 一路预留。这一路没用的原因写在注释里:它的接收 DMA 与上面某一路的发送 DMA 存在冲突, 所以干脆不用。工程里还留着逐字节中断接收的备选实现。

二、通信分层:以太网做什么,RS485 做什么

我在这套系统里做的分层决策其实只有一句话: 板与板之间、设备与上位机之间用以太网;从控制内核往下到执行机构用 RS485。 但把这个决策拆开看,依据是几条很具体的差异:

比较项以太网(板间 / 上位机)RS485(现场设备)
典型数据量面板一次上报几十字节、内核一次上报几十字节,上报节拍快几个到几十个寄存器,轮询节拍明显慢于板间链路
节点数量点对点为主(面板↔内核、板卡↔PC)一条总线上挂多台:6 路断路器、18 路光源、3 块云台驱动板
距离与布线机柜内网线,距离短到执行机构有实际走线长度,双绞差分更合适
单节点成本每块板一个 PHY 与网络变压器每台设备一个收发器即可
协议形态自己定的帧 + Modbus TCP 报文格式标准 Modbus RTU,单主站轮询

现场总线上“单主站轮询”这个特点很重要:RS485 是半双工,任何时刻只能有一个设备说话, 所以从站没有主动开口的机会,只能等主站来问。这决定了现场侧的协议只能是“主站问、从站答”, 也决定了控制内核在这条总线上必须扮演 Modbus 主机。

而 Modbus 的从站地址在这里承担的是“设备身份”:

  • 云台:两个旋转关节加一个锁止推杆,是三块相似的驱动板, 靠从站地址区分,分别是 0x01、0x02、0x03;
  • 光源:18 路,地址从 0x01 到 0x12;
  • 断路器:6 路,地址从 0x10 到 0x15。

这里有一个设计细节我觉得值得单独说:断路器那部分的代码里, 设备数组的下标与 Modbus 地址是故意解耦的,中间过了一张地址映射表。 原因是“业务上的设备编号”(哪个位置装的是什么设备)和“硬件拨码决定的从站地址”是两件独立的事, 如果把它们写成同一个东西,现场换一台设备就要改一大片代码。多写一张表, 换来的是“换设备只改表里一行”。

关于串口侧更细的帧同步问题(DMA 接收 + 空闲中断判帧、3.5 字符时间的超时判定), 我在《串口通信的帧同步:四种“怎么知道一帧收完了”的做法》 里单独整理过,这里不重复。

控制面板与集控箱之间走以太网,集控箱向下经 RS485 连接云台、光源、断路器三类执行机构,右侧另挂显控上位机
图 1 · 多板卡拓扑图

三、板间帧格式:0xAA10 + 长度 + 数据体 + CRC32

上位机与板卡之间的帧是我自己定的,结构很简单,四个字段:

字段长度说明
header2 B固定 0xAA10(小端)
data_length2 B数据体长度。内核上行 0x0044 = 68 字节(整包 76 字节);面板上行 0x0020 = 32 字节(整包 40 字节)
data_body68 B / 32 B见下面的字段拆解
crc324 BCRC32,覆盖整包前面“整包长度 − 4”个字节

下行命令帧更短,固定 12 字节:head(2) + len(2) + ipc_state(4) + crc32(4)。 其中 ipc_state 是工控机的状态,用来告诉内核“上位机软件是不是已经完成关机了”—— 这条信息直接决定了内核什么时候可以切断工控机电源。

整个结构体在源码里是按 1 字节对齐排布的,保证按字节紧排、没有填充, 这样“结构体长度”就等于“线上字节数”,收发两端才能按同一个长度常量来校验。 这一点看着像细节,其实是这个帧格式能成立的前提:只要编译器在中间插入几个填充字节, sizeof 就会大于实际帧长,长度校验会直接失效, 而且这种问题在不同编译选项下表现还不一样。

字段长度含义与约束
帧头2 字节固定值(小端)。用来判断“这一包从哪开始”,对不上就说明字节流错位了
数据体长度2 字节数据体的字节数。收发两端按同一个约定值校验;长度不对,这一包的边界就不可信
数据体定长数组按最大长度预留空间,实际只按长度字段取用;字段拆解见下一张表
校验4 字节覆盖“整包长度 − 校验字段长度”的那些字节,用来判断内容被改动或传输出错

收满一帧之后的校验只有三道,但顺序不能反——先判帧头,再判长度,最后才值得去算 CRC32:

/* 收满一帧后的三道校验:顺序不能反
   —— 伪代码骨架,具体常量按各项目的帧约定定义(非工程原码) */
if (pkt->header != PROTO_HEADER)            return ERR_HEADER; /* 帧头不对:多半是错位 */
if (pkt->data_length != expect_len)         return ERR_LENGTH; /* 长度不对:边界不可信 */
if (crc32_check(pkt, pkt_len - CRC_LEN) != 0) return ERR_CRC;  /* 校验不过:内容不可信 */
return OK;

三道校验里任何一道不过,处理方式都是同一个:当作本次传输失败,重试计数加一。 我不区分“帧头错了要不要重试”和“长度错了要不要重试”—— 它们都属于“这一包不可信”,而不是“请求本身不合法”,所以都值得重试。

同一套框架,靠长度区分两种板卡

这个帧格式最让我满意的一点是:面板和控制内核用的是同一套帧头与校验,只有数据体长度不同。 上位机那边只需要写一套解析代码,读到长度字段就知道这一包是哪种板卡发来的, 不需要为两种板卡维护两条解析路径。

两个数据体的字段拆解如下(合计分别为 68 与 32 字节):

上报方数据体字段合计
控制内核 控制故障字(1)+ 6 路断路器 × {在线状态(1)、开关状态(1)、故障值(2)}(24) + 18 路光源在线状态(18)+ 9 组光源亮度(9) + 云台状态(1)+ 水平角(4,浮点)+ 垂直角(4,浮点)+ 云台故障字(1) + 保护机构在线(1)+ 保护机构状态(1)+ 工控机电源状态(1)+ 云台工作状态(1) + 填充(2) 68 B
控制面板 20 个按键值(20 × 1)+ 11 路 ADC 值(11 × 1)+ 故障位域(1) 32 B

顺带记一个我后来读自己代码时觉得别扭的地方:下行命令帧只有 12 字节, 但它的长度字段校验值是 0x0044,也就是上行数据体的长度常量被复用了。 功能上没有问题(收发两端用的是同一个约定),但字段名写的是“长度”、 校验值描述的却是“另一种帧的数据体”,读起来容易误解。 协议常量要么按字段语义命名,要么在注释里写清它到底在描述谁的长度—— 这是我从这件事里拿到的教训。

四、两种 socket 的分工:上行 UDP 送输入,TCP 长连接送上报与收命令

同样是 LwIP 的 socket,这套系统里被用成了两种形态。它们的差别不是“哪个更好”, 而是它们承载的数据性质不同:

链路socket 类型角色与约定为什么这样选
面板 → 内核(板间输入) SOCK_DGRAM(UDP) 报文格式用 Modbus TCP,内核作从站(站号 0x0b);面板作主机,事务号固定,单次超时按“板内网往返足够快”来定,取得比现场总线紧 输入值“过期得快”:下一个采集周期就有新值,丢一包等下一包即可自愈;无连接也让拔插网线后的恢复更简单
内核 / 面板 → 显控 PC(状态上报) SOCK_STREAM(TCP) 两块板都作 client,向显控 PC 建立长连接;内核整包 76 字节、面板整包 40 字节 整机状态要完整、要有序,还要能承载以后的大块数据(例如升级文件)
显控 PC → 板卡(下行命令) 搭在同一条 TCP 长连接上 固定 12 字节命令帧,由接收分支处理 命令必须“确定收到且校验通过”,天然适合可靠传输

UDP 这条链路上有个值得说的复用:内核工程和面板工程用的是同一份 Modbus 解析代码, 内核侧是从站、面板侧是主机。这使得两边对“寄存器区”的理解天然一致—— 内核侧的保持寄存器前 20 个是按键值、接着 11 个是 ADC 值, 正好对应面板上的 20 个按键和 11 路 ADC。这种对应关系如果靠文档维护,早晚会错位。

TCP 这条链路上,两块板都做 client(主动连显控 PC),读取方式用的是“定长 + 收满为止”: 下行命令帧固定 12 字节,就一直读到收满 12 字节再交给解析。 TCP 是字节流,“一次能读到多少”由协议栈和网络状况决定,不按固定长度收就会解析到半包数据。

还有一个我一开始想拧过来的地方:同一个设备在不同链路上角色不同,是正常的。 内核对面板来说是“被问的人”(Modbus 从站),对显控 PC 来说却是“主动上报的人”(TCP client)。 我最初想统一成一个角色,后来发现那只会让代码变复杂——链路两端的关系本来就是不一样的。

关于这两种 socket 在工程化上的差别(select 超时的写法、心跳、按需上报、重连与 TIME_WAIT 的处理), 我在《以太网 UDP 通信的工程化:心跳、掉线判定与重连》 里做了单独整理,这里只记录这套系统里实际用到的取值。

每一层标注用什么协议、谁主动谁被动、数据方向
图 2 · 通信分层与协议对应图

五、下行命令与重试:3 次、3 秒、6 秒

这套系统里所有“等对端回应”的地方,用的其实是同一套策略:等待有上限、重试有上限、 到上限就重建链路,而不是无限重发。具体的时间与次数取值属于项目参数,这一节只说它们怎么定、 以及为什么这么定。下面把各个环节和它们的取舍放在一起看——分开看,很难发现它们其实是一套策略 (本节小标题沿用了当时的叫法,正文不再重复具体数值):

环节怎么定为什么这么定
发送后等待回应给每次发送设一个等待上限,用 select 之类的机制实现;返回 0 即视为“这一次没等到”通信必须有确定的返回点,否则主循环会被一次异常一直挂住
判定掉线连续失败到设定次数就判掉线;超时、包长不对、CRC32 不过,三者任一都计一次失败单次失败可能只是干扰,连续失败才说明对端或链路真的有问题
达到失败上限之后断开并重建连接,不在旧连接上继续重发旧连接可能已经进了不可恢复的状态,继续重发只是白等
断开后的重连间隔重连之间留一段明显的间隔,不立刻重连对端可能还没就绪,密集重连会形成连接风暴
连接失败重试同样留间隔,但可以比断线重连更积极连接阶段失败通常是“对端没准备好”,稍等即可
绑定端口失败重试间隔取得最短本地资源问题,恢复通常很快
板间 Modbus 单次超时按板内网的往返时间定,取得比较紧板内网响应远快于现场总线,超时定紧一点能更早暴露异常
RS485 设备单次超时按现场轮询节奏定,比板间链路宽松一条总线上要轮询多台设备,超时定得太紧会把正常设备误判成离线

面板侧的 Modbus 主机还有一层保护:写寄存器失败时先看 socket 是不是已经被关掉了 (关闭状态就直接跳出去重建),否则把重试计数加一,超过上限就跳出循环重建 socket 与绑定。 这是同一个思路的另一种写法——重试有上限,到上限就重建,而不是无限重发。

RS485 那一侧则是“分级重试”:设备在线时按上限重试;一旦判定离线,每轮只发一次、不等重试。 断路器、光源、云台三条链路都改成了这个范式。改的原因很实际: 如果所有设备一律重试到底,那么一台已经掉线的设备,每一轮轮询都要白等若干个超时, 整个轮询周期会被它一个人拖垮。

顺带说一句,“重试到上限就放弃”这个写法在这套系统里出现了很多次: 云台每次 Modbus 操作重试到上限、光源的超时计数到上限判离线、网络连续失败到上限断开、 面板连续写失败到上限重建。它不是一个推导出来的最优值, 而是一个在现场被反复验证“够用”的经验值——所以我更倾向于把它写成有名字的常量, 而不是散落在各处的字面量。

六、现场设备控制:云台、断路器、光源三类执行机构

这三类设备的控制风格完全不同,因为它们的物理特性完全不同:云台是连续运动的伺服机构, 断路器是“动作一次要花上一点时间”的开关,光源是“只调亮度”的从站。放在一起对比, 正好能看出“控制逻辑要跟着物理特性走”这件事。

云台:两个旋转关节加一个锁止推杆

云台由三块相似的驱动板组成,靠 Modbus 从站地址区分:水平关节、垂直关节、锁止推杆。 控制内核是 Modbus 主机,每次读写都包了一层“超时 + 有限次重试”的封装(具体次数与超时属于项目参数)。 驱动板的寄存器映射风格接近对象字典,32 位的量拆成高低两个 16 位寄存器:

类别寄存器内容
模式与使能运行模式、运动模式、控制源、电机使能
运动控制目标位置(32 位拆两个寄存器)、轮廓速度 / 加速度 / 减速度(同样拆高低)、启动、停止、急停
状态与反馈故障码、运动状态、到位标志、实际位置、实际速度、电机电流、母线电压、温度
锁止推杆单独的控制寄存器与状态寄存器

位置反馈用的是绝对值编码器(19 位分辨率):角度不是直接读出来的, 而是按“脉冲数 × 每脉冲对应的角度 + 零位偏移”换算出来的。这个换算里真正需要逐台标定的是零位偏移, 它取决于装配,属于项目参数,文中不写具体值。 用绝对值编码器而不是增量式的,好处是上电就能知道自己在哪,不需要回零动作。

几个关键的位置与速度参数是这样定的(取值都属于产品参数,这里只写它们各自约束什么):

  • 工作零位:两个关节各自的“正中间”姿态,收、放、回正三条序列都以它为基准点;
  • 保护位置:一个明确的“收起”姿态,整机不用时停在那里;
  • 工作区限位:以工作零位为中心对称展开,范围是按机械行程定的,不是按软件方便定的;
  • 到位容差:判断“到了没有”用的小容差——我留了容差,因为伺服不可能精确停在目标值上;
  • 漂移阈值:判断“是不是被外力移动了”用的阈值,它必须明显大于到位容差,否则正常误差会被误报成被移动;
  • 速度档位:几档固定的轮廓速度,由操纵杆的倾斜等级选择,快档用于收放、慢档用于精细对准;
  • 加减速:加速度与减速度成对下发,加速度按速度的一个倍数取(倍数属于项目参数), 减速与加速取同一个值,避免起步和停止时抖动。

每个控制周期下发一次增量,增量等于“当前档位速度 × 控制周期”——所以周期一变, 同样的速度对应的增量也会变,这两个值必须一起调(周期与档位的具体取值属于产品参数)。 同一时刻只控制一个关节,优先水平轴;如果左右(或上下)被同时按下,这次输入直接判为无效, 而不是“两个都动”或者“随便挑一个”。

关节这边有一个 8 个状态的状态机: 初始化(等云台上电)→ 延时 → 保护位 / 位置异常故障 / 正常 / 错误 / 回收中 / 放出中。 上电和故障复位之后都要先等一段上电初始化时间再开始通信, 因为驱动板上电初始化需要时间(等多久和驱动板型号有关,属于项目参数)。 收、放、回正是三条固定序列,例如“放”的序列是:解锁推杆 → 水平轴运动到工作零位 → 垂直轴运动到工作零位 → 序列结束、下使能、回到正常态;“收”则在两个轴到工作零位之后, 继续运动到保护位置,最后锁定推杆。序列执行期间不响应外部输入。

使能和下使能也是按步骤来的:使能是“设运行模式 → 设运动模式 → 设控制源 → 使能电机”, 每一步之间留出一点建立时间(具体时长属于项目参数);下使能之前先读运动状态, 如果还在运动中就先发停止,确认停下来之后再关电机使能——源码注释里写得很直白:“下使能 刹车抱死”。 急停走的是单独的急停寄存器,并且会强制把目标位置设为 0。

断路器与整机时序:为什么要给动作留足时间

6 路断路器是 485 接口的空开,地址从 0x10 到 0x15, 控制用的是一个合闸控制寄存器,状态读的是一个故障状态字。 这类断路器不是计量型的,读不到电压、电流、功率,能看到的只有开关状态和故障字 (其中一个位表示“合闸失败”)。

控制逻辑用的是三段式状态机:空闲 → 下发控制 → 验证结果。 这里最关键的一件事是给机械动作留足时间:合闸是一个物理动作, 本身就要花上几秒(具体时间看器件手册与实测,属于项目参数)。 所以下发控制之后必须等它动作完再去读状态,而且只有“这次下发没有重试”或者“已经重试到上限”时, 才切换到下一台设备。这一条如果省掉,就会出现“刚发完合闸命令就去读状态, 读到还没合上,于是判定失败并重试”的连锁误判。

工控机的电源控制单独有一个 4 态状态机(正常 / 等待关机 / 延时断电 / 空闲)。 关机流程必须等到显控 PC 在命令帧里给出的软件关机标志, 确认之后还要倒计时一段时间才真正断电——留这段倒计时是给操作系统自己收尾, 具体时长属于项目参数;开机之后也要等合闸结果稳定下来再判定成功,而不是读完一次就下结论。 这里体现的是一个原则:对上位机这种有操作系统的设备,断电不能只看按键,要看它自己说没说完。

急停是独立处理的:按下之后置一个独立的急停标志,并强制云台目标位置归零; 而退出急停的条件被收紧为“急停释放且总电释放”两个条件同时成立。 这两条都是后来改的——最初的版本把急停混在总电按键里处理, 结果急停只切掉了部分设备,工控机被硬断电。

光源:18 路轮询,每两路共用一个旋钮

光源一共 18 路,地址从 0x01 到 0x12。 这里的对应关系有点意思:每两路光源共用一个亮度旋钮, 所以 18 路对应 9 组亮度和 9 个开关按键。代码里的做法是让目标状态指向 ADC 缓冲区里 一个长度为 2 的窗口——第一个是亮度、第二个是这一组的开关——用指针把“旋钮 + 按键”绑定到设备上。

下发方式是轮询:一个索引指针逐台往下走,单次通信按现场总线的超时来等, 连续超时到设定次数就判定这台离线并把亮度清零(次数与超时属于项目参数)。 有一个细节我印象很深:正在重发的过程里不切换索引, 只有“这次没超时”或者“已经判定离线”的时候才推进到下一台。 否则会出现“重发还没结束,索引已经跳到下一台”,上一台的命令就永远补不上了。

七、状态上报与心跳

上报给显控 PC 的数据不是每周期全量发的,而是变化即发: 把当前状态拷进一份新结构体,和上一次的逐字节比较, 有差异才发,并且只发变化的那一段区间;连续若干个上报周期没有变化时,强制发一包充当心跳。 这套机制的细节(怎么求变化区间、心跳位怎么写、为什么兜底周期要取那么长)我在 学习笔记那一页里展开写了, 这里只说这套系统里的两个具体点。

第一,心跳不是单独定义的报文,而是“数据没变时照常发一包完整状态”, 这样接收侧只有一条解析路径。 第二,心跳的存在让“状态没变”从一件无法判断的事变成了一件可以判断的事: 显控 PC 只要在一个心跳周期多一点余量的时间里没有收到任何一包,就可以合理怀疑这条链路出问题了。

上报里有一个字段是后来专门加的:云台工作状态,取值是 0 到位 / 1 回收中 / 2 放出中 / 3 回正中。 加它的原因很直接——只看角度值,看不出云台“正在动”还是“停着”, 而收放过程本身要持续一段时间,操作员需要知道它到底在不在执行。 这也算是一条经验:上报字段要表达的是“状态”,不只是“数值”。

八、任务划分与优先级规划

两个工程都是 FreeRTOS,任务划分基本对称。实际创建的任务与参数如下:

所属任务栈(字)优先级周期 / 触发职责
内核app_task512MAX − 2短周期轮询顶层状态机
内核power_ctrl512MAX − 3中速周期轮询6 路断路器 + 工控机状态机
内核searchlight_ctrl_task512MAX − 3中速周期轮询18 路光源轮询
内核ptz_ctrl512—按控制周期轮询云台与锁止推杆
内核state_transmission_task512MAX − 3内循环 + 阻塞等待TCP client 上报显控 PC
内核udp_control_panel_task2048MAX − 4阻塞等待板间 Modbus 从站(UDP 承载)
面板key_task512MAX(最高)中断通知唤醒按键扫描、消抖、长按
面板ADC_task———11 路 ADC 采集(9 旋钮 + 操纵杆两轴)
面板app_task512MAX − 2短周期轮询顶层状态机
面板udp_control_panel_task2048MAX − 4短周期循环板间 Modbus 主机(UDP 承载)
面板state_transmission_task512MAX − 3阻塞等待 / 断线重连TCP client 上报显控 PC

表中的“MAX”是 FreeRTOS 的 configMAX_PRIORITIES,配置值是 32; 任务优先级是数值越大越优先,所以按键任务(MAX)比顶层状态机(MAX − 2)高两档。 这个分配反映的是“迟到了会造成什么后果”:按键迟到意味着操作丢失,所以给最高; 状态机晚一个节拍只是逻辑晚一点,所以低一档;网络任务大部分时间在阻塞等待,放在更低的位置。 (各任务的具体周期取值属于项目参数,这里用“快 / 中 / 慢”的相对关系表达。)

这里有一个我自己容易看错的地方,特意在主程序顶部写了注释: 任务的优先级用 FreeRTOS 编号(越大越优先),中断的优先级用 NVIC 编号(越小越优先), 两套编号方向相反。配置里“FreeRTOS 可以管理的中断优先级阈值”是 5, 含义是数值不小于 5 的中断服务程序才允许调用带 FromISR 后缀的 API。 工程里把串口中断定在 3、定时器与 DMA 中断定在 6:前者的优先级高于内核的屏蔽线, 响应最及时,按规则它的中断里就不应该碰 FreeRTOS 的接口; 后者落在阈值之内,可以安全调用。把这两类中断分开定级, 比“所有中断都用一个优先级”要清楚得多。

其余几个和资源相关的配置值:

  • 系统节拍按毫秒级配置(工程里用的是 1 kHz),任务里的延时常量都按毫秒写,转换靠 pdMS_TO_TICKS;
  • FreeRTOS 堆 100 KB,最小任务栈 128 字;
  • 软件定时器开启,定时器任务优先级是 MAX − 1,定时器队列长度 16;
  • 互斥锁、递归锁、计数信号量这些选项都打开了,但应用层其实没有显式用信号量或队列, 任务之间靠临界区和共享的结构体传递数据;协议栈内部按 LwIP 的需要实现了自己的信号量与邮箱;
  • 超时管理用的是自己写的软件定时器(基于系统节拍,提供重置、清标志、停止、启动), 不是 FreeRTOS 的 xTimer。原因是这里的用法基本都是“计数 + 查标志”, 自研结构更轻,也不占定时器任务的队列。

还有一条硬件层面的注意事项写在主程序顶部:这颗芯片在 120 MHz 以上主频使用缓存加速会产生异常, 所以工程里没有开启数据缓存和指令缓存。这类“芯片手册上写了、但很容易被默认配置带上”的约束, 我现在的习惯是直接写成注释放在文件最上面。

九、问题与解决:十三条现象、原因与办法

下面这些是这套系统里真正花过时间的问题,按“现象 → 原因 → 办法”整理。 它们大多不是通信协议本身的问题,而是“协议和物理世界对不上”的地方。

现象原因办法
按键只有电平、没有边沿:按住不放会反复触发,上层无法区分“刚按下” 按键读取只返回当前电平,没有“变化”的概念 增加一个按键事件数组,在“上次电平 ≠ 本次电平且本次为按下”时产生一次性按下事件;事件由上层读取、用完手动清除。云台的收、放、回正三个点按按键就靠这个事件,避免重复启动序列
按键按下去有时不响应(“按下失效”) 机械抖动让单次采样正好落在抖动窗口里,被当成“未按下”,或者被紧跟的释放判定立刻清掉 改成“连续 N 次采样一致才认可”的计数式消抖(N 与扫描周期有关,我用的值偏保守),按下和释放各一个计数器;另外加长按判定(计数到阈值判长按,并对计数做钳位防溢出),只对三个点按类按键做长按故障判定
急停只切掉部分设备;工控机被硬断电导致异常 急停逻辑混在总电按键里处理,关机判断也只看按键 独立的急停标志(按下即置位并强制云台目标归零),退出条件收紧为“急停释放且总电释放”;工控机改用 4 态状态机,先确认显控下发的软件关机标志,倒计时一段时间后再断电
通信超时行为不统一:设备掉线后主循环被长时间重试拖住 所有设备都按“失败重试若干次”处理,已经离线的设备每轮仍要等满这些重试 统一为“未判定离线时按上限重试;判定离线后只等一个超时、不重发”,并覆盖光源、断路器、云台三条链路
云台初次上电或故障复位后立刻通信,读角度 / 状态失败,被误判成通信故障 云台驱动板上电初始化需要时间,主站问得太早 初次上电与故障复位后都先等一段上电初始化时间再开始通信
非控制状态下云台被误移动(摇杆漂移或残留数据让它动起来) 非控制状态没有读取角度作为基准,增量下发无从判断“现在在哪” 非控制状态也周期性读取云台角度与状态,但只读不动,作为后续控制的基准
增量控制越限:连续下发增量后云台冲出限位,或者与历史目标偏差越来越大 每次下发前没有判断新目标是否超出角度范围,也没有和上一个目标比较 用刚读到的当前角度算目标,先与上下限钳位,再与上一次目标比较判断超调并置故障位;控制节拍也相应放缓
云台角度漂移检测:停机或断电后角度和记录值对不上 没有把“当前角度”和“上次认定的角度”做持续比对,看不出是被外力移动了还是正常误差 用两个不同的阈值分工:到位容差(小,只用于判断“到了没有”)和漂移阈值(明显更大,用于判“是不是被移动了”),两者的具体取值属于产品参数。非控制状态下持续比对,超阈值即置故障位
外部晶振失效时程序不运行 时钟初始化只依赖外部晶振(HSE),起振失败就没有时钟 修改时钟初始化逻辑:外部晶振失效时自动启用内部时钟作为兜底,保证程序至少能跑起来并上报异常
不插网线时程序卡死,串口无输出 启动流程里直接等待“网络就绪”,而网线没插时链路永远不会就绪 把网络初始化挪到空闲处理里:先读 PHY 状态寄存器判断链路,未连接就按固定间隔重试并打印等待提示;链路建立后再依次初始化协议栈、板间通信与状态上报
继电器(断路器)被误判故障,拒绝合闸 读取故障寄存器时偶发异常,被当成设备故障 移除故障寄存器判断逻辑;保留的只是“光源、断路器、云台故障清除”这类的显式操作
光源控制分散在多个任务里,行为不一致 不同任务各自下发,索引与超时计数互相干扰 把光源控制合并到一个任务里,用单索引轮询,统一超时与离线判定
联调现场逻辑不匹配(云台与断路器的控制行为不符合实际操作习惯) 纸面设计没有覆盖现场的操作顺序与节奏 在现场修改云台控制逻辑与断路器控制逻辑,并把改动点记回需求清单

按键这一条我展开说:消抖和边沿是两件事

上表里的前两条经常被混在一起,其实它们解决的是两个不同的问题: 消抖解决“这一次采样可不可信”,边沿事件解决“上层要知道的是动作而不是状态”。 只做消抖不做边沿,按住不放就会反复触发;只做边沿不做消抖, 会在抖动窗口里产生一串真假难辨的“按下—释放”。 我现在的写法是把两件事放在同一个扫描函数里:

/* 按键扫描:连续 N 次采样一致才算数,并且只在“翻转”的那一次产生一次性事件
   —— 按个人理解重写的最小片段,非工程原码;N 与扫描周期有关,我用的值偏保守 */
for (i = 0; i < KEY_NUM; i++) {
    uint8_t pressed = (level_bits >> i) & 0x01;              /* 本次采样 */
    press_cnt[i]   = pressed ? MIN(press_cnt[i] + 1, DEBOUNCE_N) : 0;
    release_cnt[i] = pressed ? 0 : MIN(release_cnt[i] + 1, DEBOUNCE_N);
    if (press_cnt[i] == DEBOUNCE_N && last_state[i] == 0) {  /* 按下:翻转沿 */
        last_state[i] = 1; key_event[i] = KEY_STATE_PRESS;
    }
    if (release_cnt[i] == DEBOUNCE_N && last_state[i] == 1) { /* 释放:翻转沿 */
        last_state[i] = 0; key_event[i] = KEY_STATE_RELEASE;
    }
}

代码里的 N 就是消抖次数,它的取值与扫描周期有关,我用的值偏保守—— 宁可多花几个周期确认,也不要让一次抖动穿透到上层。长按判定是另一个独立的计数器, 到阈值只产生一次长按事件,计数器同样做了钳位防止溢出。

事件产生之后由上层“取走并清除”,这一点很重要: 如果事件只能读不能清,上层就会把同一个动作执行很多遍。 我在这套系统里给按键事件配了一个读取函数和一个清除函数,收放序列启动之前先确认事件已经被消费掉。

另外,按键扫描任务的优先级被设成了最高,唤醒方式是“外部中断给出任务通知”, 任务本身阻塞等待通知。这样按键的响应不受其他任务影响,也不会因为轮询间隔而漏掉动作。

云台的增量下发:先钳位,再判超调

增量控制的坑在于“增量本身没错,错的是增量的起点”。 如果每次都用上一次的目标值去加增量,误差会累积;如果下发前不判限位,就会冲出工作区。 我最后的做法可以拆成四步——这里只写步骤与理由,不写角度值、速度值与周期值:

  1. 先读当前角度。每个控制周期重新读一次编码器反馈,把“现在在哪”当作这一拍的起点, 而不是沿用上一拍自己算出来的目标值。
  2. 再算目标。用“当前角度 + 本周期允许的增量”算出这一次要下发到的目标位置; 增量由当前档位速度与控制周期共同决定,所以这两个值必须一起调。
  3. 再按机械行程钳位。目标越过工作区上下限时直接钳到边界。 限位是按机械行程定的,不看软件方便。
  4. 再与上一次的目标比较。如果新目标与上一次目标之间的差距明显超过“一个周期的增量”, 说明中间有异常(反馈跳变、指令叠加、外力顶动),此时判超调、置故障位,并且这一次不下发。
步骤它在防什么省掉会怎样
读当前角度防“用上一拍的目标当起点”造成的误差累积连续下发很多拍之后,实际位置和目标值会越差越远
用当前角度算目标防“增量叠加”——每次下发都相对当前位置,而不是相对上一次的意图快速来回操作时指令会叠加,表现为“手停了机器还在走”
限位钳位防冲出工作区撞到机械限位操作员一直按住时,目标会一路加出工作区,靠机械限位硬挡是事故
与上一次目标比较防反馈跳变、指令叠加、外力顶动这类“目标突然跳一大步”的情况一次脏数据就能让云台猛地甩一下,而且事后查不出原因
判超调时不下发防“带着异常继续动作”如果只是报故障但仍然照常下发,故障位就形同虚设

这段逻辑对应源码注释里的一句话,大意是“节拍速度要放缓,每次下发新增量之前要读取云台当前角度, 云台角度必须不超过上一个增量角度值”。我把它翻译成实现就是这四步: 读当前 → 算目标 → 钳位 → 比对上一次目标,任何一步省掉都会在现场暴露出来。

没插网线这一条:现象最唬人,改法最简单

它排在这张表里的原因不是难改,而是“现象太像死机”—— 上电之后毫无反应、串口也不打印,第一反应会怀疑时钟或 Flash。 实际根因只是启动流程在等一个永远不会发生的事件。改成“空闲时按固定间隔读一次 PHY 链路状态”之后, 不插网线也能正常启动,并且每次重试都打印一行提示,人一眼就知道它在等什么。 详细的写法我放在 UDP 通信那一页的第八节里。

十、局限与还没做完的部分

这个项目是我个人的学习项目,主要功能跑通了,但有几件事确实没做完。 我把它们写清楚,比含糊地说一句“仍在完善”更有用:

  1. 网络远程升级只提出了需求,没有实现。 需求是在项目后期明确写下的,但仓库里没有对应的实现文件。 目前已经具备的条件是:片内 Flash 读写、表驱动的 CRC32、以及一条能承载大块数据的 TCP 长连接—— 这些是远程升级的前置能力,但“有能力”不等于“做完了”。 关于跳转与分区的思路,我在 《Bootloader 与 OTA:跳转前后要处理的几件事》 里整理过另一段学习记录,那是另一个项目上的实践,不是这套系统的实现。
  2. 顶层状态机的故障处理还是一个空壳。 各个功能模块的故障其实已经汇总上报了:云台有自己的故障字、 每路断路器有故障状态、光源有在线状态,它们都会进入上报给显控的数据体。 但顶层状态机里“故障态”的进入、执行、退出三个处理体目前只有日志,没有实际动作。 也就是说:故障能被看见,但系统还不会自己处置。
  3. 看门狗在这两个工程里都还没开启。 工程自己的注意事项里写着“程序基本开发完成的时候,需要开启看门狗并测试”, 但源码里找不到独立看门狗或窗口看门狗的初始化。这是我认为最应该优先补上的一项—— 它的缺失意味着一旦程序跑飞,没有任何东西能把它拉回来。
  4. 面板侧的故障码语义还没定义完。 上报结构里留了一个字节的位域,但其中几个位的含义仍然是空的, 属于“接口留了、内容没填”的状态。
  5. 栈溢出检查没有打开。配置项是关闭的。任务栈普遍给 512 字、网络任务给 2048 字, 在没开检查的情况下,栈溢出不会被主动发现,只会表现为“跑着跑着行为不对”。
  6. 有一处读代码时的怀疑没有实测确认。 按需上报里“心跳位写在新的那份结构体、而发送用的是旧的那份”这一点, 我只做了静态阅读,没有抓到实际报文来确认。我把它列成待验证项,而不是结论。
  7. 长时间运行的稳定性没有做过系统性记录。 我验证到的是功能层面(包括现场联调时对云台与断路器逻辑的修改), 连续运行几天甚至更久的表现,我没有做过有统计意义的观察,这里不做任何承诺。

参考资料与说明

  • LwIP 官方文档与源码中的 socket API 说明(开源 TCP/IP 协议栈)。
  • FreeRTOS 官方文档中关于任务优先级、任务通知、临界区与配置项的说明(开源实时内核)。
  • Modbus 应用协议规范(Modbus Application Protocol Specification)与 Modbus over Serial Line 规范中关于功能码、寄存器与帧间隔的定义。
  • 以太网 PHY 数据手册中关于基本状态寄存器与链路状态位的说明,以及 RMII 接口的引脚定义,属于公开芯片资料。
  • 关于代码来源:这套系统的网络框架最初移植自一份第三方的以太网例程 (包含 LwIP 网络驱动与 FreeRTOS 的移植层),该部分的知识产权属于原作者, 本文不转载其代码,也不包含其版权声明。文中描述的是整体架构与我在应用层所做的改动。 页面里的代码片段均为按个人理解重写的最小示例,不是工程原码。
  • 关于代码披露程度:出于对个人项目实现细节的保护,本文只公开方法与设计取舍: 板间帧只给字段表,不给结构体定义;云台增量下发的控制逻辑与专有协议的收发实现不展示代码, 以步骤说明与“每一步在防什么”的表格代替;按键消抖属于通用技巧,给出的也是把产品数值 换成 N 之后的最小片段。涉及核心实现的部分以步骤与字段说明代替代码, 示例代码均为按个人理解重写的通用片段,不代表任何产品或交付代码。
  • 文中涉及的芯片、PHY 与器件型号仅用于说明技术方案,与相关厂商无隶属或授权关系; 行业背景以中性方式描述,代码与参数不代表任何产品或交付代码。 具体角度限位、容差、漂移阈值、速度档位与超时取值均属于项目参数,本文不列出。