之前写驱动和传感器的时候,我一直提到"西风模板",但没有展开讲它到底是什么。这篇文章专门来拆它,因为我觉得理解这套架构比记住任何单个驱动都重要——你把架构吃透了,比赛的时候基本就是填空题。
先看全貌:西风模板长什么样?
我手上有三套代码:大模板手敲样板、第15届国赛满分代码、第16届省赛满分代码。它们的业务逻辑完全不同,但底层架构几乎一模一样。这说明什么?说明这套架构确实是经过多届比赛验证的。
先把整体结构画出来:
┌─────────────────────────────────────────────────┐ │ main.c(业务层) │ │ Key_Proc / Seg_Proc / Led_Proc / Uart_Proc ... │ ├─────────────────────────────────────────────────┤ │ Scheduler(调度器) │ │ task_c 数组 + Scheduler_Run() + uwTick 时基 │ ├─────────────────────────────────────────────────┤ │ Driver/(驱动层) │ │ Led.c / Seg.c / Key.c / iic.c / onewire.c ... │ ├─────────────────────────────────────────────────┤ │ 硬件(CT107D开发板) │ │ STC15F2K60S2 + 74HC573锁存器 + 各种传感器 │ └─────────────────────────────────────────────────┘关键点:驱动层和调度器在比赛前就写好了,比赛时只需要在 main.c 里写业务逻辑。我统计了一下,三套代码的 Driver 目录几乎完全相同——Led.c、Seg.c、Key.c、iic.c、onewire.c、ds1302.c 这些文件在省赛和国赛代码之间可以直接复制粘贴。
调度器的核心:一个结构体解决所有问题
先看样板代码里调度器的完整定义:
/* 定义调度器结构体 */ typedef struct { void (*task_func)(void); // 任务函数指针 unsigned long int rate_ms; // 运行周期(毫秒) unsigned long int last_run; // 上一次运行的时间戳 } task_c;这个结构体就三个字段,但它们组合在一起非常强大。task_func是函数指针,你可以把任何void func(void)类型的函数塞进去;rate_ms告诉调度器"这个函数多久执行一次";last_run记录上次执行的时间点。
再看调度器的配置表——这就是你的"任务清单":
/* 样板代码的调度器配置 */ idata task_c Scheduler_Task[] = { {Led_Proc, 1, 0}, // LED刷新 1ms {Key_Proc, 10, 0}, // 按键扫描 10ms {Seg_Proc, 100, 0}, // 数码管刷新 100ms {Uart_Proc, 10, 0}, // 串口处理 10ms {AD_DA, 160, 0}, // ADC/DAC 160ms {Read_Tem, 100, 0}, // 温度读取 100ms {Read_Time, 500, 0}, // 时间读取 500ms };比赛的时候,你只需要根据赛题需求增删这一两行。比如第15届国赛加了频率读取和超声波测距:
/* 第15届国赛的调度器配置——8个任务 */ idata task_c Scheduler_Task[] = { {Led_Proc, 1, 0}, {Key_Proc, 10, 0}, {Seg_Proc, 100, 0}, {Uart_Proc, 10, 0}, {AD_DA, 160, 0}, {Read_Freq, 1000, 0}, // 新增:NE555频率读取 {Get_Distance, 300, 0}, // 新增:超声波测距 {Run_Calc, 500, 0}, // 新增:运动轨迹计算 };看到没有?驱动层一行没改,调度器的核心代码一行没改,就多了三行配置,一个国赛题目的框架就搭起来了。
调度器到底怎么运转的?
调度器的运行逻辑其实非常简单,完整代码也就十来行:
unsigned long int uwTick; // 全局滴答计时器(在Timer1中断中每1ms递增) idata uc Task_Num; // 任务总数 /* 初始化:自动计算任务数量 */ void Scheduler_Init() { Task_Num = sizeof(Scheduler_Task) / sizeof(task_c); } /* 运行:遍历任务表,到时间就执行 */ void Scheduler_Run() { unsigned char i; for(i = 0; i < Task_Num; i++) { unsigned long int Now_Time = uwTick; if(Now_Time > (Scheduler_Task[i].last_run + Scheduler_Task[i].rate_ms)) { Scheduler_Task[i].last_run = Now_Time; // 顺序不能反! Scheduler_Task[i].task_func(); } } }我来用一个具体例子解释这段代码是怎么工作的。假设当前uwTick = 257,Seg_Proc 的rate_ms = 100,上次执行时last_run = 200:
判断条件:Now_Time(257) > last_run(200) + rate_ms(100) = 300? 结果:257 > 300 → false → 不执行 等到 uwTick = 301 时: 判断条件:301 > 300 → true → 执行 Seg_Proc() 执行后:last_run 更新为 301这个时间判断用的是时间戳比较法,不是"每隔N毫秒触发一次",而是"当前时间是否已经过了上一次执行时间+N毫秒"。两者的区别在任务执行时间不稳定时才体现出来,但在蓝桥杯的场景下,每个任务函数都很快(微秒级),所以效果一样。
为什么要"先更新时间,再执行任务"?
代码里有一行注释:"顺序不能反"。我一开始也没太在意,后来想了想才明白:
// 正确顺序 Scheduler_Task[i].last_run = Now_Time; // 先更新时间戳 Scheduler_Task[i].task_func(); // 再执行任务 // 如果反过来(错误) Scheduler_Task[i].task_func(); // 如果任务里有耗时操作... Scheduler_Task[i].last_run = Now_Time; // last_run 就会被推迟虽然蓝桥杯中任务函数通常很快,但保持正确的顺序是好习惯。万一你在某个任务里加了Sonic_Read()这种需要等待回波的函数(最大等待约30ms),如果先执行再更新时间,下一次调度就会被推迟30ms。
uwTick 会溢出吗?
uwTick是unsigned long int(32位),最大值 4,294,967,295ms ≈49.7天。蓝桥杯比赛只有5小时,完全不用操心溢出问题。
前后台分工:中断里干什么,主循环里干什么?
这是西风模板最关键的设计思想。看样板代码的 Timer1 中断服务函数:
void Time1_Server() interrupt 3 { uwTick++; // 系统滴答 +1 if(Uart_Recv_Flag) Uart_Recv_Time++; // 串口超时计数 /* 数码管动态扫描(每1ms切换一位)*/ Seg_Pos = (++Seg_Pos) % 8; if(Seg_Buf[Seg_Pos] >= ',') // 带小数点的段码 Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1); else Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0); }中断里只做三件事:递增uwTick、串口超时计数、数码管扫描。其他所有业务逻辑都在主循环里通过调度器执行。
为什么数码管扫描要放在中断里?因为数码管的动态扫描对时间要求非常严格——必须每1ms切换一位,8位数码管每个周期8ms,刷新率125Hz。如果扫描放在主循环里,其他任务执行时间不稳定,数码管就会闪烁或出现重影。放在中断里就不受主循环影响。
再看第15届国赛的中断——业务更复杂,但原则不变:
void Time1_Server() interrupt 3 { uwTick++; if(Uart_Recv_Flag == 1) Uart_Recv_Time++; // LED4到达指示:亮3秒后自动熄灭 if(Running_Arrive_Judge == 1) { if(++Led_Enable_Time >= 3000) { Running_Arrive_Judge = 0; Led_4_Enable = 0; Led_Enable_Time = 0; } } // 暂停状态时LED1闪烁(100ms切换) if(Running_Mode == 1) { if(++Led_SlowDown == 100) { Led_SlowDown = 0; Flash_Judge = !Flash_Judge; } } else { Led_SlowDown = 0; Flash_Judge = 0; } // 数码管扫描 Seg_Pos = (++Seg_Pos) % 8; if(Seg_Buf[Seg_Pos] >= ',') Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos] - ',', 1); else Seg_Disp(Seg_Pos, Seg_Buf[Seg_Pos], 0); }国赛里加了LED到达计时和闪烁控制,这些需要精确定时的逻辑也放在中断里。原则就是:需要精确到毫秒级的计时和刷新放中断,其他业务逻辑放主循环。
main函数的标准模板
三套代码的 main 函数结构高度一致:
void main() { System_Init(); // 1. 系统初始化(关闭蜂鸣器/继电器) Uart_Init(); // 2. 串口初始化(如果赛题需要) Timer0Init(); // 3. 额外定时器(如NE555频率计数) Scheduler_Init(); // 4. 调度器初始化(必须在Timer1之前!) Timer1Init(); // 5. 系统时基(最后启动) while(1) { Scheduler_Run(); // 主循环只做一件事 } }初始化顺序很重要
Scheduler_Init()必须在Timer1Init()之前。原因很简单:
// 如果先启动定时器: Timer1Init(); // Timer1开始跑,uwTick开始递增 Scheduler_Init(); // 这时Task_Num才被计算出来 // 但uwTick可能已经 > 0了 // 而所有last_run初始值都是0 // 所以第一次调度时所有任务会立即执行一次 // 如果先初始化调度器: Scheduler_Init(); // Task_Num计算完毕 Timer1Init(); // Timer1开始跑,uwTick从0开始 // 所有任务等到rate_ms时间后才会首次执行实际上两种顺序都能正常工作(第一次立即执行也没什么问题),但先初始化再启动更符合嵌入式开发的常规思维。
脏检测:为什么每次操作锁存器都要比较新旧值?
这个技巧在 Led.c 和 Init.c 里反复出现,以 LED 驱动为例:
/* Led.c 完整代码 */ idata unsigned char Temp_0_Val, Temp_0_Old; void Led_Disp(unsigned char* ucLed) { unsigned char Temp; Temp_0_Val = 0x00; // 把8个LED的状态压缩成一个字节 Temp_0_Val = ucLed[0] | (ucLed[1]<<1) | (ucLed[2]<<2) | (ucLed[3]<<3) | (ucLed[4]<<4) | (ucLed[5]<<5) | (ucLed[6]<<6) | (ucLed[7]<<7); // 脏检测:只有数据变化时才操作锁存器 if(Temp_0_Val != Temp_0_Old) { P0 = ~Temp_0_Val; // LED低电平亮,所以取反 Temp = P2 & 0x1f; Temp |= 0x80; // 选中Y4C(LED锁存器) P2 = Temp; Temp = P2 & 0x1f; P2 = Temp; // 关闭锁存器 Temp_0_Old = Temp_0_Val; // 更新旧值 } }为什么不直接每次都写锁存器?因为74HC573锁存器的操作需要先开门再关门,过程中会改变P0和P2的状态。虽然时间很短,但如果在中断里频繁操作(比如 Led_Proc 每1ms执行一次),可能会对数码管扫描产生干扰。
更重要的是,大部分时间LED状态不会变,脏检测避免了大量无意义的总线操作。在资源受限的8051单片机上,这算是一种轻量级的优化。
蜂鸣器和继电器共用同一个锁存器(Y5C,0xA0),也用了相同的脏检测模式:
/* Init.c 中的蜂鸣器和继电器控制 */ idata unsigned char Temp_1_Val = 0x00, Temp_1_Old = 0xff; // 注意:Old初值0xff void Relay(bit Enable) { if(Enable) Temp_1_Val |= 0x10; // P0.4 控制继电器 else Temp_1_Val &= ~(0x10); if(Temp_1_Val != Temp_1_Old) { // 共用变量,位操作互不干扰 P2 &= 0x1F; P0 = Temp_1_Val; // 注意:这里不取反!高电平有效 P2 |= 0xA0; P2 &= 0x1F; Temp_1_Old = Temp_1_Val; } } void Beep(bit Enable) { if(Enable) Temp_1_Val |= 0x40; // P0.6 控制蜂鸣器 else Temp_1_Val &= ~(0x40); // 共用 Temp_1_Val,位操作不会互相覆盖 ... }有一个细节值得注意:Temp_1_Old的初始值设为0xff而不是0x00。这是因为Temp_1_Val初始为0x00,如果Old也是0x00,第一次调用 Relay 或 Beep 时脏检测会失效(两者相等,不会操作锁存器)。设为0xff保证了第一次一定会执行锁存器操作。
调度周期怎么选?
我对比了三套代码的调度周期:
| 任务 | 样板 | 15届国赛 | 16届省赛 |
|---|---|---|---|
| LED刷新 | 1ms | 1ms | —(中断刷新) |
| 按键扫描 | 10ms | 10ms | 减速变量10ms |
| 数码管数据 | 100ms | 100ms | 减速变量100ms |
| 串口处理 | 10ms | 10ms | —(无串口) |
| ADC/DAC | 160ms | 160ms | —(中断中调用) |
| 温度读取 | 100ms | — | 减速变量30ms |
| 时间读取 | 500ms | — | — |
| 频率读取 | — | 1000ms | — |
| 超声波 | — | 300ms | 中断中执行 |
| 运动计算 | — | 500ms | — |
规律很明显:
- 按键/串口用10ms:人手按键的抖动时间大约10~20ms,10ms采样一次刚好能消抖
- 数码管数据用100ms:人眼感知刷新的阈值约50ms,100ms更新数据绰绰有余(扫描刷新在中断里,1ms一级,不受影响)
- ADC用160ms:为什么不是整百?因为160ms不是100的整数倍,能避免和数码管更新"撞车",减少同一时刻多个任务同时执行造成的卡顿
- 频率用1000ms:Timer0计数器计数1秒内的脉冲数,刚好就是频率值(Hz)
- 超声波用300ms:每次测距需要发送脉冲+等待回波,耗时约20~30ms,300ms间隔足够
第16届省赛的架构差异:不用调度器怎么办?
有意思的是,第16届省赛的满分代码没有使用调度器,而是用了传统的减速变量方式:
/* 第16届省赛:减速变量方式 */ void Key_Proc() { if(key_scan_slow < 10) return; // 10ms减速 key_scan_slow = 0; // ...按键处理逻辑 } void Seg_Proc() { if(seg_scan_slow < 100) return; // 100ms减速 seg_scan_slow = 0; // ...数码管显示逻辑 } /* 中断中递增减速变量 */ void Timer1_Server() interrupt 3 { if(++seg_pos == 8) seg_pos = 0; key_scan_slow++; seg_scan_slow++; // 数码管扫描、LED刷新、累加测量定时... }为什么不用调度器?我分析了代码后发现,这道题有一个特殊需求——累加测量的时间间隔必须精确到毫秒级。累加测距是在中断里执行的:
void Timer1_Server() interrupt 3 { if(measure_start_flag == 1) { if(++measure_timer >= time_interval_par * 1000) { // 精确计时 measure_timer = 0; distance += (Sonic_Read() / 340.0) * (330 + 0.6 * temperature); if(--measure_count == 0) { DAC_Transition_IRQ(distance); measure_start_flag = 0; } } } }如果用调度器,累加测量在主循环里执行,而主循环的执行时间不确定(Sonic_Read() 本身就很耗时),无法保证精确的时间间隔。放在中断里直接计时就不存在这个问题。
结论:西风模板不是万能的。需要精确计时的时候,把逻辑放在中断里更合适。但大多数赛题用调度器就够了。
调度器 vs 减速变量:什么时候用哪个?
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 多个任务需要不同周期 | 调度器 | 配置表统一管理,不遗漏 |
| 需要精确定时(累加测量等) | 中断 + 减速变量 | 调度器无法保证精确周期 |
| 任务数量少(< 5个) | 都可以 | 减速变量更轻量 |
| 比赛时间紧 | 调度器 | 熟悉的模板直接用,不容易出错 |
我的建议是:平时练习两种都掌握,比赛时根据赛题灵活选择。如果赛题没有精确定时需求,直接上调度器;有精确定时的,用减速变量。不管用哪种,驱动层的代码都是通用的。
一个容易踩的坑:idata 和 pdata 的选择
三套代码在变量声明时对idata和pdata的使用很有讲究:
/* 大量使用的变量用 idata(128字节,快速间接寻址)*/ idata uc ucLed[8]; idata uc Key_Val, Key_Old, Key_Down, Key_Up; idata unsigned long int uwTick; /* 大数组或不太频繁访问的变量用 pdata(256字节,稍慢的间接寻址)*/ pdata uc Uart_Buf[16]; // 串口缓冲区,只在接收时使用 pdata int Running_Distance_x; // 运动变量,500ms才用一次 pdata float Running_Tang_Angle;STC15F2K60S2 的内存布局:
data:直接寻址,最快,但只有128字节idata:间接寻址,速度稍慢,128字节(实际256字节但和data重叠)pdata:分页间接寻址,256字节,速度更慢xdata:间接寻址,最多2048字节(60系列),最慢
调度器结构体数组也放在idata里,因为它在中断和主循环里都要频繁访问。串口缓冲区和一些不频繁使用的大变量放在pdata,节省idata空间。
比赛时如果遇到"变量太多idata不够用"的编译错误,把一些不频繁访问的变量移到pdata或xdata就行。
总结
西风模板之所以好用,核心就三点:
- 分层清晰:驱动层稳定不变,业务层灵活调整,调度器居中协调
- 非阻塞调度:不用
delay()阻塞主循环,所有任务按需执行 - 可扩展性强:加减任务只改配置表,核心代码不用动
比赛的时候,你的时间应该花在理解赛题、设计状态机和调试业务逻辑上,而不是纠结数码管怎么消影、I2C时序怎么写。把这些底层东西提前用模板写好、调好,比赛直接拿来用,这才是正道。