news 2026/9/16 3:59:14

STM32F103交通灯控制系统:状态机与定时器驱动的工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103交通灯控制系统:状态机与定时器驱动的工程实践

简介:基于STM32F103单片机设计的交通灯控制系统开发资料,面向课程设计与单片机实践,解决红绿灯状态切换与定时控制问题。压缩包共88个文件,约1.73MB,含34个C源文件、38个H头文件,以及Keil工程、Proteus仿真、PDF设计报告、HEX烧录文件等。代码内含main函数中东西与南北方向绿黄灯顺序切换的延时控制逻辑,并封装系统时钟、GPIO、TIMER、OLED等模块;附带STM32标准外设库启动文件,便于理解工程结构。Proteus仿真工程可直观演示信号灯流程,报告补充整体方案与设计思路。已有850人学习,适合需快速完成课设或进行红绿灯逻辑验证的读者。模块化代码清晰易读,解压后可直接用Keil打开,节省搭建时间。

1. 交通灯控制系统不是流水灯,核心在状态机与安全时序

很多人在拿到"基于STM32F103单片机设计的交通灯控制系统"这类题目时,第一反应是点灯:红灯亮几秒、绿灯亮几秒、黄灯闪烁几次,然后循环。如果只是这样,用51单片机加延时函数就够了,根本不需要STM32F103。真正让这个题目有分量的地方,在于它把单片机课程设计里最经典的两个问题揉在了一起:状态机建模时序可靠性。交通灯不是简单的周期性翻转IO,它要处理倒计时显示的精确性、紧急车辆通行时的状态抢占、行人按钮的异步触发,以及断电重启后如何安全恢复,而不是直接跳到绿灯。

这个项目最适合两类人:一类是正在做单片机课程设计或毕业设计的学生,需要把"能跑"升级成"能讲清楚";另一类是刚接触STM32标准外设库的工程师,想找一个比流水灯复杂一个量级、又比FreeRTOS移植简单得多的练手对象。用STM32F103做这件事的优势在于:它有足够的定时器资源让倒计时和LED驱动互不干扰,有外部中断处理按键和传感器输入,而且标准库v3.50这一代资料极其丰富,遇到问题几乎都能查到解决方案。当然,它也引入了51单片机时代不太在意的坑,比如GPIO配置模式、定时器重装载值的边界计算、中断优先级抢占,这些恰恰是这个项目真正的学习价值所在。

下面这套方案以"状态机为核心、定时器为骨架、按键和传感器为扰动输入"的常见做法展开,从最小系统搭建一路讲到逻辑分析仪验证时序,全程可以照着抄,也能理解每一条配置为什么这么写。

2. 状态机建模与延时方案选型:先定状态表,再写代码

2.1 交通灯状态不是时间表,而是转移图

标准十字路口交通灯(不考虑左转专用相位)可以抽象为4个主状态:南北直行绿灯、南北左转绿灯、东西直行绿灯、东西左转绿灯,每个状态之间用黄灯过渡。如果再加入行人按钮和紧急车辆传感器,状态数会增加到7到8个。这里有一个关键的设计决策:状态转移只能由定时事件驱动,不能由延时函数内部的休眠驱动

普通延时法(即HAL_Delay或Delay循环)的问题是:当单片机停在延时循环里时,它无法响应按键中断、无法刷新数码管倒计时、无法处理紧急传感器输入。这在课程设计答辩演示时特别尴尬,因为老师按下行人按钮,灯要等当前相位走完才反应,看起来就像"卡住了"。

正确的建模方式是把每个状态定义成"持续时间 + 转移条件"的结构体数组。我一般这样定义:

typedef struct { uint8_t state_id; // 状态编号,用于状态机跳转 uint8_t north_south_led; // 南北方向灯组状态:0灭 1红 2绿 3黄 uint8_t east_west_led; // 东西方向灯组状态 uint16_t duration_ms; // 本状态持续时长 uint8_t next_state; // 超时后跳转的目标状态 } TrafficLightState;

参数说明:state_id是枚举值,比如0代表南北绿灯、1代表南北黄灯、2代表东西绿灯、3代表东西黄灯;north_south_ledeast_west_led各自独立控制一个方向的灯色,这样南北和东西可以用同一个结构体但方向字段互不相同。duration_ms是这个状态持续的最短时间,一般绿灯给20到30秒,黄灯固定3秒,行人按钮触发后给15秒左右。next_state让状态机在超时后知道去哪里,同时这个转移表也为后面加入紧急模式预留位置。

很多人会把黄灯闪烁单独处理成一个状态,这是对的。在标准交通灯逻辑里,黄灯不是"随便闪两下",而是固定频率的闪烁,比如1Hz,也就是500毫秒亮、500毫秒灭。如果把这个闪烁逻辑塞进状态机主循环,会让状态机的刷新频率和闪烁频率耦合在一起,后面加倒计时数码管显示时会非常痛苦。好的做法是:黄灯状态本身只负责"设置LED为黄色且进入闪烁模式",闪烁的亮灭交给定时器中断去翻转。

2.2 为什么课程设计首选"定时器时基+主循环轮询",而不是裸机延时

STM32F103有不少于4个定时器,对于交通灯这种低速外设,我们其实用不了那么多。最稳妥的架构是:TIM2产生1毫秒的时基中断,在中断里做计时累加,主循环里做状态判断和LED刷新

这个方案的核心代码如下:

volatile uint32_t g_tick_ms = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_tick_ms++; // 每进入一次中断,就是1ms过去了 } } uint32_t get_tick_ms(void) { return g_tick_ms; }

为什么不在中断里直接跑状态机?因为状态机的处理逻辑包含多个LED的赋值、数码管的段码刷新、按键状态读取,如果全部放进中断,中断服务函数会变得很长,容易超过下一次中断的到来时间。常见的做法是中断只负责维护时间基准,主循环负责业务逻辑

while (1) { uint32_t now = get_tick_ms(); // 当前状态剩余时间 = 状态预定时间 - 已运行时间 if (now - state_start_time >= current_state->duration_ms) { current_state = &state_table[current_state->next_state]; state_start_time = now; apply_led_state(current_state); // 把状态结构体里的LED值刷到GPIO } // 更新倒计时数码管(每200ms刷新一次,避免肉眼可见闪烁) if (now - display_last_update >= 200) { display_last_update = now; display_remaining_time(current_state->duration_ms - (now - state_start_time), now); } }

这里有三个需要重点说明的参数和逻辑:

第一,state_start_time必须是无符号32位变量,记录状态机进入当前状态的时刻。计算剩余时间时用now - state_start_time的差值,而不是先取一次时间再自己累加,这样可以避免在中断里做除法或取余运算。

第二,g_tick_msvolatile修饰极其重要。STM32F103是单核M3内核,虽然主循环和中断共享这个变量时大多数情况下能正常工作,但编译器优化后可能把g_tick_ms缓存在寄存器里,导致主循环读到旧值。加volatile是告诉编译器每次都从内存地址读取。如果后续使用GCC的-O2以上优化级别,这个关键字的缺失会引发"程序跑几分钟才跳变一次"的诡异现象。

第三,倒计时显示的刷新频率取200毫秒不是随便定的。数码管是动态扫描显示的,每个位需要轮流点亮,刷新太快会占用过多CPU时间,刷新太慢则会明显看到数字跳动。200毫秒对于"秒"为单位的倒计时来说完全够用,而且给人眼留了足够的观测窗口。

2.3 状态表参数设计的工程经验值

下面这张表是常见的课程设计默认参数,实际调试时可以直接抄,也可以按路口车流量调整:

状态南北LED东西LED持续时间说明
S0绿灯红灯25秒南北直行放行
S1黄灯 1Hz 闪烁红灯3秒南北清空路口
S2红灯绿灯25秒东西直行放行
S3红灯黄灯 1Hz 闪烁3秒东西清空路口
S4全红灯全红灯2秒全红清空,用于安全间隔

全红间隔这个2秒是很多新手容易漏掉的状态。中国国标(GB 14886-2016)明确规定了信号灯的转换时序,虽然没有强制全红间隔的秒数,但实际工程中会保留1到3秒全红时间,确保上一个方向的车辆完全驶出路口后才放行下一个方向。在课程设计答辩时,主动解释"为什么在绿灯切红灯之间加了全红状态",往往比代码本身更得分。

另外,如果路口有左转专用相位,状态表会变成6行以上,此时需要增加南北左转绿灯和东西左转绿灯两个状态,每个左转相位持续15到20秒,并且左转与直行之间也要加黄灯过渡。这个扩展非常容易,只需要往state_table数组里加结构体元素,并修改next_state指针即可,状态机框架本身完全不用动。

3. 从原理图到GPIO:最小系统之外,交通灯控制板还要接什么

3.1 STM32F103最小系统的必要组件与常见翻车点

网上搜"stm32f103最小系统原理图"能找到大量参考,但很多图纸抄来抄去,少了关键电容或者复位电路电阻值不对。一个能稳定跑交通灯代码的最小系统至少包含以下部分:

  • 8MHz晶振 + 两个20pF负载电容,给HSE提供时钟源
  • BOOT0下拉到GND(10K电阻),BOOT1随意,确保从Flash启动
  • NRST复位引脚接10K上拉电阻到3.3V,再串100nF电容到地
  • VDDA引脚接3.3V并加1uF和100nF两级滤波电容
  • VDD每个引脚都要有100nF去耦电容,且尽量靠近引脚放置

稳压部分课题常见两种选择:如果是纯3.3V供电(比如用ST-Link的3.3V输出),不需要额外稳压;如果输入是5V USB供电,则需要AMS1117-3.3稳压。这里有个很多人踩过的坑:AMS1117的压差是1V左右,输入5V输出3.3V没问题,但要确保输入电容不小于10uF,否则上电瞬间可能触发STM32的BOR欠压复位。

LED驱动部分,交通灯模块通常采用共阳极接法,即LED的正极接3.3V或5V,负极经过限流电阻接到STM32的GPIO。STM32F103的GPIO在推挽输出模式下,灌电流能力比拉电流强(虽然数据手册标称都是±25mA,但实际灌电流时饱和压降更低)。用共阳极+GPIO输出低电平点亮LED,是大多数开发板的设计选择。限流电阻取330欧姆到1K欧姆,取决于LED的额定电流和供电电压。如果是3.3V供电、红色LED压降2V,330欧姆电阻限流约(3.3-2)/330=3.9mA,亮度适中。

四位数码管做倒计时显示,推荐用共阴或共阳的动态扫描方式。这里不需要外接驱动芯片,用STM32的GPIO直接驱动即可,但每一段的限流电阻不能省。如果使用的是四位一体数码管,位选引脚需要加PNP三极管(共阳时)或NPN三极管(共阴时)来增强驱动能力,因为STM32单个GPIO的灌电流/拉电流不足以同时点亮四位所有段。

3.2 GPIO初始化代码:推挽输出与上拉输入一个都不能错

void GPIO_InitConfig(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB | RCC_APB2Periph_GPIOC, ENABLE); // 交通灯LED:PA0-PA5,推挽输出,复用推挽也OK,但这里用通用推挽即可 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz; // 低速外设,2MHz足够 GPIO_Init(GPIOA, &GPIO_InitStructure); // 按键输入:PA6-PA7,上拉输入,按下为低电平 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); }

参数说明里有三个容易被忽视的细节:

一是GPIO_Speed_2MHz。很多人习惯把所有引脚都配成GPIO_Speed_50MHz,在高频SPI或PWM输出时这是对的,但LED和按键这种低速应用,2MHz已经足够。关键是更高的翻转速率会带来更大的EMI辐射和电源噪声,在面包板上搭电路时,这个问题会导致数码管显示乱码或按键误触发。

二是共阳极LED输出低电平点亮,意味着初始化后GPIO输出高电平才让所有灯灭。如果初始化后忘记把LED引脚拉高,上电瞬间所有灯会亮一下,给人"系统不受控"的坏印象。在GPIO_InitConfig()末尾执行:

GPIO_SetBits(GPIOA, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4 | GPIO_Pin_5);

三是按键的GPIO速度不需要配,因为输入模式下速度配置无效,但GPIO_Mode_IPU必须显式配置。STM32F103的GPIO默认状态是浮空输入,不加上拉电阻的话,按键未按下时引脚电平不确定,轻则按键失灵,重则频繁触发外部中断。

3.3 按键处理:轮询 + 消抖,不要用阻塞延时

交通灯系统至少有两个按键:一个行人按钮,按下后尽快切换到行人绿灯;一个是紧急模式切换,模拟消防车或救护车通过时全路口红灯。

按键消抖有几种方案,最不推荐的是在主循环里Delay(10ms)——这又回到了阻塞式延时,和状态机架构冲突。常见做法是定时器时基驱动按键扫描,每20ms扫描一次,连续两次读到相同电平才认为有效:

typedef struct { uint8_t stable_state; // 稳定电平,1为未按下,0为按下 uint8_t last_state; uint8_t count; } KeyState; void KeyScan(void) { // 每20ms调用一次 uint8_t pedal = GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6); if (pedal == key.last_state) { if (key.count < 3) key.count++; if (key.count == 2) key.stable_state = pedal; } else { key.last_state = pedal; key.count = 0; } }

这段代码的逻辑是:连续3次(即60ms)读到相同电平才更新稳定状态,比简单的"延迟10ms再读一次"更能滤除机械抖动。更重要的是,这个扫描函数在while(1)主循环里调用,也可以在20ms定时中断里调用,无论哪种都不阻塞状态机运行。

key.stable_state == 0时,触发行人绿灯抢断逻辑。抢断不是马上跳进某个状态,而是把当前状态的duration_ms覆盖为一个较小值。我常用的做法是定义一个全局变量g_shorten_remaining_ms,在主循环计算剩余时间时检测:

if (key.stable_state == 0 && current_state->state_id == S0) { // 当前状态剩余时间超过3秒才截断,否则忽略 uint32_t remain = current_state->duration_ms - (now - state_start_time); if (remain > 3000) state_start_time = now - (current_state->duration_ms - 3000); }

这里改state_start_time而不是直接改duration_ms,好处是不破坏状态表的原始参数,在显示和后续状态恢复时逻辑一致。3秒的保护时间是为了避免行人按键被连续按下时,状态机在S0和行人状态之间来回跳。

4. 倒计时显示与LED刷新:动态扫描的时序竞争与解决

4.1 数码管动态扫描的基本原理:段选与位选

四位数码管显示倒计时,本质上是一个时分复用问题。四位共阳数码管有8个段选引脚(a到dp)和4个位选引脚(digit1到digit4)。动态扫描的做法是:在任何时刻只点亮一个位,点亮一段极短时间后切换到下一位,由于视觉暂留效应,人眼看到的是所有位同时亮。

关键参数是扫描频率。每个位至少需要50Hz的刷新率才不会闪烁,四位合计就是200Hz,即每个位的刷新周期最长5毫秒。反过来看,单次放置时间不能太长,否则人的眼睛会捕捉到"一个数字比另一个亮"的亮度不均。

void Display_Update(uint8_t digit1, uint8_t digit2, uint8_t digit3, uint8_t digit4) { static uint8_t current_digit = 0; uint8_t code; // 先关闭所有位选,防止拖影 GPIO_SetBits(GPIOC, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); // 切换位选 switch (current_digit) { case 0: code = digit1; GPIO_ResetBits(GPIOC, GPIO_Pin_0); break; case 1: code = digit2; GPIO_ResetBits(GPIOC, GPIO_Pin_1); break; case 2: code = digit3; GPIO_ResetBits(GPIOC, GPIO_Pin_2); break; case 3: code = digit4; GPIO_ResetBits(GPIOC, GPIO_Pin_3); break; } // 输出段码:共阳极数码管,低电平点亮段 GPIO_Write(GPIOB, Segment_Table[code]); current_digit = (current_digit + 1) % 4; }

Segment_Table是共阳数码管的段码数组,比如数字0对应0xC0,数字1对应0xF9。这里要注意一个细节:GPIO_Write会一次性写入16位数据,如果GPIOB上还接了其他外设,会干扰它们的状态。稳妥的做法是只操作低8位,先读出当前ODR再修改:

uint16_t temp = GPIO_ReadOutputData(GPIOB); temp = (temp & 0xFF00) | Segment_Table[code]; GPIO_Write(GPIOB, temp);

调用Display_Update的时机放在TIM2中断里,每2毫秒切换一位。这样四位完整扫描周期是8毫秒,每位刷新频率125Hz,高于无闪烁阈值。但要注意:如果Display_Update在中断里执行,它的执行时间不能超过2毫秒本身——实际上段码查表和GPIO操作只需要几十微秒,完全没有风险。

4.2 黄灯闪烁与倒计时显示的并发问题

当状态机进入黄灯闪烁状态时,黄灯要1Hz闪烁,同时数码管要显示剩余秒数。如果两个逻辑都在主循环里顺序执行,会出现一个优先级问题:LED的闪烁翻转频率是2Hz周期,而数码管刷新是毫秒级——如果主循环比较慢,LED灯会跳变迟缓。

解决方案是:把LED闪烁的时序判断和数码管刷新放在同一个1ms时基上,LED每500次tick翻转一次,数码管每2ms刷新一位。这样LED的翻转不会因为主循环的耗时而被推迟。

void TIM2_IRQHandler(void) { static uint16_t led_flip_counter = 0; static uint16_t display_digit_counter = 0; g_tick_ms++; // 黄灯闪烁:500ms翻转一次 if (current_state->state_id == S1 || current_state->state_id == S3) { led_flip_counter++; if (led_flip_counter >= 500) { led_flip_counter = 0; GPIO_WriteBit(GPIOA, GPIO_Pin_1, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_1))); } } // 数码管:2ms切换一位 display_digit_counter++; if (display_digit_counter >= 2) { display_digit_counter = 0; // 把剩余秒数拆成4位显示 // 这里用状态机剩余时间 / 1000 得到秒数 } }

这个设计让所有时间敏感的操作都集中到了定时器中断,主循环只需要处理状态转移和按键扫描。代价是中断服务函数变得更长,但STM32F103的Cortex-M3内核在72MHz主频下,执行完这段逻辑(几个比较、赋值、GPIO操作)需要不到10微秒,占1ms时基的1%,远没有达到需要优化的程度。

如果后续要加紧急车辆传感器(比如红外对射检测),只需在中断或主循环中增加一个IO_ReadInputDataBit判断,当检测到高优先级车辆时,置一个标志位,主循环检测到标志位后立即切换到全红保持状态。全红保持时间建议固定为5秒,确保路口内车辆清空,然后恢复默认循环。

4.3 主循环架构的最终形态

综合以上设计,主循环的逻辑可以归结为:扫描按键、检查紧急标志、检查状态是否超时、更新倒计时显示数据。伪代码如下:

while (1) { KeyScan(); if (g_emergency_flag) { current_state = &state_table[STATE_ALL_RED]; state_start_time = get_tick_ms(); g_emergency_flag = 0; } uint32_t now = get_tick_ms(); if (now - state_start_time >= current_state->duration_ms) { current_state = &state_table[current_state->next_state]; state_start_time = now; apply_led_state(current_state); } if (now - display_refresh_time >= 500) { display_refresh_time = now; uint16_t remain_sec = (current_state->duration_ms - (now - state_start_time)) / 1000; // 拆分个位、十位,送入显示缓冲区 } }

这里没有用RTOS,是因为交通灯的逻辑本身是线性状态机,用FreeRTOS反而会引入任务间通信和临界区保护这些额外复杂度。只有当你要在这个项目上叠加多路传感器采集、LCD屏显示、远程通信时,才值得考虑上FreeRTOS——那时裸机主循环的实时性才会成为瓶颈。

5. 从理论到实物:Proteus仿真与硬件调试的对照验证

5.1 仿真先行:Proteus中搭建交通灯系统的要点

很多人在Proteus中画的交通灯原理图能通过仿真,但移植到实板上完全不工作,典型原因是仿真模型忽略了GPIO的驱动能力限制。在Proteus中,你可以直接让STM32F103的引脚点亮LED,但在实板上,单个GPIO引脚灌入20mA以上电流时,输出低电平会从0V升到0.5V甚至更高,导致LED亮度降低或逻辑混乱。

一个稳妥的仿真方案是:用74HC245或ULN2003作为LED驱动芯片,STM32的GPIO只输出逻辑信号,由驱动芯片提供电流。ULN2003是达林顿管阵列,灌电流能力500mA,非常适合驱动共阳极LED灯组。但要注意ULN2003是反相的,输入高电平则输出导通到地,LED点亮逻辑需要反转。

在Proteus中进行时间验证时,有一个技巧:把TIM2的预分频系数调小,让1ms时基变成0.1ms甚至更短,状态机的25秒绿灯就会在2.5秒内跑完。这样你不需要等完整的60秒周期就能验证整个状态机循环。等仿真确认无误后,再把预分频系数改回去。这个参数就是TIM_Prescaler——72MHz时钟下,预分频71得到1MHz计数频率,自动重装载999得到1ms中断周期。

5.2 硬件调试三板斧:逻辑分析仪、串口打印和LED指示灯

实物调试中,最常见的故障现象是"状态机不跳转"或"数码管显示乱码"。我的排错顺序如下:

先用逻辑分析仪挂到状态机切换的GPIO引脚上。比如在每次状态转移时翻转PA8引脚(调试专用),状态每次跳转都会产生一个电平跳变,逻辑分析仪的边沿间隔就是每个状态的持续时间。如果发现某个状态持续时间异常长,说明duration_ms参数或state_start_time计算有误。

其次是串口打印。用USART1重定向printf,在每个状态切换时打印一行日志,格式如[t=12345ms] S0->S1, remain=25000ms。如果连printf都卡住,多半是fputc没有重定向到串口。STM32标准库中需要在文件中加入:

int fputc(int ch, FILE *f) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); USART_SendData(USART1, (uint8_t)ch); return ch; }

第三板斧是在可疑位置加LED闪烁指示灯。比如在主循环每循环一次翻转PB0上的LED,如果LED不闪,说明主循环卡死了;如果LED闪但状态不跳,说明问题在定时器中断。

一个容易被忽略的硬件坑是启动文件选择。STM32F103有多个启动文件,startup_stm32f10x_hd.s对应高密度器件(Flash大于256KB),startup_stm32f10x_md.s对应中密度(64KB到128KB Flash)。如果你的工程是STM32F103C8T6,必须用md版本,用错启动文件会导致上电后程序不运行,表现为所有LED无反应、调试器能连接但无法复位。这个问题在从网上下载别人的工程模板时经常遇到,因为对方用的是ZET6或RCT6,启动文件不兼容。

6. 状态机可视化与边界参数验证:让答辩或交付有理有据

6.1 用状态转移表自检,而不是靠眼睛看灯

课程设计最怕的是"看起来一切正常,一追问逻辑就答不上来"。我建议在项目收尾时,打印或手绘一张完整的状态转移表,把每个状态的进入条件、持续时间、退出条件、对行人按钮的响应方式都写清楚。以本文前面的5状态模型为基础,扩展成含行人抢断的8状态表:

状态进入条件持续时间退出条件行人按钮响应
S0 南北绿灯上电初始/状态完成25s超时剩余<3s忽略,否则截断至3s后进入S1
S1 南北黄灯S0超时3s超时忽略
S2 东西绿灯S1超时25s超时剩余<3s忽略,否则截断至3s后进入S3
S3 东西黄灯S2超时3s超时忽略
S4 全红清空S3超时2s超时忽略
S5 行人南北按钮按下且当前为S0/S115s超时忽略
S6 紧急全红紧急传感器触发保持按钮解除忽略
S7 恢复过渡S6退出5s超时忽略

这张表的价值不只是用来答辩,它也是代码review的检查清单。比如你发现S0被行人截断后直接跳S1,那S5行人绿灯之后应该跳回哪个状态?如果跳回S2,那么行人过马路的时间只有3秒黄灯+2秒全红,共5秒,对于15米宽的路口显然不够。正确的做法是S5结束后跳回S2的起点,重新给东西方向25秒绿灯。这种逻辑漏洞在纯看代码时不容易发现,但画成转移表一目了然。

6.2 参数边界验证清单:测试代码改一个宏就能跑

为了让自己和审阅者确信系统可靠,我通常会在工程里加一个#define TEST_MODE 1的开关。开启后状态机的时间参数按1/10缩放:25秒变2.5秒,3秒变0.3秒,1Hz闪烁变10Hz闪烁。这样可以在30秒内跑完全部状态组合,并用逻辑分析仪录制完整波形。

验证项包括:正常循环100次不出错,行人按钮在绿灯任意剩余时间按下都能正确截断到3秒,紧急模式下全红保持时间稳定在5秒,断电重启后从全红状态开始而不是从南北绿灯开始。最后一点需要额外说明:STM32上电后进入主函数的初始状态,我定义的是STATE_ALL_RED,保持2秒后自动进S0。这样电源恢复时不会出现"直接放行"的危险场景。添加这个逻辑只需要两行代码:

current_state = &state_table[STATE_ALL_RED]; state_start_time = get_tick_ms();

6.3 把裸机逻辑整理成可移植的模块化工程

当你把交通灯系统做完后,自然的进阶方向是把功能拆成bsp_led.cbsp_key.cbsp_display.capp_traffic.c四个模块。app_traffic.c对外只暴露三个接口:Traffic_InitTraffic_Tick_1ms(在定时器中断中调用)、Traffic_Loop(在主循环中调用)。这样无论后续移植到STM32F103C8T6还是STM32F407,底层BSP换掉,应用层状态机完全不用动。

如果还想要更进阶的玩法,可以把状态机的数据结构改用函数指针数组实现,每个状态对应一个void (*state_handler)(void)回调函数。这种写法在状态数量超过10个时优势明显,因为每个状态的处理逻辑可能不同,比如S0需要读取车流量传感器,S5需要启动行人倒计时显示。函数指针数组的跳转代价只有一次间接调用,在72MHz主频下几乎可以忽略,但它让代码的可读性和可维护性提升了一个档次。这正是从"课程设计代码"走向"嵌入式工程代码"的关键一步,也是这个项目的终极价值所在。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 3:57:50

窗函数法设计FIR滤波器:原理、选型与工程避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:57:14

LLM应用落地实战:RAG与Agent工程化避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 3:57:02

STM32三相逆变器SPWM控制与死区时间设置详解

简介&#xff1a;这是一套基于STM32F103微控制器和IR2104半桥驱动芯片的三相逆变器系统设计方案&#xff0c;面向电力电子与嵌入式方向开发者&#xff0c;聚焦直流转三相交流过程中的SPWM波形生成、死区时间控制、三相桥式电路构建与反馈调节机制等关键问题。压缩包共一百五十六…

作者头像 李华
网站建设 2026/9/16 3:56:51

CCS v20.4.0中文版安装教程:下载、界面切换与组件选择全攻略

用了这么多年的Code Composer Studio&#xff0c;头一次在启动器界面看到全中文的按钮&#xff0c;说实话还有点不习惯。CCS v20.4.0这个版本&#xff0c;是TI官方第一个原生支持简体中文界面的版本&#xff0c;也是CCS从经典Eclipse框架全面迁移到Theia框架之后的一个标志性更…

作者头像 李华
网站建设 2026/9/16 3:56:37

LTE-M与NB-IoT双模低功耗模组选型与PSM/eDRX配置实战

做了这么多年物联网接入&#xff0c;我越来越觉得低功耗蜂窝模组这块真的是“看着简单&#xff0c;踩坑无数”。最近在帮客户做一个资产追踪项目&#xff0c;正好用到了一个带 R7KA8D2KFLCAC 标识的 NB-IoT 模组&#xff0c;再配上设备日志里那个 2615011136000 的毫秒级时间戳…

作者头像 李华
网站建设 2026/9/16 3:56:22

头歌MySQL实训全攻略:解题思路、环境配置与排坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华