「Tech初见」C 编码准则

#Source

  1. Google C++ style

#0 - 免责声明

本文所书的 C 编码准则,它姓万,不一定适合所有人,请各位见仁见智!

#1 - 工程编译问题

#1.1 - 头文件的防卫式声明

我们都知道一个工程文件中,不仅有 .c 文件,还会有很多 .h 文件,这些 .h 有些是其对应 .c 中函数的原型声明,比如 syscall.h 声明了 sys_open() 的原型,

/** in syscall.h */ 

int sys_open(void);

随后,我们在 syscall.c 中引用该 .h 并定义 sys_open()

/** in syscall.c */

#include "syscall.h"

int
sys_open(void)
{
  ...
  return 0;
}

上述操作是类 C 编码中顺理成章的事。如果工程不大,上面的这种写法完全没有问题。但是在大工程的情况下,这种写法就会出错,即是如果工程涉及到 .h 重复引用的问题,那么将无法通过编译。下面,我们通过 demo 来看看何为重复引用?

现在局面是这样的,main.c 中引用了 syscall.h 和 sysproc.h ,而 sysproc.h 中又引用了 syscall.h 。sysproc.h 的代码大概就是这样,

/** in sysproc.h */

#include "syscall.h"
...

main.c 中引用如下,

/** in main.c */

1 #include "syscall.h"
2 #include "sysproc.h"
...

我们都知道,编译器在解析 #include 头文件时,只会将 .h 中的文本原封不动地搬到 main.c 中。在这个 demo 中,编译器解析 main.c 时会将代码转换成下面的这种局面,

/** in main.c */

/** syscall.h 中的内容 */
int sys_open(void);

/** sysproc.h 中的内容 */
int sys_open(void);		/** #include "syscall.h" 文本替换 */
...
  
main()
{
  ...
}

我们把代码写出来,问题暴露的就会很明显!我们在 main.c 中声明了两次 sys_open() ,这是肯定不能通过编译的!因为 C 编译器规定函数的签名必须唯一。其实,重复引用问题的本质,就是函数原型重复声明了

知道错在哪了,我们就想办法去改正它。接着 sy_open() 这个例子继续展开,可以在 syscall.h 和 sysproc.h 中添加防卫式声明,

/** in syscall.h */

#ifndef __SYSCALL__
#define __SYSCALL__

int sys_open(void);

#endif

/** in sysproc.h */
#ifndef __SYSPROC__
#define __SYSPROC__

#include "syscall.h"

...

#endif

就可以避免重复引用的问题,其中定义宏 __SYSCALL____SYSPROC__ 。大致规则是这样的, 如果未曾定义过宏 __SYSCALL__ ,那么执行,

#define __SYSCALL__

int sys_open(void);

#endif

如果定义过宏 __SYSCALL__ ,那么直接跳到,

#endif

略过宏保护段之间的函数原型声明,宏 __SYSPROC__ 也是同样的情况。添加之后,在 main.c 中解析第 1 行,

#include "syscall.h"

因为未曾定义宏 __SYSCALL_ ,所以编译器会将 syscall.h 的文本原封不动地插入到 main.c 中。解析第 2 行,

#include "sysproc.h"

因为定义过宏 __SYSCALL__ ,所以只引入 sysproc.h 中除 syscall.h 之外的内容,跳过宏 __SYSCALL__ 保护段之间的函数原型声明

另外,我们自己创建的 .h 文件,一般是用双引号( “” )来引用;而库文件一般是用尖括号(<>)来引用

一个好的习惯,我们出于代码规范的考量,无论工程大小,都要在 .h 文件中加上防卫式声明,来规避工程庞大之后无意间带来的重复引用问题

最后,来谈一谈我对 .h 的理解。我们都知道 C 是 70 年代的产物,它采用了最原始的头文件方式黏合整个工程。直白一点,就是 sysproc.c 若想调用 syscall.c 中定义好的函数,那么就必须引用 syscall.h 。这就是头文件的作用,也是它存在的意义!

天生我材必有用,这句谚语说得好。道理其实就是,事物的存在一定是为了解决某个问题,不然它就没有意义。.h 文件就是为了解决工程内部各文件之间的关联性问题,而 #1.1 - 头文件防卫式声明 是为了让 .h 变得更好

Go 、Python 、Java 等现代语言基本都采用更方便的 包管理 机制,直接导包就行,很大程度上避免了重复引用情况的发生。这确实是一种很好的管理方式!

#1.2 - 工程的共享头文件

#1.1 - 头文件的防卫式声明 中使用 .h 文件的方法是最基础的,在工程中通常也是这么用的。本小节的 #1.2 - 工程的共享头文件 算是高阶的用法,下面我们来仔细研究一下

在工程中一般是一个 .c 对应着一个 .h ,.h 写些声明,.c 写些定义。别的 .c 如果要使用 .c 中已定义好的函数,通过 .h 引用即可。但是如果给工程中的每个 .c 都配备一个 .h ,那么工程必然显得很臃肿。再者,如果每个 .h 里的内容并不多的,那么就更得不偿失

我们针对上述这个问题提出了一种解决方案,将众多 .h 的内容合并,只用一个 .h 文件就可以打通全局。其模样大概是这样的,工程里会有一个名叫 defs.h ,它存放一些 .c 中的函数原型声明。通过代码来一探究竟,假设工程有 proc.c 、spinlock.c 等 .c ,

/** in proc.c */

int 
cpuid(void)
{
  ...
  return x;
}

pagetable_t
proc_pagetable(struct proc* p)
{
  ...
  return pg;
}

static struct proc* 
allocproc(void)
{
  ...
  proc_pagetable(p);
  ...
}

cpuid() 是获取运行当前进程的 CPU 编号,proc_pagetable() 是为进程 p 分配页表,allocproc() 是分配进程,它会调用 proc_pagetable()

值的注意的是,allocproc() 是 static 函数,其意就是,allocproc() 这个函数只在 proc.c 中可见,对别的 .c 比如 spinlock.c 是透明的,这在中小级工程中已经可以很好地解决命名冲突的问题,

/** in spinlock.c */

void
acquire(struct spinlock* lk)
{
  ...
}

void
release(struct spinlock* lk)
{
  ...
}

spinlock.c 中定义了 proc.c 会用到的 lock 辅助函数,我们都知道在并行环境下,进入临界区之前都是要上锁的,出了临界区得放锁,这是基本规则。allocproc() 要想正常工作,它就需要 spinlock.c 的 acquire()release() 。为了能让 allocproc() 正常工作,最本能的办法就是在 proc.c 的头部引用 spinlock.h

现在问题出现了,spinlock.c 中只定义了几个辅助函数,如果单独仅仅为了这几个辅助函数创建一个 spinlock.h ,那么未免有点不划算,因为 spinlock.h 只有一点点内容,却要占据工程文件的宝贵名额

我们想在不浪费名额的情况下,让 proc.c 也能访问到 spinlock.c 的 acquire()release() 。能不能做到呢?答案是显然的,可以。通过定义 #1.2 - 工程的共享头文件 就行

将 spinlock.c 中的函数原型声明放在共享头文件 defs.h 中即可,

/** in defs.h */

// spinlock.c
void	acquire(struct spinlock*);
void	release(struct spinlock*);

为什么取名叫 defs.h 呢?其实就是英文单词 definition 的缩写,在末尾再加上后缀 s ,表示 defs.h 中有很多函数原型声明。我们为了 proc.c 更好的被其他 .c 文件更好的复用,也将其加入,形成以下格局,

/** in defs.h */

// spinlock.c
void	acquire(struct spinlock*);
void	release(struct spinlock*);

// proc.c
int	cpuid(void);
pagetable_t	proc_pagetable(struct proc*);

这样一来,在 proc.c 中通过引用 defs.h 就可以获取对 acquire()release() 的访问权,

/** in proc.c */

#include "defs.h"

int 
cpuid(void)
{
  struct spinlock lk;
  
  acquire(&lk);
  ...
  release(&lk);
}

...

在 spinlock.c 中也是同样的操作,引用 defs.h 。至此,就解决了需要为每个 .c 都配备 .h 的臃肿问题,通过一个 defs.h 让整个工程变得简洁

#1.3 - 声明 & 定义的写法

.h 中函数原型的声明一般是没有参数的,只保留数据类型比如,

/** in defs.h */

// proc.c
int	cpuid(void);
pagetable_t	proc_pagetable(struct proc*);

返回类型和函数签名应该是在同一行的,之间间隔一个 Tab 距离,这样看起来比较美观。在对应的 .c 中完成函数的定义,

/** in proc.c */

int
cpuid(void)
{
  ...
  return x;
}

...
  
static struct proc*
allocproc(void)
{
  ...
  return p;
}

这时我们采用分段式写法,即是函数签名在返回类型的下一行,返回类型独占一行。allocproc() 的 static 特性也是和返回类型写在一起

#2 - 对外良好接口

良好的接口,是衡量三方库质量的一个重要因素。如果别人使用我们写的 API 时破口大骂,那多半是我们没有掌握好编写接口的要点。其实要点很简单,就几条(我认为)

#2.1 - 标明 IN 和 OUT 参数

现在的局面是这样的,我们需要一个辅助函数,它能够根据路径名 path 读取对应的 inode 。一般我们在 .h 中声明函数原型会这么写,

/** in fs.h */

struct inode* namei(char*, int, char*);

乍一看,我们会一头雾水,心里会犯嘀咕,这几个参数是干嘛的?第 1 个 char* 是输入嘛?第 2 个 char* 是输出嘛?这些疑问,我相信一开始是没有底的,即使后来代码跟进,看了 namei() 的定义也可能不确定,

struct inode*
namei(char *path, int nameiparent, char *name)
{
  struct inode *ip, *next;

  ...
  return ip;
}

此处忽略 namei() 的业务流程,我们集中注意力,焦点打在 pathnameiparentname 上。因为我之前提起过 namei 的 Motivation ,所以能够确定 path 是输入,但能够准确地确定 name 是输出嘛?除非看了业务逻辑

有没有一种方式,让我们能够一眼看出谁是输入,谁是输出呢?显然,是可以的。我们利用宏手段,来标明 IN 和 OUT 参数。看一下如何定义,

/** in defs.h */

#define IN
#define OUT

IN 和 OUT 后面不跟任何东西,这样在编译器看来,如果代码中出现 IN 和 OUT ,那就用空格替换。空格在函数签名参数中是可以被忽略的,不会影响语法的正确性。我们可以将 namei() 的原型和定义写成,

/** in fs.h */

struct inode* namei(IN char*, IN int, OUT char*);

/** in fs.c */

struct inode*
namei(IN char *path, IN int nameiparent, OUT char *name)
{
  struct inode *ip, *next;

  ...
  return ip;
}

至此,我们一目了然

#2.2 - 自己记得要释放资源

我们都知道 malloc 内存之后,要在用完之后将其 free ,不然会造成内存泄露。这么一个简单的法则,却贯穿了整个 C 编码。C 没有 C++ 提供的 RAII 和析构机制,很多释放资源的事都得我们自己来。比如 #1.2 - 工程的共享文件 的 spinlock.c 中的 acquire()release() ,就是用来上锁和放锁的

在对外提供接口时,暂且记住不能内存泄露,不能 deadlock 。如果能做好这两点,其实已经很好了,起码性能没太大问题。比如在 create() 中我们需要根据输入的路径名 path ,再到对应上级 inode 中创建子 inode ,这个时候我们需要解析 path ,将目录名分离出来,

/** in sysfile.c */

struct inode*
create(IN char *path, IN short type)
{
  struct inode *ip;
  
	char *dirname = malloc(...);
  
  ...	/** 从 path 中解析出 目录名 */ 
  
  if(dirname) {	/** 记得 free */
	  free(dirname);	
	  dirname = 0;  
  }
  
  return ip;
}

千万记得要 free 文本缓冲区 dirname ,不要将 free 的这件琐事交给 Caller ,因为 Caller 不一定清楚 create() 做了哪些事,申请了哪些资源。最好的准则,就是各人自扫门前雪,管好自己申请的资源。acquire()release() 也是同等情况,就不再演示了

dirname 例子中有一点需要注意,我在其中是通过 malloc 获取缓冲区的,如果在 dirname 并不需要很多内存的情况下,完全可以从堆分配转为栈分配,就像这样,

char dirname[BUFSIZE];

栈上分配的好处,就是编译器会自动帮我们回收空间,这样就不需要谨记 free 。但是也又个坏处,就是栈分配不能过多,会栈溢出

#2.3 - 返回值,潜龙勿用

C 不像现代的 Go ,它的函数只能有 0 or 1 个返回值,而后者可以有多个。任何时候,都是物以稀为贵。只有 0 or 1 个返回值,那就意味着它很宝贵,所以不建议通过返回值传递大量数据,特别是结构体

大量数据一般是通过指针传递,即是参数为指针类型。通常,我们会将返回值位置空出来,作为函数执行成功与否的标记位。常见的做法,就是函数成功返回 1 ;失败返回 0

比如,我们想利用 bget() 获取块号为 blockno 的缓冲区 buf 的内容拷贝,以下通过指针参数传递,效率最高,

int 
bget(IN uint blockno, OUT struct buf* result)
{
  struct buf *b;
  
  /** 根据 blockno 定位缓冲区 b */
  ...
  /** 没找到编号为 blockno 的缓冲区 */  
  if(not found)
    return 0;
  
  /** 缓冲区内容拷贝 */
	memmove(&result->data, &b->data, ...);
  return 1;
}

这种写法比起利用返回值传递缓冲区内容,减少了一次内容的拷贝,而且还能将返回值位置空出来,作为成功与否的标记位,使 Caller 一看便知

#2.4 - Unix-like 命名法

相信大家阅读到这里,已经注意到我们上面函数、变量的命名啦!是不是与熟知的小骆驼,大骆驼,蛇形命名法不同。对,这是 Unix-like 的命名法则,我之前也很喜欢小骆驼和蛇形命名法,但是现在我觉得 Unix-like 命名法更为简洁,能在有限的空间里看到更多的代码,而且不失易读性。比如,我们根据文件路径名 path 读取对应的 inode 时定义的辅助函数,名叫 namei()

struct inode* namei(IN char*, IN int, OUT char*);

我们一眼就可以看出,这个 namei() 是根据 name 来查找 inode 。我认为是比小骆驼要温和多了,

struct inode* nameI(IN char*, IN int, OUT char*);

我不太喜欢大写,因为大写在西文世界里代表着 Panic ,惊恐,给人以一种不安的感觉,让人读我们的接口时不是很舒心。另外蛇形有点太长了,

struct inode* name_i(IN char*, IN int, OUT char*);

可以在必要的(名字有点长)时候采用蛇形命名,这个在 Unix-like 代码中也是经常出现的

最后,我有一个提议,那就是简洁。一般保持函数签名在 7 个字母长度内,如果不能避免,那就采用蛇形。而且尽量采用缩写,比如 allocate 可以写成 alloc ,process 写成 proc ,这依然不失其意

#3 - 函数内部规范

#3.1 - 不在 if-else 中写太多业务

我们在编写函数业务逻辑时,特别是碰到 for 里写 if-else 的情况,代码往往会非常的冗长。待自己写完之后,发现易读性很差,主要是缩进问题,比如,

void 
f(void)
{
  struct inode *ip;
  
  ...
  for(...) {
   	if(ip) {
      ip->type = x;
      ip->inum = y;
      ...
      ip->addrs[0] = z;
    } 
  }
}

这样的代码,一看就不是很爽,竟然在 if 里做了这么多事,让代码平白无故多了一对括号及业务逻辑的缩进往里退了一级。这很影响观感,我们在这边用个 continue 的技巧,

void 
f(void)
{
  struct inode *ip;
  
  ...
  for(...) {
   	if(!ip) 
      continue;
    
    /** 至此,ip 一定不为空 */
    ip->type = x;
    ip->inum = y;
    ...
    ip->addrs[0] = z;
  }
}

这样,业务代码依旧保持一级锁进,突出要点。再者,如果 if 里就一句话,可以不用括号

#3.2 - 避免嵌套 if-else

我相信大家都写过这样的代码,

if(flag1) {
  if(flag2) {
    if(flag3) {
      printf("ok");
    }
  }
}

目前只有 3 个 if ,看起来还行,但如果有 10 个呢?太丑了,我们可以利用 return 优化代码,

if(!flag1)
	return 0;

if(!flag2)
  return 0;

if(!flag3)
  return 0;

printf("ok");

这样也能达到同样的目的,如果在 for 循环里也碰到 if-else ,可以将 return 换成 continue 。要点,就是保持业务代码在优先的缩进级上

#3.3 - goto 无罪

其实,我们写 C 大可不必回避 goto ,这是前人从汇编语言中提炼出的智慧,能够很巧妙地解决代码复制和流程冗长的问题。我想通过 demo 展示其用法,

void
usertrap(void)
{
  struct proc *p = myproc();
  
  // save user program counter.
  p->trapframe->epc = r_sepc();
  
  uint64 scause = r_scause();
  if(scause == 8){
    // system call
    ...
  } else if(scause==13 || scause==15) { 
    /** 缺页中断 */
    uint64 va = r_stval();

    /** 虚拟地址是否合法(在堆区,见xv6 book Figure3.4) */
    if(va>=p->sz || va<=p->trapframe->sp)
      goto killing;
      
    /** 尝试分配空间(缺页中断handler) */
    char* mem = kalloc();
    if(mem == 0)
      goto killing;

    /** 做好新页的空间映射工作 */
    memset(mem, 0, PGSIZE);
    va = PGROUNDDOWN(va);
    if(mappages(p->pagetable, va, PGSIZE, (uint64)mem, PTE_W|PTE_X|PTE_R|PTE_U) != 0) 
      goto freeing;

    /** 顺利handle缺页中断 */
    goto rest;

  freeing:
    kfree(mem);

  killing:
    p->killed = 1;

  rest:
    ;
  } else if((which_dev = devintr()) != 0){
    // ok
  } else {
   ...
  }

  if(p->killed)
    exit(-1);

  // give up the CPU if this is a timer interrupt.
  if(which_dev == 2)
    yield();

  usertrapret();
}

usertrap() 的大致流程是这样的,根据中断缘由执行相应的业务代码,比如系统调用 or 缺页中断。我们重点关注缺页中断的业务代码,流程很简单,就是首先获取虚拟地址 va ,判断其是否越界,若越界直接 goto 到 killing ;没越界的情况下,就试着为其分配 page ,若系统此时无空闲内存导致分配失败,也 goto 到 killing ,结束流程;若新 page 映射虚拟地址失败,则需要将已分配的 page 释放掉,再杀掉进程,我在代码中用 goto freeing 表示;以及最后的 rest 收尾工作

这些都是我认为较为简洁的方式,不需深入 demo ,可以通过 killingfreeing 窥探究竟,即是代码复用和快速结束流程

#3.4 - 用 [] 替代 *

关于 C 的语法糖,其实并不多,数组 a 的第 i 个元素可以表示成纯正的 C 风格 *(a+i) ,也可以写成 a[i]

我更偏向于后者,因为它更为直观。我们写的代码,以后自己多半是要 review 的 or 方便继任者开拓的,不是用来装 X 的,别跟自己过不去,别为难他人!

一维数组 a 可以写成 *(a+i) ,觉得还不错,如果是二维呢?我相信会有点乱吧,但是下标法就不会乱,即是 a[i][j]

#3.5 - 琐碎小事

我看到过好多的打空格、括号等规则,我选择的是,在 for 或 if 等条件中,如果表达式唯一,空格放开了用,这样美观,比如,

if(flag == 1) {
	... 
}

如果有多个多表达式,我就在 && or || 处打空格,这样紧凑,

if(flag1==1 || flag2==0) {
  ...
}

另外,函数内部的括号我都是将左括号留在第一行末尾的,右括号另起一行,如上;函数的定义都是各占一行,

void 
f(void)
{
  ...
}

很多的细节,讲不完,还是注重实战,可以参考 Google C++ style 用于 C

#4 - 类 C 设计模式

#4.1 - 函数调用序列化

我们常常会碰到一种情况,比如针对系统调用,fork 要手动编码调用 sys_fork() 的代码,exit 也要手动调用 sys_exit() ,代码一般会写成这样,

if(fork)
  sys_fork();

if(exit)
  sys_exit();

如果之后还新增了 wait 和 kill 系统调用呢?那我们是不是还要在原先的业务代码里再加上 if 判断呢?那么这样一来,是不是破坏了只追加不修改的 OOP 原则呢?虽然 C 是面向对象的,但是不影响它可以 OOP 的这一事实!

if(fork)
  sys_fork();

if(exit)
  sys_exit();

if(wait)
  sys_wait();

if(kill)
  sys_kill();

现在通过修改业务代码的方式,完成了需求。之后如果又新增需求,请问是不是还需修改代码?也就是需求变了,代码就要跟着变!这样的代码,质量真的不高

于是,我们引用了一种设计模式(教科书的学名我就不说了,不喜欢文绉绉的),序列化最直接,就是将所有业务函数放在向量中,根据需求按编号调用,请看,

static uint64 (*syscalls[])(void) = {
[SYS_fork]    sys_fork,
[SYS_exit]    sys_exit,
[SYS_wait]    sys_wait,
[SYS_kill]    sys_kill,
};

void
syscall(void)
{
  int num;
  struct proc *p = myproc();

  num = p->trapframe->a7;
  if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
    p->trapframe->a0 = syscalls[num]();
  } else {
    printf("%d %s: unknown sys call %d\n",
            p->pid, p->name, num);
    p->trapframe->a0 = -1;
  }
}

sys_ 的子函数全部封装在函数指针数组中,而后在 syscall() 中根据 a7 寄存器传来的系统调用编号,按号调用。是不是很方便?即使以后新增了一个 read 系统调用,变动也不大。只需完成 sys_read() 的工作及在 *syscalls[] 数组中追加 sys_read 编号和函数即可

#4.2 - 有待补充

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值