soem之ec_receive_processdata() 深度分析 2.1 包装层L1980-1983intecx_receive_processdata(ecx_contextt*context,inttimeout){returnecx_receive_processdata_group(context,0,timeout);// group 0}timeout 单位是微秒。应用层 ecat_loop() 里传的是 EC_TIMEOUTRET2典型 2000µs2ms。2.2 ecx_receive_processdata_group()逐块解读L1892-1967块 1DC 标志初始化L1894-1904intpos,idx;intwkc0,wkc2;uint16 le_wkc0;intvalid_wkc0;int64 le_DCtime;boolean firstFALSE;if(context-grouplist[group].hasdc){firstTRUE;// 和 send 侧的 first 对应只处理第一帧里的 DC 时间}块 2栈循环 — 按发送顺序一帧一帧收L1905-1957posecx_pullindex(context);// pos 栈当前拉出位置从0开始。空栈返回-1。while(pos0){idxcontext-idxstack-idx[pos];wkc2ecx_waitinframe(context-port,idx,timeout);// ⭐阻塞式等匹配帧ecx_pullindexstaticintecx_pullindex(ecx_contextt*context)// L1640{intrval-1;if(context-idxstack-pulledcontext-idxstack-pushed){rvalcontext-idxstack-pulled;// 返回的是栈槽号不是idxcontext-idxstack-pulled;}returnrval;}它每次拉出一个位置并返回位置 pos0,1,2…。然后用 idxstack-idx[pos] 取出真正的帧索引。这样和 send 侧 push 顺序一致。ecx_waitinframe 是核心——循环调 ecx_inframe读 NIC 收帧 → 按 idx 匹配 → 乱序暂存收到匹配的就返回 WKC超时返回 EC_NOFRAME(-1)。if(wkc2EC_NOFRAME)// wkc20 成功收到匹配帧{/* 收到帧后先看它是 LRD / LRW 还是 LWR */if((rxbuf[idx][EC_CMDOFFSET]EC_CMD_LRD)||(rxbuf[idx][EC_CMDOFFSET]EC_CMD_LRW)){EC_CMDOFFSET0rxbuf[idx] 里存的是去掉以太网头后的部分第 0 字节就是 EtherCAT 数据报的 command 字段LRD0x0ALRW0x0DLWR0x0B。LRD/LRW 的返回帧里包含输入数据需要 memcpy 回 IOmap。LWR 的返回帧里只有 WKC没有输入所以只需要把 WKC 累加。子分支 C-LRD/LRW含输入数据的处理if(first)// 第一帧 有DC{memcpy(idxstack-data[pos],(rxbuf[idx][EC_HEADERSIZE]),context-DCl);// ★1memcpy(le_wkc,(rxbuf[idx][EC_HEADERSIZEcontext-DCl]),EC_WKCSIZE);// ★2wkcetohs(le_wkc);memcpy(le_DCtime,(rxbuf[idx][context-DCtO]),sizeof(le_DCtime));// ★3*(context-DCtime)etohll(le_DCtime);firstFALSE;}这 4 行是整个 receive 函数最精华的部分一行一行拆① memcpy(idxstack-data[pos], rxbufEC_HEADERSIZE, DCl)idxstack-data[pos] 是 send 侧 push 的 IOmap 目标指针LRW 下是 outputsiomapinputoffsetLRD 下是 inputs。EC_HEADERSIZE10EtherCAT 数据报头长 10 字节cmd 1 idx 1 ADP 2 ADO 2 elength 2 IRQ 2 10所以数据区从 10 开始。DClsublengthsend 侧记下来的第一帧数据区长度——只拷第一帧的输入部分。为什么只拷 DCl 因为第一帧里有 DC 数据报WKC 的位置不在「数据区末尾 WKC」的固定位置而是「数据区首 DCl 长」的位置。② memcpy(le_wkc, rxbuf[EC_HEADERSIZE DCl], 2)精确跳到 WKC 的位置取它小端读入再用 etohs 转WKC 也是小端。第一帧只设置 wkc 初值不累计。③ memcpy(le_DCtime, rxbuf[DCtO], 8)DCtO 就是 send 侧 ecx_adddatagram 返回的第二个数据报FRMW在整帧中的字节偏移。从那个位置起取 8 字节就是从站返回的 DC 系统时间寄存器值。etohll 把小端的 64 位整数转为主机格式。写回 context-DCtime主站的 DC 时间就变成了与从站主参考时钟对齐的值。下周期发送时把更新过的 DCtime 再写 FRMW实现分布式时钟闭环同步。else// firstFALSE非 DC 首段{memcpy(idxstack-data[pos],(rxbuf[idx][EC_HEADERSIZE]),idxstack-length[pos]);// 按实际长拷wkcwkc2;// 直接累加 wkc2waitinframe 返回的已经是 WKC}valid_wkc1;非首段不需要管 DC 和 WKC 的特殊对齐直接全拷。wkc2 已经是 ecx_waitinframe 从帧末尾读出的 WKC。子分支 C-LWR只含输出 WKC 的处理L1934-1951elseif(rxbuf[idx][EC_CMDOFFSET]EC_CMD_LWR){if(first){memcpy(le_wkc,(rxbuf[idx][EC_HEADERSIZEcontext-DCl]),EC_WKCSIZE);wkcetohs(le_wkc)*2;// ★★★ WKC × 2 补偿memcpy(le_DCtime,(rxbuf[idx][context-DCtO]),sizeof(le_DCtime));*(context-DCtime)etohll(le_DCtime);firstFALSE;}else{wkcwkc2*2;// ★★★ 非首段也×2}valid_wkc1;}WKC × 2 补偿的由来 你一定要理解这个在 LRW逻辑读写合一 里每个输出从站既参与写输出数据被它消费又参与读输入从它读到 WKC N所以 ESC 对 输出站 WKC 21 次写 11 次读 1、输入站 WKC 11 次读 1。现在退化为 LWR只写输出 了ESC 对每个输出站只 1因为是单向写没有读动作。这个 WKC 与 LRW 基准相比少了一半。为了让 blockLRW1 时上层计算 expectedWKC 的公式不变否则你要维护两套 WKC 规则SOEM 在这里手工 ×2把 LWR 的 WKC 归一化到 LRW 基准。LRD只读输入的 WKC 本身就是每个输入站 1与 LRW 的输入基准一致所以不需要修正。这个 ×2 设计使得以下公式对 LRW/非LRW 两种路径统统成立valid_wkc1;// 只要收到过帧就置1块 3释放缓冲 循环L1953-1957ecx_setbufstat(context-port,idx,EC_BUF_EMPTY);// 缓冲还给索引池posecx_pullindex(context);// 拉下一栈位置}用完的缓冲槽置 EMPTY下周期 send 可重新分配。如果栈还有没拉的多段发送继续 while 循环收。块 4清空栈 返回L1959-1967ecx_clearindex(context);// pushed0, pulled0 栈清零为下周期准备if(valid_wkc0){returnEC_NOFRAME;// 一帧都没收到超时}returnwkc;// 本轮 total WKC三、expectedWKC 公式推导 数值含义3.1 计算点ecatuser.c L201expectedWKC (ec_group[0].outputsWKC * 2) ec_group[0].inputsWKC;3.2 outputsWKC 和 inputsWKC 的增量位置ethercatconfig.c// ethercatconfig.c L992 — 在 ecx_config_create_input_mappings每分配一个输入FMMU就context-grouplist[group].inputsWKC;// ethercatconfig.c L1110 — 在 ecx_config_create_output_mappings每分配一个输出FMMU就context-grouplist[group].outputsWKC;// ethercatconfig.c L1153-1154 — ec_config_map_group 开始时清零context-grouplist[group].outputsWKC0;context-grouplist[group].inputsWKC0;实际语义outputsWKC 组内有输出 FMMU的从站数 有输出数据要接收/消费的从站数inputsWKC 组内有输入 FMMU的从站数 有输入数据要采集/发送的从站数.3 公式等价推导LRW 基准下ec_receive_processdata 内部对 LWR ×2 补偿后公式对所有路径等价expectedWKC(outputsWKC ×2)inputsWKCΣ(每个输出从站贡献2)Σ(每个输入从站贡献1)每个输出从站贡献2ESC 既接收写动作(WKC1)又提供读动作(WKC1)合计2每个输入从站贡献1ESC 只在读时提供1次贡献合计13.4 结合 ec_receive_processdata 内部的 WKC 计算规则给出对照表这就是为什么 receive 侧必须 ×2它让应用层不用关心底层实际用 LRW 还是 LWRLRD只要算 (outputsWKC×2)inputsWKC 就行。3.5 实际场景举例假设1 台伺服8字节输出 6字节输入 1 台数字IO模块4字节输入无输出outputsWKC1只有伺服有输出FMMU inputsWKC2伺服IO模块都有输入FMMU expectedWKC1×224实际周期里 WKC 含义actualWKC 4 ✔ 通信完美actualWKC 2 ⚠ 只有输出从站响应可能输入从站掉线/断电actualWKC 3 ⚠ 有一个输入从站没响应可能 IO 模块故障actualWKC 0 or -1 ❌ 链路全断帧都没回来actualWKC 4 ⚠ 帧错位罕见通常是 index 匹配逻辑异常或 FPGA 收帧出错应用层ecatuser.c L203-232就用这个判断WKC 不对则停 200ms 触发状态机重建。四、idxstack 机制 segment 分段原理为什么需要栈发送时有可能分 N 段processdata 太长超过单帧最大数据长 或 SM 边界要求。为了让收侧知道「我要收几段、每段 idx 多少、收到的数据要拷到 IOmap 哪个偏移、每段多长」send 侧必须留下线索。栈就是这个线索列表。sendside(按发送顺序压入) receiveside(按顺序拉出) pushed[0]idx3,dataIOmap[0x00],len1400→ pull pos0→ 收 idx3拷1400B 到 IOmap[0x00]pushed[1]idx5,dataIOmap[0x578],len900→ pull pos1→ 收 idx5拷900B 到 IOmap[0x578]pushed[2]idx8,dataIOmap[0x900],len64→ pull pos2→ 收 idx8拷64B 到 IOmap[0x900]为什么需要分段两个约束决定不能把所有过程数据塞进一帧约束 1EtherCAT dlength 只有 11 位值 ≤ 2047。数据区超过 ~1978 字节就超限帧头WKC 数据 ≤ MTU 1518实际上线速单帧数据上限更少。约束 2SM 同步管理器边界不能被跨帧切割。每个从站的输出 SM 占一块连续物理内存、输入 SM 占另一块。如果在两个 SM 的交界处跨帧会出现第一帧只写了 SM 的前半、第二帧再写后半——从站 ESC 不支持把一个 SM 的数据分布在两条数据报里导致 SM 状态机异常。IOsegment[] 就是 ec_config_map_group 把从站们按 SM 边界切割得到的安全长度数组。发送侧循环按这个数组分帧保证每段不会破坏任何从站的 SM 完整性。逻辑地址空间0x0000-输出从站A SM-0x0578-输出从站B SM-0x0900-输入从站A SM-0x0940-输入从站B SM 分割边界 ┃ seg01400┃ seg1900┃ seg264┃ seg3...五、DC 同步帧的闭环机制LRWFRMW可视化最后把 DC 同步这个精巧设计串起来。每个周期发生以下事主站发送侧1.构造 LRW 数据报数据sublength 字节2.用 ecx_adddatagram 在同一帧中追加 FRMW 数据报读写 DC从站的0x0910-写入 context-DCtime主站认为的当前时间-返回的 DCtOFRMW 在整帧中的字节偏移3.把 DClsublength、DCtO偏移 存到 context 里4.发出去 从站链处理-所有从站LRW 消费输出、写入输入、WKC累加-DC参考从站FRMW 把0x0910写入主站时间、同时把从站真实时间 替换到帧里返回帧中0x0910从站实际系统时间 主站接收侧5.收第一帧firstTRUE6.用 DCtO 取出8字节 → 这是从站返回的实际系统时间7.context-DCtime从站时间 → 主站内部时钟与参考从站对齐8.下一周期循环第1步使用新的 DCtime 值再次写 FRMW结果主站 DCtime 随着每个周期被参考从站拉回长期漂移逐步消除。这就是 SOEM 实现亚微秒级分布式时钟同步的核心闭环——不需要额外帧LRW 数据报的缝隙里顺便完成。调用链总图再次刻进脑子里ec_send_processdata[应用层:ecat_loop()]│ └─ecx_send_processdata_group(0)└─ecx_main_send_processdata(FALSE)L1676 ├─ ① init first/LogAdr/length/offset ├─ ② blockLRW?──┬─ YES → A.先发 LRD输入 │ │ segment循环:│ │ getidx →setupdatagram(LRD)│ │ first?→adddatagram(FRMW DCsync)←存 DCtO │ │ outframe_red →bfin_EMAC_send()│ │pushindex(idx,data,len)│ │ → B.再发 LWR输出,同样流程 │ │ → LWR段WKC后续receive×2补偿 │ └─ NO → C.发 LRW合一 │ segment循环:│ getidx →setupdatagram(LRW)│ first?→adddatagram(FRMW DCsync)│ outframe_red → send │pushindex(idx,dataoffset,len)←offset是overlap用 └─ ③returnwkc1ec_receive_processdata[应用层:ecat_loop()]│ └─ecx_receive_processdata_group(0,timeout)L1892 ├─ ①whilepullindex 不空 │ ├─ecx_waitinframe(idx,timeout)← 内部循环 inframe │ ├─ 命令LRD/LRW?│ │ firstTRUE:│ │memcpy(data,buf[10:],DCl)← 拷输入到IOmap │ │memcpy(le_wkc,buf[10DCl],2)→ wkc初值 │ │memcpy(DCtime,buf[DCtO],8)← ★同步DC时钟 │ │ firstFALSE:│ │memcpy(data,buf[10:],length)│ │ wkcwkc2 │ ├─ 命令LWR?│ │ firstTRUE:│ │ wkcwkc_from_frame ×2← ★归一化到LRW基准 │ │memcpy(DCtime,buf[DCtO],8)← 同步DC时钟 │ │ firstFALSE:│ │ wkcwkc2 ×2← ★归一化到LRW基准 │ ├─setbufstat(idx,EMPTY)←释放缓冲 ├─ ②clearindex()└─ ③returnwkc 或 EC_NOFRAME