深入理解Linux时间子系统(0.7)

news/2024/11/16 10:37:31/

学习方法论
写作原则

标题括号中的数字代表完成度与完善度
0.0-1.0 代表完成度,1.1-1.5 代表完善度
0.0 :还没开始写
0.1 :写了一个简介
0.3 :写了一小部分内容
0.5 :写了一半内容
0.9 :还有个别内容没写
1.0 :内容都写完了,但是不一定完善
1.1 :内容比较完善
1.3 :内容很完善
1.5 :内容非常完善,接近完美

目录

  • 一、时间概念解析
    • 1.1 时间使用的需求
    • 1.2 时间体系的要素
    • 1.3 时间的表示维度
    • 1.4 时钟与走时
    • 1.5 时间需求之间的关系
  • 二、时间子系统的硬件基础
    • 2.1 时钟硬件类型
    • 2.2 x86平台上的时钟
    • 2.3 ARM平台上的时钟
  • 三、时间的用户空间API
    • 3.1 知时API
    • 3.2 定时API
  • 四、时间的内核接口
    • 4.1 知时接口
    • 4.2 定时接口
    • 4.3 调度器tick与计时
  • 五、时间子系统的软件架构
    • 5.1 系统时钟的设计
    • 5.2 系统时钟的实现
    • 5.3 动态tick与定时器
    • 5.4 用户空间API的实现
  • 六、总结回顾



推荐阅读: 操作系统导论

一、时间概念解析

我们住在空间里,活在时间中。时间对我们来说是既熟悉又陌生。熟悉是因为我们每天都在时间的驱动下忙碌着,陌生是因为我们从来没有停下来认真思考过时间是什么。今天我们先从对时间的使用需求开始说起。

1.1 时间使用的需求

我们对使用时间有三种需求:知时、定时和计时。知时就是我们需要知道现在的时间是多少,表达方式是时分秒、年月日。定时是我们需要在某个时间点被告知,时间点可以是相对的或者绝对的,告知可以是一次性的或者是周期性的,比如每天早上7:30叫我起床,是绝对时间点周期性告知,每隔10分钟向我汇报一次情况,是相对时间点周期性告知。计时是我们需要知道某件事从开始到结束一共花了多少时间,比如大学运动会1000米赛跑,裁判在运动员起跑时按一下计时器,结束时再按一下计时器,得出某运动员跑一千米用了3分50秒。

1.2 时间体系的要素

为了达到知时的目的,我们首先需要建立时间体系的概念。时间体系由三个要素构成,1时间原点、2时间基本单位、3时间是否会暂停。我们把每天用的这个时间叫做自然时间,自然时间在计算机里面也叫做真实时间(Real Time),注意Real Time在这里是真实时间的意思,而不是实时的意思。自然时间有时候也会被叫做墙钟时间(wall clock time),或者简略为墙上时间(wall time),小时候家里墙上用挂钟来看时间的同学立马就能明白了。对自然时间建立的时间体系并不是唯一的,可以有不同的时间原点和时间基本单位。我们现在使用的公元纪年,它的时间原点是耶稣出生的那一年的一月一号零时零分零秒。其实我们也可以使用黄帝纪年,那现在就是5000多年了,也可以把建国的时间当做时间原点,那现在就是70几年。公元纪年的时间基本单位是秒,好在全球的秒都是一样的,没有出现什么中秒、美秒、欧秒的区分,不然换算来换算去就会很麻烦。自然时间不会暂停,计算机里面的有些时间体系可能会暂停,这个我们后面再讲。我们再来总结一下,现在全世界使用的自然时间体系是公元纪年,其时间原点是耶稣诞生当年的一月一号零时零分零秒,其时间基本单位是秒,时间流逝不会暂停。这就特别好,大家都是在同一个时间体系下生活,这样讨论时间就很方便,不用来回转换了。如果不同国家使用的时间体系都不相同,时间体系的原点不同,时间基本单位也不相同,那相互之间来回转换时间就会非常麻烦。

1.3 时间的表示维度

接下来我们说一下时间的表示维度,注意是时间的表示维度,不是时间的维度,时间本身的维度是一维的。如果我告诉你说现在的时间是六百三十七亿六千五百七十九万多秒,你是不是会一脸懵逼,反应不过来。虽然时间的基本单位是秒,但是我们如果直接用秒来表示时间,那将非常难以理解和记忆。为此我们建立了多层级的时间表示维度,60秒是一分钟,60分钟是一个小时,24小时是一天,365天是一年。然后我们说今天是某年某月某日,具体时间是几时几分几秒,就非常方便了,很便于我们人类使用理解。对于人类来说时间精确到秒就足够使用了,但是对于科学研究来说还需要更高的精度,于是我们把1秒的1/1000叫做毫秒,1毫秒的1/1000叫做微秒,1微秒的1/1000叫做纳秒。这样时间的表示维度就很丰富了,便于我们在不同的情况下使用。那么计算机中的时间表示维度是多少呢?人类善于理解多维度的时间表示,但是计算机却善于处理单维度的时间表示。但是计算机用单维度的时间表示却有个问题,如果用秒作为基本单位,那么精度显然达不到,如果用纳秒作为基本单位的话,数值又太大。所以计算机中的时间采用的是两层表示维度,超过1秒的时间用秒表示,不够一秒的时间用纳秒表示,每10亿纳秒向前进位一秒。这样计算机中时间处理就非常方便了。

1.4 时钟与走时

想要实现知时的目的我们就需要有工具,这个工具就叫做时钟(clock),有了时钟我们就能够快速准确地知道自然时间。下面我们来给时钟下一个定义。时钟,包括硬件的、软件的、机械的、电子的,都是用来追踪和记录自然时间流逝的工具。下面我们再来说一个动词,走时,大家一听这个词可能会不知道是啥意思。我再来说一句话,这个表走时非常精准,大家立马就明白了是啥意思。我们再给走时下个定义,走时,是时钟追踪和记录时间流逝的动作。为什么在这里要说个走时的概念呢,因为有了走时的概念,后面的很多东西都能很轻松地讲清楚。

1.5 时间需求之间的关系

我们再来看一下知时、计时、定时三者之间的关系。先说知时和计时,其实两者之间是可以相互转化的。知时可以转化为计时,我们在事情开始的时候记录一下时间,在事情结束的时候记录一下时间,两者之间的时间差值就是计时。计时也可以转化为知时,把计时的起点设置为某一个时间体系的时间原点,那么计时的结果就是知时的结果。计时是时间原点不特定的知时,知时是时间原点特定的计时。知时的结果是一个时间点,它是当前时间点到时间原点的一个时间段。计时的结果是时间段,它是相对于计时原点的时间点。明白了知时和计时之间的关系对于我们理解后面计算机的具体做法有很大的帮助。

下面我们再来看一下定时和知时、计时之间的关系。由于知时、计时可以相互转换,所以它们可以放在一起讨论同定时的关系。定时是需要知时、计时的支持的,如果没有知时、计时,那么就没法定时。绝对定时用知时作为基础时间比较方便,相对定时用计时作为基础时间比较方便。当然反过来也是可以的,因为知时计时是可以相互转化的。还有一点就是定时可以用来作为时钟实现走时的方法,这个在计算机时间管理的实现中就有所体现。

二、时间子系统的硬件基础

在生活中我们有各种各样的时钟来满足我们对时间的需求。比如以前家里常用的座钟、挂钟,个人也会戴个机械手表或者电子手表,这些时钟既能知时也能定时(有闹钟功能),知时本身也能转化为计时。所以一个时钟就能满足我们对时间的所有需求。在有些场合比如大学运动会时,会有专门的计时器,在比赛开始之前把计时器清零,比赛开始的时候按下开始,计时器开始走时,然后每当有一个人达到终点的时候按一下计时,计时器就会把当时的时间记下来,当所有人都跑完的时候按下结束,计时器停止走时。然后回看计时器就可以看到每个人跑完一千米的用时了。这种专用的计时器用来计时就非常方便。

现在家里有座钟、挂钟的人已经非常少了,戴手表的人也非常少了,大家基本都是用手机来看时间。手机不仅桌面上有时间显示,里面还有个时钟App,它和以前的时钟功能差不多,而且更强大。时钟App里面不仅能看时间(知时),还能定闹钟(绝对时间定时),里面还有一个计时器功能,实际上是倒计时,倒计时的本质是相对时间定时。里面还有一个秒表的功能,和我们前面说的运动会计时器的功能是一样的,所以秒表是个专业的计时器。所以手机上的时钟App完美得实现了我们对时间的所有需求。

手机实际上就是个计算机系统,而且安卓手机用的还是Linux内核。时钟App所实现的功能需要Linux内核的支持,内核时间子系统的实现需要有硬件的支持。

2.1 时钟硬件类型

计算机里面一共有三类时钟硬件,分别是真时钟RTC(Real Time Clock)、定时器Timer、计时器Counter。RTC相当于是手表、座钟,定时器相当于是闹钟,计时器相当于是运动会中的计时器。注意是三类时钟硬件,而不是三个,某一类时钟可能有多个不同的硬件,某一个时钟硬件也可能实现多种不同的时钟类型。

计算机中还有其它的时钟类型,比如晶振时钟,是驱动CPU运行的周期信号,用来触发和同步CPU内部的操作,我们常说某CPU是多少GHz,就是说这个时钟晶振每秒向CPU发送多少信号(大概如此,实际上比较复杂,还有倍频什么的,这里就不讨论了)。晶振时钟一般在CPU内部,有些嵌入式CPU的晶振在外部。时钟晶振在软件层不可见。还有一些设备也有自己的时钟,还有相应的驱动可以控制它。由于这些时钟都和时间子系统没有关系,所以本文中就不讨论它们了。

不同平台的时钟硬件各有不同,下面我们就来分别说说。

2.2 x86平台上的时钟

真时钟RTC,在x86上的硬件实现也叫做RTC,和CMOS(计算机中有很多叫做CMOS的东西,但是是不同的概念,此处的CMOS是指BIOS设置保存数据的地方)是放在一起的。由于在关机后都需要供电,所以两者放在了一起,由一个纽扣电池供电。所以有时候也会被人叫做CMOS时钟。

定时器Timer,在UP时代是PIT(Programmable Interval Timer),它以固定时间间隔向CPU发送中断信号。PIT可以在系统启动时设置每秒产生多少个定时器中断,一般设置是100,250,300,1000,这个值叫做HZ。到了SMP时代,PIT就不适用了,此时有多种不同的定时器。有一个叫做Local APIC Timer的定时器,它是和中断系统相关的。中断系统有一个全局的IO APIC,有NR_CPU个Local APIC,一个Local APIC对应一个CPU。所以在每个Local APIC都安装一个定时器,专门给自己对应的CPU发送定时器中断,就很方便。还有一个定时器叫做HPET(High Precision Event Timer),它是Intel和微软共同研发的。它不仅是个定时器,而且还有计时器的功能。HPET不和特定的CPU绑定,所以它可以给任意一个CPU发中断,这点和Local APIC Timer不同。

计时器Counter,RTC或者定时器虽然也可以实现计时器的目的,但是由于精度太差,所以系统都有专门的计时器硬件。计时器一般都是一个整数寄存器,以特定的时间间隔增长,比如说1纳秒增加1,这样两次读它的值就可以算出其中的时间差,而且精度很高。x86上最常用的计时器叫做TSC(Time Stamp Counter),是个64位整数寄存器。还有一个计时器叫做ACPI PMT(ACPI Power Management Timer),但是它是一个设备寄存器,需要通过IO端口来读取。而TSC是CPU寄存器,可以直接读取,读取速度就非常快。

2.3 ARM平台上的时钟

暂略

三、时间的用户空间API

我们先来看一下时间子系统的用户空间接口,了解一下内核是如何为程序提供时间需求API的。内核为用户空间只提供了知时、定时两类API,并没有提供计时的API,大家可以用知时的时间差来达到计时的功能。

3.1 知时API

知时API同时也提供了设置时间的API,这是因为计算机的时间有可能不准,需要校准一下,就和我们对手表进行校时是一样的。知时API从简单到强大经历了三代的发展。

第一代知时API:

 #include <time.h>
time_t time(time_t *tloc);
int stime(const time_t *t);

time_t是一个整数类型,代表的是从UNIX Epoch,也就是1970-01-01 00:00:00 +0000 (UTC)以来的秒数。UTC是世界协调时的意思,是0时区的时间。世界各地的时区是不同的,为了方便全球交流,底层的时间都是用的UTC,在上层显示的时候可以根据时区转化为当地时间。为什么要选择1970年,因为这一年UNIX诞生了。time函数是获取时间的,它可以在返回值和输出参数tloc同时返回时间,如果tloc是NULL的话就算了。stime是设置系统当前时间的,进程需要有CAP_SYS_TIME才能设置成功。

接口文档请参看:
https://man7.org/linux/man-pages/man2/time.2.html
https://man7.org/linux/man-pages/man2/stime.2.html

第二代知时API:
显然以秒为单位获取和设置时间太粗略了,于是第二代API出现了,它是以微妙为单位。

 #include <sys/time.h>
int gettimeofday(struct timeval *restrict tv, struct timezone *restrict tz);
int settimeofday(const struct timeval *tv, const struct timezone *tz);
struct timeval {time_t      tv_sec;     /* seconds */suseconds_t tv_usec;    /* microseconds */
};

timeval结构体和time_t相比多了一个微妙的字段,它代表的是从UNIX Epoch以来的秒数和微妙数,超过一秒的用秒表示,不够一秒的用微妙表示,这样表示时间的精度就大大提高了。timezone 参数已经被废弃了,使用时请设置为NULL。

接口文档请参看(两个接口都在这一个文档中):
https://man7.org/linux/man-pages/man2/gettimeofday.2.html

第三代知时API:
随着计算机的发展,第二代API的微妙级精度已经不够了,于是第三代API纳秒级的精度来了。第三代API不仅提高了精度,而且还有一个新的变化,就是可以指定时间体系了,我们前面说的时间体系的概念终于要派上用场了。

#include <time.h>
int clock_getres(clockid_t clockid, struct timespec *res);
int clock_gettime(clockid_t clockid, struct timespec *tp);
int clock_settime(clockid_t clockid, const struct timespec *tp);
struct timespec {time_t   tv_sec;        /* seconds */long     tv_nsec;       /* nanoseconds */
};

首先参数变成了timespec,它有秒和纳秒两个字段,时间的表示精度一下子就提高到了纳秒级别。为啥说是表示精度呢?因为数值能表示到纳秒不代表硬件就有纳秒的精度,于是我们需要第一个函数clock_getres来查询底层硬件的精度。如果没有clockid这个参数的话,clock_gettime、clock_settime这两个函数和前面两个函数基本没有区别,除了精度有区别之外。那么clockid代表什么意思呢?它就是我们在第一章所讲的时间体系再加上一些其它属性。我们再来回顾一下时间体系的三个要素,1时间原点,2时间基本单位,3时间是否会暂停。我们所使用的所有时间的基本单位都是秒,所以第二个要素就不用考虑了,就只剩下时间原点和时间是否会暂停了。我们先看一下clockid的取值都有哪些。

CLOCK_REALTIME,就是我们所说的自然时间,由于计算机上的时间有可能不准,所以是可以设置的,所以有可能会产生跳跃。后面所有的时间体系都是不可设置的,下面不再一一说明了。CLOCK_REALTIME_ALARM、CLOCK_REALTIME_COARSE、CLOCK_TAI虽然本身是不可设置的,但是都会受到CLOCK_REALTIME设置的影响,有可能会产生跳跃。

CLOCK_REALTIME_ALARM,和CLOCK_REALTIME相同,这个clockid在此处没有作用,在定时器设置时才有用,ALARM代表的是定时设置,如果目标定时时间到了的时候系统在休眠,会唤醒系统。

CLOCK_REALTIME_COARSE,和CLOCK_REALTIME相同,精度不高但是获取比较快。

CLOCK_TAI,和CLOCK_REALTIME相同,但是不考虑闰秒问题,TAI是International Atomic Time的反向简称。

CLOCK_MONOTONIC,由于前面几个时间体系都有可能会产生回跳,计算机中需要有一个单调递增的时间体系。此时间体系的时间原点并不重要,在Linux中是以系统启动的时间点作为时间原点,在计算机休眠时会暂停走时,受adjtime和NTP的影响可能会向前跳跃。

CLOCK_MONOTONIC_COARSE,同上,但是精度降低,访问更快。

CLOCK_MONOTONIC_RAW,同CLOCK_MONOTONIC,但是不受adjtime和NTP的影响。

CLOCK_BOOTTIME,以系统启动时间为时间原点的时间体系,不受其它因素的影响,计算机休眠时依然走时。

CLOCK_BOOTTIME_ALARM,同上,这个clockid在此处没有作用,在定时器设置时才有用,ALARM代表的是定时设置,如果目标定时时间到了的时候系统在休眠,会唤醒系统。

CLOCK_PROCESS_CPUTIME_ID,以进程创建时间为时间原点,进程运行时走时,进程休眠时暂停走时。

CLOCK_THREAD_CPUTIME_ID,以线程创建时间为时间原点,线程运行时走时,线程休眠时暂停走时。

有了这些clockid,知时API一下子就变得强大了起来,我们可以通过指定不同的clockid来选择不同的时间体系,就能获得不同的时间体系中的时间,这样就非常方便。

接口文档请参看(三个接口都在这一个文档中):
https://man7.org/linux/man-pages/man2/clock_gettime.2.html

3.2 定时API

定时API也经历了从简单到强大的发展过程,一共经历了四代。

第一代定时器API:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

进程将在seconds秒之后收到一个SIGALRM信号,关于信号的原理请参看《深入理解Linux信号机制》。这个函数很简单,但是也很简陋,时间只能精确到秒,而且一个进程同时只能设置一个alarm,如果一个alarm还没完成,你又调用了此函数,前面的alarm就会被取消并重新设置新的alarm。如果参数是0的话,可以取消前面的alarm,而不建立新的alarm。而且此定时器是一次性的,不能设置周期性的定时器。

接口文档请参看:
https://man7.org/linux/man-pages/man2/alarm.2.html

第二代定时器API:
第二代API叫做间隔定时器(interval timer)。这个接口就比上一个接口强大多了,一是一个进程可以同时设置三个定时器,不过三个定时器的时间体系是不同的;二是定时器的精度提高到了微秒级;三是定时器可以是周期性的,而前面的定时器是一次性的。

 #include <sys/time.h>
int getitimer(int which, struct itimerval *curr_value);
int setitimer(int which, const struct itimerval *restrict new_value, struct itimerval *restrict old_value);struct itimerval {struct timeval it_interval; /* Interval for periodic timer */struct timeval it_value;    /* Time until next expiration */
};
struct timeval {time_t      tv_sec;         /* seconds */suseconds_t tv_usec;        /* microseconds */
};

先看which参数,它有下面三个取值:

ITIMER_REAL,用自然时间作为时间体系,定时器到期后会给进程发送SIGALRM信号,这个就是第一代的alarm,两者不要同时使用。

ITIMER_VIRTUAL,用进程在用户空间的时间消耗作为时间体系,定时器到期后会给进程发送SIGVTALRM信号。

ITIMER_PROF,用进程消耗的时间作为时间体系,包括进程在用户空间和内核空间消耗的时间。定时器到期后会给进程发送SIGPROF信号。

我们再来看第二个参数,itimerval,它的字段数据类型timeval说明定时器的时间精度提高到了微秒级,it_value代表定时器第一次到期的时间,it_interval代表定时器以后周期性到期的时间,两者同时为0代表取消这个定时器。

setitimer是用来设置定时器的,参数new_value代表要设置的值,参数old_value返回之前设置的值,可以为NULL。getitimer可以获取之前设置的定时器的剩余到期时间。

接口文档请参看(两个接口都在这一个文档中):
https://man7.org/linux/man-pages/man2/setitimer.2.html

第三代定时器API:
第三代API就更强大了,它可以直接指定时间体系(clockid),每个时间体系都可以建立多个定时器,还可以指定定时器到期时的触发方式,而且精度也达到了纳秒级。

#include <signal.h>           /* Definition of SIGEV_* constants */
#include <time.h>
int timer_create(clockid_t clockid, struct sigevent *restrict sevp, timer_t *restrict timerid);
int timer_settime(timer_t timerid, int flags, const struct itimerspec *restrict new_value, struct itimerspec *restrict old_value);
int timer_gettime(timer_t timerid, struct itimerspec *curr_value);
int timer_getoverrun(timer_t timerid);
int timer_delete(timer_t timerid);

首先我们要使用timer_create创建一个定时器id,在输出参数timerid中返回。创建时可以指定clockid,其含义前面讲过,这里就不再赘述了。sigevent参数可以指定是否发送信号,如何发送。然后通过timer_settime给定时器设置参数,timerid代表要设置哪个定时器,itimerspec设置定时器信息,含义和前面的相同,只不过是精度到达了纳秒级,old_value返回之间的设置,可以为NULL。定时器使用完了之后通过timer_delete删除。

接口文档请参看(timer_gettime、timer_settime在同一个文档中):
https://man7.org/linux/man-pages/man2/timer_create.2.html
https://man7.org/linux/man-pages/man2/timer_settime.2.html
https://man7.org/linux/man-pages/man2/timer_getoverrun.2.html
https://man7.org/linux/man-pages/man2/timer_delete.2.html

第四代定时器API:
第三代API已经很强大了,但是它的到期方式还是利用的信号,第四代API把定时器转化为了fd,可以利用fd的接口进行操作,非常方便。

#include <sys/timerfd.h>
int timerfd_create(int clockid, int flags);
int timerfd_settime(int fd, int flags,
const struct itimerspec *new_value, struct itimerspec *old_value);
int timerfd_gettime(int fd, struct itimerspec *curr_value);

timerfd_create创建一个fd代表定时器,clockid代表时间体系,不再赘述,flags是fd相关的flag。timerfd_settime对定时器进行设置,fd代表设置的是哪个定时器,具体设置方式和前面的相同,就不再细说了。然后我们用read函数去读这个定时器fd,把定时器的异步到期转化为了同步操作。定时器没到期时read会一直阻塞,到期后read可以读到一个ulong值,代表的是定时器的到期次数。因为可能在你read它之前它已经到期很多次了,所有有个ulong值来记录一下次数。定时器使用完之后使用close函数进行删除。

接口文档请参看(三个接口都在这一个文档中):
https://man7.org/linux/man-pages/man2/timerfd_create.2.html

线程休眠API:
线程休眠API也可以看做是特殊的定时器操作,所以就放到这里来讲了

#include <unistd.h>
unsigned int sleep(unsigned int seconds);
int usleep(useconds_t usec);
#include <time.h>
int nanosleep(const struct timespec *req, struct timespec *rem);
int clock_nanosleep(clockid_t clockid, int flags, const struct timespec *request, struct timespec *remain);

可以看到休眠的接口函数也在一步一步地升级,具体的含义就不细讲了,通过前面的类比就可以明白。

接口文档请参看:
https://man7.org/linux/man-pages/man3/sleep.3.html
https://man7.org/linux/man-pages/man3/usleep.3.html
https://man7.org/linux/man-pages/man2/nanosleep.2.html
https://man7.org/linux/man-pages/man2/clock_nanosleep.2.html

四、时间的内核接口

内核时间子系统是用来实现对时间的各种需求的,不仅用户空间对时间有需求,内核其它模块对时间也有需求。内核对时间的需求和用户空间对时间的需求既有相似的地方,也有不同的地方。首先内核和用户空间一样,没有直接的计时接口,计时是通过知时的差值来实现的。其次用户空间有设置时间的接口,而内核没有设置时间的需求,所以内核只有获取时间的接口。然后内核的定时器分为低精度定时器(传统定时器)和高精度定时器。最后调度器模块对时间有定时(tick)和计时的需求,而调度器的定时(tick)也会影响时间子系统的实现,两者之间是深度绑定的,没有明显的接口。

4.1 知时接口

内核很多模块都有获取时间的需求,包括不同时间体系的时间。不过和用户空间不同的是,内核只会使用全局时间体系(和进程无关的时间体系),而不会使用局部时间体系(和进程相关的时间体系)。而且内核还提供了返回不同时间格式、不同精度、不同性能的接口。下面我们用表格的形式来看一下各个接口。

返回ktime_t的时间接口:

接口时间体系
ktime_t ktime_get( void )CLOCK_MONOTONIC
ktime_t ktime_get_boottime( void )CLOCK_BOOTTIME
ktime_t ktime_get_real( void )CLOCK_REALTIME
ktime_t ktime_get_clocktai( void )CLOCK_TAI
ktime_t ktime_get_raw( void )CLOCK_MONOTONIC_RAW

我们来解释一下各种时间体系的含义。CLOCK_REALTIME和CLOCK_TAI,这两个都是自然时间体系,不同的是后者不受闰秒的影响。不过由于用户空间可能会设置时间,NTP也会调节自然时间,所以这两个时间体系都有可能会产生回跳。CLOCK_BOOTTIME、CLOCK_MONOTONIC和CLOCK_MONOTONIC_RAW,这三个都是以系统启动的时间点为时间原点的时间体系。CLOCK_BOOTTIME、CLOCK_MONOTONIC,不同点是前者不会在系统休眠时暂停走时而后者会,相同点是两者都受NTP的影响。CLOCK_MONOTONIC_RAW和CLOCK_MONOTONIC基本相同,但是前者不受NTP的影响。ktime_t的含义在下面解释。

返回u64(纳秒)的时间接口:

接口时间体系
u64 ktime_get_ns( void )CLOCK_MONOTONIC
u64 ktime_get_boottime_ns( void )CLOCK_BOOTTIME
u64 ktime_get_real_ns( void )CLOCK_REALTIME
u64 ktime_get_clocktai_ns( void )CLOCK_TAI
u64 ktime_get_raw_ns( void )CLOCK_MONOTONIC_RAW

这个和前面有什么区别呢?区别就是u64是明确的纳秒数,而ktime_t是个不透明数据结构,需要用函数ktime_to_ns来转换纳秒数。ktime_to_ns目前的实现是直接把ktime_t作为纳秒数返回,但是这是实现,不是接口。编程要依赖于接口而不能依赖于实现,因为接口有明确的语义,是要保持稳定、保持兼容的,而实现随时都可以改变。

返回timespec64的时间接口:

接口时间体系
void ktime_get_ts64( struct timespec64 * )CLOCK_MONOTONIC
void ktime_get_boottime_ts64( struct timespec64 * )CLOCK_BOOTTIME
void ktime_get_real_ts64( struct timespec64 * )CLOCK_REALTIME
void ktime_get_clocktai_ts64( struct timespec64 * )CLOCK_TAI
void ktime_get_raw_ts64( struct timespec64 * )CLOCK_MONOTONIC_RAW

linux-src/include/linux/time64.h

struct timespec64 {time64_t	tv_sec;			/* seconds */long		tv_nsec;		/* nanoseconds */
};

通过输出参数返回想要的值。timespec64有两部分,大于一秒的用tv_sec表示,不够一秒的用tv_nsec表示。有些场景下这种表示使用比较方便。

返回time64_t(秒数)的时间接口:

接口时间体系
time64_t ktime_get_seconds( void )CLOCK_MONOTONIC
time64_t ktime_get_boottime_seconds( void )CLOCK_BOOTTIME
time64_t ktime_get_real_seconds( void )CLOCK_REALTIME
time64_t ktime_get_clocktai_seconds( void )CLOCK_TAI
time64_t ktime_get_raw_seconds( void )CLOCK_MONOTONIC_RAW

有时候当我们只需要精确到秒时就可以使用这些接口。这些接口的实现会直接返回当前的秒数不用再去访问计时器硬件,提高了效率。

下面三个表格返回的都是粗略时间,时间精度不高,但是访问效率比较高。时间精度和tick有关,根据不同的配置,精度范围在1ms到10ms之间。系统没有实现CLOCK_MONOTONIC_RAW的粗略时间版本。

返回粗略ktime_t的时间接口:

接口时间体系
ktime_t ktime_get_coarse( void )CLOCK_MONOTONIC
ktime_t ktime_get_coarse_boottime( void )CLOCK_BOOTTIME
ktime_t ktime_get_coarse_real( void )CLOCK_REALTIME
ktime_t ktime_get_coarse_clocktai( void )CLOCK_TAI

返回粗略u64(纳秒数)的时间接口:

接口时间体系
u64 ktime_get_coarse_ns( void )CLOCK_MONOTONIC
u64 ktime_get_coarse_boottime_ns( void )CLOCK_BOOTTIME
u64 ktime_get_coarse_real_ns( void )CLOCK_REALTIME
u64 ktime_get_coarse_clocktai_ns( void )CLOCK_TAI

返回粗略timespec64的时间接口:

接口时间体系
void ktime_get_coarse_ts64( struct timespec64 * )CLOCK_MONOTONIC
void ktime_get_coarse_boottime_ts64( struct timespec64 * )CLOCK_BOOTTIME
void ktime_get_coarse_real_ts64( struct timespec64 * )CLOCK_REALTIME
void ktime_get_coarse_clocktai_ts64( struct timespec64 * )CLOCK_TAI

系统还实现了一个fast版本,可用于各种场景,包括NMI以及调试输出。
返回fast u64(纳秒数)的时间接口:

接口时间体系
u64 ktime_get_mono_fast_ns( void )CLOCK_MONOTONIC
u64 ktime_get_boot_fast_ns( void )CLOCK_BOOTTIME
u64 ktime_get_real_fast_ns( void )CLOCK_REALTIME
u64 ktime_get_raw_fast_ns( void )CLOCK_MONOTONIC_RAW

没有CLOCK_TAI时间体系的版本。

4.2 定时接口

内核里也有定时的需求,内核的定时器接口分为低精度定时器(传统定时器)和高精度定时器。这两者同时存在于内核中,无论底层定时器硬件是否支持以及内核config如何配置。如果底层硬件或者config不支持高精度定时器,那么高精度定时器就会在低精度定时器的驱动下运行,这时高精度定时器的接口还是可以用的,就是效果上达不到高精度了。如果底层硬件和config都支持高精度定时器,那么调度器tick是在高精度定时器的驱动下运行的,低精度定时器是在调度器tick下运行的。高精度定时器的高精度可以达到,低精度定时器的实际精度还是低精度。

下面我们分别来看看低精度定时器和高精度定时器的接口和使用方法。

低精度定时器的定义和使用接口如下:
linux-src/include/linux/timer.h

struct timer_list {struct hlist_node	entry;unsigned long		expires;void			(*function)(struct timer_list *);u32			flags;
};#define DEFINE_TIMER(_name, _function)				\struct timer_list _name =				\__TIMER_INITIALIZER(_function, 0)#define timer_setup(timer, callback, flags)			\__init_timer((timer), (callback), (flags))#define timer_setup_on_stack(timer, callback, flags)		\__init_timer_on_stack((timer), (callback), (flags))void add_timer(struct timer_list *timer);
void add_timer_on(struct timer_list *timer, int cpu);
int mod_timer(struct timer_list *timer, unsigned long expires);
int del_timer(struct timer_list * timer);
int del_timer_sync(struct timer_list *timer);

低精度定时器的结构体是timer_list,它有两个重要的字段,一个是到期时间expires,一个是到期后执行的函数function。expires的单位是tick,当jiffies的值大于它时,说明定时器到期了。jiffies是个整数值,每次调度器tick时,它的值增加1。用上面的接口对timer_list进行初始化之后,还得单独对它的expires进行设置。假设相同的tick HZ是1000,你希望定时器1秒后到期,可以按照如下设置:
timer_list.expires = jiffies + delay

设置完之后调用add_timer或者add_timer_on来启动一个低精度定时器。如果想要修改它的到期时间再调用mod_timer接口。如果想撤回定时器则调用del_timer或者del_timer_sync,两者的区别是如果定时器函数已经在执行了,后者会等定时器函数执行完再返回,前者不会等。

高精度定时器的定义和使用接口如下:
linux-src/include/linux/hrtimer.h

struct hrtimer {struct timerqueue_node		node;ktime_t				_softexpires;enum hrtimer_restart		(*function)(struct hrtimer *);struct hrtimer_clock_base	*base;u8				state;u8				is_rel;u8				is_soft;u8				is_hard;
};void hrtimer_init(struct hrtimer *timer, clockid_t which_clock, enum hrtimer_mode mode);
void hrtimer_start(struct hrtimer *timer, ktime_t tim, const enum hrtimer_mode mode);
void hrtimer_start_expires(struct hrtimer *timer, enum hrtimer_mode mode);
void hrtimer_restart(struct hrtimer *timer);
int hrtimer_cancel(struct hrtimer *timer);
int hrtimer_try_to_cancel(struct hrtimer *timer);

高精度定时器的结构体是hrtimer,它有到期时间_softexpires,到期函数function,还有时钟信息base以及一些其它信息。hrtimer_init用来初始化定时器,可以指定时间体系clockid_t和模式hrtimer_mode。模式参数可以指定到期时间是相对时间还是绝对时间,可以指定到期函数是在硬中断中执行还是在软中断中执行。启动一个定时器用hrtimer_start或者hrtimer_start_expires,前者可以指定到期时间,后者用定时器本身的到期时间。重启一个定时器用hrtimer_restart。想取消定时器用 hrtimer_cancel或者hrtimer_try_to_cancel,两者的区别是如果到期函数正在执行,前者等待执行完成才返回,后者直接返回。

4.3 调度器tick与计时

想要全面了解进程调度相关的信息请参看《深入理解Linux进程调度》。

进程调度是需要定时和计时的。定时是为了实现调度抢占(被动调度),如果没有调度抢占,一个进程如果一直没有阻塞调用那么就会一直占用CPU,就会导致系统卡住。所以系统就需要周期性地去检查一个进程是否执行了过长的时间,如果执行时间过长了就切换到其它进程来执行。而检查进程是否执行过长时间就需要对进程的执行时间进行计时。计时和绝对时间无关,和相对时间有关,所以调度器就可以直接使用计时器对进程的执行进行计时。进程计时使用的方法实际上就是对底层计时器硬件进行的包装,这个我们后面会详细讲。

实现进程调度的定时需求就叫做调度器tick,或者简称tick。调度器tick的频率HZ,不能太低也不能太高,太低了会降低系统的响应性,太高了又会影响系统的性能。目前调度器的tick HZ是个可配置项,配置选项有100、250、300、1000四个选项,默认选项是250。早期的调度器tick HZ是固定的,也就是说无论任何情况下都是每秒tick HZ下,无论系统是否处于idle状态。实际上系统处于idle状态的时候,tick是没有意义的,只能增加耗电,所以后来系统引进了动态tick(tickless,dynamic tick)。动态tick不是指tick的HZ是动态的,不是指两次tick之间的时间间隔是变化的。HZ是在编译时就确定了的值,是不会变化的,所以两次tick之间的时间间隔是固定的,但是tick是可以暂停的。所以动态tick是指tick是可以暂停的,当系统处于idle状态的时候就暂停tick,当系统退出idle的时候再恢复tick。

可以看出当调度tick是动态tick的时候,底层的定时器应该选择一次性定时器比较好。每次定时器到期时如果还需要继续tick就继续启动定时器,如果要暂停tick就不再启动定时器,很方便。如果选择周期性定时器好像也可以,需要继续tick的时候就什么都不用做,需要暂停tick的时候就取消定时器,好像也可以,我们继续往下看有没有其它因素。

前面我们讲了低精度定时器、高精度定时器和调度器tick之间的关系,现在我们再来论述一下。当系统中只有低精度定时器时(没有配置高精度定时器或者硬件不支持高精度定时器),用调度器tick驱动低精度定时器就可以。动态tick时,如果选择的是周期性tick,当系统idle并且没有低精度定时器时,可以停掉定时器,但是如果此时有低精度定时器但是还有较长的时间才会到期,该怎么办,停掉tick就没法触发低精度定时器,不停tick就会比较耗电。此时如果用一次性tick就没有这个问题,系统idle了,但是还有低精度定时器存在,把定时器下一次到期的时候设置为最近的低精度定时器到期的时间就可以了。所以在低精度定时器的情况下,动态tick的底层定时器只能选择一次性定时器。

当系统中有高精度定时器时(硬件支持且config配置了),此时底层定时器硬件只能选择一次性定时器。结合前面分析的,不可能用调度器tick驱动高精度定时器,所以要反过来,只能用高精度定时器驱动调度器tick,方法是创建一个高精度定时器,高精度定时器都是一次性的,所以也支持动态tick。

五、时间子系统的软件架构

当我们知道了我们明白什么、我们有什么、我们想要什么的时候,我们就会知道我们应该怎么做。

从第一章我们明白了时间的基本概念,从第二章我们知道了我们有RTC、计时器、定时器三类底层硬件,从第三章和第四章我们知道了我们需要什么,那么我们就会很容易的分析出我们应该怎么做。

5.1 系统时钟的设计

在用户空间和内核空间都有知时的需求,而底层又有RTC硬件,这样看来知时的需求很好实现啊,直接访问RTC硬件就可以了。这么做行吗?我们来分析一下。首先RTC是个外设,访问RTC要走IO端口,而这相对来说是个很慢的操作。其次RTC的精度不够,有的RTC精度是秒,有的是毫秒,这显然是不够用的。最后系统要实现很多时间体系,直接访问RTC灵活性也不够。所以直接访问RTC是一个很差的设计,那么该怎么实现知时的需求呢?

我们先来回忆一下时钟和走时的定义。

时钟: 包括硬件的、软件的、机械的、电子的,都是用来追踪和记录自然时间流逝的工具。

走时: 是时钟追踪和记录时间流逝的动作。

我们用机械手表来解释一个这个概念。手表里面有发条,发条的变化是在追踪时间的流逝,然后发条通过齿轮把时间的变化记录在表盘的时针、分针、秒针上,这样我们就可以看到现在的时间是多少了。

我们再来回忆一下知时和计时之间的关系。知时是原点特定的计时,计时是原点不特定的知时,知时和计时可以相互转化。知时相减就是计时,给计时一个特定的原点就是知时。计算机上既有RTC也有计时器,RTC虽然又慢精度又低,但是计时器又快精度又高啊。计时器的精度可以达到1纳秒或者几纳秒,而且计时器大部分都是通过寄存器访问的,速度非常快的。给计时器的起点一个确定的时间点,它就是RTC了啊。于是乎方案就出来了:Linux提出了系统时钟的概念,它是一个软件时钟,相应的把RTC叫做硬件时钟。系统时钟是用一个变量xtime记录现在的时间点,xtime的初始值用RTC来初始化,这样就只用访问RTC一次就可以了,然后xtime的值随着计时器的增长而增长。xtime的值的更新有两种情况,一种是调度器tick的时候从计时器更新一下,一种是读xtime的时候从计时器更新一下。对于这个时钟,计时器就相当于是发条,调度器tick就相当于是齿轮,xtime就相当于是时针、分针、秒针,一个软件时钟就这么设计好了。

Linux中用来实现系统时钟的软件体系叫做The Linux Timekeeping Architecture。如果我们把Timekeeping翻译成“时间维护”,感觉意思好像不到位。好在我们前面讲了“走时”的概念,把Timekeeping翻译成“走时”的话,一下子就觉得意思到了。后面我们就用“Linux走时框架”这个词了。在Linux走时框架中有三个基本概念:1.走时器(struct timekeeper),用来记录一些基本数据,包括系统时钟的当前时间值和其它全局时间体系的一些数据;2.时钟源(struct clocksouce),是对计时器硬件的一种抽象;3.时钟事件设备(struct clock_event_device),是对定时器硬件的一种抽象。这三个对象相互配合共同构成了系统时钟。

系统可能会有很多计时器硬件和定时器硬件。在系统启动时每个硬件都会初始化并注册自己。注册完之后系统会选择一个最佳的时钟源作为走时器的时钟源,选择一个最佳的时钟事件设备作为更新系统时钟的设备。系统启动时会去读取RTC的值来初始化系统时钟的值,然后时钟事件设备不断产生周期性的定时器事件,在定时器事件处理函数中会读取时钟源的值,再减去上一次读到的值,得到时间差,这个时间差就是系统时钟应该前进的时间值,把这个值更新到走时器中,并相应更新其它时间体系的值。系统时钟就是按照这种方式不断地在走时。系统时钟除了在启动时和休眠唤醒时会去读取RTC的值,其它时间都不会和RTC交换,两者各自独立地走时,互不影响。

用户空间API读取和设置的时间是系统时钟,和硬件时钟RTC没有关系。如果要读写RTC的话,需要用ioctl RTC_SET_TIME对/dev/rtc进行操作。stime、settimeofday设置的系统时钟,不会更改到RTC上,系统重启后更改就消失了。通过/dev/rtc修改的硬件时间也不会更改到系统时间上,只有系统重启后才会反映到系统时钟上。对此有一个系统命令hwclock,它不仅可以修改RTC,也可以在两者之间进行同步。hwclock --hctosys 把硬件时钟同步到系统时钟,hwclock --systohc把系统时钟同步到硬件时钟。事实上我们发现用settimeofday修改的系统时钟在系统重启后生效了,并没有丢失,这是为什么呢?是因为系统默认的关机脚本里面会执行hwclock --systohc,把系统时钟同步到硬件时钟,所以我们修改的系统时钟才不会丢失。

5.2 系统时钟的实现

暂略

推荐阅读:
http://www.wowotech.net/timer_subsystem/time-subsyste-architecture.html
http://www.wowotech.net/timer_subsystem/timekeeping.html
http://www.wowotech.net/timer_subsystem/clocksource.html
http://www.wowotech.net/timer_subsystem/clock-event.html

5.3 动态tick与定时器

低精度定时器是内核在早期就有的定时器接口,它的实现是靠调度器tick来驱动的。高精度定时器是随着硬件和软件的发展而产生的。调度器tick的HZ(每秒tick多少次)是可以配置,它的配置选项有4个,100,、250、300、1000,也即是说每次tick的间隔是10ms、4ms、3.3ms、1ms。所以用调度器tick来驱动低精度定时器是很合适的,tick的精度能满足低精度定时器的精度。但是用调度器tick来驱动高精度定时器就不合适了,因为这样高精度定时器的精度最多是1ms,达不到纳秒的级别,这样就算不上是高精度定时器了。所以对于高精度定时器来说,情况就正好反了过来,高精度定时器直接用硬件实现,然后创建一个软件高精度定时器来模拟调度器tick。也就是说,对于只有低精度定时器的系统来说,是调度器tick驱动低精度定时器;对于有高精度定时器的系统来说,是高精度定时器驱动调度器tick,这个调度器tick再去驱动低精度定时器。

内核的低精度定时器接口和高精度定时器接口都是一次性的,不是周期性的。通过一次性的定时器可以实现周期性的定时器,方法是在每次定时器到期时再设置下一次的定时器,一直这样就形成了周期性的。这里说的是定时器接口的一次性和周期性,而不是定时器硬件。下面我们再来看看定时器硬件是一次性的还是周期性的。定时器硬件本身可以是一次性的也可以是周期性的,也可以两种模式都存在,由内核选择使用哪一种。对于低精度定时器来说,它的定时器硬件可以是一次性的也可以是周期性的,由于调度器tick是周期性的,所以它的底层硬件就是周期性的。低精度定时器的精度最多是1ms,也就是定时器中断做多一秒有1000次,这对于系统来说是可以承受的。但是对于高精度定时器来说,理论上它的定时器硬件也可以是周期性的。但是如果它的定时器硬件是周期性的,由于它的精度最多可以达到1纳秒,也就是说1纳秒要发生一次定时器中断,每秒发生10亿次。这对于系统来说是不可承受的,而且并不是每纳秒都有定时器事件要处理,所以大部分定时器中断是没有用的。如果我们把1纳秒1次中断改为1微妙,1微妙1次中断不就可以大大减少中断的数量嘛,但是这样定时器的精度就是1微妙,达不到1纳秒的要求了。所以对于高精度定时器,底层的定时器硬件就只能是一次性的了。每次定时器事件到来的时候再去查看一下下一个最近的定时器事件什么时候到期,然后再去设置一下定时器硬件。这样高精度定时器就可以一直运行下去了。但是我们的调度器tick也需要定时器中断,而且是周期性的,怎么办?好办,创建一个到期时间为1ms的高精度定时器,每次到期的时候再设置一下继续触发,这样就形成了一个1000HZ周期性的定时器事件,就可以驱动调度器tick。

下面我们讲一下定时器和调度器tick的初始化过程,以x86为例。系统启动时会先初始化timekeeping。然后hpet注册自己,hpet既有定时器也有计时器,hpet定时器会成为系统定时器,hpet计时器会成为timekeeper的时钟源。后面tsc计时器也会注册自己,并成为最终的时钟源。Local APIC Timer定时器也会注册自己,并成为最终的per CPU tick device。hpet最终只能做broadcast 定时器了。系统在每次run local timer的时候都会检测一下,如果不支持高精度定时器,就尝试切换到动态tick模式,如果支持高精度定时器就切换到高精度定时器模式,此模式下会尝试切换到动态tick模式。当高精度定时器和动态tick设置成功之后,Local APIC Timer会运行在一次性模式,调度器tick是由一个叫做sched_timer的高精度定时器驱动的。每次定时器到期时都会reprogram next event。

5.4 用户空间API的实现

用户空间API的实现文件如下表所示,具体实现细节就不再展开解释了,大家搜索SYSCALL_DEFINE可以快速找到函数实现的地方。

文件实现API
linux-src/kernel/time/time.ctime、stime、gettimeofday、settimeofday
linux-src/kernel/time/posix-timers.cclock_getres、clock_gettime、clock_settime
linux-src/kernel/time/itimer.calarm、getitimer、getitimer
linux-src/kernel/time/posix-timers.ctimer_create、clock_gettime、clock_settime、timer_getoverrun、timer_delete
linux-src/fs/timerfd.ctimerfd_create、timerfd_gettime、timerfd_settime
linux-src/kernel/time/hrtimer.cnanosleep
linux-src/kernel/time/posix-timers.cclock_nanosleep

六、总结回顾

通过前面的介绍,我们了解了时间的基本概念,知道了计算机中实现时间子系统的基础硬件,学会了时间的用户空间API和内核接口,明白了时间子系统的设计原理。下面我们画个图总结一下:
时间子系统框架


参考文献:

《Linux Kernel Development》
《Understanding the Linux Kernel》
《Professional Linux Kernel Architecture》
《The Linux Programming Interface》

https://man7.org/linux/man-pages/man7/time.7.html
https://man7.org/linux/man-pages/man4/rtc.4.html
https://man7.org/linux/man-pages/man8/hwclock.8.html

http://www.wowotech.net/sort/timer_subsystem


http://www.ppmy.cn/news/475232.html

相关文章

元计算模拟宇宙人生by剑桥大学材料学博士段晓明 (公号回复“元计算”下载PDF典藏版资料,欢迎转发、赞赏支持科普)

元计算模拟宇宙人生by剑桥大学材料学博士段晓明 (公号回复“元计算”下载PDF典藏版资料&#xff0c;欢迎转发、赞赏支持科普) 原创&#xff1a; 秦陇纪 科学Sciences 今天 科学Sciences导读&#xff1a;剑桥大学材料学博士段晓明《元计算模拟宇宙人生》讲座课件&#xff0c…

我写了一本操作系统词典送你了

操作系统&#xff08;Operating System&#xff0c;OS&#xff09;&#xff1a;是管理计算机硬件与软件资源的系统软件&#xff0c;同时也是计算机系统的内核与基石。操作系统需要处理管理与配置内存、决定系统资源供需的优先次序、控制输入与输出设备、操作网络与管理文件系统…

7.8 W 字总结!Java 8—Java 10 特性详解

点击关注公众号&#xff0c;回复“1024”获取2TB学习资源&#xff01; ‍ ‍Java现在发布的版本很快&#xff0c;每年两个&#xff0c;但是真正会被大规模使用的是三年一个的TLS版本。‍ ‍ 每3年发布一个TLS&#xff0c;长期维护版本。意味着Java 8 &#xff0c;Java 11&#…

八千字,带你看示波器的发展史。

01 史前时代 电子示波器的起点并不容易查证&#xff0c;所以史前时代由示波器的操作特性来划分。如今我们最常使用的可能是边沿触发模式&#xff0c;甚至通常认为这就是示波器的一部分基本功能。实际上在TEK 511之前&#xff0c;示波器并不具备触发能力。此时的示波器为了稳定…

干货:esp32彩屏自制太空人主题透明手表!

#创作来源# 牛年春节前看了稚晖君自制的百大up奖杯视频&#xff0c;用到了一块分光棱镜&#xff0c;透明的玻璃中显示动感的画面超有感觉&#xff0c;一下子就把我吸引了&#xff0c;于是就赶着快递停运前把分光棱镜买了回来&#xff0c;想着春节假期体验一把&#xff0c;奈何一…

Kickstarter | 什么反重力科技上线仅一小时获千粉

Novium Edge : 反重力多功能工具 筹集资金&#xff1a;HK$ 31,593&#xff08;仍在筹集中&#xff09; Backer数量&#xff1a;253 极简主义的多功能工具和回形针系统专为抵抗重力而设计。磁性制作的美盒刀、尺子、开信刀等。Novium Edge 结合了美工刀、尺子和开信刀的实用性 -…

用python写桌面天气预报,自己的学习曲线。

自从接触python&#xff0c;就被他优雅而简洁的代码所吸引。 举个例子: arr [ 1 , 2 , 3 , 4 , 5 , 6 , 7 , 8 , 9 , 10 , 11 , 12 , 13 , 14 ] #如果想要将arr中大于10的项取出来&#xff0c;然后每项乘以2&#xff0c;组成一个新的列表arr2&#xff0c;在python里只需要这么…

手把手教你制作ESP8266物联网创意点阵时钟,女朋友看了都想要!

本文作者&#xff1a;默 & 铁熊 前段时间我在网上看到了一款很有意思的点阵时钟&#xff0c;它可以播报天气&#xff0c;查看 YouTube 的订阅数&#xff0c;还有好看的时间动画。你可以把它当做普通闹钟&#xff0c;也可以连接蓝牙把它当做音箱来使用。它的许多功能都很有…