彙編實現的memcpy和memset
- 2019 年 12 月 19 日
- 筆記
天天山珍海味的吃,也會煩。偶爾來點花生,毛豆小酌一點,也別有一番風味。
天天java, golang, c++, 咱們今天來點彙編調劑一下,如何?
通過這篇文章,您可以了解過:
- CPU暫存器的一些知識;
- 函數調用的過程;
- 彙編的一些知識;
- glibc 中 memcpy和memset的使用;
- 彙編中memcpy和memset是如何實現的;
閑話不多說,今天來看看彙編中如何實現memcpy
和memset
(腦子裡快回憶下你最後一次接觸彙編是什麼時候……)
函數是如何被調用的
棧的簡單介紹
- 棧對函數調用來說特別重要,它其實就是進程虛擬地址空間中的一部分,當然每個執行緒可以設置單獨的調用棧(可以用戶指定,也可以系統自動分配); 棧由棧基址(%ebp)和棧頂指針(%esp)組成,這兩個元素組成一個棧幀,棧一般由高地址向低地址增長,將數據壓棧時%esp減小,反之增大;
- 調用一個新函數時,會產生一個新的棧幀,即將老的%ebp壓棧,然後將%ebp設置成跟當前的%esp一樣的值即可。函數返回後,之前壓棧的數據依然出棧,這樣最終之前進棧的%ebp也會出棧,即調用函數之前的棧幀被恢復了,也正是這種機制支撐了函數的多層嵌套調用;
不管是寫Windows程式還是Linux程式,也不管是用什麼語言來寫程式,我們經常會把某個獨立的功能抽出來封裝成一個函數,然後在需要的地方調用即可。看似簡單的用法,那它背後是如何實現的呢?一般分為四步:
- 傳遞參數,通常我們使用
棧
來傳遞參數,先將所有參數都壓棧處理; - 保存所調用的函數的下面一條指令的地址,就是我們執行完要調用的函數,拿到結果後程式接著從哪裡繼續運行的位置,通常我們也將其壓入棧里保存;
- 跳轉到被調用的函數,進行前面所說的棧幀的切換,然後執行函數主體;
- 函數返回,清理當前棧,之前壓棧的元素都退棧,最終恢復到老的棧幀,返回在第二步保存的指令地址,繼續程式的運行。
函數調用規則
函數一般都會有多個參數,我們根據函數調用時,
- 參數壓棧的方向(參數從左到右入棧,還是從右到左入棧);
- 函數調用完是函數調用者負責將之前入棧的參數退棧,還是被調用函數本身來作等
這兩點(其實還有一點,就是程式碼被編譯後,生成新函數名的規則,跟我們這裡介紹的關係不大)來分類函數的調用方式:
- stdcall: 函數參數由右向左入棧, 函數調用結束後由被調用函數清除棧內數據;
- cdecl: 函數參數由右向左入棧, 函數調用結束後由函數調用者清除棧內數據;
- fastcall: 從左開始不大於4位元組的參數放入CPU的EAX,ECX,EDX暫存器,其餘參數從右向左入棧, 函數調用結束後由被調用函數清除棧內數據; 這種方式最大的不同是用暫存器來存參數,所有它fast。
glibc中的memcpy
我們先來看下glibc中的memcpy , 原型如下:
void *memcpy(void *dest, const void *src, size_t n);
從src拷貝連續的n個位元組數據到dest中, 不會有任何的記憶體越界檢查。
char dest[5] = {0}; char test[5] = {0,'b'}; char src[10] = {'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a', 'a'}; ::memcpy(dest, src, 6); std::cout << src << std::endl; std::cout << dest << std::endl; std::cout << test << std::endl;
大家有興趣的話可以考慮下上面的程式碼輸出是什麼?
彙編實現的memcpy
說來慚愧,彙編程式碼作者本人也不會寫。不過我們可以參考linux源碼裡面的實現,這相對還是比較權威的吧。
它的實現位於 arch/x86/boot/copy.S
, 文件開頭有這麼一行注釋Copyright (C) 1991, 1992 Linus Torvalds
, 看起來應該是大神親手寫下的。我們來看一看
GLOBAL(memcpy) pushw %si pushw %di movw %ax, %di movw %dx, %si pushw %cx shrw $2, %cx rep; movsl popw %cx andw $3, %cx rep; movsb popw %di popw %si retl ENDPROC(memcpy)
- CPU的眾多通用暫存器有%esi和%edi, 它們一個是源址暫存器,一個是目的暫存器,常被用來作串操作,我們的這個memcpy最終就是將%esi指向的內容拷貝到%edi中,因為這種程式碼在linux源碼中是被標識成了
.code16
, 所有這裡都只用到這兩個暫存器的低16位:%si和%di; - 程式碼的第一,二句保存當前的%si和%di到棧中;
- 這段程式碼實際上是fastcall調用方式,
void *memcpy(void *dest, const void *src, size_t n);
其中 dest 被放在了%ax暫存器,src被放在了%dx, n被放在了%cx; movw %ax, %di
, 將dest放入%di中,movw %dx, %s
,將stc放入%si中;- 一個位元組一個位元組的拷貝太慢了,我們四個位元組四個位元組的來,
shrw $2, %cx
,看看參數n裡面有幾個4, 我們就需要循環拷貝幾次,循環的次數存在%cx中,因為後面還要用到這個%cx, 所以計算之前先將其壓棧保存pushw %cx
; rep; movsl
,rep
重複執行movsl
這個操作,每執行一次%cx的內容就減一,直到為0。movsl每次從%si中拷貝4個位元組到%di中。這其實就相當於一個for循環copy;- 參數n不一定能被4整除,剩下的餘數,我們只能一個位元組一個位元組的copy了。
andw $3, %cx
就是對%cx取余,看還剩下多少位元組沒copy; rep; movsb
一個位元組一個位元組的copy剩下的內容;
glibc中的memset
我們先來看下glibc中的memset, 原型如下:
void *memset(void *s, int c, size_t n);
這個函數的作用是用第二個參數的最低位一個位元組來填充s地址開始的n個位元組,儘管第二個參數是個int, 但是填充時只會用到它最低位的一個位元組。
你可以試一下下面程式碼的輸出:
int c = 0x44332211; int s = 0; ::memset((void*)&s, c, sizeof(s)); std::cout << std::setbase(16) << s << std::endl; // 11111111
彙編實現的memset
我們還是來看一下arch/x86/boot/copy.S
中的實現:
GLOBAL(memset) pushw %di movw %ax, %di movzbl %dl, %eax imull $0x01010101,%eax pushw %cx shrw $2, %cx rep; stosl popw %cx andw $3, %cx rep; stosb popw %di retl ENDPROC(memset)
- 不同於
memcpy
,這裡不需要%si源址暫存器,只需要目的暫存器,所以我們先將其壓棧保存pushw %di
; - 參考
void *memset(void *s, int c, size_t n)
可知,參數s被放在了%ax暫存器;參數n被放在了%cx暫存器; 參數c被放在了%dl暫存器,這裡只用到了%edx暫存器的最低一個位元組,所以對於c這個參數不管你是幾個位元組,其實多只有最低一個位元組被用到; - 和
memcpy
一樣,一次一個位元組的操作太慢了,一次四個位元組吧,假設參數c的最低一個位元組是0x11, 那麼一次set四個位元組的話,就是0x11111111: movzbl %dl, %eax imull $0x01010101,%eax
imull $0x01010101,%eax
這句話就是把0x11
變成0x11111111
rep; stosl,
rep重複執行
stosl 這個操作,每執行一次%cx的內容就減一,直到為0。stosl每次從%eax中拷貝4個位元組到%di中。這其實就相當於一個for循環copy;- 參數n不一定能被4整除,剩下的餘數,我們只能一個位元組一個位元組的copy了。
andw $3, %cx
就是對%cx取余,看還剩下多少位元組沒copy; - rep; stosl 一個位元組一個位元組的copy剩下的內容;
好了,到這裡這次的內容就結束了,有疏漏之處,歡迎指正。