简介:这份资源面向嵌入式工程师与运动控制开发者,提供将开源SOEM协议栈移植到STM32平台、构建EtherCAT主站的完整工程源码,可用于伺服电机等从站设备的实时通信与运动控制开发。压缩包共267个文件,以139个.h头文件与119个.c源文件为主体,涵盖STM32F7系列HAL库驱动、以太网MAC配置、SOEM协议栈适配及RTOS集成代码,另含uvprojx与uvoptx工程文件、hex烧录固件、s汇编启动文件及bat编译脚本,整体约1.63MB,可直接用Keil打开编译。资源完整呈现了从硬件选型、驱动开发、实时性优化到PDO/SDO通信测试与问题调试的技术路径,并保留作者调试过程中遇到的bug与排错思路,便于读者理解主站与从站通信的完整链路。目前已有18343人学习下载,适合具备一定STM32与以太网基础的开发者参考移植与二次开发。1. 用一块 STM32 跑通 EtherCAT 主站:这套 SOEM 方案到底能干什么如果你手上有一块 STM32 开发板、一台支持 EtherCAT 的伺服电机,却卡在“主站怎么搭”这一步,那这套基于 SOEM 的方案基本就是为你准备的。EtherCAT 在工业现场总线里属于硬实时那一档,传统做法是上专用主站芯片或者跑在带网卡的工控机上,成本高、门槛也高。而这个项目把开源 SOEM 协议栈移植到 STM32 上,用片内以太网外设直接收发 EtherCAT 帧,让一块几十块钱的 MCU 也能当主站去扫描从站、配置 PDO、周期发指令控制伺服电机。它适合两类人:一类是想搞懂 EtherCAT 主站底层到底怎么跑起来的嵌入式开发者,另一类是想用低成本方案做多轴运动控制、又不想被商业主站卡脖子的工程人员。下面我按“能干什么、怎么跑、怎么改”的顺序,把这份源码拆开讲。2. 架构拆解:SOEM 协议栈怎么塞进 STM32 的以太网外设2.1 整体分层与数据流这套方案的核心思路是把 SOEM 当成一个纯软件协议栈,底层网卡驱动换成 STM32 的 ETH 外设。SOEM 本身是跨平台的,它把和硬件相关的部分抽象成几个函数:网卡初始化、帧发送、帧接收、帧缓冲管理。移植的本质就是把这几个函数用 STM32 的 HAL 库或者寄存器操作实现出来。整个数据流是这样的:应用层调用ec_send_processdata()和ec_receive_processdata(),SOEM 把要发的过程数据打包成 EtherCAT 帧,交给底层osal层的发送函数,最终通过 STM32 的 ETH DMA 发出去。从站返回的帧被 ETH 接收中断捕获,存进环形缓冲区,SOEM 在下一个周期从缓冲区取走解析。这里有个关键点:EtherCAT 要求主站和从站时钟同步,STM32 这边靠定时器产生周期中断来驱动整个循环,周期抖动直接决定控制效果。常见做法是把主站循环放在一个高优先级定时器中断里,或者用 RTOS 的任务加精确延时。裸机跑的话,主循环里轮询定时器标志位也行,但周期稳定性会差一些。2.2 目录结构与关键文件拿到源码后,先看目录结构,心里有个地图:project/ ├── soem/ # SOEM 协议栈源码 │ ├── src/ │ │ ├── ec_main.c # 主站状态机、配置流程 │ │ ├── ec_config.c # 从站配置、PDO 映射 │ │ ├── ec_coe.c # CANopen over EtherCAT,SDO 读写 │ │ └── ec_dc.c # 分布式时钟 │ ├── oshw/ # 硬件抽象层 │ │ └── nicdrv.c # 网卡驱动接口,移植重点 │ └── osal/ # 操作系统抽象层 │ └── oshw.c # 定时、锁、线程接口 ├── bsp/ │ ├── eth.c # STM32 ETH 初始化、DMA 描述符 │ ├── eth_rx.c # 接收中断、帧缓冲 │ └── timer.c # 周期定时器 ├── app/ │ ├── main.c # 主站初始化、周期循环 │ └── servo_ctrl.c # 伺服电机控制逻辑 └── ...nicdrv.c是移植的重头戏,里面定义了nicdrv_init、nicdrv_send、nicdrv_recv这些函数。STM32 这边对应的实现要处理 ETH DMA 描述符链、接收中断、以及帧的进出队列。osal层里oshm.c提供定时器和互斥锁,裸机环境下用 SysTick 和关中断实现即可。2.3 网卡驱动移植的关键代码下面这段是 STM32 ETH 发送函数的典型实现,我按 SOEM 的接口要求写:/* nicdrv.c 中 STM32 发送实现 */ int nicdrv_send(void *port, void *data, int length) { uint8_t *buf = (uint8_t *)data; ETH_BufferTypeDef tx_buf; HAL_StatusTypeDef ret; /* 等待上一次发送完成,避免 DMA 描述符被覆盖 */ while (g_eth_tx_busy) { /* 可以加超时计数,防止死等 */ } g_eth_tx_busy = 1; /* 把 SOEM 的帧拷进 ETH 发送缓冲,注意长度对齐 */ memcpy(g_eth_tx_buffer, buf, length); tx_buf.buffer = g_eth_tx_buffer; tx_buf.len = length; tx_buf.next = NULL; ret = HAL_ETH_Transmit_IT(&heth, &tx_buf); if (ret != HAL_OK) { g_eth_tx_busy = 0; return 0; /* 返回 0 表示发送失败,SOEM 会重试 */ } return length; }逻辑说明:SOEM 调用发送函数时传入的是已经封装好的 EtherCAT 帧,长度通常是 64 到 1500 字节。这里用HAL_ETH_Transmit_IT启动中断方式发送,发送完成中断里把g_eth_tx_busy清零。参数port在单网口场景下忽略,data指向帧缓冲,length是帧长。注意 EtherCAT 帧最小 64 字节,如果 SOEM 传下来的帧不足 64,底层要补齐,否则有些 PHY 会丢弃。接收侧更关键,因为 EtherCAT 要求快速响应。我一般用接收中断加环形缓冲:/* eth_rx.c 接收中断处理 */ void ETH_IRQHandler(void) { HAL_ETH_IRQHandler(&heth); } /* 接收完成回调,HAL 库会调用 */ void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { uint32_t len = heth->RxDescList.RxDataLength; uint8_t *buf = (uint8_t *)heth->RxDescList.RxBuffer; /* 把帧塞进环形缓冲,供 SOEM 轮询取走 */ ringbuf_push(&g_ecat_rx_ring, buf, len); /* 重新挂接收描述符,准备下一帧 */ HAL_ETH_Receive_IT(heth, &g_rx_buf); }这里环形缓冲的大小要算好,EtherCAT 周期通常 1ms 甚至更短,如果 SOEM 取帧不及时,缓冲会溢出。我一般给 8 到 16 个帧槽位,每个槽位 1518 字节。ringbuf_push里要关中断保护,防止和 SOEM 取数据冲突。2.4 主站状态机与配置流程SOEM 的主站启动流程是固定的:初始化网卡、扫描从站、配置从站、映射 PDO、进入 OP 状态。对应到代码里:/* main.c 主站初始化 */ void ecat_master_init(void) { /* 1. 初始化网卡硬件 */ nicdrv_init(0); /* 2. 扫描从站,返回找到的从站数量 */ int slave_count = ec_config_init(FALSE); if (slave_count == 0) { printf("no slave found\r\n"); return; } /* 3. 配置从站,映射 PDO 到 IOmap */ ec_config_map(&IOmap); /* 4. 配置分布式时钟,如果从站支持 */ ec_configdc(); /* 5. 请求进入 OP 状态 */ ec_statecheck(0, EC_STATE_OPERATIONAL, EC_TIMEOUTSTATE); /* 6. 发送一次过程数据,让从站开始工作 */ ec_send_processdata(); ec_receive_processdata(EC_TIMEOUTRET); }参数说明:ec_config_init(FALSE)里的 FALSE 表示不强制所有从站都进入 INIT 状态,一般扫描阶段用 FALSE。IOmap是一块全局内存,SOEM 把从站的输入输出数据按偏移映射进去,应用层直接读写这块内存就能控制伺服。ec_statecheck的第三个参数是超时时间,单位是微秒,通常给 50000 也就是 50ms。这段跑通后,从站应该进入 OP 状态,伺服电机的使能、位置指令都可以通过 IOmap 操作了。3. 跑起来:从零搭建 STM32 EtherCAT 主站的完整步骤3.1 硬件选型与接线先说硬件。STM32 这边需要带 ETH 外设的型号,比如 STM32F407、F767、H743 这些,片内 MAC 加外部 PHY 芯片(常见 LAN8720、DP83848)。PHY 和 STM32 之间用 RMII 接口,25MHz 时钟。EtherCAT 从站这边,伺服驱动器或者耦合器都行,我用的是支持 CANopen over EtherCAT 的伺服。接线注意:STM32 的 ETH 出来是标准以太网,EtherCAT 物理层也是标准以太网 PHY,所以直连就行。但 EtherCAT 帧类型是 0x88A4,普通交换机不认,所以主站和从站之间不要接普通交换机,直接网线对接,或者用支持 EtherCAT 的专用交换机。提示:PHY 的地址配置别搞错,LAN8720 的 PHYAD 由引脚电平决定,配错了 MDIO 读不到 ID,初始化直接卡死。3.2 工程编译与烧录源码一般给的是 Keil 或者 STM32CubeIDE 工程。以 CubeIDE 为例,导入工程后先检查几个地方:第一,ETH 的引脚配置。CubeMX 生成的代码里,RMII 的引脚要和你板子对上,REF_CLK、MDIO、MDC、TXD0、TXD1、RXD0、RXD1、CRS_DV 一个都不能错。第二,PHY 地址。在ethernetif.c或者eth.c里找到 PHY 地址定义,改成你板子上的实际值。第三,堆栈大小。SOEM 里有些函数用递归,栈小了会 HardFault。我一般把主栈设到 0x2000 也就是 8KB 以上。编译通过后烧录,接上串口看打印。正常的话会输出扫描到的从站数量、从站名称、PDO 映射结果。如果卡在ec_config_init不动,八成是网卡收发没通,先拿示波器看 PHY 的 TX_CLK 有没有 25MHz 输出。3.3 周期任务与实时性调优主站跑起来后,核心就是周期任务。下面是一个典型的 1ms 周期循环:/* 定时器中断里置标志,主循环处理 */ volatile uint8_t g_cycle_flag = 0; void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); g_cycle_flag = 1; } } /* 主循环 */ while (1) { if (g_cycle_flag) { g_cycle_flag = 0; /* 1. 读从站输入,更新到 IOmap */ ec_receive_processdata(EC_TIMEOUTRET); /* 2. 执行控制算法,写输出到 IOmap */ servo_ctrl_update(); /* 3. 发送过程数据 */ ec_send_processdata(); /* 4. 检查从站状态,掉线了要处理 */ ec_statecheck(0, EC_STATE_OPERATIONAL, 1000); } }逻辑说明:ec_receive_processdata的超时参数EC_TIMEOUTRET一般是 2000 微秒,意思是等从站返回帧最多等 2ms。servo_ctrl_update里做位置环或者速度环计算,直接读写 IOmap 里对应的偏移。ec_statecheck每周期查一次状态,如果从站掉线会返回非 OP 状态,这时候要停使能、报警。实时性调优有几个血泪经验:第一,ec_send_processdata和ec_receive_processdata之间不要插耗时操作,否则周期抖动大。第二,ETH 接收中断优先级要高于定时器中断,不然收帧不及时。第三,如果周期小于 500us,裸机轮询可能扛不住,得上 RTOS 把主站任务优先级拉到最高。3.4 伺服电机控制的 PDO 映射控制伺服的核心是 PDO 映射。以常见的 CiA402 伺服为例,输出 PDO 通常包含控制字、目标位置、目标速度,输入 PDO 包含状态字、实际位置、实际速度。SOEM 里通过 SDO 读写来配置映射:/* 配置从站 1 的 PDO 映射,索引 0x1C12 是输出 PDO 映射 */ void servo_pdo_config(uint16_t slave) { uint8_t u8; uint16_t u16; uint32_t u32; /* 先停掉 PDO 分配,写 0 到 0x1C12 子索引 0 */ u8 = 0; ec_SDOwrite(slave, 0x1C12, 0x00, FALSE, sizeof(u8), &u8, EC_TIMEOUTRXM); /* 把 0x1600 映射对象写进 0x1C12 子索引 1 */ u16 = 0x1600; ec_SDOwrite(slave, 0x1C12, 0x01, FALSE, sizeof(u16), &u16, EC_TIMEOUTRXM); /* 重新使能 PDO 分配,子索引 0 写 1 */ u8 = 1; ec_SDOwrite(slave, 0x1C12, 0x00, FALSE, sizeof(u8), &u8, EC_TIMEOUTRXM); /* 配置 0x1600 的内容:控制字 0x6040、目标位置 0x607A */ u32 = 0x60400010; /* 控制字,16 位 */ ec_SDOwrite(slave, 0x1600, 0x01, FALSE, sizeof(u32), &u32, EC_TIMEOUTRXM); u32 = 0x607A0020; /* 目标位置,32 位 */ ec_SDOwrite(slave, 0x1600, 0x02, FALSE, sizeof(u32), &u32, EC_TIMEOUTRXM); }参数说明:ec_SDOwrite的第二个参数是对象索引,第三个是子索引,第四个FALSE表示不检查完整访问。0x1C12是输出 PDO 分配对象,0x1600是映射对象。每个映射条目是一个 32 位数,高 16 位是对象索引,低 16 位是位长度。比如0x60400010表示索引 0x6040、长度 16 位。配置完 PDO 后,ec_config_map会把映射好的数据按顺序排进 IOmap。应用层通过IOmap的偏移去写控制字和目标位置,伺服就动起来了。4. 避坑与排查:主站跑不起来的几个典型现场4.1 扫描不到从站现象:串口打印no slave found,或者ec_config_init返回 0。原因:最常见的是 PHY 没通,或者 RMII 时钟不对。STM32 的 ETH 需要 50MHz 参考时钟,如果 PHY 提供 25MHz 给 STM32,STM32 内部 PLL 倍频到 50MHz,这个配置在 CubeMX 里要选对。另一个原因是 EtherCAT 帧类型过滤没开,STM32 的 ETH 默认可能只收单播,EtherCAT 用的是广播或者多播,MAC 过滤要设成接收所有帧。解决:先用 ping 或者 ARP 测试底层收发通不通,再抓包看有没有 0x88A4 的帧。如果底层通但 SOEM 扫不到,检查nicdrv_recv返回的帧有没有被正确解析。4.2 从站进不了 OP 状态现象:ec_statecheck一直返回 PREOP 或者 SAFEOP,进不了 OP。原因:PDO 映射不对,或者从站不支持分布式时钟但主站强制开了 DC。还有一种情况是 SM 同步管理器配置和从站实际不符,比如从站要求 SM2 是输出、SM3 是输入,配反了就进不了 OP。解决:先用ec_config_init后的打印看从站支持的 SM 和 FMMU 数量,再对照从站手册确认 PDO 映射。如果从站不支持 DC,把ec_configdc去掉。4.3 周期抖动大导致伺服报警现象:伺服偶尔报位置偏差过大或者跟随误差,主站周期看起来是 1ms 但实际抖动到 1.5ms。原因:主循环里插了打印或者 Flash 操作,ETH 中断优先级低于定时器中断导致收帧延迟,或者ec_receive_processdata超时时间设太短。解决:把打印移到低优先级任务或者缓冲后统一输出,ETH 中断优先级设成最高,EC_TIMEOUTRET适当加大到 2000 以上。如果还不行,考虑用 DMA 发送代替中断发送,减少 CPU 占用。4.4 HardFault 随机出现现象:跑一段时间后 HardFault,看门狗复位。原因:栈溢出,或者 IOmap 越界访问。SOEM 里ec_config_map会往 IOmap 里写数据,如果 IOmap 定义太小,从站多了就写穿。另一个常见原因是中断里调用了非可重入函数。解决:把 IOmap 按从站数量算好大小,一般每个从站预留 256 字节。栈设到 8KB 以上。中断里只做标志置位和缓冲搬运,复杂处理放主循环。4.5 SDO 读写超时现象:ec_SDOwrite返回 0,超时。原因:从站还没进 PREOP 以上状态,SDO 通道没建立。或者 SDO 超时时间太短,从站响应慢。解决:确保在ec_config_init之后再调 SDO,超时参数EC_TIMEOUTRXM给到 700ms 以上。如果从站支持 Complete Access,用TRUE参数一次性读写,减少交互次数。5. 二次开发:把主站改造成你自己的运动控制平台5.1 从单轴到多轴的扩展思路这套代码默认可能只控制一个从站,实际项目里多轴联动才是常态。扩展的核心是 IOmap 的偏移管理。SOEM 在ec_config_map之后会把每个从站的输入输出按顺序排进 IOmap,你可以通过ec_slave[slave].Obytes和Ibytes拿到每个从站的字节数,然后自己算偏移。我一般会封装一个结构体数组,每个轴对应一个:typedef struct { uint16_t slave_idx; /* 从站在总线上的序号 */ uint16_t *control_word; /* 指向 IOmap 里的控制字 */ int32_t *target_pos; /* 指向目标位置 */ uint16_t *status_word; /* 指向状态字 */ int32_t *actual_pos; /* 指向实际位置 */ } axis_t; axis_t g_axes[MAX_AXIS]; void axis_bind(void) { for (int i = 0; i < MAX_AXIS; i++) { g_axes[i].control_word = (uint16_t *)(IOmap + ec_slave[i+1].Ostart); g_axes[i].target_pos = (int32_t *)(IOmap + ec_slave[i+1].Ostart + 2); g_axes[i].status_word = (uint16_t *)(IOmap + ec_slave[i+1].Istart); g_axes[i].actual_pos = (int32_t *)(IOmap + ec_slave[i+1].Istart + 2); } }这样每个周期直接操作g_axes[i].target_pos就行,不用关心底层偏移。多轴插补的话,在servo_ctrl_update里算好每个轴的目标位置,统一写进去,然后一次ec_send_processdata发出去。5.2 加入 RTOS 提升实时性裸机跑 1ms 周期还行,但要做轨迹规划、加减速、多轴插补,主循环里事情就多了。这时候上 FreeRTOS 或者 RT-Thread,把 EtherCAT 周期任务设成最高优先级,轨迹规划放低优先级任务,通过队列传目标位置。注意 RTOS 下osal层的定时和锁要用 RTOS 的 API 替换。SOEM 的osal_timer可以用xTaskGetTickCount实现,互斥锁用二值信号量。ETH 接收中断里不要调 RTOS 的 API,用FromISR版本或者只置标志。5.3 验证改造是否成功改完之后怎么验证?我一般分三步:第一步,用示波器抓 ETH 的 TX_EN 和 REF_CLK,看周期是否稳定。正常 1ms 周期下,TX_EN 的脉冲间隔应该是 1ms 加减几十微秒。第二步,在 IOmap 里放一个计数器,每个周期加一,从站那边如果能读到这个计数器并且值连续,说明过程数据通道没问题。第三步,给伺服发一个阶跃位置指令,用驱动器自带的示波器功能看位置响应,超调、跟随误差都在合理范围就说明控制链路通了。5.4 一个容易忽略的细节:DC 同步如果从站支持分布式时钟,一定要开 DC。开了之后主站会周期性发 ARMW 帧来同步从站时钟,这时候主站的周期抖动要求更高,因为从站是跟着主站时钟走的。我踩过的坑是:开了 DC 但主站周期不稳定,从站时钟跟着抖,伺服直接报同步错误。后来把主站周期中断优先级拉到最高,关掉所有无关中断,才稳定下来。从那以后我每次改完主站代码,都强制走一遍“示波器看周期、IOmap 看数据、伺服看响应”这三步,不敢跳。希望这套拆解能帮你少走点弯路,把 STM32 主站跑起来。本文还有配套的精品资源,点击获取
简介:这份资源面向嵌入式工程师与运动控制开发者,提供将开源SOEM协议栈移植到STM32平台、构建EtherCAT主站的完整工程源码,可用于伺服电机等从站设备的实时通信与运动控制开发。压缩包共267个文件,以139个.h头文件与119个.c源文件为主体,涵盖STM32F7系列HAL库驱动、以太网MAC配置、SOEM协议栈适配及RTOS集成代码,另含uvprojx与uvoptx工程文件、hex烧录固件、s汇编启动文件及bat编译脚本,整体约1.63MB,可直接用Keil打开编译。资源完整呈现了从硬件选型、驱动开发、实时性优化到PDO/SDO通信测试与问题调试的技术路径,并保留作者调试过程中遇到的bug与排错思路,便于读者理解主站与从站通信的完整链路。目前已有18343人学习下载,适合具备一定STM32与以太网基础的开发者参考移植与二次开发。1. 用一块 STM32 跑通 EtherCAT 主站:这套 SOEM 方案到底能干什么如果你手上有一块 STM32 开发板、一台支持 EtherCAT 的伺服电机,却卡在“主站怎么搭”这一步,那这套基于 SOEM 的方案基本就是为你准备的。EtherCAT 在工业现场总线里属于硬实时那一档,传统做法是上专用主站芯片或者跑在带网卡的工控机上,成本高、门槛也高。而这个项目把开源 SOEM 协议栈移植到 STM32 上,用片内以太网外设直接收发 EtherCAT 帧,让一块几十块钱的 MCU 也能当主站去扫描从站、配置 PDO、周期发指令控制伺服电机。它适合两类人:一类是想搞懂 EtherCAT 主站底层到底怎么跑起来的嵌入式开发者,另一类是想用低成本方案做多轴运动控制、又不想被商业主站卡脖子的工程人员。下面我按“能干什么、怎么跑、怎么改”的顺序,把这份源码拆开讲。2. 架构拆解:SOEM 协议栈怎么塞进 STM32 的以太网外设2.1 整体分层与数据流这套方案的核心思路是把 SOEM 当成一个纯软件协议栈,底层网卡驱动换成 STM32 的 ETH 外设。SOEM 本身是跨平台的,它把和硬件相关的部分抽象成几个函数:网卡初始化、帧发送、帧接收、帧缓冲管理。移植的本质就是把这几个函数用 STM32 的 HAL 库或者寄存器操作实现出来。整个数据流是这样的:应用层调用ec_send_processdata()和ec_receive_processdata(),SOEM 把要发的过程数据打包成 EtherCAT 帧,交给底层osal层的发送函数,最终通过 STM32 的 ETH DMA 发出去。从站返回的帧被 ETH 接收中断捕获,存进环形缓冲区,SOEM 在下一个周期从缓冲区取走解析。这里有个关键点:EtherCAT 要求主站和从站时钟同步,STM32 这边靠定时器产生周期中断来驱动整个循环,周期抖动直接决定控制效果。常见做法是把主站循环放在一个高优先级定时器中断里,或者用 RTOS 的任务加精确延时。裸机跑的话,主循环里轮询定时器标志位也行,但周期稳定性会差一些。2.2 目录结构与关键文件拿到源码后,先看目录结构,心里有个地图:project/ ├── soem/ # SOEM 协议栈源码 │ ├── src/ │ │ ├── ec_main.c # 主站状态机、配置流程 │ │ ├── ec_config.c # 从站配置、PDO 映射 │ │ ├── ec_coe.c # CANopen over EtherCAT,SDO 读写 │ │ └── ec_dc.c # 分布式时钟 │ ├── oshw/ # 硬件抽象层 │ │ └── nicdrv.c # 网卡驱动接口,移植重点 │ └── osal/ # 操作系统抽象层 │ └── oshw.c # 定时、锁、线程接口 ├── bsp/ │ ├── eth.c # STM32 ETH 初始化、DMA 描述符 │ ├── eth_rx.c # 接收中断、帧缓冲 │ └── timer.c # 周期定时器 ├── app/ │ ├── main.c # 主站初始化、周期循环 │ └── servo_ctrl.c # 伺服电机控制逻辑 └── ...nicdrv.c是移植的重头戏,里面定义了nicdrv_init、nicdrv_send、nicdrv_recv这些函数。STM32 这边对应的实现要处理 ETH DMA 描述符链、接收中断、以及帧的进出队列。osal层里oshm.c提供定时器和互斥锁,裸机环境下用 SysTick 和关中断实现即可。2.3 网卡驱动移植的关键代码下面这段是 STM32 ETH 发送函数的典型实现,我按 SOEM 的接口要求写:/* nicdrv.c 中 STM32 发送实现 */ int nicdrv_send(void *port, void *data, int length) { uint8_t *buf = (uint8_t *)data; ETH_BufferTypeDef tx_buf; HAL_StatusTypeDef ret; /* 等待上一次发送完成,避免 DMA 描述符被覆盖 */ while (g_eth_tx_busy) { /* 可以加超时计数,防止死等 */ } g_eth_tx_busy = 1; /* 把 SOEM 的帧拷进 ETH 发送缓冲,注意长度对齐 */ memcpy(g_eth_tx_buffer, buf, length); tx_buf.buffer = g_eth_tx_buffer; tx_buf.len = length; tx_buf.next = NULL; ret = HAL_ETH_Transmit_IT(&heth, &tx_buf); if (ret != HAL_OK) { g_eth_tx_busy = 0; return 0; /* 返回 0 表示发送失败,SOEM 会重试 */ } return length; }逻辑说明:SOEM 调用发送函数时传入的是已经封装好的 EtherCAT 帧,长度通常是 64 到 1500 字节。这里用HAL_ETH_Transmit_IT启动中断方式发送,发送完成中断里把g_eth_tx_busy清零。参数port在单网口场景下忽略,data指向帧缓冲,length是帧长。注意 EtherCAT 帧最小 64 字节,如果 SOEM 传下来的帧不足 64,底层要补齐,否则有些 PHY 会丢弃。接收侧更关键,因为 EtherCAT 要求快速响应。我一般用接收中断加环形缓冲:/* eth_rx.c 接收中断处理 */ void ETH_IRQHandler(void) { HAL_ETH_IRQHandler(&heth); } /* 接收完成回调,HAL 库会调用 */ void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { uint32_t len = heth->RxDescList.RxDataLength; uint8_t *buf = (uint8_t *)heth->RxDescList.RxBuffer; /* 把帧塞进环形缓冲,供 SOEM 轮询取走 */ ringbuf_push(&g_ecat_rx_ring, buf, len); /* 重新挂接收描述符,准备下一帧 */ HAL_ETH_Receive_IT(heth, &g_rx_buf); }这里环形缓冲的大小要算好,EtherCAT 周期通常 1ms 甚至更短,如果 SOEM 取帧不及时,缓冲会溢出。我一般给 8 到 16 个帧槽位,每个槽位 1518 字节。ringbuf_push里要关中断保护,防止和 SOEM 取数据冲突。2.4 主站状态机与配置流程SOEM 的主站启动流程是固定的:初始化网卡、扫描从站、配置从站、映射 PDO、进入 OP 状态。对应到代码里:/* main.c 主站初始化 */ void ecat_master_init(void) { /* 1. 初始化网卡硬件 */ nicdrv_init(0); /* 2. 扫描从站,返回找到的从站数量 */ int slave_count = ec_config_init(FALSE); if (slave_count == 0) { printf("no slave found\r\n"); return; } /* 3. 配置从站,映射 PDO 到 IOmap */ ec_config_map(&IOmap); /* 4. 配置分布式时钟,如果从站支持 */ ec_configdc(); /* 5. 请求进入 OP 状态 */ ec_statecheck(0, EC_STATE_OPERATIONAL, EC_TIMEOUTSTATE); /* 6. 发送一次过程数据,让从站开始工作 */ ec_send_processdata(); ec_receive_processdata(EC_TIMEOUTRET); }参数说明:ec_config_init(FALSE)里的 FALSE 表示不强制所有从站都进入 INIT 状态,一般扫描阶段用 FALSE。IOmap是一块全局内存,SOEM 把从站的输入输出数据按偏移映射进去,应用层直接读写这块内存就能控制伺服。ec_statecheck的第三个参数是超时时间,单位是微秒,通常给 50000 也就是 50ms。这段跑通后,从站应该进入 OP 状态,伺服电机的使能、位置指令都可以通过 IOmap 操作了。3. 跑起来:从零搭建 STM32 EtherCAT 主站的完整步骤3.1 硬件选型与接线先说硬件。STM32 这边需要带 ETH 外设的型号,比如 STM32F407、F767、H743 这些,片内 MAC 加外部 PHY 芯片(常见 LAN8720、DP83848)。PHY 和 STM32 之间用 RMII 接口,25MHz 时钟。EtherCAT 从站这边,伺服驱动器或者耦合器都行,我用的是支持 CANopen over EtherCAT 的伺服。接线注意:STM32 的 ETH 出来是标准以太网,EtherCAT 物理层也是标准以太网 PHY,所以直连就行。但 EtherCAT 帧类型是 0x88A4,普通交换机不认,所以主站和从站之间不要接普通交换机,直接网线对接,或者用支持 EtherCAT 的专用交换机。提示:PHY 的地址配置别搞错,LAN8720 的 PHYAD 由引脚电平决定,配错了 MDIO 读不到 ID,初始化直接卡死。3.2 工程编译与烧录源码一般给的是 Keil 或者 STM32CubeIDE 工程。以 CubeIDE 为例,导入工程后先检查几个地方:第一,ETH 的引脚配置。CubeMX 生成的代码里,RMII 的引脚要和你板子对上,REF_CLK、MDIO、MDC、TXD0、TXD1、RXD0、RXD1、CRS_DV 一个都不能错。第二,PHY 地址。在ethernetif.c或者eth.c里找到 PHY 地址定义,改成你板子上的实际值。第三,堆栈大小。SOEM 里有些函数用递归,栈小了会 HardFault。我一般把主栈设到 0x2000 也就是 8KB 以上。编译通过后烧录,接上串口看打印。正常的话会输出扫描到的从站数量、从站名称、PDO 映射结果。如果卡在ec_config_init不动,八成是网卡收发没通,先拿示波器看 PHY 的 TX_CLK 有没有 25MHz 输出。3.3 周期任务与实时性调优主站跑起来后,核心就是周期任务。下面是一个典型的 1ms 周期循环:/* 定时器中断里置标志,主循环处理 */ volatile uint8_t g_cycle_flag = 0; void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); g_cycle_flag = 1; } } /* 主循环 */ while (1) { if (g_cycle_flag) { g_cycle_flag = 0; /* 1. 读从站输入,更新到 IOmap */ ec_receive_processdata(EC_TIMEOUTRET); /* 2. 执行控制算法,写输出到 IOmap */ servo_ctrl_update(); /* 3. 发送过程数据 */ ec_send_processdata(); /* 4. 检查从站状态,掉线了要处理 */ ec_statecheck(0, EC_STATE_OPERATIONAL, 1000); } }逻辑说明:ec_receive_processdata的超时参数EC_TIMEOUTRET一般是 2000 微秒,意思是等从站返回帧最多等 2ms。servo_ctrl_update里做位置环或者速度环计算,直接读写 IOmap 里对应的偏移。ec_statecheck每周期查一次状态,如果从站掉线会返回非 OP 状态,这时候要停使能、报警。实时性调优有几个血泪经验:第一,ec_send_processdata和ec_receive_processdata之间不要插耗时操作,否则周期抖动大。第二,ETH 接收中断优先级要高于定时器中断,不然收帧不及时。第三,如果周期小于 500us,裸机轮询可能扛不住,得上 RTOS 把主站任务优先级拉到最高。3.4 伺服电机控制的 PDO 映射控制伺服的核心是 PDO 映射。以常见的 CiA402 伺服为例,输出 PDO 通常包含控制字、目标位置、目标速度,输入 PDO 包含状态字、实际位置、实际速度。SOEM 里通过 SDO 读写来配置映射:/* 配置从站 1 的 PDO 映射,索引 0x1C12 是输出 PDO 映射 */ void servo_pdo_config(uint16_t slave) { uint8_t u8; uint16_t u16; uint32_t u32; /* 先停掉 PDO 分配,写 0 到 0x1C12 子索引 0 */ u8 = 0; ec_SDOwrite(slave, 0x1C12, 0x00, FALSE, sizeof(u8), &u8, EC_TIMEOUTRXM); /* 把 0x1600 映射对象写进 0x1C12 子索引 1 */ u16 = 0x1600; ec_SDOwrite(slave, 0x1C12, 0x01, FALSE, sizeof(u16), &u16, EC_TIMEOUTRXM); /* 重新使能 PDO 分配,子索引 0 写 1 */ u8 = 1; ec_SDOwrite(slave, 0x1C12, 0x00, FALSE, sizeof(u8), &u8, EC_TIMEOUTRXM); /* 配置 0x1600 的内容:控制字 0x6040、目标位置 0x607A */ u32 = 0x60400010; /* 控制字,16 位 */ ec_SDOwrite(slave, 0x1600, 0x01, FALSE, sizeof(u32), &u32, EC_TIMEOUTRXM); u32 = 0x607A0020; /* 目标位置,32 位 */ ec_SDOwrite(slave, 0x1600, 0x02, FALSE, sizeof(u32), &u32, EC_TIMEOUTRXM); }参数说明:ec_SDOwrite的第二个参数是对象索引,第三个是子索引,第四个FALSE表示不检查完整访问。0x1C12是输出 PDO 分配对象,0x1600是映射对象。每个映射条目是一个 32 位数,高 16 位是对象索引,低 16 位是位长度。比如0x60400010表示索引 0x6040、长度 16 位。配置完 PDO 后,ec_config_map会把映射好的数据按顺序排进 IOmap。应用层通过IOmap的偏移去写控制字和目标位置,伺服就动起来了。4. 避坑与排查:主站跑不起来的几个典型现场4.1 扫描不到从站现象:串口打印no slave found,或者ec_config_init返回 0。原因:最常见的是 PHY 没通,或者 RMII 时钟不对。STM32 的 ETH 需要 50MHz 参考时钟,如果 PHY 提供 25MHz 给 STM32,STM32 内部 PLL 倍频到 50MHz,这个配置在 CubeMX 里要选对。另一个原因是 EtherCAT 帧类型过滤没开,STM32 的 ETH 默认可能只收单播,EtherCAT 用的是广播或者多播,MAC 过滤要设成接收所有帧。解决:先用 ping 或者 ARP 测试底层收发通不通,再抓包看有没有 0x88A4 的帧。如果底层通但 SOEM 扫不到,检查nicdrv_recv返回的帧有没有被正确解析。4.2 从站进不了 OP 状态现象:ec_statecheck一直返回 PREOP 或者 SAFEOP,进不了 OP。原因:PDO 映射不对,或者从站不支持分布式时钟但主站强制开了 DC。还有一种情况是 SM 同步管理器配置和从站实际不符,比如从站要求 SM2 是输出、SM3 是输入,配反了就进不了 OP。解决:先用ec_config_init后的打印看从站支持的 SM 和 FMMU 数量,再对照从站手册确认 PDO 映射。如果从站不支持 DC,把ec_configdc去掉。4.3 周期抖动大导致伺服报警现象:伺服偶尔报位置偏差过大或者跟随误差,主站周期看起来是 1ms 但实际抖动到 1.5ms。原因:主循环里插了打印或者 Flash 操作,ETH 中断优先级低于定时器中断导致收帧延迟,或者ec_receive_processdata超时时间设太短。解决:把打印移到低优先级任务或者缓冲后统一输出,ETH 中断优先级设成最高,EC_TIMEOUTRET适当加大到 2000 以上。如果还不行,考虑用 DMA 发送代替中断发送,减少 CPU 占用。4.4 HardFault 随机出现现象:跑一段时间后 HardFault,看门狗复位。原因:栈溢出,或者 IOmap 越界访问。SOEM 里ec_config_map会往 IOmap 里写数据,如果 IOmap 定义太小,从站多了就写穿。另一个常见原因是中断里调用了非可重入函数。解决:把 IOmap 按从站数量算好大小,一般每个从站预留 256 字节。栈设到 8KB 以上。中断里只做标志置位和缓冲搬运,复杂处理放主循环。4.5 SDO 读写超时现象:ec_SDOwrite返回 0,超时。原因:从站还没进 PREOP 以上状态,SDO 通道没建立。或者 SDO 超时时间太短,从站响应慢。解决:确保在ec_config_init之后再调 SDO,超时参数EC_TIMEOUTRXM给到 700ms 以上。如果从站支持 Complete Access,用TRUE参数一次性读写,减少交互次数。5. 二次开发:把主站改造成你自己的运动控制平台5.1 从单轴到多轴的扩展思路这套代码默认可能只控制一个从站,实际项目里多轴联动才是常态。扩展的核心是 IOmap 的偏移管理。SOEM 在ec_config_map之后会把每个从站的输入输出按顺序排进 IOmap,你可以通过ec_slave[slave].Obytes和Ibytes拿到每个从站的字节数,然后自己算偏移。我一般会封装一个结构体数组,每个轴对应一个:typedef struct { uint16_t slave_idx; /* 从站在总线上的序号 */ uint16_t *control_word; /* 指向 IOmap 里的控制字 */ int32_t *target_pos; /* 指向目标位置 */ uint16_t *status_word; /* 指向状态字 */ int32_t *actual_pos; /* 指向实际位置 */ } axis_t; axis_t g_axes[MAX_AXIS]; void axis_bind(void) { for (int i = 0; i < MAX_AXIS; i++) { g_axes[i].control_word = (uint16_t *)(IOmap + ec_slave[i+1].Ostart); g_axes[i].target_pos = (int32_t *)(IOmap + ec_slave[i+1].Ostart + 2); g_axes[i].status_word = (uint16_t *)(IOmap + ec_slave[i+1].Istart); g_axes[i].actual_pos = (int32_t *)(IOmap + ec_slave[i+1].Istart + 2); } }这样每个周期直接操作g_axes[i].target_pos就行,不用关心底层偏移。多轴插补的话,在servo_ctrl_update里算好每个轴的目标位置,统一写进去,然后一次ec_send_processdata发出去。5.2 加入 RTOS 提升实时性裸机跑 1ms 周期还行,但要做轨迹规划、加减速、多轴插补,主循环里事情就多了。这时候上 FreeRTOS 或者 RT-Thread,把 EtherCAT 周期任务设成最高优先级,轨迹规划放低优先级任务,通过队列传目标位置。注意 RTOS 下osal层的定时和锁要用 RTOS 的 API 替换。SOEM 的osal_timer可以用xTaskGetTickCount实现,互斥锁用二值信号量。ETH 接收中断里不要调 RTOS 的 API,用FromISR版本或者只置标志。5.3 验证改造是否成功改完之后怎么验证?我一般分三步:第一步,用示波器抓 ETH 的 TX_EN 和 REF_CLK,看周期是否稳定。正常 1ms 周期下,TX_EN 的脉冲间隔应该是 1ms 加减几十微秒。第二步,在 IOmap 里放一个计数器,每个周期加一,从站那边如果能读到这个计数器并且值连续,说明过程数据通道没问题。第三步,给伺服发一个阶跃位置指令,用驱动器自带的示波器功能看位置响应,超调、跟随误差都在合理范围就说明控制链路通了。5.4 一个容易忽略的细节:DC 同步如果从站支持分布式时钟,一定要开 DC。开了之后主站会周期性发 ARMW 帧来同步从站时钟,这时候主站的周期抖动要求更高,因为从站是跟着主站时钟走的。我踩过的坑是:开了 DC 但主站周期不稳定,从站时钟跟着抖,伺服直接报同步错误。后来把主站周期中断优先级拉到最高,关掉所有无关中断,才稳定下来。从那以后我每次改完主站代码,都强制走一遍“示波器看周期、IOmap 看数据、伺服看响应”这三步,不敢跳。希望这套拆解能帮你少走点弯路,把 STM32 主站跑起来。本文还有配套的精品资源,点击获取