跳到主要内容

前言

java不着急,看他们卷的我难受,都说java很卷,那我走,走计科行不行,咱先把计科的基础打好行不行?

而且我嵌入式写的时候感觉一直在cv别人家的代码,缺乏自己的思考,也缺少经验优化相关代码,想要养成看武林秘籍(书)的习惯,所以,来!先看这个吧,下面是作者对我们需要理解学习的知识体系和编程技能

  • 半导体基础、CPU工作原理、硬件电路、计算机系统结构。

  • ARM体系结构与汇编指令、汇编程序设计、ARM反汇编分析。

  • 程序的编译、链接、安装、运行和重定位分析。

  • 熟悉C语言标准、ARM、GNU编译器的特性和扩展语法。

  • C语言的模块化编程思想,学会使用模块化思想去分析复杂的系统。

  • C语言的面向对象编程(简称OOP)思想,学会使用OOP思想去分析Linux内核驱动。

  • 对指针的深刻理解,对复杂指针的声明和灵活应用。

    • 对内存堆栈管理、内存泄漏、栈溢出、段错误的深刻理解。
  • 多任务并发编程思想,CPU和操作系统基础理论。

[TOC]

工欲善其事,必先利其器

这里作者给我们强烈安利了一波vim,但是我这用keil的人暂时用不上就不学了

程序编译工具:make

现在的IDE会自动调用相关的预处理器、编译器、汇编器、链接器等工具生成可执行文件,并将可执行文件加载到内存运行,通过打印窗口,用户可以很直观地看到程序的运行结果。

使用gcc编译C源程序

在Linux环境下编译程序和在Windows下不太一样,一般在命令行下编译代码。在Linux下,我们一般使用gcc或arm-linux-gcc交叉编译器来编译程序。在使用这些编译器之前,首先需要安装它们,在Ubuntu环境下,我们可以使用apt-get命令来安装这些编译工具。

make编译工具

依赖一个叫作Makefile的文件:生成一个可执行文件所依赖的所有C源文件都在这个Makefile文件中指定。在Makefile中,通过定义一个个规则,来描述各个要生成的目标文件所依赖的源文件及编译命令,最后链接器将这些目标文件组装在一起,生成可执行文件。

CPU性能提升:流水线

流水线工作原理

一条指令的执行一般要经过取指令、翻译指令、执行指令3个基本流程。CPU内部的电路分为不同的单元:取指单元、译码单元、执行单元等,指令的执行也是按照流水线工序一步一步执行的。

如图所示,我们假设每一个步骤的执行时间都是一个时钟周期,那么一条指令执行完需要3个时钟周期。

image-20220813154912496

超流水线技术

大概就是将木桶效应中的明显短板(用时长的功能)优化,将其细节分摊开,拆分工作,每一个工序的执行时间变小,处理器的时钟周期就可以更短,cpu工作频率就可以更高(虽然说我感觉这部分讲的很逆天.

流水线通过减少每一道工序的耗费时间来提升整条流水线的效率。在CPU内部也是如此,CPU内部的数字电路是靠时钟驱动来工作的,既然每条指令的执行时钟周期数不变,即执行每条指令都需要3个时钟周期,但是我们可以通过缩短一个时钟周期的时间来提升效率,即减少每条指令所耗费的时间。一个时钟周期的时间变短,CPU主频也就相应提升,影响时钟周期时间长短的一个关键的制约因素就是CPU内部每一个工序执行单元的耗费时间。虽说电信号在电路中的传播时间很快,可以接近光速,但是经过成千上万个晶体管,不停地信号翻转,还是会带来一定的时间延迟,这个时间延迟我们可以看作电路单元的执行时间。以图2-37为例,如果每个执行单元的时间延迟都是1+0.5=1.5ns,那么你的时钟周期至少也得2ns,否则电路就会工作异常。如果驱动CPU工作的时钟周期是2ns,那么CPU的主频就是500MHz。现在的CPU流水线深度可以做到10级以上,流水线的每一级时间延迟都可以做到皮秒级别,驱动CPU工作的时钟周期可以做到更短,可以把CPU的主频飙到5GHz以上。

image-20220814151046811

我们把5级以上的流水线称为超流水线结构。为了提升CPU主频,高性能的处理器一般都会采用这种超流水线结构。Intel的i7处理器有16级流水线,AMD的速龙64系列CPU有20级流水线,史上具有最长流水线的处理器是Intel的第三代奔腾四处理器,有31级流水线。

要想提升CPU的主频,本质在于减少流水线中每一级流水的执行时间,消除木桶短板效应。解决方法有三个:一是优化流水线中各级流水线的性能,受限于当前集成电路的设计水平,这一步最难;二是依靠半导体制造工艺,工艺制程越先进,芯片面积就会越小,发热也就越小,就更容易提升主频;三是不断地增加流水线深度,流水线越深,流水线中的各级时间延迟就可以做得越小,就更容易提高主频。

流水线是否越深越好呢?不一定。流水线的本质是拿空间换时间,流水线越深,电路会越复杂,就需要更多的组合逻辑电路和寄存器,芯片面积也就越大,功耗也就随之上升了。用功耗增长换来性能提升,在PC机和服务器上还行,但对于很多靠电池供电的移动设备的处理器来说就无法接受了,CPU设计人员需要在性能和功耗之间做一个很好的平衡。(12代酷睿出现了大小核的设计呢,power and efficient core

流水线越深,就越能提升性能吗?也不一定。流水线是靠指令的并行来提升性能的,第一条指令还没有执行完,下面的第二条指令就开始取指、译码了。执行的程序指令如果是顺序结构的,没有中断或跳转,流水线确实可以提高执行效率。但是当程序指令中存在跳转、分支结构时,下面预取的指令可能就要全部丢掉了,需要到跳转的地方重新取指令执行。(这部分感觉不是很懂,重新开流水线不行吗,但是流水线过长的确实有点难搞)

流水线冒险(hazard)

大概就是,提前执行(预取和译码)了下面的指令,但是现在正在执行的条件跳转指令硬生生跳走了,刚刚提前执行(预取和译码)的指令就嘎了

引起流水线冒险的原因有很多种,根据类型不同,我们一般分为3种。

  • 结构冒险:所需的硬件正在为前面的指令工作。
  • 数据冒险:当前指令需要前面指令的运算数据才能执行。
  • 控制冒险:需根据之前指令的执行结果决定下一步的行为。

image-20220814154254723

ARM体系架构

这边我采用先看一遍再重新复习做笔记的方式重新提炼一遍书上的重点(不然不方便复习

计算机指令集一般分为四种:

  • 复杂指令集CISC
  • 精简指令集RISC
  • 显示并行指令集EPIC
  • 超长指令字指令集VLIW

我们一般学习嵌入式的就和RISC打交道(老ARM了,但是我们一般的电脑基本上都是CISC,X86

RISC有以下特点

  • Load/Store架构,CPU不能直接处理内存中的数据,要先将内存中的数据Load(加载)到寄存器中才能操作,然后将处理结果Store(存储)到内存中。(下面会提到汇编的指令)
  • 固定的指令长度、单周期指令。(不懂
  • 倾向于使用更多的寄存器来存储数据,而不是使用内存中的堆栈,效率更高。ARM指令集虽然属于RISC,但是和原汁原味的RISC相比,还是有一些差异的,具体如下。(酱紫存数据不知道寄存器会不会有寿命提前嘎掉,但是x86也是有寄存器的,不懂
  • ARM有桶型移位寄存器单周期内可以完成数据的各种移位操作。
  • 并不是所有的ARM指令都是单周期的。
  • ARM有16位的Thumb指令集,是32位ARM指令集的压缩形式,提高了代码密度。
  • 条件执行:通过指令组合,减少了分支指令数目,提高了代码密度。
  • 增加了DSP、SIMD/NEON等指令。

同时ARM有多种工作模式

image-20220819172316504

一般正常工作就是user mode,当程序运行出错或有中断发生时,ARM处理器就会切换到对应的特权工作模式。用户模式属于普通模式,有些特权指令是运行不了的,需要切换到特权模式下才能运行。在ARM处理器中,除了用户模式是普通模式,剩下的几种工作模式都属于特权模式。

在ARM处理器内部,除了基本的算术运算单元、逻辑运算单元、浮点运算单元和控制单元,还有一系列寄存器,包括各种通用寄存器、状态寄存器、控制寄存器,用来控制处理器的运行,保存程序运行时的各种状态和临时结果

image-20220819172541317

不想写了,全是汇编

跳到我们能用的吧

程序的编译、链接、安装和运行

嵌入式工程师大多数拥有电子、电气、自动化专业背景,不可能像计算机专业的学生那样掌握程序编译过程中的每一个细节,如语法分析、词法分析等。

看来我们要学习编译原理了啊= =

关于编译原理方面的图书,比较经典的就是“龙书”“虎书”和“鲸书”,此外还有Linkers and Loaders和《程序员的自我修养》。尤其是《程序员的自我修养》这本书,中文语境和写作思维更适合国内的程序员阅读,把程序的编译、链接、运行的各个细节都已经讲得很清楚了。

从源程序到二进制文件

//sub.c
int add(int a,int b)
{
return a + b;
}

int sub(int a,int b)
{
return a - b;
}

//sub.h
int add(int a, int b);
int sub(int a, int b);

//main.c
#include <stdio.h>
#include "sub.h"
int global_val = 1;
int uninit_cal;
int main(void)
{
int a, b;
static int local_val = 2;
static int uninit_local_val = 2;
a = add(2, 3);
b = sub(5, 4);
printf("a=%d\n", a);
printf("b=%d\n", b);
return 0;
}

C编译器扩展语法

指定初始化

当我定义一个数组b[100],想要初始化b[10]、b[12]为一个非零数值,如果前面还有按固定顺序初始化,那{}里要填充大量的0.

那C99改进了数组的初始化方式

int b[100]={[10]=123,[12]=23};

通过数组元素索引,我们可以直接给指定的数组元素赋值。除了数组,一个结构体变量的初始化,也可以通过指定某个结构体成员直接赋值。

指定初始化数组几个元素

int b[100]={[10 ... 15]=123,[51 ... 69]=23};

GNU C支持使用...表示范围扩展,这个特性不仅可以使用在数组初始化中,也可以使用在switch-case语句中,如下面的程序。

image-20220823185535968

这里同样有一个细节需要注意,就是...和其两端的数据范围2和8之间也要有空格,不能写成2...8的形式,否则会报编译错误。

指定初始化结构体成员

    FSM_t Gimbal_Fsm = {
.Current_State=NULL,
.Last_State=NULL,
.State_Table=Gimbal_State_Table,
.State_Change=StateChange //状态机状态变更函数
};

我们采用GNU C的初始化方式,通过结构域名.Current_State这些,就可以给结构体变量的某一个指定成员直接赋值。

语句表达式

GNU C对C语言标准作了扩展,允许在一个表达式里内嵌语句,允许在表达式内部使用局部变量、for循环和goto跳转语句。这种类型的表达式,我们称为语句表达式。语句表达式的格式如下。

({表达式1;表达式2;})

语句表达式最外面使用小括号()括起来,里面一对大括号{}包起来的是代码块,代码块里允许内嵌各种语句。语句的格式可以是一般表达式,也可以是循环、跳转语句。

和一般表达式一样,语句表达式也有自己的值。语句表达式的值为内嵌语句中最后一个表达式的值。我们举个例子,使用语句表达式求值。

例如

    int sum = (
{
int i = 0;
for (i = 0; i < 100; ++i) {
i = i + 1;
}
i;
}
);

最后一句是一个i; 语句,表示整个语句表达式的值

0长度数组

除了GCC编译器,在其他编译环境下可能就编译错误或者有警告信息。

使用例

零长度数组经常以变长结构体的形式,在某些特殊的应用场合使用。

在一个变长结构体中,零长度数组不占用结构体的存储空间

但是我们可以通过使用结构体的数组成员去访问内存,非常方便。

malloc不同大小即可实现不同的数组长度

image-20220825164026671

思考:指针与0长数组

为什么不使用指针来代替零长度数组?

在各种场合,大家可能常常会看到这样的字眼:

数组名在作为函数参数进行参数传递时,就相当于一个指针。

注意,我们千万别被这句话迷惑了:数组名在作为参数传递时,传递的确实是一个地址,但数组名绝不是指针,两者不是同一个东西。数组名用来表征一块连续内存空间的地址,而指针是一个变量,编译器要给它单独分配一个内存空间,用来存放它指向的变量的地址。我们看下面的程序。

image-20220825165749392

总而言之

指针远远没有零长度数组用得巧妙:零长度数组不会对结构体定义造成冗余,而且使用起来很方便。

属性声明:section

GNU C增加了一个attribute关键字用来声明一个函数、变量或类型的特殊属性

声明这个特殊属性有什么用呢?

主要用途就是指导编译器在编译程序时进行特定方面的优化或代码检查

例如,我们可以通过属性声明来指定某个变量的数据对齐方式。

image-20220825172959702

需要注意的是,attribute后面是两对小括号,不能图方便只写一对,否则编译就会报错。括号里面的ATTRIBUTE表示要声明的属性。目前attribute支持十几种属性声明。

  • section.
  • aligned.
  • packed.
  • format.
  • weak.
  • alias.
  • noinline.
  • always_inline.

在这些属性中,aligned和packed用来显式指定一个变量的存储对齐方式(ps.我们在很多需要字节对齐的地方有用到,但是不知道有什么用)。在正常情况下,当我们定义一个变量时,编译器会根据变量类型给这个变量分配合适大小的存储空间,按照默认的边界对齐方式分配一个地址。而使用atttribute这个属性声明,就相当于告诉编译器:按照我们指定的边界对齐方式去给这个变量分配存储空间

image-20220825174825355

内联函数

说起内联函数,又不得不说函数调用开销。一个函数在执行过程中,如果需要调用其他函数,则一般会执行下面的过程。

(1)保存当前函数现场。

(2)跳到调用函数执行。

(3)恢复当前函数现场。

(4)继续执行当前函数。

如有一个ARM程序,在main()函数中对一些数据进行处理,运算结果暂时保存在R0寄存器中。接着调用另外一个func()函数,调用结束后,返回main()函数继续处理数据。如果我们在func ()函数中要使用R0这个寄存器(用于保存函数的返回值),就会改变R0寄存器中的值,那么就篡改了main ()函数中的暂存运算结果。当我们返回main ()函数继续进行数据处理时,最后的结果肯定不正确。

那么怎么办呢?很简单,在跳到func()函数执行之前,先把R0寄存器的值保存到堆栈中,func()函数执行结束后,再将堆栈中的值恢复到R0寄存器,这样main()函数就可以继续执行了,就像什么事情都没有发生过一样。

这种方法被证明是可行的:现在的计算机系统,无论什么架构和指令集,一般都采用这种方法。这种方法虽然麻烦了点,但至少能解决问题,无非就是需要不断地保存现场、恢复现场,这就是函数调用带来的开销。

对于一般的函数调用,这种方法是没有问题的。但对于一些极端情况,例如,一个函数短小精悍,函数体内只有一行代码,在程序中被大量频繁地调用。如果每次调用,都不断地保存现场,执行时却发现函数只有一行代码,接着又要恢复现场,则来回折腾的开销比较大,性价比不高。这就和你去五星级酒店订个餐位吃饭一样:VIP包间、刀叉餐具、空调、免费的茶水和小菜,服务人员都准备好了,你到了之后只点了一碗面条,吃完之后抹嘴走人,而且一连好多天你都这么干,你说商家会不会对你有意见?

函数调用也是如此:有些函数短小精悍,而且调用频繁,调用开销大,算下来性价比不高,这时候我们就可以将这个函数声明为内联函数。编译器在编译过程中遇到内联函数,像宏一样,将内联函数直接在调用处展开,这样做就减少了函数调用的开销:直接执行内联函数展开的代码,不用再保存现场和恢复现场。

内联函数与宏

为什么要用inline?

  1. 参数类型检查:内联函数虽然具有宏的展开特性,但其本质仍是函数,在编译过程中,编译器仍可以对其进行参数检查,而宏不具备这个功能。
  2. 便于调试:函数支持的调试功能有断点、单步等,内联函数同样支持。
  3. 返回值:内联函数有返回值,返回一个结果给调用者。这个优势是相对于ANSI C说的,因为现在宏也可以有返回值和类型了,如前面使用语句表达式定义的宏。
  4. 接口封装:有些内联函数可以用来封装一个接口,而宏不具备这个特性。(能封装什么?)

数据存储与指针

image-20220826145135355

ANSI C标准为我们提供的32个关键字里,除了控制程序结构的一些关键字,绝大部分都与数据类型和存储相关

image-20220826145207253

很多时候我们编写的程序运行出现问题往往都和数据在内存中的存储有关,一个小小的细节疏忽,程序可能就运行异常了,如野指针、非法指针、数据溢出、大小端模式等。

除此之外,C语言在类型转换、隐式转换、有符号数与无符号数、数据对齐等方面也有不少编程陷阱,很多C语言初学者稍微不注意,就有可能栽倒在一个容易疏忽的细节上。

数据类型与存储

在一个32位的计算机系统中,通常4字节组成一个字(Word),字是软件开发者常用的存储单位。

一个数据在内存中有2种存储方式:高地址存储高字节数据,低地址存储低字节数据;或者高地址存储低字节数据,而低地址则存储高字节数据。不同字节的数据在内存中的存储顺序被称为字节序。根据字节序的不同,我们一般将存储模式分为大端模式和小端模式。

image-20220826145603995

不同架构的处理器,存储模式一般也不同。ARM、X86、DSP一般都采用小端模式,而IBM、Sun、PowerPC架构的处理器一般都采用大端模式。

如何判断程序运行的当前平台是大端模式还是小端模式呢?很简单,我们编写一个程序测试一下就可以知道。

image-20220826145717896

PS:8bit=1byte 在32位系统,1int = 4byte (4*8=32bit)

数据对齐

这段属实没懂

image-20220826154203228

结构体对齐

当我们调整结构体成员先后顺序,会发现会变大小,是因为对齐的原因

image-20220826160437236

typedef使用例

image-20220826161104596

感觉会好看一点

和函数指针结合

image-20220826161232197

image-20220826161515270

与枚举结合

image-2022082616151570

OOP面向对象

代码复用与分层思想

面向对象编程基础

类的封装和实例化

class的概念可以用一个结构体打包

属性就扔个变量

方法就扔个函数指针

typedef __packed struct
{
REMOTE_t *RC; //遥控器主结构体
FSM_t *Gimbal_Fsm;
Grasp_t *Graps;
// void (*Lift_Up_fun)(Lift_UP_t*,int16_t);
void (*PowerOff)(Grasp_t*);//云台断电
}Gimbal_t;

下面两个便是函数指针

继承,就把父类结构体扔到子类结构体里,多态也亦如此

(其实面向对象也就这些了

Linux内核OOP

链表抽象与封装

image-20220903171357660

这是Linux内核中为了实现对链表操作的代码复用,定义了一个通用的链表及相关操作。

你会发现这个listhead并没有用到数据域,

其实,我们可以将结构类型list_head及相关的操作看成一个基类,其他子类如果想继承子类的属性和方法,直接将list_head内嵌到自己的结构体内即可。

image-20220903171515040

image-20220903171623517

继承与私有指针

为了更好地使用OOP思想理解内核源码,我们可以把继承的概念定义得更宽松一点,除了内嵌结构体,C语言还可以有其他方法来模拟类的继承,如通过私有指针。

我们可以把使用结构体类型定义各个不同的结构体变量,也可以看作继承,各个结构体变量就是子类,然后各个子类通过私有指针扩展各自的属性或方法。

这种继承方法主要适用于父类和子类差别不大的场合。如Linux内核中的网卡设备,不同厂家的网卡、不同速度的网卡,以及相同厂家不同品牌的网卡,它们的读写操作基本上都是一样的,都通过标准的网络协议传输数据,唯一不同的就是不同网卡之间存在一些差异,如I/O寄存器、I/O内存地址、中断号等硬件资源不相同。

遇到这些设备,我们完全不必给每个类型的网卡都实现一个结构体。我们可以将各个网卡一些相同的属性抽取出来,构建一个通用的结构体net_device,然后通过一个私有指针,指向每个网卡各自不同的属性和方法,通过这种设计可以最大程度地实现代码复用。如Linux内核中的net_device结构体。

image-20220903172121548

在net_device结构体定义中,我们可以看到一个私有指针成员变量:ml_priv。

当我们使用该结构体类型定义不同的变量来表示不同型号的网卡设备时,这个私有指针就会指向各个网卡自身扩展的一些属性。

如在bfin_can.c文件中,bfin_can这种类型的网卡自定义了一个结构体,用来保存自己的I/O内存地址、接收中断号、发送中断号等。

image-20220903172406722