3. 时钟与定时器抽象:统一时钟配置接口,定时器中断与PWM输出的跨平台封装
时钟和定时器,说白了就是MCU的“心跳”和“秒表”。
我刚开始做跨平台移植那会儿,最头疼的就是这俩玩意儿。每家芯片的时钟树长得都不一样,定时器寄存器更是五花八门。你想想看,STM32的TIM、NXP的PIT、GD32的GPTM……光名字就够你记一阵子的。
但底层再怎么变,上层应用要的东西其实很简单:给我一个固定频率的时钟源,能产生定时中断,能输出PWM波。这就是我们今天要解决的问题——把“心跳”和“秒表”抽象成统一接口。
3.1 统一时钟配置接口的设计思路
时钟配置的痛点在哪?我举个例子。STM32F4要配一个168MHz的主频,你得先折腾HSE、PLL、AHB分频器、APB分频器……一套流程下来,寄存器得翻好几页。换到GD32F4,虽然内核一样,但时钟树寄存器偏移量全变了。
所以我的做法是:把时钟配置拆成三个层次。
| 层次 | 职责 | 跨平台关注点 |
|---|---|---|
| 应用层 | 调用 Clock_SetFreq(168000000) | 只关心目标频率,不关心时钟树 |
| 抽象层 | 定义 Clock_Config_t 结构体,统一描述时钟源、分频系数、PLL参数 | 提供标准化的配置参数格式 |
| 硬件层 | 实现具体芯片的寄存器操作 | 每个平台单独实现,但接口一致 |
抽象层的核心是一个结构体,我习惯这么定义:
/* 时钟源枚举 */
typedef enum {
CLOCK_SRC_HSE, /* 外部高速晶振 */
CLOCK_SRC_HSI, /* 内部高速振荡器 */
CLOCK_SRC_LSE, /* 外部低速晶振 */
CLOCK_SRC_LSI /* 内部低速振荡器 */
} Clock_Source_t;
/* 时钟配置结构体 */
typedef struct {
Clock_Source_t source; /* 时钟源 */
uint32_t freq_hz; /* 目标频率 */
uint8_t pll_mul; /* PLL倍频系数 */
uint8_t pll_div; /* PLL分频系数 */
uint8_t ahb_div; /* AHB总线分频 */
uint8_t apb1_div; /* APB1分频 */
uint8_t apb2_div; /* APB2分频 */
} Clock_Config_t;
3.2 定时器中断的跨平台封装
定时器中断,应用层要的就是一个回调函数,每隔固定时间执行一次。底层怎么配预分频器、怎么设自动重装载值、怎么清中断标志——这些都应该藏起来。
我设计的抽象接口长这样:
/* 定时器句柄——不透明指针,具体实现由硬件层定义 */
typedef void* Timer_Handle_t;
/* 定时器配置结构体 */
typedef struct {
uint32_t period_us; /* 定时周期,单位微秒 */
void (*callback)(void); /* 中断回调函数 */
uint8_t priority; /* 中断优先级 */
} Timer_Config_t;
/* 抽象接口 */
Timer_Handle_t Timer_Init(Timer_Config_t *cfg);
void Timer_Start(Timer_Handle_t htimer);
void Timer_Stop(Timer_Handle_t htimer);
void Timer_Deinit(Timer_Handle_t htimer);
这里有个关键点:句柄用 void*。为什么?因为不同芯片的定时器对象大小不一样。STM32的TIM_HandleTypeDef有几十个字节,而有些小芯片的定时器就是个寄存器基地址。用void*可以完美隐藏这些差异。
我曾经在一个项目里吃过亏。当时图省事,直接把STM32的TIM_HandleTypeDef暴露给了应用层。结果换到NXP平台,应用层代码改了一大片。从那以后,我坚持用不透明句柄,谁劝都不好使。
3.3 PWM输出的统一抽象
PWM输出比定时器中断稍微复杂一点。因为除了频率,还得管占空比、极性、对齐模式。但抽象思路是一样的:把硬件相关的寄存器操作封装在底层,上层只跟配置结构体打交道。
/* PWM通道枚举 */
typedef enum {
PWM_CH_0,
PWM_CH_1,
PWM_CH_2,
PWM_CH_3
} PWM_Channel_t;
/* PWM配置结构体 */
typedef struct {
uint32_t freq_hz; /* PWM频率 */
uint8_t duty_cycle; /* 占空比,0-100% */
PWM_Channel_t channel; /* 通道号 */
uint8_t polarity; /* 极性:0-高有效,1-低有效 */
} PWM_Config_t;
/* 抽象接口 */
PWM_Handle_t PWM_Init(PWM_Config_t *cfg);
void PWM_SetDuty(PWM_Handle_t hpwm, uint8_t duty);
void PWM_Start(PWM_Handle_t hpwm);
void PWM_Stop(PWM_Handle_t hpwm);
这里有个细节:占空比我用0-100的整数,而不是0.0-1.0的浮点数。为什么?因为嵌入式里浮点运算太慢了,而且很多MCU没有FPU。整数运算快,也够用。
3.4 整体架构与数据流
下面这张图展示了时钟与定时器抽象层的整体架构。你可以看到,应用层只跟抽象接口打交道,硬件差异被完全隔离在底层。
3.5 避坑指南与实战建议
做时钟和定时器抽象,有几个坑我踩过,分享给你:
- 时钟频率精度问题:内部振荡器(HSI/LSI)的精度通常只有±1%~±2%,如果你用内部时钟做精确延时或PWM,频率会漂。我建议对时序要求高的场景,强制使用外部晶振(HSE/LSE)。
- 定时器中断优先级:不同芯片的中断优先级分组不一样。STM32用4位优先级,NXP可能用3位。抽象层里最好只定义相对优先级(高/中/低),具体映射交给硬件层。
- PWM频率与占空比的分辨率:PWM频率越高,占空比可调步进就越粗。比如100kHz的PWM,定时器时钟72MHz,自动重装载值只有720,占空比只能调720档。这个限制是物理的,抽象层解决不了,但要在文档里写清楚。
Timer_SelfTest(),上电时跑一遍,检查定时器中断是否正常触发、PWM波形是否输出。这样换平台后,不用接示波器就能快速验证移植是否正确。
嗯,时钟和定时器的抽象就聊到这儿。说白了就是三句话:接口统一、句柄隐藏、分层隔离。你把这三点做到位了,跨平台移植就是换几个.c文件的事。