代码实现和硬件没关系,所以并不限于STM32,Arduino 之类的其他地方也能用,只要有一个能获取时间的函数就行,或者说,只要有一个会随着时间自动增加的变量就行,时间单位无所谓,所以确实想的话,拿到电脑上也能用。后面会用跑马灯程序来说明定时任务的玩法,可以左边目录直接跳过去。
动态任务
重点功能就是支持随机创建和删除任务,如果只是在初始化的时候固定的定义几个定时任务然后执行,那就没必要整个任务管理器,或者调度器,main
函数里放个循环,if-else 检查时间就行了。
举两个例子说明动态创建定时任务的使用场景:
- 检测到点击按钮后让蜂鸣器响一秒,如果这一秒期间按钮再次被点击,就将蜂鸣器关闭时间往后顺延;
- 点击按钮后让蜂鸣器响两声,然后再让电机转五秒;
首先排除软件硬延时的做法。这两个问题都可以用主函数里循环检查时间、设置状态的简单方法解决,只是如果同时还有一堆别的计时任务,主循环里横七竖八一大堆状态和时间变量,很快就不能看了。下面先用这两个例子看看定时任务调度器的大致用法,没兴趣的可以跳到后面看实现代码,或者先看实际例程,[用定时任务做个跑马灯](#实际例程 - 跑马灯)。
问题一
思路是:检测到点击后,调用一个函数点亮蜂鸣器,在这个函数里又注册一个定时一秒的任务,到时间就关闭蜂鸣器。此外,还要有一个状态变量,指示定时任务有没有执行,如果定时关闭的任务还没到时间,又检测到一次点击,就把先前创建的任务的倒计时重置,重新计一秒的时。
// DelayCallback 是任务管理器的类名,忽略一些后面实现中的细节,总之就是把任务管理器创建出来,定义为全局变量
DelayCallback task_mgr;
bool 蜂鸣器已关闭 = true; // 状态指示
// 定时关闭蜂鸣器的任务函数,返回uint32_t 的延时时间,
// 如果返回0,任务执行后就会被删除,也就是一次性延时任务,
// 否则将会按返回值再延时一次,用来实现自动重复执行的定时任务
uint32_t 关闭蜂鸣器() {
// 控制引脚关闭蜂鸣器
// ...
蜂鸣器已关闭 = true;
// 只执行一次
return 0;
}
void 蜂鸣器响一秒() {
static uint8_t index; // 这个变量是创建任务后返回的任务索引,用于后续操作任务,
// 按Windows 程序员的文化习惯,可以称之为“句柄”,[doge]
if(蜂鸣器已关闭) { // 判断定时任务的状态,若定时任务已关闭蜂鸣器并退出,则重新创建定时任务
// 控制引脚开启蜂鸣器
// ...
蜂鸣器已关闭 = false;
index = task_mgr.add_normal_task(关闭蜂鸣器, 1000); // 创建定时关闭任务,延时1 秒
}
else { // 若还没关闭,则重置任务的延时时间
task_mgr.reset_task(index, 1000);
}
}
void main() {
// ...
while(true) { // 主循环
task_mgr.tick(); // 任务管理器不是中断驱动的,要在主循环里手动调用,定时任务都在tick 函数内部调用执行
if(按键点击了) {
蜂鸣器响一秒(); // 时序控制都拿到外面去了,所以主循环里很干净
}
}
}
可见,使用定时任务后,操作逻辑更直观了,不用在主循环里折腾一堆状态转换,可以隔离每个功能模块的逻辑,不至于都混在一起,看的人眼花缭乱。
提前说明,例子中的代码并不是准确的用法,就是大致示意一下。实际创建DelayCallback
类的对象时,需要传入参数定义内部存储空间的大小,这个存储空间是静态分配的,尺寸固定,用来存放任务,就是个全局数组。运行中添加或删除任务时,添加的任务总数不能超过最初定义的最大数量,毕竟就是个静态数组,优点就是不用担心动态分配内存的问题。
上面的例子中定义了一个全局变量来传递定时任务的状态,但大家都说全局变量满天飞不是好习惯,所以为了让全局环境更干净,可以把定时任务放进一个对象里,如下:
DelayCallback task_mgr;
class 任务类 {
private:
bool 蜂鸣器已关闭 = true; // 状态变量定义为私有
uint8_t index; // 那个索引也从函数里拿出来
public:
uint32_t run() { // 关闭蜂鸣器的定时任务函数必须命名为run,暂时可以不用虚函数,原理后面会粗略提一嘴
// 操作引脚
蜂鸣器已关闭 = true;
}
void 蜂鸣器响一秒() {
if(蜂鸣器已关闭) { // 判断定时任务的状态,若定时任务已关闭蜂鸣器并退出,则重新创建定时任务
// 控制引脚开启蜂鸣器
// ...
蜂鸣器已关闭 = false;
index = task_mgr.add_normal_task(this, 1000); // 把这个对象自身送进任务管理器里,延时1 秒执行run 函数
}
else { // 若还没关闭,则重置任务的延时时间
task_mgr.reset_task(index, 1000);
}
}
};
void main() {
任务类 obj; // main 函数正常情况不会退出,所以把任务对象定义成局部变量也没关系
// 要是更讲究一点,任务管理器也不用定义成全局的,创建任务对象时当参数传进去就行了
while(true) { // 主循环
task_mgr.tick();
if(按键点击了) {
obj.蜂鸣器响一秒(); //
}
}
}
有没有变的更整洁呢?不好说,可能有人就是对面向对象深恶痛绝,因为找不到对象[doge]。实际上这么整确实能用,但任务管理器里就只能添加任务类
这一个类的对象了,也就等于只能用这一个run
函数,想添加多种任务对象的话,还是得整虚函数,利用多态。
问题二
问题二涉及了在定时任务函数里再创建一个定时任务。只是要让蜂鸣器响两声,思路很简单:定时任务一共执行四次,把点亮蜂鸣器的代码也放进定时任务函数里,添加任务后让函数立即执行一次,把蜂鸣器点亮,然后定时关、开、关。响两次后,启动电机,由蜂鸣器任务再添加一个五秒后关闭电机的任务。
DelayCallback task_mgr;
uint32_t 关电机() {
// ...
return 0; // 只执行一次
}
uint32_t 响两次() {
static int count = 4;
if(count > 0) {
// 反转引脚,第一次打开蜂鸣器,第二次关闭,第三次打开,第四次关闭,也就是响两次
// ...
--count;
return 1000; // 延时1 秒后任务函数将再次执行,切换蜂鸣器的状态
}
count = 4; // 四次执行完毕,复位计数器
// 启动电机 ...
task_mgr.add_impatient_task(关电机, 5000); // 在定时任务执行中添加另一个定时任务
// impatient_task,就是没耐心的任务,可以视为优先级更高,
// 不会因为某些设置而被跳过,只要到了时间,tick 函数一定会执行它
return 0; // 返回0,让任务管理器删除这个任务
}
void main() {
while(true) {
if(按键点击了) {
task_mgr.add_normal_task(响两次, 0); // 添加响两次任务,参数0 表示立即执行一次。
// 就是先就地调用函数,如果函数的返回值大于0,按照返回值
// 添加任务。如果返回值等于0,任务不会再被定时执行。
// 就相当于:
// uint32_t ret = 响两次();
// task_mgr.add_normal_task(响两次, ret);
}
}
}
虽说也可以不分开添加那个关电机任务,就放在响两次里面,只是演示一下可以这么干。不过运行中删除任务是不允许的,也不允许在中断函数里添加或删除任务。想在中断里控制任务,只能设置标志位,然后在主循环里控制。
主循环里添加响两次任务时,延时时间参数填了0,作用就是上面注释里说的。这种用法在添加任务的时候不管它的延时时间,让任务函数自己填参数,是个比较方便且不容易出错的方式,适合多次重复执行的任务,比如持续闪灯。
另外,不像问题一里的代码,这个例子里没有处理按键重复点击的情况,万一重复点了,就会重复创建响两次任务。同一个函数可以多次添加,会被视为不同的任务,所以如果不加处理,运行中就会导致奇怪的BUG,比如蜂鸣器多响了几次,电机该关的时候又突然重启了。倒是可以在添加任务时加上限制,一个函数只能对应一个任务,可能会更安全,但添加任务时就得把任务全部搜索一遍,多消耗时间,而且有时候可能就需要重复添加。后面再说“没耐心任务”
实现调度器
开门见山吧,先是全部的代码,然后是里面的细节。不用说,这都是C++ ,至少要用C++14 标准,MDK 现在也支持。
// Copyright (c) 2023 刻BITTER
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
#pragma once
#include <type_traits>
#define GET_TASK_MGR_TIME_TYPE(scheduler) typename decltype(scheduler)::TimeType
#define GET_TASK_MGR_INDEX_TYPE(scheduler) typename decltype(scheduler)::IndexType
namespace scheduler_basic {
#ifdef __PERF_COUNTER__
#include "perf_counter.h"
// 用来提供时间戳的函数必须放在一个类或结构体里,才能作为模板参数传入DelayCallback 中
// 时间戳函数的原型为:
// inline static auto get_time()
//
// 因为只用于转发其他库的时间函数,所以可定义为inline,返回值也定义为auto,跟着被调用的底层函数走。
// 函数必须是静态的,不能引用类里的非静态成员,因为DelayTask 中不会多余创建一个对象再调用get_time()。
// 如果get_time() 放在类里,要设为public
struct PerfCounterMsSource {
inline static auto get_time() {
return get_system_ms();
}
using TimeType = decltype(get_system_ms());
};
struct PerfCounterUsSource {
inline static auto get_time() {
return get_system_us();
}
using TimeType = decltype(get_system_us());
};
struct PerfCounterTicksSource {
inline static auto get_time() {
return get_system_ticks();
}
using TimeType = decltype(get_system_ticks());
};
#endif
#ifdef ARDUINO
#include "Arduino.h"
struct ArduinoMsSource {
inline static auto get_time() {
return millis();
}
using TimeType = decltype(millis());
};
struct ArduinoUsSource {
inline static auto get_time() {
return micros();
}
using TimeType = decltype(micros());
};
#endif
/**
* @brief DelayCallback 支持在运行时输出日志,比如提供任务运行时间、tick 运行时间,CPU 时间占用比例之类的信息。
*
* 与时间源一样,输出日志所用的函数也通过一个结构体作为模板参数传入。结构体内需包含四个函数:
*
* static auto enabled() : 返回0 或false 时关闭日志输出功能.返回值可以依赖于变量,所以能实现动态开关功能;
* static void log(const char *) : 输出字符串;
* static void log(uint32_t) : 输出整数,其中整数的类型可以自由设定,需要输出的最大的整数是时间戳,所以整数类型最好和时间戳类型一致;
* static void newline() : 输出换行或其他分隔符
*
* 默认Logger 是_DummyLogger,其中enabled 函数返回false 常量,关闭了日志输出,所以三个输出函数的实现都是空的
*
*/
struct _DummyLogger {
inline static constexpr auto enabled() {
return false;
}
inline static void log(const char *text) {}
inline static void log(uint32_t value) {}
inline static void newline() {}
};
/**
* @brief 用于释放CallbackPtr 指向的资源
*
* DelayCallback 在运行中删除任务时,将会调用destroy 函数删除任务指针。这是为了给动态创建的临时任务提供方便,
* 因为临时任务的创建者可能早就退出了,如果DelayCallback 删除任务时不做处理,就会产生内存泄漏。
* 动态任务可以是new 创建的,也可以是在内存池中分配的,DelayCallback 不关心底层细节。
*
* 由于任务列表中,一个CallbackPtr 可以对应多个任务,所以删除指针时需要实现引用计数功能。
* DelayCallback 会在添加任务时调用new_task 函数,可在其中将CallbackPtr 指向的对象中的引用计数加一。
*
* new_task 返回值用于控制要不要添加任务,返回false 则丢弃。这样可以控制一个任务对象只能被添加一次。
*
* @tparam CallbackPtr
*/
template <typename CallbackPtr>
struct _DummyDestroyer {
static constexpr auto enabled() {
return false;
}
static bool new_task(CallbackPtr fptr) {}
static void destroy(CallbackPtr fptr) {}
};
/**
* @brief 存储任务信息的结构体
*
* 由于这只是个简单的调度器,任务结构体也就尽量简单,只有回调函数的指针和计时器这两个必要的成员。
* 任务计时采用倒计时原理,每次在tick 函数中更新所有任务的倒计时数值,原因是避免计时器溢出导致BUG。
*
* 调度器只支持在一段时间后运行指定的任务,本身不支持循环执行,这也是为了实现简单。想实现延迟指定时间后运行的功能,一般可以有三种思路:
*
* 1. 任务结构体内存储一个时间戳T1,代表预定要执行的时间。由于计时器溢出的问题,这种设计容易BUG。
*
* 2. 任务结构体内记录添加任务时的时间戳T0 和延迟时间ΔT,用当前时间T 减去T0 得到时间间隔,再和ΔT 比较。
* 这是Arduino 社区推荐的delay 函数的实现方式,不会因为一次计时器溢出导致BUG,最大延时时间为一个溢出周期。
* 但结构体里要多放一个TimeType 类型的变量,空间效率差一点。
*
* 3. 结构体里只放一个倒计时器,也就是这里用的方法,每次tick 函数检查任务状态时更新所有任务的倒计时器,
* 倒计时器减去的值等于两次tick 执行的间隔,当任务的倒计时剩余时间小于等于要减去的间隔时,说明任务延时结束。
*/
template <typename TimeType, typename CallbackPtr>
struct TaskBox {
TimeType down_counter;
CallbackPtr fptr;
};
// 可以使用对象包装回调函数,但调度器内部链表中存储的还是TaskBox,只是其中的回调函数指针换成了指向对象的指针
template <typename TimeType>
class TaskBase {
public:
virtual TimeType run() = 0;
};
template <typename TimeType>
using FunctionPtr = TimeType (*)();
template <typename TimeType>
using TaskBasePtr = TaskBase<TimeType> *;
// 检查CallbackPtr 是否和FunctionPtr 类型一致
template <typename CallbackPtr, typename TimeType>
TimeType _call_callback(CallbackPtr fptr,
typename std::enable_if_t<
std::is_same<FunctionPtr<TimeType>, std::decay_t<CallbackPtr>>::value> * = nullptr) {
return fptr();
}
// 检查CallbackPtr 指向的对象中是否含有TimeType run() 函数
template <typename CallbackPtr, typename TimeType>
TimeType _call_callback(CallbackPtr fptr,
typename std::enable_if_t<
std::is_same<TimeType, decltype(std::declval<std::remove_pointer_t<CallbackPtr>>().run())>::value> * = nullptr) {
return fptr->run();
}
/**
* @brief 底层改用数组作为任务列表的定时任务管理器
*
* 由于链表和数组在操作上的巨大差别,还是整个重写一份比较方便。
*
* @tparam TimeSource
* @tparam MaxTaskCount 内部用于存储任务的数组的长度,最大不超过32
* @tparam CallbackPtr
* @tparam Logger
* @tparam CallbackDestroyer
*/
template <typename TimeSource, size_t MaxTaskCount,
typename CallbackPtr = FunctionPtr<decltype(TimeSource::get_time())>,
typename CallbackDestroyer = _DummyDestroyer<CallbackPtr>,
typename Logger = _DummyLogger>
class DelayCallback2 {
public:
using TimeType = decltype(TimeSource::get_time());
using IndexType = uint8_t;
private:
using TaskType = TaskBox<TimeType, CallbackPtr>;
TaskType _task_list[MaxTaskCount] = {0};
// 用变量中的一个bit 表示数组中对应位置的任务是不是impatient 任务,
// 所以数组最大不能超过变量的位数
uint32_t _impatient_flag_bit = 0;
constexpr static size_t _MAX_MAX_COUNT = sizeof(_impatient_flag_bit) * 8;
// 标记任务是否刚被添加,用于支持定时任务中添加新任务
uint32_t _new_task_flag_bit = 0;
// 上一次tick 被调用的时间,在初始化后,_last_tick_time 是第一个任务被添加进列表的时间
TimeType _last_tick_time = 0;
uint16_t _max_duration_of_one_tick;
IndexType _max_task_count_in_one_tick;
IndexType _current_task_count;
auto test_impatient_bit(uint8_t index) {
return _impatient_flag_bit & (1 << index);
}
void set_impatient_bit(uint8_t index) {
_impatient_flag_bit |= (1 << index);
}
void clr_impatient_bit(uint8_t index) {
_impatient_flag_bit &= ~(1 << index);
}
auto test_new_task_bit(uint8_t index) {
return _new_task_flag_bit & (1 << index);
}
void set_new_task_bit(uint8_t index) {
_new_task_flag_bit |= (1 << index);
}
void clr_new_task_bit(uint8_t index) {
_new_task_flag_bit &= ~(1 << index);
}
void clr_all_new_task_bit() {
_new_task_flag_bit = 0;
}
public:
DelayCallback2(uint16_t max_duration_of_tick, uint8_t max_task_count_in_tick) :
_max_duration_of_one_tick(max_duration_of_tick), _max_task_count_in_one_tick(max_task_count_in_tick) {
static_assert(MaxTaskCount <= _MAX_MAX_COUNT);
}
void tick() {
// TODO: 添加日志输出和信息统计
uint8_t task_counter = 0;
auto start_time = TimeSource::get_time();
// tick interval 是两次tick 开始的时间差,即上次tick 的运行时间加上退出tick 后主程序的运行时间
auto tick_interval = start_time - _last_tick_time;
_last_tick_time = start_time;
if (Logger::enabled()) {
Logger::log("[DelayCallback2]: tick start at: ");
Logger::log(start_time);
Logger::newline();
}
clr_all_new_task_bit();
for (uint8_t i = 0; i < MaxTaskCount; ++i) {
// 只能固定的遍历整个列表,不能根据当前任务总数提前终止遍历。因为允许遍历途中添加任务,所以任务总数有可能增加,
// 但添加的任务可能在当前遍历位置的前面,结果一直到列表遍历结束,检测到的任务数和任务总数都对不上。
// 可以用双条件,即,任务总数和列表总长是或的关系,只要有一个达到,就结束遍历。但这样做性价比可能很低,
// 任务列表最长只有32,大部分时候可能十来个就够了,如果设计程序时的估算比较准,列表大部分时间是比较满的,
// 那么遍历整个表的额外开销就更小了。
if (_task_list[i].down_counter == 0 || test_new_task_bit(i)) {
continue;
}
if (_task_list[i].down_counter <= tick_interval) { // 执行并处理返回值
// 如果正在遍历impatient 任务,则不管超时时间和任务数量限制,必须执行任务
if (test_impatient_bit(i)
|| ((_max_task_count_in_one_tick == 0 || task_counter < _max_task_count_in_one_tick)
&& (_max_duration_of_one_tick == 0 || ((TimeSource::get_time() - start_time) < _max_duration_of_one_tick)))) {
++task_counter;
_task_list[i].down_counter = _call_callback<CallbackPtr, TimeType>(_task_list[i].fptr);
if (_task_list[i].down_counter == 0) {
remove_task_from_list(i);
}
}
else {
_task_list[i].down_counter = 1; // 把本次没轮到的任务的倒计时设为足够小的数
}
}
else {
_task_list[i].down_counter -= tick_interval;
}
// 倒计时为0 的任务被视为已删除
}
if (Logger::enabled()) {
Logger::log("[DelayCallback2]: tick stopped, time consumption: ");
Logger::log(TimeSource::get_time() - start_time);
Logger::newline();
Logger::log("[DelayCallback2]: executed task count:");
Logger::log(task_counter);
Logger::newline();
Logger::log("[DelayCallback2]: tick interval: ");
Logger::log(tick_interval);
Logger::newline();
}
}
/**
* @brief 将上一次tick 的时间设置为当前时间
*
*/
void reset_last_tick_time() {
_last_tick_time = TimeSource::get_time();
}
bool not_full() const {
return _current_task_count < MaxTaskCount;
}
/**
* @brief 在tick 函数调用后一次性执行的最大任务数
*
* 一次tick 中执行太多耗时的任务会占用过多CPU 时间,还会导致下一次tick 被推迟,从而降低任务执行的实时性。
* 值为0 则不限制,值大于1 表示最多只执行这个数值的任务,其他任务只更新倒计时,不执行。
* 列表中的impatient 任务也会被计数,但tick 函数只有在impatient 任务全部执行后才退出。
* 即,当最大任务数小于等于impatient 任务数时,tick 函数在执行完所有impatient 任务后退出。
* 若最大任务数大于impatient 任务数,则会继续执行普通任务,直到等于最大任务数。
*
* @param max_count
*/
void set_max_task_counter_in_tick(uint8_t max_count) {
_max_task_count_in_one_tick = max_count;
}
/**
* @brief 一次tick 能执行任务的最长时间
*
* 若值不为0,则每次任务退出时在tick 函数中检查时间,若超时,后续的任务只更新倒计时,不执行。
* 与`_max_task_count_in_one_tick` 相同,若存在impatient 任务,则tick 函数在所有impatient 任务执行后才退出。
* 若_max_task_count_in_one_tick 和_max_duration_of_one_tick 都不为0,则两者同时生效,
* 只要超过其中一个的限制,tick 函数就不再执行任务。
*
* @param max_duration
*/
void set_max_duration_of_tick(uint16_t max_duration) {
_max_duration_of_one_tick = max_duration;
}
private:
/**
* @brief 创建新任务
*
* @param fptr
* @param delay_time 延迟时间,若值等于0,则立即执行一次,使用返回值创建任务
* 若返回值还是0,任务不会被加入列表
*
* @return IndexType 若任务添加失败,返回MaxTaskCount
*/
IndexType _add_task_to_list(CallbackPtr fptr, TimeType delay_time) {
do {
if (delay_time == 0) {
delay_time = _call_callback<CallbackPtr, TimeType>(fptr);
if (delay_time == 0) {
break;
}
}
if (not_full()) {
if (CallbackDestroyer::enabled()) {
if (!CallbackDestroyer::new_task(fptr)) {
break;
}
}
if (_current_task_count == 0) {
this->reset_last_tick_time();
}
uint8_t index;
for (index = 0; index < MaxTaskCount; ++index) {
if (_task_list[index].down_counter == 0)
break;
}
++_current_task_count;
_task_list[index].down_counter = delay_time;
_task_list[index].fptr = fptr;
set_new_task_bit(index);
return index;
}
} while (0);
return MaxTaskCount;
}
public:
/**
* @brief 创建一个普通任务,延时delay_time 后执行
*
* @param fptr
* @param delay_time 若值为0,则立即执行一次,然后再根据返回值加入任务列表
* 若返回值还是0,任务不会被加入列表
*
* @return IndexType 任务的索引。若任务添加失败,返回MaxTaskCount,
*/
IndexType add_normal_task(CallbackPtr fptr, TimeType delay_time) {
auto i = _add_task_to_list(fptr, delay_time);
clr_impatient_bit(i);
return i;
}
/**
* @brief 创建一个没耐心的任务,延时delay_time 后执行。
*
* 没耐心(impatient) 的任务是指:
*
* 1. 执行耗时较短;
* 2. 不允许因为tick 超时而被跳过不执行;
*
* @param fptr
* @param delay_time 若值为0,则立即执行一次,然后再根据返回值加入任务列表
* 若返回值还是0,任务不会被加入列表
*
* @return 任务的索引。若任务添加失败,返回MaxTaskCount,
*/
IndexType add_impatient_task(CallbackPtr fptr, TimeType delay_time) {
auto i = _add_task_to_list(fptr, delay_time);
set_impatient_bit(i);
return i;
}
private:
void remove_task_from_list(IndexType index) {
if (index < MaxTaskCount) {
--_current_task_count;
_task_list[index].down_counter = 0;
if(CallbackDestroyer::enabled()) {
CallbackDestroyer::destroy(_task_list[index].fptr);
}
}
}
public:
/**
* @brief
*
* @param index
*/
void remove_normal_task(IndexType index) {
remove_task_from_list(index);
}
/**
* @brief
*
* @param index
*/
void remove_impatient_task(IndexType index) {
remove_task_from_list(index);
}
/**
* @brief 重置一个正在列表中的任务的倒计时
*
* 注意,这个函数无法判断任务有没有被删除
*
* @param index
* @param delay_time 若为0 则无动作
*/
void reset_task(IndexType index, TimeType delay_time) {
if (delay_time == 0)
return;
_task_list[index].down_counter = delay_time;
}
};
} // namespace scheduler_basic
这篇博客是CC BY-SA 4.0 协议,也就是署名、相同协议传播。这部分代码是MPL 2.0 协议,前面注释上也写了,要求署名,使用后也要开源,即所谓的传染性。详细的参考:开源协议专题(六):GPL、LGPL、MPL。
代码乍一眼看上去可能有一些不明所以的地方,比如DelayCallback
变成了DelayCallback2
,这个原因是,我确实写了个DelayCallback
,但那个的底层是用链表存储任务的,写完以后觉得这都是啥,为什么要用链表,这么麻烦又没什么用,所以就变成了DelayCallback2
,改用简单的数组存储任务。
粗浅的使用了一些模板技术,用来提高扩展性和适应性,比如前面示例里面定义任务,既可以用函数指针,又可以用对象的指针,这个背后就是利用模板实现的。要说复杂吧,对没怎么接触过的人确实有点复杂,所以我不会在这里详细介绍实现原理,只说明要怎么用,最多在说明注意事项的时候提一嘴细节。
实际用法
定义调度器对象
开头的例子下面说了,要使用调度器,定义对象的时候得传入一些参数,不过除此之外,示例代码里其他的地方和实际使用是一致的,所以使用上并没有难度,就那么两三个函数。要定义调度器的对象,最简单的写法是:
namespace sb = scheduler_basic;
sb::DelayCallback2<sb::ArduinoMsSource, 10> task_mgr(0, 0);
// 这种定义的调度器只能用函数指针添加任务,且函数类型必须和下面这个一致,就是要返回uint32_t 类型,没有形参
uint32_t 任务函数() {
// ...
}
第一行是给命名空间起了个别名,叫sb
,因为scheduler_basic
太长了,用着不太方便。这个名字的意思是“基本的调度器”,可能叫“简陋的调度器” 更合适,毕竟“基本”、“基础”也有最重要的意思。第三行实际定义了调度器对象。
1、时间源
第一个模板参数是时间源,sb::ArduinoMsSource
的意思是让调度器使用Arduino 里的毫秒时间戳函数millis()
给它提供时间,所以这个调度器对象的所有函数中,提到时间时,单位都是毫秒。也可以用sb::ArduinoUsSource
换成微秒的时间函数micros()
。要是实在不知道模板是什么,可以理解成宏替换,类里面所有用到时间的地方会被替换成设置的东西。
另外,当然,要用Arduino 的函数,首先得有。Arduino 以外,还支持用perf_counter 库提供时间,这个库基于CMSIS,理论上所有Cortex-M 芯片都能用,而且使用很方便,基本上把源码文件复制粘贴到项目里就能用了,库的项目地址:https://github.com/GorgonMeducer/perf_counter。
用perf_counter 的话,有三种配置,sb::PerfCounterMsSource
,sb::PerfCounterUsSource
,sb::PerfCounterTicksSource
,对应的时间单位分别是毫秒、微妙、CPU 时钟周期。除了内置的这五种选项,也可以使用自定义的时间函数,写法很简单,后面再说。
注意:任务函数的返回值类型必须和时间函数的返回类型相同,否则会编译失败。比如Arduino 的类型是uint32_t
,所以例子里一直都用的uint32_t
。用错类型会导致编译出错,不会把错误留到运行时。想自动匹配时间类型的话,可以在定义调度器对象后获取其中使用的时间类型:
// 用宏获取类型
using TimeType = GET_TASK_MGR_TIME_TYPE(task_mgr);
// 或者手敲
using TimeType = decltype(task_mgr)::TimeType;
然后任务函数就定义成:
TimeType 任务() {
// ...
}
2、最大任务数
时间源右边的10
定义了最大任务数是10,就是在对象里定义了一个长度为10 的任务数组。每个任务在32 位单片机上占用两个32 位空间,即8 字节,所以能容纳最大10 个任务的数组要占用80 字节。再加上调度器里的其他变量,这个对象的总占用应该是96 字节,我觉得已经够省了。顺便一说,用链表的那个版本,10 个任务要120 字节。
再说一遍,这里定义了最大任务数以后就不能更改了,没有运行中动态扩展的方法,所以RAM 够的话,数值可以给大一点,免得后面遇到奇怪的BUG,检查半天发现是调度器满了,新任务加不进去。
注意:最大任务数不能超过32,否则会编译失败。 这和内部实现有关,不过想扩展到64 也很简单,再要往大可能就有点麻烦,反正32 个应该够用了。
3、超时控制参数 - 无耐心任务
圆括号里两个参数0, 0
都是超时控制参数,第一个参数定义一次tick 中能执行任务的最长时间max_duration
,第二个是最多能执行的任务数max_task_count
。执行每个任务前会检查这两个超时参数,如果从tick 函数开始执行起,经过的时间已经大于max_duration
,则不会再执行剩下的普通任务。max_task_count
同理,执行的任务总数超过时,剩下的普通任务都会被跳过。被跳过的任务会处于就绪状态,在下一次tick 时,只要没超时,就会被执行。
普通任务就是用add_normal_task
添加的任务。用add_impatient_task
添加的无耐心任务不会被跳过,即便已经超时,也还会被执行。超时参数值等于0 时关闭对应的超时控制,都取0 则完全关闭超时控制功能,此时无耐心任务和普通任务没区别。
注意:两个超时参数如果取的太小,都可能导致一部分普通任务永远执行不到。我自己也没想明白取值多少合适,没出现问题的话就都取0,关闭超时控制。
其他模板参数
先把DelayCallback2
的声明复制过来:
/**
* @brief 底层改用数组作为任务列表的定时任务管理器
*
* 由于链表和数组在操作上的巨大差别,还是整个重写一份比较方便。
*
* @tparam TimeSource
* @tparam MaxTaskCount 内部用于存储任务的数组的长度,最大不超过32
* @tparam CallbackPtr
* @tparam Logger
* @tparam CallbackDestroyer
*/
template <typename TimeSource, size_t MaxTaskCount,
typename CallbackPtr = FunctionPtr<decltype(TimeSource::get_time())>,
typename CallbackDestroyer = _DummyDestroyer<CallbackPtr>,
typename Logger = _DummyLogger>
class DelayCallback2;
从上到下,第一个参数TimeSource
,配置时间源;第二个参数MaxTaskCount
,定义任务容量,这两个前面已经说清楚了。用模板参数传参的好处是不会有额外的资源消耗,时间函数不是以函数指针的形式保存在调度器里的,而是像宏替换一样,把对象的方法里所有调用时间函数的地方替换成调用传入的时间函数。其他模板参数也一样,一经定义,就相当于写死了。
3、CallbackPtr 回调指针 - 使用任务对象
这个参数用来定义添加任务的方式,或者说是定义任务的形式,默认参数是FunctionPtr
,即函数指针。上面最简单的调度器定义形式中没有指定这个参数,所以就按照默认,调度器只接受函数指针作为任务。前面的例子中用过一次任务类,此时调度器要按如下定义:
namespace sb = scheduler_basic;
sb::DelayCallback2<sb::ArduinoMsSource, 10, 任务类*> task_mgr(0, 0); // 将任务类的指针作为回调指针,
// 也就是要用指向任务类对象的指针添加任务
如果要使用多种任务类,必须把run
函数定义为虚函数,并且让所有任务类有一个共同的基类,再用这个基类的指针作为回调指针。完整代码里已经写了一个可用的基类TaskBase
作为示范,把它复制过来:
template <typename TimeType>
class TaskBase {
public:
virtual TimeType run() = 0;
};
run
函数的返回类型也必须和时间类型一致,所以TaskBase
有一个模板参数,传入所用的时间类型。如果用TaskBase
作为基类,首先要确定时间类型TimeType
:
// 由于此时调度器还没定义,不能从中获取时间类型
using TimeType = uint32_t; // 要么手动匹配
using TimeSource = sb::ArduinoMsSource // 要么从所选的时间源里获取
using TimeType = decltype(TimeSource::get_time()) // 可以获取时间函数的返回值类型
using TimeType = TimeSource::TimeType; // 也可以直接从时间源里获取内部定义的类型
确定了TaskBase
后,才能定义调度器:
using TaskBaseType = sb::TaskBase<TimeType>; // 用时间类型确定TaskBase 的类型
// 然后就能定义调度器对象
sb::DelayCallback2<sb::ArduinoMsSource, 10, TaskBaseType*> task_mgr(0, 0);
这样一来,所有任务都必须是TaskBase
的子类,使用方法如下:
class BlinkTask : public TaskBaseType {
public:
virtual TimeType run() override {
// 切换LED 引脚状态
return 1000;
}
};
void main() {
BlinkTask blink;
task_mgr.add_normal_task(&blink, 0);
while(true) {
task_mgr.tick();
// ...
}
}
类里面只有一个虚函数,所以不会有太多的额外资源消耗。编译实测,每个子类只会额外占用8 个字节的程序空间,用来存放虚函数表。任务对象里应该会多占用4 个字节,存放指向虚函数表的指针。
4、CallbackDestroyer 任务对象销毁器 (驱逐舰 [doge])
这个属于比较“高级”的功能,后面再解释,一般没用,默认不启用。某些时候,任务对象可以是用new
在堆里动态创建的,那就需要管理任务对象的生命周期,调度器里不能只删除任务,还要delete
对应的任务对象,不然会内存泄漏。CallbackDestroyer
就是用来告诉调度器应该怎么处理任务对象的,具体而言,和时间源一样,就是传入了用来销毁任务对象的函数。默认不启用这个功能,所有管理代码会被编译器优化掉,直接删除。
5、Logger 日志输出器
更没用的功能,默认不启用,不用管。和时间源一样,这个参数也用来传入几个输出日志的函数,定义调度器输出日志的方式,比如可以让它往串口输出,也可以往OLED 屏幕上输出。
成员函数
0、tick 函数
不知道前面有没有说过,调度器的tick()
函数只能、也必须在主循环里不停调用,从而让调度器可以检查时间,执行任务。用了定时任务以后,主循环里应该尽量不使用硬延时,包括从主循环里直接调用的函数,比如软件I2C 函数,里面可能会有个死循环,用来检测有没有ACK,主循环里使用这些耗时的函数会耽误下一次tick
执行的时机。不过一般应该也没什么,反正大部分定时任务对时间的要求不算高,要求高的就在中断里手写吧,或者别用这种简陋的调度器了,换RTOS。
除了让调度器运行的tick
函数,最常使用的应该是任务添加函数。
1、增加任务
/**
* @brief 创建一个普通任务,延时delay_time 后执行
*
* @param fptr 指向任务函数或任务对象的指针
*
* @param delay_time 延时执行的时间。若值为0,则立即执行一次,然后再根据返回值加入任务列表
* 若返回值还是0,任务不会被加入列表。
*
* @return IndexType 用于操作任务的索引,后续删除任务时可用于唯一确定本次创建的任务
*/
IndexType add_normal_task(CallbackPtr fptr, TimeType delay_time);
// 创建一个没耐心任务
IndexType add_impatient_task(CallbackPtr fptr, TimeType delay_time);
IndexType
的返回值就是开头示例中用过的任务索引,实际类型是uint8_t
。其他就没什么好说的,就是示例里的用法,如果不使用超时控制,普通任务和没耐心任务是一样的。
可以在主程序和定时任务内部添加任务,不支持用中断函数直接修改调度器。
2、删除任务
/**
* @brief 将指定的普通任务删除,可以阻止任务下次执行
*
* @param index 任务的索引,即添加任务时获得的返回值
*/
void remove_normal_task(IndexType index);
// 删除无耐心任务
void remove_impatient_task(IndexType index);
主动删除任务的函数应该不常用,更安全的做法是设置标志位,让任务执行时遇到标志就自我了断,返回0。只能在主程序或主循环里删除任务,或者说,只能在调度器不运行的时候删除任务,定时任务内部或中断函数里都不能删除任务。
3、修改任务
/**
* @brief 重置一个正在列表中的任务的倒计时
*
* 注意,这个函数无法判断任务有没有被删除
*
* @param index
* @param delay_time 若为0 则无动作
*/
void reset_task(IndexType index, TimeType delay_time);
这个函数在开头的例子里用过,就是重新设置还没执行的任务的延时时间,使用场景前面也说了。要注意的是,这个函数内部不会检查任务是不是已经被删除了,因为检查不了。一个任务对应数组里一小块内存空间,任务被删除,之前返回的任务索引就失效了,要是又添加了任务,那块内存里的内容就变了。此时如果用失效的索引重置任务,实际操作的就是另一个东西,有点像野指针。所以使用这个函数前,必须检查任务反馈的标志位,确定要操作的任务还活着。
此外,这个函数只适用于一次性执行的任务,多次重复的任务会自己更新延时数值,想修改的话,只能把延时数值设置成全局变量,比如下面这样:
TimeType 定时周期 = 1000; // 默认的定时执行周期是1 秒
TimeType 闪灯任务() {
// ...
return 定时周期; // 任务函数里返回的延时时间是全局变量,所以可以在外面修改闪灯的频率
}
或者用任务对象,不用设置一个全局变量。
4、检查
bool not_full() const; // 返回当前是否还有空闲空间用来添加新任务
没什么好说的,一般也没用,因为就算检测到调度器满了,接下来也没什么好办法处理,要添加的任务添加不进去,就卡在那儿了。一种处理方法是暂时不添加,主循环里检测到没添加成功,之后继续重复尝试添加,直到有别的任务执行完腾出了地方。
5、重置参考时间
/**
* @brief 将上一次tick 的时间设置为当前时间
*
*/
void reset_last_tick_time()
不常用。这个函数的作用和tick
函数检查时间的原理有关,每次tick
中要更新任务的倒计时,倒计时减完,任务就该执行了,而倒计时要减去的时间就是上一次tick
到这次tick
的时间间隔。这个函数就是把上一次tick
的时间重置到现在。emmm,作用嘛,就是刚初始化调度器之后,因为不存在上一次tick
,所以需要重置一次时间,但是也不用手动调用它,初始化以后,添加第一个任务时会自动重置时间。
6、设置超时控制参数
void set_max_task_counter_in_tick(uint8_t max_count); // 设置任务数量
void set_max_duration_of_tick(uint16_t max_duration); // 设置执行时间
这两个参数在调度器里是用变量存储的,可以动态设置。比较特别的是超时时间,输入参数的类型是uint16_t
,而时间的参数大部分都是uint32_t
。这么做的目的当然是为了省下几个字节的内存,而且uint16_t
按理说也够了吧,只是定义一小段超时时间而已,最大65535 个时间单位。
实际例程 - 跑马灯
拿一段例程来总结一下上面提到的“知识点”[doge],实现四个LED 依次循环点亮的跑马灯程序:前一个灯亮200 毫秒,然后熄灭,下一个灯点亮。这个程序至少有两种实现思路,第一种是把控制LED 的四个引脚放在数组里,定时任务从里面取出一个引脚,点亮、熄灭,然后取下一个引脚,依次循环,这种太简单了,只用到一个定时任务,达不到示例的效果。
如果考虑单个LED 的亮-灭周期,可以发现,每个LED 都是点亮200 毫秒,然后熄灭600 毫秒,只是它们的顺序错开了200 毫秒,或者说相位依次滞后200 毫秒,如下图:
那么思路就是:用一个定时任务间隔200 毫秒创建4 个控制LED 亮灭的任务,每个任务控制一个LED 亮200 毫秒,灭600 毫秒。其实因为任务执行的时序不精密,这种方式并不如第一种思路好,而且万一要增加到5 个灯,就得改延时参数,也不如第一种思路方便。而且也不用在定时任务里创建子任务,一次性创建4 个任务,初始延时依次增加200 毫秒就行了。但是用任务生任务的玩法比较花~ 所以就这么写。
// 定时任务里要携带一个参数,就是它控制的引脚,所以必须用任务对象
namespace sb = scheduler_basic;
using TimeSource = sb::ArduinoMsSource;
using TimeType = TimeSource::TimeType;
// 使用已经定义的这个基类
using TaskBaseType = sb::TaskBase<TimeType>; // 用时间类型确定TaskBase 的类型
// 也可以自己写个基类
class TaskBaseType {
public:
virtual TimeType run() = 0; // 基类建议写成抽象类,把run 定义成纯虚函数
};
// 然后就能定义调度器对象
sb::DelayCallback2<TimeSource, 10, TaskBaseType*> task_mgr(0, 0);
// 这里先假设:引脚的类型是Pin,控制引脚高电平和低电平的函数分别是pin_hi() 和pin_lo(),LED 在高电平点亮。
// 先是控制LED 的子任务
class BlinkTask : public TaskBaseType {
private:
Pin _led;
bool _is_high_phase = true;
public:
BlinkTask(Pin target_led) : _led(target_led) {} // 用输入的引脚参数初始化对象
virtual TimeType run() override {
if(is_high_phase) {
pin_hi(_led); //点亮led,200ms 后再把它熄灭
is_high_phase = false;
return 200;
}
else {
pin_lo(_led); // 熄灭led,600ms 后再来点亮它
is_high_phase = true;
return 600;
}
}
};
// 用来生成四个子任务的任务
class 妈妈任务 : public TaskBaseType {
private:
uint8_t count = 1;
public:
virtual TimeType run() override {
switch(count) {
case 1: // 依次创建控制4 个LED 的子任务。这里用动态内存new 对象是安全的,
// 因为4 个闪灯任务无限循环执行,调度器不会删除任务,所以不需要准备任务对象销毁器
// 另外说一句,这样就相当于把对象的所有权完全转移给了调度器
task_mgr.add_normal_task(new BlinkTask(LED1), 0); // 把LED1 引脚传进对象里
break;
case 2:
task_mgr.add_normal_task(new BlinkTask(LED2), 0);
break;
case 3:
task_mgr.add_normal_task(new BlinkTask(LED3), 0);
break;
case 4:
task_mgr.add_normal_task(new BlinkTask(LED4), 0);
count = 1;
return 0; // 四个任务创建完成,复位计数器,不再定时执行
}
++count;
return 200; // 间隔200 毫秒创建四个任务
}
};
void main() {
// 定义任务对象
妈妈任务 mom;
// 这个例子更能体现出逻辑模块分离开的效果
task_mgr.add_normal_task(&mom, 0); // 启动任务创建流程,主函数里不用管细节了
while(true) {
task_mgr.tick();
// ...
}
}
自定义时间源
不管时间精度和单位,假设就用一个定时器中断随便让变量自增,再用这个变量当时间用,可以这么写:
volatile uint32_t time_counter = 0; // 计时变量,每次中断自增
// 自定义时间源
struct TimeSource {
static uint32_t get_time() { // 时间函数必须定义为静态函数,名称是get_time,返回整数值作为时间
return time_counter;
}
using TimeType = uint32_t; // 可选,定义一个TimeType 类型别名,方便外面用
};
// 这样就行了
DelayCallback2<TimeSource, 10> task_mgr(0, 0);
// 任务函数的返回类型和时间函数一致
uint32_t 任务() {
// ...
}
void main() {
// 初始化SysTick 中断
// ...
}
extern "C" {
// 定义中断服务函数,在C++ 文件里定义,要用extern "C" 把函数包起来,不然函数命名会对不上
void SysTick_Handler() {
++time_counter;
}
}
然后调度器里所有跟时间有关的地方就都跟着那个计时变量走了,比如添加任务时的参数1000
,现在就不一定代表1000 毫秒,根据SysTick 中断的设置,可以是随便什么值。当然,整的太离谱到时候也不好用。
让无耐心任务优先执行的思路
有一个技巧可以避免无耐心任务被耗时的普通任务推迟,就是把超时时间设置为较小的值,一旦时间超过阈值,其他的普通任务这次都不会再被执行,无耐心任务则畅通无阻,所以无耐心任务最多只会被普通任务推迟这个阈值的时间。但这样可能导致普通任务永远不会执行,比如,若有一个耗时的普通任务排在执行顺序前面,它的执行周期和后面几个普通任务相同,那么只要前面的任务一执行,超时就被触发了,后面的普通任务这次都执行不了。
凑合用的方法是手动开关超时控制,在刚刚添加对时间有要求的无耐心任务后开启超时控制,把max_duration
设置为大于0 的值。之后再把超时设置清零,关闭超时控制,把卡住的任务放出来。
任务对象销毁器
默认的参数是_DummyDestroyer
,就是个预定义的销毁器,内部没有实际功能,只是让调度器关掉任务销毁功能。把代码复制过来:
/**
* @brief 用于释放CallbackPtr 指向的资源
*
* DelayCallback 在运行中删除任务时,将会调用destroy 函数删除任务指针。这是为了给动态创建的临时任务提供方便,
* 因为临时任务的创建者可能早就退出了,如果DelayCallback 删除任务时不做处理,就会产生内存泄漏。
* 动态任务可以是new 创建的,也可以是在内存池中分配的,DelayCallback 不关心底层细节。
*
* 由于任务列表中,一个CallbackPtr 可以对应多个任务,所以删除指针时需要实现引用计数功能。
* DelayCallback 会在添加任务时调用new_task 函数,可在其中将CallbackPtr 指向的对象中的引用计数加一。
*
* new_task 返回值用于控制要不要添加任务,返回false 则丢弃。这样可以控制一个任务对象只能被添加一次。
*
* @tparam CallbackPtr
*/
template <typename CallbackPtr>
struct _DummyDestroyer {
static constexpr auto enabled() {
return false;
}
static bool new_task(CallbackPtr fptr) {}
static void destroy(CallbackPtr fptr) {}
};
原理和自定义时间源类似,销毁器必须按照这个格式定义这三个静态函数。第一个函数enabled()
用来控制调度器是否启用销毁功能,_DummyDestroyer
中,这个函数返回的是常量false
,所以调度器内部所有销毁任务的if
分支会被编译器优化掉,直接删除,减少资源占用。
其他的东西注释里说的应该够清楚了。三个函数的返回值类型没具体要求,对于enabled()
和new_task()
,只要有返回值就行,返回0、nullptr
或false
都能用来表示false
的意思。用法就懒得说了,懂得都懂[doge]。
日志输出器
还是相同的设计,就是几个静态函数,传进调度器里实现功能。默认参数_DummyLogger
,用来关闭日志输出功能。
/**
* @brief DelayCallback 支持在运行时输出日志,比如提供任务运行时间、tick 运行时间,CPU 时间占用比例之类的信息。
*
* 与时间源一样,输出日志所用的函数也通过一个结构体作为模板参数传入。结构体内需包含四个函数:
*
* static auto enabled() : 返回0 或false 时关闭日志输出功能.返回值可以依赖于变量,所以能实现动态开关功能;
* static void log(const char *) : 输出字符串;
* static void log(uint32_t) : 输出整数,其中整数的类型可以自由设定,需要输出的最大的整数是时间戳,所以整数类型最好和时间戳类型一致;
* static void newline() : 输出换行或其他分隔符
*
* 默认Logger 是_DummyLogger,其中enabled 函数返回false 常量,关闭了日志输出,所以三个输出函数的实现都是空的
*
*/
struct _DummyLogger {
inline static constexpr auto enabled() {
return false;
}
inline static void log(const char *text) {}
inline static void log(uint32_t value) {}
inline static void newline() {}
};
想实现日志输出,按要求实现这几个函数,然后把自定义的Logger 作为模板参数传进去就行。虽说我觉得这个日志功能没什么用,毕竟就是个简陋的调度器,根本上也没什么好输出的东西。
总结
几百行代码,配的注释加上这篇文章,总长度比代码本身都多了吧。