STM32F4 ETH通讯 纯链路层通讯

        本文讲讲我在使用STM32F407的ETH外设的一点心得体会。适合想搞懂ETH怎么使用的朋友看看。学习本文需要有STM32其它外设的使用经验。不适合完全小白的人。不然你连时钟配置都要搞半天才理解。

        有些概念我也是半知半解,讲错之处请多海涵。

        开发软件使用的是STM32CubeIDE1.18.0(截止2025-4算是最新版本),网上很多都是基于keil讲解的,而且使用的HAL库与我的也很不一样。但在寄存器这一层的道理是相通的。所以我建议你准备一本《STM32F4xx中文参考手册》。在理解代码逻辑时非常有帮助。

        硬件使用的是F407VGT6+LAN8720A

ETH介绍

        ETH是STM32上的外设,在网络中扮演的是数据链路层(MAC)的角色。你网上买块STM32F4的开发板,这颗F4MCU都会自带ETH。但想要实现网络通讯,还要求板上增加PHY芯片以及网线接口。PHY芯片能提供物理层的通讯能力。这样ETH才能驱使物理层(PHY)发送和接收数据。

        简单地说,两块开发板,上面各自有一颗STM32芯片、PHY芯片、网线接口。用网线连接起来,就可以进行通讯了。

        像上面这块开发板,如果有两块。用网线连接网口。它们就可以进行通讯了。而且你可以不用理会什么TCP/IP协议,你想发什么就发什么,对方会如实收到。MAC层是非常原始,接近硬件的通讯方式。 在MAC基础上,约定一些通讯的格式及含义(例如TCP/IP协议,EtherCAT协议),便催生出了五花八门的网络协议。也叫网络层。实际应用中,我们不可能自己搞一套通讯协议,已经有那么多成熟的轮子了,没必要再自己造一套。在STM32上使用那些既有的协议,一般是通过软件代码来实现的。例如TCP/IP协议有lwip库替我们实现了。EtherCAT协议,有SOEM库替我们实现了。我们拿来用就好。

        但是基于MAC层的通讯我们也得懂。不然新手很难搭起整个网络通讯出来。搞了半天,收不到数据,也发不出数据。甚至连初始化都会卡你很久。所以接下来,我们抛开协议不讲。只讲MAC层是怎么建立起通讯的。

硬件准备

        一般来讲,在网上买一块带有网口的开发板,它会帮你把硬件方面的接线都搞好。所以省事很多,我们只需要查看店家给的原理图,就知道后面软件初始化时,应该填哪些GPIO引脚。如下图所示:

        上面这张原理图,表示使用的是一颗型号为LAN8720A的PHY芯片。并且PHY如何与F4MCU相连,如何设置时钟引脚,都已经明确标示出来。

        那么我假设你已经准备好了一块开发板,那么现在你可以给它接上电源,然后用一根网线把它跟PC相连。后面你可以在PC上看到你开发板上发出的数据,也可以在开发板上接收到来自PC的数据。这是一件很有成就感的事。

        在PC上安装好WireShark这个软件,然后你网线插的是哪个网口,就开始捕获哪个网口的数据就行了。这个要是不懂,就自学一下吧。当你的开发板正确配置之后,你就可以在wireshark中看到PC发出的ARP、DHCP、ICMP等协议报文,你还可以看到开发板发给PC的啥协议也算不上的你的“hello”。

软件设置

PHY设置

        PHY被店家焊到板子了,但并不能直接就发挥作用。该有的初始化代码要有。PHY有四个要素:时钟、SMI接口、RMII接口、nRST引脚。搞定这四个要素,它才能默默地工作。你只需要在初始化时跟它打交道,后面你收发数据时就不需要再跟它打交道了。

时钟

        时钟是PHY运行的心脏,提供了芯片运行的动力。使用RMII接口,需要给PHY提供50Mhz的时钟频率。具体到LAN8720A这块芯片上,它的设计者知道50Mhz的晶振贵,所以它设计成只需要提供25Mhz的时钟给LAN8720A,然后芯片自己倍频为50Mhz给RMII使用。多贴心啊。

        像硬石的STM32F407开发板,它就是这么干的:

        如果你的开发板是硬石这种情况,时钟部分就不需要再怎么理会了,直接看下一节。但有的开发板它想省钱,一块晶振得打两份工。即给F407提供时钟,又得给PHY提供时钟。像下面这块板,它就把CLKIN引脚跟STM32F407的PA8连在一起了。

        这块开发板的时钟晶振刚好是25MHZ,我不知道为什么不物理上直接并联给F407和PHY,这有点超出我的知识范围。它是把25MHZ提供给F407,然后通过PA8引脚复用的方式,再接入到CLKIN。

        首先要在RCC配置中,勾选使用外部高速晶振,并勾选Master Clock Output1(它是PA8的复用)

时钟配置图中,把HSE设置为25mhz

如上设置后,生成的代码中,便会有这么一段:

/*将PA8引脚做为时钟输出,以给PHY芯片提示50Mhz时钟*/

GPIO_InitTypeDef GPIO_InitStruct = {0};

GPIO_InitStruct.Pin = GPIO_PIN_8; // 选择 PA8 引脚

GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; // 复用推挽输出

GPIO_InitStruct.Pull = GPIO_NOPULL; // 无上下拉

GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; // 高速模式

GPIO_InitStruct.Alternate = GPIO_AF0_MCO; // 复用功能 AF0 (RCC_MCO_1)

HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 初始化 PA8

SMI、RMII

        SMI是跟PHY芯片打交道的接口,而RMII是跟网络数据打交道的接口。初始化时会用到SMI,因为你读取PHY芯片的数据或者设置PHY就需要用它。而过后,你发送接收报文,就是用RMII了。有关它们的概念,网上很多文章在讲ETH时都有提到。你可以去找找。

        但其实你不懂也没关系。因为开发软件都替你做了。你只需要配置时,勾选了ETH,你就可以得到一个这样的配置界面:

        基本上,你就已经跟RMII接口挂钩了,CubeMX会替你生成使用RMII的代码。然后就是引脚的配置。

        在这个配置里,已经包含了SMI跟RMII的引脚。其中ETH_MDC+ETH_MDIO=SMI。而剩下的7个引脚则跟RMII有关。你参照着店家给的原理图,给配置正确就行了。这样初始化的代码就能生成好。你不需要理解SMI的报文长什么样,通讯的时序长什么样。当你想读取或设置PHY参数时,HAL给你提供了相应的API。后面会讲怎么用。你如果对寄存器感兴趣,可以看看这些API的源代码,并对照PHY芯片的手册去理解。这里你需要下载一本例如《LAN8720A芯片手册》之类的说明书。

nRST

        这个引脚很重要,我曾在这个引脚上吃过亏,所以单独拎出来讲讲。网上很多文章没有单独讲它,很多源代码也是把它跟RMII引脚混在一堆,不仔细看容易忽略。我不知道其它PHY芯片是什么样子,但是在LAN8720A上,它是一个硬复位的引脚。在你想使用LAN8720A的功能之前,你需要用它做一次硬复位。在我的开发板上,它是连接到了PC0,你根据你的开发板变通一下。我们需要把PC0设置成一个普通GPIO输出。然后把它拉低10ms,以达到硬复位LAN8720A的目的。

        你可以在配置中勾选它为GPIO_Output,并配置为推挽输出。然后会自动生成这个代码:

//初始化ETH_NRST
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOC, &GPIO_InitStruct);

PHY初始化的代码

        一般开发时,我会用CubeMX来生成代码,提高效率。但对于新手并不是很友好。因为MX生成的代码东一块西一块的。

        但如果把所有代码合在一起,却是又臭又长,没人讲解的话,对于新手一样不友好。那权衡之下,我觉得还是用MX来生成,因为MX生成代码这一块虽然分散,但它很标准。MX替你完成参数的配置工作,但不会帮你启动。就像生成PWM波一样,什么样的频率,什么样的占空比,MX都可以替你设置好。但你一运行代码,拿示波器一测引脚,啥都没有。因为你就差了HAL_PWM_START()这一句代码。而这是MX不会替你写的。这临门一脚需要你自己来完成。所以标准的参数部分给MX去完成,我会告诉你关键的代码要怎么写就好。

        首先是时钟这一块,如果你是硬接了一块晶振,那时钟就不需要设置了。否则,则需要根据原理图,让MCU生成相应hz的时钟给PHY芯片使用。我的是利用PA8引脚,为PHY提供时钟。所以配置PA8引脚为RCC_MCO_1,由MX生成代码后,补充上最后这一句:

static void MX_GPIO_Init(void) {
    ……
    /*Configure GPIO pin : PA8 */
    GPIO_InitStruct.Pin = GPIO_PIN_8;
    GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
    GPIO_InitStruct.Alternate = GPIO_AF0_MCO;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

    __HAL_RCC_MCO1_CONFIG(RCC_MCO1SOURCE_HSE, RCC_MCODIV_1);
}

        最后一句__HAL_RCC_MCO1_CONFIG(RCC_MCO1SOURCE_HSE, RCC_MCODIV_1);是自动生成的代码里没有,但是是很关键的一句。它把MCO1跟HSE关联起来,因为HSE已经是25MHZ了,所以选RCC_MCODIV(不分频)。如果没有这一句,时钟部分就会出错。具体参数你得根据你的实际情况来给,只要最终能给LAN8720A提供25MHZ时钟就行。如果你是其它PHY芯片,则根据该芯片的时钟要求来给提供就是了。

        SMI和RMII则完全由MX来生成代码,不需要额外添加什么代码。它们生成的代码会出现在stm32fxx_hal_msp.c当中的void HAL_ETH_MspInit(ETH_HandleTypeDef* heth)

        nRST你需要在MX配置它为普通GPIO输出,然后在HAL_ETH_MspInit中添加额外的硬复位代码。

void HAL_ETH_MspInit(ETH_HandleTypeDef* heth)
{
    ……
    if(heth->Instance==ETH)
    {
        ……
        //硬件的方式重置ETH_RST(PC0)
        HAL_GPIO_WritePin(GPIOC, GPIO_PIN_0, GPIO_PIN_SET);
        HAL_Delay(10);
        HAL_GPIO_WritePin(GPIOC, GPIO_PIN_0, GPIO_PIN_RESET);
        HAL_Delay(10);
        HAL_GPIO_WritePin(GPIOC, GPIO_PIN_0, GPIO_PIN_SET);

    }

}

        如果你没有硬复位LAN8720A的话,你会在读取PHY芯片数据时,永远都是返回0xffff。切记切记。        

        硬复位完了,还有个软复位,真特么麻烦。软复位的原理是把PHY的BCR寄存器的第31位置1,然后等PHY完成复位后会自动把它置0。我们若等到它变成0,则宣告软复位成功。

        你可以在HAL_ETH_Init()执行完之后,找个合适的地方写代码来做这个软复位。我是写在main函数中。代码如下:

//给PHY的BCR寄存器的第31位置1。PHY_RESET=0b1000 0000 0000 0000 0000 0000 0000 0000
rtn = HAL_ETH_WritePHYRegister(&heth, lan8720addr, PHY_BCR,PHY_RESET);
if (rtn != HAL_OK) {
    printf("写PHY的BCR寄存器失败 \r\n");
    return -2;
}
//延时一下,给它点时间软复位
HAL_Delay(100);
//等待复位成功
do {
    //再次读取BCR寄存器
    rtn = HAL_ETH_ReadPHYRegister(&heth, lan8720addr, PHY_BCR, &regvalue);
    if (rtn != HAL_OK) {
        printf("读取寄存器失败 \r\n");
        return -3;
    }
} while (regvalue & PHY_RESET);//只要PHY_RESET位依然是1,就会一直循环。直到它为0才会退出while

        在上面的代码中lan8720addr一般为0,店家的原理图中,如果PHYAD0悬空,就为0,拉高就为1.我的是悬空。所以这里lan8720addr为0。当然,lan8720addr的取值可以是0至31,但一般不会遇到这么复杂的情况,毕竟我们又不是开发路由器,不会在一块板上搞那么多个PHY芯片。

        PHY_BCR=0x0U,它被定义在stm32f4xx_hal_config.h当中。这个涉及到寄存器的含义。你可以看PHY寄存器的手册。

        好了,到此,你的PHY应当是初始化完成了。若此时你已经把网口连接到PC上,那PHY自己就会跑去跟PC的网口进行友好的协商,以确定以后用什么速率的通讯速度,用半双工还是全双工。这些是在PHY初始化成功时,它自动完成的,不需要你额外做什么。

        如果你想干点有成就感的事,那你可以在main函数中,不断去读取PHY的BSR寄存器,这是一个状态寄存器,它会反馈给你,PHY跟PC是否建立了链接,用的什么速率,全还是半双工。你可以选择开发板上的一颗LED灯,如果读取的结果是链接没建立,那你可以让LED闪烁,若建立成功,则让LED常亮。

while (1) {
    //判断网络是否连接
    if(LAN8720_ISLinkUp() == LAN8720_STATUS_OK){
        LEDON(0);//常亮
    else{
        LEDTOGGLE(0);//闪烁
    }
    HAL_Delay(500);
}

        其中LAN8720_ISLinkUp实现如下:

/**
* 函数功能: 判断是否连接
*/
int LAN8720_ISLinkUp(void){
    //读状态寄存器
    uint32_t readval;
    HAL_StatusTypeDef rtn = HAL_ETH_ReadPHYRegister(&heth, 0, 1, &readval);
    if (rtn != HAL_OK) {
        printf("读寄存器失败 \r\n");
        return LAN8720_STATUS_READ_ERROR;
    }

    if ((readval & 0x0004U) == 0) {
        return LAN8720_STATUS_LINK_DOWN;//链接没建立
    }

    return LAN8720_STATUS_OK;//链接成功
}

        其中是读取寄存器的第2位(0x0004U)来判断是否建立了链接。这个是根据手册得来的。再次强调有一本手册的必要性

数据的传送

        好的,到了这里,你的开发板应当跟PC之间有了相当友好的基础了。能看到开板上的LED常亮来慰藉你前面辛苦的付出。下面就开始讲怎么发送和接收数据了。网上的文章讲得很详细了。它们都会告诉你是用DMA描述符来完成数据的收发工作。这对于新手来讲,理解一个新的概念不是一件容易的事。那么我尽可能通俗一点地讲。

发送数据

        为了兼顾效率,网络数据在发送的时候,不应该由CPU来。STM32搞了一个特殊的外设,叫DMA。它可以独立于CPU干活。你想收发数据就交给它,CPU可以干别的。在活干完之后,会有相应的反馈。

发送的思路

        那我们可以设计三个变量own和dataAddr和dataLength。

  1. dataAddr用来存放一个指针,指向数据
  2. dataLength告诉DMA数据有多长,
  3. own用来通知DMA可以干活了。

使用时大概这样(伪代码):

char *buffer = “hello world”;//构建我们要发送的数据
dataAddr = buffer;	//把我们的数据,放在这个变量里
dataLength = strlen(buffer); //长度也要告诉DMA
own = 1; //DMA检测到1时,就知道来活了,于是就把addr指向的数据给发出去。
while(own == 1){//等待1被置为0,表示数据发送完了。
//做一个超时检查,若超时了还没变回0,则表示发送超时
}
//运行到这里就表示发送成功了

完善一下结构体(DMA描述符)

        那这里有一个问题。一个MAC报文的长度是有限的,例如1524bytes。但我们自定义的报文有可能远超这个长度。应该怎么办?DMA它的解决方案是采用链表(或者环形结构,但环形结构我就不研究了)。

        首先,我们把上面的三个变量定义为struct:

typedef struct{
    uint32_t own;
    uint32_t dataLength;
    uint32_t dataAddr;
}ETH_DMADescTypeDef;

        一个这样的结构就可以存放一个想要发送出去的报文了。那如果报文太长,我们再建一个ETH_DMADescTypeDef并利用next指针把它们串联起来。这样链表就可以很长很长。链表结构是很经典的数据结构,没接触过的可以去看看数据结构的书籍。

typedef struct{
    uint32_t own;
    uint32_t dataLength;
    uint32_t dataAddr;
    uint32_t next;
}ETH_DMADescTypeDef;

        DMA在发送数据的时候,从链头开始,如果检测到next不为空,就跳转到下一个链节点继续发送数据。直到next==NULL。

        上面这样的结构体,官方管它叫DMA描述符。所以再看到别人说描述符,你就不会觉得晦涩难懂了。

跟DMA关联

好了,承载一个报文的结构体有了,那如何跟DMA进行关联呢?官方提供了两个寄存器:

DMATDLAR   DMATPDR

在MX生成的代码里,有一个ETH_HandleTypeDef heth; 的结构,通过它可以访问到这两个寄存器heth.Instance->DMATDLAR、heth.Instance->DMATPDR

  • DMATDLAR是用来关联结构体数组的
  • DMATPDR是用来激活DMA去检查有没有要发送的数据

        我们可以定义一个链表,然后把这个链头的地址放在DMATDLAR寄存器里,这样DMA就能访问到要发送的报文。

ETH_DMADescTypeDef dmaRx1, dmaRx2, dmaRx3, dmaRx4;
dmaRx1.next = dmaRx2;
dmaRx2.next = dmaRx3;
dmaRx3.next = dmaRx4;
dmaRx4.next = dmaRx1;

heth->Instance->DMATDLAR = &dmaRx1;

当然,实践中,一个一个链节点去定义太麻烦,我们通常使用数组,一下子就能定义多个N个链节点。

ETH_DMADescTypeDef dmaTxList[N];
for(int i = 0; i < N; i++){
    if(i<N-1){
        dmaTxList[i].next = dmaTxList[i+1];
    }
    else{
        dmaTxList[i] = dmaTxList[0];
    }
}
heth->Instance->DMATDLAR = &dmaTxList[0];

        这里我要强调的是,定义一个数组只是方便我们一下子创建多个节点。定义多个零散的变量,再串成一个链表,效果是一样的。DMA它只认next指针。

        当我们需要发送数据时,我们只需要拿出一个结构体进行填充,然后激活DMA进行轮询就好了。(伪代码)

//设置为1,这样DMA就知道这个是要发送的。如果为0,DMA就会略过
dmaDescList[curIndex].own= 1; 
//填写数据和长度
dmaDescList[curIndex].dataAddr = yourData;
dmaDescList[curIndex].dataLength = dataLength;
//如果有更多数据,那可以设置为下一个数据的地址。DMA会自动跳转到下一个位置
dmaDescList[curIndex].next = NULL;
//这里可以填任意值,都会触发DMA去遍历链表。把遇到的own=1的数据发送出去。
heth->Instance->DMATPDR = 任意值;

        DMA内部有一个寄存器ETH_DMACHTDR ,它由DMA内部维护。这个寄存器指示当前描述符的地址。相当于上面代码中的curIndex。让DMA可以从当前链节点开始遍历,而不用从链头开始遍历。

真正的DMA描述符

        顺利的话,我们就已经掌握了发送数据的方法了。你在PC上就可以看到自己想发送的数据的样子。但实际上,DMA使用的结构体比我讲的要稍微复杂一点,主要是增加了一些状态,和控制位。但基本思想与我讲的一致。这一块可以参考《STM32F4xx中文参考手册.pdf》其中的常规Tx DMA描述符

        这个是DMA发送数据的核心。但HAL库会替我们做繁琐的工作,我们只需要掌握思想就行。有了思想,再去看HAL库的源代码,就很容易懂了。我上面所讲用的也是伪代码。主要是为了掌握思想。你没办法复制我的代码就能达到运行的效果。

HAL库使用

        我们开发会使用HAL库,所以你不必自己实现对结构体的定义,对寄存器的琐碎操作。HAL库都替你做了。而且做得很完善。下面就讲讲真正的代码:

        初始化方面,CubeMX生成的代码里,沿着下面这条线,已经完成了一切初始化操作:

//main.c
MX_ETH_Init(){
    ……
    HAL_ETH_Init(&heth);
    ……
}
//stm32f4xx_hal_eth.c
HAL_StatusTypeDef HAL_ETH_Init(ETH_HandleTypeDef *heth)
{
    //硬件的初始化,参照前面PHY要求做好相应工作
    HAL_ETH_MspInit(heth);
    ……
    //结构体(即网上说的DMA描述符)的初始化由HAL库在这里面做了
    ETH_DMATxDescListInit(heth);
}

        上面的代码由MX自动生成。你只需要在初始化完成后,调用HAL_ETH_Start就行。

//main.c
int main(void){
    ……
    MX_ETH_Init();
    //开启ETH
    if (HAL_OK != HAL_ETH_Start(&heth)) {
        printf("ETH Start FAIL");
    }
}

        你想要发送数据只需要准备好数据,然后调用HAL_ETH_Transmit()即可。

        HAL库为了方便你管理发送报文,它定义了一个ETH_TxPacketConfig

typedef struct
{
uint32_t Attributes; /*!< Tx packet HW features capabilities.
This parameter can be a combination of @ref ETH_Tx_Packet_Attributes*/

uint32_t Length; /*!< Total packet length */

ETH_BufferTypeDef *TxBuffer; /*!< Tx buffers pointers */

uint32_t SrcAddrCtrl; /*!< Specifies the source address insertion control.
This parameter can be a value of @ref ETH_Tx_Packet_Source_Addr_Control */

uint32_t CRCPadCtrl; /*!< Specifies the CRC and Pad insertion and replacement control.
This parameter can be a value of @ref ETH_Tx_Packet_CRC_Pad_Control */

uint32_t ChecksumCtrl; /*!< Specifies the checksum insertion control.
This parameter can be a value of @ref ETH_Tx_Packet_Checksum_Control */
……
} ETH_TxPacketConfigTypeDef;

        我只展示关键的字段,其中Attributes、SrcAddrCtrl、CRCPadCtrl、ChecksumCtrl已经由MX生成的代码做好设置工作了。真正需要你填写的只有TxBuffer。

        它对应的是你自己定义的报文,你不用理会DMA的什么描述符,什么寄存器。也不用管MAC要求的什么CRC校验。你只管考虑自己的应用需要什么样的报文,这是一个高级于MAC层的报文。也是HAL库让你解脱于繁琐的福利。例如我们定义这样的报文:

目标MAC地址+源MAC地址+报文长度/报文类型+报文内容

        前面三个字段是固定的,一定得有。其中“报文长度/报文类型”为两个bytes。当它的字段值 ≥ 0x0600 时,表示协议类型;若 < 0x0600 则表示数据长度。

基于上面格式制作出一个实例:

static unsigned char bytes[] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x0, 0x5, 0x68, 0x65, 0x6C, 0x6C, 0x6F};
  • 目标MAC地址是:0xffffffffffff
  • 源MAC地址是:0x000000000102
  • 数据长度:0x0005
  • 数据内容:hello的ascii码
ETH_TxPacketConfig TxConfig;
void Test_Send(){
    static unsigned char bytes[] = {0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x0, 0x00, 0x00, 0x00, 0x01, 0x02, 0x0, 0x5, 0x68, 0x65, 0x6C, 0x6C, 0x6F};
    if(TxConfig.TxBuffer == NULL){
        TxConfig.TxBuffer = malloc(sizeof(ETH_BufferTypeDef));
    }
    TxConfig.TxBuffer->buffer = bytes;
    TxConfig.TxBuffer->len = sizeof(bytes);
    TxConfig.TxBuffer->next = NULL;
    if(HAL_ETH_Transmit(&heth, &TxConfig, 1000) != HAL_OK){
        printf("ETH Transmit FAIL\n");
    }
    else{

    }
}

        注意到TxBuffer其实也是一个链表结构,当你的报文长度超过MAC的长度时,你可以用链表进行分包。这样HAL库会替你使用多个描述符去发送你的数据。

        在我的MX生成代码里,它给我准备了一个TxConfig并帮我初始化了它的其它字段,如果你的的版本没有,你可以自己定义一个,并自己参照手册完成它的其它字段初始化。

        我只需要填充TxBuffer字段,并调用HAL_ETH_Transmit发送函数。就可以在PC上捕获到数据了。

        注意TxBuffer->buffer对应的内存,需要在发送成功之后由你进行回收。因为我这里使用了static演示不需要回收。如果你是用malloc或new申请的,你要自己回收。

        如果你使用的是中断的方式发送。那你需要在中断回调中进行回收。具体可以阅读源代码,HAL有为你提供方便的回调式回收机制。

接收数据

        前面我们讲过,发送数据时,我们使用一个链表来储存要发送的报文。同样的,我们需要事先给DMA提供一个链表。这样DMA会在接收到数据时,将数据存入我们准备好的链表里。网上有讲过很高大上的东西。但我认为新手大概率是看不懂的。

        说实话,我也是一知半解的。所以咱们还是用土话来讲。好理解。

结构体
typedef struct{
    uint32_t own;
    uint32_t dataLength;
    uint32_t dataAddr;
    uint32_t next;
}ETH_DMADescTypeDef;

        还是熟悉的配方,熟悉的味道。我们定义这个结构体的数组做为接收的DMA描述符。并将它的next初始化好,成为一个链表。

ETH_DMADescTypeDef dmaRxList[N];
for(int i = 0; i < N; i++){
    if(i<N-1){
        dmaRxList[i].next = dmaRxList[i+1];
    }
    else{
        dmaRxList[i] = dmaRxList[0];
    }
}
heth->Instance->DMARDLAR = &dmaRxList[0];

        在将链表和DMA关联时,是通过DMARDLAR寄存器。

        在你调用HAL_ETH_START()之后,DMA就开始接收工作了。会把接收到数据时把数据放入链表中。你需要自己去查询节点的own位,看是否这个节点已经被DMA填充了接收数据。是的话便可以结合dataAddr+dataLength拿到数据。然后跳转到next节点重复操作。直到把所有数据都拿到手。

HAL库的使用

初始化工作:

        我们调用 HAL_ETH_Init(),它在里面会调用ETH_DMARxDescListInit来完成描述符的初始化。但是这时候还不会初始化描述符中的dataAddr,也就是它不会问你要内存空间。

        直到HAL_ETH_Start() 被调用时,它里面会执行一个ETH_UpdateDescriptor(heth) 的操作。这个时候它会就管你要内存空间了,因为这样DMA才有地方存放数据不是。而它是通过一个回调函数来管你要内存空间的。

__weak void HAL_ETH_RxAllocateCallback(uint8_t **buff)

        这是一个弱函数,需要你来实现。在这个回调中,给DMA一些内存空间。

void HAL_ETH_RxAllocateCallback(uint8_t **buff){
    //为它分配内存
    *buff = malloc(1524);
}

        如此,DMA就真正进入就绪状态了。可以准备接收数据了。

接收数据:

        我们想获取数据只需要调用相应的接口就行

HAL_StatusTypeDef HAL_ETH_ReadData(ETH_HandleTypeDef *heth, void **pAppBuff)

        很容易理解,我们传入一个指针的地址,然后HAL库就会把数据放在这个地址上。其中的复杂过程交由HAL去处理。美滋滋的。

        但你注意到没有,这是一个void类型的参数。HAL怎么知道它的具体含义呢?

        *pAppBuff它是一个char*? 还是struct?似乎都可以,因为任何变量都拥有其地址指针。那我们来看下HAL_ETH_ReadData到底干了什么活:

        这个函数它会去检查描述符的own字段以及其它状态位,在有数据时,便会把这个数据采集下来。如果一个报文太长被分成多个MAC包,就会采集到多个数据。HAL非常贴心,采集的繁琐工作他干了,在每采集到一个数据时它通过一个回调,直接把结果给到你。

__weak void HAL_ETH_RxLinkCallback(void **pStart, void **pEnd, uint8_t *buff, uint16_t Length)

        为什么需要回调呢?而不是拿到一个数据就直接返回结果呢?因为前面我们讲过,网络层的报文可能长度远超MAC层的报文,一个完整的报文需要多个描述符才能接收完整。所以回调的目录,是HAL库希望你自己进行拼接。细看这个回调的名称Link,顾名思义,就是要你把数据link起来。

        这个回调每被调用一次,就说明HAL库从一个描述符中拿到了一次数据,会把这次的数据通过buff、length给到我们。而这个buff就是我们上面*buff = malloc(1524)来的。length就是实际收到数据长度。

      首次回调时pStart和pEnd其实是空指针,它们都是void类型,唉,你发现没有,它跟pAppBuffer都是void类型。那聪明的你应该想到了,pAppBuffer具体是什么类型,取决于在HAL_ETH_RxLinkCallback 中我们怎么对待。pAppBuff和pStart是相互呼应的。我们可以定义任意的,自己觉得合适的数据类型来承载buff的数据。回调的实现方式非常多,下面给出我的思路:

void HAL_ETH_RxLinkCallback(void **pStart, void **pEnd, uint8_t *buff, uint16_t Length){
    if(*pStart == NULL){//这是报文的第一个段
        *pStart = malloc(sizeof(MyStruct));//创建你的结构体;以malloc方式得到,后面需要释放
        *pStart.append(buff,length);//把数据复制放到我们的结构体当中
        free(buff);	//释放掉内存空间,避免内存泄漏
    }
    else{
        //这不是第一段了,是后续的报文段
        *pStart.append(buff,length);//把数据追加到前面的报文中
        free(buff);
    }
}

        在HAL_ETH_ReadData 中,完成所有数据的采集后,它执行这么一句代码:

*pAppBuff = pStart

        呐呐呐,呼应上了。在回调中自定义的结构,在这里给到了返回参数。于是我们得到了这么一个使用方法:

void Test_Receive(){
    MyStruct *pAppBuff = NULL;
    if(HAL_OK == HAL_ETH_ReadData(&heth, &pAppBuff)){
        print(pAppBuff.data);//可以通过pAppBuff拿到完整的报文,具体看你怎么设计自己的结构体
        free(pAppBuff);//别忘了释放掉,避免内存泄漏
    }
}

        你可以通过打印,或者其它形式来验证你接收到的数据。

总结

        希望你通过我的文章,能顺利搭起通讯。我认为HAL库的代码怎么实现是次要的,网上的版本,以及deepseek中,诸多AI工具,他们使用的版本都不是我手头的版本。但究其核心,都是围绕DMA描述符进行的。

        所以你先理解我为你讲解的描述符,我主要是讲思想。然后再去看看手册中的描述符,实际使用中会增加一些细节。描述符融会贯通了,再去看你手头的HAL库,你就能很快抓住重点。调试好你的代码。

        MAC配置这块,我没讲,因为我现在也还没开始学。

        最后,如果有什么地方讲得不对的,欢迎指正。

        有什么地方讲得不清楚的,也欢迎提出。什么时候看到了,我会补充完整。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值