Linux内核同步机制之原子操作

       本文转自 http://www.wowotech.net/linux_kenrel/atomic.html,在作者原文基础上(黑体),加入自己的阅读理解(红色字体),形成学习笔记并记录于此。蜗窝科技,如果你是一位 Linux 驱动开发工程师,那么强烈建议把它加到你的收藏夹,干货实在太多。

一、源由

       我们的程序逻辑经常遇到这样的操作序列:

1、读一个位于 memory 中的变量的值到寄存器中

2、修改该变量的值(也就是修改寄存器中的值)

3、将寄存器中的数值写回 memory 中的变量值

       如果这个操作序列是串行化的操作(在一个 thread 中串行执行),那么一切 OK,然而,世界总是不能如你所愿。在多 CPU 体系结构中,运行在两个 CPU 上的两个内核控制路径同时并行执行上面操作序列,有可能发生下面的场景:

CPU1 上的操作 CPU2 上的操作
读操作  
  读操作
修改 修改
写操作  
  写操作

多个 CPUs 和 memory chip 是通过总线互联的,在任意时刻,只能有一个总线 master 设备(例如 CPU、DMA controller)访问该 slave 设备(在这个场景中,slave 设备是 RAM chip)。因此,来自两个 CPU 上的读 memory 操作被串行化执行,分别获得了同样的旧值。完成修改后,两个 CPU 都想进行写操作,把修改的值写回到 memory。但是,硬件 arbiter 的限制使得 CPU 的写回必须是串行化的,因此 CPU1 首先获得了访问权,进行写回动作,随后,CPU2 完成写回动作。在这种情况下,CPU1 对memory 的修改被 CPU2 的操作覆盖了,因此执行结果是错误的。

       不仅是多 CPU,在单 CPU 上也会由于有多个内核控制路径的交错执行而导致上面描述的错误。一个具体的例子如下:

系统调用的控制路径 中断 handler 控制路径
读操作  
  读操作
  修改
  写操作
修改  
写操作  

系统调用的控制路径上,完成读操作后,硬件触发中断,开始执行中断 handler。这种场景下,中断 handler 控制路径的写回操作被系统调用控制路径上的写回覆盖了,结果也是错误的。

二、对策

       对于那些有多个内核控制路径进行 read-modify-write 的变量,内核提供了一个特殊的类型 atomic_t,具体定义如下:

typedef struct {
    int counter;
} atomic_t;

从上面的定义来看,atomic_t 实际上就是一个 int 类型的 counter,不过定义这样特殊的类型 atomic_t 是有其思考的:内核定义了若干 atomic_xxx 的接口 API 函数,这些函数只会接收 atomic_t 类型的参数,这样可以确保 atomic_xxx 的接口函数只会操作 atomic_t 类型的数据。同样的,如果你定义了 atomic_t 类型的变量(你期望用 atomic_xxx 的接口 API 函数操作它),这些变量也不会被那些普通的、非原子变量操作的 API 函数接受。

       具体的接口 API 函数整理如下:

接口函数 描述
static inline void atomic_add(int i, atomic_t *v) 给一个原子变量 v 增加 i
static inline int atomic_add_return(int i, atomic_t *v) 同上,只不过将变量 v 的最新值返回
static inline void atomic_sub(int i, atomic_t *v) 给一个原子变量 v 减去 i
static inline int atomic_sub_return(int i, atomic_t *v) 同上,只不过将变量 v 的最新值返回
static inline int atomic_cmpxchg(atomic_t *ptr, int old, int new) 比较 old 和原子变量 ptr 中的值,如果相等,那么就把 new 值赋给原子变量。
返回旧的原子变量 ptr 中的值
atomic_read 获取原子变量的值
atomic_set 设定原子变量的值
atomic_inc(v) 原子变量的值加一
atomic_inc_return(v) 同上,只不过将变量 v 的最新值返回
atomic_dec(v) 原子变量的值减一
atomic_dec_return(v) 同上,只不过将变量 v 的最新值返回
atomic_sub_and_test(i, v) 给一个原子变量 v 减去 i,并判断变量 v 的最新值是否等于0
atomic_add_negative(i, v) 给一个原子变量 v 增加 i,并判断变量 v 的最新值是否是负数
static inline int atomic_add_unless(atomic_t *v, int a, int u) 只要原子变量 v 不等于 u,那么就执行原子变量 v 加a 的操作。
如果 v 不等于 u,返回非 0 值,否则返回 0 值

三、ARM中的实现

       我们以 atomic_add 为例,描述 Linux kernel 中原子操作的具体代码实现细节:

#if __LINUX_ARM_ARCH__ >= 6----------------------------------------------(1)
static inline void atomic_add(int i, atomic_t *v)
{
    unsigned long tmp;
    int result;

    prefetchw(&v->counter);----------------------------------------------(2)
    __asm__ __volatile__("@ atomic_add\n"--------------------------------(3)
"1: ldrex %0, [%3]\n"----------------------------------------------------(4)
"   add %0, %0, %4\n"----------------------------------------------------(5)
"   strex %1, %0, [%3]\n"------------------------------------------------(6)
"   teq %1, #0\n"--------------------------------------------------------(7)
"   bne 1b"
    : "=&r" (result), "=&r" (tmp), "+Qo" (v->counter)--------对应%0,%1,%2
    : "r" (&v->counter), "Ir" (i)----------------------------对应%3,%4
    : "cc");
}

#else

#ifdef CONFIG_SMP
#error SMP not supported on pre-ARMv6 CPUs
#endif

static inline int atomic_add_return(int i, atomic_t *v)
{
    unsigned long flags;
    int val;

    raw_local_irq_save(flags);
    val = v->counter;
    v->counter = val += i;
    raw_local_irq_restore(flags);

    return val;
}
#define atomic_add(i, v)    (void) atomic_add_return(i, v)

#endif

(1)ARMv6 之前的 CPU 并不支持 SMP,之后的 ARM 架构都是支持 SMP 的(例如我们熟悉的 ARMv7-A)。因此,对于 ARM 处理器,其原子操作分成了两个阵营,一个是支持 SMP 的 ARMv6 之后的 CPU,另外一个就是 ARMv6 之前的,只有单核架构的 CPU。对于 UP(uniprocessor,单核处理器),原子操作就是通过关闭 CPU 中断来完成的(关闭中断就足够了吗?万一发生了抢占呢?)

(2)这里的代码和 preloading cache 相关。在 strex(ARM 汇编指令)指令之前将要操作的 memory 内容加载到 cache 中可以显著提高性能(可见 cache的重要性,不管是对 CPU 还是对我们学习)

(3)为了完整性,我还是重复一下汇编嵌入 c 代码的语法。嵌入式汇编的语法格式是:

asm(code : output operand list : input operand list : clobber list)

output operand list 和 input operand list是 c 代码和嵌入式汇编代码的接口,clobber list 描述了汇编代码对寄存器的修改情况。为何要有 clober list?我们的 c 代码是 gcc 来处理的,当遇到嵌入汇编代码的时候,gcc 会将这些嵌入式汇编的文本送给 gas 进行后续处理。这样,gcc 需要了解嵌入汇编代码对寄存器的修改情况,否则有可能会造成大麻烦。例如:gcc 对c 代码进行处理,将某些变量值保存在寄存器中,如果嵌入汇编修改了该寄存器的值,又没有通知 gcc 的话,那么,gcc 会以为寄存器中仍然保存了之前的变量值,因此不会重新加载该变量到寄存器,而是直接使用这个被嵌入式汇编修改的寄存器,这时候,我们唯一能做的就是静静的等待程序的崩溃。还好,在 output operand list 和 input operand list 中涉及的寄存器都不需要体现在 clobber list 中(gcc 分配了这些寄存器,当然知道嵌入汇编代码会修改其内容),因此,大部分的嵌入式汇编的 clobber list 都是空的,或者只有一个 cc,通知 gcc,嵌入式汇编代码更新了 condition code register。

大家对着上面的 code 就可以分开各段内容了。@符号标识该行是注释。

这里的 __volatile__ 主要是用来防止编译器优化的。也就是说,在编译该 c 代码的时候,如果使用优化选项(-O)进行编译,对于那些没有声明 __volatile__ 的嵌入式汇编,编译器有可能会对嵌入 c 代码的汇编进行优化,编译的结果可能不是原来你撰写的汇编代码,但是如果你的嵌入式汇编使用 __asm__ __volatile__(嵌入式汇编) 的语法格式,那么也就是告诉编译器,不要随便动我的嵌入汇编代码哦。

(4)我们先看 ldrex 和 strex 这两条汇编指令的使用方法。ldr 和str 这两条指令大家都是非常的熟悉了,后缀的 ex 表示Exclusive,是 ARMv7 提供的为了实现同步的汇编指令。

LDREX  , []

是base register,保存 memory 的 address,LDREX 指令从 base register 中获取 memory address,并且将 memory 的内容加载到 (destination register) 中。这些操作和 ldr 的操作是一样的,那么如何体现 exclusive 呢?其实,在执行这条指令的时候,还放出两条“狗”来负责观察特定地址的访问(就是保存在 [] 中的地址了),这两条狗一条叫做 local monitor,一条叫做 global monitor。

STREX , , []

和 LDREX 指令类似, 是 base register,保存 memory 的address,STREX 指令从 base register 中获取 memory address,并且将 (source register) 中的内容加载到该 memory 中。这里的保存了 memeory 更新成功或者失败的结果,0 表示 memory 更新成功,1 表示失败。STREX 指令是否能成功执行是和 local monitor 和 global monitor 的状态相关的。对于 Non-shareable memory(该 memory 不是多个 CPU 之间共享的,只会被一个 CPU 访问),只需要放出该 CPU 的 local monitor 这条狗就 OK 了,下面的表格可以描述这种情况。

thread 1 thread 2 local monitor的状态
    Open Access state
LDREX   Exclusive Access state
  LDREX Exclusive Access state
  Modify Exclusive Access state
  STREX Open Access state
Modify   Open Access state
STREX   在 Open Access state 的状态下,执行 STREX 指令会导致该指令执行失败
    保持 Open Access state,直到下一个 LDREX 指令

开始的时候,local monitor 处于 Open Access state 的状态,thread 1 执行 LDREX 命令后,local monitor 的状态迁移到Exclusive Access state(标记本地 CPU 对 xxx 地址进行了 LDREX 的操作)。这时候,中断发生了,在中断 handler 中,又一次执行了 LDREX ,这时候,local monitor 的状态保持不变,直到 STREX 指令成功执行,local monitor 的状态迁移到 Open Access state 的状态(清除 xxx 地址上的 LDREX 的标记)。返回 thread 1 的时候,在 Open Access state 的状态下,执行STREX 指令会导致该指令执行失败(没有 LDREX 的标记,何来 STREX),说明有其他的内核控制路径插入了。

对于 shareable memory,需要系统中所有的 local monitor 和 global monitor 共同工作,完成 exclusive access,概念类似,这里就不再赘述了。

大概的原理已经描述完毕,下面回到具体实现面。

"1:    ldrex    %0, [%3]\n"

其中 %3 就是 input operand list 中的 "r" (&v->counter),r 是限制符(constraint),用来告诉编译器 gcc,你看着办吧,你帮我选择一个通用寄存器保存该操作数吧。%0 对应 output openrand list 中的 "=&r" (result),= 表示该操作数是 write only 的,& 表示该操作数是一个 earlyclobber operand,具体是什么意思呢?编译器在处理嵌入式汇编的时候,倾向使用尽可能少的寄存器,如果 output operand 没有&修饰的话,汇编指令中的 input 和 output 操作数会使用同样一个寄存器。因此,& 确保了 %3 和 %0 使用不同的寄存器。

(5)完成步骤(4)后,%0 这个 output 操作数已经被赋值为 atomic_t 变量的 old value,毫无疑问,这里的操作是要给 old value 加上 i 。这里 %4 对应 "Ir" (i),这里 “I” 这个限制符对应 ARM 平台,表示这是一个有特定限制的立即数,该数必须是 0~255 之间的一个整数通过 rotation 的操作得到的一个 32bit 的立即数。这是和 ARM 的 data-processing instructions 如何解析立即数有关的。每个指令 32 个 bit,其中 12 个 bit 被用来表示立即数,其中 8 个 bit 是真正的数据,4 个 bit 用来表示如何 rotation。更详细的内容请参考 ARM 文档。

(6)这一步将修改后的 new value 保存在 atomic_t 变量中。是否能够正确的操作的状态标记保存在 %1 操作数中,也就是 "=&r" (tmp)。

(7)检查 memory update 的操作是否正确完成,如果OK,皆大欢喜,如果发生了问题(有其他的内核路径插入),那么需要跳转到 lable 1 那里,重新进行一次 read-modify-write 的操作。

你可能感兴趣的:(Linux内核同步机制)