程序在计算机中是怎样运行的
大学,学了四年计算机. 但对于这个问题一直没有深入的了解;
只是大概知道程序需要编译成二进制的可执行文件计算机才可以执行;
最近,突然特别想知道具体是怎么样的?
所以,想着写篇文章记录一下;
这个问题,我想等再过几年肯定就会有更深层次的看法.
如果有理解不到位的地方,请指正.
程序是在计算机上运行的;
应用程序的运行离不开操作系统的调用和处理;
应用程序是由编程语言编写的;
第一次写于: 2020年2月23日
计算机
计算机作为现代社会早已离不开的生产工具,重要性不言而喻;
计算机的历史
计算机的历史很短,但是发展极其迅猛;
从占据一整层楼的大型机器到现在的手机平板.
功能愈发强大,用途也愈加广泛.
但是,想一想为啥要有计算机这个东西呢?
我们在没有计算机之前不也活的挺好吗?
计算机在开发出来之前,人们对于计算的处理只能靠人力或者简单的工具(例如:算盘)来处理;
满足不了人们的需求;
所以计算机就在前人的努力下研发出来了;
学习了很多的技术,都是以实际问题为出发点.
了解到为什么要有这个技术和这个技术的历史,对理解和使用是至关重要的;
计算机的组成
冯诺依曼体系的计算机有下面五个部分组成和对应现实中的硬件.
- 运算器 CPU
- 控制器 CPU
- 存储器 内存,硬盘
- 输入设备 键盘,鼠标等
- 输出设备组成 显示器等
其中 CPU 包含了 运算器和寄存器
我们要知道计算机它只能处理二进制的数据.
最开始人们确实也是二进制的方式交给计算机去计算的;
人们在纸上打孔,用于表示0 / 1 . 然后交给计算机去计算;
但这样太过于反人类,实在麻烦.
人们就发明了汇编语言;
汇编语言吧,算是人类可以读懂的;
大体是下面这样的:
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $2, %esi
movl $1, %edi
call sum
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
这样代码虽然有了一定的可读性,但是写起来也是比较恶心的.
而且每一种 CPU 的机器指令都是不一样的,对应的汇编语言也不一样。
于是,人们就想着在发明一种语言.可以跨平台和更加方便地处理程序;
因此,C语言就被发明出来了;
C语言的出现极为重要,它是很多系统,编程语言的基础.
C语言的出现就体现了封装
与分层
两个重要的思想;
人们对二进制指令封装成汇编语言,将机器与人分开;
进而,人们对汇编再次进行封装,成为可以跨平台在不同硬件上运行的程序;
屏蔽了底层实现的细节.对用户透明;
C语言是一种通用的、面向过程式的计算机程序设计语言。1972 年,为了移植与开发 UNIX 操作系统,丹尼斯·里奇在贝尔电话实验室设计开发了 C 语言。
操作系统
操作系统是使用计算机过程中的产物.
最开始,计算机是没有操作系统的;
那时候计算机的能力很弱,计算机利用率还可以;
但是随着计算机硬件的不断发展;计算机的能力得到了巨大的提升;
这就造成大量计算资源的浪费;
为什么要有操作系统
如果没有操作系统的时候,人的工作效率和机器的使用效率比较低。
所以操作系统为用户提供一套简单的操作命令,并为设计语言处理程序、调试程序等系统软件提供方便。
提高了计算机资源的使用率,也屏蔽了一些实现细节;
裸机配备操作系统和其他系统软件后,便成为一台既懂命令,又懂各种高级语言,使用操作十分方便的计算机系统。
操作系统解决了什么问题
操作系统计算机配备的一种大型系统程序,用它来实现计算机系统自身的硬件和软件资源的管理。
- 提高计算机资源利用率,
- 提高计算机的系统响应速度,增强计算机系统性能
- 方便用户使用
Linux 操作系统
一般情况下,我们开发的程序运行在Linux操作系统
上;
所以,下面的例子都是基于Linux的;
Linux系统是由C语言
和汇编语言
编写的;
遵循POSIX
系统接口;保证了程序的可移植性;
POSIX表示可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX ),POSIX标准定义了操作系统应该为应用程序提供的接口标准。
C语言
C语言是编写Unix时的一个产物;
为什么要有C语言
计算机软件的发展历程就是一个持续优化,提升效率的过程。
Unics的发明是为了将复杂的任务简单化处理。
同时,为了将软件和硬件的关联处理实现简化而重新创建了一个新的语言(C语言),
从而实现软件和硬件的分离,为现代操作系统(Unix)的发展打下了坚实的基础。
C语言解决了什么问题
C语言解决了
- 程序的移植性问题;使得一份程序可以在不同类型的机器上运行.
- 具有更好的代码可读性
语言的自举性
C语言编写的程序,可以由编译器编译成可执行文件.
编译C语言
从下面简单的程序到
- 预处理: 展开头文件/宏替换/去掉注释/条件编译 生成 .i 的文件[预处理器cpp]
- 编译: 将预处理后的文件转换成汇编语言, 生成文件 .s [编译器egcs]
- 汇编: 有汇编变为目标代码(机器代码)生成 .o 的文件[汇编器as]
- 链接: 连接目标代码, 生成可执行程序 [链接器ld]
预处理
这是一个很简单的C语言代码 文件名 hello.c
#include <stdio.h>
int sum(int a, int b)
{
return a + b;
}
int main(int argc, char const *argv[])
{
int a = 5;
int b = 8;
int c = sum(a, b);
printf("%d \n",c);
return 0;
}
我们可以执行激活预处理,这个不生成文件, 同时重定向到一个输出文件里面
gcc -E hello.c > hello.i
我们此时可以得到
# 1 "hello.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 31 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 32 "<command-line>" 2
# 1 "hello.c"
# 1 "/usr/include/stdio.h" 1 3 4
# 27 "/usr/include/stdio.h" 3 4
// 这里省去了很多引入的代码
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
# 944 "/usr/include/stdio.h" 3 4
# 2 "hello.c" 2
# 3 "hello.c"
int sum(int a, int b)
{
return a + b;
}
int main(int argc, char const *argv[])
{
int a = 5;
int b = 8;
int c = sum(a, b);
printf("%d \n",c);
return 0;
}
编译
预处理之后,需要进一步的转换成汇编代码
gcc -S hello.c
省去了一些代码后
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $32, %rsp
movl %edi, -20(%rbp)
movq %rsi, -32(%rbp)
movl $5, -4(%rbp)
movl $8, -8(%rbp)
movl -8(%rbp), %edx
movl -4(%rbp), %eax
movl %edx, %esi
movl %eax, %edi
call sum
movl %eax, -12(%rbp)
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Debian 6.3.0-18+deb9u1) 6.3.0 20170516"
.section .note.GNU-stack,"",@progbits
汇编
只激活预处理,编译,和汇编,也就是他只把程序做成obj文件
汇编处理过后的文件叫 目标文件
gcc -c hello.c
会生成二进制文件 hello.o
但是,此时程序还是不能够运行的,还是需要链接;
链接
为什么需要链接之后的代码,才能够被机器执行呢?
因为编译只是将我们自己写的代码变成了二进制形式,
它还需要和系统组件(比如标准库、动态链接库等)结合起来,
这些组件都是程序运行所必须的。
C标准库,是在编译器中,还是操作系统中?
在操作系统
中
不同的操作系统会提供不同的库
- linux下gcc连接时会使用
libstdc++.so
- mac下clang连接时会使用
libc++.so
操作系统提供了一批这样的成熟的功能模块公开发人员使用,并将一定的模块组合到一块,成为一个函数库;
开发者在使用时用连接器将这些模块结合在一块成为一个程序;他们就叫链接库;
这也就说明了为什么,不同操作系统下安装包不一样了;
链接(Link)其实就是一个打包
的过程,
它将所有二进制形式的目标文件和系统组件组合成一个可执行文件。
完成链接的过程也需要一个特殊的软件,叫做链接器。
随着我们学习的深入,我们编写的代码越来越多,最终需要将它们分散到多个源文件中,
编译器每次只能编译一个源文件,生成一个目标文件,
这个时候,链接器除了将目标文件和系统组件组合起来,还需要将编译器生成的多个目标文件组合起来。
编译是针对一个源文件的,有多少个源文件就需要编译多少次,就会生成多少个目标文件
链接是指将目标文件最终生成可执行文件
例如: 把 hello.o
文件和其它库文件,其它文件加入到最终生成的文件hello.out
中;
# -o 参数表示输出的文件名
gcc -o hello.out hello.c
根据链接方式的不同,链接过程可以分为:
- 静态链接
- 动态链接
静态链接库
.a
和.lib
可以看作一堆目标文件的集合。
当其中某个函数被调用时,就把其所在的目标文件抽出来,加入到连接过程中。
这样可以省下不少的编译耗时,也可以避免用户直接接触库代码。
动态链接库
动态连接库(.so和.dll)则有所不同。
连接器在读取动态链接库后,同样会为其分配内存地址和空间,但不会将相应的内容复制到可执行文件中。
在程序执行时操作系统将动态链接库直接加载至内存,在运行时连接。
动态链接库除了可以拥有静态链接库的上述特点外,还有其它的长处。
当多个进程使用同一个动态链接库时,
这个库只需加载一次,然后就可以被这些进程共享,既省空间又省时间。
但是,由于没有固定的地址,动态链接库的执行效率可能会有一定的损失。
程序与操作系统
上一节 C语言 中,我们讲到链接
是需要操作系统
提供的库函数的;
而程序的执行也依赖于操作系统
分配响应的资源;
所以,程序的运行离不开操作系统;
程序是如何在操作系统上运行的
首先,要知道程序是放在硬盘
中的;
你不去运行它,它就是死的;
只有,当你运行它,操作系统把程序加载到内存
中去;
这个程序才算是开始运行了;
程序运行的时候,操作系统会给它分配一段内存,用来储存程序和运行产生的数据。
这段内存有起始地址和结束地址,比如从0x1000到0x8000,起始地址是较小的那个地址,结束地址是较大的那个地址。
程序与内存
Heap
程序运行过程中,对于动态的内存占用请求(比如新建对象,或者使用malloc命令),
系统就会从预先分配好的那段内存之中,划出一部分给用户,
具体规则是从起始地址开始划分(实际上,起始地址会有一段静态数据,这里忽略)。
举例来说,用户要求得到10个字节内存,那么从起始地址0x1000开始给他分配,
一直分配到地址0x100A,如果再要求得到22个字节,那么就分配到0x1020。
这种因为用户主动请求而划分出来的内存区域,叫做 Heap(堆)。
它由起始地址开始,从低位(地址)向高位(地址)增长。
Heap 的一个重要特点就是不会自动消失,必须手动释放,或者由垃圾回收机制来回收。
Stack
除了 Heap 以外,其他的内存占用叫做 Stack(栈)。简单说,Stack 是由于函数运行而临时占用的内存区域。
在执行函数的过程中;
我们肯定会用到临时变量进行存储;
这些临时变量的就放在stack中;
举例说明一下
#include <stdio.h>
int sum(int a, int b)
{
return a + b;
}
void printSum(a, b)
{
int num = sum(a,b);
printf("%d \n",num);
}
int main(int argc, char const *argv[])
{
int a = 5;
int b = 8;
printSum(a, b);
return 0;
}
调用栈
从main函数
开始,到printSum(a,b)
遇到了一个函数,把这个函数放入调用栈中;
又碰到sum(a,b)
函数,就再把这个函数放入调用栈中;
知道没有函数之后,开始得到返回值,然后依次出栈;
其中包含函数的上下文记录的内容是 栈帧
程序与进程
当一个程序开始运行,操作系统会开启一个进程去管理这个程序;
进程是一个具有独立功能的程序关于某个数据集合的一次运行活动。
它可以申请和拥有系统资源,是一个动态的概念,是一个活动的实体。
它不只是程序的代码,还包括当前的活动,通过程序计数器的值和处理寄存器的内容来表示
程序的线程
当我们的软件对硬件的要求越来越高,一个计算机要同时运行的程序也是越来越多;
而CPU,在进程切换时,会要大量的资源浪费;
- 进程是资源拥有者,创建、撤消与切换存在较大的时空开销,因此需要引入轻型进程;
- 由于对称多处理机(SMP)出现,可以满足多个运行单位,而多个进程并行开销过大。
于是,为了解决这个问题: 线程
就出现了
线程(英语:thread)是操作系统能够进行运算调度的最小单位。
它被包含在进程之中,是进程中的实际运作单位。
线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。
线程
不拥有系统资源,只有运行必须的一些数据结构;
它与父进程的其它线程
共享该进程所拥有的全部资源
。
线程可以创建和撤消线程,从而实现程序的并发执行。
线程具有
- 就绪
- 阻塞
- 运行
参考文章