关于嵌入式开发的一些信息汇总:C标准、芯片架构、编译器、MISRA-C
- 关于C标准
- 芯片架构是什么?
- 架构对芯片有什么作用?
- arm架构
- X86架构
- mips架构
- 小结
- 编译器
- LLVM是什么?
- 前端在干什么?
- 后端在干什么?
- MISRA C的诞生
以前写过的一些零零散散的小结,也没有系统的整理过,慢慢把它们收集更新在这篇博文里面。
内容算是集大成者,有一些来自于网上的其它作者,在文后给出了原文链接。后面还会专门做一期关于嵌入式的专栏,希望大家持续关注。
关于C标准
-
C是一门开发者语言。C要转为机器语言,才能运行在芯片上。
-
语言的转化需要编译器,每种芯片有自己认识的机器语言格式,即指令集,需要独立的编译器,而芯片有多种架构,主流的有:X86、ARM、RISC-V和MIPS。
- X86 英特尔和AMD在PC市场上主导多年,CISC指令集
- ARM 在移动和便捷设备上有明显优势,32位RISC指令集
- MIPS 在网关、机顶盒市场上很受cq欢迎,32/64位RISC指令集
- RISC-V 出现晚发展快,在智能穿戴产品应用广泛,开放ISA架构
架构 发明时间 特点 代表厂商 主要应用领域 国内相关公司 X86 1976年 性能高,速度快,兼容性好 英特尔 AMD PC、服务器 中科曙光、浪潮信息 MIPS 1981年 简洁、优化方便、高拓展性 龙芯 网关、机顶盒 龙芯中科 ARM 1983年 成本低、低功耗 苹果、谷歌、IBM、华为 移动端、网络设备 中国长城、华为 RISC-V 2014年 完全开源、精简、易于移植 三星、英伟达、西部数据 智能穿戴产品 全志科技、兆易创新、北京君正 -
指令集架构(ISA, Instrucion Set Architecture)定义了基本数据类型(BYTE/HALFWORD/WORD/…)、寄存器(Register)、指令、寻址模式、异常、或者中断的处理方式等。一台计算机的指令系统反映了该计算机的全部功能,机器类型不同,其指令系统也不同,因而功能也不同。
-
处理器分为精简指令集计算机(RISC)(Reduced Instruction Set Computer)和复杂指令集计算机(CISC)(Complex Instruction Set Computer)。
-
不同的处理器(CPU)会用相应的汇编语言编写底层操作程序,而在写这个汇编语言的时候需要依照指令集架构这个规则,从而处理器进行操作。
-
CPU依靠指令来计算和控制计算机系统,每款CPU在设计时就规定了一系列与其硬件电路相配合的指令系统。
芯片架构是什么?
如前文所述,芯片架构是指对芯片的类别和属性的描述。架构一词还和语境有关,提到soc时,一般指嵌入式处理核心的类型。提到X86和arm时,指的是指令集。
架构对芯片有什么作用?
如果把CPU看作是一个解释器,架构是算法,寄存器转换级电路(RTL)是算法的实现,那么更好的架构就是更好的算法。
arm架构
Arm是高级精简指令集的简称,是一个32位的精简指令集处理器架构。
其架构图如下:(该图来自于网络)
结构说明如下:
ALU:有两个操作数锁存器、加法器、逻辑功能、结果以及零检测逻辑构成。
桶形移位寄存器:ARM采用了32位的桶形移位寄存器,这样可以使在左移右移n位、环移n位和算术右移n位等都可以一次完成。
高速乘法器:乘法器一般采用“加一移位”的方法来实现乘法。ARM为了提高运算速度,则采用两位乘法的方法,根据乘数的2位来实现“加一移位”运算;ARM高速乘法器采用32*8位的结构,这样,可以降低集成度(其相应芯片面积不到并行乘法器的1/3)。
浮点部件:浮点部件是作为选件供ARM构架使用。FPA10浮点加速器是作为协处理方式与ARM相连,并通过协处理指令的解释来执行。
控制器:ARM的控制器采用的是硬接线的可编程逻辑阵列PLA。
特点:
(1)体积小、低功耗、低成本、高性能ARM被广泛应用在嵌入式系统中的最重要的原因 支持Thumb(16位)/ARM(32位)双指令集,能很好的兼容8位/16位器件;
(2)大量使用寄存器,指令执行速度更快;
(3)大多数数据操作都在寄存器中完成;
(4)寻址方式灵活简单,执行效率高;
(5)指令长度固定。
(6)Load_store结构:在RISC中,所有的计算都要求在寄存器中完成。而寄存器和内存的通信则由单独的指令来完成。而在CSIC中,CPU是可以直接对内存进行操作的,流水线处理方式。
X86架构
X86架构(The X86 architecture)是微处理器执行的计算机语言指令集,指一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合。其PC架构如下图所示:
结构说明如下:
CPU:大家都不陌生的名词,中央处理器,计算机的核心大脑。
北桥(North Bridge Chipset):北桥是电脑主板上的一块芯片,位于CPU插座边,起连接作用。
南桥芯片(South Bridge):是主板芯片组的重要组成部分,一般位于主板上离CPU插槽较远的下方,PCI插槽的附近,这种布局是考虑到所连接的I/O总线较多,离处理器远一点有利于布线。
内存:是计算机中重要的部件之一,是与CPU进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。
显卡(Video card,Graphics card):全称显示接口卡,又称显示适配器,是计算机最基本配置、最重要的配件之一。
网卡:是工作在链路层的网络组件,是局域网中连接计算机和传输介质的接口,不仅能实现与局域网传输介质之间的物理连接和电信号匹配,还涉及帧的发送与接收、帧的封装与拆封、介质访问控制、数据的编码与解码以及数据缓存的功能等。
声卡:基本功能是把来自话筒、磁带、光盘的原始声音信号加以转换,输出到耳机、扬声器、扩音机、录音机等声响设备,或通过音乐设备数字接口(MIDI)使乐器发出美妙的声音。
SATA(Serial Advanced Technology Attachment,串行高级技术附件):是一种基于行业标准的串行硬件驱动器接口,是由Intel、IBM、Dell、APT、Maxtor和Seagate公司共同提出的硬盘接口规范。
硬盘:是电脑主要的存储媒介之一,由一个或者多个铝制或者玻璃制的碟片组成。碟片外覆盖有铁磁性材料。
mips架构
MIPS,全称为Microprocessor without Interlocked Pipeline Stage。采用5级指令流水线,能够以接近每个周期一条指令的速率执行。这在当时很罕见。它是一种采取精简指令集(RISC)的处理器架构,其特点为:
(1)包含大量的寄存器、指令数和字符。
(2)可视的管道延时时隙。
1984年,John Hennessy离开斯坦福,创立了MIPS科技公司。并且在成立的第二年就推出了第一个芯片设计R2000。
1988年,MIPS推出了R3000。这款产品很快大获成功,销售超百万颗。不少公司的消费电子产品都用到了R3000,比如索尼的PS。美国首家电脑公司DEC、爱普生、日本电器等等知名企业也均是其客户。
1991年,MIPS就推出了64bit的R4000。其竞争对手Arm则直到2012年才开始大范围推广64bit处理器设计。
直到现在,仍可以在不少产品中找到身影。比如英特尔旗下的自动驾驶公司Mobileye就仍在广泛采用其技术。而家用路由器产品中,MIPS也并不鲜见。
MIPS的创始人John Hennessy和RISC-V之父Dave Patterson也渊源颇深。两人合作撰写了2本现在被广泛用于本科生、研究生课程的教科书:《计算机体系结构:量化研究方法》和《计算机组成与设计:硬件/软件接口》。
小结
编译器
芯片是一个硬件,接收的是二进制的指令,要想让自己的编程语言转化为编程指令,就需要一个编译器。
这个部分的重要程度丝毫不亚于芯片本身。最近国内很多公司在做AI芯片,经常出现芯片很快就做出来了,但芯片受限于编译器无法发挥最大能效的窘境。总之,了解编译器还是很重要的。
这个系列讲讲如何用LLVM做一个最简单的编译器。万变不离其宗,其他复杂的编译器可以从这个例子上拓展。本部分主要讲基础知识,不需要了解细节,但是对编译器整体如何工作的要有概念。
LLVM是什么?
首先一个东西要搞明白,为什么要用LLVM? LLVM的是什么?
LLVM提供了一个模块化的编译器框架,让程序员可以绕开繁琐的编译原理,快速实现一个可以运行的编译器。主要由三个部分组成。
- 前端:将高级语言例如C或者其他语言转换成LLVM定义的中间表达方式 LLVM IR。例如非常有名的clang, 就是一个转换C/C++的前端。
- 中端:中端主要是对LLVM IR本身进行一下优化,输入是LLVM, 输出还是LLVM, 主要是消除无用代码等工作,一般来讲这个部分是不需要动的,可以不管他。
- 后端:后端输入是LLVM IR, 输出是我们的机器码。我们通常说的编译器应该主要是指这个部分。大部分优化都从这个地方实现。
至此,LLVM架构的模块化应该说的比较清楚了,它的最大的一个特点是隔离了前后端。如果你想支持一个新语言,就重新实现一个前端,例如华为“仓颉”就有自己的前端来替换clang。如果你想支持一个新硬件,那你就重行实现一个后端,让它可以正确的把LLVM IR映射到自己的芯片。
接下来我们大致讲讲前后端的流程。
前端在干什么?
我们以Clang举例,我们以Clang举例,前端主要实现四件事,即经过词法分析、语法分析、语义分析、LLVM IR生产,最终将C++转化成后端认可的LLVM IR。
词法分析:将编程语言取出一个个词,遇到不认识的字符就报错。例如将a=b+c 拆成a,= ,b ,+, c
语法分析:将语法提取出来,例如你写了个a+b=c, 明显不符合语法,直接报错
语义分析:分析一下你写的代码实际含义是不是对,例如a=b+c, a,b,c有没有定义,类型是不是对的
LLVM IR生产:经过上述三步,将你写的代码转化成树状描述(抽象语法树),然后再转化成IR定义的IR即可。
举个直观的栗子:
你写的C++如下: add.cpp
int add(int a, int b) {
return a + b;
}
生产的LLVM IR如下
(这个地方你不需要看懂每个细节,知道大概想类汇编的语言就行了, 专业的形式叫SSA, Static Single Assignment (SSA)
; ModuleID = ‘add.cpp’
source_filename = “add.cpp”
target datalayout = “e-m:o-i64:64-f80:128-n8:16:32:64-S128”
target triple = “x86_64-apple-macosx10.15.0”
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @_Z3addii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
}
后端在干什么?
后端把你的LLVM转换成真正的汇编(或者机器码),主要的流程如下。这个我们要重点讲讲,因为后续我们就是要实现一个这个东西支持一个新的芯片。先上图:
按步骤一个一个讲。
1、 DAG Lowering
这个主要负责将你的LLVM IR转换为有向无环图,便于后续利用图算法优化。
例如将下面的LLVM IR 转换成图,每个节点是一个指令。
2、 DAG Legalization
DAG图合法化,1中的DAG图都是LLVM IR指令,但实际上LLVM IR指令不可能被芯片全部支持,这个步骤就是替换这些不合法的指令。
3、 Instruction Selection
这个步骤其实和2算是一起的功能,都是为了将LLVM IR转换成机器支持的Machine DAG.
如上图,将store换成机器仍可的st, 将16位的寄存器转向32位。一切向机器指令靠拢。
4、 Scheduling
这个步骤主要是调整指令顺序的,从有向无环图再展开成顺序的指令。
例如把下面的指令调成这样的。
把%C的store提前一些,因为下一条ld要用C啦。
5、 SSA-based Machine Code Optimization
这一步骤主要是做一些公共表达式合并啊去除的操作。
6、 Register Allocation
这一步就要分配寄存器了。在5之前我们认为寄存器其实是可以无限用的,但实际硬件的寄存器有限的,所以我们得考虑寄存器数量与寄存器值的生命周期,将虚拟的寄存器替换成实际的寄存器。这个一般会用到图着色等等算法,贼复杂,好在LLVM都实现好了,不用在重复造轮子。
例如一个芯片,有32个可用的寄存器,如果函数使用到了64个,多的就只能压入堆栈或者等着了。具体怎么分配的,知乎有专家研究,见下面的文章。Frank Wang:LLVM寄存器分配(一)
7、 Prologue/Epilogue Code Insertion
这个主要是加上函数调用前的指令和函数结束后的指令。主要是调用前把参数存下来,调用后把结果写到固定的寄存器里。
8、 Peephole optimizations
这个步骤主要是对代码再最后抢救一番。比如把x*2换成x<1。再比如下面这样,将两个32bit的存储换成一个64bit的存储。
9、 Code Emission
最后一步显然,将上述优化好的中间格式转换成我们真正需要的汇编,由汇编器翻译成机器码,大功告成。
大佬的原文在此,请移步欣赏:
主流X86-ARM-RISC-V-MIPS芯片架构分析
主存储器和DRAM
内存基础知识
MISRA C的诞生
-
C语言紧凑、表达力强、功能强大。它为程序员提供了编写高效、可读和可维护代码的方法。所有这些功能都说明了它的受欢迎程度。不幸的是,该语言还使粗心的开发人员能够编写危险的、不安全的代码,这些代码可能会在开发项目的所有阶段和部署阶段导致严重问题。对于安全性和/或安全性是主要优先事项的应用程序,语言的这些缺点是一个主要问题。
-
正是在这种背景下,在 1990 年代后期,汽车工业软件可靠性协会 (MISRA) 推出了一套关于在车辆系统中使用 C 语言的指南,后来被称为 MISRA C。从那时起,该指南一直稳步发展完善,不时发布更新。还建立了使用 C++ 的类似方法。尽管该指南最初是针对汽车软件开发人员的,但很快人们就意识到它们同样适用于许多其他安全至关重要的应用领域,并且该标准现在已被许多行业广泛采用。
-
尽管 MISRA C 不是风格指南——实际上许多用户应用风格指南和标准——许多规则也促进编写清晰、可读、可维护的代码。这是非常有益的,因为易于理解的代码不太可能包含细微的错误或未定义的行为。
-
我的参考资料来自 MISRA C:2012 第三版,第一次修订版本。MISRA C 一直在接受审查,增量更改解决了指南的清晰度和准确性,并支持更新版本的 C 语言标准。尽管细节发生了变化,但总体理念和方法没有变化。
引用其中几个作为示例说明。规则 13.2 – 表达式的值及其带来的副作用在所有被允许的评估顺序下应是相同的
C 语言标准在表达式的求值顺序方面为编译器提供了非常广泛的自由度。因此,任何对评估顺序敏感的代码都是依赖于编译器的,并且依赖于编译器的代码应始终被视为不安全的。
例如,自增自减运算符的使用可能会很麻烦:
val = n++ + arr[n];
访问了arr的哪个元素?程序员是否期望用于索引数组的n的值是递增之前或之后的值?虽然看起来好像增量是在数组索引之前执行的,但它假设了左右表达式求值,这不是一个有效的假设。所以,代码不清晰,应该这样重写:
val = n + arr[n+1];
n++;
或者
val = n++;
val += arr[n];
甚至
值=n;
n++;
val += arr[n];
您选择哪个选项取决于个人风格。它们都执行相同的操作,事实上,优化编译器很可能会生成完全相同的代码。
在一个表达式中使用多个函数调用可能会出现类似的问题。函数调用可能会产生副作用,影响另一个函数。例如:
val = fun1() + fun2();
在这种情况下,如果其中一个函数可以影响另一个函数的结果,则代码是不明确的。要编写安全的代码,必须消除任何可能的歧义:
val = fun1();
val += fun2();
现在很清楚fun1()先执行了。规则 17.2 – 函数不得直接或间接调用自身
有时,表达算法的一种优雅方式是使用递归。然而,除非递归受到非常严格的控制,否则存在堆栈溢出的危险,这反过来又会导致很难定位错误。在安全关键代码中,应避免递归。规则 19.2 – 不应使用 union 关键字
尽管 C 是一种类型化语言,但类型化并不是很严格,开发人员可能会试图覆盖类型化以“简化”他们的代码。遵守数据类型的约束对于创建安全代码至关重要,因为任何绕过数据类型的尝试都可能产生未定义的结果。union关键字可用于多种目的,这通常会导致代码不清晰,但也可以作为一种避免键入的方法。
举个例子是使用联合来“拆开”一个无符号整数,因此:
union e
{
unsigned int ui;
无符号字符 a[4];
}F;
在这种情况下, ui的每个字节都可以作为a的一个元素来访问。但是,我们不能确定a[0]是否是最高有效字节中的最低字节,因为这是一个实现问题。(本质上与处理器的字节序相关联。)替代方法可能是使用移位和掩码,因此:
unsigned char getbyte(unsigned int input, unsigned int index)
{
输入 >>= (index * 8);
返回输入 & 0xff;
}
可能有人会争辩说,这些规则(以及大多数(即使不是全部)MISRA C)只是常识,任何优秀的程序员都会采用这种方法。这可能是正确的,但一套明确的指导方针可以减少出错的机会。