简介:SPOC2020课程配套的C++源码,针对Duck公司仓库红外报警装置的区域判定问题。核心任务是:n个发射器和n个接收器构成互不相交的红外线,将平面从左到右划分为0~n共n+1个区域;对于每个查询点,要求快速输出其所属区域编号,若点恰好落在红外线上,则视为位于右侧区域。代码基于坐标与直线位置关系进行判断,逻辑紧凑,适用于高校SPOC课程、算法练习或竞赛入门者参考。压缩包内仅包含1个cpp源文件,大小约1KB,无多余资源,便于直接阅读核心实现。目前已有156人学习下载,适合需要完成该作业或想了解类似平面划分问题编程解法的读者。通过阅读源码,可以学到如何处理边界条件、如何利用几何性质简化判断,并体会简洁实现带来的效率提升;同时也能作为调试和优化自身代码的对照样本,帮助理解区域编号与相对位置之间的映射关系。
1. 四路报警器(4Alarm)为什么值得从零写一遍
后半夜的档口,门磁报警比老板还勤快,一晚上能把周围几条街的人都叫醒。真正做过报警器的人都知道,难的不是响铃,而是让它不误报、不漏报、断电重启之后还不乱报。Pa1-4_SPOC20201-4Alarm_giftq41_Duck! 这种仓库名,第一眼像乱码,拆开其实信息量不小:SPOC20201 是某期在线课程的批次号,Pa1-4 表示第 1 到第 4 轮迭代,4Alarm 是题目本身,giftq41_Duck! 更像是提交者顺手给自己留的标记。技术核心就一句话:把一台四路报警控制器从选型、接线到调试完整落地。四路是一个很妙的边界——路数不多,但防区类型判断、软件去抖、布防延时、剪线报警、看门狗复位这些产品级问题一个都躲不掉。适合正在做课设但不想只交一个玩具的 A 同学,也适合想把小样机改成能扛现场干扰的老手。
2. “4路”背后的报警输入学问:通道类型与主控选型先想清楚
2.1 四路防区到底接什么:干接点、电平信号与线尾电阻
报警器里称每路输入为“防区”,一路防区挂一个或一类探测器。四路报警器的典型配置是:门磁、红外幕帘、烟感、水浸各占一路,覆盖一个小档口或者一套两居室。防区输入首先不是接传感器的“信号线”,而是接它的“开关量输出”,也就是干接点。干接点没有电位,只有通和断两种状态,靠外部回路把状态拉成 MCU 能读的电平。这样做的最大好处是传感器和主控之间没有电气上的直连,探测器那边是 12V 还是 24V,跟单片机这边 3.3V 是隔离的,抗干扰和安全性都好很多。
常见的探测器输出分成三类:常闭(NC)、常开(NO)、带线尾电阻(EOL)。常闭是正常状态下触点闭合,报警时断开;常开反过来,正常断开,报警时闭合。这个区别不是接线习惯问题,而是报警逻辑的根基。做安防的老手默认优先用常闭:因为报警线缆被人剪断、被老鼠咬断、或者端子松脱时,回路会从闭合变成断开,设备当场就能报出“防区故障”。如果用了常开,剪断线之后反而永远收不到报警信号,等于给小偷留了后门。常闭与常开再配合终端电阻,就成了 EOL——在探测器末端并/串一个精密电阻,主机通过测量回路电阻判断是正常、开路还是短路,把剪线和短接两条路都堵死。
| 输入类型 | 正常状态 | 报警状态 | 剪线后表现 | 典型探测器 |
|---|---|---|---|---|
| 常闭 NC | 闭合 | 断开 | 误报或报故障 | 门磁、烟感、紧急按钮 |
| 常开 NO | 断开 | 闭合 | 永远不报 | 红外幕帘、水浸 |
| 带 EOL | 回路电阻=终端电阻 | 电阻异常 | 报“防区故障” | 多数专业探测器 |
选择输入类型时要同时想清楚主控端怎么接线。干接点信号不能直接进单片机 GPIO:触点抖动、线缆感应、地电位差都可能让 GPIO 读到毛刺。常见做法是一路进光耦(如 PC817),探测器回路串一个限流电阻,光耦输出端接 MCU 的 GPIO 并加上拉。光耦前后彻底隔离,12V 侧就算被雷击感应出高压,也不至于直接打穿单片机。需要注意的一点是,光耦本身有导通延迟,高速场景要选带施密特特性的型号,但报警器这种毫秒级变化的场景,普通光耦完全够用。
2.2 主控、通信、供电三件事:一张选型权衡表
主控选型可以分两派。一派是 8 位单片机,比如经典的 51 系列,成本低、资料多,跑一个四路报警器绰绰有余;另一派是 Cortex-M 系列,比如常见的 STM32,优点是中断响应和调试手段更好,代码量上来之后维护价值明显。我自己做四路报警器偏向 Cortex-M 系列,原因不是算力,而是它的串口、定时器和看门狗外设更顺手,调试时接一个串口就能把状态机每一步看得明明白白。
通信方式要看落地点在哪里。课程设计和实验室验证,串口 TTL 转 USB 就是最直接的方案,一帧数据打印出来,上位机脚本一收,功能闭环。现场工程则多走 RS485 总线,四路报警主机作为从机挂到总线上,由中心软件轮询,好处是传输距离长、抗干扰强,坏处是协议要对齐。还有一种做法是给报警器加网络模块,把警情直接推到中心,但成本和部署难度都上了一个台阶,不建议第一次做就啃。
| 选型项 | 低配方案 | 推荐方案 | 理由 |
|---|---|---|---|
| 主控 | 51 / 国产 8 位 | Cortex-M 系列 | 串口调试、看门狗、定时器顺手 |
| 输入隔离 | 不隔离直接进 IO | 光耦 PC817 | 防止地电位差和线缆感应打坏芯片 |
| 通信 | LED + 蜂鸣器 | 串口 / RS485 | 至少要能远程确认布撤防状态 |
| 供电 | USB 5V | 12V 适配器 + 备用电池 | 报警主机必须断电仍然工作 |
供电在报警器里是第一优先级。现场标准做法是 12V 适配器供电,同时并一组铅酸或锂电池做备用电源,外加一个防拆开关:有人开盖先断电再报警。课程设计可以简化成 USB 或者锂电池供电,但代码里要预留电源电压采样和欠压报警的位置,不然做完之后就只是一块会响的开发板。选型时把这些列成一张表,几分钟就能把方案定下来,后面接线的返工成本会低很多。
3. 从接线到状态机:把四路报警最小系统跑起来
3.1 最小接线方案:光耦、上拉和复位按键怎么接
我以常见的 Cortex-M 系列开发板为例,给出一套可以直接抄的接线表。四路输入占四个 GPIO,四路指示灯占四个 GPIO,蜂鸣器占一个,按键占一个,串口占两个。这套接线逻辑对所有单片机都通用,只是引脚号不同。
| 功能 | 引脚 | 接法说明 |
|---|---|---|
| 防区 1~4 输入 | PB0~PB3 | 接光耦输出端,光耦输入端接探测器回路 |
| 防区指示灯 | PA0~PA3 | 接 LED 限流电阻到地,高电平点亮 |
| 蜂鸣器 | PB4 | 接三极管驱动,输出高电平响 |
| 布防/撤防按键 | PA4 | 按下接地,内部上拉打开 |
| 串口 TX/RX | PA9/PA10 | USB-TTL 模块对接 |
输入端要特别注意上拉。光耦输出端是三极管集电极开路,MCU 的 GPIO 必须内部上拉或者外部上拉 10kΩ 电阻。没有上拉时,光耦导通那一瞬间电平是确定的,但断开后 GPIO 浮空,读出来可能是 0 也可能是 1,报警状态就全乱了。我习惯外部再加一个 10kΩ 上拉,比只靠内部上拉更稳,尤其在长线场景。
// 初始化四路输入为上拉输入 void zone_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; // 内部上拉,配合外部10k更稳 gpio.Speed = GPIO_SPEED_FREQ_LOW; gpio.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; HAL_GPIO_Init(GPIOB, &gpio); }这里把四路输入统一配置成上拉输入,逻辑设计为低电平触发。探测器的常闭回路正常时把该引脚拉低,报警时断开,引脚回到高电平。这样接线最贴合常闭型探测器,也方便在调试时用手碰一下地模拟警情。
3.2 报警状态机与防抖:布防、触发、复位一个都不能少
四路报警器的状态机至少要有三个状态:撤防、布防、报警。再往上加就是布防延时、触发记忆、故障检测。最忌讳的做法是写一个巨大的循环读 GPIO,读到低就响,读完就完事,因为那会把布防和撤防、报警后怎么复位这些关键逻辑全部丢掉。
typedef enum { ST_DISARMED = 0, ST_ARMED, ST_ALARM } alarm_state_t; static alarm_state_t g_state = ST_DISARMED; static uint8_t g_zone_trigger[4]; static uint8_t g_zone_armed[4]; // 布防时同步一次防区初值 // 读一路输入,带10ms软件去抖,返回1表示触发 uint8_t zone_read_debounced(uint8_t idx) { uint8_t level1 = HAL_GPIO_ReadPin(GPIOB, 1u << idx); HAL_Delay(10); uint8_t level2 = HAL_GPIO_ReadPin(GPIOB, 1u << idx); return (level1 == level2) ? level1 : 0; }去抖时间取 10ms 是报警器里比较稳的默认值。门槛磁这类机械触点,抖动一般在 5ms 以内,10ms 连续两次读一致就能过滤掉大部分毛刺。注意这里的 10ms 是阻塞延时,只在单路读取时用,不能把整个报警循环都延住,否则按键和串口都会卡死。
// 在1ms定时中断里每隔10ms调用一次 void alarm_state_machine_update(void) { if (g_state == ST_ARMED) { for (uint8_t i = 0; i < 4; i++) { if (!g_zone_armed[i] && zone_read_debounced(i)) { g_state = ST_ALARM; g_zone_trigger[i] = 1; siren_start(); // 启动声光,见4.1 uart_send_alarm(i); // 上报警情 } } } }布防时先保存一次防区初值,是为了避免“一布防就报警”的尴尬。实际场景里,用户按下布防键之后往往还没关门,门磁还开着,如果不做初值比对,防区一检测到门开就立刻报警。常见做法是布防后给 30 秒延时,让用户离开现场;更精细的做法是像上面这样,把布防瞬间的防区状态记为正常,只有从初值变化到触发才认定为警情。 30 秒的布防延时参数建议单独定义成常量,不要散落在代码各个角落。
报警之后怎么复位同样关键。我坚持一个原则:报警状态必须锁存,直到用户主动撤防才清除。也就是说,探测器自己恢复闭合不能把警情消掉,否则坏人走了、防区恢复了,现场没留下任何处理记录,事后排查也没依据。锁存信息要放在变量里,最好同时写入 EEPROM,掉电重启后仍能提示“曾发生过警情”。
3.3 串口调试命令 duck:一个安全又好玩的自检入口
标题里那个 Duck! 给了我一个很实用的提示——给它做成调试命令。报警器这种设备,最怕的是现场复现问题,四路输入同时挂上探测器之后,你没法用手去逐个触发。我在固件里留了一个简单的串口命令解析,收到 duck 回车就执行四路自检,把每个防区的当前电平、状态机状态、布防标志全部打印出来,然后自动切换“模拟所有防区触发”再打印一轮。
// 串口收到一帧后按命令分发 void uart_cmd_dispatch(char *cmd, uint8_t len) { if (len == 5 && strncmp(cmd, "duck\r\n", 6) == 0) { for (uint8_t i = 0; i < 4; i++) { printf("zone%d raw=%d armed=%d trig=%d\r\n", i+1, zone_read_debounced(i), g_zone_armed[i], g_zone_trigger[i]); } } else if (len == 4 && strncmp(cmd, "arm\r\n", 5) == 0) { g_state = ST_ARMED; printf("state: armed\r\n"); } else if (len == 7 && strncmp(cmd, "disarm\r\n", 8) == 0) { g_state = ST_DISARMED; memset(g_zone_trigger, 0, sizeof(g_zone_trigger)); printf("state: disarmed\r\n"); } }串口命令用回车结尾,是为了防止上位机发一条指令被半路截断。arm 和 disarm 命令在调试时可以代替按键操作,尤其当按键焊坏了或者人不在主机旁边时,通过串口就能完成整轮布撤防测试。这套命令我每次调试都会先跑一遍:四个防区原始电平是否正常、布防后状态是否更新、报警锁存有没有被误清除,一眼就能看出来。做四路报警器,调试工具和业务代码同样重要,把自检命令写好,能省掉一半现场排查时间。
4. 警情怎么让现场和人知道:声光驱动与远端上报协议
4.1 本地声光报警:LED 与蜂鸣器的无阻塞驱动
报警器的声光部分,最容易写出“卡死”代码。新手常直接在报警分支里写 HAL_Delay(500),让蜂鸣器响半秒再停,结果报警期间按键、串口、其他防区全部失去响应。正确做法是让声光由时间驱动,每 1ms 或 10ms 中断检查一次状态,用全局变量记录“什么时候该响、什么时候该灭”。
static uint32_t s_siren_start_ms; static uint32_t s_siren_last_tick; // 由1ms系统tick驱动 void siren_update(uint32_t now_ms) { if (g_state == ST_ALARM) { // 响200ms,停100ms,周而复始 uint32_t pos = now_ms - s_siren_start_ms; if ((pos % 300) < 200) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_RESET); } } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_4, GPIO_PIN_RESET); } }这段逻辑把蜂鸣器脉冲模式转换成“周期 300ms、占空比 2/3”的数学表达,响 200ms 停 100ms,现场听感就是急促的“嘀嘀嘀”,比长鸣更容易让人警觉。周期和占空比拆成常量后,改一个数字就能变成消防声或门铃声,不用重写逻辑。LED 也走同一条路子,四路 LED 在正常待机时不亮,布防状态慢闪一下表示已生效,报警状态一路常亮记录触发过的防区。
现在这套声光逻辑全靠时间戳驱动,主循环可以一直空跑,按键扫描、串口解析、防区读取都不受影响。这是做报警器底层需要早点养成的一个习惯:任何输出都不要用长阻塞延时。
4.2 串口上报与上位机轮询:一个小而稳的帧协议
本地声光只解决“现场知道”,远端上报解决“人知道”。串口是最低成本的通信方式,但裸发字符串有个问题:分帧不可靠,容易半包、粘包。我在这套系统里定义一个固定 7 字节的帧,上位机和单片机都按这个格式解析。
| 字段 | 字节数 | 取值与含义 |
|---|---|---|
| 帧头 | 2 | 0xEB 0x90,区分有效数据和串口噪声 |
| 类型 | 1 | 0x01 布防,0x02 撤防,0x03 警情,0x04 心跳 |
| 防区号 | 1 | 低 4 位对应 1~4 防区,0x0F 表示全部 |
| 状态 | 1 | 0x00 正常,0x01 触发,0x02 恢复 |
| 校验 | 2 | CRC16 低字节在前 |
校验用 CRC16 而不是累加和,是为了把指令里的偶发误码也拦住。报警器通信量小,CRC16 的计算开销可以忽略不计。单片机收到上位机的布防、撤防指令后,同样回一帧确认帧,上位机软件不会出现“发了命令但不知道成没成功”的盲区。
import serial ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1) def send_cmd(cmd: bytes): ser.write(cmd) def read_frame(): data = ser.read(7) if len(data) != 7: return None if data[0] != 0xEB or data[1] != 0x90: return None crc = data[5] | (data[6] << 8) # CRC16 校验通过后才解析 return {'type': data[2], 'zone': data[3], 'status': data[4]} send_cmd(b'duck\r\n') # 自检指令 while True: frame = read_frame() if frame: print(frame)这个脚本是四路报警器上位机的最迷你版本。type 字段区分事件类型,zone 字段告诉你哪一路触发,status 字段记录触发还是恢复。串口帧只有 7 字节,在 115200 波特率下传输时间不到 1ms,四路同时报警也不会把缓冲区塞满。
5. 四路报警器常见问题排查:误报、漏报、死机三座大山
5.1 误报:干接点抖动引起的“灵异事件”
现象是设备正常运行,半夜不定时报警,有时候一晚上两三次,白天又一切正常。
排查一圈会发现不是逻辑错误,而是干接点抖动。门磁的簧片在门框震动、空调启动、甚至重型车经过时,触点会瞬间断开再闭合。单片机读到一次断开就直接判定为报警,于是误报。
处理分两级。硬件上在光耦输入侧并联 RC 吸收回路,R 取 1kΩ、C 取 104 电容,把毫秒级的毛刺直接滤掉;软件上把去抖时间从 10ms 提高到 20ms,连续两次读结果一致才进入状态机。我之前踩过一次坑:只在硬件加了电容,没有软件去抖,结果电容值太大把报警脉冲也吃掉了,导致该报的时候不报。正确做法是硬件滤高频、软件滤低频,两者配合。
提示:去抖时间不要无限加长。超过 50ms 后,真实报警信号也会被忽略,四路报警器因此漏报,代价更大。
5.2 漏报:常闭线被剪断反而没报警的隐患
现象是模拟剪线,把探测器到主机之间的线拔掉,主机毫无反应,甚至还会继续展示防区正常。
原因是防区被配置成了常开模式,正常时回路断开,只有短路才报警。剪断线缆之后回路状态没变化,主控当然不会报。这属于选型时就埋下的雷,不是代码 bug。
解决方法是把防区改成常闭模式,最好再加终端电阻。常闭模式剪线后回路开路,状态变化立刻触发“防区故障”或“防区报警”;带终端电阻后,线路短路和剪线都能区分出来。四路报警器只有四路输入,每一路在配置阶段都要明确一个问题:这段线如果断了,系统能不能知道。
5.3 死机:看门狗与复位兜底
现象是设备运行几天后毫无响应,指示灯和蜂鸣器都不动,断电重启又正常。
这种偶发死机多半是主循环卡在某个等待里,或者电源波动把程序状态打飞。报警器是安防设备,死机本身就等于漏报,必须兜底。独立看门狗(IWDG)是标准做法,启动后程序必须在一段时间内刷新它,否则系统自动复位。
// IWDG初始化后,在主循环里周期刷新 void watchdog_feed(void) { HAL_IWDG_Refresh(&hiwdg); } // 主循环每转一圈喂一次,不可以在长阻塞Delay里喂 while (1) { watchdog_feed(); alarm_state_machine_update(); siren_update(HAL_GetTick()); uart_rx_process(); }喂狗位置必须在主循环里,而且不能在 HAL_Delay 这种阻塞延时之后立刻喂。有个折叠的问题是,有人只在报警分支里喂狗,平时不喂,结果设备在静默状态下反复复位,反而不稳定。更合适的做法是用 1 秒的看门狗超时,主循环最坏情况 200ms 内肯定能转一圈,留足裕量。如果程序里有超过 1 秒的阻塞操作,说明这里的设计本身就要重构,而不是靠喂狗来掩盖。
5.4 报警锁存与复位时序:按键/命令要双重确认
现象是报警一段时间后自己停了,但防区仍然处于触发状态,再次触发同一防区,设备反而没有任何反应。
原因有两个。一是复位逻辑把报警状态清得太干净,但防区触发标志没有同步清除,下次触发被当成“还在报警中”忽略掉了;二是按键去抖不到位,用户按撤防时一次按下触发多次状态翻转,布防和撤防来回跳。
解决办法是复位动作加一个“二次确认”机制。按键按下 20ms 后读一次,松开后再读一次,两次确认一致才切换状态。串口命令也一样,上位机发 disarm 后必须回一帧确认,单片机收到确认后才清除触发标志,同时把四路防区状态重新读取一遍作为新的初值。报警锁存这条线是我见过翻车最多的位置,归根结底是状态机里把“报警”和“复位”两个动作想得太简单了。
6. 给四路报警加一道“后悔药”:警情记录回放与验收清单
6.1 用上位机脚本把警情过程回放出来
调试四路报警器时,最难受的不是找不到 bug,而是现场那一刻没抓到,后面没法复现。我给这套系统加了一个轻量“黑匣子”:单片机上电后每发生一次事件,就把时间戳、防区号、动作类型拼成一帧通过串口发出去,上位机脚本按顺序写入日志文件,之后再按时间轴回放。
import serial, datetime ser = serial.Serial('/dev/ttyUSB0', 115200, timeout=1) log = open('alarm_log.txt', 'a') while True: frame = ser.read(7) if len(frame) == 7 and frame[0] == 0xEB and frame[1] == 0x90: ts = datetime.datetime.now().strftime('%H:%M:%S') evt = {1: 'ARM', 2: 'DISARM', 3: 'ALARM', 4: 'HEART'}.get(frame[2], '?') log.write(f"{ts} {evt} zone={frame[3] & 0x0F} status={frame[4]}\n") log.flush()回放日志时,把同一时刻的防区状态排成一列,就能看出报警是先哪一路触发、隔了多久触发第二路、用户多久后撤防。这个时间轴在现场沟通时很有说服力,能直接指出是门磁线松了,还是布防延迟参数太短,不需要再靠嘴解释。
6.2 四路报警的验收清单和第一条线
最后分享一套我每次做完都跑的验收清单:第一,四路分别触发,确认指示灯和报警帧一一对应;第二,断开任一路连线,确认能报出对应防区故障;第三,布防后立刻触发,确认被布防延时拦住;第四,报警后断电重启,确认锁存信息还在;第五,用串口发 disarm,确认状态确实回到撤防。这五条跑完,一台四路报警器才算真正可以交出去。我在这条路上吃过的亏,最后都浓缩成这套流程,做下一个方案时直接抄自己。希望帮到你。
本文还有配套的精品资源,点击获取