一文看懂网卡驱动原理及移植方法

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文看懂网卡驱动原理及移植方法相关的知识,希望对你有一定的参考价值。


1、网卡设备驱动原理

1.1 层次结构

Linux系统对网络设备驱动定义了4个层次, 这4个层次有到下分为:

  • 1、网络协议接口层:实现统一的数据包收发的协议。该层主要负责调用​​dev_queue_xmit()​​​函数发送数据,​​netif_rx()​​​函数接收数据。当上层 ARP 或 IP 协议需要发送数据包时,它将调用网络协议接口层的​​dev_queue_xmit()​​​函数发送该数据包,同时需传递给该函数一个指向​​struct sk_buff​​​数据 结构的指针。​​dev_queue_xmit()​​函数的原型为:
dev_queue_xmit (struct sk_buff * skb );

同样地,上层对数据包的接收也通过向 ​​netif_rx()​​​函数传递一个 ​​struct sk_buff​​数据 结构的指针来完成。netif_rx()函数的原型为:

int netif_rx(struct sk_buff *skb);
  • 2、网络设备接口层:通过​​net_device​​结构体来描述一个具体的网络设备的信息,实现不同的硬件的统一
  • 3、设备驱动功能层:用来负责驱动网络设备硬件来完成各个功能, 它通过​​hard_start_xmit()​​ 函数启动发送操作, 并通过网络设备上的中断触发接收操作。
  • 4、网络设备与媒介层:用来负责完成数据包发送和接收的物理实体, 设备驱动功能层的函数都在这物理上驱动的

层次结构如下图所示:

一文看懂网卡驱动原理及移植方法_网络协议

设计具体的网络设备驱动程序时,我们需要完成的主要工作是编写设备驱动功 能层的相关函数以填充 net_device 数据结构的内容并将 net_device 注册入内核。

1.2 网卡驱动的初始化

网卡驱动程序,只需要编写网络设备接口层,填充​​net_device​​​数据结构的内容并将​​net_device​​注册入内核,设置硬件相关操作,使能中断处理等。

1.2.1 初始化网卡步骤

  • 1)使用​​alloc_netdev()​​来分配一个net_device结构体
  • 2)设置网卡硬件相关的寄存器
  • 3)设置​​net_device​​结构体的成员
  • 4)使用​​register_netdev()​​来注册net_device结构体

1.2.2 net_device结构体

网络设备接口层的主要功能是为千变万化的网络设备定义了统一、抽象的数据结 构 net_device 结构体,以不变应万变,实现多种硬件在软件层次上的统一。 net_device 结构体在内核中指代一个网络设备,网络设备驱动程序只需通过填充 net_device 的具体成员并注册 net_device 即可实现硬件操作函数与内核的挂接。 net_device 本身是一个巨型结构体,包含网络设备的属性描述和操作接口。当我 们编写网络设备驱动程序时,只需要了解其中的一部分。

struct net_device

char name[IFNAMSIZ]; //网卡设备名称
unsigned long mem_end; //该设备的内存结束地址
unsigned long mem_start; //该设备的内存起始地址
unsigned long base_addr; //该设备的内存I/O基地址
unsigned int irq; //该设备的中断号

unsigned char if_port; //该字段仅针对多端口设备,用于指定使用的端口类型
    unsigned char dma; //该设备的DMA通道
unsigned long state; //网络设备和网络适配器的状态信息

struct net_device_stats* (*get_stats)(struct net_device *dev); //获取流量的统计信息,运行ifconfig便会调用该成员函数,并返回一个net_device_stats结构体获取信息

struct net_device_stats stats; //用来保存统计信息的net_device_stats结构体


unsigned long features; //接口特征,
unsigned int flags; //flags指网络接口标志,以IFF_开头,包括:IFF_UP( 当设备被激活并可以开始发送数据包时, 内核设置该标志)、 IFF_AUTOMEDIA(设置设备可在多种媒介间切换)、IFF_BROADCAST( 允许广播)、IFF_DEBUG( 调试模式, 可用于控制printk调用的详细程度) 、 IFF_LOOPBACK( 回环)、IFF_MULTICAST( 允许组播) 、 IFF_NOARP( 接口不能执行ARP,点对点接口就不需要运行 ARP) 和IFF_POINTOPOINT( 接口连接到点到点链路) 等。


unsigned mtu; //最大传输单元,也叫最大数据包

unsigned short type;   //接口的硬件类型

unsigned short hard_header_len; //硬件帧头长度,在以太网设备的初始化函数中一般被赋为ETH_HLEN,即14

    unsigned char *dev_addr; //存放设备的MAC地址,需由驱动程序从硬件上读出
unsigned char broadcast[MAX_ADDR_LEN]; //存放设备的广播地址,对于以太网而言,地址长度为6个0XFF

unsigned long last_rx; //接收数据包的时间戳,调用netif_rx()后赋上jiffies即可

unsigned long trans_start; //发送数据包的时间戳,当要发送的时候赋上jiffies即可

int (*hard_start_xmit) (struct sk_buff *skb, struct net_device *dev);//数据包发送函数, 以使得驱动程序能获取从上层传递下来的数据包。

    void (*tx_timeout) (struct net_device *dev); //发包超时处理函数,需采取重新启动数据包发送过程或重新启动硬件等策略来恢复网络设备到正常状态
    ... ...

1.2.3 net_device_stats结构体

net_device_stats 结构体定义在内核的 include/linux/netdevice.h 文件中,其中重要成员如下所示:

struct net_device_stats

unsigned long rx_packets; /*收到的数据包数*/
unsigned long tx_packets; /*发送的数据包数 */
unsigned long rx_bytes; /*收到的字节数,可以通过sk_buff结构体的成员len来获取*/ unsigned long tx_bytes; /*发送的字节数,可以通过sk_buff结构体的成员len来获取*/
unsigned long rx_errors; /*收到的错误数据包数*/
unsigned long tx_errors; /*发送的错误数据包数*/
... ...

net_device_stats 结构体适宜包含在设备的私有信息结构体中,而其中统计信息的 修改则应该在设备驱动的与发送和接收相关的具体函数中完成,这些函数包括中断处 理程序、数据包发送函数、数据包发送超时函数和数据包接收相关函数等。我们应该 在这些函数中添加相应的代码:

/* 发送超时函数 */ 
void xxx_tx_timeout(struct net_device *dev)

struct xxx_priv *priv = netdev_priv(dev);
...
priv->stats.tx_errors++; /* 发送错误包数加 1 */
...


/* 中断处理函数 */
static void xxx_interrupt(int irq, void *dev_id, struct pt_regs *regs)

switch (status &ISQ_EVENT_MASK)

...
case ISQ_TRANSMITTER_EVENT: /
priv->stats.tx_packets++; /* 数据包发送成功,tx_packets 信息加1 */
netif_wake_queue(dev); /* 通知上层协议 */
if ((status &(TX_OK | TX_LOST_CRS | TX_SQE_ERROR |
TX_LATE_COL | TX_16_COL)) != TX_OK) /*读取硬件上的出错标志*/

/* 根据错误的不同情况,对 net_device_stats 的不同成员加 1 */
if ((status &TX_OK) == 0)
priv->stats.tx_errors++;
if (status &TX_LOST_CRS)
priv->stats.tx_carrier_errors++;
if (status &TX_SQE_ERROR)
priv->stats.tx_heartbeat_errors++;
if (status &TX_LATE_COL)
priv->stats.tx_window_errors++;
if (status &TX_16_COL)
priv->stats.tx_aborted_errors++;

break;
case ISQ_RX_MISS_EVENT:
priv->stats.rx_missed_errors += (status >> 6);
break;
case ISQ_TX_COL_EVENT:
priv->stats.collisions += (status >> 6);
break;

1.2.4 sk_buff结构体

sk_buff 结构体非常重要,它的含义为“套接字缓冲区”,用于在 Linux 网络子系 统中的各层之间传递数据,是 Linux 网络子系统数据传递的“中枢神经”。当发送数据包时,Linux 内核的网络处理模块必须建立一个包含要传输的数据包 的 sk_buff,然后将 sk_buff 递交给下层,各层在 sk_buff 中添加不同的协议头直至交 给网络设备发送。同样地,当网络设备从网络媒介上接收到数据包后,它必须将接收 到的数据转换为 sk_buff 数据结构并传递给上层,各层剥去相应的协议头直至交给用 户。

参看 linux/skbuff.h 中的源代码,sk_buff 结构体包含的主要成员如下:

/* /include/linux/skbuff.h */
struct sk_buff
/* These two members must be first. */
struct sk_buff *next; //指向下一个sk_buff结构体
struct sk_buff *prev; //指向前一个sk_buff结构体
    ktime_t tstamp;
struct sock *sk;
struct net_device *dev;
/*
* This is the control buffer. It is free to use for every
* layer. Please put your private variables there. If you
* want to keep them across layers you have to do a skb_clone()
* first. This is owned by whoever has the skb queued ATM.
*/
......
unsigned int len,       //数据包的总长度,包括线性数据和非线性数据
data_len; //非线性的数据长度
__u16 mac_len, //mac包头长度
hdr_len;
.....
    __u32   priority; //该sk_buff结构体的优先级
......
    __be16   protocol; //存放上层的协议类型,可以通过eth_type_trans()来获取
... ...

sk_buff_data_t transport_header;   //传输层头部的偏移值
sk_buff_data_t network_header;   //网络层头部的偏移值
sk_buff_data_t mac_header; //MAC数据链路层头部的偏移值
/* These elements must be at the end, see alloc_skb() for details. */
    sk_buff_data_t tail; //指向缓冲区的数据包末尾
sk_buff_data_t end; //指向缓冲区的末尾
unsigned char     *head, //指向缓冲区的协议头开始位置
*data; //指向缓冲区的数据包开始位置
unsigned int truesize;
atomic_t users;
  1. 数据缓冲区指针 head、data、tail 和 end。

head 指针指向内存中已分配的用于承载网络数据的缓冲区的起始地址;

data 指针则指向对应当前协议层有效数据的起始地址。各层的有效数据信息包含的内容都不一样:对于传输层而言,用户数据和传输层协议头属于有效数据。 l 对于网络层而言,用户数据、传输层协议头和网络层协议头是其有效数据。 l 对于数据链路层而言,用户数据、传输层协议头、网络层协议头和链路层头 部都属于有效数据。 因此,data 指针的值需随着当前拥有 sk_buff 的协议层的变化进行相应的移动。

tail 指针则指向对应当前协议层有效数据负载的结尾地址,与 data 指针对应。

end 指针指向内存中分配的数据缓冲区的结尾,与 head 指针对应。

其实,end 指针所指地 址 数 据 缓 冲 区 的 末 尾 还 包 括 一 个 ​​skb_shared_info​​结构体的空间,这个结构体 存放分隔存储的数据片段,意味着可以将数 据包的有效数据分成几片存储在不同的内存空间中。 每一个分片frags的长度上限是一页。

  • sk_buff结构体的空间
    *
  • 一文看懂网卡驱动原理及移植方法_网卡驱动_02

  1. 长度信息 len、data_len、truesize。

len是指数据包有 效数据的长度,包括协议头和负载;

data_len 这个成员,它记录分片的 数据长度;

truesize 表示缓存区的整体长度: sizeof(struct sk_buff) + “传入 alloc_skb()或dev_alloc_skb()的长度“(但不包括结构体 skb_shared_info 的长度)。

  • sk_buff-> data数据包格式

一文看懂网卡驱动原理及移植方法_linux_03

  • 套接字缓冲区操作
  • (1)分配
  • ​struct sk_buff *alloc_skb(unsigned int len,int priority);​​alloc_skb()函数分配一个套接字缓冲区和一个数据缓冲区,参数 len 为数据缓冲区 的空间大小,以 16 字节对齐,参数 priority 为内存分配的优先级
  • ​struct sk_buff *dev_alloc_skb(unsigned int len);​​dev_alloc_skb()函数只是以 GFP_ATOMIC 优先级(代表分配过程不能被中断)调 用上面的 alloc_skb()函数,并保存 skb->head 和 skb->data 之间的 16 个字节。
  • 分配成功之后,因为还没有存放具体的网络数据包,所以 sk_buff 的 data、tail 指 针都指向存储空间的起始地址 head,而 len 的大小则为 0。
  • (2)释放
void kfree_skb(struct sk_buff *skb);  //在内核内部使用,而网络设备 驱动程序中则必须使用下面3个其一
void dev_kfree_skb(struct sk_buff *skb); //用于非中断上下文
void dev_kfree_skb_irq(struct sk_buff *skb); //用于中断上下文
void dev_kfree_skb_any(struct sk_buff *skb); //在中断和非中断上下文中皆可采用
  • (3)指针移动
    套接字缓冲区中的数据缓冲区指针移动操作包括 put(放置)、push(推)、 pull(拉)、reserve(保留)等。
  • put 操作(用于在缓冲区尾部添加数据):
unsigned char *skb_put(struct sk_buff *skb, unsigned int len); //会检测放入缓冲区的数据
unsigned char *__skb_put(struct sk_buff *skb, unsigned int len); //不会检测放入缓冲区的数据

上述函数将 tail 指针下移,增加 sk_buff 的 len 值,并返回 skb->tail 的当前值。

  • push 操作(用于在数据包发送时添加头部
unsigned char *skb_push(struct sk_buff *skb, unsigned int len); //会检测放入缓冲区的数据
unsigned char *_ _skb_push(struct sk_buff *skb, unsigned int len); //不会检测放入缓冲区的数据

push 操作在存储空间的头部增加一段可以存储网络数据包的空间。

  • pull 操作(用于下层协议向上层协议移交数据包,使 data 指针指向上一层协议的协议头)
unsigned char * skb_pull(struct sk_buff *skb, unsigned int len);

skb_pull()函数将 data 指针下移,并减小 skb 的 len 值。

  • reserve 操作(用于在存储空间 的头部预留 len 长度的空隙)
void skb_reserve(struct sk_buff *skb, unsigned int len);

skb_reserve()函数将 data 指针和 tail 指针同时下移

1.3 网卡驱动发包过程

在内核中,当上层要发送一个数据包时, 就会调用网络设备层里net_device数据结构的成员hard_start_xmit()将数据包发送出去。

hard_start_xmit()发包函数需要我们自己构建,该函数原型如下所示:

int    (*hard_start_xmit) (struct sk_buff *skb, struct net_device *dev);

这个函数中需要涉及到sk_buff结构体,含义为(socket buffer)套接字缓冲区,用来网络各个层次之间传递数据.

发包函数处理步骤:

  • 1、把数据包发出去之前,需要使用**netif_stop_queue()**来停止上层传下来的数据包;
  • 2.1、设置寄存器,通过网络设备硬件来发送数据
  • 2.2、当数据包发出去后, 再调用dev_kfree_skb()函数来释放sk_buff,该函数原型如下:​​void dev_kfree_skb(struct sk_buff *skb);​
  • 3、当数据包发出成功,就会进入TX接收中断函数,然后更新统计信息,调用**netif_wake_queue()**来唤醒,启动上层继续发包下来;
  • 4、若数据包发出去超时,一直进不到TX中断函数,就会调用net_device结构体的*tx_timeout超时成员函数,在该函数中更新统计信息,并调用netif_wake_queue()来唤醒。

1.4 网卡驱动收包过程

接收数据包主要是通过中断函数处理,来判断中断类型,如果等于ISQ_RECEIVER_EVENT表示为接收中断,然后进入接收数据函数,通过​​netif_rx()​​​将数据上交给上层。例如,下图内核中自带的网卡驱动:​​/drivers/net/cs89x0.c​

一文看懂网卡驱动原理及移植方法_网卡驱动_04

通过获取的status标志来判断是什么中断,如果是接收中断,就进入net_rx()。

  • 收包函数处理步骤
  • 1、使用​​dev_alloc_skb()​​来构造一个新的sk_buff;
  • 2、使用​​skb_reserve(rx_skb, 2)​​ 将sk_buff缓冲区里的数据包先向后位移2字节,腾出sk_buff缓冲区里的头部空间;
  • 3、读取网络设备硬件上接收到的数据;
  • 4、使用​​memcpy()​​将数据复制到新的sk_buff里的data成员指向的地址处,可以使用skb_put()来动态扩大sk_buff结构体里中的数据区;
  • 5、使用​​eth_type_trans()​​来获取上层协议,将返回值赋给sk_buff的protocol成员里;
  • 6、然后更新统计信息,最后使用netif_rx( )来将sk_fuffer传递给上层协议中。
  • skb_put()函数
  • 原型:​​static inline unsigned char *skb_put(struct sk_buff *skb, unsigned int len);​
  • 作用:将数据区向下扩大len字节
  • sk_buff缓冲区变化图:
  • 一文看懂网卡驱动原理及移植方法_linux_05

1.5 网卡驱动的注册与注销

网络设备驱动的注册与注销使用成对出现的​​register_netdev()​​​和​​unregister_netdev()​​函数完成,这两个函数的原型为:

int register_netdev(struct net_device *dev); 
void unregister_netdev(struct net_device *dev);

这两个函数都接收一个 net_device 结构体指针为参数, net_device 的生成和成员的赋值并非一定要由工程师逐个亲自动手完成,可以利 用下面的函数帮助我们填充:

struct net_device *alloc_netdev(int sizeof_priv, const char *name,  
void(*setup)(struct net_device*));

struct net_device *alloc_etherdev(int sizeof_priv)

/* 以 ether_setup 为 alloc_netdev 的 setup 参数 */
return alloc_netdev(sizeof_priv, "eth%d", ether_setup);

​alloc_netdev()​​函数生成一个 net_device 结构体,对其成员赋值并返回该结构体的指针。第一个参数为设备私有成员的大小,第二个参数为设备名,第三个参数为 net_device 的 setup()函数指针。setup()函数接收的参数也为 net_device 指针,用 于预置 net_device 成员的值。

alloc_etherdev()是 alloc_netdev()针对以太网的“快捷”函数,其中的ether_setup()是由 Linux 内核提供的一个对以太网设备 net_device 结构体中公有成员 快速赋值的函数

释放 net_device 结构体 的函数为:

void free_netdev(struct net_device *dev);

2、编写虚拟网卡驱动

虚拟网卡驱动,也就是说不需要硬件相关操作,所以就没有中断函数,我们通过linux的ping命令来实现发包,然后在发包函数中伪造一个收的ping包函数,实现能ping通任何ip地址。

## 2.1 编写步骤

  • init初始函数
  • 1)使用alloc_netdev()来分配一个net_device结构体
  • 2)设置net_device结构体的成员
  • 3)使用register_netdev()来注册net_device结构体
  • 发包函数
  • 1)使用netif_stop_queue()来阻止上层向网络设备驱动层发送数据包
  • 2)调用收包函数,并代入发送的sk_buff缓冲区, 里面来伪造一个收的ping包函数
  • 3)使用dev_kfree_skb()函数来释放发送的sk_buff缓存区
  • 4)更新发送的统计信息
  • 5)使用netif_wake_queue()来唤醒被阻塞的上层
  • 收包函数:修改发送的sk_buff里数据包的数据,使它变为一个接收的sk_buff。
  • 1)需要对调上图的ethhdr结构体 ”源/目的”MAC地址
  • 2)需要对调上图的iphdr结构体”源/目的” IP地址
  • 3)使用ip_fast_csum()来重新获取iphdr结构体的校验码
  • 4)设置上图数据包的数据类型,之前是发送ping包0x08,需要改为0x00,表示接收ping包
  • 5)使用dev_alloc_skb()来构造一个新的sk_buff
  • 6)使用skb_reserve(rx_skb, 2);将sk_buff缓冲区里的数据包先后位移2字节,来腾出sk_buff缓冲区里的头部空间
  • 7)使用memcpy()将之前修改好的sk_buff->data复制到新的sk_buff里的data成员指向的地址处:
  • 8)设置新的sk_buff 其它成员
  • 9)使用eth_type_trans()来获取上层协议,将返回值赋给sk_buff的protocol成员里
  • 10)然后更新接收统计信息,最后使用netif_rx( )来将sk_fuffer传递给上层协议中
  • 具体代码
/*
* 参考 drivers\\net\\cs89x0.c
*/

#include <linux/module.h>
#include <linux/errno.h>
#include <linux/netdevice.h>
#include <linux/etherdevice.h>
#include <linux/kernel.h>
#include <linux/types.h>
#include <linux/fcntl.h>
#include <linux/interrupt.h>
#include <linux/ioport.h>
#include <linux/in.h>
#include <linux/skbuff.h>
#include <linux/slab.h>
#include <linux/spinlock.h>
#include <linux/string.h>
#include <linux/init.h>
#include <linux/bitops.h>
#include <linux/delay.h>
#include <linux/ip.h>

#include <asm/system.h>
#include <asm/io.h>
#include <asm/irq.h>

static struct net_device *vnet_dev;

static void emulator_rx_packet(struct sk_buff *skb, struct net_device *dev)

/* 参考LDD3 */
unsigned char *type;
struct iphdr *ih;
__be32 *saddr, *daddr, tmp;
unsigned char tmp_dev_addr[ETH_ALEN];
struct ethhdr *ethhdr;

struct sk_buff *rx_skb;

/* 1.对调ethhdr结构体的"源/目的"的mac地址 */
ethhdr = (struct ethhdr *)skb->data;
memcpy(tmp_dev_addr, ethhdr->h_dest, ETH_ALEN);
memcpy(ethhdr->h_dest, ethhdr->h_source, ETH_ALEN);
memcpy(ethhdr->h_source, tmp_dev_addr, ETH_ALEN);

/* 2.对调iphdr结构体的"源/目的"的ip地址 */
ih = (struct iphdr *)(skb->data + sizeof(struct ethhdr));
saddr = &ih->saddr;
daddr = &ih->daddr;

tmp = *saddr;
*saddr = *daddr;
*daddr = tmp;

/* 3.使用ip_fast_csum()来重新获取iphdr结构体的校验码*/
ih->check = 0; /* and rebuild the checksum (ip needs it) */
ih->check = ip_fast_csum((unsigned char *)ih,ih->ihl);

/* 4.设置数据类型*/
type = skb->data + sizeof(struct ethhdr) + sizeof(struct iphdr);
*type = 0; /*原来0x8表示发送ping包,现在0表示接收ping包 */

/* 5.构造一个新的sk_buff */
rx_skb = dev_alloc_skb(skb->len + 2);

/* 6.使用skb_reserve腾出2字节头部空间*/
skb_reserve(rx_skb, 2); /* align IP on 16B boundary */

/* 7.将之前修改好的sk_buff->data复制到新的sk_buff里 */
memcpy(skb_put(rx_skb, skb->len), skb->data, skb->len); //用skb_put()扩大sk_buff的数据区,避免溢出

/* 8.设置新sk_buff的其它成员*/
rx_skb->dev = dev;
rx_skb->ip_summed = CHECKSUM_UNNECESSARY; /* dont check it */

/* 9.使用eth_type_trans()来获取上层协议 */
rx_skb->protocol = eth_type_trans(rx_skb, dev);

/* 10.更新接收统计信息,并向上层传递sk_fuffer收包 */
dev->stats.rx_packets++;
dev->stats.rx_bytes += skb->len;
dev->last_rx = jiffies; //收包时间戳

// 提交sk_buff
netif_rx(rx_skb);


static int virt_net_send_packet(struct sk_buff *skb, struct net_device *dev)

static int cnt = 0;
printk("virt_net_send_packet cnt = %d\\n", ++cnt);

/* 1.停止该网卡的队列,阻止上层向驱动层继续发送数据包 */
netif_stop_queue(dev);

/* 2.真实驱动要把skb的数据写入网卡 ,但在此先通过emulator_rx_packet模拟 */
emulator_rx_packet(skb, dev); /* 构造一个假的sk_buff,上报 */

/* 3.释放发送的sk_buff缓存区*/
dev_kfree_skb (skb);

/* 4.更新统计信息 */
dev->stats.tx_packets++;
dev->stats.tx_bytes += skb->len;
dev->trans_start = jiffies; //发送时间戳

/* 5.数据全部发送出去后,唤醒网卡的队列 (真实网卡应在中断函数里唤醒)*/
netif_wake_queue(dev);

return 0;



static const struct net_device_ops vnetdev_ops =
.ndo_start_xmit = virt_net_send_packet,
;

static int virt_net_init(void)

/* 1. 分配一个net_device结构体 */
vnet_dev = alloc_netdev(0, "vnet%d", ether_setup);; /* alloc_ether_dev */

/* 2. 设置 */
//vnet_dev->hard_start_xmit = virt_net_send_packet;
vnet_dev->netdev_ops = &vnetdev_ops;

/* 设置MAC地址 */
vnet_dev->dev_addr[0] = 0x08;
vnet_dev->dev_addr[1] = 0x89;
vnet_dev->dev_addr[2] = 0x66;
vnet_dev->dev_addr[3] = 0x77;
vnet_dev->dev_addr[4] = 原理简单,但不知道怎么用?一文看懂「同期群模型」

目标检测算法图解:一文看懂RCNN系列算法

一文看懂 Attention(本质原理+3大优点+5大类型)

一文看懂什么是HTTPS,及其安全传输机制和原理

一文看懂什么是HTTPS,及其安全传输机制和原理

一文看懂什么是HTTPS,及其安全传输机制和原理