翻转 IO,通信卡死?

STM32 GPIO 异常调试记录

Posted by Shao Guoji on April 2, 2021

共享资源互斥访问,临界区和锁,每个软件工程师大概都能说上几嘴,我自认为也略知一二,可真当问题出现,情况又会是怎样?

事件经过

项目中使用 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操作方式有关

一番修改尝试过后,几点初步结论形成,故障的出现:

  1. 和中断有关
  2. 和 IO 翻转频率有关,操作越快故障率越高
  3. 和 IO 端口有关,操作同一组端口时故障出现
  4. 和 IO 操作方式有关

其中最后两点十分关键,同一组端口的操作会相互影响,并且相同操作产生一致的波形,不同的写法结果却存在差异,因此直接排除了硬件上的原因(一开始我还猜想,是不是由于电平高速翻转,产生了高频电磁干扰)。

关注点便来到了软件操作 IO 的输出的不同方式:

  1. 直接操作 ODR 数据寄存器
  2. 通过写入 BRR/BSRR 寄存器
  3. 库函数 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;

仔细琢磨不难发现,其中包含的操作有:

  1. 从外设 ODR 中读取 32bit 数据(读)
  2. 把读取来的数据进行异或运算(改)
  3. 运算结果写入外设 ODR 寄存器(写)

图1 读-改-写

一条语句被分成了 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 左右,所以一开始没留意):

图2 异常波形

为进一步确认问题所在,修改代码,操作 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 只能保证编译时的数据访问的可靠性,并不能避免中断异常运行时机制对数据的影响。

图3 Cortex-M3 中断机制

所以,整个问题的发生原因归结为:指令执行序列被打断,由于中断上下文恢复机制,让老数据再次被写入端口输出。

看上去无论是从编译器还是 CPU 指令层面,都无法解决。

解决方法

说到底,解决方式还得靠软件实现上的优化。知道了问题产生的原因,可从多方面​对症下药:

  1. 尽量避免直接操作 ODR,改用 BRR/BSRR 原子操作
  2. 操作 ODR 前关闭中断,完事后打开
  3. 避免 ODR 操作同时出现在中断模式和线程模式

问题总算是解决了。

这样看来,上写文切换导致的共享资源问题,不仅出现在操作系统调度中,裸机前后台中断切换时也值得注意。

参考资料