论Linux进程/线程同步在嵌入式驱动开发中的重要性(基于模拟IIC乱码场景分析)——前传

在学习资料满天飞的大环境下,知识变得非常零散,体系化的知识并不多,这就导致很多人每天都努力学习到感动自己,最终却收效甚微,甚至放弃学习。我的使命就是过滤掉大量的垃圾信息,将知识体系化,以短平快的方式直达问题本质,把大家从大海捞针的痛苦中解脱出来。

文章目录

    • 1 问题
    • 2 分析
    • 3 解决
      • 3.1 方案一
      • 3.2 方案二
      • 3.3 方案三
        • 3.3.1 选取锁定资源
        • 3.3.2 选取临界区
      • 3.4 方案四
    • 4 复盘

1 问题

一个大型嵌入式系统出现的问题往往会牵扯到各个功能模块,而且很难迅速地抓住引发问题出现的本质原因。

解决问题的第一步就是找到问题,要不停地迭代逼近问题的“本尊”。

先简单介绍一下问题背景。问题最早暴露在应用层,发现某些业务会偶现地出现逻辑错误,跟着该逻辑错误找到是EEPROM数据读写问题(查找过程略去一万字……),继续跟踪(此处略去几千字,哈哈)发现是EEPROM在切页时的逻辑处理有一些问题,解决该问题后,发现EEPROM数据读写仍然存在问题,继续跟踪(继续省略哈)发现模拟的IIC读写是存在误码(误码率大概是万分之三)的。

本文就从模拟IIC误码问题正式开始~

Tips:如果有的小伙伴对EEPROM的驱动或者如何“洁癖地”实现模拟IIC代码,可以关注我哟,后续会娓娓道来~

毛主席曾经说过,没有调研就没有发言权。话不多说,直接上误码产生时抓到的模拟IIC时序图,如下图(图1)。

图1:
论Linux进程/线程同步在嵌入式驱动开发中的重要性(基于模拟IIC乱码场景分析)——前传_第1张图片

图1中,蓝色线是SCL,黄色线是SDA。请大家睁大眼睛,找到红色矩形框框住的区域,眼尖的小盆友已经发现这里有一个蓝色的脉冲,高度连1/2高电平都没有过。少拍一个时钟,自然会导致波形和数据产生“移码突变”,从而产生误码。

2 分析

为什么会少拍一个时钟呢?是硬件问题还是软件问题呢?

首先,从软件流程上排查问题,9个bit的时钟都是正常拍的,代码具有一致性,不存在前几个bit时钟可以正常拍出,突然就出现一个时钟拍不出去的情况啊,所以,肯定不是时序逻辑上出了问题。

再次,从硬件上进行排查,是否将引脚配置配置为开漏,上拉电阻的阻值是否根据RC充放电时间计算过,驱动电流是否合适。

然后,根据外接的IIC芯片手册对一下严格的时间参数,比如上升沿和下降沿必须小于4us之类的。

然而,排查完上述项并改进后并不能解决问题。

最后的最后,来到了今天的主题——进程/线程间同步。
前几个步骤分析的思路都是单进程/线程思路,所以还没有找到误码的根本原因。

先给出结论,本次模拟IIC通信存在误码的根本原因是——互斥锁锁定的资源范围选取过小。

使用GPIO模拟IIC需要不停拉高拉低引脚电平来模拟时序。代码中使用的是 “读-改-写” 操作GPIO一个Port的数据寄存器来进行的单引脚操作。但是 “读-改-写” 并不是原子操作(使用的处理器不支持寄存器读写原子操作),问题就出在这里了。

宏观上模拟IIC和模拟SPI恰巧使用了同一个Port里的引脚,然后都各自模拟着自己的波形,然后,还恰好跑在不同的线程上;在微观上(指令周期级别)就有可能产生图1中的现象。

论Linux进程/线程同步在嵌入式驱动开发中的重要性(基于模拟IIC乱码场景分析)——前传_第2张图片
为了方便说明微观上是如何产生图1中误码波形的,假设GPIO寄存器只有3个bit,第0bit对应IIC时钟引脚,第1bit对应IIC数据引脚,第2bit对应LED控制引脚。模拟IIC和LED控制引脚时都使用 “读-改-写” 操作。两个线程交替“读-改-写”寄存器情况如下图(图2)所示。

图2:在这里插入图片描述

线程P1的优先级高于P2的优先级,P2的“读-改-写”恰好被P1中断,导致了一个结果:GPIO寄存器的第0bit先被P1写成1,然后迅速又被P2改写成了0,表现到引脚上就是时钟引脚被拉高了一段时间(线程切换的时间+代码运行的时间+虚地址转实地址的时间),然后瞬间被“打回原形”,也就呈现出了图一中的尖脉冲。

头脑敏锐的同学肯定会发现图1中的脉冲根本就没有到高电平啊,这个怎么解释呢?
脉冲没有拉高到高电平的原因是IIC总线上有耦合电容,充放电都需要时间,很短的时间不足以使充电完成;或者是驱动能力比较弱(总之是电气特性引起的,咱不是硬件大拿,也只能分析到这儿了,欢迎硬件大佬斧正)所以,引脚不能马上呈现出高电平状态。

等等,我还有问题?小盆友,你是不是有很多滴问号~
为啥,P1优先级比P2优先级高,在P1刚刚写完寄存器之后,控制权就马上被P2抢走了呢?
这个问题问的好~~
对于单核CPU,有两种解释,一种是时间片轮转,一种是P1主动释放。
(注:这种情况是在写该问题的后转时更新的)对于多核CPU(比如SMP系统),两个核之间是可以同时争抢资源,而不受优先级约束的。所以,在SMP系统中编程需要时刻注意这一点。

Tips:Linux系统在应用时如果有频繁的周期性中断(比如1/10/100*us级别的),就需要禁止时间片轮转调度算法了,防止过度频繁的调度消耗太多的处理器资源,但此时,线程的划分和优先级设计就非常重要和严格了,需要有详细的设计说明,并禁止随意更改,否则可能引起意想不到的错误,如正在讨论的模拟IIC误码。

3 解决

找到问题的本质之后,解决起来就相对容易了。

3.1 方案一

将线程睡眠延时修改为线程自旋延时。
这样优先级高的线程拿到CPU控制权之后就可以完全按照“自己的意思”来自由操作引脚时序了。不会受到低优先级线程的“暗算”。
对于低优先级线程来说,本身就存在“后手优势”,所以,高优先级线程是否释放CPU使用权对自己影响不大。

方案一的缺点有哪些呢?
(1)当延时较大时会增加CPU利用率,影响系统的实时性和响应时间。
(2)使用较大的自旋延时,容易被不知情的队友“优化”成睡眠延时。
(3)不是一种通用的解决硬件资源竞争的方法,不通用使用就不方便,容易出现纰漏。
(4)对于SMP系统不生效。

3.2 方案二

调整优先级。
将使用同一硬件资源的线程,调整为同一优先级,让他们之间不能互相打断。

缺点如下:
(1)如果在进行架构设计时硬件资源的管理并没有指定专门的线程的话,硬件资源的访问函数会被分散调用于各个线程中。第一,追踪不方便,系统太大后还会存在部门墙,互相之间的代码是看不到的;第二,即使追踪到了,如果线程过多,仅仅就为了解决硬件资源竞争问题就把所有相关线程调整为同一优先级肯定是大大的不妥,影响了其他业务逻辑怎么办?
(2)对于使用时间片轮转调度算法的系统,这招自然失效。

3.3 方案三

使用互斥锁。
使用互斥锁将整个GPIO Port寄存器锁起来,进入临界区之前获取互斥锁,退出临界区后释放锁。
优点:使用简单有效。
缺点:临界区选取过大会造成系统实时性变差。

3.3.1 选取锁定资源

通过前面的分析,知道竞争资源其实是一个GPIO的Port。先前程序出错,产生IIC乱码并不是没有用锁。其实也用了互斥锁,但是锁定资源没有识别清楚,锁定的是GPIO的一个引脚对应的内存(一个全局变量),而没有锁定整个GPIO的Port对应的寄存器。
使用锁最根本和最重要的就是精准识别竞争资源的范围,并精准加锁。

3.3.2 选取临界区

临界区的选取是一门艺术。临界区选取过大会造成系统实时性变差,临界区选取过小,又会增加线程切换次数,造成CPU资源的浪费。
一般来说,如果临界区可以选取到接近原子级别,则不再使用互斥锁,而使用自旋锁。
但是,在用户态编程一般临界区选取的都比较大,所以使用互斥锁的情况居多。

3.4 方案四

使用自旋锁。锁定的资源和互斥锁相同,都是GPIO一个Port对应的所有寄存器和相关的内存(定义的相关的变量)。不同的是,使用自旋锁时临界区的选取更加“精致”——操作寄存器前进入,操作完寄存器马上退出。
优点:加锁粒度更小,减少资源等待周期,减少线程切换时间。
缺点:如果用户态(模拟IIC的代码是在用户态实现的)开放了自旋锁的接口,应用开发工程师使用起来“度”不好把握(可能会大面积使用,造成临界区大小不好控制)。当然如果是在内核态编程,还是建议使用自旋锁的方案。

Tips:自旋锁的三个特性如下:
1)被自旋锁保护的临界区代码执行时不能进入休眠态。
2)被自旋锁保护的临界区代码执行时不能被中断。
3)被自旋锁保护的临界区代码执行时不能被内核抢占。

Tips:Linux实现自旋锁的方式如下:
1)在单核cpu、不可抢占内核中,自旋锁为空操作。
2) 在单核cpu、可抢占内核中,自旋锁实现为“禁止内核抢占”,并不实现“自旋”。
3)在多核cpu、可抢占内核中,自旋锁实现为“禁止内核抢占” + “自旋”。

4 复盘

复盘最重要的是重新理顺思路,从实践中抽象出经验、理论和规范。

1.使用锁并不难,难的是使用合适的锁,更难的是精准识别并确定锁定的竞争资源的边界。

2.编程过程中使用到共享资源,尤其是硬件资源,一定要“扪心自问”——我仔细考虑过如何避免竞态了吗?

你可能感兴趣的:(Linux,嵌入式)