Android JNI 编程 - C语言基础知识 (三)

938 阅读3分钟

线程

这个小节的目标是稍微熟悉一下c语言体系下的多线程操作,避免看到类似代码一脸懵逼 不知道为啥

使用pthread 创建一个线程

注意这里要改一下 cmake文件 否则,pthread 相关依赖是找不到的

add_executable(cstudy12 pthread_test.c)
target_link_libraries(cstudy12 pthread)
//
#include <pthread.h>
#include <stdio.h>

void *SayHello(void *name) {
  printf("hello from thread %s", name);
  pthread_exit(NULL);
}

int main() {
  //声明了一个pthread_t类型的变量tid来存储线程ID
  pthread_t tid;
  // ret!=0 代表创建线程失败了
  int ret;
  //第一个参数是指向pthread_t变量的指针,用于存储新线程的ID。第二个参数是线程的属性,可以使用NULL表示默认属性。
  // 第三个参数是指向线程入口点函数的指针。最后一个参数是传递给线程入口点函数的参数,可以使用NULL表示没有参数。
  ret = pthread_create(&tid, NULL, SayHello, "wuyue");
  if (ret) {
    printf("error create pthraed");
    return 1;
  }
  //我们使用pthread_join函数等待新线程完成执行。pthread_join函数的第一个参数是要等待的线程的ID,
  // 第二个参数是指向线程返回值的指针,如果不需要获取线程返回值,可以使用NULL表示不关心返回值。
  pthread_join(tid, NULL);
  return 0;
}

锁操作

#include <pthread.h>
#include <stdio.h>
int global_value=0;
pthread_mutex_t lock;

void *thread_func(void* arg){
  int i;
  for (i = 0; i < 100; ++i) {
    //locl和unlock要成对出现
    pthread_mutex_lock(&lock);
    global_value++;
    pthread_mutex_unlock(&lock);
  }
  return NULL;
}

int main() {
  // 注意init和destroy 要成对出现
  pthread_mutex_init(&lock,NULL);

  pthread_t t1,t2;

  pthread_create(&t1,NULL, thread_func,NULL);
  pthread_create(&t2,NULL, thread_func,NULL);

  pthread_join(t1,NULL);
  pthread_join(t2,NULL);

  pthread_mutex_destroy(&lock);
  printf("value:%d",global_value);

  return 0;
}

threadlocal

#include <pthread.h>
#include <stdio.h>

//__thread关键字告诉编译器为thread_local_var变量分配线程本地存储空间,这样每个线程都可以拥有自己的thread_local_var变量实例。
// 这意味着在不同的线程中访问thread_local_var变量时,每个线程都会访问自己的thread_local_var变量实例,从而避免了线程间的竞争和同步开销。
__thread int thread_local_var;

//在线程函数中,我们可以像使用普通变量一样使用thread_local_var变量,并且每个线程都可以拥有自己的变量实例
void *thread_func(void *arg){
  thread_local_var = (int) arg;
  printf("Thread %d: thread_local_var=%d\n", (int) pthread_self(), thread_local_var);
  pthread_exit(NULL);
}


int main() {
  pthread_t tid[2];
  int i;

  //创建两个线程
  for (i = 0; i < 2; i++) {
    if (pthread_create(&tid[i], NULL, thread_func, (void*)i) != 0) {
      perror("pthread_create");
    }
  }

  //等待线程结束
  for (i = 0; i < 2; i++) {
    pthread_join(tid[i], NULL);
  }

  return 0;
}

C语言的编译过程

这一小节 主要是为了后面静态库和动态库理解用的

预处理器

这一步执行完以后就是宏 替换后的源代码

gcc -E helloworld.c -o helloworld.i

可以看下这个helloworld.i 是啥

内容挺长的,大家可以自行看看,其实也是源代码 只不过比你写的源代码要复杂多了,宏被统一替换成源代码了 image.png

编译器

这一步执行完以后就是中间文件 也就是汇编指令

gcc -S helloworld.i -o helloworld.s

image.png

也可以直接编译成目标文件

目标文件是计算机可执行的二进制文件,它是编译器生成的中间文件,包含编译后的机器代码和未解析的符号引用。 目标文件可以用于生成最终的可执行文件或者库文件。

gcc -C helloworld.s -o helloworld.o

我们可以看一下这个 目标文件的类型

image.png

未解析的符号引用 这概念要好好掌握:

未解析的符号引用(Unresolved symbol reference)指的是在目标文件中引用的一个符号(通常是函数或变量)没有在该文件中定义。这个符号被标记为“未解析”,因为编译器无法确定该符号所代表的实际地址,因为该符号的定义在其他文件中。在链接器(Linker)将多个目标文件组合成可执行文件或者库文件时,需要解析这些未解析的符号引用,将其与正确的定义进行匹配,以确保程序能够正常运行。

未解析的符号引用通常发生在使用库文件时,因为库文件是预编译好的二进制代码,编译器无法在编译时确定库中函数或变量的定义。当链接器链接目标文件时,它会搜索库文件,以找到符号的定义,并将其与未解析的引用进行匹配。

如果链接器无法找到符号的定义,它将生成一个“未定义符号”错误并停止链接过程。因此,解决未解析的符号引用问题非常重要,通常需要使用正确的编译器选项和库文件来确保正确的链接。

我们可以使用命令来查看 有哪些未解析的符号引用

nm -u helloworld.o

image.png

这里有的人奇怪 怎么会有个puts函数,我们写的是printf啊, 这是因为我们printf里面 是个纯字符串比较简单,所以gcc的编译器自动给我们优化了

查看下汇编代码就真相大白了

image.png

链接器

这一步执行完以后就是可执行文件了

我们可以把这一步打出来看一下

gcc -v helloworld.o -o helloworld

代码我就不贴了,太长了,基本上都跟一个叫collect的程序有关。

可以看下生成的可执行文件类型是什么

image.png

静态链接库与动态链接库

静态库被使用目标代码最终和可执行文件在一起(它只会有自己用到的),而动态库与它相反,它的目标代码在运行时或者加载时链接。

静态链接的可执行文件要比动态链接的可执行文件要大得多,因为它将需要用到的代码从二进制文件中“拷贝”了一份,而动态库仅仅是复制了一些重定位和符号表信息。

静态链接的可执行文件不需要依赖其他的内容即可运行,而动态链接的可执行文件必须依赖动态库的存在。所以如果你在安装一些软件的时候,提示某个动态库不存在的时候也就不奇怪了。

即便如此,系统中一般存在一些大量公用的库,所以使用动态库并不会有什么问题。

静态链接库

已知 我们有如下 一段c 代码

image.png

add.c 和 subtract.c 代码就不放了 很简单,大家猜也能猜到 啥意思

现在我们想打出一个可执行程序

我们首先打出2个目标文件

gcc -c add.c subtract.c

image.png

然后我们使用ar命令将这两个目标文件打包成静态链接库 libmath.a

ar rcs libmath.a add.o subtract.o

image.png

然后

gcc -o main main.c -L. -lmath

image.png

即可编译出我们的可执行文件了

动态链接库

注意动态库的命名必须是lib开头

其实你看 就算刚才我们编译出来的 main 可执行文件 里面也是需要有动态链接库信息的 这个libc 应该很熟悉吧。。 image.png

还是上面那个例子

gcc -v -fPIC -shared -o libmath.so add.c subtract.c

-v 可以详细打出编译的过程日志信息,有兴趣的可以自己看下

image.png

可以看出来 这个so 文件已经出来了

image.png

继续编我们的可执行程序:

gcc -o main main.c -L. -lmath

image.png

但是当你执行的时候 你会发现报错了

image.png

他说这个 找不到这个so文件在哪里

此时我们再查看一下

image.png

要解决这个方法也很简单

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/path/to/library

再看 即可恢复正常

image.png

当然你也可以把这个so 文件拷贝到 /usr/lib 这个文件夹下, 其实很多程序的安装 就是把动态库 放到/usr/lib下

mmap 技术

mmkv 用的就是这个作为基础, 这里也用c语言来做一些演示,加深一下理解。讲白了对于andorid程序员来说,其实就是 用c语言和linux的接口 以及 android 提供的一些c语言的库,来做一些编程

优点:

在mmap技术中,操作系统将文件映射到进程的虚拟地址空间中,然后进程可以像访问内存一样访问文件的内容。这使得文件的访问变得更加高效,因为操作系统可以通过页面映射技术将文件内容读入物理内存中,并且在需要时将其刷回磁盘,而不需要频繁地进行文件I/O操作

缺点:

  1. 映射大文件时可能会消耗大量的虚拟内存。因为mmap技术将文件内容映射到进程的地址空间中,所以在映射大文件时会消耗大量的虚拟内存,可能会导致进程内存耗尽的问题。
  2. 映射文件时可能会导致文件锁定。当使用mmap技术将文件映射到进程的地址空间中时,文件可能会被锁定,导致其他进程无法对其进行访问。
  3. 不能直接访问文件系统。mmap技术只能访问已经打开的文件,不能直接访问文件系统,这可能会导致某些应用程序无法使用该技术来访问文件。
  4. 不支持对文件末尾进行动态扩展 使用mmap技术将文件映射到进程的地址空间中时,如果需要对文件末尾进行动态扩展,则需要重新映射文件,这可能会导致额外的开销和复杂性。

在使用mmap技术时,通常需要满足一些要求,其中一个要求是要按照4K的倍数进行映射。这是因为操作系统将进程的地址空间分为多个页面,每个页面通常是4K字节大小,因此如果要将文件映射到进程的地址空间中,就需要按照页面大小的倍数进行映射。

如果按照不是4K的倍数进行映射,操作系统可能会自动进行调整,但这可能会导致额外的开销和性能问题。因此,在使用mmap技术时,最好按照4K的倍数进行映射,以获得最佳的性能和效率。

通过上述的描述,我们要遵循一个使用方法, 当你使用mmap技术时,一定要适当的调整好自己的文件大小,这个文件大小一旦确定下来,后续就不要去改他了。

下面看一个例子

#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

#define FILE_SIZE 1024
int main() {
  // 此处一定要注意 有读写文件的权限才可以
  int fd = open("file.txt", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
  if (fd == -1) {
    perror("open");
    exit(EXIT_FAILURE);
  }
  if (ftruncate(fd, FILE_SIZE) == -1) {
    perror("ftruncate");
    exit(EXIT_FAILURE);
  }
  // 一般第一个参数都是为null 这里也是要有读写文件
  char *file_data = mmap(NULL, FILE_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
  if (file_data == MAP_FAILED) {
    perror("mmap");
    exit(EXIT_FAILURE);
  }
  file_data[0] = 'H';
  file_data[1] = 'e';
  file_data[2] = 'l';
  file_data[3] = 'l';
  file_data[4] = 'o';

  // 将修改 同步回文件
  if (msync(file_data, FILE_SIZE, MS_SYNC) == -1) {
    perror("msync");
    exit(EXIT_FAILURE);
  }

  // 取消映射
  if(munmap(file_data,FILE_SIZE)==-1){
    perror("munmap");
    exit(EXIT_FAILURE);
  }
  // 关闭文件
  if(close(fd)==-1){
    perror("close");
    exit(EXIT_FAILURE);
  }

  return 0;
}

如果是打开一个已存在的文件怎么做? 其实和上面的代码差不多,主要就是自己获取文件的实际大小就可以了

#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>

int main() {
  // 此处一定要注意 有读写文件的权限才可以
  int fd = open("file.txt", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
  if (fd == -1) {
    perror("open");
    exit(EXIT_FAILURE);
  }
  // 获取文件的大小 
  struct stat st;
  if (fstat(fd, &st) == -1) {
    perror("fstat");
    exit(EXIT_FAILURE);
  }
  size_t file_size = st.st_size;



  // 一般第一个参数都是为null 这里也是要有读写文件
  char *file_data = mmap(NULL, file_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
  if (file_data == MAP_FAILED) {
    perror("mmap");
    exit(EXIT_FAILURE);
  }
  file_data[0] = 'w';
  file_data[1] = 'u';
  file_data[2] = 'y';
  file_data[3] = 'u';
  file_data[4] = 'e';

  // 将修改 同步回文件
  if (msync(file_data, file_size, MS_SYNC) == -1) {
    perror("msync");
    exit(EXIT_FAILURE);
  }

  // 取消映射
  if (munmap(file_data, file_size) == -1) {
    perror("munmap");
    exit(EXIT_FAILURE);
  }
  // 关闭文件
  if (close(fd) == -1) {
    perror("close");
    exit(EXIT_FAILURE);
  }

  return 0;
}

有兴趣的可以自己试着 用mmap 封装一层 简单的接口 给java 端调用,实现一个简单版本的mmkv。 简单版本的mmkv 我们就用 mmap+文本的方式就可以了。 实际的mmkv 不过是把文本存储 替换成了 pb 协议二进制存储,效率更高,仅此而已

总结

jni 的第一部分 知识到这里就结束了,主要就是简单介绍了下c语言以及 linux下的文件操作, 有了这些基础知识 已经可以让我们看一下简单的 jni库了, 要真正的上手去写jni程序,最终还是得学习一下c++,后续的基础知识就围绕c++展开, 同时穿插一些 重要的linux基础,例如 进程,权限,共享内存,信号 等等,这些知识对后续实际的android端 jni编程非常有帮主