原子操作(atomic operation)

深入分析Volatile的实现原理


引言

在多线程并发编程中synchronized和Volatile都扮演着重要的角色,Volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程改动一个共享变量时,另外一个线程能读到这个改动的值。

它在某些情况下比synchronized的开销更小。本文将深入分析在硬件层面上Inter处理器是怎样实现Volatile的,通过深入分析能帮助我们正确的使用Volatile变量。

术语定义

术语

英文单词

描写叙述

共享变量

 

在多个线程之间可以被共享的变量被称为共享变量。共享变量包含全部的实例变量。静态变量和数组元素。

他们都被存放在堆内存中。Volatile仅仅作用于共享变量。

内存屏障

Memory Barriers

是一组处理器指令,用于实现对内存操作的顺序限制。

缓冲行

Cache line

缓存中能够分配的最小存储单位。处理器填写缓存线时会载入整个缓存线,须要使用多个主内存读周期。

原子操作

Atomic operations

不可中断的一个或一系列操作。

缓存行填充

cache line fill

当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或全部)

缓存命中

cache hit

假设进行快速缓存行填充操作的内存位置仍然是下次处理器訪问的地址时,处理器从缓存中读取操作数。而不是从内存。

写命中

write hit

当处理器将操作数写回到一个内存缓存的区域时。它首先会检查这个缓存的内存地址是否在缓存行中,假设存在一个有效的缓存行,则处理器将这个操作数写回到缓存。而不是写回到内存,这个操作被称为写命中。

写缺失

write misses the cache

一个有效的缓存行被写入到不存在的内存区域。

Volatile的官方定义

Java语言规范第三版中对volatile的定义例如以下: java编程语言同意线程訪问共享变量,为了确保共享变量能被准确和一致的更新。线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。

假设一个字段被声明成volatile。java线程内存模型确保全部线程看到这个变量的值是一致的。

为什么要使用Volatile

Volatile变量修饰符假设使用恰当的话。它比synchronized的使用和运行成本会更低,由于它不会引起线程上下文的切换和调度。

Volatile的实现原理

那么Volatile是怎样来保证可见性的呢?在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。

Java代码:

instance = new Singleton();//instance是volatile变量

汇编代码:

0x01a3de1d: movb $0x0,0x1104800(%esi);

0x01a3de24: lock addl $0x0,(%esp);

有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码。通过查IA-32架构软件开发人员手冊可知,lock前缀的指令在多核处理器下会引发了两件事情。

  • 将当前处理器缓存行的数据会写回到系统内存。

  • 这个写回内存的操作会引起在其它CPU里缓存了该内存地址的数据无效。

处理器为了提高处理速度,不直接和内存进行通讯。而是先将系统内存的数据读到内部缓存(L1,L2或其它)后再进行操作,但操作完之后不知道何时会写到内存。假设对声明了Volatile变量进行写操作。JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

可是就算写回到内存,假设其它处理器缓存的值还是旧的。再运行计算操作就会有问题。所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每一个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。当处理器发现自己缓存行相应的内存地址被改动,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行改动操作的时候,会强制又一次从系统内存里把数据读到处理器缓存里。

这两件事情在IA-32软件开发人员架构手冊的第三冊的多处理器管理章节(第八章)中有具体阐述。

Lock前缀指令会引起处理器缓存回写到内存。Lock前缀指令导致在运行指令期间,声言处理器的 LOCK# 信号。

在多处理器环境中,LOCK# 信号确保在声言该信号期间,处理器能够独占使用不论什么共享内存。(由于它会锁住总线,导致其它CPU不能訪问总线。不能訪问总线就意味着不能訪问系统内存),可是在近期的处理器里。LOCK#信号一般不锁总线。而是锁缓存,毕竟锁总线开销比較大。

在8.1.4章节有具体说明锁定操作对处理器缓存的影响。对于Intel486和Pentium处理器,在锁操作时。总是在总线上声言LOCK#信号。但在P6和近期的处理器中。假设訪问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保改动的原子性。此操作被称为“缓存锁定”,缓存一致性机制会阻止同一时候改动被两个以上处理器缓存的内存区域数据

一个处理器的缓存回写到内存会导致其它处理器的缓存无效

IA-32处理器和Intel 64处理器使用MESI(改动,独占,共享,无效)控制协议去维护内部缓存和其它处理器缓存的一致性。在多核处理器系统中进行操作的时候,IA-32 和Intel 64处理器能嗅探其它处理器訪问系统内存和它们的内部缓存。它们使用嗅探技术保证它的内部缓存。系统内存和其它处理器的缓存的数据在总线上保持一致。

比如在Pentium和P6 family处理器中,假设通过嗅探一个处理器来检測其它处理器打算写内存地址,而这个地址当前处理共享状态。那么正在嗅探的处理器将无效它的缓存行,在下次訪问同样内存地址时,强制运行缓存行填充。

Volatile的使用优化

著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类LinkedTransferQueue。他在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

追加字节能优化性能?这样的方式看起来非常奇妙。但假设深入理解处理器架构就能理解当中的奥秘。让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference仅仅做了一件事情,就将共享变量追加到64字节。我们能够来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量。一共64个字节。

/** head of the queue */
private transient final PaddedAtomicReference < QNode > head;

/** tail of the queue */

private transient final PaddedAtomicReference < QNode > tail;


static final class PaddedAtomicReference < T > extends AtomicReference < T > {

    // enough padding for 64bytes with 4byte refs 
    Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;

    PaddedAtomicReference(T r) {

        super(r);

    }

}

public class AtomicReference < V > implements java.io.Serializable {

    private volatile V value;

    //省略其它代码 }

为什么追加64字节可以提高并发编程的效率呢? 由于对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的快速缓存行是64个字节宽。不支持部分填充缓存行,这意味着假设队列的头节点和尾节点都不足64字节的话。处理器会将它们都读到同一个快速缓存行中。在多处理器下每一个处理器都会缓存相同的头尾节点,当一个处理器试图改动头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下。会导致其它处理器不能訪问自己快速缓存中的尾节点,而队列的入队和出队操作是须要不停改动头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。

Doug lea使用追加到64字节的方式来填满快速缓冲区的缓存行。避免头接点和尾节点载入到同一个缓存行,使得头尾节点在改动时不会互相锁定。

那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。

在两种场景下不应该使用这样的方式。

第一:缓存行非64字节宽的处理器。如P6系列和奔腾处理器。它们的L1和L2快速缓存行是32个字节宽。第二:共享变量不会被频繁的写。由于使用追加字节的方式须要处理器读取很多其它的字节到快速缓冲区,这本身就会带来一定的性能消耗,共享变量假设不被频繁写的话,锁的几率也很小。就不是必需通过追加字节的方式来避免相互锁定。



原子操作的实现原理



1. 引言

原子(atom)本意是“不能被进一步切割的最小粒子”。而原子操作(atomic operation)意为"不可被中断的一个或一系列操作" 。在多处理器上实现原子操作就变得有点复杂。本文让我们一起来聊一聊在Intel处理器和Java里是怎样实现原子操作的。


2. 术语定义


术语 英文 解释
缓存行 Cache line 缓存的最小操作单位
比較并交换 Compare and Swap CAS操作须要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比較下旧值有没有发生变化。假设没有发生变化。才交换成新值。发生了变化则不交换。
CPU流水线 CPU pipeline CPU流水线的工作方式就象工业生产上的装配流水线,在CPU中由5~6个不同功能的电路单元组成一条指令处理流水线。然后将一条X86指令分成5~6步后再由这些电路单元分别运行,这样就能实如今一个CPU时钟周期完毕一条指令。因此提高CPU的运算速度。

内存顺序冲突 Memory order violation 内存顺序冲突通常是由假共享引起,假共享是指多个CPU同一时候改动同一个缓存行的不同部分而引起当中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线。

3. 处理器怎样实现原子操作

32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。

3.1 处理器自己主动保证基本内存操作的原子性

首先处理器会自己主动保证主要的内存操作的原子性。

处理器保证从系统内存其中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其它处理器不能訪问这个字节的内存地址。

奔腾6和最新的处理器能自己主动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,可是复杂的内存操作处理器不能自己主动保证其原子性,比方跨总线宽度,跨多个缓存行。跨页表的訪问。

可是处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

3.2 使用总线锁保证原子性

第一个机制是通过总线锁保证原子性。假设多个处理器同一时候对共享变量进行读改写(i++就是经典的读改写操作)操作,那么共享变量就会被多个处理器同一时候进行操作,这样读改写操作就不是原子的。操作完之后共享变量的值会和期望的不一致,举个样例:假设i=1,我们进行两次i++操作,我们期望的结果是3,可是有可能结果是2。

例如以下图

(例1)

原因是有可能多个处理器同一时候从各自的缓存中读取变量i。分别进行加一操作,然后分别写入系统内存其中。

那么想要保证读改写共享变量的操作是原子的。就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁就是来解决问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其它处理器的请求将被堵塞住,那么该处理器能够独占使用共享内存。

3.3 使用缓存锁保证原子性

第二个机制是通过缓存锁定保证原子性。

在同一时刻我们仅仅需保证对某个内存地址的操作是原子性就可以,但总线锁定把CPU和内存之间通信锁住了,这使得锁定期间,其它处理器不能操作其它内存地址的数据,所以总线锁定的开销比較大,近期的处理器在某些场合下使用缓存锁定取代总线锁定来进行优化。

频繁使用的内存会缓存在处理器的L1,L2和L3快速缓存里,那么原子操作就能够直接在处理器内部缓存中进行,并不须要声明总线锁。在奔腾6和近期的处理器中能够使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”就是假设缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它运行锁操作回写内存时,处理器不在总线上声言LOCK#信号。而是改动内部的内存地址。并同意它的缓存一致性机制来保证操作的原子性。由于缓存一致性机制会阻止同一时候改动被两个以上处理器缓存的内存区域数据,当其它处理器回写已被锁定的缓存行的数据时会起缓存行无效,在例1中,当CPU1改动缓存行中的i时使用缓存锁定,那么CPU2就不能同一时候缓存了i的缓存行。

可是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。

另外一种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

以上两个机制我们能够通过Inter处理器提供了非常多LOCK前缀的指令来实现。比方位測试和改动指令BTS,BTR,BTC。交换指令XADD,CMPXCHG和其它一些操作数和逻辑指令,比方ADD(加)。OR(或)等。被这些指令操作的内存区域就会加锁。导致其它处理器不能同一时候訪问它。

4. JAVA怎样实现原子操作

在java中能够通过锁和循环CAS的方式来实现原子操作。

4.1 使用循环CAS实现原子操作

JVM中的CAS操作正是利用了上一节中提到的处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,下面代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。

   
public class Counter {
    private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;
    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> ts = new ArrayList<Thread>(600);
        long start = System.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }

        for (Thread t : ts) {
            t.start();
        }
       // 等待全部线程运行完毕
        for (Thread t : ts) {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);

    }

    /**
     * 使用CAS实现线程安全计数器
     */
    private void safeCount() {
        for (;;) {
            int i = atomicI.get();
            boolean suc = atomicI.compareAndSet(i, ++i);
            if (suc) {
                break;
            }
        }
    }
    /**
     * 非线程安全计数器
     */
    private void count() {
        i++;
    }
}

在java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比方LinkedTransferQueue类的Xfer方法。CAS尽管非常高效的解决原子操作,可是CAS仍然存在三大问题。ABA问题。循环时间长开销大和仅仅能保证一个共享变量的原子操作。

  1. ABA问题。由于CAS须要在操作值的时候检查下值有没有发生变化,假设没有发生变化则更新。可是假设一个值原来是A。变成了B,又变成了A。那么使用CAS进行检查时会发现它的值没有发生变化,可是实际上却变化了。ABA问题的解决思路就是使用版本。

    在变量前面追加上版本,每次变量更新的时候把版本加一。那么A-B-A 就会变成1A-2B-3A。


    从Java1.5開始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。

    这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,而且当前标志是否等于预期标志,假设所有相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

    public boolean compareAndSet
            (V      expectedReference,//预期引用
             V      newReference,//更新后的引用
            int    expectedStamp, //预期标志
            int    newStamp) //更新后的标志
    
  2. 循环时间长开销大。自旋CAS假设长时间不成功。会给CPU带来很大的运行开销。假设JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用。第一它能够延迟流水线运行指令(de-pipeline),使CPU不会消耗过多的运行资源,延迟的时间取决于详细实现的版本号,在一些处理器上延迟时间是零。

    第二它能够避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush)。从而提高CPU的运行效率。

  3. 仅仅能保证一个共享变量的原子操作。当对一个共享变量运行操作时,我们能够使用循环CAS的方式来保证原子操作,可是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就能够用锁,或者有一个取巧的办法。就是把多个共享变量合并成一个共享变量来操作。比方有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java1.5開始JDK提供了AtomicReference类来保证引用对象之间的原子性,你能够把多个变量放在一个对象里来进行CAS操作。

4.2 使用锁机制实现原子操作

锁机制保证了仅仅有获得锁的线程可以操作锁定的内存区域。JVM内部实现了非常多种锁机制,有偏向锁。轻量级锁和相互排斥锁,有意思的是除了偏向锁,JVM实现锁的方式都用到的循环CAS。当一个线程想进入同步块的时候使用循环CAS的方式来获取锁。当它退出同步块的时候使用循环CAS释放锁。

具体说明可以參见文章Java SE1.6中的Synchronized。

5. 參考资料

  1. Java SE1.6中的Synchronized
  2. Intel 64和IA-32架构软件开发者手冊
  3. 深入分析Volatile的实现原理

作者介绍

方腾飞,花名清英,淘宝资深开发project师,关注并发编程,眼下在广告技术部从事无线广告联盟的开发和设计工作。个人博客:http://ifeve.com 微博:http://weibo.com/kirals 欢迎通过我的微博进行技术交流。

http://www.infoq.com/cn/articles/atomic-operation


Linux 原子操作

所谓原子操作,就是该操作绝不会在运行完成前被不论什么其它任务或事件打断,也就说。它的最小的运行单位,不可能有比它更小的运行单位,因此这里的原子实际是使用了物理学里的物质微粒的概念。

 

  原子操作须要硬件的支持,因此是架构相关的。其API和原子类型的定义都定义在内核源代码树的include/asm/atomic.h文件里,它们都使用汇编语言实现,由于C语言并不能实现这种操作。

  原子操作主要用于实现资源计数。非常多引用计数(refcnt)就是通过原子操作实现的。原子类型定义例如以下:

 

typedef struct  {  volatile int counter;  }  atomic_t;


  volatile修饰字段告诉gcc不要对该类型的数据做优化处理,对它的訪问都是对内存的訪问,而不是对寄存器的訪问。

 

  原子操作API包含:

atomic_read(atomic_t * v);


  该函数对原子类型的变量进行原子读操作。它返回原子类型的变量v的值。

 

 

atomic_set(atomic_t * v, int i);


  该函数设置原子类型的变量v的值为i。

 

 

void atomic_add(int i, atomic_t *v);


  该函数给原子类型的变量v添加值i。

 

 

atomic_sub(int i, atomic_t *v);


  该函数从原子类型的变量v中减去i。

 

 

int atomic_sub_and_test(int i, atomic_t *v);


  该函数从原子类型的变量v中减去i,并推断结果是否为0,假设为0。返回真,否则返回假。

 

 

void atomic_inc(atomic_t *v);


  该函数对原子类型变量v原子地添加1。

 

 

void atomic_dec(atomic_t *v);


  该函数对原子类型的变量v原子地减1。

 

 

int atomic_dec_and_test(atomic_t *v);


  该函数对原子类型的变量v原子地减1。并推断结果是否为0,假设为0,返回真。否则返回假。

 

 

int atomic_inc_and_test(atomic_t *v);


  该函数对原子类型的变量v原子地添加1。并推断结果是否为0,假设为0。返回真,否则返回假。

 

 

int atomic_add_negative(int i, atomic_t *v);


  该函数对原子类型的变量v原子地添加I。并推断结果是否为负数。假设是,返回真。否则返回假。

 

 

int atomic_add_return(int i, atomic_t *v);


  该函数对原子类型的变量v原子地添加i,而且返回指向v的指针。

 

 

int atomic_sub_return(int i, atomic_t *v);


  该函数从原子类型的变量v中减去i,而且返回指向v的指针。

 

 

int atomic_inc_return(atomic_t * v);


  该函数对原子类型的变量v原子地添加1而且返回指向v的指针。

 

 

int atomic_dec_return(atomic_t * v);

  该函数对原子类型的变量v原子地减1而且返回指向v的指针。

  原子操作通经常使用于实现资源的引用计数,在TCP/IP协议栈的IP碎片处理中。就使用了引用计数,碎片队列结构struct ipq描写叙述了一个IP碎片。字段refcnt就是引用计数器,它的类型为atomic_t。当创建IP碎片时(在函数ip_frag_create中)。使用atomic_set函数把它设置为1。当引用该IP碎片时,就使用函数atomic_inc把引用计数加1。

  当不须要引用该IP碎片时,就使用函数ipq_put来释放该IP碎片,ipq_put使用函数atomic_dec_and_test把引用计数减1并推断引用计数是否为0,假设是就释放IP碎片。

函数ipq_kill把IP碎片从ipq队列中删除。并把该删除的IP碎片的引用计数减1(通过使用函数atomic_dec实现)。


原子操作函数原型


 原子操作仅运行一次。在运行过程中不会中断也不会休眠;是最小的运行单元。鉴于原子操作这些特性。能够利用它来解决竞态问题。
 往后其它同步机制都是在原子操作的基础上进行扩展的。


 原子操作有整型原子操作、64位原子操作以及位原子操作。



1 整型原子操作
(Atomic Integer Operations)
 要使用原子操作,须要定义一个原子变量,然后使用内核提供的接口对其进行原子操作。
 整型原子变量结构例如以下

  1. #include <linux/type.h>
  2. typedef struct {
  3.     int counter;
  4. } atomic_t;
  能够看出整型原子变量实质上是一个32位整型变量。


 整型原子变量操作接口。事实上现方式与详细的架构有关。

  1. #include <asm/atomic.h>
  2. ATOMIC_INIT(int i)                           // 定义原子变量时。将其值赋为i
  3. int atomic_read(atomic_t *v)                 // 读v的值
  4. void atomic_set(atomic_t *v, int i)          // 设置v的值为i
  5. void atomic_add(int i, atomic *v)            // v的值添加i
  6. void atomic_sub(int i, atomic *v)            // v的值降低i
  7. void atomic_inc(atomic *v)                   // v的值加1
  8. void atomic_dec(atomic *v)                   // v的值减1
  9. int atomic_sub_and_test(int i, atomic_t *v)  // v的值降低i,且结果为0时返回true
  10. int atomic_add_negative(int i, atomic_t *v)  // v的值添加i,且结果为负数时返回true
  11. int atomic_add_return(int i, atomic_t *v)    // v的值添加i,且返回结果
  12. int atomic_sub_return(int i, atomic_t *v)    // v的值降低i,且返回结果
  13. int atomic_inc_return(atomic_t *v)           // v的值加1。且返回结果
  14. int atomic_dec_return(atomic_t *v)           // v的值减1。且返回结果
  15. int atomic_dec_and_test(atomic_t *v)         // v的值减1,且结果为0时返回true
  16. int atomic_inc_and_test(atomic_t *v)         // v的值加1,且结果为0时返回true

2 64位原子操作(64-Bit Atomic Operations)
 64位原子变量结构
  1. typedef struct {
  2.     u64 __aligned(8) counter;
  3. } atomic64_t;
  64位原子变量操作接口与整型变量操作接口类似。仅仅要将整型变量接口名称的"atomic"改成"atomic64"就可以。

3 位原子操作(Atomic Bitwise Operations)
 位原子操作接口
  1. #include <asm/bitops.h>
  2. void set_bit(int nr, void *addr)           // 将addr第nr位置1
  3. void clear_bit(int nr, void *addr)         // 将addr第nr位置0
  4. void change_bit(int nr, void *addr)        // 将addr第nr位值取反
  5. int test_and_set_bit(int nr, void *addr)   // 将addr第nr位置1,并将该位之前值返回
  6. int test_and_clear_bit(int nr, void *addr) // 将addr第nr位置0。并将该位之前值返回
  7. int test_and_change_bit(int nr, void *addr)// 将addr第nr位取反。并将该位之前值返回
  8. int test_bit(int nr, void *addr)           // 将addr文章nr位值返回

版权声明:本文博主原创文章。博客,未经同意不得转载。

原文地址:https://www.cnblogs.com/bhlsheji/p/4877072.html