C 语言编程实践

假设已经熟练使用各种 C 语言机制

原则上给需求就能搞定任何代码,比如说模拟器,操作系统,编译器。

但是原理上很简单的东西,并不容易做出来,编写一个足够大的项目也是有些准则的。

  • 怎样写代码才能从一个大型项目里存活下来?

    • 核心准则:编写可读代码

    • 两个例子

核心准则:写一个能看的代码

怎么维护一个比较大的,给人看的代码。这样才能让人看得懂,让更多人参与进来。

一个极端的代码不可读的例子:

#include <stdio.h>
#include <math.h>
#define clear 1;if(c>=11){c=0;sscanf(_,"%lf%c",&r,&c);while(*++_-c);}\
  else if(argc>=4&&!main(4-(*_++=='('),argv))_++;g:c+=
#define puts(d,e) return 0;}{double a;int b;char c=(argc<4?d)&15;\
  b=(*_%__LINE__+7)%9*(3*e>>c&1);c+=
#define I(d) (r);if(argc<4&&*#d==*_){a=r;r=usage?r*a:r+a;goto g;}c=c
#define return if(argc==2)printf("%f\n",r);return argc>=4+
#define usage main(4-__LINE__/26,argv)
#define calculator *_*(int)
#define l (r);r=--b?r:
#define _ argv[1]
#define x

double r;
int main(int argc,char** argv){
  if(argc<2){
    puts(
      usage: calculator 11/26+222/31
      +~~~~~~~~~~~~~~~~~~~~~~~~calculator-\
      !                          7.584,367 )
      +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
      ! clear ! 0 ||l   -x  l   tan  I (/) |
      +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
      ! 1 | 2 | 3 ||l  1/x  l   cos  I (*) |
      +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
      ! 4 | 5 | 6 ||l  exp  l  sqrt  I (+) |
      +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~+
      ! 7 | 8 | 9 ||l  sin  l   log  I (-) |
      +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~(0
    );
  }
  return 0;
}

怎么说呢,很好看的一坨屎,最后能用。

这是一段完全不可读的代码,不可读的含义:

  • 根本看不出设计者的思路

  • 如果要新增一个运算符,基本上需要推倒重来

一个现实里可以遇到的例子,人类不可读:

一段代码自己直到是怎么回事,那么过一个月再看呢?

C语言提供了很多机制,让不可读变得可读

这是个真实存在的函数,在Linux手册里,可以看到,终端输入man 2 signal

为啥按ctrl + c的时候程序会推出呢,但是vim和man又不能用这个指令退出。实际上终端向进程发送了一个信号,这个函数就是去注册一个信号。我们可以改变按键的默认操作。

通过这个函数我们可以设置一个自己的函数,在按ctrl + c的时候printf个啥东西。

最重要的,对比上下两个代码,下面的对人类更友好。

写代码的基本准则:降低维护成本

项目越大,这个问题会越突出。大的方面来讲,问题的分解,在写头文件的时候就应该想好(含义是一个功能先写头文件)。

宏观

  • 做好分解和解耦 (现实世界也是这样管理复杂系统的)

微观

  • “不言自明”

    • 通过阅读代码能理解一段程序是做什么的 (specification)

  • “不言自证”

    • 通过阅读代码能验证一段程序与 specification 的一致性

代码的更高要求,基本上不需要注释,看到代码就知道输入是啥,输出是啥,还能感觉到

例子:实现数字逻辑电路模拟器

假想的数字逻辑电路:

  • 若干个 1-bit 边沿触发寄存器 (X, Y, ...)

  • 若干个逻辑门

做一个0、1、2的计数器,在时钟到来时00→01→10来回循环。

  • 基本的思路,状态模拟 + 计算模拟。(状态就是存储)

    • 状态 = 变量

      • int X = 0, Y = 0;

    • 计算

      • X1 = !X && Y;

      • Y1 = !X && !Y;

      • X = X1; Y = Y1;

然后再这个while循环里,不断走。通过这个方式基本上可以模拟任意一个数字电路。

存在的问题是,不好扩展,项目变大,一改需求就完了。而且这种代码,一不小心,逻辑错了,还是挺难改的。各种各样的逻辑分布在不同的文件,加一个逻辑要去改5个文件都挺难受的。

这里只给出这个模2计数器的C语言模拟代码:

用一些C语言的机制,把代码做的可维护。

研究一下这个代码,#define FORALL_REGS(_) _(X) _(Y)是个X macros,在注释里写出替换后的样子

现在有新的需求了,加上一个变量Z,也很容易,main甚至都不用动,只改宏定义就行

这种维护上的便捷,这才叫维护,而非推倒重来。初步的软件工程的思想。

这段代码的优点:

  • 增加/删除寄存器只要改一个地方

  • 阻止了一些编程错误

    • 忘记更新寄存器

    • 忘记打印寄存器

  • “不言自明” 还算不错

缺点:

  • 可读性变差 (更不像 C 代码了)

    • “不言自证” 还缺一些

  • 给 IDE 解析带来一些困难,当然IDE并不怕这些困难

还有个稍微复杂一点的例子,七段数码管

你也可以考虑增加诸如开关、UART 等外设。这段程序的原理无限接近 FPGA。

FPGA是万能的,能模拟FPGA,就能模拟计算机系统。

自制CPU,叫soc更合适,学完计算机原理课,其实是有了自己造计算机的能力的。

南大:YEMU 全系统模拟器

一个mini计算机

  • 存储系统

    • 寄存器(8 bit):PC, R0, R1, R2, R3

    • 内存:16 Byte

指令集简单一点,配合这4个寄存器以及16Byte的内存,设计4条:

bit
7
6
5
4
3
2
1
0

mov

[0

0

0

0]

[rd1

rd0]

[rs1

rs0]

add

[0

0

0

1]

[rd1

rd0]

[rs1

rs0]

load

[1

1

1

0]

[a3

a2

a1

a0]

store

[1

1

1

1]

[a3

a2

a1

a0]

模拟这个计算机的执行,如何实现模拟器呢?

这涉及到对程序的理解。

存储模型:内存 + 寄存器 (包含PC)

这个数字电路有多少状态呢,当知道所有当前的状态,可以推算出下一个时钟周期来时的下一个状态。只是这一个数字电路略微复杂

16 + 5 = 21 bytes = 168 bits

总共有 21682^{168} 种不同的取值,理论上讲,我们可以画出来这个状态机。实际上画不出来,但是我们可以从这个思路去理解计算机。

从这个角度也可以去理解买到的intel和AMD的CPU为啥很少出现bug。

回到正题,如何模拟呢?还能像刚刚的数码管一样吗?

对于寄存器和内存比较容易,用全局变量就好了。

从提升代码质量的角度去考虑,要给变量名准确的含义

再来思考可扩展性,如果要加一个新寄存器呢?

这里就全自动了。

和刚刚数码管一样,程序之间有很多内在的互相依赖。这些依赖,当时可能知道,写完再看就忘了,导致改BUG时一处改了另一处忘了。所以写代码的时候这些依赖越少越好,降低代码耦合程度。

代码不断重构,就是为了减少依赖,相关的东西在一起。

来看内在隐含的依赖

一个例子,还有一些好的方式,用结构体管理寄存器。对于复杂的情况,struct/union 是更好的设计。担心性能 (check_reg_index)?在超强的编译器优化面前,不存在的

前面模拟了状态,接下来模拟指令运行。

最后更新于