socket从userspace到kernel的api执行过程(不含tcp/ip协议栈部分)

本文深入剖析了Socket编程原理,包括glibc版本2.3.6下的socket接口定义与实现过程,详细解释了socket创建、bind、listen、accept、recv、send等函数的工作流程,并探讨了本地socket的防火墙问题。

glibc版本2.3.6Kernel版本:4.3

Userspace glibc接口说明

glibc中socket接口定义:(glibc-x.x.x/sysdeps/generic/socket.cx.x.x是版本号)

int __socket (domain, type, protocol)

    int domain;

    int type;

    int protocol;

{

 __set_errno (ENOSYS);

 return -1;

}

weak_alias (__socket, socket)

stub_warning (socket)

#include <stub-tag.h>

可以在glibc库中做权限检查,这样需要在调用socket接口时到系统服务去检查是否允许当前进程调用socket接口。需要事先配置好调用权限,在系统服务启动时候加载权限配置。gcc编译器扩展为socket定义了别名__socket,__socket的真实定义是在glibc-x.x.x/sysdeps/unix/sysv/linux/i386/socket.S。在i386、arm有对应的socket.S

socket.S关键执行点说明:

1、把socket对应的系统调用号mov到eax寄存器

movl$SYS_ify(socketcall), %eax      /* System call number in %eax.  */

2、把参数的地址弄到%esp寄存器

 lea4(%esp), %ecx

3、产生$0x80软中断,进入内核执行

socket.S 中调用ENTER_KERNEL

4、socket.S文件也有和C文件一致的别名定义,这样编译器就能找到别名的具体实现。


执行0x80软中端后就到达系统调用的总入口system_call()函数,system_call()最终使用汇编call指令(call *sys_call_table(,%eax, 4))根据寄存器%eax中的值执sys_call_table系统调用表102对应的函数指针指向的函数。102系统调用号对应的函数是:sys_socketcall(),glibc中所有socket相关的接口都走这个系统调用接口

sys_call_table系统调用号表定义在:arch/m32r/kernel/syscall_table.S

system_call()汇编接口定义在:arch/x86/kernel/entry_32.S

不同平台会有不同的定义文件


Kernel里面socket相关函数说明:

/net/socket.c里面有sys_socketcall()、sys_socket()、sys_bind()等等一系列socket函数的具体实现:

SYSCALL_DEFINE2(socketcall, int, call, unsigned long __user *, args)

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)

SYSCALL_DEFINE3(bind, int, fd, struct sockaddr __user *, umyaddr,int, addrlen)

SYSCALL_DEFINE2(listen, int, fd, int, backlog)

SYSCALL_DEFINE3(accept, int, fd, struct sockaddr __user *,upeer_sockaddr,int __user*, upeer_addrlen)

SYSCALL_DEFINE3(connect, int, fd, struct sockaddr __user *,uservaddr,int,addrlen)

SYSCALL_DEFINE6(sendto, int, fd, void __user *, buff, size_t, len,unsignedint, flags, struct sockaddr __user *, addr, int,addr_len)

SYSCALL_DEFINE4(send, int, fd, void __user *, buff, size_t, len,unsignedint, flags)

SYSCALL_DEFINE6(recvfrom, int, fd, void __user *, ubuf, size_t,size,unsignedint, flags, struct sockaddr __user *, addr, int __user*, addr_len)

SYSCALL_DEFINE4(recv, int, fd, void __user *, ubuf, size_t, size, unsignedint, flags)

SYSCALL_DEFINE5(setsockopt, int, fd, int, level, int, optname,char __user*, optval, int, optlen)

SYSCALL_DEFINE5(getsockopt, int, fd, int, level, int, optname,char __user*, optval, int __user *, optlen)

/include/linux/syscalls.h里面有上述宏的定义

socket(AF_INET, SOCK_STREAM, IPPROTO_TCP),以下接口都以此种类型的socket来说明

一、socket函数概要说明:

SYSCALL_DEFINE3(socket,int, family, int, type, int, protocol),以下的函数基本上都在socket.c文件里面实现,定义都是SYSCALL_DEFINEx()的格式。SYSCALL_DEFINEx的定义见include/linux/syscalls.h其中的x代表宏封装的函数有几个参数。宏SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)封装了socket这个函数,参数是family、type、protocol这三个,参数数据类型都是int。

1、  调用sock_create函数创建structsocket *sock;

retval = sock_create(family, type, protocol, &sock);socket_create函数只是简单的封了一下__sock_create()函数。__sock_create()函数的说明在步骤2的后面。

2、  做socket到文件描述符的映射

retval = sock_map_fd(sock, flags & (O_CLOEXEC |O_NONBLOCK));

sock_map_fd会从当前系统获取一个未使用的文件描述符,执行fd_install把获取到的文件描述符和新创建的socket文件对象绑定,执行完成后内核会把这个文件描述符返回给应用程序。应用层socket()函数的返回值就是这个文件描述符。

__sock_create流程概要说明:

假定应用层调用为:socket(AF_INET,SOCK_STREAM, IPPROTO_TCP),其他参数调用类似。

1、  selinux检测

security_socket_create(),这里可以做权限控制

2、  分配新socket

sock =sock_alloc();

3、  构造socket

pf = rcu_dereference(net_families[family]);

net_families[AF_INET]取到inet_family_ops结构。inet_family_ops的定义、注册到net_family都在af_inet.c文件实现。

pf->create(net, sock,protocol, kern); 可以在这里根据网络命名空间做权限控制,需要对struct net结构做扩展,改动比较大。此处的pf->create(),执行的是inet_family_ops结构体中的create函数,也就是inet_create()函数,inet_create会根据socket的类型(SOCK_STREAM、SOCK_DGRAM…..)从inetsw_array中找到匹配的struct inet_protosw结构体。把匹配中的结构体的.ops成员赋值给步骤2中新alloc出的sock,在bind、listen等处会调用socket的ops函数。把匹配中的结构体中的.prot成员赋值给下面sk_alloc新分配的struct sock结构体成员sk_prot。(Af_inet.c)。注意这里的struct inet_protosw结构的ops和prot成员赋给的对象分别是struct socket的ops成员和struct sock的sk_prot成员,内核中的socket系列函数会频繁用到这两个成员。

struct inet_protosw 结构体的.prot成员可以是tcp_prot、udp_prot、ping_prot、raw_prot这四个函数。

inet_create()说明:

执行sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);创建新的sock并把struct netnet赋值给新创建的sock

执行sock_init_data(sock, sk)把socket赋值给sock

上述步骤中红色的sock是struct socket结构,蓝色的sock是struct sock结构。

sk_alloc()函数说明:

本函数主要根据struct inet_protosw 的成员.prot来创建相应的sock以及构造私有的inet数据,sk_prot_alloc()函数会执行sk = kmalloc(prot->obj_size, priority),此处的prot->obj_size对应于struct proto tcp_prot 的obj_size,也就是sizeof(struct tcp_sock)。

struct tcp_sock的第一个成员是structinet_connection_sock类型的

struct inet_connection_sock 的第一个成员是 structinet_sock类型的

struct inet_sock结构的第一个成员是structsock类型的。

对于一个tcpsocket对象,从地址来看tcp_sock、inet_connection_sock、inet_sock、struct sock四者是在同一个地址的,对地址的指向进行强制类型转换后可以方便的对这四者各自的成员进行操作。

sock_init_data(sock, sk);会把sk_alloc出的struct sock赋值给步骤2中创建的socket。这样就把sock和socket关联上了。从这里也可以看出sturctsocket是一个公共的套接字结构体,而sock是和具体协议相关的套接字数据结构。struct socket结构的抽象层次要高于struct sock。

 inet_init()中执行inet_register_protosw()把结构数组inetsw_array中的元素注册到全局链表inetsw,这部分代码在系统启动时加载ip协议栈的时候执行。

每种类型的socket,在inetsw_array数组中都能找到与之对应的ops操作结构。内核把不同类型socket的操作函数接口封装到structproto结构中。

二、bind()函数概要说明:

1、  通过socket文件描述符获取socket

struct socket *sock = sockfd_lookup_light(fd, &err, &fput_needed);

2、  调用sock结构体ops成员的bind函数来绑定地址

此处的ops是structproto_ops inet_stream_ops结构。原因见__sock_create流程概要说明的步骤3。

执行inet_bind()函数还调用ns_capable(net->user_ns,CAP_NET_BIND_SERVICE)来检查能力。这里可以做能力控制。

执行inet_bind()时,先从sock里面取出structsock sk,再执行sk->sk_prot->bind(sk, uaddr, addr_len);本例中sk->sk_prot就是tcp_prot()。

执行最后的bind()函数分两种情况

2.1、RAW的socket直接调sk->sk_prot->bind()

2.2、STREAM、DGRAM的socket单独处理,把地址、端口设置到struct inet_sock变量里面,struct inet_sock指针可以是由struct sock强制类型转换得到(原因见sk_alloc()函数说明)。

三、listen()函数概要说明:

1、  和bind()调用类似。调用structproto_ops inet_stream_ops结构的listen成员函数,也就是inet_listen()。此时只是把socket的状态设置为listen并设置相应参数。

四、  accept()函数概要说明:

1、  在这里会重新分配一个真正和客户端连接的socket。bind创建的socket会把一些字段赋个新创建的socket。

2、  inet_accept()函数会执行sk1->sk_prot->accept()。这里的accept执行的是structproto tcp_prot 结构的accept成员函数,也就是inet_csk_accept。

五、  recv()函数概要说明:

1、  调用sys_recvfrom()

2、  sys_recvfrom()调用sock_recvmsg()

3、  sock_recvmsg()中的selinux检查通过后调用sock_recvmsg_nosec()

4、  sock_recvmsg_nosec()中调用const struct proto_ops inet_stream_ops 结构体的recvmsg成员函数(inet_recvmsg())。

5、  inet_recvmsg()执行sk->sk_prot->recvmsg()函数调用。执行的是struct prototcp_prot 结构体成员recvmsg,也就是tcp_recvmsg()函数。

6、  执行完上述步骤后,如果有数据sys_recvfrom()就执行move_addr_to_user()把数据拷到应用程序。

六、send()函数概要说明:

send()函数简单的对sendto函数做了个封装

1、  调用sys_sendto()

2、  sys_sendto()中先构造一个接受应用层数据的struct msghdr的结构(说明见段末),设置一些初始值,例如根据是否阻塞设置sock文件的flag标志。

3、  sys_sendto()执行sock_sendmsg()函数,在sock_sendmsg()函数中先执行security_socket_sendmsg()做selinux检查,检测通过后执行sock_sendmsg_nosec()

4、  在sock_sendmsg_nosec()中执行的sock->ops->sendmsg()

sock->ops是const structproto_ops inet_stream_ops结构

sendmsg()是inet_sendmsg()函数

在inet_sendmsg()函数中最后执行的是sk->sk_prot->sendmsg(sk, msg, size)

这里的sk->sk_prot是struct proto tcp_prot

代码最终调到tcp_sendmsg()把数据包发送出去

                     struct msghdr说明:http://blog.chinaunix.net/uid-22920230-id-3387909.html

sys_sendto构建一个结构体structmsghdr,用于接收来自应用层的数据包,下面是结构体struct msghdr的定义:

struct msghdr {

         void           *msg_name;   /* ptr to socket address structure */

         int              msg_namelen;         /* size of socket address structure */

         struct iov_iter msg_iter;          /* data */

         void           *msg_control; /* ancillary data */

         __kernel_size_t       msg_controllen;       /*ancillary data buffer length */

         unsigned int     msg_flags;       /* flagson received message */

         struct kiocb      *msg_iocb;      /* ptr toiocb for async requests */

};

这个结构体的内容可以分为四组:

第一组是msg_name和msg_namelen,记录这个消息的名字,其实就是数据包的目的地址 。msg_name是指向一个结构体structsockaddr的指针,长度为16。结构体struct sockaddr只在进行参数传递时使用,无论是在用户态还是在内核态,我们都把其强制转化为结构体struct sockaddr_in。

strcutsockaddr_in{

         sa_family_t sin_family;

                                               unsignedshort int sin_port;

                                               structin_addr sin_addr;

                                               unsignedchar __pad[__SOCK_SIZE__

 - sizeof(short int)

-sizeof(unsigned short int)

- sizeof(struct in_addr)];

                                     };

                                     structin_addr{

                                               __u32s_addr;

                                     }

         __SOCK_SIZE__的值为16,所以,structsockaddr中真正有用的数据只有8bytes。

在我们的ping的时候,传入到内核的msghdr结构中:

msg.msg_name = { sa_family_t = MY_AF_INET,

sin_port = 0,

sin_addr.s_addr = 172.16.48.1}

                            msg_msg_namelen= 16

第二组是msg_iov和msg_iovlen,记录这个消息的内容。msg_iov是一个指向结构体structiovec的指针,实际上,确切地说,应该是一个结构体strcut iovec的数组。下面是该结构体的定义:

struct iovec{

void __user  *iov_base;

__kernel_size_t iov_len;

                                     }

iov_base指向数据包缓冲区,即参数buff,iov_len是buff的长度。msghdr中允许一次传递多个buff,以数组的形式组织在 msg_iov中,msg_iovlen就记录数组的长度(即有多少个buff)。在ping程序的实例中:

msg.msg_iov = { struct iovec = { iov_base = { icmp头+填充字符'E' },

iov_len = 40 }}

                                     msg.msg_len= 1

第三组是msg_control和msg_controllen,它们可被用于发送任何的控制信息

第四组是msg_flags。其值即为传入的参数flags。raw协议不支持MSG_ OOB向标志,即带外数据。向向内核发送msg 时使用msghdr,netlink socket使用自己的消息头nlmsghdr和自己的消息地址sockaddr_nl:

struct sockaddr_nl {

sa_family_t    nl_family;

unsigned short nl_pad;

__u32          nl_pid;

__u32          nl_groups;

};

struct nlmsghdr {

__u32 nlmsg_len;   /* Lengthof message */

__u16 nlmsg_type; /* Message type*/

__u16 nlmsg_flags; /* Additional flags */

__u32 nlmsg_seq;   /* Sequencenumber */

__u32 nlmsg_pid;   /* Sendingprocess PID */

};


tcp socket 的连接发送数据包、断开连接等操作最终都是调用的struct proto tcp_prot里面的相关函数。不同协议的socket有不同的struct proto与之对应。具体见af_inet.c 中的struct inet_protosw inetsw_array定义。

对socket的控制可以在应用层做,也可以到内核做。selinux、能力的钩子已经放到现有的socket流程中了,所以对现有的钩子函数扩充来处理是比较规范的。

七、附录一个和本地socket相关的防火墙问题( 原文地址:http://www.linuxidc.com/Linux/2012-06/63520.htm)

对于本地的socket,127.0.x.x(kernel直接取ip地址的前两个字节来判断)的数据包在协议栈里被处理掉了,不会到iptables里处理。

如有下面的iptables规则:
iptables -t nat -A PREROUTING -p tcp --dport 80 -j DNAT--to-destination 127.0.0.1:1234
你觉得会成功吗?试一下就知道,不会成功。这是为什么呢?奇怪的是,不但没有返回包,即使本地没有1234这个端口在监听,也不会发送reset,这说明数据包根本就没有到达传输层,通过forward统计信息来看,它也没有经过forward,那么数据包到哪里去了呢?执行conntrack -E,发现没有任何新的conntrack生成的事件,并且/proc/net/ip_conntrack中也没有任何甚至是转化前的到达80端口的连接。

通过IP安全规则,得知实际上是不允许外部进来的包以loopback地址为目标的,否则攻击就太容易了,比方说我在局域网上放一个目标地址为127.0.0.1IP包,修改其MAC目标地址为全1,这样所有的机器都将可以收到这样的包,因此路由模块负责丢弃这样的数据包。从表象上上,数据包被丢弃了,如果从统计数据来看,执行rtstat命令,将会看到下面一列:
|rt_cache|
|in_marti|
|  an_dst|
|      34|  

这说明34个这样的包被丢弃了,在哪里被丢了呢?肯定在路由模块,毕竟这是IP的策略,查看源码,在ip_route_input_slow里面发现:

1.     if (ipv4_is_lbcast(daddr) || ipv4_is_zeronet(daddr) ||  

2.         ipv4_is_loopback(daddr))  

3.         goto martian_destination;  

4.     ...  

5.     martian_destination:  

6.         RT_CACHE_STAT_INC(in_martian_dst);  

我们看到,这种情况的目的地址是一个火星地址,被丢弃了,同时还记录了一条统计信息。这说明,环回地址只能通过本机来访问,在本机出去的包经过ip_route_output后,其dst字段即路由结果已经被设置,如果是访问127.0.0.1,那么将不会再到ip_route_input,详见ip_rcv_finish

1.     if (skb_dst(skb) == NULL) {  

2.         int err = ip_route_input(skb, iph->daddr, iph->saddr, iph->tos,  

3.                      skb->dev);  

4.         ...  

5.     }  

理解了数据包在哪里被丢弃了之后,对于出现的现象就很好理解了。为何当发起到80端口的访问且途径设置了前面iptables规则的机器时/proc/net/ip_conntrack中没有任何关于端口80的连接呢?这是因为该连接的数据包既没有被forward到网卡,也没有被input到本地,因此该连接始终没有被confirm,而我们知道ip_conntrack机制只有在confirm时才会将连接加入到hash链表中并且报告事件。
       
本文描述的是一个关于漏洞的问题,经典的《专家们公认的20个最危险的安全漏洞》一文中有下面的论述:
G5 – 没有过滤地址不正确的包
G5.1描述
IP地址欺诈是黑客经常用来隐藏自己踪迹的一种手段。例如常见的smurf攻击就利用了路由的特性向数以千记的机器发出了一串数据包。每一个数据包都假冒了一个受害主机的IP地址作为源地址,于是上千台主机会同时向这个受害的主机返回数据包,导致该主机或网络崩溃。对流进和流出你网络的数据进行过滤可以提供一种高层的保护。过滤规则如下:
1任何进入你网络的数据包不能把你网络内部的地址作为源地址。
2任何进入你网络的数据包必须把你网络内部的地址作为目的地址。
3任何离开你网络的数据包必须把你网络内部的地址作为源地址。
4任何离开你网络的数据包不能把你网络内部的地址作为目的地址。
5任何进入或离开你网络的数据包不能把一个私有地址(private address)或在RFC1918
列出的属于保留空间(包括10.x.x.x/8,172.16.x.x/12192.168.x.x/16和网络回送地址
127.0.0.0/8.)的地址作为源或目的地址。
6阻塞任意源路由包或任何设置了IP选项的包。

可见,阻塞这种目标为环回地址的包是合理的,然而这件事到底应该有谁来做,这是一个问题,到底应该由防火墙软件来做呢,还是操作系统协议栈本身来做?Linux是在IP路由模块中做的,我认为这样不是很合理,有时候我真的需要这个功能,总不能重新编译一下内核吧,我觉得要么做到Netfilter中,要么就像rp_filter那样,做成可以配置的开关。

<think>嗯,用户问的是基于DPDK的TCP/IP协议栈运行在用户态还是内核态。这个问题需要结合DPDK的设计原理来分析。 首先回顾DPDK的核心机制。根据引用[3]的描述,DPDK通过用户空间IO和轮询模式驱动,使应用程序能直接访问网卡硬件资源,绕过了内核协议栈。这很关键——传统数据流是"网卡->驱动->内核协议栈->应用",而DPDK的工作边界在网卡和协议栈之间,相当于在用户态重新实现了协议栈需要的底层能力。 用户提到的"基于DPDK的TCP/IP协议栈"其实是个混合架构。引用[4]展示了典型实现:DPDK负责数据面加速(收发包/内存管理),真正的TCP/IP协议逻辑(如示例中的拥塞控制算法)运行在用户态应用层。这里有个精妙的分工:内核只保留控制面功能(如连接建立),而数据面处理完全下沉到用户态。 引用[2]的讨论也印证了这点,腾讯F-Stack等方案就是把完整协议栈移植到用户态。不过要注意,严格说DPDK本身不提供协议栈,它只是提供构建用户态协议栈的工具包。最终协议处理函数(如TCP分段重组)是开发者实现在用户空间的应用代码里的。 用户可能混淆了"DPDK"和"基于DPDK构建的协议栈"这两个概念。需要强调:DPDK是基础设施,协议栈是跑在其上的业务逻辑。就像用钢筋水泥(DPDK)盖房子(协议栈),房子盖在哪里(用户态/内核态)取决于建筑师的选择,而DPDK这套建材天生适合盖在用户态的地基上。 考虑到用户的技术背景,补充个类比可能更清晰:传统内核协议栈像政府办公大厅(所有业务集中办理),DPDK则像开通了VIP快捷通道(用户直通硬件),而业务员(协议逻辑)直接在VIP室里办公——这就是用户态协议栈的实质。</think>基于DPDK的TCP/IP协议栈**运行在用户态**。这是DPDK的核心设计目标之一,其主要原理和特点如下: ### 核心机制 1. **绕过内核协议栈** DPDK通过**用户空间I/O(Userspace I/O)** 技术,允许应用程序直接操作网卡硬件(需兼容驱动)。数据包从网卡接收后,通过**零拷贝(Zero-Copy)** 技术直接传递到用户态内存,无需经过内核协议栈处理[^3]。 2. **轮询模式驱动(PMD)** 取代传统的中断机制,DPDK在用户态**主动轮询网卡队列**,实时检测新数据包,避免了内核态-用户态切换的开销[^3]。 3. **用户态协议栈实现** 开发者在DPDK基础上构建完整的用户态TCP/IP协议栈(如F-Stack、mTCP等),实现连接管理、拥塞控制等逻辑。例如: ```c // 示例:DPDK收包后用户态处理TCP数据 struct rte_mbuf *pkts[BURST_SIZE]; uint16_t nb_rx = rte_eth_rx_burst(port_id, queue_id, pkts, BURST_SIZE); for (int i = 0; i < nb_rx; i++) { struct ipv4_hdr *ip_hdr = rte_pktmbuf_mtod_offset(pkts[i], struct ipv4_hdr *); if (ip_hdr->next_proto_id == IPPROTO_TCP) { tcp_process_packet(ip_hdr); // 用户态TCP处理函数 } } ``` ### 与传统架构对比 | **特性** | 内核协议栈 | 基于DPDK的用户态协议栈 | |------------------|-----------------------|------------------------------| | **数据路径** | 网卡→内核→用户态 | 网卡→直接访问用户态内存 | | **切换开销** | 频繁系统调用/上下文切换 | 无切换,全程用户态轮询 | | **性能瓶颈** | 内核协议栈处理延迟 | 依赖CPU轮询效率 | | **适用场景** | 通用操作系统 | 高性能网络应用(如NFV、LB) | ### 关键优势 - **低延迟**:省去内核协议栈处理环节,延迟降低30%-50%[^3]。 - **高吞吐**:轮询模式+零拷贝,单核可处理10Gbps流量[^1]。 - **灵活控制**:开发者可定制协议逻辑(如自定义拥塞控制算法[^4])。 ### 注意事项 - **需独占网卡**:DPDK需绑定网卡到用户态,内核无法同时使用该网卡。 - **CPU资源消耗**:轮询模式持续占用CPU核心,需专用核隔离调度。 - **功能完整性**:用户态协议栈需自行实现ARP、ICMP等辅助协议。 > **总结**:DPDK通过用户态驱动和内存管理机制,使TCP/IP协议栈完全运行在用户空间,实现了高性能网络处理,尤其适合数据中心、云原生等场景[^1][^3]。 --- ### 相关问题 1. **DPDK如何避免内核态-用户态的数据拷贝?** DPDK通过`rte_pktmbuf`内存池和网卡DMA直接映射到用户空间,实现零拷贝。网卡接收数据时直接写入用户态预分配的内存块,应用通过`rte_eth_rx_burst()`获取数据包指针,无需复制[^3]。 2. **用户态协议栈如何实现与内核协议的兼容性?** 需在用户态实现ARP响应、ICMP错误报文、路由表同步等。例如通过`TUN/TAP`设备或Netlink套接字与内核交换路由信息,或完全替代内核协议栈[^2]。 3. **DPDK对TCP拥塞控制的优化空间在哪里?** 用户态协议栈可实验新型算法(如BBR、CUBIC),实时调整参数。引用[4]展示了动态窗口调整: ```c void adjust_window(int loss_event) { if (loss_event) cwnd = MAX(1, cwnd/2); // 丢包时减半 else cwnd = MIN(max_window, cwnd+1); // 正常时线性增加 } ``` 4. **DPDK是否完全脱离内核?** 不完全脱离。初始化阶段需通过`rte_eal_init()`调用内核模块(如`uio_pci_generic`)绑定设备,运行时依赖大页内存(`/dev/hugepages`)等内核支持[^3]。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值