Linux 操作系统:用户态与内核态函数调用


在编译程序时,编译器会将程序编译为如下结构加载到内存中执行。 过程参考 进程

  • 代码段:保存程序文本,指令指针 EIP 就是指向代码段,可读可执行不可写
  • 数据段:保存初始化的全局变量和静态变量,可读可写不可执行
  • BSS:未初始化的全局变量和静态变量
  • 堆(Heap):动态分配内存,向地址增大的方向增长,可读可写可执行
  • 栈(Stack):存放局部变量,函数参数,当前状态,函数调用信息等,向地址减小的方向增长,非常非常重要,可读可写可执行。

在这里插入图片描述
在程序执行过程中,程序调用到系统调用之后,就需要进入内核继续执行。

将用户态的执行和内核态的执行串起来,需要 task_struct 中两个重要的成员变量:


struct thread_info    thread_info;
void  *stack;

1 用户态函数栈

在用户态执行中,程序的执行往往是一个函数调用另一个函数。函数调用时通过栈来进行的。

函数调用通过汇编语言可以发现,其实就是指令跳转,从代码的一个地方跳到另外一个地方。但参数和返回地址应该怎样传递过去呢?

在进程的内存空间里面,栈是一个从高地址到低地址,往下增长的结构,也就是上面是栈底,下面是栈顶,入栈和出栈都是从下面的栈顶开始的。
在这里插入图片描述
32 位操作系统的情况(关于寄存器的知识可以参考 8086 CPU寄存器,文章介绍的是 16 位的,但原理相通)。在 CPU 里, ESP (Extended Stack Pointer) 是栈顶指针寄存器,入栈操作和出栈操作,会自动调整 ESP 的值。另外一个寄存器 EBP (Extended Base Pointer), 是栈基地址指针寄存器,指向当前栈帧的最底部。

例如下面的程序调用过程:

#include <stdio.h>
int Add(int x, int y)
{
    int z = 0;
    z = x + y;
    return z;
}
int main()
{
    int a = 1;
    int b = 2;
    int c = B(a, b);
    printf("%d\n", c);
    system("pause");
    return 0;
}

main 函数调用 Add 函数, main 的栈里面包含 main 函数的局部变量,然后是调用 Add 的时候要传给它的参数,然后返回 main 的地址,这个地址也应该入栈,这就形成了 main 的栈帧。
接下来就是 Add 的栈帧,先保存的是 main 栈帧的栈底位置,也就是 EBP, 因为在Add 函数里面获取 Add 传进来的参数,就是通过这个指针获取的。接下来保存的是 Add 的局部变量等。
在这里插入图片描述
当 Add 函数返回的时候,返回值会保存在 EAX 寄存器中,从栈中弹出 EBP 也就是 main 的返回地址,将指令跳转回去,参数也从栈中弹出,然后继续执行 main 函数。

对于 64 位操作系统,模式有些不一样。因为 64 位操作系统的寄存器数目较多 rax 用于函数调用返回的结果。栈顶寄存器变成了 rsp, 指向栈顶位置。堆栈的 Pop 和 Push 操作会自动调整 rsp, 栈基指针寄存器变成了 rbp, 指向当前栈帧的起始位置。

改变较多的是参数传递。 rdi、rsi、rdx、rcx、r8、r9 这 6 个寄存器,用于传递存储函数调用时的 6 个参数。如果参数超过 6 个,还是需要放到栈里面。

然而,前 6 个参数有时候需要进行寻址,但是如果在寄存器里面,时没有地址的,因而还是会放到栈里面,只不过放到栈里面的操作时被调用函数做的。
在这里插入图片描述
以上的栈操作,都是在进程的内存空间里面进行的。

2 内核态函数栈

通过系统调用,会从进程的内存空间到内核中。内核中也有各种各样的函数调用,也需要一种调用机制。

这时上面的成员变量 stack 也就是内核栈,就派上用场了。

Linux 给每个 task 都分配了内核栈。在 32 位系统上 arch/x86/include/asm/page_32_types.h 中的定义:一个 PAGE_SIZE 是 4K,左移一位就是乘以 2, 也就是 8K。


#define THREAD_SIZE_ORDER  1
#define THREAD_SIZE    (PAGE_SIZE << THREAD_SIZE_ORDER)

在 64 位系统中 arch/x86/include/asm/page_64_types.h,内核栈是这样定义的:在 PAGE_SIZE 的基础上左移两位,也即 16K, 并且要求起始地址必须是 8192 的整数倍。

#ifdef CONFIG_KASAN
#define KASAN_STACK_ORDER 1
#else
#define KASAN_STACK_ORDER 0
#endif

#define THREAD_SIZE_ORDER  (2 + KASAN_STACK_ORDER)
#define THREAD_SIZE  (PAGE_SIZE << THREAD_SIZE_ORDER)

内核栈是 task_struct 成员变量中一个非常特殊的结构,如下图所示:
在这里插入图片描述
这段空间的最低位置,是一个 thread_info 结构。这个结构是对 task_struct 的补充,因为 task_struct 结构庞大,不同的体系结构就需要保存不同的东西,所以往往与体系结构有关的,都放在 thread_info 里面。

在内核代码里有一个 union,将 thread_info 和 stack 放在一起, include/linux/sched.h 文件中。 union 中定义了 thread_info 后面是 stack。


union thread_union {
#ifndef CONFIG_THREAD_INFO_IN_TASK
  struct thread_info thread_info;
#endif
  unsigned long stack[THREAD_SIZE/sizeof(long)];
};

在内核栈的最高地址端,存放的是另一个结构, pt_regs, 定义如下。其中,32 位 和 64 位的定义不一样。、


#ifdef __i386__
struct pt_regs {
  unsigned long bx;
  unsigned long cx;
  unsigned long dx;
  unsigned long si;
  unsigned long di;
  unsigned long bp;
  unsigned long ax;
  unsigned long ds;
  unsigned long es;
  unsigned long fs;
  unsigned long gs;
  unsigned long orig_ax;
  unsigned long ip;
  unsigned long cs;
  unsigned long flags;
  unsigned long sp;
  unsigned long ss;
};
#else 
struct pt_regs {
  unsigned long r15;
  unsigned long r14;
  unsigned long r13;
  unsigned long r12;
  unsigned long bp;
  unsigned long bx;
  unsigned long r11;
  unsigned long r10;
  unsigned long r9;
  unsigned long r8;
  unsigned long ax;
  unsigned long cx;
  unsigned long dx;
  unsigned long si;
  unsigned long di;
  unsigned long orig_ax;
  unsigned long ip;
  unsigned long cs;
  unsigned long flags;
  unsigned long sp;
  unsigned long ss;
/* top of stack page */
};
#endif 

当系统调用从用户态到内核态的时候,首先要做的一件事情,就是将用户态运行过程中的 CPU 上下文保存起来,其实主要就是保存在这个结构的寄存器变量中。这样当从内核态系统调用返回的时候,才能让进程在刚才的地方接着运行下去。

在内核中, CPU 的寄存器 ESP 或者 RSP, 已经指向内核栈的栈顶,在内核里的调用都有和用户态相似的过程。

3 通过 task_struct 找内核栈

(有关 task_struct 结构的介绍,可以参考 task_struct)

如果有一个 task_struct 的 stack 指针在手,可以通过下面的函数找到这个线程内核栈:

static inline void *task_stack_page(const struct task_struct *task)
{
  return task->stack;
}

从task_struct 中得到相应的 pt_regs, 可以通过下面的函数:

/*
 * TOP_OF_KERNEL_STACK_PADDING reserves 8 bytes on top of the ring0 stack.
 * This is necessary to guarantee that the entire "struct pt_regs"
 * is accessible even if the CPU haven't stored the SS/ESP registers
 * on the stack (interrupt gate does not save these registers
 * when switching to the same priv ring).
 * Therefore beware: accessing the ss/esp fields of the
 * "struct pt_regs" is possible, but they may contain the
 * completely wrong values.
 */
#define task_pt_regs(task) \
({                  \
  unsigned long __ptr = (unsigned long)task_stack_page(task);  \
  __ptr += THREAD_SIZE - TOP_OF_KERNEL_STACK_PADDING;    \
  ((struct pt_regs *)__ptr) - 1;          \
})

可以发现,这是先从 task_struct 找到内核栈的开始位置。然后在这个位置加上 THREAD_SIZE 就到了栈的最顶位置。再减去栈的预留位置,然后转换为 struct pt_regs, 再减一,就相当于减少了一个 pt_regs 的位置,就到了 pt_regs 这个结构的首地址。

TOP_OF_KERNEL_STACK_PADDING 的定义如下:


#ifdef CONFIG_X86_32
# ifdef CONFIG_VM86
#  define TOP_OF_KERNEL_STACK_PADDING 16
# else
#  define TOP_OF_KERNEL_STACK_PADDING 8
# endif
#else
# define TOP_OF_KERNEL_STACK_PADDING 0
#endif

也就是说, 32 位机器是 8 , 其它是 0。这是为什么呢?因为压栈 pt_regs 有两种情况, CPU 用 ring 来区分权限,从而 Linux 可以区分内核态和用户态。

第一种情况,涉及到从用户态到内核态的变化的系统调用来说,因为涉及权限的改变,会压栈保存 SS, ESP 寄存器,这两个寄存器共占用 8 个 byte。

另一种情况,不涉及权限的变化,就不会压栈这 8 个 byte。这样就使得两种情况不兼容。如果没有压栈还访问,就会报错,所以还不如预留在这里,保证安全。在 64 位上,修改了这个问题,变成了定长的。

因此,如果 task_struct 在手,就能轻松得到内核栈和内核寄存器。

4 通过内核栈找 task_struct

如果一个当前在 CPU 上执行的过程,想知道自己的 task_struct 在哪里,又该用什么方法呢?

这就需要用到 thread_info 结构


struct thread_info {
  struct task_struct  *task;    /* main task structure */
  __u32      flags;    /* low level flags */
  __u32      status;    /* thread synchronous flags */
  __u32      cpu;    /* current CPU */
  mm_segment_t    addr_limit;
  unsigned int    sig_on_uaccess_error:1;
  unsigned int    uaccess_err:1;  /* uaccess failed */
};

这里面有个成员变量 task 指向 task_struct, 所以我们常用 current_thread_info()->task 来获取 task_struct。


static inline struct thread_info *current_thread_info(void)
{
  return (struct thread_info *)(current_top_of_stack() - THREAD_SIZE);
}

而 thread_info 的位置就是内核栈的最高位置,减去 THREAD_SIZE, 就到了 thread_info 的起始地址。

但是现在变成这样了,只剩下一个 flags。


struct thread_info {
        unsigned long           flags;          /* low level flags */
};

那这时候怎么获取当前运行的 task_struct 呢? current_thread_info 有了新的实现方式。

在 include/linux/thread_info.h 中定义了 current_thread_info。


#include <asm/current.h>
#define current_thread_info() ((struct thread_info *)current)
#endif

arch/x86/include/asm/current.h 中定义了 current


struct task_struct;

DECLARE_PER_CPU(struct task_struct *, current_task);

static __always_inline struct task_struct *get_current(void)
{
  return this_cpu_read_stable(current_task);
}

#define current get_current

在新的机制里,每个 CPU 运行的 task_struct 不通过 thread_info 获取了,而是直接存放在 Per CPU 变量里面了。

多核情况下,CPU 是同时运行的,但是它们共同使用其它硬件资源的时候,我们需要解决多个 CPU 之间的同步问题。

Per CPU 变量是内核中一种重要的同步机制。顾名思义,Per CPU 变量就是位每个 CPU 构造一个变量的副本,这样多个 CPU 各自操作自己的副本,互不干涉。比如,当前进程的变量 current_task 就被声明为 Per CPU 变量。

要使用 Per CPU 变量,首先要声明这个变量,在 arch/x86/include/asm/current.h 中有:


DECLARE_PER_CPU(struct task_struct *, current_task);

然后就是定义这个变量,在 arch/x86/kernel/cpu/common.c 中有:


DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;

也即是说,系统刚刚初始化的时候,current_task 都指向 init_task。
当某个 CPU 上的进程切换的时候, current_task 被修改为将要切换到目标进程。例如,进程切换函数 __switch_to 就会改变 current_task。


__visible __notrace_funcgraph struct task_struct *
__switch_to(struct task_struct *prev_p, struct task_struct *next_p)
{
......
this_cpu_write(current_task, next_p);
......
return prev_p;
}

当要获取当前运行中的 task_struct 时,就需要调用 this_cpu_read_stable 进行读取。


#define this_cpu_read_stable(var)       percpu_stable_op("mov", var)

5 总结

task_struct 的其它成员变量是和进程管理有关的,内核栈是和进程运行有关系的。

下面是 32 位和 64 位的工作模式。

  • 在用户态中,用户程序至少进行了一次函数调用。32 位和 64 位传递参数的方式稍有不同,32 位只是用函数栈,64 位的前 6 个参数用寄存器,其他的用函数栈。
  • 在内核态,32 位和 64 位都使用内核栈,格式稍有不同,主要是 pt_regs 结构
  • 在内核态,32 位和 64 位的内核栈和 task_struct 的关联关系不同。32 位主要靠 thread_info, 64 位主要靠 Per_CPU 变量。

在这里插入图片描述
参考:
Linux 下函数帧栈分析
函数调用过程(帧栈分析)
趣谈 Linux 操作系统

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值