.. Kenneth Lee 版权所有 2023 :Authors: Kenneth Lee :Version: 0.2 :Date: 2023-09-14 :Status: Draft :index:`c` C语言速成 ********* 介绍 ==== 本书的目标读者马上要学操作系统了,很多操作系统,比如著名的Linux,都是用C写的。 所以这章我们来速成一下C语言。 C语言经常被称为中低级语言。我们近距离观察一下为什么会有这种说法的。 最早的系统都是用汇编语言写的,汇编语言和直接编码的数字几乎是一一对应的,你看见 汇编语言,就能大致猜到背后的程序是什么样的,好比这样::: 00: f3 0f 1e fa endbr64 04: 55 push %rbp 05: 48 89 e5 mov %rsp,%rbp 08: 48 83 ec 20 sub $0x20,%rsp 0c: bf 00 00 80 02 mov $0x2800000,%edi 11: e8 00 00 00 00 call 16 16: 48 89 45 f0 mov %rax,-0x10(%rbp) 1a: bf 00 00 80 02 mov $0x2800000,%edi 冒号前面的是内存中的地址(这里是相对地址,等把文件放到内存中会加上一个偏移), 冒号后面的数字就是指令,指令后面的就是汇编语言的指令,我们就看最后1a位置上的那 条指令,我们还能隐约看到汇编中的0x2800000呈现在指令中的样子(最后是80 02)。 所以,看着汇编,基本上简单联想一下,我们就可以知道代码是什么样子的。汇编器只是 根据汇编代码的提示,给我们编码一个数字而已。甚至都不需要看代码的其他位置,不用 看什么全局变量之类的东西,汇编器就可以工作了。 现在我们再看C语言,我们用一个程序来做参考,假定程序是这样的: .. code-block:: c int add(int a, int b) { return a+b-10; } 现在我们看看它的汇编: .. code-block:: none 0000000000000000 : 0: f3 0f 1e fa endbr64 4: 8d 44 77 a6 lea -0xa(%rdi,%rsi,1),%eax 8: c3 ret 虽然复杂了一些,但我们开始可以看到具体的行为的,endbr64是x86汇编中放在每个函数 前面用来校验这是一个函数的,我们这里忽略。所以这里的第一条有功能的指令就是lea, 它表示Load Effective Address,用来计算地址用的,作用是把第一个寄存器加上第二个 寄存器左移0位(括号里的1),再加上-0xa(就是减去10)。第二个指令ret,就是程序 跳转回调用点。 你看,这个程序基本上还是一一对应的,我们基本上还是可以猜到它是怎么样的。如果这 是用c++编译的,它就是这样的了: .. code-block:: 0000000000000000 <_Z3addii>: 0: f3 0f 1e fa endbr64 4: 8d 44 77 f6 lea -0xa(%rdi,%rsi,2),%eax 8: c3 ret 这只有一个区别,名字从原来的add变成了_Zaddii。因为C++支持重载,所以需要在名字 中编码函数的参数,所以add后面要加上ii,表示这是add有两个int做输入参数时的版本。 如果你再加上模板,类,继承,异常这些特性。这个代码就更加难一一对应了。比如我们 把这个函数的int换成string,它就变成这个样子了: .. code-block:: cpp section .text 0000000000000000 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_>: 0: f3 0f 1e fa endbr64 4: 41 55 push %r13 6: 41 54 push %r12 8: 49 89 fc mov %rdi,%r12 b: 48 83 c7 10 add $0x10,%rdi f: 55 push %rbp 10: 53 push %rbx 11: 48 89 d3 mov %rdx,%rbx 14: 48 83 ec 18 sub $0x18,%rsp 18: 4c 8b 6e 08 mov 0x8(%rsi),%r13 1c: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 23: 00 00 25: 48 89 44 24 08 mov %rax,0x8(%rsp) 2a: 31 c0 xor %eax,%eax 2c: 49 89 3c 24 mov %rdi,(%r12) 30: 48 8b 2e mov (%rsi),%rbp 33: 48 89 e8 mov %rbp,%rax 36: 4c 01 e8 add %r13,%rax 39: 74 09 je 44 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_+0x44> 3b: 48 85 ed test %rbp,%rbp 3e: 0f 84 8e 00 00 00 je d2 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_+0xd2> 44: 4c 89 2c 24 mov %r13,(%rsp) 48: 49 83 fd 0f cmp $0xf,%r13 4c: 77 52 ja a0 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_+0xa0> section .unlikely_text 0000000000000000 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_.cold>: 0: 4c 89 e7 mov %r12,%rdi 3: e8 00 00 00 00 call 8 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_.cold+0x8> 8: 48 89 ef mov %rbp,%rdi b: e8 00 00 00 00 call 10 <_Z3addNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES4_.cold+0x10> 由于string的各种构造行为的存在,这个代码会在很多地方增加额外的代码处理这些构造 和析构。你甚至会发现,它还多了一个额外的函数出来。 所以,我们一般可以认为C++是中高级语言。而Python之类的,你基本上没法和汇编一一 对应了(实际上Python的二进制根本就不是用来运行的,而是指导Python的解释器运行 的,它严格来说是和源代码才是基本一一对应的),这就是彻底的高级语言了。 操作系统这些底层的程序经常要控制函数怎么调用,系统怎么请求,线程怎么调度,需要 直接控制汇编的代码是怎么工作的,所以就会更喜欢用C来写。这相当于就是写汇编,只 是写得比较快而已。 所以,我们基本上可以认为,去掉高级功能以后的C++就是C。C的代码都可以直接用C++编 译器来编译的。只是生成的二进制不同,其实你甚至可以在C++中强行要求编译的结果也 是C形式的,这样就可以了: .. code-block:: c extern "C" { int add(int a, int b) { return a+b-10; } } 所以,其实你完全可以在C++语言中和C混着用,比如你不用C++的cout,在C++程序中这样 写,是没有问题的: .. code-block:: c #include #include int main(void) { string s("hello world\n"); printf(s.c_str()); return 0; } 这里混用了C++的string和C的printf,是没有问题的,stdio.h中已经声明printf是 extern "C"的,所以你在main中用这个函数,它是知道这是个C的函数的,就会用printf 这个名字去调用它,而不是C++重载版本的名字去调用它。 实际上,C++主程序的main函数,就是C形式的。 C++学下去,有很多功能,比如模板,标准模板库(STL),异常处理等等。其实我觉得在 工程上,不少这些功能其实不实用,所以在这个阶段,我个人是建议根本没有必要深入进 去,就用最基本的C++的类,重载两个功能就够了,其他高级功能,特别是设计模板的高 级功能,都不要用,要实现什么功能都用C来做,就够了。所以,我们这里就来学习一下 C是怎么用的。必要的时候,单独写C的代码也没有什么不可以。 C和C++的语言区别 ================ C是基础版本的C++。只包含最简单的功能,所以你记得一组不要用的C++功能,就基本上 懂C了。 下面是明显的C不支持的C++功能(模板相关的天然不会支持,忽略): 1. 不支持类,只支持struct,里面不能包含函数,它就是一个内存数据。定义了什么, 内存就有什么,基本上一一对应。 因为不支持类,所以也不支持new。 2. 不支持引用,比如func(int &a),这样不行,需要引用,就真的写成指针,比如 func(int \*a),然后用*a这样的方式直接修改它的内容。 3. const int SIZE = 10被看作“不能更改的”变量,而不是一个常数所以不能这样定义数 组int arr[SIZE],要定义常数,只能用#define SIZE 0这样的形式。 4. 不支持重载,一个名字的函数只能有一个。当然更不支持操作符重载。 5. 头文件都有.h扩展名,所以你会用#include 而不是#include 。 6. 没有namespace的概念。 没有了,是不是很简单?(其实一些旧的标准还有更多的限制,比如不能用//写注释,变 量定义不能在函数中间之类的,但我们暂时不用考虑去考古。) 其实真正的麻烦是学习函数库。但学这个函数库不亏,因为C++很多时候也需要使用C的函 数库的。比如你要创建一个线程,pthread_create_thread就是一个C的函数,C++中就没 有对应的标准封装。 标准的C函数库可以直接上网查POSIX标准,这个标准是针对大部分Unix系统的,但 Windows也支持,所以它的标准化程度也比C++高。但大部分时候,在Unix系统中我们都可 以用man来查,如果你大概记得名字,比如要查thread有关的函数,你可以这样找::: man -k thread 下面是一批最基本的函数,都可以上网查例子或者用man学习: * scanf,对应cin * printf,对应cout * malloc,对应new * free,对应delete * open/close/read/write,对应iostream 编译 ==== C的编译器用法和C++差不多,如果用GNU的工具链,C++用g++编译,而C用gcc编译。像这 样::: gcc test.c -o test g++ test.cpp -o test 如果你使用更新潮的LLVM,那也差不多::: clang test.c -o test clang++ test.cpp -o test 其他-O,-Wall的参数完全是一样的。