Khan's Notebook GCC/GNU/Linux Delphi/Window Java/Anywhere

路漫漫,长修远,我们不能没有钱
随笔 - 172, 文章 - 0, 评论 - 257, 引用 - 0
数据加载中……

囫囵C语言(转自天涯)



作者:deepgray 提交日期:2007-1-10 09:03:00
    写在前面的话
    
    写本文的目的有:
    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。
  
  我手头的编译器这个例子不会出错,我也懒得找让这个例子成立的编译器了,大家自己动手试验一下看看吧。
  
  如果大家静下心来读上一些汇编码就会发现,微软的编译器经常会有一些精彩的表现。哎,拍微软的马屁也没用,给微软发过简历,连面试的机会都没给,就被拒了,太郁闷了!
  

posted on 2007-03-02 09:30 Khan 阅读(1519) 评论(0)  编辑 收藏 引用 所属分类: GCC/G++跨平台开发


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理