写在前面的话
写本文的目的有:
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% 的人迅速看懂并可以维护。
囫囵C语言(四):static 和 volatile
这两个关键字其实并不沾边。只是从英文上看他们似乎是一对儿。下面我们分别解释这两个关键字。请注意,本章的程序您要想试验一把,恐怕安装一个盗版的 VC 6.0了。什么,您问我的?我的是花了 5 块钱买的正版软件。
一:static
首先,static。这个关键字的语法问题笔者不想多说,因为讨论这个关键字的文章非常多。我们先看下面的一个小程序:
////////////////////////////////////////////////////////////////
/*
* 使用 Visual C++ 6.0
* 编译器:Microsoft (R) 32-bit C/C++ Optimizing Compiler Version 12.00.8168 for 80x86
* 链接器:Microsoft (R) Incremental Linker Version 6.00.8168
*
* 程序是 release 版本
*/
int global_a;
static int global_b;
int main(int argc, char* argv[])
{
int local_a;
static int local_b;
printf("local_b is at \t%p\n", &local_b);
printf("global_b is at \t%p\n", &global_b);
printf("global_a is at \t%p\n", &global_a);
printf("local_a is at \t%p\n", &local_a);
return 0;
}
编译运行的结果是:
local_b is at 00406910
global_b is at 00406914
global_a is at 00406918
local_a is at 0012FF80
///////////////////////////////////////////////////////////////
从结果我们可以很直观地看到 global_a,global_b,local_b
在可执行程序中的位置是连续的,根据我们前面的介绍,section是页面对齐的,我们可以得知,这三个变量处于同一个 section。这就是
static 局部变量具有“记忆功能”的根本原因,因为编译器将local_b 当作一个全局变量来看待的。而变量 local_a
才是在栈上分配的,所以他的地址离其他几个变量很远。
那么它们的差别又是什么呢?
我们使用 Visual C++ 6.0 中自带的 dumpbin 程序察看一下 obj 文件的符号表:
D:\test000\Release> dumpbin /SYMBOLS test000.obj
结果如下:(我只摘出了和我们论题有关系的部分)
External | _global_a
Static | _global_b
Static | _?local_b@?1??main@@9@9
从中我们可以看出:
1. static 变量同样出现在符号表中。
2. static 变量的属性和普通的全局变量不同。
3. local_b 似乎变成了一个很奇怪的名字 _?local_b@?1??main@@9@9
第一点,只有全局变量才会出现在符号表中,所以毫无疑问 local_b 被编译器看作是全局变量。
第二点,External属性表明,该符号可以被其他的 obj 引用(做链接),static 属性表明该符号不可以被其他的 obj 引用,所以所谓 static 变量不能被其他文件引用不仅仅是在 C 语法中做了规定,真正的保证是靠链接器完成的。
第三点,_?local_b@?1??main@@9@9 就是 local_b,链接器为什么要换名字呢?很简单,如果不换名字,如果有一个全局变量与之重名怎么办,所以这个变量的名字不但改变了,而且还有所在函数的名字作为后缀,以保证唯一性。
二:volatile
说道 volatile 这个关键字,恐怕有些人会想,这玩意儿是 C
语言的关键字么?你老兄不会忽悠俺吧?嘿嘿,这个关键字确实是C语言的关键字,只不过比较少用,他的作用很奇怪:编译器,不要给我优化用这个关键字修饰的
符号所涉及的程序。有看官会说这不是神经病么?让编译器优化有什么不好,我巴不得自己用的编译器优化算法世界第一呢。
好,我们来看几个例子:(Microsoft Visual C++ 6.0, 程序编译成 release 版本)
例一:
/*
* 第一个程序
*/
int main(int argc, char* argv[])
{
int i;
for (i = 0; i < 0xAAAA; i++);
return 0;
}
/*
* 汇编码
*/
_main:
00401011 xor eax,eax
00401013 ret
通过观察这个程序的汇编码我们发现,编译器发现程序的执行结果不会影响任何寄存器变量,就将这个循环优化掉了,我们在汇编码里面没有看到任何和循环有关的部分。这两句汇编码仅仅相当于 return 0;
/*
* 第二个程序
*/
int main(int argc, char* argv[])
{
volatile int i;
for (i = 0; i < 0xAAAA; i++);
return 0;
}
/*
* 汇编码
*/
_main:
00401010 push ebp
00401011 mov ebp,esp
00401013 push ecx
00401015 mov dword ptr [ebp-4],0
0040101C mov eax,0AAAAh
00401021 mov ecx,dword ptr [ebp-4]
00401024 cmp ecx,eax
00401026 jge _main+26h (00401036)
00401028 mov ecx,dword ptr [ebp-4]
0040102B inc ecx
0040102C mov dword ptr [ebp-4],ecx
0040102F mov ecx,dword ptr [ebp-4]
00401032 cmp ecx,eax
00401034 jl _main+18h (00401028)
00401036 xor eax,eax
00401038 mov esp,ebp
0040103A pop ebp
0040103B ret
我们用 volatile 修饰变量
i,然后重新编译,得到的汇编码如上所示,这回好了,循环回来了。有人说,这有什么意义呢,这个问题问得好。在通常的应用程序中,这个小小的延迟循环通常
没有用,但是写过驱动程序的朋友都知道,有时候我们写外设的时候,两个命令字之间是需要一些延迟的。
例二:
/*
* 第一个程序
*/
int main(int argc, char* argv[])
{
int *p;
p = 0x100000;
*p = 0xCCCC;
*p = 0xDDDD;
return 0;
}
/*
* 汇编码
*/
_main:
00401011 mov eax,100000h
00401016 mov dword ptr [eax],0DDDDh
0040101C xor eax,eax
0040101E ret
这个程序中,编译器认为 *p = 0xCCCC; 没有任何意义,所以被优化掉了。
/*
* 第二个程序
*/
int main(int argc, char* argv[])
{
volatile int *p;
p = 0x100000;
*p = 0xCCCC;
*p = 0xDDDD;
return 0;
}
/*
* 汇编码
*/
_main:
00401011 mov eax,100000h
00401016 mov dword ptr [eax],0CCCCh
0040101C mov dword ptr [eax],0DDDDh
00401022 xor eax,eax
00401024 ret
重新声明这个变量,*p = 0xCCCC;
被执行了,同样,这主要用于驱动外设,有的外设要求连续向一个地址写入多个不同的数据,才能外成一个完整的操作。有的群众迷惑了,为啥驱动外设要写内存
啊?我估计那是您仅仅了解 Intel 的 CPU,Intel 的CPU 外设地址和内存地址分开,访问外设的时候使用特殊指令,比如 inb,
outb。但有一些 CPU 比如 ARM,外设是和内存混合编址的,访问外设看上去就像读写普通的内存。具体细节请参考 Intel 和 ARM
的手册。
大家注意到一个精彩的细节了么,在例二中编译器发现一个寄存器 eax 可以完成整个函数,所以并没有给变量 p 在栈上分配内存。省略了栈的操作,节省了不少时间,通常栈的操作使用 5 条以上的汇编码。
又有群众反映了,你说的两个例子,都是写驱动程序用到的,我写应用程序是不是就没必要知道了呢?不是,请您继续看下面的例子:
例三:
int run = 1;
void t1()
{
run = 0;
}
void t2()
{
while (run)
{
printf("error .\n");
}
}
int main(int argc, char* argv[])
{
t1();
t2();
return 0;
}
这个程序乍看没什么问题,在某些编译器,或者某些优化参数下,while
(run)会被优化成一个死循环,是因为编译器不知道会有外部的线程要改变这个变量,当他看到 run
的定义时,认为这个循环永远不会退出,所以直接将这个循环优化成了死循环。解决的办法是用 volatile 声明变量 i。
我手头的编译器这个例子不会出错,我也懒得找让这个例子成立的编译器了,大家自己动手试验一下看看吧。
如果大家静下心来读上一些汇编码就会发现,微软的编译器经常会有一些精彩的表现。哎,拍微软的马屁也没用,给微软发过简历,连面试的机会都没给,就被拒了,太郁闷了!