Đa nền tảng với C++

C++ I choose you
Với tôi, C và C++ gần như là lựa chọn duy nhất cho cross-platform thực thụ, không phải vì cú pháp hay tính năng ngôn ngữ, mà vì sự hiện diện, C/C++ chạm được tới gần như mọi platform đang tồn tại. Dù React Native, Flutter hay .NET MAUI rất mạnh mẽ, C/C++ vẫn là "ông vua" ở những chỗ framework bậc cao không chạm tới được:
- Tầng thấp & phần cứng : kết nối, điều khiển USB, BLE, cổng COM, driver tùy chỉnh; hoặc các hệ điều hành nhúng, POS, smartwatch đời cũ, thiết bị y tế, nơi React Native hay MAUI đơn giản là không thể cài đặt.
- Hiệu năng tối đa & thời gian thực : game engine, đồ họa 3D giao tiếp trực tiếp với GPU (Vulkan, Metal, OpenGL), xử lý âm thanh/video độ trễ miligiây mà không lo bị chững vì Garbage Collection.
- Dung lượng & tài nguyên : không kèm theo Runtime (JS engine hay .NET CLR) nặng vài chục MB, binary nhỏ gọn, khởi động tức thì, chạy được trên thiết bị RAM cực hạn chế.
Case study: Tôi có số thiết bị có kết nối Bluetooth. Tôi phát hiện ra tôi sẽ tạo ra giá trị lớn hơn nhiều nếu có thể kết nối, điều khiển nó qua internet bằng API của Tiktok, Case study rơi vào địa bàn của Tầng thấp & phần cứng. Tôi sẽ cần app chạy được trên Windows, MacOS, iOS, Android để kết nối với Tiktok API và gởi lệnh xuống thiết bị bluetooth.
Strategy
Chiến lược tôi hướng tới khá đơn giản: Viết phần core, business logic, thuật toán bằng C++. Chỉ dùng ngôn ngữ lập trình của platform(Java, SWIFT ...) cho GUI và những gì không thể/không nên viết bằng C++ cross-platform.
Trong Case study trên tôi build phần C++ kết nối Bluetooth thành một dynamic-link library cross-platform (ngoại trừ iOS) còn app front-end giao tiếp với DLL sẽ viết bằng .net Maui (C#) có thể chạy cross-platform.

PS: Dynamic-Link Library có nhiều định dạng .dll .so .dylib; từ đây trở đi tôi viết tắt DLL nói chung phân biệt với chỉ định dạng .dll
Case study đối với riêng iOS tôi phải build C++ cross-platform thành một thư viện tĩnh .a compile cùng app front-end (binary sẽ nặng hơn) vì iOS policy không cho phép sử dụng DLL, một policy tôi cảm thấy hơi bất cập, khi tôi mong muốn giữ thư viện C++ kết nối BLE được cập nhật độc lập và thậm chí có thể tái sử dụng bởi các app khác trong tương lai.
1. Sơ đồ giao tiếp platform và C++ Core
Khi C++ Core được dùng trên một platform cụ thể, luôn có hai luồng gọi ngược chiều nhau đi qua đường biên:

- Chiều xuống: platform gọi vào core. Bài toán: expose C++ core ra ngoài sạch sẽ, không rò rỉ chi tiết cài đặt.
- Chiều lên: core cần dịch vụ chỉ platform mới có, Bluetooth, GPS, camera, file, notification. Bài toán: gọi ra platform mà không dính chặt vào platform nào.
Phần lớn tài liệu chỉ nói một trong hai chiều. Một dự án nghiêm túc cần giải cả hai, và chúng phải nhất quán với nhau vì cùng một core vừa bị gọi vào, vừa gọi ra.
Dưới đây là bức tranh hoàn chỉnh để bạn dễ theo dõi bài viết:

← Expose: platform gọi xuống PAL: core gọi lên →
2. Vì sao cần kiến trúc nhiều layer
Hầu hết sample code từ chính platform về DLL, JNI hay tích hợp C++ vào iOS đều trộn lẫn code platform với logic nghiệp vụ (và AI chắc chắn sẽ tham khảo chính những sample đó trước tiên). Để minh họa một API thì ổn, nhưng đưa vào sản phẩm thật thì hậu quả là: code không portable, không test được, bug ẩn nấp ở đủ ngóc ngách, và refactor trở thành ác mộng vì không ai biết đâu là ranh giới an toàn để sửa.
Tệ hơn: module boundary, nơi C++ giao tiếp với DLL, JNI hay bridge của iOS — là nơi nguy hiểm. Exception C++ không thể, và không được phép, vượt qua ranh giới này. Một exception lọt tới đó gần như chắc chắn dẫn tới undefined behavior hoặc crash cả tiến trình.
Software engineer không chỉ code để giải quyết vấn đề hiện tại, mà còn phải foresee tương lai và chuẩn bị cho các vấn đề sắp tới bằng kiến thức, kinh nghiệm, trải nghiệm của mình; đó là giá trị người ta vẫn cần ở Software Engineer trong thời đại AI này.
Cách tôi xử lý: tách từng mối quan tâm thành những layer mỏng, mỗi layer làm đúng một việc. Càng mỏng càng dễ build, test, debug và bảo trì độc lập.
Một lưu ý ngay từ đầu: không phải dự án nào cũng cần đủ số layer. Trên desktop (Windows, Linux, macOS), ví dụ như layer platform boundary thường được gộp chung với Public C API. Nhưng dù gộp, vai trò của từng layer vẫn tồn tại về mặt khái niệm.
3. Sáu layer để expose C++ Core cho platform gọi xuống

3.1. C++ Core.
Trái tim hệ thống — business logic, thuật toán, lý do dự án tồn tại. Sử dụng C++ idiomatic và không cần biết gì về platform nó sẽ chạy trên. Phải build được trên mọi target bằng toolchain riêng của target đó. Đây cũng là nơi test có giá trị nhất và dễ viết nhất.
Trong Case study của tôi C++ Core có vai trò quản lý business logic như thiết lập các vibration group, gửi lệnh cho motor, quản lý trạng thái kết nối, xử lý log. Để dễ debug tôi còn thiết kế để dễ dàng inject cơ chế communicate với thiết bị:
- BTController: kết nối với motor, loa và gởi lệnh qua bluetooth sử dụng PAL layer sẽ đề cập ở dưới
- TextController: không hề kết nối với thiết bị, chỉ in ra tên thiết bị và tên lệnh
- RawController: In ra các lệnh ở dạng raw bytes, tôi dùng tính năng này chủ yếu cho Golden Master Testing
3.2. Public C++ API.
Chỉ expose phần public, và quan trọng hơn: đổi hẳn góc nhìn — thôi nghĩ theo kiến trúc nội bộ, bắt đầu nghĩ theo cách mà client sẽ dùng API đó . Nó là một dạng SDK có thể giao cho team khác mà họ không cần hiểu toàn bộ codebase. Đây cũng là chỗ dựng compilation firewall ( như pImpl), đơn giản hóa datatype cho client (std::string thay vì std::filesystem::path), và chặn các dependency nặng như Boost rò rỉ ra ngoài — kể cả trong signature của header.
⚠️ Quan trọng: Boundary restriction
Từ điểm này trở đi, tuyệt đối không nhét bất kỳ logic nghiệp vụ nào vào các layer tiếp theo. Lý do rất thực tế: một số platform sẽ không dùng tới các layer bọc ngoài C++. Ví dụ iOS có thể consumes thẳng C++ và bỏ qua toàn bộ C API lẫn platform boundary. Nếu bạn nhét business logic vào các layer tiếp theo, hoặc iOS sẽ thiếu logic, hoặc bạn phải viết lại nó riêng cho iOS (vi phạm DRY ngay lập tức). Theo tôi đây là quy tắc quan trọng nhất của toàn bộ cách tiếp cận.
3.3. Public C API.
Case study: Front-end app viết bằng C# .net giao tiếp với DLL qua layer này.
Một số platform include được C++ trực tiếp, nhưng universal translator thực sự của điện toán vẫn là C ABI, ổn định (không name mangling loạn xạ như C++), mô hình binding/linking đơn giản, được hỗ trợ bởi gần như mọi platform.
Layer này định nghĩa interface "extern C", dùng function, không dùng class, gọi thẳng xuống C++ API. Công việc điển hình là bọc method C++ bằng C function, quản lý object lifetime, giải quyết overload (C không có overload như C++), và quyết định dùng struct hay chỉ dùng primitive type tùy độ "khó tính" của target platform, DLL pass struct qua lại khá thoải mái, JNI thì không.
Lưu ý : mức độ "thân thiện với C wrapper", đặc biệt là cách quản lý object lifetime, nên là mối quan tâm ngay từ khi thiết kế Public C++ API, đừng để tới lúc này mới phát hiện API của mình không thể bọc bằng C ABI.
Vẫn có case nên bỏ qua layer này: (đây là case tôi không nắm rõ) với kiến trúc bất đồng bộ trả kết quả trên nhiều thread, một số runtime (Android/JVM) đòi response phải được inject thủ công vào đúng loại thread. Chèn thêm C ABI ở giữa chỉ làm phức tạp một luồng vốn đã phức tạp, cái giá là nếu sau này thêm platform thứ ba, có thể phải cài lại logic inject thread riêng.
3.4. Layer Platform boundary. Đây là bước ngoặt.
Đây chính là cross-road Mọi thứ trước đây là C/C++ cross-platform. Từ đây trở đi, mọi thứ đều riêng cho từng platform, và layer này phải được implement độc lập cho mỗi target. Đây chính là module boundary là biên giới cuối cùng. Mọi thứ xảy ra bên kia nó là môi trường khác, runtime khác, ngôn ngữ khác.
Exception không được chào đón ở đây. Một exception chưa được xử lý mà lọt tới layer này gây hỗn loạn nghiêm trọng, undefined behavior, và khả năng cao là crash. Layer này là chốt chặn cuối cùng để bắt mọi exception, và cũng là nơi tự nhiên nhất để truy cập cơ chế logging của platform nếu bạn cần (vì layer này được implement độc lập cho mỗi target).
// media_engine_c_api_jni.cpp
#include <jni.h> // JNI headers
#include <android/log.h> // Android logging
#include <media_engine_c_api.h> // C API
#include "jni_utils.h" // JNIByteArrayAdapter, exceptionHandler
JNIEXPORT jboolean JNICALL
Java_openSession(JNIEnv* env, jobject thiz, jstring filePath)
try {
return ::openSession(jni_utils::getString(env, filePath));
}
catch (...) { return exceptionHandler(); }
Trên JVM, để test ta còn có thể ném một exception mới mang thông tin của exception C++ gốc, mô phỏng việc exception "lọt qua" được biên giới, nhưng bản thân tôi cũng chưa dùng nó. Đây là trường hợp khó test nhất, tôi chưa tìm được giải pháp tổng quát cho việc này, do đó tôi cho rằng phải giữ layer này càng mỏng càng tốt.
Case study: trong case của tôi layer này được gộp vào luôn layer Public C API (như tôi đã đề cập ở cuối section 2) sử dụng các macro export function được gán giá trị lúc compile time. Tôi bắt các exception trong function của layer Public C API, tôi tự nhủ rằng sẽ không exception "lọt qua" được cái lưới này rồi cross my finger cầu nguyện thánh Jason Turner sẽ phù hộ tôi.
3.5. Khai báo import native layer.
Nhiệm vụ của Layer này là ánh xạ 1:1 (hoặc gần như 1:1) với các hàm C được định nghĩa trong layer trước MagicHandCAPI.h, convert datatype của function signature cho phù hợp với target platform.
Case study: Layer này là cổng vào địa phận của .net C# app. Công việc chủ yếu của layer này là tôi cần mapping, marshalling data bạn có thể đọc về nó từ microsoft https://learn.microsoft.com/en-us/dotnet/standard/native-interop/marshalling-data-with-platform-invoke (chú ý encoding của string)
3.6. Native Wrapper layer thân thiện với platform.
Đây là nơi ta sẽ bọc interface low-level đó bằng một interface "more native" với platform hơn. Do đó logic kiểm tra error code hay quản lý object lifetime nên nằm ở layer này thay vì nhét vào import native layer.
4. Boundary contracts
Memory ownership
Vấn đề kinh điển quản lý object lifetime: Nguyên tắc vàng là đối tượng nào cấp phát bộ nhớ thì đối tượng đó phải giải phóng nó.
Trường hợp sẽ rất dễ gặp đó là cả DLL và target platform đều dùng chung một environment variable và environment variable rất dễ gặp đó là file handle.
C++ cấp phát bộ nhớ, C# sử dụng (C++ ownership)
Case study: Sau khi khởi tạo object MagicHand trong DLL tôi truyền pointer của nó sang cho .net app pointer này được convert sang Intptr cho .net giữ trong một object MagicHandClient. Khi gọi hàm dispose hay hay GC muốn destroy object nó sẽ pass con trỏ Intptr vào hàm destroy do DLL cung cấp, thời điểm dispose, logic dispose thậm chí có dispose MagicHand object hay không, sẽ do DLL quyết định.
Lưu ý: sai lầm của các của các C/C++ dev dễ gặp phải là nghĩ các ngôn ngữ khác cũng quản lý resource theo mô hình RAII.
Native platform cấp phát bộ nhớ, C/C++ sử dụng
Ngược lại khi bạn pass một object từ native platform sang cho DLL bạn cần chú ý đảm bảo native platform không move hay delete nó khi mà DLL đang dùng nó. Vì trong C/C++ DLL bạn tự viết logic quản lý life time nhưng trong native platform bạn phải chú ý Garbage Collector.
Case study: với C# .net bạn cần dùng fixed scope và GCHandle để kiểm soát Garbage Collector.
Kịch bản Shared Ownership
Khi object lifetime quá phức tạp: C++ và Native Platform đều có thể giữ và giải phóng object vào các thời điểm không xác định. Vì C++ là bên thư viện, tôi đề xuất implement một cơ chế Reference Counting (giống nguyên lý của std::shared_ptr ) bên C++ để C++ quản lý; constructor() và destructor() do C++ cung cấp.
Ban đầu tôi đề xuất vậy vì tôi là C++ dev nhưng trường hợp này việc C++ là bên thư viện là core business quả thật là hết sức thuận tiện.
Các vấn đề khác
Còn một vài vấn đề khác thuộc về C++ bạn cần chú ý, nhưng tôi coi như bạn nắm rõ với vai trò là C++dev. Như là khi bạn include một file header C++ trong client code bạn cần lưu ý việc default destructor sẽ được inline trong header dẫn đến gây crash, memory leak hoặc lỗi ABI, và thường là làm hỏng bộ test thư viện.
5. Platform Abstraction Layer (PAL) chiều core gọi lên platform
Khi core cần gọi lên platform — ví dụ xin tọa độ GPS, file operation, mở kết nối mạng, bluetooth.
PAL là layer trừu tượng nằm trên chức năng riêng của từng platform. Thay vì gọi platform-specific rải rác khắp core, ta (1) định nghĩa một interface trừu tượng đặc tả đúng chức năng cần dùng ở phía code đa nền tảng, rồi (2) cài đặt nó riêng cho từng platform — bằng C++ hoặc bằng ngôn ngữ tự nhiên của platform.
Case study: trên Windows dùng Windows Bluetooth API hay WinRT, macOS dùng IOBluetooth/IOKit, Linux dùng BlueZ thông qua D-Bus hay socket Bluetooth — ba cách hoàn toàn khác nhau. Callback và event model cũng khác: Windows là event sink, macOS là observer, Linux là signal/reply.
Case study: Nếu C++ core dùng trực tiếp bất kỳ bluetooth implement nào, nó sẽ bị dính chặt vào một platform. Vậy nên tôi đã bọc nó trong một interface trừu tượng trong header file Connect() Close() Read(char *buffer, int length) Write(const char *buffer, int length) và linking nó với file implementation cpp cụ thể từng platform/OS lúc compile time. C++ core chỉ biết là nó đang gọi một Bluetooth device mà thôi
5.1. Bốn nguyên tắc thiết kế PAL(Platform abstraction layer)
Abstraction trước, implementation sau: Thiết kế API dựa trên chức năng bạn thực sự cần, trước khi động vào implementation chi tiết của bất kỳ platform nào.
Separation of concern: native implementation lo implement cho từng platform; C++ core giữ được tính tổng quát. Dev iOS không cần đọc C++ Core, dev C++ core không cần học Java code.
Đặt đúng chỗ. PAL nằm ngay giữa thành phần cross platform code và platform specific code.
Ngăn chặn coupling, kể cả khi không bắt buộc: kể cả khi đó là một thư viện C++ cross-platform, ví dụ: thư viện C++ HTTPRequest thì về kỹ thuật vốn nó đã chạy tốt trên mọi platform không cần PAL. Nhưng cho C++ core gọi thẳng vào nó nghĩa là core sẽ dính chặt vào thư viện đó vĩnh viễn. Do đó ta nên bọc bằng một interface IHttpClient để có thể tự do thay thế thư viện khác, hoặc dùng networking stack riêng của platform khi cần.
5.2. Khi portable không có nghĩa là portable mọi nơi
- Sandbox restrictions. Mobile OS thường hạn chế truy cập file system, desktop thì toàn quyền. Cùng một đoạn code mở file bằng đường dẫn tuyệt đối chạy tốt trên desktop có thể chết ngay trên mobile — vì thế path resolution là ứng viên PAL kinh điển.
- Chức năng chỉ tồn tại ở tầng framework. GPS, sensor, camera, notification, phần cứng — bắt buộc phải có implement riêng từng platform.
- Bắt buộc chạy test trên tất cả các target platform. Mỗi platform là một cài đặt PAL riêng, tức một chỗ để bug ẩn nấp, và unit test trên máy dev sẽ không bao giờ chạm tới chúng.
6. Vài nguyên tắc thực chiến khác
Đừng abstract cái mà C++ standard đã cho sẵn. Abstraction là chi phí bảo trì, không phải free real-estate. Nếu std::thread hay std::filesystem đã đủ portable cho nhu cầu của bạn, đừng bọc thêm PAL layer lên trên chỉ "nhất quán". Và nếu bạn từng bọc thư viện nào đó trước khi C++ standard hỗ trợ, rồi sau đó C++ standard bắt kịp, hãy mạnh dạn xóa luôn thay vì giữ lại vì tiếc code.
Đôi khi dùng PAL vì lý do policy. Một thư viện network phổ biến có thể chạy tốt về mặt kỹ thuật nhưng lại dùng API bị một số platform cấm vì lý do policy, code chạy đúng, test xanh, bản build vẫn bị từ chối phát hành. PAL giải quyết được điều đó, và thường cho thêm phần thưởng: mở khóa các tính năng đặc thù của từng platform (background fetch trên iOS, networking thread pool do OS quản lý) mà thư viện "chung chung" không bao giờ chạm tới.
Tránh #if preprocessor rải rác trong thân hàm. Vấn đề không phải #ifdef tự nó xấu, mà là khi nó xen kẽ với logic, người đọc không bao giờ thấy trọn vẹn code của một platform liền mạch, mắt phải tự chạy preprocessor trong đầu. Cách chấp nhận được là để điều kiện chỉ chọn file cần biên dịch, sống trong build system và cấu trúc thư mục (PAL/iOS/, PAL/Android/) chứ không sống trong thân hàm. Mỗi file khi đó đọc được trọn vẹn như code của đúng một platform.
Chọn giữa compile-time và runtime linking. Hoặc để build system chọn đúng file cho đúng target, hoặc giữ PAL thuần là interface ta sẽ inject implementation lúc runtime. Không có lựa chọn nào đúng tuyệt đối:x
Test mọi interface PAL trên mọi target platform bằng một bộ test C++ viết dựa trên interface. Điều này quan trọng hơn vẻ ngoài của nó: C++ Core là deterministic, test một lần trên máy dev là đủ tin. Nhưng PAL có N implementation cho N platform, và mỗi implementation là một cơ hội để hành vi trôi dạt, một platform trả path khác, một platform làm tròn value, một platform ném exception còn platform kia trả error code. Bạn nhải chạy từng bộ test đó trên đúng platform của nó mới lộ ra được sự lệch pha này.
Lời khuyên tôi thấy giá trị nhất nhưng cũng dễ bị bỏ qua nhất. Dựng đủ sáu layer cộng PAL hoàn chỉnh cho một prototype trong hai tuần là over-engineering; không dựng layer cho một sản phẩm chạy trên 5 platform là tự sát.
Thực tế phần lớn dự án không implement hết mọi layer thành layer riêng biệt. Nhưng dù gộp các layer lại, vai trò của từng layer vẫn còn nguyên. Hãy biết rõ mình đang bỏ qua cái gì khi bỏ qua nó.
8. Trade off và conclusion
Mở đầu của phần kết thúc tôi xin nhấn mạnh lại (nhắc đến ở mục "C++ I choose you"), cách tiếp cận này không hề dễ dàng hơn hay tốt hơn các framework đa nền tảng "write once, run anywhere" (React Native, Flutter hay .NET). Cách tiếp cận này phù hợp khi bạn gặp cần điều khiển phần cứng low-level, hoặc cần sự kiểm soát chặt chẽ bằng profile gọn nhẹ hiệu năng cao của C/C++
Kiến trúc mà tôi đề xuất giải quyết/tránh vấn đề gì cho C++ cross-platform?
- Giữ nhiều codebase riêng biệt, mỗi team một platform? Chi phí nhân sự nhân lên, và tệ hơn: hành vi giữa các platform sẽ trôi dạt theo thời gian — một bug fix ở platform này rất dễ bị quên port sang platform kia.
- Trộn tất cả vào một khối code duy nhất, không phân layer? Đây là con đường mặc định, và cũng là con đường dẫn thẳng tới ác mộng bảo trì đã mô tả ở mục 2.
Kiến trúc này không hứa hẹn một quy trình dễ dàng. Nó hứa hẹn một thứ thực tế hơn: chi phí có kiểm soát và hành vi nhất quán giữa các platform, ngay cả khi dự án lớn dần theo thời gian.
Struggling to turn ideas into reality? With a proven track record of over 1,000 clients, our agile and flexible team will accelerate your business growth.
Book a Free ConsultationRelated articles

The Full Story of buildingSMART, the Command Center of openBIM — From Its Origins and Relationship with Autodesk to the Future of IFC5
A comprehensive guide to buildingSMART International, from the IAI era and openBIM to IFC4.3, IFC5, bSDD, BCF, IDS, OpenCDE APIs, GeoBIM, digital twins, and key open-source tools. Learn how open standards are reshaping interoperability and data sovereignty in construction.

From “BPM” to “DM”: We’re Changing the Role Name Starting September 1
From September 1, 2026, Vitalify Asia will rename BPM (Bridge Project Manager) to DM (Delivery Manager). This article explains why the role is changing, what DMs own, how PoC, MVP, and iteration shape delivery, and how VFA’s AI Foundation supports speed and quality.

How to Think About LLM Workflow Design | More Tasks Than You Think Do Not Need AI to Decide What to Do
A practical guide to designing multi-step AI systems. Learn the difference between fixed LLM workflows and AI agents, how step boundaries enable retries and testing, how to handle waiting and idempotency, and why a hybrid workflow-plus-agent architecture often works best.