ixgbe 驱动 为xxx驱动做准备2 --- 网卡二层 收发包
网络报文接收流程所涉及的内容很多,如报文vlan 单播组播等过滤、mac层卸载、报文接收描述符、校验和卸载以及分离报文有效载荷和头部等,具体内容需要看 网卡datasheet, 以82599 网卡为例:http://www.intel.com/content/www/us/en/embedded/products/networking/82599-10-gbe-controller-datasheet.html
具体可以查看官网。
这里主要说网卡接收报文,网卡接收报文肯定会涉及到一个东西--- (Legacy/Advanced ) Receive Descriptor Format-报文接收描述符,当网卡收到网络报文的时候,会往报文接收描述符中指定的地址写入报文数据,而网卡驱动则会从报文接收描述符中指定的地址读取报文,并送往上层协议栈处理。
对于82599网卡而言,其支持两种格式的报文接收描述符,即传统格式和高级格式--Legacy/Advanced;两种格式的报文接收描述符所占用的内存大小都是16Bytes;
报文接收描述符的低八个字节存放的是用于存放报文的内存起始地址,而高八个字节存放的是网卡对报文进行预处理得到的一些信息,如报文长度,VLAN Tag以及校验和信息等
读格式的报文描述符中主要有四个部分,分别是报文缓冲区地址、A0位、头缓冲区地址和DD位。对于报文缓冲区地址和头缓冲区地址,顾名思义,存储的就是用来存放报文有效载荷和头部的缓冲区首地址。而对于DD位的作用,网卡驱动可以通过读取该位的值来判断该描述符对应的缓冲区中是否已经存放了网卡接收的报文。
报文接收描述符承载了报文从网卡流入到主存的过程,是网卡驱动和网卡都会操作的对象; 两个action 处理同一个对象,一个写一个读,那怎样处理的呢???
RDBA寄存器。这个寄存器存放了报文接收描述符环形队列的起始地址,也就是上图中Base指向的地址。
RDLEN寄存器。这个寄存器存放了报文接收描述符环形队列的长度,也就是接收描述符环形队列所占用的字节数,对应上图中的Size。
RDH寄存器。这个寄存器存放的是一个距离队列头部的偏移值,代表的是第一个可以被网卡用来存放报文信息的描述符。当网卡完成了将一个报文信息存放到描述符后,就会更新RDH寄存器的值,使之指向下一个即将用来存放报文信息的描述符。也就是说这个寄存器的值是由网卡来更新的,该寄存器对应上图中的Head。
RDT寄存器。这个寄存器存放的也是一个距离队列头部的偏移值,代表的是硬件可以用来存放报文信息的最后一个描述符的下一个描述符。当网卡驱动填充了报文描述中的报文缓冲区地址后就会更新该寄存器的值,使之指向下一个即将填充地址信息并给网卡使用的描述符,该寄存器对应上图中的Tail
网卡中接收报文的最小单位是一个队列,即RX队列。所以一般来说就是一个RX队列对应一个报文接收描述符环形队列
struct ixgbe_q_vector { struct ixgbe_adapter *adapter; int cpu; /* CPU for DCA */ u16 v_idx; /* index of q_vector within array, also used for * finding the bit in EICR and friends that * represents the vector for this ring */ u16 itr; /* Interrupt throttle rate written to EITR */ struct ixgbe_ring_container rx, tx; struct napi_struct napi; #ifndef HAVE_NETDEV_NAPI_LIST struct net_device poll_dev; #endif #ifdef HAVE_IRQ_AFFINITY_HINT cpumask_t affinity_mask; #endif #ifndef IXGBE_NO_LRO struct ixgbe_lro_list lrolist; /* LRO list for queue vector*/ #endif int numa_node; char name[IFNAMSIZ + 9]; /* for dynamic allocation of rings associated with this q_vector 柔性数组成员就是由该中断向量所管理的队列,这里包括了RX队列和TX队列*/ struct ixgbe_ring ring[0] ____cacheline_internodealigned_in_smp; };
报文接收流程只需要关注其中的RX队列即可。一般来说一个中断向量会关联一个硬件中断。当网卡往中断向量中的某个RX队列的描述符中写入报文信息时,就会触发对应的硬件中断,然后中断子系统就会调用我们注册的中断处理函数来处理这个中断,在ixgbe驱动中对应的就是ixgbe_intr();在ixgbe网卡驱动的实现中,是以一个叫做struct ixgbe_ring的对象来管理报文描述符环形队列(不管是接收还是发送),其定义如下:
struct ixgbe_ring { struct ixgbe_ring *next; /* pointer to next ring in q_vector */ struct ixgbe_q_vector *q_vector; /* backpointer to host q_vector */ struct net_device *netdev; /* netdev ring belongs to */ struct device *dev; /* device for DMA mapping */ /* 环形队列缓冲区中的报文描述符数组 有desc成员的内核虚拟地址进行一致性dma映射得到。 这样ixgbe驱动可以通过desc来操作描述符环形队列,而网卡可以通过dma成员来操作描述符环形队列。*/ void *desc; /* descriptor ring memory */ union {/* 与报文描述符数组一一对应的报文缓冲区对象 */ struct ixgbe_tx_buffer *tx_buffer_info; struct ixgbe_rx_buffer *rx_buffer_info; }; unsigned long state; u8 __iomem *tail;/* 指向RDT寄存器对应的内核虚拟地址 */ /* 报文描述符数组对应的物理地址 有desc成员的内核虚拟地址进行一致性dma映射得到。 这样ixgbe驱动可以通过desc来操作描述符环形队列,而网卡可以通过dma成员来操作描述符环形队列。*/ dma_addr_t dma; /* desc成员对应的物理地址 phys. address of descriptor ring */ unsigned int size; /* length in bytes */ /* 环形队列缓冲区中的报文描述符个数 */ u16 count; /* amount of descriptors */ /* * 环形队列缓冲区关联的rx队列索引,这个索引是用来在adapter->rx数组索引环形队列缓冲区的*/ u8 queue_index; /* needed for multiqueue queue management */ u8 reg_idx; /* holds the special value that gets * the hardware register offset * associated with this ring, which is * different for DCB and RSS modes */ // next_to_use是环形队列缓冲区中将要提供给硬件使用的第一个报文描述符的索引,对应的就是RDT寄存器 //next_to_clean是环形队列缓冲区中驱动将要处理的第一个报文描述符的索引 u16 next_to_use; u16 next_to_clean; #ifdef HAVE_PTP_1588_CLOCK unsigned long last_rx_timestamp; #endif union { #ifdef CONFIG_IXGBE_DISABLE_PACKET_SPLIT u16 rx_buf_len; #else u16 next_to_alloc; #endif struct { u8 atr_sample_rate; u8 atr_count; }; }; u8 dcb_tc; struct ixgbe_queue_stats stats; ------------------------------------------ } ____cacheline_internodealigned_in_smp;
dma_addr_t-------dma成员就是desc成员对应的物理地址,有desc成员的内核虚拟地址进行一致性dma映射得到-------->ixgbe驱动可以通过desc来操作描述符环形队列,而网卡可以通过dma成员来操作描述符环形队列
int ixgbe_setup_rx_resources(struct ixgbe_ring *rx_ring) { struct device *dev = rx_ring->dev; int orig_node = dev_to_node(dev); int numa_node = -1; int size; /* 环形队列中报文接收描述符个数申请报文描述符数组所需要的内存,以及对应的用来管理报文缓冲区地址信息的缓冲区对象, 这个时候缓冲区对象中用来存放报文内容的地址仍然是无效的,因为还没有申请内存,在函数ixgbe_alloc_rx_buffers()处理完成之后, 缓冲区对象中存放报文内容的地址就是有效的,可以提供给网卡用来存放报文数据。此外,对报文接收描述符数组内存进行一致性dma映射, 获取对应的物理地址,网卡需要使用物理地址,而不是虚拟地址 */ size = sizeof(struct ixgbe_rx_buffer) * rx_ring->count; if (rx_ring->q_vector) numa_node = rx_ring->q_vector->numa_node; rx_ring->rx_buffer_info = vzalloc_node(size, numa_node); if (!rx_ring->rx_buffer_info) rx_ring->rx_buffer_info = vzalloc(size); if (!rx_ring->rx_buffer_info) goto err; /* Round up to nearest 4K */ rx_ring->size = rx_ring->count * sizeof(union ixgbe_adv_rx_desc); rx_ring->size = ALIGN(rx_ring->size, 4096); set_dev_node(dev, numa_node); rx_ring->desc = dma_alloc_coherent(dev, rx_ring->size, &rx_ring->dma, GFP_KERNEL); set_dev_node(dev, orig_node); if (!rx_ring->desc) rx_ring->desc = dma_alloc_coherent(dev, rx_ring->size, &rx_ring->dma, GFP_KERNEL); if (!rx_ring->desc) goto err; //经过ixgbe_setup_rx_resources()函数的处理,就已经成功创建了一个描述符环形的管理对像 return 0; err: vfree(rx_ring->rx_buffer_info); rx_ring->rx_buffer_info = NULL; dev_err(dev, "Unable to allocate memory for the Rx descriptor ring\n"); return -ENOMEM; }
- dma_alloc_coherent() -- 获取物理页,并将该物理页的总线地址保存于dma_handle,返回该物理页的虚拟地址DMA映射建立了一个新的结构类型---------dma_addr_t来表示总线地址。dma_addr_t类型的变量对驱动程序是不透明的;唯一允许的操作是将它们传递给DMA支持例程以及设备本身。作为一个总线地址,如果CPU直接使用了dma_addr_t,将会导致发生不可预期的后果!一致性DMA映射存在与驱动程序的生命周期中,它的缓冲区必须可同时被CPU和外围设备访问
注意ixgbe_adv_rx_desc的结构体如下:
/* Receive Descriptor - Advanced */ union ixgbe_adv_rx_desc { struct { __le64 pkt_addr; /* Packet buffer address */ __le64 hdr_addr; /* Header buffer address */ } read; struct { struct { union { __le32 data; struct { __le16 pkt_info; /* RSS, Pkt type */ __le16 hdr_info; /* Splithdr, hdrlen */ } hs_rss; } lo_dword; union { __le32 rss; /* RSS Hash */ struct { __le16 ip_id; /* IP id */ __le16 csum; /* Packet Checksum */ } csum_ip; } hi_dword; } lower; struct { __le32 status_error; /* ext status/error */ __le16 length; /* Packet length */ __le16 vlan; /* VLAN tag */ } upper; } wb; /* writeback */ };
- ixgbe_setup_rx_resources()函数已经成功创建了一个描述符环形的管理对象。接下来就需要告诉网卡这个描述符环形队列的信息,这个就是函数ixgbe_configure_rx_ring()所要做的事情
void ixgbe_configure_rx_ring(struct ixgbe_adapter *adapter, struct ixgbe_ring *ring) { struct ixgbe_hw *hw = &adapter->hw; u64 rdba = ring->dma;/* 环形队列缓冲区中报文描述符数组对应的物理地址 */ u32 rxdctl; u8 reg_idx = ring->reg_idx; /* disable queue to avoid issues while updating state/ * 将报文描述符数组的首地址写入到RDBAH和RDBAL寄存器中,并将描述符数组的长度 * 写入到RDLEN寄存器中,这样网卡芯片就知道了报文描述符的信息,后续可以收到 * 合适的网络报文后,就会将报文存放到描述符里面的dma地址中,并递增内部的 * head寄存器值 */ rxdctl = IXGBE_READ_REG(hw, IXGBE_RXDCTL(reg_idx)); ixgbe_disable_rx_queue(adapter, ring); IXGBE_WRITE_REG(hw, IXGBE_RDBAL(reg_idx), rdba & DMA_BIT_MASK(32)); IXGBE_WRITE_REG(hw, IXGBE_RDBAH(reg_idx), rdba >> 32); IXGBE_WRITE_REG(hw, IXGBE_RDLEN(reg_idx), ring->count * sizeof(union ixgbe_adv_rx_desc)); /* reset head and tail pointers * 初始状态下,网卡芯片的head和tail指针都为0,表示网卡没有可用的报文描述符 * 等后面驱动申请了n个报文描述符中的dma地址后,就会将tail寄存器值设置为n, * 表示目前网卡可用的报文描述符数量为n个。这样,等网卡收到了合适的报文之后 * 就会存到报文描述符中的dma地址处。*/ IXGBE_WRITE_REG(hw, IXGBE_RDH(reg_idx), 0); IXGBE_WRITE_REG(hw, IXGBE_RDT(reg_idx), 0); ring->tail = hw->hw_addr + IXGBE_RDT(reg_idx); /* reset ntu and ntc to place SW in sync with hardwdare */ ring->next_to_clean = 0; ring->next_to_use = 0; #ifndef CONFIG_IXGBE_DISABLE_PACKET_SPLIT ring->next_to_alloc = 0; #endif ixgbe_configure_srrctl(adapter, ring); /* In ESX, RSCCTL configuration is done by on demand */ ixgbe_configure_rscctl(adapter, ring); switch (hw->mac.type) { case ixgbe_mac_82598EB: /* * enable cache line friendly hardware writes: * PTHRESH=32 descriptors (half the internal cache), * this also removes ugly rx_no_buffer_count increment * HTHRESH=4 descriptors (to minimize latency on fetch) * WTHRESH=8 burst writeback up to two cache lines */ rxdctl &= ~0x3FFFFF; rxdctl |= 0x080420; break; case ixgbe_mac_X540: rxdctl &= ~(IXGBE_RXDCTL_RLPMLMASK | IXGBE_RXDCTL_RLPML_EN); #ifdef CONFIG_IXGBE_DISABLE_PACKET_SPLIT /* If operating in IOV mode set RLPML for X540 */ if (!(adapter->flags & IXGBE_FLAG_SRIOV_ENABLED)) break; rxdctl |= ring->rx_buf_len | IXGBE_RXDCTL_RLPML_EN; #endif /* CONFIG_IXGBE_DISABLE_PACKET_SPLIT */ break; default: break; } /* enable receive descriptor ring */ rxdctl |= IXGBE_RXDCTL_ENABLE; IXGBE_WRITE_REG(hw, IXGBE_RXDCTL(reg_idx), rxdctl); ixgbe_rx_desc_queue_enable(adapter, ring); /* 申请报文描述符中用于存储报文数据的内存 */ ixgbe_alloc_rx_buffers(ring, ixgbe_desc_unused(ring)); }
网卡驱动就是通过将接收报文描述符数组对应的物理地址写入到RDBA寄存器,并初始化RDH和RDT寄存器。通过写RDBA、RDH和RDT寄存器,网卡就知道了当前的描述符环形队列的信息。接着调用函数ixgbe_alloc_rx_buffers()申请用来存放报文数据的内存,并将对应的物理地址保存到接收描述符中,然后设置RDT寄存器,这样网卡就可以使用RDH和RDT之间的描述符进行接收报文处理了
struct ixgbe_rx_buffer { struct sk_buff *skb; dma_addr_t dma; #ifndef CONFIG_IXGBE_DISABLE_PACKET_SPLIT struct page *page; unsigned int page_offset; #endif };
static bool ixgbe_alloc_mapped_skb(struct ixgbe_ring *rx_ring, struct ixgbe_rx_buffer *bi) { struct sk_buff *skb = bi->skb; dma_addr_t dma = bi->dma; if (unlikely(dma)) return true; if (likely(!skb)) { skb = netdev_alloc_skb_ip_align(netdev_ring(rx_ring), rx_ring->rx_buf_len); if (unlikely(!skb)) { rx_ring->rx_stats.alloc_rx_buff_failed++; return false; } bi->skb = skb; }pci_alloc_ dma = dma_map_single(rx_ring->dev, skb->data, rx_ring->rx_buf_len, DMA_FROM_DEVICE); /* * if mapping failed free memory back to system since * there isn't much point in holding memory we can't use */ if (dma_mapping_error(rx_ring->dev, dma)) { dev_kfree_skb_any(skb); bi->skb = NULL; rx_ring->rx_stats.alloc_rx_buff_failed++; return false; } bi->dma = dma; return true; } void ixgbe_alloc_rx_buffers(struct ixgbe_ring *rx_ring, u16 cleaned_count) { union ixgbe_adv_rx_desc *rx_desc; struct ixgbe_rx_buffer *bi; u16 i = rx_ring->next_to_use; /* nothing to do */ if (!cleaned_count) return; /* * 获取下一个将要提供给硬件使用的报文描述符(对应的索引为rx_ring->next_to_use), * 以及报文描述符对应的缓冲区对象,缓冲区对象中保存了用于存放报文数据的内存地址信息, * 当然用于存放报文的内存对应的物理地址也会保存到报文描述符中。 #define IXGBE_RX_DESC(R, i) \ (&(((union ixgbe_adv_rx_desc *)((R)->desc))[i])) */ rx_desc = IXGBE_RX_DESC(rx_ring, i); bi = &rx_ring->rx_buffer_info[i]; /*报文描述符队列在逻辑上是环形的(实际上是线性的,因为内存地址是线性分布的),当我们操作这个队列到达末尾的时候, 通过将索引重新指向队列开头来实现环形操作。所以呢,在计算之后, i表示的就是 目前位置距离队列末尾之间还没有提供给硬件使用的报文描述符个数的相反数,也就是 * 当前处理位置和队列末尾距离。在下面的循环中,每处理一个报文描述符(申请用于存放报文数据的内存)都会将i递增, * 当i等于0的时候,说明达到了队列的末尾,下次处理就要从队列头开始了,从而实现 * 队列的环形操作。 */ i -= rx_ring->count; do { /* 申请用于存放报文数据的内存,并进行dma流式映射*/ #ifdef CONFIG_IXGBE_DISABLE_PACKET_SPLIT if (!ixgbe_alloc_mapped_skb(rx_ring, bi)) #else if (!ixgbe_alloc_mapped_page(rx_ring, bi)) #endif break; /* * Refresh the desc even if buffer_addrs didn't change * because each write-back erases this info. */ #ifdef CONFIG_IXGBE_DISABLE_PACKET_SPLIT /* rx_desc->read.pkt_addr存放的地址就是用于存放报文的dma起始地址 */ rx_desc->read.pkt_addr = cpu_to_le64(bi->dma); #else rx_desc->read.pkt_addr = cpu_to_le64(bi->dma + bi->page_offset); #endif /* rx_desc和bi递增,指向下一个描述符和对应的缓冲区对象 */ rx_desc++; bi++; i++; /* 如果i == 0,说明操作环形队列缓冲区已经转了一圈了,这个时候就需要重新让 * rx_desc和bi分别指向描述符数组和缓冲区数组的起始位置,从头开始处理,当然 * 对应的i值也就要重新计算了,此时的值为队列中描述符个数的相反数。*/ if (unlikely(!i)) { rx_desc = IXGBE_RX_DESC(rx_ring, 0); bi = rx_ring->rx_buffer_info; i -= rx_ring->count; } /* clear the hdr_addr for the next_to_use descriptor */ rx_desc->read.hdr_addr = 0; cleaned_count--; } while (cleaned_count); /* i加上rx_ring->count之后指向的就是最后一个可用(对网卡芯片来说)的报文描述符的 * 下一个位置,,这个时候需要将这个索引值i写入到网卡芯片的tail寄存器中,让网卡 * 芯片知道目前可用的报文描述数量(tail - head)*/ i += rx_ring->count; if (rx_ring->next_to_use != i) ixgbe_release_rx_desc(rx_ring, i); } static inline void ixgbe_release_rx_desc(struct ixgbe_ring *rx_ring, u32 val) {/* * 因为i指向的是最后一个可用报文描述符的下一个位置,这个位置也是下一次要 * 提供给网卡芯片使用的报文描述符的位置 */ rx_ring->next_to_use = val; #ifndef CONFIG_IXGBE_DISABLE_PACKET_SPLIT /* update next to alloc since we have filled the ring */ rx_ring->next_to_alloc = val; #endif /* * Force memory writes to complete before letting h/w * know there are new descriptors to fetch. (Only * applicable for weak-ordered memory model archs, * such as IA-64). */ wmb(); /* 将val 值写入到tail寄存器中 */ writel(val, rx_ring->tail); }
对于 ixgbe驱动中NAPI接口的数据包接收流程如下:就不说了,到处都是
至于发包流程:可以看我之前的文章 不说了;或者看Intel的芯片手册:http://www.intel.com/content/www/us/en/embedded/products/networking/82599-10-gbe-controller-datasheet.html
参考:https://tqr.ink/2017/05/01/intel-82599-transmit-packet/
这里面的缓存关系为:
ixgbe_ring
{
struct ixgbe_rx_buffer *rx_buffer_info;
void *desc; /* descriptor ring memory */----------------------------指向的是环形队列描述符的内存地址
dma_addr_t dma; /* phys. address of descriptor ring */------------------------ 指向的是环形队列描述符的内存地址
}
ixgbe_ring 中的desc 以及 dma 指向的是 环形缓冲区的描述符地址!!!
那么存储报文的地址是???
ixgbe_ring rx_ring[MAX]
rx_desc = IXGBE_RX_DESC(rx_ring, i);
bi = &rx_ring->rx_buffer_info[i];
数据缓冲区:
skb = netdev_alloc_skb_ip_align(netdev_ring(rx_ring),rx_ring->rx_buf_len);
bi->skb = skb;
bi->dma = dma_map_single(rx_ring->dev, skb->data,rx_ring->rx_buf_len, DMA_FROM_DEVICE);
rx_desc->read.pkt_addr = cpu_to_le64(bi->dma + bi->page_offset);
再来看读取报文的时候:
也就是软中断poll读取的时候
ixgbe_clean_rx_irq----》
rx_desc = IXGBE_RX_DESC(rx_ring, ntc);
rx_buffer = &rx_ring->rx_buffer_info[ntc];
skb = rx_buffer->skb;
napi_gro_receive---------------->进入协议栈
处理完后:
if (cleaned_count)
ixgbe_alloc_rx_buffers(rx_ring, cleaned_count);
-------->继续分配 数据包缓存 然后设置 ,描述符指针
目前在执行 ixgbe_clean_rx_irq 收取报文时;根据DISABLE_PACKET_SPLIT 决定 fetch_buffer 的数据是直接来之 rx_buff->skb
还是来之 rx_buffer->page,然后page 转换到新的new_skbbuff->data 中;不同方式 对不同的pkt-lenth效率不同
问题一:如下代码中
rx_buffer->page_offset ^= truesize; 这句话是?
static int ixgbe_clean_rx_irq(struct ixgbe_q_vector *q_vector, struct ixgbe_ring *rx_ring, const int budget) { --------------------------- /* Frame size depend on rx_ring setup when PAGE_SIZE=4K */ #if (PAGE_SIZE < 8192) xdp.frame_sz = ixgbe_rx_frame_truesize(rx_ring, 0);///* Must be power-of-2 */ #endif ----------------------------- else if (skb) { ixgbe_add_rx_frag(rx_ring, rx_buffer, skb, size); } else if (ring_uses_build_skb(rx_ring)) { skb = ixgbe_build_skb(rx_ring, rx_buffer, &xdp, rx_desc); } else { skb = ixgbe_construct_skb(rx_ring, rx_buffer, &xdp, rx_desc); } ---------------------------- } static void ixgbe_add_rx_frag(struct ixgbe_ring *rx_ring, struct ixgbe_rx_buffer *rx_buffer, struct sk_buff *skb, unsigned int size) { #if (PAGE_SIZE < 8192) unsigned int truesize = ixgbe_rx_pg_size(rx_ring) / 2; #else unsigned int truesize = ring_uses_build_skb(rx_ring) ? SKB_DATA_ALIGN(IXGBE_SKB_PAD + size) : SKB_DATA_ALIGN(size); #endif skb_add_rx_frag(skb, skb_shinfo(skb)->nr_frags, rx_buffer->page, rx_buffer->page_offset, size, truesize); #if (PAGE_SIZE < 8192) rx_buffer->page_offset ^= truesize; #else rx_buffer->page_offset += truesize; #endif }
目前page_size为4K, 最后计算得到truesize=2048;
rx_buffer->page_offset ^= truesize; --->/* flip page offset to other buffer */
PAGE_SIZE 一般为 4096,page->offset 的初始值为 0。下面这句代码执行异或运算,而IGB_RX_BUFSZ 的值为 2048。那么 page->offset 的值就在 0 和 2048 之间来回变换,即有两个缓存区,0 ~ 2047、2048 ~ 4095。图中阴影部分表示缓存区 0 ~ 2047,空白部分为 2048 ~ 4095。这样的转换是一种 ping-pong 机制,可以称之为 ping-pong page
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!