65.9K
CodeProject 正在变化。 阅读更多。
Home

是这个计算机的时钟慢了,还是我的问题?

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (7投票s)

2009年10月15日

CPOL

8分钟阅读

viewsIcon

28245

downloadIcon

470

如何使用 NTP、DAYTIME 和 TIME 协议从各种时间服务器请求和解析数据。

引言

时间服务器、原子钟、时间协议以及其他类似事物已经存在多年。将它们以某种方式联系起来,让您可以在电脑前获取“正确”的时间,这无疑是一项很酷的创新。随着技术的发展,一些价格低于在高档餐厅吃一顿饭的原子表已经问世好几年了。我上一块手表就是这样一块。我想,用不了多久,您的录像机也将能够获取正确的时间,从而使闪烁的 12:00 成为过去!

如今主要有三种“时间”协议在使用:网络时间协议 (RFC 1305)、日期时间协议 (RFC 867) 和时间协议 (RFC 868)。前者使用最广泛,而后者两种正在被淘汰。本文将展示处理每种协议的代码示例。

这篇文章中实际上并没有什么新鲜的内容,都是其他地方讨论过的。我主要只是想把这三种方法集中在一个地方,并展示“解析”从时间服务器返回的数据时基本的代码差异。尽管我提供了一个非常基础(读:简陋)的 GUI 只是为了展示,但这里的目的是让您能够利用这些信息创造出更精彩的东西!

三个示例都是属性表上的一个页面。对于每个页面/选项卡,都会创建一个 15 秒的计时器来处理轮询间隔。虽然只进行一次轮询就足够了。在每个计时器函数中,会创建一个辅助线程来查询时间服务器。这样做的目的是为了防止 UI 在等待与时间服务器的通信完成时被冻结。

套接字 (Sockets)

当我刚开始这项练习时,我只使用了 Windows 套接字 API。起初我不想让 MFC 隐藏其中的细节。最简单的用法是创建套接字对象并将其绑定到提供程序,连接到某个地址的某个端口上的套接字,然后从已连接的套接字接收数据。代码如下:

SOCKET rSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (INVALID_SOCKET != rSocket)
{
    SOCKADDR_IN rSocketAddr     = { 0 };
    rSocketAddr.sin_family      = AF_INET;
    rSocketAddr.sin_addr.s_addr = inet_addr(<host>);
    rSocketAddr.sin_port        = htons(<port>);

    int nResult = connect(rSocket, (LPSOCKADDR) &rSocketAddr, sizeof(rSocketAddr));
    if (ERROR_SUCCESS == nResult)
        int nBytes = recv(rSocket, <buffer>, <size_of_buffer>, 0);
}

如果这些步骤中的任何一个失败,我们可以使用 WSAGetLastError() 来帮助找出原因。一个常见的问题是忘记调用 WSAStartup()。在正确设置好这些之后,我用 MFC 的等效类 CSocket 替换了 Windows 套接字 API。现在我们有了:

CSocket rSocket;
if (rSocket.Create())
{
    if (rSocket.Connect(<host>, <port>))
        int nBytes = rSocket.Receive(<buffer>, <size_of_buffer>);
}

正如您所见,*address*、*family* 和 *protocol* 都有默认值。它们分别是 AF_INETSOCK_STREAMIPPROTO_TCP。在代码量或复杂性方面并没有节省多少。

还有一点值得一提的是,recv() 可能不会一次性接收所有请求的数据。一个更健壮的实现需要使用循环结构来确保在继续之前已接收到所有请求的字节。

日期时间协议

要使用此协议,它自 1983 年左右就已存在,我们连接到主机 129.6.15.28time-a.nist.gov)的端口 **13**。也存在其他主机。连接后,时间服务器会发送回一个 49-51 个字符的字符串,格式如下:

JJJJJ YR-MO-DA HH:MM:SS TT L H msADV UTC(NIST) OTM

您可以在 http://tf.nist.gov/service/its.htm 阅读有关不同字段的信息。*对于字符串开头和结尾的 0xA 字符,我没有解释。* 一个用户定义的 CMessage 会被发送到主线程,将该字符组成的字符串作为 LPARAM 参数传递。使用 PostMessage() 很重要,因为我们不希望辅助线程与主线程拥有的任何 UI 控件交互。那样很容易导致死锁。这可以通过以下方式完成:

char *pszBuffer = new char[64];
int nBytes = rSocket.Receive(pszBuffer, 64);
if (nBytes > 0)
{
    pszBuffer[nBytes] = '\0';
    pDlg->PostMessage(UWM_INSERT_ITEM, 0, (LPARAM) pszBuffer);
}

在处理用户定义消息的函数中,指针被强制转换为 char 类型。由于日期和时间是标准格式,我们可以使用 COleDateTime::ParseDateTime() 来处理实际的解析工作。由于只发送了年份的最后两位数字,因此在年份前面加上“20”。我们还需要调整本地时区。将创建第二个 COleDateTime 对象,表示当前日期和时间。要查看我们的计算机时钟与原子钟的时间相比如何,只需计算两者之间的差值即可。我们将使用 COleDateTimeSpan 对象来实现此目的。此时,我们可以根据需要更改计算机时钟,或者只在屏幕上显示差异。另外,由于我们不再需要之前分配的 char 指针,因此可以将其删除。所有这些的代码如下:

char            *pszBuffer = (char *) lpParam;
CString         pstr = pszBuffer;
COleDateTime    timeRemote;
SYSTEMTIME      st1, 
                st2;

// the date/time is in YY-MM-DD HH:MM:SS format
timeRemote.ParseDateTime(_T("20") + pstr.Mid(7, 17));

timeRemote.GetAsSystemTime(st1);
SystemTimeToTzSpecificLocalTime(&m_tzi, &st1, &st2);

timeRemote = st2;
COleDateTime timeLocal = COleDateTime::GetCurrentTime();

int nItem = m_lcTimes.InsertItem(0, timeRemote.Format(_T("%c")));
m_lcTimes.SetItemText(nItem, 1, timeLocal.Format(_T("%c")));
    
COleDateTimeSpan timeSpan = timeRemote - timeLocal;
m_lcTimes.SetItemText(nItem, 2, timeSpan.Format(_T("%H:%M:%S")));

delete pszBuffer;

时间协议

要使用此协议,我们连接到主机 129.6.15.29time-b.nist.gov)的端口 **37**。连接后,时间服务器会发送回一个 32 位的时间戳,其中包含自 1900 年 1 月 1 日以来 UTC 秒数的时间。由于该值基于 1900 年(纪元),我们必须从中减去 70 年的秒数,以便接受 time_t 参数的 COleDateTime 构造函数能够正常工作。接下来,创建一个 time_t 指针,并将用户定义的 CMessage 发送给主线程,将指针作为 LPARAM 参数传递。这可以通过以下方式完成:

UINT uBuffer;
if (rSocket.Receive(&uBuffer, sizeof(UINT)) > 0)
{
    time_t *t = new time_t(ntohl(uBuffer) - 0x83aa7e80);
    pDlg->PostMessage(UWM_INSERT_ITEM, 0, (LPARAM) t);
}

在消息中,我们不是发送 UINT 变量,而是创建一个 time_t 变量。否则,从 32 位到 64 位的转换会带来一些麻烦。在处理用户定义消息的函数中,我们将 LPARAM 值强制转换回 time_t 指针,并将其值传递给 COleDateTime 构造函数。所有这些的代码如下:

time_t          *t = (time_t *) lpParam;
COleDateTime    timeLocal = COleDateTime::GetCurrentTime(),
                timeRemote(*t);
 
int nItem = m_lcTimes.InsertItem(0, timeRemote.Format(_T("%c")));
m_lcTimes.SetItemText(nItem, 1, timeLocal.Format(_T("%c")));

COleDateTimeSpan timeSpan = timeRemote.m_dt - timeLocal.m_dt;
m_lcTimes.SetItemText(nItem, 2, timeSpan.Format(_T("%H:%M:%S")));

delete t;

网络时间协议

这是最常用的 Internet 时间协议,也是性能最好的协议。要使用它,我们连接到某个时间服务器的端口 **123**。大约有 1,846 个这样的服务器可用。您可以访问 NTP Pool Project 获取更多信息。我选择连接到 *us.pool.ntp.org* 而不是一个特定的服务器,这样下一个可用的时间服务器(稍后会详细介绍)就会响应我的查询。无论您选择哪个,请记住,许多时间服务器都是由志愿者提供的,而且几乎所有时间服务器实际上都是文件服务器、邮件服务器或 Web 服务器,它们恰好也运行 NTP。

连接后,我们需要向时间服务器发送少量信息(NTP 控制消息),以便它能够响应。消息的前三位是 NTP 版本号。尽管 4 是最新的,但我们将使用 3 来避免额外的数据。消息的接下来三位是 NTP 控制消息的模式,目前为 6。消息的其余位为 0。代码如下:

NTP_Packet NTP_Send = { 0 }; // NTP_Packet is described below
NTP_Send.nControlWord = 0x1B;
rSocket.Send(&NTP_Send, sizeof(NTP_Send));

接收后,时间服务器将发送一个 60 字节的时间戳,其中包含自 1900 年 1 月 1 日以来 UTC 秒数的时间。用于接收响应前 48 字节的结构如下:

struct NTP_Packet
{
    union
    {
        struct _ControlWord
        {
            unsigned int uLI:2;       // 00 = no leap, clock ok   
            unsigned int uVersion:3;  // version 3 or version 4
            unsigned int uMode:3;     // 3 for client, 4 for server, etc.
            unsigned int uStratum:8;  // 0 is unspecified, 1 for primary reference system, 
                                      // 2 for next level, etc.
            int nPoll:8;              // seconds as the nearest power of 2
            int nPrecision:8;         // seconds to the nearest power of 2
        };

        int nControlWord;             // 4
    };

    int nRootDelay;                   // 4
    int nRootDispersion;              // 4
    int nReferenceIdentifier;         // 4

    __int64 n64ReferenceTimestamp;    // 8
    __int64 n64OriginateTimestamp;    // 8
    __int64 n64ReceiveTimestamp;      // 8

    int nTransmitTimestampSeconds;    // 4
    int nTransmitTimestampFractions;  // 4

    // 12 more bytes here
};

在此练习中,我们感兴趣的字段是 nTransmitTimestampSeconds,由于该值基于 1900 年,我们必须再次从中减去 70 年的秒数,以便 COleDateTime 构造函数能够正常工作。接下来,创建一个 time_t 指针,并将用户定义的 CMessage 发送给主线程,将指针作为 LPARAM 参数传递。代码如下:

NTP_Packet NTP_Recv;
if (rSocket.Receive(&NTP_Recv, sizeof(NTP_Recv)) > 0)
{
    time_t *t = new time_t(ntohl(NTP_Recv.nTransmitTimestampSeconds) - 0x83aa7e80);
    pDlg->PostMessage(UWM_INSERT_ITEM, 0, (LPARAM) t);
}

在处理用户定义消息的函数中,我们将 LPARAM 值强制转换回 time_t 指针,并将其值传递给 COleDateTime 构造函数。最后一段代码如下:

time_t          *t = (time_t *) lpParam;
COleDateTime    timeLocal = COleDateTime::GetCurrentTime(),
                timeRemote(*t);

int nItem = m_lcTimes.InsertItem(0, timeRemote.Format(_T("%c")));
m_lcTimes.SetItemText(nItem, 1, timeLocal.Format(_T("%c")));

COleDateTimeSpan timeSpan = timeRemote - timeLocal;
m_lcTimes.SetItemText(nItem, 2, timeSpan.Format(_T("%H:%M:%S")));

delete t;

哪个时间服务器响应了?

之前我提到 *us.pool.ntp.org* 只是一个 NTP 时间服务器池。虽然不是必需的,但您可以使用 getnameinfo() 来查找实际响应请求的主机。我用于此的代码如下:

char szHost[NI_MAXHOST];
getnameinfo((sockaddr *) &server_addr, 
            sizeof(server_addr), szHost, sizeof(szHost), NULL, 0, 0);

TRACE(_T("Host: %S\n"), szHost);
TRACE(_T("IP address: %S\n"), inet_ntoa(server_addr.sin_addr));

MFC 中与之等效的处理方式稍微复杂一些:

CString strPeer;
UINT uPeer;
rSocket.GetPeerName(strPeer, uPeer);

ULONG ulAddr = inet_addr(strPeer);
HOSTENT *hostent = gethostbyaddr((const char *) &ulAddr, sizeof(ULONG), AF_INET);

TRACE(_T("Host: %s\n"), hostent->h_name);
TRACE(_T("IP address: %s\n"), strPeer);

与 VS6 的区别

这项练习最初是在 VS6(我日常使用的编译器)上创建的。当一切都工作正常后,我将其移到了装有 VS2005 的机器上。我曾怀疑需要纠正一些与 Unicode 相关的问题。令我高兴的是,它*编译*得很顺利。然而,*运行*成功却没能实现。在计时器事件(15 秒)触发后,我遇到了一个 MFC 文件中的断言,该文件与 CAsyncSocket 方法有关。什么?大约 45 分钟后,我将其缩小到 CTimeDlg::OnInsertItem() 方法。看起来 time_t 现在是一个 64 位类型(除非定义了 _USE_32BIT_TIME_T),但我只传递了一个 32 位值给它。在强制类型转换后,最高有效 32 位(高 DWORD)的值类似于 0xfdfdfdfd。使用以下代码可以轻松重现此问题:

unsigned int *u32 = new unsigned int;
*u32 = 0x12345678;
unsigned __int64 *u64 = (unsigned __int64 *) u32;

当该值随后被传递给 COleDateTime 构造函数时,它会创建一个无效的对象,从而导致在使用该对象时出现上述断言。至少,在使用该对象之前,我应该检查 m_status 成员。

附加功能

使用属性表时,框架会自动在表上添加 OK、Cancel、Apply 和 Help 按钮。对于此特定练习,只有 Cancel 按钮是相关的。要解决此问题,需要移除 OK、Apply 和 Help 按钮。这可以在表的 OnInitDialog() 方法中轻松完成,如下所示:

CWnd *pWnd = GetDlgItem(IDHELP);
ASSERT(NULL != pWnd);
pWnd->ShowWindow(SW_HIDE);

pWnd = GetDlgItem(ID_APPLY_NOW);
ASSERT(NULL != pWnd);
pWnd->ShowWindow(SW_HIDE);

pWnd = GetDlgItem(IDOK);
ASSERT(NULL != pWnd);
pWnd->ShowWindow(SW_HIDE);

现在,需要将 Cancel 按钮向右移动以填补其他按钮留下的空白。我们将使用以下方法将其放置在 Help 按钮所在的位置:

CRect rect;
GetDlgItem(IDHELP)->GetWindowRect(rect);
ScreenToClient(rect);
GetDlgItem(IDCANCEL)->MoveWindow(rect);

结语

这项练习工作起来很有趣,也激发了我对一项涉及天气的类似练习的兴趣。鉴于我对套接字和时间服务器的经验不足,肯定有些地方我遗漏了,或者包含了但未能解释清楚,或者完全错了。如果您有任何意见,欢迎指正。

尽情享用!

© . All rights reserved.