RK3588 + IgH EtherCAT 主站低延迟优化实践:从百微秒抖动到微秒级实时性
前言
最近在 RK3588 上做 IgH EtherCAT 主站时,我一直在思考一个问题:
EtherCAT 主站能够跑起来,和 EtherCAT 主站能够稳定地按照 1 ms 周期运行,其实是两回事。
安装 IgH、扫描到从站、PDO 正常收发、从站进入 OP,这些只能说明 EtherCAT 通信链路已经建立。
但如果这套系统最终要用于机器人、运动控制或者其他周期控制场景,还要继续回答几个问题:
- 1 ms 周期线程能不能准时被 Linux 调度起来?
- CPU 繁忙之后,会不会偶尔出现几十甚至几百微秒的抖动?
- PREEMPT_RT 到底有没有必要?
- CPU 绑核到底解决了什么问题?
- CPU 调频会不会影响 EtherCAT 周期?
- Linux 通用网卡驱动的一些默认策略是否适合 EtherCAT?
- 优化之后 WKC 是否仍然正常?
- 所谓“延迟降低”,测到的究竟是什么延迟?
为了把这些问题弄清楚,我在正点原子 RK3588 开发板上搭建了一套完整实验环境,对 IgH EtherCAT 主站从用户态实时线程、CPU 调度、CPU 调频、网卡驱动,到 PREEMPT_RT 和 CPU 隔离逐项进行测试。
最终,在我的测试环境中:
未优化状态下,周期线程平均唤醒延迟约 106 μs;
完成用户态调度、CPU 调频和 stmmac 参数优化以后,平均唤醒延迟降低到约 1.95 μs。
更重要的是,在人为制造 CPU、内存和进程调度压力后,普通 Linux 内核出现过 165 μs 量级的最大唤醒延迟,而 PREEMPT_RT 加 CPU 隔离后,最大值被压缩到了 3.7 μs 左右。
整个实验过程中 EtherCAT WKC 保持正常。
我把基准程序、测试脚本、原始结果、stmmac 修改以及 Rockchip PREEMPT_RT 移植记录全部整理并开源了:
GitHub:
https://github.com/VictorJiaxinWang/RK3588-Igh-Low-Latency
这篇文章不打算只列几个 Linux 命令,而是想把整个优化过程背后的原因讲清楚。
一、先搞清楚:我们究竟在优化什么
实验环境如下。
| 开发板 | 正点原子 RK3588 |
| 系统 | Debian |
| 普通内核 | Linux 5.10.160 |
| 实时内核 | Linux 5.10.160-rt89 |
| EtherCAT Master | IgH EtherCAT Master 1.5.3 |
| IgH 网络驱动 | ec_generic |
| EtherCAT 网口 | eth1,100 Mbit/s,全双工 |
| 管理网口 | eth0 |
| 从站 | 3 个 Renesas RZ/T2 EtherCAT 从站 |
| EtherCAT 周期 | 1 ms |
| 同步方式 | SM |
| Process Data | 132 Bytes |
| 正常 WKC | 9 |
周期程序中,每隔 1 ms 完成一次典型 IgH 主站处理:
ecrt_master_receive();
ecrt_domain_process();
/* 读取输入数据 */
/* 更新输出数据 */
ecrt_domain_queue();
ecrt_master_send();
数据再经过:
EtherCAT 周期线程
|
|
IgH EtherCAT Master
|
ec_generic
|
Linux net_device
|
stmmac
|
DMA / GMAC
|
PHY
|
EtherCAT 从站
这里很容易产生一个误区。
很多人看到“延迟优化”,第一反应是:
EtherCAT 数据帧从 RK3588 发出去,再经过三个从站返回,一共花了多长时间?
但本文测试的并不是这个时间。
我重点观察的是两个指标。
wake lateness
假设周期应该在:
T = 100 ms
开始。
但是 Linux 实际到:
T = 100.015 ms
才把 EtherCAT 周期线程调度起来。
那么:
wake lateness = 15 μs
它描述的是:
Linux 实时任务到底晚醒了多久。
execution time
线程被唤醒以后,从开始执行:
ecrt_master_receive();
到本周期 EtherCAT 数据处理完成所花费的时间。
它反映用户态程序、IgH、内核网络路径等共同形成的执行开销。
因此:
wake lateness
和:
EtherCAT 物理链路 RTT
完全不是同一个东西。
如果想测真正的“帧离开 MAC 到帧返回 MAC”的时间,需要硬件时间戳、从站时钟、GPIO 配合示波器甚至专门的抓包设备。
这一点一定要先区分清楚,否则后面的所有数据都会被错误解读。
二、一个 1 ms EtherCAT 周期,为什么会晚几十微秒
很多做 MCU 或 RTOS 的工程师第一次接触 Linux 实时控制时会有一个疑问。
我明明写的是:
clock_nanosleep(...)
让线程每 1 ms 运行一次,为什么还会晚几十甚至几百微秒?
原因在于:
定时器到期,只代表任务具备运行条件,并不代表 CPU 会在这一纳秒立刻执行它。
比如计划:
100.000 ms EtherCAT 周期应该开始
实际可能变成:
100.000 ms 定时器到期
100.006 ms CPU 正在处理中断
100.014 ms 内核正在执行不可抢占代码
100.021 ms 调度器开始调度
100.024 ms EtherCAT 线程真正开始执行
那么这个周期已经晚了:
24 μs
Linux 中影响这个时间的因素很多:
CPU 调度、任务迁移、CPU 调频、中断、软中断、workqueue、内核临界区、缺页、日志打印、动态内存分配……
所以降低 EtherCAT 周期抖动,本质上并不是简单地“优化 EtherCAT 协议栈”。
很多时候真正需要处理的是:
Linux 实时运行环境。
这也是这次实验最终得到的一个比较重要的认识。
三、第一步优化:CPU 绑核 + SCHED_FIFO
原始状态下,我首先进行了一个几乎没有做实时优化的测试:
普通 Linux 调度
ondemand governor
结果如下:
| 普通调度 | 106.078 μs | 204.667 μs | 2643.629 μs | 38.120 μs |
平均晚醒超过了 100 μs。
甚至出现过超过 2 ms 的极端样本。
对于 1 ms EtherCAT 控制周期来说,这种状态显然谈不上良好的实时性。
于是首先做两个最基本的改动:
EtherCAT 线程固定 CPU6
SCHED_FIFO priority = 80
对应 Linux API:
sched_setaffinity();
sched_setscheduler(..., SCHED_FIFO, ...);
优化后:
| 普通调度 | 106.078 μs | 204.667 μs | 2643.629 μs |
| CPU6 + FIFO 80 | 10.947 μs | 17.333 μs | 151.277 μs |
平均唤醒延迟直接从:
106 μs
降低到了:
11 μs
改善非常明显。
CPU 绑核究竟解决了什么
假设 EtherCAT 周期线程上一周期运行在 CPU4。
下一周期 Linux 又把它迁移到了 CPU6。
线程虽然还是那个线程,但 CPU Cache、运行上下文以及相关数据局部性都会受到影响。
固定:
CPU6
以后,可以减少这种任务迁移。
SCHED_FIFO 又解决了什么
Linux 普通程序通常运行在 CFS 调度器下面。
如果 EtherCAT 周期线程也只是一个普通进程,那么系统里其他进程同样会参与 CPU 时间竞争。
改成:
SCHED_FIFO 80
以后,当 EtherCAT 实时线程进入 runnable 状态,它会优先于普通 CFS 任务运行。
但这里也要特别提醒一句:
实时优先级不是越高越好。
如果高优先级线程里面出现:
while (1) {
/* 永远不阻塞 */
}
或者大量执行:
printf();
甚至调用不可控的阻塞操作,那么它完全有可能把系统里的其他任务饿死。
所以实时线程的设计原则应该是:
准时醒来
快速完成
主动睡眠
而不是长期占据 CPU。
四、一个非常容易被忽略的因素:CPU 调频
完成实时调度以后,平均延迟虽然已经从 106 μs 降到了 11 μs 左右,但我发现还有很大的优化空间。
这次真正让我比较意外的是 CPU governor。
原始系统使用:
ondemand
它的目标首先是降低功耗。
CPU 空闲时降低频率,有负载以后再升频。
这对于普通 Linux 应用非常合理,但对 1 ms 周期实时任务未必合适。
EtherCAT 周期任务有一个特点:
运行时间很短
休眠时间很长
运行时间很短
再次休眠
比如一个周期真正执行工作的时间可能只有几微秒。
CPU governor 看到的负载并不是持续高负载。
于是可能出现:
CPU 降频
周期线程醒来
低频运行
governor 判断需要升频
频率调整
任务已经快执行结束
对吞吐量影响不大,但会直接增加短周期任务的不确定性。
所以将 CPU6 改为:
echo performance > \\
/sys/devices/system/cpu/cpu6/cpufreq/scaling_governor
结果非常明显:
| CPU6 + FIFO | 10.947 μs | 17.333 μs | 151.277 μs | 18.925 μs |
| 加 performance | 2.340 μs | 3.333 μs | 13.514 μs | 4.733 μs |
平均 wake lateness:
10.947 μs
变成:
2.340 μs
execution mean 也从:
18.925 μs
降到了:
4.733 μs
这组结果说明:
在短周期实时任务中,CPU 调频策略可能比很多人想象得更重要。
当然 performance 不是没有代价。
它会增加:
- 功耗;
- 温度;
- 长期运行时发生热降频的风险。
所以正式设备上还应该同步观察:
cat /sys/class/thermal/thermal_zone*/temp
否则测试十几秒很好看,跑几个小时以后发生 thermal throttling,实时性又会发生变化。
五、继续向下追:stmmac 的发送中断合并
做到这里以后,用户态线程和 CPU 层面的优化基本已经完成。
接下来我开始继续往 Linux 网络栈和网卡驱动下面看。
RK3588 使用 Synopsys DesignWare GMAC,对应 Linux 中常见的:
stmmac
驱动框架。
而我的 IgH 使用:
ec_generic
也就是说 IgH 并没有完全绕开 Linux 通用网络设备路径。
这意味着:
Linux 网卡为了普通 TCP/IP 吞吐量设计的一些默认策略,也可能影响 EtherCAT。
其中一个比较典型的机制就是:
Interrupt Coalescing
即中断合并。
普通网络可能每秒接收几十万甚至上百万个数据包。
如果 DMA 每处理一个描述符就产生一次中断:
packet
IRQ
packet
IRQ
packet
IRQ
CPU 会把大量时间花在中断处理上。
所以现代网卡通常会把多个完成事件合并起来:
packet
packet
packet
packet
IRQ
或者等待一定时间后再触发完成通知。
对于普通网络而言,这是非常合理的吞吐量优化。
但 EtherCAT 的目标不同。
我们更关心:
确定性
而不是:
最大吞吐量
1 ms EtherCAT 周期里本来就没有多少帧,没有太大的必要等待“攒够一批再处理”。
所以首先通过 ethtool 查看:
ethtool -c eth1
然后测试:
ethtool -C eth1 tx-usecs 0 tx-frames 25
修改以后一定再次:
ethtool -c eth1
确认驱动真正接受了参数。
实验结果:
| performance | 2.340 μs | 3.333 μs | 13.514 μs |
| tx-usecs = 0 | 1.948 μs | 3.000 μs | 3.949 μs |
平均值改善已经不像前两步那么夸张。
但是最大值:
13.514 μs
降低到了:
3.949 μs
这一点很有意义。
因为实时系统真正难处理的,往往不是平均值,而是偶尔突然出现的长尾。
为什么我没有直接修改 stmmac 源码
这里也总结一个我现在比较推荐的驱动优化方法。
不要看到驱动里某个参数以后马上改源码。
应该先:
运行时修改
实际测试
确认有效
最后固化
比如本项目先通过:
ethtool -C
完成 A/B 测试。
确认确实有收益之后,才修改 stmmac,使发送合并时间能够通过设备树单独配置。
项目中已经包含对应补丁:
patches/stmmac/
这种开发方式有一个很大的好处:
以后即使优化效果不好,也很容易知道:
到底是哪一个变量造成的。
而不是一口气修改十几个内核参数,最后延迟确实降低了,却没人说得清究竟为什么。
还有一点需要特别说明:
tx-usecs = 0
改善的是主机侧的软件处理和 DMA 完成通知行为。
它不能简单理解成:
EtherCAT 数据帧因此提前了同样多的时间出现在网线上。
如果要证明物理发帧时间变化,仍然需要硬件时间戳或者示波器验证。
六、为什么平均已经 2 μs 了,还要 PREEMPT_RT
做到前面的优化之后,空闲状态已经很好看:
wake mean ≈ 1.95 μs
P99 ≈ 3 μs
于是会自然产生一个问题:
这种情况下还有必要折腾 PREEMPT_RT 吗?
只看空闲系统,很可能会得到:
没必要。
所以我又人为制造了一组系统压力:
- 8 个持续 CPU 计算任务;
- tmpfs 内存反复写入;
- 持续创建短生命周期进程。
然后在完全相同的 EtherCAT 参数下重新测试。
这时候普通 Linux 内核的结果变成:
| wake mean | 3.775 μs |
| wake P99 | 14.000 μs |
| wake max | 165.614 μs |
| execution max | 105.917 μs |
平均值其实仍然只有:
3.775 μs
乍看完全可以接受。
但:
wake max = 165.614 μs
这才是真正的问题。
也就是说,大部分周期都很好,但系统繁忙时偶尔会出现一个很长的调度延迟。
对于日志服务器,这可能毫无影响。
对于机器人 1 kHz 控制环,这种偶发抖动就值得认真对待。
PREEMPT_RT 做的并不是“让 Linux 跑得更快”
这是理解 PREEMPT_RT 最重要的一点。
它主要解决的是:
最坏情况下,我到底要等多久才能运行?
普通 Linux 内核中存在一些实时线程暂时不能抢占的执行区间。
PREEMPT_RT 会让更多内核执行路径变得可抢占,并将大量中断线程化,同时改变部分锁的实时语义。
换成 Rockchip PREEMPT_RT 方案以后:
| wake mean | 3.775 μs | 2.407 μs |
| wake P99 | 14.000 μs | 5.667 μs |
| wake max | 165.614 μs | 9.517 μs |
| execution max | 105.917 μs | 24.802 μs |
最明显的并不是 mean。
而是:
165.614 μs
被压缩到了:
9.517 μs
这才体现出了实时内核真正的价值:
它主要是在压缩长尾,而不是追求漂亮的平均值。
所以测试 PREEMPT_RT,如果只是:
系统什么都不运行
cyclictest 跑十几秒
很容易得出错误结论。
实时系统一定要在接近真实负载,甚至比真实负载更恶劣的情况下测试。
七、绑核还不够:给 EtherCAT 准备一颗真正“干净”的 CPU
前面我们已经:
EtherCAT Thread -> CPU6
但这里还有一个隐藏问题。
CPU affinity 只是告诉 Linux:
EtherCAT 线程只能去 CPU6。
它并没有告诉 Linux:
其他东西不能来 CPU6。
于是 CPU6 上仍然可能出现:
EtherCAT Thread
普通进程
IRQ
workqueue
kernel thread
这就像给 EtherCAT 分配了一间办公室,却没有锁门。
别人仍然可以进来。
因此项目又做了一步:
CPU6 runtime isolation
主要包括:
- 停止 irqbalance;
- 从默认 IRQ affinity 中移除 CPU6;
- 从 unbound workqueue CPU mask 中移除 CPU6;
- 将普通任务迁出 CPU6;
- 将 eth1 活跃 IRQ 固定到 CPU5。
此时 CPU6 主要留给 EtherCAT 周期线程。
结果:
| RT | 2.407 μs | 5.667 μs | 9.517 μs |
| RT + CPU6 隔离 | 1.852 μs | 3.000 μs | 3.711 μs |
最大调度延迟进一步从:
9.517 μs
降低到了:
3.711 μs
项目提供了对应脚本:
./tools/apply_cpu6_shield.sh apply
测试结束后:
./tools/apply_cpu6_shield.sh restore
需要注意,这里做的是:
运行时 CPU 隔离。
它还不等同于完整的:
isolcpus
nohz_full
rcu_nocbs
启动级 CPU 隔离方案。
真正做工业实时系统,还需要进一步规划:
Real-Time CPU
Housekeeping CPU
IRQ
RCU callback
per-CPU kthread
workqueue
但对于理解 CPU 隔离的效果,这套运行时方案已经非常直观。
八、最终结果,以及我认为最值得关注的地方
整个空闲测试的累积结果如下。
| S0 | 普通调度 + ondemand | 106.078 μs | 204.667 μs | 2643.629 μs |
| S1 | CPU6 + FIFO 80 | 10.947 μs | 17.333 μs | 151.277 μs |
| S2 | performance | 2.340 μs | 3.333 μs | 13.514 μs |
| S5 | stmmac tx-usecs=0 | 1.948 μs | 3.000 μs | 3.949 μs |
从:
106.078 μs
降到:
1.948 μs
平均唤醒延迟下降约:
98.2%
但如果只看这张表,其实还没有抓住整个实验最重要的地方。
真正让我觉得 PREEMPT_RT 和 CPU 隔离有价值的是压力测试:
| 普通 Linux | 3.775 μs | 14.000 μs | 165.614 μs |
| PREEMPT_RT | 2.407 μs | 5.667 μs | 9.517 μs |
| RT + CPU 隔离 | 1.852 μs | 3.000 μs | 3.711 μs |
如果只看 mean:
3.775 μs
2.407 μs
1.852 μs
似乎区别没有那么惊人。
但看 max:
165.614 μs
9.517 μs
3.711 μs
完全是另一回事。
所以以后评价 Linux EtherCAT 主站实时性,我认为至少应该同时看:
mean
P99
max
missed deadline
WKC
只说一句:
“我的平均抖动只有 2 μs。”
其实是不够的。
实时控制真正担心的是:
系统最忙的时候,会不会突然出现一个 100 μs、500 μs,甚至超过一个周期的异常值?
九、如果你也在 RK3588 上做 IgH,我建议这样优化
如果你的 EtherCAT 主站目前已经能够正常进入 OP,但是周期实时性不好,我不建议一上来就修改内核。
比较稳妥的顺序是:
1. 先建立一个最小周期程序
只保留:
receive
domain_process
PDO
domain_queue
send
不要把机器人控制算法、日志、IPC、状态机全部塞进去。
否则后面发现 100 μs 抖动时,你根本不知道是谁造成的。
2. 使用绝对时间周期
不要简单:
usleep(1000);
推荐:
clock_nanosleep(
CLOCK_MONOTONIC,
TIMER_ABSTIME,
...
);
因为相对睡眠容易累计前一个周期产生的误差。
假设第一个周期晚了:
20 μs
绝对时间方式仍然按照:
T0
T0 + 1 ms
T0 + 2 ms
T0 + 3 ms
调度。
不会变成:
T0
T0 + 1.020 ms
T0 + 2.040 ms
…
3. 实时循环中尽量不要做不可控操作
尤其避免:
printf();
malloc();
free();
文件 IO
复杂锁等待
程序启动以后可以先:
mlockall(MCL_CURRENT | MCL_FUTURE);
避免实时过程中发生内存缺页。
测试数据最好预分配内存保存,循环结束之后统一写 CSV。
本项目的 baseline 程序也是按照这个思路设计的。
4. 然后做 CPU affinity + SCHED_FIFO
这是成本最低,而且通常收益非常明显的一步。
先不要折腾 PREEMPT_RT。
如果这一层都没有配置好,直接换 RT 内核的意义其实有限。
5. 固定 CPU governor
检查:
cat /sys/devices/system/cpu/cpu6/cpufreq/scaling_governor
对于确定性优先的控制 CPU,可以测试:
performance
然后重新测量。
不要凭感觉判断有没有效果。
6. 查看网卡中断合并参数
ethtool -c eth1
尝试修改后:
ethtool -c eth1
确认驱动真的接受了设置。
如果运行时实验确实证明有效,再考虑设备树或者驱动级固化。
7. 一定做压力测试
不要让 RK3588 什么都不干,然后宣布:
Linux 已经达到微秒级实时性
真正应该模拟:
CPU 竞争
内存活动
进程创建
网络活动
观察:
P99
max
deadline
WKC
8. 长尾确实存在,再考虑 PREEMPT_RT
PREEMPT_RT 的目标不是让系统“更快”,而是让系统:
更可预测。
这是实时系统里非常重要的区别。
9. 最后考虑 CPU isolation
把 EtherCAT 实时线程放到一颗 CPU,只完成了一半。
还应该继续确认:
cat /proc/interrupts
看看是否有大量 IRQ 跑到这颗 CPU。
还应该检查:
irqbalance
workqueue
普通进程
kernel thread
RCU
是否仍然会进入实时 CPU。
做到这一层以后,才开始接近真正意义上的:
Real-Time CPU
十、如何直接复现这个项目
项目地址:
https://github.com/VictorJiaxinWang/RK3588-Igh-Low-Latency
仓库主要结构:
app/ecat_baseline/
EtherCAT 周期基准程序
tools/
阶梯实验、压力测试、CPU 隔离、结果汇总
configs/
RT 相关配置
patches/stmmac/
stmmac 发送中断合并修改
patches/preempt-rt/
Rockchip PREEMPT_RT 补丁
results/
实验结果
docs/
实验文章以及 RT 移植过程
tests/
离线检查
首先编译:
make
make check
运行 1 ms 基准:
./app/ecat_baseline/ecat_baseline \\
–slaves 3 \\
–duration 60 \\
–period-us 1000 \\
–cpu 6 \\
–priority 80 \\
–sync sm
然后逐级测试:
./tools/run_latency_ladder.sh \\
–stage s0 –duration 15 –runs 3
./tools/run_latency_ladder.sh \\
–stage s1 –duration 15 –runs 3
./tools/run_latency_ladder.sh \\
–stage s2 –duration 15 –runs 3
./tools/run_latency_ladder.sh \\
–stage s5 –duration 15 –runs 3
压力测试:
./tools/run_loaded_latency.sh \\
–duration 60 \\
–runs 3
结果可以使用项目中的 Python 工具统一汇总:
python3 tools/summarize_results.py \\
results/idle-ladder.csv
不过这里有一点必须说明。
仓库里的 baseline 是按照我当前的三台 RZ/T2 EtherCAT 从站 PDO 映射写的。
如果你的从站不同,需要修改:
Vendor ID
Product Code
PDO Mapping
Slave Count
Expected WKC
不要直接下载程序以后接自己的 EtherCAT 从站就运行。
另外,baseline 默认让所有 RxPDO 输出保持为 0,本身不包含电机使能和运动控制逻辑,这也是为了尽量把测试变量限制在 EtherCAT 主站实时性本身。
结语
做完这组实验以后,我对“Linux EtherCAT 主站优化”最大的认识发生了一些变化。
一开始很容易把注意力全部放在:
IgH
EtherCAT
网卡驱动
PREEMPT_RT
这些名字上。
真正一路测下来以后,会发现问题其实跨越了好几层:
应用程序实时循环
Linux 调度器
CPU affinity
CPUFreq
中断
workqueue
网卡驱动
DMA
Linux 内核抢占模型
所以一个成熟的 Linux EtherCAT 主站,绝不只是:
把 IgH 编译通过
这么简单。
它更像是一整套从硬件、驱动、Linux 内核到实时应用程序共同组成的系统。
另外,我越来越不建议用“经验参数”做实时优化。
网上经常能够看到类似:
加 isolcpus
关 irqbalance
开 performance
打 PREEMPT_RT
提高线程优先级
这样的配置清单。
这些配置本身未必错。
但如果不知道某项参数为什么改,也没有修改前后的 A/B 数据,那么最后即使系统变好了,也很难形成真正能够迁移到下一块硬件上的能力。
我更推荐的方式是:
一次只改变一个主要变量,然后测量。
能运行,不代表实时。
平均值很低,也不代表稳定。
真正值得关注的是,当系统开始繁忙之后,那些最差的周期究竟发生了什么。
这可能才是 Linux EtherCAT 主站从“能用”走向“工程可用”过程中,最需要解决的问题。
完整代码、测试脚本、驱动补丁、PREEMPT_RT 移植记录以及原始实验数据已经开源:
GitHub:RK3588-Igh-Low-Latency
https://github.com/VictorJiaxinWang/RK3588-Igh-Low-Latency
如果你也在 RK3588、ARM Linux 或其他嵌入式 Linux 平台上做 IgH EtherCAT 主站,希望这个项目能够给你的实时性优化和问题定位提供一些可以直接复现的参考。



