目录
声明
本博客所记录的关于正点原子i.MX6ULL开发板的学习笔记,(内容参照正点原子I.MX6U嵌入式linux驱动开发指南,可在正点原子官方获取正点原子Linux开发板 — 正点原子资料下载中心 1.0.0 文档),旨在如实记录我在学校学习该开发板过程中所遭遇的各类问题以及详细的解决办法。其初衷纯粹是为了个人知识梳理、学习总结以及日后回顾查阅方便,同时也期望能为同样在学习这款开发板的同学或爱好者提供一些解决问题的思路和参考。我尽力保证内容的准确性和可靠性,但由于个人知识水平和实践经验有限,若存在错误或不严谨之处,恳请各位读者批评指正。
责任声明:虽然我力求提供有效的问题解决办法,但由于开发板使用环境、硬件差异、软件版本等多种因素的影响,我的笔记内容不一定适用于所有情况。对于因参考本笔记而导致的任何直接或间接损失,我不承担任何法律责任。使用本笔记内容的读者应自行承担相关风险,并在必要时寻求专业技术支持。
1. 并发与竞争介绍
并发就是多个“用户”同时访问同一个共享资源,Linux 系统是个多任务操作系统,会存在多个任务同时访问同一片内存区域,这些任务可能会相互覆盖这段内存中的数据,造成内存数据混乱。
主要原因:
①、多线程并发访问, Linux 是多任务(线程)的系统,所以多线程访问是最基本的原因。
②、抢占式并发访问,从 2.6 版本内核开始, Linux 内核支持抢占,也就是说调度程序可以在任意时刻抢占正在运行的线程,从而运行其他的线程。
③、中断程序并发访问,硬件中断。
④、 SMP(多核)核间并发访问,现在 ARM 架构的多核 SOC 很常见,多核 CPU 存在核间并发访问。
并发访问带来的问题就是竞争,学过FreeRTOS和UCOS的同学应该知道临界区这个概念,所谓的临界区就是共享数据段,对于临界区必须保证一次只有一个线程访问,也就是要保证临界区是原子访问的,这里的原子访问就表示这一个访问是一个步骤,不能再进行拆分。如果多个线程同时操作临界区就表示存在竞争。我们保护的不是代码,而是数据!某个线程的局部变量不需要保护,我们要保护的是多个线程都会访问的共享数据。
2. 原子操作
原子操作就是指不能再进一步分割的操作,一般原子操作用于变量或者位操作。Linux 内核提供了一组原子操作 API 函数来完成此功能, Linux 内核提供了两组原子操作 API 函数,一组是对整形变量进行操作的,一组是对位进行操作的。
2.1 原子整形操作 API 函数
Linux 内核定义了叫做 atomic_t 的结构体来完成整形数据的原子操作,在使用中用原子变量来代替整形变量,此结构体定义在 include/linux/types.h 文件中,定义如下:
typedef struct {
int counter;
} atomic_t;
如果要使用原子操作 API 函数,首先要先定义一个 atomic_t 的变量
atomic_t a; //定义 a
也可以在定义原子变量的时候给原子变量赋初值,如下所示:
atomic_t b = ATOMIC_INIT(0); //定义原子变量 b 并赋初值为 0
可以通过宏 ATOMIC_INIT 向原子变量赋初值。
接下来就是对原子变量进行操作,比如读、写、增加、减少等等, Linux 内核提供了大量的原子操作 API 函数:
如果使用 64 位的 SOC 的话,就要用到 64 位的原子变量, Linux 内核也定义了 64 位原子结构体,如下所示:
typedef struct {
long long counter;
} atomic64_t;
相应的也提供了 64 位原子变量的操作 API 函数,这里我们就不详细讲解了,和表 47.2.1.1中的 API 函数有用法一样,只是将“atomic_”前缀换为“atomic64_”,将 int 换为 long long。
2.2 原子位操作 API 函数
Linux 内核也提供了一系列的原子位操作 API 函数,只不过原子位操作不像原子整形变量那样有个 atomic_t 的数据结构,原子位操作是直接对内存进行操作API 函数如表 47.2.3.1 所示:
注:原子操作只能对整形变量或者位进行保护
3. 自旋锁
3.1 自旋锁简介
当一个线程要访问某个共享资源的时候首先要先获取相应的锁, 锁只能被一个线程持有,只要此线程不释放持有的锁,那么其他的线程就不能获取此锁。对于自旋锁而言,如果自旋锁正在被线程 A 持有,线程 B 想要获取自旋锁,那么线程 B 就会处于忙循环-旋转-等待状态,线程 B 不会进入休眠状态或者说去做其他的处理,而是会一直傻傻的在那里“转圈圈”的等待锁可用。
自旋锁的一个缺点:等待自旋锁的线程会一直处于自旋状态,这样会浪费处理器时间,降低系统性能,所以自旋锁的持有时间不能太长。所以自旋锁适用于短时期的轻量级加锁,如果遇到需要长时间持有锁的场景那就需要换其他的方法了。
Linux 内核使用结构体 spinlock_t 表示自旋锁,结构体定义如下所示:
/*
* 自旋锁结构体定义
* 使用联合体(union)实现两种不同的布局:
* 1. 普通情况下的简单结构
* 2. 调试情况下带有锁依赖跟踪的复杂结构
*/
typedef struct spinlock {
union {
/* 普通情况下的自旋锁结构 */
struct raw_spinlock rlock;
#ifdef CONFIG_DEBUG_LOCK_ALLOC
/*
* 调试模式下使用的结构
* 定义填充字节和锁依赖映射
*/
/*
* 计算填充大小:
* LOCK_PADSIZE 是 raw_spinlock 结构中 dep_map 字段的偏移量
* 这样可以确保调试结构与非调试结构的布局一致
*/
#define LOCK_PADSIZE (offsetof(struct raw_spinlock, dep_map))
struct {
/*
* 填充字节,确保调试结构和非调试结构的
* raw_spinlock 部分内存布局一致
*/
u8 __padding[LOCK_PADSIZE];
/*
* 锁依赖映射结构,用于内核锁调试器(lockdep)跟踪锁的获取顺序
* 帮助检测死锁和违反锁顺序规则的情况
*/
struct lockdep_map dep_map;
};
#endif
};
} spinlock_t;
在使用自旋锁之前,肯定要先定义一个自旋锁变量,定义方法如下所示:
spinlock_t lock; //定义自旋锁
定义好自旋锁变量以后就可以使用相应的 API 函数来操作自旋锁。
3.2 自旋锁 API 函数
最基本的自旋锁 API 函数如表 47.3.2.1 所示:
自旋锁API 函数适用于SMP或支持抢占的单CPU下线程之间的并发访问,也就是用于线程与线程之间,被自旋锁保护的临界区一定不能调用任何能够引起睡眠和阻塞的API 函数,否则的话会可能会导致死锁现象的发生。自旋锁会自动禁止抢占,也就说当线程 A得到锁以后会暂时禁止内核抢占。如果线程 A 在持有锁期间进入了休眠状态,那么线程 A 会自动放弃 CPU 使用权。线程 B 开始运行,线程 B 也想要获取锁,但是此时锁被 A 线程持有,而且内核抢占还被禁止了!线程 B 无法被调度出去,那么线程 A 就无法运行,锁也就无法释放,死锁发生!
中断里面可以使用自旋锁,但是在中断里面使用自旋锁的时候,在获取锁之前一定要先禁止本地中断。
Linux 内核提供了相应的 API 函数,如表所示
建议使用 spin_lock_irqsave/ spin_unlock_irqrestore,因为这一组函数会保存中断状态,在释放锁的时候会恢复中断状态。一般在线程中使用 spin_lock_irqsave/ spin_unlock_irqrestore,在中断中使用 spin_lock/spin_unlock。
下半部(BH)也会竞争共享资源,有些资料也会将下半部叫做底半部。如果要在下半部里面使用自旋锁,可以使用表 47.3.2.3 中的 API 函数:
3.3 其他类型的锁
3.3.1 读写自旋锁
读写自旋锁为读和写操作提供了不同的锁,一次只能允许一个写操作,也就是只能一个线程持有写锁,而且不能进行读操作。但是当没有写操作的时候允许一个或多个线程持有读锁,可以进行并发的读操作。
Linux 内核使用 rwlock_t 结构体表示读写锁,结构体定义如下:
typedef struct {
arch_rwlock_t raw_lock;
} rwlock_t;
读写锁操作 API 函数分为两部分,一个是给读使用的,一个是给写使用的,这些 API 函数如表 47.3.3.1 所示:
3.3.2 顺序锁
顺序锁在读写锁的基础上衍生而来的,使用读写锁的时候读操作和写操作不能同时进行。使用顺序锁的话可以允许在写的时候进行读操作,也就是实现同时读写,但是不允许同时进行并发的写操作。顺序锁保护的资源不能是指针,因为如果在写操作的时候可能会导致指针无效,而这个时候恰巧有读操作访问指针的话就可能导致意外发生,比如读取野指针导致系统崩溃。
Linux 内核使用 seqlock_t 结构体表示顺序锁,结构体定义如下:
typedef struct {
struct seqcount seqcount;
spinlock_t lock;
} seqlock_t;
关于顺序锁的 API 函数如表 47.3.3.2 所示:
3.3.3 自旋锁使用注意事项
在使用自旋锁的时候要注意一下几点:
①、因为在等待自旋锁的时候处于“自旋”状态,因此锁的持有时间不能太长,一定要短,否则的话会降低系统性能。如果临界区比较大,运行时间比较长的话要选择其他的并发处理方式,比如信号量和互斥体。
②、自旋锁保护的临界区内不能调用任何可能导致线程休眠的 API 函数,否则的话可能导致死锁。
③、不能递归申请自旋锁,因为一旦通过递归的方式申请一个你正在持有的锁,那么你就必须“自旋”,等待锁被释放,然而你正处于“自旋”状态,根本没法释放锁。结果就是自己把自己锁死了!
④、在编写驱动程序的时候我们必须考虑到驱动的可移植性,因此不管你用的是单核的还是多核的 SOC,都将其当做多核 SOC 来编写驱动程序。
4. 信号量
4.1 信号量简介
信号量是同步的一种方式。 Linux 内核也提供了信号量机制,信号量常常用于控制对共享资源的访问。相比于自旋锁,信号量可以使线程进入休眠状态。使用信号量会提高处理器的使用效率,毕竟不用一直傻乎乎的在那里“自旋”等待。但是,信号量的开销要比自旋锁大,因为信号量使线程进入休眠状态以后会切换线程,切换线程就会有开销。
信号量的特点:
①、因为信号量可以使等待资源线程进入休眠状态,因此适用于那些占用资源比较久的场合。
②、信号量不能用于中断中,因为信号量会引起休眠,中断不能休眠。
③、如果共享资源的持有时间比较短,那就不适合使用信号量了,因为频繁的休眠、切换线程引起的开销要远大于信号量带来的那点优势。
4.2 信号量API函数
Linux 内核使用 semaphore 结构体表示信号量,结构体内容如下所示:
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
}
要想使用信号量就得先定义,然后初始化信号量。有关信号量的 API 函数如表 47.4.2.1 所示
信号量的使用如下所示:
struct semaphore sem; /* 定义信号量 */
sema_init(&sem, 1); /* 初始化信号量 */
down(&sem); /* 申请信号量 */
/* 临界区 */
up(&sem); /* 释放信号量 */
5. 互斥体
5.1 互斥体简介
将信号量的值设置为 1 就可以使用信号量进行互斥访问了,虽然可以通过信号量实现互斥,但是 Linux 提供了一个比信号量更专业的机制来进行互斥,它就是互斥体—mutex。互斥访问表示一次只有一个线程可以访问共享资源,不能递归申请互斥体。
使用 mutex 结构体表示互斥体,定义如下
struct mutex {
/* 1: unlocked, 0: locked, negative: locked, possible waiters */
atomic_t count;
spinlock_t wait_lock;
}
在使用 mutex 之前要先定义一个 mutex 变量。在使用 mutex 的时候要注意如下几点:
①、 mutex 可以导致休眠,因此不能在中断中使用 mutex,中断中只能使用自旋锁。
②、和信号量一样, mutex 保护的临界区可以调用引起阻塞的 API 函数。
③、因为一次只有一个线程可以持有 mutex,因此,必须由 mutex 的持有者释放 mutex。并且 mutex 不能递归上锁和解锁。
5.2 互斥体 API 函数
互斥体的使用如下所示:
1 struct mutex lock; /* 定义一个互斥体 */
2 mutex_init(&lock); /* 初始化互斥体 */
3
4 mutex_lock(&lock); /* 上锁 */
5 /* 临界区 */
6 mutex_unlock(&lock); /* 解锁 */