Authors: | Kenneth Lee |
---|---|
Version: | 0.2 |
Date: | 2023-09-14 |
Status: | Draft |
本书的目标读者马上要学操作系统了,很多操作系统,比如著名的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 <main+0x16> 16: 48 89 45 f0 mov %rax,-0x10(%rbp) 1a: bf 00 00 80 02 mov $0x2800000,%edi
冒号前面的是内存中的地址(这里是相对地址,等把文件放到内存中会加上一个偏移), 冒号后面的数字就是指令,指令后面的就是汇编语言的指令,我们就看最后1a位置上的那 条指令,我们还能隐约看到汇编中的0x2800000呈现在指令中的样子(最后是80 02)。
所以,看着汇编,基本上简单联想一下,我们就可以知道代码是什么样子的。汇编器只是 根据汇编代码的提示,给我们编码一个数字而已。甚至都不需要看代码的其他位置,不用 看什么全局变量之类的东西,汇编器就可以工作了。
现在我们再看C语言,我们用一个程序来做参考,假定程序是这样的:
int add(int a, int b) {
return a+b-10;
}
现在我们看看它的汇编:
0000000000000000 <add>:
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++编译的,它就是这样的了:
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,它就变成这个样子了:
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形式的,这样就可以了:
extern "C" {
int add(int a, int b) {
return a+b-10;
}
}
所以,其实你完全可以在C++语言中和C混着用,比如你不用C++的cout,在C++程序中这样 写,是没有问题的:
#include <stdio.h>
#include <string>
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++功能(模板相关的天然不会支持,忽略):
不支持类,只支持struct,里面不能包含函数,它就是一个内存数据。定义了什么, 内存就有什么,基本上一一对应。
因为不支持类,所以也不支持new。
不支持引用,比如func(int &a),这样不行,需要引用,就真的写成指针,比如 func(int *a),然后用*a这样的方式直接修改它的内容。
const int SIZE = 10被看作“不能更改的”变量,而不是一个常数所以不能这样定义数 组int arr[SIZE],要定义常数,只能用#define SIZE 0这样的形式。
不支持重载,一个名字的函数只能有一个。当然更不支持操作符重载。
头文件都有.h扩展名,所以你会用#include <stdio.h>而不是#include <iostream>。
没有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的参数完全是一样的。