简介:这份资源面向嵌入式与工业自动化方向的开发者,提供将开源SOEM方案移植到STM32平台、构建EtherCAT主站的完整工程代码。EtherCAT作为高精度运动控制领域的实时工业以太网协议,通常依赖Linux主站,而本工程让STM32F767等高性能MCU直接承担主站角色,降低了硬件成本与开发门槛。压缩包共267个文件,约1.63MB,以139个.h头文件与119个.c源文件为主体,涵盖STM32 HAL库外设驱动、以太网MAC与PHY配置、SOEM协议栈适配及RTOS集成代码,另含uvprojx、uvoptx等Keil工程文件、hex烧录固件、dbgconf调试配置与启动汇编文件,可直接编译验证。工程覆盖PDO/SDO通信、PDO映射、中断优先级与任务调度等实时性优化要点,并保留作者调试过程中遇到的问题记录,便于读者对照排查通信异常。目前已有18343人学习下载,适合希望掌握EtherCAT主站移植、伺服电机通信与嵌入式实时以太网开发的工程师参考实践。1. 用一块 STM32 跑通 EtherCAT 主站:这套 SOEM 方案到底能干什么如果你手上有一块带以太网外设的 STM32,比如 F407、F429、F767 或者 H743,又正好想接一台 EtherCAT 伺服电机做位置控制,那这套基于 SOEM 的裸机主站方案值得花一个下午跑一遍。它做的事情很具体:把开源 EtherCAT 主站协议栈 SOEM 移植到 STM32 上,用片内 MAC 加一颗 PHY 芯片组成 EtherCAT 网口,主站周期性地发过程数据帧,从站伺服按周期收位置指令、回状态字和实际位置。整个链路不依赖 PC、不依赖 Linux、不依赖专用主站芯片,一块板子加一根网线就能把伺服转起来。适合谁?做运动控制、机器人关节、多轴同步的嵌入式开发者,尤其是那些被“EtherCAT 主站必须跑在工控机或专用芯片上”这种说法劝退过的人。这套方案把主站压进了 MCU,成本直接砍到几十块钱级别,代价是你要自己管好内存和中断。2. 架构拆解:SOEM 在 STM32 上是怎么落地的2.1 整体分层与数据流这套方案的分层很清晰,从上到下依次是应用层、SOEM 协议栈层、网络适配层和硬件层。应用层负责业务逻辑,比如周期性地写目标位置、读实际位置、判断伺服使能状态。SOEM 层是核心,它处理 EtherCAT 的邮箱通信、过程数据交换、状态机切换和分布式时钟同步。网络适配层是移植的关键,它把 SOEM 需要的原始以太网帧收发接口对接到底层 MAC 驱动上。硬件层就是 STM32 的 ETH 外设加外部 PHY,常见的是 LAN8720 或 DP83848,通过 RMII 接口连接。数据流是这样的:应用层调用ec_send_processdata(),SOEM 把过程数据组装成 EtherCAT 帧,通过网络适配层的发送函数交给 STM32 的 ETH DMA 发出去。帧在 EtherCAT 环网上走一圈,从站伺服在帧经过时把输入数据插入、把输出数据取走。帧回到主站后,ETH 中断触发接收,网络适配层把帧交给 SOEM 的接收处理函数,SOEM 解析后更新内部的过程数据映像。应用层再调用ec_receive_processdata()拿到最新数据。整个过程在一个控制周期内完成,周期通常是 1ms 到 4ms,取决于从站数量和 MCU 主频。这里有个关键点:SOEM 默认是跑在 Linux 或 Windows 上的,它依赖操作系统的 socket 接口和时钟。移植到 STM32 裸机时,必须把这两个依赖替换掉。socket 接口换成 STM32 的 ETH 驱动,时钟换成 MCU 的定时器。这是整个移植工作的核心,也是后面代码讲解的重点。2.2 目录结构与关键文件一份典型的工程目录长这样,我按实际项目里常见的组织方式列出来:EtherCAT_STM32/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f4xx_hal_conf.h │ │ └── ethercat_app.h │ └── Src/ │ ├── main.c │ ├── ethercat_app.c │ └── stm32f4xx_hal_msp.c ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ │ └── CMSIS/ ├── Middlewares/ │ └── SOEM/ │ ├── soem/ │ │ ├── ec_main.c │ │ ├── ec_config.c │ │ ├── ec_coe.c │ │ ├── ec_dc.c │ │ └── oshw/ │ │ ├── oshw.c │ │ └── oshw.h │ └── osal/ │ ├── osal.c │ └── osal.h ├── BSP/ │ ├── lan8720.c │ └── lan8720.h └── MDK-ARM/ └── project.uvprojxMiddlewares/SOEM/soem/下面是协议栈源码,oshw/目录是硬件抽象层,移植时主要改这里。osal/是操作系统抽象层,裸机下用不到太多,但时钟和延时函数要在这里实现。BSP/lan8720.c是 PHY 驱动,负责 PHY 初始化、自协商和链路状态读取。Core/Src/ethercat_app.c是应用层,主站的初始化、配置和周期任务都写在这里。2.3 网络适配层的移植要点SOEM 在oshw.c里定义了几个必须实现的函数,最核心的是oshw_send和oshw_recv。在 Linux 上它们就是sendto和recvfrom,在 STM32 上要换成 ETH 驱动的收发接口。下面是我在实际项目里用的实现方式:// oshw.c - STM32 网络适配层实现 #include "oshw.h" #include "lan8720.h" #include "stm32f4xx_hal.h" extern ETH_HandleTypeDef heth; static uint8_t tx_buffer[EC_BUFSIZE]; static uint8_t rx_buffer[EC_BUFSIZE]; // 发送原始以太网帧 int oshw_send(void *port, const void *data, size_t len) { // 等待上一次发送完成,ETH DMA 发送是异步的 while (heth.TxDesc->Status & ETH_DMATXDESC_OWN) { // 超时保护,避免死等 } memcpy(tx_buffer, data, len); // 把缓冲区地址挂到 DMA 描述符上,启动发送 HAL_ETH_Transmit(&heth, (uint8_t *)tx_buffer, len, 100); return len; } // 接收原始以太网帧 int oshw_recv(void *port, void *data, size_t len) { // 检查接收描述符是否有新帧 if (heth.RxDesc->Status & ETH_DMARXDESC_OWN) { return 0; // 没有新数据 } uint32_t frame_len = (heth.RxDesc->Status & ETH_DMARXDESC_FL) >> 16; if (frame_len > len) { frame_len = len; } memcpy(data, rx_buffer, frame_len); // 释放描述符,让 DMA 继续接收 HAL_ETH_ReleaseRxDescriptor(&heth); return frame_len; }oshw_send里那个while循环是等 DMA 发送完成,实际项目里我会加一个计数器做超时,避免 PHY 链路断了之后死循环。oshw_recv是非阻塞的,没有数据就返回 0,SOEM 上层会自己处理。EC_BUFSIZE一般定义成 1518,够放一个标准以太网帧。时钟部分在osal.c里实现,SOEM 需要微秒级和毫秒级的延时:// osal.c - 时钟与延时实现 #include "osal.h" #include "stm32f4xx_hal.h" // 返回微秒级时间戳,用于 DC 同步计算 uint64_t osal_current_time(void) { // 用 TIM2 做自由运行的微秒计数器 return __HAL_TIM_GET_COUNTER(&htim2); } // 毫秒级延时 void osal_usleep(uint32_t usec) { uint32_t start = __HAL_TIM_GET_COUNTER(&htim2); // 处理计数器溢出,TIM2 配置为 1MHz 时约 71 分钟溢出一次 while ((__HAL_TIM_GET_COUNTER(&htim2) - start) < usec) { // 空转等待 } }osal_current_time返回的是微秒计数,SOEM 的分布式时钟同步算法会用它来算主站和从站之间的时钟偏移。TIM2 要配置成 1MHz 的计数频率,预分频值根据 APB1 时钟算。比如 APB1 是 84MHz,预分频就是 84-1。2.4 主站初始化与从站配置流程初始化流程分几步:先初始化 ETH 外设和 PHY,然后调用ec_init()绑定网口,接着ec_config_init()扫描从站,再ec_config_map()映射过程数据,最后ec_configdc()配置分布式时钟。下面是一个完整的主站初始化函数:// ethercat_app.c - 主站初始化 #include "ethercat_app.h" #include "soem/ethercat.h" #define EC_TIMEOUTMON 500 static char IOmap[4096]; static int expected_slave_count = 1; int ethercat_master_init(void) { // 初始化 ETH 外设和 PHY,这一步在 main.c 里已经做了 // 这里只做 SOEM 层的初始化 if (ec_init("eth0") <= 0) { return -1; // 网口初始化失败 } // 扫描从站,返回找到的从站数量 if (ec_config_init(FALSE) <= 0) { return -2; // 没有找到从站 } // 检查从站数量是否符合预期 if (ec_slavecount != expected_slave_count) { return -3; // 从站数量不对 } // 配置过程数据映射,IOmap 是主站和从站交换数据的缓冲区 ec_config_map(&IOmap); // 配置分布式时钟,让从站时钟和主站对齐 ec_configdc(); // 把主站和所有从站切到 OP 状态 ec_statecheck(0, EC_STATE_OPERATIONAL, EC_TIMEOUTSTATE); return 0; }ec_init的参数是网口名字,在 STM32 上这个名字其实没用,但 SOEM 的接口要求传一个字符串,我一般就写 "eth0"。ec_config_init的FALSE表示不跳过从站扫描,如果从站已经配置好了可以传TRUE加快启动。IOmap的大小要算够,每个从站的过程数据长度加起来,再留点余量。一个典型的伺服从站,输入输出各 32 字节,一个从站就是 64 字节,4096 够接几十个从站了。ec_statecheck是等所有从站进入 OP 状态,超时时间EC_TIMEOUTSTATE默认是 2000ms。如果从站没进 OP,常见原因是 PDO 映射不对或者 DC 配置有问题,后面避坑章节会细说。3. 跑起来:从零搭建工程与周期任务3.1 硬件连接与 PHY 配置硬件上需要一块 STM32 开发板,一颗 LAN8720 PHY 模块,一根网线,一台支持 EtherCAT 的伺服驱动器。接线方面,STM32 的 ETH 外设通过 RMII 接口连 LAN8720,RMII 需要 50MHz 参考时钟,LAN8720 可以自己产生,也可以从 STM32 的 MCO 引脚输出。我一般用 LAN8720 自己产生的 50MHz,省一个引脚。PHY 地址通过 RXER/PHYAD0 引脚上的下拉电阻决定,LAN8720 模块上一般已经焊好了,地址是 0 或 1。STM32 的 ETH 配置用 CubeMX 生成就行,关键是几个参数:PHY 地址要和硬件一致,RMII 模式,自动协商开启,接收和发送描述符数量各给 4 个以上。下面是我常用的 ETH 初始化片段:// lan8720.c - PHY 初始化与链路检测 #include "lan8720.h" void lan8720_init(void) { uint32_t phy_addr = LAN8720_ADDR; uint32_t reg_value = 0; // 软复位 PHY HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_RESET); HAL_Delay(100); // 配置为自动协商,100Mbps 全双工 HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_FULLDUPLEX_100M | PHY_AUTONEGOTIATION); // 重启自动协商 HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_FULLDUPLEX_100M | PHY_AUTONEGOTIATION | PHY_RESTART_AUTONEGOTIATION); // 等待协商完成 do { HAL_ETH_ReadPHYRegister(&heth, phy_addr, PHY_BSR, ®_value); } while (!(reg_value & PHY_AUTONEGO_COMPLETE)); // 读取协商结果,确认链路速度和双工模式 HAL_ETH_ReadPHYRegister(&heth, phy_addr, PHY_BSR, ®_value); if (reg_value & PHY_LINKED_STATUS) { // 链路已建立 } }PHY_BCR是基本控制寄存器,PHY_BSR是基本状态寄存器。自动协商完成后要读一次PHY_BSR确认链路状态,有些 PHY 在协商完成后不会自动更新链路状态位,需要读两次。这个坑我踩过,第一次读到的值可能是旧的。3.2 周期任务与过程数据交换主站跑起来之后,核心就是一个周期性的任务,每个周期做三件事:发过程数据、收过程数据、处理业务逻辑。在裸机上可以用定时器中断触发,也可以在主循环里用 SysTick 做时间片。我一般用 TIM3 做 1ms 中断,在中断里置一个标志位,主循环检测到标志位就执行周期任务。这样中断里不做太多事,避免影响 ETH 中断的响应。// ethercat_app.c - 周期任务 volatile uint8_t ecat_cycle_flag = 0; // TIM3 中断服务函数,1ms 触发一次 void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); ecat_cycle_flag = 1; } } // 主循环里的周期任务 void ethercat_cycle_task(void) { if (!ecat_cycle_flag) { return; } ecat_cycle_flag = 0; // 发送过程数据 ec_send_processdata(); // 接收过程数据,超时时间 200us int wkc = ec_receive_processdata(EC_TIMEOUTRET); // 检查工作计数器,判断从站是否都正常响应 if (wkc < ec_group[0].nslaves) { // 有从站没响应,做错误处理 return; } // 业务逻辑:读实际位置,写目标位置 int32_t actual_pos = *(int32_t *)(ec_slave[1].inputs); int32_t target_pos = actual_pos + 1000; // 每次增加 1000 个脉冲 *(int32_t *)(ec_slave[1].outputs) = target_pos; }ec_send_processdata和ec_receive_processdata是 SOEM 的核心接口,前者把输出数据发出去,后者把输入数据收回来。ec_receive_processdata的参数是超时时间,单位是微秒,EC_TIMEOUTRET默认是 2000。返回值wkc是工作计数器,每个从站正常处理帧后会加 1,如果wkc小于从站数量,说明有从站掉线或没响应。ec_slave[1].inputs和ec_slave[1].outputs是指向过程数据映像的指针,索引 1 表示第一个从站,索引 0 是主站自己。输入和输出的偏移量在ec_config_map之后由 SOEM 自动算好,应用层直接按指针读写就行。这里假设伺服的 PDO 映射里第一个 4 字节是实际位置,第二个 4 字节是目标位置,实际项目里要根据 ESI 文件或驱动器手册确认。3.3 状态机与错误处理EtherCAT 从站有四个状态:INIT、PREOP、SAFEOP、OP。主站要按顺序把从站推到 OP 状态,中间任何一步失败都要能检测到并处理。SOEM 提供了ec_statecheck来检查状态,但实际项目里我会在每个状态切换后都检查一遍,而不是直接跳到 OP。// ethercat_app.c - 状态机切换 int ethercat_set_state(uint16_t slave, uint8_t state) { ec_slave[slave].state = state; ec_writestate(slave); // 等待状态切换完成,超时 2000ms int timeout = 2000; while (timeout > 0) { ec_statecheck(slave, state, 1000); if (ec_slave[slave].state == state) { return 0; } timeout -= 10; HAL_Delay(10); } return -1; // 状态切换超时 } // 完整的启动流程 int ethercat_startup(void) { // 先切到 PREOP,配置邮箱通信 if (ethercat_set_state(0, EC_STATE_PREOP) != 0) { return -1; } // 配置 PDO 映射和 DC ec_config_map(&IOmap); ec_configdc(); // 切到 SAFEOP,此时输入数据有效但输出被禁止 if (ethercat_set_state(0, EC_STATE_SAFEOP) != 0) { return -2; } // 切到 OP,输出使能 if (ethercat_set_state(0, EC_STATE_OP) != 0) { return -3; } return 0; }ec_writestate是请求从站切换状态,ec_statecheck是检查从站当前状态。注意ec_statecheck的第二个参数是超时时间,单位是微秒,这里传 1000 表示等 1ms。实际项目里我会把状态切换的超时设长一点,比如 3000ms,因为有些伺服驱动器上电后需要时间初始化。错误处理方面,至少要监控三个东西:工作计数器wkc、从站状态ec_slave[].state、AL 状态码ec_slave[].ALstatuscode。wkc不对说明通信有问题,state掉了说明从站异常,ALstatuscode非零说明从站内部有错误。这三个都要在周期任务里检查,发现异常就切回 SAFEOP 或 INIT,然后重新走启动流程。4. 避坑与排查:那些让我熬夜的 EtherCAT 问题4.1 从站进不了 OP 状态现象是ec_statecheck一直超时,从站停在 SAFEOP 或者 PREOP。原因通常有三个:PDO 映射不匹配、DC 配置不对、或者从站的输入输出长度和IOmap对不上。解决方法是先用ec_config_map之后打印每个从站的Ibytes和Obytes,确认和 ESI 文件里描述的一致。如果 DC 有问题,先把ec_configdc注释掉,用自由运行模式跑通再开 DC。我遇到过一次是伺服的 PDO 映射里多了一个 2 字节的状态字,导致IOmap偏移全错,从站一直进不了 OP。4.2 工作计数器 wkc 不稳定现象是wkc时对时错,偶尔少 1 或者少 2。原因一般是网络干扰或者 PHY 链路质量差。EtherCAT 对帧的实时性要求高,如果 PHY 的自动协商没完成就发帧,或者网线太长、屏蔽不好,都会导致丢帧。解决方法是先确认 PHY 链路状态稳定,用lan8720的链路检测函数每隔 100ms 读一次PHY_BSR,确认PHY_LINKED_STATUS一直是 1。然后检查网线,EtherCAT 建议用带屏蔽的 CAT5e 以上网线,长度不要超过 100 米。如果还不行,把 ETH 的接收描述符数量从 4 增加到 8,给 DMA 更多缓冲。4.3 DC 同步导致周期抖动现象是开了ec_configdc之后,周期任务的时间抖动变大,有时候会超过 1ms。原因是 DC 同步需要主站和从站的时钟对齐,如果主站的微秒计数器精度不够,或者osal_current_time的实现有累积误差,DC 算法就会不断调整,导致抖动。解决方法是把 TIM2 的计数频率提高到 10MHz,osal_current_time返回的值除以 10 得到微秒。另外ec_configdc之后要调用ec_dcsync0启动同步,同步周期要和主站的任务周期一致。我一般把 DC 同步周期设成和 TIM3 中断周期一样,都是 1ms。4.4 内存不够导致 HardFault现象是ec_config_map之后跑一段时间就进 HardFault。原因是IOmap定义太小,或者 SOEM 内部的缓冲区溢出。SOEM 在ec_config_map时会根据从站数量动态分配一些内存,如果 MCU 的堆空间不够,就会分配失败。解决方法是把IOmap定义成全局数组而不是局部变量,大小按从站数量算够,每个从站至少留 128 字节。另外检查startup_stm32f4xx.s里的堆栈大小,堆至少给 0x1000,栈至少给 0x800。我习惯在ec_config_map之后打印一下ec_slavecount和每个从站的Ibytes、Obytes,确认总长度没超过IOmap的大小。4.5 PHY 初始化失败导致 ping 不通现象是 ETH 初始化之后,PC 和 STM32 之间 ping 不通,SOEM 也找不到从站。原因是 PHY 的复位时序不对,或者 RMII 的 50MHz 时钟没起来。LAN8720 的复位引脚需要至少 100us 的低电平,然后等 100ms 让内部 PLL 锁定。如果 50MHz 时钟是从 STM32 的 MCO 输出的,要确认 MCO 的配置正确,分频系数算对。我一般先用 CubeMX 生成一个最简单的 ETH 回环测试,确认 PHY 能收发帧,再往上跑 SOEM。回环测试就是发一个固定帧,看能不能收到,能收到说明 PHY 和 DMA 都正常。5. 二次开发:把主站改成你自己的运动控制逻辑5.1 从单轴到多轴的扩展这套方案默认是单轴控制,要扩展到多轴,核心是改IOmap的大小和周期任务里的数据读写。每个从站的过程数据长度不一样,要先算总长度。假设每个伺服从站输入 32 字节、输出 32 字节,4 个从站就是 256 字节,IOmap给 1024 字节足够。周期任务里用循环遍历所有从站:// 多轴周期任务 #define AXIS_COUNT 4 void ethercat_multi_axis_task(void) { if (!ecat_cycle_flag) return; ecat_cycle_flag = 0; ec_send_processdata(); int wkc = ec_receive_processdata(EC_TIMEOUTRET); if (wkc < ec_group[0].nslaves) return; for (int i = 1; i <= AXIS_COUNT; i++) { // 每个从站的输入输出指针 int32_t *actual_pos = (int32_t *)ec_slave[i].inputs; int32_t *target_pos = (int32_t *)ec_slave[i].outputs; // 简单的梯形速度规划,每个周期走固定步长 if (*target_pos < target_pos_limit[i]) { *target_pos += speed_step[i]; } } }ec_slave[i].inputs和outputs的偏移量在ec_config_map之后自动算好,不用手动算。多轴的关键是同步,所有轴的过程数据在同一个 EtherCAT 帧里传输,所以它们天然是同步的。如果要插补,就在这个循环里算好每个轴的目标位置,然后一次性写进去。5.2 加入 CiA402 伺服状态机实际伺服驱动器都遵循 CiA402 协议,有使能、运行、故障复位等状态。要控制伺服转起来,必须按 CiA402 的状态机顺序发控制字。下面是一个简化的使能流程:// CiA402 控制字 #define CW_SHUTDOWN 0x0006 #define CW_SWITCH_ON 0x0007 #define CW_ENABLE_OP 0x000F #define CW_FAULT_RESET 0x0080 // 伺服使能流程 int servo_enable(uint16_t slave) { uint16_t *control_word = (uint16_t *)ec_slave[slave].outputs; uint16_t *status_word = (uint16_t *)ec_slave[slave].inputs; // 先发故障复位 *control_word = CW_FAULT_RESET; HAL_Delay(10); // 等状态字确认 if ((*status_word & 0x004F) == 0x0040) { // 故障已复位 } // 发 Shutdown *control_word = CW_SHUTDOWN; HAL_Delay(10); // 发 Switch On *control_word = CW_SWITCH_ON; HAL_Delay(10); // 发 Enable Operation *control_word = CW_ENABLE_OP; HAL_Delay(10); // 检查状态字是否进入 Operation Enabled if ((*status_word & 0x006F) == 0x0027) { return 0; // 使能成功 } return -1; }control_word和status_word的偏移量取决于 PDO 映射,一般控制字在输出的前 2 字节,状态字在输入的前 2 字节。0x004F和0x006F是状态字的掩码,用来提取状态位。这个流程里每个步骤之间要加延时,让伺服有时间响应。实际项目里我会把延时改成状态字轮询,等状态字确认后再发下一步,这样更可靠。5.3 验证方法与调试技巧改完代码之后怎么验证?我一般分三步。第一步,用ec_slavecount确认从站数量对,用ec_slave[].state确认所有从站都进了 OP。第二步,用wkc确认通信稳定,连续跑 10000 个周期,wkc一次都不能错。第三步,用示波器或者逻辑分析仪抓 ETH 的 TX 和 RX 信号,看帧的周期是不是稳定的 1ms,抖动在 100us 以内算合格。调试的时候有个技巧:把ec_slave[].inputs和outputs的内容通过串口打印出来,每个周期打印一次,看数据有没有在变。如果输入数据一直是 0,说明从站没在发数据,检查 PDO 映射和从站状态。如果输出数据写了但伺服不动,检查 CiA402 状态机是不是没走到 Operation Enabled。串口打印会占用时间,建议只在调试时开,正式跑的时候关掉。5.4 一个我踩过的坑有一次我把IOmap定义成了局部数组,放在main函数里,结果ec_config_map之后跑了几秒钟就 HardFault。查了半天发现是局部数组在栈上,SOEM 内部保存了IOmap的指针,函数返回后栈被复用,指针就悬空了。从那以后我每次定义IOmap都强制加static,或者直接放全局。这个坑不大,但很隐蔽,因为编译不会报错,运行时才出问题。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源面向嵌入式与工业自动化方向的开发者,提供将开源SOEM方案移植到STM32平台、构建EtherCAT主站的完整工程代码。EtherCAT作为高精度运动控制领域的实时工业以太网协议,通常依赖Linux主站,而本工程让STM32F767等高性能MCU直接承担主站角色,降低了硬件成本与开发门槛。压缩包共267个文件,约1.63MB,以139个.h头文件与119个.c源文件为主体,涵盖STM32 HAL库外设驱动、以太网MAC与PHY配置、SOEM协议栈适配及RTOS集成代码,另含uvprojx、uvoptx等Keil工程文件、hex烧录固件、dbgconf调试配置与启动汇编文件,可直接编译验证。工程覆盖PDO/SDO通信、PDO映射、中断优先级与任务调度等实时性优化要点,并保留作者调试过程中遇到的问题记录,便于读者对照排查通信异常。目前已有18343人学习下载,适合希望掌握EtherCAT主站移植、伺服电机通信与嵌入式实时以太网开发的工程师参考实践。1. 用一块 STM32 跑通 EtherCAT 主站:这套 SOEM 方案到底能干什么如果你手上有一块带以太网外设的 STM32,比如 F407、F429、F767 或者 H743,又正好想接一台 EtherCAT 伺服电机做位置控制,那这套基于 SOEM 的裸机主站方案值得花一个下午跑一遍。它做的事情很具体:把开源 EtherCAT 主站协议栈 SOEM 移植到 STM32 上,用片内 MAC 加一颗 PHY 芯片组成 EtherCAT 网口,主站周期性地发过程数据帧,从站伺服按周期收位置指令、回状态字和实际位置。整个链路不依赖 PC、不依赖 Linux、不依赖专用主站芯片,一块板子加一根网线就能把伺服转起来。适合谁?做运动控制、机器人关节、多轴同步的嵌入式开发者,尤其是那些被“EtherCAT 主站必须跑在工控机或专用芯片上”这种说法劝退过的人。这套方案把主站压进了 MCU,成本直接砍到几十块钱级别,代价是你要自己管好内存和中断。2. 架构拆解:SOEM 在 STM32 上是怎么落地的2.1 整体分层与数据流这套方案的分层很清晰,从上到下依次是应用层、SOEM 协议栈层、网络适配层和硬件层。应用层负责业务逻辑,比如周期性地写目标位置、读实际位置、判断伺服使能状态。SOEM 层是核心,它处理 EtherCAT 的邮箱通信、过程数据交换、状态机切换和分布式时钟同步。网络适配层是移植的关键,它把 SOEM 需要的原始以太网帧收发接口对接到底层 MAC 驱动上。硬件层就是 STM32 的 ETH 外设加外部 PHY,常见的是 LAN8720 或 DP83848,通过 RMII 接口连接。数据流是这样的:应用层调用ec_send_processdata(),SOEM 把过程数据组装成 EtherCAT 帧,通过网络适配层的发送函数交给 STM32 的 ETH DMA 发出去。帧在 EtherCAT 环网上走一圈,从站伺服在帧经过时把输入数据插入、把输出数据取走。帧回到主站后,ETH 中断触发接收,网络适配层把帧交给 SOEM 的接收处理函数,SOEM 解析后更新内部的过程数据映像。应用层再调用ec_receive_processdata()拿到最新数据。整个过程在一个控制周期内完成,周期通常是 1ms 到 4ms,取决于从站数量和 MCU 主频。这里有个关键点:SOEM 默认是跑在 Linux 或 Windows 上的,它依赖操作系统的 socket 接口和时钟。移植到 STM32 裸机时,必须把这两个依赖替换掉。socket 接口换成 STM32 的 ETH 驱动,时钟换成 MCU 的定时器。这是整个移植工作的核心,也是后面代码讲解的重点。2.2 目录结构与关键文件一份典型的工程目录长这样,我按实际项目里常见的组织方式列出来:EtherCAT_STM32/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f4xx_hal_conf.h │ │ └── ethercat_app.h │ └── Src/ │ ├── main.c │ ├── ethercat_app.c │ └── stm32f4xx_hal_msp.c ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ │ └── CMSIS/ ├── Middlewares/ │ └── SOEM/ │ ├── soem/ │ │ ├── ec_main.c │ │ ├── ec_config.c │ │ ├── ec_coe.c │ │ ├── ec_dc.c │ │ └── oshw/ │ │ ├── oshw.c │ │ └── oshw.h │ └── osal/ │ ├── osal.c │ └── osal.h ├── BSP/ │ ├── lan8720.c │ └── lan8720.h └── MDK-ARM/ └── project.uvprojxMiddlewares/SOEM/soem/下面是协议栈源码,oshw/目录是硬件抽象层,移植时主要改这里。osal/是操作系统抽象层,裸机下用不到太多,但时钟和延时函数要在这里实现。BSP/lan8720.c是 PHY 驱动,负责 PHY 初始化、自协商和链路状态读取。Core/Src/ethercat_app.c是应用层,主站的初始化、配置和周期任务都写在这里。2.3 网络适配层的移植要点SOEM 在oshw.c里定义了几个必须实现的函数,最核心的是oshw_send和oshw_recv。在 Linux 上它们就是sendto和recvfrom,在 STM32 上要换成 ETH 驱动的收发接口。下面是我在实际项目里用的实现方式:// oshw.c - STM32 网络适配层实现 #include "oshw.h" #include "lan8720.h" #include "stm32f4xx_hal.h" extern ETH_HandleTypeDef heth; static uint8_t tx_buffer[EC_BUFSIZE]; static uint8_t rx_buffer[EC_BUFSIZE]; // 发送原始以太网帧 int oshw_send(void *port, const void *data, size_t len) { // 等待上一次发送完成,ETH DMA 发送是异步的 while (heth.TxDesc->Status & ETH_DMATXDESC_OWN) { // 超时保护,避免死等 } memcpy(tx_buffer, data, len); // 把缓冲区地址挂到 DMA 描述符上,启动发送 HAL_ETH_Transmit(&heth, (uint8_t *)tx_buffer, len, 100); return len; } // 接收原始以太网帧 int oshw_recv(void *port, void *data, size_t len) { // 检查接收描述符是否有新帧 if (heth.RxDesc->Status & ETH_DMARXDESC_OWN) { return 0; // 没有新数据 } uint32_t frame_len = (heth.RxDesc->Status & ETH_DMARXDESC_FL) >> 16; if (frame_len > len) { frame_len = len; } memcpy(data, rx_buffer, frame_len); // 释放描述符,让 DMA 继续接收 HAL_ETH_ReleaseRxDescriptor(&heth); return frame_len; }oshw_send里那个while循环是等 DMA 发送完成,实际项目里我会加一个计数器做超时,避免 PHY 链路断了之后死循环。oshw_recv是非阻塞的,没有数据就返回 0,SOEM 上层会自己处理。EC_BUFSIZE一般定义成 1518,够放一个标准以太网帧。时钟部分在osal.c里实现,SOEM 需要微秒级和毫秒级的延时:// osal.c - 时钟与延时实现 #include "osal.h" #include "stm32f4xx_hal.h" // 返回微秒级时间戳,用于 DC 同步计算 uint64_t osal_current_time(void) { // 用 TIM2 做自由运行的微秒计数器 return __HAL_TIM_GET_COUNTER(&htim2); } // 毫秒级延时 void osal_usleep(uint32_t usec) { uint32_t start = __HAL_TIM_GET_COUNTER(&htim2); // 处理计数器溢出,TIM2 配置为 1MHz 时约 71 分钟溢出一次 while ((__HAL_TIM_GET_COUNTER(&htim2) - start) < usec) { // 空转等待 } }osal_current_time返回的是微秒计数,SOEM 的分布式时钟同步算法会用它来算主站和从站之间的时钟偏移。TIM2 要配置成 1MHz 的计数频率,预分频值根据 APB1 时钟算。比如 APB1 是 84MHz,预分频就是 84-1。2.4 主站初始化与从站配置流程初始化流程分几步:先初始化 ETH 外设和 PHY,然后调用ec_init()绑定网口,接着ec_config_init()扫描从站,再ec_config_map()映射过程数据,最后ec_configdc()配置分布式时钟。下面是一个完整的主站初始化函数:// ethercat_app.c - 主站初始化 #include "ethercat_app.h" #include "soem/ethercat.h" #define EC_TIMEOUTMON 500 static char IOmap[4096]; static int expected_slave_count = 1; int ethercat_master_init(void) { // 初始化 ETH 外设和 PHY,这一步在 main.c 里已经做了 // 这里只做 SOEM 层的初始化 if (ec_init("eth0") <= 0) { return -1; // 网口初始化失败 } // 扫描从站,返回找到的从站数量 if (ec_config_init(FALSE) <= 0) { return -2; // 没有找到从站 } // 检查从站数量是否符合预期 if (ec_slavecount != expected_slave_count) { return -3; // 从站数量不对 } // 配置过程数据映射,IOmap 是主站和从站交换数据的缓冲区 ec_config_map(&IOmap); // 配置分布式时钟,让从站时钟和主站对齐 ec_configdc(); // 把主站和所有从站切到 OP 状态 ec_statecheck(0, EC_STATE_OPERATIONAL, EC_TIMEOUTSTATE); return 0; }ec_init的参数是网口名字,在 STM32 上这个名字其实没用,但 SOEM 的接口要求传一个字符串,我一般就写 "eth0"。ec_config_init的FALSE表示不跳过从站扫描,如果从站已经配置好了可以传TRUE加快启动。IOmap的大小要算够,每个从站的过程数据长度加起来,再留点余量。一个典型的伺服从站,输入输出各 32 字节,一个从站就是 64 字节,4096 够接几十个从站了。ec_statecheck是等所有从站进入 OP 状态,超时时间EC_TIMEOUTSTATE默认是 2000ms。如果从站没进 OP,常见原因是 PDO 映射不对或者 DC 配置有问题,后面避坑章节会细说。3. 跑起来:从零搭建工程与周期任务3.1 硬件连接与 PHY 配置硬件上需要一块 STM32 开发板,一颗 LAN8720 PHY 模块,一根网线,一台支持 EtherCAT 的伺服驱动器。接线方面,STM32 的 ETH 外设通过 RMII 接口连 LAN8720,RMII 需要 50MHz 参考时钟,LAN8720 可以自己产生,也可以从 STM32 的 MCO 引脚输出。我一般用 LAN8720 自己产生的 50MHz,省一个引脚。PHY 地址通过 RXER/PHYAD0 引脚上的下拉电阻决定,LAN8720 模块上一般已经焊好了,地址是 0 或 1。STM32 的 ETH 配置用 CubeMX 生成就行,关键是几个参数:PHY 地址要和硬件一致,RMII 模式,自动协商开启,接收和发送描述符数量各给 4 个以上。下面是我常用的 ETH 初始化片段:// lan8720.c - PHY 初始化与链路检测 #include "lan8720.h" void lan8720_init(void) { uint32_t phy_addr = LAN8720_ADDR; uint32_t reg_value = 0; // 软复位 PHY HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_RESET); HAL_Delay(100); // 配置为自动协商,100Mbps 全双工 HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_FULLDUPLEX_100M | PHY_AUTONEGOTIATION); // 重启自动协商 HAL_ETH_WritePHYRegister(&heth, phy_addr, PHY_BCR, PHY_FULLDUPLEX_100M | PHY_AUTONEGOTIATION | PHY_RESTART_AUTONEGOTIATION); // 等待协商完成 do { HAL_ETH_ReadPHYRegister(&heth, phy_addr, PHY_BSR, ®_value); } while (!(reg_value & PHY_AUTONEGO_COMPLETE)); // 读取协商结果,确认链路速度和双工模式 HAL_ETH_ReadPHYRegister(&heth, phy_addr, PHY_BSR, ®_value); if (reg_value & PHY_LINKED_STATUS) { // 链路已建立 } }PHY_BCR是基本控制寄存器,PHY_BSR是基本状态寄存器。自动协商完成后要读一次PHY_BSR确认链路状态,有些 PHY 在协商完成后不会自动更新链路状态位,需要读两次。这个坑我踩过,第一次读到的值可能是旧的。3.2 周期任务与过程数据交换主站跑起来之后,核心就是一个周期性的任务,每个周期做三件事:发过程数据、收过程数据、处理业务逻辑。在裸机上可以用定时器中断触发,也可以在主循环里用 SysTick 做时间片。我一般用 TIM3 做 1ms 中断,在中断里置一个标志位,主循环检测到标志位就执行周期任务。这样中断里不做太多事,避免影响 ETH 中断的响应。// ethercat_app.c - 周期任务 volatile uint8_t ecat_cycle_flag = 0; // TIM3 中断服务函数,1ms 触发一次 void TIM3_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(&htim3, TIM_FLAG_UPDATE)) { __HAL_TIM_CLEAR_FLAG(&htim3, TIM_FLAG_UPDATE); ecat_cycle_flag = 1; } } // 主循环里的周期任务 void ethercat_cycle_task(void) { if (!ecat_cycle_flag) { return; } ecat_cycle_flag = 0; // 发送过程数据 ec_send_processdata(); // 接收过程数据,超时时间 200us int wkc = ec_receive_processdata(EC_TIMEOUTRET); // 检查工作计数器,判断从站是否都正常响应 if (wkc < ec_group[0].nslaves) { // 有从站没响应,做错误处理 return; } // 业务逻辑:读实际位置,写目标位置 int32_t actual_pos = *(int32_t *)(ec_slave[1].inputs); int32_t target_pos = actual_pos + 1000; // 每次增加 1000 个脉冲 *(int32_t *)(ec_slave[1].outputs) = target_pos; }ec_send_processdata和ec_receive_processdata是 SOEM 的核心接口,前者把输出数据发出去,后者把输入数据收回来。ec_receive_processdata的参数是超时时间,单位是微秒,EC_TIMEOUTRET默认是 2000。返回值wkc是工作计数器,每个从站正常处理帧后会加 1,如果wkc小于从站数量,说明有从站掉线或没响应。ec_slave[1].inputs和ec_slave[1].outputs是指向过程数据映像的指针,索引 1 表示第一个从站,索引 0 是主站自己。输入和输出的偏移量在ec_config_map之后由 SOEM 自动算好,应用层直接按指针读写就行。这里假设伺服的 PDO 映射里第一个 4 字节是实际位置,第二个 4 字节是目标位置,实际项目里要根据 ESI 文件或驱动器手册确认。3.3 状态机与错误处理EtherCAT 从站有四个状态:INIT、PREOP、SAFEOP、OP。主站要按顺序把从站推到 OP 状态,中间任何一步失败都要能检测到并处理。SOEM 提供了ec_statecheck来检查状态,但实际项目里我会在每个状态切换后都检查一遍,而不是直接跳到 OP。// ethercat_app.c - 状态机切换 int ethercat_set_state(uint16_t slave, uint8_t state) { ec_slave[slave].state = state; ec_writestate(slave); // 等待状态切换完成,超时 2000ms int timeout = 2000; while (timeout > 0) { ec_statecheck(slave, state, 1000); if (ec_slave[slave].state == state) { return 0; } timeout -= 10; HAL_Delay(10); } return -1; // 状态切换超时 } // 完整的启动流程 int ethercat_startup(void) { // 先切到 PREOP,配置邮箱通信 if (ethercat_set_state(0, EC_STATE_PREOP) != 0) { return -1; } // 配置 PDO 映射和 DC ec_config_map(&IOmap); ec_configdc(); // 切到 SAFEOP,此时输入数据有效但输出被禁止 if (ethercat_set_state(0, EC_STATE_SAFEOP) != 0) { return -2; } // 切到 OP,输出使能 if (ethercat_set_state(0, EC_STATE_OP) != 0) { return -3; } return 0; }ec_writestate是请求从站切换状态,ec_statecheck是检查从站当前状态。注意ec_statecheck的第二个参数是超时时间,单位是微秒,这里传 1000 表示等 1ms。实际项目里我会把状态切换的超时设长一点,比如 3000ms,因为有些伺服驱动器上电后需要时间初始化。错误处理方面,至少要监控三个东西:工作计数器wkc、从站状态ec_slave[].state、AL 状态码ec_slave[].ALstatuscode。wkc不对说明通信有问题,state掉了说明从站异常,ALstatuscode非零说明从站内部有错误。这三个都要在周期任务里检查,发现异常就切回 SAFEOP 或 INIT,然后重新走启动流程。4. 避坑与排查:那些让我熬夜的 EtherCAT 问题4.1 从站进不了 OP 状态现象是ec_statecheck一直超时,从站停在 SAFEOP 或者 PREOP。原因通常有三个:PDO 映射不匹配、DC 配置不对、或者从站的输入输出长度和IOmap对不上。解决方法是先用ec_config_map之后打印每个从站的Ibytes和Obytes,确认和 ESI 文件里描述的一致。如果 DC 有问题,先把ec_configdc注释掉,用自由运行模式跑通再开 DC。我遇到过一次是伺服的 PDO 映射里多了一个 2 字节的状态字,导致IOmap偏移全错,从站一直进不了 OP。4.2 工作计数器 wkc 不稳定现象是wkc时对时错,偶尔少 1 或者少 2。原因一般是网络干扰或者 PHY 链路质量差。EtherCAT 对帧的实时性要求高,如果 PHY 的自动协商没完成就发帧,或者网线太长、屏蔽不好,都会导致丢帧。解决方法是先确认 PHY 链路状态稳定,用lan8720的链路检测函数每隔 100ms 读一次PHY_BSR,确认PHY_LINKED_STATUS一直是 1。然后检查网线,EtherCAT 建议用带屏蔽的 CAT5e 以上网线,长度不要超过 100 米。如果还不行,把 ETH 的接收描述符数量从 4 增加到 8,给 DMA 更多缓冲。4.3 DC 同步导致周期抖动现象是开了ec_configdc之后,周期任务的时间抖动变大,有时候会超过 1ms。原因是 DC 同步需要主站和从站的时钟对齐,如果主站的微秒计数器精度不够,或者osal_current_time的实现有累积误差,DC 算法就会不断调整,导致抖动。解决方法是把 TIM2 的计数频率提高到 10MHz,osal_current_time返回的值除以 10 得到微秒。另外ec_configdc之后要调用ec_dcsync0启动同步,同步周期要和主站的任务周期一致。我一般把 DC 同步周期设成和 TIM3 中断周期一样,都是 1ms。4.4 内存不够导致 HardFault现象是ec_config_map之后跑一段时间就进 HardFault。原因是IOmap定义太小,或者 SOEM 内部的缓冲区溢出。SOEM 在ec_config_map时会根据从站数量动态分配一些内存,如果 MCU 的堆空间不够,就会分配失败。解决方法是把IOmap定义成全局数组而不是局部变量,大小按从站数量算够,每个从站至少留 128 字节。另外检查startup_stm32f4xx.s里的堆栈大小,堆至少给 0x1000,栈至少给 0x800。我习惯在ec_config_map之后打印一下ec_slavecount和每个从站的Ibytes、Obytes,确认总长度没超过IOmap的大小。4.5 PHY 初始化失败导致 ping 不通现象是 ETH 初始化之后,PC 和 STM32 之间 ping 不通,SOEM 也找不到从站。原因是 PHY 的复位时序不对,或者 RMII 的 50MHz 时钟没起来。LAN8720 的复位引脚需要至少 100us 的低电平,然后等 100ms 让内部 PLL 锁定。如果 50MHz 时钟是从 STM32 的 MCO 输出的,要确认 MCO 的配置正确,分频系数算对。我一般先用 CubeMX 生成一个最简单的 ETH 回环测试,确认 PHY 能收发帧,再往上跑 SOEM。回环测试就是发一个固定帧,看能不能收到,能收到说明 PHY 和 DMA 都正常。5. 二次开发:把主站改成你自己的运动控制逻辑5.1 从单轴到多轴的扩展这套方案默认是单轴控制,要扩展到多轴,核心是改IOmap的大小和周期任务里的数据读写。每个从站的过程数据长度不一样,要先算总长度。假设每个伺服从站输入 32 字节、输出 32 字节,4 个从站就是 256 字节,IOmap给 1024 字节足够。周期任务里用循环遍历所有从站:// 多轴周期任务 #define AXIS_COUNT 4 void ethercat_multi_axis_task(void) { if (!ecat_cycle_flag) return; ecat_cycle_flag = 0; ec_send_processdata(); int wkc = ec_receive_processdata(EC_TIMEOUTRET); if (wkc < ec_group[0].nslaves) return; for (int i = 1; i <= AXIS_COUNT; i++) { // 每个从站的输入输出指针 int32_t *actual_pos = (int32_t *)ec_slave[i].inputs; int32_t *target_pos = (int32_t *)ec_slave[i].outputs; // 简单的梯形速度规划,每个周期走固定步长 if (*target_pos < target_pos_limit[i]) { *target_pos += speed_step[i]; } } }ec_slave[i].inputs和outputs的偏移量在ec_config_map之后自动算好,不用手动算。多轴的关键是同步,所有轴的过程数据在同一个 EtherCAT 帧里传输,所以它们天然是同步的。如果要插补,就在这个循环里算好每个轴的目标位置,然后一次性写进去。5.2 加入 CiA402 伺服状态机实际伺服驱动器都遵循 CiA402 协议,有使能、运行、故障复位等状态。要控制伺服转起来,必须按 CiA402 的状态机顺序发控制字。下面是一个简化的使能流程:// CiA402 控制字 #define CW_SHUTDOWN 0x0006 #define CW_SWITCH_ON 0x0007 #define CW_ENABLE_OP 0x000F #define CW_FAULT_RESET 0x0080 // 伺服使能流程 int servo_enable(uint16_t slave) { uint16_t *control_word = (uint16_t *)ec_slave[slave].outputs; uint16_t *status_word = (uint16_t *)ec_slave[slave].inputs; // 先发故障复位 *control_word = CW_FAULT_RESET; HAL_Delay(10); // 等状态字确认 if ((*status_word & 0x004F) == 0x0040) { // 故障已复位 } // 发 Shutdown *control_word = CW_SHUTDOWN; HAL_Delay(10); // 发 Switch On *control_word = CW_SWITCH_ON; HAL_Delay(10); // 发 Enable Operation *control_word = CW_ENABLE_OP; HAL_Delay(10); // 检查状态字是否进入 Operation Enabled if ((*status_word & 0x006F) == 0x0027) { return 0; // 使能成功 } return -1; }control_word和status_word的偏移量取决于 PDO 映射,一般控制字在输出的前 2 字节,状态字在输入的前 2 字节。0x004F和0x006F是状态字的掩码,用来提取状态位。这个流程里每个步骤之间要加延时,让伺服有时间响应。实际项目里我会把延时改成状态字轮询,等状态字确认后再发下一步,这样更可靠。5.3 验证方法与调试技巧改完代码之后怎么验证?我一般分三步。第一步,用ec_slavecount确认从站数量对,用ec_slave[].state确认所有从站都进了 OP。第二步,用wkc确认通信稳定,连续跑 10000 个周期,wkc一次都不能错。第三步,用示波器或者逻辑分析仪抓 ETH 的 TX 和 RX 信号,看帧的周期是不是稳定的 1ms,抖动在 100us 以内算合格。调试的时候有个技巧:把ec_slave[].inputs和outputs的内容通过串口打印出来,每个周期打印一次,看数据有没有在变。如果输入数据一直是 0,说明从站没在发数据,检查 PDO 映射和从站状态。如果输出数据写了但伺服不动,检查 CiA402 状态机是不是没走到 Operation Enabled。串口打印会占用时间,建议只在调试时开,正式跑的时候关掉。5.4 一个我踩过的坑有一次我把IOmap定义成了局部数组,放在main函数里,结果ec_config_map之后跑了几秒钟就 HardFault。查了半天发现是局部数组在栈上,SOEM 内部保存了IOmap的指针,函数返回后栈被复用,指针就悬空了。从那以后我每次定义IOmap都强制加static,或者直接放全局。这个坑不大,但很隐蔽,因为编译不会报错,运行时才出问题。希望帮到你。本文还有配套的精品资源,点击获取