频道栏目
首页 > 资讯 > Linux > 正文

Linux 第三次握手ACK的接收和TCP连接建立完成的实验分享

18-06-05        来源:[db:作者]  
收藏   我要投稿

注:本文分析基于3.10.0-693.el7内核版本,即CentOS 7.4

客户端发送第三个握手报文ACK报文后,客户端其实就已经处于连接建立的状态,此时服务端还需要接收到这个ACK报文才算最终完成连接建立。

TCP层接收到ACK还是由tcp_v4_rcv()处理,这就是TCP层的对外接口。

int tcp_v4_rcv(struct sk_buff *skb)
{
...
    //根据报文的源和目的地址在established哈希表以及listen哈希表中查找连接
    //之前服务端接收到客户端的SYN报文时,socket的状态依然是listen
    //所以在接收到客户端的ACK时(第三次握手),依然从listen哈希表中找到对应的连接
    //这里有个疑问就是,既然此时还是listen状态,为啥所有的解释都是说在接收到SYN
    //报文后服务端进入SYN_RECV,连netstat命令查出来的也是。。。
    sk = __inet_lookup_skb(&tcp_hashinfo, skb, th->source, th->dest);
    if (!sk)
        goto no_tcp_socket;
...
    ret = 0;
    if (!sock_owned_by_user(sk)) {//如果sk没有被用户锁定,及没在使用
        if (!tcp_prequeue(sk, skb))
            ret = tcp_v4_do_rcv(sk, skb);//进入到主处理函数
...
}

然后还是老相好tcp_v4_do_rcv(),

int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
    struct sock *rsk;
...
    //上面说过,此时连接状态还是listen,至少内核里是这样的
    if (sk->sk_state == TCP_LISTEN) {
        //查找半连接队列,找到第一次握手创建的socket
        //并根据找到的这个socket创建一个新的socket返回
        struct sock *nsk = tcp_v4_hnd_req(sk, skb);
        if (!nsk)
            goto discard;

        if (nsk != sk) {
            sock_rps_save_rxhash(nsk, skb);
            //处理新创建的socket
            if (tcp_child_process(sk, nsk, skb)) {
                rsk = nsk;
                goto reset;
            }
            return 0;
        }
    } 
...
}

在SYN报文接收时就会将请求放入半连接队列,因此在第三次握手时就能在半连接队列找到对应的连接了。

static struct sock *tcp_v4_hnd_req(struct sock *sk, struct sk_buff *skb)
{
    struct tcphdr *th = tcp_hdr(skb);
    const struct iphdr *iph = ip_hdr(skb);
    struct sock *nsk;
    struct request_sock **prev;
    //在第一次握手时会将连接放入半连接队列,因此这里是能找到对应连接的
    struct request_sock *req = inet_csk_search_req(sk, &prev, th->source,
                               iph->saddr, iph->daddr);
    //找到之前的连接
    if (req)
        //使用这个req创建一个socket并返回
        return tcp_check_req(sk, skb, req, prev, false);

...
}

struct request_sock *inet_csk_search_req(const struct sock *sk,
                     struct request_sock ***prevp,
                     const __be16 rport, const __be32 raddr,
                     const __be32 laddr)
{
    const struct inet_connection_sock *icsk = inet_csk(sk);
    struct listen_sock *lopt = icsk->icsk_accept_queue.listen_opt;
    struct request_sock *req, **prev;
    //遍历半连接队列,查找对应连接
    for (prev = &lopt->syn_table[inet_synq_hash(raddr, rport, lopt->hash_rnd,
                            lopt->nr_table_entries)];
         (req = *prev) != NULL;
         prev = &req->dl_next) {
        const struct inet_request_sock *ireq = inet_rsk(req);

        if (ireq->ir_rmt_port == rport &&
            ireq->ir_rmt_addr == raddr &&
            ireq->ir_loc_addr == laddr &&
            AF_INET_FAMILY(req->rsk_ops->family)) {
            WARN_ON(req->sk);
            *prevp = prev;
            break;
        }
    }

    return req;
}

找到这个半连接请求后,就根据这个请求信息创建一个新的socket,由tcp_check_req()操作。

struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
               struct request_sock *req,
               struct request_sock **prev,
               bool fastopen)
{
    struct tcp_options_received tmp_opt;
    struct sock *child;
    const struct tcphdr *th = tcp_hdr(skb);
    __be32 flg = tcp_flag_word(th) & (TCP_FLAG_RST|TCP_FLAG_SYN|TCP_FLAG_ACK);
    bool paws_reject = false;

    BUG_ON(fastopen == (sk->sk_state == TCP_LISTEN));

    tmp_opt.saw_tstamp = 0;
    if (th->doff > (sizeof(struct tcphdr)>>2)) {
        tcp_parse_options(skb, &tmp_opt, 0, NULL);//分析TCP头部选项

        if (tmp_opt.saw_tstamp) {//如果开启了时间戳选项
            //这个时间其实就是客户端发送SYN报文的时间
            //req->ts_recent是在收到SYN报文时记录的
            tmp_opt.ts_recent = req->ts_recent;
            //注释里写ts_recent_stamp表示的是记录ts_recent时的时间
            //这里通过推算的方法得出ts_recent的时间,但是我觉得明显估计的不对
            //按照代码说的,如果SYNACK报文没有重传(req->num_timeout=0)
            //那么ts_recent_stamp即为当前时间减去1
            //但是收到SYN报文的时间肯定不可能是1s前,连接建立也就几毫秒的事。。。
            tmp_opt.ts_recent_stamp = get_seconds() - ((TCP_TIMEOUT_INIT/HZ)<num_timeout);
            //确认时间戳是否回绕,比较第一次握手报文和第三次握手报文的时间戳
            //没有回绕,返回false
            paws_reject = tcp_paws_reject(&tmp_opt, th->rst);
        }
    }

    //如果接收到的报文序列号等于之前SYN报文的序列号,说明这是一个重传SYN报文
    //如果SYN报文时间戳没有回绕,那就重新发送SYNACK报文,然后更新半连接超时时间
    if (TCP_SKB_CB(skb)->seq == tcp_rsk(req)->rcv_isn &&
        flg == TCP_FLAG_SYN && !paws_reject) {

        if (!tcp_oow_rate_limited(sock_net(sk), skb, LINUX_MIB_TCPACKSKIPPEDSYNRECV,
                      &tcp_rsk(req)->last_oow_ack_time) &&
                      !inet_rtx_syn_ack(sk, req))//没有超过速率限制,那就重发SYNACK报文
            req->expires = min(TCP_TIMEOUT_INIT << req->num_timeout,
                       TCP_RTO_MAX) + jiffies;//更新半连接的超时时间
        return NULL;
    }

    //收到的ACK报文的确认号不对,返回listen socket
    if ((flg & TCP_FLAG_ACK) && !fastopen &&
        (TCP_SKB_CB(skb)->ack_seq != tcp_rsk(req)->snt_isn + 1))
        return sk;

    /* Also, it would be not so bad idea to check rcv_tsecr, which
     * is essentially ACK extension and too early or too late values
     * should cause reset in unsynchronized states.
     */

    /* RFC793: "first check sequence number". */

    //报文时间戳回绕,或者报文序列不在窗口范围,发送ACK后丢弃
    if (paws_reject || !tcp_in_window(TCP_SKB_CB(skb)->seq, TCP_SKB_CB(skb)->end_seq,
                      tcp_rsk(req)->rcv_nxt, tcp_rsk(req)->rcv_nxt + req->rcv_wnd)) {
        /* Out of window: send ACK and drop. */
        if (!(flg & TCP_FLAG_RST))
            req->rsk_ops->send_ack(sk, skb, req);
        if (paws_reject)
            NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_PAWSESTABREJECTED);
        return NULL;
    }

    /* In sequence, PAWS is OK. */
    //开启了时间戳,且收到的报文序列号小于等于期望接收的序列号
    if (tmp_opt.saw_tstamp && !after(TCP_SKB_CB(skb)->seq, tcp_rsk(req)->rcv_nxt))
        req->ts_recent = tmp_opt.rcv_tsval;//更新ts_recent为第三次握手报文的时间戳

    //清除SYN标记  
    if (TCP_SKB_CB(skb)->seq == tcp_rsk(req)->rcv_isn) {
        /* Truncate SYN, it is out of window starting
           at tcp_rsk(req)->rcv_isn + 1. */
        flg &= ~TCP_FLAG_SYN;
    }

    /* RFC793: "second check the RST bit" and
     *     "fourth, check the SYN bit"
     */
    if (flg & (TCP_FLAG_RST|TCP_FLAG_SYN)) {
        TCP_INC_STATS_BH(sock_net(sk), TCP_MIB_ATTEMPTFAILS);
        goto embryonic_reset;
    }

    /* ACK sequence verified above, just make sure ACK is
     * set.  If ACK not set, just silently drop the packet.
     *
     * XXX (TFO) - if we ever allow "data after SYN", the
     * following check needs to be removed.
     */
    if (!(flg & TCP_FLAG_ACK))
        return NULL;

    /* For Fast Open no more processing is needed (sk is the
     * child socket).
     */
    if (fastopen)
        return sk;

    /* While TCP_DEFER_ACCEPT is active, drop bare ACK. */
    //设置了TCP_DEFER_ACCEPT,即延迟ACK选项,且该ACK没有携带数据,那就先丢弃
    if (req->num_timeout < inet_csk(sk)->icsk_accept_queue.rskq_defer_accept &&
        TCP_SKB_CB(skb)->end_seq == tcp_rsk(req)->rcv_isn + 1) {
        inet_rsk(req)->acked = 1;//标记已经接收过ACK报文了
        NET_INC_STATS_BH(sock_net(sk), LINUX_MIB_TCPDEFERACCEPTDROP);
        return NULL;
    }

    /* OK, ACK is valid, create big socket and
     * feed this segment to it. It will repeat all
     * the tests. THIS SEGMENT MUST MOVE SOCKET TO
     * ESTABLISHED STATE. If it will be dropped after
     * socket is created, wait for troubles.
     */
    //这里总算是正常ACK报文了,创建一个新的socket并返回
    child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL);
    if (child == NULL)
        goto listen_overflow;
    //将老的socket从半连接队列里摘链
    inet_csk_reqsk_queue_unlink(sk, req, prev);
    //删除摘除的请求,然后更新半连接队列的统计信息
    //如果半连接队列为空,删除SYNACK定时器
    inet_csk_reqsk_queue_removed(sk, req);

    //将新创建的新socket加入全连接队列里,并更新队列统计信息
    inet_csk_reqsk_queue_add(sk, req, child);
    return child;
...
}

找到半连接队列里的请求后,还需要和当前接收到的报文比较,检查是否出现时间戳回绕的情况(timestamps选项开启的前提下),通过tcp_paws_reject()检测。

static inline bool tcp_paws_reject(const struct tcp_options_received *rx_opt,
                   int rst)
{
    //检查时间戳是否回绕
    if (tcp_paws_check(rx_opt, 0))
        return false;

    //第三次握手报文一般都不会有rst标志
    //另一个条件是,当前时间和上次该ip通信的时间间隔大于TCP_PAWS_MSL(60s),
    //即一个time_wait状态持续时间
    if (rst && get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_MSL)
        return false;
    return true;
}

static inline bool tcp_paws_check(const struct tcp_options_received *rx_opt,
                  int paws_win)
{
    //rx_opt->ts_recent是SYN报文发送时间,
    //rx_opt->rcv_tsval是客户端发送第三次握手报文的时间
    //也就是要保证时间戳没有回绕,正常情况下这里就满足返回了
    if ((s32)(rx_opt->ts_recent - rx_opt->rcv_tsval) <= paws_win)
        return true;
    //距离上一次收到这个ip的报文过去了24天,一般不可能
    if (unlikely(get_seconds() >= rx_opt->ts_recent_stamp + TCP_PAWS_24DAYS))
        return true;
    //没有开启时间戳
    if (!rx_opt->ts_recent)
        return true;
    return false;
}

确认时间戳未发生回绕后,看下是不是重传的SYN报文,如果是那就重发SYNACK报文,并重置SYNACK定时器。

接下来一个比较重要的点就是,如果开启了TCP_DEFER_ACCEPT选项,即延迟ACK选项,但是接收到的这个ACK没有携带数据,那就先丢弃,标记收到过ACK报文,等待后续客户端发送数据再做连接建立的真正操作。

重重检查后,总算是要创建新的socket了,因此inet_csk(sk)->icsk_af_ops->syn_recv_sock上场了。我们熟悉的icsk_af_ops又来了,它指向的是ipv4_specific,

const struct inet_connection_sock_af_ops ipv4_specific = {
    .queue_xmit    = ip_queue_xmit,
    .send_check    = tcp_v4_send_check,
    .rebuild_header    = inet_sk_rebuild_header,
    .sk_rx_dst_set     = inet_sk_rx_dst_set,
    .conn_request      = tcp_v4_conn_request,
    .syn_recv_sock     = tcp_v4_syn_recv_sock,
    ...
};

所以创建新socket就是tcp_v4_syn_recv_sock()完成的。

/*
 * The three way handshake has completed - we got a valid synack -
 * now create the new socket.
 */
struct sock *tcp_v4_syn_recv_sock(struct sock *sk, struct sk_buff *skb,
                  struct request_sock *req,
                  struct dst_entry *dst)
{
    struct inet_request_sock *ireq;
    struct inet_sock *newinet;
    struct tcp_sock *newtp;
    struct sock *newsk;
#ifdef CONFIG_TCP_MD5SIG
    struct tcp_md5sig_key *key;
#endif
    struct ip_options_rcu *inet_opt;
    //如果全连接队列已经满了,那就丢弃报文
    if (sk_acceptq_is_full(sk))
        goto exit_overflow;
    //创建一个新的socket用于连接建立后的处理,原来的socket继续监听新发起的连接
    newsk = tcp_create_openreq_child(sk, req, skb);
    if (!newsk)
        goto exit_nonewsk;

...
    //处理新创建的socket的端口,一般就是和原来监听socket使用同一个端口
    if (__inet_inherit_port(sk, newsk) < 0)
        goto put_and_exit;
    //将新创建的新socket加入established哈希表中
    __inet_hash_nolisten(newsk, NULL);

    return newsk;
...
}

检查全连接队列是否满了,满了就丢弃报文,否则请出tcp_create_openreq_child()创建新socket。

struct sock *tcp_create_openreq_child(struct sock *sk, struct request_sock *req, struct sk_buff *skb)
{
    //创建子socket
    struct sock *newsk = inet_csk_clone_lock(sk, req, GFP_ATOMIC);
    //接下来就是新socket的各种初始化了
    if (newsk != NULL) {
        const struct inet_request_sock *ireq = inet_rsk(req);
        struct tcp_request_sock *treq = tcp_rsk(req);
        struct inet_connection_sock *newicsk = inet_csk(newsk);
        struct tcp_sock *newtp = tcp_sk(newsk);
        ...
        //初始化新socket的各个定时器
        tcp_init_xmit_timers(newsk);
        ...
        //如果开启时间戳
        if (newtp->rx_opt.tstamp_ok) {
            //记录第三次握手报文发送的时间,上面的流程已经将ts_recent更新
            newtp->rx_opt.ts_recent = req->ts_recent;
            newtp->rx_opt.ts_recent_stamp = get_seconds();
            newtp->tcp_header_len = sizeof(struct tcphdr) + TCPOLEN_TSTAMP_ALIGNED;
        } else {
            newtp->rx_opt.ts_recent_stamp = 0;
            newtp->tcp_header_len = sizeof(struct tcphdr);
        }
        ...
    }
    return newsk;
}

struct sock *inet_csk_clone_lock(const struct sock *sk,
                 const struct request_sock *req,
                 const gfp_t priority)
{
    struct sock *newsk = sk_clone_lock(sk, priority);

    if (newsk != NULL) {
        struct inet_connection_sock *newicsk = inet_csk(newsk);

        //新创建的socket状态设置为SYN_RECV
        newsk->sk_state = TCP_SYN_RECV;
        newicsk->icsk_bind_hash = NULL;
        //记录目的端口,以及服务器端端口
        inet_sk(newsk)->inet_dport = inet_rsk(req)->ir_rmt_port;
        inet_sk(newsk)->inet_num = inet_rsk(req)->ir_num;
        inet_sk(newsk)->inet_sport = htons(inet_rsk(req)->ir_num);
        newsk->sk_write_space = sk_stream_write_space;

        inet_sk(newsk)->mc_list = NULL;
        newicsk->icsk_retransmits = 0;//重传次数
        newicsk->icsk_backoff     = 0;//退避指数
        newicsk->icsk_probes_out  = 0;

        /* Deinitialize accept_queue to trap illegal accesses. */
        memset(&newicsk->icsk_accept_queue, 0, sizeof(newicsk->icsk_accept_queue));

        security_inet_csk_clone(newsk, req);
    }
    return newsk;
}

从inet_csk_clone_lock()中我们终于看到socket的状态进入SYN_RECV了,千呼万唤始出来啊,这都是第三次握手报文了,说好的接收到SYN报文就进入SYN_RECV的呢,骗得我好苦。

创建好新的socket后,需要将该socket归档,处理其使用端口,并且放入bind哈希表,这样之后我们才能查询得到这个新的socket。

int __inet_inherit_port(struct sock *sk, struct sock *child)
{
    struct inet_hashinfo *table = sk->sk_prot->h.hashinfo;
    unsigned short port = inet_sk(child)->inet_num;
    const int bhash = inet_bhashfn(sock_net(sk), port,
            table->bhash_size);
    struct inet_bind_hashbucket *head = &table->bhash[bhash];
    struct inet_bind_bucket *tb;

    spin_lock(&head->lock);
    tb = inet_csk(sk)->icsk_bind_hash;
    //一般新socket和原先的socket端口都是一样的
    if (tb->port != port) {
        /* NOTE: using tproxy and redirecting skbs to a proxy
         * on a different listener port breaks the assumption
         * that the listener socket's icsk_bind_hash is the same
         * as that of the child socket. We have to look up or
         * create a new bind bucket for the child here. */
        inet_bind_bucket_for_each(tb, &head->chain) {
            if (net_eq(ib_net(tb), sock_net(sk)) &&
                tb->port == port)
                break;
        }
        if (!tb) {
            tb = inet_bind_bucket_create(table->bind_bucket_cachep,
                             sock_net(sk), head, port);
            if (!tb) {
                spin_unlock(&head->lock);
                return -ENOMEM;
            }
        }
    }
    //将新的socket加入bind哈希表中
    inet_bind_hash(child, tb, port);
    spin_unlock(&head->lock);

    return 0;
}

但是加入bind哈希表并不足够,bind哈希表只是存储绑定的ip和端口信息,还需要以下几个动作:

将新建的socket加入establish哈希表 将原先老的socket从半连接队列里拆除并更新半连接统计信息 将新建的socket加入全连接队列

加入全连接队列由inet_csk_reqsk_queue_add()操作,

static inline void inet_csk_reqsk_queue_add(struct sock *sk,
                        struct request_sock *req,
                        struct sock *child)
{
    reqsk_queue_add(&inet_csk(sk)->icsk_accept_queue, req, sk, child);
}

static inline void reqsk_queue_add(struct request_sock_queue *queue,
                   struct request_sock *req,
                   struct sock *parent,
                   struct sock *child)
{
    req->sk = child;
    sk_acceptq_added(parent);

    if (queue->rskq_accept_head == NULL)
        queue->rskq_accept_head = req;
    else
        queue->rskq_accept_tail->dl_next = req;

    queue->rskq_accept_tail = req;
    req->dl_next = NULL;
}

static inline void sk_acceptq_added(struct sock *sk)
{   
    //全连接队列里连接数量统计更新
    sk->sk_ack_backlog++;
}

有一点要注意的就是,加入半连接队列的函数是inet_csk_reqsk_queue_added(),和加入全连接队列的函数就差一个单词,一个是add,一个是added,别混淆了。

返回这个新创建的socket后,就进入tcp_child_process()函数继续深造。

int tcp_child_process(struct sock *parent, struct sock *child,
              struct sk_buff *skb)
{
    int ret = 0;
    int state = child->sk_state;
    //新的socket没有没用户占用
    if (!sock_owned_by_user(child)) {
        //对,又是它,就是它,处理各种状态socket的接口
        ret = tcp_rcv_state_process(child, skb, tcp_hdr(skb),
                        skb->len);
        /* Wakeup parent, send SIGIO */
        if (state == TCP_SYN_RECV && child->sk_state != state)
            parent->sk_data_ready(parent, 0);
    } else {
        /* Alas, it is possible again, because we do lookup
         * in main socket hash table and lock on listening
         * socket does not protect us more.
         */
        //用户占用则加入backlog队列
        __sk_add_backlog(child, skb);
    }

    bh_unlock_sock(child);
    sock_put(child);
    return ret;
}

接着socket就要从SYN_RECV进入ESTABLISHED状态了,这就又要tcp_rcv_state_process()出马了。

int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb,
              const struct tcphdr *th, unsigned int len)
{
    struct tcp_sock *tp = tcp_sk(sk);
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct request_sock *req;
    int queued = 0;
    bool acceptable;
    u32 synack_stamp;

    tp->rx_opt.saw_tstamp = 0;
...

    req = tp->fastopen_rsk;//快速开启选项相关
...
    /* step 5: check the ACK field */
    //检查ACK确认号的合法值
    acceptable = tcp_ack(sk, skb, FLAG_SLOWPATH |
                      FLAG_UPDATE_TS_RECENT) > 0;

    switch (sk->sk_state) {
    //这个是新创建的socket,所以此时状态是SYN_RECV
    case TCP_SYN_RECV:
        if (!acceptable)
            return 1;

        /* Once we leave TCP_SYN_RECV, we no longer need req
         * so release it.
         */
        if (req) {//快速开启走这个流程
            synack_stamp = tcp_rsk(req)->snt_synack;
            tp->total_retrans = req->num_retrans;
            reqsk_fastopen_remove(sk, req, false);
        } else {
            //非快速开启流程
            synack_stamp = tp->lsndtime;
            /* Make sure socket is routed, for correct metrics. */
            icsk->icsk_af_ops->rebuild_header(sk);
            tcp_init_congestion_control(sk);//初始化拥塞控制
            //mtu探测初始化
            tcp_mtup_init(sk);
            tp->copied_seq = tp->rcv_nxt;
            //初始化接收和发送缓存空间
            tcp_init_buffer_space(sk);
        }
        smp_mb();
        //服务端连接状态终于抵达终点,established
        tcp_set_state(sk, TCP_ESTABLISHED);
        //调用sock_def_wakeup唤醒该sock上等待队列的所有进程
        sk->sk_state_change(sk);

        /* Note, that this wakeup is only for marginal crossed SYN case.
         * Passively open sockets are not waked up, because
         * sk->sk_sleep == NULL and sk->sk_socket == NULL.
         */
        //对于服务端是被动开启socket,所以不会走这个流程
        if (sk->sk_socket)
            sk_wake_async(sk, SOCK_WAKE_IO, POLL_OUT);
        ...
    }

    /* step 6: check the URG bit */
    tcp_urg(sk, skb, th);

    /* step 7: process the segment text */
    switch (sk->sk_state) {
    ...
    case TCP_ESTABLISHED:
        //这时socket已经是established状态了,可以处理及接收数据了
        tcp_data_queue(sk, skb);
        queued = 1;
        break;
    }

    /* tcp_data could move socket to TIME-WAIT */
    if (sk->sk_state != TCP_CLOSE) {
        tcp_data_snd_check(sk);
        tcp_ack_snd_check(sk);
    }

    if (!queued) {
discard:
        __kfree_skb(skb);
    }
    return 0;
}

终于的终于,服务端也到达established状态,连接总算是可靠的建立了。

我们大概总结下第三次握手报文的接收处理流程:

查找半连接队列获取该连接之前的请求req 判断是否是重传SYN报文,是的话重传SYNACK报文,并重置SYNACK定时器 如果开启了延迟ACK选项,且ACK报文未携带数据,丢弃报文 根据半连接队列的req,创建一个新的socket,将其状态置为SYN_RECV 将新创建的socket加入bind哈希表和establisted哈希表 将原来老的监听socket移出半连接队列并更新信息 将新创建的socket加入全连接队列并更新统计信息 新建的socket进入ESTABLISHED状态,唤醒该socket上所有睡眠的进程 如果有数据的话,可以着手处理数据了

相关TAG标签
上一篇:从输入的SQL参数中获取表名及字段名的操作教程
下一篇:.NET Core 3支持Windows桌面应用的新特性介绍
相关文章
图文推荐

关于我们 | 联系我们 | 广告服务 | 投资合作 | 版权申明 | 在线帮助 | 网站地图 | 作品发布 | Vip技术培训 | 举报中心

版权所有: 红黑联盟--致力于做实用的IT技术学习网站