杂谈(4)-关于C语言的返回值返回到哪里

今天看到有人提问,C语言中return是return到哪里,感觉这个问题很有意思,故除了回答之外,顺便写篇文章(内容上是相同的)。


先说结论:
返回给逻辑上的主调方。如果指的是返回到寄存器还是哪个主存单元,则答案是不能确定,取决于具体情况(主要是编译器具体策略)。
“逻辑中的主调方”并非专有名词。但要明白一点:主调方caller和被调方callee都只是逻辑上的控制流程, 不一定真正形成子流程结构

C语言中的控制流和函数

C中的函数被编译为中间代码,再从中间代码生成机器的目标码。这个过程相当复杂,具体涉及编译原理,我们不展开叙述。从中间代码生成目标代码的过程与机器本身高度相关,我们按下不表,主要看生成中间代码的过程,这一部分才是涉及到控制流逻辑核心的部分。
控制流(Control Flow)可以理解为计算机中,程序从入口点(Entry)开始的程序执行流,顺序结构的控制流是一路向下不回头的,分支结构每次执行时控制流根据条件前往其中一个分支,循环结构可以将控制流导回之前的部分,流图表现为成环。C语言代码生成中间代码时,函数一般会形成符号(Symbol)(但有时不会。声明和定义通常都会产生符号),以及和符号对应的生成代码(只有定义会产生生成代码,声明则不会),写入符号表等表格。这也是C语言分单元编译链接的理论基础。
在典型的函数控制流中,一个典型的函数会被单独抽出来:

int foo(int a) { // 假定a通过AX传入
  return calc(a); // 假定calc是一个通过AX寄存器传参的外部函数
  // 假定calc和foo仍通过AX返回参数
}

// main的某处
short x = foo(1);

其对应的基本中间代码可能是这样的:

; 数据段某处
dw x ? ; 一个2字节变量x

proc foo near ; foo在这里是一个符号
  call calc ; 编译器在链接阶段查找外部符号表中的calc,决定该链接谁
foo endp

; main的某处
mov ax, 1
call foo
mov x, ax

一切看起来简单易懂。但是C中有个东西,叫做内联(inline)。
首先,inline不一定形成符号。事实上,被内联的函数总是不会产生额外的子流程结构,在x86汇编中表现为不会形成新的proc。但要注意一点:写了inline不一定内联,不写inline也不一定不内联,具体取决于编译器的优化策略。此外还有一个事实经常被误解:有些人认为一个函数要么内联要么不内联,这是错误的。是否内联取决于很多因素,比如是否会造成代码膨胀(典型的就是转化为内联后,由于内联代码量比较大,生成代码比call指令大很多倍,导致可执行文件体积暴增),是否有性能问题,等等。因此,编译器实际上会对每次函数调用(而不是每个函数本身)判断是否适合内联,一个函数可能某些调用内联了,而另一些同一函数的调用却没有内联
说完了这点,我们来说说计算机底层如何确定控制流走向。计算机底层如何确定下一条指令是什么呢?在计算机组成原理中会讲到,答案是程序计数器,在x86架构下表现为CS段寄存器和IP寄存器,CS:IP指示着下一条指令的位置。if、switch、while等的底层是条件跳转,通过ALU等CPU组件实现条件判断后,发出微控制信号决定CS:IP是否进行跳转以及该如何进行跳转。例如一个典型的if语句:

// 假定x,a分别在AX,BX,b要直接写回内存数据段
if (x == 1) a = 1;
else a = 2;
b = a + 1;

可能生成这样的中间代码:

test ax, 1
jnz tbranch_x_eq_1
mov ax, 2
jmp endif_x_eq_1
tbranch_x_eq_1: mov ax, 1
endif_x_eq_1: mov cx, ax
add cx, 1
mov cx, b

这里是为了强调:计算机底层通常不存在“要么转到a,要么转到b”的情形,而是“满足条件就转移,否则继续顺序执行”。C语言语法掩盖了这一层,而翻译后的中间代码会揭示“隐式”跳转。

函数调用和参数、返回值“协议”以及return的执行流程

就像应用程序有协议格式一样,主调方需要了解被调方是如何获取参数值、如何返回数据的。这个规则类似于协议,但一般被称为调用约定。主要的调用约定有stdcall、fastcall等等,这篇文章:https://www.cnblogs.com/findumars/p/5356217.html 叙述地很详尽,我们这里对函数调用约定的细节按下不表。
这里提到这点是为了强调一个事实:函数的本质是对底层控制流的一层封装,而不能绝对准确地对应机器的底层结构
因此我们已经可以对你的问题给出一个粗略的答案:return语句不存在绝对准确的对应(要特别强调,汇编语言中的ret尽管也是return的含义,但和C语言的return有着本质区别,二者的语义完全不同,更不能错误地认为二者等价),我们要进一步了解,就需要明白return和函数调用中到底发生了什么。
计算机操作系统一般会保证应用程序具有(Stack)结构RAM区域。它的名字叫做栈,但和数据结构中讲的栈有着一定不同。首先,计算机底层栈虽然遵守LIFO先进后出,但数据结构中的栈一般是严格规定了每次入栈出栈的必须是完整元素,而底层栈并非以完整元素为基础单元入栈,而是以字节为单位进行存储。其次,数据结构中的栈规定只有栈顶能进行操作,但在计算机底层栈上,只要不违反CPU的特权级规则,你通常可以进行随机访问。
函数开始调用时,首先对参数求值,按调用约定压栈。然后通过call指令调用。这里产生了一个问题:call指令执行完子流程后,还需要确定该回到哪继续执行(C中显然通常是调用位置之后),但整台机器通常只有一组程序计数器(x86下就是CS:IP),怎么办?CPU设计者使用了一个巧妙的方案:call指令会进行一个隐式压栈,压栈的内容就是返回时的跳转依据。在x86下,这个跳转依据是IP或者CS:IP,取决于call所调用的是长跳调用还是短跳调用。
函数返回时,首先按约定把返回值存到对应的地方(一般是寄存器或者主存中某处,取决于编译器采用的具体策略),然后把进程栈的内容弹出到IP或CS:IP(取决于这个调用被声明为长跳调用还是短跳调用),这样一来,通过设定程序计数器,我们就实现了将控制流导回到函数执行的下一指令位置。但要特别注意:该过程不会进行任何合法性检查,因此会跑飞;该跑飞崩溃状况不一定是恶意或意外,典型的恶意导致跑飞过程就是通过缓冲区溢出等手段达成控制流劫 持
典型的意外导致跑飞:

proc foo near ; foo子过程
  push ax ; 压栈两个字节
  ; do something
  ret ; 忘记弹出那两个字节就ret了,导致IP没有获取到正确的值,而是AX的原始值,这会导致不可预测的结果

入口点与C语言程序的结束

经验丰富的C工程师都知道,main并非进程的真正入口。如果你试过查看编译器编译C给出的汇编代码,就不难注意到:很多时候,符号表会出现一个你没有定义的符号_start或_main或WinMain(取决于目标平台),这些符号的实际代码内部出现了call main。同样地,main函数的return也绝非进程的真正终点。进程main函数结束后,首先会调用atexit等注册的特殊的退出事件回调,然后由操作系统和C运行时库(CRT)执行一些清理工作,才会真正退出。这些工作是我们看不到的。
现在我们能够对你的问题做出进一步回答:对于main函数而言,return会返回给CRT和OS。裸机(Bare Metal)是指程序运行在没有任何OS和运行时支持的环境,由CPU直接执行,这种情况下标准C甚至不能执行。但OS也是C写的,既然裸机无法执行标准C语言,OS是如何启动的呢?
在机器上电后,首先进行上电自检POST,ROM中的BIOS/UEFI被加载执行,BIOS/UEFI会进一步检查磁盘的主引导扇区MBR等位置检索启动信息。检索到引导扇区后,BIOS/UEFI指导机器加载引导扇区内的指定代码,该指定代码被称为Bootloader它是机器码,可直接被CPU执行。然后,一级bootloader一般会去找到磁盘中另一端程序,这被称为链式引导。最后一级bootloader将OS载入RAM,OS从内核开始加载;以Linux的标准boot为例,专门的指令会加载内核镜像,由内核镜像创建一块临时文件系统(称为raminitfs),并以此为基础进一步引导用户态启动。至此,OS才完全启动。bootloader实际编写中,第一级通常是汇编编写,然后由汇编程序调用C语言编写的基本过程,这时还没有CRT支持。如果OS最后也退出了,但机器仍处于上电状态,不难想到,C代码执行时会一层层返回到调用它的汇编层(除非还没返回到就已经断电了),在这一层已经不存在语法上的参数和返回值,而只有逻辑上的参数和返回值。事实上,C语言的参数和返回值也只是逻辑上的,不存在目标代码层的绝对对应。

总结

  1. 返回值不存在底层的绝对对应,只是逻辑上的概念。
  2. 返回值可能存储在寄存器里,也可能是主存中,具体由编译器决定策略。(题外话:目标位置是主存,不代表一定会写入主存,这取决于实际的缓存策略。这个东西很复杂,展开讲的话可以出本书专门写一个章节。)
  3. C的return语句不一定对应目标代码的ret返回行为,典型的例子是内联函数。
  4. main的return并不是进程的真正结束,控制流会转移到CRT,再转移到OS。
  5. OS由bootloader引导启动,而后者不涉及CRT。如果真的要返回,理论上C可以层层返回到汇编写的部分,不过实际上应该会在这之前由C部分引导断电(这点我不太确定是否真的是C部分来执行)。

题外话:有返回值的函数若不写return,其行为是未定义的。

  • 10
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值