1. 程式人生 > >Linux核心分析(六):程序的描述和程序的建立

Linux核心分析(六):程序的描述和程序的建立

一、Linux中的程序簡析

程序是具有多道程式設計的作業系統的基本概念,關於程序的定義就是程式執行的一個例項,也是系統資源排程的最小單位。如果同一個程式被多個使用者同時執行,那麼這個程式就有多個相對獨立的程序,與此同時他們又共享相同的執行程式碼。在Linux系統中程序的概念類似於任務或者執行緒(task & threads)。

實際上我們說程序是一個程式執行時候的一個例項實際上說的是它就是一個可以充分描述程式以達到了其可以執行狀態的的一個數據和程式碼集合。一個程序會被產生並會複製出自己的子代,類似細胞分裂一樣。從系統的角度來看程序的任務實際上就是擔當承載系統資源的單位,系統在排程和分配資源的時候也會以他們作為基本單位開始進行分配。(系統中的資源很多例如CPU的時間片、記憶體堆疊等等)

程式或者程序執行的時候會需要建立新的例項,這個時候A如果新建立了B那麼A就是B的父程序。建立一個新程序的時候,實際上系統就是在複製他的父程序。實際上就是複製了幾乎所有父程序的資訊包括程式碼。子程序接收父程序地址空間的一個邏輯拷貝,(實際上就是可以理解為面向物件中的類建立例項的過程或者繼承父類的這種關係,實際上他們看起來域屬性是一樣的但是又不會完全一樣,所以我們說這裡面是邏輯上的一個複製)然後,這個子程序會從建立程序那個系統呼叫服務程式碼之後的下一條指令開始執行(ret_from_fork),執行程式碼與父程序是相同的。但是我們要知道實際上雖然AB都是指向相同的程式碼部分,但是正如我們知道的程式需要指令和資料,所以他們的資料拷貝是不同的,因此程序對一個記憶體單元的修改在AB之間是不可見的。以上是早期的時候情況,現代的系統實際上可能並不是這樣的。在支援多執行緒應用的系統中很多擁有相對獨立執行路徑的使用者程式共享應用程式的大部分資料結構。那麼這樣的話一個程序就是由幾個使用者執行緒組成,而且每一個執行線路就是一個執行緒。

那系統中的資料結構又是什麼樣子的呢?首先最應該知道就是系統如何管理這些程序,那麼系統一定要有相應的資料結構去標識每一個程序以及他們的擴充套件資料結構。實際上這個結構就是我們在作業系統中所說的PCB(Process Control Block)在Linux中這個資料結構我們叫做task_struct,它實際上至少應該包括以下資訊,比如優先順序,它的執行狀態,他所在的記憶體空間,它的檔案訪問許可權等等。
這裡寫圖片描述

實際上我們看到他的結構還是很冗長的,不僅僅包含了很多程序資訊的標識欄位,同時又有很多的指標指向很多附件的資料結構。圖中列出來的包括程序的基本資訊thread_info、記憶體區域描述mm_struct、與程序相關的tty tty_struct、當前的目錄fs_struct 、檔案描述符files_struct、所接受的訊號singal_struct等等。

1.1程序的狀態
程序執行時,它會根據具體情況改變狀態 。程序狀態是排程和對換的依據。Linux中的程序主要有如下狀態(上面圖中的那個state欄位)
這裡寫圖片描述
(1)執行態:程序正在使用CPU執行的狀態。處於執行態的程序又稱為當前程序(current process)。
(2)可執行態:程序已分配到除CPU外所需要的其它資源,等待系統把CPU分配給它之後即可投入執行。
(3)等待態:又稱睡眠態,它是程序正在等待某個事件或某個資源時所處的狀態。 等待態進一步分為可中斷的等待態和不可中斷的等待態。處於可中斷等待態的程序可以由訊號(signal)解除其等待態。處於不可中斷等待態的程序,一般是直接或間接等待硬體條件。 它只能用特定的方式來解除,例如使用喚醒函式wake_up()等。

可中斷的等待狀態:程序被掛起,直到等到一個可以喚醒他的東西,例如一個硬體中斷、某項系統資源、或一個訊號量。當它等到這些喚醒條件的之後就會進入可執行狀態。
不可中斷的等待:一種常見的狀態就是這個程序正在訪問一個獨佔的臨界資源,這種時候處於一種不可搶佔的狀態。通常當程序接收到SIGSTOP、SIGTSTP、SIGTTIN或 SIGTTOU訊號後就處於這種狀態。例如,正接受除錯的程序就處於這種狀態。

(4)暫停態:程序需要接受某種特殊處理而止執行所處的狀態。通常程序在接受到外部程序的某個訊號進入暫停態,例如,正在接受除錯的程序就處於這種狀態。
(5)僵死狀態
程序雖然已經終止,但由於某種原因,父程序還沒有執行wait()系統呼叫,終止程序的資訊也還沒有回收。顧名思義,處於該狀態的程序就是死程序,這種程序實際上是系統中的垃圾,必須進行相應處理以釋放其佔用的資源。
我們在設定這些狀態的時候是可以直接用語句進行的比如:p—>state = TASK_RUNNING。同時核心也會使用set_task_state和set_current_state。
這裡寫圖片描述

1.2關於thread_info和核心棧
程序是一個動態的東西,所以系統也是希望很有效率的進行管理。Linux核心把兩個不同的資料結構緊湊的存放在一個單獨為程序分配的記憶體區域中:一個是核心態的程序堆疊,另一個是緊挨著程序描述符的小資料結構thread_info,叫做執行緒描述符。實際上就像我們猜到的一樣他們既然總是在一個8k的頁中,所以基於Linux簡潔優雅的特性這些資訊的地址分配肯定也是基於8K對齊的(即起始地址都是8k整數倍)

為什麼需要核心棧:
程序在核心態執行時需要自己的堆疊資訊, 因此linux核心為每個程序都提供了一個核心棧kernel stack

struct task_struct
{
    // ...
    void *stack;    //  指向核心棧的指標
    // ...
};

核心態的程序訪問處於核心資料段的棧,這個棧不同於使用者態的程序所用的棧。使用者態程序所用的棧,是在程序線性地址空間中;而核心棧是當程序從使用者空間進入核心空間時,特權級發生變化,需要切換堆疊,那麼核心空間中使用的就是這個核心棧。因為核心控制路徑使用很少的棧空間,所以只需要幾千個位元組的核心態堆疊。

需要注意的是,核心態堆疊僅用於核心例程,Linux核心另外為中斷提供了單獨的硬中斷棧和軟中斷棧

為什麼需要thread_info:
核心還需要儲存每個程序的PCB資訊, linux核心是支援不同體系的的, 但是不同的體系結構可能程序需要儲存的資訊不盡相同, 這就需要我們實現一種通用的方式, 我們將體系結構相關的部分和無關的部門進行分離
用一種通用的方式來描述程序, 這就是struct task_struct, 而thread_info就儲存了特定體系結構的彙編程式碼段需要訪問的那部分程序的資料,我們在thread_info中嵌入指向task_struct的指標, 則我們可以很方便的通過thread_info來查詢task_struct

linux將核心棧和程序控制塊thread_info融合在一起, 組成一個聯合體thread_union
通常核心棧和thread_info一同儲存在一個聯合體中, thread_info儲存了執行緒所需的所有特定處理器的資訊, 以及通用的task_struct的指標

核心資料結構描述
thread_union

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

這塊區域32位上通常是8K=8192(佔兩個頁框),64位上通常是16K,其實地址必須是8192的整數倍。
下圖中顯示了在實體記憶體中存放兩種資料結構的方式。執行緒描述符駐留與這個記憶體區的開始,而棧頂末端向下增長。
這裡寫圖片描述
在這個圖中,esp暫存器是CPU棧指標,用來存放棧頂單元的地址。在80x86系統中,棧起始於頂端,並朝著這個記憶體區開始的方向增長。從使用者態剛切換到核心態以後,程序的核心棧總是空的。因此,esp暫存器指向這個棧的頂端。一旦資料寫入堆疊,esp的值就遞減(向下遞增)。

同時我們可以看到,thread_info和核心棧雖然共用了thread_union結構, 但是thread_info大小固定, 儲存在聯合體的開始部分, 而核心棧由高地址向低地址擴充套件, 當核心棧的棧頂到達thread_info的儲存空間時, 則會發生棧溢位

系統的current指標指向了當前執行程序的thread_union(或者thread_info)的地址

程序task_struct中的stack指標指向了程序的thread_union(或者thread_info)的地址, 在早期的核心中這個指標用struct thread_info *thread_info來表示, 但是新的核心中用了一個更淺顯的名字void *stack, 即核心棧

在Linux3.5.4核心中,thread_info結構是72個位元組長(ULK3時代的核心中,這個結構的大小是52個位元組),因此核心棧能擴充套件到8120個位元組。thread_info結構的定義如下:

struct thread_info {
    struct task_struct  *task;       /* main task structure */
    struct exec_domain  *exec_domain;    /* execution domain */
    __u32     flags;    /* low level flags */
    __u32    status;   /* thread synchronous flags */
    __u32    cpu;     /* current CPU */
    int      preempt_count;   /* 0 => preemptable, <0 => BUG */
    mm_segment_t            addr_limit;
    struct restart_block     restart_block;
    void __user             *sysenter_return;
#ifdef CONFIG_X86_32
    unsigned long previous_esp; /* ESP of the previous stack in
                                   case of nested (IRQ) stacks
                                   */
    __u8                supervisor_stack[0];
#endif
    unsigned int        sig_on_uaccess_error:1;
    unsigned int        uaccess_err:1;    /* uaccess failed */
};

Linux核心中使用一個聯合體來表示一個程序的執行緒描述符和核心棧:

#define THREAD_SIZE        8192  
union thread_union {
    struct thread_info thread_info;
    unsigned long stack[THREAD_SIZE/sizeof(long)];
};

下面來說說如何通過esp棧指標來獲取當前在CPU上正在執行程序的thread_info結構。實際上,上面提到,thread_info結構和核心態堆疊是緊密結合在一起的,佔據兩個頁框的實體記憶體空間。而且,這兩個頁框的起始起始地址是8K對齊的。那既然是8k的整數倍那麼後13位就是一樣的呢。遮蔽esp的低13位然後加上thread_info的頁內偏移量就可以快速找到thread_info結構

所有的體系結構都必須實現兩個current和current_thread_info的符號定義巨集或者函式

current_thread_info可獲得當前執行程序的thread_info例項指標, 其地址可以根據核心指標來確定, 因為thread_info總是位於起始位置,因為每個程序都有自己的核心棧, 因此程序到核心棧的對映是唯一的, 那麼指向核心棧的指標通常儲存在一個特別保留的暫存器中(多數情況下是esp)

current給出了當前程序程序描述符task_struct的地址,該地址往往通過current_thread_info來確定 current = current_thread_info()->task

因此我們的關鍵就是current_thread_info的實現了,即如何通過esp棧指標來獲取當前在CPU上正在執行程序的thread_info結構。

早期的版本中,不需要對64位處理器的支援,所以,核心通過簡單的遮蔽掉esp的低13位有效位就可以獲得thread_info結構的基地址了。

早期版本如3.14的Linux/arch/x86/include/asm/thread_info.hstatic inline struct thread_info *current_thread_info(void)
{
   return (struct thread_info *)
        (current_stack_pointer & ~(THREAD_SIZE - 1));
 }
當前的棧指標(current_stack_pointer == sp)就是esp,
THREAD_SIZE為8K,二進位制的表示為0000 0000 0000 0000 0010 0000 0000 0000。
current_thread_info(void)

而在3.18
linux/arch/x86/include/asm/thread_info.h中,有如下程式碼:

#ifndef __ASSEMBLY__
DECLARE_PER_CPU(unsigned long, kernel_stack);
static inline struct thread_info *current_thread_info(void)
{
   struct thread_info *ti;
   ti = (void *)(this_cpu_read_stable(kernel_stack) +
   KERNEL_STACK_OFFSET - THREAD_SIZE);
   return ti;
}

#else /* !__ASSEMBLY__ */
/* how to get the thread information struct from ASM */ #define GET_THREAD_INFO(reg) \
_ASM_MOV PER_CPU_VAR(kernel_stack),reg ; \
_ASM_SUB $(THREAD_SIZE-KERNEL_STACK_OFFSET),reg ;

/* Same if PER_CPU_VAR(kernel_stack) is, perhaps with some offset, already in a certain register (to be used in assembler memory operands)*/
#define THREAD_INFO(reg, off) KERNEL_STACK_OFFSET+(off)-THREAD_SIZE(reg)
#endif

kernel_stack per-CPU變數
用於指向當前CPU上執行的程序的核心棧,由於核心棧與thread_info是放在一起的,所以,核心中也用這個變數來獲取當前程序的thread_info

程序最常用的是程序描述符結構task_struct而不是thread_info結構的地址。為了獲取當前CPU上執行程序的task_struct結構,核心提供了current巨集。由於task_struct *task在thread_info的起始位置,該巨集本質上等價於current_thread_info()->task,
在include/asm-generic/current.h中定義:

#define get_current() (current_thread_info()->task)
#define current get_current()

這個定義是體系結構無關的,當然linux也為各個體系結構定義了更加方便或者快速的current

如在3.18中對於x86結構,在/arch/x86/include/asm/current.h中定義:

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

1.3程序的建立
fork、vfork、clone三個系統呼叫都可以建立一個新程序,而且都是通過呼叫do_fork來實現的
1.子程序被建立後繼承了父程序的資源。
2.子程序共享父程序的虛存空間。
3.寫時拷貝 (copy on write):子程序在建立後共享父程序的虛存記憶體空間,寫時拷貝技術允許父子程序能讀相同的物理頁。只要兩者有一個程序試圖寫一個物理頁,核心就把這個頁的內容拷貝到一個新的物理 頁,並把這個新的物理頁分配給正在寫的程序
4.子程序在建立後執行的是父程序的程式程式碼。
這裡寫圖片描述

我們都知道,對於父程序 fork 返回子程序號,對於子程序 fork 返回 0 ,這也是執行路徑如此的原因所在。但是, fork 的返回不同值的原因又是什麼,這就得看 fork 的實現了。fork 先是呼叫 find_empty_process 為子程序找到一個空閒的任務號,然後呼叫 copy_process 複製程序, fork 返回 copy_process 的返回值 last_pid ,也就是子程序號。所以fork()實際上是一次呼叫,兩次返回

下圖是負責建立程序的函式的層次結構

從圖中,可以看到 do_fork 是程序建立的基礎。可以在 ./linux/kernel/fork.c 內找到 do_fork 函式(以及合作函式 copy_process)。

fork()函式建立新程序是通過下列一系列函式實現的:
fork() -> sys_clone() -> do_fork() -> copy_process()->dup_task_struct() -> copy_thread() -> ret_from_fork()

do_fork 函式首先會分配一個新的 PID(但是我還沒找到該呼叫)。接下來,do_fork 檢查偵錯程式是否在跟蹤父程序。如果是,在 clone_flags 內設定 CLONE_PTRACE 標誌以做好執行 fork 操作的準備。

之後 do_fork 函式還會呼叫 copy_process,向其傳遞這些標誌、堆疊、登錄檔、父程序以及最新分配的 PID。
新的程序在 copy_process 函式內作為父程序的一個副本建立。此函式能執行除啟動程序之外的所有操作,啟動程序在之後進行處理。copy_process 內的第一步是驗證 CLONE 標誌以確保這些標誌是一致的。如果不一致,就會返回 EINVAL 錯誤。接下來,詢問 Linux Security Module (LSM) 看當前任務是否可以建立一個新任務。

do_fork原始碼如下

long do_fork(unsigned long clone_flags,
         unsigned long stack_start,
         unsigned long stack_size,
         int __user *parent_tidptr,
         int __user *child_tidptr)
{
    struct task_struct *p;
    int trace = 0;
    long nr;

    // Determine whether and which event to report to ptracer.  When called from kernel_thread or CLONE_UNTRACED is explicitly requested, no event is reported; otherwise, report if the event for the type of forking is enabled.

    if (!(clone_flags & CLONE_UNTRACED)) {
        if (clone_flags & CLONE_VFORK)
                  trace = PTRACE_EVENT_VFORK;
         else if ((clone_flags & CSIGNAL) != SIGCHLD)
                  trace = PTRACE_EVENT_CLONE;
         else
                  trace = PTRACE_EVENT_FORK;

    if(likely(!ptrace_event_enabled(current, trace)))
            trace = 0;
    }

    //呼叫 copy_process建立程序
    p = copy_process(clone_flags, stack_start,   
                     stack_size,child_tidptr, 
                     NULL, trace);
// Do this prior waking up the new thread - the thread pointer might get invalid after that point, if the thread exits quickly.

    if (!IS_ERR(p)) {   /*判斷p的有效性*/ 
            struct completion vfork;
            struct pid *pid;

            trace_sched_process_fork(current, p);

            pid = get_task_pid(p, PIDTYPE_PID);
            nr = pid_vnr(pid);/*返回p的名稱空間的pid*/  

       /*如果設定父程序的TID,將nr放到parent_tidptr地址*/
            if (clone_flags & CLONE_PARENT_SETTID)
                  put_user(nr, parent_tidptr);

       /*如果設定了該標誌,初始化程序中的completion結構*/  
            if (clone_flags & CLONE_VFORK) {
                 p->vfork_done = &vfork;
                 init_completion(&vfork);
                 get_task_struct(p);
            }

         /*喚醒函式,將程序入執行佇列,建立程序copy_process之後並未執行.返回到do_fork中,將新建立程序加入到執行佇列中等待被執行。*/  
            wake_up_new_task(p);

/* forking complete and child started to run, tell ptracer */
            if (unlikely(trace))
                 ptrace_event_pid(trace, pid);

            if (clone_flags & CLONE_VFORK) {
                if (!wait_for_vfork_done(p, &vfork))
                ptrace_event_pid
               (PTRACE_EVENT_VFORK_DONE, pid);
            }

            put_pid(pid);
    } else {
            nr = PTR_ERR(p);
    }
     return nr;
}

接下來,呼叫 dup_task_struct 函式(在 ./linux/kernel/fork.c 內),這會分配一個新 task_struct 並將當前程序的描述符複製到其內。在新的執行緒堆疊設定好後,一些狀態資訊也會被初始化,並且會將控制返回給 copy_process。控制回到 copy_process 後,除了其他幾個限制和安全檢查之外,還會執行一些常規管理,包括在新 task_struct 上的各種初始化。

部分dup_task_struct原始碼如下:

//dup_task_struct根據父程序建立子程序核心棧和程序描述符:
static struct task_struct *dup_task_struct(struct task_struct *orig)  
{  
  struct task_struct *tsk;  
  struct thread_info *ti;  
  int node = tsk_fork_get_node(orig);  
  int err;  
  /*建立程序描述符物件*/
  tsk = alloc_task_struct_node(node);  
  if (!tsk)  
         return NULL;  
/*給新程序分配一個新的核心堆疊*/  
  ti = alloc_thread_info_node(tsk, node);  
  if (!ti) /*如果thread info結構沒申請到,釋放tsk*/  
         goto free_tsk;  

  /*複製task_struct,使子程序描述符和父程序一致*/  
  err = arch_dup_task_struct(tsk, orig);
  if (err)  
         goto free_ti;  

  tsk->stack = ti; /*task對應棧*/  
#ifdef CONFIG_SECCOMP  
   /* We must handle setting up seccomp filters once   
      we're under the sighand lock in case orig has 
      changed between now and then. Until then,filter 
      must be NULL to avoid messing up  the usage 
      counts on the error path calling free_task.  
   */  
    tsk->seccomp.filter = NULL;  
#endif  

   /*初始化thread info結構*/  
   setup_thread_stack(tsk, orig);//使子程序thread_info內容與父程序一致但task指向子程序task_struct
   clear_user_return_notifier(tsk);  
   clear_tsk_need_resched(tsk);  
   set_task_stack_end_magic(tsk);  

#ifdef CONFIG_CC_STACKPROTECTOR  
   tsk->stack_canary = get_random_int(); 
   /*初始化stack_canary變數*/  
   .......

之後,會呼叫一系列複製函式來複制此程序的各個方面,比如複製開放檔案描述符(copy_files)、複製符號資訊(copy_sighand 和 copy_signal)、複製程序記憶體(copy_mm)以及最終複製執行緒(copy_thread)。

最終複製執行緒(copy_thread)部分原始碼

//新程序有自己的堆疊且會根據task_pt_regs中的內容進行修改。  
int copy_thread(unsigned long clone_flags, 
                unsigned long sp, unsigned long arg,   
                struct task_struct *p)  
   {  
      struct pt_regs *childregs = task_pt_regs(p);  
      struct task_struct *tsk;  
      int err;  
    //排程到子程序時的核心棧頂  
      p->thread.sp = (unsigned long) childregs;
      p->thread.sp0 = (unsigned long) (childregs+1);  
      memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));  

      if (unlikely(p->flags & PF_KTHREAD)) 
      {  
      /* kernel thread */  
      memset(childregs, 0, sizeof(struct pt_regs));  
  p->thread.ip =(unsignedlong)ret_from_kernel_thread;  
        task_user_gs(p) = __KERNEL_STACK_CANARY;  
        childregs->ds = __USER_DS;  
        childregs->es = __USER_DS;  
        childregs->fs = __KERNEL_PERCPU;  
        childregs->bx = sp;  /* function */  
        childregs->bp = arg;  
        childregs->orig_ax = -1;  
      childregs->cs = __KERNEL_CS | get_kernel_rpl();       childregs->flags = X86_EFLAGS_IF | X86_EFLAGS_FIXED;  
    p->thread.io_bitmap_ptr = NULL;  
        return 0;  
   }  
   *childregs = *current_pt_regs();//複製核心堆疊  
   childregs->ax = 0;//eax暫存器值強置為0,即子程序返回到使用者態時返回值為0  
   if (sp)  
      childregs->sp = sp;//sp為父程序傳給子程序的使用者態棧,可以與父程序共享  

   p->thread.ip = (unsigned long) ret_from_fork; //排程到子程序時的第一條指令地址  
   task_user_gs(p) = get_user_gs(current_pt_regs());   
   p->thread.io_bitmap_ptr = NULL;  
   tsk = current;  

之後,這個新任務會被指定給一個處理程式,同時對允許執行程序的處理程式進行額外的檢查(cpus_allowed)。新程序的優先順序從父程序的優先順序繼承後,執行一小部分額外的常規管理,而且控制也會被返回給 do_fork。在此時,新程序存在但尚未執行。do_fork 函式通過呼叫 wake_up_new_task 來修復此問題。此函式(可在 ./linux/kernel/sched.c 內找到)初始化某些排程程式的常規管理資訊,將新程序放置在執行佇列之內,然後將其喚醒以便執行。最後,一旦返回至 do_fork,此 PID 值即被返回給呼叫程式,程序完成。

二、程序建立總結

實際上,使用者空間的暫存器、使用者態堆疊等資訊在切換到核心態的上下文時儲存在核心棧中,父程序在核心態(dup_task_struct)複製出子程序,但子程序作為一個獨立的程序,之後被排程執行時必須有一個指令地址,程序切換時,ip地址及當前核心棧的位置esp都存在於thread_info中,由copy_thread設定其thread.ip指向ret_from_fork作為子程序執行的第一條語句,並完成了核心態到使用者態的切換。

程序建立由系統呼叫來建立新程序,歸根結底都是呼叫do_fork來實現。do_fork主要就是呼叫copy_process。

copy_process()主要完成程序資料結構,各種資源的初始化。初始化方式可以重新分配,也可以共享父程序資源,主要根據傳入clone_flags引數來確定。將task_struct結構體分配給子程序,併為其分配pid,最後將其加入可執行佇列中。

dup_task_struct()為子程序獲取程序描述符

copy_thread()函式將父程序核心棧複製到子程序中,同時設定子程序排程後執行的第一條語句地址為do_frok返回,並將儲存返回值的暫存器eax值置為0,因此子程序返回為0,而父程序繼續執行之後的初始化,最後返回子程序的pid(tgid)。