举例说明单片机,主循环和中断资源访问冲突的案例
好的,我们来举几个单片机中主循环(主程序)和中断服务程序访问共享资源(如全局变量、硬件寄存器、缓冲区等)发生冲突的典型案例,并解释冲突是如何发生的。
核心问题: 当主循环和中断服务程序异步地(即发生时机不确定)访问同一个资源,并且至少有一个访问是“写”操作时,如果访问不是“原子性”的(即不能被中断打断的最小操作单元),就可能出现冲突,导致数据损坏、程序逻辑错误或硬件操作异常。
案例 1:全局状态标志位 (Flag)
- 场景: 系统有一个按键,按下时产生外部中断。中断服务程序负责设置一个全局标志位
keyPressed = 1;
。主循环不断检查这个标志位,如果keyPressed == 1
,则执行相应的处理函数handleKeyPress()
,然后将标志位清零keyPressed = 0;
。 - 代码片段 (伪代码):
volatile int keyPressed = 0; // 必须声明为 volatile,防止编译器优化掉对内存的访问// 中断服务程序 (ISR) void EXTI0_IRQHandler(void) {if (检查中断源是按键) {keyPressed = 1; // 中断中设置标志位清除中断标志;} }// 主循环 void main(void) {while(1) {if (keyPressed == 1) { // 主循环读取标志位handleKeyPress(); // 执行按键处理keyPressed = 0; // 主循环清除标志位}// ... 其他任务 ...} }
- 冲突发生过程:
- 主循环执行到
if (keyPressed == 1)
,此时keyPressed
是 0,条件不成立,跳过处理部分。 - 就在
if
语句之后,keyPressed = 0;
语句之前,按键被按下,触发中断。 - 中断服务程序执行,将
keyPressed
设置为 1。 - 中断返回,主循环继续执行
keyPressed = 0;
(此时它本意是清除之前可能存在的标志,但实际清除了中断刚设置的新标志)。
- 主循环执行到
- 结果: 虽然按键确实被按下并触发了中断,但主循环在处理完上一个按键事件后(或者根本没处理),立刻将中断刚设置的新标志清零了。主循环中的
if (keyPressed == 1)
条件永远没有机会成立,导致这次按键事件被完全丢失,handleKeyPress()
永远不会被执行。 - 根本原因: 主循环检查标志位 (
keyPressed == 1
) 和清除标志位 (keyPressed = 0
) 不是原子操作。中断可以在两者之间发生,覆盖掉中断设置的值。
案例 2:共享数据缓冲区 (Buffer)
- 场景: 系统通过 UART 接收数据。UART 接收完成中断 (
RXNE
) 将接收到的字节存入一个全局环形缓冲区rxBuffer
,并更新写索引writeIndex
。主循环定期检查readIndex != writeIndex
,如果不相等,则从rxBuffer
中读取一个字节(根据readIndex
)进行处理,然后递增readIndex
。 - 代码片段 (伪代码):
#define BUFFER_SIZE 64 volatile uint8_t rxBuffer[BUFFER_SIZE]; volatile uint16_t writeIndex = 0; // 中断写入位置 volatile uint16_t readIndex = 0; // 主循环读取位置// UART 接收中断服务程序 (ISR) void USART1_IRQHandler(void) {if (UART1->SR & RXNE) { // 检查接收寄存器非空标志uint8_t data = UART1->DR; // 读取接收到的数据rxBuffer[writeIndex] = data; // 写入缓冲区writeIndex = (writeIndex + 1) % BUFFER_SIZE; // 更新写索引} }// 主循环 void main(void) {while(1) {if (readIndex != writeIndex) { // 检查是否有新数据uint8_t receivedData = rxBuffer[readIndex]; // 读取一个字节readIndex = (readIndex + 1) % BUFFER_SIZE; // 更新读索引processData(receivedData); // 处理数据}// ... 其他任务 ...} }
- 冲突发生过程 (读取损坏或重复):
- 主循环执行
if (readIndex != writeIndex)
,假设此时readIndex = 5
,writeIndex = 10
,条件成立。 - 主循环开始执行
uint8_t receivedData = rxBuffer[readIndex];
(读取rxBuffer[5]
)。 - 就在读取
rxBuffer[5]
之后,更新readIndex
之前,发生 UART 接收中断。 - 中断服务程序执行:收到一个新字节
0xAA
,将其写入rxBuffer[writeIndex]
(即rxBuffer[10]
),然后更新writeIndex = (10 + 1) % 64 = 11
。 - 中断返回,主循环继续执行
readIndex = (5 + 1) % 64 = 6;
。
- 主循环执行
- 结果 (数据丢失?): 这个例子本身冲突不明显,主循环成功读取了位置5的数据,中断正确写入了位置10的数据并更新了写索引。主循环更新读索引到6也正确。似乎没问题? 问题在于中断更新
writeIndex
和主循环更新readIndex
都是多字节操作(如果BUFFER_SIZE
> 256,uint16_t
在8位MCU上需要多条指令)。更严重的冲突在于缓冲区满/空的判断逻辑。 - 更严重的冲突 (缓冲区状态判断错误): 判断缓冲区是否有数据的核心是
readIndex != writeIndex
。假设缓冲区是空的 (readIndex == writeIndex = 15
)。- 主循环执行
if (readIndex != writeIndex)
,此时两者相等 (15),条件不成立,跳过读取。 - 紧接着,UART 中断发生,写入一个字节到
rxBuffer[15]
,然后更新writeIndex = (15 + 1) % 64 = 16
。 - 中断返回。此时缓冲区确实有数据 (
readIndex=15, writeIndex=16
),但主循环刚刚判断过并且认为没有数据,它要等到下一次检查这个条件时才会发现数据。这造成了短暂的延迟,但通常可接受。 - 更糟糕的情况 (临界区问题): 如果主循环在检查
readIndex != writeIndex
之后,读取数据之前被中断,且中断导致缓冲区状态发生根本变化(比如从非空变空或反之),主循环基于过时的状态信息进行操作,可能导致读取无效数据或写入冲突。不过环形缓冲区的设计通常能避免严重损坏,但临界区保护仍是必要的。
- 主循环执行
案例 3:直接操作硬件寄存器 (Peripheral Register)
- 场景: 主循环需要配置一个定时器 (TIM) 为 PWM 输出模式并启动它。配置过程涉及写入多个寄存器 (
TIMx_CR1
,TIMx_CCMR1
,TIMx_CCER
,TIMx_ARR
,TIMx_CCR1
,TIMx_EGR
,TIMx_CR1
再次写入启动)。同时,该定时器的更新中断 (UIE
) 被使能用于其他目的。中断服务程序会读取或修改定时器的某些寄存器(例如读取计数器值TIMx_CNT
或清除状态标志)。 - 代码片段 (伪代码):
// 定时器更新中断服务程序 (ISR) void TIM2_IRQHandler(void) {if (TIM2->SR & UIF) { // 检查更新中断标志uint16_t cntVal = TIM2->CNT; // 中断中读取计数器值 (示例操作)// ... 可能还有其他操作 ...TIM2->SR &= ~UIF; // 清除更新中断标志} }// 主循环中配置并启动PWM void configureAndStartPWM(void) {// 1. 停止定时器 (如果正在运行)TIM2->CR1 &= ~TIM_CR1_CEN;// 2. 配置模式 (PWM模式1)TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;// 3. 使能输出比较TIM2->CCER |= TIM_CCER_CC1E;// 4. 设置自动重装载值 (周期)TIM2->ARR = 999;// 5. 设置捕获比较值 (占空比)TIM2->CCR1 = 500;// 6. 产生更新事件 (加载预装载寄存器) - 可选,但常见TIM2->EGR |= TIM_EGR_UG;// 7. 清除可能挂起的更新中断标志 - 重要!TIM2->SR &= ~TIM_SR_UIF;// 8. 使能更新中断 (如果需要在配置后启用)// TIM2->DIER |= TIM_DIER_UIE;// 9. 启动定时器TIM2->CR1 |= TIM_CR1_CEN; }
- 冲突发生过程:
- 主循环执行到步骤 3 (
TIM2->CCER |= TIM_CCER_CC1E;
)。 - 就在步骤 3 之后,步骤 4 之前,定时器更新中断发生(可能是之前配置残留或硬件原因)。
- 中断服务程序执行:它读取了
TIM2->CNT
(此时定时器处于一个部分配置的状态,ARR
,CCR1
还是旧值或未定义值),然后清除了UIF
标志。 - 中断返回,主循环继续执行步骤 4 (
TIM2->ARR = 999;
), 步骤 5 (TIM2->CCR1 = 500;
) 等等。
- 主循环执行到步骤 3 (
- 结果:
- 中断读取到无效数据: 中断服务程序读取的
TIM2->CNT
值是在定时器配置中途进行的,这个值可能毫无意义,导致基于此值的后续逻辑出错。 - 中断清除标志干扰主循环: 主循环在步骤 7 尝试清除可能挂起的
UIF
标志。如果中断在第 3 步和第 7 步之间发生并清除了UIF
,主循环的清除操作可能无效或不必要,但也可能掩盖了真正需要在主循环中处理的状态。更严重的是,如果主循环在步骤 7 之后才使能中断 (TIM2->DIER |= TIM_DIER_UIE;
),而中断在步骤 7 之前发生并清除了UIF
,那么一个本应在配置完成后触发的中断可能就被意外清除了。 - 硬件处于不一致状态: 在步骤 1 停止定时器后,到步骤 9 重新启动之前,对寄存器的多次写入应该是一个“事务”。中断在这个事务中途访问寄存器,看到的配置是不完整且不一致的。即使中断只是读取状态,也可能读到矛盾的信息。如果中断尝试修改寄存器,后果更不可预测,可能导致 PWM 输出异常(毛刺、错误占空比/周期)、定时器计数异常甚至硬件锁定。
- 中断读取到无效数据: 中断服务程序读取的
- 根本原因: 对硬件寄存器的配置通常需要写入多个寄存器才能完成一个有效的、一致的功能状态。这个配置过程不是原子的。中断可以在配置过程的任意时刻插入,访问或修改处于中间(无效)状态的硬件寄存器。
总结
这些案例清晰地展示了主循环与中断服务程序异步访问共享资源时可能发生的冲突:
- 标志位冲突: 导致事件丢失或重复处理。
- 缓冲区冲突: 导致数据损坏、丢失、重复读取或状态判断错误。
- 硬件寄存器冲突: 导致硬件行为异常、读取到无效数据或状态、配置失效。
避免冲突的关键策略:
- 临界区保护 (Critical Sections): 在访问共享资源的代码段(主循环或中断)前后,使用关中断 (
__disable_irq()
/cli()
) / 开中断 (__enable_irq()
/sei()
) 或信号量 (Semaphore) / 互斥锁 (Mutex - 通常在RTOS中) 等手段,确保访问过程不被中断(或其他任务)打断。(对于案例1,保护if(keyPressed)
检查和keyPressed=0
的整个区域;对于案例2,保护读写索引的更新;对于案例3,保护整个配置函数或关键步骤) - 原子操作 (Atomic Operations): 使用处理器支持的原子指令(如ARM的
LDREX
/STREX
)或确保数据类型在平台上能单条指令完成读写(如8位MCU上的uint8_t
)。(对案例1的flag,如果平台支持原子读写uint8_t
,单独读写是原子的,但检查+清零的组合操作仍需临界区保护) - 无锁编程 (Lock-Free Programming): 设计特殊的数据结构和算法(如精心设计的环形缓冲区,使用单独的读/写指针和镜像索引),利用内存屏障确保可见性,避免使用锁。这需要较高的技巧,且并非所有场景都适用。
- 禁止在中断中执行耗时操作或复杂逻辑: 尽量保持中断服务程序简短,只做最必要的操作(如设置标志、存入缓冲区、清除中断标志),复杂的处理交给主循环基于标志位去完成。这本身就能减少冲突窗口和可能性。
- 使用
volatile
关键字: 告知编译器该变量可能被意外修改(如被中断修改),强制每次访问都从内存读取/写入,防止编译器优化导致访问不到最新值。这是解决冲突的必要条件,但远非充分条件。它解决的是编译器优化带来的可见性问题,解决不了上述的并发访问时序问题。
理解这些冲突场景及其根本原因,是编写健壮、可靠的嵌入式系统程序的基础。务必在涉及共享资源访问的地方仔细考虑并发访问的可能性,并采取适当的保护措施。