备用面试题

1、C程序代码编译、运行全过程解析

很多嵌入式初学者,不明白一个简单的C语言程序,是如何通过一步步编译、链接变成一个可执行文件的,程序到底是怎么运行的?运行的过程中需要什么环境支持?

今天就跟大家一起捋一捋这个流程,搞清程序编译、链接、加载、运行的整个脉络,以及程序在运行过程中的内存布局、堆栈变化。

  1. 程序的编译、链接过程
    就以hello.c为例:从一个C语言源文件,到生成最后的可执行文件,基本流程如下:
    C 源文件:编写一个简单的helloworld程序
    预处理:生成预处理后的C源文件 hello.i
    编译:将C源文件翻译成汇编文件 hello.s
    汇编:将汇编文件汇编成目标文件 hello.o
    链接:将目标文件链接成可执行文件
    在这里插入图片描述
    为了加深对这个过程的理解,我们可以在Linux环境下面,通过gcc命令精确控制每一个编译、链接过程
$  gcc  -E  hello.c  >  hello.i  //会生成预处理后的C源文件hello.i
$  gcc  -S  hello.i              //将hello.i编译成汇编文件hello.s
$  gcc  -c  hello.s              //将汇编文件hello.s汇编成hello.o
$  gcc hello.o  -o hello         //将目标文件链接成可执行文件hello
$  ./hello                       // 运行可执行文件hello
  1. 程序的执行过程

当我们在shell交互环境下敲击 $ ./hello,这个hello程序到底是怎么运行的呢?

很简单。shell会首先通过系统调用fork创建一个子进程,然后从磁盘上将可执行文件hello的代码段、数据段加载(map)到这个子进程的地址空间内,接下来,在操作系统调度器的调度下,各个进程轮流占用CPU,就可以直接执行了。
在这里插入图片描述

  • 在操作系统层面,对于每一个进程,在内核中都会有一个task_struct的结构体来描述它,里面存储进程的各种信息,各个结构体构成一个链表,操作系统通过调度器来轮流执行每个进程,如上图所示。
  1. 进程的虚拟空间和物理空间
  • 每个进程使用的都是虚拟地址,地址空间0~4G,都是相同的。但是CPU在实际执行过程中,对于每个进程相同的虚拟地址,会映射到物理内存中的不同位置。每个进程都有自己的进程页表,在这个页表里有该进程虚拟地址和物理地址的对应关系。
    在这里插入图片描述
  • CPU内部有一个叫MMU的硬件部件会根据这个映射关系,直接将虚拟地址转换成物理地址,如下图所示。
    在这里插入图片描述
    使用虚拟地址的好处之一就是:为每个进程提供一个独立的、私有的物理地址空间,保护每个进程的空间不会被其它进程破坏。同时通过MMU对内存读写权限进行管理、保障系统的安全运行。如下图所示,每个进程在我们的物理内存(DDR)上,都有各自独立的内存空间:一个进程崩溃了,一般情况下,不会影响到系统,不会影响到其它进程的运行。
    在这里插入图片描述
  1. 进程栈
  • 栈是C语言运行的基础。没有栈,C语言函数是无法运行的:这是因为函数调用过程中的返回地址、参数传递、函数内的局部变量都是在栈中存储的,没有栈,C语言函数就无法运行。

  • Linux进程中的代码也是由一个个函数组成的,所以在运行进程之前,我们要首先初始化栈,如下图所示:

  • 在程序运行过程中,通过栈指针,我们就可以将函数内的局部变量、返回地址保存在栈中。随着函数不断地调用、函数退出,而不断地入栈、出栈。

  • 栈是一种数据结构,CPU的寄存器一般来讲,在设计的时候,会自动入栈出栈、自动增减栈的地址。比如ARM中的入栈出栈操作,当我们使用push/pop入栈出栈的时候,CPU的寄存器SP,即栈指针会自动增减地址,一直指向栈顶,这些都是指令集的实现,即CPU内部硬件电路的实现。

  1. 用户栈、内核栈、中断栈
  • 在Linux环境下,进程一般分为两种,用户态进程和内核态进程。甭管是什么态,只要你是C语言,运行C代码就必须指定栈,否则C代码就无法运行。所以栈又分为用户栈和内核栈。

  • 用户栈的虚拟地址空间在用户空间,内核栈的地址在内核空间。它们都是虚拟地址,最后通过MMU映射到物理内存的不同区域。

  • 有时候,你还会看到中断栈的字眼,千万别被它吓到。中断程序、中断函数也是C语言,也是妖他妈生的,想运行中断处理程序也必须需要栈的支持,一般这种栈叫做中断栈。它可以使一个独立的中断栈,也可以占用进程栈的空间,跟进程栈共享。

  1. 小结
  • 以上只是简单介绍一下一个C语言从编译、链接、运行、到进程创建、内存堆栈的大致流程。实际过程比这个更复杂一些、更深一些,限于篇幅的关系,很多细节无法一一细讲。

参考:C语言编译链接加载过程

参考:C/C++的编译和链接过程

2、变量,常量,静态变量存储的位置

常见的存储区域可分为:

1、栈

由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。

2、堆

由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个delete。如果程序员没有释放掉,程序会一直占用内存,导致内存泄漏,在程序结束后,操作系统会自动回收。

3、自由存储区

由malloc等分配的内存块,它和堆是十分相似的,不过它是用free来释放分配的内存。

4、全局/静态存储区

全局变量和静态变量被分配到同一块内存中,在以前的C语言中,全局变量又分为初始化的和未初始化的,在C++里面没有这个区分了,他们共同占用同一块内存区。

5、常量存储区

这是一块比较特殊的存储区,他们里面存放的是常量,不允许修改(当然,你要通过非正当手段也可以修改)。

参考:变量,常量,静态变量存储的位置

  • 今天开始看《程序员的自我修养:链接、装载与库》,对ELF文件格式内容进行一下总结,主要分析全局变量,静态变量以及局部变量存放位置。
  • ELF文件有很多种:可重定位文件(如静态库),可执行文件,共享文件(动态库),核心转储文件(Core dump file)。
  • ELF文件主要有以下段:

.file header

.text section

.data section

.bss section

这里主要分析以下每个字段的内容。

file header字段里存放了描述整个文件的基本属性信息的内容,如程序入口地址,其他各段信息(偏移量和范围)

.text section:主要是编译后的源码指令,是只读字段。

.data section :初始化后的非const的全局变量变量或者局部static变量。

.bss:未初始化后的非const全局变量和局部static变量。

另外,还有一些其他字段:如.rodata字段和.comment字段分别存放只读数据和注释部分。
用书上提供的例子做测试:

#include <stdio.h>
int global_init_val = 84;//.data
int global_uninit_val;
char b[]="aaa";//.data
char c[]="dddd";//.data
const char e[]="yyyy";//g++:not found  gcc:.rodata
const int a = 0x555555;//g++:not found  gcc:.rodata

void func1(int i)
{
        char * a ="abab";//.rodata
        const char c[] = "eeee";//.text(len >3)
        char b[] = "ddd";//.rodata(len <=3)
        char d[] = "xxxx";//.text(>3 ?)
        printf("%d/n",i);
}
int main()
{
        static int static_var = 85;//.data
        static int static_var2;

        int a = 1;
        int b;

        func1(static_var+static_var2 + a+b);
        return a;
}

经过objdump测试.o文件:

所有的初始化后的非const的全局变量变量或者局部static变量都放在.data段

而在g++下:

  • const的全局变量或者static变量则不可见(猜想可能是编译时作为优化存放在寄存器中 ?)

在gcc下:

  • const的全局变量或者static变量存放在.rodata和.text中,都是作为只读变量来存放的。

参考:全局变量,静态变量以及局部变量存放位置

3、类中什么不能继承

  • 1、构造函数不能被继承,但是可以被调用。派生类的构造函数在初始化列表中应调用基类的构造函数。
  • 2、C++赋值运算符重载函数不能被继承
  • 理解:
  • 缺省构造函数,拷贝构造函数,拷贝赋值函数,以及析构函数这四种成员函数被称作特殊的成员函数。如果用户程序没有显式地声明这些特殊的成员函数,那么编译器实现将隐式地声明它们。
  • C++标准规定:如果派生类中声明的成员与基类的成员同名,那么,基类的成员会被覆盖,哪怕基类的成员与派生类的成员的数据类型和参数个数都完全不同。
  • 所以,“赋值运算符重载函数”不是不能被派生类继承,而是被派生类的默认“赋值运算符重载函数”给覆盖了。

这就是C++赋值运算符重载函数不能被派生类继承的真实原因!

参考:C++类体系中,不能被派生类继承的有?

4、C++中哪些类不能被继承?

如果需要设计一个类被继承,那么就需要析构函数加上virtual 为虚函数。
如果需要设计一个类不能被继承,则需要在定义类名后边加上 final.

参考:C++中哪些类不能被继承?

5、为什么基类中的析构函数要声明为虚析构函数?

  • 用对象指针来调用一个函数,有以下两种情况:

  • 1、 如果是虚函数,会调用派生类中的版本。

  • 2、如果是非虚函数,会调用指针所指类型的实现版本。

  • 析构函数也会遵循以上两种情况,因为析构函数也是函数嘛,不要把它看得太特殊。 当对象出了作用域或是我们删除对象指针,析构函数就会被调用。

  • 当派生类对象出了作用域,派生类的析构函数会先调用,然后再调用它父类的析构函数, 这样能保证分配给对象的内存得到正确释放。

  • 但是,如果我们删除一个指向派生类对象的基类指针,而基类析构函数又是非虚的话, 那么就会先调用基类的析构函数(上面第2种情况),派生类的析构函数得不到调用。
    请看例子:

 class Base
{
	public:
		Base()
			{
				cout<<"Base Constructor"<<endl;
			}

			~Base()
			{
				cout<<"Base Destructor"<<endl;
			}
};

class Derived: public Base
{
	public:
		Derived()
			{
				cout<<"Derived Constructor"<<endl;
			}

			~Derived()
			{
				cout<<"Derived Destructor"<<endl;
			}
}
;

int main()
{
	Base *p	= new Derived();
	delete	p;
	return	0;
}

输出是:

Base Constructor
Derived Constructor
Base Destructor

如果我们把基类的析构函数声明为虚析构函数,这会使得所有派生类的析构函数也为虚。 从而使析构函数得到正确调用。
将基类的析构函数声明为虚的之后,得到的输出是:

Base Constructor
Derived Constructor
Derived Destructor
Base Destructor

因此,如果我们可能会删除一个指向派生类的基类指针时,应该把析构函数声明为虚函数。 事实上,《Effective C++》中的观点是,只要一个类有可能会被其它类所继承, 就应该声明虚析构函数。

C++中的虚函数是如何工作的?

  • 虚函数依赖虚函数表进行工作。如果一个类中,有函数被关键词virtual进行修饰, 那么一个虚函数表就会被构建起来保存这个类中虚函数的地址。同时, 编译器会为这个类添加一个隐藏指针指向虚函数表。如果在派生类中没有重写虚函数, 那么,派生类中虚表存储的是父类虚函数的地址。每当虚函数被调用时, 虚表会决定具体去调用哪个函数。因此,C++中的动态绑定是通过虚函数表机制进行的。

  • 当我们用基类指针指向派生类时,虚表指针vptr指向派生类的虚函数表。 这个机制可以保证派生类中的虚函数被调用到。

class Shape
{
	public:
		int edge_length;
		virtual int circumference()
			{
				cout<<"Circumference of Base Classn";
				return 0;
			}
};

class Triangle:	public Shape
{
	public:
		int circumference()
			{
				cout<<"Circumference of Triangle Classn";
				return  3 * edge_length;
			}
};

int main()
{
	Shape *x = new Shape();
	x->circumference();

	// prints “Circumference of Base Class”
	Shape *y = new Triangle();
	y->circumference();

	// prints “Circumference of Triangle Class”
	return 0;
}

输出:

Base Constructor
Derived Constructor
Derived Destructor
Base Destructor

参考:为什么基类中的析构函数要声明为虚析构函数?

6、智能指针

6.1 .智能指针的作用

C++程序设计中使用堆内存是非常频繁的操作,堆内存的申请和释放都由程序员自己管理。程序员自己管理堆内存可以提高了程序的效率,但是整体来说堆内存的管理是麻烦的,C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。

理解智能指针需要从下面三个层次:

  • 1、从较浅的层面看,智能指针是利用了一种叫做RAII(资源获取即初始化)的技术对普通的指针进行封装,这使得智能指针实质是一个对象,行为表现的却像一个指针。
  • 2、智能指针的作用是防止忘记调用delete释放内存和程序异常的进入catch块忘记释放内存。另外指针的释放时机也是非常有考究的,多次释放同一个指针会造成程序崩溃,这些都可以通过智能指针来解决。
  • 3、智能指针还有一个作用是把值语义转换成引用语义。C++和Java有一处最大的区别在于语义不同,在Java里面下列代码:
   Animal a = new Animal();
  Animal b = a;

你当然知道,这里其实只生成了一个对象,a和b仅仅是把持对象的引用而已。但在C++中不是这样,

     Animal a;
     Animal b = a;

这里却是就是生成了两个对象。

6.2.智能指针的使用

智能指针在C++11版本之后提供,包含在头文件中,shared_ptr、unique_ptr、weak_ptr

2.1 shared_ptr的使用

shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。

  • 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如std::shared_ptr p4 = new int(1);的写法是错误的
  • 拷贝和赋值。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1,指向后来的对象。
  • get函数获取原始指针
  • 注意不要用一个原始指针初始化多个shared_ptr,否则会造成二次释放同一内存
#include <iostream>
#include <memory>

int main() {
    {
        int a = 10;
        std::shared_ptr<int> ptra = std::make_shared<int>(a);
        std::shared_ptr<int> ptra2(ptra); //copy
        std::cout << ptra.use_count() << std::endl;

        int b = 20;
        int *pb = &a;
        //std::shared_ptr<int> ptrb = pb;  //error
        std::shared_ptr<int> ptrb = std::make_shared<int>(b);
        ptra2 = ptrb; //assign
        pb = ptrb.get(); //获取原始指针

        std::cout << ptra.use_count() << std::endl;
        std::cout << ptrb.use_count() << std::endl;
    }
}

2.2 unique_ptr的使用

  • unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比与原始指针unique_ptr用于其RAII的特性,使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。
#include <iostream>
#include <memory>

int main() {
    {
        std::unique_ptr<int> uptr(new int(10));  //绑定动态对象
        //std::unique_ptr<int> uptr2 = uptr;  //不能賦值
        //std::unique_ptr<int> uptr2(uptr);  //不能拷貝
        std::unique_ptr<int> uptr2 = std::move(uptr); //轉換所有權
        uptr2.release(); //释放所有权
    }
    //超過uptr的作用域,內存釋放
}

2.3 weak_ptr的使用

  • weak_ptr是为了配合shared_ptr而引入的一种智能指针,因为它不具有普通指针的行为,没有重载operator*和->,它的最大作用在于协助shared_ptr工作,像旁观者那样观测资源的使用情况。weak_ptr可以从一个shared_ptr或者另一个weak_ptr对象构造,获得资源的观测权。但weak_ptr没有共享资源,它的构造不会引起指针引用计数的增加。使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expired()的功能等价于use_count()==0,但更快,表示被观测的资源(也就是shared_ptr的管理的资源)已经不复存在。weak_ptr可以使用一个非常重要的成员函数lock()从被观测的shared_ptr获得一个可用的shared_ptr对象, 从而操作资源。但当expired()==true的时候,lock()函数将返回一个存储空指针的shared_ptr。
#include <iostream>
#include <memory>

int main() {
    {
        std::shared_ptr<int> sh_ptr = std::make_shared<int>(10);
        std::cout << sh_ptr.use_count() << std::endl;

        std::weak_ptr<int> wp(sh_ptr);
        std::cout << wp.use_count() << std::endl;

        if(!wp.expired()){
            std::shared_ptr<int> sh_ptr2 = wp.lock(); //get another shared_ptr
            *sh_ptr = 100;
            std::cout << wp.use_count() << std::endl;
        }
    }
    //delete memory
}

参考:C++11中智能指针的原理、使用、实现
参考:
参考:

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

落花逐流水

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

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

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

打赏作者

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

抵扣说明:

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

余额充值