简介:面向嵌入式物联网单片机项目开发者,该压缩包提供一份基于STM32F103和DM9000以太网芯片的LWIP协议通信完整例程。代码采用KEIL HAL库编写,已在STM32F103上运行验证,更换同系列其他型号时仅需调整芯片型号与FLASH容量,并注意选择J-Link或ST-Link调试器,移植性表现良好,可直接嵌入到实际网络产品原型。
包内共374个文件,以157个C源码和181个头文件为核心,覆盖以太网协议栈、底层外设驱动及上层应用逻辑;同时附带hex固件、Keil工程、烧录脚本和说明文档,整体仅2.71MB,便于快速下载与对照学习。目前已有217人学习,代码中引脚接线均有清晰定义,注释详尽。阅读后可深入理解DM9000驱动方式、LWIP协议栈集成思路及单片机网络应用开发流程,尤其适合毕业设计、项目立项或从入门到进阶的开发者作为实用参考资料。
1. 这块板子真没必要换MAC芯片,DM9000配合HAL库跑通LWIP才是性价比最高的一条路
做单片机以太网项目,第一步就卡在选型上:带MAC的芯片贵且缺货,用外部PHY又绕不开MAC层实现。其实STM32F103配上DM9000这颗以太网控制器,走FSMC总线挂载、HAL库初始化、LWIP协议栈通信,是成本最低、资料最全、踩坑最可控的一条路线。这套例程把FSMC时序配置、DM9000寄存器读写、LWIP底层netif适配都提前拆好了,你拿到手改下引脚和芯片型号就能跑。适合正在做嵌入式联网设备、需要快速实现TCP/UDP通信的工程师,也适合想把LWIP内部数据流捋清楚的学生。下面我按实际调试顺序,把从寄存器到协议栈的每个关键点过一遍。
2. FSMC总线初始化与DM9000寄存器读写时序
2.1 DM9000挂载在FSMC Bank1上的地址映射关系
DM9000的数据线直接接STM32F103的FSMC_D0-D15,命令/数据引脚CMD由地址线A1控制,片选CS接到FSMC_NE1。这个接法决定了DM9000在内存中被映射到Bank1的起始地址0x60000000,CMD位为低时访问的是索引寄存器,CMD位为高时访问的是数据寄存器。因为A1接CMD,所以命令地址偏移是0x00,数据地址偏移是0x02,这也是代码里常见0x60000000和0x60000002两个宏的由来。
2.1.1 地址错位问题
很多人在读写DM9000时发现数据全是0xFF或者写入无效,多半是地址偏移算错。A1作为寄存器选择位,访问地址需要按字对齐,不能简单地以为命令地址是0x60000000、数据地址是0x60000001。FSMC的16位模式下,地址线是右移一位对齐的,因此数据地址必须是0x60000002。我一般会在初始化后先读芯片ID寄存器,读到0x90000A46说明地址映射正确,否则先查地址线和FSMC配置。
#define DM9000_CMD_ADDR ((uint32_t)0x60000000) #define DM9000_DATA_ADDR ((uint32_t)0x60000002) static uint8_t dm9000_read_reg(uint8_t reg) { *(volatile uint16_t *)DM9000_CMD_ADDR = reg; return *(volatile uint8_t *)DM9000_DATA_ADDR; }这段代码是DM9000所有寄存器操作的基础。第一步向命令地址写入寄存器编号,第二步从数据地址读取寄存器内容。读写函数本身很短,但时序参数没配好会在这里卡很久。
2.2 用STM32CubeMX配置FSMC后手工校准时序参数
在CubeMX中把FSMC的Bank1 NOR/PSRAM使能、选择16位数据宽度、Memory Type选NOR Flash,会自动生成HAL_SRAM_Init相关的初始化代码。生成完后不要直接烧录,要用示波器或者逻辑分析仪看NE1、NOE、NWE的时序是否符合DM9000数据手册要求。DM9000对读周期要求tRD最小约80ns,对写周期要求tWP最小约80ns,FSMC默认参数往往偏快,高性能配置下可能跑不稳。
static void fsmc_dm9000_init(void) { FSMC_NORSRAM_TimingTypeDef timing = {0}; FSMC_NORSRAM_InitTypeDef fsmc = {0}; __HAL_RCC_FSMC_CLK_ENABLE(); timing.AddressSetupTime = 8; timing.DataSetupTime = 10; timing.AccessMode = FSMC_ACCESS_MODE_A; fsmc.FSMC_Bank = FSMC_BANK1_NORSRAM1; fsmc.FSMC_DataAddressMux = FSMC_DATA_ADDRESS_MUX_DISABLE; fsmc.FSMC_MemoryType = FSMC_MEMORY_TYPE_SRAM; fsmc.FSMC_MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16; fsmc.FSMC_BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE; fsmc.FSMC_WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW; fsmc.FSMC_WrapMode = FSMC_WRAP_MODE_DISABLE; fsmc.FSMC_WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_NS_ACCESS; fsmc.FSMC_WriteOperation = FSMC_WRITE_OPERATION_ENABLE; fsmc.FSMC_WaitSignal = FSMC_WAIT_SIGNAL_DISABLE; fsmc.FSMC_ExtendedMode = FSMC_EXTENDED_MODE_DISABLE; fsmc.FSMC_WriteBurst = FSMC_WRITE_BURST_DISABLE; fsmc.FSMC_ReadWriteTimingStruct = &timing; fsmc.FSMC_WriteTimingStruct = &timing; HAL_SRAM_Init(&fsmc, &timing); }AddressSetupTime设成8、DataSetupTime设成10,适用于72MHz主频下FSMC时钟为HCLK的配置,换算下来读写周期在110ns左右,给DM9000留出了余量。如果主频改了,这个数字要重新折算,比如跑48MHz时DataSetupTime可以降到6。改完整后先做一次连续读ID的操作,大于100次循环都稳定读到同一个值才算时序过关。
2.3 DM9000寄存器读写与PHY状态读取验证
DM9000上电后要通过软件复位寄存器0x00写0x01复位,然后等待内部PHY完成上电自检。PHY是否就绪要看寄存器0x01的bit0,这个位为1表示PHY已经准备好。之后读取芯片ID寄存器0x02和0x03,正常情况下高字节是0x90、低字节是0xA6。
void dm9000_reset(void) { dm9000_write_reg(DM9000_REG_RESET, 0x01); HAL_Delay(20); dm9000_write_reg(DM9000_REG_GPCR, 0x01); dm9000_write_reg(DM9000_REG_GPR, 0x00); uint8_t id_h = dm9000_read_reg(DM9000_REG_VID_H); uint8_t id_l = dm9000_read_reg(DM9000_REG_VID_L); if (id_h != 0x90 || id_l != 0xA6) { dm9000_write_reg(DM9000_REG_RESET, 0x01); HAL_Delay(50); id_h = dm9000_read_reg(DM9000_REG_VID_H); id_l = dm9000_read_reg(DM9000_REG_VID_L); } uint8_t phy_status = dm9000_read_reg(DM9000_REG_PHY_STATUS); if (phy_status & 0x04) { // bit2为1表示链路已连接 } }这里的PHY状态寄存器地址是0x1F,是DM9000内部的PHY状态汇总,bit2对应网线连接状态。调试时如果发现读到的phy_status一直是0,先检查网线是否插好,再检查DM9000的TX+、TX-、RX+、RX-有没有接反。DM9000是10/100M自适应,但要求网络变压器中心抽头处理正确,不然PHY状态也会异常。
3. LWIP协议栈移植:从netif接口到TCP/IP链路打通
3.1 LWIP组件包与HAL库驱动的衔接点
文件列表里出现的mib2.c和fsdata.c说明这个例程集成了SNMP和HTTP Server服务,这两个组件依赖LWIP的netif和pbuf机制正常工作。LWIP对底层网卡驱动暴露的标准接口是low_level_init、low_level_output和low_level_input三个函数,分别负责初始化网卡、发送数据包、接收数据包。HAL库的DM9000驱动只需要做好一件事:把DM9000收到的数据帧填充成struct pbuf,把struct pbuf里的数据拆成以太网帧发出去。
理解这个衔接点是移植的关键。LWIP内部不管底层是DM9000还是ENC28J60,它只认netif结构体里注册的这三个函数指针。DM9000的收包方式是中断或者轮询读取FIFO,LWIP的收包方式是主动调用low_level_input,所以两个模块之间需要一个缓冲区交接。
3.2 lwipopts.h内存参数配置
STM32F103的RAM资源有限,ZET6是64KB,RCT6是48KB,LWIP的内存配置不能直接照搬网上那些给F407或者F429用的配置。PBUF池和内存堆设置太大,编译能过但运行时会频繁申请失败;设置太小,TCP传输速率上不去。我习惯先把以下参数作为起点,跑通PING后再逐步调大。
#define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) #define MEMP_NUM_PBUF 20 #define MEMP_NUM_UDP_PCB 8 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 16 #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 512 #define LWIP_DHCP 1 #define LWIP_DNS 0 #define LWIP_NETCONN 1 #define LWIP_SOCKET 0这里PBUF_POOL_SIZE设为20,每个pbuf的缓冲区大小是512字节,加起来占用的RAM约10KB,再加上MEM_SIZE的16KB堆空间,整体内存占用在30KB以内,在F103上跑得稳。LWIP_SOCKET关掉是因为BSD Socket层对RAM消耗大,用netconn API开发效率更高。如果后续要跑HTTP Server,MEMP_NUM_TCP_SEG建议加到24,否则并发连接下发送队列会阻塞。
3.3 low_level_init与网卡轮询收发逻辑
low_level_init里要设置netif的MAC地址、MTU和硬件地址长度。DM9000的MAC地址存储在芯片内部EEPROM中,出厂可能是全0xFF,我们代码里直接用一个静态数组定义,烧录后每次上电都用同一个MAC。做产品时MAC地址要按批次分配,不能所有板子都一样,否则局域网内会地址冲突。
static err_t low_level_init(struct netif *netif) { static const uint8_t mac[6] = {0x00, 0x80, 0xE1, 0x00, 0x01, 0x01}; netif->hwaddr_len = 6; MEMCPY(netif->hwaddr, mac, 6); netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; dm9000_set_mac(mac); dm9000_rx_enable(); return ERR_OK; }接收数据时,LWIP在主循环里通过ethernetif_input(netif)轮询网卡,DM9000有数据帧到达时RX SRAM里会写入帧头信息,我们读取状态字判断帧长度,然后分配pbuf把数据搬进去。这里有个性能细节:DM9000的数据宽度是16位,读取时用uint16_t指针操作比逐字节读取快很多。
struct pbuf *low_level_input(struct netif *netif) { uint16_t status = dm9000_read_reg(DM9000_REG_RX_STATUS_H); uint16_t len = dm9000_read_reg(DM9000_REG_RX_LEN_H); if (status & 0x01) { dm9000_rx_skip(); return NULL; } struct pbuf *p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { uint8_t *buf = (uint8_t *)p->payload; dm9000_rx_read(buf, len); dm9000_rx_skip(); } else { dm9000_rx_skip(); } return p; }这段是典型的接收流程。先判断RX状态寄存器的最高位是否为1,为1说明当前帧无错误,再读帧长度。分配pbuf失败时不能停在这里,必须调用rx_skip跳过当前帧,否则RX FIFO不释放会一直报溢出错误。DM9000的RX SRAM需要手动写命令字来释放,很多人在这一步漏了,导致通信到第二次就卡死。
3.4 协议栈初始化顺序与主循环集成
协议栈初始化顺序错了,后面所有网络请求都会异常。先初始化底层硬件,再初始化LWIP核心,最后注册网卡接口。顺序反了,比如先调tcpip_init再调FSMC和DM9000,收发回调还没准备好就被触发,程序直接跑飞。
void ethernet_lwip_init(void) { fsmc_dm9000_init(); dm9000_reset(); lwip_init(); netif_add(&g_netif, &g_ipaddr, &g_netmask, &g_gateway, NULL, ethernetif_init, ethernet_input); netif_set_default(&g_netif); netif_set_up(&g_netif); }主循环里调用ethernetif_check_link检测链路变化并调用ethernetif_input处理收包。LWIP的定时器靠sys_check_timeouts驱动,这个函数必须在主循环里高频调用,否则ARP老化、TCP重传都不工作。我一般把主循环的周期控制在1ms以内,用HAL_GetTick做非阻塞延时,确保协议栈的时基拉得均匀。
while (1) { ethernetif_check_link(&g_netif); ethernetif_input(&g_netif); sys_check_timeouts(); user_tcp_poll(); }ethernetif_input和sys_check_timeouts不能互换位置,先收包后处理超时,保证刚收到的ACK能立刻驱动TCP状态机推进。个人项目里把sys_check_timeouts放前面看似没问题,但高负载下会额外引入几十毫秒的确认延迟,下载吞吐量会掉一截。
4. 抓包验证与排查网络通信故障
4.1 用Wireshark确认ARP与ICMP流程
板子烧录后先不急着写应用层代码,第一步是用网线直连电脑,给板子静态IP例如192.168.1.10,电脑配置192.168.1.2。然后打开Wireshark监听对应网卡,在电脑上ping 192.168.1.10。
Wireshark里依次能看到三个关键过程:电脑发出ARP广播询问192.168.1.10的MAC地址、板子应答ARP包、电脑发出ICMP echo request、板子回ICMP echo reply。如果只看到ARP request没有应答,问题出在以太网驱动层,LWIP根本没收到ARP广播,优先排查FSMC时序和DM9000接收中断;如果ARP有应答但ICMP不回,问题出在LWIP的IP层或者内存配置,重点看pbuf分配是否失败。
ping 192.168.1.10 -t持续ping的同时观察Wireshark,如果有规律地出现几个request对应一个reply,说明收发路径上的某一段在丢包,通常是pbuf池不够或者中断和主循环竞争导致。这时可以把PBUF_POOL_SIZE临时调到40再试,如果恢复稳定,再反过来分析内存预算。
4.2 常见故障定位表
| 故障现象 | 排查方向 | 处理建议 |
|---|---|---|
| 读ID全为0xFF | FSMC时序过快,DM9000未响应 | 拉长DataSetupTime,检查NE1片选 |
| PHY状态bit2一直是0 | 网线未接好,变压器虚焊,TX/RX差分对接反 | 换网线、用万用表量变压器来线、核对原理图 |
| ARP能通但ICMP不通 | LWIP内存池耗尽或IP层校验失败 | 检查PBUF_POOL_SIZE,打开LWIP_DEBUG查看日志 |
| TCP连接建立后马上断开 | TCP定时器未驱动或ACK超时 | 确认主循环里调用了sys_check_timeouts |
| 通信一段时间后死机 | RX SRAM溢出或指针越界 | 检查rx_skip是否执行,校验pbuf分配释放配对 |
4.3 串口日志配合调试
网络协议栈的问题靠串口日志辅助定位是最直接的手段。LWIP本身提供了LWIP_DEBUG宏,但STM32F103跑全量调试日志太耗资源,我一般是把LWIP_DEBUG关闭,自己在DM9000驱动层加关键节点打印。
#define DBG_PRINT(fmt, ...) \ do { \ char buf[128]; \ snprintf(buf, sizeof(buf), fmt, ##__VA_ARGS__); \ HAL_UART_Transmit(&huart1, (uint8_t *)buf, strlen(buf), 100); \ } while (0)用串口1输出日志时注意,串口1挂载在APB2总线上,时钟频率72MHz,波特率设置到115200没问题;如果换成串口3或者串口2,它们在APB1总线上,最高36MHz,波特率误差会大一些,建议不要超过57600。每次收包和发包都打印会严重影响实时性,只打印异常分支就够了,比如ID读取失败、pbuf_alloc返回NULL、PHY链路断开。
5. 让DM9000跑到更快的几个实用调整
5.1 开启DHCP动态获取IP
静态IP只适合调试,做产品要支持DHCP。LWIP的DHCP客户端是可选组件,lwipopts.h里LWIP_DHCP设为1后,再在代码里调用dhcp_start(&g_netif),协议栈会自动完成发现、请求、确认流程。要注意DHCP在网线插上后需要一段时间协商,不能在netif_set_up后立刻去做TCP连接,要等dhcp_supplied_address返回非0值。另外DHCP的lease时间到了之后会自动续租,续租失败也不应该主动断开TCP连接,LWIP内部已经处理了,不要自己加业务逻辑干扰它。
5.2 调整TCP窗口与PBUF池避免吞吐量瓶颈
TCP的吞吐量上限受接收窗口和发送缓冲限制。在RAM允许的前提下,把TCP_WND从默认的4KB提升到8KB,TCP_SND_BUF从2KB提升到6KB,配合前面的PBUF_POOL_SIZE调整,下载速率会明显改善。具体做法是在lwipopts.h中覆盖默认值。注意这里不是越大越好,F103的64KB RAM要留给系统栈和业务数据,调完用FreeRTOS统计或者mallinfo看一下剩余堆空间,留出至少10KB余量。STM32F103用DM9000的内核瓶颈主要在FSMC总线拷贝和LWIP内存搬运,实际吞吐量做到5-7Mbps已经算正常水平,不要拿它和带MAC的F407比。
5.3 从轮询切到中断收包降低CPU占用
例程默认是主循环轮询,CPU利用率在高速传输时接近100%。接手量产项目时,建议把DM9000的INT引脚接到STM32的外部中断线上,收到帧后置位一个标志位,主循环发现标志位再调用收包逻辑。这样CPU空闲时不会反复查询芯片寄存器,功耗和发热都会降下来。
void EXTI15_10_IRQHandler(void) { if (__HAL_GPIO_EXTI_GET_IT(DM9000_INT_PIN) != RESET) { g_dm9000_rx_flag = 1; __HAL_GPIO_EXTI_CLEAR_IT(DM9000_INT_PIN); } }中断服务函数里不能直接调用pbuf_alloc和low_level_input,这些函数带临界区保护,放在中断里容易和主循环的LWIP调用产生嵌套。用标志位把实际收包动作延后到主循环执行是习惯做法。配合这个改动,ethernetif_input里要加一下判断,标志位是0就跳过收包,避免对DM9000寄存器的无意义访问。这套改下来以后,同样的工程CPU占有率能降30%以上,也为以后移植到FreeRTOS环境留好了接口。
本文还有配套的精品资源,点击获取