Overview — Zephyr Project Documentation
[!机翻自官网]
线程模型
用户模式线程被 Zephyr 视为不信任,因此与其他用户模式线程和内核隔离。 有缺陷或恶意的用户模式线程不能泄漏或修改另一个线程或内核的私有数据/资源,并且不能干扰或控制另一个用户模式线程或内核。
Zephyr 用户模式功能示例:
- 内核可以防止许多无意的编程错误,否则这些错误可能会悄无声息地或令人震惊地破坏系统。
- 内核可以对复杂的数据解析器(如解释器、网络协议和文件系统)进行沙箱处理,这样恶意的第三方代码或数据就无法危害内核或其他线程。
- 内核可以支持多个逻辑 "应用程序 "的概念,每个应用程序都有自己的线程组和私有数据结构,如果其中一个应用程序崩溃或受到其他危害,这些应用程序就会相互隔离。
设计目标
对于在非特权 CPU 状态(以下简称 "用户模式")下运行的线程,我们的目标是防止以下情况发生:
- 我们会防止访问未特别授权的内存,或错误访问与策略不兼容的内存,如尝试写入只读区域。
- 对线程堆栈缓冲区的访问将受到策略控制,该策略部分取决于底层内存保护硬件。
- 默认情况下,用户线程可以读/写访问自己的堆栈缓冲区。
- 用户线程默认永远无法访问不属于同一内存域的用户线程栈。
- 默认情况下,用户线程永远无法访问监督线程拥有的线程栈,或用于处理系统调用权限提升、中断或 CPU 异常的线程栈。
- 用户线程可以读/写访问同一内存域中其他用户线程的堆栈,具体取决于硬件。
- 在 MPU 系统上,线程只能访问自己的堆栈缓冲区。
- 在 MMU 系统中,线程可以访问同一内存域中的任何用户线程栈。可移植代码不应假定这一点。
- 默认情况下,所有内核范围内的线程都可以只读方式访问程序文本和只读数据。这一策略可以调整。
- 除上述内容外,用户线程默认不允许访问任何内存。
- 我们根据每个对象或每个驱动程序实例的权限粒度,防止使用未经特别授权的设备驱动程序或内核对象。
- 我们会验证带有错误参数的内核或驱动程序 API 调用,否则会导致内核崩溃或内核专用数据结构损坏。这包括:
- 使用错误的内核对象类型。
- 使用超出适当范围或不合理值的参数。
- 根据 API 的语义,传递调用线程没有足够权限读取或写入的内存缓冲区。
- 使用未处于正确初始化状态的内核对象。
- 我们确保检测和安全处理用户模式堆栈溢出。
- 防止调用内核配置排除功能的系统调用。
- 防止禁用或篡改内核定义和硬件执行的内存保护。
- 防止从用户模式重新进入监管模式,除非通过内核定义的系统调用和中断处理程序。
- 防止用户模式线程引入新的可执行代码,内核系统调用支持的情况除外。
我们特别不防范以下攻击:
- 内核本身以及在管理模式下执行的任何线程都被假定为可信的。
- 假设构建系统使用的工具链和任何补充程序是可信的。
- 假设内核构建是可信的。有相当多的构建时逻辑用于创建有效内核对象表、定义系统调用和配置中断。在此过程中使用的 .elf 二进制文件都被假定为可信代码。
- 我们无法防止在内核模式下完成的内存域配置中发生错误,从而将私有内核数据结构暴露给用户线程。内核对象的 RAM 应始终配置为仅限管理程序。
- 可以对用户模式线程进行顶层声明,并将权限分配给内核对象。一般来说,作为生成 zephyr.elf 的内核构建的一部分的所有 C 和头文件都被假定为可信的。
- 我们不会通过线程CPU 不足来防止DDOS攻击。 Zephyr 没有线程优先级老化,并且特定优先级的用户线程可以使所有较低优先级的线程挨饿,如果未启用时间分片,则还可以使其他相同优先级的线程挨饿。
- 对于可以同时活动的线程数量存在构建时定义的限制,在此之后创建新用户线程将失败。
- 在管理模式下运行的线程的堆栈溢出可能会被捕获,但无法保证系统的完整性。
策略细节
概括地说,我们通过以下机制来实现这些线程级内存保护目标:
- 任何用户线程都只能访问内存的一个子集:通常是它的堆栈、程序文本、只读数据,以及它所属的内存保护设计中配置的任何分区。对其他内存的访问必须通过系统调用以线程的名义进行,或由监督线程使用内存域应用程序接口专门授予。新创建的线程继承父线程的内存域配置。线程之间可以通过共享相同内存域的成员身份,或通过内核对象(如 semaphores 和管道)进行通信。
- 用户线程不能直接访问属于内核对象的内存。虽然内核对象的指针可用于引用这些对象,但对内核对象的实际操作是通过系统调用接口完成的。设备驱动程序和线程栈也被视为内核对象。这就确保了内核对象中任何属于内核私有的数据都不会被篡改。
- 用户线程默认情况下没有权限访问除自己线程对象外的任何内核对象或驱动程序。这种访问权限必须由另一个处于监督模式的线程授予,或者该线程同时拥有接收线程对象和被授予访问权限的内核对象的权限。创建新线程时,可以选择自动继承父线程授予的所有内核对象权限,但父线程本身除外。
- 出于性能和占用空间的考虑,Zephyr 通常很少或根本不对内核对象或设备驱动程序 API 进行参数错误检查。通过系统调用从用户模式访问涉及到额外的处理函数层,这些处理函数需要严格验证访问权限和对象类型,通过边界检查或其他方法检查其他参数的有效性,并验证对相关内存缓冲区的正确读/写访问。
- 线程堆栈的定义方式是,超过指定的堆栈空间将产生硬件故障。具体做法因体系结构而异。
注意
**如果要在用户模式下使用所有内核对象、线程栈和设备驱动程序实例,则必须在构建时对其进行定义。**内核对象的动态用例需要通过预定义的可用对象池。
如果在内核启动后加载执行额外的应用程序二进制数据,则会受到一些限制:
- 加载对象代码将无法定义任何内核可识别的内核对象。相反,这些代码需要使用 API 从池中请求内核对象。
- 同样,由于加载的目标代码不是内核构建过程的一部分,因此无论以何种模式运行,这些代码都无法安装中断处理程序、实例化设备驱动程序或定义系统调用。
- 如果加载的目标代码并非来自经过验证的源代码,则应始终在 CPU 已处于用户模式的情况下输入。
ekko@work: ~/zephyrproject/zephyr/samples/userspace dev!
$ tree
.
├── hello_world_user
│ ├── CMakeLists.txt
│ ├── README.rst
│ ├── prj.conf
│ ├── sample.yaml
│ └── src
│ └── main.c
├── malloc
│ ├── CMakeLists.txt
│ ├── prj.conf
│ └── src
│ └── main.c
├── prod_consumer
│ ├── CMakeLists.txt
│ ├── README.rst
│ ├── prj.conf
│ ├── sample.yaml
│ └── src
│ ├── app_a.c
│ ├── app_a.h
│ ├── app_b.c
│ ├── app_b.h
│ ├── app_shared.c
│ ├── app_shared.h
│ ├── app_syscall.c
│ ├── app_syscall.h
│ ├── main.c
│ ├── sample_driver.h
│ ├── sample_driver_foo.c
│ └── sample_driver_handlers.c
├── shared_mem
│ ├── CMakeLists.txt
│ ├── README.rst
│ ├── boards
│ │ ├── nucleo_f746zg.overlay
│ │ └── ok3568.conf
│ ├── prj.conf
│ ├── sample.yaml
│ └── src
│ ├── enc.c
│ ├── enc.h
│ ├── main.c
│ └── main.h
└── syscall_perf
├── CMakeLists.txt
├── README.rst
├── prj.conf
├── sample.yaml
└── src
├── main.c
├── test_supervisor.c
├── test_user.c
└── thread_def.h
11 directories, 43 files
官方共有四个用例,测试libc增加了一个用例,各用例内容:
用例 | 功能 |
---|---|
hello_world_user | 基础用例,在用户模式线程打印信息 |
prod_consumer | 生产者消费者用例,较完整的流程,包括了内存域配置、资源池配置、内核对象权限传递、系统调用等用户模式基本操作,用户模式开发可参考该例程 |
shared_mem | 共享内存用例,主要靠内存域配置实现多用户线程的通信 |
syscall_perf | 系统调用用例,用于测试特权模式和用户模式系统调用的性能差异,但使用的riscv的csr(控制状态)寄存器系统调用 |
malloc | 用户模式使用libc中malloc示例 |
本节讲解使用用户模式需要哪些步骤,涉及到哪些操作,只做基本描述,详细步骤请参考prod_consumer例程。
需要在zephyr工程文件中开启用户空间
[!prj.conf]
CONFIG_USERSPACE=y
用户模式对内存权限有要求,因此使用到的全局变量需要手动规划到具体的分区:
K_APPMEM_PARTITION_DEFINE(app_a_partition);//定义a app使用到的分区
#define APP_A_DATA K_APP_DMEM(app_a_partition)//data数据段,存放初始不为0的全局/静态数据
#define APP_A_BSS K_APP_BMEM(app_a_partition)//bss段,存放初始化为0的全局/静态数据
//后续定义全局变量需要在前面加上数据段修饰
APP_A_BSS unsigned int count;
APP_A_DATA uint32_t baudrate = 115200;
和普通创建线程流程相同,差异点在option选择K_USER,用户模式,以及线程运行延时为无限,因为后续对该线程还有其他操作
k_tid_t user_tid = k_thread_create(&user_thread, user_stack, STACKSIZE,
thread_entry, NULL, NULL, NULL,
-1, K_USER,
K_FOREVER);
用户模式下的线程只有权限操作内存域中的分区,这一步也就把前面全局变量的分区权限给到了用户线程,内存域可配多个分区,也可动态修改,多个用户线程可通过某一个大家都有权限的分区实现共享内存通信和数据交互
struct k_mem_domain user_domain;//定义一个内存域
//a app的分区列表
struct k_mem_partition *app_a_parts[] = {
&user_hello,
&z_libc_partition,//libc 全局变量分区
&z_malloc_partition//libc malloc分区
};
//初始化内存域
k_mem_domain_init(&user_domain, ARRAY_SIZE(app_a_parts), app_a_parts);
//将用户线程添加到内存域
k_mem_domain_add_thread(&user_domain, user_tid);
资源池就是一个k_heap的指针,也就是一个可以申请内存的堆,在一些api函数中需要用到这个资源池来进行操作。而创建的线程默认是没有配置资源池的,特权模式的线程可以使用系统本身的资源池,而用户模式的线程就需要手动绑定一个资源池:
//全局变量定义资源池
K_HEAP_DEFINE(app_a_resource_pool, 256 * 5 + 128);
//将资源池绑定线程
k_thread_heap_assign(user_tid, &app_a_resource_pool);
内核对象的定义都用到了系统堆,用户模式是没有权限进行这种操作的,所以需要在全局变量创建(功能综述最后的注意中有提到),然后通过授权方式传递给用户线程:
//消息队列
K_MSGQ_DEFINE(mqueue, SAMPLE_DRIVER_MSG_SIZE, MAX_MSGS, 4);
//队列
K_QUEUE_DEFINE(queue);
//驱动
APP_A_BSS const struct device *sample_device;
sample_device = device_get_binding(SAMPLE_DRIVER_NAME_0);
//授权内核对象列表
k_thread_access_grant(user_tid, &mqueue, &queue, sample_device);
终于,到了最后一步,之后该线程就运行在了用户模式
k_thread_start(user_tid);
如果用户线程需要创建自己内部使用的一些内核对象,比如信号量,队列,锁等,而不是通过授权内核对象的方式获取全局内核对象的话,则按照以下步骤进行创建:
首先需要开启动态创建对象功能,该功能是用户模式才有的,在工程配置文件添加以下选项:
CONFIG_DYNAMIC_OBJECTS=y
也就是前面提到的第5步,资源池配置,资源池除了用于已授权内核对象部分api使用,也可以用于动态创建内核对象
/*
K_OBJ_MEM_SLAB,
K_OBJ_MSGQ,
K_OBJ_MUTEX,
K_OBJ_PIPE,
K_OBJ_QUEUE,
K_OBJ_POLL_SIGNAL,
K_OBJ_SEM,
K_OBJ_STACK,
K_OBJ_THREAD,
K_OBJ_TIMER,
K_OBJ_THREAD_STACK_ELEMENT,等等类型
*/
//队列
struct k_queue *queue = k_object_alloc(K_OBJ_QUEUE);
printf("queue : %p\n",queue);
k_queue_init(queue);
printf("k_queue_init finish\n");
int test = 10;
k_queue_alloc_append(queue,&test);
printf("k_queue_alloc_append finish\n");
int *get = k_queue_get(queue,K_NO_WAIT);
printf("get %p : %d\n",get,*get);
//锁
struct k_mutex *mutex = k_object_alloc(K_OBJ_MUTEX);
printf("mutex : %p\n",mutex);
k_mutex_init(mutex);
printf("k_mutex_init finish\n");
k_mutex_lock(mutex,K_FOREVER);
printf("k_mutex_lock finish\n");
k_mutex_unlock(mutex);
printf("k_mutex_unlock finish\n");
//消息队列
struct k_msgq *msgq = k_object_alloc(K_OBJ_MSGQ);
printf("msgq : %p\n",msgq);
k_msgq_alloc_init(msgq,1,5);
printf("k_msgq_alloc_init finish\n");
k_msgq_put(msgq,&test,K_NO_WAIT);
printf("k_msgq_put finish\n");
int recv = 0;
k_msgq_get(msgq,&recv,K_NO_WAIT);
printf("recv : %d\n",recv);
//信号量
struct k_sem *sem = k_object_alloc(K_OBJ_SEM);
printf("sem : %p\n",sem);
k_sem_init(sem,0,10);
printf("k_sem_init finish\n");
k_sem_give(sem);
printf("k_sem_give finish\n");
k_sem_take(sem,K_NO_WAIT);
printf("k_sem_take finish\n");
//栈
struct k_stack *stack = k_object_alloc(K_OBJ_STACK);
printf("stack : %p\n",stack);
k_stack_alloc_init(stack,10);
printf("k_sem_take finish\n");
k_stack_push(stack,1);
printf("k_sem_take finish\n");
stack_data_t pop;
k_stack_pop(stack,&pop,K_NO_WAIT);
printf("pop : %ld\n",pop);
并不是全部api都可正常使用,上图示例中可正常使用,未在官方文档找到完整的在用户模式下动态创建的对象能使用的api列表,参考资料为Memory Protection Design #thread-resource-pools— Zephyr Project Documentation,Kernel Objects — Zephyr Project Documentation
基本前面的步骤中已经说明了当前可以使用的部分:
前面的功能综述中已经对用户模式下的各种特性优势做了说明,这部分就说一下在实际使用中可能的几个场景:
本文只是初步测试用户模式后的一点总结,详细资料请参考官网以及用例程序。User Mode — Zephyr Project Documentation