现代C:标准库:日期、时间与实用函数

发布时间:2026/7/17 19:39:07

现代C:标准库:日期、时间与实用函数 引言在前面的几讲中我都以较大的篇幅介绍了 C 标准库中的一些重要概念和相关接口的使用方式。除此之外标准库中还有一些功能十分明确使用方式也十分简单的常用接口这些接口也为日常的 C 应用开发提供了重要支持。因此在接下来的两讲中我将围绕这部分内容展开介绍。今天我们先来看看标准库中与日期、时间以及实用函数有关的内容。其中日期与时间的相关接口由头文件 time.h 提供而实用函数的功能则可被进一步细分为字符串与数值转换、随机数生成、动态内存管理以及进程控制等不同的几类这些功能对应的编程接口均由头文件 stdlib.h 提供。下面我们就来分别看看这两类接口的使用方式以及它们背后的一些基本原理。日期与时间首先来看由头文件 time.h 提供的日期与时间相关接口。那么在 C 语言中日期与时间的概念是怎样体现的又应该如何对它们进行操作和转换呢在构建应用程序时我们经常会用到日期与时间这两种概念。比如在记录日志时通常需要保存每个事件的确切发生日期和时间在进行优化时则需要通过测量代码的运行时间来寻找性能痛点而在生成随机数时甚至需要使用当前时间作为不同的随机种子。看到这里你可能已经发现了这一讲中我们提到的“时间”有两种不同的含义。一种是指时间上的跨度而另一种则指以小时、分钟、秒组成的确切时间点。至于具体是哪一种含义需要你结合上下文来理解。当然在可能会引起误解的地方我也会特别说明下。日历时间在 C 语言中时间可以被分为“日历时间Calendar Time”与“处理器时间Processor Time”。其中前者是指从世界协调时间UTC1970 年 1 月 1 日 00 时 00 分 00 秒到当前 UTC 时间所经历的秒数其中不包括闰秒。在 C 标准库中该值由自定义的类型关键字 time_t 表示。该类型通常对应于一个整数类型在某些老版本的标准库中可能被实现为 32 位有符号整型。同时time.h 头文件也提供了一个非常直观的名称为 time 的方法可用于获取这个值。来看下面这个例子#include stdio.h #include time.h int main(void) { time_t currTime time(NULL); if(currTime ! (time_t)(-1)) printf(The current timestamp is: %ld(s), currTime); return 0; }这里我们将 time 方法调用后的返回值保存在了变量 currTime 中。而当方法调用成功后即返回值不为 -1该值通过 printf 函数被打印了出来。不仅如此在获取到这个整型时间值后借由标准库提供的其他时间与日期处理函数我们还可以对它做进一步处理。比如将它格式化为本地时间并以特定的格式输出。继续来看下面这个例子#include stdio.h #include time.h int main(void) { time_t currTime time(NULL); if(currTime ! (time_t)(-1)) { char buff[64]; struct tm* tm localtime(currTime); if (strftime(buff, sizeof buff, %A %c, tm)) printf(The current local time is: %s, buff); // The current local time is: Saturday Sat Jan 8 16:30:49 2022. } return 0; }在这段代码中我们用与之前类似的方式获取了当前的日历时间该值被存放到变量 currTime 中。在代码的第 7 行通过使用名为 localtime 的方法我们可以将该日历时间转换成与本地时间相关的多种信息。这些信息将以不同字段的形式被存放在名为 tm 的结构对象中。接着通过调用 strftime 方法我们可以继续对这个时间对象进行格式化。该方法调用后会将生成的结果字符串存放到由变量 buff 对应的字符数组中。这里传入的第三个参数为一个包含有格式控制占位符的字符串。其中%A 用于显示完整的周工作日名称%c 用于显示标准日期和时间字符串。strftime 方法将根据占位符字符串的具体组成格式来输出相应的结果字符串。处理器时间接着我们再来看处理器时间CPU Time。顾名思义处理器时间即 CPU 资源被调度以支持程序在某段时间内正常运作所花费的时间。需要注意的是在默认情况下这个时间应该是应用运行所涉及的所有独立 CPU 所消耗时间的总和。C 标准库也为我们提供了一个直观的名称为 clock 的方法可用于返回这个值。来看下面这个例子#include time.h #include stdio.h int main(void) { clock_t startTime clock(); for(int i 0; i 10000000; i) {} clock_t endTime clock(); printf(Consumed CPU time is%fs\n, (double)(endTime - startTime) / CLOCKS_PER_SEC); return 0; }位于代码第 4 行的 clock 方法在调用后会返回类型为 clock_t 的值。该类型由标准库的具体实现定义因此其值可能为整数也可能为浮点数。不同于日历时间的是为了更精确地计算 CPU 耗时处理器时间并不直接以“秒”为单位而是以 “clock tick” 为单位。为了将这个时间换算为秒你需要将它除以标准库中提供的宏常量 CLOCKS_PER_SEC。该常量表明了在当前系统上每 1 秒钟对应的 clock tick 次数。需要注意的是这里我们提到的 clock tick与程序运行所在计算机的实际物理 CPU 频率没有直接关系。应用程序可以通过读取计算机上的硬件定时器来获得对应进程的 CPU 使用时间。对于程序运行来说一段时间内花费的处理器时间与墙上时钟时间Wall-clock Time可能并不一致。前者依赖于程序使用的线程数量、所在平台的物理 CPU 核数以及操作系统调度 CPU 的具体策略等。而后者则是现实世界的时间流逝也就是一个恒定递增的值。因此调用一次 clock 方法所返回的处理器时间一般没有太多意义。通常我们会按照上面例子中的方式来使用这个时间即在一段代码的前后分两次获取处理器时间并通过计算两者之间的差值来了解 CPU 执行这段代码所花费的时间。当然为了方便你更好地理解处理器时间与墙上时钟时间的区别你可以在具有多核 CPU 的计算机上编译和运行下面这段代码并在评论区告诉我你的运行结果。关于代码的具体实现细节你可以尝试参考注释进行理解如果有问题也可以在评论区随时跟我讨论。#include stdio.h #include time.h #include stdlib.h #include threads.h typedef struct timespec ts_t; int run(void* data) { // 模拟的耗时任务 volatile double d 0; for (int n 0; n 10000; n) for (int m 0; m 10000; m) d d * n * m; return 0; } int main(void) { // 首次记录日历时间与处理器时间 ts_t ts1; timespec_get(ts1, TIME_UTC); clock_t t1 clock(); // 创建两个线程做一些耗时任务 thrd_t thr1, thr2; thrd_create(thr1, run, NULL); thrd_create(thr2, run, NULL); thrd_join(thr1, NULL); thrd_join(thr2, NULL); // 再次记录日历时间与处理器时间 ts_t ts2; timespec_get(ts2, TIME_UTC); clock_t t2 clock(); // 分别计算和打印处理器时间与墙上时钟时间耗时 printf(CPU time used (per clock()): %.2f ms\n, 1000.0 * (t2 - t1) / CLOCKS_PER_SEC); printf(Wall time passed: %.2f ms\n, 1000.0 * ts2.tv_sec 1e-6 * ts2.tv_nsec - (1000.0 * ts1.tv_sec 1e-6 * ts1.tv_nsec)); return 0; }其他相关处理函数除了我在上面介绍过的一些常见日期与时间处理函数外C 标准库还提供了另外一些相关函数我将它们整理在了下面的表格中供你参考。当然这里并没有包含那些已经被标记为“废弃”的接口。你可以点击这个链接来查看更多信息。Y2038 问题到这里我讲完了如何在 C 代码中使用日期和时间操作的相关函数接下来我想和你讨论一个可能由 time_t 类型引发的问题。我在讲日历时间的时候提到过某些旧版本的 C 标准库在实现用于存放日历时间的 time_t 类型时可能会采用 32 位有符号整数。而在这种情况下time_t 所能够表示的时间跨度便会大大缩小并会在不久之后的 UTC 时间 2038 年 1 月 19 日 03 时 14 分 08 秒发生上溢出。当该类型变量溢出后其表示的具体日期和时间将会从 1901 年开始“重新计时”。你可以通过下图图片来自 Wikipedia来观察这个问题的发生过程。可以说这是一个全球性问题严重性甚至可以与 Y2K 等问题比肩。由于 C 语言被广泛应用在各类软硬件系统中因此从常见的交通设施、通信设备到某些早期的计算机操作系统它们都可能会在那时受到 Y2038 问题的影响。看完了与头文件 time.h 相关的内容接着我们再来看看 stdlib.h 头文件提供的众多实用函数。由于这些函数的功能十分混杂我将它们分为了几个不同的类别来分别为你介绍。首先来看数值与字符串转换的相关接口。字符串到数值的转换这类接口的使用方式都十分简单直接来看下面这段代码#include stdio.h #include stdlib.h #include errno.h int main(void) { // 一次性字符串到数值转换 const char* strA 1.0; printf(%f\n, atof(strA)); // 带溢出检查的转换函数执行后会保存不能被转换部分的地址 const char* strB 200000000000000000000000000000.0; char* end; double num strtol(strB, end, 10); if (errno ERANGE) { // 判断转换结果是否发生溢出 printf(Range error, got: ); errno 0; } printf(%f\n, num); return 0; }stdlib.h 头文件提供了众多函数可用于将一个字符串转换为特定类型的数字值。在上面代码的第 7 行我们使用名为 atof 的函数将字符串 strA 转换为一个双精度浮点数。实际上以字母 “a” 开头的这类函数只能对字符串进行一次性转换。相对地在代码的第 11 行名为 strtol 的函数将字符串 strB 转换为了对应的长整型数值。而这一类以 “str” 开头的函数会在每次执行时判断转换结果是否发生溢出同时保存不能被转换部分的地址。在这种情况下通过再次调用这类函数我们便能够对剩余部分的字符串继续进行转换直至将整个字符串处理完毕。关于这些函数的更多信息你可以点击这个链接进行查看。生成随机数作为实用函数的一部分“随机数生成”是一个不可或缺的重要功能。同样地我们也可以通过配合使用 stdlib.h 提供的 rand 与 srand 方法来生成随机数。它们的基本用法如下所示#include stdio.h #include stdlib.h #include time.h int main (void) { srand(time(NULL)); // 初始化随机数种子 while (getchar() \n) printf(%d, rand() % 10); // 生成并打印 0-9 的随机数 return 0; }可以看到我们首先在代码的第 5 行使用 srand 方法设定了程序每次运行时需要使用的随机数种子。在代码第 7 行rand 函数的调用会产生范围为 [0, RAND_MAX] 的随机数。通过对它进行求余处理可以将结果限定到一个指定的范围。对于大多数 C 标准库实现来说rand 函数在内部会采用“线性同余发生器Linear Congruential Generator”等伪随机算法来计算函数每次调用时需要产生的随机数。这也就意味着该函数产生的随机数本质上并不是随机的。如果我们没有使用 srand 函数设置新的随机数种子那么当每次程序重新运行时通过 rand 函数产生的随机数序列都将会是相同的。而这个种子便会作为 rand 函数在计算下一个随机数时所采用算法的输入参数。事实上计算机无法生成“真正的随机数”。正如 MIT 教授 Steve Ward 说的那样“传统计算机系统最不擅长的一件事就是抛硬币”。计算机软件的执行会按照既定的算法展开因此当输入和算法不变时输出结果就变得有迹可循。即使我们可以用更复杂的算法来让输出的变化模式变得难以琢磨但无论如何这都并非真正的随机。而伪随机数算法之所以可被用来生成随机数则是因为从统计学角度来讲其生成的数字符合随机数在均匀性、独立性等特征上的要求。并且伪随机数的生成不需要特殊的硬件支持。同时在大多数场景中伪随机数也可以满足基本的使用需求。动态内存管理动态内存管理本质上就是堆内存管理。我曾在 08 讲 中介绍过 VAS 中堆的概念以及如何使用 malloc 与 free 函数来在堆上分配和释放一段内存空间。但实际上除这两个函数外C 标准库还为我们提供了另外一些函数可用于在分配堆内存时进行更加精确的控制。你可以通过下面的表格来了解这几个函数的基本功能。由于它们的使用方式较为简单这里我就不详细介绍了。进程控制接下来我们来看看实用函数中与进程控制相关的内容。虽然 C 标准库为我们提供了进程控制的相关能力但这个能力实际上却十分有限。借助标准库提供的接口我们可以控制程序的退出形式正常终止、异常终止、快速终止等获取当前系统的环境变量或是与宿主机上的命令处理器进行交互。但除此之外我们无法再控制进程的其他行为比如创建进程或使用进程间通信。对于其中几个函数的使用方式你可以参考下面这个例子#include stdio.h #include stdlib.h void exitHandler() { printf(%s\n, getenv(PATH)); } int main(void) { if (!atexit(exitHandler)) { exit(EXIT_SUCCESS); } return 0; }这里在代码的第 7 行我们使用函数 atexit 为程序注册了一个回调函数。这个函数会在程序显式调用 exit 函数时或从 main 函数内正常退出时被触发。在对应的回调函数 exitHandler 中我们使用 getenv 函数获取并打印了当前宿主机上环境变量 PATH 的值。当回调函数注册成功后返回整型数值 0通过显式调用函数 exit我们正常退出了当前程序。此时回调函数被调用环境变量 PATH 的值被打印了出来。还有一些我没提到的函数它们的使用方式也很简单。同样地我将它们整理在了下面的表格中供你参考。这里需要你注意exit、quick_exit、_Exit以及 abort 这四个可用于终止程序运行的函数它们实际上对应着不同的使用场景。其中exit 函数在退出程序时会进行一系列资源清理工作比如冲刷并关闭各种 IO 流、移除由函数 tmpfile 创建的临时文件等。除此之外它还会在中途触发用户注册的回调函数以进行自定义的收尾工作。但相对地quick_exit 函数在终止程序时并不会进行上述资源清理工作。它仅会通过回调函数来执行用户自定义的收尾工作。 _Exit 函数则更加彻底它会直接终止程序的执行而不做任何处理。不同于这三类函数abort 函数在调用时会向当前程序发送信号 SIGABRT并根据情况选择终止程序或执行相应的信号处理程序。其他接口除了上面提到的这几类重要接口外stdlib.h 中还包含有一些与搜索排序、整数算数、宽字符串转换相关的接口。它们的使用方式也十分简单这里我就不一一介绍了。你可以参考下表来了解这些最常用接口的名称与功能也可以点击这个链接来了解有关它们的更多信息。总结好了讲到这里今天的内容也就基本结束了。最后我来给你总结一下。这一讲我主要介绍了 C 标准库中与时间日期处理、字符串和数值转换、随机数生成、动态内存管理、进程控制以及搜索排序等功能相关的接口。其中第一部分功能的接口由 time.h 提供其余部分由 stdlib.h 提供。C 语言中的时间可以被分为日历时间与处理器时间。通过名为 time 与 clock 的两个接口我们可以分别获得与它们对应的两个值。进一步借助 localtime 与 strftime 等接口日历时间可以被转换为本地时间并按照指定的形式进行格式化。而处理器时间由于在默认情况下以 clock tick 为单位因此需要将它除以宏常量 CLOCKS_PER_SEC从而得到以秒为单位的值。通过使用 atof 与 strtol 等接口我们可以实现字符串到数字值的转换。其中前一类以 “a” 开头的接口仅能对字符串进行一次转换而后一类以 “str” 开头的接口则可在对字符串进行数值转换的基础上同时检查转换结果是否发生溢出并保存不能被转换部分的地址。通过配合使用 rand 与 srand 接口我们可以在 C 程序中生成伪随机数。在大多数标准库中rand 函数会使用伪随机算法来实现。因此为了使每次调用 rand 函数生成的随机数序列都不尽相同在调用该接口前可以配合使用 srand 与 time 函数来为其设置随时间变化的不同随机数种子。malloc、calloc以及 free 等接口的实现方便了我们在程序中动态操作堆内存。而通过调用 exit、abort、quick_exit 等接口我们可以精确地控制程序在退出时的具体行为。除此之外abs、qsort、bsearch、mblen 等接口的提供也使得标准库在搜索排序、整数算数、宽字符串转换等方面为 C 编程提供了一定帮助。

相关新闻