将大型项目从 Windows 移植到 Linux。
功能类似,差异细微,编译器众多……
引言
要将软件移植到新平台,强烈建议使用一个抽象层,将平台特定头文件的使用限制在少数几个 .cpp 文件中。然后可以为这些 .cpp 文件开发并行版本以支持新平台,而无需更改软件的其余部分。实现这些 .cpp 文件需要为已支持的平台找到对应的平台特定函数。然而,抽象层也可能需要演进,以处理平台之间的细微差异。并且,新平台可能需要使用新的编译器,这也可能意味着必须使用新的构建工具。
背景
本文讨论的代码取自健壮服务核心(RSC)。如果您是第一次阅读有关 RSC 的文章,请花几分钟阅读这篇前言。
平台抽象层
RSC 是在 Windows 上开发的,但目标是最终也能支持 Linux。因此,从一开始就定义了一个平台抽象层,以便将特定于 Windows 的头文件限制在一组有限的文件中使用。Windows 的目标实现被放在 *.win.cpp 文件中,总计约 3.5K 行代码,在移植开始时不到项目的 1.5%。该抽象层由以下头文件组成,移植工作需要为它们开发 Linux 目标实现:
标题 | 描述 |
SysConsole.h | 控制台函数 |
SysFile.h | 目录函数 |
SysHeap.h | 默认 C++ 堆的 Heap 子类 |
SysLock.h | 低开销互斥锁 |
SysMemory.h | 内存分配与保护 |
SysMutex.h | 互斥锁 |
SysSignals.h | POSIX 信号 |
SysThread.h | 线程 |
SysThreadStack.h | 堆栈跟踪 |
SysTickTimer.h | 高精度时钟 |
SysTime.h | 日历时钟 |
SysIpL2Addr.h | IP 第 2 层地址和函数 |
SysIpL3Addr.h | IP 第 3 层地址和函数 |
SysSocket.h | 套接字函数 |
SysTcpSocket.h | TCP 套接字函数 |
SysUdpSocket.h | UDP 套接字函数 |
RscLauncher.h | 失败时自动重启 |
Linux 的目标实现被放在 *.linux.cpp 文件中,首先开发的是 IP 网络部分。这相当直接,因为 Windows 和 Linux 在套接字类型和函数方面非常相似。但它们报告错误的方式不同,这导致了一些重构,改进了 RSC 的套接字错误报告,而这方面无论如何都需要改进。
接着,工作转向了内核函数。RSC 最初开发时,C++11 尚未问世。到移植开始时,编译器已经实现了 C++20。但正是 C++11 增加了大部分可用于移植的头文件。与其实现 Linux 的目标实现,不如直接用 C++11 中已经处理了平台差异的头文件来取代部分平台抽象层。为此,采用了以下 C++ 库头文件:
标题 | 描述 |
<chrono> | 高精度时钟和日历时钟 |
<condition_variable> | 允许线程等待直到收到通知或超时 |
<filesystem> | 目录函数 (C++17) |
<mutex> | 互斥锁 |
<ratio> | 被 <chrono> 使用 |
<thread> | 线程 |
采用 <chrono>
也让 RSC 能够移除 Duration.h 和 TimePoint.h。尽管这两个文件都与平台无关,但它们支持时间间隔和时间戳的方式与 <chrono>
不兼容。
与 <chrono>
不同,<thread>
几乎没有减少代码量。它可以创建线程,但无法指定它们的堆栈大小。它的线程标识符是不透明的,并且它提供了 join
函数,这在玩具示例中很流行,但在其他地方用途有限。不过,它的 sleep_for
函数证明了其用处。创建一个线程对象时的一个主要问题是,底层线程通常在其线程对象完全构造好之前就开始运行,因为它不知道正在创建一个对象来包装它。RSC 有一个功能丰富的 Thread
类,包含一个 Pause
函数,但在 Thread
对象构造完成之前无法使用。之后,应该只使用 Pause
,而不要使用 sleep_for
。但是 sleep_for
允许一个线程短暂休眠,直到其 Thread
对象完全构造好之后再继续执行。
在采用新的 C++ 库头文件后,平台适配层的内核部分看起来是这样的:
标题 | 描述 |
SysConsole.h | 保留 |
由使用 <filesystem> 的 FileSystem.h 替代 | |
SysHeap.h | 保留 |
由使用 <mutex> 的 Lock.h 替代(此头文件后来被删除) | |
SysMemory.h | 保留 |
由使用 <mutex> 的 Mutex.h 替代 | |
SysSignals.h | 保留 |
SysThread.h | 保留,但部分被使用 <condition_variable> 和 <mutex> 的 Gate.h 替代 |
SysThreadStack.h | 重命名为 SysStackTrace.h,因为 C++23 将引入 <stacktrace> |
由 <chrono> 和简单的 SteadyTime.h 替代 | |
由 <chrono> 和简单的 SystemTime.h 替代 | |
RscLauncher.h | 保留 |
静态分析工具
第一个问题是 RSC 有一个C++ 静态分析工具,经常用来清理代码。但它不仅仅是报告问题,还提供了一个编辑器,可以交互式地修复三分之二的问题。这变得非常有用,以至于该工具必须支持 RSC 使用的任何 C++ 语言特性。这就成了问题,因为该工具在很大程度上是一个 C++ 编译器,而支持一个完全兼容的 C++ 编译器所处理的所有语言特性是不现实的。
然而,采用 <chrono>
迫使该工具进化。问题与其说是 <chrono>
本身,不如说是它对 <ratio>
的使用,后者是一个模板元编程的“老鼠窝”,在编译时将一个比率转换为另一个。
ratio
使用整型字面量作为模板参数,这是该工具不支持的。工具希望模板参数是一个“类型”。它用 TypeSpec
表示一个类型,这是一个抽象类,其派生类有 DataSpec
(几乎所有类型)和 FuncSpec
(函数类型)。模板名称由 TypeName
表示,它有一个 vector
存放其后的模板参数 TypeSpec
。然而,字面量位于类层次结构的另一个分支,在 CxxToken
之下。因此,创建了一个 TemplateArg
类。它包装一个 TypeSpec
或一个 Expression
(可以是一个整型字面量),并且其几乎所有函数都简单地转发给它包装的这两个类之一。
接下来,工具需要知道 std::ratio
的一个实例可以转换为另一个实例。它已经支持类型转换、转换构造函数和转换运算符。但这些都不适用于两个不同的 ratio,它们是两个不同的模板实例。为解决此问题,修改了 StackArg::MatchWith
,使其在处理 std::ratio
实例时剥离模板参数,以检测可转换性。
最后,用于处理 POSIX 信号的 Linux 目标实现需要使用 sigaction
来注册其信号处理程序。这为处理程序提供了有关信号的附加信息,以便在调试时,当有问题的代码写入一个有效但写保护的地址时,能将 SIGSEGV
映射为 RSC 专有的 SIGWRITE
,从而简化调试。然而,某个有特殊需求的开发者也用 sigaction
命名了一个传递给 sigaction
函数的 struct
。这意味着工具必须支持详述类型说明符:在类型名前加上关键字 class
、struct
、union
或 enum
,以区别于其他同名事物,例如函数。支持这个相当容易:修改了 Parser::GetTypeSpec
,使其在类型名前查找这些关键字之一。
改进工具的过程中也发现了一些需要纠正的错误和奇怪行为。最终,花在增强工具上的时间几乎和实际移植到 Linux 的时间一样多。
Windows 和 Linux 之间的差异
在移植过程中,必须解决 Linux 和 Windows 之间的各种细微差异。
文本文件。 如果在 Linux 上读取在 Windows 上创建的文本文件,会出现问题。Windows 在行尾使用 \r\n
(CRLF),而 Linux 只使用 \n
(LF)。因此,添加了 FileSystem::GetLine
函数,其签名与 std::getline
相同,用于移除 \r
,否则当 Linux 读取在 Windows 上创建的文件时,std::getline
返回的每个字符串末尾都会出现 \r
。
目录分隔符。 Linux 使用 /
来分隔路径中的目录,而 Windows 使用 \
。因此,常量 PATH_SEPARATOR
使用 std::filesystem::path::preferred_separator
进行初始化,该函数知道自己正在运行的平台。
堆。Windows 允许你创建与默认 C++ 堆功能相同的附加堆。然而,Linux 不支持附加堆。RSC 曾使用 Windows 的堆接口来创建附加堆,但它也开发了自己的 BuddyHeap
来支持写保护内存,因为 Windows 堆如果被写保护会崩溃。因此,移植到 Linux 意味着所有附加堆都必须使用 BuddyHeap
。然而,BuddyHeap
的大小是固定的,只能通过重启来扩展,这对于其中一个堆来说是不可接受的。因此,RSC 必须实现可扩展的 SlabHeap
来补充 BuddyHeap
。
POSIX 信号。C++ 标准定义了 SIGABRT
、SIGFPE
、SIGILL
、SIGINT
、SIGSEGV
和 SIGTERM
。Windows 增加了与 SIGINT
类似的 SIGBREAK
,而 Linux 增加了与 SIGSEGV
类似的 SIGBUS
。
Gate.h。它提供一个对象,线程可以在其上等待,直到被通知或超时。它取代了 Windows 的 CreateEvent
、WaitForSingleObject
和 SetEvent
,其平台无关的对应物是 Gate::Gate
(构造函数)、Gate::WaitFor
和 Gate::Notify
。尽管 Gate
的代码不多,但要正确实现它需要进行大量的研究,以及修复一些细微的错误。值得注意的是,Gate
需要三个临界区对象来实现其接口:一个 condition_variable
、一个 mutex
和一个 atomic_bool
。
线程标识符。当一个线程退出时,RSC 不会从其 ThreadRegistry
中移除其标识符(包括原生标识符和 RSC 标识符)。它只将它们标记为 Deleted
,这样调试工具仍然可以找到原生标识符,并在显示线程退出前捕获的跟踪记录时将其映射到 RSC 的标识符。这在 Windows 上运行良好,但在 Linux 上不行。当一个线程退出时,Linux 上的 POSIX 线程实现会立即将其标识符重新分配给它创建的下一个线程。因为该标识符仍在 ThreadRegistry
中,新线程会阻塞,永远等待其 Thread
对象被标记为 Constructed
。必须修改代码来处理这种情况。
线程优先级。尽管 POSIX 线程支持优先级,但 Linux 调度器在标准构建中并不支持它们。这是一个问题,因为尽管 RSC 的大多数线程以相同优先级运行,但它有两个线程以较高优先级运行,以模拟健康检查和时钟中断。结果发现,这两个线程可以和其他所有线程以相同的优先级运行,并且仍然能完成它们的工作。这是因为 Windows 和 Linux 都以非常高的频率进行上下文切换,因此那些本应是高优先级的线程也能被频繁调度。
堆栈跟踪。虽然在 Linux 上捕获线程的堆栈很容易,但显示出来的函数名看起来像是受到了传输线路噪声的影响。为了与 C 兼容,C++ 编译器在将函数名添加到包含调试信息的目标文件时会对其进行“名字修饰”(mangling)。将地址映射到函数名的 Windows 函数会“反修饰”(demangle)该名称,但等效的 Linux 函数不会。必须使用一个单独的工具来完成此操作。
套接字解除阻塞。RSC 支持重启,这是系统的部分重启。在重启期间,除非线程的数据需要保留,否则线程会退出。然而,这对被阻塞的线程构成了问题。它们如何解除阻塞以便退出?没有办法解除从 cin
读取的阻塞,所以 CinThread
总是存活。对于阻塞在 recvfrom
上的 UdpIoThread
实例,在 Windows 上的方法是删除该线程的套接字,这会立即导致 recvfrom
返回错误。在 Linux 上,什么也没发生:recvfrom
继续阻塞该线程。有些人贬低 Windows,但我发现它是一个很好的操作系统。无论如何,现在必须找到一个不同的解决方案,即让 UdpIoThread
向自己的套接字发送一条消息来解除其 recvfrom
的阻塞。
类似功能的函数
如果您正在将软件从 Windows 移植到 Linux,或者反之,您通常需要找到某个函数在新平台上的等效函数。搜索“当前函数名”和“新平台名”通常会返回一个网站链接,在那里您的问题已经得到了解答。如果这不起作用,Windows 函数的文档在这里,而 Linux 函数的文档在这里。至少 Windows 会按主题对其函数进行分组,不像 Linux。看到这一切,我更加确信,一个大型操作系统应该使用面向对象的语言,这将有助于组织其庞杂的函数。
下表列出了 RSC 平台抽象层中使用的 Windows 和 Linux 的类似函数。首先是内核函数:
标题 | Windows | Linux |
SysConsole.h | SetConsoleTitle | 一个奇怪的转义序列 |
SysHeap.h | HeapAlloc HeapSize HeapFree HeapValidate | malloc malloc_usable_size 免费 mcheck |
SysMemory.h | VirtualAlloc VirtualFree VirtualLock VirtualProtect VirtualUnlock | mmap munmap mlock mprotect munlock |
SysThread.h | SetPriorityClass _beginthreadex CloseHandle signal SetThreadPriority | setpriority pthread_create 退出线程时无对应函数 sigaction (更佳)pthread_setschedprio |
SysStackTrace.h | RtlCaptureStackBackTrace SymFromAddr 和SymGetLineFromAddr64 | backtrace backtrace_symbols 和__cxxabiv1::__cxa_demangle |
RscLauncher.h | CreateProcessA WaitForSingleObject 和GetExitCodeProcess | posix_spawnp waitpid |
以及 IP 网络函数,它们的名称相同或非常相似:
标题 | Windows | Linux |
SysIpL2Addr.h | gethostname | gethostname |
SysIpL3Addr.h | getaddrinfo freeaddrinfo getnameinfo | getaddrinfo freeaddrinfo getnameinfo |
SysSocket.h | socket bind getsockopt setsockopt ioctlsocket closesocket | socket bind getsockopt setsockopt ioctl close |
SysTcpSocket.h | connect listen accept WSAPoll getsockname getpeername recv send 关闭 | connect listen accept poll getsockname getpeername recv send 关闭 |
SysUdpSocket.h | recvfrom sendto | recvfrom sendto |
使用多个编译器进行构建
在移植开始时,RSC 是使用通过 VS2022 的属性表创建的 .vcxproj 文件构建的。为了支持在多个平台上构建,Visual Studio 建议采用 CMake,而我对此一无所知。它似乎是一个流行的构建系统,有很好的文档,而且在 stackOverflow 上有很多关于它的问题和答案。然而,所有这些信息都是零散的,示例通常很简单。没有一个能解决如何将一个大型项目从 VS2022 构建迁移到 CMake 构建的问题。
幸运的是,我搜索到了一个开源工具 CMakeConverter,它可以分析 .vcxproj 文件来生成 CMake 文件。使用这个工具很简单,它很好地创建了 CMake 需要的文件。但仍有一些工作要做。RSC 有一个大多数项目共享的属性文件,而该工具不知道如何处理。但它生成的文件让我很好地了解了如何在 CMake 中构建一个大型项目。其中一个文件 GlobalSettingsInclude,显然是为了起到与那个共享属性文件相同的作用。
一旦有了整体的 CMake 框架,就可以在找到如何设置编译器和链接器选项等问题的答案后,逐步修改其文件。VS2022 也已经发展到与 CMake 集成得相当好。你不再需要从解决方案或项目文件打开 VS2022。你可以删除那些文件,直接打开包含源代码的文件夹。VS2022 随后会分析其 CMakeLists 文件来配置解决方案。点击“管理配置…”会打开一个选项卡,允许你添加新的构建配置(编译器加平台的组合),VS2022 会将其保存在一个 JSON 文件中。
没过多久就成功地进行了一次构建。这令人惊讶,因为我讨厌学习需要大量配置的工具,而且 C++ 的构建过程本身就很荒谬。另一个惊喜是 x64 构建速度快得多,因为它们现在使用 Ninja 而不是 VS2022。
现在构建可以使用 gcc 或 clang 编译器以及 MSVC。gcc 和 clang 都生成了新的编译器警告。一些通过更改代码解决,另一些则通过在 CMake 文件中添加编译器选项来抑制。然而,使用 clang 时出现了一个问题。RSC 有一套测试,以查看在发生坏事时它是否能存活。其中一个测试是除以零。如果用 MSVC 或 gcc 编译,这个测试会通过,但 clang 会进入一个无限循环,不断地除以零。在阅读了一些 Windows 文档后,我在 RSC 的 Windows 结构化异常处理程序中添加了对 _fpreset
的调用,解决了这个问题。
为了在 Linux 上测试,我选择了 WSL2 和 Ubuntu。当通过“调试”菜单启动 RSC 时,VS2022 会创建一个 Linux 控制台窗口选项卡。但 VS2022 在这方面还需要一些改进,因为它没有将所有键盘事件转发到该窗口。我发现只按 enter 键不会导致 std::getline(cin,
str)
返回;enter 前必须先按一个空格。同样,ctrl-C 也没有任何作用。为了确认不是 RSC 的问题,这些操作不得不在一个真正的 Ubuntu 控制台上进行测试。
直到最近,为这次移植更改构建过程还需要付出更多的努力。但是 VS2022 已经发展到可以很好地支持 Linux 和其他 C++ 编译器。我最初以为即使我能让一切都正常工作,我最终也会得到一个降级的 IDE 体验。但这次过渡比预期的要容易得多,而且我实际上更喜欢使用 CMake。
历史
- 2022年7月7日:扩展了关于静态分析工具的部分
- 2022年7月4日:初始版本