STM32F4+DP83848以太网通信指南系列(八):收包流程

前言:项目需求使用STM32F407进行以太网通信,并涉及到数据链路层的工业以太网通信,使用LWIP协议栈并不能满足需求,因此需要自己摸清STM32F407调用外部PHY进行网络收发包的过程,并在此基础上尝试自己构建适用于项目的网络协议栈。我基本上是从零开始着手这个项目的,之前只有一些STC51系列和STM32F1系列单片机开发的经验,项目开发过程中学习、参考、借鉴了很多网络上的教程和博客,在此尤其感谢正点原子团队发布的相关视频教程。公司买的开发板主芯片是一颗STM32F407,搭配了一颗DP83848的PHY,因此本系列教程将使用DP83848进行适配,同时原子哥的学习板和教程中是使用LAN8720这颗PHY进行适配的,本系列指南也会花一些篇幅介绍各种PHY与STM32芯片进行适配的方法。

为了您更好地阅读本系列,请点击原创连接进行浏览:

本章为系列指南的第八章,讲述如何使用STM32F407芯片配合DP83848进行以太网数据的收包流程,将监听到的网络包数据通过UART传给PC,同时辅以WireShark监听对比验证。

关于UART,也就是串口通信的使用,这里不做赘述,需要补充知识的朋友可以先浏览一下我在STM32F1系列的UART教程,F4系列也是大同小异,资源和引脚有所调整而已。我们这里预设两个函数分别为UART6Init()UART6Send(),实现的功能是串口6的初始化和发送。

以太网中断

《STM32F4+DP83848以太网通信指南第五章:MAC+DMA配置》中,我们已经添加了以太网中断,其思路就是想让每次以太网上有收到包都能触发中断,我们可以在中断中将DMA中的数据包取出来进行分析,然后复位,让芯片下一次继续响应中断。

配置中断的代码非常简单,跟其他任何中断都一样,这里再复习一次:

void ETH_NVIC_Config(void) {
    NVIC_InitTypeDef   NVIC_InitStructure;

    /* Enable the Ethernet global Interrupt */
    NVIC_InitStructure.NVIC_IRQChannel = ETH_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}

整个工程的优先级组别选用的NVIC_PriorityGroup_4,有4位抢占位,0位响应位,也就是可以分配16个可以互相嵌套的中断等级。这里以太网中断的主优先级为1,相当于第二高,前面预留了个优先级为0的,用来分配给系统计时器,毕竟不能因为以太网数据的响应影响系统走时。

UART6Init()串口初始化函数中,给串口的中断等级是2,低于以太网中断,因为串口的波特率是9600,要远远低于以太网速率,如果给串口的优先级过高,会影响以太网的使用。

配置了中断后,我们还需要知道中断的入口函数,这个函数名是固定死的,不能乱写,我们去找找。

《STM32F4+DP83848以太网通信指南第五章:MAC+DMA配置》我提到,中断配置代码中的ETH_IRQn变量,我们可以在stm32f4xx.h文件中找到定义,是在一个枚举结构中。那与之对应的中断入口名称该怎么找呢,原来所有的中断入口的定义,都用汇编入口的方式定义在启动文件中,这份启动文件我们之前一直没有关注过,现在打开看一看,在startup_stm32f40_41xxx.s中148行有其定义,截图如下。

中断中的数据处理

配置好了以太网中断,也知道了中断入口函数的名称,下面我们就来编写以太网中断函数。打开工程中的stm32f4xx_it.c文件,一般每一个使用了中断的STM32工程都会有这么一个文件,用来集中管理中断入口。追加以下代码:

/**
  * @brief  This function handles ethernet DMA interrupt request.
  * @param  None
  * @retval None
  */
void ETH_IRQHandler(void)
{
    /* Handles all the received frames */
    /* check if any packet received */
      while(ETH_CheckFrameReceived()){ 
        /* process received ethernet packet */
        Pkt_Handle();
    }
    /* Clear the Eth DMA Rx IT pending bits */
    ETH_DMAClearITPendingBit(ETH_DMA_IT_R);
    ETH_DMAClearITPendingBit(ETH_DMA_IT_NIS);
}

上面的代码配上英文的注释也很好理解,以太网中断中首先检查是否接受到以太网的数据包,如果是,就调用Pkt_Handle()函数进行下一层的分析和处理,最后两行Clear复位中断标记,让下一次中断能够产生。那么问题就集中到Pkt_Handle()函数上来了。

Pkt_Handle()函数是我自己命名的,这个函数的原型取自LWIP中的LWIP_Pkt_Handle(),我们先来观察一下LWIP中的包分析函数怎么写的:

STM32F4x7_ETH_LwIP_V1.1.1/Project/Standalone/udp_echo_client/src/netconf.c中,有如下代码:

/**
* @brief  Called when a frame is received
* @param  None
* @retval None
*/
void LwIP_Pkt_Handle(void)
{
  /* Read a received packet from the Ethernet buffers and send it to the lwIP for handling */
  ethernetif_input(&gnetif);
}

可以看到LWIP继续调用了下层的ethernetif_input(),继续追踪到我们之前提到的最底层文件ethernetif.c,是不是有种似曾相识的感觉,这个文件前几章我们不止一次遇到过,分别为我们提供了low_level_initlow_level_output多个重要函数,我们现在又一次遇到它了,看上去它这次要为我们的以太网监听提供low_level_input了。

果不其然,在ethernetif_input函数中,我们看到了这个预料中的函数调用,截图如下:

我这里把路径为STM32F4x7_ETH_LwIP_V1.1.1/Utilities/Third_Party/lwip-1.4.1/port/STM32F4x7/Standalone/ethernetif.c的原版low_level_input()的所有代码都贴出来,感兴趣的朋友可以仔细研读:

/**
 * Should allocate a pbuf and transfer the bytes of the incoming
 * packet from the interface into the pbuf.
 *
 * @param netif the lwip network interface structure for this ethernetif
 * @return a pbuf filled with the received packet (including MAC header)
 *         NULL on memory error
 */
static struct pbuf * low_level_input(struct netif *netif)
{
  struct pbuf *p, *q;
  u16_t len;
  int l =0;
  FrameTypeDef frame;
  u8 *buffer;
  uint32_t i=0;
  __IO ETH_DMADESCTypeDef *DMARxNextDesc;


  p = NULL;

  /* get received frame */
  frame = ETH_Get_Received_Frame();

  /* Obtain the size of the packet and put it into the "len" variable. */
  len = frame.length;
  buffer = (u8 *)frame.buffer;

  /* We allocate a pbuf chain of pbufs from the Lwip buffer pool */
  p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL);

  /* copy received frame to pbuf chain */
  if (p != NULL)
  {
    for (q = p; q != NULL; q = q->next)
    {
      memcpy((u8_t*)q->payload, (u8_t*)&buffer[l], q->len);
      l = l + q->len;
    }    
  }

  /* Release descriptors to DMA */
  /* Check if frame with multiple DMA buffer segments */
  if (DMA_RX_FRAME_infos->Seg_Count > 1)
  {
    DMARxNextDesc = DMA_RX_FRAME_infos->FS_Rx_Desc;
  }
  else
  {
    DMARxNextDesc = frame.descriptor;
  }

  /* Set Own bit in Rx descriptors: gives the buffers back to DMA */
  for (i=0; i<DMA_RX_FRAME_infos->Seg_Count; i++)
  {  
    DMARxNextDesc->Status = ETH_DMARxDesc_OWN;
    DMARxNextDesc = (ETH_DMADESCTypeDef *)(DMARxNextDesc->Buffer2NextDescAddr);
  }

  /* Clear Segment_Count */
  DMA_RX_FRAME_infos->Seg_Count =0;

  /* When Rx Buffer unavailable flag is set: clear it and resume reception */
  if ((ETH->DMASR & ETH_DMASR_RBUS) != (u32)RESET)  
  {
    /* Clear RBUS ETHERNET DMA flag */
    ETH->DMASR = ETH_DMASR_RBUS;
    /* Resume DMA reception */
    ETH->DMARPDR = 0;
  }
  return p;
}

同样的,配合注释应该也容易理解。22行的frame变量取到了以太网数据包,ETH_Get_Received_Frame函数,不用LWIP也是有的,在之前我们提到过的stm32f4x7_eth.c文件中,lenbuffer两个变量一个是包长度,一个是包内容头指针。

下面/* copy received frame to pbuf chain */那一段是用链表遍历的方式,将以太网包数据放入LWIP处理数据的pbuf链表中,方便LWIP上层逻辑获取数据,这里我们不使用LWIP,这一段可忽略。

接下来所有的操作都是针对DMA进行的,将DMA复位,因此我们需要保留,否则会一直产生重复的中断。

通过以上的分析,我们可以轻松写出自己的Pkg_Handle()函数了:

void Pkt_Handle(void) {
    FrameTypeDef frame;

    /* get received frame */
    frame = ETH_Get_Received_Frame();
    /* Obtain the size of the packet and put it into the "len" variable. */
    receiveLen = frame.length;
    receiveBuffer = (u8 *)frame.buffer;

    printf("0011%d0022\n", receiveLen);    //将每一个的包长度发往串口

    if(receiveBuffer[41] == 201){        //如果第42字节是十进制201,则将整个包内容发往串口
        for (i = 0; i < receiveLen; i++) {
            printf("%c", receiveBuffer[i]);
        }
    }

    /* Check if frame with multiple DMA buffer segments */
    if (DMA_RX_FRAME_infos->Seg_Count > 1) {
        DMARxNextDesc = DMA_RX_FRAME_infos->FS_Rx_Desc;
    } else {
        DMARxNextDesc = frame.descriptor;
    }

    /* Set Own bit in Rx descriptors: gives the buffers back to DMA */
    for (i = 0; i < DMA_RX_FRAME_infos->Seg_Count; i++) {
        DMARxNextDesc->Status = ETH_DMARxDesc_OWN;
        DMARxNextDesc = (ETH_DMADESCTypeDef *)(DMARxNextDesc->Buffer2NextDescAddr);
    }

    /* Clear Segment_Count */
    DMA_RX_FRAME_infos->Seg_Count = 0;

    /* When Rx Buffer unavailable flag is set: clear it and resume reception */
    if ((ETH->DMASR & ETH_DMASR_RBUS) != (u32)RESET) {
        /* Clear RBUS ETHERNET DMA flag */
        ETH->DMASR = ETH_DMASR_RBUS;
        /* Resume DMA reception */
        ETH->DMARPDR = 0;
    }
}

验证和总结

上述函数,配合ETH_IRQHandler中断中的调用,完成了以太网的收包,并且将接受的包的长度使用0011%d0022通过printf函数通过UART发往了PC端,因为如果将整个包内容发往PC的话,串口数据会非常多。同时如果为了验证buffer中的内容能正确获取,我们写了一个if判断,判断如果数据包中的第42个字节为201,则将包内容转发到串口中去。

我们用JLink烧录进STM32F4,将PC的有线网卡与STM32直接,打开WireShark和串口通信助手进行观察和验证,截图如下。

红色框框部分是关注重点,我们在CMD命令窗口ping 192.168.1.201,可以触发一个ARP包,这个包中的第42个字节就是201,因此可以触发STM32中的if判断,将包内容通过串口转发给PC,而其他普通包,STM32则使用0011%d0022的格式将包长度发给了PC,整个实验顺利完成。

总结一下,本章我们依旧分析到了ethernetif.c文件,这次是观察的它的low_level_input()函数,借助这个函数,我们编写了我们自己的处理包的逻辑函数Pkg_Handle(),并通过以太网中断入口函数ETH_IRQHandler调用它,最后我们成功的使用WireShark配合串口进行了收包的验证。

我们可以发现这个系列教程的后半段几乎都在不停地围绕LWIP库中的ethernetif.c文件进行分析,到目前为止,它的几个重要底层函数low_level_initlow_level_outputlow_level_input已经分别为我们的以太网初始化、以太网发包、以太网收包等代码提供了重要的核心逻辑。

下一章,是我们这个系列的最后一章,我们将在STM32F4上,利用之前实验过的各个功能,自己构建一个能响应ARP协议的程序。有了ARP协议的处理过程,我们就能在此基础上扩展更多其他的协议,即使遇到工业以太网跑在链路层的各个协议,我们也能捡其重点,按照自己的意愿,随心所欲的搭建了。

STM32F4+DP83848以太网通信指南系列(七):发包流程

前言:项目需求使用STM32F407进行以太网通信,并涉及到数据链路层的工业以太网通信,使用LWIP协议栈并不能满足需求,因此需要自己摸清STM32F407调用外部PHY进行网络收发包的过程,并在此基础上尝试自己构建适用于项目的网络协议栈。我基本上是从零开始着手这个项目的,之前只有一些STC51系列和STM32F1系列单片机开发的经验,项目开发过程中学习、参考、借鉴了很多网络上的教程和博客,在此尤其感谢正点原子团队发布的相关视频教程。公司买的开发板主芯片是一颗STM32F407,搭配了一颗DP83848的PHY,因此本系列教程将使用DP83848进行适配,同时原子哥的学习板和教程中是使用LAN8720这颗PHY进行适配的,本系列指南也会花一些篇幅介绍各种PHY与STM32芯片进行适配的方法。

为了您更好地阅读本系列,请点击原创连接进行浏览:

本章为系列指南的第七章,讲述如何在之前的基础上,编写程序在STM32上发送一个网络包,并使用WireShark进行验证。

先回顾一下之前的章节我们做好的准备工作,在《STM32F4+DP83848以太网通信指南第五章:MAC+DMA配置》结束时我们封装了一个DP83848的初始化函数,该函数完成了PHY的配置,MAC层的配置,DMA的配置,并且启用了以太网中断,函数命名为DP83848Init(),那么今天,我们要做的主要任务就是编写一个类似的DP83848Send(u8* data, u16 length)函数。

可以在本章的一开始跟大家剧透一个好消息,有了《STM32F4+DP83848以太网通信指南第四章:PHY配置》《STM32F4+DP83848以太网通信指南第五章:MAC+DMA配置》 的基础,我们本章最终实现的DP83848Send(u8* data, u16 length)函数,只有两行代码,非常非常简单。这两行代码我暂时先不贴出来,我们来顺着原来的思路,根据相关文档和官方示例代码,顺藤摸瓜,一步一步深入了解以太网发包的流程,最终理解体系结构后,也就水到渠成能够写出来了。

《STM32F4+DP83848以太网通信指南第五章:MAC+DMA配置》 最后一部分提到在LWIP官方样例中,路径为STM32F4x7_ETH_LwIP_V1.1.1\Utilities\Third_Party\lwip-1.4.1\port\STM32F4x7\Standalone\ethernetif.c的文件中,第76行有个low_level_init()函数,该函数调用ETH库函数对MAC底层及DMA进行了初始化。同样的,这份文件的138行,有个名为low_level_output(struct netif *netif, struct pbuf *p)的函数,疑似是向外输出网络包的函数,下面就对这部分代码进行分析,并试着用其中的核心逻辑进行测试。

因为ethernetif.c这份代码本身隶属于LWIP,而我们是不使用LWIP的,所以这份代码只能尽量去看懂和借鉴,想要原封不动地使用是不可以的。

我们先完整地贴出这个函数:

/**
 * This function should do the actual transmission of the packet. The packet is
 * contained in the pbuf that is passed to the function. This pbuf
 * might be chained.
 *
 * @param netif the lwip network interface structure for this ethernetif
 * @param p the MAC packet to send (e.g. IP packet including MAC addresses and type)
 * @return ERR_OK if the packet could be sent
 *         an err_t value if the packet couldn't be sent
 *
 * @note Returning ERR_MEM here if a DMA queue of your MAC is full can lead to
 *       strange results. You might consider waiting for space in the DMA queue
 *       to become availale since the stack doesn't retry to send a packet
 *       dropped because of memory failure (except for the TCP timers).
 */

static err_t low_level_output(struct netif *netif, struct pbuf *p) {
    err_t errval;
    struct pbuf *q;
    u8 *buffer =  (u8 *)(DMATxDescToSet->Buffer1Addr);
    __IO ETH_DMADESCTypeDef *DmaTxDesc;
    uint16_t framelength = 0;
    uint32_t bufferoffset = 0;
    uint32_t byteslefttocopy = 0;
    uint32_t payloadoffset = 0;

    DmaTxDesc = DMATxDescToSet;
    bufferoffset = 0;

    /* copy frame from pbufs to driver buffers */
    for(q = p; q != NULL; q = q->next) {
        /* Is this buffer available? If not, goto error */
        if((DmaTxDesc->Status & ETH_DMATxDesc_OWN) != (u32)RESET) {
            errval = ERR_BUF;
            goto error;
        }

        /* Get bytes in current lwIP buffer */
        byteslefttocopy = q->len;
        payloadoffset = 0;

        /* Check if the length of data to copy is bigger than Tx buffer size*/
        while( (byteslefttocopy + bufferoffset) > ETH_TX_BUF_SIZE ) {
            /* Copy data to Tx buffer*/
            memcpy( (u8_t *)((u8_t *)buffer + bufferoffset), (u8_t *)((u8_t *)q->payload + payloadoffset), (ETH_TX_BUF_SIZE - bufferoffset) );

            /* Point to next descriptor */
            DmaTxDesc = (ETH_DMADESCTypeDef *)(DmaTxDesc->Buffer2NextDescAddr);

            /* Check if the buffer is available */
            if((DmaTxDesc->Status & ETH_DMATxDesc_OWN) != (u32)RESET) {
                errval = ERR_USE;
                goto error;
            }

            buffer = (u8 *)(DmaTxDesc->Buffer1Addr);

            byteslefttocopy = byteslefttocopy - (ETH_TX_BUF_SIZE - bufferoffset);
            payloadoffset = payloadoffset + (ETH_TX_BUF_SIZE - bufferoffset);
            framelength = framelength + (ETH_TX_BUF_SIZE - bufferoffset);
            bufferoffset = 0;
        }

        /* Copy the remaining bytes */
        memcpy( (u8_t *)((u8_t *)buffer + bufferoffset), (u8_t *)((u8_t *)q->payload + payloadoffset), byteslefttocopy );
        bufferoffset = bufferoffset + byteslefttocopy;
        framelength = framelength + byteslefttocopy;
    }

    /* Note: padding and CRC for transmitted frame
       are automatically inserted by DMA */

    /* Prepare transmit descriptors to give to DMA*/
    ETH_Prepare_Transmit_Descriptors(framelength);

    errval = ERR_OK;

error:

    /* When Transmit Underflow flag is set, clear it and issue a Transmit Poll Demand to resume transmission */
    if ((ETH->DMASR & ETH_DMASR_TUS) != (uint32_t)RESET) {
        /* Clear TUS ETHERNET DMA flag */
        ETH->DMASR = ETH_DMASR_TUS;

        /* Resume DMA transmission*/
        ETH->DMATPDR = 0;
    }
    return errval;
}

这个函数的官方注释描述的就是用来向外发送以太网包的,函数中说要发的包在第二个参数,类型为pbuf结构体指针的参数p中,并且说了p可能是个链表,我们看到函数的两个入参都是结构体参数,这两个结构体的定义我们不需要管,是LWIP自己封装的一个结构体。我们去寻迹参数p的用法,在代码片段的30行,使用q变量和for循环遍历p,因此我们能够确定p就是个头尾相接的pbuf链表。继续观察遍历体中的操作逻辑,我们看到整个for循环的主要目的就是在尝试将q->payload中的byte,利用函数memcopy()buffer变量中堆,并且做了一些长度的校验,我们继而去观察一下buffer变量的定义,第19行的u8 *buffer = (u8 *)(DMATxDescToSet->Buffer1Addr);是一个比较重要的线索,由此我们可以抽丝剥茧出整体的逻辑,应该就是将首尾相接的p遍历出来,取其中每个元素的payload区域,向DMATxDescToSet->Buffer1Addr中压。最后,第73行的ETH_Prepare_Transmit_Descriptors(framelength);调用了ETH库中的函数,实现了最终的结局,将网络包发出去,入参的framelength应该就是需要发出去的包长度,包内容应该就是通过DMA技术,将内存中的DMATxDescToSet->Buffer1Addr发出去了。

有了以上针对low_level_output()函数的分析,我们来做实验印证一下,因为我们从零开始构建的项目没有LWIP,也没有ethernetif.c,更没有low_level_output()函数,因此,函数内部的逻辑都需要我们自己手动实现,慢着,不要一看到「手动实现」就头疼,你以为手动实现就很复杂吗?不,LWIP把事情搞复杂了,又是pbuf又是链表的,还有长度判断导致的Buffer2NextDescAddr切换(详见第43-62行一整段,不过不重要),如果我们手动写这段逻辑,放弃一些异常处理,再放弃那些跟LWIP强相关的结构体,我们整个发包函数只要两行就行:

void DP83848Send(u8* data, u16 length){
    memcpy((u8 *)DMATxDescToSet->Buffer1Addr, data, length);

    /* Prepare transmit descriptors to give to DMA*/
    ETH_Prepare_Transmit_Descriptors(length);
}

这里附带说明一下,并不是LWIP原版代码又臭又长,LWIP要做一个TCP/IP全栈协议,还要考虑包长度溢出的众多问题,我们精简版的协议很多不需要考虑,因此可以放弃很多繁琐的操作。

有了上述DP83848Send()函数,下面来做个小程序试验一下:

int main() {
    u8 MyMacAddr[6] = {0x08, 0x00, 0x06, 0x00, 0x00, 0x09};
    /* 下面是一段60byte大小的ARP报文,手动构建的 */
    u8 mydata[60] = {    0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00,
                     0x00, 0x01, 0x08, 0x06, 0x00, 0x01, 0x08, 0x00, 0x06, 0x04,
                     0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xc0, 0xa8,
                     0x02, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xc0, 0xa8,
                     0x02, 0xf0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
                     0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
    u32 clock;

    /* 默认调用SystemInit,系统时钟168MHz */
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4);        //4位抢占,0位响应

    DP83848Init(MyMacAddr);

    while(1){
        DP83848Send(mydata, 60);

        clock = 42000000;    //1s延时,while中每个步进需要4个周期
        while(clock--);
    }

}

使用Keil编译,用JLink下载到STM32F407中,给开发板接上网线,用WireShark就可以在网口中观察到STM32每隔1秒钟向外发送ARP报文了,虽然这段报文几乎没有任何意义。

我使用WireShark截图如下:

总结一下,这一章我们完成了一个DP83848Send()发包函数,这个函数可以接受一个字节buffer,一个字节buffer的长度,将这个buffer通过以太网发送出去,buffer内部的内容全部需要我们手工构建。DP83848Send()函数的设计思路来自于分析LWIP官网示例,主要是ethernetif.c中的代码。下一章我们同样根据这份代码,分析收包逻辑,实现STM32对以太网上数据的监听。

STM32F4+DP83848以太网通信指南系列(六):Wireshark使用

前言:项目需求使用STM32F407进行以太网通信,并涉及到数据链路层的工业以太网通信,使用LWIP协议栈并不能满足需求,因此需要自己摸清STM32F407调用外部PHY进行网络收发包的过程,并在此基础上尝试自己构建适用于项目的网络协议栈。我基本上是从零开始着手这个项目的,之前只有一些STC51系列和STM32F1系列单片机开发的经验,项目开发过程中学习、参考、借鉴了很多网络上的教程和博客,在此尤其感谢正点原子团队发布的相关视频教程。公司买的开发板主芯片是一颗STM32F407,搭配了一颗DP83848的PHY,因此本系列教程将使用DP83848进行适配,同时原子哥的学习板和教程中是使用LAN8720这颗PHY进行适配的,本系列指南也会花一些篇幅介绍各种PHY与STM32芯片进行适配的方法。

为了您更好地阅读本系列,请点击原创连接进行浏览:

本章为系列指南的第六章,这一章我们暂时离开Keil,离开STM32,离开C语言,这一章我们要去了解一些以太网相关的知识,特别是学习使用大名鼎鼎的调试以太网通信程序的利器:WireShark。

帧结构

我们从小就听说过计算机中全都是二进制的0和1,这个道理几乎连考不上三本的文科生都懂,但是很难有直观的感受,甚至连由16个bit,也就是16个二进制的0/1构成的十六进制0/F,再由两个十六进制的0/F形成的0x00/0xFF这种Byte(字节),都很难直观感受到,毕竟计算机世界太丰富了,各类色彩,图片,网站,视频,游戏充斥着我们的日常,所谓「乱花渐欲迷人眼,浅草才能没马蹄」,形形色色的计算机资源就是「乱花」,其背后单调的Byte才是「浅草」,我们在网络通信时只需要Byte的颗粒度,至于细化到bit的精度,只有在研究纯电子电路时才会涉及到。如果说如何传输bit是PHY层负责的问题,那么如何构造和解析Byte就是MAC层要肩负的责任了。

由Byte组成的一个个网络帧,势必要按照一定的格式进行排列,就像如果要你自己自己编排一个串口通信的协议,你也一定会考虑包头、包尾、校验位这些。

Ethernet V2协议在链路层规定了一个基本的帧结构,如下:

目标MAC 源MAC 类型 数据 循环冗余校验FCS
6字节 6字节 2字节 46-1500字节 4字节

因此,最小的帧长度是6 + 6 + 2 + 46 + 4 = 64个Byte,最大长度是6 + 6 + 2 + 1500 + 4 = 1518个Byte。

上表最后4个Byte形成了一组循环冗余的CRC校验数据,很遗憾的是我们能通过软件手段监听出来的帧结构都不会含有这段数据,因为网卡在接受到报文后就会立即对数据进行校验,凡是不满足校验规则的数据包会直接丢弃,不会触发操作系统的以太网事件。因此你能监听到的报文都是符合规则的,网卡交给上层软件应用时也会自动将校验数据剥离。此外,具体的循环冗余CRC校验的算法是什么大家也不用管了,在使用STM32调用发包指令时,MAC层会帮我们计算好的。

一个典型的ARP帧报文如下:

ff ff ff ff ff ff 00 11 0e 0b 03 8a 08 06 00 01
08 00 06 04 00 01 00 11 0e 0b 03 8a c0 a8 01 c7
00 00 00 00 00 00 c0 a8 01 fe 00 00 00 00 00 00
00 00 00 00 00 00 00 00 20 20 20 20

这是一个最简单的60Byte的ARP报文(另有4个FCS校验数据被网卡吞了,总长度64Byte),看上去是不是像是乱码,完全无法理解,别着急,下面介绍网络编程的神器WireShark。

WireShark·线路鲨鱼

这款名字里带有「鲨鱼」的软件,真的是名副其实,大鳄级的网络编程神器:精确到微妙级别的底层网络包监听,内置上千种网络协议,上下文分析得丝丝入扣,你通过它即可完全理解整个网络上正在发生了什么。无论是初学网络的思科学员想一窥网络的究竟,还是努力工作的程序员需要进行协议的分析,亦或是躲在阴暗处的黑客试图在交换机上刮起一场网络风暴,WireShark都能助你一臂之力。

软件下载地址:https://www.wireshark.org/#download

WireShark为了能够监听到网络底层的数据包,在Windows上使用了叫做winpcap的技术,而winpcap又是使用了libpcap这个库,这样的调用关系其实是为了解决一件事:能在操作系统上层操作底层网络数据包。

我们平时在C++,JAVA中谈论到的网络编程,其实所处的层级都是高层,回想一下那些Socket编程所讲到的知识,一般上来就会讲TCP/UDP,也就是处在高层应用开发的高度来讲,网络编程非TCP即UDP,别无其他。在《STM32F4+DP83848以太网通信指南第一章:知识储备》里我讲到以太网分为很多层(按照OSI模型有七层,按照TCP/IP协议有五层),除了应用层以外,我们还有很多底层的数据包跑在网络上(比如ARP数据包、ICMP数据包等),这些数据包大部分不是由一般编程人员通过各种网络应用程序发起的,那么它们是由谁发起的,又是由谁来负责接受和解析呢?这个答案是网卡、交换机,以及操作系统,这些数据包关系到网络的通路、拓扑、路由,用户层面一般不需要了解和控制,对操作系统来说,一般也是不会让用户随意触碰甚至自行构建网络帧的。

WireShark利用winpcap做到了监听底层网络包,它严格地限制了自己无法对网络数据进行修改、编辑、转发。一旦普通用户对以太网的了解更多一些,并且能够利用WireShark处理和转发网络包,那对于网络安全真是一件非常恐怖的事情。

说了这么多,其实都是在讲一些周边知识。WireShark的安装步骤我就不赘述了,下载和安装都是很简单的,现在我们来看看上面提到的那份看上去像乱码的ARP数据包,其实它就是我用WireShark捕获到的,上面的「乱码」只不过是我用十六进制的方式直接复制出来,接下来我们看看这份数据包在WireShark的帮助下会不会更清晰一点:

ARP帧

如上图所示,在WireShark的帮助下,数据包中每一个结构都清晰可见,WireShark帮助我们将一堆十六进制的Byte理解得就如同json数据一样清晰,其内置的数千种网络协议,使得WireShark对各种各样的网络包每一位的作用都了如指掌。在软件的下方区域点击任意一位Byte,它都会在中部区域将这一位的含义和作用清晰地显示出来。

我们再来看一个稍微复杂点的IPV4数据包,是我们常用的一个ping指令发出去的ICMP帧,在WireShark上显示74个Byte:

ICMP帧

同样,WireShark帮助我们理解起来要非常清晰,截图其实并不能很好地体现,我们在使用WireShark的时候随时可以用鼠标点击任意字节,显示其含义。

广播和点对点

最后我们再介绍一些交换机的附带知识,我们知道网络帧一般会在开头的6个字节标明这个网络帧的接受设备的MAC地址,如果这个包要向局域网内所有设备进行广播,比如上面那个ARP包,这六个字节一般为FF FF FF FF FF FF,如果这个包要向一个特定的设备进行通信,这六个字节则为一个确定的MAC地址,比如上面那个ICMP包,其指向了交换机,交换机收到了以后需要向上行网络请求。

如果局域网内的主机A需要向主机B发送一个点对点的数据,那么封装后的以太网帧的前六个字节就为主机B的MAC地址,交换机收到后会查询自己的维护的映射表,比如找到自己的端口2上的设备的MAC地址就是B的地址,那么交换机就向端口2上的网线投送这个数据包,交换机其他端口上的设备就收不到这则数据包了。

那么我们在做以太网协议分析时,一般是处于主机C的位置来监听和分析A和B的通信,大多数情况下他们之间是点对点通信,主机C在局域网中是观察不到这些数据包的,因为交换机压根就不将A和B的通信数据转发给主机C。

这时候就要祭出又一款硬件神器了:带有端口镜像功能的网管型交换机

网管型交换机

利用这种交换机,可以进入管理控制界面,配置端口映射,将设备A和设备B之间通信的双向数据全都转发给设备C,方便WireShark进行协议分析,最终我们可以根据分析出来的协议,在嵌入式STM32中编写自己的协议栈,实现DIY的设备D,与设备A进行通信,取代设备B。

端口映射

上图就是我购买的一款网管型交换机中配置端口映射的界面。

小结

这一章我们对以太网的帧结构有了一个基本的认识,了解了网络上跑的各种数据包在MAC层的表现形式,能够运用WireShark观察一些简单的由各种十六进制数据组成的数据包,最后我们还了解了如何运用网管型交换机配置端口映射,在局域网中的C端监听A和B的通信。

以上,为下一步在STM32嵌入式设备中编写网络协议打下了基础。

丁丁生于 1987.07.01 ,30岁,英文ID:newflydd

  • 现居住地 江苏 ● 泰州 ● 姜堰
  • 创建了 Jblog 开源博客系统
  • 坚持十余年的 独立博客 作者
  • 大学本科毕业后就职于 中国电信江苏泰州分公司,前两年从事Oracle数据库DBA工作,两年后公司精简技术人员,被安排到农村担任支局长(其本质是搞销售),于2016年因志向不合从国企辞职,在小城镇找了一份程序员的工作。
  • Git OSChina 上积极参与开源社区