以下内容为阅读书籍《从单片机基础到程序框架(2019版)》作者:吴坚鸿
第88章内容至第91章内容的观后感

问题的起点:罪恶的Delay()

在单片机编程的入门阶段,我们几乎都写过这样的代码来让LED闪烁:

1
2
3
4
5
6
while(1) {
LED_On();
Delay(500); // CPU在此空转,等待500毫秒
LED_Off();
Delay(500);
}

这段代码简单直观,却隐藏着一个致命缺陷:Delay()函数是阻塞式的。在CPU空等的这500毫秒内,它无法响应按键、处理串口数据或执行任何其他任务。系统被完全“冻结”了。

要构建一个能同时处理多项事务的健壮系统,我们必须消灭Delay()。原作者通过一个“跑马灯”案例,展示实现高效并发的编程框架。

第一重境界:阻塞式移位

这是最直接的跑马-灯实现。利用C语言的位移操作,结合Delay(),让灯光依次流动。

1
2
3
4
5
6
7
8
9
10
11
void LedTask(void) {
static uint8_t led_mask = 0x01;

P0 = ~led_mask; // 灌电流点亮,所以取反
Delay(100000); // 阻塞延时,系统“假死”

led_mask <<= 1; // 左移一位
if (led_mask == 0) {
led_mask = 0x01;
}
}
  • 优点:代码逻辑简单。
  • 缺点:阻塞式,无法实现多任务并行。

第二重境界:非阻塞式移位

用定时器中断驱动的“软件定时器”取代Delay()。任务函数不再等待,而是每次被调用时检查时间是否到达。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 在定时器中断服务函数中 (例如每1ms一次):
// volatile uint16_t timer_tick = 0;
// timer_tick++;

void LedTask(void) {
static uint16_t last_time = 0;
static uint8_t led_mask = 0x01;

// 检查自上次执行以来是否已过去200ms
if (timer_tick - last_time >= 200) {
last_time = timer_tick; // 更新时间戳

P0 = ~led_mask;
led_mask <<= 1;
if (led_mask == 0) {
led_mask = 0x01;
}
}
// 如果时间没到,函数立刻返回
}
  • 优点:非阻塞,CPU被释放出来,可以while(1)中调用其他任务。
  • 缺点:逻辑依然僵化。它强依赖于连续的IO口(P0口),且难以实现复杂的、非线性的亮灯逻辑。

第三重境界:状态机思维的升华

这正是作者思想的精华所在。他彻底抛弃了“移位”这种面向过程的具象思维,转向一种更抽象、更强大的状态机思维。跑马灯的每一步,都被封装成一个独立的、自管理的任务函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 任务一:跑马灯A
void LedTask_A(void) {
static uint16_t last_time = 0;
static uint8_t step = 0; // 状态机A的状态变量

if (IsTimerExpired(&last_time, 200) == false) {
return; // 时间未到,立即返回,让出CPU
}

switch (step) {
case 0:
P0 = 0b11111110; // 点亮第0个灯
step = 1; // 切换到下一个状态
break;
// ... 其他状态 ...
case 7:
P0 = 0b11101111; // 点亮第4个灯
step = 0; // 回到初始状态
break;
}
}

初看之下,我们只是将一个任务用switch-case重写了。但作者真正的意图远不止于此。
现在
这个LedTask_A函数本身,就是一个独立的、可轮询的“微型进程”

手动时间片:while(1)循环的真正威力

现在,想象我们有第二个、第三个任务,比如另一个速度不同的跑马灯LedTask_B,和一个按键扫描任务KeyTask。我们同样将它们各自封装成独立的状态机函数。

此时,main函数里的while(1)循环就展现了它真正的威力:

1
2
3
4
5
6
7
8
9
void main(void) {
// ... 初始化 ...
while(1) {
// 手动进行“时间片”轮转
LedTask_A(); // 给任务A一个执行机会
LedTask_B(); // 立即给任务B一个执行机会
KeyTask(); // 立即给任务C一个执行机会
}
}

这才是整个框架的核心!主循环变成了一个永不停歇的高速调度器

它以极快的速度,一遍又一遍地“轮询”每一个任务函数。而每一个状态机任务都必须遵守一个原则:快速执行,快速返回。它们只在自己的时间片内(即函数被调用的瞬间)检查条件、执行一个微小的步骤,然后立刻退出,将CPU控制权交还给主循环,以便下一个任务可以被调用。

这就相当于我们**手动地将CPU时间切成了一个个微小的“时间片”**,并依次分配给了每一个任务。由于切换速度极快(远超人眼感知),在宏观上,所有任务看起来就像在同时运行。

终极挑战:在“时间片”内处理“长时任务”

这个手动调度模型有一个前提:每个任务的“时间片”必须极短。如果KeyTask的某一步需要执行1秒钟,那么任务_A任务_B都会因此卡顿1微秒。

那么,如何用这个框架处理播放音频、请求网络这种看似耗时很长的任务呢?

答案依然是分解。我们的目标,是确保任何任务的任何一个case状态,都是一个能在微秒级完成的“微操作”。CPU的角色是**“管理者”,而非“劳工”**。

案例1:播放音频(I/O密集型任务)

CPU的工作不是逐点播放音频数据,而是将数据分批“喂”给能自动工作的硬件(如DMA+DAC)。状态机只在关键时间点介入:

  • STATE_START: 动作(瞬间):配置并启动DMA播放第一个数据块。切换到STATE_WAIT
  • STATE_WAIT: 检查(瞬间):查询DMA中断标志。若缓冲区将空,则启动下一个数据块的填充和DMA重定向。

CPU从不参与“播放”这个持续的过程,它只在需要“换弹夹”时,做一个“瞬间”的搬运工。

案例2:请求网络(等待密集型任务)

网络请求的本质是“发送指令 -> 漫长等待 -> 接收数据”。状态机将此过程拆分:

  • STATE_SEND: 动作(瞬间):通过串口向WiFi模块发送HTTP请求指令。切换到STATE_WAIT
  • STATE_WAIT: 检查(瞬间):轮询串口接收缓冲区。有数据吗?有,就读一点分析一下;没有,就立即返回。

CPU将等待的“锅”甩给了网络,自己只负责在每个时间片里做一次快速的“检查”,绝不原地空等。

当然,这些步骤又自己从头实现着实没必要,这种情况最好还是引入操作系统,例如RTOS

总结

读完吴坚鸿老师的这几章,有一种豁然开朗的感觉。这本书教授的不仅仅是一种编程技巧(术),更是一种解决复杂问题的系统化思想(道)。

这个框架的本质,就是用状态机将每个任务“切片”,再用主循环将CPU时间“切片”。通过这两层“切片”,我们就在裸机上构建了一个高效、稳定、资源占用极低的合作式多任务调度系统

它虽然不如RTOS(实时操作系统)的抢占式调度来得强大和完善,但它不需要复杂的汇编上下文切换,仅仅依靠 C 语言的 switch-case 语法,就巧妙地实现了多任务并发。对于资源受限、逻辑中等复杂的单片机项目而言,这无疑是一套性价比极高、值得反复研读的程序架构思想。