语法要点

操作符号

运算符优先级

有些表达式的优先级和直觉相反,有些表达式看起来有二义从而难以确定应该怎么理解,出现二义时需要引入结合性来分析,下面列举几个建议添加括号说明的例子。

if (flags & NEED_READ != 0)             // A
    read_data();

r = hi << 4 + low;                      // B

while (c = getc(in) != EOF)             // C
    putc(c, out);

p = n * sizeof * q;                     // D

apple = sizeof(int) * p;                // E
A
先执行逻辑比较再进行位运算
B
先计算加法再计算移位
C
先比较getc()EOF是否不相等,再赋值
D
sizeof优先级低于解引用,等价于n * sizeof(*q)
E
sizeofint先结合,如果写成sizeof(int)p则编译不通过

括号是超越优先级的存在,必要的时候用括号增加易读性,但是切莫滥用括号。我精心编写了一组口诀来记住优先级,如下所示。

  成员-一元-莫转换,四则-移位-比较看
  位与-逻辑-问条件,赋值-逗号-后排见

clang-op-priority.png

关键字

const
定义常量
volatile
防止编译器优化省略
register
建议编译器优化放到寄存器中,不在内存中故不能取地址
static
用于限制作用域或存储位置
extern
用于声明外部变量

如下代码如果不声明为volatile会被编译器优化为一行代码:

XBYTE[2]=0x55;
XBYTE[2]=0x56;
XBYTE[2]=0x57;
XBYTE[2]=0x58;

但是对于配置外部硬件而言,可能需要一个完整的操作顺序,这时就不能被优化。

一个const volatile例子是只读状态寄存器,它不允许程序员改变,但可能受硬件环境影响。指针也可以是volatile,如中断服务程序可以更改一个指针的指向。

用C++编译器编译C语言会修改函数名以支持重载,宏__cplusplus是C++编译器内置的宏,用extern "C"可以防止修改函数名导致符号找不到。通常都需要C文件在C++编译器和C编译器编译后得到一致的结果,所以这种写法在C代码中很常见。

#ifdef __cplusplus
extern "C" {
#endif
...
#ifdef __cplusplus
}
#endif

内置类型

内置类型的最大值、最小值可以从头文件limits.h中查询。

printf("%d", INT_MAX);

右移位的时候,如果是一个负数,那么高位可能是由0填充,也可能是由符号位填充,因此可移植的办法是使用无符号类型来右移位。此外移位的数目应该要保证大于等于0,并且严格小于对象的位长。对于负数,右移一位并不等于除以2,举例来说,-1 >> 1一般不会是0,而-1 / 2则为0。如果知道数值为非负,那么用移位来代替除法是没有问题的。

数组指针

数组

C99支持VLA(变长数组),但是不推荐使用,因为栈上分配数组容易导致崩溃。

指针

指针常量:指向固定

int *const cp = &x;

常量指针:只读指针

const int *pc = &x;
int const *pc = &x;

双常量指针:指向固定的只读指针。

const int *const cpc = &x;

理解const要记住它修饰的是其右边的东西,如右边是ptr,那么就表示指向固定,如右边是*ptr,那么就表示内容只读。

更复杂的形式:

const int **p;                          // A
char *const *(*next)()                  // B
A
右边是**p所以实际上是指内容只读
B
函数指针,返回一个二级指针,第一级为常量指针

函数

参数从右往左入栈。

数组参数默认会退化为指针:

// 等价写法:int arr[]
void arr1_args(int *arr, int len);
// 等价写法:int (arr[])[LEN] -> int arr[][LEN]
void arrp_args(int (*arr)[LEN]);
// 等价写法:int arr[][COL]
void arr2_args(int (*arr)[COL], int row);
int isvowel(c)
    char c;
{
    return c == 'a' || c == 'e' || c == 'i' || c == 'o' ||
        c == 'u';
}

在很多库函数中能看到这样的写法,这种写法主要是为了与老的编译器兼容,老的编译器不支持指定函数参数类型,所以在传递参数时会默认转换为int,这种写法和如下写法等价。

int isvowel(int i)
{
    char c = i;
    return c == 'a' || c == 'e' || c == 'i' || c == 'o' ||
        c == 'u';
}

如果编译器不优化,inline就是普通函数,更便于调试,调试好了之后采用优化重新编译,inline函数就像宏一样融入代码。在早期,inline还不是关键字,可以看到gcc库中有用__inline__,就是处于兼容性考虑。

可变参数

#include <stdarg.h>
#include <stdio.h>

void test1(int count, ...)              // number by count
{
    va_list args;
    va_start(args, count);
    for (int i = 0; i < count; i++) {
        printf("%d\n", va_arg(args, int));
    }
    va_end(args);
}

void test2(const char* s, ...)          // end by NULL
{
    const char* value = s;
    va_list args;
    va_start(args, s);
    while (value != NULL) {
        printf("%s\n", value);
        value = va_arg(args, char*);
    }
    va_end(args);
}

void test3(const char* format, ...)     // parse by format
{
    va_list args;
    va_start(args, format);
    vprintf(format, args);
    va_end(args);
}

int main(int argc, char* argv[])
{
    test1(3, 11, 22, 33);
    test2("hello", "world", NULL);
    test3("number %d\n", 1234);
    return 0;
}

结构体

结构体长度

如果将尾部成员定义为0长数组,计算结构体大小时其对应成员长度按0计入。这种技巧的写法主要用在对性能和内存要求比较苛刻的情况。

class empty {};
struct zero_only {char str[0];};
struct zero_tail {int count; char str[0];};
printf("%d %d %d\n", sizeof(struct empty),
       sizeof(struct zero_only), sizeof(struct zero_tail));
1 0 4

用g++编译的结果如上所示,但这不是说答案就应该是这样的。标准C不允许0大小的对象,即0长数组、空结构、空共同体都是不允许的。所以这种写法没有可移植性。而C++标准则允许空类,空类具有长度是为了保证每个对象都有一个独立的地址。

在比较底层的代码中,经常可以看到在结构体定义中使用属性:

struct x86_hw_tss {
    u32                     reserved1;
    u64                     sp0;
    u64                     sp1;
    ...
} __attribute__((packed)) ____cacheline_aligned;
packed
表示成员字段不用对齐
____cacheline_aligned
整个数据结构按照高速缓存行的大小对齐

默认的位段还是会对齐,仅在在能够容纳的地方优化存储,如果加上packed属性就会取消一切对齐。不论那种方法都不允许对位段成员取地址。

// 1 + 2 + 8
typedef struct bsi {bool b; short s; int i;} __attribute__((__packed__)) BSI;
typedef struct bis {bool b:6; int i:6; short s:1;} BIS;
typedef struct bisp {bool b:6; int i:6; short s:1;} __attribute__((packed)) BISP;
printf("%d %d %d\n", sizeof(BSI), sizeof(BIS), sizeof(BISP));
7 4 2

结构体组织

offsetof(class, member)可用于计算成员偏移量,但是这个宏其实是有很多限制的,比方不能对结构体中的压缩位字段计算。

结构体的点操作符可以帮助我们访问结构的成员,反过来如果我们知道当前结构或数据类型属于某个结构体,也可以利用存储特性由成员获取父结构体。举一个例子,请看如下代码,问号处的代码应该怎么写才能返回信息所属的人?

struct person {
    char name[10];
    int age;
    int id;
    struct infomation info;
};

struct person *get_person(struct infomation *info)
{
    // ?
}

int test_get_person(void)
{
    struct person p;
    return &p == get_person(&p.info);
}

在Linux内核中大量的运用到了get_person()这样的函数,或者说宏,比较典型易读的代码可以参考linux/list.h文件。实现的关键思路就是在struct的表示中,成员地址相对于结构体的首地址偏移量是固定的。典型的宏定义如下。

/**
 * container_of - cast a member of a structure out to the containing structure
 * @ptr:        the pointer to the member.
 * @type:       the type of the container struct this is embedded in.
 * @member:     the name of the member within the struct.
 *
 */
#define container_of(ptr, type, member) ({                              \
            const typeof(((type *)0)->member) *__mptr = (ptr);          \
            (type *)((char *)__mptr - offsetof(type, member));})

另一种更简洁的写法如下:

#define container_of(ptr, type, member)                         \
    (type *)((char *)(ptr) - (char *) &((type *)0)->member)

有了这个宏,问号处的代码就很清晰了,直接写return container_of(info, struct person, info)即可。

#define max(a, b) ((a) > (b) ? (a) : (b))

int c = max(a++, ++b);                  // A

#define TP struct table *
TP a, b;                                // B

#define a (x) sum(x)
a(u);                                   // C
A
宏的定义本身没有问题,问题出在调用处执行了有副作用的代码
B
解决方法是避免用宏来定义类型,改用typedef
C
展开得到(x) sum(x)(u),故定义时要仔细

一个典型的assert宏定义为如下形式:

#define __assert_err(e, file, line)                                     \
    ((void)printf("%s:%u: assert '%s'\n", file, line, e), abort())
#define assert_err(e)                                                   \
    ((void)((e) ? (void) 0 : __assert_err(#e, __FILE__, __LINE__)))

如果一个宏当中包含多个语句,应当使用do-while形式:

#define SET32(p_dst, src)                       \
    do {                                        \
        (p_dst)[0] = (u8)(src);                 \
        (p_dst)[1] = (u8)(((u32)(src)) >> 8);   \
        (p_dst)[2] = (u8)(((u32)(src)) >> 16);  \
        (p_dst)[3] = (u8)(((u32)(src)) >> 24);  \
    } while (0)

不能简单用大括号包围,因为遇到不带括号的if-else语句时会打断if-else从而不能通过编译。

可执行程序

linux-mem-model.png

位置无关代码的概念和动态库紧紧相连。静态库主要有两个问题,一是升级麻烦,二是常用函数大量复制让软件体积暴涨。优点是代码和数据在可执行文件有备份,符号地址固定。如果不将动态库编译成位置无关代码,就需要加载到指定地址,运行时就不得不修改页面将其安排到固定位置,造成效率下降。

注意事项

常量与变量

自动变量

#define BUFSIZE         1024
int main()
{
    int c;
    char buf[BUFSIZE];

    setbuf(stdout, buf);
    while((c = getchar()) != EOF)
        putchar(c);

    return 0;
}

在main函数运行之后,将会刷新缓存,而此时作为自动变量的buf已经被释放了。可以通过将buf定义为静态变量避免这个问题。

进制误用

struct {
    int part_number;
    char *description;
} part_table[] = {
    {027, "windows"},
    {077, "linux"},
    {123, "others"},
};

这里企图用0来对齐,但是编译器会将027/077视为八进制数据,这很可能不是程序编写者本身的意图。

越界与溢出

访问越界

int a[10];
for (int i = 0; i <= 10; i++) {
    a[i] = 0;
    printf("a[%d] = %d, ", i, a[i]);
}

如果编译器按照内存地址递减的顺序给变量分配空间,那么a[10]对应的实际就是变量i,当循环到i=10的时候会将i复位为0,从而形成死循环。

运算溢出

if (a + b < 0)                          // A
    printf("out of range\n");

A处企图用两个整型相加的结果为负来判断是否溢出,但是这样的方法并不正确,例如某些机器上溢出会产生一个溢出状态,此时的结果就不为负数。比较简单的方式是通过a > INT_MAX - b来判断,此外也可以将其转换为无符号整数来判断。

输入输出

char c;

for (int i = 0; i < 5; i++) {
    scanf("%d", &c);
    printf("%d ", i);
}

这部分代码的关键问题是c被声明为char类型,而在输入时又当作整型数,会导致变量c附近的内存被覆盖。

异常处理

错误编号

不要在正常返回的情况下检查errno,因为即便所调用的函数返回正确,也可能在函数中又调用了其他函数,而其他函数有可能会设置errno。

信号处理

信号可能出现在某些复杂的库函数中,如果signal处理函数中再调用这样的函数,结果可能导致不堪设想的后果。因此首先要避免在信号处理函数中调用复杂函数。

假设malloc执行过程被一个信号中断,此时malloc用于追踪可用内存的数据结构可能只有部分更新,如果在signal处理函数中再调用malloc,就可能让malloc完全崩溃。故切忌在信号处理函数中调用malloc。在信号处理函数中中使用longjmp也不安全,因为信号可能发生在malloc更新数据结构的过程中。信号处理函数能够做的安全的事情就是设置一个标志然后返回,期待主程序检查到这个标志。

对于算术运算,某些机器在信号处理函数返回时还会重新执行失败的操作,而我们又没有办法更新操作数,故此时唯一安全可移植的办法就是打印一条出错消息,然后用exit退出程序。

简单来说,大多情况信号处理函数正确做法是设置标志,由主程序检测标志,特殊情况,如算术出错信号,应直接退出程序。