2024年4月

【版权声明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权)
https://www.cnblogs.com/cnb-yuchen/p/18107586
出自【
进步*于辰的博客

注:为了阐述更加严谨,本篇文章中将使用一些二进制的相关概念,出自上篇博文。

前言

在解析
Float类
的源码时,我对
MAX_VALUE/MIN_VALUE
的值很好奇,它们是怎么来的?于是利用我所知的二进制知识,尝试运算。一开工就发现没辙,因为我压根不知道小数的二进制是怎样表示、又是如何存储的。于是寻得一方案:

启发博文:
浮点数(小数)在计算机中如何用二进制存储?
(转发)。

这位博主的阐述专业且详细,下面我通过个人理解,尽量简明扼要地为大家阐明这个知识点。

正文

在开始之前,大家先看一张图。
在这里插入图片描述
Float 就是单精度,就是说 Float 变量由
32
位(4字节)二进制表示。现在大家对这张图有所疑惑,无妨,我要表达的意思是,小数的二进制由三部分组成,与整数完全不同。换言之,给我们一组小数的二进制,我们无法直接看出它的
真值
是多少。因此,需要使用一种纸面上的二进制数表现形式。

如何运算出这种纸面上的二进制数表现形式?
所谓纸面上,就是一目了然,也就是使用整数二进制的表现形式去表现小数。那位博主是这样说的:

二进制转换为十进制的方法就是各个位的数字与位权乘积之和。

什么是“位权”?这张图给了答案。
在这里插入图片描述
就是
底数的指数幂

答案很清楚了,可是如何运算浮点数的小数部分的二进制,难道使用如上“求和”的方法?当然可以,不过不太方便。

从另一位博主那儿“取经”得一方法:使用上图示例。整数部分照旧,是
1011
,将小数部分
0.1875
进行以下运算:

0.1875 * 2 = 0.3750 → 0
0.3750 * 2 = 0.7500 → 0
0.7500 * 2 = 1.5000 → 1
0.5000 * 2 = 1.0000 → 1


0011

结论:

将小数部分乘以
2
,取结果的整数部分,如此反复,直至结果为
0
,最后依次得到的整数部分就是小数部分的二进制。

PS:暂不懂其中原理,就挺好用。

补充一点
:那位博主将
100
个 float 类型的
0.1
相加,最终结果不是
10.0

在这里插入图片描述
大家便可明了,无论二选一,
0.1
都是无限小数。无论单双精度,都无法表示完全,必然有所缺失或增加(四舍五入),故是
10.000002

成功了一半,可
1011.0011
只是
11.1875
在纸面上的二进制数表现形式。

浮点数(小数)在计算机中如何用二进制存储?
回到第一张图,小数的二进制由符号、指数、尾数三部分组成,这说明必然有一个公式,将这三部分进行运算,从而得到“真值”。

公式是这样的:
在这里插入图片描述
二进制中基数(又称“底数”)是
2
,自然不必考虑。小数内部构造的三部分正好与图中三个未知变量对应,下面我一一剖析。(以单精度为例)

符号部分占
1
位,即
0/1
。(PS:小数没有“补码”之说)

指数部分(
8
位)与尾数部分(
23
位)又是如何表示小数的?

我们来探讨一下,看到
m * n
e

这样的公式,给你
11.1875
这个小数,你能想到哪些等式?

11.1875 = 666666.875 * 10
-1
,m是
666666.875
,n是
10
,e是
-1
11.1875 = 1.11875 * 10
1
, m是
1.11875
,n是
10
,e是
1
......

有问题么?这里是二进制,n 是
2
,不是
10
,故等式不能这么写。

可是要满足如下等式,m 是多少?

11.1875 = m * 2
-1
11.1875 = m * 2
1
......

看到这样的等式,大家是否似曾相识?没错,位运算,也就是这样:

11.1875 = m * 2
-1
= m >> 1
11.1875 = m * 2
1
= m << 1
......

明白了么?

可这里有个问题,因为位运算移动的位数
e
是任意的,故
m
任意,则必然存在一个规范,用于限制
e
的值。

规范定义:

尾数部分用的是“将小数点前面的值固定为
1
的正则表达式”。
什么是正则表达式?按照特定的规则来表示数据的形式的表达式。

这样就清楚了,规范就是“
将小数点左边第一位固定为
1
,其他为
0

”。如此,
e
就只有一个值。

PS:不过,对于那位博主将规范定义为“正则表达式”这一点,我的个人看法是,意思没错,可用词似乎不恰当,当时我就被误导了。当然,也可能是我的功底不扎实。

规范知道了,可尾数
m
是多少呢?大家在上文的阅读中有没有注意到一个细节?就是“
纸面上的二进制数表现形式
”那儿,我最后说了一句:“成功了一半”。成功在哪?又何出此言?

其实,
小数在纸面上的二进制数表现形式就是
m * 2
e

的结果
。以
11.1875
为例:

11.1875 → 1011.0011

规范是“将小数点左边第一位固定为
1
,其他为
0
”,就是这样:

11.1875

1011.0011
=
1.0110011
<< 3 =
1.0110011
* 2
3

这样,难道 m 是
1.0110011
?当然不是,那位博主已阐明:
在这里插入图片描述

因此,m 是
01100110000000000000000
。e 是
3

对应到小数的内部构造,
11.1875
的二进制是:

0 00000011 01100110000000000000000

这是正确答案吗?还不是。

运用以上方法,我们来计算一下
0.1875
的二进制:

0.0011						原始数值
1.1							左移使个位为 1
1.10000000000000000000000	确保小数点后有23位
10000000000000000000000		仅保留小数点后的部分

得出,m 是
10000000000000000000000
,e 是
-3

因此,
0.1875
的二进制是
0 10000011 10000000000000000000000
。(指数
e
使用的是“原码”,不是“补码”)

这样看来,似乎没有问题,可实际上指数部分还有点“门道”。

那位博主阐述道:

指数部分使用了“
EXCESS系统表现
”。

什么是“EXCESS系统表现”?那位博主已阐述得很清楚,我就不赘述了。

总结:

  • 11.1875
    的二进制是
    0 10000010 01100110000000000000000
  • 0.1875
    的二进制是
    0 06666661100 10000000000000000000000

PS:

  1. 如果采用双精度,同理,只是二进制位数增加了而已。
  2. 那位博主运用 c++ 代码进行了验证,我把他提供的 code copy test 了一下,同样验证无误,只是目前我暂不知如何使用 java 进行验证,需要大家自行研究了。

本文完结。

上一篇:《
二进制相关概念、运算与应用
》。

前言


公众号每月定期推广和分享的C#/.NET/.NET Core优秀项目和框架(每周至少会推荐两个优秀的项目和框架当然节假日除外),公众号推文中有项目和框架的介绍、功能特点、使用方式以及部分功能截图等(打不开或者打开GitHub很慢的同学可以优先查看公众号推文,文末一定会附带项目和框架源码地址)。注意:排名不分先后,都是十分优秀的开源项目和框架,每周定期更新分享(欢迎关注公众号:
追逐时光者
,第一时间获取每周精选分享资讯

首先看完成效果

一个玩家的效果

多个玩家的效果

源码地址

https://gitee.com/chesterdotchen/snake-with-orleans

项目介绍

Snake.Common项目

IGameGrain:游戏的Grain定义,与State定义

ISnakeGrain:蛇的Grain定义,与State定义

另外包含了游戏界面的宽高,蛇的初始长度,蛇的四个方向等

Snake.Server

GameGrain:GameGrain的实现

SnakeGrain:SnakeGrain的实现

SnakeHub:服务端与客户端的SingalR通知

Snake.Client

Form1.cs:游戏主界面

DrawGame:从服务端接收到SingalR通知后,调用此类方法画页面

启动方式

首先启动Snake.Server
然后启动Snake.Client(支持启动多个)

总结

Orleans7是一种基于.NET的开源分布式系统框架,它具有许多优点和适用场景:

  1. 简化分布式系统开发: Orleans7提供了高层次的抽象,使得开发者可以更轻松地构建和管理分布式系统,无需处理底层的复杂性。

  2. 透明的水平扩展: Orleans7支持透明的水平扩展,可以自动地将负载平衡在集群中的各个节点上,无需手动管理节点。

  3. 高度可扩展性: Orleans7可以轻松地扩展以应对不同规模和负载的系统需求,使其适用于大型和高流量的应用场景。

  4. 高可用性: Orleans7提供了内置的故障恢复和容错机制,使得系统可以在节点故障时保持可用性,提高了系统的稳定性。

  5. 灵活的编程模型: Orleans7采用了基于Actor模型的编程范式,使得开发者可以使用面向对象的方式来构建分布式系统,简化了代码的编写和维护。

  6. 分布式状态管理: Orleans7提供了强大的分布式状态管理机制,可以轻松地在集群中共享和管理状态,避免了传统分布式系统中状态同步的复杂性。

  7. 跨平台兼容性: Orleans7基于.NET平台开发,支持跨多种操作系统和云平台,如Windows、Linux和Azure等,使其具有广泛的适用性。

  8. 生态系统丰富: Orleans7拥有一个活跃的社区和丰富的生态系统,提供了许多扩展和工具,可以帮助开发者更好地构建和管理分布式系统。

Orleans7适用于需要构建高性能、高可用性和高度可扩展性的分布式系统的场景,如在线游戏、实时分析、物联网应用等。同时,对于开发者来说,如果需要简化分布式系统的开发和管理,并且希望能够以面向对象的方式来编写代码,那么Orleans7也是一个很好的选择。

写在前面

在实际项目的开发过程中,我们程序往往需要在不同环境中运行。例如:开发环境、测试环境和生产环境。

每个环境中的配置参数可能都会有所不同,例如数据库连接信息、文件服务器等等。

Spring Boot 提供了非常方便的方式来管理这些不同环境的配置。

一、Spring Profile 介绍

Spring Profile

Spring
框架用于处理不同环境配置的解决方案。

Profile
可以帮助我们在不改变应用代码的情况下,根据当前环境动态地激活或者切换不同的配置。

Spring Boot
为每个
Profile
提供了一个独立的
application.properties
(或
application.yml
)配置文件。

默认情况下,
Spring Boot
使用的是
application.properties
文件。

当你激活一个特定的
Profile
时,
Spring Boot
会查找名为
application-{profile}.properties
的文件,并把其中的属性加载到
Spring Environment
中。

二、Properties与YAML

Spring Boot
支持使用
Properties

YAML
两种配置方式。

两者功能类似,都能完成
Spring Boot
的配置,但是
Properties
的优先级要高于
YMAL

2.1
Properties

  • Spring Boot
    默认的配置文件格式

  • 以“.”为分割的key=value键值对,例如,指定端口为8080 配置如下

    server.port=8080
    
  • 不支持复杂的数据结构,只支持字符串类型的键值对。因此,它的使用适用于简单的配置场景

2.2
YAML

  • 采用树状结构,一目了然

    server:
     port: 8080
    
  • 在key后的冒号一定要跟一个空格,如“port: 8080”

  • YAML
    格式文件不支持注解
    @PropertySource
    导入配置

  • 支持复杂的数据结构,如列表和键值对,因此,
    YAML
    的使用适用于需要表示复杂数据结构的场合

2.3 如何选择


Spring Boot
应用中,你可以根据自己的实际需求,选择使用
YAML
还是
Properties
格式的配置文件。

如果你的配置比较简单,
Properties
格式可能会更好些。

如果你的配置比较复杂,或者你希望配置文件更具可读性,那么
YAML
格式可能是更好的选择

三、创建配置文件

3.1 创建配置文件

在实际项目开发中,我们一般有三个环境:开发(
dev
)、测试(
test
)、生产(
prod
)。我们需要建立如下几个配置文件

  • application.yml
    或者
    application.properties
    用于存放所有环境通用的配置
  • application-dev.yml
    或者
    application-dev.properties
    存放开发环境的特殊配置
  • application-test.yml
    或者
    application-test.properties
    存放测试环境的特殊配置
  • application-prod.yml
    或者
    application-prod.properties
    存放生产环境的特殊配置

配置文件

3.2 配置文件内容

①配置开发环境

application-dev.yml

server:
 port: 8080
 tomcat:
  uri-encoding: UTF-8
environment:
 name: 开发环境

application-dev.properties

server.port=8080
server.tomcat.uri-encoding=UTF-8
environment.name=开发环境

②配置生产环境

application-prod.yml

server:
 port: 8090
 tomcat:
  uri-encoding: UTF-8
environment:
 name: 生产环境

application-prod.properties

server.port=8090
server.tomcat.uri-encoding=UTF-8
environment.name=生产环境

application-test.yml

application-test.properties
配置文件类似,由于篇幅原因,这里就不一一例举了

四、激活 Profile

在上一小节中,我们创建了三个配置文件即配置了三种环境,那么我们怎么才能激活对应环境呢

4.1 配置文件激活

其实很多简单,我们只需要在
application.yml
或者
application.properties
添加如下配置即可,即可激活开发环境


application.yml

# application.yml
spring
 profiles:
  active: dev


application.properties

spring.profiles.active=dev

4.2 启动参数激活

在命令行中设置
spring.profiles.active
系统属性。

例如,你可以使用以下命令来启动你的应用,并激活 "prod" 环境:

# 企业级项目开发中,针对生产环境,一般通过启动命令再指定激活生产 环境
java -jar $APP_NAME --spring.profiles.active=prod

4.3 环境变量激活

我们还可以通过配置服务器系统环境变量来激活环境,在环境变量中设置
SPRING_PROFILES_ACTIVE
即可,不过这种方式不常用,了解即可

五、测试是否生效

上一小节中,我们通过配置激活了开发环境,我们就来实际验证以下是否生效。

出现下图红框表示部分,说明我们激活了
dev
开发环境

image-20240329232015193

本期内容就到这了,希望对你有所帮助,我们下期再见 (●'◡'●)

前一章讲了 eBPF 为什么这么吊,不理解没关系,现在开始,我们通过一个 “Hello world” 例子,来真正入门一下。

BCC Python
框架是上手 eBPF 的最友好方式。来看。

2.1 BCC 的 Hello World

下面的程序是一段 BCC 框架的 Hello World 程序。

#!/usr/bin/python3
from bcc import BPF

program = r"""
int hello(void *ctx) {
	bpf_trace_printk("Hello World!\n");
	return 0;
}
"""

b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

b.trace_print()

这段程序包含了两部分:

  • 运行在内核态的 eBPF 程序本身(
    hello()
    );
  • 运行在用户态的,用于加载 eBPF 程序到内核空间并读取它生成的 trace 控制程序(
    hello.py
    )。

下图显示了这段代码运行时的状态。

下面来逐行解释这段代码。

第一行告诉你,这是一个 Python 程序。实际上
#!/usr/bin/python3
是指定默认的 Python 解释器。

eBPF 程序本身是 C 语言编写的。这部分代码为:

int hello(void *ctx) {
	bpf_trace_printk("Hello World!");
	return 0;
}

其中,
bpf_trace_printk()
是 eBPF 辅助函数,用于打印一条消息。有关辅助函数的更多讨论,见第 5 章。

这段 eBPF 程序是以静态字符串
program
的形式被定义在 Python 脚本中,并作为参数,传递给 BPF 对象:

b = BPF(text=program)

当然,C 程序最终会由 BCC 框架负责编译执行。

eBPF 程序需要绑定到一个事件上。在这个例子中,我们选择的事件为
execve
系统调用。当有任何应用程序运行时,都会调用
execve()
,从而触发我们绑定的 eBPF。然而,
execve
系统调用在不同架构的 Linux 上可能会有不同的实现方式。但是,eBPF 提供了一种非常方便的方式(通过名称)来寻找当前支持的系统调用,就像这样:

syscall = b.get_syscall_fnname("execve")

现在,变量
syscall
指代了系统调用。接下来,使用一个探针
kprobe
(详见
第 1 章
)来将
hello()
函数绑定到
execve
事件上。

b.attach_kprobe(event=syscall, fn_name="hello")

此时,eBPF 程序已经被成功加载到内核,并完成了绑定。那么,当有一个进程被执行时,将触发这段
hello()
程序,完成一条消息的打印。剩下的工作,就是去读取 trace 的输出,并打印到标准输出中。

b.trace_print()

trace_print()
函数将进入无限循环,直到你键入
Ctrl+C
终止这段 eBPF 程序。

下面这张图显示了这段 eBPF 程序的运行原理:

根据这张图回顾一下整个流程。

1)这段 Python 程序编译了 C 代码,载入内核,并与
execve()
完成绑定。

2)当有其他进程运行时,执行
execve()
系统调用,触发 eBPF 中的
hello()
程序段,打印一行输出(在 pipe 中,后文会再次提到)。

3)用户态的程序读取这些输出,并打印到屏幕上。

2.2 运行 Hello World

运行这段程序,其结果取决于你当前的运行环境正在或即将运行的进程。

如果这段代码啥也没输出,请再起一个终端,手动执行一个程序。eBPF 将打印一行行的 Hello world 消息。

书里没有提到,但是很重要,运行 BCC 框架的 eBPF 程序,需要先安装 bcc-python 库。译者使用 REHL8-x86 操作系统,因此通过 yum 包管理器来安装:
yum install -y python3-bcc.x86_64

这里书中再次强调,eBPF 程序是立即生效的。首先是不需要重启,其次是对应用程序无侵入(已经重复很多遍了)。这是因为,eBPF 所绑定的是
execve()
系统调用,因此和应用程序没关系。即使你写了一个脚本,手动调用这个系统调用,那么,这个 eBPF 也会触发。

打印输出除了 “Hello World” 字符串以外,还有其他信息。例如,执行
execve
的进程 ID 为 5412,并使用了
bash
命令等等。Python 程序从哪里读取这个输出信息的呢?实际上,
bpf_trace_printk()
辅助函数会把打印写入
/sys/kernel/debug/tracing/trace_pipe
文件中。你可以通过
cat
指令来查看(需要 root 权限)。

eBPF 程序使用这种方式打印信息,虽然简单,但却有下面两点局限性:

  • 仅支持字符串类型的输出。你想传结构体类型?没门。
  • trace_pipe
    文件只有这一个。也就是说,所有正在运行的 eBPF 都会把输出写入到这里。难受吧!

那么,有没有一种更好的方式传递数据呢??答案就是:eBPF 映射(
maps
)。

2.3 eBPF 映射:maps

映射
maps
是 eBPF 的扩展功能,它是一类
可以让 eBPF 程序和用户态程序访问的数据结构

maps
支持内核态 eBPF 之间的通信,也支持 eBPF 到用户态程序之间的通信。主要的作用包括以下几种:

  • 用户空间写入需要由 eBPF 程序检索的配置信息。
  • 一个 eBPF 存储状态,以供另一个 eBPF 程序(或者同一 eBPF 的后续指令)使用。
  • eBPF 程序将数据写入
    maps
    ,以供用户空间应用程序读取,从而打印结果。

eBPF
maps
有很多种类型,在
uapi/linux/bpf.h
文件中可以查看,内核文档中也有相关的介绍。

通常,eBPF
maps
都是键值对类型结构,但具体
key

value
的指代和形式又有所区别。本章,将主要介绍
hash

perf

ring buffer
以及
eBPF 程序数组

诚然,eBPF
maps
不止这些。

有些
map
,形似数组,但其
key
小得仅有 4 字节;【array】

有些
map
,如哈希表,
key
的种类能够包罗万象;【hash】

有些
map
,便利操作,或
FIFO
列队而伺,或
FILO
作栈而生;或
LRU
行冷热数据分离,或
LPM
做最长前缀匹配;【queue、stack、lru、lpm、Bloom filter】

有些
map
,特殊对象专用,拓宽网络和尾调用的技术;【sockmaps、devmaps、program array、map-of-map】

有些
map
,对应CPU核心,寻求并发操作的可能性。【cpu-*】

接下来的例子,我们来看一下使用哈希表类型的
map
基本用法。

2.3.1 哈希表 map

在上一个给出的例子中,我们的 eBPF 程序绑定了
execve()
系统调用。接下来,要用哈希表
HASH
做一下改编,
key
用来存储用户 ID,
value
用来记录某个用户下的进程执行调用
execve()
的次数。这个程序统计了不同的用户分别运行了多少个程序。

来看这个 eBPF 程序的 C 代码。

BPF_HASH(counter_table); 				// A

int hello(void *ctx) {
    u64 uid;
    u64 counter = 0;
    u64 *p;
    
    uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;	// B
    p = counter_table.lookup(&uid);			// C
    if (p != 0) {					// D
        counter = *p;
    }
    counter++;						// E
    counter_table.update(&uid, &counter);		// F
    return 0;
}

代码解释:

【A】
BPF_HASH()
是一个
BCC
宏声明的哈希表。

【B】
bpf_get_current_uid_gid()
是一个辅助函数,用来获取当前进程的用户 ID。这个辅助函数返回值是一个 64 位的值,其中,用户 ID 存储在低 32 位(高 32 位为用户组 ID)。

【C】通过
key
查找哈希表中的
value
。这里是通过
uid
查找
p
。返回一个指针。

【D】如果指定的
uid
,在哈希表中存在一个
p
,将哈希表中的
p
值设置给
counter
;若哈希表中不存在对应
uid

p

counter
的值将为默认值
0

【E】无论
counter
值为多少,在这里都对其进行自增操作。

【F】使用新的
counter
值,更新对应
uid
的哈希表。

我们仔细看一下这两行代码。首先是查找哈希表
value

p = counter_table.lookup(&uid);

然后是更新哈希表:

counter_table.update(&uid, &counter);

你可能会有点疑问了:C 语言能这么写?结构体可以直接调用成员函数?不对吧?实际上,你是对的,C 语言确实不支持在结构体中定义这样的函数。但是,BCC 框架中的 C,实际上是一种不严格的 C。BCC 在真正执行 C 代码的编译前,会重写这些不严格的语法(实际上是通过若干个 BCC 宏来实现的)。

接下来,和前面的例子一样,将这段 C 程序声明为一个
program
字符串,然后通过 BCC 将其编译载入内核,并绑定在
execve()
系统调用上。

b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

但这次,还需要一些额外的工作,在用户态中读取哈希表的内容。

while true:						# A
    sleep(2)
    s = ""
    for k, v in b["counter_table"].items():		# B
        s += f"ID {k.value}: {v.value}\t"
    print(s)

代码解释:

【A】无限循环。每隔 2s 打印输出。

【B】BCC 框架会自动创建一个 Python 对象来指代哈希表。这个循环将会遍历 eBPF 定义的
counter_table
哈希表中的所有键值对,然后完成打印。

运行这段程序,你需要两个终端。终端 1 运行 eBPF 程序,终端 2 运行指令。

可以看到,每 2s 输出一行。我们关注最后一行的两个键值对:

  • key = 501, value = 5
  • key = 0, value = 2

在第二个终端里,作者的用户 ID 为 501。当运行
ls
命令时,值为 501 的
uid
计数器自增 1。而当运行
sudo ls
时,发生了两次
execve()
。第一次是在 501 用户下的
sudo
命令,第二次是在
root
用户下的
ls
命令。

这个例子给出了使用哈希表
map
从内核态向用户态传递数据的方式。当然,你也可以使用数组类型的
map
来实现这个功能(因为
key
为整数)。

Linux 内核中存在一个 名为
perf
的子系统,也可以传递内核态数据到用户空间,eBPF 刚好也支持这种方式。我们来看一下。

2.3.2 Perf 和 Ring buffer map

在这一小节中,我们再来看一种更复杂的 “Hello World” BCC 程序,它使用了
Perf
环形缓冲区,用来向用户态传递自定义的数据结构。

环形缓冲区:内核 5.8 版本才引入的结构,在这之前为普通的基于共享内存的缓冲区。实际上
perf
环形缓冲区更有优势,具体可以参考 Andrii Nakryiko 的这篇博客:
https://nakryiko.com/posts/bpf-ringbuf/

那么,问题来了,什么是环形缓冲区?

环形缓冲区
是一种数据结构,它不是 eBPF 独有的。环形缓冲区实际上是一段内存空间,其空间中的地址在逻辑上首尾相连成环。环形缓冲区包括两个工作指针,一个负责读,一个负责写,二者同向移动。
写指针
指向的位置就是下个数据被写入的位置(数据可以任意长度,其长度信息包含在数据头中),同理,
读指针
指向的位置就是下一个需要读取的数据开头(根据数据头中的长度,控制读指针移动距离)。

下图直观的展示了环形缓冲区的样貌。

读指针和写指针始终朝着一个方向运动。若在某一时刻,
读指针追上了写指针
,则说明缓冲区没数据可读了。相反,
若写指针追上了读指针
,则说明缓冲区没空间可写了,那么此时,需要写入的数据就会被丢弃(丢弃计数器会增加)。如果你控制的好,
读写指针以相同的速率运动
,始终不会相遇,那么恭喜你,你便拥有了一个无限大的循环缓冲区可以使用。

了解了环形缓冲区的概念后,我们再来改进一下之前绑定到
execve()
的 eBPF 程序,来实时打印运行进程的简单信息。

BPF_PERF_OUTPUT(output);							// A

struct data_t {									// B
    int pid;
    int uid;
    char command[16];
    char message[12];
};

int hello(void *ctx) {
    struct data_t data = {};							// C
    char message[12] = "Hello World";

    dara.pid = bpf_get_current_pid_tgid() >> 32;				// D
    data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;				// E

    bpf_get_current_comm(&data.command, sizeof(data.command));			// F
    bpf_probe_read_kernel(&data.message, sizeof(data.message), message);	// G

    output.perf_submit(ctx, &data, sizeof(data));				// H
    return 0;
}

代码解释:

【A】BCC 框架声明了一个宏定义
BPF_PERF_OUTPUT
,用来创建一个
perf
映射区域,以便内核态可以向用户态传递消息。这里定义为
output

【B】每次
hello()
运行之时,都会填充一个结构体来存储关键字段。这是结构体定义,包括进程 ID、用户 ID、当前运行指令名称以及
message
信息。

【C】
data
被定义为本地变量,
message
被赋值为
"Hello world"
字符串。

【D】
bpf_get_current_pid_tgid()
,辅助函数,用于获取触发当前 eBPF 程序的进程 ID。该函数返回一个 64 位的值,高 32 位是进程 ID(低 32 位为线程组 ID,对于单线程的进程,同为进程 ID)。

【E】
bpf_get_current_uid_gid()
,辅助函数,前文介绍过,用于获取用户 ID。

【F】
bpf_get_current_comm()
,辅助函数,用于获取当前执行的指令名称。

在 C 语言中,你不可以直接使用
"="
赋值字符串,你需要传入一个待写入字符串的地址。

【G】这个例子中,
message = "Hello World"

bpf_probe_read_kernel()
辅助函数会将它拷贝到
data
结构体的对应位置。

【H】此时,
data
结构体中已经填充了
pid

uid

command[]
以及
message[]
。这里调用
output.perf_submit()

data
结构体提交到
map
中。

接下来,与第一个 “Hello World” 程序类似,这一段 C 程序将被定义为一段字符串
program
,下面是 Python 代码。

b = BPF(text=program)							# A
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

def print_event(cpu, data, size):					# B
    data = b["output"].event(data)
    print(f"{data.pid} {data.uid} {data.command.decode()} {data.message.decode()}")

b["output"].open_perf_buffer(print_event)				# C
while True:								![image](uploading...)# D
    b.perf_buffer_poll()

代码解释:

【A】编译、加载、绑定 eBPF C程序。不再赘述。

【B】
print_event()
是一个回调函数,用于将
data
的内容打印到屏幕上。BCC 已经做了很多复繁重的工作,因此你只需要简单的
b["output"].event()
来从内核态
map
中获取数据。

【C】
b["output"].open_perf_buffer()
用于打开
perf ring buffer
。该函数接收
print_event
参数,是将其声明为一个回调。即,当
perf ring buffer
中有数据时,触发回调,打印这个数据。

【D】无限循环,调用
perf_buffer_poll()
拉取
perf ring buffer
内容。

运行这段程序,你能得到以下输出:

和以前一样,你可能需要另起一个终端,执行命令,来验证你的程序。

这个例子和最初的 “Hello World” 程序最大的不同就是,我们不再使用有限的
trace pipe
传递数据,而使用了
perf ring buffer
。执行原理通先前也有了些许区别,如下图所示。

通过环形缓冲区传递数据会不会仍然使用了
trace pipe
呢?你可以运行一下命令检验一下:

cat /sys/kernel/debug/tracing/trace_pipe

这个例子还给出了一些辅助函数的使用示例,第 7 章我们会更加详细讨论。

这些辅助函数主要辅助于检索事件触发时的上下文信息,辅助函数的合理使用,能够极大提高性能。因为这些上下文信息产生于内核、收集于内核、最后仍然应用于内核。这减少了很多不必要的内核态和用户态的切换。

2.3.3 函数调用

能否在 eBPF 程序的 C 代码中将重复代码块抽象成函数,并执行函数调用?这个看似简单的动作,在早先的 eBPF版本中并不支持(仅支持调用辅助函数)。如果你非要调用自定义函数,有没有方法呢?当然有,你可以将其声明为内联函数。就像下面这样。

static __always_inline void my_function(void *ctx, int val)

__always_inline
修饰符会在编译期间,对当前函数进行优化。

那么,普通函数和内联函数有什么区别呢?我们可以用一张图来加以说明:

对于普通函数(上图左侧),当函数 F 被调用时,顺序执行的指令会跳转到函数 F 的起始地址(函数调用实际上就是地址切换),执行 F 的指令序列。当函数 F 执行完毕,
return
语句会再次跳转回函数 F 调用前的位置,接续进行。

对于内联函数(上图右侧),并没有地址跳转,因为编译时这个函数会完全编译到顺序执行的指令序列中。

但是,内联函数是有局限性的。如果你在多个位置调用了同一个内联函数,那么在最终的可执行文件中,必然会产生该函数的多个指令副本。(这也是为啥通过
kprobe
探针无法绑定到内核内联函数的原因,我们第 7 章再来看这个问题)

直到 4.16 版本的内核以及 6.0 版本的 LLVM,eBPF 中内联函数的限制才被取消。因此,在这之后,你可以放心地定义函数调用(但必须是
static
的)。

2.3.4 尾调用

尾调用是什么?引用
ebpf.io
网站的一句介绍:“尾调用允许 eBPF 调用和执行另一个 eBPF 并替换执行上下文,类似于一个进程执行
execve()
系统调用的方式。”

换句话说,尾调用之后,函数不会再返回给调用者了。

Tail calls can call and execute another eBPF program and replace the execution context, similar to how the execve() system call operates for regular processes.

尾调用也不是 eBPF 独有的思想。eBPF 为什么要使用尾调用呢?这是因为,eBPF 的运行栈太有限了(仅有 512 字节),在递归调用函数时(实际上是向运行栈中一节一节地添加栈帧),很容易导致栈溢出。而尾调用恰恰允许在不增加堆栈的情况下,调用一系列函数。这是非常有效且实用的。

你可以使用下面的辅助函数来增加一个尾调用:

long bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index)

其三个参数的含义分别是:

  • ctx
    向被调用者传递当前 eBPF 程序的上下文信息。
  • prog_array_map
    是一个程序数组(
    BPF_MAP_TYPE_PROG_ARRAY
    )类型的 eBPF
    map
    ,用于记录一组 eBPF 程序的文件描述符。
  • index
    为程序数组中需要调用的 eBPF 程序索引。

这个辅助函数一旦运行成功,就不会返回了。因为调用者的运行栈已经被下一个 eBPF 程序的运行栈替换了。当然,如果指定
index
的 eBPF 程序不存在,该辅助函数也会执行失败,此时调用者继续执行。无事发生。

需要注意的是,若使用尾调用,所有需要执行的 eBPF 程序需要同时加载到内核中。而且还需要设置好程序数组
map

使用 BCC 框架如何进行尾调用呢?可以使用下面简单的方式:

prog_array_map.call(ctx, index)

在编译它之前,BCC 框架会自动将其转换为标准的尾调用辅助函数:

bpf_tail_call(ctx, prog_array_map, index)

下面来看一个使用尾调用的 BCC 框架的具体例子。

BPF_PROG_ARRAY(syscall, 300);						// A

int hello(struct bpf_raw_tracepoint_args *ctx) {			// B
    int opcode = ctx->args[1];						// C
    syscall.call(ctx, opcode);						// D
    bpf_trace_printk("Another syscall: %d", opcode);			// E
    return 0;
}

int hello_execve(void *ctx) {						// F
    bpf_trace_printk("Executing a program");
    return 0;
}

int hello_timer(struct bpf_raw_tracepoint_args *ctx) {			// G
    if (ctx->args[1] == 222) {
        bpf_trace_printk("Creating a timer");
    } else if (ctx->args[1] == 226) {
        bpf_trace_printk("Deleting a timer");
    } else {
        bpf_trace_printk("Some other timer operation");
    }
    return 0;
}

int ignore_opcode(void *ctx) { 						// H
    return 0;
}

代码解释:

【A】
BPF_PROG_ARRAY
宏定义,对应映射类型
BPF_MAP_TYPE_PROG_ARRAY
。在这里,命名为
syscall
,容量为 300。

【B】即将被用户态代码绑定在
sys_enter
类别的
Tracepoint
上,即当有任何系统调用被执行时,都会触发这个函数。
bpf_raw_tracepoint_args
类型的结构体
ctx
存放上下文信息。

译者注:
sys_enter

raw_syscalls
类型的
Tracepoint
;同族还有
sys_exit

详细信息可查看文件:
/sys/kernel/debug/tracing/events/raw_syscalls/sys_enter/format

【C】对于
sys_enter
类型的追踪点,其参数第 2 项为操作码,即指代即将执行的系统调用号。这里赋值给变量
opcode

【D】这一步,我们把
opcode
作为索引,进行尾调用,执行下一个 eBPF 程序。

再次提醒,这里的写法是 BCC 优化,在真正编译前,BCC 最终会将其重写为
bpf_tail_call
辅助函数。

【E】如果尾调用成功,这一行将永远不会被执行。添加这一行的原因是保底输出,防止程序数组
map
没有命中。

【F】
hello_execve()
,程序数组的一项,对应
execve()
系统调用。经由尾调用触发。

【G】
hello_timer()
,程序数组的一项,对应计时器相关的系统调用。经由尾调用触发。

【H】
ignore_opcode()
,程序数组的一项,用于忽略我们不关心的系统调用。经由尾调用触发。

现在,我们来看一下用户态的程序(重点,如何加载和设置尾调用)。

b = BPF(text=program)
b.attach_raw_tracepoint(tp="sys_enter", fn_name="hello")		# A

ignore_fn = b.load_func("ignore_opcode", BPF.RAW_TRACEPOINT)		# B
exec_fn = b.load_func("hello_exec", BPF.RAW_TRACEPOINT)
timer_fn = b.load_func("hello_timer", BPF.RAW_TRACEPOINT)

prog_array = b.get_table("syscall")					# C
prog_array[ct.c_int(59)] = ct.c_int(exec_fn.fd)
prog_array[ct.c_int(222)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(223)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(224)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(225)] = ct.c_int(timer_fn.fd)
prog_array[ct.c_int(226)] = ct.c_int(timer_fn.fd)

# Ignore same syscalls that come up a lot				# D
prog_array[ct.c_int(21)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(22)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(25)] = ct.c_int(ignore_fn.fd)
...

b.trace_print()								# E

代码解释:

【A】与前文绑定到
kprobe
不同,这次用户态将
hello()
主 eBPF 程序绑定到
sys_enter
追踪点(
Tracepoint
)上.

【B】这些
load_func()
方法用来将每个尾调用函数载入内核,并返回尾调用函数的
文件描述符
。尾调用需要和父调用保持相同的程序类型(这里是
BPF.RAW_TRACEPOINT
)。

一定不要混淆,每个尾调用程序本身就是一个 eBPF 程序。

【C】接下来,向我们创建好的
syscall
程序数组中添充条目。大可不必全部填满,如果执行时遇到空的,那也没啥影响。同样的,将多个
index
指向同一个尾调用也是可以的(事实上这段程序就是这样做的,将计时器相关的系统调用指向同一个 eBPF 尾调用)。

译者注:这里的
ct.c_int()
来自 Python 的 ctypes 库,用于 Python 到 C 的类型转换。

【D】由于一些系统调用会频繁地被执行,所以使用
ignore_opcode()
尾调用将他们忽略掉。

【E】不断打印输出,直到用户终止程序。

运行这段程序,获得下面的输出:

当遇到尾调用没匹配上的系统调用时,会输出 “Another syscall”。

内核 4.2 版本才开始支持尾调用,然而在很长的一段时间内,尾调用和 BPF 的编译过程不太兼容(尾调用需要 JIT 编译器的支持)。直到 5.10 版本才解決了这个问题。

你可以最多链接 33 个尾调用(而每个 eBPF 程序的指令复杂度最大支持 100w)。这样一来,eBPF 才能真正发挥出巨大潜力来了。

2.4 小结

本章给出了 eBPF BCC 框架实现的 “Hello World” 程序,以及它的一些变体。同时,也介绍了 eBPF maps 在内核和用户态交互之间的应用。

BCC 框架为我们提供了很好的封装,我们不需要了解程序具体要如何编译、如何载入内核以及如何绑定事件,即可成功运行我们的自定义逻辑。

但作为学习者,仅了解这些是不够的。eBPF 程序到底怎么执行?看来要深入地剖析了。且听下回分解。