23.1 预备知识

strcpy 和 strncpy

把一个字符串拷贝给另一个字符串。

  • include

  • char strcpy(char dest, const char *src);
  • char strncpy(char dest, const char *src, size_t n);

strcpy 拷贝时包括结尾的 \0。
确保读写不会越界是调用者的责任。
凡是有指针参数的 C 标准库函数基本上都会要求 str 和 dest 所指向的空间不能重叠。
strncpy 参数 n 指定最多从 src 中拷贝 n 个字节到 dest 中:

  • 提前遇到 src 的 \0 则提前结束,后续不足 n 的部分,全用 \0 填充;
  • 但若 n 比 src 小,只拷贝了 src 的一部分,则要自己负责 dest 能以 \0 结尾,通常是拷贝完后,手动替换最后一个字符为 \0,如 buf[sizeof(buf)-1] = '\0';
  • 至于 n 比 src 还要大,则读 src 会越界,这就是调用者的锅了。

总之,strncpy 会保证向 dest 中写满 n 个字符,调用时通常让 n 的值等于 dest 所指向的内存空间的大小(保证不会写越界)。

  1. char buf[10];
  2. strncpy(buf, "hello world", sizeof(buf));
  3. buf[sizeof(buf)-1] = '\0';

strcpy 比 strncpy 更不安全,使用 strcpy,当 src 超过 dest 长度时就会写越界。
写越界可能覆盖了保存在栈帧上的返回地址,导致函数返回时跳转到非法地址而出错。
buf 这种由调用者分配并传给 strcpy 函数访问的一段内存通常称为缓冲区(Buffer)
缓冲区写越界的错误称为缓冲区溢出
该 bug 可能被恶意利用,使函数返回时跳转到事先设计好的地址,执行指令,甚至启动一个 shell。

实现一个 strcpy()

  1. #include <stdio.h>
  2. /**
  3. * 实现一个 strcpy 函数,将 src 字符串复制到 dst 字符串中,并返回 dst。
  4. */
  5. char *my_strcpy(char *dest, const char *src)
  6. {
  7. for (int i; i < sizeof(src); i++)
  8. dest[i] = src[i];
  9. return dest;
  10. }
  11. /* linux的实现 参照:http://www.chinaunix.net/old_jh/23/25356.html */
  12. char *strcpy(char *strDest, const char *strSrc)
  13. {
  14. if ((strDest == NULL) || (strSrc == NULL)) //[1]
  15. // throw "Invalid argument(s)";
  16. printf("wrong"); //[2]
  17. char *strDestCopy = strDest; //[3]
  18. while ((*strDest++ = *strSrc++) != '\0')
  19. ; //[4]
  20. printf("ok"); //[2]
  21. return strDestCopy;
  22. }
  23. int main(void)
  24. {
  25. // int i = 10;
  26. char buf[10] = {'c', 'c', 'c', 'c', 'c', 'c', 'c', 'c', 'c', 'c'};
  27. strcpy(buf, "Hello");
  28. // printf("%s\n", buf);
  29. // printf("%d\n", sizeof(buf));
  30. printf("[%c][%c][%c]\n", buf[4], buf[5], buf[6]);
  31. printf("--------[%c]--------\n", buf[8]);
  32. }

实现一个压缩字符串空白的函数 shrink_space

  1. /**
  2. * 编一个函数,输入一个字符串,要求返回一个新字符串,把其中所有的一个或多个连续空白字符都压缩成一个空格。
  3. * 空白字符包括:空格、'\t'、'\n'、'\r'。
  4. *
  5. * @file 1-2.c
  6. * @date 2022-05-12
  7. */
  8. #include <stdio.h>
  9. #include <stddef.h>
  10. int is_space(char c)
  11. {
  12. return c == ' ' || c == '\t' || c == '\n' || c == '\r';
  13. }
  14. char *shrink_space(char *dest, const char *src, size_t n)
  15. {
  16. size_t i;
  17. char *result = dest;
  18. for (i = 0; i < n && src[i] != '\0'; i++)
  19. {
  20. if (!is_space(src[i]))
  21. {
  22. *dest++ = src[i];
  23. }
  24. else if (!is_space(src[i - 1]))
  25. {
  26. *dest++ = ' ';
  27. }
  28. }
  29. for (; i < n; i++)
  30. {
  31. *dest++ = '\0';
  32. }
  33. return result;
  34. }
  35. int main(void)
  36. {
  37. char buf[20];
  38. shrink_space(buf, " Abc \t\n def \r gh. ", sizeof(buf));
  39. printf("[%s]\n", buf);
  40. return 0;
  41. }

malloc 和 free

C 标准库函数 malloc 可以在堆空间动态分配内存,底层通过 brk 系统(抬高 break)调用向操作系统申请内存。动态分配的内存用完之后用 free 释放(准确说是归还给了 malloc),下次调用 malloc 时这块内存可再次分配出来。

#include <stdlib.h>

void *malloc(size_t size); // size 是要分配的字节数
// 成功返回所分配内存空间的首地址,出错返回 NULL 野指针

void free(void *ptr); // ptr 是之前用 malloc 申请的内存块首地址
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct
{
    int number;
    char *msg;
} unit_t;

int main(void)
{
    unit_t *p = malloc(sizeof(unit_t));

    if (p == NULL)
    {
        printf("out of memory\n");
        exit(1);
    }
    p->number = 3;
    p->msg = malloc(20);
    strcpy(p->msg, "hello world");
    printf("number: %d\nmsg: %s\n", p->number, p->msg);
    free(p->msg);
    free(p);
    p = NULL;

    return 0;
}

分配完内存不释放,会慢慢耗尽系统内存,称为内存泄漏(Memory Leak),大量内存泄漏会导致物理内存紧缺,换页频繁,拖慢当前进程甚至是整个系统。

malloc(0) 是合法的,返回一个非 NULL 指针,也可传给 free 释放,但不能通过该指针访问内存。
free(NULL) 也合法,不做任何事。
但 free 一个野指针就不合法了,如分配了 p 再连续两次 free(p),第二次就会报运行时错误。

基于环形链表 的 malloc 和 free 简单实现

实际 libc 的实现要复杂很多。
Break 以上不属于当前进程的地址空间,需要通过 brk 系统调用向内核申请,由内核分配物理内存映射到进程地址空间,并抬高 Break。
调用 malloc 申请的内存块,就属于用户程序了,free 释放后成为空闲块后才继续归 malloc 管理。
环形链表只串联空闲块。
image.png
19.5 链接详解-虚拟内存管理提到过进程地址空间
image.png

23.2 传入参数与传出参数

strcpy 的 src 参数就是传入参数,dest 参数就是传出参数。有些函数的指针参数同时充当这两种角色,称为 Value-result 参数。
很多系统函数对于指针参数时 NULL 的情况有特殊规定,如传入的是 NULL 表示缺省值,或表示不做特别处理。如传出的是 NULL 表示调用者不需要传出值等。

23.3 两层指针的参数

两层指针也是指针,同样可以表示传入参数、传出参数或者 Value-result 参数,只不过该参数所指的内存空间应该解释成指针变量。
两层指针作为传出参数还有一个特别用法,可以在函数中分配内存,调用者通过传出参数取得指向该内存的指针。

23.4 返回值是指针的情况

实现者返回一个指针,调用者将返回值保存下来以备后用。
所以通过调用函数来分配内存有两种:

  • 上一节的通过两层指针的传出参数函数,间接操作外部指针变量本身的内存空间,实现内存分配。
  • 本节的直接返回分配后的指针,调用者只需将这个返回的指针重新赋值给外部指针变量即可。

    23.5 回调函数

    参数是一个函数指针,调用者将函数地址传递给实现者,让实现者去调用它,这称为回调函数,如 qsort(3) 和 bsearch(3) 。
    回调函数接口示例:void func(void (*f)(void *), void *p);
/* para_callback.h */
#ifndef PARA_CALLBACK_H
#define PARA_CALLBACK_H

typedef void (*callback_t)(void *);
void repeat_three_times(callback_t, void *);

#endif

/* para_callback.c */
#include "para_callback.h"

void repeat_three_times(callback_t cb, void *para)
{
    cb(para);
    cb(para);
    cb(para);
}

/* main.c */
#include <stdio.h>
#include "para_callback.h"

static void say_hello(void *str)
{
    printf("Hello %s\n", (const char *)str);
}

int main(void)
{
    repeat_three_times(say_hello, "World");
    return 0;
}

回调函数的一个经典应用就是实现类似 C++ 的泛型算法,书中实现了一个通用的泛型 max 函数,可以在任意一组对象中找出最大值,调用者还要提供一个做比较的回调函数:

/* generics.h */
#ifndef GENERICS_H
#define GENERICS_H

#include <stddef.h>

typedef int (*cmp_t)(const void *, const void *);
/* 对象数组基地址,共有多少个对象,每个对象大小,比较函数 */
void *max(const void *base, size_t nmemb, size_t size, cmp_t cmp);

#endif

/* generics.c */
#include "generics.h"

void *max(const void *base, size_t nmemb, size_t size, cmp_t cmp)
{
     const char *_base = base;
     const char *temp = _base;
     size_t i;
     for(i = 1; i < nmemb; i++)
         if(cmp(temp, _base + size * i) < 0)
             temp = _base + size * i;
     return (void *)temp;
}

/* main.c */
#include <stdio.h>
#include "generics.h"

typedef struct {
     const char *name;
     int score;
} student_t;

int cmp_student(const void *a, const void *b)
{
     if(((student_t *)a)->score > ((student_t *)b)->score)
      return 1;
     else if(((student_t *)a)->score == ((student_t *)b)->score)
      return 0;
     else
      return -1;
}

int main(void)
{
     student_t list[4] = {{"Tom", 68}, {"Jerry", 72},
               {"Moby", 60}, {"Kirby", 89}};
     student_t *pmax = max(list, sizeof(list)/sizeof(student_t), sizeof(student_t), cmp_student);
     printf("%s gets the highest score %d\n", pmax->name, pmax->score);

     return 0;
}

以上回调函数都是被同步执行,还有异步执行的回调
调用者将回调函数传给实现者,实现者注册(记住)该回调函数,当某个事件发生时,实现者再调用先前注册(存储)的函数。
比如 sigaction(2) 注册一个信号函数,当信号产生时由操作系统调用该信号函数。
再比如 pthread_create(3) 注册一个线程函数,当发生调度时操作系统切换到新注册的线程函数中运行。
GUI 编程中为按钮注册回调函数,当用户点击按钮时被调用。
注册回调函数的代码框架:

/* registry.h */
#ifndef REGISTRY_H
#define REGISTRY_H

typedef void (*registry_t)(void);
extern void register_func(registry_t);

#endif

/* registry.c */
#include <unistd.h>
#include "registry.h"

static registry_t func;

void register_func(registry_t f)
{
     func = f;
}

static void on_some_event(void)
{
     ...
     func();
     ...
}

参数和返回值都可以是函数指针,因此可以 func()() 调用,但 C 语言中很少见返回函数指针,更多是在函数式编程语言中比较常见,如 LISP。基本思想是把函数也当做一种数据来操作,可以输入、输出和参与运算,操作函数的函数称为高阶函数(High-order Function)。

实现 C 标准库 qsort(3) 函数

#include "qsort.h"
// #include <stdlib.h>
#include <stdio.h>

// 参考文章 https://blog.csdn.net/Junadhkjashka/article/details/121695781
// 一个字节一个字节的拷贝
void swap(char *bub1, char *bub2, int w)
{
    int i = 0;
    //这里所接收的都是char*类型的指针,所以每次交换只能交换一个字节,如果想要完整的交换原本的两个元素,则需要接收原本元素所占字节大小
    for (i = 0; i < w; i++)
    {
        char temp = *bub1;
        *bub1 = *bub2;
        *bub2 = temp;
        bub1++;
        bub2++;
    }
}

void qsort(void *base, size_t nmemb, size_t size, compar cmp)
{
    void *_base = base;
    char *current;
    char *next;
    // 不好意思,冒泡排序~
    for (size_t i = 0; i < nmemb - 1; i++)
        for (size_t j = i + 1; j < nmemb; j++)
        {
            current = _base + size * i;
            next = _base + size * j;
            if (cmp(current, next) > 0)
            {
                swap(current, next, size);
                // 下面这样也行
                // for (size_t k = 0; k < size; k++)
                // {
                //     char temp = *(current + k);
                //     *(current + k) = *(next + k);
                //     *(next + k) = temp;
                // }
            }
        }
}

int compar_cb(const void *a, const void *b)
{
    return *(int *)a - *(int *)b;
}

int main(void)
{
    int arr[5] = {5, 4, 3, 2, 1};
    qsort(arr, 5, sizeof(int), compar_cb);
    for (int i = 0; i < 5; i++)
        printf("%d\n", arr[i]);
    return 0;
}

实现 C 标准库 bsearch(3) 函数

todo 暂无。

23.6 可变参数

要处理可变参数,需要用到 C 标准库的 stdarg.h 中的 va_list 类型和 va_start、va_arg、va_end 宏。
stdarg.h 取参数的原理,就是不断修改指针指向,然后用 *p 间接寻址运算符从函数的栈帧上取值即可,其中涉及到精妙的位运算就先不细究了。

用可变参数实现简单的 printf 函数:

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

void myprintf(const char *format, ...)
{
    va_list ap;
    char c;

    va_start(ap, format);
    while (c = *format++)
    {
        switch (c)
        {
        case 'c':
        {
            /* char is promoted to int when passed through '...' */
            char ch = va_arg(ap, int);
            putchar(ch);
            break;
        }
        case 's':
        {
            char *p = va_arg(ap, char *);
            fputs(p, stdout);
            break;
        }
        default:
            putchar(c);
        }
    }
    va_end(ap);
}

int main(void)
{
    myprintf("c\ts\n", '1', "hello");
    return 0;
}

照理说,printf 实现者只管按格式化字符串的描述从栈上取参数即可,调用者传入的参数类型和个数不正确,是调用者的事情。
但另一种可以确定可变参数的个数的办法,就是在参数列表的末尾按约定传一个 NULL 哨兵(Sentinel),如 execl(3) 函数。具体用法用到再说。