ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于STM32F103C8T6的PWM调光与红外遥控隔离驱动方案

基于STM32F103C8T6的PWM调光与红外遥控隔离驱动方案

简介:这份资源面向嵌入式初学者与电子爱好者,提供基于STM32F103C8T6的220V调光完整方案,涵盖PWM亮度调节、遥控解码与Flash掉电记忆三大功能。程序通过配置PWM通道的频率与占空比实现细腻调光,利用GPIO捕获红外或射频遥控脉冲并软件解码,同时借助内部Flash保存上次亮度,上电自动恢复。压缩包共182个文件,约3.76MB,以41个c源文件与43个h头文件构成核心代码,配合Keil工程文件、编译中间产物、hex与axf固件及原理图PDF,便于对照硬件连接理解实现。已有2110人学习,适合作为STM32外设编程与智能照明控制的实战参考。

1. 从一块最小系统板到一盏能调光的灯:这套方案要解决什么

手上只有一块 STM32F103C8T6 最小系统板,却要交一份能演示、能答辩、能写进报告的调光控制方案,这是很多毕设和课设里最典型的开局。难点不在写几行 PWM 输出,而在于把三件事串成一条完整链路:单片机产生可调占空比的驱动信号、驱动级把弱电信号安全地作用到 220V 灯具上、遥控端把人的操作解码成亮度指令。这套方案就是围绕这条链路展开的,适合正在做单片机课程设计、嵌入式毕设,或者要给出一份可交付调光控制方案的读者。它不追求堆功能,而是把 PWM 调光、220V 侧隔离驱动、红外遥控解码、按键与遥控双通道调光这几块讲透,让方案从需求到验收都能自圆其说。

2. 需求拆解与技术选型:为什么是 STM32F103C8T6 加 PWM 调光

2.1 功能需求先立住,别一上来就画电路

调光类项目最容易翻车的地方,是需求没定清楚就开始堆代码,最后发现遥控和按键抢资源、亮度跳变、220V 侧根本不敢上电。我一般会先把需求拆成四类,再决定技术路线。

第一类是控制需求:亮度要能连续可调,通常分 8 到 16 档就够演示,档位太少看不出调光效果,太多则按键操作繁琐。第二类是输入需求:至少支持按键本地调光和红外遥控调光两条通道,两条通道要能互相同步,不能各调各的。第三类是安全需求:220V 强电与 3.3V 控制侧必须电气隔离,这是整个方案的红线,不是可选项。第四类是演示需求:上电有默认亮度、调光过程平滑、遥控按键有明确映射,答辩时能一眼看懂。

把这四类需求写进设计文档的第一页,后面每一个模块的存在都能对应回某条需求,方案就不会散。很多同学的报告写不厚,根源就是需求部分只有一句“实现 LED 调光”,没有把档位、通道、隔离、默认状态这些约束讲出来。

2.2 主控选型:C8T6 够用,但要算清资源账

STM32F103C8T6 是 Cortex-M3 内核,主频 72MHz,64KB Flash、20KB SRAM,片上带多个通用定时器,其中 TIM1 是高级定时器,TIM2、TIM3、TIM4 是通用定时器,都能输出 PWM。对调光项目来说,资源完全够用,但要把账算清楚,否则后面加功能会捉襟见肘。

我通常这样分配:TIM3 的某个通道输出 PWM 驱动信号,因为 TIM3 是通用定时器,配置简单、不占用高级定时器的互补输出资源;TIM2 或 SysTick 做毫秒时基,给按键扫描和遥控解码提供节拍;红外接收用外部中断加定时器捕获,或者用定时器输入捕获测脉宽。GPIO 方面,PWM 输出占 1 个引脚,红外接收占 1 个,按键占 2 到 3 个,驱动使能占 1 个,剩下的引脚留给调试串口和状态指示。

这里有个血泪经验:不要一上来就把所有定时器都占满,留一个定时器做备用时基,调试阶段用得上。C8T6 的引脚复用关系要提前查数据手册,PWM 通道和串口、I2C 可能冲突,选引脚时就要避开。

2.3 驱动方案:220V 侧怎么安全地调

220V 调光有两条常见路线,一条是可控硅相位控制,一条是过零检测加可控硅斩波。前者电路简单但对感性负载和电网干扰敏感,后者调光更平滑但需要过零检测电路。对课设和毕设来说,通常的做法是采用光耦隔离加可控硅或专用调光驱动模块,控制侧只负责输出 PWM 或触发信号,强电侧由隔离驱动完成。

需要明确一点:PWM 直接驱动 220V 负载是不成立的,中间必须有隔离和功率驱动环节。方案里我会把驱动级单独画成一个模块,输入是 3.3V 的 PWM 或使能信号,输出是受控的 220V 回路,中间用光耦做电气隔离。这样写进报告,安全逻辑是完整的,答辩时被问到“强电怎么隔离”也能答上来。

提示:强电部分只做方案描述和接口定义,实际搭建必须由具备条件的人在隔离、断电、限流的前提下进行,控制板调试阶段先用 LED 或低压负载替代。

2.4 遥控方案:红外解码选 NEC 协议的理由

红外遥控里 NEC 协议最常见,帧格式固定:9ms 引导码、4.5ms 结果码、8 位地址、8 位地址反码、8 位命令、8 位命令反码,用脉宽区分 0 和 1。选它的理由很实际:时序规整、资料多、解码逻辑清晰,适合作为课设里的“协议解析”亮点。

解码方式有两种,一种是外部中断加定时器计时,记录每个电平的持续时间再判断;另一种是用定时器输入捕获直接测脉宽。前者实现简单,后者精度高。我一般用外部中断加一个微秒级计时变量,在中断里记录上升沿和下降沿的时间差,主循环里做状态机解析。这样不占用太多定时器资源,也方便调试时打印原始时序。

3. 总体设计:四个模块与一条控制链路

3.1 系统分层:控制层、驱动层、输入层、指示层

整套方案按职责分成四层,每层只做一件事,层与层之间用明确的接口变量通信。

控制层是核心,跑在主循环里,维护一个全局亮度变量brightness,取值范围 0 到 100,对应占空比 0% 到 100%。所有调光操作最终都落到修改这个变量,再由它去更新 PWM 比较寄存器。这样做的好处是按键和遥控两条通道不会打架,谁改都是改变量,PWM 输出统一刷新。

驱动层负责把亮度变量翻译成定时器比较值,并处理驱动使能、软启动等细节。输入层包括按键扫描和红外解码,输出的是“亮度加一档”“亮度减一档”“设置到某档”这类事件。指示层用 LED 或串口打印当前亮度和档位,答辩时直观。

模块主要职责关键接口
控制层维护亮度状态、调度调光事件brightness、set_brightness()
驱动层PWM 初始化与占空比刷新pwm_init()、pwm_set_duty()
输入层按键扫描、红外解码key_scan()、ir_decode()
指示层状态显示与调试输出status_update()

这张表建议直接放进设计文档的总体设计章节,模块边界一目了然。

3.2 控制主流程:一个主循环加两个节拍

主循环的结构我一般写成“节拍驱动加事件处理”,不写死延时,避免遥控解码被阻塞。

/* 主循环:1ms 节拍驱动按键与遥控,10ms 刷新一次 PWM */ int main(void) { system_clock_init(); /* 72MHz 系统时钟 */ pwm_init(); /* TIM3 通道输出 PWM */ key_init(); /* 按键 GPIO 初始化 */ ir_init(); /* 红外接收外部中断初始化 */ status_init(); /* 指示与串口初始化 */ set_brightness(50); /* 上电默认 50% 亮度 */ uint32_t tick_1ms = 0; while (1) { if (systick_elapsed(&tick_1ms, 1)) { /* 每 1ms 进入一次 */ key_scan(); /* 按键消抖与事件产生 */ ir_tick(); /* 红外解码状态机推进 */ } if (systick_elapsed(&tick_1ms, 10)) { /* 每 10ms 刷新输出 */ pwm_set_duty(brightness); /* 亮度变量映射到比较值 */ status_update(); /* 更新指示 */ } } }

这段代码的关键点有三个。第一,systick_elapsed用非阻塞方式判断时间,主循环不会被delay卡住,红外解码的时序才不会被破坏。第二,按键扫描放在 1ms 节拍里,消抖逻辑用计数器实现,连续多次采样一致才认一次按下。第三,PWM 刷新放在 10ms 节拍里,比 1ms 慢,是因为人眼对亮度变化的响应不需要那么快,降低刷新频率也能减少对其它任务的干扰。

参数上,brightness用 0 到 100 的整数,映射到定时器比较值时按compare = period * brightness / 100计算,注意整数运算的截断,亮度为 1 时不能算出 0,否则最低档会变成全灭。

3.3 亮度映射:线性占空比不等于线性观感

很多人第一次做调光会发现,占空比从 10% 调到 20%,肉眼几乎看不出变化,从 80% 调到 90% 却很明显。原因是人眼对亮度的感知接近对数关系,线性占空比映射到观感上并不线性。

课设层面不必上复杂的 Gamma 校正,但可以做一层简单的分段映射:低亮度区间步进小,高亮度区间步进大。比如把 0 到 100 的亮度值映射到占空比时,前 30 档每档占空比增加 1%,后 70 档每档增加约 1.4%。这样调光手感会均匀很多,答辩演示时“每一档都有变化”这一点很加分。

如果时间充裕,可以在驱动层加一张 16 项的查表,把档位直接映射到比较值,代码更清晰,也方便后期调整曲线。查表法的另一个好处是换驱动电路时只改表不改逻辑。

4. 关键模块实现:PWM 输出、红外解码与双通道调光

4.1 PWM 初始化:TIM3 通道配置与频率选择

PWM 频率的选择有讲究。频率太低,灯具会闪烁,人眼能察觉;频率太高,驱动级的光耦和可控硅可能跟不上。调光场景通常选 1kHz 到 5kHz,我一般取 1kHz 左右,兼顾闪烁抑制和驱动兼容性。

以 72MHz 主频、TIM3 为例,预分频设为 71,计数时钟就是 1MHz;自动重装值设为 999,PWM 频率就是 1MHz / 1000 = 1kHz,占空比分辨率 1000 级,足够 100 档亮度使用。

/* TIM3 通道 1 输出 1kHz PWM,分辨率 1000 */ void pwm_init(void) { /* 使能 TIM3 与 GPIOA 时钟 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* PA6 配置为复用推挽输出,对应 TIM3_CH1 */ GPIO_InitTypeDef gpio; gpio.GPIO_Pin = GPIO_Pin_6; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &gpio); /* 时基:72MHz / (71+1) = 1MHz,计 1000 个数为 1ms */ TIM_TimeBaseInitTypeDef tb; tb.TIM_Prescaler = 71; tb.TIM_Period = 999; tb.TIM_CounterMode = TIM_CounterMode_Up; tb.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &tb); /* 通道 1:PWM1 模式,初始占空比 50% */ TIM_OCInitTypeDef oc; oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_OutputState = TIM_OutputState_Enable; oc.TIM_Pulse = 500; oc.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM3, &oc); TIM_OC1PreloadConfig(TIM3, TIM_OCPreload_Enable); TIM_Cmd(TIM3, ENABLE); }

配置里几个参数要能解释清楚:预分频 71 是把 72MHz 分频到 1MHz,自动重装 999 是让计数器从 0 数到 999 共 1000 个 tick,所以 PWM 周期是 1ms。TIM_Pulse就是比较值,改它就能改占空比。TIM_OCPreload_Enable让比较值在更新事件时生效,避免改占空比时输出出现毛刺。

刷新占空比的函数单独封装,控制层只传亮度百分比:

/* 亮度 0~100 映射到比较值 0~1000,带最小占空比保护 */ void pwm_set_duty(uint8_t brightness) { if (brightness > 100) brightness = 100; uint16_t compare = (uint16_t)((uint32_t)brightness * 1000 / 100); if (brightness > 0 && compare == 0) compare = 1; /* 最低档不熄灭 */ TIM_SetCompare1(TIM3, compare); }

这里的最小占空比保护是踩过坑才加的:亮度设为 1 时,整数除法算出 10,没问题;但如果分辨率调低,比如周期只有 100,1% 就会算成 1,再低就归零,最低档直接灭灯,演示时很尴尬。

4.2 红外解码:NEC 协议的状态机实现

红外接收头输出的是反相电平,无信号时为高,有载波时为低。NEC 帧的引导码是 9ms 低加 4.5ms 高,数据位用 560us 低加不同长度的高来区分:高电平 560us 表示 0,1.69ms 表示 1。

解码用外部中断记录边沿时间,主循环里跑状态机。核心是维护一个“上一次边沿时间”和“当前解码状态”,每次下降沿到来时算出高电平持续时间,判断是引导码、数据位还是重复码。

/* 红外解码状态机:在 1ms 节拍中调用 */ void ir_tick(void) { if (!ir_frame_ready) return; /* 没有新边沿,直接返回 */ ir_frame_ready = 0; uint16_t duration = ir_last_duration; /* 单位 us,由中断记录 */ switch (ir_state) { case IR_IDLE: if (duration > 8000 && duration < 10000) { /* 9ms 引导码 */ ir_state = IR_LEADER; ir_bit_cnt = 0; ir_data = 0; } break; case IR_LEADER: if (duration > 4000 && duration < 5000) { /* 4.5ms 结果码 */ ir_state = IR_DATA; } else { ir_state = IR_IDLE; } break; case IR_DATA: if (duration > 400 && duration < 800) { /* 560us 判 0 */ ir_data <<= 1; } else if (duration > 1500 && duration < 1900) { /* 1.69ms 判 1 */ ir_data = (ir_data << 1) | 1; } else { ir_state = IR_IDLE; break; } if (++ir_bit_cnt >= 32) { /* 收满 32 位 */ ir_command = (uint8_t)(ir_data >> 16); /* 取命令字节 */ ir_state = IR_IDLE; ir_event = 1; /* 通知主循环处理 */ } break; default: ir_state = IR_IDLE; break; } }

这段状态机的容错点在于每个判断都给了时间窗口,而不是精确等于某个值。实际接收头的输出会有几十微秒的抖动,窗口太窄会大量丢帧。窗口也不能太宽,否则 0 和 1 会混淆,560us 和 1.69ms 之间留足间隔就行。

命令字节取出来后,主循环根据ir_event标志做映射,比如命令 0x18 对应亮度加,0x52 对应亮度减,0x45 对应开关。映射表建议写成数组或 switch,别散落在各处。

4.3 双通道调光:按键与遥控如何不打架

按键和遥控同时存在时,最容易出的问题是状态不同步:遥控把亮度调到 80,按键再按一下却从 50 开始加。解决办法是让两条通道都只操作同一个brightness变量,谁都不维护自己的副本。

按键扫描产生的是事件,不是直接改亮度:

/* 按键扫描:返回事件类型,不直接操作亮度 */ typedef enum { KEY_NONE, KEY_UP, KEY_DOWN, KEY_SET } key_event_t; key_event_t key_scan(void) { static uint8_t up_cnt = 0, down_cnt = 0; key_event_t ev = KEY_NONE; /* 上键:低电平有效,连续 3 次 1ms 采样一致才认 */ if (key_up_pressed()) { if (++up_cnt >= 3) { up_cnt = 0; ev = KEY_UP; } } else { up_cnt = 0; } /* 下键同理,略 */ return ev; }

主循环里统一处理事件:

key_event_t ev = key_scan(); if (ev == KEY_UP) set_brightness(brightness + 5); if (ev == KEY_DOWN) set_brightness(brightness - 5); if (ir_event) { ir_event = 0; if (ir_command == 0x18) set_brightness(brightness + 5); if (ir_command == 0x52) set_brightness(brightness - 5); }

set_brightness内部做边界钳位和变化检测,亮度没变就不刷新 PWM,减少无谓操作。这样按键和遥控天然同步,因为它们的输入都汇到同一个函数。步进取 5 是个折中,100 档分 20 步,按起来不累,演示时变化也明显。

注意:红外解码和按键扫描都在 1ms 节拍里跑,单次执行时间要控制住。红外状态机只做判断和移位,不做串口打印,打印放到主循环的低优先级分支里,否则会拖慢节拍导致丢帧。

5. 避坑与排查:调试阶段最容易翻车的五个点

5.1 PWM 有输出但灯不亮或亮度不随档位变化

现象是示波器能看到占空比在变,但负载端没反应。原因通常有两个:一是驱动级的使能信号没拉高,光耦或驱动模块处于关闭状态;二是 PWM 极性配反了,TIM_OCPolarity_High配成Low后,占空比 90% 实际输出的是 10% 的低电平。解决方法是先用万用表测驱动使能脚电平,再核对TIM_OCInitTypeDef里的极性字段,最后用示波器直接量驱动级输入端的波形,逐级排除。

5.2 红外遥控偶尔失灵,换个角度就好

现象是遥控要对着特定角度、特定距离才响应。原因多半是解码时间窗口太窄,或者接收头的供电滤波不足。NEC 的 0 和 1 判据窗口如果只留 ±100us,接收头输出稍有抖动就判错。解决方法是把窗口放宽到 ±200us 左右,同时在接收头电源脚就近加 100nF 和 10uF 电容。另外要确认外部中断优先级高于其它中断,否则边沿时间记录会被打断,算出的脉宽失真。

5.3 调光时灯具闪烁或发出啸叫

现象是低亮度档位闪烁明显,或者能听到轻微啸叫。原因是 PWM 频率偏低,或者驱动级是过零触发型,PWM 频率和电网频率不同步。解决方法是把 PWM 频率提到 2kHz 以上,先试 4kHz;如果驱动级是可控硅斩波,则要改成过零同步触发,PWM 只做使能,斩波相位由过零检测后的延时决定。课设阶段如果只做低压 LED 演示,把频率提到 1kHz 以上基本就能消除可见闪烁。

5.4 亮度变量改了但输出不变

现象是串口打印的亮度值在变,PWM 占空比却不动。原因通常是pwm_set_duty没被调用,或者比较值算出来后没写进寄存器。检查主循环里刷新分支是否真的每 10ms 执行一次,systick_elapsed的写法是否正确——常见错误是把时间戳传值而不是传地址,导致判断永远为假。另一个可能是TIM_SetCompare1用错了通道,TIM3_CH1 对应的是TIM_SetCompare1,写成TIM_SetCompare2就改到别的通道去了。

5.5 上电瞬间亮度跳变或驱动级误触发

现象是上电时灯闪一下,或者驱动级在初始化期间被误触发。原因是 GPIO 初始化前引脚处于浮空状态,驱动级输入不确定;或者 PWM 初始化时比较值默认是 0,但极性配置导致输出高电平。解决方法是在 GPIO 初始化时先把输出脚拉低,再配置为复用功能;PWM 初始化时先设一个安全的初始比较值,再使能定时器。驱动使能脚加上拉或下拉电阻,保证上电默认是关闭状态。

6. 验收与答辩:怎么证明这套调光方案真的跑通了

验收不是看代码行数,而是看行为是否可复现。我一般按四步走,每一步都有明确的通过标准。

第一步,静态检查。确认 PWM 引脚、红外引脚、按键引脚与原理图一致,驱动级隔离器件型号和方向正确,强电部分有明确的隔离边界标注。这一步在答辩时用一张接口定义表就能说清,表里列出每个引脚的功能、电平、连接对象。

第二步,分级上电。先只给控制板供电,用示波器测 PWM 输出,验证占空比随亮度变量线性变化,频率稳定在设定值。再接入低压负载替代强电负载,验证驱动级使能逻辑。最后才在具备条件时接入隔离后的强电回路。每一步都记录波形或现象,写进验收记录。

第三步,功能验收。按键连续加档到 100 再减到 0,观察是否有跳档、卡档;遥控在 3 米内不同角度各按 10 次,统计识别成功率,课设层面 90% 以上算通过;按键和遥控交替操作,确认亮度状态同步。下面这张表可以直接作为验收清单。

验收项操作通过标准
PWM 输出示波器测占空比亮度 0/50/100 对应 0%/50%/100%,误差小于 2%
按键调光连续按上/下键每步 5 档,边界不越界,无跳档
遥控调光3 米内多角度按键识别成功率不低于 90%
双通道同步遥控调后按键调亮度从当前值继续增减,不重置
上电默认反复上电默认亮度固定,无闪烁跳变

第四步,答辩陈述。按“需求—方案—实现—验证”四段讲,每段控制在两分钟。重点讲三个设计决策:为什么选 PWM 调光而不是模拟调压,为什么强电侧必须隔离,为什么两条输入通道共用一个亮度变量。被问到细节时,直接打开示波器波形或串口日志,比口头解释有说服力。

从那以后我每次带这类调光项目,都强制先写验收清单再写代码,清单里每一条都能对应到一个可观测的现象,代码写完逐条打勾,答辩时心里就有底。希望这套结构能帮到你,把手上那块最小系统板变成一份拿得出手的完整方案。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进