1. 项目缘起:一个经典嵌入式网络方案的再审视
最近在整理一个老项目的技术文档,核心平台是STM32F107,搭配DM9161以太网PHY芯片,跑着FreeRTOS和LWIP协议栈。这套组合在十年前是相当经典的嵌入式网络解决方案,很多工业控制、数据采集设备都这么干。现在回头看,虽然主控和PHY都算不上最新,但其中涉及的网络协议栈移植、驱动适配、实时系统下的网络任务调度,依然是嵌入式工程师绕不开的“硬骨头”。网上相关的资料不少,但要么是零散的代码片段,要么是只讲理论不接地气。今天我就结合自己当年踩过的坑和后续维护的经验,把这套方案的里里外外、从硬件连接到软件调试,系统地拆解一遍。无论你是正在做类似项目的新手,还是想优化老代码的老鸟,希望这些从实战中总结出来的细节,能帮你少走点弯路。
这套方案的核心价值在于,它提供了一个在资源受限的MCU上实现稳定、可靠TCP/IP通信的完整范例。STM32F107自带以太网MAC控制器,DM9161是成熟可靠的10/100M PHY,FreeRTOS提供任务调度和资源管理,LWIP则负责实现复杂的网络协议。听起来像是“标准答案”,但真正把它们揉在一起,让系统稳定跑起来,中间有太多细节需要打磨。比如,中断服务程序(ISR)里该怎么喂狗?LWIP的内存池(memp)和内存堆(mem)怎么配置才不爆?FreeRTOS的任务优先级和栈空间该如何分配才能保证网络响应又不影响其他功能?这些都不是配置向导能一键生成的,需要你对每一层都有清晰的认识。
2. 硬件基石:STM32F107与DM9161的电路设计与调试要点
硬件是软件跑起来的前提,尤其是网络部分,硬件设计上的小瑕疵可能导致软件调试陷入绝境。STM32F107的以太网模块(MAC)需要通过一个标准化的介质独立接口(MII)或简化独立接口(RMII)连接到外部的PHY芯片,我们用的DM9161同时支持这两种模式。
2.1 接口模式选择与电路连接
当时我们选择的是RMII模式。原因很简单:RMII只需要7根数据线(2根收、2根发、1个参考时钟、1个载波侦听、1个收发使能),比MII的16根线节省了大量IO口,这对于IO资源并不算特别宽裕的STM32F107来说是个重要优势。但选择RMII,就意味着对时钟信号的要求更高。
这里有个关键点:RMII的参考时钟(REF_CLK)必须是50MHz的连续时钟,而且要求非常稳定。这个时钟可以由PHY(DM9161)提供,也可以由外部晶振或MCU提供。我们的设计是让DM9161提供REF_CLK。因此,在原理图上,需要确保DM9161的XTL1和XTL2引脚接上了25MHz的无源晶振,并且其CLK_OUT引脚(REF_CLK输出)正确连接到了STM32F107的ETH_RMII_REF_CLK引脚。PCB布局时,这组时钟线要尽量短,远离高频噪声源,并做好阻抗控制。
注意:曾经遇到过板子焊接好后,网络时通时断,ping包丢包率极高。用示波器抓取REF_CLK信号,发现波形畸变严重,边沿有振铃。最后排查是时钟线走线过长且靠近了一个开关电源的电感。重新调整布局后问题解决。所以,RMII模式下的时钟信号质量,是硬件调试的第一道坎。
除了时钟,电源和复位也要留意。DM9161通常需要3.3V的IO电源(VDDIO)和1.2V或2.5V的内核电源(VDDC)。要确保上电时序和电压纹波符合数据手册要求。复位引脚(RST)建议通过一个RC电路连接到MCU的GPIO,实现上电复位和软件可控复位,这在驱动调试阶段非常有用。
2.2 网络变压器与RJ45接口
DM9161的模拟收发线路(TX±, RX±)需要通过网络变压器(也叫以太网变压器或PHY变压器)耦合到RJ45接口。这个变压器的作用是隔离、阻抗匹配和信号整形。选型时要注意其共模抑制比和带宽。连接时,变压器的中心抽头接法要正确,通常需要连接到经过滤波的3.3V电源(VDD33),并通过电容耦合到地,以提供共模偏置。
一个容易忽略的细节是终端匹配电阻。在RMII的接收数据线(RXD0, RXD1)上,靠近STM32F107引脚端,通常需要串联一个33欧姆左右的电阻(具体值根据PCB阻抗调整),并在对地接一个15pF左右的电容,用于抑制信号反射,改善信号完整性。这个在官方评估板原理图上通常能找到参考设计,自己画板时千万别省。
3. 软件架构搭建:FreeRTOS与LWIP的初始化与集成
硬件准备妥当后,就进入了软件世界。我们的目标是让FreeRTOS和LWIP在STM32F107上和谐共处,为应用提供网络服务。
3.1 FreeRTOS的移植与基础配置
FreeRTOS的移植已经非常成熟,对于Cortex-M3内核的STM32F107,主要就是实现几个与处理器架构相关的函数,集中在port.c和portmacro.h文件里。现在STM32CubeMX工具可以一键生成FreeRTOS工程,大大降低了入门门槛。但生成后的配置,需要我们根据实际项目调整。
首先是FreeRTOSConfig.h这个配置文件。里面有几个参数至关重要:
configTOTAL_HEAP_SIZE:这是FreeRTOS动态内存堆的总大小。所有任务栈、队列、信号量等内核对象都从这里分配。对于要运行LWIP的系统,这个值不能太小。我当时给了30KB((30 * 1024)),这包括了网络任务栈和LWIP内部的部分内存需求。你需要根据任务数量、栈深度和LWIP内存用量来估算。configUSE_PREEMPTION和configUSE_TIME_SLICING:通常使能可抢占式和时间片轮转调度,让系统响应更及时。configMAX_PRIORITIES:最大优先级数。网络相关任务(如LWIP的tcpip_thread)需要较高的优先级,以确保网络数据包能得到及时处理,但也不能太高而阻塞关键的控制任务。我们通常将网络任务设为中等偏高优先级。
创建第一个任务——通常是网络初始化任务或主应用任务。在任务中,我们进行硬件初始化和LWIP的启动。
3.2 LWIP的移植与底层驱动对接
LWIP的移植,核心是实现一个名为ethernetif.c的网卡接口文件。这个文件是LWIP协议栈与你的具体硬件(STM32F107 MAC + DM9161 PHY)之间的桥梁。它需要完成以下几件事:
- 底层初始化(
low_level_init):初始化STM32F107的以太网MAC外设。包括配置时钟、GPIO(复用为RMII功能)、MAC工作模式(全双工、100M速率等)、DMA描述符。最重要的是配置DMA描述符环(Rx/Tx Descriptor List),这是MAC和CPU内存之间交换数据包的核心数据结构。要正确分配内存并初始化描述符的地址和状态字段。 - PHY初始化与状态维护:通过STM32F107的MAC提供的SMI(站管理接口)总线,去读写DM9161的内部寄存器,完成PHY的软复位、自协商(或强制模式设置)、以及链路状态检测。需要定期(例如在
ethernetif.c的周期性处理函数中)查询PHY的链路状态寄存器,当链路断开或恢复时,通知LWIP。 - 数据包收发函数:
low_level_input:当MAC收到一个包并触发接收中断后,这个函数被调用。它需要从已完成的DMA接收描述符中,将数据包拷贝到LWIP的pbuf结构体中,并返回给上层。low_level_output:当LWIP上层协议(如TCP/IP)要发送一个数据包时,调用此函数。它需要将pbuf中的数据拷贝到一个空闲的DMA发送描述符中,并启动MAC发送。
这里最大的坑在于内存管理。LWIP有自己的内存池(memp)来存放协议控制结构(如TCP PCB),以及内存堆(mem)来存放实际的数据包(pbuf)。而STM32F107的MAC DMA描述符又指向了一片物理内存。这三者之间的关系必须理清。
我的经验是:将DMA描述符环和它们指向的数据缓冲区,放在一片独立、连续且非缓存(Non-Cacheable)的内存区域。对于STM32F107,我们可以通过修改链接脚本(.ld文件),在RAM中专门划分出一块区域,比如叫ETH_DMA。描述符环和缓冲区都放在这里。这样可以避免因为CPU缓存(如果Cortex-M7才有)导致的数据一致性问题,也保证了DMA能够正确访问。
// 示例:在链接脚本中定义ETH DMA区域 MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K ETH_DMA (xrw) : ORIGIN = 0x2000C000, LENGTH = 16K /* 专门划出16K给以太网DMA */ } SECTIONS { .eth_dma (NOLOAD) : { . = ALIGN(4); _seth_dma = .; *(.eth_dma) . = ALIGN(4); _eeth_dma = .; } >ETH_DMA }然后在代码中,用属性将相关数组定位到这个区域:
// 定义发送和接收描述符环 __attribute__((section(".eth_dma"))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]; __attribute__((section(".eth_dma"))) ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]; // 定义接收缓冲区 __attribute__((section(".eth_dma"))) uint8_t Rx_Buff[ETH_RXBUFNB][ETH_RX_BUF_SIZE];4. 协议栈的精细调优:LWIP参数配置与内存管理实战
LWIP默认的配置(lwipopts.h)是为通用场景设计的,在资源紧张的STM32F107上直接使用,很容易导致内存耗尽或性能低下。必须根据应用场景进行裁剪和优化。
4.1 关键参数配置解析
打开lwipopts.h,以下这些参数需要重点关注和调整:
- 协议使能:用不到的协议一定要关掉,节省代码空间和内存。比如我们只用了TCP和ICMP(ping),那就把
LWIP_UDP、LWIP_DHCP(如果静态IP)、LWIP_AUTOIP、LWIP_IGMP等都设为0。 - 内存与缓冲区配置:
MEM_SIZE:这是LWIP动态内存堆(mem)的大小,用于分配pbuf等。至少需要20KB以上。可以设置为(20 * 1024)。PBUF_POOL_SIZE和PBUF_POOL_BUFSIZE:PBUF池是存放数据包最有效率的方式。BUFSIZE通常设为以太网MTU(1500字节)加上协议头开销(如PBUF_LINK_ENCAPSULATION_HLEN + PBUF_LINK_HLEN + PBUF_IP_HLEN + PBUF_TRANSPORT_HLEN),大约1536字节。POOL_SIZE决定了池中pbuf的数量,它限制了系统能同时处理的数据包数量。对于TCP服务器,这个值不能太小,建议至少16-32个。TCP_WND和TCP_MSS:TCP窗口大小和最大报文段长度。TCP_MSS通常设为(MTU - 40)(IPv4+TCP头)约1460字节。TCP_WND是接收窗口,太小会严重影响TCP吞吐量。在STM32F107上,受限于内存,可以设为(4 * TCP_MSS)或(8 * TCP_MSS),即5840或11680字节。发送窗口TCP_SND_BUF也应设为类似大小。
- 定时器与超时:
TCP_TMR_INTERVAL和ARP_TMR_INTERVAL定义了TCP和ARP定时器的调用周期,默认都是250ms。在要求快速响应的系统中,可以适当缩短,比如设为100ms,但会增加CPU负担。超时参数如TCP_MSL、TCP_MAXRTX等,可以根据网络可靠性调整。
4.2 内存使用监控与优化技巧
即使配置好了参数,在复杂应用下,内存泄漏或耗尽仍是常见问题。LWIP提供了内存统计功能,在lwipopts.h中使能LWIP_STATS和LWIP_STATS_DISPLAY。然后可以在应用中定期(如在某个低优先级任务中)调用stats_display()来打印内存使用情况。
更实用的方法是在mem.c的mem_malloc函数中加入水位检测。例如,当剩余堆内存低于某个阈值(如2KB)时,触发一个警告或记录日志。这能帮助你在系统崩溃前发现问题。
// 在 mem.c 的 mem_malloc 函数中(简化示例) void *mem_malloc(mem_size_t size) { // ... 原有的分配逻辑 ... void *ret = /* 分配到的指针 */; #if MEM_OVERFLOW_CHECK // 假设 MEM_SIZE 是总大小,_mem_len 是当前已用内存的某种度量 if ( (MEM_SIZE - _mem_len) < (2*1024) ) { // 剩余内存小于2KB LWIP_DEBUGF(MEM_DEBUG | LWIP_DBG_LEVEL_SERIOUS, ("mem_malloc: memory low, left < 2KB!\n")); // 可以在这里触发一个信号量或设置标志,让其他任务知道内存紧张 } #endif return ret; }对于TCP应用,要特别注意连接的管理。每个TCP连接(PCB)都会占用内存。务必在连接关闭后(收到ERR_CLSD或ERR_ABRT回调),及时调用tcp_close()或tcp_abort()来释放PCB。在tcp_err回调函数中处理异常断开,是防止PCB泄漏的关键。
5. 任务设计与系统整合:让网络服务稳定运行
有了协议栈,还需要在FreeRTOS中合理地设计任务,让网络数据收发、协议处理、应用程序逻辑协同工作。
5.1 网络任务与中断处理
标准的做法是创建一个专用的网络任务,运行LWIP的tcpip_thread。这个任务由LWIP提供,它内部有一个消息队列,处理来自底层驱动(中断服务程序)和上层应用的各种网络事件。
中断服务程序(ISR)的设计原则是快进快出。在STM32F107的以太网中断服务函数中,我们只做最必要的事情:
- 判断中断来源(接收完成、发送完成、错误等)。
- 如果是接收中断,则将一个“收到数据包”的事件(通过
tcpip_callback或发送消息到tcpip_mbox)提交给LWIP的tcpip_thread。绝对不要在ISR中进行复杂的数据拷贝或协议处理! - 清除中断标志。
这样,耗时的数据包处理(从DMA缓冲区拷贝到pbuf,协议栈解析)都在低优先级的tcpip_thread中完成,不会阻塞其他高优先级任务。
5.2 应用层任务与网络接口
应用任务(比如一个TCP服务器任务)如何与LWIP交互?LWIP提供了两种API:Raw/Callback API和Sequential API (Netconn API)。
- Raw/Callback API:这是最原始、效率最高的方式,但也是异步的、基于回调的,编程模型复杂。你需要为TCP连接设置一系列回调函数(
recv,sent,err,poll),当事件发生时由LWIP核心调用。这对代码结构要求高,容易出错。 - Sequential API (Netconn API):这是更高层次的、阻塞式的API,类似于BSD Socket,对开发者友好得多。而且,FreeRTOS的官方组件FreeRTOS-Plus-TCP实际上也提供了类似的Socket接口。但在原生LWIP中,我们可以使用
netconnAPI。它内部仍然运行在tcpip_thread的上下文中,但通过信号量让应用任务可以“阻塞”等待网络事件。
对于大多数应用,我推荐使用Netconn API。它大大简化了编程。你的应用任务可以像下面这样写一个TCP服务器:
void tcp_server_task(void *arg) { struct netconn *conn, *newconn; err_t err; // 创建新的TCP连接结构 conn = netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 8080); // 绑定到8080端口 netconn_listen(conn); // 开始监听 while(1) { // 等待客户端连接(阻塞) err = netconn_accept(conn, &newconn); if (err == ERR_OK) { // 为新连接创建一个处理任务 xTaskCreate(tcp_connection_handler, "TCP Handler", 512, (void*)newconn, tskIDLE_PRIORITY + 2, NULL); } } netconn_close(conn); netconn_delete(conn); } void tcp_connection_handler(void *arg) { struct netconn *conn = (struct netconn *)arg; struct netbuf *buf; char *data; u16_t len; do { // 阻塞等待接收数据 if (netconn_recv(conn, &buf) == ERR_OK) { do { netbuf_data(buf, (void**)&data, &len); // 获取数据指针和长度 // 处理数据 data[0..len-1] ... // 例如,回显数据 netconn_write(conn, data, len, NETCONN_COPY); } while (netbuf_next(buf) >= 0); // 处理netbuf链中的所有片段 netbuf_delete(buf); } } while( /* 根据应用逻辑判断是否关闭连接 */ ); netconn_close(conn); netconn_delete(conn); vTaskDelete(NULL); }5.3 系统稳定性保障:看门狗与栈溢出检测
在长时间运行的嵌入式设备中,稳定性至关重要。FreeRTOS提供了两种有用的调试机制:
栈溢出检测:在
FreeRTOSConfig.h中定义configCHECK_FOR_STACK_OVERFLOW为1或2。方法1在任务切换时检查栈指针是否越界;方法2还会在任务切换时用特定模式填充栈空间,并在下次切换时检查该模式是否被破坏,能检测到栈溢出但未立即导致崩溃的情况。务必为每个任务分配合适的栈空间,网络任务和tcpip_thread的栈需求较大,建议至少512-1024字(取决于处理器架构和函数调用深度)。看门狗集成:硬件看门狗(IWDG或WWDG)是最后一道防线。我们需要在FreeRTOS的空闲任务钩子函数(
vApplicationIdleHook)和可能长时间阻塞的任务中喂狗。但要注意,不能在中断服务程序(ISR)中喂狗,因为ISR可能因为高优先级任务一直运行而无法及时执行。一个稳健的做法是创建一个低优先级的“看门狗守护任务”,这个任务只做一件事:等待一个由其他任务或定时器定期发送的信号量,收到信号量就喂狗。这样,只要系统主循环和关键任务还在运行,信号量就会被定期发送,看门狗就能得到及时喂养。
6. 调试与排错:从Ping不通到稳定传输的完整链路
最后,分享一些实际调试中遇到的问题和解决方法。
问题一:Ping不通。这是第一个里程碑。首先检查硬件:电源、时钟、复位信号。然后用逻辑分析仪或示波器抓一下RMII数据线和REF_CLK,看是否有正常波形。软件上:
- 确认PHY初始化成功,链路状态是否为“已连接”(Link Up)。
- 确认MAC的DMA描述符初始化正确,特别是缓冲区地址。
- 确认中断是否使能,中断服务函数是否被触发。
- 在
low_level_input函数中加调试输出,看是否收到原始的以太网帧(比如ARP请求包)。如果收不到,问题在驱动层;如果收到了但LWIP上层没反应,问题可能在pbuf分配或消息传递。
问题二:TCP连接建立失败或频繁断开。
- 检查LWIP内存配置,特别是
PBUF_POOL_SIZE和MEM_SIZE,可能内存不足导致分配失败。 - 检查
TCP_MSS和TCP_WND设置是否合理。如果对端发送的包超过MSS或窗口太小,会导致性能问题或连接重置。 - 使用Wireshark抓包(如果条件允许),对比STM32发送的TCP SYN/ACK包和正常包的区别,检查IP地址、端口、序列号、窗口大小等字段。
- 检查应用层代码,是否及时处理了接收到的数据并发送了ACK。在
tcp_recv回调中,如果收到了数据但没调用tcp_recved()来更新接收窗口,会导致对端停止发送。
问题三:长时间运行后死机或重启。
- 首要怀疑内存泄漏。使用LWIP的内存统计功能和FreeRTOS的堆使用情况函数(如
xPortGetFreeHeapSize())进行监控。 - 检查任务栈是否溢出。开启FreeRTOS的栈溢出检测,观察是否有相关错误提示。
- 检查中断嵌套或优先级配置。确保以太网中断的优先级设置合理(通常不应是最高优先级),避免中断被长时间关闭导致数据丢失或看门狗复位。
- 在
tcp_err回调函数中加入日志,记录TCP连接异常断开的原因,这常常是网络不稳定或对端异常关闭导致的,需要确保资源被正确释放。
调试是一个系统工程,从物理层到应用层,需要逐层排查。我的习惯是,每完成一个驱动或协议层,就用最简单的测试(如Ping、Loopback测试)验证其基本功能,然后再向上集成,这样能最快定位问题所在。这套DM9161+STM32F107+FreeRTOS+LWIP的组合,虽然组件都有些年头,但把它们吃透,你对嵌入式网络系统的理解会上一个大台阶。