基于STM32的车位占用检测系统:从红外传感到ESP8266上报 简介这是一套基于STM32单片机设计的停车场车位占用情况检测系统项目包适用于毕业设计、课程设计、工程实训及学科竞赛等场景。项目包含完整源码、工程文件与说明源码采用标准STM32库编写涵盖车位检测、状态指示与通信等核心逻辑可直接编译烧录运行。压缩包内共有856个文件以C源码、H头文件、启动汇编文件、链接脚本及工程配置文件为主同时包含原理图、PCB设计文件、日志与图片资源整体体积约32.18MB目录结构清晰便于查找。目前已有108人学习下载项目经过严格测试可直接复现若不会画板还可参考引脚定义改用面包板、杜邦线与外设模块搭建硬件。拿到资料包后既能按原方案快速复刻也能在此基础上扩展更多智能停车功能。1. 基于STM32的停车场车位占用检测系统到底在解决什么一辆车开进车位安装在车位上方或地面的传感器在几百毫秒内完成一次采样STM32根据阈值判定出“占用”或“空闲”再把车位编号和状态推送到OLED屏幕或上位机——这就是基于STM32设计的停车场车位占用情况检测系统要做的事。把这个题目做成毕设、课设或实训项目难度梯度很合适硬件只有传感器、最小系统板、显示和通信模块软件则涉及GPIO、ADC、定时器、串口和DMA这几类嵌入式外设。它不像平衡车那样依赖控制算法也不像网关类项目需要深度协议栈恰好把单片机外设串起来用了一遍所以在各类竞赛和课程项目中高频出现。下面的路线按“检测原理—器件选型—HAL库编码—显示上报—调试交付”展开最终落成一块可独立工作的车位检测节点。2. 从检测原理到器件选型车位占用检测为什么是组合决策安装位置决定检测原理检测原理决定STM32用哪组外设。这个系统表面看只是判断“车位上有没有车”但红外反射、超声波测距、地磁扰动三种方案各有各的探测量、量程和误报来源选错方案后面调阈值会非常难受。这一章先对比三者的差异再落到STM32最小系统最后把传感器信号进MCU之前的处理讲清楚。2.1 红外反射、超声波、地磁三选一先看安装位置2.1.1 红外反射车位正上方垂直向下安装红外对管TCRT5000、ITR8307类把发射管和接收管集成在同一个封装里发射管发出约940nm红外光光打在车顶表面后反射回来被接收管捕获输出端电压随反射光强度变化。安装在车位正上方垂直向下时反射面就是车顶平面信号起伏规律探测距离通常落在3到25cm通过调节发射管限流电阻可以改变灵敏度。这个方案最大的干扰来自太阳光和旁边车位的车灯做课设时优先考虑给红外管套一个黑色热缩管做遮光罩能明显压低环境光干扰。信号读取上接收管输出是模拟量超过阈值就认为“有车”。不要用滞回比较器去接GPIO直接把输出送到ADC引脚让MCU来判定这样阈值参数可以在固件里修改而不用每次换电阻。2.1.2 超声波用回波时间算距离不受可见光影响HC-SR04或US-015是常见的超声波测距模块STM32给Trig脚输出一个10us以上的高电平模块发出40kHz脉冲回波到来后Echo脚持续高电平高电平时长与距离成正比。距离计算公式是distance(cm) t_us / 58从公式能推出采样周期如果安装高度是2米最远测距2.5m一次往返大约需要14.7ms。HC-SR04需要物体表面有平整反射车辆侧面和保险杠通常满足条件而柱子、消防栓这类细长物体会因为声束扩散造成误报。超声波模块的供电电流在空闲状态下很小但测量瞬间能到15mA以上MCU的3.3V LDO不要直接挂在总线上最好给模块单独供5V电平输出端再分压到3.3V。2.1.3 地磁贴地安装检测车体对磁场的扰动地磁方案利用车体金属部件对地球磁场的扰动来判断占用传感器如HMC5883L或QMC5883L埋在车位地面下。它不依赖上方空间适合室内多层停车场的车位几何条件。代价是需要提前埋设且相邻车道的车辆会带来磁场串扰需要做方向分量加阈值窗口的复合判断标定和后期维护成本明显高。对于毕设和实训这个方案在时间成本上不划算。三种方案的对比参数如下方案探测量有效量程典型误报源传感器成本综合难度红外反射反射光强度325 cm阳光、邻车灯光极低低超声波测距回波时间5250 cm细柱、排气管反射中中地磁扰动磁场分量贴近路面相邻车道磁场串扰偏高较高综合来看课设和实训优先选红外反射安装位置固定为车位正上方如果项目要求场景更接近商业停车场的封闭区域选超声波因为它的量程允许传感器安装在更高的梁上探测一致性更容易保证。2.2 STM32 选型与最小系统框架2.2.1 为什么是 STM32F103C8T6车位检测节点的计算任务并不重但外设需求正好卡在F103系列的中等配置上一路ADC采集红外输出电压一路定时器做周期调度两路UART分别接ESP8266和调试口再加一路I2C驱动OLED。STM32F103C8T6有48个引脚、64KB Flash、20KB SRAM主频72MHz片上集成3个UART、2个I2C、2个SPI和2个12位ADC成本和供货都比较稳妥。对新接触STM32的人来说Keil5安装STM32芯片包时选择“STM32F1xx Device Support”新建工程时选C8T6对应型号编译后通过ST-Link用SWD接口烧录整个工具链资料非常齐全。2.2.2 晶振电容计算8MHz无源晶振按负载电容反推最小系统板上那颗8MHz无源晶振配两个负载电容是关键。晶振规格书里会给出负载电容CLSTM32F103C8T6官方参考设计常用12pF或20pF。引脚上的两个电容CL1、CL2串联后再与走线寄生电容CS并联关系式是CL (CL1 × CL2) / (CL1 CL2) CS取CL1CL2走线寄生电容按4pF估算如果选用20pF负载电容的晶振CL1和CL2应约为32pF选用12pF规格时两个电容取约16pF。电容误差选5%或10%都够但两个电容尽量同批次避免匹配误差导致起振困难。测量波形时示波器探头要夹10x档测X1引脚直接用1x档会因探头电容改变频率。2.3 传感器信号进 STM32 之前的信号调理红外对管的输出是毫伏到伏级的电压可以直接进PA0的ADC通道。但直接从模块输出口飞线到MCU容易引入共地噪声常见做法是在中间加一级RC低通R取10kΩC取0.1uF截止频率约160Hz能滤掉50Hz工频倍频和灯具闪烁造成的低频扰动。ADC采样周期定为50ms等于每20次采集中间隔着2个RC时间常数电平已经平复。注意如果传感器模块内部已经带了比较器输出高低电平那就接普通GPIO读电平不要再接ADC通道否则你读到的是0和3.3V两个饱和值阈值判断失去意义。给电路加一个下拉电阻也值得做传感器输出悬空时ADC读数为0程序会把该状态判定为“空闲”相比随机读数要好处理得多。3. 用HAL库把车位检测核心逻辑写进 STM32框架确定后编程分三步走先用CubeMX把外设引脚和时钟配置生成骨架然后实现ADC采样和定时器调度最后写占用状态判定。这一章围绕一个车位的检测节点来写代码基于STM32 HAL库工程语言C。3.1 CubeMX 配置图表一个车位节点用掉哪些外设打开CubeMX新建STM32F103C8T6工程时钟树将HSE设为8MHz晶振、SYSCLK配到72MHz后按下表分配外设。表格里的引脚对应最常见的F103C8T6蓝色开发板丝印如果你用自定义板卡按实际原理图调整即可外设PIN配置项对应关系ADC1PA0采样时间55.5周期ADC_EXTERNALTRIGCONV_T2_TRGOTIM2—PSC7199ARR499Update事件触发ADC周期50msUSART1PA9/PA10115200, 8N1调试打印USART2PA2/PA3115200, 8N1ESP8266I2C1PB6/PB7400kHzOLEDSYSPA13/PA14Serial WireSWD调试ADC的触发源选TIM2 Trigger out注意在TIM2配置里要把TRGO设置为Update Event这样硬件会自动以50ms为周期启动一次采样省掉在中断里手动调HAL_ADC_Start这个动作。USART1做调试打印USART2专门和ESP8266通信OLED挂在I2C1上。PA13/PA14如果被复用成GPIO会造成ST-Link连不上CubeMX里务必勾选Serial Wire调试口。3.2 用 DMAADC 连续采样别让 CPU 掉进采集循环直接在主循环里做阻塞式ADC采集会让CPU不断等待转换完成。更好的做法是把ADC1配成DMA模式转换结果自动写入内存数组CPU可以去处理显示和通信。/* adc_dma.c - 单通道DMA采集示例 */ #define ADC_SAMPLE_SIZE 32 uint16_t adc_raw[ADC_SAMPLE_SIZE]; void MX_ADC1_Init_DMA(void) { ADC_ChannelConfTypeDef sConfig {0}; hadc1.Instance ADC1; hadc1.Init.ScanConvMode ADC_SCAN_DISABLE; /* 单通道不扫描 */ hadc1.Init.ContinuousConvMode DISABLE; /* 定时器触发关闭连续 */ hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_TRGO; /* TIM2触发 */ hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); sConfig.Channel ADC_CHANNEL_0; sConfig.Rank ADC_REGULAR_RANK_1; sConfig.SamplingTime ADC_SAMPLETIME_55CYCLES_5; /* 采样周期加长降噪 */ HAL_ADC_ConfigChannel(hadc1, sConfig); hdma_adc1.Instance DMA1_Channel1; /* F103的ADC1固定用DMA1CH1 */ 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; hdma_adc1.Init.Mode DMA_CIRCULAR; /* 循环模式写满重新开始 */ HAL_DMA_Init(hdma_adc1); __HAL_LINKDMA(hadc1, DMA_Handle, hdma_adc1); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_raw, ADC_SAMPLE_SIZE); }每50ms的TIM2触发一次单次转换DMA把结果循环写入adc_raw[32]写满32个点后回到数组头继续覆盖。这个设计有三个参数值得注意第一ContinuousConvMode设为DISABLE转换完全由硬件定时器节拍驱动第二DMA采用CIRCULAR模式无需在中断里重新启动DMA第三采样时间选55.5周期而不是1.5周期因为红外反射信号本身是慢变化量采样时间越长等效于对输入的抗混叠滤波越强。阈值比较不要在中断里做。主循环里做如下平均即可uint32_t sum 0; for (uint8_t i 0; i ADC_SAMPLE_SIZE; i) { sum adc_raw[i]; } uint16_t adc_avg sum / ADC_SAMPLE_SIZE;32点的算术平均会把单个尖峰压到3%以内对车位占用这种秒级事件完全够用。若后期扩展多个车位注意DMA循环模式会让数组自动覆盖计算平均值时不要跨过数组边界。3.3 定时器周期扫描 迟滞阈值状态机占位状态定义为状态机把判断从“一次比较”变成“一段连续判据”/* parking_state_machine.c */ typedef enum { PARK_FREE 0, /* 空闲ADC采样值低 */ PARK_OCCUPIED, /* 占用ADC采样值高 */ PARK_JUDGING /* 正在确认翻转 */ } park_state_t; park_state_t park_state PARK_FREE; uint8_t judge_counter 0; uint16_t adc_avg 0; /* 迟滞阈值占用判定与释放判定不共用同一个值 */ #define THRESHOLD_OCCUPY_HIGH 1900U /* ADC值高于此值判占用 */ #define THRESHOLD_RELEASE_LOW 1400U /* ADC值低于此值判空闲 */ #define CONFIRM_TIMES 3 /* 连续3个周期才确认状态变化 */判定逻辑写成函数update_parking_state()放在主循环里以固定周期调用void update_parking_state(void) { switch (park_state) { case PARK_FREE: if (adc_avg THRESHOLD_OCCUPY_HIGH) { judge_counter; if (judge_counter CONFIRM_TIMES) { judge_counter 0; park_state PARK_OCCUPIED; } } else { judge_counter 0; } break; case PARK_OCCUPIED: if (adc_avg THRESHOLD_RELEASE_LOW) { judge_counter; if (judge_counter CONFIRM_TIMES) { judge_counter 0; park_state PARK_FREE; } } else { judge_counter 0; } break; case PARK_JUDGING: /* 保留状态若用作多节点校准可在此扩展 */ break; default: park_state PARK_FREE; break; } }这套状态机解决的是“阈值附近抖动”问题。如果占用和释放共用一个阈值比如都是1600那么ADC在1599和1601之间波动时车位状态会反复翻转。拆成1900和1400两个阈值后中间留出500个ADC码值的迟滞带只有采样值穿越整个迟滞带时状态才会变化配合CONFIRM_TIMES3连续三个50ms周期确认同一个方向才翻转整个判定过程最长150ms对人眼和上位机显示来说都属于实时响应。参数调整建议当现场阳光干扰明显时优先提高THRESHOLD_OCCUPY_HIGH而不是降低THRESHOLD_RELEASE_LOW当车位顶部有低垂管道时降低THRESHOLD_RELEASE_LOW防止误释放。调试阶段把adc_avg通过串口打印出来观察空闲值和占用值的分布区间再让两个阈值各向内缩100码留出余量。4. 显示与上报联动车位状态从 MCU 到 OLED 和 ESP8266检测逻辑确定后下一步是把状态变成人能看懂的界面和上位机能解析的数据。常见做法是板载OLED显示车位编号和状态同时通过ESP8266把状态帧推送到远端监控端。这两部分独立实现串在一起的关键是确定“什么时候触发发送”。4.1 OLED 和 I2C车位状态按节拍刷新显示0.96寸OLED大多用SSD1306控制器I2C地址通常是0x3C或者0x3D。每个字符占6×8像素配合分页坐标就能完成文本显示不需要额外字库。显示上只做一件事把完整车位信息组成一行字符串在检测周期里刷新。/* oled_parking.c */ void OLED_ShowParkingStatus(uint8_t spot_id, park_state_t state) { char line[20]; if (state PARK_OCCUPIED) { sprintf(line, SPOT%02d OCC, spot_id); } else { sprintf(line, SPOT%02d FRE, spot_id); } OLED_ShowString(0, 0, (uint8_t *)line); }调用这个函数时可直接在50ms周期里执行不必等状态翻转。OLED的I2C速度不要超过400kHz快速模式下每帧约要传1024字节按400kHz算刷一帧约25ms和检测周期接近。如果检测和显示共享I2C总线建议把显示刷新放进主循环尾部避免与ADC定时器中断互相抢总线。显示数据和检测状态分离OLED只消费全局变量park_state不直接读传感器这样后期加掉电保存功能时不用动显示层。4.2 用 ESP8266 走串口把车位状态帧推给上位机ESP8266上电后需要连接Wi-Fi常见做法是开机时给ESP8266发一组AT指令完成加入网络。真正要传递车位状态时不需要每次发长AT指令只需在状态变化时把一帧短数据通过UART塞进ESP8266的透传通道由模块转发。自定义一个简单的帧格式长度6字节字节序字段取值说明0帧头0xAA1帧头0x552车位编号0-2553状态0空闲1占用4校验和字节2字节3的低8位5帧尾0x0D发送函数如下/* uart2_report.c */ #define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 #define FRAME_TAIL 0x0D void send_spot_status(uint8_t spot_id, uint8_t occupied) { uint8_t frame[6]; frame[0] FRAME_HEAD1; frame[1] FRAME_HEAD2; frame[2] spot_id; frame[3] occupied; frame[4] (uint8_t)(spot_id occupied); /* 简易校验只验证数据域 */ frame[5] FRAME_TAIL; HAL_UART_Transmit(huart2, frame, sizeof(frame), 100); }参数说明HAL_UART_Transmit最后那个timeout设为100ms波特率115200时6字节只需0.5ms就能打完超时余量给得很足。调用位置放在检测状态机翻转结束之后也就是park_state实际改变时而不是每个50ms周期都发上位机收到的全是变化事件数据量小且便于查询。帧里的校验和用的是spot_idoccupied碰撞概率不高用于停车场项目足够。如果后期要接多个车位节点帧头可以换成节点ID校验换成CRC8上位机按节点ID分发处理。4.3 防误报连续确认与迟滞阈值配合使用前面4.1和4.2保证显示上“及时”但真正的难点在于“不要乱报”。把第3章的迟滞和连续确认逻辑串起来就得到如下的联动关系/* 在主循环里按50ms周期调用 */ void parking_task_50ms(void) { adc_avg calc_adc_average(adc_raw, ADC_SAMPLE_SIZE); update_parking_state(); /* 只有当状态真正翻转时才更新显示和上报 */ if (park_state ! last_reported_state) { OLED_ShowParkingStatus(SPOT_ID, park_state); send_spot_status(SPOT_ID, park_state PARK_OCCUPIED ? 1 : 0); last_reported_state park_state; } }这个任务函数把三件事固定到一个节拍读ADC平均、跑状态机、在翻转时刻刷新外设。时序参数固定为50ms调用周期如果你把TIM2的周期改了CONFIRM_TIMES对应的判定总时长会跟着变。例如周期改成20ms确认时长变为3×2060ms周期改成100ms确认时长变为300ms。调整时保持“占用在300ms以内确认”这个观感节奏即可。阈值方面再给一组实测中常见的参考值场景空闲ADC参考占用ADC参考建议占用阈值建议释放阈值室内停车位普通LED照明380240019001400白天靠窗车位有阳光900250021001700夜间关灯停车场120230018001200表格里的数据来自TCRT5000配10kΩ负载电阻的典型表现。不同批次传感器电压会差30%左右所以不要把表格数值当固定配置它只用来观察“两个区间分得开不开”。开机后先串口打印10秒空闲值再打印10秒用物体遮挡后的值落在两个区间中间才把阈值写死。5. 交付之前三个让检测更可靠的落地调试技巧5.1 串口打印原始 ADC先把每个车位的空闲基准定下来不同车位在同一时刻的ADC基准差很多——上方有没有梁、地面是什么颜色都直接影响反射强度。把调试串口USART1重定向到printf开机先打200组原始值观察基准线/* retarget.c */ int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 10); return ch; } /* 开机自检阶段打印 */ printf([CAL] baseline%d\r\n, adc_avg);串口工具显示格式设为115200-8-N-1记录“空闲值”和“遮挡值”两组数。随后把阈值按中间偏上取值同时让上下阈值保持至少400码的间隔。这一步花5分钟后期少调三小时。5.2 阈值参数写进片内 Flash避免断电重新标定每次换电池都重新调阈值很麻烦。把两组阈值存放在STM32最后一页Flash上电时先读读不到再用默认值/* flash_param.c */ #define PARAM_BASE_ADDR 0x0800FC00 /* F103C8T6最后1KB64KB-1KB */ uint32_t param_buf[4]; void param_save(uint16_t th_h, uint16_t th_l) { param_buf[0] 0x5A5A5A5A; /* 幻数标识参数区有效 */ param_buf[1] th_h; param_buf[2] th_l; HAL_FLASH_Unlock(); FLASH_ErasePage(PARAM_BASE_ADDR); for (int i 0; i 4; i) { HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, PARAM_BASE_ADDR i * 4, param_buf[i]); } HAL_FLASH_Lock(); }Flash擦除整页约20ms写入4个字后立即锁回整个过程在系统初始化阶段执行一次。注意F103擦除页大小是1KBPARAM_BASE_ADDR必须页对齐否则擦除会越过边界把代码区尾部的数据冲掉。5.3 用 ST-Link 批量烧录从一块板到多节点交付向实训现场交付多块板时不要每块都打开Keil点下载。用ST-Link Utility或J-Flash加载编译好的hex文件批量烧打开J-Flash选择设备型号STM32F103C8连接后用File→Open Data File装入工程输出目录里的hex点Program直接写入。这个动作也可以脚本化命令行加参数运行一次约20秒适合在实验课或竞赛现场为多块板同时烧录。烧完找一块联调打开串口助手确认0xAA 0x55开头的状态帧能正常发出再抽查另一块的USB转串口电平是否在3.3V范围内、波形没有明显下拉毛刺就可以封板交付。本文还有配套的精品资源点击获取