Linux READ_ONCE/WRITE_ONCE宏

文章目录

  • 前言
  • 一、简介
    • 1.1 READ_ONCE
    • 1.2 WRITE_ONCE
    • 1.3 volatile 关键字
  • 二、Compiler barrier
    • 2.1 barrier
    • 2.2 READ_ONCE/ WRITE_ONCE
  • 三、总结
  • 参考资料

前言

最近在看 arm64 架构 内存页表源码部分,发现在遍历页表项的时候经常出现 READ_ONCE宏 和WRITE_ONCE宏。

READ_ONCE宏,比如判断一个内核地址是否有效:

/*
 * Check whether a kernel address is valid (derived from arch/x86/).
 */
int kern_addr_valid(unsigned long addr)
{
	pgd_t *pgdp;
	pud_t *pudp, pud;
	pmd_t *pmdp, pmd;
	pte_t *ptep, pte;

	if ((((long)addr) >> VA_BITS) != -1UL)
		return 0;

	pgdp = pgd_offset_k(addr);
	if (pgd_none(READ_ONCE(*pgdp)))
		return 0;

	pudp = pud_offset(pgdp, addr);
	pud = READ_ONCE(*pudp);
	if (pud_none(pud))
		return 0;

	if (pud_sect(pud))
		return pfn_valid(pud_pfn(pud));

	pmdp = pmd_offset(pudp, addr);
	pmd = READ_ONCE(*pmdp);
	if (pmd_none(pmd))
		return 0;

	if (pmd_sect(pmd))
		return pfn_valid(pmd_pfn(pmd));

	ptep = pte_offset_kernel(pmdp, addr);
	pte = READ_ONCE(*ptep);
	if (pte_none(pte))
		return 0;

	return pfn_valid(pte_pfn(pte));
}

这里读取pgd,pud,pmd,pte时不是直接读取,而是使用READ_ONCE宏来读取。

WRITE_ONCE宏,比如设置页表项:

static inline void set_pte(pte_t *ptep, pte_t pte)
{
	WRITE_ONCE(*ptep, pte);

	/*
	 * Only if the new pte is valid and kernel, otherwise TLB maintenance
	 * or update_mmu_cache() have the necessary barriers.
	 */
	if (pte_valid_not_user(pte)) {
		dsb(ishst);
		isb();
	}
}
static inline void set_pmd(pmd_t *pmdp, pmd_t pmd)
{
#ifdef __PAGETABLE_PMD_FOLDED
	if (in_swapper_pgdir(pmdp)) {
		set_swapper_pgd((pgd_t *)pmdp, __pgd(pmd_val(pmd)));
		return;
	}
#endif /* __PAGETABLE_PMD_FOLDED */

	WRITE_ONCE(*pmdp, pmd);

	if (pmd_valid(pmd)) {
		dsb(ishst);
		isb();
	}
}
static inline void set_pud(pud_t *pudp, pud_t pud)
{
#ifdef __PAGETABLE_PUD_FOLDED
	if (in_swapper_pgdir(pudp)) {
		set_swapper_pgd((pgd_t *)pudp, __pgd(pud_val(pud)));
		return;
	}
#endif /* __PAGETABLE_PUD_FOLDED */

	WRITE_ONCE(*pudp, pud);

	if (pud_valid(pud)) {
		dsb(ishst);
		isb();
	}
}
static inline void set_pgd(pgd_t *pgdp, pgd_t pgd)
{
	if (in_swapper_pgdir(pgdp)) {
		set_swapper_pgd(pgdp, pgd);
		return;
	}

	WRITE_ONCE(*pgdp, pgd);
	dsb(ishst);
	isb();
}

一、简介

1.1 READ_ONCE

// linux-5.4.18/include/linux/compiler.h

#define __READ_ONCE(x, check)						\
({									\
	union { typeof(x) __val; char __c[1]; } __u;			\
	if (check)							\
		__read_once_size(&(x), __u.__c, sizeof(x));		\
	else								\
		__read_once_size_nocheck(&(x), __u.__c, sizeof(x));	\
	smp_read_barrier_depends(); /* Enforce dependency ordering from x */ \
	__u.__val;							\
})
#define READ_ONCE(x) __READ_ONCE(x, 1)

(1)它使用了一个匿名联合体,通过将变量 x 的类型作为联合体成员 __val 的类型,将变量 x 的值存储在联合体中。它还定义了一个长度为1的 char 数组 __c,这是一个变长的数组,可以直接当指针使用,但是又无需占用内存,用于获取 x 的内存表示。

(2)根据参数 check 的值,__READ_ONCE 宏选择调用 __read_once_size 函数来读取 x 的值。这里 check 为真,则调用带检查的读取函数 __read_once_size:

#define __READ_ONCE_SIZE						\
({									\
	switch (size) {							\
	case 1: *(__u8 *)res = *(volatile __u8 *)p; break;		\
	case 2: *(__u16 *)res = *(volatile __u16 *)p; break;		\
	case 4: *(__u32 *)res = *(volatile __u32 *)p; break;		\
	case 8: *(__u64 *)res = *(volatile __u64 *)p; break;		\
	default:							\
		barrier();						\
		__builtin_memcpy((void *)res, (const void *)p, size);	\
		barrier();						\
	}								\
})

static __always_inline
void __read_once_size(const volatile void *p, void *res, int size)
{
	__READ_ONCE_SIZE;
}

__READ_ONCE_SIZE 宏是一个代码块,根据给定的 size 参数,使用不同的方式进行变量的读取。它使用 switch 语句根据 size 的值选择不同的情况,然后使用适当的类型进行指针转换和赋值操作来读取变量的值。对于大小为1、2、4和8字节的变量,分别使用 volatile 类型的指针来读取值,并将其赋值给 res。对于其他大小的变量,使用内置函数 __builtin_memcpy 进行内存拷贝操作。

其中 volatile 关键字的作用是指示被访问的变量可能会出现意外改变,因此编译器不应对其值进行优化或做出假设。使用 volatile 的目的是确保读取操作直接在内存位置上进行,而不依赖于任何缓存(比如寄存器缓存)或优化值。

volatile 关键字就是告诉编译器对变量的访问不做优化,因此,volatile 关键字会有一点损耗性能。

volatile 关键字在 __READ_ONCE_SIZE 宏中读取由 p 指向的变量的值时使用。它确保编译器不会对读取操作进行优化或重新排序,这样做可能会导致在多个线程或硬件设备并发共享或修改变量时出现不正确的行为。

volatile 关键字都是用于全局变量,非全局变量加volatile修饰没有意义。

通过在这个上下文中使用 volatile,代码读取操作应直接访问内存位置并每次获取最新的值,而不依赖于编译器可能做出的任何假设或优化。

使用 volatile 并不能提供并发访问的原子性或同步保证。根据具体的使用情况和与并发相关的要求,可能需要额外的同步机制,例如原子操作或锁定。

(3)smp_read_barrier_depends 在arm64架构下为空。

(4)__READ_ONCE 宏返回联合体 __u 中的 __val 成员,即变量 x 的值。

1.2 WRITE_ONCE

static __always_inline void __write_once_size(volatile void *p, void *res, int size)
{
	switch (size) {
	case 1: *(volatile __u8 *)p = *(__u8 *)res; break;
	case 2: *(volatile __u16 *)p = *(__u16 *)res; break;
	case 4: *(volatile __u32 *)p = *(__u32 *)res; break;
	case 8: *(volatile __u64 *)p = *(__u64 *)res; break;
	default:
		barrier();
		__builtin_memcpy((void *)p, (const void *)res, size);
		barrier();
	}
}
#define WRITE_ONCE(x, val) \
({							\
	union { typeof(x) __val; char __c[1]; } __u =	\
		{ .__val = (__force typeof(x)) (val) }; \
	__write_once_size(&(x), __u.__c, sizeof(x));	\
	__u.__val;					\
})

1.3 volatile 关键字

对于 volatile 关键字,这位 博主 城中之城做过总结:

volatile是多CPU之间修改变量的及时性,内存屏障是多CPU之间对变量读写的顺序性。两者不是一回事。volatile会在函数的开头把一个变量加载到寄存器,然后一直操作这个寄存器,函数末尾把寄存器的值写会内存(实际上是cache,但是不影响逻辑),如果这个函数中间执行很长或者中间被切走了,那么变量修改的值就不能及时传递到其他CPU,这样其他CPU就不会看到此次变量的修改,所以加个volatile,让它每次都读写内存,这样其他CPU就能及时看到变量的修改。

volatile会写到内存,有cache就写到cache,逻辑是一样的,因为CPU之间寄存器不是共享的,每个CPU都有自己的寄存器,默认是先改寄存器,后面某个时刻再写到内存,这样会影响变量修改传播的及时性。没有cache的时候,它的语义是写到内存,因为所有CPU内存是共享的,其他CPU就能及时看到修改,有了cache就是写到cache,因为有cache一致性协议,所以语义是不变的。

内存屏障对变量修改传播的及时性没有作用,它是让多CPU之间看到的执行顺序有一致性。

在没有cache的年代,volatile是让读写变量都直接从内存读写,不用寄存器缓存,这样做的目的是为了及时在多CPU之间传播更改,因为内存是多个CPU共享的,而寄存器是各个CPU私有的。后来有了cache,如果把cache看成透明的话,不影响理解,如果考虑cache的参与,cache在多CPU之间有一致性协议,可以及时传播更新,所以符合volatile的语义。有些人不知道volatile背后的语义,机械的理解volatile,以为volatile写到内存是为了绕过cache。

记住volatile的本质是及时在多CPU之间传播更新,就不会有误解,内存是多CPU共享的,cache有一致性协议,都可以实现及时传播更改的目的。

volatile还有一个常用的用途就是驱动中修饰硬件外设寄存器,硬件设备寄存器这个是全局的,所有的CPU都是共享的,volatile让编译器不用寄存器缓存,这是属于CPU和设备之间的及时传播更新。

二、Compiler barrier

========================
EXPLICIT KERNEL BARRIERS
========================

The Linux kernel has a variety of different barriers that act at different
levels:

  (*) Compiler barrier.

  (*) CPU memory barriers.

  (*) MMIO write barrier.

READ_ONCE() / WRITE_ONCE() 属于Compiler barrier。

2.1 barrier

Linux内核中有一个显式的编译器屏障函数barrier(),它可以防止编译器将屏障前后的内存访问指令交换位置。

这是一个通用的屏障函数,没有 read-read 或 write-write 的变体。然而,READ_ONCE() 和 WRITE_ONCE() 可以被看作是 barrier() 的弱形式,只影响由 READ_ONCE() 或 WRITE_ONCE() 标记的特定访问。

READ_ONCE() 函数表示对变量的读取操作,而 WRITE_ONCE() 函数表示对变量的写入操作。这些函数虽然不是完全的屏障函数,但它们具有类似的效果,可以防止编译器对被标记的访问进行优化或重新排序。

使用 READ_ONCE() 和 WRITE_ONCE() 函数可以确保被标记的内存访问不会被编译器重新排序或优化,从而提供了一种在并发代码中保证内存访问顺序和一致性的机制。虽然它们只影响特定的访问,但对于保证特定访问的正确性和可靠性非常有用。

barrier() 函数具有以下效果:

(1) 防止编译器重新排序屏障之后的访问,使其在屏障之前的任何访问之前。这个属性的一个例子是简化中断处理代码和被中断的代码之间的通信。

barrier() 函数在Linux内核中起到的作用是防止编译器将屏障后的内存访问指令重新排序到屏障前的指令之前。这个特性确保了内存访问的顺序按照代码中的顺序执行。

其中一个使用 barrier() 函数的示例是在中断处理器(interrupt-handler)代码和被中断的代码之间进行通信。当一个中断发生时,中断处理器代码会接管执行来处理中断。在这个过程中,它可能需要访问共享数据或与被中断的代码进行通信。

通过在适当的位置放置 barrier(),它确保中断处理器代码的内存访问不会被重新排序到被中断的代码的内存访问之前。这保证了被中断的代码的内存访问在中断处理器代码执行之前完成。

换句话说,barrier() 函数强制执行内存访问的特定顺序,防止任何可能的重新排序,从而破坏中断处理器代码和被中断的代码之间的预期通信和同步。

总的来说,barrier() 函数在维护内存访问的正确顺序和促进中断处理器代码与被中断的代码之间的通信方面起着关键作用,特别是在并发执行环境中。

(2)在循环内部,强制编译器在每次循环中加载该循环条件中使用的变量。这样做可以确保在每次循环迭代中获取变量的最新值。这对于一些需要确保在循环迭代中变量值每次都被重新加载的情况很有用。

通过使用 barrier() 函数,可以在编写并发代码时控制内存访问的顺序和正确性。它是一种同步原语,用于确保数据的一致性和可见性,特别是在多线程或中断处理的上下文中。

需要注意的是,barrier() 函数只提供编译器层面的屏障效果,而不提供硬件层面的原子性或同步保证。在需要更强的同步保证时,可能需要使用其他同步原语,如原子操作或锁定。

综上所述,barrier() 函数在Linux内核中用于防止编译器重排内存访问,并影响到后续的访问顺序。它在并发代码中起到重要的作用,确保数据的可见性和一致性。

2.2 READ_ONCE/ WRITE_ONCE

READ_ONCE() 和 WRITE_ONCE() 函数可以防止一些在单线程代码中完全安全的优化但是在并发代码中可能导致问题的情况,以下是这些优化类型的一些示例:
(1)
编译器有权对同一变量的加载和存储进行重新排序,而在某些情况下,CPU也有权对同一变量的加载进行重新排序。这意味着以下代码:

a[0] = x;
a[1] = x;

编译器可能对同一变量的加载进行重新排序变为:

a[1] = x;
a[0] = x;

可能会导致在 a[1] 中存储了一个比 a[0] 中更旧的 x 的值。

为了防止编译器和CPU执行这种重新排序,可以使用以下方式:

a[0] = READ_ONCE(x);
a[1] = READ_ONCE(x);

简而言之,READ_ONCE() 和 WRITE_ONCE() 函数为从多个CPU对单个变量的访问提供了缓存一致性(cache coherence)。

通过使用 READ_ONCE() 函数,可以确保在加载变量 x 时,编译器不会对其进行重新排序,并且可以获取到最新的值。这样可以保证 a[0] 和 a[1] 中存储的值是相同且最新的。

类似地,使用 WRITE_ONCE() 函数可以确保对变量 x 的写入操作不会被重新排序,从而保证了对变量的写入操作的顺序和一致性。

READ_ONCE() 和 WRITE_ONCE() 函数为多个CPU对单个变量的访问提供了缓存一致性,防止了编译器和CPU对变量访问的重新排序,确保了访问的正确性和一致性。

(2)
编译器有权将连续的对同一变量的加载操作进行合并。这种合并可能会导致编译器对以下代码进行"优化":

while (tmp = a)
	do_something_with(tmp);

将其转换为以下代码,尽管从某种意义上说对于单线程代码来说是合法的,但几乎肯定不是开发者的意图:

对同一变量a的加载操作进行合并:

if (tmp = a)
	for (;;)
		do_something_with(tmp);

为了防止编译器对代码进行合并,可以使用 READ_ONCE() 函数:

while (tmp = READ_ONCE(a))
	do_something_with(tmp);

通过使用 READ_ONCE() 函数,可以告诉编译器在每次循环迭代中重新加载变量 a 的值,阻止了编译器对连续加载操作的合并。这样可以确保每次迭代都获取到变量的最新值,避免出现意外的行为。

总的来说,使用 READ_ONCE() 函数可以防止编译器对连续加载操作进行合并,确保代码的预期行为,尤其在并发代码中非常重要。

(3)
编译器有权在它知道变量的值时完全省略加载操作。例如,如果编译器可以证明变量 a 的值始终为零,它可以优化以下代码:

while (tmp = a)
	do_something_with(tmp);

转换为:

do { } while (0);

这种转换对于单线程代码是有益的,因为它消除了加载操作和分支。问题在于,编译器将根据其假设当前的CPU是唯一更新变量 a 的CPU来执行证明。如果变量 a 是共享的,那么编译器的证明将是错误的。使用 READ_ONCE() 函数告诉编译器它并不像自以为的那样了解情况:

while (tmp = READ_ONCE(a))
	do_something_with(tmp);

但请注意,编译器也会密切关注您在 READ_ONCE() 之后对值的处理方式。例如,假设您执行以下操作,其中 MAX 是一个值为 1 的预处理宏:

while ((tmp = READ_ONCE(a)) % MAX)
	do_something_with(tmp);

那么编译器知道对 MAX 应用 “%” 运算符的结果始终为零,这再次允许编译器将代码优化成非常简单的形式。(它仍然会从变量 a 中加载值。)

因此,虽然使用 READ_ONCE() 可以告诉编译器不要进行值的假设,但编译器仍会根据代码中的操作对其进行优化。您需要仔细考虑代码中对值的使用,以确保编译器不会对代码进行过度优化。

(4)
如果编译器知道变量已经具有要存储的值,它有权完全省略存储操作。同样,编译器假设当前的CPU是唯一一个对变量进行存储的CPU,这可能导致编译器在处理共享变量时出错。例如,假设您有以下代码:

a = 0;
... 不对变量 a 进行存储的代码 ...
a = 0;

编译器看到变量 a 的值已经是零,因此它可能会省略第二个存储操作。如果在此期间其他CPU可能对变量 a 进行了存储,这将导致严重问题。

使用 WRITE_ONCE() 函数可以防止编译器做出这种错误的推测:

WRITE_ONCE(a, 0);
... 不对变量 a 进行存储的代码 ...
WRITE_ONCE(a, 0);

(5)
编译器在未被告知的情况下可以重新排序内存访问。例如,考虑进程级代码和中断处理程序之间的以下交互:

void process_level(void)
{
	msg = get_message();
	flag = true;
}

void interrupt_handler(void)
{
	if (flag)
		process_message(msg);
}

没有任何机制可以阻止编译器将 process_level() 转换为以下形式,实际上,这对于单线程代码可能是有效的:

void process_level(void)
{
	flag = true;
	msg = get_message();
}

}
如果中断发生在这两个语句之间,那么 interrupt_handler() 可能会接收到一个损坏的 msg。使用 WRITE_ONCE() 可以防止这种情况发生,如下所示:

void process_level(void)
{
	WRITE_ONCE(msg, get_message());
	WRITE_ONCE(flag, true);
}

void interrupt_handler(void)
{
	if (READ_ONCE(flag))
		process_message(READ_ONCE(msg));
}

请注意,interrupt_handler() 中的 READ_ONCE() 和 WRITE_ONCE() 封装是必需的,如果该中断处理程序本身可能被其他访问 ‘flag’ 和 ‘msg’ 的东西中断,例如嵌套中断或非屏蔽中断(NMI)。否则,除了用于文档目的之外,interrupt_handler() 中不需要使用 READ_ONCE() 和 WRITE_ONCE()。(还要注意,在现代Linux内核中,通常不会发生嵌套中断,实际上,如果中断处理程序在启用中断的情况下返回,将会出现 WARN_ONCE() 错误。)

应该假设编译器可以将 READ_ONCE() 和 WRITE_ONCE() 移动到不包含 READ_ONCE()、WRITE_ONCE()、barrier() 或类似原子操作的代码之后。

这种效果也可以通过使用 barrier() 实现,但是 READ_ONCE() 和 WRITE_ONCE() 更加精确:使用 READ_ONCE() 和 WRITE_ONCE(),编译器只需忘记指定内存位置的内容,而使用 barrier(),编译器必须丢弃所有当前缓存在任何机器寄存器中的内存位置的值。当然,编译器还必须遵守 READ_ONCE() 和 WRITE_ONCE() 出现的顺序,尽管CPU当然不必这样做。

(6)
编译器有权对变量进行存储的虚构,就像以下示例中所示:

if (a)
	b = a;
else
	b = 42;

编译器可以通过进行如下优化来节省一个分支:

b = 42;
if (a)
	b = a;

在单线程代码中,这不仅安全,而且还可以节省一个分支。不幸的是,在并发代码中,这种优化可能导致其他CPU在加载变量 b 时看到一个虚假的值42,即使变量 a 从未为零。使用 WRITE_ONCE() 可以防止这种情况发生,如下所示:

if (a)
	WRITE_ONCE(b, a);
else
	WRITE_ONCE(b, 42);

编译器也可以虚构加载操作。这通常会造成较少的破坏,但可能导致缓存行反弹,从而导致性能和可扩展性下降。使用 READ_ONCE() 可以防止虚构的加载操作,如下所示:

value = READ_ONCE(variable);

通过使用 READ_ONCE(),可以告诉编译器不要对加载操作进行虚构,确保加载的值是准确和一致的。这有助于避免不必要的缓存行反弹,并提高性能和可扩展性。

(7)
对于对齐的内存位置,其大小允许使用单个内存引用指令访问的情况,可以防止"load tearing"
和"store tearing。其中一个大的访问被多个较小的访问所替代。例如,对于具有带有7位立即字段的16位存储指令的体系结构,编译器可能会诱使使用两个16位存储立即指令来实现以下32位存储:

p = 0x00010002;

请注意,GCC确实使用这种类型的优化,这并不奇怪,因为构建常量然后将其存储可能需要超过两个指令。因此,在单线程代码中,这种优化可能是有效的。实际上,最近的一个错误(已经修复)导致GCC在一个volatile存储中错误地使用了这种优化。在没有这类错误的情况下,使用WRITE_ONCE()可以防止存储撕裂,如下所示:

WRITE_ONCE(p, 0x00010002);

使用紧凑结构(packed structures)也可能导致加载和存储撕裂,就像以下示例一样:

struct __attribute__((__packed__)) foo {
	short a;
	int b;
	short c;
};
struct foo foo1, foo2;
...

foo2.a = foo1.a;
foo2.b = foo1.b;
foo2.c = foo1.c;

因为没有READ_ONCE()或WRITE_ONCE()包装和没有volatile标记,编译器完全有权将这三个赋值语句实现为一对32位加载,后跟一对32位存储。这将导致’foo1.b’上的加载tearing和’foo2.b’上的存储tearing。在这个示例中,再次使用READ_ONCE()和WRITE_ONCE()可以防止tearing:

foo2.a = foo1.a;
WRITE_ONCE(foo2.b, READ_ONCE(foo1.b));
foo2.c = foo1.c;

对于已标记为volatile的变量,使用READ_ONCE()和WRITE_ONCE()是不必要的,因为它们已经具有了volatile的语义。例如,因为“jiffies”被标记为volatile,所以从来没有必要说READ_ONCE(jiffies)。READ_ONCE()和WRITE_ONCE()实际上是通过volatile转换实现的,当其参数已经标记为volatile时,volatile转换不会产生任何效果。

三、总结

编译器屏障(如READ_ONCE()和WRITE_ONCE())主要影响编译器及其优化策略的行为。它们对生成的代码施加了一定的顺序约束,防止编译器在屏障之间重新排序操作。

然而,这些屏障对CPU的执行和指令重新排序的潜力没有直接影响。CPU仍然可以进行自己的优化,包括指令重排和乱序执行,只要它保持编程语言和内存模型定义的可观察行为即可。

为了确保在CPU级别上操作的正确同步和顺序,可能需要使用额外的同步机制,如内存屏障或原子操作。这些机制向CPU提供了显式指令,以强制执行特定的顺序约束和同步语义。

在编写并发或底层代码时,考虑到编译器优化和CPU执行之间的相互作用非常重要。应该使用适当的同步技术,结合合适的编译器屏障和内存屏障,以实现所需的行为,避免数据竞争和意外的重排序等问题。

Compiler barrier 和 CPU memory barriers 是不相同的两个概念。

参考资料

Linux 5.4.18
Documentation/memory-barriers.txt

https://aijishu.com/a/1060000000350623
https://zhuanlan.zhihu.com/p/102753962

你可能感兴趣的:(Linux,内核常用API,Linux内核杂谈,linux,c语言,系统安全)