签到

05月08日
尚未签到

共有回帖数 0

    cynthie

    等级:
    写在前面的话
        
        写本文的目的有:
        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 回复

共有回帖数 0
  • 回 帖
  • 表情 图片 视频
  • 发表

登录直线网账号

Copyright © 2010~2015 直线网 版权所有,All Rights Reserved.沪ICP备10039589号 意见反馈 | 关于直线 | 版权声明 | 会员须知