You are on page 1of 53

Chương 2: Xây dựng hệ thống nhúng Linux

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.

2.1. Quy trình xây dựng hệ thống nhúng Linux.


Một hệ thống nhúng thông thường sẽ bao gồm các thành phần sau:
 Vi xử lí trung tâm: Là các chip vi xử lí 32 bit, các vi xử lýđóng vai trò là bộ
xử lý trung tâm trong hệ thống nhúng. Ngày nay cùng với sự phát triển của
ngành công nghiệp điện tử, nhiều hãng điện tử cho ra đời các chip vi xử lý
32 bit với nhiều tính năng tích hợp phục vụ trong hệ thống nhúng như
Renesas với các chip họ SH, AMCC với PowerPC, Cirrus Logic với ARM7,
ARM9, Atmel…
 Bộ nhớ: bao gồm bộ nhớ RAM, EEPROM hay Flash ROM.
 Các ngoại vi: Bao gồm các giao tiếp IO như USB, Ethenet, I2C, SPI … Tùy
vào các mục đích yêu cầu của hệ thống khác nhau và sẽ được thiết kế các
ngoại vi
khác nhau, trong đó sẽ có một số các ngoại vi chung như usb, ethenet, serial .
Các
ngoại vi này vừa đảm nhiệm chức năng giao tiếp trong hệ thống vừa làm
nhiệm vụ
nạp phần mềm cho hệ thống hay gỡ rối hệ thống.
 Phần mềm cho hệ thống nhúng: Phần mềm là các chương trình đảm nhiệm
khởi động và điểu khiển các hoạt động của hệ thống nhúng . Một hệ điều
hành cũng được coi là một phần mềm trên hệ thống nhúng, có thể liệt kê
được một số hệ điều hành dành riêng cho hệ thống nhúng như Linux,
Window IOT, QNX … Việc phát triển một hệ thống nhúng Linux nói riêng
sẽ song song với việc cung cấp các bộ phần mềm chạy trên nó . Bao gồm 4
thành phần quan trọ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.

Một Số Câu Hỏi Có Thể Đặt Ra:

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.

Tại sao phải thực hiện Relocation ?

Ở 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.

U-boot thực sự ở đâu ?

Để trả lời cho câu hỏi này chúng ta sẽ xem xét hình 2.1.

Hình 2.1: U-boot memory map.


b) Linux Kernel.
Là thành phần quan trọng nhất của hệ thống . Được chia thành nhiều thành
phần khác nhau. Mỗi một thành phần sẽ đảm nhận một chức năng cụ thể
trong hệ thống như quản lí bộ nhớ, quản lí thiết bị, quản lí tiến trình vv…
File chứa kernel sẽ là zimage (nén tự giải) và uimage (phần header hỗ trợ
boot từ U-boot) mà thông thường chúng ta sẽ gọi tắt là Image.
Ban đầu U-boot sẽ load kernel lên RAM và truyền các tham số khởi động
cần thiết cho nó.
Khái niệm Board Support Package, hay thường được gọi là bộ BSP bao gồm
bootloader và một hệ điều hành linux hoàn chỉnh mà có thể chạy được trên
con chip đó. Thông thường chúng ta sẽ bán bộ BSP cho khách hàng – những
người sẽ phát triển các application trên BSP của chúng ta.
Một ưu điểm của các hệ điều hành linux đó chính là các thành phần của một
OS sẽ được triển khai dưới dạng một kernel module. Do đó, tùy vào nhu cầu
sử dụng mà bản thân chúng ta hay các lập trình viên có thể lựa chọn hoặc
loại bỏ các thành phần không cần thiết khỏi hệ thống, giúp cho hệ thống nhỏ
gọn và nhanh hơn.
c) Rootfs.
Hay còn được biết đến như hệ thống tệp tin của Linux . Giúp quản lý cách dữ
liệu được đọc và ghi trên thiết bị . Cho phép người dùng truy cập nhanh
chóng và an toàn nhất có thể.
Có thể liệt kê các loại filesystem phố biết trên Linux như:

 Filesystem cơ bản: EXT2, EXT3, EXT4, XFS, Btrfs, JFS, NTFS,…


 Filesystem dành cho dạng lưu trữ Flash: thẻ nhớ,…
 Filesystem dành cho hệ cơ sở dữ liệu
 Filesystem mục đích đặc biệt: procfs, sysfs, tmpfs, squashfs, debugfs,…

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:

Hình 2.2: Hệ thống tệp tin Linux.


Linux dùng ký tự ‘/’ để tách các đường dẫn ( khác với Windows sử dụng “\”
để tách các đường dẫn) tất cả các tập tin thư mục điều được bắt đầu từ thư
mục gốc ( / ), cũng không có kí tự ổ đĩa giống như Windows.
Các thư mục được mô tả như sau:

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”.

2.2. Lựa chọn nhân.


Các hướng dẫn sau đây sẽ mô tả một cách chi tiết các bước cấu hình, biên dịch
và cài đặt nhân Linux. Với các thao tác mô phỏng dựa trên việc cài đặt cho thiết bị
Raspberry Pi 3 model B+ (rpi3b+). Thông tin chi tiết các bạn có thể tham khảo trên
main page của Raspberry Pi.
Trước khi đi vào từng bước được đề cập ở trên, chúng ta hãy cùng nhau tìm
hiểu vì sao cần phải biên dịch lại nhân Linux và lợi ích của việc biên dịch lại này là
gì.
Đối với người dùng đã quen với những hệ điều hành "đóng" (close source) thì
khái niệm biên dịch lại nhân là một khái niệm khá lạ lẫm . Điều này cũng dễ hiểu vì
kernel của các hệ điều hành "đóng" hiển nhiên là "đóng" và người dùng bình
thường không thể có cơ hội tiếp cận với mã nguồn của nhân để có thể biên dịch lại
nhân nếu muốn. Trong khi đó, mã nguồn của nhân Linux hoàn toàn "mở" (open
source) và đây là điều kiện rất thuận lợi cho vấn đề biên dịch lại nhân . Câu hỏi
được đặt ra là tại sao lại cần phải biên dịch lại nhân Linux ? Sau đây là một số
trường hợp thường gặp nhất:
 Tái biên dịch kernel để chữa lỗi của nhân. Nếu các lỗi này thuộc về lõi của
nhân thì phải vá nguồn của nhân và biên dịch lại nó để sửa chữa các lỗi
được công bố.

 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.

Hình 2.3.1: File “.config”.


Linux kernel còn cung cấp cho chúng ta một số các công cụ dùng để thực
hiện cấu hình. Bản chất các công cụ này sẽ ghi lại các thay đổi do chúng ta lựa
chọn vào file “.config”. Có ba công cụ dùng để cấu hình phổ biến đó là:
 make config: make config là phương tiện đơn giản nhất và không đòi hỏi
thêm bất cứ thư viện nào khác để chạy công cụ này. make config sẽ đưa ra
một loạt câu hỏi và sau khi nhận được câu trả lời của bạn (Y, N, M như đã nói
ở trên sau khi bạn nhấn phím Enter, xác nhận câu trả lời của mình), nó sẽ hình
thành một cấu hình biên dịch nhân Linux. Nhược điểm của phương tiện này là
ở chỗ, nếu bạn lỡ trả lời sai (chọn Y, N hoặc M và gõ phím Enter), bạn không
thể quay ngược lại để điều chỉnh mà phải bắt đầu lại từ đầu.

Hình 2.3.2: make config.


 make menuconfig: Đây là công cụ tôi ưa thích và hay sử dụng nhất. Công cụ
này cần thư viện và các binaries "ncurses" để tạo giao diện đồ họa (GUI) đơn
giản. Với công cụ này, bạn có thể điều chỉnh lại các chi tiết tuỳ thích mà
không phải bắt đầu lại từ đầu (nếu lỡ chọn sai) như dùng make config. Với
giao diện đơn giản này, bạn có thể di chuyển, thay đổi các chọn lựa bằng cách
dùng các phím mũi tên (lên xuống), chọn Y bằng phím Y, chọn N bằng phím
N và chọn M bằng phím M. Với công cụ này, bạn cũng có thể tải một cấu
hình biên dịch nhân có sẵn (đã làm từ trước và đã biên dịch thành công chẳng
hạn) mà chẳng phải đi xuyên qua mọi chọn lựa để hình thành một cấu hình
biên dịch nhân mới. Một đặc tính của công cụ này là nó chứa "trợ giúp ngữ
cảnh" (phần giúp đỡ hoặc thông tin cho từng mục trong quá trình điều chỉnh
cấu hình). Nếu bạn không nắm rõ giá trị hoặc tác dụng của module nào đó, bộ
phận trợ giúp này chắc chắn sẽ hữu ích.

Hình 2.3.3: menuconfig.


 make xconfig: có lẽ là phương tiện được dùng rộng rãi nhất cho những hệ
thống chạy X Window. make xconfig cần trọn bộ thư viện Qt và X
Window để tạo các giao diện đồ họa . Các chọn lựa và cách di chuyển
trong giao diện này hoàn toàn giống như trường hợp dùng menuconfig và
thêm một khả năng nữa là có thể dùng chuột để chọn. Nếu bạn cần biên
dịch lại nhân và có thể dùng X Window thì nên dùng công cụ xconfig
này vì nó dễ dùng nhất.

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.

2.4. Tối giản, thay đổi, cải tiến mã nguồn nhân


Có lẽ vấn đề về tối ưu, thay đổi, cải tiến mã nguồn nhân thường là câu hỏi
được đặt ra nhiều nhất trong quá trình làm việc với nhân Linux . Thực ra quá trình
này rất đơn giản nếu bạn biết tiếp cận đúng hướng. Tuy nhiên, để cho hệ thống có
thể hoạt động ổn định thì chúng ta cần mất nhiều thời gian để thực nghiệm thực
tế vì bất cứ một thay đổi nhỏ nào trên mã nguồn cũng có thể làm hỏng toàn bộ hệ
thống.
Để hiểu được tường tận quá trình này chúng ta sẽ cùng nhau tìm hiểu về
build system của nhân Linux và thực hiện thêm module driver có tên là “hello”
vào phiên bản nhân của chúng ta . Khái niệm build system ở đây có thể hiểu đơn
giản đó chính là một hệ thống giúp quản lí, biên dịch source code . Build system
mà nhân Linux sử dụng có tên là Kbuild. Bao gồm 2 thành phần cơ bản:
 Makefile: Chứa các quy tắc biên dịch. Quản lí source code.
 Kconfig: Đóng vai trò cấu hình các options trên menuconfig
Chúng ta sẽ cùng nhau cải tiến mã nguồn nhân bằng cách built-in module
“hello” driver vào trong nhân Linux của rpi3b+.
a) Bước 1:
Di chuyển vào thư mục source code linux kernel . Tạo một thư mục bên trong
thư mục drivers/, chứa toàn bộ trình điều khiển của chúng ta. Ở đây chúng ta
tạo ra folder hello/.
Trong folder hello/ ta tạo ra các file như sau:

Hình 2.4.1: hello module.

Chúng ta có thể bỏ qua file Kbuild ở đây.


b) Bước 2
Nội dung file helloworld.c được viết như sau:

Hình 2.4.2: helloworld.c.

Đâ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:

Hình 2.4.4: Kconfig.


Giải thích các lệnh.
 menu/endmenu: Tạo một menu cấu hình trong menuconfig.
 config HELLO_WORLD: Nều module này được chọn trong menuconfig:
Hệ thống sẽ tự thêm tiền tố CONFIG_ vào trước HELLO_WORLD. Khi đó
ta thu được biến cấu hình tương ứng ở phần c) là
CONFIG_HELLO_WORLD.
 bool “hello kernel”: Tạo một lựa chọn trong menu “Hello World Kernel
Modules”
 depends on ARM: menu này chỉ được hiện thị nếu kiến trúc được set thành
ARCH=arm.
 default y if ARM: mặc định CONFIG_HELLO_WORLD=y nếu kiến là
ARM.
 help: Chứa các thông tin hỗ trợ người dùng.
e) Bước 5
Trong thư mục drivers/ sẽ có 1 file Makefile và một file Kconfig . Chúng ta phải
thêm đường dẫn vào 2 file đó để build system có thể nhìn thấy Makefile và
Kconfig trong moudle hello của chúng ta.

Trong file Kconfig thêm dòng như sau:


 source “drivers/hello/Kconfig”
Trong file Makefile thêm dòng như sau:
 obj-y += hello/

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

Hình 2.4.5: menuconfig ARCH=arm.


Hình 2.4.6: menu “Hello World Kernel Modules”.

Hình 2.4.7: Cấu hình “hello kernel”


g) Bước 7
Tổng kết.
Mỗi một module của thiết bị sẽ tương ứng với một menu nhỏ trong
menuconfig. Có thể thấy rằng menuconfig là một công cụ tuyệt vời, cung cấp một
cái nhìn trực quan tới các module hệ thống thông qua giao diện đồ họa . Chính vì lí
do này mà menuconfig trở thành một công cụ ưa thích không chỉ với tôi mà còn là
với đa số các lập trình viên khác sử dụng để lược bỏ, tối ưu hay cải tiến cho nhân
Linux của mình.
Ban đầu sau khi gõ lệnh:
 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-
bcm2709_defconfig
build system sẽ tự động thêm tiền tố CONFIG_ vào tất cả các biến cấu hình được
liệt kê trong file bcm2709_defconfig và lưu vào file .config. Có thể tìm file này
thông qua câu lệnh:
 find . -name bcm2709_defconfig
Tiếp theo, khi chúng ta muốn sửa đổi các module mặc định được build
(Được liệt kê trong file “.config”) bằng cách sử dụng menuconfig:
 make ARCH=arm menuconfig
menuconfig sẽ tự động đọc toàn bộ cấu hình của file “ .config” với các thông tin Y
(Yes), M (module), N (No). Nếu bạn muốn loại bỏ hay thêm bất kì module nào thì
sau khi “save”, menuconfig sẽ ghi lại toàn bộ thông tin thay đổi xuống file
“.config”

Một điểm lưu ý. Nếu bạn sử dụng lệnh:


 make ARCH=x86 menuconfig
thì bạn sẽ không thể nào lựa chọn được menu “Hello World Kernel Modules” mà
chúng ta vừa tạo ở phía trên. Sở dĩ như vậy vì ta set depends on ARM
trong file Kconfig. Tức là chỉ khi nào kiến trúc được set thành kiến trúc ARM thì
chúng ta mới có quyền lựa chọn nó.
Bài tập.
Bài tập: Hãy viết một kernel module in ra nội dung “Hello World” khi insmod
vào trong kernel. Module này phải được hiển thị ra menuconfig và được build dưới
dạng built-in.

2.5. Biên dịch và cài đặt nhân.


Sau khi thực hiện cấu hình và sửa đổi nhân Linux . Công việc tiếp theo đó
chính là biên dịch và cài đặt lên thiết bị rpi3b+.
a) Local building.
Việc build kernel trên Pi sẽ tốn rất nhiều thời gian tùy thuộc vào thiết bị
Raspberry Pi mà bạn sử dụng là version bao nhiêu.
Chúng ta có hai câu lệnh chính để thực hiện quá trình biên dịch, đó là:
 make -j4 zImage modules dtbs
 sudo make modules_install

Các thành phần chúng ta thu được đó chính là:


 kernel image: Hình ảnh nhị phân của kernel.
 modules: Các modules của nhân.
 device tree blobs: File nhị phân của device tree, mô tả các phần
cứng có trong thiết bị rpi3b+.
sau khi thu được các thành phần cần thiết . Chúng ta sẽ tiến hành việc cài
đặt. Công việc này khá dễ dành khi thực hiện Local building vì bản thân
các files đã nằm trên rpi3b+ rồi. Việc còn lại của chúng ta là thực thi lần
lượt các lệnh:
 sudo cp arch/arm/boot/dts/*.dtb /boot/
 sudo cp arch/arm/boot/dts/overlays/*.dtb* /boot/overlays/
 sudo cp arch/arm/boot/dts/overlays/README /boot/overlays/
 sudo cp arch/arm/boot/zImage /boot/$KERNEL.img
Xin lưu ý rằng. Mọi thao tác trên đều được thực hiện ở thư mục chứa mã
nguồn nhân Linux của rpi3b+. Sau khi thực hiện việc cài đặt, reboot lại thiết bị
và tận hưởng thành quả.
b) Cross-building.
Sử dụng câu lệnh sau đây:
 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage
modules dtbs
các thành phần thu được tương tự như Local building . Việc cài đặt thông
qua Cross-building phức tạp hơn Local-building vì các files build ra đều
nằm trên host.

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:

Hình 2.5.1: lsblk


Với sdb1 là phân vùng FAT (boot – phân vùng khởi động) và sdb2 là phân
vùng hệ thống filesystem ext4.
Chúng ta sẽ tiến hành mount các thư mục này.
 mkdir mnt
 mkdir mnt/fat32
 mkdir mnt/ext4
 sudo mount /dev/sdb6 mnt/fat32
 sudo mount /dev/sdb7 mnt/ext4
Hình 2.5.2: mount SD card partion.
Tiếp theo, cài đặt các modules.
 sudo env PATH=$PATH make ARCH=arm CROSS_COMPILE=arm-
linux-nueabihf- INSTALL_MOD_PATH=mnt/ext4 modules_install

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.

2.6. Cài đặt module khởi động (Bootloader).


Để biên dịch bộ nạp khởi động U-boot cho Raspberry Pi, trước tiên chúng ta
cần phải cài đặt trình biên dịch chéo GCC và trình biên dịch cây thiết bị.
Sử dụng hai câu lệnh sau:
 apt-get install gcc-arm-linux-gnueabi
 apt-get install device-tree-compiler
Sau khi cài đặt xong trình biên dịch chéo chúng ta sẽ tiếp tục download mã
nguồn U-boot.
 wget ftp://ftp.denx.de/pub/u-boot/u-boot-2018.09.tar.bz2
 tar -xjf u-boot-2018.09.tar.bz2
 cd u-boot-2018.09
Tiến hành Cross-Compiling.
Trước khi cross-compiling U-boot cho vi xử lí kiến trúc ARM. Ta cần phải
thực hiện clean.
 make CROSS_COMPILE=arm-linux-gnueabi- distclean
Tiếp theo, đặt cấu hình mặc định. Điều này thay đổi phụ thuộc vào biến thể
Raspberry mà bạn sử dụng.
Raspberry PI Variant Processor Configuration File
Raspberry PI Model A BCM2835 rpi_defconfig
Raspberry PI Model A+ BCM2835 rpi_defconfig
Raspberry PI Model B+ BCM2835 rpi_defconfig
Raspberry PI Compute Module BCM2835 rpi_defconfig
Raspberry PI Zero BCM2835 rpi_defconfig
Raspberry PI Zero W BCM2835 rpi_0_w_defconfig
Raspberry PI 2 Model B BCM2836 rpi_2_defconfig
Raspberry PI 3 Model B BCM2837 rpi_3_defconfig
Raspberry PI 3 Model B+ BCM2837B0 rpi_3_defconfig

Ở đây chúng ta sử dụng rpi3b+. Chạy lệnh:


 make CROSS_COMPILE=arm-linux-gnueabi- rpi_defconfig
Và cuối cùng thực hiện biên dịch U-boot.
 make CROSS_COMPILE=arm-linux-gnueabi- u-boot.bin
Sau khi biên dịch xong chúng ta sẽ thu được file u-boot.bin có thể sẵn sàng
sao chép vào SD card.
U-boot có thể được khởi động được ở phần vùng FAT16 hoặc FAT32 nên
đầu tiên chúng ta sẽ kiểm tra phân vùng của SD card.
 sudo fdisk /dev/sdx
Cần phải thật sự lưu ý rằng sdx ở đây tôi lấy là ví dụ. Bạn nên kiểm tra lại
xem file system của SD card là gì. Bằng cách sử dụng lệnh “lsblk” trước và sau
khi cắm SD card vào máy và theo dõi sự thay đổi.
Sau khi sử dụng lệnh “fdisk” ở trên sẽ có một menu được hiển thị. Nhập p
để đảm bảo chọn đúng ổ đĩa.
Nếu đã chọn đúng ổ đĩa. Xóa tất cả các phân vùng hiện có. Nhập n để
thêm một partion mới.
Cuối cùng ghi tất cả các partion vào ổ đĩa và thoát bằng lệnh w.
Bây giờ thực hiện format thẻ SD.
 sudo mkfs.vfat /dev/sdb1
Thực hiện mount file system, lúc này bạn đã có thể thêm các file cần thiết.
 sudo mount -t vfat /dev/sdb1 /media/card
Copy các file dưới đây vào thẻ SD.
 bootcode.bin
 start.elf
 config.txt
 cmdline.txt
 u-boot.bin
Bạn có thể lấy bootcode.bin và start.elf từ kho lưu trữ Raspberry Pi:
 wget https://github.com/raspberrypi/firmware/raw/master/boot/bootcode.bin
 wget https://github.com/raspberrypi/firmware/raw/master/boot/start.elf
Tạo file config.txt và thêm dòng “kernel=u-boot.bin”.
Sau khi hoàn tất cấu hình Thẻ SD, hãy umount và lắp nó vào Raspberry.
 sudo umount /media/card

2.8. Các kỹ thuật lập trình Linux.


2.8.1. Tiến trình
Chương trình (program) là một file chạy được (executable) chứa các chỉ lệnh
(instruction) được viết để thực thi một công việc nào đó trên máy tính. Ví dụ dễ
hiểu là một chương trình trên Windows là helloworld.exe mà chắc những người
lần đầu tiếp cận lập trình đều biết. Một chương trình được viết bởi lập trình viên có
thể bằng nhiều ngôn ngữ khác nhau, và được lưu ở trên ổ cứng của máy tính. Khi
được chạy, máy tính sẽ tải chương trình đó vào bộ nhớ RAM để thực thi, vì vậy
chương trình cũng có thể được gọi là một thực thể bị động (passive entity).

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”.

Hình 2.8.1.1: ps aux


Chúng ta có thể thấy cột thứ 2 trong hình đó chính là process id (pid) của các
tiến trình trong hệ thống. Ví dụ, chương trình [watchdog/1] có thể bị tác động
thông qua pid như tắt chương trình đi bằng lệnh “kill 14”.
Một tiến trình cũng trải qua các quá trình như con người: Nó được sinh ra,
nó có thể có một cuộc đời ít ý nghĩa hoặc nhiều ý nghĩa, nó có thể sinh ra một hoặc
nhiều tiến trình con, và thậm chí, nó có có thể chết đi . Điều khác biệt nhỏ duy nhất
là: tiến trình không có giới tính. Mỗi tiến trình chỉ có một tiến trình cha duy nhất.
a) Tạo một tiến trình mới.
Trong rất nhiều ứng dụng, việc tạo multiple processes có thể rất hữu dụng . Ví
dụ, một tiến trình network server lắng nghe và phục vụ nhiều clients khác nhau, nó
có thể tạo ra các tiến trình con làm nhiệm vụ xử lý và phục vụ cho từng client, nhờ
đó nó rảnh tay và tiếp tục lắng nghe từ các client khác.
Tiến trình con được tạo ra bằng system call fork(), có prototype như sau:

Hình 2.8.1.2: fork()


System call fork() làm việc bằng cách tạo ra 1 tiến trình mới với PID mới, và
nhân bản dữ liệu từ tiến trình cha sang tiến trình con (các segment stack, data và
heap), riêng text segment không cần sao chép mà được sử dụng chung
(sharable) bởi cả 2 tiến trình.

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:

Hình 2.8.1.4: Kết quả khi dùng sleep() với fork().


b) Quản lý tiến trình.
Trong nhiều ứng dụng Linux, một tiến trình cha cần biết khi nào một trong các
tiến trình con của nó thay đổi trạng thái, khi nào kết thúc hoặc bị dừng bởi một
signal nào đó. Ở phần này chúng ta sẽ tìm hiều hai kĩ thuật được sử dụng để giám
sát các tiến trình con: system call wait() (và các biến thể của nó) và việc sử dụng
tín hiệu SIGCHLD.
Prototype của wait như sau:
Hình 2.8.2.1: System call wait().

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:

Hình 2.8.2.3: System call waitpid().


Giá trị trả về cũng như biến nguyên nhân kết thúc “status” của waitpid() giống
với wait(). Trong đó đối số “pid” xác định tiến trình con mà chúng ta muốn theo
dõi, với quy ước 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ì.

d) Tiến trình orphan và tiến trình zombie


Như đã nói ở trên, tiến trình cha có thể gọi system call wait() để xác nhận trạng
thái kết thúc của tiến trình con. Tuy nhiên, có nhiều trường hợp tiến trình cha bị
kết thúc trước tiến trình con, điều này có thể do tiến trình cha bị crash hoặc lập
trình viên không kết thúc tiến trình con trước khi kết thúc tiến trình cha. Hoặc có
trường hợp tiến trình con kết thúc trước khi tiến trình cha gọi wait(). Hai trường
hợp này sẽ giới thiệu thuật ngữ trạng thái mới của tiến trình con là orphan và
zombie,

 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.

2.8.2. Cơ chế liên lạc tiến trình.


IPC là một cơ chế cho phép một tiến trình giao tiếp với một tiến trình khác,
thông thường là trong cùng một hệ thống.

Giao tiếp có thể được phân thành 2 dạng:


 Giữa những tiến trình có liên quan với nhau – được khởi tạo từ một
tiến trình chung.
 Giữa nhưng tiếng trình riêng biệt, không liên quan với nhau.
Sau đây là một số phương pháp phổ biến để thực hiện IPC:

 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:

 Các bước thiết lập một socket phía client gồm:


 Tạo một socket bằng hàm socket()
 Kết nối socket đến địa chỉ của server bằng hàm connect()
 Gửi và nhận dữ liệu: Có một số cách khác nhau, đơn giản nhất
là sử dụng các hàm read() và write()
 Đóng kết nối bằng hàm close()
Các bước thiết lập một socket phía server gồm:

 Tạo một socket bằng hàm socket()


 Gắn (bind) socket đến địa chỉ của server sử dụng
hàm bind(). Đối với server trên internet địa chỉ bao gồm địa chỉ
ip của máy host + số hiệu cổng dịch vụ (port number)
 Lắng nghe (listen) các kết nối đến từ clients sử dụng
hàm listen()
 Chấp nhận các kết nối sử dụng hàm accept(). Hàm này sẽ dừng
(block) cho đến khi nhận được một client kết nối đến.
 Gửi và nhận dữ liệu với client (hàm read(), write())
 Đóng kết nối bằng hàm close()
Souce code client <client.c>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h> // Chứa cấu trúc cần thiết cho socket.
#include <netinet/in.h> // Thư viện chứa các hằng số, cấu trúc khi sử dụng địa chỉ trên
internet
#include <arpa/inet.h>
#include <unistd.h>

#define BUFF_SIZE 256


#define handle_error(msg) \
do { perror(msg); exit(EXIT_FAILURE); } while (0)

/* Chức năng chat */


void chat_func(int server_fd)
{
int numb_write, numb_read;
char recvbuff[BUFF_SIZE];
char sendbuff[BUFF_SIZE];

while (1) {
memset(sendbuff, '0', BUFF_SIZE);
memset(recvbuff, '0', BUFF_SIZE);

printf("Please enter the message : ");


fgets(sendbuff, BUFF_SIZE, stdin);

/* Gửi thông điệp tới server bằng hàm write */


numb_write = write(server_fd, sendbuff, sizeof(sendbuff));
if (numb_write == -1)
handle_error("write()");

if (strncmp("exit", sendbuff, 4) == 0) {
printf("Client exit ...\n");
break;
}

/* Nhận thông điệp từ server bằng hàm read */


numb_read = read(server_fd, recvbuff, sizeof(recvbuff));
if (numb_read < 0)
handle_error("read()");

if (strncmp("exit", recvbuff, 4) == 0) {
printf("Server exit ...\n");
break;
}

printf("\nMessage from Server: %s\n",recvbuff);


}

close(server_fd); /*close*/
}

int main(int argc, char *argv[])


{
int portno;
int server_fd;
struct sockaddr_in serv_addr;

memset(&serv_addr, '0',sizeof(serv_addr));

/* Đọc portnumber từ command line */


if (argc < 3) {
printf("command : ./client <server address> <port number>\n");
exit(1);
}
portno = atoi(argv[2]);

/* Khởi tạo địa chỉ server */


serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(portno);
if (inet_pton(AF_INET, argv[1], &serv_addr.sin_addr) == -1)
handle_error("inet_pton()");

/* Tạo socket */
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1)
handle_error("socket()");

/* Kết nối tới server*/


if (connect(server_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
handle_error("connect()");

chat_func(server_fd);

return 0;
}

Source code server <server.c>


#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/socket.h> // Chứa cấu trúc cần thiết cho socket.
#include <netinet/in.h> // Thư viện chứa các hằng số, cấu trúc khi sử dụng địa chỉ trên
internet
#include <arpa/inet.h>
#include <unistd.h>

#define LISTEN_BACKLOG 50
#define BUFF_SIZE 256
#define handle_error(msg) \
do { perror(msg); exit(EXIT_FAILURE); } while (0)

/* Chức năng chat */


void chat_func(int new_socket_fd)
{
int numb_read, numb_write;
char sendbuff[BUFF_SIZE];
char recvbuff[BUFF_SIZE];

while (1) {
memset(sendbuff, '0', BUFF_SIZE);
memset(recvbuff, '0', BUFF_SIZE);

/* Đọc dữ liệu từ socket */


/* Hàm read sẽ block cho đến khi đọc được dữ liệu */
numb_read = read(new_socket_fd, recvbuff, BUFF_SIZE);
if(numb_read == -1)
handle_error("read()");

if (strncmp("exit", recvbuff, 4) == 0) {
system("clear");
break;
}

printf("\nMessage from Client: %s\n", recvbuff);

/* Nhập phản hồi từ bàn phím */


printf("Please respond the message : ");
fgets(sendbuff, BUFF_SIZE, stdin);

/* Ghi dữ liệu tới client thông qua hàm write */


numb_write = write(new_socket_fd, sendbuff, sizeof(sendbuff));
if (numb_write == -1)
handle_error("write()");

if (strncmp("exit", sendbuff, 4) == 0) {
system("clear");
break;
}

sleep(1);
}
close(new_socket_fd);
}

int main(int argc, char *argv[])


{
int port_no, len, opt;
int server_fd, new_socket_fd;
struct sockaddr_in serv_addr, client_addr;

/* Đọc portnumber trên command line */


if (argc < 2) {
printf("No port provided\ncommand: ./server <port number>\n");
exit(EXIT_FAILURE);
} else
port_no = atoi(argv[1]);

memset(&serv_addr, 0, sizeof(struct sockaddr_in));


memset(&client_addr, 0, sizeof(struct sockaddr_in));

/* 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));

/* Ngăn lỗi : “address already in use” */


if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT,
&opt, sizeof(opt)))
handle_error("setsockopt()");

/* Khởi tạo địa chỉ cho server */


serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(port_no);
serv_addr.sin_addr.s_addr = inet_addr("192.168.88.128"); //INADDR_ANY

/* Gắn socket với địa chỉ server */


if (bind(server_fd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) == -1)
handle_error("bind()");

/* Nghe tối đa 5 kết nối trong hàng đợi */


if (listen(server_fd, LISTEN_BACKLOG) == -1)
handle_error("listen()");

/* Dùng để lấy thông tin client */


len = sizeof(client_addr);

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];

inet_ntop(client_addr.sin_family, (struct sockaddr*)&client_addr, temp,


sizeof(temp));
printf("Server : got connection from %s\n", temp);

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

2.8.4. Lập trình đa luồng.

Để tạo ra một thread mới, ta sử dụng hàm pthread_create() với prototype

như sau:
Hình 2.8.4.1: pthread_create().

Hàm pthread_create() tạo ra một thread mới trong tiến trình, entry point của


thread mới này là hàm “start()” với đối số là “arg” (start(arg)). Main thread của
tiến trình tiếp tục thực thi với các câu lệnh sau hàm pthread_create() đó. Đối số
“arg” được truyền vào có kiểu void, nghĩa là ta có thể truyền bất kỳ kiểu dữ liệu
nào vào hàm start(), hoặc truyền vào con trỏ NULL nếu hàm start() không cần đối
số. Nếu muốn truyền nhiều đối số vào hàm start(), ta có thể khai báo đối số “arg”
dưới dạng một con trỏ trỏ đến một cấu trúc với các đối số là các trường riêng biệt.

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 …

c) Kết thúc thread.


Một thread đang thực thi có thể được kết thúc bằng một trong các cách sau:

 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

 Thread bị hủy bỏ bằng hàm pthread_cancel()

 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 đó.

Hàm pthread_exit() có prototype như sau:

Hình 2.8.4.3: pthread_exit().

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:

Hình 2.8.4.4: demo1 multithread().

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:

 gcc -pthread -o pthread pthread.c

Bây giờ chạy chương trình, bạn sẽ thấy output của chương trình như sau:

Hình 2.8.4.5: ./pthread .

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:

Hình 2.8.4.4: demo2 multithread().

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

You might also like