网页资讯视频图片知道文库贴吧地图采购
进入贴吧全吧搜索

 
 
 
日一二三四五六
       
       
       
       
       
       

签到排名:今日本吧第个签到,

本吧因你更精彩,明天继续来努力!

本吧签到人数:0

一键签到
成为超级会员,使用一键签到
一键签到
本月漏签0次!
0
成为超级会员,赠送8张补签卡
如何使用?
点击日历上漏签日期,即可进行补签。
连续签到:天  累计签到:天
0
超级会员单次开通12个月以上,赠送连续签到卡3张
使用连续签到卡
03月01日漏签0天
c语言吧 关注:801,795贴子:4,376,674
  • 看贴

  • 图片

  • 吧主推荐

  • 视频

  • 游戏

  • 1 2 3 4 5 下一页 尾页
  • 71回复贴,共5页
  • ,跳到 页  
<<返回c语言吧
>0< 加载中...

深夜布道!C调用的汇编级解释!以及stdcall和cdcel 不加精对不起我!

  • 只看楼主
  • 收藏

  • 回复
  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
声明:
写给新手的!关于这个我相信有的老鸟也不一定那么清楚,所以老鸟亦可作为参考!
看时间决定~先写下题目!然后能写多少写多少!因为是深夜我睡没着闲,所以不知道多久睡意挡不住就睡觉去了,建议各位兄弟姐妹在我说回帖允许之前不要回帖,以保证整篇文章的清晰性~因为我基本上要做一个对新手来说类似教程级的篇文章,所以完整性很重要!
为了不让自己没稿子满嘴跑火车,先定下如下大纲:
1.80X86 32位汇编基础以及寄存器设定
2.栈帧与C函数调用
3.函数调用的汇编级解释以及栈图
4.stdcall和cdcel



  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
那么开始了!
1.80X86 32位CPU的编程模型programming model
80X86有16个通用寄存器register。从某种程度上来说介绍80X86的CPU编程模型,就是介绍这16个寄存器。没听过CPU寄存器这名字的童鞋不用看下去了请回家睡觉去。本文基本上适合大抵知道汇编怎么回事的童鞋而不是完全的汇编白痴。
另外,介绍的是32位汇编,请把80X86的16位汇编先忘记掉,这么短的文章不可能介绍完汇编,而是给出汇编最基本的东西和最简单的抽象。可能的话稍微解释下16位汇编。
eax ebx ecx edx
这4个寄存器是通用寄存器。用来暂存数据的地方。
esi edi(extension source index, extension destination index.)
它们也可以用来暂存数据。更一般的是伴随串指令使用。
esp ebp(extension stack pointer, extension base pointer)
栈指针寄存器和基址指针寄存器。关于栈和过程调用,最重要的寄存器就是这两个了!绝对不要忘掉这两个!
eip(extension instruction pointer, or program count)
指令指针寄存器!这就是“顺序存储控制”的核心!又称程序计数器!
eflag
标志寄存器。算术、逻辑及相关指令运算会影响该标志寄存器中的位。这个寄存器很重要也很麻烦。
以上!就是32位汇编(又称平坦地址模式汇编)会使用到的所有寄存器,一共十个,都是32位的。啊不是说十六个寄存器吗?
对,还有6个寄存器,分别名为:
cs ;代码段寄存器code segment
ds ;数据段寄存器data segment
ss ;栈寄存器stack segment
es fs gs; 附加段寄存器
这6个寄存器都是16位寄存器。即使是现今的80686 32位系统中,它们仍然是16位的。这些段寄存器在8086中用来对内存地址进行段指定。有8086 16位汇编知识的同学都知道怎么回事,...还是解释一下吧,8086是16位CPU,而地址线是20位。20根地址线表明能寻址的空间是2^20也就是1M(1024 * 1024).16位不够表达1M的地址空间,因此由“段*16+偏移”得到内存地址值。
但是在32位系统中,这些段寄存器已经不怎么使用了。总之32位汇编不需要关注这些寄存器,因为32位系统CPU和各寄存器是32位,地址线也是32位,一个32位值足够表达32位寻址空间。...实际上这些段寄存器在32位系统中是同一个值,用来指向某个索引表,但这是本文不需要在此关注的东西。
以上,16个寄存器介绍完毕!接下来介绍简洁的编程模型抽象!


2026-03-01 14:04:11
广告
不感兴趣
开通SVIP免广告
  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
由于是简单而本质的抽象,因此我们不考虑分页机制、MMU(memory management unit)之类的。正是如此,它们本来对于我们就是透明的。
所以内存就被考虑为一个从编号(地址)0开始、以编号(地址)0xffff ffff结束的字节序列。每一个字节都被顺序地编号。编号就是字节的地址。
在32位FLAT模式汇编中,本来就是如此。
在程序加载入内存后,程序的指令和数据都按某种方式存放在内存里面。要访问和执行他们,只需要知道他们的地址就可以了。
最重要的东西登场,它就是eip,指令指针寄存器,或称程序计数器。eip中的值程序员无法修改(嗯,可是汇编程序员呢?汇编程序员也无法修改它的值吗?废话,汇编程序员也是程序员啊!),它的值就是下一条即将执行的指令的地址。就是说eip永远指向下一条指令。
然后就是esp,它指向栈的栈顶。当向栈压入数据或从栈弹出数据时,esp的值不断变化,但无论如何变化,它都指向栈顶。
最后就是ebp,它用来把栈中的某个地址作为基址(基本地址,这样理解就是了),它用来标识栈中的某个固定位置,因此可以通过它访问这个固定位置附近的数据。
80X86的栈是向下增长的。也就是说,当向栈压入4个字节的数据时,esp = esp - 4; 当从栈中弹出4个字节时,esp = esp + 4。
以上!多么幸福的事情啊,32位汇编只需要在意这3个寄存器就可以了!(标志寄存器也挺重要的啊!但是跟本文要陈述的东西没太大关系,略)。



  • 幻の上帝
  • 葱の帝球
    15
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
等了半个小时不见下文忍不住插一下楼。。。


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼

这你妹,解释下上图代表什么意思...纯粹照顾完全的新手。
首先,那一排格子代表内存空间中的一小段,每个格子代表4个字节。右边的十六位数值代表方格的地址。格子中间的“...”代表格子的内容。
图中地址是从下往上增长的。
esp永远指向栈顶。一开始它指向地址为0x0063 fff4的字节。然后向栈压入4个字节。
对80X86来说,指令就是push ...;
数据压入后,esp指向0x0063 fff0。这是新的栈顶。
弹出数据跟上面的过程相反。esp中的值会增加。
...这你妹,我画这干嘛,多此一举...好像画了图也没能说的更明白或者表达更深层次的意思啊。总之就是这样了,esp永远指向栈顶,记住就OK。


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
关于80X86 32位CPU汇编模型就讲上面这些了。之所以讲这么少,因为这就是最基本的和最本质的内容,讲多了反而把重点搞没了。
总结就是记住3个寄存器。eip, esp, ebp。记住他们的意义就可以了。
2.栈帧与C函数调用
关于这个其实没有什么好讲的。
关于计算机,最重要的三个抽象是什么?答案是虚拟地址空间、进程、文件。
一个进程就是一个运行中的程序,或者被加载到内存中的程序。现代操作系统使进程看上去独占了所有的系统资源,但实际上系统中运行着多个进程。
所以从一个进程的视角看去,它独占了系统中的所有内存资源和CPU资源。对于32位系统虚拟地址空间被抽象为编号0~0xffff ffff的字节序列,它是平坦的,线性的,被系统抽象了的,所以叫它平坦地址或线性地址、虚拟地址。
对于Linux来说,保留高1G为系统使用。0-3G空间被应用程序也就是进程独占。
对于一个被加载了的程序也就是进程,其在内存中的分布为:
栈
共享内存段
自由存储区(堆)
BSS段
数据段
只读数据段                        
代码段                             
栈向下增长。
每一个函数调用,都是一个栈帧。
以下代码:
int add(int x, int y)
{
    int z;
    z = x + y;
    return z;
}
int main(int argc, char* argv[])
{
    add(3, 5);
    return 0;
}
那么main函数是一个栈帧,add是一个栈帧。
当程序运行时,main函数栈帧先被建立,这个栈帧在高地址。然后调用add函数。此时add函数栈帧被建立,在低地址。当程序执行流进入add函数时,add函数内的局部变量在add函数栈帧中被建立。然后add返回。当add函数返回,此时add函数栈帧被销毁,同时add函数内的局部变量也被销毁。所以,C编程原则告诉我们:永远不要返回一个指向局部对象的指针。也就是说如下代码是错误的:
int* getNumber(void)
{
    int a = 3;
    return &a;
}
那么运行时的栈是什么样子的呢?它是一个随着运行,不断增长(进入新的函数调用)和缩短(函数返回)的动态影像。
OK,关于C栈帧就说到这里,完毕。


  • 寻路m
  • 毛蛋
    1
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
不是故意水的,等了接近一个小时了。


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
3.函数调用的汇编级解释以及栈图
终于到这里了!开始本篇最重要的内容。
累。我可是全手工打码的啊。
首先复习两个80X86汇编指令,call和ret.
先来一段汇编代码。很简单,有注释。
请注意。不同的汇编编译器使用不同的文法。MASM、NASM、gcc后端汇编编译器,它们的文法几乎完全不一样。尤其是gcc后端,他妹的那文法那个汗。
这里使用的是MASM.学习汇编的话用MASM还是NASM都没关系,学了之后用什么都一样,因为那只是文法方面的东西。指令助记符一般也不会有太多改变。如果真的写汇编代码的话,我想我倾向于使用NASM.
汇编语句分为指令(instruction)、指示性语句(directive)、和宏(macro).
只有指令是真正的机器代码。指示性语句是编译器处理的东西。宏是一堆指令性语句或指示性语句。
以下代码使用MASM。
.386                                             ;386系统
.MODEL FLAT                                     ;32位平坦地址模式
Exit PROTO NEAR32 stdcall, dwPara:DWORD         ;退出函数原型
                                                ;Exit是函数名,dwPara是函数参数
.STACK    4096                                   ;保留4096字节栈空间
.DATA                                            ;数据段,定义全局变量
number1     DWORD    11111111h                  ;定义变量number1,大小4字节
number2     DWORD     22222222h                  ;定义变量number2, 大小4字节
.CODE                                  ;程序代码
Init PROTO NEAR32                     ;定义函数Init
        mov    number1, 0             ;假设该指令地址为0x0040 0000



2026-03-01 13:58:11
广告
不感兴趣
开通SVIP免广告
  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
        mov    number2, 0
        ret                           ;函数Init返回
Init ENDP                            ;函数Init结束
_start:                              ;相当于main函数
        call    Init                 ;调用函数Init,此指令地址为0x0040 000f
         ......                       ;该处指令地址为0x0040 0014
       
        INVOKE Exit, 0                ;调用Exit退出
PUBLIC _start                        ;公开入口点
END                                   ;程序结束
其实代码不用看的...
假设程序被加载入内存,这时esp被初始化,然后esp指向栈顶。设此时栈顶地址为0x0063 00f8.一切为了说明方便哈。总之程序加载后,栈被初始化,也就是esp被初始化,esp会指向内存中的某个地址,并以这个地址作为栈的起始。
eip始终指向执行流,也就是“下一条指令”。
这里说明一下。程序一旦加载,所有的指令、全局变量都被载入内存并有了确切的内存地址(程序加载前,或者说程序没有运行时,只是硬盘上的一个可执行文件对吧。程序运行前有一个系统加载动作,这个加载由操作系统完成)。这个我的另一篇BLOG《程序员的基本概念》里面略提过。清楚加载细节的是操作系统开发者,同时涉及到编译器和链接器。要更明白这个问题请参照《Linker and Loader》。
那么程序加载。栈初始化了。数据区域在内存中开辟出来了,全局变量被给予确切地址(这里是虚拟地址,因为这是一个进程,它的地址只管在虚拟地址空间中给就可以了,虚拟地址到物理地址的映射由操作系统和MMU完成)。代码段(也就是要执行的指令)也被放入内存中并给予确切地址。eip指向代码段的开始,并开始执行程序...
所以eip只管指向某个内存地址,这个内存地址存储着程序员编写的指令,然后CPU把指令取出来执行就是了。所以计算机叫做“顺序存储控制机”。对不起我啰嗦了。
好的。我们假设了,在程序加载后,esp被初始化为0x0063 00f8,并假设了mov number1, 0这个指令的地址在0x0040 0000,根据这个假设的地址和每个指令码的长度(这些指令都放在代码段,而且一个一个指令就是挨着放的),推断出call指令的地址是0x0040 000f,call指令的下一条指令的地址是0x0040 0014(因为这个call指令的长度占用5个字节,0x0040 000f + 5 = 0x0040 0014)。这里不算我对指令长度的计算错误,总之假设我的地址计算是正确的。
OK开始了。程序已经加载。那么开始程序执行。eip首先指向call指令,因为_start开始那里就是call指令。嗯,eip就是一个32位寄存器,这个寄存器里面的值永远是即将执行的指令的内存地址,这时eip里面的值是0x0040 000f。
call指令执行!该指令首先将下一条指令的地址压入栈,也就是说,call指令的第一个动作是将0x0040 0014(call指令的下一条指令地址)压入栈。esp此时变化,其值变为0x0063 00f4。为什么?因为esp被初始化为0x0063 00f8,一个地址4个字节入栈之后,esp = esp - 4。然后call指令转去调用Init过程代码。eip变化为0x0040 0000,为什么?因为Init过程的第一个指令地址就是0x0040 0000.这个过程是由CPU自动完成的,也就是说,call指令,让CPU自动完成这一系列动作。
然后Init过程执行到ret指令。
ret指令干什么?它将栈内数据弹出,并用该数据填充eip。栈内数据是什么?就是0x0040 0014,它就是call指令的下一条指令的地址!同时esp = esp + 4.也就是说,ret指令执行后,eip值变为0x0040 0014, esp的值变回0x0063 00f8.这个过程由CPU自动完成。ret指令让CPU自动完成这一系列动作。
整理:执行call,call指令首先将下一条指令地址入栈,然后跑去执行过程代码;过程代码中执行ret,ret首先从栈中将下一条指令地址弹回eip,这样程序就开始执行call指令后的指令。一句话:eip始终指向下一条指令地址。
以上!就是汇编函数调用和返回的过程。就是一个call和一个ret.eip在这个执行过程中通过栈来保存。
接下来,让我们开始考察C语言的过程调用和返回,也就是C语言函数的参数压栈和参数访问过程。


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
先看一个汇编调用压参和参数访问过程。
假设有一个add过程,这个过程的工作是将两个整型值(每个整型值4字节)相加,并将相加的和返回eax寄存器。
如果通过把参数压入堆栈来传递参数调用过程,那么调用方(caller)代码如下:
    push var1               ;第一个变量值
    push var2               ;第二个变量值
    call add                ;调用add过程
    add esp, 8              ;从栈移除参数
而被调用过程(callee)add的代码如下:
add    PROC NEAR32           ;add过程,该过程将两个整型值相加
    push ebp                 ;保存基栈指针
    mov ebp, esp            ;建立栈
    mov eax, [ebp + 8]     ;复制第二个参数值(var2)
    mov eax, [ebp + 12]    ;加上第一个参数值(var1)
    pop ebp                 ;恢复ebp寄存器
    ret                     ;过程返回
add     ENDP                ;过程结束
我们将根据这段代码建立栈图。
    


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
啊,好累,休息一下。
栈图先不发上来...可能要等到明晚再续了。
请勿在中间加水回贴,谢谢


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
先把调用栈图发上来...
解释再说


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
这你妹...有点看不清楚。点击看大图。要不将就看吧。
每个格子是一个字节。
左边是caller(调用者)栈,右边是callee(被调用者)栈(是同一个栈,分别是压参前、call指令执行后的状态。caller和callee的视图)。
图中画的内存地址是向上增长的。
首先,esp是栈顶,直接从caller栈顶看起。也就是,在调用前,esp指向某个内存地址。
在调用函数前将参数压入栈中。
push var1
push var2
这两行代码使esp - 8. 然后压参完毕,图中即为压参完毕esp.
然后调用函数:
call add
嗯,之前复习call指令时说什么了?call指令执行时,首先将返回地址压入栈。
也就是将add esp, 8 这条指令的地址压入栈。
如左图所示。
然后call指令执行过程调用,eip指向add函数内第一条指令的地址:
push ebp    ;将ebp保存到栈中,同时esp - 4(说过了80X86的栈是向低地址方向增长的).
此时ebp原值被保存入栈中。参看右图,蓝色部分是ebp原值。
然后:
mov ebp, esp
此时以ebp为基准的栈建立了。此时ebp和esp都指向栈顶(ebp原值被栈保存起来了哦)。
为什么要这么做?
因为esp是随时变动的,只要有压栈和出栈的操作,esp的值就随着压栈和出栈的操作变化(随着push和pop操作变化,甚或,程序员直接改动esp的值)。
而ebp却不会随着push和pop操作变化。程序员在callee中不会修改ebp的值,而是使用ebp作为基准访问参数。
那么接下来就很好理解了,第二个参数的地址是ebp + 8, 第一个参数的地址是ebp + 12.
所以
mov eax, [ebp + 8]      ;复制第二个参数值(var2)到eax
mov eax, [ebp + 12]     ;加上第一个参数值(var1)
就不难理解了。
在过程把实现代码处理完毕的最后,pop ebp将ebp原值从栈中弹出恢复。
然后ret返回指令将返回地址弹出并赋给eip(请注意,返回地址弹出后,esp + 4, 这时esp正好指向调用者压参完毕的位置),...
回到调用者的地方并继续执行。
那么调用处的add esp, 8               ;从栈移除参数
是干什么用的?注释已经说得很清楚了。
调用者将var1和var2压到栈中,由于调用者的压栈,esp被往下移动了8;那么这个esp的原始位置也就是caller的栈顶应该在过程调用后恢复,add esp, 8就是恢复esp的。
ok。基本上就是如此了!
对于C语言的过程调用,比如,在main函数里面调用add
int main(int argc, char* argv[])
{
    ...
    add(x, y);
    ...
}
实际上,这里add(x, y)(调用者处)被编译器编译成如下汇编代码:
push y
push x
call add
add esp, 8
以上,这就是C过程调用的汇编解释。
接下来给出一般过程的入口代码和出口代码。


  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
不难猜测,所有的过程(被调用函数)都有一样的入口代码和出口代码:
所有的C函数,在被编译器编译成汇编代码之后,
函数开始的几行汇编代码总是这样的,所以我们称这它为入口代码(entry code):
push ebp         ;保存基址
mov   ebp, esp    ;建立ebp偏移基准
sub esp, n      ;n个字节的局部变量参数
push ...         ;保存过程中会用到的通用寄存器
...
pushf             ;保存标识寄存器,也就是保存标志位
而结尾的几行总是这样的,所以称其为出口代码:
popf             ;恢复标识寄存器
pop ...         ;恢复寄存器
...
mov esp, ebp    ;恢复callee esp
pop ebp          ;恢复ebp
ret              ;返回



2026-03-01 13:52:11
广告
不感兴趣
开通SVIP免广告
  • elf0223
  • 强能力者
    7
该楼层疑似违规已被系统折叠 隐藏此楼查看此楼
4. stdcall和cdcel
既然已经了解了上述内容,那么调用惯例就很容易理解了。
cdcel和stdcall是约定俗成的调用惯例,它们的区别在于由谁来恢复esp。
cdcel是由调用者恢复esp的调用惯例,
也就是说
push var1
push var2
call add
add esp, 8
这是cdcel调用惯例
而stdcall则是由callee恢复esp的调用惯例
stdcall会在callee里面将ret这样写:
ret 8
意思是返回的同时esp + 8.
这两种调用惯例,stdcall的好处是不用每次都在调用过程后写add esp, 8这样就减小了代码量,减小了目标文件的体积。
而stdcall的缺陷更明显,那就是callee有时候无法推断参数的个数和长度,这样的话esp只能由调用者恢复(比如变参数函数,这种函数callee是无法推断参数个数的,也就无法知道应该在ret后面加多少偏移量)。


登录百度账号

扫二维码下载贴吧客户端

下载贴吧APP
看高清直播、视频!
  • 贴吧页面意见反馈
  • 违规贴吧举报反馈通道
  • 贴吧违规信息处理公示
  • 1 2 3 4 5 下一页 尾页
  • 71回复贴,共5页
  • ,跳到 页  
<<返回c语言吧
分享到:
©2026 Baidu贴吧协议|隐私政策|吧主制度|意见反馈|网络谣言警示