桌面应用的开发人员通常很少关注硬件架构。 首先,它们通常使用高级编程语言,以牺牲性能为代价隐藏这些复杂性。 其次,在大多数情况下,他们的代码运行在 x86 架构上,他们通常认为 x86 架构的特性是理所当然的。 例如,它们可能假设 int 的大小为 32 位,但在许多情况下并非如此。
嵌入式开发人员需要处理更多种类的体系结构。 即使他们不是用目标平台原生的汇编语言编写代码,他们也应该知道所有的 C 和 C++ 基础类型都是依赖于体系结构的;标准只保证 int 至少是 16 位的。 他们还应该了解特定体系结构的特点,如字符顺序和对齐方式,并考虑到使用浮点或 64 位数字执行的操作在 x86 体系结构上相对便宜,在其他体系结构上可能要昂贵得多。
由于他们的目标是从嵌入式硬件获得最大可能的性能,因此他们应该了解如何在内存中组织数据,以便最有效地利用 CPU 缓存和操作系统分页机制。
在本章中,我们将介绍以下主题:
- 探索固定宽度整数类型
- 使用
size_t
类型 - 检测平台的字节顺序
- 转换字符顺序
- 使用数据对齐
- 使用填充结构
- 将数据与高速缓存线对齐
通过了解这些主题,我们将了解如何针对目标平台定制代码,以实现最高性能和可移植性。
C 和 C++ 开发人员经常忘记,基本数据类型(如 char、Short 和 int)的大小取决于体系结构。 同时,大多数硬件外设定义了关于用于数据交换的字段大小的具体要求。 为了使使用外部硬件或通信协议的代码可移植,嵌入式开发人员使用固定大小的整数类型,它显式指定数据字段的大小。
一些最常用的数据类型如下所示:
| 宽度 | 签名 | 无符号 |
| 8 位 | int8_t
| uint8_t
|
| 16 位 | int16_t
| uint16_t
|
| 32 位 | int32_t
| uint32_t
|
指针大小还取决于体系结构。 开发人员通常需要寻址数组的元素,由于数组在内部表示为指针,因此偏移量表示取决于指针的大小。 size_t
是一种特殊的数据类型,因为它以独立于体系结构的方式表示偏移量和数据大小。
在本食谱中,我们将学习如何在代码中使用固定大小的数据类型,使其可以跨体系结构移植。 这样,我们可以使我们的应用更快地与其他目标平台协同工作,并且只需更少的代码修改。
我们将创建一个模拟与外部设备进行数据交换的应用。 要执行此操作,请执行以下步骤:
-
在您的工作目录(即
~/test
)中,创建一个名为fixed_types
的子目录。 -
使用您喜欢的文本编辑器在
fixed_types
子目录中创建名为fixed_types.cpp
的文件。 将以下代码片段复制到fixed_types.cpp
文件中:
#include <iostream>
void SendDataToDevice(void* buffer, uint32_t size) {
// This is a stub function to send data pointer by
// buffer.
std::cout << "Sending data chunk of size " << size << std::endl;
}
int main() {
char buffer[] = "Hello, world!";
uint32_t size = sizeof(buffer);
SendDataToDevice(&size, sizeof(size));
SendDataToDevice(buffer, size);
return 0;
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(fixed_types)
add_executable(fixed_types fixed_types.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++ 11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的食谱来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行二进制文件,看看它是如何工作的。
运行二进制文件时,您将看到以下输出:
在这个简单的程序中,我们模拟与外部设备的通信。 因为我们没有真正的设备,所以SendDataToDevice
函数只打印它应该发送到目标设备的数据大小。
假设该设备可以对可变大小的数据块进行操作。 每个数据块都按其大小作为前缀,并编码为 32 位无符号整数。 这可以用以下几个方面来描述:
| ==同步,由 Elderman 更正==@ELDER_MAN | 有效载荷 | | 0-4 字节 | 5-N 字节,其中 N 为大小 |
在我们的代码中,我们将size
声明为uint32_t
:
uint32_t size = sizeof(buffer);
这意味着它在每个平台上都需要 32 位(16 位、32 位或 64 位)。
现在,我们将向设备发送大小:
SendDataToDevice(&size, sizeof(size));
SendDataToDevice
不发送实际数据;相反,它报告要发送的数据的大小。 如我们所见,大小为4
字节,与预期不谋而合:
Sending data chunk of size 4
假设我们声明int
数据类型,如下所示:
int size = sizeof(buffer);
在这种情况下,此代码只能在 32 位和 64 位系统上运行,并在 16 位系统上静默产生不正确的结果,因为这里的sizeof(int)
是 16。
我们在这个配方中实现的代码不是完全可移植的,因为它没有考虑 32 位字中的字节顺序。 这个顺序称为字节顺序,它的含义将在本章后面讨论。
指针大小还取决于体系结构。 开发人员通常需要寻址数组的元素,而且由于数组在内部表示为指针,因此偏移量表示取决于指针的大小。
例如,在 32 位系统中,指针为 32 位,与int
相同。 但是,在 64 位系统中,int
的大小仍然是 32 位,而指针是 64 位。
size_t
是一种特殊的数据类型,因为它以独立于体系结构的方式表示偏移量和数据大小。
在本食谱中,我们将学习如何在处理数组时使用size_t
。
我们将创建一个处理可变大小的数据缓冲区的应用。 如果需要,我们需要能够访问目标平台提供的任何内存地址。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建一个名为sizet
的子目录。 - 使用您喜欢的文本编辑器在
sizet
子目录中创建名为sizet.cpp
的文件。 将以下代码片段复制到sizet.cpp
文件中:
#include <iostream>
void StoreData(const char* buffer, size_t size) {
std::cout << "Store " << size << " bytes of data" << std::endl;
}
int main() {
char data[] = "Hello,\x1b\a\x03world!";
const char *buffer = data;
std::cout << "Size of buffer pointer is " << sizeof(buffer) << std::endl;
std::cout << "Size of int is " << sizeof(int) << std::endl;
std::cout << "Size of size_t is " << sizeof(size_t) << std::endl;
StoreData(data, sizeof(data));
return 0;
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(sizet)
add_executable(sizet sizet.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++ 11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的配方来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行
sizet
应用可执行文件。
在本例中,我们模拟了一个在文件或数据库中存储任意数据的函数。 该函数接受指向数据和数据大小的指针。 但是我们应该用什么类型来表示尺寸呢? 如果我们在 64 位系统中使用无符号整型,我们就人为地限制了函数只能处理最多 4 GB 数据的能力。
为避免此类限制,我们使用size_t
作为size
的数据类型:
void StoreData(const char* buffer, size_t size) {
大多数接受索引和大小的标准库 API 还处理size_t
参数。 例如,将数据块从源缓冲区复制到目标缓冲区的memcpy
C 函数声明如下:
void *memset(void *b, int c, size_t len);
运行前面的代码会产生以下输出:
正如我们所看到的,目标系统上的指针大小是 64 位,尽管int
的大小是 32 位。 在我们的程序中使用size_t
允许它使用嵌入式电路板的所有内存。
C++ 标准定义了std::size_t
类型。 它与普通 Csize_t
相同,只是它是在std
名称空间中定义的。 在您的 C++ 代码中最好使用std::size_t
,因为它是标准的一部分,但是std::size_t
和size_t
都是可以互换的。
字节顺序定义表示大数值的字节在内存中的存储顺序。
有两种类型的字符顺序:
-
Big-Endian:首先存储最高有效字节。 32 位值0x01020304存储在
ptr
地址, 具体如下:| 内存中的偏移量(字节) | 值 | | Ptr | 0x01 | | Ptr+1。 | 0x02 0x02 | | Ptr+2 | OX03 | | Ptr+3 | 0x04 |
大端架构的示例包括 AVR32 和摩托罗拉 68000。
-
Little-Endian:首先存储最低有效字节。 32 位值0x01020304存储在
ptr
地址, 具体如下:| 内存中的偏移量(字节) | 值 | | Ptr | 0x04 | | Ptr+1。 | 0x03 0x03 | | Ptr+2 | 0x02 0x02 | | Ptr+3 | 0x01 |
x86 架构是小端的。
在与其他系统交换数据时,特别需要注意字符顺序。 如果开发人员发送一个 32 位整数,比如 0x01020304,如果接收方的字符顺序与发送方的字符顺序不匹配,则可能会将其读取为 0x04030201。 这就是数据应该序列化的原因。
在本食谱中,我们将学习如何确定目标系统的字节顺序。
我们将创建一个简单的程序来检测目标平台的字节顺序。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建一个名为endianness
的子目录。 - 使用您喜欢的文本编辑器在 loop 子目录中创建一个名为
loop.cpp
的文件。 将以下代码片段复制到endianness.cpp
文件中:
#include <iostream>
int main() {
union {
uint32_t i;
uint8_t c[4];
} data;
data.i = 0x01020304;
if (data.c[0] == 0x01) {
std::cout << "Big-endian" << std::endl;
} else {
std::cout << "Little-endian" << std::endl;
}
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(endianness)
add_executable(endianness endianness.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++ 11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的配方来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行二进制文件。
在这个配方中,我们利用 C 的union
函数的功能将不同数据类型的表示映射到相同的内存空间。
我们定义了一个具有两个数据字段的联合-一个 8 位整数数组和一个 32 位整数数组。 这些数据字段共享相同的内存,因此在一个字段中所做的更改会自动反映在另一个字段中:
union {
uint32_t i;
uint8_t c[4];
} data
接下来,我们为 32 位整数字段分配一个精心编制的值,其中每个字节都是预先知道的,并且与其他字节不同。 我们使用值为 1、2、3 和 4 的字节组成目标值。
将该值分配给 32 位字段i
时,它会自动将所有字段重写为c
字节数组字段。 现在,我们可以读取数组的第一个元素,根据读取的内容,我们可以推断硬件平台的字节顺序。
如果值为 1,这意味着第一个字节包含最高有效字节,因此体系结构是大端的。 否则,它是小端的。 当我们运行二进制文件时,它会产生以下输出:
正如我们所看到的,程序检测到我们的系统是小端的。 此技术可用于检测运行时的字符顺序,并相应地调整应用逻辑。
如今,最广泛使用的平台,如 x86 和Acorn RISC Machine(ARM),都是小端的。 但是,您的代码不应该隐式假定系统的字节顺序。
如果您需要在同一系统上运行的应用之间交换数据,那么坚持使用目标平台的字节顺序是安全的。 但是,如果您的应用需要通过网络协议或公共数据存储与其他系统交换数据,请考虑将您的二进制数据转换为公共字符顺序。
基于文本的数据格式不存在字符顺序问题。 使用 JSON 格式表示与平台无关的、人类可读的数据。
Note: Converting from a binary representation and back can be costly for your target embedded platform.
虽然序列化库在幕后处理字符顺序,但在某些情况下,开发人员可能希望自己实现轻量级通信协议。
虽然 C++ 标准库不提供序列化函数,但开发人员可以利用这样一个事实,即在二进制网络协议中,字节顺序是定义的,并且始终是大端的。
标准库提供了一组函数,可用于在当前平台(硬件)和大端(网络)字节顺序之间进行转换:
uint32_t
htonl(uint32_t
value):将uint32_t
从硬件转换为网络订单uint32_t
ntohl(uint32_t
value):将uint32_t
从网络订单转换为硬件订单uint16_t
htons(uint16_t
值):将uint16_t
从硬件转换为网络订单uint16_t
ntohl(uint16_t
value):将uint16_t
从网络订单转换为硬件订单
开发人员可以使用这些函数在不同平台上运行的应用之间交换二进制数据。
在本指南中,我们将学习如何对字符串进行编码,以便它们可以在可能具有相同或不同字节顺序的两个系统之间进行交换。
在本食谱中,我们将创建两个应用:发送方和接收方。 发送方将为接收方写入数据,从而以独立于平台的方式对其进行编码。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建一个名为enconv
的子目录。 - 使用您喜欢的文本编辑器在
enconv
子目录中创建和编辑名为sender.cpp
的文件。 包括所需的头文件,如下所示:
#include <stdexcept>
#include <arpa/inet.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
- 然后,定义一个将数据写入文件描述符的函数:
void WriteData(int fd, const void* ptr, size_t size) {
size_t offset =0;
while (size) {
const char *buffer = (const char*)ptr + offset;
int written = write(fd, buffer, size);
if (written < 0) {
throw std::runtime_error("Can not write to file");
}
offset += written;
size -= written;
}
}
- 现在,我们需要定义一个格式化和写入消息的函数,以及调用它的主函数:
void WriteMessage(int fd, const char* str) {
uint32_t size = strlen(str);
uint32_t encoded_size = htonl(size);
WriteData(fd, &encoded_size, sizeof(encoded_size));
WriteData(fd, str, size);
}
int main(int argc, char** argv) {
int fd = open("envconv.data",
O_WRONLY|O_APPEND|O_CREAT, 0666);
for (int i = 1; i < argc; i++) {
WriteMessage(fd, argv[i]);
}
}
- 同样,使用相同的包含集创建名为
receiver.cpp
的文件:
#include <stdexcept>
#include <arpa/inet.h>
#include <fcntl.h>
#include <stdint.h>
#include <string.h>
#include <unistd.h>
- 添加以下代码,该代码从文件描述符中读取数据:
void ReadData(int fd, void* ptr, size_t size) {
size_t offset =0;
while (size) {
char *buffer = (char*)ptr + offset;
int received = read(fd, buffer, size);
if (received < 0) {
throw std::runtime_error("Can not read from file");
} else if (received == 0) {
throw std::runtime_error("No more data");
}
offset += received;
size -= received;
}
}
- 现在,定义一个将读取消息的函数,以及调用它的 Main 函数:
std::string ReadMessage(int fd) {
uint32_t encoded_size = 0;
ReadData(fd, &encoded_size, sizeof(encoded_size));
uint32_t size = ntohl(encoded_size);
auto data = std::make_unique<char[]>(size);
ReadData(fd, data.get(), size);
return std::string(data.get(), size);
}
int main(void) {
int fd = open("envconv.data", O_RDONLY, 0666);
while(true) {
try {
auto s = ReadMessage(fd);
std::cout << "Read: " << s << std::endl;
} catch(const std::runtime_error& e) {
std::cout << e.what() << std::endl;
break;
}
}
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(conv)
add_executable(sender sender.cpp)
add_executable(receiver receiver.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++ 14")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
-
构建应用,并将生成的两个可执行二进制文件
sender
和receiver
复制到目标系统。 使用第 2 章、设置环境中的食谱来实现此目的。 -
切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
-
运行
sender
二进制文件并传递两个命令行参数:Hello
和Worlds
。 这不会生成任何输出。 -
然后,打开接收器。
-
现在,检查用于数据交换的
sender
和receiver
的文件内容。 它将是二进制格式,因此我们需要使用xxd
工具将其转换为十六进制格式:
$ xxd envconv.data
0000000: 0000 0005 4865 6c6c 6f00 0000 0557 6f72 ....Hello....Wor
0000010: 6c64 ld
- 该文件包含两个字符串
hello
和world
,按其大小作为前缀。size
字段始终以大端字节顺序存储,与体系结构无关。 这允许发送方和接收方在具有不同字符顺序的两台不同计算机上运行。
在这个配方中,我们创建了两个二进制文件,分别是发送方和接收方,它们模拟两个主机之间的数据交换。 我们不能对它们的字节顺序做出任何假设,这就是为什么数据交换格式必须是明确的。
发送方和接收方交换大小可变的数据块。 我们将每个块编码为 4 字节整数,以便定义即将到来的块大小,后跟块内容。
虽然发送器不会在屏幕上生成任何输出,但它会将编码的数据块保存在文件中。 当我们运行接收器时,它能够读取、解码和显示发送器保存的任何信息,如以下屏幕截图所示:
虽然我们在本地保持平台格式的块大小,但在发送时需要将其转换为统一的表示形式。 我们使用htonl
函数来执行此操作:
uint32_t encoded_size = htonl(size);
此时,我们可以将编码大小写入输出流:
WriteData(fd, &encoded_size, sizeof(encoded_size));
该块的内容如下:
WriteData(fd, str, size);
接收器依次从输入流读取大小:
uint32_t encoded_size = 0;
ReadData(fd, &encoded_size, sizeof(encoded_size));
大小是编码的,在接收器使用ntohl
函数将其转换为平台表示之前无法直接使用:
uint32_t size = ntohl(encoded_size);
只有在这样做之后,它才会知道随后的块的大小,并且可以分配和读取它:
auto data = std::make_unique<char[]>(size);
ReadData(fd, data.get(), size);
由于序列化的data
大小始终表示为大端,因此读取函数不需要假设写入数据的平台的字节顺序。 它可以处理来自任何处理器架构的数据。
处理器读写数据不是以字节为单位,而是以内存字(与其数据地址大小匹配的区块)为单位。 32 位处理器使用 32 位字,64 位处理器使用 64 位字,依此类推。
当字对齐时,读写效率最高-数据地址是字大小的倍数。 例如,对于 32 位架构,地址 0x00000004 是对齐的,而 0x00000005 是未对齐的。 在 x86 平台上,访问未对齐的数据比访问对齐的数据慢。 然而,在 ARM 上,访问未对齐的数据会产生硬件异常并导致程序终止:
Compilers align data automatically. When it comes to structures, the result may be surprising for developers who are not aware of alignment.
struct {
uint8_t c;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
前面代码片段的输出是什么?sizeof(uint8_t)
是 1,而sizeof(uint32_t)
是 4。开发人员可能希望结构的大小是各个大小的总和;但是,结果在很大程度上取决于目标体系结构。
对于 x86,结果是8
。 让我们在i
之前再添加一个uint8_t
字段:
struct {
uint8_t c;
uint8_t cc;
uint32_t i;
} a = {1, 1};
std::cout << sizeof(a) << std::endl;
结果还是 8 分! 编译器通过添加填充字节,根据对齐规则优化数据字段在结构中的位置。 规则取决于体系结构,对于其他体系结构,结果可能会有所不同。 因此,在没有*序列化的情况下,不能在两个不同的系统之间直接交换结构,*将在第 8 章,通信和序列化中进行详细说明。
在本食谱中,我们将学习如何使用编译器隐式应用于对齐数据的规则来编写更高效的内存代码。
我们将创建一个分配结构数组的程序,并检查字段的顺序如何影响内存消耗。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建一个名为alignment
的子目录。 - 使用您喜欢的文本编辑器在 loop 子目录中创建一个名为
alignment.cpp
的文件。 添加所需的头部并定义两种数据类型,即Category
和ObjectMetadata1
:
#include <iostream>
enum class Category: uint8_t {
file, directory, socket
};
struct ObjectMetadata1 {
uint8_t access_flags;
uint32_t size;
uint32_t owner_id;
Category category;
};
- 现在,让我们定义另一个名为
ObjectMetadata2
的数据类型,以及使用所有这些数据类型的代码:
struct ObjectMetadata2 {
uint32_t size;
uint32_t owner_id;
uint8_t access_flags;
Category category;
};
int main() {
ObjectMetadata1 object_pool1[1000];
ObjectMetadata2 object_pool2[1000];
std::cout << "Poorly aligned:" << sizeof(object_pool1) << std::endl;
std::cout << "Well aligned:" << sizeof(object_pool2) << std::endl;
return 0;
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(alignment)
add_executable(alignment alignment.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "--std=c++ 11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的配方来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行二进制文件。
在我们的示例应用中,我们定义了两个数据结构ObjectMetadata1
和ObjectMetadata2
,它们将保存一些关于文件对象的元数据。 我们定义了四个表示对象的字段:
- 访问标志:表示文件访问类型的位组合,例如读、写或执行。 所有位字段都打包到单个
uint8_t
字段中。 - 大小:以 32 位无符号整数表示的对象大小。 它将支持的对象大小限制为 4 GB,但这足以证明正确数据对齐的重要性。
- 所有者 ID:在我们的系统中标识用户的 32 位整数。
- 类别:对象的类别。 这可以是文件、目录或套接字。 因为我们只定义了三个类别,所以
uint8_t
数据类型足以表示所有类别。 这就是我们使用enum
类声明它们的原因:
enum class Category: uint8_t {
ObjectMetadata1
和ObjectMetadata2
包含完全相同的字段;唯一的区别是它们在结构中的排序方式。
现在,我们声明两个对象池。 两个池都包含 1,000 个对象;object_pool1
以ObjectMetadata1
结构保存元数据,而object_pool2
使用ObjectMetadata2
结构。 现在,让我们检查应用的输出:
这两个对象池在功能和性能方面是相同的。 然而,如果我们检查它们占用了多少内存,我们可以看到一个显著的差异:object_pool1
比object_pool2
大 4KB。 考虑到object_pool2
的大小是 12KB,我们由于不注意数据对齐而浪费了 33%的内存。 在处理数据结构时要注意对齐和填充,因为不正确的字段排序可能会导致内存使用效率低下,就像object_pool2
的情况一样。 使用这些简单的规则来组织您的数据字段,以使它们正确对齐:
- 根据它们的大小对它们进行分组。
- 从最大数据类型到最小数据类型对组进行排序。
良好对齐的数据结构速度快、内存效率高,并且不需要实现任何额外的代码。
每个硬件平台都有自己的对齐要求,其中一些要求很棘手。 您可能需要参考目标平台编译器文档和最佳实践,以最大限度地利用硬件。 如果您的目标平台是 ARM,请考虑阅读http://infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.faqs/ka15414.html上有关调整预期的 ARM 技术文章。
虽然结构中数据字段的正确对齐可以产生更紧凑的数据表示形式,但请注意性能方面的影响。 将一起使用的数据保存在同一内存区域称为数据局部性,可以显著提高数据访问性能。 适合同一高速缓存线的数据元素可以比跨越高速缓存线边界的元素快得多地被读取或写入。 在许多情况下,更可取的做法是以额外的内存使用为代价来获得性能提升。 我们将在将数据与高速缓存线对齐菜谱中更详细地回顾这项技术。
在本食谱中,我们将学习如何定义数据成员之间没有任何填充字节的结构。 如果您的应用可以处理大量对象,这可能会显著减少应用使用的内存量。
不过,请注意,这是有代价的。 未对齐的内存访问速度较慢,从而导致性能不佳。 对于某些体系结构,禁止非对齐访问,因此需要 C++ 编译器生成比对齐访问多得多的代码来访问数据字段。
尽管打包结构可能会导致更高效的内存使用,但除非确实必要,否则请避免使用此技术。 它有太多隐含的限制,这些限制可能会在以后的应用中导致晦涩难懂的问题。
将打包结构视为传输编码,并且仅使用它们在应用外部存储、加载或交换数据。 但是,即使在这些情况下,使用适当的数据序列化也是更好的解决方案。
在这个简单的应用中,我们将定义一个压缩结构数组,看看这对它所需的内存量有何影响。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建alignment
子目录的副本。 将其命名为packed_alignment
。 - 通过将
__attribute__((packed))
添加到每个结构的定义来修改alignment.cpp
文件:
struct ObjectMetadata1 {
uint8_t access_flags;
uint32_t size;
uint32_t owner_id;
Category category;
} __attribute__((packed));
struct ObjectMetadata2 {
uint32_t size;
uint32_t owner_id;
uint8_t access_flags;
Category category;
} __attribute__((packed));
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的配方来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行二进制文件。
在此配方中,我们修改了使用数据对齐配方中的代码,为每个结构添加了一个压缩属性:
} __attribute__((packed));
此属性指示编译器不要向结构添加填充字节,以符合目标平台的对齐要求。
运行前面的代码会给出以下输出:
如果编译器不添加填充字节,则数据字段的顺序将变得无关紧要。 由于ObjectMetadata1
和ObjectMetadata2
结构具有完全相同的数据字段,因此它们的打包形式的大小变得相同。
GNU Compiler Collection
(GCC)使开发人员可以使用其属性对数据布局进行大量控制。 您可以转到GCC 类型属性页面,了解所有支持的属性及其含义。
其他编译器提供类似的功能,但它们的 API 可能不同。 例如,Microsoft 编译器定义了#pragma pack
编译器指令来声明压缩结构。 有关更多详细信息,请参阅Pragma Pack Reference页面。
在本食谱中,我们将学习如何将数据结构与高速缓存线对齐。 数据对齐会显著影响系统的性能,特别是在多核系统中工作的多线程应用的情况下。
首先,如果一起使用的数据位于同一缓存行中,则频繁访问这些数据的速度要快得多。 如果编程一致地先访问变量 A,然后访问变量 B,则处理器每次都必须使其缓存无效并重新加载,如果它们不在同一行中的话。
其次,您不希望将不同线程独立使用的数据保留在同一缓存行中。 如果同一高速缓存线被不同的 CPU 核心修改,则需要高速缓存同步,这会影响使用共享数据的多线程应用的整体性能,因为在这种情况下,内存访问时间会显著增加。
我们将创建一个使用四种不同方法分配四个缓冲区的应用,以了解如何对齐静态和动态分配的内存。 要执行此操作,请执行以下步骤:
- 在您的工作目录(即
~/test
)中,创建一个名为cache_align
的子目录。 - 使用您喜欢的文本编辑器在
cache_align
子目录中创建名为cache_align.cpp
的文件。 将以下代码片段复制到cache_align.cpp
文件中,以定义必要的常量和检测对齐的函数:
#include <stdlib.h>
#include <stdio.h>
constexpr int kAlignSize = 128;
constexpr int kAllocBytes = 128;
constexpr int overlap(void* ptr) {
size_t addr = (size_t)ptr;
return addr & (kAlignSize - 1);
}
- 现在,定义几个以不同方式分配的缓冲区:
int main() {
char static_buffer[kAllocBytes];
char* dynamic_buffer = new char[kAllocBytes];
alignas(kAlignSize) char aligned_static_buffer[kAllocBytes];
char* aligned_dynamic_buffer = nullptr;
if (posix_memalign((void**)&aligned_dynamic_buffer,
kAlignSize, kAllocBytes)) {
printf("Failed to allocate aligned memory buffer\n");
}
- 添加以下使用它们的代码:
printf("Static buffer address: %p (%d)\n", static_buffer,
overlap(static_buffer));
printf("Dynamic buffer address: %p (%d)\n", dynamic_buffer,
overlap(dynamic_buffer));
printf("Aligned static buffer address: %p (%d)\n", aligned_static_buffer,
overlap(aligned_static_buffer));
printf("Aligned dynamic buffer address: %p (%d)\n", aligned_dynamic_buffer,
overlap(aligned_dynamic_buffer));
delete[] dynamic_buffer;
free(aligned_dynamic_buffer);
return 0;
}
- 在 loop 子目录中创建名为
CMakeLists.txt
的文件,内容如下:
cmake_minimum_required(VERSION 3.5.1)
project(cache_align)
add_executable(cache_align cache_align.cpp)
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR arm)
SET(CMAKE_CXX_FLAGS "-std=c++ 11")
set(CMAKE_CXX_COMPILER /usr/bin/arm-linux-gnueabi-g++)
- 构建应用并将生成的可执行二进制文件复制到目标系统。 使用第 2 章、设置环境中的配方来实现此目的。
- 切换到目标系统的终端。 如果需要,请使用您的用户凭据登录。
- 运行二进制文件。
在第一个代码片段中,我们创建了两对内存缓冲区。 在每一对中,第一个缓冲区被分配给堆栈,而第二个缓冲区被分配给堆。
第一对是使用标准 C++ 技术创建的。 堆栈上的静态缓冲区声明为数组:
char static_buffer[kAllocBytes];
要创建动态缓冲区,我们使用new
C++ 关键字:
char* dynamic_buffer = new char[kAllocBytes];
在第二对中,我们创建内存对齐的缓冲区。 在堆栈上声明静态缓冲区类似于常规静态缓冲区。 我们使用一个附加属性alignas
,它是在 C++ 11 中引入的,作为一种标准化的、独立于平台的方法来对齐内存中的数据:
alignas(kAlignSize) char aligned_static_buffer[kAllocBytes];
此属性需要将对齐大小作为参数。 我们希望数据按高速缓存线边界对齐。 根据平台的不同,高速缓存线大小可能会有所不同。 最常见的大小为 32、64 和 128 字节。 使用 128 个字节可以使我们的缓冲区与其中任何一个对齐。
没有对动态缓冲区执行相同操作的标准方法。 要在堆上分配内存,我们使用一个名为posix_memalign
的 C 函数。 这仅在可移植操作系统****接口(POSIX)系统(大多数类 Unix)中可用,但这不需要 C++ 11 标准的支持:
if (posix_memalign((void**)&aligned_dynamic_buffer,
kAlignSize, kAllocBytes)) {
posix_memalign
类似于malloc
,但有三个参数,而不是一个。 第二个参数是对齐大小,与 Align 属性的大小相同。 第三个是要分配的内存大小。 第一个参数用于返回指向分配的内存的指针。 与malloc
不同,posix_memalign
可能失败,不仅因为它不能分配内存,而且如果传递给函数的对齐大小不是 2 的幂。 posix_memalign
返回错误代码作为其结果值,以帮助开发人员区分这两种情况。
我们定义函数重叠,通过屏蔽所有对齐位来计算指针的未对齐部分:
size_t addr = (size_t)ptr;
return addr & (kAlignSize - 1);
当我们运行应用时,我们可以看到不同之处:
第一对中的两个缓冲器的地址具有未对齐的部分,而第二对的地址是对齐的-未对齐的部分为零。 因此,对第二对缓冲区的元素的随机访问速度更快,因为所有这些元素都同时在高速缓存中可用。
CPU 访问数据对齐对于通过硬件地址转换机制高效地映射内存也至关重要。 现代操作系统操作 4KB 的内存块或页面来将进程的虚拟地址空间映射到物理内存。 在 4 KB 边界上对齐数据结构可以提高性能。
我们在本配方中描述的相同技术可以应用于将数据与内存页面边界对齐。 但是,请注意,posix_memalign
需要的内存可能是请求来满足此请求的内存的两倍。 对于较大的对齐块,这种内存开销增长可能非常显著。