共有回帖数 0 个
-
写在前面的话
写本文的目的有:
1. 献给三跃妈
2. 笔者自认为至少将脱离技术道路若干年甚至从此脱离,写此文以作纪念
3. 目前的教科书有一些严重的问题影响了很多的人,至少笔者学习的道路比较坎坷
4. 希望本文能够给一些想了解嵌入式的有志青年
5. 计算机的书籍很少把一些东西搅合到一起说,讲操作系统的书专讲操作系统,讲 C 语言的书专讲语法,甚至有些程序员写了5年程序不能把自己的程序的来龙去脉讲清楚。C是把这些问题串到一起的切入点。
本文的标题也意味着其中没有过多的细节讲解,也不是一章讲解C语言的教程。(其实是笔者岁数大了懒得翻阅资料了,笔者已经30高龄了,希望大家谅解)。另外笔者希望读者有 C 语言的基础,了解至少一种 CPU 的汇编语言。
另外,目前关于 C 语言是不是过时的争论很多,我不想参与这些争论,正所谓萝卜白菜各有所爱��
囫囵C语言(一):可执行文件的结构和加载
看到这个标题很多人可能想,老家伙老糊涂了。可执行文件的结构和 C 语言有什么关系。
我们先来看一个程序:
/////////////////////////////////////////////////////////////////
int global_a = 0x5; /* 01 */
int global_b; /* 02 */
/* 03 */
int main() /* 04 */
{ /* 05 */
char *q = "123456789"; /* 06 */
/* 07 */
q[3] = 'A'; /* 08 */
/* 09 */
global_a = 0xaaaaaaaa; /* 10 */
global_b = 0xbbbbbbbb; /* 11 */
/* 12 */
// strcmp(q, NULL); /* 13 */
return 0x0; /* 14 */
} /* 15 */
1. 你能说出程序中出现的变量和常量在可执行程序的哪个段中么?
2. 程序运行的结果是什么?
/////////////////////////////////////////////////////////////////
能正确回答上面问题者,此节可以跳过不读:
如果有人问笔者第一个问题,笔者会响亮的回答:“不知道”!。因为你没告诉我目标 CPU,编译器,链接器。
如果有人问笔者第二个问题,笔者会更响亮的回答:“不知道”!。因为你没告诉我链接器,链接参数,目标操作系统。
比如 "123456789" 在某些编译环境下出现在 ".text" 中,某些编译环境下出现在 ".data" 中。
再比如,如果用 VC6.0 环境,编译时加上 /GF 选项,该程序会崩溃(第 8 行)。
再比如第 13 行,这种错误极为愚蠢,但是在某些操作系统下居然执行得挺顺利,至少不会崩溃(一种HP的UNIX操作系统上,可惜笔者没有留意版本号)。
所以 C 程序严重依赖于,CPU,编译器,链接器,操作系统。正是因为这种不确定性,所以为了保证你写的程序能在各种环境下运行,或者你想能够在任何环境下 debug 你的 C 程序。你必须知道可执行文件的格式和操作系统如何加载。否则当你在介绍自己的时候,只能使用类似:“我是X86平台上,VC6.0集成开发环境下的 C 语言高手” 之类的描述。颇为尴尬。
为了说明方便我们的讨论建立在一套虚拟的环境上。当然了这仅限于宏观的讨论,一些具体的例子我会给出我调试所用的环境。我们假设虚拟环境满足下列条件:
1. 足够物理内存
2. 操作系统不允许缺页中断
3. 物理页面 4K
4. 二级页表映射
5. 4G 虚拟地址空间
6. 操作系统不支持 swap 机制
7. I/O 使用独立的地址空间
8. 有若干通用寄存器 r0,r1,r2,r3,......
9. 函数的返回值放在 r0 中
10. 单 CPU
(哈哈,没有具体的环境,我说错了也没人知道)
言归正传,过于古老的文件结构我们不提(入门的格式请参考 a.out 格式)。现在比较常用的文件格式是 ELF 和 PE/COFF。嵌入式方面 ELF 比较主流。
可执行文件基本上的结构如下图:
+----------------------------------+
| |
| 文件头 |
| |
+----------------------------------+
| |
| 段描述表 |
| |
+----------------------------------+
| |
| 段1 |
| |
+----------------------------------+
| |
| : |
| |
+----------------------------------+
| |
| 段n |
| |
+----------------------------------+
其中这些段中常见的段有 .text,.rodata,.rwdata,.bss。还有一些段因为编译器和文件格式有细微差别我们不再一一说明。
参考:1. Executable and Linkable Format Specification
2. PE/COFF Sepcification
.text:正文段,也称为程序段,可执行的代码
.rodata:只读数据段,存放只读数据
.rwdata:可读写数据段,
.bss段:未初始化数据 (下文详述)
有了虚拟的环境就好蒙了:就上面的例子来说,我们先回答第一个问题:
1. a 在 .rwdata 中
2. b 在 .bss 中
3. q 程序运行的时候从 stack 中分配
4. 'A',0x5,0xaaaaaaaa,0xbbbbbbbb,0x0 在 .text 段。
5. "123456789" 在 .rodata 中
第二个问题,程序在第 8 行会崩溃。程序为什么会崩溃呢?要回答这个问题我们要知道可执行程序的加载。
可执行程序的加载
当操作系统装载一个可执行文件的时候,首先操作系统盘但该文件是否是一个合法的可执行文件。如果是操作系统将按照段表中的指示为可执行程序分配地址空间。操作系统的内存管理十分复杂,我们不在这里讨论��
就上面的例子来说可执行文件在磁盘中的 layout 如下:(假设程序的虚拟地址从 0x00400000 开始,该平台的页面大小是 4K)
+----------------------------------+
| |
| 文件头 |
| |
+----------------------------------+------------------
| .text 描述 | ^
| 虚拟地址起始位置 : 0x00400000 | |
| 占用虚拟空间大小 : 0x00001000 | |
| 实际大小 : 0x00000130 | |
| 属性 :执行/只读 | |
+----------------------------------+ |
| .rwdata 描述 | |
| 虚拟地址起始位置 : 0x00401000 | |
| 占用虚拟空间大小 : 0x00001000 |
| 实际大小 : 0x00000004 | 段描述表
| 属性 :读写 | |
+----------------------------------+
| .rodata 描述 | |
| 虚拟地址起始位置 : 0x00402000 | |
| 占用虚拟空间大小 : 0x00001000 | |
| 实际大小 : 0x0000000A | |
| 属性 :只读 | |
+----------------------------------+ |
| .bss 描述 | |
| 虚拟地址起始位置 : 0x00403000 | |
| 占用虚拟空间大小 : 0x00001000 | |
| 实际大小 : 0x00000000 | |
| 属性 :读写 | v
+----------------------------------+-----------------
| |
| .text 段 | - 4K对齐,不满补 0
| |
+----------------------------------+-----------------
|0x5 |
| .rwdata 段 | - 4K对齐,不满补 0
| |
+----------------------------------+-----------------
|123456789 |
| .rodata 段 | - 4K对齐,不满补 0
| |
+----------------------------------+-----------------
请注意,.bss 段仅仅有描述,在文件中并不存在。为什么呢?.bss 专用于存放未初始化的数据。因为未初始化的数据缺省是 0,所以只需要标记出长度就可以了。操作系统会在加载的时候为它分配清 0 的页面。这种技术好像叫做 ZFOD (Zero Filled On Demand)。
操作系统首先将文件读入物理页面中(物理页面的管理比较复杂,不属于本文讨论的范围),反正大家就认为操作系统找到了一批空闲的物理页面,将可执行文件全部装载。如图:
:
+----------------------------------+ ---- 物理页面对齐
| |
| .text 段 |
| |
+----------------------------------+
:
:
+----------------------------------+ ---- 物理页面对齐
|0x5 |
| .rwdata 段 |
| |
+----------------------------------+
:
:
+----------------------------------+ ---- 物理页面对齐
|123456789 |
| .rodata 段 |
| |
+----------------------------------+
:
:
在物理地址中,这几个段并不连续,顺序也不能保证,甚至如果一个段占用几个页面的时候,段内的连续性和顺序都不能保证。实际上我们也不程序关心在物理内存中的 layout。只需要页面对齐即可。
最后操作系统为程序创建虚拟地址空间,并建立虚拟地址-物理地址映射(虚拟地址的管理十分复杂,反正大就认为映射建好了。另外:注意我们的假设,系统不支持缺页机制和 swap 机制,否则没有这么简单)。然后我们从虚拟地址空间看来,程序的 layout 如下图:
+----------------------------------+ 0x00400000
| |
| .text 段 |
| |
+----------------------------------+ 0x00401000
|0x5 |
| .rwdata 段 |
| |
+----------------------------------+ 0x00402000
|123456789 |
| .rodata 段 |
| |
+----------------------------------+ 0x00403000
| |
| .bss 段 |
| |
+----------------------------------+
同时操作系统会根据段的属性设置页面的属性,这就是为什么通常程序的段是页面对齐的,因为机器只能以页面为单位设置属性。
所以第二个问题自然就有了答案。程序会 crash。因为 .rodata 段所属的页面是只读的。其实有些编译器会将常量 "123456789" 放在 ".text" 中,其实是一样的,两个段都是只读的,写操作都会导致非法访问,甚至同一种编译器,不同的变异参数,这个常量也会出现在不同的位置。实际上这个保护由编译器,链接器,操作系统,CPU串通好了,共同完成的。
所以说计算机有些具体问题并没有一定之规,但是他们基本的原理是一样的。我们掌握了基本原理,具体问题可以具体分析。
囫囵C语言(二):陷阱,中断和异常
上一章怀疑笔者老糊涂的读者,看到这个标题,基本上已经打消了疑虑:老家伙确实糊涂了。这三个概念和C语言有什么关系呢?
中断这个词恐怕人民群众都不陌生。很多人把中断分为两种:硬件中断和软件中断。其实怎么叫关系都不大,关键是我们要明白他们之间的异同点。
笔者本身比较喜欢把 “中断”,分为三种即陷阱,中断和异常,我似乎记得Intel是这么划分的(这句话我不保证正确啊,有兴趣的读者自己看一下 Intel 的手册)。他们的英文分别是 trap,interrupt 和 exception。
陷阱 (trap):
大家都知道,现代的CPU都是有优先级概念的,用户程序运行在低优先级,操作系统运行在高优先级。高优先级的一些指令低优先级无法执行。有一些操作只能由操作系统来执行,用户想要执行这些操作的时候就要通知操作系统,让操作系统来执行。用户态的程序就是用这种方法来通知操作系统的。
具体怎样做的呢?操作系统会把这些功能编号,比如向一个端口写一个字符的功能调用编号 12,有两个参数,端口号 port 和写入的字符 bytevalue。我们可以如下实现:(这个例子无法编译,但是这种汇编和 C 混合编程的风格微软的编译器支持,十分好用,顺便夸一句微软,他们的编译器是我用过得最优秀的商业编译器)
int outb(int port, int bytevalue)
{
__asm mov r0, 12; /* 功能号 */
__asm mov r1, port; /* 参数 port */
__asm mov r2, bytevalue; /* 参数 bytevalue */
__asm trap /* 陷入内核 */
return r0; /* 返回值 */
}
在操作系统的 trap 处理的 handler 里面,相信大家已经知道怎么办了。有些敏感的读者可能已经明白了,原来一部分 C 的库函数是用这种方法实现的。
中断:
中断我们这里专指来自于硬件的中断,通常分为电平触发和边沿触发(请参考数字电路)。简单的说就是 CPU 每执行完一条都去检测一条管腿的电平是否变化。如果满足条件,CPU 转向事先注册好的函数。系统中最重要的一个中断就是我们经常说的时钟中断。为什么要说这个呢?这和 C 程序有什么关系呢?书上说了中断是由操作系统处理的,操作系统会保存程序的现场啊,用户程序根本感觉不到中断的存在啊。书上说得没错,但是它有两件事情没有告诉你:
1. 线程调度策略。
2. 程序的现场不包括什么?
这里笔者想插一句话表达对国内操作系统教材作者的敬仰,他们是怎么把操作系统拆成一块一块儿的呢?因为,进程管理,线程调度,内存管理,中断管理,IPC,都是互相关联的。笔者十分怀疑分块讨论的意义到底有多大。
言归正传,先回答第一个问题,线程调度时机。在哪些情况下操作系统会运行 scheduler 呢?现代操作系统调度的基本单位都是线程,所以我们不讨论进程的概念。
1. 一些系统调用
2. I/O 操作
3. 一个线程创建
4. 一个线程结束
5. mutex lock
6. P semaphore
7. 硬件中断 / 时钟中断
8. 主动放弃 CPU,比如 sleep(), yield()
9. 给另外一个线程发消息,信号
10. 主动唤醒另外一个线程
11. 进程结束
:
:
欢迎大家来电来函补充 (我记不住那么多了)
第二个问题,现场不包括什么。至少不包括全局变量。
于是就有了一个经典的面试题:
int a;
void thread_1()
{
for (;;)
{
do something;
a++;
}
}
void thread_2()
{
for (;;)
{
do something;
a--;
}
}
main()
{
create_thread(thread_1);
create_thread(thread_2);
}
现在大家应该明白这种写法的错误了吧。因为 a++,a--,并不是一条汇编语言,它会被中断打断,而中断又会引起线程调度。有可能将另外一个线程投入运行。所以结果是无法预测的。讨论这个问题的文章很多,笔者也就不多费口舌了。
提个思考题,操作系统内部,中断和中断之间,中断和线程之间,怎么保护临界资源的呢?多个 CPU 之间呢?
异常:exception
异常是指一条指令会引起 CPU 的不快,比如除零。有群众说了,如果我除零错了,操作系统把我终止了不就完了,我回去改程序,改对了重新运行不就行了么。
但是有时候 CPU 希望操作系统能够排除这个异常,然后 CPU 重新尝试去执行这条引起异常的指令。这有什么用呢?下面我给大家介绍一个十分重要的异常,缺页异常。
大家都知道,现代的 CPU 都支持虚拟内存管理,我们还是在我们的虚拟 CPU 上讨论这个问题,上面我们说过了,我们的 CPU 使用 2 级页表映射,叶面大小 4K。我实在懒得写如何映射了,请大家参考 Intel 的手册。因为我们的重点不在这里。看下面的语句:
char *p = (char *)malloc(100 * 1024 * 1024);
有人说,没什么不同啊,只不过申请的内存稍微有点儿多啊。但操作系统真地给你那么多内存了么?如果这样的程序来上几个,系统内存岂不是早被耗光,但实际上并没有。所以操作系统采用了在我国盛行的一种机制:打白条!其实我们申请内存的时候操作系统仅仅在虚空间中分配了内存,也就是说仅仅是标记着,这100M的内存归你用,但是我先不给你,当你真的用的时候我再给你分配,这个分配指的就是实实在在的物理页面了。具体怎么实现的呢?我们看下面的语句发生了什么?
p[0x4538] = 'A';
有人疑问了,普通的赋值语句啊。没错,但是这条赋值语句执行了两次(这可不一定啊,我没说绝对,我只是在介绍一种机制),第一次没成功,因为发生了缺页异常,我们刚才说了操作系统仅仅是把这 100M 内存分配给用户了,但是没有对应真正的物理页面。操作系统并没有为 p+0x4538 所在的页面建立页表映射。所以缺页异常发生了。然后操作系统一看这个地址是已经分配给你了,我给你找个物理页面,给你建立好映射,你再执行一次试试。就这一点来说,操作系统比我们的某些官老爷信誉要良好的多,白条兑现了。
于是第二次执行成功了。有人看到这里已经满头雾水了,这个老家伙到底想说什么?
注意到了么,操作系统要给他临时找一个页面,找不到怎么办?对,页面交换,找个倒霉蛋,把它的一部分页面写到硬盘上,实际上操作系统只要空闲物理页面少于一定的程度就会做 swap。那么,如果你有个程序需要较高的效率,较好的反应速度,算法写得再好也没用,一个页面被交换出去全完。
现在明白了吧,优化程序,了解操作系统的运行机制是必不可少的。当然了优化程序绝不仅仅是这些。所以一个优秀的程序员十分有必要知道,你的程序到底运行在 “什么” 上面。
稍微总结一下:
陷阱:由 trap 指令引起,恢复后 CPU 执行下一条指令
中断:由硬件电平引起,恢复后 CPU 执行下一条指令
异常:由软件指令引起,恢复后 CPU 重新执行该条指令
有个牛人说过,Oracle 的数据库为什么总比别人的快一点点呢?因为那批人是写操作系统的��
囫囵C语言(三):谁调用了我的 main?
现在最重要的是要跟得上潮流,所以套用比较时髦的话,谁动了我的奶酪。谁调用了我的 main?不过作为计算机工作者,我劝大家还是不要赶时髦,今天Java热,明天 .net 流行,什么时髦就学什么。我的意思是先花几年把基本功学好,等你赶时髦的时候也好事半功倍。废话不多说了。
我们都听说过一句话:“main是C语言的入口”。我至今不明白为什么这么说。就好像如果有人说:“挣钱是泡妞”,肯定无数砖头拍过来。这句话应该是“挣钱是泡妞的一个条件,只不过这个条件特别重要”。那么上面那句话应该是 “main是C语言中一个符号,只不过这个符号比较特别。”
我们看下面的例子:
/* file name test00.c */
int main(int argc, char* argv)
{
return 0;
}
编译链接它:
cc test00.c -o test.exe
会生成 test.exe
但是我们加上这个选项: -nostdlib (不链接标准库)
cc test00.c -nostdlib -o test.exe
链接器会报错:
undefined symbol: __start
也就是说:
1. 编译器缺省是找 __start 符号,而不是 main
2. __start 这个符号是程序的起始点
3. main 是被标准库调用的一个符号
再来思考一个问题:
我们写程序,比如一个模块,通常要有 initialize 和 de-initialize,但是我们写 C 程序的时候为什么有些模块没有这两个过程么呢?比如我们程序从 main 开始就可以 malloc,free,但是我们在 main 里面却没有初始化堆。再比如在 main 里面可以直接 printf,可是我们并没有打开标准输出文件啊。(不知道什么是 stdin,stdout,stderr 以及 printf 和 stdout 关系的群众请先看看 C 语言中文件的概念)。
有人说,这些东西不需要初始化。如果您真得这么想,请您不要再往下看了,我个人认为计算机软件不适合您。
聪明的人民群众会想,一定是在 main 之前干了些什么。使这些函数可以直接调用而不用初始化。通常,我们会在编译器的环境中找到一个名字类似于 crt0.o 的文件,这个文件中包含了我们刚才所说的 __start 符号。(crt 大概是 C Runtime 的缩写,请大家帮助确认一下。)
那么真正的 crt0.s 是什么样子呢?下面我们给出部分伪代码:
///////////////////////////////////////////////////////
section .text:
__start:
:
init stack;
init heap;
open stdin;
open stdout;
open stderr;
:
push argv;
push argc;
call _main; (调用 main)
:
destory heap;
close stdin;
close stdout;
close stderr;
:
call __exit;
////////////////////////////////////////////////////
实际上可能还有很多初始化工作,因为都是和操作系统相关的,笔者就不一一列出了。
注意:
1. 不同的编译器,不一定缺省得符号都是 __start。
2. 汇编里面的 _main 就是 C 语言里面的 main,是因为汇编器和C编译器对符号的命名有差异(通常是差一个下划线'_')。
3. 目前操作系统结构有两个主要的分支:微内核和宏内核。微内核的优点是,结构清晰,简单,内核组件较少,便于维护;缺点是,进程间通信较多,程序频繁进出内核,效率较低。宏内核正好相反。我说这个是什么目的是:没办法保证每个组件都在用户空间(标准库函数)中初始化,有些组件确实可能不要初始化,操作系统在创建进程的时候在内核空间做的。这依赖于操作系统的具体实现,比如堆,宏内核结构可能在内核初始化,微内核结构在用户空间;即使同样是微内核,这个东东也可能会被拿到内核空间初始化。
随着 CPU 技术的发展,存储量的迅速扩展,代码复杂程度的增加,微内核被越来越多的采用。你会为了 10% 的效率使代码复杂度增加么?要知道每隔 18 个月 CPU 的速度就会翻一番。所以我对程序员的要求是,我首先不要你的代码效率高,我首先要你的代码能让 80% 的人迅速看懂并可以维护��
楼主 2016-01-28 14:29 回复
Copyright © 2010~2015 直线网 版权所有,All Rights Reserved.沪ICP备10039589号
意见反馈 |
关于直线 |
版权声明 |
会员须知