You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

15 KiB

大咖助阵LMOS为什么说 C 语言是一把瑞士军刀?

你好我是LMOS。

很高兴受邀来到这个专栏做一期分享。也许这门课的一些同学对我很熟悉,我是极客时间上《操作系统实战45讲》这门课的作者同时也是LMOS、LMOSEM这两套操作系统的独立开发者。十几年来我一直专注于操作系统内核研发在C语言的使用方面有比较深刻的理解所以想在这里把我的经验、见解分享给你。

操作系统和C语言的起源有着千丝万缕的联系那么今天我就先从C语言的起源和发展历史讲起。然后我会从C语言自身的语法特性出发向你展示这门古老的语言简单在哪里又难在哪里。

C语言、UNIX的起源和发展

从英国的剑桥大学到美国的贝尔实验室C语言走过了一段不平凡的旅程。从最开始的CPL语言到BCPL语言再到B语言到最终的C语言一共经历了四次改进。从20世纪中叶到21世纪初C语言以它的灵活、高效、通用、抽象、可移植的特性在计算机界占据了不可撼动的地位。但是C语言是如何产生的诞生几十年来它的地位为何一直不可动摇请往下看。

C语言是两位牛人“玩”出来的

1969年夏天美国贝尔实验室的肯·汤普森的妻子回了娘家这位理工男终于有了自己的时间。于是他以BCPL语言为基础设计出了简单且接近于机器语言的B语言取BCPL的首字母。然后他又用B语言写出了UNICS操作系统这就是后来风靡全世界的UNIX操作系统的初级版本。

那么肯·汤普森为什么要写这个操作系统呢背后的原因是我们这些凡人想象不到的为了玩一个叫“Space Travel”的游戏。牛人就是牛人这个“玩出来”的操作系统成功到让人无法想象。

而肯·汤普森一位同样是牛人的朋友也疯狂地热爱这款游戏这个朋友就是C语言之父请注意不是谭浩强老师丹尼斯·里奇。他为了能早点儿玩上游戏加入了汤普森的疯狂项目一起开发UNIX。他的主要工作是改造B语言使其更加成熟。1972年丹尼斯·里奇在B语言的基础上设计出了一种新的语言他取了BCPL的第二个字母作为这种语言的名字这就是C语言。C语言实现之后汤普森和里奇用它重写了UNIX。

C语言和UNIX操作系统

听到这儿你应该可以理解C语言和UNIX操作系统从诞生时就密切相关。那么C语言对UNIX操作系统的发展具体有什么影响呢我们先从C语言出现之前说起。

在C语言出现之前UNIX操作系统的初级版本是用汇编语言编写的。用机器语言或者汇编语言开发的程序是不可能在诸如X86、Alpha、SPARC、PPC和ARM等机器上任意运行的想要运行就得重写所有代码。**而用C语言编写的程序则可以在任意架构的处理器上运行。**只需要有那种架构的处理器对应的C语言编译器和库然后将C源代码编译、链接成目标二进制文件之后即可在该架构的处理器上运行。

正是C语言的这种高性能和强大的可移植性促进了UNIX生态的发展。UNIX诞生后的40年间出现的各种操作系统都是和UNIX有关系的或者受其影响。甚至直到2021年各种版本的UNIX内核和周边工具仍然使用C语言作为最主要的开发语言。

你可以看下这个UNIX家谱图来自维基百科更直观地感受UNIX的发展史

图片

看到这个庞大的家谱图不知道你是否吃惊不已但是我想说的是这些操作系统内核都是使用C语言开发的无一例外。甚至可以说C语言就是开发操作系统的专用语言。也正因如此C语言成了计算机史上的一颗明珠一座灯塔永远闪耀在计算机历史的长河之上。

用一个程序体会C语言的简单性

从对C语言起源的介绍中你可以了解到C语言最开始是被设计用来开发UNIX的而这造就了它自身的语言特性

  • 要预知程序的运行流程和结果,就需要简单的类型系统和静态编译;
  • 需要用C语言开发底层核心代码要求C语言能灵活地操控内存和寄存器。
  • 需要C语言是可以移植的所以需要提供结构体、函数等抽象的编程机制。

**正是这些需求导致了C语言的高效、简单、灵活和可移植性。**所以很多人说C语言是一种非常简单的语言。

我写了一个经典的C语言程序Hello World 你可以从中体会C语言的简单性。代码如下所示

#include "stdio.h"
// 定义申明两个全局变量hellostr、global类型分别是char*、int
char* hellostr = "HelloWorld";
int global = 5;
// 定义一个结构体类型 HW
struct HW {
  char* str;
  int sum;
  long indx;
};
// 函数;
void show(struct HW* hw, long x) {
  printf("%d %d %s\n", global, x, hellostr);
  printf("%d %d %s\n", hw->sum, hw->indx, hw->str);
}
// 函数;
int main(int argc, char const *argv[]) {
  // 定义三个局部变量x、parm、ishw类型分别是int、log、struct HW;
  int x;
  long parm = 10;
  struct HW ishw;
  // 变量赋值;
  ishw.str = hellostr;
  ishw.sum = global;
  ishw.indx = parm;
  // 调用函数;
  show(&ishw, parm);
  return x;
}

这个短短的代码就几乎包含了C语言90%的特性,有函数,有变量。其中,变量包括局部变量和全局变量;变量还有类型,用于存放各种类型的数据;还有一种特殊的变量即指针,指针也有类型,用于存放其它变量的地址。

总之一句话,C语言就是函数+变量。函数表示算法操作,变量存放数据,即数据结构,合起来就是程序=算法+数据结构。

C语言难在哪里

你可以看到从语言特性上来看C语言极其简单。但是很多程序员却说C语言用起来无比困难这又是为什么呢

其实你可以这么理解:C语言就像一把锋利的瑞士军刀使用起来非常简单并不像飞机坦克一样难于驾驭但同时它对使用者的技巧要求极高使用时稍有不慎就会伤及自身。C语言可操控寄存器和内存的特性对初级软件开发者极其不友好很容易导致软件bug而且bug查找起来非常困难。

通过汇编代码看C语言的本质

而C语言使用的困难之处就要从C语言的本质说起了。

我们知道C语言的代码是不能直接执行的需要通过C编译器编译。C编译器首先将C代码编译成汇编代码然后再通过汇编器编译成二进制机器代码。这刚好给了我们一个通过观察汇编代码了解C语言本质的机会。接下来我们就按三个步骤观察下。

第一步观察C语言如何处理全局变量。代码如下

.globl hellostr  	
.section .rodata
.LC0:
	.string	"HelloWorld"  // 字符变量放在可执行文件的 rodata 段;
	.data
	.align 8
	.type hellostr, @object
	.size hellostr, 8
hellostr:  // 字符指针变量放在可执行文件的 data 段;
	.quad .LC0
	.globl global
	.align 4
	.type global, @object
	.size global, 4
global:
	.long 5  // long 型变量放在可执行文件的 rodata 段;
	.section .rodata

我们看到C语言对全局变量的处理是放在可执行文件的某个段中的这些段会被操作系统的程序加载器映射到进程相应的地址空间中代码通过地址就能访问到它们了。

第二步观察C语言如何处理局部变量。代码如下

main:
.LFB1:
	.cfi_startproc
	pushq %rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq %rsp, %rbp
	.cfi_def_cfa_register 6
	subq $64, %rsp  // 在栈中分配局部变量的内存空间;
    // 保存 main 的两个参数;
	movl %edi, -52(%rbp)
	movq %rsi, -64(%rbp)
    // long parm = 10;
	movq $10, -8(%rbp)
	movq hellostr(%rip), %rax
    // ishw.str = hellostr;
	movq %rax, -48(%rbp)
	movl global(%rip), %eax
    // ishw.sum = global;
	movl %eax, -40(%rbp)
	movq -8(%rbp), %rax
    // ishw.indx = parm;
	movq %rax, -32(%rbp)
    // 处理给 show 函数传递的参数;
	movq -8(%rbp), %rdx
	leaq -48(%rbp), %rax
	movq %rdx, %rsi
	movq %rax, %rdi
    // 调用 show 函数;
	call show
	movl -12(%rbp), %eax
	leave
	.cfi_def_cfa 7, 8
    // 返回;
	ret
	.cfi_endproc

由上可知C语言把局部变量放在栈中。栈也是一块内存空间数据从栈顶压入也从栈顶弹出。所以栈的特性是先进后出栈顶由RSP寄存器指向因此RSP也被称为栈指针寄存器。上面的代码对RSP减去64就是在栈中分配局部变量的空间。

还有call指令也要用到栈以上述代码为例它是把第31行的 movl -12(%rbp), %eax 的地址压入栈顶然后跳转show函数的地址开始运行代码。而在show函数的最后有一条ret指令从栈顶弹出返回地址 movl -12(%rbp), %eax 的地址到RIP程序指针寄存器使得程序流程回到main函数中继续执行。这样就完成了函数调用。

第三步观察C语言如何处理函数。代码如下

show:
.LFB0:
	.cfi_startproc
	pushq %rbp
	.cfi_def_cfa_offset 16
	.cfi_offset 6, -16
	movq %rsp, %rbp
	.cfi_def_cfa_register 6
    // 在栈中分配局部变量空间;
	subq $16, %rsp
    // 把 hw 和 x 两个参数变量放在栈空间中;
	movq %rdi, -8(%rbp)
	movq %rsi, -16(%rbp)
    // 处理 printf 函数的参数;
	movq hellostr(%rip), %rcx
	movl global(%rip), %eax
	movq -16(%rbp), %rdx
	movl %eax, %esi
	movl $.LC1, %edi
	movl $0, %eax
    // 调用 printf 函数;
	call printf
	movq -8(%rbp), %rax
	movq (%rax), %rcx
    // -8(%rbp) 指向的内存中放的 hw 指针;
	movq -8(%rbp), %rax
    // 16(%rax) 指向的内存中放的 hw->indx
	movq 16(%rax), %rdx
	movq -8(%rbp), %rax
    // 8(%rax) 指向的内存中放的 hw->sum
	movl 8(%rax), %eax
	movl %eax, %esi
    // $.LC1 指向的内存中放的 "HelloWorld",即 hw->str
	movl $.LC1, %edi
	movl $0, %eax
    // 调用 printf 函数;
	call printf
	nop
	leave
	.cfi_def_cfa 7, 8
	ret

上面的代码清楚地展示了C语言编译器是如何编译一个C语言函数如何处理函数参数的。你可以发现C语言编译出来的代码和你手写的汇编代码相差无几有时甚至还要更高效。

因为汇编代码和机器指令直接对应所以我们通过汇编代码可以非常直观地观察到C语言编译器编译C代码的结果清楚地看到一行C代码编译成的机器指令。在这个过程中我们就可以清楚地知道C语言的变量、指针、函数的实现机制是什么从而达到了解C语言本质的目的。

C语言指针带来的陷阱

在上面用汇编代码观察C语言的时候我们看到了C语言是如何处理指针变量的。而这就是C语言的灵活之处也是其难点。**C语言的指针导致C语言程序员可以毫无节制地操控内存这个特性赋予了C语言强大、灵活的特点同时也带来了陷阱。**下面我们用几个例子看看,具体有哪些陷阱。

陷阱一:未初始化的指针

指针变量中存放的是地址数据,未初始化即为地址数据不明,指向何处也就不清楚。如果你指向了一个关键内存地址,对其进行读写,就会破坏其中的重要数据,从而导致代码逻辑出现问题,而且这样的问题非常难于查找。

你可以观察下面的代码,思考它是不是有问题。

int main(int argc, char const *argv[]) {
  int* p;
  int k = *p;
  for (int i = k; i > 100; i++) {
    printf("hello world\n");
  }
  return 0;
}

这代码有问题吗p没有初始化所以p的值是不确定的可以指向任意地址。而这个地址中的数据也是不确定的所以问题来了i可能大于100也可能小于100代码的行为是不确定的所以出问题之后就极其难以查找。

陷阱二:指针越界

我们经常用指针操作一块连续的内存,比如数组。这样的情况下,如果代码逻辑出现问题,很容易导致指针越界,超出指针指向这块内存的边界,从而改写不该操作的内存中的数据。

我们还是来看一个具体的代码:

char str[5] = { 0 };
void stringcopy(char* dest, char* src) {
  for(; *src != 0; dest++, src++) {
    *dest = *src;
  }
  return;
}
int main(int argc, char const *argv[]) {
  stringcopy(str, "helloworld");
  return 0;
}

从上述代码可以看出str只能存放5个字符而helloworld是10个字符。而stringcopy函数的实现是把两个参数作为指针使用所以这个代码一定会导致指针越界。如果 str[5]后面存放了关键数据这个关键数据一定会被破坏从而导致未知bug并且这样的bug很难查找。

陷阱三:栈破坏

指针可以指向任意的内存,栈也是内存,因此用指针很容易操作栈中的内容。而栈中保存着函数的返回地址和局部变量,其中重要的函数返回地址,经常被黑客作为攻击点。他们通过改写返回地址,使函数返回到自己写好的函数上。下面来看看黑客们是如何操作的。

来看下面的代码,它展示了黑客们攻击时利用的“陷阱”。你可以先试想下这段代码的运行结果。

void test() {
  printf("test");
  return;
}
void stackret(long* l) {
 *l-- = (long)test;
 *l-- = (long)test;
 *l-- = (long)test;
 *l-- = (long)test;
 *l-- = (long)test;
  return;
}
int main(void) {
  int* p;
  long x = 0;
  stackret(&x);
  return 0;
}

你一定想不到程序会输出“test”可是我们明明没有调用test函数这是为什么呢

我们在stackret函数中不小心修改了栈中的内容用test地址覆盖了返回地址。因为x变量在栈分配内存我们传给stackret函数的就是x的地址自然就可以修改栈中的内容。这个特性经常被木马程序所利用。

上面我从三个方面向你展现了指针可能带来的危险。总之C语言的指针给开发人员带来了内存的完全可控性但是也给程序开发带来了困难稍有不慎就会坠入万劫不复的深渊。所以在使用指针时要非常小心。

重点回顾

今天的分享就到这里了,最后我来给你总结一下。

  1. 首先我带你回顾了C语言的起源以及它和UNIX操作系统的密切联系。C 语言是牛人们“玩”出来的,而 C 语言和UNIX在发展过程中互相成就了对方。
  2. C语言最开始是被设计用来开发UNIX的这导致了C语言的高效、简单、灵活和可移植性。我们用一个代码实例了解了C语言的简单性。
  3. 我们通过观察汇编代码了解了C语言的本质进而理解了C语言指针可能带来的陷阱。

关于C语言我想和你聊的还远不止这些。在“LMOS说C语言”的下篇我会和你分享C语言在工程项目中的应用方式以及如何用C语言来实现面向对象的编程方法我们到时候见