首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

以太网之W5500

W5500WIZnet出品的一款全硬件TCP/IP协议芯片。它在一颗芯片上集成了TCP/IP协议栈、10/100M的MAC和PHY。 只用一颗芯片就可以在我们的应用中扩展出网络连接。因为芯片集成了TCP/IP协议和BSD套接字,所以用户也可以很方便的编程驱动W5500。 与宿主MCU之间通过SPI进行通信,接口简单、速度快。非常适合在嵌入式的系统中应用。

1. W5500结构

如左图所示为W5500的结构框图,从中可以看出它是3.3V供电,需要一个25MHz的时钟源驱动, 在芯片内部通过一个锁相环(PLL)倍频到150MHz。

因为芯片内部集成了一个物理层PHY,所以将其接入到网络中时,用户还需要提供一个隔离变压器(Transformer)和RJ45水晶头座。

芯片采用802.3以太网链路层MAC,支持10/100M连接的自协商,通过MII管理器(MII Manager)控制PHY进行链路层的网络通信。

W5500还支持IPv4, ARP, PPPoE, ICMP, IGMP, UDP和TCP等网络层和传输层的通信协议,并且提供了一套Socket接口,最多支持8个Socket。 我们只需要关心如何通过Socket编程实现UDP和TCP通信就好了。

W5500内部还有32KB的收发缓存,我们可以通过修改W5500的寄存器配置为每个Socket分配合适大小的收发缓存。

W5500与宿主之间通过SPI进行通信,支持80MHz的通信频率。用户通过SPI接口访问W5500的寄存器, 进而完成对其进行配置和网络通信等任务。

2. Host Interface

W5500以SPI从模式与宿主通信,需要SCSn, SCLK, MOSI, MISO四根信号线。

SPI通信中,根据时钟信号SCLK的电平和相位的不同,一共有4种工作方式。 其中模式0(CPOL = 0, CPHA = 0)和模式3(CPOL = 1, CPHA = 1)都是在时钟信号的上升沿采集数据,下降沿准备数据。 W5500只支持这两种工作模式,而且每次数据传输都是从最高位(MSB)到最低位(LSB)进行传输的。

W5500支持与宿主之间固定数据长度的通信,也支持可变数据长度的通信方式。具体由通信数据帧中的OM位决定。

在可变长度的通信方式下,片选信号SCSn将被用于标识数据传输的结束信号。所以在该模式下SCSn信号线必须由宿主控制。 在固定数据长度的通信方式下,可以直接把SCSn接地。只是这样,宿主的SPI端口将被W5500独占,不能再用于同其它设备的通信了。

2.1 SPI数据帧格式

如下图1所示,W5500的SPI数据帧格式由16位的地址段、8位的控制段和N个字节的数据段构成。

图1 W5500的SPI数据帧格式

地址段描述了后面数据段中进行读写访问的偏移地址, W5500内部以该段中的偏移地址为基础通过地址自增来实现数据的连续读写访问。

控制段又分为3个部分:

数据段就是实际传输的数据。其长度由控制段的OM[1:0]和片选信号决定,数据的传输方向由控制段的RWB位决定。

3. 网络通信实现

通过W5500进行网络通信,本质上就是通过SPI对W5500的寄存器和缓存进行读写访问。我们在SPI通信一文中已经给出了一个读W5500寄存器的例程。下面为了方便的对W5500进行访问, 我们先简单的对SPI接口进行一个封装。然后对W5500进行初始化配置,最后通过对Socket的访问分别实现TCP和UDP的网络通信。

3.1 W5500访问接口

根据前文,我们知道对W5500的访问需要遵循一定的数据帧格式。为了能够方便地进行访问W5500,我们定义了一系列的函数, 对其进行了简单的封装,采用可变长度的访问方式。下面以w5500_read_bytes()为例,予以介绍。

w5500_read_bytes()的定义和实现如下面代码所示。它的功能是从W5500中读取一段连续的寄存器或者缓存。它有三个参数:

        void w5500_read_bytes(uint32 addrbsb, uint8 *buf, int len) {
            spi_select();
        
            spi_exchange(W5500_SPI, (addrbsb & 0x00FF0000) >> 16);
            spi_exchange(W5500_SPI, (addrbsb & 0x0000FF00) >> 8);
            spi_exchange(W5500_SPI, (addrbsb & 0x000000F8));
        
            for (int i = 0; i < len; i++)
                buf[i] = spi_exchange(W5500_SPI, 0x00);
        
            spi_unselect();
        }

在函数的开始和结束,我们成对的调用了spi_select()和spi_unselect()。它们是两个宏定义, 分别用于片选W5500和释放片选信号。在不同的项目中,我们可能有不同的片选信号控制方式, 需要根据实际需要完成这两个宏的实现。因为W5500的可变长度访问方式是根据片选信号判断数据传送结束的, 所以必须保证在函数结束的时候调用spi_unselect()释放片选信号。

接着,我们在第4到6行中通过位与和右移位操作,通过spi_exchange()函数发送了地址段和控制段。 其中的W5500_SPI也是一个宏定义,需要根据实际使用的SPI外设进行设置。在发送控制段时, 我们只是用0x000000F8取出了addrbsb中的BSB部分。而控制段中的RWB和OM部分,则因为位与操作而为0, 也就表示本次通信是读操作,并且采用可变长度访问方式。

在第8、9行中,我们通过一个for循环处理数据段,把从W5500中读上来的数据保存到缓存buf中。

类似的,我们还定义了函数w5500_write_bytes()用于向W5500中写数据。它与w5500_write_bytes()相比有三点不同之处:

大多数时候,我们需要一个字节一个字节的访问W5500中的内容, 所以专门定义了两个函数w5500_read_byte()和w5500_write_byte()用于一个字节的读写操作。 函数的实现没有什么讲的,其声明如下面代码所示。w5500_read_byte()只有一个参数addrbsb用于描述需要访问的地址和区块, 最终会返回读上来的字节。w5500_write_byte()多了一个参数data,用于记录需要下发的数据的内容,没有返回值。

    uint8 w5500_read_byte(uint32 addrbsb);
    void w5500_write_byte(uint32 addrbsb, uint8 data);

此外,因为W5500的数据传输是高位在前低位在后的,这点与STM32的小端存储方式不一致。 所以如果从W5500中读上来一个16位的数据,它在STM32中高8位的数据和低8位的数据正好相反。为了解决这类问题, 我们专门定义了两个函数w5500_read_bytes_inv()和w5500_write_bytes_inv()。 它们的参数列表和实现方式分别与w5500_read_bytes()和w5500_write_bytes()类似,只是数据传输时是逆序更新buf缓存的。

        // w5500_read_bytes
        for (int i = 0; i < len; i++)
            buf[len - i - 1] = spi_exchange(W5500_SPI, 0x00);
        // w5500_write_bytes
        for (int i = 0; i < len; i++)
            spi_exchange(W5500_SPI, buf[len - i - 1]);

借助这六个函数,我们就能够很方便的访问W5500了。下面我们利用它们对W5500进行初始化配置。

3.2 W5500的初始化配置

为了方便的对W5500进行配置,我们先定义了一个结构用于描述W5500的配置信息,如下:

        struct w5500 {
            uint8 mac[6];           // 本地MAC地址
            uint8 ip[4];            // 本地IP地址
            uint8 sub[4];           // 子网掩码
            uint8 gw[4];            // 网关地址
            uint8 txbuf_size[8];    // 各个Socket的发送缓存区大小
            uint8 rxbuf_size[8];    // 各个Socket的接收缓存区大小
            uint16 timeout;         // 超时时长
            uint8 rcount;           // 重复次数
        };
        struct w5500 gW5500 = {
            .mac = { 0x00,0x98,0xdc,0x42,0x61,0x11 },
            .ip = { 192,168,1,10 },
            .sub = { 255,255,255,0 },
            .gw = { 192,168,1,1 },
            .txbuf_size = { 2,2,2,2, 2,2,2,2 },
            .rxbuf_size = { 2,2,2,2, 2,2,2,2 },
            .timeout = 2000,
            .rcount = 8
        };

接下来,我们定义函数w5500_init()进行初始化配置,它有一个参数dev,指向了我们的配置对象。 首先初始化SPI接口,再延时一段是时间保证W5500开始工作。

    w5500_error_t w5500_init(struct w5500 *dev) {
        w5500_init_spi();
        delay_ms(2000);
下面验证W5500是否已经接入网络中了。W5500的PHYCFGR用于描述其内部PHY工作情况, 其中bit0='1'(也就是这里的W5500_PHY_LinkUp)表示链接已经建立,bit1='1'表示工作在100M的带宽下,bit1='0'则是10M带宽。 而bit2='1'表示链接是全双工通信,bit2='0'则是半双工通信。
       // 检查链接是否建立
        uint8 retry = 100;
        while (0 == (W5500_PHY_LinkUp & w5500_read_byte(W5500_PHYCFGR))) {
            delay_ms(100);
            if (0 == retry--)
                return Err_W5500_Phy_No_Link;
            retry--;
        }
接下来,依次把参数dev各个字段中的值下发到W5500,如果最后函数返回Err_W5500_No_Error则表明配置成功。
        w5500_sw_reset();
        // 配置本地mac, 子网掩码, 网关, ip
        w5500_write_bytes(W5500_SHAR, dev->mac, 6);
        w5500_write_bytes(W5500_SUBR, dev->sub, 4);
        w5500_write_bytes(W5500_GAR, dev->gw, 4);
        w5500_write_bytes(W5500_SIPR, dev->ip, 4);
        // 配置Socket收发缓存大小
        for (int i = 0; i < W5500_Max_Socket_Num; i++) {
            w5500_write_byte(W5500_Sn_RXBUF_SIZE(i), dev->rxbuf_size[i]);
            w5500_write_byte(W5500_Sn_TXBUF_SIZE(i), dev->txbuf_size[i]);
        }
        // 超时时间和重复次数
        w5500_write_bytes_inv(W5500_RTR, (uint8*)&(dev->timeout), 2);
        w5500_write_byte(W5500_RCR, dev->rcount);
        // 配置中断
        w5500_write_byte(W5500_IMR, (W5500_INT_CONFLICT | W5500_INT_UNREACH));
        w5500_write_byte(W5500_SIMR, U8Bit(0));
        w5500_write_byte(W5500_Sn_IMR(0), W5500_SnIR_All);
    
        return Err_W5500_No_Error;
    }
完成初始配置之后,我们还需要配置Socket,才进行UDP和TCP的网络通信了。

3.3 Socket配置

我们仍然先定义一个结构体用于方便Socket的管理:

        struct w5500_socket {
            uint8 n;                // Socket索引,取值[0,7]
            uint8 mode;             // Socket的工作模式
            uint8 remote_ip[4];     // 远端的IP地址
            uint16 remote_port;     // 远端的端口
            uint16 local_port;      // 本地Socket的端口
        };
        struct w5500_socket gSocket0 = {
            .n = 0,
            .mode = W5500_SnMR_UDP,
            .remote_ip = { 192,168,1,101 },
            .remote_port = 5000,
            .local_port = 5000
        };
其中的字段n表示socket的索引,因为W5500就只有8个Socket,所以其取值范围就是[0,7]。socket可以工作在UDP、TCP、MACRAW 等工作模式下,在不同的工作模式下还有一些可选的功能,这些都由SnMR寄存器管理, 其具体的位定义参考W5500的文档

在使用Socket进行通信之前,需要先配置Socket并打开之。 在下面的函数中我们配置了Socket的工作模式和本地端口后,就将其打开了。

    w5500_error_t w5500_init_socket(const struct w5500_socket *socket) {
        w5500_error_t err = w5500_close_socket(socket);
        if (Err_W5500_No_Error != err)
            return err;
        // 配置mode和port
        w5500_write_byte(W5500_Sn_MR(socket->n), socket->mode);
        w5500_write_bytes_inv(W5500_Sn_PORT(socket->n), (uint8 *)&(socket->local_port), 2);
        // 打开套接字
        if (!w5500_send_sncmd(socket->n, W5500_SnCR_OPEN))
            return Err_W5500_Socket_CmdErr;
    
        return Err_W5500_No_Error;
    }
完成Socket配置之后,我们就可以进行UDP和TCP的网络通信了。

3.4 TCP通信

如果在初始化Socket时,工作模式选择的是TCP,那么成功进程初始化之后,Socket将进入SOCK_INIT状态。 在这个状态下,我们还需要进一步的配置才可以进行TCP通信。

我们知道TCP是一种可靠的网络传输方式,在进行TCP通信之前,需要先进行一个3次握手的流程以建立连接:

  1. 首先,由客户端发送SYN报文给服务器,请求建立连接,此时客户端进入SYN_SEND状态。
  2. 当服务器接收到SYN报文后,发送一个SYN-ACK作为应答,然后进入SYN_RECV状态。
  3. 客户端接收到SYN-ACK后,发送一个ACK报文,进入Established状态。
在3次握手完成之后,服务器和客户端之间就建立起了连接,可以互相传输数据了。服务器和客户端是一个相对的概念, 在一次TCP连接中,发起连接的机器就是客户端,另一方则是服务器。W5500提供了LISTEN指令和CONNECT指令, 分别用于服务器和客户端模式。

我们定义了函数w5500_listen_socket(),用于作为服务器监听socket的本地端口。该函数有一个参数socket为我们的操作对象, 在该函数中我们只是向W5500发送了一条LISTEN的指令。

    w5500_error_t w5500_listen_socket(const struct w5500_socket *socket) {
        // 监听连接
        if (!w5500_send_sncmd(socket->n, W5500_SnCR_LISTEN))
            return Err_W5500_Socket_CmdErr;
        return Err_W5500_No_Error;
    }
成功调用该函数之后,Socket将工作在服务器模式下,接收到SYN报文后,W5500会帮我们完成三次握手操作,并建立连接。

我们还定义了函数w5500_connect_socket(),用于作为客户端发起TCP连接。这个函数看似复杂了一些,其工作实际上十分简单: (1)先配置服务器端的ip和端口(2)再发送CONNECT指令给Socket,W5500会帮我们完成三次握手操作。 如果成功连接到服务器上,Socket就会进入ESTABLISHED状态(3)通过判定Socket的状态,判定连接是否建立, 如果长时间没有收到来自服务器的SYN-ACK信号,就返回超时错误字并退出(4)如果成功建立了连接就返回No_Error并退出。

    w5500_error_t w5500_connect_socket(const struct w5500_socket *socket) {
        // 配置远端
        w5500_write_bytes(W5500_Sn_DIPR(socket->n), socket->remote_ip, 4);
        w5500_write_bytes_inv(W5500_Sn_DPORT(socket->n), (uint8 *)&(socket->remote_port), 2);
        // 建立连接
        if (!w5500_send_sncmd(socket->n, W5500_SnCR_CONNECT))
            return Err_W5500_Socket_CmdErr;
        // 超时退出
        uint8 tmp = w5500_read_byte(W5500_Sn_IR(socket->n));
        while (W5500_SnSR_ESTABLISHED != w5500_read_byte(W5500_Sn_SR(socket->n))) {
            tmp = w5500_read_byte(W5500_Sn_IR(socket->n));
            if (W5500_SnIR_TIMEOUT & tmp) {
                w5500_write_byte(W5500_Sn_IR(socket->n), W5500_SnIR_TIMEOUT);
                return Err_W5500_Timeout;
            }
        }
        // 清除连接中断
        w5500_write_byte(W5500_Sn_IR(socket->n), W5500_SnIR_CON);
        return Err_W5500_No_Error;
    }
成功调用该函数之后,就可以建立起一个TCP连接。

建立起连接之后,我们就可以通过网络与socket进行通信了。Socket把接收到的数据放在接收缓存中, 我们通过向Socket的发送缓存中写数据实现发送网络数据。

这里定义了函数w5500_receive()用于接收TCP数据,它有三个参数,socket指向了我们的套接字对象, buf指向了一段缓存用于存放接收数据,len为需要接收的数据长度。该函数最后返回一个整型数据表示接收的数据长度, 如果中间出了什么错误,则返回-1。

        int w5500_receive(struct w5500_socket *socket, uint8 *buf, uint16 len) {
            uint16 ptr = 0;
            w5500_read_bytes_inv(W5500_Sn_RX_RD(socket->n), (uint8*)&ptr, 2);
        
            uint32 addrbsb = ((uint32)ptr << 8) + (socket->n << 5) + 0x18;
            w5500_read_bytes(addrbsb, buf, len);
        
            ptr += len;
            w5500_write_bytes_inv(W5500_Sn_RX_RD(socket->n), (uint8*)&ptr, 2);
        
            if (!w5500_send_sncmd(socket->n, W5500_SnCR_RECV))
                return -1;
        
            return len;
        }
该函数是完全按照文档中的第56页描述的接收数据流程编写的:
  1. Sn_RX_RD寄存器中获取接收缓存的起始地址(2,3行)。
  2. 从起始地址开始读取数据(5,6行)。
  3. 结束读取数据后,用已读数据长度与起始地址的和更新Sn_RX_RD(8,9行)。
  4. 发送RECV指令给Socket,通知W5500更新接收缓存地址(11,12行)。

类似的,我们还定义了w5500_send()函数用于发送TCP数据。也是按照文档中描述的流程编写的。

  1. Sn_TX_WR寄存器中获取发送缓存的起始地址。
  2. 从起始地址开始写入数据。
  3. 结束写入数据后,用已写数据长度与起始地址的和更新Sn_TX_WR。
  4. 发送SEND指令给Socket,通知W5500发送数据并更新接收缓存地址。
我们的实现中,在文档流程之前先检查了接收缓存是否有足够的空间供我们写入新的数据。在文档流程之后检测是否成功发送, 并报错。

3.5 UDP通信

用UDP模式初始化Socket,结束后Socket就会进入SOCK_UDP状态。在这个状态下,我们可以直接进行UDP通信。

UDP的网路通信,不需要经过TCP这样复杂的握手机制建立连接,它的是一种简单的不可靠信息传递方式。 原理上,我们也是通过对Socket的接收和发送缓存进行访问,来实现UDP的网络通信,但与TCP相比略有不同。

接收到一包UDP网络数据,我们需要先找到数据的来源,也就是发送这包数据的计算机的IP和端口。 在官方的WiKi上, 说明了W5500中UDP接收和发送数据的组织方式,如下图2所示。前10个字节描述了数据包的信息,包括远端机器的IP、 端口和实际数据内容长度。剩下的部分才是实际的数据内容。

图1 UDP模式下的接收和发送格式
根据这个数据格式,我们又定义了函数w5500_receive_from()和w5500_send_to()用于UDP模式下数据的接收和发送。

函数w5500_receive_from()的参数列表与w5500_receive()的一样。函数所完成的功能也基本是一样的, 只是前者需要先从接收缓存中取出前10个字节,解析出IP,端口和数据长度,如下面代码所示。

        int16 ptr = 0;
        w5500_read_bytes_inv(W5500_Sn_RX_RD(socket->n), (uint8*)&ptr, 2);
    
        uint8 head[8];
        uint32 addrbsb = ((uint32)ptr << 8) + (socket->n << 5) + 0x18;
        w5500_read_bytes(addrbsb, head, 8);
        ptr += 8;
    
        socket->remote_ip[0] = head[0];
        socket->remote_ip[1] = head[1];
        socket->remote_ip[2] = head[2];
        socket->remote_ip[3] = head[3];
        ((uint8 *)&(socket->remote_port))[1] = head[4];
        ((uint8 *)&(socket->remote_port))[0] = head[5];
        ((uint8 *)&len)[1] = head[6];
        ((uint8 *)&len)[0] = head[7];

函数w5500_send_to()则在配置完远程ip和端口后,干脆直接调用了w5500_send()。 配置远程ip和端口用于指示本包数据的目标机器和应用程序。

        w5500_error_t w5500_send_to(struct w5500_socket *socket, const uint8 *buf, uint16 len) {
            w5500_write_bytes(W5500_Sn_DIPR(socket->n), socket->remote_ip, 4);
            w5500_write_bytes_inv(W5500_Sn_DPORT(socket->n), (uint8 *)&socket->remote_port, 2);
        
            return w5500_send(socket, buf, len);
        }

4. 总结

W5500是一款硬件实现的集TCP/IP协议栈、10M/100M MAC、PHY与一身的芯片,通过SPI与宿主机器通信, 提供了方便的接口,非常适合嵌入式的设备。它支持8个独立的Socket,也就是说同时可以支持与8个设备的网络通信。

在本文对应的例程中 ,我们使用了三个Socket,它们所实现的功能都是把接收到的数据包再原样返回。 其中一个Socket工作在UDP模式下,一个工作在TCP服务器模式下,另一个则工作在TCP客户端模式下。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1