共享资源互斥访问,临界区和锁,每个软件工程师大概都能说上几嘴,我自认为也略知一二,可真当问题出现,情况又会是怎样?
事件经过
项目中使用 STM32 进行 RS485 通信,2 线方式,半双工通信,通过 UASRT 外设驱动接收发器芯片,需要控制收发切换管脚,同时,闪烁 LED 作为工作指示。
诡异的事情在于,当程序快速翻转 LED GPIO 时,总线通信便会卡住,并在一定时间内随机出现。
示波器测量芯片收发切换管脚,发现产生故障时电平一直为高(发送状态),导致无法接收数据及回应,通信瘫痪。
去掉翻转 LED GPIO 代码,一切恢复正常。
程序结构
串口使用 IDLE 中断配合 DMA 进行连续收发,标准固件库裸机开发方式。
简单来说就是:
①接收数据 → ②进入 USART IDLE 中断 → ③从 DMA buffer 拿数据并处理协议 → ④拉高收发脚 → ⑤开 DMA 发送数据 → ⑥DMA TC 中断 → ⑦USART TC 中断 → ⑧拉低收发脚 → ⑨接收数据
485 收发管脚操作代码(调用固件库 API):
#define COM_R485_TXD GPIO_WriteBit(GPIOA,GPIO_Pin_4,1)
#define COM_R485_RXD GPIO_WriteBit(GPIOA,GPIO_Pin_4,0)
指示灯翻转 IO 的代码(写 ODR 寄存器):
GPIOA->ODR ^= GPIO_Pin_5;
故障分析
「不应该啊!」
按道理说闪灯和通信之间互不影响,是完全独立的功能,怎么会这样?
「玄学,太玄了(陷入沉思)……」
在没有任何头绪的情况下,只能变着各种法子改代码,控制变量对比运行结果,简称「瞎试」。
对比排查
结果记录
程序修改 | 结果对比 | 结论 |
---|---|---|
翻转 IO (GPIOA->ODR^= GPIO_Pin_15;) | 随机出现通信卡死(0~15s 内必现) | 系统存在隐患 |
main 函数 while(1) 中翻转 IO,调整语句位置 | 同上 | 与翻转操作在 mian 中的位置无关 |
协议解析函数从中断移到 main() 中处理 | 故障未出现 | 与协议处理、程序中断时机有关 |
翻转 IO 的时间周期改为 10 倍 | 故障出现概率减小(15s~3min 内大概率出现) | 与 IO 翻转频率有关 |
翻转其它 IO 口,如 PA5,PB10 等 | 翻转 PA 口结果相同,翻转 PB 口故障未出现 | 与 IO 端口有关 |
使用BRR和BSRR翻转IO(示波器查看翻转波形一致) | 故障未出现 | 排除硬件问题,与IO操作方式有关 |
一番修改尝试过后,几点初步结论形成,故障的出现:
- 和中断有关
- 和 IO 翻转频率有关,操作越快故障率越高
- 和 IO 端口有关,操作同一组端口时故障出现
- 和 IO 操作方式有关
其中最后两点十分关键,同一组端口的操作会相互影响,并且相同操作产生一致的波形,不同的写法结果却存在差异,因此直接排除了硬件上的原因(一开始我还猜想,是不是由于电平高速翻转,产生了高频电磁干扰)。
关注点便来到了软件操作 IO 的输出的不同方式:
- 直接操作 ODR 数据寄存器
- 通过写入 BRR/BSRR 寄存器
- 库函数 API 等
直接访问 ODR 寄存器会出问题,通过 BRR 和 BSRR 寄存器输出却不会,这其中的区别是什么?
非原子操作与中断隐患
BSRR、BRR、 ODR 之间的关系
配置 BSRR , BRR 是为了对端口输出进行配置,而 ODR 寄存器也是用于输出数据的寄存器,一个 ODR 寄存器控制了一组(16位)的 GPIO 输出。因此,对 ODR 进行修改也可以到达对 IO 口输出进行配置。
但是,由于对 ODR 寄存器的读写操作必须以 16 位的形式进行。因此,如果使用 ODR 改写数据以控制输出时,须采用「读-改-写」的形式进行。
而对 BSRR 的操作,是写 1 有效,写 0 不改变原状态。
BSRR/BRR 寄存器操作只要一次写操作便能完成,ODR 修改会被拆分处理。回到一切问题的罪魁祸首,LED 翻转语句:
GPIOA->ODR ^= GPIO_Pin_5;
这是再平常不过的写法,其等价于:
GPIOA->ODR = GPIOA->ODR ^ GPIO_Pin_5;
仔细琢磨不难发现,其中包含的操作有:
- 从外设 ODR 中读取 32bit 数据(读)
- 把读取来的数据进行异或运算(改)
- 运算结果写入外设 ODR 寄存器(写)
一条语句被分成了 3 步操作,是一个非原子操作,如同化学概念中的物质被分割,在这 3 步操作之间的间隙里,都是有可能被中断打断的。
真相大白之时
意识到了非原子操作被打断的可能性后,再次仔细翻看程序,留意到串口 TC(发送完成)中断 ISR 里,正是对 485 收发脚的控制,并且也为 A 组端口 GPIO。
void USART2_IRQHandler(void)
{
if(USART_GetITStatus(USART2,USART_IT_TC) != RESET) // 串口发送完成中断
{
USART_ClearITPendingBit(USART2, USART_IT_TC); // 清除中断标志
COM_R485_RXD; // 拉低 IO 进入接收状态
}
}
此时,事情才开始明朗,脑袋也一下子热乎了起来。
不防假设,在上一次发送前把收发脚拉高了之后,串口硬件发送完成中断尚未到来,程序从 DMA 中断返回到 mian(),此时往下执行到翻转 LED IO 代码,正好完成了 ODR 「读改写」三部曲的第一步,读取数据,此时读到收发脚的电平还是高的。
不巧的事情来了,这时候,串口移位寄存器把数据全部发出,TC 中断产生,ISR 里把收发脚拉低,准备下一次的接收,中断返回。
返回后的程序,继续完成「读改写」三部曲后两步,异或操作和写入寄存器,但进行运算的数据仍然是被中断前的老数据,同一组端口的收发脚依然记录为高,运算后写入 ODR。
本来意图在 TC 中断后拉低的 IO,在中断返回时被「恢复」到了原本的高电平,485 收发器再次进入了发送状态,然而此刻并没有数据要发送,也无法接收,造成总线阻塞。
再用示波器放大出现故障时的收发脚波形,发现本来刚要被拉低的 IO,马上就变高了(时间非常短,在 500ns 左右,所以一开始没留意):
为进一步确认问题所在,修改代码,操作 ODR 前关闭串口 TC 中断,总线工作正常:
USART_ITConfig(USART2,USART_IT_TC,DISABLE);
GPIOA->ODR^= GPIO_Pin_5;
USART_ITConfig(USART2,USART_IT_TC,ENABLE);
终于,真相大白,之前的一切诡异现象都能够得到解释。
volatile 为什么不起作用?
对于这样的数值更新问题,C 语言的 volatile 关键字提供了一种解决方法,在 STM32 头文件中的寄存器定义内(包括 GPIO 的 ODR),也大量使用了 __IO 修饰符(volatile 的宏),保证数据访问不会被编译器优化(尤其是嵌入式开发中,存储器内容会被硬件改变)。
/**
* @brief General Purpose I/O
*/
typedef struct
{
__IO uint32_t CRL;
__IO uint32_t CRH;
__IO uint32_t IDR;
__IO uint32_t ODR;
__IO uint32_t BSRR;
__IO uint32_t BRR;
__IO uint32_t LCKR;
} GPIO_TypeDef;
volatile 提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,告诉编译器对该变量不做优化,都会直接从变量内存地址中读取数据,从而可以提供对特殊地址的稳定访问。
可是,在本案例中,为什么 volatile 不起作用?
首先我们查看 ODR 操作语句对应的汇编代码:
238: GPIOA->ODR^= GPIO_Pin_5;
0x0800019E 48BE LDR r0,[pc,#760] ; @0x08000498
0x080001A0 6800 LDR r0,[r0,#0x00]
0x080001A2 F0800020 EOR r0,r0,#0x20
0x080001A6 49BC LDR r1,[pc,#752] ; @0x08000498
0x080001A8 6008 STR r0,[r1,#0x00]
ODR 的数据确实是从外设寄存器读取(通过 0x08000498 地址的存储器映射完成),但是,访存完毕结束,执行异或 EOR 指令运算只能通过 r0 寄存器完成(根据 ARM 汇编的规则,目标操作数和第一操作数必须是寄存器)。
多条指令执行过程中会被中断打断。根据 Cortex-M3 的中断处理机制特性:在进入 ISR 时自动把 r0 寄存器压栈,中断返回时自动弹出。
估计是两次操作 IO 的方式不同,编译器没有意识到内存在中断中被修改,寄存器 r0 并不会被更新,反倒被恢复。
volatile 只能保证编译时的数据访问的可靠性,并不能避免中断异常运行时机制对数据的影响。
所以,整个问题的发生原因归结为:指令执行序列被打断,由于中断上下文恢复机制,让老数据再次被写入端口输出。
看上去无论是从编译器还是 CPU 指令层面,都无法解决。
解决方法
说到底,解决方式还得靠软件实现上的优化。知道了问题产生的原因,可从多方面对症下药:
- 尽量避免直接操作 ODR,改用 BRR/BSRR 原子操作
- 操作 ODR 前关闭中断,完事后打开
- 避免 ODR 操作同时出现在中断模式和线程模式
问题总算是解决了。
这样看来,上写文切换导致的共享资源问题,不仅出现在操作系统调度中,裸机前后台中断切换时也值得注意。
参考资料