时间与日期模块:时间戳获取、时间格式化、定时器模块的设计
时间处理,在嵌入式系统里是个绕不开的话题。我做了十几年嵌入式,几乎每个项目都会跟时间打交道。从简单的LED闪烁,到复杂的协议栈超时重传,再到数据采集的时间戳记录——时间模块就像系统的脉搏,看似基础,但一旦出问题,排查起来特别头疼。
今天咱们就聊聊,怎么把时间与日期模块设计得既通用又可靠。说白了,就是三个核心能力:获取时间戳、格式化时间、管理定时器。
一、时间戳获取:从硬件到软件的桥梁
时间戳是什么?就是一个单调递增的数值,通常从某个固定起点开始计数。在嵌入式世界里,最常见的来源是RTC(实时时钟)或者系统滴答定时器。
我个人习惯把时间戳获取封装成一个底层接口,上层代码只管调用,不用关心底层是RTC还是别的什么。这样换平台时,只需要改底层驱动就行。
核心接口设计
/* time_stamp.h */
#ifndef TIME_STAMP_H
#define TIME_STAMP_H
#include <stdint.h>
/* 时间戳类型,单位:秒 */
typedef uint64_t timestamp_t;
/* 获取当前时间戳(Unix时间戳,从1970-01-01 00:00:00 UTC开始) */
timestamp_t timestamp_get(void);
/* 获取高精度时间戳,单位:微秒 */
uint64_t timestamp_get_us(void);
/* 设置RTC时间(用于校准) */
void timestamp_set(timestamp_t ts);
#endif /* TIME_STAMP_H */
这里我用了uint64_t,为什么?因为32位的时间戳在2038年就会溢出。嗯,这个问题我在一个物联网项目里遇到过,当时设备要运行10年以上,32位时间戳根本撑不住。所以,新设计一律用64位,别给自己挖坑。
二、时间格式化:让人看得懂的时间
时间戳是给机器看的,人得看年月日时分秒。格式化的核心就是做这个转换。
我曾经在一个数据采集项目中,日志里全是原始时间戳,调试时得拿计算器算,太痛苦了。从那以后,我坚持所有日志输出必须带可读时间。
我的经验:格式化函数一定要支持线程安全,用可重入版本。否则在多任务环境下,共享缓冲区会互相覆盖,查错查到崩溃。
/* time_format.h */
#ifndef TIME_FORMAT_H
#define TIME_FORMAT_H
#include <stdint.h>
#include <time.h>
/* 时间格式化缓冲区大小 */
#define TIME_STR_BUF_SIZE 32
/* 将时间戳格式化为字符串 "YYYY-MM-DD HH:MM:SS" */
void time_format(timestamp_t ts, char *buf, size_t buf_size);
/* 将时间戳格式化为自定义格式,类似strftime */
int time_format_custom(timestamp_t ts, const char *fmt, char *buf, size_t buf_size);
/* 解析时间字符串为时间戳 */
timestamp_t time_parse(const char *time_str, const char *fmt);
#endif /* TIME_FORMAT_H */
实现时,我建议用标准库的gmtime_r或localtime_r,而不是gmtime或localtime。前者是线程安全的,后者不是。你想想看,如果两个任务同时调用格式化函数,共享的静态缓冲区会被覆盖,结果就是乱码。
三、定时器模块:软件定时器的设计
硬件定时器数量有限,而且每个定时器通常只能产生一个中断。但在实际项目中,我们可能需要几十个甚至上百个定时任务。这时候,软件定时器就派上用场了。
软件定时器的核心思想:用一个硬件定时器作为心跳,管理多个软件定时器。每个软件定时器记录自己的到期时间,心跳中断时检查哪些定时器到期了,然后执行回调。
定时器模块架构图
你看这个架构,硬件定时器在最底层,提供稳定的心跳。中间是管理核心,负责维护定时器链表、检查到期、分发回调。最上层是具体的软件定时器实例,每个实例有自己的到期时间和回调函数。
四、定时器模块的实现要点
下面我给出一个轻量级的软件定时器实现。这个版本我用了单向链表,适合定时器数量不多的场景。
/* soft_timer.h */
#ifndef SOFT_TIMER_H
#define SOFT_TIMER_H
#include <stdint.h>
/* 定时器状态 */
typedef enum {
TIMER_STOPPED = 0,
TIMER_RUNNING,
TIMER_EXPIRED
} timer_state_t;
/* 定时器回调函数类型 */
typedef void (*timer_callback_t)(void *arg);
/* 软件定时器结构体 */
typedef struct soft_timer {
struct soft_timer *next; /* 链表指针 */
uint32_t period; /* 周期,单位:心跳节拍 */
uint32_t remaining; /* 剩余节拍数 */
timer_state_t state; /* 当前状态 */
timer_callback_t callback; /* 到期回调函数 */
void *arg; /* 回调参数 */
uint8_t is_periodic; /* 是否周期定时器 */
} soft_timer_t;
/* 初始化定时器模块 */
void soft_timer_init(void);
/* 创建定时器 */
void soft_timer_start(soft_timer_t *timer, uint32_t period,
timer_callback_t cb, void *arg, uint8_t periodic);
/* 停止定时器 */
void soft_timer_stop(soft_timer_t *timer);
/* 心跳中断处理函数(在硬件定时器中断中调用) */
void soft_timer_tick(void);
#endif /* SOFT_TIMER_H */
注意:soft_timer_tick函数通常在中断上下文中调用。所以回调函数里不能做耗时操作,比如打印、动态内存分配、阻塞等待。我曾经见过一个同事在回调里调了printf,结果系统直接卡死。回调里只应该做标记、发信号量、或者设置标志位。
实现文件的核心逻辑是这样的:
/* soft_timer.c */
#include "soft_timer.h"
static soft_timer_t *timer_list = NULL;
void soft_timer_init(void) {
timer_list = NULL;
}
void soft_timer_start(soft_timer_t *timer, uint32_t period,
timer_callback_t cb, void *arg, uint8_t periodic) {
/* 先停止,防止重复添加 */
soft_timer_stop(timer);
timer->period = period;
timer->remaining = period;
timer->state = TIMER_RUNNING;
timer->callback = cb;
timer->arg = arg;
timer->is_periodic = periodic;
/* 插入链表头部 */
timer->next = timer_list;
timer_list = timer;
}
void soft_timer_stop(soft_timer_t *timer) {
soft_timer_t **pp = &timer_list;
while (*pp != NULL) {
if (*pp == timer) {
*pp = timer->next;
timer->state = TIMER_STOPPED;
timer->next = NULL;
return;
}
pp = &(*pp)->next;
}
}
void soft_timer_tick(void) {
soft_timer_t *timer = timer_list;
while (timer != NULL) {
if (timer->state == TIMER_RUNNING) {
if (timer->remaining > 0) {
timer->remaining--;
}
if (timer->remaining == 0) {
timer->state = TIMER_EXPIRED;
/* 执行回调 */
if (timer->callback != NULL) {
timer->callback(timer->arg);
}
/* 周期定时器重新装载 */
if (timer->is_periodic) {
timer->remaining = timer->period;
timer->state = TIMER_RUNNING;
} else {
/* 单次定时器自动从链表移除 */
soft_timer_stop(timer);
}
}
}
timer = timer->next;
}
}
五、使用示例与避坑指南
来看看怎么用这个模块。假设我们要做一个LED闪烁,500ms亮,500ms灭:
#include "soft_timer.h"
static soft_timer_t led_timer;
void led_toggle(void *arg) {
(void)arg;
/* 翻转LED引脚 */
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
void main(void) {
/* 初始化硬件定时器,产生1ms心跳中断 */
hw_timer_init(1); /* 1ms一个节拍 */
/* 初始化软件定时器模块 */
soft_timer_init();
/* 启动周期定时器,500ms = 500个节拍 */
soft_timer_start(&led_timer, 500, led_toggle, NULL, 1);
while (1) {
/* 主循环做其他事情 */
}
}
/* 硬件定时器中断服务函数 */
void HW_TIMER_IRQHandler(void) {
soft_timer_tick(); /* 喂心跳 */
}
避坑指南:
- 我曾经在一个项目中,把心跳节拍设成了10ms,结果定时器精度完全不够用。建议根据实际需求选择节拍,一般1ms比较通用。
- 我曾经在中断里直接调用了
malloc分配定时器,结果堆碎片导致系统崩溃。定时器结构体最好静态分配,或者用内存池。 - 注意:如果定时器链表很长,
soft_timer_tick的执行时间会变长。建议控制定时器数量,或者用跳表/红黑树优化查找。
六、时间模块的集成与测试
把时间戳、格式化和定时器三个模块组合起来,就是一个完整的时间子系统。我通常会在系统启动时做一次时间同步,然后定时器模块开始工作,日志输出时自动带上格式化时间。
测试时,我会写一个简单的测试用例:
void test_time_module(void) {
char buf[TIME_STR_BUF_SIZE];
timestamp_t ts;
/* 测试时间戳获取 */
ts = timestamp_get();
printf("Current timestamp: %llu\n", (unsigned long long)ts);
/* 测试格式化 */
time_format(ts, buf, sizeof(buf));
printf("Formatted time: %s\n", buf);
/* 测试定时器 */
soft_timer_t test_timer;
soft_timer_start(&test_timer, 1000, test_callback, NULL, 0);
/* 模拟心跳 */
for (int i = 0; i < 1005; i++) {
soft_timer_tick();
}
/* 验证定时器到期 */
assert(test_timer.state == TIMER_EXPIRED);
}
嗯,这套设计我在多个项目里验证过,从STM32到ESP32,从FreeRTOS到裸机,都能直接复用。你只要把底层的硬件定时器接口和RTC接口适配一下,上层代码基本不用动。
时间模块看似简单,但设计得好,能省很多后期维护的精力。记住一句话:接口要通用,实现要隔离,测试要覆盖。这样不管换什么平台,你的时间模块都能稳稳地跑起来。
公众号:蓝海资料掘金营,微信deep3321