
之前写驱动和传感器的时候我一直提到西风模板但没有展开讲它到底是什么。这篇文章专门来拆它因为我觉得理解这套架构比记住任何单个驱动都重要——你把架构吃透了比赛的时候基本就是填空题。先看全貌西风模板长什么样我手上有三套代码大模板手敲样板、第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 257Seg_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 int32位最大值 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; // 选中Y4CLED锁存器 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刷新1ms1ms—中断刷新按键扫描10ms10ms减速变量10ms数码管数据100ms100ms减速变量100ms串口处理10ms10ms—无串口ADC/DAC160ms160ms—中断中调用温度读取100ms—减速变量30ms时间读取500ms——频率读取—1000ms—超声波—300ms中断中执行运动计算—500ms—规律很明显按键/串口用10ms人手按键的抖动时间大约10~20ms10ms采样一次刚好能消抖数码管数据用100ms人眼感知刷新的阈值约50ms100ms更新数据绰绰有余扫描刷新在中断里1ms一级不受影响ADC用160ms为什么不是整百因为160ms不是100的整数倍能避免和数码管更新撞车减少同一时刻多个任务同时执行造成的卡顿频率用1000msTimer0计数器计数1秒内的脉冲数刚好就是频率值Hz超声波用300ms每次测距需要发送脉冲等待回波耗时约20~30ms300ms间隔足够第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的使用很有讲究/* 大量使用的变量用 idata128字节快速间接寻址*/ idata uc ucLed[8]; idata uc Key_Val, Key_Old, Key_Down, Key_Up; idata unsigned long int uwTick; /* 大数组或不太频繁访问的变量用 pdata256字节稍慢的间接寻址*/ 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时序怎么写。把这些底层东西提前用模板写好、调好比赛直接拿来用这才是正道。