傻孩子(GorgonMeducer) · 2020年06月22日

漫谈C变量——对齐 (2)

作者:GorgonMeducer 傻孩子
首发:裸机思维
  • 什么情况下会产生非对齐的操作呢?
    • *

在讨论这个问题之前,我们先要记住一个结论:

一般情况下,出于效率和兼容性的考虑,编译器会避免产生非对齐的操作。当且仅当编译器不知道(被蒙蔽)的情况下,才有可能产生隐性的非对齐的操作。

  不知所云?让我们举一个简单的例子:


  extern void word_access ( uint32_t *pwTarget );

  // 如果你这么用,显然是没有任何问题的

  extern uint32_t wDemo;

  ...

  word_access (&wDemo);

  // 如果你这么做呢……

   extern uint8_t chBuffer[16];

   ...

  word_access ((uint32_t *)&chBuffer[1]);

不管你是否已经明白问题所在了,我们来简单分析下这段代码:

  • 函数 word\_access() 需要一个 uint32\_t 型的指针作为形式参数
  • 参考 上一篇文章 的内容,我们知道,对于 (uint32\_t * ) 指针的操作,编译器会生成对齐到word的操作指令, 比如LDR和STR。我们可以下结论说,函数 word\_access() 存在隐含要求,就是传入的指针必须是word对齐的。
  • 最后一个例子中,数组chBuffer[] 很有可能被分配在一个对齐到 word 或者 halfword 的地址上,那么 &chBuffer[1] 几乎可以肯定是一个非对齐的地址
  • 把一个非对齐的地址传给一个默认需要对齐的函数,结果不言自明。

可能有人会问:既然代码已经写的清清楚楚——“我们使用的是一个非对齐的地址”——为什么编译器仍然会假装不知道呢?其实编译器并非不知道,如果我们直接这么写:

word\_access (&chBuffer[1]);

编译器立马就会报告Error:“指针的类型不符”。为了头疼医头,脚疼医脚的“屏蔽”这个Error,很多人会加入强制类型转换 (uint32\_t *) 。实际上,从ANSI-C的标准来看,这个代码并没有任何问题,语法和逻辑上都讲得通。但是对齐是一个“潜规则”,你不遵守它,就会吃亏。这里,强制类型转换相当于直接给编译器蒙住了眼睛:“甭管之前看到了什么,反正现在这个指针,我说是对齐的就是对齐的!!!”

  • 谁会写这么傻的代码呢?
    • *

  也许你不会直接写出这么傻的代码,但是下面的“高级”用法确更加稀松平常:

  // 这是一个消息地图中常见的消息处理函数

  void xxx_msg_handler( uint8_t *pchStream, uint16_t hwSize )

  {

    // offset 0x00:  1 BYTE    Command / Message
    uint8_t chCMD = pchStream[0];       
    // offset 0x01:  4 BYTE    Serial Number of the frame
    uint32_t wSN = *(uint32_t *)&pchStream[1];

   ...

  }

对于通信数据帧解析来说,上述用法在常见不过了,怎么样踩地雷了吧?这只是举一个例子,只要用到指针强制类型转换的地方,都是在“蒙蔽”编译器,有可能受到对齐潜规则的惩罚。

  • 为什么我这么写了,代码执行的好好的?
    • *

因为 ARMv7-M 支持非对齐操作,具体请看 对齐(1)的内容,所以你幸免于难。但是,对如下的情况,你就绝无可能幸免:

  • 如果 ARMv7-M 中关闭了对非对齐操作的支持(感觉是废话)
  • 用的是 ARMv6-M,本身就不支持任何非对齐操作
  • 如果编译器用的是STMLDMPOPPUSH这种完全不支持非对齐操作的指令
  • 如果编译器用的是LDRDSTRD这类双字(DWORD)操作的指令,地址没有对齐到WORD或者DWORD
  • 如果你操作的地址比 0xE0000000 大,简单说就是你在访问Processor的系统外设,比如NVICSysTickSCBMPU等等(不知道我在说啥也没关系,通常这类操作都是用CMSIS库实现的,不太可能出现非对齐操作)
  • 非对齐操作有什么危害?
    • *

  非对齐操作的危害主要有以下几点:

  • 影响代码的可移植性。(比如:好好的工程,加入某个模块就立即异常……还没有源代码,只有.a,呵呵)
  • 直接导致性能下降。尽管LDR/STR这样的指令支持非对齐操作,但其实我们的流水线是通过1)将这一非对齐的操作拆分成两个对齐的操作,最后2)再组装起来 实现的。
  • 如果操作的目标地址上是一个“易失性”的寄存器,那么非对齐的操作被拆分了以后,会导致原本的一次操作变成了连续的两次。从而对操作的内容产生破坏性的后果。
    • *

注意:这里“易失性”意思就是,每次操作的时候:

  • 要么操作本身会导致寄存器内容改变

    比如,GPIO的Toggle寄存器,每次写操作都会导致对应的引脚翻转

    比如,外设的中断状态寄存器,读取状态寄存器的操作本身就会清除标志

    ……

  • 要么每次读取的内容都会不同

    比如, Timer计数器,每次读取的时候计数器的内容都不同

    比如,ADC的采样结果寄存器,读取顺序不同,很可能每次读取时候的值都会变化

    ……

    • *
  • 如果操作的目标地址由多个Processor共享,甚至是与DMA共享,那么非对齐操作导致的连续两次操作,其原子性是没有保证的——其它Processor(总线Master)很有可能在你的两次操作中间插入进来——破坏了数据内容的完整性。这个很难调试,很难发现的哦!

是不是越听腿越哆嗦?啥?不哆嗦?莫装13,反正以后坑的是自己。珍爱生命,远离非对齐操作。

  • 针对本文的例子,如何避免非对齐操作?
    • *

1、对第一个例子来说,要么避免给函数提供非对齐的地址,要么直接告诉编译器对应的函数处理的地址可能是非对齐的,直接修改函数原形即可:

// 假设我们有一个函数,它要执行一个可能非对齐的 32bit 的整数操作

**extern void word\_access ( uint32\_t \_\_packed \*pwTarget );**

2、对第二个例子来说,由于数据帧的格式已经确定,因此,我们需要直接告诉编译器对目标数据的访问是非对齐的,对应的代码如下:

   // 这是一个消息地图中常见的消息处理函数

  void xxx_msg_handler( uint8_t *pchStream, uint16_t hwSize )

  {

    // offset 0x00:  1 BYTE    Command / Message

    uint8_t chCMD = pchStream[0];       

    // offset 0x01:  4 BYTE    Serial Number of the frame

    uint32_t wSN = *(uint32_t __packed*)&pchStream[1];

 ...

  }


专栏推荐文章


如果你喜欢我的思维,欢迎订阅裸机思维
版权归裸机思维(傻孩子图书工作室旗下公众号)所有,
所有内容原创,严禁任何形式的转载。
推荐阅读
关注数
1480
内容数
119
探讨嵌入式系统开发的相关思维、方法、技巧。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息