比特币源码分析--P2P网络初始化

2021/6/27 14:17:22

本文主要是介绍比特币源码分析--P2P网络初始化,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

比特币源码分析--P2P网络初始化


     区块链和AI无疑是近期业界当之无愧的两大风口。AI就不说了,区块链从17年各种数字货币被炒上了天,一下成为了人们街头巷议的焦点,本文撇开数字货币的投资不说,仅仅从技术层面来剖析一下区块链各个部分的原理。毕竟目前已经有包括BAT等巨头在内的许多公司投入到了区块链的研发,其相关的应用相信也会越来越多的落地,作为技术人员,学习一下区块链的源码,抓住这个风口是非常值得的。

     本系列将以比特币区块链作为学习的对象,这是所有区块链项目的始祖,也是学习区块链的最佳原材料。另外对于区块链的学习,建议可以先看一下《精通比特币》这本书,对比特币整体的原理有一定了解后,再结合源码一块块的学习将会事半功倍。

     因为区块链的本质是一个建立在P2P网络上的分布式数据库,所以P2P网络可以算得上是区块链的一块基石,我们就以P2P网络作为切入点来开始比特币源码的学习。本文将结合比特币源码,分析比特币P2P网络中的一个节点是如何发现其他节点并与之建立数据通信的通道。

    由于作者本人也是区块链新手,所以文中有错误的地方也欢迎大家指正。

1、源码获取
  比特币是一个开源项目,其源码可以从以下github链接上获取:

  https://github.com/bitcoin/bitcoin

  关于源码的编译,在《精通比特币》一书中有较为详细的说明,有兴趣的读者可以参考此书中的说明尝试去编译一下。

2、比特币P2P网络
  区块链从本质上讲就是建立在一个P2P网络上的分布式数据库,然后采用PoW或者PoS等共识算法让网络上的节点对某件事情(比如比特币交易)达成共识。因此在了解比特币其他模块之前,先了解比特币的P2P网络是一个比较好的切入点。本文就从源码的角度来分析一下比特币网络相关的知识点,比如一个新启动的节点是如何发现并连接其他节点的,其他网络的节点又是如何连接到我们的节点上。

2.1、节点发现
  当一个新的网络节点启动后,为了能够参与协同运作,它必须至少发现一个其他网络中的节点并与之建立连接。比特币的网络拓扑结构不基于地理位置,因此可以随机的选择节点建立连接。

  那么一个新启动的节点是如何发现其他网络的节点呢?比特币网络采用了两种方式:

  (1) 利用种子节点

  比特币的客户端会维护一个列表,列表中记录了长期稳定运行的节点,这些节点也被称之为种子节点。连接到种子节点的好处就是新节点可以快速的发现网络中的其他节点。在比特币里,可以通过选项“-dnsseed”来指定是否使用种子节点,该选项默认是开启的。

    (2) 节点引荐

  除了使用种子节点外,还可以将当前启动节点引荐给其他节点的方式。可以通过“-seednode”选项指定一个节点的ip,之后节点将和该节点建立连接,将该节点作为DNS种子节点,在引荐信息形成之后断开与该节点的连接,并与新发现的节点连接。

2.2 握手协议
  当节点与对等节点建立好连接后,首先要做的就是握手。其过程如下:

  (1)节点向peer发送version消息开始握手,此消息中包含如下一些内容:

  PROTOCOL_VERSION:当前节点的比特币P2P协议的版本号;

  nLocalServices:节点支持的本地服务列表,目前仅支持NODE_NETWORK;

  nTime:当前时间;

  addrYou:当前节点可见的远程节点的IP地址;

  addrMe:本节点发现的本地IP地址;

    subver:当前节点运行的软件类型的子版本号;

  baseHeight:当前节点上的区块链的高度。

  对等节点收到version消息后,会回应verack进行确认并建立连接。有时候对等端可能需要互换连接并连回起始节点,此时对等端也会发送该节点的version消息。

    参考下图:

    

2.3、地址广播及发现
  完成握手协议后,新节点将会发送一条包含自己IP地址的addr消息给对等端,对等端收到以后又向与它连接的相邻节点发送addr消息,这样新节点的ip地址就会在P2P网络中广播出去。此外新节点还可以发送getaddr消息,要求对等端把自己知道的节点的IP地址发送过来。通过这种方式,新节点可以找到需要连接的对等节点。如下图:

    

    节点必须连接到若干不同的对等节点才能在比特币网络中建立通向比特币网络的种类各异的路径(path)。由于节点可以随时加入和离开,通讯路径是不可靠的。因此,节点必须持续进行两项工作:在失去已有连接时发现新节点,并在其他节点启动时为其提供帮助。节点启动时只需要一个连接,因为第一个节点可以将它引荐给它的对等节点,而这些节点又会进一步提供引荐。一个节点,如果连接到大量的其他对等节点,这既没必要,也是对网络资源的浪费。在启动完成后,节点会记住它最近成功连接的对等节点;因此,当重新启动后它可以迅速与先前的对等节点网络重新建立连接。如果先前的网络的对等节点对连接请求无应答,该节点可以使用种子节点进行重启动。

    用户可以通过提供-connect=选项来指定一个或多个IP地址,从而达到复写自动节点管理功能并指定IP地址列表的目的。如果采用此选项,节点只连接到这些选定的节点IP地址,而不会自动发现并维护对等节点之间的连接。

    如果已建立的连接没有数据通信,所在的节点会定期发送信息以维持连接。如果节点持续某个连接长达90分钟没有任何通信,它会被认为已经从网络中断开,网络将开始查找一个新的对等节点。因此,比特币网络会随时根据变化的节点及网络问题进行动态调整,不需经过中心化的控制即可进行规模增、减的有机调整。

3、源码分析
    以上分析了比特币P2P网络中的握手协议,以及地址的广播扩散的原理。这一节我们就来分析源码,看看上面描述的这些交互过程在代码中是如何体现的。

3.1、初始化参数
    比特币核心的入口函数main函数位于文件bitcoind.cpp中,入口函数非常简短:

int main(int argc, char* argv[])
{
    SetupEnvironment();
 
    // Connect bitcoind signal handlers
    noui_connect();
 
    return (AppInit(argc, argv) ? EXIT_SUCCESS : EXIT_FAILURE);
}
    比特币核心的初始化操作的重任基本上都在AppInit这个函数里。我们对这个函数抽丝剥茧,一步一步的展开分析。

static bool AppInit(int argc, char* argv[])
{
    bool fRet = false;
 
    //
    // Parameters
    //
    // If Qt is used, parameters/bitcoin.conf are parsed in qt/bitcoin.cpp's main()
    SetupServerArgs();
    第一个调用的就是SetupServerArgs,这里会初始化整个服务端的相关参数,给一些默认值。首先来认识几个类:

3.1.1 CBaseChainParams
  这是比特币客户端(bitcoin_cli)和服务端共享的一个类,定义了基本的比特币参数,主要是比特币的数据存储目录和相互通信的rpc端口号。这个类比较简单,可以一窥源码:

class CBaseChainParams
{
public:
    /** BIP70 chain name strings (main, test or regtest) */
    static const std::string MAIN;
    static const std::string TESTNET;
    static const std::string REGTEST;
 
    const std::string& DataDir() const { return strDataDir; }
    int RPCPort() const { return nRPCPort; }
 
    CBaseChainParams() = delete;
    CBaseChainParams(const std::string& data_dir, int rpc_port) : nRPCPort(rpc_port), strDataDir(data_dir) {}
 
private:
    int nRPCPort;
    std::string strDataDir;
};
  里面就两个成员:nRPCPort定义了客户端和服务端通信的rpc端口,strDataDir定义了数据存储的目录。

3.1.2 CChainParams
    这个类定义了比特币系统的很多比较重要的参数。我们看看都有哪些:

    Consensus::Params consensus;
    CMessageHeader::MessageStartChars pchMessageStart;
    int nDefaultPort;
    uint64_t nPruneAfterHeight;
    std::vector vSeeds;
    std::vector base58Prefixes[MAX_BASE58_TYPES];
    std::string bech32_hrp;
    std::string strNetworkID;
    CBlock genesis;
    std::vector vFixedSeeds;
    bool fDefaultConsistencyChecks;
    bool fRequireStandard;
    bool fMineBlocksOnDemand;
    CCheckpointData checkpointData;
    ChainTxData chainTxData;
    bool m_fallback_fee_enabled;
  比较多,这里我们就关注几个和比特币网络相关的参数:

  nDefaultPort:比特币P2P网络默认的监听端口,默认是8333。

  vSeeds:这个是比特币代码中内置的一些DNS种子节点。在默认开启-dnsseed选项并且不指定-connect的情况下,新节点启动时将尝试通过这些种子节点加入P2P网络中。

3.1.3 CMainParams
  这个类继承自上一节的CChainParams。这个类的构造函数里初始化了比特币系统的一些核心参数。另外比特币创世区块(区块链上第一个区块)也是在这里生成的。这里暂且先不关心其他的,先把关注点放在P2P网络相关的事情上来。

  在这个类的构造函数中内置了一些种子节点:

// Note that of those which support the service bits prefix, most only support a subset of
        // possible options.
        // This is fine at runtime as we'll fall back to using them as a oneshot if they don't support the
        // service bits we want, but we should get them updated to support all service bits wanted by any
        // release ASAP to avoid it where possible.
        vSeeds.emplace_back("seed.bitcoin.sipa.be"); // Pieter Wuille, only supports x1, x5, x9, and xd
        vSeeds.emplace_back("dnsseed.bluematt.me"); // Matt Corallo, only supports x9
        vSeeds.emplace_back("dnsseed.bitcoin.dashjr.org"); // Luke Dashjr
        vSeeds.emplace_back("seed.bitcoinstats.com"); // Christian Decker, supports x1 - xf
        vSeeds.emplace_back("seed.bitcoin.jonasschnelli.ch"); // Jonas Schnelli, only supports x1, x5, x9, and xd
        vSeeds.emplace_back("seed.btc.petertodd.org"); // Peter Todd, only supports x1, x5, x9, and xd
        vSeeds.emplace_back("seed.bitcoin.sprovoost.nl"); // Sjors Provoost
  感兴趣的同学可以用DNS查询工具来查询这些种子。

  认识了这三个记录了比特币系统运行所必须的参数的类以后,回到之前提到的SetupServerArgs这个函数:

void SetupServerArgs()
{
    const auto defaultBaseParams = CreateBaseChainParams(CBaseChainParams::MAIN);
    const auto testnetBaseParams = CreateBaseChainParams(CBaseChainParams::TESTNET);
    const auto defaultChainParams = CreateChainParams(CBaseChainParams::MAIN);
    const auto testnetChainParams = CreateChainParams(CBaseChainParams::TESTNET);
  可以看到这里针对比特币主网和测试用公网生成了不同的默认参数。

  最终这些参数信息将保存在全局变量中,方便其他模块引用:globalChainParams和globalChainBaseParams。

  AppInit函数的最后,会调用AppInitMain,完成整个系统的初始化,AppInitMain比较重,比特币系统核心的东西基本上都从这里诞生。

3.2 比特币P2P网络组件
  在继续了解比特币网络的连接过程之前,需要先简单了解一些和网络相关的封装类。逐一过一下。

3.2.1 CConnman
  顾名思义,这个类是网络连接的管理类。负责节点的初始化及启动,P2P消息的推送及接收,接收其他节点的连接等等。这个类比较庞大。

3.2.2 PeerLogicValidation
  这个类多重继承了两个接口类CValidationInterface和NetEventsInterface。其中CValidationInterface是和钱包相关的一个接口,暂且不提。NetEventsInterface是和网络相关的,看一下这个接口的定义:

class NetEventsInterface
{
public:
    virtual bool ProcessMessages(CNode* pnode, std::atomic& interrupt) = 0;
    virtual bool SendMessages(CNode* pnode, std::atomic& interrupt) = 0;
    virtual void InitializeNode(CNode* pnode) = 0;
    virtual void FinalizeNode(NodeId id, bool& update_connection_time) = 0;
 
protected:
    /**
     * Protected destructor so that instances can only be deleted by derived classes.
     * If that restriction is no longer desired, this should be made public and virtual.
     */
    ~NetEventsInterface() = default;
};
  NetEventsInterface::ProcessMessage:处理接收到的消息;

  NetEventsInterface::SendMessage:发送消息;

  NetEventsInterface::InitializeNode:初始化节点;

3.2.3 CNode
  维护节点信息,包括通信的套接字,发送缓冲区,接收缓冲区等等。

3.2.4 CNetAddr和CService
  对IP地址的封装(IPV4和IPV6).

class CNetAddr
{
    protected:
        unsigned char ip[16]; // in network byte order
        uint32_t scopeId; // for scoped/link-local ipv6 addresses
  可以看到其主要属性就是一个ip地址。另外这个类里还包含了一些对IP地址类型进行各种判断的工具函数。

  CService继承了CNetAddr,再其基础上多了端口号的属性。

class CService : public CNetAddr
{
    protected:
        uint16_t port; // host order
3.3 利用种子发现节点
    本文之前已经提到过,新启动的节点首先需要发现网络中的其他节点并与之建立连接。发现节点可以通过内置的种子节点,也可以在启动bitcoind时通过-dnsseed选项指定一个种子节点。接下来就逐步分析代码里是如何实现的。

  继续看AppInitMain这个函数,他会创建出上一节提到的连接管理器对象:

g_connman = std::unique_ptr(new CConnman(GetRand(std::numeric_limits::max()), GetRand(std::numeric_limits::max())));
    CConnman& connman = *g_connman;
 
    peerLogic.reset(new PeerLogicValidation(&connman, scheduler));
  这两个对象均为全局变量。

  接下来将调用Discover函数,找到所有的本地网络接口地址,并保存起来。这些地址在随后将发送给连接到的对等节点(广播本机地址)。

void Discover()
{
    if (!fDiscover)
        return;
 
#ifdef WIN32
    // Get local host IP
    char pszHostName[256] = "";
    if (gethostname(pszHostName, sizeof(pszHostName)) != SOCKET_ERROR)
    {
        std::vector vaddr;
        if (LookupHost(pszHostName, vaddr, 0, true))
        {
            for (const CNetAddr &addr : vaddr)
            {
                if (AddLocal(addr, LOCAL_IF))
                    LogPrintf("%s: %s - %s\n", __func__, pszHostName, addr.ToString());
            }
        }
    }
#else
    // Get local host ip
    struct ifaddrs* myaddrs;
    if (getifaddrs(&myaddrs) == 0)
    {
        for (struct ifaddrs* ifa = myaddrs; ifa != nullptr; ifa = ifa->ifa_next)
        {
            if (ifa->ifa_addr == nullptr) continue;
            if ((ifa->ifa_flags & IFF_UP) == 0) continue;
            if (strcmp(ifa->ifa_name, "lo") == 0) continue;
            if (strcmp(ifa->ifa_name, "lo0") == 0) continue;
            if (ifa->ifa_addr->sa_family == AF_INET)
            {
                struct sockaddr_in* s4 = (struct sockaddr_in*)(ifa->ifa_addr);
                CNetAddr addr(s4->sin_addr);
                if (AddLocal(addr, LOCAL_IF))
                    LogPrintf("%s: IPv4 %s: %s\n", __func__, ifa->ifa_name, addr.ToString());
            }
            else if (ifa->ifa_addr->sa_family == AF_INET6)
            {
                struct sockaddr_in6* s6 = (struct sockaddr_in6*)(ifa->ifa_addr);
                CNetAddr addr(s6->sin6_addr);
                if (AddLocal(addr, LOCAL_IF))
                    LogPrintf("%s: IPv6 %s: %s\n", __func__, ifa->ifa_name, addr.ToString());
            }
        }
        freeifaddrs(myaddrs);
    }
#endif
}
  主要就是调用getifaddrs这个网络api获取所有的本地地址,并调用AddLocal将这些地址添加到保存本地地址的全局变量中。这个后续在说明广播本地地址给相邻节点时还会提到,暂且略过。

  接着初始化一个封装网络连接各种参数的CConnman::Options对象:

    CConnman::Options connOptions;
    connOptions.nLocalServices = nLocalServices;
    connOptions.nMaxConnections = nMaxConnections;
    connOptions.nMaxOutbound = std::min(MAX_OUTBOUND_CONNECTIONS, connOptions.nMaxConnections);
    connOptions.nMaxAddnode = MAX_ADDNODE_CONNECTIONS;
    connOptions.nMaxFeeler = 1;
    connOptions.nBestHeight = chain_active_height;
    connOptions.uiInterface = &uiInterface;
    connOptions.m_msgproc = peerLogic.get();
    connOptions.nSendBufferMaxSize = 1000*gArgs.GetArg("-maxsendbuffer", DEFAULT_MAXSENDBUFFER);
    connOptions.nReceiveFloodSize = 1000*gArgs.GetArg("-maxreceivebuffer", DEFAULT_MAXRECEIVEBUFFER);
    connOptions.m_added_nodes = gArgs.GetArgs("-addnode");
 
    connOptions.nMaxOutboundTimeframe = nMaxOutboundTimeframe;
    connOptions.nMaxOutboundLimit = nMaxOutboundLimit;
  这些参数包含了后续网络连接及通信过程中的很多参数,比如一个节点允许的最大连接数,能够连接的外部节点的最大数目,-seednode选项指定的种子节点等等。如果运行bitcoind时通过-seednode指定了种子节点,这些种子节点也会被保存进来:

    connOptions.vSeedNodes = gArgs.GetArgs("-seednode");
  接下来,启动节点、发现节点的过程就正式拉开序幕了:

    if (!connman.Start(scheduler, connOptions)) {
        return false;
    }
     调用CConnman::Start开始,一个崭新的节点即将诞生了。

  首先需要用前面生成的CConnman::Options对CConnman进行初始化,很简单:

    Init(connOptions);
 其实就是将Options里面的值复制一份给CConnman相应的字段而已。

 接下来就要加载已经保存的节点的地址了。之前在说明节点发现的原理时提到过,这里在温习一下:

    在启动完成后,节点会记住它最近成功连接的对等节点;因此,当重新启动后它可以迅速与先前的对等节点网络重新建立连接。如果先前的网络的对等节点对连接请求无应答,该节点可以使用种子节点进行重启动。

    加载先前连接过的对等节点的地址的代码:

    // Load addresses from peers.dat
    int64_t nStart = GetTimeMillis();
    {
        CAddrDB adb;
        if (adb.Read(addrman))
            LogPrintf("Loaded %i addresses from peers.dat  %dms\n", addrman.size(), GetTimeMillis() - nStart);
        else {
            addrman.Clear(); // Addrman can be in an inconsistent state after failure, reset it
            LogPrintf("Invalid or missing peers.dat; recreating\n");
            DumpAddresses();
        }
    }
    if (clientInterface)
        clientInterface->InitMessage(_("Loading banlist..."));
    // Load addresses from banlist.dat
    nStart = GetTimeMillis();
    CBanDB bandb;
    banmap_t banmap;
    if (bandb.Read(banmap)) {
        SetBanned(banmap); // thread save setter
        SetBannedSetDirty(false); // no need to write down, just read data
        SweepBanned(); // sweep out unused entries
 
        LogPrint(BCLog::NET, "Loaded %d banned node ips/subnets from banlist.dat  %dms\n",
            banmap.size(), GetTimeMillis() - nStart);
    } else {
        LogPrintf("Invalid or missing banlist.dat; recreating\n");
        SetBannedSetDirty(true); // force write
        DumpBanlist();
    }
    其中CAddrMan暂时不必细究,把它理解成一个小型DB即可。

    最后CConnman将调兵遣将,把任务交给几个线程去做:

    (1) net线程

    // Send and receive from sockets, accept connections
    threadSocketHandler = std::thread(&TraceThread >, "net", std::function(std::bind(&CConnman::ThreadSocketHandler, this)));
    从注释上就知道,这个"net"线程的任务就是从套接字发送和接收数据,同时还要监听其他节点的连接请求。

    (2) dnsseed线程

    if (!gArgs.GetBoolArg("-dnsseed", true))
        LogPrintf("DNS seeding disabled\n");
    else
        threadDNSAddressSeed = std::thread(&TraceThread >, "dnsseed", std::function(std::bind(&CConnman::ThreadDNSAddressSeed, this)));
    这个线程名字就叫做"dnsseed",它的作用是通过dns查询解析出种子节点的地址,之后新启动的节点将要向这些种子节点发起连接。

    (3)opencon线程

    if (connOptions.m_use_addrman_outgoing || !connOptions.m_specified_outgoing.empty())
        threadOpenConnections = std::thread(&TraceThread >, "opencon", std::function(std::bind(&CConnman::ThreadOpenConnections, this, connOptions.m_specified_outgoing)));
    这个线程将负责向已发现的节点发起连接。

    (4)msghand线程

    // Process messages
    threadMessageHandler = std::thread(&TraceThread >, "msghand", std::function(std::bind(&CConnman::ThreadMessageHandler, this)));
    此线程将负责比特币P2P协议的消息处理。

    接下来我们各个击破,对这四个线程进行逐一分析。

3.3.1 解析种子节点
    首先来看看种子节点的解析,之前提到过,利用种子发现节点有两种方式,一种是开启-dnsseed选项(默认开启)连接内置的一些由专人维护的比较稳定的DNS种子,还由一种是通过-seednode选项指定种子节点。这里dnsseed线程的作用是在开启-dnsseed选项时,解析比特币P2P网络内置的DNS种子:

void CConnman::ThreadDNSAddressSeed()
{
    // goal: only query DNS seeds if address need is acute
    // Avoiding DNS seeds when we don't need them improves user privacy by
    //  creating fewer identifying DNS requests, reduces trust by giving seeds
    //  less influence on the network topology, and reduces traffic to the seeds.
    if ((addrman.size() > 0) &&
        (!gArgs.GetBoolArg("-forcednsseed", DEFAULT_FORCEDNSSEED))) {
        if (!interruptNet.sleep_for(std::chrono::seconds(11)))
            return;
 
        LOCK(cs_vNodes);
        int nRelevant = 0;
        for (auto pnode : vNodes) {
            nRelevant += pnode->fSuccessfullyConnected && !pnode->fFeeler && !pnode->fOneShot && !pnode->m_manual_connection && !pnode->fInbound;
        }
        if (nRelevant >= 2) {
            LogPrintf("P2P peers available. Skipped DNS seeding.\n");
            return;
        }
    }
 
    const std::vector &vSeeds = Params().DNSSeeds();
    int found = 0;
 
    LogPrintf("Loading addresses from DNS seeds (could take a while)\n");
 
    for (const std::string &seed : vSeeds) {
        if (interruptNet) {
            return;
        }
        if (HaveNameProxy()) {
            AddOneShot(seed);
        } else {
            std::vector vIPs;
            std::vector vAdd;
            ServiceFlags requiredServiceBits = GetDesirableServiceFlags(NODE_NONE);
            std::string host = strprintf("x%x.%s", requiredServiceBits, seed);
            CNetAddr resolveSource;
            if (!resolveSource.SetInternal(host)) {
                continue;
            }
            unsigned int nMaxIPs = 256; // Limits number of IPs learned from a DNS seed
            if (LookupHost(host.c_str(), vIPs, nMaxIPs, true))
            {
                for (const CNetAddr& ip : vIPs)
                {
                    int nOneDay = 24*3600;
                    CAddress addr = CAddress(CService(ip, Params().GetDefaultPort()), requiredServiceBits);
                    addr.nTime = GetTime() - 3*nOneDay - GetRand(4*nOneDay); // use a random age between 3 and 7 days old
                    vAdd.push_back(addr);
                    found++;
                }
                addrman.Add(vAdd, resolveSource);
            } else {
                // We now avoid directly using results from DNS Seeds which do not support service bit filtering,
                // instead using them as a oneshot to get nodes with our desired service bits.
                AddOneShot(seed);
            }
        }
    }
 
    LogPrintf("%d addresses found from DNS seeds\n", found);
}
    这个线程的工作其实比较简单,代码也比较短。简单来分析一下:

    (1) 首先通过Params().DNSSeeds()拿到内置的DNS种子节点,这个在前文提到过的CMainParams的构造函数中已经说明,比特币系统已经内置了一些DNS种子在里面。

    (2) 对于每个种子,通过LookupHost调用,进行DNS查询,这个函数最终调用的是操作系统api:getaddrinfo,解析到的ip地址将存入CAddrMan中以备后用。

3.3.2 节点连接的建立
    上一节已经解析出了比特币系统内置的DNS种子节点,接下来就要连接这些节点。连接工作由上一节提到的opencon线程来处理。这个线程代码稍长,挑主要的来分析。

    (1) 连接由-seednode指定的种子节点

    如果用户通过-seednode指定了种子节点,那么将尝试连接这些种子节点(这是节点发现的第二种方式)

    while (!interruptNet)
    {
        ProcessOneShot();
    ProcessOneshot函数的实现如下:

void CConnman::ProcessOneShot()
{
    std::string strDest;
    {
        LOCK(cs_vOneShots);
        if (vOneShots.empty())
            return;
        strDest = vOneShots.front();
        vOneShots.pop_front();
    }
    CAddress addr;
    CSemaphoreGrant grant(*semOutbound, true);
    if (grant) {
        OpenNetworkConnection(addr, false, &grant, strDest.c_str(), true);
    }
}
    最终调用了OpenNetworkConnection来连接这些种子节点。

    (2) 如果CAddrMan中有记录的地址信息(上一次连接过的peer的地址或者是解析出来的内置dns种子的地址),同样调用OpenNetworkConnection来发起连接。

    继续抽丝剥茧,揭开OpenNetworkConnection这个函数的神秘面纱,看看他是如何发起连接的。

void CConnman::OpenNetworkConnection(const CAddress& addrConnect, bool fCountFailure, CSemaphoreGrant *grantOutbound, const char *pszDest, bool fOneShot, bool fFeeler, bool manual_connection)
{
    //
    // Initiate outbound network connection
    //
    if (interruptNet) {
        return;
    }
    if (!fNetworkActive) {
        return;
    }
    if (!pszDest) {
        if (IsLocal(addrConnect) ||
            FindNode(static_cast(addrConnect)) || IsBanned(addrConnect) ||
            FindNode(addrConnect.ToStringIPPort()))
            return;
    } else if (FindNode(std::string(pszDest)))
        return;
 
    CNode* pnode = ConnectNode(addrConnect, pszDest, fCountFailure, manual_connection);
 
    if (!pnode)
        return;
    if (grantOutbound)
        grantOutbound->MoveTo(pnode->grantOutbound);
    if (fOneShot)
        pnode->fOneShot = true;
    if (fFeeler)
        pnode->fFeeler = true;
    if (manual_connection)
        pnode->m_manual_connection = true;
 
    m_msgproc->InitializeNode(pnode);
    {
        LOCK(cs_vNodes);
        vNodes.push_back(pnode);
    }
}
    代码很短,除了一些错误检查外,主要是两步:ConnectNode创建节点,InitializeNode初始化节点。其中连接已发现节点在ConnectNode中完成,而前文提到的握手协议(发送version握手消息)则在InitializeNode中发起。节点之间的握手后文单独分析。先看节点的连接。

    ConnectNode函数先做一些检查,以确保给定的节点地址还没有连接。之后将创建套接字,并建立网络连接:

if (addrConnect.IsValid()) {
        bool proxyConnectionFailed = false;
 
        if (GetProxy(addrConnect.GetNetwork(), proxy)) {
            hSocket = CreateSocket(proxy.proxy);
            if (hSocket == INVALID_SOCKET) {
                return nullptr;
            }
            connected = ConnectThroughProxy(proxy, addrConnect.ToStringIP(), addrConnect.GetPort(), hSocket, nConnectTimeout, &proxyConnectionFailed);
        } else {
            // no proxy needed (none set for target network)
            hSocket = CreateSocket(addrConnect);
            if (hSocket == INVALID_SOCKET) {
                return nullptr;
            }
            connected = ConnectSocketDirectly(addrConnect, hSocket, nConnectTimeout, manual_connection);
        }
    这里根据是否有设置代理分别进行处理,对于不配置代理的情况,将直接通过ConnectSocketDirectly来连接,其内部是调用socket api的connect函数。如果一切正常,那么此时到所有发现的节点的网络连接就已经建立起来了,两个节点之间就可以互通数据。ConnectNode函数的最后,会创建一个CNode对象,将连接好的套接字及其他必要的信息封装起来:

    NodeId id = GetNewNodeId();
    uint64_t nonce = GetDeterministicRandomizer(RANDOMIZER_ID_LOCALHOSTNONCE).Write(id).Finalize();
    CAddress addr_bind = GetBindAddress(hSocket);
    CNode* pnode = new CNode(id, nLocalServices, GetBestHeight(), hSocket, addrConnect, CalculateKeyedNetGroup(addrConnect), nonce, addr_bind, pszDest ? pszDest : "", false);
    pnode->AddRef();
    可以看到,CNode封装了许多的东西,包括已连接的套接字,本节点支持的服务,本节点当前的区块高度等等。生成的节点将会加入到集合中。

    连接好以后,两个节点就可以开始握手交互了。

3.3.3 通过套接字收发数据
    上一节描述了节点是如何连接到发现相邻节点的。这一节来看看是如何通过套接字收发数据的。收发数据是在net线程中完成的。线程函数体为CConnman::ThreadSocketHandler。相信有过网络编程基础的同学对这一部分会倍感亲切。我们截取其主要代码一窥究竟。值得注意的是消息的收发是有net线程和msghand线程协同处理的:msghand线程在条件变量上阻塞等待节点的新消息的到来,net线程从套接字读取数据,将数据拼接成消息放到节点的消息缓冲区中,并通知msghand线程有新消息可以处理了。

    ThreadSocketHandler将遍历所有节点,将其套接字加入到接收描述符集合、发送描述符集合中,然后通过select函数等待相应的描述符中的读写事件的到来:

            LOCK(cs_vNodes);
            for (CNode* pnode : vNodes)
            {
                // Implement the following logic:
                // * If there is data to send, select() for sending data. As this only
                //   happens when optimistic write failed, we choose to first drain the
                //   write buffer in this case before receiving more. This avoids
                //   needlessly queueing received data, if the remote peer is not themselves
                //   receiving data. This means properly utilizing TCP flow control signalling.
                // * Otherwise, if there is space left in the receive buffer, select() for
                //   receiving data.
                // * Hand off all complete messages to the processor, to be handled without
                //   blocking here.
 
                bool select_recv = !pnode->fPauseRecv;
                bool select_send;
                {
                    LOCK(pnode->cs_vSend);
                    select_send = !pnode->vSendMsg.empty();
                }
 
                LOCK(pnode->cs_hSocket);
                if (pnode->hSocket == INVALID_SOCKET)
                    continue;
 
                FD_SET(pnode->hSocket, &fdsetError);
                hSocketMax = std::max(hSocketMax, pnode->hSocket);
                have_fds = true;
 
                if (select_send) {
                    FD_SET(pnode->hSocket, &fdsetSend);
                    continue;
                }
                if (select_recv) {
                    FD_SET(pnode->hSocket, &fdsetRecv);
                }
            }
        }
 
        int nSelect = select(have_fds ? hSocketMax + 1 : 0,
                             &fdsetRecv, &fdsetSend, &fdsetError, &timeout);
    (1) 接收数据并处理

    当某个节点的套接字可读时,将从套接字读取数据并把数据添加到节点的接收缓冲区(pNode->ReceiveMsgBytes):

        for (CNode* pnode : vNodesCopy)
        {
            if (interruptNet)
                return;
 
            //
            // Receive
            //
            bool recvSet = false;
            bool sendSet = false;
            bool errorSet = false;
            {
                LOCK(pnode->cs_hSocket);
                if (pnode->hSocket == INVALID_SOCKET)
                    continue;
                recvSet = FD_ISSET(pnode->hSocket, &fdsetRecv);
                sendSet = FD_ISSET(pnode->hSocket, &fdsetSend);
                errorSet = FD_ISSET(pnode->hSocket, &fdsetError);
            }
            if (recvSet || errorSet)
            {
                // typical socket buffer is 8K-64K
                char pchBuf[0x10000];
                int nBytes = 0;
                {
                    LOCK(pnode->cs_hSocket);
                    if (pnode->hSocket == INVALID_SOCKET)
                        continue;
                    nBytes = recv(pnode->hSocket, pchBuf, sizeof(pchBuf), MSG_DONTWAIT);
                }
                if (nBytes > 0)
                {
                    bool notify = false;
                    if (!pnode->ReceiveMsgBytes(pchBuf, nBytes, notify))
                        pnode->CloseSocketDisconnect();
                    RecordBytesRecv(nBytes);
    最后,把接收缓冲区的数据拼接到待处理消息缓冲区,然后通知消息处理线程有新的消息需要处理:

                    if (notify) {
                        size_t nSizeAdded = 0;
                        auto it(pnode->vRecvMsg.begin());
                        for (; it != pnode->vRecvMsg.end(); ++it) {
                            if (!it->complete())
                                break;
                            nSizeAdded += it->vRecv.size() + CMessageHeader::HEADER_SIZE;
                        }
                        {
                            LOCK(pnode->cs_vProcessMsg);
                            pnode->vProcessMsg.splice(pnode->vProcessMsg.end(), pnode->vRecvMsg, pnode->vRecvMsg.begin(), it);
                            pnode->nProcessQueueSize += nSizeAdded;
                            pnode->fPauseRecv = pnode->nProcessQueueSize > nReceiveFloodSize;
                        }
                        WakeMessageHandler();    //唤醒消息处理线程
    代码中WakeMessageHandler将唤醒msghand线程,告知其有新消息可以处理了。

void CConnman::WakeMessageHandler()
{
    {
        std::lock_guard lock(mutexMsgProc);
        fMsgProcWake = true;
    }
    condMsgProc.notify_one();
}
    msghand线程被唤醒后,将从节点的缓冲区中取出消息并进行处理。处理完后又在条件变量上阻塞等待下一条消息的到来。这属于典型的线程间的同步模型,相信码农们已经非常熟悉了。

  (2) 发送数据

    当某个节点的套接字可写时将数据通过套接字发送出去:

            if (sendSet)
            {
                LOCK(pnode->cs_vSend);
                size_t nBytes = SocketSendData(pnode);
                if (nBytes) {
                    RecordBytesSent(nBytes);
                }
            }
 

    下一小节在分析一下msghand线程是如何处理消息的,这也是两个peer节点交互流程的最后一块拼图。

3.3.4 消息处理线程
    消息处理线程的处理逻辑其实非常简单:从节点的缓冲区中取出一个消息,处理,然后阻塞等待下一条消息。

        for (CNode* pnode : vNodesCopy)
        {
            if (pnode->fDisconnect)
                continue;
 
            // Receive messages
            bool fMoreNodeWork = m_msgproc->ProcessMessages(pnode, flagInterruptMsgProc);
            fMoreWork |= (fMoreNodeWork && !pnode->fPauseSend);
            if (flagInterruptMsgProc)
                return;
            // Send messages
            {
                LOCK(pnode->cs_sendProcessing);
                m_msgproc->SendMessages(pnode, flagInterruptMsgProc);
            }
 
            if (flagInterruptMsgProc)
                return;
        }
 
        {
            LOCK(cs_vNodes);
            for (CNode* pnode : vNodesCopy)
                pnode->Release();
        }
 
        std::unique_lock lock(mutexMsgProc);
        if (!fMoreWork) {
            condMsgProc.wait_until(lock, std::chrono::steady_clock::now() + std::chrono::milliseconds(100), [this] { return fMsgProcWake; });
        }
        fMsgProcWake = false;
    注释中其实已经比较清楚了:PeerLogicValidation::ProcessMessgae()从节点的接收缓冲区中取消息并处理,PeerLogicValidation::SendMessage将节点发送缓冲区中的消息发送出去,最后在条件变量上阻塞等待下一条消息的到来。

    ProcessMessage函数的代码如下:

bool PeerLogicValidation::ProcessMessages(CNode* pfrom, std::atomic& interruptMsgProc)
{
    const CChainParams& chainparams = Params();
    //
    // Message format
    //  (4) message start
    //  (12) command
    //  (4) size
    //  (4) checksum
    //  (x) data
    //
    bool fMoreWork = false;
 
    if (!pfrom->vRecvGetData.empty())
        ProcessGetData(pfrom, chainparams.GetConsensus(), connman, interruptMsgProc);
 
    if (pfrom->fDisconnect)
        return false;
 
    // this maintains the order of responses
    if (!pfrom->vRecvGetData.empty()) return true;
 
    // Don't bother if send buffer is too full to respond anyway
    if (pfrom->fPauseSend)
        return false;
 
    std::list msgs;
    {
        LOCK(pfrom->cs_vProcessMsg);
        if (pfrom->vProcessMsg.empty())
            return false;
        // Just take one message
        msgs.splice(msgs.begin(), pfrom->vProcessMsg, pfrom->vProcessMsg.begin());
        pfrom->nProcessQueueSize -= msgs.front().vRecv.size() + CMessageHeader::HEADER_SIZE;
        pfrom->fPauseRecv = pfrom->nProcessQueueSize > connman->GetReceiveFloodSize();
        fMoreWork = !pfrom->vProcessMsg.empty();
    }
    CNetMessage& msg(msgs.front());
 
    msg.SetVersion(pfrom->GetRecvVersion());
    // Scan for message start
    if (memcmp(msg.hdr.pchMessageStart, chainparams.MessageStart(), CMessageHeader::MESSAGE_START_SIZE) != 0) {
        LogPrint(BCLog::NET, "PROCESSMESSAGE: INVALID MESSAGESTART %s peer=%d\n", SanitizeString(msg.hdr.GetCommand()), pfrom->GetId());
        pfrom->fDisconnect = true;
        return false;
    }
 
    // Read header
    CMessageHeader& hdr = msg.hdr;
    if (!hdr.IsValid(chainparams.MessageStart()))
    {
        LogPrint(BCLog::NET, "PROCESSMESSAGE: ERRORS IN HEADER %s peer=%d\n", SanitizeString(hdr.GetCommand()), pfrom->GetId());
        return fMoreWork;
    }
    std::string strCommand = hdr.GetCommand();
 
    // Message size
    unsigned int nMessageSize = hdr.nMessageSize;
 
    // Checksum
    CDataStream& vRecv = msg.vRecv;
    const uint256& hash = msg.GetMessageHash();
    if (memcmp(hash.begin(), hdr.pchChecksum, CMessageHeader::CHECKSUM_SIZE) != 0)
    {
        LogPrint(BCLog::NET, "%s(%s, %u bytes): CHECKSUM ERROR expected %s was %s\n", __func__,
           SanitizeString(strCommand), nMessageSize,
           HexStr(hash.begin(), hash.begin()+CMessageHeader::CHECKSUM_SIZE),
           HexStr(hdr.pchChecksum, hdr.pchChecksum+CMessageHeader::CHECKSUM_SIZE));
        return fMoreWork;
    }
 
    // Process message
    bool fRet = false;
    try
    {
        fRet = ProcessMessage(pfrom, strCommand, vRecv, msg.nTime, chainparams, connman, interruptMsgProc);
        if (interruptMsgProc)
            return false;
        if (!pfrom->vRecvGetData.empty())
            fMoreWork = true;
    }
    catch (const std::ios_base::failure& e)
    {
        connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_MALFORMED, std::string("error parsing message")));
        if (strstr(e.what(), "end of data"))
        {
            // Allow exceptions from under-length message on vRecv
            LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught, normally caused by a message being shorter than its stated length\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
        }
        else if (strstr(e.what(), "size too large"))
        {
            // Allow exceptions from over-long size
            LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
        }
        else if (strstr(e.what(), "non-canonical ReadCompactSize()"))
        {
            // Allow exceptions from non-canonical encoding
            LogPrint(BCLog::NET, "%s(%s, %u bytes): Exception '%s' caught\n", __func__, SanitizeString(strCommand), nMessageSize, e.what());
        }
        else
        {
            PrintExceptionContinue(&e, "ProcessMessages()");
        }
    }
    catch (const std::exception& e) {
        PrintExceptionContinue(&e, "ProcessMessages()");
    } catch (...) {
        PrintExceptionContinue(nullptr, "ProcessMessages()");
    }
 
    if (!fRet) {
        LogPrint(BCLog::NET, "%s(%s, %u bytes) FAILED peer=%d\n", __func__, SanitizeString(strCommand), nMessageSize, pfrom->GetId());
    }
 
    LOCK(cs_main);
    SendRejectsAndCheckIfBanned(pfrom, connman);
 
    return fMoreWork;
}
    代码稍长,但逻辑实际上比较清晰:从节点消息缓冲区(vProcessMsg)中取出消息,然后读取消息头,消息校验和,消息长度等,对消息进行检查后,调用ProcessMessage消息处理消息:

    ProcessMessage里有许多的if-else分支,针对不同的消息走不同的分支处理。这里就不在展开了,等分析具体的P2P协议消息时在回到这个函数里来看。

3.3.5 节点之间握手
    前文提到过,当两个节点之间的网络连接建立起来以后,就需要按照比特币的P2P网络协议来进行通信。首先要做的就是两个节点间的握手。两个节点之间互发version消息,并向对方回以verack进行确认。

    握手是在一个节点初始化的时候出发的,还记得前文提到的OpenNetworkConnection函数么,这个函数在生成一个新节点以后,还会对节点进行初始化:

    m_msgproc->InitializeNode(pnode);
    握手消息version就是在这里发送出去的:

void PeerLogicValidation::InitializeNode(CNode *pnode) {
    CAddress addr = pnode->addr;
    std::string addrName = pnode->GetAddrName();
    NodeId nodeid = pnode->GetId();
    {
        LOCK(cs_main);
        mapNodeState.emplace_hint(mapNodeState.end(), std::piecewise_construct, std::forward_as_tuple(nodeid), std::forward_as_tuple(addr, std::move(addrName)));
    }
    if(!pnode->fInbound)
        PushNodeVersion(pnode, connman, GetTime());
}
    PushNodeVersion将向peer发送version消息:

static void PushNodeVersion(CNode *pnode, CConnman* connman, int64_t nTime)
{
    ServiceFlags nLocalNodeServices = pnode->GetLocalServices();
    uint64_t nonce = pnode->GetLocalNonce();
    int nNodeStartingHeight = pnode->GetMyStartingHeight();
    NodeId nodeid = pnode->GetId();
    CAddress addr = pnode->addr;
 
    CAddress addrYou = (addr.IsRoutable() && !IsProxy(addr) ? addr : CAddress(CService(), addr.nServices));
    CAddress addrMe = CAddress(CService(), nLocalNodeServices);
 
    connman->PushMessage(pnode, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::VERSION, PROTOCOL_VERSION, (uint64_t)nLocalNodeServices, nTime, addrYou, addrMe,
            nonce, strSubVersion, nNodeStartingHeight, ::fRelayTxes));
 
    if (fLogIPs) {
        LogPrint(BCLog::NET, "send version message: version %d, blocks=%d, us=%s, them=%s, peer=%d\n", PROTOCOL_VERSION, nNodeStartingHeight, addrMe.ToString(), addrYou.ToString(), nodeid);
    } else {
        LogPrint(BCLog::NET, "send version message: version %d, blocks=%d, us=%s, peer=%d\n", PROTOCOL_VERSION, nNodeStartingHeight, addrMe.ToString(), nodeid);
    }
}
    结合前文描述的version消息,上面这段代码就很容易理解了。version消息将本节点的nLocalService,addrMe,addrYou,当前节点的区块链高度等信息发送给peer。

    之后就是等待peer回以verack的确认消息的处理了。根据前文的分析,当节点收到peer的消息后,select函数将返回,套接字在可读描述符集合中将被置位,然后从套接字里读取数据,数据最终在PeerLogicValidation::ProcessMessage函数被消费掉,来看看一个节点收到version消息时是如何处理的:

    else if (strCommand == NetMsgType::VERSION)
    {
        // Each connection can only send one version message
        if (pfrom->nVersion != 0)
        {
            connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_DUPLICATE, std::string("Duplicate version message")));
            LOCK(cs_main);
            Misbehaving(pfrom->GetId(), 1);
            return false;
        }
 
        int64_t nTime;
        CAddress addrMe;
        CAddress addrFrom;
        uint64_t nNonce = 1;
        uint64_t nServiceInt;
        ServiceFlags nServices;
        int nVersion;
        int nSendVersion;
        std::string strSubVer;
        std::string cleanSubVer;
        int nStartingHeight = -1;
        bool fRelay = true;
 
        vRecv >> nVersion >> nServiceInt >> nTime >> addrMe;
        nSendVersion = std::min(nVersion, PROTOCOL_VERSION);
        nServices = ServiceFlags(nServiceInt);
        if (!pfrom->fInbound)
        {
            connman->SetServices(pfrom->addr, nServices);
        }
        if (!pfrom->fInbound && !pfrom->fFeeler && !pfrom->m_manual_connection && !HasAllDesirableServiceFlags(nServices))
        {
            LogPrint(BCLog::NET, "peer=%d does not offer the expected services (%08x offered, %08x expected); disconnecting\n", pfrom->GetId(), nServices, GetDesirableServiceFlags(nServices));
            connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_NONSTANDARD,
                               strprintf("Expected to offer services %08x", GetDesirableServiceFlags(nServices))));
            pfrom->fDisconnect = true;
            return false;
        }
 
        if (nServices & ((1 << 7) | (1 << 5))) {
            if (GetTime() < 1533096000) {
                // Immediately disconnect peers that use service bits 6 or 8 until August 1st, 2018
                // These bits have been used as a flag to indicate that a node is running incompatible
                // consensus rules instead of changing the network magic, so we're stuck disconnecting
                // based on these service bits, at least for a while.
                pfrom->fDisconnect = true;
                return false;
            }
        }
 
        if (nVersion < MIN_PEER_PROTO_VERSION)
        {
            // disconnect from peers older than this proto version
            LogPrint(BCLog::NET, "peer=%d using obsolete version %i; disconnecting\n", pfrom->GetId(), nVersion);
            connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::REJECT, strCommand, REJECT_OBSOLETE,
                               strprintf("Version must be %d or greater", MIN_PEER_PROTO_VERSION)));
            pfrom->fDisconnect = true;
            return false;
        }
 
        if (nVersion == 10300)
            nVersion = 300;
        if (!vRecv.empty())
            vRecv >> addrFrom >> nNonce;
        if (!vRecv.empty()) {
            vRecv >> LIMITED_STRING(strSubVer, MAX_SUBVERSION_LENGTH);
            cleanSubVer = SanitizeString(strSubVer);
        }
        if (!vRecv.empty()) {
            vRecv >> nStartingHeight;
        }
        if (!vRecv.empty())
            vRecv >> fRelay;
        // Disconnect if we connected to ourself
        if (pfrom->fInbound && !connman->CheckIncomingNonce(nNonce))
        {
            LogPrintf("connected to self at %s, disconnecting\n", pfrom->addr.ToString());
            pfrom->fDisconnect = true;
            return true;
        }
 
        if (pfrom->fInbound && addrMe.IsRoutable())
        {
            SeenLocal(addrMe);
        }
 
        // Be shy and don't send version until we hear
        if (pfrom->fInbound)
            PushNodeVersion(pfrom, connman, GetAdjustedTime());
 
        connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::VERACK));
connman->PushMessage(pfrom, CNetMsgMaker(INIT_PROTO_VERSION).Make(NetMsgType::VERACK));
    主要是对接收到的数据进行合法性检查,没问题了发送verack进行确认。对于verack的处理此处就不在展开,可以自行在ProcessMessage函数中去查看。

3.3.6 时序图
    洋洋洒洒一大堆,很多读者可能还是有点云里雾里的感觉,这里再用时序图来对上面的过程做一个补充。

    (1) 比特币P2P网络的初始化

    

    (2) 连接到peer

    

4 小结
    本文分析了比特币P2P网络的节点发现原理、节点之间连接的协议以及网络连接、节点间数据通信的源码实现。区块链的本质是建立在P2P网络上的一个分布式数据库,网络上的节点通过Pow工作量证明或Pos权益证明等算法达成共识。所以P2P网络以及其共识算法可以看做是区块链的基石,所以了解比特币区块链的P2P网络节点发现以及互联互通的原理和实现对学习区块链来说非常重要,同时对于像作者这样的新手来说,也是进入区块链学习的一个不错的切入点。

    本文只是分析了节点是如何连接到发现的相邻节点上(对外),作为P2P网络,自己的节点当然也可以作为网络中的一个peer为其他节点服务。下一篇文章我们将分析一下比特币区块链是如何通过minipunp实现端口映射,让网络中的其他节点连接到自己。

    参考文章《精通比特币》

 



这篇关于比特币源码分析--P2P网络初始化的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程