
简介基于STM32F407ZET6的自动灯光调节工程资源面向嵌入式开发者与STM32学习者演示如何借助光敏电阻感知环境光强通过ADC采集与PWM占空比控制实现LED亮度自动调节并加入按键手动控制。系统在环境光线变暗时增大PWM占空比使LED更亮在光线充足时自动降低亮度既节能又能提供舒适照明。压缩包容量约457KB共107个文件其中包含49个C源码、50个H头文件以及hex固件、uvprojx工程、启动文件和一批stm32f4xx标准外设库文件如TIM、ADC、RCC等可直接在Keil环境中编译烧录。工程结构覆盖GPIO、ADC、PWM、中断等关键外设的初始化与调用流程代码中包括系统时钟配置、光敏电阻采样、PWM调光、按键响应等核心环节以C语言为主也适合结合C进行面向对象封装练习。从工程模板到外设驱动均有完整示例便于快速上手。已有4172人学习下载适合课程设计、毕业设计或项目移植能够帮助理解智能照明的完整实现思路。1. 光敏电阻采样到 PWM 调光整个链路从 ADC 读数开始直接把这个工程当成“点灯”就低估它了。stm32f407zet6 上做自动灯光调节核心链路只有三条光敏电阻把光照转成电阻变化stm32f407 的 ADC 把电阻分压转成数字量再用 TIM 输出 PWM 控制 LED 亮度。但实际调下来你会发现ADC 读到的数值在不同板子上差异极大——同一光照条件下有的板子读 2200有的读 3200甚至同一块板子相隔一小时读数都能漂 100 多。原因是光敏电阻的响应曲线非线性且和分压电阻、VREF 精度强相关所以“读 ADC → 线性映射到 PWM”这种最简单写法注定没法用。这个项目适合两类人一是做课程设计需要把传感器、中断、定时器串成完整闭环的在校生二是想系统梳理 STM32F4 系列 ADC 采样、DMA 搬运、PWM 输出、按键中断这几个外设配合方式的在职工程师。下面按照我实际调这块板子的顺序来讲先解决“读数稳不稳”再解决“映射合不合理”最后处理手动和自动切换时的状态冲突。2. ADC 采样链路光敏电阻分压网络与 12 位采样配置2.1 分压网络的器件选型与接线光敏电阻本身不能直接接 ADC必须和一个固定电阻串联构成分压电路。常见的接法有两种光敏电阻接 VCC、固定电阻接地或者反过来。我习惯把光敏电阻放在 VCC 侧、固定电阻接地侧这样环境光越强光敏电阻阻值越小ADC 引脚电压越低读数值越小语义上和“光照越强数值越低”的直觉对应。固定电阻的取值直接决定了 ADC 读数的动态范围。光敏电阻在暗环境下的阻值可以到几百 kΩ强光下只有几 kΩ跨度非常大。如果固定电阻取 10kΩ那么暗环境下分压接近 VCCADC 读满量程强光下分压非常低读数接近 0动态范围确实够用。但如果固定电阻取 100kΩ光照中等时读数就会长时间停留在 3000 以上LED 亮度变化的区分度很差。常见做法是取 4.7kΩ 到 10kΩ 之间我最终用了 10kΩ因为 stm32f407zet6 的 ADC 是 12 位量程 0~409510kΩ 配合常见的光敏电阻可以在室内自然光环境下覆盖大约 400~3800 的范围区分度最好。接线时注意ADC 引脚 PA1 要远离板载 PWM 输出引脚强驱动信号会耦合到高阻采样网络里导致读数抖动。如果板子上 PA1 正好挨着 TIM 的 CH 输出引脚宁可换一个 ADC 通道也不要飞线硬接。2.2 ADC 初始化12 位分辨率与 480 周期采样时间stm32f407zet6 的 ADC 最高 12 位分辨率时钟来自 APB2默认 84MHz。ADC 内部需要一个 12MHz 左右的等效时钟所以要设置预分频。这里有个容易忽略的参数采样时间。ADC 采样的过程是内部开关闭合给采样电容充电充电时间不足读数就会偏低且不稳定。光敏电阻分压网络的等效源阻抗在几十 kΩ 级别必须用长采样时间。ADC_HandleTypeDef hadc1; static void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ClockPrescaler ADC_CLOCK_SYNC_PCLK_DIV4; // 84MHz / 4 21MHz hadc1.Init.Resolution ADC_RESOLUTION_12B; // 12位4096级 hadc1.Init.ScanConvMode DISABLE; // 单通道不需要扫描 hadc1.Init.ContinuousConvMode ENABLE; // 连续转换配合DMA hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.EOCSelection ADC_EOC_SINGLE_CONV; hadc1.Init.SamplingTimeCommon ADC_SAMPLINGTIME_480CYCLES; hadc1.Init.Overrun ADC_OVR_DATA_OVERWRITTEN; HAL_ADC_Init(hadc1); }关键参数说明ADC_SAMPLINGTIME_480CYCLES是重点480 个 ADC 时钟周期换算到 21MHz 时钟下大约是 22.9 微秒这对几十 kΩ 源阻抗来说是够用的。如果用 3 周期或 15 周期的采样时间读到的数值会比真实值偏低 20% 到 30%而且这个偏差会随着环境温度变化漂移很难校准。ContinuousConvMode必须设为 ENABLE因为后面要接 DMA 循环模式如果只转换一次DMA 收到一个数据后就不再搬运了。2.3 DMA 循环模式为什么不用中断轮询很多初学方案是在主循环里调用HAL_ADC_Start()然后阻塞等待转换完成再读HAL_ADC_GetValue()这样做的直接后果是主循环被 ADC 转换时间卡住。ADC 转换加上 480 周期的采样单次耗时约 30 微秒看起来不长但主循环还要处理按键扫描、PWM 渐变、状态切换累加起来延迟会超过 1 毫秒按键响应明显变肉。DMA 循环模式是更合适的选择。ADC 连续转换DMA 自动把结果搬运到内存数组CPU 完全不介入。配置如下DMA_HandleTypeDef hdma_adc1; static void MX_DMA_Init(void) { __HAL_RCC_DMA2_CLK_ENABLE(); hdma_adc1.Instance DMA2_Stream0; hdma_adc1.Init.Channel DMA_CHANNEL_0; // ADC1 映射到 DMA2 Stream0 Channel0 hdma_adc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc DMA_PINC_DISABLE; // 外设地址固定 hdma_adc1.Init.MemInc DMA_MINC_ENABLE; // 内存地址递增填充数组 hdma_adc1.Init.PeriphDataAlignment DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment DMA_MDATAALIGN_HALFWORD; // 12位数据用16位搬运 hdma_adc1.Init.Mode DMA_CIRCULAR; // 循环模式写满自动回卷 hdma_adc1.Init.Priority DMA_PRIORITY_HIGH; HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); }DMA 配置的要点集中在传输方向、数据宽度和循环模式三个地方。DMA_PERIPH_TO_MEMORY表示从 ADC 数据寄存器往内存搬外设地址不需要递增内存地址必须递增这样每次转换结果会依次填入数组的不同位置。注意DMA_PDATAALIGN_HALFWORD和DMA_MDATAALIGN_HALFWORD必须配对ADC 的 12 位结果存储在 16 位寄存器的低 12 位如果按字节对齐搬运高字节会错位。DMA_CIRCULAR是核心优势数组写满后 DMA 自动把写入位置重置到数组开头不需要手动清零或者重新启动传输。启动时只需要一次调用HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 16);这句的意思是ADC1 在 DMA 的配合下持续转换每完成一次转换结果存入adc_buf数组的下一个位置数组长度 16写满后回到开头继续覆盖。之后主循环里直接读adc_buf数组就行数据永远是最近 16 次转换的滚动窗口不需要关心转换时序。这里数组长度 16 是经验值太短了滤波效果差太长了内存浪费明显16 对 12 位 ADC 来说完全足够。2.4 主循环里怎么取用 DMA 数据DMA 在后台持续写入adc_buf主循环读取时要注意一个细节如果主循环恰好读到 DMA 写了一半的位置会拿到一个新旧混合的数据但这在 16 位对齐、单次写入的前提下几乎不可能发生撕裂。16 位数据搬运用一条 LDRH 指令即可完成原子性有保证所以直接读没有问题。配置项参数说明ADC 时钟21MHzAPB2 84MHz 四分频满足 ≤36MHz 规格分辨率12 位0~4095匹配 PWM 1000 级分辨率采样时间480 周期适配高阻源约 22.9 微秒DMA 模式CIRCULAR循环搬运无 CPU 中断开销数据宽度HALFWORD16 位对齐避免数据错位缓冲区长度16滑动平均的基础取平均的逻辑放在主循环里做每 50 毫秒从adc_buf里取 16 个值求平均作为这一周期内的有效光照读数。这样 PWM 输出的亮度不会因为单次采样噪声而闪动。这个平均窗口对应 50ms 的响应周期人眼对灯光变化的感知本来就是渐进的50ms 的更新率在自动调光场景下完全够用。3. TIM1 PWM 调光占空比映射与亮度曲线3.1 为什么用 TIM1时钟域与频率选择stm32f407zet6 的 TIM1 挂在 APB2 总线上时钟 168MHz比挂在 APB1 上的 TIM2~TIM5 的 84MHz 高了一倍。虽然对 PWM 调光来说 84MHz 也够用但用 TIM1 可以在相同分频系数下获得更细的步进或者用更大的 ARR 值换取更高的分辨率。PWM 输出频率需要想清楚太低了LED 会有肉眼可见的频闪摄像头下尤其明显太高了MOSFET 驱动损耗增加LED 驱动芯片的响应可能跟不上。做室内智能照明5kHz 到 20kHz 是常用区间。我最后定了 10kHz这个频率下人眼完全无感知且绝大多数 LED 驱动电路都能稳定响应。TIM1 配置要确认 ARR 的上限。 16 位定时器PSC 和 ARR 都是 16 位寄存器最大 65535。计算输出频率的公式是f 168MHz / ((PSC 1) * (ARR 1))。为了得到 10kHz同时尽量提高占空比分辨率取 PSC 83ARR 199这样168MHz / (84 * 200) 10kHz占空比分辨率 1/200 0.5%这个精度对 LED 亮度调节来说已经超出了人眼的可分辨范围。3.2 占空比映射策略线性拉伸为什么不行拿到 ADC 平均值后最简单的做法是duty 1000 - adc_value / 4看起来能把 0~4095 映射到 0~1000但实际效果很差。光敏电阻在暗处的阻值变化斜率大ADC 读数的微分变化在低照度区非常敏感映射后 PWM 在低光照区迅速顶满强光区抬升平缓LED 亮度几乎不变。结果就是黄昏时分灯一下子跳到最亮下午阳光直射时灯又几乎全灭毫无过渡可言。我采用的方案是分段线性映射先标定三个关键点。第一步在完全黑暗的环境中记录 ADC 读数记为adc_dark第二步在室内正常照明下记录读数记为adc_indoor第三步用手机手电筒近距离照射光敏电阻记录读数记为adc_bright。这三个点分别对应 PWM 占空比的三个目标值暗处 80%室内 30%强光 5%。这样做的原因很直接自动灯光调节的目的不是“光照越强灯越暗”这种线性关系而是在人需要补光的时候给足亮度环境光充足的时候迅速降低功耗。uint16_t map_adc_to_duty(uint16_t adc_val) { static const uint16_t adc_pts[3] {400, 2000, 3600}; // 标定点 static const uint16_t duty_pts[3] {80, 300, 850}; // 对应占空比 if (adc_val adc_pts[0]) return duty_pts[0]; if (adc_val adc_pts[2]) return duty_pts[2]; if (adc_val adc_pts[1]) { return duty_pts[0] (uint32_t)(adc_val - adc_pts[0]) * (duty_pts[1] - duty_pts[0]) / (adc_pts[1] - adc_pts[0]); } else { return duty_pts[1] (uint32_t)(adc_val - adc_pts[1]) * (duty_pts[2] - duty_pts[1]) / (adc_pts[2] - adc_pts[1]); } }映射函数只做两件事查区间然后做比例插值。adc_pts数组存的是标定时记录的三个 ADC 读数duty_pts存的是对应的 PWM 占空比目标值。两个区间分别计算避免用一把尺子量到底。注意乘法用(uint32_t)强转adc_val - adc_pts[0]最大 3000 多乘以差值后接近千万级16 位乘法会溢出必须转 32 位。这个细节在写代码的时候最容易忽略但恰恰是数据稳定后最容易踩的坑。3.3 PWM 输出与 LED 驱动接线TIM_HandleTypeDef htim1; TIM_OC_InitTypeDef sConfigOC; htim1.Instance TIM1; htim1.Init.Prescaler 84 - 1; // 168MHz / 84 2MHz htim1.Init.CounterMode TIM_COUNTERMODE_UP; htim1.Init.Period 200 - 1; // 2MHz / 200 10kHz htim1.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(htim1); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 0; // 初始占空比 0 sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_PWM_ConfigChannel(htim1, sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim1, TIM_CHANNEL_1);设置Pulse 0而不是直接跳过这一步是因为有些参考代码在调用HAL_TIM_PWM_Start后不初始化比较寄存器输出变成随机电平LED 上电瞬间会闪一下。先写 0 再启动定时器可以保证上电时输出一直是低电平。TIM1 的 Channel 1 输出引脚是 PA8。PA8 同时是 MCO1 的复用引脚部分开发板默认把它配置为时钟输出如果发现 PWM 没有波形先查板子的跳线帽或者原理图上 PA8 是否被其它外设占用。LED 驱动不推荐直接接 GPIO 灌电流stm32f407 的 GPIO 输出驱动能力有限直接驱动高亮 LED 会导致引脚发热和电压跌落。常见做法是加一个 NPN 三极管或 N-MOSFETPWM 接栅极或基极LED 串限流电阻后接 VCC。4. 按键 GPIO 中断与手动/自动状态切换4.1 按键接线与外部中断配置手动控制需要至少两个按键一个切换自动/手动/关闭三种模式一个在手动模式下调整亮度。按键接法有两种区别在中断触发的边沿方向。按键一端接 GPIO、一端接 GND 时GPIO 内部上拉输入按键按下时 GPIO 从高电平变为低电平触发下降沿中断按键一端接 3.3V 时按下触发电平上升需要配置上升沿中断。以我们板子上的设计为例使用的是按下接 GND 的方式所以配置下降沿触发。GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOE_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_4 | GPIO_PIN_3; GPIO_InitStruct.Mode GPIO_MODE_IT_FALLING; // 下降沿触发 GPIO_InitStruct.Pull GPIO_PULLUP; // 内部上拉按下为低 HAL_GPIO_Init(GPIOE, GPIO_InitStruct); HAL_NVIC_SetPriority(EXTI4_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI4_IRQn); HAL_NVIC_SetPriority(EXTI3_IRQn, 2, 0); HAL_NVIC_EnableIRQ(EXTI3_IRQn);GPIO 配置里GPIO_MODE_IT_FALLING表示外部中断模式下降沿触发。GPIO_PULLUP是必须的如果按键另一端接 GND 而 GPIO 没有上拉按键松开时引脚电平悬空中断会被噪声反复触发。PE4 按键承担模式切换功能PE3 按键承担手动亮度调节功能。4.2 中断回调只置标志位按键消抖不能放在中断服务函数里因为在中断里调用HAL_Delay会阻塞整个系统而中断服务函数应该尽快返回。处理方法是中断里只置标志位主循环里检测到标志位后再做延时和电平确认volatile uint8_t mode_key_pressed 0; volatile uint8_t adj_key_pressed 0; void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_4) { mode_key_pressed 1; // 通知主循环处理模式切换 } else if (GPIO_Pin GPIO_PIN_3) { adj_key_pressed 1; // 通知主循环处理亮度调整 } }回调函数命名HAL_GPIO_EXTI_Callback是 HAL 库定义好的弱函数在用户代码里重写即可不需要额外注册。关键点在两个标志位加volatile中断函数和主循环共享变量时编译器优化可能把主循环里的标志位读取缓存到寄存器而不重新加载加上 volatile 强制每次从内存读取。回调里没有任何耗时操作整个执行时间在微秒级符合中断处理的原则。4.3 主循环中的状态机处理模式切换在标志位置位后统一在主循环里处理这样不用考虑中断嵌套和时序冲突。状态机定义三个模式自动调节、手动调节、关闭。自动模式下PWM 输出由 ADC 映射函数计算手动模式下每次按键步进 5% 占空比关闭模式下PWM 输出 0。typedef enum { LIGHT_AUTO 0, LIGHT_MANUAL, LIGHT_OFF } LightMode; LightMode current_mode LIGHT_AUTO; uint16_t manual_duty 300; // 手动模式初始占空比 30% void handle_mode_key(void) { mode_key_pressed 0; HAL_Delay(20); // 消抖确认 if (HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_4) GPIO_PIN_RESET) { current_mode (LightMode)((current_mode 1) % 3); } }函数开头先清除标志位再延时和常见做法的顺序正好相反。这样做有一个好处如果按键在延时期间产生多次中断标志位不会再被置位因为按下状态只有一次有效转换如果先延时再清除标志位延时期间产生的中断可能把标志位置位导致一次按键被误判为两次。(current_mode 1) % 3实现 0→1→2→0 的循环计数比 switch 判断更简洁。然后在主循环里做模式调度while (1) { if (mode_key_pressed) handle_mode_key(); if (adj_key_pressed) { adj_key_pressed 0; HAL_Delay(20); if (HAL_GPIO_ReadPin(GPIOE, GPIO_PIN_3) GPIO_PIN_RESET) { manual_duty 50; // 每次按键增加 5% manual_duty 50; // 修正每次按键增加50 if (manual_duty 200) manual_duty 50; // 到顶回卷从5%重新开始 } } switch (current_mode) { case LIGHT_AUTO: set_pwm_duty(map_adc_to_duty(get_avg_adc())); break; case LIGHT_MANUAL: set_pwm_duty(manual_duty); break; case LIGHT_OFF: set_pwm_duty(0); break; } HAL_Delay(20); }这段代码里有一个值得注意的细节手动模式每次按键步进 50也就是 5% 占空比超过 200 时回卷到 50。为什么不直接加到 200 就停止因为用户可能在手动模式下不小心把灯调到很暗如果没有回卷逻辑需要按很多次才能调回来。回卷设计让调节有循环感使用体验更顺手。4.4 C/C 混合工程的组织思路这个工程源码用 C 写但工程文件列表里明确提到 C/C说明编译链同时支持两种语言。实际开发时推荐把手动/自动模式切换这块封装成 C 类对外提供setMode、setDuty接口底层 ADC 采样和 PWM 输出仍保留在 C 文件里。C 文件通过extern C导出函数给 C 调用C 文件里维护状态机逻辑。这样做的好处是把硬件访问和业务逻辑分离ADC 和 PWM 代码一旦调好就基本不动模式切换、按键逻辑、亮度映射这类业务代码可以方便地扩展测试。5. 数据滤波与照度标定把 ADC 原始值变成可用光照度5.1 滑动平均加限幅消掉突变毛刺DMA 缓冲区的 16 个采样值直接平均后读到的光照值仍然偶发跳变。跳变的来源主要是市电 50Hz 的灯光频闪以及周围物体移动造成的阴影变化。如果直接把平均值作为映射输入亮度会跟随这些毛刺上下跳动观感非常差。解决方案是两级处理第一级DMA 缓冲区的 16 个值取平均第二级对平均值做变化限幅即每次允许的最大变化幅度不超过 80。如果前后两次读数的差值超过 80认为是一次干扰将本次读数组装为上一次的值加或减80。80 对应 12 位 ADC 的约 2% 量程在自动调光场景下这个变化率折算到 PWM 占空比上约 8%既不会让人觉得灯突然跳动也不会让灯对真实的光照变化反应迟钝。5.2 用三段标定建立 ADC 到照度的映射如果只是做自动调光直接把 ADC 值映射到 PWM 占空比就够了。但如果后续要做照度上报、日志记录或者多灯联动就需要把 ADC 读数换算成 lux。光敏电阻的阻值与照度之间不是线性关系而近似为R A * lux^(-γ)所以 ADC 读数与 lux 之间接近幂函数关系直接用线性拟合误差很大。常见做法是采集多个标定点做分段线性插值。不需要专业照度计用手机的自动亮度传感器或者已知参数的 LED 光源作为参考即可。标定方法是在暗室、室内日光灯、窗户旁、手电筒直射四种典型环境下分别记录 ADC 平均值同时用手机照度 App 记录环境照度组成四组对应关系存入查找表。下表是我在这块开发板上实测的一组标定数据不同批次的光敏电阻会有差异但数量级可以参考环境描述ADC 读数环境照度 (lux)关闭窗帘的暗室36808室内日光灯2150120窗户旁散射光820800手电筒近距离2603000利用这组数据写一个插值函数输入 ADC 读数输出对应的照度估算值。这里要注意一个反向关系光照越强光敏电阻阻值越低ADC 读数越大还是越小取决于你的分压接法。以本文采用的“光敏电阻接 VCC、固定电阻接地”方案为例光照越强 ADC 读数越大。如果你用的是另一种接法映射函数里的比较方向要反过来。5.3 渐变过渡亮度平滑变化的工程实现直接把映射函数计算出的目标占空比写入比较寄存器会带来一个体验问题模式切换瞬间LED 从 5% 跳到 80%人会明显感觉刺眼。加一层渐变逻辑每次 PWM 更新只朝目标占空比移动一小步步长取 1即一次主循环周期20ms变化 0.5%。这意味着从 5% 到 80% 需要 1.5 秒完成过渡视觉上是平滑的。void set_pwm_duty(uint16_t target) { static uint16_t current 0; if (current target) current 1; else if (current target) current - 1; __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, current); }__HAL_TIM_SET_COMPARE是 HAL 提供的宏直接修改 TIM1 的 CCR1 寄存器不需要停止定时器。渐变过程中current是静态局部变量保持在上一次输出的位置每次主循环向目标靠拢一步。把渐变步长放在输出函数而非映射函数里意味着无论模式切换还是自动调节触发的亮度变化都会经过同一个平滑路径。这是一个值得坚持的工程习惯输入路径可以有多种输出路径只有一条统一在这一条路径上做处理逻辑不容易漏。本文还有配套的精品资源点击获取