Professional Documents
Culture Documents
Hiện nay hệ thống nhúng đã và đang từng bước phát triển tại Việt Nam . Nó
thay thế cho các hệ thống vi xử lí đơn thuần trước đây . Hệ thống nhúng được sử
dụng rộng rãi trong mọi ngành công nghiệp như hệ thống viễn thông, hệ thống máy
tính, các hệ thống điều khiển trong các ngành hàng không, ngân hàng cho đến các
thiết bị dân dụng như tủ lạnh, điều hòa vv… .Mặc dù khả năng ứng dụng của nó
được áp dụng rộng rãi tuy nhiên ta vẫn chưa có một khái niệm hay một qui trình cụ
thể để phát triển một hệ thống nhúng như vậy. Thông qua khả năng vận hành, xây
dựng và phát triển hệ thống nhúng chúng ta có thể phân loại hệ thống nhúng như
sau:
Hệ thống nhúng không sử dụng OS – (Operating System – Hệ điều hành):
Thông thường nó sẽ là một thiết bị vi xử lí hay vi điều khiển được lập trình bởi các
kĩ sư nhằm phục vụ một vài chức năng nhất định . Số lượng các chức năng mà các
thiết bị này mang lại thường không nhiều (thường chỉ chuyên dụng một vài chức
năng nào đó cụ thể) . Các hệ thống nhúng như vậy chúng ta có thể bắt gặp rất
nhiều trong cuộc sống hàng ngày như radio, máy giặt, biển quảng cáo vv…
Hệ thống nhúng sử dụng OS: Là các hệ thống nhúng rất phổ biến hiện nay .
Cùng với sự bùng nổ của các ngành công nghiệp 4.0 đặc biệt là IOT. Các thiết bị
nhúng sử dụng hệ điều hành đang tăng trưởng một cách vô cùng nhanh chóng . Mọi
người có thể đã quen làm việc với các hệ điều hành như Windows, MacOS . Tuy
nhiên hệ điều hành được sử dụng phổ biến nhất trong hệ thống nhúng đó chính là
hệ điều hành Linux. Chính vì lí do đó mà nội dung trong bộ tài liệu này sẽ xoay
quanh việc tìm hiểu và triển khai hệ điều hành Linux lên một hệ thống nhúng.
Ngoài ra, do tính chất quan trọng của mỗi ứng dụng, mỗi ngành công nghiệp
là khác nhau nên kéo theo việc phân chia sử dụng các thiết bị nhúng có OS cũng sẽ
chia thành hai loại:
Các thiết bị nhúng có sử dụng hệ điều hành thời gian thực (Real Time OS –
RTOS): RTOS là một hệ điều hành nhằm phục vụ các ứng dụng thời gian
thực, xử lí các tác vụ mà không bị chậm chễ . Được ứng dụng rất nhiều trong
các hệ thống có tính quan trọng. Như y tế, ô tô, máy bay, các dây chuyền sản
xuất. Lấy ví dụ như hệ thống túi khí trong xe ô tô . Nếu túi khí không được
bật ngay sau khi va chạm, dù chỉ chậm vài chục mili giây thôi cũng sẽ gây
nguy hiểm cho người lái xe.
Các thiết bị nhúng không phải là thời gian thực (non-RTOS): Là các thiết bị
phục vụ cho các ứng dụng thông thường. Dù tốc độ phản hồi có thể sai số
đâu đó một vài giây cũng không gây ảnh hướng nghiêm trọng tới hệ thống
hay người sử dụng.
a) Bootloader.
Bootloader hay còn gọi là bộ nạp khởi động. Một bootloader có thể mọi
người đã biết tới đó chính là BIOS trên Windows. Bootloader hiện đang
được sử dụng rộng rãi trong các hệ thống nhúng Linux có tên là U-boot.
Chức năng chính của U-boot đó chính là khởi tạo các thành phần phần cứng
cần thiết và load các thành phần khác của OS lên RAM như Linux Kernel,
Rootfs vv… Không như các bootloader khác. U-boot được dành riêng cho
các thiết bị nhúng. Tại sao lại như vậy? Có thể kể ra một số các ưu điểm như
sau:
Keep it Fast : Một điều mà user cũng như developer mong muốn rằng
thiết bị của mình phải được khởi động thật nhanh. U-boot đảm bảo điều
này bằng cách loại bỏ những thứ không cần thiết. Chỉ khởi tạo khi cần.
Keep it Debuggable : U-boot hỗ trợ debug qua JTAG hoặc DBM.
Keep it Small : Khi start hệ thống. U-boot sẽ được load vào Internal
RAM. Bộ nhớ Internal RAM là rất giới hạn nên chương trình tải vào nó
phải nhỏ nhất có thể.
Keep it Open : Điều mà cộng đồng Opensource luôn yêu thích.
Trên đây là 4 trong 10 ưu điểm của U-boot so với các bootloader khác. Mọi
người có thể tìm hiểu thêm với keyword The 10 Golden rules of U-boot
design.
Quá trình thực thi của U-boot (U-boot Process) có thể chia ra làm hai loại là
Two-Stage và Single-Stage. Dưới đây là mô tả của Two-Stage:
ROM Bootloader : Khi khởi động hệ thống chương trình đầu tiên được
khởi chạy không phải U-boot mà là BootRom. Rom code được ghi tại nơi
sản xuất và người dùng không thể sửa đổi.
Chức năng chính của BootRom đây chính là load SPL - Second Program
Loader - Chương trình tải phụ vào IRAM và excute nó.
Ngoài ra chúng ta có thể chọn device boot bằng các switch trên thiết bị
(SD card/ eMMC vv..).
SPL : Chương trình tải phụ. Nhiệm vụ chính của SPL đó chính là setup
một vài thành phần cần thiết như DRAM controler, eMMC ... Sau đó
load U-boot tới địa chỉ CONFIG_SYS_TEXT_BASE của RAM.
Hay nói ngắn gọn. Chức năng chính của SPL là để load được U-boot lên
RAM.
U-boot: Sau khi được load. U-boot sẽ thực hiện việc relocation. Di dời
đến địa chỉ gd->relocaddr của RAM (Thường là địa chỉ cuối của RAM)
và nhảy đến mã của U-boot sau khi di dời.
Tiếp đến U-boot thực hiện load Kernel. Có thể từ nhiều device khác
nhau.Tùy theo cấu hình rồi sau đó trao quyền lại cho Kernel.
Tại sao lại phân chia ra Single-Stage/Two-Stage, thêm SPL vào làm gì,
sao không load thẳng U-boot vào IRAM ngay từ đầu đi?
Một trong các lí do có thể kể tới đó chính là phụ thuộc vào từng nhà sản xuất
và phần cứng. Có phần cứng chỉ cần sử dụng mã ROM là đã có thẻ load và
khởi động U-boot. Tuy nhiên một số thiết bị khác yêu cầu phải sử dụng đến
SPL.
Nguyên nhân chính đó chính là do sự giới hạn về IRAM. Giá thành của nó
không hề rẻ. Mà với tiêu chí của người dùng - Rẻ là đã tốt rồi nên giải pháp
của nhà sx đó chính là tăng code và giảm IROM.
Ở các giai đoạn trước của Boot-loader (ROM code or SPL). Chúng sẽ tải U-
boot lên RAM mà không hề biết trước kế hoạch cho các vùng nhớ mà U-
boot có thể tải lên là : bản thân U-boot, kernel-image, kernel-image
uncompress...
Nó đơn giản load U-boot lên RAM ở một địa chỉ thấp. Sau đó khi u-boot
thực hiện một số khởi tạo cơ bản và phát hiện hiện tại nó không nằm ở vị trí
được lập kế hoạch, chức năng relocation di chuyển U-boot đến vị trí đã lên
kế hoạch và nhảy tới nó.
Bản chất việc relocation là để đảm bảo cho U-boot, kernel-image, rootfs ...
khi load lên RAM sẽ không bị ghi đè lên nhau. Mà được load vào một vị trí
tính toán từ trước.
Để trả lời cho câu hỏi này chúng ta sẽ xem xét hình 2.1.
Filesystem của hệ điều hành Linux được tổ chức theo tiêu chuẩn cấp bậc của
hệ thống tập tin Filesystem Hierarchy Standard ( FHS ). Tiêu chuẩn này
định nghĩa mục đích của mỗi thư mục.
Dưới đây là cấu trúc cây thư mục trong Linux:
Thư
mục Chức năng
/bin Các chương trình cơ bản
/boot Chứa nhân Linux để khởi động và các file system maps cũng như
các file khởi động giai đoạn hai.
/dev Chứa các tập tin thiết bị (CDRom, HDD, FDD….).
/etc Chứa các tập tin cầu hình hệ thống.
/home Thư mục dành cho người dùng khác root.
/lib Chứa các thư viện dùng chung cho các lệnh nằm trong /bin và
/sbin. Và thư mục này cũng chứa các module của kernel.
/mnt Mount point mặc định cho những hệ thống file kết nối bên ngoài.
hoặc
/media
/opt Thư mục chứa các phần mềm cài thêm.
/sbin Các chương trình hệ thống
/srv Dữ liệu được sử dụng bởi các máy chủ lưu trữ trên hệ thống.
/tmp Thư mục chứa các file tạm thời.
/usr Thư mục chứa những file cố định hoặc quan trọng để phục vụ tất cả
người dùng.
/var Dữ liệu biến được xử lý bởi daemon. Điều này bao gồm các tệp
nhật ký, hàng đợi, bộ đệm, bộ nhớ cache,…
/root Các tệp cá nhân của người quản trị (root)
/proc Sử dụng cho nhân Linux. Chúng được sử dụng bởi nhân để xuất dữ
Thư
mục Chức năng
liệu sang không gian người dùng.
Tất cả hệ điều hành Linux thì tên các filesystem điều phân biệt chữ hoa chữ
thường.
d) Toolchain.
Toolchain là một tập hợp các công cụ lập trình được sử dụng trong nhiệm vụ
phát triển, tạo mới các sản phẩm phần mềm.
Một toolchain cơ bản ít nhất sẽ bao gồm các thành phần:
Bunitils: Là tập hợp các tool như as (assembler), ld (linker), objcopy vv..
Compiler: C/C++ compiler.
Standard C library: Là các API dựa theo chuẩn POSIX, các ứng dụng sẽ
sử dụng các libs này để giao tiếp với kernel.
Chúng ta có thể tự build ra toolchain dành riêng cho platform của mình từ
source code sử dụng các build system như Buildroot hoặc Yocto Project
hoặc cũng có thể trực tiếp toolchain mà vendor cung cấp.
Bản thân Toolchain sẽ được chia thành 2 loại dựa theo mục đích sử dụng, đó
là:
Native Toolchain: Lấy ví dụ với file hello.c in ra thông điệp “hello
world” với hệ thống. Thiết bị mà chúng ta muốn sử dụng để in ra thông
điệp này là raspberry pi 3b+ có kiến trúc ARM. Một native toolchain là
toolchain sẽ chạy trực tiếp trên thiết bị rpi3b+ này. Sau khi biên dịch
xong file source code cho ra được file executable (file thực thi) chạy
được trên rpi3b+ và in thông điệp “hello world”.\
Cross Toolchain: Một cross toolchain sẽ biên dịch file hello .c trên laptop
hay PC (có kiến trúc x86) mà chúng ta sử dụng hằng ngày ra file
executable. File thực thi được biên dịch xong sẽ không chạy được trên
laptop hay PC của chúng ta (có kiến trúc x86) mà sẽ chạy được trên thiết
bị rpi3b+ và in thông điệp “hello world”.
Biên dịch lại nhân để nâng cao hiệu năng của nhân. Theo mặc định, các
bản phân phối Linux (Distro) thường kèm một phiên bản nhân được biên
dịch với hầu hết những thành phần có sẵn để có thể đáp ứng rộng rãi cấu
hình phần cứng (có thể hiện diện trên các máy). Lấy ví dụ nôm na rằng với
một nhân Linux được biên dịch đầy đủ các thành phần sẽ giúp cho một
Distro Linux chạy được trên đa dạng nền tảng phần cứng. Lấy ví dụ như
HP, ASUS, DELL vv… . Tuy nhiên, nếu chúng ta nắm được các thành
phần cấu thành trên một máy tính cụ thể như HP (sound card, graphic card,
network card vv…) thì không có lý do gì phải bao gồm trọn bộ những
thành phần không cần thiết và không dùng đến.
Biên dịch lại nhân để loại bỏ những "drivers" không được dùng và có thể
gây "hiểu lầm" cho nhân.
Biên dịch lại nhân để thử nghiệm một chức năng hoặc một module mình
vừa tạo ra. Có thể là một app hay một driver cụ thể nào đó.
Phiên bản của nhân Linux có quy ước rất đơn giản và dễ nhớ. Vấn đề này cần
nắm rõ trước khi chọn một phiên bản nào đó của nhân Linux để vá và biên dịch.
Phiên bản của nhân Linux bao gồm ba nhóm số tách ra bởi các dấu chấm. Ví dụ:
5.4.44
Số thứ nhất: 5 là số hiệu phiên bản chính.
Số thứ hai: 4 là số chỉ định cho tình trạng của phiên bản . Nếu số này là số
chẵn, nó chỉ định cho phiên bản ổn định (stable), có thể dùng cho môi
trường production. Nếu số này là số lẻ, nó chỉ định cho phiên bản không
ổn định, nó thường dùng trong môi trường đang phát triển (development) .
Các kernel thuộc dạng này thường có nhiều lỗi và không ổn định . Không
nên dùng phiên bản đang phát triển cho môi trường production.
Số thứ ba: 44 là chỉ định cho số hiệu phát hành của một phiên bản nhân
Linux. Một phiên bản ổn định của một nhân Linux có thể có nhiều số hiệu
phát hành khác nhau.
Điều cần nắm ở đây là chỉ nên xử dụng phiên bản ổn định (stable) của nhân
Linux (số chẵn ở giữa) cho môi trường production và dùng phiên bản thử nghiệm
của nhân Linux (số lẻ) cho môi trường thử nghiệm và phát triển.
Có hai phương pháp chính để xây dựng nhân Linux. Chúng ta có thể xây dựng
cục bộ trên Raspberry Pi (local building), điều này sẽ mất nhiều thời gian; hoặc
chúng có thể biên dịch chéo (cross compile), nhanh hơn nhiều nhưng yêu cầu nhiều
thiết lập hơn.
a) Local Building.
Trên Raspberry Pi, trước tiên hãy cài đặt phiên bản mới nhất của
Raspberry Pi OS đó chính là hệ điều hành Raspbian . Sau đó khởi động Pi của
bạn, cắm Ethernet để cấp cho bạn quyền truy cập vào các nguồn và đăng
nhập.
Tiếp theo, chúng ta sẽ tải xuống mã nguồn thông qua việc sử dụng git.
git clone --depth=1 https://github.com/raspberrypi/linux
Để tải xuống một nhánh khác hãy sử dụng option --branch.
git clone --depth=1--branch <branch>
https://github.com/raspberrypi/linux
Trong đó <branch> là tên của nhánh mà bạn muốn tải xuống . Tham khảo
kho lưu trữ https://github.com/raspberrypi/linux để biết thông tin về các
nhánh có sẵn.
b) Cross-compiling.
Đầu tiên, chúng ta sẽ cần một máy chủ để thực hiện việc biên dịch chéo .
Lời khuyên ở đây là sử dụng Ubuntu; vì Raspbian cũng là một bản phân phối
Debian, nên có nghĩa là nhiều khía cạnh tương tự nhau, chẳng hạn như các
dòng lệnh.
Bạn có thể sử dụng Ubuntu thông qua việc cài đặt máy ảo trên Virtual Box
hoặc VMWare.
Sau khi cài đặt và khởi động máy chủ Ubuntu. Tương tự Local building.
Việc đầu tiên chúng ta cần làm đó là tải mã nguồn.
git clone --depth=1 https://github.com/raspberrypi/linux
git clone --depth=1--branch <branch>
https://github.com/raspberrypi/linux
2.3. Cấu hình nhân.
Việc cấu hình biên dịch nhân Linux thông qua một file ẩn đó là file
“.config”. Bên trong file có chứa các biến được set thành các giá trị Y (Yes),
N (No) hoặc M (Module). Các giá trị này được xử dụng trong quá trình biên
dịch; chúng dùng để xác định những gì không được biên dịch, những gì
được biên dịch và nếu được biên dịch thì sẽ theo dạng nào.
Biến được set với giá trị Y (Yes): Các modules tương ứng với biến được
build theo dạng built-in. Có nghĩa là modules sẽ được build ra và khởi
động cùng kernel trong quá trình boot (quá trình khởi động).
Biến được set với giá trị M (Modules). Các modules tương ứng với biến
được build theo dạng loadable module. Có nghĩa là modules sẽ được
build ra và load cùng kernel. Tuy nhiên sẽ không được chạy. Khi nào cần
thì sẽ được khởi chạy thủ công vào hệ thống.
Biến được set với giá trị N (No): Không được build.
a) Local Building.
Thực hiện cài đặt một số các packages cần thiết.
sudo apt install git bc bison flex libssl-dev make
Áp dụng cấu hình mặc định dành cho rpi2, rpi3/3b+.
cd linux
KERNEL=kernel7
make bcm2709_defconfig
Sau khi thực hiện xong lệnh make bcm2709_defconfig . Toàn bộ cấu
hình mặc định trong file bcm2709_defconfig sẽ được lưu vào file “.config”.
b) Cross-buiding.
Thực hiện cài đặt một số các packages cần thiết.
sudo apt install git bc bison flex libssl-dev make libc6-dev
libncurses5-dev crossbuild-essential-armhf
Áp dụng cấu hình mặc định cho rpi2, rpi3/3b+.
cd linux
KERNEL=kernel7
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
bcm2709_defconfig
Sau khi thực hiện xong lệnh make bcm2709_defconfig . Toàn bộ cấu
hình mặcđịnh trong file bcm2709_defconfig sẽ được lưu vào file “.config”.
Chú ý: Để tăng tốc độ xử biên dịch trên các hệ thống đa xử lí chúng ta
có thể sử dụng option -j n, với n là số luồng xử lí nhân với hệ số 1.5.
Đây là một module đơn giản với chức năng in ra thông điệp “Hello World
Kernel Modules” khi được cài đặt vào nhân Linux và in ra thông điệp “Say Good
Bye” khi bị loại bỏ ra khỏi nó.
c) Bước 3
Nội dung file Makefile được viết như sau:
Hình 2.4.3: Makefile.
Cờ -Wall cho phép trình biên dịch hiển thị tất cả các bản tin cảnh báo trong
quá trình biên dịch.
CONFIG_HELLO_WORLD có thể là y hoặc m tùy theo lựa chọn của bạn trong
menuconfig.
obj-y: chỉ rằng object file được chỉ định được biên dịch theo kiểu built-in.
obj-m: chỉ rằng object file được chỉ định được biên dịch theo kiểu module.
d) Bước 4
Nội dung file Kconfig được viết như sau:
f) Bước 6
Cùng nhìn xem giao diện menuconfig mà chúng ta thu được.
Gõ lệnh:
make ARCH=arm menuconfig
Sau khi build nhân Linux thành công, chúng ta cần sao chép chúng vào rpi3b+
và cài đặt các modules. Điều này tốt nhất nên thực hiện trực tiếp thông qua đọc đọc
thẻ SD.
Đầu tiên, sử dụng lệnh lsblk sau khi cắm đầu đọc thẻ nhớ của chúng ta vào
host. Kết quả nhận được sẽ trông như thế này:
Cuối cùng, copy kernel và dtbs vào trong SD card. Hãy đảm bảo sao lưu lại
kernel của bạn trước đó.
sudo cp mnt/fat32/$KERNEL.img mnt/fat32/$KERNEL-backup.img
sudo cp arch/arm/boot/zImage mnt/fat32/$KERNEL.img
sudo cp arch/arm/boot/dts/*.dtb mnt/fat32/
sudo cp arch/arm/boot/dts/overlays/*.dtb* mnt/fat32/overlays/
sudo cp arch/arm/boot/dts/overlays/README mnt/fat32/overlays/
sudo umount mnt/fat32
sudo umount mnt/ext4
Một lựa chọn khác, đó là chúng ta có thể backup nhân Linux tại cùng một
nơi, nhưng với một tên tệp khác – ví dụ: my-kernel .img. Điều này cho phép chúng
ta dễ dàng quay lại với nhân Linux cũ nếu nhân Linux mới hoạt động trục trặc.
Khác với chương trình, tiến trình (process) là một phiên bản đang chạy của
một chương trình. Ví dụ một chương trình helloworld.exe đang chạy trong máy
tính được gọi là một tiến trình. Tiến trình được gọi là thực thể chủ động (active
entity) để phân biệt với chương trình, vì nó sẽ được copy từ ổ cứng sang bộ nhớ
RAM để chạy trên hệ điều hành máy tính. Máy tính xây dựng một tiến trình từ một
chương trình, bằng cách copy chương trình đang nằm trên ổ cứng vào RAM, cấp
phát các tài nguyên cần thiết để xây dựng một tiến trình. Bạn có thể tạo được nhiều
tiến trình từ một chương trình, ví dụ như khi bạn double click để chạy chương trình
helloworld.exe nhiều lần.
Mỗi tiến trình trong hệ thống có một số định danh (process ID hay pid), là
một số
dương nhằm để xác định tiến trình đó là duy nhất trong hệ thống . Để theo dõi toàn
bộ tiến trình đang chạy chúng ta có thể dùng command “ps aux”.
Vì vậy, tiến trình con sẽ kế thừa toàn bộ các biến (bao gồm cả biến môi trường),
giá trị hiện tại của các biến, các mô tả file và stack frame của tiến trình cha. Sau lời
gọi fork(), 2 tiến trình sẽ đồng thời tồn tại và chúng sẽ tiếp tục chạy sau thời điểm
fork() return.
Câu hỏi đặt ra là sau fork() cả 2 tiến trình cha và con đồng thời đang tồn tại, vậy
làm cách nào để biết chúng ta đang lập trình cho tiến trình cha hay con? Việc phân
biệt này dựa trên giá trị return của fork(). Với tiến trình cha, fork() sẽ return 1 số
nguyên dương là PID của tiến trình con mà nó vừa tạo ra. Với tiến trình con, fork()
sẽ return giá trị 0, tiến trình con có thể lấy dược PID của mình bằng system call
getpid(). System call fork() trả về -1 khi bị lỗi.
Vì vậy, lập trình tạo ra 1 tiến trình mới bằng fork() thường có mẫu viết code
như sau:
Hình 2.8.1.3: Tạo một tiến trình mới dùng fork().
Việc sử dụng sleep() (trong process cha) trong chương trình cho phép tiến
con sẽ được lên lịch cho CPU trước tiến trình cha, để con có thể hoàn thành công
việc của nó và kết thúc trước khi tiến trình cha tiếp tục thực hiện . Đoạn mã dưới
đây là ví dụ.
Hình 2.8.1.3: Dùng sleep() với fork().
Sau khi chạy chương trình trên thì kết quả chúng ta thu được như sau:
System call wait () được gọi trong tiến trình cha chờ cho đến khi một trong các
tiến trình con của nó bị kết thúc và trả về trạng thái kết thúc của tiến trình con đó
vào con trỏ “status”. System call wati() được thực hiện như sau:
System call wait() được gọi trong tiến trình cha để chờ cho đến khi 1 trong các
tiến trình con của nó bị kết thúc và trả về trạng thái kết thúc của tiến trình con đó
vào con trỏ “status”.
Tại thời điểm wait() được gọi, nếu chưa có tiến trình con nào kết thúc, wait() sẽ
block cho đến khi có 1 tiến trình con bị kết thúc. Nếu có 1 tiến trình con đã kết
thúc từ trước khi wait() được gọi, nó sẽ return ngay lập tức. Nếu con trỏ “status”
không NULL, nguyên nhân kết thúc của tiến trình con sẽ được lưu vào số nguyên
mà “status” trỏ đến.
Khi bị lỗi, wait () trả về –1. Một lỗi có thể xảy ra là quá trình gọi không có tiến
trình con, được biểu thị bằng giá trị errno ECHILD. Điều này nghĩa là chúng ta có
thể sử dụng vòng lặp sau để đợi tất cả các tiến trình con kết thúc:
Hình 2.8.2.2: wait() error.
System call wait() tồn tại 1 số hạn chế khi nó chỉ có thể theo dõi 1 tiến trình bị
kết thúc tiếp theo trong số tất cả các tiến trình con, và sẽ block tiến trình nếu chưa
có tiến trình con nào bị kết thúc. Trong nhiều trường hợp chúng ta chỉ muốn theo
dõi 1 tiến trình cụ thể. Có 1 system call là waitpid() có thể đáp ứng yêu cầu này với
prototype như sau:
Nếu pid >0, chờ tiến trình con có định danh là pid
Nếu pid = 0, chờ bất kỳ tiến trình con nào nằm trong nhóm với tiến trình cha
Nếu pid < -1, chờ bất kỳ tiến trình con có process group ID (chúng ta sẽ học
ở bài sau) bằng giá trị tuyệt đối với pid. Ví dụ pid ==-200 thì sẽ chờ tiến
trình con có pid 200
Nếu pid == -1, chờ bất kỳ tiến trình con nào (giống với wait())
Đối số “options” là 1 bit mask có thể OR với 0 hoặc 1 trong các flag
WUNTRACED, WCONTINUED, WNOHANG.
Giá trị “status” chỉ ra nguyên nhân kết thúc của tiến trình con có thể rơi vào 1
trong các trường hợp sau:
Tiến trình con kết thúc bình thường bằng hàm exit() hoăc system call _exit()
Tiến trình con kết thúc vì nhận 1 signal.
Tiến trình con bị dừng bởi 1 signal
Tiến trình con tiếp tục bởi SIGCONT signal
Khi lập trình Linux, chúng ta có thể xác định giá trị “status” bằng các macro
được cung cấp bởi header <sys/wait.h> như dưới đây:
WIFEXITED(status): Return true nếu tiến trình con kết thúc bình thường
bằng exit() hoặc _exit()
WIFSIGNALED(status): Return true nếu tiến trình con kết thúc bởi 1 signal.
Trong trường hợp này có thể dùng thêm macro WTERMSIG(status) để trả
về số signal đã kết thúc tiến trình con và macro WCOREDUMP(status) để
trả về true nếu tiến trình sinh ra file cordump.
WIFSTOPPED(status): Return true nếu tiến trình con bị dừng bởi 1 signal.
Có thể dùng thêm macro WSTOPSIG(status) để trả về số signal đã dừng tiến
trình con.
WIFCONTINUED(status): Return true nếu tiến trình con được phục hồi bởi
signal SIGCONT.
c) Ví dụ.
Để minh họa rõ hơn về trạng thái của wait() chúng ta sẽ xét 1 chương trình đơn
giản sau đây:
Hình
2.8.2.4: System call waitpid() và wait().
Hình 2.8.2.5: wait() và “status”
Kết quả trên cho thấy tiến trình con đã bị kết thúc bởi 1 signal có số signal 6, và
file coredump đã được sinh ra.
Ngoài 2 system call wait() và waitpid(), chúng ta cũng có thể sử dụng một số
system call khác là:
waittid()
wait3()
wait4()
Các system call này không những có thể thực hiện đủ công việc của waitpid() mà
còn có thể cung cấp thêm 1 số thông tin về tài nguyên của tiến trình con. Chúng ta
không thể đi qua tất cả các system call liên quan đến chờ tiến trình con mà chỉ cần
hiểu bản chất của chúng là gì.
Orphan process: Dịch nôm na là tiến trình mồ côi, là trạng thái của tiến
trình con khi tiến trình cha kết thúc trong khi tiến trình con vẫn tồn tại. Lúc
này tiến trình con sẽ ở trạng thái “mồ côi”. Trong Linux, sau khi tiến trình
con “mồ côi”, tiến trình init vốn là tiến trình đầu tiên và là tiến trình “tổ tiên”
của mọi tiến trình sẽ nhận tiến trình con đó làm “con”. Lúc này, nếu gọi
system call getppid() trong tiến trình con, nó sẽ return giá trị “1” là PID của
tiến trình init.
Zombie process: Dịch tạm là tiến trình thây ma, là trạng thái của tiến trình
con khi nó kết thúc mà tiến trình cha chưa gọi wait(). Quan điểm của HĐH
Linux là khi tiến trình con kết thúc, thay vì biến mất hoàn toàn thì tiến trình
cha vẫn có thể gọi wait() để lấy trạng thái kết thúc của tiến trình con sau đó.
Vì vậy, kernel đã chuyển tiến trình con thành trạng thái zombie process. Lúc
này hầu hết các tài nguyên của tiến trình con đã được thu hồi và có thể sử
dụng cho các tiến trình khác nhưng vẫn còn một số thông tin cơ bản của tiến
trình con được giữ lại (PID, trạng thái kết thúc và một số chỉ số của tiến
trình). Những thông tin này sẽ được sử dụng để trả về khi tiến trình cha gọi
wait() và sau đó sẽ được giải phóng hoàn toàn bởi kernel.
Khi sử dụng command ps, tiến trình zombie sẽ được nhận biết bằng trạng thái
“Z+” hoặc ký hiệu <defunct>.
Tiến trình zombie đúng với cảm hứng của zombie trong các bộ phim chúng ta
thường xem: đã bị thu hồi tài nguyên (đã chết) nhưng vẫn chưa biến mất hoàn toàn
mà vẫn tồn tại (ở dạng thây ma). Vì đã là zombie, nên nó không thể bị giết lại lần
nữa và vẫn chiếm dụng tài nguyên PID. Lúc này chỉ còn cách là kill tiến trình cha
để tiến trình init nhận tiến trình thây ma là con, sau đó tiến trình init sẽ tự động gọi
wait() để giải phóng hoàn toàn tiến trình con. Tiến trình zombie thường không phải
là vấn đề lớn với hệ thống nếu chỉ có 1 vài cái, vì tài nguyên nó chiếm dụng là khá
ít. Tuy nhiên, vì nó vẫn chiếm dụng PID, nên nếu có quá nhiều tiến trình zombie
có thể làm đầy bảng PID của kernel và sẽ không thể tạo ra tiến trình mới.
e) Bài tập.
Bài 1: Viết 1 chương trình C fork ra cha và con, trong process cha in ra pid và
"I am parent", trong process con làm tương tự. So sánh PID in ra và PID sử dụng
lệnh PS.
Bài 2: Thử mở 1 file trước khi fork, sau đó cả cha và con cùng ghi vào file fd
trước đó và check kết quả.
Bài 3: Viết 2 chương trình cha và con. Chương trình cha gọi con với tham số
truyền vào là đường dẫn đến file. Chương trình con ghi hello world vào file và trả
về trạng thái kết quả cho cha. Cha phải biết kết quả thực hiện của chương trình
con.
Pipes – Giao tiếp giữa hai tiến trình liên quan. IPC sử dụng Pipe là
phương pháp bán song công (half duplex)- nghĩa là chỉ giao tiếp theo
một phía: tiến trình thứ nhất sẽ giao tiếp với tiến trình thứ hai. Để tiến
trình thứ hai có thể giao tiếp ngược lại với tiến trình thứ nhất (song
công), chúng ta phải sử dụng thêm một Pipe khác
Shared files (FIFO) giao tiếp giữa hai tiến trình riêng biệt. Giao tiếp
sử dụng FIFO là song công, tiến trình thứ nhất có thể giao tiếp với
tiếng trình thứ hai vào ngược lại
Shared memory- giao tiếp giữa nhiều tiến trình bằng cách sử dụng
chung một vùng bộ nhớ chung. Phương pháp này yêu cầu các tiến
trình truy cập vùng nhớ chung một cách đồng bộ
Message queues – giao tiếp giữa nhiều tiến trình song công. Các tiến
trình sẽ giao tiếp với nhau bằng cách gửi một bản tin và nhận nó thông
qua hàng đợi (queue). Bản tin đã đc nhận bới một tiếng trình sẽ bị xóa
khỏi hàng đơi.
Signal – tiến trình nguồn sẽ gửi một signal (phân biệt bằng số thứ tự
định sẵn) để tiến trình đích tương ứng sẽ xử lý.
Socket - tiến trình client kết nối đến tiến trình server để yêu cầu trao
đổi dữ liệu. Có thể hiểu socket như là một điểm đầu cuối giữa hai tiến
trình. Khi kết nối được thiết lập hai bên có thể trao đổi dữ liệu.
Có rất nhiều cách khác nhau để test minh họa giao tiếp giữa các tiến trình
sau khi chạy, trong đó có 2 cách phổ biến nhất được sử dụng trong các ví dụ của
bài viết này:
Sử dụng terminal để chạy một tiến trình sau đỏ sử dụng một terminal
khác thực hiện việc tương tự
Sử dụng hàm hệ thống (system function) fork để sinh ra một tiến trình
con (child process) từ một tiến trình cha (parent process)
Demo IPC thông qua việc sử dụng socket. Chúng ta sẽ tìm hiểu thêm một vài khái
niệm về nó.
Có 2 loại socket được sử dụng rộng rãi là: stream sockets và datagram sockets.
Stream sockets: Dựa trên giao thức TCP (Tranmission Control Protocol), là
giao thức hướng luồng (stream oriented). Việc truyền dữ liệu chỉ thực hiện giữa 2
tiến trình đã thiết lập kết nối. Giao thức này đảm bảo dữ liệu được truyền đến nơi
nhận một cách đáng tin cậy, đúng thứ tự nhờ vào cơ chế quản lý luồng lưu thông
trên mạng và cơ chế chống tắc nghẽn.
Datagram sockets: Dựa trên giao thức UDP (User Datagram Protocol), là
giao thức hướng thông điệp (message oriented). Việc truyền dữ liệu không yêu cầu
có sự thiết lập kết nối giữa tiến quá trình. Ngược lại với giao thức TCP thì dữ liệu
được truyền theo giao thức UDP không được tin cậy, có thế không đúng trình tự và
lặp lại. Tuy nhiên vì nó không yêu cầu thiết lập kết nối không phải có những cơ
chế phức tạp nên tốc độ nhanh…ứng dụng cho các ứng dụng truyền dữ liệu nhanh
như chat, game…..
Mô hình lập trình socket TCP giữa 2 tiến trình client và server như sau:
while (1) {
memset(sendbuff, '0', BUFF_SIZE);
memset(recvbuff, '0', BUFF_SIZE);
if (strncmp("exit", sendbuff, 4) == 0) {
printf("Client exit ...\n");
break;
}
if (strncmp("exit", recvbuff, 4) == 0) {
printf("Server exit ...\n");
break;
}
close(server_fd); /*close*/
}
memset(&serv_addr, '0',sizeof(serv_addr));
/* Tạo socket */
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1)
handle_error("socket()");
chat_func(server_fd);
return 0;
}
#define LISTEN_BACKLOG 50
#define BUFF_SIZE 256
#define handle_error(msg) \
do { perror(msg); exit(EXIT_FAILURE); } while (0)
while (1) {
memset(sendbuff, '0', BUFF_SIZE);
memset(recvbuff, '0', BUFF_SIZE);
if (strncmp("exit", recvbuff, 4) == 0) {
system("clear");
break;
}
if (strncmp("exit", sendbuff, 4) == 0) {
system("clear");
break;
}
sleep(1);
}
close(new_socket_fd);
}
/* Tạo socket */
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1)
handle_error("socket()");
// fprintf(stderr, "ERROR on socket() : %s\n", strerror(errno));
while (1) {
printf("Server is listening at port : %d \n....\n",port_no);
new_socket_fd = accept(server_fd, (struct sockaddr*)&client_addr,
(socklen_t *)&len);
if (new_socket_fd == -1)
handle_error("accept()");
system("clear");
char temp[BUFF_SIZE];
chat_func(new_socket_fd);
}
close(server_fd);
return 0;
}
Biên dịch hai file trên và chạy trên hai terminal khác nhau như dưới đây:
2.8.3. Luồng.
Thread là một cơ chế cho phép một ứng dụng thực thi đồng thời nhiều công
việc (multi-task). Ví dụ một trường hợp đòi hỏi multi-task sau: một tiến trình web
server của một trang web giải trí phải phục vụ hàng trăm hoặc hàng nghìn client
cùng một lúc. Công việc đầu tiên của tiến trình là lắng nghe xem có client nào yêu
cầu dịch vụ không. Giả sử có client A kết nối yêu cầu nghe một bài nhạc, server
phải xử lý chạy bài hát client A yêu cầu; nếu trong lúc đó client B kết nối yêu cầu
tải một bức ảnh thì server lúc đó không thể phục vụ vì đang bận phục vụ client A .
Đây chính là kịch bản yêu cầu một tiến trình cần thực thi multi-task . Qua các bài
học về process, ta thấy tiến trình server nói trên có thể giải quyết bài toán này
như sau: Server chỉ làm công việc chính là lắng nghe xem có client nào yêu cầu
dịch vụ không; khi tiến trình A kết nối, server dùng system call fork() để tạo ra một
tiến trình con chỉ làm công việc client A yêu cầu, trong khi nó lại tiếp tục lắng
nghe các yêu cầu từ các client khác. Tương tự, khi client B kết nối, server lại tạo ra
một tiến trình con khác phục vụ yêu cầu của client B . Trong bài học này, chúng ta
sẽ thấy việc xử lý đa tác vụ của server như trên có thể dùng thread. Thậm chí trong
trường hợp này thread còn tỏ ra thích hợp hơn so với việc sử dụng các tiến trình
con như ở trên.
Thread là một thành phần của tiến trình, một tiến trình có thể chứa một hoặc
nhiều thread. Hệ điều hành Unix quan niệm rằng mỗi tiến trình khi bắt đầu chạy
luôn có một thread chính (main thread); nếu không có thread nào được tạo thêm thì
tiến trình đó được gọi là đơn luồng (single-thread), ngược lại nếu có thêm
thread thì được gọi là đa luồng (multi-thread). Các thread trong tiến trình chia sẻ
các vùng nhớ toàn cục (global memory) của tiến trình bao gồm initialized data,
uninitialized data và vùng nhớ heap.
Quay trở lại ví dụ trên, tiến trình server tạo ra các tiến trình con để phục vụ yêu
cầu multi-task. Cách này tuy giải quyết được yêu cầu nhưng tồn tại các hạn chế sau
đây:
Việc chia sẻ dữ liệu giữa các tiến trình khá khó khăn. Vì mỗi tiến trình trong
Linux có không gian bộ nhớ riêng biệt nên chúng ta phải sử dụng các
phương pháp giao tiếp liên tiến trình (IPC) như shared memory, message
queue, socket... để chia sẻ dữ liệu.
Tạo ra một tiến trình mới bằng system call fork() khá "tốn kém" về mặt tài
nguyên cũng như thời gian vì phải tạo ra các vùng nhớ riêng biệt cho tiến
trình con. Điều này khá quan trọng trong các hệ thống embedded có phần
cứng bị hạn chế.
Thread có thể giải quyết được 2 vấn đề trên vì có các ưu điểm sau:
Chia sẻ dữ liệu giữa các thread trong tiến trình rất đơn giản vì chúng sử có
chung không gian bộ nhớ toàn cục. Do vậy, chỉ cần tạo dữ liệu ở trong
các vùng nhớ toàn cục này thì các thread đều có thể truy xuất được.
Việc tạo ra một thread mới nhanh hơn đáng kể so với việc tạo ra một tiến
trình mới vì các thread dùng chung nhiều phần không gian bộ nhớ nên chỉ
cần tạo không gian bộ nhớ cho những phần riêng thay vì phải nhân bản toàn
bộ các vùng nhớ như khi tạo tiến trình con.
Hiển nhiên thread cũng không phải là chìa khóa vạn năng. Việc sử dụng thread
cũng có các nhược điểm sau:
Vì các thread dùng chung vùng nhớ toàn cục nên việc lập trình trên các
thread "nguy hiểm" hơn trên process. Nếu một thread gây ra lỗi trên vùng
nhớ toàn cục thì sẽ kéo theo các thread khác cũng bị lỗi theo.
Các thread cùng chia sẻ vùng nhớ toàn cục của một tiến trình (3 GB với hệ
thống 32 bit), cụ thể mỗi thread sẽ được cung cấp một vùng nhớ riêng trong
tổng thể bộ nhớ của tiến trình. Bộ nhớ của tiến trình tuy là lớn nhưng cũng là
một số hữu hạn. Nên một tiến trình cũng bị giới hạn bởi số lượng thread có
thể tạo ra hoặc tạo ra các thread cần bộ nhớ lớn.
Cả hai nhược điểm trên không xảy ra trên tiến trình vì mỗi tiến trình có không
gian bộ nhớ riêng.
Quay lại thời điểm sơ khai của thread, khi đó mỗi nhà cung cấp phần cứng triển
khai thread và cung cấp các API để lập trình thread của riêng mình. Điều này gây
khó khăn cho các developer khi phải học nhiều phiên bản thread và viết 1 chương
trình thread chạy đa nền tảng phần cứng. Trước nhu cầu xây dựng một giao diện
lập trình thread chung, tiêu chuẩn POSIX Thread (pthread) cung cấp các giao diện
lập trình thread trên ngôn ngữ C/C++ đã ra đời.
Trước khi bắt tay vào khám phá các API của pthread, chúng ta cần lướt
qua một số kiểu dữ liệu pthread định nghĩa riêng dưới đây:
Kiểu dữ liệu Mô tả
pthread_t Số định danh của thread (threadID)
pthread_mutex_t Mutex
pthread_mutexattr_t Thuộc tính của mutex
pthread_cond_t Biến điều kiện
pthread_condattr_t Thuộc tính của biến điều kiện
pthread_key_t Khóa cho dữ liệu của thread
pthread_attr_t Thuộc tính của thread
như sau:
Hình 2.8.4.1: pthread_create().
b) Thread ID.
Mỗi thread trong tiến trình có 1 số định danh duy nhất là thread ID. Thread
ID trong Posix có kiểu dữ liệu là pthread_t, chính là giá trị pthread_t
*thread được gán vào trong hàm pthread_create() ở trên. Ta có thể kiểm tra được
thread ID của thread đang chạy bằng hàm pthread_self() với prototype như sau:
Hình 2.8.4.2: pthread_self().
Hàm sẽ trả về thread ID của thread đang gọi. Thread ID có thể được dùng khi
lập trình viên muốn tác động vào thread đó như join thread (sẽ học dưới đây), kill
thread …
Hàm bắt đầu của thread thực thi câu lệnh return
Một hàm bất kỳ trong thread gọi hàm pthread_exit(), chúng ta sẽ nói về hàm
này ở dưới đây
Một thread bất kỳ của tiến trình gọi hàm exit() hoặc thread chính của tiến
trình (hàm main()) gọi return. Cả 2 cách này đều có tác dụng kết thúc tiến
trình đang chạy và tất nhiên cả các thread của tiến trình đó.
Việc gọi hàm pthread_exit() có tác dụng giống với việc gọi return của hàm
bắt đầu của thread đó, chỉ khác là pthread_exit() có thể gọi từ bất kỳ hàm nào
trong thread còn return bắt buộc phải gọi ở hàm bắt đầu của thread. Đối số retval là
giá trị return của hàm. Lưu ý rằng hàm pthread_exit() không return giá trị nào cho
hàm gọi nó.
d) Ví dụ.
Sau khi nắm được lý thuyết cơ bản của pthread cùng với 2 hàm pthread_create()
và pthread_exit(), ta có thể viết được 1 chương trình sử dụng pthread. Để sử dụng
các hàm API của Posix thread, chương trình cần khai báo thư viện pthread.h.
Chương trình ví dụ dưới đây tạo ra 1 thread mới trong hàm main(), hàm bắt
đầu của thread là printHello() sẽ in ra màn hình dòng chữ “Hello World! This is
entry point of thread!”. Vì hàm bắt đầu này không cần đối số truyền vào nên tham
số “void *arg” sẽ truyền vào NULL:
Lưu chương trình với tên gọi pthread.c và compile chương trình với option
“pthread” như sau:
Bây giờ chạy chương trình, bạn sẽ thấy output của chương trình như sau:
Trong chương trình trên, hàm bắt đầu printHello() không truyền đối số. Giả
sử bạn cần viết 1 chương trình cần truyền đối số thì sẽ phải xây dựng một struct để
lưu các đối số đó và truyền địa chỉ vào đối số “void *arg” trong hàm
pthread_create().
Để hiểu rõ hơn, chúng ta xét ví dụ khác: tạo ra 1 thread mới cũng với hàm
bắt đầu là printHello() nhưng đối số “thr” có kiểu dữ liệu struct thread_args được
truyền vào. Lưu ý trong hàm pthread_create(), đối số của hàm printHello() quy
định kiểu void*, nên trong hàm printHello() ta cần khai báo args kiểu void*, sau đó
ép về kiểu thread_args sau:
Bây giờ compile chương trình và chạy, chúng ta sẽ được output như sau:
Hình 2.8.4.5: ./pthread .
d) Bài tập.
Bài 1: Viết 1 chương trình C tạo ra 3 thread, các thread lần lượt được tăng 1
biến chung thêm một đơn vị và ghi giá trị mới vào 1 trong 2 file output . Sử dụng
semaphore để đồng bộ việc ghi vào 2 file
Bài 2: Tạo 10 ma trận kích thước 200x200. Nhân 10 ma trận đó với nhau sử
dụng multi thread.
Bài 3: Viết 1 chương trình C tạo 10 file, mỗi file chứa 5 triệu số ngẫu nhiên có
1 chữ số. Sử dụng multithread và không multithread rồi so sánh thời gian.
2.9. Tổng kết chương