2024年11月

大家好,我是木宛哥;在 10余年的工作经历让我深刻体会到软件开发不仅仅是写代码,更是一个系统化的交付过程。
为此我总结了20条编程感悟,涵盖了代码规范、设计原则、测试方法与交付流程等多个方面;​通过遵循代码规范,让代码更加可读与可维护,同时合理的设计能够有效应对需求变化,模块化的单元测试又确保了产品的可靠性,顾全的交付流程最后提升了项目质量。
希望这些感悟更多程序员提供参考,帮助大家在编程的道路上不断进步。

1. 清晰的命名

● 原则:代码应该易于阅读和理解;例如:变量、函数和类的名称应能清楚表达其意图;

● 示例:

// 明确表示学生数量
int numberOfStudents = 30; 

/**
 * 计算圆面积
 * @param radius 半径
 * @return 面积
 */
public double calculateAreaOfCircle(double radius) {
    return Math.PI * radius * radius;
}

2. 使用注释

● 原则:在复杂或重要的代码段添加注释,帮助他人理解;

● 示例:

/**
 * 计算给定列表的平均值
 *
 * @param numbers 要计算的数字列表
 * @return 返回数字的平均值,如果列表为空则返回0
 */
public static double calculateAverage(List<Double> numbers) {
    if (numbers == null || numbers.isEmpty()) {
        return 0;
    }

    double sum = 0.0; // 用于保存数字的总和
    int count = 0; // 用于记录有效数字的数量

    // 遍历列表中的每个数字并计算总和
    //【注意】:检查列表中的每个元素是否为 null,需要过滤
    for (Double num : numbers) {
        if (num != null) {
            sum += num; 
            count++;
        }
    }

    if (count == 0) {
        return 0;
    }
    double average = sum / count;
    return average;
}

3. 一致的编码风格

● 原则:遵循团队的编码标准,保持代码风格一致;

● 示例:使用统一的缩进和大括号位置。例如 IDEA 等 IDE 中配置统一的 CodeStyle:Alibaba-CodeStyle 、Google-CodeStyle 等;

4. 代码模块化

● 原则:将功能分解成小模块,增加重用性;

● 示例:

public class Calculator {
    /**
     * 加
     * @param a
     * @param b
     * @return
     */
    public int add(int a, int b) {
        return a + b;
    }

    /**
     * 减
     * @param a
     * @param b
     * @return
     */
    public int subtract(int a, int b) {
        return a - b;
    }
}

5. 避免重复代码

● 原则:遵循DRY原则(Don’t Repeat Yourself);

● 示例:

//不好的实践:重复
public class Calculator {
    public void addAndPrint(int a, int b) {
        int result = a + b;
        System.out.println("Result: " + result);
    }

    public void addAndPrintAnother(int x, int y) {
        int result = x + y;
        System.out.println("Result: " + result);
    }
}

//好的实践:我们可以提取出一个公共方法来遵循DRY原则:
public class Calculator {

    public void addAndPrint(int a, int b) {
        printResult(add(a, b));
    }

    public int add(int a, int b) {
        return a + b;
    }

    private void printResult(int result) {
        System.out.println("Result: " + result);
    }
}

6. 依赖接口而不是具体的实现

● 原则:依赖接口而不是具体的实现,增强灵活性;

● 示例:


public interface Shape {
    double area();
}

public class Circle implements Shape {
    private double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

public class Square implements Shape {
    private double sideLength;

    public Square(double sideLength) {
        this.sideLength = sideLength;
    }

    @Override
    public double area() {
        return sideLength * sideLength;
    }
}

//依赖接口而不是具体的实现
void printf(Shape shape);

7. 避免魔法数字

● 原则:使用常量代替魔法数字;

● 示例:


final double FIXED_RATE = 3
double area = FIXED_NO * radius

8. 简化条件语句

● 原则:避免复杂的条件逻辑。用快速 return 来减少 if 嵌套层次;

● 示例:


//不推荐:嵌套太深
public void checkUser(User user) {
    if (user != null) {
        if (user.getAge() > 18) {
            if (user.isActive()) {
                // 允许访问
                System.out.println("Access granted");
            } else {
                System.out.println("User is not active");
            }
        } else {
            System.out.println("User is underage");
        }
    } else {
        System.out.println("User is null");
    }
}

//推荐:快速失败返回
public void checkUser(User user) {
    if (user == null) {
        System.out.println("User is null");
        return;
    }
    
    if (user.getAge() <= 18) {
        System.out.println("User is underage");
        return;
    }

    if (!user.isActive()) {
        System.out.println("User is not active");
        return;
    }

    // 允许访问
    System.out.println("Access granted");
}

9. 异常处理

● 原则:通过适当的异常处理提高程序的健壮性;

● 示例:


//异常
try {
    int result = 10 / 0;
} catch (ArithmeticException e) {
    log.error("非法参数,不能被零除");
}

//熔断
try (Entry entry = SphU.entry("resourceName")) {  
    // 你的业务逻辑  
} catch (BlockException ex) {  
    // 处理被阻止的请求  
}

10. 标准化错误日志处理

● 原则:统一错误处理的方式和日志记录。方便日志采集和告警配置;

● 示例:


public void logError(String message) {
    log.error("ERROR|Trace:{0}|Msg:{1} " Context.getTrace(), message);
}

11. 方法参数不宜过长

● 原则:方法参数应尽量少,避免混乱,超过3个推荐封装成模型;

● 示例:


//不推荐
void createUser(String name,int age,String email);


//推荐
public class UserService {
    public void createUser(User user) {
        
    }
}

class User {
    private String name;
    private int age;
    private String email;

    public User(String name, int age, String email) {
        this.name = name;
        this.age = age;
        this.email = email;
    }

    // Getters and Setters
}

12. 使用现有工具类来简化操作

● 原则:优先使用现有工具类 如
apache.commons
来简化操作

● 示例:


StringUtils.isNotEmpty("");
CollectionUtils.isNotEmpty()

13. 尽量使用不变的变量

● 原则:使用
final
关键字声明不可变的变量,提高代码的可靠性;

● 示例:


final int MAX_VALUE = 100;
ImmutableList.of();

14. 测试驱动开发(TDD)

● 原则:先写测试,再写代码,确保代码的可测试性;

● 示例:


@Test
public void testAdd() {
    Calculator calculator = new Calculator();
    assertEquals(5, calculator.add(2, 3));
}

15. 避免过度优化

● 原则:优先考虑代码的可读性,优化通常是在识别出性能问题后进行的;

16. 使用版本控制

● 原则:使用版本控制工具(git)管理代码变化;

17. 重视系分文档

● 原则:

○ 开发前,考虑清楚为什么要做这个需求。从背景及现状分析->为什么要做(why)->要做什么(what)->如何去做(how) 体系化思考;

○ 再从业务用例分析->系统依赖分析->领域模型分析->架构设计分析->时序图分析等落地最终的系分;

18. 重视代码评审

● 原则:定期进行代码评审,提高代码质量,提高团队研发意识;

19. 重视每一次交付

● 原则:

○ 事前锁定资源,上下游达成一致,明确里程碑计划;

○ 事中按需推进,每周项目进度同步,及时通晒风险;

○ 事后组织复盘以及关注业务数据(关注价值)

20.重视交付质量

● 原则:新功能需多考虑灰度验证

○ 后端服务:可按分组进行灰度验证(gray 分组->default 分组)

○ 客户端:小范围升级验证无问题后,逐步放量升级;

写在最后

欢迎关注我的公众号:编程启示录,第一时间获取最新消息;

微信 公众号
image image

上一篇:《人工智能——自然语言处理简介》

序言:
人工智能大语言模型(LLM)如何理解人类的自然语言?这个过程的核心在于将文本转化为计算机能处理的数值形式,经过计算,最终达到对语言的理解。起初,我们只是简单的随便用一个数字来表示一个单词或一个词根,但随着研究深入,我们发现,不同的数值表达方式能显著提高模型对语言的理解效果。因此,在构建大语言模型(LLM)时,关键的一步是将人类语言转化成合适的数值表示,以便模型能够接收、处理并生成有效的输出。好了,让我们进入正题。

与真实数据源协同工作

现在你已经了解了获取句子、用词索引进行编码以及对结果进行序列化的基本知识,你可以通过使用一些著名的公共数据集,将其通过工具转换为易于序列化的格式来进一步提升技能。我们将从TensorFlow Datasets中的IMDb数据集开始,它的大部分处理工作已经为你完成了。之后,我们将亲自动手处理一个基于JSON的数据集以及几个包含情感数据的逗号分隔值(CSV)数据集!

从TensorFlow Datasets获取文本

我们在第4章探索过TFDS,因此如果你对本节中的某些概念不熟悉,可以回顾一下。TFDS的目标是尽可能简单地以标准化方式访问数据。它提供了多个基于文本的数据集,我们将探索imdb_reviews,这是一个包含5万条电影评论的IMDb数据集,每条评论的情感被标注为正面或负面。

下面的代码将加载IMDb数据集的训练集并逐项迭代,将包含评论的文本字段添加到名为imdb_sentences的列表中。每条评论由文本和表示情感的标签组成。注意,将tfds.load调用包裹在tfds.as_numpy中,确保数据以字符串而不是张量的形式加载:

imdb_sentences = []

train_data = tfds.as_numpy(tfds.load('imdb_reviews', split="train"))

for item in train_data:

imdb_sentences.append(str(item['text']))

获得句子后,可以像之前一样创建一个分词器并对它们进行拟合,还可以创建一组序列:

tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=5000)

tokenizer.fit_on_texts(imdb_sentences)

sequences = tokenizer.texts_to_sequences(imdb_sentences)

你还可以打印出你的词索引以查看:

print(tokenizer.word_index)

词索引过大无法全部显示,但以下是前20个词。注意,分词器按数据集中词的频率排列,因此“the”、“and”和“a”等常用词被索引:

{'the': 1, 'and': 2, 'a': 3, 'of': 4, 'to': 5, 'is': 6, 'br': 7, 'in': 8, 'it': 9, 'i': 10, 'this': 11, 'that': 12, 'was': 13, 'as': 14, 'for': 15, 'with': 16, 'movie': 17, 'but': 18, 'film': 19, "'s": 20, ...}

这些是停用词,如上一节所述。由于这些词出现频率最高且缺乏独特性,它们的存在会影响训练准确性。

此外,注意“br”也在列表中,因为它在该语料库中常用作HTML标签

你可以更新代码,使用BeautifulSoup移除HTML标签,添加字符串转换以移除标点符号,并从给定列表中删除停用词,如下所示:

from bs4 import BeautifulSoup

import string

stopwords = ["a", ..., "yourselves"]

table = str.maketrans('', '', string.punctuation)

imdb_sentences = []

train_data = tfds.as_numpy(tfds.load('imdb_reviews', split="train"))

for item in train_data:

sentence = str(item['text'].decode('UTF-8').lower())

soup = BeautifulSoup(sentence)

sentence = soup.get_text()

words = sentence.split()

filtered_sentence = ""

for word in words:

word = word.translate(table)

if word not in stopwords:

filtered_sentence = filtered_sentence + word + " "

imdb_sentences.append(filtered_sentence)

tokenizer = tf.keras.preprocessing.text.Tokenizer(num_words=25000)

tokenizer.fit_on_texts(imdb_sentences)

sequences = tokenizer.texts_to_sequences(imdb_sentences)

print(tokenizer.word_index)

注意,在处理之前将句子转换为小写,因为所有的停用词都存储为小写。现在打印出的词索引如下所示

{'movie': 1, 'film': 2, 'not': 3, 'one': 4, 'like': 5, 'just': 6, 'good': 7, 'even': 8, 'no': 9, 'time': 10, 'really': 11, 'story': 12, 'see': 13, 'can': 14, 'much': 15, ...}

可以看到现在比之前干净了许多。不过,仍有改进空间,我注意到在查看完整索引时,一些不常见的词在末尾显得无意义。评论者经常将词组合在一起,比如用连字符(“annoying-conclusion”)或斜杠(“him/her”),移除标点会错误地将这些词合并为一个词。

你可以添加代码,在句子创建后立即在这些字符周围添加空格

sentence = sentence.replace(",", " , ")

sentence = sentence.replace(".", " . ")

sentence = sentence.replace("-", " - ")

sentence = sentence.replace("/", " / ")

这样,类似“him/her”这样的组合词会被转换为“him / her”,然后/被去掉,分词后会成为两个词。这样可能会带来更好的训练效果。

现在你已经有了语料库的分词器,可以对句子进行编码。例如,前面章节的简单句子会变成这样

sentences = [

'Today is a sunny day',

'Today is a rainy day',

'Is it sunny today?'

]

sequences = tokenizer.texts_to_sequences(sentences)

print(sequences)

结果为:

[[516, 5229, 147], [516, 6489, 147], [5229, 516]]

如果解码,可以看到停用词已被删除,句子被编码为“today sunny day”、“today rainy day”和“sunny today”。

如果想在代码中解码,可以创建一个新字典,将键和值反转(即词索引中的键值对互换)并进行查找。代码如下:

reverse_word_index = dict(

[(value, key) for (key, value) in tokenizer.word_index.items()])

decoded_review = ' '.join([reverse_word_index.get(i, '?') for i in sequences[0]])

print(decoded_review)

这将输出:

today sunny day

使用IMDb子词数据集

TFDS还包含几个使用子词预处理的IMDb数据集。在这里,你不需要按词分割句子,它们已经按子词进行分割。使用子词是一种在按字母(少量低语义令牌)和按词(大量高语义令牌)之间的折中方法,通常可以非常有效地训练语言分类器。这些数据集还包含用于分割和编码语料库的编码器和解码器。

要访问它们,可以调用tfds.load并传入imdb_reviews/subwords8k或imdb_reviews/subwords32k,例如

(train_data, test_data), info = tfds.load(

'imdb_reviews/subwords8k',

split=(tfds.Split.TRAIN, tfds.Split.TEST),

as_supervised=True,

with_info=True

)

可以通过info对象访问编码器,这将帮助查看词汇量大小

encoder = info.features['text'].encoder

print('Vocabulary size: {}'.format(encoder.vocab_size))

输出8185,因为在此实例中词汇量由8,185个令牌组成。若想查看子词列表,可以使用encoder.subwords属性获取:

print(encoder.subwords)

输出类似以下内容

['the_', ', ', '. ', 'a_', 'and_', 'of_', 'to_', 's_', 'is_', 'br', 'in_', 'I_', 'that_', ...]

这里可以注意到,停用词、标点和语法在语料库中都有,还有像
这样的HTML标签。空格用下划线表示,因此第一个令牌是“the”。

若想编码字符串,可以使用编码器

sample_string = 'Today is a sunny day'

encoded_string = encoder.encode(sample_string)

print('Encoded string is {}'.format(encoded_string))

输出将是令牌列表

Encoded string is [6427, 4869, 9, 4, 2365, 1361, 606]

你的五个词被编码为七个令牌。查看令牌,可以用编码器的subwords属性返回一个数组。它是从零开始的,例如“Today”中的“Tod”编码为6427,是数组中的第6426项:

print(encoder.subwords[6426])

输出:

Tod

若要解码,可以使用编码器的decode方法:

encoded_string = encoder.encode(sample_string)

original_string = encoder.decode(encoded_string)

test_string = encoder.decode([6427, 4869, 9, 4, 2365, 1361, 606])

后面的代码行将产生相同的结果,因为encoded_string尽管名字如此,其实是一个和下一行硬编码的列表相同的令牌列表。

本节总结:
本节主要介绍了如何将文本表达转化为计算机可理解的数字表达形式。 具体来说,就是通过TensorFlow Datasets对文本进行预处理,包括分词、去停用词等步骤,最终将文本转换为数字序列,为后续的自然语言处理任务做好准备;下一篇是这一篇的补充,主要讲述如何从CSV和JSON文件中提取文本用于训练模型。

序列化(Serialization)

反序列化(Deserialization)
是计算机科学中用于数据存储和传输的两种基本操作。

  1. 序列化

    • 序列化是将对象的状态信息转换为可以存储或传输的形式的过程。简单来说,就是将对象转换为字节序列(比如JSON、XML等格式)。
    • 目的:使得对象可以在网络上进行传输,或者存储到文件、数据库中。
    • 例子:将一个Java对象转换为JSON字符串,以便通过网络发送到另一个系统。
  2. 反序列化

    • 反序列化是序列化的逆过程,即从存储或传输的形式中恢复对象的状态信息,重新构建对象。
    • 目的:从文件、数据库或网络接收到的数据中恢复出原来的对象。
    • 例子:从一个JSON字符串中解析出数据,并根据这些数据创建一个新的Java对象。
      序列化和反序列化的应用场景
  • 网络传输
    :在网络通信中,对象需要在不同的服务间传输,序列化可以将对象转换为字节流,通过网络发送,接收方再通过反序列化恢复为对象。
  • 数据存储
    :将对象序列化后存储到文件或数据库中,以便后续读取和使用。
  • 分布式系统
    :在分布式系统中,对象可能需要在不同的节点间传递,序列化和反序列化是实现这一功能的关键技术。
  • 远程方法调用(RPC)
    :在RPC中,对象作为方法参数或返回值需要在不同的地址空间间传递,也需要经过序列化和反序列化。
    注意
    :序列化和反序列化时,需要确保双方使用相同的格式和规则,否则可能导致数据错误或无法解析。此外,序列化和反序列化过程中还可能涉及到安全问题,如序列化漏洞等,需要特别注意。

蓝图

image-20241108094408670

数据库自己管理磁盘数据和缓冲区,而不是通过操作系统管理(
Os is not your friend.
)。

数据存储

三层视图

数据库以页(page)为存储数据的基本单位,文件(file)是一系列页的集合,页中存储页数据(data),形成
文件-页-数据
三层架构。

文件有不同的组织形式,页包含页头和页数据,页数据可以采用不同方式组织:元组,日志,索引。

黄色部分为课程会提及的内容。

image-20241108102336292

采用Heapfile进行文件存储时的执行图:

image-20241108093210314

  • 页目录:存储管理的页的元信息(空闲页,空页)
  • 页头:存储页的元信息(页大小,校验和,数据库版本,事务可见性,压缩元数据)

面向元组的数据存储

image-20241108102828520

  • 通过<FileId, PageId, Slot>定位到一个指向tuple的指针(磁盘地址),然后找到tuple。

  • slot指针的灵活性:内部元组位置变化时,外部无感知;指针可以指向其他页,可以存储大数据(文件,大文本);支持变长记录。

  • 数据库会为每个元组分配一个数据记录的唯一标识(record identifier),来表示元组的
    物理位置
    。SQLite和Oracle中为ROWID,Pg中是CTID,<PageId, Slot>。但是他们对于应用程序是无用的。

image-20241108103947481

  • Header包含:可见性信息;NULL Bit Map。

  • Data包含:行数据。

Tuple只是一个字符串(char[]),本身不存储类型信息,类型信息存在数据库的
System Catalogs
中。(为了保证数据紧凑;非自解释的)

存数据时会遇到的问题:

  • 数据对齐:填充,重排序

image-20241108123458670

  • 精确值问题:BIGDECIMAL(转为字符串存储)

image-20241108123607713

  • 空值:Bit Map;特殊值

  • 大值和文件:Overflow Page和External File。

    大值采用溢出页;大文件可以采用溢出页,也可以用外部文件系统存储,然后存储一个指向文件路径的指针,而不是直接存储文件内容(Oracle:BFILE, Microsoft: FILESTREAM)。

image-20241108123852928

image-20241108123909572

日志结构存储

基本概念:

  • 利写不利读,非原地更新:只有PUT和DELETE操作,顺序IO。查询时由最新到最老时查询日志。
  • 加速查询:索引。

image-20241108132439250

  • 加速查询:日志压缩,且压缩时会排序日志。
  • 压缩方式:层级压缩,统一压缩

image-20241108133449643

特点 Level Compaction Universal Compaction
层级结构 有多层级,L0、L1、L2 等 无层级结构,所有文件在同一级别
文件组织方式 每个层级内文件不重叠,跨层逐渐下推 基于文件大小和数量合并,文件可能有重叠
合并策略 层级压缩,按顺序下推合并 文件数量和大小超过阈值时触发合并
写放大 较高,因为需要不断下推文件至更低层级 较低,因为减少频繁合并
读放大 较低,因为相同键在每层只存在一次 较高,因为没有严格层级,需检查多个文件
适用场景 读多写少的场景 写多读少、实时数据的高写入场景

索引组织存储

直接用索引组织数据,数据挂在叶子结点上,Page内部的tuple有序。

SQLite和MySQL默认用这种方式组织数据,Oracle和SQL Server可选。

image-20241108133519165

和基于元组的存储对比:

特性 Index-Organized Storage Tuple-Oriented Storage
数据与索引存储 数据存储在主键索引结构中 数据和索引独立存储
数据排序 数据按照主键顺序排序 数据无序存储
主键查询性能 高效,因数据已按主键排序 依赖主键索引,但数据本身无序
插入和更新性能 插入和更新时可能需要索引重排,较慢 插入和更新较快,无需主键排序
适用场景 主键查询频繁,数据顺序性强的场景 多种查询模式,插入和更新频繁的场景

数据模型

image-20241108160451120

  1. N-ary Storage Model (NSM)
  2. Decomposition Storage Model (DSM)
  3. Hybrid Storage Model (PAX)

NSM

优点:操作一条完整记录时快速。

缺点:操作一批记录的某一个特定列的时候,非顺序读取,会有无效IO,且数据不好做压缩。

image-20241108160903173

DSM

优点:可以取到一批特定列;可以做数据压缩。

缺点:在操作完整记录的时候,需要分解查询,得到结果后还需要再进行合并。

image-20241108161201607

PAX

结合了NSM和DSM,既能一次处理一个完整记录,也能在读取一系列特定的属性时顺序读取并避免过多的无用IO。

image-20241108161918379

数据压缩方式

目标:

  1. 压缩结果为定长值(存储定长数据)。

  2. 仅在需要时解压缩,否则都采用压缩形式处理数据。

  3. 必须是无损的。

常用的压缩方式:字典压缩。

维护字典映射,数据表中存储映射值。结果定长,且可以支持范围查询。

image-20241108162715705

image-20241108162731540

字典映射的实现方式:数组。【哈希表不支持范围查询,B+树内存消耗大】

image-20241108163114414

Elf(可执行和可链接文件)是一个永远也绕不开的话题,只要我们还在使用安卓手机/linux服务器,我们就需要了解elf的一些方方面面,现在就让我们从一个常量值提取的小需求出发,逐步解析elf文件结构吧!

一、写作目的:

网络上关于elf文件结构描述的文章不在少数,但能具体到二进制分析的却屈指可数,总给人一种八股文的感觉,而最近恰好又遇到了一个需要通过符号表获取其表示的常量值的需求,在完成之后,我将实现的过程进行总结提炼写下这么一篇elf结构入门的文章供后续学习回顾。

二、需求:

在C++中存在许多常量赋值和使用的操作,现在我们获取到了一个由C++编译成的动态链接库.so文件,我们想要反推一下其中可能的符号及其所表示的值。

三、基础知识

①.Elf文件类型:

Elf文件类型分为三种,.o\.so\.exe,普通的.exe可执行文件相信大家并不陌生,这里主要介绍一下.o和.so文件。.o为可重定位的目标文件,.so为共享目标文件,两者的区别就是.o是静态的,.so是动态的,静态就是指它将被链接器在编译时合并到可执行文件中,而动态则是在可执行文件要使用它时才进行加载。除了用途不同,其文件结构和文件结构中各种数据类型都是相同的,elf文件中数据类型大致为图中几类有符号无符号1/2/4大小的地址偏移整数等(我们解析时只需记住char是1字节half是2字节其余4字节即可)。

②.Elf文件链接视图和执行视图

elf的文件结构就稍微复杂一些,其分为链接视图和执行视图两种视角,之所以叫视角,是因为如同人看待一个物体的不同角度,虽然看上去不一样,但本质都是同一个物体。这里的链接视图即指以链接器的角度来看elf文件,它关注的是elf文件的节区,即用头部节取表去定位各个节区然后进行链接,而执行视图则是以程序执行的角度来看elf文件,它关注的是如何使用程序头部表去定位各段然后加载到内存中去。其两种视图的对应关系也如上图所示,.text节对应代码段,.rodata/.data/.bbs等包含数据的节对应数据段,其余还有一些专门链接用的如动态符号表等,则不会被加载到内存中。

四、实现

在了解了上述的一些基础知识后,我们也知道要获取符号及其对应值,我们不能从执行视图出发,因为符号表可能都不会被程序头部表识别到,所以我们从链接视图出发,根据头部节区表定位数据节区和符号表节区,根据其索引关系完成匹配,具体实现过程如下所示。

接着,让我们一步一步梳理

①解析elf文件头部:

首先贴出elf文件头的结构定义

想必精通C/C++的各位大佬一定是一眼秒懂的,这里就不过多解释构造了,其ELF32的数据类型具体表示含义在上面已有展示,这里也不多说。这里关键数据有以下几点:

e_ident:十六字节数组

首先就是魔数了,看文件先看魔数,这里的16位比特的e_ident的前4位数据只能是0x7F454C46,转换成ascii码即0x7F ELF。然后依次表示进制(1为32位/2为64位)、大小端(1为LSB/2为MSB)、版本信息(1为当前版本)、运行所在系统(0为UNIX/3为Linux...)、操作系统ABI、7位填充数据。

e_type:两字节目标文件类型

1表示可重定位文件、2表示可执行文件、3表示共享对象文件、4表示核心转储文件

依照这个规律解读上图所示例子,即这是一个32位/LSB/当前版本/运行在UNIX上的.so文件。

在确定了文件类型之后,我们便可以依照上述的流程接着往下解析...

节区头部表格偏移、表项大小、表项数

根据elf头部结构我们可以轻松知道上面我们要的信息

e_shoff(32-35)\e_shentsize(46-47)\e_shnum(48-49)

该测试文件头部节区表偏移为2896(LSB)、表项大小为40、表项数为22

节区头部表名称字符串表索引

节区头部表中每一个表项所需使用的名称字符串,对应的字符串表,这个在解析节区头部表项时会使用到,此处为21(0X15)

验证

我们知道从链接视图来看,elf文件头部节区表结束后文件也就读取完了,故我们2896+40*22应该就是文件大小3776了(果真如此,看来上述分析工作全对)

②获取头部节区表:

在elf头部中,我们已经获取了头部节区表偏移、表项大小、表项数,现在我们就可以根据头部表项依次读取节区了,节区表表项结构如下图所示

其中我们需要关注的有以下几点:

表项序号(index)、节区名(sh_name)、节区类型(sh_type)、节区偏移(sh_offset)、节区长度(sh_size)、附加信息(sh_info)

表项序号:

加载时通过计数获得

节区偏移:

表项内第17-20个字节,表示文件内节区数据偏移

节区长度:

表项内第21-24个字节,表示文件内节区数据偏移

节区名:

节点区名为在对应(文件头部的字符串表索引)字符串表中的索引,再以\0结尾取得一个字符串。

节区类型:

位于单个表项的第5-8位比特,表示节区用途,常见的有:

SHT_PROGBITS(0x1):包含程序定义的数据,如代码、只读数据、可读写数据等。

SHT_SYMTAB(0x2):包含符号表信息,用于链接或调试。

SHT_STRTAB(0x3):包含字符串表,通常用于表示符号表或节区表中的名字。

...

SHT_DYNSYM(11):包含动态链接符号表,用于运行时的符号解析。

...

③获取节区字符串表

而上文文件头部中我们已经得到节区使用的字符串表项的索引为15,而节区表偏移为2896、表项大小为40,所以该字符串表表项的偏移为2896 + 21*40 = 3736

从字符串表的节区表项中我们可以得到其实际字符串表的偏移为2691(0xA83),长度为202(CA)

而一个表项的节区名即表项内第1-4个字节,为对应字符串表的内部索引,字符串表的节区名索引为178(0XB2),再根据\0结尾断句,即头部节区表对应字符串表名字为.shstrtab

(通过节区头部表项对应的字符串表.shstrtab我们也能够大致知道该elf文件中的成分信息了--如是否包含某些特定节区)

上述字符串表第5-8位为0x03000000,即表示它包含字符串表(其他节区也可能包含字符串表,但用法就不尽相同了)...

④获取符号表

(从上述节区名字符串表中我们可以得知存在动态符号表.dynsym,不存在静态符号表.symtab,所以在遍历节区表项的时候,我们不仅可以通过名称字符串”.dynsym”也可以通过节区类型11/0x0B来定位动态符号表项)

根据符号表节区表项的信息我们可以知道符号表存放的具体位置及单个项目大小

sh_addr(节区在内存中位置):第13-16个字节,值为524(0x0C020000)

sh_offset(节区数据文件中偏移):第17-20个字节,值为524(0x0C020000)

sh_size(节区长度):第21-24个字节,值为304(0x30010000)

sh_link(节区头部表索引):第25-28个字节,值为7

sh_entsize(节区中单个项目大小):第37-40个字节,值为16(0x10000000)

在上述符号表中,实际存储19个符号结构体(304 / 16)

单个符号项如上图所示,其中有这么几个值

符号名称:

st_name,第1-4个字节,为符号表中sh_link指向的字符串表中的索引,同样通过索引+\0结尾的方式获取该符号名称字符串。

符号值:

st_value,第5-8个字节,根据具体情况取得含义,例如符号表示函数时,该值为函数在内存中的起始地址,若该符号表示全局或静态变量时,表示内存在变量中的位置。

符号值值大小:

st_size,第9-12个字节,变量长度或者函数代码所占字节数

符号类型:

st_info,第13个字节,根据1个字节的八位比特作为flag标注符号的特征,高4位表示绑定属性(Binding),低4位表示符号类型(Type)

Type:

STT_OBJECT(1):数据对象,通常是变量

STT_FUNC(2):函数或其他可执行代码

...

Binding:

STB_LOCAL(0):局部符号,只在当前模块中可见

STB_GLOBAL(1):全局符号,在所有模块中可见

...

节区头部索引:

st_shndx,第15-16个字节,根据具体情况取得含义

⑤获取符号名称字符串表

首先我们要获取符号名,符号名即变量名/函数名...

根据节区头部符号表项中的sh_link值7,我们可以计算出对应字符串表的起始地址

2896+40*7

(根据节区表项中的节区类型为3,我们也可以笃定该节区就是我们要找的字符串节区)

读取节区信息:

名称:93(0x5D),加上名称符号表偏移2691,得到该名称字符串.dynstr

偏移:1180(0x9C04)

大小:341(0x0155)

以下即符号名字字符串表

⑥遍历符号

获取符号类型

遍历符号表(与4中图重复)每一个符号(4中已简述每个符号结构),获取其符号名和符号类型,st_info的低四位为1,则符号为OBJECT变量,若4size则可能为字符串指针

如这六个符号,其符号值大小为4,st_info(0x11)为00010001即全局的数据对象

获取符号值节区

在根据st_shndx值18(0x12),定位到符号值存储节区头部表项偏移2896 + 18*40 = 3616

名称值为77+2691即.ARM.attributes

内存中地址为14620(0x1C39)

文件偏移为2332(0x1C09)

节区长度为24(0x18)

获取符号值和对应常量

再结合上述六个符号(符号名对应字符串表已在上文给出),我们可以得到以下信息

(ad_value为通过计算st_value与上述内存偏移14620获得的符号变量值-地址)(注意:此处地址值仍需LSB转换)

(value为通过地址值偏移获取的变量对应的常量值)

st_name-1: 1426 = 1180 + 246(0xF6) -> “global_var2”

st_value-1: 14628(0x2439)

ad_value-1: 1831(0x2707)

Value: ”测”

st_name-2: 1385 = 1180 + 205(0xCD) -> “a”

st_value-2: 14636(0x2C39)

ad_value-2: 0xFFFFFF7F

Value-2: 0x7FFFFFFF(INT_MAX)

st_name-3: 1438 = 1180 + 258(0x0201) -> “global_var3”

st_value-3: 14632(0x2839)

ad_value-3: 1743(0xCF06)

Value: ”abc”

st_name-4: 1401 = 1180 + 221(0xDD) -> “b”

st_value-4: 14640(0x3039)

ad_value-4: 0xFFFFFF7F

Value-4: 0x7FFFFFFF(INT_MAX)

st_name-5: 1403 = 1180 + 223(0xDF) -> “global_var”

st_value-5: 14620(0x1C39)

ad_value-5: 1747(0xD306)

Value: ”doGlobalVarTest测试”

st_name-6: 1414 = 1180 + 234(0xEA) -> “global_var1”

st_value-6: 14624(0x2039)

ad_value-6: 1652(0x7406)

Value: “测aaa”

四、总结

通过上述步骤,我们依托定位符号常量的需求,逐步分析了elf的文件架构。并根据以下测试程序我们实验了有哪些数据会在编译成.so文件后保留符号(全局非静态变量),以及如何获取其变量值