简介:面向SPOC 2020课程编程作业“1-4 Alarm”,讲述Duck公司仓库安装红外报警装置:n个发射器和n个接收器发出的红外线互不相交,将平面从左到右分成n+1个区域,需要快速判断任意给定点落在编号为多少的区域;若点恰在红外线上,则归属右侧区域。这一题型需要将几何模型转化为程序逻辑,适合正在学习算法与C++实现的学生参考练习。资源为rar压缩包,内含1个cpp源文件,包体仅1KB,代码量小、逻辑集中,可作为SPOC作业的独立解决方案或对照查错材料。源码实现了区域划分与边界判断的核心处理,能依据输入的坐标输出对应区域编号,对“在线上视为右侧区域”的边界条件做了明确比较,便于读者理解二分查找或线性扫描的应用方式。目前已有156人学习,适合算法入门者作为课后练习参考,也可用于复习几何分区的程序设计技巧。
1. 一个作业标题里藏着的状态机问题:Pa1-4 到底在考什么
Pa1-4_SPOC20201-4Alarm_giftq41_Duck!_乍看像一串乱码,但它其实是某高校嵌入式系统课程的第四次编程作业。拆开看:Pa1-4是第四次作业的编号,SPOC20201-4标记课程与学期序号,Alarm点明题目核心——闹钟系统,giftq41是这道题配套的测试例或配置编号,Duck!则是要求实现的提示音效果。一个闹钟看似简单,往深了做就是典型的有限状态机(FSM)题目:它要同时处理按键输入、时间基准、时间比较、蜂鸣器输出,还要在睡眠、响铃、暂停、停止之间正确切换。这篇笔记把一个常见的可复现实现拆开讲:状态怎么定、代码怎么写、参数怎么调、哪些地方翻车,让新手能照做,让熟手能抄走边界条件。
适合谁:正在做同类课程作业的开发者、想用状态机思路重写杂乱的延时触发逻辑的嵌入式初学者、需要在一个小系统里练模块划分的工程师。读完你会发现,这个标题考的不是"会不会用定时器",而是"能不能把一堆相互干扰的功能放进一张清晰的状态表"。
2. 拆解 Alarm 系统:四个模块与状态表的设计
2.1 为什么闹钟系统适合用状态机而不是死循环轮询
很多初学者拿到这道题的第一反应是写一个while(1)循环,反复读按键、反复比较当前时间和闹钟时间,到点就触发蜂鸣器。这种做法在前 10 分钟看起来能跑,一旦加入"暂停 5 分钟"(Snooze)和"按键关掉响铃"这两个需求,代码就开始失控:按下暂停键时蜂鸣器正在响,如果轮询代码在响铃函数内部死等 5 分钟,按键就永远读不到。
状态机解决的是"系统在某一时刻只能处于一个明确状态"这件事。闹钟本质上只有几个状态:设定等待、正常走时、响铃中、暂停中。每个状态只处理自己该处理的事件,状态与状态之间的转换用一张表表达。
以这道题为例,四个状态是:
ALM_OFF:闹钟关闭,只更新当前时间ALM_ARMED:闹钟已开启,当前时间不断和设定时间比较ALM_RINGING:已到闹钟时间,蜂鸣器正在响ALM_SNOOZE:用户按了暂停键,等待暂停周期结束
先定义好状态枚举,这是后面所有模块的基础。
typedef enum { ALM_OFF = 0, ALM_ARMED, ALM_RINGING, ALM_SNOOZE } alm_state_t;这里我用enum而非#define,是因为编译器能对枚举变量做类型检查,而且调试器里能看到状态名而不是一个裸数字。状态数只有 4 个,用enum完全够。如果你要扩展更多状态(比如"设置输入中""铃声渐强"),枚举同样好加。注意枚举顺序会影响调试时看内存数据,所以我把ALM_OFF显式定义为 0,其余自动加 1。
2.2 时间基准模块:软件定时器与硬定时器的分工
状态机本身不产生时间,它需要外部喂一个节拍。常见做法是用硬件定时器产生 1ms 中断,在中断里累加一个tick计数器,状态机在主循环里读这个计数器来判断时间变化。作业里要求实现的Alarm系统不必精确到秒以内,所以用一个 1ms 的节拍足够。
我的做法是维护一个毫秒计数和一个"秒钟更新"事件标志:每累计到 1000ms 就把tick_1s标志置 1,主循环看到标志后,把当前时分秒加 1 秒并清标志。这样做的原因是:时间更新逻辑放在中断里容易堵住中断,放在主循环则可以通过调试器随时观察当前时间的值。
volatile uint32_t g_ms_tick = 0; volatile uint8_t g_sec_flag = 0; void SysTick_Handler(void) { g_ms_tick++; if (g_ms_tick % 1000 == 0) { g_sec_flag = 1; } }2.3 把当前时间和闹钟设定时间放进同一个结构体
一个很自然的错误是在主循环里用if (cur_h == alm_h && cur_m == alm_m)来判断是否触发响铃。这个判断在秒数不为 0 时会导致闹钟在整个分钟内都反复进入响铃状态。正确做法是让当前时间和闹钟设定时间都包含秒字段,比较时精确到秒,触发后立刻切换状态而不是继续停留在比较逻辑里。
typedef struct { uint8_t hour; uint8_t minute; uint8_t second; } time_t; static time_t g_cur_time; static time_t g_alarm_time; static uint8_t g_alarm_enabled = 0;2.4 状态转换表:把每个事件能做什么先写清楚
状态机最难的不是写代码,而是确定"在什么状态下,收到什么事件,跳到哪个状态"。推荐先画一张状态 × 事件的表格:行是当前状态,列是事件,单元格是次态。事件就四类:秒更新、到点触发、暂停键按下、停止键按下。完成表格后写代码只是翻译。
| 当前状态 | 秒更新 | 到设定时间 | 暂停键 | 停止键 |
|---|---|---|---|---|
| ALM_OFF | 更新时间 | 不可能 | 保持 | 保持 |
| ALM_ARMED | 更新时间 | 进入 RINGING | 进入 SNOOZE | 回到 OFF |
| ALM_RINGING | 保持 | — | 进入 SNOOZE | 回到 OFF |
| ALM_SNOOZE | 更新时间 | — | 保持 | 回到 OFF |
这张表看起来简单,但它解决了实际项目里 80% 的扯皮问题。开发中经常有人问"响铃的时候按暂停键应该怎样",答案在表里一眼就能看到。我一般会先把表画在纸上,再写枚举和状态转换函数。
3. 把 Alarm 跑起来:按键、比较、提示音三块核心代码
3.1 按键扫描与状态注入:用事件而非电平驱动状态机
按键处理是这道题翻车最多的位置。原因在于:机械按键按下和释放都有抖动,如果直接在状态机里读电平,一次按下可能被识别成"按下了五次",状态可能在暂停和响铃之间疯狂跳转。常见做法是消抖后把"一次有效按键"转换成一个事件,丢给状态机。
int key_scan(void) { static uint8_t last_level = 0; static uint8_t press_cnt = 0; uint8_t level = read_key_pin(); int event = KEY_NONE; if (level != last_level) { press_cnt++; if (press_cnt >= 10) { press_cnt = 0; last_level = level; if (level == 0) { event = KEY_PRESSED; } } } else { press_cnt = 0; } return event; }逻辑说明:read_key_pin()读到的低电平表示按下,高电平表示释放。每次检测到电平变化后开始连续计数,只有连续 10 次读到同一电平才认为消抖完成。数 10 次而不是 10 毫秒,是因为这里的扫描周期由主循环节奏决定,不依赖确切时间,实现最简单,代码逻辑也更直白。
参数说明:press_cnt的阈值 10 是根据主循环周期换算的。如果主循环大约每 5ms 扫描一次,10 次对应约 50ms 消抖,这是机械键盘按键的常见消抖时长范围。换成扫描周期更短的定时器驱动时,阈值要相应调大,否则仍有抖动残余。如果你希望更稳,可以让扫描周期和消抖时长都用常量定义,比如扫描周期 5ms,消抖阈值 10,对应 50ms 消抖窗口。
3.2 闹钟时间比较逻辑:精确到秒且区分"已触发"状态
时间比较看起来简单,实际上有一个细节:如果只用"当前时分等于设定时分"判断,那么响铃状态会在这一分钟内反复被触发,因为整个 1 分钟里时分都没变。必须带上秒,并且在进入响铃状态后通过状态切换来阻断后续比较。
int check_alarm_trigger(void) { if (!g_alarm_enabled) { return 0; } if (g_cur_time.hour == g_alarm_time.hour && g_cur_time.minute == g_alarm_time.minute && g_cur_time.second == g_alarm_time.second) { return 1; } return 0; }这个函数的调用位置在状态机内部,因此只有当前状态是ALM_ARMED时才会被调用。一旦返回 1,状态机立即切到ALM_RINGING,下一轮主循环进来时当前状态已经变了,不会再走这一分支。这是状态机比裸比较循环优雅的地方。
逻辑说明:三个条件缺一不可。如果去掉second判断,则响铃阶段可能从整分开始一直持续到下一分钟,产生"响 60 秒"的长鸣;如果只比较秒而不比较分,则每小时都会在相同秒数处触发一次。跨天边界也有一个坑:如果闹钟设定的时间小于当前时间(比如当前 23:59 设定 06:00),大多数简版实现不会响。这道题里一般不需要跨天,但如果你要改造成"明天再响",需要在午夜重置时保留闹钟设定值。
3.3 蜂鸣器驱动与 Duck! 提示音:用频率切换产生辨识度
Duck!是作业里对提示音效果的要求,说白了就是让蜂鸣器发出短促、有节奏的"嘎嘎"声,而不是单一的"滴——"长音。实现方式是用定时器产生 PWM 驱动蜂鸣器,在软件里周期切换频率。
void buzzer_duck_play(int duration_ms) { static uint32_t last_toggle_ms = 0; static uint8_t phase = 0; uint32_t now = g_ms_tick; if (now - last_toggle_ms >= 180) { last_toggle_ms = now; phase = !phase; if (phase) { set_buzzer_freq(750); } else { set_buzzer_freq(980); } } }逻辑说明:这个函数每 180ms 在 750Hz 和 980Hz 之间切换一次,产生类似"嘎、嘎"的双音节奏。set_buzzer_freq()是底层驱动,内部把目标频率换算成定时器自动重装载值,两个频率交替播放。蜂鸣器种类有源和无源驱动方式不同:无源蜂鸣器需要给定频率的方波才能发声,有源蜂鸣器内部自带振荡器,只需要给高低电平就能响,但音调单一。作业里一般使用无源蜂鸣器,所以必须有频率切换逻辑。
参数说明:180ms的切换间隔是试出来的经验值——太长听起来像两个独立的声音,太短则混成粗噪声。如果你想模拟更接近鸭叫的"嘎——嘎"节奏,可以把间隔设在 150ms 到 200ms 之间。750Hz和980Hz的选择原则:两者频率差要超过人耳能区分的临界带宽,同时都在蜂鸣器谐振频率附近,音量才够大。不同蜂鸣器的谐振频率不同,买回来的模块要先用扫频测一下再定这两个值。
3.4 状态机主循环:一个 switch 加上四个状态动作
有了事件源、时间基准和蜂鸣器驱动,主循环就可以写得很短了。核心是把所有状态转移收敛到一个函数里。
void alm_state_machine(void) { int key = key_scan(); switch (g_alm_state) { case ALM_OFF: if (key == KEY_PRESSED) { g_alarm_enabled = 1; g_alm_state = ALM_ARMED; } break; case ALM_ARMED: if (g_sec_flag) { g_sec_flag = 0; update_current_time(); if (check_alarm_trigger()) { g_alm_state = ALM_RINGING; buzzer_duck_start(); } } if (key == KEY_PRESSED) { g_alarm_enabled = 0; g_alm_state = ALM_OFF; } break; case ALM_RINGING: if (key == KEY_PRESSED) { buzzer_duck_stop(); g_alarm_enabled = 0; g_alm_state = ALM_OFF; } break; case ALM_SNOOZE: if (g_sec_flag) { g_sec_flag = 0; update_current_time(); /* snooze_until 记录的是重新响铃的绝对时间 */ if (g_ms_tick >= g_snooze_until_ms) { g_alm_state = ALM_RINGING; buzzer_duck_start(); } } if (key == KEY_PRESSED) { g_alarm_enabled = 0; g_alm_state = ALM_OFF; } break; default: g_alm_state = ALM_OFF; break; } }逻辑说明:这个函数每次运行只处理一个事件,所以不存在"响铃时按暂停键但暂停逻辑没被执行"的竞态问题。ALM_OFF状态下按一次键进入ALM_ARMED,再按一次回到ALM_OFF,这是作业要求的开关切换。ALM_RINGING状态里按键直接关掉响铃并回到关闭,符合"响铃时按任意键停止"的常见交互。ALM_SNOOZE状态用g_snooze_until_ms记录暂停结束的绝对毫秒时间,代码里必须先让update_current_time()更新时间,再做时间比较,否则当前时间停留在进入暂停那一刻。
参数说明:g_snooze_until_ms建议在进入暂停时设置为g_ms_tick + 5 * 60 * 1000,也就是 5 分钟暂停。如果你拿的是有源蜂鸣器,buzzer_duck_start和buzzer_duck_stop的行为会变成"拉高电平"和"拉低电平",但状态转移逻辑完全不用动。key_scan()的返回值只在每次循环开始时读一次,保证一个循环内按键事件不会被重复消费。
4. 避坑:闹钟系统最常见的五个翻车现场
4.1 现象:闹钟比手机慢了几秒,一天下来差一分钟
原因:软件定时器的累计误差来自g_ms_tick递增依赖于中断准确,但中断频繁进出会挤占主循环时间,而update_current_time()里如果用了浮点或除法运算,累计误差会更大。解决:把时间更新放到时间基准模块里,用整数累加,不涉及除法。常见做法是直接用秒计数器,定时器中断每 1000 次置一次标志,主循环收到标志后执行一次second++,够用。误差达到秒级时,优先检查晶振频率和中断优先级设置,而不是改代码。
4.2 现象:设定的闹钟时间到了,但蜂鸣器一声不响
原因:这类问题八成是当前时间没有更新。我在调试时发现初学版代码经常把update_current_time()放在ALM_ARMED分支里,而初始状态是ALM_OFF,于是当前时间在关闭状态下一直没有走秒,设定时间自然永远到不了。解决:把时间更新放到状态机的最前面,与状态无关,每个循环都先更新时间再处理状态转换。我一般单独写一个time_slice()函数,主循环无条件调用,状态机只负责判断是否触发响铃。
4.3 现象:按键按一下没反应,按两三下才进入响铃状态
原因:这就是按键抖动没消干净。press_cnt >= 10的阈值看起来很大,但如果主循环运行速度非常快(比如状态机没有任何轮询延时,循环周期只有 1ms),10 次计数只消抖了 10ms,机械按键的抖动通常持续 10~20ms,所以仍会误触发。解决:把消抖计数改成时间计数,扫描函数里记录抖动开始时刻,只有抖动窗口超过 50ms 才确认一次按下;或者把主循环末尾加一个固定延时,但这样会拖慢整个状态机,不推荐。
4.4 现象:响铃之后按停止键只停了一下,紧接着又响起来
原因:很有趣,代码里按键后把g_alarm_enabled设为 0,但状态机切换到ALM_OFF后,下一轮主循环进入ALM_OFF分支,如果这里也有"按下按键进入 ARMED"的逻辑,而按键消抖标志还没恢复,同一个按键事件会被第二次消费。解决:按键事件只能被消费一次。在key_scan()里用一个局部变量记录"上次返回的事件",只有检测到"从无事件变成有事件"时才返回有效值。简单方案是在状态机里用static int prev_key_event把上次的事件存下来,主循环在每个 case 里比较后再决定是否响应。
4.5 现象:暂停 5 分钟后闹钟不重新响,而是直接停在暂停状态
原因:g_ms_tick是无符号 32 位整数,如果程序运行超过 49.7 天,它会回绕到 0。g_snooze_until_ms是绝对时间,如果暂停发生在回绕前几秒,那么回绕后g_ms_tick远小于g_snooze_until_ms,比较条件永远不成立。解决:把绝对时间比较改成相对时间比较,即记录g_snooze_remaining_ms并每次循环递减,减到 0 时触发重新响铃。这道题的运行时长通常不会超过一天,但养成用相对时间而不是绝对时间的习惯,以后做长时间运行的设备会少踩一个大坑。
5. 用日志和自动化验证状态转换,给 giftq41 加一道回归关卡
状态机写完,所有人都会遇到同一个问题:凭什么相信状态转换是对的?肉眼盯蜂鸣器响不响只是最终验证,中间任何一跳错都可能被掩盖。我习惯的做法是:给每个状态转移加一条调试日志,日志输出占位符包含[当前状态] -> [次态] 原因事件。在实际系统里这行日志会被当成输出标记,比如Duck!这个字符串就是我在响铃状态入口打出的标记,这样查看日志时只要搜Duck!就能定位有没有进入响铃。
验证流程我通常分三步走。第一步,静态查表:把状态转移表打印出来,对着代码逐行核对每个 case 的转移目标。第二步,动态喂事件:用一个测试函数替代真实按键,模拟"设定时间到"check_alarm_trigger()返回 1,"按暂停键" 返回KEY_PRESSED,把状态机的每个分支都跑一遍。第三步,把时间基准拨快:把g_ms_tick的递增步长从 1 改成 60,也就是模拟 60 倍速走时,让原本要等 1 分钟的触发条件在 1 秒内到达,快速验证响铃和暂停流程。
void test_event_inject(void) { /* 替代真实按键,手动触发各分支 */ key_inject_event(KEY_PRESSED); /* ALM_OFF -> ALM_ARMED */ update_current_time(); g_cur_time.hour = g_alarm_time.hour; g_cur_time.minute = g_alarm_time.minute; g_cur_time.second = g_alarm_time.second; check_alarm_trigger(); /* 模拟到点 */ key_inject_event(KEY_PRESSED); /* 假设进入 RINGING 后按停止 */ }逻辑说明:key_inject_event是key_scan的测试替身,直接返回目标事件,不经过消抖逻辑。测试完后再把真实按键接回。giftq41这里的作用就是测试例编号:我把这一组注入序列存成编号giftq41,每次改完状态机代码就跑一遍,确保新增功能没有破坏既有行为。这比每次手动按按键可靠得多,尤其适合交作业前最后的回归。
最后说一个心得:这题做着做着你就会发现,蜂鸣器响不响不是重点,重点是你敢不敢改代码。状态机结构清晰时,加一个"音量渐强"状态只需要在表里加一行;结构不清晰时,加一个按键判断都可能牵扯出五个if嵌套。我的习惯是先把状态转移表打印出来贴屏幕上,改代码前先改表,再改代码,让表和代码永远同步。这个习惯救了我很多次,希望帮到你。
本文还有配套的精品资源,点击获取