一張圖看懂Linux內核中Percpu變量的實現

2021-01-19 51cto

我們在使用各種程式語言進行多線程編程時,經常會用到thread local變量。

所謂thread local變量,就是對於同一個變量,每個線程都有自己的一份,對該變量的訪問是線程隔離的,它們之間不會相互影響,所以也就不會有各種多線程問題。

正確的使用thread local變量,能極大的簡化多線程開發。所以不管是c/c++/rust,還是java/c#等,都內置了對thread local變量的支持。

但你知道嗎,不僅是在程式語言中,在linux內核中,也有一個類似的機制,用來實現類似的目的,它叫做percpu變量。

percpu變量,顧名思義,就是對於同一個變量,每個cpu都有自己的一份,它可以被用來存放一些cpu獨有的數據,比如cpu的id,cpu上正在運行的線程等等,因該機制可以非常方便的解決一些特定問題,所以在內核編程中被廣泛使用。

好奇的你們肯定都在問,它是怎麼實現的呢?

我們先不管細節,先來看一張圖,這樣從全局的角度來了解下它的實現。

從上圖中我們可以看到,各種源文件中通過DEFINE_PER_CPU的方式,定義了很多percpu變量,這些變量根據vmlinux.lds.S中的相關定義,會被linker聚合在一起,然後放到最終vmlinux文件的,一個名叫.data..percpu的section裡。

這些變量的地址也是被特殊處理過的,它們從零開始依次遞增,這樣一個變量的地址,就是該變量在整個vmlinux的.data..percpu區裡的位置,有了這個位置,然後再知道某個cpu的percpu內存塊的起始地址,就可以很方便的計算出該cpu對應的該變量的運行時內存地址。

linux內核在啟動時,會先把vmlinux文件加載到內存中,然後根據cpu的個數,為每個cpu都分配一塊用於存放percpu變量的內存區域,之後把vmlinux中的.data..percpu section裡的內容,拷貝到各個cpu的percpu內存塊的static區域裡,最後將各percpu內存塊的起始地址放到對應cpu的gs寄存器裡。

到這裡有關percpu變量的初始化工作就已經結束了。

當我們在訪問percpu變量時,只需要將gs寄存器裡的地址,加上我們想要訪問的percpu變量的地址,就能得到在該cpu上,該percpu變量真實的內存地址。

有了這個地址,我們就可以方便的操作這個percpu變量了。

上圖中重點描述的是那些,在內核編譯期就已經確定的percpu變量,這些變量是靜態的,是不會隨著時間的推移而動態的增加或減少的,所以它們在內核初始化時,就直接被拷貝到了各個percpu內存塊的static區。

除了這種靜態percpu變量,還有另外兩種percpu變量。

其中一種是內核模塊中的靜態percpu變量,它雖然也是在編譯期就能確定的,但由於內核模塊動態加載的特性,它不是完全靜態的,內核為這種percpu變量在percpu內存塊中單獨開闢了一個區域,叫reserved區,當內核模塊被加載到內存時,其靜態percpu變量就會在這個區域分配內存。

另外一種percpu變量就是純動態的percpu變量,它是在運行時動態分配的,它使用的內存是上圖中的dynamic區。

static區的大小是在編譯期就算好的,是固定不變的,reserved區也是固定不變的,但其大小是預估的,dynamic區是可以動態增加的。

雖然這三種percpu變量的分配方式不同,但它們的內在機制本質上都是一樣的,所以這裡我們只講內核裡的靜態percpu變量,對其他兩種方式感興趣的同學,可以參考內核源碼自己研究下。

下面我們就用一個具體的例子,來看下percpu變量到底是怎麼實現的。

上圖中的current表示要獲取當前線程對象,它其實是一個宏,具體定義如下:

由上可見,current獲取的當前線程對象其實是一個名為current_task的percpu變量。

在get_current方法中,通過this_cpu_read_stable方法,獲取屬於當前cpu的current_task。

this_cpu_read_stable方法其實也是一個宏,它全部展開後是下面這個樣子:

在這裡,我們先不講宏展開後各語句到底是什麼意思,我們先跑個題。

讀過linux內核源碼的同學都知道,在linux內核中,宏使用的非常多,且比較複雜,如果我們對自己進行宏展開的正確性沒有信心的話,可以使用下面我介紹的這個方式,使用它,你可以非常容易的得到任意文件宏展開後的結果。

我們知道,一個程序的構建分為預處理、編譯、彙編、連結這些階段,而宏展開就發生在預處理階段。

各個階段在完成後,一般都會生成一個臨時文件給下一階段使用,這些臨時文件默認是不會保存到磁碟上的,但我們可以通過指定一些參數,告知gcc幫我們保留下來這些臨時文件,這樣我們就可以查看各個階段的生成內容了。

依據該思路,我們只要在編譯比如上面的net/socket.c文件時,加上這些參數,我們就能得到這些臨時文件,也就可以查看其預處理之後的宏展開是什麼樣子的了。

但是,如果只是為了查看單個文件的宏展開後結果,就保存下整個內核中,所有源文件編譯時的臨時文件,這是非常耗時且不划算的,那有沒有辦法可以想查看哪個文件的宏展開,就單獨編譯一次那個文件呢?

還真有。

其實說起來該方法也很簡單,我們只需要知道編譯某個文件時使用的編譯命令是什麼,這樣當我們需要查看這個文件的宏展開時,再使用這個編譯命令,且加上一些特定的參數,再編譯一遍,這樣就能得到該文件編譯過程中,各階段的臨時文件了。

那如何找到編譯各個源文件時使用的命令呢?

這個內核其實已經幫我們做好了。

當我們在編譯內核時,內核中每個文件被編譯時使用的命令,都會保存到一個對應的臨時文件裡,比如上面net/socket.c文件的編譯命令就保存在下面的文件裡:

net/socket.c的編譯命令就是上圖中的第一行,從gcc開始到該行結束的部分。

這個編譯命令夠複雜吧,但我們不用管,我們只用知道,使用該命令,就可以將net/socket.c編譯成net/socket.o。

現在我們在該命令的基礎上,加上-save-temps=obj參數,告知gcc在編譯時保留下各階段的臨時文件,具體操作流程如下:

由上可見,加上-save-temps=obj參數後,該編譯過程多生成兩個文件,而net/socket.i就是gcc預處理之後的文件。

打開net/socket.i,並找到我們需要的get_current方法:

看上圖中的選中部分,其內容和我們自己宏展開後的結果,是完全一樣的。

這個方法還不錯吧。

當然,我們還可以通過反編譯的方式,進一步確認下宏展開後確實是這樣:

由上可見,宏展開後其實主要就是一條mov指令,其中current_task變量地址的值為0x16d00。

該指令的意思是,將gs寄存器裡的地址,和current_task的地址相加,然後將相加後地址指向的內存空間裡的值,移動到rax裡。

這個和我們上面提到的,percpu的實現機制是一致的。

好,我們回到上文中斷的部分,來繼續看下get_current方法裡宏展開後各語句的意思。

上文講到,get_current方法裡的this_cpu_read_stable方法宏展開後主要是一條asm語句,可能有些同學對該語句不太熟悉,它其實並不是c語言標準規範裡的語法,而是gcc對c標準的擴展,通過asm語句,我們可以在c中直接執行彙編指令。

有關其詳細的語法規則,可以參考以下連結:

https://gcc.gnu.org/onlinedocs/gcc/Using-Assembly-Language-with-C.html#Using-Assembly-Language-with-C

不關心細節的同學可以不用去看具體語法,我們只要知道該asm語句的意思是,獲取current_task的地址,將該地址與gs段寄存器裡的基礎地址值相加,得到一個最終的地址,然後通過mov指令,將該最終地址指向的內存的值,放到pfo_val__變量裡。

該指令執行完畢後,pfo_val__變量裡存放的值,就是當前cpu執行的當前線程對象struct task_struct的地址,也就是說,pfo_val__變量為當前正在執行的線程對象的指針。

那為什麼通過這種方式,得到的就是當前cpu正在執行的當前線程對象的指針呢?

這個其實上文我們已經講過了,關鍵點在於gs寄存器中存放的是當前cpu的percpu內存塊的起始地址,而current_task的地址表示的又是,current_task變量在任意percpu內存塊的位置,所以這兩個地址一相加,得到的自然就是當前cpu的current_task變量的當前值了。

理論上是如此,不過我們還是通過源碼角度再看下。

首先我們來看下current_task變量的定義:

DEFINE_PER_CPU還是一個宏,其展開後如下:

在宏展開後的變量定義中,最重要的是指定該變量的section為.data..percpu。

我們再看什麼地方使用了這個section:

由上圖可見,PERCPU_INPUT宏裡使用了該section,而PERCPU_INPUT宏又被下面的PERCPU_VADDR宏使用。

我們再來看下PERCPU_VADDR宏在哪裡使用:

由上可見PERCPU_VADDR宏又在vmlinux.lds.S文件中使用。

vmlinux.lds.S是一個連結腳本,在連結階段,linker會根據vmlinux.lds.S裡的定義,把相同section的內核變量或方法,聚合起來,放到最終輸出文件vmlinux的對應section裡。

比如上面的PERCPU_VADDR宏就是說,把所有源文件中的屬於各種.data..percpu section的變量提取出來,然後依次放入到輸出文件vmlinux的.data..percpu的section中。

上圖中需要注意的是,在調用PERCPU_VADDR時,傳入的vaddr參數是0,它表示vmlinux中.data..percpu section裡存放的變量地址是從0開始,依次遞增的。

這個我們之前也說過,該地址是用來表示該變量在.data..percpu section裡的位置,也就是說,該地址表示的是該變量在運行時的,各cpu的percpu內存塊裡的位置。

vmlinux裡.data..percpu section存放的變量地址是從0開始的,這個我們可以通過__per_cpu_start的值得到確認:

另一個需要注意的是,__per_cpu_load的地址值是正常的內核編譯地址,它用來指定,當vmlinux被加載到內存後,vmlinux裡的.data..percpu section所處內存的位置:

綜上可知,PERCPU_VADDR宏的作用是,將所有源文件中屬於各個.data..percpu section的變量聚合起來,然後依次放到輸出文件vmlinux的.data..percpu section中,且section中的變量地址是從0開始的,這樣這些變量的地址就表示其所處的該section的位置。

另外,PERCPU_VADDR宏裡還定義了三個地址值:

__per_cpu_load表示當vmlinux被加載到內存時,vmlinux中的.data..percpu section所處內存位置。__per_cpu_start的值是0。__per_cpu_end的值是vmlinux中的.data..percpu section的結束地址。

這樣通過__per_cpu_load就可以知道當vmlinux被加載到內存時,.data..percpu section所處位置,通過__per_cpu_end - __per_cpu_start,就可以知道.data..percpu section的大小。

由上可見,內核中的percpu變量佔用內存大小差不多是170KiB。

到這裡,有關percpu變量的所有準備工作都已做好,下面我們來看下,在內核vmlinux文件啟動過程中,它是怎麼利用這些信息,為各個cpu分配percpu內存塊,初始化內存塊數據,及設置內存塊地址到gs寄存器的。

通過搜索__per_cpu_load, __per_cpu_start, __per_cpu_end我們可以知道,這些內存分配工作是在setup_per_cpu_areas方法裡完成的:

該方法的文件路徑和大致樣子就如上圖所示,為了方便查看,我刪除了很多不必要的代碼。

由於該方法的邏輯非常複雜,這裡我們就不詳細講解每行代碼了,只看些關鍵部分。

該方法及相關方法的主要作用是為每個cpu分配自己的percpu內存塊:

然後將vmlinux的.data..percpu section拷貝到各個cpu的percpu內存塊裡:

這裡的ai->static_size就是__per_cpu_end減去__per_cpu_start的值。

最後設置各cpu的percpu內存塊的起始地址值到各自cpu的gs寄存器裡:

上圖中需要注意的是gs寄存器的設置方式,我們知道,在x86_64模式下,段寄存器CS, DS, ES, SS基本上是不用了,FS和GS雖然還在用,但使用傳統的mov指令等方式設置FS和GS值,支持的地址空間只能到32位,如果想要支持到64位,必須通過寫MSR的形式來完成。

這個在AMD官方文檔裡有詳細說明:

在設置完gs寄存器的值後,我們再回頭來想想,內核是如何獲取當前cpu的current_task變量的地址值的呢:

mov %gs:0x16d00, %rax

現在這行代碼的意思你就完全明白了吧。

到這裡,percpu部分的內容就已經完全講完了,但有關如何獲取當前cpu正在運行的當前線程的current_task值,還有一點沒講到。

我們知道,一個cpu是可以運行多個線程的,如果想要讓current_task這個percpu變量,指向當前cpu的當前線程,那在線程切換的時候必須要更新一下current_task:

如上。

現在,有關percpu變量的知識,你是否已經完全了解了呢,如果還有疑問,可以再去看看文章開始我畫的那張圖,或者給我留言,我們可以一起討論。

本文轉載自微信公眾號「卯時卯刻」,可以通過以下二維碼關注。轉載本文請聯繫卯時卯刻公眾號。

【編輯推薦】

【責任編輯:

武曉燕

TEL:(010)68476606】

點讚 0

相關焦點

  • 深入作業系統,從內核理解網絡包的接收過程(Linux篇)
    對於Linux來說,它實現的是鏈路層、網絡層和傳輸層這三層。在Linux內核實現中,鏈路層協議靠網卡驅動來實現,內核協議棧來實現網絡層和傳輸層。內核對更上層的應用層提供socket接口來供用戶進程訪問。
  • 什麼Linux,Linux內核及Linux作業系統
    但是它有不能稱為一個真正的或者說可用於生產的作業系統,因為它只實現了對計算機資源的簡單管理(也就是實現了一個作業系統內核),卻沒有編譯工具等其它作業系統必備的工具集成到其中。圖4 CentOS發行版Red Hat和CentOS等作業系統發行版主要應用在企業服務中,更多的應用在服務端業務中,比如Web服務和雲計算等等。
  • 「正點原子Linux連載」第三十七章Linux內核移植
    37.2.1修改頂層Makefile修改頂層Makefile,直接在頂層Makefile文件裡面定義ARCH和CROSS_COMPILE這兩個的變量值為arm和arm-linux-gnueabihf-,結果如圖37.2.1所示:圖37.2.1 修改頂層Makefile
  • 當運行 Linux 內核的機器死機時……
    作者 | dog250 責編 | 張文頭圖 | CSDN 下載自視覺中國曾經寫過一個模塊,當運行 Linux 內核的機器死機時,SSH肯定無法登錄了,但只要它還響應中斷,就盡力讓它可以通過網絡帶回一些信息。
  • Linux中的DTrace:BPF進入4.9內核
    DTrace 是 Solaris 系統中的高級追蹤器。對於長期使用 DTrace 的用戶和專家,這將是一個振奮人心的裡程碑!現在在 Linux 系統上,你可以在生產環境中使用安全的、低負載的定製追蹤系統,通過執行時間的柱狀圖和頻率統計等信息,分析應用的性能以及內核。
  • 簡單了解一下Linux 的內核結構
    Linux 的內核結構內核直接坐落在硬體上,內核的主要作用就是 I/O 交互、內存管理和控制 CPU 訪問。上圖中還包括了中斷和調度器,中斷是與設備交互的主要方式。中斷出現時調度器就會發揮作用。這裡的低級代碼停止正在運行的進程,將其狀態保存在內核進程結構中,並啟動驅動程序。
  • 從串口驅動到Linux驅動模型,想轉Linux的必會!
    這就是傳說中的串口控制臺。。這個串口的指令功能是由Uboot本身完成的。並不是linux下的串口驅動。引入此圖旨在讓讀者感性的認識到串口控制臺的功能是什麼。下面正式開始對串口打開。發送。接收函數的分析。這裡向前引用一個函數。就是linux內核中幾種2440晶片通用的串口發送函數s3c24xx_serial_start_tx。
  • Linux 內核報TCP SACK漏洞 CVE-2019-11477/78/79,需儘快處理
    上圖中用戶A通過13個100位元組的段發送1k字節的數據,每個段具有20位元組的TCP頭,總計是13個段。在接收端,用戶B接收了段1,2,4,6,8-13,而段3,5和7丟失,B沒有接收到。通過使用ACK號,用戶B告訴A,他需要段3,用戶A收到B接收到2,而沒有收到3,A將重新發送全部段,儘管B已經收到了4,6和8-13段。所以導致大量重複傳輸,性能低下。
  • 面試問了解Linux內存管理嗎?10張圖給你安排得明明白白!
    數據段數據段用來存放可執行文件中已初始化全局變量,換句話說就是存放程序靜態分配的變量和全局變量。BSS段BSS段包含了程序中未初始化的全局變量,在內存中 bss 段全部置零。當進程調用malloc等函數分配內存時,新分配的內存就被動態添加到堆上(堆被擴張);當利用free等函數釋放內存時,被釋放的內存從堆中被剔除(堆被縮減)棧 stack棧是用戶存放程序臨時創建的局部變量,也就是函數中定義的變量(但不包括 static 聲明的變量,static意味著在數據段中存放變量)。
  • Linux內核學習神器,Bochs安裝及常見問題解決
    bochs與虛擬化產品VMware和VirtualBox很像,但又有不同,後者更多的是一個產品,用於實現計算虛擬化,而前者則更多的是一個開發工具,用於作業系統的開發調試。在Ubuntu上的安裝bochs可以跨平臺安裝,可以安裝在Linux或者Windows等作業系統,Ubuntu(本文基於16.04版本)上安裝非常簡單,執行如下命令既可以安裝:sudo apt-get install bochs使用bochs我們以運行Linux0.12版本內核為例簡單介紹一下bochs的使用。
  • 「正點原子Linux連載」第五十二章Linux阻塞和非阻塞IO實驗
    阻塞式IO如圖52.1.1.1所示:圖52.1.1.1 阻塞IO訪問示意圖。圖52.1.1.1中應用程式調用read函數從設備中讀取數據,當設備不可用或數據未準備好的時候就會進入到休眠態。等設備可用的時候就會從休眠態喚醒,然後從設備中讀取數據返回給應用程式。
  • ARM64 Linux 內核頁表的塊映射
    作者 | 宋寶華 責編 | 張文頭圖 | CSDN 下載自視覺中國內核文檔 Documentation/arm64/memory.rst 描述了 ARM64 Linux 內核空間的內存映射情況,應該是此方面最權威文檔。
  • BPF和Go:在Linux中內省的現代方式
    然而,最令人印象深刻的是,新的BPF程序不僅能夠在處理數據包時運行,而且能夠響應其他內核事件,並在內核和用戶空間之間來回傳遞信息。這些變化為使用BPF的新方法提供了機會。一些過去需要通過編寫複雜而危險的內核模塊來實現的事情,現在可以相對簡單地通過BPF來完成。為什麼這麼好呢?
  • ARM平臺上實現Linux PPP撥號
    硬體平臺:億道Liod平臺(基於PXA270) 作業系統:嵌入式Linux本文引用地址:http://www.eepw.com.cn/article/201611/316817.htm 下面主要介紹一下如何在Liod平臺上進行ppp撥號,實現
  • 配置windows上的windbg,linux上的lldb,打入clr內部這一篇就夠了
    <1> 配置微軟公有符號符號其實就是pdb文件,我們在debug模式下編譯項目都會看到這個,它的作用會對dll進行打標,這樣在調試時通過pdb就能看到局部變量,全局變量,行號等等其他信息,在FCL類庫中的pdb文件就放在微軟的公有伺服器上,SRV*C:\mysymbols
  • PWM在ARM Linux中的原理和蜂鳴器驅動實例開發
    s3c2410_gpio_cfgpin(S3C2410_GPB0,S3C2410_GPB0_OUTP);s3c2410_gpio_setpin(S3C2410_GPB0,0);}else//如果輸入的參數大於0,就讓蜂鳴器開始工作,不同的參數,蜂鳴器的頻率也不一樣{//定義一些局部變量unsignedlongtcon;unsignedlongtcnt
  • 淺談內核的Makefile、Kconfig和.config文件
    Kconfig:一個文本形式的文件,內核的配置菜單。.config:編譯內核所依據的配置。2三者的語法1.Makefile參考:linux-3.4.2/drivers/Makefile作用:用來定義哪些內容作為模塊編譯,哪些條件編譯等。子目錄Makefile被頂層Makefile包含。
  • 【國家宣傳周】一張漫畫圖看懂,憲法是什麼?
    【國家宣傳周】一張漫畫圖看懂,憲法是什麼?喜歡此內容的人還喜歡原標題:《【國家宣傳周】一張漫畫圖看懂,憲法是什麼?》
  • 一張圖看懂如何快速反擊
    大家好,今天我們分享一張圖看懂如何快速反擊,希望大家喜歡!今天的信息圖表將會給大家展示從防守到快速進攻的重要性,其中提供了有用的實踐方法,以幫助球員提高比賽的攻防轉換中的能力,我們現在開始:信息圖表:圖1-防守到進攻轉換
  • Linux系統的環境變量
    設置變量對於一般人最實用的功能就是: 不用拷貝某些dll文件到系統目錄中了,而path 這一系統變量就是系統搜索dll文件的一系列路徑。1、Shell定義的環境變量Shell在開始執行的時候就已經定義了一些與系統工作環境有關的變量,用戶還可以重新定義這些變量。