嵌入式Linux--进程

一、程序与进程基本概念

1.1 进程与程序

之前,我们写了很多的C语言的小demo,老师也同样教过我们如何写C语言,C 语言程序总是从 main 函数开始执行,main()函数的原型是:

int main(void)

或者

int main(int argc, char *argv[])

不知大家是否想过“谁”调用了 main()函数?

事实上,操作系统下的应用程序在运行 main()函数之前需要先执行一段引导代码,最终由这段 引导代码 去调用应用程序的 main()函数,我们在编写应用程序的时候,不用考虑引导代码的问题,在编译链接时,由链接器将引导代码链接到我们的应用程序当中,一起构成最终的可执行文件。

当执行应用程序时,在 Linux 下输入可执行文件的相对路径或绝对路径就可以运行该程序,譬如./app或/home/dt/app,还可根据应用程序是否接受传参在执行命令时在后面添加传入的参数信息,譬如第二种main(int argc, char *argv[]): ./app arg1 arg2 或/home/dt/app arg1 arg2。程序运行需要通过操作系统的加载器来实现,加载器是操作系统中的程序,
当执行程序时,加载器负责将此应用程序加载内存中去执行。

所以由此可知,对于操作系统下的应用程序来说,链接器和加载器都是至关重要的!

1.2 程序如何结束?

程序结束其实就是进程终止,进程终止的方式通常有多种,大体上分为正常终止和异常终止,正常终止包括:

  1. main()函数中通过 return 语句返回来终止进程;
  2. 应用程序中调用 exit()函数终止进程;
  3. 应用程序中调用_exit()或_Exit()终止进程;
    (以上这些是在前面给大家介绍的,异常终止包括:)
  4. 应用程序中调用 abort()函数终止进程;
  5. 进程接收到一个信号,譬如 SIGKILL 信号。

1.3 何为进程?

进程是一个动态过程,而非静态文件,它是程序的一次运行过程,当应用程序被加载到内存中运行之后它就称为了一个进程,当程序运行结束后也就意味着进程终止,这就是进程的一个生命周期。

1.4 进程号

Linux 系统下的每一个进程都有一个进程号(processID,简称 PID),进程号是一个正数,用于唯一标识系统中的某一个进程。在 Ubuntu 系统下执行 ps 命令可以查到系统中进程相关的一些信息,包括每个进程的进程号。
在这里插入图片描述
上图中红框标识显示的便是每个进程所对应的进程号,在linux中,每个PID号都是唯一的,所以不存在重复。

在应用程序中,可通过系统调用 getpid()来获取本进程的进程号,其函数原型如下所示:

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);

测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
	 pid_t pid = getpid();
	 printf("本进程的 PID 为: %d\n", pid);
	 exit(0);
}

同样,可以使用 getppid()系统调用获取父进程的进程号:

#include <sys/types.h>
#include <unistd.h>

pid_t getppid(void);

测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
	 pid_t pid = getpid(); //获取本进程 pid
	 printf("本进程的 PID 为: %d\n", pid);
	 pid = getppid(); //获取父进程 pid
	 printf("父进程的 PID 为: %d\n", pid);
	 exit(0);
}

在这里解释并介绍一下,Linux下的进程:

进程名称进程定义
主进程程序执行的入口,可以理解为常用的main 函数
父进程父进程是子进程的创造者,可有多个子进程。 任何进程都有父进程,追根溯源是系统启动程序。对于我们一般写的程序,主进程是最初始的父进程。
子进程对于父进程而言, 父进程创建的进程, 子进程只能对应一个父进程
守护进程Linux 中的后台服务进程。它是一个生存期较长的进程,通常独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件
僵尸进程当子进程比父进程先结束,而父进程又没有回收子进程,释放子进程占用的资源,此时子进程将成为一个僵尸进程

二、进程的内存布局

2.1 程序的结构

历史沿袭至今,C 语言程序一直都是由以下几部分组成的:

  1. 正文段。也可称为代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防止程序由于意外而修改其指令;正文段是可以共享的,即使在多个进程间也可同时运行同一段程序。
  2. 初始化数据段。通常将此段称为数据段,包含了显式初始化的全局变量和静态变量,当程序加载到内存中时,从可执行文件中读取这些变量的值。
  3. 未初始化数据段。包含了未进行显式初始化的全局变量和静态变量,通常将此段称为 bss 段,这一名词来源于早期汇编程序中的一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,系统会将本段内所有内存初始化为 0,可执行文件并没有为 bss 段变量分配存储空间,在可执行文件中只需记录 bss 段的位置及其所需大小,直到程序运行时,由加载器来分配这一段内存空间。
  4. 栈。函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中。栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量(所谓自动变量)、实参和返回值。
  5. 堆。可在运行时动态进行内存分配的一块区域,譬如使用 malloc()分配的内存空间,就是从系统堆内存中申请分配的。
    在这里插入图片描述

三、进程的虚拟地址空间

3.1 什么是linux下的虚拟地址

在 Linux 系统中,采用了虚拟内存管理技术,事实上大多数现在操作系统都是如此!在 Linux 系统中,每一个进程都在自己独立的地址空间中运行,在 32 位系统中,每个进程的逻辑地址空间均为 4GB,这 4GB 的内存空间按照 3:1 的比例进行分配,其中用户进程享有 3G 的空间,而内核独自享有剩下的 1G 空间,如下所示:
在这里插入图片描述
tips:虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU 会将物理地址“翻译”为对应的物理地址:
在这里插入图片描述
这一点很好理解,就如文章中提到的,应用程序运行在一个虚拟地址空间中,所以程序中读写的内存地址对应也是虚拟地址,并不是真正的物理地址,譬如应用程序中读写 0x80800000 这个虚拟的地址,实际上并不对应于硬件的 0x80800000这个物理地址。

3.2 为什么需要引入虚拟地址呢?

首先我们得知道什么是物理内存,物理内存(Physical memory)是相对于虚拟内存而言的。物理内存是指通过物理内存条而获得的内存空间,而虚拟内存则是指将硬盘的一块区域划分通过内存管理技术划分出来作为内存。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。常见的物理内存规格有256M、512M、1G、2G等,现如今随着计算机硬件的发展,已经出现4G、8G甚至更高容量的内存规格。当物理内存不足时,可以用虚拟内存代替。
在这里插入图片描述
计算机物理内存的大小是固定的,就是计算机的实际物理内存,试想一下,如果操作系统没有虚拟地址机制,所有的应用程序访问的内存地址就是实际的物理地址,所以要将所有应用程序加载到内存中,但是我们实际的物理内存只有 4G,所以就会出现一些问题:

  1. 内部不足。当多个程序需要运行时,必须保证这些程序用到的内存总量要小于计算机实际的物理内存的大小,否则会直接溢出。
  2. 内存使用效率低。内存空间不足时,就需要将其它程序暂时拷贝到硬盘中,然后将新的程序装入内存。然而由于大量的数据装入装出,内存的使用效率就会非常低。
  3. 进程地址空间不隔离。由于程序是直接访问物理内存的,所以每一个进程都可以修改其它进程的内存数据,甚至修改内核地址空间中的数据,所以有些恶意程序可以随意修改别的进程,就会造成一些破坏,系统不安全、不稳定。
  4. 无法确定程序的链接地址。程序运行时,链接地址和运行地址必须一致,否则程序无法运行!因为程序代码加载到内存的地址是由系统随机分配的,是无法预知的,所以程序的运行地址在编译程序时是无法确认的。

针对以上的一些问题,就引入了虚拟地址机制,程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上。所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点:
在这里插入图片描述

  1. 进程与进程、进程与内核相互隔离。一个进程不能读取或修改另一个进程或内核的内存数据,这是因为每一个进程的虚拟地址空间映射到了不同的物理地址空间。提高了系统的安全性与稳定性。
  2. 在某些应用场合下,两个或者更多进程能够共享内存。因为每个进程都有自己的映射表,可以让不同进程的虚拟地址空间映射到相同的物理地址空间中。通常,共享内存可用于实现进程间通信。
  3. 便于实现内存保护机制。譬如在多个进程共享内存时,允许每个进程对内存采取不同的保护措施,
    例如,一个进程可能以只读方式访问内存,而另一进程则能够以可读可写的方式访问。
  4. 编译应用程序时,无需关心链接地址。前面提到了,当程序运行时,要求链接地址与运行地址一致,在引入了虚拟地址机制后,便无需关心这个问题。因为每个进程都只拥有唯一得进程地址,并且是在他们的虚拟地址中进行。

四、创建子进程

4.1 介绍fork()函数

一个现有的进程可以调用 fork()函数创建一个新的进程,调用 fork()函数的进程称为父进程,由 fork()函数创建出来的进程被称为子进程(child process),fork()函数原型如下所示(fork()为系统调用):

#include <unistd.h>

pid_t fork(void);

在诸多的应用中,创建多个进程是任务分解时行之有效的方法。
下面是场景举例:
譬如,某一网络服务器进程可在监听客户端请求的同时,为处理每一个请求事件而创建一个新的子进程,与此同时,服务器进程会继续监听更多的客户端连接请求。在一个大型的应用程序任务中,创建子进程通常会简化应用程序的设计,同时提高了系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)。

1. 理解 fork()系统调用的关键在于,完成对其调用后将存在两个进程,一个是原进程(父进程)、另一个则是创建出来的子进程,并且每个进程都会从 fork()函数的返回处继续执行,会导致调用 fork()返回两次值,子进程返回一个值、父进程返回一个值。在程序代码中,可通过返回值来区分是子进程还是父进程。

2. fork()调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0;如果调用失败,父进程返回值-1,不创建子进程,并设置 errno。

3. fork()调用成功后,子进程和父进程会继续执行 fork()调用之后的指令,子进程、父进程各自在自己的进程空间中运行。事实上,子进程是父进程的一个副本,譬如子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制,执行 fork()之后,每个进程均可修改各自的栈数据以及堆段中的变量,而并不影响另一个进程。

下面是测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
	pid_t pid;
	pid = fork();
	switch (pid) 
	{
	 case -1:
	 perror("fork error");
	 exit(-1);
	 
	 case 0:
	 printf("这是子进程打印信息<pid: %d, 父进程 pid: %d>\n",getpid(),getppid());
	 _exit(0); //子进程使用_exit()退出
	 
	 default:
	 printf("这是父进程打印信息<pid: %d, 子进程 pid: %d>\n",
	 getpid(), pid);
	 exit(0);
 	}
}

在这里插入图片描述
从打印结果可知,fork()之后的语句被执行了两次,所以 switch…case 语句被执行了两次,
第一次进入到了"case 0"分支,通过上面的介绍可知,fork()返回值为 0 表示当前处于子进程;在子进程中我们通过 getpid()获取到子进程自己的 PID(46802),通过 getppid()获取到父进程的 PID(46803),将其打印出来。

第二次进入到了 default 分支,表示当前处于父进程,此时 fork()函数的返回值便是创建出来的子进程对应的 PID。

fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,最终父进程会执行到 exit()结束进程,而子进程则会通过_exit()结束进程。

这样,如果大家还是比较懵懵懂懂的,这里在举一个例子:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
	 pid_t pid;
	 pid = fork();
	 switch (pid) 
	{
	 case -1:
	 perror("fork error");
	 exit(-1);
	 
	 case 0:
	 printf("这是子进程打印信息\n");
	 printf("%d\n", pid);
	 _exit(0);
	 
	 default:
	 printf("这是父进程打印信息\n");
	 printf("%d\n", pid);
	 exit(0);
   }
}

在这里插入图片描述
在 exit()函数之前添加了打印信息,而从上图中可以知道,打印的 pid 值并不相同,0 表示子进程打印出来的,46953 表示的是父进程打印出来的,所以从这里可以证实,fork()函数调用完成之后,父进程、子进程会各自继续执行 fork()之后的指令,它们共享代码段,但并不共享数据段、堆、栈等,而是子进程拥有父进
程数据段、堆、栈等副本,所以对于同一个局部变量,它们打印出来的值是不相同的,因为 fork()调用返回值不同,在父、子进程中赋予了 pid 不同的值。

4.2 父、子进程间的文件共享

调用 fork()函数之后,子进程会获得父进程所有文件描述符的副本,这些副本的创建方式类似于 dup(),这也意味着父、子进程对应的文件描述符均指向相同的文件表,如下图所示:
在这里插入图片描述
由此可知,子进程拷贝了父进程的文件描述符表,使得父、子进程中对应的文件描述符指向了相同的文件表,也意味着父、子进程中对应的文件描述符指向了磁盘中相同的文件,因而这些文件在父、子进程间实现了共享,譬如,如果子进程更新了文件偏移量,那么这个改变也会影响到父进程中相应文件描述符的位置
偏移量。

根据下面的测试代码,我们可以看看在共享文件下,会发生什么:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main(void)
{
	 pid_t pid;
	 int fd;
	 int i;
	 pid = fork();
	 switch (pid) 
	 {
		 case -1:
		 perror("fork error");
		 exit(-1);
		 
		 case 0:
		 /* 子进程 */
		 fd = open("./test.txt", O_WRONLY);
		 if (0 > fd) 
		 {
			 perror("open error");
			 _exit(-1);
		 }
		 for (i = 0; i < 4; i++) //循环写入 4 次
		 write(fd, "1122", 4);
		 close(fd);
		 _exit(0);
		 
		 default:
		 /* 父进程 */
		 fd = open("./test.txt", O_WRONLY);
		 if (0 > fd) 
		 {
			 perror("open error");
			 exit(-1);
		 }
		 for (i = 0; i < 4; i++) //循环写入 4 次
		 write(fd, "AABB", 4);
		 close(fd);
		 exit(0);
	 }
}

从测试结果可知,这种文件共享方式实现的是一种两个进程分别各自对文件进行写入操作,因为父、子进程的这两个文件描述符分别指向的是不同的文件表,意味着它们有各自的文件偏移量,一个进程修改了文件偏移量并不会影响另一个进程的文件偏移量,所以写入的数据会出现覆盖的情况。

4.3 fork()函数使用场景

fork()函数有以下两种用法:

  1. 父进程希望子进程复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的,父进程等待客户端的服务请求,当接收到客户端发送的请求事件后,调用 fork()创建一个子进程,使子进程去处理此请求、而父进程可以继续等待下一个服务请求。
  2. 一个进程要执行不同的程序。譬如在程序 app1 中调用 fork()函数创建了子进程,此时子进程是要去执行另一个程序 app2,也就是子进程需要执行的代码是 app2 程序对应的代码,子进程将从 app2程序的 main 函数开始运行。这种情况,通常在子进程从 fork()函数返回之后立即调用 exec 族函数
    来实现,关于 exec 函数将在后面内容向大家介绍哈。

4.4 系统调用 vfork()

除了 fork()系统调用之外,Linux 系统还提供了 vfork()系统调用用于创建子进程,vfork()与 fork()函数在功能上是相同的,并且返回值也相同,在一些细节上存在区别,vfork()函数原型如下所示:

#include <sys/types.h>
#include <unistd.h>

pid_t vfork(void);

在这里还是向大家介绍一下vfork()函数的为啥使用吧!

从前面的介绍可知,可以将 fork()认作对父进程的数据段、堆段、栈段以及其它
一些数据结构创建拷贝,由此可以看出,使用 fork()系统调用的代价是很大的,它复制了父进程中的数据段和堆栈段中的绝大部分内容,这将会消耗比较多的时间,效率会有所降低,而且太浪费,原因有很多,其中之一在于,fork()函数之
后子进程通常会调用 exec 函数,也就是 fork()第二种使用场景下,这使得子进程不再执行父程序中的代码段,而是执行新程序的代码段,从新程序的 main 函数开始执行、并为新程序重新初始化其数据段、堆段、栈段等;那么在这种情况下,子进程并不需要用到父进程的数据段、堆段、栈段(譬如父程序中定义的局部变量、全局变量等)中的数据,此时就会导致浪费时间、效率降低。

事实上,现代 Linux 系统采用了一些技术来避免这种浪费,其中很重要的一点就是内核采用了写时复制(copy-on-write)技术,关于这种技术的实现细节就不给大家介绍了,有兴趣读者可以自己搜索相应的文档了解。

vfork()与 fork()函数主要有以下两个区别:

  1. vfork()与 fork()一样都创建了子进程,但 vfork()函数并不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用 exec(或_exit),于是也就不会引用该地址空间的数据。不过在子进程调用 exec 或_exit 之前,它在父进程的空间中运行、子进程共享父进程的内存。这种优化工作方式的实现提高的效率;但如果子进程修改了父进程的数据(除了 vfork 返回值的变量)、进行了函数调用、或者没有调用 exec 或_exit 就返回将可能带来未知的结果。
  2. 另一个区别在于,vfork()保证子进程先运行,子进程调用 exec 之后父进程才可能被调度运行。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
int main(void)
{
	 pid_t pid;
	 int num = 100;
	 pid = vfork();
	 switch (pid) 
	 {
		 case -1:
		 perror("vfork error");
		 exit(-1);
		 
		 case 0:
		 /* 子进程 */
		 printf("子进程打印信息\n");
		 printf("子进程打印 num: %d\n", num);
		 _exit(0);
		 
		 default:
		 /* 父进程 */
		 printf("父进程打印信息\n");
		 printf("父进程打印 num: %d\n", num);
		 exit(0);
	 }
}

tips:在正式的使用场合下,一般应在子进程中立即调用 exec,如果 exec 调用失败,子进程则应调用_exit()退出(vfork 产生的子进程不应调用 exit 退出,因为这会导致对父进程 stdio 缓冲区的刷新和关闭)。

4.5 fork()之后的竞争条件

调用 fork()之后,子进程成为了一个独立的进程,可被系统调度运行,而父进程也继续被系统调度运行,这里出现了一个问题,调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定的,譬如有如下示例代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
	 switch (fork()) 
	 {
		 case -1:
		 perror("fork error");
		 exit(-1);
		 
		 case 0:
		 /* 子进程 */
		 printf("子进程打印信息\n");
		 _exit(0);
		 
		 default:
		 /* 父进程 */
		 printf("父进程打印信息\n");
		 exit(0);
	 }
}

在这里插入图片描述
从测试结果可知,虽然绝大部分情况下,父进程会先于子进程被执行,但是并不排除子进程先于父进程被执行的可能性。而对于有些特定的应用程序,它对于执行的顺序有一定要求的,譬如它必须要求父进程先运行,或者必须要求子进程先运行,程序产生正确的结果它依赖于特定的执行顺序,那么将可能因竞争条件
而导致失败、无法得到正确的结果。

那我们如何规避这种错误,按照我们的想法去进行我们想要的程序呢?
答案是:使用信号
例如:让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它。
(“信号” 这章内容我没有单独拿出来做,主要认为大家有这个概念就可以了,到时候用到可以去查。
给大家一个信号的概念:
信号是事件发生时对进程的通知机制,也可以把它称为软件中断。信号与硬件中断的相似之处在于能够打断程序当前执行的正常流程,其实是在软件层次上对中断机制的一种模拟。大多数情况下,是无法预测信号达到的准确时间,所以,信号提供了一种处理异步事件的方法。信号的方法还有很多,大家可以到正点原子的课程听懂就行,用的时候查一下,或者返回课程复习一下就可以了)

使用信号解决父、子进程先后执行的问题,测试代码:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
static void sig_handler(int sig)
{
 printf("接收到信号\n");
}
int main(void)
{
	 struct sigaction sig = {0};
	 sigset_t wait_mask;
	 /* 初始化信号集 */
	 sigemptyset(&wait_mask);
	 /* 设置信号处理方式 */
	 sig.sa_handler = sig_handler;
	 sig.sa_flags = 0;
	 if (-1 == sigaction(SIGUSR1, &sig, NULL)) 
	 {
		 perror("sigaction error");
		 exit(-1);
	 }
	 switch (fork()) 
	 {
		 case -1:
		 perror("fork error");
		 exit(-1);
		 
		 case 0:
		 /* 子进程 */
		 printf("子进程开始执行\n");
		 printf("子进程打印信息\n");
		 printf("~~~~~~~~~~~~~~~\n");
		 sleep(2);
		 kill(getppid(), SIGUSR1); //发送信号给父进程、唤醒它
		 _exit(0);
		 
		 default:
		 /* 父进程 */
		 if (-1 != sigsuspend(&wait_mask))//挂起、阻塞
		 exit(-1);
		 printf("父进程开始执行\n");
		 printf("父进程打印信息\n");
		 exit(0);
	 }
}

在这里插入图片描述
本章介绍到这,这么长的文章,如果有人看到这,还是希望大家把代码都敲一敲/复制粘贴进虚拟机也好,理解一下,在虚拟机跑跑,看一下现象,会有很多收获的。

本文参考正点原子的嵌入式LinuxC应用编程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

The endeavor

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值