内核对象

内核对象可以是以下三类数据之一:

  • 核心内核对象,例如信号量、线程、管道等。

  • 线程栈,即 z_thread_stack_element 数组,使用 K_THREAD_STACK_DEFINE() 声明。

  • 属于一组已定义子系统之一的设备驱动程序实例(const struct device)。

已知的内核对象和驱动程序子系统集合在 include/zephyr/sys/kobject.h 中定义为 k_objects。

内核对象对用户线程完全不透明。用户线程调用 API 时使用内核对象的地址,但绝不能解引用这些地址,否则会导致内存保护故障。所有内核对象都必须放在用户线程无法访问的内存中。

由于用户线程不能直接操作内核对象,所有使用都必须通过系统调用。对内核对象执行系统调用时,系统调用处理函数会检查内核对象地址是否有效,以及调用线程是否具有足够的操作权限。

对象权限同时也具有对象引用的语义。这对某些执行临时分配的对象 API,以及自身从运行时内存池分配的对象尤为重要。

如果对象失去所有引用,可能发生以下两件事:

  • 如果对象具有关联的清理函数,可能会调用该函数,以释放对象正在使用的运行时分配的缓冲区。

  • 如果对象本身是动态分配的,则会释放其内存。

对象放置

仅供特权线程使用的内核对象没有位置限制,可以放在二进制文件中的任何位置,甚至可以在栈上声明。但为了防止用户线程意外或故意破坏这些对象,不得将其放在用户线程可以直接访问的任何内存中。

要让用户线程能够通过系统调用 API 使用静态内核对象,其声明方式必须满足以下条件:

  • 对象必须在构建时声明为顶层全局对象,以便出现在 ELF 符号表中。允许使用 static 作用域声明内核对象。构建后脚本 scripts/build/gen_kobject_list.py 会扫描生成的 ELF 文件以查找内核对象,并将其内存地址放入专用的内核对象元数据表。内核对象可以是数组成员,也可以嵌入其他数据结构。

  • 内核对象必须放在为内核保留的内存中,不得放在任何用户可访问的内存分区中。

  • 为内核对象保留的内存必须由该对象独占。内核对象不能是联合体数据类型的成员。

找到的内核对象如果不满足上述条件,就不会被纳入生成的表中。该表用于验证用户模式传入的内核对象指针。

排查某个对象为何意外未被跟踪时,scripts/build/gen_kobject_list.py 脚本的调试输出可能有帮助。使用 --verbose 标志运行脚本,或以详细输出模式调用构建系统时,会打印这些信息。

动态对象

启用 CONFIG_DYNAMIC_OBJECTS 后,也可以在运行时分配内核对象。此时,可使用 k_object_alloc() API 从调用线程的资源池中实例化对象。这类分配有两种释放方式:

  • 特权线程可以调用 k_object_free() 强制释放动态对象。

  • 如果对象的引用数降为零(即没有线程拥有其权限),该对象会自动释放。用户线程可以通过 k_object_release() 放弃自己对对象的权限,线程终止时也会自动清除其权限。特权线程还可以使用 k_object_access_revoke() 撤销其他线程的引用。

由于权限还用于引用计数,因此即使权限系统不对特权线程强制执行访问控制,特权线程也应获取其正在使用的对象的权限。

实现细节

scripts/build/gen_kobject_list.py 脚本是一个构建后步骤,通过解析内核生成的 ELF 文件中的 DWARF 调试信息,查找二进制文件中所有有效的内核对象实例。

符合对象放置条件的内核对象结构体或数组实例,其内存地址都会被放入由 gperf 工具生成的专用内核对象完美哈希表。执行系统调用时,内核收到的内存地址可能是有效内核对象,也可能不是;通过在此表中进行常数时间查找,即可验证该地址。

驱动程序是一个特例。所有驱动程序都是 device 实例,但必须知道其所属的子系统,才能防止错误操作,例如对传感器驱动程序对象调用 UART API。找到 device 结构体时,会检查其 API 指针以确定驱动程序所属的子系统。

该表将内核对象的内存地址映射到 z_object 实例,后者包含该对象的全部元数据,包括:

  • 表示对象权限的位域。所有线程在构建时都会被分配一个数字 ID,用于索引对象的权限位域,判断该线程是否拥有权限。位域大小由 CONFIG_MAX_THREAD_BYTES 选项控制;如果该值过小,构建系统会报错。

  • 标明对象种类的类型字段,其值为 k_objects 的一个枚举值。

  • 对象的一组标志,目前用于跟踪初始化状态以及对象是否公开。

  • 一个额外数据字段,其语义因对象类型而异,参见 z_object_data 的定义。

运行时分配的动态对象通过运行时红黑树跟踪。验证对象指针时,该树与 gperf 表配合使用。

特权线程访问权限

特权线程可以访问任意内核对象,但仍会跟踪其权限,原因有二:

  • 特权线程调用 k_thread_user_mode_enter() 后,会以用户模式运行,并保留其在特权模式下获授的所有权限(许多情况下由自身授予)。

  • 特权线程使用 K_INHERIT_PERMS 选项创建用户线程时,子线程会获得与父线程相同的权限,但不包括父线程对象。

用户线程访问权限

默认情况下,新创建的用户线程仅拥有自身线程对象的访问权限,其他内核对象默认不可用。必须显式或隐式授予访问权限,可通过以下几种方式实现。

  • 使用 K_INHERIT_PERMS 创建线程时,该线程会继承父线程的全部权限,但不包括父线程对象。

  • 拥有某对象权限或在特权模式下运行的线程,可以通过 k_object_access_grant() API 将该对象的权限授予另一个线程。也可以使用便捷的伪函数 k_thread_access_grant(),它接受任意数量的内核对象指针,并对每个对象调用 k_object_access_grant()。获授权的线程和被授权访问的对象都不必处于已初始化状态。如果调用来自用户模式,调用者必须同时拥有内核对象和目标线程对象的权限。

  • 特权线程可以使用 k_object_access_all_grant() API 将特定内核对象声明为公开对象,使所有当前和未来的线程均可使用。此时必须假定任何不可信或已被攻击的代码都能访问该对象。请谨慎使用此 API!

  • 如果线程通过 K_THREAD_DEFINE() 静态声明,可以使用 K_THREAD_ACCESS_GRANT() 在启动时授予该线程一组内核对象的访问权限。

线程获得对象访问权限后,可以使用 k_object_access_revoke() API 移除该权限。用户线程无法使用此 API,但可以使用 k_object_release() 放弃自身对对象的权限。

特权线程可以使用 k_object_access_revoke_others() 撤销调用者以外所有线程的访问权限。如果之前将访问权限授予了不可信的用户线程,而该线程可能又将权限授予其他线程,这个 API 可用于重置对象权限。它也会撤销此前通过 k_object_access_all_grant() 设置的公开访问状态。

在特权模式下调用 API,为内核未跟踪的内核对象设置权限,不会产生任何效果。在用户模式下执行同样操作,则会使调用线程发生致命错误。

使用 k_object_alloc() 分配对象时,会隐式授予调用线程该对象的权限。

初始化状态

如果内核对象被视为未初始化,大多数针对它的操作都会失败。必须先执行该对象适当的初始化函数。

有些对象会在启动时隐式初始化:

  • 通过静态初始化宏(例如信号量的 K_SEM_DEFINE)声明的内核对象,在构建时即处于已初始化状态。

  • 设备驱动程序对象在启动早期由内核运行其初始化函数后,即被视为已初始化。

如果内核对象使用私有静态初始化器初始化,特权线程必须在某个时刻对其调用 k_object_init(),否则用户线程访问时,内核会将其视为未初始化。这种情况很少见,通常只用于嵌入较大结构体并进行静态初始化的内核对象。

struct foo {
    struct k_sem sem;
    ...
};

struct foo my_foo = {
    .sem = Z_SEM_INITIALIZER(my_foo.sem, 0, 1),
    ...
};

...
k_object_init(&my_foo.sem);
...

创建新的内核对象类型

实现新的内核功能或驱动程序子系统时,可能需要定义新的内核对象类型。创建核心内核对象与创建新的驱动程序子系统所需的步骤不同。

创建新的核心内核对象

  • 在 scripts/build/gen_kobject_list.py 中,将结构体名称加入 kobjects 列表。

现在,新结构体的实例应当会被跟踪。

创建新的驱动程序子系统内核对象

所有驱动程序实例都是 device,通过各自设置的 API 结构体来区分。

  • 在 scripts/build/gen_kobject_list.py 中,将新子系统的 API 结构体名称加入 subsystems 列表。

现在,新子系统的驱动程序实例应当会被跟踪。

配置选项

相关配置选项:

API 参考

User Mode APIs