简介:该资源面向嵌入式初学者与电子爱好者,提供一套基于STM32F103C8T6的220V调光完整方案,涵盖PWM亮度调节、遥控解码与Flash掉电记忆三大功能。程序通过配置定时器PWM通道改变占空比来调整负载亮度,利用GPIO捕获红外或射频遥控脉冲并软件解码,同时借助内部Flash保存上次亮度值,重新上电后自动恢复。压缩包共182个文件,约3.76MB,以C源文件与头文件为主体,辅以Keil工程文件、编译中间产物、HEX与AXF固件、原理图PDF及批处理脚本,构成从源码到烧录的完整工程。已有2110人学习下载,适合希望掌握STM32定时器、外部中断与Flash读写的读者参考,也可作为遥控调光类课程设计或DIY项目的实践素材。
1. 从一颗可控硅到一盏灯:这套调光方案到底解决什么问题
做灯控项目的人大多踩过同一个坑:单片机 PWM 输出调得挺顺,接上白炽灯或 LED 驱动却出现闪烁、低亮度段跳变、遥控一按就抖。问题往往不在 PWM 本身,而在「弱电控制强电」这一段的过零检测、触发时序和隔离设计。这套基于 STM32F103C8T6 的 220V 调光程序,把 PWM 调光、遥控解码、遥控调光三件事串成一条完整链路,适合正在做智能照明课设、毕设,或需要一份可交付调光控制方案的从业者参考。它要解决的核心不是「怎么输出一路 PWM」,而是「怎么让 PWM 在 220V 侧稳定、可调、可遥控」。
2. 需求拆解与技术选型:为什么是 STM32F103C8T6 加可控硅
2.1 功能需求先立住:三件事必须同时成立
这套方案的功能边界要先划清楚,否则后面选型和代码都会飘。我把需求拆成三条主线:第一,本地调光,通过电位器或按键给出目标亮度,MCU 输出对应占空比;第二,遥控解码,接收红外或 433MHz 遥控信号,解析出按键码;第三,遥控调光,把解码得到的按键映射成亮度增减、开关、记忆亮度等动作。三条主线共用一套亮度状态机,任何一路输入都只改「目标亮度」这一个变量,再由统一的输出层去驱动可控硅。
这样拆的好处是,调光和遥控不会互相打架。很多同学一开始把遥控处理直接写在中断里改 PWM 寄存器,结果遥控连发时亮度乱跳,这就是没有把「输入」和「输出」解耦的血泪经验。通常的做法是:输入层只更新目标值,输出层在固定周期里比较目标值和当前值,做平滑过渡。
2.2 主控选型:C8T6 的资源刚好够用
STM32F103C8T6 是 Cortex-M3 内核,72MHz 主频,64KB Flash、20KB SRAM,带 3 个通用定时器和 1 个高级定时器。对这套方案来说,关键资源是定时器:一个定时器做 PWM 输出,一个定时器做遥控解码的输入捕获或周期采样,再留一个做系统时基。C8T6 的定时器数量刚好覆盖,不需要外扩。
选它而不是选更小的型号,主要看两点:一是高级定时器 TIM1 支持互补输出和死区,虽然这套单向可控硅方案用不到死区,但后续如果要改成双向或加保护,留了余地;二是 20KB SRAM 足够放遥控码表和亮度曲线表。选它而不是选更大的型号,是因为调光控制本身不复杂,引脚和成本要控制,C8T6 的最小系统板也最容易拿到。
2.3 强电侧选型:可控硅加过零检测是绕不开的组合
220V 调光有两条技术路线:一是可控硅相位切割,二是专用调光芯片或 PWM 驱动模块。这套方案走的是可控硅相位切割,原因是它成本低、电路成熟、和 MCU 的接口简单。相位切割的关键是过零检测:交流电每个半波从零点开始,MCU 要在过零后延迟一个角度再触发可控硅,延迟角越大,灯越暗。
这里有个反直觉的点:PWM 在这套方案里不是直接驱动灯的,而是用来「表示延迟角」。定时器计数值对应延迟时间,比较值对应触发时刻。理解这一点,后面看代码就不会把 PWM 占空比和亮度直接划等号。通常的做法是:过零检测电路输出方波到 MCU 的外部中断引脚,每个过零沿启动一次定时器,定时器比较匹配时输出一个窄脉冲触发可控硅。
2.4 遥控方案:解码和调光要分两层
遥控部分支持红外和 433MHz 两种常见方式,解码层负责把波形还原成按键码,调光层负责把按键码翻译成亮度动作。红外用 NEC 协议居多,433MHz 一般用 EV1527 或类似编码。两层分开写,换遥控器只需要改解码层,调光逻辑不动。
| 模块 | 选型 | 关键参数 | 作用 |
|---|---|---|---|
| 主控 | STM32F103C8T6 | 72MHz,64KB Flash | 状态机与定时控制 |
| 调光执行 | 单向可控硅 + 光耦 | 触发电流按器件手册 | 相位切割 |
| 过零检测 | 光耦 + 限流电阻 | 输出方波,边沿对齐过零 | 提供触发基准 |
| 遥控接收 | 红外一体化头 / 433 超外差 | 载波 38kHz / 315 或 433MHz | 接收按键码 |
| 人机输入 | 电位器 + 按键 | ADC 采样 / GPIO | 本地调光 |
3. 总体设计:状态机、定时器与中断的职责划分
3.1 三层结构:输入层、状态层、输出层
整套程序按三层组织。输入层包括 ADC 采样电位器、按键扫描、遥控解码,职责是把物理动作变成「亮度目标值」和「开关命令」。状态层维护一个亮度状态机,保存当前亮度、目标亮度、开关状态、记忆亮度,负责平滑过渡和边界处理。输出层根据当前亮度计算延迟角,在过零中断里启动定时器,比较匹配时触发可控硅。
三层之间只通过全局状态结构体通信,不互相直接调用。这样调试时可以先单独验证输入层,再验证输出层,最后联调。我一般会定义一个LightState结构体,把亮度、目标、开关、遥控码都放进去,中断里只读不写,主循环里统一更新,避免中断和主循环同时改一个变量导致玄学问题。
3.2 定时器分配:谁负责 PWM,谁负责遥控
定时器分配是这套方案的骨架。TIM1 或 TIM2 做调光定时,工作在单脉冲模式:过零中断里设置计数值和比较值,启动后计到比较值触发一次输出,计到重装值自动停止,等下一个过零沿再来。TIM3 做遥控解码的时基,红外用输入捕获测脉宽,433 用周期采样加软件解码。SysTick 做 1ms 系统时基,负责按键消抖和亮度平滑。
这里要注意:调光定时器不能用普通的连续 PWM 模式,因为相位切割每个半波只触发一次,连续输出会一直导通,灯就全亮了。单脉冲模式才是对的。很多翻车案例就是把定时器配成 PWM 输出模式,结果可控硅一上电就常通,灯闪一下再常亮,查半天以为是过零电路坏了。
3.3 亮度到延迟角的映射:线性还是查表
亮度值和延迟角不是线性关系。人眼对亮度的感知近似对数,可控硅的导通角和输出光通量也不是正比。直接把 0 到 100 的亮度线性映射到 0 到 10ms 延迟,低亮度段会跳变明显。通常的做法是建一张亮度到延迟的查表,表里按感知曲线预先算好,运行时只做查表和插值。
/* 亮度 0~100 映射到半波内的延迟计数值,表按感知曲线预生成 */ static const uint16_t brightness_to_delay[101] = { 10000, 9800, 9600, /* ... 中间省略 ... */ 200, 100, 0 }; uint16_t get_delay_ticks(uint8_t brightness) { if (brightness > 100) brightness = 100; return brightness_to_delay[brightness]; /* 查表,避免运行时浮点运算 */ }这段代码的关键是表由离线工具生成,运行时只做一次数组访问。参数说明:数组单位是定时器计数,半波 10ms 对应计数值由定时器时钟和预分频决定,换晶振或改预分频要重新生成表。表长 101 对应 0 到 100 的亮度档位,如果要做更细的调光,可以扩到 256 档,但要注意 Flash 占用。
3.4 过零中断与触发时序
过零检测电路在每个半波零点附近输出一个边沿,MCU 用外部中断捕获。中断服务程序里做三件事:读取当前亮度对应的延迟计数值,写入定时器比较寄存器,启动单脉冲定时器。触发脉冲宽度要够可控硅导通,又不能太宽导致关断失败,一般取几十微秒,按器件手册的触发电流和光耦响应调。
void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) != RESET) { uint16_t delay = get_delay_ticks(g_light.current_brightness); TIM_SetCounter(TIM2, 0); TIM_SetCompare1(TIM2, delay); /* 延迟到比较值触发 */ TIM_Cmd(TIM2, ENABLE); /* 单脉冲模式,触发后自动停 */ EXTI_ClearITPendingBit(EXTI_Line0); } }这段中断要尽量短,只做寄存器和状态读取,不做亮度计算和遥控解析。参数说明:delay为 0 时表示全亮,等于半波计数值时表示全暗;实际使用要留一点余量,避免比较值落在过零噪声区。中断优先级要高于遥控解码,否则遥控连发时可能丢过零沿,灯会闪。
4. 模块实现:遥控解码、调光状态机与本地输入
4.1 红外 NEC 解码:用输入捕获测脉宽
红外一体化接收头输出的是反相波形,NEC 协议用 9ms 低电平加 4.5ms 高电平做引导码,之后是 32 位数据,每位用 560us 低电平加不同长度高电平区分 0 和 1。解码用输入捕获最稳:上升沿和下降沿都捕获,算两次捕获的差值得到脉宽,再按阈值判 0 或 1。
void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET) { uint16_t capture = TIM_GetCapture1(TIM3); uint16_t width = capture - g_last_capture; /* 两次捕获差值即脉宽 */ g_last_capture = capture; nec_decode_feed(width); /* 送入解码状态机 */ TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); } }解码状态机按引导码、地址码、数据码、结束码推进,收到完整帧后把按键码写入遥控队列。参数说明:捕获时钟要足够细,通常 1us 计数,560us 对应 560 个计数;阈值取 1.5 倍关系,比如 560us 和 1690us 的分界放在 1100us 左右。注意红外头输出有抖动,解码前要做简单滤波,连续两次一致才认。
4.2 433MHz 解码:周期采样加软件判码
433 模块没有标准协议,常见的是 EV1527 编码:每个码位用不同宽度的脉冲表示,同步码拉长。解码不用输入捕获,用定时器周期采样 GPIO 电平,记录高低电平持续时长,再按比例判码。采样周期取 100us 左右,既能分辨码位又不至于中断太频繁。
void sample_433_pin(void) /* 每 100us 调用一次 */ { static uint8_t last_level = 0; static uint16_t high_cnt = 0, low_cnt = 0; uint8_t level = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_1); if (level != last_level) { if (level) { /* 上升沿,结算低电平宽度 */ ev1527_feed(low_cnt, 1); low_cnt = 0; } else { /* 下降沿,结算高电平宽度 */ ev1527_feed(high_cnt, 0); high_cnt = 0; } last_level = level; } if (level) high_cnt++; else low_cnt++; }这段代码的核心是把「宽度」翻译成「码位」,ev1527_feed里按同步码对齐,再按高低宽度比例判 0 和 1。参数说明:采样周期决定最小可分辨宽度,100us 对应 10kHz 采样,对 1527 的码位足够;计数变量用 16 位,注意溢出前结算。433 模块容易受干扰,通常的做法是连续收到两帧相同码才执行动作。
4.3 调光状态机:目标值、当前值与平滑过渡
状态机是整套程序的中枢。它保存current_brightness和target_brightness,主循环每 10ms 比较一次,当前值向目标值靠近一步,步长按调光速度设定。遥控按一下加 10 档,本地电位器连续变化,两者都只改目标值,当前值平滑跟上,灯就不会突跳。
void light_state_update(void) /* 每 10ms 调用 */ { if (g_light.current_brightness < g_light.target_brightness) { g_light.current_brightness++; /* 步长可按需调整 */ } else if (g_light.current_brightness > g_light.target_brightness) { g_light.current_brightness--; } if (g_light.power_on == 0) { g_light.current_brightness = 0; /* 关灯时强制归零 */ } }参数说明:步长 1 对应 10ms 走一档,100 档走完约 1 秒,手感比较自然;要更快就加大步长或缩短调用周期。注意关灯逻辑要放在比较之后,否则关灯瞬间当前值还会往上走一下再归零,出现闪一下的现象。这个细节我在早期项目里翻过车,后来固定把「开关优先」写在状态机最前面。
4.4 本地输入:ADC 采样与按键消抖
电位器接 ADC 通道,采样后做滑动平均,映射到 0 到 100 的亮度档。按键用 GPIO 扫描,20ms 消抖,短按切换开关,长按做亮度连续调节。ADC 和按键都在主循环里处理,不占中断资源。
uint8_t read_pot_brightness(void) { static uint16_t buf[8] = {0}; static uint8_t idx = 0; uint32_t sum = 0; buf[idx++ & 7] = ADC_GetConversionValue(ADC1); for (int i = 0; i < 8; i++) sum += buf[i]; return (uint8_t)((sum / 8) * 100 / 4095); /* 12 位 ADC 映射到 0~100 */ }滑动平均长度取 8,兼顾响应和平滑;映射用整数运算,避免浮点。参数说明:4095 是 12 位 ADC 满量程,如果参考电压或分压比不同,映射系数要跟着改。电位器两端要加滤波电容,否则采样值跳动会让亮度一直微调,看起来像接触不良。
5. 避坑与排查:强电调光最容易翻车的五个点
5.1 灯闪但 PWM 波形正常
现象是示波器看 MCU 输出脉冲没问题,灯却规律闪烁。原因多半是过零检测边沿和实际过零点有偏差,或者触发脉冲落在可控硅关断区。解决方法是先单独测过零电路输出,确认边沿对齐零点,再调触发延迟的起始偏移量,通常留 0.5 到 1ms 余量。
5.2 低亮度段跳变或直接熄灭
现象是亮度调到 10 以下就不亮或突然跳到很亮。原因是延迟角接近半波末端,可控硅触发电流不够或维持电流不足。解决方法是限制最小亮度档,或在低亮度段改用更宽的触发脉冲,同时确认负载类型,LED 驱动和白炽灯的维持电流差别很大。
5.3 遥控一按灯就抖
现象是遥控连发时亮度乱跳甚至复位。原因是解码在中断里做了太多事,或者遥控和过零中断抢优先级。解决方法是解码只做波形到码值的转换,动作交给主循环队列,过零中断优先级设最高,遥控解码次之。
5.4 上电瞬间灯常亮
现象是通电后灯直接全亮,按键和遥控都失效。原因是调光定时器配成了连续 PWM 模式,或者触发引脚上电默认电平不对。解决方法是确认定时器工作在单脉冲模式,触发引脚初始化为低电平,上电先关输出再初始化状态机。
5.5 调光有嗡嗡声
现象是灯亮着但有明显工频噪声。原因是相位切割波形陡峭,负载和线路产生机械振动或谐波。解决方法是加扼流圈或滤波电容,触发角尽量避开半波起始的陡变区,必要时改用更高频率的调光方式。这类问题没有银弹,通常要结合负载实测。
6. 验收与答辩:怎么证明这套调光方案真的能交付
验收这套方案,我一般按「静态检查、分模块验证、联调、边界测试」四步走,每一步都有可观察的判据,答辩时直接拿数据说话。
第一步静态检查,看原理图和 PCB 的强电弱电隔离距离、光耦方向、过零检测分压比,用万用表测关键点静态电压。第二步分模块验证:过零电路单独上电,示波器看方波频率是否 100Hz(全波过零)且边沿对齐;PWM 触发单独测,看单脉冲宽度和延迟是否随亮度变化;遥控单独测,用逻辑分析仪抓解码输出,对照按键码表。第三步联调,本地调光和遥控交替操作,观察亮度是否平滑、有无跳变。第四步边界测试,覆盖亮度 0、1、99、100 四档,遥控连发 20 次,快速开关 50 次,记录有无复位、常亮、闪烁。
| 验收项 | 判据 | 工具 |
|---|---|---|
| 过零检测 | 100Hz 方波,边沿偏差小于 1ms | 示波器 |
| 触发脉冲 | 宽度符合器件手册,延迟随亮度单调 | 示波器 |
| 遥控解码 | 按键码与码表一致,连发不丢帧 | 逻辑分析仪 |
| 亮度平滑 | 0 到 100 过渡无跳变,无闪断 | 目视 + 录像 |
| 稳定性 | 连续开关 50 次不复位不常亮 | 实测记录 |
答辩时最容易被打的点是「你怎么证明低亮度段可靠」。我的建议是提前录一段从 0 调到 100 再调回 0 的视频,配上示波器触发波形同屏,比口头解释有说服力。另外把亮度到延迟的映射表打印出来,说明曲线来源,评委一看就知道你不是拍脑袋调的参数。
从那以后我每次做强电相关的课设或交付,都强制先单独验证过零和触发这两段,再碰调光逻辑,因为这两段一旦有偏差,后面所有调试都是在错误基准上打转。希望这套结构和验收方法能帮到你,需要完整工程的话可以按标题找这份资源对照着改。
本文还有配套的精品资源,点击获取